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 @@ -