feat(catalog): add product catalog APIs

This commit is contained in:
lai_hong
2026-09-13 11:17:53 +08:00
parent 63c0013076
commit 59e398fcf9
15 changed files with 655 additions and 68 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"]
+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
```
+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 地址。
+3 -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。
+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 内部网络,不对公网暴露。
@@ -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");
+11
View File
@@ -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[]
+29 -36
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 },
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());
+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) {
+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;
}
+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) }),
};
}
}