Files
jiu/docs/plans/2026-07-10-pay-v2-integration.md
T

32 KiB
Raw Blame History

jiu 授权续费对接 pay v2 改造计划

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: 把 jiu 已上线的 pay v1 对接(后端 PayService + 客户端 PurchaseCard)升级到 pay v2 契约(/api/v2、int64 最小单位金额、session render_type 多态、webhook event_type 事件模型),保持官网/旧客户端兼容,全程 httptest 模拟联调。

Architecture: 调研结论——jiu 两侧 v1 对接均已完整上线(后端 PayService 四职责:签名下单/webhook 入账/查单兜底/entitle 续期;前端 PurchaseCard 三态状态机 + 3s 轮询 + 30s 心跳兜底;官网 checkout.njk 走同一后端接口)。本任务是契约升级而非新建:改动收敛在 backend/internal/service/pay.gomodel/license_purchase.goclient/lib/{models,repositories,screens/settings} 与官网兼容层。签名算法(HMAC-SHA256 四段 base64)、幂等键(out_trade_no)、续期语义(max(现到期,now)+时长 的 entitle)、路由挂载、PAY_* 配置链全部沿用零改动

Tech Stack: Go/Gin/GORM + SQLite in-memory 测试(testutil.SetupTestDB + httptest.NewServer 模拟 pay);Flutter/Riverpod + qr_flutter(新增,纯 Dart,全平台安全)。

v2 契约要点(已读 design/pay-v2 分支代码核实,字段级)

v1(现实现) v2(本计划目标)
下单 POST /api/v1/orders {product_id, biz_system, biz_ref, return_url, client_type} POST /api/v2/orders {sku, method, biz_system, biz_ref, return_url}sku 直接用 biz_codeproductID() 查询/缓存整段删除);无 client_type
下单响应 {pay_url, out_trade_no, amount("2999.00"), subject} {order_no, session:{render_type, payload, expires_at?}}alipay 落 render_type="redirect"payload={"url":...}不回传金额
查单 GET /api/v1/orders/:otnstatus pending|paid|closed|refunded GET /api/v2/orders/:no(无鉴权),{order_no, status, subject, amount_minor, currency, paid_at?}status 八态 created|pending|paid|canceled|expired|refunding|partially_refunded|refunded不回传 biz_ref
webhook 扁平 payloadamount string 元 event_type(当前仅 payment.succeeded+ X-Pay-Event 头(不参与签名);amount_minor int64 + currency无 trade_no 字段;重投固定 60s、无退避无死信(P6 才加);成功判定=HTTP 200 且 body 前 4096 字节大小写不敏感含 "SUCCESS"(现回包 {"code":"SUCCESS"} 天然满足)
签名 base64(HMAC_SHA256(secret, "jiu\nts\nnonce\nrawBody")) ±5min 完全一致,util.PaySign/PaySignVerify 零改动pay 侧无 nonce 去重,jiu 侧自建(见 D3)
金额 string 元 amount_minor int64CNY=分)+ currency 大写码
retry/cancel POST /api/v2/orders/:no/retry409 currency_mismatch=需换方式新建单);POST /api/v2/orders/:no/cancel 恒 200 {"data":{"canceled":bool}}(仅 pending 单翻转成功)

pay 侧欠账(jiu 无法解决,列入联调前置依赖,不阻塞本计划):

  1. wap 端型透传缺口(手机拉起支付宝 App 断线的唯一根因)v2 下单无 client_type、gateway 不设 Metadata["is_mobile"] → alipay 恒走 page.pay。redirect 机制本身没有问题;pay 补契约后 jiu 跟进一行;
  2. qr render_type 文档有口径(qr_content/display_amount/expires_at)但无代码实现——体验增强项而非缺失:alipay PC 收银台页面本身含二维码,桌面扫码今天即可用,qr 内嵌只是省一次浏览器跳转;
  3. seedPlansalipay_sandbox.enabled=true 时执行,生产套餐种子需部署时确认;
  4. pay 侧需配置 BIZ_JIU_SECRET / BIZ_JIU_CALLBACK_URL
  5. 查单/retry/cancel 无鉴权本身是 pay 既定惯例(不可猜 ID + payload 无敏感字段,与 refunds 查询同型,P4 终审已归档为模式一致),非设计缺陷;jiu 侧也无未鉴权暴露(客户端/官网只轮询 jiu 自己的 JWT+shop_id 接口)。真正的加固点仅两处:order_no 熵偏薄(时间戳+uuid 前 8 hex ≈32 位)、改状态端点cancel/retry)可被持单号者骚扰——建议 pay 仓增熵 + 对改状态端点限流。

