Merge pull request #7282 from feeeei/feat/affiliate-offline-withdrawal

feat(affiliate): 提取记录支持登记线下提现
This commit is contained in:
Wesley Liddick
2026-09-23 11:20:32 +08:00
committed by GitHub
20 changed files with 2084 additions and 36 deletions
@@ -201,6 +201,42 @@ func (h *AffiliateHandler) GetUserOverview(c *gin.Context) {
response.Success(c, overview) response.Success(c, overview)
} }
// WithdrawQuotaRequest 是登记线下提现的请求体。Amount 是已在站外打款给
// 用户的金额,由 AffiliateService 校验。
type WithdrawQuotaRequest struct {
Amount float64 `json:"amount"`
}
// WithdrawQuota records an offline withdrawal paid outside the site and
// deducts it from the user's available affiliate quota. The required
// Idempotency-Key header identifies one registration: a repeated request with
// the same key deducts nothing and returns the first result with
// X-Idempotency-Replayed: true.
// POST /api/v1/admin/affiliates/users/:user_id/withdraw
func (h *AffiliateHandler) WithdrawQuota(c *gin.Context) {
userID, err := strconv.ParseInt(c.Param("user_id"), 10, 64)
if err != nil || userID <= 0 {
response.BadRequest(c, "Invalid user_id")
return
}
var req WithdrawQuotaRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
result, err := h.affiliateService.AdminWithdrawQuota(c.Request.Context(), userID, req.Amount, c.GetHeader("Idempotency-Key"))
if err != nil {
response.ErrorFrom(c, err)
return
}
if result != nil && result.Replayed {
c.Header("X-Idempotency-Replayed", "true")
}
response.Success(c, result)
}
// ListInviteRecords returns all inviter-invitee relationships. // ListInviteRecords returns all inviter-invitee relationships.
// GET /api/v1/admin/affiliates/invites // GET /api/v1/admin/affiliates/invites
func (h *AffiliateHandler) ListInviteRecords(c *gin.Context) { func (h *AffiliateHandler) ListInviteRecords(c *gin.Context) {
@@ -214,7 +250,8 @@ func (h *AffiliateHandler) ListInviteRecords(c *gin.Context) {
response.Paginated(c, items, total, filter.Page, filter.PageSize) response.Paginated(c, items, total, filter.Page, filter.PageSize)
} }
// ListRebateRecords returns all order-level affiliate rebate records. // ListRebateRecords returns every affiliate rebate accrual, with order details
// when the rebate came from a payment order.
// GET /api/v1/admin/affiliates/rebates // GET /api/v1/admin/affiliates/rebates
func (h *AffiliateHandler) ListRebateRecords(c *gin.Context) { func (h *AffiliateHandler) ListRebateRecords(c *gin.Context) {
page, pageSize := response.ParsePagination(c) page, pageSize := response.ParsePagination(c)
@@ -227,7 +264,8 @@ func (h *AffiliateHandler) ListRebateRecords(c *gin.Context) {
response.Paginated(c, items, total, filter.Page, filter.PageSize) response.Paginated(c, items, total, filter.Page, filter.PageSize)
} }
// ListTransferRecords returns all affiliate quota-to-balance transfer records. // ListTransferRecords returns affiliate quota outflows: user transfers into
// balance and admin-recorded offline withdrawals.
// GET /api/v1/admin/affiliates/transfers // GET /api/v1/admin/affiliates/transfers
func (h *AffiliateHandler) ListTransferRecords(c *gin.Context) { func (h *AffiliateHandler) ListTransferRecords(c *gin.Context) {
page, pageSize := response.ParsePagination(c) page, pageSize := response.ParsePagination(c)
@@ -0,0 +1,172 @@
package admin
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
const testWithdrawIdempotencyKey = "affiliate-withdraw-1-5d0c7a2e"
type withdrawQuotaCall struct {
userID int64
amount float64
operationID string
}
type withdrawQuotaRepoStub struct {
service.AffiliateRepository
calls []withdrawQuotaCall
result *service.AffiliateWithdrawResult
err error
}
func (s *withdrawQuotaRepoStub) WithdrawQuota(_ context.Context, userID int64, amount float64, operationID string) (*service.AffiliateWithdrawResult, error) {
s.calls = append(s.calls, withdrawQuotaCall{userID: userID, amount: amount, operationID: operationID})
return s.result, s.err
}
type withdrawQuotaResponse struct {
Code int `json:"code"`
Reason string `json:"reason"`
Data json.RawMessage `json:"data"`
}
func performWithdrawQuota(t *testing.T, repo *withdrawQuotaRepoStub, userID, idempotencyKey, body string) (*httptest.ResponseRecorder, withdrawQuotaResponse) {
t.Helper()
gin.SetMode(gin.TestMode)
router := gin.New()
handler := NewAffiliateHandler(service.NewAffiliateService(repo, nil, nil, nil), nil)
router.POST("/api/v1/admin/affiliates/users/:user_id/withdraw", handler.WithdrawQuota)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/affiliates/users/"+userID+"/withdraw", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
var resp withdrawQuotaResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
return rec, resp
}
// TestAffiliateHandlerWithdrawQuota_Success 验证登记线下提现把路径中的用户、
// 舍入后的金额与幂等键派生的 operation_id 交给仓储,并返回登记结果。
func TestAffiliateHandlerWithdrawQuota_Success(t *testing.T) {
repo := &withdrawQuotaRepoStub{result: &service.AffiliateWithdrawResult{LedgerID: 9, UserID: 42, Amount: 12.34567891}}
rec, resp := performWithdrawQuota(t, repo, "42", testWithdrawIdempotencyKey, `{"amount":12.3456789149}`)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, 0, resp.Code)
var data service.AffiliateWithdrawResult
require.NoError(t, json.Unmarshal(resp.Data, &data))
require.Equal(t, int64(9), data.LedgerID)
require.Empty(t, rec.Header().Get("X-Idempotency-Replayed"))
require.Len(t, repo.calls, 1)
require.Equal(t, int64(42), repo.calls[0].userID)
require.Equal(t, 12.34567891, repo.calls[0].amount)
require.Regexp(t, `^[0-9a-f]{64}$`, repo.calls[0].operationID)
}
// TestAffiliateHandlerWithdrawQuota_SameKeyMapsToSameOperation 验证重试时带同一个
// Idempotency-Key 的请求落到同一个 operation_id,换一个键则是另一笔登记。
func TestAffiliateHandlerWithdrawQuota_SameKeyMapsToSameOperation(t *testing.T) {
repo := &withdrawQuotaRepoStub{result: &service.AffiliateWithdrawResult{LedgerID: 9, UserID: 42, Amount: 10}}
for _, key := range []string{testWithdrawIdempotencyKey, testWithdrawIdempotencyKey, testWithdrawIdempotencyKey + "-next"} {
rec, _ := performWithdrawQuota(t, repo, "42", key, `{"amount":10}`)
require.Equal(t, http.StatusOK, rec.Code)
}
require.Len(t, repo.calls, 3)
require.Equal(t, repo.calls[0].operationID, repo.calls[1].operationID)
require.NotEqual(t, repo.calls[0].operationID, repo.calls[2].operationID)
}
// TestAffiliateHandlerWithdrawQuota_ReplayReturnsFirstResult 验证命中已有登记时
// 原样返回首次登记的结果,并以 X-Idempotency-Replayed 标明是重放。
func TestAffiliateHandlerWithdrawQuota_ReplayReturnsFirstResult(t *testing.T) {
repo := &withdrawQuotaRepoStub{result: &service.AffiliateWithdrawResult{
LedgerID: 9,
UserID: 42,
Amount: 10,
AvailableQuotaAfter: 90,
HistoryQuotaAfter: 100,
Replayed: true,
}}
rec, resp := performWithdrawQuota(t, repo, "42", testWithdrawIdempotencyKey, `{"amount":10}`)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "true", rec.Header().Get("X-Idempotency-Replayed"))
require.JSONEq(t, `{
"ledger_id": 9,
"user_id": 42,
"amount": 10,
"available_quota_after": 90,
"frozen_quota_after": 0,
"history_quota_after": 100
}`, string(resp.Data))
}
// TestAffiliateHandlerWithdrawQuota_RejectsBadRequests 验证非法用户 ID、非法 JSON、
// 缺少或非法的幂等键与非法金额在进入仓储前以 400 拒绝。
func TestAffiliateHandlerWithdrawQuota_RejectsBadRequests(t *testing.T) {
cases := []struct {
name string
userID string
key string
body string
wantReason string
}{
{name: "non-numeric user id", userID: "abc", key: testWithdrawIdempotencyKey, body: `{"amount":1}`},
{name: "non-positive user id", userID: "0", key: testWithdrawIdempotencyKey, body: `{"amount":1}`},
{name: "malformed json", userID: "42", key: testWithdrawIdempotencyKey, body: `{"amount":`},
{name: "missing idempotency key", userID: "42", body: `{"amount":1}`, wantReason: "IDEMPOTENCY_KEY_REQUIRED"},
{name: "invalid idempotency key", userID: "42", key: "has space", body: `{"amount":1}`, wantReason: "IDEMPOTENCY_KEY_INVALID"},
{name: "zero amount", userID: "42", key: testWithdrawIdempotencyKey, body: `{"amount":0}`, wantReason: "AFFILIATE_WITHDRAW_AMOUNT_INVALID"},
{name: "missing amount", userID: "42", key: testWithdrawIdempotencyKey, body: `{}`, wantReason: "AFFILIATE_WITHDRAW_AMOUNT_INVALID"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
repo := &withdrawQuotaRepoStub{}
rec, resp := performWithdrawQuota(t, repo, tc.userID, tc.key, tc.body)
require.Equal(t, http.StatusBadRequest, rec.Code)
require.Equal(t, tc.wantReason, resp.Reason)
require.Empty(t, repo.calls)
})
}
}
// TestAffiliateHandlerWithdrawQuota_RepositoryRejections 验证额度不足以 400、
// 同一幂等键换了用户或金额以 409 返回,前端据错误码提示。
func TestAffiliateHandlerWithdrawQuota_RepositoryRejections(t *testing.T) {
cases := []struct {
err error
wantStatus int
wantReason string
}{
{err: service.ErrAffiliateQuotaInsufficient, wantStatus: http.StatusBadRequest, wantReason: "AFFILIATE_QUOTA_INSUFFICIENT"},
{err: service.ErrIdempotencyKeyConflict, wantStatus: http.StatusConflict, wantReason: "IDEMPOTENCY_KEY_CONFLICT"},
}
for _, tc := range cases {
t.Run(tc.wantReason, func(t *testing.T) {
repo := &withdrawQuotaRepoStub{err: tc.err}
rec, resp := performWithdrawQuota(t, repo, "42", testWithdrawIdempotencyKey, `{"amount":5}`)
require.Equal(t, tc.wantStatus, rec.Code)
require.Equal(t, tc.wantReason, resp.Reason)
require.Empty(t, rec.Header().Get("X-Idempotency-Replayed"))
require.Len(t, repo.calls, 1)
})
}
}
@@ -407,6 +407,10 @@ func (r *oauthEmailAffiliateRepoStub) TransferQuotaToBalance(context.Context, in
panic("unexpected TransferQuotaToBalance call") panic("unexpected TransferQuotaToBalance call")
} }
func (r *oauthEmailAffiliateRepoStub) WithdrawQuota(context.Context, int64, float64, string) (*service.AffiliateWithdrawResult, error) {
panic("unexpected WithdrawQuota call")
}
func (r *oauthEmailAffiliateRepoStub) ListInvitees(context.Context, int64, int) ([]service.AffiliateInvitee, error) { func (r *oauthEmailAffiliateRepoStub) ListInvitees(context.Context, int64, int) ([]service.AffiliateInvitee, error) {
panic("unexpected ListInvitees call") panic("unexpected ListInvitees call")
} }
+196 -12
View File
@@ -341,6 +341,171 @@ VALUES ($1, 'transfer', $2, NULL, $3, $4, $5, $6, NOW(), NOW())`,
return transferred, newBalance, nil return transferred, newBalance, nil
} }
// WithdrawQuota 登记一笔线下提现。operationID 标识一次登记:同一事务内先以
// operation_id 唯一约束写入占位流水,同标识的流水已存在时不重复扣减,用户与金额
// 一致则返回该流水记录的结果(Replayed=true),不一致返回
// ErrIdempotencyKeyConflict。占位成功后先解冻已到期的冻结额度,再以
// aff_quota >= amount 为条件原子扣减并补写额度快照。可提取额度不足或用户没有
// 返利档案时返回 ErrAffiliateQuotaInsufficient,占位随事务回滚,不占用该标识。
func (r *affiliateRepository) WithdrawQuota(ctx context.Context, userID int64, amount float64, operationID string) (*service.AffiliateWithdrawResult, error) {
if userID <= 0 {
return nil, service.ErrUserNotFound
}
if amount <= 0 {
return nil, service.ErrAffiliateWithdrawAmountInvalid
}
if operationID == "" {
return nil, service.ErrIdempotencyKeyRequired
}
var result *service.AffiliateWithdrawResult
err := r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error {
ledgerID, claimed, err := claimAffiliateWithdrawLedger(txCtx, txClient, userID, amount, operationID)
if err != nil {
return err
}
if !claimed {
result, err = findAffiliateWithdrawByOperation(txCtx, txClient, userID, amount, operationID)
return err
}
if _, err := thawFrozenQuotaTx(txCtx, txClient, userID); err != nil {
return fmt.Errorf("thaw before withdraw: %w", err)
}
res, err := txClient.ExecContext(txCtx, `
UPDATE user_affiliates
SET aff_quota = aff_quota - $1,
updated_at = NOW()
WHERE user_id = $2
AND aff_quota >= $1`, amount, userID)
if err != nil {
return fmt.Errorf("deduct affiliate quota: %w", err)
}
affected, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("deduct affiliate quota: %w", err)
}
if affected == 0 {
return service.ErrAffiliateQuotaInsufficient
}
snapshot, err := queryAffiliateTransferSnapshot(txCtx, txClient, userID)
if err != nil {
return err
}
if _, err := txClient.ExecContext(txCtx, `
UPDATE user_affiliate_ledger
SET balance_after = $1,
aff_quota_after = $2,
aff_frozen_quota_after = $3,
aff_history_quota_after = $4,
updated_at = NOW()
WHERE id = $5`,
snapshot.BalanceAfter,
snapshot.AvailableQuotaAfter,
snapshot.FrozenQuotaAfter,
snapshot.HistoryQuotaAfter,
ledgerID,
); err != nil {
return fmt.Errorf("record affiliate withdraw snapshot: %w", err)
}
result = &service.AffiliateWithdrawResult{
LedgerID: ledgerID,
UserID: userID,
Amount: amount,
AvailableQuotaAfter: snapshot.AvailableQuotaAfter,
FrozenQuotaAfter: snapshot.FrozenQuotaAfter,
HistoryQuotaAfter: snapshot.HistoryQuotaAfter,
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
// claimAffiliateWithdrawLedger 为有返利档案的用户写入一条带 operation_id 的
// withdraw 占位流水。同标识的流水已存在或用户没有返利档案时 claimed 为 false。
// 同标识的并发事务在唯一索引上等待先写入者结束:先写入者提交后返回 claimed=false,
// 回滚后本事务继续写入。
func claimAffiliateWithdrawLedger(ctx context.Context, client affiliateQueryExecer, userID int64, amount float64, operationID string) (int64, bool, error) {
rows, err := client.QueryContext(ctx, `
INSERT INTO user_affiliate_ledger (user_id, action, amount, operation_id, created_at, updated_at)
SELECT ua.user_id, 'withdraw', $2, $3, NOW(), NOW()
FROM user_affiliates ua
WHERE ua.user_id = $1
ON CONFLICT (operation_id) WHERE operation_id IS NOT NULL DO NOTHING
RETURNING id`, userID, amount, operationID)
if err != nil {
return 0, false, fmt.Errorf("claim affiliate withdraw ledger: %w", err)
}
defer func() { _ = rows.Close() }()
if !rows.Next() {
if err := rows.Err(); err != nil {
return 0, false, fmt.Errorf("claim affiliate withdraw ledger: %w", err)
}
return 0, false, nil
}
var ledgerID int64
if err := rows.Scan(&ledgerID); err != nil {
return 0, false, err
}
return ledgerID, true, rows.Close()
}
// findAffiliateWithdrawByOperation 读取 operation_id 已对应的线下提现流水,
// 用户与金额一致时返回该流水记录的登记结果(Replayed=true),不一致返回
// ErrIdempotencyKeyConflict;没有对应流水说明用户没有返利档案,返回
// ErrAffiliateQuotaInsufficient。
func findAffiliateWithdrawByOperation(ctx context.Context, client affiliateQueryExecer, userID int64, amount float64, operationID string) (*service.AffiliateWithdrawResult, error) {
rows, err := client.QueryContext(ctx, `
SELECT id,
user_id = $2 AND action = 'withdraw' AND amount = CAST($3 AS DECIMAL(20,8)),
user_id,
amount::double precision,
COALESCE(aff_quota_after, 0)::double precision,
COALESCE(aff_frozen_quota_after, 0)::double precision,
COALESCE(aff_history_quota_after, 0)::double precision
FROM user_affiliate_ledger
WHERE operation_id = $1`, operationID, userID, amount)
if err != nil {
return nil, fmt.Errorf("query affiliate withdraw by operation: %w", err)
}
defer func() { _ = rows.Close() }()
if !rows.Next() {
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("query affiliate withdraw by operation: %w", err)
}
return nil, service.ErrAffiliateQuotaInsufficient
}
var sameRequest bool
result := &service.AffiliateWithdrawResult{Replayed: true}
if err := rows.Scan(
&result.LedgerID,
&sameRequest,
&result.UserID,
&result.Amount,
&result.AvailableQuotaAfter,
&result.FrozenQuotaAfter,
&result.HistoryQuotaAfter,
); err != nil {
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
if !sameRequest {
return nil, service.ErrIdempotencyKeyConflict
}
return result, nil
}
func (r *affiliateRepository) ListInvitees(ctx context.Context, inviterID int64, limit int) ([]service.AffiliateInvitee, error) { func (r *affiliateRepository) ListInvitees(ctx context.Context, inviterID int64, limit int) ([]service.AffiliateInvitee, error) {
if limit <= 0 { if limit <= 0 {
limit = 100 limit = 100
@@ -466,13 +631,14 @@ func (r *affiliateRepository) ListAffiliateRebateRecords(ctx context.Context, fi
"inviter.email", "inviter.username", "invitee.email", "invitee.username", "inviter.email", "inviter.username", "invitee.email", "invitee.username",
"po.id::text", "po.out_trade_no", "po.payment_type", "po.status", "po.id::text", "po.out_trade_no", "po.payment_type", "po.status",
}) })
// 返利记录列出全部 accrue 流水:非订单来源的流水没有订单,被邀请人账号
// 删除后 source_user_id 为 NULL,这两类行都保留。
baseJoin := ` baseJoin := `
FROM user_affiliate_ledger ual FROM user_affiliate_ledger ual
JOIN payment_orders po ON po.id = ual.source_order_id
JOIN users invitee ON invitee.id = ual.source_user_id
JOIN users inviter ON inviter.id = ual.user_id JOIN users inviter ON inviter.id = ual.user_id
WHERE ual.action = 'accrue' LEFT JOIN users invitee ON invitee.id = ual.source_user_id
AND ual.source_order_id IS NOT NULL` LEFT JOIN payment_orders po ON po.id = ual.source_order_id
WHERE ual.action = 'accrue'`
if where != "" { if where != "" {
where = strings.Replace(where, "WHERE ", " AND ", 1) where = strings.Replace(where, "WHERE ", " AND ", 1)
} }
@@ -496,7 +662,7 @@ WHERE ual.action = 'accrue'
args = append(args, filter.PageSize, (filter.Page-1)*filter.PageSize) args = append(args, filter.PageSize, (filter.Page-1)*filter.PageSize)
rows, err := client.QueryContext(ctx, ` rows, err := client.QueryContext(ctx, `
SELECT po.id, SELECT po.id,
po.out_trade_no, COALESCE(po.out_trade_no, ''),
ual.user_id, ual.user_id,
COALESCE(inviter.email, ''), COALESCE(inviter.email, ''),
COALESCE(inviter.username, ''), COALESCE(inviter.username, ''),
@@ -506,8 +672,8 @@ SELECT po.id,
po.amount::double precision, po.amount::double precision,
po.pay_amount::double precision, po.pay_amount::double precision,
ual.amount::double precision, ual.amount::double precision,
po.payment_type, COALESCE(po.payment_type, ''),
po.status, COALESCE(po.status, ''),
ual.created_at ual.created_at
`+baseJoin+where+` `+baseJoin+where+`
`+orderBy+` `+orderBy+`
@@ -520,17 +686,21 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
items := make([]service.AffiliateRebateRecord, 0) items := make([]service.AffiliateRebateRecord, 0)
for rows.Next() { for rows.Next() {
var item service.AffiliateRebateRecord var item service.AffiliateRebateRecord
var orderID sql.NullInt64
var inviteeID sql.NullInt64
var orderAmount sql.NullFloat64
var payAmount sql.NullFloat64
if err := rows.Scan( if err := rows.Scan(
&item.OrderID, &orderID,
&item.OutTradeNo, &item.OutTradeNo,
&item.InviterID, &item.InviterID,
&item.InviterEmail, &item.InviterEmail,
&item.InviterUsername, &item.InviterUsername,
&item.InviteeID, &inviteeID,
&item.InviteeEmail, &item.InviteeEmail,
&item.InviteeUsername, &item.InviteeUsername,
&item.OrderAmount, &orderAmount,
&item.PayAmount, &payAmount,
&item.RebateAmount, &item.RebateAmount,
&item.PaymentType, &item.PaymentType,
&item.OrderStatus, &item.OrderStatus,
@@ -538,6 +708,10 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
); err != nil { ); err != nil {
return nil, 0, err return nil, 0, err
} }
item.OrderID = nullableInt64Ptr(orderID)
item.InviteeID = nullableInt64Ptr(inviteeID)
item.OrderAmount = nullableFloat64Ptr(orderAmount)
item.PayAmount = nullableFloat64Ptr(payAmount)
items = append(items, item) items = append(items, item)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
@@ -554,7 +728,7 @@ func (r *affiliateRepository) ListAffiliateTransferRecords(ctx context.Context,
baseJoin := ` baseJoin := `
FROM user_affiliate_ledger ual FROM user_affiliate_ledger ual
JOIN users u ON u.id = ual.user_id JOIN users u ON u.id = ual.user_id
WHERE ual.action = 'transfer'` WHERE ual.action IN ('transfer', 'withdraw')`
if where != "" { if where != "" {
where = strings.Replace(where, "WHERE ", " AND ", 1) where = strings.Replace(where, "WHERE ", " AND ", 1)
} }
@@ -566,6 +740,7 @@ WHERE ual.action = 'transfer'`
orderBy := buildAffiliateRecordOrderBy(filter, map[string]string{ orderBy := buildAffiliateRecordOrderBy(filter, map[string]string{
"user": "u.email", "user": "u.email",
"action": "ual.action",
"amount": "ual.amount", "amount": "ual.amount",
"balance_after": "ual.balance_after", "balance_after": "ual.balance_after",
"available_quota_after": "ual.aff_quota_after", "available_quota_after": "ual.aff_quota_after",
@@ -576,6 +751,7 @@ WHERE ual.action = 'transfer'`
args = append(args, filter.PageSize, (filter.Page-1)*filter.PageSize) args = append(args, filter.PageSize, (filter.Page-1)*filter.PageSize)
rows, err := client.QueryContext(ctx, ` rows, err := client.QueryContext(ctx, `
SELECT ual.id, SELECT ual.id,
ual.action,
ual.user_id, ual.user_id,
COALESCE(u.email, ''), COALESCE(u.email, ''),
COALESCE(u.username, ''), COALESCE(u.username, ''),
@@ -602,6 +778,7 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
var historyQuotaAfter sql.NullFloat64 var historyQuotaAfter sql.NullFloat64
if err := rows.Scan( if err := rows.Scan(
&item.LedgerID, &item.LedgerID,
&item.Action,
&item.UserID, &item.UserID,
&item.UserEmail, &item.UserEmail,
&item.Username, &item.Username,
@@ -958,6 +1135,13 @@ func nullableFloat64Ptr(v sql.NullFloat64) *float64 {
return &v.Float64 return &v.Float64
} }
func nullableInt64Ptr(v sql.NullInt64) *int64 {
if !v.Valid {
return nil
}
return &v.Int64
}
func generateAffiliateCode() (string, error) { func generateAffiliateCode() (string, error) {
buf := make([]byte, affiliateCodeLength) buf := make([]byte, affiliateCodeLength)
if _, err := rand.Read(buf); err != nil { if _, err := rand.Read(buf); err != nil {
@@ -4,7 +4,10 @@ package repository
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"fmt" "fmt"
"sync"
"testing" "testing"
"time" "time"
@@ -417,3 +420,383 @@ func TestAffiliateRepository_ListUsersWithCustomSettings(t *testing.T) {
require.GreaterOrEqual(t, total, int64(2), "total must include at least our 2 custom rows") require.GreaterOrEqual(t, total, int64(2), "total must include at least our 2 custom rows")
} }
func mustCreateAffiliateWithdrawUser(t *testing.T, client *dbent.Client, label string) *service.User {
t.Helper()
return mustCreateUser(t, client, &service.User{
Email: fmt.Sprintf("affiliate-withdraw-%s-%d@example.com", label, time.Now().UnixNano()),
PasswordHash: "hash",
Role: service.RoleUser,
Status: service.StatusActive,
Balance: 5.5,
Concurrency: 5,
})
}
func mustSeedAffiliateQuota(t *testing.T, ctx context.Context, client *dbent.Client, userID int64, quota, frozen, history float64) {
t.Helper()
affCode := fmt.Sprintf("AFW%d-%06d", userID, time.Now().UnixNano()%1_000_000)
_, err := client.ExecContext(ctx, `
INSERT INTO user_affiliates (user_id, aff_code, aff_quota, aff_frozen_quota, aff_history_quota, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())`, userID, affCode, quota, frozen, history)
require.NoError(t, err)
}
// mustCreateCommittedAffiliateWithdrawUser 在测试事务之外建用户与返利档案,
// 测试结束时删除(级联返利档案与流水)。WithdrawQuota 在这些用例里自行开启
// 并提交事务,与生产调用一致。
func mustCreateCommittedAffiliateWithdrawUser(t *testing.T, label string, quota, frozen, history float64) *service.User {
t.Helper()
client := testEntClient(t)
u := mustCreateAffiliateWithdrawUser(t, client, label)
t.Cleanup(func() {
_, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM users WHERE id = $1", u.ID)
})
mustSeedAffiliateQuota(t, context.Background(), client, u.ID, quota, frozen, history)
return u
}
func newAffiliateWithdrawOperationID(label string) string {
sum := sha256.Sum256([]byte(fmt.Sprintf("%s-%d", label, time.Now().UnixNano())))
return hex.EncodeToString(sum[:])
}
// TestAffiliateRepository_WithdrawQuota_DeductsAndRecordsLedger 覆盖线下提现主路径:
// 只扣可提取额度,余额与累计返利不变,流水 action=withdraw 带额度快照,并出现在提取记录中。
func TestAffiliateRepository_WithdrawQuota_DeductsAndRecordsLedger(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
txCtx := dbent.NewTxContext(ctx, tx)
client := tx.Client()
repo := NewAffiliateRepository(client, integrationDB)
u := mustCreateAffiliateWithdrawUser(t, client, "user")
mustSeedAffiliateQuota(t, txCtx, client, u.ID, 20, 0, 30)
operationID := newAffiliateWithdrawOperationID("deduct")
result, err := repo.WithdrawQuota(txCtx, u.ID, 12.5, operationID)
require.NoError(t, err)
require.Positive(t, result.LedgerID)
require.False(t, result.Replayed)
require.InDelta(t, 12.5, result.Amount, 1e-9)
require.InDelta(t, 7.5, result.AvailableQuotaAfter, 1e-9)
require.InDelta(t, 0.0, result.FrozenQuotaAfter, 1e-9)
require.InDelta(t, 30.0, result.HistoryQuotaAfter, 1e-9)
require.InDelta(t, 7.5, querySingleFloat(t, txCtx, client,
"SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9)
require.InDelta(t, 30.0, querySingleFloat(t, txCtx, client,
"SELECT aff_history_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9)
require.InDelta(t, 5.5, querySingleFloat(t, txCtx, client,
"SELECT balance::double precision FROM users WHERE id = $1", u.ID), 1e-9)
records, total, err := repo.ListAffiliateTransferRecords(txCtx, service.AffiliateRecordFilter{
Search: u.Email,
Page: 1,
PageSize: 20,
SortDesc: true,
})
require.NoError(t, err)
require.EqualValues(t, 1, total)
require.Len(t, records, 1)
record := records[0]
require.Equal(t, result.LedgerID, record.LedgerID)
require.Equal(t, "withdraw", record.Action)
require.Equal(t, u.ID, record.UserID)
require.InDelta(t, 12.5, record.Amount, 1e-9)
require.True(t, record.SnapshotAvailable)
require.InDelta(t, 5.5, *record.BalanceAfter, 1e-9)
require.InDelta(t, 7.5, *record.AvailableQuotaAfter, 1e-9)
require.InDelta(t, 30.0, *record.HistoryQuotaAfter, 1e-9)
rows, err := client.QueryContext(txCtx, "SELECT operation_id FROM user_affiliate_ledger WHERE id = $1", result.LedgerID)
require.NoError(t, err)
defer func() { _ = rows.Close() }()
require.True(t, rows.Next())
var storedOperationID string
require.NoError(t, rows.Scan(&storedOperationID))
require.Equal(t, operationID, storedOperationID)
}
// TestAffiliateRepository_WithdrawQuota_ThawsMaturedQuotaFirst 验证已过冻结期
// 但尚未解冻的额度在扣减前解冻并可被提现。
func TestAffiliateRepository_WithdrawQuota_ThawsMaturedQuotaFirst(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
txCtx := dbent.NewTxContext(ctx, tx)
client := tx.Client()
repo := NewAffiliateRepository(client, integrationDB)
u := mustCreateAffiliateWithdrawUser(t, client, "thaw")
mustSeedAffiliateQuota(t, txCtx, client, u.ID, 0, 10, 10)
_, err := client.ExecContext(txCtx, `
INSERT INTO user_affiliate_ledger (user_id, action, amount, frozen_until, created_at, updated_at)
VALUES ($1, 'accrue', 10, NOW() - INTERVAL '1 hour', NOW(), NOW())`, u.ID)
require.NoError(t, err)
result, err := repo.WithdrawQuota(txCtx, u.ID, 10, newAffiliateWithdrawOperationID("thaw"))
require.NoError(t, err)
require.InDelta(t, 0.0, result.AvailableQuotaAfter, 1e-9)
require.InDelta(t, 0.0, result.FrozenQuotaAfter, 1e-9)
require.Equal(t, 1, querySingleInt(t, txCtx, client,
"SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.ID))
}
// TestAffiliateRepository_WithdrawQuota_InsufficientLeavesQuotaUntouched 验证未到期的
// 冻结额度不可提现:额度不足时不扣减、不写流水,占位随事务回滚、不占用该
// operation_id,同一标识改成可提取范围内的金额仍可登记;没有返利档案的用户
// 同样按额度不足处理。
func TestAffiliateRepository_WithdrawQuota_InsufficientLeavesQuotaUntouched(t *testing.T) {
ctx := context.Background()
client := testEntClient(t)
repo := NewAffiliateRepository(client, integrationDB)
u := mustCreateCommittedAffiliateWithdrawUser(t, "insufficient", 5, 10, 15)
_, err := integrationDB.ExecContext(ctx, `
INSERT INTO user_affiliate_ledger (user_id, action, amount, frozen_until, created_at, updated_at)
VALUES ($1, 'accrue', 10, NOW() + INTERVAL '1 hour', NOW(), NOW())`, u.ID)
require.NoError(t, err)
operationID := newAffiliateWithdrawOperationID("insufficient")
_, err = repo.WithdrawQuota(ctx, u.ID, 6, operationID)
require.ErrorIs(t, err, service.ErrAffiliateQuotaInsufficient)
require.InDelta(t, 5.0, querySingleFloat(t, ctx, client,
"SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9)
require.InDelta(t, 10.0, querySingleFloat(t, ctx, client,
"SELECT aff_frozen_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9)
require.Equal(t, 0, querySingleInt(t, ctx, client,
"SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.ID))
result, err := repo.WithdrawQuota(ctx, u.ID, 5, operationID)
require.NoError(t, err)
require.False(t, result.Replayed)
require.InDelta(t, 0.0, result.AvailableQuotaAfter, 1e-9)
noProfile := mustCreateAffiliateWithdrawUser(t, client, "no-profile")
t.Cleanup(func() {
_, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM users WHERE id = $1", noProfile.ID)
})
_, err = repo.WithdrawQuota(ctx, noProfile.ID, 1, newAffiliateWithdrawOperationID("no-profile"))
require.ErrorIs(t, err, service.ErrAffiliateQuotaInsufficient)
require.Equal(t, 0, querySingleInt(t, ctx, client,
"SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1", noProfile.ID))
}
// TestAffiliateRepository_WithdrawQuota_RetryAfterCommitReplaysFirstResult 复现
// 「提交成功但响应丢失后重试」:首次登记已在独立事务中提交,以同一 operation_id
// 重试不重复扣减、不重复写流水,返回首次登记的流水与额度快照。两笔合法的等额打款
// 使用不同的 operation_id,各扣一次。
func TestAffiliateRepository_WithdrawQuota_RetryAfterCommitReplaysFirstResult(t *testing.T) {
ctx := context.Background()
client := testEntClient(t)
repo := NewAffiliateRepository(client, integrationDB)
u := mustCreateCommittedAffiliateWithdrawUser(t, "retry", 100, 0, 100)
operationID := newAffiliateWithdrawOperationID("retry")
first, err := repo.WithdrawQuota(ctx, u.ID, 10, operationID)
require.NoError(t, err)
require.False(t, first.Replayed)
retry, err := repo.WithdrawQuota(ctx, u.ID, 10, operationID)
require.NoError(t, err)
require.True(t, retry.Replayed)
require.Equal(t, first.LedgerID, retry.LedgerID)
require.Equal(t, u.ID, retry.UserID)
require.InDelta(t, 10.0, retry.Amount, 1e-9)
require.InDelta(t, 90.0, retry.AvailableQuotaAfter, 1e-9)
require.InDelta(t, first.FrozenQuotaAfter, retry.FrozenQuotaAfter, 1e-9)
require.InDelta(t, 100.0, retry.HistoryQuotaAfter, 1e-9)
require.InDelta(t, 90.0, querySingleFloat(t, ctx, client,
"SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9)
require.Equal(t, 1, querySingleInt(t, ctx, client,
"SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.ID))
second, err := repo.WithdrawQuota(ctx, u.ID, 10, newAffiliateWithdrawOperationID("retry-second"))
require.NoError(t, err)
require.False(t, second.Replayed)
require.NotEqual(t, first.LedgerID, second.LedgerID)
require.InDelta(t, 80.0, querySingleFloat(t, ctx, client,
"SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9)
require.Equal(t, 2, querySingleInt(t, ctx, client,
"SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.ID))
}
// TestAffiliateRepository_WithdrawQuota_SameOperationWaitsForInFlightRegistration
// 固定「并发同键」的交错:首个登记已占位并扣减但未提交时,同一 operation_id 的
// 请求在唯一索引上等待。首个登记提交后,等待的请求不重复扣减,返回同一条流水;
// 首个登记回滚后,等待的请求自行完成登记。两种结局都只扣一次。
func TestAffiliateRepository_WithdrawQuota_SameOperationWaitsForInFlightRegistration(t *testing.T) {
for _, commitFirst := range []bool{true, false} {
name := "first rolls back"
if commitFirst {
name = "first commits"
}
t.Run(name, func(t *testing.T) {
ctx := context.Background()
client := testEntClient(t)
repo := NewAffiliateRepository(client, integrationDB)
u := mustCreateCommittedAffiliateWithdrawUser(t, "inflight", 100, 0, 100)
operationID := newAffiliateWithdrawOperationID("inflight")
tx, err := client.Tx(ctx)
require.NoError(t, err)
t.Cleanup(func() { _ = tx.Rollback() })
first, err := repo.WithdrawQuota(dbent.NewTxContext(ctx, tx), u.ID, 10, operationID)
require.NoError(t, err)
type outcome struct {
result *service.AffiliateWithdrawResult
err error
}
waiting := make(chan outcome, 1)
go func() {
result, err := repo.WithdrawQuota(ctx, u.ID, 10, operationID)
waiting <- outcome{result: result, err: err}
}()
select {
case got := <-waiting:
t.Fatalf("same operation must wait for the in-flight registration, got result=%+v err=%v", got.result, got.err)
case <-time.After(300 * time.Millisecond):
}
if commitFirst {
require.NoError(t, tx.Commit())
} else {
require.NoError(t, tx.Rollback())
}
var got outcome
select {
case got = <-waiting:
case <-time.After(10 * time.Second):
t.Fatal("waiting registration did not finish after the in-flight one ended")
}
require.NoError(t, got.err)
if commitFirst {
require.True(t, got.result.Replayed)
require.Equal(t, first.LedgerID, got.result.LedgerID)
} else {
require.False(t, got.result.Replayed)
require.NotEqual(t, first.LedgerID, got.result.LedgerID)
}
require.InDelta(t, 90.0, got.result.AvailableQuotaAfter, 1e-9)
require.InDelta(t, 90.0, querySingleFloat(t, ctx, client,
"SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9)
require.Equal(t, 1, querySingleInt(t, ctx, client,
"SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.ID))
})
}
}
// TestAffiliateRepository_WithdrawQuota_ConcurrentSameOperationDeductsOnce 让多个请求
// 同时以同一 operation_id 登记:只有一个执行扣减,其余返回同一条流水。
func TestAffiliateRepository_WithdrawQuota_ConcurrentSameOperationDeductsOnce(t *testing.T) {
ctx := context.Background()
client := testEntClient(t)
repo := NewAffiliateRepository(client, integrationDB)
u := mustCreateCommittedAffiliateWithdrawUser(t, "concurrent", 100, 0, 100)
operationID := newAffiliateWithdrawOperationID("concurrent")
const workers = 8
results := make([]*service.AffiliateWithdrawResult, workers)
errs := make([]error, workers)
start := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
results[i], errs[i] = repo.WithdrawQuota(ctx, u.ID, 10, operationID)
}(i)
}
close(start)
wg.Wait()
executed := 0
for i := 0; i < workers; i++ {
require.NoError(t, errs[i])
require.Equal(t, results[0].LedgerID, results[i].LedgerID)
require.InDelta(t, 90.0, results[i].AvailableQuotaAfter, 1e-9)
if !results[i].Replayed {
executed++
}
}
require.Equal(t, 1, executed)
require.InDelta(t, 90.0, querySingleFloat(t, ctx, client,
"SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9)
require.Equal(t, 1, querySingleInt(t, ctx, client,
"SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.ID))
}
// TestAffiliateRepository_WithdrawQuota_RejectsOperationReuseWithDifferentRequest 验证
// 同一 operation_id 换了金额或用户时以 ErrIdempotencyKeyConflict 拒绝,
// 不扣减、不写流水。
func TestAffiliateRepository_WithdrawQuota_RejectsOperationReuseWithDifferentRequest(t *testing.T) {
ctx := context.Background()
client := testEntClient(t)
repo := NewAffiliateRepository(client, integrationDB)
u := mustCreateCommittedAffiliateWithdrawUser(t, "conflict", 100, 0, 100)
other := mustCreateCommittedAffiliateWithdrawUser(t, "conflict-other", 100, 0, 100)
operationID := newAffiliateWithdrawOperationID("conflict")
_, err := repo.WithdrawQuota(ctx, u.ID, 10, operationID)
require.NoError(t, err)
_, err = repo.WithdrawQuota(ctx, u.ID, 11, operationID)
require.ErrorIs(t, err, service.ErrIdempotencyKeyConflict)
_, err = repo.WithdrawQuota(ctx, other.ID, 10, operationID)
require.ErrorIs(t, err, service.ErrIdempotencyKeyConflict)
require.InDelta(t, 90.0, querySingleFloat(t, ctx, client,
"SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9)
require.InDelta(t, 100.0, querySingleFloat(t, ctx, client,
"SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", other.ID), 1e-9)
require.Equal(t, 1, querySingleInt(t, ctx, client,
"SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id IN ($1, $2) AND action = 'withdraw'", u.ID, other.ID))
}
// TestAffiliateRepository_ListAffiliateRebateRecords_IncludesNonOrderAccruals 验证兑换码
// 等非订单来源的返利出现在返利记录中,订单字段为空。
func TestAffiliateRepository_ListAffiliateRebateRecords_IncludesNonOrderAccruals(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
txCtx := dbent.NewTxContext(ctx, tx)
client := tx.Client()
repo := NewAffiliateRepository(client, integrationDB)
inviter := mustCreateAffiliateWithdrawUser(t, client, "inviter")
invitee := mustCreateAffiliateWithdrawUser(t, client, "invitee")
mustSeedAffiliateQuota(t, txCtx, client, inviter.ID, 0, 0, 0)
applied, err := repo.AccrueQuota(txCtx, inviter.ID, invitee.ID, 2, 0, nil)
require.NoError(t, err)
require.True(t, applied)
records, total, err := repo.ListAffiliateRebateRecords(txCtx, service.AffiliateRecordFilter{
Search: invitee.Email,
Page: 1,
PageSize: 20,
SortDesc: true,
})
require.NoError(t, err)
require.EqualValues(t, 1, total)
require.Len(t, records, 1)
record := records[0]
require.Nil(t, record.OrderID)
require.Nil(t, record.OrderAmount)
require.Nil(t, record.PayAmount)
require.Empty(t, record.OutTradeNo)
require.Equal(t, inviter.ID, record.InviterID)
require.NotNil(t, record.InviteeID)
require.Equal(t, invitee.ID, *record.InviteeID)
require.InDelta(t, 2.0, record.RebateAmount, 1e-9)
}
@@ -20,9 +20,56 @@ func TestAffiliateRecordQueriesUseLedgerAuditFields(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
content := string(source) content := string(source)
require.Contains(t, content, "JOIN payment_orders po ON po.id = ual.source_order_id") require.Contains(t, content, "LEFT JOIN payment_orders po ON po.id = ual.source_order_id")
require.Contains(t, content, "ual.amount::double precision") require.Contains(t, content, "ual.amount::double precision")
require.Contains(t, content, "ual.balance_after::double precision") require.Contains(t, content, "ual.balance_after::double precision")
require.NotContains(t, content, "parseAffiliateRebateAmount") require.NotContains(t, content, "parseAffiliateRebateAmount")
require.NotContains(t, content, `"current_balance": "u.balance"`) require.NotContains(t, content, `"current_balance": "u.balance"`)
} }
// TestAffiliateRebateRecordsQueryKeepsNonOrderAccruals 锁定返利记录列出全部
// accrue 流水:订单与被邀请人均为 LEFT JOIN,且不按 source_order_id 过滤,
// 兑换码、管理员充值来源的返利才会出现在明细里。
func TestAffiliateRebateRecordsQueryKeepsNonOrderAccruals(t *testing.T) {
source, err := os.ReadFile("affiliate_repo.go")
require.NoError(t, err)
content := string(source)
require.Contains(t, content, "LEFT JOIN payment_orders po ON po.id = ual.source_order_id")
require.Contains(t, content, "LEFT JOIN users invitee ON invitee.id = ual.source_user_id")
require.NotContains(t, content, "\nJOIN payment_orders po ON po.id = ual.source_order_id")
require.NotContains(t, content, "\nJOIN users invitee ON invitee.id = ual.source_user_id")
require.NotContains(t, content, "AND ual.source_order_id IS NOT NULL")
}
// TestAffiliateTransferRecordsQueryIncludesOfflineWithdrawals 锁定提取记录同时
// 列出转入余额与线下提现两类额度流出。
func TestAffiliateTransferRecordsQueryIncludesOfflineWithdrawals(t *testing.T) {
source, err := os.ReadFile("affiliate_repo.go")
require.NoError(t, err)
content := string(source)
require.Contains(t, content, "WHERE ual.action IN ('transfer', 'withdraw')")
}
// TestAffiliateWithdrawClaimsOperationBeforeDeducting 锁定线下提现的幂等形态:
// 同一事务内先按 operation_id 唯一约束写入占位流水,冲突时不扣减,
// 且唯一约束由迁移建立。
func TestAffiliateWithdrawClaimsOperationBeforeDeducting(t *testing.T) {
source, err := os.ReadFile("affiliate_repo.go")
require.NoError(t, err)
content := string(source)
require.Contains(t, content, "ON CONFLICT (operation_id) WHERE operation_id IS NOT NULL DO NOTHING")
withdraw := content[strings.Index(content, "func (r *affiliateRepository) WithdrawQuota("):]
withdraw = withdraw[:strings.Index(withdraw, "\n}\n")]
claimAt := strings.Index(withdraw, "claimAffiliateWithdrawLedger(")
deductAt := strings.Index(withdraw, "SET aff_quota = aff_quota - $1")
require.Positive(t, claimAt)
require.Greater(t, deductAt, claimAt, "operation claim must precede the quota deduction")
migration, err := os.ReadFile("../../migrations/240_affiliate_ledger_operation_id.sql")
require.NoError(t, err)
require.Contains(t, string(migration), "CREATE UNIQUE INDEX IF NOT EXISTS idx_user_affiliate_ledger_operation_id")
require.Contains(t, string(migration), "WHERE operation_id IS NOT NULL")
}
+1
View File
@@ -823,6 +823,7 @@ func registerAffiliateRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
users.GET("/lookup", h.Admin.Affiliate.LookupUsers) users.GET("/lookup", h.Admin.Affiliate.LookupUsers)
users.POST("/batch-rate", h.Admin.Affiliate.BatchSetRate) users.POST("/batch-rate", h.Admin.Affiliate.BatchSetRate)
users.GET("/:user_id/overview", h.Admin.Affiliate.GetUserOverview) users.GET("/:user_id/overview", h.Admin.Affiliate.GetUserOverview)
users.POST("/:user_id/withdraw", h.Admin.Affiliate.WithdrawQuota)
users.PUT("/:user_id", h.Admin.Affiliate.UpdateUserSettings) users.PUT("/:user_id", h.Admin.Affiliate.UpdateUserSettings)
users.DELETE("/:user_id", h.Admin.Affiliate.ClearUserSettings) users.DELETE("/:user_id", h.Admin.Affiliate.ClearUserSettings)
} }
+65 -4
View File
@@ -2,6 +2,8 @@ package service
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"errors" "errors"
"math" "math"
"strings" "strings"
@@ -17,6 +19,9 @@ var (
ErrAffiliateCodeTaken = infraerrors.Conflict("AFFILIATE_CODE_TAKEN", "affiliate code already in use") ErrAffiliateCodeTaken = infraerrors.Conflict("AFFILIATE_CODE_TAKEN", "affiliate code already in use")
ErrAffiliateAlreadyBound = infraerrors.Conflict("AFFILIATE_ALREADY_BOUND", "affiliate inviter already bound") ErrAffiliateAlreadyBound = infraerrors.Conflict("AFFILIATE_ALREADY_BOUND", "affiliate inviter already bound")
ErrAffiliateQuotaEmpty = infraerrors.BadRequest("AFFILIATE_QUOTA_EMPTY", "no affiliate quota available to transfer") ErrAffiliateQuotaEmpty = infraerrors.BadRequest("AFFILIATE_QUOTA_EMPTY", "no affiliate quota available to transfer")
ErrAffiliateQuotaInsufficient = infraerrors.BadRequest("AFFILIATE_QUOTA_INSUFFICIENT", "insufficient available affiliate quota")
ErrAffiliateWithdrawAmountInvalid = infraerrors.BadRequest("AFFILIATE_WITHDRAW_AMOUNT_INVALID", "invalid offline withdrawal amount")
) )
const ( const (
@@ -102,6 +107,7 @@ type AffiliateRepository interface {
GetAccruedRebateFromInvitee(ctx context.Context, inviterID, inviteeUserID int64) (float64, error) GetAccruedRebateFromInvitee(ctx context.Context, inviterID, inviteeUserID int64) (float64, error)
ThawFrozenQuota(ctx context.Context, userID int64) (float64, error) ThawFrozenQuota(ctx context.Context, userID int64) (float64, error)
TransferQuotaToBalance(ctx context.Context, userID int64) (float64, float64, error) TransferQuotaToBalance(ctx context.Context, userID int64) (float64, float64, error)
WithdrawQuota(ctx context.Context, userID int64, amount float64, operationID string) (*AffiliateWithdrawResult, error)
ListInvitees(ctx context.Context, inviterID int64, limit int) ([]AffiliateInvitee, error) ListInvitees(ctx context.Context, inviterID int64, limit int) ([]AffiliateInvitee, error)
// 管理端:用户级专属配置 // 管理端:用户级专属配置
@@ -156,25 +162,31 @@ type AffiliateInviteRecord struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
// AffiliateRebateRecord 是一笔返利入账流水。非订单来源(兑换码、管理员充值)
// 的流水没有订单信息,OrderID/OrderAmount/PayAmount 为 nil;被邀请人账号被删除时
// InviteeID 为 nil。
type AffiliateRebateRecord struct { type AffiliateRebateRecord struct {
OrderID int64 `json:"order_id"` OrderID *int64 `json:"order_id"`
OutTradeNo string `json:"out_trade_no"` OutTradeNo string `json:"out_trade_no"`
InviterID int64 `json:"inviter_id"` InviterID int64 `json:"inviter_id"`
InviterEmail string `json:"inviter_email"` InviterEmail string `json:"inviter_email"`
InviterUsername string `json:"inviter_username"` InviterUsername string `json:"inviter_username"`
InviteeID int64 `json:"invitee_id"` InviteeID *int64 `json:"invitee_id"`
InviteeEmail string `json:"invitee_email"` InviteeEmail string `json:"invitee_email"`
InviteeUsername string `json:"invitee_username"` InviteeUsername string `json:"invitee_username"`
OrderAmount float64 `json:"order_amount"` OrderAmount *float64 `json:"order_amount"`
PayAmount float64 `json:"pay_amount"` PayAmount *float64 `json:"pay_amount"`
RebateAmount float64 `json:"rebate_amount"` RebateAmount float64 `json:"rebate_amount"`
PaymentType string `json:"payment_type"` PaymentType string `json:"payment_type"`
OrderStatus string `json:"order_status"` OrderStatus string `json:"order_status"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
// AffiliateTransferRecord 是一笔返利额度流出:Action 为 transfer(用户转入余额)
// 或 withdraw(管理员登记的线下提现)。
type AffiliateTransferRecord struct { type AffiliateTransferRecord struct {
LedgerID int64 `json:"ledger_id"` LedgerID int64 `json:"ledger_id"`
Action string `json:"action"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
UserEmail string `json:"user_email"` UserEmail string `json:"user_email"`
Username string `json:"username"` Username string `json:"username"`
@@ -191,6 +203,18 @@ type AffiliateTransferRecord struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
// AffiliateWithdrawResult 是线下提现登记完成后的流水 ID 与额度快照。
// Replayed 表示本次请求命中了同一幂等标识的已有登记,返回的是首次登记的结果。
type AffiliateWithdrawResult struct {
LedgerID int64 `json:"ledger_id"`
UserID int64 `json:"user_id"`
Amount float64 `json:"amount"`
AvailableQuotaAfter float64 `json:"available_quota_after"`
FrozenQuotaAfter float64 `json:"frozen_quota_after"`
HistoryQuotaAfter float64 `json:"history_quota_after"`
Replayed bool `json:"-"`
}
type AffiliateUserOverview struct { type AffiliateUserOverview struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Email string `json:"email"` Email string `json:"email"`
@@ -559,6 +583,43 @@ func (s *AffiliateService) AdminBatchSetUserRebateRate(ctx context.Context, user
return s.repo.BatchSetUserRebateRate(ctx, cleaned, ratePercent) return s.repo.BatchSetUserRebateRate(ctx, cleaned, ratePercent)
} }
// AdminWithdrawQuota 登记一笔已在站外打款的线下提现:从用户可提取返利额度
// 中扣除 amount,并写入 action=withdraw 的流水。冻结期内的额度不可扣除。
// 金额按返利流水精度保留 8 位小数,舍入后必须为正。
//
// idempotencyKey 标识一次登记且必填:同一个键只扣减一次,重复请求在用户与金额
// 一致时返回首次登记的结果,不一致时返回 ErrIdempotencyKeyConflict。
func (s *AffiliateService) AdminWithdrawQuota(ctx context.Context, userID int64, amount float64, idempotencyKey string) (*AffiliateWithdrawResult, error) {
if userID <= 0 {
return nil, infraerrors.BadRequest("INVALID_USER", "invalid user")
}
if s == nil || s.repo == nil {
return nil, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
}
key, err := NormalizeIdempotencyKey(idempotencyKey)
if err != nil {
return nil, err
}
if key == "" {
return nil, ErrIdempotencyKeyRequired
}
if math.IsNaN(amount) || math.IsInf(amount, 0) {
return nil, ErrAffiliateWithdrawAmountInvalid
}
amount = roundTo(amount, 8)
if amount <= 0 || math.IsInf(amount, 0) {
return nil, ErrAffiliateWithdrawAmountInvalid
}
return s.repo.WithdrawQuota(ctx, userID, amount, affiliateWithdrawOperationID(key))
}
// affiliateWithdrawOperationID 把线下提现登记的幂等键映射为流水的 operation_id,
// 同一个键始终得到同一个值。
func affiliateWithdrawOperationID(idempotencyKey string) string {
sum := sha256.Sum256([]byte("admin.affiliates.withdraw\x00" + idempotencyKey))
return hex.EncodeToString(sum[:])
}
// AdminListCustomUsers 列出有专属配置的用户。 // AdminListCustomUsers 列出有专属配置的用户。
func (s *AffiliateService) AdminListCustomUsers(ctx context.Context, filter AffiliateAdminFilter) ([]AffiliateAdminEntry, int64, error) { func (s *AffiliateService) AdminListCustomUsers(ctx context.Context, filter AffiliateAdminFilter) ([]AffiliateAdminEntry, int64, error) {
if s == nil || s.repo == nil { if s == nil || s.repo == nil {
@@ -5,6 +5,7 @@ package service
import ( import (
"context" "context"
"math" "math"
"strings"
"testing" "testing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -129,3 +130,118 @@ func TestIsValidAffiliateCodeFormat(t *testing.T) {
}) })
} }
} }
type affiliateWithdrawCall struct {
userID int64
amount float64
operationID string
}
type affiliateWithdrawRepoStub struct {
AffiliateRepository
calls []affiliateWithdrawCall
result *AffiliateWithdrawResult
err error
}
func (s *affiliateWithdrawRepoStub) WithdrawQuota(_ context.Context, userID int64, amount float64, operationID string) (*AffiliateWithdrawResult, error) {
s.calls = append(s.calls, affiliateWithdrawCall{userID: userID, amount: amount, operationID: operationID})
return s.result, s.err
}
const testAffiliateWithdrawKey = "affiliate-withdraw-7-3f2b8c1e"
// TestAdminWithdrawQuota_RejectsInvalidAmount 覆盖线下提现金额校验:非正数、
// NaN/Inf、舍入到 8 位小数后为 0 或溢出的金额都在服务层拒绝,不进入仓储。
func TestAdminWithdrawQuota_RejectsInvalidAmount(t *testing.T) {
t.Parallel()
for _, amount := range []float64{0, -1, math.NaN(), math.Inf(1), math.Inf(-1), 1e-9, math.MaxFloat64} {
repo := &affiliateWithdrawRepoStub{}
svc := &AffiliateService{repo: repo}
_, err := svc.AdminWithdrawQuota(context.Background(), 1, amount, testAffiliateWithdrawKey)
require.ErrorIs(t, err, ErrAffiliateWithdrawAmountInvalid, "amount %v", amount)
require.Empty(t, repo.calls, "amount %v must not reach repository", amount)
}
}
// TestAdminWithdrawQuota_RejectsInvalidUser 验证缺少目标用户时直接拒绝。
func TestAdminWithdrawQuota_RejectsInvalidUser(t *testing.T) {
t.Parallel()
repo := &affiliateWithdrawRepoStub{}
svc := &AffiliateService{repo: repo}
_, err := svc.AdminWithdrawQuota(context.Background(), 0, 1, testAffiliateWithdrawKey)
require.Error(t, err)
require.Empty(t, repo.calls)
}
// TestAdminWithdrawQuota_RequiresValidIdempotencyKey 验证幂等键必填且须为
// 可见 ASCII、不超过 128 字符;不合格的键不进入仓储。
func TestAdminWithdrawQuota_RequiresValidIdempotencyKey(t *testing.T) {
t.Parallel()
cases := []struct {
key string
want error
}{
{key: "", want: ErrIdempotencyKeyRequired},
{key: " ", want: ErrIdempotencyKeyRequired},
{key: "has space", want: ErrIdempotencyKeyInvalid},
{key: "登记", want: ErrIdempotencyKeyInvalid},
{key: strings.Repeat("k", 129), want: ErrIdempotencyKeyInvalid},
}
for _, tc := range cases {
repo := &affiliateWithdrawRepoStub{}
svc := &AffiliateService{repo: repo}
_, err := svc.AdminWithdrawQuota(context.Background(), 1, 1, tc.key)
require.ErrorIs(t, err, tc.want, "key %q", tc.key)
require.Empty(t, repo.calls, "key %q must not reach repository", tc.key)
}
}
// TestAdminWithdrawQuota_DerivesStableOperationID 验证仓储收到的 operation_id
// 由幂等键确定性派生:同一个键(含首尾空白)得到同一个值,不同的键得到不同的值,
// 且不是原始键本身。
func TestAdminWithdrawQuota_DerivesStableOperationID(t *testing.T) {
t.Parallel()
repo := &affiliateWithdrawRepoStub{result: &AffiliateWithdrawResult{LedgerID: 1}}
svc := &AffiliateService{repo: repo}
for _, key := range []string{testAffiliateWithdrawKey, " " + testAffiliateWithdrawKey + " ", testAffiliateWithdrawKey + "-2"} {
_, err := svc.AdminWithdrawQuota(context.Background(), 42, 10, key)
require.NoError(t, err)
}
require.Len(t, repo.calls, 3)
first := repo.calls[0].operationID
require.Regexp(t, `^[0-9a-f]{64}$`, first)
require.NotEqual(t, testAffiliateWithdrawKey, first)
require.Equal(t, first, repo.calls[1].operationID)
require.NotEqual(t, first, repo.calls[2].operationID)
}
// TestAdminWithdrawQuota_RoundsAmountToLedgerPrecision 验证金额按 8 位小数舍入后
// 交给仓储,仓储结果原样返回。
func TestAdminWithdrawQuota_RoundsAmountToLedgerPrecision(t *testing.T) {
t.Parallel()
want := &AffiliateWithdrawResult{LedgerID: 9, UserID: 42, Amount: 12.34567891}
repo := &affiliateWithdrawRepoStub{result: want}
svc := &AffiliateService{repo: repo}
got, err := svc.AdminWithdrawQuota(context.Background(), 42, 12.3456789149, testAffiliateWithdrawKey)
require.NoError(t, err)
require.Same(t, want, got)
require.Len(t, repo.calls, 1)
require.Equal(t, int64(42), repo.calls[0].userID)
require.Equal(t, 12.34567891, repo.calls[0].amount)
}
// TestAdminWithdrawQuota_PropagatesRepositoryErrors 验证额度不足与同键不同参数
// 由仓储判定,服务层原样返回,前端据错误码提示。
func TestAdminWithdrawQuota_PropagatesRepositoryErrors(t *testing.T) {
t.Parallel()
for _, want := range []error{ErrAffiliateQuotaInsufficient, ErrIdempotencyKeyConflict} {
repo := &affiliateWithdrawRepoStub{err: want}
svc := &AffiliateService{repo: repo}
_, err := svc.AdminWithdrawQuota(context.Background(), 1, 1, testAffiliateWithdrawKey)
require.ErrorIs(t, err, want)
}
}
@@ -150,6 +150,10 @@ func (r *paymentFulfillmentAffiliateRepoStub) TransferQuotaToBalance(context.Con
panic("unexpected TransferQuotaToBalance call") panic("unexpected TransferQuotaToBalance call")
} }
func (r *paymentFulfillmentAffiliateRepoStub) WithdrawQuota(context.Context, int64, float64, string) (*AffiliateWithdrawResult, error) {
panic("unexpected WithdrawQuota call")
}
func (r *paymentFulfillmentAffiliateRepoStub) ListInvitees(context.Context, int64, int) ([]AffiliateInvitee, error) { func (r *paymentFulfillmentAffiliateRepoStub) ListInvitees(context.Context, int64, int) ([]AffiliateInvitee, error) {
panic("unexpected ListInvitees call") panic("unexpected ListInvitees call")
} }
@@ -0,0 +1,10 @@
-- 管理员登记线下提现的幂等标识:同一 operation_id 只对应一条流水,
-- 重试同一次登记时据此返回首次登记的结果,不重复扣减。
ALTER TABLE user_affiliate_ledger
ADD COLUMN IF NOT EXISTS operation_id VARCHAR(64);
COMMENT ON COLUMN user_affiliate_ledger.operation_id IS '管理员登记线下提现的幂等标识(由 Idempotency-Key 派生的 SHA-256 十六进制串);其他流水为 NULL';
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_affiliate_ledger_operation_id
ON user_affiliate_ledger (operation_id)
WHERE operation_id IS NOT NULL;
+56 -4
View File
@@ -46,25 +46,35 @@ export interface AffiliateInviteRecord {
created_at: string created_at: string
} }
/**
* One rebate accrual. Non-order sources (redeem codes, admin recharges) carry no
* order, so order fields are null; invitee_id is null when the invitee account
* was deleted.
*/
export interface AffiliateRebateRecord { export interface AffiliateRebateRecord {
order_id: number order_id: number | null
out_trade_no: string out_trade_no: string
inviter_id: number inviter_id: number
inviter_email: string inviter_email: string
inviter_username: string inviter_username: string
invitee_id: number invitee_id: number | null
invitee_email: string invitee_email: string
invitee_username: string invitee_username: string
order_amount: number order_amount: number | null
pay_amount: number pay_amount: number | null
rebate_amount: number rebate_amount: number
payment_type: string payment_type: string
order_status: string order_status: string
created_at: string created_at: string
} }
/** transfer = user moved quota into balance; withdraw = admin-recorded offline withdrawal. */
export type AffiliateOutflowAction = 'transfer' | 'withdraw'
/** One affiliate quota outflow: a transfer into balance or an offline withdrawal. */
export interface AffiliateTransferRecord { export interface AffiliateTransferRecord {
ledger_id: number ledger_id: number
action: AffiliateOutflowAction
user_id: number user_id: number
user_email: string user_email: string
username: string username: string
@@ -77,6 +87,26 @@ export interface AffiliateTransferRecord {
created_at: string created_at: string
} }
export interface WithdrawAffiliateQuotaRequest {
/** Amount already paid to the user outside the site (USD). */
amount: number
}
export interface AffiliateWithdrawResult {
ledger_id: number
user_id: number
amount: number
available_quota_after: number
frozen_quota_after: number
history_quota_after: number
}
export interface AffiliateWithdrawResponse {
result: AffiliateWithdrawResult
/** True when the key matched an earlier registration and nothing was deducted again. */
replayed: boolean
}
export interface AffiliateUserOverview { export interface AffiliateUserOverview {
user_id: number user_id: number
email: string email: string
@@ -215,6 +245,27 @@ export async function getUserOverview(
return data return data
} }
/**
* Records an offline withdrawal. `idempotencyKey` identifies one registration:
* a retry with the same key deducts nothing and returns the first result, with
* `replayed` set.
*/
export async function withdrawUserQuota(
userId: number,
payload: WithdrawAffiliateQuotaRequest,
idempotencyKey: string,
): Promise<AffiliateWithdrawResponse> {
const response = await apiClient.post<AffiliateWithdrawResult>(
`/admin/affiliates/users/${userId}/withdraw`,
payload,
{ headers: { 'Idempotency-Key': idempotencyKey } },
)
return {
result: response.data,
replayed: response.headers?.['x-idempotency-replayed'] === 'true',
}
}
export const affiliatesAPI = { export const affiliatesAPI = {
listUsers, listUsers,
lookupUsers, lookupUsers,
@@ -225,6 +276,7 @@ export const affiliatesAPI = {
listRebateRecords, listRebateRecords,
listTransferRecords, listTransferRecords,
getUserOverview, getUserOverview,
withdrawUserQuota,
} }
export default affiliatesAPI export default affiliatesAPI
+32 -4
View File
@@ -422,10 +422,12 @@ export default {
affiliates: { affiliates: {
invitesDescription: 'View site-wide inviter and invitee relationships', invitesDescription: 'View site-wide inviter and invitee relationships',
rebatesDescription: 'View recharge orders that generated affiliate rebates', rebatesDescription: 'View every affiliate rebate accrual from recharge orders, redeem codes, and admin recharges',
transfersDescription: 'View affiliate quota transfers into account balance', transfersDescription: 'View affiliate quota transfers into balance and offline withdrawals',
errors: { errors: {
loadFailed: 'Failed to load affiliate records' loadFailed: 'Failed to load affiliate records',
AFFILIATE_QUOTA_INSUFFICIENT: 'Insufficient available affiliate quota',
AFFILIATE_WITHDRAW_AMOUNT_INVALID: 'Invalid withdrawal amount'
}, },
records: { records: {
search: 'Search', search: 'Search',
@@ -450,7 +452,33 @@ export default {
historyQuotaAfter: 'Historical Rebate After', historyQuotaAfter: 'Historical Rebate After',
invitedAt: 'Invited At', invitedAt: 'Invited At',
rebatedAt: 'Rebated At', rebatedAt: 'Rebated At',
transferredAt: 'Transferred At' transferredAt: 'Transferred At',
outflowType: 'Type'
},
outflowTypes: {
transfer: 'To Balance',
withdraw: 'Offline Withdrawal'
},
withdraw: {
button: 'Record Offline Withdrawal',
title: 'Record Offline Withdrawal',
user: 'User',
userPlaceholder: 'Search by email or username',
noUserFound: 'No matching users',
changeUser: 'Change user',
availableQuota: 'Available quota',
frozenHint: 'Rebates still in the freeze period are not included in the available quota',
amount: 'Withdrawal amount (USD)',
amountHint: 'Enter the amount already paid to this user outside the site',
fillAll: 'All',
warning: 'Recording deducts this amount from the user\'s available affiliate quota and cannot be undone. Make sure the off-site payment is complete.',
submit: 'Confirm',
submitting: 'Recording...',
success: 'Recorded offline withdrawal of {amount}; {remaining} still available',
replayed: 'This offline withdrawal of {amount} was already recorded and was not deducted again; {remaining} was left available after it',
uncertainHint: 'The last submission returned no result and may already be recorded. The user and amount are locked; submitting again retries the same registration and never deducts twice.',
amountRequired: 'Enter an amount greater than 0',
amountExceeds: 'Amount cannot exceed the available quota'
}, },
overview: { overview: {
title: 'Affiliate User Overview', title: 'Affiliate User Overview',
+32 -4
View File
@@ -422,10 +422,12 @@ export default {
affiliates: { affiliates: {
invitesDescription: '查看全站邀请关系和被邀请用户累计返利', invitesDescription: '查看全站邀请关系和被邀请用户累计返利',
rebatesDescription: '查看每一笔产生返利的充值订单', rebatesDescription: '查看每一笔返利入账,包括充值订单、兑换码和管理员充值产生的返利',
transfersDescription: '查看返利额度转入账户余额的提取流水', transfersDescription: '查看返利额度转入余额与线下提现的流水',
errors: { errors: {
loadFailed: '加载邀请返利记录失败' loadFailed: '加载邀请返利记录失败',
AFFILIATE_QUOTA_INSUFFICIENT: '可提取返利额度不足',
AFFILIATE_WITHDRAW_AMOUNT_INVALID: '提现金额无效'
}, },
records: { records: {
search: '搜索', search: '搜索',
@@ -450,7 +452,33 @@ export default {
historyQuotaAfter: '提取后历史返利', historyQuotaAfter: '提取后历史返利',
invitedAt: '邀请时间', invitedAt: '邀请时间',
rebatedAt: '返利时间', rebatedAt: '返利时间',
transferredAt: '提取时间' transferredAt: '提取时间',
outflowType: '类型'
},
outflowTypes: {
transfer: '转入余额',
withdraw: '线下提现'
},
withdraw: {
button: '登记线下提现',
title: '登记线下提现',
user: '用户',
userPlaceholder: '输入邮箱或用户名搜索',
noUserFound: '未找到匹配的用户',
changeUser: '更换用户',
availableQuota: '当前可提取额度',
frozenHint: '冻结期内的返利不计入可提取额度',
amount: '提现金额(USD)',
amountHint: '填写已在站外实际打款给该用户的金额',
fillAll: '全部',
warning: '登记后将从该用户的可提取返利额度中扣除,且无法撤销。请确认已完成站外打款。',
submit: '确认登记',
submitting: '登记中...',
success: '已登记线下提现 {amount},剩余可提取 {remaining}',
replayed: '这笔线下提现此前已登记({amount}),本次未重复扣减;登记后剩余可提取 {remaining}',
uncertainHint: '上次提交没有收到结果,可能已经登记成功。用户与金额已锁定,重新提交会沿用同一笔登记,已登记时不会重复扣减。',
amountRequired: '请输入大于 0 的金额',
amountExceeds: '提现金额不能超过当前可提取额度'
}, },
overview: { overview: {
title: '用户返利概览', title: '用户返利概览',
@@ -12,6 +12,15 @@
<button class="btn btn-secondary px-2 md:px-3" :disabled="loading" :title="t('common.refresh')" @click="loadRecords"> <button class="btn btn-secondary px-2 md:px-3" :disabled="loading" :title="t('common.refresh')" @click="loadRecords">
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" /> <Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button> </button>
<button
v-if="props.type === 'transfers'"
type="button"
class="btn btn-primary ml-auto"
data-test="affiliate-withdraw-open"
@click="withdrawDialog = true"
>
{{ t('admin.affiliates.withdraw.button') }}
</button>
</div> </div>
</template> </template>
@@ -36,7 +45,9 @@
/> />
</template> </template>
<template #cell-invitee="{ row }"> <template #cell-invitee="{ row }">
<span v-if="row.invitee_id == null" class="text-sm text-gray-400 dark:text-dark-500">-</span>
<UserCell <UserCell
v-else
:id="row.invitee_id" :id="row.invitee_id"
:email="row.invitee_email" :email="row.invitee_email"
:username="row.invitee_username" :username="row.invitee_username"
@@ -57,29 +68,38 @@
<span class="font-mono text-sm text-gray-700 dark:text-gray-300">{{ row.aff_code || '-' }}</span> <span class="font-mono text-sm text-gray-700 dark:text-gray-300">{{ row.aff_code || '-' }}</span>
</template> </template>
<template #cell-order="{ row }"> <template #cell-order="{ row }">
<div class="space-y-0.5"> <span v-if="row.order_id == null" class="text-sm text-gray-400 dark:text-dark-500">-</span>
<div v-else class="space-y-0.5">
<div class="font-mono text-sm text-gray-900 dark:text-white">#{{ row.order_id }}</div> <div class="font-mono text-sm text-gray-900 dark:text-white">#{{ row.order_id }}</div>
<div class="max-w-56 truncate text-sm text-gray-500 dark:text-dark-400">{{ row.out_trade_no }}</div> <div class="max-w-56 truncate text-sm text-gray-500 dark:text-dark-400">{{ row.out_trade_no }}</div>
</div> </div>
</template> </template>
<template #cell-payment_type="{ row }"> <template #cell-payment_type="{ row }">
{{ t('payment.methods.' + row.payment_type, row.payment_type || '-') }} <template v-if="row.payment_type">{{ t('payment.methods.' + row.payment_type, row.payment_type) }}</template>
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
</template> </template>
<template #cell-order_status="{ row }"> <template #cell-order_status="{ row }">
<OrderStatusBadge :status="row.order_status" /> <OrderStatusBadge v-if="row.order_status" :status="row.order_status" />
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
</template> </template>
<template #cell-total_rebate="{ row }"> <template #cell-total_rebate="{ row }">
<AmountText :value="row.total_rebate" /> <AmountText :value="row.total_rebate" />
</template> </template>
<template #cell-order_amount="{ row }"> <template #cell-order_amount="{ row }">
<AmountText :value="row.order_amount" /> <NullableAmountText :value="row.order_amount" />
</template> </template>
<template #cell-pay_amount="{ row }"> <template #cell-pay_amount="{ row }">
<span class="text-sm text-gray-900 dark:text-white">¥{{ formatAmount(row.pay_amount) }}</span> <span v-if="row.pay_amount == null" class="text-sm text-gray-400 dark:text-dark-500">-</span>
<span v-else class="text-sm text-gray-900 dark:text-white">¥{{ formatAmount(row.pay_amount) }}</span>
</template> </template>
<template #cell-rebate_amount="{ row }"> <template #cell-rebate_amount="{ row }">
<AmountText :value="row.rebate_amount" strong /> <AmountText :value="row.rebate_amount" strong />
</template> </template>
<template #cell-action="{ row }">
<span :class="['badge whitespace-nowrap', row.action === 'withdraw' ? 'badge-warning' : 'badge-primary']">
{{ outflowTypeLabel(row.action) }}
</span>
</template>
<template #cell-amount="{ row }"> <template #cell-amount="{ row }">
<AmountText :value="row.amount" strong /> <AmountText :value="row.amount" strong />
</template> </template>
@@ -113,6 +133,13 @@
</template> </template>
</TablePageLayout> </TablePageLayout>
<AffiliateOfflineWithdrawDialog
v-if="props.type === 'transfers'"
:show="withdrawDialog"
@close="withdrawDialog = false"
@success="handleWithdrawSuccess"
/>
<BaseDialog <BaseDialog
:show="overviewDialog" :show="overviewDialog"
:title="t('admin.affiliates.overview.title')" :title="t('admin.affiliates.overview.title')"
@@ -151,6 +178,7 @@ import Pagination from '@/components/common/Pagination.vue'
import BaseDialog from '@/components/common/BaseDialog.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import OrderStatusBadge from '@/components/payment/OrderStatusBadge.vue' import OrderStatusBadge from '@/components/payment/OrderStatusBadge.vue'
import AffiliateOfflineWithdrawDialog from './AffiliateOfflineWithdrawDialog.vue'
import type { Column } from '@/components/common/types' import type { Column } from '@/components/common/types'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { affiliatesAPI, type AffiliateInviteRecord, type AffiliateRebateRecord, type AffiliateTransferRecord, type AffiliateUserOverview, type ListAffiliateRecordsParams } from '@/api/admin/affiliates' import { affiliatesAPI, type AffiliateInviteRecord, type AffiliateRebateRecord, type AffiliateTransferRecord, type AffiliateUserOverview, type ListAffiliateRecordsParams } from '@/api/admin/affiliates'
@@ -174,6 +202,7 @@ const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const overviewDialog = ref(false) const overviewDialog = ref(false)
const overviewLoading = ref(false) const overviewLoading = ref(false)
const selectedOverview = ref<AffiliateUserOverview | null>(null) const selectedOverview = ref<AffiliateUserOverview | null>(null)
const withdrawDialog = ref(false)
let debounceTimer: ReturnType<typeof setTimeout> | null = null let debounceTimer: ReturnType<typeof setTimeout> | null = null
const columns = computed<Column[]>(() => { const columns = computed<Column[]>(() => {
@@ -201,6 +230,7 @@ const columns = computed<Column[]>(() => {
} }
return [ return [
{ key: 'user', label: t('admin.affiliates.records.user'), sortable: true }, { key: 'user', label: t('admin.affiliates.records.user'), sortable: true },
{ key: 'action', label: t('admin.affiliates.records.outflowType'), sortable: true },
{ key: 'amount', label: t('admin.affiliates.records.transferAmount'), sortable: true }, { key: 'amount', label: t('admin.affiliates.records.transferAmount'), sortable: true },
{ key: 'balance_after', label: t('admin.affiliates.records.balanceAfter'), sortable: true }, { key: 'balance_after', label: t('admin.affiliates.records.balanceAfter'), sortable: true },
{ key: 'available_quota_after', label: t('admin.affiliates.records.availableQuotaAfter'), sortable: true }, { key: 'available_quota_after', label: t('admin.affiliates.records.availableQuotaAfter'), sortable: true },
@@ -303,6 +333,17 @@ function handleSort(key: string, order: 'asc' | 'desc') {
void loadRecords() void loadRecords()
} }
function handleWithdrawSuccess() {
withdrawDialog.value = false
reloadFromFirstPage()
}
function outflowTypeLabel(action: string | null | undefined): string {
return action === 'withdraw'
? t('admin.affiliates.outflowTypes.withdraw')
: t('admin.affiliates.outflowTypes.transfer')
}
function formatAmount(value: number | null | undefined): string { function formatAmount(value: number | null | undefined): string {
return Number(value || 0).toFixed(2) return Number(value || 0).toFixed(2)
} }
@@ -0,0 +1,357 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.affiliates.withdraw.title')"
width="normal"
@close="handleClose"
>
<form class="space-y-4" @submit.prevent="submit">
<div>
<label class="input-label">{{ t('admin.affiliates.withdraw.user') }}</label>
<div
v-if="selectedUser"
class="flex items-center justify-between gap-3 rounded-md border border-primary-200 bg-primary-50 px-3 py-2 dark:border-primary-700/50 dark:bg-primary-900/20"
data-test="withdraw-selected-user"
>
<div class="min-w-0 truncate text-sm">
<span class="font-mono text-gray-500 dark:text-dark-400">#{{ selectedUser.id }}</span>
<span class="ml-2 font-medium text-gray-900 dark:text-white">{{ selectedUser.email }}</span>
<span v-if="selectedUser.username" class="ml-1 text-xs text-gray-500 dark:text-dark-400">({{ selectedUser.username }})</span>
</div>
<button
type="button"
class="text-lg leading-none text-gray-400 hover:text-red-600 disabled:opacity-50"
:title="t('admin.affiliates.withdraw.changeUser')"
:disabled="submitting || outcomeUncertain"
data-test="withdraw-clear-user"
@click="clearUser"
>
×
</button>
</div>
<template v-else>
<input
v-model="userQuery"
type="text"
class="input"
:placeholder="t('admin.affiliates.withdraw.userPlaceholder')"
data-test="withdraw-user-search"
@input="onUserQueryInput"
/>
<div
v-if="userResults.length > 0"
class="mt-1 max-h-40 overflow-y-auto rounded border border-gray-200 dark:border-dark-700"
>
<button
v-for="user in userResults"
:key="user.id"
type="button"
class="w-full px-3 py-1.5 text-left text-sm text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-dark-800"
data-test="withdraw-user-option"
@click="selectUser(user)"
>
{{ user.email }} <span class="text-xs text-gray-500 dark:text-dark-400">({{ user.username }})</span>
</button>
</div>
<p v-else-if="searched" class="input-hint">{{ t('admin.affiliates.withdraw.noUserFound') }}</p>
</template>
</div>
<div
v-if="selectedUser"
class="rounded-lg border border-gray-100 bg-gray-50 p-3 dark:border-dark-700 dark:bg-dark-800"
>
<div class="flex items-center justify-between text-sm">
<span class="text-gray-500 dark:text-dark-400">{{ t('admin.affiliates.withdraw.availableQuota') }}</span>
<span
v-if="overviewLoading"
class="h-4 w-4 animate-spin rounded-full border-2 border-primary-500 border-t-transparent"
></span>
<span v-else class="font-semibold text-gray-900 dark:text-white" data-test="withdraw-available-quota">
${{ formatPreciseAmount(availableQuota) }}
</span>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-dark-400">{{ t('admin.affiliates.withdraw.frozenHint') }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.affiliates.withdraw.amount') }}</label>
<div class="flex gap-2">
<input
v-model="amount"
type="number"
min="0"
step="any"
inputmode="decimal"
class="input"
:disabled="!selectedUser || overviewLoading || submitting || outcomeUncertain"
data-test="withdraw-amount"
/>
<button
type="button"
class="btn btn-secondary shrink-0"
:disabled="!selectedUser || overviewLoading || submitting || outcomeUncertain || availableQuota <= 0"
data-test="withdraw-fill-all"
@click="fillAll"
>
{{ t('admin.affiliates.withdraw.fillAll') }}
</button>
</div>
<p v-if="amountError" class="input-error-text" data-test="withdraw-amount-error">{{ amountError }}</p>
<p v-else class="input-hint">{{ t('admin.affiliates.withdraw.amountHint') }}</p>
</div>
<div
v-if="outcomeUncertain"
class="rounded-lg border border-blue-200 bg-blue-50 p-3 text-sm text-blue-800 dark:border-blue-700/50 dark:bg-blue-900/20 dark:text-blue-300"
data-test="withdraw-uncertain-hint"
>
{{ t('admin.affiliates.withdraw.uncertainHint') }}
</div>
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-700/50 dark:bg-amber-900/20 dark:text-amber-300">
{{ t('admin.affiliates.withdraw.warning') }}
</div>
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button type="button" class="btn btn-secondary" :disabled="submitting" @click="handleClose">
{{ t('common.cancel') }}
</button>
<button
type="button"
class="btn btn-primary"
:disabled="!canSubmit"
data-test="withdraw-submit"
@click="submit"
>
{{ submitting ? t('admin.affiliates.withdraw.submitting') : t('admin.affiliates.withdraw.submit') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import { useAppStore } from '@/stores/app'
import { affiliatesAPI, type AffiliateWithdrawResult, type SimpleUser } from '@/api/admin/affiliates'
import { extractApiErrorCode, extractI18nErrorMessage } from '@/utils/apiError'
import {
completeAffiliateWithdrawOperation,
prepareAffiliateWithdrawOperation,
type AffiliateWithdrawOperation,
} from './affiliateWithdrawOperation'
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits<{
close: []
success: [result: AffiliateWithdrawResult]
}>()
// 返利额度按 8 位小数记账,金额比较在同一精度上进行。
const LEDGER_SCALE = 1e8
const { t } = useI18n()
const appStore = useAppStore()
const userQuery = ref('')
const userResults = ref<SimpleUser[]>([])
const searched = ref(false)
const selectedUser = ref<SimpleUser | null>(null)
const availableQuota = ref(0)
const overviewLoading = ref(false)
const amount = ref<number | string>('')
const submitting = ref(false)
// 已提交、尚未拿到确定结果的登记。结果未知时锁定用户与金额,重试沿用它的幂等键。
const pendingOperation = ref<AffiliateWithdrawOperation | null>(null)
const outcomeUncertain = computed(() => pendingOperation.value?.outcomeUncertain === true)
let searchTimer: ReturnType<typeof setTimeout> | null = null
let searchSeq = 0
let overviewSeq = 0
const amountText = computed(() => String(amount.value ?? '').trim())
const parsedAmount = computed(() => (amountText.value === '' ? Number.NaN : Number(amountText.value)))
const amountError = computed(() => {
if (outcomeUncertain.value || !selectedUser.value || overviewLoading.value || amountText.value === '') return ''
const value = parsedAmount.value
if (!Number.isFinite(value) || Math.round(value * LEDGER_SCALE) <= 0) {
return t('admin.affiliates.withdraw.amountRequired')
}
if (Math.round(value * LEDGER_SCALE) > Math.round(availableQuota.value * LEDGER_SCALE)) {
return t('admin.affiliates.withdraw.amountExceeds')
}
return ''
})
// 结果未知的登记可能已经扣减,重试不受刷新后的可提取额度约束。
const canSubmit = computed(() => {
if (selectedUser.value === null || submitting.value) return false
if (outcomeUncertain.value) return true
return !overviewLoading.value && amountText.value !== '' && amountError.value === ''
})
function formatPreciseAmount(value: number): string {
const [intPart, fraction = ''] = Number(value || 0).toFixed(8).replace(/0+$/, '').split('.')
return `${intPart}.${fraction.padEnd(2, '0')}`
}
function showError(error: unknown) {
appStore.showError(extractI18nErrorMessage(error, t, 'admin.affiliates.errors', t('common.error')))
}
function clearSearchTimer() {
if (searchTimer) {
clearTimeout(searchTimer)
searchTimer = null
}
}
function onUserQueryInput() {
clearSearchTimer()
searched.value = false
const query = userQuery.value.trim()
if (!query) {
searchSeq++
userResults.value = []
return
}
searchTimer = setTimeout(() => {
void searchUsers(query)
}, 300)
}
async function searchUsers(query: string) {
const seq = ++searchSeq
try {
const users = await affiliatesAPI.lookupUsers(query)
if (seq !== searchSeq) return
userResults.value = users || []
searched.value = true
} catch (error) {
if (seq !== searchSeq) return
showError(error)
}
}
async function loadAvailableQuota(userId: number) {
const seq = ++overviewSeq
overviewLoading.value = true
try {
const overview = await affiliatesAPI.getUserOverview(userId)
if (seq !== overviewSeq) return
availableQuota.value = Number(overview.available_quota || 0)
} catch (error) {
if (seq !== overviewSeq) return
availableQuota.value = 0
// 从未产生过邀请返利的用户没有返利档案,概览接口返回 USER_NOT_FOUND,可提取额度即为 0。
if (extractApiErrorCode(error) === 'USER_NOT_FOUND') return
if (!outcomeUncertain.value) selectedUser.value = null
showError(error)
} finally {
if (seq === overviewSeq) overviewLoading.value = false
}
}
function selectUser(user: SimpleUser) {
clearSearchTimer()
searchSeq++
selectedUser.value = user
userQuery.value = ''
userResults.value = []
searched.value = false
availableQuota.value = 0
amount.value = ''
void loadAvailableQuota(user.id)
}
function clearUser() {
overviewSeq++
selectedUser.value = null
availableQuota.value = 0
overviewLoading.value = false
amount.value = ''
}
function fillAll() {
amount.value = Math.round(availableQuota.value * LEDGER_SCALE) / LEDGER_SCALE
}
function resetForm() {
clearSearchTimer()
searchSeq++
overviewSeq++
userQuery.value = ''
userResults.value = []
searched.value = false
selectedUser.value = null
availableQuota.value = 0
overviewLoading.value = false
amount.value = ''
submitting.value = false
pendingOperation.value = null
}
function handleClose() {
if (submitting.value) return
emit('close')
}
async function submit() {
const user = selectedUser.value
if (!user || !canSubmit.value) return
if (!pendingOperation.value) {
pendingOperation.value = prepareAffiliateWithdrawOperation(user.id, parsedAmount.value)
}
const operation = pendingOperation.value
submitting.value = true
try {
const { result, replayed } = await affiliatesAPI.withdrawUserQuota(
operation.userId,
{ amount: operation.amount },
operation.key,
)
completeAffiliateWithdrawOperation(operation)
pendingOperation.value = null
appStore.showSuccess(
t(replayed ? 'admin.affiliates.withdraw.replayed' : 'admin.affiliates.withdraw.success', {
amount: `$${formatPreciseAmount(result.amount)}`,
remaining: `$${formatPreciseAmount(result.available_quota_after)}`,
}),
)
emit('success', result)
} catch (error) {
showError(error)
// 网络错误、超时、5xx 与 408/409 时服务端可能已经登记:保留这笔登记,
// 重试沿用同一请求与幂等键,直到拿到确定结果。
const status = (error as { status?: number } | null)?.status
if (!operation.outcomeUncertain && status && status >= 400 && status < 500 && status !== 408 && status !== 409) {
completeAffiliateWithdrawOperation(operation)
pendingOperation.value = null
} else {
operation.outcomeUncertain = true
}
void loadAvailableQuota(operation.userId)
} finally {
submitting.value = false
}
}
watch(
() => props.show,
(show) => {
if (show) resetForm()
},
{ immediate: true },
)
onBeforeUnmount(clearSearchTimer)
</script>
@@ -0,0 +1,179 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AdminAffiliateRecordsTable from '../AdminAffiliateRecordsTable.vue'
const { listInviteRecords, listRebateRecords, listTransferRecords, getUserOverview } = vi.hoisted(() => ({
listInviteRecords: vi.fn(),
listRebateRecords: vi.fn(),
listTransferRecords: vi.fn(),
getUserOverview: vi.fn(),
}))
vi.mock('@/api/admin/affiliates', () => {
const affiliatesAPI = { listInviteRecords, listRebateRecords, listTransferRecords, getUserOverview }
return { affiliatesAPI, default: affiliatesAPI }
})
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ showError: vi.fn(), showSuccess: vi.fn() }),
}))
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-i18n')>()
return {
...actual,
useI18n: () => ({
t: (key: string) => key,
}),
}
})
const TablePageLayoutStub = {
template: '<div><slot name="filters" /><slot name="table" /><slot name="pagination" /></div>',
}
const DataTableStub = {
props: ['columns', 'data'],
template: `
<table>
<tr v-for="(row, index) in data" :key="index" :data-test="'row-' + index">
<td v-for="column in columns" :key="column.key" :data-test="'cell-' + column.key">
<slot :name="'cell-' + column.key" :row="row" />
</td>
</tr>
</table>
`,
}
const WithdrawDialogStub = {
name: 'AffiliateOfflineWithdrawDialog',
props: ['show'],
emits: ['close', 'success'],
template: '<div data-test="withdraw-dialog" :data-show="String(show)" />',
}
function mountTable(type: 'invites' | 'rebates' | 'transfers') {
return mount(AdminAffiliateRecordsTable, {
props: { type },
global: {
stubs: {
AppLayout: { template: '<main><slot /></main>' },
TablePageLayout: TablePageLayoutStub,
DataTable: DataTableStub,
Pagination: true,
Icon: true,
OrderStatusBadge: true,
BaseDialog: true,
AffiliateOfflineWithdrawDialog: WithdrawDialogStub,
},
},
})
}
function page<T>(items: T[]) {
return { items, total: items.length, page: 1, page_size: 20, pages: 1 }
}
describe('AdminAffiliateRecordsTable', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
})
it('renders non-order rebate accruals with empty order fields', async () => {
listRebateRecords.mockResolvedValue(page([
{
order_id: null,
out_trade_no: '',
inviter_id: 1,
inviter_email: 'inviter@example.com',
inviter_username: 'inviter',
invitee_id: 2,
invitee_email: 'invitee@example.com',
invitee_username: 'invitee',
order_amount: null,
pay_amount: null,
rebate_amount: 2,
payment_type: '',
order_status: '',
created_at: '2026-09-17T08:00:00Z',
},
]))
const wrapper = mountTable('rebates')
await flushPromises()
const row = wrapper.get('[data-test="row-0"]')
expect(row.get('[data-test="cell-order"]').text()).toBe('-')
expect(row.get('[data-test="cell-order_amount"]').text()).toBe('-')
expect(row.get('[data-test="cell-pay_amount"]').text()).toBe('-')
expect(row.get('[data-test="cell-payment_type"]').text()).toBe('-')
expect(row.get('[data-test="cell-order_status"]').text()).toBe('-')
expect(row.get('[data-test="cell-rebate_amount"]').text()).toBe('$2.00')
expect(wrapper.find('[data-test="affiliate-withdraw-open"]').exists()).toBe(false)
})
it('lists offline withdrawals next to balance transfers with their type', async () => {
listTransferRecords.mockResolvedValue(page([
{
ledger_id: 11,
action: 'withdraw',
user_id: 42,
user_email: 'inviter@example.com',
username: 'inviter',
amount: 12.5,
balance_after: 5.5,
available_quota_after: 7.5,
frozen_quota_after: 0,
history_quota_after: 30,
snapshot_available: true,
created_at: '2026-09-17T08:00:00Z',
},
{
ledger_id: 10,
action: 'transfer',
user_id: 42,
user_email: 'inviter@example.com',
username: 'inviter',
amount: 3,
balance_after: 5.5,
available_quota_after: 20,
frozen_quota_after: 0,
history_quota_after: 30,
snapshot_available: true,
created_at: '2026-09-16T08:00:00Z',
},
]))
const wrapper = mountTable('transfers')
await flushPromises()
const withdrawRow = wrapper.get('[data-test="row-0"]')
expect(withdrawRow.get('[data-test="cell-action"]').text()).toBe('admin.affiliates.outflowTypes.withdraw')
expect(withdrawRow.get('[data-test="cell-amount"]').text()).toBe('$12.50')
const transferRow = wrapper.get('[data-test="row-1"]')
expect(transferRow.get('[data-test="cell-action"]').text()).toBe('admin.affiliates.outflowTypes.transfer')
})
it('opens the offline withdrawal dialog and reloads the first page after recording', async () => {
listTransferRecords.mockResolvedValue(page([]))
const wrapper = mountTable('transfers')
await flushPromises()
expect(listTransferRecords).toHaveBeenCalledTimes(1)
const button = wrapper.get('[data-test="affiliate-withdraw-open"]')
expect(button.text()).toBe('admin.affiliates.withdraw.button')
await button.trigger('click')
expect(wrapper.get('[data-test="withdraw-dialog"]').attributes('data-show')).toBe('true')
wrapper.findComponent(WithdrawDialogStub).vm.$emit('success', { ledger_id: 12 })
await flushPromises()
expect(wrapper.get('[data-test="withdraw-dialog"]').attributes('data-show')).toBe('false')
expect(listTransferRecords).toHaveBeenCalledTimes(2)
expect(listTransferRecords.mock.calls[1][0]).toMatchObject({ page: 1 })
})
})
@@ -0,0 +1,225 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AffiliateOfflineWithdrawDialog from '../AffiliateOfflineWithdrawDialog.vue'
const { lookupUsers, getUserOverview, withdrawUserQuota, showError, showSuccess } = vi.hoisted(() => ({
lookupUsers: vi.fn(),
getUserOverview: vi.fn(),
withdrawUserQuota: vi.fn(),
showError: vi.fn(),
showSuccess: vi.fn(),
}))
vi.mock('@/api/admin/affiliates', () => ({
affiliatesAPI: { lookupUsers, getUserOverview, withdrawUserQuota },
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ showError, showSuccess }),
}))
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-i18n')>()
return {
...actual,
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) =>
params ? `${key} ${JSON.stringify(params)}` : key,
}),
}
})
const BaseDialogStub = {
props: ['show', 'title'],
emits: ['close'],
template: '<div v-if="show"><slot /><slot name="footer" /></div>',
}
function mountDialog() {
return mount(AffiliateOfflineWithdrawDialog, {
props: { show: true },
global: { stubs: { BaseDialog: BaseDialogStub } },
})
}
function overviewWithQuota(availableQuota: number) {
return {
user_id: 42,
email: 'inviter@example.com',
username: 'inviter',
aff_code: 'INVITER',
rebate_rate_percent: 20,
invited_count: 3,
rebated_invitee_count: 2,
available_quota: availableQuota,
history_quota: 50,
}
}
function withdrawResponse(amount: number, availableAfter: number, replayed = false) {
return {
result: {
ledger_id: 9,
user_id: 42,
amount,
available_quota_after: availableAfter,
frozen_quota_after: 0,
history_quota_after: 50,
},
replayed,
}
}
async function pickUser(wrapper: VueWrapper, availableQuota: number) {
lookupUsers.mockResolvedValue([{ id: 42, email: 'inviter@example.com', username: 'inviter' }])
getUserOverview.mockResolvedValue(overviewWithQuota(availableQuota))
await wrapper.get('[data-test="withdraw-user-search"]').setValue('inviter')
vi.advanceTimersByTime(300)
await flushPromises()
await wrapper.get('[data-test="withdraw-user-option"]').trigger('click')
await flushPromises()
}
describe('AffiliateOfflineWithdrawDialog', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
})
afterEach(() => {
vi.useRealTimers()
})
it('records the exact available quota as the withdrawal amount', async () => {
const wrapper = mountDialog()
await pickUser(wrapper, 12.34567891)
expect(lookupUsers).toHaveBeenCalledWith('inviter')
expect(getUserOverview).toHaveBeenCalledWith(42)
expect(wrapper.get('[data-test="withdraw-available-quota"]').text()).toBe('$12.34567891')
await wrapper.get('[data-test="withdraw-fill-all"]').trigger('click')
expect((wrapper.get('[data-test="withdraw-amount"]').element as HTMLInputElement).value).toBe('12.34567891')
withdrawUserQuota.mockResolvedValue(withdrawResponse(12.34567891, 0))
await wrapper.get('[data-test="withdraw-submit"]').trigger('click')
await flushPromises()
expect(withdrawUserQuota).toHaveBeenCalledWith(42, { amount: 12.34567891 }, expect.stringMatching(/^affiliate-withdraw-/))
expect(showSuccess).toHaveBeenCalledWith(
'admin.affiliates.withdraw.success {"amount":"$12.34567891","remaining":"$0.00"}',
)
expect(wrapper.emitted('success')?.[0]?.[0]).toMatchObject({ ledger_id: 9 })
})
it('blocks amounts above the available quota', async () => {
const wrapper = mountDialog()
await pickUser(wrapper, 10)
await wrapper.get('[data-test="withdraw-amount"]').setValue('10.00000001')
expect(wrapper.get('[data-test="withdraw-amount-error"]').text()).toBe('admin.affiliates.withdraw.amountExceeds')
expect(wrapper.get('[data-test="withdraw-submit"]').attributes('disabled')).toBeDefined()
await wrapper.get('[data-test="withdraw-submit"]').trigger('click')
await flushPromises()
expect(withdrawUserQuota).not.toHaveBeenCalled()
})
it('treats a user without an affiliate profile as having no available quota', async () => {
lookupUsers.mockResolvedValue([{ id: 7, email: 'fresh@example.com', username: 'fresh' }])
getUserOverview.mockRejectedValue({ reason: 'USER_NOT_FOUND', message: 'user not found' })
const wrapper = mountDialog()
await wrapper.get('[data-test="withdraw-user-search"]').setValue('fresh')
vi.advanceTimersByTime(300)
await flushPromises()
await wrapper.get('[data-test="withdraw-user-option"]').trigger('click')
await flushPromises()
expect(showError).not.toHaveBeenCalled()
expect(wrapper.get('[data-test="withdraw-selected-user"]').text()).toContain('fresh@example.com')
expect(wrapper.get('[data-test="withdraw-available-quota"]').text()).toBe('$0.00')
expect(wrapper.get('[data-test="withdraw-fill-all"]').attributes('disabled')).toBeDefined()
await wrapper.get('[data-test="withdraw-amount"]').setValue('1')
expect(wrapper.get('[data-test="withdraw-amount-error"]').text()).toBe('admin.affiliates.withdraw.amountExceeds')
expect(wrapper.get('[data-test="withdraw-submit"]').attributes('disabled')).toBeDefined()
})
it('shows the API error and reloads the available quota when recording fails', async () => {
const wrapper = mountDialog()
await pickUser(wrapper, 10)
withdrawUserQuota.mockRejectedValue({ status: 400, reason: 'AFFILIATE_QUOTA_INSUFFICIENT', message: 'insufficient' })
await wrapper.get('[data-test="withdraw-amount"]').setValue('5')
await wrapper.get('[data-test="withdraw-submit"]').trigger('click')
await flushPromises()
expect(showError).toHaveBeenCalledWith('admin.affiliates.errors.AFFILIATE_QUOTA_INSUFFICIENT {}')
expect(getUserOverview).toHaveBeenCalledTimes(2)
expect(wrapper.emitted('success')).toBeUndefined()
})
it.each([
['a network error', { status: 0, message: 'Network error. Please check your connection.' }],
['a gateway error', { status: 502, message: 'Bad Gateway' }],
['a request timeout', { status: 408, message: 'Request Timeout' }],
])('retries the same registration with the same key after %s', async (_label, failure) => {
const wrapper = mountDialog()
await pickUser(wrapper, 10)
await wrapper.get('[data-test="withdraw-fill-all"]').trigger('click')
// 服务端其实已登记,只是响应丢失:刷新后的可提取额度已扣到 0。
withdrawUserQuota.mockRejectedValueOnce(failure)
getUserOverview.mockResolvedValue(overviewWithQuota(0))
await wrapper.get('[data-test="withdraw-submit"]').trigger('click')
await flushPromises()
expect(showError).toHaveBeenCalledTimes(1)
expect(wrapper.get('[data-test="withdraw-available-quota"]').text()).toBe('$0.00')
expect(wrapper.get('[data-test="withdraw-uncertain-hint"]').text()).toBe('admin.affiliates.withdraw.uncertainHint')
expect(wrapper.get('[data-test="withdraw-amount"]').attributes('disabled')).toBeDefined()
expect(wrapper.get('[data-test="withdraw-fill-all"]').attributes('disabled')).toBeDefined()
expect(wrapper.get('[data-test="withdraw-clear-user"]').attributes('disabled')).toBeDefined()
expect(wrapper.find('[data-test="withdraw-amount-error"]').exists()).toBe(false)
expect(wrapper.get('[data-test="withdraw-submit"]').attributes('disabled')).toBeUndefined()
withdrawUserQuota.mockResolvedValueOnce(withdrawResponse(10, 0, true))
await wrapper.get('[data-test="withdraw-submit"]').trigger('click')
await flushPromises()
expect(withdrawUserQuota).toHaveBeenCalledTimes(2)
const [firstCall, retryCall] = withdrawUserQuota.mock.calls
expect(firstCall).toEqual([42, { amount: 10 }, expect.stringMatching(/^affiliate-withdraw-/)])
expect(retryCall).toEqual(firstCall)
expect(showSuccess).toHaveBeenCalledWith(
'admin.affiliates.withdraw.replayed {"amount":"$10.00","remaining":"$0.00"}',
)
expect(wrapper.emitted('success')).toHaveLength(1)
})
it('starts a new registration with a fresh key after a definite rejection', async () => {
const wrapper = mountDialog()
await pickUser(wrapper, 10)
await wrapper.get('[data-test="withdraw-amount"]').setValue('6')
withdrawUserQuota.mockRejectedValueOnce({ status: 400, reason: 'AFFILIATE_QUOTA_INSUFFICIENT', message: 'insufficient' })
await wrapper.get('[data-test="withdraw-submit"]').trigger('click')
await flushPromises()
expect(wrapper.find('[data-test="withdraw-uncertain-hint"]').exists()).toBe(false)
expect(wrapper.get('[data-test="withdraw-amount"]').attributes('disabled')).toBeUndefined()
await wrapper.get('[data-test="withdraw-amount"]').setValue('5')
withdrawUserQuota.mockResolvedValueOnce(withdrawResponse(5, 5))
await wrapper.get('[data-test="withdraw-submit"]').trigger('click')
await flushPromises()
expect(withdrawUserQuota).toHaveBeenCalledTimes(2)
const [rejectedCall, nextCall] = withdrawUserQuota.mock.calls
expect(nextCall[1]).toEqual({ amount: 5 })
expect(nextCall[2]).not.toBe(rejectedCall[2])
expect(showSuccess).toHaveBeenCalledWith('admin.affiliates.withdraw.success {"amount":"$5.00","remaining":"$5.00"}')
})
})
@@ -0,0 +1,50 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { completeAffiliateWithdrawOperation, prepareAffiliateWithdrawOperation } from '../affiliateWithdrawOperation'
let adminId = 2000
beforeEach(() => {
localStorage.setItem('auth_user', JSON.stringify({ id: ++adminId }))
sessionStorage.clear()
})
describe('affiliate offline withdrawal operation identity', () => {
it('keeps the key of an unfinished registration for the same user and ledger amount', () => {
const first = prepareAffiliateWithdrawOperation(42, 10)
const retry = prepareAffiliateWithdrawOperation(42, 10.000000001)
expect(first.outcomeUncertain).toBe(false)
expect(first.key).toMatch(new RegExp(`^affiliate-withdraw-${adminId}-`))
expect(retry.key).toBe(first.key)
expect(retry.amount).toBe(10)
expect(retry.outcomeUncertain).toBe(true)
completeAffiliateWithdrawOperation(retry)
const next = prepareAffiliateWithdrawOperation(42, 10)
expect(next.key).not.toBe(first.key)
expect(next.outcomeUncertain).toBe(false)
})
it('isolates different users, amounts and administrators', () => {
const first = prepareAffiliateWithdrawOperation(42, 10)
const differentAmount = prepareAffiliateWithdrawOperation(42, 10.5)
const differentUser = prepareAffiliateWithdrawOperation(43, 10)
localStorage.setItem('auth_user', JSON.stringify({ id: ++adminId }))
const differentAdmin = prepareAffiliateWithdrawOperation(42, 10)
expect(new Set([first, differentAmount, differentUser, differentAdmin].map(operation => operation.key)).size).toBe(4)
})
it('resumes a key stored by an earlier page and clears it after completion', () => {
const scope = `sub2api:admin:affiliate-withdraw:${adminId}:42:12.5`
sessionStorage.setItem(scope, 'saved-withdraw-key')
const resumed = prepareAffiliateWithdrawOperation(42, 12.5)
expect(resumed.key).toBe('saved-withdraw-key')
expect(resumed.outcomeUncertain).toBe(true)
completeAffiliateWithdrawOperation(resumed)
expect(sessionStorage.getItem(scope)).toBeNull()
expect(prepareAffiliateWithdrawOperation(42, 12.5).key).not.toBe('saved-withdraw-key')
})
})
@@ -0,0 +1,68 @@
/** 一笔线下提现登记及标识它的幂等键。 */
export interface AffiliateWithdrawOperation {
userId: number
amount: number
key: string
storageKey: string | null
outcomeUncertain: boolean
}
// 返利额度按 8 位小数记账。
const LEDGER_SCALE = 1e8
const pendingKeys = new Map<string, string>()
function currentAdminId(): number | null {
try {
const user = JSON.parse(globalThis.localStorage?.getItem('auth_user') ?? 'null') as { id?: unknown } | null
const id = user?.id
return typeof id === 'number' && Number.isSafeInteger(id) && id > 0 ? id : null
} catch {
return null
}
}
function readStoredKey(storageKey: string): string | null {
try {
return globalThis.sessionStorage?.getItem(storageKey) ?? null
} catch {
return null
}
}
function storeKey(storageKey: string, key: string | null) {
try {
if (key) globalThis.sessionStorage?.setItem(storageKey, key)
else globalThis.sessionStorage?.removeItem(storageKey)
} catch {
// 浏览器存储不可用时,同一页面内的重试仍由内存中的键保护。
}
}
/**
* 取得该用户与金额对应的登记。结果未知的登记在完成前一直保留它的键,
* 再次提交同一用户与金额时沿用该键,服务端据此返回首次结果而不重复扣减。
*/
export function prepareAffiliateWithdrawOperation(userId: number, amount: number): AffiliateWithdrawOperation {
const normalizedAmount = Math.round(amount * LEDGER_SCALE) / LEDGER_SCALE
const adminId = currentAdminId()
const storageKey = adminId ? `sub2api:admin:affiliate-withdraw:${adminId}:${userId}:${normalizedAmount}` : null
let key = storageKey ? pendingKeys.get(storageKey) ?? readStoredKey(storageKey) : null
const outcomeUncertain = !!key
if (!key) {
const requestId = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
key = `affiliate-withdraw-${adminId ?? 'unknown'}-${requestId}`
}
if (storageKey) {
pendingKeys.set(storageKey, key)
storeKey(storageKey, key)
}
return { userId, amount: normalizedAmount, key, storageKey, outcomeUncertain }
}
/** 登记有了确定结果后释放它的键。 */
export function completeAffiliateWithdrawOperation(operation: AffiliateWithdrawOperation) {
if (!operation.storageKey) return
pendingKeys.delete(operation.storageKey)
storeKey(operation.storageKey, null)
}