mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-20 08:19:58 +00:00
fix(affiliate): 线下提现登记按 Idempotency-Key 幂等
登记请求只有金额,提交成功但响应丢失时,管理员重试会把同一笔站外打款再扣 一次返利额度。现在一次登记由 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,登记有确定结果后 释放
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 验证兑换码
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 列出有专属配置的用户。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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<AffiliateWithdrawResult> {
|
||||
const { data } = await apiClient.post<AffiliateWithdrawResult>(
|
||||
idempotencyKey: string,
|
||||
): Promise<AffiliateWithdrawResponse> {
|
||||
const response = await apiClient.post<AffiliateWithdrawResult>(
|
||||
`/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 = {
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
|
||||
@@ -443,6 +443,8 @@ export default {
|
||||
submit: '确认登记',
|
||||
submitting: '登记中...',
|
||||
success: '已登记线下提现 {amount},剩余可提取 {remaining}',
|
||||
replayed: '这笔线下提现此前已登记({amount}),本次未重复扣减;登记后剩余可提取 {remaining}',
|
||||
uncertainHint: '上次提交没有收到结果,可能已经登记成功。用户与金额已锁定,重新提交会沿用同一笔登记,已登记时不会重复扣减。',
|
||||
amountRequired: '请输入大于 0 的金额',
|
||||
amountExceeds: '提现金额不能超过当前可提取额度'
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary shrink-0"
|
||||
:disabled="!selectedUser || overviewLoading || submitting || availableQuota <= 0"
|
||||
:disabled="!selectedUser || overviewLoading || submitting || outcomeUncertain || availableQuota <= 0"
|
||||
data-test="withdraw-fill-all"
|
||||
@click="fillAll"
|
||||
>
|
||||
@@ -101,6 +101,14 @@
|
||||
<p v-else class="input-hint">{{ t('admin.affiliates.withdraw.amountHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="outcomeUncertain"
|
||||
class="rounded-lg border border-blue-200 bg-blue-50 p-3 text-sm text-blue-800 dark:border-blue-700/50 dark:bg-blue-900/20 dark:text-blue-300"
|
||||
data-test="withdraw-uncertain-hint"
|
||||
>
|
||||
{{ t('admin.affiliates.withdraw.uncertainHint') }}
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-700/50 dark:bg-amber-900/20 dark:text-amber-300">
|
||||
{{ t('admin.affiliates.withdraw.warning') }}
|
||||
</div>
|
||||
@@ -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<number | string>('')
|
||||
const submitting = ref(false)
|
||||
// 已提交、尚未拿到确定结果的登记。结果未知时锁定用户与金额,重试沿用它的幂等键。
|
||||
const pendingOperation = ref<AffiliateWithdrawOperation | null>(null)
|
||||
const outcomeUncertain = computed(() => pendingOperation.value?.outcomeUncertain === true)
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let searchSeq = 0
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+87
-14
@@ -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"}')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { completeAffiliateWithdrawOperation, prepareAffiliateWithdrawOperation } from '../affiliateWithdrawOperation'
|
||||
|
||||
let adminId = 2000
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('auth_user', JSON.stringify({ id: ++adminId }))
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
describe('affiliate offline withdrawal operation identity', () => {
|
||||
it('keeps the key of an unfinished registration for the same user and ledger amount', () => {
|
||||
const first = prepareAffiliateWithdrawOperation(42, 10)
|
||||
const retry = prepareAffiliateWithdrawOperation(42, 10.000000001)
|
||||
|
||||
expect(first.outcomeUncertain).toBe(false)
|
||||
expect(first.key).toMatch(new RegExp(`^affiliate-withdraw-${adminId}-`))
|
||||
expect(retry.key).toBe(first.key)
|
||||
expect(retry.amount).toBe(10)
|
||||
expect(retry.outcomeUncertain).toBe(true)
|
||||
|
||||
completeAffiliateWithdrawOperation(retry)
|
||||
const next = prepareAffiliateWithdrawOperation(42, 10)
|
||||
expect(next.key).not.toBe(first.key)
|
||||
expect(next.outcomeUncertain).toBe(false)
|
||||
})
|
||||
|
||||
it('isolates different users, amounts and administrators', () => {
|
||||
const first = prepareAffiliateWithdrawOperation(42, 10)
|
||||
const differentAmount = prepareAffiliateWithdrawOperation(42, 10.5)
|
||||
const differentUser = prepareAffiliateWithdrawOperation(43, 10)
|
||||
localStorage.setItem('auth_user', JSON.stringify({ id: ++adminId }))
|
||||
const differentAdmin = prepareAffiliateWithdrawOperation(42, 10)
|
||||
|
||||
expect(new Set([first, differentAmount, differentUser, differentAdmin].map(operation => operation.key)).size).toBe(4)
|
||||
})
|
||||
|
||||
it('resumes a key stored by an earlier page and clears it after completion', () => {
|
||||
const scope = `sub2api:admin:affiliate-withdraw:${adminId}:42:12.5`
|
||||
sessionStorage.setItem(scope, 'saved-withdraw-key')
|
||||
|
||||
const resumed = prepareAffiliateWithdrawOperation(42, 12.5)
|
||||
expect(resumed.key).toBe('saved-withdraw-key')
|
||||
expect(resumed.outcomeUncertain).toBe(true)
|
||||
|
||||
completeAffiliateWithdrawOperation(resumed)
|
||||
expect(sessionStorage.getItem(scope)).toBeNull()
|
||||
expect(prepareAffiliateWithdrawOperation(42, 12.5).key).not.toBe('saved-withdraw-key')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
/** 一笔线下提现登记及标识它的幂等键。 */
|
||||
export interface AffiliateWithdrawOperation {
|
||||
userId: number
|
||||
amount: number
|
||||
key: string
|
||||
storageKey: string | null
|
||||
outcomeUncertain: boolean
|
||||
}
|
||||
|
||||
// 返利额度按 8 位小数记账。
|
||||
const LEDGER_SCALE = 1e8
|
||||
|
||||
const pendingKeys = new Map<string, string>()
|
||||
|
||||
function currentAdminId(): number | null {
|
||||
try {
|
||||
const user = JSON.parse(globalThis.localStorage?.getItem('auth_user') ?? 'null') as { id?: unknown } | null
|
||||
const id = user?.id
|
||||
return typeof id === 'number' && Number.isSafeInteger(id) && id > 0 ? id : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredKey(storageKey: string): string | null {
|
||||
try {
|
||||
return globalThis.sessionStorage?.getItem(storageKey) ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function storeKey(storageKey: string, key: string | null) {
|
||||
try {
|
||||
if (key) globalThis.sessionStorage?.setItem(storageKey, key)
|
||||
else globalThis.sessionStorage?.removeItem(storageKey)
|
||||
} catch {
|
||||
// 浏览器存储不可用时,同一页面内的重试仍由内存中的键保护。
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得该用户与金额对应的登记。结果未知的登记在完成前一直保留它的键,
|
||||
* 再次提交同一用户与金额时沿用该键,服务端据此返回首次结果而不重复扣减。
|
||||
*/
|
||||
export function prepareAffiliateWithdrawOperation(userId: number, amount: number): AffiliateWithdrawOperation {
|
||||
const normalizedAmount = Math.round(amount * LEDGER_SCALE) / LEDGER_SCALE
|
||||
const adminId = currentAdminId()
|
||||
const storageKey = adminId ? `sub2api:admin:affiliate-withdraw:${adminId}:${userId}:${normalizedAmount}` : null
|
||||
let key = storageKey ? pendingKeys.get(storageKey) ?? readStoredKey(storageKey) : null
|
||||
const outcomeUncertain = !!key
|
||||
if (!key) {
|
||||
const requestId = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
key = `affiliate-withdraw-${adminId ?? 'unknown'}-${requestId}`
|
||||
}
|
||||
if (storageKey) {
|
||||
pendingKeys.set(storageKey, key)
|
||||
storeKey(storageKey, key)
|
||||
}
|
||||
return { userId, amount: normalizedAmount, key, storageKey, outcomeUncertain }
|
||||
}
|
||||
|
||||
/** 登记有了确定结果后释放它的键。 */
|
||||
export function completeAffiliateWithdrawOperation(operation: AffiliateWithdrawOperation) {
|
||||
if (!operation.storageKey) return
|
||||
pendingKeys.delete(operation.storageKey)
|
||||
storeKey(operation.storageKey, null)
|
||||
}
|
||||
Reference in New Issue
Block a user