已定设计决策

  • D1 金额链路(保持"价格权威在 pay"v2 下单不回金额 → CreatePurchase 下单成功后立即查单回填 amount_minor/currency/subjectbest-effort,失败不阻断下单);webhook settle 时若本地 amount_minor==0(回填失败残单)先补查一次再核对,仍拿不到则拒绝入账(fail-closed,等 pay 重投)。核对 = int64 相等 + currency 相等。
  • D2 事件分发:以 payload 的 event_type 为准(X-Pay-Event 头不参与签名,仅日志)。payment.succeeded → settle其余事件(refund.* 等)记 ALERT 日志后 ack SUCCESS——pay v2 重投无退避无死信,回 FAIL 会 60s 永久重投;退款接入按任务书排除、另起任务。
  • D3 nonce 防重放jiu 侧内存去重(map[nonce]time.Time,10 分钟窗口滚动清理),命中回 401。pay 每次重投重新生成 nonce/签名,合法重投不受影响(真正幂等仍靠 settle 的 out_trade_no 短路);单实例部署,重启丢失可接受(settle 幂等兜底)。
  • D4 表结构license_purchases 新增 amount_minor bigint + currency varchar(8);旧 amount保留只读(响应兼容输出),启动时 Go 回填存量(复用 toCents,参照 backfillPinyin 先例),观察一版后另行 DROP(同定价字段消歧惯例)。
  • D5 兼容铁律:后端下单/查单响应同时输出新字段(render_type/payload/amount_minor/currency)与弃用字段(pay_url=redirect 时的 payload.url、amount=分格式化元串)——官网 web/checkout.njk:167 直读 d.pay_url、已发行客户端读 pay_url/amount,两者不升级也不能坏。官网本期零改动
  • D6 retry 不接:jiu 门店场景单渠道 alipay,无换支付方式需求;409 currency_mismatch 按约定=新建订单,现有「重新选择」语义已覆盖。cancel 接(轻量):「重新选择」时透传 pay cancel,防 pending 单堆积挤占 reconcile 每轮 50 条限额。
  • D7 客户端 render_type 分发redirectlaunchUrl(现行为);qr → 卡片内嵌 QrImageView(新增 qr_flutter 依赖,向前兼容,按契约文档口径 qr_content 实现、联调校准);未知 → toast 提示升级。iOS hideExternalPurchaseUi 合规开关不动。
  • D8 状态映射reconcile 遇 canceled/expired → 本地标 failed(不扩枚举);created/pending 继续等;refunding/partially_refunded/refunded → no-op 记日志(退款另起任务)。
  • D9 配置零新增PAY_BASE_URL/PAY_SECRET/PAY_RETURN_URL 三件套已存在(viper BindEnv + production.env + render-env.sh + Bitwarden),无需动。
  • D10 UI 治理口径(按 2026-07-10 铁律:原型=代码 100% 一致,前端改动必须先改原型):授权管理已完成 design-first——原型 license.html/m-license.html(授权信息/在线购买续费/订单管理三 tab)已评审通过并提交(13b7274),实现按原型逐像素落地,原型与代码同一提交qr 内嵌态动 PurchaseCard 前仍需先补原型。三闸:settings/新屏 golden + check_ds_code.mjs + check-ds.mjs
  • D11 授权管理提升(2026-07-10 评审通过并入):授权管理从设置子面板提升为独立一级屏(桌面侧边栏「系统」组 + 路由 /license;移动 /me/license 保持入口),屏内三 tab:授权信息(三格+兑换券+降级规则+iOS 客服卡)/在线购买续费(购买卡,iOS 合规态整 tab 隐藏)/订单管理(license_purchases 流水)。「继续支付」= 打开下单时存储的 pay_url(收银台 URL 复用,失效则取消重买,不接 retry 维持 D6);「授权续期至」= settle 时回写 renewed_to

