3b5d84a7e3
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
355 lines
32 KiB
Markdown
355 lines
32 KiB
Markdown
# 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` int64(CNY=分)+ `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/Currency,amount 标弃用 |
|
||
| `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 handler(Purchase/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.url,D11 继续支付用)。兼容字段:`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"` → 返回 nil(ack)、purchase 状态不变。
|
||
- **金额不符**:amount_minor 差 1 分 → `ErrPayAmount`。
|
||
- **残单兜底**:购买单 amount_minor=0,mock 查单回 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 超时单 ×4,mock 查单分别回 `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、返回 true;pay 回 `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_test)Expected: 全 PASS 无 golden diff(PurchaseCard 不在 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 客服卡)|在线购买续费(PurchaseCard,iOS `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 收尾。
|
||
|
||
---
|
||
|
||
## 联调 checklist(pay 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 幂等
|
||
- [ ] 官网 checkout(pay_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 v2(v1 契约升级)"` 登记并标 doing
|
||
3. superpowers:subagent-driven-development 按任务逐个派发执行,每任务提交
|