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

355 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.go``model/license_purchase.go``client/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_code**`productID()` 查询/缓存整段删除);**无 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/:otn`status `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 | 扁平 payload`amount` 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/retry`409 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. `seedPlans``alipay_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/subject`best-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 分发**`redirect``launchUrl`(现行为);`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"`
- DoD`go 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.sql``backend/testutil/setup.go``backend/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支付对接开发指南.html``docs/db-schema.html``docs/index.html``docs/plans/*` | 改/建 | 文档 v2 化 + 计划登记 |
---
### Task 1: license_purchases 表 v2 金额列 + 存量回填
**Files:** Modify: `backend/internal/model/license_purchase.go``backend/schema/schema.sql`license_purchases 段)、`backend/testutil/setup.go:163` 附近建表 SQL、`backend/main.go`autoMigrate 之后)、`backend/internal/service/pay.go`(回填函数)
**Interfaces (Produces):** `model.LicensePurchase.AmountMinor int64` / `.Currency string``service.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 加
```go
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`):
```go
// 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 建表同步加两列(SQLite`amount_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.go`CreatePurchase/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 跑测 FAIL**`go 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+session`st, err := s.queryOrder(orderNo)` best-effort 回填(err 时只写 out_trade_no,金额留 0 由 D1 兜底);`clientType` 参数保留但不发送(注释:pay v2 端型透传欠账,补契约后跟进)。购买单回填时**一并存 `pay_url`**redirect 时的 payload.urlD11 继续支付用)。兼容字段:`RenderType=="redirect"``PayURL=Payload["url"]``Amount=formatMinor(AmountMinor)`0 时留空)。
```go
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.go`payNotification/HandleCallback/settle)、`backend/internal/service/pay_test.go`callbackBody 及全部回调用例)
**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`
- **未知事件 ack**`event_type:"refund.succeeded"` → 返回 nilack)、purchase 状态不变。
- **金额不符**amount_minor 差 1 分 → `ErrPayAmount`
- **残单兜底**:购买单 amount_minor=0mock 查单回 299900 → settle 成功且回填。
- [ ] **3.2 跑测 FAIL**`go test ./internal/service/ -run TestCallback -v`
- [ ] **3.3 实现**payNotification 改字段(EventType/AmountMinor int64/Currency,删 Amount/TradeNo);HandleCallback 验签+时间窗后加:
```go
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`):
```go
// 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 展示用):
```go
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 走 payload`X-Pay-Event` 头不参与签名不依赖)。
- [ ] **3.4 跑测 PASS**`go 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.go`reconcileOnce)、`pay_test.go`
- [ ] **4.1 写失败测试**pending 超时单 ×4mock 查单分别回 `paid`(断言入账续期)、`canceled``expired`(断言标 failed)、`refunded`(断言状态不变仅日志)。
- [ ] **4.2 跑测 FAIL****4.3 实现**
```go
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 跑测 PASS****4.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.go`license 组 +1 行)、`pay_test.go``handler` 测试
**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 跑测 FAIL****5.3 实现**service 校验 `shop_id+out_trade_no+status=pending` → POST pay cancel(无签名无 body)→ `canceled==true``UPDATE ... WHERE id=? AND status='pending' SET status='failed'`handler 判 admin/superadmin,路由挂 license 组(LicenseGuard 豁免区,锁定店也能清单)。客户端对接在 Task 7。
- [ ] **5.4 跑测 PASS****5.5 提交**`feat(backend): 购买单取消透传 pay v2 cancel`
### Task 6: 客户端模型/仓库层 v2 化
**Files:** Modify: `client/lib/models/license.dart`PurchaseOrder/PurchaseStatusInfo)、`client/lib/repositories/license_repository.dart`+cancelPurchase);Create: `client/lib/core/utils/money.dart`Test: `client/test/`(现有 license 模型/面板测试同步)
**Interfaces (Produces):**
```dart
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` FAIL****6.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.dart``client/pubspec.yaml``qr_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` 分发**
```dart
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 现有测试回归 + golden**`cd 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.go`license 组 +1 行)、`pay_test.go`
**Interfaces (Produces):** `GET /api/v1/license/purchases?page=&page_size=&status=` → 200
```json
{"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 跑测 FAIL****9.3 实现**service 查询+summary 聚合一次事务内完成;handler 参数校验 page_size≤100)→ **9.4 跑测 PASS**`go 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 test`settings 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.html`license_purchases 两新列)、`docs/index.html`(登记本计划 HTML);Create: `docs/plans/pay-v2-integration.html`(本计划同内容 HTML 阅读版,沿用既有深色主题样式块)
- [x] **8.1** 按本计划契约表更新开发指南;**8.2** db-schema 列级同步(含 pay_url/renewed_to);**8.3** CONTRACT 台账回填(授权管理屏实现完成、fidelity/golden 结果);**8.4** 用户手册补「授权管理/订单管理」节(`docs/manual/user-manual.html``web/content/docs.md` 两侧同步)
- [x] **8.5 提交**`docs: pay 对接指南升 v2 契约 + 授权管理屏文档同步`
> 执行顺序:Task 1→2→3→4→5→9(后端链)与 Task 6→7→10(前端链,依赖对应后端任务接口形状)交错推进,Task 8 收尾。
---
## 联调 checklistpay v2 部署后执行,不阻塞本计划开发)
- [ ] pay v2 合 main 部署;生产 `accounts` 配置 alipay 且 `seedPlans` 套餐落库(注意 seed 现仅 sandbox 开关下执行)
- [ ] pay 侧 `BIZ_JIU_SECRET`=jiu 侧 `PAY_SECRET`Bitwarden 单源)与 `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
```bash
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.md`checkbox 执行真相源)+ HTML 阅读版登记 `docs/index.html`Task 8 亦覆盖)
2. `node ~/.claude/skills/todo/todo.mjs add "jiu 授权续费对接 pay v2v1 契约升级)"` 登记并标 doing
3. superpowers:subagent-driven-development 按任务逐个派发执行,每任务提交