From 57b7dbdc8558f330a2ef1758c0c95d913056aef3 Mon Sep 17 00:00:00 2001 From: feeeei Date: Thu, 17 Sep 2026 21:22:09 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(affiliate):=20=E6=8F=90=E5=8F=96?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E6=94=AF=E6=8C=81=E7=99=BB=E8=AE=B0=E7=BA=BF?= =?UTF-8?q?=E4=B8=8B=E6=8F=90=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 管理员在站外给邀请人打款后,系统里原本无法扣减对应的返利额度,用户仍可把 这部分额度转入余额。现在可在「邀请返利 → 提取记录」点「登记线下提现」, 从该用户的可提取返利额度中扣除已打款金额: - POST /api/v1/admin/affiliates/users/:user_id/withdraw,请求体只含 amount, 金额按 8 位小数舍入后校验 - 先解冻已到期的冻结额度,再以 aff_quota >= amount 为条件原子扣减,写入 action=withdraw 的流水并带额度快照;额度不足返回 AFFILIATE_QUOTA_INSUFFICIENT - 不新增字段与迁移:线下提现复用已有 action 列区分,操作人由管理端审计日志记录 - 提取记录同时列出转入余额与线下提现,新增「类型」列 - 弹窗选择用户后展示可提取额度,支持一键填入全部;从未产生返利的用户按 0 处理 返利记录改为 LEFT JOIN 订单与被邀请人,兑换码、管理员充值来源的返利也会出现 在明细中,接口里订单相关字段与被邀请人 ID 可为 null。 --- .../handler/admin/affiliate_handler.go | 36 +- .../admin/affiliate_handler_withdraw_test.go | 105 ++++++ .../internal/handler/auth_email_oauth_test.go | 4 + backend/internal/repository/affiliate_repo.go | 139 +++++++- .../affiliate_repo_integration_test.go | 156 +++++++++ .../repository/affiliate_repo_test.go | 27 +- backend/internal/server/routes/admin.go | 1 + backend/internal/service/affiliate_service.go | 48 ++- .../service/affiliate_service_test.go | 64 ++++ .../service/payment_fulfillment_test.go | 4 + frontend/src/api/admin/affiliates.ts | 44 ++- .../src/i18n/locales/en/admin/overview.ts | 34 +- .../src/i18n/locales/zh/admin/overview.ts | 34 +- .../affiliates/AdminAffiliateRecordsTable.vue | 51 ++- .../AffiliateOfflineWithdrawDialog.vue | 323 ++++++++++++++++++ .../AdminAffiliateRecordsTable.spec.ts | 179 ++++++++++ .../AffiliateOfflineWithdrawDialog.spec.ts | 152 +++++++++ 17 files changed, 1365 insertions(+), 36 deletions(-) create mode 100644 backend/internal/handler/admin/affiliate_handler_withdraw_test.go create mode 100644 frontend/src/views/admin/affiliates/AffiliateOfflineWithdrawDialog.vue create mode 100644 frontend/src/views/admin/affiliates/__tests__/AdminAffiliateRecordsTable.spec.ts create mode 100644 frontend/src/views/admin/affiliates/__tests__/AffiliateOfflineWithdrawDialog.spec.ts diff --git a/backend/internal/handler/admin/affiliate_handler.go b/backend/internal/handler/admin/affiliate_handler.go index d443d344d2..0c2db16cd1 100644 --- a/backend/internal/handler/admin/affiliate_handler.go +++ b/backend/internal/handler/admin/affiliate_handler.go @@ -201,6 +201,36 @@ 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. +// 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) + if err != nil { + response.ErrorFrom(c, err) + return + } + 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 +244,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 +258,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..2ef1be66bf --- /dev/null +++ b/backend/internal/handler/admin/affiliate_handler_withdraw_test.go @@ -0,0 +1,105 @@ +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" +) + +type withdrawQuotaCall struct { + userID int64 + amount float64 +} + +type withdrawQuotaRepoStub struct { + service.AffiliateRepository + calls []withdrawQuotaCall + result *service.AffiliateWithdrawResult + err error +} + +func (s *withdrawQuotaRepoStub) WithdrawQuota(_ context.Context, userID int64, amount float64) (*service.AffiliateWithdrawResult, error) { + s.calls = append(s.calls, withdrawQuotaCall{userID: userID, amount: amount}) + return s.result, s.err +} + +type withdrawQuotaResponse struct { + Code int `json:"code"` + Reason string `json:"reason"` + Data *service.AffiliateWithdrawResult `json:"data"` +} + +func performWithdrawQuota(t *testing.T, repo *withdrawQuotaRepoStub, userID, body string) (int, 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") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + var resp withdrawQuotaResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + return rec.Code, resp +} + +// TestAffiliateHandlerWithdrawQuota_Success 验证登记线下提现把路径中的用户与 +// 舍入后的金额交给仓储,并返回登记结果。 +func TestAffiliateHandlerWithdrawQuota_Success(t *testing.T) { + repo := &withdrawQuotaRepoStub{result: &service.AffiliateWithdrawResult{LedgerID: 9, UserID: 42, Amount: 12.34567891}} + status, resp := performWithdrawQuota(t, repo, "42", `{"amount":12.3456789149}`) + + require.Equal(t, http.StatusOK, status) + require.Equal(t, 0, resp.Code) + require.NotNil(t, resp.Data) + require.Equal(t, int64(9), resp.Data.LedgerID) + require.Equal(t, []withdrawQuotaCall{{userID: 42, amount: 12.34567891}}, repo.calls) +} + +// TestAffiliateHandlerWithdrawQuota_RejectsBadRequests 验证非法用户 ID、非法 JSON +// 与非法金额在进入仓储前以 400 拒绝;金额错误带 AFFILIATE_WITHDRAW_AMOUNT_INVALID。 +func TestAffiliateHandlerWithdrawQuota_RejectsBadRequests(t *testing.T) { + cases := []struct { + name string + userID string + body string + wantReason string + }{ + {name: "non-numeric user id", userID: "abc", body: `{"amount":1}`}, + {name: "non-positive user id", userID: "0", body: `{"amount":1}`}, + {name: "malformed json", userID: "42", body: `{"amount":`}, + {name: "zero amount", userID: "42", body: `{"amount":0}`, wantReason: "AFFILIATE_WITHDRAW_AMOUNT_INVALID"}, + {name: "missing amount", userID: "42", body: `{}`, wantReason: "AFFILIATE_WITHDRAW_AMOUNT_INVALID"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + repo := &withdrawQuotaRepoStub{} + status, resp := performWithdrawQuota(t, repo, tc.userID, tc.body) + + require.Equal(t, http.StatusBadRequest, status) + require.Equal(t, tc.wantReason, resp.Reason) + require.Empty(t, repo.calls) + }) + } +} + +// TestAffiliateHandlerWithdrawQuota_InsufficientQuota 验证额度不足以 400 和 +// AFFILIATE_QUOTA_INSUFFICIENT 返回,前端据此提示。 +func TestAffiliateHandlerWithdrawQuota_InsufficientQuota(t *testing.T) { + repo := &withdrawQuotaRepoStub{err: service.ErrAffiliateQuotaInsufficient} + status, resp := performWithdrawQuota(t, repo, "42", `{"amount":5}`) + + require.Equal(t, http.StatusBadRequest, status) + require.Equal(t, "AFFILIATE_QUOTA_INSUFFICIENT", resp.Reason) + 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..18e3ea6725 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) (*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..3553600557 100644 --- a/backend/internal/repository/affiliate_repo.go +++ b/backend/internal/repository/affiliate_repo.go @@ -341,6 +341,102 @@ VALUES ($1, 'transfer', $2, NULL, $3, $4, $5, $6, NOW(), NOW())`, return transferred, newBalance, nil } +// WithdrawQuota 登记一笔线下提现:先解冻已到期的冻结额度,再以 +// aff_quota >= amount 为条件原子扣减,并写入带额度快照的 withdraw 流水。 +// 可提取额度不足返回 ErrAffiliateQuotaInsufficient。 +func (r *affiliateRepository) WithdrawQuota(ctx context.Context, userID int64, amount float64) (*service.AffiliateWithdrawResult, error) { + if userID <= 0 { + return nil, service.ErrUserNotFound + } + if amount <= 0 { + return nil, service.ErrAffiliateWithdrawAmountInvalid + } + + var result *service.AffiliateWithdrawResult + err := r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error { + 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 + } + + rows, err := txClient.QueryContext(txCtx, ` +INSERT INTO user_affiliate_ledger ( + user_id, + action, + amount, + source_user_id, + balance_after, + aff_quota_after, + aff_frozen_quota_after, + aff_history_quota_after, + created_at, + updated_at +) +VALUES ($1, 'withdraw', $2, NULL, $3, $4, $5, $6, NOW(), NOW()) +RETURNING id`, + userID, + amount, + snapshot.BalanceAfter, + snapshot.AvailableQuotaAfter, + snapshot.FrozenQuotaAfter, + snapshot.HistoryQuotaAfter, + ) + if err != nil { + return fmt.Errorf("insert affiliate withdraw ledger: %w", err) + } + if !rows.Next() { + _ = rows.Close() + if err := rows.Err(); err != nil { + return fmt.Errorf("insert affiliate withdraw ledger: %w", err) + } + return errors.New("insert affiliate withdraw ledger: no id returned") + } + var ledgerID int64 + if err := rows.Scan(&ledgerID); err != nil { + _ = rows.Close() + return err + } + if err := rows.Close(); err != nil { + return 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 +} + func (r *affiliateRepository) ListInvitees(ctx context.Context, inviterID int64, limit int) ([]service.AffiliateInvitee, error) { if limit <= 0 { limit = 100 @@ -466,13 +562,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 +593,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 +603,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 +617,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 +639,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 +659,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 +671,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 +682,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 +709,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 +1066,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..f74da3da6f 100644 --- a/backend/internal/repository/affiliate_repo_integration_test.go +++ b/backend/internal/repository/affiliate_repo_integration_test.go @@ -417,3 +417,159 @@ 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) +} + +// 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) + + result, err := repo.WithdrawQuota(txCtx, u.ID, 12.5) + require.NoError(t, err) + require.Positive(t, result.LedgerID) + 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) +} + +// 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) + 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 验证未到期的 +// 冻结额度不可提现,额度不足时不扣减、不写流水。 +func TestAffiliateRepository_WithdrawQuota_InsufficientLeavesQuotaUntouched(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, "insufficient") + mustSeedAffiliateQuota(t, txCtx, client, u.ID, 5, 10, 15) + _, 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) + + _, err = repo.WithdrawQuota(txCtx, u.ID, 6) + require.ErrorIs(t, err, service.ErrAffiliateQuotaInsufficient) + + require.InDelta(t, 5.0, querySingleFloat(t, txCtx, client, + "SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9) + require.InDelta(t, 10.0, querySingleFloat(t, txCtx, client, + "SELECT aff_frozen_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9) + require.Equal(t, 0, querySingleInt(t, txCtx, client, + "SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.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..b3221a1ce8 100644 --- a/backend/internal/repository/affiliate_repo_test.go +++ b/backend/internal/repository/affiliate_repo_test.go @@ -20,9 +20,34 @@ 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')") +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 405a5f343f..b3f6b602e6 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -820,6 +820,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..2c6423c787 100644 --- a/backend/internal/service/affiliate_service.go +++ b/backend/internal/service/affiliate_service.go @@ -17,6 +17,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 +105,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) (*AffiliateWithdrawResult, error) ListInvitees(ctx context.Context, inviterID int64, limit int) ([]AffiliateInvitee, error) // 管理端:用户级专属配置 @@ -156,25 +160,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 +201,16 @@ type AffiliateTransferRecord struct { CreatedAt time.Time `json:"created_at"` } +// AffiliateWithdrawResult 是线下提现登记完成后的流水 ID 与额度快照。 +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"` +} + type AffiliateUserOverview struct { UserID int64 `json:"user_id"` Email string `json:"email"` @@ -559,6 +579,26 @@ func (s *AffiliateService) AdminBatchSetUserRebateRate(ctx context.Context, user return s.repo.BatchSetUserRebateRate(ctx, cleaned, ratePercent) } +// AdminWithdrawQuota 登记一笔已在站外打款的线下提现:从用户可提取返利额度 +// 中扣除 amount,并写入 action=withdraw 的流水。冻结期内的额度不可扣除。 +// 金额按返利流水精度保留 8 位小数,舍入后必须为正。 +func (s *AffiliateService) AdminWithdrawQuota(ctx context.Context, userID int64, amount float64) (*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") + } + 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) +} + // 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..1e3f0b6c59 100644 --- a/backend/internal/service/affiliate_service_test.go +++ b/backend/internal/service/affiliate_service_test.go @@ -129,3 +129,67 @@ func TestIsValidAffiliateCodeFormat(t *testing.T) { }) } } + +type affiliateWithdrawCall struct { + userID int64 + amount float64 +} + +type affiliateWithdrawRepoStub struct { + AffiliateRepository + calls []affiliateWithdrawCall + result *AffiliateWithdrawResult + err error +} + +func (s *affiliateWithdrawRepoStub) WithdrawQuota(_ context.Context, userID int64, amount float64) (*AffiliateWithdrawResult, error) { + s.calls = append(s.calls, affiliateWithdrawCall{userID: userID, amount: amount}) + return s.result, s.err +} + +// 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) + 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) + require.Error(t, err) + require.Empty(t, repo.calls) +} + +// 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) + require.NoError(t, err) + require.Same(t, want, got) + require.Equal(t, []affiliateWithdrawCall{{userID: 42, amount: 12.34567891}}, repo.calls) +} + +// TestAdminWithdrawQuota_PropagatesInsufficientQuota 验证额度不足由仓储判定, +// 服务层原样返回,前端据错误码提示。 +func TestAdminWithdrawQuota_PropagatesInsufficientQuota(t *testing.T) { + t.Parallel() + repo := &affiliateWithdrawRepoStub{err: ErrAffiliateQuotaInsufficient} + svc := &AffiliateService{repo: repo} + _, err := svc.AdminWithdrawQuota(context.Background(), 1, 1) + require.ErrorIs(t, err, ErrAffiliateQuotaInsufficient) +} diff --git a/backend/internal/service/payment_fulfillment_test.go b/backend/internal/service/payment_fulfillment_test.go index b1366c3c96..f604e811f9 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) (*AffiliateWithdrawResult, error) { + panic("unexpected WithdrawQuota call") +} + func (r *paymentFulfillmentAffiliateRepoStub) ListInvitees(context.Context, int64, int) ([]AffiliateInvitee, error) { panic("unexpected ListInvitees call") } diff --git a/frontend/src/api/admin/affiliates.ts b/frontend/src/api/admin/affiliates.ts index dadb0ae9e5..8cc736bce7 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,20 @@ 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 AffiliateUserOverview { user_id: number email: string @@ -215,6 +239,17 @@ export async function getUserOverview( return data } +export async function withdrawUserQuota( + userId: number, + payload: WithdrawAffiliateQuotaRequest, +): Promise { + const { data } = await apiClient.post( + `/admin/affiliates/users/${userId}/withdraw`, + payload, + ) + return data +} + export const affiliatesAPI = { listUsers, lookupUsers, @@ -225,6 +260,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 725cdada28..51bd13a6ac 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -390,10 +390,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', @@ -418,7 +420,31 @@ 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', + 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 b3b3e706d2..abc2b0c8d7 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -390,10 +390,12 @@ export default { affiliates: { invitesDescription: '查看全站邀请关系和被邀请用户累计返利', - rebatesDescription: '查看每一笔产生返利的充值订单', - transfersDescription: '查看返利额度转入账户余额的提取流水', + rebatesDescription: '查看每一笔返利入账,包括充值订单、兑换码和管理员充值产生的返利', + transfersDescription: '查看返利额度转入余额与线下提现的流水', errors: { - loadFailed: '加载邀请返利记录失败' + loadFailed: '加载邀请返利记录失败', + AFFILIATE_QUOTA_INSUFFICIENT: '可提取返利额度不足', + AFFILIATE_WITHDRAW_AMOUNT_INVALID: '提现金额无效' }, records: { search: '搜索', @@ -418,7 +420,31 @@ 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}', + 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..8d3cf67979 --- /dev/null +++ b/frontend/src/views/admin/affiliates/AffiliateOfflineWithdrawDialog.vue @@ -0,0 +1,323 @@ + + + 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..f6aac2570a --- /dev/null +++ b/frontend/src/views/admin/affiliates/__tests__/AffiliateOfflineWithdrawDialog.spec.ts @@ -0,0 +1,152 @@ +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 } }, + }) +} + +async function pickUser(wrapper: VueWrapper, availableQuota: number) { + lookupUsers.mockResolvedValue([{ id: 42, email: 'inviter@example.com', username: 'inviter' }]) + getUserOverview.mockResolvedValue({ + 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, + }) + 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({ + ledger_id: 9, + user_id: 42, + amount: 12.34567891, + available_quota_after: 0, + frozen_quota_after: 0, + history_quota_after: 50, + }) + await wrapper.get('[data-test="withdraw-submit"]').trigger('click') + await flushPromises() + + expect(withdrawUserQuota).toHaveBeenCalledWith(42, { amount: 12.34567891 }) + 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({ 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() + }) +}) From c19204289d04f643fcf3442dbf998d3796f33555 Mon Sep 17 00:00:00 2001 From: feeeei Date: Wed, 23 Sep 2026 01:24:59 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(affiliate):=20=E7=BA=BF=E4=B8=8B?= =?UTF-8?q?=E6=8F=90=E7=8E=B0=E7=99=BB=E8=AE=B0=E6=8C=89=20Idempotency-Key?= =?UTF-8?q?=20=E5=B9=82=E7=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 登记请求只有金额,提交成功但响应丢失时,管理员重试会把同一笔站外打款再扣 一次返利额度。现在一次登记由 Idempotency-Key 标识,数据库在同一事务内以唯一 约束去重: - user_affiliate_ledger 新增 operation_id(幂等键派生的 SHA-256)与部分唯一 索引(迁移 240);接口要求 Idempotency-Key,缺失或非法返回 400 - 事务内先以 operation_id 写入占位流水,再解冻、扣减并补写额度快照;同键 重试返回首笔流水的结果并带 X-Idempotency-Replayed,不重复扣减;同键换了 用户或金额返回 409 IDEMPOTENCY_KEY_CONFLICT;额度不足时占位随事务回滚 - 并发的同键请求在唯一索引上等待先到者:先到者提交则返回同一条流水,回滚 则自行登记 - 弹窗在网络错误、超时、5xx、408/409 时锁定用户与金额,重试沿用同一请求与 幂等键;幂等键按管理员、用户、金额暂存于 sessionStorage,登记有确定结果后 释放 --- .../handler/admin/affiliate_handler.go | 10 +- .../admin/affiliate_handler_withdraw_test.go | 133 ++++++--- .../internal/handler/auth_email_oauth_test.go | 2 +- backend/internal/repository/affiliate_repo.go | 147 +++++++--- .../affiliate_repo_integration_test.go | 253 +++++++++++++++++- .../repository/affiliate_repo_test.go | 22 ++ backend/internal/service/affiliate_service.go | 27 +- .../service/affiliate_service_test.go | 82 ++++-- .../service/payment_fulfillment_test.go | 2 +- .../240_affiliate_ledger_operation_id.sql | 10 + frontend/src/api/admin/affiliates.ts | 22 +- .../src/i18n/locales/en/admin/overview.ts | 2 + .../src/i18n/locales/zh/admin/overview.ts | 2 + .../AffiliateOfflineWithdrawDialog.vue | 66 +++-- .../AffiliateOfflineWithdrawDialog.spec.ts | 101 ++++++- .../affiliateWithdrawOperation.spec.ts | 50 ++++ .../affiliates/affiliateWithdrawOperation.ts | 68 +++++ 17 files changed, 859 insertions(+), 140 deletions(-) create mode 100644 backend/migrations/240_affiliate_ledger_operation_id.sql create mode 100644 frontend/src/views/admin/affiliates/__tests__/affiliateWithdrawOperation.spec.ts create mode 100644 frontend/src/views/admin/affiliates/affiliateWithdrawOperation.ts diff --git a/backend/internal/handler/admin/affiliate_handler.go b/backend/internal/handler/admin/affiliate_handler.go index 0c2db16cd1..a5ed9e1cc0 100644 --- a/backend/internal/handler/admin/affiliate_handler.go +++ b/backend/internal/handler/admin/affiliate_handler.go @@ -208,7 +208,10 @@ type WithdrawQuotaRequest struct { } // WithdrawQuota records an offline withdrawal paid outside the site and -// deducts it from the user's available affiliate quota. +// 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) @@ -223,11 +226,14 @@ func (h *AffiliateHandler) WithdrawQuota(c *gin.Context) { return } - result, err := h.affiliateService.AdminWithdrawQuota(c.Request.Context(), userID, req.Amount) + 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) } diff --git a/backend/internal/handler/admin/affiliate_handler_withdraw_test.go b/backend/internal/handler/admin/affiliate_handler_withdraw_test.go index 2ef1be66bf..0d4344fde5 100644 --- a/backend/internal/handler/admin/affiliate_handler_withdraw_test.go +++ b/backend/internal/handler/admin/affiliate_handler_withdraw_test.go @@ -13,9 +13,12 @@ import ( "github.com/stretchr/testify/require" ) +const testWithdrawIdempotencyKey = "affiliate-withdraw-1-5d0c7a2e" + type withdrawQuotaCall struct { - userID int64 - amount float64 + userID int64 + amount float64 + operationID string } type withdrawQuotaRepoStub struct { @@ -25,18 +28,18 @@ type withdrawQuotaRepoStub struct { err error } -func (s *withdrawQuotaRepoStub) WithdrawQuota(_ context.Context, userID int64, amount float64) (*service.AffiliateWithdrawResult, error) { - s.calls = append(s.calls, withdrawQuotaCall{userID: userID, amount: amount}) +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 *service.AffiliateWithdrawResult `json:"data"` + Code int `json:"code"` + Reason string `json:"reason"` + Data json.RawMessage `json:"data"` } -func performWithdrawQuota(t *testing.T, repo *withdrawQuotaRepoStub, userID, body string) (int, withdrawQuotaResponse) { +func performWithdrawQuota(t *testing.T, repo *withdrawQuotaRepoStub, userID, idempotencyKey, body string) (*httptest.ResponseRecorder, withdrawQuotaResponse) { t.Helper() gin.SetMode(gin.TestMode) router := gin.New() @@ -45,61 +48,125 @@ func performWithdrawQuota(t *testing.T, repo *withdrawQuotaRepoStub, userID, bod 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.Code, resp + return rec, resp } -// TestAffiliateHandlerWithdrawQuota_Success 验证登记线下提现把路径中的用户与 -// 舍入后的金额交给仓储,并返回登记结果。 +// TestAffiliateHandlerWithdrawQuota_Success 验证登记线下提现把路径中的用户、 +// 舍入后的金额与幂等键派生的 operation_id 交给仓储,并返回登记结果。 func TestAffiliateHandlerWithdrawQuota_Success(t *testing.T) { repo := &withdrawQuotaRepoStub{result: &service.AffiliateWithdrawResult{LedgerID: 9, UserID: 42, Amount: 12.34567891}} - status, resp := performWithdrawQuota(t, repo, "42", `{"amount":12.3456789149}`) + rec, resp := performWithdrawQuota(t, repo, "42", testWithdrawIdempotencyKey, `{"amount":12.3456789149}`) - require.Equal(t, http.StatusOK, status) + require.Equal(t, http.StatusOK, rec.Code) require.Equal(t, 0, resp.Code) - require.NotNil(t, resp.Data) - require.Equal(t, int64(9), resp.Data.LedgerID) - require.Equal(t, []withdrawQuotaCall{{userID: 42, amount: 12.34567891}}, repo.calls) + 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_RejectsBadRequests 验证非法用户 ID、非法 JSON -// 与非法金额在进入仓储前以 400 拒绝;金额错误带 AFFILIATE_WITHDRAW_AMOUNT_INVALID。 +// 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", body: `{"amount":1}`}, - {name: "non-positive user id", userID: "0", body: `{"amount":1}`}, - {name: "malformed json", userID: "42", body: `{"amount":`}, - {name: "zero amount", userID: "42", body: `{"amount":0}`, wantReason: "AFFILIATE_WITHDRAW_AMOUNT_INVALID"}, - {name: "missing amount", userID: "42", body: `{}`, wantReason: "AFFILIATE_WITHDRAW_AMOUNT_INVALID"}, + {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{} - status, resp := performWithdrawQuota(t, repo, tc.userID, tc.body) + rec, resp := performWithdrawQuota(t, repo, tc.userID, tc.key, tc.body) - require.Equal(t, http.StatusBadRequest, status) + require.Equal(t, http.StatusBadRequest, rec.Code) require.Equal(t, tc.wantReason, resp.Reason) require.Empty(t, repo.calls) }) } } -// TestAffiliateHandlerWithdrawQuota_InsufficientQuota 验证额度不足以 400 和 -// AFFILIATE_QUOTA_INSUFFICIENT 返回,前端据此提示。 -func TestAffiliateHandlerWithdrawQuota_InsufficientQuota(t *testing.T) { - repo := &withdrawQuotaRepoStub{err: service.ErrAffiliateQuotaInsufficient} - status, resp := performWithdrawQuota(t, repo, "42", `{"amount":5}`) +// 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, http.StatusBadRequest, status) - require.Equal(t, "AFFILIATE_QUOTA_INSUFFICIENT", resp.Reason) - require.Len(t, repo.calls, 1) + 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 18e3ea6725..0a6b653ec6 100644 --- a/backend/internal/handler/auth_email_oauth_test.go +++ b/backend/internal/handler/auth_email_oauth_test.go @@ -407,7 +407,7 @@ func (r *oauthEmailAffiliateRepoStub) TransferQuotaToBalance(context.Context, in panic("unexpected TransferQuotaToBalance call") } -func (r *oauthEmailAffiliateRepoStub) WithdrawQuota(context.Context, int64, float64) (*service.AffiliateWithdrawResult, error) { +func (r *oauthEmailAffiliateRepoStub) WithdrawQuota(context.Context, int64, float64, string) (*service.AffiliateWithdrawResult, error) { panic("unexpected WithdrawQuota call") } diff --git a/backend/internal/repository/affiliate_repo.go b/backend/internal/repository/affiliate_repo.go index 3553600557..4e2eaaf3d4 100644 --- a/backend/internal/repository/affiliate_repo.go +++ b/backend/internal/repository/affiliate_repo.go @@ -341,19 +341,34 @@ VALUES ($1, 'transfer', $2, NULL, $3, $4, $5, $6, NOW(), NOW())`, return transferred, newBalance, nil } -// WithdrawQuota 登记一笔线下提现:先解冻已到期的冻结额度,再以 -// aff_quota >= amount 为条件原子扣减,并写入带额度快照的 withdraw 流水。 -// 可提取额度不足返回 ErrAffiliateQuotaInsufficient。 -func (r *affiliateRepository) WithdrawQuota(ctx context.Context, userID int64, amount float64) (*service.AffiliateWithdrawResult, error) { +// 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) } @@ -380,45 +395,21 @@ WHERE user_id = $2 return err } - rows, err := txClient.QueryContext(txCtx, ` -INSERT INTO user_affiliate_ledger ( - user_id, - action, - amount, - source_user_id, - balance_after, - aff_quota_after, - aff_frozen_quota_after, - aff_history_quota_after, - created_at, - updated_at -) -VALUES ($1, 'withdraw', $2, NULL, $3, $4, $5, $6, NOW(), NOW()) -RETURNING id`, - userID, - amount, + 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, - ) - if err != nil { - return fmt.Errorf("insert affiliate withdraw ledger: %w", err) - } - if !rows.Next() { - _ = rows.Close() - if err := rows.Err(); err != nil { - return fmt.Errorf("insert affiliate withdraw ledger: %w", err) - } - return errors.New("insert affiliate withdraw ledger: no id returned") - } - var ledgerID int64 - if err := rows.Scan(&ledgerID); err != nil { - _ = rows.Close() - return err - } - if err := rows.Close(); err != nil { - return err + ledgerID, + ); err != nil { + return fmt.Errorf("record affiliate withdraw snapshot: %w", err) } result = &service.AffiliateWithdrawResult{ @@ -437,6 +428,84 @@ RETURNING id`, 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 diff --git a/backend/internal/repository/affiliate_repo_integration_test.go b/backend/internal/repository/affiliate_repo_integration_test.go index f74da3da6f..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" @@ -439,6 +442,25 @@ VALUES ($1, $2, $3, $4, $5, NOW(), NOW())`, userID, affCode, quota, frozen, hist 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) { @@ -450,10 +472,12 @@ func TestAffiliateRepository_WithdrawQuota_DeductsAndRecordsLedger(t *testing.T) 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) + 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) @@ -484,6 +508,14 @@ func TestAffiliateRepository_WithdrawQuota_DeductsAndRecordsLedger(t *testing.T) 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 验证已过冻结期 @@ -502,7 +534,7 @@ INSERT INTO user_affiliate_ledger (user_id, action, amount, frozen_until, create VALUES ($1, 'accrue', 10, NOW() - INTERVAL '1 hour', NOW(), NOW())`, u.ID) require.NoError(t, err) - result, err := repo.WithdrawQuota(txCtx, u.ID, 10) + 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) @@ -511,30 +543,225 @@ VALUES ($1, 'accrue', 10, NOW() - INTERVAL '1 hour', NOW(), NOW())`, u.ID) } // TestAffiliateRepository_WithdrawQuota_InsufficientLeavesQuotaUntouched 验证未到期的 -// 冻结额度不可提现,额度不足时不扣减、不写流水。 +// 冻结额度不可提现:额度不足时不扣减、不写流水,占位随事务回滚、不占用该 +// operation_id,同一标识改成可提取范围内的金额仍可登记;没有返利档案的用户 +// 同样按额度不足处理。 func TestAffiliateRepository_WithdrawQuota_InsufficientLeavesQuotaUntouched(t *testing.T) { ctx := context.Background() - tx := testEntTx(t) - txCtx := dbent.NewTxContext(ctx, tx) - client := tx.Client() + client := testEntClient(t) repo := NewAffiliateRepository(client, integrationDB) - u := mustCreateAffiliateWithdrawUser(t, client, "insufficient") - mustSeedAffiliateQuota(t, txCtx, client, u.ID, 5, 10, 15) - _, err := client.ExecContext(txCtx, ` + 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(txCtx, u.ID, 6) + _, err = repo.WithdrawQuota(ctx, u.ID, 6, operationID) require.ErrorIs(t, err, service.ErrAffiliateQuotaInsufficient) - require.InDelta(t, 5.0, querySingleFloat(t, txCtx, client, + 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, txCtx, client, + 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, txCtx, client, + 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 验证兑换码 diff --git a/backend/internal/repository/affiliate_repo_test.go b/backend/internal/repository/affiliate_repo_test.go index b3221a1ce8..a9a2016678 100644 --- a/backend/internal/repository/affiliate_repo_test.go +++ b/backend/internal/repository/affiliate_repo_test.go @@ -51,3 +51,25 @@ func TestAffiliateTransferRecordsQueryIncludesOfflineWithdrawals(t *testing.T) { 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/service/affiliate_service.go b/backend/internal/service/affiliate_service.go index 2c6423c787..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" @@ -105,7 +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) (*AffiliateWithdrawResult, error) + WithdrawQuota(ctx context.Context, userID int64, amount float64, operationID string) (*AffiliateWithdrawResult, error) ListInvitees(ctx context.Context, inviterID int64, limit int) ([]AffiliateInvitee, error) // 管理端:用户级专属配置 @@ -202,6 +204,7 @@ type AffiliateTransferRecord struct { } // AffiliateWithdrawResult 是线下提现登记完成后的流水 ID 与额度快照。 +// Replayed 表示本次请求命中了同一幂等标识的已有登记,返回的是首次登记的结果。 type AffiliateWithdrawResult struct { LedgerID int64 `json:"ledger_id"` UserID int64 `json:"user_id"` @@ -209,6 +212,7 @@ type AffiliateWithdrawResult struct { AvailableQuotaAfter float64 `json:"available_quota_after"` FrozenQuotaAfter float64 `json:"frozen_quota_after"` HistoryQuotaAfter float64 `json:"history_quota_after"` + Replayed bool `json:"-"` } type AffiliateUserOverview struct { @@ -582,13 +586,23 @@ func (s *AffiliateService) AdminBatchSetUserRebateRate(ctx context.Context, user // AdminWithdrawQuota 登记一笔已在站外打款的线下提现:从用户可提取返利额度 // 中扣除 amount,并写入 action=withdraw 的流水。冻结期内的额度不可扣除。 // 金额按返利流水精度保留 8 位小数,舍入后必须为正。 -func (s *AffiliateService) AdminWithdrawQuota(ctx context.Context, userID int64, amount float64) (*AffiliateWithdrawResult, error) { +// +// 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 } @@ -596,7 +610,14 @@ func (s *AffiliateService) AdminWithdrawQuota(ctx context.Context, userID int64, if amount <= 0 || math.IsInf(amount, 0) { return nil, ErrAffiliateWithdrawAmountInvalid } - return s.repo.WithdrawQuota(ctx, userID, amount) + 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 列出有专属配置的用户。 diff --git a/backend/internal/service/affiliate_service_test.go b/backend/internal/service/affiliate_service_test.go index 1e3f0b6c59..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" @@ -131,8 +132,9 @@ func TestIsValidAffiliateCodeFormat(t *testing.T) { } type affiliateWithdrawCall struct { - userID int64 - amount float64 + userID int64 + amount float64 + operationID string } type affiliateWithdrawRepoStub struct { @@ -142,11 +144,13 @@ type affiliateWithdrawRepoStub struct { err error } -func (s *affiliateWithdrawRepoStub) WithdrawQuota(_ context.Context, userID int64, amount float64) (*AffiliateWithdrawResult, error) { - s.calls = append(s.calls, affiliateWithdrawCall{userID: userID, amount: amount}) +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) { @@ -154,7 +158,7 @@ func TestAdminWithdrawQuota_RejectsInvalidAmount(t *testing.T) { 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) + _, 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) } @@ -165,11 +169,55 @@ func TestAdminWithdrawQuota_RejectsInvalidUser(t *testing.T) { t.Parallel() repo := &affiliateWithdrawRepoStub{} svc := &AffiliateService{repo: repo} - _, err := svc.AdminWithdrawQuota(context.Background(), 0, 1) + _, 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) { @@ -178,18 +226,22 @@ func TestAdminWithdrawQuota_RoundsAmountToLedgerPrecision(t *testing.T) { repo := &affiliateWithdrawRepoStub{result: want} svc := &AffiliateService{repo: repo} - got, err := svc.AdminWithdrawQuota(context.Background(), 42, 12.3456789149) + got, err := svc.AdminWithdrawQuota(context.Background(), 42, 12.3456789149, testAffiliateWithdrawKey) require.NoError(t, err) require.Same(t, want, got) - require.Equal(t, []affiliateWithdrawCall{{userID: 42, amount: 12.34567891}}, repo.calls) + 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_PropagatesInsufficientQuota 验证额度不足由仓储判定, -// 服务层原样返回,前端据错误码提示。 -func TestAdminWithdrawQuota_PropagatesInsufficientQuota(t *testing.T) { +// TestAdminWithdrawQuota_PropagatesRepositoryErrors 验证额度不足与同键不同参数 +// 由仓储判定,服务层原样返回,前端据错误码提示。 +func TestAdminWithdrawQuota_PropagatesRepositoryErrors(t *testing.T) { t.Parallel() - repo := &affiliateWithdrawRepoStub{err: ErrAffiliateQuotaInsufficient} - svc := &AffiliateService{repo: repo} - _, err := svc.AdminWithdrawQuota(context.Background(), 1, 1) - require.ErrorIs(t, err, ErrAffiliateQuotaInsufficient) + 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 f604e811f9..0a7d1b8895 100644 --- a/backend/internal/service/payment_fulfillment_test.go +++ b/backend/internal/service/payment_fulfillment_test.go @@ -150,7 +150,7 @@ func (r *paymentFulfillmentAffiliateRepoStub) TransferQuotaToBalance(context.Con panic("unexpected TransferQuotaToBalance call") } -func (r *paymentFulfillmentAffiliateRepoStub) WithdrawQuota(context.Context, int64, float64) (*AffiliateWithdrawResult, error) { +func (r *paymentFulfillmentAffiliateRepoStub) WithdrawQuota(context.Context, int64, float64, string) (*AffiliateWithdrawResult, error) { panic("unexpected WithdrawQuota 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 8cc736bce7..570bbf1fe7 100644 --- a/frontend/src/api/admin/affiliates.ts +++ b/frontend/src/api/admin/affiliates.ts @@ -101,6 +101,12 @@ export interface AffiliateWithdrawResult { 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 @@ -239,15 +245,25 @@ 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, -): Promise { - const { data } = await apiClient.post( + idempotencyKey: string, +): Promise { + const response = await apiClient.post( `/admin/affiliates/users/${userId}/withdraw`, payload, + { headers: { 'Idempotency-Key': idempotencyKey } }, ) - return data + return { + result: response.data, + replayed: response.headers?.['x-idempotency-replayed'] === 'true', + } } export const affiliatesAPI = { diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 51bd13a6ac..d469c778ef 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -443,6 +443,8 @@ export default { 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' }, diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index abc2b0c8d7..95396dea1d 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -443,6 +443,8 @@ export default { submit: '确认登记', submitting: '登记中...', success: '已登记线下提现 {amount},剩余可提取 {remaining}', + replayed: '这笔线下提现此前已登记({amount}),本次未重复扣减;登记后剩余可提取 {remaining}', + uncertainHint: '上次提交没有收到结果,可能已经登记成功。用户与金额已锁定,重新提交会沿用同一笔登记,已登记时不会重复扣减。', amountRequired: '请输入大于 0 的金额', amountExceeds: '提现金额不能超过当前可提取额度' }, diff --git a/frontend/src/views/admin/affiliates/AffiliateOfflineWithdrawDialog.vue b/frontend/src/views/admin/affiliates/AffiliateOfflineWithdrawDialog.vue index 8d3cf67979..a9fe09d7b5 100644 --- a/frontend/src/views/admin/affiliates/AffiliateOfflineWithdrawDialog.vue +++ b/frontend/src/views/admin/affiliates/AffiliateOfflineWithdrawDialog.vue @@ -22,7 +22,7 @@ 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" + :disabled="submitting || outcomeUncertain" data-test="withdraw-clear-user" @click="clearUser" > @@ -84,13 +84,13 @@ step="any" inputmode="decimal" class="input" - :disabled="!selectedUser || overviewLoading || submitting" + :disabled="!selectedUser || overviewLoading || submitting || outcomeUncertain" data-test="withdraw-amount" />
+
+ {{ t('admin.affiliates.withdraw.uncertainHint') }} +
+
{{ t('admin.affiliates.withdraw.warning') }}
@@ -132,6 +140,11 @@ 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 @@ -156,6 +169,9 @@ const availableQuota = ref(0) const overviewLoading = ref(false) const amount = ref('') const submitting = ref(false) +// 已提交、尚未拿到确定结果的登记。结果未知时锁定用户与金额,重试沿用它的幂等键。 +const pendingOperation = ref(null) +const outcomeUncertain = computed(() => pendingOperation.value?.outcomeUncertain === true) let searchTimer: ReturnType | null = null let searchSeq = 0 @@ -165,7 +181,7 @@ const amountText = computed(() => String(amount.value ?? '').trim()) const parsedAmount = computed(() => (amountText.value === '' ? Number.NaN : Number(amountText.value))) const amountError = computed(() => { - if (!selectedUser.value || overviewLoading.value || amountText.value === '') return '' + 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') @@ -176,14 +192,12 @@ const amountError = computed(() => { return '' }) -const canSubmit = computed( - () => - selectedUser.value !== null && - !overviewLoading.value && - !submitting.value && - amountText.value !== '' && - amountError.value === '', -) +// 结果未知的登记可能已经扣减,重试不受刷新后的可提取额度约束。 +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('.') @@ -240,7 +254,7 @@ async function loadAvailableQuota(userId: number) { availableQuota.value = 0 // 从未产生过邀请返利的用户没有返利档案,概览接口返回 USER_NOT_FOUND,可提取额度即为 0。 if (extractApiErrorCode(error) === 'USER_NOT_FOUND') return - selectedUser.value = null + if (!outcomeUncertain.value) selectedUser.value = null showError(error) } finally { if (seq === overviewSeq) overviewLoading.value = false @@ -283,6 +297,7 @@ function resetForm() { overviewLoading.value = false amount.value = '' submitting.value = false + pendingOperation.value = null } function handleClose() { @@ -293,11 +308,21 @@ function handleClose() { 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 = await affiliatesAPI.withdrawUserQuota(user.id, { amount: parsedAmount.value }) + const { result, replayed } = await affiliatesAPI.withdrawUserQuota( + operation.userId, + { amount: operation.amount }, + operation.key, + ) + completeAffiliateWithdrawOperation(operation) + pendingOperation.value = null appStore.showSuccess( - t('admin.affiliates.withdraw.success', { + t(replayed ? 'admin.affiliates.withdraw.replayed' : 'admin.affiliates.withdraw.success', { amount: `$${formatPreciseAmount(result.amount)}`, remaining: `$${formatPreciseAmount(result.available_quota_after)}`, }), @@ -305,7 +330,16 @@ async function submit() { emit('success', result) } catch (error) { showError(error) - void loadAvailableQuota(user.id) + // 网络错误、超时、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 } diff --git a/frontend/src/views/admin/affiliates/__tests__/AffiliateOfflineWithdrawDialog.spec.ts b/frontend/src/views/admin/affiliates/__tests__/AffiliateOfflineWithdrawDialog.spec.ts index f6aac2570a..d1b5b2e722 100644 --- a/frontend/src/views/admin/affiliates/__tests__/AffiliateOfflineWithdrawDialog.spec.ts +++ b/frontend/src/views/admin/affiliates/__tests__/AffiliateOfflineWithdrawDialog.spec.ts @@ -43,9 +43,8 @@ function mountDialog() { }) } -async function pickUser(wrapper: VueWrapper, availableQuota: number) { - lookupUsers.mockResolvedValue([{ id: 42, email: 'inviter@example.com', username: 'inviter' }]) - getUserOverview.mockResolvedValue({ +function overviewWithQuota(availableQuota: number) { + return { user_id: 42, email: 'inviter@example.com', username: 'inviter', @@ -55,7 +54,26 @@ async function pickUser(wrapper: VueWrapper, availableQuota: number) { 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() @@ -84,18 +102,11 @@ describe('AffiliateOfflineWithdrawDialog', () => { 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({ - ledger_id: 9, - user_id: 42, - amount: 12.34567891, - available_quota_after: 0, - frozen_quota_after: 0, - history_quota_after: 50, - }) + 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(withdrawUserQuota).toHaveBeenCalledWith(42, { amount: 12.34567891 }, expect.stringMatching(/^affiliate-withdraw-/)) expect(showSuccess).toHaveBeenCalledWith( 'admin.affiliates.withdraw.success {"amount":"$12.34567891","remaining":"$0.00"}', ) @@ -140,7 +151,7 @@ describe('AffiliateOfflineWithdrawDialog', () => { const wrapper = mountDialog() await pickUser(wrapper, 10) - withdrawUserQuota.mockRejectedValue({ reason: 'AFFILIATE_QUOTA_INSUFFICIENT', message: 'insufficient' }) + 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() @@ -149,4 +160,66 @@ describe('AffiliateOfflineWithdrawDialog', () => { 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) +}