1107 lines
46 KiB
Markdown
1107 lines
46 KiB
Markdown
|
|
# 阿里询盘后台 - 后端 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<typeof schema> | 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<typeof schema> | 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<typeof schema>, { 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<string, any>) {
|
||
|
|
const out: Record<string, any> = {};
|
||
|
|
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 维度)
|