Global Constraints

  • 金额禁 float;全链路 int64 最小单位 + 币种码。
  • 幂等:pay 60s 无限重投必须安全(settle 短路 + 未知事件 ack)。
  • 多租户:Status/Cancel 均带 shop_id 条件(从 JWT 取,middleware.GetShopID)。
  • 零写真实库:所有验证走 SQLite in-memory + httptest不部署、不发版、不打 tag
  • Go 命令前 export PATH="/opt/homebrew/bin:$PATH"
  • DoDgo build ./... && go vet ./... && go test ./... 全绿;flutter analyze --no-fatal-infos --no-fatal-warnings && flutter test 全绿。
  • 提交规范:feat(backend)|feat(client)|test(backend)|docs: ...,每任务一提交。

File Structure

文件 动作 职责
backend/internal/model/license_purchase.go +AmountMinor/Currencyamount 标弃用
backend/internal/service/pay.go 下单/查单/webhook/settle/reconcile 全部 v2 化;删 productID 缓存;+nonce 去重、+回填、+CancelPurchase、+formatMinor
backend/internal/service/pay_test.go mock server 换 v2 端点与形状;新增重放/未知事件/残单兜底/cancel 用例
backend/internal/handler/pay.go +Cancel handlerPurchase/Callback 基本不动)
backend/internal/router/router.go +license.POST("/purchase/:out_trade_no/cancel", ...)
backend/schema/schema.sqlbackend/testutil/setup.gobackend/main.go 列同步 / SQLite 建表同步 / 启动回填挂载
client/lib/models/license.dart PurchaseOrder/PurchaseStatusInfo v2 字段
client/lib/core/utils/money.dart yuanFromMinor(int) 分→元展示
client/lib/repositories/license_repository.dart +cancelPurchase
client/lib/screens/settings/purchase_card.dart render_type 分发 + qr 内嵌 + 金额展示 + 重选时 cancel
client/pubspec.yaml +qr_flutter
docs/pay支付对接开发指南.htmldocs/db-schema.htmldocs/index.htmldocs/plans/* 改/建 文档 v2 化 + 计划登记

Task 1: license_purchases 表 v2 金额列 + 存量回填

Files: Modify: backend/internal/model/license_purchase.gobackend/schema/schema.sqllicense_purchases 段)、backend/testutil/setup.go:163 附近建表 SQL、backend/main.goautoMigrate 之后)、backend/internal/service/pay.go(回填函数)

Interfaces (Produces): model.LicensePurchase.AmountMinor int64 / .Currency stringservice.BackfillPurchaseAmountMinor(db *gorm.DB)

  • 1.1 写失败测试pay_test.go):插入 LicensePurchase{Amount:"2999.00", AmountMinor:0}{Amount:"1.00"},调 BackfillPurchaseAmountMinor(db),断言 amount_minor 分别为 299900/100、currency=="CNY";已有 amount_minor>0 的行不被覆盖。
  • 1.2 跑测确认 FAIL(字段/函数不存在编译失败即视为 FAIL):cd backend && go test ./internal/service/ -run TestBackfill -v
  • 1.3 实现model 加
AmountMinor int64      `gorm:"not null;default:0" json:"amount_minor"` // v2 口径:最小单位(CNY=分)
Currency    string     `gorm:"size:8;not null;default:''" json:"currency"`
PayURL      string     `gorm:"size:512" json:"pay_url,omitempty"`   // v2 session payload.url,订单管理「继续支付」复用
RenewedTo   *time.Time `json:"renewed_to,omitempty"`                // settle 续期后的授权到期日(订单流水「授权续期至」展示)
Amount      string     `gorm:"size:16" json:"amount"` // Deprecated: v1 元字符串,只读兜底输出,观察一版后 DROP

回填(service/pay.go,复用现有 toCents,删除不再使用的 amountEqual):

// BackfillPurchaseAmountMinor 启动回填:v1 存量购买单 amount("2999.00") → amount_minor(299900)+CNY。
// 幂等:只处理 amount_minor=0 且 amount 非空的行(参照 backfillPinyin 先例)。
func BackfillPurchaseAmountMinor(db *gorm.DB) {
	var rows []model.LicensePurchase
	if err := db.Where("amount_minor = 0 AND amount <> ''", ).Find(&rows).Error; err != nil { return }
	for _, p := range rows {
		if c, err := toCents(p.Amount); err == nil && c > 0 {
			db.Model(&model.LicensePurchase{}).Where("id = ?", p.ID).
				Updates(map[string]any{"amount_minor": c, "currency": "CNY"})
		}
	}
}

main.go 在 autoMigrate 后调 service.BackfillPurchaseAmountMinor(db)schema.sql 与 testutil/setup.go 的 license_purchases 建表同步加两列(SQLiteamount_minor INTEGER NOT NULL DEFAULT 0, currency TEXT NOT NULL DEFAULT '')。

  • 1.4 跑测 PASS:同 1.2 命令,Expected: PASS
  • 1.5 提交git commit -m "feat(backend): license_purchases 增 amount_minor/currency 列 + 存量回填(pay v2 金额口径)"

Task 2: PayService 下单 + 查单切 /api/v2

Files: Modify: backend/internal/service/pay.goCreatePurchase/PurchaseResult/queryOrder/Status;删 productID/prodCache/prodMu/prodAt 及 struct 对应字段)、backend/internal/service/pay_test.go

Interfaces:

  • Consumes: Task 1 的 AmountMinor/Currency 字段

  • Produces: PurchaseResult{OutTradeNo, RenderType, Payload map[string]any, AmountMinor, Currency, Subject, PayURL(deprecated), Amount(deprecated)}payOrderStatus{OrderNo, Status, Subject, AmountMinor, Currency, PaidAt}formatMinor(int64) string

  • 2.1 写失败测试mock pay 挂 POST /api/v2/orders(断言收到 sku=="annual_standard"method=="alipay"biz_system=="jiu"、签名四头可验)回 {"data":{"order_no":"pay-x1","session":{"render_type":"redirect","payload":{"url":"https://pay.test/cashier"}}}};挂 GET /api/v2/orders/pay-x1{"data":{"order_no":"pay-x1","status":"pending","subject":"岩美酒库·标准版年付","amount_minor":299900,"currency":"CNY"}}。断言 CreatePurchase 返回 RenderType=="redirect"、Payload["url"]、AmountMinor==299900、PayURL=="https://pay.test/cashier" && Amount=="2999.00"(兼容字段)DB 购买单回填 out_trade_no/amount_minor/currency。

  • 2.2 跑测 FAILgo test ./internal/service/ -run TestCreatePurchase -v

  • 2.3 实现:请求体 {"sku": bizCode, "method": "alipay", "biz_system": "jiu", "biz_ref": <purchase_id>, "return_url": s.retURL}signedPost("/api/v2/orders", ...);解析 order_no+sessionst, err := s.queryOrder(orderNo) best-effort 回填(err 时只写 out_trade_no,金额留 0 由 D1 兜底);clientType 参数保留但不发送(注释:pay v2 端型透传欠账,补契约后跟进)。购买单回填时一并存 pay_urlredirect 时的 payload.urlD11 继续支付用)。兼容字段:RenderType=="redirect"PayURL=Payload["url"]Amount=formatMinor(AmountMinor)0 时留空)。

func formatMinor(minor int64) string { // 分→元串,仅 2 位小数币种(CNY)
	if minor <= 0 { return "" }
	return fmt.Sprintf("%d.%02d", minor/100, minor%100)
}

queryOrder 改 GET /api/v2/orders/:no,结构体按 Produces 定义(status 八态见契约表)。Status()(客户端轮询响应)加 AmountMinor/Currency 输出,Amount 输出 formatMinor(p.AmountMinor)、残单回退 p.Amount。

  • 2.4 跑测 PASS 后全量:go build ./... && go vet ./... && go test ./internal/service/
  • 2.5 提交feat(backend): pay 下单/查单切 v2 契约——sku 直用 biz_code、session 多态响应、金额 int64 分

Task 3: webhook v2——事件分发 + int64 金额核对 + nonce 防重放

Files: Modify: backend/internal/service/pay.gopayNotification/HandleCallback/settle)、backend/internal/service/pay_test.gocallbackBody 及全部回调用例)

Interfaces:

  • Consumes: Task 2 的 queryOrder(残单兜底)

  • Produces: settle(outTradeNo, bizCode string, amountMinor int64, currency, channel string, paidAt time.Time) error

  • 3.1 写失败测试

    • 成功入账:v2 payload {"event_type":"payment.succeeded","out_trade_no":..,"product_biz_code":"annual_standard","amount_minor":299900,"currency":"CNY","channel":"alipay","paid_at":RFC3339} → 断言 license 续期 365 天、purchase 标 paid;重复投递(新 nonce 新签名)→ 仍 SUCCESS 且不双续(幂等回归)。
    • nonce 重放:同 ts/nonce/sign 原样重发 → ErrPaySignature
    • 未知事件 ackevent_type:"refund.succeeded" → 返回 nilack)、purchase 状态不变。
    • 金额不符amount_minor 差 1 分 → ErrPayAmount
    • 残单兜底:购买单 amount_minor=0mock 查单回 299900 → settle 成功且回填。
  • 3.2 跑测 FAILgo test ./internal/service/ -run TestCallback -v

  • 3.3 实现payNotification 改字段(EventType/AmountMinor int64/Currency,删 Amount/TradeNo);HandleCallback 验签+时间窗后加:

if s.replayed(nonce) { return ErrPaySignature }
...
switch n.EventType {
case "payment.succeeded":
	return s.settle(n.OutTradeNo, n.ProductBizCode, n.AmountMinor, n.Currency, n.Channel, paidAt)
default: // refund.*/未来事件:本期不接(另起任务);ack 防 60s 永久重投,ALERT 留痕
	log.Printf("[pay] ALERT unhandled webhook event=%s out_trade_no=%s (acked)", n.EventType, n.OutTradeNo)
	return nil
}

