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