From 59e398fcf9d34ea958757057a1d35e4d5949bf4b Mon Sep 17 00:00:00 2001 From: lai_hong Date: Sun, 13 Sep 2026 11:17:29 +0800 Subject: [PATCH] feat(catalog): add product catalog APIs --- .dockerignore | 13 ++ docker-compose.dev.yml | 17 ++ docker/Dockerfile.dev | 14 ++ docs/R1-商品目录接口对接说明.md | 172 ++++++++++++++++++ docs/R1商品目录接口验证说明.md | 90 +++++++++ docs/api-contract-v1.md | 4 +- docs/本机容器化联调教程.md | 125 +++++++++++++ .../migration.sql | 22 +++ prisma/schema.prisma | 29 ++- prisma/seed.ts | 71 ++++---- src/categories/categories.service.ts | 3 +- src/products/dto/create-product.dto.ts | 61 ++++++- src/products/dto/list-products-query.dto.ts | 30 +++ src/products/products.controller.ts | 7 +- src/products/products.service.ts | 65 +++++-- 15 files changed, 655 insertions(+), 68 deletions(-) create mode 100644 .dockerignore create mode 100644 docker-compose.dev.yml create mode 100644 docker/Dockerfile.dev create mode 100644 docs/R1-商品目录接口对接说明.md create mode 100644 docs/R1商品目录接口验证说明.md create mode 100644 docs/本机容器化联调教程.md create mode 100644 prisma/migrations/20260912090000_r1_product_catalog/migration.sql create mode 100644 src/products/dto/list-products-query.dto.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a04f87b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +# 不把 Windows 本机依赖、构建产物或密钥复制进 Linux 镜像。 +node_modules +dist +.env +.env.* +!.env.example +.git +.gitignore +coverage +*.log +logs +.vscode +.idea diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..ea39d71 --- /dev/null +++ b/docker-compose.dev.yml @@ -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: diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev new file mode 100644 index 0000000..a5d79a6 --- /dev/null +++ b/docker/Dockerfile.dev @@ -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"] diff --git a/docs/R1-商品目录接口对接说明.md b/docs/R1-商品目录接口对接说明.md new file mode 100644 index 0000000..aba7fb0 --- /dev/null +++ b/docs/R1-商品目录接口对接说明.md @@ -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-`,例如 `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 +``` diff --git a/docs/R1商品目录接口验证说明.md b/docs/R1商品目录接口验证说明.md new file mode 100644 index 0000000..a54fc86 --- /dev/null +++ b/docs/R1商品目录接口验证说明.md @@ -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 元。 | +| 验证结论 | 前端展示随数据库和接口返回同步改变,前后端商品数据通路正常。 | + +![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 地址。 diff --git a/docs/api-contract-v1.md b/docs/api-contract-v1.md index 1b997ff..539c13c 100644 --- a/docs/api-contract-v1.md +++ b/docs/api-contract-v1.md @@ -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。 diff --git a/docs/本机容器化联调教程.md b/docs/本机容器化联调教程.md new file mode 100644 index 0000000..20096ba --- /dev/null +++ b/docs/本机容器化联调教程.md @@ -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 内部网络,不对公网暴露。 diff --git a/prisma/migrations/20260912090000_r1_product_catalog/migration.sql b/prisma/migrations/20260912090000_r1_product_catalog/migration.sql new file mode 100644 index 0000000..15e58a5 --- /dev/null +++ b/prisma/migrations/20260912090000_r1_product_catalog/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 94ce1a4..135e8bb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/prisma/seed.ts b/prisma/seed.ts index 7686458..84d6640 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -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 }, - }); - - 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()); diff --git a/src/categories/categories.service.ts b/src/categories/categories.service.ts index b229ae4..0373d1e 100644 --- a/src/categories/categories.service.ts +++ b/src/categories/categories.service.ts @@ -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) { diff --git a/src/products/dto/create-product.dto.ts b/src/products/dto/create-product.dto.ts index b7030ca..e01815c 100644 --- a/src/products/dto/create-product.dto.ts +++ b/src/products/dto/create-product.dto.ts @@ -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; + + @ApiPropertyOptional({ default: 0 }) + @IsOptional() + @Type(() => Number) + @IsInt() + sort?: number; + @ApiPropertyOptional({ enum: ProductStatus, default: ProductStatus.DRAFT }) @IsOptional() @IsEnum(ProductStatus) diff --git a/src/products/dto/list-products-query.dto.ts b/src/products/dto/list-products-query.dto.ts new file mode 100644 index 0000000..79477ae --- /dev/null +++ b/src/products/dto/list-products-query.dto.ts @@ -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; +} diff --git a/src/products/products.controller.ts b/src/products/products.controller.ts index 935eb12..a510646 100644 --- a/src/products/products.controller.ts +++ b/src/products/products.controller.ts @@ -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() diff --git a/src/products/products.service.ts b/src/products/products.service.ts index c476473..66073ef 100644 --- a/src/products/products.service.ts +++ b/src/products/products.service.ts @@ -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) }), + }; } }