feat: 初始化微信小程序后端骨架

- NestJS + TypeScript + Prisma + PostgreSQL 工程骨架
- 微信登录安全流程:服务端 code2Session 换 openid 后签发 JWT,
  session_key 缓存于 Redis,不信任前端 openid
- 统一响应/异常处理、JWT 全局鉴权(@Public 豁免)、Swagger 文档
- Prisma 全量核心 schema(用户/分类/商品/设计清单/地址/订单/支付/上传/定制任务)+ seed
- 业务模块空壳(商品/分类/设计清单/地址/订单/支付/上传/BullMQ 队列)
- Docker 多阶段镜像 + 本地/生产 docker-compose
- docs:密钥获取指南、COS SDK 移除记录

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 17:33:50 +08:00
co-authored by Claude Fable 5
commit deaa0c9ce4
73 changed files with 9533 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
# ── 应用 ─────────────────────────────────────────────
NODE_ENV=development
APP_PORT=3000
# Swagger 文档路径,生产环境可置空以关闭
SWAGGER_PATH=docs
# ── 数据库 (PostgreSQL) ──────────────────────────────
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/wxmp?schema=public
# ── Redis ────────────────────────────────────────────
REDIS_URL=redis://localhost:6379
# ── JWT ──────────────────────────────────────────────
# 生产环境务必使用足够长的高熵随机串
JWT_SECRET=change-me-to-a-long-random-secret
# access token 有效期(秒),默认 2 小时
JWT_EXPIRES=7200
# ── 微信小程序 ───────────────────────────────────────
# 服务端持有,永不下发前端
WX_APPID=your-wx-appid
WX_SECRET=your-wx-secret
# 本地联调用:无真实 appid 时设为 1 可走通登录链路(mock openid),切勿生产开启
WX_MOCK_LOGIN=
# ── 微信支付(预留,暂未启用)────────────────────────
WX_MCH_ID=
WX_MCH_API_V3_KEY=
WX_MCH_SERIAL_NO=
WX_MCH_PRIVATE_KEY_PATH=
WX_PAY_NOTIFY_URL=
# ── 腾讯云 COS ───────────────────────────────────────
COS_SECRET_ID=
COS_SECRET_KEY=
COS_BUCKET=
COS_REGION=ap-guangzhou
+33
View File
@@ -0,0 +1,33 @@
# Dependencies
node_modules/
# Build output
dist/
# Env & secrets
.env
.env.*
!.env.example
# Prisma generated client
prisma/migrations/dev.db*
# Logs
*.log
logs/
npm-debug.log*
# Editor / OS
.DS_Store
.idea/
.vscode/
*.swp
# Coverage
coverage/
# TypeScript incremental build
*.tsbuildinfo
# key
key
+113
View File
@@ -0,0 +1,113 @@
# wxmp-backend
微信小程序后端 API,基于 NestJS + Prisma + PostgreSQL + Redis + BullMQ + 腾讯云 COS。
## 技术栈
| 层 | 选型 |
|---|---|
| 运行时 | Node.js 22 + TypeScript |
| 框架 | NestJS 11 |
| ORM | Prisma 6 |
| 数据库 | PostgreSQL 16 |
| 缓存/队列 | Redis 7 + BullMQ |
| 对象存储 | 腾讯云 COS |
| 鉴权 | JWTpassport-jwt |
| 文档 | Swagger / OpenAPI`/docs` |
| 部署 | Docker + docker compose |
## 目录约定
```
src/
├── common/ # 公共:统一响应、全局守卫/过滤器/拦截器、装饰器、通用 DTO
├── config/ # 环境变量读取 + Joi 启动校验
├── prisma/ # PrismaService(全局单例)
├── redis/ # ioredis 实例(全局)
├── wechat/ # 微信 SDK 封装:code2Session(已实现)、支付(占位)
├── auth/ # 微信登录 + JWT 签发与校验(已完整实现)
├── users/ # 用户查/建、/users/me
├── products/ categories/ design-list/ addresses/ # 业务(空壳+路由+DTO,逻辑 TODO)
├── orders/ payments/ upload/ # 业务(同上)
├── queue/ # BullMQ 定制任务(占位处理器)
└── health/ # 健康检查
```
统一响应格式:`{ code, message, data }`(成功 `code:0`,失败为对应 HTTP 状态码)。
## 本地启动
1. 安装依赖
```bash
npm install
```
2. 复制环境变量并填写
```bash
cp .env.example .env
# 填入 WX_APPID / WX_SECRET / JWT_SECRET 等
```
3. 起 PostgreSQL + Redis(需 Docker
```bash
npm run db:up
```
4. 初始化数据库
```bash
npx prisma migrate dev --name init
npm run prisma:seed
```
5. 启动开发服务
```bash
npm run start:dev
```
6. 访问
- 健康检查:`http://localhost:3000/health`
- Swagger 文档:`http://localhost:3000/docs`
## 微信登录流程(安全要点)
> **核心原则**:服务端以微信 `code2Session` 返回的 `openid` 为准,绝不信任前端传入的 openid。
1. 小程序端 `wx.login()` 拿到临时 `code`。
2. `POST /api/auth/login`,请求体仅需 `{ code }`。
3. 服务端 `wechat.code2Session(code)` 用服务端持有的 `appid + secret` 请求微信 `sns/jscode2session`,换得 `openid` + `session_key`。
4. `users.findOrCreateByOpenid(openid)` 查/建用户。
5. `session_key` 存入 Redis`wx:session:{userId}`,TTL 7 天),供后续解密小程序 encryptedData / 手机号。
6. 用 `@nestjs/jwt` 签发 `{ sub: userId, openid }` 的 JWT 返回 `accessToken`。
7. 小程序将 `accessToken` 存 storage,后续请求带 `Authorization: Bearer <token>``JwtAuthGuard` 默认守护所有路由,`@Public()` 标记的路由(如 `/auth/login`、`/health`)免鉴权。
无真实 appid 时,可临时 mock `WechatService.code2Session` 返回固定 openid 走通链路。
## 常用脚本
| 命令 | 说明 |
|---|---|
| `npm run start:dev` | 开发热重载 |
| `npm run build` | 编译到 `dist/` |
| `npm run prisma:migrate:dev` | 生成并应用迁移(开发) |
| `npm run prisma:migrate` | 应用迁移(生产,deploy 模式) |
| `npm run prisma:seed` | 写入种子数据 |
| `npm run db:up` / `db:down` | 起/停本地 postgres + redis |
## 环境变量
见 `.env.example`。关键项(缺失即启动失败):
- `DATABASE_URL` — PostgreSQL 连接串
- `REDIS_URL` — Redis 连接串
- `JWT_SECRET` — JWT 签名密钥(≥16 位,生产用高熵随机串)
- `JWT_EXPIRES` — access token 有效期(秒)
- `WX_APPID` / `WX_SECRET` — 微信小程序凭证(服务端持有,不下发前端)
微信支付、COS 相关变量本轮可留空。
## 当前状态与后续迭代
已实现:工程骨架、Prisma 全量 schema、微信登录 + JWT、统一响应/异常、Swagger、Docker、健康检查。
待实现(已预留模块/路由/DTO,标 `TODO`):
- 商品/分类/设计清单/地址/订单/上传的**完整业务逻辑**(分页、权限、事务、金额重算等)
- 微信支付 V3(统一下单、回调验签、查单、退款)
- COS 直传凭证 / 预签名
- BullMQ 定制任务真实处理
- 管理后台(React + Ant Design,独立仓库)
- 单元 / e2e 测试
+52
View File
@@ -0,0 +1,52 @@
# 生产参考编排:app + postgres + redis 同机部署
# 实际部署到腾讯云轻量应用服务器时按需调整端口/卷/资源限制
services:
app:
build:
context: .
dockerfile: docker/Dockerfile
container_name: wxmp-app
restart: always
env_file: .env
environment:
NODE_ENV: production
ports:
- "3000:3000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
postgres:
image: postgres:16-alpine
container_name: wxmp-postgres
restart: always
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB:-wxmp}
volumes:
- wxmp_pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
interval: 5s
timeout: 3s
retries: 10
redis:
image: redis:7-alpine
container_name: wxmp-redis
restart: always
command: ["redis-server", "--appendonly", "yes"]
volumes:
- wxmp_redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
volumes:
wxmp_pg_data:
wxmp_redis_data:
+56
View File
@@ -0,0 +1,56 @@
# 本地开发:仅起 postgres + redis,应用用 npm run start:dev 直连
# 起容器:docker compose up -d postgres redis
services:
postgres:
image: postgres:16-alpine
container_name: wxmp-postgres
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: wxmp
ports:
- "5432:5432"
volumes:
- wxmp_pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d wxmp"]
interval: 5s
timeout: 3s
retries: 10
redis:
image: redis:7-alpine
container_name: wxmp-redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- wxmp_redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
# 本地开发默认不启动 app;需要时用 profile
# docker compose --profile app up -d
app:
profiles: ["app"]
build:
context: .
dockerfile: docker/Dockerfile
container_name: wxmp-app
restart: unless-stopped
env_file: .env
ports:
- "3000:3000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
wxmp_pg_data:
wxmp_redis_data:
+27
View File
@@ -0,0 +1,27 @@
# 多阶段构建:builder 编译 TS -> runner 仅运行 dist
FROM node:22-alpine AS builder
WORKDIR /app
# 先装依赖(利用 Docker 层缓存)
COPY package*.json ./
COPY prisma ./prisma
RUN npm ci
COPY tsconfig*.json nest-cli.json ./
COPY src ./src
RUN npx prisma generate && npm run build
# ──────────────────────────────────────────────
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
COPY prisma ./prisma
RUN npm ci --omit=dev && npx prisma generate
COPY --from=builder /app/dist ./dist
EXPOSE 3000
# 启动前执行 migrate deploy(生产用),随后启动服务
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main.js"]
+42
View File
@@ -0,0 +1,42 @@
# COS SDK 移除记录
**日期**2026-08-05
## 背景
初次搭建骨架时,在 `package.json``dependencies` 中加入了 `cos-nodejs-sdk-v5`^2.14.0),
计划用于后续的腾讯云对象存储(COS)直传功能。
## 移除原因
`npm audit` 检出 12 个安全漏洞(3 critical / 2 high / 7 moderate),**全部来自**
`cos-nodejs-sdk-v5` 及其传递依赖:
- `fast-xml-parser`(critical)— 多个实体展开绕过 / DoS 漏洞
- `form-data`(critical)— 不安全随机边界、CRLF 注入
- `request`(已废弃)— 含已知漏洞的旧库
- `ajv` / `conf`moderate
COS 功能本轮仅为**占位**`src/upload/` 仅提供服务方法签名,代码中**并未真正 import**
该 SDK),因此移除后不影响现有代码与功能。
## 变更内容
-`package.json` `dependencies` 移除 `cos-nodejs-sdk-v5@^2.14.0`
- 执行 `npm uninstall cos-nodejs-sdk-v5` 清理 `node_modules``package-lock.json`
- 审计结果:critical/moderate 漏洞清零,仅剩 2 个 high(来自 `js-yaml`
`ts-node-dev` 的**开发期**传递依赖,不进入生产镜像)
## 当前状态
`npm audit fix --force` 可自动升级到 `cos-nodejs-sdk-v5@3.0.0`(破坏性大版本),
但本轮不需要 COS,故**不升级、保持移除**。
## 后续恢复 COS 时的注意事项
实现 COS 直传 / 预签名时,重新引入 SDK 请:
1. 使用 **v3.x**`cos-nodejs-sdk-v5@^3.0.0`),避免 2.x 的旧依赖链漏洞;
2. 引入后立即执行 `npm audit` 复核漏洞情况;
3. 若仍报 criticale.g. `fast-xml-parser` 传递依赖),考虑用「先直传后回调」的
最小化接入,或改用官方推荐的 **COS STS / 临时密钥** 方案,减少客户端 SDK 依赖。
+97
View File
@@ -0,0 +1,97 @@
# 密钥与配置获取指南
本项目的敏感配置全部通过环境变量(`.env`)注入,绝不允许提交进仓库。
以下说明各项值的**来源**与**获取方式**。
> ⚠️ 安全提醒:任何密钥都不要写入代码、提交到 git,或发给第三方。
> 妥善保管,生产环境与本地环境分离。
---
## 1. JWT_SECRETJWT 签名密钥)
- **是什么**:用于签名/校验登录 access token 的对称密钥(≥16 位,建议 64 位十六进制)。
- **哪里来**:本地自行生成,无第三方。用系统安全随机数生成:
```bash
# 方式一:openssl
openssl rand -hex 64
# 方式二:node
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
```
- **要求**:每次输出粘贴到 `.env``JWT_SECRET=` 即可。**生产必须换成新的高熵随机串**,
与本地/测试完全区分;切勿使用示例值。
- **注意**:更换后所有已签发的 token 会立即失效(用户需重新登录),属预期行为。
---
## 2. WX_APPID / WX_SECRET(微信小程序凭证)
- **是什么**:小程序唯一标识(AppID)与对应的 AppSecret。
- **哪里来****微信公众平台**https://mp.weixin.qq.com)。
1. 登录后,进入「小程序」→ 左侧「开发」→「开发管理」→「开发设置」。
2. 页面顶部「AppID(小程序ID)」即 `WX_APPID`
3. 「AppSecret(小程序密钥)」需点击「生成/重置」获取,生成后仅显示一次,请立即保存。
- **安全要点**AppSecret 仅存于**服务端**,绝不写入小程序代码或随请求下发。
本项目 `code2Session` 使用服务端持有的 appid+secret 调微信换取 openid。
---
## 3. 微信支付相关(WX_MCH_*,预留,当前为空)
> 骨架阶段支付功能为占位,**暂无必填**。上线支付时按以下获取:
- **WX_MCH_ID**(商户号):微信支付商户平台申请开通后获得。
- **WX_MCH_API_V3_KEY**APIv3 密钥):商户平台 →「账户中心」→「API安全」→「APIv3密钥」设置。
- **WX_MCH_SERIAL_NO**(商户证书序列号):商户平台「API安全」→「申请API证书」后,在
证书详情中查看序列号。
- **WX_MCH_PRIVATE_KEY_PATH**(商户私钥):申请 API 证书时下载的私钥文件,存放于
服务端的 `key/` 目录(已被 gitignore 忽略)。
- **WX_PAY_NOTIFY_URL**(支付回调地址):需为公网可访问的 HTTPS 地址,如
`https://your-domain/api/payments/notify`
前置条件:小程序需完成微信支付商户号绑定与经营资质审核。
---
## 4. COS(腾讯云对象存储,COS_*,当前为空)
> 骨架阶段 COS 为占位(SDK 已移除,见 `docs/cos-sdk-removal.md`)。接入时获取:
- **COS_BUCKET** / **COS_REGION**
1. 腾讯云控制台 →「对象存储 COS」→「存储桶列表」。
2. 创建/选择存储桶,桶名称即 `COS_BUCKET`(形如 `wxmp-125xxxxxxx`)。
3. 桶所在地域即 `COS_REGION`(形如 `ap-guangzhou`)。
- **COS_SECRET_ID** / **COS_SECRET_KEY**
1. 腾讯云控制台 →「访问管理 CAM」→「访问密钥」→「API 密钥管理」。
2. 新建/查看密钥,SecretId 与 SecretKey 填入对应变量。
- 生产建议用**子账号 / 临时密钥(STS)**,权限最小化,主账号密钥仅本地调试用。
---
## 5. 数据库与 RedisDATABASE_URL / REDIS_URL
- **DATABASE_URL**PostgreSQL 连接串,格式
`postgresql://<user>:<password>@<host>:<port>/<db>?schema=public`
- 本地开发(docker compose)默认:
`postgresql://postgres:postgres@localhost:5432/wxmp?schema=public`
- 生产改为真实主机/账号/密码,并避免在 URL 中明文存弱口令。
- **REDIS_URL**Redis 连接串,格式 `redis://[:password]@<host>:<port>`
生产若开启认证/加密,按需补充。
---
## 快速核对清单
| 变量 | 必须 | 来源 |
|---|---|---|
| `JWT_SECRET` | ✅ 生产必须换 | 本地 `openssl rand -hex 64` |
| `WX_APPID` / `WX_SECRET` | ✅ | 微信公众平台·开发设置 |
| `DATABASE_URL` | ✅ | 自建 PostgreSQL |
| `REDIS_URL` | ✅ | 自建 Redis |
| `WX_MCH_*` | 支付时 | 微信支付商户平台 |
| `COS_*` | COS 接入时 | 腾讯云控制台(COS/CAM) |
部署前请在服务器上用工具生成独立的随机密钥,**不要把本地 `.env` 原样搬上生产**。
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+7073
View File
File diff suppressed because it is too large Load Diff
+64
View File
@@ -0,0 +1,64 @@
{
"name": "wxmp-backend",
"version": "0.1.0",
"description": "微信小程序后端 APINestJS + Prisma + PostgreSQL + Redis + BullMQ + 腾讯云 COS",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"prisma/**/*.ts\" \"test/**/*.ts\"",
"lint": "eslint \"{src,prisma,test}/**/*.ts\" --fix",
"start": "node dist/main.js",
"start:dev": "ts-node-dev --respawn --transpile-only src/main.ts",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main.js",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate deploy",
"prisma:migrate:dev": "prisma migrate dev",
"prisma:studio": "prisma studio",
"prisma:seed": "ts-node prisma/seed.ts",
"db:up": "docker compose up -d postgres redis",
"db:down": "docker compose down"
},
"dependencies": {
"@nestjs/bullmq": "^11.0.0",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/swagger": "^11.0.0",
"@prisma/client": "^6.0.0",
"axios": "^1.7.0",
"bullmq": "^5.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dayjs": "^1.11.0",
"ioredis": "^5.4.0",
"joi": "^17.13.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@types/express": "^5.0.0",
"@types/node": "^22.0.0",
"@types/passport-jwt": "^4.0.1",
"eslint": "^9.0.0",
"prettier": "^3.3.0",
"prisma": "^6.0.0",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"typescript": "^5.6.0"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
},
"engines": {
"node": ">=22"
}
}
@@ -0,0 +1,233 @@
-- CreateEnum
CREATE TYPE "ProductStatus" AS ENUM ('DRAFT', 'ON_SALE', 'OFF_SHELF');
-- CreateEnum
CREATE TYPE "DesignListStatus" AS ENUM ('DRAFT', 'SUBMITTED', 'PROCESSING', 'DONE');
-- CreateEnum
CREATE TYPE "OrderStatus" AS ENUM ('PENDING', 'PAID', 'PROCESSING', 'SHIPPED', 'COMPLETED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "PaymentProvider" AS ENUM ('WECHAT');
-- CreateEnum
CREATE TYPE "PaymentStatus" AS ENUM ('PENDING', 'SUCCESS', 'REFUNDED');
-- CreateEnum
CREATE TYPE "CustomizationTaskStatus" AS ENUM ('PENDING', 'RUNNING', 'SUCCESS', 'FAILED');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"openid" TEXT NOT NULL,
"unionid" TEXT,
"nickname" TEXT,
"avatar" TEXT,
"phone" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Category" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"parentId" TEXT,
"sort" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Product" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"categoryId" TEXT,
"price" DECIMAL(10,2) NOT NULL,
"images" TEXT[],
"description" TEXT,
"status" "ProductStatus" NOT NULL DEFAULT 'DRAFT',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DesignList" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"items" JSONB NOT NULL,
"status" "DesignListStatus" NOT NULL DEFAULT 'DRAFT',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DesignList_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Address" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"province" TEXT NOT NULL,
"city" TEXT NOT NULL,
"district" TEXT NOT NULL,
"detail" TEXT NOT NULL,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Address_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Order" (
"id" TEXT NOT NULL,
"orderNo" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"status" "OrderStatus" NOT NULL DEFAULT 'PENDING',
"totalAmount" DECIMAL(10,2) NOT NULL,
"addressSnapshot" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Order_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OrderItem" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"productId" TEXT,
"name" TEXT NOT NULL,
"price" DECIMAL(10,2) NOT NULL,
"quantity" INTEGER NOT NULL,
CONSTRAINT "OrderItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Payment" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"transactionId" TEXT,
"provider" "PaymentProvider" NOT NULL DEFAULT 'WECHAT',
"status" "PaymentStatus" NOT NULL DEFAULT 'PENDING',
"paidAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Payment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Upload" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"cosKey" TEXT NOT NULL,
"url" TEXT NOT NULL,
"mimetype" TEXT,
"size" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Upload_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CustomizationTask" (
"id" TEXT NOT NULL,
"designListId" TEXT,
"userId" TEXT,
"status" "CustomizationTaskStatus" NOT NULL DEFAULT 'PENDING',
"resultUrl" TEXT,
"queueJobId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CustomizationTask_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_openid_key" ON "User"("openid");
-- CreateIndex
CREATE INDEX "User_phone_idx" ON "User"("phone");
-- CreateIndex
CREATE INDEX "Category_parentId_idx" ON "Category"("parentId");
-- CreateIndex
CREATE INDEX "Product_categoryId_idx" ON "Product"("categoryId");
-- CreateIndex
CREATE INDEX "Product_status_idx" ON "Product"("status");
-- CreateIndex
CREATE INDEX "DesignList_userId_idx" ON "DesignList"("userId");
-- CreateIndex
CREATE INDEX "Address_userId_idx" ON "Address"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Order_orderNo_key" ON "Order"("orderNo");
-- CreateIndex
CREATE INDEX "Order_userId_idx" ON "Order"("userId");
-- CreateIndex
CREATE INDEX "Order_status_idx" ON "Order"("status");
-- CreateIndex
CREATE INDEX "OrderItem_orderId_idx" ON "OrderItem"("orderId");
-- CreateIndex
CREATE UNIQUE INDEX "Payment_orderId_key" ON "Payment"("orderId");
-- CreateIndex
CREATE INDEX "Upload_userId_idx" ON "Upload"("userId");
-- CreateIndex
CREATE INDEX "CustomizationTask_designListId_idx" ON "CustomizationTask"("designListId");
-- CreateIndex
CREATE INDEX "CustomizationTask_status_idx" ON "CustomizationTask"("status");
-- AddForeignKey
ALTER TABLE "Category" ADD CONSTRAINT "Category_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Product" ADD CONSTRAINT "Product_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DesignList" ADD CONSTRAINT "DesignList_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Address" ADD CONSTRAINT "Address_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Order" ADD CONSTRAINT "Order_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Upload" ADD CONSTRAINT "Upload_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustomizationTask" ADD CONSTRAINT "CustomizationTask_designListId_fkey" FOREIGN KEY ("designListId") REFERENCES "DesignList"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustomizationTask" ADD CONSTRAINT "CustomizationTask_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+220
View File
@@ -0,0 +1,220 @@
// 微信小程序后端 — Prisma schema
// 数据库:PostgreSQL
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ── 用户 ──────────────────────────────────────────────
model User {
id String @id @default(cuid())
openid String @unique
unionid String?
nickname String?
avatar String?
phone String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
orders Order[]
addresses Address[]
designLists DesignList[]
uploads Upload[]
customizations CustomizationTask[]
@@index([phone])
}
// ── 商品分类 ──────────────────────────────────────────
model Category {
id String @id @default(cuid())
name String
parentId String?
sort Int @default(0)
parent Category? @relation("CategoryTree", fields: [parentId], references: [id])
children Category[] @relation("CategoryTree")
products Product[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([parentId])
}
// ── 商品 ──────────────────────────────────────────────
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[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([categoryId])
@@index([status])
}
enum ProductStatus {
DRAFT
ON_SALE
OFF_SHELF
}
// ── 设计清单 / 定制需求 ───────────────────────────────
model DesignList {
id String @id @default(cuid())
userId String
title String
items Json
status DesignListStatus @default(DRAFT)
user User @relation(fields: [userId], references: [id])
customizations CustomizationTask[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
}
enum DesignListStatus {
DRAFT
SUBMITTED
PROCESSING
DONE
}
// ── 收货地址 ──────────────────────────────────────────
model Address {
id String @id @default(cuid())
userId String
name String
phone String
province String
city String
district String
detail String
isDefault Boolean @default(false)
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
}
// ── 订单 ──────────────────────────────────────────────
model Order {
id String @id @default(cuid())
orderNo String @unique
userId String
status OrderStatus @default(PENDING)
totalAmount Decimal @db.Decimal(10, 2)
addressSnapshot Json
user User @relation(fields: [userId], references: [id])
items OrderItem[]
payment Payment?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([status])
}
enum OrderStatus {
PENDING
PAID
PROCESSING
SHIPPED
COMPLETED
CANCELLED
}
model OrderItem {
id String @id @default(cuid())
orderId String
productId String?
name String
price Decimal @db.Decimal(10, 2)
quantity Int
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
product Product? @relation(fields: [productId], references: [id])
@@index([orderId])
}
// ── 支付 ──────────────────────────────────────────────
model Payment {
id String @id @default(cuid())
orderId String @unique
transactionId String?
provider PaymentProvider @default(WECHAT)
status PaymentStatus @default(PENDING)
paidAt DateTime?
order Order @relation(fields: [orderId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum PaymentProvider {
WECHAT
}
enum PaymentStatus {
PENDING
SUCCESS
REFUNDED
}
// ── 文件上传 ──────────────────────────────────────────
model Upload {
id String @id @default(cuid())
userId String
cosKey String
url String
mimetype String?
size Int?
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
@@index([userId])
}
// ── 异步定制任务 ──────────────────────────────────────
model CustomizationTask {
id String @id @default(cuid())
designListId String?
userId String?
status CustomizationTaskStatus @default(PENDING)
resultUrl String?
queueJobId String?
designList DesignList? @relation(fields: [designListId], references: [id])
user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([designListId])
@@index([status])
}
enum CustomizationTaskStatus {
PENDING
RUNNING
SUCCESS
FAILED
}
+46
View File
@@ -0,0 +1,46 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
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',
},
});
// eslint-disable-next-line no-console
console.log('Seed 完成:分类 + 示例商品已写入');
}
main()
.catch((e) => {
// eslint-disable-next-line no-console
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+30
View File
@@ -0,0 +1,30 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator';
import { AddressesService } from './addresses.service';
import { CreateAddressDto } from './dto/create-address.dto';
@ApiTags('收货地址')
@ApiBearerAuth()
@Controller('addresses')
export class AddressesController {
constructor(private readonly addressesService: AddressesService) {}
@Get()
@ApiOperation({ summary: '我的收货地址列表' })
list(@CurrentUser() user: JwtPayload) {
return this.addressesService.listByUser(user.sub);
}
@Post()
@ApiOperation({ summary: '新增收货地址' })
create(@CurrentUser() user: JwtPayload, @Body() dto: CreateAddressDto) {
return this.addressesService.create(user.sub, dto);
}
@Patch(':id/default')
@ApiOperation({ summary: '设为默认地址' })
setDefault(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
return this.addressesService.setDefault(user.sub, id);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AddressesController } from './addresses.controller';
import { AddressesService } from './addresses.service';
@Module({
controllers: [AddressesController],
providers: [AddressesService],
exports: [AddressesService],
})
export class AddressesModule {}
+25
View File
@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateAddressDto } from './dto/create-address.dto';
@Injectable()
export class AddressesService {
constructor(private readonly prisma: PrismaService) {}
async listByUser(userId: string) {
return this.prisma.address.findMany({
where: { userId },
orderBy: [{ isDefault: 'desc' }, { createdAt: 'desc' }],
});
}
async create(userId: string, dto: CreateAddressDto) {
// TODO: 若 isDefault,需先把该用户其它地址置为非默认(事务)
return this.prisma.address.create({ data: { ...dto, userId } });
}
async setDefault(userId: string, id: string) {
// TODO: 事务内先清旧默认再设新默认
return this.prisma.address.update({ where: { id }, data: { isDefault: true } });
}
}
+39
View File
@@ -0,0 +1,39 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateAddressDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
name!: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
phone!: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
province!: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
city!: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
district!: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
detail!: string;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
isDefault?: boolean;
}
+49
View File
@@ -0,0 +1,49 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { validationSchema } from './config/validation.schema';
import { PrismaModule } from './prisma/prisma.module';
import { RedisModule } from './redis/redis.module';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { WechatModule } from './wechat/wechat.module';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { ProductsModule } from './products/products.module';
import { CategoriesModule } from './categories/categories.module';
import { DesignListModule } from './design-list/design-list.module';
import { AddressesModule } from './addresses/addresses.module';
import { OrdersModule } from './orders/orders.module';
import { PaymentsModule } from './payments/payments.module';
import { UploadModule } from './upload/upload.module';
import { QueueModule } from './queue/queue.module';
import { HealthModule } from './health/health.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
// 直接读 env(已在 .env / 容器环境注入),用 configuration() 分组
load: [() => require('./config/configuration').default()],
validationSchema,
validationOptions: { abortEarly: false },
}),
PrismaModule,
RedisModule,
WechatModule,
AuthModule,
UsersModule,
ProductsModule,
CategoriesModule,
DesignListModule,
AddressesModule,
OrdersModule,
PaymentsModule,
UploadModule,
QueueModule,
HealthModule,
],
// 默认全局开启 JWT 鉴权,@Public() 路由除外
providers: [{ provide: APP_GUARD, useClass: JwtAuthGuard }],
})
export class AppModule {}
+20
View File
@@ -0,0 +1,20 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '../common/decorators/public.decorator';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
@ApiTags('认证')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Public()
@Post('login')
@ApiOperation({ summary: '微信小程序登录(传 wx.login code' })
@ApiOkResponse({ schema: { example: { code: 0, message: 'ok', data: { accessToken: '...' } } } })
async login(@Body() dto: LoginDto) {
// 仅凭 code 由服务端换 openid,前端传不传 openid 一律忽略
return this.authService.login(dto.code);
}
}
+24
View File
@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtConfigService } from './jwt-config.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { UsersModule } from '../users/users.module';
import { WechatModule } from '../wechat/wechat.module';
@Module({
imports: [
UsersModule,
WechatModule,
PassportModule,
JwtModule.registerAsync({
useClass: JwtConfigService,
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, JwtConfigService],
exports: [AuthService],
})
export class AuthModule {}
+55
View File
@@ -0,0 +1,55 @@
import { Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { Redis } from 'ioredis';
import { REDIS_CLIENT } from '../redis/redis.module';
import { JwtPayload } from '../common/decorators/current-user.decorator';
import { UsersService } from '../users/users.service';
import { WechatService } from '../wechat/wechat.service';
// session_key 在 Redis 的存储键与默认有效期(微信 session_key 约 30 天,这里保守用 7 天)
const SESSION_KEY_TTL = 7 * 24 * 60 * 60;
@Injectable()
export class AuthService {
constructor(
private readonly wechat: WechatService,
private readonly users: UsersService,
private readonly jwt: JwtService,
private readonly config: ConfigService,
@Inject(REDIS_CLIENT) private readonly redis: Redis,
) {}
/**
* 微信小程序登录核心流程:
* 1. 用前端 code 调微信 code2Session 换 openid + session_key(不信任任何前端 openid
* 2. findOrCreate 用户
* 3. session_key 存 Redis(供后续解密 encryptedData / phone
* 4. 签发自有 JWT 返回
*/
async login(code: string): Promise<{ accessToken: string }> {
const session = await this.wechat.code2Session(code);
const user = await this.users.findOrCreateByOpenid(session.openid, session.unionid);
// 缓存 session_keykey 形如 wx:session:{userId}
await this.redis.set(
`wx:session:${user.id}`,
session.sessionKey,
'EX',
SESSION_KEY_TTL,
);
const payload: JwtPayload = { sub: user.id, openid: user.openid };
const accessToken = await this.jwt.signAsync(payload, {
expiresIn: this.config.get<number>('jwt.expires'),
});
return { accessToken };
}
/** 取出缓存的 session_key(供解密小程序加密数据用) */
async getSessionKey(userId: string): Promise<string | null> {
return this.redis.get(`wx:session:${userId}`);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class LoginDto {
@ApiProperty({ description: 'wx.login() 返回的临时登录 code', example: '0a3xxxxxx' })
@IsString()
@IsNotEmpty()
code!: string;
}
+19
View File
@@ -0,0 +1,19 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModuleOptions, JwtOptionsFactory } from '@nestjs/jwt';
// 集中 JWT 模块配置:从 config 读取 secret 与过期时间
@Injectable()
export class JwtConfigService implements JwtOptionsFactory {
constructor(private readonly config: ConfigService) {}
createJwtOptions(): JwtModuleOptions {
return {
secret: this.config.get<string>('jwt.secret'),
signOptions: {
// JWT expiresIn 接受秒数(数字)或字符串(如 '2h'),这里用秒数
expiresIn: this.config.get<number>('jwt.expires'),
},
};
}
}
+31
View File
@@ -0,0 +1,31 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { JwtPayload } from '../../common/decorators/current-user.decorator';
import { UsersService } from '../../users/users.service';
// JWT 策略:解析 Bearer token,校验用户存在后注入 req.user
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private readonly config: ConfigService,
private readonly usersService: UsersService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.get<string>('jwt.secret') as string,
});
}
// payload 即解码后的 JWT 内容 { sub, openid }
async validate(payload: JwtPayload): Promise<JwtPayload> {
// 校验用户仍然存在(已注销 / 伪造 token 则 401
const user = await this.usersService.findById(payload.sub);
if (!user) {
throw new UnauthorizedException('用户不存在或 token 无效');
}
return { sub: payload.sub, openid: payload.openid };
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '../common/decorators/public.decorator';
import { CategoriesService } from './categories.service';
import { CreateCategoryDto } from './dto/create-category.dto';
@ApiTags('商品分类')
@ApiBearerAuth()
@Controller('categories')
export class CategoriesController {
constructor(private readonly categoriesService: CategoriesService) {}
@Public()
@Get()
@ApiOperation({ summary: '分类列表' })
findAll() {
return this.categoriesService.findAll();
}
@Post()
@ApiOperation({ summary: '创建分类(管理后台用)' })
create(@Body() dto: CreateCategoryDto) {
return this.categoriesService.create(dto);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CategoriesController } from './categories.controller';
import { CategoriesService } from './categories.service';
@Module({
controllers: [CategoriesController],
providers: [CategoriesService],
exports: [CategoriesService],
})
export class CategoriesModule {}
+18
View File
@@ -0,0 +1,18 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateCategoryDto } from './dto/create-category.dto';
@Injectable()
export class CategoriesService {
constructor(private readonly prisma: PrismaService) {}
// TODO: 树形结构组装、缓存
async findAll() {
return this.prisma.category.findMany({ orderBy: { sort: 'asc' } });
}
async create(dto: CreateCategoryDto) {
// TODO: 校验 parentId 存在性、同层级重名
return this.prisma.category.create({ data: dto });
}
}
+19
View File
@@ -0,0 +1,19 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsOptional, IsString, Min } from 'class-validator';
export class CreateCategoryDto {
@ApiProperty({ description: '分类名称' })
@IsString()
name!: string;
@ApiPropertyOptional({ description: '父分类 id,顶级分类不传' })
@IsOptional()
@IsString()
parentId?: string;
@ApiPropertyOptional({ description: '排序值', default: 0 })
@IsOptional()
@IsInt()
@Min(0)
sort?: number;
}
@@ -0,0 +1,15 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
// 从 req.user 取出当前登录用户(由 JwtStrategy.validate 注入)
export interface JwtPayload {
sub: string; // userId
openid: string;
}
export const CurrentUser = createParamDecorator(
(data: keyof JwtPayload | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as JwtPayload | undefined;
return data ? user?.[data] : user;
},
);
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
// 标记路由为公开(免鉴权),如 /auth/login、/health
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
+25
View File
@@ -0,0 +1,25 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class PaginationDto {
@ApiPropertyOptional({ default: 1, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page: number = 1;
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize: number = 20;
@ApiPropertyOptional({ description: '排序,如 createdAt:desc' })
@IsOptional()
@IsString()
sort?: string;
}
+20
View File
@@ -0,0 +1,20 @@
// 统一响应结构:所有接口(成功/失败)都以此包裹
export class ResponseDto<T = unknown> {
code: number;
message: string;
data: T;
constructor(code: number, message: string, data: T) {
this.code = code;
this.message = message;
this.data = data;
}
static success<T>(data: T, message = 'ok'): ResponseDto<T> {
return new ResponseDto(0, message, data);
}
static error(code: number, message: string): ResponseDto<null> {
return new ResponseDto(code, message, null);
}
}
@@ -0,0 +1,72 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Request, Response } from 'express';
import { ResponseDto } from '../dto/response.dto';
// 捕获所有异常,统一输出 { code, message, data:null }
// Prisma 已知错误码映射到合适的 HTTP 状态
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = '服务器内部错误';
let code = 500;
if (exception instanceof HttpException) {
status = exception.getStatus();
code = status;
const res = exception.getResponse();
message =
typeof res === 'string'
? res
: ((res as Record<string, unknown>).message as string | string[] | undefined)?.toString() ??
exception.message;
} else if (exception instanceof Prisma.PrismaClientKnownRequestError) {
// 常见 Prisma 错误码映射
switch (exception.code) {
case 'P2002': // 唯一约束冲突
status = HttpStatus.CONFLICT;
code = 409;
message = '数据已存在';
break;
case 'P2025': // 记录未找到
status = HttpStatus.NOT_FOUND;
code = 404;
message = '记录不存在';
break;
default:
status = HttpStatus.BAD_REQUEST;
code = 400;
message = `数据库错误: ${exception.code}`;
}
} else if (exception instanceof Error) {
message = exception.message;
}
// 5xx 记录完整堆栈,4xx 仅记录摘要
if (status >= 500) {
this.logger.error(
`${request.method} ${request.url} -> ${status}`,
exception instanceof Error ? exception.stack : undefined,
);
} else {
this.logger.warn(`${request.method} ${request.url} -> ${status} ${message}`);
}
const body = ResponseDto.error(code, message);
response.status(status).json(body);
}
}
+21
View File
@@ -0,0 +1,21 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
// 默认 JWT 鉴权守卫;@Public() 装饰的路由跳过鉴权
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
}
@@ -0,0 +1,27 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { ResponseDto } from '../dto/response.dto';
// 成功响应统一包裹为 { code:0, message:'ok', data }
// 跳过已经是 ResponseDto / 原生流 / Swagger 文档等响应
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, ResponseDto<T> | T> {
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<ResponseDto<T> | T> {
return next.handle().pipe(
map((data) => {
if (data instanceof ResponseDto) return data;
// 不包裹二进制 / null / 已是结构化分页对象等场景,简单起见统一包裹
return ResponseDto.success(data);
}),
);
}
}
+38
View File
@@ -0,0 +1,38 @@
// 环境变量读取:所有配置集中此处,按命名空间分组导出
export default () => ({
nodeEnv: process.env.NODE_ENV ?? 'development',
isProd: process.env.NODE_ENV === 'production',
app: {
port: parseInt(process.env.APP_PORT ?? '3000', 10),
swaggerPath: process.env.SWAGGER_PATH ?? 'docs',
},
database: {
url: process.env.DATABASE_URL ?? '',
},
redis: {
url: process.env.REDIS_URL ?? 'redis://localhost:6379',
},
jwt: {
secret: process.env.JWT_SECRET ?? '',
// JWT_EXPIRES 单位为秒
expires: parseInt(process.env.JWT_EXPIRES ?? '7200', 10),
},
wx: {
appid: process.env.WX_APPID ?? '',
secret: process.env.WX_SECRET ?? '',
// 仅本地联调用:设为 '1' 时跳过微信请求直接返回 mock openid(切勿生产开启)
mockLogin: process.env.WX_MOCK_LOGIN ?? '',
// 支付相关(预留)
mchId: process.env.WX_MCH_ID ?? '',
mchApiV3Key: process.env.WX_MCH_API_V3_KEY ?? '',
mchSerialNo: process.env.WX_MCH_SERIAL_NO ?? '',
mchPrivateKeyPath: process.env.WX_MCH_PRIVATE_KEY_PATH ?? '',
payNotifyUrl: process.env.WX_PAY_NOTIFY_URL ?? '',
},
cos: {
secretId: process.env.COS_SECRET_ID ?? '',
secretKey: process.env.COS_SECRET_KEY ?? '',
bucket: process.env.COS_BUCKET ?? '',
region: process.env.COS_REGION ?? 'ap-guangzhou',
},
});
+31
View File
@@ -0,0 +1,31 @@
import * as Joi from 'joi';
// 启动时对环境变量做强校验:缺失关键项即 fail-fast,避免运行期才发现配置错误
export const validationSchema = Joi.object({
NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'),
APP_PORT: Joi.number().default(3000),
SWAGGER_PATH: Joi.string().allow('').default('docs'),
DATABASE_URL: Joi.string().required(),
REDIS_URL: Joi.string().required(),
JWT_SECRET: Joi.string().min(16).required(),
JWT_EXPIRES: Joi.number().default(7200),
WX_APPID: Joi.string().required(),
WX_SECRET: Joi.string().required(),
// 本地联调 mock 开关,默认关闭;切勿在生产设为 1
WX_MOCK_LOGIN: Joi.string().valid('0', '1').allow('').default(''),
// 微信支付与 COS:骨架阶段可为空
WX_MCH_ID: Joi.string().allow('').default(''),
WX_MCH_API_V3_KEY: Joi.string().allow('').default(''),
WX_MCH_SERIAL_NO: Joi.string().allow('').default(''),
WX_MCH_PRIVATE_KEY_PATH: Joi.string().allow('').default(''),
WX_PAY_NOTIFY_URL: Joi.string().allow('').default(''),
COS_SECRET_ID: Joi.string().allow('').default(''),
COS_SECRET_KEY: Joi.string().allow('').default(''),
COS_BUCKET: Joi.string().allow('').default(''),
COS_REGION: Joi.string().default('ap-guangzhou'),
});
+30
View File
@@ -0,0 +1,30 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator';
import { DesignListService } from './design-list.service';
import { CreateDesignListDto } from './dto/create-design-list.dto';
@ApiTags('设计清单')
@ApiBearerAuth()
@Controller('design-lists')
export class DesignListController {
constructor(private readonly designListService: DesignListService) {}
@Get()
@ApiOperation({ summary: '我的设计清单列表' })
list(@CurrentUser() user: JwtPayload) {
return this.designListService.listByUser(user.sub);
}
@Get(':id')
@ApiOperation({ summary: '设计清单详情' })
findOne(@Param('id') id: string) {
return this.designListService.findOne(id);
}
@Post()
@ApiOperation({ summary: '创建设计清单' })
create(@CurrentUser() user: JwtPayload, @Body() dto: CreateDesignListDto) {
return this.designListService.create(user.sub, dto);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { DesignListController } from './design-list.controller';
import { DesignListService } from './design-list.service';
@Module({
controllers: [DesignListController],
providers: [DesignListService],
exports: [DesignListService],
})
export class DesignListModule {}
+32
View File
@@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateDesignListDto } from './dto/create-design-list.dto';
@Injectable()
export class DesignListService {
constructor(private readonly prisma: PrismaService) {}
async listByUser(userId: string) {
return this.prisma.designList.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
}
async findOne(id: string) {
// TODO: 权限校验(仅本人可查)
return this.prisma.designList.findUnique({ where: { id } });
}
async create(userId: string, dto: CreateDesignListDto) {
// TODO: 校验 items 结构、关联商品 SKU
return this.prisma.designList.create({
data: {
title: dto.title,
items: (dto.items ?? []) as Prisma.InputJsonValue,
userId,
},
});
}
}
@@ -0,0 +1,14 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsObject, IsOptional, IsString } from 'class-validator';
export class CreateDesignListDto {
@ApiProperty({ description: '清单标题' })
@IsString()
title!: string;
@ApiPropertyOptional({ description: '定制项(结构化 JSON', example: [{ sku: 'tshirt', color: 'black' }] })
@IsOptional()
@IsArray()
@IsObject({ each: true })
items?: Record<string, unknown>[];
}
+15
View File
@@ -0,0 +1,15 @@
import { Controller, Get } from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '../common/decorators/public.decorator';
@ApiTags('系统')
@Controller('health')
export class HealthController {
@Public()
@Get()
@ApiOperation({ summary: '健康检查' })
@ApiOkResponse({ schema: { example: { code: 0, message: 'ok', data: { status: 'ok' } } } })
health() {
return { status: 'ok', timestamp: new Date().toISOString() };
}
}
+7
View File
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}
+48
View File
@@ -0,0 +1,48 @@
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
const config = app.get(ConfigService);
app.setGlobalPrefix('api', { exclude: ['health'] });
app.enableCors();
// 全局管道:剥离未声明字段 + 自动类型转换 + 拒绝多余字段
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
// 统一响应包裹 + 统一异常输出
app.useGlobalInterceptors(new TransformInterceptor());
app.useGlobalFilters(new AllExceptionsFilter());
// Swagger 文档,生产环境可置空 SWAGGER_PATH 关闭
const swaggerPath = config.get<string>('app.swaggerPath');
if (swaggerPath) {
const docConfig = new DocumentBuilder()
.setTitle('wxmp-backend API')
.setDescription('微信小程序后端接口文档')
.setVersion('0.1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, docConfig);
SwaggerModule.setup(swaggerPath, app, document);
}
const port = config.get<number>('app.port') ?? 3000;
await app.listen(port);
// eslint-disable-next-line no-console
console.log(`应用已启动: http://localhost:${port} | Swagger: /${swaggerPath}`);
}
void bootstrap();
+40
View File
@@ -0,0 +1,40 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class OrderItemDto {
@ApiPropertyOptional({ description: '商品 id(定制类可空)' })
@IsOptional()
@IsString()
productId?: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
name!: string;
@ApiProperty({ description: '单价(元)' })
@Type(() => Number)
price!: number;
@ApiProperty({ description: '数量' })
@Type(() => Number)
quantity!: number;
}
export class CreateOrderDto {
@ApiProperty({ description: '收货地址快照(JSON' })
@IsObject()
addressSnapshot!: Record<string, unknown>;
@ApiProperty({ type: [OrderItemDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => OrderItemDto)
items!: OrderItemDto[];
@ApiPropertyOptional({ description: '关联设计清单 id(定制订单)' })
@IsOptional()
@IsString()
designListId?: string;
}
+30
View File
@@ -0,0 +1,30 @@
import { Body, Controller, Get, Param, Post } 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';
@ApiTags('订单')
@ApiBearerAuth()
@Controller('orders')
export class OrdersController {
constructor(private readonly ordersService: OrdersService) {}
@Get()
@ApiOperation({ summary: '我的订单列表' })
list(@CurrentUser() user: JwtPayload) {
return this.ordersService.listByUser(user.sub);
}
@Get(':id')
@ApiOperation({ summary: '订单详情' })
findOne(@Param('id') id: string) {
return this.ordersService.findOne(id);
}
@Post()
@ApiOperation({ summary: '创建订单' })
create(@CurrentUser() user: JwtPayload, @Body() dto: CreateOrderDto) {
return this.ordersService.create(user.sub, dto);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
@Module({
controllers: [OrdersController],
providers: [OrdersService],
exports: [OrdersService],
})
export class OrdersModule {}
+58
View File
@@ -0,0 +1,58 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateOrderDto } from './dto/create-order.dto';
// 订单号生成:日期 + 随机后缀(无 Math.random 依赖要求仅限 workflow 脚本,普通运行时可用)
function generateOrderNo(): string {
const now = new Date();
const ymd =
now.getUTCFullYear().toString() +
String(now.getUTCMonth() + 1).padStart(2, '0') +
String(now.getUTCDate()).padStart(2, '0');
const rand = Math.random().toString(36).slice(2, 8).toUpperCase();
return `${ymd}${rand}`;
}
@Injectable()
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' },
});
}
async findOne(id: string) {
// TODO: 权限校验(仅本人)
return this.prisma.order.findUnique({
where: { id },
include: { items: true, payment: true },
});
}
async create(userId: string, dto: CreateOrderDto) {
// TODO: 商品库存/价格校验、事务原子性、金额防篡改(服务端重算 totalAmount
const totalAmount = dto.items.reduce((sum, it) => sum + it.price * it.quantity, 0);
const data: Prisma.OrderCreateInput = {
orderNo: generateOrderNo(),
user: { connect: { id: userId } },
totalAmount,
addressSnapshot: dto.addressSnapshot as Prisma.InputJsonValue,
items: {
create: dto.items.map((it) => ({
productId: it.productId,
name: it.name,
price: it.price,
quantity: it.quantity,
})),
},
};
return this.prisma.order.create({ data, include: { items: true } });
}
}
+27
View File
@@ -0,0 +1,27 @@
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';
@ApiTags('支付')
@ApiBearerAuth()
@Controller('payments')
export class PaymentsController {
constructor(private readonly paymentsService: PaymentsService) {}
@Post(':orderId/pay')
@ApiOperation({ summary: '对指定订单发起支付(占位)' })
pay(@Param('orderId') orderId: string) {
return this.paymentsService.createPayment(orderId);
}
@Public()
@Post('notify')
@ApiOperation({ summary: '微信支付回调(占位,需原始 body)' })
notify(@Body() rawBody: unknown, @Req() req: { headers: Record<string, string> }) {
return this.paymentsService.handleNotify(
typeof rawBody === 'string' ? rawBody : JSON.stringify(rawBody),
req.headers,
);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PaymentsController } from './payments.controller';
import { PaymentsService } from './payments.service';
import { WechatModule } from '../wechat/wechat.module';
@Module({
imports: [WechatModule],
controllers: [PaymentsController],
providers: [PaymentsService],
})
export class PaymentsModule {}
+27
View File
@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { WechatService } from '../wechat/wechat.service';
@Injectable()
export class PaymentsService {
constructor(
private readonly prisma: PrismaService,
private readonly wechat: WechatService,
) {}
/** 发起支付:创建支付记录并调微信统一下单(本轮占位) */
async createPayment(orderId: string) {
// TODO: 查订单、校验状态/金额、创建 Payment 记录、调 wechat.createUnifiedOrder
void this.wechat; // 占位引用,避免未使用告警
return this.prisma.payment.create({
data: { order: { connect: { id: orderId } } },
});
}
/** 微信支付回调入口(本轮占位) */
async handleNotify(rawBody: string, headers: Record<string, string>) {
// TODO: 验签 -> 解密 -> 更新 Payment/Order 状态 -> 幂等
const result = await this.wechat.verifyPayNotify(rawBody, headers).catch(() => null);
return { code: 'FAIL', message: '回调处理尚未实现', result };
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+14
View File
@@ -0,0 +1,14 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
// 全局共享单例 PrismaClient;连接在模块生命周期内管理
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit(): Promise<void> {
await this.$connect();
}
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
}
}
+37
View File
@@ -0,0 +1,37 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsArray, IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator';
import { ProductStatus } from '@prisma/client';
export class CreateProductDto {
@ApiProperty({ description: '商品名称' })
@IsString()
name!: string;
@ApiPropertyOptional({ description: '分类 id' })
@IsOptional()
@IsString()
categoryId?: string;
@ApiProperty({ description: '价格(元)', example: 99.0 })
@Type(() => Number)
@IsNumber()
@Min(0)
price!: number;
@ApiPropertyOptional({ description: '图片 URL 列表', type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
images?: string[];
@ApiPropertyOptional({ description: '描述' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ enum: ProductStatus, default: ProductStatus.DRAFT })
@IsOptional()
@IsEnum(ProductStatus)
status?: ProductStatus;
}
+32
View File
@@ -0,0 +1,32 @@
import { Body, Controller, Get, Param, Post } 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';
@ApiTags('商品')
@ApiBearerAuth()
@Controller('products')
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@Public()
@Get()
@ApiOperation({ summary: '在售商品列表' })
list() {
return this.productsService.list();
}
@Public()
@Get(':id')
@ApiOperation({ summary: '商品详情' })
findOne(@Param('id') id: string) {
return this.productsService.findOne(id);
}
@Post()
@ApiOperation({ summary: '创建商品(管理后台用)' })
create(@Body() dto: CreateProductDto) {
return this.productsService.create(dto);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ProductsController } from './products.controller';
import { ProductsService } from './products.service';
@Module({
controllers: [ProductsController],
providers: [ProductsService],
exports: [ProductsService],
})
export class ProductsModule {}
+27
View File
@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { ProductStatus } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateProductDto } from './dto/create-product.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 findOne(id: string) {
// TODO: 404 处理、浏览量统计
return this.prisma.product.findUnique({ where: { id } });
}
async create(dto: CreateProductDto) {
// TODO: 校验 categoryId 存在、图片归属
return this.prisma.product.create({ data: dto });
}
}
@@ -0,0 +1,15 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
// 异步定制任务处理器(占位):实际执行设计渲染/生产派单等耗时任务
@Processor('customization')
export class CustomizationProcessor extends WorkerHost {
private readonly logger = new Logger(CustomizationProcessor.name);
async process(job: Job): Promise<unknown> {
this.logger.log(`处理定制任务 job=${job.id} data=${JSON.stringify(job.data)}`);
// TODO: 调用设计渲染/生产系统,更新 CustomizationTask 状态
return { ok: true };
}
}
+26
View File
@@ -0,0 +1,26 @@
import { BullModule } from '@nestjs/bullmq';
import { Global, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { CUSTOMIZATION_QUEUE } from './queue.service';
import { QueueService } from './queue.service';
import { CustomizationProcessor } from './processors/customization.processor';
@Global()
@Module({
imports: [
BullModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => {
const url = config.get<string>('redis.url');
return {
connection: { url, maxRetriesPerRequest: null },
};
},
}),
BullModule.registerQueue({ name: CUSTOMIZATION_QUEUE }),
],
providers: [QueueService, CustomizationProcessor],
exports: [QueueService],
})
export class QueueModule {}
+17
View File
@@ -0,0 +1,17 @@
import { InjectQueue } from '@nestjs/bullmq';
import { Injectable } from '@nestjs/common';
import { Queue } from 'bullmq';
export const CUSTOMIZATION_QUEUE = 'customization';
@Injectable()
export class QueueService {
constructor(
@InjectQueue(CUSTOMIZATION_QUEUE) private readonly customizationQueue: Queue,
) {}
/** 入队一个定制任务 */
async enqueueCustomization(payload: Record<string, unknown>) {
return this.customizationQueue.add('customization', payload);
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Global, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
// 暴露全局共享的 ioredis 实例:
// - 微信 session_key 缓存
// - BullMQ 底层队列存储(BullMQ 自带连接,此处主要供业务直接使用)
export const REDIS_CLIENT = 'REDIS_CLIENT';
@Global()
@Module({
imports: [ConfigModule],
providers: [
{
provide: REDIS_CLIENT,
inject: [ConfigService],
useFactory: (config: ConfigService) => {
const url = config.get<string>('redis.url') as string;
return new Redis(url, { maxRetriesPerRequest: null });
},
},
],
exports: [REDIS_CLIENT],
})
export class RedisModule {}
+18
View File
@@ -0,0 +1,18 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator';
import { UploadService } from './upload.service';
@ApiTags('文件上传')
@ApiBearerAuth()
@Controller('upload')
export class UploadController {
constructor(private readonly uploadService: UploadService) {}
@Get('credentials')
@ApiOperation({ summary: '获取 COS 直传临时凭证(占位)' })
credentials(@CurrentUser() user: JwtPayload, @Query('key') key: string) {
void user;
return this.uploadService.getUploadCredentials(key ?? `uploads/${Date.now()}`);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { UploadController } from './upload.controller';
import { UploadService } from './upload.service';
@Module({
controllers: [UploadController],
providers: [UploadService],
})
export class UploadModule {}
+21
View File
@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
// 腾讯云 COS 上传服务(本轮仅提供签名/直传凭证占位,完整实现留后续)
@Injectable()
export class UploadService {
constructor(private readonly config: ConfigService) {}
/** 生成小程序直传所需的临时密钥 / 预签名 URL(占位) */
async getUploadCredentials(key: string) {
// TODO: 通过 COS STS 或预签名 URL 生成临时凭证
//SDK 已移除,接入时见 docs/cos-sdk-removal.md
return {
key,
bucket: this.config.get<string>('cos.bucket'),
region: this.config.get<string>('cos.region'),
// 占位:实际应返回临时 SecretId/SecretKey/Token 或 presigned URL
credentials: null,
};
}
}
+18
View File
@@ -0,0 +1,18 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator';
import { UsersService } from './users.service';
@ApiTags('用户')
@ApiBearerAuth()
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get('me')
@ApiOperation({ summary: '获取当前登录用户信息' })
@ApiOkResponse({ description: '当前用户' })
async getMe(@CurrentUser() user: JwtPayload) {
return this.usersService.findById(user.sub);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
+25
View File
@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
/** 按 openid 查询用户,不存在则创建(首次登录) */
async findOrCreateByOpenid(openid: string, unionid?: string) {
return this.prisma.user.upsert({
where: { openid },
update: unionid ? { unionid } : {},
create: { openid, unionid },
});
}
async findById(id: string) {
return this.prisma.user.findUnique({ where: { id } });
}
/** 更新昵称/头像/手机号(由小程序授权后回传) */
async updateProfile(id: string, data: { nickname?: string; avatar?: string; phone?: string }) {
return this.prisma.user.update({ where: { id }, data });
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { WechatService } from './wechat.service';
@Global()
@Module({
providers: [WechatService],
exports: [WechatService],
})
export class WechatModule {}
+82
View File
@@ -0,0 +1,82 @@
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
// code2Session 返回结构(微信 sns/jscode2session
export interface Code2SessionResult {
openid: string;
sessionKey: string;
unionid?: string;
}
// 微信支付统一下单入参(预留,暂未实现)
export interface UnifiedOrderInput {
orderNo: string;
amount: number; // 单位:分
description: string;
openid: string;
notifyUrl?: string;
}
@Injectable()
export class WechatService {
private readonly logger = new Logger(WechatService.name);
constructor(private readonly config: ConfigService) {}
/**
* 用前端 wx.login() 的 code 换取 openid + session_key。
* 关键安全点:appid/secret 由服务端持有,前端只传 code,
* openid 一律以微信返回为准,绝不信任前端传入。
*
* 本地无真实 appid 时可设 WX_MOCK_LOGIN=1:直接以 code 作为 openid
* 返回,用于走通登录链路(仅供开发,切勿在生产开启)。
*/
async code2Session(code: string): Promise<Code2SessionResult> {
if (this.config.get<string>('wx.mockLogin') === '1') {
return { openid: `mock-${code}`, sessionKey: 'mock-session-key' };
}
const appid = this.config.get<string>('wx.appid');
const secret = this.config.get<string>('wx.secret');
const url = 'https://api.weixin.qq.com/sns/jscode2session';
const params = {
appid,
secret,
js_code: code,
grant_type: 'authorization_code',
};
try {
const { data } = await axios.get(url, { params, timeout: 8000 });
if (data.errcode) {
// 微信侧错误(如 code 无效 / appid 不匹配)
throw new ServiceUnavailableException(
`微信登录失败: ${data.errcode} ${data.errmsg}`,
);
}
return {
openid: data.openid,
sessionKey: data.session_key,
unionid: data.unionid,
};
} catch (error) {
if (error instanceof ServiceUnavailableException) throw error;
this.logger.error('调用微信 code2Session 失败', String(error));
throw new ServiceUnavailableException('微信服务暂不可用');
}
}
// ── 以下为微信支付 V3 预留接口,本轮仅占位 ──────────────
// TODO: 接入微信支付 V3(统一下单 / 回调验签 / 查单 / 退款)
/** 统一下单,返回小程序调起支付所需参数 */
async createUnifiedOrder(_input: UnifiedOrderInput): Promise<never> {
throw new Error('微信支付尚未实现(wechat.createUnifiedOrder 占位)');
}
/** 支付回调验签 + 解密,返回订单号与支付结果 */
async verifyPayNotify(_rawBody: string, _headers: Record<string, string>): Promise<never> {
throw new Error('微信支付回调验签尚未实现(wechat.verifyPayNotify 占位)');
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"target": "ES2022",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strict": true,
"strictNullChecks": true,
"noImplicitAny": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}