feat(affiliate): 完善邀请返利系统

- 修复返利不到账的根因:tryClaimAffiliateRebateAudit 中 PostgreSQL 参数类型推断冲突
  - 补全 OAuth 注册路径(LinuxDo/OIDC/WeChat/Pending Flow)的邀请码绑定
  - 前端 OAuth 注册页面传递 aff_code 参数
  - 新增返利冻结期机制:可配置冻结时间,到期后自动解冻(懒解冻)
  - 新增返利有效期:绑定后 N 天内有效,过期不再产生返利
  - 新增单人返利上限:超出上限部分精确截断
  - 增强返利流程 slog 结构化日志,便于排查问题
  - 已邀请用户列表增加返利明细列
This commit is contained in:
shaw
2026-04-26 12:31:52 +08:00
parent 496469ac4e
commit 9b6dcc57bd
42 changed files with 852 additions and 104 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
docs/claude-relay-service/ docs/claude-relay-service/
.codex
# =================== # ===================
# Go 后端 # Go 后端

View File

@@ -186,6 +186,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
DefaultConcurrency: settings.DefaultConcurrency, DefaultConcurrency: settings.DefaultConcurrency,
DefaultBalance: settings.DefaultBalance, DefaultBalance: settings.DefaultBalance,
AffiliateRebateRate: settings.AffiliateRebateRate, AffiliateRebateRate: settings.AffiliateRebateRate,
AffiliateRebateFreezeHours: settings.AffiliateRebateFreezeHours,
AffiliateRebateDurationDays: settings.AffiliateRebateDurationDays,
AffiliateRebatePerInviteeCap: settings.AffiliateRebatePerInviteeCap,
DefaultUserRPMLimit: settings.DefaultUserRPMLimit, DefaultUserRPMLimit: settings.DefaultUserRPMLimit,
DefaultSubscriptions: defaultSubscriptions, DefaultSubscriptions: defaultSubscriptions,
EnableModelFallback: settings.EnableModelFallback, EnableModelFallback: settings.EnableModelFallback,
@@ -342,6 +345,9 @@ type UpdateSettingsRequest struct {
DefaultConcurrency int `json:"default_concurrency"` DefaultConcurrency int `json:"default_concurrency"`
DefaultBalance float64 `json:"default_balance"` DefaultBalance float64 `json:"default_balance"`
AffiliateRebateRate *float64 `json:"affiliate_rebate_rate"` AffiliateRebateRate *float64 `json:"affiliate_rebate_rate"`
AffiliateRebateFreezeHours *int `json:"affiliate_rebate_freeze_hours"`
AffiliateRebateDurationDays *int `json:"affiliate_rebate_duration_days"`
AffiliateRebatePerInviteeCap *float64 `json:"affiliate_rebate_per_invitee_cap"`
DefaultUserRPMLimit int `json:"default_user_rpm_limit"` DefaultUserRPMLimit int `json:"default_user_rpm_limit"`
DefaultSubscriptions []dto.DefaultSubscriptionSetting `json:"default_subscriptions"` DefaultSubscriptions []dto.DefaultSubscriptionSetting `json:"default_subscriptions"`
AuthSourceDefaultEmailBalance *float64 `json:"auth_source_default_email_balance"` AuthSourceDefaultEmailBalance *float64 `json:"auth_source_default_email_balance"`
@@ -485,6 +491,33 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
if affiliateRebateRate > service.AffiliateRebateRateMax { if affiliateRebateRate > service.AffiliateRebateRateMax {
affiliateRebateRate = service.AffiliateRebateRateMax affiliateRebateRate = service.AffiliateRebateRateMax
} }
affiliateRebateFreezeHours := previousSettings.AffiliateRebateFreezeHours
if req.AffiliateRebateFreezeHours != nil {
affiliateRebateFreezeHours = *req.AffiliateRebateFreezeHours
}
if affiliateRebateFreezeHours < 0 {
affiliateRebateFreezeHours = service.AffiliateRebateFreezeHoursDefault
}
if affiliateRebateFreezeHours > service.AffiliateRebateFreezeHoursMax {
affiliateRebateFreezeHours = service.AffiliateRebateFreezeHoursMax
}
affiliateRebateDurationDays := previousSettings.AffiliateRebateDurationDays
if req.AffiliateRebateDurationDays != nil {
affiliateRebateDurationDays = *req.AffiliateRebateDurationDays
}
if affiliateRebateDurationDays < 0 {
affiliateRebateDurationDays = service.AffiliateRebateDurationDaysDefault
}
if affiliateRebateDurationDays > service.AffiliateRebateDurationDaysMax {
affiliateRebateDurationDays = service.AffiliateRebateDurationDaysMax
}
affiliateRebatePerInviteeCap := previousSettings.AffiliateRebatePerInviteeCap
if req.AffiliateRebatePerInviteeCap != nil {
affiliateRebatePerInviteeCap = *req.AffiliateRebatePerInviteeCap
}
if affiliateRebatePerInviteeCap < 0 {
affiliateRebatePerInviteeCap = service.AffiliateRebatePerInviteeCapDefault
}
// 通用表格配置:兼容旧客户端未传字段时保留当前值。 // 通用表格配置:兼容旧客户端未传字段时保留当前值。
if req.TableDefaultPageSize <= 0 { if req.TableDefaultPageSize <= 0 {
req.TableDefaultPageSize = previousSettings.TableDefaultPageSize req.TableDefaultPageSize = previousSettings.TableDefaultPageSize
@@ -1137,6 +1170,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
DefaultConcurrency: req.DefaultConcurrency, DefaultConcurrency: req.DefaultConcurrency,
DefaultBalance: req.DefaultBalance, DefaultBalance: req.DefaultBalance,
AffiliateRebateRate: affiliateRebateRate, AffiliateRebateRate: affiliateRebateRate,
AffiliateRebateFreezeHours: affiliateRebateFreezeHours,
AffiliateRebateDurationDays: affiliateRebateDurationDays,
AffiliateRebatePerInviteeCap: affiliateRebatePerInviteeCap,
DefaultUserRPMLimit: req.DefaultUserRPMLimit, DefaultUserRPMLimit: req.DefaultUserRPMLimit,
DefaultSubscriptions: defaultSubscriptions, DefaultSubscriptions: defaultSubscriptions,
EnableModelFallback: req.EnableModelFallback, EnableModelFallback: req.EnableModelFallback,
@@ -1458,6 +1494,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
DefaultConcurrency: updatedSettings.DefaultConcurrency, DefaultConcurrency: updatedSettings.DefaultConcurrency,
DefaultBalance: updatedSettings.DefaultBalance, DefaultBalance: updatedSettings.DefaultBalance,
AffiliateRebateRate: updatedSettings.AffiliateRebateRate, AffiliateRebateRate: updatedSettings.AffiliateRebateRate,
AffiliateRebateFreezeHours: updatedSettings.AffiliateRebateFreezeHours,
AffiliateRebateDurationDays: updatedSettings.AffiliateRebateDurationDays,
AffiliateRebatePerInviteeCap: updatedSettings.AffiliateRebatePerInviteeCap,
DefaultUserRPMLimit: updatedSettings.DefaultUserRPMLimit, DefaultUserRPMLimit: updatedSettings.DefaultUserRPMLimit,
DefaultSubscriptions: updatedDefaultSubscriptions, DefaultSubscriptions: updatedDefaultSubscriptions,
EnableModelFallback: updatedSettings.EnableModelFallback, EnableModelFallback: updatedSettings.EnableModelFallback,
@@ -1768,6 +1807,15 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.AffiliateRebateRate != after.AffiliateRebateRate { if before.AffiliateRebateRate != after.AffiliateRebateRate {
changed = append(changed, "affiliate_rebate_rate") changed = append(changed, "affiliate_rebate_rate")
} }
if before.AffiliateRebateFreezeHours != after.AffiliateRebateFreezeHours {
changed = append(changed, "affiliate_rebate_freeze_hours")
}
if before.AffiliateRebateDurationDays != after.AffiliateRebateDurationDays {
changed = append(changed, "affiliate_rebate_duration_days")
}
if before.AffiliateRebatePerInviteeCap != after.AffiliateRebatePerInviteeCap {
changed = append(changed, "affiliate_rebate_per_invitee_cap")
}
if !equalDefaultSubscriptions(before.DefaultSubscriptions, after.DefaultSubscriptions) { if !equalDefaultSubscriptions(before.DefaultSubscriptions, after.DefaultSubscriptions) {
changed = append(changed, "default_subscriptions") changed = append(changed, "default_subscriptions")
} }

View File

@@ -435,6 +435,7 @@ func (h *AuthHandler) createLinuxDoOAuthChoicePendingSession(
type completeLinuxDoOAuthRequest struct { type completeLinuxDoOAuthRequest struct {
InvitationCode string `json:"invitation_code" binding:"required"` InvitationCode string `json:"invitation_code" binding:"required"`
AffCode string `json:"aff_code,omitempty"`
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"` AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
AdoptAvatar *bool `json:"adopt_avatar,omitempty"` AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
} }
@@ -518,7 +519,7 @@ func (h *AuthHandler) CompleteLinuxDoOAuthRegistration(c *gin.Context) {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)
return return
} }
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode) tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode)
if err != nil { if err != nil {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)
return return

View File

@@ -67,6 +67,7 @@ type createPendingOAuthAccountRequest struct {
VerifyCode string `json:"verify_code,omitempty"` VerifyCode string `json:"verify_code,omitempty"`
Password string `json:"password" binding:"required,min=6"` Password string `json:"password" binding:"required,min=6"`
InvitationCode string `json:"invitation_code,omitempty"` InvitationCode string `json:"invitation_code,omitempty"`
AffCode string `json:"aff_code,omitempty"`
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"` AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
AdoptAvatar *bool `json:"adopt_avatar,omitempty"` AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
} }
@@ -1751,6 +1752,7 @@ func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string)
user, user,
strings.TrimSpace(req.InvitationCode), strings.TrimSpace(req.InvitationCode),
strings.TrimSpace(session.ProviderType), strings.TrimSpace(session.ProviderType),
strings.TrimSpace(req.AffCode),
); err != nil { ); err != nil {
_ = tx.Rollback() _ = tx.Rollback()
if rollbackCreatedUser(err) { if rollbackCreatedUser(err) {

View File

@@ -582,6 +582,7 @@ func (h *AuthHandler) createOIDCOAuthChoicePendingSession(
type completeOIDCOAuthRequest struct { type completeOIDCOAuthRequest struct {
InvitationCode string `json:"invitation_code" binding:"required"` InvitationCode string `json:"invitation_code" binding:"required"`
AffCode string `json:"aff_code,omitempty"`
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"` AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
AdoptAvatar *bool `json:"adopt_avatar,omitempty"` AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
} }
@@ -665,7 +666,7 @@ func (h *AuthHandler) CompleteOIDCOAuthRegistration(c *gin.Context) {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)
return return
} }
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode) tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode)
if err != nil { if err != nil {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)
return return

View File

@@ -481,6 +481,7 @@ func (h *AuthHandler) wechatPaymentResumeService() *service.PaymentResumeService
type completeWeChatOAuthRequest struct { type completeWeChatOAuthRequest struct {
InvitationCode string `json:"invitation_code" binding:"required"` InvitationCode string `json:"invitation_code" binding:"required"`
AffCode string `json:"aff_code,omitempty"`
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"` AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
AdoptAvatar *bool `json:"adopt_avatar,omitempty"` AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
} }
@@ -547,7 +548,7 @@ func (h *AuthHandler) CompleteWeChatOAuthRegistration(c *gin.Context) {
return return
} }
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode) tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode)
if err != nil { if err != nil {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)
return return

View File

@@ -106,11 +106,14 @@ type SystemSettings struct {
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
CustomEndpoints []CustomEndpoint `json:"custom_endpoints"` CustomEndpoints []CustomEndpoint `json:"custom_endpoints"`
DefaultConcurrency int `json:"default_concurrency"` DefaultConcurrency int `json:"default_concurrency"`
DefaultBalance float64 `json:"default_balance"` DefaultBalance float64 `json:"default_balance"`
AffiliateRebateRate float64 `json:"affiliate_rebate_rate"` AffiliateRebateRate float64 `json:"affiliate_rebate_rate"`
DefaultUserRPMLimit int `json:"default_user_rpm_limit"` AffiliateRebateFreezeHours int `json:"affiliate_rebate_freeze_hours"`
DefaultSubscriptions []DefaultSubscriptionSetting `json:"default_subscriptions"` AffiliateRebateDurationDays int `json:"affiliate_rebate_duration_days"`
AffiliateRebatePerInviteeCap float64 `json:"affiliate_rebate_per_invitee_cap"`
DefaultUserRPMLimit int `json:"default_user_rpm_limit"`
DefaultSubscriptions []DefaultSubscriptionSetting `json:"default_subscriptions"`
// Model fallback configuration // Model fallback configuration
EnableModelFallback bool `json:"enable_model_fallback"` EnableModelFallback bool `json:"enable_model_fallback"`

View File

@@ -86,17 +86,21 @@ func (r *affiliateRepository) BindInviter(ctx context.Context, userID, inviterID
return bound, nil return bound, nil
} }
func (r *affiliateRepository) AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64) (bool, error) { func (r *affiliateRepository) AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64, freezeHours int) (bool, error) {
if amount <= 0 { if amount <= 0 {
return false, nil return false, nil
} }
var applied bool var applied bool
err := r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error { err := r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error {
res, err := txClient.ExecContext(txCtx, // freezeHours > 0: add to frozen quota; == 0: add to available quota directly
"UPDATE user_affiliates SET aff_quota = aff_quota + $1, aff_history_quota = aff_history_quota + $1, updated_at = NOW() WHERE user_id = $2", var updateSQL string
amount, inviterID, if freezeHours > 0 {
) updateSQL = "UPDATE user_affiliates SET aff_frozen_quota = aff_frozen_quota + $1, aff_history_quota = aff_history_quota + $1, updated_at = NOW() WHERE user_id = $2"
} else {
updateSQL = "UPDATE user_affiliates SET aff_quota = aff_quota + $1, aff_history_quota = aff_history_quota + $1, updated_at = NOW() WHERE user_id = $2"
}
res, err := txClient.ExecContext(txCtx, updateSQL, amount, inviterID)
if err != nil { if err != nil {
return err return err
} }
@@ -106,10 +110,19 @@ func (r *affiliateRepository) AccrueQuota(ctx context.Context, inviterID, invite
return nil return nil
} }
if _, err = txClient.ExecContext(txCtx, ` 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 {
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) 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 { VALUES ($1, 'accrue', $2, $3, NOW(), NOW())`, inviterID, amount, inviteeUserID); err != nil {
return fmt.Errorf("insert affiliate accrue ledger: %w", err) return fmt.Errorf("insert affiliate accrue ledger: %w", err)
}
} }
applied = true applied = true
@@ -121,6 +134,76 @@ VALUES ($1, 'accrue', $2, $3, NOW(), NOW())`, inviterID, amount, inviteeUserID);
return applied, nil return applied, nil
} }
func (r *affiliateRepository) GetAccruedRebateFromInvitee(ctx context.Context, inviterID, inviteeUserID int64) (float64, error) {
client := clientFromContext(ctx, r.client)
rows, err := client.QueryContext(ctx,
`SELECT COALESCE(SUM(amount), 0)::double precision FROM user_affiliate_ledger WHERE user_id = $1 AND source_user_id = $2 AND action = 'accrue'`,
inviterID, inviteeUserID)
if err != nil {
return 0, fmt.Errorf("query accrued rebate from invitee: %w", err)
}
defer func() { _ = rows.Close() }()
var total float64
if rows.Next() {
if err := rows.Scan(&total); err != nil {
return 0, err
}
}
return total, rows.Close()
}
func (r *affiliateRepository) ThawFrozenQuota(ctx context.Context, userID int64) (float64, error) {
var thawed float64
err := r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error {
var err error
thawed, err = thawFrozenQuotaTx(txCtx, txClient, userID)
return err
})
return thawed, err
}
// thawFrozenQuotaTx moves matured frozen quota to available quota within an existing tx.
func thawFrozenQuotaTx(txCtx context.Context, txClient *dbent.Client, userID int64) (float64, error) {
rows, err := txClient.QueryContext(txCtx, `
WITH matured AS (
UPDATE user_affiliate_ledger
SET frozen_until = NULL, updated_at = NOW()
WHERE user_id = $1
AND frozen_until IS NOT NULL
AND frozen_until <= NOW()
RETURNING amount
)
SELECT COALESCE(SUM(amount), 0) FROM matured`, userID)
if err != nil {
return 0, fmt.Errorf("thaw frozen quota: %w", err)
}
defer func() { _ = rows.Close() }()
var thawed float64
if rows.Next() {
if err := rows.Scan(&thawed); err != nil {
return 0, err
}
}
if err := rows.Close(); err != nil {
return 0, err
}
if thawed <= 0 {
return 0, nil
}
_, err = txClient.ExecContext(txCtx, `
UPDATE user_affiliates
SET aff_quota = aff_quota + $1,
aff_frozen_quota = GREATEST(aff_frozen_quota - $1, 0),
updated_at = NOW()
WHERE user_id = $2`, thawed, userID)
if err != nil {
return 0, fmt.Errorf("move thawed quota: %w", err)
}
return thawed, nil
}
func (r *affiliateRepository) TransferQuotaToBalance(ctx context.Context, userID int64) (float64, float64, error) { func (r *affiliateRepository) TransferQuotaToBalance(ctx context.Context, userID int64) (float64, float64, error) {
var transferred float64 var transferred float64
var newBalance float64 var newBalance float64
@@ -130,6 +213,11 @@ func (r *affiliateRepository) TransferQuotaToBalance(ctx context.Context, userID
return err return err
} }
// Thaw any matured frozen quota before transfer.
if _, err := thawFrozenQuotaTx(txCtx, txClient, userID); err != nil {
return fmt.Errorf("thaw before transfer: %w", err)
}
rows, err := txClient.QueryContext(txCtx, ` rows, err := txClient.QueryContext(txCtx, `
WITH claimed AS ( WITH claimed AS (
SELECT aff_quota::double precision AS amount SELECT aff_quota::double precision AS amount
@@ -211,10 +299,16 @@ func (r *affiliateRepository) ListInvitees(ctx context.Context, inviterID int64,
SELECT ua.user_id, SELECT ua.user_id,
COALESCE(u.email, ''), COALESCE(u.email, ''),
COALESCE(u.username, ''), COALESCE(u.username, ''),
ua.created_at ua.created_at,
COALESCE(SUM(ual.amount), 0)::double precision AS total_rebate
FROM user_affiliates ua FROM user_affiliates ua
LEFT JOIN users u ON u.id = ua.user_id LEFT JOIN users u ON u.id = ua.user_id
LEFT JOIN user_affiliate_ledger ual
ON ual.user_id = $1
AND ual.source_user_id = ua.user_id
AND ual.action = 'accrue'
WHERE ua.inviter_id = $1 WHERE ua.inviter_id = $1
GROUP BY ua.user_id, u.email, u.username, ua.created_at
ORDER BY ua.created_at DESC ORDER BY ua.created_at DESC
LIMIT $2`, inviterID, limit) LIMIT $2`, inviterID, limit)
if err != nil { if err != nil {
@@ -226,7 +320,7 @@ LIMIT $2`, inviterID, limit)
for rows.Next() { for rows.Next() {
var item service.AffiliateInvitee var item service.AffiliateInvitee
var createdAt time.Time var createdAt time.Time
if err := rows.Scan(&item.UserID, &item.Email, &item.Username, &createdAt); err != nil { if err := rows.Scan(&item.UserID, &item.Email, &item.Username, &createdAt, &item.TotalRebate); err != nil {
return nil, err return nil, err
} }
item.CreatedAt = &createdAt item.CreatedAt = &createdAt
@@ -299,6 +393,7 @@ SELECT user_id,
inviter_id, inviter_id,
aff_count, aff_count,
aff_quota::double precision, aff_quota::double precision,
aff_frozen_quota::double precision,
aff_history_quota::double precision, aff_history_quota::double precision,
created_at, created_at,
updated_at updated_at
@@ -326,6 +421,7 @@ WHERE user_id = $1`, userID)
&inviterID, &inviterID,
&out.AffCount, &out.AffCount,
&out.AffQuota, &out.AffQuota,
&out.AffFrozenQuota,
&out.AffHistoryQuota, &out.AffHistoryQuota,
&out.CreatedAt, &out.CreatedAt,
&out.UpdatedAt, &out.UpdatedAt,
@@ -351,6 +447,7 @@ SELECT user_id,
inviter_id, inviter_id,
aff_count, aff_count,
aff_quota::double precision, aff_quota::double precision,
aff_frozen_quota::double precision,
aff_history_quota::double precision, aff_history_quota::double precision,
created_at, created_at,
updated_at updated_at
@@ -380,6 +477,7 @@ LIMIT 1`, strings.ToUpper(strings.TrimSpace(code)))
&inviterID, &inviterID,
&out.AffCount, &out.AffCount,
&out.AffQuota, &out.AffQuota,
&out.AffFrozenQuota,
&out.AffHistoryQuota, &out.AffHistoryQuota,
&out.CreatedAt, &out.CreatedAt,
&out.UpdatedAt, &out.UpdatedAt,

View File

@@ -125,7 +125,7 @@ func TestAffiliateRepository_AccrueQuota_ReusesOuterTransaction(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.True(t, bound, "invitee must bind to inviter") require.True(t, bound, "invitee must bind to inviter")
applied, err := repo.AccrueQuota(txCtx, inviter.ID, invitee.ID, 3.5) applied, err := repo.AccrueQuota(txCtx, inviter.ID, invitee.ID, 3.5, 0)
require.NoError(t, err) require.NoError(t, err)
require.True(t, applied, "AccrueQuota must report applied=true") require.True(t, applied, "AccrueQuota must report applied=true")

View File

@@ -716,6 +716,9 @@ func TestAPIContracts(t *testing.T) {
"default_concurrency": 5, "default_concurrency": 5,
"default_balance": 1.25, "default_balance": 1.25,
"affiliate_rebate_rate": 20, "affiliate_rebate_rate": 20,
"affiliate_rebate_freeze_hours": 0,
"affiliate_rebate_duration_days": 0,
"affiliate_rebate_per_invitee_cap": 0,
"default_user_rpm_limit": 0, "default_user_rpm_limit": 0,
"default_subscriptions": [], "default_subscriptions": [],
"enable_model_fallback": false, "enable_model_fallback": false,
@@ -898,6 +901,9 @@ func TestAPIContracts(t *testing.T) {
"default_concurrency": 0, "default_concurrency": 0,
"default_balance": 0, "default_balance": 0,
"affiliate_rebate_rate": 20, "affiliate_rebate_rate": 20,
"affiliate_rebate_freeze_hours": 0,
"affiliate_rebate_duration_days": 0,
"affiliate_rebate_per_invitee_cap": 0,
"default_user_rpm_limit": 0, "default_user_rpm_limit": 0,
"default_subscriptions": [], "default_subscriptions": [],
"enable_model_fallback": false, "enable_model_fallback": false,

View File

@@ -65,16 +65,18 @@ type AffiliateSummary struct {
InviterID *int64 `json:"inviter_id,omitempty"` InviterID *int64 `json:"inviter_id,omitempty"`
AffCount int `json:"aff_count"` AffCount int `json:"aff_count"`
AffQuota float64 `json:"aff_quota"` AffQuota float64 `json:"aff_quota"`
AffFrozenQuota float64 `json:"aff_frozen_quota"`
AffHistoryQuota float64 `json:"aff_history_quota"` AffHistoryQuota float64 `json:"aff_history_quota"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
type AffiliateInvitee struct { type AffiliateInvitee struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Email string `json:"email"` Email string `json:"email"`
Username string `json:"username"` Username string `json:"username"`
CreatedAt *time.Time `json:"created_at,omitempty"` CreatedAt *time.Time `json:"created_at,omitempty"`
TotalRebate float64 `json:"total_rebate"`
} }
type AffiliateDetail struct { type AffiliateDetail struct {
@@ -83,6 +85,7 @@ type AffiliateDetail struct {
InviterID *int64 `json:"inviter_id,omitempty"` InviterID *int64 `json:"inviter_id,omitempty"`
AffCount int `json:"aff_count"` AffCount int `json:"aff_count"`
AffQuota float64 `json:"aff_quota"` AffQuota float64 `json:"aff_quota"`
AffFrozenQuota float64 `json:"aff_frozen_quota"`
AffHistoryQuota float64 `json:"aff_history_quota"` AffHistoryQuota float64 `json:"aff_history_quota"`
// EffectiveRebateRatePercent 是当前用户作为邀请人时实际生效的返利比例: // EffectiveRebateRatePercent 是当前用户作为邀请人时实际生效的返利比例:
// 优先用户自己的专属比例aff_rebate_rate_percent否则回退到全局比例。 // 优先用户自己的专属比例aff_rebate_rate_percent否则回退到全局比例。
@@ -95,7 +98,9 @@ type AffiliateRepository interface {
EnsureUserAffiliate(ctx context.Context, userID int64) (*AffiliateSummary, error) EnsureUserAffiliate(ctx context.Context, userID int64) (*AffiliateSummary, error)
GetAffiliateByCode(ctx context.Context, code string) (*AffiliateSummary, error) GetAffiliateByCode(ctx context.Context, code string) (*AffiliateSummary, error)
BindInviter(ctx context.Context, userID, inviterID int64) (bool, error) BindInviter(ctx context.Context, userID, inviterID int64) (bool, error)
AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64) (bool, error) AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64, freezeHours int) (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) TransferQuotaToBalance(ctx context.Context, userID int64) (float64, float64, error)
ListInvitees(ctx context.Context, inviterID int64, limit int) ([]AffiliateInvitee, error) ListInvitees(ctx context.Context, inviterID int64, limit int) ([]AffiliateInvitee, error)
@@ -160,6 +165,12 @@ func (s *AffiliateService) EnsureUserAffiliate(ctx context.Context, userID int64
} }
func (s *AffiliateService) GetAffiliateDetail(ctx context.Context, userID int64) (*AffiliateDetail, error) { func (s *AffiliateService) GetAffiliateDetail(ctx context.Context, userID int64) (*AffiliateDetail, error) {
// Lazy thaw: move any matured frozen quota to available before reading.
if s != nil && s.repo != nil {
// best-effort: thaw failure is non-fatal
_, _ = s.repo.ThawFrozenQuota(ctx, userID)
}
summary, err := s.EnsureUserAffiliate(ctx, userID) summary, err := s.EnsureUserAffiliate(ctx, userID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -174,6 +185,7 @@ func (s *AffiliateService) GetAffiliateDetail(ctx context.Context, userID int64)
InviterID: summary.InviterID, InviterID: summary.InviterID,
AffCount: summary.AffCount, AffCount: summary.AffCount,
AffQuota: summary.AffQuota, AffQuota: summary.AffQuota,
AffFrozenQuota: summary.AffFrozenQuota,
AffHistoryQuota: summary.AffHistoryQuota, AffHistoryQuota: summary.AffHistoryQuota,
EffectiveRebateRatePercent: s.resolveRebateRatePercent(ctx, summary), EffectiveRebateRatePercent: s.resolveRebateRatePercent(ctx, summary),
Invitees: invitees, Invitees: invitees,
@@ -250,13 +262,43 @@ func (s *AffiliateService) AccrueInviteRebate(ctx context.Context, inviteeUserID
if err != nil { if err != nil {
return 0, err return 0, err
} }
// 有效期检查:超过返利有效期后不再产生返利
if s.settingService != nil {
if durationDays := s.settingService.GetAffiliateRebateDurationDays(ctx); durationDays > 0 {
if time.Now().After(inviteeSummary.CreatedAt.AddDate(0, 0, durationDays)) {
return 0, nil
}
}
}
rebateRatePercent := s.resolveRebateRatePercent(ctx, inviterSummary) rebateRatePercent := s.resolveRebateRatePercent(ctx, inviterSummary)
rebate := roundTo(baseRechargeAmount*(rebateRatePercent/100), 8) rebate := roundTo(baseRechargeAmount*(rebateRatePercent/100), 8)
if rebate <= 0 { if rebate <= 0 {
return 0, nil return 0, nil
} }
applied, err := s.repo.AccrueQuota(ctx, *inviteeSummary.InviterID, inviteeUserID, rebate) // 单人上限检查:精确截断到剩余额度
if s.settingService != nil {
if perInviteeCap := s.settingService.GetAffiliateRebatePerInviteeCap(ctx); perInviteeCap > 0 {
existing, err := s.repo.GetAccruedRebateFromInvitee(ctx, *inviteeSummary.InviterID, inviteeUserID)
if err != nil {
return 0, err
}
if existing >= perInviteeCap {
return 0, nil
}
if remaining := perInviteeCap - existing; rebate > remaining {
rebate = roundTo(remaining, 8)
}
}
}
var freezeHours int
if s.settingService != nil {
freezeHours = s.settingService.GetAffiliateRebateFreezeHours(ctx)
}
applied, err := s.repo.AccrueQuota(ctx, *inviteeSummary.InviterID, inviteeUserID, rebate, freezeHours)
if err != nil { if err != nil {
return 0, err return 0, err
} }

View File

@@ -175,6 +175,7 @@ func (s *AuthService) FinalizeOAuthEmailAccount(
user *User, user *User,
invitationCode string, invitationCode string,
signupSource string, signupSource string,
affiliateCode string,
) error { ) error {
if s == nil || user == nil || user.ID <= 0 { if s == nil || user == nil || user.ID <= 0 {
return ErrServiceUnavailable return ErrServiceUnavailable
@@ -194,6 +195,7 @@ func (s *AuthService) FinalizeOAuthEmailAccount(
s.updateOAuthSignupSource(ctx, user.ID, signupSource) s.updateOAuthSignupSource(ctx, user.ID, signupSource)
grantPlan := s.resolveSignupGrantPlan(ctx, signupSource) grantPlan := s.resolveSignupGrantPlan(ctx, signupSource)
s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults") s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults")
s.bindOAuthAffiliate(ctx, user.ID, affiliateCode)
return nil return nil
} }

View File

@@ -563,7 +563,8 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username
// LoginOrRegisterOAuthWithTokenPair 用于第三方 OAuth/SSO 登录,返回完整的 TokenPair。 // LoginOrRegisterOAuthWithTokenPair 用于第三方 OAuth/SSO 登录,返回完整的 TokenPair。
// 与 LoginOrRegisterOAuth 功能相同,但返回 TokenPair 而非单个 token。 // 与 LoginOrRegisterOAuth 功能相同,但返回 TokenPair 而非单个 token。
// invitationCode 仅在邀请码注册模式下新用户注册时使用;已有账号登录时忽略。 // invitationCode 仅在邀请码注册模式下新用户注册时使用;已有账号登录时忽略。
func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, email, username, invitationCode string) (*TokenPair, *User, error) { // affiliateCode 用于邀请返利绑定,仅在新用户注册时使用。
func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, email, username, invitationCode, affiliateCode string) (*TokenPair, *User, error) {
// 检查 refreshTokenCache 是否可用 // 检查 refreshTokenCache 是否可用
if s.refreshTokenCache == nil { if s.refreshTokenCache == nil {
return nil, nil, errors.New("refresh token cache not configured") return nil, nil, errors.New("refresh token cache not configured")
@@ -666,6 +667,7 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema
user = newUser user = newUser
s.postAuthUserBootstrap(ctx, user, signupSource, false) s.postAuthUserBootstrap(ctx, user, signupSource, false)
s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults") s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults")
s.bindOAuthAffiliate(ctx, user.ID, affiliateCode)
} }
} else { } else {
if err := s.userRepo.Create(ctx, newUser); err != nil { if err := s.userRepo.Create(ctx, newUser); err != nil {
@@ -683,6 +685,7 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema
user = newUser user = newUser
s.postAuthUserBootstrap(ctx, user, signupSource, false) s.postAuthUserBootstrap(ctx, user, signupSource, false)
s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults") s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults")
s.bindOAuthAffiliate(ctx, user.ID, affiliateCode)
if invitationRedeemCode != nil { if invitationRedeemCode != nil {
if err := s.redeemRepo.Use(ctx, invitationRedeemCode.ID, user.ID); err != nil { if err := s.redeemRepo.Use(ctx, invitationRedeemCode.ID, user.ID); err != nil {
return nil, nil, ErrInvitationCodeInvalid return nil, nil, ErrInvitationCodeInvalid
@@ -777,6 +780,22 @@ func authSourceSignupSettings(defaults *AuthSourceDefaultSettings, signupSource
} }
} }
// bindOAuthAffiliate initializes the affiliate profile and binds the inviter
// for an OAuth-registered user. Failures are logged but never block registration.
func (s *AuthService) bindOAuthAffiliate(ctx context.Context, userID int64, affiliateCode string) {
if s.affiliateService == nil || userID <= 0 {
return
}
if _, err := s.affiliateService.EnsureUserAffiliate(ctx, userID); err != nil {
logger.LegacyPrintf("service.auth", "[Auth] Failed to initialize affiliate profile for user %d: %v", userID, err)
}
if code := strings.TrimSpace(affiliateCode); code != "" {
if err := s.affiliateService.BindInviterByCode(ctx, userID, code); err != nil {
logger.LegacyPrintf("service.auth", "[Auth] Failed to bind affiliate inviter for user %d: %v", userID, err)
}
}
}
func (s *AuthService) postAuthUserBootstrap(ctx context.Context, user *User, signupSource string, touchLogin bool) { func (s *AuthService) postAuthUserBootstrap(ctx context.Context, user *User, signupSource string, touchLogin bool) {
if user == nil || user.ID <= 0 { if user == nil || user.ID <= 0 {
return return

View File

@@ -622,7 +622,7 @@ func TestAuthService_LoginOrRegisterOAuthWithTokenPair_UsesLinuxDoAuthSourceDefa
service.defaultSubAssigner = assigner service.defaultSubAssigner = assigner
service.refreshTokenCache = &refreshTokenCacheStub{} service.refreshTokenCache = &refreshTokenCacheStub{}
tokenPair, user, err := service.LoginOrRegisterOAuthWithTokenPair(context.Background(), "linuxdo-123@linuxdo-connect.invalid", "linuxdo_user", "") tokenPair, user, err := service.LoginOrRegisterOAuthWithTokenPair(context.Background(), "linuxdo-123@linuxdo-connect.invalid", "linuxdo_user", "", "")
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, tokenPair) require.NotNil(t, tokenPair)
require.NotNil(t, user) require.NotNil(t, user)
@@ -658,7 +658,7 @@ func TestAuthService_LoginOrRegisterOAuthWithTokenPair_ExistingUserDoesNotGrantA
service.defaultSubAssigner = assigner service.defaultSubAssigner = assigner
service.refreshTokenCache = &refreshTokenCacheStub{} service.refreshTokenCache = &refreshTokenCacheStub{}
tokenPair, user, err := service.LoginOrRegisterOAuthWithTokenPair(context.Background(), existing.Email, "linuxdo_user", "") tokenPair, user, err := service.LoginOrRegisterOAuthWithTokenPair(context.Background(), existing.Email, "linuxdo_user", "", "")
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, tokenPair) require.NotNil(t, tokenPair)
require.Equal(t, existing.ID, user.ID) require.Equal(t, existing.ID, user.ID)

View File

@@ -20,10 +20,15 @@ const (
// Affiliate rebate settings // Affiliate rebate settings
const ( const (
AffiliateRebateRateDefault = 20.0 AffiliateRebateRateDefault = 20.0
AffiliateRebateRateMin = 0.0 AffiliateRebateRateMin = 0.0
AffiliateRebateRateMax = 100.0 AffiliateRebateRateMax = 100.0
AffiliateEnabledDefault = false // 邀请返利总开关默认关闭 AffiliateEnabledDefault = false // 邀请返利总开关默认关闭
AffiliateRebateFreezeHoursDefault = 0 // 0 = 不冻结(向后兼容)
AffiliateRebateFreezeHoursMax = 720 // 最大 30 天
AffiliateRebateDurationDaysDefault = 0 // 0 = 永久有效
AffiliateRebateDurationDaysMax = 3650 // ~10 年
AffiliateRebatePerInviteeCapDefault = 0.0 // 0 = 无上限
) )
// Platform constants // Platform constants
@@ -97,6 +102,9 @@ const (
SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册 SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册
SettingKeyAffiliateEnabled = "affiliate_enabled" // 邀请返利功能总开关 SettingKeyAffiliateEnabled = "affiliate_enabled" // 邀请返利功能总开关
SettingKeyAffiliateRebateRate = "affiliate_rebate_rate" // 邀请返利比例百分比0-100 SettingKeyAffiliateRebateRate = "affiliate_rebate_rate" // 邀请返利比例百分比0-100
SettingKeyAffiliateRebateFreezeHours = "affiliate_rebate_freeze_hours" // 返利冻结期小时0=不冻结)
SettingKeyAffiliateRebateDurationDays = "affiliate_rebate_duration_days" // 返利有效期0=永久)
SettingKeyAffiliateRebatePerInviteeCap = "affiliate_rebate_per_invitee_cap" // 单人返利上限0=无上限)
// 邮件服务设置 // 邮件服务设置
SettingKeySMTPHost = "smtp_host" // SMTP服务器地址 SettingKeySMTPHost = "smtp_host" // SMTP服务器地址

View File

@@ -269,7 +269,9 @@ func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder) e
switch action { switch action {
case redeemActionSkipCompleted: case redeemActionSkipCompleted:
s.applyAffiliateRebateForOrder(ctx, o) if err := s.applyAffiliateRebateForOrder(ctx, o); err != nil {
return err
}
// Code already created and redeemed — just mark completed // Code already created and redeemed — just mark completed
return s.markCompleted(ctx, o, "RECHARGE_SUCCESS") return s.markCompleted(ctx, o, "RECHARGE_SUCCESS")
case redeemActionCreate: case redeemActionCreate:
@@ -283,7 +285,9 @@ func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder) e
if _, err := s.redeemService.Redeem(ctx, o.UserID, o.RechargeCode); err != nil { if _, err := s.redeemService.Redeem(ctx, o.UserID, o.RechargeCode); err != nil {
return fmt.Errorf("redeem balance: %w", err) return fmt.Errorf("redeem balance: %w", err)
} }
s.applyAffiliateRebateForOrder(ctx, o) if err := s.applyAffiliateRebateForOrder(ctx, o); err != nil {
return err
}
return s.markCompleted(ctx, o, "RECHARGE_SUCCESS") return s.markCompleted(ctx, o, "RECHARGE_SUCCESS")
} }
@@ -361,12 +365,12 @@ func (s *PaymentService) hasAuditLog(ctx context.Context, orderID int64, action
return c > 0 return c > 0
} }
func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *dbent.PaymentOrder) { func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *dbent.PaymentOrder) error {
if o == nil || o.OrderType != payment.OrderTypeBalance || o.Amount <= 0 { if o == nil || o.OrderType != payment.OrderTypeBalance || o.Amount <= 0 {
return return nil
} }
if s.affiliateService == nil { if s.affiliateService == nil {
return return nil
} }
tx, err := s.entClient.Tx(ctx) tx, err := s.entClient.Tx(ctx)
@@ -374,7 +378,7 @@ func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *db
s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{ s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
"error": fmt.Sprintf("begin affiliate rebate tx: %v", err), "error": fmt.Sprintf("begin affiliate rebate tx: %v", err),
}) })
return return fmt.Errorf("begin affiliate rebate tx: %w", err)
} }
defer func() { _ = tx.Rollback() }() defer func() { _ = tx.Rollback() }()
@@ -384,10 +388,10 @@ func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *db
s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{ s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
"error": err.Error(), "error": err.Error(),
}) })
return return fmt.Errorf("claim affiliate rebate audit: %w", err)
} }
if !claimed { if !claimed {
return return nil
} }
rebateAmount, err := s.affiliateService.AccrueInviteRebate(txCtx, o.UserID, o.Amount) rebateAmount, err := s.affiliateService.AccrueInviteRebate(txCtx, o.UserID, o.Amount)
@@ -395,7 +399,7 @@ func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *db
s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{ s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
"error": err.Error(), "error": err.Error(),
}) })
return return fmt.Errorf("accrue affiliate rebate: %w", err)
} }
if rebateAmount <= 0 { if rebateAmount <= 0 {
@@ -406,14 +410,15 @@ func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *db
s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{ s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
"error": err.Error(), "error": err.Error(),
}) })
return return fmt.Errorf("update affiliate rebate skipped audit: %w", err)
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{ s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
"error": fmt.Sprintf("commit affiliate rebate tx: %v", err), "error": fmt.Sprintf("commit affiliate rebate tx: %v", err),
}) })
return fmt.Errorf("commit affiliate rebate tx: %w", err)
} }
return return nil
} }
if err := s.updateClaimedAffiliateRebateAudit(txCtx, tx.Client(), o.ID, "AFFILIATE_REBATE_APPLIED", map[string]any{ if err := s.updateClaimedAffiliateRebateAudit(txCtx, tx.Client(), o.ID, "AFFILIATE_REBATE_APPLIED", map[string]any{
@@ -423,14 +428,16 @@ func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *db
s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{ s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
"error": err.Error(), "error": err.Error(),
}) })
return return fmt.Errorf("update affiliate rebate applied audit: %w", err)
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{ s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
"error": fmt.Sprintf("commit affiliate rebate tx: %v", err), "error": fmt.Sprintf("commit affiliate rebate tx: %v", err),
}) })
return fmt.Errorf("commit affiliate rebate tx: %w", err)
} }
return nil
} }
func (s *PaymentService) tryClaimAffiliateRebateAudit(ctx context.Context, client *dbent.Client, orderID int64, baseAmount float64) (bool, error) { func (s *PaymentService) tryClaimAffiliateRebateAudit(ctx context.Context, client *dbent.Client, orderID int64, baseAmount float64) (bool, error) {
@@ -444,11 +451,11 @@ func (s *PaymentService) tryClaimAffiliateRebateAudit(ctx context.Context, clien
}) })
rows, err := client.QueryContext(ctx, ` rows, err := client.QueryContext(ctx, `
INSERT INTO payment_audit_logs (order_id, action, detail, operator, created_at) INSERT INTO payment_audit_logs (order_id, action, detail, operator, created_at)
SELECT $1, 'AFFILIATE_REBATE_APPLIED', $2, 'system', NOW() SELECT $1::text, 'AFFILIATE_REBATE_APPLIED', $2::text, 'system', NOW()
WHERE NOT EXISTS ( WHERE NOT EXISTS (
SELECT 1 SELECT 1
FROM payment_audit_logs FROM payment_audit_logs
WHERE order_id = $1 WHERE order_id = $1::text
AND action IN ('AFFILIATE_REBATE_APPLIED', 'AFFILIATE_REBATE_SKIPPED') AND action IN ('AFFILIATE_REBATE_APPLIED', 'AFFILIATE_REBATE_SKIPPED')
) )
ON CONFLICT (order_id, action) DO NOTHING ON CONFLICT (order_id, action) DO NOTHING

View File

@@ -1175,6 +1175,24 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
updates[SettingKeyDefaultBalance] = strconv.FormatFloat(settings.DefaultBalance, 'f', 8, 64) updates[SettingKeyDefaultBalance] = strconv.FormatFloat(settings.DefaultBalance, 'f', 8, 64)
settings.AffiliateRebateRate = clampAffiliateRebateRate(settings.AffiliateRebateRate) settings.AffiliateRebateRate = clampAffiliateRebateRate(settings.AffiliateRebateRate)
updates[SettingKeyAffiliateRebateRate] = strconv.FormatFloat(settings.AffiliateRebateRate, 'f', 8, 64) updates[SettingKeyAffiliateRebateRate] = strconv.FormatFloat(settings.AffiliateRebateRate, 'f', 8, 64)
if settings.AffiliateRebateFreezeHours < 0 {
settings.AffiliateRebateFreezeHours = AffiliateRebateFreezeHoursDefault
}
if settings.AffiliateRebateFreezeHours > AffiliateRebateFreezeHoursMax {
settings.AffiliateRebateFreezeHours = AffiliateRebateFreezeHoursMax
}
updates[SettingKeyAffiliateRebateFreezeHours] = strconv.Itoa(settings.AffiliateRebateFreezeHours)
if settings.AffiliateRebateDurationDays < 0 {
settings.AffiliateRebateDurationDays = AffiliateRebateDurationDaysDefault
}
if settings.AffiliateRebateDurationDays > AffiliateRebateDurationDaysMax {
settings.AffiliateRebateDurationDays = AffiliateRebateDurationDaysMax
}
updates[SettingKeyAffiliateRebateDurationDays] = strconv.Itoa(settings.AffiliateRebateDurationDays)
if settings.AffiliateRebatePerInviteeCap < 0 {
settings.AffiliateRebatePerInviteeCap = AffiliateRebatePerInviteeCapDefault
}
updates[SettingKeyAffiliateRebatePerInviteeCap] = strconv.FormatFloat(settings.AffiliateRebatePerInviteeCap, 'f', 8, 64)
updates[SettingKeyDefaultUserRPMLimit] = strconv.Itoa(settings.DefaultUserRPMLimit) updates[SettingKeyDefaultUserRPMLimit] = strconv.Itoa(settings.DefaultUserRPMLimit)
defaultSubsJSON, err := json.Marshal(settings.DefaultSubscriptions) defaultSubsJSON, err := json.Marshal(settings.DefaultSubscriptions)
if err != nil { if err != nil {
@@ -1512,6 +1530,54 @@ func (s *SettingService) GetAffiliateRebateRatePercent(ctx context.Context) floa
return clampAffiliateRebateRate(rate) return clampAffiliateRebateRate(rate)
} }
// GetAffiliateRebateFreezeHours 返回返利冻结期(小时)。
// 返回 0 表示不冻结(向后兼容)。
func (s *SettingService) GetAffiliateRebateFreezeHours(ctx context.Context) int {
raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateFreezeHours)
if err != nil {
return AffiliateRebateFreezeHoursDefault
}
hours, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || hours < 0 {
return AffiliateRebateFreezeHoursDefault
}
if hours > AffiliateRebateFreezeHoursMax {
return AffiliateRebateFreezeHoursMax
}
return hours
}
// GetAffiliateRebateDurationDays 返回返利有效期(天)。
// 返回 0 表示永久有效。
func (s *SettingService) GetAffiliateRebateDurationDays(ctx context.Context) int {
raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateDurationDays)
if err != nil {
return AffiliateRebateDurationDaysDefault
}
days, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || days < 0 {
return AffiliateRebateDurationDaysDefault
}
if days > AffiliateRebateDurationDaysMax {
return AffiliateRebateDurationDaysMax
}
return days
}
// GetAffiliateRebatePerInviteeCap 返回单人返利上限。
// 返回 0 表示无上限。
func (s *SettingService) GetAffiliateRebatePerInviteeCap(ctx context.Context) float64 {
raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebatePerInviteeCap)
if err != nil {
return AffiliateRebatePerInviteeCapDefault
}
cap, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
if err != nil || cap < 0 || math.IsNaN(cap) || math.IsInf(cap, 0) {
return AffiliateRebatePerInviteeCapDefault
}
return cap
}
// IsPasswordResetEnabled 检查是否启用密码重置功能 // IsPasswordResetEnabled 检查是否启用密码重置功能
// 要求:必须同时开启邮件验证 // 要求:必须同时开启邮件验证
func (s *SettingService) IsPasswordResetEnabled(ctx context.Context) bool { func (s *SettingService) IsPasswordResetEnabled(ctx context.Context) bool {
@@ -1755,6 +1821,9 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency), SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64), SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
SettingKeyAffiliateRebateRate: strconv.FormatFloat(AffiliateRebateRateDefault, 'f', 8, 64), SettingKeyAffiliateRebateRate: strconv.FormatFloat(AffiliateRebateRateDefault, 'f', 8, 64),
SettingKeyAffiliateRebateFreezeHours: strconv.Itoa(AffiliateRebateFreezeHoursDefault),
SettingKeyAffiliateRebateDurationDays: strconv.Itoa(AffiliateRebateDurationDaysDefault),
SettingKeyAffiliateRebatePerInviteeCap: strconv.FormatFloat(AffiliateRebatePerInviteeCapDefault, 'f', 2, 64),
SettingKeyDefaultUserRPMLimit: "0", SettingKeyDefaultUserRPMLimit: "0",
SettingKeyDefaultSubscriptions: "[]", SettingKeyDefaultSubscriptions: "[]",
SettingKeyAuthSourceDefaultEmailBalance: "0", SettingKeyAuthSourceDefaultEmailBalance: "0",
@@ -1890,6 +1959,21 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
} else { } else {
result.AffiliateRebateRate = AffiliateRebateRateDefault result.AffiliateRebateRate = AffiliateRebateRateDefault
} }
if freezeHours, err := strconv.Atoi(settings[SettingKeyAffiliateRebateFreezeHours]); err == nil && freezeHours >= 0 {
if freezeHours > AffiliateRebateFreezeHoursMax {
freezeHours = AffiliateRebateFreezeHoursMax
}
result.AffiliateRebateFreezeHours = freezeHours
}
if durationDays, err := strconv.Atoi(settings[SettingKeyAffiliateRebateDurationDays]); err == nil && durationDays >= 0 {
if durationDays > AffiliateRebateDurationDaysMax {
durationDays = AffiliateRebateDurationDaysMax
}
result.AffiliateRebateDurationDays = durationDays
}
if perInviteeCap, err := strconv.ParseFloat(settings[SettingKeyAffiliateRebatePerInviteeCap], 64); err == nil && perInviteeCap >= 0 {
result.AffiliateRebatePerInviteeCap = perInviteeCap
}
result.DefaultSubscriptions = parseDefaultSubscriptions(settings[SettingKeyDefaultSubscriptions]) result.DefaultSubscriptions = parseDefaultSubscriptions(settings[SettingKeyDefaultSubscriptions])
// 敏感信息直接返回,方便测试连接时使用 // 敏感信息直接返回,方便测试连接时使用

View File

@@ -104,12 +104,15 @@ type SystemSettings struct {
CustomMenuItems string // JSON array of custom menu items CustomMenuItems string // JSON array of custom menu items
CustomEndpoints string // JSON array of custom endpoints CustomEndpoints string // JSON array of custom endpoints
DefaultConcurrency int DefaultConcurrency int
DefaultBalance float64 DefaultBalance float64
AffiliateEnabled bool AffiliateEnabled bool
AffiliateRebateRate float64 AffiliateRebateRate float64
DefaultUserRPMLimit int AffiliateRebateFreezeHours int
DefaultSubscriptions []DefaultSubscriptionSetting AffiliateRebateDurationDays int
AffiliateRebatePerInviteeCap float64
DefaultUserRPMLimit int
DefaultSubscriptions []DefaultSubscriptionSetting
// Model fallback configuration // Model fallback configuration
EnableModelFallback bool `json:"enable_model_fallback"` EnableModelFallback bool `json:"enable_model_fallback"`

View File

@@ -0,0 +1,17 @@
-- 1) Add frozen quota column to user_affiliates for rebate freeze period.
ALTER TABLE user_affiliates
ADD COLUMN IF NOT EXISTS aff_frozen_quota DECIMAL(20,8) NOT NULL DEFAULT 0;
COMMENT ON COLUMN user_affiliates.aff_frozen_quota IS 'Rebate quota currently frozen (pending thaw after freeze period)';
-- 2) Add frozen_until column to user_affiliate_ledger for per-entry freeze tracking.
-- NULL = no freeze (or already thawed); non-NULL = frozen until this timestamp.
ALTER TABLE user_affiliate_ledger
ADD COLUMN IF NOT EXISTS frozen_until TIMESTAMPTZ NULL;
COMMENT ON COLUMN user_affiliate_ledger.frozen_until IS 'Rebate frozen until this time; NULL means already thawed or never frozen';
-- 3) Partial index for efficient thaw queries (only rows still frozen).
CREATE INDEX IF NOT EXISTS idx_ual_frozen_thaw
ON user_affiliate_ledger (user_id, frozen_until)
WHERE frozen_until IS NOT NULL;

View File

@@ -74,6 +74,26 @@ describe('oauth adoption auth api', () => {
}) })
}) })
it('posts affiliate code when completing linuxdo oauth registration', async () => {
const { completeLinuxDoOAuthRegistration } = await import('@/api/auth')
await completeLinuxDoOAuthRegistration(
'invite-code',
{
adoptDisplayName: true,
adoptAvatar: false
},
' AFF123 '
)
expect(post).toHaveBeenCalledWith('/auth/oauth/linuxdo/complete-registration', {
invitation_code: 'invite-code',
aff_code: 'AFF123',
adopt_display_name: true,
adopt_avatar: false
})
})
it('posts oidc invitation completion with adoption decisions', async () => { it('posts oidc invitation completion with adoption decisions', async () => {
const { completeOIDCOAuthRegistration } = await import('@/api/auth') const { completeOIDCOAuthRegistration } = await import('@/api/auth')
@@ -134,6 +154,26 @@ describe('oauth adoption auth api', () => {
}) })
}) })
it('posts affiliate code when creating pending wechat oauth account', async () => {
const { createPendingWeChatOAuthAccount } = await import('@/api/auth')
await createPendingWeChatOAuthAccount(
'invite-code',
{
adoptDisplayName: false,
adoptAvatar: true
},
'WXAFF'
)
expect(post).toHaveBeenCalledWith('/auth/oauth/wechat/complete-registration', {
invitation_code: 'invite-code',
aff_code: 'WXAFF',
adopt_display_name: false,
adopt_avatar: true
})
})
it('classifies oauth completion results as login or bind', async () => { it('classifies oauth completion results as login or bind', async () => {
const { getOAuthCompletionKind } = await import('@/api/auth') const { getOAuthCompletionKind } = await import('@/api/auth')

View File

@@ -309,6 +309,9 @@ export interface SystemSettings {
// Default settings // Default settings
default_balance: number; default_balance: number;
affiliate_rebate_rate: number; affiliate_rebate_rate: number;
affiliate_rebate_freeze_hours: number;
affiliate_rebate_duration_days: number;
affiliate_rebate_per_invitee_cap: number;
default_concurrency: number; default_concurrency: number;
default_user_rpm_limit: number; default_user_rpm_limit: number;
default_subscriptions: DefaultSubscriptionSetting[]; default_subscriptions: DefaultSubscriptionSetting[];
@@ -494,6 +497,9 @@ export interface UpdateSettingsRequest {
totp_enabled?: boolean; // TOTP 双因素认证 totp_enabled?: boolean; // TOTP 双因素认证
default_balance?: number; default_balance?: number;
affiliate_rebate_rate?: number; affiliate_rebate_rate?: number;
affiliate_rebate_freeze_hours?: number;
affiliate_rebate_duration_days?: number;
affiliate_rebate_per_invitee_cap?: number;
default_concurrency?: number; default_concurrency?: number;
default_user_rpm_limit?: number; default_user_rpm_limit?: number;
default_subscriptions?: DefaultSubscriptionSetting[]; default_subscriptions?: DefaultSubscriptionSetting[];

View File

@@ -564,9 +564,10 @@ export async function resetPassword(request: ResetPasswordRequest): Promise<Rese
*/ */
export async function completeLinuxDoOAuthRegistration( export async function completeLinuxDoOAuthRegistration(
invitationCode: string, invitationCode: string,
decision?: OAuthAdoptionDecision decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<OAuthTokenResponse> { ): Promise<OAuthTokenResponse> {
return createPendingLinuxDoOAuthAccount(invitationCode, decision) return createPendingLinuxDoOAuthAccount(invitationCode, decision, affiliateCode)
} }
/** /**
@@ -576,27 +577,32 @@ export async function completeLinuxDoOAuthRegistration(
*/ */
export async function completeOIDCOAuthRegistration( export async function completeOIDCOAuthRegistration(
invitationCode: string, invitationCode: string,
decision?: OAuthAdoptionDecision decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<OAuthTokenResponse> { ): Promise<OAuthTokenResponse> {
return createPendingOIDCOAuthAccount(invitationCode, decision) return createPendingOIDCOAuthAccount(invitationCode, decision, affiliateCode)
} }
export async function completeWeChatOAuthRegistration( export async function completeWeChatOAuthRegistration(
invitationCode: string, invitationCode: string,
decision?: OAuthAdoptionDecision decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<OAuthTokenResponse> { ): Promise<OAuthTokenResponse> {
return createPendingWeChatOAuthAccount(invitationCode, decision) return createPendingWeChatOAuthAccount(invitationCode, decision, affiliateCode)
} }
async function createPendingOAuthAccount( async function createPendingOAuthAccount(
provider: 'linuxdo' | 'oidc' | 'wechat', provider: 'linuxdo' | 'oidc' | 'wechat',
invitationCode: string, invitationCode: string,
decision?: OAuthAdoptionDecision decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> { ): Promise<PendingOAuthCreateAccountResponse> {
const normalizedAffiliateCode = affiliateCode?.trim()
const { data } = await apiClient.post<PendingOAuthCreateAccountResponse>( const { data } = await apiClient.post<PendingOAuthCreateAccountResponse>(
`/auth/oauth/${provider}/complete-registration`, `/auth/oauth/${provider}/complete-registration`,
{ {
invitation_code: invitationCode, invitation_code: invitationCode,
...(normalizedAffiliateCode ? { aff_code: normalizedAffiliateCode } : {}),
...serializeOAuthAdoptionDecision(decision) ...serializeOAuthAdoptionDecision(decision)
} }
) )
@@ -605,23 +611,26 @@ async function createPendingOAuthAccount(
export async function createPendingLinuxDoOAuthAccount( export async function createPendingLinuxDoOAuthAccount(
invitationCode: string, invitationCode: string,
decision?: OAuthAdoptionDecision decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> { ): Promise<PendingOAuthCreateAccountResponse> {
return createPendingOAuthAccount('linuxdo', invitationCode, decision) return createPendingOAuthAccount('linuxdo', invitationCode, decision, affiliateCode)
} }
export async function createPendingOIDCOAuthAccount( export async function createPendingOIDCOAuthAccount(
invitationCode: string, invitationCode: string,
decision?: OAuthAdoptionDecision decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> { ): Promise<PendingOAuthCreateAccountResponse> {
return createPendingOAuthAccount('oidc', invitationCode, decision) return createPendingOAuthAccount('oidc', invitationCode, decision, affiliateCode)
} }
export async function createPendingWeChatOAuthAccount( export async function createPendingWeChatOAuthAccount(
invitationCode: string, invitationCode: string,
decision?: OAuthAdoptionDecision decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> { ): Promise<PendingOAuthCreateAccountResponse> {
return createPendingOAuthAccount('wechat', invitationCode, decision) return createPendingOAuthAccount('wechat', invitationCode, decision, affiliateCode)
} }
export async function completePendingOAuthBindLogin( export async function completePendingOAuthBindLogin(

View File

@@ -42,9 +42,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
withDefaults(defineProps<{ const props = withDefaults(defineProps<{
disabled?: boolean disabled?: boolean
affCode?: string
showDivider?: boolean showDivider?: boolean
}>(), { }>(), {
showDivider: true showDivider: true
@@ -55,6 +57,7 @@ const { t } = useI18n()
function startLogin(): void { function startLogin(): void {
const redirectTo = (route.query.redirect as string) || '/dashboard' const redirectTo = (route.query.redirect as string) || '/dashboard'
storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code))
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1' const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '') const normalized = apiBase.replace(/\/$/, '')
const startURL = `${normalized}/auth/oauth/linuxdo/start?redirect=${encodeURIComponent(redirectTo)}` const startURL = `${normalized}/auth/oauth/linuxdo/start?redirect=${encodeURIComponent(redirectTo)}`

View File

@@ -23,9 +23,11 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
disabled?: boolean disabled?: boolean
affCode?: string
providerName?: string providerName?: string
showDivider?: boolean showDivider?: boolean
}>(), { }>(), {
@@ -45,6 +47,7 @@ const providerInitial = computed(() => normalizedProviderName.value.charAt(0).to
function startLogin(): void { function startLogin(): void {
const redirectTo = (route.query.redirect as string) || '/dashboard' const redirectTo = (route.query.redirect as string) || '/dashboard'
storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code))
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1' const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '') const normalized = apiBase.replace(/\/$/, '')
const startURL = `${normalized}/auth/oauth/oidc/start?redirect=${encodeURIComponent(redirectTo)}` const startURL = `${normalized}/auth/oauth/oidc/start?redirect=${encodeURIComponent(redirectTo)}`

View File

@@ -33,9 +33,11 @@ import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { resolveWeChatOAuthStart } from '@/api/auth' import { resolveWeChatOAuthStart } from '@/api/auth'
import { useAppStore } from '@/stores' import { useAppStore } from '@/stores'
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
disabled?: boolean disabled?: boolean
affCode?: string
showDivider?: boolean showDivider?: boolean
}>(), { }>(), {
showDivider: true, showDivider: true,
@@ -84,6 +86,7 @@ function startLogin(): void {
return return
} }
const redirectTo = (route.query.redirect as string) || '/dashboard' const redirectTo = (route.query.redirect as string) || '/dashboard'
storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code))
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1' const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '') const normalized = apiBase.replace(/\/$/, '')
const mode = resolvedStart.value.mode const mode = resolvedStart.value.mode

View File

@@ -989,6 +989,8 @@ export default {
rebateRateHint: 'What you earn each time an invitee recharges', rebateRateHint: 'What you earn each time an invitee recharges',
invitedUsers: 'Invited Users', invitedUsers: 'Invited Users',
availableQuota: 'Available Rebate Quota', availableQuota: 'Available Rebate Quota',
frozenQuota: 'Frozen',
frozenQuotaHint: 'Recently earned rebates pending release',
totalQuota: 'Historical Rebate Quota' totalQuota: 'Historical Rebate Quota'
}, },
transfer: { transfer: {
@@ -1005,6 +1007,7 @@ export default {
columns: { columns: {
email: 'Email', email: 'Email',
username: 'Username', username: 'Username',
rebate: 'Rebate',
joinedAt: 'Joined At' joinedAt: 'Joined At'
} }
}, },
@@ -1012,7 +1015,8 @@ export default {
title: 'How It Works', title: 'How It Works',
line1: 'Share your affiliate code or invite link with new users.', line1: 'Share your affiliate code or invite link with new users.',
line2: 'When invitees recharge, you receive {rate} of the recharge as rebate quota.', line2: 'When invitees recharge, you receive {rate} of the recharge as rebate quota.',
line3: 'Transfer rebate quota to balance at any time.' line3: 'Transfer rebate quota to balance at any time.',
line4: 'Newly earned rebates may have a waiting period before they can be transferred.'
} }
}, },
@@ -4788,6 +4792,12 @@ export default {
enabledHint: 'When off, the affiliate menu is hidden, the aff parameter is ignored at signup, and new recharges generate no rebate. Existing rebate balances can still be transferred.', enabledHint: 'When off, the affiliate menu is hidden, the aff parameter is ignored at signup, and new recharges generate no rebate. Existing rebate balances can still be transferred.',
rebateRate: 'Global Rebate Rate', rebateRate: 'Global Rebate Rate',
rebateRateHint: 'Default percentage given back to the inviter on recharges (0-100, e.g. 10 = 10%).', rebateRateHint: 'Default percentage given back to the inviter on recharges (0-100, e.g. 10 = 10%).',
freezeHours: 'Rebate Freeze Period (hours)',
freezeHoursDesc: 'New rebates will be frozen for this period before becoming available for withdrawal. 0 = no freeze.',
durationDays: 'Rebate Duration (days)',
durationDaysDesc: 'Rebate relationship expires after this many days since invitee registration. 0 = permanent.',
perInviteeCap: 'Per-Invitee Rebate Cap',
perInviteeCapDesc: 'Maximum total rebate from a single invitee. 0 = no limit.',
customUsers: { customUsers: {
title: 'Per-User Overrides', title: 'Per-User Overrides',
description: 'Set a custom invite code or exclusive rebate rate for specific users. Lists only users that have an override applied.', description: 'Set a custom invite code or exclusive rebate rate for specific users. Lists only users that have an override applied.',

View File

@@ -993,6 +993,8 @@ export default {
rebateRateHint: '被邀请用户每次充值后你可获得的返利比例', rebateRateHint: '被邀请用户每次充值后你可获得的返利比例',
invitedUsers: '邀请人数', invitedUsers: '邀请人数',
availableQuota: '可转返利额度', availableQuota: '可转返利额度',
frozenQuota: '冻结中',
frozenQuotaHint: '新产生的返利正在冻结期中',
totalQuota: '历史返利额度' totalQuota: '历史返利额度'
}, },
transfer: { transfer: {
@@ -1009,6 +1011,7 @@ export default {
columns: { columns: {
email: '邮箱', email: '邮箱',
username: '用户名', username: '用户名',
rebate: '返利明细',
joinedAt: '注册时间' joinedAt: '注册时间'
} }
}, },
@@ -1016,7 +1019,8 @@ export default {
title: '使用说明', title: '使用说明',
line1: '将邀请码或邀请链接分享给新用户。', line1: '将邀请码或邀请链接分享给新用户。',
line2: '被邀请用户充值后,你可获得 {rate} 的返利额度。', line2: '被邀请用户充值后,你可获得 {rate} 的返利额度。',
line3: '返利额度可随时转入账户余额。' line3: '返利额度可随时转入账户余额。',
line4: '新产生的返利需要经过冻结期后才能提现。'
} }
}, },
@@ -4951,6 +4955,12 @@ export default {
enabledHint: '关闭后用户菜单中的邀请页面入口隐藏、注册时忽略邀请码、新充值不再产生返利。已有返利额度仍可转入余额。', enabledHint: '关闭后用户菜单中的邀请页面入口隐藏、注册时忽略邀请码、新充值不再产生返利。已有返利额度仍可转入余额。',
rebateRate: '全局返利比例', rebateRate: '全局返利比例',
rebateRateHint: '充值后返给邀请人的默认比例0-100%,例如填写 10 表示返利 10%)。', rebateRateHint: '充值后返给邀请人的默认比例0-100%,例如填写 10 表示返利 10%)。',
freezeHours: '返利冻结期(小时)',
freezeHoursDesc: '新产生的返利将在冻结期内无法提现。0 = 不冻结。',
durationDays: '返利有效期(天)',
durationDaysDesc: '被邀请用户注册后多少天内的充值产生返利。0 = 永久有效。',
perInviteeCap: '单人返利上限',
perInviteeCapDesc: '每个被邀请用户最多产生的返利总额。0 = 无上限。',
customUsers: { customUsers: {
title: '专属用户配置', title: '专属用户配置',
description: '为指定用户设置专属邀请码或专属返利比例。仅展示已设置过专属配置的用户。', description: '为指定用户设置专属邀请码或专属返利比例。仅展示已设置过专属配置的用户。',

View File

@@ -130,6 +130,7 @@ export interface AffiliateInvitee {
email: string email: string
username: string username: string
created_at?: string created_at?: string
total_rebate: number
} }
export interface UserAffiliateDetail { export interface UserAffiliateDetail {
@@ -138,6 +139,7 @@ export interface UserAffiliateDetail {
inviter_id?: number | null inviter_id?: number | null
aff_count: number aff_count: number
aff_quota: number aff_quota: number
aff_frozen_quota: number
aff_history_quota: number aff_history_quota: number
/** 当前用户作为邀请人时实际生效的返利比例专属覆盖全局。0-100。 */ /** 当前用户作为邀请人时实际生效的返利比例专属覆盖全局。0-100。 */
effective_rebate_rate_percent: number effective_rebate_rate_percent: number

View File

@@ -0,0 +1,48 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
clearAffiliateReferralCode,
clearOAuthAffiliateCode,
loadAffiliateReferralCode,
loadOAuthAffiliateCode,
resolveAffiliateReferralCode,
storeAffiliateReferralCode,
storeOAuthAffiliateCode
} from '@/utils/oauthAffiliate'
describe('oauthAffiliate', () => {
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
vi.useRealTimers()
})
it('persists affiliate referral code across pages', () => {
expect(resolveAffiliateReferralCode(' 5579J7CFG9PF ')).toBe('5579J7CFG9PF')
expect(loadAffiliateReferralCode()).toBe('5579J7CFG9PF')
expect(resolveAffiliateReferralCode()).toBe('5579J7CFG9PF')
})
it('expires stale affiliate referral code', () => {
const now = Date.UTC(2026, 0, 1)
storeAffiliateReferralCode('AFF123', now)
expect(loadAffiliateReferralCode(now + 30 * 24 * 60 * 60 * 1000 - 1)).toBe('AFF123')
expect(loadAffiliateReferralCode(now + 30 * 24 * 60 * 60 * 1000 + 1)).toBe('')
expect(localStorage.getItem('affiliate_referral_code')).toBeNull()
})
it('keeps oauth transient code separate from persistent referral code', () => {
storeAffiliateReferralCode('PERSISTED')
storeOAuthAffiliateCode('OAUTH')
expect(loadAffiliateReferralCode()).toBe('PERSISTED')
expect(loadOAuthAffiliateCode()).toBe('OAUTH')
clearOAuthAffiliateCode()
expect(loadOAuthAffiliateCode()).toBe('')
expect(loadAffiliateReferralCode()).toBe('PERSISTED')
clearAffiliateReferralCode()
expect(loadAffiliateReferralCode()).toBe('')
})
})

View File

@@ -0,0 +1,133 @@
const OAUTH_AFFILIATE_CODE_KEY = 'oauth_aff_code'
const AFFILIATE_REFERRAL_CODE_KEY = 'affiliate_referral_code'
const AFFILIATE_REFERRAL_TTL_MS = 30 * 24 * 60 * 60 * 1000
interface StoredAffiliateReferralCode {
code: string
expiresAt: number
}
export function normalizeOAuthAffiliateCode(value?: unknown): string {
const raw = Array.isArray(value) ? value[0] : value
return typeof raw === 'string' ? raw.trim() : ''
}
export function pickOAuthAffiliateCode(...values: unknown[]): string {
for (const value of values) {
const code = normalizeOAuthAffiliateCode(value)
if (code) {
return code
}
}
return ''
}
export function storeAffiliateReferralCode(value?: unknown, now = Date.now()): void {
if (typeof window === 'undefined') {
return
}
const code = normalizeOAuthAffiliateCode(value)
if (!code) {
return
}
try {
const payload: StoredAffiliateReferralCode = {
code,
expiresAt: now + AFFILIATE_REFERRAL_TTL_MS
}
window.localStorage.setItem(AFFILIATE_REFERRAL_CODE_KEY, JSON.stringify(payload))
} catch {
// 忽略浏览器存储异常。
}
}
export function loadAffiliateReferralCode(now = Date.now()): string {
if (typeof window === 'undefined') {
return ''
}
try {
const raw = window.localStorage.getItem(AFFILIATE_REFERRAL_CODE_KEY)
if (!raw) {
return ''
}
const parsed = JSON.parse(raw) as Partial<StoredAffiliateReferralCode>
const code = normalizeOAuthAffiliateCode(parsed.code)
const expiresAt = Number(parsed.expiresAt) || 0
if (!code || expiresAt <= now) {
clearAffiliateReferralCode()
return ''
}
return code
} catch {
clearAffiliateReferralCode()
return ''
}
}
export function clearAffiliateReferralCode(): void {
if (typeof window === 'undefined') {
return
}
try {
window.localStorage.removeItem(AFFILIATE_REFERRAL_CODE_KEY)
} catch {
// 忽略浏览器存储异常。
}
}
export function resolveAffiliateReferralCode(...values: unknown[]): string {
const code = pickOAuthAffiliateCode(...values)
if (code) {
storeAffiliateReferralCode(code)
return code
}
return loadAffiliateReferralCode()
}
export function storeOAuthAffiliateCode(value?: unknown): void {
if (typeof window === 'undefined') {
return
}
const code = normalizeOAuthAffiliateCode(value)
try {
if (code) {
window.sessionStorage.setItem(OAUTH_AFFILIATE_CODE_KEY, code)
} else {
window.sessionStorage.removeItem(OAUTH_AFFILIATE_CODE_KEY)
}
} catch {
// 忽略浏览器存储异常。
}
}
export function loadOAuthAffiliateCode(): string {
if (typeof window === 'undefined') {
return ''
}
try {
return normalizeOAuthAffiliateCode(window.sessionStorage.getItem(OAUTH_AFFILIATE_CODE_KEY))
} catch {
return ''
}
}
export function clearOAuthAffiliateCode(): void {
if (typeof window === 'undefined') {
return
}
try {
window.sessionStorage.removeItem(OAUTH_AFFILIATE_CODE_KEY)
} catch {
// 忽略浏览器存储异常。
}
}
export function clearAllAffiliateReferralCodes(): void {
clearOAuthAffiliateCode()
clearAffiliateReferralCode()
}
export function oauthAffiliatePayload(value?: unknown): { aff_code?: string } {
const code = normalizeOAuthAffiliateCode(value)
return code ? { aff_code: code } : {}
}

View File

@@ -3898,6 +3898,56 @@
</p> </p>
</div> </div>
<div>
<label class="input-label">
{{ t('admin.settings.features.affiliate.freezeHours') }}
</label>
<input
v-model.number="form.affiliate_rebate_freeze_hours"
type="number"
step="1"
min="0"
max="720"
class="input"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.freezeHoursDesc') }}
</p>
</div>
<div>
<label class="input-label">
{{ t('admin.settings.features.affiliate.durationDays') }}
</label>
<input
v-model.number="form.affiliate_rebate_duration_days"
type="number"
step="1"
min="0"
max="3650"
class="input"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.durationDaysDesc') }}
</p>
</div>
<div>
<label class="input-label">
{{ t('admin.settings.features.affiliate.perInviteeCap') }}
</label>
<input
v-model.number="form.affiliate_rebate_per_invitee_cap"
type="number"
step="0.01"
min="0"
class="input"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.perInviteeCapDesc') }}
</p>
</div>
<!-- 专属用户管理 --> <!-- 专属用户管理 -->
<div class="border-t border-gray-100 pt-6 dark:border-dark-700"> <div class="border-t border-gray-100 pt-6 dark:border-dark-700">
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
@@ -5333,6 +5383,9 @@ const form = reactive<SettingsForm>({
totp_encryption_key_configured: false, totp_encryption_key_configured: false,
default_balance: 0, default_balance: 0,
affiliate_rebate_rate: 20, affiliate_rebate_rate: 20,
affiliate_rebate_freeze_hours: 0,
affiliate_rebate_duration_days: 0,
affiliate_rebate_per_invitee_cap: 0,
default_concurrency: 1, default_concurrency: 1,
default_subscriptions: [], default_subscriptions: [],
force_email_on_third_party_signup: false, force_email_on_third_party_signup: false,
@@ -6261,6 +6314,9 @@ async function saveSettings() {
100, 100,
Math.max(0, Number(form.affiliate_rebate_rate) || 0), Math.max(0, Number(form.affiliate_rebate_rate) || 0),
), ),
affiliate_rebate_freeze_hours: Math.max(0, Math.min(720, Number(form.affiliate_rebate_freeze_hours) || 0)),
affiliate_rebate_duration_days: Math.max(0, Math.min(3650, Math.floor(Number(form.affiliate_rebate_duration_days) || 0))),
affiliate_rebate_per_invitee_cap: Math.max(0, Number(form.affiliate_rebate_per_invitee_cap) || 0),
default_concurrency: form.default_concurrency, default_concurrency: form.default_concurrency,
default_subscriptions: normalizedDefaultSubscriptions, default_subscriptions: normalizedDefaultSubscriptions,
force_email_on_third_party_signup: form.force_email_on_third_party_signup, force_email_on_third_party_signup: form.force_email_on_third_party_signup,

View File

@@ -167,6 +167,11 @@ import {
isRegistrationEmailSuffixAllowed, isRegistrationEmailSuffixAllowed,
normalizeRegistrationEmailSuffixWhitelist normalizeRegistrationEmailSuffixWhitelist
} from '@/utils/registrationEmailPolicy' } from '@/utils/registrationEmailPolicy'
import {
clearAllAffiliateReferralCodes,
loadAffiliateReferralCode,
oauthAffiliatePayload
} from '@/utils/oauthAffiliate'
const { t, locale } = useI18n() const { t, locale } = useI18n()
@@ -261,7 +266,7 @@ onMounted(async () => {
initialTurnstileToken.value = registerData.turnstile_token || '' initialTurnstileToken.value = registerData.turnstile_token || ''
promoCode.value = registerData.promo_code || '' promoCode.value = registerData.promo_code || ''
invitationCode.value = registerData.invitation_code || '' invitationCode.value = registerData.invitation_code || ''
affCode.value = registerData.aff_code || '' affCode.value = registerData.aff_code || loadAffiliateReferralCode()
pendingAuthToken.value = registerData.pending_auth_token || activePendingSession?.token || '' pendingAuthToken.value = registerData.pending_auth_token || activePendingSession?.token || ''
pendingAuthTokenField.value = registerData.pending_auth_token_field || activePendingSession?.token_field || 'pending_auth_token' pendingAuthTokenField.value = registerData.pending_auth_token_field || activePendingSession?.token_field || 'pending_auth_token'
pendingProvider.value = registerData.pending_provider || activePendingSession?.provider || '' pendingProvider.value = registerData.pending_provider || activePendingSession?.provider || ''
@@ -501,6 +506,7 @@ async function handleVerify(): Promise<void> {
password: password.value, password: password.value,
verify_code: verifyCode.value.trim(), verify_code: verifyCode.value.trim(),
invitation_code: invitationCode.value || undefined, invitation_code: invitationCode.value || undefined,
...oauthAffiliatePayload(affCode.value || loadAffiliateReferralCode()),
adopt_display_name: pendingAdoptionDecision.value?.adoptDisplayName, adopt_display_name: pendingAdoptionDecision.value?.adoptDisplayName,
adopt_avatar: pendingAdoptionDecision.value?.adoptAvatar adopt_avatar: pendingAdoptionDecision.value?.adoptAvatar
} }
@@ -533,6 +539,7 @@ async function handleVerify(): Promise<void> {
// Clear session data // Clear session data
sessionStorage.removeItem('register_data') sessionStorage.removeItem('register_data')
clearAllAffiliateReferralCodes()
// Show success toast // Show success toast
appStore.showSuccess(t('auth.accountCreatedSuccess', { siteName: siteName.value })) appStore.showSuccess(t('auth.accountCreatedSuccess', { siteName: siteName.value }))

View File

@@ -255,6 +255,11 @@ import {
type OAuthTokenResponse, type OAuthTokenResponse,
type PendingOAuthExchangeResponse type PendingOAuthExchangeResponse
} from '@/api/auth' } from '@/api/auth'
import {
clearAllAffiliateReferralCodes,
loadOAuthAffiliateCode,
oauthAffiliatePayload
} from '@/utils/oauthAffiliate'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -568,6 +573,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
if (getOAuthCompletionKind(completion) === 'bind') { if (getOAuthCompletionKind(completion) === 'bind') {
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile') const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
clearPendingAuthSession() clearPendingAuthSession()
clearAllAffiliateReferralCodes()
appStore.showSuccess(bindSuccessMessage) appStore.showSuccess(bindSuccessMessage)
await router.replace(bindRedirect) await router.replace(bindRedirect)
return return
@@ -579,6 +585,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
persistOAuthTokenContext(completion) persistOAuthTokenContext(completion)
await authStore.setToken(completion.access_token) await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect) await router.replace(redirect)
} }
@@ -627,18 +634,20 @@ async function handleSubmitInvitation() {
isSubmitting.value = true isSubmitting.value = true
try { try {
const affCode = loadOAuthAffiliateCode()
const decision = currentAdoptionDecision()
const completion: LinuxDoPendingActionResponse = legacyPendingOAuthToken.value const completion: LinuxDoPendingActionResponse = legacyPendingOAuthToken.value
? ( ? (
await apiClient.post<LinuxDoPendingActionResponse>('/auth/oauth/linuxdo/complete-registration', { await apiClient.post<LinuxDoPendingActionResponse>('/auth/oauth/linuxdo/complete-registration', {
pending_oauth_token: legacyPendingOAuthToken.value, pending_oauth_token: legacyPendingOAuthToken.value,
invitation_code: invitationCode.value.trim(), invitation_code: invitationCode.value.trim(),
...serializeAdoptionDecision(currentAdoptionDecision()) ...oauthAffiliatePayload(affCode),
...serializeAdoptionDecision(decision)
}) })
).data ).data
: await completeLinuxDoOAuthRegistration( : affCode
invitationCode.value.trim(), ? await completeLinuxDoOAuthRegistration(invitationCode.value.trim(), decision, affCode)
currentAdoptionDecision() : await completeLinuxDoOAuthRegistration(invitationCode.value.trim(), decision)
)
await finalizePendingAccountResponse(completion) await finalizePendingAccountResponse(completion)
} catch (e: unknown) { } catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { message?: string } } } const err = e as { message?: string; response?: { data?: { message?: string } } }
@@ -673,6 +682,7 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
password: payload.password, password: payload.password,
verify_code: payload.verifyCode || undefined, verify_code: payload.verifyCode || undefined,
invitation_code: payload.invitationCode || undefined, invitation_code: payload.invitationCode || undefined,
...oauthAffiliatePayload(loadOAuthAffiliateCode()),
...serializeAdoptionDecision(currentAdoptionDecision()) ...serializeAdoptionDecision(currentAdoptionDecision())
}) })
await finalizePendingAccountResponse(data) await finalizePendingAccountResponse(data)
@@ -720,6 +730,7 @@ async function handleSubmitTotpChallenge() {
totp_code: code totp_code: code
}) })
await authStore.setToken(completion.access_token) await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirectTo.value) await router.replace(redirectTo.value)
} catch (e: unknown) { } catch (e: unknown) {
@@ -743,6 +754,7 @@ onMounted(async () => {
if (legacyLogin) { if (legacyLogin) {
persistOAuthTokenContext(legacyLogin) persistOAuthTokenContext(legacyLogin)
await authStore.setToken(legacyLogin.access_token) await authStore.setToken(legacyLogin.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect) await router.replace(redirect)
return return

View File

@@ -186,6 +186,7 @@ import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores' import { useAuthStore, useAppStore } from '@/stores'
import { getPublicSettings, isTotp2FARequired, isWeChatWebOAuthEnabled } from '@/api/auth' import { getPublicSettings, isTotp2FARequired, isWeChatWebOAuthEnabled } from '@/api/auth'
import type { TotpLoginResponse } from '@/types' import type { TotpLoginResponse } from '@/types'
import { clearAllAffiliateReferralCodes } from '@/utils/oauthAffiliate'
const { t } = useI18n() const { t } = useI18n()
@@ -355,6 +356,7 @@ async function handleLogin(): Promise<void> {
} }
// Show success toast // Show success toast
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
// Redirect to dashboard or intended route // Redirect to dashboard or intended route
@@ -397,6 +399,7 @@ async function handle2FAVerify(code: string): Promise<void> {
// Close modal and show success // Close modal and show success
show2FAModal.value = false show2FAModal.value = false
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
// Redirect to dashboard or intended route // Redirect to dashboard or intended route

View File

@@ -264,6 +264,11 @@ import {
type OAuthTokenResponse, type OAuthTokenResponse,
type PendingOAuthExchangeResponse type PendingOAuthExchangeResponse
} from '@/api/auth' } from '@/api/auth'
import {
clearAllAffiliateReferralCodes,
loadOAuthAffiliateCode,
oauthAffiliatePayload
} from '@/utils/oauthAffiliate'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -590,6 +595,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
if (getOAuthCompletionKind(completion) === 'bind') { if (getOAuthCompletionKind(completion) === 'bind') {
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile') const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
clearPendingAuthSession() clearPendingAuthSession()
clearAllAffiliateReferralCodes()
appStore.showSuccess(bindSuccessMessage) appStore.showSuccess(bindSuccessMessage)
await router.replace(bindRedirect) await router.replace(bindRedirect)
return return
@@ -601,6 +607,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
persistOAuthTokenContext(completion) persistOAuthTokenContext(completion)
await authStore.setToken(completion.access_token) await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect) await router.replace(redirect)
} }
@@ -649,18 +656,20 @@ async function handleSubmitInvitation() {
isSubmitting.value = true isSubmitting.value = true
try { try {
const affCode = loadOAuthAffiliateCode()
const decision = currentAdoptionDecision()
const completion: PendingOidcCompletion = legacyPendingOAuthToken.value const completion: PendingOidcCompletion = legacyPendingOAuthToken.value
? ( ? (
await apiClient.post<PendingOidcCompletion>('/auth/oauth/oidc/complete-registration', { await apiClient.post<PendingOidcCompletion>('/auth/oauth/oidc/complete-registration', {
pending_oauth_token: legacyPendingOAuthToken.value, pending_oauth_token: legacyPendingOAuthToken.value,
invitation_code: invitationCode.value.trim(), invitation_code: invitationCode.value.trim(),
...serializeAdoptionDecision(currentAdoptionDecision()) ...oauthAffiliatePayload(affCode),
...serializeAdoptionDecision(decision)
}) })
).data ).data
: await completeOIDCOAuthRegistration( : affCode
invitationCode.value.trim(), ? await completeOIDCOAuthRegistration(invitationCode.value.trim(), decision, affCode)
currentAdoptionDecision() : await completeOIDCOAuthRegistration(invitationCode.value.trim(), decision)
)
await finalizePendingAccountResponse(completion) await finalizePendingAccountResponse(completion)
} catch (e: unknown) { } catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { message?: string } } } const err = e as { message?: string; response?: { data?: { message?: string } } }
@@ -695,6 +704,7 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
password: payload.password, password: payload.password,
verify_code: payload.verifyCode || undefined, verify_code: payload.verifyCode || undefined,
invitation_code: payload.invitationCode || undefined, invitation_code: payload.invitationCode || undefined,
...oauthAffiliatePayload(loadOAuthAffiliateCode()),
...serializeAdoptionDecision(currentAdoptionDecision()) ...serializeAdoptionDecision(currentAdoptionDecision())
}) })
await finalizePendingAccountResponse(data) await finalizePendingAccountResponse(data)
@@ -742,6 +752,7 @@ async function handleSubmitTotpChallenge() {
totp_code: code totp_code: code
}) })
await authStore.setToken(completion.access_token) await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirectTo.value) await router.replace(redirectTo.value)
} catch (e: unknown) { } catch (e: unknown) {
@@ -767,6 +778,7 @@ onMounted(async () => {
if (legacyLogin) { if (legacyLogin) {
persistOAuthTokenContext(legacyLogin) persistOAuthTokenContext(legacyLogin)
await authStore.setToken(legacyLogin.access_token) await authStore.setToken(legacyLogin.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect) await router.replace(redirect)
return return

View File

@@ -15,17 +15,20 @@
<LinuxDoOAuthSection <LinuxDoOAuthSection
v-if="linuxdoOAuthEnabled" v-if="linuxdoOAuthEnabled"
:disabled="isLoading" :disabled="isLoading"
:aff-code="formData.aff_code"
:show-divider="false" :show-divider="false"
/> />
<WechatOAuthSection <WechatOAuthSection
v-if="wechatOAuthEnabled" v-if="wechatOAuthEnabled"
:disabled="isLoading" :disabled="isLoading"
:aff-code="formData.aff_code"
:show-divider="false" :show-divider="false"
/> />
<OidcOAuthSection <OidcOAuthSection
v-if="oidcOAuthEnabled" v-if="oidcOAuthEnabled"
:disabled="isLoading" :disabled="isLoading"
:provider-name="oidcOAuthProviderName" :provider-name="oidcOAuthProviderName"
:aff-code="formData.aff_code"
:show-divider="false" :show-divider="false"
/> />
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -293,6 +296,11 @@ import {
isRegistrationEmailSuffixAllowed, isRegistrationEmailSuffixAllowed,
normalizeRegistrationEmailSuffixWhitelist normalizeRegistrationEmailSuffixWhitelist
} from '@/utils/registrationEmailPolicy' } from '@/utils/registrationEmailPolicy'
import {
clearAffiliateReferralCode,
loadAffiliateReferralCode,
resolveAffiliateReferralCode
} from '@/utils/oauthAffiliate'
const { t, locale } = useI18n() const { t, locale } = useI18n()
@@ -378,9 +386,19 @@ watch(validationToastMessage, (value, previousValue) => {
} }
}) })
function syncAffiliateReferralCode(): string {
const code = resolveAffiliateReferralCode(route.query.aff, route.query.aff_code)
if (code) {
formData.aff_code = code
}
return code
}
// ==================== Lifecycle ==================== // ==================== Lifecycle ====================
onMounted(async () => { onMounted(async () => {
syncAffiliateReferralCode()
try { try {
const settings = await getPublicSettings() const settings = await getPublicSettings()
registrationEnabled.value = settings.registration_enabled registrationEnabled.value = settings.registration_enabled
@@ -407,10 +425,7 @@ onMounted(async () => {
await validatePromoCodeDebounced(promoParam) await validatePromoCodeDebounced(promoParam)
} }
} }
const affParam = (route.query.aff as string) || (route.query.aff_code as string) syncAffiliateReferralCode()
if (affParam) {
formData.aff_code = affParam.trim()
}
} catch (error) { } catch (error) {
console.error('Failed to load public settings:', error) console.error('Failed to load public settings:', error)
} finally { } finally {
@@ -418,6 +433,13 @@ onMounted(async () => {
} }
}) })
watch(
() => [route.query.aff, route.query.aff_code],
() => {
syncAffiliateReferralCode()
}
)
onUnmounted(() => { onUnmounted(() => {
if (promoValidateTimeout) { if (promoValidateTimeout) {
clearTimeout(promoValidateTimeout) clearTimeout(promoValidateTimeout)
@@ -702,6 +724,11 @@ async function handleRegister(): Promise<void> {
isLoading.value = true isLoading.value = true
try { try {
const affCode = formData.aff_code.trim() || loadAffiliateReferralCode()
if (affCode) {
formData.aff_code = affCode
}
// If email verification is enabled, redirect to verification page // If email verification is enabled, redirect to verification page
if (emailVerifyEnabled.value) { if (emailVerifyEnabled.value) {
// Store registration data in sessionStorage // Store registration data in sessionStorage
@@ -713,7 +740,7 @@ async function handleRegister(): Promise<void> {
turnstile_token: turnstileToken.value, turnstile_token: turnstileToken.value,
promo_code: formData.promo_code || undefined, promo_code: formData.promo_code || undefined,
invitation_code: formData.invitation_code || undefined, invitation_code: formData.invitation_code || undefined,
...(formData.aff_code ? { aff_code: formData.aff_code } : {}) ...(affCode ? { aff_code: affCode } : {})
}) })
) )
@@ -729,8 +756,9 @@ async function handleRegister(): Promise<void> {
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined, turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
promo_code: formData.promo_code || undefined, promo_code: formData.promo_code || undefined,
invitation_code: formData.invitation_code || undefined, invitation_code: formData.invitation_code || undefined,
...(formData.aff_code ? { aff_code: formData.aff_code } : {}) ...(affCode ? { aff_code: affCode } : {})
}) })
clearAffiliateReferralCode()
// Show success toast // Show success toast
appStore.showSuccess(t('auth.accountCreatedSuccess', { siteName: siteName.value })) appStore.showSuccess(t('auth.accountCreatedSuccess', { siteName: siteName.value }))

View File

@@ -340,6 +340,11 @@ import {
type OAuthTokenResponse, type OAuthTokenResponse,
type PendingOAuthExchangeResponse type PendingOAuthExchangeResponse
} from '@/api/auth' } from '@/api/auth'
import {
clearAllAffiliateReferralCodes,
loadOAuthAffiliateCode,
oauthAffiliatePayload
} from '@/utils/oauthAffiliate'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -802,6 +807,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
if (getOAuthCompletionKind(completion) === 'bind') { if (getOAuthCompletionKind(completion) === 'bind') {
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile') const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
clearPendingAuthSession() clearPendingAuthSession()
clearAllAffiliateReferralCodes()
appStore.showSuccess(bindSuccessMessage) appStore.showSuccess(bindSuccessMessage)
await router.replace(bindRedirect) await router.replace(bindRedirect)
return return
@@ -813,6 +819,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
persistOAuthTokenContext(completion) persistOAuthTokenContext(completion)
await authStore.setToken(completion.access_token) await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect) await router.replace(redirect)
} }
@@ -861,18 +868,20 @@ async function handleSubmitInvitation() {
isSubmitting.value = true isSubmitting.value = true
try { try {
const affCode = loadOAuthAffiliateCode()
const decision = currentAdoptionDecision()
const completion: PendingWeChatCompletion = legacyPendingOAuthToken.value const completion: PendingWeChatCompletion = legacyPendingOAuthToken.value
? ( ? (
await apiClient.post<PendingWeChatCompletion>('/auth/oauth/wechat/complete-registration', { await apiClient.post<PendingWeChatCompletion>('/auth/oauth/wechat/complete-registration', {
pending_oauth_token: legacyPendingOAuthToken.value, pending_oauth_token: legacyPendingOAuthToken.value,
invitation_code: invitationCode.value.trim(), invitation_code: invitationCode.value.trim(),
...serializeAdoptionDecision(currentAdoptionDecision()) ...oauthAffiliatePayload(affCode),
...serializeAdoptionDecision(decision)
}) })
).data ).data
: await completeWeChatOAuthRegistration( : affCode
invitationCode.value.trim(), ? await completeWeChatOAuthRegistration(invitationCode.value.trim(), decision, affCode)
currentAdoptionDecision() : await completeWeChatOAuthRegistration(invitationCode.value.trim(), decision)
)
await finalizePendingAccountResponse(completion) await finalizePendingAccountResponse(completion)
} catch (e: unknown) { } catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { message?: string } } } const err = e as { message?: string; response?: { data?: { message?: string } } }
@@ -907,6 +916,7 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
password: payload.password, password: payload.password,
verify_code: payload.verifyCode || undefined, verify_code: payload.verifyCode || undefined,
invitation_code: payload.invitationCode || undefined, invitation_code: payload.invitationCode || undefined,
...oauthAffiliatePayload(loadOAuthAffiliateCode()),
...serializeAdoptionDecision(currentAdoptionDecision()) ...serializeAdoptionDecision(currentAdoptionDecision())
}) })
await finalizePendingAccountResponse(data) await finalizePendingAccountResponse(data)
@@ -955,6 +965,7 @@ async function handleSubmitTotpChallenge() {
}) })
persistOAuthTokenContext(completion) persistOAuthTokenContext(completion)
await authStore.setToken(completion.access_token) await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirectTo.value) await router.replace(redirectTo.value)
} catch (e: unknown) { } catch (e: unknown) {
@@ -1015,6 +1026,7 @@ onMounted(async () => {
if (legacyLogin) { if (legacyLogin) {
persistOAuthTokenContext(legacyLogin) persistOAuthTokenContext(legacyLogin)
await authStore.setToken(legacyLogin.access_token) await authStore.setToken(legacyLogin.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect) await router.replace(redirect)
return return

View File

@@ -112,6 +112,7 @@ describe('EmailVerifyView', () => {
apiClientPostMock.mockReset() apiClientPostMock.mockReset()
authStoreState.pendingAuthSession = null authStoreState.pendingAuthSession = null
sessionStorage.clear() sessionStorage.clear()
localStorage.clear()
getPublicSettingsMock.mockResolvedValue({ getPublicSettingsMock.mockResolvedValue({
turnstile_enabled: false, turnstile_enabled: false,
@@ -136,6 +137,7 @@ describe('EmailVerifyView', () => {
JSON.stringify({ JSON.stringify({
email: 'fresh@example.com', email: 'fresh@example.com',
password: 'secret-123', password: 'secret-123',
aff_code: 'AFF123',
}) })
) )
@@ -334,6 +336,7 @@ describe('EmailVerifyView', () => {
email: 'fresh@example.com', email: 'fresh@example.com',
password: 'secret-123', password: 'secret-123',
verify_code: '123456', verify_code: '123456',
aff_code: 'AFF123',
}) })
expect(persistOAuthTokenContextMock).toHaveBeenCalledWith({ expect(persistOAuthTokenContextMock).toHaveBeenCalledWith({
access_token: 'oauth-access-token', access_token: 'oauth-access-token',

View File

@@ -93,6 +93,7 @@ describe('LinuxDoCallbackView', () => {
}) })
window.location.hash = '' window.location.hash = ''
localStorage.clear() localStorage.clear()
sessionStorage.clear()
}) })
it('accepts the legacy fragment token success callback without pending-session exchange', async () => { it('accepts the legacy fragment token success callback without pending-session exchange', async () => {

View File

@@ -97,6 +97,7 @@ describe('OidcCallbackView', () => {
}) })
window.location.hash = '' window.location.hash = ''
localStorage.clear() localStorage.clear()
sessionStorage.clear()
}) })
it('accepts the legacy fragment token success callback without pending-session exchange', async () => { it('accepts the legacy fragment token success callback without pending-session exchange', async () => {

View File

@@ -172,6 +172,7 @@ describe('WechatCallbackView', () => {
appStoreState.cachedPublicSettings = null appStoreState.cachedPublicSettings = null
appStoreState.publicSettingsLoaded = false appStoreState.publicSettingsLoaded = false
localStorage.clear() localStorage.clear()
sessionStorage.clear()
locationState.current = { locationState.current = {
href: 'http://localhost/auth/wechat/callback', href: 'http://localhost/auth/wechat/callback',
hash: '', hash: '',

View File

@@ -9,21 +9,17 @@
<template v-else-if="detail"> <template v-else-if="detail">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<!-- 返利比例用主色突出让用户一眼看到能拿多少 --> <div class="card p-5">
<div class="card relative overflow-hidden p-5"> <p class="flex items-center gap-1.5 text-sm text-gray-500 dark:text-dark-400">
<div class="absolute -right-6 -top-6 h-24 w-24 rounded-full bg-primary-500/10"></div> <Icon name="dollar" size="sm" class="text-primary-500" />
<div class="relative"> {{ t('affiliate.stats.rebateRate') }}
<p class="flex items-center gap-1.5 text-sm text-gray-500 dark:text-dark-400"> </p>
<Icon name="dollar" size="sm" class="text-primary-500" /> <p class="mt-2 text-2xl font-semibold text-primary-600 dark:text-primary-400">
{{ t('affiliate.stats.rebateRate') }} {{ formattedRebateRate }}<span class="ml-0.5 text-base font-medium">%</span>
</p> </p>
<p class="mt-2 text-2xl font-semibold text-primary-600 dark:text-primary-400"> <p class="mt-1 text-xs text-gray-400 dark:text-dark-500">
{{ formattedRebateRate }}<span class="ml-0.5 text-base font-medium">%</span> {{ t('affiliate.stats.rebateRateHint') }}
</p> </p>
<p class="mt-1 text-xs text-gray-400 dark:text-dark-500">
{{ t('affiliate.stats.rebateRateHint') }}
</p>
</div>
</div> </div>
<div class="card p-5"> <div class="card p-5">
<p class="text-sm text-gray-500 dark:text-dark-400">{{ t('affiliate.stats.invitedUsers') }}</p> <p class="text-sm text-gray-500 dark:text-dark-400">{{ t('affiliate.stats.invitedUsers') }}</p>
@@ -42,6 +38,9 @@
<p class="mt-2 text-2xl font-semibold text-gray-900 dark:text-white"> <p class="mt-2 text-2xl font-semibold text-gray-900 dark:text-white">
{{ formatCurrency(detail.aff_history_quota) }} {{ formatCurrency(detail.aff_history_quota) }}
</p> </p>
<p v-if="detail.aff_frozen_quota > 0" class="mt-1 text-xs text-amber-600 dark:text-amber-400">
{{ t('affiliate.stats.frozenQuota') }}: {{ formatCurrency(detail.aff_frozen_quota) }}
</p>
</div> </div>
</div> </div>
@@ -79,6 +78,7 @@
<li>1. {{ t('affiliate.tips.line1') }}</li> <li>1. {{ t('affiliate.tips.line1') }}</li>
<li>2. {{ t('affiliate.tips.line2', { rate: `${formattedRebateRate}%` }) }}</li> <li>2. {{ t('affiliate.tips.line2', { rate: `${formattedRebateRate}%` }) }}</li>
<li>3. {{ t('affiliate.tips.line3') }}</li> <li>3. {{ t('affiliate.tips.line3') }}</li>
<li v-if="detail.aff_frozen_quota > 0">4. {{ t('affiliate.tips.line4') }}</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -115,6 +115,7 @@
<tr class="border-b border-gray-200 text-gray-500 dark:border-dark-700 dark:text-dark-400"> <tr class="border-b border-gray-200 text-gray-500 dark:border-dark-700 dark:text-dark-400">
<th class="px-3 py-2 font-medium">{{ t('affiliate.invitees.columns.email') }}</th> <th class="px-3 py-2 font-medium">{{ t('affiliate.invitees.columns.email') }}</th>
<th class="px-3 py-2 font-medium">{{ t('affiliate.invitees.columns.username') }}</th> <th class="px-3 py-2 font-medium">{{ t('affiliate.invitees.columns.username') }}</th>
<th class="px-3 py-2 font-medium text-right">{{ t('affiliate.invitees.columns.rebate') }}</th>
<th class="px-3 py-2 font-medium">{{ t('affiliate.invitees.columns.joinedAt') }}</th> <th class="px-3 py-2 font-medium">{{ t('affiliate.invitees.columns.joinedAt') }}</th>
</tr> </tr>
</thead> </thead>
@@ -126,6 +127,7 @@
> >
<td class="px-3 py-3 text-gray-900 dark:text-white">{{ item.email || '-' }}</td> <td class="px-3 py-3 text-gray-900 dark:text-white">{{ item.email || '-' }}</td>
<td class="px-3 py-3 text-gray-700 dark:text-gray-300">{{ item.username || '-' }}</td> <td class="px-3 py-3 text-gray-700 dark:text-gray-300">{{ item.username || '-' }}</td>
<td class="px-3 py-3 text-right font-medium text-emerald-600 dark:text-emerald-400">{{ formatCurrency(item.total_rebate) }}</td>
<td class="px-3 py-3 text-gray-700 dark:text-gray-300">{{ formatDateTime(item.created_at) || '-' }}</td> <td class="px-3 py-3 text-gray-700 dark:text-gray-300">{{ formatDateTime(item.created_at) || '-' }}</td>
</tr> </tr>
</tbody> </tbody>