# 阿里询盘后台 - 后端 MVP 实施计划 (Plan 1, v2) > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 搭建询盘后台的 Node.js + TypeScript + Express + Prisma + MySQL 后端,实现 `/api/ingest`(插件推送幂等入库) + 管理员 JWT 登录 + 询盘列表/详情/消息/单询盘导出/统计/列表导出 API,可用 supertest / curl 完整验证。 **Architecture:** 分层(routes / services / middleware / utils)。幂等靠唯一键 + 早返回(`messagesCount` 存**总量** + `has_failed_batches` 防失败批次被跳过);消息批量用 `createMany({ skipDuplicates: true })` 分批 500、失败批次隔离。PrismaClient **惰性初始化**(适配 testcontainers)。ESM(`"type":"module"`)。所有时间存 UTC。 **Tech Stack:** Node 18, TypeScript 5(ESM), Express 4, Prisma 5, MySQL 8, zod, jsonwebtoken, bcryptjs, express-rate-limit, vitest, supertest, testcontainers(mysql)。 **关联 spec:** `docs/superpowers/specs/2026-07-13-alibaba-inquiry-backend-design.md`(v3) **v2 修订说明:** 修正 plan review 发现的 4 致命(ESM type / messagesCount 用总量 / 增量回归测试 / 惰性 Prisma)+ 关键重要项(.env 不被覆盖、Task9 单一实现、vitest singleFork、单询盘导出 API、customerUpdate 不覆盖 null、统一测试脚手架、游标 zod 校验、显式 equals、docker exec -i)+ 高价值建议(SIGTERM、buildWhere 复用、e2e 补全、DoD 增量测试)。 **前提:** 工作目录 `d:\alibb-server\backend\`,在该目录 `git init`。Docker Desktop 已装。 --- ## 文件结构 ``` d:\alibb-server\backend\ prisma\schema.prisma # 4 表 + 索引(手写,不用 prisma init) prisma\migrations\ # prisma migrate 生成 src\ config\env.ts # loadEnv() 函数(延迟校验,适配测试覆写) lib\prisma.ts # getPrisma() 惰性单例 middleware\{jwtAuth,ingestAuth,rateLimit,error}.ts routes\{auth,ingest,inquiries,stats,export}.ts services\{auth,ingest,inquiry,stats}.service.ts utils\{batchInsert,time}.ts app.ts # createApp() 装配 server.ts # listen + bootstrapAdmin + SIGTERM tests\ setup.ts # setupApp(): 起 testcontainer + migrate + 返回 {app, prisma, resetDb} fixtures.ts # makePayload / seedInquiries {health,auth,ingest,batchInsert,inquiries,stats,export}.test.ts Dockerfile docker-compose.yml scripts\e2e.sh package.json(type:module) tsconfig.json .env.example .env vitest.config.ts(singleFork) ``` --- ## Task 1: 项目骨架(ESM + 依赖 + vitest singleFork) **Files:** `package.json`, `tsconfig.json`, `.env.example`, `.gitignore`, `vitest.config.ts` - [ ] **Step 1: 初始化** ```bash mkdir -p d:/alibb-server/backend && cd d:/alibb-server/backend git init && npm init -y ``` - [ ] **Step 2: 装依赖** ```bash npm install express @prisma/client prisma zod jsonwebtoken bcryptjs express-rate-limit dotenv cors morgan mysql2 npm install -D typescript tsx @types/express @types/node @types/jsonwebtoken @types/bcryptjs @types/cors @types/morgan vitest supertest @types/supertest testcontainers @testcontainers/mysql ``` - [ ] **Step 3: `package.json` 关键字段**(必须含 `"type":"module"`,否则 ESM build 跑不起来 —— 修正 F1) ```jsonc { "name": "inquiry-backend", "type": "module", "scripts": { "dev": "tsx watch src/server.ts", "build": "tsc", "start": "node dist/server.js", "test": "vitest run", "db:migrate": "prisma migrate dev", "db:deploy": "prisma migrate deploy", "db:generate": "prisma generate" } } ``` - [ ] **Step 4: `tsconfig.json`** ```jsonc { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "esModuleInterop": true, "outDir": "dist", "rootDir": "src", "resolveJsonModule": true, "skipLibCheck": true, "types": ["vitest/globals"] }, "include": ["src/**/*"] } ``` > NodeNext 要求 import 路径带 `.js` 后缀(写 `.js`,tsc 输出 `.js`)。测试文件由 vitest(tsx)跑,不受 tsc 管。 - [ ] **Step 5: `.gitignore`** ``` node_modules/ dist/ .env *.log ``` - [ ] **Step 6: `.env.example`**(并 `cp .env.example .env` —— 修正 I2,避免后续被覆盖) ``` DATABASE_URL="mysql://root:root@localhost:3306/inquiry" INGEST_TOKEN="dev-ingest-token-1234" JWT_SECRET="dev-jwt-secret-1234" JWT_EXPIRES_IN="7d" ADMIN_USERNAME="admin" ADMIN_PASSWORD="admin-password-1234" PORT=8080 NODE_ENV=development ``` ```bash cp .env.example .env ``` > 注:不再有 `JWT_REFRESH_SECRET`(MVP 单密钥,refresh 用 JWT_SECRET;修正 I7)。`TEST_DATABASE_URL` 由测试运行时注入,不入 env。 - [ ] **Step 7: `vitest.config.ts`**(`singleFork` 串行,避免并行起多个 mysql 容器 —— 修正 I4) ```ts import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { globals: true, testTimeout: 90_000, hookTimeout: 90_000, poolOptions: { forks: { singleFork: true } } } }); ``` - [ ] **Step 8: 临时 `src/server.ts`** = `console.log('placeholder');` - [ ] **Step 9: Commit** ```bash git add -A && git commit -m "chore: scaffold ESM backend project" ``` --- ## Task 2: Prisma Schema(手写,跳过 prisma init) **Files:** `prisma/schema.prisma`(手写);起本地 mysql 容器;首次迁移 - [ ] **Step 1: 起本地 MySQL 容器**(开发用) ```bash docker run -d --name inquiry-mysql -e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=inquiry -p 3306:3306 mysql:8 --max_allowed_packet=128M --character-set-server=utf8mb4 ``` - [ ] **Step 2: 手写 `prisma/schema.prisma`**(不用 `prisma init`,它会覆盖 .env —— 修正 I2)。完整对应 spec §6(含 v3 `has_failed_batches`、`Direction.system`、`MEDIUMTEXT content`、复合索引) ```prisma generator client { provider = "prisma-client-js" } datasource db { provider = "mysql"; url = env("DATABASE_URL") } enum Direction { incoming outgoing system } model Customer { id BigInt @id @default(autoincrement()) customerId String @unique @map("customer_id") @db.VarChar(64) name String? @db.VarChar(128) country String? @index @db.VarChar(64) email String? @db.VarChar(128) company String? @db.VarChar(255) registerTime DateTime? @map("register_time") avatarUrl String? @map("avatar_url") @db.VarChar(512) buyerTags String? @map("buyer_tags") @db.VarChar(255) buyerFeatures String? @map("buyer_features") @db.VarChar(255) bProductViews Int? @map("behavior_product_views") bValidInquiries Int? @map("behavior_valid_inquiries") bValidRfq Int? @map("behavior_valid_rfq") bLoginDays Int? @map("behavior_login_days") bSpamInquiries Int? @map("behavior_spam_inquiries") bTopIndustry String? @map("behavior_top_industry") @db.VarChar(255) inquiries Inquiry[] createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@map("customers") } model Inquiry { id BigInt @id @default(autoincrement()) inquiryId String @unique @map("inquiry_id") @db.VarChar(32) customerId String? @index @map("customer_id") @db.VarChar(64) manager String? @index @db.VarChar(128) createTime DateTime @map("create_time") updateTime DateTime @map("update_time") status String? @db.VarChar(64) msgPreview String? @map("msg_preview") @db.VarChar(512) isFlagged Boolean @default(false) @map("is_flagged") messagesCount Int @default(0) @map("messages_count") lastSendTime DateTime? @map("last_send_time") hasFailedBatches Boolean @default(false) @map("has_failed_batches") customer Customer? @relation(fields: [customerId], references: [customerId]) messages ChatMessage[] createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@map("inquiries") } model ChatMessage { id BigInt @id @default(autoincrement()) messageId String @unique @map("message_id") @db.VarChar(64) inquiryId String @index @map("inquiry_id") @db.VarChar(32) sender String? @db.VarChar(128) direction Direction sendTime DateTime @map("send_time") content String @db.MediumText translation String? @db.Text inquiry Inquiry @relation(fields: [inquiryId], references: [inquiryId]) createdAt DateTime @default(now()) @map("created_at") @@index([inquiryId, sendTime, messageId]) @@map("chat_messages") } model User { id BigInt @id @default(autoincrement()) username String @unique passwordHash String @map("password_hash") createdAt DateTime @default(now()) @map("created_at") @@map("users") } ``` - [ ] **Step 3: 迁移 + 生成 client** ```bash npx prisma migrate dev --name init npx prisma generate ``` - [ ] **Step 4: 验证表结构**(Git Bash 用 `-i` 不用 `-it` —— 修正 I9) ```bash docker exec -i inquiry-mysql mysql -uroot -proot -e "USE inquiry; SHOW TABLES; DESC chat_messages;" ``` Expected:4 表;`chat_messages.content` 为 `MEDIUMTEXT`;`message_id` UNIQUE;索引含 `inquiry_id_send_time_message_id_idx`。 - [ ] **Step 5: Commit** ```bash git add -A && git commit -m "feat(db): prisma schema (4 tables, has_failed_batches, system enum, composite index)" ``` --- ## Task 3: 惰性 env + 惰性 Prisma(修正 F4) **Files:** `src/config/env.ts`, `src/lib/prisma.ts` - [ ] **Step 1: `src/config/env.ts`**(函数化 `loadEnv`,延迟校验,让测试能先覆写 `process.env`) ```ts import { z } from 'zod'; const schema = z.object({ DATABASE_URL: z.string().regex(/^mysql:\/\//, 'must start with mysql://'), INGEST_TOKEN: z.string().min(8), JWT_SECRET: z.string().min(8), JWT_EXPIRES_IN: z.string().default('7d'), ADMIN_USERNAME: z.string(), ADMIN_PASSWORD: z.string().min(8), PORT: z.coerce.number().default(8080), NODE_ENV: z.enum(['development', 'production']).default('development'), }); let _env: z.infer | null = null; export function loadEnv() { if (_env) return _env; // 首次调用时加载 .env(server 用);测试会在调用前手动设 process.env if (!process.env.DATABASE_URL) { const dotenv = await import('dotenv'); await dotenv.config(); } ... } ``` > 注:`loadEnv` 不能用顶层 `import 'dotenv/config'`(会立即固化),改用动态 import + 顶层 await 函数。简化写法: ```ts import { z } from 'zod'; import { config as dotenvConfig } from 'dotenv'; dotenvConfig(); // 仅设置 process.env,不校验 const schema = z.object({ /* 同上,无 JWT_REFRESH_SECRET */ }); let _env: z.infer | null = null; export function loadEnv() { if (_env) return _env; const parsed = schema.safeParse(process.env); if (!parsed.success) { console.error('❌ invalid env:', parsed.error.flatten().fieldErrors); process.exit(1); } _env = parsed.data; return _env; } export const env = new Proxy({} as z.infer, { get: (_, k) => (loadEnv() as any)[k] }); ``` > `env` 用 Proxy 延迟:模块加载时不校验,首次访问才 `loadEnv()`。测试在 import app 前覆写 `process.env`,Proxy 自然取到测试值。 - [ ] **Step 2: `src/lib/prisma.ts`**(惰性单例,修正 F4) ```ts import { PrismaClient } from '@prisma/client'; let _prisma: PrismaClient | null = null; export function getPrisma(): PrismaClient { if (!_prisma) _prisma = new PrismaClient(); return _prisma; } export async function disconnectPrisma() { if (_prisma) { await _prisma.$disconnect(); _prisma = null; } } ``` > 所有 service 用 `getPrisma()`,**不在模块顶层实例化**。测试在 setupApp 里先覆写 `process.env.DATABASE_URL` 再首次调 `getPrisma()`。 - [ ] **Step 3: Commit** ```bash git add -A && git commit -m "feat(config): lazy env proxy and prisma singleton (testcontainer-friendly)" ``` --- ## Task 4: Express app + 健康检查 + 错误中间件 + SIGTERM **Files:** `src/middleware/error.ts`, `src/app.ts`, `src/server.ts` - [ ] **Step 1: `src/middleware/error.ts`** ```ts import type { ErrorRequestHandler } from 'express'; export const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => { console.error(err); res.status(err.status ?? 500).json({ error: err.message ?? 'internal error' }); }; ``` - [ ] **Step 2: `src/app.ts`** ```ts import express from 'express'; import cors from 'cors'; import morgan from 'morgan'; import { errorHandler } from './middleware/error.js'; export function createApp() { const app = express(); // MVP:CORS 全开(ingest 靠 token 鉴权;后台 Plan 3 联调时收窄到前端域名) app.use(cors({ origin: '*' })); app.use(express.json({ limit: '50mb' })); if (process.env.NODE_ENV !== 'test') app.use(morgan('dev')); app.get('/api/health', (_req, res) => res.json({ ok: true })); app.use(errorHandler); return app; } ``` - [ ] **Step 3: `tests/health.test.ts`**(纯单元,不起容器) ```ts import { describe, it, expect } from 'vitest'; import request from 'supertest'; import { createApp } from '../src/app.js'; describe('health', () => { it('ok', async () => { const res = await request(createApp()).get('/api/health'); expect(res.status).toBe(200); expect(res.body).toEqual({ ok: true }); }); }); ``` - [ ] **Step 4: 跑测试通过** ```bash npm test -- health # 应 1 passed ``` - [ ] **Step 5: `src/server.ts`**(含 SIGTERM 优雅关停 —— S9) ```ts import { createApp } from './app.js'; import { loadEnv } from './config/env.js'; import { bootstrapAdmin } from './services/auth.service.js'; import { disconnectPrisma } from './lib/prisma.js'; async function main() { loadEnv(); const { env } = await import('./config/env.js'); await bootstrapAdmin(); const app = createApp(); const server = app.listen(env.PORT, () => console.log(`listening on ${env.PORT}`)); process.on('SIGTERM', async () => { server.close(); await disconnectPrisma(); process.exit(0); }); } main(); ``` - [ ] **Step 6: Commit** `feat(app): express app, health, error handler, SIGTERM` --- ## Task 5: 管理员 bootstrap + JWT 登录 + 统一测试脚手架 **Files:** `src/services/auth.service.ts`, `src/routes/auth.ts`, `tests/setup.ts`, `tests/auth.test.ts`;Modify `src/app.ts` - [ ] **Step 1: `tests/setup.ts`**(统一脚手架 —— 修正 I8/F4。所有集成测试都用 `setupApp()`) ```ts import { MySqlContainer } from '@testcontainers/mysql'; import type { StartedTestContainer } from 'testcontainers'; import { execSync } from 'node:child_process'; import { getPrisma, disconnectPrisma } from '../src/lib/prisma.js'; import { createApp } from '../src/app.js'; // 注:MySqlContainer 默认 user=root password=test let container: StartedTestContainer | null = null; export async function setupApp() { if (!container) { container = await new MySqlContainer('mysql:8').withDatabase('t').start(); process.env.DATABASE_URL = `mysql://root:test@${container.getHost()}:${container.getMappedPort(3306)}/t`; process.env.INGEST_TOKEN = 'dev-ingest-token-1234'; process.env.JWT_SECRET = 'dev-jwt-secret-1234'; process.env.ADMIN_USERNAME = 'admin'; process.env.ADMIN_PASSWORD = 'admin-password-1234'; execSync('npx prisma migrate deploy', { stdio: 'inherit', env: process.env }); } const prisma = getPrisma(); const app = createApp(); return { app, prisma }; } export async function resetDb() { const prisma = getPrisma(); await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS=0'); for (const t of ['chat_messages', 'inquiries', 'customers', 'users']) { await prisma.$executeRawUnsafe(`DELETE FROM ${t}`); } await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS=1'); } export async function teardown() { await disconnectPrisma(); await container?.stop(); container = null; } ``` > 关键:先起容器 → 覆写 `process.env.DATABASE_URL` → 才首次 `getPrisma()`(惰性,取到容器地址)。`vitest singleFork` 保证一个进程一个容器。 - [ ] **Step 2: `tests/auth.test.ts`** ```ts import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import request from 'supertest'; import { setupApp, resetDb, teardown } from './setup.js'; let app: any; beforeAll(async () => { ({ app } = await setupApp()); }, 90000); afterAll(async () => { await teardown(); }); describe('auth', () => { it('login with admin env creds returns token', async () => { const res = await request(app).post('/api/auth/login').send({ username: 'admin', password: 'admin-password-1234' }); expect(res.status).toBe(200); expect(res.body.token).toMatch(/^[A-Za-z0-9._-]+$/); }); it('wrong password 401', async () => { const res = await request(app).post('/api/auth/login').send({ username: 'admin', password: 'wrong' }); expect(res.status).toBe(401); }); }); ``` - [ ] **Step 3: `src/services/auth.service.ts`** ```ts import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import { getPrisma } from '../lib/prisma.js'; import { env } from '../config/env.js'; export async function bootstrapAdmin() { const prisma = getPrisma(); if (await prisma.user.count() === 0) { await prisma.user.create({ data: { username: env.ADMIN_USERNAME, passwordHash: await bcrypt.hash(env.ADMIN_PASSWORD, 10) } }); console.log(`seeded admin "${env.ADMIN_USERNAME}"`); } } export async function login(username: string, password: string) { const prisma = getPrisma(); const user = await prisma.user.findUnique({ where: { username } }); if (!user || !(await bcrypt.compare(password, user.passwordHash))) throw Object.assign(new Error('invalid credentials'), { status: 401 }); return { token: jwt.sign({ sub: user.id, u: user.username }, env.JWT_SECRET, { expiresIn: env.JWT_EXPIRES_IN }), expiresIn: env.JWT_EXPIRES_IN }; } ``` > 注:测试里 `bootstrapAdmin` 由 `/api/auth/login` 首次调用时触发?不——改为在 `createApp` 或 setupApp 末尾调一次。**实现时在 `setupApp()` 末尾加 `await bootstrapAdmin();`**(或在 server.ts 启动调;测试环境 setupApp 调)。 - [ ] **Step 4: `src/routes/auth.ts`** ```ts import { Router } from 'express'; import jwt from 'jsonwebtoken'; import { login } from '../services/auth.service.js'; import { env } from '../config/env.js'; import { jwtAuth } from '../middleware/jwtAuth.js'; export const authRouter = Router(); authRouter.post('/login', async (req, res, next) => { try { res.json(await login(req.body.username, req.body.password)); } catch (e) { next(e); } }); // refresh 用同一个 JWT_SECRET(MVP 单密钥) authRouter.post('/refresh', jwtAuth, (req, res) => { const u = (req as any).user; res.json({ token: jwt.sign({ sub: u.sub, u: u.u }, env.JWT_SECRET, { expiresIn: env.JWT_EXPIRES_IN }), expiresIn: env.JWT_EXPIRES_IN }); }); ``` - [ ] **Step 5: `src/middleware/jwtAuth.ts`** ```ts import type { RequestHandler } from 'express'; import jwt from 'jsonwebtoken'; import { env } from '../config/env.js'; export const jwtAuth: RequestHandler = (req, res, next) => { const h = req.headers.authorization || ''; const token = h.startsWith('Bearer ') ? h.slice(7) : ''; try { (req as any).user = jwt.verify(token, env.JWT_SECRET); next(); } catch { res.status(401).json({ error: 'unauthorized' }); } }; ``` - [ ] **Step 6: `src/app.ts` 挂载** ```ts import { authRouter } from './routes/auth.js'; // health 之后、errorHandler 之前: app.use('/api/auth', authRouter); ``` - [ ] **Step 7: 测试通过 + Commit** `feat(auth): admin bootstrap, login, refresh, jwt middleware` --- ## Task 6: /api/ingest — 鉴权 + 限流 + payload zod 校验 **Files:** `src/middleware/{ingestAuth,rateLimit}.ts`, `src/routes/ingest.ts`;Modify `src/app.ts` - [ ] **Step 1: `src/middleware/ingestAuth.ts`**(timingSafeEqual —— S1) ```ts import type { RequestHandler } from 'express'; import { timingSafeEqual } from 'node:crypto'; import { env } from '../config/env.js'; export const ingestAuth: RequestHandler = (req, res, next) => { const h = req.headers.authorization || ''; const token = h.startsWith('Bearer ') ? h.slice(7) : ''; const want = env.INGEST_TOKEN; const ok = token.length === want.length && timingSafeEqual(Buffer.from(token), Buffer.from(want)); if (!ok) return res.status(401).json({ error: 'invalid ingest token' }); next(); }; ``` - [ ] **Step 2: `src/middleware/rateLimit.ts`**(MVP IP 维度 —— S3) ```ts import rateLimit from 'express-rate-limit'; // 注:spec 写 60/min/token,MVP 用 IP 维度(单操作者够用);多操作者后切 key export const ingestLimiter = rateLimit({ windowMs: 60_000, max: 60, standardHeaders: true, legacyHeaders: false }); ``` - [ ] **Step 3: `tests/fixtures.ts`** ```ts export function makePayload(o: any = {}) { return { inquiry: { inquiryId: '13776617920', manager: 'cara Hu', createTime: '2026-03-13T10:11:50Z', updateTime: '2026-07-13T10:11:50Z', status: '洽谈中', msgPreview: 'hi', isFlagged: false, ...o.inquiry }, customer: { customerId: 'cid-1', name: 'Carlos', country: 'PE', email: null, company: 'World Shop', registerTime: '2025-06-23T00:00:00Z', avatarUrl: '', buyerTags: 'TM沟通', buyerFeatures: '', behavior: { productViews: null, validInquiries: null, validRfq: 39 }, ...o.customer }, messages: [ { messageId: 'm1', sendTime: '2026-07-13T02:11:50Z', sender: 'cara Hu', direction: 'outgoing', content: 'hello', translation: '' }, { messageId: 'm2', sendTime: '2026-07-13T02:12:00Z', sender: 'Carlos', direction: 'incoming', content: 'hi', translation: '' }, ], ...o, }; } ``` - [ ] **Step 4: `src/routes/ingest.ts`**(zod schema + 路由;`messages.min(1)` 防 S4 空数组边界) ```ts import { Router } from 'express'; import { z } from 'zod'; import { ingestAuth } from '../middleware/ingestAuth.js'; import { ingestLimiter } from '../middleware/rateLimit.js'; import { ingest } from '../services/ingest.service.js'; const MessageSchema = z.object({ messageId: z.string().min(1), sendTime: z.string().datetime(), sender: z.string().optional(), direction: z.enum(['incoming', 'outgoing', 'system']), content: z.string(), translation: z.string().optional().default(''), }); const BehaviorSchema = z.object({ productViews: z.number().nullable().optional(), validInquiries: z.number().nullable().optional(), validRfq: z.number().nullable().optional(), loginDays: z.number().nullable().optional(), spamInquiries: z.number().nullable().optional(), topIndustry: z.string().nullable().optional(), }).optional(); const PayloadSchema = z.object({ inquiry: z.object({ inquiryId: z.string().min(1), manager: z.string().optional(), createTime: z.string().datetime(), updateTime: z.string().datetime(), status: z.string().optional(), msgPreview: z.string().optional(), isFlagged: z.boolean().optional().default(false), }), customer: z.object({ customerId: z.string().min(1), name: z.string().optional(), country: z.string().nullable().optional(), email: z.string().nullable().optional(), company: z.string().nullable().optional(), registerTime: z.string().datetime().nullable().optional(), avatarUrl: z.string().optional(), buyerTags: z.string().optional(), buyerFeatures: z.string().optional(), behavior: BehaviorSchema, }).nullable(), messages: z.array(MessageSchema).min(1), }); export const ingestRouter = Router(); ingestRouter.post('/', ingestLimiter, ingestAuth, async (req, res, next) => { const parsed = PayloadSchema.safeParse(req.body); if (!parsed.success) return res.status(400).json({ error: 'invalid payload', issues: parsed.error.flatten() }); try { res.json(await ingest(parsed.data)); } catch (e) { next(e); } }); ``` - [ ] **Step 5: 占位 `src/services/ingest.service.ts`** = `export async function ingest(p:any){ return {ok:true,inserted:0,failedBatches:0,earlyReturn:false}; }` - [ ] **Step 6: `src/app.ts` 挂 `app.use('/api/ingest', ingestRouter)`** - [ ] **Step 7: 写 `tests/ingest.test.ts` 的鉴权/校验部分(用 setupApp 模板)** ```ts import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import request from 'supertest'; import { setupApp, resetDb, teardown } from './setup.js'; import { makePayload } from './fixtures.js'; let app: any; beforeAll(async () => { ({ app } = await setupApp()); }, 90000); afterAll(teardown); describe('ingest auth/validation', () => { it('401 without token', async () => { expect((await request(app).post('/api/ingest').send(makePayload())).status).toBe(401); }); it('400 bad payload', async () => { expect((await request(app).post('/api/ingest').set('Authorization', 'Bearer dev-ingest-token-1234').send({ inquiry: {} })).status).toBe(400); }); }); ``` - [ ] **Step 8: 测试通过 + Commit** `feat(ingest): auth, rate-limit, payload validation` --- ## Task 7: ingest 早返回 + customer/inquiry upsert(customerUpdate 不覆盖 null) **Files:** `src/services/ingest.service.ts`, `src/utils/time.ts` - [ ] **Step 1: `src/utils/time.ts`** ```ts export const maxSendTime = (msgs: { sendTime: string }[]) => msgs.length ? new Date(Math.max(...msgs.map(m => Date.parse(m.sendTime)))) : null; ``` - [ ] **Step 2: `src/services/ingest.service.ts`**(早返回 + 主表 upsert;customerUpdate 用 `Prisma.skip` 不覆盖 null —— 修正 I6) ```ts import { Prisma } from '@prisma/client'; import { getPrisma } from '../lib/prisma.js'; import { batchInsertMessages } from '../utils/batchInsert.js'; import { maxSendTime } from '../utils/time.js'; type Payload = { inquiry: any; customer: any | null; messages: { messageId: string; sendTime: string; sender?: string; direction: string; content: string; translation?: string }[]; }; export async function ingest(p: Payload) { const prisma = getPrisma(); const { inquiry: iq, customer: cu, messages } = p; const newCount = messages.length; const newLast = maxSendTime(messages); // 早返回:存在 + 无失败批次 + count + lastSendTime 均一致 const existing = await prisma.inquiry.findUnique({ where: { inquiryId: iq.inquiryId } }); if (existing && !existing.hasFailedBatches && existing.messagesCount === newCount && newLast && existing.lastSendTime && existing.lastSendTime.getTime() === newLast.getTime()) { return { ok: true, inserted: existing.messagesCount, failedBatches: 0, earlyReturn: true }; } // 主表事务 await prisma.$transaction(async (tx) => { if (cu) { const data = mapCustomer(cu); const update = skipNulls(data); // 不用 null 覆盖已落库值 —— 修正 I6 await tx.customer.upsert({ where: { customerId: cu.customerId }, create: data, update, }); } await tx.inquiry.upsert({ where: { inquiryId: iq.inquiryId }, create: { inquiryId: iq.inquiryId, customerId: cu?.customerId ?? null, manager: iq.manager, createTime: new Date(iq.createTime), updateTime: new Date(iq.updateTime), status: iq.status, msgPreview: iq.msgPreview, isFlagged: iq.isFlagged }, update: { customerId: cu?.customerId ?? null, manager: iq.manager, createTime: new Date(iq.createTime), updateTime: new Date(iq.updateTime), status: iq.status, msgPreview: iq.msgPreview, isFlagged: iq.isFlagged }, }); }); // 批量消息(Task 8) const { failedBatches } = await batchInsertMessages(iq.inquiryId, messages); // 用"总量"(批量后该询盘的消息总数),不是 createMany 的本次 delta —— 修正 F2 const total = await prisma.chatMessage.count({ where: { inquiryId: iq.inquiryId } }); await prisma.inquiry.update({ where: { inquiryId: iq.inquiryId }, data: { messagesCount: total, lastSendTime: newLast, hasFailedBatches: failedBatches > 0 }, }); return { ok: true, inserted: total, failedBatches, earlyReturn: false }; } function mapCustomer(c: any) { return { customerId: c.customerId, name: c.name, country: c.country, email: c.email, company: c.company, registerTime: c.registerTime ? new Date(c.registerTime) : null, avatarUrl: c.avatarUrl, buyerTags: c.buyerTags, buyerFeatures: c.buyerFeatures, bProductViews: c.behavior?.productViews ?? null, bValidInquiries: c.behavior?.validInquiries ?? null, bValidRfq: c.behavior?.validRfq ?? null, bLoginDays: c.behavior?.loginDays ?? null, bSpamInquiries: c.behavior?.spamInquiries ?? null, bTopIndustry: c.behavior?.topIndustry ?? null }; } // update 时跳过值为 null 的字段(避免覆盖已落库的有效值) function skipNulls(data: Record) { const out: Record = {}; for (const [k, v] of Object.entries(data)) if (v != null || k === 'customerId') out[k] = v; return out; } ``` - [ ] **Step 3: Commit** `feat(ingest): early-return + upsert (customerUpdate skips nulls)` --- ## Task 8: 批量消息写入(单一 createMany 实现 + 增量回归测试) **Files:** `src/utils/batchInsert.ts`;`tests/ingest.test.ts` 加增量测试 - [ ] **Step 1: `src/utils/batchInsert.ts`**(只留 createMany skipDuplicates,删掉 raw SQL 双实现 —— 修正 I1) ```ts import { getPrisma } from '../lib/prisma.js'; import type { Direction } from '@prisma/client'; const BATCH = 500; export async function batchInsertMessages(inquiryId: string, messages: any[]) { const prisma = getPrisma(); let failedBatches = 0; for (let i = 0; i < messages.length; i += BATCH) { const batch = messages.slice(i, i + BATCH); try { // createMany + skipDuplicates:MySQL 下生成 INSERT IGNORE,重复 messageId 跳过。 // MVP 接受"重推时已存在的消息不更新 content"(spec R6 背书);需要更新 content 再换 raw ON DUPLICATE KEY。 await prisma.chatMessage.createMany({ skipDuplicates: true, data: batch.map(m => ({ messageId: m.messageId, inquiryId, sender: m.sender ?? null, direction: m.direction as Direction, sendTime: new Date(m.sendTime), content: m.content, translation: m.translation ?? '', })), }); } catch (e) { console.error(`batch at offset ${i} failed:`, e); failedBatches++; } } return { failedBatches }; } ``` - [ ] **Step 2: `tests/ingest.test.ts` 加幂等 + 增量回归测试(修正 F3 —— 这个测试会逼出 F2 修复)** ```ts import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import request from 'supertest'; import { setupApp, resetDb, teardown } from './setup.js'; import { makePayload } from './fixtures.js'; let app: any; const TOKEN = 'Bearer dev-ingest-token-1234'; beforeAll(async () => { ({ app } = await setupApp()); }, 90000); afterAll(teardown); describe('ingest idempotency + increment', () => { it('push N → push N again early-returns', async () => { await resetDb(); const p = makePayload(); const r1 = await request(app).post('/api/ingest').set('Authorization', TOKEN).send(p); expect(r1.status).toBe(200); expect(r1.body.inserted).toBe(2); expect(r1.body.earlyReturn).toBe(false); const r2 = await request(app).post('/api/ingest').set('Authorization', TOKEN).send(p); expect(r2.body.earlyReturn).toBe(true); // 同样 payload → 早返回 }); it('push N → push N+1 → push N+1 again early-returns (regression for messagesCount=total)', async () => { await resetDb(); const base = makePayload(); // 推 2 条 await request(app).post('/api/ingest').set('Authorization', TOKEN).send(base); // 推 3 条(新增 m3) const withOneMore = makePayload({ messages: [ ...base.messages, { messageId: 'm3', sendTime: '2026-07-13T02:13:00Z', sender: 'x', direction: 'incoming', content: 'c3', translation: '' }, ]}); const r3 = await request(app).post('/api/ingest').set('Authorization', TOKEN).send(withOneMore); expect(r3.body.inserted).toBe(3); // 总量 3,不是 delta 1 expect(r3.body.earlyReturn).toBe(false); // 再推同样 3 条 → 早返回(若 messagesCount 退化成 delta,这里会失败) const r4 = await request(app).post('/api/ingest').set('Authorization', TOKEN).send(withOneMore); expect(r4.body.earlyReturn).toBe(true); expect(r4.body.inserted).toBe(3); }); }); ``` - [ ] **Step 3: 跑测试** ```bash npm test -- ingest ``` Expected:增量回归测试通过(证明 messagesCount 用 total)。 - [ ] **Step 4: Commit** `feat(ingest): batched createMany skipDuplicates + idempotency/increment regression tests` --- ## Task 9: 询盘列表(筛选/搜索/排序/分页)+ buildInquiryWhere 复用 **Files:** `src/services/inquiry.service.ts`, `src/routes/inquiries.ts`;Modify `src/app.ts` - [ ] **Step 1: `src/services/inquiry.service.ts`**(`buildInquiryWhere` 导出复用 —— S6;显式 equals —— I10) ```ts import { Prisma } from '@prisma/client'; import { getPrisma } from '../lib/prisma.js'; export function buildInquiryWhere(q: any): Prisma.InquiryWhereInput { const where: Prisma.InquiryWhereInput = {}; if (q.country) where.customer = { is: { country: { equals: q.country } } }; if (q.manager) where.manager = { equals: q.manager }; if (q.status) where.status = { equals: q.status }; if (q.dateFrom || q.dateTo) { where.createTime = {}; if (q.dateFrom) where.createTime.gte = new Date(q.dateFrom); if (q.dateTo) where.createTime.lte = new Date(q.dateTo); } if (q.q) where.OR = [ { inquiryId: { contains: q.q } }, { msgPreview: { contains: q.q } }, { customer: { is: { name: { contains: q.q } } } }, { customer: { is: { company: { contains: q.q } } } }, ]; return where; } export async function listInquiries(q: any) { const prisma = getPrisma(); const size = Math.min(Number(q.size) || 20, 100); const page = Number(q.page) || 1; const where = buildInquiryWhere(q); const orderBy = q.sort === 'update_time_desc' ? { updateTime: 'desc' as const } : q.sort === 'create_time_asc' ? { createTime: 'asc' as const } : { createTime: 'desc' as const }; const [items, total] = await Promise.all([ prisma.inquiry.findMany({ where, orderBy, skip: (page - 1) * size, take: size, include: { customer: true } }), prisma.inquiry.count({ where }), ]); return { items, total, page, size }; } ``` - [ ] **Step 2: `src/routes/inquiries.ts`**(骨架,本 Task 只挂 list) ```ts import { Router } from 'express'; import { jwtAuth } from '../middleware/jwtAuth.js'; import { listInquiries, getInquiryDetail, listMessages } from '../services/inquiry.service.js'; export const inquiriesRouter = Router(); inquiriesRouter.get('/', jwtAuth, async (req, res, next) => { try { res.json(await listInquiries(req.query)); } catch (e) { next(e); } }); ``` - [ ] **Step 3: `src/app.ts` 挂 `app.use('/api/inquiries', inquiriesRouter)`** - [ ] **Step 4: 测试 `tests/inquiries.test.ts`(list 筛选;seedInquiries fixture 自行构造或复用 makePayload 推几条)** ```ts it('lists filtered by country', async () => { await resetDb(); await pushInquiry(app, makePayload({ customer: { country: 'PE' } })); // helper: POST /api/ingest await pushInquiry(app, makePayload({ inquiry: { inquiryId: 'X2' }, customer: { country: 'US' } })); const token = (await request(app).post('/api/auth/login').send({ username: 'admin', password: 'admin-password-1234' })).body.token; const res = await request(app).get('/api/inquiries?country=PE').set('Authorization', 'Bearer ' + token); expect(res.status).toBe(200); expect(res.body.items.length).toBe(1); expect(res.body.items[0].customer.country).toBe('PE'); }); ``` - [ ] **Step 5: 测试通过 + Commit** `feat(api): inquiry list with filters/search/pagination, shared buildInquiryWhere` --- ## Task 10: 询盘详情 API **Files:** Modify `src/services/inquiry.service.ts`, `src/routes/inquiries.ts` - [ ] **Step 1: 服务方法** ```ts export async function getInquiryDetail(inquiryId: string) { return getPrisma().inquiry.findUnique({ where: { inquiryId }, include: { customer: true, _count: { select: { messages: true } } }, }); } ``` - [ ] **Step 2: 路由 `inquiriesRouter.get('/:id', jwtAuth, ...)`** → `res.json(await getInquiryDetail(req.params.id))` - [ ] **Step 3: 测试(推一条 → GET /:id → 200 含 customer + _count.messages) + Commit** `feat(api): inquiry detail` --- ## Task 11: 聊天游标分页(复合游标 + zod 校验) **Files:** Modify `src/services/inquiry.service.ts`, `src/routes/inquiries.ts` - [ ] **Step 1: 服务方法**(`(sendTime, messageId)` 复合游标) ```ts export async function listMessages(inquiryId: string, q: { afterSendTime?: string; afterMessageId?: string; size?: string }) { const prisma = getPrisma(); const size = Math.min(Number(q.size) || 50, 200); const where: Prisma.ChatMessageWhereInput = { inquiryId }; if (q.afterSendTime && q.afterMessageId) { const after = new Date(q.afterSendTime); where.OR = [{ sendTime: { gt: after } }, { sendTime: after, messageId: { gt: q.afterMessageId } }]; } const items = await prisma.chatMessage.findMany({ where, orderBy: [{ sendTime: 'asc' }, { messageId: 'asc' }], take: size, }); const last = items[items.length - 1]; return { items, nextCursor: items.length === size && last ? { afterSendTime: last.sendTime.toISOString(), afterMessageId: last.messageId } : null }; } ``` - [ ] **Step 2: 路由 + zod query 校验(半残游标 400 —— 修正 I3)** ```ts const CursorQuery = z.object({ afterSendTime: z.string().datetime().optional(), afterMessageId: z.string().optional(), size: z.string().optional(), }).refine(d => (!!d.afterSendTime && !!d.afterMessageId) || (!d.afterSendTime && !d.afterMessageId), { message: 'cursor fields must be both present or both absent' }); inquiriesRouter.get('/:id/messages', jwtAuth, async (req, res, next) => { const parsed = CursorQuery.safeParse(req.query); if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); try { res.json(await listMessages(req.params.id, parsed.data)); } catch (e) { next(e); } }); ``` - [ ] **Step 3: 测试(插 50 条 → 首页 size=20 → 带 nextCursor 取下页;另测只传 afterSendTime → 400) + Commit** `feat(api): chat cursor pagination (composite key, zod-validated)` --- ## Task 12: 统计 API **Files:** `src/services/stats.service.ts`, `src/routes/stats.ts`;Modify `src/app.ts` - [ ] **Step 1: 服务**(`_count` 重命名为 count —— S5) ```ts import { getPrisma } from '../lib/prisma.js'; export async function overview() { const prisma = getPrisma(); const total = await prisma.inquiry.count(); const countryTop10 = (await prisma.customer.groupBy({ by: ['country'], _count: { id: true }, orderBy: { _count: { id: 'desc' } }, take: 10, where: { country: { not: null } }, })).map(r => ({ country: r.country, count: r._count.id })); const byManager = (await prisma.inquiry.groupBy({ by: ['manager'], _count: { id: true }, orderBy: { _count: { id: 'desc' } }, where: { manager: { not: null } }, })).map(r => ({ manager: r.manager, count: r._count.id })); const monthly = await prisma.$queryRaw<{ ym: string; c: bigint }[]>` SELECT DATE_FORMAT(create_time, '%Y-%m') ym, COUNT(*) c FROM inquiries GROUP BY ym ORDER BY ym DESC LIMIT 12`; return { total, countryTop10, byManager, monthly: monthly.map(r => ({ ym: r.ym, count: Number(r.c) })) }; } ``` - [ ] **Step 2: 路由 `/api/stats/overview`(jwtAuth)+ 挂载** - [ ] **Step 3: 测试 + Commit** `feat(api): stats overview` --- ## Task 13: 导出(列表 CSV + 单询盘聊天 CSV) **Files:** `src/routes/export.ts`, Modify `src/routes/inquiries.ts`(单询盘导出);`src/app.ts` - [ ] **Step 1: `src/routes/export.ts`**(列表 CSV stream,复用 buildInquiryWhere —— S6) ```ts import { Router } from 'express'; import { getPrisma } from '../lib/prisma.js'; import { jwtAuth } from '../middleware/jwtAuth.js'; import { buildInquiryWhere } from '../services/inquiry.service.js'; export const exportRouter = Router(); exportRouter.get('/', jwtAuth, async (req, res, next) => { try { res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Disposition', 'attachment; filename="inquiries.csv"'); res.write('询价单号,业务员,国家,买家,公司,状态,创建时间,消息数\n'); const rows = await getPrisma().inquiry.findMany({ where: buildInquiryWhere(req.query), include: { customer: true }, orderBy: { createTime: 'desc' } }); for (const r of rows) { res.write([r.inquiryId, r.manager, r.customer?.country, r.customer?.name, r.customer?.company, r.status, r.createTime.toISOString(), r.messagesCount].map(csvEscape).join(',') + '\n'); } res.end(); } catch (e) { next(e); } }); function csvEscape(v: any) { const s = v == null ? '' : String(v); return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; } ``` - [ ] **Step 2: 单询盘聊天导出**(挂到 inquiriesRouter —— 修正 I5) ```ts // src/routes/inquiries.ts inquiriesRouter.get('/:id/messages/export', jwtAuth, async (req, res, next) => { try { res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="chat_${req.params.id}.csv"`); res.write('时间,方向,发送者,原文,翻译\n'); const msgs = await getPrisma().chatMessage.findMany({ where: { inquiryId: req.params.id }, orderBy: [{ sendTime: 'asc' }, { messageId: 'asc' }] }); for (const m of msgs) { res.write([m.sendTime.toISOString(), m.direction, m.sender, m.content, m.translation ?? ''].map(csvEscape).join(',') + '\n'); } res.end(); } catch (e) { next(e); } }); ``` - [ ] **Step 3: 挂 `/api/export`(exportRouter)+ 测试(列表导出 + 单询盘导出,验证 CSV 含表头且不含/含聊天) + Commit** `feat(api): csv export (list + single-inquiry chat)` --- ## Task 14: Docker 开发环境 + 端到端 e2e **Files:** `Dockerfile`, `docker-compose.yml`, `scripts/e2e.sh` - [ ] **Step 1: `Dockerfile`**(多阶段;`prisma generate` 在 build 阶段;运行时 `migrate deploy`) ```dockerfile FROM node:18-bookworm-slim AS build WORKDIR /app COPY package*.json ./ && RUN npm ci COPY . . && RUN npx prisma generate && npm run build FROM node:18-bookworm-slim WORKDIR /app COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/dist ./dist COPY --from=build /app/prisma ./prisma COPY --from=build /app/package.json . EXPOSE 8080 CMD ["sh","-c","npx prisma migrate deploy && node dist/server.js"] ``` - [ ] **Step 2: `docker-compose.yml`**(mysql `--max_allowed_packet=128M` —— spec v3) ```yaml services: mysql: image: mysql:8 environment: { MYSQL_ROOT_PASSWORD: root, MYSQL_DATABASE: inquiry } command: --max_allowed_packet=128M --character-set-server=utf8mb4 volumes: [mysql_data:/var/lib/mysql] ports: ["3306:3306"] healthcheck: { test: ["CMD","mysqladmin","ping","-h","localhost","-uroot","-proot"], interval: 5s, retries: 20 } backend: build: . environment: DATABASE_URL: mysql://root:root@mysql:3306/inquiry INGEST_TOKEN: dev-ingest-token-1234 JWT_SECRET: dev-jwt-secret-1234 ADMIN_USERNAME: admin ADMIN_PASSWORD: admin-password-1234 NODE_ENV: production PORT: "8080" depends_on: { mysql: { condition: service_healthy } } ports: ["8080:8080"] volumes: { mysql_data: {} } ``` - [ ] **Step 3: `scripts/e2e.sh`**(补全 detail/messages/stats/export 断言 —— S7) ```bash #!/usr/bin/env bash set -e BASE=http://localhost:8080; ITOK=dev-ingest-token-1234 JWT=$(curl -s -XPOST $BASE/api/auth/login -H 'Content-Type: application/json' \ -d '{"username":"admin","password":"admin-password-1234"}' | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log(JSON.parse(s).token))") echo "auth ok" curl -sf -XPOST $BASE/api/ingest -H "Authorization: Bearer $ITOK" -H 'Content-Type: application/json' \ -d '{"inquiry":{"inquiryId":"E2E-1","createTime":"2026-07-13T10:00:00Z","updateTime":"2026-07-13T10:00:00Z","manager":"tester","status":"new"},"customer":{"customerId":"c-e2e","name":"Test Buyer","country":"US"},"messages":[{"messageId":"e2e-m1","sendTime":"2026-07-13T10:00:00Z","direction":"outgoing","content":"hello e2e"}]}' >/dev/null echo "ingest ok" curl -sf "$BASE/api/inquiries/E2E-1" -H "Authorization: Bearer $JWT" | grep -q E2E-1 && echo "detail ok" curl -sf "$BASE/api/inquiries/E2E-1/messages" -H "Authorization: Bearer $JWT" | grep -q e2e-m1 && echo "messages ok" curl -sf "$BASE/api/stats/overview" -H "Authorization: Bearer $JWT" | grep -q total && echo "stats ok" curl -sf "$BASE/api/export" -H "Authorization: Bearer $JWT" | grep -q E2E-1 && echo "export ok" ``` - [ ] **Step 4: 起服务跑 e2e** ```bash docker compose up -d --build sleep 30; bash scripts/e2e.sh ``` Expected:`auth/ingest/detail/messages/stats/export ok` 全部打印。 - [ ] **Step 5: 跑全量测试** ```bash npm test ``` Expected:全部 pass。 - [ ] **Step 6: Commit** `feat: docker dev env + e2e; Plan 1 v2 complete` --- ## 完成标准(DoD) - [ ] 4 张表按 schema 建好(`has_failed_batches`、`MEDIUMTEXT content`、`Direction` 含 `system`、复合索引) - [ ] `/api/ingest`:zod 校验 + token 鉴权(timingSafeEqual)+ 限流 + 早返回(`!hasFailedBatches` + count + lastSendTime) + 主表 upsert(customerUpdate 不覆盖 null)+ 批量消息(createMany skipDuplicates 分批 500、失败隔离)+ **messagesCount 用总量** - [ ] `/api/auth/login` + `/refresh`:env bootstrap admin + JWT - [ ] `/api/inquiries`(列表筛选/搜索/分页)+ `/:id`(详情)+ `/:id/messages`(复合游标分页,zod 校验)+ `/:id/messages/export`(单询盘聊天 CSV) - [ ] `/api/stats/overview`(`_count` → `count` 重命名) - [ ] `/api/export`(列表 CSV stream,不含聊天) - [ ] **增量幂等回归测试**:推 N → 推 N+1 → 再推 N+1 必须早返回(S11) - [ ] `docker compose up` 一键起,e2e 六步全 ok - [ ] 全量 vitest 通过(惰性 Prisma + testcontainers + singleFork) ## 后续 Plan(本 plan 不含) - **Plan 2**:插件推送改造(配置页 + IndexedDB 队列经 SW 落盘 + chrome.alarms worker + optional_host_permissions + apiMsgToChat 字段扩展) - **Plan 3**:管理后台前端(Vue3 + Element Plus:登录/列表/详情虚拟滚动聊天/统计/导出;CORS 收窄) - **Plan 4**:正式部署(域名 + Let's Encrypt + 备份 cron + 健康监控 + 限流切 token 维度)