Compare commits

...

10 Commits

23 changed files with 2287 additions and 31 deletions

View File

@@ -1,8 +1,14 @@
NODE_ENV=development
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/personal_brand
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=personal_brand
POSTGRES_PORT=5432
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

@@ -28,3 +28,41 @@ corepack enable
pnpm install
pnpm check
```
## 本地数据库
在有 Docker 的机器上执行:
```bash
cp .env.example .env
docker compose up -d postgres
docker compose ps
pnpm db:migrate
pnpm db:seed
```
确认数据库健康后,再启动 API
```bash
pnpm --filter @personal-brand/api dev
```
停止数据库但保留数据:
```bash
docker compose stop postgres
```
停止并删除容器但保留数据卷:
```bash
docker compose down
```
删除容器和本地数据库数据(不可恢复):
```bash
docker compose down -v
```
`DATABASE_URL` 用于宿主机运行 API 时连接 `localhost`。如果以后把 API 也放进 Compose连接地址应改为服务名 `postgres`,不能继续使用 `localhost`

View File

@@ -4,9 +4,31 @@
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsc -p tsconfig.json --watch",
"dev": "tsx watch src/main.ts",
"lint": "eslint .",
"typecheck": "tsc -p tsconfig.json --noEmit",
"clean": "rimraf dist"
"clean": "rimraf dist",
"start": "node dist/main.js",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:migrate:deploy": "prisma migrate deploy",
"db:seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@nestjs/common": "^11.0.11",
"@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",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@types/node": "^22.10.5",
"prisma": "^6.2.1",
"tsx": "^4.19.2"
}
}

View File

@@ -0,0 +1,159 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum BookingStatus {
received
contacting
confirmed
completed
cancelled
}
enum BookingRequestType {
package
private_chef
}
enum ContactPreference {
wechat
phone
none
}
enum FulfillmentType {
pickup
delivery
}
model BrandProfile {
id String @id @default(uuid())
name String
tagline String?
description String?
avatarUrl String?
coverUrl String?
hours String?
address String?
deliveryNote String?
contactPhone String?
contactWechat String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
services Service[]
bookings Booking[]
customerUsers CustomerUser[]
tags Tag[]
}
model Service {
id String @id @default(uuid())
brandId String
brand BrandProfile @relation(fields: [brandId], references: [id])
name String
description String
coverUrl String?
priceText String?
priceAmount Int?
bookingEnabled Boolean @default(true)
isEnabled Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
bookings Booking[]
bookingItems BookingItem[]
@@index([brandId, isEnabled, sortOrder])
}
model CustomerUser {
id String @id @default(uuid())
brandId String
brand BrandProfile @relation(fields: [brandId], references: [id])
openid String
lastLoginAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
bookings Booking[]
@@unique([brandId, openid])
}
model Booking {
id String @id @default(uuid())
bookingNo String @unique
brandId String
brand BrandProfile @relation(fields: [brandId], references: [id])
customerUserId String
customerUser CustomerUser @relation(fields: [customerUserId], references: [id])
requestType BookingRequestType
customerName String
phone String?
wechatId String?
contactPreference ContactPreference
fulfillmentType FulfillmentType?
serviceId String?
service Service? @relation(fields: [serviceId], references: [id], onDelete: SetNull)
requestedDate DateTime?
requestedTime String?
deliveryAddress String?
estimatedAmount Int?
extraData Json?
note String?
adminNote String?
status BookingStatus @default(received)
isRead Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
items BookingItem[]
tags BookingTag[]
@@index([brandId, status, createdAt])
@@index([customerUserId, createdAt])
}
model BookingItem {
id String @id @default(uuid())
bookingId String
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
serviceId String?
service Service? @relation(fields: [serviceId], references: [id], onDelete: SetNull)
serviceNameSnapshot String
unitPriceSnapshot Int
quantity Int
subtotalSnapshot Int
}
model Tag {
id String @id @default(uuid())
brandId String
brand BrandProfile @relation(fields: [brandId], references: [id])
name String
isPreset Boolean @default(false)
isEnabled Boolean @default(true)
createdAt DateTime @default(now())
bookings BookingTag[]
@@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
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([bookingId, tagId])
}

69
apps/api/prisma/seed.ts Normal file
View File

@@ -0,0 +1,69 @@
import { PrismaClient } from "@prisma/client";
import { hash } from "bcryptjs";
const prisma = new PrismaClient();
async function main(): Promise<void> {
const brand = await prisma.brandProfile.upsert({
where: { id: "00000000-0000-0000-0000-000000000001" },
update: {},
create: {
id: "00000000-0000-0000-0000-000000000001",
name: "阿成家常菜",
tagline: "现做家常套餐,也为小型聚餐定制一桌热饭菜",
description: "每天认真做好一顿家常饭。",
hours: "周一至周六 10:30-20:00",
address: "大学城生活区东门附近",
deliveryNote: "目前支持部分学生宿舍楼配送,无法送达时会主动联系。",
},
});
await prisma.service.createMany({
data: [
{
brandId: brand.id,
name: "红烧肉双拼套餐",
description: "红烧肉、时蔬、米饭",
priceText: "¥28 / 份",
priceAmount: 2800,
sortOrder: 1,
},
{
brandId: brand.id,
name: "小炒鸡套餐",
description: "现炒鸡块、时蔬、米饭",
priceText: "¥25 / 份",
priceAmount: 2500,
sortOrder: 2,
},
{
brandId: brand.id,
name: "家庭聚餐私厨",
description: "根据人数、地点和口味人工沟通报价",
priceText: "面议",
bookingEnabled: true,
sortOrder: 3,
},
],
skipDuplicates: true,
});
for (const name of ["普通套餐", "私厨", "配送", "自取", "熟客", "重点跟进"]) {
await prisma.tag.upsert({
where: { brandId_name: { brandId: brand.id, name } },
update: {},
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,
};
}
}

18
apps/api/src/admin.dto.ts Normal file
View File

@@ -0,0 +1,18 @@
import { IsArray, IsIn, IsOptional, IsString } from "class-validator";
export class UpdateBookingStatusDto {
@IsIn(["received", "contacting", "confirmed", "completed", "cancelled"])
status!: "received" | "contacting" | "confirmed" | "completed" | "cancelled";
@IsOptional()
@IsString()
adminNote?: string;
}
export class CreateTagDto {
@IsString() name!: string;
}
export class UpdateBookingTagsDto {
@IsArray() @IsString({ each: true }) tagIds!: string[];
}

34
apps/api/src/auth.ts Normal file
View File

@@ -0,0 +1,34 @@
import { UnauthorizedException } from "@nestjs/common";
import { createHmac, timingSafeEqual } from "node:crypto";
type Identity = { subject: string; brandId: string; role: "customer" | "admin"; exp: number };
function secret(): string {
const value = process.env.SESSION_SECRET;
if (!value || value.length < 32) throw new UnauthorizedException("服务端会话密钥未正确配置");
return value;
}
export function signIdentity(
identity: Omit<Identity, "exp">,
ttlSeconds = 60 * 60 * 24 * 7,
): string {
const payload = Buffer.from(
JSON.stringify({ ...identity, exp: Math.floor(Date.now() / 1000) + ttlSeconds }),
).toString("base64url");
const signature = createHmac("sha256", secret()).update(payload).digest("base64url");
return `${payload}.${signature}`;
}
export function readIdentity(token: string): Identity {
const [payload, signature] = token.split(".");
if (!payload || !signature) throw new UnauthorizedException("登录态无效");
const expected = createHmac("sha256", secret()).update(payload).digest();
const actual = Buffer.from(signature, "base64url");
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
throw new UnauthorizedException("登录态无效");
const identity = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Identity;
if (!identity.exp || identity.exp < Math.floor(Date.now() / 1000))
throw new UnauthorizedException("登录态已过期");
return identity;
}

View File

@@ -0,0 +1,34 @@
import { Type } from "class-transformer";
import {
IsArray,
IsIn,
IsInt,
IsISO8601,
IsOptional,
IsString,
Max,
Min,
ValidateNested,
} from "class-validator";
export class BookingItemDto {
@IsString() serviceId!: string;
@IsInt() @Min(1) @Max(50) quantity!: number;
}
export class CreateBookingDto {
@IsIn(["package", "private_chef"]) requestType!: "package" | "private_chef";
@IsString() customerName!: string;
@IsOptional() @IsString() phone?: string;
@IsOptional() @IsString() wechatId?: string;
@IsIn(["wechat", "phone", "none"]) contactPreference!: "wechat" | "phone" | "none";
@IsOptional() @IsIn(["pickup", "delivery"]) fulfillmentType?: "pickup" | "delivery";
@IsOptional() @IsString() serviceId?: string;
@IsOptional() @IsISO8601() requestedDate?: string;
@IsOptional() @IsString() requestedTime?: string;
@IsOptional() @IsString() deliveryAddress?: string;
@IsOptional() @IsString() note?: string;
@IsOptional() extraData?: Record<string, unknown>;
@IsArray() @ValidateNested({ each: true }) @Type(() => BookingItemDto) items: BookingItemDto[] =
[];
}

View File

@@ -0,0 +1,188 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { PrismaService } from "./prisma.service.js";
export interface CreateBookingInput {
requestType: "package" | "private_chef";
customerName: string;
phone?: string;
wechatId?: string;
contactPreference: "wechat" | "phone" | "none";
fulfillmentType?: "pickup" | "delivery";
serviceId?: string;
requestedDate?: string;
requestedTime?: string;
deliveryAddress?: string;
note?: string;
extraData?: Prisma.InputJsonObject;
items?: Array<{ serviceId: string; quantity: number }>;
}
@Injectable()
export class BookingService {
constructor(private readonly prisma: PrismaService) {}
async create(brandId: string, customerUserId: string, input: CreateBookingInput) {
if (!input.customerName.trim() || (!input.phone?.trim() && !input.wechatId?.trim())) {
throw new BadRequestException("称呼和至少一种联系方式为必填项");
}
if (
input.requestType === "package" &&
(!input.items?.length || input.items.some((item) => item.quantity < 1))
) {
throw new BadRequestException("普通套餐至少选择一份有效套餐");
}
const extraData = input.extraData as { people?: unknown; location?: unknown } | undefined;
const allowedExtraKeys = new Set([
"people",
"location",
"budgetRange",
"tastePreferences",
"allergyNotes",
"dishPreferences",
]);
if (input.extraData && Object.keys(input.extraData).some((key) => !allowedExtraKeys.has(key)))
throw new BadRequestException("存在不支持的行业字段");
if (
input.requestType === "private_chef" &&
(!input.requestedDate || !extraData?.people || !extraData.location)
) {
throw new BadRequestException("私厨需求必须填写日期、人数和地点");
}
const services = input.items?.length
? await this.prisma.service.findMany({
where: {
id: { in: input.items.map((item) => item.serviceId) },
brandId,
isEnabled: true,
},
})
: [];
const serviceMap = new Map(services.map((service) => [service.id, service]));
const items = (input.items ?? []).map((item) => {
const service = serviceMap.get(item.serviceId);
if (!service || !service.bookingEnabled || service.priceAmount === null)
throw new BadRequestException("套餐不存在或暂不可预订");
return {
serviceId: service.id,
serviceNameSnapshot: service.name,
unitPriceSnapshot: service.priceAmount,
quantity: item.quantity,
subtotalSnapshot: service.priceAmount * item.quantity,
};
});
const estimatedAmount = items.reduce((sum, item) => sum + item.subtotalSnapshot, 0) || null;
const booking = await this.prisma.booking.create({
data: {
bookingNo: `AC${Date.now()}${Math.floor(Math.random() * 1000)
.toString()
.padStart(3, "0")}`,
brandId,
customerUserId,
requestType: input.requestType,
customerName: input.customerName.trim(),
phone: input.phone?.trim() ?? null,
wechatId: input.wechatId?.trim() ?? null,
contactPreference: input.contactPreference,
fulfillmentType: input.fulfillmentType ?? null,
serviceId: items[0]?.serviceId ?? input.serviceId ?? null,
requestedDate: input.requestedDate
? new Date(`${input.requestedDate}T00:00:00.000Z`)
: null,
requestedTime: input.requestedTime ?? null,
deliveryAddress: input.deliveryAddress ?? null,
estimatedAmount,
...(input.extraData ? { extraData: input.extraData } : {}),
note: input.note ?? null,
items: { create: items },
},
select: { bookingNo: true, status: true, estimatedAmount: true, createdAt: true },
});
return booking;
}
async listForCustomer(brandId: string, customerUserId: string) {
return this.prisma.booking.findMany({
where: { brandId, customerUserId },
orderBy: { createdAt: "desc" },
include: { items: true, service: { select: { name: true } } },
});
}
async cancelForCustomer(brandId: string, customerUserId: string, id: string) {
const booking = await this.prisma.booking.findFirst({ where: { id, brandId, customerUserId } });
if (!booking) throw new NotFoundException("订单不存在");
if (booking.status !== "received") throw new BadRequestException("当前状态不可取消");
return this.prisma.booking.update({ where: { id }, data: { status: "cancelled" } });
}
async listForAdmin(brandId: string) {
return this.prisma.booking.findMany({
where: { brandId },
orderBy: { createdAt: "desc" },
include: { items: true, tags: { include: { tag: true } } },
});
}
async updateStatus(
brandId: string,
id: string,
status: "received" | "contacting" | "confirmed" | "completed" | "cancelled",
adminNote?: string,
) {
const booking = await this.prisma.booking.findFirst({ where: { id, brandId } });
if (!booking) throw new NotFoundException("订单不存在");
const allowed: Record<string, string[]> = {
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 },
});
}
async markRead(brandId: string, id: string) {
const booking = await this.prisma.booking.findFirst({ where: { id, brandId } });
if (!booking) throw new NotFoundException("订单不存在");
return this.prisma.booking.update({ where: { id }, data: { isRead: true } });
}
async listTags(brandId: string) {
return this.prisma.tag.findMany({
where: { brandId, isEnabled: true },
orderBy: [{ isPreset: "desc" }, { name: "asc" }],
});
}
async createTag(brandId: string, name: string) {
const normalized = name.trim();
if (!normalized || normalized.length > 24) throw new BadRequestException("标签名称长度无效");
return this.prisma.tag.create({ data: { brandId, name: normalized } });
}
async updateTags(brandId: string, bookingId: string, tagIds: string[]) {
const booking = await this.prisma.booking.findFirst({ where: { id: bookingId, brandId } });
if (!booking) throw new NotFoundException("订单不存在");
const tags = await this.prisma.tag.findMany({
where: { id: { in: tagIds }, brandId, isEnabled: true },
select: { id: true },
});
if (tags.length !== new Set(tagIds).size) throw new BadRequestException("存在无效标签");
await this.prisma.$transaction([
this.prisma.bookingTag.deleteMany({ where: { bookingId } }),
this.prisma.bookingTag.createMany({
data: tags.map((tag) => ({ bookingId, tagId: tag.id })),
}),
]);
return this.prisma.booking.findUnique({
where: { id: bookingId },
include: { tags: { include: { tag: true } } },
});
}
}

32
apps/api/src/http.ts Normal file
View File

@@ -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<FastifyReply>();
const request = context.getRequest<FastifyRequest>();
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,
});
}
}

View File

@@ -1,3 +1,263 @@
export function healthMessage(): string {
return "personal-brand-api";
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";
import { ApiExceptionFilter, RequestIdMiddleware } from "./http.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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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<NestFastifyApplication> {
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter());
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;
}
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" });
}

View File

@@ -0,0 +1,9 @@
import { Injectable } from "@nestjs/common";
import { PrismaClient } from "@prisma/client";
@Injectable()
export class PrismaService extends PrismaClient {
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
}
}

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

View File

@@ -0,0 +1,44 @@
import { Injectable, ServiceUnavailableException, UnauthorizedException } from "@nestjs/common";
interface WechatSessionResponse {
openid?: string;
session_key?: string;
unionid?: string;
errcode?: number;
errmsg?: string;
}
export interface WechatSession {
openid: string;
sessionKey: string;
unionid?: string;
}
@Injectable()
export class WechatAuthService {
async exchangeCode(code: string): Promise<WechatSession> {
if (!code.trim()) throw new UnauthorizedException("微信登录 code 不能为空");
const appId = process.env.WECHAT_APP_ID;
const appSecret = process.env.WECHAT_APP_SECRET;
if (!appId || !appSecret) throw new ServiceUnavailableException("微信登录尚未配置");
const query = new URLSearchParams({
appid: appId,
secret: appSecret,
js_code: code,
grant_type: "authorization_code",
});
const response = await fetch(
`https://api.weixin.qq.com/sns/jscode2session?${query.toString()}`,
);
if (!response.ok) throw new ServiceUnavailableException("微信登录服务暂时不可用");
const result = (await response.json()) as WechatSessionResponse;
if (!result.openid || !result.session_key)
throw new UnauthorizedException(result.errmsg ?? "微信登录失败");
return {
openid: result.openid,
sessionKey: result.session_key,
...(result.unionid ? { unionid: result.unionid } : {}),
};
}
}

