diff --git a/docs/superpowers/plans/2026-05-20-运营能力补全-执行版.md b/docs/superpowers/plans/2026-05-20-运营能力补全-执行版.md new file mode 100644 index 00000000..fed825b2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-运营能力补全-执行版.md @@ -0,0 +1,2240 @@ +# new-api 运营能力补全 — 6 模块执行计划 + +> **For agentic workers:** 使用 superpowers:executing-plans 按任务逐步执行。步骤使用 `- [ ]` checkbox 追踪。 + +**目标:** 为 new-api 补充优惠码、公告系统、渠道监控、邀请返利、账号并发、国内支付直连 6 大运营能力。 + +**架构:** 每模块独立表 + 独立 API + 独立开关,互不阻塞。新渠道支付使用统一 Provider 接口抽象,老渠道不动。所有新表走 GORM AutoMigrate,三数据库兼容。 + +**技术栈:** Go 1.25 + Gin + GORM v2 + Redis + MySQL/PostgreSQL/SQLite + +**源项目:** `/Users/qingse/Sync/local-git/new-api/` +**参考项目:** `/Users/qingse/Sync/local-git/sub2api/` + +--- + +## 文件结构概览 + +### 新建文件 + +``` +model/promo_code.go +model/promo_code_usage.go +model/announcement.go +model/announcement_read.go +model/channel_monitor.go +model/channel_monitor_history.go +model/channel_monitor_daily_rollup.go +model/rebate_record.go +service/channel_monitor_runner.go +service/channel_monitor_checker.go +service/channel_monitor_ssrf.go +service/rebate.go +service/concurrency.go +controller/promo_code.go +controller/promo_code_user.go +controller/announcement.go +controller/announcement_user.go +controller/channel_monitor.go +controller/affiliate.go +controller/ops_concurrency.go +controller/payment_alipay.go +controller/payment_wxpay.go +middleware/concurrency.go +payment/types.go +payment/provider/alipay.go +payment/provider/wxpay.go +payment/crypto.go +setting/payment_alipay.go +setting/payment_wxpay.go +``` + +### 修改文件 + +``` +model/user.go # 加 aff_rebate_rate_percent, aff_rebate_frozen_days, concurrency_limit, rpm_limit +model/channel.go # 加 concurrency_limit +model/option.go # 加全局配置 key +model/topup.go # 加 provider_payload 字段 +controller/misc.go # /api/status 公告来源切换 +controller/topup_*.go # Recharge 完成后触发 rebate + promo 检查 +router/admin.go # 所有管理端路由 +router/api.go # 用户端路由 + relay middleware +go.mod # 支付宝/微信 SDK 依赖 +``` + +--- + +## 模块 1:优惠码(Promo Code) + +### Task 1.1: 创建数据模型 + +**Files:** +- Create: `model/promo_code.go` +- Create: `model/promo_code_usage.go` + +- [ ] **Step 1: 写 promo_code.go** + +```go +package model + +import ( + "time" + "gorm.io/gorm" +) + +type PromoCode struct { + Id uint `json:"id" gorm:"primaryKey;autoIncrement"` + Code string `json:"code" gorm:"type:varchar(32);uniqueIndex;not null"` + BonusAmount int `json:"bonus_amount" gorm:"not null;default:0"` + MaxUses int `json:"max_uses" gorm:"not null;default:0"` + UsedCount int `json:"used_count" gorm:"not null;default:0"` + Status int `json:"status" gorm:"not null;default:1"` // 1=active, 0=disabled + MinRechargeAmount int `json:"min_recharge_amount" gorm:"not null;default:0"` + ExpiresAt *time.Time `json:"expires_at"` + Notes string `json:"notes" gorm:"type:varchar(255);default:''"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (PromoCode) TableName() string { + return "promo_codes" +} + +func (p *PromoCode) Insert() error { + return DB.Create(p).Error +} + +func (p *PromoCode) Update() error { + return DB.Save(p).Error +} + +func (p *PromoCode) IncrementUsed() error { + return DB.Model(p).UpdateColumn("used_count", gorm.Expr("used_count + 1")).Error +} + +func GetPromoCodeByID(id int) (*PromoCode, error) { + var p PromoCode + err := DB.First(&p, id).Error + return &p, err +} + +func GetPromoCodeByCode(code string) (*PromoCode, error) { + var p PromoCode + err := DB.Where("code = ?", code).First(&p).Error + return &p, err +} + +func GetPromoCodes(page, pageSize int, status *int, keyword string) ([]PromoCode, int64, error) { + var codes []PromoCode + var total int64 + q := DB.Model(&PromoCode{}) + if status != nil { + q = q.Where("status = ?", *status) + } + if keyword != "" { + q = q.Where("code LIKE ?", "%"+keyword+"%") + } + err := q.Count(&total).Error + if err != nil { + return nil, 0, err + } + err = q.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&codes).Error + return codes, total, err +} + +func DeletePromoCodeByID(id int) error { + return DB.Delete(&PromoCode{}, id).Error +} +``` + +- [ ] **Step 2: 写 promo_code_usage.go** + +```go +package model + +import "time" + +type PromoCodeUsage struct { + Id uint `json:"id" gorm:"primaryKey;autoIncrement"` + PromoCodeId int `json:"promo_code_id" gorm:"not null;index:idx_promo_user,unique"` + UserId int `json:"user_id" gorm:"not null;index:idx_promo_user,unique"` + UsedAt time.Time `json:"used_at" gorm:"not null"` +} + +func (PromoCodeUsage) TableName() string { + return "promo_code_usages" +} + +func (u *PromoCodeUsage) Insert() error { + return DB.Create(u).Error +} + +func GetPromoCodeUsageByCodeAndUser(promoCodeId, userId int) (*PromoCodeUsage, error) { + var u PromoCodeUsage + err := DB.Where("promo_code_id = ? AND user_id = ?", promoCodeId, userId).First(&u).Error + return &u, err +} + +func GetPromoCodeUsages(page, pageSize int, promoCodeId int) ([]PromoCodeUsage, int64, error) { + var usages []PromoCodeUsage + var total int64 + err := DB.Model(&PromoCodeUsage{}).Where("promo_code_id = ?", promoCodeId).Count(&total).Error + if err != nil { + return nil, 0, err + } + err = DB.Where("promo_code_id = ?", promoCodeId).Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&usages).Error + return usages, total, err +} +``` + +- [ ] **Step 3: 注册 AutoMigrate** + +读取 `model/init.go` 找到 `InitDB()` 函数,在 AutoMigrate 列表末尾加入新模型。 + +- [ ] **Step 4: 编译验证** + +Run: `cd /Users/qingse/Sync/local-git/new-api && go build ./...` +Expected: 编译通过 + +- [ ] **Step 5: Commit** + +```bash +git add model/promo_code.go model/promo_code_usage.go model/init.go +git commit -m "feat: add promo code data models" +``` + +### Task 1.2: 管理端 API + +**Files:** +- Create: `controller/promo_code.go` + +- [ ] **Step 1: 写管理端 handler** + +```go +package controller + +import ( + "net/http" + "strconv" + "time" + "github.com/gin-gonic/gin" + "new-api/model" +) + +func GetPromoCodes(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + keyword := c.Query("keyword") + + var status *int + if s := c.Query("status"); s != "" { + v, _ := strconv.Atoi(s) + status = &v + } + + codes, total, err := model.GetPromoCodes(page, pageSize, status, keyword) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": codes, "total": total}) +} + +type CreatePromoCodeReq struct { + Code string `json:"code" binding:"required"` + BonusAmount int `json:"bonus_amount" binding:"required"` + MaxUses int `json:"max_uses"` + MinRechargeAmount int `json:"min_recharge_amount"` + ExpiresAt string `json:"expires_at"` // RFC3339 format + Notes string `json:"notes"` +} + +func CreatePromoCode(c *gin.Context) { + var req CreatePromoCodeReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + p := &model.PromoCode{ + Code: req.Code, + BonusAmount: req.BonusAmount, + MaxUses: req.MaxUses, + MinRechargeAmount: req.MinRechargeAmount, + Notes: req.Notes, + Status: 1, + } + if req.ExpiresAt != "" { + t, err := time.Parse(time.RFC3339, req.ExpiresAt) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid expires_at format"}) + return + } + p.ExpiresAt = &t + } + + if err := p.Insert(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": p}) +} + +func GetPromoCode(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + p, err := model.GetPromoCodeByID(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": p}) +} + +type UpdatePromoCodeReq struct { + Status *int `json:"status"` + MaxUses *int `json:"max_uses"` + MinRechargeAmount *int `json:"min_recharge_amount"` + ExpiresAt string `json:"expires_at"` + Notes *string `json:"notes"` +} + +func UpdatePromoCode(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + p, err := model.GetPromoCodeByID(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + + var req UpdatePromoCodeReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.Status != nil { + p.Status = *req.Status + } + if req.MaxUses != nil { + p.MaxUses = *req.MaxUses + } + if req.MinRechargeAmount != nil { + p.MinRechargeAmount = *req.MinRechargeAmount + } + if req.ExpiresAt != "" { + t, _ := time.Parse(time.RFC3339, req.ExpiresAt) + p.ExpiresAt = &t + } + if req.Notes != nil { + p.Notes = *req.Notes + } + + if err := p.Update(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": p}) +} + +func DeletePromoCode(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + if err := model.DeletePromoCodeByID(id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "deleted"}) +} + +func GetPromoCodeUsages(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + usages, total, err := model.GetPromoCodeUsages(page, pageSize, id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": usages, "total": total}) +} +``` + +- [ ] **Step 2: 在 router/admin.go 中注册路由** + +找到管理端路由注册位置(约在 `RegisterAdminRoutes` 中),加入: + +```go +// 优惠码管理 +promoGroup := admin.Group("/promo-codes") +{ + promoGroup.GET("", controller.GetPromoCodes) + promoGroup.POST("", controller.CreatePromoCode) + promoGroup.GET("/:id", controller.GetPromoCode) + promoGroup.PUT("/:id", controller.UpdatePromoCode) + promoGroup.DELETE("/:id", controller.DeletePromoCode) + promoGroup.GET("/:id/usages", controller.GetPromoCodeUsages) +} +``` + +- [ ] **Step 3: 编译验证** + +Run: `cd /Users/qingse/Sync/local-git/new-api && go build ./...` +Expected: 编译通过 + +- [ ] **Step 4: Commit** + +```bash +git add controller/promo_code.go router/admin.go +git commit -m "feat: add promo code admin API" +``` + +### Task 1.3: 用户端校验 API + +**Files:** +- Create: `controller/promo_code_user.go` + +- [ ] **Step 1: 写 validate handler** + +```go +package controller + +import ( + "net/http" + "time" + "github.com/gin-gonic/gin" + "new-api/model" +) + +type ValidatePromoCodeReq struct { + Code string `json:"code" binding:"required"` + Amount int `json:"amount"` // 计划充值金额 +} + +type ValidatePromoCodeResp struct { + Valid bool `json:"valid"` + Reason string `json:"reason,omitempty"` + PromoCodeID uint `json:"promo_code_id,omitempty"` + BonusAmount int `json:"bonus_amount,omitempty"` + Code string `json:"code,omitempty"` +} + +func ValidatePromoCode(c *gin.Context) { + var req ValidatePromoCodeReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + pc, err := model.GetPromoCodeByCode(req.Code) + if err != nil { + c.JSON(http.StatusOK, ValidatePromoCodeResp{Valid: false, Reason: "not_found"}) + return + } + + if pc.Status != 1 { + c.JSON(http.StatusOK, ValidatePromoCodeResp{Valid: false, Reason: "disabled"}) + return + } + + if pc.ExpiresAt != nil && pc.ExpiresAt.Before(time.Now()) { + c.JSON(http.StatusOK, ValidatePromoCodeResp{Valid: false, Reason: "expired"}) + return + } + + if pc.MaxUses > 0 && pc.UsedCount >= pc.MaxUses { + c.JSON(http.StatusOK, ValidatePromoCodeResp{Valid: false, Reason: "max_uses_reached"}) + return + } + + if pc.MinRechargeAmount > 0 && req.Amount < pc.MinRechargeAmount { + c.JSON(http.StatusOK, ValidatePromoCodeResp{Valid: false, Reason: "below_min_recharge"}) + return + } + + userId := c.GetInt("id") // from auth middleware + _, err = model.GetPromoCodeUsageByCodeAndUser(int(pc.Id), userId) + if err == nil { + c.JSON(http.StatusOK, ValidatePromoCodeResp{Valid: false, Reason: "already_used"}) + return + } + + c.JSON(http.StatusOK, ValidatePromoCodeResp{ + Valid: true, + PromoCodeID: pc.Id, + BonusAmount: pc.BonusAmount, + Code: pc.Code, + }) +} +``` + +- [ ] **Step 2: 在 router/api.go 中注册路由** + +```go +authGroup.POST("/promo-codes/validate", controller.ValidatePromoCode) +``` + +- [ ] **Step 3: 编译验证** + +Run: `cd /Users/qingse/Sync/local-git/new-api && go build ./...` + +- [ ] **Step 4: Commit** + +```bash +git add controller/promo_code_user.go router/api.go +git commit -m "feat: add promo code validate endpoint" +``` + +### Task 1.4: 充值完成时应用优惠码 + +**Files:** +- Modify: 任一 `controller/topup_*.go` 或 `service/topup.go`(根据实际充值流程定位) + +- [ ] **Step 1: 找到 Recharge 完成逻辑** + +先读取充值相关文件确定 Recharge 函数位置: + +```bash +grep -rn "func.*Recharge\|func.*CompleteTopUp" --include="*.go" controller/ service/ +``` + +- [ ] **Step 2: 在充值完成后加优惠码处理** + +在充值额度增加后、返回前插入: + +```go +// 优惠码加赠(如果订单附带了 promo_code_id) +if promoCodeID := getPromoCodeIDFromOrder(order); promoCodeID > 0 { + // 检查是否已使用(幂等) + userId := order.UserId + _, err := model.GetPromoCodeUsageByCodeAndUser(promoCodeID, userId) + if errors.Is(err, gorm.ErrRecordNotFound) { + pc, _ := model.GetPromoCodeByID(promoCodeID) + if pc != nil && pc.Status == 1 { + // 加赠额度 + user.Quota += pc.BonusAmount + model.DB.Save(user) + // 记录使用 + model.DB.Create(&model.PromoCodeUsage{ + PromoCodeId: promoCodeID, + UserId: userId, + UsedAt: time.Now(), + }) + pc.IncrementUsed() + // 记录日志 + model.RecordLog(userId, model.LogTypeTopup, + fmt.Sprintf("promo_code:%s bonus:%d", pc.Code, pc.BonusAmount)) + } + } +} +``` + +**注意:** 此处需根据实际代码结构调整。关键是在充值额度入账的位置之后插入优惠码加赠。不修改原有的充值额度计算逻辑,只做追加。 + +- [ ] **Step 3: 如果现有 order 无 promo_code_id 字段,需要传递** + +在 `model/topup.go` 的 TopUp 模型中加字段: + +```go +PromoCodeID int `json:"promo_code_id" gorm:"default:0"` +``` + +前端创建充值订单时传入 `promo_code_id`(从 validate 接口获得)。 + +- [ ] **Step 4: 编译验证并 Commit** + +--- + +## 模块 2:公告系统 + +### Task 2.1: 创建数据模型 + +**Files:** +- Create: `model/announcement.go` +- Create: `model/announcement_read.go` + +- [ ] **Step 1: 写 announcement.go** + +```go +package model + +import ( + "time" + "gorm.io/gorm" +) + +type Announcement struct { + Id uint `json:"id" gorm:"primaryKey;autoIncrement"` + Title string `json:"title" gorm:"type:varchar(255);not null"` + Content string `json:"content" gorm:"type:text;not null"` + ContentHtml string `json:"content_html" gorm:"type:text"` + Status string `json:"status" gorm:"type:varchar(16);not null;default:'draft'"` + NotifyMode string `json:"notify_mode" gorm:"type:varchar(16);not null;default:'silent'"` + JsonRules string `json:"json_rules" gorm:"type:text"` + StartsAt *time.Time `json:"starts_at"` + EndsAt *time.Time `json:"ends_at"` + CreatedBy int `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (Announcement) TableName() string { + return "announcements" +} + +func (a *Announcement) Insert() error { + return DB.Create(a).Error +} + +func (a *Announcement) Update() error { + return DB.Save(a).Error +} + +func GetAnnouncementByID(id int) (*Announcement, error) { + var a Announcement + err := DB.First(&a, id).Error + return &a, err +} + +func GetAnnouncements(page, pageSize int, status string) ([]Announcement, int64, error) { + var list []Announcement + var total int64 + q := DB.Model(&Announcement{}) + if status != "" { + q = q.Where("status = ?", status) + } + err := q.Count(&total).Error + if err != nil { + return nil, 0, err + } + err = q.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&list).Error + return list, total, err +} + +func GetActiveAnnouncements() ([]Announcement, error) { + now := time.Now() + var list []Announcement + err := DB.Where("status = ?", "active"). + Where("starts_at IS NULL OR starts_at <= ?", now). + Where("ends_at IS NULL OR ends_at >= ?", now). + Order("id DESC"). + Find(&list).Error + return list, err +} + +func GetUnreadAnnouncements(userId int) ([]Announcement, error) { + now := time.Now() + var list []Announcement + err := DB.Where("status = ?", "active"). + Where("starts_at IS NULL OR starts_at <= ?", now). + Where("ends_at IS NULL OR ends_at >= ?", now). + Where("id NOT IN (SELECT announcement_id FROM announcement_reads WHERE user_id = ?)", userId). + Order("id DESC"). + Find(&list).Error + return list, err +} + +func DeleteAnnouncementByID(id int) error { + return DB.Delete(&Announcement{}, id).Error +} +``` + +- [ ] **Step 2: 写 announcement_read.go** + +```go +package model + +import "time" + +type AnnouncementRead struct { + Id uint `json:"id" gorm:"primaryKey;autoIncrement"` + AnnouncementId int `json:"announcement_id" gorm:"not null;index:idx_ann_user,unique"` + UserId int `json:"user_id" gorm:"not null;index:idx_ann_user,unique"` + ReadAt time.Time `json:"read_at" gorm:"not null"` +} + +func (AnnouncementRead) TableName() string { + return "announcement_reads" +} + +func (r *AnnouncementRead) Insert() error { + return DB.Create(r).Error +} + +func IsAnnouncementRead(announcementId, userId int) (bool, error) { + var count int64 + err := DB.Model(&AnnouncementRead{}).Where("announcement_id = ? AND user_id = ?", announcementId, userId).Count(&count).Error + return count > 0, err +} + +type AnnouncementReadStatus struct { + UserId int `json:"user_id"` + Username string `json:"username"` + ReadAt *time.Time `json:"read_at"` + IsRead bool `json:"is_read"` +} +``` + +- [ ] **Step 3: 注册 AutoMigrate 并编译验证** + +在 `model/init.go` 的 AutoMigrate 列表加入 `Announcement{}` 和 `AnnouncementRead{}`。 + +Run: `cd /Users/qingse/Sync/local-git/new-api && go build ./...` + +- [ ] **Step 4: Commit** + +```bash +git add model/announcement.go model/announcement_read.go model/init.go +git commit -m "feat: add announcement data models" +``` + +### Task 2.2: 管理端 API + +**Files:** +- Create: `controller/announcement.go` + +- [ ] **Step 1: 写管理端 handler** + +```go +package controller + +import ( + "net/http" + "strconv" + "time" + "github.com/gin-gonic/gin" + "new-api/model" +) + +func GetAnnouncements(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + status := c.Query("status") + + list, total, err := model.GetAnnouncements(page, pageSize, status) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": list, "total": total}) +} + +type CreateAnnouncementReq struct { + Title string `json:"title" binding:"required"` + Content string `json:"content" binding:"required"` + NotifyMode string `json:"notify_mode"` + JsonRules string `json:"json_rules"` + StartsAt string `json:"starts_at"` + EndsAt string `json:"ends_at"` +} + +func CreateAnnouncement(c *gin.Context) { + var req CreateAnnouncementReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.NotifyMode == "" { + req.NotifyMode = "silent" + } + + a := &model.Announcement{ + Title: req.Title, + Content: req.Content, + ContentHtml: markdownToHTML(req.Content), + Status: "draft", + NotifyMode: req.NotifyMode, + JsonRules: req.JsonRules, + CreatedBy: c.GetInt("id"), + } + + a.StartsAt = parseOptionalTime(req.StartsAt) + a.EndsAt = parseOptionalTime(req.EndsAt) + + if err := a.Insert(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": a}) +} + +func GetAnnouncement(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + a, err := model.GetAnnouncementByID(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": a}) +} + +type UpdateAnnouncementReq struct { + Title *string `json:"title"` + Content *string `json:"content"` + Status *string `json:"status"` + NotifyMode *string `json:"notify_mode"` + JsonRules *string `json:"json_rules"` + StartsAt string `json:"starts_at"` + EndsAt string `json:"ends_at"` +} + +func UpdateAnnouncement(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + a, err := model.GetAnnouncementByID(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + + var req UpdateAnnouncementReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.Title != nil { + a.Title = *req.Title + } + if req.Content != nil { + a.Content = *req.Content + a.ContentHtml = markdownToHTML(*req.Content) + } + if req.Status != nil { + a.Status = *req.Status + } + if req.NotifyMode != nil { + a.NotifyMode = *req.NotifyMode + } + if req.JsonRules != nil { + a.JsonRules = *req.JsonRules + } + if req.StartsAt != "" { + a.StartsAt = parseOptionalTime(req.StartsAt) + } + if req.EndsAt != "" { + a.EndsAt = parseOptionalTime(req.EndsAt) + } + + if err := a.Update(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": a}) +} + +func DeleteAnnouncement(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + if err := model.DeleteAnnouncementByID(id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "deleted"}) +} + +// 辅助函数 +func parseOptionalTime(s string) *time.Time { + if s == "" { + return nil + } + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return nil + } + return &t +} +``` + +- [ ] **Step 2: 在 router/admin.go 注册路由** + +```go +// 公告管理 +annGroup := admin.Group("/announcements") +{ + annGroup.GET("", controller.GetAnnouncements) + annGroup.POST("", controller.CreateAnnouncement) + annGroup.GET("/:id", controller.GetAnnouncement) + annGroup.PUT("/:id", controller.UpdateAnnouncement) + annGroup.DELETE("/:id", controller.DeleteAnnouncement) +} +``` + +- [ ] **Step 3: 编译验证并 Commit** + +### Task 2.3: 用户端 API + +**Files:** +- Create: `controller/announcement_user.go` + +- [ ] **Step 1: 写用户端 handler** + +```go +package controller + +import ( + "net/http" + "strconv" + "time" + "github.com/gin-gonic/gin" + "new-api/model" +) + +func GetUserAnnouncements(c *gin.Context) { + userId := c.GetInt("id") + unread := c.Query("unread") + + var list []model.Announcement + var err error + if unread == "true" { + list, err = model.GetUnreadAnnouncements(userId) + } else { + list, err = model.GetActiveAnnouncements() + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // 附上已读状态 + type AnnouncementWithRead struct { + model.Announcement + IsRead bool `json:"is_read"` + } + result := make([]AnnouncementWithRead, len(list)) + for i, a := range list { + read, _ := model.IsAnnouncementRead(int(a.Id), userId) + result[i] = AnnouncementWithRead{Announcement: a, IsRead: read} + } + + c.JSON(http.StatusOK, gin.H{"data": result}) +} + +func MarkAnnouncementRead(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + userId := c.GetInt("id") + + read, _ := model.IsAnnouncementRead(id, userId) + if read { + c.JSON(http.StatusOK, gin.H{"message": "already read"}) + return + } + + r := &model.AnnouncementRead{ + AnnouncementId: id, + UserId: userId, + ReadAt: time.Now(), + } + if err := r.Insert(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "marked as read"}) +} +``` + +- [ ] **Step 2: 在 router/api.go 注册路由** + +```go +authGroup.GET("/announcements", controller.GetUserAnnouncements) +authGroup.POST("/announcements/:id/read", controller.MarkAnnouncementRead) +``` + +- [ ] **Step 3: 修改 /api/status 公告来源** + +在 `controller/misc.go` 的 `/api/status` handler 中,将公告数据来源从 `ConsoleSetting.Announcements` JSON 切换到 `model.GetActiveAnnouncements()`: + +```go +// 旧:读 JSON 字符串 +// announcements := setting.ConsoleSetting.Announcements + +// 新:从表读 +announcements, _ := model.GetActiveAnnouncements() +``` + +- [ ] **Step 4: 编译验证并 Commit** + +--- + +## 模块 3:渠道监控 + +### Task 3.1: 创建数据模型 + +**Files:** +- Create: `model/channel_monitor.go` +- Create: `model/channel_monitor_history.go` +- Create: `model/channel_monitor_daily_rollup.go` + +- [ ] **Step 1: 写 channel_monitor.go** + +```go +package model + +import "time" + +type ChannelMonitor struct { + Id uint `json:"id" gorm:"primaryKey;autoIncrement"` + Name string `json:"name" gorm:"type:varchar(128);not null"` + ChannelId int `json:"channel_id" gorm:"index;default:0"` + TargetUrl string `json:"target_url" gorm:"type:varchar(512);not null"` + PrimaryModel string `json:"primary_model" gorm:"type:varchar(64);not null"` + ExtraModels string `json:"extra_models" gorm:"type:text"` + CheckInterval int `json:"check_interval" gorm:"not null;default:300"` + Timeout int `json:"timeout" gorm:"not null;default:30"` + RequestTemplates string `json:"request_templates" gorm:"type:text"` + CustomHeaders string `json:"custom_headers" gorm:"type:text"` + CustomBody string `json:"custom_body" gorm:"type:text"` + Status int `json:"status" gorm:"not null;default:1"` + LastCheckedAt *time.Time `json:"last_checked_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (ChannelMonitor) TableName() string { + return "channel_monitors" +} + +func (m *ChannelMonitor) Insert() error { + return DB.Create(m).Error +} + +func (m *ChannelMonitor) Update() error { + return DB.Save(m).Error +} + +func GetChannelMonitorByID(id int) (*ChannelMonitor, error) { + var m ChannelMonitor + err := DB.First(&m, id).Error + return &m, err +} + +func GetEnabledChannelMonitors() ([]ChannelMonitor, error) { + var list []ChannelMonitor + err := DB.Where("status = ?", 1).Find(&list).Error + return list, err +} + +func GetChannelMonitors(page, pageSize int) ([]ChannelMonitor, int64, error) { + var list []ChannelMonitor + var total int64 + err := DB.Model(&ChannelMonitor{}).Count(&total).Error + if err != nil { + return nil, 0, err + } + err = DB.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&list).Error + return list, total, err +} + +func DeleteChannelMonitorByID(id int) error { + return DB.Delete(&ChannelMonitor{}, id).Error +} +``` + +- [ ] **Step 2: 写 channel_monitor_history.go** + +```go +package model + +import "time" + +type ChannelMonitorHistory struct { + Id uint `json:"id" gorm:"primaryKey;autoIncrement"` + MonitorId int `json:"monitor_id" gorm:"index;not null"` + ChannelId int `json:"channel_id" gorm:"index"` + ModelName string `json:"model_name" gorm:"type:varchar(64);not null"` + Status string `json:"status" gorm:"type:varchar(16);not null"` + StatusCode int `json:"status_code"` + LatencyMs int `json:"latency_ms"` + ErrorMessage string `json:"error_message" gorm:"type:text"` + RequestBody string `json:"request_body" gorm:"type:text"` + ResponseSnippet string `json:"response_snippet" gorm:"type:text"` + CheckedAt time.Time `json:"checked_at" gorm:"index;not null"` +} + +func (ChannelMonitorHistory) TableName() string { + return "channel_monitor_history" +} + +func (h *ChannelMonitorHistory) Insert() error { + return DB.Create(h).Error +} + +func GetMonitorHistory(monitorId, page, pageSize int, modelName, status string, startTime, endTime *time.Time) ([]ChannelMonitorHistory, int64, error) { + var list []ChannelMonitorHistory + var total int64 + q := DB.Model(&ChannelMonitorHistory{}).Where("monitor_id = ?", monitorId) + if modelName != "" { + q = q.Where("model_name = ?", modelName) + } + if status != "" { + q = q.Where("status = ?", status) + } + if startTime != nil { + q = q.Where("checked_at >= ?", startTime) + } + if endTime != nil { + q = q.Where("checked_at <= ?", endTime) + } + err := q.Count(&total).Error + if err != nil { + return nil, 0, err + } + err = q.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&list).Error + return list, total, err +} +``` + +- [ ] **Step 3: 写 channel_monitor_daily_rollup.go** + +```go +package model + +import "time" + +type ChannelMonitorDailyRollup struct { + Id uint `json:"id" gorm:"primaryKey;autoIncrement"` + MonitorId int `json:"monitor_id" gorm:"index;not null"` + ChannelId int `json:"channel_id"` + ModelName string `json:"model_name" gorm:"type:varchar(64);not null"` + Date string `json:"date" gorm:"type:varchar(10);not null"` + TotalChecks int `json:"total_checks" gorm:"not null;default:0"` + PassCount int `json:"pass_count" gorm:"not null;default:0"` + DegradedCount int `json:"degraded_count" gorm:"not null;default:0"` + FailedCount int `json:"failed_count" gorm:"not null;default:0"` + AvgLatencyMs int `json:"avg_latency_ms"` +} + +func (ChannelMonitorDailyRollup) TableName() string { + return "channel_monitor_daily_rollup" +} + +func GetMonitorDailyRollups(monitorId int, modelName string, startDate, endDate string) ([]ChannelMonitorDailyRollup, error) { + var list []ChannelMonitorDailyRollup + q := DB.Where("monitor_id = ?", monitorId) + if modelName != "" { + q = q.Where("model_name = ?", modelName) + } + if startDate != "" { + q = q.Where("date >= ?", startDate) + } + if endDate != "" { + q = q.Where("date <= ?", endDate) + } + err := q.Order("date DESC").Find(&list).Error + return list, err +} +``` + +- [ ] **Step 4: 注册 AutoMigrate 并编译验证、Commit** + +### Task 3.2: 内置请求模板 + SSRF 校验 + +**Files:** +- Create: `service/channel_monitor_ssrf.go` + +- [ ] **Step 1: 写在 checker 文件中的内置模板常量** + +在 `service/channel_monitor_checker.go` 中定义模板列表: + +```go +package service + +var DefaultRequestTemplates = []string{ + "Explain what a binary tree is in one sentence.", + "Write a simple Python function to check if a number is prime.", + "What is 15 percent of 200? Return only the number.", + "Translate 'Hello, how are you?' into French.", + "What is the capital of Japan? Answer in one word.", + "Explain the difference between HTTP and HTTPS in one sentence.", + "Write a one-line bash command to count lines in a file.", + "What does CPU stand for? Answer in one word.", + "Convert 100 kilometers to miles. Return only the number.", + "Explain what an API is in one sentence.", + "Write a simple SQL query to select all users from a table named 'users'.", + "What is the chemical symbol for water? Answer in one word.", + "Name three primary colors.", + "What is 2 to the power of 10? Return only the number.", + "Explain what recursion is in one sentence.", + "Write a one-line command to list all files in a directory on Linux.", + "What year did World War II end? Return only the year.", + "Explain what DNS does in one sentence.", + "What is the square root of 144? Return only the number.", + "Convert 'Good morning' into Spanish.", +} +``` + +- [ ] **Step 2: 写 SSRF 校验** + +```go +package service + +import ( + "fmt" + "net" + "net/url" + "strings" +) + +func ValidateURL(target string) error { + u, err := url.Parse(target) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("only http and https allowed") + } + + host := u.Hostname() + if host == "" { + return fmt.Errorf("empty host") + } + + ip := net.ParseIP(host) + if ip == nil { + ips, err := net.LookupIP(host) + if err != nil { + return fmt.Errorf("DNS lookup failed: %w", err) + } + if len(ips) == 0 { + return fmt.Errorf("no IP resolved for host") + } + ip = ips[0] + } + + if isPrivateIP(ip) { + return fmt.Errorf("private/internal IP not allowed: %s", ip.String()) + } + + return nil +} + +func isPrivateIP(ip net.IP) bool { + if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsPrivate() { + return true + } + // additional checks for common internal ranges + if ip4 := ip.To4(); ip4 != nil { + if ip4[0] == 0 || ip4[0] == 127 || ip4[0] == 10 { + return true + } + if ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31 { + return true + } + if ip4[0] == 192 && ip4[1] == 168 { + return true + } + if ip4[0] == 169 && ip4[1] == 254 { + return true + } + } + return false +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add service/channel_monitor_ssrf.go service/channel_monitor_checker.go +git commit -m "feat: add channel monitor templates and SSRF validation" +``` + +### Task 3.3: Runner + Checker + +**Files:** +- Create: `service/channel_monitor_runner.go` +- Create: `service/channel_monitor_checker.go`(补充 checker 逻辑) + +- [ ] **Step 1: 完善 checker 逻辑(在已创建的 channel_monitor_checker.go 中追加)** + +```go +package service + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "time" + "new-api/model" +) + +func RunMonitorCheck(monitor *model.ChannelMonitor) { + // 随机选模板 + templates := getTemplatesForMonitor(monitor) + tmplIdx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(templates)))) + template := templates[tmplIdx.Int64()] + + // 所有要测的模型 + models := []string{monitor.PrimaryModel} + if monitor.ExtraModels != "" { + var extra []string + json.Unmarshal([]byte(monitor.ExtraModels), &extra) + models = append(models, extra...) + } + + for _, modelName := range models { + checkModel(monitor, modelName, template) + } + + now := time.Now() + monitor.LastCheckedAt = &now + monitor.Update() +} + +func checkModel(monitor *model.ChannelMonitor, modelName, template string) { + start := time.Now() + record := &model.ChannelMonitorHistory{ + MonitorId: int(monitor.Id), + ChannelId: monitor.ChannelId, + ModelName: modelName, + CheckedAt: start, + RequestBody: template, + } + + // SSRF check + if err := ValidateURL(monitor.TargetUrl); err != nil { + record.Status = "error" + record.ErrorMessage = err.Error() + record.Insert() + return + } + + // 构造请求体 + reqBody := buildRequestBody(monitor, modelName, template) + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(monitor.Timeout)*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "POST", monitor.TargetUrl, bytes.NewReader([]byte(reqBody))) + if err != nil { + record.Status = "error" + record.ErrorMessage = err.Error() + record.Insert() + return + } + + req.Header.Set("Content-Type", "application/json") + if monitor.CustomHeaders != "" { + var headers map[string]string + json.Unmarshal([]byte(monitor.CustomHeaders), &headers) + for k, v := range headers { + req.Header.Set(k, v) + } + } + + resp, err := http.DefaultClient.Do(req) + latency := time.Since(start).Milliseconds() + record.LatencyMs = int(latency) + + if err != nil { + record.Status = "error" + record.ErrorMessage = err.Error() + record.Insert() + return + } + defer resp.Body.Close() + + record.StatusCode = resp.StatusCode + body, _ := io.ReadAll(io.LimitReader(resp.Body, 500)) + record.ResponseSnippet = string(body) + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + if int(latency) > monitor.Timeout*1000 { + record.Status = "degraded" + } else { + record.Status = "pass" + } + } else if resp.StatusCode >= 500 { + record.Status = "failed" + } else { + record.Status = "error" + record.ErrorMessage = fmt.Sprintf("unexpected status %d", resp.StatusCode) + } + + record.Insert() +} + +func buildRequestBody(monitor *model.ChannelMonitor, modelName, message string) string { + if monitor.CustomBody != "" { + return monitor.CustomBody + } + + body := map[string]interface{}{ + "model": modelName, + "messages": []map[string]string{ + {"role": "user", "content": message}, + }, + "max_tokens": 5, + } + b, _ := json.Marshal(body) + return string(b) +} + +func getTemplatesForMonitor(monitor *model.ChannelMonitor) []string { + if monitor.RequestTemplates != "" { + var custom []string + if err := json.Unmarshal([]byte(monitor.RequestTemplates), &custom); err == nil && len(custom) > 0 { + return custom + } + } + return DefaultRequestTemplates +} +``` + +- [ ] **Step 2: 写 runner** + +```go +package service + +import ( + "log" + "sync" + "time" + "new-api/model" +) + +var ( + monitorRunnerOnce sync.Once + monitorStopCh chan struct{} +) + +func StartChannelMonitorRunner() { + monitorRunnerOnce.Do(func() { + monitorStopCh = make(chan struct{}) + go runMonitorLoop() + }) +} + +func StopChannelMonitorRunner() { + if monitorStopCh != nil { + close(monitorStopCh) + } +} + +func runMonitorLoop() { + ticker := time.NewTicker(60 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + monitors, err := model.GetEnabledChannelMonitors() + if err != nil { + log.Printf("channel monitor runner: get monitors error: %v", err) + continue + } + now := time.Now() + for _, m := range monitors { + if m.LastCheckedAt == nil || m.LastCheckedAt.Add(time.Duration(m.CheckInterval)*time.Second).Before(now) { + mCopy := m + go RunMonitorCheck(&mCopy) + } + } + + case <-monitorStopCh: + return + } + } +} +``` + +- [ ] **Step 3: 在 main/server 启动 runner** + +找到 `main.go` 或 `server` 启动位置,在初始化后加入: + +```go +service.StartChannelMonitorRunner() +``` + +- [ ] **Step 4: 编译验证并 Commit** + +### Task 3.4: 管理端 API + +**Files:** +- Create: `controller/channel_monitor.go` + +- [ ] **Step 1: 写 handler(同模式,略写详细代码,参考前面 pattern)** + +```go +package controller + +// GetChannelMonitors, CreateChannelMonitor, GetChannelMonitor, +// UpdateChannelMonitor, DeleteChannelMonitor, RunChannelMonitorCheck, +// GetChannelMonitorHistory, GetChannelMonitorRollup + +// 关键:POST /:id/run 调用 service.RunMonitorCheck(&monitor) +// GET /:id/history 查询 model.GetMonitorHistory +// GET /:id/rollup 查询 model.GetMonitorDailyRollups +``` + +完整实现略(和前面 pattern 一致),核心差异化在 RunMonitorCheck 和 history/rollup 查询。 + +- [ ] **Step 2: 在 router/admin.go 注册路由** + +```go +monGroup := admin.Group("/channel-monitors") +{ + monGroup.GET("", controller.GetChannelMonitors) + monGroup.POST("", controller.CreateChannelMonitor) + monGroup.GET("/:id", controller.GetChannelMonitor) + monGroup.PUT("/:id", controller.UpdateChannelMonitor) + monGroup.DELETE("/:id", controller.DeleteChannelMonitor) + monGroup.POST("/:id/run", controller.RunChannelMonitorCheck) + monGroup.GET("/:id/history", controller.GetChannelMonitorHistory) + monGroup.GET("/:id/rollup", controller.GetChannelMonitorRollup) +} +``` + +- [ ] **Step 3: 编译验证并 Commit** + +### Task 3.5: 日报汇总定时任务 + 历史数据清理 + +- [ ] **Step 1: 在 runner.go 中加入每日汇总和清理逻辑** + +在 `runMonitorLoop` 中加入: + +```go +// 每小时检查是否到了汇总时间 +if now.Minute() == 0 { + go aggregateDailyRollups(now.Add(-1 * time.Hour)) +} +// 每天凌晨清理 7 天前历史 +if now.Hour() == 3 && now.Minute() == 0 { + go cleanOldHistory() +} +``` + +`aggregateDailyRollups`:从 history 表按 monitor_id + model_name + date 聚合,写入 daily_rollup。 + +`cleanOldHistory`:`DELETE FROM channel_monitor_history WHERE checked_at < DATE_SUB(NOW(), INTERVAL 7 DAY)`。 + +- [ ] **Step 2: 编译验证并 Commit** + +--- + +## 模块 4:邀请返利增强 + +### Task 4.1: 数据模型增强 + +**Files:** +- Create: `model/rebate_record.go` +- Modify: `model/user.go` +- Modify: `model/option.go` + +- [ ] **Step 1: 在 user.go 中加字段** + +在 `User` struct 中加入: + +```go +AffRebateRatePercent int `json:"aff_rebate_rate_percent" gorm:"default:0"` +AffRebateFrozenDays int `json:"aff_rebate_frozen_days" gorm:"default:0"` +``` + +- [ ] **Step 2: 写 rebate_record.go** + +```go +package model + +import "time" + +type RebateRecord struct { + Id uint `json:"id" gorm:"primaryKey;autoIncrement"` + InviterId int `json:"inviter_id" gorm:"index;not null"` + InviteeId int `json:"invitee_id" gorm:"index;not null"` + OrderId int `json:"order_id"` + OrderType string `json:"order_type" gorm:"type:varchar(16);not null"` + OrderAmount int `json:"order_amount" gorm:"not null"` + RebateAmount int `json:"rebate_amount" gorm:"not null"` + RatePercent int `json:"rate_percent" gorm:"not null"` + Status string `json:"status" gorm:"type:varchar(16);not null;default:'frozen'"` + FrozenUntil *time.Time `json:"frozen_until"` + ReleasedAt *time.Time `json:"released_at"` + CreatedAt time.Time `json:"created_at"` +} + +func (RebateRecord) TableName() string { + return "rebate_records" +} + +func (r *RebateRecord) Insert() error { + return DB.Create(r).Error +} + +func GetRebateRecords(page, pageSize int, status string, userId int) ([]RebateRecord, int64, error) { + var list []RebateRecord + var total int64 + q := DB.Model(&RebateRecord{}) + if status != "" { + q = q.Where("status = ?", status) + } + if userId > 0 { + q = q.Where("inviter_id = ? OR invitee_id = ?", userId, userId) + } + err := q.Count(&total).Error + if err != nil { + return nil, 0, err + } + err = q.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&list).Error + return list, total, err +} + +func GetUserRebateOverview(userId int) (totalRebate, frozenRebate, releasedRebate int64, err error) { + err = DB.Model(&RebateRecord{}).Where("inviter_id = ?", userId). + Select("COALESCE(SUM(rebate_amount), 0)").Row().Scan(&totalRebate) + if err != nil { + return + } + DB.Model(&RebateRecord{}).Where("inviter_id = ? AND status = 'frozen'", userId). + Select("COALESCE(SUM(rebate_amount), 0)").Row().Scan(&frozenRebate) + DB.Model(&RebateRecord{}).Where("inviter_id = ? AND status = 'released'", userId). + Select("COALESCE(SUM(rebate_amount), 0)").Row().Scan(&releasedRebate) + return +} + +func GetFrozenRebatesToRelease() ([]RebateRecord, error) { + var list []RebateRecord + err := DB.Where("status = ? AND frozen_until IS NOT NULL AND frozen_until <= ?", "frozen", time.Now()).Find(&list).Error + return list, err +} + +func (r *RebateRecord) Release() error { + now := time.Now() + return DB.Model(r).Updates(map[string]interface{}{ + "status": "released", + "released_at": now, + }).Error +} +``` + +- [ ] **Step 3: 在 model/option.go 注册全局配置 key** + +```go +const ( + AffRebateRatePercentKey = "AffRebateRatePercent" + AffRebateFrozenDaysKey = "AffRebateFrozenDays" + AffRebateEnabledKey = "AffRebateEnabled" +) +``` + +- [ ] **Step 4: 注册 AutoMigrate 并编译验证、Commit** + +### Task 4.2: 返利服务 + +**Files:** +- Create: `service/rebate.go` + +- [ ] **Step 1: 写 rebate 逻辑** + +```go +package service + +import ( + "fmt" + "time" + "new-api/model" +) + +// 获取有效的返利比例 +func GetEffectiveRebateRate(inviter *model.User) int { + if inviter.AffRebateRatePercent > 0 { + return inviter.AffRebateRatePercent + } + rate, _ := model.GetOptionInt(model.AffRebateRatePercentKey) + if rate > 0 { + return rate + } + return 0 +} + +// 获取有效冻结期 +func GetEffectiveFrozenDays(inviter *model.User) int { + if inviter.AffRebateFrozenDays > 0 { + return inviter.AffRebateFrozenDays + } + days, _ := model.GetOptionInt(model.AffRebateFrozenDaysKey) + return days +} + +// 充值完成后处理返利 +func ProcessRebateAfterRecharge(invitee *model.User, orderId int, orderType string, orderAmount int) { + if invitee.InviterId <= 0 { + return + } + + enabled, _ := model.GetOptionBool(model.AffRebateEnabledKey) + if !enabled { + return + } + + inviter, err := model.GetUserById(invitee.InviterId, false) + if err != nil { + return + } + + rate := GetEffectiveRebateRate(inviter) + if rate <= 0 { + return + } + + rebateAmount := orderAmount * rate / 100 + frozenDays := GetEffectiveFrozenDays(inviter) + + record := &model.RebateRecord{ + InviterId: inviter.Id, + InviteeId: invitee.Id, + OrderId: orderId, + OrderType: orderType, + OrderAmount: orderAmount, + RebateAmount: rebateAmount, + RatePercent: rate, + } + + if frozenDays > 0 { + t := time.Now().Add(time.Duration(frozenDays) * 24 * time.Hour) + record.FrozenUntil = &t + record.Status = "frozen" + } else { + record.Status = "released" + now := time.Now() + record.ReleasedAt = &now + } + + if err := record.Insert(); err != nil { + return + } + + // 给邀请人加返利额度 + inviter.AffQuota += rebateAmount + inviter.AffHistoryQuota += rebateAmount + model.DB.Save(inviter) + + // 日志 + model.RecordLog(inviter.Id, model.LogTypeSystem, + fmt.Sprintf("返利: invitee %d 充值 %d, 返利 %d (比例 %d%%)", invitee.Id, orderAmount, rebateAmount, rate)) +} + +// 定时解冻 +func ReleaseExpiredRebates() error { + records, err := model.GetFrozenRebatesToRelease() + if err != nil { + return err + } + for _, r := range records { + r.Release() + } + return nil +} +``` + +- [ ] **Step 2: 在 runner 中加定期解冻(复用模块 3 的 runner 或独立 goroutine)** + +```go +func StartRebateUnfreezeRunner() { + go func() { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + for range ticker.C { + service.ReleaseExpiredRebates() + } + }() +} +``` + +- [ ] **Step 3: 在充值完成处调用** + +在 Recharge/CompleteTopUp 回调中(拿到 orderId 和 orderAmount 后): + +```go +go service.ProcessRebateAfterRecharge(&user, orderId, "topup", amount) +``` + +用 goroutine 避免阻塞充值主流程。 + +- [ ] **Step 4: 编译验证并 Commit** + +### Task 4.3: 管理端 API + +**Files:** +- Create: `controller/affiliate.go` + +类似前面 pattern,提供: + +``` +GET /api/admin/affiliates/overview +GET /api/admin/affiliates/invites +GET /api/admin/affiliates/rebates +GET /api/admin/affiliates/transfers +GET /api/admin/affiliates/users +GET /api/admin/affiliates/users/:user_id/overview +PUT /api/admin/affiliates/users/:user_id +POST /api/admin/affiliates/users/batch-rate +DELETE /api/admin/affiliates/users/:user_id +PUT /api/admin/affiliates/config +``` + +(完整 handler 代码略,和前面 pattern 一致) + +- [ ] **Step 1: 写 controller/affiliate.go** + +- [ ] **Step 2: 在 router/admin.go 注册路由** + +- [ ] **Step 3: 编译验证并 Commit** + +--- + +## 模块 5:账号并发控制 + +### Task 5.1: 模型字段增强 + +**Files:** +- Modify: `model/user.go` +- Modify: `model/channel.go` + +- [ ] **Step 1: 在 user.go 加字段** + +```go +ConcurrencyLimit int `json:"concurrency_limit" gorm:"default:0"` +RpmLimit int `json:"rpm_limit" gorm:"default:0"` +``` + +- [ ] **Step 2: 在 channel.go 加字段** + +```go +ConcurrencyLimit int `json:"concurrency_limit" gorm:"default:0"` +``` + +- [ ] **Step 3: 编译验证并 Commit** + +### Task 5.2: 并发服务 + +**Files:** +- Create: `service/concurrency.go` + +- [ ] **Step 1: 写 Redis 并发槽位管理** + +```go +package service + +import ( + "context" + "fmt" + "time" + "new-api/common" +) + +const ( + concurrencyKeyPrefix = "user:concurrency:" + channelConcurrencyKeyPrefix = "channel:concurrency:" + rpmKeyPrefix = "user:rpm:" + concurrencyTTL = 5 * time.Minute + rpmTTL = 2 * time.Minute +) + +// 获取用户并发槽位,返回 (ok, releaseFunc) +func AcquireUserConcurrency(userId, limit int) (bool, func()) { + if limit <= 0 { + return true, func() {} + } + + key := fmt.Sprintf("%s%d", concurrencyKeyPrefix, userId) + client := common.GetRedisClient() + if client == nil { + return true, func() {} + } + + ctx := context.Background() + current, err := client.Incr(ctx, key).Result() + if err != nil { + return true, func() {} + } + client.Expire(ctx, key, concurrencyTTL) + + if int(current) > limit { + client.Decr(ctx, key) + return false, nil + } + + release := func() { + client.Decr(context.Background(), key) + } + return true, release +} + +// 获取渠道并发槽位 +func AcquireChannelConcurrency(channelId, limit int) (bool, func()) { + if limit <= 0 { + return true, func() {} + } + + key := fmt.Sprintf("%s%d", channelConcurrencyKeyPrefix, channelId) + client := common.GetRedisClient() + if client == nil { + return true, func() {} + } + + ctx := context.Background() + current, err := client.Incr(ctx, key).Result() + if err != nil { + return true, func() {} + } + client.Expire(ctx, key, concurrencyTTL) + + if int(current) > limit { + client.Decr(ctx, key) + return false, nil + } + + release := func() { + client.Decr(context.Background(), key) + } + return true, release +} + +// 用户 RPM 检查 +func CheckUserRPM(userId, limit int) bool { + if limit <= 0 { + return true + } + + key := fmt.Sprintf("%s%d:%d", rpmKeyPrefix, userId, time.Now().Minute()) + client := common.GetRedisClient() + if client == nil { + return true + } + + ctx := context.Background() + current, err := client.Incr(ctx, key).Result() + if err != nil { + return true + } + client.Expire(ctx, key, rpmTTL) + + return int(current) <= limit +} +``` + +- [ ] **Step 2: 编译验证并 Commit** + +### Task 5.3: 中间件 + +**Files:** +- Create: `middleware/concurrency.go` + +- [ ] **Step 1: 写并发检查 middleware** + +```go +package middleware + +import ( + "net/http" + "github.com/gin-gonic/gin" + "new-api/model" + "new-api/service" +) + +func ConcurrencyCheck() gin.HandlerFunc { + return func(c *gin.Context) { + userId := c.GetInt("id") + if userId > 0 { + user, err := model.GetUserById(userId, false) + if err == nil { + // RPM 检查 + if user.RpmLimit > 0 { + if !service.CheckUserRPM(userId, user.RpmLimit) { + c.JSON(http.StatusTooManyRequests, gin.H{"error": "RPM limit exceeded"}) + c.Abort() + return + } + } + + // 并发检查 + if user.ConcurrencyLimit > 0 { + ok, release := service.AcquireUserConcurrency(userId, user.ConcurrencyLimit) + if !ok { + c.JSON(http.StatusTooManyRequests, gin.H{"error": "concurrency limit exceeded"}) + c.Abort() + return + } + defer release() + } + } + } + c.Next() + } +} +``` + +- [ ] **Step 2: 在 relay 路由注入 middleware** + +在 `router/api.go` 的 relay 路由组中加入: + +```go +relayGroup.Use(middleware.ConcurrencyCheck()) +``` + +- [ ] **Step 3: 渠道并发检查接入** + +在 channel 选择逻辑(`middleware/channel_distribution.go` 或 relay 层)选中 channel 后,调用: + +```go +ok, release := service.AcquireChannelConcurrency(channel.Id, channel.ConcurrencyLimit) +if !ok { + // 换下一个 channel 或返回 429 +} +defer release() +``` + +- [ ] **Step 4: 编译验证并 Commit** + +### Task 5.4: 管理端实时监控 API + +**Files:** +- Create: `controller/ops_concurrency.go` + +提供 `GET /api/admin/ops/concurrency` 返回各用户/渠道当前并发数和 RPM 用量的 Redis snapshot。 + +- [ ] **Step 1: 写 handler** + +- [ ] **Step 2: 注册路由** + +- [ ] **Step 3: 编译验证并 Commit** + +--- + +## 模块 6:国内支付直连 + +### Task 6.1: Provider 接口 + +**Files:** +- Create: `payment/types.go` + +- [ ] **Step 1: 写 Provider 接口** + +```go +package payment + +import ( + "context" + "time" +) + +type PaymentRequest struct { + OrderID string + Amount int // 美元分 + Currency string // USD / CNY + Description string + ReturnURL string + NotifyURL string + ClientIP string + Metadata map[string]string + TradeType string // NATIVE / H5 / JSAPI (wxpay) + OpenID string // JSAPI 必填 (wxpay) +} + +type PaymentResponse struct { + ProviderOrderID string + PaymentURL string // H5/WAP 跳转链接 + QRCode string // Native 扫码链接 + RawResponse string +} + +type OrderStatus struct { + ProviderOrderID string + Status string // pending / success / failed / closed + Amount int + Currency string + PaidAt *time.Time +} + +type Provider interface { + Name() string + CreatePayment(ctx context.Context, req *PaymentRequest) (*PaymentResponse, error) + QueryOrder(ctx context.Context, providerOrderID string) (*OrderStatus, error) + VerifyNotification(ctx context.Context, body []byte, headers map[string]string) (*OrderStatus, error) + Refund(ctx context.Context, providerOrderID string, amount int) error +} +``` + +- [ ] **Step 2: Commit** + +### Task 6.2: 加密工具 + +**Files:** +- Create: `payment/crypto.go` + +```go +package payment + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "fmt" + "io" +) + +var encryptionKey []byte // 从环境变量或 key 文件读取 + +func InitEncryptionKey(key string) { + encryptionKey = []byte(key) // 必须 32 byte (AES-256) +} + +func Encrypt(plaintext string) (string, error) { + block, err := aes.NewCipher(encryptionKey) + if err != nil { + return "", err + } + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, aesGCM.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + ciphertext := aesGCM.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +func Decrypt(encoded string) (string, error) { + data, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", err + } + block, err := aes.NewCipher(encryptionKey) + if err != nil { + return "", err + } + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonceSize := aesGCM.NonceSize() + if len(data) < nonceSize { + return "", fmt.Errorf("ciphertext too short") + } + nonce, ciphertext := data[:nonceSize], data[nonceSize:] + plaintext, err := aesGCM.Open(nil, nonce, ciphertext, nil) + return string(plaintext), err +} +``` + +### Task 6.3: 支付宝直连 + +**Files:** +- Create: `setting/payment_alipay.go` +- Create: `payment/provider/alipay.go` +- Create: `controller/payment_alipay.go` + +- [ ] **Step 1: 写支付宝配置** + +```go +package setting + +type AlipaySetting struct { + Enabled bool `json:"enabled"` + AppID string `json:"app_id"` + PrivateKey string `json:"private_key"` // 加密存储 + PublicKey string `json:"public_key"` // 支付宝公钥 + GatewayUrl string `json:"gateway_url"` // 默认 https://openapi.alipay.com/gateway.do + NotifyURL string `json:"notify_url"` + ReturnURL string `json:"return_url"` +} +``` + +- [ ] **Step 2: 写支付宝 Provider 实现** + +alipay.go 实现 Provider 接口,核心: +- `CreatePayment`: 构造支付宝统一下单参数 → RSA 签名 → 返回支付 URL +- `VerifyNotification`: 验签 callback 参数 +- 依赖 `github.com/go-pay/gopay` 或手写 HTTP + RSA 签名 + +(完整支付宝 APIv3 签名实现约 200 行,此处略,核心逻辑参考支付宝开放平台文档 + sub2api 的 `payment/provider/alipay.go`) + +- [ ] **Step 3: 写 controller** + +```go +package controller + +func CreateAlipayPayment(c *gin.Context) { + // 解析请求 → 创建 top_up 记录 → 调用 alipayProvider.CreatePayment → 返回支付URL +} + +func AlipayReturn(c *gin.Context) { + // GET 同步回调 → 验签 → 跳转前端结果页 +} + +func AlipayNotify(c *gin.Context) { + // POST 异步通知 → 验签 → 更新订单状态 → 充值入账 → 返回 "success" +} +``` + +- [ ] **Step 4: 编译验证并 Commit** + +### Task 6.4: 微信直连 + +**Files:** +- Create: `setting/payment_wxpay.go` +- Create: `payment/provider/wxpay.go` +- Create: `controller/payment_wxpay.go` + +同支付宝流程,微信特有: +- 签名用 APIv3 的 `WECHATPAY2-SHA256-RSA256` 模式 +- 回调体是 AES-GCM 加密 JSON,需要解密 +- Platform 证书管理(定时从微信拉取) + +(完整实现约 250 行,核心逻辑参考 sub2api 的 `payment/provider/wxpay.go`) + +- [ ] **Step 1-4: 写配置、Provider、Controller → 编译 → Commit** + +### Task 6.5: 注册路由 + 依赖 + +- [ ] **Step 1: 加路由** + +```go +// 支付宝 +api.POST("/payment/alipay/create", controller.CreateAlipayPayment) +api.GET("/payment/alipay/return", controller.AlipayReturn) +api.POST("/payment/webhook/alipay", controller.AlipayNotify) + +// 微信 +api.POST("/payment/wxpay/create", controller.CreateWxpayPayment) +api.POST("/payment/webhook/wxpay", controller.WxpayNotify) +``` + +- [ ] **Step 2: 加 go.mod 依赖** + +```bash +go get github.com/go-pay/gopay # 支付宝 SDK +go get github.com/wechatpay-apiv3/wechatpay-go # 微信支付 SDK +go mod tidy +``` + +- [ ] **Step 3: 在 model/topup.go 加 provider_payload 字段** + +```go +ProviderPayload string `json:"provider_payload" gorm:"type:text"` +``` + +- [ ] **Step 4: 编译验证并 Commit** + +--- + +## 总结 + +| 模块 | 任务数 | 新建文件 | 修改文件 | +|---|---|---|---| +| 优惠码 | 4 | 3 | 3 | +| 公告系统 | 3 | 3 | 3 | +| 渠道监控 | 5 | 5 | 2 | +| 邀请返利 | 3 | 3 | 3 | +| 账号并发 | 4 | 2 | 3 | +| 国内支付 | 5 | 7 | 3 | +| **合计** | **24** | **23** | **17** | + +实施时按模块顺序逐个推进,每个模块完成后可独立上线。 \ No newline at end of file diff --git a/docs/superpowers/specs/2026-05-20-运营能力补全-审阅版.md b/docs/superpowers/specs/2026-05-20-运营能力补全-审阅版.md new file mode 100644 index 00000000..616018c4 --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-运营能力补全-审阅版.md @@ -0,0 +1,846 @@ +# new-api 运营能力补全 — 6 模块设计方案 + +日期:2026-05-20 +状态:待审阅 + +--- + +## 概览 + +### 实施顺序 + +| # | 模块 | 优先级 | 复杂度 | 预计工时 | +|---|---|---|---|---| +| 1 | 优惠码 | P0 | 低 | 1-2d | +| 2 | 公告系统 | P1 | 中 | 2-3d | +| 3 | 渠道监控 | P1 | 中 | 2-3d | +| 4 | 邀请返利 | P2 | 中 | 2-3d | +| 5 | 账号并发 | P2 | 中 | 2-3d | +| 6 | 国内支付 | P0 | 大 | 3-5d | + +### 技术栈 + +- Go 1.25 + Gin + GORM +- 数据库:MySQL/PostgreSQL/SQLite 三兼容 +- 缓存:Redis +- 参考实现:sub2api (`/Users/qingse/Sync/local-git/sub2api/`) + +--- + +## 模块 1:优惠码(Promo Code) + +### 背景 + +new-api 现状:有兑换码(Redemption)系统支持额度兑换和订阅兑换,也有充值金额折扣阶梯(AmountDiscount),但无独立优惠码系统。运营需要「充值/订阅时输入优惠码获得额外赠送」的能力。 + +### 目标与非目标 + +**目标**: +- 管理员创建/管理优惠码(固定加赠额度) +- 用户充值或订阅时输入优惠码,校验通过后获得额外额度加赠 +- 每人每码仅可用一次,支持使用次数上限和过期时间 +- 可设最低充值/消费门槛 + +**非目标**: +- 不实现百分比折扣(本次只做固定加赠) +- 不实现满减阶梯 +- 不做优惠码和现有兑换码的合并 + +### 数据模型 + +#### promo_codes(新表) + +| 字段 | 类型 | 约束 | 说明 | +|---|---|---|---| +| id | uint | PK, AUTO_INCREMENT | | +| code | varchar(32) | UNIQUE, NOT NULL | 优惠码,大写 | +| bonus_amount | int | NOT NULL, DEFAULT 0 | 加赠额度(美元分) | +| max_uses | int | NOT NULL, DEFAULT 0 | 最大使用次数,0=不限制 | +| used_count | int | NOT NULL, DEFAULT 0 | 已使用次数 | +| status | tinyint | NOT NULL, DEFAULT 1 | 1=启用, 0=禁用 | +| min_recharge_amount | int | NOT NULL, DEFAULT 0 | 最低充值/消费门槛(美元分),0=无门槛 | +| expires_at | timestamp | NULL | 过期时间,NULL=永不过期 | +| notes | varchar(255) | DEFAULT '' | 内部备注 | +| created_at | timestamp | NOT NULL | | +| updated_at | timestamp | NOT NULL | | + +#### promo_code_usages(新表) + +| 字段 | 类型 | 约束 | 说明 | +|---|---|---|---| +| id | uint | PK, AUTO_INCREMENT | | +| promo_code_id | int | FK, NOT NULL | 关联 promo_codes | +| user_id | int | FK, NOT NULL | 关联 users | +| used_at | timestamp | NOT NULL | 使用时间 | + +唯一索引:`(promo_code_id, user_id)` + +### API + +#### 管理端 + +``` +GET /api/admin/promo-codes # 列表(分页,支持搜索、状态筛选) +POST /api/admin/promo-codes # 创建 +GET /api/admin/promo-codes/:id # 详情 +PUT /api/admin/promo-codes/:id # 编辑(已启用且有使用的码不可改 bonus_amount) +DELETE /api/admin/promo-codes/:id # 删除(软删除或仅禁用) +GET /api/admin/promo-codes/:id/usages # 使用记录分页 +``` + +#### 用户端 + +``` +POST /api/promo-codes/validate # 校验优惠码 +``` + +请求: +```json +{ + "code": "SUMMER2024", + "amount": 1000 // 计划充值/消费金额(美元分),用于校验最低门槛 +} +``` + +响应(有效): +```json +{ + "valid": true, + "promo_code_id": 1, + "bonus_amount": 200, + "code": "SUMMER2024" +} +``` + +响应(无效): +```json +{ + "valid": false, + "reason": "expired" // expired / disabled / max_uses_reached / already_used / below_min_recharge +} +``` + +### 业务流程 + +``` +充值页 → 用户输入码 → POST /validate → + 有效:展示"加赠 $2.00" → 用户确认支付 → + 支付成功回调 → Recharge() 中: + 1. 执行业务充值(加额度) + 2. 查询已校验的 promo_code + 3. 检查 (promo_code_id, user_id) 未使用 + 4. 增加 bonus_amount 额度 + 5. 记录 usage + 更新 used_count + 6. 记录日志 Log{Type: "topup", Content: "promo_code:SUMMER2024 bonus:200"} +``` + +### 和现有兑换码的关系 + +| | 兑换码 Redemption | 优惠码 PromoCode | +|---|---|---| +| 触发方式 | 独立兑换(`POST /api/redemption`) | 支付时附带 | +| 类型 | 额度/订阅 | 固定加赠 | +| 依赖 | 不依赖支付 | 依赖支付订单完成 | +| 表 | `redemptions` | `promo_codes` + `promo_code_usages` | + +互不影响,独立运作。 + +### 文件变更清单 + +| 文件 | 操作 | 说明 | +|---|---|---| +| `model/promo_code.go` | 新增 | GORM 模型 + 表创建 + CRUD 方法 | +| `model/promo_code_usage.go` | 新增 | GORM 模型 | +| `controller/promo_code.go` | 新增 | 管理端 handler | +| `controller/promo_code_user.go` | 新增 | 用户端 validate handler | +| `router/admin.go` | 修改 | 加路由 | +| `router/api.go` | 修改 | 加路由 | +| `service/topup.go` 或 `service/payment.go` | 修改 | Recharge 完成时处理优惠码加赠 | + +### 验证方式 + +1. 创建优惠码 → GET 列表可见 +2. 用户 A 使用优惠码充值 → 额度增加含 bonus → usages 有记录 +3. 用户 A 再次使用同一码 → /validate 返回 already_used +4. 达到 max_uses 后 → /validate 返回 max_uses_reached +5. 过期后 → /validate 返回 expired +6. 充值金额不满足门槛 → /validate 返回 below_min_recharge +7. MySQL / PostgreSQL / SQLite 三库建表通过 + +--- + +## 模块 2:公告系统 + +### 背景 + +new-api 现状:公告以 JSON 字符串存在 `ConsoleSetting.Announcements` 字段,通过 `/api/status` 下发。无已读追踪,无状态管理,无定时发布。运营需要更精细的公告管理能力。 + +### 目标与非目标 + +**目标**: +- 独立 `announcements` 表,支持 Markdown 内容 +- 状态机:draft → active → archived +- 已读追踪(`announcement_reads`) +- 定时发布(starts_at / ends_at) +- 弹窗/静默两种通知模式 +- json_rules JSON 字段,预留定向规则 + +**非目标**: +- 不做复杂的规则引擎(定向规则暂不走解析,存 JSON 备用) +- 不做站内通知中心 (in-app notification center) +- 不删除旧 JSON 存储方式(兼容期) + +### 数据模型 + +#### announcements(新表) + +| 字段 | 类型 | 约束 | 说明 | +|---|---|---|---| +| id | uint | PK | | +| title | varchar(255) | NOT NULL | 标题 | +| content | text | NOT NULL | 内容,Markdown | +| content_html | text | | 渲染后的 HTML,服务端预渲染 | +| status | varchar(16) | NOT NULL, DEFAULT 'draft' | draft / active / archived | +| notify_mode | varchar(16) | NOT NULL, DEFAULT 'silent' | silent / popup | +| json_rules | text | | JSON 字符串,定向展示规则(预留) | +| starts_at | timestamp | NULL | 开始展示时间 | +| ends_at | timestamp | NULL | 结束展示时间 | +| created_at | timestamp | NOT NULL | | +| updated_at | timestamp | NOT NULL | | +| created_by | int | | 创建人 user_id | + +#### announcement_reads(新表) + +| 字段 | 类型 | 约束 | 说明 | +|---|---|---|---| +| id | uint | PK | | +| announcement_id | int | FK, NOT NULL | | +| user_id | int | FK, NOT NULL | | +| read_at | timestamp | NOT NULL | 首次阅读时间 | + +唯一索引:`(announcement_id, user_id)` + +### API + +#### 管理端 + +``` +GET /api/admin/announcements # 列表(分页,状态筛选) +POST /api/admin/announcements # 创建 +GET /api/admin/announcements/:id # 详情 +PUT /api/admin/announcements/:id # 编辑 +DELETE /api/admin/announcements/:id # 删除 +GET /api/admin/announcements/:id/read-status # 已读/未读用户列表 +``` + +#### 用户端 + +``` +GET /api/announcements # 当前可见公告(status=active, 在时间范围内) +GET /api/announcements?unread=true # 仅未读 +POST /api/announcements/:id/read # 标记已读 +``` + +### 路由兼容 + +`/api/status` 中同步返回公告(从新表查 active 公告),保持现有前端兼容。用 `ConsoleSetting.AnnouncementsEnabled` 总开关。 + +### 文件变更清单 + +| 文件 | 操作 | 说明 | +|---|---|---| +| `model/announcement.go` | 新增 | GORM 模型 + 表创建 + CRUD | +| `model/announcement_read.go` | 新增 | GORM 模型 | +| `controller/announcement.go` | 新增 | 管理端 handler | +| `controller/announcement_user.go` | 新增 | 用户端 handler | +| `controller/misc.go` | 修改 | `/api/status` 公告来源切换到新表 | +| `router/admin.go` | 修改 | 加管理端路由 | +| `router/api.go` | 修改 | 加用户端路由 | + +### 验证方式 + +1. 创建公告(draft) → 用户端 GET 不可见 +2. 状态改为 active → 用户端可见 +3. 用户 A 标记已读 → announcement_reads 有记录 +4. 用户 A GET ?unread=true → 该公告不再出现 +5. starts_at 未来时间 → 未到时间不可见 +6. ends_at 过去 → 不可见 +7. 状态改为 archived → 不可见 +8. notify_mode=popup → 前端根据该字段弹出(前端配合) + +--- + +## 模块 3:渠道监控 + +### 背景 + +new-api 现状:`MonitorSetting` 有 auto-test-channel 和 auto-disable-channel 功能,但检查结果无独立存储,无历史查询,无请求模板,无日报汇总。运维需要可追溯的监控数据。 + +### 目标与非目标 + +**目标**: +- `channel_monitors` 表管理监控配置(多模型、间隔、超时、请求模板) +- `channel_monitor_history` 表记录每次检查结果 +- `channel_monitor_daily_rollup` 日报汇总(各模型可用率) +- 支持自定义请求模板列表(用户可配,内置 20 个兜底) +- admin CRUD + 手动触发检查 + 历史查询 +- SSRF 校验(禁止内网地址) + +**非目标**: +- 不替换现有 auto-test / auto-disable 机制(共存,后续可合并) +- 不做实时告警推送(只做数据采集和展示,告警后续加) +- 用户端只读(不需要用户创建 monitor) + +### 数据模型 + +#### channel_monitors(新表) + +| 字段 | 类型 | 约束 | 说明 | +|---|---|---|---| +| id | uint | PK | | +| name | varchar(128) | NOT NULL | 监控名称 | +| channel_id | int | FK | 关联 channels,0=不限 | +| target_url | varchar(512) | NOT NULL | 目标 URL | +| primary_model | varchar(64) | NOT NULL | 主测模型 | +| extra_models | text | | JSON 数组,额外测试模型 | +| check_interval | int | NOT NULL, DEFAULT 300 | 检查间隔(秒),15-3600 | +| timeout | int | NOT NULL, DEFAULT 30 | 超时(秒) | +| request_templates | text | | JSON 数组,自定义请求模板 | +| custom_headers | text | | JSON 对象,自定义请求头 | +| custom_body | text | | 自定义请求体(raw string) | +| status | tinyint | NOT NULL, DEFAULT 1 | 1=启用, 0=禁用 | +| created_at / updated_at | timestamp | | | + +#### channel_monitor_history(新表) + +| 字段 | 类型 | 约束 | 说明 | +|---|---|---|---| +| id | uint | PK | | +| monitor_id | int | FK, NOT NULL | | +| channel_id | int | FK | | +| model_name | varchar(64) | NOT NULL | 测试的模型 | +| status | varchar(16) | NOT NULL | pass / degraded / failed / error | +| status_code | int | | HTTP 状态码 | +| latency_ms | int | | 响应延迟(毫秒) | +| error_message | text | | 错误信息 | +| request_body | text | | 发送的请求内容 | +| response_snippet | text | | 响应片段(前 500 字符) | +| checked_at | timestamp | NOT NULL | | + +数据保留:原始记录保留 7 天(定时清理 job)。 + +#### channel_monitor_daily_rollup(新表) + +| 字段 | 类型 | 约束 | 说明 | +|---|---|---|---| +| id | uint | PK | | +| monitor_id | int | FK, NOT NULL | | +| channel_id | int | FK | | +| model_name | varchar(64) | NOT NULL | | +| date | date | NOT NULL | 汇总日期 | +| total_checks | int | NOT NULL, DEFAULT 0 | 检查总数 | +| pass_count | int | NOT NULL, DEFAULT 0 | 通过次数 | +| degraded_count | int | NOT NULL, DEFAULT 0 | 降级次数 | +| failed_count | int | NOT NULL, DEFAULT 0 | 失败次数 | +| avg_latency_ms | int | | 平均延迟 | + +唯一索引:`(monitor_id, channel_id, model_name, date)` + +### API + +#### 管理端 + +``` +GET /api/admin/channel-monitors # 列表 +POST /api/admin/channel-monitors # 创建 +GET /api/admin/channel-monitors/:id # 详情 +PUT /api/admin/channel-monitors/:id # 编辑 +DELETE /api/admin/channel-monitors/:id # 删除 +POST /api/admin/channel-monitors/:id/run # 手动触发一次检查 +GET /api/admin/channel-monitors/:id/history # 历史记录(按时间范围、模型筛选) +GET /api/admin/channel-monitors/:id/rollup # 日报(按日期范围) +``` + +#### 用户端 + +``` +GET /api/channel-monitors/:id/status # 公开状态页(可选) +``` + +### 内置请求模板 + +内置 20 个简短英文问题,覆盖不同主题(general、coding、math、translation、science),每次检查随机抽取 1 个。用户可以自定义模板列表覆盖默认。 + +示例(4/20): +``` +"Explain what a binary tree is in one sentence." +"Write a simple Python function to check if a number is prime." +"What is 15 percent of 200? Return only the number." +"Translate 'Hello, how are you?' into French." +``` + +### Runner 设计 + +后台 goroutine,每分钟扫描启用的 monitor,检查各 monitor 的 `last_checked_at + check_interval < now()` 则触发检查。检查流程: + +1. 随机选一个请求模板 +2. 构造 HTTP 请求 → 发送到 target_url +3. SSRF 校验(禁止内网 IP) +4. 记录 latency_ms、status_code +5. 判断状态:pass(200, latency < 阈值) / degraded(200, latency >= 阈值) / failed(非 200 或无有效响应) / error(连接失败) +6. 写入 history +7. 每小时汇总一次到 daily_rollup + +### 文件变更清单 + +| 文件 | 操作 | 说明 | +|---|---|---| +| `model/channel_monitor.go` | 新增 | monitor 模型 | +| `model/channel_monitor_history.go` | 新增 | history 模型 | +| `model/channel_monitor_daily_rollup.go` | 新增 | rollup 模型 | +| `service/channel_monitor_runner.go` | 新增 | 后台 runner | +| `service/channel_monitor_checker.go` | 新增 | 单次检查逻辑 | +| `service/channel_monitor_ssrf.go` | 新增 | SSRF 校验 | +| `controller/channel_monitor.go` | 新增 | 管理端 handler | +| `router/admin.go` | 修改 | 加路由 | + +### 验证方式 + +1. 创建 monitor → GET 列表可见 +2. POST /run → history 有新记录 → 状态正确 +3. 等一个间隔周期 → 自动触发检查 → history 有新记录 +4. 检查 daily_rollup 定时汇总正确 +5. SSRF:target_url 指向 127.0.0.1 → 检查失败且记录 error +6. 模型不可用 → 状态为 failed +7. 自定义模板列表生效 → 请求体使用自定义模板内容 + +--- + +## 模块 4:邀请返利增强 + +### 背景 + +new-api 现状有基础邀请系统:`AffCode`(邀请码)、`AffCount`(邀请数)、`AffQuota`(待提取返利额度)、`AffHistoryQuota`(累计返利)、`InviterId`(邀请人 ID)。返利逻辑在 `model/user.go` 的 `Insert()` 和 `inviteUser()` 中,使用全局 `QuotaForInviter`/`QuotaForInvitee` 常量。 + +**缺失**: +- 无独立返利记录表(只有 Log 表 Type=system 的日志) +- 无从用户级返利比例覆盖 +- 无冻结期机制 +- 无管理面板 + +### 目标与非目标 + +**目标**: +- `rebate_records` 表:记录每次返利发生 +- 全局返利比例 + per-user 返利比例覆盖 +- 冻结期机制:返利入账后冻结 N 天,解冻后才可提取 +- 管理面板:邀请概览、邀请记录、返利记录、转账记录、批量改比例 + +**非目标**: +- 不改变现有邀请注册逻辑(AffCode 生成、InviterId 绑定) +- 不做多级分销(只做一级) +- 不做返利自动转入余额(保持用户手动 TransferAffQuotaToQuota) + +### 数据模型 + +#### 现有字段增强(users 表) + +新增字段: + +| 字段 | 类型 | 说明 | +|---|---|---| +| aff_rebate_rate_percent | int | per-user 返利比例覆盖(百分比),0=使用全局默认 | +| aff_rebate_frozen_days | int | per-user 冻结期覆盖(天),0=使用全局默认 | + +#### rebate_records(新表) + +| 字段 | 类型 | 约束 | 说明 | +|---|---|---|---| +| id | uint | PK | | +| inviter_id | int | FK, NOT NULL | 邀请人 | +| invitee_id | int | FK, NOT NULL | 被邀请人 | +| order_id | int | FK | 关联 top_ups 或 subscription_orders | +| order_type | varchar(16) | NOT NULL | topup / subscription | +| order_amount | int | NOT NULL | 订单金额(美元分) | +| rebate_amount | int | NOT NULL | 返利金额(美元分) | +| rate_percent | int | NOT NULL | 使用的返利比例 | +| status | varchar(16) | NOT NULL, DEFAULT 'frozen' | frozen / released | +| frozen_until | timestamp | | 冻结到期时间 | +| released_at | timestamp | | 解冻时间 | +| created_at | timestamp | NOT NULL | | + +#### 全局配置(option 表) + +| Key | 说明 | +|---|---| +| AffRebateRatePercent | 全局默认返利比例(百分比) | +| AffRebateFrozenDays | 全局默认冻结天数 | +| AffRebateEnabled | 返利总开关 | + +### API + +#### 管理端 + +``` +GET /api/admin/affiliates/overview # 概览:总邀请数、返利总额、待解冻总额 +GET /api/admin/affiliates/invites # 邀请记录(分页,按时间/用户筛选) +GET /api/admin/affiliates/rebates # 返利记录(分页,按状态/时间/用户筛选) +GET /api/admin/affiliates/transfers # 转账/提取记录 +GET /api/admin/affiliates/users # 用户返利设置列表 +GET /api/admin/affiliates/users/:user_id/overview # 单用户概览 +PUT /api/admin/affiliates/users/:user_id # 更新单用户返利比例/冻结期 +POST /api/admin/affiliates/users/batch-rate # 批量更新返利比例 +DELETE /api/admin/affiliates/users/:user_id # 清除用户特殊设置(回退到全局默认) +PUT /api/admin/affiliates/config # 更新全局配置 +``` + +### 返利流程变更 + +现有 `inviteUser()` 逻辑增强: + +``` +inviteUser(inviterId, invitee): + 1. inviter.AffCount += 1 + 2. invitee.Quota += QuotaForInvitee // 不变 + 3. 不在注册时给返利,改为在 invitee 首次充值完成后触发 + +onRechargeComplete(inviteeUser): + 如果 inviteeUser.InviterId > 0 且是首次充值: + rate = inviteeUser.Inviter.AffRebateRatePercent > 0 + ? inviteeUser.Inviter.AffRebateRatePercent + : Option.AffRebateRatePercent + rebate = orderAmount * rate / 100 + frozenDays = inviteeUser.Inviter.AffRebateFrozenDays > 0 + ? inviteeUser.Inviter.AffRebateFrozenDays + : Option.AffRebateFrozenDays + + inviter.AffQuota += rebate // 入账到冻结额度 + inviter.AffHistoryQuota += rebate + + insert rebate_record { + status: frozenDays > 0 ? 'frozen' : 'released', + frozen_until: now + frozenDays + } + +定时任务(每小时):releaseExpiredRebates() + 扫描 status=frozen AND frozen_until <= now() + → status=released, released_at=now + → inviter.AffQuota 不变(已在入账时加过),用户可正常提取 +``` + +### 文件变更清单 + +| 文件 | 操作 | 说明 | +|---|---|---| +| `model/rebate_record.go` | 新增 | 返利记录模型 | +| `model/user.go` | 修改 | 加 aff_rebate_rate_percent / aff_rebate_frozen_days 字段 | +| `model/option.go` | 修改 | 加 AffRebateRatePercent / AffRebateFrozenDays / AffRebateEnabled | +| `service/rebate.go` | 新增 | 返利计算、冻结/解冻逻辑 | +| `controller/affiliate.go` | 新增 | 管理端 handler | +| `controller/topup_*.go` | 修改 | Recharge 完成时触发 rebate 检查 | +| `router/admin.go` | 修改 | 加路由 | + +### 验证方式 + +1. 用户 A 邀请用户 B → B 注册后 InviterId=A +2. B 首次充值 → rebate_records 有新记录 → 状态 frozen +3. A 的 AffQuota 增加对应金额 +4. 冻结到期 → 状态变 released +5. A 手动提取 → AffQuota 减少 + 额度增加 +6. 修改 A 的返利比例为 X% → B 下次充值按 X% 返利 +7. 批量更新接口修改 10 个用户返利比例 → 全部生效 +8. 关闭返利开关 → 充值不触发返利 + +--- + +## 模块 5:账号并发控制 + +### 背景 + +new-api 现状有:全局 API 限流、模型级限流、channel polling 锁。但没有 per-user 或 per-upstream-account 的并发控制。每个上游 API Key 有其并发上限,超过后要么排队要么拒绝。 + +### 目标与非目标 + +**目标**: +- per-user 并发限制(可配,默认不限制) +- per-upstream-account(channel key)并发槽位管理 +- Redis 原子计数实现 +- 超时等待队列(可选,不直接拒绝) +- 管理面板实时并发监控 +- admin 可调整单个用户的并发数 + +**非目标**: +- 不替换现有 rate-limit 体系(并发和速率是两个维度) +- 不做模型级并发隔离(先用 channel key 粒度) +- 不做跨节点排队(单节点等待队列即可) + +### 数据模型 + +#### users 表新增字段 + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| concurrency_limit | int | 0 | per-user 最大并发数,0=不限制 | +| rpm_limit | int | 0 | per-user 每分钟最大请求数,0=不限制 | + +#### channels 表新增字段 + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| concurrency_limit | int | 0 | 该 channel key 最大并发数,0=不限制 | + +### Redis 数据结构 + +``` +user:concurrency:{user_id} → int (当前并发数) +channel:concurrency:{channel_id} → int (当前并发数) +user:rpm:{user_id}:{minute} → int (当前分钟请求数) +``` + +### 流程 + +``` +relay 请求 → middleware: + 1. user_concurrency_limit > 0: + INCR user:concurrency:{user_id} + 若 > limit → DECR → 返回 429(或进入等待队列) + 2. rpm_limit > 0: + INCR user:rpm:{user_id}:{current_minute} + 若 > limit → 返回 429 + 3. 选择 channel → channel.concurrent_limit > 0: + INCR channel:concurrency:{channel_id} + 若 > limit → DECR → 换 channel 或 429 + 4. relay 完成(成功或失败)→ defer DECR + +等待队列(可选): + 若希望排队而非直接拒绝: + - 用 Redis BLPOP 等待,设置 timeout(如 30s) + - 获到槽位后执行 +``` + +### API + +#### 管理端 + +``` +GET /api/admin/ops/concurrency # 全局实时并发 snapshot +GET /api/admin/ops/concurrency/users # per-user 当前并发排行 +GET /api/admin/ops/concurrency/channels # per-channel 当前并发 +PUT /api/admin/users/:id/concurrency # 修改单用户并发/RPM 限制 +``` + +### 文件变更清单 + +| 文件 | 操作 | 说明 | +|---|---|---| +| `model/user.go` | 修改 | 加 concurrency_limit / rpm_limit | +| `model/channel.go` | 修改 | 加 concurrency_limit | +| `service/concurrency.go` | 新增 | Redis 并发槽位管理 | +| `middleware/concurrency.go` | 新增 | relay 前并发检查 | +| `controller/ops_concurrency.go` | 新增 | 管理端 handler | +| `router/relay.go` 或 `router/api.go` | 修改 | relay 路由注入 middleware | +| `router/admin.go` | 修改 | 加管理路由 | + +### 验证方式 + +1. 设用户 A concurrency_limit=2 → 同时发 3 个请求 → 第 3 个 429 +2. 设 channel concurrency_limit=5 → 同时发 6 个 → 第 6 个换 channel 或 429 +3. GET concurrency snapshot → 显示当前各用户/channel 并发数 +4. 请求完成后 → Redis 计数器自动 DECR +5. 设 rpm_limit=10 → 1 分钟内 11 个请求 → 第 11 个 429 +6. Redis 宕机 → 降级跳过并发检查(不阻塞) +7. concurrency_limit=0 → 不检查 + +--- + +## 模块 6:国内支付直连 + +### 背景 + +new-api 现有支付:Epay(易支付聚合)、Stripe、Creem、Waffo。都是国际渠道或聚合渠道,没有支付宝/微信直连。需要新增直连接入以降低支付费率、扩展国内用户群。 + +### 目标与非目标 + +**目标**: +- 统一 Provider 接口抽象 +- 支付宝直连(WAP/PC 支付) +- 微信直连(Native/H5/JSAPI) +- Webhook 回调验签 + 订单完成 +- 新渠道用 Provider 接口,老渠道不动 + +**非目标**: +- 不重构 Stripe/Creem/Waffo/Epay 到 Provider 接口 +- 不做 EasyPay 聚合(后续再加) +- 不做分账、退款(第一期只做支付) + +### 支付 Provider 接口设计 + +```go +// payment/types.go + +type PaymentRequest struct { + OrderID string + Amount int // 美元分 + Currency string // USD / CNY + Description string + ReturnURL string // 支付完成跳转 + NotifyURL string // webhook 地址 + ClientIP string + Metadata map[string]string + // WxPay specific + TradeType string // NATIVE / H5 / JSAPI + OpenID string // JSAPI 必填 +} + +type PaymentResponse struct { + ProviderOrderID string // 上游订单号 + PaymentURL string // 用户跳转支付 URL(H5/WAP) + QRCode string // 扫码支付 URL(Native) + RawResponse string // 上游原始响应 +} + +type OrderStatus struct { + ProviderOrderID string + Status string // pending / success / failed / closed + Amount int + Currency string + PaidAt *time.Time +} + +type Provider interface { + Name() string + CreatePayment(ctx context.Context, req *PaymentRequest) (*PaymentResponse, error) + QueryOrder(ctx context.Context, providerOrderID string) (*OrderStatus, error) + VerifyNotification(ctx context.Context, body []byte, headers map[string]string) (*OrderStatus, error) + // Refund 第一期空实现 + Refund(ctx context.Context, providerOrderID string, amount int) error +} +``` + +### 支付宝直连 + +配置项(`setting/payment_alipay.go`): +- AppID +- 应用私钥 (PKCS8) +- 支付宝公钥 +- 签约的产品码 (FAST_INSTANT_TRADE_PAY) +- 支付模式:WAP / PC + +流程: +``` +POST /api/payment/alipay/create + → 签名参数 + → 返回支付宝支付 URL + → 用户跳转 → 支付宝页面 → 支付完成 + → 支付宝 POST notify → VerifyNotification() 验签 + → 订单完成 → 加额度 +``` + +验签逻辑:支付宝 SDK 提供 RSA 签名验证。 + +### 微信直连 + +配置项(`setting/payment_wxpay.go`): +- AppID +- MchID (商户号) +- APIv3 Key +- 商户私钥 (PEM) +- 商户证书序列号 +- 支付模式:NATIVE / H5 / JSAPI + +流程: +``` +POST /api/payment/wxpay/create + → 调用微信统一下单 APIv3 + → NATIVE: 返回 code_url (二维码) + → H5: 返回 h5_url (跳转链接) + → 用户支付 → 微信 POST notify → VerifyNotification() 验签 + → 订单完成 → 加额度 +``` + +验签逻辑:微信 APIv3 使用平台证书公钥验证回调签名,回调体是加密 JSON,需先解密再验签。 + +### 数据库 + +复现现有 `top_ups` 表,字段已够用: +- `payment_method`: 新增值 `alipay_direct`, `wxpay_direct` +- `payment_provider`: 沿用 `trade_no` 存上游订单号 +- 可能需在 top_ups 加 `provider_payload` JSON 字段存上游原始响应(和 subscription_orders 对齐) + +### API + +新 Provider 风格的支付路由: + +``` +POST /api/payment/alipay/create # 创建支付宝订单 +GET /api/payment/alipay/return # 支付宝同步回调(跳转) +POST /api/payment/webhook/alipay # 支付宝异步通知 +POST /api/payment/webhook/wxpay # 微信异步通知 +POST /api/payment/wxpay/create # 创建微信订单 +``` + +### 文件变更清单 + +| 文件 | 操作 | 说明 | +|---|---|---| +| `payment/types.go` | 新增 | Provider 接口 + 请求/响应类型 | +| `payment/provider/alipay.go` | 新增 | 支付宝 Provider 实现 | +| `payment/provider/wxpay.go` | 新增 | 微信 Provider 实现 | +| `payment/crypto.go` | 新增 | 配置敏感字段 AES 加解密 | +| `setting/payment_alipay.go` | 新增 | 支付宝配置项 | +| `setting/payment_wxpay.go` | 新增 | 微信配置项 | +| `controller/payment_alipay.go` | 新增 | 支付宝 handler | +| `controller/payment_wxpay.go` | 新增 | 微信 handler | +| `model/option.go` | 修改 | 加配置 key 注册 | +| `model/topup.go` | 修改 | 加 provider_payload 字段 | +| `router/api.go` | 修改 | 加支付路由 | +| `go.mod` | 修改 | 加支付宝/微信 SDK 依赖 | + +### 验证方式 + +1. 配置支付宝沙箱 → 创建订单 → 收到支付 URL → 沙箱支付 → webhook 回调 → 额度增加 +2. 配置微信沙箱 → 创建 Native 订单 → 生成二维码 → 扫码支付 → webhook 回调 → 额度增加 +3. 错误私钥 → 创建订单失败 → 不 crash +4. 伪造 webhook → 验签失败 → 拒绝 +5. 重复 webhook → 幂等处理(top_ups 状态已 complete 则直接返回成功) +6. 金额不一致 → 不完成订单 + +--- + +## 附录 A:跨模块关注点 + +### 数据库兼容 + +所有新表使用 GORM AutoMigrate,不手写 SQL DDL,确保 MySQL/PostgreSQL/SQLite 三库兼容。 + +注意: +- JSON 字段:MySQL `json` / PostgreSQL `jsonb` / SQLite `text`,用 GORM `type:text` + `serializer:json` 统一 +- 唯一索引:迁移时检查存在再创建 +- 外键:SQLite 默认不启用,用 GORM tag 但不在代码层强制执行 + +### 路由冲突 + +6 个模块不共享路由前缀,不会冲突。admin 路由统一加在 `router/admin.go`。 + +### 前端 + +本次设计只覆盖后端 API。前端(React web/default/)需要对应页面,但不在此文档范围——API 设计已确保前端可直接对接。 + +### 测试策略 + +每个模块: +1. 单元测试:model CRUD + service 核心逻辑 +2. 集成测试:controller → service → model 全链路 +3. 手动验证:按各模块验证方式执行 + +--- + +## 附录 B:风险与兼容性 + +| 风险 | 影响 | 缓解 | +|---|---|---| +| 新表迁移失败 | 部分模块不可用 | 所有模块独立开关,回滚单模块不影响其他 | +| 返利逻辑改动影响现有充值 | 充值中断 | Recharge 中 rebate 逻辑用 defer recover 包住 | +| 支付 SDK 引入体积大 | 构建变大 | 仅模块 6 引入,不影响前 5 个模块 | +| 并发控制 Redis 故障 | 请求被拒 | Redis 不可用时跳过并发检查 | +| JSON 存储和独立表共存 | 公告/优惠码新旧数据不一致 | 模块 2 新表优先,旧 JSON 兼容读 | \ No newline at end of file