diff --git a/apps/api/src/booking.service.ts b/apps/api/src/booking.service.ts index 2fbbc8e..4a15c72 100644 --- a/apps/api/src/booking.service.ts +++ b/apps/api/src/booking.service.ts @@ -132,6 +132,15 @@ export class BookingService { ) { const booking = await this.prisma.booking.findFirst({ where: { id, brandId } }); if (!booking) throw new NotFoundException("订单不存在"); + const allowed: Record = { + received: ["contacting", "cancelled"], + contacting: ["confirmed", "cancelled"], + confirmed: ["completed", "cancelled"], + completed: [], + cancelled: [], + }; + if (status !== booking.status && !allowed[booking.status]?.includes(status)) + throw new BadRequestException("不允许的订单状态流转"); return this.prisma.booking.update({ where: { id }, data: { status, ...(adminNote !== undefined ? { adminNote } : {}), isRead: true }, diff --git a/apps/api/src/http.ts b/apps/api/src/http.ts new file mode 100644 index 0000000..4378c99 --- /dev/null +++ b/apps/api/src/http.ts @@ -0,0 +1,32 @@ +import { Catch, HttpException, Injectable } from "@nestjs/common"; +import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common"; +import type { FastifyReply, FastifyRequest } from "fastify"; +import { randomUUID } from "node:crypto"; + +@Injectable() +export class RequestIdMiddleware { + use(request: FastifyRequest, reply: FastifyReply, next: () => void): void { + const requestId = request.headers["x-request-id"]?.toString() ?? randomUUID(); + reply.header("x-request-id", requestId); + next(); + } +} + +@Catch() +export class ApiExceptionFilter implements ExceptionFilter { + catch(exception: unknown, host: ArgumentsHost): void { + const context = host.switchToHttp(); + const response = context.getResponse(); + const request = context.getRequest(); + const status = exception instanceof HttpException ? exception.getStatus() : 500; + const detail = exception instanceof HttpException ? exception.getResponse() : "服务器内部错误"; + response.status(status).send({ + data: null, + error: { + code: `HTTP_${status}`, + message: typeof detail === "string" ? detail : "请求失败", + }, + requestId: request.headers["x-request-id"] ?? null, + }); + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 45ef8d0..63ee550 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -23,6 +23,7 @@ import { AdminAuthService } from "./admin-auth.service.js"; import { AdminLoginDto } from "./admin-auth.dto.js"; import type { FastifyRequest } from "fastify"; import { readIdentity, signIdentity } from "./auth.js"; +import { ApiExceptionFilter, RequestIdMiddleware } from "./http.js"; @Controller("health") export class HealthController { @@ -250,6 +251,8 @@ export async function createApp(): Promise { app.useGlobalPipes( new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }), ); + app.useGlobalFilters(new ApiExceptionFilter()); + app.use(new RequestIdMiddleware().use.bind(new RequestIdMiddleware())); app.enableCors(); return app; } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..71babc7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: personal_brand + ports: + - "5432:5432" + volumes: + - personal-brand-postgres:/var/lib/postgresql/data + +volumes: + personal-brand-postgres: diff --git a/package.json b/package.json index e178b54..246285c 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "format": "prettier --write .", "format:check": "prettier --check .", "check": "pnpm format:check && pnpm lint && pnpm typecheck", + "db:migrate": "pnpm --filter @personal-brand/api db:migrate", + "db:seed": "pnpm --filter @personal-brand/api db:seed", "clean": "turbo run clean && rimraf node_modules .turbo" }, "devDependencies": {