import "reflect-metadata"; import { BadRequestException, Body, Controller, Get, Module, Param, Post, Req, ValidationPipe, } from "@nestjs/common"; import { NestFactory } from "@nestjs/core"; import { Prisma } from "@prisma/client"; import { FastifyAdapter } from "@nestjs/platform-fastify"; import type { NestFastifyApplication } from "@nestjs/platform-fastify"; import { PrismaService } from "./prisma.service.js"; import { WechatAuthService } from "./wechat-auth.js"; import { BookingService } from "./booking.service.js"; import { CreateBookingDto } from "./booking.dto.js"; import { CreateTagDto, UpdateBookingStatusDto, UpdateBookingTagsDto } from "./admin.dto.js"; 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"; @Controller("health") export class HealthController { @Get() check(): { status: string; service: string } { return { status: "ok", service: "personal-brand-api" }; } } @Controller("public") export class PublicController { constructor(private readonly prisma: PrismaService) {} @Get("brand") async brand(): Promise { return this.prisma.brandProfile.findFirst({ select: { id: true, name: true, tagline: true, description: true, avatarUrl: true, coverUrl: true, hours: true, address: true, deliveryNote: true, }, }); } @Get("services") async services(): Promise { return this.prisma.service.findMany({ where: { isEnabled: true }, orderBy: { sortOrder: "asc" }, select: { id: true, name: true, description: true, coverUrl: true, priceText: true, priceAmount: true, bookingEnabled: true, }, }); } } @Controller("customer/session") export class CustomerSessionController { constructor( private readonly wechatAuth: WechatAuthService, private readonly prisma: PrismaService, ) {} @Post("wechat") async wechatLogin(@Body() body: { code?: string }): Promise<{ token: string }> { const session = await this.wechatAuth.exchangeCode(body.code ?? ""); const brand = await this.prisma.brandProfile.findFirst({ select: { id: true } }); if (!brand) throw new BadRequestException("品牌尚未初始化"); await this.prisma.customerUser.upsert({ where: { brandId_openid: { brandId: brand.id, openid: session.openid } }, update: { lastLoginAt: new Date() }, create: { brandId: brand.id, openid: session.openid }, }); return { token: signIdentity({ subject: session.openid, brandId: brand.id, role: "customer" }), }; } } @Controller("admin/session") export class AdminSessionController { constructor(private readonly auth: AdminAuthService) {} @Post("login") login(@Body() body: AdminLoginDto): Promise<{ token: string; displayName: string }> { return this.auth.login(body.username, body.password); } } @Controller("customer/bookings") export class CustomerBookingController { constructor( private readonly prisma: PrismaService, private readonly bookings: BookingService, ) {} @Post() async create(@Req() request: FastifyRequest, @Body() body: CreateBookingDto): Promise { const identity = this.identity(request); const customer = await this.prisma.customerUser.findUnique({ where: { brandId_openid: identity }, }); if (!customer) throw new BadRequestException("请先完成微信登录"); return this.bookings.create(identity.brandId, customer.id, { ...body, extraData: body.extraData as Prisma.InputJsonObject, }); } @Get() async list(@Req() request: FastifyRequest): Promise { const identity = this.identity(request); const customer = await this.prisma.customerUser.findUnique({ where: { brandId_openid: identity }, }); if (!customer) throw new BadRequestException("请先完成微信登录"); return this.bookings.listForCustomer(identity.brandId, customer.id); } @Post(":id/cancel") async cancel(@Req() request: FastifyRequest, @Param("id") id: string): Promise { const identity = this.identity(request); const customer = await this.prisma.customerUser.findUnique({ where: { brandId_openid: identity }, }); if (!customer) throw new BadRequestException("请先完成微信登录"); return this.bookings.cancelForCustomer(identity.brandId, customer.id, id); } private identity(request: FastifyRequest): { brandId: string; openid: string } { const token = request.headers.authorization?.replace(/^Bearer\s+/i, ""); if (!token) throw new BadRequestException("缺少客户登录态"); try { const identity = readIdentity(token); if (identity.role !== "customer") throw new BadRequestException("客户登录态无效"); return { brandId: identity.brandId, openid: identity.subject }; } catch (error) { if (error instanceof BadRequestException) throw error; throw new BadRequestException("客户登录态无效"); } } } @Controller("admin/bookings") export class AdminBookingController { constructor(private readonly bookings: BookingService) {} @Get() list(@Req() request: FastifyRequest): Promise { this.requireAdmin(request); return this.bookings.listForAdmin( process.env.DEFAULT_BRAND_ID ?? "00000000-0000-0000-0000-000000000001", ); } @Post(":id/status") update( @Req() request: FastifyRequest, @Param("id") id: string, @Body() body: UpdateBookingStatusDto, ): Promise { this.requireAdmin(request); return this.bookings.updateStatus( process.env.DEFAULT_BRAND_ID ?? "00000000-0000-0000-0000-000000000001", id, body.status, body.adminNote, ); } @Post(":id/read") markRead(@Req() request: FastifyRequest, @Param("id") id: string): Promise { this.requireAdmin(request); return this.bookings.markRead( process.env.DEFAULT_BRAND_ID ?? "00000000-0000-0000-0000-000000000001", id, ); } @Post(":id/tags") updateTags( @Req() request: FastifyRequest, @Param("id") id: string, @Body() body: UpdateBookingTagsDto, ): Promise { this.requireAdmin(request); return this.bookings.updateTags( process.env.DEFAULT_BRAND_ID ?? "00000000-0000-0000-0000-000000000001", id, body.tagIds, ); } @Get("/tags") listTags(@Req() request: FastifyRequest): Promise { this.requireAdmin(request); return this.bookings.listTags( process.env.DEFAULT_BRAND_ID ?? "00000000-0000-0000-0000-000000000001", ); } @Post("/tags") createTag(@Req() request: FastifyRequest, @Body() body: CreateTagDto): Promise { this.requireAdmin(request); return this.bookings.createTag( process.env.DEFAULT_BRAND_ID ?? "00000000-0000-0000-0000-000000000001", body.name, ); } private requireAdmin(request: FastifyRequest): void { const token = request.headers.authorization?.replace(/^Bearer\s+/i, ""); if (!token) throw new BadRequestException("缺少管理员登录态"); const identity = readIdentity(token); if (identity.role !== "admin") throw new BadRequestException("无管理员权限"); } } @Module({ controllers: [ HealthController, PublicController, CustomerSessionController, AdminSessionController, CustomerBookingController, AdminBookingController, ], providers: [PrismaService, WechatAuthService, BookingService, AdminAuthService], exports: [PrismaService, WechatAuthService], }) export class AppModule {} export async function createApp(): Promise { const app = await NestFactory.create(AppModule, new FastifyAdapter()); app.useGlobalPipes( new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }), ); app.enableCors(); return app; } if (process.env.NODE_ENV !== "test") { const app = await createApp(); await app.listen({ port: Number(process.env.API_PORT ?? 3001), host: "0.0.0.0" }); }