3df5867c02
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2989 lines
125 KiB
Markdown
2989 lines
125 KiB
Markdown
# pangolin · 接入 pay v2 统一支付网关 Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现本计划。步骤用 `- [ ]` checkbox 追踪。每个任务自包含:不读全仓也能按锚点+代码落地。
|
||
|
||
> **契约真相源(必读,pay 仓分支 `design/pay-v2` @ `c0c7ecc`,当前 `~/code/pay` 工作区即该分支):**
|
||
> - `~/code/pay/internal/handler/gateway.go` + `internal/router/router.go`——/api/v2 端点(下单/查单/retry/cancel;改状态端点 per-IP 限流 429)。
|
||
> - `~/code/pay/internal/handler/order.go::verifyBizSign` + `internal/util/sign.go::HMACSign`——业务方签名范式。
|
||
> - `~/code/pay/internal/webhook/notifier.go` + `internal/gateway/settle.go`——webhook 出站契约(头/签名/重试/ACK)。
|
||
> - `~/code/pay/docs/pay-v2-unified-gateway-design.html` §4.2——render_type 契约。
|
||
> - 本仓 `CLAUDE.md`「数据层:多数据库」节——双方言纪律;`server/internal/codes/service.go::applySubscription`——订阅叠加语义唯一真相(webhook 开通必须复用,不复制)。
|
||
|
||
**Goal:** pangolin 客户端可以用「支付宝 / USDT(TRC20)」购买 pro 月/季/年三档订阅:server 侧以业务方身份(`biz_system=pangolin`,HMAC 签名)代理 pay v2 下单/查单/换渠道/取消,接收 `payment.succeeded` webhook 后**复用 codes 的订阅叠加语义**开通/续期;client 侧新增购买页(三档 PlanCard)与支付页(按 render_type 多态 + 轮询)。全部单测以 httptest 模拟 pay(签名语义照抄 pay 源码),端到端真联调等 pay 部署后按文末 checklist 执行。**不 merge、不部署、不发版。**
|
||
|
||
**Architecture:**
|
||
|
||
- **server 新包 `internal/pay/`**:`sign.go`(HMAC-SHA256 + `\n` join + std base64,照抄 pay `util/sign.go`)、`client.go`(出站 PayClient:下单签名,查单/retry/cancel 免签——pay 侧只在 create 且 biz_system 非空时验签)、`catalog.go`(sku→plan/days/展示价 单源)、`store.go`(购买台账 `pay_purchases`)、`handler.go`(JWT 保护的 App 代理端点 `/v1/pay/*`)、`webhook.go`(`POST /v1/webhook/pay` 接收器)。
|
||
- **叠加复用**:`internal/codes` 内新增导出方法 `Service.GrantPaidSubscriptionTx(ctx, tx, userID, plan, days, ref)`——在**调用方事务**里走 `GetPlanIDTx → applySubscription(source='pay')`,与兑换码同一段代码;`applySubscription`/`Store.CreateSubscription` 参数化 `source`(兑换路径传 `'code'`,行为零变化)。
|
||
- **幂等三层**:传输层 nonce SETNX(±5min 窗口,rdb 可空降级)→ **业务层以 `out_trade_no` 为幂等键**(pay 重投每次换新 nonce,唯一可靠键是订单号):台账行 `status created→paid` 在锁内 CAS,已 paid 直接回 SUCCESS → 兜底:台账缺行(下单后本地写失败)时按 `biz_ref`(=用户 uuid)定位用户补建台账再开通,webhook 自足可修复。
|
||
- **client**:`services/payment_api.dart`(经 ApiClient 走 server 代理,JWT 自动注入)+ `state/payment_provider.dart`(StateNotifier 支付流控制器,3s 轮询查单直到本地 `activated`)+ `screens/purchase_page.dart`/`payment_page.dart`(组件全部取自 `client/lib/widgets/` 真相源:PlanCard/PangolinButton/showPangolinToast/PangolinIcons+既有 inline card 配方,不新造样式)。
|
||
|
||
---
|
||
|
||
## 关键决策(已定,含依据)
|
||
|
||
| 决策点 | 结论 | 依据 |
|
||
|---|---|---|
|
||
| **sku 命名** | `pro_month` / `pro_quarter` / `pro_year`(= pay `products.biz_code`,webhook `product_biz_code` 原样回带) | 计划前提定死;pay 侧 `sku` 即 `biz_code`(`gateway.go` → `DBProductResolver.Resolve` 按 `biz_code+active` 查) |
|
||
| **定价** | CNY 29.99/68.88/199.99(pay `products.price` 元字符串,alipay CNY 走 fallback);USDT 微单位价配 `product_prices`(见部署附录,汇率可调) | 既有决策;pay 结算币种由 method `SettleCurrencies[0]` 决定,客户端永不传金额 |
|
||
| **时长映射** | month=31 / quarter=92 / year=366 天(覆盖大月/最长季,对用户宽松),单源在 `internal/pay/catalog.go` | subscriptions 以 `duration_days` 叠加(codes 既有语义),pay 侧无时长概念 |
|
||
| **薄表设计** | 单表 `pay_purchases`:`out_trade_no UNIQUE` 既是 biz_ref↔订单映射又是 webhook 幂等台账(status `created/paid/canceled` + 锁内 CAS),不另建 events 表 | pay 重投同事件 nonce 每次不同(`notifier.go` 每 attempt `uuid.NewString()`),nonce 表防不了重投;订单号才是稳定幂等键 |
|
||
| **webhook 幂等键** | `out_trade_no` + 行级锁(`dialect.LockForUpdate()`)内 status CAS;nonce SETNX 仅作 ±5min 传输重放防御(rdb=nil 时跳过) | 同上;开通与台账翻转同一 `*sql.Tx`,崩溃安全(未 commit 则 pay 重投再来一次) |
|
||
| **subscriptions.source 扩 `'pay'`** | 迁移 000021:MySQL `MODIFY ENUM`,SQLite 重建表(CHECK 不可 ALTER) | 数据诚实:运营查询要能区分付费/卡密/试用;台账 join 只能兜底不便审计 |
|
||
| **biz_ref = users.uuid** | 下单时带用户 uuid;webhook 主定位走 out_trade_no,uuid 作缺行兜底与交叉校验 | uuid 不透出自增 id、稳定、已有 UNIQUE 索引 |
|
||
| **叠加复用方式** | codes 包内导出 `GrantPaidSubscriptionTx`(吃调用方 tx),`applySubscription` 原封不动只加 `source` 参数 | 「复用同一段叠加语义,可提炼共用,不复制」;pay 包 import codes 单向无环 |
|
||
| **配置** | `PAY_BASE_URL` / `PAY_BIZ_SYSTEM`(默认 `pangolin`)/ `PAY_BIZ_SECRET`,在 `mountV1` 里 `os.Getenv`;`PAY_BASE_URL` 空则不挂载 /v1/pay(webhook 同) | 与 SMTP_HOST/WEBHOOK_SECRET 等既有可选特性同风格(`cmd/server/main.go` mountV1) |
|
||
| **价格展示** | 新端点 `GET /v1/pay/catalog` 下发三档(server catalog 单源);不复用 `/v1/plans`(plans 表一 code 一行,装不下月/季/年三档) | `migrations/000014` plans 只有单 price_cents/period;catalog 展示价与 pay 种子价一致性列入联调 checklist |
|
||
| **409 currency_mismatch** | server 原样映射为 409 `CURRENCY_MISMATCH`;client 收到后自动 `cancel 旧单 → 新 method 重新下单`(pay 契约:换结算币种必须新单) | pay `gateway.go` Retry 明确 409;`AuthApiException` 补 `code` 字段以便 client 分辨 |
|
||
| **qr 渲染** | 预留:显示 `display_amount` + 复制 `qr_content` 兜底文案,不引入 qr_flutter | scope 定义「qr 预留」;当面付非首发渠道 |
|
||
|
||
## Global Constraints
|
||
|
||
- **分支/worktree**:全部工作在本 worktree(`.claude/worktrees/pay-v2-integration`,基于 codes 集成分支)。**只加代码不动 codes 集成已有行为**;`todo/` 不在本计划内动。
|
||
- **双方言铁律**(仓根 CLAUDE.md):SQL 一律 `?` 占位、时间 Go 端算好传参、禁 `NOW()`/`UTC_TIMESTAMP()`/`FIELD()`;upsert 走 `dialect.Upsert`、行锁走 `dialect.LockForUpdate()`;迁移 `migrations/{mysql,sqlite}/000021_*` 两套同号。
|
||
- **文案脱敏**:所有用户可见文案(apierr 双语、Flutter l10n)禁「VPN/翻墙/科学上网」等红线词(CI `ci/scan-redline.sh` 扫描)。
|
||
- **金额纪律**:金额一律 int64 最小单位(CNY=分,USDT=微);**客户端只传 sku+method,绝不传金额**;展示价仅 catalog,扣款额以 pay 为准。
|
||
- **HMAC 契约逐字节照抄**(pay `internal/util/sign.go`):HMAC-SHA256,message = `strings.Join(parts, "\n")`,**std base64**(非 hex 非 url-safe);入站/出站 parts 均为 `[system, ts, nonce, rawBody]`;ts=unix 秒十进制字符串,窗口 ±300s。
|
||
- **webhook ACK**:HTTP 200 且 body 含 `SUCCESS`(pay 大写包含判定),否则 pay 以 30s→1h 退避重投最多 12 次——**接收器必须重投安全**。
|
||
- 每个 Task 结束:server 任务跑 `cd /Users/wangjia/code/pangolin/.claude/worktrees/pay-v2-integration/server && go build ./... && go vet ./... && go test ./...`;client 任务跑 `cd .../client && flutter analyze && flutter test`。全绿再 commit(每刀一 commit)。
|
||
- **范围外**:退款接入、订阅(recurring)接入、旧 pangolin-pay 退役、IAP/广告、部署/发版、pay 侧任何代码改动。
|
||
|
||
---
|
||
|
||
## Task 0: Preflight — 现状锚点(全部已于 2026-07-10 逐文件核实,执行前抽查 2-3 条)
|
||
|
||
**pay 侧契约锚点(`~/code/pay`,分支 design/pay-v2 @ c0c7ecc)**
|
||
- 下单:`POST /api/v2/orders`,body `{sku, method, biz_system?, biz_ref?, return_url?, metadata?}`(`handler/gateway.go:24-33` `createV2Request`);metadata 白名单**仅 `is_mobile`/`render`**(`gateway.go:38-41`);`biz_system!=""` 必验签(`order.go:68-93 verifyBizSign`:头 `X-Pay-System/X-Pay-Timestamp/X-Pay-Nonce/X-Pay-Sign`,±300s,`HMACVerify(secret, sign, system, ts, nonce, rawBody)`);body 上限 64KB。
|
||
- 响应包络:成功 `{"data": ...}`,错误 `{"code","message"}`(`util/response.go`)。下单/retry 返回 `OrderResult{order_no, session{render_type, payload, expires_at?}}`;查单 `GET /api/v2/orders/:order_no` 返回 `{order_no, status, subject, amount_minor, currency, paid_at?}`(**无 session**——render payload 只在 create/retry 给)。
|
||
- retry:`POST .../retry` body `{method, metadata?}`;跨币种 → **409 `currency_mismatch`**;非 pending → 409 `order_not_pending`。cancel:`POST .../cancel` → `{"data":{"canceled":bool}}`。改状态 POST 有 per-IP 限流(默认 30/min,429 `rate_limited`)。
|
||
- 签名原语(`util/sign.go`):`HMACSign(secret, parts...) = base64.Std(HMAC-SHA256(secret, strings.Join(parts,"\n")))`。
|
||
- webhook 出站(`webhook/notifier.go:142-191`):POST 到 biz CallbackURL,头 `X-Pay-System/X-Pay-Event/X-Pay-Timestamp/X-Pay-Nonce/X-Pay-Sign`,sign parts=`[bizSystem, ts, nonce, payloadJSON]`;**nonce 每次重投都是新 uuid**;ACK=200+body 含 "SUCCESS";退避 30s×2 上限 1h,maxAttempts 12。
|
||
- `payment.succeeded` payload(`gateway/settle.go:84-95`):`{event_type, out_trade_no, biz_system, biz_ref, product_biz_code, amount_minor(int64), currency, channel, paid_at(RFC3339)}`——**无 refund_id 字段**。
|
||
- render_type payload:`crypto_address` → `{address, amount(string 展示), amount_minor(int64), currency:"USDT", network:"TRC20", contract}`(`provider/crypto/crypto.go:223-235`);`redirect` → `{url}`(`provider/alipay/alipay.go:108-112`);`qr` → `{qr_content, display_amount(元 string), currency:"CNY"}`(`alipay.go:138-147`)。alipay:metadata `render=qr`→qr,`is_mobile` 真值→wap,否则 page。
|
||
- 产品解析:sku=`products.biz_code`(active);crypto 结算 USDT **必须**有 `product_prices(product_id,'USDT',amount_minor)` 行;alipay CNY 可 fallback `products.price` 元字符串。
|
||
|
||
**pangolin server 锚点(本 worktree)**
|
||
- 迁移最高号 **000020**(`migrations/{mysql,sqlite}/000020_codes_lib_legacy_rename.*`;`internal/store/sqlite_migrate_test.go:31-33` 断言 `version = 20`)→ 本计划新增 **000021**。
|
||
- `subscriptions` 表:`source ENUM('trial','code')`(mysql 000002)/ `TEXT CHECK (source IN ('trial','code'))`(sqlite 000002);索引 `idx_subs_user_exp/idx_user_exp`。
|
||
- 叠加语义:`internal/codes/service.go:182-221 applySubscription(ctx, tx, userID, planID, durationDays)`——同 plan 活跃订阅取最晚一条 `max(expires,now)+days` 原地延长(`store.go:131 ExtendSubscription`),否则新建 `max(now, latestSamePlan)+days`(`store.go:155 CreateSubscription`,**source 硬编码 `'code'`**)。`store.go:93 GetPlanIDTx` / `store.go:186 WriteAuditLog` / `store.go:239 BeginTx`;`PlanCode` 常量 `PlanFree/PlanPro/PlanTeam`(store.go:13-20)。
|
||
- 路由:chi;`cmd/server/main.go:222 mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Service)`;webhook 免 JWT 挂 `v1.Post("/webhook/store/codes", ...)`(main.go:363);受保护组 `v1.Group(func(protected chi.Router){ protected.Use(auth.RequireAuth(tm)); ... })`;`getenvDefault` 助手已存在。
|
||
- 鉴权取 uid:`auth.UserIDFromContext(ctx) (int64, bool)`(middleware 注入 `codes.CtxKeyUserID`);错误统一 `apierr.WriteJSON(w, status, *apierr.Error)`,`apierr.New(code, zh, en)`,预置 `ErrUnauthorized/ErrBadRequest/ErrNotFound/ErrInternal/ErrRateLimited`。
|
||
- dialect:`internal/db/dialect.go`——`dbx.DialectForDB(db)`,`LockForUpdate()`(mysql `FOR UPDATE`/sqlite 空),`Upsert(conflictCols, setExprs...)`。
|
||
- 出站 HTTP 范式:`internal/alert/notifier.go`(`http.Client{Timeout}` 存 struct + `NewRequestWithContext` + drain body + BaseURL 测试钩子)。
|
||
- sqlite 测试底座:`internal/codes/sqlite_helper_test.go::openMigratedSQLite`(`store.Open(&config.Config{Driver:"sqlite",DSN:":memory:"})` + `store.MigrateUp(db,"sqlite")` + `store.ApplyCodesLibMigrations`)+ `seedUser(t, db, id)`;plans 三行由迁移种子。httptest 风格:`internal/codes/webhook_test.go`。
|
||
- OpenAPI:CI 只结构校验 `design/server/openapi.yaml`(不校验与代码同步);`server/api/openapi.yaml` 为文档,Task 8 顺手补新端点。
|
||
|
||
**pangolin client 锚点**
|
||
- 导航:`lib/state/navigation_provider.dart` `enum NavView { connect, servers, stats, account, contact, settings, plans, redeem, devices }` + `kAccountSubViews`;4 壳 `lib/shell/{home,desktop,tablet,mobile}_shell.dart` 各有 `switch(view)`;移动端下钻用 `Navigator.push(MaterialPageRoute)`(`screens/account_page.dart:41-47 open()`)。
|
||
- 组件真相源 `lib/widgets/`:`PlanCard{name, price, period, features, ctaLabel, featured, isCurrent, popularLabel, onPressed}`、`PangolinButton{label, onPressed, icon?, variant(primary/secondary/ghost/danger), expand}`、`showPangolinToast(context, msg)`、`ContentTopBar`、`PangolinIcons.{copy?, externalLink, checkCircle, creditCard, arrowLeft}`(copy 需 grep 确认,无则用 `LucideIcons.copy` 补进 `pangolin_icons.dart` 登记簿)、卡片=inline `Container(color: c.surface, radius PangolinRadius.lg, border c.border, shadow PangolinShadow.sm)` 配方(`account_page.dart::_Card`)、子页骨架 `_SubScaffold`(`widgets/account_screens.dart:25`,embedded 双态)。
|
||
- 主题:`final c = context.pangolin;`(PangolinScheme:bg/surface/fg1-3/accent/border/success/danger…);文本 `PangolinText.{display,h1-3,body,sm,caption,mono}`;**金额/地址用 `PangolinText.mono`**。
|
||
- 网络:`lib/services/api_client.dart` `ApiClient.getJson/postJson`(Bearer 自动注入,401 刷新重试一次,非 2xx 抛 `AuthApiException{statusCode, messageZh, messageEn}`——**无 code 字段,Task 6 补**);DI 在 `lib/state/account_providers.dart`(`apiClientProvider`/`accountApiProvider`/`meProvider(AsyncNotifier)`/`plansProvider`)。
|
||
- l10n:`lib/l10n/app_text.dart` 抽象 getter 契约 + `strings_zh.dart`/`strings_en.dart` 双实现;**组件内禁写死字面量**。
|
||
- 测试:`test/helpers/harness.dart::wrapThemed(child, {overrides})` + `setUpAll(disableGoogleFontsFetching)`;网络 mock 用 `package:http/testing.dart MockClient` 或 provider override;golden 在 `test/golden/desktop_pages_golden_test.dart`(本计划**不加新 golden**,取舍见 Self-Review)。
|
||
- pubspec:有 `http/flutter_riverpod`;**无 url_launcher(Task 7 加)、无 qr_flutter(不加)**;剪贴板用 SDK `flutter/services.dart Clipboard`(零依赖)。
|
||
|
||
### Steps
|
||
|
||
- [ ] **Step 1: 基线**——`cd /Users/wangjia/code/pangolin/.claude/worktrees/pay-v2-integration/server && go build ./... && go test ./...`;`cd ../client && flutter analyze && flutter test`。基线不绿先停(带病不开工)。
|
||
- [ ] **Step 2: 抽查锚点**——任选 3 条(建议:pay `util/sign.go` 的 base64.Std、pangolin `codes/store.go` CreateSubscription 的 `'code'` 硬编码、client `AuthApiException` 无 code 字段)确认没漂移;漂移则回报调整计划。
|
||
|
||
---
|
||
|
||
## Task 1: 迁移 000021 — pay_purchases 台账 + subscriptions.source 扩 'pay'(双方言)
|
||
|
||
**产出**:`server/migrations/{mysql,sqlite}/000021_pay_purchases.{up,down}.sql` 四个文件 + `sqlite_migrate_test.go` 断言更新。
|
||
|
||
### Steps
|
||
|
||
- [ ] **Step 1: 先改测试(失败先行)**——`server/internal/store/sqlite_migrate_test.go`:
|
||
- `if v != 20` → `if v != 21`(两处字符串 `want 20` 同步)。
|
||
- 表清单断言数组加入 `"pay_purchases"`。
|
||
- 运行 `go test ./internal/store/ -run TestSQLiteMigrate -count=1` 确认红。
|
||
|
||
- [ ] **Step 2: 写迁移**
|
||
|
||
`server/migrations/mysql/000021_pay_purchases.up.sql`:
|
||
|
||
```sql
|
||
-- pay v2 接入:订阅来源扩 'pay' + 购买台账(biz_ref↔out_trade_no 映射 + webhook 幂等消费)
|
||
ALTER TABLE subscriptions MODIFY source ENUM('trial','code','pay') NOT NULL;
|
||
|
||
CREATE TABLE pay_purchases (
|
||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||
user_id BIGINT UNSIGNED NOT NULL,
|
||
biz_ref CHAR(36) NOT NULL, -- users.uuid 冗余(webhook 兜底定位)
|
||
sku VARCHAR(64) NOT NULL, -- pay products.biz_code
|
||
out_trade_no VARCHAR(64) NOT NULL UNIQUE, -- pay 订单号 = 幂等键
|
||
method VARCHAR(32) NOT NULL, -- alipay | crypto(retry 换渠道时更新)
|
||
status ENUM('created','paid','canceled') NOT NULL DEFAULT 'created',
|
||
amount_minor BIGINT NOT NULL DEFAULT 0, -- webhook 回填,最小单位
|
||
currency VARCHAR(16) NOT NULL DEFAULT '',
|
||
channel VARCHAR(32) NOT NULL DEFAULT '',
|
||
sub_id BIGINT UNSIGNED NULL, -- 开通/延长的 subscriptions.id
|
||
paid_at DATETIME(6) NULL,
|
||
created_at DATETIME(6) NOT NULL,
|
||
updated_at DATETIME(6) NOT NULL,
|
||
INDEX idx_pay_user (user_id, created_at),
|
||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||
```
|
||
|
||
`server/migrations/mysql/000021_pay_purchases.down.sql`:
|
||
|
||
```sql
|
||
DROP TABLE IF EXISTS pay_purchases;
|
||
-- 注意:若已存在 source='pay' 的行,该 MODIFY 会失败——down 前需人工清理(标准 enum 收窄语义)。
|
||
ALTER TABLE subscriptions MODIFY source ENUM('trial','code') NOT NULL;
|
||
```
|
||
|
||
`server/migrations/sqlite/000021_pay_purchases.up.sql`(SQLite CHECK 不可 ALTER → 重建表;显式拷 id 保 AUTOINCREMENT 序列):
|
||
|
||
```sql
|
||
-- 订阅 source 扩 'pay':重建表(CHECK 约束不可 ALTER)
|
||
CREATE TABLE subscriptions_new (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
user_id INTEGER NOT NULL,
|
||
plan_id INTEGER NOT NULL,
|
||
expires_at DATETIME NOT NULL,
|
||
source TEXT NOT NULL CHECK (source IN ('trial','code','pay')),
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||
FOREIGN KEY (plan_id) REFERENCES plans(id)
|
||
);
|
||
INSERT INTO subscriptions_new (id, user_id, plan_id, expires_at, source, created_at)
|
||
SELECT id, user_id, plan_id, expires_at, source, created_at FROM subscriptions;
|
||
DROP TABLE subscriptions;
|
||
ALTER TABLE subscriptions_new RENAME TO subscriptions;
|
||
CREATE INDEX idx_subs_user_exp ON subscriptions (user_id, expires_at);
|
||
|
||
CREATE TABLE pay_purchases (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
user_id INTEGER NOT NULL,
|
||
biz_ref TEXT NOT NULL,
|
||
sku TEXT NOT NULL,
|
||
out_trade_no TEXT NOT NULL UNIQUE,
|
||
method TEXT NOT NULL,
|
||
status TEXT NOT NULL DEFAULT 'created' CHECK (status IN ('created','paid','canceled')),
|
||
amount_minor INTEGER NOT NULL DEFAULT 0,
|
||
currency TEXT NOT NULL DEFAULT '',
|
||
channel TEXT NOT NULL DEFAULT '',
|
||
sub_id INTEGER NULL,
|
||
paid_at DATETIME NULL,
|
||
created_at DATETIME NOT NULL,
|
||
updated_at DATETIME NOT NULL,
|
||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||
);
|
||
CREATE INDEX idx_pay_user ON pay_purchases (user_id, created_at);
|
||
```
|
||
|
||
`server/migrations/sqlite/000021_pay_purchases.down.sql`(反向重建,'pay' 行会因 CHECK 失败——同 mysql 语义,down 前人工清理):
|
||
|
||
```sql
|
||
DROP TABLE IF EXISTS pay_purchases;
|
||
CREATE TABLE subscriptions_old (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
user_id INTEGER NOT NULL,
|
||
plan_id INTEGER NOT NULL,
|
||
expires_at DATETIME NOT NULL,
|
||
source TEXT NOT NULL CHECK (source IN ('trial','code')),
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||
FOREIGN KEY (plan_id) REFERENCES plans(id)
|
||
);
|
||
INSERT INTO subscriptions_old (id, user_id, plan_id, expires_at, source, created_at)
|
||
SELECT id, user_id, plan_id, expires_at, source, created_at FROM subscriptions;
|
||
DROP TABLE subscriptions;
|
||
ALTER TABLE subscriptions_old RENAME TO subscriptions;
|
||
CREATE INDEX idx_subs_user_exp ON subscriptions (user_id, expires_at);
|
||
```
|
||
|
||
- [ ] **Step 3: 验证 + commit**——`go test ./internal/store/ -count=1` 绿(up→down→up 幂等由既有测试覆盖);`go build ./... && go test ./...` 全绿;commit `feat(server): 迁移 000021 pay_purchases 台账 + subscriptions.source 扩 pay`。
|
||
|
||
---
|
||
|
||
## Task 2: codes 包提炼 — GrantPaidSubscriptionTx(叠加语义复用,source 参数化)
|
||
|
||
**产出**:`internal/codes/store.go` `CreateSubscription` 加 `source` 参数;`service.go` `applySubscription` 加 `source` 参数(兑换路径传 `"code"`);新文件 `internal/codes/paygrant.go` 导出事务内授予方法;sqlite 测试。
|
||
|
||
### Steps
|
||
|
||
- [ ] **Step 1: 失败测试**——新文件 `server/internal/codes/paygrant_sqlite_test.go`(复用 `sqlite_helper_test.go` 的 `openMigratedSQLite`/`seedUser`):
|
||
|
||
```go
|
||
package codes_test
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/wangjia/pangolin/server/internal/codes"
|
||
)
|
||
|
||
// 新用户:GrantPaidSubscriptionTx 新建一行 source='pay',到期 = now+days。
|
||
func TestGrantPaidSubscription_NewRow(t *testing.T) {
|
||
db := openMigratedSQLite(t)
|
||
seedUser(t, db, 1)
|
||
store := codes.NewStore(db)
|
||
svc := codes.NewService(store, nil, 5, time.Hour)
|
||
ctx := context.Background()
|
||
|
||
tx, err := store.BeginTx(ctx)
|
||
if err != nil {
|
||
t.Fatalf("BeginTx: %v", err)
|
||
}
|
||
subID, expiresAt, err := svc.GrantPaidSubscriptionTx(ctx, tx, 1, codes.PlanPro, 31, "order:test001")
|
||
if err != nil {
|
||
t.Fatalf("GrantPaidSubscriptionTx: %v", err)
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
t.Fatalf("commit: %v", err)
|
||
}
|
||
if subID == 0 {
|
||
t.Fatal("subID = 0")
|
||
}
|
||
want := time.Now().UTC().AddDate(0, 0, 31)
|
||
if d := expiresAt.Sub(want); d > time.Minute || d < -time.Minute {
|
||
t.Errorf("expiresAt = %v, want ≈ %v", expiresAt, want)
|
||
}
|
||
var source string
|
||
if err := db.QueryRow(`SELECT source FROM subscriptions WHERE id = ?`, subID).Scan(&source); err != nil {
|
||
t.Fatalf("query source: %v", err)
|
||
}
|
||
if source != "pay" {
|
||
t.Errorf("source = %q, want pay", source)
|
||
}
|
||
}
|
||
|
||
// 已有同 plan 活跃订阅:原地延长(行数不变,expires 累加),复用兑换码同一段叠加语义。
|
||
func TestGrantPaidSubscription_StacksOnActive(t *testing.T) {
|
||
db := openMigratedSQLite(t)
|
||
seedUser(t, db, 1)
|
||
store := codes.NewStore(db)
|
||
svc := codes.NewService(store, nil, 5, time.Hour)
|
||
ctx := context.Background()
|
||
|
||
grant := func(days int) time.Time {
|
||
tx, err := store.BeginTx(ctx)
|
||
if err != nil {
|
||
t.Fatalf("BeginTx: %v", err)
|
||
}
|
||
_, exp, err := svc.GrantPaidSubscriptionTx(ctx, tx, 1, codes.PlanPro, days, "order:test002")
|
||
if err != nil {
|
||
t.Fatalf("grant: %v", err)
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
t.Fatalf("commit: %v", err)
|
||
}
|
||
return exp
|
||
}
|
||
first := grant(31)
|
||
second := grant(92)
|
||
|
||
want := first.AddDate(0, 0, 92)
|
||
if d := second.Sub(want); d > time.Minute || d < -time.Minute {
|
||
t.Errorf("stacked expiresAt = %v, want ≈ %v", second, want)
|
||
}
|
||
var n int
|
||
if err := db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id = 1`).Scan(&n); err != nil {
|
||
t.Fatalf("count: %v", err)
|
||
}
|
||
if n != 1 {
|
||
t.Errorf("subscription rows = %d, want 1(原地延长)", n)
|
||
}
|
||
}
|
||
```
|
||
|
||
运行 `go test ./internal/codes/ -run TestGrantPaid -count=1` → 编译失败(方法不存在)即红。
|
||
|
||
- [ ] **Step 2: 实现**
|
||
1. `store.go::CreateSubscription` 签名加尾参 `source string`,INSERT 改 `VALUES (?, ?, ?, ?, ?)` 并传 `source`(替换硬编码 `'code'`)。
|
||
2. `grep -rn "CreateSubscription(" server --include='*.go'` 找全部调用点:`service.go::applySubscription` 一处 + 可能的测试直调(如 `internal/store/sqlite_stores_test.go`)——全部补 `"code"` 实参。
|
||
3. `service.go::applySubscription` 签名加尾参 `source string`,透传给 `CreateSubscription`;`grantSubscription` 回调内调用处传 `"code"`。(**ExtendSubscription 路径不动 source**:行的 source 记录「创建者」,付费延长既有卡密行属预期,台账/审计有完整出处。)
|
||
4. 新文件 `server/internal/codes/paygrant.go`:
|
||
|
||
```go
|
||
package codes
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"time"
|
||
)
|
||
|
||
// GrantPaidSubscriptionTx 在调用方事务里执行支付驱动的订阅开通/续期。
|
||
// 与兑换码走同一段叠加语义(applySubscription):同 plan 有活跃订阅则原地
|
||
// 延长 max(expires,now)+days,否则新建 source='pay' 的一行。ref 为审计
|
||
// 追踪串(如 "pay:<out_trade_no>"),审计写失败不影响事务(与兑换一致)。
|
||
func (svc *Service) GrantPaidSubscriptionTx(
|
||
ctx context.Context, tx *sql.Tx, userID int64, plan PlanCode, days int, ref string,
|
||
) (subID int64, expiresAt time.Time, err error) {
|
||
planID, err := svc.store.GetPlanIDTx(ctx, tx, plan)
|
||
if err != nil {
|
||
return 0, time.Time{}, err
|
||
}
|
||
subID, expiresAt, err = svc.applySubscription(ctx, tx, userID, planID, days, "pay")
|
||
if err != nil {
|
||
return 0, time.Time{}, err
|
||
}
|
||
meta, _ := json.Marshal(map[string]any{
|
||
"plan": string(plan), "duration_days": days, "sub_id": subID,
|
||
})
|
||
_ = svc.store.WriteAuditLog(ctx, tx, formatUserActor(userID), "pay_grant", ref, string(meta))
|
||
return subID, expiresAt, nil
|
||
}
|
||
```
|
||
|
||
> `formatUserActor`:若 `service.go` 里已有 `fmt.Sprintf("user:%d", userID)` 的内联写法而无助手,则在 paygrant.go 里直接 `fmt.Sprintf("user:%d", userID)`(import fmt),不强造助手。
|
||
|
||
- [ ] **Step 3: 验证 + commit**——`go build ./... && go test ./...` 全绿(既有兑换测试必须原样过——source 参数化行为零变化);commit `feat(server): codes.GrantPaidSubscriptionTx 提炼订阅叠加语义供 pay 复用`。
|
||
|
||
---
|
||
|
||
## Task 3: internal/pay — 签名原语 + 出站 PayClient(下单/查单/retry/cancel)
|
||
|
||
**产出**:`server/internal/pay/{sign.go,client.go,client_test.go}`。签名逐字节照抄 pay `internal/util/sign.go`;测试用 httptest 起假 pay,验签语义照抄 `verifyBizSign`。
|
||
|
||
### Steps
|
||
|
||
- [ ] **Step 1: 失败测试** `server/internal/pay/client_test.go`(包内测试,可用未导出符号):
|
||
|
||
```go
|
||
package pay
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strconv"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
const testSecret = "test-biz-secret"
|
||
|
||
// fakePay 模拟 pay v2:create 按 pay verifyBizSign 语义验签(±300s+HMAC),
|
||
// 其余端点免签(与 pay 一致:仅 create 且 biz_system 非空时验签)。
|
||
func fakePay(t *testing.T, handler http.HandlerFunc) *httptest.Server {
|
||
t.Helper()
|
||
srv := httptest.NewServer(handler)
|
||
t.Cleanup(srv.Close)
|
||
return srv
|
||
}
|
||
|
||
func verifyCreateSign(t *testing.T, r *http.Request, body []byte) {
|
||
t.Helper()
|
||
system := r.Header.Get("X-Pay-System")
|
||
ts := r.Header.Get("X-Pay-Timestamp")
|
||
nonce := r.Header.Get("X-Pay-Nonce")
|
||
sign := r.Header.Get("X-Pay-Sign")
|
||
if system != "pangolin" || ts == "" || nonce == "" || sign == "" {
|
||
t.Fatalf("签名头缺失: system=%q ts=%q nonce=%q sign=%q", system, ts, nonce, sign)
|
||
}
|
||
tsi, err := strconv.ParseInt(ts, 10, 64)
|
||
if err != nil {
|
||
t.Fatalf("ts 非 unix 秒: %v", err)
|
||
}
|
||
if d := time.Now().Unix() - tsi; d > 300 || d < -300 {
|
||
t.Fatalf("ts 超 ±300s 窗口: %d", d)
|
||
}
|
||
if !hmacVerify(testSecret, sign, system, ts, nonce, string(body)) {
|
||
t.Fatal("HMAC 校验失败(须为 std base64 + \\n join)")
|
||
}
|
||
}
|
||
|
||
func TestCreateOrder_SignsAndParses(t *testing.T) {
|
||
srv := fakePay(t, func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost || r.URL.Path != "/api/v2/orders" {
|
||
t.Fatalf("意外请求: %s %s", r.Method, r.URL.Path)
|
||
}
|
||
body, _ := io.ReadAll(r.Body)
|
||
verifyCreateSign(t, r, body)
|
||
var req map[string]any
|
||
_ = json.Unmarshal(body, &req)
|
||
if req["sku"] != "pro_month" || req["method"] != "crypto" ||
|
||
req["biz_system"] != "pangolin" || req["biz_ref"] != "uuid-1" {
|
||
t.Fatalf("下单 body 不符: %v", req)
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte(`{"data":{"order_no":"pay123","session":{
|
||
"render_type":"crypto_address",
|
||
"payload":{"address":"Txx","amount":"4.201234","amount_minor":4201234,
|
||
"currency":"USDT","network":"TRC20","contract":"Tcc"},
|
||
"expires_at":"2026-07-10T12:00:00Z"}}}`))
|
||
})
|
||
c := NewClient(srv.URL, "pangolin", testSecret)
|
||
res, err := c.CreateOrder(t.Context(), "pro_month", "crypto", "uuid-1", nil)
|
||
if err != nil {
|
||
t.Fatalf("CreateOrder: %v", err)
|
||
}
|
||
if res.OrderNo != "pay123" || res.Session.RenderType != "crypto_address" {
|
||
t.Fatalf("解析不符: %+v", res)
|
||
}
|
||
if got := res.Session.Payload["address"]; got != "Txx" {
|
||
t.Errorf("payload.address = %v", got)
|
||
}
|
||
if res.Session.ExpiresAt == nil {
|
||
t.Error("expires_at 未解析")
|
||
}
|
||
}
|
||
|
||
func TestGetOrder_ParsesStatus(t *testing.T) {
|
||
srv := fakePay(t, func(w http.ResponseWriter, r *http.Request) {
|
||
if r.URL.Path != "/api/v2/orders/pay123" {
|
||
t.Fatalf("path = %s", r.URL.Path)
|
||
}
|
||
_, _ = w.Write([]byte(`{"data":{"order_no":"pay123","status":"succeeded",
|
||
"subject":"Pangolin 专业版·月付","amount_minor":4201234,"currency":"USDT",
|
||
"paid_at":"2026-07-10T11:00:00Z"}}`))
|
||
})
|
||
c := NewClient(srv.URL, "pangolin", testSecret)
|
||
st, err := c.GetOrder(t.Context(), "pay123")
|
||
if err != nil {
|
||
t.Fatalf("GetOrder: %v", err)
|
||
}
|
||
if st.Status != "succeeded" || st.AmountMinor != 4201234 || st.PaidAt == nil {
|
||
t.Fatalf("解析不符: %+v", st)
|
||
}
|
||
}
|
||
|
||
func TestRetry_CurrencyMismatchMapsToError(t *testing.T) {
|
||
srv := fakePay(t, func(w http.ResponseWriter, r *http.Request) {
|
||
if r.URL.Path != "/api/v2/orders/pay123/retry" {
|
||
t.Fatalf("path = %s", r.URL.Path)
|
||
}
|
||
w.WriteHeader(http.StatusConflict)
|
||
_, _ = w.Write([]byte(`{"code":"currency_mismatch","message":"换渠道需新单"}`))
|
||
})
|
||
c := NewClient(srv.URL, "pangolin", testSecret)
|
||
_, err := c.Retry(t.Context(), "pay123", "alipay", nil)
|
||
var pe *Error
|
||
if !errors.As(err, &pe) || pe.Code != "currency_mismatch" || pe.HTTPStatus != http.StatusConflict {
|
||
t.Fatalf("err = %v, want *pay.Error{409 currency_mismatch}", err)
|
||
}
|
||
}
|
||
|
||
func TestCancel(t *testing.T) {
|
||
srv := fakePay(t, func(w http.ResponseWriter, r *http.Request) {
|
||
if r.URL.Path != "/api/v2/orders/pay123/cancel" {
|
||
t.Fatalf("path = %s", r.URL.Path)
|
||
}
|
||
_, _ = w.Write([]byte(`{"data":{"canceled":true}}`))
|
||
})
|
||
c := NewClient(srv.URL, "pangolin", testSecret)
|
||
ok, err := c.Cancel(t.Context(), "pay123")
|
||
if err != nil || !ok {
|
||
t.Fatalf("Cancel = %v, %v", ok, err)
|
||
}
|
||
}
|
||
```
|
||
|
||
> 若 Go 版本 `t.Context()` 不可用(需 go1.24+;本仓 golang:1.25 应可用),退回 `context.Background()`。
|
||
|
||
- [ ] **Step 2: 实现** `server/internal/pay/sign.go`:
|
||
|
||
```go
|
||
// Package pay 实现 pangolin 作为业务方接入 pay v2 统一支付网关:出站客户端
|
||
// (签名下单/查单/换渠道/取消)、购买台账、App 代理端点与 payment.succeeded
|
||
// webhook 接收器。契约真相源:pay 仓 design/pay-v2 分支(见计划文档头)。
|
||
package pay
|
||
|
||
import (
|
||
"crypto/hmac"
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"strings"
|
||
)
|
||
|
||
// hmacSign 逐字节照抄 pay internal/util/sign.go::HMACSign:
|
||
// HMAC-SHA256(secret, strings.Join(parts, "\n")) → 标准 base64(非 hex/url-safe)。
|
||
func hmacSign(secret string, parts ...string) string {
|
||
mac := hmac.New(sha256.New, []byte(secret))
|
||
mac.Write([]byte(strings.Join(parts, "\n")))
|
||
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||
}
|
||
|
||
func hmacVerify(secret, sig string, parts ...string) bool {
|
||
expected := hmacSign(secret, parts...)
|
||
return hmac.Equal([]byte(expected), []byte(sig))
|
||
}
|
||
|
||
// newNonce 返回 32 字符随机 hex(不新增 uuid 依赖)。
|
||
func newNonce() string {
|
||
b := make([]byte, 16)
|
||
_, _ = rand.Read(b)
|
||
return hex.EncodeToString(b)
|
||
}
|
||
```
|
||
|
||
`server/internal/pay/client.go`:
|
||
|
||
```go
|
||
package pay
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// Session 是 pay 下单/retry 返回的渲染会话(render_type 多态,payload 原样透传给客户端)。
|
||
type Session struct {
|
||
RenderType string `json:"render_type"`
|
||
Payload map[string]any `json:"payload"`
|
||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||
}
|
||
|
||
// OrderResult 对应 pay gateway.OrderResult。
|
||
type OrderResult struct {
|
||
OrderNo string `json:"order_no"`
|
||
Session Session `json:"session"`
|
||
}
|
||
|
||
// OrderStatus 对应 pay gateway.OrderStatusView(查单无 session)。
|
||
type OrderStatus struct {
|
||
OrderNo string `json:"order_no"`
|
||
Status string `json:"status"`
|
||
Subject string `json:"subject"`
|
||
AmountMinor int64 `json:"amount_minor"`
|
||
Currency string `json:"currency"`
|
||
PaidAt *time.Time `json:"paid_at,omitempty"`
|
||
}
|
||
|
||
// Error 是 pay 网关的业务错误({code,message} 包络 + HTTP 状态)。
|
||
type Error struct {
|
||
HTTPStatus int `json:"-"`
|
||
Code string `json:"code"`
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
func (e *Error) Error() string {
|
||
return fmt.Sprintf("pay: HTTP %d %s: %s", e.HTTPStatus, e.Code, e.Message)
|
||
}
|
||
|
||
// Client 是 pay v2 出站客户端。仅 CreateOrder 需业务方 HMAC 签名
|
||
// (pay 只在 create 且 biz_system 非空时验签;查单/retry/cancel 以
|
||
// order_no 为持有凭据,pay 侧免签 + 改状态端点 per-IP 限流)。
|
||
type Client struct {
|
||
baseURL string
|
||
system string
|
||
secret string
|
||
hc *http.Client
|
||
now func() time.Time // 测试注入
|
||
}
|
||
|
||
func NewClient(baseURL, system, secret string) *Client {
|
||
return &Client{
|
||
baseURL: strings.TrimRight(baseURL, "/"),
|
||
system: system,
|
||
secret: secret,
|
||
hc: &http.Client{Timeout: 15 * time.Second},
|
||
now: time.Now,
|
||
}
|
||
}
|
||
|
||
type createOrderReq struct {
|
||
SKU string `json:"sku"`
|
||
Method string `json:"method"`
|
||
BizSystem string `json:"biz_system"`
|
||
BizRef string `json:"biz_ref"`
|
||
ReturnURL string `json:"return_url,omitempty"`
|
||
Metadata map[string]string `json:"metadata,omitempty"`
|
||
}
|
||
|
||
// CreateOrder 签名下单。metadata 只应含 pay 白名单键(is_mobile/render),
|
||
// 由 handler 层过滤;金额永远不出现在请求里(pay 按 sku+结算币种定价)。
|
||
func (c *Client) CreateOrder(ctx context.Context, sku, method, bizRef string, metadata map[string]string) (*OrderResult, error) {
|
||
body, err := json.Marshal(createOrderReq{
|
||
SKU: sku, Method: method, BizSystem: c.system, BizRef: bizRef, Metadata: metadata,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var out OrderResult
|
||
if err := c.do(ctx, http.MethodPost, "/api/v2/orders", body, true, &out); err != nil {
|
||
return nil, err
|
||
}
|
||
return &out, nil
|
||
}
|
||
|
||
func (c *Client) GetOrder(ctx context.Context, orderNo string) (*OrderStatus, error) {
|
||
var out OrderStatus
|
||
if err := c.do(ctx, http.MethodGet, "/api/v2/orders/"+url.PathEscape(orderNo), nil, false, &out); err != nil {
|
||
return nil, err
|
||
}
|
||
return &out, nil
|
||
}
|
||
|
||
type retryReq struct {
|
||
Method string `json:"method"`
|
||
Metadata map[string]string `json:"metadata,omitempty"`
|
||
}
|
||
|
||
// Retry 换支付方式重试。跨结算币种 → pay 回 409 currency_mismatch
|
||
// (*Error),调用方按「取消旧单 + 新单」处理。
|
||
func (c *Client) Retry(ctx context.Context, orderNo, method string, metadata map[string]string) (*OrderResult, error) {
|
||
body, err := json.Marshal(retryReq{Method: method, Metadata: metadata})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var out OrderResult
|
||
if err := c.do(ctx, http.MethodPost, "/api/v2/orders/"+url.PathEscape(orderNo)+"/retry", body, false, &out); err != nil {
|
||
return nil, err
|
||
}
|
||
return &out, nil
|
||
}
|
||
|
||
func (c *Client) Cancel(ctx context.Context, orderNo string) (bool, error) {
|
||
var out struct {
|
||
Canceled bool `json:"canceled"`
|
||
}
|
||
if err := c.do(ctx, http.MethodPost, "/api/v2/orders/"+url.PathEscape(orderNo)+"/cancel", nil, false, &out); err != nil {
|
||
return false, err
|
||
}
|
||
return out.Canceled, nil
|
||
}
|
||
|
||
// do 发送请求并解 {"data":...} 成功包络 / {code,message} 错误包络。
|
||
func (c *Client) do(ctx context.Context, httpMethod, path string, body []byte, signed bool, out any) error {
|
||
req, err := http.NewRequestWithContext(ctx, httpMethod, c.baseURL+path, bytes.NewReader(body))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
if signed {
|
||
ts := strconv.FormatInt(c.now().Unix(), 10)
|
||
nonce := newNonce()
|
||
req.Header.Set("X-Pay-System", c.system)
|
||
req.Header.Set("X-Pay-Timestamp", ts)
|
||
req.Header.Set("X-Pay-Nonce", nonce)
|
||
req.Header.Set("X-Pay-Sign", hmacSign(c.secret, c.system, ts, nonce, string(body)))
|
||
}
|
||
resp, err := c.hc.Do(req)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rb, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||
_ = resp.Body.Close()
|
||
if readErr != nil {
|
||
return readErr
|
||
}
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
pe := &Error{HTTPStatus: resp.StatusCode}
|
||
if json.Unmarshal(rb, pe) != nil || pe.Code == "" {
|
||
pe.Code = "upstream_error"
|
||
pe.Message = strings.TrimSpace(string(rb))
|
||
}
|
||
return pe
|
||
}
|
||
if out == nil {
|
||
return nil
|
||
}
|
||
var env struct {
|
||
Data json.RawMessage `json:"data"`
|
||
}
|
||
if err := json.Unmarshal(rb, &env); err != nil {
|
||
return fmt.Errorf("pay: 解析响应包络: %w", err)
|
||
}
|
||
return json.Unmarshal(env.Data, out)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 验证 + commit**——`go test ./internal/pay/ -count=1` 绿;`go build ./... && go vet ./... && go test ./...` 全绿;commit `feat(server): pay v2 出站客户端(HMAC 签名下单/查单/retry/cancel)`。
|
||
|
||
---
|
||
|
||
## Task 4: internal/pay — catalog + 购买台账 Store + App 代理端点 + mountV1 接线
|
||
|
||
**产出**:`server/internal/pay/{catalog.go,store.go,handler.go,handler_test.go,testutil_test.go}` + `cmd/server/main.go` 接线。端点(全部 JWT 保护):`GET /v1/pay/catalog`、`POST /v1/pay/orders`、`GET /v1/pay/orders/{orderNo}`、`POST /v1/pay/orders/{orderNo}/retry`、`POST /v1/pay/orders/{orderNo}/cancel`;webhook 路由留 Task 5。
|
||
|
||
### Steps
|
||
|
||
- [ ] **Step 1: 失败测试**——先建 `server/internal/pay/testutil_test.go`(sqlite 底座,仿 `internal/codes/sqlite_helper_test.go`,但 pay 是包内测试):
|
||
|
||
```go
|
||
package pay
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/wangjia/pangolin/server/internal/config"
|
||
"github.com/wangjia/pangolin/server/internal/store"
|
||
)
|
||
|
||
func openMigratedSQLite(t *testing.T) *sql.DB {
|
||
t.Helper()
|
||
db, err := store.Open(&config.Config{Driver: "sqlite", DSN: ":memory:"})
|
||
if err != nil {
|
||
t.Fatalf("open: %v", err)
|
||
}
|
||
t.Cleanup(func() { _ = db.Close() })
|
||
if err := store.MigrateUp(db, "sqlite"); err != nil {
|
||
t.Fatalf("migrate: %v", err)
|
||
}
|
||
if err := store.ApplyCodesLibMigrations(context.Background(), db, "sqlite"); err != nil {
|
||
t.Fatalf("lib migrate: %v", err)
|
||
}
|
||
return db
|
||
}
|
||
|
||
// seedUser 造最小 users 行(列以 codes/sqlite_helper_test.go::seedUser 为准,
|
||
// 实现时照抄那份 INSERT——含 uuid/email/pw_hash/dp_uuid 等 NOT NULL 列)。
|
||
func seedUser(t *testing.T, db *sql.DB, id int64, uuid string) {
|
||
t.Helper()
|
||
_, err := db.Exec(
|
||
`INSERT INTO users (id, uuid, email, pw_hash, dp_uuid, status, created_at)
|
||
VALUES (?, ?, ?, 'x', ?, 'active', ?)`,
|
||
id, uuid, uuid+"@t.local", uuid, time.Now().UTC())
|
||
if err != nil {
|
||
t.Fatalf("seedUser: %v", err)
|
||
}
|
||
}
|
||
```
|
||
|
||
再写 `server/internal/pay/handler_test.go`:
|
||
|
||
```go
|
||
package pay
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
|
||
"github.com/wangjia/pangolin/server/internal/codes"
|
||
)
|
||
|
||
// newHandlerRig: 假 pay + sqlite 台账 + chi 路由(URL 参数解析需要真实路由)。
|
||
func newHandlerRig(t *testing.T, payFn http.HandlerFunc) (*chi.Mux, *Store) {
|
||
t.Helper()
|
||
db := openMigratedSQLite(t)
|
||
seedUser(t, db, 1, "uuid-1")
|
||
seedUser(t, db, 2, "uuid-2")
|
||
srv := fakePay(t, payFn)
|
||
st := NewStore(db)
|
||
h := NewHandler(NewClient(srv.URL, "pangolin", testSecret), st, db)
|
||
r := chi.NewRouter()
|
||
r.Get("/v1/pay/catalog", h.Catalog)
|
||
r.Post("/v1/pay/orders", h.CreateOrder)
|
||
r.Get("/v1/pay/orders/{orderNo}", h.GetOrder)
|
||
r.Post("/v1/pay/orders/{orderNo}/retry", h.Retry)
|
||
r.Post("/v1/pay/orders/{orderNo}/cancel", h.Cancel)
|
||
return r, st
|
||
}
|
||
|
||
// authed 注入 uid(auth.RequireAuth 注入的就是 codes.CtxKeyUserID)。
|
||
func authed(r *http.Request, uid int64) *http.Request {
|
||
return r.WithContext(context.WithValue(r.Context(), codes.CtxKeyUserID, uid))
|
||
}
|
||
|
||
func TestCreateOrder_ProxiesAndRecords(t *testing.T) {
|
||
router, st := newHandlerRig(t, func(w http.ResponseWriter, r *http.Request) {
|
||
_, _ = w.Write([]byte(`{"data":{"order_no":"pay001","session":{
|
||
"render_type":"redirect","payload":{"url":"https://alipay.example/x"}}}}`))
|
||
})
|
||
body := []byte(`{"sku":"pro_month","method":"alipay","metadata":{"is_mobile":"1","evil":"x"}}`)
|
||
req := authed(httptest.NewRequest(http.MethodPost, "/v1/pay/orders", bytes.NewReader(body)), 1)
|
||
w := httptest.NewRecorder()
|
||
router.ServeHTTP(w, req)
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("code = %d, body = %s", w.Code, w.Body)
|
||
}
|
||
var resp struct {
|
||
OrderNo string `json:"order_no"`
|
||
Session Session `json:"session"`
|
||
}
|
||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||
if resp.OrderNo != "pay001" || resp.Session.RenderType != "redirect" {
|
||
t.Fatalf("resp = %+v", resp)
|
||
}
|
||
row, err := st.GetForUser(context.Background(), 1, "pay001")
|
||
if err != nil {
|
||
t.Fatalf("台账未落: %v", err)
|
||
}
|
||
if row.SKU != "pro_month" || row.BizRef != "uuid-1" || row.Status != "created" {
|
||
t.Fatalf("台账行不符: %+v", row)
|
||
}
|
||
}
|
||
|
||
func TestCreateOrder_UnknownSKU400(t *testing.T) {
|
||
router, _ := newHandlerRig(t, func(w http.ResponseWriter, r *http.Request) {
|
||
t.Fatal("不该打到 pay")
|
||
})
|
||
body := []byte(`{"sku":"pro_lifetime","method":"alipay"}`)
|
||
req := authed(httptest.NewRequest(http.MethodPost, "/v1/pay/orders", bytes.NewReader(body)), 1)
|
||
w := httptest.NewRecorder()
|
||
router.ServeHTTP(w, req)
|
||
if w.Code != http.StatusBadRequest {
|
||
t.Fatalf("code = %d", w.Code)
|
||
}
|
||
}
|
||
|
||
func TestGetOrder_OwnershipEnforced(t *testing.T) {
|
||
router, st := newHandlerRig(t, func(w http.ResponseWriter, r *http.Request) {
|
||
_, _ = w.Write([]byte(`{"data":{"order_no":"pay001","status":"pending",
|
||
"subject":"s","amount_minor":2999,"currency":"CNY"}}`))
|
||
})
|
||
if err := st.Insert(context.Background(), 1, "uuid-1", "pro_month", "pay001", "alipay"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 属主可查
|
||
w := httptest.NewRecorder()
|
||
router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodGet, "/v1/pay/orders/pay001", nil), 1))
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("owner code = %d", w.Code)
|
||
}
|
||
var resp struct {
|
||
PayStatus string `json:"pay_status"`
|
||
Activated bool `json:"activated"`
|
||
}
|
||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||
if resp.PayStatus != "pending" || resp.Activated {
|
||
t.Fatalf("resp = %+v", resp)
|
||
}
|
||
// 他人 404
|
||
w2 := httptest.NewRecorder()
|
||
router.ServeHTTP(w2, authed(httptest.NewRequest(http.MethodGet, "/v1/pay/orders/pay001", nil), 2))
|
||
if w2.Code != http.StatusNotFound {
|
||
t.Fatalf("other code = %d", w2.Code)
|
||
}
|
||
}
|
||
|
||
func TestRetry_CurrencyMismatchMapped409(t *testing.T) {
|
||
router, st := newHandlerRig(t, func(w http.ResponseWriter, r *http.Request) {
|
||
w.WriteHeader(http.StatusConflict)
|
||
_, _ = w.Write([]byte(`{"code":"currency_mismatch","message":"换渠道需新单"}`))
|
||
})
|
||
_ = st.Insert(context.Background(), 1, "uuid-1", "pro_month", "pay001", "crypto")
|
||
body := []byte(`{"method":"alipay"}`)
|
||
w := httptest.NewRecorder()
|
||
router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodPost, "/v1/pay/orders/pay001/retry", bytes.NewReader(body)), 1))
|
||
if w.Code != http.StatusConflict {
|
||
t.Fatalf("code = %d", w.Code)
|
||
}
|
||
var e struct {
|
||
Code string `json:"code"`
|
||
}
|
||
_ = json.Unmarshal(w.Body.Bytes(), &e)
|
||
if e.Code != "CURRENCY_MISMATCH" {
|
||
t.Fatalf("code = %q, want CURRENCY_MISMATCH", e.Code)
|
||
}
|
||
}
|
||
|
||
func TestCatalog(t *testing.T) {
|
||
router, _ := newHandlerRig(t, nil)
|
||
w := httptest.NewRecorder()
|
||
router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodGet, "/v1/pay/catalog", nil), 1))
|
||
var resp struct {
|
||
Items []CatalogItem `json:"items"`
|
||
}
|
||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||
if len(resp.Items) != 3 || resp.Items[0].SKU != "pro_month" {
|
||
t.Fatalf("catalog = %+v", resp.Items)
|
||
}
|
||
}
|
||
```
|
||
|
||
> 注:`newHandlerRig(t, nil)` 会在 `fakePay` 里传 nil handler——给 `TestCatalog` 单独 new 一个不发请求的 rig 或传空 handler `func(http.ResponseWriter,*http.Request){}`,实现时取后者。
|
||
|
||
- [ ] **Step 2: 实现** `server/internal/pay/catalog.go`:
|
||
|
||
```go
|
||
package pay
|
||
|
||
import "github.com/wangjia/pangolin/server/internal/codes"
|
||
|
||
// CatalogItem 是可购档位。SKU 与 pay 侧 products.biz_code 一一对应
|
||
// (webhook product_biz_code 原样回带);PriceMinor 仅展示(CNY 分),
|
||
// 实际扣款以 pay 侧 ProductPrice/price 为准——一致性列入联调 checklist。
|
||
type CatalogItem struct {
|
||
SKU string `json:"sku"`
|
||
Plan string `json:"plan"`
|
||
Days int `json:"days"`
|
||
PriceMinor int64 `json:"price_minor"`
|
||
Currency string `json:"currency"`
|
||
}
|
||
|
||
// Catalog 三档单源。时长取宽松口径(31/92/366 覆盖大月与最长季)。
|
||
var Catalog = []CatalogItem{
|
||
{SKU: "pro_month", Plan: string(codes.PlanPro), Days: 31, PriceMinor: 2999, Currency: "CNY"},
|
||
{SKU: "pro_quarter", Plan: string(codes.PlanPro), Days: 92, PriceMinor: 6888, Currency: "CNY"},
|
||
{SKU: "pro_year", Plan: string(codes.PlanPro), Days: 366, PriceMinor: 19999, Currency: "CNY"},
|
||
}
|
||
|
||
func CatalogBySKU(sku string) (CatalogItem, bool) {
|
||
for _, it := range Catalog {
|
||
if it.SKU == sku {
|
||
return it, true
|
||
}
|
||
}
|
||
return CatalogItem{}, false
|
||
}
|
||
```
|
||
|
||
`server/internal/pay/store.go`(台账;时间全部 Go 端算好传 `?`,行锁走 `dialect.LockForUpdate()`):
|
||
|
||
```go
|
||
package pay
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"fmt"
|
||
"time"
|
||
|
||
dbx "github.com/wangjia/pangolin/server/internal/db"
|
||
)
|
||
|
||
// PurchaseRow 是 pay_purchases 一行:biz_ref↔out_trade_no 映射 + webhook 幂等台账。
|
||
type PurchaseRow struct {
|
||
ID int64
|
||
UserID int64
|
||
BizRef string
|
||
SKU string
|
||
OutTradeNo string
|
||
Method string
|
||
Status string // created | paid | canceled
|
||
AmountMinor int64
|
||
Currency string
|
||
Channel string
|
||
SubID sql.NullInt64
|
||
PaidAt sql.NullTime
|
||
}
|
||
|
||
type Store struct {
|
||
db *sql.DB
|
||
dialect dbx.Dialect
|
||
}
|
||
|
||
func NewStore(db *sql.DB) *Store {
|
||
return &Store{db: db, dialect: dbx.DialectForDB(db)}
|
||
}
|
||
|
||
func (s *Store) BeginTx(ctx context.Context) (*sql.Tx, error) {
|
||
return s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
|
||
}
|
||
|
||
// Insert 下单成功后落台账(status=created)。
|
||
func (s *Store) Insert(ctx context.Context, userID int64, bizRef, sku, outTradeNo, method string) error {
|
||
now := time.Now().UTC()
|
||
_, err := s.db.ExecContext(ctx,
|
||
`INSERT INTO pay_purchases (user_id, biz_ref, sku, out_trade_no, method, status, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, 'created', ?, ?)`,
|
||
userID, bizRef, sku, outTradeNo, method, now, now)
|
||
if err != nil {
|
||
return fmt.Errorf("pay.Store.Insert: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
const purchaseCols = `id, user_id, biz_ref, sku, out_trade_no, method, status,
|
||
amount_minor, currency, channel, sub_id, paid_at`
|
||
|
||
func scanPurchase(row *sql.Row) (*PurchaseRow, error) {
|
||
var p PurchaseRow
|
||
if err := row.Scan(&p.ID, &p.UserID, &p.BizRef, &p.SKU, &p.OutTradeNo, &p.Method,
|
||
&p.Status, &p.AmountMinor, &p.Currency, &p.Channel, &p.SubID, &p.PaidAt); err != nil {
|
||
return nil, err
|
||
}
|
||
return &p, nil
|
||
}
|
||
|
||
// GetForUser 按 (userID, outTradeNo) 取行——所有权校验由查询本身完成。
|
||
func (s *Store) GetForUser(ctx context.Context, userID int64, outTradeNo string) (*PurchaseRow, error) {
|
||
return scanPurchase(s.db.QueryRowContext(ctx,
|
||
`SELECT `+purchaseCols+` FROM pay_purchases WHERE user_id = ? AND out_trade_no = ?`,
|
||
userID, outTradeNo))
|
||
}
|
||
|
||
// LockByOutTradeNoTx 事务内锁行(mysql FOR UPDATE;sqlite 空后缀,靠
|
||
// _txlock=immediate 串行化——与 codes 兑换同一套悲观语义)。
|
||
func (s *Store) LockByOutTradeNoTx(ctx context.Context, tx *sql.Tx, outTradeNo string) (*PurchaseRow, error) {
|
||
q := `SELECT ` + purchaseCols + ` FROM pay_purchases WHERE out_trade_no = ? ` + s.dialect.LockForUpdate()
|
||
return scanPurchase(tx.QueryRowContext(ctx, q, outTradeNo))
|
||
}
|
||
|
||
// InsertFromWebhookTx 兜底补台账(下单后本地写失败的孤儿单,webhook 按 biz_ref 修复)。
|
||
func (s *Store) InsertFromWebhookTx(ctx context.Context, tx *sql.Tx, userID int64, bizRef, sku, outTradeNo, channel string) (int64, error) {
|
||
now := time.Now().UTC()
|
||
res, err := tx.ExecContext(ctx,
|
||
`INSERT INTO pay_purchases (user_id, biz_ref, sku, out_trade_no, method, status, channel, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, 'created', ?, ?, ?)`,
|
||
userID, bizRef, sku, outTradeNo, channel, channel, now, now)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("pay.Store.InsertFromWebhookTx: %w", err)
|
||
}
|
||
id, _ := res.LastInsertId()
|
||
return id, nil
|
||
}
|
||
|
||
// MarkPaidTx 台账翻转 →paid 并回填结算信息(幂等判定已在锁内完成,直写)。
|
||
func (s *Store) MarkPaidTx(ctx context.Context, tx *sql.Tx, id int64, amountMinor int64, currency, channel string, subID int64, paidAt time.Time) error {
|
||
_, err := tx.ExecContext(ctx,
|
||
`UPDATE pay_purchases SET status = 'paid', amount_minor = ?, currency = ?,
|
||
channel = ?, sub_id = ?, paid_at = ?, updated_at = ?
|
||
WHERE id = ?`,
|
||
amountMinor, currency, channel, subID, paidAt, time.Now().UTC(), id)
|
||
if err != nil {
|
||
return fmt.Errorf("pay.Store.MarkPaidTx: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// UpdateMethod retry 换渠道成功后同步台账(仅未支付单)。
|
||
func (s *Store) UpdateMethod(ctx context.Context, userID int64, outTradeNo, method string) error {
|
||
_, err := s.db.ExecContext(ctx,
|
||
`UPDATE pay_purchases SET method = ?, updated_at = ?
|
||
WHERE user_id = ? AND out_trade_no = ? AND status = 'created'`,
|
||
method, time.Now().UTC(), userID, outTradeNo)
|
||
if err != nil {
|
||
return fmt.Errorf("pay.Store.UpdateMethod: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// MarkCanceled 仅未支付单可取消(paid 行不动——钱已收,开通不回退)。
|
||
func (s *Store) MarkCanceled(ctx context.Context, userID int64, outTradeNo string) error {
|
||
_, err := s.db.ExecContext(ctx,
|
||
`UPDATE pay_purchases SET status = 'canceled', updated_at = ?
|
||
WHERE user_id = ? AND out_trade_no = ? AND status = 'created'`,
|
||
time.Now().UTC(), userID, outTradeNo)
|
||
if err != nil {
|
||
return fmt.Errorf("pay.Store.MarkCanceled: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SubscriptionExpiry 查开通行的到期时间(查单响应回带给客户端)。
|
||
func (s *Store) SubscriptionExpiry(ctx context.Context, subID int64) (time.Time, error) {
|
||
var exp time.Time
|
||
err := s.db.QueryRowContext(ctx,
|
||
`SELECT expires_at FROM subscriptions WHERE id = ?`, subID).Scan(&exp)
|
||
return exp, err
|
||
}
|
||
```
|
||
|
||
`server/internal/pay/handler.go`:
|
||
|
||
```go
|
||
package pay
|
||
|
||
import (
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"io"
|
||
"log/slog"
|
||
"net/http"
|
||
"time"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
|
||
"github.com/wangjia/pangolin/server/internal/apierr"
|
||
"github.com/wangjia/pangolin/server/internal/auth"
|
||
)
|
||
|
||
// Handler 是面向 App 的下单代理(JWT 保护;user→biz_ref 映射在 server 侧,
|
||
// 客户端只传 sku+method+端型 metadata,永远不传金额)。
|
||
type Handler struct {
|
||
client *Client
|
||
store *Store
|
||
db *sql.DB
|
||
}
|
||
|
||
func NewHandler(client *Client, store *Store, db *sql.DB) *Handler {
|
||
return &Handler{client: client, store: store, db: db}
|
||
}
|
||
|
||
// allowedMetadataKeys 与 pay gateway.go 白名单一致(is_mobile/render)。
|
||
var allowedMetadataKeys = map[string]bool{"is_mobile": true, "render": true}
|
||
|
||
func filterMetadata(in map[string]string) map[string]string {
|
||
if len(in) == 0 {
|
||
return nil
|
||
}
|
||
out := make(map[string]string, len(in))
|
||
for k, v := range in {
|
||
if allowedMetadataKeys[k] {
|
||
out[k] = v
|
||
}
|
||
}
|
||
if len(out) == 0 {
|
||
return nil
|
||
}
|
||
return out
|
||
}
|
||
|
||
func writeJSON(w http.ResponseWriter, v any) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_ = json.NewEncoder(w).Encode(v)
|
||
}
|
||
|
||
// writePayErr 把 pay 网关错误映射为 apierr(脱敏双语;code 透传供客户端分辨)。
|
||
func writePayErr(w http.ResponseWriter, err error) {
|
||
var pe *Error
|
||
if !errors.As(err, &pe) {
|
||
apierr.WriteJSON(w, http.StatusBadGateway,
|
||
apierr.New("PAY_UPSTREAM", "支付服务暂不可用,请稍后重试", "Payment service unavailable, please retry later"))
|
||
return
|
||
}
|
||
switch pe.Code {
|
||
case "currency_mismatch":
|
||
apierr.WriteJSON(w, http.StatusConflict,
|
||
apierr.New("CURRENCY_MISMATCH", "该支付方式结算币种与订单不符,请重新下单", "Settlement currency mismatch, please create a new order"))
|
||
case "order_not_pending":
|
||
apierr.WriteJSON(w, http.StatusConflict,
|
||
apierr.New("ORDER_NOT_PENDING", "订单状态已变化,请刷新后重试", "Order is no longer pending"))
|
||
case "order_not_found", "product_not_found":
|
||
apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound)
|
||
case "rate_limited":
|
||
apierr.WriteJSON(w, http.StatusTooManyRequests, apierr.ErrRateLimited)
|
||
case "unknown_method", "bad_request", "method_not_recurring":
|
||
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
|
||
default: // no_account / no_settle_currency / create_failed / upstream_error…
|
||
apierr.WriteJSON(w, http.StatusBadGateway,
|
||
apierr.New("PAY_UPSTREAM", "支付服务暂不可用,请稍后重试", "Payment service unavailable, please retry later"))
|
||
}
|
||
}
|
||
|
||
// ─── GET /v1/pay/catalog ────────────────────────────────────────────────────
|
||
|
||
func (h *Handler) Catalog(w http.ResponseWriter, _ *http.Request) {
|
||
writeJSON(w, map[string]any{"items": Catalog})
|
||
}
|
||
|
||
// ─── POST /v1/pay/orders ────────────────────────────────────────────────────
|
||
|
||
type createOrderRequest struct {
|
||
SKU string `json:"sku"`
|
||
Method string `json:"method"`
|
||
Metadata map[string]string `json:"metadata,omitempty"`
|
||
}
|
||
|
||
type sessionResponse struct {
|
||
OrderNo string `json:"order_no"`
|
||
Session Session `json:"session"`
|
||
}
|
||
|
||
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
uid, ok := auth.UserIDFromContext(ctx)
|
||
if !ok {
|
||
apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized)
|
||
return
|
||
}
|
||
var req createOrderRequest
|
||
if err := json.NewDecoder(io.LimitReader(r.Body, 16<<10)).Decode(&req); err != nil {
|
||
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
|
||
return
|
||
}
|
||
if _, ok := CatalogBySKU(req.SKU); !ok || req.Method == "" {
|
||
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
|
||
return
|
||
}
|
||
var bizRef string
|
||
err := h.db.QueryRowContext(ctx,
|
||
`SELECT uuid FROM users WHERE id = ? AND status = 'active'`, uid).Scan(&bizRef)
|
||
if err == sql.ErrNoRows {
|
||
apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound)
|
||
return
|
||
} else if err != nil {
|
||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||
return
|
||
}
|
||
|
||
res, err := h.client.CreateOrder(ctx, req.SKU, req.Method, bizRef, filterMetadata(req.Metadata))
|
||
if err != nil {
|
||
writePayErr(w, err)
|
||
return
|
||
}
|
||
if err := h.store.Insert(ctx, uid, bizRef, req.SKU, res.OrderNo, req.Method); err != nil {
|
||
// 不吞单:webhook 会按 biz_ref 兜底补台账,这里记日志便于追查。
|
||
slog.Error("pay: 台账写入失败(webhook 将按 biz_ref 兜底)", "order_no", res.OrderNo, "err", err)
|
||
}
|
||
writeJSON(w, sessionResponse{OrderNo: res.OrderNo, Session: res.Session})
|
||
}
|
||
|
||
// ─── GET /v1/pay/orders/{orderNo} ───────────────────────────────────────────
|
||
|
||
type orderStatusResponse struct {
|
||
OrderNo string `json:"order_no"`
|
||
PayStatus string `json:"pay_status"` // pay 侧状态词汇原样透传
|
||
Activated bool `json:"activated"` // 本地台账已消费(权益已开通)——客户端轮询以此为成功判据
|
||
ExpiresAt *string `json:"expires_at,omitempty"`
|
||
}
|
||
|
||
func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
uid, ok := auth.UserIDFromContext(ctx)
|
||
if !ok {
|
||
apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized)
|
||
return
|
||
}
|
||
orderNo := chi.URLParam(r, "orderNo")
|
||
row, err := h.store.GetForUser(ctx, uid, orderNo)
|
||
if err == sql.ErrNoRows {
|
||
apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound)
|
||
return
|
||
} else if err != nil {
|
||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||
return
|
||
}
|
||
st, err := h.client.GetOrder(ctx, orderNo)
|
||
if err != nil {
|
||
writePayErr(w, err)
|
||
return
|
||
}
|
||
resp := orderStatusResponse{OrderNo: orderNo, PayStatus: st.Status, Activated: row.Status == "paid"}
|
||
if row.SubID.Valid {
|
||
if exp, err := h.store.SubscriptionExpiry(ctx, row.SubID.Int64); err == nil {
|
||
s := exp.UTC().Format(time.RFC3339)
|
||
resp.ExpiresAt = &s
|
||
}
|
||
}
|
||
writeJSON(w, resp)
|
||
}
|
||
|
||
// ─── POST /v1/pay/orders/{orderNo}/retry ────────────────────────────────────
|
||
|
||
type retryOrderRequest struct {
|
||
Method string `json:"method"`
|
||
Metadata map[string]string `json:"metadata,omitempty"`
|
||
}
|
||
|
||
func (h *Handler) Retry(w http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
uid, ok := auth.UserIDFromContext(ctx)
|
||
if !ok {
|
||
apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized)
|
||
return
|
||
}
|
||
orderNo := chi.URLParam(r, "orderNo")
|
||
var req retryOrderRequest
|
||
if err := json.NewDecoder(io.LimitReader(r.Body, 16<<10)).Decode(&req); err != nil || req.Method == "" {
|
||
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
|
||
return
|
||
}
|
||
if _, err := h.store.GetForUser(ctx, uid, orderNo); err == sql.ErrNoRows {
|
||
apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound)
|
||
return
|
||
} else if err != nil {
|
||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||
return
|
||
}
|
||
res, err := h.client.Retry(ctx, orderNo, req.Method, filterMetadata(req.Metadata))
|
||
if err != nil {
|
||
writePayErr(w, err)
|
||
return
|
||
}
|
||
_ = h.store.UpdateMethod(ctx, uid, orderNo, req.Method)
|
||
writeJSON(w, sessionResponse{OrderNo: res.OrderNo, Session: res.Session})
|
||
}
|
||
|
||
// ─── POST /v1/pay/orders/{orderNo}/cancel ───────────────────────────────────
|
||
|
||
func (h *Handler) Cancel(w http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
uid, ok := auth.UserIDFromContext(ctx)
|
||
if !ok {
|
||
apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized)
|
||
return
|
||
}
|
||
orderNo := chi.URLParam(r, "orderNo")
|
||
if _, err := h.store.GetForUser(ctx, uid, orderNo); err == sql.ErrNoRows {
|
||
apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound)
|
||
return
|
||
} else if err != nil {
|
||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||
return
|
||
}
|
||
canceled, err := h.client.Cancel(ctx, orderNo)
|
||
if err != nil {
|
||
writePayErr(w, err)
|
||
return
|
||
}
|
||
if canceled {
|
||
_ = h.store.MarkCanceled(ctx, uid, orderNo)
|
||
}
|
||
writeJSON(w, map[string]bool{"canceled": canceled})
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: mountV1 接线**——`cmd/server/main.go`(import 加 `"github.com/wangjia/pangolin/server/internal/pay"`):
|
||
|
||
在 Codes 构造块之后加:
|
||
|
||
```go
|
||
// ── Pay(pay v2 统一支付网关;PAY_BASE_URL 未配则整组不挂载)──────────────
|
||
var payHandler *pay.Handler
|
||
if payBase := os.Getenv("PAY_BASE_URL"); payBase != "" {
|
||
paySystem := getenvDefault("PAY_BIZ_SYSTEM", "pangolin")
|
||
paySecret := os.Getenv("PAY_BIZ_SECRET")
|
||
payClient := pay.NewClient(payBase, paySystem, paySecret)
|
||
payStore := pay.NewStore(sqlDB)
|
||
payHandler = pay.NewHandler(payClient, payStore, sqlDB)
|
||
} else {
|
||
log.Printf("PAY_BASE_URL 未配置 — /v1/pay 支付端点不挂载")
|
||
}
|
||
```
|
||
|
||
受保护组内(`protected.Get("/plans", ...)` 附近)加:
|
||
|
||
```go
|
||
if payHandler != nil {
|
||
protected.Get("/pay/catalog", payHandler.Catalog)
|
||
protected.Post("/pay/orders", payHandler.CreateOrder)
|
||
protected.Get("/pay/orders/{orderNo}", payHandler.GetOrder)
|
||
protected.Post("/pay/orders/{orderNo}/retry", payHandler.Retry)
|
||
protected.Post("/pay/orders/{orderNo}/cancel", payHandler.Cancel)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 验证 + commit**——`go build ./... && go vet ./... && go test ./...` 全绿;commit `feat(server): /v1/pay 下单代理端点 + 购买台账(JWT 鉴权,user→biz_ref 映射)`。
|
||
|
||
---
|
||
|
||
## Task 5: webhook 接收器 — 验签/时间窗/nonce → 幂等开通(复用叠加语义)→ 回 SUCCESS
|
||
|
||
**产出**:`server/internal/pay/{webhook.go,webhook_sqlite_test.go}` + mountV1 webhook 路由。**重投安全是硬指标**:pay 以 30s→1h 退避重投 12 次,每次 nonce 都是新的——幂等只能靠 `out_trade_no` 锁内 CAS。
|
||
|
||
### Steps
|
||
|
||
- [ ] **Step 1: 失败测试** `server/internal/pay/webhook_sqlite_test.go`:
|
||
|
||
```go
|
||
package pay
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strconv"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/wangjia/pangolin/server/internal/codes"
|
||
)
|
||
|
||
func newWebhookRig(t *testing.T) (*WebhookHandler, *sql.DB, *Store) {
|
||
t.Helper()
|
||
db := openMigratedSQLite(t)
|
||
seedUser(t, db, 1, "uuid-1")
|
||
st := NewStore(db)
|
||
codesSvc := codes.NewService(codes.NewStore(db), nil, 5, time.Hour)
|
||
h := NewWebhookHandler(st, codesSvc, db, nil, "pangolin", testSecret, 5*time.Minute, 15*time.Minute)
|
||
return h, db, st
|
||
}
|
||
|
||
// deliver 按 pay notifier.go 出站语义构造签名请求(每次新 nonce,模拟重投)。
|
||
func deliver(t *testing.T, h *WebhookHandler, payload map[string]any) *httptest.ResponseRecorder {
|
||
t.Helper()
|
||
body, _ := json.Marshal(payload)
|
||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||
nonce := newNonce()
|
||
r := httptest.NewRequest(http.MethodPost, "/v1/webhook/pay", bytes.NewReader(body))
|
||
r.Header.Set("Content-Type", "application/json")
|
||
r.Header.Set("X-Pay-System", "pangolin")
|
||
r.Header.Set("X-Pay-Event", "payment.succeeded")
|
||
r.Header.Set("X-Pay-Timestamp", ts)
|
||
r.Header.Set("X-Pay-Nonce", nonce)
|
||
r.Header.Set("X-Pay-Sign", hmacSign(testSecret, "pangolin", ts, nonce, string(body)))
|
||
w := httptest.NewRecorder()
|
||
h.ServeHTTP(w, r)
|
||
return w
|
||
}
|
||
|
||
func succeededPayload(orderNo, sku string) map[string]any {
|
||
return map[string]any{
|
||
"event_type": "payment.succeeded",
|
||
"out_trade_no": orderNo,
|
||
"biz_system": "pangolin",
|
||
"biz_ref": "uuid-1",
|
||
"product_biz_code": sku,
|
||
"amount_minor": int64(4201234),
|
||
"currency": "USDT",
|
||
"channel": "crypto",
|
||
"paid_at": time.Now().UTC().Format(time.RFC3339),
|
||
}
|
||
}
|
||
|
||
func TestWebhook_HappyPath(t *testing.T) {
|
||
h, db, st := newWebhookRig(t)
|
||
ctx := context.Background()
|
||
if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
w := deliver(t, h, succeededPayload("pay001", "pro_month"))
|
||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "SUCCESS") {
|
||
t.Fatalf("ACK 不符: %d %q(pay 要求 200+body 含 SUCCESS)", w.Code, w.Body.String())
|
||
}
|
||
row, err := st.GetForUser(ctx, 1, "pay001")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if row.Status != "paid" || !row.SubID.Valid || row.AmountMinor != 4201234 || row.Channel != "crypto" {
|
||
t.Fatalf("台账未正确消费: %+v", row)
|
||
}
|
||
var source string
|
||
var expires time.Time
|
||
if err := db.QueryRow(`SELECT source, expires_at FROM subscriptions WHERE id = ?`, row.SubID.Int64).
|
||
Scan(&source, &expires); err != nil {
|
||
t.Fatalf("订阅未开通: %v", err)
|
||
}
|
||
if source != "pay" {
|
||
t.Errorf("source = %q, want pay", source)
|
||
}
|
||
want := time.Now().UTC().AddDate(0, 0, 31)
|
||
if d := expires.Sub(want); d > time.Minute || d < -time.Minute {
|
||
t.Errorf("expires = %v, want ≈ %v(pro_month=31 天)", expires, want)
|
||
}
|
||
}
|
||
|
||
func TestWebhook_RedeliveryIdempotent(t *testing.T) {
|
||
h, db, st := newWebhookRig(t)
|
||
ctx := context.Background()
|
||
_ = st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto")
|
||
deliver(t, h, succeededPayload("pay001", "pro_month"))
|
||
var exp1 time.Time
|
||
_ = db.QueryRow(`SELECT expires_at FROM subscriptions WHERE user_id = 1`).Scan(&exp1)
|
||
|
||
// 重投(新 nonce)必须:200+SUCCESS、订阅行数不变、到期不变。
|
||
w := deliver(t, h, succeededPayload("pay001", "pro_month"))
|
||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "SUCCESS") {
|
||
t.Fatalf("重投未确认: %d %q", w.Code, w.Body.String())
|
||
}
|
||
var n int
|
||
_ = db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id = 1`).Scan(&n)
|
||
if n != 1 {
|
||
t.Fatalf("重投多开了订阅: rows = %d", n)
|
||
}
|
||
var exp2 time.Time
|
||
_ = db.QueryRow(`SELECT expires_at FROM subscriptions WHERE user_id = 1`).Scan(&exp2)
|
||
if !exp1.Equal(exp2) {
|
||
t.Errorf("重投改了到期: %v → %v", exp1, exp2)
|
||
}
|
||
}
|
||
|
||
func TestWebhook_TwoOrdersStack(t *testing.T) {
|
||
h, db, st := newWebhookRig(t)
|
||
ctx := context.Background()
|
||
_ = st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto")
|
||
_ = st.Insert(ctx, 1, "uuid-1", "pro_quarter", "pay002", "crypto")
|
||
deliver(t, h, succeededPayload("pay001", "pro_month"))
|
||
deliver(t, h, succeededPayload("pay002", "pro_quarter"))
|
||
var n int
|
||
_ = db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id = 1`).Scan(&n)
|
||
if n != 1 {
|
||
t.Fatalf("同 plan 应原地叠加: rows = %d", n)
|
||
}
|
||
var expires time.Time
|
||
_ = db.QueryRow(`SELECT expires_at FROM subscriptions WHERE user_id = 1`).Scan(&expires)
|
||
want := time.Now().UTC().AddDate(0, 0, 31+92)
|
||
if d := expires.Sub(want); d > time.Minute || d < -time.Minute {
|
||
t.Errorf("expires = %v, want ≈ %v(31+92 天叠加)", expires, want)
|
||
}
|
||
}
|
||
|
||
// 台账缺行(下单后本地写失败):按 biz_ref 兜底定位用户、补台账、照常开通。
|
||
func TestWebhook_MissingLedgerFallsBackToBizRef(t *testing.T) {
|
||
h, db, st := newWebhookRig(t)
|
||
w := deliver(t, h, succeededPayload("pay-orphan", "pro_year"))
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("code = %d body = %s", w.Code, w.Body.String())
|
||
}
|
||
row, err := st.GetForUser(context.Background(), 1, "pay-orphan")
|
||
if err != nil {
|
||
t.Fatalf("兜底台账未建: %v", err)
|
||
}
|
||
if row.Status != "paid" || row.SKU != "pro_year" {
|
||
t.Fatalf("兜底行不符: %+v", row)
|
||
}
|
||
var n int
|
||
_ = db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id = 1 AND source = 'pay'`).Scan(&n)
|
||
if n != 1 {
|
||
t.Errorf("订阅未开通: %d", n)
|
||
}
|
||
}
|
||
|
||
func TestWebhook_RejectsBadSignatureAndStaleTimestamp(t *testing.T) {
|
||
h, db, _ := newWebhookRig(t)
|
||
body, _ := json.Marshal(succeededPayload("pay001", "pro_month"))
|
||
mk := func(mutate func(r *http.Request)) int {
|
||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||
nonce := newNonce()
|
||
r := httptest.NewRequest(http.MethodPost, "/v1/webhook/pay", bytes.NewReader(body))
|
||
r.Header.Set("X-Pay-System", "pangolin")
|
||
r.Header.Set("X-Pay-Timestamp", ts)
|
||
r.Header.Set("X-Pay-Nonce", nonce)
|
||
r.Header.Set("X-Pay-Sign", hmacSign(testSecret, "pangolin", ts, nonce, string(body)))
|
||
mutate(r)
|
||
w := httptest.NewRecorder()
|
||
h.ServeHTTP(w, r)
|
||
return w.Code
|
||
}
|
||
if c := mk(func(r *http.Request) { r.Header.Set("X-Pay-Sign", "AAAA") }); c != http.StatusUnauthorized {
|
||
t.Errorf("坏签名 code = %d, want 401", c)
|
||
}
|
||
if c := mk(func(r *http.Request) { r.Header.Set("X-Pay-System", "jiu") }); c != http.StatusUnauthorized {
|
||
t.Errorf("错 system code = %d, want 401", c)
|
||
}
|
||
if c := mk(func(r *http.Request) {
|
||
stale := strconv.FormatInt(time.Now().Add(-10*time.Minute).Unix(), 10)
|
||
r.Header.Set("X-Pay-Timestamp", stale)
|
||
// 注意:重签,否则先挂在签名而非时间窗上
|
||
nonce := r.Header.Get("X-Pay-Nonce")
|
||
r.Header.Set("X-Pay-Sign", hmacSign(testSecret, "pangolin", stale, nonce, string(body)))
|
||
}); c != http.StatusUnauthorized {
|
||
t.Errorf("过期 ts code = %d, want 401", c)
|
||
}
|
||
var n int
|
||
_ = db.QueryRow(`SELECT COUNT(*) FROM subscriptions`).Scan(&n)
|
||
if n != 0 {
|
||
t.Errorf("拒绝路径不得开通: %d", n)
|
||
}
|
||
}
|
||
|
||
// 未知 sku(catalog 漂移)→ 500,pay 会重投,期间可修 catalog 后自愈。
|
||
func TestWebhook_UnknownSKU500(t *testing.T) {
|
||
h, _, _ := newWebhookRig(t)
|
||
w := deliver(t, h, succeededPayload("pay001", "pro_lifetime"))
|
||
if w.Code != http.StatusInternalServerError {
|
||
t.Fatalf("code = %d, want 500", w.Code)
|
||
}
|
||
}
|
||
|
||
// 白名单外事件(如未来误配 refund.succeeded):确认不处理,避免无谓重投 12 次。
|
||
func TestWebhook_IgnoredEventAcked(t *testing.T) {
|
||
h, db, _ := newWebhookRig(t)
|
||
p := succeededPayload("pay001", "pro_month")
|
||
p["event_type"] = "refund.succeeded"
|
||
w := deliver(t, h, p)
|
||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "SUCCESS") {
|
||
t.Fatalf("白名单外事件应直接确认: %d", w.Code)
|
||
}
|
||
var n int
|
||
_ = db.QueryRow(`SELECT COUNT(*) FROM subscriptions`).Scan(&n)
|
||
if n != 0 {
|
||
t.Errorf("不得开通: %d", n)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 实现** `server/internal/pay/webhook.go`:
|
||
|
||
```go
|
||
package pay
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"net/http"
|
||
"strconv"
|
||
"time"
|
||
|
||
"github.com/redis/go-redis/v9"
|
||
|
||
"github.com/wangjia/pangolin/server/internal/codes"
|
||
)
|
||
|
||
// Granter 抽象 codes.Service 的支付授予入口(测试可替身;生产传 *codes.Service)。
|
||
type Granter interface {
|
||
GrantPaidSubscriptionTx(ctx context.Context, tx *sql.Tx, userID int64, plan codes.PlanCode, days int, ref string) (int64, time.Time, error)
|
||
}
|
||
|
||
// WebhookHandler 接收 pay 的 payment.succeeded 出站 webhook。
|
||
// 验签与 pay verifyBizSign 对称:同 secret,parts=[system, ts, nonce, rawBody],
|
||
// ±tolerance 时间窗。幂等三层:nonce SETNX(传输重放)→ out_trade_no 锁内
|
||
// CAS(业务幂等,重投唯一可靠键)→ biz_ref 兜底(台账缺行自修复)。
|
||
type WebhookHandler struct {
|
||
store *Store
|
||
granter Granter
|
||
db *sql.DB
|
||
rdb *redis.Client // 可为 nil:跳过 nonce 层,业务幂等仍成立
|
||
system string
|
||
secret string
|
||
tolerance time.Duration
|
||
nonceTTL time.Duration
|
||
now func() time.Time // 测试注入
|
||
}
|
||
|
||
func NewWebhookHandler(store *Store, granter Granter, db *sql.DB, rdb *redis.Client,
|
||
system, secret string, tolerance, nonceTTL time.Duration) *WebhookHandler {
|
||
return &WebhookHandler{store: store, granter: granter, db: db, rdb: rdb,
|
||
system: system, secret: secret, tolerance: tolerance, nonceTTL: nonceTTL, now: time.Now}
|
||
}
|
||
|
||
// webhookEvent 对应 pay settle.go::enqueuePaymentSucceeded 的 payload
|
||
// (注意:payment.succeeded 无 refund_id 字段)。
|
||
type webhookEvent struct {
|
||
EventType string `json:"event_type"`
|
||
OutTradeNo string `json:"out_trade_no"`
|
||
BizSystem string `json:"biz_system"`
|
||
BizRef string `json:"biz_ref"`
|
||
ProductBizCode string `json:"product_biz_code"`
|
||
AmountMinor int64 `json:"amount_minor"`
|
||
Currency string `json:"currency"`
|
||
Channel string `json:"channel"`
|
||
PaidAt string `json:"paid_at"` // RFC3339
|
||
}
|
||
|
||
func (h *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||
body, err := io.ReadAll(io.LimitReader(r.Body, 64<<10))
|
||
if err != nil {
|
||
http.Error(w, "read body", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if !h.verify(r, body) {
|
||
http.Error(w, "signature verification failed", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
// nonce 防重放(仅传输层;pay 每次重投换新 nonce,业务幂等靠 out_trade_no)。
|
||
if h.rdb != nil {
|
||
if nonce := r.Header.Get("X-Pay-Nonce"); nonce != "" {
|
||
ok, err := h.rdb.SetNX(r.Context(), "pay:webhook:nonce:"+nonce, 1, h.nonceTTL).Result()
|
||
if err == nil && !ok {
|
||
writeSuccess(w) // 同 nonce 重放:已处理过,直接确认
|
||
return
|
||
}
|
||
}
|
||
}
|
||
var ev webhookEvent
|
||
if err := json.Unmarshal(body, &ev); err != nil {
|
||
http.Error(w, "bad payload", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if ev.EventType != "payment.succeeded" {
|
||
// 事件白名单外(pay 侧只应配 payment.succeeded):确认不处理,免重投。
|
||
writeSuccess(w)
|
||
return
|
||
}
|
||
if err := h.settle(r.Context(), &ev); err != nil {
|
||
slog.Error("pay webhook 开通失败(pay 将退避重投)", "order_no", ev.OutTradeNo, "err", err)
|
||
http.Error(w, "settle failed", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
writeSuccess(w)
|
||
}
|
||
|
||
// writeSuccess:pay 的 ACK 判据是 HTTP 200 且 body 含 "SUCCESS"(大写包含)。
|
||
func writeSuccess(w http.ResponseWriter) {
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte("SUCCESS"))
|
||
}
|
||
|
||
func (h *WebhookHandler) verify(r *http.Request, body []byte) bool {
|
||
if r.Header.Get("X-Pay-System") != h.system {
|
||
return false
|
||
}
|
||
ts := r.Header.Get("X-Pay-Timestamp")
|
||
nonce := r.Header.Get("X-Pay-Nonce")
|
||
sign := r.Header.Get("X-Pay-Sign")
|
||
if ts == "" || nonce == "" || sign == "" {
|
||
return false
|
||
}
|
||
tsi, err := strconv.ParseInt(ts, 10, 64)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
tol := int64(h.tolerance.Seconds())
|
||
if d := h.now().Unix() - tsi; d > tol || d < -tol {
|
||
return false
|
||
}
|
||
return hmacVerify(h.secret, sign, h.system, ts, nonce, string(body))
|
||
}
|
||
|
||
// settle 幂等开通:锁台账行 → created→paid 翻转 + 同事务 grant(叠加语义
|
||
// 复用 codes.applySubscription)。已 paid 直接返回 nil(重投/并发输家)。
|
||
// canceled 行也照常开通——钱已实收,本地 cancel 只是未支付单的整理。
|
||
func (h *WebhookHandler) settle(ctx context.Context, ev *webhookEvent) error {
|
||
item, ok := CatalogBySKU(ev.ProductBizCode)
|
||
if !ok {
|
||
return fmt.Errorf("未知 product_biz_code %q(与 pay 种子漂移?)", ev.ProductBizCode)
|
||
}
|
||
tx, err := h.store.BeginTx(ctx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
|
||
var purchaseID, userID int64
|
||
row, err := h.store.LockByOutTradeNoTx(ctx, tx, ev.OutTradeNo)
|
||
switch {
|
||
case err == sql.ErrNoRows:
|
||
// 台账缺行(下单后本地写失败)→ 按 biz_ref=用户 uuid 兜底定位补建。
|
||
if err := h.db.QueryRowContext(ctx,
|
||
`SELECT id FROM users WHERE uuid = ? AND status = 'active'`, ev.BizRef).Scan(&userID); err != nil {
|
||
return fmt.Errorf("biz_ref %q 定位用户失败: %w", ev.BizRef, err)
|
||
}
|
||
purchaseID, err = h.store.InsertFromWebhookTx(ctx, tx, userID, ev.BizRef, ev.ProductBizCode, ev.OutTradeNo, ev.Channel)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
case err != nil:
|
||
return err
|
||
case row.Status == "paid":
|
||
return nil // 幂等:已消费,直接 SUCCESS
|
||
default:
|
||
purchaseID, userID = row.ID, row.UserID
|
||
}
|
||
|
||
subID, _, err := h.granter.GrantPaidSubscriptionTx(ctx, tx, userID,
|
||
codes.PlanCode(item.Plan), item.Days, "pay:"+ev.OutTradeNo)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
paidAt, perr := time.Parse(time.RFC3339, ev.PaidAt)
|
||
if perr != nil {
|
||
paidAt = h.now().UTC()
|
||
}
|
||
if err := h.store.MarkPaidTx(ctx, tx, purchaseID, ev.AmountMinor, ev.Currency, ev.Channel, subID, paidAt); err != nil {
|
||
return err
|
||
}
|
||
return tx.Commit()
|
||
}
|
||
```
|
||
|
||
> ⚠️ 一个必须注意的点:兜底路径里 `users` 查询走 `h.db`(事务外只读),`pay_purchases`/`subscriptions` 写全部在 `tx` 内——崩溃在 commit 前则整体回滚,pay 重投后重来,不会出现「开通了但台账没翻转」的半态。
|
||
|
||
- [ ] **Step 3: mountV1 接线**——Task 4 的 Pay 构造块里补 webhook(需要 codesSvc,故整块放 Codes 块之后):
|
||
|
||
```go
|
||
var payWebhook *pay.WebhookHandler
|
||
// (放进 Task 4 的 if payBase != "" 块内)
|
||
payWebhook = pay.NewWebhookHandler(payStore, codesSvc, sqlDB, rdb,
|
||
paySystem, paySecret, 5*time.Minute, 15*time.Minute)
|
||
```
|
||
|
||
路由(webhook 免 JWT,挂在 `v1.Post("/webhook/store/codes", ...)` 旁):
|
||
|
||
```go
|
||
if payWebhook != nil {
|
||
v1.Post("/webhook/pay", payWebhook.ServeHTTP)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 验证 + commit**——`go build ./... && go vet ./... && go test ./...` 全绿;另跑 `./run_sqlite_test.sh` 兜一遍实库;commit `feat(server): pay webhook 接收器(验签/时间窗/nonce + out_trade_no 幂等开通,回 SUCCESS)`。
|
||
|
||
---
|
||
|
||
## Task 6: Flutter — 模型 + PaymentApi + 支付流控制器(轮询)
|
||
|
||
**产出**:`client/lib/models/payment.dart`、`client/lib/services/payment_api.dart`、`client/lib/state/payment_provider.dart`、`AuthApiException` 补 `code` 字段、单测两份。全部经 server 代理(JWT 由 ApiClient 自动注入),客户端只传 sku+method。
|
||
|
||
### Steps
|
||
|
||
- [ ] **Step 1: AuthApiException 补 code(小前置)**——`client/lib/services/auth_api.dart`:
|
||
|
||
```dart
|
||
class AuthApiException implements Exception {
|
||
const AuthApiException({
|
||
required this.statusCode,
|
||
required this.messageZh,
|
||
required this.messageEn,
|
||
this.code,
|
||
});
|
||
|
||
final int statusCode;
|
||
final String messageZh;
|
||
final String messageEn;
|
||
|
||
/// 服务端 apierr 的机器码(如 CURRENCY_MISMATCH),旧调用点可空。
|
||
final String? code;
|
||
|
||
@override
|
||
String toString() => 'AuthApiException($statusCode): $messageZh';
|
||
}
|
||
```
|
||
|
||
`client/lib/services/api_client.dart::_throwFromResponse` 补解析(在既有 zh/en 解析处):
|
||
|
||
```dart
|
||
Never _throwFromResponse(http.Response resp) {
|
||
String zh = '操作失败 (HTTP ${resp.statusCode})';
|
||
String en = 'Request failed (HTTP ${resp.statusCode})';
|
||
String? code;
|
||
try {
|
||
final b = jsonDecode(resp.body) as Map<String, dynamic>;
|
||
zh = b['message_zh'] as String? ?? zh;
|
||
en = b['message_en'] as String? ?? en;
|
||
code = b['code'] as String?;
|
||
} catch (_) {}
|
||
throw AuthApiException(statusCode: resp.statusCode, messageZh: zh, messageEn: en, code: code);
|
||
}
|
||
```
|
||
|
||
跑 `flutter analyze`(命名参数可选,既有构造点零改动)。
|
||
|
||
- [ ] **Step 2: 失败测试** `client/test/unit/payment_api_test.dart`:
|
||
|
||
```dart
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:http/http.dart' as http;
|
||
import 'package:http/testing.dart';
|
||
import 'package:pangolin_vpn/services/api_client.dart';
|
||
import 'package:pangolin_vpn/services/auth_api.dart';
|
||
import 'package:pangolin_vpn/services/payment_api.dart';
|
||
|
||
ApiClient _client(MockClient mock) => ApiClient(
|
||
baseUrl: 'http://x',
|
||
getToken: () => 'tok1',
|
||
refresh: () async => false,
|
||
client: mock,
|
||
);
|
||
|
||
void main() {
|
||
test('catalog 解析三档', () async {
|
||
final api = PaymentApi(_client(MockClient((req) async {
|
||
expect(req.url.path, '/v1/pay/catalog');
|
||
return http.Response(
|
||
'{"items":[{"sku":"pro_month","plan":"pro","days":31,"price_minor":2999,"currency":"CNY"},'
|
||
'{"sku":"pro_quarter","plan":"pro","days":92,"price_minor":6888,"currency":"CNY"},'
|
||
'{"sku":"pro_year","plan":"pro","days":366,"price_minor":19999,"currency":"CNY"}]}',
|
||
200);
|
||
})));
|
||
final items = await api.catalog();
|
||
expect(items.length, 3);
|
||
expect(items.first.sku, 'pro_month');
|
||
expect(items.first.priceLabel(), '¥29.99');
|
||
});
|
||
|
||
test('createOrder 只传 sku+method+metadata,解析 session', () async {
|
||
final api = PaymentApi(_client(MockClient((req) async {
|
||
expect(req.url.path, '/v1/pay/orders');
|
||
expect(req.body.contains('"amount'), isFalse, reason: '客户端绝不传金额');
|
||
return http.Response(
|
||
'{"order_no":"pay1","session":{"render_type":"crypto_address",'
|
||
'"payload":{"address":"Txx","amount":"4.201234","amount_minor":4201234,'
|
||
'"currency":"USDT","network":"TRC20"},"expires_at":"2026-07-10T12:00:00Z"}}',
|
||
200);
|
||
})));
|
||
final order = await api.createOrder(sku: 'pro_month', method: 'crypto');
|
||
expect(order.orderNo, 'pay1');
|
||
expect(order.session.renderType, 'crypto_address');
|
||
expect(order.session.payload['address'], 'Txx');
|
||
expect(order.session.expiresAt, isNotNull);
|
||
});
|
||
|
||
test('orderStatus 解析 activated/expires_at', () async {
|
||
final api = PaymentApi(_client(MockClient((req) async => http.Response(
|
||
'{"order_no":"pay1","pay_status":"succeeded","activated":true,'
|
||
'"expires_at":"2026-08-10T12:00:00Z"}',
|
||
200))));
|
||
final st = await api.orderStatus('pay1');
|
||
expect(st.activated, isTrue);
|
||
expect(st.payStatus, 'succeeded');
|
||
expect(st.expiresAt, isNotNull);
|
||
});
|
||
|
||
test('retry 409 CURRENCY_MISMATCH 抛带 code 的异常', () async {
|
||
final api = PaymentApi(_client(MockClient((req) async => http.Response(
|
||
'{"code":"CURRENCY_MISMATCH","message_zh":"该支付方式结算币种与订单不符,请重新下单",'
|
||
'"message_en":"mismatch"}',
|
||
409))));
|
||
try {
|
||
await api.retry('pay1', method: 'alipay');
|
||
fail('应抛异常');
|
||
} on AuthApiException catch (e) {
|
||
expect(e.statusCode, 409);
|
||
expect(e.code, 'CURRENCY_MISMATCH');
|
||
}
|
||
});
|
||
}
|
||
```
|
||
|
||
`client/test/unit/payment_flow_test.dart`(控制器逻辑,轮询用手动 `pollOnce` 驱动,不依赖真实 Timer):
|
||
|
||
```dart
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:pangolin_vpn/models/payment.dart';
|
||
import 'package:pangolin_vpn/services/auth_api.dart';
|
||
import 'package:pangolin_vpn/services/payment_api.dart';
|
||
import 'package:pangolin_vpn/state/payment_provider.dart';
|
||
|
||
class _FakePaymentApi implements PaymentApi {
|
||
_FakePaymentApi();
|
||
int createCalls = 0, cancelCalls = 0, statusCalls = 0;
|
||
bool activated = false;
|
||
Object? retryError;
|
||
String lastMethod = '';
|
||
|
||
@override
|
||
Future<List<PayCatalogItem>> catalog() async => const [];
|
||
|
||
@override
|
||
Future<PayOrder> createOrder({required String sku, required String method, Map<String, String>? metadata}) async {
|
||
createCalls++;
|
||
lastMethod = method;
|
||
return PayOrder(
|
||
orderNo: 'pay$createCalls',
|
||
session: PaySession(renderType: method == 'crypto' ? 'crypto_address' : 'redirect', payload: const {'url': 'https://x'}, expiresAt: null));
|
||
}
|
||
|
||
@override
|
||
Future<PayOrderStatus> orderStatus(String orderNo) async {
|
||
statusCalls++;
|
||
return PayOrderStatus(orderNo: orderNo, payStatus: activated ? 'succeeded' : 'pending', activated: activated, expiresAt: null);
|
||
}
|
||
|
||
@override
|
||
Future<PayOrder> retry(String orderNo, {required String method, Map<String, String>? metadata}) async {
|
||
if (retryError != null) throw retryError!;
|
||
return PayOrder(orderNo: orderNo, session: PaySession(renderType: 'redirect', payload: const {'url': 'https://y'}, expiresAt: null));
|
||
}
|
||
|
||
@override
|
||
Future<void> cancel(String orderNo) async {
|
||
cancelCalls++;
|
||
}
|
||
}
|
||
|
||
ProviderContainer _container(_FakePaymentApi api) {
|
||
final c = ProviderContainer(overrides: [paymentApiProvider.overrideWithValue(api)]);
|
||
addTearDown(c.dispose);
|
||
return c;
|
||
}
|
||
|
||
void main() {
|
||
const item = PayCatalogItem(sku: 'pro_month', plan: 'pro', days: 31, priceMinor: 2999, currency: 'CNY');
|
||
|
||
test('start → awaitingPayment,pollOnce 到 activated 变 succeeded', () async {
|
||
final api = _FakePaymentApi();
|
||
final c = _container(api);
|
||
final ctl = c.read(paymentFlowProvider.notifier);
|
||
await ctl.start(item, 'crypto');
|
||
expect(c.read(paymentFlowProvider).phase, PaymentPhase.awaitingPayment);
|
||
await ctl.pollOnce();
|
||
expect(c.read(paymentFlowProvider).phase, PaymentPhase.awaitingPayment);
|
||
api.activated = true;
|
||
await ctl.pollOnce();
|
||
expect(c.read(paymentFlowProvider).phase, PaymentPhase.succeeded);
|
||
});
|
||
|
||
test('switchMethod 遇 CURRENCY_MISMATCH → cancel 旧单 + 新 method 重新下单', () async {
|
||
final api = _FakePaymentApi();
|
||
final c = _container(api);
|
||
final ctl = c.read(paymentFlowProvider.notifier);
|
||
await ctl.start(item, 'crypto');
|
||
api.retryError = const AuthApiException(
|
||
statusCode: 409, messageZh: 'x', messageEn: 'x', code: 'CURRENCY_MISMATCH');
|
||
await ctl.switchMethod('alipay');
|
||
expect(api.cancelCalls, 1);
|
||
expect(api.createCalls, 2, reason: '换币种必须新单(pay 契约)');
|
||
expect(api.lastMethod, 'alipay');
|
||
expect(c.read(paymentFlowProvider).order?.orderNo, 'pay2');
|
||
});
|
||
}
|
||
```
|
||
|
||
> 若 `paymentFlowProvider` 的 succeeded 路径里调用 `meProvider.refresh()`,fake 容器里没配 auth 会打真网络——实现时把「刷新 me」做成 best-effort(`try { ... } catch (_) {}`)或经注入回调,保证本测试不需要覆盖 meProvider。
|
||
|
||
- [ ] **Step 3: 实现**
|
||
|
||
`client/lib/models/payment.dart`:
|
||
|
||
```dart
|
||
// payment.dart — pay v2 支付领域模型(server 代理端点的响应形状)。
|
||
// 金额只读展示:price_minor 为 CNY 分;crypto 精确金额在 session.payload 里。
|
||
|
||
class PayCatalogItem {
|
||
const PayCatalogItem({
|
||
required this.sku,
|
||
required this.plan,
|
||
required this.days,
|
||
required this.priceMinor,
|
||
required this.currency,
|
||
});
|
||
|
||
final String sku;
|
||
final String plan;
|
||
final int days;
|
||
final int priceMinor;
|
||
final String currency;
|
||
|
||
factory PayCatalogItem.fromJson(Map<String, dynamic> j) => PayCatalogItem(
|
||
sku: j['sku'] as String,
|
||
plan: j['plan'] as String? ?? 'pro',
|
||
days: (j['days'] as num?)?.toInt() ?? 0,
|
||
priceMinor: (j['price_minor'] as num?)?.toInt() ?? 0,
|
||
currency: j['currency'] as String? ?? 'CNY',
|
||
);
|
||
|
||
String priceLabel() => '¥${(priceMinor / 100).toStringAsFixed(2)}';
|
||
}
|
||
|
||
class PaySession {
|
||
const PaySession({required this.renderType, required this.payload, this.expiresAt});
|
||
|
||
final String renderType; // crypto_address | redirect | qr
|
||
final Map<String, dynamic> payload;
|
||
final DateTime? expiresAt;
|
||
|
||
factory PaySession.fromJson(Map<String, dynamic> j) => PaySession(
|
||
renderType: j['render_type'] as String? ?? '',
|
||
payload: (j['payload'] as Map<String, dynamic>?) ?? const {},
|
||
expiresAt: j['expires_at'] == null ? null : DateTime.tryParse(j['expires_at'] as String),
|
||
);
|
||
}
|
||
|
||
class PayOrder {
|
||
const PayOrder({required this.orderNo, required this.session});
|
||
|
||
final String orderNo;
|
||
final PaySession session;
|
||
|
||
factory PayOrder.fromJson(Map<String, dynamic> j) => PayOrder(
|
||
orderNo: j['order_no'] as String,
|
||
session: PaySession.fromJson((j['session'] as Map<String, dynamic>?) ?? const {}),
|
||
);
|
||
}
|
||
|
||
class PayOrderStatus {
|
||
const PayOrderStatus({
|
||
required this.orderNo,
|
||
required this.payStatus,
|
||
required this.activated,
|
||
this.expiresAt,
|
||
});
|
||
|
||
final String orderNo;
|
||
final String payStatus;
|
||
final bool activated; // server 台账已消费 = 权益已开通(轮询成功判据)
|
||
final DateTime? expiresAt;
|
||
|
||
factory PayOrderStatus.fromJson(Map<String, dynamic> j) => PayOrderStatus(
|
||
orderNo: j['order_no'] as String? ?? '',
|
||
payStatus: j['pay_status'] as String? ?? '',
|
||
activated: j['activated'] as bool? ?? false,
|
||
expiresAt: j['expires_at'] == null ? null : DateTime.tryParse(j['expires_at'] as String),
|
||
);
|
||
}
|
||
```
|
||
|
||
`client/lib/services/payment_api.dart`:
|
||
|
||
```dart
|
||
// payment_api.dart — /v1/pay 代理端点封装(JWT 经 ApiClient 自动注入)。
|
||
import '../models/payment.dart';
|
||
import 'api_client.dart';
|
||
|
||
class PaymentApi {
|
||
PaymentApi(this._c);
|
||
final ApiClient _c;
|
||
|
||
Future<List<PayCatalogItem>> catalog() async {
|
||
final body = await _c.getJson('/v1/pay/catalog');
|
||
final items = (body['items'] as List<dynamic>?) ?? const [];
|
||
return [for (final it in items) PayCatalogItem.fromJson(it as Map<String, dynamic>)];
|
||
}
|
||
|
||
Future<PayOrder> createOrder({
|
||
required String sku,
|
||
required String method,
|
||
Map<String, String>? metadata,
|
||
}) async =>
|
||
PayOrder.fromJson(await _c.postJson('/v1/pay/orders', {
|
||
'sku': sku,
|
||
'method': method,
|
||
if (metadata != null && metadata.isNotEmpty) 'metadata': metadata,
|
||
}));
|
||
|
||
Future<PayOrderStatus> orderStatus(String orderNo) async =>
|
||
PayOrderStatus.fromJson(await _c.getJson('/v1/pay/orders/$orderNo'));
|
||
|
||
Future<PayOrder> retry(String orderNo, {required String method, Map<String, String>? metadata}) async =>
|
||
PayOrder.fromJson(await _c.postJson('/v1/pay/orders/$orderNo/retry', {
|
||
'method': method,
|
||
if (metadata != null && metadata.isNotEmpty) 'metadata': metadata,
|
||
}));
|
||
|
||
Future<void> cancel(String orderNo) async {
|
||
await _c.postJson('/v1/pay/orders/$orderNo/cancel');
|
||
}
|
||
}
|
||
```
|
||
|
||
`client/lib/state/payment_provider.dart`(轮询模板 = `_DevicesAutoRefresh`/`ConnectionController`:`Timer.periodic` + 再入保护 + dispose 取消;测试接缝 = provider override + `pollOnce`):
|
||
|
||
```dart
|
||
// payment_provider.dart — 支付流状态机:选档下单 → 等待支付(轮询查单)→ 成功/失败。
|
||
// 轮询成功判据是 server 的 activated(webhook 已开通),不是 pay 的 succeeded——
|
||
// 保证用户看到「成功」时权益一定已到账。
|
||
import 'dart:async';
|
||
import 'dart:io' show Platform;
|
||
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
||
import '../models/payment.dart';
|
||
import '../services/auth_api.dart';
|
||
import '../services/payment_api.dart';
|
||
import 'account_providers.dart';
|
||
|
||
final paymentApiProvider = Provider<PaymentApi>(
|
||
(ref) => PaymentApi(ref.watch(apiClientProvider)),
|
||
);
|
||
|
||
final payCatalogProvider = FutureProvider<List<PayCatalogItem>>(
|
||
(ref) => ref.watch(paymentApiProvider).catalog(),
|
||
);
|
||
|
||
enum PaymentPhase { idle, creating, awaitingPayment, succeeded, failed }
|
||
|
||
@immutable
|
||
class PaymentFlowState {
|
||
const PaymentFlowState({
|
||
this.phase = PaymentPhase.idle,
|
||
this.item,
|
||
this.method = '',
|
||
this.order,
|
||
this.status,
|
||
this.errorZh,
|
||
this.errorEn,
|
||
});
|
||
|
||
final PaymentPhase phase;
|
||
final PayCatalogItem? item;
|
||
final String method; // alipay | crypto
|
||
final PayOrder? order;
|
||
final PayOrderStatus? status;
|
||
final String? errorZh;
|
||
final String? errorEn;
|
||
|
||
PaymentFlowState copyWith({
|
||
PaymentPhase? phase,
|
||
PayCatalogItem? item,
|
||
String? method,
|
||
PayOrder? order,
|
||
PayOrderStatus? status,
|
||
String? errorZh,
|
||
String? errorEn,
|
||
}) =>
|
||
PaymentFlowState(
|
||
phase: phase ?? this.phase,
|
||
item: item ?? this.item,
|
||
method: method ?? this.method,
|
||
order: order ?? this.order,
|
||
status: status ?? this.status,
|
||
errorZh: errorZh,
|
||
errorEn: errorEn,
|
||
);
|
||
}
|
||
|
||
class PaymentFlowController extends StateNotifier<PaymentFlowState> {
|
||
PaymentFlowController(this._ref) : super(const PaymentFlowState());
|
||
|
||
final Ref _ref;
|
||
Timer? _poll;
|
||
bool _polling = false; // 再入保护(轮询慢于间隔时跳过本拍)
|
||
|
||
static const _pollInterval = Duration(seconds: 3);
|
||
|
||
bool get _isMobile {
|
||
if (kIsWeb) return false;
|
||
return Platform.isAndroid || Platform.isIOS;
|
||
}
|
||
|
||
Map<String, String> _metadata(String method) =>
|
||
method == 'alipay' ? {'is_mobile': _isMobile ? '1' : '0'} : const {};
|
||
|
||
Future<void> start(PayCatalogItem item, String method) async {
|
||
_stopPolling();
|
||
state = PaymentFlowState(phase: PaymentPhase.creating, item: item, method: method);
|
||
try {
|
||
final order = await _ref
|
||
.read(paymentApiProvider)
|
||
.createOrder(sku: item.sku, method: method, metadata: _metadata(method));
|
||
if (!mounted) return;
|
||
state = state.copyWith(phase: PaymentPhase.awaitingPayment, order: order);
|
||
_startPolling();
|
||
} on AuthApiException catch (e) {
|
||
if (!mounted) return;
|
||
state = state.copyWith(phase: PaymentPhase.failed, errorZh: e.messageZh, errorEn: e.messageEn);
|
||
}
|
||
}
|
||
|
||
/// 换支付方式:先 retry;pay 回 409 CURRENCY_MISMATCH(跨结算币种)时,
|
||
/// 按契约「换币种必须新单」→ cancel 旧单 + 新 method 重新下单。
|
||
Future<void> switchMethod(String method) async {
|
||
final item = state.item;
|
||
final order = state.order;
|
||
if (item == null || order == null) return;
|
||
try {
|
||
final next = await _ref
|
||
.read(paymentApiProvider)
|
||
.retry(order.orderNo, method: method, metadata: _metadata(method));
|
||
if (!mounted) return;
|
||
state = state.copyWith(phase: PaymentPhase.awaitingPayment, method: method, order: next);
|
||
} on AuthApiException catch (e) {
|
||
if (!mounted) return;
|
||
if (e.code == 'CURRENCY_MISMATCH') {
|
||
try {
|
||
await _ref.read(paymentApiProvider).cancel(order.orderNo);
|
||
} catch (_) {} // 旧单取消失败不阻断新单
|
||
await start(item, method);
|
||
return;
|
||
}
|
||
state = state.copyWith(phase: PaymentPhase.failed, errorZh: e.messageZh, errorEn: e.messageEn);
|
||
}
|
||
}
|
||
|
||
Future<void> cancel() async {
|
||
final order = state.order;
|
||
_stopPolling();
|
||
if (order != null) {
|
||
try {
|
||
await _ref.read(paymentApiProvider).cancel(order.orderNo);
|
||
} catch (_) {}
|
||
}
|
||
if (mounted) state = const PaymentFlowState();
|
||
}
|
||
|
||
/// 单拍轮询(Timer 驱动;测试直接调用,不依赖真实时钟)。
|
||
Future<void> pollOnce() async {
|
||
final order = state.order;
|
||
if (order == null || _polling) return;
|
||
_polling = true;
|
||
try {
|
||
final st = await _ref.read(paymentApiProvider).orderStatus(order.orderNo);
|
||
if (!mounted) return;
|
||
if (st.activated) {
|
||
_stopPolling();
|
||
state = state.copyWith(phase: PaymentPhase.succeeded, status: st);
|
||
// 权益已变,best-effort 刷新「我的」(失败不影响成功态)。
|
||
try {
|
||
await _ref.read(meProvider.notifier).refresh();
|
||
} catch (_) {}
|
||
} else {
|
||
state = state.copyWith(status: st);
|
||
}
|
||
} on AuthApiException {
|
||
// 单拍失败静默,下一拍重试(网络抖动不打断等待页)。
|
||
} finally {
|
||
_polling = false;
|
||
}
|
||
}
|
||
|
||
void _startPolling() {
|
||
_poll?.cancel();
|
||
_poll = Timer.periodic(_pollInterval, (_) => pollOnce());
|
||
}
|
||
|
||
void _stopPolling() {
|
||
_poll?.cancel();
|
||
_poll = null;
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_stopPolling();
|
||
super.dispose();
|
||
}
|
||
}
|
||
|
||
final paymentFlowProvider =
|
||
StateNotifierProvider<PaymentFlowController, PaymentFlowState>(
|
||
(ref) => PaymentFlowController(ref),
|
||
);
|
||
```
|
||
|
||
> `meProvider.refresh()` 在测试容器缺 auth 配置时可能抛——已包 `try/catch`,与 Step 2 的测试假设一致。`dart:io Platform` 在 web 不可用,已用 `kIsWeb` 短路(本项目桌面/移动为主)。
|
||
|
||
- [ ] **Step 4: 验证 + commit**——`flutter analyze && flutter test test/unit/payment_api_test.dart test/unit/payment_flow_test.dart && flutter test` 全绿;commit `feat(client): pay 支付领域模型 + PaymentApi + 支付流控制器(轮询 activated)`。
|
||
|
||
---
|
||
|
||
## Task 7: Flutter — 购买页 + 支付页(render_type 多态)+ 导航/l10n/pubspec + widget 测试
|
||
|
||
**产出**:`client/lib/screens/purchase_page.dart`、`client/lib/screens/payment_page.dart`、NavView 扩展 + 4 壳接线、l10n 三文件新增文案、pubspec 加 `url_launcher`、widget 测试。**UI 铁律**:组件全部取自 `client/lib/widgets/` 真相源(PlanCard/PangolinButton/showPangolinToast/PangolinIcons/`_SubScaffold` 模式/inline card 配方);**不新造视觉样式**——若实现中发现需要真相源没有的组件,停下先补 `design/preview/` 规格再继续(全局记忆铁律)。
|
||
|
||
### Steps
|
||
|
||
- [ ] **Step 1: pubspec + l10n(前置)**
|
||
1. `client/pubspec.yaml` dependencies 加 `url_launcher: ^6.3.0`,`flutter pub get`。(剪贴板用 SDK `Clipboard`,零依赖;qr 预留不加 qr_flutter。)
|
||
2. `client/lib/l10n/app_text.dart` 抽象契约加(红线词自查:无 VPN/翻墙等):
|
||
|
||
```dart
|
||
// ── 购买 / 支付 ──
|
||
String get purchaseTitle; // 购买套餐 / Purchase
|
||
String get paymentTitle; // 支付 / Payment
|
||
String get buyNow; // 立即购买 / Buy now
|
||
String get payMethodAlipay; // 支付宝 / Alipay
|
||
String get payMethodCrypto; // USDT (TRC20)
|
||
String get choosePayMethod; // 选择支付方式 / Choose payment method
|
||
String get proMonthly; // 专业版·月付 / Pro · Monthly
|
||
String get proQuarterly; // 专业版·季付 / Pro · Quarterly
|
||
String get proYearly; // 专业版·年付 / Pro · Yearly
|
||
String get perQuarter; // /季 / /quarter
|
||
String get perYear; // /年 / /year
|
||
String get payAmountLabel; // 转账金额 / Amount
|
||
String get payAddressLabel; // 收款地址 / Address
|
||
String get payNetworkLabel; // 网络 / Network
|
||
String get payExactAmountHint; // 金额须精确一致,到账后自动开通 / Send the exact amount; activates automatically
|
||
String get copied; // 已复制 / Copied
|
||
String get openAlipay; // 打开支付宝支付 / Pay with Alipay
|
||
String get openAlipayHint; // 完成支付后返回,本页会自动刷新 / Return after paying; this page refreshes automatically
|
||
String get awaitingPayment; // 等待付款 / Awaiting payment
|
||
String get paySucceeded; // 已开通 / Activated
|
||
String get payExpiresAt; // 有效期至 / Valid until
|
||
String get payDone; // 完成 / Done
|
||
String get payFailed; // 支付失败 / Payment failed
|
||
String get payRetry; // 重试 / Retry
|
||
String get switchPayMethod; // 换一种支付方式 / Switch payment method
|
||
String get cancelOrder; // 取消订单 / Cancel order
|
||
String get qrNotSupported; // 请复制内容后在支付宝内打开 / Copy and open in Alipay
|
||
```
|
||
|
||
3. `strings_zh.dart` / `strings_en.dart` 各补实现(照上注释的中英文案)。跑 `bash ci/scan-redline.sh`(或 CI 同款 grep)确认无红线词。
|
||
|
||
- [ ] **Step 2: 失败测试** `client/test/widget/payment_pages_test.dart`:
|
||
|
||
```dart
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:pangolin_vpn/l10n/strings_zh.dart';
|
||
import 'package:pangolin_vpn/models/payment.dart';
|
||
import 'package:pangolin_vpn/screens/payment_page.dart';
|
||
import 'package:pangolin_vpn/screens/purchase_page.dart';
|
||
import 'package:pangolin_vpn/state/payment_provider.dart';
|
||
import 'package:pangolin_vpn/widgets/plan_card.dart';
|
||
|
||
import '../helpers/harness.dart';
|
||
|
||
const _items = [
|
||
PayCatalogItem(sku: 'pro_month', plan: 'pro', days: 31, priceMinor: 2999, currency: 'CNY'),
|
||
PayCatalogItem(sku: 'pro_quarter', plan: 'pro', days: 92, priceMinor: 6888, currency: 'CNY'),
|
||
PayCatalogItem(sku: 'pro_year', plan: 'pro', days: 366, priceMinor: 19999, currency: 'CNY'),
|
||
];
|
||
|
||
void main() {
|
||
setUpAll(disableGoogleFontsFetching);
|
||
const t = StringsZh();
|
||
|
||
testWidgets('购买页:三档 PlanCard + 价格', (tester) async {
|
||
await tester.pumpWidget(wrapThemed(
|
||
PurchaseScreen(t: t, embedded: true),
|
||
overrides: [payCatalogProvider.overrideWith((ref) async => _items)],
|
||
));
|
||
await tester.pumpAndSettle();
|
||
expect(find.byType(PlanCard), findsNWidgets(3));
|
||
expect(find.text('¥29.99'), findsOneWidget);
|
||
expect(find.text('¥199.99'), findsOneWidget);
|
||
expect(find.text(t.proQuarterly), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('支付页 crypto_address:地址/金额/复制,金额用 mono', (tester) async {
|
||
final flow = PaymentFlowState(
|
||
phase: PaymentPhase.awaitingPayment,
|
||
item: _items.first,
|
||
method: 'crypto',
|
||
order: const PayOrder(
|
||
orderNo: 'pay1',
|
||
session: PaySession(renderType: 'crypto_address', payload: {
|
||
'address': 'TXYZabc123',
|
||
'amount': '4.201234',
|
||
'amount_minor': 4201234,
|
||
'currency': 'USDT',
|
||
'network': 'TRC20',
|
||
}),
|
||
),
|
||
);
|
||
await tester.pumpWidget(wrapThemed(
|
||
PaymentScreen(t: t, embedded: true),
|
||
overrides: [
|
||
paymentFlowProvider.overrideWith((ref) => _FixedFlowController(flow)),
|
||
],
|
||
));
|
||
await tester.pump();
|
||
expect(find.text('TXYZabc123'), findsOneWidget);
|
||
expect(find.textContaining('4.201234'), findsOneWidget);
|
||
expect(find.text(t.payExactAmountHint), findsOneWidget);
|
||
expect(find.text(t.awaitingPayment), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('支付页 redirect:外链按钮 + 引导文案', (tester) async {
|
||
final flow = PaymentFlowState(
|
||
phase: PaymentPhase.awaitingPayment,
|
||
item: _items.first,
|
||
method: 'alipay',
|
||
order: const PayOrder(
|
||
orderNo: 'pay1',
|
||
session: PaySession(renderType: 'redirect', payload: {'url': 'https://alipay.example/x'}),
|
||
),
|
||
);
|
||
await tester.pumpWidget(wrapThemed(
|
||
PaymentScreen(t: t, embedded: true),
|
||
overrides: [paymentFlowProvider.overrideWith((ref) => _FixedFlowController(flow))],
|
||
));
|
||
await tester.pump();
|
||
expect(find.text(t.openAlipay), findsOneWidget);
|
||
expect(find.text(t.openAlipayHint), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('支付页成功态:已开通 + 完成', (tester) async {
|
||
final flow = PaymentFlowState(
|
||
phase: PaymentPhase.succeeded,
|
||
item: _items.first,
|
||
method: 'crypto',
|
||
status: PayOrderStatus(
|
||
orderNo: 'pay1', payStatus: 'succeeded', activated: true,
|
||
expiresAt: DateTime.utc(2026, 8, 10)),
|
||
);
|
||
await tester.pumpWidget(wrapThemed(
|
||
PaymentScreen(t: t, embedded: true),
|
||
overrides: [paymentFlowProvider.overrideWith((ref) => _FixedFlowController(flow))],
|
||
));
|
||
await tester.pump();
|
||
expect(find.text(t.paySucceeded), findsOneWidget);
|
||
expect(find.text(t.payDone), findsOneWidget);
|
||
});
|
||
}
|
||
|
||
/// 固定状态的控制器替身(不发网络、不起 Timer)。
|
||
class _FixedFlowController extends PaymentFlowController {
|
||
_FixedFlowController(PaymentFlowState fixed) : super(_dummyRef()) {
|
||
state = fixed;
|
||
}
|
||
static Ref _dummyRef() => throw UnimplementedError(); // 见下注
|
||
}
|
||
```
|
||
|
||
> `_FixedFlowController` 直接继承会被 Ref 卡住——实现时改为给 `PaymentFlowController` 加 `@visibleForTesting PaymentFlowController.fixed(PaymentFlowState s)` 命名构造(`_ref` 置 late 不触碰,或把 `_ref` 改 `Ref?` 空安全短路),测试用 `paymentFlowProvider.overrideWith((ref) => PaymentFlowController.fixed(flow))`。以最终编译通过的最小改法为准,**不得为测试放宽生产逻辑**。
|
||
|
||
- [ ] **Step 3: 实现购买页** `client/lib/screens/purchase_page.dart`(骨架仿 `widgets/account_screens.dart::PlansScreen`,选档 → 底部弹层选方式 → `paymentFlowProvider.start` → 进支付页):
|
||
|
||
```dart
|
||
// purchase_page.dart — 购买套餐(三档 pro:月/季/年,pay v2 通道)。
|
||
// 档位数据来自 GET /v1/pay/catalog(server 单源);金额仅展示,扣款以 pay 为准。
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
||
import '../l10n/app_text.dart';
|
||
import '../models/payment.dart';
|
||
import '../pangolin_theme.dart';
|
||
import '../state/payment_provider.dart';
|
||
import '../widgets/pangolin_icons.dart';
|
||
import '../widgets/plan_card.dart';
|
||
|
||
class PurchaseScreen extends ConsumerWidget {
|
||
const PurchaseScreen({super.key, required this.t, this.onBack, this.onOrderCreated, this.embedded = false});
|
||
|
||
final AppText t;
|
||
final VoidCallback? onBack;
|
||
/// 下单成功(进入 awaitingPayment)后由壳导航到支付页。
|
||
final VoidCallback? onOrderCreated;
|
||
final bool embedded;
|
||
|
||
String _name(String sku) => switch (sku) {
|
||
'pro_month' => t.proMonthly,
|
||
'pro_quarter' => t.proQuarterly,
|
||
'pro_year' => t.proYearly,
|
||
_ => sku,
|
||
};
|
||
|
||
String _period(String sku) => switch (sku) {
|
||
'pro_month' => t.perMonth,
|
||
'pro_quarter' => t.perQuarter,
|
||
'pro_year' => t.perYear,
|
||
_ => '',
|
||
};
|
||
|
||
Future<void> _choose(BuildContext context, WidgetRef ref, PayCatalogItem item) async {
|
||
final c = context.pangolin;
|
||
final method = await showModalBottomSheet<String>(
|
||
context: context,
|
||
backgroundColor: c.surface,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(PangolinRadius.xl)),
|
||
),
|
||
builder: (sheetCtx) => SafeArea(
|
||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 8),
|
||
child: Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Text(t.choosePayMethod,
|
||
style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
|
||
),
|
||
),
|
||
ListTile(
|
||
leading: Icon(PangolinIcons.creditCard, color: c.fg2),
|
||
title: Text(t.payMethodAlipay, style: PangolinText.body.copyWith(color: c.fg1)),
|
||
trailing: Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3),
|
||
onTap: () => Navigator.of(sheetCtx).pop('alipay'),
|
||
),
|
||
ListTile(
|
||
leading: Icon(PangolinIcons.globe, color: c.fg2),
|
||
title: Text(t.payMethodCrypto, style: PangolinText.body.copyWith(color: c.fg1)),
|
||
trailing: Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3),
|
||
onTap: () => Navigator.of(sheetCtx).pop('crypto'),
|
||
),
|
||
const SizedBox(height: 12),
|
||
]),
|
||
),
|
||
);
|
||
if (method == null || !context.mounted) return;
|
||
await ref.read(paymentFlowProvider.notifier).start(item, method);
|
||
if (!context.mounted) return;
|
||
if (ref.read(paymentFlowProvider).phase != PaymentPhase.idle) {
|
||
onOrderCreated?.call();
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final c = context.pangolin;
|
||
final catalog = ref.watch(payCatalogProvider);
|
||
|
||
final body = catalog.when(
|
||
loading: () => const Center(
|
||
child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())),
|
||
error: (_, __) => Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(40),
|
||
child: Text(t.lang == AppLang.zh ? '加载失败,请重试' : 'Failed to load, retry',
|
||
style: PangolinText.body.copyWith(color: c.fg3)),
|
||
),
|
||
),
|
||
data: (items) => ListView(
|
||
padding: const EdgeInsets.fromLTRB(20, 14, 20, 24),
|
||
children: [
|
||
for (final it in items)
|
||
Padding(
|
||
padding: EdgeInsets.only(bottom: 14, top: it.sku == 'pro_year' ? 12 : 0),
|
||
child: PlanCard(
|
||
name: _name(it.sku),
|
||
price: it.priceLabel(),
|
||
period: _period(it.sku),
|
||
features: t.featsPro,
|
||
ctaLabel: t.buyNow,
|
||
featured: it.sku == 'pro_year',
|
||
popularLabel: it.sku == 'pro_year' ? t.mostPopular : null,
|
||
onPressed: () => _choose(context, ref, it),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
if (embedded) return body;
|
||
return Scaffold(
|
||
backgroundColor: c.bg,
|
||
body: SafeArea(
|
||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(8, 6, 16, 10),
|
||
child: Row(children: [
|
||
IconButton(
|
||
onPressed: onBack ?? () => Navigator.of(context).maybePop(),
|
||
icon: Icon(PangolinIcons.arrowLeft, size: 22, color: c.fg1),
|
||
),
|
||
Text(t.purchaseTitle,
|
||
style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
|
||
]),
|
||
),
|
||
Expanded(child: body),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
> `PangolinIcons.globe/chevronRight/creditCard/arrowLeft` 已在登记簿;`t.featsPro/t.mostPopular/t.perMonth` 为既有文案。移动/桌面双态骨架与 `_SubScaffold` 同构(它是 account_screens 的私有类,不跨文件引用,这里内联同配方)。
|
||
|
||
- [ ] **Step 4: 实现支付页** `client/lib/screens/payment_page.dart`:
|
||
|
||
```dart
|
||
// payment_page.dart — 支付页:按 session.render_type 多态渲染,轮询到
|
||
// activated 切成功态。crypto_address=地址+精确金额+复制;redirect=外链拉起;
|
||
// qr=预留(复制内容兜底)。
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:url_launcher/url_launcher.dart';
|
||
|
||
import '../l10n/app_text.dart';
|
||
import '../pangolin_theme.dart';
|
||
import '../state/payment_provider.dart';
|
||
import '../widgets/pangolin_button.dart';
|
||
import '../widgets/pangolin_icons.dart';
|
||
import '../widgets/pangolin_toast.dart';
|
||
|
||
class PaymentScreen extends ConsumerWidget {
|
||
const PaymentScreen({super.key, required this.t, this.onBack, this.onDone, this.embedded = false});
|
||
|
||
final AppText t;
|
||
final VoidCallback? onBack;
|
||
/// 成功态「完成」/取消订单后的退出导航(壳层决定去向)。
|
||
final VoidCallback? onDone;
|
||
final bool embedded;
|
||
|
||
bool get _zh => t.lang == AppLang.zh;
|
||
|
||
Future<void> _copy(BuildContext context, String text) async {
|
||
await Clipboard.setData(ClipboardData(text: text));
|
||
if (context.mounted) showPangolinToast(context, t.copied);
|
||
}
|
||
|
||
// 卡片配方 = account_page.dart::_Card(真相源既有样式,非新造)。
|
||
Widget _card(PangolinScheme c, {required Widget child}) => Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: c.surface,
|
||
borderRadius: BorderRadius.circular(PangolinRadius.lg),
|
||
border: Border.all(color: c.border),
|
||
boxShadow: PangolinShadow.sm,
|
||
),
|
||
child: child,
|
||
);
|
||
|
||
Widget _kvRow(BuildContext context, String label, String value, {bool mono = true}) {
|
||
final c = context.pangolin;
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
SizedBox(
|
||
width: 76,
|
||
child: Text(label, style: PangolinText.sm.copyWith(color: c.fg3)),
|
||
),
|
||
Expanded(
|
||
child: SelectableText(value,
|
||
style: (mono ? PangolinText.mono : PangolinText.body)
|
||
.copyWith(color: c.fg1, fontSize: 14)),
|
||
),
|
||
IconButton(
|
||
visualDensity: VisualDensity.compact,
|
||
icon: Icon(PangolinIcons.copy, size: 16, color: c.fg3),
|
||
onPressed: () => _copy(context, value),
|
||
),
|
||
]),
|
||
);
|
||
}
|
||
|
||
Widget _awaiting(BuildContext context, WidgetRef ref, PaymentFlowState s) {
|
||
final c = context.pangolin;
|
||
final session = s.order!.session;
|
||
final payload = session.payload;
|
||
|
||
Widget renderBody;
|
||
switch (session.renderType) {
|
||
case 'crypto_address':
|
||
renderBody = _card(c, child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
_kvRow(context, t.payNetworkLabel, '${payload['currency'] ?? 'USDT'} · ${payload['network'] ?? 'TRC20'}'),
|
||
_kvRow(context, t.payAddressLabel, '${payload['address'] ?? ''}'),
|
||
_kvRow(context, t.payAmountLabel, '${payload['amount'] ?? ''}'),
|
||
const SizedBox(height: 6),
|
||
Text(t.payExactAmountHint, style: PangolinText.caption.copyWith(color: c.warning)),
|
||
]));
|
||
case 'redirect':
|
||
renderBody = _card(c, child: Column(children: [
|
||
PangolinButton(
|
||
label: t.openAlipay,
|
||
icon: PangolinIcons.externalLink,
|
||
expand: true,
|
||
onPressed: () => launchUrl(Uri.parse('${payload['url'] ?? ''}'),
|
||
mode: LaunchMode.externalApplication),
|
||
),
|
||
const SizedBox(height: 10),
|
||
Text(t.openAlipayHint, style: PangolinText.caption.copyWith(color: c.fg3)),
|
||
]));
|
||
case 'qr': // 预留:复制内容兜底,不引入二维码渲染依赖
|
||
renderBody = _card(c, child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
_kvRow(context, t.payAmountLabel, '¥${payload['display_amount'] ?? ''}', mono: true),
|
||
_kvRow(context, t.payAddressLabel, '${payload['qr_content'] ?? ''}'),
|
||
const SizedBox(height: 6),
|
||
Text(t.qrNotSupported, style: PangolinText.caption.copyWith(color: c.fg3)),
|
||
]));
|
||
default:
|
||
renderBody = _card(c, child: Text('${session.renderType}: ${_zh ? "暂不支持,请换支付方式" : "Unsupported, switch method"}',
|
||
style: PangolinText.body.copyWith(color: c.fg2)));
|
||
}
|
||
|
||
return ListView(padding: const EdgeInsets.fromLTRB(20, 14, 20, 24), children: [
|
||
renderBody,
|
||
const SizedBox(height: 16),
|
||
Row(children: [
|
||
SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: c.accent)),
|
||
const SizedBox(width: 10),
|
||
Text(t.awaitingPayment, style: PangolinText.sm.copyWith(color: c.fg2)),
|
||
]),
|
||
const SizedBox(height: 22),
|
||
PangolinButton(
|
||
label: t.switchPayMethod,
|
||
variant: PangolinButtonVariant.secondary,
|
||
expand: true,
|
||
onPressed: () => _pickAndSwitch(context, ref, s),
|
||
),
|
||
const SizedBox(height: 10),
|
||
PangolinButton(
|
||
label: t.cancelOrder,
|
||
variant: PangolinButtonVariant.ghost,
|
||
expand: true,
|
||
onPressed: () async {
|
||
await ref.read(paymentFlowProvider.notifier).cancel();
|
||
onDone?.call();
|
||
},
|
||
),
|
||
]);
|
||
}
|
||
|
||
Future<void> _pickAndSwitch(BuildContext context, WidgetRef ref, PaymentFlowState s) async {
|
||
final other = s.method == 'crypto' ? 'alipay' : 'crypto';
|
||
await ref.read(paymentFlowProvider.notifier).switchMethod(other);
|
||
}
|
||
|
||
Widget _succeeded(BuildContext context, PaymentFlowState s) {
|
||
final c = context.pangolin;
|
||
final exp = s.status?.expiresAt;
|
||
return Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(32),
|
||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||
Icon(PangolinIcons.checkCircle, size: 56, color: c.success),
|
||
const SizedBox(height: 16),
|
||
Text(t.paySucceeded, style: PangolinText.h2.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
|
||
if (exp != null) ...[
|
||
const SizedBox(height: 8),
|
||
Text('${t.payExpiresAt} ${exp.toLocal().toString().split(' ').first}',
|
||
style: PangolinText.sm.copyWith(color: c.fg2)),
|
||
],
|
||
const SizedBox(height: 24),
|
||
PangolinButton(label: t.payDone, expand: true, onPressed: () => onDone?.call()),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _failed(BuildContext context, WidgetRef ref, PaymentFlowState s) {
|
||
final c = context.pangolin;
|
||
return Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(32),
|
||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||
Text(t.payFailed, style: PangolinText.h3.copyWith(color: c.danger)),
|
||
const SizedBox(height: 8),
|
||
Text((_zh ? s.errorZh : s.errorEn) ?? '', style: PangolinText.sm.copyWith(color: c.fg3)),
|
||
const SizedBox(height: 20),
|
||
PangolinButton(
|
||
label: t.payRetry,
|
||
expand: true,
|
||
onPressed: () {
|
||
final item = s.item;
|
||
if (item != null) ref.read(paymentFlowProvider.notifier).start(item, s.method);
|
||
},
|
||
),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final c = context.pangolin;
|
||
final s = ref.watch(paymentFlowProvider);
|
||
|
||
final body = switch (s.phase) {
|
||
PaymentPhase.creating => const Center(child: CircularProgressIndicator()),
|
||
PaymentPhase.awaitingPayment => _awaiting(context, ref, s),
|
||
PaymentPhase.succeeded => _succeeded(context, s),
|
||
PaymentPhase.failed => _failed(context, ref, s),
|
||
PaymentPhase.idle => Center(
|
||
child: Text(_zh ? '暂无进行中的订单' : 'No active order',
|
||
style: PangolinText.body.copyWith(color: c.fg3))),
|
||
};
|
||
|
||
if (embedded) return body;
|
||
return Scaffold(
|
||
backgroundColor: c.bg,
|
||
body: SafeArea(
|
||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(8, 6, 16, 10),
|
||
child: Row(children: [
|
||
IconButton(
|
||
onPressed: onBack ?? () => Navigator.of(context).maybePop(),
|
||
icon: Icon(PangolinIcons.arrowLeft, size: 22, color: c.fg1),
|
||
),
|
||
Text(t.paymentTitle,
|
||
style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
|
||
]),
|
||
),
|
||
Expanded(child: body),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
> `PangolinIcons.copy` 若登记簿没有:在 `pangolin_icons.dart` 加 `static const copy = LucideIcons.copy;` 并登记进 name map(登记簿新增图标属允许操作,非新造样式)。
|
||
|
||
- [ ] **Step 5: 导航接线**
|
||
1. `lib/state/navigation_provider.dart`:enum 加 `purchase, payment`;`kAccountSubViews` 加两者。
|
||
2. `lib/shell/desktop_shell.dart`:`titles` 加 `NavView.purchase: t.purchaseTitle, NavView.payment: t.paymentTitle`;`content()` 加:
|
||
|
||
```dart
|
||
case NavView.purchase:
|
||
return PurchaseScreen(t: t, embedded: true, onOrderCreated: () => go(NavView.payment));
|
||
case NavView.payment:
|
||
return PaymentScreen(t: t, embedded: true, onDone: () => go(NavView.account));
|
||
```
|
||
|
||
`ContentTopBar` 返回逻辑:`payment → purchase → account`(把现有 `onBack: isSub ? () => go(NavView.account) : null` 细化为 switch,payment 回 purchase)。
|
||
3. `plans` 入口改道:desktop_shell 的 `case NavView.plans: PlansScreen(..., onChoose: (code) => go(code == 'pro' ? NavView.purchase : NavView.redeem))`;tablet/mobile 壳与 `account_page.dart` 的升级入口同法(移动端 `Navigator.push(MaterialPageRoute(builder: (_) => PurchaseScreen(t: t, onOrderCreated: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => PaymentScreen(t: t))))))`——沿用 `account_page.dart::open()` 的双态惯例)。tablet_shell/mobile_shell 的 switch 各补 `purchase/payment` case(样式对齐各壳既有子页写法)。
|
||
4. `flutter analyze` 过——**Dart switch 对 enum 是穷尽检查,漏壳会直接编译错**,这是接线完整性的保险。
|
||
|
||
- [ ] **Step 6: 验证 + commit**——`flutter analyze && flutter test` 全绿(含 Task 6 单测 + 本任务 widget 测试 + 既有 golden 不回归——新页面不进 golden,见 Self-Review 取舍);commit `feat(client): 购买页三档 + 支付页 render_type 多态(crypto/redirect/qr 预留)+ 导航接线`。
|
||
|
||
---
|
||
|
||
## Task 8: 终验 — 全量矩阵 + OpenAPI 登记 + 交付说明
|
||
|
||
### Steps
|
||
|
||
- [ ] **Step 1: 全量测试矩阵**
|
||
|
||
```bash
|
||
cd /Users/wangjia/code/pangolin/.claude/worktrees/pay-v2-integration/server
|
||
go build ./... && go vet ./... && go test ./... && ./run_sqlite_test.sh
|
||
# docker 可用则加跑:./run_mysql_test.sh(重点:000021 迁移在 mysql 上的 MODIFY ENUM)
|
||
cd ../client
|
||
flutter analyze && flutter test
|
||
```
|
||
|
||
- [ ] **Step 2: 迁移彩排(sqlite 文件库模拟带数据升级)**——重点验 000021 的 subscriptions 重建不丢行、id/AUTOINCREMENT 序列保持:up 到 000020 → 手工插 users/subscriptions(source='trial'/'code' 各一行)→ up 到 021 → 断言行数/id 不变、可插 source='pay' → down → up 幂等。
|
||
- [ ] **Step 3: OpenAPI 登记**——`server/api/openapi.yaml` 补 `/v1/pay/catalog`、`/v1/pay/orders`、`/v1/pay/orders/{orderNo}`(GET/retry/cancel)、`/v1/webhook/pay` 条目(CI 仅结构校验 `design/server/openapi.yaml`,若两文件同步维护则两处都补);`python -m openapi_spec_validator` 本地过一遍(或走 CI)。
|
||
- [ ] **Step 4: 手动冒烟(可选,本地起 server)**——`PAY_BASE_URL` 指向本机假 pay(可用 Task 3 的 httptest 思路写个临时 main 或 `python -m http.server` 改造),curl 走一遍 create→webhook→get 看 activated 翻转。
|
||
- [ ] **Step 5: 收尾 commit + 汇总**——向用户报告:测试矩阵结果、联调 checklist(下节)与部署附录(pay 侧种子/biz 配置/pangolin env)是上线前提、本计划已知取舍(Self-Review)。**到此为止,不 merge、不部署。**
|
||
|
||
---
|
||
|
||
## Self-Review Checklist(执行完逐条打勾,含取舍说明)
|
||
|
||
- [ ] `git log` 每刀一 commit;server/client 全量测试绿;`run_sqlite_test.sh` 绿。
|
||
- [ ] **金额纪律**:client 代码 grep `amount` 确认只有展示/payload 读取,无任何请求体携带金额;server 下单请求体只含 sku/method/biz_system/biz_ref/metadata。
|
||
- [ ] **HMAC 对称性**:`internal/pay/sign.go` 与 pay `util/sign.go` 逐字节同构(`\n` join + std base64);webhook 验签 parts 顺序 `[system, ts, nonce, body]` 与 notifier 出站一致。
|
||
- [ ] **重投安全**:`TestWebhook_RedeliveryIdempotent` 断言订阅行数与到期都不变;500 路径(未知 sku)不产生半态(事务回滚)。
|
||
- [ ] **叠加语义零复制**:webhook 开通只经 `GrantPaidSubscriptionTx → applySubscription`,`grep -rn "AddDate" server/internal/pay/` 应为 0 行(时长计算只在 codes 包)。
|
||
- [ ] **codes 既有行为零变化**:兑换/试用相关既有测试未改一行且全绿(source 参数化只是穿参)。
|
||
- [ ] **UI 真相源纪律**:新页面 grep 无硬编码 hex(全部 `context.pangolin`/`PangolinText`/`PangolinRadius`);组件仅 PlanCard/PangolinButton/toast/icons + `_Card` 同款 inline 配方。
|
||
- [ ] **取舍 1(golden)**:purchase/payment 未加 desktop golden——页面状态依赖运行时订单,固化价值低;若用户要求像素闸,后续按 design/preview 补规格 + golden 一并做。
|
||
- [ ] **取舍 2(bottom sheet)**:支付方式选择用 Material `showModalBottomSheet` + `ListTile`(SDK 原生组件,主题色仍走 token)——真相源暂无「选择弹层」规格;若视觉不满意,先补 `design/preview/` 规格再改,不在本计划内造样式。
|
||
- [ ] **取舍 3(qr)**:qr render_type 只做复制兜底,未渲染二维码(不引 qr_flutter);当面付上线前需补。
|
||
- [ ] **取舍 4(catalog 双源)**:展示价在 pangolin catalog.go、扣款价在 pay 种子——两处人工对齐,漂移风险列入联调 checklist 第 2 条;后续可做 pay 端 price 查询接口消掉双源。
|
||
- [ ] **取舍 5(轮询打 pay)**:GET /v1/pay/orders/{no} 每拍都回源 pay 查单(3s 间隔单用户可接受;pay 侧 GET 无限流)。量大再加 server 短缓存。
|
||
- [ ] 遗留项登记:① mysql 集成测试若本机无 docker,交付说明标注「待跑」;② OpenAPI 两份文件的同步策略;③ 支付宝 `return_url`(App 场景暂空,web 用户中心接入时再传);④ 本计划 HTML 阅读版 + `docs/index.html` 登记(定稿后按仓规矩补)。
|
||
|
||
---
|
||
|
||
## 联调 Checklist(端到端,等 pay 部署后执行;不阻塞上面任何开发)
|
||
|
||
> 单测已用 httptest 假 pay 全覆盖签名/幂等语义;本节是真环境验收,逐条打勾。
|
||
|
||
1. [ ] **pay 侧就绪**:pay-server 部署完成,`/api/v2` 可达;pangolin 的 biz 配置与种子 SQL 已按「部署附录」落库(products×3 + product_prices(USDT)×3 + biz_system=pangolin)。
|
||
2. [ ] **价格一致性**:`GET <pay>/api/v2` 下单三档,断言 pay 返回/扣款金额与 pangolin `catalog.go` 展示价一致(CNY 2999/6888/19999 分;USDT 按附录种子)。
|
||
3. [ ] **pangolin server 配置**:`PAY_BASE_URL`/`PAY_BIZ_SYSTEM=pangolin`/`PAY_BIZ_SECRET`(与 pay biz 配置同 secret,存 Bitwarden 不落明文)已入 `/etc/pangolin*` env;重启后日志无「PAY_BASE_URL 未配置」。
|
||
4. [ ] **签名互通**:App(或 curl 带 JWT)`POST /v1/pay/orders {"sku":"pro_month","method":"crypto"}` → 200 返回 crypto_address session(地址/amount/amount_minor/USDT/TRC20)。403/401 则核对 secret 与时钟(±300s)。
|
||
5. [ ] **webhook 连通**:pay 侧 CallbackURL 指向 `http://<pangolin-server>:8080/v1/webhook/pay`;SupportedEvents=[payment.succeeded];用 pay 的重发工具(或手工 SQL 触发)投递一条 → pangolin 日志无验签错误、pay 侧标记 delivered(收到 200+SUCCESS)。
|
||
6. [ ] **USDT 真付一单**(小额档):转账精确金额 → pay 确认 → webhook → App 轮询页自动切「已开通」;`subscriptions` 出现 `source='pay'` 行,`pay_purchases` 行 `paid` + sub_id/amount/currency 回填;audit_log 有 `pay_grant`。
|
||
7. [ ] **重投验证**:pay 侧手动重发同一事件 → pangolin 回 SUCCESS,订阅到期不变(查库比对)。
|
||
8. [ ] **支付宝 redirect 一单**:`method=alipay` + `metadata.is_mobile` 按端型 → 返回 redirect url 可拉起;支付后同 6 号流程走通。
|
||
9. [ ] **换渠道**:crypto 下单后 `POST .../retry {"method":"alipay"}` → 收 409 CURRENCY_MISMATCH;App 自动取消旧单新建 alipay 单(观察 pay 侧旧单 canceled、新单 pending)。
|
||
10. [ ] **叠加**:同一账号再购一档 → `expires_at` 在原值上顺延(不是从 now 重算,若原订阅未过期)。
|
||
11. [ ] **限流不误伤**:连续下单/取消超 30 次/分触发 pay 429 → App 提示「操作过于频繁」而非崩溃。
|
||
12. [ ] **时钟检查**:pangolin1 与 pay 所在机 `timedatectl` NTP 同步(±300s 窗口的前提)。
|
||
|
||
---
|
||
|
||
## 部署附录(联调/上线前的 pay 侧与 pangolin 侧配置;**本计划不执行部署**)
|
||
|
||
### A. pay 侧种子 SQL(pay 库;merchants/products/product_prices 为 GORM 表)
|
||
|
||
> 金额:CNY 走 `products.price` 元字符串(alipay fallback);USDT 走 `product_prices.amount_minor` **微单位(1 USDT = 1_000_000)**。USDT 定价按 ≈7.15 汇率取整,上线前可调——**调价只改 pay 侧,pangolin `catalog.go` 展示价同步改**。
|
||
|
||
```sql
|
||
-- ① 收款商户(alipay 渠道;crypto 渠道账户按 pay 的 crypto provider 配置走,不在此表)
|
||
INSERT INTO merchants (code, name, channel, production, enabled, created_at, updated_at)
|
||
VALUES ('pangolin', 'Pangolin', 'alipay', 1, 1, NOW(), NOW());
|
||
-- 记下自增 id,下面记作 <MID>
|
||
|
||
-- ② 三档产品(biz_code 即 v2 sku,与 pangolin catalog.go 严格一致)
|
||
INSERT INTO products (merchant_id, name, description, price, active, sort, biz_code, created_at, updated_at) VALUES
|
||
(<MID>, 'Pangolin 专业版·月付', '31 天', '29.99', 1, 1, 'pro_month', NOW(), NOW()),
|
||
(<MID>, 'Pangolin 专业版·季付', '92 天', '68.88', 1, 2, 'pro_quarter', NOW(), NOW()),
|
||
(<MID>, 'Pangolin 专业版·年付', '366 天','199.99', 1, 3, 'pro_year', NOW(), NOW());
|
||
|
||
-- ③ USDT 结算价(crypto 渠道必需;微单位)
|
||
-- 29.99/7.15≈4.19 → 4.20;68.88/7.15≈9.63 → 9.70;199.99/7.15≈27.97 → 27.99
|
||
INSERT INTO product_prices (product_id, currency, amount_minor, created_at, updated_at) VALUES
|
||
((SELECT id FROM products WHERE biz_code='pro_month'), 'USDT', 4200000, NOW(), NOW()),
|
||
((SELECT id FROM products WHERE biz_code='pro_quarter'), 'USDT', 9700000, NOW(), NOW()),
|
||
((SELECT id FROM products WHERE biz_code='pro_year'), 'USDT', 27990000, NOW(), NOW());
|
||
```
|
||
|
||
> 注:pay 是 GORM/MySQL 环境,`NOW()` 在 pay 库合法(pangolin 的「禁 NOW()」纪律只约束本仓 server SQL)。执行前用 `SELECT * FROM products WHERE biz_code LIKE 'pro_%'` 确认无残留旧行(幂等按 biz_code 判)。
|
||
|
||
### B. pay 侧 biz 配置(pay config,字段名以 pay `internal/config` 实际为准)
|
||
|
||
```yaml
|
||
# pay 配置新增业务系统条目(与 config.C.BizByName 对应):
|
||
biz_systems:
|
||
- name: pangolin
|
||
secret: <与 pangolin PAY_BIZ_SECRET 相同,Bitwarden 生成 32+ 字节随机串>
|
||
callback_url: http://<pangolin-server>:8080/v1/webhook/pay
|
||
supported_events: [payment.succeeded] # 白名单只开这一个
|
||
```
|
||
|
||
### C. pangolin 侧 env(`/etc/pangolin-server.env` 或等价,不入 git)
|
||
|
||
```bash
|
||
PAY_BASE_URL=http://<pay-server 地址> # 如 https://pay.51yanmei.com
|
||
PAY_BIZ_SYSTEM=pangolin # 默认值即 pangolin,可省
|
||
PAY_BIZ_SECRET=<同 B 节 secret,Bitwarden 取>
|
||
```
|
||
|
||
部署顺序:pangolin `cmd/migrate up`(000021)→ 重启 pangolin-server → pay 侧种子+biz 配置 → 联调 checklist。
|
||
|
||
---
|
||
|
||
## 风险清单(执行者注意)
|
||
|
||
1. **SQLite subscriptions 重建**(Task 1):worktree 测试全走 `:memory:` 无存量;生产 sqlite 库升级前务必过 Task 8 Step 2 彩排。重建期间的 FK 引用(无表引用 subscriptions,安全)已核。
|
||
2. **`auth.UserIDFromContext` 与 `codes.CtxKeyUserID` 的耦合**:handler 测试直接注 `codes.CtxKeyUserID`——若 auth 侧 helper 实现读的是别的 key,测试会假红;实现 Task 4 时先读 `internal/auth/middleware.go:71` 确认(锚点已核:就是这个 key)。
|
||
3. **pay 状态词汇**:`OrderStatus.Status` 的取值(pending/succeeded/canceled…)以 pay 实现为准,pangolin 只透传展示、不做分支判断(成功判据是本地 activated)——避免词汇漂移引 bug。
|
||
4. **`t.Context()`**(Task 3 测试)需 go1.24+;CI 容器 golang:1.25 满足,本机旧 Go 则替换 `context.Background()`。
|
||
5. **Flutter `_FixedFlowController`**(Task 7 测试)的构造细节以最终编译为准,允许给生产控制器加 `@visibleForTesting` 命名构造,不允许放宽生产逻辑。
|
||
6. **桌面壳 payment 返回键**:等待支付中用户点返回(purchase)不取消订单(订单仍在 pay 侧 pending,可从头再进);只有显式「取消订单」才 cancel——注意别在 onBack 里误调 cancel()。
|
||
|