From bb5ccb13590f700194178bd1e86c5a98d6f8edfa Mon Sep 17 00:00:00 2001 From: que01 Date: Fri, 18 Sep 2026 16:50:35 +0800 Subject: [PATCH] feat: add admin authentication --- .env.example | 1 + apps/api/package.json | 1 + apps/api/prisma/schema.prisma | 12 ++++++++++++ apps/api/prisma/seed.ts | 10 ++++++++++ apps/api/src/admin-auth.dto.ts | 6 ++++++ apps/api/src/admin-auth.service.ts | 30 ++++++++++++++++++++++++++++++ apps/api/src/main.ts | 14 +++++++++++++- apps/api/src/types/bcryptjs.d.ts | 4 ++++ pnpm-lock.yaml | 8 ++++++++ 9 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/admin-auth.dto.ts create mode 100644 apps/api/src/admin-auth.service.ts create mode 100644 apps/api/src/types/bcryptjs.d.ts diff --git a/.env.example b/.env.example index 2aa78dc..0d53e87 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ API_PORT=3001 ADMIN_PORT=3000 SESSION_SECRET=replace-with-a-long-random-value DEFAULT_BRAND_ID=00000000-0000-0000-0000-000000000001 +SEED_ADMIN_PASSWORD=change-me-now-123 OBJECT_STORAGE_ENDPOINT= OBJECT_STORAGE_BUCKET= OBJECT_STORAGE_ACCESS_KEY= diff --git a/apps/api/package.json b/apps/api/package.json index 3b63541..de1e76c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -18,6 +18,7 @@ "@nestjs/core": "^11.0.11", "@nestjs/platform-fastify": "^11.0.11", "@prisma/client": "^6.2.1", + "bcryptjs": "^2.4.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "fastify": "^5.2.1", diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 73fcccf..aeac718 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -138,6 +138,18 @@ model Tag { @@unique([brandId, name]) } +model AdminUser { + id String @id @default(uuid()) + username String @unique + passwordHash String + displayName String + role String @default("admin") + isEnabled Boolean @default(true) + lastLoginAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + model BookingTag { bookingId String tagId String diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 142c71c..b1dbebf 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -1,4 +1,5 @@ import { PrismaClient } from "@prisma/client"; +import { hash } from "bcryptjs"; const prisma = new PrismaClient(); @@ -54,6 +55,15 @@ async function main(): Promise { create: { brandId: brand.id, name, isPreset: true }, }); } + await prisma.adminUser.upsert({ + where: { username: "admin" }, + update: {}, + create: { + username: "admin", + passwordHash: await hash(process.env.SEED_ADMIN_PASSWORD ?? "change-me-now-123", 12), + displayName: "品牌管理员", + }, + }); } main().finally(() => prisma.$disconnect()); diff --git a/apps/api/src/admin-auth.dto.ts b/apps/api/src/admin-auth.dto.ts new file mode 100644 index 0000000..aba2187 --- /dev/null +++ b/apps/api/src/admin-auth.dto.ts @@ -0,0 +1,6 @@ +import { IsString, MinLength } from "class-validator"; + +export class AdminLoginDto { + @IsString() username!: string; + @IsString() @MinLength(8) password!: string; +} diff --git a/apps/api/src/admin-auth.service.ts b/apps/api/src/admin-auth.service.ts new file mode 100644 index 0000000..45a4eed --- /dev/null +++ b/apps/api/src/admin-auth.service.ts @@ -0,0 +1,30 @@ +import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { compare } from "bcryptjs"; +import { PrismaService } from "./prisma.service.js"; +import { signIdentity } from "./auth.js"; + +@Injectable() +export class AdminAuthService { + constructor(private readonly prisma: PrismaService) {} + + async login(username: string, password: string): Promise<{ token: string; displayName: string }> { + const user = await this.prisma.adminUser.findUnique({ where: { username } }); + // bcryptjs ships incomplete typings in this dependency version. + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + if (!user || !user.isEnabled || !(await compare(password, user.passwordHash))) { + throw new UnauthorizedException("账号或密码错误"); + } + await this.prisma.adminUser.update({ + where: { id: user.id }, + data: { lastLoginAt: new Date() }, + }); + return { + token: signIdentity({ + subject: user.id, + brandId: process.env.DEFAULT_BRAND_ID ?? "", + role: "admin", + }), + displayName: user.displayName, + }; + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index b942dec..7892a9a 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -19,6 +19,8 @@ import { WechatAuthService } from "./wechat-auth.js"; import { BookingService } from "./booking.service.js"; import { CreateBookingDto } from "./booking.dto.js"; import { UpdateBookingStatusDto } 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"; @@ -92,6 +94,15 @@ export class CustomerSessionController { } } +@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( @@ -185,10 +196,11 @@ export class AdminBookingController { HealthController, PublicController, CustomerSessionController, + AdminSessionController, CustomerBookingController, AdminBookingController, ], - providers: [PrismaService, WechatAuthService, BookingService], + providers: [PrismaService, WechatAuthService, BookingService, AdminAuthService], exports: [PrismaService, WechatAuthService], }) export class AppModule {} diff --git a/apps/api/src/types/bcryptjs.d.ts b/apps/api/src/types/bcryptjs.d.ts new file mode 100644 index 0000000..de4c226 --- /dev/null +++ b/apps/api/src/types/bcryptjs.d.ts @@ -0,0 +1,4 @@ +declare module "bcryptjs" { + export function compare(password: string, hash: string): Promise; + export function hash(password: string, saltRounds: number): Promise; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88f0dcd..6df487c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,9 @@ importers: '@prisma/client': specifier: ^6.2.1 version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) + bcryptjs: + specifier: ^2.4.3 + version: 2.4.3 class-transformer: specifier: ^0.5.1 version: 0.5.1 @@ -572,6 +575,9 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + bcryptjs@2.4.3: + resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==} + brace-expansion@1.1.21: resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} @@ -1666,6 +1672,8 @@ snapshots: balanced-match@4.0.4: {} + bcryptjs@2.4.3: {} + brace-expansion@1.1.21: dependencies: balanced-match: 1.0.2