feat(affiliate): 提取记录支持登记线下提现

管理员在站外给邀请人打款后,系统里原本无法扣减对应的返利额度,用户仍可把
这部分额度转入余额。现在可在「邀请返利 → 提取记录」点「登记线下提现」,
从该用户的可提取返利额度中扣除已打款金额:

- POST /api/v1/admin/affiliates/users/:user_id/withdraw,请求体只含 amount,
  金额按 8 位小数舍入后校验
- 先解冻已到期的冻结额度,再以 aff_quota >= amount 为条件原子扣减,写入
  action=withdraw 的流水并带额度快照;额度不足返回 AFFILIATE_QUOTA_INSUFFICIENT
- 不新增字段与迁移:线下提现复用已有 action 列区分,操作人由管理端审计日志记录
- 提取记录同时列出转入余额与线下提现,新增「类型」列
- 弹窗选择用户后展示可提取额度,支持一键填入全部;从未产生返利的用户按 0 处理

返利记录改为 LEFT JOIN 订单与被邀请人,兑换码、管理员充值来源的返利也会出现
在明细中,接口里订单相关字段与被邀请人 ID 可为 null。
This commit is contained in:
feeeei
2026-09-17 23:42:02 +08:00
parent efe9aab1e4
commit 57b7dbdc85
17 changed files with 1365 additions and 36 deletions
@@ -201,6 +201,36 @@ func (h *AffiliateHandler) GetUserOverview(c *gin.Context) {
response.Success(c, overview)
}
// WithdrawQuotaRequest 是登记线下提现的请求体。Amount 是已在站外打款给
// 用户的金额,由 AffiliateService 校验。
type WithdrawQuotaRequest struct {
Amount float64 `json:"amount"`
}
// WithdrawQuota records an offline withdrawal paid outside the site and
// deducts it from the user's available affiliate quota.
// POST /api/v1/admin/affiliates/users/:user_id/withdraw
func (h *AffiliateHandler) WithdrawQuota(c *gin.Context) {
userID, err := strconv.ParseInt(c.Param("user_id"), 10, 64)
if err != nil || userID <= 0 {
response.BadRequest(c, "Invalid user_id")
return
}
var req WithdrawQuotaRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
result, err := h.affiliateService.AdminWithdrawQuota(c.Request.Context(), userID, req.Amount)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
// ListInviteRecords returns all inviter-invitee relationships.
// GET /api/v1/admin/affiliates/invites
func (h *AffiliateHandler) ListInviteRecords(c *gin.Context) {
@@ -214,7 +244,8 @@ func (h *AffiliateHandler) ListInviteRecords(c *gin.Context) {
response.Paginated(c, items, total, filter.Page, filter.PageSize)
}
// ListRebateRecords returns all order-level affiliate rebate records.
// ListRebateRecords returns every affiliate rebate accrual, with order details
// when the rebate came from a payment order.
// GET /api/v1/admin/affiliates/rebates
func (h *AffiliateHandler) ListRebateRecords(c *gin.Context) {
page, pageSize := response.ParsePagination(c)
@@ -227,7 +258,8 @@ func (h *AffiliateHandler) ListRebateRecords(c *gin.Context) {
response.Paginated(c, items, total, filter.Page, filter.PageSize)
}
// ListTransferRecords returns all affiliate quota-to-balance transfer records.
// ListTransferRecords returns affiliate quota outflows: user transfers into
// balance and admin-recorded offline withdrawals.
// GET /api/v1/admin/affiliates/transfers
func (h *AffiliateHandler) ListTransferRecords(c *gin.Context) {
page, pageSize := response.ParsePagination(c)
@@ -0,0 +1,105 @@
package admin
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type withdrawQuotaCall struct {
userID int64
amount float64
}
type withdrawQuotaRepoStub struct {
service.AffiliateRepository
calls []withdrawQuotaCall
result *service.AffiliateWithdrawResult
err error
}
func (s *withdrawQuotaRepoStub) WithdrawQuota(_ context.Context, userID int64, amount float64) (*service.AffiliateWithdrawResult, error) {
s.calls = append(s.calls, withdrawQuotaCall{userID: userID, amount: amount})
return s.result, s.err
}
type withdrawQuotaResponse struct {
Code int `json:"code"`
Reason string `json:"reason"`
Data *service.AffiliateWithdrawResult `json:"data"`
}
func performWithdrawQuota(t *testing.T, repo *withdrawQuotaRepoStub, userID, body string) (int, withdrawQuotaResponse) {
t.Helper()
gin.SetMode(gin.TestMode)
router := gin.New()
handler := NewAffiliateHandler(service.NewAffiliateService(repo, nil, nil, nil), nil)
router.POST("/api/v1/admin/affiliates/users/:user_id/withdraw", handler.WithdrawQuota)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/affiliates/users/"+userID+"/withdraw", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
var resp withdrawQuotaResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
return rec.Code, resp
}
// TestAffiliateHandlerWithdrawQuota_Success 验证登记线下提现把路径中的用户与
// 舍入后的金额交给仓储,并返回登记结果。
func TestAffiliateHandlerWithdrawQuota_Success(t *testing.T) {
repo := &withdrawQuotaRepoStub{result: &service.AffiliateWithdrawResult{LedgerID: 9, UserID: 42, Amount: 12.34567891}}
status, resp := performWithdrawQuota(t, repo, "42", `{"amount":12.3456789149}`)
require.Equal(t, http.StatusOK, status)
require.Equal(t, 0, resp.Code)
require.NotNil(t, resp.Data)
require.Equal(t, int64(9), resp.Data.LedgerID)
require.Equal(t, []withdrawQuotaCall{{userID: 42, amount: 12.34567891}}, repo.calls)
}
// TestAffiliateHandlerWithdrawQuota_RejectsBadRequests 验证非法用户 ID、非法 JSON
// 与非法金额在进入仓储前以 400 拒绝;金额错误带 AFFILIATE_WITHDRAW_AMOUNT_INVALID。
func TestAffiliateHandlerWithdrawQuota_RejectsBadRequests(t *testing.T) {
cases := []struct {
name string
userID string
body string
wantReason string
}{
{name: "non-numeric user id", userID: "abc", body: `{"amount":1}`},
{name: "non-positive user id", userID: "0", body: `{"amount":1}`},
{name: "malformed json", userID: "42", body: `{"amount":`},
{name: "zero amount", userID: "42", body: `{"amount":0}`, wantReason: "AFFILIATE_WITHDRAW_AMOUNT_INVALID"},
{name: "missing amount", userID: "42", body: `{}`, wantReason: "AFFILIATE_WITHDRAW_AMOUNT_INVALID"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
repo := &withdrawQuotaRepoStub{}
status, resp := performWithdrawQuota(t, repo, tc.userID, tc.body)
require.Equal(t, http.StatusBadRequest, status)
require.Equal(t, tc.wantReason, resp.Reason)
require.Empty(t, repo.calls)
})
}
}
// TestAffiliateHandlerWithdrawQuota_InsufficientQuota 验证额度不足以 400 和
// AFFILIATE_QUOTA_INSUFFICIENT 返回,前端据此提示。
func TestAffiliateHandlerWithdrawQuota_InsufficientQuota(t *testing.T) {
repo := &withdrawQuotaRepoStub{err: service.ErrAffiliateQuotaInsufficient}
status, resp := performWithdrawQuota(t, repo, "42", `{"amount":5}`)
require.Equal(t, http.StatusBadRequest, status)
require.Equal(t, "AFFILIATE_QUOTA_INSUFFICIENT", resp.Reason)
require.Len(t, repo.calls, 1)
}
@@ -407,6 +407,10 @@ func (r *oauthEmailAffiliateRepoStub) TransferQuotaToBalance(context.Context, in
panic("unexpected TransferQuotaToBalance call")
}
func (r *oauthEmailAffiliateRepoStub) WithdrawQuota(context.Context, int64, float64) (*service.AffiliateWithdrawResult, error) {
panic("unexpected WithdrawQuota call")
}
func (r *oauthEmailAffiliateRepoStub) ListInvitees(context.Context, int64, int) ([]service.AffiliateInvitee, error) {
panic("unexpected ListInvitees call")
}
+127 -12
View File
@@ -341,6 +341,102 @@ VALUES ($1, 'transfer', $2, NULL, $3, $4, $5, $6, NOW(), NOW())`,
return transferred, newBalance, nil
}
// WithdrawQuota 登记一笔线下提现:先解冻已到期的冻结额度,再以
// aff_quota >= amount 为条件原子扣减,并写入带额度快照的 withdraw 流水。
// 可提取额度不足返回 ErrAffiliateQuotaInsufficient。
func (r *affiliateRepository) WithdrawQuota(ctx context.Context, userID int64, amount float64) (*service.AffiliateWithdrawResult, error) {
if userID <= 0 {
return nil, service.ErrUserNotFound
}
if amount <= 0 {
return nil, service.ErrAffiliateWithdrawAmountInvalid
}
var result *service.AffiliateWithdrawResult
err := r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error {
if _, err := thawFrozenQuotaTx(txCtx, txClient, userID); err != nil {
return fmt.Errorf("thaw before withdraw: %w", err)
}
res, err := txClient.ExecContext(txCtx, `
UPDATE user_affiliates
SET aff_quota = aff_quota - $1,
updated_at = NOW()
WHERE user_id = $2
AND aff_quota >= $1`, amount, userID)
if err != nil {
return fmt.Errorf("deduct affiliate quota: %w", err)
}
affected, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("deduct affiliate quota: %w", err)
}
if affected == 0 {
return service.ErrAffiliateQuotaInsufficient
}
snapshot, err := queryAffiliateTransferSnapshot(txCtx, txClient, userID)
if err != nil {
return err
}
rows, err := txClient.QueryContext(txCtx, `
INSERT INTO user_affiliate_ledger (
user_id,
action,
amount,
source_user_id,
balance_after,
aff_quota_after,
aff_frozen_quota_after,
aff_history_quota_after,
created_at,
updated_at
)
VALUES ($1, 'withdraw', $2, NULL, $3, $4, $5, $6, NOW(), NOW())
RETURNING id`,
userID,
amount,
snapshot.BalanceAfter,
snapshot.AvailableQuotaAfter,
snapshot.FrozenQuotaAfter,
snapshot.HistoryQuotaAfter,
)
if err != nil {
return fmt.Errorf("insert affiliate withdraw ledger: %w", err)
}
if !rows.Next() {
_ = rows.Close()
if err := rows.Err(); err != nil {
return fmt.Errorf("insert affiliate withdraw ledger: %w", err)
}
return errors.New("insert affiliate withdraw ledger: no id returned")
}
var ledgerID int64
if err := rows.Scan(&ledgerID); err != nil {
_ = rows.Close()
return err
}
if err := rows.Close(); err != nil {
return err
}
result = &service.AffiliateWithdrawResult{
LedgerID: ledgerID,
UserID: userID,
Amount: amount,
AvailableQuotaAfter: snapshot.AvailableQuotaAfter,
FrozenQuotaAfter: snapshot.FrozenQuotaAfter,
HistoryQuotaAfter: snapshot.HistoryQuotaAfter,
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (r *affiliateRepository) ListInvitees(ctx context.Context, inviterID int64, limit int) ([]service.AffiliateInvitee, error) {
if limit <= 0 {
limit = 100
@@ -466,13 +562,14 @@ func (r *affiliateRepository) ListAffiliateRebateRecords(ctx context.Context, fi
"inviter.email", "inviter.username", "invitee.email", "invitee.username",
"po.id::text", "po.out_trade_no", "po.payment_type", "po.status",
})
// 返利记录列出全部 accrue 流水:非订单来源的流水没有订单,被邀请人账号
// 删除后 source_user_id 为 NULL,这两类行都保留。
baseJoin := `
FROM user_affiliate_ledger ual
JOIN payment_orders po ON po.id = ual.source_order_id
JOIN users invitee ON invitee.id = ual.source_user_id
JOIN users inviter ON inviter.id = ual.user_id
WHERE ual.action = 'accrue'
AND ual.source_order_id IS NOT NULL`
LEFT JOIN users invitee ON invitee.id = ual.source_user_id
LEFT JOIN payment_orders po ON po.id = ual.source_order_id
WHERE ual.action = 'accrue'`
if where != "" {
where = strings.Replace(where, "WHERE ", " AND ", 1)
}
@@ -496,7 +593,7 @@ WHERE ual.action = 'accrue'
args = append(args, filter.PageSize, (filter.Page-1)*filter.PageSize)
rows, err := client.QueryContext(ctx, `
SELECT po.id,
po.out_trade_no,
COALESCE(po.out_trade_no, ''),
ual.user_id,
COALESCE(inviter.email, ''),
COALESCE(inviter.username, ''),
@@ -506,8 +603,8 @@ SELECT po.id,
po.amount::double precision,
po.pay_amount::double precision,
ual.amount::double precision,
po.payment_type,
po.status,
COALESCE(po.payment_type, ''),
COALESCE(po.status, ''),
ual.created_at
`+baseJoin+where+`
`+orderBy+`
@@ -520,17 +617,21 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
items := make([]service.AffiliateRebateRecord, 0)
for rows.Next() {
var item service.AffiliateRebateRecord
var orderID sql.NullInt64
var inviteeID sql.NullInt64
var orderAmount sql.NullFloat64
var payAmount sql.NullFloat64
if err := rows.Scan(
&item.OrderID,
&orderID,
&item.OutTradeNo,
&item.InviterID,
&item.InviterEmail,
&item.InviterUsername,
&item.InviteeID,
&inviteeID,
&item.InviteeEmail,
&item.InviteeUsername,
&item.OrderAmount,
&item.PayAmount,
&orderAmount,
&payAmount,
&item.RebateAmount,
&item.PaymentType,
&item.OrderStatus,
@@ -538,6 +639,10 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
); err != nil {
return nil, 0, err
}
item.OrderID = nullableInt64Ptr(orderID)
item.InviteeID = nullableInt64Ptr(inviteeID)
item.OrderAmount = nullableFloat64Ptr(orderAmount)
item.PayAmount = nullableFloat64Ptr(payAmount)
items = append(items, item)
}
if err := rows.Err(); err != nil {
@@ -554,7 +659,7 @@ func (r *affiliateRepository) ListAffiliateTransferRecords(ctx context.Context,
baseJoin := `
FROM user_affiliate_ledger ual
JOIN users u ON u.id = ual.user_id
WHERE ual.action = 'transfer'`
WHERE ual.action IN ('transfer', 'withdraw')`
if where != "" {
where = strings.Replace(where, "WHERE ", " AND ", 1)
}
@@ -566,6 +671,7 @@ WHERE ual.action = 'transfer'`
orderBy := buildAffiliateRecordOrderBy(filter, map[string]string{
"user": "u.email",
"action": "ual.action",
"amount": "ual.amount",
"balance_after": "ual.balance_after",
"available_quota_after": "ual.aff_quota_after",
@@ -576,6 +682,7 @@ WHERE ual.action = 'transfer'`
args = append(args, filter.PageSize, (filter.Page-1)*filter.PageSize)
rows, err := client.QueryContext(ctx, `
SELECT ual.id,
ual.action,
ual.user_id,
COALESCE(u.email, ''),
COALESCE(u.username, ''),
@@ -602,6 +709,7 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
var historyQuotaAfter sql.NullFloat64
if err := rows.Scan(
&item.LedgerID,
&item.Action,
&item.UserID,
&item.UserEmail,
&item.Username,
@@ -958,6 +1066,13 @@ func nullableFloat64Ptr(v sql.NullFloat64) *float64 {
return &v.Float64
}
func nullableInt64Ptr(v sql.NullInt64) *int64 {
if !v.Valid {
return nil
}
return &v.Int64
}
func generateAffiliateCode() (string, error) {
buf := make([]byte, affiliateCodeLength)
if _, err := rand.Read(buf); err != nil {
@@ -417,3 +417,159 @@ func TestAffiliateRepository_ListUsersWithCustomSettings(t *testing.T) {
require.GreaterOrEqual(t, total, int64(2), "total must include at least our 2 custom rows")
}
func mustCreateAffiliateWithdrawUser(t *testing.T, client *dbent.Client, label string) *service.User {
t.Helper()
return mustCreateUser(t, client, &service.User{
Email: fmt.Sprintf("affiliate-withdraw-%s-%d@example.com", label, time.Now().UnixNano()),
PasswordHash: "hash",
Role: service.RoleUser,
Status: service.StatusActive,
Balance: 5.5,
Concurrency: 5,
})
}
func mustSeedAffiliateQuota(t *testing.T, ctx context.Context, client *dbent.Client, userID int64, quota, frozen, history float64) {
t.Helper()
affCode := fmt.Sprintf("AFW%d-%06d", userID, time.Now().UnixNano()%1_000_000)
_, err := client.ExecContext(ctx, `
INSERT INTO user_affiliates (user_id, aff_code, aff_quota, aff_frozen_quota, aff_history_quota, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())`, userID, affCode, quota, frozen, history)
require.NoError(t, err)
}
// TestAffiliateRepository_WithdrawQuota_DeductsAndRecordsLedger 覆盖线下提现主路径:
// 只扣可提取额度,余额与累计返利不变,流水 action=withdraw 带额度快照,并出现在提取记录中。
func TestAffiliateRepository_WithdrawQuota_DeductsAndRecordsLedger(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
txCtx := dbent.NewTxContext(ctx, tx)
client := tx.Client()
repo := NewAffiliateRepository(client, integrationDB)
u := mustCreateAffiliateWithdrawUser(t, client, "user")
mustSeedAffiliateQuota(t, txCtx, client, u.ID, 20, 0, 30)
result, err := repo.WithdrawQuota(txCtx, u.ID, 12.5)
require.NoError(t, err)
require.Positive(t, result.LedgerID)
require.InDelta(t, 12.5, result.Amount, 1e-9)
require.InDelta(t, 7.5, result.AvailableQuotaAfter, 1e-9)
require.InDelta(t, 0.0, result.FrozenQuotaAfter, 1e-9)
require.InDelta(t, 30.0, result.HistoryQuotaAfter, 1e-9)
require.InDelta(t, 7.5, querySingleFloat(t, txCtx, client,
"SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9)
require.InDelta(t, 30.0, querySingleFloat(t, txCtx, client,
"SELECT aff_history_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9)
require.InDelta(t, 5.5, querySingleFloat(t, txCtx, client,
"SELECT balance::double precision FROM users WHERE id = $1", u.ID), 1e-9)
records, total, err := repo.ListAffiliateTransferRecords(txCtx, service.AffiliateRecordFilter{
Search: u.Email,
Page: 1,
PageSize: 20,
SortDesc: true,
})
require.NoError(t, err)
require.EqualValues(t, 1, total)
require.Len(t, records, 1)
record := records[0]
require.Equal(t, result.LedgerID, record.LedgerID)
require.Equal(t, "withdraw", record.Action)
require.Equal(t, u.ID, record.UserID)
require.InDelta(t, 12.5, record.Amount, 1e-9)
require.True(t, record.SnapshotAvailable)
require.InDelta(t, 5.5, *record.BalanceAfter, 1e-9)
require.InDelta(t, 7.5, *record.AvailableQuotaAfter, 1e-9)
require.InDelta(t, 30.0, *record.HistoryQuotaAfter, 1e-9)
}
// TestAffiliateRepository_WithdrawQuota_ThawsMaturedQuotaFirst 验证已过冻结期
// 但尚未解冻的额度在扣减前解冻并可被提现。
func TestAffiliateRepository_WithdrawQuota_ThawsMaturedQuotaFirst(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
txCtx := dbent.NewTxContext(ctx, tx)
client := tx.Client()
repo := NewAffiliateRepository(client, integrationDB)
u := mustCreateAffiliateWithdrawUser(t, client, "thaw")
mustSeedAffiliateQuota(t, txCtx, client, u.ID, 0, 10, 10)
_, err := client.ExecContext(txCtx, `
INSERT INTO user_affiliate_ledger (user_id, action, amount, frozen_until, created_at, updated_at)
VALUES ($1, 'accrue', 10, NOW() - INTERVAL '1 hour', NOW(), NOW())`, u.ID)
require.NoError(t, err)
result, err := repo.WithdrawQuota(txCtx, u.ID, 10)
require.NoError(t, err)
require.InDelta(t, 0.0, result.AvailableQuotaAfter, 1e-9)
require.InDelta(t, 0.0, result.FrozenQuotaAfter, 1e-9)
require.Equal(t, 1, querySingleInt(t, txCtx, client,
"SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.ID))
}
// TestAffiliateRepository_WithdrawQuota_InsufficientLeavesQuotaUntouched 验证未到期的
// 冻结额度不可提现,额度不足时不扣减、不写流水。
func TestAffiliateRepository_WithdrawQuota_InsufficientLeavesQuotaUntouched(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
txCtx := dbent.NewTxContext(ctx, tx)
client := tx.Client()
repo := NewAffiliateRepository(client, integrationDB)
u := mustCreateAffiliateWithdrawUser(t, client, "insufficient")
mustSeedAffiliateQuota(t, txCtx, client, u.ID, 5, 10, 15)
_, err := client.ExecContext(txCtx, `
INSERT INTO user_affiliate_ledger (user_id, action, amount, frozen_until, created_at, updated_at)
VALUES ($1, 'accrue', 10, NOW() + INTERVAL '1 hour', NOW(), NOW())`, u.ID)
require.NoError(t, err)
_, err = repo.WithdrawQuota(txCtx, u.ID, 6)
require.ErrorIs(t, err, service.ErrAffiliateQuotaInsufficient)
require.InDelta(t, 5.0, querySingleFloat(t, txCtx, client,
"SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9)
require.InDelta(t, 10.0, querySingleFloat(t, txCtx, client,
"SELECT aff_frozen_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9)
require.Equal(t, 0, querySingleInt(t, txCtx, client,
"SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.ID))
}
// TestAffiliateRepository_ListAffiliateRebateRecords_IncludesNonOrderAccruals 验证兑换码
// 等非订单来源的返利出现在返利记录中,订单字段为空。
func TestAffiliateRepository_ListAffiliateRebateRecords_IncludesNonOrderAccruals(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
txCtx := dbent.NewTxContext(ctx, tx)
client := tx.Client()
repo := NewAffiliateRepository(client, integrationDB)
inviter := mustCreateAffiliateWithdrawUser(t, client, "inviter")
invitee := mustCreateAffiliateWithdrawUser(t, client, "invitee")
mustSeedAffiliateQuota(t, txCtx, client, inviter.ID, 0, 0, 0)
applied, err := repo.AccrueQuota(txCtx, inviter.ID, invitee.ID, 2, 0, nil)
require.NoError(t, err)
require.True(t, applied)
records, total, err := repo.ListAffiliateRebateRecords(txCtx, service.AffiliateRecordFilter{
Search: invitee.Email,
Page: 1,
PageSize: 20,
SortDesc: true,
})
require.NoError(t, err)
require.EqualValues(t, 1, total)
require.Len(t, records, 1)
record := records[0]
require.Nil(t, record.OrderID)
require.Nil(t, record.OrderAmount)
require.Nil(t, record.PayAmount)
require.Empty(t, record.OutTradeNo)
require.Equal(t, inviter.ID, record.InviterID)
require.NotNil(t, record.InviteeID)
require.Equal(t, invitee.ID, *record.InviteeID)
require.InDelta(t, 2.0, record.RebateAmount, 1e-9)
}
@@ -20,9 +20,34 @@ func TestAffiliateRecordQueriesUseLedgerAuditFields(t *testing.T) {
require.NoError(t, err)
content := string(source)
require.Contains(t, content, "JOIN payment_orders po ON po.id = ual.source_order_id")
require.Contains(t, content, "LEFT JOIN payment_orders po ON po.id = ual.source_order_id")
require.Contains(t, content, "ual.amount::double precision")
require.Contains(t, content, "ual.balance_after::double precision")
require.NotContains(t, content, "parseAffiliateRebateAmount")
require.NotContains(t, content, `"current_balance": "u.balance"`)
}
// TestAffiliateRebateRecordsQueryKeepsNonOrderAccruals 锁定返利记录列出全部
// accrue 流水:订单与被邀请人均为 LEFT JOIN,且不按 source_order_id 过滤,
// 兑换码、管理员充值来源的返利才会出现在明细里。
func TestAffiliateRebateRecordsQueryKeepsNonOrderAccruals(t *testing.T) {
source, err := os.ReadFile("affiliate_repo.go")
require.NoError(t, err)
content := string(source)
require.Contains(t, content, "LEFT JOIN payment_orders po ON po.id = ual.source_order_id")
require.Contains(t, content, "LEFT JOIN users invitee ON invitee.id = ual.source_user_id")
require.NotContains(t, content, "\nJOIN payment_orders po ON po.id = ual.source_order_id")
require.NotContains(t, content, "\nJOIN users invitee ON invitee.id = ual.source_user_id")
require.NotContains(t, content, "AND ual.source_order_id IS NOT NULL")
}
// TestAffiliateTransferRecordsQueryIncludesOfflineWithdrawals 锁定提取记录同时
// 列出转入余额与线下提现两类额度流出。
func TestAffiliateTransferRecordsQueryIncludesOfflineWithdrawals(t *testing.T) {
source, err := os.ReadFile("affiliate_repo.go")
require.NoError(t, err)
content := string(source)
require.Contains(t, content, "WHERE ual.action IN ('transfer', 'withdraw')")
}
+1
View File
@@ -820,6 +820,7 @@ func registerAffiliateRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
users.GET("/lookup", h.Admin.Affiliate.LookupUsers)
users.POST("/batch-rate", h.Admin.Affiliate.BatchSetRate)
users.GET("/:user_id/overview", h.Admin.Affiliate.GetUserOverview)
users.POST("/:user_id/withdraw", h.Admin.Affiliate.WithdrawQuota)
users.PUT("/:user_id", h.Admin.Affiliate.UpdateUserSettings)
users.DELETE("/:user_id", h.Admin.Affiliate.ClearUserSettings)
}
+44 -4
View File
@@ -17,6 +17,9 @@ var (
ErrAffiliateCodeTaken = infraerrors.Conflict("AFFILIATE_CODE_TAKEN", "affiliate code already in use")
ErrAffiliateAlreadyBound = infraerrors.Conflict("AFFILIATE_ALREADY_BOUND", "affiliate inviter already bound")
ErrAffiliateQuotaEmpty = infraerrors.BadRequest("AFFILIATE_QUOTA_EMPTY", "no affiliate quota available to transfer")
ErrAffiliateQuotaInsufficient = infraerrors.BadRequest("AFFILIATE_QUOTA_INSUFFICIENT", "insufficient available affiliate quota")
ErrAffiliateWithdrawAmountInvalid = infraerrors.BadRequest("AFFILIATE_WITHDRAW_AMOUNT_INVALID", "invalid offline withdrawal amount")
)
const (
@@ -102,6 +105,7 @@ type AffiliateRepository interface {
GetAccruedRebateFromInvitee(ctx context.Context, inviterID, inviteeUserID int64) (float64, error)
ThawFrozenQuota(ctx context.Context, userID int64) (float64, error)
TransferQuotaToBalance(ctx context.Context, userID int64) (float64, float64, error)
WithdrawQuota(ctx context.Context, userID int64, amount float64) (*AffiliateWithdrawResult, error)
ListInvitees(ctx context.Context, inviterID int64, limit int) ([]AffiliateInvitee, error)
// 管理端:用户级专属配置
@@ -156,25 +160,31 @@ type AffiliateInviteRecord struct {
CreatedAt time.Time `json:"created_at"`
}
// AffiliateRebateRecord 是一笔返利入账流水。非订单来源(兑换码、管理员充值)
// 的流水没有订单信息,OrderID/OrderAmount/PayAmount 为 nil;被邀请人账号被删除时
// InviteeID 为 nil。
type AffiliateRebateRecord struct {
OrderID int64 `json:"order_id"`
OrderID *int64 `json:"order_id"`
OutTradeNo string `json:"out_trade_no"`
InviterID int64 `json:"inviter_id"`
InviterEmail string `json:"inviter_email"`
InviterUsername string `json:"inviter_username"`
InviteeID int64 `json:"invitee_id"`
InviteeID *int64 `json:"invitee_id"`
InviteeEmail string `json:"invitee_email"`
InviteeUsername string `json:"invitee_username"`
OrderAmount float64 `json:"order_amount"`
PayAmount float64 `json:"pay_amount"`
OrderAmount *float64 `json:"order_amount"`
PayAmount *float64 `json:"pay_amount"`
RebateAmount float64 `json:"rebate_amount"`
PaymentType string `json:"payment_type"`
OrderStatus string `json:"order_status"`
CreatedAt time.Time `json:"created_at"`
}
// AffiliateTransferRecord 是一笔返利额度流出:Action 为 transfer(用户转入余额)
// 或 withdraw(管理员登记的线下提现)。
type AffiliateTransferRecord struct {
LedgerID int64 `json:"ledger_id"`
Action string `json:"action"`
UserID int64 `json:"user_id"`
UserEmail string `json:"user_email"`
Username string `json:"username"`
@@ -191,6 +201,16 @@ type AffiliateTransferRecord struct {
CreatedAt time.Time `json:"created_at"`
}
// AffiliateWithdrawResult 是线下提现登记完成后的流水 ID 与额度快照。
type AffiliateWithdrawResult struct {
LedgerID int64 `json:"ledger_id"`
UserID int64 `json:"user_id"`
Amount float64 `json:"amount"`
AvailableQuotaAfter float64 `json:"available_quota_after"`
FrozenQuotaAfter float64 `json:"frozen_quota_after"`
HistoryQuotaAfter float64 `json:"history_quota_after"`
}
type AffiliateUserOverview struct {
UserID int64 `json:"user_id"`
Email string `json:"email"`
@@ -559,6 +579,26 @@ func (s *AffiliateService) AdminBatchSetUserRebateRate(ctx context.Context, user
return s.repo.BatchSetUserRebateRate(ctx, cleaned, ratePercent)
}
// AdminWithdrawQuota 登记一笔已在站外打款的线下提现:从用户可提取返利额度
// 中扣除 amount,并写入 action=withdraw 的流水。冻结期内的额度不可扣除。
// 金额按返利流水精度保留 8 位小数,舍入后必须为正。
func (s *AffiliateService) AdminWithdrawQuota(ctx context.Context, userID int64, amount float64) (*AffiliateWithdrawResult, error) {
if userID <= 0 {
return nil, infraerrors.BadRequest("INVALID_USER", "invalid user")
}
if s == nil || s.repo == nil {
return nil, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
}
if math.IsNaN(amount) || math.IsInf(amount, 0) {
return nil, ErrAffiliateWithdrawAmountInvalid
}
amount = roundTo(amount, 8)
if amount <= 0 || math.IsInf(amount, 0) {
return nil, ErrAffiliateWithdrawAmountInvalid
}
return s.repo.WithdrawQuota(ctx, userID, amount)
}
// AdminListCustomUsers 列出有专属配置的用户。
func (s *AffiliateService) AdminListCustomUsers(ctx context.Context, filter AffiliateAdminFilter) ([]AffiliateAdminEntry, int64, error) {
if s == nil || s.repo == nil {
@@ -129,3 +129,67 @@ func TestIsValidAffiliateCodeFormat(t *testing.T) {
})
}
}
type affiliateWithdrawCall struct {
userID int64
amount float64
}
type affiliateWithdrawRepoStub struct {
AffiliateRepository
calls []affiliateWithdrawCall
result *AffiliateWithdrawResult
err error
}
func (s *affiliateWithdrawRepoStub) WithdrawQuota(_ context.Context, userID int64, amount float64) (*AffiliateWithdrawResult, error) {
s.calls = append(s.calls, affiliateWithdrawCall{userID: userID, amount: amount})
return s.result, s.err
}
// TestAdminWithdrawQuota_RejectsInvalidAmount 覆盖线下提现金额校验:非正数、
// NaN/Inf、舍入到 8 位小数后为 0 或溢出的金额都在服务层拒绝,不进入仓储。
func TestAdminWithdrawQuota_RejectsInvalidAmount(t *testing.T) {
t.Parallel()
for _, amount := range []float64{0, -1, math.NaN(), math.Inf(1), math.Inf(-1), 1e-9, math.MaxFloat64} {
repo := &affiliateWithdrawRepoStub{}
svc := &AffiliateService{repo: repo}
_, err := svc.AdminWithdrawQuota(context.Background(), 1, amount)
require.ErrorIs(t, err, ErrAffiliateWithdrawAmountInvalid, "amount %v", amount)
require.Empty(t, repo.calls, "amount %v must not reach repository", amount)
}
}
// TestAdminWithdrawQuota_RejectsInvalidUser 验证缺少目标用户时直接拒绝。
func TestAdminWithdrawQuota_RejectsInvalidUser(t *testing.T) {
t.Parallel()
repo := &affiliateWithdrawRepoStub{}
svc := &AffiliateService{repo: repo}
_, err := svc.AdminWithdrawQuota(context.Background(), 0, 1)
require.Error(t, err)
require.Empty(t, repo.calls)
}
// TestAdminWithdrawQuota_RoundsAmountToLedgerPrecision 验证金额按 8 位小数舍入后
// 交给仓储,仓储结果原样返回。
func TestAdminWithdrawQuota_RoundsAmountToLedgerPrecision(t *testing.T) {
t.Parallel()
want := &AffiliateWithdrawResult{LedgerID: 9, UserID: 42, Amount: 12.34567891}
repo := &affiliateWithdrawRepoStub{result: want}
svc := &AffiliateService{repo: repo}
got, err := svc.AdminWithdrawQuota(context.Background(), 42, 12.3456789149)
require.NoError(t, err)
require.Same(t, want, got)
require.Equal(t, []affiliateWithdrawCall{{userID: 42, amount: 12.34567891}}, repo.calls)
}
// TestAdminWithdrawQuota_PropagatesInsufficientQuota 验证额度不足由仓储判定,
// 服务层原样返回,前端据错误码提示。
func TestAdminWithdrawQuota_PropagatesInsufficientQuota(t *testing.T) {
t.Parallel()
repo := &affiliateWithdrawRepoStub{err: ErrAffiliateQuotaInsufficient}
svc := &AffiliateService{repo: repo}
_, err := svc.AdminWithdrawQuota(context.Background(), 1, 1)
require.ErrorIs(t, err, ErrAffiliateQuotaInsufficient)
}
@@ -150,6 +150,10 @@ func (r *paymentFulfillmentAffiliateRepoStub) TransferQuotaToBalance(context.Con
panic("unexpected TransferQuotaToBalance call")
}
func (r *paymentFulfillmentAffiliateRepoStub) WithdrawQuota(context.Context, int64, float64) (*AffiliateWithdrawResult, error) {
panic("unexpected WithdrawQuota call")
}
func (r *paymentFulfillmentAffiliateRepoStub) ListInvitees(context.Context, int64, int) ([]AffiliateInvitee, error) {
panic("unexpected ListInvitees call")
}