fix: correct affiliate audit record sources

This commit is contained in:
shaw
2026-05-03 22:12:57 +08:00
parent 76e2503d5e
commit 0b84d12dbb
11 changed files with 334 additions and 98 deletions

View File

@@ -4,7 +4,6 @@ import (
"context" "context"
"crypto/rand" "crypto/rand"
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@@ -115,7 +114,7 @@ func (r *affiliateRepository) BindInviter(ctx context.Context, userID, inviterID
return bound, nil 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 { if amount <= 0 {
return false, nil return false, nil
} }
@@ -141,15 +140,15 @@ func (r *affiliateRepository) AccrueQuota(ctx context.Context, inviterID, invite
if freezeHours > 0 { if freezeHours > 0 {
if _, err = txClient.ExecContext(txCtx, ` if _, err = txClient.ExecContext(txCtx, `
INSERT INTO user_affiliate_ledger (user_id, action, amount, source_user_id, frozen_until, created_at, updated_at) 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, NOW() + make_interval(hours => $4), NOW(), NOW())`, VALUES ($1, 'accrue', $2, $3, $4, NOW() + make_interval(hours => $5), NOW(), NOW())`,
inviterID, amount, inviteeUserID, freezeHours); err != nil { inviterID, amount, inviteeUserID, nullableInt64Arg(sourceOrderID), freezeHours); err != nil {
return fmt.Errorf("insert affiliate accrue ledger: %w", err) return fmt.Errorf("insert affiliate accrue ledger: %w", err)
} }
} else { } else {
if _, err = txClient.ExecContext(txCtx, ` if _, err = txClient.ExecContext(txCtx, `
INSERT INTO user_affiliate_ledger (user_id, action, amount, source_user_id, created_at, updated_at) INSERT INTO user_affiliate_ledger (user_id, action, amount, source_user_id, source_order_id, created_at, updated_at)
VALUES ($1, 'accrue', $2, $3, NOW(), NOW())`, inviterID, amount, inviteeUserID); err != nil { VALUES ($1, 'accrue', $2, $3, $4, NOW(), NOW())`, inviterID, amount, inviteeUserID, nullableInt64Arg(sourceOrderID)); err != nil {
return fmt.Errorf("insert affiliate accrue ledger: %w", err) return fmt.Errorf("insert affiliate accrue ledger: %w", err)
} }
} }
@@ -304,9 +303,32 @@ FROM cleared`, userID)
return err return err
} }
snapshot, err := queryAffiliateTransferSnapshot(txCtx, txClient, userID)
if err != nil {
return err
}
if _, err = txClient.ExecContext(txCtx, ` if _, err = txClient.ExecContext(txCtx, `
INSERT INTO user_affiliate_ledger (user_id, action, amount, source_user_id, created_at, updated_at) INSERT INTO user_affiliate_ledger (
VALUES ($1, 'transfer', $2, NULL, NOW(), NOW())`, userID, transferred); err != nil { 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) 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) { func (r *affiliateRepository) ListAffiliateRebateRecords(ctx context.Context, filter service.AffiliateRecordFilter) ([]service.AffiliateRebateRecord, int64, error) {
client := clientFromContext(ctx, r.client) 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", "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",
}) })
baseJoin := ` baseJoin := `
FROM payment_audit_logs pal FROM user_affiliate_ledger ual
JOIN payment_orders po ON po.id::text = pal.order_id JOIN payment_orders po ON po.id = ual.source_order_id
JOIN user_affiliates invitee_aff ON invitee_aff.user_id = po.user_id JOIN users invitee ON invitee.id = ual.source_user_id
JOIN users invitee ON invitee.id = po.user_id JOIN users inviter ON inviter.id = ual.user_id
JOIN users inviter ON inviter.id = invitee_aff.inviter_id WHERE ual.action = 'accrue'
WHERE pal.action = 'AFFILIATE_REBATE_APPLIED'` AND ual.source_order_id IS NOT NULL`
if where != "" { if where != "" {
where = strings.Replace(where, "WHERE ", " AND ", 1) where = strings.Replace(where, "WHERE ", " AND ", 1)
} }
@@ -461,31 +483,32 @@ WHERE pal.action = 'AFFILIATE_REBATE_APPLIED'`
} }
orderBy := buildAffiliateRecordOrderBy(filter, map[string]string{ orderBy := buildAffiliateRecordOrderBy(filter, map[string]string{
"order": "po.id", "order": "po.id",
"inviter": "inviter.email", "inviter": "inviter.email",
"invitee": "invitee.email", "invitee": "invitee.email",
"order_amount": "po.amount", "order_amount": "po.amount",
"pay_amount": "po.pay_amount", "pay_amount": "po.pay_amount",
"payment_type": "po.payment_type", "rebate_amount": "ual.amount",
"order_status": "po.status", "payment_type": "po.payment_type",
"created_at": "pal.created_at", "order_status": "po.status",
}, "pal.created_at") "created_at": "ual.created_at",
}, "ual.created_at")
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, po.out_trade_no,
invitee_aff.inviter_id, ual.user_id,
COALESCE(inviter.email, ''), COALESCE(inviter.email, ''),
COALESCE(inviter.username, ''), COALESCE(inviter.username, ''),
po.user_id, ual.source_user_id,
COALESCE(invitee.email, ''), COALESCE(invitee.email, ''),
COALESCE(invitee.username, ''), COALESCE(invitee.username, ''),
po.amount::double precision, po.amount::double precision,
po.pay_amount::double precision, po.pay_amount::double precision,
COALESCE(pal.detail, ''), ual.amount::double precision,
po.payment_type, po.payment_type,
po.status, po.status,
pal.created_at ual.created_at
`+baseJoin+where+` `+baseJoin+where+`
`+orderBy+` `+orderBy+`
LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...) 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) items := make([]service.AffiliateRebateRecord, 0)
for rows.Next() { for rows.Next() {
var item service.AffiliateRebateRecord var item service.AffiliateRebateRecord
var detail string
if err := rows.Scan( if err := rows.Scan(
&item.OrderID, &item.OrderID,
&item.OutTradeNo, &item.OutTradeNo,
@@ -509,14 +531,13 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
&item.InviteeUsername, &item.InviteeUsername,
&item.OrderAmount, &item.OrderAmount,
&item.PayAmount, &item.PayAmount,
&detail, &item.RebateAmount,
&item.PaymentType, &item.PaymentType,
&item.OrderStatus, &item.OrderStatus,
&item.CreatedAt, &item.CreatedAt,
); err != nil { ); err != nil {
return nil, 0, err return nil, 0, err
} }
item.RebateAmount = parseAffiliateRebateAmount(detail)
items = append(items, item) items = append(items, item)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
@@ -533,7 +554,6 @@ 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
JOIN user_affiliates ua ON ua.user_id = ual.user_id
WHERE ual.action = 'transfer'` WHERE ual.action = 'transfer'`
if where != "" { if where != "" {
where = strings.Replace(where, "WHERE ", " AND ", 1) where = strings.Replace(where, "WHERE ", " AND ", 1)
@@ -545,13 +565,13 @@ WHERE ual.action = 'transfer'`
} }
orderBy := buildAffiliateRecordOrderBy(filter, map[string]string{ orderBy := buildAffiliateRecordOrderBy(filter, map[string]string{
"user": "u.email", "user": "u.email",
"amount": "ual.amount", "amount": "ual.amount",
"current_balance": "u.balance", "balance_after": "ual.balance_after",
"remaining_quota": "ua.aff_quota", "available_quota_after": "ual.aff_quota_after",
"frozen_quota": "ua.aff_frozen_quota", "frozen_quota_after": "ual.aff_frozen_quota_after",
"history_quota": "ua.aff_history_quota", "history_quota_after": "ual.aff_history_quota_after",
"created_at": "ual.created_at", "created_at": "ual.created_at",
}, "ual.created_at") }, "ual.created_at")
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, `
@@ -560,10 +580,10 @@ SELECT ual.id,
COALESCE(u.email, ''), COALESCE(u.email, ''),
COALESCE(u.username, ''), COALESCE(u.username, ''),
ual.amount::double precision, ual.amount::double precision,
u.balance::double precision, ual.balance_after::double precision,
ua.aff_quota::double precision, ual.aff_quota_after::double precision,
ua.aff_frozen_quota::double precision, ual.aff_frozen_quota_after::double precision,
ua.aff_history_quota::double precision, ual.aff_history_quota_after::double precision,
ual.created_at ual.created_at
`+baseJoin+where+` `+baseJoin+where+`
`+orderBy+` `+orderBy+`
@@ -576,20 +596,32 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
items := make([]service.AffiliateTransferRecord, 0) items := make([]service.AffiliateTransferRecord, 0)
for rows.Next() { for rows.Next() {
var item service.AffiliateTransferRecord var item service.AffiliateTransferRecord
var balanceAfter sql.NullFloat64
var availableQuotaAfter sql.NullFloat64
var frozenQuotaAfter sql.NullFloat64
var historyQuotaAfter sql.NullFloat64
if err := rows.Scan( if err := rows.Scan(
&item.LedgerID, &item.LedgerID,
&item.UserID, &item.UserID,
&item.UserEmail, &item.UserEmail,
&item.Username, &item.Username,
&item.Amount, &item.Amount,
&item.CurrentBalance, &balanceAfter,
&item.RemainingQuota, &availableQuotaAfter,
&item.FrozenQuota, &frozenQuotaAfter,
&item.HistoryQuota, &historyQuotaAfter,
&item.CreatedAt, &item.CreatedAt,
); err != nil { ); err != nil {
return nil, 0, err 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) items = append(items, item)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
@@ -675,7 +707,7 @@ func buildAffiliateRecordOrderBy(filter service.AffiliateRecordFilter, sortColum
if !filter.SortDesc { if !filter.SortDesc {
direction = "ASC" 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) { 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() 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 { func (r *affiliateRepository) withTx(ctx context.Context, fn func(txCtx context.Context, txClient *dbent.Client) error) error {
if tx := dbent.TxFromContext(ctx); tx != nil { if tx := dbent.TxFromContext(ctx); tx != nil {
return fn(ctx, tx.Client()) return fn(ctx, tx.Client())
@@ -888,6 +910,54 @@ func queryUserBalance(ctx context.Context, client affiliateQueryExecer, userID i
return balance, nil 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) { 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 {
@@ -1046,6 +1116,13 @@ func nullableArg(v *float64) any {
return *v return *v
} }
func nullableInt64Arg(v *int64) any {
if v == nil {
return nil
}
return *v
}
// ListUsersWithCustomSettings 列出有专属配置(自定义码或专属比例)的用户。 // ListUsersWithCustomSettings 列出有专属配置(自定义码或专属比例)的用户。
// //
// 单一查询同时处理"无搜索"与"按邮箱/用户名模糊搜索" // 单一查询同时处理"无搜索"与"按邮箱/用户名模糊搜索"

View File

@@ -78,6 +78,26 @@ VALUES ($1, $2, $3, $3, NOW(), NOW())`, u.ID, affCode, 12.34)
ledgerCount := querySingleInt(t, txCtx, client, ledgerCount := querySingleInt(t, txCtx, client,
"SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'transfer'", u.ID) "SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'transfer'", u.ID)
require.Equal(t, 1, ledgerCount) 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, &quotaAfter, &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 // TestAffiliateRepository_AccrueQuota_ReusesOuterTransaction guards the
@@ -125,7 +145,7 @@ func TestAffiliateRepository_AccrueQuota_ReusesOuterTransaction(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.True(t, bound, "invitee must bind to inviter") 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.NoError(t, err)
require.True(t, applied, "AccrueQuota must report applied=true") require.True(t, applied, "AccrueQuota must report applied=true")

View File

@@ -1,6 +1,7 @@
package repository package repository
import ( import (
"os"
"strings" "strings"
"testing" "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, "ua.aff_quota + COALESCE(matured.matured_frozen_quota, 0)")
require.Contains(t, query, "frozen_until <= NOW()") 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"`)
}

View File

@@ -98,7 +98,7 @@ type AffiliateRepository interface {
EnsureUserAffiliate(ctx context.Context, userID int64) (*AffiliateSummary, error) EnsureUserAffiliate(ctx context.Context, userID int64) (*AffiliateSummary, error)
GetAffiliateByCode(ctx context.Context, code string) (*AffiliateSummary, error) GetAffiliateByCode(ctx context.Context, code string) (*AffiliateSummary, error)
BindInviter(ctx context.Context, userID, inviterID int64) (bool, 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) 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)
@@ -174,16 +174,21 @@ type AffiliateRebateRecord struct {
} }
type AffiliateTransferRecord struct { type AffiliateTransferRecord struct {
LedgerID int64 `json:"ledger_id"` LedgerID int64 `json:"ledger_id"`
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"`
Amount float64 `json:"amount"` Amount float64 `json:"amount"`
CurrentBalance float64 `json:"current_balance"` BalanceAfter *float64 `json:"balance_after,omitempty"`
RemainingQuota float64 `json:"remaining_quota"` AvailableQuotaAfter *float64 `json:"available_quota_after,omitempty"`
FrozenQuota float64 `json:"frozen_quota"` FrozenQuotaAfter *float64 `json:"frozen_quota_after,omitempty"`
HistoryQuota float64 `json:"history_quota"` HistoryQuotaAfter *float64 `json:"history_quota_after,omitempty"`
CreatedAt time.Time `json:"created_at"` 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 { 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) { 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 { if s == nil || s.repo == nil {
return 0, nil return 0, nil
} }
@@ -367,7 +376,7 @@ func (s *AffiliateService) AccrueInviteRebate(ctx context.Context, inviteeUserID
freezeHours = s.settingService.GetAffiliateRebateFreezeHours(ctx) 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 { if err != nil {
return 0, err return 0, err
} }

View File

@@ -394,7 +394,8 @@ func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *db
return nil 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 { if err != nil {
s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{ s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
"error": err.Error(), "error": err.Error(),

View 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'
);

View File

@@ -127,3 +127,18 @@ func TestMigration124BackfillsLegacyOIDCSecurityFlagsSafely(t *testing.T) {
require.Contains(t, sql, "oidc_connect_enabled") require.Contains(t, sql, "oidc_connect_enabled")
require.Contains(t, sql, "'false'") 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")
}

View File

@@ -69,10 +69,11 @@ export interface AffiliateTransferRecord {
user_email: string user_email: string
username: string username: string
amount: number amount: number
current_balance: number balance_after?: number | null
remaining_quota: number available_quota_after?: number | null
frozen_quota: number frozen_quota_after?: number | null
history_quota: number history_quota_after?: number | null
snapshot_available: boolean
created_at: string created_at: string
} }

View File

@@ -1664,10 +1664,10 @@ export default {
paymentType: 'Payment Method', paymentType: 'Payment Method',
orderStatus: 'Order Status', orderStatus: 'Order Status',
transferAmount: 'Transfer Amount', transferAmount: 'Transfer Amount',
currentBalance: 'Current Balance', balanceAfter: 'Balance After',
remainingQuota: 'Remaining Quota', availableQuotaAfter: 'Available After',
frozenQuota: 'Frozen Rebate', frozenQuotaAfter: 'Frozen After',
historyQuota: 'Historical Rebate', historyQuotaAfter: 'Historical Rebate After',
invitedAt: 'Invited At', invitedAt: 'Invited At',
rebatedAt: 'Rebated At', rebatedAt: 'Rebated At',
transferredAt: 'Transferred At' transferredAt: 'Transferred At'

View File

@@ -1685,10 +1685,10 @@ export default {
paymentType: '支付方式', paymentType: '支付方式',
orderStatus: '订单状态', orderStatus: '订单状态',
transferAmount: '提取金额', transferAmount: '提取金额',
currentBalance: '当前余额', balanceAfter: '提取后余额',
remainingQuota: '剩余可提取', availableQuotaAfter: '提取后可提',
frozenQuota: '冻结返利', frozenQuotaAfter: '提取后冻结',
historyQuota: '历史返利', historyQuotaAfter: '提取后历史返利',
invitedAt: '邀请时间', invitedAt: '邀请时间',
rebatedAt: '返利时间', rebatedAt: '返利时间',
transferredAt: '提取时间' transferredAt: '提取时间'

View File

@@ -83,17 +83,17 @@
<template #cell-amount="{ row }"> <template #cell-amount="{ row }">
<AmountText :value="row.amount" strong /> <AmountText :value="row.amount" strong />
</template> </template>
<template #cell-current_balance="{ row }"> <template #cell-balance_after="{ row }">
<AmountText :value="row.current_balance" /> <NullableAmountText :value="row.balance_after" />
</template> </template>
<template #cell-remaining_quota="{ row }"> <template #cell-available_quota_after="{ row }">
<AmountText :value="row.remaining_quota" /> <NullableAmountText :value="row.available_quota_after" />
</template> </template>
<template #cell-frozen_quota="{ row }"> <template #cell-frozen_quota_after="{ row }">
<AmountText :value="row.frozen_quota" /> <NullableAmountText :value="row.frozen_quota_after" />
</template> </template>
<template #cell-history_quota="{ row }"> <template #cell-history_quota_after="{ row }">
<AmountText :value="row.history_quota" /> <NullableAmountText :value="row.history_quota_after" />
</template> </template>
<template #cell-created_at="{ row }"> <template #cell-created_at="{ row }">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(row.created_at) }}</span> <span class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(row.created_at) }}</span>
@@ -142,7 +142,7 @@
</template> </template>
<script setup lang="ts"> <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 { useI18n } from 'vue-i18n'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue' import TablePageLayout from '@/components/layout/TablePageLayout.vue'
@@ -202,10 +202,10 @@ 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: 'amount', label: t('admin.affiliates.records.transferAmount'), sortable: true }, { key: 'amount', label: t('admin.affiliates.records.transferAmount'), sortable: true },
{ key: 'current_balance', label: t('admin.affiliates.records.currentBalance'), sortable: true }, { key: 'balance_after', label: t('admin.affiliates.records.balanceAfter'), sortable: true },
{ key: 'remaining_quota', label: t('admin.affiliates.records.remainingQuota'), sortable: true }, { key: 'available_quota_after', label: t('admin.affiliates.records.availableQuotaAfter'), sortable: true },
{ key: 'frozen_quota', label: t('admin.affiliates.records.frozenQuota'), sortable: true }, { key: 'frozen_quota_after', label: t('admin.affiliates.records.frozenQuotaAfter'), sortable: true },
{ key: 'history_quota', label: t('admin.affiliates.records.historyQuota'), 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 }, { 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({ const OverviewStat = defineComponent({
props: { props: {
label: { type: String, required: true }, label: { type: String, required: true },