docs: pay v2 改造计划双产物落库(.md 真相源 + HTML 阅读版)+ 索引登记
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -80,6 +80,7 @@
|
||||
<ul>
|
||||
<li><a href="plans/mobile-screens-implementation.html">移动端全屏落地实现计划(m-* 原型 → Flutter,2026-07-04 已执行)</a><span class="tag html">HTML</span> <span class="hint">— 底部 tab + 我的 hub + sheet 交互范式;Stage0 基建 + 4 并行工作包 + 合流验收全记录</span></li>
|
||||
<li><a href="plans/security-hardening.html">安全三件套实现计划(2026-07-05)</a><span class="tag html">HTML</span> <span class="hint">— 限流/登录锁外置 Redis + 公开接口反爬(日配额/UA/防盗链)+ 边缘加固(nginx limit_conn/安全组/DDoS runbook)</span></li>
|
||||
<li><a href="plans/pay-v2-integration.html">授权续费对接 pay v2 改造计划(2026-07-10,执行中)</a><span class="tag html">HTML</span> <span class="hint">— v1→v2 契约升级(金额 int64 分/session 多态/webhook 事件模型)+ 授权管理独立屏三 tab(订单管理);执行真相源 plans/2026-07-10-pay-v2-integration.md</span></li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
# 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 阅读版,沿用既有深色主题样式块)
|
||||
|
||||
- [ ] **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` 两侧同步)
|
||||
- [ ] **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 按任务逐个派发执行,每任务提交
|
||||
@@ -0,0 +1,114 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>jiu 授权续费对接 pay v2 改造计划 — 酒库管理系统</title><style>
|
||||
:root{
|
||||
--primary:#2563AC; --primary-dark:#154072; --danger:#D14343; --danger-bg:#FDECEC;
|
||||
--success:#2E8B57; --success-bg:#E6F3EC; --warn:#B45309; --warn-bg:#FFF4E5; --accent:#8B2331;
|
||||
--ink:#232934; --muted:#6E7888; --border:#DCE2EB; --paper:#F5F7FA; --head:#F0F4FF;
|
||||
}
|
||||
*{box-sizing:border-box;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;}
|
||||
body{margin:0;background:var(--paper);color:var(--ink);padding:28px;line-height:1.7;max-width:1000px;}
|
||||
h1{font-size:22px;margin:0 0 4px;}
|
||||
h2{font-size:17px;margin:30px 0 12px;color:var(--primary-dark);border-bottom:2px solid var(--head);padding-bottom:6px;}
|
||||
h3{font-size:14.5px;margin:18px 0 8px;color:var(--primary-dark);}
|
||||
.sub{color:var(--muted);font-size:13px;margin-bottom:18px;}
|
||||
.card{background:#fff;border:1px solid var(--border);border-radius:10px;padding:16px 20px;margin:14px 0;font-size:13.5px;}
|
||||
code{font-family:ui-monospace,Menlo,monospace;font-size:12px;background:#EEF2F8;padding:1px 5px;border-radius:4px;color:var(--primary-dark);}
|
||||
pre{background:#0F1722;color:#D8E1EC;border-radius:8px;padding:12px 14px;overflow-x:auto;font-size:12px;line-height:1.55;}
|
||||
pre code{background:none;color:inherit;padding:0;}
|
||||
ul,ol{margin:6px 0;padding-left:22px;} li{margin:4px 0;font-size:13px;}
|
||||
table{border-collapse:collapse;width:100%;margin:10px 0;font-size:12.5px;background:#fff;}
|
||||
th,td{border:1px solid var(--border);padding:6px 10px;text-align:left;vertical-align:top;}
|
||||
th{background:var(--head);color:var(--primary-dark);white-space:nowrap;}
|
||||
.tag{display:inline-block;font-size:11px;padding:1px 8px;border-radius:10px;margin-right:6px;font-weight:600;}
|
||||
.tag.keep{background:var(--success-bg);color:var(--success);}
|
||||
.tag.change{background:var(--warn-bg);color:var(--warn);}
|
||||
.tag.risk{background:var(--danger-bg);color:var(--danger);}
|
||||
.chk{color:var(--success);font-weight:700;margin-right:4px;}
|
||||
.hl{background:var(--warn-bg);padding:0 3px;border-radius:3px;}
|
||||
.wrap{overflow-x:auto;}
|
||||
</style></head><body>
|
||||
<h1>jiu 授权续费对接 pay v2 改造计划</h1>
|
||||
<div class="sub">2026-07-10 · 状态:<b>已批准,执行中</b> · 执行真相源 = <code>docs/plans/2026-07-10-pay-v2-integration.md</code>(checkbox),本页为阅读版 · pay v2 契约读自 <code>design/pay-v2</code> 分支代码(非文档口径)· 已并入授权管理独立屏三 tab 实现(原型评审通过 13b7274)</div>
|
||||
|
||||
<h2>Context:调研结论改变了任务定位</h2><div class="card">
|
||||
<p style="margin:4px 0"><b>jiu 两侧的 pay v1 对接已经完整上线</b>,本任务不是从零接入,而是<b>契约升级(v1 → v2)</b>:</p>
|
||||
<ul>
|
||||
<li>后端 <code>backend/internal/service/pay.go</code> 已有 <code>PayService</code> 四职责:签名下单 <code>CreatePurchase</code> / webhook 入账 <code>HandleCallback→settle</code>(FOR UPDATE + out_trade_no 幂等 + 金额核对)/ 续期 <code>entitle</code>(max(现到期,now)+时长)/ 60s 查单兜底 <code>reconcileOnce</code>;表 <code>license_purchases</code>、配置 <code>PAY_BASE_URL/PAY_SECRET/PAY_RETURN_URL</code>、路由 <code>POST /api/v1/pay/callback</code> 全部就位</li>
|
||||
<li>客户端 <code>purchase_card.dart</code> 已有三态状态机(选套餐 → launchUrl 拉起收银台 → 3s 轮询 + 30s 心跳兜底),iOS <code>hideExternalPurchaseUi</code> 合规开关、promo 限购、到期横幅入口都在</li>
|
||||
<li>官网 <code>web/checkout.njk:167</code> 直读下单响应的 <code>d.pay_url</code> 跳收银台——<span class="hl">兼容约束的来源</span></li>
|
||||
</ul>
|
||||
<p style="margin:4px 0"><span class="tag keep">零改动沿用</span>HMAC 签名算法(<code>util.PaySign</code>:base64(HMAC-SHA256(secret, "jiu\n ts\n nonce\n rawBody"))、±5min 窗)、out_trade_no 幂等键、entitle 续期语义、路由挂载、PAY_* 配置链、iOS 合规开关、轮询/心跳。</p>
|
||||
</div>
|
||||
|
||||
<h2>v2 契约 delta(已读 design/pay-v2 代码核实)</h2><div class="wrap">
|
||||
<table>
|
||||
<tr><th>项</th><th>v1(现实现)</th><th>v2(本计划目标)</th></tr>
|
||||
<tr><td><b>下单</b></td><td><code>POST /api/v1/orders</code><br>{product_id, biz_system, biz_ref, return_url, client_type}</td><td><code>POST /api/v2/orders</code> {sku, method, biz_system, biz_ref, return_url};<b>sku 直接用 biz_code</b>(productID 查询+10min 缓存整段删除);<span class="tag risk">无 client_type</span></td></tr>
|
||||
<tr><td><b>下单响应</b></td><td>{pay_url, out_trade_no, amount("2999.00"), subject}</td><td>{order_no, session:{render_type, payload, expires_at?}};alipay 落 <code>redirect</code> + payload.url;<span class="tag change">不回传金额</span></td></tr>
|
||||
<tr><td><b>查单</b></td><td><code>GET /api/v1/orders/:otn</code>,四态</td><td><code>GET /api/v2/orders/:no</code>(无鉴权,见风险节),{order_no, status, subject, amount_minor, currency, paid_at?},八态 created|pending|paid|canceled|expired|refunding|partially_refunded|refunded;不回传 biz_ref</td></tr>
|
||||
<tr><td><b>webhook</b></td><td>扁平 payload,amount 元串,有 trade_no</td><td>+<code>event_type</code>(当前仅 payment.succeeded)+ X-Pay-Event 头(<b>不参与签名</b>);amount_minor int64 + currency;<b>无 trade_no</b>;重投固定 60s 无退避无死信;成功判定 = 200 且 body 含 "SUCCESS"(大小写不敏感,现回包 {"code":"SUCCESS"} 天然满足)</td></tr>
|
||||
<tr><td><b>签名</b></td><td colspan="2"><span class="tag keep">完全一致</span>四段 \n 拼接 HMAC-SHA256 → base64 标准编码,<code>util.PaySign/PaySignVerify</code> 零改动;pay 侧无 nonce 去重,jiu 侧自建(D3)</td></tr>
|
||||
<tr><td><b>金额</b></td><td>string 元</td><td>amount_minor int64(CNY=分)+ currency 大写码,全链路禁 float</td></tr>
|
||||
<tr><td><b>retry / cancel</b></td><td>无</td><td>retry:409 currency_mismatch=需换方式新建单(jiu 单渠道不接,D6);cancel:恒 200 {"data":{"canceled":bool}},仅 pending 翻转成功(jiu 接,防单堆积)</td></tr>
|
||||
</table></div>
|
||||
|
||||
<div class="card"><b>pay 侧欠账(jiu 无法解决,列联调前置依赖,不阻塞开发):</b>
|
||||
<ol>
|
||||
<li><span class="tag risk">wap 缺口</span><b>手机拉起支付宝 App 断线的唯一根因</b>:v2 下单无 client_type、gateway 不设 Metadata["is_mobile"] → alipay 恒走 page.pay。redirect 机制本身没有问题;pay 补契约后 jiu 跟进一行</li>
|
||||
<li><code>qr</code> render_type 仅文档口径无代码实现——<b>体验增强项而非缺失</b>:alipay PC 收银台页面本身含二维码,桌面扫码今天即可用,qr 内嵌只是省一次浏览器跳转</li>
|
||||
<li><code>seedPlans</code> 仅 alipay_sandbox 开关下执行,生产套餐种子需部署时确认</li>
|
||||
<li>pay 侧需配置 <code>BIZ_JIU_SECRET</code> / <code>BIZ_JIU_CALLBACK_URL</code></li>
|
||||
<li>查单/retry/cancel <b>无鉴权本身是 pay 既定惯例</b>(不可猜 ID + payload 无敏感字段,与 refunds 查询同型,P4 终审已归档为模式一致),非设计缺陷;jiu 侧也无未鉴权暴露(客户端/官网只轮询 jiu 自己的 JWT+shop_id 接口)。真正的加固点仅两处:order_no 熵偏薄(≈32 位)、<b>改状态端点</b>(cancel/retry)可被持单号者骚扰——建议 pay 仓增熵 + 对改状态端点限流</li>
|
||||
</ol></div>
|
||||
|
||||
<h2>已定设计决策</h2><div class="card"><ul>
|
||||
<li><b>D1 金额链路(价格权威在 pay 不变)</b>:v2 下单不回金额 → 下单成功后<b>立即查单</b>回填 amount_minor/currency/subject(best-effort,失败不阻断);settle 时本地 amount_minor==0(残单)先补查再核对,仍拿不到则拒绝入账 fail-closed 等 pay 重投。核对 = int64 相等 + currency 相等</li>
|
||||
<li><b>D2 事件分发</b>:以 payload.event_type 为准(X-Pay-Event 头不参与签名仅日志)。payment.succeeded → settle;<b>其余事件(refund.* 等)记 ALERT 日志后 ack SUCCESS</b>——pay v2 重投无退避无死信,回 FAIL 会 60s 永久重投;退款接入按任务书排除、另起任务</li>
|
||||
<li><b>D3 nonce 防重放</b>:jiu 侧内存去重(map + 10min 滚动清理),命中回 401。pay 每次重投重新生成 nonce/签名不受影响;真正幂等仍靠 settle 短路;单实例部署,重启丢失由幂等兜底</li>
|
||||
<li><b>D4 表结构</b>:license_purchases 新增 <code>amount_minor bigint</code> + <code>currency varchar(8)</code>;旧 amount 列保留只读,启动 Go 回填存量(复用 toCents,参照 backfillPinyin),观察一版后另行 DROP(同定价消歧惯例)</li>
|
||||
<li><b>D5 兼容铁律</b>:后端下单/查单响应<b>同时输出</b>新字段与弃用字段(pay_url=redirect 时的 payload.url、amount=格式化元串)——官网 checkout 与已发行客户端不升级也不能坏;<b>官网本期零改动</b></li>
|
||||
<li><b>D6 retry 不接 / cancel 接</b>:单渠道 alipay 无换方式需求,409 currency_mismatch 按约定=新建订单(「重新选择」已覆盖);cancel 轻量接入,「重新选择」时透传 pay,防 pending 堆积挤占 reconcile 每轮 50 条限额</li>
|
||||
<li><b>D7 客户端 render_type 分发</b>:redirect → launchUrl(现行为);qr → 卡内嵌 QrImageView(新增 qr_flutter,纯 Dart 全平台,按文档口径 qr_content 实现、联调校准);未知 → toast 提示升级</li>
|
||||
<li><b>D8 状态映射</b>:reconcile 遇 canceled/expired → 标 failed(不扩本地枚举);created/pending 继续等;refund 系 no-op 记日志</li>
|
||||
<li><b>D9 配置零新增</b>:PAY_* 三件套已在 viper/production.env/render-env.sh/Bitwarden 链上</li>
|
||||
<li><b>D10 UI 治理(按 2026-07-10 铁律:原型=代码 100% 一致,前端改动先改原型)</b>:授权管理已完成 design-first(原型 license.html / m-license.html 三 tab 已评审提交 13b7274),实现逐像素落地、原型与代码同提交;qr 内嵌态动 PurchaseCard 前仍需先补原型</li>
|
||||
<li><b>D11 授权管理提升(2026-07-10 评审通过并入)</b>:授权管理提升独立一级屏(桌面侧边栏「系统」组 + <code>/license</code>;移动 <code>/me/license</code>),三 tab:授权信息 / 在线购买续费(iOS 合规态整 tab 隐藏)/ 订单管理。「继续支付」=打开下单时存储的 pay_url(不接 retry,维持 D6);「授权续期至」=settle 回写 renewed_to;模型加 <code>pay_url</code>/<code>renewed_to</code> 两列</li>
|
||||
</ul></div>
|
||||
|
||||
<h2>任务分解(8 任务,每任务 TDD + 独立提交)</h2>
|
||||
<div class="card"><ul>
|
||||
<li><span class="chk">1</span><b>表结构 + 存量回填</b> — model 加 AmountMinor/Currency;<code>BackfillPurchaseAmountMinor</code>("2999.00"→299900+CNY,幂等只处理 amount_minor=0 行)挂 main.go;schema.sql / testutil SQLite 建表同步。测试:回填正确、已有值不覆盖</li>
|
||||
<li><span class="chk">2</span><b>下单+查单切 /api/v2</b> — 请求体 {sku, method:"alipay", biz_system, biz_ref, return_url};解析 order_no+session;下单后查单回填;删 productID/prodCache;<code>PurchaseResult</code> 改 {out_trade_no, render_type, payload, amount_minor, currency, subject, <s>pay_url</s>, <s>amount</s>}(弃用字段照 D5 输出);queryOrder 八态。测试:mock v2 双端点断言请求形状/签名可验/兼容字段</li>
|
||||
<li><span class="chk">3</span><b>webhook v2</b> — payNotification 改 event_type/amount_minor/currency(删 amount/trade_no);事件分发(D2)+ nonce 防重放(D3);settle 签名改 int64 + 残单补查(D1);handler Callback 零改动。测试:入账/幂等重投/nonce 重放 401/refund ack/金额差 1 分拒绝/残单兜底</li>
|
||||
<li><span class="chk">4</span><b>reconcile 八态映射</b> — paid→settle;canceled/expired→failed;refund 系 no-op。测试 ×4 状态</li>
|
||||
<li><span class="chk">5</span><b>取消透传</b> — <code>POST /api/v1/license/purchase/:otn/cancel</code>(admin,license 组=LicenseGuard 豁免区)→ pay cancel → canceled==true 才标 failed(false=已付竞态,保持 pending 等入账)。测试:成功/竞态/跨店 404</li>
|
||||
<li><span class="chk">6</span><b>客户端模型层</b> — PurchaseOrder{renderType, payload, amountMinor, currency} + redirectUrl/qrContent getter;<code>yuanFromMinor</code>(299900→"2,999.00")落 core/utils/money.dart;repository +cancelPurchase。测试:fromJson/格式化</li>
|
||||
<li><span class="chk">7</span><b>PurchaseCard 分发</b> — _submit 按 renderType:redirect 外跳 / qr 内嵌 QrImageView(180) / 未知 toast;金额展示 yuanFromMinor;「重新选择」fire-and-forget cancel;pubspec +qr_flutter ^4.1.0。验收:flutter analyze+test、settings golden 无 diff、check_ds_code.mjs 过闸</li>
|
||||
<li><span class="chk">9</span><b>订单列表接口</b> — <code>GET /api/v1/license/purchases</code>:分页/状态筛选/summary(累计·笔数·待支付),仅管理员,跨店隔离;pending 单回 pay_url 供继续支付;LEFT JOIN users 取下单人</li>
|
||||
<li><span class="chk">10</span><b>授权管理独立屏三 tab(双端)</b> — 按已评审原型逐像素实现:路由 <code>/license</code>+侧边栏项+settings 摘除授权子导航;订单 tab 桌面 DsTable+抽屉 / 移动 MCard 流+sheet;继续支付/取消订单/重新购买切购买 tab;settings golden 更新 + 新屏 golden ×3 + fidelity 注册</li>
|
||||
<li><span class="chk">8</span><b>文档同步(收尾)</b> — <code>docs/pay支付对接开发指南.html</code> v2 化(标注 v1 断代);db-schema.html 四新列;CONTRACT 台账回填;用户手册补「授权管理/订单管理」节(两侧同步)</li>
|
||||
</ul>
|
||||
<p style="margin:6px 0 0;font-size:12.5px;color:var(--muted)">执行顺序:后端链 1→2→3→4→5→9 与前端链 6→7→10 交错推进,Task 8 收尾。</p></div>
|
||||
|
||||
<h2>联调 checklist(pay v2 部署后,不阻塞开发)</h2><div class="card"><ul>
|
||||
<li>pay v2 合 main 部署;生产 accounts 配 alipay、seedPlans 套餐落库(seed 现仅 sandbox 开关下执行,需确认)</li>
|
||||
<li>密钥对齐: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</li>
|
||||
<li>¥0.01/¥1 真单走通(jiu payPlans 白名单无 test_liandiao:用 promo ¥1,或临时加 test_liandiao 映射联调后移除):下单→收银台→实付→webhook 续期→轮询 success→写权限即时恢复(InvalidateLicensePhase)</li>
|
||||
<li>阻断 webhook 验证 reconcile 5 分钟查单兜底;接收器临时回 FAIL 观察 60s 重投与幂等</li>
|
||||
<li>官网 checkout(pay_url 兼容字段)+ 旧版客户端购买路径回归</li>
|
||||
<li><b>pay 侧欠账跟进</b>:端型透传补契约 → jiu 加回 client_type 一行 → 真机验证手机拉起支付宝;查单/cancel 无鉴权加固(限流+增熵);refund 事件消费另起任务</li>
|
||||
<li>取消路径:客户端「重新选择」→ pay 订单翻 canceled</li>
|
||||
</ul></div>
|
||||
|
||||
<h2>红线与 DoD</h2><div class="card"><ul>
|
||||
<li>金额禁 float;幂等(60s 无限重投安全);Status/Cancel 带 shop_id(JWT 取);<b>零写真实库</b>(SQLite in-memory + httptest);<b>不部署不发版不打 tag</b></li>
|
||||
<li><code>go build/vet/test ./...</code> + <code>flutter analyze/test</code> + <code>check_ds_code.mjs</code> + <code>check-l1-sync.mjs</code> 全绿后停在本地提交态,todo 标 done 等验收</li>
|
||||
</ul></div>
|
||||
|
||||
<h2>批准后落地流程</h2><div class="card"><ol>
|
||||
<li>计划双产物:.md(checkbox 真相源)落 <code>docs/plans/2026-07-10-pay-v2-integration.md</code> + 本 HTML 落 <code>docs/plans/pay-v2-integration.html</code> 登记 index.html</li>
|
||||
<li>todo 登记(全局 todo skill)并标 doing</li>
|
||||
<li>subagent-driven-development 逐任务派发执行,每任务一提交</li>
|
||||
</ol></div>
|
||||
</body></html>
|
||||
Reference in New Issue
Block a user