git-subtree-dir: server git-subtree-mainline:c4e617ada7git-subtree-split:e64421295f
45 KiB
商业化 P0 实现计划(后端)· slogan-agent
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 落地商业化四支柱中的「VIP 会员充值(虎皮椒聚合支付)」与「广告激励(效果图加次 / 体验会员)」,key 未配置时优雅降级。
Architecture: 沿用 Controller→Service→DAO 三层 + 包级单例。新增 payment/ 适配器包隔离虎皮椒签名/HTTP;dao/ 五张新表(slogan_ 前缀沿用仓库规范);效果图限额改造为「基础额度 3 + 广告额外次数」,VIP 用户不限。回调 /member/order/notify 因需返回裸文本 "success",用标准 handler 手动绑定并加入 publicPaths。
Tech Stack: Go 1.22 / GoFrame v2 / SQLite / crypto/md5 / GoFrame http client
关联 spec: docs/superpowers/specs/2026-07-31-commerce-monetization-design.md 支柱 A/B + 第 6 节配置。
测试方式(沿用仓库惯例): 纯逻辑用 go test(签名算法、续期计算、限频数学);DAO/接口用启动服务 + sqlite3 + curl 冒烟;支付全链路用本地 mock 虎皮椒服务(payment.api_base 可覆盖)。
任务总览与文件映射
| 任务 | 文件 |
|---|---|
| T1 | consts/table_name.go、consts/status.go、5 个 entity |
| T2 | dao/member_plan_dao.go(含 seed) |
| T3 | dao/payment_order_dao.go、dao/user_member_dao.go、dao/pay_notify_log_dao.go |
| T4 | payment/gateway.go + payment/gateway_test.go |
| T5 | service/member_service.go + service/member_service_test.go |
| T6 | model/dto/dto.go 追加、controller/member_controller.go、common/auth_middleware.go、main.go、config.yml |
| T7 | dao/ad_reward_log_dao.go、service/ad_service.go + 测试、controller/ad_controller.go |
| T8 | service/effect_image_service.go 限额改造 |
| T9 | 构建 + 全链路冒烟 |
Task 1: 表名常量、状态常量、5 个 Entity
Files:
-
Modify:
styleagent/consts/table_name.go -
Modify:
styleagent/consts/status.go -
Create:
styleagent/model/entity/member_plan.go -
Create:
styleagent/model/entity/payment_order.go -
Create:
styleagent/model/entity/user_member.go -
Create:
styleagent/model/entity/pay_notify_log.go -
Create:
styleagent/model/entity/ad_reward_log.go -
Step 1: 追加表名常量(
table_name.go末尾)
TableNameMemberPlan = "slogan_member_plan"
TableNamePaymentOrder = "slogan_payment_order"
TableNameUserMember = "slogan_user_member"
TableNamePayNotifyLog = "slogan_pay_notify_log"
TableNameAdRewardLog = "slogan_ad_reward_log"
- Step 2: 追加状态/类型常量(
status.go末尾)
// 支付订单状态
const (
PayStatusPending = "pending"
PayStatusPaid = "paid"
PayStatusClosed = "closed"
)
// 广告激励类型
const (
AdTypeEffectExtra = "effect_extra"
AdTypeVipTrial = "vip_trial"
)
// 会员开通来源
const (
MemberSourceVipPay = "vip_pay"
MemberSourceAdTrial = "ad_trial"
)
- Step 3: 创建 5 个 Entity(或m/json 标签,gtime 时间字段,与 spec SQL 列一一对应)
model/entity/member_plan.go:
package entity
import "github.com/gogf/gf/v2/os/gtime"
type MemberPlan struct {
Id int64 `orm:"id" json:"id"`
Name string `orm:"name" json:"name"`
PriceFen int `orm:"price_fen" json:"price_fen"`
DurationDays int `orm:"duration_days" json:"duration_days"`
Features string `orm:"features" json:"features"` // 权益 JSON 数组字符串
Sort int `orm:"sort" json:"sort"`
Status int `orm:"status" json:"status"`
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"`
}
model/entity/payment_order.go:
package entity
import "github.com/gogf/gf/v2/os/gtime"
type PaymentOrder struct {
Id int64 `orm:"id" json:"id"`
OrderNo string `orm:"order_no" json:"order_no"`
UserId int64 `orm:"user_id" json:"user_id"`
PlanId int64 `orm:"plan_id" json:"plan_id"`
AmountFen int `orm:"amount_fen" json:"amount_fen"`
Channel string `orm:"channel" json:"channel"`
Status string `orm:"status" json:"status"`
TradeNo string `orm:"trade_no" json:"trade_no"`
NotifyRaw string `orm:"notify_raw" json:"-"`
PaidAt *gtime.Time `orm:"paid_at" json:"paid_at"`
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"`
}
model/entity/user_member.go:
package entity
import "github.com/gogf/gf/v2/os/gtime"
type UserMember struct {
Id int64 `orm:"id" json:"id"`
UserId int64 `orm:"user_id" json:"user_id"`
PlanId int64 `orm:"plan_id" json:"plan_id"`
ExpireAt *gtime.Time `orm:"expire_at" json:"expire_at"`
Source string `orm:"source" json:"source"`
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"`
UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"`
}
model/entity/pay_notify_log.go:
package entity
import "github.com/gogf/gf/v2/os/gtime"
type PayNotifyLog struct {
Id int64 `orm:"id" json:"id"`
OrderNo string `orm:"order_no" json:"order_no"`
Body string `orm:"body" json:"body"`
Sign string `orm:"sign" json:"sign"`
RemoteIp string `orm:"remote_ip" json:"remote_ip"`
Status string `orm:"status" json:"status"`
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"`
}
model/entity/ad_reward_log.go:
package entity
import "github.com/gogf/gf/v2/os/gtime"
type AdRewardLog struct {
Id int64 `orm:"id" json:"id"`
UserId int64 `orm:"user_id" json:"user_id"`
AdType string `orm:"ad_type" json:"ad_type"`
RewardKey string `orm:"reward_key" json:"reward_key"`
Status string `orm:"status" json:"status"`
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"`
}
- Step 4: 编译验证
Run: go build ./... Expected: 无输出(成功)
- Step 5: 提交
git add styleagent/consts styleagent/model/entity
git commit -m "feat: 商业化 P0 表名/状态常量与实体定义"
Task 2: member_plan DAO(建表 + seed 套餐)
Files:
-
Create:
styleagent/dao/member_plan_dao.go -
Step 1: 创建 DAO(沿用 partner_store_dao 模式:init 建表 + seed,包级单例)
package dao
import (
"context"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
var MemberPlan = &memberPlanDao{}
type memberPlanDao struct{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameMemberPlan+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT '',
price_fen INTEGER NOT NULL DEFAULT 0,
duration_days INTEGER NOT NULL DEFAULT 30,
features TEXT NOT NULL DEFAULT '[]',
sort INTEGER NOT NULL DEFAULT 0,
status INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create member_plan table failed: %v", err)
}
seedMemberPlans(ctx)
}
func seedMemberPlans(ctx context.Context) {
r, err := g.DB().Model(consts.TableNameMemberPlan).Ctx(ctx).Count()
if err != nil || r > 0 {
return
}
plans := []struct {
name string
price int
days int
features string
sort int
}{
{"月卡 ¥29.9", 2990, 30, `["effect_unlimited","cps_commission_x15"]`, 1},
{"年卡 ¥199", 19900, 365, `["effect_unlimited","ai_priority","cps_commission_x15","store_discount"]`, 2},
}
for _, p := range plans {
_, _ = g.DB().Exec(ctx,
"INSERT INTO "+consts.TableNameMemberPlan+" (name, price_fen, duration_days, features, sort, status, created_at) VALUES (?, ?, ?, ?, ?, 1, datetime('now','localtime'))",
p.name, p.price, p.days, p.features, p.sort)
}
}
func (d *memberPlanDao) ListEnabled(ctx context.Context) ([]*entity.MemberPlan, error) {
var list []*entity.MemberPlan
err := g.DB().Model(consts.TableNameMemberPlan).Ctx(ctx).
Where("status", 1).OrderAsc("sort").OrderAsc("id").Scan(&list)
return list, err
}
func (d *memberPlanDao) GetOne(ctx context.Context, id int64) (*entity.MemberPlan, error) {
var p *entity.MemberPlan
err := g.DB().Model(consts.TableNameMemberPlan).Ctx(ctx).
Where("id", id).Where("status", 1).Scan(&p)
return p, err
}
- Step 2: 编译
Run: go build ./... Expected: 成功
- Step 3: 提交
git add styleagent/dao/member_plan_dao.go
git commit -m "feat: 会员套餐 DAO(建表 + 月卡/年卡 seed)"
Task 3: payment_order / user_member / pay_notify_log 三个 DAO
Files:
-
Create:
styleagent/dao/payment_order_dao.go -
Create:
styleagent/dao/user_member_dao.go -
Create:
styleagent/dao/pay_notify_log_dao.go -
Step 1: payment_order_dao
package dao
import (
"context"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
var PaymentOrder = &paymentOrderDao{}
type paymentOrderDao struct{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePaymentOrder+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_no TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL DEFAULT 0,
plan_id INTEGER NOT NULL DEFAULT 0,
amount_fen INTEGER NOT NULL DEFAULT 0,
channel TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
trade_no TEXT NOT NULL DEFAULT '',
notify_raw TEXT NOT NULL DEFAULT '',
paid_at DATETIME,
created_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create payment_order table failed: %v", err)
}
_, _ = g.DB().Exec(ctx, `CREATE INDEX IF NOT EXISTS idx_payment_order_user ON `+consts.TableNamePaymentOrder+`(user_id, created_at)`)
}
func (d *paymentOrderDao) Insert(ctx context.Context, order *entity.PaymentOrder) (int64, error) {
r, err := g.DB().Model(consts.TableNamePaymentOrder).Ctx(ctx).Data(g.Map{
"order_no": order.OrderNo, "user_id": order.UserId, "plan_id": order.PlanId,
"amount_fen": order.AmountFen, "channel": order.Channel, "status": order.Status,
}).Insert()
if err != nil {
return 0, err
}
return r.LastInsertId()
}
func (d *paymentOrderDao) GetByOrderNo(ctx context.Context, orderNo string) (*entity.PaymentOrder, error) {
var o *entity.PaymentOrder
err := g.DB().Model(consts.TableNamePaymentOrder).Ctx(ctx).
Where("order_no", orderNo).Scan(&o)
return o, err
}
// MarkPaid 状态机 pending→paid(只更新 pending 行,返回是否成功,回调并发安全)
func (d *paymentOrderDao) MarkPaid(ctx context.Context, orderNo, tradeNo, notifyRaw string) (bool, error) {
r, err := g.DB().Exec(ctx,
"UPDATE "+consts.TableNamePaymentOrder+" SET status=?, trade_no=?, notify_raw=?, paid_at=datetime('now','localtime') WHERE order_no=? AND status=?",
consts.PayStatusPaid, tradeNo, notifyRaw, orderNo, consts.PayStatusPending)
if err != nil {
return false, err
}
n, _ := r.RowsAffected()
return n > 0, nil
}
func (d *paymentOrderDao) GetByUser(ctx context.Context, userId int64) ([]*entity.PaymentOrder, error) {
var list []*entity.PaymentOrder
err := g.DB().Model(consts.TableNamePaymentOrder).Ctx(ctx).
Where("user_id", userId).OrderDesc("id").Limit(20).Scan(&list)
return list, err
}
- Step 2: user_member_dao(续期计算放 Service,DAO 只做读写;upsert 用 ON CONFLICT)
package dao
import (
"context"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
var UserMember = &userMemberDao{}
type userMemberDao struct{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameUserMember+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE,
plan_id INTEGER NOT NULL DEFAULT 0,
expire_at DATETIME,
source TEXT NOT NULL DEFAULT 'vip_pay',
created_at DATETIME DEFAULT (datetime('now','localtime')),
updated_at DATETIME
)`)
if err != nil {
g.Log().Warningf(ctx, "create user_member table failed: %v", err)
}
}
func (d *userMemberDao) GetByUser(ctx context.Context, userId int64) (*entity.UserMember, error) {
var m *entity.UserMember
err := g.DB().Model(consts.TableNameUserMember).Ctx(ctx).
Where("user_id", userId).Scan(&m)
return m, err
}
// Upsert 无则插入有则整体覆盖(expire_at 由 Service 算好传入)
func (d *userMemberDao) Upsert(ctx context.Context, userId, planId int64, expireAt, source string) error {
_, err := g.DB().Exec(ctx,
"INSERT INTO "+consts.TableNameUserMember+" (user_id, plan_id, expire_at, source, created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now','localtime'), datetime('now','localtime')) "+
"ON CONFLICT(user_id) DO UPDATE SET plan_id=excluded.plan_id, expire_at=excluded.expire_at, source=excluded.source, updated_at=datetime('now','localtime')",
userId, planId, expireAt, source)
return err
}
// IsVip 当前是否会员(未过期)
func (d *userMemberDao) IsVip(ctx context.Context, userId int64) bool {
n, err := g.DB().Model(consts.TableNameUserMember).Ctx(ctx).
Where("user_id", userId).Where("expire_at > datetime('now','localtime')").Count()
return err == nil && n > 0
}
- Step 3: pay_notify_log_dao
package dao
import (
"context"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
var PayNotifyLog = &payNotifyLogDao{}
type payNotifyLogDao struct{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePayNotifyLog+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_no TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
sign TEXT NOT NULL DEFAULT '',
remote_ip TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'ok',
created_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create pay_notify_log table failed: %v", err)
}
}
func (d *payNotifyLogDao) Insert(ctx context.Context, log *entity.PayNotifyLog) error {
_, err := g.DB().Model(consts.TableNamePayNotifyLog).Ctx(ctx).Data(g.Map{
"order_no": log.OrderNo, "body": log.Body, "sign": log.Sign,
"remote_ip": log.RemoteIp, "status": log.Status,
}).Insert()
return err
}
- Step 4: 编译 + 建表冒烟
Run: go build ./... && go run main.go & → 等服务启动后:
Run: sqlite3 slogan.db ".tables" | tr ' ' '\n' | grep -E "member|payment|notify"
Expected: 输出 slogan_member_plan slogan_payment_order slogan_user_member slogan_pay_notify_log slogan_ad_reward_log(ad_reward 表 T7 才建,此处 4 张)+ 杀掉后台进程
- Step 5: 提交
git add styleagent/dao
git commit -m "feat: 支付订单/会员/回调日志 DAO"
Task 4: payment 适配器(虎皮棋签名算法,TDD)
Files:
- Create:
styleagent/payment/gateway.go - Test:
styleagent/payment/gateway_test.go
签名算法(参数名升序拼接
key=value&...+ secret → md5)为虎皮棋经典签名约定;真实签名规则以官方最新文档为准,全部收敛在本包内。api_base可配置以便冒烟指向本地 mock。
- Step 1: 先写失败测试(
payment/gateway_test.go)
package payment
import "testing"
func TestSignDeterministic(t *testing.T) {
params := map[string]string{
"appid": "1000", "trade_order_id": "ORDER001", "total_fee": "29.90",
}
s1 := Sign(params, "secret123")
s2 := Sign(params, "secret123")
if s1 != s2 {
t.Fatalf("相同参数签名应一致: %s != %s", s1, s2)
}
if s1 == "" {
t.Fatal("签名不应为空")
}
}
func TestSignChangesWithSecret(t *testing.T) {
params := map[string]string{"appid": "1000", "trade_order_id": "ORDER001"}
if Sign(params, "a") == Sign(params, "b") {
t.Fatal("不同 secret 签名应不同")
}
}
func TestVerifyNotify(t *testing.T) {
params := map[string]string{
"appid": "1000", "trade_order_id": "ORDER001", "total_fee": "29.90",
"status": "OD", "hash": "",
}
hash := Sign(params, "secret123")
if !VerifyNotify(params, hash, "secret123") {
t.Fatal("正确签名应通过验签")
}
params["total_fee"] = "0.01"
if VerifyNotify(params, hash, "secret123") {
t.Fatal("篡改参数后应验签失败")
}
if VerifyNotify(params, hash, "wrong-secret") {
t.Fatal("错误 secret 应验签失败")
}
}
func TestGetConfigDisabledWhenEmpty(t *testing.T) {
cfg := GetConfig(t.Context())
if cfg.Enabled {
t.Fatal("默认配置(key 为空)应 disabled")
}
}
- Step 2: 运行确认失败
Run: go test ./styleagent/payment/... Expected: FAIL(Sign/VerifyNotify/GetConfig 未定义)
- Step 3: 实现 gateway.go
package payment
import (
"context"
"crypto/md5"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
)
// 虎皮棋聚合支付适配器:签名/HTTP 细节全部收敛在本包,业务层不感知。
// 注意:签名规则与字段名以官方最新文档为准(当前实现为经典 md5 约定)。
type Config struct {
AppId string
AppSecret string
NotifyUrl string
Channel string // 逗号分隔,如 "alipay,wechat"
ApiBase string
Enabled bool
}
func GetConfig(ctx context.Context) Config {
cfg := Config{
AppId: g.Cfg().MustGet(ctx, "payment.xunhu_appid", "").String(),
AppSecret: g.Cfg().MustGet(ctx, "payment.xunhu_appsecret", "").String(),
NotifyUrl: g.Cfg().MustGet(ctx, "payment.notify_url", "").String(),
Channel: g.Cfg().MustGet(ctx, "payment.channel", "alipay").String(),
ApiBase: g.Cfg().MustGet(ctx, "payment.api_base", "https://api.xunhupay.com").String(),
}
cfg.Enabled = cfg.AppId != "" && cfg.AppSecret != ""
return cfg
}
// CreateOrder 创建支付单,返回收银台/支付 URL(金额单位:分)
func CreateOrder(ctx context.Context, orderNo string, amountFen int) (payURL string, err error) {
cfg := GetConfig(ctx)
if !cfg.Enabled {
return "", errors.New("支付未开通,请在 config.yml 配置 payment")
}
channel := "alipay"
if first := strings.Split(cfg.Channel, ",")[0]; first != "" {
channel = first
}
params := map[string]string{
"appid": cfg.AppId,
"trade_order_id": orderNo,
"total_fee": fmt.Sprintf("%.2f", float64(amountFen)/100),
"title": "形象会员",
"notify_url": cfg.NotifyUrl,
"type": channel,
"version": "1.1",
"nonce_str": nonce(),
}
params["hash"] = Sign(params, cfg.AppSecret)
var resp struct {
Errcode int `json:"errcode"`
Errmsg string `json:"errmsg"`
Url string `json:"url"`
}
if _, err := g.Client().SetTimeout(10 * time.Second).Post(context.Background(), cfg.ApiBase+"/payment/do.html", params, &resp); err != nil {
return "", fmt.Errorf("虎皮棋下单失败: %w", err)
}
if resp.Errcode != 0 {
return "", fmt.Errorf("虎皮棋下单失败: %s", resp.Errmsg)
}
if resp.Url == "" {
return "", errors.New("虎皮棋下单失败: 返回为空")
}
return resp.Url, nil
}
// Sign 参数名升序拼接 key=value,追加 secret 后 md5 hex
func Sign(params map[string]string, secret string) string {
keys := make([]string, 0, len(params))
for k := range params {
if params[k] == "" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
var sb strings.Builder
for i, k := range keys {
if i > 0 {
sb.WriteString("&")
}
sb.WriteString(k)
sb.WriteString("=")
sb.WriteString(params[k])
}
sb.WriteString(secret)
sum := md5.Sum([]byte(sb.String()))
return hex.EncodeToString(sum[:])
}
// VerifyNotify 验签:复制参数去掉 hash 后重算签名比较
func VerifyNotify(params map[string]string, hash, secret string) bool {
if hash == "" || secret == "" {
return false
}
cp := make(map[string]string, len(params))
for k, v := range params {
if k != "hash" {
cp[k] = v
}
}
return Sign(cp, secret) == strings.ToLower(hash)
}
func nonce() string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
g.Client().SetTimeout(10 * time.Second)为 10 秒超时,需 import "time"。
- Step 4: 运行测试确认通过
Run: go test ./styleagent/payment/... -v Expected: PASS(4 个用例)
- Step 5: 提交
git add styleagent/payment
git commit -m "feat: 虎皮棋支付适配器(签名/下单/验签,未配置降级)"
Task 5: member service(套餐/状态/下单/回调处理,TDD 续期计算)
Files:
-
Create:
styleagent/service/member_service.go -
Test:
styleagent/service/member_service_test.go -
Step 1: 先写失败测试(续期纯函数)
package service
import (
"testing"
"time"
"github.com/gogf/gf/v2/os/gtime"
)
func TestNextExpireFromNow(t *testing.T) {
got := NextExpire(nil, 30)
want := time.Now().Add(30 * 24 * time.Hour).Format("2006-01-02 15:04:05")
gotT, _ := time.Parse("2006-01-02 15:04:05", got)
wantT, _ := time.Parse("2006-01-02 15:04:05", want)
if !gotT.Equal(wantT) {
t.Fatalf("过期会员应从现在起算: got=%s want~%s", got, want)
}
}
func TestNextExpireStackOnFuture(t *testing.T) {
base := gtime.NewFromTime(time.Now().Add(10 * 24 * time.Hour))
got := NextExpire(base, 30)
gotT, _ := time.Parse("2006-01-02 15:04:05", got)
if gotT.Before(base.Time) {
t.Fatalf("未过期会员应叠加: got=%s base=%s", got, base.Format("2006-01-02 15:04:05"))
}
}
- Step 2: 运行确认失败
Run: go test ./styleagent/service/... -run "TestNextExpire" Expected: FAIL(NextExpire 未定义)
- Step 3: 实现 member_service.go
package service
import (
"context"
"errors"
"fmt"
"time"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/dao"
"slogan-agent/styleagent/model/entity"
"slogan-agent/styleagent/payment"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
type memberService struct{}
var MemberService = new(memberService)
func (s *memberService) PlanList(ctx context.Context) ([]*entity.MemberPlan, error) {
return dao.MemberPlan.ListEnabled(ctx)
}
type MemberStatus struct {
IsVip bool `json:"is_vip"`
ExpireAt string `json:"expire_at"`
PlanName string `json:"plan_name"`
Benefits []string `json:"benefits"`
}
func (s *memberService) Status(ctx context.Context, userId int64) (*MemberStatus, error) {
st := &MemberStatus{Benefits: make([]string, 0)}
um, err := dao.UserMember.GetByUser(ctx, userId)
if err != nil {
return nil, err
}
if um == nil || um.ExpireAt == nil || um.ExpireAt.Time.Before(time.Now()) {
return st, nil
}
st.IsVip = true
st.ExpireAt = um.ExpireAt.Format("2006-01-02 15:04:05")
if plan, _ := dao.MemberPlan.GetOne(ctx, um.PlanId); plan != nil {
st.PlanName = plan.Name
st.Benefits = parseBenefits(plan.Features)
}
return st, nil
}
// CreateOrder 下单:生成业务订单号 → 虎皮棋下单 → 返回支付 URL
func (s *memberService) CreateOrder(ctx context.Context, userId, planId int64) (*entity.PaymentOrder, string, error) {
plan, err := dao.MemberPlan.GetOne(ctx, planId)
if err != nil {
return nil, "", err
}
if plan == nil {
return nil, "", errors.New("套餐不存在")
}
order := &entity.PaymentOrder{
OrderNo: fmt.Sprintf("M%d%d", time.Now().UnixNano()/1e6, userId%1000),
UserId: userId,
PlanId: planId,
AmountFen: plan.PriceFen,
Channel: "alipay",
Status: consts.PayStatusPending,
}
if _, err := dao.PaymentOrder.Insert(ctx, order); err != nil {
return nil, "", err
}
payURL, err := payment.CreateOrder(ctx, order.OrderNo, plan.PriceFen)
if err != nil {
return nil, "", err
}
return order, payURL, nil
}
func (s *memberService) OrderStatus(ctx context.Context, orderNo string) (*entity.PaymentOrder, error) {
return dao.PaymentOrder.GetByOrderNo(ctx, orderNo)
}
// HandlePaidNotify 验签已在 handler 完成;状态机 pending→paid 幂等,成功开通/续期
func (s *memberService) HandlePaidNotify(ctx context.Context, orderNo, tradeNo, notifyRaw string) (string, error) {
order, err := dao.PaymentOrder.GetByOrderNo(ctx, orderNo)
if err != nil {
return "no_order", err
}
if order == nil {
return "no_order", nil
}
ok, err := dao.PaymentOrder.MarkPaid(ctx, orderNo, tradeNo, notifyRaw)
if err != nil {
return "no_order", err
}
if !ok {
return "duplicate", nil // 已是 paid 或已关闭
}
days := 30
if plan, _ := dao.MemberPlan.GetOne(ctx, order.PlanId); plan != nil {
days = plan.DurationDays
}
um, _ := dao.UserMember.GetByUser(ctx, order.UserId)
expireAt := NextExpire(um.ExpireAt, days)
if err := dao.UserMember.Upsert(ctx, order.UserId, order.PlanId, expireAt, consts.MemberSourceVipPay); err != nil {
return "no_order", err
}
g.Log().Infof(ctx, "会员开通成功 user=%d order=%s expire=%s", order.UserId, orderNo, expireAt)
return "ok", nil
}
// NextExpire 续期计算:未过期在原有效期上叠加,过期/无记录从现在起算
func NextExpire(old *gtime.Time, days int) string {
base := time.Now()
if old != nil && old.Time.After(base) {
base = old.Time
}
return base.Add(time.Duration(days) * 24 * time.Hour).Format("2006-01-02 15:04:05")
}
func parseBenefits(features string) []string {
var list []string
_ = json.Unmarshal([]byte(features), &list)
if list == nil {
list = make([]string, 0)
}
return list
}
需要 import
"encoding/json"。
- Step 4: 运行测试确认通过
Run: go test ./styleagent/service/... -run "TestNextExpire" -v Expected: PASS
- Step 5: 提交
git add styleagent/service/member_service.go styleagent/service/member_service_test.go
git commit -m "feat: 会员服务(套餐/状态/下单/回调幂等续期)"
Task 6: member controller + DTO + 回调路由 + 配置
Files:
-
Modify:
styleagent/model/dto/dto.go(追加) -
Create:
styleagent/controller/member_controller.go -
Modify:
common/auth_middleware.go(publicPaths) -
Modify:
main.go(注册控制器 + 回调裸文本路由) -
Modify:
config.yml(payment 段) -
Step 1: dto.go 追加会员 DTO
type MemberPlanListReq struct {
g.Meta `path:"/plan/list" method:"get" tags:"会员" summary:"会员套餐列表"`
}
type MemberPlanListRes struct {
List []*entity.MemberPlan `json:"list"`
}
type MemberStatusReq struct {
g.Meta `path:"/status" method:"get" tags:"会员" summary:"我的会员状态"`
}
type MemberStatusRes struct {
IsVip bool `json:"is_vip"`
ExpireAt string `json:"expire_at"`
PlanName string `json:"plan_name"`
Benefits []string `json:"benefits"`
}
type MemberOrderCreateReq struct {
g.Meta `path:"/order/create" method:"post" tags:"会员" summary:"创建支付订单"`
PlanId int64 `v:"required" json:"plan_id"`
}
type MemberOrderCreateRes struct {
OrderNo string `json:"order_no"`
PayUrl string `json:"pay_url"`
}
type MemberOrderStatusReq struct {
g.Meta `path:"/order/status" method:"get" tags:"会员" summary:"订单状态"`
OrderNo string `v:"required" json:"order_no"`
}
type MemberOrderStatusRes struct {
Status string `json:"status"`
TradeNo string `json:"trade_no"`
PaidAt string `json:"paid_at"`
}
- Step 2: member_controller.go(struct 名
member→ 路由前缀/member)
package controller
import (
"context"
commonHttp "slogan-agent/common"
"slogan-agent/styleagent/model/dto"
"slogan-agent/styleagent/service"
)
type member struct{}
var Member = new(member)
// PlanList 会员套餐列表
func (c *member) PlanList(ctx context.Context, req *dto.MemberPlanListReq) (res *dto.MemberPlanListRes, err error) {
list, err := service.MemberService.PlanList(ctx)
if err != nil {
return nil, err
}
return &dto.MemberPlanListRes{List: list}, nil
}
// Status 我的会员状态
func (c *member) Status(ctx context.Context, req *dto.MemberStatusReq) (res *dto.MemberStatusRes, err error) {
st, err := service.MemberService.Status(ctx, commonHttp.GetUserId(g.RequestFromCtx(ctx)))
if err != nil {
return nil, err
}
return &dto.MemberStatusRes{
IsVip: st.IsVip, ExpireAt: st.ExpireAt, PlanName: st.PlanName, Benefits: st.Benefits,
}, nil
}
// OrderCreate 下单 → 返回支付 URL
func (c *member) OrderCreate(ctx context.Context, req *dto.MemberOrderCreateReq) (res *dto.MemberOrderCreateRes, err error) {
order, payURL, err := service.MemberService.CreateOrder(ctx, commonHttp.GetUserId(g.RequestFromCtx(ctx)), req.PlanId)
if err != nil {
return nil, err
}
return &dto.MemberOrderCreateRes{OrderNo: order.OrderNo, PayUrl: payURL}, nil
}
// OrderStatus 订单状态(App 轮询)
func (c *member) OrderStatus(ctx context.Context, req *dto.MemberOrderStatusReq) (res *dto.MemberOrderStatusRes, err error) {
order, err := service.MemberService.OrderStatus(ctx, req.OrderNo)
if err != nil || order == nil {
return nil, errors.New("订单不存在")
}
paidAt := ""
if order.PaidAt != nil {
paidAt = order.PaidAt.Format("2006-01-02 15:04:05")
}
return &dto.MemberOrderStatusRes{Status: order.Status, TradeNo: order.TradeNo, PaidAt: paidAt}, nil
}
需要 import
"errors"与"github.com/gogf/gf/v2/frame/g"(g.RequestFromCtx)。
- Step 3: 回调裸文本 handler(member_controller.go 追加,不用 2 参签名)
// MemberNotify 虎皮棋支付回调:验签 → 幂等开通 → 返回裸文本 "success"
// 虎皮棋要求回调响应体为字面 "success",故不走统一 JSON 包装
func MemberNotify(r *ghttp.Request) {
ctx := r.Context()
body := r.GetRawString()
hash := r.Get("hash").String()
orderNo := r.Get("trade_order_id").String()
remoteIP := r.GetClientIp()
params := make(map[string]string)
for k, v := range r.GetRequestMap() {
params[k] = fmt.Sprint(v)
}
ok := payment.VerifyNotify(params, hash, g.Cfg().MustGet(ctx, "payment.xunhu_appsecret", "").String())
if !ok {
_ = dao.PayNotifyLog.Insert(ctx, &entity.PayNotifyLog{
OrderNo: orderNo, Body: body, Sign: hash, RemoteIp: remoteIP, Status: "bad_sign",
})
r.Response.Write("fail")
r.ExitAll()
return
}
state, err := service.MemberService.HandlePaidNotify(ctx, orderNo, r.Get("transaction_id").String(), body)
_ = dao.PayNotifyLog.Insert(ctx, &entity.PayNotifyLog{
OrderNo: orderNo, Body: body, Sign: hash, RemoteIp: remoteIP, Status: state,
})
if err != nil || state != "ok" {
r.Response.Write("fail")
r.ExitAll()
return
}
r.Response.Write("success")
r.ExitAll()
}
import 追加:
"fmt"、"github.com/gogf/gf/v2/net/ghttp"、"slogan-agent/styleagent/dao"、"slogan-agent/styleagent/model/entity"、"slogan-agent/styleagent/payment"。
- Step 4: publicPaths 放行回调(
common/auth_middleware.go的 publicPaths 数组加一项)
"/member/order/notify",
- Step 5: main.go 注册(RouteRegister 数组加
controller.Member,并手动绑定回调路由)
commonHttp.RouteRegister([]interface{}{
controller.User,
controller.UserPhoto,
controller.Wardrobe,
controller.BodyMeasurement,
controller.Avatar,
controller.Hairstyle,
controller.Outfit,
controller.PartnerStore,
controller.Member,
})
// 虎皮棋支付回调(裸文本 "success",不走统一 JSON 包装)
commonHttp.Httpserver.Group("/member/order", func(group *ghttp.RouterGroup) {
group.POST("/notify", controller.MemberNotify)
})
- Step 6: config.yml 追加 payment 段
# 支付(虎皮棋聚合支付,key 为空则支付功能降级关闭)
payment:
xunhu_appid: ""
xunhu_appsecret: ""
notify_url: "http://localhost:3007/member/order/notify" # 生产需公网可达
channel: "alipay,wechat"
api_base: "https://api.xunhupay.com"
- Step 7: 编译 + 冒烟(降级路径)
Run: go build ./... Expected: 成功
Run: 启动服务 → curl -s http://127.0.0.1:3007/member/plan/list -H "Authorization: Bearer $TOKEN"
Expected: {"code":0,...,"data":{"list":[{"name":"月卡 ¥29.9","price_fen":2990,...},...]}}(2 个套餐)
Run: curl -s -X POST http://127.0.0.1:3007/member/order/create -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"plan_id":1}'
Expected: code=0 但 data 为 null?—— 不对:CreateOrder 返回 error → 统一响应 {"code":-1,"message":"支付未开通,请在 config.yml 配置 payment",...}(降级路径正确)
Run: curl -s http://127.0.0.1:3007/member/order/notify (无鉴权)
Expected: 返回 fail(key 为空验签失败),且 pay_notify_log 多一条 bad_sign 记录
Run: sqlite3 slogan.db "SELECT status FROM slogan_pay_notify_log ORDER BY id DESC LIMIT 1"
Expected: bad_sign
- Step 8: 提交
git add styleagent/model/dto/dto.go styleagent/controller/member_controller.go common/auth_middleware.go main.go config.yml
git commit -m "feat: 会员接口(套餐/状态/下单/回调)+ 支付配置"
Task 7: 广告激励(ad_reward DAO + service + controller,TDD 限频)
Files:
-
Create:
styleagent/dao/ad_reward_log_dao.go -
Create:
styleagent/service/ad_service.go -
Test:
styleagent/service/ad_service_test.go -
Create:
styleagent/controller/ad_controller.go -
Modify:
styleagent/model/dto/dto.go -
Modify:
main.go、config.yml -
Step 1: ad_reward_log DAO(唯一索引
user_id+reward_key防并发重复)
package dao
import (
"context"
"fmt"
"time"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
var AdRewardLog = &adRewardLogDao{}
type adRewardLogDao struct{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameAdRewardLog+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
ad_type TEXT NOT NULL DEFAULT '',
reward_key TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'ok',
created_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create ad_reward_log table failed: %v", err)
}
_, _ = g.DB().Exec(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_ad_reward_unique ON `+consts.TableNameAdRewardLog+`(user_id, reward_key)`)
}
// rewardKey 自然日去重粒度:"2026-07-31:effect_extra"
func rewardKey(adType string) string {
return fmt.Sprintf("%s:%s", time.Now().Format("2006-01-02"), adType)
}
func (d *adRewardLogDao) CountTodayByType(ctx context.Context, userId int64, adType string) (int, error) {
n, err := g.DB().Model(consts.TableNameAdRewardLog).Ctx(ctx).
Where("user_id", userId).Where("reward_key", rewardKey(adType)).Count()
return int(n), err
}
// Insert 领取记录(唯一索引冲突即返回错误 → 视为限频)
func (d *adRewardLogDao) Insert(ctx context.Context, userId int64, adType string) (int64, error) {
r, err := g.DB().Model(consts.TableNameAdRewardLog).Ctx(ctx).Data(g.Map{
"user_id": userId, "ad_type": adType, "reward_key": rewardKey(adType), "status": "ok",
}).Insert()
if err != nil {
return 0, err
}
return r.LastInsertId()
}
- Step 2: 先写限频数学失败测试(
service/ad_service_test.go)
package service
import "testing"
func TestRewardRemaining(t *testing.T) {
if got := rewardRemaining(2, 0); got != 2 {
t.Fatalf("未领取时剩余应为 2, got %d", got)
}
if got := rewardRemaining(2, 2); got != 0 {
t.Fatalf("已用满时剩余应为 0, got %d", got)
}
if got := rewardRemaining(1, 1); got != 0 {
t.Fatalf("vip_trial 已用完剩余应为 0, got %d", got)
}
}
func TestRewardQuota(t *testing.T) {
if got := rewardQuota(t.Context(), "effect_extra"); got != 2 {
t.Fatalf("默认每日 2 次, got %d", got)
}
}
- Step 3: 运行确认失败
Run: go test ./styleagent/service/... -run "TestReward" Expected: FAIL(函数未定义)
- Step 4: 实现 ad_service.go
package service
import (
"context"
"errors"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/dao"
"github.com/gogf/gf/v2/frame/g"
)
type adService struct{}
var AdService = new(adService)
type AdRewardResult struct {
AdType string `json:"ad_type"`
RemainingToday int `json:"remaining_today"`
}
// Claim 领取广告激励:服务端限频计数,不信任客户端
func (s *adService) Claim(ctx context.Context, userId int64, adType string) (*AdRewardResult, error) {
if adType != consts.AdTypeEffectExtra && adType != consts.AdTypeVipTrial {
return nil, errors.New("无效的广告类型")
}
limit := rewardQuota(ctx, adType)
used, err := dao.AdRewardLog.CountTodayByType(ctx, userId, adType)
if err != nil {
return nil, err
}
if used >= limit {
return nil, errors.New("今日次数已用完")
}
if _, err := dao.AdRewardLog.Insert(ctx, userId, adType); err != nil {
return nil, errors.New("今日次数已用完") // 唯一索引兜底并发
}
if adType == consts.AdTypeVipTrial {
_ = dao.UserMember.Upsert(ctx, userId, 0, NextExpire(nil, 1), consts.MemberSourceAdTrial)
}
return &AdRewardResult{AdType: adType, RemainingToday: limit - used - 1}, nil
}
func rewardQuota(ctx context.Context, adType string) int {
if adType == consts.AdTypeVipTrial {
return g.Cfg().MustGet(ctx, "ad.limit_vip_trial", 1).Int()
}
return g.Cfg().MustGet(ctx, "ad.limit_effect_extra", 2).Int()
}
func rewardRemaining(limit, used int) int {
if r := limit - used; r > 0 {
return r
}
return 0
}
- Step 5: DTO 追加(AdRewardInfo 定义在 dto 包内,controller 从 service 结果转换)
type AdRewardClaimReq struct {
g.Meta `path:"/reward/claim" method:"post" tags:"广告" summary:"领取广告激励"`
AdType string `v:"required|in:effect_extra,vip_trial" json:"ad_type"`
}
type AdRewardInfo struct {
AdType string `json:"ad_type"`
RemainingToday int `json:"remaining_today"`
}
type AdRewardClaimRes struct {
Reward *AdRewardInfo `json:"reward"`
}
- Step 6: ad_controller.go(struct 名
ad→ 前缀/ad)
package controller
import (
"context"
commonHttp "slogan-agent/common"
"slogan-agent/styleagent/model/dto"
"slogan-agent/styleagent/service"
"github.com/gogf/gf/v2/frame/g"
)
type ad struct{}
var Ad = new(ad)
// RewardClaim 领取广告激励(限频:effect_extra 每日 2 次 / vip_trial 每日 1 次)
func (c *ad) RewardClaim(ctx context.Context, req *dto.AdRewardClaimReq) (res *dto.AdRewardClaimRes, err error) {
result, err := service.AdService.Claim(ctx, commonHttp.GetUserId(g.RequestFromCtx(ctx)), req.AdType)
if err != nil {
return nil, err
}
return &dto.AdRewardClaimRes{Reward: &dto.AdRewardInfo{
AdType: result.AdType, RemainingToday: result.RemainingToday,
}}, nil
}
- Step 7: 注册 + 配置(main.go RouteRegister 加
controller.Ad;config.yml 加 ad 段)
# 广告激励限频(自然日)
ad:
limit_effect_extra: 2
limit_vip_trial: 1
- Step 8: 测试 + 编译 + 冒烟
Run: go test ./styleagent/service/... -run "TestReward" -v Expected: PASS
Run: go build ./... Expected: 成功
Run: 启动服务 → 依次 curl -X POST .../ad/reward/claim -d '{"ad_type":"effect_extra"}' 三次(带 TOKEN)
Expected: 前两次 {"code":0,...,"data":{"reward":{"ad_type":"effect_extra","remaining_today":1}}} / remaining_today:0,第三次 {"code":-1,"message":"今日次数已用完"}
Run: sqlite3 slogan.db "SELECT reward_key, COUNT(*) FROM slogan_ad_reward_log GROUP BY reward_key"
Expected: 当天 key 计数 2
- Step 9: 提交
git add styleagent/dao/ad_reward_log_dao.go styleagent/service/ad_service.go styleagent/service/ad_service_test.go styleagent/controller/ad_controller.go styleagent/model/dto/dto.go main.go config.yml
git commit -m "feat: 广告激励(效果图加次/体验会员,服务端限频防刷)"
Task 8: 效果图限额改造(VIP 不限 + 广告加次)
Files:
-
Modify:
styleagent/service/effect_image_service.go(run方法限额判定) -
Step 1: 修改限额判定(原 40-44 行)
// 每日限额:VIP 不限;普通用户 = 基础额度 + 广告激励额外次数
if !dao.UserMember.IsVip(ctx, userId) {
limit := dailyEffectLimit(ctx)
if limit > 0 {
used, _ := dao.PlanEffectImage.CountByUserToday(ctx, userId)
extra, _ := dao.AdRewardLog.CountTodayByType(ctx, userId, consts.AdTypeEffectExtra)
if used >= limit+extra {
g.Log().Warningf(ctx, "效果图任务: 用户 %d 当日次数已用尽(%d/%d)", userId, used, limit+extra)
return
}
}
}
- Step 2: 编译
Run: go build ./... Expected: 成功
- Step 3: 提交
git add styleagent/service/effect_image_service.go
git commit -m "feat: 效果图限额支持 VIP 不限与广告加次"
Task 9: 全链路冒烟(mock 虎皮棋 + 回调验签 + 会员开通)
Files: 无(临时文件 /tmp/mock_xunhu.py)
- Step 1: 写本地 mock 虎皮棋服务(
/tmp/mock_xunhu.py,签名算法与服务端一致)
import hashlib, json
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import parse_qs
SECRET = "test-secret"
APPID = "test-appid"
def sign(params: dict) -> str:
parts = []
for k in sorted(params):
if params[k] == "":
continue
parts.append(f"{k}={params[k]}")
return hashlib.md5(("&".join(parts) + SECRET).encode()).hexdigest()
class H(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length).decode()
p = {k: v[0] for k, v in parse_qs(body).items()}
if self.path == "/payment/do.html":
resp = {"errcode": 0, "errmsg": "ok", "url": "http://localhost:3998/pay?order=" + p["trade_order_id"]}
self.send_response(200); self.send_header("Content-Type", "application/json")
self.end_headers(); self.wfile.write(json.dumps(resp).encode())
else:
self.send_response(404); self.end_headers()
def do_GET(self):
self.send_response(200); self.end_headers()
self.wfile.write(b"<html>fake cashier page</html>")
HTTPServer(("127.0.0.1", 3998), H).serve_forever()
- Step 2: 临时配置指向 mock(
config.ymlpayment 段替换;先备份)
payment:
xunhu_appid: "test-appid"
xunhu_appsecret: "test-secret"
notify_url: "http://localhost:3007/member/order/notify"
channel: "alipay,wechat"
api_base: "http://127.0.0.1:3998"
- Step 3: 重启服务,执行全链路
cp config.yml /tmp/config.yml.bak
python3 /tmp/mock_xunhu.py & # mock 先起
go run main.go & # 后端(先改好 config.yml)
# 1) 下单
curl -s -X POST http://127.0.0.1:3007/member/order/create -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"plan_id":1}'
# → {"order_no":"M...","pay_url":"http://localhost:3998/pay?order=M..."}
# 2) 模拟回调(用 mock 的签名算法对参数签名,python 现场算)
ORDER=上面返回的order_no
PAYLOAD=$(python3 -c "
import sys
p = {'appid':'test-appid','trade_order_id':'$ORDER','total_fee':'29.90','status':'OD','transaction_id':'TX001','notify_url':'http://localhost:3007/member/order/notify'}
def sign(d):
parts=[]
for k in sorted(d):
if d[k]=='': continue
parts.append(f'{k}={d[k]}')
import hashlib
return hashlib.md5(('&'.join(parts)+'test-secret').encode()).hexdigest()
h = sign(p)
p['hash']=h
import urllib.parse
print(urllib.parse.urlencode(p))
")
curl -s -X POST http://127.0.0.1:3007/member/order/notify -d "$PAYLOAD"
# → success
# 3) 订单状态
curl -s "http://127.0.0.1:3007/member/order/status?order_no=$ORDER" -H "Authorization: Bearer $TOKEN"
# → {"status":"paid","trade_no":"TX001",...}
# 4) 会员状态
curl -s http://127.0.0.1:3007/member/status -H "Authorization: Bearer $TOKEN"
# → {"is_vip":true,"expire_at":"...","plan_name":"月卡 ¥29.9","benefits":["effect_unlimited","cps_commission_x15"]}
# 5) 幂等:重复回调
curl -s -X POST http://127.0.0.1:3007/member/order/notify -d "$PAYLOAD"
# → success(幂等);pay_notify_log 出现 duplicate 记录
sqlite3 slogan.db "SELECT status FROM slogan_pay_notify_log ORDER BY id DESC LIMIT 2"
# → duplicate / ok
- Step 4: 验证 VIP 效果图限额 + 广告加次
# vip 用户选定主方案 → 效果图生成不再受 3 次限制(日志无"当日次数已用尽")
# 非 vip:先领 2 次 effect_extra,再生成 → 当日额度 3+2=5(多生成 2 张验证计数生效)
- Step 5: 恢复配置并清理
cp /tmp/config.yml.bak config.yml
kill %1 %2 2>/dev/null
git diff --stat # 期望无 config.yml 变更
- Step 6: 提交最终状态
git status # 确认无残留
git log --oneline -5
自检清单
- 5 张表名、5 个 entity 与 spec SQL 列一致(表名前缀 slogan_ 为仓库规范差异,已注明)
/member/order/notify在 publicPaths + 手动裸文本绑定,验签失败记 bad_sign- 支付/广告 key 未配置 → 明确错误信息,不 panic
- 回调幂等:MarkPaid 状态机 + pay_notify_log 审计
- 效果图限额 = 基础 3 + 广告额外次数;VIP 跳过限额
go build、go test ./...、全链路冒烟全部通过
后续计划(P1,不在本计划内)
CPS 统一引擎(4 表 + Provider 抽象 + 美团联盟适配器 + 方案驱动推荐 + 6 接口)、客户端会员中心与 CPS 入口、P2 京东/淘宝适配器与广告 SDK。