Compare commits
15
Commits
2445aef666
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c07e03333c | ||
|
|
e5deacd19b | ||
|
|
aed7d12051 | ||
|
|
854ea103a9 | ||
|
|
46b150e3b8 | ||
|
|
17e4e7f560 | ||
|
|
c48e79802c | ||
|
|
05584172ca | ||
|
|
3e4c88768f | ||
|
|
d27753f732 | ||
|
|
679e472570 | ||
|
|
c6877c355a | ||
|
|
16e625aefb | ||
|
|
59e398fcf9 | ||
|
|
63c0013076 |
@@ -0,0 +1,13 @@
|
||||
# 不把 Windows 本机依赖、构建产物或密钥复制进 Linux 镜像。
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.git
|
||||
.gitignore
|
||||
coverage
|
||||
*.log
|
||||
logs
|
||||
.vscode
|
||||
.idea
|
||||
@@ -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:
|
||||
@@ -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"]
|
||||
@@ -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 商品目录的核心目标已经完成:后端提供可复用的商品分类、商品列表和商品详情接口,前端已经完成真实接口对接,并通过修改后端商品价格后前端同步变化的方式验证了数据通路。该接口可以交给其他成员继续用于设计清单、订单和生产流程开发。
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# R1 商品目录接口对接说明
|
||||
|
||||
**提供方:成员 1(R1 商品目录)**
|
||||
|
||||
**消费者:成员 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.
@@ -0,0 +1,90 @@
|
||||
# R1 商品目录交付说明
|
||||
|
||||
**提供方:成员 1(R1 商品目录)**
|
||||
|
||||
**可使用成员:成员 2(设计清单)、成员 3(订单)、成员 4(设计与生产)**
|
||||
|
||||
**分支:`feat/r1-catalog`**
|
||||
|
||||
R1 商品目录已完成,可以对外提供商品数据。商品页面现在从后端接口读取商品信息,不再以写死在前端的商品数据作为主数据源。本说明中的验证结果用于证明商品目录已经真实可用;接口说明用于方便其他成员接入这份目录数据。
|
||||
|
||||
## 已完成内容
|
||||
|
||||
| 完成项 | 结果 |
|
||||
| --- | --- |
|
||||
| 后端商品数据 | 已建立商品分类及 5 个在售商品的本机数据。 |
|
||||
| 商品接口 | 已提供商品列表、单个商品详情和分类列表接口。 |
|
||||
| 前端商品页面 | 首页、商品列表、商品详情、立即定制页均已接入商品接口。 |
|
||||
| 本机联调环境 | Docker 中的后端、数据库、Redis 已启动;微信开发者工具可正常运行前端。 |
|
||||
|
||||
## 已完成前后端数据通路验证
|
||||
|
||||
这次验证不只是确认接口能访问,而是确认后端数据变化会传到前端页面,证明前端展示的确实是后端商品数据。
|
||||
|
||||
| 验证步骤 | 验证结果 |
|
||||
| --- | --- |
|
||||
| 读取商品初始值 | `notebook-small` 的价格为 12 元,划线原价为 18 元。 |
|
||||
| 修改后端数据库 | 将该商品测试价格改为 19 元,测试划线原价改为 29 元。 |
|
||||
| 重新编译小程序 | 微信开发者工具重新编译后,商品卡片和详情页同步显示 19 元、29 元。 |
|
||||
| 验证结论 | 前端展示随数据库和接口返回同步改变,前后端商品数据通路正常。 |
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
**结论:** 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.
@@ -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
|
||||
|
||||
请求体:
|
||||
|
||||
@@ -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
@@ -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 §6:requestId 防重复下单,重复请求返回已创建订单)
|
||||
-- 允许已有 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");
|
||||
@@ -56,8 +56,19 @@ model Product {
|
||||
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[]
|
||||
@@ -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 §6:requestId 重复请求返回已创建订单)
|
||||
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 {
|
||||
|
||||
@@ -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());
|
||||
+29
-36
@@ -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 × 148mm(A6)'], ['材质', '高档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 × 297mm(A4)'], ['开合', '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 },
|
||||
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 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',
|
||||
},
|
||||
});
|
||||
|
||||
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) => {
|
||||
main().catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
}).finally(async () => prisma.$disconnect());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+179
-28
@@ -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 },
|
||||
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(),
|
||||
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);
|
||||
|
||||
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 } },
|
||||
totalAmount,
|
||||
addressSnapshot: dto.addressSnapshot as Prisma.InputJsonValue,
|
||||
// 关联设计清单(R4 下单后 WCD 派单读取 designData;R3 契约 CreateOrderDto 已含该字段)
|
||||
requestId: dto.requestId,
|
||||
totalAmount: total,
|
||||
paymentExpiresAt,
|
||||
addressSnapshot: addressSnapshot as Prisma.InputJsonValue,
|
||||
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,
|
||||
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 },
|
||||
});
|
||||
|
||||
return this.prisma.order.create({ data, include: { items: 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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: '支付服务已配置但统一下单尚未接入' };
|
||||
}
|
||||
|
||||
/** 微信支付回调入口(本轮占位) */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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) }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user