nonce 去重(PayService 加 seenMu sync.Mutex; seen map[string]time.Time):

// replayed nonce 防重放:10 分钟窗口内重复即拒绝(pay 合法重投每次生成新 nonce 不受影响;
// 单实例内存实现,重启丢失由 settle 幂等兜底)。
func (s *PayService) replayed(nonce string) bool {
	s.seenMu.Lock(); defer s.seenMu.Unlock()
	now := time.Now()
	for k, t := range s.seen { if now.Sub(t) > 10*time.Minute { delete(s.seen, k) } }
	if _, ok := s.seen[nonce]; ok { return true }
	if s.seen == nil { s.seen = map[string]time.Time{} }
	s.seen[nonce] = now
	return false
}

settle 核对段(替换 amountEqual 调用;paid Updates 去掉 trade_no、renewed_to=entitle 后的授权到期日D11 展示用):

if p.AmountMinor == 0 { // D1 残单兜底:下单后回填失败,入账前补查权威价
	if st, qerr := s.queryOrder(outTradeNo); qerr == nil && st.AmountMinor > 0 {
		p.AmountMinor, p.Currency = st.AmountMinor, st.Currency
		if err := tx.Model(&model.LicensePurchase{}).Where("id = ?", p.ID).
			Updates(map[string]any{"amount_minor": p.AmountMinor, "currency": p.Currency}).Error; err != nil { return err }
	}
}
if amountMinor != p.AmountMinor || !strings.EqualFold(currency, p.Currency) {
	log.Printf("[pay] amount mismatch out_trade_no=%s purchase=%d/%s callback=%d/%s", outTradeNo, p.AmountMinor, p.Currency, amountMinor, currency)
	return ErrPayAmount
}

