diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go index 3d80107f..a297c56c 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -390,7 +390,7 @@ func (h *UserHandler) GetUserUsage(c *gin.Context) { // GetBalanceHistory handles getting user's balance/concurrency change history // GET /api/v1/admin/users/:id/balance-history // Query params: -// - type: filter by record type (balance, admin_balance, concurrency, admin_concurrency, subscription) +// - type: filter by record type (balance, affiliate_balance, admin_balance, concurrency, admin_concurrency, subscription) func (h *UserHandler) GetBalanceHistory(c *gin.Context) { userID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { diff --git a/backend/internal/service/admin_balance_history_test.go b/backend/internal/service/admin_balance_history_test.go new file mode 100644 index 00000000..291d3f7b --- /dev/null +++ b/backend/internal/service/admin_balance_history_test.go @@ -0,0 +1,86 @@ +package service + +import ( + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/stretchr/testify/require" +) + +func TestMergeBalanceHistoryCodesIncludesAffiliateTransfersByDefault(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC) + older := now.Add(-2 * time.Hour) + newer := now.Add(time.Hour) + + usedBy := int64(10) + redeemCodes := []RedeemCode{ + { + ID: 1, + Type: RedeemTypeBalance, + Value: 8, + Status: StatusUsed, + UsedBy: &usedBy, + UsedAt: &now, + CreatedAt: now, + }, + { + ID: 2, + Type: RedeemTypeConcurrency, + Value: 1, + Status: StatusUsed, + UsedBy: &usedBy, + UsedAt: &older, + CreatedAt: older, + }, + } + affiliateCodes := []RedeemCode{ + { + ID: -20, + Type: RedeemTypeAffiliateBalance, + Value: 3.5, + Status: StatusUsed, + UsedBy: &usedBy, + UsedAt: &newer, + CreatedAt: newer, + }, + } + + got := mergeBalanceHistoryCodes(redeemCodes, affiliateCodes, pagination.PaginationParams{ + Page: 1, + PageSize: 2, + }) + + require.Len(t, got, 2) + require.Equal(t, RedeemTypeAffiliateBalance, got[0].Type) + require.Equal(t, RedeemTypeBalance, got[1].Type) +} + +func TestMergeBalanceHistoryCodesPaginatesAfterCombiningSources(t *testing.T) { + t.Parallel() + + base := time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC) + usedBy := int64(10) + at := func(hours int) *time.Time { + v := base.Add(time.Duration(hours) * time.Hour) + return &v + } + + got := mergeBalanceHistoryCodes( + []RedeemCode{ + {ID: 1, Type: RedeemTypeBalance, UsedBy: &usedBy, UsedAt: at(4), CreatedAt: *at(4)}, + {ID: 2, Type: RedeemTypeConcurrency, UsedBy: &usedBy, UsedAt: at(2), CreatedAt: *at(2)}, + }, + []RedeemCode{ + {ID: -3, Type: RedeemTypeAffiliateBalance, UsedBy: &usedBy, UsedAt: at(3), CreatedAt: *at(3)}, + {ID: -4, Type: RedeemTypeAffiliateBalance, UsedBy: &usedBy, UsedAt: at(1), CreatedAt: *at(1)}, + }, + pagination.PaginationParams{Page: 2, PageSize: 2}, + ) + + require.Len(t, got, 2) + require.Equal(t, RedeemTypeConcurrency, got[0].Type) + require.Equal(t, int64(-4), got[1].ID) +} diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index d966c684..be4c23dc 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -2,6 +2,7 @@ package service import ( "context" + "database/sql" "encoding/json" "errors" "fmt" @@ -973,16 +974,213 @@ func (s *adminServiceImpl) GetUserUsageStats(ctx context.Context, userID int64, // GetUserBalanceHistory returns paginated balance/concurrency change records for a user. func (s *adminServiceImpl) GetUserBalanceHistory(ctx context.Context, userID int64, page, pageSize int, codeType string) ([]RedeemCode, int64, float64, error) { params := pagination.PaginationParams{Page: page, PageSize: pageSize} + if codeType == RedeemTypeAffiliateBalance { + codes, total, err := s.listAffiliateBalanceHistory(ctx, userID, params) + if err != nil { + return nil, 0, 0, err + } + totalRecharged, err := s.redeemCodeRepo.SumPositiveBalanceByUser(ctx, userID) + if err != nil { + return nil, 0, 0, err + } + return codes, total, totalRecharged, nil + } + + if codeType == "" { + return s.getAllUserBalanceHistory(ctx, userID, params) + } + codes, result, err := s.redeemCodeRepo.ListByUserPaginated(ctx, userID, params, codeType) if err != nil { return nil, 0, 0, err } + total := result.Total // Aggregate total recharged amount (only once, regardless of type filter) totalRecharged, err := s.redeemCodeRepo.SumPositiveBalanceByUser(ctx, userID) if err != nil { return nil, 0, 0, err } - return codes, result.Total, totalRecharged, nil + return codes, total, totalRecharged, nil +} + +func (s *adminServiceImpl) getAllUserBalanceHistory(ctx context.Context, userID int64, params pagination.PaginationParams) ([]RedeemCode, int64, float64, error) { + needed := params.Offset() + params.Limit() + if needed < params.Limit() { + needed = params.Limit() + } + + redeemCodes, redeemTotal, err := s.listRedeemBalanceHistoryForMerge(ctx, userID, needed) + if err != nil { + return nil, 0, 0, err + } + affiliateCodes, affiliateTotal, err := s.listAffiliateBalanceHistoryForMerge(ctx, userID, needed) + if err != nil { + return nil, 0, 0, err + } + codes := mergeBalanceHistoryCodes(redeemCodes, affiliateCodes, params) + + totalRecharged, err := s.redeemCodeRepo.SumPositiveBalanceByUser(ctx, userID) + if err != nil { + return nil, 0, 0, err + } + return codes, redeemTotal + affiliateTotal, totalRecharged, nil +} + +func (s *adminServiceImpl) listRedeemBalanceHistoryForMerge(ctx context.Context, userID int64, needed int) ([]RedeemCode, int64, error) { + if needed <= 0 { + return nil, 0, nil + } + + var ( + out []RedeemCode + total int64 + ) + for page := 1; len(out) < needed; page++ { + params := pagination.PaginationParams{Page: page, PageSize: 1000} + codes, result, err := s.redeemCodeRepo.ListByUserPaginated(ctx, userID, params, "") + if err != nil { + return nil, 0, err + } + if result != nil { + total = result.Total + } + out = append(out, codes...) + if len(codes) < params.Limit() || int64(len(out)) >= total { + break + } + } + if len(out) > needed { + out = out[:needed] + } + return out, total, nil +} + +func (s *adminServiceImpl) listAffiliateBalanceHistoryForMerge(ctx context.Context, userID int64, needed int) ([]RedeemCode, int64, error) { + if needed <= 0 { + return nil, 0, nil + } + + var ( + out []RedeemCode + total int64 + ) + for page := 1; len(out) < needed; page++ { + params := pagination.PaginationParams{Page: page, PageSize: 1000} + codes, currentTotal, err := s.listAffiliateBalanceHistory(ctx, userID, params) + if err != nil { + return nil, 0, err + } + total = currentTotal + out = append(out, codes...) + if len(codes) < params.Limit() || int64(len(out)) >= total { + break + } + } + if len(out) > needed { + out = out[:needed] + } + return out, total, nil +} + +func (s *adminServiceImpl) listAffiliateBalanceHistory(ctx context.Context, userID int64, params pagination.PaginationParams) ([]RedeemCode, int64, error) { + if s == nil || s.entClient == nil || userID <= 0 { + return nil, 0, nil + } + + rows, err := s.entClient.QueryContext(ctx, ` +SELECT id, + amount::double precision, + created_at +FROM user_affiliate_ledger +WHERE user_id = $1 + AND action = 'transfer' +ORDER BY created_at DESC, id DESC +OFFSET $2 +LIMIT $3`, userID, params.Offset(), params.Limit()) + if err != nil { + return nil, 0, err + } + defer func() { _ = rows.Close() }() + + codes := make([]RedeemCode, 0, params.Limit()) + for rows.Next() { + var id int64 + var amount float64 + var createdAt time.Time + if err := rows.Scan(&id, &amount, &createdAt); err != nil { + return nil, 0, err + } + usedBy := userID + usedAt := createdAt + codes = append(codes, RedeemCode{ + ID: -id, + Code: fmt.Sprintf("AFF-%d", id), + Type: RedeemTypeAffiliateBalance, + Value: amount, + Status: StatusUsed, + UsedBy: &usedBy, + UsedAt: &usedAt, + CreatedAt: createdAt, + }) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + + total, err := countAffiliateBalanceHistory(ctx, s.entClient, userID) + if err != nil { + return nil, 0, err + } + return codes, total, nil +} + +func countAffiliateBalanceHistory(ctx context.Context, client *dbent.Client, userID int64) (int64, error) { + rows, err := client.QueryContext(ctx, ` +SELECT COUNT(*) +FROM user_affiliate_ledger +WHERE user_id = $1 + AND action = 'transfer'`, userID) + if err != nil { + return 0, err + } + defer func() { _ = rows.Close() }() + + var total sql.NullInt64 + if rows.Next() { + if err := rows.Scan(&total); err != nil { + return 0, err + } + } + if err := rows.Err(); err != nil { + return 0, err + } + if !total.Valid { + return 0, nil + } + return total.Int64, nil +} + +func mergeBalanceHistoryCodes(redeemCodes, affiliateCodes []RedeemCode, params pagination.PaginationParams) []RedeemCode { + combined := append(append([]RedeemCode{}, redeemCodes...), affiliateCodes...) + sort.SliceStable(combined, func(i, j int) bool { + return redeemCodeHistoryTime(combined[i]).After(redeemCodeHistoryTime(combined[j])) + }) + offset := params.Offset() + if offset >= len(combined) { + return []RedeemCode{} + } + end := offset + params.Limit() + if end > len(combined) { + end = len(combined) + } + return combined[offset:end] +} + +func redeemCodeHistoryTime(code RedeemCode) time.Time { + if code.UsedAt != nil { + return *code.UsedAt + } + return code.CreatedAt } func (s *adminServiceImpl) BindUserAuthIdentity(ctx context.Context, userID int64, input AdminBindAuthIdentityInput) (*AdminBoundAuthIdentity, error) { diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index bb32540b..632ebf5f 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -51,10 +51,11 @@ const ( // Redeem type constants const ( - RedeemTypeBalance = domain.RedeemTypeBalance - RedeemTypeConcurrency = domain.RedeemTypeConcurrency - RedeemTypeSubscription = domain.RedeemTypeSubscription - RedeemTypeInvitation = domain.RedeemTypeInvitation + RedeemTypeBalance = domain.RedeemTypeBalance + RedeemTypeConcurrency = domain.RedeemTypeConcurrency + RedeemTypeSubscription = domain.RedeemTypeSubscription + RedeemTypeInvitation = domain.RedeemTypeInvitation + RedeemTypeAffiliateBalance = "affiliate_balance" ) // PromoCode status constants diff --git a/frontend/src/api/admin/users.ts b/frontend/src/api/admin/users.ts index 3c75a6c4..fabc69bc 100644 --- a/frontend/src/api/admin/users.ts +++ b/frontend/src/api/admin/users.ts @@ -249,7 +249,7 @@ export interface BalanceHistoryResponse extends PaginatedResponse Math.ceil(total.value / pageSize) || 1) const typeOptions = computed(() => [ { value: '', label: t('admin.users.allTypes') }, { value: 'balance', label: t('admin.users.typeBalance') }, + { value: 'affiliate_balance', label: t('admin.users.typeAffiliateBalance') }, { value: 'admin_balance', label: t('admin.users.typeAdminBalance') }, { value: 'concurrency', label: t('admin.users.typeConcurrency') }, { value: 'admin_concurrency', label: t('admin.users.typeAdminConcurrency') }, @@ -235,7 +236,7 @@ const loadHistory = async (page: number) => { const isAdminType = (type: string) => type === 'admin_balance' || type === 'admin_concurrency' // Helper: check if balance type (includes admin_balance) -const isBalanceType = (type: string) => type === 'balance' || type === 'admin_balance' +const isBalanceType = (type: string) => type === 'balance' || type === 'admin_balance' || type === 'affiliate_balance' // Helper: check if subscription type const isSubscriptionType = (type: string) => type === 'subscription' @@ -291,6 +292,8 @@ const getItemTitle = (item: BalanceHistoryItem) => { switch (item.type) { case 'balance': return t('redeem.balanceAddedRedeem') + case 'affiliate_balance': + return t('redeem.balanceAddedAffiliate') case 'admin_balance': return item.value >= 0 ? t('redeem.balanceAddedAdmin') : t('redeem.balanceDeductedAdmin') case 'concurrency': diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 50b19d2a..4ddd22f5 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1050,6 +1050,7 @@ export default { recentActivity: 'Recent Activity', historyWillAppear: 'Your redemption history will appear here', balanceAddedRedeem: 'Balance Added (Redeem)', + balanceAddedAffiliate: 'Balance Added (Affiliate Transfer)', balanceAddedAdmin: 'Balance Added (Admin)', balanceDeductedAdmin: 'Balance Deducted (Admin)', concurrencyAddedRedeem: 'Concurrency Added (Redeem)', @@ -1834,6 +1835,7 @@ export default { noBalanceHistory: 'No records found for this user', allTypes: 'All Types', typeBalance: 'Balance (Redeem)', + typeAffiliateBalance: 'Balance (Affiliate Transfer)', typeAdminBalance: 'Balance (Admin)', typeConcurrency: 'Concurrency (Redeem)', typeAdminConcurrency: 'Concurrency (Admin)', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index ac5735f5..933bccce 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1054,6 +1054,7 @@ export default { recentActivity: '最近活动', historyWillAppear: '您的兑换历史将显示在这里', balanceAddedRedeem: '余额充值(兑换)', + balanceAddedAffiliate: '余额充值(返利转入)', balanceAddedAdmin: '余额充值(管理员)', balanceDeductedAdmin: '余额扣除(管理员)', concurrencyAddedRedeem: '并发增加(兑换)', @@ -1891,6 +1892,7 @@ export default { noBalanceHistory: '暂无变动记录', allTypes: '全部类型', typeBalance: '余额(兑换码)', + typeAffiliateBalance: '余额(返利转入)', typeAdminBalance: '余额(管理员调整)', typeConcurrency: '并发(兑换码)', typeAdminConcurrency: '并发(管理员调整)',