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

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

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

返利记录改为 LEFT JOIN 订单与被邀请人,兑换码、管理员充值来源的返利也会出现
在明细中,接口里订单相关字段与被邀请人 ID 可为 null。
This commit is contained in:
feeeei
2026-09-17 23:42:02 +08:00
parent efe9aab1e4
commit 57b7dbdc85
17 changed files with 1365 additions and 36 deletions
@@ -201,6 +201,36 @@ func (h *AffiliateHandler) GetUserOverview(c *gin.Context) {
response.Success(c, overview)
}
// WithdrawQuotaRequest 是登记线下提现的请求体。Amount 是已在站外打款给
// 用户的金额,由 AffiliateService 校验。
type WithdrawQuotaRequest struct {
Amount float64 `json:"amount"`
}
// WithdrawQuota records an offline withdrawal paid outside the site and
// deducts it from the user's available affiliate quota.
// POST /api/v1/admin/affiliates/users/:user_id/withdraw
func (h *AffiliateHandler) WithdrawQuota(c *gin.Context) {
userID, err := strconv.ParseInt(c.Param("user_id"), 10, 64)
if err != nil || userID <= 0 {
response.BadRequest(c, "Invalid user_id")
return
}
var req WithdrawQuotaRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
result, err := h.affiliateService.AdminWithdrawQuota(c.Request.Context(), userID, req.Amount)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
// ListInviteRecords returns all inviter-invitee relationships.
// GET /api/v1/admin/affiliates/invites
func (h *AffiliateHandler) ListInviteRecords(c *gin.Context) {
@@ -214,7 +244,8 @@ func (h *AffiliateHandler) ListInviteRecords(c *gin.Context) {
response.Paginated(c, items, total, filter.Page, filter.PageSize)
}
// ListRebateRecords returns all order-level affiliate rebate records.
// ListRebateRecords returns every affiliate rebate accrual, with order details
// when the rebate came from a payment order.
// GET /api/v1/admin/affiliates/rebates
func (h *AffiliateHandler) ListRebateRecords(c *gin.Context) {
page, pageSize := response.ParsePagination(c)
@@ -227,7 +258,8 @@ func (h *AffiliateHandler) ListRebateRecords(c *gin.Context) {
response.Paginated(c, items, total, filter.Page, filter.PageSize)
}
// ListTransferRecords returns all affiliate quota-to-balance transfer records.
// ListTransferRecords returns affiliate quota outflows: user transfers into
// balance and admin-recorded offline withdrawals.
// GET /api/v1/admin/affiliates/transfers
func (h *AffiliateHandler) ListTransferRecords(c *gin.Context) {
page, pageSize := response.ParsePagination(c)
@@ -0,0 +1,105 @@
package admin
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type withdrawQuotaCall struct {
userID int64
amount float64
}
type withdrawQuotaRepoStub struct {
service.AffiliateRepository
calls []withdrawQuotaCall
result *service.AffiliateWithdrawResult
err error
}
func (s *withdrawQuotaRepoStub) WithdrawQuota(_ context.Context, userID int64, amount float64) (*service.AffiliateWithdrawResult, error) {
s.calls = append(s.calls, withdrawQuotaCall{userID: userID, amount: amount})
return s.result, s.err
}
type withdrawQuotaResponse struct {
Code int `json:"code"`
Reason string `json:"reason"`
Data *service.AffiliateWithdrawResult `json:"data"`
}
func performWithdrawQuota(t *testing.T, repo *withdrawQuotaRepoStub, userID, body string) (int, withdrawQuotaResponse) {
t.Helper()
gin.SetMode(gin.TestMode)
router := gin.New()
handler := NewAffiliateHandler(service.NewAffiliateService(repo, nil, nil, nil), nil)
router.POST("/api/v1/admin/affiliates/users/:user_id/withdraw", handler.WithdrawQuota)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/affiliates/users/"+userID+"/withdraw", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
var resp withdrawQuotaResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
return rec.Code, resp
}
// TestAffiliateHandlerWithdrawQuota_Success 验证登记线下提现把路径中的用户与
// 舍入后的金额交给仓储,并返回登记结果。
func TestAffiliateHandlerWithdrawQuota_Success(t *testing.T) {
repo := &withdrawQuotaRepoStub{result: &service.AffiliateWithdrawResult{LedgerID: 9, UserID: 42, Amount: 12.34567891}}
status, resp := performWithdrawQuota(t, repo, "42", `{"amount":12.3456789149}`)
require.Equal(t, http.StatusOK, status)
require.Equal(t, 0, resp.Code)
require.NotNil(t, resp.Data)
require.Equal(t, int64(9), resp.Data.LedgerID)
require.Equal(t, []withdrawQuotaCall{{userID: 42, amount: 12.34567891}}, repo.calls)
}
// TestAffiliateHandlerWithdrawQuota_RejectsBadRequests 验证非法用户 ID、非法 JSON
// 与非法金额在进入仓储前以 400 拒绝;金额错误带 AFFILIATE_WITHDRAW_AMOUNT_INVALID。
func TestAffiliateHandlerWithdrawQuota_RejectsBadRequests(t *testing.T) {
cases := []struct {
name string
userID string
body string
wantReason string
}{
{name: "non-numeric user id", userID: "abc", body: `{"amount":1}`},
{name: "non-positive user id", userID: "0", body: `{"amount":1}`},
{name: "malformed json", userID: "42", body: `{"amount":`},
{name: "zero amount", userID: "42", body: `{"amount":0}`, wantReason: "AFFILIATE_WITHDRAW_AMOUNT_INVALID"},
{name: "missing amount", userID: "42", body: `{}`, wantReason: "AFFILIATE_WITHDRAW_AMOUNT_INVALID"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
repo := &withdrawQuotaRepoStub{}
status, resp := performWithdrawQuota(t, repo, tc.userID, tc.body)
require.Equal(t, http.StatusBadRequest, status)
require.Equal(t, tc.wantReason, resp.Reason)
require.Empty(t, repo.calls)
})
}
}
// TestAffiliateHandlerWithdrawQuota_InsufficientQuota 验证额度不足以 400 和
// AFFILIATE_QUOTA_INSUFFICIENT 返回,前端据此提示。
func TestAffiliateHandlerWithdrawQuota_InsufficientQuota(t *testing.T) {
repo := &withdrawQuotaRepoStub{err: service.ErrAffiliateQuotaInsufficient}
status, resp := performWithdrawQuota(t, repo, "42", `{"amount":5}`)
require.Equal(t, http.StatusBadRequest, status)
require.Equal(t, "AFFILIATE_QUOTA_INSUFFICIENT", resp.Reason)
require.Len(t, repo.calls, 1)
}
@@ -407,6 +407,10 @@ func (r *oauthEmailAffiliateRepoStub) TransferQuotaToBalance(context.Context, in
panic("unexpected TransferQuotaToBalance call")
}
func (r *oauthEmailAffiliateRepoStub) WithdrawQuota(context.Context, int64, float64) (*service.AffiliateWithdrawResult, error) {
panic("unexpected WithdrawQuota call")
}
func (r *oauthEmailAffiliateRepoStub) ListInvitees(context.Context, int64, int) ([]service.AffiliateInvitee, error) {
panic("unexpected ListInvitees call")
}