Compare commits

...
15 Commits
Author SHA1 Message Date
lai_hong c07e03333c merge: record R2 address-design history 2026-09-16 20:21:00 +08:00
lai_hong e5deacd19b Merge remote-tracking branch 'refs/remotes/submission/feat/r1-catalog' into consolidate-main 2026-09-16 20:20:59 +08:00
lai_hong aed7d12051 fix(migration): tolerate existing order request id 2026-09-16 20:13:15 +08:00
lhmin0604andClaude 854ea103a9 docs(contract): §5 补记 GET /api/design-list/:id(后端已实现,R3 fetchDesign 消费)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-16 20:11:09 +08:00
lhmin0604andClaude 46b150e3b8 feat(orders): 对齐契约 §6——服务端重算金额/地址快照/requestId 幂等/分页 + confirm/cancel 接口
- POST /api/orders 改为 { addressId, items[{productId,quantity}], designListId?, requestId? },
  totalAmount 按 Product 真实价格重算,客户端不再可传金额(DTO 白名单同步收紧)
- requestId 幂等:Order 新增唯一列 + 迁移 20260915000000,重复请求返回已创建订单
- GET /api/orders 支持 status/page/pageSize,返回 { list, total, page, pageSize }
- GET /api/orders/:id 补本人校验(越权 403 / 不存在 404)
- 新增 PATCH /api/orders/:id/confirm(SHIPPED→COMPLETED)、POST /api/orders/:id/cancel(仅 PENDING)
- 下单事务内推进设计清单 DRAFT/SUBMITTED→PROCESSING(单向,R2 交接的状态触发权)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-16 20:11:00 +08:00
lhmin0604andClaude 17e4e7f560 docs(contract): §5 补记 GET /api/design-list/:id(后端已实现,R3 fetchDesign 消费)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-15 08:15:10 +08:00
lhmin0604andClaude c48e79802c fix(orders): getOwned 统一带 relations,修复 include 分支的类型错误
Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-15 06:08:14 +08:00
lhmin0604andClaude 05584172ca feat(orders): 对齐契约 §6——服务端重算金额/地址快照/requestId 幂等/分页 + confirm/cancel 接口
- POST /api/orders 改为 { addressId, items[{productId,quantity}], designListId?, requestId? },
  totalAmount 按 Product 真实价格重算,客户端不再可传金额(DTO 白名单同步收紧)