handler Callback 零改动(event 走 payloadX-Pay-Event 头不参与签名不依赖)。

  • 3.4 跑测 PASSgo test ./internal/service/ ./internal/handler/
  • 3.5 提交feat(backend): pay webhook 升 v2 事件模型——event_type 分发、amount_minor 核对、nonce 防重放

Task 4: 查单兜底 reconcile v2 状态映射

Files: Modify: backend/internal/service/pay.goreconcileOnce)、pay_test.go

  • 4.1 写失败测试pending 超时单 ×4mock 查单分别回 paid(断言入账续期)、canceledexpired(断言标 failed)、refunded(断言状态不变仅日志)。
  • 4.2 跑测 FAIL4.3 实现
switch st.Status {
case "paid":
	paidAt := time.Now(); if st.PaidAt != nil { paidAt = *st.PaidAt }
	s.settle(p.OutTradeNo, p.ProductBizCode, st.AmountMinor, st.Currency, "", paidAt)
case "canceled", "expired":
	s.db.Model(&model.LicensePurchase{}).Where("id = ? AND status = 'pending'", p.ID).Update("status", "failed")
case "refunding", "partially_refunded", "refunded":
	log.Printf("[pay] order %s status=%s (no-op,退款接入另起任务)", p.OutTradeNo, st.Status)
} // created/pending:继续等
  • 4.4 跑测 PASS4.5 提交feat(backend): pay 查单兜底适配 v2 订单八态

Task 5: 取消购买单透传(防 pending 堆积)

Files: Modify: backend/internal/service/pay.go+CancelPurchase)、backend/internal/handler/pay.go+Cancel)、backend/internal/router/router.golicense 组 +1 行)、pay_test.gohandler 测试

Interfaces (Produces): POST /api/v1/license/purchase/:out_trade_no/cancel → 200 {"data":{"canceled":bool}};仅管理员(handler 内判权同 Purchase

  • 5.1 写失败测试:本店 pending 单 + mock pay POST /api/v2/orders/:no/cancel{"data":{"canceled":true}} → 本地标 failed、返回 truepay 回 canceled:false(已支付竞态)→ 本地保持 pending(等 webhook 入账)、返回 false;他店单 → ErrPurchaseNotFound。
  • 5.2 跑测 FAIL5.3 实现service 校验 shop_id+out_trade_no+status=pending → POST pay cancel(无签名无 body)→ canceled==trueUPDATE ... WHERE id=? AND status='pending' SET status='failed'handler 判 admin/superadmin,路由挂 license 组(LicenseGuard 豁免区,锁定店也能清单)。客户端对接在 Task 7。
  • 5.4 跑测 PASS5.5 提交feat(backend): 购买单取消透传 pay v2 cancel

Task 6: 客户端模型/仓库层 v2 化

Files: Modify: client/lib/models/license.dartPurchaseOrder/PurchaseStatusInfo)、client/lib/repositories/license_repository.dart+cancelPurchase);Create: client/lib/core/utils/money.dartTest: client/test/(现有 license 模型/面板测试同步)

Interfaces (Produces):

class PurchaseOrder {
  final String outTradeNo; final String renderType;           // redirect | qr | ...
  final Map<String, dynamic> payload;
  final int amountMinor; final String currency; final String subject;
  String get redirectUrl => payload['url'] as String? ?? '';
  String get qrContent  => payload['qr_content'] as String? ?? '';
}
String yuanFromMinor(int minor); // 299900 → "2,999.00"(千分位+2位小数,money.dart
Future<bool> cancelPurchase(String outTradeNo);  // repository
  • 6.1 写失败测试fromJson 解析 v2 响应(render_type/payload/amount_minor);yuanFromMinor(299900)=="2,999.00"(100)=="1.00"
  • 6.2 flutter test FAIL6.3 实现PurchaseStatusInfo 加 amountMinor/currency,保留 amount 串回退;repository cancelPurchase POST 对应端点,404/409 转 AppException)→ 6.4 PASS
  • 6.5 提交feat(client): 购买模型/仓库切 pay v2 契约(render_type 多态 + 分金额)

Task 7: PurchaseCard render_type 分发 + qr 内嵌 + cancel

Files: Modify: client/lib/screens/settings/purchase_card.dartclient/pubspec.yamlqr_flutter: ^4.1.0,纯 Dart 全平台)

Interfaces (Consumes): Task 6 的 PurchaseOrder/yuanFromMinor/cancelPurchase

  • 7.0 原型先行(铁律)design/prototype/screens/settings.html 授权面板购买卡与 m-license.html buyCard 补「qr 内嵌二维码」支付形态(等待态卡内嵌二维码占位 + 「打开支付宝扫码支付」文案),跑 node design/prototype/tools/check-ds.mjs 全绿,先给用户过目再动代码
  • 7.1 实现 _submit 分发
final order = await ref.read(licenseRepositoryProvider)
    .createPurchase(_plan.bizCode, clientType: _clientType);
switch (order.renderType) {
  case 'redirect':
    await launchUrl(Uri.parse(order.redirectUrl), mode: LaunchMode.externalApplication);
  case 'qr':
    break; // 不外跳,waiting 态内嵌二维码
  default:
    showDsToast(context, '当前版本暂不支持该支付方式,请升级应用', bg: context.tokens.danger);
    setState(() => _submitting = false);
    return;
}

