Files
slogan/server/docs/superpowers/plans/2026-07-31-commerce-p0-backend.md
T
admin a6de9ebd12 Add 'server/' from commit 'e64421295fff83acbb6d6ab3d3b27f3ef8368f00'
git-subtree-dir: server
git-subtree-mainline: c4e617ada7
git-subtree-split: e64421295f
2026-08-04 15:02:35 +08:00

45 KiB
Raw Blame History

商业化 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/ 适配器包隔离虎皮椒签名/HTTPdao/ 五张新表(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.goconsts/status.go、5 个 entity
T2 dao/member_plan_dao.go(含 seed
T3 dao/payment_order_dao.godao/user_member_dao.godao/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.gocommon/auth_middleware.gomain.goconfig.yml
T7 dao/ad_reward_log_dao.goservice/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(续期计算放 ServiceDAO 只做读写;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_logad_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: FAILSign/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: PASS4 个用例)

  • 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: FAILNextExpire 未定义)

  • 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.gopublicPaths

  • Modify: main.go(注册控制器 + 回调裸文本路由)

  • Modify: config.ymlpayment 段)

  • 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.gostruct 名 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: 回调裸文本 handlermember_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: 返回 failkey 为空验签失败),且 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 + controllerTDD 限频)

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.goconfig.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.gostruct 名 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.Adconfig.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.gorun 方法限额判定)

  • 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: 临时配置指向 mockconfig.yml payment 段替换;先备份)
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 buildgo test ./...、全链路冒烟全部通过

后续计划(P1,不在本计划内)

CPS 统一引擎(4 表 + Provider 抽象 + 美团联盟适配器 + 方案驱动推荐 + 6 接口)、客户端会员中心与 CPS 入口、P2 京东/淘宝适配器与广告 SDK。