feat: add admin authentication

This commit is contained in:
2026-09-18 16:50:35 +08:00
parent b89853bc94
commit bb5ccb1359
9 changed files with 85 additions and 1 deletions

View File

@@ -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=

View File

@@ -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",

View File

@@ -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

View File

@@ -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<void> {
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());

View File

@@ -0,0 +1,6 @@
import { IsString, MinLength } from "class-validator";
export class AdminLoginDto {
@IsString() username!: string;
@IsString() @MinLength(8) password!: string;
}

View File

@@ -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,
};
}
}

View File

@@ -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 {}

4
apps/api/src/types/bcryptjs.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module "bcryptjs" {
export function compare(password: string, hash: string): Promise<boolean>;
export function hash(password: string, saltRounds: number): Promise<string>;
}

8
pnpm-lock.yaml generated
View File

@@ -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