- requestId 幂等:Order 新增唯一列 + 迁移 20260915000000,重复请求返回已创建订单
- GET /api/orders 支持 status/page/pageSize,返回 { list, total, page, pageSize }
- GET /api/orders/:id 补本人校验(越权 403 / 不存在 404)
- 新增 PATCH /api/orders/:id/confirm(SHIPPED→COMPLETED)、POST /api/orders/:id/cancel(仅 PENDING)
- 下单事务内推进设计清单 DRAFT/SUBMITTED→PROCESSING(单向,R2 交接的状态触发权)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-15 06:06:38 +08:00
lai_hong 3e4c88768f docs: add R1 frontend backend task report 2026-09-14 19:48:36 +08:00
lai_hong d27753f732 chore: snapshot handoff documents before home search redesign 2026-09-14 10:08:19 +08:00
lai_hong 679e472570 fix(order): align prisma schema with order service 2026-09-13 22:22:42 +08:00
lhmin0604 c6877c355a merge: integrate catalog into order payment flow 2026-09-13 18:18:14 +08:00
lhmin0604 16e625aefb feat(r3): complete order and payment flow 2026-09-13 13:56:59 +08:00
lai_hong 59e398fcf9 feat(catalog): add product catalog APIs 2026-09-13 11:17:53 +08:00
lhmin0604andClaude 63c0013076 Merge branch 'feat/r2-address-design' into main
R2 地址与设计清单完整 CRUD(事务/归属校验/状态机/2MB 限制)、
PATCH /api/users/me 补齐、api-contract §5 措辞修正与实现注记

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-12 20:41:19 +08:00
34 changed files with 1238 additions and 139 deletions
+13
View File
@@ -0,0 +1,13 @@
# 不把 Windows 本机依赖、构建产物或密钥复制进 Linux 镜像。
node_modules
dist
.env
.env.*
!.env.example
.git
.gitignore
coverage
*.log
logs
.vscode
.idea
+17
View File
@@ -0,0 +1,17 @@
# 仅用于本机联调。与 docker-compose.yml 一起使用:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
services:
app:
build:
context: .
dockerfile: docker/Dockerfile.dev
environment:
NODE_ENV: development
volumes:
# Windows 只保存源码;Node、Prisma 和 NestJS 均在 Linux 容器内运行。
- .:/app
# 避免 Windows 的 node_modules 覆盖容器内 Linux 依赖。
- wxmp_node_modules:/app/node_modules
volumes:
wxmp_node_modules:
+14
View File
@@ -0,0 +1,14 @@
# 本机开发镜像:运行在 Linux 容器中,保留 devDependencies 以支持热重载和 seed。
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY prisma ./prisma
RUN npx prisma generate
COPY . .
EXPOSE 3090
CMD ["sh", "-c", "npx prisma migrate deploy && npm run start:dev"]
+210
View File
@@ -0,0 +1,210 @@
# 智绘微刻小程序 R1 前后端任务报告
> 报告日期:2026-09-14
> 关联前端:`wechat_wc`Gitee
> 关联后端:`wxmp_backend`GitHub
> 本报告用于说明当前小程序的整体功能、R1 商品目录工作、前后端联调结果以及可交付状态。
## 一、项目概况
“智绘微刻”是一个微信小程序定制商城。用户可以浏览激光微雕商品,查看商品详情,制作个性化设计,保存设计清单,填写收货地址并提交订单。后端负责统一保存商品、用户、地址、设计、订单和支付等业务数据,前端负责小程序页面展示和交互。
简单来说,整个项目是一条完整的定制购物链路:
**浏览商品 → 查看详情 → 进入定制 → 保存设计 → 选择地址 → 提交订单 → 查看订单状态。**
前端采用 Taro + React + TypeScript,可编译为微信小程序;后端采用 NestJS + Prisma,使用 PostgreSQL 保存业务数据,Redis 用于缓存或队列能力。开发环境通过 Docker Compose 统一运行,减少 Windows 与 Linux 部署环境之间的差异。
## 二、当前小程序已经具备的功能
### 1. 首页
- 小程序品牌标题和搜索入口。
- 定制成品展示区。
- 热门品类图片入口。
- 为你推荐商品区。
- 搜索商品时只显示搜索结果,自动隐藏成品展示、热门品类和推荐区,避免内容混杂。
- 商品价格、名称和图片优先来自后端接口,接口暂时不可用时保留本地缓存展示。
### 2. 商品目录与详情
- 商品列表页展示在售商品。
- 支持商品图片、名称、描述和价格展示。
- 商品详情页展示主图、名称、价格、原价、商品介绍、设计理念、使用场景、标签和规格参数。
- 商品详情页保留原有的沉浸式主图收束动画和滚动过渡。
- 顶部返回按钮和底部返回按钮均可返回商品列表。
- 商品列表和商品详情均已接入后端,而不是只依赖前端写死数据。
### 3. 个性化定制
- 定制页面支持词云、底图和贴纸等设计元素。
- 支持贴纸编辑、位置调整、缩放和旋转。
- 设计数据按统一结构保存,可供后续生产投递使用。
- 支持从设计清单继续编辑。
### 4. 设计清单
- 查看个人设计清单。
- 按“未设计、设计中、生产中、已下单”等状态查看。
- 进入设计、继续设计和批量删除。
- 设计数据支持本地暂存与后端同步。
### 5. 地址与订单
- 收货地址新增、修改、删除和设置默认地址。
- 结算页展示设计预览、商品数量、价格和收货地址。
- 下单时由后端根据真实商品价格重新计算金额,避免只相信前端传来的价格。
- 订单列表、订单详情、订单状态和支付入口已经接入项目流程。
### 6. 用户与其他页面
- 微信登录、用户资料和个人中心。
- 订单、设计清单、地址、设置、协议和客服页面。
- 词云生成任务、任务状态查询和结果获取接口。
- OSS 图片资源和上传凭证接口。
## 三、R1 商品目录工作内容
R1 的核心不是单独做一个静态目录页面,而是建立“商品目录数据通路”:后端提供统一商品数据,前端通过接口读取并展示,其他成员的设计、订单和生产功能都使用同一份商品信息。
### 后端已完成
- 商品分类数据模型和分类列表接口。
- 商品数据模型、在售状态和排序字段。
- 商品列表接口,支持分页、分类筛选和关键词搜索。
- 商品详情接口,只返回在售商品。
- 创建商品接口,供管理后台或初始化数据使用。
- DTO 参数校验、分页上限和统一金额输出。
- Prisma 数据库迁移和种子数据。
- Swagger 接口说明和 `api-contract-v1.md` 契约同步。
### 前端已完成
- 首页、商品列表页调用 `fetchProducts`
- 商品详情页调用 `fetchProduct`
- 增加后端字段到前端商品模型的适配处理。
- 服务端字段不完整时,使用本地商品资料补齐介绍、规格、标签和图片。
- 商品价格、名称和图片不再由页面单独维护,减少前后端数据不一致。
### R1 对外接口
| 接口 | 用途 | 状态 |
|---|---|---|
| `GET /api/categories` | 获取商品分类 | 已完成 |
| `GET /api/products` | 获取在售商品列表,支持分页、分类和关键词 | 已完成 |
| `GET /api/products/:id` | 获取商品详情 | 已完成 |
| `POST /api/products` | 创建商品,管理端使用 | 已完成 |
接口契约中的基础路径、鉴权规则、分页格式、金额格式和错误码保持统一,后续成员可直接按契约消费,不需要各自猜测字段。
## 四、前后端联调与验证结果
本项目已经进行过本机容器化联调和前端编译验证。
### 已验证内容
1. Docker Compose 可以启动后端、PostgreSQL 和 Redis 容器。
2. 后端健康检查可用,Swagger 文档可以打开。
3. 商品列表接口和商品详情接口可以返回商品数据。
4. 前端商品列表和详情页能够读取后端数据。
5. 修改后端数据库中的商品价格后,重新编译并刷新前端,页面价格同步变化。
6. 该价格变化证明前端不是只显示写死数据,前后端数据通路已经打通。
7. 商品详情页的介绍文本、主图、价格和滚动过渡已恢复并编译通过。
8. 自定义底部栏增加了独立点击入口,降低不同微信基础库下点击失效的概率。
### 当前验证结论
商品目录接口已经经过实际联调,可以作为其他成员开发设计清单、订单和生产流程时的共同数据来源。前端和后端的目录字段能够对齐,商品信息修改能够传递到页面,R1 商品目录部分达到可交付状态。
## 五、工作量概览
工作量不仅包括写几个接口,还包括数据模型、接口契约、种子数据、容器化环境、前端适配和联调验证。
### 后端工作量
- 商品分类和商品数据结构设计。
- Prisma schema 与迁移脚本。
- 商品列表、详情和创建接口。
- 分页、关键词、分类筛选和在售状态过滤。
- DTO 校验和金额类型转换。
- 商品种子数据维护。
- Docker 开发镜像和 Compose 联调配置。
- 接口契约、对接说明和验证说明文档。
R1 商品目录实现涉及约 15 个后端文件,包含接口、服务、DTO、数据库迁移、种子数据、Docker 配置和文档。
### 前端工作量
- 首页商品数据接入。
- 商品列表页和详情页数据接入。
- 前后端商品字段适配及本地兜底。
- 商品搜索结果布局优化。
- 热门品类改为图片入口,降低图标入口的识别成本。
- 商品详情页介绍文本和规格信息补齐。
- 沉浸式详情页滚动过渡恢复和节流优化。
- 详情页返回逻辑兼容处理。
- 自定义底部栏点击逻辑增强。
- 微信小程序构建产物更新。
前端 R1 相关改动覆盖首页、商品列表、商品详情、商品接口适配、自定义底部栏及对应编译产物。
## 六、项目的独特性和提升点
### 1. 商品数据真正统一
商品价格、图片、描述和规格由后端统一维护,首页、商品目录、详情、定制和订单可以使用同一商品记录。后续调整价格或下架商品时,不需要逐个修改前端页面。
### 2. 面向定制生产,而不是普通电商展示
商品信息不仅用于展示,还与设计数据、词云结果、贴纸和后续生产投递关联。设计清单中的数据结构为后续生成生产文件保留了统一入口。
### 3. 前端具备离线兜底能力
当开发环境接口暂时不可用时,页面仍可以使用本地商品配置展示,方便开发调试;接口恢复后再以服务端数据为准。
### 4. 详情页具有沉浸式交互
商品详情页不是简单的静态表格,而是通过主图收束、标题接替、内容渐入和滚动节流,形成更接近实物定制展示的浏览体验。
### 5. 开发环境可迁移
Docker Compose 将 Node、PostgreSQL 和 Redis 组合在一起,本机 Windows 环境可以模拟 Linux 容器运行方式,后续迁移到服务器时主要调整环境变量和反向代理配置。
### 6. 契约优先的多人协作
前后端以 `api-contract-v1.md` 为共同依据,接口字段、金额、分页、鉴权和错误码都有明确约定。R2、R3、R4 成员可以在不复制整套项目的情况下,直接消费 R1 商品接口。
## 七、代码交付情况
### 前端
- 远程仓库:Gitee `lhmin0604/wechat_wc`
- 分支:`feat/r1-catalog`
- 最新提交:`99bcf4a fix(ui): restore tab bar and detail navigation`
- 状态:已推送,工作区干净
### 后端
- 远程仓库:GitHub `obroccolio/wxmp_backend`
- 分支:`feat/r1-catalog`
- 最新提交:`d27753f chore: snapshot handoff documents before home search redesign`
- 状态:已推送,工作区干净
两个仓库均保留独立分支,其他成员可以先拉取分支检查,再合并到各自主干,不会覆盖主干历史。
## 八、仍需在上线前确认的事项
以下内容不影响 R1 商品目录已经完成,但在真机和正式上线前仍需确认:
- 微信公众平台 AppID、AppSecret 与后端环境变量必须属于同一个小程序。
- 真机不能访问本机 `127.0.0.1`,需要使用 HTTPS 域名、FRP 或服务器反向代理。
- 微信公众平台需要配置后端接口合法域名和 OSS 图片合法域名。
- 生产环境需要使用正式数据库、Redis、OSS 和 JWT 密钥,不能沿用本机测试值。
- 真机测试时应再次检查首页、商品列表、商品详情、设计清单、地址和订单请求是否返回 200。
## 九、最终结论
当前项目已经形成一个可运行的定制商城小程序雏形,前端页面、后端业务接口、数据库、Docker 开发环境和多人协作契约已经连接起来。
R1 商品目录的核心目标已经完成:后端提供可复用的商品分类、商品列表和商品详情接口,前端已经完成真实接口对接,并通过修改后端商品价格后前端同步变化的方式验证了数据通路。该接口可以交给其他成员继续用于设计清单、订单和生产流程开发。
+172
View File
@@ -0,0 +1,172 @@
# R1 商品目录接口对接说明
**提供方:成员 1R1 商品目录)**
**消费者:成员 2(设计清单)、成员 3(订单)、成员 4(设计/生产数据)**
**分支:`feat/r1-catalog`**
本文档是 R1 向其他路线交付商品数据的简明使用说明。字段、路径与
[`api-contract-v1.md`](./api-contract-v1.md) 第 3 节一致;发生冲突时以 API 契约为准。
## 1. 使用边界
- 商品与分类查询都是公开接口,调用时必须传 `auth: false`,不依赖登录态。
- 消费方只能读取在售商品;下架或不存在的商品详情返回 `404`
- `productId` 是跨路线的稳定业务 ID,不能用数据库生成顺序、数组下标或展示名称替代。
- `price` 是服务端权威价格。前端可展示,**不能**作为下单金额依据。
## 2. 稳定商品 ID
| productId | 商品 |
|---|---|
| `notebook-small` | 微雕笔记本(小) |
| `notebook-large` | 微雕笔记本(大) |
| `coaster` | 铜质杯垫 |
| `penbox` | 竹制笔盒 |
| `booklamp` | 书本型灯 |
每个商品都对应一个稳定分类 ID`cat-<productId>`,例如 `cat-penbox`
## 3. 查询接口
### `GET /api/categories`
返回扁平分类数组,已按 `sort` 升序排列。
```ts
type CategoryDTO = {
id: string
name: string
parentId?: string | null
sort: number
}
```
### `GET /api/products`
查询参数均可选:
```ts
{
page?: number // 默认 1
pageSize?: number // 默认 20,最大 100
categoryId?: string
keyword?: string // 匹配名称、副标题、描述
}
```
响应 `data`
```ts
{
list: ProductDTO[]
total: number
page: number
pageSize: number
}
```
### `GET /api/products/:id`
返回一个在售商品;商品不存在或不是 `ON_SALE` 时返回 `404`
```ts
type ProductDTO = {
id: string
name: string
categoryId?: string
price: number // 元,服务端已转为 number
originalPrice?: number
leadTime: string
subtitle?: string
description?: string
story?: string
scene?: string
tags: string[]
specs: [string, string][]
tone: [number, number, number]
mask: {
shape: 'rect' | 'circle'
width: number
height: number
borderRadius?: number
}
images: string[]
iconImg?: string
status: 'ON_SALE'
sort: number
}
```
所有接口沿用统一响应包裹:`{ code: 0, message: 'ok', data }`
## 4. 前端调用方式
前端消费者统一从 R1 API 层调用,不能在页面直接拼 HTTP 请求:
```ts
import { fetchCategories, fetchProduct, fetchProducts } from '../../utils/api/product'
const { list } = await fetchProducts({ keyword: '笔记本', page: 1, pageSize: 20 })
const product = await fetchProduct('penbox')
const categories = await fetchCategories()
```
## 5. 给成员 2:设计清单
设计清单保存商品相关数据时使用:
```ts
{
productId: product.id,
productName: product.name,
unitPrice: product.price, // 仅展示/设计清单快照;不是订单结算依据
count: quantity,
designData: {
category: { id: product.id, mask: product.mask, tone: product.tone },
},
}
```
`mask``tone` 必须原样保留,供 R4 构造生产画布;不要把 `productName` 当作关联键。
## 6. 给成员 3:订单
R3 创建订单时,客户端请求只传:
```ts
{
items: [{ productId: 'penbox', quantity: 1 }]
}
```
R3 服务端必须:
1.`productId` 查询商品并确认 `status === ON_SALE`
2. 以服务端 `price` 重算订单总价;
3.`productId``name``price``quantity` 写进 `OrderItem` 快照;
4. 忽略客户端传入的单价、商品名和总金额。
## 7. 给成员 4:设计与生产
R4 若需要从商品恢复画布尺寸或主题色,使用 `ProductDTO.mask``ProductDTO.tone`
生产任务内保存的是设计快照;不要仅凭商品名称重新定位商品。
## 8. 联调前置条件
R1 合入或本地联调前,后端需要先执行数据库迁移与种子数据:
```bash
npm run prisma:migrate
npm run prisma:seed
```
最小 smoke 验收:
```text
GET /api/categories
GET /api/products?page=1&pageSize=20
GET /api/products/penbox
GET /api/products/not-on-sale-or-missing -> 404
```
Binary file not shown.
Binary file not shown.
+90
View File
@@ -0,0 +1,90 @@
# R1 商品目录交付说明
**提供方:成员 1R1 商品目录)**
**可使用成员:成员 2(设计清单)、成员 3(订单)、成员 4(设计与生产)**
**分支:`feat/r1-catalog`**
R1 商品目录已完成,可以对外提供商品数据。商品页面现在从后端接口读取商品信息,不再以写死在前端的商品数据作为主数据源。本说明中的验证结果用于证明商品目录已经真实可用;接口说明用于方便其他成员接入这份目录数据。
## 已完成内容
| 完成项 | 结果 |
| --- | --- |
| 后端商品数据 | 已建立商品分类及 5 个在售商品的本机数据。 |
| 商品接口 | 已提供商品列表、单个商品详情和分类列表接口。 |
| 前端商品页面 | 首页、商品列表、商品详情、立即定制页均已接入商品接口。 |
| 本机联调环境 | Docker 中的后端、数据库、Redis 已启动;微信开发者工具可正常运行前端。 |
## 已完成前后端数据通路验证
这次验证不只是确认接口能访问,而是确认后端数据变化会传到前端页面,证明前端展示的确实是后端商品数据。
| 验证步骤 | 验证结果 |
| --- | --- |
| 读取商品初始值 | `notebook-small` 的价格为 12 元,划线原价为 18 元。 |
| 修改后端数据库 | 将该商品测试价格改为 19 元,测试划线原价改为 29 元。 |
| 重新编译小程序 | 微信开发者工具重新编译后,商品卡片和详情页同步显示 19 元、29 元。 |
| 验证结论 | 前端展示随数据库和接口返回同步改变,前后端商品数据通路正常。 |
![image-20260912143719516](C:\Users\24595\AppData\Roaming\Typora\typora-user-images\image-20260912143719516.png)
![image-20260912143750067](C:\Users\24595\AppData\Roaming\Typora\typora-user-images\image-20260912143750067.png)
**结论:** R1 商品页面读取的是后端真实商品数据;后端数据变化能够传递到前端页面。因此以下接口已完成实际联调验证,可作为其他路线的商品数据来源。
## 对外使用约定
| 约定 | 说明 |
| --- | --- |
| 稳定商品 ID | 跨模块使用 `productId` 关联商品,例如 `notebook-small``coaster``penbox`。不要使用数组下标或商品名称作为关联键。 |
| 公开查询 | 商品与分类查询不依赖登录态;前端调用时使用 `auth: false`。 |
| 价格来源 | `price` 是服务端权威价格。前端可展示价格,订单金额必须由 R3 服务端重新查询并计算。 |
| 在售限制 | 商品列表与详情只返回 `ON_SALE` 商品;不存在或下架商品的详情返回 `404`。 |
## 商品接口
所有接口统一返回:`{ code: 0, message: 'ok', data }`。实际业务数据位于 `data` 中。
| 接口 | 用途 | 参数 | 验证状态 |
| --- | --- | --- | --- |
| `GET /api/categories` | 获取按 `sort` 排序的分类列表 | 无 | 已验证 |
| `GET /api/products` | 获取在售商品分页列表 | `page``pageSize``categoryId``keyword` 均可选 | 已验证 |
| `GET /api/products/:id` | 获取一个在售商品的完整详情 | `id` 为稳定商品 ID | 已验证 |
### 商品数据字段
| 字段 | 含义 |
| --- | --- |
| `id``name``categoryId``status``sort` | 商品标识、名称、分类、在售状态和稳定排序。 |
| `price``originalPrice``leadTime` | 商品价格、划线原价和制作周期。 |
| `subtitle``description``story``scene` | 商品卖点、介绍、设计理念和使用场景。 |
| `images``iconImg``tone` | 商品图片、图标与详情页视觉主题色。 |
| `tags``specs``mask` | 商品标签、规格参数和定制画布信息。 |
### 可用商品 ID
| productId | 商品名称 | 对应分类 ID |
| --- | --- | --- |
| `notebook-small` | 微雕笔记本(小) | `cat-notebook-small` |
| `notebook-large` | 微雕笔记本(大) | `cat-notebook-large` |
| `coaster` | 铜质杯垫 | `cat-coaster` |
| `penbox` | 竹制笔盒 | `cat-penbox` |
| `booklamp` | 书本型灯 | `cat-booklamp` |
## 给其他成员的使用说明
| 成员 | 可以使用的商品数据 |
| --- | --- |
| 成员 2:设计清单 | 保存 `productId``productName``price``count`;设计数据中原样保留 `mask``tone`。 |
| 成员 3:订单 | 客户端只传 `productId``quantity`;订单服务端必须重新读取 `price` 计算金额。 |
| 成员 4:设计与生产 | 使用 `mask` 确定画布尺寸,使用 `tone` 作为主题色;生产任务保存商品 ID 与设计快照。 |
## 本机联调地址
- 后端接口:`http://127.0.0.1:3090`
- Swagger 接口页面:`http://127.0.0.1:3090/docs`
- 小程序导入目录:`wechat_wc/dist`
以上地址只适用于本机模拟器联调。真机和生产环境需要替换为已配置合法域名的 HTTPS 地址。
Binary file not shown.
+7 -1
View File
@@ -56,7 +56,7 @@
更新当前用户资料。请求体:`{ nickname?: string, avatar?: string }`
## 3. 商品与分类(R1,待实现
## 3. 商品与分类(R1
### GET /api/categories
@@ -75,6 +75,8 @@
仅返回 `status=ON_SALE` 的商品。
响应 `data``{ list: ProductDTO[], total: number, page: number, pageSize: number }`
### GET /api/products/:id
公开接口。返回单个在售商品;不存在返回 404。
@@ -149,6 +151,10 @@
返回当前用户设计清单,按 `createdAt` 倒序。
### GET /api/design-list/:id
返回单条设计清单(后端已实现 `@Get(':id')`R3 结算页 `fetchDesign` 消费)。仅本人可查,越权 403 / 不存在 404。
### POST /api/design-list
请求体:
+125
View File
@@ -0,0 +1,125 @@
# 本机容器化联调教程
这套方案的目标是:**Windows 只负责编辑源码和运行微信开发者工具;PostgreSQL、Redis、Node.js/NestJS、Prisma 都运行在 Linux 容器中。** 因此本机行为尽量贴近未来 Linux 服务器,避免把 Windows 的 Node、数据库服务或路径习惯带进部署产物。
## 先理解两种 Compose 运行方式
| 目的 | 命令 | 特性 |
| --- | --- | --- |
| 日常开发、改代码自动重载 | `docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build` | 源码挂载进 Linux 开发容器,NestJS 热重载 |
| 发布前模拟 | `docker compose up -d --build` | 使用 `docker/Dockerfile` 的多阶段生产构建,不挂载源码 |
两种方式共用 PostgreSQL、Redis、迁移文件和 `.env`;不要同时启动它们。日常使用第一种,准备交付或部署前再使用第二种。
> Windows 要运行 `node:alpine`、`postgres:alpine` 这类 Linux 镜像,Docker Desktop 底层必须使用 **WSL 2、Hyper-V 或 Docker VMM** 之一。你不需要在 WSL 里写代码或打开 Ubuntu;它只是 Docker 的 Linux 运行底座。若完全不允许这些虚拟化能力,就只能改用远程 Linux 测试机,无法在 Windows 本机运行 Linux Compose。
## 一次性安装
1. 在 BIOS/UEFI 确认开启 CPU 虚拟化(Intel VT-x / AMD-V)。
2. 以管理员身份打开 PowerShell,执行 `wsl --install`;已安装时执行 `wsl --update`,按提示重启。
3. 安装 Docker Desktop for Windows,首次启动时选择 **Use WSL 2 instead of Hyper-V**。无需在 WSL 内安装 Node、PostgreSQL 或 Redis。
4. 重启 Docker Desktop 后,在普通 PowerShell 验证:
```powershell
docker version
docker compose version
```
安装依据见 [Docker Desktop for Windows 官方文档](https://docs.docker.com/desktop/setup/install/windows-install/) 与 [WSL 2 后端说明](https://docs.docker.com/desktop/features/wsl/)。
## 启动后端联调环境
以下命令均在 `G:\wordcloud_wechat\wxmp_backend` 执行。
1. 创建只属于本机的配置文件(该文件被 Git 忽略):
```powershell
Copy-Item .env.example .env
```
2. 打开 `.env`,至少改成下列本机联调值。不要提交 `.env`,真实微信密钥也不要放入前端。
```dotenv
NODE_ENV=development
APP_PORT=3090
JWT_SECRET=dev-only-change-this-to-a-long-random-string
WX_APPID=local-dev-appid
WX_SECRET=local-dev-secret
WX_MOCK_LOGIN=1
```
`DATABASE_URL` 和 `REDIS_URL` 可保持示例值:Compose 会在容器内自动改为 `postgres`、`redis` 服务地址。
3. 首次先拉取所有公开测试镜像,再构建并启动开发环境:
```powershell
docker compose -f docker-compose.yml -f docker-compose.dev.yml pull
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
docker compose -f docker-compose.yml -f docker-compose.dev.yml ps
```
预期 `postgres`、`redis`、`app` 都是 running/healthy。查看后端实时日志:
```powershell
docker compose -f docker-compose.yml -f docker-compose.dev.yml logs -f app
```
4. 导入 R1 商品初始数据(只需首次,或想恢复演示数据时执行):
```powershell
docker compose -f docker-compose.yml -f docker-compose.dev.yml exec app npm run prisma:seed
```
5. 在浏览器检查:
- `http://127.0.0.1:3090/docs`Swagger 接口页
- `http://127.0.0.1:3090/api/products`R1 商品列表 JSON
修改 `src/` 或 `prisma/` 后,开发 Compose 的 `app` 会热重载;变更依赖或 Dockerfile 后重新执行 `up -d --build`。停止环境用 `docker compose -f docker-compose.yml -f docker-compose.dev.yml down`。这不会删除数据库数据卷;需要完全清空数据时才使用 `down -v`。
## 微信开发者工具:打开哪里、怎样看到页面
应导入 **前端项目根目录**`G:\wordcloud_wechat\wechat_wc`。
不要导入工作区总目录 `G:\wordcloud_wechat`,也不要直接导入 `dist`。原因是前端根目录的 `project.config.json` 中已经声明 `miniprogramRoot: "dist/"`;开发者工具从该根目录读取项目配置,再把 `dist` 当作实际小程序输出。
首次操作如下:
1. 在一个 PowerShell 窗口进入前端目录,安装依赖并创建本机环境文件:
```powershell
cd G:\wordcloud_wechat\wechat_wc
npm ci
Copy-Item .env.example .env
npm run dev:weapp
```
最后一条命令保持运行。Taro 会持续把源码编译到 `dist/`;它不是服务器,也不需要在 Windows 安装后端 Node。
2. 打开微信开发者工具,选择 **导入项目**,目录选择 `G:\wordcloud_wechat\wechat_wc`。若提示 AppID,使用项目已有 AppID;仅查看 UI 也可选择测试号/游客模式(以工具实际选项为准)。
3. 等待终端首次构建完成,在开发者工具点顶部 **编译**。中间的 **模拟器** 就是小程序运行画面;切换底部或右侧的 **Console** 看前端异常、**Network** 看接口请求。以后保存 `src` 中的文件,Taro 会更新 `dist`,工具会自动或在点“编译”后刷新。
4. 为本机 HTTP 接口联调,打开开发者工具的 **详情 → 本地设置**,勾选“**不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书**”。仅本机开发使用,提交/真机预览前必须关闭。
前端 `.env` 已提供:
```dotenv
TARO_APP_API_BASE_URL=http://127.0.0.1:3090
```
它会在 `npm run dev:weapp` 编译时注入;`src/utils/request.ts` 因而请求本机 Docker 后端。更换 `.env` 后必须重启该 Taro 命令。Taro 环境变量与构建调试方式可参阅 [Taro 官方文档](https://docs.taro.zone/docs/env-mode-config) 和 [调试文档](https://docs.taro.zone/docs/envs-debug)。
## 联调检查顺序
```text
浏览器 /docs、/api/products 正常
Taro 终端构建成功,dist/ 更新
微信开发者工具导入 wechat_wc 并编译
Network 中商品请求为 http://127.0.0.1:3090/api/products,状态 200
商品页展示 R1 数据
```
注意:`127.0.0.1` 仅代表运行开发者工具的这台 Windows 电脑。它适合模拟器,不适合真实手机;真机或他人联调时,应改为可访问的 HTTPS 测试域名,并在微信公众平台配置合法 request 域名。生产环境通常让 Nginx/Caddy 作为 HTTPS 入口,应用、PostgreSQL、Redis 仍留在 Docker 内部网络,不对公网暴露。
+1 -1
View File
@@ -60,7 +60,7 @@
"typescript": "^5.6.0"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
"seed": "node prisma/seed.js"
},
"engines": {
"node": ">=22"
@@ -0,0 +1,22 @@
-- R1 商品目录:补齐小程序展示与下单所需的稳定商品字段。
ALTER TABLE "Product"
ADD COLUMN "originalPrice" DECIMAL(10,2),
ADD COLUMN "leadTime" TEXT NOT NULL DEFAULT '3-5个工作日',
ADD COLUMN "subtitle" TEXT,
ADD COLUMN "iconImg" TEXT,
ADD COLUMN "story" TEXT,
ADD COLUMN "scene" TEXT,
ADD COLUMN "tags" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "specs" JSONB NOT NULL DEFAULT '[]',
ADD COLUMN "tone" INTEGER[] NOT NULL DEFAULT ARRAY[]::INTEGER[],
ADD COLUMN "mask" JSONB,
ADD COLUMN "sort" INTEGER NOT NULL DEFAULT 0;
-- 兼容已有开发数据;R1 seed 会为每个正式商品写入真实 mask。
UPDATE "Product"
SET "mask" = '{"shape":"rect","width":300,"height":300}'::jsonb
WHERE "mask" IS NULL;
ALTER TABLE "Product" ALTER COLUMN "mask" SET NOT NULL;
CREATE INDEX "Product_status_sort_idx" ON "Product"("status", "sort");
@@ -0,0 +1,19 @@
-- R1 product fields and R3 order idempotency fields.
ALTER TABLE "Product"
ADD COLUMN IF NOT EXISTS "originalPrice" DECIMAL(10,2),
ADD COLUMN IF NOT EXISTS "leadTime" TEXT NOT NULL DEFAULT '7-10 个工作日',
ADD COLUMN IF NOT EXISTS "subtitle" TEXT,
ADD COLUMN IF NOT EXISTS "tone" INTEGER[] NOT NULL DEFAULT ARRAY[]::INTEGER[],
ADD COLUMN IF NOT EXISTS "tags" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
ADD COLUMN IF NOT EXISTS "specs" JSONB,
ADD COLUMN IF NOT EXISTS "mask" JSONB,
ADD COLUMN IF NOT EXISTS "story" TEXT,
ADD COLUMN IF NOT EXISTS "scene" TEXT,
ADD COLUMN IF NOT EXISTS "iconImg" TEXT,
ADD COLUMN IF NOT EXISTS "sort" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "Order"
ADD COLUMN IF NOT EXISTS "requestId" TEXT,
ADD COLUMN IF NOT EXISTS "paidAt" TIMESTAMP(3);
CREATE UNIQUE INDEX IF NOT EXISTS "Order_userId_requestId_key" ON "Order"("userId", "requestId");
@@ -0,0 +1,11 @@
-- 待付款订单统一在服务端以创建后 30 分钟为截止时间,避免只由客户端倒计时控制。
ALTER TYPE "OrderStatus" ADD VALUE IF NOT EXISTS 'PAYMENT_EXPIRED';
ALTER TABLE "Order" ADD COLUMN IF NOT EXISTS "paymentExpiresAt" TIMESTAMP(3);
-- 为已有待付款订单补齐截止时间;历史订单会按创建时间计算并在下一次读取时自动过期。
UPDATE "Order"
SET "paymentExpiresAt" = "createdAt" + INTERVAL '30 minutes'
WHERE "paymentExpiresAt" IS NULL AND "status" = 'PENDING';
CREATE INDEX IF NOT EXISTS "Order_paymentExpiresAt_idx" ON "Order"("paymentExpiresAt");
@@ -0,0 +1,4 @@
-- 订单幂等键(api-contract-v1 §6requestId 防重复下单,重复请求返回已创建订单)
-- 允许已有 R1/R3 本地库安全接入 R2:字段已存在时不应中断整套迁移。
ALTER TABLE "Order" ADD COLUMN IF NOT EXISTS "requestId" TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS "Order_requestId_key" ON "Order"("requestId");
+28 -9
View File
@@ -52,15 +52,26 @@ model Category {
// ── 商品 ──────────────────────────────────────────────
model Product {
id String @id @default(cuid())
name String
categoryId String?
price Decimal @db.Decimal(10, 2)
images String[]
description String?
status ProductStatus @default(DRAFT)
category Category? @relation(fields: [categoryId], references: [id])
orderItems OrderItem[]
id String @id @default(cuid())
name String
categoryId String?
price Decimal @db.Decimal(10, 2)
originalPrice Decimal? @db.Decimal(10, 2)
leadTime String @default("3-5个工作日")
subtitle String?
images String[]
iconImg String?
description String?
story String?
scene String?
tags String[] @default([])
specs Json @default("[]")
tone Int[] @default([])
mask Json
sort Int @default(0)
status ProductStatus @default(DRAFT)
category Category? @relation(fields: [categoryId], references: [id])
orderItems OrderItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -127,8 +138,13 @@ model Order {
status OrderStatus @default(PENDING)
totalAmount Decimal @db.Decimal(10, 2)
addressSnapshot Json
// 待付款订单的服务端截止时间
paymentExpiresAt DateTime?
paidAt DateTime?
// 关联的设计清单(R4 下单后 WCD 派单据此读取 designData
designListId String?
// 客户端幂等键(api-contract-v1 §6requestId 重复请求返回已创建订单)
requestId String? @unique
user User @relation(fields: [userId], references: [id])
designList DesignList? @relation(fields: [designListId], references: [id])
items OrderItem[]
@@ -140,6 +156,8 @@ model Order {
@@index([userId])
@@index([status])
@@index([designListId])
@@index([paymentExpiresAt])
@@unique([userId, requestId])
}
enum OrderStatus {
@@ -149,6 +167,7 @@ enum OrderStatus {
SHIPPED
COMPLETED
CANCELLED
PAYMENT_EXPIRED
}
model OrderItem {
+20
View File
@@ -0,0 +1,20 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
const products = [
{ id: 'notebook-small', name: '微雕笔记本(小)', price: 12, originalPrice: 18, leadTime: '3-5个工作日', iconImg: '/icon/书本.png', images: ['/img/book_small/The1.jpg'], mask: { shape: 'rect', width: 300, height: 420, borderRadius: 8 }, tags: ['伴手礼', '学生', '手账'], sort: 1 },
{ id: 'notebook-large', name: '微雕笔记本(大)', price: 45, originalPrice: 58, leadTime: '3-5个工作日', iconImg: '/icon/书本_大.png', images: ['/img/book_big/The1.jpg', '/img/book_big/The2.jpg'], mask: { shape: 'rect', width: 340, height: 480, borderRadius: 8 }, tags: ['毕业礼', '团建', '企业定制'], sort: 2 },
{ id: 'coaster', name: '铜质杯垫', price: 25, originalPrice: 35, leadTime: '5-7个工作日', iconImg: '/icon/杯子.png', images: ['/img/cup/The1.jpg'], mask: { shape: 'circle', width: 280, height: 280 }, tags: ['乔迁礼', '黄铜', '桌面美学'], sort: 3 },
{ id: 'penbox', name: '竹制笔盒', price: 75, originalPrice: 98, leadTime: '7-10个工作日', iconImg: '/icon/笔盒.png', images: ['/img/penbox/The1.jpg', '/img/penbox/The2.jpg', '/img/penbox/The3.jpg'], mask: { shape: 'rect', width: 320, height: 160, borderRadius: 12 }, tags: ['文房', '书法', '天然材质'], sort: 4 },
{ id: 'booklamp', name: '书本型灯', price: 45, originalPrice: 68, leadTime: '5-7个工作日', iconImg: '/icon/书灯.png', images: ['/img/booklight/The1.jpg', '/img/booklight/The2.jpg'], mask: { shape: 'rect', width: 320, height: 240, borderRadius: 4 }, tags: ['情侣礼', '夜灯', '创意礼物'], sort: 5 },
];
async function main() {
const category = await prisma.category.upsert({ where: { id: 'cat-custom' }, update: { name: '定制商品', sort: 1 }, create: { id: 'cat-custom', name: '定制商品', sort: 1 } });
for (const product of products) {
await prisma.product.upsert({ where: { id: product.id }, update: { ...product, categoryId: category.id, status: 'ON_SALE', tone: [28, 22, 18] }, create: { ...product, categoryId: category.id, status: 'ON_SALE', tone: [28, 22, 18] } });
}
console.log(`Seed 完成:${products.length} 个在售商品已写入`);
}
main().catch((error) => { console.error(error); process.exitCode = 1; }).finally(() => prisma.$disconnect());
+32 -39
View File
@@ -1,46 +1,39 @@
import { PrismaClient } from '@prisma/client';
import { Prisma, PrismaClient, ProductStatus } from '@prisma/client';
const prisma = new PrismaClient();
const oss = 'https://wordcloudwechat.oss-cn-hangzhou.aliyuncs.com';
const catalog = [
{ id: 'notebook-small', categoryId: 'cat-notebook-small', categoryName: '微雕笔记本(小)', sort: 1, name: '微雕笔记本(小)', price: 12, originalPrice: 18, leadTime: '3-5个工作日', subtitle: '掌心间的专属印记 · 激光微雕', iconImg: '/icon/书本.png', description: '便携 A6 笔记本,PU 封面可激光微雕名字或图案。', story: '让纸笔的温度留下专属印记。', scene: '课堂笔记、旅行手账、日常备忘。', tags: ['伴手礼', '学生', '手账'], specs: [['尺寸', '105 × 148mmA6'], ['材质', '高档PU皮革']], tone: [28, 22, 18], mask: { shape: 'rect', width: 300, height: 420, borderRadius: 8 }, images: [`${oss}/img/book_small/The1.jpg`] },
{ id: 'notebook-large', categoryId: 'cat-notebook-large', categoryName: '微雕笔记本(大)', sort: 2, name: '微雕笔记本(大)', price: 45, originalPrice: 58, leadTime: '3-5个工作日', subtitle: '大篇幅定制首选 · 180°平摊', iconImg: '/icon/书本_大.png', description: 'A4 大开本,适合呈现班级名单、团队口号或公司 Logo。', story: '为一群人的记忆留下郑重的书写空间。', scene: '毕业纪念、企业礼品、团队伴手礼。', tags: ['毕业礼', '团建', '企业定制'], specs: [['尺寸', '210 × 297mmA4'], ['开合', '180°平摊']], tone: [35, 28, 22], mask: { shape: 'rect', width: 340, height: 480, borderRadius: 8 }, images: [`${oss}/img/book_big/The1.jpg`, `${oss}/img/book_big/The2.jpg`] },
{ id: 'coaster', categoryId: 'cat-coaster', categoryName: '铜质杯垫', sort: 3, name: '铜质杯垫', price: 25, originalPrice: 35, leadTime: '5-7个工作日', subtitle: '黄铜质感 · 拉丝哑光', iconImg: '/icon/杯子.png', description: '拉丝黄铜与软木防滑垫组合,适合刻印名字、日期或祝福。', story: '让日常器物留下温柔的氧化与记忆。', scene: '乔迁礼、新婚回礼、办公桌布置。', tags: ['乔迁礼', '新婚贺礼', '桌面美学'], specs: [['直径', '100mm'], ['材质', '黄铜 + 软木']], tone: [42, 30, 18], mask: { shape: 'circle', width: 280, height: 280 }, images: [`${oss}/img/cup/The1.jpg`] },
{ id: 'penbox', categoryId: 'cat-penbox', categoryName: '竹制笔盒', sort: 4, name: '竹制笔盒', price: 75, originalPrice: 98, leadTime: '7-10个工作日', subtitle: '天然楠竹 · 磁吸开合', iconImg: '/icon/笔盒.png', description: '天然楠竹打磨抛光,磁吸盒盖与分层隔板兼具实用和雅致。', story: '每一道竹纹都不重复,也值得留下不同的名字。', scene: '书房案头、教师节礼物、书法爱好者。', tags: ['文房', '书法', '教师节'], specs: [['尺寸', '200 × 65 × 30mm'], ['材质', '天然楠竹']], tone: [30, 24, 16], mask: { shape: 'rect', width: 320, height: 160, borderRadius: 12 }, images: [`${oss}/img/penbox/The1.jpg`, `${oss}/img/penbox/The2.jpg`] },
{ id: 'booklamp', categoryId: 'cat-booklamp', categoryName: '书本型灯', sort: 5, name: '书本型灯', price: 45, originalPrice: 68, leadTime: '5-7个工作日', subtitle: '开合即亮 · LED暖光', iconImg: '/icon/书灯.png', description: '打开即亮的暖光书灯,封面可定制文字、图案或照片。', story: '打开一本书,也打开一束为你而亮的光。', scene: '情侣礼物、毕业纪念、床头夜灯。', tags: ['情侣礼', '毕业纪念', '夜灯'], specs: [['光源', 'LED 暖白光(3000K'], ['续航', '6-8小时']], tone: [25, 20, 15], mask: { shape: 'rect', width: 320, height: 240, borderRadius: 4 }, images: [`${oss}/img/booklight/The1.jpg`, `${oss}/img/booklight/The2.jpg`] },
];
async function main() {
// 分类
const apparel = await prisma.category.upsert({
where: { id: 'cat-apparel' },
update: {},
create: { id: 'cat-apparel', name: '服饰', sort: 1 },
});
const tshirt = await prisma.category.upsert({
where: { id: 'cat-tshirt' },
update: {},
create: { id: 'cat-tshirt', name: 'T恤', parentId: apparel.id, sort: 1 },
});
// 示例商品
await prisma.product.upsert({
where: { id: 'prod-demo-tshirt' },
update: {},
create: {
id: 'prod-demo-tshirt',
name: '示例定制 T恤',
categoryId: tshirt.id,
price: 99.0,
images: [],
description: '用于本地开发联调的示例商品,可在此设计清单中上传自定义图案。',
status: 'ON_SALE',
},
});
for (const product of catalog) {
await prisma.category.upsert({
where: { id: product.categoryId },
update: { name: product.categoryName, sort: product.sort },
create: { id: product.categoryId, name: product.categoryName, sort: product.sort },
});
const { categoryName, ...data } = product;
if (!categoryName) throw new Error('分类名称不能为空');
const productData: Prisma.ProductUncheckedCreateInput = {
...data,
specs: data.specs as Prisma.InputJsonValue,
mask: data.mask as Prisma.InputJsonValue,
status: ProductStatus.ON_SALE,
};
await prisma.product.upsert({ where: { id: product.id }, update: productData, create: productData });
}
// eslint-disable-next-line no-console
console.log('Seed 完成:分类 + 示例商品已写入');
console.log('Seed 完成:5 个在售商品与稳定分类已写入');
}
main()
.catch((e) => {
// eslint-disable-next-line no-console
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
main().catch((error) => {
// eslint-disable-next-line no-console
console.error(error);
process.exit(1);
}).finally(async () => prisma.$disconnect());
+11 -2
View File
@@ -14,10 +14,19 @@ export class AuthController {
@ApiOperation({ summary: '登录:wx.login code 换 openid,自动注册/续登并签发 token' })
@ApiOkResponse({
schema: {
example: { code: 0, message: 'ok', data: { accessToken: '...' } },
example: {
code: 0,
message: 'ok',
data: { accessToken: '...', isNewUser: true, nickname: null, avatar: null },
},
},
})
async login(@Body() dto: LoginDto): Promise<{ accessToken: string }> {
async login(@Body() dto: LoginDto): Promise<{
accessToken: string;
isNewUser: boolean;
nickname: string | null;
avatar: string | null;
}> {
return this.authService.login(dto);
}
+14 -3
View File
@@ -34,10 +34,16 @@ export class AuthService {
* 说明:个人主体小程序无 getPhoneNumber 权限,故不强制手机号注册。
* 手机号采集留作未来换企业主体后可选补充(见 register())。
*/
async login(dto: LoginDto): Promise<{ accessToken: string }> {
async login(dto: LoginDto): Promise<{
accessToken: string;
isNewUser: boolean;
nickname: string | null;
avatar: string | null;
}> {
const session = await this.wechat.code2Session(dto.code);
// upsert:openid 在库则续登,不在库则直接建用户(无需手机号)
// 先查一次以判断是否为首次登录;随后仍以 upsert 防止并发登录重复建用户。
const existing = await this.users.findByOpenid(session.openid);
const user = await this.users.findOrCreateByOpenid(
session.openid,
session.unionid,
@@ -52,7 +58,12 @@ export class AuthService {
);
const accessToken = await this.signToken(user.id, user.openid);
return { accessToken };
return {
accessToken,
isNewUser: !existing,
nickname: user.nickname,
avatar: user.avatar,
};
}
/**
+1 -2
View File
@@ -6,9 +6,8 @@ import { CreateCategoryDto } from './dto/create-category.dto';
export class CategoriesService {
constructor(private readonly prisma: PrismaService) {}
// TODO: 树形结构组装、缓存
async findAll() {
return this.prisma.category.findMany({ orderBy: { sort: 'asc' } });
return this.prisma.category.findMany({ orderBy: [{ sort: 'asc' }, { createdAt: 'asc' }] });
}
async create(dto: CreateCategoryDto) {
+8 -3
View File
@@ -33,15 +33,20 @@ export class DesignListService {
constructor(private readonly prisma: PrismaService) {}
async listByUser(userId: string) {
return this.prisma.designList.findMany({
const rows = await this.prisma.designList.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
include: { orders: { select: { id: true }, orderBy: { createdAt: 'desc' }, take: 1 } },
});
return rows.map(({ orders, ...row }) => ({ ...row, orderId: orders[0]?.id ?? null }));
}
async findOne(userId: string, id: string) {
const list = await this.getOwnedList(userId, id);
return list;
await this.getOwnedList(userId, id);
const linked = await this.prisma.designList.findUnique({ where: { id }, include: { orders: { select: { id: true }, orderBy: { createdAt: 'desc' }, take: 1 } } });
if (!linked) throw new NotFoundException('设计清单不存在');
const { orders, ...row } = linked;
return { ...row, orderId: orders[0]?.id ?? null };
}
async create(userId: string, dto: CreateDesignListDto) {
+16 -15
View File
@@ -1,31 +1,26 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
import { IsArray, IsInt, IsNotEmpty, IsOptional, IsString, Max, Min, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class OrderItemDto {
@ApiPropertyOptional({ description: '商品 id(定制类可空)' })
@IsOptional()
@IsString()
productId?: string;
@ApiProperty()
@ApiProperty({ description: '商品 id' })
@IsString()
@IsNotEmpty()
name!: string;
@ApiProperty({ description: '单价(元)' })
@Type(() => Number)
price!: number;
productId!: string;
@ApiProperty({ description: '数量' })
@Type(() => Number)
@IsInt()
@Min(1)
@Max(999)
quantity!: number;
}
export class CreateOrderDto {
@ApiProperty({ description: '收货地址快照(JSON' })
@IsObject()
addressSnapshot!: Record<string, unknown>;
@ApiProperty({ description: '本人收货地址 id' })
@IsString()
@IsNotEmpty()
addressId!: string;
@ApiProperty({ type: [OrderItemDto] })
@IsArray()
@@ -37,4 +32,10 @@ export class CreateOrderDto {
@IsOptional()
@IsString()
designListId?: string;
@ApiPropertyOptional({ description: '客户端幂等键' })
@IsOptional()
@IsString()
@IsNotEmpty()
requestId?: string;
}
+11
View File
@@ -0,0 +1,11 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional } from 'class-validator';
import { OrderStatus } from '@prisma/client';
import { PaginationDto } from '../../common/dto/pagination.dto';
export class OrderQueryDto extends PaginationDto {
@ApiPropertyOptional({ enum: OrderStatus })
@IsOptional()
@IsEnum(OrderStatus)
status?: OrderStatus;
}
+18 -5
View File
@@ -1,8 +1,9 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator';
import { OrdersService } from './orders.service';
import { CreateOrderDto } from './dto/create-order.dto';
import { OrderQueryDto } from './dto/order-query.dto';
@ApiTags('订单')
@ApiBearerAuth()
@@ -12,14 +13,14 @@ export class OrdersController {
@Get()
@ApiOperation({ summary: '我的订单列表' })
list(@CurrentUser() user: JwtPayload) {
return this.ordersService.listByUser(user.sub);
list(@CurrentUser() user: JwtPayload, @Query() query: OrderQueryDto) {
return this.ordersService.listByUser(user.sub, query);
}
@Get(':id')
@ApiOperation({ summary: '订单详情' })
findOne(@Param('id') id: string) {
return this.ordersService.findOne(id);
findOne(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
return this.ordersService.findOne(user.sub, id);
}
@Post()
@@ -27,4 +28,16 @@ export class OrdersController {
create(@CurrentUser() user: JwtPayload, @Body() dto: CreateOrderDto) {
return this.ordersService.create(user.sub, dto);
}
@Patch(':id/confirm')
@ApiOperation({ summary: '确认收货(SHIPPED -> COMPLETED' })
confirm(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
return this.ordersService.confirm(user.sub, id);
}
@Post(':id/cancel')
@ApiOperation({ summary: '取消订单(仅 PENDING' })
cancel(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
return this.ordersService.cancel(user.sub, id);
}
}
+184 -33
View File
@@ -1,9 +1,11 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { DesignListStatus, OrderStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateOrderDto } from './dto/create-order.dto';
import { OrderQueryDto } from './dto/order-query.dto';
const PAYMENT_TIMEOUT_MS = 30 * 60 * 1000;
// 订单号生成:日期 + 随机后缀(无 Math.random 依赖要求仅限 workflow 脚本,普通运行时可用)
function generateOrderNo(): string {
const now = new Date();
const ymd =
@@ -18,43 +20,192 @@ function generateOrderNo(): string {
export class OrdersService {
constructor(private readonly prisma: PrismaService) {}
async listByUser(userId: string) {
return this.prisma.order.findMany({
where: { userId },
include: { items: true, payment: true },
orderBy: { createdAt: 'desc' },
});
private serialize(order: any) {
return {
...order,
totalAmount: Number(order.totalAmount),
paidAt: order.paidAt?.toISOString?.() ?? null,
paymentExpiresAt: order.paymentExpiresAt?.toISOString?.() ?? null,
createdAt: order.createdAt.toISOString(),
updatedAt: order.updatedAt.toISOString(),
items: order.items?.map((item: any) => ({
...item,
price: Number(item.price),
})),
};
}
async findOne(id: string) {
// TODO: 权限校验(仅本人)
return this.prisma.order.findUnique({
where: { id },
include: { items: true, payment: true },
});
async listByUser(userId: string, query: OrderQueryDto) {
await this.expirePendingOrders({ userId });
const where = { userId, ...(query.status ? { status: query.status } : {}) };
const [rows, total] = await this.prisma.$transaction([
this.prisma.order.findMany({
where,
include: { items: true, payment: true },
orderBy: { createdAt: 'desc' },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
}),
this.prisma.order.count({ where }),
]);
return { list: rows.map((row) => this.serialize(row)), total, page: query.page, pageSize: query.pageSize };
}
async findOne(userId: string, id: string) {
await this.expirePendingOrders({ id, userId });
const order = await this.prisma.order.findUnique({ where: { id }, include: { items: true, payment: true } });
if (!order) throw new NotFoundException('订单不存在');
if (order.userId !== userId) throw new ForbiddenException('无权访问该订单');
return this.serialize(order);
}
async create(userId: string, dto: CreateOrderDto) {
// TODO: 商品库存/价格校验、事务原子性、金额防篡改(服务端重算 totalAmount
const totalAmount = dto.items.reduce((sum, it) => sum + it.price * it.quantity, 0);
if (dto.requestId) {
const existing = await this.prisma.order.findFirst({
where: { userId, requestId: dto.requestId },
include: { items: true, payment: true },
});
if (existing) return this.serialize(existing);
}
const data: Prisma.OrderCreateInput = {
orderNo: generateOrderNo(),
user: { connect: { id: userId } },
totalAmount,
addressSnapshot: dto.addressSnapshot as Prisma.InputJsonValue,
// 关联设计清单(R4 下单后 WCD 派单读取 designDataR3 契约 CreateOrderDto 已含该字段)
designList: dto.designListId ? { connect: { id: dto.designListId } } : undefined,
items: {
create: dto.items.map((it) => ({
productId: it.productId,
name: it.name,
price: it.price,
quantity: it.quantity,
})),
},
const address = await this.prisma.address.findUnique({ where: { id: dto.addressId } });
if (!address) throw new NotFoundException('地址不存在');
if (address.userId !== userId) throw new ForbiddenException('无权使用该地址');
const design = dto.designListId
? await this.prisma.designList.findUnique({ where: { id: dto.designListId } })
: null;
if (dto.designListId && !design) throw new NotFoundException('设计清单不存在');
if (design && design.userId !== userId) throw new ForbiddenException('无权使用该设计清单');
const products = await this.prisma.product.findMany({
where: { id: { in: dto.items.map((item) => item.productId) }, status: 'ON_SALE' },
});
if (products.length !== new Set(dto.items.map((item) => item.productId)).size) {
throw new BadRequestException('存在无效或已下架商品');
}
const byId = new Map(products.map((product) => [product.id, product]));
const total = dto.items.reduce(
(sum, item) => sum.plus(new Prisma.Decimal(byId.get(item.productId)!.price).mul(item.quantity)),
new Prisma.Decimal(0),
);
const addressSnapshot = {
id: address.id,
name: address.name,
phone: address.phone,
province: address.province,
city: address.city,
district: address.district,
detail: address.detail,
};
const paymentExpiresAt = new Date(Date.now() + PAYMENT_TIMEOUT_MS);
return this.prisma.order.create({ data, include: { items: true } });
try {
const created = await this.prisma.$transaction(async (tx) => {
let orderNo = generateOrderNo();
for (let attempt = 0; attempt < 3; attempt += 1) {
const collision = await tx.order.findUnique({ where: { orderNo }, select: { id: true } });
if (!collision) break;
orderNo = generateOrderNo();
}
const order = await tx.order.create({
data: {
orderNo,
user: { connect: { id: userId } },
requestId: dto.requestId,
totalAmount: total,
paymentExpiresAt,
addressSnapshot: addressSnapshot as Prisma.InputJsonValue,
designList: dto.designListId ? { connect: { id: dto.designListId } } : undefined,
items: {
create: dto.items.map((item) => ({
productId: item.productId,
name: byId.get(item.productId)!.name,
price: byId.get(item.productId)!.price,
quantity: item.quantity,
})),
},
payment: { create: { status: 'PENDING' } },
},
include: { items: true, payment: true },
});
if (dto.designListId) {
await tx.designList.updateMany({
where: {
id: dto.designListId,
status: { in: [DesignListStatus.DRAFT, DesignListStatus.SUBMITTED] },
},
data: { status: DesignListStatus.PROCESSING },
});
}
return order;
});
return this.serialize(created);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
const existing = dto.requestId
? await this.prisma.order.findFirst({
where: { userId, requestId: dto.requestId },
include: { items: true, payment: true },
})
: null;
if (existing) return this.serialize(existing);
throw new ConflictException('订单号冲突,请重试');
}
throw error;
}
}
async cancel(userId: string, id: string) {
const order = await this.getOwned(userId, id);
if (order.status !== OrderStatus.PENDING) throw new ConflictException('仅待付款订单可取消');
const updated = await this.prisma.order.update({
where: { id },
data: { status: OrderStatus.CANCELLED },
include: { items: true, payment: true },
});
return this.serialize(updated);
}
async confirm(userId: string, id: string) {
const order = await this.getOwned(userId, id);
if (order.status !== OrderStatus.SHIPPED) throw new ConflictException('仅待收货订单可确认收货');
const updated = await this.prisma.$transaction(async (tx) => {
const completed = await tx.order.update({
where: { id },
data: { status: OrderStatus.COMPLETED },
include: { items: true, payment: true },
});
if (order.designListId) {
await tx.designList.updateMany({
where: { id: order.designListId, status: DesignListStatus.PROCESSING },
data: { status: DesignListStatus.DONE },
});
}
return completed;
});
return this.serialize(updated);
}
private async getOwned(userId: string, id: string) {
await this.expirePendingOrders({ id, userId });
const order = await this.prisma.order.findUnique({ where: { id }, include: { items: true, payment: true } });
if (!order) throw new NotFoundException('订单不存在');
if (order.userId !== userId) throw new ForbiddenException('无权操作该订单');
return order;
}
private async expirePendingOrders(where: Prisma.OrderWhereInput) {
await this.prisma.order.updateMany({
where: {
...where,
status: OrderStatus.PENDING,
paymentExpiresAt: { lte: new Date() },
},
data: { status: OrderStatus.PAYMENT_EXPIRED },
});
}
}
+3 -2
View File
@@ -2,6 +2,7 @@ import { Body, Controller, Param, Post, Req } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '../common/decorators/public.decorator';
import { PaymentsService } from './payments.service';
import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator';
@ApiTags('支付')
@ApiBearerAuth()
@@ -11,8 +12,8 @@ export class PaymentsController {
@Post(':orderId/pay')
@ApiOperation({ summary: '对指定订单发起支付(占位)' })
pay(@Param('orderId') orderId: string) {
return this.paymentsService.createPayment(orderId);
pay(@CurrentUser() user: JwtPayload, @Param('orderId') orderId: string) {
return this.paymentsService.createPayment(user.sub, orderId);
}
@Public()
+26 -7
View File
@@ -1,4 +1,6 @@
import { Injectable } from '@nestjs/common';
import { ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OrderStatus } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { WechatService } from '../wechat/wechat.service';
@@ -7,15 +9,32 @@ export class PaymentsService {
constructor(
private readonly prisma: PrismaService,
private readonly wechat: WechatService,
private readonly config: ConfigService,
) {}
/** 发起支付:创建支付记录并调微信统一下单(本轮占位) */
async createPayment(orderId: string) {
// TODO: 查订单、校验状态/金额、创建 Payment 记录、调 wechat.createUnifiedOrder
void this.wechat; // 占位引用,避免未使用告警
return this.prisma.payment.create({
data: { order: { connect: { id: orderId } } },
});
async createPayment(userId: string, orderId: string) {
const order = await this.prisma.order.findUnique({ where: { id: orderId }, include: { payment: true } });
if (!order) throw new NotFoundException('订单不存在');
if (order.userId !== userId) throw new ForbiddenException('无权支付该订单');
if (order.status === OrderStatus.PENDING && order.paymentExpiresAt && order.paymentExpiresAt <= new Date()) {
await this.prisma.order.updateMany({
where: { id: order.id, userId, status: OrderStatus.PENDING },
data: { status: OrderStatus.PAYMENT_EXPIRED },
});
throw new ConflictException('订单超时未支付');
}
if (order.status === OrderStatus.PAYMENT_EXPIRED) throw new ConflictException('订单超时未支付');
if (order.status !== OrderStatus.PENDING) throw new ConflictException('当前订单不可支付');
const configured = Boolean(
this.config.get<string>('wx.mchId') &&
this.config.get<string>('wx.mchApiV3Key') &&
this.config.get<string>('wx.mchSerialNo') &&
this.config.get<string>('wx.mchPrivateKeyPath'),
);
if (!configured) return { configured: false, message: '支付未配置' };
void this.wechat;
return { configured: true, message: '支付服务已配置但统一下单尚未接入' };
}
/** 微信支付回调入口(本轮占位) */
+58 -3
View File
@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsArray, IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator';
import { IsArray, IsEnum, IsInt, IsNumber, IsObject, IsOptional, IsString, Min } from 'class-validator';
import { ProductStatus } from '@prisma/client';
export class CreateProductDto {
@@ -19,17 +19,72 @@ export class CreateProductDto {
@Min(0)
price!: number;
@ApiPropertyOptional({ description: '图片 URL 列表', type: [String] })
@ApiPropertyOptional({ description: '划线原价(元)' })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
originalPrice?: number;
@ApiProperty({ description: '生产工期,例如 3-5个工作日' })
@IsString()
leadTime!: string;
@ApiPropertyOptional({ description: '一句话卖点' })
@IsOptional()
@IsString()
subtitle?: string;
@ApiProperty({ description: '图片 URL 列表', type: [String] })
@IsArray()
@IsString({ each: true })
images?: string[];
images!: string[];
@ApiPropertyOptional({ description: '商品图标 URL' })
@IsOptional()
@IsString()
iconImg?: string;
@ApiPropertyOptional({ description: '描述' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ description: '商品故事' })
@IsOptional()
@IsString()
story?: string;
@ApiPropertyOptional({ description: '使用场景' })
@IsOptional()
@IsString()
scene?: string;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@ApiProperty({ description: '规格键值对数组' })
@IsArray()
specs!: [string, string][];
@ApiProperty({ description: '主题色 RGB' })
@IsArray()
@IsInt({ each: true })
tone!: number[];
@ApiProperty({ description: '设计画布遮罩' })
@IsObject()
mask!: Record<string, unknown>;
@ApiPropertyOptional({ default: 0 })
@IsOptional()
@Type(() => Number)
@IsInt()
sort?: number;
@ApiPropertyOptional({ enum: ProductStatus, default: ProductStatus.DRAFT })
@IsOptional()
@IsEnum(ProductStatus)
@@ -0,0 +1,30 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class ListProductsQueryDto {
@ApiPropertyOptional({ default: 1, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize = 20;
@ApiPropertyOptional({ description: '分类 ID' })
@IsOptional()
@IsString()
categoryId?: string;
@ApiPropertyOptional({ description: '商品名称或描述关键词' })
@IsOptional()
@IsString()
keyword?: string;
}
+15
View File
@@ -0,0 +1,15 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
import { PaginationDto } from '../../common/dto/pagination.dto';
export class ProductQueryDto extends PaginationDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
categoryId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
keyword?: string;
}
+4 -3
View File
@@ -1,8 +1,9 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '../common/decorators/public.decorator';
import { ProductsService } from './products.service';
import { CreateProductDto } from './dto/create-product.dto';
import { ListProductsQueryDto } from './dto/list-products-query.dto';
@ApiTags('商品')
@ApiBearerAuth()
@@ -13,8 +14,8 @@ export class ProductsController {
@Public()
@Get()
@ApiOperation({ summary: '在售商品列表' })
list() {
return this.productsService.list();
list(@Query() query: ListProductsQueryDto) {
return this.productsService.list(query);
}
@Public()
+54 -11
View File
@@ -1,27 +1,70 @@
import { Injectable } from '@nestjs/common';
import { ProductStatus } from '@prisma/client';
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma, Product, ProductStatus } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateProductDto } from './dto/create-product.dto';
import { ListProductsQueryDto } from './dto/list-products-query.dto';
@Injectable()
export class ProductsService {
constructor(private readonly prisma: PrismaService) {}
async list() {
// TODO: 分页、按分类/状态筛选、价格区间
return this.prisma.product.findMany({
where: { status: ProductStatus.ON_SALE },
orderBy: { createdAt: 'desc' },
});
async list(query: ListProductsQueryDto) {
const { page = 1, pageSize = 20, categoryId, keyword } = query;
const where: Prisma.ProductWhereInput = {
status: ProductStatus.ON_SALE,
...(categoryId ? { categoryId } : {}),
...(keyword?.trim()
? {
OR: [
{ name: { contains: keyword.trim(), mode: 'insensitive' } },
{ subtitle: { contains: keyword.trim(), mode: 'insensitive' } },
{ description: { contains: keyword.trim(), mode: 'insensitive' } },
],
}
: {}),
};
const [list, total] = await this.prisma.$transaction([
this.prisma.product.findMany({
where,
orderBy: [{ sort: 'asc' }, { createdAt: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.product.count({ where }),
]);
return {
list: list.map((product) => this.toDto(product)),
total,
page,
pageSize,
};
}
async findOne(id: string) {
// TODO: 404 处理、浏览量统计
return this.prisma.product.findUnique({ where: { id } });
const product = await this.prisma.product.findFirst({
where: { id, status: ProductStatus.ON_SALE },
});
if (!product) throw new NotFoundException('商品不存在或已下架');
return this.toDto(product);
}
async create(dto: CreateProductDto) {
// TODO: 校验 categoryId 存在、图片归属
return this.prisma.product.create({ data: dto });
return this.prisma.product.create({
data: {
...dto,
specs: dto.specs as Prisma.InputJsonValue,
mask: dto.mask as Prisma.InputJsonValue,
},
});
}
private toDto(product: Product) {
const { originalPrice, ...rest } = product;
return {
...rest,
price: Number(product.price),
...(originalPrice === null ? {} : { originalPrice: Number(originalPrice) }),
};
}
}