This commit is contained in:
浅念
2026-08-27 16:11:49 +08:00
commit 2d8ef3d7bb
104 changed files with 20196 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
node_modules
dist
.env
.env.*
*.log
.git
.gitignore
tests
vitest.config.ts
README.md
Dockerfile
docker-compose.yml
.dockerignore
+8
View File
@@ -0,0 +1,8 @@
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
+25
View File
@@ -0,0 +1,25 @@
FROM node:20-bookworm-slim AS build
WORKDIR /app
RUN apt-get update -y \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx prisma generate && npm run build
# runtime:仅装 production 依赖,大幅减小镜像体积、减少 CVE 暴露面
FROM node:20-bookworm-slim
WORKDIR /app
RUN apt-get update -y \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY package*.json ./
RUN npm ci --omit=dev
# prisma 在 dependencies,npm ci 保留 CLI;生成的 client(.prisma)需从 build 阶段拷过来
COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma
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"]
+62
View File
@@ -0,0 +1,62 @@
services:
mysql:
image: mysql:8
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: inquiry
# utf8mb4 + UTC(容器内 MySQL 一律按 UTC 存,应用层负责显示侧时区)
command: >
--max_allowed_packet=128M
--character-set-server=utf8mb4
--default-time-zone=+00:00
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot"]
interval: 5s
retries: 20
backend:
build: .
environment:
# timezone=Z 让 mysql2 driver 也按 UTC 解释时间,避免 JS Date 双重偏移
DATABASE_URL: mysql://root:root@mysql:3306/inquiry?timezone=Z
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"
# nginx: serves the Vue3 frontend (built from ../frontend) on :80 and
# reverse-proxies /api/* -> http://backend:8080 (joins the default network so
# it resolves the `backend` service by name). Task 8.
# 启动: docker compose up -d nginx
nginx:
build:
context: ../frontend
dockerfile: Dockerfile
depends_on:
- backend
ports:
- "8088:80"
# 一次性备份服务;默认不启动,手动触发:
# docker compose --profile backup run --rm backup
# 或宿主 crontab:0 3 * * * cd /path && docker compose --profile backup run --rm backup
backup:
image: mysql:8
profiles: ["backup"]
depends_on:
mysql:
condition: service_healthy
environment:
MYSQL_PWD: root
volumes:
- ./backups:/backup
- ./scripts/backup.sh:/backup.sh:ro
entrypoint: ["/bin/sh", "/backup.sh"]
volumes:
mysql_data: {}
+5723
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
{
"name": "inquiry-backend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"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"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"@prisma/client": "^5.22.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.6",
"dotenv": "^16.6.1",
"express": "^4.22.2",
"express-rate-limit": "^7.5.1",
"jsonwebtoken": "^9.0.3",
"morgan": "^1.11.0",
"mysql2": "^3.22.6",
"prisma": "^5.22.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@testcontainers/mysql": "^10.28.0",
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.25",
"@types/jsonwebtoken": "^9.0.10",
"@types/morgan": "^1.9.10",
"@types/node": "^20.19.43",
"@types/supertest": "^6.0.3",
"supertest": "^7.2.2",
"testcontainers": "^10.28.0",
"tsx": "^4.23.1",
"typescript": "^5.9.3",
"vitest": "^2.1.9"
}
}
@@ -0,0 +1,83 @@
-- CreateTable
CREATE TABLE `customers` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`customer_id` VARCHAR(64) NOT NULL,
`name` VARCHAR(128) NULL,
`country` VARCHAR(64) NULL,
`email` VARCHAR(128) NULL,
`company` VARCHAR(255) NULL,
`register_time` DATETIME(3) NULL,
`avatar_url` VARCHAR(512) NULL,
`buyer_tags` VARCHAR(255) NULL,
`buyer_features` VARCHAR(255) NULL,
`behavior_product_views` INTEGER NULL,
`behavior_valid_inquiries` INTEGER NULL,
`behavior_valid_rfq` INTEGER NULL,
`behavior_login_days` INTEGER NULL,
`behavior_spam_inquiries` INTEGER NULL,
`behavior_top_industry` VARCHAR(255) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `customers_customer_id_key`(`customer_id`),
INDEX `customers_country_idx`(`country`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `inquiries` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`inquiry_id` VARCHAR(32) NOT NULL,
`customer_id` VARCHAR(64) NULL,
`manager` VARCHAR(128) NULL,
`create_time` DATETIME(3) NOT NULL,
`update_time` DATETIME(3) NOT NULL,
`status` VARCHAR(64) NULL,
`msg_preview` VARCHAR(512) NULL,
`is_flagged` BOOLEAN NOT NULL DEFAULT false,
`messages_count` INTEGER NOT NULL DEFAULT 0,
`last_send_time` DATETIME(3) NULL,
`has_failed_batches` BOOLEAN NOT NULL DEFAULT false,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `inquiries_inquiry_id_key`(`inquiry_id`),
INDEX `inquiries_customer_id_idx`(`customer_id`),
INDEX `inquiries_manager_idx`(`manager`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `chat_messages` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`message_id` VARCHAR(64) NOT NULL,
`inquiry_id` VARCHAR(32) NOT NULL,
`sender` VARCHAR(128) NULL,
`direction` ENUM('incoming', 'outgoing', 'system') NOT NULL,
`send_time` DATETIME(3) NOT NULL,
`content` MEDIUMTEXT NOT NULL,
`translation` TEXT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `chat_messages_message_id_key`(`message_id`),
INDEX `chat_messages_inquiry_id_send_time_message_id_idx`(`inquiry_id`, `send_time`, `message_id`),
INDEX `chat_messages_inquiry_id_idx`(`inquiry_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `users` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(191) NOT NULL,
`password_hash` VARCHAR(191) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `users_username_key`(`username`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `inquiries` ADD CONSTRAINT `inquiries_customer_id_fkey` FOREIGN KEY (`customer_id`) REFERENCES `customers`(`customer_id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `chat_messages` ADD CONSTRAINT `chat_messages_inquiry_id_fkey` FOREIGN KEY (`inquiry_id`) REFERENCES `inquiries`(`inquiry_id`) ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable: 客户行为新增「被加为黑名单数」(对应 alibaba 详情页可见字段)
ALTER TABLE `customers` ADD COLUMN `behavior_blacklist` INTEGER NULL;
@@ -0,0 +1,2 @@
-- AlterTable: 客户「最近询盘产品」图片 URL 列表(JSON 字符串)
ALTER TABLE `customers` ADD COLUMN `recent_products_images` TEXT NULL;
@@ -0,0 +1,3 @@
-- AlterTable: 询盘新增「该会话所有询盘号+创建时间」(allInquiries,JSON 字符串)
-- 用于「一会话多询盘」合并:同 secTradeId 只存 1 条,all_inquiries 记录该会话全部询盘。
ALTER TABLE `inquiries` ADD COLUMN `all_inquiries` TEXT NULL;
@@ -0,0 +1,2 @@
-- AlterTable: 客户手机号(alibaba 详情展开后可见的「手机」字段,部分客户有)
ALTER TABLE `customers` ADD COLUMN `phone` VARCHAR(64) NULL;
@@ -0,0 +1,4 @@
-- 店内行为:询盘产品名 + 数量 + 浏览产品(图/链接/ID)
ALTER TABLE `customers` ADD COLUMN `store_inquiry_product` VARCHAR(512) NULL;
ALTER TABLE `customers` ADD COLUMN `store_inquiry_qty` VARCHAR(32) NULL;
ALTER TABLE `customers` ADD COLUMN `store_visit_products` TEXT NULL;
@@ -0,0 +1,5 @@
-- 客户基本信息扩展(折叠面板展开后的字段):座机/商业类型/销售平台/公司官网
ALTER TABLE `customers` ADD COLUMN `landline` VARCHAR(32) NULL;
ALTER TABLE `customers` ADD COLUMN `business_type` VARCHAR(128) NULL;
ALTER TABLE `customers` ADD COLUMN `sales_platform` VARCHAR(255) NULL;
ALTER TABLE `customers` ADD COLUMN `website` VARCHAR(512) NULL;
@@ -0,0 +1,3 @@
-- 加宽 customer_id(原 VarChar(64) 放不下 sec_ 兜底的 secTradeId 会话标识)
ALTER TABLE `customers` MODIFY COLUMN `customer_id` VARCHAR(256) NOT NULL;
ALTER TABLE `inquiries` MODIFY COLUMN `customer_id` VARCHAR(256);
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "mysql"
+99
View File
@@ -0,0 +1,99 @@
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(256)
name String? @db.VarChar(128)
country String? @db.VarChar(64)
email String? @db.VarChar(128)
phone String? @db.VarChar(64)
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")
bBlacklist Int? @map("behavior_blacklist")
bTopIndustry String? @map("behavior_top_industry") @db.VarChar(255)
recentProductsImages String? @map("recent_products_images") @db.Text
storeInquiryProduct String? @map("store_inquiry_product") @db.VarChar(512)
storeInquiryQty String? @map("store_inquiry_qty") @db.VarChar(32)
storeVisitProducts String? @map("store_visit_products") @db.Text
landline String? @map("landline") @db.VarChar(32)
businessType String? @map("business_type") @db.VarChar(128)
salesPlatform String? @map("sales_platform") @db.VarChar(255)
website String? @map("website") @db.VarChar(512)
inquiries Inquiry[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([country])
@@map("customers")
}
model Inquiry {
id BigInt @id @default(autoincrement())
inquiryId String @unique @map("inquiry_id") @db.VarChar(32)
customerId String? @map("customer_id") @db.VarChar(256)
manager String? @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)
allInquiries String? @map("all_inquiries") @db.Text
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")
@@index([customerId])
@@index([manager])
@@map("inquiries")
}
model ChatMessage {
id BigInt @id @default(autoincrement())
messageId String @unique @map("message_id") @db.VarChar(64)
inquiryId String @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])
@@index([inquiryId])
@@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")
}
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
# 一次性 mysqldump 备份(由 docker compose --profile backup run --rm backup 触发)
set -e
mkdir -p /backup
mysqldump -h mysql -uroot inquiry | gzip > "/backup/inquiry-$(date +%F).sql.gz"
# 14 天滚动清理
find /backup -name "inquiry-*.sql.gz" -mtime +14 -delete
echo "backup done: /backup/inquiry-$(date +%F).sql.gz"
+18
View File
@@ -0,0 +1,18 @@
#!/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"
+46
View File
@@ -0,0 +1,46 @@
import express from 'express';
import cors from 'cors';
import morgan from 'morgan';
import { errorHandler } from './middleware/error.js';
import { authRouter } from './routes/auth.js';
import { ingestRouter } from './routes/ingest.js';
import { inquiriesRouter } from './routes/inquiries.js';
import { statsRouter } from './routes/stats.js';
import { exportRouter } from './routes/export.js';
import { getPrisma } from './lib/prisma.js';
export function createApp() {
const app = express();
// 反代(OpenResty)一层:让 express-rate-limit / req.ip 取真实客户端 IP,
// 否则 X-Forwarded-For 存在但 trust proxy 关着,rate-limit 报 ERR_ERL_UNEXPECTED_X_FORWARDED_FOR
app.set('trust proxy', 1);
// Prisma 的 BigInt 主键默认无法 JSON 序列化,统一转字符串
app.set('json replacer', (_k: string, v: unknown) => (typeof v === 'bigint' ? v.toString() : v));
// MVP:CORS 全开(ingest 靠 token 鉴权;后台 Plan 3 联调时收窄到前端域名)。
// ALLOWED_ORIGINS 已在 env 就位(spec §10),Plan 3 联调时切换到动态 origin 校验函数:
// origin: (origin, cb) => {
// if (env.ALLOWED_ORIGINS === '*') return cb(null, true);
// const list = env.ALLOWED_ORIGINS.split(',').map(s => s.trim());
// if (!origin || list.includes(origin)) return cb(null, true);
// return cb(new Error('Not allowed by CORS'));
// }
app.use(cors({ origin: true }));
app.use(express.json({ limit: '50mb' }));
if (process.env.NODE_ENV !== 'test') app.use(morgan('dev'));
// health 探活:连得上 DB 才返回 200,否则 503(让 orchestrator 重启)
app.get('/api/health', async (_req, res) => {
try {
await getPrisma().$queryRaw`SELECT 1`;
res.json({ ok: true });
} catch {
res.status(503).json({ ok: false });
}
});
app.use('/api/auth', authRouter);
app.use('/api/ingest', ingestRouter);
app.use('/api/inquiries', inquiriesRouter);
app.use('/api/stats', statsRouter);
app.use('/api/export', exportRouter);
app.use(errorHandler);
return app;
}
+32
View File
@@ -0,0 +1,32 @@
import { z } from 'zod';
import { config as dotenvConfig } from 'dotenv';
dotenvConfig(); // 仅设置 process.env,不校验
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', 'test']).default('development'),
// 逗号分隔域名,默认 `*` 允许全部;Plan 3 联调时配具体前端域名(见 spec §10)
ALLOWED_ORIGINS: z.string().default('*'),
});
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: (_t, k) => (loadEnv() as any)[k],
});
+15
View File
@@ -0,0 +1,15 @@
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;
}
}
+14
View File
@@ -0,0 +1,14 @@
import type { ErrorRequestHandler } from 'express';
import { env } from '../config/env.js';
export const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => {
console.error(err);
const status = err.status ?? 500;
const isClientError = status >= 400 && status < 500;
// 5xx 在生产环境不回显内部 message(避免泄漏表名/SQL/堆栈);4xx 与 dev 环境保留
const message =
!isClientError && env.NODE_ENV === 'production'
? 'internal error'
: err.message ?? 'internal error';
res.status(status).json({ error: message });
};
+14
View File
@@ -0,0 +1,14 @@
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();
};
+14
View File
@@ -0,0 +1,14 @@
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' });
}
};
+8
View File
@@ -0,0 +1,8 @@
import rateLimit from 'express-rate-limit';
export const ingestLimiter = rateLimit({
windowMs: 60_000,
max: 60,
standardHeaders: true,
legacyHeaders: false,
});
+33
View File
@@ -0,0 +1,33 @@
import { Router } from 'express';
import { z } from 'zod';
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();
const LoginBody = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
authRouter.post('/login', async (req, res, next) => {
const parsed = LoginBody.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'invalid body', issues: parsed.error.flatten() });
try {
res.json(await login(parsed.data.username, parsed.data.password));
} catch (e) {
next(e);
}
});
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,
} as jwt.SignOptions),
expiresIn: env.JWT_EXPIRES_IN,
});
});
+59
View File
@@ -0,0 +1,59 @@
import { Router } from 'express';
import { getPrisma } from '../lib/prisma.js';
import { jwtAuth } from '../middleware/jwtAuth.js';
import { buildInquiryWhere } from '../services/inquiry.service.js';
import { csvEscape } from '../utils/csv.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(''); // UTF-8 BOM
res.write('询价单号,所有询盘号,业务员,买家,国家,公司,邮箱,电话,注册时间,买家标签,买家特征,状态,创建时间,产品浏览数,有效询盘,有效RFQ数,登录天数,垃圾询盘数,被加为黑名单数,最常采购行业,最近询盘产品图\n');
const rows = await getPrisma().inquiry.findMany({
where: buildInquiryWhere(req.query),
include: { customer: true },
orderBy: { createTime: 'desc' },
});
for (const r of rows) {
const c = r.customer;
// 所有询盘号:从 allInquiries(JSON)提取 inquiryId,分号分隔
let allIds = r.inquiryId;
if (r.allInquiries) {
try { allIds = (JSON.parse(r.allInquiries) as any[]).map((x) => x.inquiryId).join('; '); } catch {}
}
res.write(
[
r.inquiryId,
allIds,
r.manager,
c?.name,
c?.country,
c?.company,
c?.email,
c?.phone,
c?.registerTime ? c.registerTime.toISOString() : '',
c?.buyerTags,
c?.buyerFeatures,
r.status,
r.createTime.toISOString(),
c?.bProductViews,
c?.bValidInquiries,
c?.bValidRfq,
c?.bLoginDays,
c?.bSpamInquiries,
c?.bBlacklist,
c?.bTopIndustry,
c?.recentProductsImages,
]
.map(csvEscape)
.join(',') + '\n',
);
}
res.end();
} catch (e) {
next(e);
}
});
+85
View File
@@ -0,0 +1,85 @@
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(),
blacklistCount: 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(),
landline: z.string().nullable().optional(),
businessType: z.string().nullable().optional(),
salesPlatform: z.string().nullable().optional(),
website: z.string().nullable().optional(),
phone: z.string().nullable().optional(),
company: z.string().nullable().optional(),
registerTime: z.string().nullable().optional(), // 页面是 "2016-08-27"(日期非 ISO),放宽
avatarUrl: z.string().optional(),
buyerTags: z.string().optional(),
buyerFeatures: z.string().optional(),
recentInquiryProducts: z.array(z.string()).optional(),
storeInquiryProduct: z.string().nullable().optional(),
storeInquiryQty: z.string().nullable().optional(),
storeVisitProducts: z.array(z.object({ img: z.string().optional(), url: z.string().optional(), productId: z.string().optional() })).optional(),
behavior: BehaviorSchema,
})
.nullable(),
messages: z.array(MessageSchema).min(0), // 阶段1:允许空(先走通列表+客户+行为,聊天后续补充)
});
export const ingestRouter = Router();
ingestRouter.post('/', ingestLimiter, ingestAuth, async (req, res, next) => {
const parsed = PayloadSchema.safeParse(req.body);
if (!parsed.success) {
// format() 显示完整 path(如 messages.0.sendTime),便于定位 null 字段
console.error('[ingest 400]', JSON.stringify(parsed.error.format()));
return res
.status(400)
.json({ error: 'invalid payload', issues: parsed.error.flatten() });
}
try {
const d = parsed.data;
console.log(`[ingest in] inquiry=${d.inquiry?.inquiryId} customer=${d.customer?.customerId ?? '-'} messages=${d.messages?.length ?? 0}`);
const result = await ingest(d);
console.log(`[ingest ok] inquiry=${d.inquiry?.inquiryId} -> ${JSON.stringify(result)}`);
res.json(result);
} catch (e) {
console.error('[ingest err] inquiry=', parsed.data.inquiry?.inquiryId, e);
next(e);
}
});
+109
View File
@@ -0,0 +1,109 @@
import { Router } from 'express';
import { z } from 'zod';
import { jwtAuth } from '../middleware/jwtAuth.js';
import { getPrisma } from '../lib/prisma.js';
import { listInquiries, getInquiryDetail, listMessages } from '../services/inquiry.service.js';
import { csvEscape } from '../utils/csv.js';
export const inquiriesRouter = Router();
// list 查询参数:size/page 非数字时退回默认;dateFrom/dateTo 用 ISO datetime 校验,非法 → 400
const ListQuery = z.object({
size: z
.string()
.optional()
.refine((v) => v == null || /^\d+$/.test(v), { message: 'size must be an integer' }),
page: z
.string()
.optional()
.refine((v) => v == null || /^\d+$/.test(v), { message: 'page must be an integer' }),
dateFrom: z.string().datetime().optional(),
dateTo: z.string().datetime().optional(),
country: z.string().optional(),
manager: z.string().optional(),
status: z.string().optional(),
q: z.string().optional(),
sort: z.enum(['update_time_desc', 'create_time_asc', 'create_time_desc', 'updated_desc']).optional(),
});
const CursorQuery = z
.object({
afterSendTime: z.string().datetime().optional(),
afterMessageId: z.string().optional(),
beforeSendTime: z.string().datetime().optional(),
beforeMessageId: z.string().optional(),
size: z.string().optional(),
})
.refine(
(d) =>
(!!d.afterSendTime && !!d.afterMessageId) ||
(!d.afterSendTime && !d.afterMessageId),
{ message: 'after cursor fields must be both present or both absent' },
)
.refine(
(d) =>
(!!d.beforeSendTime && !!d.beforeMessageId) ||
(!d.beforeSendTime && !d.beforeMessageId),
{ message: 'before cursor fields must be both present or both absent' },
)
.refine(
(d) =>
!(
(d.afterSendTime || d.afterMessageId) &&
(d.beforeSendTime || d.beforeMessageId)
),
{ message: 'after and before cursors are mutually exclusive' },
);
inquiriesRouter.get('/', jwtAuth, async (req, res, next) => {
const parsed = ListQuery.safeParse(req.query);
if (!parsed.success) return res.status(400).json({ error: 'invalid query', issues: parsed.error.flatten() });
try {
res.json(await listInquiries(parsed.data));
} catch (e) {
next(e);
}
});
inquiriesRouter.get('/:id', jwtAuth, async (req, res, next) => {
try {
const r = await getInquiryDetail(req.params.id);
if (!r) return res.status(404).json({ error: 'not found' });
res.json(r);
} catch (e) {
next(e);
}
});
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);
}
});
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(''); // UTF-8 BOM
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);
}
});
+13
View File
@@ -0,0 +1,13 @@
import { Router } from 'express';
import { jwtAuth } from '../middleware/jwtAuth.js';
import { overview } from '../services/stats.service.js';
export const statsRouter = Router();
statsRouter.get('/overview', jwtAuth, async (_req, res, next) => {
try {
res.json(await overview());
} catch (e) {
next(e);
}
});
+26
View File
@@ -0,0 +1,26 @@
import { createApp } from './app.js';
import { loadEnv, env } from './config/env.js';
import { disconnectPrisma } from './lib/prisma.js';
import { bootstrapAdmin } from './services/auth.service.js';
async function shutdown(server: import('http').Server) {
await new Promise<void>((r) => server.close(() => r()));
await disconnectPrisma();
process.exit(0);
}
async function main() {
loadEnv();
await bootstrapAdmin();
const app = createApp();
const server = app.listen(env.PORT, () => console.log(`listening on ${env.PORT}`));
process.on('SIGTERM', () => shutdown(server));
process.on('SIGINT', () => shutdown(server));
process.on('unhandledRejection', (reason) => console.error('unhandledRejection', reason));
process.on('uncaughtException', (err) => {
console.error('uncaughtException', err);
shutdown(server);
});
}
main();
+30
View File
@@ -0,0 +1,30 @@
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: String(user.id), u: user.username }, env.JWT_SECRET, {
expiresIn: env.JWT_EXPIRES_IN,
} as jwt.SignOptions),
expiresIn: env.JWT_EXPIRES_IN,
};
}
+120
View File
@@ -0,0 +1,120 @@
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 newLast = maxSendTime(messages);
// 按 customerId 去重:同客户只存 1 条代表 inquiry(同客户询盘详情/聊天一致)。
// 后续同客户询盘 → 追加到代表的 allInquiries,消息归代表,不新建 inquiry。
if (cu?.customerId) {
// 代表 = 该客户最早的询盘(createTime 升序第一);同客户后续询盘追加到 allInquiries
const rep = await prisma.inquiry.findFirst({
where: { customerId: cu.customerId },
orderBy: { createTime: 'asc' },
});
if (rep && rep.inquiryId !== iq.inquiryId) {
const allList: any[] = rep.allInquiries
? JSON.parse(rep.allInquiries)
: [{ inquiryId: rep.inquiryId, createTime: rep.createTime.toISOString(), status: rep.status, msgPreview: rep.msgPreview }];
if (!allList.find((x: any) => x.inquiryId === iq.inquiryId)) {
allList.push({ inquiryId: iq.inquiryId, createTime: iq.createTime, status: iq.status, msgPreview: iq.msgPreview });
}
// 代表 createTime = 该客户所有会话(询盘)里最早的创建时间
const allTimes = allList.map((x: any) => new Date(x.createTime).getTime()).filter((t: number) => !isNaN(t));
const earliest = allTimes.length ? new Date(Math.min(...allTimes)) : new Date(iq.createTime);
await prisma.$transaction(async (tx) => {
if (cu) {
const data = mapCustomer(cu);
await tx.customer.upsert({ where: { customerId: cu.customerId }, create: data, update: skipNulls(data) });
}
await tx.inquiry.update({
where: { inquiryId: rep.inquiryId },
data: {
allInquiries: JSON.stringify(allList),
createTime: earliest,
updateTime: new Date(iq.updateTime),
status: iq.status ?? undefined,
msgPreview: iq.msgPreview ?? undefined,
},
});
});
const { failedBatches } = await batchInsertMessages(rep.inquiryId, messages);
const total = await prisma.chatMessage.count({ where: { inquiryId: rep.inquiryId } });
await prisma.inquiry.update({
where: { inquiryId: rep.inquiryId },
data: { messagesCount: total, lastSendTime: newLast, hasFailedBatches: failedBatches > 0 },
});
return { ok: true, inserted: total, failedBatches, earlyReturn: false };
}
}
// 新客户(或同 inquiryId 代表重抓):customer/inquiry 每次 upsert(保证行为/字段最新);
// 消息只在「有变化」时才 batchInsert(增量,skipDuplicates 去重),没变则跳过省一次批量插入。
const existing = await prisma.inquiry.findUnique({ where: { inquiryId: iq.inquiryId } });
const msgsChanged = !existing || existing.hasFailedBatches
|| existing.messagesCount !== messages.length
|| !(newLast && existing.lastSendTime && existing.lastSendTime.getTime() === newLast.getTime());
// 建/更新代表 inquiry + customer(每次,保证最新)
await prisma.$transaction(async (tx) => {
if (cu) {
const data = mapCustomer(cu);
await tx.customer.upsert({ where: { customerId: cu.customerId }, create: data, update: skipNulls(data) });
}
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,
},
});
});
// 消息没变 → 跳过 batchInsert(customer/inquiry 已更新 above);变了才插入 + 更新计数
if (!msgsChanged) {
return { ok: true, inserted: existing!.messagesCount, failedBatches: 0, earlyReturn: true };
}
const { failedBatches } = await batchInsertMessages(iq.inquiryId, messages);
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) {
// 空串归一化为 null:避免空串占据 NOT NULL 语义、影响 stats 的 `country IS NOT NULL` 过滤等
const s = (v: any) => (v == null || v === '' ? null : v);
return {
customerId: c.customerId, name: s(c.name), country: s(c.country), email: s(c.email), phone: s(c.phone), landline: s(c.landline), businessType: s(c.businessType), salesPlatform: s(c.salesPlatform), website: s(c.website), company: s(c.company),
registerTime: c.registerTime ? new Date(c.registerTime) : null, avatarUrl: s(c.avatarUrl),
buyerTags: s(c.buyerTags), buyerFeatures: s(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, bBlacklist: c.behavior?.blacklistCount ?? null, bTopIndustry: c.behavior?.topIndustry ?? null, recentProductsImages: Array.isArray(c.recentInquiryProducts) && c.recentInquiryProducts.length ? JSON.stringify(c.recentInquiryProducts) : null, storeInquiryProduct: s(c.storeInquiryProduct), storeInquiryQty: s(c.storeInquiryQty), storeVisitProducts: Array.isArray(c.storeVisitProducts) && c.storeVisitProducts.length ? JSON.stringify(c.storeVisitProducts) : null,
};
}
// update 时跳过 null 字段(避免覆盖已落库的有效值,如上次抓到 behavior 这次没抓到)
function skipNulls(data: Record<string, any>) {
const out: Record<string, any> = {};
for (const [k, v] of Object.entries(data)) if (v != null) out[k] = v;
return out;
}
+110
View File
@@ -0,0 +1,110 @@
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 as any).gte = new Date(q.dateFrom);
if (q.dateTo) (where.createTime as any).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 getInquiryDetail(inquiryId: string) {
return getPrisma().inquiry.findUnique({
where: { inquiryId },
include: { customer: true, _count: { select: { messages: true } } },
});
}
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 }
: q.sort === 'updated_desc' ? { updatedAt: 'desc' as const } // 抓取时间(入库 updatedAt,每次推送刷新)倒序
: { 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 };
}
/**
* 微信式分页:默认从最新开始,向前(after=更新)或向后(before=更旧)翻页。
*
* - 无 cursor:取最新 size 条(反转成 asc 返回,顶旧底新),prevCursor 指向已加载最旧的一条,
* 前端滚到底部显示最新、向上「加载更多」用 prevCursor 拉更旧的历史。
* - before cursor:取 before 之前(更旧)的 size 条,prevCursor 继续向上。
* - after cursor:取 after 之后(更新)的 size 条,nextCursor 继续向前(保留兼容)。
*
* 默认 + before 都按 desc 取再反转 asc,保证返回数组始终是旧→新顺序(前端渲染统一)。
*/
export async function listMessages(
inquiryId: string,
q: {
afterSendTime?: string; afterMessageId?: string;
beforeSendTime?: string; beforeMessageId?: string;
size?: string;
},
) {
const prisma = getPrisma();
const size = Math.min(Number(q.size) || 50, 200);
const where: Prisma.ChatMessageWhereInput = { inquiryId };
const hasBefore = !!(q.beforeSendTime && q.beforeMessageId);
const hasAfter = !!(q.afterSendTime && q.afterMessageId);
let orderBy: Prisma.ChatMessageFindManyArgs['orderBy'];
if (hasBefore) {
const before = new Date(q.beforeSendTime as string);
where.OR = [
{ sendTime: { lt: before } },
{ sendTime: before, messageId: { lt: q.beforeMessageId as string } },
];
orderBy = [{ sendTime: 'desc' }, { messageId: 'desc' }];
} else if (hasAfter) {
const after = new Date(q.afterSendTime as string);
where.OR = [
{ sendTime: { gt: after } },
{ sendTime: after, messageId: { gt: q.afterMessageId as string } },
];
orderBy = [{ sendTime: 'asc' }, { messageId: 'asc' }];
} else {
// 默认:从最新开始
orderBy = [{ sendTime: 'desc' }, { messageId: 'desc' }];
}
const raw = await prisma.chatMessage.findMany({ where, orderBy, take: size });
// 默认 + before 按 desc 取,反转成 asc(旧→新);after 已是 asc
const items = hasAfter ? raw : raw.slice().reverse();
const first = items[0];
const last = items[items.length - 1];
const fullPage = items.length === size;
return {
items,
// prevCursor:向上加载更旧(默认 + before 模式)
prevCursor:
!hasAfter && first && fullPage
? { beforeSendTime: first.sendTime.toISOString(), beforeMessageId: first.messageId }
: null,
// nextCursor:向前加载更新(仅 after 模式)
nextCursor:
hasAfter && last && fullPage
? { afterSendTime: last.sendTime.toISOString(), afterMessageId: last.messageId }
: null,
};
}
+36
View File
@@ -0,0 +1,36 @@
import { getPrisma } from '../lib/prisma.js';
export async function overview() {
const prisma = getPrisma();
const total = await prisma.inquiry.count();
// 国家统计按 inquiry 计数(join customer):一个客户多条询盘会被多次计入,
// 反映"哪个国家的询盘最多",比按 customer 计数更贴合业务语义。
const countryTop10 = (
await prisma.$queryRaw<{ country: string | null; count: bigint | number }[]>`
SELECT c.country AS country, COUNT(*) AS count
FROM inquiries i JOIN customers c ON i.customer_id = c.customer_id
WHERE c.country IS NOT NULL
GROUP BY c.country ORDER BY count DESC LIMIT 10`
).map((r) => ({ country: r.country, count: Number(r.count) }));
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') AS ym, COUNT(*) AS 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) })),
};
}
+26
View File
@@ -0,0 +1,26 @@
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 {
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 };
}
+6
View File
@@ -0,0 +1,6 @@
// 共用 CSV 字段转义:list export 与 single-inquiry chat export 都用同一份逻辑。
// 含 `,` `"` `\n` 的字段用 `"..."` 包裹,内部 `"` 转义为 `""`;null/undefined → 空串。
export function csvEscape(v: any): string {
const s = v == null ? '' : String(v);
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}
+2
View File
@@ -0,0 +1,2 @@
export const maxSendTime = (msgs: { sendTime: string }[]) =>
msgs.length ? new Date(Math.max(...msgs.map(m => Date.parse(m.sendTime)))) : null;
+29
View File
@@ -0,0 +1,29 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { setupApp, 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);
});
});
+54
View File
@@ -0,0 +1,54 @@
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(async () => {
await teardown();
});
async function login() {
return (
await request(app)
.post('/api/auth/login')
.send({ username: 'admin', password: 'admin-password-1234' })
).body.token;
}
async function pushInquiry(p: any) {
await request(app)
.post('/api/ingest')
.set('Authorization', 'Bearer dev-ingest-token-1234')
.send(p);
}
describe('export', () => {
it('list CSV has header + row, no chat content', async () => {
await resetDb();
await pushInquiry(makePayload()); // inquiryId 13776617920, messages hello/hi
const token = await login();
const res = await request(app).get('/api/export').set('Authorization', 'Bearer ' + token);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('text/csv');
const text = res.text;
expect(text).toContain('询价单号'); // 表头
expect(text).toContain('13776617920'); // 数据行
expect(text).not.toContain('hello'); // 列表导出不含聊天原文
});
it('single-inquiry chat CSV contains message content', async () => {
await resetDb();
await pushInquiry(makePayload());
const token = await login();
const res = await request(app)
.get('/api/inquiries/13776617920/messages/export')
.set('Authorization', 'Bearer ' + token);
expect(res.status).toBe(200);
expect(res.text).toContain('时间');
expect(res.text).toContain('hello'); // 含聊天原文
});
});
+45
View File
@@ -0,0 +1,45 @@
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: o.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: '',
},
],
};
}
+12
View File
@@ -0,0 +1,12 @@
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import { createApp } from '../src/app.js';
describe('health', () => {
it('ok (200 if DB reachable, 503 if not — both acceptable, never 500)', async () => {
const res = await request(createApp()).get('/api/health');
// 单元环境无容器连不上 → 503;有容器 → 200。两者都算通过(只杜绝 500 崩)
expect([200, 503]).toContain(res.status);
expect(res.body).toHaveProperty('ok');
});
});
+73
View File
@@ -0,0 +1,73 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { setupApp, teardown, resetDb } from './setup.js';
import { makePayload } from './fixtures.js';
const TOKEN = 'Bearer dev-ingest-token-1234';
let app: any;
beforeAll(async () => {
({ app } = await setupApp());
}, 90000);
afterAll(async () => {
await teardown();
});
describe('ingest auth/validation', () => {
it('401 without token', async () => {
const res = await request(app).post('/api/ingest').send(makePayload());
expect(res.status).toBe(401);
});
it('400 bad payload', async () => {
const res = await request(app)
.post('/api/ingest')
.set('Authorization', 'Bearer dev-ingest-token-1234')
.send({ inquiry: {} });
expect(res.status).toBe(400);
});
});
describe('ingest basic insert', () => {
it('accepts valid payload, inserts 2 messages', async () => {
await resetDb();
const res = await request(app).post('/api/ingest')
.set('Authorization', 'Bearer dev-ingest-token-1234').send(makePayload());
expect(res.status).toBe(200);
expect(res.body.inserted).toBe(2);
expect(res.body.earlyReturn).toBe(false);
});
});
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);
});
it('push N → push N+1 → push N+1 again early-returns (messagesCount=total regression)', async () => {
await resetDb();
const base = makePayload();
await request(app).post('/api/ingest').set('Authorization', TOKEN).send(base);
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,这里 earlyReturn 会是 false)
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);
});
});
+152
View File
@@ -0,0 +1,152 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { setupApp, resetDb, teardown } from './setup.js';
import { makePayload } from './fixtures.js';
import { getPrisma } from '../src/lib/prisma.js';
let app: any;
beforeAll(async () => {
({ app } = await setupApp());
}, 90000);
afterAll(async () => {
await teardown();
});
async function login() {
const r = await request(app)
.post('/api/auth/login')
.send({ username: 'admin', password: 'admin-password-1234' });
return r.body.token;
}
async function pushInquiry(payload: any) {
await request(app)
.post('/api/ingest')
.set('Authorization', 'Bearer dev-ingest-token-1234')
.send(payload);
}
describe('inquiries list', () => {
it('lists filtered by country', async () => {
await resetDb();
await pushInquiry(makePayload({ customer: { country: 'PE' } }));
await pushInquiry(
makePayload({ inquiry: { inquiryId: 'X2' }, customer: { customerId: 'c2', country: 'US' } }),
);
const token = await login();
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');
expect(res.body).toHaveProperty('total');
expect(res.body).toHaveProperty('page');
expect(res.body).toHaveProperty('size');
});
it('401 without jwt', async () => {
const res = await request(app).get('/api/inquiries');
expect(res.status).toBe(401);
});
});
describe('inquiries detail', () => {
it('gets inquiry detail with customer + message count', async () => {
await resetDb();
await pushInquiry(makePayload()); // inquiryId='13776617920', 2 messages
const token = await login();
const res = await request(app)
.get('/api/inquiries/13776617920')
.set('Authorization', 'Bearer ' + token);
expect(res.status).toBe(200);
expect(res.body.inquiryId).toBe('13776617920');
expect(res.body.customer).toBeTruthy();
expect(res.body._count.messages).toBe(2);
});
it('404 for unknown inquiry', async () => {
const token = await login();
const res = await request(app)
.get('/api/inquiries/NOPE')
.set('Authorization', 'Bearer ' + token);
expect(res.status).toBe(404);
});
});
describe('inquiry messages pagination', () => {
// 造 50 条消息(sendTime 各不同),用于测默认从最新 + before 向上翻页
async function seed50() {
await resetDb();
const prisma = getPrisma();
await pushInquiry(makePayload()); // 建 inquiry + 2 条消息
const base = Date.parse('2026-07-13T02:12:00Z');
await prisma.chatMessage.createMany({
data: Array.from({ length: 48 }, (_, i) => ({
messageId: 'p' + i,
inquiryId: '13776617920',
sender: 'x',
direction: 'incoming' as const,
sendTime: new Date(base + (i + 1) * 1000),
content: 'c' + i,
translation: '',
})),
});
}
it('默认无 cursor 返回最新一页(asc 旧→新)+ prevCursor 指向更旧', async () => {
await seed50();
const token = await login();
const r1 = await request(app)
.get('/api/inquiries/13776617920/messages?size=20')
.set('Authorization', 'Bearer ' + token);
expect(r1.status).toBe(200);
expect(r1.body.items.length).toBe(20);
expect(r1.body.prevCursor).toBeTruthy();
// 默认从最新:返回的 20 条应是全局最新的 20 条,且按 sendTime 升序(顶旧底新)
const ts = r1.body.items.map((m: any) => new Date(m.sendTime).getTime());
for (let i = 1; i < ts.length; i++) expect(ts[i]).toBeGreaterThanOrEqual(ts[i - 1]);
// 最新一条(全局最大 sendTime)应在 items 末尾
const prisma = getPrisma();
const maxRow = await prisma.chatMessage.findFirst({
where: { inquiryId: '13776617920' },
orderBy: { sendTime: 'desc' },
});
expect(ts[ts.length - 1]).toBe(maxRow!.sendTime.getTime());
});
it('before cursor 向上加载更旧,与已加载不重叠', async () => {
await seed50();
const token = await login();
const r1 = await request(app)
.get('/api/inquiries/13776617920/messages?size=20')
.set('Authorization', 'Bearer ' + token);
const c = r1.body.prevCursor;
expect(c).toBeTruthy();
const r2 = await request(app)
.get(
`/api/inquiries/13776617920/messages?size=20&beforeSendTime=${encodeURIComponent(c.beforeSendTime)}&beforeMessageId=${c.beforeMessageId}`,
)
.set('Authorization', 'Bearer ' + token);
expect(r2.status).toBe(200);
expect(r2.body.items.length).toBe(20);
// 不重叠
const ids1 = new Set(r1.body.items.map((m: any) => m.messageId));
expect(r2.body.items.every((m: any) => !ids1.has(m.messageId))).toBe(true);
// r2 全部比 r1 第一条更旧
const r1First = new Date(r1.body.items[0].sendTime).getTime();
expect(r2.body.items.every((m: any) => new Date(m.sendTime).getTime() <= r1First)).toBe(true);
});
it('400 when only one cursor field provided (after or before)', async () => {
const token = await login();
const r1 = await request(app)
.get('/api/inquiries/13776617920/messages?afterSendTime=2026-07-13T02:12:00Z')
.set('Authorization', 'Bearer ' + token);
expect(r1.status).toBe(400);
const r2 = await request(app)
.get('/api/inquiries/13776617920/messages?beforeSendTime=2026-07-13T02:12:00Z')
.set('Authorization', 'Bearer ' + token);
expect(r2.status).toBe(400);
});
});
+41
View File
@@ -0,0 +1,41 @@
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';
import { bootstrapAdmin } from '../src/services/auth.service.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();
await bootstrapAdmin(); // 确保 admin 存在(auth 测试需要)
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');
await bootstrapAdmin(); // resetDb 清了 users,重新 seed admin
}
export async function teardown() {
await disconnectPrisma();
await container?.stop();
container = null;
}
+58
View File
@@ -0,0 +1,58 @@
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(async () => {
await teardown();
});
async function login() {
return (
await request(app)
.post('/api/auth/login')
.send({ username: 'admin', password: 'admin-password-1234' })
).body.token;
}
async function pushInquiry(p: any) {
await request(app)
.post('/api/ingest')
.set('Authorization', 'Bearer dev-ingest-token-1234')
.send(p);
}
describe('stats overview', () => {
it('returns total / countryTop10 / byManager / monthly', async () => {
await resetDb();
await pushInquiry(makePayload({ customer: { country: 'PE', name: 'A' } }));
await pushInquiry(
makePayload({
inquiry: { inquiryId: 'X2' },
customer: { customerId: 'c2', country: 'US', name: 'B' },
}),
);
const token = await login();
const res = await request(app)
.get('/api/stats/overview')
.set('Authorization', 'Bearer ' + token);
expect(res.status).toBe(200);
expect(res.body.total).toBe(2);
expect(Array.isArray(res.body.countryTop10)).toBe(true);
expect(res.body.countryTop10.some((c: any) => c.country === 'PE')).toBe(true);
expect(Array.isArray(res.body.byManager)).toBe(true);
expect(Array.isArray(res.body.monthly)).toBe(true);
// 确认 _count 已重命名为 count(前端友好)
expect(res.body.countryTop10[0]).toHaveProperty('count');
expect(res.body.countryTop10[0].count).not.toBeUndefined();
});
it('401 without jwt', async () => {
const res = await request(app).get('/api/stats/overview');
expect(res.status).toBe(401);
});
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"outDir": "dist",
"rootDir": "src",
"resolveJsonModule": true,
"skipLibCheck": true,
"types": ["vitest/globals"]
},
"include": ["src/**/*"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
testTimeout: 90_000,
hookTimeout: 90_000,
poolOptions: { forks: { singleFork: true } },
},
});