diff --git a/backend/internal/handler/admin/affiliate_handler.go b/backend/internal/handler/admin/affiliate_handler.go index d443d344d2..a5ed9e1cc0 100644 --- a/backend/internal/handler/admin/affiliate_handler.go +++ b/backend/internal/handler/admin/affiliate_handler.go @@ -201,6 +201,42 @@ func (h *AffiliateHandler) GetUserOverview(c *gin.Context) { 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. // GET /api/v1/admin/affiliates/invites 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) } -// 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 func (h *AffiliateHandler) ListRebateRecords(c *gin.Context) { 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) } -// 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 func (h *AffiliateHandler) ListTransferRecords(c *gin.Context) { page, pageSize := response.ParsePagination(c) diff --git a/backend/internal/handler/admin/affiliate_handler_withdraw_test.go b/backend/internal/handler/admin/affiliate_handler_withdraw_test.go new file mode 100644 index 0000000000..0d4344fde5 --- /dev/null +++ b/backend/internal/handler/admin/affiliate_handler_withdraw_test.go @@ -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) + }) + } +} diff --git a/backend/internal/handler/auth_email_oauth_test.go b/backend/internal/handler/auth_email_oauth_test.go index 493b509989..0a6b653ec6 100644 --- a/backend/internal/handler/auth_email_oauth_test.go +++ b/backend/internal/handler/auth_email_oauth_test.go @@ -407,6 +407,10 @@ func (r *oauthEmailAffiliateRepoStub) TransferQuotaToBalance(context.Context, in 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) { panic("unexpected ListInvitees call") } diff --git a/backend/internal/repository/affiliate_repo.go b/backend/internal/repository/affiliate_repo.go index 61da539e25..4e2eaaf3d4 100644 --- a/backend/internal/repository/affiliate_repo.go +++ b/backend/internal/repository/affiliate_repo.go @@ -341,6 +341,171 @@ VALUES ($1, 'transfer', $2, NULL, $3, $4, $5, $6, NOW(), NOW())`, 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) { if limit <= 0 { limit = 100 @@ -466,13 +631,14 @@ func (r *affiliateRepository) ListAffiliateRebateRecords(ctx context.Context, fi "inviter.email", "inviter.username", "invitee.email", "invitee.username", "po.id::text", "po.out_trade_no", "po.payment_type", "po.status", }) + // 返利记录列出全部 accrue 流水:非订单来源的流水没有订单,被邀请人账号 + // 删除后 source_user_id 为 NULL,这两类行都保留。 baseJoin := ` 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 -WHERE ual.action = 'accrue' - AND ual.source_order_id IS NOT NULL` +LEFT JOIN users invitee ON invitee.id = ual.source_user_id +LEFT JOIN payment_orders po ON po.id = ual.source_order_id +WHERE ual.action = 'accrue'` if where != "" { 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) rows, err := client.QueryContext(ctx, ` SELECT po.id, - po.out_trade_no, + COALESCE(po.out_trade_no, ''), ual.user_id, COALESCE(inviter.email, ''), COALESCE(inviter.username, ''), @@ -506,8 +672,8 @@ SELECT po.id, po.amount::double precision, po.pay_amount::double precision, ual.amount::double precision, - po.payment_type, - po.status, + COALESCE(po.payment_type, ''), + COALESCE(po.status, ''), ual.created_at `+baseJoin+where+` `+orderBy+` @@ -520,17 +686,21 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...) items := make([]service.AffiliateRebateRecord, 0) for rows.Next() { var item service.AffiliateRebateRecord + var orderID sql.NullInt64 + var inviteeID sql.NullInt64 + var orderAmount sql.NullFloat64 + var payAmount sql.NullFloat64 if err := rows.Scan( - &item.OrderID, + &orderID, &item.OutTradeNo, &item.InviterID, &item.InviterEmail, &item.InviterUsername, - &item.InviteeID, + &inviteeID, &item.InviteeEmail, &item.InviteeUsername, - &item.OrderAmount, - &item.PayAmount, + &orderAmount, + &payAmount, &item.RebateAmount, &item.PaymentType, &item.OrderStatus, @@ -538,6 +708,10 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...) ); err != nil { return nil, 0, err } + item.OrderID = nullableInt64Ptr(orderID) + item.InviteeID = nullableInt64Ptr(inviteeID) + item.OrderAmount = nullableFloat64Ptr(orderAmount) + item.PayAmount = nullableFloat64Ptr(payAmount) items = append(items, item) } if err := rows.Err(); err != nil { @@ -554,7 +728,7 @@ func (r *affiliateRepository) ListAffiliateTransferRecords(ctx context.Context, baseJoin := ` FROM user_affiliate_ledger ual JOIN users u ON u.id = ual.user_id -WHERE ual.action = 'transfer'` +WHERE ual.action IN ('transfer', 'withdraw')` if where != "" { where = strings.Replace(where, "WHERE ", " AND ", 1) } @@ -566,6 +740,7 @@ WHERE ual.action = 'transfer'` orderBy := buildAffiliateRecordOrderBy(filter, map[string]string{ "user": "u.email", + "action": "ual.action", "amount": "ual.amount", "balance_after": "ual.balance_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) rows, err := client.QueryContext(ctx, ` SELECT ual.id, + ual.action, ual.user_id, COALESCE(u.email, ''), COALESCE(u.username, ''), @@ -602,6 +778,7 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...) var historyQuotaAfter sql.NullFloat64 if err := rows.Scan( &item.LedgerID, + &item.Action, &item.UserID, &item.UserEmail, &item.Username, @@ -958,6 +1135,13 @@ func nullableFloat64Ptr(v sql.NullFloat64) *float64 { return &v.Float64 } +func nullableInt64Ptr(v sql.NullInt64) *int64 { + if !v.Valid { + return nil + } + return &v.Int64 +} + func generateAffiliateCode() (string, error) { buf := make([]byte, affiliateCodeLength) if _, err := rand.Read(buf); err != nil { diff --git a/backend/internal/repository/affiliate_repo_integration_test.go b/backend/internal/repository/affiliate_repo_integration_test.go index b01ed528a6..c68482dd75 100644 --- a/backend/internal/repository/affiliate_repo_integration_test.go +++ b/backend/internal/repository/affiliate_repo_integration_test.go @@ -4,7 +4,10 @@ package repository import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" + "sync" "testing" "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") } + +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) +} diff --git a/backend/internal/repository/affiliate_repo_test.go b/backend/internal/repository/affiliate_repo_test.go index ccb7bb3daf..a9a2016678 100644 --- a/backend/internal/repository/affiliate_repo_test.go +++ b/backend/internal/repository/affiliate_repo_test.go @@ -20,9 +20,56 @@ func TestAffiliateRecordQueriesUseLedgerAuditFields(t *testing.T) { require.NoError(t, err) 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.balance_after::double precision") require.NotContains(t, content, "parseAffiliateRebateAmount") 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") +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index ababc06d50..ff9ffa4943 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -823,6 +823,7 @@ func registerAffiliateRoutes(admin *gin.RouterGroup, h *handler.Handlers) { users.GET("/lookup", h.Admin.Affiliate.LookupUsers) users.POST("/batch-rate", h.Admin.Affiliate.BatchSetRate) 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.DELETE("/:user_id", h.Admin.Affiliate.ClearUserSettings) } diff --git a/backend/internal/service/affiliate_service.go b/backend/internal/service/affiliate_service.go index 91cca5e216..f48156e995 100644 --- a/backend/internal/service/affiliate_service.go +++ b/backend/internal/service/affiliate_service.go @@ -2,6 +2,8 @@ package service import ( "context" + "crypto/sha256" + "encoding/hex" "errors" "math" "strings" @@ -17,6 +19,9 @@ var ( ErrAffiliateCodeTaken = infraerrors.Conflict("AFFILIATE_CODE_TAKEN", "affiliate code already in use") ErrAffiliateAlreadyBound = infraerrors.Conflict("AFFILIATE_ALREADY_BOUND", "affiliate inviter already bound") 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 ( @@ -102,6 +107,7 @@ type AffiliateRepository interface { GetAccruedRebateFromInvitee(ctx context.Context, inviterID, inviteeUserID int64) (float64, error) ThawFrozenQuota(ctx context.Context, userID int64) (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) // 管理端:用户级专属配置 @@ -156,25 +162,31 @@ type AffiliateInviteRecord struct { CreatedAt time.Time `json:"created_at"` } +// AffiliateRebateRecord 是一笔返利入账流水。非订单来源(兑换码、管理员充值) +// 的流水没有订单信息,OrderID/OrderAmount/PayAmount 为 nil;被邀请人账号被删除时 +// InviteeID 为 nil。 type AffiliateRebateRecord struct { - OrderID int64 `json:"order_id"` + OrderID *int64 `json:"order_id"` OutTradeNo string `json:"out_trade_no"` InviterID int64 `json:"inviter_id"` InviterEmail string `json:"inviter_email"` InviterUsername string `json:"inviter_username"` - InviteeID int64 `json:"invitee_id"` + InviteeID *int64 `json:"invitee_id"` InviteeEmail string `json:"invitee_email"` InviteeUsername string `json:"invitee_username"` - OrderAmount float64 `json:"order_amount"` - PayAmount float64 `json:"pay_amount"` + OrderAmount *float64 `json:"order_amount"` + PayAmount *float64 `json:"pay_amount"` RebateAmount float64 `json:"rebate_amount"` PaymentType string `json:"payment_type"` OrderStatus string `json:"order_status"` CreatedAt time.Time `json:"created_at"` } +// AffiliateTransferRecord 是一笔返利额度流出:Action 为 transfer(用户转入余额) +// 或 withdraw(管理员登记的线下提现)。 type AffiliateTransferRecord struct { LedgerID int64 `json:"ledger_id"` + Action string `json:"action"` UserID int64 `json:"user_id"` UserEmail string `json:"user_email"` Username string `json:"username"` @@ -191,6 +203,18 @@ type AffiliateTransferRecord struct { 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 { UserID int64 `json:"user_id"` Email string `json:"email"` @@ -559,6 +583,43 @@ func (s *AffiliateService) AdminBatchSetUserRebateRate(ctx context.Context, user 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 列出有专属配置的用户。 func (s *AffiliateService) AdminListCustomUsers(ctx context.Context, filter AffiliateAdminFilter) ([]AffiliateAdminEntry, int64, error) { if s == nil || s.repo == nil { diff --git a/backend/internal/service/affiliate_service_test.go b/backend/internal/service/affiliate_service_test.go index c02a4dd711..b3d92039a5 100644 --- a/backend/internal/service/affiliate_service_test.go +++ b/backend/internal/service/affiliate_service_test.go @@ -5,6 +5,7 @@ package service import ( "context" "math" + "strings" "testing" "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) + } +} diff --git a/backend/internal/service/payment_fulfillment_test.go b/backend/internal/service/payment_fulfillment_test.go index b1366c3c96..0a7d1b8895 100644 --- a/backend/internal/service/payment_fulfillment_test.go +++ b/backend/internal/service/payment_fulfillment_test.go @@ -150,6 +150,10 @@ func (r *paymentFulfillmentAffiliateRepoStub) TransferQuotaToBalance(context.Con 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) { panic("unexpected ListInvitees call") } diff --git a/backend/migrations/240_affiliate_ledger_operation_id.sql b/backend/migrations/240_affiliate_ledger_operation_id.sql new file mode 100644 index 0000000000..5a97f41821 --- /dev/null +++ b/backend/migrations/240_affiliate_ledger_operation_id.sql @@ -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; diff --git a/frontend/src/api/admin/affiliates.ts b/frontend/src/api/admin/affiliates.ts index dadb0ae9e5..570bbf1fe7 100644 --- a/frontend/src/api/admin/affiliates.ts +++ b/frontend/src/api/admin/affiliates.ts @@ -46,25 +46,35 @@ export interface AffiliateInviteRecord { 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 { - order_id: number + order_id: number | null out_trade_no: string inviter_id: number inviter_email: string inviter_username: string - invitee_id: number + invitee_id: number | null invitee_email: string invitee_username: string - order_amount: number - pay_amount: number + order_amount: number | null + pay_amount: number | null rebate_amount: number payment_type: string order_status: 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 { ledger_id: number + action: AffiliateOutflowAction user_id: number user_email: string username: string @@ -77,6 +87,26 @@ export interface AffiliateTransferRecord { 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 { user_id: number email: string @@ -215,6 +245,27 @@ export async function getUserOverview( 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 { + const response = await apiClient.post( + `/admin/affiliates/users/${userId}/withdraw`, + payload, + { headers: { 'Idempotency-Key': idempotencyKey } }, + ) + return { + result: response.data, + replayed: response.headers?.['x-idempotency-replayed'] === 'true', + } +} + export const affiliatesAPI = { listUsers, lookupUsers, @@ -225,6 +276,7 @@ export const affiliatesAPI = { listRebateRecords, listTransferRecords, getUserOverview, + withdrawUserQuota, } export default affiliatesAPI diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 3df37d96b1..8c04ed51e3 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -422,10 +422,12 @@ export default { affiliates: { invitesDescription: 'View site-wide inviter and invitee relationships', - rebatesDescription: 'View recharge orders that generated affiliate rebates', - transfersDescription: 'View affiliate quota transfers into account balance', + rebatesDescription: 'View every affiliate rebate accrual from recharge orders, redeem codes, and admin recharges', + transfersDescription: 'View affiliate quota transfers into balance and offline withdrawals', 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: { search: 'Search', @@ -450,7 +452,33 @@ export default { historyQuotaAfter: 'Historical Rebate After', invitedAt: 'Invited 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: { title: 'Affiliate User Overview', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index 2f5b44f495..1ec1b74283 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -422,10 +422,12 @@ export default { affiliates: { invitesDescription: '查看全站邀请关系和被邀请用户累计返利', - rebatesDescription: '查看每一笔产生返利的充值订单', - transfersDescription: '查看返利额度转入账户余额的提取流水', + rebatesDescription: '查看每一笔返利入账,包括充值订单、兑换码和管理员充值产生的返利', + transfersDescription: '查看返利额度转入余额与线下提现的流水', errors: { - loadFailed: '加载邀请返利记录失败' + loadFailed: '加载邀请返利记录失败', + AFFILIATE_QUOTA_INSUFFICIENT: '可提取返利额度不足', + AFFILIATE_WITHDRAW_AMOUNT_INVALID: '提现金额无效' }, records: { search: '搜索', @@ -450,7 +452,33 @@ export default { historyQuotaAfter: '提取后历史返利', invitedAt: '邀请时间', 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: { title: '用户返利概览', diff --git a/frontend/src/views/admin/affiliates/AdminAffiliateRecordsTable.vue b/frontend/src/views/admin/affiliates/AdminAffiliateRecordsTable.vue index 789df41a0d..ce105853fe 100644 --- a/frontend/src/views/admin/affiliates/AdminAffiliateRecordsTable.vue +++ b/frontend/src/views/admin/affiliates/AdminAffiliateRecordsTable.vue @@ -12,6 +12,15 @@ + @@ -36,7 +45,9 @@ /> + @@ -113,6 +133,13 @@ + + (null) +const withdrawDialog = ref(false) let debounceTimer: ReturnType | null = null const columns = computed(() => { @@ -201,6 +230,7 @@ const columns = computed(() => { } return [ { 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: 'balance_after', label: t('admin.affiliates.records.balanceAfter'), 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() } +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 { return Number(value || 0).toFixed(2) } diff --git a/frontend/src/views/admin/affiliates/AffiliateOfflineWithdrawDialog.vue b/frontend/src/views/admin/affiliates/AffiliateOfflineWithdrawDialog.vue new file mode 100644 index 0000000000..a9fe09d7b5 --- /dev/null +++ b/frontend/src/views/admin/affiliates/AffiliateOfflineWithdrawDialog.vue @@ -0,0 +1,357 @@ + + + diff --git a/frontend/src/views/admin/affiliates/__tests__/AdminAffiliateRecordsTable.spec.ts b/frontend/src/views/admin/affiliates/__tests__/AdminAffiliateRecordsTable.spec.ts new file mode 100644 index 0000000000..6306a5364b --- /dev/null +++ b/frontend/src/views/admin/affiliates/__tests__/AdminAffiliateRecordsTable.spec.ts @@ -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() + return { + ...actual, + useI18n: () => ({ + t: (key: string) => key, + }), + } +}) + +const TablePageLayoutStub = { + template: '
', +} + +const DataTableStub = { + props: ['columns', 'data'], + template: ` + + + + +
+ +
+ `, +} + +const WithdrawDialogStub = { + name: 'AffiliateOfflineWithdrawDialog', + props: ['show'], + emits: ['close', 'success'], + template: '
', +} + +function mountTable(type: 'invites' | 'rebates' | 'transfers') { + return mount(AdminAffiliateRecordsTable, { + props: { type }, + global: { + stubs: { + AppLayout: { template: '
' }, + TablePageLayout: TablePageLayoutStub, + DataTable: DataTableStub, + Pagination: true, + Icon: true, + OrderStatusBadge: true, + BaseDialog: true, + AffiliateOfflineWithdrawDialog: WithdrawDialogStub, + }, + }, + }) +} + +function page(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 }) + }) +}) diff --git a/frontend/src/views/admin/affiliates/__tests__/AffiliateOfflineWithdrawDialog.spec.ts b/frontend/src/views/admin/affiliates/__tests__/AffiliateOfflineWithdrawDialog.spec.ts new file mode 100644 index 0000000000..d1b5b2e722 --- /dev/null +++ b/frontend/src/views/admin/affiliates/__tests__/AffiliateOfflineWithdrawDialog.spec.ts @@ -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() + return { + ...actual, + useI18n: () => ({ + t: (key: string, params?: Record) => + params ? `${key} ${JSON.stringify(params)}` : key, + }), + } +}) + +const BaseDialogStub = { + props: ['show', 'title'], + emits: ['close'], + template: '
', +} + +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"}') + }) +}) diff --git a/frontend/src/views/admin/affiliates/__tests__/affiliateWithdrawOperation.spec.ts b/frontend/src/views/admin/affiliates/__tests__/affiliateWithdrawOperation.spec.ts new file mode 100644 index 0000000000..99424f1ef0 --- /dev/null +++ b/frontend/src/views/admin/affiliates/__tests__/affiliateWithdrawOperation.spec.ts @@ -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') + }) +}) diff --git a/frontend/src/views/admin/affiliates/affiliateWithdrawOperation.ts b/frontend/src/views/admin/affiliates/affiliateWithdrawOperation.ts new file mode 100644 index 0000000000..ab3f9bbe64 --- /dev/null +++ b/frontend/src/views/admin/affiliates/affiliateWithdrawOperation.ts @@ -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() + +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) +}