Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
5b5db885
Unverified
Commit
5b5db885
authored
Apr 24, 2026
by
Wesley Liddick
Committed by
GitHub
Apr 24, 2026
Browse files
Merge pull request #1897 from VpSanta33/codex/invite-affiliate-rebate
feat: 新增邀请返利功能,并支持后台配置返利比例
parents
76aae5aa
f03de00c
Changes
33
Show whitespace changes
Inline
Side-by-side
backend/cmd/server/wire_gen.go
View file @
5b5db885
...
...
@@ -69,7 +69,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
apiKeyAuthCacheInvalidator
:=
service
.
ProvideAPIKeyAuthCacheInvalidator
(
apiKeyService
)
promoService
:=
service
.
NewPromoService
(
promoCodeRepository
,
userRepository
,
billingCacheService
,
client
,
apiKeyAuthCacheInvalidator
)
subscriptionService
:=
service
.
NewSubscriptionService
(
groupRepository
,
userSubscriptionRepository
,
billingCacheService
,
client
,
configConfig
)
authService
:=
service
.
NewAuthService
(
client
,
userRepository
,
redeemCodeRepository
,
refreshTokenCache
,
configConfig
,
settingService
,
emailService
,
turnstileService
,
emailQueueService
,
promoService
,
subscriptionService
)
affiliateRepository
:=
repository
.
NewAffiliateRepository
(
client
,
db
)
affiliateService
:=
service
.
NewAffiliateService
(
affiliateRepository
,
settingRepository
,
apiKeyAuthCacheInvalidator
,
billingCacheService
)
authService
:=
service
.
ProvideAuthService
(
client
,
userRepository
,
redeemCodeRepository
,
refreshTokenCache
,
configConfig
,
settingService
,
emailService
,
turnstileService
,
emailQueueService
,
promoService
,
subscriptionService
,
affiliateService
)
userService
:=
service
.
NewUserService
(
userRepository
,
settingRepository
,
apiKeyAuthCacheInvalidator
,
billingCache
)
redeemCache
:=
repository
.
NewRedeemCache
(
redisClient
)
redeemService
:=
service
.
NewRedeemService
(
redeemCodeRepository
,
userRepository
,
subscriptionService
,
redeemCache
,
billingCacheService
,
client
,
apiKeyAuthCacheInvalidator
)
...
...
@@ -80,7 +82,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
totpCache
:=
repository
.
NewTotpCache
(
redisClient
)
totpService
:=
service
.
NewTotpService
(
userRepository
,
secretEncryptor
,
totpCache
,
settingService
,
emailService
,
emailQueueService
)
authHandler
:=
handler
.
NewAuthHandler
(
configConfig
,
authService
,
userService
,
settingService
,
promoService
,
redeemService
,
totpService
)
userHandler
:=
handler
.
New
UserHandler
(
userService
,
authService
,
emailService
,
emailCache
)
userHandler
:=
handler
.
Provide
UserHandler
(
userService
,
authService
,
emailService
,
emailCache
,
affiliateService
)
apiKeyHandler
:=
handler
.
NewAPIKeyHandler
(
apiKeyService
)
usageLogRepository
:=
repository
.
NewUsageLogRepository
(
client
,
db
)
usageService
:=
service
.
NewUsageService
(
usageLogRepository
,
userRepository
,
client
,
apiKeyAuthCacheInvalidator
)
...
...
@@ -91,6 +93,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
announcementReadRepository
:=
repository
.
NewAnnouncementReadRepository
(
client
)
announcementService
:=
service
.
NewAnnouncementService
(
announcementRepository
,
announcementReadRepository
,
userRepository
,
userSubscriptionRepository
)
announcementHandler
:=
handler
.
NewAnnouncementHandler
(
announcementService
)
channelMonitorRepository
:=
repository
.
NewChannelMonitorRepository
(
client
,
db
)
channelMonitorService
:=
service
.
ProvideChannelMonitorService
(
channelMonitorRepository
,
secretEncryptor
)
channelMonitorUserHandler
:=
handler
.
NewChannelMonitorUserHandler
(
channelMonitorService
,
settingService
)
dashboardAggregationRepository
:=
repository
.
NewDashboardAggregationRepository
(
db
)
dashboardStatsCache
:=
repository
.
NewDashboardCache
(
redisClient
,
configConfig
)
dashboardService
:=
service
.
NewDashboardService
(
usageLogRepository
,
dashboardAggregationRepository
,
dashboardStatsCache
,
configConfig
)
...
...
@@ -192,7 +197,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
paymentConfigService
:=
service
.
ProvidePaymentConfigService
(
client
,
settingRepository
,
encryptionKey
)
registry
:=
payment
.
ProvideRegistry
()
defaultLoadBalancer
:=
payment
.
ProvideDefaultLoadBalancer
(
client
,
encryptionKey
)
paymentService
:=
service
.
New
PaymentService
(
client
,
registry
,
defaultLoadBalancer
,
redeemService
,
subscriptionService
,
paymentConfigService
,
userRepository
,
groupRepository
)
paymentService
:=
service
.
Provide
PaymentService
(
client
,
registry
,
defaultLoadBalancer
,
redeemService
,
subscriptionService
,
paymentConfigService
,
userRepository
,
groupRepository
,
affiliateService
)
settingHandler
:=
admin
.
NewSettingHandler
(
settingService
,
emailService
,
turnstileService
,
opsService
,
paymentConfigService
,
paymentService
)
opsHandler
:=
admin
.
NewOpsHandler
(
opsService
)
updateCache
:=
repository
.
NewUpdateCache
(
redisClient
)
...
...
@@ -221,20 +226,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
scheduledTestService
:=
service
.
ProvideScheduledTestService
(
scheduledTestPlanRepository
,
scheduledTestResultRepository
)
scheduledTestHandler
:=
admin
.
NewScheduledTestHandler
(
scheduledTestService
)
channelHandler
:=
admin
.
NewChannelHandler
(
channelService
,
billingService
)
sqlDB
,
err
:=
repository
.
ProvideSQLDB
(
client
)
if
err
!=
nil
{
return
nil
,
err
}
channelMonitorRepository
:=
repository
.
NewChannelMonitorRepository
(
client
,
sqlDB
)
channelMonitorRequestTemplateRepository
:=
repository
.
NewChannelMonitorRequestTemplateRepository
(
client
,
sqlDB
)
channelMonitorHandler
:=
admin
.
NewChannelMonitorHandler
(
channelMonitorService
)
channelMonitorRequestTemplateRepository
:=
repository
.
NewChannelMonitorRequestTemplateRepository
(
client
,
db
)
channelMonitorRequestTemplateService
:=
service
.
NewChannelMonitorRequestTemplateService
(
channelMonitorRequestTemplateRepository
)
channelMonitorRequestTemplateHandler
:=
admin
.
NewChannelMonitorRequestTemplateHandler
(
channelMonitorRequestTemplateService
)
channelMonitorService
:=
service
.
ProvideChannelMonitorService
(
channelMonitorRepository
,
secretEncryptor
)
channelMonitorHandler
:=
admin
.
NewChannelMonitorHandler
(
channelMonitorService
)
channelMonitorUserHandler
:=
handler
.
NewChannelMonitorUserHandler
(
channelMonitorService
,
settingService
)
channelMonitorRunner
:=
service
.
ProvideChannelMonitorRunner
(
channelMonitorService
,
settingService
)
paymentHandler
:=
admin
.
NewPaymentHandler
(
paymentService
,
paymentConfigService
)
availableChannelUserHandler
:=
handler
.
NewAvailableChannelHandler
(
channelService
,
apiKeyService
,
settingService
)
adminHandlers
:=
handler
.
ProvideAdminHandlers
(
dashboardHandler
,
adminUserHandler
,
groupHandler
,
accountHandler
,
adminAnnouncementHandler
,
dataManagementHandler
,
backupHandler
,
oAuthHandler
,
openAIOAuthHandler
,
geminiOAuthHandler
,
antigravityOAuthHandler
,
proxyHandler
,
adminRedeemHandler
,
promoHandler
,
settingHandler
,
opsHandler
,
systemHandler
,
adminSubscriptionHandler
,
adminUsageHandler
,
userAttributeHandler
,
errorPassthroughHandler
,
tlsFingerprintProfileHandler
,
adminAPIKeyHandler
,
scheduledTestHandler
,
channelHandler
,
channelMonitorHandler
,
channelMonitorRequestTemplateHandler
,
paymentHandler
)
usageRecordWorkerPool
:=
service
.
NewUsageRecordWorkerPool
(
configConfig
)
userMsgQueueCache
:=
repository
.
NewUserMsgQueueCache
(
redisClient
)
...
...
@@ -245,9 +241,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
totpHandler
:=
handler
.
NewTotpHandler
(
totpService
)
handlerPaymentHandler
:=
handler
.
NewPaymentHandler
(
paymentService
,
paymentConfigService
,
channelService
)
paymentWebhookHandler
:=
handler
.
NewPaymentWebhookHandler
(
paymentService
,
registry
)
availableChannelHandler
:=
handler
.
NewAvailableChannelHandler
(
channelService
,
apiKeyService
,
settingService
)
idempotencyCoordinator
:=
service
.
ProvideIdempotencyCoordinator
(
idempotencyRepository
,
configConfig
)
idempotencyCleanupService
:=
service
.
ProvideIdempotencyCleanupService
(
idempotencyRepository
,
configConfig
)
handlers
:=
handler
.
ProvideHandlers
(
authHandler
,
userHandler
,
apiKeyHandler
,
usageHandler
,
redeemHandler
,
subscriptionHandler
,
announcementHandler
,
channelMonitorUserHandler
,
adminHandlers
,
gatewayHandler
,
openAIGatewayHandler
,
handlerSettingHandler
,
totpHandler
,
handlerPaymentHandler
,
paymentWebhookHandler
,
availableChannel
User
Handler
,
idempotencyCoordinator
,
idempotencyCleanupService
)
handlers
:=
handler
.
ProvideHandlers
(
authHandler
,
userHandler
,
apiKeyHandler
,
usageHandler
,
redeemHandler
,
subscriptionHandler
,
announcementHandler
,
channelMonitorUserHandler
,
adminHandlers
,
gatewayHandler
,
openAIGatewayHandler
,
handlerSettingHandler
,
totpHandler
,
handlerPaymentHandler
,
paymentWebhookHandler
,
availableChannelHandler
,
idempotencyCoordinator
,
idempotencyCleanupService
)
jwtAuthMiddleware
:=
middleware
.
NewJWTAuthMiddleware
(
authService
,
userService
)
adminAuthMiddleware
:=
middleware
.
NewAdminAuthMiddleware
(
authService
,
userService
,
settingService
)
apiKeyAuthMiddleware
:=
middleware
.
NewAPIKeyAuthMiddleware
(
apiKeyService
,
subscriptionService
,
configConfig
)
...
...
@@ -263,6 +260,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
subscriptionExpiryService
:=
service
.
ProvideSubscriptionExpiryService
(
userSubscriptionRepository
)
scheduledTestRunnerService
:=
service
.
ProvideScheduledTestRunnerService
(
scheduledTestPlanRepository
,
scheduledTestService
,
accountTestService
,
rateLimitService
,
configConfig
)
paymentOrderExpiryService
:=
service
.
ProvidePaymentOrderExpiryService
(
paymentService
)
channelMonitorRunner
:=
service
.
ProvideChannelMonitorRunner
(
channelMonitorService
,
settingService
)
v
:=
provideCleanup
(
client
,
redisClient
,
opsMetricsCollector
,
opsAggregationService
,
opsAlertEvaluatorService
,
opsCleanupService
,
opsScheduledReportService
,
opsSystemLogSink
,
schedulerSnapshotService
,
tokenRefreshService
,
accountExpiryService
,
subscriptionExpiryService
,
usageCleanupService
,
idempotencyCleanupService
,
pricingService
,
emailQueueService
,
billingCacheService
,
usageRecordWorkerPool
,
subscriptionService
,
oAuthService
,
openAIOAuthService
,
geminiOAuthService
,
antigravityOAuthService
,
openAIGatewayService
,
scheduledTestRunnerService
,
backupService
,
paymentOrderExpiryService
,
channelMonitorRunner
)
application
:=
&
Application
{
Server
:
httpServer
,
...
...
backend/internal/handler/admin/setting_handler.go
View file @
5b5db885
...
...
@@ -185,6 +185,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
CustomEndpoints
:
dto
.
ParseCustomEndpoints
(
settings
.
CustomEndpoints
),
DefaultConcurrency
:
settings
.
DefaultConcurrency
,
DefaultBalance
:
settings
.
DefaultBalance
,
AffiliateRebateRate
:
settings
.
AffiliateRebateRate
,
DefaultUserRPMLimit
:
settings
.
DefaultUserRPMLimit
,
DefaultSubscriptions
:
defaultSubscriptions
,
EnableModelFallback
:
settings
.
EnableModelFallback
,
...
...
@@ -338,6 +339,7 @@ type UpdateSettingsRequest struct {
// 默认配置
DefaultConcurrency
int
`json:"default_concurrency"`
DefaultBalance
float64
`json:"default_balance"`
AffiliateRebateRate
*
float64
`json:"affiliate_rebate_rate"`
DefaultUserRPMLimit
int
`json:"default_user_rpm_limit"`
DefaultSubscriptions
[]
dto
.
DefaultSubscriptionSetting
`json:"default_subscriptions"`
AuthSourceDefaultEmailBalance
*
float64
`json:"auth_source_default_email_balance"`
...
...
@@ -468,6 +470,16 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
if
req
.
DefaultBalance
<
0
{
req
.
DefaultBalance
=
0
}
affiliateRebateRate
:=
previousSettings
.
AffiliateRebateRate
if
req
.
AffiliateRebateRate
!=
nil
{
affiliateRebateRate
=
*
req
.
AffiliateRebateRate
}
if
affiliateRebateRate
<
service
.
AffiliateRebateRateMin
{
affiliateRebateRate
=
service
.
AffiliateRebateRateMin
}
if
affiliateRebateRate
>
service
.
AffiliateRebateRateMax
{
affiliateRebateRate
=
service
.
AffiliateRebateRateMax
}
// 通用表格配置:兼容旧客户端未传字段时保留当前值。
if
req
.
TableDefaultPageSize
<=
0
{
req
.
TableDefaultPageSize
=
previousSettings
.
TableDefaultPageSize
...
...
@@ -1119,6 +1131,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
CustomEndpoints
:
customEndpointsJSON
,
DefaultConcurrency
:
req
.
DefaultConcurrency
,
DefaultBalance
:
req
.
DefaultBalance
,
AffiliateRebateRate
:
affiliateRebateRate
,
DefaultUserRPMLimit
:
req
.
DefaultUserRPMLimit
,
DefaultSubscriptions
:
defaultSubscriptions
,
EnableModelFallback
:
req
.
EnableModelFallback
,
...
...
@@ -1433,6 +1446,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
CustomEndpoints
:
dto
.
ParseCustomEndpoints
(
updatedSettings
.
CustomEndpoints
),
DefaultConcurrency
:
updatedSettings
.
DefaultConcurrency
,
DefaultBalance
:
updatedSettings
.
DefaultBalance
,
AffiliateRebateRate
:
updatedSettings
.
AffiliateRebateRate
,
DefaultUserRPMLimit
:
updatedSettings
.
DefaultUserRPMLimit
,
DefaultSubscriptions
:
updatedDefaultSubscriptions
,
EnableModelFallback
:
updatedSettings
.
EnableModelFallback
,
...
...
@@ -1738,6 +1752,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if
before
.
DefaultBalance
!=
after
.
DefaultBalance
{
changed
=
append
(
changed
,
"default_balance"
)
}
if
before
.
AffiliateRebateRate
!=
after
.
AffiliateRebateRate
{
changed
=
append
(
changed
,
"affiliate_rebate_rate"
)
}
if
!
equalDefaultSubscriptions
(
before
.
DefaultSubscriptions
,
after
.
DefaultSubscriptions
)
{
changed
=
append
(
changed
,
"default_subscriptions"
)
}
...
...
backend/internal/handler/auth_handler.go
View file @
5b5db885
...
...
@@ -48,6 +48,7 @@ type RegisterRequest struct {
TurnstileToken
string
`json:"turnstile_token"`
PromoCode
string
`json:"promo_code"`
// 注册优惠码
InvitationCode
string
`json:"invitation_code"`
// 邀请码
AffCode
string
`json:"aff_code"`
// 邀请返利码
}
// SendVerifyCodeRequest 发送验证码请求
...
...
@@ -164,7 +165,15 @@ func (h *AuthHandler) Register(c *gin.Context) {
return
}
_
,
user
,
err
:=
h
.
authService
.
RegisterWithVerification
(
c
.
Request
.
Context
(),
req
.
Email
,
req
.
Password
,
req
.
VerifyCode
,
req
.
PromoCode
,
req
.
InvitationCode
)
_
,
user
,
err
:=
h
.
authService
.
RegisterWithVerification
(
c
.
Request
.
Context
(),
req
.
Email
,
req
.
Password
,
req
.
VerifyCode
,
req
.
PromoCode
,
req
.
InvitationCode
,
req
.
AffCode
,
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
...
...
backend/internal/handler/dto/settings.go
View file @
5b5db885
...
...
@@ -108,6 +108,7 @@ type SystemSettings struct {
DefaultConcurrency
int
`json:"default_concurrency"`
DefaultBalance
float64
`json:"default_balance"`
AffiliateRebateRate
float64
`json:"affiliate_rebate_rate"`
DefaultUserRPMLimit
int
`json:"default_user_rpm_limit"`
DefaultSubscriptions
[]
DefaultSubscriptionSetting
`json:"default_subscriptions"`
...
...
backend/internal/handler/user_handler.go
View file @
5b5db885
...
...
@@ -5,6 +5,7 @@ import (
"strings"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
infraerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
middleware2
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
...
...
@@ -18,6 +19,7 @@ type UserHandler struct {
authService
*
service
.
AuthService
emailService
*
service
.
EmailService
emailCache
service
.
EmailCache
affiliateService
*
service
.
AffiliateService
}
// NewUserHandler creates a new UserHandler
...
...
@@ -35,6 +37,13 @@ func NewUserHandler(
}
}
func
(
h
*
UserHandler
)
SetAffiliateService
(
affiliateService
*
service
.
AffiliateService
)
{
if
h
==
nil
{
return
}
h
.
affiliateService
=
affiliateService
}
// ChangePasswordRequest represents the change password request payload
type
ChangePasswordRequest
struct
{
OldPassword
string
`json:"old_password" binding:"required"`
...
...
@@ -159,6 +168,63 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
response
.
Success
(
c
,
profileResp
)
}
func
(
h
*
UserHandler
)
affiliateServiceOrErr
()
(
*
service
.
AffiliateService
,
error
)
{
if
h
==
nil
||
h
.
affiliateService
==
nil
{
return
nil
,
infraerrors
.
ServiceUnavailable
(
"SERVICE_UNAVAILABLE"
,
"affiliate service unavailable"
)
}
return
h
.
affiliateService
,
nil
}
// GetAffiliate returns the current user's affiliate details.
// GET /api/v1/user/aff
func
(
h
*
UserHandler
)
GetAffiliate
(
c
*
gin
.
Context
)
{
subject
,
ok
:=
middleware2
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
{
response
.
Unauthorized
(
c
,
"User not authenticated"
)
return
}
affiliateSvc
,
err
:=
h
.
affiliateServiceOrErr
()
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
detail
,
err
:=
affiliateSvc
.
GetAffiliateDetail
(
c
.
Request
.
Context
(),
subject
.
UserID
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
detail
)
}
// TransferAffiliateQuota transfers all available affiliate quota into current balance.
// POST /api/v1/user/aff/transfer
func
(
h
*
UserHandler
)
TransferAffiliateQuota
(
c
*
gin
.
Context
)
{
subject
,
ok
:=
middleware2
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
{
response
.
Unauthorized
(
c
,
"User not authenticated"
)
return
}
affiliateSvc
,
err
:=
h
.
affiliateServiceOrErr
()
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
transferred
,
balance
,
err
:=
affiliateSvc
.
TransferAffiliateQuota
(
c
.
Request
.
Context
(),
subject
.
UserID
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
gin
.
H
{
"transferred_quota"
:
transferred
,
"balance"
:
balance
,
})
}
type
StartIdentityBindingRequest
struct
{
Provider
string
`json:"provider" binding:"required"`
RedirectTo
string
`json:"redirect_to"`
...
...
backend/internal/handler/wire.go
View file @
5b5db885
...
...
@@ -80,6 +80,18 @@ func ProvideSettingHandler(settingService *service.SettingService, buildInfo Bui
return
NewSettingHandler
(
settingService
,
buildInfo
.
Version
)
}
func
ProvideUserHandler
(
userService
*
service
.
UserService
,
authService
*
service
.
AuthService
,
emailService
*
service
.
EmailService
,
emailCache
service
.
EmailCache
,
affiliateService
*
service
.
AffiliateService
,
)
*
UserHandler
{
handler
:=
NewUserHandler
(
userService
,
authService
,
emailService
,
emailCache
)
handler
.
SetAffiliateService
(
affiliateService
)
return
handler
}
// ProvideHandlers creates the Handlers struct
func
ProvideHandlers
(
authHandler
*
AuthHandler
,
...
...
@@ -125,7 +137,7 @@ func ProvideHandlers(
var
ProviderSet
=
wire
.
NewSet
(
// Top-level handlers
NewAuthHandler
,
New
UserHandler
,
Provide
UserHandler
,
NewAPIKeyHandler
,
NewUsageHandler
,
NewRedeemHandler
,
...
...
backend/internal/repository/affiliate_repo.go
0 → 100644
View file @
5b5db885
package
repository
import
(
"context"
"crypto/rand"
"database/sql"
"errors"
"fmt"
"strings"
"time"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/lib/pq"
)
const
(
affiliateCodeLength
=
12
affiliateCodeMaxAttempts
=
12
)
var
affiliateCodeCharset
=
[]
byte
(
"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
)
type
affiliateQueryExecer
interface
{
QueryContext
(
ctx
context
.
Context
,
query
string
,
args
...
any
)
(
*
sql
.
Rows
,
error
)
ExecContext
(
ctx
context
.
Context
,
query
string
,
args
...
any
)
(
sql
.
Result
,
error
)
}
type
affiliateRepository
struct
{
client
*
dbent
.
Client
}
func
NewAffiliateRepository
(
client
*
dbent
.
Client
,
_
*
sql
.
DB
)
service
.
AffiliateRepository
{
return
&
affiliateRepository
{
client
:
client
}
}
func
(
r
*
affiliateRepository
)
EnsureUserAffiliate
(
ctx
context
.
Context
,
userID
int64
)
(
*
service
.
AffiliateSummary
,
error
)
{
if
userID
<=
0
{
return
nil
,
service
.
ErrUserNotFound
}
client
:=
clientFromContext
(
ctx
,
r
.
client
)
return
ensureUserAffiliateWithClient
(
ctx
,
client
,
userID
)
}
func
(
r
*
affiliateRepository
)
GetAffiliateByCode
(
ctx
context
.
Context
,
code
string
)
(
*
service
.
AffiliateSummary
,
error
)
{
client
:=
clientFromContext
(
ctx
,
r
.
client
)
return
queryAffiliateByCode
(
ctx
,
client
,
code
)
}
func
(
r
*
affiliateRepository
)
BindInviter
(
ctx
context
.
Context
,
userID
,
inviterID
int64
)
(
bool
,
error
)
{
var
bound
bool
err
:=
r
.
withTx
(
ctx
,
func
(
txCtx
context
.
Context
,
txClient
*
dbent
.
Client
)
error
{
if
_
,
err
:=
ensureUserAffiliateWithClient
(
txCtx
,
txClient
,
userID
);
err
!=
nil
{
return
err
}
if
_
,
err
:=
ensureUserAffiliateWithClient
(
txCtx
,
txClient
,
inviterID
);
err
!=
nil
{
return
err
}
res
,
err
:=
txClient
.
ExecContext
(
txCtx
,
"UPDATE user_affiliates SET inviter_id = $1, updated_at = NOW() WHERE user_id = $2 AND inviter_id IS NULL"
,
inviterID
,
userID
,
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"bind inviter: %w"
,
err
)
}
affected
,
_
:=
res
.
RowsAffected
()
if
affected
==
0
{
bound
=
false
return
nil
}
if
_
,
err
=
txClient
.
ExecContext
(
txCtx
,
"UPDATE user_affiliates SET aff_count = aff_count + 1, updated_at = NOW() WHERE user_id = $1"
,
inviterID
,
);
err
!=
nil
{
return
fmt
.
Errorf
(
"increment inviter aff_count: %w"
,
err
)
}
bound
=
true
return
nil
})
if
err
!=
nil
{
return
false
,
err
}
return
bound
,
nil
}
func
(
r
*
affiliateRepository
)
AccrueQuota
(
ctx
context
.
Context
,
inviterID
,
inviteeUserID
int64
,
amount
float64
)
(
bool
,
error
)
{
if
amount
<=
0
{
return
false
,
nil
}
var
applied
bool
err
:=
r
.
withTx
(
ctx
,
func
(
txCtx
context
.
Context
,
txClient
*
dbent
.
Client
)
error
{
res
,
err
:=
txClient
.
ExecContext
(
txCtx
,
"UPDATE user_affiliates SET aff_quota = aff_quota + $1, aff_history_quota = aff_history_quota + $1, updated_at = NOW() WHERE user_id = $2"
,
amount
,
inviterID
,
)
if
err
!=
nil
{
return
err
}
affected
,
_
:=
res
.
RowsAffected
()
if
affected
==
0
{
applied
=
false
return
nil
}
if
_
,
err
=
txClient
.
ExecContext
(
txCtx
,
`
INSERT INTO user_affiliate_ledger (user_id, action, amount, source_user_id, created_at, updated_at)
VALUES ($1, 'accrue', $2, $3, NOW(), NOW())`
,
inviterID
,
amount
,
inviteeUserID
);
err
!=
nil
{
return
fmt
.
Errorf
(
"insert affiliate accrue ledger: %w"
,
err
)
}
applied
=
true
return
nil
})
if
err
!=
nil
{
return
false
,
err
}
return
applied
,
nil
}
func
(
r
*
affiliateRepository
)
TransferQuotaToBalance
(
ctx
context
.
Context
,
userID
int64
)
(
float64
,
float64
,
error
)
{
var
transferred
float64
var
newBalance
float64
err
:=
r
.
withTx
(
ctx
,
func
(
txCtx
context
.
Context
,
txClient
*
dbent
.
Client
)
error
{
if
_
,
err
:=
ensureUserAffiliateWithClient
(
txCtx
,
txClient
,
userID
);
err
!=
nil
{
return
err
}
rows
,
err
:=
txClient
.
QueryContext
(
txCtx
,
`
WITH claimed AS (
SELECT aff_quota::double precision AS amount
FROM user_affiliates
WHERE user_id = $1
AND aff_quota > 0
FOR UPDATE
),
cleared AS (
UPDATE user_affiliates ua
SET aff_quota = 0,
updated_at = NOW()
FROM claimed c
WHERE ua.user_id = $1
RETURNING c.amount
)
SELECT amount
FROM cleared`
,
userID
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"claim affiliate quota: %w"
,
err
)
}
if
!
rows
.
Next
()
{
_
=
rows
.
Close
()
if
err
:=
rows
.
Err
();
err
!=
nil
{
return
err
}
return
service
.
ErrAffiliateQuotaEmpty
}
if
err
:=
rows
.
Scan
(
&
transferred
);
err
!=
nil
{
_
=
rows
.
Close
()
return
err
}
if
err
:=
rows
.
Close
();
err
!=
nil
{
return
err
}
if
transferred
<=
0
{
return
service
.
ErrAffiliateQuotaEmpty
}
affected
,
err
:=
txClient
.
User
.
Update
()
.
Where
(
user
.
IDEQ
(
userID
))
.
AddBalance
(
transferred
)
.
AddTotalRecharged
(
transferred
)
.
Save
(
txCtx
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"credit user balance by affiliate quota: %w"
,
err
)
}
if
affected
==
0
{
return
service
.
ErrUserNotFound
}
newBalance
,
err
=
queryUserBalance
(
txCtx
,
txClient
,
userID
)
if
err
!=
nil
{
return
err
}
if
_
,
err
=
txClient
.
ExecContext
(
txCtx
,
`
INSERT INTO user_affiliate_ledger (user_id, action, amount, source_user_id, created_at, updated_at)
VALUES ($1, 'transfer', $2, NULL, NOW(), NOW())`
,
userID
,
transferred
);
err
!=
nil
{
return
fmt
.
Errorf
(
"insert affiliate transfer ledger: %w"
,
err
)
}
return
nil
})
if
err
!=
nil
{
return
0
,
0
,
err
}
return
transferred
,
newBalance
,
nil
}
func
(
r
*
affiliateRepository
)
ListInvitees
(
ctx
context
.
Context
,
inviterID
int64
,
limit
int
)
([]
service
.
AffiliateInvitee
,
error
)
{
if
limit
<=
0
{
limit
=
100
}
client
:=
clientFromContext
(
ctx
,
r
.
client
)
rows
,
err
:=
client
.
QueryContext
(
ctx
,
`
SELECT ua.user_id,
COALESCE(u.email, ''),
COALESCE(u.username, ''),
ua.created_at
FROM user_affiliates ua
LEFT JOIN users u ON u.id = ua.user_id
WHERE ua.inviter_id = $1
ORDER BY ua.created_at DESC
LIMIT $2`
,
inviterID
,
limit
)
if
err
!=
nil
{
return
nil
,
err
}
defer
func
()
{
_
=
rows
.
Close
()
}()
invitees
:=
make
([]
service
.
AffiliateInvitee
,
0
)
for
rows
.
Next
()
{
var
item
service
.
AffiliateInvitee
var
createdAt
time
.
Time
if
err
:=
rows
.
Scan
(
&
item
.
UserID
,
&
item
.
Email
,
&
item
.
Username
,
&
createdAt
);
err
!=
nil
{
return
nil
,
err
}
item
.
CreatedAt
=
&
createdAt
invitees
=
append
(
invitees
,
item
)
}
if
err
:=
rows
.
Err
();
err
!=
nil
{
return
nil
,
err
}
return
invitees
,
nil
}
func
(
r
*
affiliateRepository
)
withTx
(
ctx
context
.
Context
,
fn
func
(
txCtx
context
.
Context
,
txClient
*
dbent
.
Client
)
error
)
error
{
if
tx
:=
dbent
.
TxFromContext
(
ctx
);
tx
!=
nil
{
return
fn
(
ctx
,
tx
.
Client
())
}
tx
,
err
:=
r
.
client
.
Tx
(
ctx
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"begin affiliate transaction: %w"
,
err
)
}
defer
func
()
{
_
=
tx
.
Rollback
()
}()
txCtx
:=
dbent
.
NewTxContext
(
ctx
,
tx
)
if
err
:=
fn
(
txCtx
,
tx
.
Client
());
err
!=
nil
{
return
err
}
if
err
:=
tx
.
Commit
();
err
!=
nil
{
return
fmt
.
Errorf
(
"commit affiliate transaction: %w"
,
err
)
}
return
nil
}
func
ensureUserAffiliateWithClient
(
ctx
context
.
Context
,
client
affiliateQueryExecer
,
userID
int64
)
(
*
service
.
AffiliateSummary
,
error
)
{
summary
,
err
:=
queryAffiliateByUserID
(
ctx
,
client
,
userID
)
if
err
==
nil
{
return
summary
,
nil
}
if
!
errors
.
Is
(
err
,
service
.
ErrAffiliateProfileNotFound
)
{
return
nil
,
err
}
for
i
:=
0
;
i
<
affiliateCodeMaxAttempts
;
i
++
{
code
,
codeErr
:=
generateAffiliateCode
()
if
codeErr
!=
nil
{
return
nil
,
codeErr
}
_
,
insertErr
:=
client
.
ExecContext
(
ctx
,
`
INSERT INTO user_affiliates (user_id, aff_code, created_at, updated_at)
VALUES ($1, $2, NOW(), NOW())
ON CONFLICT (user_id) DO NOTHING`
,
userID
,
code
)
if
insertErr
==
nil
{
break
}
if
isAffiliateUniqueViolation
(
insertErr
)
{
continue
}
return
nil
,
insertErr
}
return
queryAffiliateByUserID
(
ctx
,
client
,
userID
)
}
func
queryAffiliateByUserID
(
ctx
context
.
Context
,
client
affiliateQueryExecer
,
userID
int64
)
(
*
service
.
AffiliateSummary
,
error
)
{
rows
,
err
:=
client
.
QueryContext
(
ctx
,
`
SELECT user_id,
aff_code,
inviter_id,
aff_count,
aff_quota::double precision,
aff_history_quota::double precision,
created_at,
updated_at
FROM user_affiliates
WHERE user_id = $1`
,
userID
)
if
err
!=
nil
{
return
nil
,
err
}
defer
func
()
{
_
=
rows
.
Close
()
}()
if
!
rows
.
Next
()
{
if
err
:=
rows
.
Err
();
err
!=
nil
{
return
nil
,
err
}
return
nil
,
service
.
ErrAffiliateProfileNotFound
}
var
out
service
.
AffiliateSummary
var
inviterID
sql
.
NullInt64
if
err
:=
rows
.
Scan
(
&
out
.
UserID
,
&
out
.
AffCode
,
&
inviterID
,
&
out
.
AffCount
,
&
out
.
AffQuota
,
&
out
.
AffHistoryQuota
,
&
out
.
CreatedAt
,
&
out
.
UpdatedAt
,
);
err
!=
nil
{
return
nil
,
err
}
if
inviterID
.
Valid
{
out
.
InviterID
=
&
inviterID
.
Int64
}
return
&
out
,
nil
}
func
queryAffiliateByCode
(
ctx
context
.
Context
,
client
affiliateQueryExecer
,
code
string
)
(
*
service
.
AffiliateSummary
,
error
)
{
rows
,
err
:=
client
.
QueryContext
(
ctx
,
`
SELECT user_id,
aff_code,
inviter_id,
aff_count,
aff_quota::double precision,
aff_history_quota::double precision,
created_at,
updated_at
FROM user_affiliates
WHERE aff_code = $1
LIMIT 1`
,
strings
.
ToUpper
(
strings
.
TrimSpace
(
code
)))
if
err
!=
nil
{
return
nil
,
err
}
defer
func
()
{
_
=
rows
.
Close
()
}()
if
!
rows
.
Next
()
{
if
err
:=
rows
.
Err
();
err
!=
nil
{
return
nil
,
err
}
return
nil
,
service
.
ErrAffiliateProfileNotFound
}
var
out
service
.
AffiliateSummary
var
inviterID
sql
.
NullInt64
if
err
:=
rows
.
Scan
(
&
out
.
UserID
,
&
out
.
AffCode
,
&
inviterID
,
&
out
.
AffCount
,
&
out
.
AffQuota
,
&
out
.
AffHistoryQuota
,
&
out
.
CreatedAt
,
&
out
.
UpdatedAt
,
);
err
!=
nil
{
return
nil
,
err
}
if
inviterID
.
Valid
{
out
.
InviterID
=
&
inviterID
.
Int64
}
return
&
out
,
nil
}
func
queryUserBalance
(
ctx
context
.
Context
,
client
affiliateQueryExecer
,
userID
int64
)
(
float64
,
error
)
{
rows
,
err
:=
client
.
QueryContext
(
ctx
,
"SELECT balance::double precision FROM users WHERE id = $1 LIMIT 1"
,
userID
,
)
if
err
!=
nil
{
return
0
,
err
}
defer
func
()
{
_
=
rows
.
Close
()
}()
if
!
rows
.
Next
()
{
if
err
:=
rows
.
Err
();
err
!=
nil
{
return
0
,
err
}
return
0
,
service
.
ErrUserNotFound
}
var
balance
float64
if
err
:=
rows
.
Scan
(
&
balance
);
err
!=
nil
{
return
0
,
err
}
return
balance
,
nil
}
func
generateAffiliateCode
()
(
string
,
error
)
{
buf
:=
make
([]
byte
,
affiliateCodeLength
)
if
_
,
err
:=
rand
.
Read
(
buf
);
err
!=
nil
{
return
""
,
fmt
.
Errorf
(
"generate affiliate code: %w"
,
err
)
}
for
i
:=
range
buf
{
buf
[
i
]
=
affiliateCodeCharset
[
int
(
buf
[
i
])
%
len
(
affiliateCodeCharset
)]
}
return
string
(
buf
),
nil
}
func
isAffiliateUniqueViolation
(
err
error
)
bool
{
var
pqErr
*
pq
.
Error
if
errors
.
As
(
err
,
&
pqErr
)
{
return
string
(
pqErr
.
Code
)
==
"23505"
}
return
false
}
backend/internal/repository/affiliate_repo_integration_test.go
0 → 100644
View file @
5b5db885
//go:build integration
package
repository
import
(
"context"
"fmt"
"testing"
"time"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func
querySingleFloat
(
t
*
testing
.
T
,
ctx
context
.
Context
,
client
*
dbent
.
Client
,
query
string
,
args
...
any
)
float64
{
t
.
Helper
()
rows
,
err
:=
client
.
QueryContext
(
ctx
,
query
,
args
...
)
require
.
NoError
(
t
,
err
)
defer
func
()
{
_
=
rows
.
Close
()
}()
require
.
True
(
t
,
rows
.
Next
(),
"expected one row"
)
var
value
float64
require
.
NoError
(
t
,
rows
.
Scan
(
&
value
))
require
.
NoError
(
t
,
rows
.
Err
())
return
value
}
func
querySingleInt
(
t
*
testing
.
T
,
ctx
context
.
Context
,
client
*
dbent
.
Client
,
query
string
,
args
...
any
)
int
{
t
.
Helper
()
rows
,
err
:=
client
.
QueryContext
(
ctx
,
query
,
args
...
)
require
.
NoError
(
t
,
err
)
defer
func
()
{
_
=
rows
.
Close
()
}()
require
.
True
(
t
,
rows
.
Next
(),
"expected one row"
)
var
value
int
require
.
NoError
(
t
,
rows
.
Scan
(
&
value
))
require
.
NoError
(
t
,
rows
.
Err
())
return
value
}
func
TestAffiliateRepository_TransferQuotaToBalance_UsesClaimedQuotaBeforeClear
(
t
*
testing
.
T
)
{
ctx
:=
context
.
Background
()
tx
:=
testEntTx
(
t
)
txCtx
:=
dbent
.
NewTxContext
(
ctx
,
tx
)
client
:=
tx
.
Client
()
repo
:=
NewAffiliateRepository
(
client
,
integrationDB
)
u
:=
mustCreateUser
(
t
,
client
,
&
service
.
User
{
Email
:
fmt
.
Sprintf
(
"affiliate-transfer-%d@example.com"
,
time
.
Now
()
.
UnixNano
()),
PasswordHash
:
"hash"
,
Role
:
service
.
RoleUser
,
Status
:
service
.
StatusActive
,
Balance
:
5.5
,
Concurrency
:
5
,
})
affCode
:=
fmt
.
Sprintf
(
"AFF%09d"
,
time
.
Now
()
.
UnixNano
()
%
1
_000_000_000
)
_
,
err
:=
client
.
ExecContext
(
txCtx
,
`
INSERT INTO user_affiliates (user_id, aff_code, aff_quota, aff_history_quota, created_at, updated_at)
VALUES ($1, $2, $3, $3, NOW(), NOW())`
,
u
.
ID
,
affCode
,
12.34
)
require
.
NoError
(
t
,
err
)
transferred
,
balance
,
err
:=
repo
.
TransferQuotaToBalance
(
txCtx
,
u
.
ID
)
require
.
NoError
(
t
,
err
)
require
.
InDelta
(
t
,
12.34
,
transferred
,
1e-9
)
require
.
InDelta
(
t
,
17.84
,
balance
,
1e-9
)
affQuota
:=
querySingleFloat
(
t
,
txCtx
,
client
,
"SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1"
,
u
.
ID
)
require
.
InDelta
(
t
,
0.0
,
affQuota
,
1e-9
)
persistedBalance
:=
querySingleFloat
(
t
,
txCtx
,
client
,
"SELECT balance::double precision FROM users WHERE id = $1"
,
u
.
ID
)
require
.
InDelta
(
t
,
17.84
,
persistedBalance
,
1e-9
)
ledgerCount
:=
querySingleInt
(
t
,
txCtx
,
client
,
"SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'transfer'"
,
u
.
ID
)
require
.
Equal
(
t
,
1
,
ledgerCount
)
}
func
TestAffiliateRepository_TransferQuotaToBalance_EmptyQuota
(
t
*
testing
.
T
)
{
ctx
:=
context
.
Background
()
tx
:=
testEntTx
(
t
)
txCtx
:=
dbent
.
NewTxContext
(
ctx
,
tx
)
client
:=
tx
.
Client
()
repo
:=
NewAffiliateRepository
(
client
,
integrationDB
)
u
:=
mustCreateUser
(
t
,
client
,
&
service
.
User
{
Email
:
fmt
.
Sprintf
(
"affiliate-empty-%d@example.com"
,
time
.
Now
()
.
UnixNano
()),
PasswordHash
:
"hash"
,
Role
:
service
.
RoleUser
,
Status
:
service
.
StatusActive
,
Balance
:
3.21
,
Concurrency
:
5
,
})
affCode
:=
fmt
.
Sprintf
(
"AFF%09d"
,
time
.
Now
()
.
UnixNano
()
%
1
_000_000_000
)
_
,
err
:=
client
.
ExecContext
(
txCtx
,
`
INSERT INTO user_affiliates (user_id, aff_code, aff_quota, aff_history_quota, created_at, updated_at)
VALUES ($1, $2, 0, 0, NOW(), NOW())`
,
u
.
ID
,
affCode
)
require
.
NoError
(
t
,
err
)
transferred
,
balance
,
err
:=
repo
.
TransferQuotaToBalance
(
txCtx
,
u
.
ID
)
require
.
ErrorIs
(
t
,
err
,
service
.
ErrAffiliateQuotaEmpty
)
require
.
InDelta
(
t
,
0.0
,
transferred
,
1e-9
)
require
.
InDelta
(
t
,
0.0
,
balance
,
1e-9
)
persistedBalance
:=
querySingleFloat
(
t
,
txCtx
,
client
,
"SELECT balance::double precision FROM users WHERE id = $1"
,
u
.
ID
)
require
.
InDelta
(
t
,
3.21
,
persistedBalance
,
1e-9
)
}
backend/internal/repository/wire.go
View file @
5b5db885
...
...
@@ -91,6 +91,7 @@ var ProviderSet = wire.NewSet(
NewChannelRepository
,
NewChannelMonitorRepository
,
NewChannelMonitorRequestTemplateRepository
,
NewAffiliateRepository
,
// Cache implementations
NewGatewayCache
,
...
...
backend/internal/server/api_contract_test.go
View file @
5b5db885
...
...
@@ -715,6 +715,7 @@ func TestAPIContracts(t *testing.T) {
"force_email_on_third_party_signup": false,
"default_concurrency": 5,
"default_balance": 1.25,
"affiliate_rebate_rate": 20,
"default_user_rpm_limit": 0,
"default_subscriptions": [],
"enable_model_fallback": false,
...
...
@@ -895,6 +896,7 @@ func TestAPIContracts(t *testing.T) {
"custom_endpoints": [],
"default_concurrency": 0,
"default_balance": 0,
"affiliate_rebate_rate": 20,
"default_user_rpm_limit": 0,
"default_subscriptions": [],
"enable_model_fallback": false,
...
...
backend/internal/server/routes/user.go
View file @
5b5db885
...
...
@@ -25,6 +25,8 @@ func RegisterUserRoutes(
user
.
GET
(
"/profile"
,
h
.
User
.
GetProfile
)
user
.
PUT
(
"/password"
,
h
.
User
.
ChangePassword
)
user
.
PUT
(
""
,
h
.
User
.
UpdateProfile
)
user
.
GET
(
"/aff"
,
h
.
User
.
GetAffiliate
)
user
.
POST
(
"/aff/transfer"
,
h
.
User
.
TransferAffiliateQuota
)
user
.
POST
(
"/account-bindings/email/send-code"
,
h
.
User
.
SendEmailBindingCode
)
user
.
POST
(
"/account-bindings/email"
,
h
.
User
.
BindEmailIdentity
)
user
.
DELETE
(
"/account-bindings/:provider"
,
h
.
User
.
UnbindIdentity
)
...
...
backend/internal/service/affiliate_service.go
0 → 100644
View file @
5b5db885
package
service
import
(
"context"
"errors"
"math"
"strconv"
"strings"
"time"
infraerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
var
(
ErrAffiliateProfileNotFound
=
infraerrors
.
NotFound
(
"AFFILIATE_PROFILE_NOT_FOUND"
,
"affiliate profile not found"
)
ErrAffiliateCodeInvalid
=
infraerrors
.
BadRequest
(
"AFFILIATE_CODE_INVALID"
,
"invalid affiliate code"
)
ErrAffiliateAlreadyBound
=
infraerrors
.
Conflict
(
"AFFILIATE_ALREADY_BOUND"
,
"affiliate inviter already bound"
)
ErrAffiliateQuotaEmpty
=
infraerrors
.
BadRequest
(
"AFFILIATE_QUOTA_EMPTY"
,
"no affiliate quota available to transfer"
)
)
const
(
affiliateInviteesLimit
=
100
)
type
AffiliateSummary
struct
{
UserID
int64
`json:"user_id"`
AffCode
string
`json:"aff_code"`
InviterID
*
int64
`json:"inviter_id,omitempty"`
AffCount
int
`json:"aff_count"`
AffQuota
float64
`json:"aff_quota"`
AffHistoryQuota
float64
`json:"aff_history_quota"`
CreatedAt
time
.
Time
`json:"created_at"`
UpdatedAt
time
.
Time
`json:"updated_at"`
}
type
AffiliateInvitee
struct
{
UserID
int64
`json:"user_id"`
Email
string
`json:"email"`
Username
string
`json:"username"`
CreatedAt
*
time
.
Time
`json:"created_at,omitempty"`
}
type
AffiliateDetail
struct
{
UserID
int64
`json:"user_id"`
AffCode
string
`json:"aff_code"`
InviterID
*
int64
`json:"inviter_id,omitempty"`
AffCount
int
`json:"aff_count"`
AffQuota
float64
`json:"aff_quota"`
AffHistoryQuota
float64
`json:"aff_history_quota"`
Invitees
[]
AffiliateInvitee
`json:"invitees"`
}
type
AffiliateRepository
interface
{
EnsureUserAffiliate
(
ctx
context
.
Context
,
userID
int64
)
(
*
AffiliateSummary
,
error
)
GetAffiliateByCode
(
ctx
context
.
Context
,
code
string
)
(
*
AffiliateSummary
,
error
)
BindInviter
(
ctx
context
.
Context
,
userID
,
inviterID
int64
)
(
bool
,
error
)
AccrueQuota
(
ctx
context
.
Context
,
inviterID
,
inviteeUserID
int64
,
amount
float64
)
(
bool
,
error
)
TransferQuotaToBalance
(
ctx
context
.
Context
,
userID
int64
)
(
float64
,
float64
,
error
)
ListInvitees
(
ctx
context
.
Context
,
inviterID
int64
,
limit
int
)
([]
AffiliateInvitee
,
error
)
}
type
AffiliateService
struct
{
repo
AffiliateRepository
settingRepo
SettingRepository
authCacheInvalidator
APIKeyAuthCacheInvalidator
billingCacheService
*
BillingCacheService
}
func
NewAffiliateService
(
repo
AffiliateRepository
,
settingRepo
SettingRepository
,
authCacheInvalidator
APIKeyAuthCacheInvalidator
,
billingCacheService
*
BillingCacheService
)
*
AffiliateService
{
return
&
AffiliateService
{
repo
:
repo
,
settingRepo
:
settingRepo
,
authCacheInvalidator
:
authCacheInvalidator
,
billingCacheService
:
billingCacheService
,
}
}
func
(
s
*
AffiliateService
)
EnsureUserAffiliate
(
ctx
context
.
Context
,
userID
int64
)
(
*
AffiliateSummary
,
error
)
{
if
userID
<=
0
{
return
nil
,
infraerrors
.
BadRequest
(
"INVALID_USER"
,
"invalid user"
)
}
if
s
==
nil
||
s
.
repo
==
nil
{
return
nil
,
infraerrors
.
ServiceUnavailable
(
"SERVICE_UNAVAILABLE"
,
"affiliate service unavailable"
)
}
return
s
.
repo
.
EnsureUserAffiliate
(
ctx
,
userID
)
}
func
(
s
*
AffiliateService
)
GetAffiliateDetail
(
ctx
context
.
Context
,
userID
int64
)
(
*
AffiliateDetail
,
error
)
{
summary
,
err
:=
s
.
EnsureUserAffiliate
(
ctx
,
userID
)
if
err
!=
nil
{
return
nil
,
err
}
invitees
,
err
:=
s
.
listInvitees
(
ctx
,
userID
)
if
err
!=
nil
{
return
nil
,
err
}
return
&
AffiliateDetail
{
UserID
:
summary
.
UserID
,
AffCode
:
summary
.
AffCode
,
InviterID
:
summary
.
InviterID
,
AffCount
:
summary
.
AffCount
,
AffQuota
:
summary
.
AffQuota
,
AffHistoryQuota
:
summary
.
AffHistoryQuota
,
Invitees
:
invitees
,
},
nil
}
func
(
s
*
AffiliateService
)
BindInviterByCode
(
ctx
context
.
Context
,
userID
int64
,
rawCode
string
)
error
{
code
:=
strings
.
ToUpper
(
strings
.
TrimSpace
(
rawCode
))
if
code
==
""
{
return
nil
}
if
s
==
nil
||
s
.
repo
==
nil
{
return
infraerrors
.
ServiceUnavailable
(
"SERVICE_UNAVAILABLE"
,
"affiliate service unavailable"
)
}
selfSummary
,
err
:=
s
.
repo
.
EnsureUserAffiliate
(
ctx
,
userID
)
if
err
!=
nil
{
return
err
}
if
selfSummary
.
InviterID
!=
nil
{
return
nil
}
inviterSummary
,
err
:=
s
.
repo
.
GetAffiliateByCode
(
ctx
,
code
)
if
err
!=
nil
{
if
errors
.
Is
(
err
,
ErrAffiliateProfileNotFound
)
{
return
ErrAffiliateCodeInvalid
}
return
err
}
if
inviterSummary
==
nil
||
inviterSummary
.
UserID
<=
0
||
inviterSummary
.
UserID
==
userID
{
return
ErrAffiliateCodeInvalid
}
bound
,
err
:=
s
.
repo
.
BindInviter
(
ctx
,
userID
,
inviterSummary
.
UserID
)
if
err
!=
nil
{
return
err
}
if
!
bound
{
return
ErrAffiliateAlreadyBound
}
return
nil
}
func
(
s
*
AffiliateService
)
AccrueInviteRebate
(
ctx
context
.
Context
,
inviteeUserID
int64
,
baseRechargeAmount
float64
)
(
float64
,
error
)
{
if
s
==
nil
||
s
.
repo
==
nil
{
return
0
,
nil
}
if
inviteeUserID
<=
0
||
baseRechargeAmount
<=
0
||
math
.
IsNaN
(
baseRechargeAmount
)
||
math
.
IsInf
(
baseRechargeAmount
,
0
)
{
return
0
,
nil
}
inviteeSummary
,
err
:=
s
.
repo
.
EnsureUserAffiliate
(
ctx
,
inviteeUserID
)
if
err
!=
nil
{
return
0
,
err
}
if
inviteeSummary
.
InviterID
==
nil
||
*
inviteeSummary
.
InviterID
<=
0
{
return
0
,
nil
}
rebateRatePercent
:=
s
.
loadAffiliateRebateRatePercent
(
ctx
)
rebate
:=
roundTo
(
baseRechargeAmount
*
(
rebateRatePercent
/
100
),
8
)
if
rebate
<=
0
{
return
0
,
nil
}
if
_
,
err
:=
s
.
repo
.
EnsureUserAffiliate
(
ctx
,
*
inviteeSummary
.
InviterID
);
err
!=
nil
{
return
0
,
err
}
applied
,
err
:=
s
.
repo
.
AccrueQuota
(
ctx
,
*
inviteeSummary
.
InviterID
,
inviteeUserID
,
rebate
)
if
err
!=
nil
{
return
0
,
err
}
if
!
applied
{
return
0
,
nil
}
return
rebate
,
nil
}
func
(
s
*
AffiliateService
)
TransferAffiliateQuota
(
ctx
context
.
Context
,
userID
int64
)
(
float64
,
float64
,
error
)
{
if
s
==
nil
||
s
.
repo
==
nil
{
return
0
,
0
,
infraerrors
.
ServiceUnavailable
(
"SERVICE_UNAVAILABLE"
,
"affiliate service unavailable"
)
}
transferred
,
balance
,
err
:=
s
.
repo
.
TransferQuotaToBalance
(
ctx
,
userID
)
if
err
!=
nil
{
return
0
,
0
,
err
}
if
transferred
>
0
{
s
.
invalidateAffiliateCaches
(
ctx
,
userID
)
}
return
transferred
,
balance
,
nil
}
func
(
s
*
AffiliateService
)
listInvitees
(
ctx
context
.
Context
,
inviterID
int64
)
([]
AffiliateInvitee
,
error
)
{
if
s
==
nil
||
s
.
repo
==
nil
{
return
nil
,
infraerrors
.
ServiceUnavailable
(
"SERVICE_UNAVAILABLE"
,
"affiliate service unavailable"
)
}
invitees
,
err
:=
s
.
repo
.
ListInvitees
(
ctx
,
inviterID
,
affiliateInviteesLimit
)
if
err
!=
nil
{
return
nil
,
err
}
for
i
:=
range
invitees
{
invitees
[
i
]
.
Email
=
maskEmail
(
invitees
[
i
]
.
Email
)
}
return
invitees
,
nil
}
func
(
s
*
AffiliateService
)
loadAffiliateRebateRatePercent
(
ctx
context
.
Context
)
float64
{
if
s
==
nil
||
s
.
settingRepo
==
nil
{
return
AffiliateRebateRateDefault
}
raw
,
err
:=
s
.
settingRepo
.
GetValue
(
ctx
,
SettingKeyAffiliateRebateRate
)
if
err
!=
nil
{
return
AffiliateRebateRateDefault
}
rate
,
err
:=
strconv
.
ParseFloat
(
strings
.
TrimSpace
(
raw
),
64
)
if
err
!=
nil
{
return
AffiliateRebateRateDefault
}
if
math
.
IsNaN
(
rate
)
||
math
.
IsInf
(
rate
,
0
)
{
return
AffiliateRebateRateDefault
}
if
rate
<
AffiliateRebateRateMin
{
return
AffiliateRebateRateMin
}
if
rate
>
AffiliateRebateRateMax
{
return
AffiliateRebateRateMax
}
return
rate
}
func
roundTo
(
v
float64
,
scale
int
)
float64
{
factor
:=
math
.
Pow10
(
scale
)
return
math
.
Round
(
v
*
factor
)
/
factor
}
func
maskEmail
(
email
string
)
string
{
email
=
strings
.
TrimSpace
(
email
)
if
email
==
""
{
return
""
}
at
:=
strings
.
Index
(
email
,
"@"
)
if
at
<=
0
||
at
>=
len
(
email
)
-
1
{
return
"***"
}
local
:=
email
[
:
at
]
domain
:=
email
[
at
+
1
:
]
dot
:=
strings
.
LastIndex
(
domain
,
"."
)
maskedLocal
:=
maskSegment
(
local
)
if
dot
<=
0
||
dot
>=
len
(
domain
)
-
1
{
return
maskedLocal
+
"@"
+
maskSegment
(
domain
)
}
domainName
:=
domain
[
:
dot
]
tld
:=
domain
[
dot
:
]
return
maskedLocal
+
"@"
+
maskSegment
(
domainName
)
+
tld
}
func
maskSegment
(
s
string
)
string
{
r
:=
[]
rune
(
s
)
if
len
(
r
)
==
0
{
return
"***"
}
if
len
(
r
)
==
1
{
return
string
(
r
[
0
])
+
"***"
}
return
string
(
r
[
0
])
+
"***"
}
func
(
s
*
AffiliateService
)
invalidateAffiliateCaches
(
ctx
context
.
Context
,
userID
int64
)
{
if
s
.
authCacheInvalidator
!=
nil
{
s
.
authCacheInvalidator
.
InvalidateAuthCacheByUserID
(
ctx
,
userID
)
}
if
s
.
billingCacheService
!=
nil
{
go
func
()
{
cacheCtx
,
cancel
:=
context
.
WithTimeout
(
context
.
Background
(),
5
*
time
.
Second
)
defer
cancel
()
_
=
s
.
billingCacheService
.
InvalidateUserBalance
(
cacheCtx
,
userID
)
}()
}
}
backend/internal/service/affiliate_service_test.go
0 → 100644
View file @
5b5db885
//go:build unit
package
service
import
(
"context"
"testing"
"github.com/stretchr/testify/require"
)
type
affiliateSettingRepoStub
struct
{
value
string
err
error
}
func
(
s
*
affiliateSettingRepoStub
)
Get
(
context
.
Context
,
string
)
(
*
Setting
,
error
)
{
return
nil
,
s
.
err
}
func
(
s
*
affiliateSettingRepoStub
)
GetValue
(
context
.
Context
,
string
)
(
string
,
error
)
{
if
s
.
err
!=
nil
{
return
""
,
s
.
err
}
return
s
.
value
,
nil
}
func
(
s
*
affiliateSettingRepoStub
)
Set
(
context
.
Context
,
string
,
string
)
error
{
return
s
.
err
}
func
(
s
*
affiliateSettingRepoStub
)
GetMultiple
(
context
.
Context
,
[]
string
)
(
map
[
string
]
string
,
error
)
{
if
s
.
err
!=
nil
{
return
nil
,
s
.
err
}
return
map
[
string
]
string
{},
nil
}
func
(
s
*
affiliateSettingRepoStub
)
SetMultiple
(
context
.
Context
,
map
[
string
]
string
)
error
{
return
s
.
err
}
func
(
s
*
affiliateSettingRepoStub
)
GetAll
(
context
.
Context
)
(
map
[
string
]
string
,
error
)
{
if
s
.
err
!=
nil
{
return
nil
,
s
.
err
}
return
map
[
string
]
string
{},
nil
}
func
(
s
*
affiliateSettingRepoStub
)
Delete
(
context
.
Context
,
string
)
error
{
return
s
.
err
}
func
TestAffiliateRebateRatePercentSemantics
(
t
*
testing
.
T
)
{
t
.
Parallel
()
svc
:=
&
AffiliateService
{
settingRepo
:
&
affiliateSettingRepoStub
{
value
:
"1"
}}
rate
:=
svc
.
loadAffiliateRebateRatePercent
(
context
.
Background
())
require
.
Equal
(
t
,
1.0
,
rate
)
svc
.
settingRepo
=
&
affiliateSettingRepoStub
{
value
:
"0.2"
}
rate
=
svc
.
loadAffiliateRebateRatePercent
(
context
.
Background
())
require
.
Equal
(
t
,
0.2
,
rate
)
}
func
TestMaskEmail
(
t
*
testing
.
T
)
{
t
.
Parallel
()
require
.
Equal
(
t
,
"a***@g***.com"
,
maskEmail
(
"alice@gmail.com"
))
require
.
Equal
(
t
,
"x***@d***"
,
maskEmail
(
"x@domain"
))
require
.
Equal
(
t
,
""
,
maskEmail
(
""
))
}
backend/internal/service/auth_service.go
View file @
5b5db885
...
...
@@ -72,6 +72,7 @@ type AuthService struct {
turnstileService
*
TurnstileService
emailQueueService
*
EmailQueueService
promoService
*
PromoService
affiliateService
*
AffiliateService
defaultSubAssigner
DefaultSubscriptionAssigner
}
...
...
@@ -121,13 +122,26 @@ func (s *AuthService) EntClient() *dbent.Client {
return
s
.
entClient
}
func
(
s
*
AuthService
)
SetAffiliateService
(
affiliateService
*
AffiliateService
)
{
if
s
==
nil
{
return
}
s
.
affiliateService
=
affiliateService
}
// Register 用户注册,返回token和用户
func
(
s
*
AuthService
)
Register
(
ctx
context
.
Context
,
email
,
password
string
)
(
string
,
*
User
,
error
)
{
return
s
.
RegisterWithVerification
(
ctx
,
email
,
password
,
""
,
""
,
""
)
}
// RegisterWithVerification 用户注册(支持邮件验证、优惠码和邀请码),返回token和用户
func
(
s
*
AuthService
)
RegisterWithVerification
(
ctx
context
.
Context
,
email
,
password
,
verifyCode
,
promoCode
,
invitationCode
string
)
(
string
,
*
User
,
error
)
{
// RegisterWithVerification 用户注册(支持邮件验证、优惠码、邀请码和邀请返利码),返回token和用户。
// affiliateCode 使用可选参数以兼容旧调用方。
func
(
s
*
AuthService
)
RegisterWithVerification
(
ctx
context
.
Context
,
email
,
password
,
verifyCode
,
promoCode
,
invitationCode
string
,
affiliateCode
...
string
)
(
string
,
*
User
,
error
)
{
affiliateCodeRaw
:=
""
if
len
(
affiliateCode
)
>
0
{
affiliateCodeRaw
=
affiliateCode
[
0
]
}
// 检查是否开放注册(默认关闭:settingService 未配置时不允许注册)
if
s
.
settingService
==
nil
||
!
s
.
settingService
.
IsRegistrationEnabled
(
ctx
)
{
return
""
,
nil
,
ErrRegDisabled
...
...
@@ -223,6 +237,17 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
}
s
.
postAuthUserBootstrap
(
ctx
,
user
,
"email"
,
true
)
s
.
assignSubscriptions
(
ctx
,
user
.
ID
,
grantPlan
.
Subscriptions
,
"auto assigned by signup defaults"
)
if
s
.
affiliateService
!=
nil
{
if
_
,
err
:=
s
.
affiliateService
.
EnsureUserAffiliate
(
ctx
,
user
.
ID
);
err
!=
nil
{
logger
.
LegacyPrintf
(
"service.auth"
,
"[Auth] Failed to initialize affiliate profile for user %d: %v"
,
user
.
ID
,
err
)
}
if
code
:=
strings
.
TrimSpace
(
affiliateCodeRaw
);
code
!=
""
{
if
err
:=
s
.
affiliateService
.
BindInviterByCode
(
ctx
,
user
.
ID
,
code
);
err
!=
nil
{
// 邀请返利码绑定失败不影响注册,只记录日志
logger
.
LegacyPrintf
(
"service.auth"
,
"[Auth] Failed to bind affiliate inviter for user %d: %v"
,
user
.
ID
,
err
)
}
}
}
// 标记邀请码为已使用(如果使用了邀请码)
if
invitationRedeemCode
!=
nil
{
...
...
backend/internal/service/domain_constants.go
View file @
5b5db885
...
...
@@ -18,6 +18,13 @@ const (
RoleUser
=
domain
.
RoleUser
)
// Affiliate rebate settings
const
(
AffiliateRebateRateDefault
=
20.0
AffiliateRebateRateMin
=
0.0
AffiliateRebateRateMax
=
100.0
)
// Platform constants
const
(
PlatformAnthropic
=
domain
.
PlatformAnthropic
...
...
@@ -87,6 +94,7 @@ const (
SettingKeyPasswordResetEnabled
=
"password_reset_enabled"
// 是否启用忘记密码功能(需要先开启邮件验证)
SettingKeyFrontendURL
=
"frontend_url"
// 前端基础URL,用于生成邮件中的重置密码链接
SettingKeyInvitationCodeEnabled
=
"invitation_code_enabled"
// 是否启用邀请码注册
SettingKeyAffiliateRebateRate
=
"affiliate_rebate_rate"
// 邀请返利比例(百分比,0-100)
// 邮件服务设置
SettingKeySMTPHost
=
"smtp_host"
// SMTP服务器地址
...
...
backend/internal/service/payment_fulfillment.go
View file @
5b5db885
...
...
@@ -2,6 +2,7 @@ package service
import
(
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
...
...
@@ -268,6 +269,7 @@ func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder) e
switch
action
{
case
redeemActionSkipCompleted
:
s
.
applyAffiliateRebateForOrder
(
ctx
,
o
)
// Code already created and redeemed — just mark completed
return
s
.
markCompleted
(
ctx
,
o
,
"RECHARGE_SUCCESS"
)
case
redeemActionCreate
:
...
...
@@ -281,6 +283,7 @@ func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder) e
if
_
,
err
:=
s
.
redeemService
.
Redeem
(
ctx
,
o
.
UserID
,
o
.
RechargeCode
);
err
!=
nil
{
return
fmt
.
Errorf
(
"redeem balance: %w"
,
err
)
}
s
.
applyAffiliateRebateForOrder
(
ctx
,
o
)
return
s
.
markCompleted
(
ctx
,
o
,
"RECHARGE_SUCCESS"
)
}
...
...
@@ -358,6 +361,139 @@ func (s *PaymentService) hasAuditLog(ctx context.Context, orderID int64, action
return
c
>
0
}
func
(
s
*
PaymentService
)
applyAffiliateRebateForOrder
(
ctx
context
.
Context
,
o
*
dbent
.
PaymentOrder
)
{
if
o
==
nil
||
o
.
OrderType
!=
payment
.
OrderTypeBalance
||
o
.
Amount
<=
0
{
return
}
if
s
.
affiliateService
==
nil
{
return
}
tx
,
err
:=
s
.
entClient
.
Tx
(
ctx
)
if
err
!=
nil
{
s
.
writeAuditLog
(
ctx
,
o
.
ID
,
"AFFILIATE_REBATE_FAILED"
,
"system"
,
map
[
string
]
any
{
"error"
:
fmt
.
Sprintf
(
"begin affiliate rebate tx: %v"
,
err
),
})
return
}
defer
func
()
{
_
=
tx
.
Rollback
()
}()
txCtx
:=
dbent
.
NewTxContext
(
ctx
,
tx
)
claimed
,
err
:=
s
.
tryClaimAffiliateRebateAudit
(
txCtx
,
tx
.
Client
(),
o
.
ID
,
o
.
Amount
)
if
err
!=
nil
{
s
.
writeAuditLog
(
ctx
,
o
.
ID
,
"AFFILIATE_REBATE_FAILED"
,
"system"
,
map
[
string
]
any
{
"error"
:
err
.
Error
(),
})
return
}
if
!
claimed
{
return
}
rebateAmount
,
err
:=
s
.
affiliateService
.
AccrueInviteRebate
(
txCtx
,
o
.
UserID
,
o
.
Amount
)
if
err
!=
nil
{
s
.
writeAuditLog
(
ctx
,
o
.
ID
,
"AFFILIATE_REBATE_FAILED"
,
"system"
,
map
[
string
]
any
{
"error"
:
err
.
Error
(),
})
return
}
if
rebateAmount
<=
0
{
if
err
:=
s
.
updateClaimedAffiliateRebateAudit
(
txCtx
,
tx
.
Client
(),
o
.
ID
,
"AFFILIATE_REBATE_SKIPPED"
,
map
[
string
]
any
{
"baseAmount"
:
o
.
Amount
,
"reason"
:
"no inviter bound or rebate amount <= 0"
,
});
err
!=
nil
{
s
.
writeAuditLog
(
ctx
,
o
.
ID
,
"AFFILIATE_REBATE_FAILED"
,
"system"
,
map
[
string
]
any
{
"error"
:
err
.
Error
(),
})
return
}
if
err
:=
tx
.
Commit
();
err
!=
nil
{
s
.
writeAuditLog
(
ctx
,
o
.
ID
,
"AFFILIATE_REBATE_FAILED"
,
"system"
,
map
[
string
]
any
{
"error"
:
fmt
.
Sprintf
(
"commit affiliate rebate tx: %v"
,
err
),
})
}
return
}
if
err
:=
s
.
updateClaimedAffiliateRebateAudit
(
txCtx
,
tx
.
Client
(),
o
.
ID
,
"AFFILIATE_REBATE_APPLIED"
,
map
[
string
]
any
{
"baseAmount"
:
o
.
Amount
,
"rebateAmount"
:
rebateAmount
,
});
err
!=
nil
{
s
.
writeAuditLog
(
ctx
,
o
.
ID
,
"AFFILIATE_REBATE_FAILED"
,
"system"
,
map
[
string
]
any
{
"error"
:
err
.
Error
(),
})
return
}
if
err
:=
tx
.
Commit
();
err
!=
nil
{
s
.
writeAuditLog
(
ctx
,
o
.
ID
,
"AFFILIATE_REBATE_FAILED"
,
"system"
,
map
[
string
]
any
{
"error"
:
fmt
.
Sprintf
(
"commit affiliate rebate tx: %v"
,
err
),
})
}
}
func
(
s
*
PaymentService
)
tryClaimAffiliateRebateAudit
(
ctx
context
.
Context
,
client
*
dbent
.
Client
,
orderID
int64
,
baseAmount
float64
)
(
bool
,
error
)
{
if
client
==
nil
{
return
false
,
errors
.
New
(
"nil payment client"
)
}
oid
:=
strconv
.
FormatInt
(
orderID
,
10
)
detail
,
_
:=
json
.
Marshal
(
map
[
string
]
any
{
"baseAmount"
:
baseAmount
,
"status"
:
"reserved"
,
})
rows
,
err
:=
client
.
QueryContext
(
ctx
,
`
INSERT INTO payment_audit_logs (order_id, action, detail, operator, created_at)
SELECT $1, 'AFFILIATE_REBATE_APPLIED', $2, 'system', NOW()
WHERE NOT EXISTS (
SELECT 1
FROM payment_audit_logs
WHERE order_id = $1
AND action IN ('AFFILIATE_REBATE_APPLIED', 'AFFILIATE_REBATE_SKIPPED')
)
ON CONFLICT (order_id, action) DO NOTHING
RETURNING id`
,
oid
,
string
(
detail
))
if
err
!=
nil
{
return
false
,
err
}
defer
func
()
{
_
=
rows
.
Close
()
}()
if
!
rows
.
Next
()
{
if
err
:=
rows
.
Err
();
err
!=
nil
{
return
false
,
err
}
return
false
,
nil
}
var
claimID
int64
if
err
:=
rows
.
Scan
(
&
claimID
);
err
!=
nil
{
return
false
,
err
}
return
true
,
nil
}
func
(
s
*
PaymentService
)
updateClaimedAffiliateRebateAudit
(
ctx
context
.
Context
,
client
*
dbent
.
Client
,
orderID
int64
,
action
string
,
detail
map
[
string
]
any
)
error
{
if
client
==
nil
{
return
errors
.
New
(
"nil payment client"
)
}
oid
:=
strconv
.
FormatInt
(
orderID
,
10
)
detailJSON
,
_
:=
json
.
Marshal
(
detail
)
updated
,
err
:=
client
.
PaymentAuditLog
.
Update
()
.
Where
(
paymentauditlog
.
OrderIDEQ
(
oid
),
paymentauditlog
.
ActionEQ
(
"AFFILIATE_REBATE_APPLIED"
),
)
.
SetAction
(
action
)
.
SetDetail
(
string
(
detailJSON
))
.
SetOperator
(
"system"
)
.
Save
(
ctx
)
if
err
!=
nil
{
return
err
}
if
updated
==
0
{
return
errors
.
New
(
"affiliate rebate claim log not found"
)
}
return
nil
}
func
(
s
*
PaymentService
)
markFailed
(
ctx
context
.
Context
,
oid
int64
,
cause
error
)
{
now
:=
time
.
Now
()
r
:=
psErrMsg
(
cause
)
...
...
backend/internal/service/payment_service.go
View file @
5b5db885
...
...
@@ -181,6 +181,7 @@ type PaymentService struct {
userRepo
UserRepository
groupRepo
GroupRepository
resumeService
*
PaymentResumeService
affiliateService
*
AffiliateService
}
func
NewPaymentService
(
entClient
*
dbent
.
Client
,
registry
*
payment
.
Registry
,
loadBalancer
payment
.
LoadBalancer
,
redeemService
*
RedeemService
,
subscriptionSvc
*
SubscriptionService
,
configService
*
PaymentConfigService
,
userRepo
UserRepository
,
groupRepo
GroupRepository
)
*
PaymentService
{
...
...
@@ -189,6 +190,13 @@ func NewPaymentService(entClient *dbent.Client, registry *payment.Registry, load
return
svc
}
func
(
s
*
PaymentService
)
SetAffiliateService
(
affiliateService
*
AffiliateService
)
{
if
s
==
nil
{
return
}
s
.
affiliateService
=
affiliateService
}
// --- Provider Registry ---
// EnsureProviders lazily initializes the provider registry on first call.
...
...
backend/internal/service/setting_service.go
View file @
5b5db885
...
...
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"log/slog"
"math"
"net/url"
"sort"
"strconv"
...
...
@@ -1167,6 +1168,8 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
// 默认配置
updates
[
SettingKeyDefaultConcurrency
]
=
strconv
.
Itoa
(
settings
.
DefaultConcurrency
)
updates
[
SettingKeyDefaultBalance
]
=
strconv
.
FormatFloat
(
settings
.
DefaultBalance
,
'f'
,
8
,
64
)
settings
.
AffiliateRebateRate
=
clampAffiliateRebateRate
(
settings
.
AffiliateRebateRate
)
updates
[
SettingKeyAffiliateRebateRate
]
=
strconv
.
FormatFloat
(
settings
.
AffiliateRebateRate
,
'f'
,
8
,
64
)
updates
[
SettingKeyDefaultUserRPMLimit
]
=
strconv
.
Itoa
(
settings
.
DefaultUserRPMLimit
)
defaultSubsJSON
,
err
:=
json
.
Marshal
(
settings
.
DefaultSubscriptions
)
if
err
!=
nil
{
...
...
@@ -1719,6 +1722,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyOIDCConnectUserInfoUsernamePath
:
""
,
SettingKeyDefaultConcurrency
:
strconv
.
Itoa
(
s
.
cfg
.
Default
.
UserConcurrency
),
SettingKeyDefaultBalance
:
strconv
.
FormatFloat
(
s
.
cfg
.
Default
.
UserBalance
,
'f'
,
8
,
64
),
SettingKeyAffiliateRebateRate
:
strconv
.
FormatFloat
(
AffiliateRebateRateDefault
,
'f'
,
8
,
64
),
SettingKeyDefaultUserRPMLimit
:
"0"
,
SettingKeyDefaultSubscriptions
:
"[]"
,
SettingKeyAuthSourceDefaultEmailBalance
:
"0"
,
...
...
@@ -1846,6 +1850,11 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
}
else
{
result
.
DefaultBalance
=
s
.
cfg
.
Default
.
UserBalance
}
if
rebateRate
,
err
:=
strconv
.
ParseFloat
(
settings
[
SettingKeyAffiliateRebateRate
],
64
);
err
==
nil
{
result
.
AffiliateRebateRate
=
clampAffiliateRebateRate
(
rebateRate
)
}
else
{
result
.
AffiliateRebateRate
=
AffiliateRebateRateDefault
}
result
.
DefaultSubscriptions
=
parseDefaultSubscriptions
(
settings
[
SettingKeyDefaultSubscriptions
])
// 敏感信息直接返回,方便测试连接时使用
...
...
@@ -2130,6 +2139,19 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
return
result
}
func
clampAffiliateRebateRate
(
value
float64
)
float64
{
if
math
.
IsNaN
(
value
)
||
math
.
IsInf
(
value
,
0
)
{
return
AffiliateRebateRateDefault
}
if
value
<
AffiliateRebateRateMin
{
return
AffiliateRebateRateMin
}
if
value
>
AffiliateRebateRateMax
{
return
AffiliateRebateRateMax
}
return
value
}
func
isFalseSettingValue
(
value
string
)
bool
{
switch
strings
.
ToLower
(
strings
.
TrimSpace
(
value
))
{
case
"false"
,
"0"
,
"off"
,
"disabled"
:
...
...
backend/internal/service/settings_view.go
View file @
5b5db885
...
...
@@ -106,6 +106,7 @@ type SystemSettings struct {
DefaultConcurrency
int
DefaultBalance
float64
AffiliateRebateRate
float64
DefaultUserRPMLimit
int
DefaultSubscriptions
[]
DefaultSubscriptionSetting
...
...
backend/internal/service/wire.go
View file @
5b5db885
...
...
@@ -391,6 +391,53 @@ func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupReposit
return
svc
}
func
ProvideAuthService
(
entClient
*
dbent
.
Client
,
userRepo
UserRepository
,
redeemRepo
RedeemCodeRepository
,
refreshTokenCache
RefreshTokenCache
,
cfg
*
config
.
Config
,
settingService
*
SettingService
,
emailService
*
EmailService
,
turnstileService
*
TurnstileService
,
emailQueueService
*
EmailQueueService
,
promoService
*
PromoService
,
defaultSubAssigner
DefaultSubscriptionAssigner
,
affiliateService
*
AffiliateService
,
)
*
AuthService
{
svc
:=
NewAuthService
(
entClient
,
userRepo
,
redeemRepo
,
refreshTokenCache
,
cfg
,
settingService
,
emailService
,
turnstileService
,
emailQueueService
,
promoService
,
defaultSubAssigner
,
)
svc
.
SetAffiliateService
(
affiliateService
)
return
svc
}
func
ProvidePaymentService
(
entClient
*
dbent
.
Client
,
registry
*
payment
.
Registry
,
loadBalancer
payment
.
LoadBalancer
,
redeemService
*
RedeemService
,
subscriptionSvc
*
SubscriptionService
,
configService
*
PaymentConfigService
,
userRepo
UserRepository
,
groupRepo
GroupRepository
,
affiliateService
*
AffiliateService
,
)
*
PaymentService
{
svc
:=
NewPaymentService
(
entClient
,
registry
,
loadBalancer
,
redeemService
,
subscriptionSvc
,
configService
,
userRepo
,
groupRepo
)
svc
.
SetAffiliateService
(
affiliateService
)
return
svc
}
// ProvideBillingCacheService wires BillingCacheService with its RPM dependencies.
func
ProvideBillingCacheService
(
cache
BillingCache
,
...
...
@@ -407,7 +454,7 @@ func ProvideBillingCacheService(
// ProviderSet is the Wire provider set for all services
var
ProviderSet
=
wire
.
NewSet
(
// Core services
New
AuthService
,
Provide
AuthService
,
NewUserService
,
NewAPIKeyService
,
ProvideAPIKeyAuthCacheInvalidator
,
...
...
@@ -486,8 +533,9 @@ var ProviderSet = wire.NewSet(
NewGroupCapacityService
,
NewChannelService
,
NewModelPricingResolver
,
NewAffiliateService
,
ProvidePaymentConfigService
,
New
PaymentService
,
Provide
PaymentService
,
ProvidePaymentOrderExpiryService
,
ProvideBalanceNotifyService
,
ProvideChannelMonitorService
,
...
...
Prev
1
2
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment