mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-05-05 05:30:44 +08:00
fix: correct affiliate audit record sources
This commit is contained in:
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -115,7 +114,7 @@ func (r *affiliateRepository) BindInviter(ctx context.Context, userID, inviterID
|
||||
return bound, nil
|
||||
}
|
||||
|
||||
func (r *affiliateRepository) AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64, freezeHours int) (bool, error) {
|
||||
func (r *affiliateRepository) AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64, freezeHours int, sourceOrderID *int64) (bool, error) {
|
||||
if amount <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
@@ -141,15 +140,15 @@ func (r *affiliateRepository) AccrueQuota(ctx context.Context, inviterID, invite
|
||||
|
||||
if freezeHours > 0 {
|
||||
if _, err = txClient.ExecContext(txCtx, `
|
||||
INSERT INTO user_affiliate_ledger (user_id, action, amount, source_user_id, frozen_until, created_at, updated_at)
|
||||
VALUES ($1, 'accrue', $2, $3, NOW() + make_interval(hours => $4), NOW(), NOW())`,
|
||||
inviterID, amount, inviteeUserID, freezeHours); err != nil {
|
||||
INSERT INTO user_affiliate_ledger (user_id, action, amount, source_user_id, source_order_id, frozen_until, created_at, updated_at)
|
||||
VALUES ($1, 'accrue', $2, $3, $4, NOW() + make_interval(hours => $5), NOW(), NOW())`,
|
||||
inviterID, amount, inviteeUserID, nullableInt64Arg(sourceOrderID), freezeHours); err != nil {
|
||||
return fmt.Errorf("insert affiliate accrue ledger: %w", err)
|
||||
}
|
||||
} else {
|
||||
if _, err = txClient.ExecContext(txCtx, `
|
||||
INSERT INTO user_affiliate_ledger (user_id, action, amount, source_user_id, created_at, updated_at)
|
||||
VALUES ($1, 'accrue', $2, $3, NOW(), NOW())`, inviterID, amount, inviteeUserID); err != nil {
|
||||
INSERT INTO user_affiliate_ledger (user_id, action, amount, source_user_id, source_order_id, created_at, updated_at)
|
||||
VALUES ($1, 'accrue', $2, $3, $4, NOW(), NOW())`, inviterID, amount, inviteeUserID, nullableInt64Arg(sourceOrderID)); err != nil {
|
||||
return fmt.Errorf("insert affiliate accrue ledger: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -304,9 +303,32 @@ FROM cleared`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
snapshot, err := queryAffiliateTransferSnapshot(txCtx, txClient, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = txClient.ExecContext(txCtx, `
|
||||
INSERT INTO user_affiliate_ledger (user_id, action, amount, source_user_id, created_at, updated_at)
|
||||
VALUES ($1, 'transfer', $2, NULL, NOW(), NOW())`, userID, transferred); err != nil {
|
||||
INSERT INTO user_affiliate_ledger (
|
||||
user_id,
|
||||
action,
|
||||
amount,
|
||||
source_user_id,
|
||||
balance_after,
|
||||
aff_quota_after,
|
||||
aff_frozen_quota_after,
|
||||
aff_history_quota_after,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES ($1, 'transfer', $2, NULL, $3, $4, $5, $6, NOW(), NOW())`,
|
||||
userID,
|
||||
transferred,
|
||||
snapshot.BalanceAfter,
|
||||
snapshot.AvailableQuotaAfter,
|
||||
snapshot.FrozenQuotaAfter,
|
||||
snapshot.HistoryQuotaAfter,
|
||||
); err != nil {
|
||||
return fmt.Errorf("insert affiliate transfer ledger: %w", err)
|
||||
}
|
||||
|
||||
@@ -440,17 +462,17 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
|
||||
|
||||
func (r *affiliateRepository) ListAffiliateRebateRecords(ctx context.Context, filter service.AffiliateRecordFilter) ([]service.AffiliateRebateRecord, int64, error) {
|
||||
client := clientFromContext(ctx, r.client)
|
||||
where, args := buildAffiliateRecordWhere(filter, "pal.created_at", []string{
|
||||
where, args := buildAffiliateRecordWhere(filter, "ual.created_at", []string{
|
||||
"inviter.email", "inviter.username", "invitee.email", "invitee.username",
|
||||
"po.id::text", "po.out_trade_no", "po.payment_type", "po.status",
|
||||
})
|
||||
baseJoin := `
|
||||
FROM payment_audit_logs pal
|
||||
JOIN payment_orders po ON po.id::text = pal.order_id
|
||||
JOIN user_affiliates invitee_aff ON invitee_aff.user_id = po.user_id
|
||||
JOIN users invitee ON invitee.id = po.user_id
|
||||
JOIN users inviter ON inviter.id = invitee_aff.inviter_id
|
||||
WHERE pal.action = 'AFFILIATE_REBATE_APPLIED'`
|
||||
FROM user_affiliate_ledger ual
|
||||
JOIN payment_orders po ON po.id = ual.source_order_id
|
||||
JOIN users invitee ON invitee.id = ual.source_user_id
|
||||
JOIN users inviter ON inviter.id = ual.user_id
|
||||
WHERE ual.action = 'accrue'
|
||||
AND ual.source_order_id IS NOT NULL`
|
||||
if where != "" {
|
||||
where = strings.Replace(where, "WHERE ", " AND ", 1)
|
||||
}
|
||||
@@ -461,31 +483,32 @@ WHERE pal.action = 'AFFILIATE_REBATE_APPLIED'`
|
||||
}
|
||||
|
||||
orderBy := buildAffiliateRecordOrderBy(filter, map[string]string{
|
||||
"order": "po.id",
|
||||
"inviter": "inviter.email",
|
||||
"invitee": "invitee.email",
|
||||
"order_amount": "po.amount",
|
||||
"pay_amount": "po.pay_amount",
|
||||
"payment_type": "po.payment_type",
|
||||
"order_status": "po.status",
|
||||
"created_at": "pal.created_at",
|
||||
}, "pal.created_at")
|
||||
"order": "po.id",
|
||||
"inviter": "inviter.email",
|
||||
"invitee": "invitee.email",
|
||||
"order_amount": "po.amount",
|
||||
"pay_amount": "po.pay_amount",
|
||||
"rebate_amount": "ual.amount",
|
||||
"payment_type": "po.payment_type",
|
||||
"order_status": "po.status",
|
||||
"created_at": "ual.created_at",
|
||||
}, "ual.created_at")
|
||||
args = append(args, filter.PageSize, (filter.Page-1)*filter.PageSize)
|
||||
rows, err := client.QueryContext(ctx, `
|
||||
SELECT po.id,
|
||||
po.out_trade_no,
|
||||
invitee_aff.inviter_id,
|
||||
ual.user_id,
|
||||
COALESCE(inviter.email, ''),
|
||||
COALESCE(inviter.username, ''),
|
||||
po.user_id,
|
||||
ual.source_user_id,
|
||||
COALESCE(invitee.email, ''),
|
||||
COALESCE(invitee.username, ''),
|
||||
po.amount::double precision,
|
||||
po.pay_amount::double precision,
|
||||
COALESCE(pal.detail, ''),
|
||||
ual.amount::double precision,
|
||||
po.payment_type,
|
||||
po.status,
|
||||
pal.created_at
|
||||
ual.created_at
|
||||
`+baseJoin+where+`
|
||||
`+orderBy+`
|
||||
LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
|
||||
@@ -497,7 +520,6 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
|
||||
items := make([]service.AffiliateRebateRecord, 0)
|
||||
for rows.Next() {
|
||||
var item service.AffiliateRebateRecord
|
||||
var detail string
|
||||
if err := rows.Scan(
|
||||
&item.OrderID,
|
||||
&item.OutTradeNo,
|
||||
@@ -509,14 +531,13 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
|
||||
&item.InviteeUsername,
|
||||
&item.OrderAmount,
|
||||
&item.PayAmount,
|
||||
&detail,
|
||||
&item.RebateAmount,
|
||||
&item.PaymentType,
|
||||
&item.OrderStatus,
|
||||
&item.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
item.RebateAmount = parseAffiliateRebateAmount(detail)
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -533,7 +554,6 @@ func (r *affiliateRepository) ListAffiliateTransferRecords(ctx context.Context,
|
||||
baseJoin := `
|
||||
FROM user_affiliate_ledger ual
|
||||
JOIN users u ON u.id = ual.user_id
|
||||
JOIN user_affiliates ua ON ua.user_id = ual.user_id
|
||||
WHERE ual.action = 'transfer'`
|
||||
if where != "" {
|
||||
where = strings.Replace(where, "WHERE ", " AND ", 1)
|
||||
@@ -545,13 +565,13 @@ WHERE ual.action = 'transfer'`
|
||||
}
|
||||
|
||||
orderBy := buildAffiliateRecordOrderBy(filter, map[string]string{
|
||||
"user": "u.email",
|
||||
"amount": "ual.amount",
|
||||
"current_balance": "u.balance",
|
||||
"remaining_quota": "ua.aff_quota",
|
||||
"frozen_quota": "ua.aff_frozen_quota",
|
||||
"history_quota": "ua.aff_history_quota",
|
||||
"created_at": "ual.created_at",
|
||||
"user": "u.email",
|
||||
"amount": "ual.amount",
|
||||
"balance_after": "ual.balance_after",
|
||||
"available_quota_after": "ual.aff_quota_after",
|
||||
"frozen_quota_after": "ual.aff_frozen_quota_after",
|
||||
"history_quota_after": "ual.aff_history_quota_after",
|
||||
"created_at": "ual.created_at",
|
||||
}, "ual.created_at")
|
||||
args = append(args, filter.PageSize, (filter.Page-1)*filter.PageSize)
|
||||
rows, err := client.QueryContext(ctx, `
|
||||
@@ -560,10 +580,10 @@ SELECT ual.id,
|
||||
COALESCE(u.email, ''),
|
||||
COALESCE(u.username, ''),
|
||||
ual.amount::double precision,
|
||||
u.balance::double precision,
|
||||
ua.aff_quota::double precision,
|
||||
ua.aff_frozen_quota::double precision,
|
||||
ua.aff_history_quota::double precision,
|
||||
ual.balance_after::double precision,
|
||||
ual.aff_quota_after::double precision,
|
||||
ual.aff_frozen_quota_after::double precision,
|
||||
ual.aff_history_quota_after::double precision,
|
||||
ual.created_at
|
||||
`+baseJoin+where+`
|
||||
`+orderBy+`
|
||||
@@ -576,20 +596,32 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
|
||||
items := make([]service.AffiliateTransferRecord, 0)
|
||||
for rows.Next() {
|
||||
var item service.AffiliateTransferRecord
|
||||
var balanceAfter sql.NullFloat64
|
||||
var availableQuotaAfter sql.NullFloat64
|
||||
var frozenQuotaAfter sql.NullFloat64
|
||||
var historyQuotaAfter sql.NullFloat64
|
||||
if err := rows.Scan(
|
||||
&item.LedgerID,
|
||||
&item.UserID,
|
||||
&item.UserEmail,
|
||||
&item.Username,
|
||||
&item.Amount,
|
||||
&item.CurrentBalance,
|
||||
&item.RemainingQuota,
|
||||
&item.FrozenQuota,
|
||||
&item.HistoryQuota,
|
||||
&balanceAfter,
|
||||
&availableQuotaAfter,
|
||||
&frozenQuotaAfter,
|
||||
&historyQuotaAfter,
|
||||
&item.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
item.BalanceAfter = nullableFloat64Ptr(balanceAfter)
|
||||
item.AvailableQuotaAfter = nullableFloat64Ptr(availableQuotaAfter)
|
||||
item.FrozenQuotaAfter = nullableFloat64Ptr(frozenQuotaAfter)
|
||||
item.HistoryQuotaAfter = nullableFloat64Ptr(historyQuotaAfter)
|
||||
item.SnapshotAvailable = balanceAfter.Valid &&
|
||||
availableQuotaAfter.Valid &&
|
||||
frozenQuotaAfter.Valid &&
|
||||
historyQuotaAfter.Valid
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -675,7 +707,7 @@ func buildAffiliateRecordOrderBy(filter service.AffiliateRecordFilter, sortColum
|
||||
if !filter.SortDesc {
|
||||
direction = "ASC"
|
||||
}
|
||||
return "ORDER BY " + column + " " + direction
|
||||
return "ORDER BY " + column + " " + direction + " NULLS LAST"
|
||||
}
|
||||
|
||||
func queryAffiliateRecordCount(ctx context.Context, client affiliateQueryExecer, query string, args ...any) (int64, error) {
|
||||
@@ -694,16 +726,6 @@ func queryAffiliateRecordCount(ctx context.Context, client affiliateQueryExecer,
|
||||
return total, rows.Err()
|
||||
}
|
||||
|
||||
func parseAffiliateRebateAmount(detail string) float64 {
|
||||
var payload struct {
|
||||
RebateAmount float64 `json:"rebateAmount"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(detail), &payload); err != nil {
|
||||
return 0
|
||||
}
|
||||
return payload.RebateAmount
|
||||
}
|
||||
|
||||
func (r *affiliateRepository) withTx(ctx context.Context, fn func(txCtx context.Context, txClient *dbent.Client) error) error {
|
||||
if tx := dbent.TxFromContext(ctx); tx != nil {
|
||||
return fn(ctx, tx.Client())
|
||||
@@ -888,6 +910,54 @@ func queryUserBalance(ctx context.Context, client affiliateQueryExecer, userID i
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
type affiliateTransferSnapshot struct {
|
||||
BalanceAfter float64
|
||||
AvailableQuotaAfter float64
|
||||
FrozenQuotaAfter float64
|
||||
HistoryQuotaAfter float64
|
||||
}
|
||||
|
||||
func queryAffiliateTransferSnapshot(ctx context.Context, client affiliateQueryExecer, userID int64) (*affiliateTransferSnapshot, error) {
|
||||
rows, err := client.QueryContext(ctx, `
|
||||
SELECT u.balance::double precision,
|
||||
ua.aff_quota::double precision,
|
||||
ua.aff_frozen_quota::double precision,
|
||||
ua.aff_history_quota::double precision
|
||||
FROM users u
|
||||
JOIN user_affiliates ua ON ua.user_id = u.id
|
||||
WHERE u.id = $1
|
||||
LIMIT 1`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query affiliate transfer snapshot: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
if !rows.Next() {
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, service.ErrUserNotFound
|
||||
}
|
||||
|
||||
var snapshot affiliateTransferSnapshot
|
||||
if err := rows.Scan(
|
||||
&snapshot.BalanceAfter,
|
||||
&snapshot.AvailableQuotaAfter,
|
||||
&snapshot.FrozenQuotaAfter,
|
||||
&snapshot.HistoryQuotaAfter,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &snapshot, rows.Err()
|
||||
}
|
||||
|
||||
func nullableFloat64Ptr(v sql.NullFloat64) *float64 {
|
||||
if !v.Valid {
|
||||
return nil
|
||||
}
|
||||
return &v.Float64
|
||||
}
|
||||
|
||||
func generateAffiliateCode() (string, error) {
|
||||
buf := make([]byte, affiliateCodeLength)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
@@ -1046,6 +1116,13 @@ func nullableArg(v *float64) any {
|
||||
return *v
|
||||
}
|
||||
|
||||
func nullableInt64Arg(v *int64) any {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
// ListUsersWithCustomSettings 列出有专属配置(自定义码或专属比例)的用户。
|
||||
//
|
||||
// 单一查询同时处理"无搜索"与"按邮箱/用户名模糊搜索":
|
||||
|
||||
@@ -78,6 +78,26 @@ VALUES ($1, $2, $3, $3, NOW(), NOW())`, u.ID, affCode, 12.34)
|
||||
ledgerCount := querySingleInt(t, txCtx, client,
|
||||
"SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'transfer'", u.ID)
|
||||
require.Equal(t, 1, ledgerCount)
|
||||
|
||||
rows, err := client.QueryContext(txCtx, `
|
||||
SELECT amount::double precision,
|
||||
balance_after::double precision,
|
||||
aff_quota_after::double precision,
|
||||
aff_frozen_quota_after::double precision,
|
||||
aff_history_quota_after::double precision
|
||||
FROM user_affiliate_ledger
|
||||
WHERE user_id = $1 AND action = 'transfer'
|
||||
LIMIT 1`, u.ID)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = rows.Close() }()
|
||||
require.True(t, rows.Next(), "expected transfer ledger")
|
||||
var amount, balanceAfter, quotaAfter, frozenAfter, historyAfter float64
|
||||
require.NoError(t, rows.Scan(&amount, &balanceAfter, "aAfter, &frozenAfter, &historyAfter))
|
||||
require.InDelta(t, 12.34, amount, 1e-9)
|
||||
require.InDelta(t, 17.84, balanceAfter, 1e-9)
|
||||
require.InDelta(t, 0.0, quotaAfter, 1e-9)
|
||||
require.InDelta(t, 0.0, frozenAfter, 1e-9)
|
||||
require.InDelta(t, 12.34, historyAfter, 1e-9)
|
||||
}
|
||||
|
||||
// TestAffiliateRepository_AccrueQuota_ReusesOuterTransaction guards the
|
||||
@@ -125,7 +145,7 @@ func TestAffiliateRepository_AccrueQuota_ReusesOuterTransaction(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.True(t, bound, "invitee must bind to inviter")
|
||||
|
||||
applied, err := repo.AccrueQuota(txCtx, inviter.ID, invitee.ID, 3.5, 0)
|
||||
applied, err := repo.AccrueQuota(txCtx, inviter.ID, invitee.ID, 3.5, 0, nil)
|
||||
require.NoError(t, err)
|
||||
require.True(t, applied, "AccrueQuota must report applied=true")
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -13,3 +14,15 @@ func TestAffiliateUserOverviewSQLIncludesMaturedFrozenQuota(t *testing.T) {
|
||||
require.Contains(t, query, "ua.aff_quota + COALESCE(matured.matured_frozen_quota, 0)")
|
||||
require.Contains(t, query, "frozen_until <= NOW()")
|
||||
}
|
||||
|
||||
func TestAffiliateRecordQueriesUseLedgerAuditFields(t *testing.T) {
|
||||
source, err := os.ReadFile("affiliate_repo.go")
|
||||
require.NoError(t, err)
|
||||
content := string(source)
|
||||
|
||||
require.Contains(t, content, "JOIN payment_orders po ON po.id = ual.source_order_id")
|
||||
require.Contains(t, content, "ual.amount::double precision")
|
||||
require.Contains(t, content, "ual.balance_after::double precision")
|
||||
require.NotContains(t, content, "parseAffiliateRebateAmount")
|
||||
require.NotContains(t, content, `"current_balance": "u.balance"`)
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ type AffiliateRepository interface {
|
||||
EnsureUserAffiliate(ctx context.Context, userID int64) (*AffiliateSummary, error)
|
||||
GetAffiliateByCode(ctx context.Context, code string) (*AffiliateSummary, error)
|
||||
BindInviter(ctx context.Context, userID, inviterID int64) (bool, error)
|
||||
AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64, freezeHours int) (bool, error)
|
||||
AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64, freezeHours int, sourceOrderID *int64) (bool, error)
|
||||
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)
|
||||
@@ -174,16 +174,21 @@ type AffiliateRebateRecord struct {
|
||||
}
|
||||
|
||||
type AffiliateTransferRecord struct {
|
||||
LedgerID int64 `json:"ledger_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
UserEmail string `json:"user_email"`
|
||||
Username string `json:"username"`
|
||||
Amount float64 `json:"amount"`
|
||||
CurrentBalance float64 `json:"current_balance"`
|
||||
RemainingQuota float64 `json:"remaining_quota"`
|
||||
FrozenQuota float64 `json:"frozen_quota"`
|
||||
HistoryQuota float64 `json:"history_quota"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LedgerID int64 `json:"ledger_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
UserEmail string `json:"user_email"`
|
||||
Username string `json:"username"`
|
||||
Amount float64 `json:"amount"`
|
||||
BalanceAfter *float64 `json:"balance_after,omitempty"`
|
||||
AvailableQuotaAfter *float64 `json:"available_quota_after,omitempty"`
|
||||
FrozenQuotaAfter *float64 `json:"frozen_quota_after,omitempty"`
|
||||
HistoryQuotaAfter *float64 `json:"history_quota_after,omitempty"`
|
||||
SnapshotAvailable bool `json:"snapshot_available"`
|
||||
CurrentBalance float64 `json:"-"`
|
||||
RemainingQuota float64 `json:"-"`
|
||||
FrozenQuota float64 `json:"-"`
|
||||
HistoryQuota float64 `json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type AffiliateUserOverview struct {
|
||||
@@ -307,6 +312,10 @@ func (s *AffiliateService) BindInviterByCode(ctx context.Context, userID int64,
|
||||
}
|
||||
|
||||
func (s *AffiliateService) AccrueInviteRebate(ctx context.Context, inviteeUserID int64, baseRechargeAmount float64) (float64, error) {
|
||||
return s.AccrueInviteRebateForOrder(ctx, inviteeUserID, baseRechargeAmount, nil)
|
||||
}
|
||||
|
||||
func (s *AffiliateService) AccrueInviteRebateForOrder(ctx context.Context, inviteeUserID int64, baseRechargeAmount float64, sourceOrderID *int64) (float64, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return 0, nil
|
||||
}
|
||||
@@ -367,7 +376,7 @@ func (s *AffiliateService) AccrueInviteRebate(ctx context.Context, inviteeUserID
|
||||
freezeHours = s.settingService.GetAffiliateRebateFreezeHours(ctx)
|
||||
}
|
||||
|
||||
applied, err := s.repo.AccrueQuota(ctx, *inviteeSummary.InviterID, inviteeUserID, rebate, freezeHours)
|
||||
applied, err := s.repo.AccrueQuota(ctx, *inviteeSummary.InviterID, inviteeUserID, rebate, freezeHours, sourceOrderID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -394,7 +394,8 @@ func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *db
|
||||
return nil
|
||||
}
|
||||
|
||||
rebateAmount, err := s.affiliateService.AccrueInviteRebate(txCtx, o.UserID, o.Amount)
|
||||
sourceOrderID := o.ID
|
||||
rebateAmount, err := s.affiliateService.AccrueInviteRebateForOrder(txCtx, o.UserID, o.Amount, &sourceOrderID)
|
||||
if err != nil {
|
||||
s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
|
||||
"error": err.Error(),
|
||||
|
||||
85
backend/migrations/134_affiliate_ledger_audit_snapshots.sql
Normal file
85
backend/migrations/134_affiliate_ledger_audit_snapshots.sql
Normal file
@@ -0,0 +1,85 @@
|
||||
-- 邀请返利流水补充订单关联和转余额快照。
|
||||
-- 这些字段只用于审计展示;历史旧流水无法可靠反推的字段保持 NULL,避免把当前状态误展示为历史状态。
|
||||
|
||||
ALTER TABLE user_affiliate_ledger
|
||||
ADD COLUMN IF NOT EXISTS source_order_id BIGINT NULL REFERENCES payment_orders(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE user_affiliate_ledger
|
||||
ADD COLUMN IF NOT EXISTS balance_after DECIMAL(20,8) NULL;
|
||||
|
||||
ALTER TABLE user_affiliate_ledger
|
||||
ADD COLUMN IF NOT EXISTS aff_quota_after DECIMAL(20,8) NULL;
|
||||
|
||||
ALTER TABLE user_affiliate_ledger
|
||||
ADD COLUMN IF NOT EXISTS aff_frozen_quota_after DECIMAL(20,8) NULL;
|
||||
|
||||
ALTER TABLE user_affiliate_ledger
|
||||
ADD COLUMN IF NOT EXISTS aff_history_quota_after DECIMAL(20,8) NULL;
|
||||
|
||||
COMMENT ON COLUMN user_affiliate_ledger.source_order_id IS '产生该返利流水的充值订单;转余额或无法可靠回填的历史数据为 NULL';
|
||||
COMMENT ON COLUMN user_affiliate_ledger.balance_after IS '邀请返利转余额后的用户余额快照;无法取得时为 NULL';
|
||||
COMMENT ON COLUMN user_affiliate_ledger.aff_quota_after IS '邀请返利转余额后的可用返利额度快照;无法取得时为 NULL';
|
||||
COMMENT ON COLUMN user_affiliate_ledger.aff_frozen_quota_after IS '邀请返利转余额后的冻结返利额度快照;无法取得时为 NULL';
|
||||
COMMENT ON COLUMN user_affiliate_ledger.aff_history_quota_after IS '邀请返利转余额后的历史返利总额快照;无法取得时为 NULL';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_affiliate_ledger_source_order_id
|
||||
ON user_affiliate_ledger(source_order_id)
|
||||
WHERE source_order_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_affiliate_ledger_rebate_lookup
|
||||
ON user_affiliate_ledger(action, source_order_id, user_id, source_user_id, created_at)
|
||||
WHERE action = 'accrue';
|
||||
|
||||
-- 尽力回填 PR #2169 合并后、该迁移前已经产生的返利流水。
|
||||
-- 只有在同一订单只能匹配到一条返利流水时才回填,避免把多笔同额流水错误绑定到订单。
|
||||
WITH rebate_audits AS (
|
||||
SELECT po.id AS order_id,
|
||||
po.user_id AS invitee_user_id,
|
||||
invitee_aff.inviter_id,
|
||||
rebate_detail.rebate_amount,
|
||||
pal.created_at AS audit_created_at
|
||||
FROM payment_audit_logs pal
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT substring(
|
||||
pal.detail
|
||||
FROM '"rebateAmount"[[:space:]]*:[[:space:]]*(-?[0-9]+(\.[0-9]+)?)'
|
||||
)::numeric AS rebate_amount
|
||||
) rebate_detail
|
||||
JOIN payment_orders po ON po.id::text = pal.order_id
|
||||
JOIN user_affiliates invitee_aff ON invitee_aff.user_id = po.user_id
|
||||
WHERE pal.action = 'AFFILIATE_REBATE_APPLIED'
|
||||
AND rebate_detail.rebate_amount IS NOT NULL
|
||||
),
|
||||
ranked_matches AS (
|
||||
SELECT ual.id AS ledger_id,
|
||||
ra.order_id,
|
||||
COUNT(*) OVER (PARTITION BY ra.order_id) AS order_match_count,
|
||||
COUNT(*) OVER (PARTITION BY ual.id) AS ledger_match_count,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY ual.id
|
||||
ORDER BY ABS(EXTRACT(EPOCH FROM (ual.created_at - ra.audit_created_at))), ra.order_id
|
||||
) AS ledger_rank
|
||||
FROM rebate_audits ra
|
||||
JOIN user_affiliate_ledger ual
|
||||
ON ual.action = 'accrue'
|
||||
AND ual.source_order_id IS NULL
|
||||
AND ual.user_id = ra.inviter_id
|
||||
AND ual.source_user_id = ra.invitee_user_id
|
||||
AND ABS(ual.amount - ra.rebate_amount) < 0.00000001
|
||||
AND ual.created_at BETWEEN ra.audit_created_at - INTERVAL '10 minutes'
|
||||
AND ra.audit_created_at + INTERVAL '10 minutes'
|
||||
)
|
||||
UPDATE user_affiliate_ledger ual
|
||||
SET source_order_id = ranked_matches.order_id,
|
||||
updated_at = NOW()
|
||||
FROM ranked_matches
|
||||
WHERE ual.id = ranked_matches.ledger_id
|
||||
AND ranked_matches.order_match_count = 1
|
||||
AND ranked_matches.ledger_match_count = 1
|
||||
AND ranked_matches.ledger_rank = 1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM user_affiliate_ledger existing
|
||||
WHERE existing.source_order_id = ranked_matches.order_id
|
||||
AND existing.action = 'accrue'
|
||||
);
|
||||
@@ -127,3 +127,18 @@ func TestMigration124BackfillsLegacyOIDCSecurityFlagsSafely(t *testing.T) {
|
||||
require.Contains(t, sql, "oidc_connect_enabled")
|
||||
require.Contains(t, sql, "'false'")
|
||||
}
|
||||
|
||||
func TestMigration134AddsAffiliateLedgerAuditFieldsWithoutJSONCast(t *testing.T) {
|
||||
content, err := FS.ReadFile("134_affiliate_ledger_audit_snapshots.sql")
|
||||
require.NoError(t, err)
|
||||
|
||||
sql := string(content)
|
||||
require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS source_order_id BIGINT")
|
||||
require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS balance_after DECIMAL(20,8)")
|
||||
require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS aff_quota_after DECIMAL(20,8)")
|
||||
require.Contains(t, sql, "substring(")
|
||||
require.Contains(t, sql, `"rebateAmount"`)
|
||||
require.Contains(t, sql, "COUNT(*) OVER (PARTITION BY ra.order_id) AS order_match_count")
|
||||
require.Contains(t, sql, "COUNT(*) OVER (PARTITION BY ual.id) AS ledger_match_count")
|
||||
require.NotContains(t, sql, "detail::jsonb")
|
||||
}
|
||||
|
||||
@@ -69,10 +69,11 @@ export interface AffiliateTransferRecord {
|
||||
user_email: string
|
||||
username: string
|
||||
amount: number
|
||||
current_balance: number
|
||||
remaining_quota: number
|
||||
frozen_quota: number
|
||||
history_quota: number
|
||||
balance_after?: number | null
|
||||
available_quota_after?: number | null
|
||||
frozen_quota_after?: number | null
|
||||
history_quota_after?: number | null
|
||||
snapshot_available: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1664,10 +1664,10 @@ export default {
|
||||
paymentType: 'Payment Method',
|
||||
orderStatus: 'Order Status',
|
||||
transferAmount: 'Transfer Amount',
|
||||
currentBalance: 'Current Balance',
|
||||
remainingQuota: 'Remaining Quota',
|
||||
frozenQuota: 'Frozen Rebate',
|
||||
historyQuota: 'Historical Rebate',
|
||||
balanceAfter: 'Balance After',
|
||||
availableQuotaAfter: 'Available After',
|
||||
frozenQuotaAfter: 'Frozen After',
|
||||
historyQuotaAfter: 'Historical Rebate After',
|
||||
invitedAt: 'Invited At',
|
||||
rebatedAt: 'Rebated At',
|
||||
transferredAt: 'Transferred At'
|
||||
|
||||
@@ -1685,10 +1685,10 @@ export default {
|
||||
paymentType: '支付方式',
|
||||
orderStatus: '订单状态',
|
||||
transferAmount: '提取金额',
|
||||
currentBalance: '当前余额',
|
||||
remainingQuota: '剩余可提取',
|
||||
frozenQuota: '冻结返利',
|
||||
historyQuota: '历史返利',
|
||||
balanceAfter: '提取后余额',
|
||||
availableQuotaAfter: '提取后可提',
|
||||
frozenQuotaAfter: '提取后冻结',
|
||||
historyQuotaAfter: '提取后历史返利',
|
||||
invitedAt: '邀请时间',
|
||||
rebatedAt: '返利时间',
|
||||
transferredAt: '提取时间'
|
||||
|
||||
@@ -83,17 +83,17 @@
|
||||
<template #cell-amount="{ row }">
|
||||
<AmountText :value="row.amount" strong />
|
||||
</template>
|
||||
<template #cell-current_balance="{ row }">
|
||||
<AmountText :value="row.current_balance" />
|
||||
<template #cell-balance_after="{ row }">
|
||||
<NullableAmountText :value="row.balance_after" />
|
||||
</template>
|
||||
<template #cell-remaining_quota="{ row }">
|
||||
<AmountText :value="row.remaining_quota" />
|
||||
<template #cell-available_quota_after="{ row }">
|
||||
<NullableAmountText :value="row.available_quota_after" />
|
||||
</template>
|
||||
<template #cell-frozen_quota="{ row }">
|
||||
<AmountText :value="row.frozen_quota" />
|
||||
<template #cell-frozen_quota_after="{ row }">
|
||||
<NullableAmountText :value="row.frozen_quota_after" />
|
||||
</template>
|
||||
<template #cell-history_quota="{ row }">
|
||||
<AmountText :value="row.history_quota" />
|
||||
<template #cell-history_quota_after="{ row }">
|
||||
<NullableAmountText :value="row.history_quota_after" />
|
||||
</template>
|
||||
<template #cell-created_at="{ row }">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(row.created_at) }}</span>
|
||||
@@ -142,7 +142,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineComponent, h, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, defineComponent, h, onMounted, reactive, ref, type PropType } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
@@ -202,10 +202,10 @@ const columns = computed<Column[]>(() => {
|
||||
return [
|
||||
{ key: 'user', label: t('admin.affiliates.records.user'), sortable: true },
|
||||
{ key: 'amount', label: t('admin.affiliates.records.transferAmount'), sortable: true },
|
||||
{ key: 'current_balance', label: t('admin.affiliates.records.currentBalance'), sortable: true },
|
||||
{ key: 'remaining_quota', label: t('admin.affiliates.records.remainingQuota'), sortable: true },
|
||||
{ key: 'frozen_quota', label: t('admin.affiliates.records.frozenQuota'), sortable: true },
|
||||
{ key: 'history_quota', label: t('admin.affiliates.records.historyQuota'), 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: 'frozen_quota_after', label: t('admin.affiliates.records.frozenQuotaAfter'), sortable: true },
|
||||
{ key: 'history_quota_after', label: t('admin.affiliates.records.historyQuotaAfter'), sortable: true },
|
||||
{ key: 'created_at', label: t('admin.affiliates.records.transferredAt'), sortable: true },
|
||||
]
|
||||
})
|
||||
@@ -368,6 +368,21 @@ const AmountText = defineComponent({
|
||||
},
|
||||
})
|
||||
|
||||
const NullableAmountText = defineComponent({
|
||||
props: {
|
||||
value: { type: Number as PropType<number | null | undefined>, default: null },
|
||||
},
|
||||
setup(amountProps) {
|
||||
return () => {
|
||||
const value = amountProps.value
|
||||
if (value === null || value === undefined) {
|
||||
return h('span', { class: 'text-sm text-gray-400 dark:text-dark-500' }, '-')
|
||||
}
|
||||
return h(AmountText, { value })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const OverviewStat = defineComponent({
|
||||
props: {
|
||||
label: { type: String, required: true },
|
||||
|
||||
Reference in New Issue
Block a user