Document the six-module operations roadmap and executable task breakdown for promo codes, announcements, channel monitoring, rebates, concurrency controls, and domestic payments.
56 KiB
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_*.go或service/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.go 或 server 启动位置,在初始化后加入:
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。
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 中加入:
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 签名 → 返回支付 URLVerifyNotification: 验签 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 |
实施时按模块顺序逐个推进,每个模块完成后可独立上线。