|
|
# 技术设计方案 v1.0(邮件自动预报系统)
|
|
|
|
|
|
> 状态:可直接开发(**2026-07-23 现行口径回写**;**2026-08-03 指令拆分产品规则锁定**)
|
|
|
> 依据:`docx/需求规格-邮件自动预报-v0.2.md`、`docx/异常场景与系统韧性设计.md`、**`docx/产品规则-邮件指令识别与拆分.md`**
|
|
|
> 栈锁定:Next.js App Router + MySQL 8 + docker-compose(`web` / `worker` / `mysql`)
|
|
|
> **回写要点**:`src/worker`;IMAP lookback N 天;无 audit_log;路由 `/confirm` `/logs` `/settings`;本地 MySQL **7023**;seed 仅账号;详见 `docx/现行口径-修订说明-v1.md`
|
|
|
> **指令口径**:主题/正文/附件分工、仅最新段可确认、软词→客户指令 — 见产品规则页;实现见 `instruction-lexicon.ts` / `split-instructions.ts`(**无 LLM**)
|
|
|
|
|
|
---
|
|
|
|
|
|
## 1. 细化技术选型
|
|
|
|
|
|
### 1.1 ORM
|
|
|
|
|
|
| 维度 | Prisma | Drizzle | TypeORM |
|
|
|
|---|---|---|---|
|
|
|
| AI 生成 Schema | 高(schema 声明式) | 高(TS 表定义) | 中 |
|
|
|
| 类型安全 | 生成客户端强类型 | 推断强 | 装饰器弱于前两者 |
|
|
|
| 迁移 | `prisma migrate` 成熟 | `drizzle-kit` 可用 | 易踩坑 |
|
|
|
| JSON 字段 | 原生支持 | 原生支持 | 一般 |
|
|
|
|
|
|
**唯一推荐:Prisma 5.x**
|
|
|
理由:Cursor 生成/改 schema 成功率最高;迁移命令固定;与 Next.js 官方示例一致;`Json` 字段直接对应 `type_evidence` / `shipments`。
|
|
|
|
|
|
### 1.2 UI 组件库
|
|
|
|
|
|
| 维度 | shadcn/ui | Ant Design | MUI |
|
|
|
|---|---|---|---|
|
|
|
| 客服后台 | 中(需自拼表格) | 高(Table/Form 开箱) | 高 |
|
|
|
| 虚拟滚动 327+ | `@tanstack/react-virtual` 自接 | `Table virtual` 文档成熟 | 需 DataGrid Pro 或自接 |
|
|
|
| 定制 | 最高 | 中 | 中 |
|
|
|
| 包体/样式冲突 | 低 | 中 | 中 |
|
|
|
|
|
|
**唯一推荐:Ant Design 5.x + `@tanstack/react-virtual`(仅确认页货件表)**
|
|
|
理由:列表/筛选/Form/Modal 一次齐;确认页用 TanStack Virtual 包 Antd Table 行,避免 Pro 授权;后台信息密度匹配运营场景。
|
|
|
|
|
|
### 1.3 Worker
|
|
|
|
|
|
| 维度 | setInterval 脚本 | BullMQ | PM2 only |
|
|
|
|---|---|---|---|
|
|
|
| 对齐 60s 轮询 | 直接 | 过重 | 仅进程管理 |
|
|
|
| `GET_LOCK` 互斥 | 业务内实现 | 队列锁重复 | 不解决业务锁 |
|
|
|
| compose 复杂度 | 低(第二容器) | 需 Redis | 需宿主机 PM2 |
|
|
|
|
|
|
**唯一推荐:独立 Node 入口 `worker/index.ts` + `setInterval(POLL_INTERVAL_MS)` + MySQL `GET_LOCK('imap_poll', 0)`**
|
|
|
理由:PRD 明确单连接轮询,无任务扇出;不加 Redis;docker 内 `node dist/worker.js` 常驻即可。Compose 用 `restart: unless-stopped`,不用 PM2。
|
|
|
|
|
|
### 1.4 其余锁定
|
|
|
|
|
|
| 项 | 选型 |
|
|
|
|---|---|
|
|
|
| 语言 | TypeScript 5.x strict |
|
|
|
| 运行时 | Node.js 20 LTS |
|
|
|
| Next | 15.x App Router |
|
|
|
| 鉴权(一期) | 自建 session:`iron-session` + 环境变量账号;Cookie HttpOnly |
|
|
|
| HTTP(CC) | `undici`(Node 原生 fetch 封装层 `CcHttpClient`) |
|
|
|
| IMAP | `imapflow` |
|
|
|
| 邮件解析 | `mailparser` |
|
|
|
| xlsx | `exceljs` |
|
|
|
| zip | `adm-zip`(只解一层) |
|
|
|
| 密码 MD5 | `crypto.createHash('md5')` |
|
|
|
| 校验 | `zod`(API 入参 + 解析中间态) |
|
|
|
| 日志 | `pino` |
|
|
|
| 包管理 | `pnpm` |
|
|
|
| 测试 | `vitest`(单元)+ `playwright`(E2E,M4+) |
|
|
|
|
|
|
---
|
|
|
|
|
|
## 2. 系统架构设计
|
|
|
|
|
|
### 2.1 总体架构(文字)
|
|
|
|
|
|
```
|
|
|
imap.qq.com:993
|
|
|
│ SINCE N天(已读+未读) / STORE \Seen
|
|
|
▼
|
|
|
┌────────────────── worker 容器 ──────────────────┐
|
|
|
│ ImapPoller → SnapshotStore → ParsePipeline │
|
|
|
│ │ │ │ │
|
|
|
│ └──────────────┴──────────────┘ │
|
|
|
│ ▼ │
|
|
|
│ Prisma → MySQL │
|
|
|
│ (FETCHED→PARSING→PARSED|PENDING_CONFIRM|…) │
|
|
|
└─────────────────────────────────────────────────┘
|
|
|
▲
|
|
|
│ 读写同一库
|
|
|
┌────────────────── web 容器 ─────────────────────┐
|
|
|
│ Next.js App Router │
|
|
|
│ UI: 列表/详情/确认导入/日志 │
|
|
|
│ Route Handlers: /api/* │
|
|
|
│ ImportService → ConflictCheck → CcAdapter │
|
|
|
│ │ │ │
|
|
|
│ │ ▼ │
|
|
|
│ │ test.saas.carriercentral.vip│
|
|
|
│ │ customerLogin / SaveContainer│
|
|
|
│ │ GetContainerList / ShipLine │
|
|
|
│ └────────── MySQL (status/import/audit) ──┘
|
|
|
└─────────────────────────────────────────────────┘
|
|
|
```
|
|
|
|
|
|
**硬边界:**
|
|
|
- **人工确认路径**(`NEW_CONTAINER`):仅 Web API 在用户点击确认后调用 `SaveContainer`。
|
|
|
- **自动执行路径**(`TRANSFER` / `INSTRUCTION_HOLD_SPLIT` / `INSTRUCTION_LABEL`):允许 worker/executor 调用对应 CC 写接口,受 `ENABLE_AUTO_EXEC_*` + `cc_write_capability`(live|mock|off)+ 审计约束;mock ≠ TEST 验收。
|
|
|
- 禁止对超时/未知态盲重放写接口。
|
|
|
|
|
|
### 2.2 模块划分
|
|
|
|
|
|
| 模块 | 路径 | 输入 | 输出 |
|
|
|
|---|---|---|---|
|
|
|
| IMAP 拉取 | `src/worker` + `src/services/imap` | 设置页 mailbox + IMAP_LOOKBACK_DAYS | `mail_message` FETCHED + 快照/附件 | `mail_message` FETCHED + 快照/附件 |
|
|
|
| 解析引擎 | `src/services/parse` | mail_id | `mail_type` + `parse_result` + 新 status |
|
|
|
| 类型判定 | `src/services/parse/classify.ts` | subject/body/filenames | `MailType` + `type_evidence` |
|
|
|
| 卡派解析 | `src/services/parse/packing-list.ts` | xlsx/csv buffer | header + shipments[] |
|
|
|
| 状态机 | `src/services/state-machine.ts` | from/to/guard | 合法迁移或抛 `IllegalTransition` |
|
|
|
| CC Adapter | `src/services/cc` | 业务 DTO | CC 响应;token 缓存 |
|
|
|
| 确认导入 | `src/services/import` | mail_id + 勾选行 + 柜头 | `container_import` + 终态 |
|
|
|
| 补偿重试 | `src/services/compensation` | compensation id | 重试 SaveContainer |
|
|
|
| 前端 | `src/app/(ops)/*` | — | 运营 UI |
|
|
|
|
|
|
### 2.3 数据流
|
|
|
|
|
|
```
|
|
|
1. ImapPoller.GET_LOCK('imap_poll')
|
|
|
2. SEARCH SINCE N天 → 过滤已入库 UID → FETCH → 写 data/mails/{id}/raw.eml + attachments
|
|
|
3. INSERT mail_message status=FETCHED(幂等:message_id / folder+uid / raw_hash)
|
|
|
4. STORE \Seen(失败仅记日志,下轮补 Seen)
|
|
|
5. status→PARSING → classify + packing-list
|
|
|
6. NEW + valid rows≥1 → PENDING_CONFIRM;否则 PARSED / PARSE_FAILED / REJECTED_VALIDATION
|
|
|
7. 用户 POST /api/mails/:id/import
|
|
|
8. CAS: PENDING_CONFIRM→IMPORTING(version 校验)
|
|
|
9. GetContainerList 冲突检测 → 失败则回滚可编辑态并返回 CONFLICT
|
|
|
10. 按柜 SaveContainer(一柜一请求;货件=勾选行)
|
|
|
11. 全成功 SUCCESS;多柜部分 PARTIAL_SUCCESS;失败 FAILED + import_compensation
|
|
|
```
|
|
|
|
|
|
### 2.4 第三方依赖
|
|
|
|
|
|
| 依赖 | 用途 | 超时 |
|
|
|
|---|---|---|
|
|
|
| `imap.qq.com:993` | 拉取/标已读 | connect 30s / read 60s |
|
|
|
| `https://test.saas.carriercentral.vip/api` | CC | HTTP 60s |
|
|
|
| 本地 `./data` | 快照与附件 | — |
|
|
|
| exceljs / adm-zip | 清单解析 | 单附件 ≤20MB |
|
|
|
|
|
|
---
|
|
|
|
|
|
## 3. 数据库设计
|
|
|
|
|
|
Prisma schema 落地;下列 SQL 语义等同。字符集 `utf8mb4`,引擎 InnoDB。
|
|
|
|
|
|
### 3.1 `mail_message`
|
|
|
|
|
|
```sql
|
|
|
CREATE TABLE mail_message (
|
|
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
|
|
message_id VARCHAR(998) NULL,
|
|
|
imap_uid BIGINT UNSIGNED NULL,
|
|
|
folder VARCHAR(64) NOT NULL DEFAULT 'INBOX',
|
|
|
subject VARCHAR(998) NOT NULL DEFAULT '(无主题)',
|
|
|
from_addr VARCHAR(320) NOT NULL DEFAULT '',
|
|
|
received_at DATETIME(3) NULL,
|
|
|
body_text MEDIUMTEXT NULL,
|
|
|
mail_type VARCHAR(32) NOT NULL DEFAULT 'UNKNOWN',
|
|
|
status VARCHAR(32) NOT NULL DEFAULT 'FETCHED',
|
|
|
type_evidence JSON NULL,
|
|
|
snapshot_path VARCHAR(512) NULL,
|
|
|
raw_hash CHAR(64) NULL,
|
|
|
last_error VARCHAR(1000) NULL,
|
|
|
version INT UNSIGNED NOT NULL DEFAULT 1,
|
|
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
|
|
UNIQUE KEY uk_message_id (message_id),
|
|
|
UNIQUE KEY uk_folder_uid (folder, imap_uid),
|
|
|
UNIQUE KEY uk_raw_hash (raw_hash),
|
|
|
KEY idx_status_received (status, received_at),
|
|
|
KEY idx_mail_type (mail_type)
|
|
|
);
|
|
|
```
|
|
|
|
|
|
`mail_type` 枚举值:`NEW_CONTAINER|TRANSFER|INSTRUCTION_HOLD_SPLIT|INSTRUCTION_LABEL|WORK_ORDER|UNKNOWN`
|
|
|
工单关键词(贴标/拦截/拍照/转仓/快递单号)覆盖为 `WORK_ORDER`;解析产出 `parse_result.mail_record`(提单 `BL_FORECAST` / 工单表);**本期工单不 auto-exec**。
|
|
|
`status` 枚举值:PRD §6.3 全量。
|
|
|
|
|
|
### 3.2 `mail_attachment`
|
|
|
|
|
|
```sql
|
|
|
CREATE TABLE mail_attachment (
|
|
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
|
|
mail_id BIGINT UNSIGNED NOT NULL,
|
|
|
filename VARCHAR(512) NOT NULL,
|
|
|
content_type VARCHAR(128) NULL,
|
|
|
sha256 CHAR(64) NOT NULL,
|
|
|
path VARCHAR(512) NOT NULL,
|
|
|
size INT UNSIGNED NOT NULL,
|
|
|
rejected TINYINT(1) NOT NULL DEFAULT 0,
|
|
|
template_id VARCHAR(64) NULL,
|
|
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
|
KEY idx_mail (mail_id),
|
|
|
CONSTRAINT fk_att_mail FOREIGN KEY (mail_id) REFERENCES mail_message(id)
|
|
|
);
|
|
|
```
|
|
|
|
|
|
### 3.3 `parse_result`
|
|
|
|
|
|
```sql
|
|
|
CREATE TABLE parse_result (
|
|
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
|
|
mail_id BIGINT UNSIGNED NOT NULL,
|
|
|
container_header JSON NOT NULL,
|
|
|
shipments JSON NOT NULL,
|
|
|
lineage JSON NULL,
|
|
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
|
|
UNIQUE KEY uk_mail (mail_id),
|
|
|
CONSTRAINT fk_parse_mail FOREIGN KEY (mail_id) REFERENCES mail_message(id)
|
|
|
);
|
|
|
```
|
|
|
|
|
|
`shipments[]` 元素契约:
|
|
|
|
|
|
```ts
|
|
|
{
|
|
|
row_index: number;
|
|
|
row_status: 'VALID' | 'INVALID';
|
|
|
invalid_reasons?: string[];
|
|
|
warnings?: string[]; // e.g. CHANNEL_UNMAPPED
|
|
|
F_FBACode?: string;
|
|
|
F_Transporter?: string;
|
|
|
F_CTNS?: number;
|
|
|
F_CBM?: number | null;
|
|
|
F_Weight?: number | null;
|
|
|
F_FBAID?: string | null;
|
|
|
F_ReferenceId?: string | null;
|
|
|
F_ShipmentID?: string | null;
|
|
|
F_Address?: string | null;
|
|
|
F_Expected_DeliveryDateB?: string | null; // YYYY-MM-DD
|
|
|
F_Expected_DeliveryDateE?: string | null;
|
|
|
F_Remark?: string | null;
|
|
|
channel_raw?: string;
|
|
|
}
|
|
|
```
|
|
|
|
|
|
`container_header` 契约:含 `F_ContainerNo`, `container_no_valid`, `F_CabinetType`, `F_ETA`, `F_ETD`, `F_BLCopyCode`, `F_Classis`, `F_ShippingLineId`, `F_MemoRemark`, `F_Instruction`, 推荐 `F_TransMode`/`F_OperationType`。
|
|
|
|
|
|
### 3.4 `container_import`
|
|
|
|
|
|
```sql
|
|
|
CREATE TABLE container_import (
|
|
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
|
|
mail_id BIGINT UNSIGNED NOT NULL,
|
|
|
container_no VARCHAR(50) NOT NULL,
|
|
|
external_id VARCHAR(64) NULL,
|
|
|
request_body MEDIUMTEXT NULL,
|
|
|
response_body MEDIUMTEXT NULL,
|
|
|
status VARCHAR(32) NOT NULL, -- PENDING|IMPORTING|SUCCESS|FAILED|CONFLICT|TIMEOUT_UNKNOWN
|
|
|
last_error VARCHAR(1000) NULL,
|
|
|
shipments_hash CHAR(64) NOT NULL,
|
|
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
|
|
KEY idx_mail (mail_id),
|
|
|
KEY idx_container (container_no),
|
|
|
UNIQUE KEY uk_mail_container_shiphash (mail_id, container_no, shipments_hash),
|
|
|
CONSTRAINT fk_imp_mail FOREIGN KEY (mail_id) REFERENCES mail_message(id)
|
|
|
);
|
|
|
```
|
|
|
|
|
|
活跃柜占位(应用层):导入前插入/更新 `status IN ('IMPORTING','SUCCESS')` 同行柜号冲突检查;另建:
|
|
|
|
|
|
```sql
|
|
|
CREATE TABLE container_active_lock (
|
|
|
container_no VARCHAR(50) NOT NULL PRIMARY KEY,
|
|
|
mail_id BIGINT UNSIGNED NOT NULL,
|
|
|
import_id BIGINT UNSIGNED NULL,
|
|
|
locked_at DATETIME(3) NOT NULL
|
|
|
);
|
|
|
```
|
|
|
|
|
|
### 3.5 `import_compensation`
|
|
|
|
|
|
```sql
|
|
|
CREATE TABLE import_compensation (
|
|
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
|
|
import_id BIGINT UNSIGNED NOT NULL,
|
|
|
retry_count INT UNSIGNED NOT NULL DEFAULT 0,
|
|
|
max_retry INT UNSIGNED NOT NULL DEFAULT 3,
|
|
|
next_retry_at DATETIME(3) NULL,
|
|
|
reason VARCHAR(64) NOT NULL,
|
|
|
status VARCHAR(32) NOT NULL DEFAULT 'OPEN', -- OPEN|DONE|EXHAUSTED
|
|
|
retry_token CHAR(36) NOT NULL,
|
|
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
|
|
UNIQUE KEY uk_retry_token (retry_token),
|
|
|
KEY idx_open (status, next_retry_at),
|
|
|
CONSTRAINT fk_comp_imp FOREIGN KEY (import_id) REFERENCES container_import(id)
|
|
|
);
|
|
|
```
|
|
|
|
|
|
### 3.6 `cc_token_cache`
|
|
|
|
|
|
```sql
|
|
|
CREATE TABLE cc_token_cache (
|
|
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
|
|
login_mark CHAR(36) NOT NULL,
|
|
|
token VARCHAR(128) NOT NULL,
|
|
|
expire_at DATETIME(3) NOT NULL,
|
|
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
|
|
UNIQUE KEY uk_login_mark (login_mark)
|
|
|
);
|
|
|
```
|
|
|
|
|
|
### 3.7 `audit_log`(**一期不做 / 表已移除**)
|
|
|
|
|
|
> 现行口径:不落库、无 Admin 查询页。以下 DDL 仅作历史参考。
|
|
|
|
|
|
### 3.7.1 (历史)`audit_log`
|
|
|
|
|
|
```sql
|
|
|
CREATE TABLE audit_log (
|
|
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
|
|
actor VARCHAR(64) NOT NULL,
|
|
|
action VARCHAR(64) NOT NULL,
|
|
|
mail_id BIGINT UNSIGNED NULL,
|
|
|
payload JSON NULL,
|
|
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
|
KEY idx_mail_time (mail_id, created_at)
|
|
|
);
|
|
|
```
|
|
|
|
|
|
### 3.8 其余
|
|
|
|
|
|
```sql
|
|
|
CREATE TABLE sender_customer_map (
|
|
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
|
|
match_type VARCHAR(32) NOT NULL, -- EMAIL|DOMAIN|SUBJECT|ATTACHMENT
|
|
|
match_value VARCHAR(320) NOT NULL,
|
|
|
customer_code VARCHAR(64) NOT NULL,
|
|
|
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
|
|
UNIQUE KEY uk_match (match_type, match_value)
|
|
|
);
|
|
|
|
|
|
CREATE TABLE app_user (
|
|
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
|
|
username VARCHAR(64) NOT NULL UNIQUE,
|
|
|
password_hash VARCHAR(128) NOT NULL,
|
|
|
role VARCHAR(16) NOT NULL DEFAULT 'ops', -- ops|admin
|
|
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
|
|
);
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 4. API 设计
|
|
|
|
|
|
Base:`/api`。鉴权:未登录 → `401`。成功包络:
|
|
|
|
|
|
```ts
|
|
|
{ ok: true, data: T }
|
|
|
{ ok: false, error: { code: string, message: string, details?: unknown } }
|
|
|
```
|
|
|
|
|
|
### 4.1 认证
|
|
|
|
|
|
| 方法 | 路径 | 入参 | 出参 | 错误 |
|
|
|
|---|---|---|---|---|
|
|
|
| POST | `/api/auth/login` | `{username,password}` | `{role}` + Set-Cookie | `401 INVALID_CREDENTIALS` |
|
|
|
| POST | `/api/auth/logout` | — | `{ok:true}` | — |
|
|
|
| GET | `/api/auth/me` | — | `{username,role}` | `401` |
|
|
|
|
|
|
### 4.2 邮件列表
|
|
|
|
|
|
| 方法 | 路径 | 入参 | 出参 |
|
|
|
|---|---|---|---|
|
|
|
| GET | `/api/mails` | query: `page=1&pageSize=20&mail_type=&status=&q=` | `{items: MailListItem[], total}` |
|
|
|
|
|
|
`MailListItem`:`id,subject,from_addr,received_at,mail_type,status,container_no?`
|
|
|
|
|
|
错误:`400 VALIDATION`
|
|
|
|
|
|
### 4.3 邮件详情
|
|
|
|
|
|
| 方法 | 路径 | 出参 |
|
|
|
|---|---|---|
|
|
|
| GET | `/api/mails/:id` | `MailDetail`:邮件字段 + `type_evidence` + `parse_result` + `attachments[]` + `imports[]` |
|
|
|
| GET | `/api/mails/:id/attachments/:attId/download` | file stream |
|
|
|
|
|
|
错误:`404 NOT_FOUND`
|
|
|
|
|
|
### 4.4 确认导入
|
|
|
|
|
|
| 方法 | 路径 | 入参 | 出参 |
|
|
|
|---|---|---|---|
|
|
|
| POST | `/api/mails/:id/import` | 见下 | `{mail_status, imports[], accepted_count}` |
|
|
|
|
|
|
```ts
|
|
|
// body
|
|
|
{
|
|
|
version: number;
|
|
|
idempotency_key: string; // mail_id:container_no:shipments_hash 或整单 UUID
|
|
|
F_TransMode: 0|1|3;
|
|
|
F_OperationType: 0|2|4;
|
|
|
container_header: { /* 可覆盖字段 */ };
|
|
|
selected_row_indexes: number[];
|
|
|
ack_unmapped_channels?: boolean;
|
|
|
force_skip_conflict?: boolean; // 需 admin + ENABLE_FORCE_IMPORT
|
|
|
}
|
|
|
```
|
|
|
|
|
|
| 错误码 | 含义 |
|
|
|
|---|---|
|
|
|
| `409 ALREADY_IMPORTING` | CAS 失败 |
|
|
|
| `409 VERSION_CONFLICT` | version 不匹配 |
|
|
|
| `400 ACK_UNMAPPED_REQUIRED` | 未确认未映射渠道 |
|
|
|
| `409 CONFLICT_CONTAINER` | GetContainerList / 本地锁冲突 |
|
|
|
| `503 CONFLICT_CHECK_FAILED` | 列表接口失败且未强跳 |
|
|
|
| `429 RATE_LIMITED` | >10/min |
|
|
|
| `403 FORBIDDEN` | 非 NEW / 无权限强跳 |
|
|
|
| `400 INVALID_ROWS` | 勾选含 INVALID |
|
|
|
|
|
|
限流:每用户 10 次/分钟(内存滑动窗或 DB)。
|
|
|
|
|
|
### 4.5 Admin 改类型
|
|
|
|
|
|
| 方法 | 路径 | 入参 | 条件 |
|
|
|
|---|---|---|---|
|
|
|
| POST | `/api/mails/:id/type` | `{version, mail_type, reason}` | `role=admin` 且 `ENABLE_TYPE_OVERRIDE=true` |
|
|
|
|
|
|
成功:更新 `mail_type`;若 NEW 且 valid≥1 → `PENDING_CONFIRM`;写 `audit_log action=TYPE_OVERRIDE`。
|
|
|
错误:`403` / `409 MAIL_BUSY` / `409 VERSION_CONFLICT` / `409 IMMUTABLE_STATUS`
|
|
|
|
|
|
### 4.6 重新解析
|
|
|
|
|
|
| 方法 | 路径 | 条件 |
|
|
|
|---|---|---|
|
|
|
| POST | `/api/mails/:id/reparse` | status ∉ `IMPORTING|SUCCESS|PARTIAL_SUCCESS` |
|
|
|
|
|
|
行为:status→`PARSING`;worker 或 web 内同步调用 `ParsePipeline`(一期 **web 同步执行解析** 亦可;生产可投递 DB 标志位由 worker 捞——**一期锁定:API 进程内直接跑 ParsePipeline**,与 worker 共用 `src/services/parse`)。
|
|
|
错误:`409 ILLEGAL_TRANSITION`
|
|
|
|
|
|
### 4.7 补偿重试
|
|
|
|
|
|
| 方法 | 路径 | 入参 |
|
|
|
|---|---|---|
|
|
|
| GET | `/api/imports` | `page,status,mail_id` |
|
|
|
| POST | `/api/imports/:importId/retry` | `{retry_token?}` |
|
|
|
| POST | `/api/compensations/:id/retry` | — admin 可重置 exhausted |
|
|
|
|
|
|
行为:锁 `container_import`;SUCCESS 跳过;核对超时单;再 SaveContainer。
|
|
|
错误:`409` / `400 EXHAUSTED`
|
|
|
|
|
|
### 4.8 忽略邮件
|
|
|
|
|
|
| 方法 | 路径 |
|
|
|
|---|---|
|
|
|
| POST | `/api/mails/:id/ignore` | admin → `IGNORED` + audit |
|
|
|
|
|
|
### 4.9 健康检查
|
|
|
|
|
|
| GET `/api/health` | `{mysql:ok, imap_lock?:string}` | 无鉴权 |
|
|
|
|
|
|
---
|
|
|
|
|
|
## 5. 项目工程结构
|
|
|
|
|
|
```
|
|
|
email-forecast/
|
|
|
├── docker-compose.yml # web + worker + mysql
|
|
|
├── Dockerfile # 同一镜像,不同 CMD
|
|
|
├── .env.example
|
|
|
├── package.json # pnpm workspaces 可不用,单 package
|
|
|
├── prisma/
|
|
|
│ ├── schema.prisma
|
|
|
│ └── seed.ts # 仅 AppUser(admin/ops)
|
|
|
├── data/ # volume 挂载(gitignore)
|
|
|
├── docx/ # 需求/设计(已有,不进镜像必填)
|
|
|
├── src/
|
|
|
│ ├── app/
|
|
|
│ │ ├── (auth)/login/page.tsx
|
|
|
│ │ ├── (ops)/
|
|
|
│ │ │ ├── layout.tsx
|
|
|
│ │ │ ├── mails/page.tsx # 列表
|
|
|
│ │ │ ├── mails/[id]/page.tsx # 详情
|
|
|
│ │ │ ├── mails/[id]/import/page.tsx # 确认导入
|
|
|
│ │ │ └── imports/page.tsx # 日志/补偿
|
|
|
│ │ ├── api/... # Route Handlers
|
|
|
│ │ └── layout.tsx
|
|
|
│ ├── components/
|
|
|
│ │ ├── MailTable.tsx
|
|
|
│ │ ├── ShipmentVirtualTable.tsx # antd + tanstack virtual
|
|
|
│ │ └── StatusTag.tsx
|
|
|
│ ├── services/
|
|
|
│ │ ├── db.ts # PrismaClient 单例
|
|
|
│ │ ├── state-machine.ts
|
|
|
│ │ ├── imap/
|
|
|
│ │ │ ├── client.ts
|
|
|
│ │ │ └── poller.ts
|
|
|
│ │ ├── parse/
|
|
|
│ │ │ ├── pipeline.ts
|
|
|
│ │ │ ├── classify.ts
|
|
|
│ │ │ ├── packing-list.ts
|
|
|
│ │ │ └── channel-map.ts
|
|
|
│ │ ├── cc/
|
|
|
│ │ │ ├── http.ts
|
|
|
│ │ │ ├── auth.ts # token DB+memory
|
|
|
│ │ │ ├── save-container.ts
|
|
|
│ │ │ └── conflict.ts
|
|
|
│ │ ├── import/
|
|
|
│ │ │ ├── confirm.ts
|
|
|
│ │ │ └── compensation.ts
|
|
|
│ │ └── audit.ts
|
|
|
│ ├── types/
|
|
|
│ │ ├── mail.ts # MailStatus, MailType
|
|
|
│ │ ├── parse.ts
|
|
|
│ │ └── cc.ts
|
|
|
│ ├── utils/
|
|
|
│ │ ├── hash.ts
|
|
|
│ │ ├── iso6346.ts
|
|
|
│ │ └── dates.ts # Asia/Shanghai
|
|
|
│ └── worker/
|
|
|
│ └── index.ts # setInterval 入口
|
|
|
├── tests/
|
|
|
│ ├── unit/
|
|
|
│ └── e2e/
|
|
|
└── README.md
|
|
|
```
|
|
|
|
|
|
### docker-compose 对应
|
|
|
|
|
|
| 服务 | 镜像/构建 | 命令 | 职责 |
|
|
|
|---|---|---|---|
|
|
|
| `mysql` | `mysql:8.0` | 官方入口 | 库 `email_forecast` |
|
|
|
| `web` | 本 Dockerfile | `pnpm start`(`next start`) | UI + `/api` + 导入/CC |
|
|
|
| `worker` | 同镜像 | `node dist/worker/index.js` 或 `tsx src/worker/index.ts` | 仅 IMAP+解析+PARSING 回收 |
|
|
|
|
|
|
共享:`./data` volume、同一 `DATABASE_URL`、同一代码树 `src/services/*`。
|
|
|
|
|
|
`.env` 键名严格对齐 PRD §9.1:`CC_SAAS_HEADER`、`POLL_INTERVAL_MS`、`ENABLE_TYPE_OVERRIDE` 等。
|
|
|
Worker 轮询基线优先读 `imap_settings.poll_interval_ms`(设置页,默认 30min);过滤规则见 `imap_filter_rule`;每封写 `imap_pull_log`(≤1000)。
|
|
|
|
|
|
---
|
|
|
|
|
|
## 6. MVP 实现路径(PRD §10)
|
|
|
|
|
|
| 里程碑 | 任务拆分(可直接建 Jira) | DoD |
|
|
|
|---|---|---|
|
|
|
| **M0** | 初始化 Next15+Prisma+Antd;`docker-compose.yml`;`.env.example`;`/api/health`;登录页壳 | `docker compose up` 打开登录页;migrate 成功 |
|
|
|
| **M1** | Prisma 全表;`seed.ts` 写入 M1–M4 元数据;列表+详情只读;状态/类型 Tag | 列表 4 条;类型与金样例一致 |
|
|
|
| **M2** | `classify.ts` 规则;`packing-list.ts` 吃邮件2 xlsx;详情展示 327 行虚拟表;种子挂附件路径 | shipments 行数=327;核心列断言通过 vitest |
|
|
|
| **M3** | `CcAdapter` login/token;SaveContainer mock 单测;可选 TEST 烟雾脚本 `scripts/cc-smoke.ts` | mock `code=200`;TEST 有账号则 1 柜成功 |
|
|
|
| **M4** | 确认导入页;CAS+幂等;冲突检测;补偿表+重试 API;Admin 改类型 | 勾选 2 行 → payload 2 货件;冲突阻断;双击不双写 |
|
|
|
| **M5** | GetShippingLineList 下拉;`/logs` 导入+拉取;README;回收定时;**无审计页** | 可演示全流程 | 可演示全流程 |
|
|
|
|
|
|
**Cursor 编码顺序:** 严格 M0→M5;每里程碑只改该阶段文件。
|
|
|
|
|
|
---
|
|
|
|
|
|
## 7. 风险与工程约束
|
|
|
|
|
|
| 项 | 约束 |
|
|
|
|---|---|
|
|
|
| 附件 | 单文件 ≤20MB;zip 只解一层;路径禁止 `..`;落盘 `data/mails/{mailId}/` |
|
|
|
| IMAP 锁 | 每轮 `SELECT GET_LOCK('imap_poll',0)`;`finally RELEASE_LOCK`;拿不到锁直接 `return` |
|
|
|
| Token | 读路径:DB `expire_at` 权威 → 内存缓存;写路径:`GET_LOCK('cc_login',5)` 后 login 写 DB |
|
|
|
| 虚拟滚动 | `ShipmentVirtualTable` 固定行高 48px;只渲染可视区±10 行;禁止 Antd Table 默认全量 DOM |
|
|
|
| CC 超时 | 禁止盲重放;`TIMEOUT_UNKNOWN` → 补偿 + GetContainerList 核对 |
|
|
|
| 状态机 | 仅 `state-machine.ts` 允许改 `status`;API 禁止通用 PATCH status |
|
|
|
| 韧性条文 | 实现错误处理对照 `docx/异常场景与系统韧性设计.md` 全文 |
|
|
|
|
|
|
---
|
|
|
|
|
|
## 8. 核心模块 I/O 契约(Cursor 直接实现)
|
|
|
|
|
|
### 8.1 `ParsePipeline.run(mailId: bigint)`
|
|
|
|
|
|
- 读 `mail_message` + attachments
|
|
|
- 写:`type_evidence`, `mail_type`, `parse_result`, `status`, `last_error`
|
|
|
- 副作用:无 CC 调用
|
|
|
|
|
|
### 8.2 `ImapPoller.tick()`
|
|
|
|
|
|
- 锁 → fetch → upsert mail → seen → 对每个新 id 调 `ParsePipeline`
|
|
|
- 另:回收 `PARSING` 超时、`IMPORTING` 超时扫描(只改状态/写补偿,不调 CC)
|
|
|
|
|
|
### 8.3 `ConfirmImport.execute(input)`
|
|
|
|
|
|
- 校验 NEW + version + 勾选
|
|
|
- CAS status
|
|
|
- conflict check
|
|
|
- 逐柜 `SaveContainer`
|
|
|
- 更新 mail 终态 + audit
|
|
|
|
|
|
### 8.4 `CcAuth.getToken()`
|
|
|
|
|
|
- 返回 `{token, loginMark}`;410 路径由 `CcHttpClient.request` 触发 `refresh+replay once`
|
|
|
|
|
|
---
|
|
|
|
|
|
## 9. docker-compose 骨架(落地原文)
|
|
|
|
|
|
```yaml
|
|
|
services:
|
|
|
mysql:
|
|
|
image: mysql:8.0
|
|
|
environment:
|
|
|
MYSQL_DATABASE: email_forecast
|
|
|
MYSQL_USER: app
|
|
|
MYSQL_PASSWORD: app
|
|
|
MYSQL_ROOT_PASSWORD: root
|
|
|
ports: ["7023:3306"] # 本地避让宿主机 3306
|
|
|
volumes: ["mysql_data:/var/lib/mysql"]
|
|
|
command: ["--default-authentication-plugin=mysql_native_password","--character-set-server=utf8mb4"]
|
|
|
web:
|
|
|
build: .
|
|
|
command: ["pnpm","start"]
|
|
|
ports: ["3100:3100"]
|
|
|
env_file: [.env]
|
|
|
volumes: ["./data:/app/data"]
|
|
|
depends_on: [mysql]
|
|
|
worker:
|
|
|
build: .
|
|
|
command: ["node","dist/worker/index.js"]
|
|
|
env_file: [.env]
|
|
|
volumes: ["./data:/app/data"]
|
|
|
depends_on: [mysql]
|
|
|
volumes:
|
|
|
mysql_data:
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 10. 文档索引
|
|
|
|
|
|
| 文档 | 路径 |
|
|
|
|---|---|
|
|
|
| PRD | `docx/需求规格-邮件自动预报-v0.2.md` |
|
|
|
| 韧性 | `docx/异常场景与系统韧性设计.md` |
|
|
|
| 本设计 | `docx/技术设计方案-邮件自动预报-v1.0.md` |
|
|
|
| 实施计划 | `.cursor/rules/implementation-plan.mdc` |
|
|
|
|
|
|
---
|
|
|
|
|
|
## 附录 A — CC Write API 矩阵(一期扩范围)
|
|
|
|
|
|
| capability id | 默认 mode | 拟定 endpoint | 触发 mail_type | 完成标准 |
|
|
|
|---|---|---|---|---|
|
|
|
| `save_container` | live | `/Container/SaveContainer` | NEW_CONTAINER(人工确认) | 已有 |
|
|
|
| `transfer` | mock | `/Container/TransferWarehouse` | TRANSFER | live 真接通 + 烟雾 1 次 |
|
|
|
| `hold_split` | mock | `/Container/HoldSplitInstruction` | INSTRUCTION_HOLD_SPLIT | 同上 |
|
|
|
| `label` | mock | `/Container/ApplyLabelInstruction` | INSTRUCTION_LABEL | 同上 |
|
|
|
|
|
|
表 `cc_write_capability` 可改 mode:`live|mock|off`。`CC_MOCK=true` 时全局按 mock。mock ≠ TEST 验收。
|