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