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

feat(affiliate): 提取记录支持登记线下提现
This commit is contained in:
Wesley Liddick
2026-09-23 11:20:32 +08:00
committed by GitHub
20 changed files with 2084 additions and 36 deletions
@@ -201,6 +201,42 @@ func (h *AffiliateHandler) GetUserOverview(c *gin.Context) {
response.Success(c, overview)
}
// WithdrawQuotaRequest 是登记线下提现的请求体。Amount 是已在站外打款给
// 用户的金额,由 AffiliateService 校验。
type WithdrawQuotaRequest struct {
Amount float64 `json:"amount"`
}
// WithdrawQuota records an offline withdrawal paid outside the site and
// deducts it from the user's available affiliate quota. The required
// Idempotency-Key header identifies one registration: a repeated request with
// the same key deducts nothing and returns the first result with
// X-Idempotency-Replayed: true.
// POST /api/v1/admin/affiliates/users/:user_id/withdraw
func (h *AffiliateHandler) WithdrawQuota(c *gin.Context) {
userID, err := strconv.ParseInt(c.Param("user_id"), 10, 64)
if err != nil || userID <= 0 {
response.BadRequest(c, "Invalid user_id")
return
}
var req WithdrawQuotaRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
result, err := h.affiliateService.AdminWithdrawQuota(c.Request.Context(), userID, req.Amount, c.GetHeader("Idempotency-Key"))
if err != nil {
response.ErrorFrom(c, err)
return
}
if result != nil && result.Replayed {
c.Header("X-Idempotency-Replayed", "true")
}
response.Success(c, result)
}
// ListInviteRecords returns all inviter-invitee relationships.
// GET /api/v1/admin/affiliates/invites
func (h *AffiliateHandler) ListInviteRecords(c *gin.Context) {
@@ -214,7 +250,8 @@ func (h *AffiliateHandler) ListInviteRecords(c *gin.Context) {
response.Paginated(c, items, total, filter.Page, filter.PageSize)
}
// ListRebateRecords returns all order-level affiliate rebate records.
// ListRebateRecords returns every affiliate rebate accrual, with order details
// when the rebate came from a payment order.
// GET /api/v1/admin/affiliates/rebates
func (h *AffiliateHandler) ListRebateRecords(c *gin.Context) {
page, pageSize := response.ParsePagination(c)
@@ -227,7 +264,8 @@ func (h *AffiliateHandler) ListRebateRecords(c *gin.Context) {
response.Paginated(c, items, total, filter.Page, filter.PageSize)
}
// ListTransferRecords returns all affiliate quota-to-balance transfer records.
// ListTransferRecords returns affiliate quota outflows: user transfers into
// balance and admin-recorded offline withdrawals.
// GET /api/v1/admin/affiliates/transfers
func (h *AffiliateHandler) ListTransferRecords(c *gin.Context) {
page, pageSize := response.ParsePagination(c)
@@ -0,0 +1,172 @@
package admin
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
const testWithdrawIdempotencyKey = "affiliate-withdraw-1-5d0c7a2e"
type withdrawQuotaCall struct {
userID int64
amount float64
operationID string
}
type withdrawQuotaRepoStub struct {
service.AffiliateRepository
calls []withdrawQuotaCall
result *service.AffiliateWithdrawResult
err error
}
func (s *withdrawQuotaRepoStub) WithdrawQuota(_ context.Context, userID int64, amount float64, operationID string) (*service.AffiliateWithdrawResult, error) {
s.calls = append(s.calls, withdrawQuotaCall{userID: userID, amount: amount, operationID: operationID})
return s.result, s.err
}
type withdrawQuotaResponse struct {
Code int `json:"code"`
Reason string `json:"reason"`
Data json.RawMessage `json:"data"`
}
func performWithdrawQuota(t *testing.T, repo *withdrawQuotaRepoStub, userID, idempotencyKey, body string) (*httptest.ResponseRecorder, withdrawQuotaResponse) {
t.Helper()
gin.SetMode(gin.TestMode)
router := gin.New()
handler := NewAffiliateHandler(service.NewAffiliateService(repo, nil, nil, nil), nil)
router.POST("/api/v1/admin/affiliates/users/:user_id/withdraw", handler.WithdrawQuota)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/affiliates/users/"+userID+"/withdraw", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
var resp withdrawQuotaResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
return rec, resp
}
// TestAffiliateHandlerWithdrawQuota_Success 验证登记线下提现把路径中的用户、
// 舍入后的金额与幂等键派生的 operation_id 交给仓储,并返回登记结果。
func TestAffiliateHandlerWithdrawQuota_Success(t *testing.T) {
repo := &withdrawQuotaRepoStub{result: &service.AffiliateWithdrawResult{LedgerID: 9, UserID: 42, Amount: 12.34567891}}
rec, resp := performWithdrawQuota(t, repo, "42", testWithdrawIdempotencyKey, `{"amount":12.3456789149}`)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, 0, resp.Code)
var data service.AffiliateWithdrawResult
require.NoError(t, json.Unmarshal(resp.Data, &data))
require.Equal(t, int64(9), data.LedgerID)
require.Empty(t, rec.Header().Get("X-Idempotency-Replayed"))
require.Len(t, repo.calls, 1)
require.Equal(t, int64(42), repo.calls[0].userID)
require.Equal(t, 12.34567891, repo.calls[0].amount)
require.Regexp(t, `^[0-9a-f]{64}$`, repo.calls[0].operationID)
}
// TestAffiliateHandlerWithdrawQuota_SameKeyMapsToSameOperation 验证重试时带同一个
// Idempotency-Key 的请求落到同一个 operation_id,换一个键则是另一笔登记。
func TestAffiliateHandlerWithdrawQuota_SameKeyMapsToSameOperation(t *testing.T) {
repo := &withdrawQuotaRepoStub{result: &service.AffiliateWithdrawResult{LedgerID: 9, UserID: 42, Amount: 10}}
for _, key := range []string{testWithdrawIdempotencyKey, testWithdrawIdempotencyKey, testWithdrawIdempotencyKey + "-next"} {
rec, _ := performWithdrawQuota(t, repo, "42", key, `{"amount":10}`)
require.Equal(t, http.StatusOK, rec.Code)
}
require.Len(t, repo.calls, 3)
require.Equal(t, repo.calls[0].operationID, repo.calls[1].operationID)
require.NotEqual(t, repo.calls[0].operationID, repo.calls[2].operationID)
}
// TestAffiliateHandlerWithdrawQuota_ReplayReturnsFirstResult 验证命中已有登记时
// 原样返回首次登记的结果,并以 X-Idempotency-Replayed 标明是重放。
func TestAffiliateHandlerWithdrawQuota_ReplayReturnsFirstResult(t *testing.T) {
repo := &withdrawQuotaRepoStub{result: &service.AffiliateWithdrawResult{
LedgerID: 9,
UserID: 42,
Amount: 10,
AvailableQuotaAfter: 90,
HistoryQuotaAfter: 100,
Replayed: true,
}}
rec, resp := performWithdrawQuota(t, repo, "42", testWithdrawIdempotencyKey, `{"amount":10}`)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "true", rec.Header().Get("X-Idempotency-Replayed"))
require.JSONEq(t, `{
"ledger_id": 9,
"user_id": 42,
"amount": 10,
"available_quota_after": 90,
"frozen_quota_after": 0,
"history_quota_after": 100
}`, string(resp.Data))
}
// TestAffiliateHandlerWithdrawQuota_RejectsBadRequests 验证非法用户 ID、非法 JSON、
// 缺少或非法的幂等键与非法金额在进入仓储前以 400 拒绝。
func TestAffiliateHandlerWithdrawQuota_RejectsBadRequests(t *testing.T) {
cases := []struct {
name string
userID string
key string
body string
wantReason string
}{
{name: "non-numeric user id", userID: "abc", key: testWithdrawIdempotencyKey, body: `{"amount":1}`},
{name: "non-positive user id", userID: "0", key: testWithdrawIdempotencyKey, body: `{"amount":1}`},
{name: "malformed json", userID: "42", key: testWithdrawIdempotencyKey, body: `{"amount":`},
{name: "missing idempotency key", userID: "42", body: `{"amount":1}`, wantReason: "IDEMPOTENCY_KEY_REQUIRED"},
{name: "invalid idempotency key", userID: "42", key: "has space", body: `{"amount":1}`, wantReason: "IDEMPOTENCY_KEY_INVALID"},
{name: "zero amount", userID: "42", key: testWithdrawIdempotencyKey, body: `{"amount":0}`, wantReason: "AFFILIATE_WITHDRAW_AMOUNT_INVALID"},
{name: "missing amount", userID: "42", key: testWithdrawIdempotencyKey, body: `{}`, wantReason: "AFFILIATE_WITHDRAW_AMOUNT_INVALID"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
repo := &withdrawQuotaRepoStub{}
rec, resp := performWithdrawQuota(t, repo, tc.userID, tc.key, tc.body)
require.Equal(t, http.StatusBadRequest, rec.Code)
require.Equal(t, tc.wantReason, resp.Reason)
require.Empty(t, repo.calls)
})
}
}
// TestAffiliateHandlerWithdrawQuota_RepositoryRejections 验证额度不足以 400、
// 同一幂等键换了用户或金额以 409 返回,前端据错误码提示。
func TestAffiliateHandlerWithdrawQuota_RepositoryRejections(t *testing.T) {
cases := []struct {
err error
wantStatus int
wantReason string
}{
{err: service.ErrAffiliateQuotaInsufficient, wantStatus: http.StatusBadRequest, wantReason: "AFFILIATE_QUOTA_INSUFFICIENT"},
{err: service.ErrIdempotencyKeyConflict, wantStatus: http.StatusConflict, wantReason: "IDEMPOTENCY_KEY_CONFLICT"},
}
for _, tc := range cases {
t.Run(tc.wantReason, func(t *testing.T) {
repo := &withdrawQuotaRepoStub{err: tc.err}
rec, resp := performWithdrawQuota(t, repo, "42", testWithdrawIdempotencyKey, `{"amount":5}`)
require.Equal(t, tc.wantStatus, rec.Code)
require.Equal(t, tc.wantReason, resp.Reason)
require.Empty(t, rec.Header().Get("X-Idempotency-Replayed"))
require.Len(t, repo.calls, 1)
})
}
}
@@ -407,6 +407,10 @@ func (r *oauthEmailAffiliateRepoStub) TransferQuotaToBalance(context.Context, in
panic("unexpected TransferQuotaToBalance call")
}
func (r *oauthEmailAffiliateRepoStub) WithdrawQuota(context.Context, int64, float64, string) (*service.AffiliateWithdrawResult, error) {
panic("unexpected WithdrawQuota call")
}
func (r *oauthEmailAffiliateRepoStub) ListInvitees(context.Context, int64, int) ([]service.AffiliateInvitee, error) {
panic("unexpected ListInvitees call")
}
+196 -12
View File
@@ -341,6 +341,171 @@ VALUES ($1, 'transfer', $2, NULL, $3, $4, $5, $6, NOW(), NOW())`,
return transferred, newBalance, nil
}
// WithdrawQuota 登记一笔线下提现。operationID 标识一次登记:同一事务内先以
// operation_id 唯一约束写入占位流水,同标识的流水已存在时不重复扣减,用户与金额
// 一致则返回该流水记录的结果(Replayed=true),不一致返回
// ErrIdempotencyKeyConflict。占位成功后先解冻已到期的冻结额度,再以
// aff_quota >= amount 为条件原子扣减并补写额度快照。可提取额度不足或用户没有
// 返利档案时返回 ErrAffiliateQuotaInsufficient,占位随事务回滚,不占用该标识。
func (r *affiliateRepository) WithdrawQuota(ctx context.Context, userID int64, amount float64, operationID string) (*service.AffiliateWithdrawResult, error) {
if userID <= 0 {
return nil, service.ErrUserNotFound
}
if amount <= 0 {
return nil, service.ErrAffiliateWithdrawAmountInvalid
}
if operationID == "" {
return nil, service.ErrIdempotencyKeyRequired
}
var result *service.AffiliateWithdrawResult
err := r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error {
ledgerID, claimed, err := claimAffiliateWithdrawLedger(txCtx, txClient, userID, amount, operationID)
if err != nil {
return err
}
if !claimed {
result, err = findAffiliateWithdrawByOperation(txCtx, txClient, userID, amount, operationID)
return err
}
if _, err := thawFrozenQuotaTx(txCtx, txClient, userID); err != nil {
return fmt.Errorf("thaw before withdraw: %w", err)
}
res, err := txClient.ExecContext(txCtx, `
UPDATE user_affiliates
SET aff_quota = aff_quota - $1,
updated_at = NOW()
WHERE user_id = $2
AND aff_quota >= $1`, amount, userID)
if err != nil {
return fmt.Errorf("deduct affiliate quota: %w", err)
}
affected, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("deduct affiliate quota: %w", err)
}
if affected == 0 {
return service.ErrAffiliateQuotaInsufficient
}
snapshot, err := queryAffiliateTransferSnapshot(txCtx, txClient, userID)
if err != nil {
return err
}
if _, err := txClient.ExecContext(txCtx, `
UPDATE user_affiliate_ledger
SET balance_after = $1,
aff_quota_after = $2,
aff_frozen_quota_after = $3,
aff_history_quota_after = $4,
updated_at = NOW()
WHERE id = $5`,
snapshot.BalanceAfter,
snapshot.AvailableQuotaAfter,
snapshot.FrozenQuotaAfter,
snapshot.HistoryQuotaAfter,
ledgerID,
); err != nil {
return fmt.Errorf("record affiliate withdraw snapshot: %w", err)
}
result = &service.AffiliateWithdrawResult{
LedgerID: ledgerID,
UserID: userID,
Amount: amount,
AvailableQuotaAfter: snapshot.AvailableQuotaAfter,
FrozenQuotaAfter: snapshot.FrozenQuotaAfter,
HistoryQuotaAfter: snapshot.HistoryQuotaAfter,
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
// claimAffiliateWithdrawLedger 为有返利档案的用户写入一条带 operation_id 的
// withdraw 占位流水。同标识的流水已存在或用户没有返利档案时 claimed 为 false。
// 同标识的并发事务在唯一索引上等待先写入者结束:先写入者提交后返回 claimed=false,
// 回滚后本事务继续写入。
func claimAffiliateWithdrawLedger(ctx context.Context, client affiliateQueryExecer, userID int64, amount float64, operationID string) (int64, bool, error) {
rows, err := client.QueryContext(ctx, `
INSERT INTO user_affiliate_ledger (user_id, action, amount, operation_id, created_at, updated_at)
SELECT ua.user_id, 'withdraw', $2, $3, NOW(), NOW()
FROM user_affiliates ua
WHERE ua.user_id = $1
ON CONFLICT (operation_id) WHERE operation_id IS NOT NULL DO NOTHING
RETURNING id`, userID, amount, operationID)
if err != nil {
return 0, false, fmt.Errorf("claim affiliate withdraw ledger: %w", err)
}
defer func() { _ = rows.Close() }()
if !rows.Next() {
if err := rows.Err(); err != nil {
return 0, false, fmt.Errorf("claim affiliate withdraw ledger: %w", err)
}
return 0, false, nil
}
var ledgerID int64
if err := rows.Scan(&ledgerID); err != nil {
return 0, false, err
}
return ledgerID, true, rows.Close()
}
// findAffiliateWithdrawByOperation 读取 operation_id 已对应的线下提现流水,
// 用户与金额一致时返回该流水记录的登记结果(Replayed=true),不一致返回
// ErrIdempotencyKeyConflict;没有对应流水说明用户没有返利档案,返回
// ErrAffiliateQuotaInsufficient。
func findAffiliateWithdrawByOperation(ctx context.Context, client affiliateQueryExecer, userID int64, amount float64, operationID string) (*service.AffiliateWithdrawResult, error) {
rows, err := client.QueryContext(ctx, `
SELECT id,
user_id = $2 AND action = 'withdraw' AND amount = CAST($3 AS DECIMAL(20,8)),
user_id,
amount::double precision,
COALESCE(aff_quota_after, 0)::double precision,
COALESCE(aff_frozen_quota_after, 0)::double precision,
COALESCE(aff_history_quota_after, 0)::double precision
FROM user_affiliate_ledger
WHERE operation_id = $1`, operationID, userID, amount)
if err != nil {
return nil, fmt.Errorf("query affiliate withdraw by operation: %w", err)
}
defer func() { _ = rows.Close() }()
if !rows.Next() {
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("query affiliate withdraw by operation: %w", err)
}
return nil, service.ErrAffiliateQuotaInsufficient
}
var sameRequest bool
result := &service.AffiliateWithdrawResult{Replayed: true}
if err := rows.Scan(
&result.LedgerID,
&sameRequest,
&result.UserID,
&result.Amount,
&result.AvailableQuotaAfter,
&result.FrozenQuotaAfter,
&result.HistoryQuotaAfter,
); err != nil {
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
if !sameRequest {
return nil, service.ErrIdempotencyKeyConflict
}
return result, nil
}
func (r *affiliateRepository) ListInvitees(ctx context.Context, inviterID int64, limit int) ([]service.AffiliateInvitee, error) {
if limit <= 0 {
limit = 100
@@ -466,13 +631,14 @@ func (r *affiliateRepository) ListAffiliateRebateRecords(ctx context.Context, fi
"inviter.email", "inviter.username", "invitee.email", "invitee.username",
"po.id::text", "po.out_trade_no", "po.payment_type", "po.status",
})
// 返利记录列出全部 accrue 流水:非订单来源的流水没有订单,被邀请人账号
// 删除后 source_user_id 为 NULL,这两类行都保留。
baseJoin := `
FROM user_affiliate_ledger ual
JOIN payment_orders po ON po.id = ual.source_order_id
JOIN users invitee ON invitee.id = ual.source_user_id
JOIN users inviter ON inviter.id = ual.user_id
WHERE ual.action = 'accrue'
AND ual.source_order_id IS NOT NULL`
LEFT JOIN users invitee ON invitee.id = ual.source_user_id
LEFT JOIN payment_orders po ON po.id = ual.source_order_id
WHERE ual.action = 'accrue'`
if where != "" {
where = strings.Replace(where, "WHERE ", " AND ", 1)
}
@@ -496,7 +662,7 @@ WHERE ual.action = 'accrue'
args = append(args, filter.PageSize, (filter.Page-1)*filter.PageSize)
rows, err := client.QueryContext(ctx, `
SELECT po.id,
po.out_trade_no,
COALESCE(po.out_trade_no, ''),
ual.user_id,
COALESCE(inviter.email, ''),
COALESCE(inviter.username, ''),
@@ -506,8 +672,8 @@ SELECT po.id,
po.amount::double precision,
po.pay_amount::double precision,
ual.amount::double precision,
po.payment_type,
po.status,
COALESCE(po.payment_type, ''),
COALESCE(po.status, ''),
ual.created_at
`+baseJoin+where+`
`+orderBy+`
@@ -520,17 +686,21 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
items := make([]service.AffiliateRebateRecord, 0)
for rows.Next() {
var item service.AffiliateRebateRecord
var orderID sql.NullInt64
var inviteeID sql.NullInt64
var orderAmount sql.NullFloat64
var payAmount sql.NullFloat64
if err := rows.Scan(
&item.OrderID,
&orderID,
&item.OutTradeNo,
&item.InviterID,
&item.InviterEmail,
&item.InviterUsername,
&item.InviteeID,
&inviteeID,
&item.InviteeEmail,
&item.InviteeUsername,
&item.OrderAmount,
&item.PayAmount,
&orderAmount,
&payAmount,
&item.RebateAmount,
&item.PaymentType,
&item.OrderStatus,
@@ -538,6 +708,10 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
); err != nil {
return nil, 0, err
}
item.OrderID = nullableInt64Ptr(orderID)
item.InviteeID = nullableInt64Ptr(inviteeID)
item.OrderAmount = nullableFloat64Ptr(orderAmount)
item.PayAmount = nullableFloat64Ptr(payAmount)
items = append(items, item)
}
if err := rows.Err(); err != nil {
@@ -554,7 +728,7 @@ func (r *affiliateRepository) ListAffiliateTransferRecords(ctx context.Context,
baseJoin := `
FROM user_affiliate_ledger ual
JOIN users u ON u.id = ual.user_id
WHERE ual.action = 'transfer'`
WHERE ual.action IN ('transfer', 'withdraw')`
if where != "" {
where = strings.Replace(where, "WHERE ", " AND ", 1)
}
@@ -566,6 +740,7 @@ WHERE ual.action = 'transfer'`
orderBy := buildAffiliateRecordOrderBy(filter, map[string]string{
"user": "u.email",
"action": "ual.action",
"amount": "ual.amount",
"balance_after": "ual.balance_after",
"available_quota_after": "ual.aff_quota_after",
@@ -576,6 +751,7 @@ WHERE ual.action = 'transfer'`
args = append(args, filter.PageSize, (filter.Page-1)*filter.PageSize)
rows, err := client.QueryContext(ctx, `
SELECT ual.id,
ual.action,
ual.user_id,
COALESCE(u.email, ''),
COALESCE(u.username, ''),
@@ -602,6 +778,7 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
var historyQuotaAfter sql.NullFloat64
if err := rows.Scan(
&item.LedgerID,
&item.Action,
&item.UserID,
&item.UserEmail,
&item.Username,
@@ -958,6 +1135,13 @@ func nullableFloat64Ptr(v sql.NullFloat64) *float64 {
return &v.Float64
}
func nullableInt64Ptr(v sql.NullInt64) *int64 {
if !v.Valid {
return nil
}
return &v.Int64
}
func generateAffiliateCode() (string, error) {
buf := make([]byte, affiliateCodeLength)
if _, err := rand.Read(buf); err != nil {
@@ -4,7 +4,10 @@ package repository
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"sync"
"testing"
"time"
@@ -417,3 +420,383 @@ func TestAffiliateRepository_ListUsersWithCustomSettings(t *testing.T) {
require.GreaterOrEqual(t, total, int64(2), "total must include at least our 2 custom rows")
}
func mustCreateAffiliateWithdrawUser(t *testing.T, client *dbent.Client, label string) *service.User {
t.Helper()
return mustCreateUser(t, client, &service.User{
Email: fmt.Sprintf("affiliate-withdraw-%s-%d@example.com", label, time.Now().UnixNano()),
PasswordHash: "hash",
Role: service.RoleUser,
Status: service.StatusActive,
Balance: 5.5,
Concurrency: 5,
})
}
func mustSeedAffiliateQuota(t *testing.T, ctx context.Context, client *dbent.Client, userID int64, quota, frozen, history float64) {
t.Helper()
affCode := fmt.Sprintf("AFW%d-%06d", userID, time.Now().UnixNano()%1_000_000)
_, err := client.ExecContext(ctx, `
INSERT INTO user_affiliates (user_id, aff_code, aff_quota, aff_frozen_quota, aff_history_quota, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())`, userID, affCode, quota, frozen, history)
require.NoError(t, err)
}
// mustCreateCommittedAffiliateWithdrawUser 在测试事务之外建用户与返利档案,
// 测试结束时删除(级联返利档案与流水)。WithdrawQuota 在这些用例里自行开启
// 并提交事务,与生产调用一致。
func mustCreateCommittedAffiliateWithdrawUser(t *testing.T, label string, quota, frozen, history float64) *service.User {
t.Helper()
client := testEntClient(t)
u := mustCreateAffiliateWithdrawUser(t, client, label)
t.Cleanup(func() {
_, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM users WHERE id = $1", u.ID)
})
mustSeedAffiliateQuota(t, context.Background(), client, u.ID, quota, frozen, history)
return u
}
func newAffiliateWithdrawOperationID(label string) string {
sum := sha256.Sum256([]byte(fmt.Sprintf("%s-%d", label, time.Now().UnixNano())))
return hex.EncodeToString(sum[:])
}
// TestAffiliateRepository_WithdrawQuota_DeductsAndRecordsLedger 覆盖线下提现主路径:
// 只扣可提取额度,余额与累计返利不变,流水 action=withdraw 带额度快照,并出现在提取记录中。
func TestAffiliateRepository_WithdrawQuota_DeductsAndRecordsLedger(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
txCtx := dbent.NewTxContext(ctx, tx)
client := tx.Client()
repo := NewAffiliateRepository(client, integrationDB)
u := mustCreateAffiliateWithdrawUser(t, client, "user")
mustSeedAffiliateQuota(t, txCtx, client, u.ID, 20, 0, 30)
operationID := newAffiliateWithdrawOperationID("deduct")
result, err := repo.WithdrawQuota(txCtx, u.ID, 12.5, operationID)
require.NoError(t, err)
require.Positive(t, result.LedgerID)
require.False(t, result.Replayed)
require.InDelta(t, 12.5, result.Amount, 1e-9)
require.InDelta(t, 7.5, result.AvailableQuotaAfter, 1e-9)
require.InDelta(t, 0.0, result.FrozenQuotaAfter, 1e-9)
require.InDelta(t, 30.0, result.HistoryQuotaAfter, 1e-9)
require.InDelta(t, 7.5, querySingleFloat(t, txCtx, client,
"SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9)
require.InDelta(t, 30.0, querySingleFloat(t, txCtx, client,
"SELECT aff_history_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9)
require.InDelta(t, 5.5, querySingleFloat(t, txCtx, client,
"SELECT balance::double precision FROM users WHERE id = $1", u.ID), 1e-9)
records, total, err := repo.ListAffiliateTransferRecords(txCtx, service.AffiliateRecordFilter{
Search: u.Email,
Page: 1,
PageSize: 20,
SortDesc: true,
})
require.NoError(t, err)
require.EqualValues(t, 1, total)
require.Len(t, records, 1)
record := records[0]
require.Equal(t, result.LedgerID, record.LedgerID)
require.Equal(t, "withdraw", record.Action)
require.Equal(t, u.ID, record.UserID)
require.InDelta(t, 12.5, record.Amount, 1e-9)
require.True(t, record.SnapshotAvailable)
require.InDelta(t, 5.5, *record.BalanceAfter, 1e-9)
require.InDelta(t, 7.5, *record.AvailableQuotaAfter, 1e-9)
require.InDelta(t, 30.0, *record.HistoryQuotaAfter, 1e-9)
rows, err := client.QueryContext(txCtx, "SELECT operation_id FROM user_affiliate_ledger WHERE id = $1", result.LedgerID)
require.NoError(t, err)
defer func() { _ = rows.Close() }()
require.True(t, rows.Next())
var storedOperationID string
require.NoError(t, rows.Scan(&storedOperationID))
require.Equal(t, operationID, storedOperationID)
}
// TestAffiliateRepository_WithdrawQuota_ThawsMaturedQuotaFirst 验证已过冻结期
// 但尚未解冻的额度在扣减前解冻并可被提现。
func TestAffiliateRepository_WithdrawQuota_ThawsMaturedQuotaFirst(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
txCtx := dbent.NewTxContext(ctx, tx)
client := tx.Client()
repo := NewAffiliateRepository(client, integrationDB)
u := mustCreateAffiliateWithdrawUser(t, client, "thaw")
mustSeedAffiliateQuota(t, txCtx, client, u.ID, 0, 10, 10)
_, err := client.ExecContext(txCtx, `
INSERT INTO user_affiliate_ledger (user_id, action, amount, frozen_until, created_at, updated_at)
VALUES ($1, 'accrue', 10, NOW() - INTERVAL '1 hour', NOW(), NOW())`, u.ID)
require.NoError(t, err)
result, err := repo.WithdrawQuota(txCtx, u.ID, 10, newAffiliateWithdrawOperationID("thaw"))
require.NoError(t, err)
require.InDelta(t, 0.0, result.AvailableQuotaAfter, 1e-9)
require.InDelta(t, 0.0, result.FrozenQuotaAfter, 1e-9)
require.Equal(t, 1, querySingleInt(t, txCtx, client,
"SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.ID))
}
// TestAffiliateRepository_WithdrawQuota_InsufficientLeavesQuotaUntouched 验证未到期的
// 冻结额度不可提现:额度不足时不扣减、不写流水,占位随事务回滚、不占用该
// operation_id,同一标识改成可提取范围内的金额仍可登记;没有返利档案的用户
// 同样按额度不足处理。
func TestAffiliateRepository_WithdrawQuota_InsufficientLeavesQuotaUntouched(t *testing.T) {
ctx := context.Background()
client := testEntClient(t)
repo := NewAffiliateRepository(client, integrationDB)
u := mustCreateCommittedAffiliateWithdrawUser(t, "insufficient", 5, 10, 15)
_, err := integrationDB.ExecContext(ctx, `
INSERT INTO user_affiliate_ledger (user_id, action, amount, frozen_until, created_at, updated_at)
VALUES ($1, 'accrue', 10, NOW() + INTERVAL '1 hour', NOW(), NOW())`, u.ID)
require.NoError(t, err)
operationID := newAffiliateWithdrawOperationID("insufficient")
_, err = repo.WithdrawQuota(ctx, u.ID, 6, operationID)
require.ErrorIs(t, err, service.ErrAffiliateQuotaInsufficient)
require.InDelta(t, 5.0, querySingleFloat(t, ctx, client,
"SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9)
require.InDelta(t, 10.0, querySingleFloat(t, ctx, client,
"SELECT aff_frozen_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9)
require.Equal(t, 0, querySingleInt(t, ctx, client,
"SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.ID))
result, err := repo.WithdrawQuota(ctx, u.ID, 5, operationID)
require.NoError(t, err)
require.False(t, result.Replayed)
require.InDelta(t, 0.0, result.AvailableQuotaAfter, 1e-9)
noProfile := mustCreateAffiliateWithdrawUser(t, client, "no-profile")
t.Cleanup(func() {
_, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM users WHERE id = $1", noProfile.ID)
})
_, err = repo.WithdrawQuota(ctx, noProfile.ID, 1, newAffiliateWithdrawOperationID("no-profile"))
require.ErrorIs(t, err, service.ErrAffiliateQuotaInsufficient)
require.Equal(t, 0, querySingleInt(t, ctx, client,
"SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1", noProfile.ID))
}
// TestAffiliateRepository_WithdrawQuota_RetryAfterCommitReplaysFirstResult 复现
// 「提交成功但响应丢失后重试」:首次登记已在独立事务中提交,以同一 operation_id
// 重试不重复扣减、不重复写流水,返回首次登记的流水与额度快照。两笔合法的等额打款
// 使用不同的 operation_id,各扣一次。
func TestAffiliateRepository_WithdrawQuota_RetryAfterCommitReplaysFirstResult(t *testing.T) {
ctx := context.Background()
client := testEntClient(t)
repo := NewAffiliateRepository(client, integrationDB)
u := mustCreateCommittedAffiliateWithdrawUser(t, "retry", 100, 0, 100)
operationID := newAffiliateWithdrawOperationID("retry")
first, err := repo.WithdrawQuota(ctx, u.ID, 10, operationID)
require.NoError(t, err)
require.False(t, first.Replayed)
retry, err := repo.WithdrawQuota(ctx, u.ID, 10, operationID)
require.NoError(t, err)
require.True(t, retry.Replayed)
require.Equal(t, first.LedgerID, retry.LedgerID)
require.Equal(t, u.ID, retry.UserID)
require.InDelta(t, 10.0, retry.Amount, 1e-9)
require.InDelta(t, 90.0, retry.AvailableQuotaAfter, 1e-9)
require.InDelta(t, first.FrozenQuotaAfter, retry.FrozenQuotaAfter, 1e-9)
require.InDelta(t, 100.0, retry.HistoryQuotaAfter, 1e-9)
require.InDelta(t, 90.0, querySingleFloat(t, ctx, client,
"SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9)
require.Equal(t, 1, querySingleInt(t, ctx, client,
"SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.ID))
second, err := repo.WithdrawQuota(ctx, u.ID, 10, newAffiliateWithdrawOperationID("retry-second"))
require.NoError(t, err)
require.False(t, second.Replayed)
require.NotEqual(t, first.LedgerID, second.LedgerID)
require.InDelta(t, 80.0, querySingleFloat(t, ctx, client,
"SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9)
require.Equal(t, 2, querySingleInt(t, ctx, client,
"SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.ID))
}
// TestAffiliateRepository_WithdrawQuota_SameOperationWaitsForInFlightRegistration
// 固定「并发同键」的交错:首个登记已占位并扣减但未提交时,同一 operation_id 的
// 请求在唯一索引上等待。首个登记提交后,等待的请求不重复扣减,返回同一条流水;
// 首个登记回滚后,等待的请求自行完成登记。两种结局都只扣一次。
func TestAffiliateRepository_WithdrawQuota_SameOperationWaitsForInFlightRegistration(t *testing.T) {
for _, commitFirst := range []bool{true, false} {
name := "first rolls back"
if commitFirst {
name = "first commits"
}
t.Run(name, func(t *testing.T) {
ctx := context.Background()
client := testEntClient(t)
repo := NewAffiliateRepository(client, integrationDB)
u := mustCreateCommittedAffiliateWithdrawUser(t, "inflight", 100, 0, 100)
operationID := newAffiliateWithdrawOperationID("inflight")
tx, err := client.Tx(ctx)
require.NoError(t, err)
t.Cleanup(func() { _ = tx.Rollback() })
first, err := repo.WithdrawQuota(dbent.NewTxContext(ctx, tx), u.ID, 10, operationID)
require.NoError(t, err)
type outcome struct {
result *service.AffiliateWithdrawResult
err error
}
waiting := make(chan outcome, 1)
go func() {
result, err := repo.WithdrawQuota(ctx, u.ID, 10, operationID)
waiting <- outcome{result: result, err: err}
}()
select {
case got := <-waiting:
t.Fatalf("same operation must wait for the in-flight registration, got result=%+v err=%v", got.result, got.err)
case <-time.After(300 * time.Millisecond):
}
if commitFirst {
require.NoError(t, tx.Commit())
} else {
require.NoError(t, tx.Rollback())
}
var got outcome
select {
case got = <-waiting:
case <-time.After(10 * time.Second):
t.Fatal("waiting registration did not finish after the in-flight one ended")
}
require.NoError(t, got.err)
if commitFirst {
require.True(t, got.result.Replayed)
require.Equal(t, first.LedgerID, got.result.LedgerID)
} else {
require.False(t, got.result.Replayed)
require.NotEqual(t, first.LedgerID, got.result.LedgerID)
}
require.InDelta(t, 90.0, got.result.AvailableQuotaAfter, 1e-9)
require.InDelta(t, 90.0, querySingleFloat(t, ctx, client,
"SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9)
require.Equal(t, 1, querySingleInt(t, ctx, client,
"SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.ID))
})
}
}
// TestAffiliateRepository_WithdrawQuota_ConcurrentSameOperationDeductsOnce 让多个请求
// 同时以同一 operation_id 登记:只有一个执行扣减,其余返回同一条流水。
func TestAffiliateRepository_WithdrawQuota_ConcurrentSameOperationDeductsOnce(t *testing.T) {
ctx := context.Background()
client := testEntClient(t)
repo := NewAffiliateRepository(client, integrationDB)
u := mustCreateCommittedAffiliateWithdrawUser(t, "concurrent", 100, 0, 100)
operationID := newAffiliateWithdrawOperationID("concurrent")
const workers = 8
results := make([]*service.AffiliateWithdrawResult, workers)
errs := make([]error, workers)
start := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
results[i], errs[i] = repo.WithdrawQuota(ctx, u.ID, 10, operationID)
}(i)
}
close(start)
wg.Wait()
executed := 0
for i := 0; i < workers; i++ {
require.NoError(t, errs[i])
require.Equal(t, results[0].LedgerID, results[i].LedgerID)
require.InDelta(t, 90.0, results[i].AvailableQuotaAfter, 1e-9)
if !results[i].Replayed {
executed++
}
}
require.Equal(t, 1, executed)
require.InDelta(t, 90.0, querySingleFloat(t, ctx, client,
"SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9)
require.Equal(t, 1, querySingleInt(t, ctx, client,
"SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.ID))
}
// TestAffiliateRepository_WithdrawQuota_RejectsOperationReuseWithDifferentRequest 验证
// 同一 operation_id 换了金额或用户时以 ErrIdempotencyKeyConflict 拒绝,
// 不扣减、不写流水。
func TestAffiliateRepository_WithdrawQuota_RejectsOperationReuseWithDifferentRequest(t *testing.T) {
ctx := context.Background()
client := testEntClient(t)
repo := NewAffiliateRepository(client, integrationDB)
u := mustCreateCommittedAffiliateWithdrawUser(t, "conflict", 100, 0, 100)
other := mustCreateCommittedAffiliateWithdrawUser(t, "conflict-other", 100, 0, 100)
operationID := newAffiliateWithdrawOperationID("conflict")
_, err := repo.WithdrawQuota(ctx, u.ID, 10, operationID)
require.NoError(t, err)
_, err = repo.WithdrawQuota(ctx, u.ID, 11, operationID)
require.ErrorIs(t, err, service.ErrIdempotencyKeyConflict)
_, err = repo.WithdrawQuota(ctx, other.ID, 10, operationID)
require.ErrorIs(t, err, service.ErrIdempotencyKeyConflict)
require.InDelta(t, 90.0, querySingleFloat(t, ctx, client,
"SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9)
require.InDelta(t, 100.0, querySingleFloat(t, ctx, client,
"SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", other.ID), 1e-9)
require.Equal(t, 1, querySingleInt(t, ctx, client,
"SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id IN ($1, $2) AND action = 'withdraw'", u.ID, other.ID))
}
// TestAffiliateRepository_ListAffiliateRebateRecords_IncludesNonOrderAccruals 验证兑换码
// 等非订单来源的返利出现在返利记录中,订单字段为空。
func TestAffiliateRepository_ListAffiliateRebateRecords_IncludesNonOrderAccruals(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
txCtx := dbent.NewTxContext(ctx, tx)
client := tx.Client()
repo := NewAffiliateRepository(client, integrationDB)
inviter := mustCreateAffiliateWithdrawUser(t, client, "inviter")
invitee := mustCreateAffiliateWithdrawUser(t, client, "invitee")
mustSeedAffiliateQuota(t, txCtx, client, inviter.ID, 0, 0, 0)
applied, err := repo.AccrueQuota(txCtx, inviter.ID, invitee.ID, 2, 0, nil)
require.NoError(t, err)
require.True(t, applied)
records, total, err := repo.ListAffiliateRebateRecords(txCtx, service.AffiliateRecordFilter{
Search: invitee.Email,
Page: 1,
PageSize: 20,
SortDesc: true,
})
require.NoError(t, err)
require.EqualValues(t, 1, total)
require.Len(t, records, 1)
record := records[0]
require.Nil(t, record.OrderID)
require.Nil(t, record.OrderAmount)
require.Nil(t, record.PayAmount)
require.Empty(t, record.OutTradeNo)
require.Equal(t, inviter.ID, record.InviterID)
require.NotNil(t, record.InviteeID)
require.Equal(t, invitee.ID, *record.InviteeID)
require.InDelta(t, 2.0, record.RebateAmount, 1e-9)
}
@@ -20,9 +20,56 @@ func TestAffiliateRecordQueriesUseLedgerAuditFields(t *testing.T) {
require.NoError(t, err)
content := string(source)
require.Contains(t, content, "JOIN payment_orders po ON po.id = ual.source_order_id")
require.Contains(t, content, "LEFT JOIN payment_orders po ON po.id = ual.source_order_id")
require.Contains(t, content, "ual.amount::double precision")
require.Contains(t, content, "ual.balance_after::double precision")
require.NotContains(t, content, "parseAffiliateRebateAmount")
require.NotContains(t, content, `"current_balance": "u.balance"`)
}
// TestAffiliateRebateRecordsQueryKeepsNonOrderAccruals 锁定返利记录列出全部
// accrue 流水:订单与被邀请人均为 LEFT JOIN,且不按 source_order_id 过滤,
// 兑换码、管理员充值来源的返利才会出现在明细里。
func TestAffiliateRebateRecordsQueryKeepsNonOrderAccruals(t *testing.T) {
source, err := os.ReadFile("affiliate_repo.go")
require.NoError(t, err)
content := string(source)
require.Contains(t, content, "LEFT JOIN payment_orders po ON po.id = ual.source_order_id")
require.Contains(t, content, "LEFT JOIN users invitee ON invitee.id = ual.source_user_id")
require.NotContains(t, content, "\nJOIN payment_orders po ON po.id = ual.source_order_id")
require.NotContains(t, content, "\nJOIN users invitee ON invitee.id = ual.source_user_id")
require.NotContains(t, content, "AND ual.source_order_id IS NOT NULL")
}
// TestAffiliateTransferRecordsQueryIncludesOfflineWithdrawals 锁定提取记录同时
// 列出转入余额与线下提现两类额度流出。
func TestAffiliateTransferRecordsQueryIncludesOfflineWithdrawals(t *testing.T) {
source, err := os.ReadFile("affiliate_repo.go")
require.NoError(t, err)
content := string(source)
require.Contains(t, content, "WHERE ual.action IN ('transfer', 'withdraw')")
}
// TestAffiliateWithdrawClaimsOperationBeforeDeducting 锁定线下提现的幂等形态:
// 同一事务内先按 operation_id 唯一约束写入占位流水,冲突时不扣减,
// 且唯一约束由迁移建立。
func TestAffiliateWithdrawClaimsOperationBeforeDeducting(t *testing.T) {
source, err := os.ReadFile("affiliate_repo.go")
require.NoError(t, err)
content := string(source)
require.Contains(t, content, "ON CONFLICT (operation_id) WHERE operation_id IS NOT NULL DO NOTHING")
withdraw := content[strings.Index(content, "func (r *affiliateRepository) WithdrawQuota("):]
withdraw = withdraw[:strings.Index(withdraw, "\n}\n")]
claimAt := strings.Index(withdraw, "claimAffiliateWithdrawLedger(")
deductAt := strings.Index(withdraw, "SET aff_quota = aff_quota - $1")
require.Positive(t, claimAt)
require.Greater(t, deductAt, claimAt, "operation claim must precede the quota deduction")
migration, err := os.ReadFile("../../migrations/240_affiliate_ledger_operation_id.sql")
require.NoError(t, err)
require.Contains(t, string(migration), "CREATE UNIQUE INDEX IF NOT EXISTS idx_user_affiliate_ledger_operation_id")
require.Contains(t, string(migration), "WHERE operation_id IS NOT NULL")
}
+1
View File
@@ -823,6 +823,7 @@ func registerAffiliateRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
users.GET("/lookup", h.Admin.Affiliate.LookupUsers)
users.POST("/batch-rate", h.Admin.Affiliate.BatchSetRate)
users.GET("/:user_id/overview", h.Admin.Affiliate.GetUserOverview)
users.POST("/:user_id/withdraw", h.Admin.Affiliate.WithdrawQuota)
users.PUT("/:user_id", h.Admin.Affiliate.UpdateUserSettings)
users.DELETE("/:user_id", h.Admin.Affiliate.ClearUserSettings)
}
+65 -4
View File
@@ -2,6 +2,8 @@ package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"math"
"strings"
@@ -17,6 +19,9 @@ var (
ErrAffiliateCodeTaken = infraerrors.Conflict("AFFILIATE_CODE_TAKEN", "affiliate code already in use")
ErrAffiliateAlreadyBound = infraerrors.Conflict("AFFILIATE_ALREADY_BOUND", "affiliate inviter already bound")
ErrAffiliateQuotaEmpty = infraerrors.BadRequest("AFFILIATE_QUOTA_EMPTY", "no affiliate quota available to transfer")
ErrAffiliateQuotaInsufficient = infraerrors.BadRequest("AFFILIATE_QUOTA_INSUFFICIENT", "insufficient available affiliate quota")
ErrAffiliateWithdrawAmountInvalid = infraerrors.BadRequest("AFFILIATE_WITHDRAW_AMOUNT_INVALID", "invalid offline withdrawal amount")
)
const (
@@ -102,6 +107,7 @@ type AffiliateRepository interface {
GetAccruedRebateFromInvitee(ctx context.Context, inviterID, inviteeUserID int64) (float64, error)
ThawFrozenQuota(ctx context.Context, userID int64) (float64, error)
TransferQuotaToBalance(ctx context.Context, userID int64) (float64, float64, error)
WithdrawQuota(ctx context.Context, userID int64, amount float64, operationID string) (*AffiliateWithdrawResult, error)
ListInvitees(ctx context.Context, inviterID int64, limit int) ([]AffiliateInvitee, error)
// 管理端:用户级专属配置
@@ -156,25 +162,31 @@ type AffiliateInviteRecord struct {
CreatedAt time.Time `json:"created_at"`
}
// AffiliateRebateRecord 是一笔返利入账流水。非订单来源(兑换码、管理员充值)
// 的流水没有订单信息,OrderID/OrderAmount/PayAmount 为 nil;被邀请人账号被删除时
// InviteeID 为 nil。
type AffiliateRebateRecord struct {
OrderID int64 `json:"order_id"`
OrderID *int64 `json:"order_id"`
OutTradeNo string `json:"out_trade_no"`
InviterID int64 `json:"inviter_id"`
InviterEmail string `json:"inviter_email"`
InviterUsername string `json:"inviter_username"`
InviteeID int64 `json:"invitee_id"`
InviteeID *int64 `json:"invitee_id"`
InviteeEmail string `json:"invitee_email"`
InviteeUsername string `json:"invitee_username"`
OrderAmount float64 `json:"order_amount"`
PayAmount float64 `json:"pay_amount"`
OrderAmount *float64 `json:"order_amount"`
PayAmount *float64 `json:"pay_amount"`
RebateAmount float64 `json:"rebate_amount"`
PaymentType string `json:"payment_type"`
OrderStatus string `json:"order_status"`
CreatedAt time.Time `json:"created_at"`
}
// AffiliateTransferRecord 是一笔返利额度流出:Action 为 transfer(用户转入余额)
// 或 withdraw(管理员登记的线下提现)。
type AffiliateTransferRecord struct {
LedgerID int64 `json:"ledger_id"`
Action string `json:"action"`
UserID int64 `json:"user_id"`
UserEmail string `json:"user_email"`
Username string `json:"username"`
@@ -191,6 +203,18 @@ type AffiliateTransferRecord struct {
CreatedAt time.Time `json:"created_at"`
}
// AffiliateWithdrawResult 是线下提现登记完成后的流水 ID 与额度快照。
// Replayed 表示本次请求命中了同一幂等标识的已有登记,返回的是首次登记的结果。
type AffiliateWithdrawResult struct {
LedgerID int64 `json:"ledger_id"`
UserID int64 `json:"user_id"`
Amount float64 `json:"amount"`
AvailableQuotaAfter float64 `json:"available_quota_after"`
FrozenQuotaAfter float64 `json:"frozen_quota_after"`
HistoryQuotaAfter float64 `json:"history_quota_after"`
Replayed bool `json:"-"`
}
type AffiliateUserOverview struct {
UserID int64 `json:"user_id"`
Email string `json:"email"`
@@ -559,6 +583,43 @@ func (s *AffiliateService) AdminBatchSetUserRebateRate(ctx context.Context, user
return s.repo.BatchSetUserRebateRate(ctx, cleaned, ratePercent)
}
// AdminWithdrawQuota 登记一笔已在站外打款的线下提现:从用户可提取返利额度
// 中扣除 amount,并写入 action=withdraw 的流水。冻结期内的额度不可扣除。
// 金额按返利流水精度保留 8 位小数,舍入后必须为正。
//
// idempotencyKey 标识一次登记且必填:同一个键只扣减一次,重复请求在用户与金额
// 一致时返回首次登记的结果,不一致时返回 ErrIdempotencyKeyConflict。
func (s *AffiliateService) AdminWithdrawQuota(ctx context.Context, userID int64, amount float64, idempotencyKey string) (*AffiliateWithdrawResult, error) {
if userID <= 0 {
return nil, infraerrors.BadRequest("INVALID_USER", "invalid user")
}
if s == nil || s.repo == nil {
return nil, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
}
key, err := NormalizeIdempotencyKey(idempotencyKey)
if err != nil {
return nil, err
}
if key == "" {
return nil, ErrIdempotencyKeyRequired
}
if math.IsNaN(amount) || math.IsInf(amount, 0) {
return nil, ErrAffiliateWithdrawAmountInvalid
}
amount = roundTo(amount, 8)
if amount <= 0 || math.IsInf(amount, 0) {
return nil, ErrAffiliateWithdrawAmountInvalid
}
return s.repo.WithdrawQuota(ctx, userID, amount, affiliateWithdrawOperationID(key))
}
// affiliateWithdrawOperationID 把线下提现登记的幂等键映射为流水的 operation_id,
// 同一个键始终得到同一个值。
func affiliateWithdrawOperationID(idempotencyKey string) string {
sum := sha256.Sum256([]byte("admin.affiliates.withdraw\x00" + idempotencyKey))
return hex.EncodeToString(sum[:])
}
// AdminListCustomUsers 列出有专属配置的用户。
func (s *AffiliateService) AdminListCustomUsers(ctx context.Context, filter AffiliateAdminFilter) ([]AffiliateAdminEntry, int64, error) {
if s == nil || s.repo == nil {
@@ -5,6 +5,7 @@ package service
import (
"context"
"math"
"strings"
"testing"
"github.com/stretchr/testify/require"
@@ -129,3 +130,118 @@ func TestIsValidAffiliateCodeFormat(t *testing.T) {
})
}
}
type affiliateWithdrawCall struct {
userID int64
amount float64
operationID string
}
type affiliateWithdrawRepoStub struct {
AffiliateRepository
calls []affiliateWithdrawCall
result *AffiliateWithdrawResult
err error
}
func (s *affiliateWithdrawRepoStub) WithdrawQuota(_ context.Context, userID int64, amount float64, operationID string) (*AffiliateWithdrawResult, error) {
s.calls = append(s.calls, affiliateWithdrawCall{userID: userID, amount: amount, operationID: operationID})
return s.result, s.err
}
const testAffiliateWithdrawKey = "affiliate-withdraw-7-3f2b8c1e"
// TestAdminWithdrawQuota_RejectsInvalidAmount 覆盖线下提现金额校验:非正数、
// NaN/Inf、舍入到 8 位小数后为 0 或溢出的金额都在服务层拒绝,不进入仓储。
func TestAdminWithdrawQuota_RejectsInvalidAmount(t *testing.T) {
t.Parallel()
for _, amount := range []float64{0, -1, math.NaN(), math.Inf(1), math.Inf(-1), 1e-9, math.MaxFloat64} {
repo := &affiliateWithdrawRepoStub{}
svc := &AffiliateService{repo: repo}
_, err := svc.AdminWithdrawQuota(context.Background(), 1, amount, testAffiliateWithdrawKey)
require.ErrorIs(t, err, ErrAffiliateWithdrawAmountInvalid, "amount %v", amount)
require.Empty(t, repo.calls, "amount %v must not reach repository", amount)
}
}
// TestAdminWithdrawQuota_RejectsInvalidUser 验证缺少目标用户时直接拒绝。
func TestAdminWithdrawQuota_RejectsInvalidUser(t *testing.T) {
t.Parallel()
repo := &affiliateWithdrawRepoStub{}
svc := &AffiliateService{repo: repo}
_, err := svc.AdminWithdrawQuota(context.Background(), 0, 1, testAffiliateWithdrawKey)
require.Error(t, err)
require.Empty(t, repo.calls)
}
// TestAdminWithdrawQuota_RequiresValidIdempotencyKey 验证幂等键必填且须为
// 可见 ASCII、不超过 128 字符;不合格的键不进入仓储。
func TestAdminWithdrawQuota_RequiresValidIdempotencyKey(t *testing.T) {
t.Parallel()
cases := []struct {
key string
want error
}{
{key: "", want: ErrIdempotencyKeyRequired},
{key: " ", want: ErrIdempotencyKeyRequired},
{key: "has space", want: ErrIdempotencyKeyInvalid},
{key: "登记", want: ErrIdempotencyKeyInvalid},
{key: strings.Repeat("k", 129), want: ErrIdempotencyKeyInvalid},
}
for _, tc := range cases {
repo := &affiliateWithdrawRepoStub{}
svc := &AffiliateService{repo: repo}
_, err := svc.AdminWithdrawQuota(context.Background(), 1, 1, tc.key)
require.ErrorIs(t, err, tc.want, "key %q", tc.key)
require.Empty(t, repo.calls, "key %q must not reach repository", tc.key)
}
}
// TestAdminWithdrawQuota_DerivesStableOperationID 验证仓储收到的 operation_id
// 由幂等键确定性派生:同一个键(含首尾空白)得到同一个值,不同的键得到不同的值,
// 且不是原始键本身。
func TestAdminWithdrawQuota_DerivesStableOperationID(t *testing.T) {
t.Parallel()
repo := &affiliateWithdrawRepoStub{result: &AffiliateWithdrawResult{LedgerID: 1}}
svc := &AffiliateService{repo: repo}
for _, key := range []string{testAffiliateWithdrawKey, " " + testAffiliateWithdrawKey + " ", testAffiliateWithdrawKey + "-2"} {
_, err := svc.AdminWithdrawQuota(context.Background(), 42, 10, key)
require.NoError(t, err)
}
require.Len(t, repo.calls, 3)
first := repo.calls[0].operationID
require.Regexp(t, `^[0-9a-f]{64}$`, first)
require.NotEqual(t, testAffiliateWithdrawKey, first)
require.Equal(t, first, repo.calls[1].operationID)
require.NotEqual(t, first, repo.calls[2].operationID)
}
// TestAdminWithdrawQuota_RoundsAmountToLedgerPrecision 验证金额按 8 位小数舍入后
// 交给仓储,仓储结果原样返回。
func TestAdminWithdrawQuota_RoundsAmountToLedgerPrecision(t *testing.T) {
t.Parallel()
want := &AffiliateWithdrawResult{LedgerID: 9, UserID: 42, Amount: 12.34567891}
repo := &affiliateWithdrawRepoStub{result: want}
svc := &AffiliateService{repo: repo}
got, err := svc.AdminWithdrawQuota(context.Background(), 42, 12.3456789149, testAffiliateWithdrawKey)
require.NoError(t, err)
require.Same(t, want, got)
require.Len(t, repo.calls, 1)
require.Equal(t, int64(42), repo.calls[0].userID)
require.Equal(t, 12.34567891, repo.calls[0].amount)
}
// TestAdminWithdrawQuota_PropagatesRepositoryErrors 验证额度不足与同键不同参数
// 由仓储判定,服务层原样返回,前端据错误码提示。
func TestAdminWithdrawQuota_PropagatesRepositoryErrors(t *testing.T) {
t.Parallel()
for _, want := range []error{ErrAffiliateQuotaInsufficient, ErrIdempotencyKeyConflict} {
repo := &affiliateWithdrawRepoStub{err: want}
svc := &AffiliateService{repo: repo}
_, err := svc.AdminWithdrawQuota(context.Background(), 1, 1, testAffiliateWithdrawKey)
require.ErrorIs(t, err, want)
}
}
@@ -150,6 +150,10 @@ func (r *paymentFulfillmentAffiliateRepoStub) TransferQuotaToBalance(context.Con
panic("unexpected TransferQuotaToBalance call")
}
func (r *paymentFulfillmentAffiliateRepoStub) WithdrawQuota(context.Context, int64, float64, string) (*AffiliateWithdrawResult, error) {
panic("unexpected WithdrawQuota call")
}
func (r *paymentFulfillmentAffiliateRepoStub) ListInvitees(context.Context, int64, int) ([]AffiliateInvitee, error) {
panic("unexpected ListInvitees call")
}
@@ -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;