mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-05-04 21:20:51 +08:00
feat(affiliate): 完善邀请返利系统
- 修复返利不到账的根因:tryClaimAffiliateRebateAudit 中 PostgreSQL 参数类型推断冲突 - 补全 OAuth 注册路径(LinuxDo/OIDC/WeChat/Pending Flow)的邀请码绑定 - 前端 OAuth 注册页面传递 aff_code 参数 - 新增返利冻结期机制:可配置冻结时间,到期后自动解冻(懒解冻) - 新增返利有效期:绑定后 N 天内有效,过期不再产生返利 - 新增单人返利上限:超出上限部分精确截断 - 增强返利流程 slog 结构化日志,便于排查问题 - 已邀请用户列表增加返利明细列
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
docs/claude-relay-service/
|
docs/claude-relay-service/
|
||||||
|
.codex
|
||||||
|
|
||||||
# ===================
|
# ===================
|
||||||
# Go 后端
|
# Go 后端
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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服务器地址
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
// 敏感信息直接返回,方便测试连接时使用
|
// 敏感信息直接返回,方便测试连接时使用
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
17
backend/migrations/133_affiliate_rebate_freeze.sql
Normal file
17
backend/migrations/133_affiliate_rebate_freeze.sql
Normal 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;
|
||||||
@@ -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')
|
||||||
|
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)}`
|
||||||
|
|||||||
@@ -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)}`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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: '为指定用户设置专属邀请码或专属返利比例。仅展示已设置过专属配置的用户。',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
48
frontend/src/utils/__tests__/oauthAffiliate.spec.ts
Normal file
48
frontend/src/utils/__tests__/oauthAffiliate.spec.ts
Normal 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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
133
frontend/src/utils/oauthAffiliate.ts
Normal file
133
frontend/src/utils/oauthAffiliate.ts
Normal 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 } : {}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 }))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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: '',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user