waiting 态:renderType=='qr' 时嵌 QrImageView(data: order.qrContent, size: 180)(容器沿用现有 t.bg+border 卡式)+ 文案「打开支付宝扫码支付」;redirect 保持现文案。金额展示统一 ¥${yuanFromMinor(order.amountMinor)}0 时显示 '—')。_backToPick 对 pending 单 fire-and-forget cancelPurchase(order.outTradeNo)(失败静默,reconcile 兜底)。轮询/成功态/iOS 合规开关/promo 逻辑全部不动。

  • 7.2 现有测试回归 + goldencd client && flutter analyze --no-fatal-infos --no-fatal-warnings && flutter test(含 settings golden ×3 主题、license_panel_ios_testExpected: 全 PASS 无 golden diffPurchaseCard 不在 golden 锁定区)
  • 7.3 DS 闸node client/tool/check_ds_code.mjs Expected: PASS(新 UI 无硬编码色)
  • 7.4 提交feat(client): 购买卡按 render_type 分发——redirect 外跳/qr 内嵌二维码/重选取消订单

Task 9: 订单列表接口(授权管理·订单管理 tab 数据源)

Files: Modify: backend/internal/service/pay.go+ListPurchases)、backend/internal/handler/pay.go+Purchases)、backend/internal/router/router.golicense 组 +1 行)、pay_test.go

Interfaces (Produces): GET /api/v1/license/purchases?page=&page_size=&status= → 200

{"data":{"items":[{"out_trade_no":"pay-...","product_biz_code":"annual_pro","amount_minor":599900,"currency":"CNY","amount":"5999.00","status":"pending","pay_url":"https://...","user_name":"王老板","created_at":"...","paid_at":null,"renewed_to":null}],
"total":7,"summary":{"paid_total_minor":419700,"paid_count":5,"pending_count":1,"total_count":7}}}

仅管理员(handler 内判权同 Purchase);shop_id 从 JWT 取;pay_url 仅 pending 单返回(继续支付用);user_name LEFT JOIN users 取。挂 license 组(LicenseGuard 豁免区,锁定店也能看订单)。

  • 9.1 写失败测试:种 3 店 7 单(跨店隔离断言)→ 列表按 created_at DESC 分页正确、status 筛选、summary 四值正确、pending 单带 pay_url 而 paid 单不带、operator 角色 403。
  • 9.2 跑测 FAIL9.3 实现service 查询+summary 聚合一次事务内完成;handler 参数校验 page_size≤100)→ 9.4 跑测 PASSgo build ./... && go vet ./... && go test ./...
  • 9.5 提交feat(backend): 授权订单列表接口——分页/筛选/汇总,支撑订单管理 tab

Task 10: 授权管理独立屏三 tab(桌面+移动,按已评审原型实现)

Files: Create: client/lib/screens/license/license_screen.dart(三 tab 容器)、client/lib/screens/license/license_orders_tab.dart(订单列表双形态);Modify: client/lib/screens/settings/settings_screen.dart(摘除授权子导航)、client/lib/screens/shell/app_shell.dart(桌面侧边栏「系统」组 + 窄屏二级屏标题映射)、client/lib/core/router/app_router.dart/license 路由;/me/license 指向新屏)、client/lib/repositories/license_repository.dart+listPurchases)、client/lib/models/license.dart+PurchaseRecord/PurchaseSummary)、tools/screens.mjs(注册 license 屏 fidelity)、golden 基准更新

Interfaces:

  • Consumes: Task 9 接口、Task 6 的 yuanFromMinor/cancelPurchase、Task 5 cancel 端点
  • Produces: 路由 /license(桌面)与 /me/license(移动,同一 Widget 双形态)

真相源:原型 design/prototype/screens/license.html + m-license.html(已评审提交 13b7274),逐像素还原:

  • 三 tab.seg 同构 DsSeg):授权信息(现 LicensePanel 拆分:三格授权卡/兑换券/降级规则/iOS 客服卡)|在线购买续费(PurchaseCardiOS hideExternalPurchaseUi 时整 tab 隐藏)|订单管理

  • 订单管理 tab:KPI 四格(当前授权到期/累计购买/订单数/待支付·点击筛选)→ 桌面 DsTable(订单号 mono/套餐/时长/金额/状态徽章/下单人/下单·支付时间/操作)+ 详情抽屉;移动 MCard 流 + 详情 sheet;套餐/状态/时间筛选(桌面 DsChip、移动 sheet);待支付「继续支付」=launchUrl(pay_url)、「取消订单」=cancelPurchase;已关闭「重新购买」切购买 tab;已支付显示「授权续期至」

  • settings 摘除授权子导航(剩 门店信息/用户管理/偏好);到期横幅/弹窗按钮跳转改 /license(窄屏 /me/license

  • 10.1 实现三 tab 容器与路由app_shell/_navItems/branch 顺序对齐、窄屏标题映射补行)

  • 10.2 实现订单 tab 双形态(模型 fromJson 测试先行;轮询不做——列表下拉刷新即可,心跳兜底已有)

  • 10.3 验收flutter analyze --no-fatal-infos --no-fatal-warnings && flutter testsettings golden 更新(子导航少一项会入镜,重新生成基准);新屏 golden ×3 主题 + tools/screens.mjs 注册 fidelity(阈内);node client/tool/check_ds_code.mjs

  • 10.4 提交feat(client): 授权管理独立一级屏——授权/购买续费/订单管理三 tab(桌面+移动)