View File

@@ -1,5 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*.ts"]
}

22
docker-compose.yml Normal file
View File

@@ -0,0 +1,22 @@
services:
postgres:
image: postgres:16-alpine
container_name: personal-brand-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-personal_brand}
ports:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- personal-brand-postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 12
start_period: 5s
volumes:
personal-brand-postgres:

View File

@@ -158,6 +158,10 @@ POST /api/admin/uploads/presign
## 5. 文件上传流程
## 5.1 微信客户会话
小程序调用 `wx.login` 获取临时 `code`,提交到 `/api/customer/session/wechat`。服务端使用微信官方 `jscode2session` 接口换取 OpenID 和 session key并在服务端建立 `CustomerUser` 映射和登录态。当前代码将微信调用封装在 `WechatAuthService`,使用 Node 原生 `fetch`,不引入第三方微信 SDK后续订单接口必须从服务端登录态解析当前 OpenID不能由客户端提交或选择 `customer_user_id`。生产实现不得把 OpenID 或 session key 返回给小程序,应改为 HttpOnly 会话 Cookie 或短期签名令牌。
1. 后台请求上传凭证。
2. API 校验管理员权限和文件元数据。
3. 前端直接上传对象存储。

View File

@@ -2,7 +2,7 @@ import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["**/dist/**", "**/.next/**", "**/node_modules/**", "prototype/**"] },
{ ignores: ["**/dist/**", "**/.next/**", "**/node_modules/**", "prototype/**", "**/prisma/**"] },
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
{

View File

@@ -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": {

View File

@@ -2,3 +2,13 @@ export type BookingStatus = "received" | "contacting" | "confirmed" | "completed
export type BookingRequestType = "package" | "private_chef";
export type ContactPreference = "wechat" | "phone" | "none";
export type FulfillmentType = "pickup" | "delivery";
export type OrderStatus = BookingStatus;
export interface PublicService {
id: string;
name: string;
description: string;
priceText: string | null;
priceAmount: number | null;
bookingEnabled: boolean;
}

1310
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff