feat: wave 2 migration (P3, P5, P7 openspec changes)

Implements, verifies, and archives the three remaining Wave 2 changes from
openspec/MIGRATION-PLAN.md.

- add-wallet-settlement (P3): demo recharge, guarded withdrawal freeze and
  one-time admin review, paginated own fund entries, idempotent per-shop
  weekly/monthly settlement statements with commission rate and one-time
  payout confirmation.
- add-merchant-onboarding (P5): personal/enterprise applications with one live
  application per user, guarded review with mandatory rejection reason, and
  transactional shop + owner provisioning returning one-time credentials;
  mall onboarding/status pages and an admin review console.
- add-membership-messaging (P7): platform member levels, append-only growth
  accrual on order completion with guarded one-way leveling, order/shipment/
  refund system messages with unread/read state and soft deletion, plus the
  mall header unread badge.

Backend: migrations 0019-0023, new wallet, settlement, merchant_onboarding,
membership and messaging modules, event hooks in order/fulfillment/aftersale,
and integration suites for each. Shared contract extended and all three
frontends updated; code indexes, domain docs, backend guidelines and the
migration tracker synced.

Verification: cargo test -p vmall-api green twice consecutively; mall, admin
and shop-admin builds pass; browser smoke on every new surface; openspec
validate --all --strict green (33 passed).

