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:
feeeei
2026-09-23 01:24:59 +08:00
parent 57b7dbdc85
commit c19204289d
17 changed files with 859 additions and 140 deletions
+24 -3
View File
@@ -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")
}