From 0a7cd5b96816dd080ef5179c01f7cb4d1c7d2edf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Fri, 31 Jul 2026 13:19:44 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E5=95=86=E4=B8=9A=E5=8C=96=20P0=20?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E8=AE=A1=E5=88=92=EF=BC=88=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E6=94=AF=E4=BB=98+=E5=B9=BF=E5=91=8A=E6=BF=80=E5=8A=B1?= =?UTF-8?q?=EF=BC=8C=E5=90=8E=E7=AB=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-07-31-commerce-p0-backend.md | 1485 +++++++++++++++++ 1 file changed, 1485 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-31-commerce-p0-backend.md diff --git a/docs/superpowers/plans/2026-07-31-commerce-p0-backend.md b/docs/superpowers/plans/2026-07-31-commerce-p0-backend.md new file mode 100644 index 0000000..9821f7c --- /dev/null +++ b/docs/superpowers/plans/2026-07-31-commerce-p0-backend.md @@ -0,0 +1,1485 @@ +# 商业化 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` 末尾) + +```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` 末尾) + +```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`: +```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`: +```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`: +```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`: +```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`: +```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: 提交** + +```bash +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,包级单例) + +```go +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: 提交** + +```bash +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** + +```go +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) + +```go +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** + +```go +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: 提交** + +```bash +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`) + +```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** + +```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: 提交** + +```bash +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: 先写失败测试**(续期纯函数) + +```go +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** + +```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: 提交** + +```bash +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** + +```go +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`) + +```go +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 参签名)** + +```go +// 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 数组加一项) + +```go + "/member/order/notify", +``` + +- [ ] **Step 5: main.go 注册**(RouteRegister 数组加 `controller.Member`,并手动绑定回调路由) + +```go + 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 段** + +```yaml +# 支付(虎皮棋聚合支付,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: 提交** + +```bash +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` 防并发重复) + +```go +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`) + +```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** + +```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 结果转换) + +```go +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`) + +```go +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 段) + +```yaml +# 广告激励限频(自然日) +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: 提交** + +```bash +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 行) + +```go + // 每日限额: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: 提交** + +```bash +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`,签名算法与服务端一致) + +```python +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"fake cashier page") + +HTTPServer(("127.0.0.1", 3998), H).serve_forever() +``` + +- [ ] **Step 2: 临时配置指向 mock**(`config.yml` payment 段替换;先备份) + +```yaml +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: 重启服务,执行全链路** + +```bash +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 效果图限额 + 广告加次** + +```bash +# vip 用户选定主方案 → 效果图生成不再受 3 次限制(日志无"当日次数已用尽") +# 非 vip:先领 2 次 effect_extra,再生成 → 当日额度 3+2=5(多生成 2 张验证计数生效) +``` + +- [ ] **Step 5: 恢复配置并清理** + +```bash +cp /tmp/config.yml.bak config.yml +kill %1 %2 2>/dev/null +git diff --stat # 期望无 config.yml 变更 +``` + +- [ ] **Step 6: 提交最终状态** + +```bash +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。