mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 16:26:30 +00:00
Merge pull request #7282 from feeeei/feat/affiliate-offline-withdrawal
feat(affiliate): 提取记录支持登记线下提现
This commit is contained in:
@@ -201,6 +201,42 @@ func (h *AffiliateHandler) GetUserOverview(c *gin.Context) {
|
|||||||
response.Success(c, overview)
|
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.
|
// ListInviteRecords returns all inviter-invitee relationships.
|
||||||
// GET /api/v1/admin/affiliates/invites
|
// GET /api/v1/admin/affiliates/invites
|
||||||
func (h *AffiliateHandler) ListInviteRecords(c *gin.Context) {
|
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)
|
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
|
// GET /api/v1/admin/affiliates/rebates
|
||||||
func (h *AffiliateHandler) ListRebateRecords(c *gin.Context) {
|
func (h *AffiliateHandler) ListRebateRecords(c *gin.Context) {
|
||||||
page, pageSize := response.ParsePagination(c)
|
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)
|
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
|
// GET /api/v1/admin/affiliates/transfers
|
||||||
func (h *AffiliateHandler) ListTransferRecords(c *gin.Context) {
|
func (h *AffiliateHandler) ListTransferRecords(c *gin.Context) {
|
||||||
page, pageSize := response.ParsePagination(c)
|
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")
|
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) {
|
func (r *oauthEmailAffiliateRepoStub) ListInvitees(context.Context, int64, int) ([]service.AffiliateInvitee, error) {
|
||||||
panic("unexpected ListInvitees call")
|
panic("unexpected ListInvitees call")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -341,6 +341,171 @@ VALUES ($1, 'transfer', $2, NULL, $3, $4, $5, $6, NOW(), NOW())`,
|
|||||||
return transferred, newBalance, nil
|
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) {
|
func (r *affiliateRepository) ListInvitees(ctx context.Context, inviterID int64, limit int) ([]service.AffiliateInvitee, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 100
|
limit = 100
|
||||||
@@ -466,13 +631,14 @@ func (r *affiliateRepository) ListAffiliateRebateRecords(ctx context.Context, fi
|
|||||||
"inviter.email", "inviter.username", "invitee.email", "invitee.username",
|
"inviter.email", "inviter.username", "invitee.email", "invitee.username",
|
||||||
"po.id::text", "po.out_trade_no", "po.payment_type", "po.status",
|
"po.id::text", "po.out_trade_no", "po.payment_type", "po.status",
|
||||||
})
|
})
|
||||||
|
// 返利记录列出全部 accrue 流水:非订单来源的流水没有订单,被邀请人账号
|
||||||
|
// 删除后 source_user_id 为 NULL,这两类行都保留。
|
||||||
baseJoin := `
|
baseJoin := `
|
||||||
FROM user_affiliate_ledger ual
|
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
|
JOIN users inviter ON inviter.id = ual.user_id
|
||||||
WHERE ual.action = 'accrue'
|
LEFT JOIN users invitee ON invitee.id = ual.source_user_id
|
||||||
AND ual.source_order_id IS NOT NULL`
|
LEFT JOIN payment_orders po ON po.id = ual.source_order_id
|
||||||
|
WHERE ual.action = 'accrue'`
|
||||||
if where != "" {
|
if where != "" {
|
||||||
where = strings.Replace(where, "WHERE ", " AND ", 1)
|
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)
|
args = append(args, filter.PageSize, (filter.Page-1)*filter.PageSize)
|
||||||
rows, err := client.QueryContext(ctx, `
|
rows, err := client.QueryContext(ctx, `
|
||||||
SELECT po.id,
|
SELECT po.id,
|
||||||
po.out_trade_no,
|
COALESCE(po.out_trade_no, ''),
|
||||||
ual.user_id,
|
ual.user_id,
|
||||||
COALESCE(inviter.email, ''),
|
COALESCE(inviter.email, ''),
|
||||||
COALESCE(inviter.username, ''),
|
COALESCE(inviter.username, ''),
|
||||||
@@ -506,8 +672,8 @@ SELECT po.id,
|
|||||||
po.amount::double precision,
|
po.amount::double precision,
|
||||||
po.pay_amount::double precision,
|
po.pay_amount::double precision,
|
||||||
ual.amount::double precision,
|
ual.amount::double precision,
|
||||||
po.payment_type,
|
COALESCE(po.payment_type, ''),
|
||||||
po.status,
|
COALESCE(po.status, ''),
|
||||||
ual.created_at
|
ual.created_at
|
||||||
`+baseJoin+where+`
|
`+baseJoin+where+`
|
||||||
`+orderBy+`
|
`+orderBy+`
|
||||||
@@ -520,17 +686,21 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
|
|||||||
items := make([]service.AffiliateRebateRecord, 0)
|
items := make([]service.AffiliateRebateRecord, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var item service.AffiliateRebateRecord
|
var item service.AffiliateRebateRecord
|
||||||
|
var orderID sql.NullInt64
|
||||||
|
var inviteeID sql.NullInt64
|
||||||
|
var orderAmount sql.NullFloat64
|
||||||
|
var payAmount sql.NullFloat64
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&item.OrderID,
|
&orderID,
|
||||||
&item.OutTradeNo,
|
&item.OutTradeNo,
|
||||||
&item.InviterID,
|
&item.InviterID,
|
||||||
&item.InviterEmail,
|
&item.InviterEmail,
|
||||||
&item.InviterUsername,
|
&item.InviterUsername,
|
||||||
&item.InviteeID,
|
&inviteeID,
|
||||||
&item.InviteeEmail,
|
&item.InviteeEmail,
|
||||||
&item.InviteeUsername,
|
&item.InviteeUsername,
|
||||||
&item.OrderAmount,
|
&orderAmount,
|
||||||
&item.PayAmount,
|
&payAmount,
|
||||||
&item.RebateAmount,
|
&item.RebateAmount,
|
||||||
&item.PaymentType,
|
&item.PaymentType,
|
||||||
&item.OrderStatus,
|
&item.OrderStatus,
|
||||||
@@ -538,6 +708,10 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
|
|||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
item.OrderID = nullableInt64Ptr(orderID)
|
||||||
|
item.InviteeID = nullableInt64Ptr(inviteeID)
|
||||||
|
item.OrderAmount = nullableFloat64Ptr(orderAmount)
|
||||||
|
item.PayAmount = nullableFloat64Ptr(payAmount)
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
@@ -554,7 +728,7 @@ func (r *affiliateRepository) ListAffiliateTransferRecords(ctx context.Context,
|
|||||||
baseJoin := `
|
baseJoin := `
|
||||||
FROM user_affiliate_ledger ual
|
FROM user_affiliate_ledger ual
|
||||||
JOIN users u ON u.id = ual.user_id
|
JOIN users u ON u.id = ual.user_id
|
||||||
WHERE ual.action = 'transfer'`
|
WHERE ual.action IN ('transfer', 'withdraw')`
|
||||||
if where != "" {
|
if where != "" {
|
||||||
where = strings.Replace(where, "WHERE ", " AND ", 1)
|
where = strings.Replace(where, "WHERE ", " AND ", 1)
|
||||||
}
|
}
|
||||||
@@ -566,6 +740,7 @@ WHERE ual.action = 'transfer'`
|
|||||||
|
|
||||||
orderBy := buildAffiliateRecordOrderBy(filter, map[string]string{
|
orderBy := buildAffiliateRecordOrderBy(filter, map[string]string{
|
||||||
"user": "u.email",
|
"user": "u.email",
|
||||||
|
"action": "ual.action",
|
||||||
"amount": "ual.amount",
|
"amount": "ual.amount",
|
||||||
"balance_after": "ual.balance_after",
|
"balance_after": "ual.balance_after",
|
||||||
"available_quota_after": "ual.aff_quota_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)
|
args = append(args, filter.PageSize, (filter.Page-1)*filter.PageSize)
|
||||||
rows, err := client.QueryContext(ctx, `
|
rows, err := client.QueryContext(ctx, `
|
||||||
SELECT ual.id,
|
SELECT ual.id,
|
||||||
|
ual.action,
|
||||||
ual.user_id,
|
ual.user_id,
|
||||||
COALESCE(u.email, ''),
|
COALESCE(u.email, ''),
|
||||||
COALESCE(u.username, ''),
|
COALESCE(u.username, ''),
|
||||||
@@ -602,6 +778,7 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
|
|||||||
var historyQuotaAfter sql.NullFloat64
|
var historyQuotaAfter sql.NullFloat64
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&item.LedgerID,
|
&item.LedgerID,
|
||||||
|
&item.Action,
|
||||||
&item.UserID,
|
&item.UserID,
|
||||||
&item.UserEmail,
|
&item.UserEmail,
|
||||||
&item.Username,
|
&item.Username,
|
||||||
@@ -958,6 +1135,13 @@ func nullableFloat64Ptr(v sql.NullFloat64) *float64 {
|
|||||||
return &v.Float64
|
return &v.Float64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nullableInt64Ptr(v sql.NullInt64) *int64 {
|
||||||
|
if !v.Valid {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &v.Int64
|
||||||
|
}
|
||||||
|
|
||||||
func generateAffiliateCode() (string, error) {
|
func generateAffiliateCode() (string, error) {
|
||||||
buf := make([]byte, affiliateCodeLength)
|
buf := make([]byte, affiliateCodeLength)
|
||||||
if _, err := rand.Read(buf); err != nil {
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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")
|
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)
|
require.NoError(t, err)
|
||||||
content := string(source)
|
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.amount::double precision")
|
||||||
require.Contains(t, content, "ual.balance_after::double precision")
|
require.Contains(t, content, "ual.balance_after::double precision")
|
||||||
require.NotContains(t, content, "parseAffiliateRebateAmount")
|
require.NotContains(t, content, "parseAffiliateRebateAmount")
|
||||||
require.NotContains(t, content, `"current_balance": "u.balance"`)
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -823,6 +823,7 @@ func registerAffiliateRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
|||||||
users.GET("/lookup", h.Admin.Affiliate.LookupUsers)
|
users.GET("/lookup", h.Admin.Affiliate.LookupUsers)
|
||||||
users.POST("/batch-rate", h.Admin.Affiliate.BatchSetRate)
|
users.POST("/batch-rate", h.Admin.Affiliate.BatchSetRate)
|
||||||
users.GET("/:user_id/overview", h.Admin.Affiliate.GetUserOverview)
|
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.PUT("/:user_id", h.Admin.Affiliate.UpdateUserSettings)
|
||||||
users.DELETE("/:user_id", h.Admin.Affiliate.ClearUserSettings)
|
users.DELETE("/:user_id", h.Admin.Affiliate.ClearUserSettings)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"math"
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -17,6 +19,9 @@ var (
|
|||||||
ErrAffiliateCodeTaken = infraerrors.Conflict("AFFILIATE_CODE_TAKEN", "affiliate code already in use")
|
ErrAffiliateCodeTaken = infraerrors.Conflict("AFFILIATE_CODE_TAKEN", "affiliate code already in use")
|
||||||
ErrAffiliateAlreadyBound = infraerrors.Conflict("AFFILIATE_ALREADY_BOUND", "affiliate inviter already bound")
|
ErrAffiliateAlreadyBound = infraerrors.Conflict("AFFILIATE_ALREADY_BOUND", "affiliate inviter already bound")
|
||||||
ErrAffiliateQuotaEmpty = infraerrors.BadRequest("AFFILIATE_QUOTA_EMPTY", "no affiliate quota available to transfer")
|
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 (
|
const (
|
||||||
@@ -102,6 +107,7 @@ type AffiliateRepository interface {
|
|||||||
GetAccruedRebateFromInvitee(ctx context.Context, inviterID, inviteeUserID int64) (float64, error)
|
GetAccruedRebateFromInvitee(ctx context.Context, inviterID, inviteeUserID int64) (float64, error)
|
||||||
ThawFrozenQuota(ctx context.Context, userID int64) (float64, error)
|
ThawFrozenQuota(ctx context.Context, userID int64) (float64, error)
|
||||||
TransferQuotaToBalance(ctx context.Context, userID int64) (float64, 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)
|
ListInvitees(ctx context.Context, inviterID int64, limit int) ([]AffiliateInvitee, error)
|
||||||
|
|
||||||
// 管理端:用户级专属配置
|
// 管理端:用户级专属配置
|
||||||
@@ -156,25 +162,31 @@ type AffiliateInviteRecord struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AffiliateRebateRecord 是一笔返利入账流水。非订单来源(兑换码、管理员充值)
|
||||||
|
// 的流水没有订单信息,OrderID/OrderAmount/PayAmount 为 nil;被邀请人账号被删除时
|
||||||
|
// InviteeID 为 nil。
|
||||||
type AffiliateRebateRecord struct {
|
type AffiliateRebateRecord struct {
|
||||||
OrderID int64 `json:"order_id"`
|
OrderID *int64 `json:"order_id"`
|
||||||
OutTradeNo string `json:"out_trade_no"`
|
OutTradeNo string `json:"out_trade_no"`
|
||||||
InviterID int64 `json:"inviter_id"`
|
InviterID int64 `json:"inviter_id"`
|
||||||
InviterEmail string `json:"inviter_email"`
|
InviterEmail string `json:"inviter_email"`
|
||||||
InviterUsername string `json:"inviter_username"`
|
InviterUsername string `json:"inviter_username"`
|
||||||
InviteeID int64 `json:"invitee_id"`
|
InviteeID *int64 `json:"invitee_id"`
|
||||||
InviteeEmail string `json:"invitee_email"`
|
InviteeEmail string `json:"invitee_email"`
|
||||||
InviteeUsername string `json:"invitee_username"`
|
InviteeUsername string `json:"invitee_username"`
|
||||||
OrderAmount float64 `json:"order_amount"`
|
OrderAmount *float64 `json:"order_amount"`
|
||||||
PayAmount float64 `json:"pay_amount"`
|
PayAmount *float64 `json:"pay_amount"`
|
||||||
RebateAmount float64 `json:"rebate_amount"`
|
RebateAmount float64 `json:"rebate_amount"`
|
||||||
PaymentType string `json:"payment_type"`
|
PaymentType string `json:"payment_type"`
|
||||||
OrderStatus string `json:"order_status"`
|
OrderStatus string `json:"order_status"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AffiliateTransferRecord 是一笔返利额度流出:Action 为 transfer(用户转入余额)
|
||||||
|
// 或 withdraw(管理员登记的线下提现)。
|
||||||
type AffiliateTransferRecord struct {
|
type AffiliateTransferRecord struct {
|
||||||
LedgerID int64 `json:"ledger_id"`
|
LedgerID int64 `json:"ledger_id"`
|
||||||
|
Action string `json:"action"`
|
||||||
UserID int64 `json:"user_id"`
|
UserID int64 `json:"user_id"`
|
||||||
UserEmail string `json:"user_email"`
|
UserEmail string `json:"user_email"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
@@ -191,6 +203,18 @@ type AffiliateTransferRecord struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
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 {
|
type AffiliateUserOverview struct {
|
||||||
UserID int64 `json:"user_id"`
|
UserID int64 `json:"user_id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
@@ -559,6 +583,43 @@ func (s *AffiliateService) AdminBatchSetUserRebateRate(ctx context.Context, user
|
|||||||
return s.repo.BatchSetUserRebateRate(ctx, cleaned, ratePercent)
|
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 列出有专属配置的用户。
|
// AdminListCustomUsers 列出有专属配置的用户。
|
||||||
func (s *AffiliateService) AdminListCustomUsers(ctx context.Context, filter AffiliateAdminFilter) ([]AffiliateAdminEntry, int64, error) {
|
func (s *AffiliateService) AdminListCustomUsers(ctx context.Context, filter AffiliateAdminFilter) ([]AffiliateAdminEntry, int64, error) {
|
||||||
if s == nil || s.repo == nil {
|
if s == nil || s.repo == nil {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"math"
|
"math"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"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")
|
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) {
|
func (r *paymentFulfillmentAffiliateRepoStub) ListInvitees(context.Context, int64, int) ([]AffiliateInvitee, error) {
|
||||||
panic("unexpected ListInvitees call")
|
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;
|
||||||
@@ -46,25 +46,35 @@ export interface AffiliateInviteRecord {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One rebate accrual. Non-order sources (redeem codes, admin recharges) carry no
|
||||||
|
* order, so order fields are null; invitee_id is null when the invitee account
|
||||||
|
* was deleted.
|
||||||
|
*/
|
||||||
export interface AffiliateRebateRecord {
|
export interface AffiliateRebateRecord {
|
||||||
order_id: number
|
order_id: number | null
|
||||||
out_trade_no: string
|
out_trade_no: string
|
||||||
inviter_id: number
|
inviter_id: number
|
||||||
inviter_email: string
|
inviter_email: string
|
||||||
inviter_username: string
|
inviter_username: string
|
||||||
invitee_id: number
|
invitee_id: number | null
|
||||||
invitee_email: string
|
invitee_email: string
|
||||||
invitee_username: string
|
invitee_username: string
|
||||||
order_amount: number
|
order_amount: number | null
|
||||||
pay_amount: number
|
pay_amount: number | null
|
||||||
rebate_amount: number
|
rebate_amount: number
|
||||||
payment_type: string
|
payment_type: string
|
||||||
order_status: string
|
order_status: string
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** transfer = user moved quota into balance; withdraw = admin-recorded offline withdrawal. */
|
||||||
|
export type AffiliateOutflowAction = 'transfer' | 'withdraw'
|
||||||
|
|
||||||
|
/** One affiliate quota outflow: a transfer into balance or an offline withdrawal. */
|
||||||
export interface AffiliateTransferRecord {
|
export interface AffiliateTransferRecord {
|
||||||
ledger_id: number
|
ledger_id: number
|
||||||
|
action: AffiliateOutflowAction
|
||||||
user_id: number
|
user_id: number
|
||||||
user_email: string
|
user_email: string
|
||||||
username: string
|
username: string
|
||||||
@@ -77,6 +87,26 @@ export interface AffiliateTransferRecord {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WithdrawAffiliateQuotaRequest {
|
||||||
|
/** Amount already paid to the user outside the site (USD). */
|
||||||
|
amount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AffiliateWithdrawResult {
|
||||||
|
ledger_id: number
|
||||||
|
user_id: number
|
||||||
|
amount: number
|
||||||
|
available_quota_after: number
|
||||||
|
frozen_quota_after: number
|
||||||
|
history_quota_after: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AffiliateWithdrawResponse {
|
||||||
|
result: AffiliateWithdrawResult
|
||||||
|
/** True when the key matched an earlier registration and nothing was deducted again. */
|
||||||
|
replayed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface AffiliateUserOverview {
|
export interface AffiliateUserOverview {
|
||||||
user_id: number
|
user_id: number
|
||||||
email: string
|
email: string
|
||||||
@@ -215,6 +245,27 @@ export async function getUserOverview(
|
|||||||
return data
|
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,
|
||||||
|
idempotencyKey: string,
|
||||||
|
): Promise<AffiliateWithdrawResponse> {
|
||||||
|
const response = await apiClient.post<AffiliateWithdrawResult>(
|
||||||
|
`/admin/affiliates/users/${userId}/withdraw`,
|
||||||
|
payload,
|
||||||
|
{ headers: { 'Idempotency-Key': idempotencyKey } },
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
result: response.data,
|
||||||
|
replayed: response.headers?.['x-idempotency-replayed'] === 'true',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const affiliatesAPI = {
|
export const affiliatesAPI = {
|
||||||
listUsers,
|
listUsers,
|
||||||
lookupUsers,
|
lookupUsers,
|
||||||
@@ -225,6 +276,7 @@ export const affiliatesAPI = {
|
|||||||
listRebateRecords,
|
listRebateRecords,
|
||||||
listTransferRecords,
|
listTransferRecords,
|
||||||
getUserOverview,
|
getUserOverview,
|
||||||
|
withdrawUserQuota,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default affiliatesAPI
|
export default affiliatesAPI
|
||||||
|
|||||||
@@ -422,10 +422,12 @@ export default {
|
|||||||
|
|
||||||
affiliates: {
|
affiliates: {
|
||||||
invitesDescription: 'View site-wide inviter and invitee relationships',
|
invitesDescription: 'View site-wide inviter and invitee relationships',
|
||||||
rebatesDescription: 'View recharge orders that generated affiliate rebates',
|
rebatesDescription: 'View every affiliate rebate accrual from recharge orders, redeem codes, and admin recharges',
|
||||||
transfersDescription: 'View affiliate quota transfers into account balance',
|
transfersDescription: 'View affiliate quota transfers into balance and offline withdrawals',
|
||||||
errors: {
|
errors: {
|
||||||
loadFailed: 'Failed to load affiliate records'
|
loadFailed: 'Failed to load affiliate records',
|
||||||
|
AFFILIATE_QUOTA_INSUFFICIENT: 'Insufficient available affiliate quota',
|
||||||
|
AFFILIATE_WITHDRAW_AMOUNT_INVALID: 'Invalid withdrawal amount'
|
||||||
},
|
},
|
||||||
records: {
|
records: {
|
||||||
search: 'Search',
|
search: 'Search',
|
||||||
@@ -450,7 +452,33 @@ export default {
|
|||||||
historyQuotaAfter: 'Historical Rebate After',
|
historyQuotaAfter: 'Historical Rebate After',
|
||||||
invitedAt: 'Invited At',
|
invitedAt: 'Invited At',
|
||||||
rebatedAt: 'Rebated At',
|
rebatedAt: 'Rebated At',
|
||||||
transferredAt: 'Transferred At'
|
transferredAt: 'Transferred At',
|
||||||
|
outflowType: 'Type'
|
||||||
|
},
|
||||||
|
outflowTypes: {
|
||||||
|
transfer: 'To Balance',
|
||||||
|
withdraw: 'Offline Withdrawal'
|
||||||
|
},
|
||||||
|
withdraw: {
|
||||||
|
button: 'Record Offline Withdrawal',
|
||||||
|
title: 'Record Offline Withdrawal',
|
||||||
|
user: 'User',
|
||||||
|
userPlaceholder: 'Search by email or username',
|
||||||
|
noUserFound: 'No matching users',
|
||||||
|
changeUser: 'Change user',
|
||||||
|
availableQuota: 'Available quota',
|
||||||
|
frozenHint: 'Rebates still in the freeze period are not included in the available quota',
|
||||||
|
amount: 'Withdrawal amount (USD)',
|
||||||
|
amountHint: 'Enter the amount already paid to this user outside the site',
|
||||||
|
fillAll: 'All',
|
||||||
|
warning: 'Recording deducts this amount from the user\'s available affiliate quota and cannot be undone. Make sure the off-site payment is complete.',
|
||||||
|
submit: 'Confirm',
|
||||||
|
submitting: 'Recording...',
|
||||||
|
success: 'Recorded offline withdrawal of {amount}; {remaining} still available',
|
||||||
|
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'
|
||||||
},
|
},
|
||||||
overview: {
|
overview: {
|
||||||
title: 'Affiliate User Overview',
|
title: 'Affiliate User Overview',
|
||||||
|
|||||||
@@ -422,10 +422,12 @@ export default {
|
|||||||
|
|
||||||
affiliates: {
|
affiliates: {
|
||||||
invitesDescription: '查看全站邀请关系和被邀请用户累计返利',
|
invitesDescription: '查看全站邀请关系和被邀请用户累计返利',
|
||||||
rebatesDescription: '查看每一笔产生返利的充值订单',
|
rebatesDescription: '查看每一笔返利入账,包括充值订单、兑换码和管理员充值产生的返利',
|
||||||
transfersDescription: '查看返利额度转入账户余额的提取流水',
|
transfersDescription: '查看返利额度转入余额与线下提现的流水',
|
||||||
errors: {
|
errors: {
|
||||||
loadFailed: '加载邀请返利记录失败'
|
loadFailed: '加载邀请返利记录失败',
|
||||||
|
AFFILIATE_QUOTA_INSUFFICIENT: '可提取返利额度不足',
|
||||||
|
AFFILIATE_WITHDRAW_AMOUNT_INVALID: '提现金额无效'
|
||||||
},
|
},
|
||||||
records: {
|
records: {
|
||||||
search: '搜索',
|
search: '搜索',
|
||||||
@@ -450,7 +452,33 @@ export default {
|
|||||||
historyQuotaAfter: '提取后历史返利',
|
historyQuotaAfter: '提取后历史返利',
|
||||||
invitedAt: '邀请时间',
|
invitedAt: '邀请时间',
|
||||||
rebatedAt: '返利时间',
|
rebatedAt: '返利时间',
|
||||||
transferredAt: '提取时间'
|
transferredAt: '提取时间',
|
||||||
|
outflowType: '类型'
|
||||||
|
},
|
||||||
|
outflowTypes: {
|
||||||
|
transfer: '转入余额',
|
||||||
|
withdraw: '线下提现'
|
||||||
|
},
|
||||||
|
withdraw: {
|
||||||
|
button: '登记线下提现',
|
||||||
|
title: '登记线下提现',
|
||||||
|
user: '用户',
|
||||||
|
userPlaceholder: '输入邮箱或用户名搜索',
|
||||||
|
noUserFound: '未找到匹配的用户',
|
||||||
|
changeUser: '更换用户',
|
||||||
|
availableQuota: '当前可提取额度',
|
||||||
|
frozenHint: '冻结期内的返利不计入可提取额度',
|
||||||
|
amount: '提现金额(USD)',
|
||||||
|
amountHint: '填写已在站外实际打款给该用户的金额',
|
||||||
|
fillAll: '全部',
|
||||||
|
warning: '登记后将从该用户的可提取返利额度中扣除,且无法撤销。请确认已完成站外打款。',
|
||||||
|
submit: '确认登记',
|
||||||
|
submitting: '登记中...',
|
||||||
|
success: '已登记线下提现 {amount},剩余可提取 {remaining}',
|
||||||
|
replayed: '这笔线下提现此前已登记({amount}),本次未重复扣减;登记后剩余可提取 {remaining}',
|
||||||
|
uncertainHint: '上次提交没有收到结果,可能已经登记成功。用户与金额已锁定,重新提交会沿用同一笔登记,已登记时不会重复扣减。',
|
||||||
|
amountRequired: '请输入大于 0 的金额',
|
||||||
|
amountExceeds: '提现金额不能超过当前可提取额度'
|
||||||
},
|
},
|
||||||
overview: {
|
overview: {
|
||||||
title: '用户返利概览',
|
title: '用户返利概览',
|
||||||
|
|||||||
@@ -12,6 +12,15 @@
|
|||||||
<button class="btn btn-secondary px-2 md:px-3" :disabled="loading" :title="t('common.refresh')" @click="loadRecords">
|
<button class="btn btn-secondary px-2 md:px-3" :disabled="loading" :title="t('common.refresh')" @click="loadRecords">
|
||||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="props.type === 'transfers'"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary ml-auto"
|
||||||
|
data-test="affiliate-withdraw-open"
|
||||||
|
@click="withdrawDialog = true"
|
||||||
|
>
|
||||||
|
{{ t('admin.affiliates.withdraw.button') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -36,7 +45,9 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #cell-invitee="{ row }">
|
<template #cell-invitee="{ row }">
|
||||||
|
<span v-if="row.invitee_id == null" class="text-sm text-gray-400 dark:text-dark-500">-</span>
|
||||||
<UserCell
|
<UserCell
|
||||||
|
v-else
|
||||||
:id="row.invitee_id"
|
:id="row.invitee_id"
|
||||||
:email="row.invitee_email"
|
:email="row.invitee_email"
|
||||||
:username="row.invitee_username"
|
:username="row.invitee_username"
|
||||||
@@ -57,29 +68,38 @@
|
|||||||
<span class="font-mono text-sm text-gray-700 dark:text-gray-300">{{ row.aff_code || '-' }}</span>
|
<span class="font-mono text-sm text-gray-700 dark:text-gray-300">{{ row.aff_code || '-' }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template #cell-order="{ row }">
|
<template #cell-order="{ row }">
|
||||||
<div class="space-y-0.5">
|
<span v-if="row.order_id == null" class="text-sm text-gray-400 dark:text-dark-500">-</span>
|
||||||
|
<div v-else class="space-y-0.5">
|
||||||
<div class="font-mono text-sm text-gray-900 dark:text-white">#{{ row.order_id }}</div>
|
<div class="font-mono text-sm text-gray-900 dark:text-white">#{{ row.order_id }}</div>
|
||||||
<div class="max-w-56 truncate text-sm text-gray-500 dark:text-dark-400">{{ row.out_trade_no }}</div>
|
<div class="max-w-56 truncate text-sm text-gray-500 dark:text-dark-400">{{ row.out_trade_no }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #cell-payment_type="{ row }">
|
<template #cell-payment_type="{ row }">
|
||||||
{{ t('payment.methods.' + row.payment_type, row.payment_type || '-') }}
|
<template v-if="row.payment_type">{{ t('payment.methods.' + row.payment_type, row.payment_type) }}</template>
|
||||||
|
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
|
||||||
</template>
|
</template>
|
||||||
<template #cell-order_status="{ row }">
|
<template #cell-order_status="{ row }">
|
||||||
<OrderStatusBadge :status="row.order_status" />
|
<OrderStatusBadge v-if="row.order_status" :status="row.order_status" />
|
||||||
|
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
|
||||||
</template>
|
</template>
|
||||||
<template #cell-total_rebate="{ row }">
|
<template #cell-total_rebate="{ row }">
|
||||||
<AmountText :value="row.total_rebate" />
|
<AmountText :value="row.total_rebate" />
|
||||||
</template>
|
</template>
|
||||||
<template #cell-order_amount="{ row }">
|
<template #cell-order_amount="{ row }">
|
||||||
<AmountText :value="row.order_amount" />
|
<NullableAmountText :value="row.order_amount" />
|
||||||
</template>
|
</template>
|
||||||
<template #cell-pay_amount="{ row }">
|
<template #cell-pay_amount="{ row }">
|
||||||
<span class="text-sm text-gray-900 dark:text-white">¥{{ formatAmount(row.pay_amount) }}</span>
|
<span v-if="row.pay_amount == null" class="text-sm text-gray-400 dark:text-dark-500">-</span>
|
||||||
|
<span v-else class="text-sm text-gray-900 dark:text-white">¥{{ formatAmount(row.pay_amount) }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template #cell-rebate_amount="{ row }">
|
<template #cell-rebate_amount="{ row }">
|
||||||
<AmountText :value="row.rebate_amount" strong />
|
<AmountText :value="row.rebate_amount" strong />
|
||||||
</template>
|
</template>
|
||||||
|
<template #cell-action="{ row }">
|
||||||
|
<span :class="['badge whitespace-nowrap', row.action === 'withdraw' ? 'badge-warning' : 'badge-primary']">
|
||||||
|
{{ outflowTypeLabel(row.action) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
<template #cell-amount="{ row }">
|
<template #cell-amount="{ row }">
|
||||||
<AmountText :value="row.amount" strong />
|
<AmountText :value="row.amount" strong />
|
||||||
</template>
|
</template>
|
||||||
@@ -113,6 +133,13 @@
|
|||||||
</template>
|
</template>
|
||||||
</TablePageLayout>
|
</TablePageLayout>
|
||||||
|
|
||||||
|
<AffiliateOfflineWithdrawDialog
|
||||||
|
v-if="props.type === 'transfers'"
|
||||||
|
:show="withdrawDialog"
|
||||||
|
@close="withdrawDialog = false"
|
||||||
|
@success="handleWithdrawSuccess"
|
||||||
|
/>
|
||||||
|
|
||||||
<BaseDialog
|
<BaseDialog
|
||||||
:show="overviewDialog"
|
:show="overviewDialog"
|
||||||
:title="t('admin.affiliates.overview.title')"
|
:title="t('admin.affiliates.overview.title')"
|
||||||
@@ -151,6 +178,7 @@ import Pagination from '@/components/common/Pagination.vue'
|
|||||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import OrderStatusBadge from '@/components/payment/OrderStatusBadge.vue'
|
import OrderStatusBadge from '@/components/payment/OrderStatusBadge.vue'
|
||||||
|
import AffiliateOfflineWithdrawDialog from './AffiliateOfflineWithdrawDialog.vue'
|
||||||
import type { Column } from '@/components/common/types'
|
import type { Column } from '@/components/common/types'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { affiliatesAPI, type AffiliateInviteRecord, type AffiliateRebateRecord, type AffiliateTransferRecord, type AffiliateUserOverview, type ListAffiliateRecordsParams } from '@/api/admin/affiliates'
|
import { affiliatesAPI, type AffiliateInviteRecord, type AffiliateRebateRecord, type AffiliateTransferRecord, type AffiliateUserOverview, type ListAffiliateRecordsParams } from '@/api/admin/affiliates'
|
||||||
@@ -174,6 +202,7 @@ const pagination = reactive({ page: 1, page_size: 20, total: 0 })
|
|||||||
const overviewDialog = ref(false)
|
const overviewDialog = ref(false)
|
||||||
const overviewLoading = ref(false)
|
const overviewLoading = ref(false)
|
||||||
const selectedOverview = ref<AffiliateUserOverview | null>(null)
|
const selectedOverview = ref<AffiliateUserOverview | null>(null)
|
||||||
|
const withdrawDialog = ref(false)
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
const columns = computed<Column[]>(() => {
|
const columns = computed<Column[]>(() => {
|
||||||
@@ -201,6 +230,7 @@ const columns = computed<Column[]>(() => {
|
|||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{ key: 'user', label: t('admin.affiliates.records.user'), sortable: true },
|
{ key: 'user', label: t('admin.affiliates.records.user'), sortable: true },
|
||||||
|
{ key: 'action', label: t('admin.affiliates.records.outflowType'), sortable: true },
|
||||||
{ key: 'amount', label: t('admin.affiliates.records.transferAmount'), sortable: true },
|
{ key: 'amount', label: t('admin.affiliates.records.transferAmount'), sortable: true },
|
||||||
{ key: 'balance_after', label: t('admin.affiliates.records.balanceAfter'), sortable: true },
|
{ key: 'balance_after', label: t('admin.affiliates.records.balanceAfter'), sortable: true },
|
||||||
{ key: 'available_quota_after', label: t('admin.affiliates.records.availableQuotaAfter'), sortable: true },
|
{ key: 'available_quota_after', label: t('admin.affiliates.records.availableQuotaAfter'), sortable: true },
|
||||||
@@ -303,6 +333,17 @@ function handleSort(key: string, order: 'asc' | 'desc') {
|
|||||||
void loadRecords()
|
void loadRecords()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleWithdrawSuccess() {
|
||||||
|
withdrawDialog.value = false
|
||||||
|
reloadFromFirstPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
function outflowTypeLabel(action: string | null | undefined): string {
|
||||||
|
return action === 'withdraw'
|
||||||
|
? t('admin.affiliates.outflowTypes.withdraw')
|
||||||
|
: t('admin.affiliates.outflowTypes.transfer')
|
||||||
|
}
|
||||||
|
|
||||||
function formatAmount(value: number | null | undefined): string {
|
function formatAmount(value: number | null | undefined): string {
|
||||||
return Number(value || 0).toFixed(2)
|
return Number(value || 0).toFixed(2)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
<template>
|
||||||
|
<BaseDialog
|
||||||
|
:show="show"
|
||||||
|
:title="t('admin.affiliates.withdraw.title')"
|
||||||
|
width="normal"
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<form class="space-y-4" @submit.prevent="submit">
|
||||||
|
<div>
|
||||||
|
<label class="input-label">{{ t('admin.affiliates.withdraw.user') }}</label>
|
||||||
|
<div
|
||||||
|
v-if="selectedUser"
|
||||||
|
class="flex items-center justify-between gap-3 rounded-md border border-primary-200 bg-primary-50 px-3 py-2 dark:border-primary-700/50 dark:bg-primary-900/20"
|
||||||
|
data-test="withdraw-selected-user"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 truncate text-sm">
|
||||||
|
<span class="font-mono text-gray-500 dark:text-dark-400">#{{ selectedUser.id }}</span>
|
||||||
|
<span class="ml-2 font-medium text-gray-900 dark:text-white">{{ selectedUser.email }}</span>
|
||||||
|
<span v-if="selectedUser.username" class="ml-1 text-xs text-gray-500 dark:text-dark-400">({{ selectedUser.username }})</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
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 || outcomeUncertain"
|
||||||
|
data-test="withdraw-clear-user"
|
||||||
|
@click="clearUser"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<input
|
||||||
|
v-model="userQuery"
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
:placeholder="t('admin.affiliates.withdraw.userPlaceholder')"
|
||||||
|
data-test="withdraw-user-search"
|
||||||
|
@input="onUserQueryInput"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="userResults.length > 0"
|
||||||
|
class="mt-1 max-h-40 overflow-y-auto rounded border border-gray-200 dark:border-dark-700"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="user in userResults"
|
||||||
|
:key="user.id"
|
||||||
|
type="button"
|
||||||
|
class="w-full px-3 py-1.5 text-left text-sm text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-dark-800"
|
||||||
|
data-test="withdraw-user-option"
|
||||||
|
@click="selectUser(user)"
|
||||||
|
>
|
||||||
|
{{ user.email }} <span class="text-xs text-gray-500 dark:text-dark-400">({{ user.username }})</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="searched" class="input-hint">{{ t('admin.affiliates.withdraw.noUserFound') }}</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="selectedUser"
|
||||||
|
class="rounded-lg border border-gray-100 bg-gray-50 p-3 dark:border-dark-700 dark:bg-dark-800"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-gray-500 dark:text-dark-400">{{ t('admin.affiliates.withdraw.availableQuota') }}</span>
|
||||||
|
<span
|
||||||
|
v-if="overviewLoading"
|
||||||
|
class="h-4 w-4 animate-spin rounded-full border-2 border-primary-500 border-t-transparent"
|
||||||
|
></span>
|
||||||
|
<span v-else class="font-semibold text-gray-900 dark:text-white" data-test="withdraw-available-quota">
|
||||||
|
${{ formatPreciseAmount(availableQuota) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-dark-400">{{ t('admin.affiliates.withdraw.frozenHint') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="input-label">{{ t('admin.affiliates.withdraw.amount') }}</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
v-model="amount"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
inputmode="decimal"
|
||||||
|
class="input"
|
||||||
|
:disabled="!selectedUser || overviewLoading || submitting || outcomeUncertain"
|
||||||
|
data-test="withdraw-amount"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary shrink-0"
|
||||||
|
:disabled="!selectedUser || overviewLoading || submitting || outcomeUncertain || availableQuota <= 0"
|
||||||
|
data-test="withdraw-fill-all"
|
||||||
|
@click="fillAll"
|
||||||
|
>
|
||||||
|
{{ t('admin.affiliates.withdraw.fillAll') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="amountError" class="input-error-text" data-test="withdraw-amount-error">{{ amountError }}</p>
|
||||||
|
<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>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<button type="button" class="btn btn-secondary" :disabled="submitting" @click="handleClose">
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
data-test="withdraw-submit"
|
||||||
|
@click="submit"
|
||||||
|
>
|
||||||
|
{{ submitting ? t('admin.affiliates.withdraw.submitting') : t('admin.affiliates.withdraw.submit') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</BaseDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
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
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
success: [result: AffiliateWithdrawResult]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 返利额度按 8 位小数记账,金额比较在同一精度上进行。
|
||||||
|
const LEDGER_SCALE = 1e8
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
const userQuery = ref('')
|
||||||
|
const userResults = ref<SimpleUser[]>([])
|
||||||
|
const searched = ref(false)
|
||||||
|
const selectedUser = ref<SimpleUser | null>(null)
|
||||||
|
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
|
||||||
|
let overviewSeq = 0
|
||||||
|
|
||||||
|
const amountText = computed(() => String(amount.value ?? '').trim())
|
||||||
|
const parsedAmount = computed(() => (amountText.value === '' ? Number.NaN : Number(amountText.value)))
|
||||||
|
|
||||||
|
const amountError = computed(() => {
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
if (Math.round(value * LEDGER_SCALE) > Math.round(availableQuota.value * LEDGER_SCALE)) {
|
||||||
|
return t('admin.affiliates.withdraw.amountExceeds')
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 结果未知的登记可能已经扣减,重试不受刷新后的可提取额度约束。
|
||||||
|
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('.')
|
||||||
|
return `${intPart}.${fraction.padEnd(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(error: unknown) {
|
||||||
|
appStore.showError(extractI18nErrorMessage(error, t, 'admin.affiliates.errors', t('common.error')))
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSearchTimer() {
|
||||||
|
if (searchTimer) {
|
||||||
|
clearTimeout(searchTimer)
|
||||||
|
searchTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUserQueryInput() {
|
||||||
|
clearSearchTimer()
|
||||||
|
searched.value = false
|
||||||
|
const query = userQuery.value.trim()
|
||||||
|
if (!query) {
|
||||||
|
searchSeq++
|
||||||
|
userResults.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
searchTimer = setTimeout(() => {
|
||||||
|
void searchUsers(query)
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchUsers(query: string) {
|
||||||
|
const seq = ++searchSeq
|
||||||
|
try {
|
||||||
|
const users = await affiliatesAPI.lookupUsers(query)
|
||||||
|
if (seq !== searchSeq) return
|
||||||
|
userResults.value = users || []
|
||||||
|
searched.value = true
|
||||||
|
} catch (error) {
|
||||||
|
if (seq !== searchSeq) return
|
||||||
|
showError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAvailableQuota(userId: number) {
|
||||||
|
const seq = ++overviewSeq
|
||||||
|
overviewLoading.value = true
|
||||||
|
try {
|
||||||
|
const overview = await affiliatesAPI.getUserOverview(userId)
|
||||||
|
if (seq !== overviewSeq) return
|
||||||
|
availableQuota.value = Number(overview.available_quota || 0)
|
||||||
|
} catch (error) {
|
||||||
|
if (seq !== overviewSeq) return
|
||||||
|
availableQuota.value = 0
|
||||||
|
// 从未产生过邀请返利的用户没有返利档案,概览接口返回 USER_NOT_FOUND,可提取额度即为 0。
|
||||||
|
if (extractApiErrorCode(error) === 'USER_NOT_FOUND') return
|
||||||
|
if (!outcomeUncertain.value) selectedUser.value = null
|
||||||
|
showError(error)
|
||||||
|
} finally {
|
||||||
|
if (seq === overviewSeq) overviewLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectUser(user: SimpleUser) {
|
||||||
|
clearSearchTimer()
|
||||||
|
searchSeq++
|
||||||
|
selectedUser.value = user
|
||||||
|
userQuery.value = ''
|
||||||
|
userResults.value = []
|
||||||
|
searched.value = false
|
||||||
|
availableQuota.value = 0
|
||||||
|
amount.value = ''
|
||||||
|
void loadAvailableQuota(user.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearUser() {
|
||||||
|
overviewSeq++
|
||||||
|
selectedUser.value = null
|
||||||
|
availableQuota.value = 0
|
||||||
|
overviewLoading.value = false
|
||||||
|
amount.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillAll() {
|
||||||
|
amount.value = Math.round(availableQuota.value * LEDGER_SCALE) / LEDGER_SCALE
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
clearSearchTimer()
|
||||||
|
searchSeq++
|
||||||
|
overviewSeq++
|
||||||
|
userQuery.value = ''
|
||||||
|
userResults.value = []
|
||||||
|
searched.value = false
|
||||||
|
selectedUser.value = null
|
||||||
|
availableQuota.value = 0
|
||||||
|
overviewLoading.value = false
|
||||||
|
amount.value = ''
|
||||||
|
submitting.value = false
|
||||||
|
pendingOperation.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
if (submitting.value) return
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
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, replayed } = await affiliatesAPI.withdrawUserQuota(
|
||||||
|
operation.userId,
|
||||||
|
{ amount: operation.amount },
|
||||||
|
operation.key,
|
||||||
|
)
|
||||||
|
completeAffiliateWithdrawOperation(operation)
|
||||||
|
pendingOperation.value = null
|
||||||
|
appStore.showSuccess(
|
||||||
|
t(replayed ? 'admin.affiliates.withdraw.replayed' : 'admin.affiliates.withdraw.success', {
|
||||||
|
amount: `$${formatPreciseAmount(result.amount)}`,
|
||||||
|
remaining: `$${formatPreciseAmount(result.available_quota_after)}`,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
emit('success', result)
|
||||||
|
} catch (error) {
|
||||||
|
showError(error)
|
||||||
|
// 网络错误、超时、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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(show) => {
|
||||||
|
if (show) resetForm()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(clearSearchTimer)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import AdminAffiliateRecordsTable from '../AdminAffiliateRecordsTable.vue'
|
||||||
|
|
||||||
|
const { listInviteRecords, listRebateRecords, listTransferRecords, getUserOverview } = vi.hoisted(() => ({
|
||||||
|
listInviteRecords: vi.fn(),
|
||||||
|
listRebateRecords: vi.fn(),
|
||||||
|
listTransferRecords: vi.fn(),
|
||||||
|
getUserOverview: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/admin/affiliates', () => {
|
||||||
|
const affiliatesAPI = { listInviteRecords, listRebateRecords, listTransferRecords, getUserOverview }
|
||||||
|
return { affiliatesAPI, default: affiliatesAPI }
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/stores/app', () => ({
|
||||||
|
useAppStore: () => ({ showError: vi.fn(), showSuccess: vi.fn() }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('vue-i18n')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const TablePageLayoutStub = {
|
||||||
|
template: '<div><slot name="filters" /><slot name="table" /><slot name="pagination" /></div>',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DataTableStub = {
|
||||||
|
props: ['columns', 'data'],
|
||||||
|
template: `
|
||||||
|
<table>
|
||||||
|
<tr v-for="(row, index) in data" :key="index" :data-test="'row-' + index">
|
||||||
|
<td v-for="column in columns" :key="column.key" :data-test="'cell-' + column.key">
|
||||||
|
<slot :name="'cell-' + column.key" :row="row" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
|
||||||
|
const WithdrawDialogStub = {
|
||||||
|
name: 'AffiliateOfflineWithdrawDialog',
|
||||||
|
props: ['show'],
|
||||||
|
emits: ['close', 'success'],
|
||||||
|
template: '<div data-test="withdraw-dialog" :data-show="String(show)" />',
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountTable(type: 'invites' | 'rebates' | 'transfers') {
|
||||||
|
return mount(AdminAffiliateRecordsTable, {
|
||||||
|
props: { type },
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
AppLayout: { template: '<main><slot /></main>' },
|
||||||
|
TablePageLayout: TablePageLayoutStub,
|
||||||
|
DataTable: DataTableStub,
|
||||||
|
Pagination: true,
|
||||||
|
Icon: true,
|
||||||
|
OrderStatusBadge: true,
|
||||||
|
BaseDialog: true,
|
||||||
|
AffiliateOfflineWithdrawDialog: WithdrawDialogStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function page<T>(items: T[]) {
|
||||||
|
return { items, total: items.length, page: 1, page_size: 20, pages: 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AdminAffiliateRecordsTable', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders non-order rebate accruals with empty order fields', async () => {
|
||||||
|
listRebateRecords.mockResolvedValue(page([
|
||||||
|
{
|
||||||
|
order_id: null,
|
||||||
|
out_trade_no: '',
|
||||||
|
inviter_id: 1,
|
||||||
|
inviter_email: 'inviter@example.com',
|
||||||
|
inviter_username: 'inviter',
|
||||||
|
invitee_id: 2,
|
||||||
|
invitee_email: 'invitee@example.com',
|
||||||
|
invitee_username: 'invitee',
|
||||||
|
order_amount: null,
|
||||||
|
pay_amount: null,
|
||||||
|
rebate_amount: 2,
|
||||||
|
payment_type: '',
|
||||||
|
order_status: '',
|
||||||
|
created_at: '2026-09-17T08:00:00Z',
|
||||||
|
},
|
||||||
|
]))
|
||||||
|
|
||||||
|
const wrapper = mountTable('rebates')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const row = wrapper.get('[data-test="row-0"]')
|
||||||
|
expect(row.get('[data-test="cell-order"]').text()).toBe('-')
|
||||||
|
expect(row.get('[data-test="cell-order_amount"]').text()).toBe('-')
|
||||||
|
expect(row.get('[data-test="cell-pay_amount"]').text()).toBe('-')
|
||||||
|
expect(row.get('[data-test="cell-payment_type"]').text()).toBe('-')
|
||||||
|
expect(row.get('[data-test="cell-order_status"]').text()).toBe('-')
|
||||||
|
expect(row.get('[data-test="cell-rebate_amount"]').text()).toBe('$2.00')
|
||||||
|
expect(wrapper.find('[data-test="affiliate-withdraw-open"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists offline withdrawals next to balance transfers with their type', async () => {
|
||||||
|
listTransferRecords.mockResolvedValue(page([
|
||||||
|
{
|
||||||
|
ledger_id: 11,
|
||||||
|
action: 'withdraw',
|
||||||
|
user_id: 42,
|
||||||
|
user_email: 'inviter@example.com',
|
||||||
|
username: 'inviter',
|
||||||
|
amount: 12.5,
|
||||||
|
balance_after: 5.5,
|
||||||
|
available_quota_after: 7.5,
|
||||||
|
frozen_quota_after: 0,
|
||||||
|
history_quota_after: 30,
|
||||||
|
snapshot_available: true,
|
||||||
|
created_at: '2026-09-17T08:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ledger_id: 10,
|
||||||
|
action: 'transfer',
|
||||||
|
user_id: 42,
|
||||||
|
user_email: 'inviter@example.com',
|
||||||
|
username: 'inviter',
|
||||||
|
amount: 3,
|
||||||
|
balance_after: 5.5,
|
||||||
|
available_quota_after: 20,
|
||||||
|
frozen_quota_after: 0,
|
||||||
|
history_quota_after: 30,
|
||||||
|
snapshot_available: true,
|
||||||
|
created_at: '2026-09-16T08:00:00Z',
|
||||||
|
},
|
||||||
|
]))
|
||||||
|
|
||||||
|
const wrapper = mountTable('transfers')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const withdrawRow = wrapper.get('[data-test="row-0"]')
|
||||||
|
expect(withdrawRow.get('[data-test="cell-action"]').text()).toBe('admin.affiliates.outflowTypes.withdraw')
|
||||||
|
expect(withdrawRow.get('[data-test="cell-amount"]').text()).toBe('$12.50')
|
||||||
|
|
||||||
|
const transferRow = wrapper.get('[data-test="row-1"]')
|
||||||
|
expect(transferRow.get('[data-test="cell-action"]').text()).toBe('admin.affiliates.outflowTypes.transfer')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the offline withdrawal dialog and reloads the first page after recording', async () => {
|
||||||
|
listTransferRecords.mockResolvedValue(page([]))
|
||||||
|
|
||||||
|
const wrapper = mountTable('transfers')
|
||||||
|
await flushPromises()
|
||||||
|
expect(listTransferRecords).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
const button = wrapper.get('[data-test="affiliate-withdraw-open"]')
|
||||||
|
expect(button.text()).toBe('admin.affiliates.withdraw.button')
|
||||||
|
await button.trigger('click')
|
||||||
|
expect(wrapper.get('[data-test="withdraw-dialog"]').attributes('data-show')).toBe('true')
|
||||||
|
|
||||||
|
wrapper.findComponent(WithdrawDialogStub).vm.$emit('success', { ledger_id: 12 })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="withdraw-dialog"]').attributes('data-show')).toBe('false')
|
||||||
|
expect(listTransferRecords).toHaveBeenCalledTimes(2)
|
||||||
|
expect(listTransferRecords.mock.calls[1][0]).toMatchObject({ page: 1 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import AffiliateOfflineWithdrawDialog from '../AffiliateOfflineWithdrawDialog.vue'
|
||||||
|
|
||||||
|
const { lookupUsers, getUserOverview, withdrawUserQuota, showError, showSuccess } = vi.hoisted(() => ({
|
||||||
|
lookupUsers: vi.fn(),
|
||||||
|
getUserOverview: vi.fn(),
|
||||||
|
withdrawUserQuota: vi.fn(),
|
||||||
|
showError: vi.fn(),
|
||||||
|
showSuccess: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/admin/affiliates', () => ({
|
||||||
|
affiliatesAPI: { lookupUsers, getUserOverview, withdrawUserQuota },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/app', () => ({
|
||||||
|
useAppStore: () => ({ showError, showSuccess }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('vue-i18n')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key: string, params?: Record<string, unknown>) =>
|
||||||
|
params ? `${key} ${JSON.stringify(params)}` : key,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const BaseDialogStub = {
|
||||||
|
props: ['show', 'title'],
|
||||||
|
emits: ['close'],
|
||||||
|
template: '<div v-if="show"><slot /><slot name="footer" /></div>',
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountDialog() {
|
||||||
|
return mount(AffiliateOfflineWithdrawDialog, {
|
||||||
|
props: { show: true },
|
||||||
|
global: { stubs: { BaseDialog: BaseDialogStub } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function overviewWithQuota(availableQuota: number) {
|
||||||
|
return {
|
||||||
|
user_id: 42,
|
||||||
|
email: 'inviter@example.com',
|
||||||
|
username: 'inviter',
|
||||||
|
aff_code: 'INVITER',
|
||||||
|
rebate_rate_percent: 20,
|
||||||
|
invited_count: 3,
|
||||||
|
rebated_invitee_count: 2,
|
||||||
|
available_quota: availableQuota,
|
||||||
|
history_quota: 50,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
await wrapper.get('[data-test="withdraw-user-option"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AffiliateOfflineWithdrawDialog', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('records the exact available quota as the withdrawal amount', async () => {
|
||||||
|
const wrapper = mountDialog()
|
||||||
|
await pickUser(wrapper, 12.34567891)
|
||||||
|
|
||||||
|
expect(lookupUsers).toHaveBeenCalledWith('inviter')
|
||||||
|
expect(getUserOverview).toHaveBeenCalledWith(42)
|
||||||
|
expect(wrapper.get('[data-test="withdraw-available-quota"]').text()).toBe('$12.34567891')
|
||||||
|
|
||||||
|
await wrapper.get('[data-test="withdraw-fill-all"]').trigger('click')
|
||||||
|
expect((wrapper.get('[data-test="withdraw-amount"]').element as HTMLInputElement).value).toBe('12.34567891')
|
||||||
|
|
||||||
|
withdrawUserQuota.mockResolvedValue(withdrawResponse(12.34567891, 0))
|
||||||
|
await wrapper.get('[data-test="withdraw-submit"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(withdrawUserQuota).toHaveBeenCalledWith(42, { amount: 12.34567891 }, expect.stringMatching(/^affiliate-withdraw-/))
|
||||||
|
expect(showSuccess).toHaveBeenCalledWith(
|
||||||
|
'admin.affiliates.withdraw.success {"amount":"$12.34567891","remaining":"$0.00"}',
|
||||||
|
)
|
||||||
|
expect(wrapper.emitted('success')?.[0]?.[0]).toMatchObject({ ledger_id: 9 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks amounts above the available quota', async () => {
|
||||||
|
const wrapper = mountDialog()
|
||||||
|
await pickUser(wrapper, 10)
|
||||||
|
|
||||||
|
await wrapper.get('[data-test="withdraw-amount"]').setValue('10.00000001')
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="withdraw-amount-error"]').text()).toBe('admin.affiliates.withdraw.amountExceeds')
|
||||||
|
expect(wrapper.get('[data-test="withdraw-submit"]').attributes('disabled')).toBeDefined()
|
||||||
|
await wrapper.get('[data-test="withdraw-submit"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(withdrawUserQuota).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats a user without an affiliate profile as having no available quota', async () => {
|
||||||
|
lookupUsers.mockResolvedValue([{ id: 7, email: 'fresh@example.com', username: 'fresh' }])
|
||||||
|
getUserOverview.mockRejectedValue({ reason: 'USER_NOT_FOUND', message: 'user not found' })
|
||||||
|
const wrapper = mountDialog()
|
||||||
|
|
||||||
|
await wrapper.get('[data-test="withdraw-user-search"]').setValue('fresh')
|
||||||
|
vi.advanceTimersByTime(300)
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.get('[data-test="withdraw-user-option"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(showError).not.toHaveBeenCalled()
|
||||||
|
expect(wrapper.get('[data-test="withdraw-selected-user"]').text()).toContain('fresh@example.com')
|
||||||
|
expect(wrapper.get('[data-test="withdraw-available-quota"]').text()).toBe('$0.00')
|
||||||
|
expect(wrapper.get('[data-test="withdraw-fill-all"]').attributes('disabled')).toBeDefined()
|
||||||
|
|
||||||
|
await wrapper.get('[data-test="withdraw-amount"]').setValue('1')
|
||||||
|
expect(wrapper.get('[data-test="withdraw-amount-error"]').text()).toBe('admin.affiliates.withdraw.amountExceeds')
|
||||||
|
expect(wrapper.get('[data-test="withdraw-submit"]').attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the API error and reloads the available quota when recording fails', async () => {
|
||||||
|
const wrapper = mountDialog()
|
||||||
|
await pickUser(wrapper, 10)
|
||||||
|
|
||||||
|
withdrawUserQuota.mockRejectedValue({ 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()
|
||||||
|
|
||||||
|
expect(showError).toHaveBeenCalledWith('admin.affiliates.errors.AFFILIATE_QUOTA_INSUFFICIENT {}')
|
||||||
|
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