You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
mail-yubao/docx/技术设计方案-邮件自动预报-v1.0.md

651 lines
26 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 技术设计方案 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 验收。