From 1efe235d818554865fa198e72dae96876c1d9310 Mon Sep 17 00:00:00 2001 From: JIA-ss <627723154@qq.com> Date: Tue, 3 Mar 2026 15:09:45 +0800 Subject: [PATCH 1/5] feat: add referral system for user growth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement a full referral/invite system that lets users invite others and earn rewards when their invitees register. ## Backend ### Data model - New Ent schemas: `UserReferralProfile` (per-user referral code) and `ReferralRelation` (inviter↔invitee binding with reward amounts). `invitee_id` carries a UNIQUE constraint to prevent duplicate grants. - New migrations 064/065: create tables and add performance indexes (inviter_id, invitee_id, reward_granted) in a non-transactional file so they work with MySQL's implicit-commit DDL. ### Service layer (`referral.go` / `referral_service.go`) - `ReferralRepository` interface covers profile CRUD, relation CRUD, reward helpers, and list/stats queries. - `ReferralService.ProcessReferralRegistration` wraps reward grants in a single DB transaction; rolls back atomically on any failure. - `GrantRewardsInTx` updates both balances then sets `reward_granted` via `MarkRewardGranted`, giving an application-level idempotency flag. - Cache invalidation for inviter/invitee reward totals after commit. ### Repository (`referral_repo.go`) - All methods (including read-only list/stats) use `clientFromContext` so they participate in the caller's transaction when one is active. - `CreateRelation` back-fills `relation.ID` via pointer mutation so the subsequent `MarkRewardGranted` call uses the correct row ID. ### Auth integration (`auth_service.go`) - Registration flow accepts an optional `ref` query parameter. - Referral code is normalised (`strings.ToUpper` + `TrimSpace`) before lookup to tolerate lowercase user input. - Self-referral is rejected; invalid codes are silently ignored so registration is never blocked by a bad referral code. - On successful referral processing the caller receives the updated user object (balance already incremented). ### Settings (`setting_service.go`) - New keys: `referral_enabled`, `referral_inviter_reward`, `referral_invitee_reward`. Default reward values are **0** so rewards are opt-in; admins must explicitly configure amounts. - DB errors in `GetReferralRewards` are now propagated (previously swallowed), preventing silent 0-reward record creation. ### HTTP layer - `GET /api/v1/referral` — user fetches own referral info + stats. - `GET /api/v1/referral/invitees` — paginated invitee list. - `GET /api/v1/admin/referral/stats` — platform-wide stats for admins. ## Frontend ### User page (`ReferralView.vue`) - Displays referral code and full invite link (built from `window.location.origin` to work behind any reverse proxy). - Copy buttons support Clipboard API with `execCommand` fallback for HTTP environments. - Paginated invitee table with per-table error state (`inviteesError`) so a failed list request does not break the whole page. - Full-page error state with retry button for the initial load. ### Register page (`RegisterView.vue` / `EmailVerifyView.vue`) - Reads `?ref=CODE` from the URL and pre-fills the referral code field. ### Admin settings (`SettingsView.vue`) - New "Referral Program" section: enable/disable toggle, inviter reward amount, invitee reward amount. - Form defaults changed to 0/0 (was 10/5) to match backend semantics. ### Navigation & routing - Sidebar entry for `/referral` shown only when `referral_enabled`. - Vue Router route added for the new page. ### i18n - Full translations for all referral strings in `en.ts` and `zh.ts`. --- backend/ent/schema/referral_relation.go | 79 +++ backend/ent/schema/user.go | 6 + backend/ent/schema/user_referral_profile.go | 62 +++ .../handler/admin/referral_handler.go | 32 ++ .../internal/handler/admin/setting_handler.go | 14 + backend/internal/handler/auth_handler.go | 5 +- backend/internal/handler/dto/settings.go | 6 + backend/internal/handler/handler.go | 2 + backend/internal/handler/referral_handler.go | 67 +++ backend/internal/handler/setting_handler.go | 1 + backend/internal/handler/wire.go | 6 + backend/internal/repository/referral_repo.go | 247 ++++++++++ backend/internal/repository/wire.go | 1 + backend/internal/server/routes/admin.go | 10 + backend/internal/server/routes/user.go | 7 + backend/internal/service/auth_service.go | 35 +- .../service/auth_service_register_test.go | 6 +- backend/internal/service/domain_constants.go | 8 + backend/internal/service/referral.go | 105 ++++ backend/internal/service/referral_service.go | 301 ++++++++++++ backend/internal/service/setting_service.go | 52 ++ backend/internal/service/settings_view.go | 6 + backend/internal/service/wire.go | 1 + .../migrations/064_add_referral_system.sql | 35 ++ .../065_add_referral_indexes_notx.sql | 15 + frontend/src/api/admin/settings.ts | 10 + frontend/src/api/index.ts | 1 + frontend/src/api/referral.ts | 45 ++ frontend/src/components/layout/AppSidebar.vue | 21 + frontend/src/i18n/locales/en.ts | 41 +- frontend/src/i18n/locales/zh.ts | 41 +- frontend/src/router/index.ts | 12 + frontend/src/stores/app.ts | 1 + frontend/src/types/index.ts | 28 ++ frontend/src/views/admin/SettingsView.vue | 73 ++- frontend/src/views/auth/EmailVerifyView.vue | 5 +- frontend/src/views/auth/RegisterView.vue | 40 +- frontend/src/views/user/ReferralView.vue | 448 ++++++++++++++++++ 38 files changed, 1859 insertions(+), 16 deletions(-) create mode 100644 backend/ent/schema/referral_relation.go create mode 100644 backend/ent/schema/user_referral_profile.go create mode 100644 backend/internal/handler/admin/referral_handler.go create mode 100644 backend/internal/handler/referral_handler.go create mode 100644 backend/internal/repository/referral_repo.go create mode 100644 backend/internal/service/referral.go create mode 100644 backend/internal/service/referral_service.go create mode 100644 backend/migrations/064_add_referral_system.sql create mode 100644 backend/migrations/065_add_referral_indexes_notx.sql create mode 100644 frontend/src/api/referral.ts create mode 100644 frontend/src/views/user/ReferralView.vue diff --git a/backend/ent/schema/referral_relation.go b/backend/ent/schema/referral_relation.go new file mode 100644 index 000000000..a12a17c53 --- /dev/null +++ b/backend/ent/schema/referral_relation.go @@ -0,0 +1,79 @@ +package schema + +import ( + "time" + + "entgo.io/ent" + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/entsql" + "entgo.io/ent/schema" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" + "entgo.io/ent/schema/index" +) + +// ReferralRelation holds the schema definition for the ReferralRelation entity. +// +// 用户邀请关系记录:记录邀请人(inviter)与被邀请人(invitee)的关联, +// 以及双方获得的奖励金额快照和奖励发放状态。 +// +// 关键约束:invitee_id 唯一,确保同一用户只能被邀请一次(幂等性保障)。 +// +// 删除策略:硬删除(关系一旦建立不应更改,如需审计请在删除前记录日志) +type ReferralRelation struct { + ent.Schema +} + +func (ReferralRelation) Annotations() []schema.Annotation { + return []schema.Annotation{ + entsql.Annotation{Table: "referral_relations"}, + } +} + +func (ReferralRelation) Fields() []ent.Field { + return []ent.Field{ + field.Int64("inviter_id"). + Positive(). + Comment("邀请人的 user_id"), + field.Int64("invitee_id"). + Positive(). + Comment("被邀请人的 user_id,唯一约束确保一人只能被邀请一次"), + field.Float("inviter_reward"). + SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}). + Default(0). + Comment("发放给邀请人的余额奖励(创建时快照,不受后续配置变更影响)"), + field.Float("invitee_reward"). + SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}). + Default(0). + Comment("发放给被邀请人的余额奖励(创建时快照)"), + field.Bool("reward_granted"). + Default(false). + Comment("奖励是否已发放,防止重复发放(幂等标志)"), + field.Time("created_at"). + Immutable(). + Default(time.Now). + SchemaType(map[string]string{dialect.Postgres: "timestamptz"}), + } +} + +func (ReferralRelation) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("inviter", User.Type). + Ref("referrals_given"). + Field("inviter_id"). + Unique(). + Required(), + edge.From("invitee", User.Type). + Ref("referral_received"). + Field("invitee_id"). + Unique(). + Required(), + } +} + +func (ReferralRelation) Indexes() []ent.Index { + return []ent.Index{ + index.Fields("invitee_id").Unique(), // 幂等约束:同一被邀请人只能出现一次 + index.Fields("inviter_id"), // 按邀请人查询列表(分页高频) + } +} diff --git a/backend/ent/schema/user.go b/backend/ent/schema/user.go index 0a3b5d9ec..e214cc004 100644 --- a/backend/ent/schema/user.go +++ b/backend/ent/schema/user.go @@ -93,6 +93,12 @@ func (User) Edges() []ent.Edge { edge.To("usage_logs", UsageLog.Type), edge.To("attribute_values", UserAttributeValue.Type), edge.To("promo_code_usages", PromoCodeUsage.Type), + // 裂变推广:用户的专属邀请码 Profile(一对一) + edge.To("referral_profile", UserReferralProfile.Type), + // 裂变推广:该用户作为邀请人发起的邀请关系 + edge.To("referrals_given", ReferralRelation.Type), + // 裂变推广:该用户被邀请的关系记录(最多一条) + edge.To("referral_received", ReferralRelation.Type), } } diff --git a/backend/ent/schema/user_referral_profile.go b/backend/ent/schema/user_referral_profile.go new file mode 100644 index 000000000..9cbe1aae2 --- /dev/null +++ b/backend/ent/schema/user_referral_profile.go @@ -0,0 +1,62 @@ +package schema + +import ( + "time" + + "entgo.io/ent" + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/entsql" + "entgo.io/ent/schema" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" + "entgo.io/ent/schema/index" +) + +// UserReferralProfile holds the schema definition for the UserReferralProfile entity. +// +// 每个用户的专属邀请码 Profile,在用户注册时自动创建(懒加载)。 +// 与 User 表一对一关系,通过独立表实现,不修改现有 users 表。 +// +// 删除策略:硬删除(与 RedeemCode 相同,邀请码 Profile 删除无需保留历史) +type UserReferralProfile struct { + ent.Schema +} + +func (UserReferralProfile) Annotations() []schema.Annotation { + return []schema.Annotation{ + entsql.Annotation{Table: "user_referral_profiles"}, + } +} + +func (UserReferralProfile) Fields() []ent.Field { + return []ent.Field{ + field.Int64("user_id"). + Positive(), + field.String("referral_code"). + MaxLen(8). + NotEmpty(). + Unique(). + Comment("8位大写字母+数字的专属邀请码"), + field.Time("created_at"). + Immutable(). + Default(time.Now). + SchemaType(map[string]string{"postgres": "timestamptz"}), + } +} + +func (UserReferralProfile) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("user", User.Type). + Ref("referral_profile"). + Field("user_id"). + Unique(). + Required(), + } +} + +func (UserReferralProfile) Indexes() []ent.Index { + return []ent.Index{ + index.Fields("user_id").Unique(), + index.Fields("referral_code").Unique(), + } +} diff --git a/backend/internal/handler/admin/referral_handler.go b/backend/internal/handler/admin/referral_handler.go new file mode 100644 index 000000000..42c2b83cb --- /dev/null +++ b/backend/internal/handler/admin/referral_handler.go @@ -0,0 +1,32 @@ +package admin + +import ( + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" +) + +// ReferralHandler handles admin referral management endpoints +type ReferralHandler struct { + referralService *service.ReferralService +} + +// NewReferralHandler creates a new admin ReferralHandler +func NewReferralHandler(referralService *service.ReferralService) *ReferralHandler { + return &ReferralHandler{ + referralService: referralService, + } +} + +// GetPlatformStats handles getting platform-wide referral statistics +// GET /api/v1/admin/referral/stats +func (h *ReferralHandler) GetPlatformStats(c *gin.Context) { + stats, err := h.referralService.GetPlatformStats(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, stats) +} diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index e32c142f4..40ddb2cfb 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -123,6 +123,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { OpsQueryModeDefault: settings.OpsQueryModeDefault, OpsMetricsIntervalSeconds: settings.OpsMetricsIntervalSeconds, MinClaudeCodeVersion: settings.MinClaudeCodeVersion, + ReferralEnabled: settings.ReferralEnabled, + ReferralInviterReward: settings.ReferralInviterReward, + ReferralInviteeReward: settings.ReferralInviteeReward, }) } @@ -193,6 +196,11 @@ type UpdateSettingsRequest struct { OpsMetricsIntervalSeconds *int `json:"ops_metrics_interval_seconds"` MinClaudeCodeVersion string `json:"min_claude_code_version"` + + // 裂变推广配置 + ReferralEnabled bool `json:"referral_enabled"` + ReferralInviterReward float64 `json:"referral_inviter_reward"` + ReferralInviteeReward float64 `json:"referral_invitee_reward"` } // UpdateSettings 更新系统设置 @@ -465,6 +473,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { EnableIdentityPatch: req.EnableIdentityPatch, IdentityPatchPrompt: req.IdentityPatchPrompt, MinClaudeCodeVersion: req.MinClaudeCodeVersion, + ReferralEnabled: req.ReferralEnabled, + ReferralInviterReward: req.ReferralInviterReward, + ReferralInviteeReward: req.ReferralInviteeReward, OpsMonitoringEnabled: func() bool { if req.OpsMonitoringEnabled != nil { return *req.OpsMonitoringEnabled @@ -561,6 +572,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { OpsQueryModeDefault: updatedSettings.OpsQueryModeDefault, OpsMetricsIntervalSeconds: updatedSettings.OpsMetricsIntervalSeconds, MinClaudeCodeVersion: updatedSettings.MinClaudeCodeVersion, + ReferralEnabled: updatedSettings.ReferralEnabled, + ReferralInviterReward: updatedSettings.ReferralInviterReward, + ReferralInviteeReward: updatedSettings.ReferralInviteeReward, }) } diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 1ffa9d717..93c445364 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -45,7 +45,8 @@ type RegisterRequest struct { VerifyCode string `json:"verify_code"` TurnstileToken string `json:"turnstile_token"` PromoCode string `json:"promo_code"` // 注册优惠码 - InvitationCode string `json:"invitation_code"` // 邀请码 + InvitationCode string `json:"invitation_code"` // 邀请码(兑换码类型) + ReferralCode string `json:"referral_code"` // 裂变推广邀请码 } // SendVerifyCodeRequest 发送验证码请求 @@ -119,7 +120,7 @@ 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.ReferralCode) if err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index beb03e679..ac5e56ec4 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -77,6 +77,11 @@ type SystemSettings struct { OpsMetricsIntervalSeconds int `json:"ops_metrics_interval_seconds"` MinClaudeCodeVersion string `json:"min_claude_code_version"` + + // 裂变推广配置 + ReferralEnabled bool `json:"referral_enabled"` + ReferralInviterReward float64 `json:"referral_inviter_reward"` + ReferralInviteeReward float64 `json:"referral_invitee_reward"` } type DefaultSubscriptionSetting struct { @@ -107,6 +112,7 @@ type PublicSettings struct { LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` SoraClientEnabled bool `json:"sora_client_enabled"` Version string `json:"version"` + ReferralEnabled bool `json:"referral_enabled"` } // SoraS3Settings Sora S3 存储配置 DTO(响应用,不含敏感字段) diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 1e1247fc8..e5692d238 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -27,6 +27,7 @@ type AdminHandlers struct { UserAttribute *admin.UserAttributeHandler ErrorPassthrough *admin.ErrorPassthroughHandler APIKey *admin.AdminAPIKeyHandler + Referral *admin.ReferralHandler } // Handlers contains all HTTP handlers @@ -45,6 +46,7 @@ type Handlers struct { SoraClient *SoraClientHandler Setting *SettingHandler Totp *TotpHandler + Referral *ReferralHandler } // BuildInfo contains build-time information diff --git a/backend/internal/handler/referral_handler.go b/backend/internal/handler/referral_handler.go new file mode 100644 index 000000000..2316d3c7c --- /dev/null +++ b/backend/internal/handler/referral_handler.go @@ -0,0 +1,67 @@ +package handler + +import ( + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" +) + +// ReferralHandler handles user referral/invitation endpoints +type ReferralHandler struct { + referralService *service.ReferralService +} + +// NewReferralHandler creates a new ReferralHandler +func NewReferralHandler(referralService *service.ReferralService) *ReferralHandler { + return &ReferralHandler{ + referralService: referralService, + } +} + +// GetMyReferralInfo handles getting the current user's referral info +// GET /api/v1/referral +func (h *ReferralHandler) GetMyReferralInfo(c *gin.Context) { + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + + // 使用请求的 Origin 作为站点基础 URL(用于构建邀请链接) + siteBaseURL := c.Request.Header.Get("Origin") + + info, err := h.referralService.GetMyReferralInfo(c.Request.Context(), subject.UserID, siteBaseURL) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, info) +} + +// ListMyInvitees handles listing the current user's invited users +// GET /api/v1/referral/invitees +func (h *ReferralHandler) ListMyInvitees(c *gin.Context) { + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + + page, pageSize := response.ParsePagination(c) + params := pagination.PaginationParams{ + Page: page, + PageSize: pageSize, + } + + invitees, result, err := h.referralService.ListMyInvitees(c.Request.Context(), subject.UserID, params) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Paginated(c, invitees, result.Total, params.Page, params.PageSize) +} diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index a48eaf318..58695a44d 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -54,5 +54,6 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, SoraClientEnabled: settings.SoraClientEnabled, Version: h.version, + ReferralEnabled: settings.ReferralEnabled, }) } diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index 76f5a9796..b93773efb 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -30,6 +30,7 @@ func ProvideAdminHandlers( userAttributeHandler *admin.UserAttributeHandler, errorPassthroughHandler *admin.ErrorPassthroughHandler, apiKeyHandler *admin.AdminAPIKeyHandler, + referralHandler *admin.ReferralHandler, ) *AdminHandlers { return &AdminHandlers{ Dashboard: dashboardHandler, @@ -53,6 +54,7 @@ func ProvideAdminHandlers( UserAttribute: userAttributeHandler, ErrorPassthrough: errorPassthroughHandler, APIKey: apiKeyHandler, + Referral: referralHandler, } } @@ -82,6 +84,7 @@ func ProvideHandlers( soraClientHandler *SoraClientHandler, settingHandler *SettingHandler, totpHandler *TotpHandler, + referralHandler *ReferralHandler, _ *service.IdempotencyCoordinator, _ *service.IdempotencyCleanupService, ) *Handlers { @@ -100,6 +103,7 @@ func ProvideHandlers( SoraClient: soraClientHandler, Setting: settingHandler, Totp: totpHandler, + Referral: referralHandler, } } @@ -118,6 +122,7 @@ var ProviderSet = wire.NewSet( NewSoraGatewayHandler, NewTotpHandler, ProvideSettingHandler, + NewReferralHandler, // Admin handlers admin.NewDashboardHandler, @@ -141,6 +146,7 @@ var ProviderSet = wire.NewSet( admin.NewUserAttributeHandler, admin.NewErrorPassthroughHandler, admin.NewAdminAPIKeyHandler, + admin.NewReferralHandler, // AdminHandlers and Handlers constructors ProvideAdminHandlers, diff --git a/backend/internal/repository/referral_repo.go b/backend/internal/repository/referral_repo.go new file mode 100644 index 000000000..c875cb07b --- /dev/null +++ b/backend/internal/repository/referral_repo.go @@ -0,0 +1,247 @@ +package repository + +// 注意:此文件依赖 Ent 代码生成产物。 +// 修改 ent/schema/ 下的 Schema 文件后,必须先在 backend/ 目录执行: +// +// go generate ./ent/... +// +// 才能使本文件编译通过。 + +import ( + "context" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/ent/referralrelation" + "github.com/Wei-Shaw/sub2api/ent/userreferralprofile" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/service" +) + +type referralRepository struct { + client *dbent.Client +} + +// NewReferralRepository 创建裂变推广仓储实例 +func NewReferralRepository(client *dbent.Client) service.ReferralRepository { + return &referralRepository{client: client} +} + +// ===================== +// Profile 操作 +// ===================== + +func (r *referralRepository) CreateProfile(ctx context.Context, userID int64, referralCode string) (*service.UserReferralProfile, error) { + client := clientFromContext(ctx, r.client) + m, err := client.UserReferralProfile.Create(). + SetUserID(userID). + SetReferralCode(referralCode). + Save(ctx) + if err != nil { + return nil, err + } + return referralProfileEntityToService(m), nil +} + +func (r *referralRepository) GetProfileByUserID(ctx context.Context, userID int64) (*service.UserReferralProfile, error) { + client := clientFromContext(ctx, r.client) + m, err := client.UserReferralProfile.Query(). + Where(userreferralprofile.UserIDEQ(userID)). + Only(ctx) + if err != nil { + if dbent.IsNotFound(err) { + return nil, nil // 惰性创建场景:profile 不存在返回 nil + } + return nil, err + } + return referralProfileEntityToService(m), nil +} + +func (r *referralRepository) GetProfileByCode(ctx context.Context, code string) (*service.UserReferralProfile, error) { + client := clientFromContext(ctx, r.client) + m, err := client.UserReferralProfile.Query(). + Where(userreferralprofile.ReferralCodeEQ(code)). + Only(ctx) + if err != nil { + if dbent.IsNotFound(err) { + return nil, service.ErrReferralCodeNotFound + } + return nil, err + } + return referralProfileEntityToService(m), nil +} + +// ===================== +// 关系操作 +// ===================== + +func (r *referralRepository) CreateRelation(ctx context.Context, relation *service.ReferralRelation) error { + client := clientFromContext(ctx, r.client) + m, err := client.ReferralRelation.Create(). + SetInviterID(relation.InviterID). + SetInviteeID(relation.InviteeID). + SetInviterReward(relation.InviterReward). + SetInviteeReward(relation.InviteeReward). + SetRewardGranted(relation.RewardGranted). + Save(ctx) + if err != nil { + return err + } + relation.ID = m.ID + relation.CreatedAt = m.CreatedAt + return nil +} + +func (r *referralRepository) GetRelationByInviteeID(ctx context.Context, inviteeID int64) (*service.ReferralRelation, error) { + client := clientFromContext(ctx, r.client) + m, err := client.ReferralRelation.Query(). + Where(referralrelation.InviteeIDEQ(inviteeID)). + Only(ctx) + if err != nil { + if dbent.IsNotFound(err) { + return nil, nil // 该用户未被邀请 + } + return nil, err + } + return referralRelationEntityToService(m), nil +} + +// MarkRewardGranted 将邀请关系的 reward_granted 标志设为 true, +// 表示奖励已成功发放。必须在事务上下文中调用。 +func (r *referralRepository) MarkRewardGranted(ctx context.Context, id int64) error { + client := clientFromContext(ctx, r.client) + return client.ReferralRelation.UpdateOneID(id).SetRewardGranted(true).Exec(ctx) +} + +// ===================== +// 查询操作 +// ===================== + +func (r *referralRepository) ListByInviterID(ctx context.Context, inviterID int64, params pagination.PaginationParams) ([]service.ReferralRelation, *pagination.PaginationResult, error) { + client := clientFromContext(ctx, r.client) + q := client.ReferralRelation.Query(). + Where(referralrelation.InviterIDEQ(inviterID)). + WithInvitee() + + total, err := q.Clone().Count(ctx) + if err != nil { + return nil, nil, err + } + + entities, err := q. + Order(dbent.Desc(referralrelation.FieldID)). + Offset(params.Offset()). + Limit(params.Limit()). + All(ctx) + if err != nil { + return nil, nil, err + } + + out := make([]service.ReferralRelation, len(entities)) + for i, e := range entities { + out[i] = *referralRelationEntityToService(e) + } + + return out, paginationResultFromTotal(int64(total), params), nil +} + +func (r *referralRepository) CountByInviterID(ctx context.Context, inviterID int64) (int64, error) { + client := clientFromContext(ctx, r.client) + n, err := client.ReferralRelation.Query(). + Where(referralrelation.InviterIDEQ(inviterID)). + Count(ctx) + return int64(n), err +} + +func (r *referralRepository) SumRewardsByInviterID(ctx context.Context, inviterID int64) (float64, error) { + client := clientFromContext(ctx, r.client) + var result []struct { + Sum float64 `json:"sum"` + } + err := client.ReferralRelation.Query(). + Where(referralrelation.InviterIDEQ(inviterID)). + Aggregate(dbent.As(dbent.Sum(referralrelation.FieldInviterReward), "sum")). + Scan(ctx, &result) + if err != nil { + return 0, err + } + if len(result) == 0 { + return 0, nil + } + return result[0].Sum, nil +} + +// ===================== +// 管理员统计 +// ===================== + +func (r *referralRepository) GetPlatformStats(ctx context.Context) (*service.ReferralStats, error) { + client := clientFromContext(ctx, r.client) + total, err := client.ReferralRelation.Query().Count(ctx) + if err != nil { + return nil, err + } + + var inviterSums []struct { + Sum float64 `json:"sum"` + } + if err := client.ReferralRelation.Query(). + Aggregate(dbent.As(dbent.Sum(referralrelation.FieldInviterReward), "sum")). + Scan(ctx, &inviterSums); err != nil { + return nil, err + } + + var inviteeSums []struct { + Sum float64 `json:"sum"` + } + if err := client.ReferralRelation.Query(). + Aggregate(dbent.As(dbent.Sum(referralrelation.FieldInviteeReward), "sum")). + Scan(ctx, &inviteeSums); err != nil { + return nil, err + } + + stats := &service.ReferralStats{TotalRelations: int64(total)} + if len(inviterSums) > 0 { + stats.TotalInviterRewardGiven = inviterSums[0].Sum + } + if len(inviteeSums) > 0 { + stats.TotalInviteeRewardGiven = inviteeSums[0].Sum + } + + return stats, nil +} + +// ===================== +// 实体 → 服务模型映射 +// ===================== + +func referralProfileEntityToService(m *dbent.UserReferralProfile) *service.UserReferralProfile { + if m == nil { + return nil + } + return &service.UserReferralProfile{ + ID: m.ID, + UserID: m.UserID, + ReferralCode: m.ReferralCode, + CreatedAt: m.CreatedAt, + } +} + +func referralRelationEntityToService(m *dbent.ReferralRelation) *service.ReferralRelation { + if m == nil { + return nil + } + rel := &service.ReferralRelation{ + ID: m.ID, + InviterID: m.InviterID, + InviteeID: m.InviteeID, + InviterReward: m.InviterReward, + InviteeReward: m.InviteeReward, + RewardGranted: m.RewardGranted, + CreatedAt: m.CreatedAt, + } + // 填充辅助字段(通过 WithInvitee() 预加载) + if invitee := m.Edges.Invitee; invitee != nil { + rel.InviteeEmail = invitee.Email + } + return rel +} diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index 2e35e0a00..ffd3e877e 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -70,6 +70,7 @@ var ProviderSet = wire.NewSet( NewUserAttributeValueRepository, NewUserGroupRateRepository, NewErrorPassthroughRepository, + NewReferralRepository, // Cache implementations NewGatewayCache, diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index c36c36a0a..c7f431f60 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -78,6 +78,9 @@ func RegisterAdminRoutes( // API Key 管理 registerAdminAPIKeyRoutes(admin, h) + + // 裂变推广管理 + registerAdminReferralRoutes(admin, h) } } @@ -486,3 +489,10 @@ func registerErrorPassthroughRoutes(admin *gin.RouterGroup, h *handler.Handlers) rules.DELETE("/:id", h.Admin.ErrorPassthrough.Delete) } } + +func registerAdminReferralRoutes(admin *gin.RouterGroup, h *handler.Handlers) { + referral := admin.Group("/referral") + { + referral.GET("/stats", h.Admin.Referral.GetPlatformStats) + } +} diff --git a/backend/internal/server/routes/user.go b/backend/internal/server/routes/user.go index d0ed24899..3c1aa191d 100644 --- a/backend/internal/server/routes/user.go +++ b/backend/internal/server/routes/user.go @@ -87,5 +87,12 @@ func RegisterUserRoutes( subscriptions.GET("/progress", h.Subscription.GetProgress) subscriptions.GET("/summary", h.Subscription.GetSummary) } + + // 裂变推广 + referral := authenticated.Group("/referral") + { + referral.GET("", h.Referral.GetMyReferralInfo) + referral.GET("/invitees", h.Referral.ListMyInvitees) + } } } diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index fe3a0f258..d6515132e 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -65,6 +65,7 @@ type AuthService struct { turnstileService *TurnstileService emailQueueService *EmailQueueService promoService *PromoService + referralService *ReferralService defaultSubAssigner DefaultSubscriptionAssigner } @@ -83,6 +84,7 @@ func NewAuthService( turnstileService *TurnstileService, emailQueueService *EmailQueueService, promoService *PromoService, + referralService *ReferralService, defaultSubAssigner DefaultSubscriptionAssigner, ) *AuthService { return &AuthService{ @@ -95,17 +97,18 @@ func NewAuthService( turnstileService: turnstileService, emailQueueService: emailQueueService, promoService: promoService, + referralService: referralService, defaultSubAssigner: defaultSubAssigner, } } // Register 用户注册,返回token和用户 func (s *AuthService) Register(ctx context.Context, email, password string) (string, *User, error) { - return s.RegisterWithVerification(ctx, email, password, "", "", "") + 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和用户 +func (s *AuthService) RegisterWithVerification(ctx context.Context, email, password, verifyCode, promoCode, invitationCode, referralCode string) (string, *User, error) { // 检查是否开放注册(默认关闭:settingService 未配置时不允许注册) if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) { return "", nil, ErrRegDisabled @@ -153,6 +156,19 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw } } + // 预验证裂变推广邀请码(用户 ID 尚不存在,自引用检查放到注册后) + var referralInviterID int64 + if referralCode != "" && s.referralService != nil && s.settingService != nil { + referralCode = strings.ToUpper(strings.TrimSpace(referralCode)) + if enabled, _ := s.settingService.IsReferralEnabled(ctx); enabled { + profile, err := s.referralService.referralRepo.GetProfileByCode(ctx, referralCode) + if err == nil && profile != nil { + referralInviterID = profile.UserID + } + // 邀请码无效时静默忽略(不阻断注册) + } + } + // 检查邮箱是否已存在 existsEmail, err := s.userRepo.ExistsByEmail(ctx, email) if err != nil { @@ -217,6 +233,19 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw } } + // 处理裂变推广奖励(inviterID 已在注册前验证,此处仅做最终自引用防护) + if referralInviterID > 0 && referralInviterID != user.ID && s.referralService != nil { + if err := s.referralService.ProcessReferralRegistration(ctx, referralInviterID, user.ID); err != nil { + // 奖励发放失败不影响注册,只记录日志 + logger.LegacyPrintf("service.auth", "[Auth] Failed to process referral for user %d: %v", user.ID, err) + } else { + // 重新获取用户信息以获取更新后的余额 + if updatedUser, err := s.userRepo.GetByID(ctx, user.ID); err == nil { + user = updatedUser + } + } + } + // 生成token token, err := s.GenerateToken(user) if err != nil { diff --git a/backend/internal/service/auth_service_register_test.go b/backend/internal/service/auth_service_register_test.go index 1999e759e..41c65eb5c 100644 --- a/backend/internal/service/auth_service_register_test.go +++ b/backend/internal/service/auth_service_register_test.go @@ -170,7 +170,7 @@ func TestAuthService_Register_EmailVerifyEnabledButServiceNotConfigured(t *testi }, nil) // 应返回服务不可用错误,而不是允许绕过验证 - _, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "any-code", "", "") + _, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "any-code", "", "", "") require.ErrorIs(t, err, ErrServiceUnavailable) } @@ -182,7 +182,7 @@ func TestAuthService_Register_EmailVerifyRequired(t *testing.T) { SettingKeyEmailVerifyEnabled: "true", }, cache) - _, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "", "", "") + _, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "", "", "", "") require.ErrorIs(t, err, ErrEmailVerifyRequired) } @@ -196,7 +196,7 @@ func TestAuthService_Register_EmailVerifyInvalid(t *testing.T) { SettingKeyEmailVerifyEnabled: "true", }, cache) - _, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "wrong", "", "") + _, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "wrong", "", "", "") require.ErrorIs(t, err, ErrInvalidVerifyCode) require.ErrorContains(t, err, "verify code") } diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index df2130027..949f43fb3 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -201,6 +201,14 @@ const ( // SettingKeyMinClaudeCodeVersion 最低 Claude Code 版本号要求 (semver, 如 "2.1.0",空值=不检查) SettingKeyMinClaudeCodeVersion = "min_claude_code_version" + + // ========================= + // 裂变推广设置 + // ========================= + + SettingKeyReferralEnabled = "referral_enabled" // 是否启用裂变推广功能(默认 false) + SettingKeyReferralInviterReward = "referral_inviter_reward" // 邀请人奖励余额(默认 10.0) + SettingKeyReferralInviteeReward = "referral_invitee_reward" // 被邀请人奖励余额(默认 5.0) ) // AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys). diff --git a/backend/internal/service/referral.go b/backend/internal/service/referral.go new file mode 100644 index 000000000..114efdbdb --- /dev/null +++ b/backend/internal/service/referral.go @@ -0,0 +1,105 @@ +package service + +import ( + "context" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" +) + +// ===================== +// 领域模型 +// ===================== + +// UserReferralProfile 用户专属邀请码 Profile +type UserReferralProfile struct { + ID int64 + UserID int64 + ReferralCode string + CreatedAt time.Time +} + +// ReferralRelation 邀请关系记录 +type ReferralRelation struct { + ID int64 + InviterID int64 + InviteeID int64 + InviterReward float64 + InviteeReward float64 + RewardGranted bool + CreatedAt time.Time + + // 辅助字段(查询时填充,非 DB 字段) + InviteeEmail string +} + +// ReferralStats 全平台邀请统计(管理员视图) +type ReferralStats struct { + TotalRelations int64 `json:"total_relations"` + TotalInviterRewardGiven float64 `json:"total_inviter_reward_granted"` + TotalInviteeRewardGiven float64 `json:"total_invitee_reward_granted"` +} + +// ReferralInfo 用户端邀请信息(聚合视图) +type ReferralInfo struct { + ReferralCode string `json:"referral_code"` + ReferralLink string `json:"referral_link"` + TotalInvitees int64 `json:"total_invitees"` + TotalRewardEarned float64 `json:"total_reward_earned"` + InviterInfo *InviterInfo `json:"inviter_info,omitempty"` +} + +// InviterInfo 邀请人信息(脱敏) +type InviterInfo struct { + EmailMasked string `json:"email_masked"` +} + +// ReferralInvitee 邀请记录列表项 +type ReferralInvitee struct { + EmailMasked string `json:"email_masked"` + RegisteredAt time.Time `json:"registered_at"` + RewardEarned float64 `json:"reward_earned"` +} + +// ===================== +// Repository 接口 +// ===================== + +// ReferralRepository 邀请推广数据仓储接口 +type ReferralRepository interface { + // Profile 操作 + CreateProfile(ctx context.Context, userID int64, referralCode string) (*UserReferralProfile, error) + GetProfileByUserID(ctx context.Context, userID int64) (*UserReferralProfile, error) + GetProfileByCode(ctx context.Context, code string) (*UserReferralProfile, error) + + // 关系操作 + CreateRelation(ctx context.Context, relation *ReferralRelation) error + GetRelationByInviteeID(ctx context.Context, inviteeID int64) (*ReferralRelation, error) + + // 关系状态更新 + MarkRewardGranted(ctx context.Context, id int64) error + + // 查询操作 + ListByInviterID(ctx context.Context, inviterID int64, params pagination.PaginationParams) ([]ReferralRelation, *pagination.PaginationResult, error) + CountByInviterID(ctx context.Context, inviterID int64) (int64, error) + SumRewardsByInviterID(ctx context.Context, inviterID int64) (float64, error) + + // 管理员统计 + GetPlatformStats(ctx context.Context) (*ReferralStats, error) +} + +// ===================== +// 错误定义 +// ===================== + +var ( + ErrReferralCodeNotFound = newBadRequestError("REFERRAL_CODE_NOT_FOUND", "referral code not found or invalid") + ErrReferralCodeInvalid = newBadRequestError("REFERRAL_CODE_INVALID", "referral code is invalid") + ErrSelfReferralNotAllowed = newBadRequestError("SELF_REFERRAL_NOT_ALLOWED", "cannot use your own referral code") + ErrAlreadyReferred = newBadRequestError("ALREADY_REFERRED", "this user has already been referred") +) + +func newBadRequestError(code, msg string) error { + return infraerrors.BadRequest(code, msg) +} diff --git a/backend/internal/service/referral_service.go b/backend/internal/service/referral_service.go new file mode 100644 index 000000000..7737866c8 --- /dev/null +++ b/backend/internal/service/referral_service.go @@ -0,0 +1,301 @@ +package service + +import ( + "context" + "crypto/rand" + "fmt" + "strings" + "time" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" +) + +// referralCodeCharset 邀请码字符集:大写字母 + 数字,去除易混淆字符 +const referralCodeCharset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + +// referralCodeLength 邀请码长度:8 位 +const referralCodeLength = 8 + +// referralCodeMaxRetries 邀请码冲突时最大重试次数 +const referralCodeMaxRetries = 5 + +// ReferralService 裂变推广业务逻辑服务 +type ReferralService struct { + referralRepo ReferralRepository + userRepo UserRepository + settingService *SettingService + billingCacheService *BillingCacheService + entClient *dbent.Client +} + +// NewReferralService 创建裂变推广服务实例 +func NewReferralService( + referralRepo ReferralRepository, + userRepo UserRepository, + settingService *SettingService, + billingCacheService *BillingCacheService, + entClient *dbent.Client, +) *ReferralService { + return &ReferralService{ + referralRepo: referralRepo, + userRepo: userRepo, + settingService: settingService, + billingCacheService: billingCacheService, + entClient: entClient, + } +} + +// GetOrCreateProfile 获取或懒加载创建用户的专属邀请码 Profile +func (s *ReferralService) GetOrCreateProfile(ctx context.Context, userID int64) (*UserReferralProfile, error) { + // 先查询 + profile, err := s.referralRepo.GetProfileByUserID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("get referral profile: %w", err) + } + if profile != nil { + return profile, nil + } + + // 不存在则生成并创建(带重试应对极小概率的码冲突) + for i := 0; i < referralCodeMaxRetries; i++ { + code, err := generateReferralCode() + if err != nil { + return nil, fmt.Errorf("generate referral code: %w", err) + } + created, err := s.referralRepo.CreateProfile(ctx, userID, code) + if err == nil { + return created, nil + } + // 唯一约束冲突时重试,其他错误直接返回 + if !isUniqueConflict(err) { + return nil, fmt.Errorf("create referral profile: %w", err) + } + } + return nil, fmt.Errorf("failed to generate unique referral code after %d retries", referralCodeMaxRetries) +} + +// ValidateReferralCode 注册前验证邀请码有效性 +// 返回 nil, nil 表示空码(不报错) +func (s *ReferralService) ValidateReferralCode(ctx context.Context, code string, inviteeID int64) (*UserReferralProfile, error) { + code = strings.TrimSpace(strings.ToUpper(code)) + if code == "" { + return nil, nil + } + + profile, err := s.referralRepo.GetProfileByCode(ctx, code) + if err != nil { + return nil, err // ErrReferralCodeNotFound + } + + // 自己不能使用自己的邀请码 + if profile.UserID == inviteeID { + return nil, ErrSelfReferralNotAllowed + } + + return profile, nil +} + +// GrantRewardsInTx 在已有事务上下文中完成邀请关系创建和奖励发放。 +// +// 调用者(AuthService)负责事务管理,本方法仅在 txCtx 中执行。 +// 幂等保障:invitee_id UNIQUE 约束(DB 层)+ reward_granted 标志(应用层)。 +func (s *ReferralService) GrantRewardsInTx(ctx context.Context, inviterID, inviteeID int64, inviterReward, inviteeReward float64) error { + relation := &ReferralRelation{ + InviterID: inviterID, + InviteeID: inviteeID, + InviterReward: inviterReward, + InviteeReward: inviteeReward, + RewardGranted: false, + } + + // 创建邀请关系(invitee_id UNIQUE 约束保证幂等) + if err := s.referralRepo.CreateRelation(ctx, relation); err != nil { + return fmt.Errorf("create referral relation: %w", err) + } + + // 发放邀请人奖励 + if inviterReward > 0 { + if err := s.userRepo.UpdateBalance(ctx, inviterID, inviterReward); err != nil { + return fmt.Errorf("grant inviter reward: %w", err) + } + } + + // 发放被邀请人奖励 + if inviteeReward > 0 { + if err := s.userRepo.UpdateBalance(ctx, inviteeID, inviteeReward); err != nil { + return fmt.Errorf("grant invitee reward: %w", err) + } + } + + // 标记奖励已发放(应用层幂等标志) + if err := s.referralRepo.MarkRewardGranted(ctx, relation.ID); err != nil { + return fmt.Errorf("mark reward granted: %w", err) + } + + return nil +} + +// GetMyReferralInfo 获取当前用户的邀请统计信息 +func (s *ReferralService) GetMyReferralInfo(ctx context.Context, userID int64, siteBaseURL string) (*ReferralInfo, error) { + // 获取或创建 Profile + profile, err := s.GetOrCreateProfile(ctx, userID) + if err != nil { + return nil, err + } + + // 统计数据 + totalInvitees, err := s.referralRepo.CountByInviterID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("count invitees: %w", err) + } + + totalRewardEarned, err := s.referralRepo.SumRewardsByInviterID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("sum rewards: %w", err) + } + + info := &ReferralInfo{ + ReferralCode: profile.ReferralCode, + ReferralLink: buildReferralLink(siteBaseURL, profile.ReferralCode), + TotalInvitees: totalInvitees, + TotalRewardEarned: totalRewardEarned, + } + + // 如果该用户自己是被邀请的,填充邀请人信息 + relation, err := s.referralRepo.GetRelationByInviteeID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("get inviter relation: %w", err) + } + if relation != nil { + inviter, err := s.userRepo.GetByID(ctx, relation.InviterID) + if err == nil && inviter != nil { + info.InviterInfo = &InviterInfo{ + EmailMasked: maskEmail(inviter.Email), + } + } + } + + return info, nil +} + +// ListMyInvitees 分页获取当前用户邀请的用户列表 +func (s *ReferralService) ListMyInvitees(ctx context.Context, userID int64, params pagination.PaginationParams) ([]ReferralInvitee, *pagination.PaginationResult, error) { + relations, result, err := s.referralRepo.ListByInviterID(ctx, userID, params) + if err != nil { + return nil, nil, fmt.Errorf("list invitees: %w", err) + } + + invitees := make([]ReferralInvitee, len(relations)) + for i, rel := range relations { + invitees[i] = ReferralInvitee{ + EmailMasked: maskEmail(rel.InviteeEmail), + RegisteredAt: rel.CreatedAt, + RewardEarned: rel.InviterReward, + } + } + + return invitees, result, nil +} + +// GetPlatformStats 获取全平台邀请统计(管理员) +func (s *ReferralService) GetPlatformStats(ctx context.Context) (*ReferralStats, error) { + return s.referralRepo.GetPlatformStats(ctx) +} + +// ProcessReferralRegistration 在注册完成后处理邀请奖励(自带事务)。 +// 从 SettingService 读取奖励金额后,在单一事务内创建邀请关系并更新双方余额。 +// 失败不影响注册结果,调用方应仅记录日志。 +func (s *ReferralService) ProcessReferralRegistration(ctx context.Context, inviterID, inviteeID int64) error { + inviterReward, inviteeReward, err := s.settingService.GetReferralRewards(ctx) + if err != nil { + return fmt.Errorf("get referral rewards: %w", err) + } + + tx, err := s.entClient.Tx(ctx) + if err != nil { + return fmt.Errorf("begin referral tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + txCtx := dbent.NewTxContext(ctx, tx) + + if err := s.GrantRewardsInTx(txCtx, inviterID, inviteeID, inviterReward, inviteeReward); err != nil { + return err + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit referral tx: %w", err) + } + + s.InvalidateRewardCaches(inviterID, inviteeID) + return nil +} + +// InvalidateRewardCaches 异步失效奖励相关的余额缓存(在事务提交后调用) +func (s *ReferralService) InvalidateRewardCaches(inviterID, inviteeID int64) { + if s.billingCacheService == nil { + return + } + go func() { + cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = s.billingCacheService.InvalidateUserBalance(cacheCtx, inviterID) + _ = s.billingCacheService.InvalidateUserBalance(cacheCtx, inviteeID) + }() +} + +// ===================== +// 辅助函数 +// ===================== + +// generateReferralCode 使用 crypto/rand 生成 8 位大写字母+数字的邀请码 +func generateReferralCode() (string, error) { + buf := make([]byte, referralCodeLength) + if _, err := rand.Read(buf); err != nil { + return "", err + } + code := make([]byte, referralCodeLength) + for i, b := range buf { + code[i] = referralCodeCharset[int(b)%len(referralCodeCharset)] + } + return string(code), nil +} + +// maskEmail 对邮箱地址进行脱敏处理,只保留前3位和域名部分 +// 例如:user@example.com → use***@example.com +func maskEmail(email string) string { + if email == "" { + return "" + } + parts := strings.SplitN(email, "@", 2) + if len(parts) != 2 { + return email + } + local := parts[0] + domain := parts[1] + if len(local) <= 3 { + return local + "***@" + domain + } + return local[:3] + "***@" + domain +} + +// buildReferralLink 构建邀请链接 +func buildReferralLink(siteBaseURL, code string) string { + base := strings.TrimSuffix(strings.TrimSpace(siteBaseURL), "/") + if base == "" { + return "?ref=" + code + } + return base + "/register?ref=" + code +} + +// isUniqueConflict 判断错误是否为唯一约束冲突(用于邀请码重试逻辑) +func isUniqueConflict(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "duplicate key") || + strings.Contains(msg, "unique constraint") || + strings.Contains(msg, "duplicate entry") +} diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index f7e4fb6be..29e4bae79 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -127,6 +127,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeySoraClientEnabled, SettingKeyCustomMenuItems, SettingKeyLinuxDoConnectEnabled, + SettingKeyReferralEnabled, } settings, err := s.settingRepo.GetMultiple(ctx, keys) @@ -167,6 +168,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true", CustomMenuItems: settings[SettingKeyCustomMenuItems], LinuxDoOAuthEnabled: linuxDoEnabled, + ReferralEnabled: settings[SettingKeyReferralEnabled] == "true", }, nil } @@ -438,6 +440,11 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet // Claude Code version check updates[SettingKeyMinClaudeCodeVersion] = settings.MinClaudeCodeVersion + // 裂变推广配置 + updates[SettingKeyReferralEnabled] = strconv.FormatBool(settings.ReferralEnabled) + updates[SettingKeyReferralInviterReward] = strconv.FormatFloat(settings.ReferralInviterReward, 'f', 2, 64) + updates[SettingKeyReferralInviteeReward] = strconv.FormatFloat(settings.ReferralInviteeReward, 'f', 2, 64) + err = s.settingRepo.SetMultiple(ctx, updates) if err == nil { // 先使 inflight singleflight 失效,再刷新缓存,缩小旧值覆盖新值的竞态窗口 @@ -661,6 +668,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true", InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", TotpEnabled: settings[SettingKeyTotpEnabled] == "true", + ReferralEnabled: settings[SettingKeyReferralEnabled] == "true", SMTPHost: settings[SettingKeySMTPHost], SMTPUsername: settings[SettingKeySMTPUsername], SMTPFrom: settings[SettingKeySMTPFrom], @@ -705,6 +713,16 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin } result.DefaultSubscriptions = parseDefaultSubscriptions(settings[SettingKeyDefaultSubscriptions]) + // 裂变推广奖励配置(默认 0,需管理员显式配置) + result.ReferralInviterReward = 0 + if v, err := strconv.ParseFloat(settings[SettingKeyReferralInviterReward], 64); err == nil { + result.ReferralInviterReward = v + } + result.ReferralInviteeReward = 0 + if v, err := strconv.ParseFloat(settings[SettingKeyReferralInviteeReward], 64); err == nil { + result.ReferralInviteeReward = v + } + // 敏感信息直接返回,方便测试连接时使用 result.SMTPPassword = settings[SettingKeySMTPPassword] result.TurnstileSecretKey = settings[SettingKeyTurnstileSecretKey] @@ -1780,3 +1798,37 @@ func maxInt64(value int64, min int64) int64 { } return value } + +// IsReferralEnabled 检查裂变推广功能是否启用 +func (s *SettingService) IsReferralEnabled(ctx context.Context) (bool, error) { + val, err := s.settingRepo.GetValue(ctx, SettingKeyReferralEnabled) + if err != nil { + return false, nil // default: disabled + } + return val == "true", nil +} + +// GetReferralRewards 获取邀请奖励配置,返回 (inviterReward, inviteeReward)。 +// 配置缺失时返回默认值 0(需管理员显式配置后才发放奖励)。 +func (s *SettingService) GetReferralRewards(ctx context.Context) (inviterReward, inviteeReward float64, err error) { + settings, err := s.settingRepo.GetMultiple(ctx, []string{ + SettingKeyReferralInviterReward, + SettingKeyReferralInviteeReward, + }) + inviterReward = 0 + inviteeReward = 0 + if err != nil { + return 0, 0, fmt.Errorf("get referral reward settings: %w", err) + } + if v, ok := settings[SettingKeyReferralInviterReward]; ok { + if f, e := strconv.ParseFloat(v, 64); e == nil { + inviterReward = f + } + } + if v, ok := settings[SettingKeyReferralInviteeReward]; ok { + if f, e := strconv.ParseFloat(v, 64); e == nil { + inviteeReward = f + } + } + return inviterReward, inviteeReward, nil +} diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 9f0de6000..41dfa4c3f 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -65,6 +65,11 @@ type SystemSettings struct { // Claude Code version check MinClaudeCodeVersion string + + // 裂变推广配置 + ReferralEnabled bool + ReferralInviterReward float64 + ReferralInviteeReward float64 } type DefaultSubscriptionSetting struct { @@ -97,6 +102,7 @@ type PublicSettings struct { LinuxDoOAuthEnabled bool Version string + ReferralEnabled bool } // SoraS3Settings Sora S3 存储配置 diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index c71851901..996f9348d 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -379,4 +379,5 @@ var ProviderSet = wire.NewSet( ProvideIdempotencyCoordinator, ProvideSystemOperationLockService, ProvideIdempotencyCleanupService, + NewReferralService, ) diff --git a/backend/migrations/064_add_referral_system.sql b/backend/migrations/064_add_referral_system.sql new file mode 100644 index 000000000..2ad8092a9 --- /dev/null +++ b/backend/migrations/064_add_referral_system.sql @@ -0,0 +1,35 @@ +-- +goose Up +-- +goose StatementBegin +-- 用户邀请裂变推广机制 - 新增两张独立表,不修改任何现有表 +-- user_referral_profiles: 每个用户的专属邀请码(懒加载,注册时创建) +-- referral_relations: 邀请关系记录(含奖励金额快照和幂等标志) + +CREATE TABLE user_referral_profiles ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id), + referral_code VARCHAR(8) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_urp_user_id UNIQUE (user_id), + CONSTRAINT uq_urp_referral_code UNIQUE (referral_code) +); + +CREATE TABLE referral_relations ( + id BIGSERIAL PRIMARY KEY, + inviter_id BIGINT NOT NULL REFERENCES users(id), + invitee_id BIGINT NOT NULL REFERENCES users(id), + inviter_reward DECIMAL(20,8) NOT NULL DEFAULT 0, + invitee_reward DECIMAL(20,8) NOT NULL DEFAULT 0, + reward_granted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_rr_invitee_id UNIQUE (invitee_id) +); + +-- 按邀请人查询邀请记录(分页列表高频查询) +CREATE INDEX idx_referral_relations_inviter_id ON referral_relations(inviter_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS referral_relations; +DROP TABLE IF EXISTS user_referral_profiles; +-- +goose StatementEnd diff --git a/backend/migrations/065_add_referral_indexes_notx.sql b/backend/migrations/065_add_referral_indexes_notx.sql new file mode 100644 index 000000000..32f50d2ba --- /dev/null +++ b/backend/migrations/065_add_referral_indexes_notx.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- +goose StatementBegin +-- 并发创建索引(不阻塞写入),需 _notx 后缀 +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_urp_user_id_concurrent + ON user_referral_profiles(user_id); + +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_urp_referral_code_concurrent + ON user_referral_profiles(referral_code); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX CONCURRENTLY IF EXISTS idx_urp_user_id_concurrent; +DROP INDEX CONCURRENTLY IF EXISTS idx_urp_referral_code_concurrent; +-- +goose StatementEnd diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 52855a040..c95517d4b 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -78,6 +78,11 @@ export interface SystemSettings { // Claude Code version check min_claude_code_version: string + + // 裂变推广配置 + referral_enabled: boolean + referral_inviter_reward: number + referral_invitee_reward: number } export interface UpdateSettingsRequest { @@ -128,6 +133,11 @@ export interface UpdateSettingsRequest { ops_query_mode_default?: 'auto' | 'raw' | 'preagg' | string ops_metrics_interval_seconds?: number min_claude_code_version?: string + + // 裂变推广配置 + referral_enabled?: boolean + referral_inviter_reward?: number + referral_invitee_reward?: number } /** diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 070ce6485..117f25c37 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -17,6 +17,7 @@ export { redeemAPI, type RedeemHistoryItem } from './redeem' export { userGroupsAPI } from './groups' export { totpAPI } from './totp' export { default as announcementsAPI } from './announcements' +export { referralAPI } from './referral' // Admin APIs export { adminAPI } from './admin' diff --git a/frontend/src/api/referral.ts b/frontend/src/api/referral.ts new file mode 100644 index 000000000..31d65113e --- /dev/null +++ b/frontend/src/api/referral.ts @@ -0,0 +1,45 @@ +/** + * Referral API endpoints + * Handles user referral/invitation system + */ + +import { apiClient } from './client' +import type { ReferralInfo, ReferralInvitee, BasePaginationResponse, ReferralStats } from '@/types' + +/** + * Get current user's referral info (code, link, stats) + */ +export async function getMyReferralInfo(): Promise { + const { data } = await apiClient.get('/referral') + return data +} + +/** + * Get paginated list of users invited by current user + */ +export async function listMyInvitees( + page = 1, + pageSize = 20 +): Promise> { + const { data } = await apiClient.get>( + '/referral/invitees', + { params: { page, page_size: pageSize } } + ) + return data +} + +/** + * Admin: Get platform-wide referral statistics + */ +export async function getAdminReferralStats(): Promise { + const { data } = await apiClient.get('/admin/referral/stats') + return data +} + +export const referralAPI = { + getMyReferralInfo, + listMyInvitees, + getAdminReferralStats +} + +export default referralAPI diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index dcfc60bbb..560516953 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -492,6 +492,21 @@ const SoraIcon = { ) } +const ReferralIcon = { + render: () => + h( + 'svg', + { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, + [ + h('path', { + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + d: 'M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0 0a2.25 2.25 0 1 0 3.935 2.186 2.25 2.25 0 0 0-3.935-2.186zm0-12.814a2.25 2.25 0 1 0 3.933-2.185 2.25 2.25 0 0 0-3.933 2.185z' + }) + ] + ) +} + const ChevronDoubleRightIcon = { render: () => h( @@ -528,6 +543,9 @@ const userNavItems = computed((): NavItem[] => { ] : []), { path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true }, + ...(appStore.cachedPublicSettings?.referral_enabled + ? [{ path: '/referral', label: t('nav.referral'), icon: ReferralIcon }] + : []), { path: '/profile', label: t('nav.profile'), icon: UserIcon }, ...customMenuItemsForUser.value.map((item): NavItem => ({ path: `/custom/${item.id}`, @@ -559,6 +577,9 @@ const personalNavItems = computed((): NavItem[] => { ] : []), { path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true }, + ...(appStore.cachedPublicSettings?.referral_enabled + ? [{ path: '/referral', label: t('nav.referral'), icon: ReferralIcon }] + : []), { path: '/profile', label: t('nav.profile'), icon: UserIcon }, ...customMenuItemsForUser.value.map((item): NavItem => ({ path: `/custom/${item.id}`, diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 41edeb6a0..38d724681 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -230,6 +230,7 @@ export default { noOptionsFound: 'No options found', noGroupsAvailable: 'No groups available', unknownError: 'Unknown error occurred', + retry: 'Retry', saving: 'Saving...', selectedCount: '({count} selected)', refresh: 'Refresh', @@ -282,7 +283,8 @@ export default { mySubscriptions: 'My Subscriptions', buySubscription: 'Recharge / Subscription', docs: 'Docs', - sora: 'Sora Studio' + sora: 'Sora Studio', + referral: 'Referral' }, // Auth @@ -695,6 +697,32 @@ export default { pleaseEnterCode: 'Please enter a redeem code' }, + // Referral + referral: { + title: 'Referral Program', + description: 'Invite friends to join and earn balance rewards for both of you', + myCode: 'Your Referral Code', + myLink: 'Your Referral Link', + copyCode: 'Copy Code', + copyLink: 'Copy Link', + codeCopied: 'Code copied!', + linkCopied: 'Link copied!', + copyFailed: 'Copy failed', + stats: 'Your Stats', + totalInvitees: 'Total Invitees', + totalRewardEarned: 'Total Reward Earned', + invitedBy: 'You were invited by', + inviteeList: 'Your Invitees', + noInvitees: 'You have not invited anyone yet. Share your referral link to get started!', + emailMasked: 'Email', + registeredAt: 'Registered At', + rewardEarned: 'Reward Earned', + howItWorks: 'How It Works', + step1: 'Share your referral link or code with friends', + step2: 'Your friend registers using your link or enters your code', + step3: 'Both you and your friend receive a balance reward' + }, + // Profile profile: { title: 'Profile Settings', @@ -3643,6 +3671,17 @@ export default { enabled: 'Enable Sora Client', enabledHint: 'When enabled, the Sora entry will be shown in the sidebar for users to access Sora features' }, + referral: { + title: 'Referral Program', + description: 'Configure the referral/invitation reward system', + enabled: 'Enable Referral Program', + enabledHint: 'When enabled, users can invite others and earn balance rewards', + inviterReward: 'Inviter Reward ($)', + inviterRewardHint: 'Balance credited to the user who sent the invitation when their invitee registers', + inviteeReward: 'Invitee Reward ($)', + inviteeRewardHint: 'Balance credited to the newly registered user who used an invitation link', + rewardPlaceholder: '0.00' + }, customMenu: { title: 'Custom Menu Pages', description: 'Add custom iframe pages to the sidebar navigation. Each page can be visible to regular users or administrators.', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 397ecbb2f..2fd857fa2 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -230,6 +230,7 @@ export default { noOptionsFound: '无匹配选项', noGroupsAvailable: '无可用分组', unknownError: '发生未知错误', + retry: '重试', saving: '保存中...', selectedCount: '(已选 {count} 个)', refresh: '刷新', @@ -282,7 +283,8 @@ export default { mySubscriptions: '我的订阅', buySubscription: '充值/订阅', docs: '文档', - sora: 'Sora 创作' + sora: 'Sora 创作', + referral: '邀请推广' }, // Auth @@ -701,6 +703,32 @@ export default { pleaseEnterCode: '请输入兑换码' }, + // Referral + referral: { + title: '邀请推广', + description: '邀请好友注册,双方均可获得余额奖励', + myCode: '您的邀请码', + myLink: '您的邀请链接', + copyCode: '复制邀请码', + copyLink: '复制邀请链接', + codeCopied: '邀请码已复制!', + linkCopied: '链接已复制!', + copyFailed: '复制失败', + stats: '邀请统计', + totalInvitees: '已邀请人数', + totalRewardEarned: '累计获得奖励', + invitedBy: '您的邀请人', + inviteeList: '已邀请用户', + noInvitees: '您还没有邀请任何人,分享您的邀请链接开始吧!', + emailMasked: '邮箱', + registeredAt: '注册时间', + rewardEarned: '获得奖励', + howItWorks: '如何使用', + step1: '分享您的邀请链接或邀请码给好友', + step2: '好友通过您的链接注册,或注册时填写您的邀请码', + step3: '双方均可获得余额奖励' + }, + // Profile profile: { title: '个人设置', @@ -3813,6 +3841,17 @@ export default { enabled: '启用 Sora 客户端', enabledHint: '开启后,侧边栏将显示 Sora 入口,用户可访问 Sora 功能' }, + referral: { + title: '邀请推广', + description: '配置邀请注册奖励机制', + enabled: '启用邀请推广', + enabledHint: '开启后,用户可通过邀请他人注册获得余额奖励', + inviterReward: '邀请人奖励($)', + inviterRewardHint: '被邀请人成功注册后,邀请人获得的余额奖励', + inviteeReward: '被邀请人奖励($)', + inviteeRewardHint: '通过邀请链接注册的新用户获得的余额奖励', + rewardPlaceholder: '0.00' + }, customMenu: { title: '自定义菜单页面', description: '添加自定义 iframe 页面到侧边栏导航。每个页面可以设置为普通用户或管理员可见。', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 08f492d4d..dd924ffe7 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -155,6 +155,18 @@ const routes: RouteRecordRaw[] = [ descriptionKey: 'redeem.description' } }, + { + path: '/referral', + name: 'Referral', + component: () => import('@/views/user/ReferralView.vue'), + meta: { + requiresAuth: true, + requiresAdmin: false, + title: 'Referral', + titleKey: 'referral.title', + descriptionKey: 'referral.description' + } + }, { path: '/profile', name: 'Profile', diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index 37439a4c1..bb24fcca8 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -330,6 +330,7 @@ export const useAppStore = defineStore('app', () => { custom_menu_items: [], linuxdo_oauth_enabled: false, sora_client_enabled: false, + referral_enabled: false, version: siteVersion.value } } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6e5aa3020..383f2707a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -63,6 +63,33 @@ export interface RegisterRequest { turnstile_token?: string promo_code?: string invitation_code?: string + referral_code?: string +} + +// ==================== Referral Types ==================== + +export interface ReferralInviterInfo { + email_masked: string +} + +export interface ReferralInfo { + referral_code: string + referral_link: string + total_invitees: number + total_reward_earned: number + inviter_info?: ReferralInviterInfo +} + +export interface ReferralInvitee { + email_masked: string + registered_at: string + reward_earned: number +} + +export interface ReferralStats { + total_relations: number + total_inviter_reward_given: number + total_invitee_reward_given: number } export interface SendVerifyCodeRequest { @@ -105,6 +132,7 @@ export interface PublicSettings { custom_menu_items: CustomMenuItem[] linuxdo_oauth_enabled: boolean sora_client_enabled: boolean + referral_enabled: boolean version: string } diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 3a42a5b71..990ba91ac 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1110,6 +1110,68 @@ + +
+
+

