- Remove old review reports (keep latest only) - Move docs/ to deploy/docs-backup/ - Move performance-testing/ to deploy/ - Clean up test output files - Organize root directory
437 lines
11 KiB
Markdown
437 lines
11 KiB
Markdown
# Sub2API 模块分析报告:订阅与兑换码模块
|
||
|
||
## 1. 模块概述
|
||
|
||
### 1.1 模块定位
|
||
订阅与兑换码模块是Sub2API系统的权益管理核心,负责管理用户的订阅服务、套餐计划以及通过兑换码进行余额充值和权益发放。
|
||
|
||
### 1.2 核心职责
|
||
- **订阅管理**:管理用户的订阅服务(包月、包年等)
|
||
- **套餐计划**:创建和管理订阅套餐
|
||
- **兑换码系统**:生成、验证、兑换激活码/充值码
|
||
- **权益发放**:自动将权益发放到用户账户
|
||
|
||
## 2. 代码结构分析
|
||
|
||
### 2.1 核心文件
|
||
|
||
| 文件路径 | 职责 | 代码行数 |
|
||
|---------|------|----------|
|
||
| `service/subscription_service.go` | 订阅核心服务 | ~800行 |
|
||
| `service/redeem_service.go` | 兑换码服务 | ~500行 |
|
||
| `handler/subscription_handler.go` | 订阅API处理器 | ~300行 |
|
||
| `handler/redeem_handler.go` | 兑换码API处理器 | ~200行 |
|
||
| `repository/user_subscription_repo.go` | 订阅数据访问层 | ~500行 |
|
||
| `repository/redeem_code_repo.go` | 兑换码数据访问层 | ~400行 |
|
||
|
||
### 2.2 核心数据模型
|
||
|
||
```go
|
||
// 用户订阅 - ent/schema/usersubscription.go
|
||
type UserSubscription struct {
|
||
ID int64
|
||
UserID int64
|
||
GroupID int64 // 订阅的分组
|
||
PlanID string // 套餐ID
|
||
PlanName string // 套餐名称
|
||
Status string // active/expired/canceled
|
||
StartDate time.Time // 开始日期
|
||
EndDate time.Time // 结束日期
|
||
AutoRenew bool // 自动续费
|
||
CreatedAt time.Time
|
||
UpdatedAt time.Time
|
||
}
|
||
|
||
// 订阅套餐 - 配置定义
|
||
type SubscriptionPlan struct {
|
||
ID string
|
||
Name string
|
||
GroupID int64
|
||
DurationDays int // 时长(天)
|
||
Price float64 // 价格
|
||
ModelLimits string // 模型限制(JSON)
|
||
}
|
||
|
||
// 兑换码 - ent/schema/redeemcode.go
|
||
type RedeemCode struct {
|
||
ID int64
|
||
Code string // 兑换码
|
||
Type string // balance/concurrency/subscription/invitation
|
||
Value float64 // 金额或并发数
|
||
Status string // unused/used/expired
|
||
GroupID *int64 // 订阅类型专用
|
||
ValidDays int // 有效期(天)
|
||
UserID int64 // 使用者
|
||
UsedAt *time.Time
|
||
Notes string
|
||
CreatedAt time.Time
|
||
}
|
||
```
|
||
|
||
## 3. 功能详细分析
|
||
|
||
### 3.1 订阅管理
|
||
|
||
#### 3.1.1 创建订阅
|
||
|
||
```go
|
||
// service/subscription_service.go - CreateSubscription
|
||
func (s *SubscriptionService) CreateSubscription(ctx context.Context, userID int64, planID string) (*UserSubscription, error) {
|
||
// 1. 获取套餐信息
|
||
plan := s.getPlan(planID)
|
||
if plan == nil {
|
||
return nil, ErrPlanNotFound
|
||
}
|
||
|
||
// 2. 验证分组权限
|
||
if !s.userCanAccessGroup(ctx, userID, plan.GroupID) {
|
||
return nil, ErrNoAccessToGroup
|
||
}
|
||
|
||
// 3. 计算订阅时间
|
||
now := time.Now()
|
||
endDate := now.AddDate(0, 0, plan.DurationDays)
|
||
|
||
// 4. 创建订阅记录
|
||
subscription := &UserSubscription{
|
||
UserID: userID,
|
||
GroupID: plan.GroupID,
|
||
PlanID: planID,
|
||
PlanName: plan.Name,
|
||
Status: StatusActive,
|
||
StartDate: now,
|
||
EndDate: endDate,
|
||
AutoRenew: false,
|
||
}
|
||
|
||
return s.subscriptionRepo.Create(ctx, subscription)
|
||
}
|
||
```
|
||
|
||
#### 3.1.2 订阅验证
|
||
|
||
```go
|
||
// 验证用户是否有有效订阅
|
||
func (s *SubscriptionService) ValidateSubscription(ctx context.Context, userID int64, groupID int64) error {
|
||
// 1. 查询用户在该分组的有效订阅
|
||
sub, err := s.subscriptionRepo.GetActiveByUserAndGroup(ctx, userID, groupID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 2. 检查是否过期
|
||
if time.Now().After(sub.EndDate) {
|
||
return ErrSubscriptionExpired
|
||
}
|
||
|
||
return nil
|
||
}
|
||
```
|
||
|
||
#### 3.1.3 自动续期
|
||
|
||
```go
|
||
// 自动检查和续期订阅
|
||
func (s *SubscriptionService) ProcessAutoRenew() {
|
||
// 1. 获取即将到期的订阅
|
||
subscriptions := s.subscriptionRepo.GetExpiringSoon(3) // 3天内到期
|
||
|
||
for _, sub := range subscriptions {
|
||
// 2. 检查自动续费开关
|
||
if !sub.AutoRenew {
|
||
continue
|
||
}
|
||
|
||
// 3. 尝试扣款
|
||
user, _ := s.userRepo.GetByID(ctx, sub.UserID)
|
||
plan := s.getPlan(sub.PlanID)
|
||
|
||
if user.Balance >= plan.Price {
|
||
// 4. 扣款并延长
|
||
s.userRepo.DeductBalance(ctx, sub.UserID, plan.Price)
|
||
s.extendSubscription(ctx, sub.ID, plan.DurationDays)
|
||
} else {
|
||
// 5. 余额不足,标记为即将过期
|
||
s.sendRenewalReminder(ctx, sub.UserID, sub.ID)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.2 兑换码系统
|
||
|
||
#### 3.2.1 生成兑换码
|
||
|
||
```go
|
||
// service/redeem_service.go - GenerateCodes
|
||
func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequest) ([]RedeemCode, error) {
|
||
// 1. 验证请求
|
||
if req.Count <= 0 || req.Count > 1000 {
|
||
return nil, ErrInvalidCount
|
||
}
|
||
|
||
// 2. 生成随机码
|
||
codes := make([]RedeemCode, 0, req.Count)
|
||
for i := 0; i < req.Count; i++ {
|
||
// 格式:XXXX-XXXX-XXXX-XXXX
|
||
code := generateCode()
|
||
|
||
codes = append(codes, RedeemCode{
|
||
Code: code,
|
||
Type: req.Type,
|
||
Value: req.Value,
|
||
Status: StatusUnused,
|
||
GroupID: req.GroupID,
|
||
ValidDays: req.ValidDays,
|
||
})
|
||
}
|
||
|
||
// 3. 批量保存
|
||
return s.redeemRepo.CreateBatch(ctx, codes)
|
||
}
|
||
|
||
func generateCode() string {
|
||
// 生成16字节随机数,转为32位hex,分4段
|
||
bytes := make([]byte, 16)
|
||
rand.Read(bytes)
|
||
hex := hex.EncodeToString(bytes)
|
||
|
||
// 格式:XXXX-XXXX-XXXX-XXXX
|
||
return fmt.Sprintf("%s-%s-%s-%s",
|
||
strings.ToUpper(hex[0:8]),
|
||
strings.ToUpper(hex[8:16]),
|
||
strings.ToUpper(hex[16:24]),
|
||
strings.ToUpper(hex[24:32]))
|
||
}
|
||
```
|
||
|
||
#### 3.2.2 兑换流程
|
||
|
||
```go
|
||
// service/redeem_service.go - Redeem
|
||
func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) (*RedeemCode, error) {
|
||
// 1. 获取分布式锁(防止并发兑换)
|
||
if !s.acquireLock(ctx, code) {
|
||
return nil, ErrCodeLocked
|
||
}
|
||
defer s.releaseLock(ctx, code)
|
||
|
||
// 2. 查询兑换码
|
||
redeemCode, err := s.redeemRepo.GetByCode(ctx, code)
|
||
if err != nil {
|
||
return nil, ErrCodeNotFound
|
||
}
|
||
|
||
// 3. 验证状态
|
||
if redeemCode.Status != StatusUnused {
|
||
return nil, ErrCodeAlreadyUsed
|
||
}
|
||
|
||
// 4. 验证有效期
|
||
if redeemCode.CreatedAt.AddDate(0, 0, redeemCode.ValidDays).Before(time.Now()) {
|
||
return nil, ErrCodeExpired
|
||
}
|
||
|
||
// 5. 执行兑换逻辑
|
||
switch redeemCode.Type {
|
||
case TypeBalance:
|
||
// 增加余额
|
||
err = s.userRepo.AddBalance(ctx, userID, redeemCode.Value)
|
||
|
||
case TypeConcurrency:
|
||
// 增加并发
|
||
err = s.userRepo.AddConcurrency(ctx, userID, int(redeemCode.Value))
|
||
|
||
case TypeSubscription:
|
||
// 开通订阅
|
||
err = s.createSubscription(ctx, userID, redeemCode.GroupID, redeemCode.Value)
|
||
}
|
||
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 6. 标记为已使用
|
||
err = s.redeemRepo.MarkAsUsed(ctx, redeemCode.ID, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 7. 返回更新后的记录
|
||
return s.redeemRepo.GetByID(ctx, redeemCode.ID)
|
||
}
|
||
```
|
||
|
||
### 3.3 兑换码类型
|
||
|
||
| 类型 | 值含义 | 用途 |
|
||
|------|--------|------|
|
||
| **balance** | 金额 | 充值余额 |
|
||
| **concurrency** | 数值 | 增加并发数 |
|
||
| **subscription** | 天数 | 开通订阅 |
|
||
| **invitation** | 0 | 邀请注册 |
|
||
|
||
### 3.4 权益发放
|
||
|
||
```go
|
||
// 订阅权益发放
|
||
func (s *SubscriptionService) GrantSubscriptionBenefits(ctx context.Context, userID int64, groupID int64, days int) error {
|
||
// 1. 查找或创建订阅
|
||
sub, err := s.subscriptionRepo.GetActiveByUserAndGroup(ctx, userID, groupID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if sub != nil {
|
||
// 已有订阅,延长时间
|
||
sub.EndDate = sub.EndDate.AddDate(0, 0, days)
|
||
return s.subscriptionRepo.Update(ctx, sub)
|
||
}
|
||
|
||
// 2. 创建新订阅
|
||
newSub := &UserSubscription{
|
||
UserID: userID,
|
||
GroupID: groupID,
|
||
Status: StatusActive,
|
||
StartDate: time.Now(),
|
||
EndDate: time.Now().AddDate(0, 0, days),
|
||
}
|
||
|
||
return s.subscriptionRepo.Create(ctx, newSub)
|
||
}
|
||
```
|
||
|
||
## 4. 配置参数
|
||
|
||
### 4.1 订阅配置(config.yaml)
|
||
|
||
```yaml
|
||
subscription:
|
||
# 套餐配置
|
||
plans:
|
||
- id: "monthly_basic"
|
||
name: "月度基础套餐"
|
||
group_id: 1
|
||
duration_days: 30
|
||
price: 99.00
|
||
model_limits:
|
||
- "claude-3-5-sonnet-20241022"
|
||
|
||
- id: "yearly_pro"
|
||
name: "年度专业套餐"
|
||
group_id: 1
|
||
duration_days: 365
|
||
price: 990.00
|
||
model_limits:
|
||
- "claude-3-5-sonnet-20241022"
|
||
- "claude-3-opus-5-20251101"
|
||
|
||
# 自动续费配置
|
||
auto_renew:
|
||
enabled: true
|
||
reminder_days: 3
|
||
retry_attempts: 3
|
||
```
|
||
|
||
### 4.2 兑换码配置
|
||
|
||
```yaml
|
||
redeem_code:
|
||
# 生成配置
|
||
length: 32
|
||
format: "XXXX-XXXX-XXXX-XXXX"
|
||
batch_max: 1000
|
||
|
||
# 有效期配置
|
||
default_valid_days: 30
|
||
max_valid_days: 365
|
||
```
|
||
|
||
## 5. 修改和扩展指南
|
||
|
||
### 5.1 常见修改场景
|
||
|
||
**场景1:添加新套餐**
|
||
|
||
```go
|
||
// 在配置中添加新套餐
|
||
const PlanIDEnterprise = "enterprise"
|
||
|
||
var SubscriptionPlans = map[string]*SubscriptionPlan{
|
||
PlanIDEnterprise: {
|
||
ID: PlanIDEnterprise,
|
||
Name: "企业版",
|
||
DurationDays: 365,
|
||
Price: 9999.00,
|
||
},
|
||
}
|
||
```
|
||
|
||
**场景2:自定义兑换码前缀**
|
||
|
||
```go
|
||
// 生成带前缀的兑换码
|
||
func generateCodeWithPrefix(prefix string) string {
|
||
bytes := make([]byte, 16)
|
||
rand.Read(bytes)
|
||
hex := hex.EncodeToString(bytes)
|
||
|
||
return fmt.Sprintf("%s-%s-%s-%s",
|
||
prefix,
|
||
strings.ToUpper(hex[0:8]),
|
||
strings.ToUpper(hex[8:16]),
|
||
strings.ToUpper(hex[16:24]))
|
||
}
|
||
```
|
||
|
||
### 5.2 注意事项
|
||
|
||
1. **安全性**:兑换码需要足够的随机性
|
||
2. **幂等性**:兑换操作需要支持幂等
|
||
3. **原子性**:兑换和权益发放需要事务保证
|
||
|
||
## 6. 测试覆盖
|
||
|
||
### 6.1 测试场景
|
||
|
||
| 测试文件 | 场景 |
|
||
|----------|------|
|
||
| `subscription_service_test.go` | 订阅CRUD |
|
||
| `redeem_service_test.go` | 兑换码生成和兑换 |
|
||
|
||
## 7. 监控与运维
|
||
|
||
### 7.1 关键指标
|
||
|
||
| 指标 | 说明 |
|
||
|------|------|
|
||
| `subscription_active_count` | 有效订阅数 |
|
||
| `subscription_expiring_soon` | 即将到期订阅数 |
|
||
| `redeem_code_used_rate` | 兑换码使用率 |
|
||
|
||
### 7.2 运维任务
|
||
|
||
| 任务 | 频率 | 说明 |
|
||
|------|------|------|
|
||
| 订阅到期处理 | 每天 | 处理到期订阅 |
|
||
| 兑换码统计 | 每周 | 统计使用情况 |
|
||
|
||
## 8. 总结
|
||
|
||
订阅与兑换码模块特点:
|
||
|
||
- **多种权益类型**:支持余额、并发、订阅等多种权益
|
||
- **完善的验证**:状态、有效期检查确保安全
|
||
- **分布式锁**:防止并发兑换导致的问题
|
||
|
||
**潜在改进点:**
|
||
1. 兑换码目前无系统标识,存在跨实例使用风险
|
||
2. 可增加更丰富的套餐类型
|
||
|
||
**修改建议:**
|
||
- 如需解决跨实例使用,需在码中嵌入实例标识
|
||
|
||
---
|
||
*文档版本:1.0*
|
||
*最后更新:2025-01*
|
||
*分析基于:Sub2API v0.1.104* |