diff --git a/backend/internal/repository/affiliate_repo.go b/backend/internal/repository/affiliate_repo.go
index 793e1032..61da539e 100644
--- a/backend/internal/repository/affiliate_repo.go
+++ b/backend/internal/repository/affiliate_repo.go
@@ -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 列出有专属配置(自定义码或专属比例)的用户。
//
// 单一查询同时处理"无搜索"与"按邮箱/用户名模糊搜索":
diff --git a/backend/internal/repository/affiliate_repo_integration_test.go b/backend/internal/repository/affiliate_repo_integration_test.go
index 697a193b..b01ed528 100644
--- a/backend/internal/repository/affiliate_repo_integration_test.go
+++ b/backend/internal/repository/affiliate_repo_integration_test.go
@@ -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")
diff --git a/backend/internal/repository/affiliate_repo_test.go b/backend/internal/repository/affiliate_repo_test.go
index 03999fa9..ccb7bb3d 100644
--- a/backend/internal/repository/affiliate_repo_test.go
+++ b/backend/internal/repository/affiliate_repo_test.go
@@ -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"`)
+}
diff --git a/backend/internal/service/affiliate_service.go b/backend/internal/service/affiliate_service.go
index d8a59135..91cca5e2 100644
--- a/backend/internal/service/affiliate_service.go
+++ b/backend/internal/service/affiliate_service.go
@@ -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
}
diff --git a/backend/internal/service/payment_fulfillment.go b/backend/internal/service/payment_fulfillment.go
index 5df69aea..4ae6d134 100644
--- a/backend/internal/service/payment_fulfillment.go
+++ b/backend/internal/service/payment_fulfillment.go
@@ -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(),
diff --git a/backend/migrations/134_affiliate_ledger_audit_snapshots.sql b/backend/migrations/134_affiliate_ledger_audit_snapshots.sql
new file mode 100644
index 00000000..8a87ed1f
--- /dev/null
+++ b/backend/migrations/134_affiliate_ledger_audit_snapshots.sql
@@ -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'
+ );
diff --git a/backend/migrations/auth_identity_payment_migrations_regression_test.go b/backend/migrations/auth_identity_payment_migrations_regression_test.go
index 798ae0fe..99216296 100644
--- a/backend/migrations/auth_identity_payment_migrations_regression_test.go
+++ b/backend/migrations/auth_identity_payment_migrations_regression_test.go
@@ -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")
+}
diff --git a/frontend/src/api/admin/affiliates.ts b/frontend/src/api/admin/affiliates.ts
index 37b03f00..dadb0ae9 100644
--- a/frontend/src/api/admin/affiliates.ts
+++ b/frontend/src/api/admin/affiliates.ts
@@ -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
}
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 4ddd22f5..195d0237 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -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'
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 933bccce..0f95d652 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -1685,10 +1685,10 @@ export default {
paymentType: '支付方式',
orderStatus: '订单状态',
transferAmount: '提取金额',
- currentBalance: '当前余额',
- remainingQuota: '剩余可提取',
- frozenQuota: '冻结返利',
- historyQuota: '历史返利',
+ balanceAfter: '提取后余额',
+ availableQuotaAfter: '提取后可提',
+ frozenQuotaAfter: '提取后冻结',
+ historyQuotaAfter: '提取后历史返利',
invitedAt: '邀请时间',
rebatedAt: '返利时间',
transferredAt: '提取时间'
diff --git a/frontend/src/views/admin/affiliates/AdminAffiliateRecordsTable.vue b/frontend/src/views/admin/affiliates/AdminAffiliateRecordsTable.vue
index 74416a4a..789df41a 100644
--- a/frontend/src/views/admin/affiliates/AdminAffiliateRecordsTable.vue
+++ b/frontend/src/views/admin/affiliates/AdminAffiliateRecordsTable.vue
@@ -83,17 +83,17 @@
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
{{ formatDateTime(row.created_at) }}
@@ -142,7 +142,7 @@