+ {{ t('admin.settings.referral.title') }} +

+

+ {{ t('admin.settings.referral.description') }} +

+
+
+ +
+
+ +

+ {{ t('admin.settings.referral.enabledHint') }} +

+
+ +
+ + +
+ + +

+ {{ t('admin.settings.referral.inviterRewardHint') }} +

+
+ + +
+ + +

+ {{ t('admin.settings.referral.inviteeRewardHint') }} +

+
+
+
+
@@ -1438,7 +1500,11 @@ const form = reactive({ ops_query_mode_default: 'auto', ops_metrics_interval_seconds: 60, // Claude Code version check - min_claude_code_version: '' + min_claude_code_version: '', + // 裂变推广配置 + referral_enabled: false, + referral_inviter_reward: 0, + referral_invitee_reward: 0 }) const defaultSubscriptionGroupOptions = computed(() => @@ -1623,7 +1689,10 @@ async function saveSettings() { fallback_model_antigravity: form.fallback_model_antigravity, enable_identity_patch: form.enable_identity_patch, identity_patch_prompt: form.identity_patch_prompt, - min_claude_code_version: form.min_claude_code_version + min_claude_code_version: form.min_claude_code_version, + referral_enabled: form.referral_enabled, + referral_inviter_reward: form.referral_inviter_reward, + referral_invitee_reward: form.referral_invitee_reward } const updated = await adminAPI.settings.updateSettings(payload) Object.assign(form, updated) diff --git a/frontend/src/views/auth/EmailVerifyView.vue b/frontend/src/views/auth/EmailVerifyView.vue index 7f797eb43..e0ca27a9a 100644 --- a/frontend/src/views/auth/EmailVerifyView.vue +++ b/frontend/src/views/auth/EmailVerifyView.vue @@ -202,6 +202,7 @@ const password = ref('') const initialTurnstileToken = ref('') const promoCode = ref('') const invitationCode = ref('') +const referralCode = ref('') const hasRegisterData = ref(false) // Public settings @@ -232,6 +233,7 @@ onMounted(async () => { initialTurnstileToken.value = registerData.turnstile_token || '' promoCode.value = registerData.promo_code || '' invitationCode.value = registerData.invitation_code || '' + referralCode.value = registerData.referral_code || '' hasRegisterData.value = !!(email.value && password.value) } catch { hasRegisterData.value = false @@ -387,7 +389,8 @@ async function handleVerify(): Promise { verify_code: verifyCode.value.trim(), turnstile_token: initialTurnstileToken.value || undefined, promo_code: promoCode.value || undefined, - invitation_code: invitationCode.value || undefined + invitation_code: invitationCode.value || undefined, + referral_code: referralCode.value || undefined }) // Clear session data diff --git a/frontend/src/views/auth/RegisterView.vue b/frontend/src/views/auth/RegisterView.vue index 53cfe0d16..be9e50061 100644 --- a/frontend/src/views/auth/RegisterView.vue +++ b/frontend/src/views/auth/RegisterView.vue @@ -148,6 +148,27 @@
+ +
+ +
+
+ +
+ +
+
+