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:
@@ -2,6 +2,8 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"math"
|
||||
"strings"
|
||||
@@ -17,6 +19,9 @@ var (
|
||||
ErrAffiliateCodeTaken = infraerrors.Conflict("AFFILIATE_CODE_TAKEN", "affiliate code already in use")
|
||||
ErrAffiliateAlreadyBound = infraerrors.Conflict("AFFILIATE_ALREADY_BOUND", "affiliate inviter already bound")
|
||||
ErrAffiliateQuotaEmpty = infraerrors.BadRequest("AFFILIATE_QUOTA_EMPTY", "no affiliate quota available to transfer")
|
||||
|
||||
ErrAffiliateQuotaInsufficient = infraerrors.BadRequest("AFFILIATE_QUOTA_INSUFFICIENT", "insufficient available affiliate quota")
|
||||
ErrAffiliateWithdrawAmountInvalid = infraerrors.BadRequest("AFFILIATE_WITHDRAW_AMOUNT_INVALID", "invalid offline withdrawal amount")
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -102,6 +107,7 @@ type AffiliateRepository interface {
|
||||
GetAccruedRebateFromInvitee(ctx context.Context, inviterID, inviteeUserID int64) (float64, error)
|
||||
ThawFrozenQuota(ctx context.Context, userID int64) (float64, error)
|
||||
TransferQuotaToBalance(ctx context.Context, userID int64) (float64, float64, error)
|
||||
WithdrawQuota(ctx context.Context, userID int64, amount float64, operationID string) (*AffiliateWithdrawResult, error)
|
||||
ListInvitees(ctx context.Context, inviterID int64, limit int) ([]AffiliateInvitee, error)
|
||||
|
||||
// 管理端:用户级专属配置
|
||||
@@ -156,25 +162,31 @@ type AffiliateInviteRecord struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// AffiliateRebateRecord 是一笔返利入账流水。非订单来源(兑换码、管理员充值)
|
||||
// 的流水没有订单信息,OrderID/OrderAmount/PayAmount 为 nil;被邀请人账号被删除时
|
||||
// InviteeID 为 nil。
|
||||
type AffiliateRebateRecord struct {
|
||||
OrderID int64 `json:"order_id"`
|
||||
OrderID *int64 `json:"order_id"`
|
||||
OutTradeNo string `json:"out_trade_no"`
|
||||
InviterID int64 `json:"inviter_id"`
|
||||
InviterEmail string `json:"inviter_email"`
|
||||
InviterUsername string `json:"inviter_username"`
|
||||
InviteeID int64 `json:"invitee_id"`
|
||||
InviteeID *int64 `json:"invitee_id"`
|
||||
InviteeEmail string `json:"invitee_email"`
|
||||
InviteeUsername string `json:"invitee_username"`
|
||||
OrderAmount float64 `json:"order_amount"`
|
||||
PayAmount float64 `json:"pay_amount"`
|
||||
OrderAmount *float64 `json:"order_amount"`
|
||||
PayAmount *float64 `json:"pay_amount"`
|
||||
RebateAmount float64 `json:"rebate_amount"`
|
||||
PaymentType string `json:"payment_type"`
|
||||
OrderStatus string `json:"order_status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// AffiliateTransferRecord 是一笔返利额度流出:Action 为 transfer(用户转入余额)
|
||||
// 或 withdraw(管理员登记的线下提现)。
|
||||
type AffiliateTransferRecord struct {
|
||||
LedgerID int64 `json:"ledger_id"`
|
||||
Action string `json:"action"`
|
||||
UserID int64 `json:"user_id"`
|
||||
UserEmail string `json:"user_email"`
|
||||
Username string `json:"username"`
|
||||
@@ -191,6 +203,18 @@ type AffiliateTransferRecord struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// AffiliateWithdrawResult 是线下提现登记完成后的流水 ID 与额度快照。
|
||||
// Replayed 表示本次请求命中了同一幂等标识的已有登记,返回的是首次登记的结果。
|
||||
type AffiliateWithdrawResult struct {
|
||||
LedgerID int64 `json:"ledger_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
AvailableQuotaAfter float64 `json:"available_quota_after"`
|
||||
FrozenQuotaAfter float64 `json:"frozen_quota_after"`
|
||||
HistoryQuotaAfter float64 `json:"history_quota_after"`
|
||||
Replayed bool `json:"-"`
|
||||
}
|
||||
|
||||
type AffiliateUserOverview struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
@@ -559,6 +583,43 @@ func (s *AffiliateService) AdminBatchSetUserRebateRate(ctx context.Context, user
|
||||
return s.repo.BatchSetUserRebateRate(ctx, cleaned, ratePercent)
|
||||
}
|
||||
|
||||
// AdminWithdrawQuota 登记一笔已在站外打款的线下提现:从用户可提取返利额度
|
||||
// 中扣除 amount,并写入 action=withdraw 的流水。冻结期内的额度不可扣除。
|
||||
// 金额按返利流水精度保留 8 位小数,舍入后必须为正。
|
||||
//
|
||||
// idempotencyKey 标识一次登记且必填:同一个键只扣减一次,重复请求在用户与金额
|
||||
// 一致时返回首次登记的结果,不一致时返回 ErrIdempotencyKeyConflict。
|
||||
func (s *AffiliateService) AdminWithdrawQuota(ctx context.Context, userID int64, amount float64, idempotencyKey string) (*AffiliateWithdrawResult, error) {
|
||||
if userID <= 0 {
|
||||
return nil, infraerrors.BadRequest("INVALID_USER", "invalid user")
|
||||
}
|
||||
if s == nil || s.repo == nil {
|
||||
return nil, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
|
||||
}
|
||||
key, err := NormalizeIdempotencyKey(idempotencyKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if key == "" {
|
||||
return nil, ErrIdempotencyKeyRequired
|
||||
}
|
||||
if math.IsNaN(amount) || math.IsInf(amount, 0) {
|
||||
return nil, ErrAffiliateWithdrawAmountInvalid
|
||||
}
|
||||
amount = roundTo(amount, 8)
|
||||
if amount <= 0 || math.IsInf(amount, 0) {
|
||||
return nil, ErrAffiliateWithdrawAmountInvalid
|
||||
}
|
||||
return s.repo.WithdrawQuota(ctx, userID, amount, affiliateWithdrawOperationID(key))
|
||||
}
|
||||
|
||||
// affiliateWithdrawOperationID 把线下提现登记的幂等键映射为流水的 operation_id,
|
||||
// 同一个键始终得到同一个值。
|
||||
func affiliateWithdrawOperationID(idempotencyKey string) string {
|
||||
sum := sha256.Sum256([]byte("admin.affiliates.withdraw\x00" + idempotencyKey))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// AdminListCustomUsers 列出有专属配置的用户。
|
||||
func (s *AffiliateService) AdminListCustomUsers(ctx context.Context, filter AffiliateAdminFilter) ([]AffiliateAdminEntry, int64, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -129,3 +130,118 @@ func TestIsValidAffiliateCodeFormat(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type affiliateWithdrawCall struct {
|
||||
userID int64
|
||||
amount float64
|
||||
operationID string
|
||||
}
|
||||
|
||||
type affiliateWithdrawRepoStub struct {
|
||||
AffiliateRepository
|
||||
calls []affiliateWithdrawCall
|
||||
result *AffiliateWithdrawResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *affiliateWithdrawRepoStub) WithdrawQuota(_ context.Context, userID int64, amount float64, operationID string) (*AffiliateWithdrawResult, error) {
|
||||
s.calls = append(s.calls, affiliateWithdrawCall{userID: userID, amount: amount, operationID: operationID})
|
||||
return s.result, s.err
|
||||
}
|
||||
|
||||
const testAffiliateWithdrawKey = "affiliate-withdraw-7-3f2b8c1e"
|
||||
|
||||
// TestAdminWithdrawQuota_RejectsInvalidAmount 覆盖线下提现金额校验:非正数、
|
||||
// NaN/Inf、舍入到 8 位小数后为 0 或溢出的金额都在服务层拒绝,不进入仓储。
|
||||
func TestAdminWithdrawQuota_RejectsInvalidAmount(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, amount := range []float64{0, -1, math.NaN(), math.Inf(1), math.Inf(-1), 1e-9, math.MaxFloat64} {
|
||||
repo := &affiliateWithdrawRepoStub{}
|
||||
svc := &AffiliateService{repo: repo}
|
||||
_, err := svc.AdminWithdrawQuota(context.Background(), 1, amount, testAffiliateWithdrawKey)
|
||||
require.ErrorIs(t, err, ErrAffiliateWithdrawAmountInvalid, "amount %v", amount)
|
||||
require.Empty(t, repo.calls, "amount %v must not reach repository", amount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminWithdrawQuota_RejectsInvalidUser 验证缺少目标用户时直接拒绝。
|
||||
func TestAdminWithdrawQuota_RejectsInvalidUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := &affiliateWithdrawRepoStub{}
|
||||
svc := &AffiliateService{repo: repo}
|
||||
_, err := svc.AdminWithdrawQuota(context.Background(), 0, 1, testAffiliateWithdrawKey)
|
||||
require.Error(t, err)
|
||||
require.Empty(t, repo.calls)
|
||||
}
|
||||
|
||||
// TestAdminWithdrawQuota_RequiresValidIdempotencyKey 验证幂等键必填且须为
|
||||
// 可见 ASCII、不超过 128 字符;不合格的键不进入仓储。
|
||||
func TestAdminWithdrawQuota_RequiresValidIdempotencyKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
key string
|
||||
want error
|
||||
}{
|
||||
{key: "", want: ErrIdempotencyKeyRequired},
|
||||
{key: " ", want: ErrIdempotencyKeyRequired},
|
||||
{key: "has space", want: ErrIdempotencyKeyInvalid},
|
||||
{key: "登记", want: ErrIdempotencyKeyInvalid},
|
||||
{key: strings.Repeat("k", 129), want: ErrIdempotencyKeyInvalid},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
repo := &affiliateWithdrawRepoStub{}
|
||||
svc := &AffiliateService{repo: repo}
|
||||
_, err := svc.AdminWithdrawQuota(context.Background(), 1, 1, tc.key)
|
||||
require.ErrorIs(t, err, tc.want, "key %q", tc.key)
|
||||
require.Empty(t, repo.calls, "key %q must not reach repository", tc.key)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminWithdrawQuota_DerivesStableOperationID 验证仓储收到的 operation_id
|
||||
// 由幂等键确定性派生:同一个键(含首尾空白)得到同一个值,不同的键得到不同的值,
|
||||
// 且不是原始键本身。
|
||||
func TestAdminWithdrawQuota_DerivesStableOperationID(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := &affiliateWithdrawRepoStub{result: &AffiliateWithdrawResult{LedgerID: 1}}
|
||||
svc := &AffiliateService{repo: repo}
|
||||
|
||||
for _, key := range []string{testAffiliateWithdrawKey, " " + testAffiliateWithdrawKey + " ", testAffiliateWithdrawKey + "-2"} {
|
||||
_, err := svc.AdminWithdrawQuota(context.Background(), 42, 10, key)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
require.Len(t, repo.calls, 3)
|
||||
first := repo.calls[0].operationID
|
||||
require.Regexp(t, `^[0-9a-f]{64}$`, first)
|
||||
require.NotEqual(t, testAffiliateWithdrawKey, first)
|
||||
require.Equal(t, first, repo.calls[1].operationID)
|
||||
require.NotEqual(t, first, repo.calls[2].operationID)
|
||||
}
|
||||
|
||||
// TestAdminWithdrawQuota_RoundsAmountToLedgerPrecision 验证金额按 8 位小数舍入后
|
||||
// 交给仓储,仓储结果原样返回。
|
||||
func TestAdminWithdrawQuota_RoundsAmountToLedgerPrecision(t *testing.T) {
|
||||
t.Parallel()
|
||||
want := &AffiliateWithdrawResult{LedgerID: 9, UserID: 42, Amount: 12.34567891}
|
||||
repo := &affiliateWithdrawRepoStub{result: want}
|
||||
svc := &AffiliateService{repo: repo}
|
||||
|
||||
got, err := svc.AdminWithdrawQuota(context.Background(), 42, 12.3456789149, testAffiliateWithdrawKey)
|
||||
require.NoError(t, err)
|
||||
require.Same(t, want, got)
|
||||
require.Len(t, repo.calls, 1)
|
||||
require.Equal(t, int64(42), repo.calls[0].userID)
|
||||
require.Equal(t, 12.34567891, repo.calls[0].amount)
|
||||
}
|
||||
|
||||
// TestAdminWithdrawQuota_PropagatesRepositoryErrors 验证额度不足与同键不同参数
|
||||
// 由仓储判定,服务层原样返回,前端据错误码提示。
|
||||
func TestAdminWithdrawQuota_PropagatesRepositoryErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, want := range []error{ErrAffiliateQuotaInsufficient, ErrIdempotencyKeyConflict} {
|
||||
repo := &affiliateWithdrawRepoStub{err: want}
|
||||
svc := &AffiliateService{repo: repo}
|
||||
_, err := svc.AdminWithdrawQuota(context.Background(), 1, 1, testAffiliateWithdrawKey)
|
||||
require.ErrorIs(t, err, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +150,10 @@ func (r *paymentFulfillmentAffiliateRepoStub) TransferQuotaToBalance(context.Con
|
||||
panic("unexpected TransferQuotaToBalance call")
|
||||
}
|
||||
|
||||
func (r *paymentFulfillmentAffiliateRepoStub) WithdrawQuota(context.Context, int64, float64, string) (*AffiliateWithdrawResult, error) {
|
||||
panic("unexpected WithdrawQuota call")
|
||||
}
|
||||
|
||||
func (r *paymentFulfillmentAffiliateRepoStub) ListInvitees(context.Context, int64, int) ([]AffiliateInvitee, error) {
|
||||
panic("unexpected ListInvitees call")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user