From 95b9f6511c51f7d0299f7a1c2fe406b93fca7721 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Wed, 16 Sep 2026 22:49:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=95=86=E5=93=81?= =?UTF-8?q?=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/studio-shop.md | 92 +++++ packages/app/src/components/ProductShop.vue | 150 ++++++++ packages/app/src/components/ProfileMenu.vue | 1 + packages/app/src/components/ShopOrders.vue | 238 +++++++++++++ packages/app/src/pages.json | 35 ++ packages/app/src/pages/admin/index.vue | 3 + packages/app/src/pages/admin/product-edit.vue | 299 ++++++++++++++++ packages/app/src/pages/admin/products.vue | 162 +++++++++ packages/app/src/pages/home/index.vue | 4 + packages/app/src/pages/shop/detail.vue | 212 +++++++++++ packages/app/src/pages/shop/index.vue | 124 +++++++ packages/app/src/pages/shop/orders.vue | 58 +++ packages/app/src/pages/shop/shop.css | 184 ++++++++++ packages/app/src/utils/shop.ts | 32 ++ .../20260916090000_studio_shop/migration.sql | 74 ++++ packages/server/prisma/schema.prisma | 71 ++++ packages/server/src/app.module.ts | 2 + .../__tests__/wechat-product-pay.spec.ts | 70 ++++ .../server/src/payment/wechat-pay.service.ts | 36 +- .../src/shop/__tests__/shop.dto.spec.ts | 64 ++++ .../shop/__tests__/shop.permissions.spec.ts | 41 +++ .../src/shop/__tests__/shop.service.spec.ts | 273 ++++++++++++++ packages/server/src/shop/dto/shop.dto.ts | 67 ++++ packages/server/src/shop/shop.controller.ts | 109 ++++++ packages/server/src/shop/shop.module.ts | 12 + packages/server/src/shop/shop.service.ts | 333 ++++++++++++++++++ .../create-studio-upload-credential.dto.ts | 2 +- packages/shared/src/index.ts | 2 + packages/shared/src/types/shop.ts | 53 +++ packages/shared/src/types/studio.ts | 2 +- 30 files changed, 2800 insertions(+), 5 deletions(-) create mode 100644 docs/studio-shop.md create mode 100644 packages/app/src/components/ProductShop.vue create mode 100644 packages/app/src/components/ShopOrders.vue create mode 100644 packages/app/src/pages/admin/product-edit.vue create mode 100644 packages/app/src/pages/admin/products.vue create mode 100644 packages/app/src/pages/shop/detail.vue create mode 100644 packages/app/src/pages/shop/index.vue create mode 100644 packages/app/src/pages/shop/orders.vue create mode 100644 packages/app/src/pages/shop/shop.css create mode 100644 packages/app/src/utils/shop.ts create mode 100644 packages/server/prisma/migrations/20260916090000_studio_shop/migration.sql create mode 100644 packages/server/src/payment/__tests__/wechat-product-pay.spec.ts create mode 100644 packages/server/src/shop/__tests__/shop.dto.spec.ts create mode 100644 packages/server/src/shop/__tests__/shop.permissions.spec.ts create mode 100644 packages/server/src/shop/__tests__/shop.service.spec.ts create mode 100644 packages/server/src/shop/dto/shop.dto.ts create mode 100644 packages/server/src/shop/shop.controller.ts create mode 100644 packages/server/src/shop/shop.module.ts create mode 100644 packages/server/src/shop/shop.service.ts create mode 100644 packages/shared/src/types/shop.ts diff --git a/docs/studio-shop.md b/docs/studio-shop.md new file mode 100644 index 0000000..3d0c69c --- /dev/null +++ b/docs/studio-shop.md @@ -0,0 +1,92 @@ +# 馆内好物:架构、使用与交付 + +## 产品范围 + +本次实现轻量的「单商品立即购买 + 微信支付 + 到馆自提」。首页进入好物列表和详情,个人中心查看商品订单;管理中心新增「商品、库存与销售订单」。 + +- 管理员维护标题、HTTPS 封面(复用 COS 上传)、说明、价格、排序、上下架状态。 +- 各尺码、颜色按独立商品管理,在标题中标明规格;不引入购物车、物流、优惠券或复杂 SKU 系统。 +- 创建商品默认库存为 0;保存后通过库存调整补货。调整数量为有符号整数,必须填写原因。 +- 顾客填写取货人、手机号及数量,确认购买后微信付款。价格变化时先阻止下单,要求确认最新价格。 +- 付款成功后显示提货码。管理员核对商品数量和取货人,输入顾客的 12 位提货码并确认交付。 +- 管理端支持商品搜索、订单状态筛选、商品名 / 订单号 / 手机号搜索、分页、累计成交额、销量、待取货数、低库存提示。 +- 售后通过详情页「联系场馆」沟通;本版不含线上退款流程。不能把已付款订单当作未付款订单取消。若后续引入商户平台退款,需配套退款对账及库存回补逻辑,不能仅修改订单状态。 + +## 现有架构与接入方式 + +仓库为 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. 状态更新带条件,重复回调、重复取消或并发扫描不会重复计算销量、重复返还库存。核销仅允许 PAID + 正确提货码,并记录操作人/时间。 +10. 管理员保存商品和库存均校验版本;发生竞争时提示刷新。库存调整记录最近 50 条人工调整原因及操作人;系统预占/释放由订单本身追溯。 + +状态:`PENDING → PAID → COMPLETED`;`PENDING → CANCELLED`。商品可下架但不删除,保证历史订单有效。 + +## 接口 + +公开:`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 ` diff --git a/packages/app/src/components/ProfileMenu.vue b/packages/app/src/components/ProfileMenu.vue index 532c276..c1fabec 100644 --- a/packages/app/src/components/ProfileMenu.vue +++ b/packages/app/src/components/ProfileMenu.vue @@ -41,6 +41,7 @@ const emit = defineEmits<{ const menuItems = computed(() => { const items: MenuItem[] = [ + { key: 'shop-orders', type: 'item', title: '我的商品订单', path: '/pages/shop/orders', requireAuth: true }, { key: 'progress', type: 'item', title: '我的成长档案', path: '/pages/profile/progress', requireAuth: true }, { key: 'portrait', type: 'item', title: '身体状态评估', path: '/pages/portrait/index' }, { key: 'plan', type: 'item', title: '我的改善计划', path: '/pages/portrait/plan', requireAuth: true }, diff --git a/packages/app/src/components/ShopOrders.vue b/packages/app/src/components/ShopOrders.vue new file mode 100644 index 0000000..2cf36c1 --- /dev/null +++ b/packages/app/src/components/ShopOrders.vue @@ -0,0 +1,238 @@ + + + + diff --git a/packages/app/src/pages.json b/packages/app/src/pages.json index 6ae4ed8..6ff1d1d 100644 --- a/packages/app/src/pages.json +++ b/packages/app/src/pages.json @@ -206,6 +206,18 @@ "style": { "navigationStyle": "custom" } + }, + { + "path": "products", + "style": { + "navigationStyle": "custom" + } + }, + { + "path": "product-edit", + "style": { + "navigationStyle": "custom" + } } ] }, @@ -249,6 +261,29 @@ } } ] + }, + { + "root": "pages/shop", + "pages": [ + { + "path": "index", + "style": { + "navigationStyle": "custom" + } + }, + { + "path": "detail", + "style": { + "navigationStyle": "custom" + } + }, + { + "path": "orders", + "style": { + "navigationStyle": "custom" + } + } + ] } ], "globalStyle": { diff --git a/packages/app/src/pages/admin/index.vue b/packages/app/src/pages/admin/index.vue index 5476cb6..59e70df 100644 --- a/packages/app/src/pages/admin/index.vue +++ b/packages/app/src/pages/admin/index.vue @@ -62,6 +62,9 @@ + 馆内好物 + 商品、库存与销售订单› + 系统设置 diff --git a/packages/app/src/pages/admin/product-edit.vue b/packages/app/src/pages/admin/product-edit.vue new file mode 100644 index 0000000..f7d2c7c --- /dev/null +++ b/packages/app/src/pages/admin/product-edit.vue @@ -0,0 +1,299 @@ +