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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 17:33:50 +08:00
co-authored by Claude Fable 5
commit deaa0c9ce4
73 changed files with 9533 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator';
import { AddressesService } from './addresses.service';
import { CreateAddressDto } from './dto/create-address.dto';
@ApiTags('收货地址')
@ApiBearerAuth()
@Controller('addresses')
export class AddressesController {
constructor(private readonly addressesService: AddressesService) {}
@Get()
@ApiOperation({ summary: '我的收货地址列表' })
list(@CurrentUser() user: JwtPayload) {
return this.addressesService.listByUser(user.sub);
}
@Post()
@ApiOperation({ summary: '新增收货地址' })
create(@CurrentUser() user: JwtPayload, @Body() dto: CreateAddressDto) {
return this.addressesService.create(user.sub, dto);
}
@Patch(':id/default')
@ApiOperation({ summary: '设为默认地址' })
setDefault(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
return this.addressesService.setDefault(user.sub, id);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AddressesController } from './addresses.controller';
import { AddressesService } from './addresses.service';
@Module({
controllers: [AddressesController],
providers: [AddressesService],
exports: [AddressesService],
})
export class AddressesModule {}
+25
View File
@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateAddressDto } from './dto/create-address.dto';
@Injectable()
export class AddressesService {
constructor(private readonly prisma: PrismaService) {}
async listByUser(userId: string) {
return this.prisma.address.findMany({
where: { userId },
orderBy: [{ isDefault: 'desc' }, { createdAt: 'desc' }],
});
}
async create(userId: string, dto: CreateAddressDto) {
// TODO: 若 isDefault,需先把该用户其它地址置为非默认(事务)
return this.prisma.address.create({ data: { ...dto, userId } });
}
async setDefault(userId: string, id: string) {
// TODO: 事务内先清旧默认再设新默认
return this.prisma.address.update({ where: { id }, data: { isDefault: true } });
}
}
+39
View File
@@ -0,0 +1,39 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateAddressDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
name!: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
phone!: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
province!: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
city!: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
district!: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
detail!: string;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
isDefault?: boolean;
}
+49
View File
@@ -0,0 +1,49 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { validationSchema } from './config/validation.schema';
import { PrismaModule } from './prisma/prisma.module';
import { RedisModule } from './redis/redis.module';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { WechatModule } from './wechat/wechat.module';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { ProductsModule } from './products/products.module';
import { CategoriesModule } from './categories/categories.module';
import { DesignListModule } from './design-list/design-list.module';
import { AddressesModule } from './addresses/addresses.module';
import { OrdersModule } from './orders/orders.module';
import { PaymentsModule } from './payments/payments.module';
import { UploadModule } from './upload/upload.module';
import { QueueModule } from './queue/queue.module';
import { HealthModule } from './health/health.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
// 直接读 env(已在 .env / 容器环境注入),用 configuration() 分组
load: [() => require('./config/configuration').default()],
validationSchema,
validationOptions: { abortEarly: false },
}),
PrismaModule,
RedisModule,
WechatModule,
AuthModule,
UsersModule,
ProductsModule,
CategoriesModule,
DesignListModule,
AddressesModule,
OrdersModule,
PaymentsModule,
UploadModule,
QueueModule,
HealthModule,
],
// 默认全局开启 JWT 鉴权,@Public() 路由除外
providers: [{ provide: APP_GUARD, useClass: JwtAuthGuard }],
})
export class AppModule {}
+20
View File
@@ -0,0 +1,20 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '../common/decorators/public.decorator';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
@ApiTags('认证')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Public()
@Post('login')
@ApiOperation({ summary: '微信小程序登录(传 wx.login code' })
@ApiOkResponse({ schema: { example: { code: 0, message: 'ok', data: { accessToken: '...' } } } })
async login(@Body() dto: LoginDto) {
// 仅凭 code 由服务端换 openid,前端传不传 openid 一律忽略
return this.authService.login(dto.code);
}
}
+24
View File
@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtConfigService } from './jwt-config.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { UsersModule } from '../users/users.module';
import { WechatModule } from '../wechat/wechat.module';
@Module({
imports: [
UsersModule,
WechatModule,
PassportModule,
JwtModule.registerAsync({
useClass: JwtConfigService,
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, JwtConfigService],
exports: [AuthService],
})
export class AuthModule {}
+55
View File
@@ -0,0 +1,55 @@
import { Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { Redis } from 'ioredis';
import { REDIS_CLIENT } from '../redis/redis.module';
import { JwtPayload } from '../common/decorators/current-user.decorator';
import { UsersService } from '../users/users.service';
import { WechatService } from '../wechat/wechat.service';
// session_key 在 Redis 的存储键与默认有效期(微信 session_key 约 30 天,这里保守用 7 天)
const SESSION_KEY_TTL = 7 * 24 * 60 * 60;
@Injectable()
export class AuthService {
constructor(
private readonly wechat: WechatService,
private readonly users: UsersService,
private readonly jwt: JwtService,
private readonly config: ConfigService,
@Inject(REDIS_CLIENT) private readonly redis: Redis,
) {}
/**
* 微信小程序登录核心流程:
* 1. 用前端 code 调微信 code2Session 换 openid + session_key(不信任任何前端 openid
* 2. findOrCreate 用户
* 3. session_key 存 Redis(供后续解密 encryptedData / phone
* 4. 签发自有 JWT 返回
*/
async login(code: string): Promise<{ accessToken: string }> {
const session = await this.wechat.code2Session(code);
const user = await this.users.findOrCreateByOpenid(session.openid, session.unionid);
// 缓存 session_keykey 形如 wx:session:{userId}
await this.redis.set(
`wx:session:${user.id}`,
session.sessionKey,
'EX',
SESSION_KEY_TTL,
);
const payload: JwtPayload = { sub: user.id, openid: user.openid };
const accessToken = await this.jwt.signAsync(payload, {
expiresIn: this.config.get<number>('jwt.expires'),
});
return { accessToken };
}
/** 取出缓存的 session_key(供解密小程序加密数据用) */
async getSessionKey(userId: string): Promise<string | null> {
return this.redis.get(`wx:session:${userId}`);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class LoginDto {
@ApiProperty({ description: 'wx.login() 返回的临时登录 code', example: '0a3xxxxxx' })
@IsString()
@IsNotEmpty()
code!: string;
}
+19
View File
@@ -0,0 +1,19 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModuleOptions, JwtOptionsFactory } from '@nestjs/jwt';
// 集中 JWT 模块配置:从 config 读取 secret 与过期时间
@Injectable()
export class JwtConfigService implements JwtOptionsFactory {
constructor(private readonly config: ConfigService) {}
createJwtOptions(): JwtModuleOptions {
return {
secret: this.config.get<string>('jwt.secret'),
signOptions: {
// JWT expiresIn 接受秒数(数字)或字符串(如 '2h'),这里用秒数
expiresIn: this.config.get<number>('jwt.expires'),
},
};
}
}
+31
View File
@@ -0,0 +1,31 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { JwtPayload } from '../../common/decorators/current-user.decorator';
import { UsersService } from '../../users/users.service';
// JWT 策略:解析 Bearer token,校验用户存在后注入 req.user
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private readonly config: ConfigService,
private readonly usersService: UsersService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.get<string>('jwt.secret') as string,
});
}
// payload 即解码后的 JWT 内容 { sub, openid }
async validate(payload: JwtPayload): Promise<JwtPayload> {
// 校验用户仍然存在(已注销 / 伪造 token 则 401
const user = await this.usersService.findById(payload.sub);
if (!user) {
throw new UnauthorizedException('用户不存在或 token 无效');
}
return { sub: payload.sub, openid: payload.openid };
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '../common/decorators/public.decorator';
import { CategoriesService } from './categories.service';
import { CreateCategoryDto } from './dto/create-category.dto';
@ApiTags('商品分类')
@ApiBearerAuth()
@Controller('categories')
export class CategoriesController {
constructor(private readonly categoriesService: CategoriesService) {}
@Public()
@Get()
@ApiOperation({ summary: '分类列表' })
findAll() {
return this.categoriesService.findAll();
}
@Post()
@ApiOperation({ summary: '创建分类(管理后台用)' })
create(@Body() dto: CreateCategoryDto) {
return this.categoriesService.create(dto);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CategoriesController } from './categories.controller';
import { CategoriesService } from './categories.service';
@Module({
controllers: [CategoriesController],
providers: [CategoriesService],
exports: [CategoriesService],
})
export class CategoriesModule {}
+18
View File
@@ -0,0 +1,18 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateCategoryDto } from './dto/create-category.dto';
@Injectable()
export class CategoriesService {
constructor(private readonly prisma: PrismaService) {}
// TODO: 树形结构组装、缓存
async findAll() {
return this.prisma.category.findMany({ orderBy: { sort: 'asc' } });
}
async create(dto: CreateCategoryDto) {
// TODO: 校验 parentId 存在性、同层级重名
return this.prisma.category.create({ data: dto });
}
}
+19
View File
@@ -0,0 +1,19 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsOptional, IsString, Min } from 'class-validator';
export class CreateCategoryDto {
@ApiProperty({ description: '分类名称' })
@IsString()
name!: string;
@ApiPropertyOptional({ description: '父分类 id,顶级分类不传' })
@IsOptional()
@IsString()
parentId?: string;
@ApiPropertyOptional({ description: '排序值', default: 0 })
@IsOptional()
@IsInt()
@Min(0)
sort?: number;
}
@@ -0,0 +1,15 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
// 从 req.user 取出当前登录用户(由 JwtStrategy.validate 注入)
export interface JwtPayload {
sub: string; // userId
openid: string;
}
export const CurrentUser = createParamDecorator(
(data: keyof JwtPayload | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as JwtPayload | undefined;
return data ? user?.[data] : user;
},
);
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
// 标记路由为公开(免鉴权),如 /auth/login、/health
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
+25
View File
@@ -0,0 +1,25 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class PaginationDto {
@ApiPropertyOptional({ default: 1, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page: number = 1;
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize: number = 20;
@ApiPropertyOptional({ description: '排序,如 createdAt:desc' })
@IsOptional()
@IsString()
sort?: string;
}
+20
View File
@@ -0,0 +1,20 @@
// 统一响应结构:所有接口(成功/失败)都以此包裹
export class ResponseDto<T = unknown> {
code: number;
message: string;
data: T;
constructor(code: number, message: string, data: T) {
this.code = code;
this.message = message;
this.data = data;
}
static success<T>(data: T, message = 'ok'): ResponseDto<T> {
return new ResponseDto(0, message, data);
}
static error(code: number, message: string): ResponseDto<null> {
return new ResponseDto(code, message, null);
}
}
@@ -0,0 +1,72 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Request, Response } from 'express';
import { ResponseDto } from '../dto/response.dto';
// 捕获所有异常,统一输出 { code, message, data:null }
// Prisma 已知错误码映射到合适的 HTTP 状态
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = '服务器内部错误';
let code = 500;
if (exception instanceof HttpException) {
status = exception.getStatus();
code = status;
const res = exception.getResponse();
message =
typeof res === 'string'
? res
: ((res as Record<string, unknown>).message as string | string[] | undefined)?.toString() ??
exception.message;
} else if (exception instanceof Prisma.PrismaClientKnownRequestError) {
// 常见 Prisma 错误码映射
switch (exception.code) {
case 'P2002': // 唯一约束冲突
status = HttpStatus.CONFLICT;
code = 409;
message = '数据已存在';
break;
case 'P2025': // 记录未找到
status = HttpStatus.NOT_FOUND;
code = 404;
message = '记录不存在';
break;
default:
status = HttpStatus.BAD_REQUEST;
code = 400;
message = `数据库错误: ${exception.code}`;
}
} else if (exception instanceof Error) {
message = exception.message;
}
// 5xx 记录完整堆栈,4xx 仅记录摘要
if (status >= 500) {
this.logger.error(
`${request.method} ${request.url} -> ${status}`,
exception instanceof Error ? exception.stack : undefined,
);
} else {
this.logger.warn(`${request.method} ${request.url} -> ${status} ${message}`);
}
const body = ResponseDto.error(code, message);
response.status(status).json(body);
}
}
+21
View File
@@ -0,0 +1,21 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
// 默认 JWT 鉴权守卫;@Public() 装饰的路由跳过鉴权
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
}
@@ -0,0 +1,27 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { ResponseDto } from '../dto/response.dto';
// 成功响应统一包裹为 { code:0, message:'ok', data }
// 跳过已经是 ResponseDto / 原生流 / Swagger 文档等响应
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, ResponseDto<T> | T> {
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<ResponseDto<T> | T> {
return next.handle().pipe(
map((data) => {
if (data instanceof ResponseDto) return data;
// 不包裹二进制 / null / 已是结构化分页对象等场景,简单起见统一包裹
return ResponseDto.success(data);
}),
);
}
}
+38
View File
@@ -0,0 +1,38 @@
// 环境变量读取:所有配置集中此处,按命名空间分组导出
export default () => ({
nodeEnv: process.env.NODE_ENV ?? 'development',
isProd: process.env.NODE_ENV === 'production',
app: {
port: parseInt(process.env.APP_PORT ?? '3000', 10),
swaggerPath: process.env.SWAGGER_PATH ?? 'docs',
},
database: {
url: process.env.DATABASE_URL ?? '',
},
redis: {
url: process.env.REDIS_URL ?? 'redis://localhost:6379',
},
jwt: {
secret: process.env.JWT_SECRET ?? '',
// JWT_EXPIRES 单位为秒
expires: parseInt(process.env.JWT_EXPIRES ?? '7200', 10),
},
wx: {
appid: process.env.WX_APPID ?? '',
secret: process.env.WX_SECRET ?? '',
// 仅本地联调用:设为 '1' 时跳过微信请求直接返回 mock openid(切勿生产开启)
mockLogin: process.env.WX_MOCK_LOGIN ?? '',
// 支付相关(预留)
mchId: process.env.WX_MCH_ID ?? '',
mchApiV3Key: process.env.WX_MCH_API_V3_KEY ?? '',
mchSerialNo: process.env.WX_MCH_SERIAL_NO ?? '',
mchPrivateKeyPath: process.env.WX_MCH_PRIVATE_KEY_PATH ?? '',
payNotifyUrl: process.env.WX_PAY_NOTIFY_URL ?? '',
},
cos: {
secretId: process.env.COS_SECRET_ID ?? '',
secretKey: process.env.COS_SECRET_KEY ?? '',
bucket: process.env.COS_BUCKET ?? '',
region: process.env.COS_REGION ?? 'ap-guangzhou',
},
});
+31
View File
@@ -0,0 +1,31 @@
import * as Joi from 'joi';
// 启动时对环境变量做强校验:缺失关键项即 fail-fast,避免运行期才发现配置错误
export const validationSchema = Joi.object({
NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'),
APP_PORT: Joi.number().default(3000),
SWAGGER_PATH: Joi.string().allow('').default('docs'),
DATABASE_URL: Joi.string().required(),
REDIS_URL: Joi.string().required(),
JWT_SECRET: Joi.string().min(16).required(),
JWT_EXPIRES: Joi.number().default(7200),
WX_APPID: Joi.string().required(),
WX_SECRET: Joi.string().required(),
// 本地联调 mock 开关,默认关闭;切勿在生产设为 1
WX_MOCK_LOGIN: Joi.string().valid('0', '1').allow('').default(''),
// 微信支付与 COS:骨架阶段可为空
WX_MCH_ID: Joi.string().allow('').default(''),
WX_MCH_API_V3_KEY: Joi.string().allow('').default(''),
WX_MCH_SERIAL_NO: Joi.string().allow('').default(''),
WX_MCH_PRIVATE_KEY_PATH: Joi.string().allow('').default(''),
WX_PAY_NOTIFY_URL: Joi.string().allow('').default(''),
COS_SECRET_ID: Joi.string().allow('').default(''),
COS_SECRET_KEY: Joi.string().allow('').default(''),
COS_BUCKET: Joi.string().allow('').default(''),
COS_REGION: Joi.string().default('ap-guangzhou'),
});
+30
View File
@@ -0,0 +1,30 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator';
import { DesignListService } from './design-list.service';
import { CreateDesignListDto } from './dto/create-design-list.dto';
@ApiTags('设计清单')
@ApiBearerAuth()
@Controller('design-lists')
export class DesignListController {
constructor(private readonly designListService: DesignListService) {}
@Get()
@ApiOperation({ summary: '我的设计清单列表' })
list(@CurrentUser() user: JwtPayload) {
return this.designListService.listByUser(user.sub);
}
@Get(':id')
@ApiOperation({ summary: '设计清单详情' })
findOne(@Param('id') id: string) {
return this.designListService.findOne(id);
}
@Post()
@ApiOperation({ summary: '创建设计清单' })
create(@CurrentUser() user: JwtPayload, @Body() dto: CreateDesignListDto) {
return this.designListService.create(user.sub, dto);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { DesignListController } from './design-list.controller';
import { DesignListService } from './design-list.service';
@Module({
controllers: [DesignListController],
providers: [DesignListService],
exports: [DesignListService],
})
export class DesignListModule {}
+32
View File
@@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateDesignListDto } from './dto/create-design-list.dto';
@Injectable()
export class DesignListService {
constructor(private readonly prisma: PrismaService) {}
async listByUser(userId: string) {
return this.prisma.designList.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
}
async findOne(id: string) {
// TODO: 权限校验(仅本人可查)
return this.prisma.designList.findUnique({ where: { id } });
}
async create(userId: string, dto: CreateDesignListDto) {
// TODO: 校验 items 结构、关联商品 SKU
return this.prisma.designList.create({
data: {
title: dto.title,
items: (dto.items ?? []) as Prisma.InputJsonValue,
userId,
},
});
}
}
@@ -0,0 +1,14 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsObject, IsOptional, IsString } from 'class-validator';
export class CreateDesignListDto {
@ApiProperty({ description: '清单标题' })
@IsString()
title!: string;
@ApiPropertyOptional({ description: '定制项(结构化 JSON', example: [{ sku: 'tshirt', color: 'black' }] })
@IsOptional()
@IsArray()
@IsObject({ each: true })
items?: Record<string, unknown>[];
}
+15
View File
@@ -0,0 +1,15 @@
import { Controller, Get } from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '../common/decorators/public.decorator';
@ApiTags('系统')
@Controller('health')
export class HealthController {
@Public()
@Get()
@ApiOperation({ summary: '健康检查' })
@ApiOkResponse({ schema: { example: { code: 0, message: 'ok', data: { status: 'ok' } } } })
health() {
return { status: 'ok', timestamp: new Date().toISOString() };
}
}
+7
View File
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}
+48
View File
@@ -0,0 +1,48 @@
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
const config = app.get(ConfigService);
app.setGlobalPrefix('api', { exclude: ['health'] });
app.enableCors();
// 全局管道:剥离未声明字段 + 自动类型转换 + 拒绝多余字段
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
// 统一响应包裹 + 统一异常输出
app.useGlobalInterceptors(new TransformInterceptor());
app.useGlobalFilters(new AllExceptionsFilter());
// Swagger 文档,生产环境可置空 SWAGGER_PATH 关闭
const swaggerPath = config.get<string>('app.swaggerPath');
if (swaggerPath) {
const docConfig = new DocumentBuilder()
.setTitle('wxmp-backend API')
.setDescription('微信小程序后端接口文档')
.setVersion('0.1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, docConfig);
SwaggerModule.setup(swaggerPath, app, document);
}
const port = config.get<number>('app.port') ?? 3000;
await app.listen(port);
// eslint-disable-next-line no-console
console.log(`应用已启动: http://localhost:${port} | Swagger: /${swaggerPath}`);
}
void bootstrap();
+40
View File
@@ -0,0 +1,40 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class OrderItemDto {
@ApiPropertyOptional({ description: '商品 id(定制类可空)' })
@IsOptional()
@IsString()
productId?: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
name!: string;
@ApiProperty({ description: '单价(元)' })
@Type(() => Number)
price!: number;
@ApiProperty({ description: '数量' })
@Type(() => Number)
quantity!: number;
}
export class CreateOrderDto {
@ApiProperty({ description: '收货地址快照(JSON' })
@IsObject()
addressSnapshot!: Record<string, unknown>;
@ApiProperty({ type: [OrderItemDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => OrderItemDto)
items!: OrderItemDto[];
@ApiPropertyOptional({ description: '关联设计清单 id(定制订单)' })
@IsOptional()
@IsString()
designListId?: string;
}
+30
View File
@@ -0,0 +1,30 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator';
import { OrdersService } from './orders.service';
import { CreateOrderDto } from './dto/create-order.dto';
@ApiTags('订单')
@ApiBearerAuth()
@Controller('orders')
export class OrdersController {
constructor(private readonly ordersService: OrdersService) {}
@Get()
@ApiOperation({ summary: '我的订单列表' })
list(@CurrentUser() user: JwtPayload) {
return this.ordersService.listByUser(user.sub);
}
@Get(':id')
@ApiOperation({ summary: '订单详情' })
findOne(@Param('id') id: string) {
return this.ordersService.findOne(id);
}
@Post()
@ApiOperation({ summary: '创建订单' })
create(@CurrentUser() user: JwtPayload, @Body() dto: CreateOrderDto) {
return this.ordersService.create(user.sub, dto);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
@Module({
controllers: [OrdersController],
providers: [OrdersService],
exports: [OrdersService],
})
export class OrdersModule {}
+58
View File
@@ -0,0 +1,58 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateOrderDto } from './dto/create-order.dto';
// 订单号生成:日期 + 随机后缀(无 Math.random 依赖要求仅限 workflow 脚本,普通运行时可用)
function generateOrderNo(): string {
const now = new Date();
const ymd =
now.getUTCFullYear().toString() +
String(now.getUTCMonth() + 1).padStart(2, '0') +
String(now.getUTCDate()).padStart(2, '0');
const rand = Math.random().toString(36).slice(2, 8).toUpperCase();
return `${ymd}${rand}`;
}
@Injectable()
export class OrdersService {
constructor(private readonly prisma: PrismaService) {}
async listByUser(userId: string) {
return this.prisma.order.findMany({
where: { userId },
include: { items: true, payment: true },
orderBy: { createdAt: 'desc' },
});
}
async findOne(id: string) {
// TODO: 权限校验(仅本人)
return this.prisma.order.findUnique({
where: { id },
include: { items: true, payment: true },
});
}
async create(userId: string, dto: CreateOrderDto) {
// TODO: 商品库存/价格校验、事务原子性、金额防篡改(服务端重算 totalAmount
const totalAmount = dto.items.reduce((sum, it) => sum + it.price * it.quantity, 0);
const data: Prisma.OrderCreateInput = {
orderNo: generateOrderNo(),
user: { connect: { id: userId } },
totalAmount,
addressSnapshot: dto.addressSnapshot as Prisma.InputJsonValue,
items: {
create: dto.items.map((it) => ({
productId: it.productId,
name: it.name,
price: it.price,
quantity: it.quantity,
})),
},
};
return this.prisma.order.create({ data, include: { items: true } });
}
}
+27
View File
@@ -0,0 +1,27 @@
import { Body, Controller, Param, Post, Req } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '../common/decorators/public.decorator';
import { PaymentsService } from './payments.service';
@ApiTags('支付')
@ApiBearerAuth()
@Controller('payments')
export class PaymentsController {
constructor(private readonly paymentsService: PaymentsService) {}
@Post(':orderId/pay')
@ApiOperation({ summary: '对指定订单发起支付(占位)' })
pay(@Param('orderId') orderId: string) {
return this.paymentsService.createPayment(orderId);
}
@Public()
@Post('notify')
@ApiOperation({ summary: '微信支付回调(占位,需原始 body)' })
notify(@Body() rawBody: unknown, @Req() req: { headers: Record<string, string> }) {
return this.paymentsService.handleNotify(
typeof rawBody === 'string' ? rawBody : JSON.stringify(rawBody),
req.headers,
);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PaymentsController } from './payments.controller';
import { PaymentsService } from './payments.service';
import { WechatModule } from '../wechat/wechat.module';
@Module({
imports: [WechatModule],
controllers: [PaymentsController],
providers: [PaymentsService],
})
export class PaymentsModule {}
+27
View File
@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { WechatService } from '../wechat/wechat.service';
@Injectable()
export class PaymentsService {
constructor(
private readonly prisma: PrismaService,
private readonly wechat: WechatService,
) {}
/** 发起支付:创建支付记录并调微信统一下单(本轮占位) */
async createPayment(orderId: string) {
// TODO: 查订单、校验状态/金额、创建 Payment 记录、调 wechat.createUnifiedOrder
void this.wechat; // 占位引用,避免未使用告警
return this.prisma.payment.create({
data: { order: { connect: { id: orderId } } },
});
}
/** 微信支付回调入口(本轮占位) */
async handleNotify(rawBody: string, headers: Record<string, string>) {
// TODO: 验签 -> 解密 -> 更新 Payment/Order 状态 -> 幂等
const result = await this.wechat.verifyPayNotify(rawBody, headers).catch(() => null);
return { code: 'FAIL', message: '回调处理尚未实现', result };
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+14
View File
@@ -0,0 +1,14 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
// 全局共享单例 PrismaClient;连接在模块生命周期内管理
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit(): Promise<void> {
await this.$connect();
}
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
}
}
+37
View File
@@ -0,0 +1,37 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsArray, IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator';
import { ProductStatus } from '@prisma/client';
export class CreateProductDto {
@ApiProperty({ description: '商品名称' })
@IsString()
name!: string;
@ApiPropertyOptional({ description: '分类 id' })
@IsOptional()
@IsString()
categoryId?: string;
@ApiProperty({ description: '价格(元)', example: 99.0 })
@Type(() => Number)
@IsNumber()
@Min(0)
price!: number;
@ApiPropertyOptional({ description: '图片 URL 列表', type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
images?: string[];
@ApiPropertyOptional({ description: '描述' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ enum: ProductStatus, default: ProductStatus.DRAFT })
@IsOptional()
@IsEnum(ProductStatus)
status?: ProductStatus;
}
+32
View File
@@ -0,0 +1,32 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '../common/decorators/public.decorator';
import { ProductsService } from './products.service';
import { CreateProductDto } from './dto/create-product.dto';
@ApiTags('商品')
@ApiBearerAuth()
@Controller('products')
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@Public()
@Get()
@ApiOperation({ summary: '在售商品列表' })
list() {
return this.productsService.list();
}
@Public()
@Get(':id')
@ApiOperation({ summary: '商品详情' })
findOne(@Param('id') id: string) {
return this.productsService.findOne(id);
}
@Post()
@ApiOperation({ summary: '创建商品(管理后台用)' })
create(@Body() dto: CreateProductDto) {
return this.productsService.create(dto);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ProductsController } from './products.controller';
import { ProductsService } from './products.service';
@Module({
controllers: [ProductsController],
providers: [ProductsService],
exports: [ProductsService],
})
export class ProductsModule {}
+27
View File
@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { ProductStatus } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateProductDto } from './dto/create-product.dto';
@Injectable()
export class ProductsService {
constructor(private readonly prisma: PrismaService) {}
async list() {
// TODO: 分页、按分类/状态筛选、价格区间
return this.prisma.product.findMany({
where: { status: ProductStatus.ON_SALE },
orderBy: { createdAt: 'desc' },
});
}
async findOne(id: string) {
// TODO: 404 处理、浏览量统计
return this.prisma.product.findUnique({ where: { id } });
}
async create(dto: CreateProductDto) {
// TODO: 校验 categoryId 存在、图片归属
return this.prisma.product.create({ data: dto });
}
}
@@ -0,0 +1,15 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
// 异步定制任务处理器(占位):实际执行设计渲染/生产派单等耗时任务
@Processor('customization')
export class CustomizationProcessor extends WorkerHost {
private readonly logger = new Logger(CustomizationProcessor.name);
async process(job: Job): Promise<unknown> {
this.logger.log(`处理定制任务 job=${job.id} data=${JSON.stringify(job.data)}`);
// TODO: 调用设计渲染/生产系统,更新 CustomizationTask 状态
return { ok: true };
}
}
+26
View File
@@ -0,0 +1,26 @@
import { BullModule } from '@nestjs/bullmq';
import { Global, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { CUSTOMIZATION_QUEUE } from './queue.service';
import { QueueService } from './queue.service';
import { CustomizationProcessor } from './processors/customization.processor';
@Global()
@Module({
imports: [
BullModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => {
const url = config.get<string>('redis.url');
return {
connection: { url, maxRetriesPerRequest: null },
};
},
}),
BullModule.registerQueue({ name: CUSTOMIZATION_QUEUE }),
],
providers: [QueueService, CustomizationProcessor],
exports: [QueueService],
})
export class QueueModule {}
+17
View File
@@ -0,0 +1,17 @@
import { InjectQueue } from '@nestjs/bullmq';
import { Injectable } from '@nestjs/common';
import { Queue } from 'bullmq';
export const CUSTOMIZATION_QUEUE = 'customization';
@Injectable()
export class QueueService {
constructor(
@InjectQueue(CUSTOMIZATION_QUEUE) private readonly customizationQueue: Queue,
) {}
/** 入队一个定制任务 */
async enqueueCustomization(payload: Record<string, unknown>) {
return this.customizationQueue.add('customization', payload);
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Global, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
// 暴露全局共享的 ioredis 实例:
// - 微信 session_key 缓存
// - BullMQ 底层队列存储(BullMQ 自带连接,此处主要供业务直接使用)
export const REDIS_CLIENT = 'REDIS_CLIENT';
@Global()
@Module({
imports: [ConfigModule],
providers: [
{
provide: REDIS_CLIENT,
inject: [ConfigService],
useFactory: (config: ConfigService) => {
const url = config.get<string>('redis.url') as string;
return new Redis(url, { maxRetriesPerRequest: null });
},
},
],
exports: [REDIS_CLIENT],
})
export class RedisModule {}
+18
View File
@@ -0,0 +1,18 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator';
import { UploadService } from './upload.service';
@ApiTags('文件上传')
@ApiBearerAuth()
@Controller('upload')
export class UploadController {
constructor(private readonly uploadService: UploadService) {}
@Get('credentials')
@ApiOperation({ summary: '获取 COS 直传临时凭证(占位)' })
credentials(@CurrentUser() user: JwtPayload, @Query('key') key: string) {
void user;
return this.uploadService.getUploadCredentials(key ?? `uploads/${Date.now()}`);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { UploadController } from './upload.controller';
import { UploadService } from './upload.service';
@Module({
controllers: [UploadController],
providers: [UploadService],
})
export class UploadModule {}
+21
View File
@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
// 腾讯云 COS 上传服务(本轮仅提供签名/直传凭证占位,完整实现留后续)
@Injectable()
export class UploadService {
constructor(private readonly config: ConfigService) {}
/** 生成小程序直传所需的临时密钥 / 预签名 URL(占位) */
async getUploadCredentials(key: string) {
// TODO: 通过 COS STS 或预签名 URL 生成临时凭证
//SDK 已移除,接入时见 docs/cos-sdk-removal.md
return {
key,
bucket: this.config.get<string>('cos.bucket'),
region: this.config.get<string>('cos.region'),
// 占位:实际应返回临时 SecretId/SecretKey/Token 或 presigned URL
credentials: null,
};
}
}
+18
View File
@@ -0,0 +1,18 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator';
import { UsersService } from './users.service';
@ApiTags('用户')
@ApiBearerAuth()
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get('me')
@ApiOperation({ summary: '获取当前登录用户信息' })
@ApiOkResponse({ description: '当前用户' })
async getMe(@CurrentUser() user: JwtPayload) {
return this.usersService.findById(user.sub);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
+25
View File
@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
/** 按 openid 查询用户,不存在则创建(首次登录) */
async findOrCreateByOpenid(openid: string, unionid?: string) {
return this.prisma.user.upsert({
where: { openid },
update: unionid ? { unionid } : {},
create: { openid, unionid },
});
}
async findById(id: string) {
return this.prisma.user.findUnique({ where: { id } });
}
/** 更新昵称/头像/手机号(由小程序授权后回传) */
async updateProfile(id: string, data: { nickname?: string; avatar?: string; phone?: string }) {
return this.prisma.user.update({ where: { id }, data });
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { WechatService } from './wechat.service';
@Global()
@Module({
providers: [WechatService],
exports: [WechatService],
})
export class WechatModule {}
+82
View File
@@ -0,0 +1,82 @@
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
// code2Session 返回结构(微信 sns/jscode2session
export interface Code2SessionResult {
openid: string;
sessionKey: string;
unionid?: string;
}
// 微信支付统一下单入参(预留,暂未实现)
export interface UnifiedOrderInput {
orderNo: string;
amount: number; // 单位:分
description: string;
openid: string;
notifyUrl?: string;
}
@Injectable()
export class WechatService {
private readonly logger = new Logger(WechatService.name);
constructor(private readonly config: ConfigService) {}
/**
* 用前端 wx.login() 的 code 换取 openid + session_key。
* 关键安全点:appid/secret 由服务端持有,前端只传 code,
* openid 一律以微信返回为准,绝不信任前端传入。
*
* 本地无真实 appid 时可设 WX_MOCK_LOGIN=1:直接以 code 作为 openid
* 返回,用于走通登录链路(仅供开发,切勿在生产开启)。
*/
async code2Session(code: string): Promise<Code2SessionResult> {
if (this.config.get<string>('wx.mockLogin') === '1') {
return { openid: `mock-${code}`, sessionKey: 'mock-session-key' };
}
const appid = this.config.get<string>('wx.appid');
const secret = this.config.get<string>('wx.secret');
const url = 'https://api.weixin.qq.com/sns/jscode2session';
const params = {
appid,
secret,
js_code: code,
grant_type: 'authorization_code',
};
try {
const { data } = await axios.get(url, { params, timeout: 8000 });
if (data.errcode) {
// 微信侧错误(如 code 无效 / appid 不匹配)
throw new ServiceUnavailableException(
`微信登录失败: ${data.errcode} ${data.errmsg}`,
);
}
return {
openid: data.openid,
sessionKey: data.session_key,
unionid: data.unionid,
};
} catch (error) {
if (error instanceof ServiceUnavailableException) throw error;
this.logger.error('调用微信 code2Session 失败', String(error));
throw new ServiceUnavailableException('微信服务暂不可用');
}
}
// ── 以下为微信支付 V3 预留接口,本轮仅占位 ──────────────
// TODO: 接入微信支付 V3(统一下单 / 回调验签 / 查单 / 退款)
/** 统一下单,返回小程序调起支付所需参数 */
async createUnifiedOrder(_input: UnifiedOrderInput): Promise<never> {
throw new Error('微信支付尚未实现(wechat.createUnifiedOrder 占位)');
}
/** 支付回调验签 + 解密,返回订单号与支付结果 */
async verifyPayNotify(_rawBody: string, _headers: Record<string, string>): Promise<never> {
throw new Error('微信支付回调验签尚未实现(wechat.verifyPayNotify 占位)');
}
}