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,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 占位)');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user