The three changes share the @vmall/shared contract, the mall mock adapter and
per-app locale/nav files, so they are committed together to keep every commit
buildable.
This commit is contained in:
2026-09-25 15:25:29 +00:00
parent 772aafa3fb
commit 9904696e76
120 changed files with 14097 additions and 125 deletions
+21 -9
View File
@@ -12,22 +12,34 @@
## 交接说明(handoff)
**进度**:Wave 1 完成(P6/P0/P2/P1 已归档)。剩 P3、P5、P7(Wave 2)、P4(Wave 3)。
**下一个建议**:P3 `add-wallet-settlement`(P0 已归档,硬依赖解除)。
**进度**:Wave 1 完成(P6/P0/P2/P1 已归档)+ P3 + P5 + P7 完成。只剩 P4(Wave 3)。
**下一个建议**:P4 `add-mobile-h5`——P0/P3 软依赖已全部归档,可直接起步;这也是迁移计划的最后一个 change。
**工作流约定(用户明确要求)**:一次只实现一个 change,完成后停下来等人工 review;可以提议下一个,但不要自动连续实施,除非明确要求连续执行。
**实施要点**:
- 下一个可用迁移号:`0019`(0016 aftersales / 0017 freight / 0018 reviews 已占用)。各 proposal tasks.md 里的迁移号是提议时抢占的(P3 写 0018/0019、P5/P7 写 0016/0017),**实施时以实际顺序重编号**。
- 下一个可用迁移号:`0024`(0019 wallet / 0020 settlement / 0021 merchant_applications / 0022 storefront seller link / 0023 membership+messaging 已占用)。各 proposal tasks.md 里的迁移号是提议时抢占的(P4 写 0016/0017),**实施时以实际顺序重编号**。
- 文档与索引已就位:改代码前先查 `docs/code_index/index.md`;`docs/backend-guidelines.md` 有并发/状态机模式与 PG 陷阱;`docs/design-guidelines.md` 管 UI;增删文件要同步索引。
- 前端派发模式已验证:我(主线)做迁移+共享契约+Rust 模块+集成测试,然后按 app 并行派三个子任务(mall/shop-admin/admin)写页面,主线统一构建+smoke+归档。
- 前端派发模式已验证:我(主线)做迁移+共享契约+Rust 模块+集成测试,然后按 app 并行派子任务写页面(P3 三个 app 并行;P5/P7 只有 mall+admin,shop-admin 无改动但要求照常构建),主线统一构建+smoke+归档。
- 共享契约的 admin 方法放在 `api.admin.*` 下(去掉任务描述里的 `admin` 前缀),与既有命名一致;mall 的 `LIVE_PICKS` 只需挑客户侧方法。
- 事件型副作用(消息、成长值)要与业务状态迁移放在**同一个事务**里,并靠部分唯一索引 + `ON CONFLICT DO NOTHING` 保证可重试;为此常需把原先只收 `&PgPool` 的 repo 辅助函数改成 `&mut PgConnection`(P7 改了 `maybe_mark_order_shipped` / `maybe_mark_order_completed`,后者返回 `bool` 表示本次是否真的完成,供调用方决定是否计提)。
**踩过的坑(别再踩)**:
- `SUM(bigint)` 在 Postgres 返回 NUMERIC,解码 i64 前必须 `COALESCE(SUM(x),0)::bigint`(售后曾因此 500)。
- sqlx 运行时解码:`cargo check` 绿不代表 SQL 对——加列要同步所有列清单常量与 INSERT 的列数/参数数。
- sqlx 运行时解码:`cargo check` 绿不代表 SQL 对——加列要同步所有列清单常量与 INSERT 的列数/参数数;只被单模块读写的列(如 `orders.completed_at`)可不进 `models.rs` 行结构,避免牵动所有列清单。
- **新增迁移文件后必须强制重编译**:`sqlx::migrate!` 在编译期把迁移列表嵌进二进制,仅新增 `.sql` 文件时 cargo 不一定重编,`cargo run` 会用旧列表“静默跳过”新迁移(P5 的 0022 就这样没生效)。改完跑 `touch apps/api/src/main.rs` 再起服务,并到 `_sqlx_migrations` 确认真应用了。
- 外币订单退款:客户无该币种账户会失败,用 `account::service::ensure_monetary_account` 懒建(已存在)。
- 共享契约改动要同时改 interface 和 createApi 实现两处;mall 还要 mock 实现 + LIVE_PICKS 精确挑方法(漏挑会静默落回 mock)。
- 共享契约改动要同时改 interface 和 createApi 实现两处;mall 还要 mock 实现 + LIVE_PICKS 精确挑方法(漏挑会静默落回 mock)。createApi/mock 里已有一批相对 ApiClient 的“多余键”(points/brands/shop profile),`nuxt build` 不做类型检查所以不报——别顺手去改共享契约清理。
- `orders.updated_at` 不能当“完成时间”:退款会 bump 它;按周期归集订单必须用专用列(P3 加了 `completed_at`,在完成迁移的那条 UPDATE 里写入)。
- 结算类周期归集:金额跨币种要经 `money::convert_minor` 折回基准币,别直接相加不同币种的最小单位。成长值同理:折回基准币后按基准币 exponent 截断成“整数单位”,再用整数除/乘,别用浮点。
- 会员等级阈值是**全平台唯一**:共享测试库里 `member_levels` 会跨用例/跨轮次残留,凡是断言“最高等级/下一等级/没有等级”的用例都必须先清空该表(P7 的 `reset_levels`)或用“恰好等于自身阈值”的构造,否则随机碰撞导致 flaky。
- 等级显示要**按当前阈值重新推导**,`users.level` 只作为“客户持有该等级”的标记(删除在用等级要据此拒绝);升级只允许单向(guarded update 比较阈值)。
- “一用户一条有效记录”用**部分唯一索引**(`UNIQUE (user_id) WHERE status IN (...)`)+ service 先查一次给友好 409;事务内先 `FOR UPDATE` 锁行、再置备(建店铺/建账号)、最后做 guarded 状态翻转,任何一步失败整笔回滚。
- 邮箱唯一性:为入驻审批新建店主账号时,申请人的联系邮箱可能已被注册;先查重、退化成 `local+owner@domain`,别直接 INSERT 撞唯一键。
- mall 登录页的注册链接必须带上 `redirect`,否则“注册后回到已填表单”不成立(P5 修了 login→register 的 redirect 透传与 register 的跳转);表单要 `novalidate`,否则原生校验会拦掉 submit 事件、本地化内联报错永远不触发。
- `openspec archive` 会写 `## Purpose` 占位(`TBD - created by archiving ...`),`openspec validate --all --strict` 会因此判 fail;归档后必须立刻把新 spec 的 Purpose 写成真句子(Wave 1 的 shipping/aftersale/reviews 也遗留了同样问题,P3 一并修掉)。
- dev server:`pnpm build` 后要 `rm -rf apps/<app>/.nuxt` 再 dev;dev 绑 IPv6 localhost;页面卡 Loading 无报错=旧 tab 拿旧 chunk hash,重启+开新 tab。
- mall 页面用 `playwright-cli` 做登录时别用 `input` 的全局下标取字段(页头搜索框会先匹配),用 placeholder 精确定位;同理别用 `main form input`(VField 不一定包在 form 里),用组件提供的 `data-testid`。
- 测试库迁移校验和冲突(改了已应用的本地迁移):`DROP SCHEMA public CASCADE; CREATE SCHEMA public;`(vmall_test)。
## 依赖图
@@ -61,11 +73,11 @@ P2 freight ───────────┘(改 checkout/order totals,
| P0 | `add-aftersale-refunds` | — | archived | 2026-09-23 |
| P1 | `add-product-reviews` | — | archived | 2026-09-24 |
| P2 | `add-freight-templates` | —(与 P0 串行) | archived | 2026-09-24 |
| P3 | `add-wallet-settlement` | P0 | proposed | — |
| P3 | `add-wallet-settlement` | P0 | archived | 2026-09-24 |
| P4 | `add-mobile-h5` | P0、P3(软) | proposed | — |
| P5 | `add-merchant-onboarding` | — | proposed | — |
| P5 | `add-merchant-onboarding` | — | archived | 2026-09-24 |
| P6 | `add-content-admin-ui` | — | archived | 2026-09-23 |
| P7 | `add-membership-messaging` | — | proposed | — |
| P7 | `add-membership-messaging` | — | archived | 2026-09-25 |
状态取值:`proposed` → `implementing` → `verified`(tasks 全勾 + 测试/构建/smoke 通过)→ `archived`。