diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index e529bd8..1935a5a 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,10 +1,41 @@ import "reflect-metadata"; -import { Body, Controller, Get, Injectable, Module, Post } from "@nestjs/common"; +import { + BadRequestException, + Body, + Controller, + Get, + Injectable, + Module, + Post, + Req, +} from "@nestjs/common"; import { NestFactory } from "@nestjs/core"; import { FastifyAdapter } from "@nestjs/platform-fastify"; import type { NestFastifyApplication } from "@nestjs/platform-fastify"; import { PrismaClient } from "@prisma/client"; import { WechatAuthService } from "./wechat-auth.js"; +import type { FastifyRequest } from "fastify"; +import { createHmac } from "node:crypto"; + +function signIdentity(identity: { brandId: string; openid: string }): string { + const payload = Buffer.from(JSON.stringify(identity)).toString("base64url"); + const signature = createHmac("sha256", process.env.SESSION_SECRET ?? "development-only-secret") + .update(payload) + .digest("base64url"); + return `${payload}.${signature}`; +} + +function readIdentity(token: string): { brandId: string; openid: string } { + const [payload, signature] = token.split("."); + const expected = createHmac("sha256", process.env.SESSION_SECRET ?? "development-only-secret") + .update(payload ?? "") + .digest("base64url"); + if (!payload || signature !== expected) throw new BadRequestException("客户登录态无效"); + return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { + brandId: string; + openid: string; + }; +} @Injectable() export class PrismaService extends PrismaClient { @@ -23,30 +54,108 @@ export class HealthController { @Controller("public") export class PublicController { + constructor(private readonly prisma: PrismaService) {} + @Get("brand") - brand(): { message: string } { - return { message: "public brand endpoint is ready" }; + 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") - services(): { message: string } { - return { message: "public services endpoint is ready" }; + 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) {} + constructor( + private readonly wechatAuth: WechatAuthService, + private readonly prisma: PrismaService, + ) {} @Post("wechat") - async wechatLogin(@Body() body: { code?: string }): Promise<{ openid: string }> { + async wechatLogin(@Body() body: { code?: string }): Promise<{ token: string }> { const session = await this.wechatAuth.exchangeCode(body.code ?? ""); - return { openid: session.openid }; + 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({ brandId: brand.id, openid: session.openid }) }; + } +} + +@Controller("customer/bookings") +export class CustomerBookingController { + constructor(private readonly prisma: PrismaService) {} + + @Get() + async list(@Req() request: FastifyRequest): Promise { + const identity = this.identity(request); + return this.prisma.booking.findMany({ + where: { customerUser: { brandId: identity.brandId, openid: identity.openid } }, + orderBy: { createdAt: "desc" }, + select: { + bookingNo: true, + requestType: true, + customerName: true, + status: true, + estimatedAmount: true, + requestedDate: true, + requestedTime: true, + fulfillmentType: true, + createdAt: true, + items: { select: { serviceNameSnapshot: true, quantity: true, subtotalSnapshot: true } }, + }, + }); + } + + private identity(request: FastifyRequest): { brandId: string; openid: string } { + const token = request.headers.authorization?.replace(/^Bearer\s+/i, ""); + if (!token) throw new BadRequestException("缺少客户登录态"); + try { + return readIdentity(token); + } catch (error) { + if (error instanceof BadRequestException) throw error; + throw new BadRequestException("客户登录态无效"); + } } } @Module({ - controllers: [HealthController, PublicController, CustomerSessionController], + controllers: [ + HealthController, + PublicController, + CustomerSessionController, + CustomerBookingController, + ], providers: [PrismaService, WechatAuthService], exports: [PrismaService, WechatAuthService], })