Compare commits
11 Commits
55b2b091c2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7dfa603ba8 | |||
| f2687c502a | |||
| 0e8b8e1591 | |||
| 0d4caaba00 | |||
| bb5ccb1359 | |||
| b89853bc94 | |||
| ae5f26baab | |||
| 1e03d055aa | |||
| bd45625d36 | |||
| de8f989083 | |||
| 5e71b89e95 |
@@ -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=
|
||||
|
||||
89
PROJECT_STATUS.md
Normal file
89
PROJECT_STATUS.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Project Status
|
||||
|
||||
最后更新:2026-09-18
|
||||
|
||||
## 当前目标
|
||||
|
||||
先为餐饮摊主朋友交付一个真正能带来订单的个人品牌小程序,再根据真实使用把通用能力产品化。产品方的收益、运营和平台化目标不能牺牲朋友的实际使用效果。
|
||||
|
||||
## 当前阶段
|
||||
|
||||
后端基础与工程环境已建立,正在把 API 做到可以交给小程序和管理后台接入的程度。小程序和后台页面尚未接入真实 API。
|
||||
|
||||
## 已完成
|
||||
|
||||
- 产品 PRD、原型规格、架构决策、数据/API 设计和实施计划。
|
||||
- 餐饮试点画像与首轮需求访谈决策。
|
||||
- 可点击低保真原型:`prototype/index.html`。
|
||||
- Git 仓库、`main` 分支和远程仓库。
|
||||
- pnpm workspace + Turborepo Monorepo。
|
||||
- TypeScript 严格配置、ESLint、Prettier、EditorConfig、环境变量模板。
|
||||
- NestJS + Fastify API 骨架。
|
||||
- Prisma PostgreSQL schema:品牌、服务、客户身份、订单、订单明细、标签、管理员。
|
||||
- 微信官方 `jscode2session` 登录交换边界。
|
||||
- 客户登录 token、客户订单提交/查询/取消。
|
||||
- 普通套餐价格快照和私厨需求人工报价模型。
|
||||
- 管理员账号密码登录和角色 token。
|
||||
- 管理员订单列表、状态、未读、标签和内部备忘能力。
|
||||
- DTO 校验、全局异常响应、请求 ID和订单状态流转规则。
|
||||
- Docker PostgreSQL Compose、seed 和 Prisma 脚本说明。
|
||||
- 已执行多轮 Codex 代码审查并修复关键认证和权限问题。
|
||||
|
||||
## 最近提交
|
||||
|
||||
```text
|
||||
f2687c5 chore: prepare portable postgres development environment
|
||||
0e8b8e1 feat: add api http conventions and booking state rules
|
||||
0d4caab feat: add admin order tags and read state
|
||||
bb5ccb1 feat: add admin authentication
|
||||
b89853b fix: harden api authentication and validation
|
||||
```
|
||||
|
||||
远程:`https://git.app.que01.top/que01/persionalBrand.git`
|
||||
|
||||
## 下一步优先级
|
||||
|
||||
1. 在有 Docker/PostgreSQL 的机器执行 Prisma migration 和 seed,确认真实数据库可运行。
|
||||
2. 修正并补齐 API 集成测试:客户隔离、订单提交、取消、状态机、管理员权限、标签归属。
|
||||
3. 补管理员订单详情、筛选和未读数量接口。
|
||||
4. 补 OpenAPI/Swagger 契约和统一响应示例。
|
||||
5. 创建类型化 `api-client`,再接入管理后台和小程序。
|
||||
6. 先接管理后台订单处理,再接小程序品牌展示和下单。
|
||||
|
||||
## 未决问题
|
||||
|
||||
- 普通套餐采用固定菜单、每日菜单,还是固定套餐加每日可用状态。
|
||||
- 朋友最终品牌名称、图片、联系方式、营业时间和配送范围。
|
||||
- 微信小程序主体、AppID、服务类目和订阅消息模板是否可用。
|
||||
- 生产部署使用哪家 PostgreSQL、对象存储和 API 托管服务。
|
||||
- 当前 token 是开发阶段的签名 Bearer token;生产前应替换为带撤销能力的正式 session/Cookie 方案。
|
||||
|
||||
## 当前明确不做
|
||||
|
||||
- 支付、退款、发票、财务对账。
|
||||
- 复杂库存、自动配送判断和每日菜单编排。
|
||||
- 多商户注册、套餐计费、会员和营销系统。
|
||||
- 自动发布微信小程序。
|
||||
|
||||
## 跨电脑恢复
|
||||
|
||||
```bash
|
||||
git clone https://git.app.que01.top/que01/persionalBrand.git
|
||||
cd persionalBrand
|
||||
cat PROJECT_STATUS.md
|
||||
cat agent.md
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
有 Docker 的机器再执行:
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres
|
||||
pnpm install
|
||||
pnpm db:migrate
|
||||
pnpm db:seed
|
||||
```
|
||||
|
||||
## 更新规则
|
||||
|
||||
每完成一个可验证的开发任务,更新“当前阶段”“已完成”“最近提交”和“下一步优先级”。只记录已经发生或已经确认的事实;未确认内容放入“未决问题”。
|
||||
38
README.md
38
README.md
@@ -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`。
|
||||
|
||||
1
agent.md
1
agent.md
@@ -16,6 +16,7 @@
|
||||
|
||||
开始开发或修改需求前,按以下顺序阅读:
|
||||
|
||||
0. `PROJECT_STATUS.md`:当前阶段、最近提交、下一步和未决问题;跨电脑恢复时优先阅读。
|
||||
1. `docs/PRD.md`:产品范围、功能需求和验收标准。
|
||||
2. `docs/01-prototype-spec.md`:页面、字段、状态和交互要求。
|
||||
3. `docs/02-architecture-and-decisions.md`:技术选型和架构决策。
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
159
apps/api/prisma/schema.prisma
Normal file
159
apps/api/prisma/schema.prisma
Normal 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
69
apps/api/prisma/seed.ts
Normal 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());
|
||||
6
apps/api/src/admin-auth.dto.ts
Normal file
6
apps/api/src/admin-auth.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsString, MinLength } from "class-validator";
|
||||
|
||||
export class AdminLoginDto {
|
||||
@IsString() username!: string;
|
||||
@IsString() @MinLength(8) password!: string;
|
||||
}
|
||||
30
apps/api/src/admin-auth.service.ts
Normal file
30
apps/api/src/admin-auth.service.ts
Normal 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
18
apps/api/src/admin.dto.ts
Normal 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
34
apps/api/src/auth.ts
Normal 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;
|
||||
}
|
||||
34
apps/api/src/booking.dto.ts
Normal file
34
apps/api/src/booking.dto.ts
Normal 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[] =
|
||||
[];
|
||||
}
|
||||
188
apps/api/src/booking.service.ts
Normal file
188
apps/api/src/booking.service.ts
Normal 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
32
apps/api/src/http.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
9
apps/api/src/prisma.service.ts
Normal file
9
apps/api/src/prisma.service.ts
Normal 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
4
apps/api/src/types/bcryptjs.d.ts
vendored
Normal 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>;
|
||||
}
|
||||
44
apps/api/src/wechat-auth.ts
Normal file
44
apps/api/src/wechat-auth.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
22
docker-compose.yml
Normal 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:
|
||||
@@ -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. 前端直接上传对象存储。
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
1310
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user