Task 8: 文档同步

Files: Modify: docs/pay支付对接开发指南.html(端点/签名示例/payload/金额口径/事件模型全节 v2 化,标注 v1 历史断代)、docs/db-schema.htmllicense_purchases 两新列)、docs/index.html(登记本计划 HTML);Create: docs/plans/pay-v2-integration.html(本计划同内容 HTML 阅读版,沿用既有深色主题样式块)

  • 8.1 按本计划契约表更新开发指南;8.2 db-schema 列级同步(含 pay_url/renewed_to);8.3 CONTRACT 台账回填(授权管理屏实现完成、fidelity/golden 结果);8.4 用户手册补「授权管理/订单管理」节(docs/manual/user-manual.htmlweb/content/docs.md 两侧同步)
  • 8.5 提交docs: pay 对接指南升 v2 契约 + 授权管理屏文档同步

执行顺序:Task 1→2→3→4→5→9(后端链)与 Task 6→7→10(前端链,依赖对应后端任务接口形状)交错推进,Task 8 收尾。


联调 checklist(pay v2 部署后执行,不阻塞本计划开发)

  • pay v2 合 main 部署;生产 accounts 配置 alipay 且 seedPlans 套餐落库(注意 seed 现仅 sandbox 开关下执行)
  • pay 侧 BIZ_JIU_SECRET=jiu 侧 PAY_SECRETBitwarden 单源)与 BIZ_JIU_CALLBACK_URL=https://jiu.51yanmei.com/api/v1/pay/callback 就位;jiu 侧 PAY_BASE_URL 指向 v2 部署
  • ¥0.01/¥1 真单走通(jiu payPlans 白名单无 test_liandiao:联调用 promo ¥1,或临时给 payPlans 加 test_liandiao: {Days:1, standard} 映射、联调后移除):下单 → redirect 收银台 → 实付 → webhook 入账续期 → 客户端轮询转 success → InvalidateLicensePhase 写权限即时恢复
  • 阻断 webhook(临时改 callback_url)验证 reconcile 5 分钟查单兜底入账
  • 重投验证:webhook 接收器临时回 FAIL,观察 pay 60s 重投与 jiu 幂等
  • 官网 checkoutpay_url 兼容字段)+ 旧版客户端购买路径回归
  • pay 侧欠账跟进:端型透传(is_mobile)补契约 → jiu 下单体传 client_type 一行跟进,真机验证手机拉起支付宝 App;refund 事件消费另起任务(jiu 当前 ack+ALERT
  • 取消路径:客户端「重新选择」→ pay 订单翻 canceled

最终验证(DoD

export PATH="/opt/homebrew/bin:$PATH"
cd backend && go build ./... && go vet ./... && go test ./...
cd ../client && flutter analyze --no-fatal-infos --no-fatal-warnings && flutter test
node client/tool/check_ds_code.mjs && node tools/check-l1-sync.mjs

全绿后停在本地提交态:不部署、不发版todo 标 done 等用户验收。

批准后的落地流程

  1. 计划双产物:本文件存 docs/plans/2026-07-10-pay-v2-integration.mdcheckbox 执行真相源)+ HTML 阅读版登记 docs/index.htmlTask 8 亦覆盖)
  2. node ~/.claude/skills/todo/todo.mjs add "jiu 授权续费对接 pay v2v1 契约升级)" 登记并标 doing
  3. superpowers:subagent-driven-development 按任务逐个派发执行,每任务提交