new-api/docs/superpowers/plans/2026-05-20-运营能力补全-执行版.md
zizi 059512f6ef docs: add operations capability implementation plan
Document the six-module operations roadmap and executable task breakdown for promo codes, announcements, channel monitoring, rebates, concurrency controls, and domestic payments.
2026-05-20 12:57:40 +08:00

56 KiB
Raw Blame History

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

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
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
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

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 中),加入:

// 优惠码管理
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
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

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 中注册路由
authGroup.POST("/promo-codes/validate", controller.ValidatePromoCode)
  • Step 3: 编译验证

Run: cd /Users/qingse/Sync/local-git/new-api && go build ./...

  • Step 4: Commit
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_*.goservice/topup.go(根据实际充值流程定位)

  • Step 1: 找到 Recharge 完成逻辑

先读取充值相关文件确定 Recharge 函数位置:

grep -rn "func.*Recharge\|func.*CompleteTopUp" --include="*.go" controller/ service/
  • Step 2: 在充值完成后加优惠码处理

在充值额度增加后、返回前插入:

// 优惠码加赠(如果订单附带了 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 模型中加字段:

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

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
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
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

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 注册路由
// 公告管理
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

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 注册路由
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()

// 旧:读 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

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
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
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 中定义模板列表:

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 校验
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
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 中追加)

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
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.goserver 启动位置,在初始化后加入:

service.StartChannelMonitorRunner()
  • Step 4: 编译验证并 Commit

Task 3.4: 管理端 API

Files:

  • Create: controller/channel_monitor.go

  • Step 1: 写 handler同模式略写详细代码参考前面 pattern

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 注册路由
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 中加入:

// 每小时检查是否到了汇总时间
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。

cleanOldHistoryDELETE 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 中加入:

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
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
const (
	AffRebateRatePercentKey = "AffRebateRatePercent"
	AffRebateFrozenDaysKey  = "AffRebateFrozenDays"
	AffRebateEnabledKey     = "AffRebateEnabled"
)
  • Step 4: 注册 AutoMigrate 并编译验证、Commit

Task 4.2: 返利服务

Files:

  • Create: service/rebate.go

  • Step 1: 写 rebate 逻辑

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
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 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 加字段

ConcurrencyLimit int `json:"concurrency_limit" gorm:"default:0"`
RpmLimit         int `json:"rpm_limit" gorm:"default:0"`
  • Step 2: 在 channel.go 加字段
ConcurrencyLimit int `json:"concurrency_limit" gorm:"default:0"`
  • Step 3: 编译验证并 Commit

Task 5.2: 并发服务

Files:

  • Create: service/concurrency.go

  • Step 1: 写 Redis 并发槽位管理

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

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 路由组中加入:

relayGroup.Use(middleware.ConcurrencyCheck())
  • Step 3: 渠道并发检查接入

在 channel 选择逻辑(middleware/channel_distribution.go 或 relay 层)选中 channel 后,调用:

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 接口

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
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: 写支付宝配置

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
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: 加路由
// 支付宝
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 依赖
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 字段
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

实施时按模块顺序逐个推进,每个模块完成后可独立上线。