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:
@@ -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
@@ -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
|
||||
@@ -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 |
|
||||
| 鉴权 | JWT(passport-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 测试
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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"]
|
||||
@@ -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. 若仍报 critical(e.g. `fast-xml-parser` 传递依赖),考虑用「先直传后回调」的
|
||||
最小化接入,或改用官方推荐的 **COS STS / 临时密钥** 方案,减少客户端 SDK 依赖。
|
||||
@@ -0,0 +1,97 @@
|
||||
# 密钥与配置获取指南
|
||||
|
||||
本项目的敏感配置全部通过环境变量(`.env`)注入,绝不允许提交进仓库。
|
||||
以下说明各项值的**来源**与**获取方式**。
|
||||
|
||||
> ⚠️ 安全提醒:任何密钥都不要写入代码、提交到 git,或发给第三方。
|
||||
> 妥善保管,生产环境与本地环境分离。
|
||||
|
||||
---
|
||||
|
||||
## 1. JWT_SECRET(JWT 签名密钥)
|
||||
|
||||
- **是什么**:用于签名/校验登录 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. 数据库与 Redis(DATABASE_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` 原样搬上生产**。
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
Generated
+7073
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "wxmp-backend",
|
||||
"version": "0.1.0",
|
||||
"description": "微信小程序后端 API(NestJS + 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;
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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_key,key 形如 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}`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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'),
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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>[];
|
||||
}
|
||||
@@ -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() };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
+48
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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()}`);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { WechatService } from './wechat.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [WechatService],
|
||||
exports: [WechatService],
|
||||
})
|
||||
export class WechatModule {}
|
||||
@@ -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 占位)');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user