Merge pull request #2169 from lyen1688/feat/admin-affiliate-records

feat: 新增管理后台邀请返利记录页面
This commit is contained in:
Wesley Liddick
2026-05-03 21:10:33 +08:00
committed by GitHub
20 changed files with 1606 additions and 8 deletions

View File

@@ -2,8 +2,11 @@ package admin
import (
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
@@ -181,3 +184,108 @@ func (h *AffiliateHandler) LookupUsers(c *gin.Context) {
}
response.Success(c, result)
}
// GetUserOverview returns one user's affiliate overview.
// GET /api/v1/admin/affiliates/users/:user_id/overview
func (h *AffiliateHandler) GetUserOverview(c *gin.Context) {
userID, err := strconv.ParseInt(c.Param("user_id"), 10, 64)
if err != nil || userID <= 0 {
response.BadRequest(c, "Invalid user_id")
return
}
overview, err := h.affiliateService.AdminGetUserOverview(c.Request.Context(), userID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, overview)
}
// ListInviteRecords returns all inviter-invitee relationships.
// GET /api/v1/admin/affiliates/invites
func (h *AffiliateHandler) ListInviteRecords(c *gin.Context) {
page, pageSize := response.ParsePagination(c)
filter := parseAffiliateRecordFilter(c, page, pageSize)
items, total, err := h.affiliateService.AdminListInviteRecords(c.Request.Context(), filter)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Paginated(c, items, total, filter.Page, filter.PageSize)
}
// ListRebateRecords returns all order-level affiliate rebate records.
// GET /api/v1/admin/affiliates/rebates
func (h *AffiliateHandler) ListRebateRecords(c *gin.Context) {
page, pageSize := response.ParsePagination(c)
filter := parseAffiliateRecordFilter(c, page, pageSize)
items, total, err := h.affiliateService.AdminListRebateRecords(c.Request.Context(), filter)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Paginated(c, items, total, filter.Page, filter.PageSize)
}
// ListTransferRecords returns all affiliate quota-to-balance transfer records.
// GET /api/v1/admin/affiliates/transfers
func (h *AffiliateHandler) ListTransferRecords(c *gin.Context) {
page, pageSize := response.ParsePagination(c)
filter := parseAffiliateRecordFilter(c, page, pageSize)
items, total, err := h.affiliateService.AdminListTransferRecords(c.Request.Context(), filter)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Paginated(c, items, total, filter.Page, filter.PageSize)
}
func parseAffiliateRecordFilter(c *gin.Context, page, pageSize int) service.AffiliateRecordFilter {
filter := service.AffiliateRecordFilter{
Search: c.Query("search"),
Page: page,
PageSize: pageSize,
SortBy: c.Query("sort_by"),
SortDesc: c.Query("sort_order") != "asc",
}
if filter.PageSize > 100 {
filter.PageSize = 100
}
userTZ := c.Query("timezone")
if t := parseAffiliateRecordStartTime(c.Query("start_at"), userTZ); t != nil {
filter.StartAt = t
}
if t := parseAffiliateRecordEndTime(c.Query("end_at"), userTZ); t != nil {
filter.EndAt = t
}
return filter
}
func parseAffiliateRecordStartTime(raw string, userTZ string) *time.Time {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
return &parsed
}
if parsed, err := timezone.ParseInUserLocation("2006-01-02", raw, userTZ); err == nil {
return &parsed
}
return nil
}
func parseAffiliateRecordEndTime(raw string, userTZ string) *time.Time {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
return &parsed
}
if parsed, err := timezone.ParseInUserLocation("2006-01-02", raw, userTZ); err == nil {
end := parsed.AddDate(0, 0, 1).Add(-time.Nanosecond)
return &end
}
return nil
}

View File

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

View File

