Files
mp-pilates/docs/studio-shop.md
2026-10-18 14:59:48 +08:00

99 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 馆内好物:架构、使用与交付
## 产品范围
本次实现轻量的「单商品立即购买 + 微信支付 + 到馆自提」。首页进入好物列表和详情,个人中心查看商品订单;管理中心新增「商品、库存与销售订单」。
- 管理员维护标题、HTTPS 封面(复用 COS 上传)、说明、价格、排序、上下架状态。
- 各尺码、颜色按独立商品管理,在标题中标明规格;不引入购物车、物流、优惠券或复杂 SKU 系统。
- 创建商品默认库存为 0;保存后通过库存调整补货。调整数量为有符号整数,必须填写原因。
- 顾客填写取货人、手机号及数量,确认购买后微信付款。价格变化时先阻止下单,要求确认最新价格。
- 付款成功后订单进入待发货。管理员核对商品数量和取货人后,直接确认发货并完成订单。
- 管理端支持商品搜索、订单状态筛选、商品名 / 订单号 / 手机号搜索、分页、累计成交额、销量、待发货数、低库存提示。
- 售后通过详情页「联系场馆」沟通;本版不含线上退款流程。不能把已付款订单当作未付款订单取消。若后续引入商户平台退款,需配套退款对账及库存回补逻辑,不能仅修改订单状态。
## 现有架构与接入方式
仓库为 pnpm workspace:`app`(Vue 3、Pinia、uni-app)、`server`(NestJS、Prisma/MySQL)、`shared`(共享类型)。现有会员卡订单与开卡、续卡、邀请赠课、身体画像关联,因此实物商品使用独立模型和模块,不进入会员卡发放链路。
- 服务端:`src/shop/`,经 `AppModule` 注册。复用 `PrismaService`、JWT、`RolesGuard`、微信支付签名/解密请求能力、现有调度框架。
- 数据:新增 `Product`、`ProductOrder`、`ProductStockChange`;订单关联现有 `User`。
- 前端:`pages/shop` 独立分包,首页 `ProductShop` 展示精选商品;用户及管理员复用 `ShopOrders` 组件;商品管理仍在 admin 分包。
- 共享类型:`types/shop.ts`。所有金额按整数分传输,界面按元展示。
- 视觉:延续首页奶油白、暖棕、鼠尾草绿,用大封面、留白、衬线标题和清晰的价格层级呈现馆内精选。
- 技能:使用 frontend-design 指导界面;服务端遵循仓库既有 NestJS/Prisma、DTO 校验、权限、事务和 Jest 约定。当前技能目录没有专门的 NestJS 后端技能,没有引入无关鉴权框架。
## 库存和支付规则
`Product.stock` 表示**当前可售库存**,已经排除待付款订单预占量。`sold` 为已付款订单的累计件数。
1. 下单以用户行锁串行限制每人最多 5 笔待付款订单,以 `(userId, requestId)` 保证幂等。
2. 事务内读取商品,验证上架及顾客看到的单价,用条件更新 `stock >= quantity`、商品 `version` 原子预占库存。订单保存当时的标题、封面、单价和联系人快照。
3. 每笔最多 99 件,订单有效期 15 分钟。统一下单使用同一个商户订单号和明确的微信 `time_expire`。
4. 客户端支付成功不直接更新订单。服务端查询微信,核对商户、应用、订单号、金额、币种、openid 和微信交易号,才能从 PENDING 更新为 PAID。
5. 商品回调独立使用 `/api/shop/wx-notify`,只接受 AES-GCM 解密后的资源,再查单确认,不依赖旧会员卡回调的验签占位实现。成功返回 HTTP 204,绕过统一 API 包装。
6. 定时任务每 30 秒按最近对账时间分批扫描待付款订单,支持漏回调恢复;用户也可刷新支付状态。失败会保留库存并重试。
7. 取消订单先查单、关单,关单成功后才原子释放库存;若先查到支付成功则转为待发货。关单失败绝不提前释放。
8. 微信尚无订单时,可能存在正在途中的预支付请求;过期后再等待 60 秒才释放,避免“取消释放后又支付”。因此用户从未成功调起支付时,取消可能提示等待,最晚由超时任务回收(微信服务异常期间会继续保留并重试)。
9. 状态更新带条件,重复回调、重复取消或并发扫描不会重复计算销量、重复返还库存。发货完成仅允许已付款订单,并记录操作人/时间。
10. 管理员保存商品和库存均校验版本;发生竞争时提示刷新。库存调整记录最近 50 条人工调整原因及操作人;系统预占/释放由订单本身追溯。
状态:`PENDING → PAID → COMPLETED`;`PENDING → CANCELLED`。商品可下架但不删除,保证历史订单有效。
### 已发布小程序的过渡兼容(2026-10-18)
数据库删除 `pickup_code` 后,商城订单响应暂时保留可选的 `pickupCode` 字段,由订单 ID 派生为稳定的 12 位大写十六进制展示值。旧版小程序仍可展示、输入并提交这个值;新版小程序不使用它。它不落库,也不作为交付凭证或鉴权依据。完成订单仍必须通过馆主权限及 PAID 状态检查,新旧请求体均可接受。待旧版小程序退出使用后可移除此响应兼容层。
此兼容层及删列迁移已于 2026-10-18 与体测服务一并上线。旧订单除取货码外的原始字段摘要保持一致,线上订单列表、兼容字段和权限验证通过;详见 [本次生产发布记录](posture-assessment.md#生产发布记录2026-10-18)。
## 接口
公开:`GET /shop/products`、`GET /shop/products/:id`。
登录用户:`POST /shop/orders`、`GET /shop/orders`、`GET /shop/orders/:id`,以及 `POST /shop/orders/:id/pay|sync|cancel`。所有读写均按 JWT 用户限定订单归属。
管理员:`/admin/shop/summary`、`/admin/shop/products`(GET/POST)、`/admin/shop/products/:id`(GET/PUT)、`/admin/shop/products/:id/stock`(GET/POST)、`/admin/shop/orders`(GET)、`/admin/shop/orders/:id/complete`(POST)。控制器统一要求 ADMIN。
分页默认 20,最大 50;金额、数量、手机号、封面 URL、状态筛选及分页都使用 DTO 校验。客户端不能指定订单总价或购买用户。
## 上线顺序
2026-09-16 已按用户授权完成生产数据库迁移和服务端部署。以下为后续发布参考流程;本次执行结果见文末。
1. 备份数据库,先在测试数据库验证迁移 `20260916090000_studio_shop`。此迁移只新增 3 张表及关系索引,不改写已有会员数据。
2. 在 server 包运行 `pnpm exec prisma migrate deploy` 和 `pnpm prisma:generate`。
3. 根目录运行 `pnpm build:shared`、`pnpm build:server`,部署并重启后端。
4. 沿用微信支付配置和 COS 配置,无新增必填环境变量。确保 `API_BASE_URL` 指向服务域名、外网能访问 `/api/shop/wx-notify`,调度进程正常运行。
5. 构建发布小程序 `pnpm build:app`;新页面仍调用既有线上 API 域名,必须先上线后端。
6. 管理员创建真实商品、上传封面、补货、上架。没有硬编码测试商品写入数据库。
7. 在微信真机执行一笔小额购买:取消支付后续付、查看服务端支付确认、管理员直接发货完成、超时关单及库存回补。退款不在本版本内。
## 验证与限制
- 共享包、服务端构建;uni-app 类型检查;微信小程序构建;Prisma schema validate;Git diff 空白检查。
- Jest 覆盖价格变更、越权归属、负数/小数/非法输入、请求幂等、最后库存竞争、支付金额及付款人核对、重复回调、关单失败、过期释放、版本冲突、发货完成状态和微信 GET 签名规范。
- 全量 Jest 覆盖率运行:26 个套件、418 项测试通过(新增 60 项测试);并发测试基于条件更新行为模拟,不能替代真实 MySQL 多连接并发验证。
- 用本地独立测试数据在 390px 手机尺寸 H5 预览检查列表、详情、管理汇总、编辑页面。测试图片为标注 PREVIEW 的占位插画,未上传 COS、未接触线上商品。
- 已完成生产数据库迁移、服务端部署和线上接口冒烟验证。尚未执行真实微信扣款、COS 实际上传或微信真机验收;小程序新版仍需发布。
- 仓库 `pnpm lint` 目前失败于 `eslint: command not found`;package.json 没有安装 ESLint,仓库也没有提供 ESLint 配置。此项不代表 lint 已通过。
- 构建有既有 Browserslist 数据过期、Sass legacy API 警告;商城共享样式已改为纯 CSS,通过 Vue `<style scoped src="…/shop.css">` 引入,避免 Sass `@import` 弃用警告及 uni-app 全局样式注入对 `@use` 顺序的影响。
微信协议参考:[官方 JSAPI 查单、关单接口](https://github.com/wechatpay-apiv3/wechatpay-go/blob/main/docs/payments/jsapi/JsapiApi.md)、[官方小程序下单及有效期说明](https://pay.wechatpay.cn/doc/v3/merchant/4012791897)。
## 生产部署记录(2026-09-16)
- 服务目录:`/usr/local/web/mp-pilates-server`;共享包:`/usr/local/web/mp-pilates-shared`。
- 使用服务器现有 Node 22.16.0、PM2 和 Prisma 5.22.0。仓库旧部署脚本写死的 Node 22.17.1 路径不适用于当前服务器,本次未使用该脚本。
- 迁移:`20260916090000_studio_shop` 成功;全部 10 个迁移完成。新三表可通过 Prisma Client 正常访问,初始均为 0 条。
- 发布包 SHA-256:`7eeea42587cf2e188e337619a9a4ea0b88bbe97fe3857e733e39b4e65fe3f1fb`。
- 备份目录:`/usr/local/web/releases/mp-pilates-shop-20260916/`(服务器本机受限访问)。
- `database-before.sql.gz`:迁移前完整数据库逻辑备份,已检查 gzip 完整性。
- `previous-build.tgz`:旧服务、共享包、Prisma schema/迁移和生成的 Prisma Client。
- `live-dist-before` / `live-shared-before`:切换前运行产物。
- `release.tgz` / `stage`:本次发布包和暂存文件。
- 保留线上环境配置、支付证书和已有依赖。PM2 已重启并保存进程配置,健康检查及商品列表成功。
- 公网验证:健康、商品列表、场馆信息均 200;非法分页 400;未登录商品订单/管理接口 401。
- 服务器本机验证:管理员商品/订单/汇总接口 200;普通会员管理接口 403;用户商品订单 200;旧会员卡列表 200;无效支付回调 400。权限验证使用 30 秒有效、仅用于只读冒烟检查的测试身份,不操作真实会员账号。
- 未创建演示商品、测试订单或发起真实扣款。小程序新版发布后,管理员即可从新入口维护真实商品。
- 回滚以恢复旧运行产物和对应生成 Client、重启 PM2 为主;新表为增量结构,应保留,避免删除上线后产生的数据。数据库全量恢复仅用于灾难恢复,不应在正常版本回滚时直接覆盖。