@@ -4,6 +4,7 @@ import (
"context"
"crypto/rand"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
@@ -22,6 +23,34 @@ const (
var affiliateCodeCharset = []byte("ABCDEFGHJKLMNPQRSTUVWXYZ23456789")
const affiliateUserOverviewSQL = `
SELECT ua.user_id,
COALESCE(u.email, ''),
COALESCE(u.username, ''),
ua.aff_code,
COALESCE(ua.aff_rebate_rate_percent, 0)::double precision,
(ua.aff_rebate_rate_percent IS NOT NULL) AS has_custom_rate,
ua.aff_count,
COALESCE(rebated.rebated_invitee_count, 0),
(ua.aff_quota + COALESCE(matured.matured_frozen_quota, 0))::double precision,
ua.aff_history_quota::double precision
FROM user_affiliates ua
JOIN users u ON u.id = ua.user_id
LEFT JOIN (
SELECT user_id, COUNT(DISTINCT source_user_id)::integer AS rebated_invitee_count
FROM user_affiliate_ledger
WHERE action = 'accrue' AND source_user_id IS NOT NULL
GROUP BY user_id
) rebated ON rebated.user_id = ua.user_id
LEFT JOIN (
SELECT user_id, COALESCE(SUM(amount), 0)::double precision AS matured_frozen_quota
FROM user_affiliate_ledger
WHERE action = 'accrue' AND frozen_until IS NOT NULL AND frozen_until <= NOW()
GROUP BY user_id
) matured ON matured.user_id = ua.user_id
WHERE ua.user_id = $1
LIMIT 1`
type affiliateQueryExecer interface {
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
@@ -332,6 +361,349 @@ LIMIT $2`, inviterID, limit)
return invitees, nil
}
func (r *affiliateRepository) ListAffiliateInviteRecords(ctx context.Context, filter service.AffiliateRecordFilter) ([]service.AffiliateInviteRecord, int64, error) {
client := clientFromContext(ctx, r.client)
where, args := buildAffiliateRecordWhere(filter, "ua.created_at", []string{
"inviter.email", "inviter.username", "invitee.email", "invitee.username",
"ua.inviter_id::text", "ua.user_id::text", "inviter_aff.aff_code",
})
total, err := queryAffiliateRecordCount(ctx, client, `
SELECT COUNT(*)
FROM user_affiliates ua
JOIN users invitee ON invitee.id = ua.user_id
JOIN users inviter ON inviter.id = ua.inviter_id
JOIN user_affiliates inviter_aff ON inviter_aff.user_id = ua.inviter_id
`+where, args...)
if err != nil {
return nil, 0, err
}
orderBy := buildAffiliateRecordOrderBy(filter, map[string]string{
"inviter": "inviter.email",
"invitee": "invitee.email",
"aff_code": "inviter_aff.aff_code",
"total_rebate": "total_rebate",
"created_at": "ua.created_at",
}, "ua.created_at")
args = append(args, filter.PageSize, (filter.Page-1)*filter.PageSize)
rows, err := client.QueryContext(ctx, `
SELECT ua.inviter_id,
COALESCE(inviter.email, ''),
COALESCE(inviter.username, ''),
ua.user_id,
COALESCE(invitee.email, ''),
COALESCE(invitee.username, ''),
COALESCE(inviter_aff.aff_code, ''),
COALESCE(SUM(ual.amount), 0)::double precision AS total_rebate,
ua.created_at
FROM user_affiliates ua
JOIN users invitee ON invitee.id = ua.user_id
JOIN users inviter ON inviter.id = ua.inviter_id
JOIN user_affiliates inviter_aff ON inviter_aff.user_id = ua.inviter_id
LEFT JOIN user_affiliate_ledger ual
ON ual.user_id = ua.inviter_id
AND ual.source_user_id = ua.user_id
AND ual.action = 'accrue'
`+where+`
GROUP BY ua.inviter_id, inviter.email, inviter.username, ua.user_id, invitee.email, invitee.username, inviter_aff.aff_code, ua.created_at
`+orderBy+`
LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
if err != nil {
return nil, 0, err
}
defer func() { _ = rows.Close() }()
items := make([]service.AffiliateInviteRecord, 0)
for rows.Next() {
var item service.AffiliateInviteRecord
if err := rows.Scan(
&item.InviterID,
&item.InviterEmail,
&item.InviterUsername,
&item.InviteeID,
&item.InviteeEmail,
&item.InviteeUsername,
&item.AffCode,
&item.TotalRebate,
&item.CreatedAt,
); err != nil {
return nil, 0, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
return items, total, nil
}
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{
"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'`
if where != "" {
where = strings.Replace(where, "WHERE ", " AND ", 1)
}
total, err := queryAffiliateRecordCount(ctx, client, "SELECT COUNT(*) "+baseJoin+where, args...)
if err != nil {
return nil, 0, err
}
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")
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,
COALESCE(inviter.email, ''),
COALESCE(inviter.username, ''),
po.user_id,
COALESCE(invitee.email, ''),
COALESCE(invitee.username, ''),
po.amount::double precision,
po.pay_amount::double precision,
COALESCE(pal.detail, ''),
po.payment_type,
po.status,
pal.created_at
`+baseJoin+where+`
`+orderBy+`
LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
if err != nil {
return nil, 0, err
}
defer func() { _ = rows.Close() }()
items := make([]service.AffiliateRebateRecord, 0)
for rows.Next() {
var item service.AffiliateRebateRecord
var detail string
if err := rows.Scan(
&item.OrderID,
&item.OutTradeNo,
&item.InviterID,
&item.InviterEmail,
&item.InviterUsername,
&item.InviteeID,
&item.InviteeEmail,
&item.InviteeUsername,
&item.OrderAmount,
&item.PayAmount,
&detail,
&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 {
return nil, 0, err
}
return items, total, nil
}
func (r *affiliateRepository) ListAffiliateTransferRecords(ctx context.Context, filter service.AffiliateRecordFilter) ([]service.AffiliateTransferRecord, int64, error) {
client := clientFromContext(ctx, r.client)
where, args := buildAffiliateRecordWhere(filter, "ual.created_at", []string{
"u.email", "u.username", "u.id::text",
})
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)
}
total, err := queryAffiliateRecordCount(ctx, client, "SELECT COUNT(*) "+baseJoin+where, args...)
if err != nil {
return nil, 0, err
}
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",
}, "ual.created_at")
args = append(args, filter.PageSize, (filter.Page-1)*filter.PageSize)
rows, err := client.QueryContext(ctx, `
SELECT ual.id,
ual.user_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.created_at
`+baseJoin+where+`
`+orderBy+`
LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
if err != nil {
return nil, 0, err
}
defer func() { _ = rows.Close() }()
items := make([]service.AffiliateTransferRecord, 0)
for rows.Next() {
var item service.AffiliateTransferRecord
if err := rows.Scan(
&item.LedgerID,
&item.UserID,
&item.UserEmail,
&item.Username,
&item.Amount,
&item.CurrentBalance,
&item.RemainingQuota,
&item.FrozenQuota,
&item.HistoryQuota,
&item.CreatedAt,
); err != nil {
return nil, 0, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
return items, total, nil
}
func (r *affiliateRepository) GetAffiliateUserOverview(ctx context.Context, userID int64) (*service.AffiliateUserOverview, error) {
if userID <= 0 {
return nil, service.ErrUserNotFound
}
client := clientFromContext(ctx, r.client)
rows, err := client.QueryContext(ctx, affiliateUserOverviewSQL, userID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
if !rows.Next() {
if err := rows.Err(); err != nil {
return nil, err
}
return nil, service.ErrUserNotFound
}
var overview service.AffiliateUserOverview
var customRate float64
var hasCustomRate bool
if err := rows.Scan(
&overview.UserID,
&overview.Email,
&overview.Username,
&overview.AffCode,
&customRate,
&hasCustomRate,
&overview.InvitedCount,
&overview.RebatedInviteeCount,
&overview.AvailableQuota,
&overview.HistoryQuota,
); err != nil {
return nil, err
}
if hasCustomRate {
overview.RebateRatePercent = customRate
overview.RebateRateCustom = true
}
return &overview, rows.Err()
}
func buildAffiliateRecordWhere(filter service.AffiliateRecordFilter, timeColumn string, searchColumns []string) (string, []any) {
clauses := make([]string, 0, 3)
args := make([]any, 0, 3)
if filter.StartAt != nil {
args = append(args, *filter.StartAt)
clauses = append(clauses, fmt.Sprintf("%s >= $%d", timeColumn, len(args)))
}
if filter.EndAt != nil {
args = append(args, *filter.EndAt)
clauses = append(clauses, fmt.Sprintf("%s <= $%d", timeColumn, len(args)))
}
search := strings.TrimSpace(filter.Search)
if search != "" && len(searchColumns) > 0 {
args = append(args, "%"+strings.ToLower(search)+"%")
parts := make([]string, 0, len(searchColumns))
for _, col := range searchColumns {
parts = append(parts, fmt.Sprintf("LOWER(%s) LIKE $%d", col, len(args)))
}
clauses = append(clauses, "("+strings.Join(parts, " OR ")+")")
}
if len(clauses) == 0 {
return "", args
}
return "WHERE " + strings.Join(clauses, " AND "), args
}
func buildAffiliateRecordOrderBy(filter service.AffiliateRecordFilter, sortColumns map[string]string, fallbackColumn string) string {
column := sortColumns[filter.SortBy]
if column == "" {
column = fallbackColumn
}
direction := "DESC"
if !filter.SortDesc {
direction = "ASC"
}
return "ORDER BY " + column + " " + direction
}
func queryAffiliateRecordCount(ctx context.Context, client affiliateQueryExecer, query string, args ...any) (int64, error) {
rows, err := client.QueryContext(ctx, query, args...)
if err != nil {
return 0, err
}
defer func() { _ = rows.Close() }()
if !rows.Next() {
return 0, rows.Err()
}
var total int64
if err := rows.Scan(&total); err != nil {
return 0, 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 {
if tx := dbent.TxFromContext(ctx); tx != nil {
return fn(ctx, tx.Client())

View File

@@ -0,0 +1,15 @@
package repository
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestAffiliateUserOverviewSQLIncludesMaturedFrozenQuota(t *testing.T) {
query := strings.Join(strings.Fields(affiliateUserOverviewSQL), " ")
require.Contains(t, query, "ua.aff_quota + COALESCE(matured.matured_frozen_quota, 0)")
require.Contains(t, query, "frozen_until <= NOW()")
}

View File

@@ -602,11 +602,16 @@ func registerChannelMonitorRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
func registerAffiliateRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
affiliates := admin.Group("/affiliates")
{
affiliates.GET("/invites", h.Admin.Affiliate.ListInviteRecords)
affiliates.GET("/rebates", h.Admin.Affiliate.ListRebateRecords)
affiliates.GET("/transfers", h.Admin.Affiliate.ListTransferRecords)
users := affiliates.Group("/users")
{
users.GET("", h.Admin.Affiliate.ListUsers)
users.GET("/lookup", h.Admin.Affiliate.LookupUsers)
users.POST("/batch-rate", h.Admin.Affiliate.BatchSetRate)
users.GET("/:user_id/overview", h.Admin.Affiliate.GetUserOverview)
users.PUT("/:user_id", h.Admin.Affiliate.UpdateUserSettings)
users.DELETE("/:user_id", h.Admin.Affiliate.ClearUserSettings)
}

View File

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

View File

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

View File

@@ -110,6 +110,10 @@ type AffiliateRepository interface {
SetUserRebateRate(ctx context.Context, userID int64, ratePercent *float64) error
BatchSetUserRebateRate(ctx context.Context, userIDs []int64, ratePercent *float64) error
ListUsersWithCustomSettings(ctx context.Context, filter AffiliateAdminFilter) ([]AffiliateAdminEntry, int64, error)
ListAffiliateInviteRecords(ctx context.Context, filter AffiliateRecordFilter) ([]AffiliateInviteRecord, int64, error)
ListAffiliateRebateRecords(ctx context.Context, filter AffiliateRecordFilter) ([]AffiliateRebateRecord, int64, error)
ListAffiliateTransferRecords(ctx context.Context, filter AffiliateRecordFilter) ([]AffiliateTransferRecord, int64, error)
GetAffiliateUserOverview(ctx context.Context, userID int64) (*AffiliateUserOverview, error)
}
// AffiliateAdminFilter 列表筛选条件
@@ -130,6 +134,71 @@ type AffiliateAdminEntry struct {
AffCount int `json:"aff_count"`
}
type AffiliateRecordFilter struct {
Search string
Page int
PageSize int
StartAt *time.Time
EndAt *time.Time
SortBy string
SortDesc bool
}
type AffiliateInviteRecord struct {
InviterID int64 `json:"inviter_id"`
InviterEmail string `json:"inviter_email"`
InviterUsername string `json:"inviter_username"`
InviteeID int64 `json:"invitee_id"`
InviteeEmail string `json:"invitee_email"`
InviteeUsername string `json:"invitee_username"`
AffCode string `json:"aff_code"`
TotalRebate float64 `json:"total_rebate"`
CreatedAt time.Time `json:"created_at"`
}
type AffiliateRebateRecord struct {
OrderID int64 `json:"order_id"`
OutTradeNo string `json:"out_trade_no"`
InviterID int64 `json:"inviter_id"`
InviterEmail string `json:"inviter_email"`
InviterUsername string `json:"inviter_username"`
InviteeID int64 `json:"invitee_id"`
InviteeEmail string `json:"invitee_email"`
InviteeUsername string `json:"invitee_username"`
OrderAmount float64 `json:"order_amount"`
PayAmount float64 `json:"pay_amount"`
RebateAmount float64 `json:"rebate_amount"`
PaymentType string `json:"payment_type"`
OrderStatus string `json:"order_status"`
CreatedAt time.Time `json:"created_at"`
}
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"`
}
type AffiliateUserOverview struct {
UserID int64 `json:"user_id"`
Email string `json:"email"`
Username string `json:"username"`
AffCode string `json:"aff_code"`
RebateRatePercent float64 `json:"rebate_rate_percent"`
RebateRateCustom bool `json:"-"`
InvitedCount int `json:"invited_count"`
RebatedInviteeCount int `json:"rebated_invitee_count"`
AvailableQuota float64 `json:"available_quota"`
HistoryQuota float64 `json:"history_quota"`
}
type AffiliateService struct {
repo AffiliateRepository
settingService *SettingService
@@ -488,3 +557,59 @@ func (s *AffiliateService) AdminListCustomUsers(ctx context.Context, filter Affi
}
return s.repo.ListUsersWithCustomSettings(ctx, filter)
}
func (s *AffiliateService) AdminListInviteRecords(ctx context.Context, filter AffiliateRecordFilter) ([]AffiliateInviteRecord, int64, error) {
if s == nil || s.repo == nil {
return nil, 0, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
}
return s.repo.ListAffiliateInviteRecords(ctx, normalizeAffiliateRecordFilter(filter))
}
func (s *AffiliateService) AdminListRebateRecords(ctx context.Context, filter AffiliateRecordFilter) ([]AffiliateRebateRecord, int64, error) {
if s == nil || s.repo == nil {
return nil, 0, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
}
return s.repo.ListAffiliateRebateRecords(ctx, normalizeAffiliateRecordFilter(filter))
}
func (s *AffiliateService) AdminListTransferRecords(ctx context.Context, filter AffiliateRecordFilter) ([]AffiliateTransferRecord, int64, error) {
if s == nil || s.repo == nil {
return nil, 0, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
}
return s.repo.ListAffiliateTransferRecords(ctx, normalizeAffiliateRecordFilter(filter))
}
func (s *AffiliateService) AdminGetUserOverview(ctx context.Context, userID int64) (*AffiliateUserOverview, error) {
if userID <= 0 {
return nil, infraerrors.BadRequest("INVALID_USER", "invalid user")
}
if s == nil || s.repo == nil {
return nil, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
}
overview, err := s.repo.GetAffiliateUserOverview(ctx, userID)
if err != nil {
return nil, err
}
if overview != nil {
if !overview.RebateRateCustom {
overview.RebateRatePercent = s.globalRebateRatePercent(ctx)
}
overview.RebateRatePercent = clampAffiliateRebateRate(overview.RebateRatePercent)
}
return overview, nil
}
func normalizeAffiliateRecordFilter(filter AffiliateRecordFilter) AffiliateRecordFilter {
if filter.Page <= 0 {
filter.Page = 1
}
if filter.PageSize <= 0 {
filter.PageSize = 20
}
if filter.PageSize > 100 {
filter.PageSize = 100
}
filter.Search = strings.TrimSpace(filter.Search)
filter.SortBy = strings.TrimSpace(filter.SortBy)
return filter
}

View File

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

View File

@@ -23,6 +23,71 @@ export interface ListAffiliateUsersParams {
search?: string
}
export interface ListAffiliateRecordsParams {
page?: number
page_size?: number
search?: string
start_at?: string
end_at?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
timezone?: string
}
export interface AffiliateInviteRecord {
inviter_id: number
inviter_email: string
inviter_username: string
invitee_id: number
invitee_email: string
invitee_username: string
aff_code: string
total_rebate: number
created_at: string
}
export interface AffiliateRebateRecord {
order_id: number
out_trade_no: string
inviter_id: number
inviter_email: string
inviter_username: string
invitee_id: number
invitee_email: string
invitee_username: string
order_amount: number
pay_amount: number
rebate_amount: number
payment_type: string
order_status: string
created_at: string
}
export interface AffiliateTransferRecord {
ledger_id: number
user_id: number
user_email: string
username: string
amount: number
current_balance: number
remaining_quota: number
frozen_quota: number
history_quota: number
created_at: string
}
export interface AffiliateUserOverview {
user_id: number
email: string
username: string
aff_code: string
rebate_rate_percent: number
invited_count: number
rebated_invitee_count: number
available_quota: number
history_quota: number
}
export interface UpdateAffiliateUserRequest {
aff_code?: string
aff_rebate_rate_percent?: number | null
@@ -97,12 +162,68 @@ export async function batchSetRate(
return data
}
function recordParams(params: ListAffiliateRecordsParams = {}) {
return {
page: params.page ?? 1,
page_size: params.page_size ?? 20,
search: params.search ?? '',
start_at: params.start_at || undefined,
end_at: params.end_at || undefined,
sort_by: params.sort_by || undefined,
sort_order: params.sort_order || undefined,
timezone: params.timezone || undefined,
}
}
export async function listInviteRecords(
params: ListAffiliateRecordsParams = {},
): Promise<PaginatedResponse<AffiliateInviteRecord>> {
const { data } = await apiClient.get<PaginatedResponse<AffiliateInviteRecord>>(
'/admin/affiliates/invites',
{ params: recordParams(params) },
)
return data
}
export async function listRebateRecords(
params: ListAffiliateRecordsParams = {},
): Promise<PaginatedResponse<AffiliateRebateRecord>> {
const { data } = await apiClient.get<PaginatedResponse<AffiliateRebateRecord>>(
'/admin/affiliates/rebates',
{ params: recordParams(params) },
)
return data
}
export async function listTransferRecords(
params: ListAffiliateRecordsParams = {},
): Promise<PaginatedResponse<AffiliateTransferRecord>> {
const { data } = await apiClient.get<PaginatedResponse<AffiliateTransferRecord>>(
'/admin/affiliates/transfers',
{ params: recordParams(params) },
)
return data
}
export async function getUserOverview(
userId: number,
): Promise<AffiliateUserOverview> {
const { data } = await apiClient.get<AffiliateUserOverview>(
`/admin/affiliates/users/${userId}/overview`,
)
return data
}
export const affiliatesAPI = {
listUsers,
lookupUsers,
updateUserSettings,
clearUserSettings,
batchSetRate,
listInviteRecords,
listRebateRecords,
listTransferRecords,
getUserOverview,
}
export default affiliatesAPI

View File

@@ -249,7 +249,7 @@ export interface BalanceHistoryResponse extends PaginatedResponse<BalanceHistory
* @param id - User ID
* @param page - Page number
* @param pageSize - Items per page
* @param type - Optional type filter (balance, admin_balance, concurrency, admin_concurrency, subscription)
* @param type - Optional type filter (balance, affiliate_balance, admin_balance, concurrency, admin_concurrency, subscription)
* @returns Paginated balance history with total_recharged
*/
export async function getUserBalanceHistory(

View File

@@ -196,6 +196,7 @@ const totalPages = computed(() => 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':

View File

@@ -721,6 +721,19 @@ const adminNavItems = computed((): NavItem[] => {
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true },
{ path: '/admin/promo-codes', label: t('nav.promoCodes'), icon: GiftIcon, hideInSimpleMode: true },
{
path: '/admin/affiliates',
label: t('nav.affiliateManagement'),
icon: UsersIcon,
hideInSimpleMode: true,
expandOnly: true,
featureFlag: flagAffiliate,
children: [
{ path: '/admin/affiliates/invites', label: t('nav.affiliateInviteRecords'), icon: UsersIcon },
{ path: '/admin/affiliates/rebates', label: t('nav.affiliateRebateRecords'), icon: OrderIcon },
{ path: '/admin/affiliates/transfers', label: t('nav.affiliateTransferRecords'), icon: CreditCardIcon },
],
},
{
path: '/admin/orders',
label: t('nav.orderManagement'),

View File

@@ -347,6 +347,10 @@ export default {
usage: 'Usage',
redeem: 'Redeem',
affiliate: 'Affiliate Rebates',
affiliateManagement: 'Affiliate Rebates',
affiliateInviteRecords: 'Invite Records',
affiliateRebateRecords: 'Rebate Records',
affiliateTransferRecords: 'Transfer Records',
profile: 'Profile',
users: 'Users',
groups: 'Groups',
@@ -1046,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)',
@@ -1635,6 +1640,49 @@ export default {
}
},
affiliates: {
invitesDescription: 'View site-wide inviter and invitee relationships',
rebatesDescription: 'View recharge orders that generated affiliate rebates',
transfersDescription: 'View affiliate quota transfers into account balance',
errors: {
loadFailed: 'Failed to load affiliate records'
},
records: {
search: 'Search',
searchPlaceholder: 'Email, username, user ID, or order number',
startAt: 'Start date',
endAt: 'End date',
inviter: 'Inviter',
invitee: 'Invitee',
user: 'User',
affCode: 'Invite Code',
order: 'Order',
totalRebate: 'Total Rebate',
orderAmount: 'Top-up Amount',
payAmount: 'Paid Amount',
rebateAmount: 'Rebate Amount',
paymentType: 'Payment Method',
orderStatus: 'Order Status',
transferAmount: 'Transfer Amount',
currentBalance: 'Current Balance',
remainingQuota: 'Remaining Quota',
frozenQuota: 'Frozen Rebate',
historyQuota: 'Historical Rebate',
invitedAt: 'Invited At',
rebatedAt: 'Rebated At',
transferredAt: 'Transferred At'
},
overview: {
title: 'Affiliate User Overview',
affCode: 'Invite Code',
rebateRate: 'Rebate Rate',
invitedCount: 'Invited Users',
rebatedInviteeCount: 'Rebated Invitees',
availableQuota: 'Available Quota',
historyQuota: 'Historical Rebate'
}
},
// Users
users: {
title: 'User Management',
@@ -1787,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)',

View File

@@ -347,6 +347,10 @@ export default {
usage: '使用记录',
redeem: '兑换',
affiliate: '邀请返利',
affiliateManagement: '邀请返利',
affiliateInviteRecords: '邀请记录',
affiliateRebateRecords: '返利记录',
affiliateTransferRecords: '提取记录',
profile: '个人资料',
users: '用户管理',
groups: '分组管理',
@@ -1050,6 +1054,7 @@ export default {
recentActivity: '最近活动',
historyWillAppear: '您的兑换历史将显示在这里',
balanceAddedRedeem: '余额充值(兑换)',
balanceAddedAffiliate: '余额充值(返利转入)',
balanceAddedAdmin: '余额充值(管理员)',
balanceDeductedAdmin: '余额扣除(管理员)',
concurrencyAddedRedeem: '并发增加(兑换)',
@@ -1656,6 +1661,49 @@ export default {
}
},
affiliates: {
invitesDescription: '查看全站邀请关系和被邀请用户累计返利',
rebatesDescription: '查看每一笔产生返利的充值订单',
transfersDescription: '查看返利额度转入账户余额的提取流水',
errors: {
loadFailed: '加载邀请返利记录失败'
},
records: {
search: '搜索',
searchPlaceholder: '邮箱、用户名、用户 ID、订单号',
startAt: '开始日期',
endAt: '结束日期',
inviter: '邀请人',
invitee: '被邀请人',
user: '用户',
affCode: '邀请码',
order: '订单',
totalRebate: '累计返利',
orderAmount: '充值金额',
payAmount: '支付金额',
rebateAmount: '返利金额',
paymentType: '支付方式',
orderStatus: '订单状态',
transferAmount: '提取金额',
currentBalance: '当前余额',
remainingQuota: '剩余可提取',
frozenQuota: '冻结返利',
historyQuota: '历史返利',
invitedAt: '邀请时间',
rebatedAt: '返利时间',
transferredAt: '提取时间'
},
overview: {
title: '用户返利概览',
affCode: '邀请码',
rebateRate: '返利比例',
invitedCount: '邀请人数',
rebatedInviteeCount: '已产生返利人数',
availableQuota: '可提余额',
historyQuota: '历史返利'
}
},
// Users Management
users: {
title: '用户管理',
@@ -1844,6 +1892,7 @@ export default {
noBalanceHistory: '暂无变动记录',
allTypes: '全部类型',
typeBalance: '余额(兑换码)',
typeAffiliateBalance: '余额(返利转入)',
typeAdminBalance: '余额(管理员调整)',
typeConcurrency: '并发(兑换码)',
typeAdminConcurrency: '并发(管理员调整)',

View File

@@ -517,6 +517,46 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'admin.usage.description'
}
},
{
path: '/admin/affiliates',
redirect: '/admin/affiliates/invites'
},
{
path: '/admin/affiliates/invites',
name: 'AdminAffiliateInvites',
component: () => import('@/views/admin/affiliates/AdminAffiliateInvitesView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Affiliate Invite Records',
titleKey: 'nav.affiliateInviteRecords',
descriptionKey: 'admin.affiliates.invitesDescription'
}
},
{
path: '/admin/affiliates/rebates',
name: 'AdminAffiliateRebates',
component: () => import('@/views/admin/affiliates/AdminAffiliateRebatesView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Affiliate Rebate Records',
titleKey: 'nav.affiliateRebateRecords',
descriptionKey: 'admin.affiliates.rebatesDescription'
}
},
{
path: '/admin/affiliates/transfers',
name: 'AdminAffiliateTransfers',
component: () => import('@/views/admin/affiliates/AdminAffiliateTransfersView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Affiliate Transfer Records',
titleKey: 'nav.affiliateTransferRecords',
descriptionKey: 'admin.affiliates.transfersDescription'
}
},
// ==================== Payment Admin Routes ====================

View File

@@ -0,0 +1,7 @@
<template>
<AdminAffiliateRecordsTable type="invites" />
</template>
<script setup lang="ts">
import AdminAffiliateRecordsTable from './AdminAffiliateRecordsTable.vue'
</script>

View File

@@ -0,0 +1,7 @@
<template>
<AdminAffiliateRecordsTable type="rebates" />
</template>
<script setup lang="ts">
import AdminAffiliateRecordsTable from './AdminAffiliateRecordsTable.vue'
</script>

View File

@@ -0,0 +1,392 @@
<template>
<AppLayout>
<TablePageLayout>
<template #filters>
<div class="flex flex-wrap items-center gap-3">
<div class="relative w-full md:w-80">
<Icon name="search" size="md" class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input v-model="filters.search" type="text" class="input pl-10" :placeholder="t('admin.affiliates.records.searchPlaceholder')" @input="debounceLoad" />
</div>
<input v-model="filters.start_at" type="date" class="input w-full sm:w-44" :title="t('admin.affiliates.records.startAt')" @change="reloadFromFirstPage" />
<input v-model="filters.end_at" type="date" class="input w-full sm:w-44" :title="t('admin.affiliates.records.endAt')" @change="reloadFromFirstPage" />
<button class="btn btn-secondary px-2 md:px-3" :disabled="loading" :title="t('common.refresh')" @click="loadRecords">
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
</div>
</template>
<template #table>
<DataTable
:columns="columns"
:data="records"
:loading="loading"
:server-side-sort="true"
default-sort-key="created_at"
default-sort-order="desc"
:sort-storage-key="sortStorageKey"
@sort="handleSort"
>
<template #cell-inviter="{ row }">
<UserCell
:id="row.inviter_id"
:email="row.inviter_email"
:username="row.inviter_username"
:clickable="props.type !== 'transfers'"
@open="openUserOverview"
/>
</template>
<template #cell-invitee="{ row }">
<UserCell
:id="row.invitee_id"
:email="row.invitee_email"
:username="row.invitee_username"
:clickable="props.type !== 'transfers'"
@open="openUserOverview"
/>
</template>
<template #cell-user="{ row }">
<UserCell
:id="row.user_id"
:email="row.user_email"
:username="row.username"
:clickable="true"
@open="openUserOverview"
/>
</template>
<template #cell-aff_code="{ row }">
<span class="font-mono text-sm text-gray-700 dark:text-gray-300">{{ row.aff_code || '-' }}</span>
</template>
<template #cell-order="{ row }">
<div class="space-y-0.5">
<div class="font-mono text-sm text-gray-900 dark:text-white">#{{ row.order_id }}</div>
<div class="max-w-56 truncate text-sm text-gray-500 dark:text-dark-400">{{ row.out_trade_no }}</div>
</div>
</template>
<template #cell-payment_type="{ row }">
{{ t('payment.methods.' + row.payment_type, row.payment_type || '-') }}
</template>
<template #cell-order_status="{ row }">
<OrderStatusBadge :status="row.order_status" />
</template>
<template #cell-total_rebate="{ row }">
<AmountText :value="row.total_rebate" />
</template>
<template #cell-order_amount="{ row }">
<AmountText :value="row.order_amount" />
</template>
<template #cell-pay_amount="{ row }">
<span class="text-sm text-gray-900 dark:text-white">¥{{ formatAmount(row.pay_amount) }}</span>
</template>
<template #cell-rebate_amount="{ row }">
<AmountText :value="row.rebate_amount" strong />
</template>
<template #cell-amount="{ row }">
<AmountText :value="row.amount" strong />
</template>
<template #cell-current_balance="{ row }">
<AmountText :value="row.current_balance" />
</template>
<template #cell-remaining_quota="{ row }">
<AmountText :value="row.remaining_quota" />
</template>
<template #cell-frozen_quota="{ row }">
<AmountText :value="row.frozen_quota" />
</template>
<template #cell-history_quota="{ row }">
<AmountText :value="row.history_quota" />
</template>
<template #cell-created_at="{ row }">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(row.created_at) }}</span>
</template>
</DataTable>
</template>
<template #pagination>
<Pagination
v-if="pagination.total > 0"
:page="pagination.page"
:total="pagination.total"
:page-size="pagination.page_size"
@update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/>
</template>
</TablePageLayout>
<BaseDialog
:show="overviewDialog"
:title="t('admin.affiliates.overview.title')"
width="normal"
@close="overviewDialog = false"
>
<div v-if="overviewLoading" class="flex justify-center py-8">
<div class="h-6 w-6 animate-spin rounded-full border-2 border-primary-500 border-t-transparent"></div>
</div>
<div v-else-if="selectedOverview" class="space-y-4">
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4 dark:border-dark-700 dark:bg-dark-800">
<div class="font-mono text-sm text-gray-900 dark:text-white">#{{ selectedOverview.user_id }}</div>
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">{{ selectedOverview.email || '-' }}</div>
<div class="mt-0.5 text-sm text-gray-500 dark:text-dark-400">{{ selectedOverview.username || '-' }}</div>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<OverviewStat :label="t('admin.affiliates.overview.affCode')" :value="selectedOverview.aff_code || '-'" mono />
<OverviewStat :label="t('admin.affiliates.overview.rebateRate')" :value="formatPercent(selectedOverview.rebate_rate_percent)" />
<OverviewStat :label="t('admin.affiliates.overview.invitedCount')" :value="String(selectedOverview.invited_count)" />
<OverviewStat :label="t('admin.affiliates.overview.rebatedInviteeCount')" :value="String(selectedOverview.rebated_invitee_count)" />
<OverviewStat :label="t('admin.affiliates.overview.availableQuota')" :value="'$' + formatAmount(selectedOverview.available_quota)" />
<OverviewStat :label="t('admin.affiliates.overview.historyQuota')" :value="'$' + formatAmount(selectedOverview.history_quota)" />
</div>
</div>
</BaseDialog>
</AppLayout>
</template>
<script setup lang="ts">
import { computed, defineComponent, h, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Icon from '@/components/icons/Icon.vue'
import OrderStatusBadge from '@/components/payment/OrderStatusBadge.vue'
import type { Column } from '@/components/common/types'
import { useAppStore } from '@/stores/app'
import { affiliatesAPI, type AffiliateInviteRecord, type AffiliateRebateRecord, type AffiliateTransferRecord, type AffiliateUserOverview, type ListAffiliateRecordsParams } from '@/api/admin/affiliates'
import type { PaginatedResponse } from '@/types'
import { extractI18nErrorMessage } from '@/utils/apiError'
import { formatDateTime as formatDisplayDateTime } from '@/utils/format'
type RecordType = 'invites' | 'rebates' | 'transfers'
type AffiliateRecord = AffiliateInviteRecord | AffiliateRebateRecord | AffiliateTransferRecord
const props = defineProps<{
type: RecordType
}>()
const { t } = useI18n()
const appStore = useAppStore()
const loading = ref(false)
const records = ref<AffiliateRecord[]>([])
const filters = reactive({ search: '', start_at: '', end_at: '' })
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const overviewDialog = ref(false)
const overviewLoading = ref(false)
const selectedOverview = ref<AffiliateUserOverview | null>(null)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
const columns = computed<Column[]>(() => {
if (props.type === 'invites') {
return [
{ key: 'inviter', label: t('admin.affiliates.records.inviter'), sortable: true },
{ key: 'invitee', label: t('admin.affiliates.records.invitee'), sortable: true },
{ key: 'aff_code', label: t('admin.affiliates.records.affCode'), sortable: true },
{ key: 'total_rebate', label: t('admin.affiliates.records.totalRebate'), sortable: true },
{ key: 'created_at', label: t('admin.affiliates.records.invitedAt'), sortable: true },
]
}
if (props.type === 'rebates') {
return [
{ key: 'order', label: t('admin.affiliates.records.order'), sortable: true },
{ key: 'inviter', label: t('admin.affiliates.records.inviter'), sortable: true },
{ key: 'invitee', label: t('admin.affiliates.records.invitee'), sortable: true },
{ key: 'order_amount', label: t('admin.affiliates.records.orderAmount'), sortable: true },
{ key: 'pay_amount', label: t('admin.affiliates.records.payAmount'), sortable: true },
{ key: 'rebate_amount', label: t('admin.affiliates.records.rebateAmount') },
{ key: 'payment_type', label: t('admin.affiliates.records.paymentType'), sortable: true },
{ key: 'order_status', label: t('admin.affiliates.records.orderStatus'), sortable: true },
{ key: 'created_at', label: t('admin.affiliates.records.rebatedAt'), sortable: true },
]
}
return [
{ key: 'user', label: t('admin.affiliates.records.user'), sortable: true },
{ key: 'amount', label: t('admin.affiliates.records.transferAmount'), sortable: true },
{ key: 'current_balance', label: t('admin.affiliates.records.currentBalance'), sortable: true },
{ key: 'remaining_quota', label: t('admin.affiliates.records.remainingQuota'), sortable: true },
{ key: 'frozen_quota', label: t('admin.affiliates.records.frozenQuota'), sortable: true },
{ key: 'history_quota', label: t('admin.affiliates.records.historyQuota'), sortable: true },
{ key: 'created_at', label: t('admin.affiliates.records.transferredAt'), sortable: true },
]
})
const sortStorageKey = computed(() => `admin-affiliate-${props.type}-table-sort`)
function loadInitialSortState(): { sort_by: string; sort_order: 'asc' | 'desc' } {
const fallback = { sort_by: 'created_at', sort_order: 'desc' as 'asc' | 'desc' }
try {
const raw = localStorage.getItem(sortStorageKey.value)
if (!raw) return fallback
const parsed = JSON.parse(raw) as { key?: string; order?: string }
const key = typeof parsed.key === 'string' ? parsed.key : ''
if (!columns.value.some((column) => column.key === key && column.sortable)) return fallback
return {
sort_by: key,
sort_order: parsed.order === 'asc' ? 'asc' : 'desc',
}
} catch {
return fallback
}
}
const sortState = reactive(loadInitialSortState())
function userTimezone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone
} catch {
return 'UTC'
}
}
function buildParams(): ListAffiliateRecordsParams {
return {
page: pagination.page,
page_size: pagination.page_size,
search: filters.search.trim() || undefined,
start_at: filters.start_at || undefined,
end_at: filters.end_at || undefined,
sort_by: sortState.sort_by,
sort_order: sortState.sort_order,
timezone: userTimezone(),
}
}
async function fetchRecords(params: ListAffiliateRecordsParams): Promise<PaginatedResponse<AffiliateRecord>> {
if (props.type === 'invites') {
return affiliatesAPI.listInviteRecords(params)
}
if (props.type === 'rebates') {
return affiliatesAPI.listRebateRecords(params)
}
return affiliatesAPI.listTransferRecords(params)
}
async function loadRecords() {
loading.value = true
try {
const res = await fetchRecords(buildParams())
records.value = res.items || []
pagination.total = res.total || 0
} catch (error) {
appStore.showError(extractI18nErrorMessage(error, t, 'admin.affiliates.errors', t('common.error')))
} finally {
loading.value = false
}
}
function debounceLoad() {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => reloadFromFirstPage(), 300)
}
function reloadFromFirstPage() {
pagination.page = 1
void loadRecords()
}
function handlePageChange(page: number) {
pagination.page = page
void loadRecords()
}
function handlePageSizeChange(size: number) {
pagination.page_size = size
pagination.page = 1
void loadRecords()
}
function handleSort(key: string, order: 'asc' | 'desc') {
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
void loadRecords()
}
function formatAmount(value: number | null | undefined): string {
return Number(value || 0).toFixed(2)
}
function formatPercent(value: number | null | undefined): string {
const rounded = Math.round(Number(value || 0) * 100) / 100
return `${Number.isInteger(rounded) ? rounded.toString() : rounded.toString()}%`
}
function formatDateTime(value: string | null | undefined): string {
return value ? formatDisplayDateTime(value) : '-'
}
async function openUserOverview(userId: number) {
if (!userId) return
overviewDialog.value = true
overviewLoading.value = true
selectedOverview.value = null
try {
selectedOverview.value = await affiliatesAPI.getUserOverview(userId)
} catch (error) {
overviewDialog.value = false
appStore.showError(extractI18nErrorMessage(error, t, 'admin.affiliates.errors', t('common.error')))
} finally {
overviewLoading.value = false
}
}
const UserCell = defineComponent({
props: {
id: { type: Number, required: true },
email: { type: String, default: '' },
username: { type: String, default: '' },
clickable: { type: Boolean, default: false },
},
emits: ['open'],
setup(cellProps, { emit }) {
return () => h('div', { class: 'space-y-0.5' }, [
h('div', { class: 'font-mono text-sm text-gray-900 dark:text-white' }, `#${cellProps.id}`),
h(cellProps.clickable ? 'button' : 'div', {
class: cellProps.clickable
? 'max-w-56 truncate text-left text-sm font-medium text-primary-600 hover:text-primary-700 hover:underline dark:text-primary-400 dark:hover:text-primary-300'
: 'max-w-56 truncate text-sm text-gray-700 dark:text-gray-300',
type: cellProps.clickable ? 'button' : undefined,
onClick: cellProps.clickable ? () => emit('open', cellProps.id) : undefined,
}, cellProps.email || '-'),
h('div', { class: 'max-w-56 truncate text-sm text-gray-500 dark:text-dark-400' }, cellProps.username || '-'),
])
},
})
const AmountText = defineComponent({
props: {
value: { type: Number, default: 0 },
strong: { type: Boolean, default: false },
},
setup(amountProps) {
return () => h('span', {
class: amountProps.strong
? 'text-sm font-semibold text-emerald-600 dark:text-emerald-400'
: 'text-sm text-gray-900 dark:text-white',
}, `$${formatAmount(amountProps.value)}`)
},
})
const OverviewStat = defineComponent({
props: {
label: { type: String, required: true },
value: { type: String, required: true },
mono: { type: Boolean, default: false },
},
setup(statProps) {
return () => h('div', { class: 'rounded-lg border border-gray-100 bg-white p-3 dark:border-dark-700 dark:bg-dark-900' }, [
h('div', { class: 'text-sm text-gray-500 dark:text-dark-400' }, statProps.label),
h('div', {
class: statProps.mono
? 'mt-1 font-mono text-base font-semibold text-gray-900 dark:text-white'
: 'mt-1 text-base font-semibold text-gray-900 dark:text-white',
}, statProps.value),
])
},
})
onMounted(() => {
void loadRecords()
})
</script>

View File

@@ -0,0 +1,7 @@
<template>
<AdminAffiliateRecordsTable type="transfers" />
</template>
<script setup lang="ts">
import AdminAffiliateRecordsTable from './AdminAffiliateRecordsTable.vue'
</script>