Ship deploy pack and product updates for clone-and-run.

Adds Baota/Docker deploy scripts, fixtures/test workbench, customer accounts, and parse/IMAP hardening so others can pull main and deploy without local artifacts.

Co-authored-by: Cursor <cursoragent@cursor.com>
main
你的GitHub用户名 1 month ago
parent 7214d32627
commit 627d1b7be8

@ -11,3 +11,9 @@ data
coverage
playwright-report
test-results
deploy/*.tar
上传到服务器
fixtures
docx
docs
.git

@ -16,6 +16,12 @@ ENABLE_ISO_CHECK=true
# 然后:pnpm exec tsx scripts/apply-demovip-cc.ts
# 一期 PRD 默认仍是 test.saas + Saas:TEST(有正式 TEST 账号时改回)
CC_MOCK=true
# 测试基础设施(与 CC_MOCK 正交):Mock IMAP + 可故障注入的 MockTargetAPI。生产勿开。
# TEST_MODE=1
# 生产可观测:连续 IMAP 失败 / CC 鉴权过期 POST JSON(空则只写日志)
# ALERT_WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=
# ALERT_WEBHOOK_TIMEOUT_MS=5000
IMAP_HOST=imap.qq.com
IMAP_PORT=993
@ -47,6 +53,10 @@ IMAP_READ_TIMEOUT_MS=60000
IMAP_LOOKBACK_DAYS=3
IMAP_MAX_FETCH_PER_TICK=100
IMAP_IDLE_ENABLED=true
# 国内访问 Gmail:填本机代理(Clash 混合端口多为 http://127.0.0.1:7890,SOCKS 为 socks5://127.0.0.1:7891)
# 默认只代理 Gmail/Outlook;QQ/163 仍直连。全部邮箱走代理则 IMAP_PROXY_HOSTS=*
# IMAP_PROXY=socks5://127.0.0.1:7891
# IMAP_PROXY_HOSTS=gmail,google,outlook,office365,hotmail,live,msn
# OCR:off | local | aliyun
OCR_PROVIDER=local

3
.gitattributes vendored

@ -0,0 +1,3 @@
*.sh text eol=lf
*.yml text eol=lf
Dockerfile text eol=lf

7
.gitignore vendored

@ -14,6 +14,13 @@ coverage
data/*
!data/.gitkeep
# local pack / oversized artifacts (not for clone deploy)
上传到服务器/
deploy/*.tar
deploy/*.tar.gz
.tmp-ingest*
.tmp-*
# local test records / smoke reports
*.log
data/logs/

@ -1,21 +1,26 @@
FROM node:20-bookworm AS deps
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
RUN npm install -g pnpm@9.15.0
COPY package.json pnpm-lock.yaml* ./
COPY prisma ./prisma
RUN pnpm install --frozen-lockfile || pnpm install
FROM node:20-bookworm AS builder
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
RUN npm install -g pnpm@9.15.0
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# next build 预渲染会读取 env;构建期占位,运行时由 .env 覆盖
ENV DATABASE_URL=mysql://build:build@127.0.0.1:3306/build
ENV SESSION_SECRET=build-time-placeholder-secret-at-least-32-chars
ENV APP_ADMIN_PASS=build-admin-pass-not-used-at-runtime
ENV APP_OPS_PASS=build-ops-pass-not-used-at-runtime
RUN pnpm db:generate && pnpm build
FROM node:20-bookworm AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
RUN npm install -g pnpm@9.15.0
COPY --from=builder /app/package.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
@ -26,6 +31,8 @@ COPY --from=builder /app/src ./src
COPY --from=builder /app/scripts ./scripts
COPY --from=builder /app/tsconfig.json ./tsconfig.json
COPY --from=builder /app/tsconfig.worker.json ./tsconfig.worker.json
RUN mkdir -p /app/data && chmod +x /app/scripts/docker-entrypoint-web.sh
EXPOSE 3000
RUN mkdir -p /app/data \
&& sed -i 's/\r$//' /app/scripts/*.sh \
&& chmod +x /app/scripts/docker-entrypoint-web.sh
EXPOSE 3100
CMD ["pnpm", "start"]

@ -70,9 +70,32 @@ pnpm worker # 常驻轮询
## 测试
```bash
pnpm test # vitest 单元/集成
docker compose up -d mysql
pnpm exec prisma db push # 含 trace_id 列时需推一次
pnpm test:all # 一条命令:vitest + 覆盖率(coverage/)
pnpm test # 仅 vitest(含 integration,需 MySQL)
pnpm test:e2e # playwright(需先起 dev)
pnpm test:e2e:test-mode # TEST_MODE Playwright(端口 3110)
```
覆盖率报告:`coverage/coverage-summary.json` 与终端 `text-summary`。
TEST_MODE=1 时可用 `/test-workbench` 注入样例、切故障、看 Mock 请求。
生产告警:配置 `ALERT_WEBHOOK_URL` 后,IMAP 连续失败 ≥3 或 CC 鉴权/登录失败会 POST JSON(15 分钟去重)。未配置时只写日志。
### 已知问题与建议
| 项 | 现状 | 建议 |
|---|---|---|
| 关键字未命中 | `IGNORED` / `SKIP_FILTER`,不进业务列表 | 属过滤设计;已拉取但分类 `UNKNOWN` 会以 `PARSED` 出现在列表供人工处理 |
| IMAP SEARCH/FETCH | 瞬时超时会重试 3 次;鉴权失败不重试(走连续失败退避) | 保持 |
| `markSeen` | 处理失败且尚未落库时不再标已读,便于 lookback 重试 | 已按 UID 入库的仍标已读(DB 幂等) |
| 部分成功(CC 对象 data) | 仍作 `TIMEOUT_UNKNOWN` 进补偿 | 与「缺 external id」同路径,避免误标成功 |
| 工单/DO/转仓 5xx | 邮件 `FAILED`,未统一进预报补偿表 | 一键可从详情重试;若要对齐预报补偿可再抽公共队列 |
| 既有单测漂移 | `mail-intent` / `extract-header` / `four-business` / `work-order-dedupe` 4 项与当前解析文案/字段不一致 | 与本次可观测性无关;建议单独立项对齐 expected |
```bash
# CC
pnpm cc:smoke # 跟随 .env(默认 mock 假成功,非 TEST 验收)
pnpm cc:smoke -- --mode=mock # 强制 mock

@ -0,0 +1,89 @@
# =============================================================================
# 宝塔部署模板 — 192.168.2.14 + MySQL(email_forecast)
# 首次部署:cp deploy/.env.bt-host-mysql.example .env 再 bash deploy/install-bt-host-mysql.sh
# 更新代码:勿覆盖服务器已有 .env(上传包不含根目录 .env)
# =============================================================================
APP_PORT=30327
PUBLIC_HOST=192.168.2.14
MYSQL_USER=yubao
MYSQL_PASSWORD=CHANGE_ME_STRONG_PASSWORD
MYSQL_HOST=host.docker.internal
MYSQL_PORT=3306
MYSQL_DATABASE=yubao
DATABASE_URL=mysql://yubao:CHANGE_ME_STRONG_PASSWORD@host.docker.internal:3306/yubao
# 生产必须替换;弱口令会导致 NODE_ENV=production 拒绝启动
APP_ADMIN_USER=admin
APP_ADMIN_PASS=CHANGE_ME_admin_pass
APP_OPS_USER=ops
APP_OPS_PASS=CHANGE_ME_ops_pass
# 内网 HTTP 访问必须 false;仅在全站 HTTPS 时改为 true
COOKIE_SECURE=false
# CC(推荐上线后在 Admin「设置 → CarrierCentral」配置,DB 优先于 .env)
CC_MOCK=true
CC_API_BASE=https://test.saas.carriercentral.vip/api
CC_SAAS_HEADER=TEST
CC_LOGIN_MARK=11111111-2222-3333-4444-555555555555
CC_USERNAME=
CC_PASSWORD_PLAIN=
# IMAP 可选回退;生产推荐 Admin「设置 → 邮箱绑定」
IMAP_HOST=imap.qq.com
IMAP_PORT=993
IMAP_USER=
IMAP_PASS=
POLL_INTERVAL_MS=1800000
DATA_RETENTION_DAYS=30
ENABLE_TYPE_OVERRIDE=true
ENABLE_FORCE_IMPORT=false
ENABLE_ISO_CHECK=true
CC_HTTP_TIMEOUT_MS=60000
CC_READ_TIMEOUT_MS=8000
IMAP_CONNECT_TIMEOUT_MS=30000
IMAP_READ_TIMEOUT_MS=60000
IMAP_LOOKBACK_DAYS=3
IMAP_MAX_FETCH_PER_TICK=100
IMAP_IDLE_ENABLED=true
# OAuth 回调基址须改为实际访问地址
OAUTH_PUBLIC_BASE_URL=http://192.168.2.14:30327
OAUTH_GOOGLE_CLIENT_ID=
OAUTH_GOOGLE_CLIENT_SECRET=
OAUTH_MS_CLIENT_ID=
OAUTH_MS_CLIENT_SECRET=
OAUTH_MS_TENANT=common
OCR_PROVIDER=local
OCR_MIN_CONFIDENCE=40
ENABLE_AUTO_EXEC_TRANSFER=true
ENABLE_AUTO_EXEC_HOLD_SPLIT=true
ENABLE_AUTO_EXEC_LABEL=true
AUTO_EXEC_REQUIRE_CONFIRM=false
PARSING_STALE_MS=600000
IMPORTING_TIMEOUT_MS=180000
COMPENSATION_MAX_RETRY=3
SHIPMENT_ROW_SOFT_LIMIT=1000
SHIPMENT_ROW_HARD_LIMIT=5000
ATTACHMENT_MAX_BYTES=20971520
# 首次启动自动 db push + seed 账号
SEED_ON_START=true
# 生产勿开
# TEST_MODE=1
# 可选告警
# ALERT_WEBHOOK_URL=
# ALERT_WEBHOOK_TIMEOUT_MS=5000
# 国内服务器访问 Gmail 时可配置代理(Worker 容器内)
# IMAP_PROXY=socks5://host.docker.internal:7891
# IMAP_PROXY_HOSTS=gmail,google,outlook,office365,hotmail,live,msn

@ -0,0 +1,44 @@
# Build Linux image on dev machine and export tar (Option C)
# Usage: powershell -ExecutionPolicy Bypass -File deploy/build-image-local.ps1
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
$ImageTag = "yubao-app:latest"
$OutTar = Join-Path $Root "deploy\yubao-app-image.tar"
$Platform = "linux/amd64"
Set-Location $Root
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
throw "docker not found - start Docker Desktop first"
}
docker info *> $null
if ($LASTEXITCODE -ne 0) {
throw "Docker is not running"
}
Write-Host "build: platform=$Platform tag=$ImageTag"
Write-Host "build: Next.js compile may take 5-15 minutes..."
$env:DOCKER_BUILDKIT = '1'
& docker build --platform $Platform -t $ImageTag -f Dockerfile .
if ($LASTEXITCODE -ne 0) {
throw "docker build failed"
}
if (Test-Path $OutTar) {
Remove-Item -Force $OutTar
}
Write-Host "build: export -> $OutTar"
& docker save $ImageTag -o $OutTar
if ($LASTEXITCODE -ne 0) {
throw "docker save failed"
}
$sizeMb = [math]::Round((Get-Item $OutTar).Length / 1MB, 1)
Write-Host ""
Write-Host "build: done size=${sizeMb}MB"
Write-Host "upload deploy/yubao-app-image.tar to /home/project/yubao/deploy/"
Write-Host "then on server: bash deploy/install-prebuilt-image.sh"

@ -0,0 +1,63 @@
# 数据库导入说明(宝塔面板)
## 文件
| 文件 | 说明 |
|------|------|
| `email_forecast.sql` | **推荐导入**:utf8mb4 / utf8mb4_unicode_ci,含本机完整数据 |
| `email_forecast.sql.gz` | 压缩版(二选一即可) |
## 建库时必须与 SQL 一致
宝塔 → 数据库 → 添加数据库:
| 项 | 必须选 |
|----|--------|
| 数据库名 | `email_forecast` |
| 字符集 | **utf8mb4** |
| 校对规则 / 排序规则 | **utf8mb4_unicode_ci**(不要选 general_ci / 0900_ai_ci) |
若建库时选了别的校对规则,导入时会出现「字符集不一致可能导致恢复失败」。
### 已建错库时的修正
在宝塔 phpMyAdmin 或 MySQL 终端执行:
```sql
ALTER DATABASE email_forecast
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
```
然后再导入本 SQL。
## 导入步骤
1. 确认库为 **utf8mb4 + utf8mb4_unicode_ci**
2. 创建用户 **`email_forecast`**,授权主机 **`172.%`**(Docker 访问)
3. 进入该库 → **导入** → 上传 **`email_forecast.sql`**
4. 等待成功
SQL 文件开头已含:
```sql
SET NAMES utf8mb4;
SET CHARACTER SET utf8mb4;
```
与查价项目导入方式一致。
## 导入后连接配置
```env
DATABASE_URL=mysql://email_forecast:密码@host.docker.internal:3306/email_forecast
```
## 与 Docker 内置 MySQL 二选一
| 方式 | 说明 |
|------|------|
| **宝塔 MySQL(生产)** | 导入本 SQL;使用 `deploy/docker-compose.bt-host-mysql.yml` |
| **Docker MySQL(本地)** | 根目录 `docker-compose.yml`,端口 7023 |
不要同时连两个库。

File diff suppressed because one or more lines are too long

@ -0,0 +1,66 @@
#!/usr/bin/env bash
# 从本机 MySQL 导出完整数据
# 用法:bash deploy/database/export-local.sh
# 依赖:mysqldump、项目根目录 .env(默认 localhost:7023)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
OUT_DIR="${ROOT}/deploy/database"
STAMP="$(date +%Y%m%d-%H%M%S)"
OUT_FILE="${OUT_DIR}/email_forecast-full-${STAMP}.sql"
if [[ ! -f "${ROOT}/.env" ]]; then
echo "缺少 ${ROOT}/.env"
exit 1
fi
read_env() {
local key="$1"
local line
line="$(grep -E "^[[:space:]]*${key}=" "${ROOT}/.env" 2>/dev/null | tail -1 || true)"
[[ -z "$line" ]] && return
line="${line#*=}"
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
echo "$line"
}
DATABASE_URL="$(read_env DATABASE_URL)"
if [[ -z "$DATABASE_URL" ]]; then
echo ".env 中未设置 DATABASE_URL"
exit 1
fi
if ! command -v mysqldump >/dev/null 2>&1; then
echo "未找到 mysqldump,请安装 MySQL 客户端"
exit 1
fi
if [[ ! "$DATABASE_URL" =~ mysql://([^:]+):([^@]+)@([^:/]+):?([0-9]*)/([^?]+) ]]; then
echo "无法解析 DATABASE_URL: $DATABASE_URL"
exit 1
fi
DB_USER="${BASH_REMATCH[1]}"
DB_PASS="${BASH_REMATCH[2]}"
DB_HOST="${BASH_REMATCH[3]}"
DB_PORT="${BASH_REMATCH[4]:-3306}"
DB_NAME="${BASH_REMATCH[5]}"
mkdir -p "$OUT_DIR"
mysqldump \
-h "$DB_HOST" \
-P "$DB_PORT" \
-u "$DB_USER" \
-p"$DB_PASS" \
--default-character-set=utf8mb4 \
--single-transaction \
--routines \
--triggers \
--set-gtid-purged=OFF \
"$DB_NAME" > "$OUT_FILE"
gzip -kf "$OUT_FILE"
echo "已导出: ${OUT_FILE}"
echo "已压缩: ${OUT_FILE}.gz"

@ -0,0 +1,66 @@
# 邮件预报 — 宝塔宿主机 MySQL + Docker 应用(无容器 MySQL)
# 服务器示例:192.168.2.14 代码目录:/home/project/yubao 对外端口:30327
#
# 用法(在项目根目录 /home/project/yubao):
# cp deploy/.env.bt-host-mysql.example .env # 若根目录尚无 .env
# bash deploy/install-bt-host-mysql.sh
name: yx-bt
services:
web:
build:
context: ..
dockerfile: Dockerfile
image: yubao-app:latest
restart: unless-stopped
command: ["sh", "/app/scripts/docker-entrypoint-web.sh"]
env_file:
- ../.env
environment:
NODE_ENV: production
DATABASE_URL: ${DATABASE_URL}
SEED_ON_START: ${SEED_ON_START:-true}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- ../data:/app/data
ports:
- "0.0.0.0:${APP_PORT:-30327}:3100"
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3100/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 10s
retries: 3
start_period: 90s
networks:
- internal
worker:
image: yubao-app:latest
restart: unless-stopped
command: ["pnpm", "exec", "tsx", "src/worker/index.ts"]
env_file:
- ../.env
environment:
NODE_ENV: production
DATABASE_URL: ${DATABASE_URL}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- ../data:/app/data
depends_on:
web:
condition: service_started
networks:
- internal
networks:
internal:
driver: bridge

@ -0,0 +1,62 @@
# 邮件预报 — 预构建镜像部署(服务器不编译,省内存)
# 镜像须先在开发机 build 并 docker load
# 用法:bash deploy/install-prebuilt-image.sh [镜像tar路径]
name: yx-bt-prebuilt
services:
web:
image: yubao-app:latest
pull_policy: never
restart: unless-stopped
# 不走 entrypoint:db push 已由 install-prebuilt-image.sh 完成,避免 Windows CRLF 导致 set -e 失败
command: ["pnpm", "start"]
env_file:
- ../.env
environment:
NODE_ENV: production
COOKIE_SECURE: ${COOKIE_SECURE:-false}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- ../data:/app/data
ports:
- "0.0.0.0:${APP_PORT:-30327}:3100"
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3100/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 10s
retries: 3
start_period: 90s
networks:
- internal
worker:
image: yubao-app:latest
pull_policy: never
restart: unless-stopped
command: ["pnpm", "exec", "tsx", "src/worker/index.ts"]
env_file:
- ../.env
environment:
NODE_ENV: production
DATABASE_URL: ${DATABASE_URL}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- ../data:/app/data
depends_on:
web:
condition: service_started
networks:
- internal
networks:
internal:
driver: bridge

@ -0,0 +1,61 @@
#!/usr/bin/env bash
# 低内存首次部署:串行 build 1 次,web/worker 共用镜像
# 用法:bash deploy/install-bt-host-mysql-lowmem.sh
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
COMPOSE_FILE="${ROOT_DIR}/deploy/docker-compose.bt-host-mysql.yml"
ENV_FILE="${ROOT_DIR}/.env"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log() { echo -e "${GREEN}[deploy-lowmem]${NC} $*"; }
warn() { echo -e "${YELLOW}[deploy-lowmem]${NC} $*"; }
err() { echo -e "${RED}[deploy-lowmem]${NC} $*" >&2; }
if [[ ! -f "$ENV_FILE" ]]; then
err "缺少 .env,请先 cp deploy/.env.bt-host-mysql.example .env 并填写"
exit 1
fi
if command -v free >/dev/null 2>&1; then
total_kb="$(free -k | awk '/^Mem:/{print $2}')"
avail_kb="$(free -k | awk '/^Mem:/{print $7}')"
log "内存:总计约 $((total_kb / 1024))MB,可用约 $((avail_kb / 1024))MB"
if [[ -n "${avail_kb:-}" && "$avail_kb" -lt 1500000 ]]; then
warn "可用内存偏低。建议先停查价等其它 Docker 项目,或加 2G Swap 后再部署。"
fi
fi
mkdir -p "${ROOT_DIR}/data"
cd "$ROOT_DIR"
compose=(docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE")
export DOCKER_BUILDKIT=1
export BUILDKIT_MAX_PARALLELISM=1
log "停止可能半成品的容器…"
"${compose[@]}" down --remove-orphans 2>/dev/null || true
log "串行构建镜像(仅 1 次,web/worker 共用 yubao-app:latest)…"
warn "Next.js 编译峰值约 2~4GB,期间 CPU/内存会飙高,服务器变慢属正常"
"${compose[@]}" build web
log "启动 web + worker…"
"${compose[@]}" up -d
log "同步数据库结构…"
"${compose[@]}" run --rm --no-deps web pnpm exec prisma db push --skip-generate
log "同步默认账号…"
"${compose[@]}" run --rm --no-deps web pnpm db:seed || true
"${compose[@]}" ps
APP_PORT="$(grep -E '^APP_PORT=' "$ENV_FILE" | tail -1 | cut -d= -f2- | tr -d '\r' || echo 30327)"
PUBLIC_HOST="$(grep -E '^PUBLIC_HOST=' "$ENV_FILE" | tail -1 | cut -d= -f2- | tr -d '\r' || echo 127.0.0.1)"
log "完成 → http://${PUBLIC_HOST}:${APP_PORT}/login"

@ -0,0 +1,187 @@
#!/usr/bin/env bash
# 宝塔宿主机 MySQL + Docker 应用 — 192.168.2.14:30327
# 用法(/home/project/yubao):
# bash deploy/install-bt-host-mysql.sh
# 若尚无 .env,会自动从 deploy/.env.bt-host-mysql.example 复制
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
COMPOSE_FILE="${ROOT_DIR}/deploy/docker-compose.bt-host-mysql.yml"
ENV_FILE="${ROOT_DIR}/.env"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log() { echo -e "${GREEN}[deploy-bt]${NC} $*"; }
warn() { echo -e "${YELLOW}[deploy-bt]${NC} $*"; }
err() { echo -e "${RED}[deploy-bt]${NC} $*" >&2; }
read_env() {
local key="$1"
local line
line="$(grep -E "^[[:space:]]*${key}=" "$ENV_FILE" 2>/dev/null | tail -1 || true)"
if [[ -z "$line" ]]; then
echo ""
return
fi
line="${line#*=}"
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
if [[ "$line" == \"*\" && "$line" == *\" ]]; then
line="${line:1:${#line}-2}"
elif [[ "$line" == \'*\' && "$line" == *\' ]]; then
line="${line:1:${#line}-2}"
fi
echo "$line"
}
validate_env() {
local example="${ROOT_DIR}/deploy/.env.bt-host-mysql.example"
if [[ ! -f "$ENV_FILE" ]]; then
if [[ -f "$example" ]]; then
log "未找到 .env,从 deploy/.env.bt-host-mysql.example 自动复制"
cp "$example" "$ENV_FILE"
else
err "未找到 .env 且缺少 ${example}"
exit 1
fi
fi
DATABASE_URL="$(read_env DATABASE_URL)"
SESSION_SECRET="$(read_env SESSION_SECRET)"
APP_ADMIN_PASS="$(read_env APP_ADMIN_PASS)"
APP_OPS_PASS="$(read_env APP_OPS_PASS)"
APP_PORT="$(read_env APP_PORT)"
PUBLIC_HOST="$(read_env PUBLIC_HOST)"
MYSQL_USER="$(read_env MYSQL_USER)"
MYSQL_PASSWORD="$(read_env MYSQL_PASSWORD)"
local missing=0
for key in DATABASE_URL SESSION_SECRET APP_ADMIN_PASS APP_OPS_PASS MYSQL_USER MYSQL_PASSWORD; do
local val="${!key:-}"
if [[ -z "$val" ]] || [[ "$val" == CHANGE_ME* ]]; then
err "请在 .env 中填写: $key"
missing=1
fi
done
if [[ "$APP_ADMIN_PASS" == "$APP_OPS_PASS" ]]; then
err "APP_ADMIN_PASS 与 APP_OPS_PASS 不能相同"
missing=1
fi
for weak in admin123 ops123 password 123456; do
if [[ "$APP_ADMIN_PASS" == "$weak" || "$APP_OPS_PASS" == "$weak" ]]; then
err "生产环境禁止使用弱口令: $weak"
missing=1
fi
done
if [[ "$SESSION_SECRET" == *"change-me"* ]] || [[ ${#SESSION_SECRET} -lt 32 ]]; then
err "SESSION_SECRET 须至少 32 字符且非开发默认值"
missing=1
fi
if [[ $missing -ne 0 ]]; then
exit 1
fi
}
preflight_mysql() {
local db_user db_pass db_name
db_user="$(read_env MYSQL_USER)"
db_pass="$(read_env MYSQL_PASSWORD)"
db_name="$(read_env MYSQL_DATABASE)"
if [[ -z "$db_pass" ]]; then
return 0
fi
log "测试宝塔 MySQL 连接..."
if docker run --rm --add-host=host.docker.internal:host-gateway mysql:8 \
mysql -h host.docker.internal -u "${db_user}" -p"${db_pass}" -e "SELECT 1" "${db_name}" >/dev/null 2>&1; then
log "MySQL 连接正常"
return 0
fi
err "MySQL 认证失败:请核对 .env 中 MYSQL_PASSWORD / DATABASE_URL 是否与宝塔一致"
err "常见原因:"
err " 1) 宝塔用户未授权 Docker 网段(172.%)"
err " 2) 重新上传代码覆盖了 .env"
err "修复示例:"
err " MYSQL_PASSWORD=宝塔里 ${db_user} 用户的真实密码"
err " DATABASE_URL=mysql://${db_user}:真实密码@host.docker.internal:3306/${db_name}"
exit 1
}
deploy_docker() {
if ! command -v docker >/dev/null 2>&1; then
err "未安装 Docker"
exit 1
fi
if ! docker compose version >/dev/null 2>&1; then
err "需要 Docker Compose V2"
exit 1
fi
mkdir -p "${ROOT_DIR}/data"
cd "$ROOT_DIR"
local compose=(docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE")
log "构建并启动(无容器 MySQL,连接宝塔 MySQL)..."
export DOCKER_BUILDKIT=1
export BUILDKIT_MAX_PARALLELISM=1
"${compose[@]}" build web
"${compose[@]}" up -d
log "同步数据库结构(prisma db push)..."
"${compose[@]}" run --rm --no-deps web pnpm exec prisma db push --skip-generate
log "同步默认账号(admin / ops)..."
"${compose[@]}" run --rm --no-deps web pnpm db:seed
log "容器状态:"
"${compose[@]}" ps
}
smoke_check() {
local port="${APP_PORT:-30327}"
local url="http://127.0.0.1:${port}/api/health"
log "健康检查: ${url}"
local body
body="$(curl -sf "$url" 2>/dev/null || true)"
if [[ "$body" == \{* ]]; then
log "Web 服务正常"
else
warn "Web 暂未响应,请查看:docker compose -f deploy/docker-compose.bt-host-mysql.yml logs -f web"
fi
}
print_summary() {
local port="${APP_PORT:-30327}"
local host="${PUBLIC_HOST:-<服务器内网IP>}"
echo ""
log "========== 部署完成(宝塔 MySQL 模式)=========="
echo " 内网访问: http://${host}:${port}"
echo " 登录页: http://${host}:${port}/login"
echo " 邮件列表: http://${host}:${port}/mails"
echo " 设置页: http://${host}:${port}/settings"
echo ""
echo " 常用命令:"
echo " docker compose --env-file .env -f deploy/docker-compose.bt-host-mysql.yml logs -f worker"
echo " docker compose --env-file .env -f deploy/docker-compose.bt-host-mysql.yml restart"
echo ""
warn "首次登录后请立即修改 admin/ops 密码(.env 中 APP_ADMIN_PASS / APP_OPS_PASS)"
warn "CC / IMAP 推荐在 Admin「设置」页配置,不必改代码"
}
main() {
validate_env
preflight_mysql
deploy_docker
smoke_check
print_summary
}
main "$@"

@ -0,0 +1,65 @@
#!/usr/bin/env bash
# 预构建镜像部署 — 服务器不编译,仅 load + 启动
# 用法:
# bash deploy/install-prebuilt-image.sh
# bash deploy/install-prebuilt-image.sh /path/to/yubao-app-image.tar
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
COMPOSE_FILE="${ROOT_DIR}/deploy/docker-compose.prebuilt.yml"
ENV_FILE="${ROOT_DIR}/.env"
IMAGE_TAG="yubao-app:latest"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log() { echo -e "${GREEN}[prebuilt]${NC} $*"; }
warn() { echo -e "${YELLOW}[prebuilt]${NC} $*"; }
err() { echo -e "${RED}[prebuilt]${NC} $*" >&2; }
TAR_PATH="${1:-${ROOT_DIR}/deploy/yubao-app-image.tar}"
if [[ ! -f "$ENV_FILE" ]]; then
err "缺少 .env,请先配置 DATABASE_URL 等"
exit 1
fi
if ! command -v docker >/dev/null 2>&1; then
err "未安装 Docker"
exit 1
fi
mkdir -p "${ROOT_DIR}/data"
if docker image inspect "$IMAGE_TAG" >/dev/null 2>&1; then
log "镜像已存在: $IMAGE_TAG"
elif [[ -f "$TAR_PATH" ]]; then
log "加载镜像: $TAR_PATH"
docker load -i "$TAR_PATH"
else
err "未找到镜像 $IMAGE_TAG,也未找到 tar: $TAR_PATH"
err "请先从开发机上传 deploy/yubao-app-image.tar"
exit 1
fi
cd "$ROOT_DIR"
compose=(docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE")
log "启动 web + worker(不编译)…"
"${compose[@]}" up -d
log "同步数据库结构…"
"${compose[@]}" run --rm --no-deps web pnpm exec prisma db push --skip-generate
log "同步默认账号…"
"${compose[@]}" run --rm --no-deps web pnpm db:seed || true
"${compose[@]}" ps
APP_PORT="$(grep -E '^APP_PORT=' "$ENV_FILE" | tail -1 | cut -d= -f2- | tr -d '\r' || echo 30327)"
PUBLIC_HOST="$(grep -E '^PUBLIC_HOST=' "$ENV_FILE" | tail -1 | cut -d= -f2- | tr -d '\r' || echo 127.0.0.1)"
log "完成 → http://${PUBLIC_HOST}:${APP_PORT}/login"
warn "更新版本:开发机重新 build-image-local.ps1 → 上传新 tar → bash deploy/update-prebuilt-image.sh"

@ -0,0 +1,29 @@
# 方案 C — 预构建镜像部署(服务器不编译)
## 一、上传
1. 镜像/yubao-app-image.tar → 服务器 /home/project/yubao/deploy/yubao-app-image.tar
2. 部署/deploy/ 内全部 → 服务器 /home/project/yubao/deploy/(覆盖同名脚本)
3. 数据库/(若尚未导入)→ 宝塔导入 email_forecast.sql 到 yubao 库
## 二、服务器首次启动
```bash
cd /home/project/yubao
chmod +x deploy/install-prebuilt-image.sh
bash deploy/install-prebuilt-image.sh
```
## 三、后续更新(只换镜像)
开发机重新 pack-prebuilt-upload.ps1,上传新 tar 后:
```bash
bash deploy/update-prebuilt-image.sh deploy/yubao-app-image.tar
```
## 访问
http://192.168.2.14:30327/login
服务器运行时内存约 1~1.5GB,无需在服务器上编译 Next.js。

@ -0,0 +1,89 @@
# 方案 C:开发机构镜像 + 服务器 load(最省服务器内存)
# Usage: powershell -ExecutionPolicy Bypass -File deploy/pack-prebuilt-upload.ps1
#
# 产出:
# 上传到服务器/
# ├── 镜像/yubao-app-image.tar
# ├── 部署/deploy/ (仅脚本 + compose,不含 src)
# ├── 数据库/ (SQL,可选)
# └── README.md
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
$Out = Join-Path $Root ([char]0x4e0a + [char]0x4f20 + [char]0x5230 + [char]0x670d + [char]0x52a1 + [char]0x5668)
$ImageDir = Join-Path $Out ([char]0x955c + [char]0x50cf)
$DeployDir = Join-Path $Out ([char]0x90e8 + [char]0x7f72)
$DbDir = Join-Path $Out ([char]0x6570 + [char]0x636e + [char]0x5e93)
Write-Host "prebuilt-pack: build image on dev machine…"
& (Join-Path $Root "deploy\build-image-local.ps1")
$TarSrc = Join-Path $Root "deploy\yubao-app-image.tar"
if (-not (Test-Path $TarSrc)) {
throw "missing $TarSrc after build"
}
Write-Host "prebuilt-pack: clean $Out (keep build tar)"
if (Test-Path $Out) {
Remove-Item -Recurse -Force $Out
}
New-Item -ItemType Directory -Path $ImageDir -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $DeployDir "deploy") -Force | Out-Null
New-Item -ItemType Directory -Path $DbDir -Force | Out-Null
Copy-Item $TarSrc (Join-Path $ImageDir "yubao-app-image.tar") -Force
Write-Host "prebuilt-pack: image copied"
$DeployFiles = @(
"docker-compose.prebuilt.yml",
"docker-compose.bt-host-mysql.yml",
".env.bt-host-mysql.example",
"install-prebuilt-image.sh",
"update-prebuilt-image.sh",
"install-bt-host-mysql.sh",
"install-bt-host-mysql-lowmem.sh",
"update-code-only-bt-host-mysql.sh"
)
$deployDst = Join-Path $DeployDir "deploy"
foreach ($f in $DeployFiles) {
$src = Join-Path $Root "deploy\$f"
if (Test-Path $src) {
Copy-Item $src (Join-Path $deployDst $f) -Force
Write-Host "prebuilt-pack: deploy/$f"
}
}
# scripts/docker-entrypoint-web.sh referenced in compose lives in image; not needed on host
$DbFiles = @("email_forecast.sql", "email_forecast.sql.gz", "README.md")
foreach ($f in $DbFiles) {
$src = Join-Path $Root "deploy\database\$f"
if (Test-Path $src) {
Copy-Item $src (Join-Path $DbDir $f) -Force
Write-Host "prebuilt-pack: database/$f"
}
}
function Convert-ShFilesToLf {
param([string]$BaseDir)
Get-ChildItem -Path $BaseDir -Filter "*.sh" -Recurse -File | ForEach-Object {
$text = [System.IO.File]::ReadAllText($_.FullName)
$fixed = $text -replace "`r`n", "`n" -replace "`r", "`n"
if ($text -ne $fixed) {
[System.IO.File]::WriteAllText($_.FullName, $fixed, [System.Text.UTF8Encoding]::new($false))
}
}
}
Convert-ShFilesToLf -BaseDir $DeployDir
$readmePath = Join-Path $Root "deploy\pack-prebuilt-readme.md"
if (Test-Path $readmePath) {
Copy-Item $readmePath (Join-Path $Out "README.md") -Force
} else {
Set-Content -Path (Join-Path $Out "README.md") -Value "# 预构建镜像部署`n见 deploy/pack-prebuilt-readme.md" -Encoding UTF8
}
$imgMb = [math]::Round((Get-Item (Join-Path $ImageDir "yubao-app-image.tar")).Length / 1MB, 1)
Write-Host ""
Write-Host "prebuilt-pack: done -> $Out"
Write-Host ("prebuilt-pack: image=" + $imgMb + "MB")

@ -0,0 +1,110 @@
# 上传到服务器 — 使用说明
本目录仅含**生产部署所需**文件,不含测试、文档、开发脚本、`node_modules` 等。
```
上传到服务器/
├── README.md ← 本说明
├── RELEASE-NOTES.md ← 部署步骤摘要
├── 代码/ ← 上传到服务器项目目录 /home/project/yubao
└── 数据库/ ← 宝塔 MySQL 导入用
├── email_forecast.sql
├── email_forecast.sql.gz
└── README.md
```
---
## 第一步:导入数据库(宝塔)
1. 宝塔创建数据库 **`email_forecast`**
- 字符集:**`utf8mb4`**
- 校对规则:**`utf8mb4_unicode_ci`**(必须一致,否则会提示「字符集不一致可能导致恢复失败」)
2. 创建用户 **`email_forecast`**,授权主机 **`172.%`**(Docker 容器访问)
3. 进入该库 → **导入** → 上传 **`数据库/email_forecast.sql`**(或 `.sql.gz`)
若库已建错校对规则,先执行:
```sql
ALTER DATABASE email_forecast CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
再导入。包内含本机完整业务数据;若只想空库,可跳过导入,首次 `install` 会 `db push` + seed。
---
## 第二步:上传代码
将 **`代码/`** 内全部内容上传到服务器 `/home/project/yubao/`。
**重要:不要覆盖服务器上已有的 `.env` 文件。**
---
## 第三步:配置与启动
### 首次部署
```bash
cd /home/project/yubao
cp deploy/.env.bt-host-mysql.example .env
nano .env # 改密码、SESSION_SECRET、CC/IMAP 等
chmod +x deploy/install-bt-host-mysql.sh
bash deploy/install-bt-host-mysql.sh
```
### 更新代码(已有 .env)
```bash
cd /home/project/yubao
cp .env .env.backup.$(date +%Y%m%d)
# 用新「代码/」覆盖本目录(勿覆盖 .env)
chmod +x deploy/update-code-only-bt-host-mysql.sh
bash deploy/update-code-only-bt-host-mysql.sh
# schema 有变更时:
bash deploy/update-code-only-bt-host-mysql.sh --db-push
```
---
## 访问地址
| 用途 | 地址 |
|------|------|
| 登录 | http://192.168.2.14:30327/login |
| 邮件列表 | http://192.168.2.14:30327/mails |
| 设置(Admin) | http://192.168.2.14:30327/settings |
默认账号由 `.env` 中 `APP_ADMIN_USER` / `APP_OPS_PASS` 决定;**生产勿用 admin123**。
---
## 重新打包(开发机 Windows)
### 方案 A:服务器编译(需 4GB+ 可用内存)
```powershell
cd X:\work\yx
powershell -ExecutionPolicy Bypass -File deploy\pack-server-upload.ps1
```
### 方案 C:开发机构镜像(推荐,服务器不编译)
```powershell
cd X:\work\yx
# 需 Docker Desktop 已启动;本机内存建议 ≥ 8GB
powershell -ExecutionPolicy Bypass -File deploy\pack-prebuilt-upload.ps1
```
产出含 `镜像/yubao-app-image.tar`(约 800MB~1.5GB),服务器执行 `bash deploy/install-prebuilt-image.sh`。
---
## 与查价项目(chajia)同机部署
| 项目 | 端口 | 库名 | 目录 |
|------|------|------|------|
| chajia | 30325 | chajia | /home/project/chajia |
| 邮件预报(本项) | 30327 | email_forecast | /home/project/yubao |
二者共用宝塔 MySQL,各自独立库与用户,互不影响。

@ -0,0 +1,193 @@
# Pack production upload bundle (上传到服务器)
# Usage: powershell -ExecutionPolicy Bypass -File deploy/pack-server-upload.ps1
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
$Out = Join-Path $Root ([char]0x4e0a + [char]0x4f20 + [char]0x5230 + [char]0x670d + [char]0x52a1 + [char]0x5668)
$Code = Join-Path $Out ([char]0x4ee3 + [char]0x7801)
$Db = Join-Path $Out ([char]0x6570 + [char]0x636e + [char]0x5e93)
Write-Host "pack: clean $Out"
if (Test-Path $Out) {
Remove-Item -Recurse -Force $Out
}
New-Item -ItemType Directory -Path $Code -Force | Out-Null
New-Item -ItemType Directory -Path $Db -Force | Out-Null
function Copy-TreeLiteral {
param(
[Parameter(Mandatory = $true)][string]$SourceDir,
[Parameter(Mandatory = $true)][string]$DestDir
)
$stack = New-Object System.Collections.Stack
$stack.Push(@($SourceDir, $DestDir))
while ($stack.Count -gt 0) {
$pair = $stack.Pop()
$currentSrc = $pair[0]
$currentDst = $pair[1]
if (-not (Test-Path -LiteralPath $currentDst)) {
New-Item -ItemType Directory -Path $currentDst -Force | Out-Null
}
Get-ChildItem -LiteralPath $currentSrc -Force | ForEach-Object {
if ($_.PSIsContainer) {
$stack.Push(@($_.FullName, (Join-Path $currentDst $_.Name)))
} elseif ($_.Extension -ne ".md") {
Copy-Item -LiteralPath $_.FullName -Destination (Join-Path $currentDst $_.Name) -Force
}
}
}
}
$RequiredRoutes = @(
"src\app\api\mails\[id]\route.ts",
"src\app\api\mails\[id]\import\route.ts",
"src\app\api\mails\[id]\do\confirm\route.ts",
"src\app\api\mails\[id]\transfer\confirm\route.ts",
"src\app\api\mails\[id]\work-order\confirm\route.ts",
"src\app\api\mails\[id]\attachments\[attId]\download\route.ts",
"src\app\api\compensations\[id]\retry\route.ts",
"src\app\api\imports\[importId]\retry\route.ts",
"src\app\api\settings\mailboxes\[id]\route.ts",
"src\app\api\settings\imap\filters\[id]\route.ts",
"src\app\api\oauth\callback\[provider]\route.ts"
)
function Convert-ShFilesToLf {
param([Parameter(Mandatory = $true)][string]$BaseDir)
Get-ChildItem -Path $BaseDir -Filter "*.sh" -Recurse -File -ErrorAction SilentlyContinue | ForEach-Object {
$text = [System.IO.File]::ReadAllText($_.FullName)
$fixed = $text -replace "`r`n", "`n" -replace "`r", "`n"
if ($text -ne $fixed) {
[System.IO.File]::WriteAllText($_.FullName, $fixed, [System.Text.UTF8Encoding]::new($false))
Write-Host "pack: LF $($_.FullName.Substring($BaseDir.Length).TrimStart('\','/'))"
}
}
}
function Test-RequiredRoutes {
param([string]$BaseDir)
$missing = @()
foreach ($rel in $RequiredRoutes) {
$path = Join-Path $BaseDir $rel
if (-not (Test-Path -LiteralPath $path)) {
$missing += $rel
}
}
if ($missing.Count -gt 0) {
throw "pack: missing dynamic API routes (bracket paths): $($missing -join ', ')"
}
}
$CodeDirs = @(
"src",
"prisma",
"public",
"scripts"
)
foreach ($dir in $CodeDirs) {
$src = Join-Path $Root $dir
if (-not (Test-Path $src)) {
throw "missing dir: $dir"
}
Write-Host "pack: copy $dir"
$dstDir = Join-Path $Code $dir
Copy-TreeLiteral -SourceDir $src -DestDir $dstDir
}
$deploySrc = Join-Path $Root "deploy"
$deployDst = Join-Path $Code "deploy"
New-Item -ItemType Directory -Path $deployDst -Force | Out-Null
Get-ChildItem -Path $deploySrc -Force | Where-Object {
$_.Name -ne "database" -and
$_.Name -ne "pack-server-upload.ps1" -and
$_.Name -ne "pack-readme-template.md"
} | ForEach-Object {
Copy-Item -Path $_.FullName -Destination $deployDst -Recurse -Force
}
$CodeFiles = @(
"package.json",
"pnpm-lock.yaml",
"next.config.ts",
"tsconfig.json",
"tsconfig.worker.json",
"next-env.d.ts",
"Dockerfile",
".dockerignore"
)
foreach ($file in $CodeFiles) {
$src = Join-Path $Root $file
if (Test-Path $src) {
Copy-Item -Path $src -Destination (Join-Path $Code $file) -Force
Write-Host "pack: copy $file"
}
}
$envExample = Join-Path $Root "deploy\.env.bt-host-mysql.example"
if (Test-Path $envExample) {
Copy-Item -Path $envExample -Destination (Join-Path $deployDst ".env.bt-host-mysql.example") -Force
Write-Host "pack: copy deploy/.env.bt-host-mysql.example"
}
$staleEnvPath = Join-Path $Code ".env"
if ((Test-Path -LiteralPath $Code) -and (Test-Path -LiteralPath $staleEnvPath)) {
Remove-Item -LiteralPath $staleEnvPath -Force
Write-Host "pack: removed stale 代码/.env"
}
$DbFiles = @("email_forecast.sql", "email_forecast.sql.gz", "README.md")
foreach ($file in $DbFiles) {
$src = Join-Path $Root "deploy\database\$file"
if (Test-Path $src) {
Copy-Item -Path $src -Destination (Join-Path $Db $file) -Force
Write-Host "pack: database $file"
}
}
function Get-DirSizeBytes($path) {
(Get-ChildItem -Path $path -Recurse -File -ErrorAction SilentlyContinue |
Measure-Object -Property Length -Sum).Sum
}
$codeSize = [math]::Round((Get-DirSizeBytes $Code) / 1MB, 2)
$dbSize = [math]::Round((Get-DirSizeBytes $Db) / 1KB, 1)
$fileCount = (Get-ChildItem -Path $Out -Recurse -File).Count
Write-Host ""
Write-Host "pack: normalize shell scripts (CRLF -> LF)"
Convert-ShFilesToLf -BaseDir $Code
Write-Host ""
Write-Host "pack: verify dynamic API routes"
Test-RequiredRoutes -BaseDir $Code
$ForbiddenInCode = @(
(Join-Path $Code ".env"),
(Join-Path $Code "node_modules"),
(Join-Path $Code ".next"),
(Join-Path $Code "data"),
(Join-Path $Code ".git")
)
foreach ($bad in $ForbiddenInCode) {
if (Test-Path -LiteralPath $bad) {
throw "pack: forbidden path present in 代码/: $bad"
}
}
Write-Host ""
Write-Host "pack: done -> $Out"
Write-Host "pack: files=$fileCount codeMB=$codeSize dbKB=$dbSize"
Write-Host "pack: SAFETY ok (no .env/node_modules/.next/data/.git in 代码/)"
$readmeSrc = Join-Path $Root "deploy\pack-readme-template.md"
if (Test-Path $readmeSrc) {
Copy-Item $readmeSrc (Join-Path $Out "README.md") -Force
}
$releaseNotesSrc = Join-Path $Root "deploy\宝塔部署说明.md"
if (Test-Path $releaseNotesSrc) {
Copy-Item $releaseNotesSrc (Join-Path $Out "RELEASE-NOTES.md") -Force
Write-Host "pack: RELEASE-NOTES.md"
}

@ -0,0 +1,117 @@
#!/usr/bin/env bash
# 代码/镜像更新(服务器上可选同步库结构)
# 用法:
# bash deploy/update-code-only-bt-host-mysql.sh
# bash deploy/update-code-only-bt-host-mysql.sh --db-push # schema 有变更时
#
# 特点:串行 build;默认不碰库;失败 fail-fast
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
COMPOSE_FILE="${ROOT_DIR}/deploy/docker-compose.bt-host-mysql.yml"
ENV_FILE="${ROOT_DIR}/.env"
DO_DB_PUSH=false
for arg in "$@"; do
case "$arg" in
--db-push) DO_DB_PUSH=true ;;
-h|--help)
echo "用法: bash deploy/update-code-only-bt-host-mysql.sh [--db-push]"
exit 0
;;
esac
done
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log() { echo -e "${GREEN}[update-code]${NC} $*"; }
warn() { echo -e "${YELLOW}[update-code]${NC} $*"; }
err() { echo -e "${RED}[update-code]${NC} $*" >&2; }
CURRENT_STEP="init"
on_err() {
local code=$?
err "失败中断:步骤「${CURRENT_STEP}」退出码=${code}"
exit "$code"
}
trap on_err ERR
require_file() {
local path="$1"
if [[ ! -f "$path" ]]; then
err "缺少文件: $path"
exit 1
fi
}
run_step() {
local name="$1"
shift
CURRENT_STEP="$name"
log "→ ${name}"
"$@"
log "✓ ${name} 完成"
}
CURRENT_STEP="preflight"
log "前置校验..."
if [[ ! -f "$ENV_FILE" ]]; then
err "缺少 .env,禁止继续"
exit 1
fi
if ! command -v docker >/dev/null 2>&1; then
err "未安装 docker"
exit 1
fi
require_file "${ROOT_DIR}/src/app/api/mails/[id]/route.ts"
require_file "${ROOT_DIR}/Dockerfile"
require_file "${ROOT_DIR}/package.json"
require_file "${ROOT_DIR}/pnpm-lock.yaml"
log "前置校验通过"
cd "$ROOT_DIR"
compose=(docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE")
export DOCKER_BUILDKIT=1
export BUILDKIT_MAX_PARALLELISM=1
run_step "停止 web/worker" \
"${compose[@]}" stop web worker
run_step "构建 web" \
"${compose[@]}" build web
run_step "构建 worker" \
"${compose[@]}" build worker
run_step "启动全部服务" \
"${compose[@]}" up -d
if [[ "$DO_DB_PUSH" == "true" ]]; then
run_step "同步数据库结构(prisma db push)" \
"${compose[@]}" run --rm --no-deps web pnpm exec prisma db push --skip-generate
else
warn "跳过 db push(schema 无变更可忽略;有变更请加 --db-push)"
fi
CURRENT_STEP="校验容器状态"
for svc in web worker; do
state="$("${compose[@]}" ps --status running --services 2>/dev/null | grep -x "$svc" || true)"
if [[ -z "$state" ]]; then
err "服务未处于 running: ${svc}"
exit 1
fi
log "✓ ${svc} running"
done
CURRENT_STEP="done"
trap - ERR
log "完成(代码-only 更新)"

@ -0,0 +1,43 @@
#!/usr/bin/env bash
# 预构建镜像更新 — 加载新 tar 并重建容器(不编译)
# 用法:bash deploy/update-prebuilt-image.sh [镜像tar路径]
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
COMPOSE_FILE="${ROOT_DIR}/deploy/docker-compose.prebuilt.yml"
ENV_FILE="${ROOT_DIR}/.env"
IMAGE_TAG="yubao-app:latest"
TAR_PATH="${1:-${ROOT_DIR}/deploy/yubao-app-image.tar}"
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m'
log() { echo -e "${GREEN}[prebuilt-update]${NC} $*"; }
err() { echo -e "${RED}[prebuilt-update]${NC} $*" >&2; }
if [[ ! -f "$ENV_FILE" ]]; then
err "缺少 .env"
exit 1
fi
if [[ ! -f "$TAR_PATH" ]]; then
err "缺少镜像 tar: $TAR_PATH"
exit 1
fi
cd "$ROOT_DIR"
compose=(docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE")
log "停止 web/worker…"
"${compose[@]}" stop web worker || true
log "加载新镜像…"
docker load -i "$TAR_PATH"
log "重建容器…"
"${compose[@]}" up -d --force-recreate
"${compose[@]}" ps
log "完成"

@ -0,0 +1,150 @@
# 宝塔 + 宿主机 MySQL + Docker 应用(内网 30327)
与查价项目同一套模式:**MySQL 在宝塔,应用在 Docker**。
适用于:
- MySQL:**宝塔面板**,库名 `email_forecast`
- 应用:**Docker 容器**(web + worker)
- 代码目录:`/home/project/yubao`
- 访问:内网 `http://服务器IP:30327`
---
## 一、宝塔 MySQL 准备
1. 创建数据库 **`email_forecast`**
- 字符集:**utf8mb4**
- 校对规则:**utf8mb4_unicode_ci**(与 SQL 一致;选错会出现「字符集不一致可能导致恢复失败」)
2. 创建用户 **`email_forecast`**,赋予该库全部权限
3. 授权 Docker 网段(容器经 `host.docker.internal` 连宿主机):
```sql
-- 在宝塔 phpMyAdmin 或终端执行(按实际密码改)
CREATE USER 'email_forecast'@'172.%' IDENTIFIED BY '你的强密码';
GRANT ALL PRIVILEGES ON email_forecast.* TO 'email_forecast'@'172.%';
FLUSH PRIVILEGES;
```
4. **推荐**:导入 `数据库/email_forecast.sql`(本机完整数据);也可不导入,由 install 脚本 `prisma db push` + seed
### 测试(在服务器上)
```bash
mysql -h 127.0.0.1 -u email_forecast -p email_forecast
```
---
## 二、开发机打包
```powershell
cd X:\work\yx
powershell -ExecutionPolicy Bypass -File deploy\pack-server-upload.ps1
# 可选压缩便于上传
Compress-Archive -Path "上传到服务器\代码\*" -DestinationPath "yubao-code.zip" -Force
```
将 **`上传到服务器/代码/`** 解压到服务器 `/home/project/yubao/`。
---
## 三、配置 .env
```bash
cd /home/project/yubao
cp deploy/.env.bt-host-mysql.example .env
nano .env
```
必改项:
| 变量 | 说明 |
|------|------|
| `APP_PORT` | 对外端口,默认 `30327`(勿与 chajia 30325 冲突) |
| `PUBLIC_HOST` | 服务器内网 IP |
| `MYSQL_PASSWORD` | 宝塔里 email_forecast 用户密码 |
| `DATABASE_URL` | `mysql://email_forecast:密码@host.docker.internal:3306/email_forecast` |
| `SESSION_SECRET` | ≥32 字符随机串 |
| `APP_ADMIN_PASS` / `APP_OPS_PASS` | **不能**用 admin123/ops123,且二者须不同 |
| `OAUTH_PUBLIC_BASE_URL` | `http://内网IP:30327` |
| `CC_MOCK` | 无 CC 账号时 `true`;有账号改 `false` 并在设置页填凭据 |
密码含 `@`、`#` 等需 [URL 编码](https://www.urlencoder.org/)。
---
## 四、首次启动
```bash
chmod +x deploy/install-bt-host-mysql.sh
bash deploy/install-bt-host-mysql.sh
```
脚本会:校验 MySQL → 构建 web/worker → `prisma db push` → seed 账号 → 健康检查。
**不要**用根目录 `docker-compose.yml` 里的容器 MySQL(那是本地开发用的 7023 端口)。
---
## 五、更新代码
```bash
bash deploy/update-code-only-bt-host-mysql.sh
# Prisma schema 有变更时:
bash deploy/update-code-only-bt-host-mysql.sh --db-push
```
---
## 六、内网访问
| 用途 | 地址 |
|------|------|
| 登录 | `http://192.168.x.x:30327/login` |
| 邮件 | `http://192.168.x.x:30327/mails` |
| 健康检查 | `http://192.168.x.x:30327/api/health` |
---
## 七、宝塔防火墙
- 放行 **30327**(内网即可)
- **不要**对公网开放 3306
---
## 八、架构示意
```
内网浏览器 ──HTTP:30327──► [Docker] web (Next.js :3100)
│
├──► [Docker] worker (IMAP 拉取 + 解析)
│
└──► host.docker.internal:3306
│
[宝塔] MySQL email_forecast
```
---
## 九、故障排查
| 现象 | 处理 |
|------|------|
| `Can't connect to MySQL` | 用户是否授权 `172.%`;`DATABASE_URL` 是否用 `host.docker.internal` |
| 生产启动报 SESSION_SECRET / 弱口令 | 改 `.env` 中 `SESSION_SECRET`、`APP_*_PASS` |
| Worker 不拉信 | `docker compose -f deploy/docker-compose.bt-host-mysql.yml logs -f worker`;检查设置页邮箱绑定 |
| 端口不通 | 宝塔防火墙、安全组是否放行 30327 |
| API 404(带 `[id]` 的路由) | Windows 打包方括号路径丢失;重新 `pack-server-upload.ps1` 并核对 RequiredRoutes |
---
## 十、导出本机库
```bash
# 本机(需 MySQL 客户端 + 本地 docker mysql 在跑)
bash deploy/database/export-local.sh
# 或直接:
# docker exec yx-mysql-1 mysqldump -uapp -papp email_forecast > deploy/database/email_forecast.sql
```

@ -0,0 +1,371 @@
# 邮件指令提取规则(对照实现)
> **用途**:给产品/运营对照「接下来没见过、格式很怪」的邮件,判断系统会不会拉进库、认成哪类指令、字段从哪来。
> **约束**:解析**不走大模型**,全部是词表 + 正则 + 固定模板。新格式要么命中现有规则,要么漏/错,不会「猜一下」。
> **实现真源**:`src/services/parse/`(词表 `instruction-lexicon.ts`、分类 `classify.ts`、拆段 `split-instructions.ts`、流水线 `pipeline.ts`)。
> **产品口径**:`docx/产品规则-邮件指令识别与拆分.md`(冲突时以代码为准,并回写产品页)。
> **日期**:2026-08-14
---
## 0. 一句话
一封信要变成可确认的业务卡,必须连过四道门:
1. **拉取**:过滤规则 / 业务兜底认为「相关」才入库。
2. **分类**:主题+正文+附件名打分,得到整封主类型。
3. **拆指令**:按时间轮切开,每段映射到 UI 卡(预报 / 转仓 / 工单 / 上传 DO / 客户指令)。
4. **抽字段**:柜头来自主题模板或正文标签;货件行几乎只来自 **xlsx/xls/csv**(卡派表或贴标指令单)。
任何一步没命中,后面再「看起来像业务」也不会自动补全。
```mermaid
flowchart TD
A[IMAP 新信] --> B{拉取过滤}
B -->|SKIP_FILTER / 黑名单| Z[不入库]
B -->|入库| C[classify 打分]
C --> D[split 拆时间轮]
D --> E{有表格附件?}
E -->|xlsx/xls/csv| F[卡派表 / 贴标指令单]
E -->|无表| G[仅柜头 + 空货件]
F --> H[详情指令卡]
G --> H
H --> I[人工确认写入 CC]
```
---
## 1. 拉取:什么样的信根本进不来
实现:`src/services/imap/pull-filter.ts` + `classify.ts` `isBusinessRelevantMail`。
| 顺序 | 条件 | 结果 |
|------|------|------|
| 1 | 发件人在黑名单,或主题/正文命中关键词黑名单 | **不拉** `SKIP_BLACKLIST` |
| 2 | 发件人在白名单 | **拉** |
| 3 | 主题/正文命中设置页关键词 | **拉** |
| 4 | 过滤全空时走业务兜底 | 见下表 |
| 5 | 已配置过滤但以上都不中 | **不拉** `SKIP_FILTER` |
业务兜底(过滤全空才用)任一为真即拉:
- 分类结果不是 `UNKNOWN`
- 主题/正文/附件名命中:`新增预报|转仓|换标|覆盖贴|贴标|拍照|拆柜清单|卡转海|拦截|改自提|留仓|卡派|装箱|货件清单|船名|航次|柜型|40HQ|20GP|ETA|预报资料|拆柜|柜号|快递单号|工单|DO|DO请查收`
- 文本或附件名里有 **ISO 柜号** `[A-Z]{4}` + 7 位数字(如 `WHSU8127240`)
- 附件名像卡派/装箱/packing/清单/预报/转仓/换标/贴标/货件/DO
- 有表格附件 **且** 正文弱物流词:`资料|清单|柜|仓|FBA|shipment|container`
**未见过的信在这里就会丢:**
- 纯英文、无柜号、无上表关键词(例如 “please book this container”)
- 只写「请安排」「帮忙处理」而无标准词
- 柜号不是 ISO 四字母+七数字(短柜号、带校验位空格、中文「柜」无号)
- 设置了关键词过滤,但新客户用了别的叫法,又不在白名单
---
## 2. 整封分类(主类型)
实现:`classifyMail`。优先级(同分取更靠前):
`DO_UPLOAD` > `NEW_CONTAINER` > `TRANSFER` > `WORK_ORDER`
总分 **&lt; 40 → `UNKNOWN`**(列表里常变成忽略/无法确认)。
| 信号 | 加分 | 落到 |
|------|------|------|
| `DO请查收` / 请查收 DO / 上传 DO / 文件名独立 `DO` 的 PDF | 55(无真实 DO 且主题已是拆柜清单则降到 25) | DO_UPLOAD |
| `新增预报` / `请查收新增预报` | 50 | NEW_CONTAINER |
| 附件名 `数据模版` 或 `预报资料\|货件清单\|装箱清单` 的表格 | 45(已有预报分则 +15) | NEW_CONTAINER |
| `新增转仓` 或 `转仓`(排除 `不转仓`) | 50 | TRANSFER |
| 硬工单:贴标/拦截/拍照/快递单号,或换标/覆盖贴/贴好拍照 | 45 | WORK_ORDER |
| 软词:拆柜清单 / 卡转海 / 派送要求 / 改自提 / 更新派送单 / 留仓 | 45 | WORK_ORDER |
| 附件名 `换标\|贴标指令` | 30 | WORK_ORDER |
| 正文最新段已是硬工单 | 压过主题上的历史「新增预报」/DO | WORK_ORDER |
| 分类仍 UNKNOWN,但有 ISO 柜号或「预约码」 | 40 兜底 | WORK_ORDER(mail1 类) |
**会错的典型:**
- 主题还挂着「新增预报」,最新回复是换标 → 会压成工单(这是刻意的)。
- 「拦截-209件」只是渠道汇总,**不当**整票拦截;真正拦截必须正文另有「拦截」且不是 `拦截-数字件`。
- 文件名叫 `卡派资料.xlsx` **不会单独**打成预报,还要有「新增预报」或「数据模版/装箱清单」等。
- 英文 “new booking / transload / relabel” **不加分**。
---
## 3. 多轮拆段 → 指令卡
实现:`split-instructions.ts` + `instruction-lexicon.ts`。
### 3.1 怎么切开
按边界切(保留分隔标签到后一段):
- `---- 原始邮件 / 转发的邮件 / Original Message ----`
- `在 yyyy-mm-dd … 写道` / `在 yyyy年m月d日 … 写道` / `On … wrote:`
- `发件人` + `发送时间/日期` 邮件头
- 行内嵌套 `> 在 … 写道`(QQ 引用)
**不用**单独的 `Dear` 当切段点。
弱切(独立成行才切):`新增预报|请查收新增预报|新增转仓|更新派送单|拆柜清单|派送要求|换标|覆盖贴|贴标指令|出库|留仓|拆分|DO请查收|请查收 DO`
### 3.2 关键词 → UI kind
| UI | 关键词(节选,见词表) | 确认动作 |
|----|------------------------|----------|
| `forecast` | 新增预报、请查收新增预报;正文 `柜号:XXXX1234567 派送单,请查收`;提柜+柜型+卡派/拆柜 | 预报表单 |
| `transfer` | 新增转仓、转仓(`不转仓` 除外) | 批量转仓;CC 到仓闸门失败则降级工单 |
| `work_order` | 贴标/换标/覆盖贴/操作指令、拍照、拦截(非渠道件数)、快递单号;软词拆柜清单/卡转海/派送要求/改自提/更新派送单/改地址派送/点前送达 | 客户留言 / 转渠道 / 拆分 / 出库 |
| `do_upload` | DO请查收、请查收 DO、文件名独立 DO 的 PDF | 上传 DO |
| `customer_instruction` | 按照正常预报接受、帮忙安排派单、继续暂存 | **只读**,不提交 |
跳过(不出卡):QQ 转发壳、`发自我的iPhone`、空 Original、主题残片。
**可确认的一般是最新一轮有实质指令的段。** 历史预报/转仓可只读展示;历史转仓现在也允许单独执行。
同线程主题只变 Re:/Fw: 时,**不得**再用首封主题「新增预报」给最新轮再造一张预报卡。
### 3.3 附件挂到哪张卡
实现:`match-segment-attachments.ts`。顺序:
1. 主键交叉:附件名与本段出现同一 `FBA…` / `YT…` / `X0…`(FNSKU)/ `WL…` 唛头。**不用 ISO 柜号**给工单分附件。
2. 正文/主题出现该文件主名(去扩展名、去 `(1)`)。
3. 仅**当前可确认段**角色兜底:文件名含卡转海 / 换标|覆盖贴|操作指令|贴标交付。
4. `*DO*.pdf` 只进上传 DO 卡。
5. 卡派/装箱 xlsx 是解析源,默认不进工单附件。
6. 跳过 `*邮箱.pdf` / QQ 截图。
---
## 4. 预报(NEW_CONTAINER / forecast)
### 4.1 主题柜头:两种互斥模板
**A. 客户+提单预报(`bl-plus-template.ts`)**
剥掉 `Fw:/转发/新增预报N:` 后,用 **`+`(或全角+)** 分段。无 `+` 才按空格切,二者互斥。
常见形态:
`新辰泽+WHLC027G597465+WHSU8127240+洛杉矶+40HQ+EDT2026.04-25+ETA2026.05-15+船名航次HMM EMERALD 013E+提拆派+不带托架`
能抽出:客户名、提单、柜号、目的港(**美国港口白名单**)、柜型、ETD/ETA、船名航次、服务(提拆派/直送)、是否带车架。
客户名不得是柜号/提单/「贴标/转发/柜号」等。目的港未进白名单(洛杉矶/长滩/纽约/萨凡纳/奥克兰/西雅图/塔科马/休斯顿/迈阿密/查尔斯顿等)→ **不写港口**。
标签型(首段就是 `柜号:` / `提单号:`,客户不在提单前):
`Fw: Fw:回复:柜号:TRHU8524770+提单号:EGLV143661466958+封号:EMCEJJ0635+YWZD+不带车架+整柜直送`
| 段 | 落到 |
|----|------|
| `柜号:TRHU8524770` | 柜号 |
| `提单号:EGLV143661466958` | 提单 |
| `封号:EMCEJJ0635` | **备注**(预报 UI 无封号格) |
| `YWZD` | 客户(排除已知字段后仅剩 1 个独立缩写) |
| `不带车架` | 车架 = 不带 |
| `整柜直送` | 业务类型 = 直送 |
**带「名称:值」前缀的段,不得去抢客户名。** 前缀已经说明这是有名字的字段;对不上预报 UI 的(封号/铅封/预约码等)一律进备注。无前缀的短码(如 YWZD)才参与客户竞争。
**B. 工单/拆柜清单主题(`ops-subject-template.ts`)**
形态:`柜号+提单+船名航次:…+ETA:…+FedEx-29件+拦截-209件+…`
一旦命中 B,**不再当 A 的客户+提单预报**。渠道 `Xxx-N件` 只作汇总,不是整票指令。
### 4.2 正文柜头补全(`extract-container-header.ts`)
只认**带标签**的字段,不扫全文乱截:
| 字段 | 规则 |
|------|------|
| 柜型 | `\b(45HC\|45HQ\|40HC\|40HQ\|40GP\|20GP\|20HQ\|45GP)\b` |
| ETA | `ETA\|预计抵港\|抵港日\|到港` + 日期 |
| ETD | `EDT\|ETD\|预计开船\|开船日\|离港` + 日期 |
| 提单 | `提单(号)\|B/L\|BLCopy` + 号;否则跳过 ISO 柜号取裸 `ABCD`+数字串 |
| 装货港 | `装货港\|起运港\|POL` |
| 目的港 | `目的港\|卸货港\|码头\|POD` + 白名单 |
| 船司 | 显式「船司」> 提单前 2–4 位前缀 > 正文别名 > 船名首词 |
| 车架 | `带车架/带托架` vs `不带车架/不带托架` |
| 操作 | `提拆派` → 0;`直送` → 2 |
| 运输方式 | 柜型/船名航次/海运提单前缀 → 海运;否则航司别名 → 空运 |
| 封号/铅封 | `封号\|铅封\|封条` + 号 → **备注**(预报 UI 无此格) |
**正文没有这些标签时,柜头对应格就是空的**(例如只写 “arriving LA next week”)。
附件解析结果优先;邮件文本只补空字段。
### 4.3 货件行(几乎只认表格)
选表优先级(文件名):`数据模版|提拆派|卡派资料|卡派清单|装箱清单|packing` > 其它表格 > `换标|覆盖贴|贴标|操作指令`。
**卡派表**(`packing-list.ts`):
- 优先 sheet 名含「卡派」,否则第一张表。
- 前 15 行找表头:同时有「渠道」+(件数或 CTNS)+(仓库或 FBACode)。
- **必有列**:仓库ID/`FBACode`、渠道/`Transporter`、件数/`CTNS`。缺一则 `PARSE_FAILED`,再尝试贴标指令单。
- 「注意:…」模板说明行不是货件。
- 渠道映射:卡派→TRUCK,UPS/FEDEX/DHL/USPS/SF/4PX/YunExpress,自提,留仓/存仓,换标,扣货/拦截。超 40 字或「注意:」开头不当渠道。
列名别名见代码 `HEADER_ALIASES`(中英混排、`Custmer Ref No.` 错拼也认)。
**贴标指令单**(`label-instruction.ts`)表头含「贴标项目 / 详细操作指令 / 新FBA / 覆盖贴 / 换标」或同时有柜号+操作箱数。
`lineage.template=label-instruction` 时:**预报货件列表必须空**,换标行不当 SaveContainer 货件。
无表格:预报可仅柜头进待确认;货件列表空。PDF/图片卡派表 **不会**当货件解析(转仓截图 OCR 是另一条路)。
### 4.4 柜头 Instruction / 备注(不是货件行备注)
| 表单格 | CC 字段 | 实际来源 |
|--------|---------|----------|
| Instruction | `F_Instruction` 拆柜指引 | ① 预报正文段(含新增预报/提单/柜型/ETA/船名,且不含换标/贴标)截 500 字;② 否则 `船名航次 xxx`;③ 贴标单则用首行操作说明(预报卡会改回 ①②) |
| 备注 | `F_MemoRemark` | **优先**:主题/正文里带标签但对不上预报 UI 的字段(`封号:EMCEJJ0635`、`铅封:…`、`预约码:…` 等),多段用 ` / ` 拼接。**否则回退**:`客户名 / 提拆派或直送`。贴标 sheet 标题含贴标/换标/指令时预报卡会清掉。 |
| 货件「行备注」 | `F_Remark` | 卡派列「备注 / Remark / Instruction / 配送时段」 |
**未映射标签 → 备注(硬规则)**
- 段内已有 `名称:值`(或 `名称:值`)前缀,说明这是有名字的字段,**禁止**拿去填客户。
- 名称能对上预报 UI(柜号/提单/柜型/ETA/ETD/船名航次/目的港/装货港/船司/客户/运输方式/业务类型)→ 填对应格。
- 名称对不上(封号、铅封、预约码,以及其它未知标签)→ 整段原文进备注。
- 无前缀的剩余短码:仅当排除已知字段后**恰好剩 1 个**才当客户;剩多个则全部进备注,不强猜客户。
- 已占用的「柜号:」「提单号:」会从备注里剥掉,避免和柜号/提单格重复;**封号保留**。
去 QQ/Outlook/iPhone 转发壳后再填 Instruction。
---
## 5. 转仓(TRANSFER)
- 关键词:`新增转仓` / `转仓`,排除 `不转仓`。
- 货件:卡派表行备注「转 POC2」「转VGT2」等 → `extractTransferPairs`。
- 截图/OCR 表(`transfer-instruction-table.ts`):
- 标准:`地址 PSC2 箱数 7 … 仓库重定向 POC2`
- 宽松:同行 `地址 X … 仓库重定向 Y`
- 仓码归一:FWA4 / PSC2 / POC2 / VGT2(含 OCR 错写 EWAD、WAAD4 等)
- 确认时查 CC 是否已到仓;不可转则自动改提交工单。
**会漏:** 英文 transload、没有「地址/仓库重定向」的自由句、目标仓不是上述几种代码、只在 PDF 里画了箭头。
---
## 6. 工单(WORK_ORDER)
硬动作:`贴标` `拦截` `拍照` `快递单号`,同义 `换标` `覆盖贴` `贴好拍照`。
软词(也走工单卡,不硬套某张 CC 工单界面):拆柜清单、卡转海、派送要求、改自提、更新派送单、改地址派送、点前送达。
mail1:主题只有柜号+预约码、无标准词 → 分类工单,详情用 `work_order_actions` 补卡。
留言取**当前段**,从 `Dear` / `原箱号` 起,禁止整封线程盖住本段。
面板分流(管理端/客户端):拦截/改渠道 → 转渠道;数量拆分 → Split;出库/贴标文件 → 出库;其余 → 客户留言。
---
## 7. 上传 DO
文件名:`DO请查收` / `提货单` / `delivery order`,或 **PDF 且独立单词 DO**(`WHSU8127240-DO.pdf`、`xxx-DO.pdf`)。
正文写「DO 也同步上传」但**没有**这类附件 → **不出**空 DO 卡。
柜号:已有柜头 > 文件名 ISO 柜号 > 主题/正文第一个 ISO 柜号。
`.docx` / 图片扫描件不叫 DO、文件名是 `DeliveryOrder_final.pdf` 无独立 `DO` 词 → 可能认不出。
---
## 8. 金样覆盖(已按这些规则调过)
| 样例 | 期望 | 规则依赖 |
|------|------|----------|
| 邮件1 仅柜号 TIIU8073522 | 工单兜底 | ISO 柜号、无关键词 |
| 邮件2 MATU + 卡派 xlsx | 转仓 | `新增转仓` + 卡派表 |
| 邮件3 拆柜清单+渠道件数 | 工单 | 工单主题模板 + 软词;渠道件数不是拦截指令 |
| 邮件4 预报+换标+DO | 工单为主,预报/DO 可并存 | 最新段硬工单压预报;卡派表优先于换标表;DO 文件名 |
这四封能过,**不代表**换一套客户模板也能过。
---
## 9. 覆盖缺口清单(用来对照新邮件)
把新邮件贴到下面问:会在哪一层失败?
### 9.1 进不了库
- [ ] 没有 ISO 柜号,也没有第 1 节关键词
- [ ] 纯英文 booking / sailing / transload / relabel / hold
- [ ] 已配关键词过滤,新叫法不在列表、发件人不在白名单
- [ ] 主题乱码(GBK/UTF-8 错码)导致关键词对不上
### 9.2 能进库但类型错 / UNKNOWN
- [ ] 同义说法:新增订舱、做预报、改仓、贴新标、hold cargo、stop shipment
- [ ] 「转」但没写「转仓」;「不要转仓」以外的否定
- [ ] 预报附件不叫数据模版/装箱清单/卡派资料
- [ ] 主题预报 + 正文最新段换标(会变成工单,可能符合产品,但预报卡不是当前)
- [ ] 预报与 DO 分数接近(close_call),以优先级表为准
### 9.3 类型对但字段空
- [ ] 主题不用 `+` 分隔(用空格/逗号/表格/HTML)
- [ ] 港口不在美国白名单(如西雅图别称未收录、欧洲港)
- [ ] 柜型写成 40'HQ / 40 尺柜 / HC 无数字
- [ ] 日期无 ETA/ETD 标签(只写 5/15)
- [ ] 船名不写「船名航次」四字
- [ ] 只写「备注:」标签、没有 `封号:` 这类字段名 — 当前**不**从「备注:」抽备注;未映射的 `名称:值` 才会进备注
- [ ] 封号写在无 `+`、无冒号的自由句里(如 “seal EMCEJJ0635”)
### 9.4 有附件但货件为空 / 错表
- [ ] 卡派是 PDF、图片、Word,不是 xlsx/xls/csv
- [ ] 表头不叫渠道+件数+仓库(例如全英文 Warehouse / Qty 但没有渠道)
- [ ] 缺仓库ID/渠道/件数三列之一
- [ ] 只有换标指令单被当成预报行(文件名若不像卡派,可能先吃换标表;预报卡会清空货件)
- [ ] 表在 zip 内层路径/加密压缩包
- [ ] 附件过大被拒
### 9.5 转仓 / 工单 / DO 细节
- [ ] 转仓目标仓不是 PSC2/POC2/FWA4/VGT2
- [ ] 工单附件只靠柜号关联(规则故意不用柜号分发)
- [ ] DO 是 JPG/扫描件,或文件名无独立 DO
### 9.6 编码与客户端壳
- [ ] QQ 转发壳、iPhone「发自我的」会剥;剥完若没剩指令词则跳过该段
- [ ] 正文 HTML 未转纯文本时,标签可能打断正则
- [ ] 繁体:新增預報、轉倉、貼標 — **词表是简体**,繁体会漏
---
## 10. 扩展新格式时改哪里
不要在详情页临场「理解」一封信。按层改:
| 缺口 | 改 |
|------|----|
| 拉不进来 | 设置页关键词/白名单,或 `BUSINESS_TEXT_RE` / `isBusinessRelevantMail` |
| 类型错 | `classify.ts` 加分 + `instruction-lexicon.ts` 词表 + 单测 |
| 拆不出卡 | `KEYWORD_RULES`、切段边界、`split-instructions` |
| 主题柜头 | `bl-plus-template` 或 `ops-subject-template`(先判断像 A 还是 B);未映射标签进 `memo_bits` → 备注 |
| 正文柜头 | `extract-container-header.ts` 标签正则;港口 `us-ports.ts`;船司 `carrier-aliases.ts` |
| 卡派列名 | `packing-list.ts` `HEADER_ALIASES` |
| 贴标表 | `label-instruction.ts` `LABEL_HEADER_ALIASES` |
| 转仓 OCR | `transfer-instruction-table.ts` + 仓码归一 |
| DO 文件名 | `do-upload-extract.ts` |
| 附件挂段 | `match-segment-attachments.ts` |
流程:**先在本页记下「哪一层缺口」→ 改对应文件 → 补 `tests/unit` 或 fixtures → `pnpm test` 相关文件。**
---
## 11. 结论(给评审用)
当前规则覆盖的是:**简体中文 + 固定口头禅 + ISO 柜号 + `+` 主题模板 + 中英列名接近金样的 xlsx**。
对「还没拿到的邮件」:
- **同类客户、换柜号/换船名/换附件内容**:一般能覆盖。
- **换一套邮件习惯**(英文、繁体、无 `+`、无「新增预报」、PDF 清单、新仓码、新列名):**不能默认覆盖**,会按第 9 节某一层失败,需要加词或加模板,而不是调模型。
建议每来一类新失败信:先标失败层(拉取 / 分类 / 拆段 / 柜头 / 表格),再决定是加关键词还是加模板,避免一条超级正则吃所有格式。

@ -42,6 +42,7 @@
| 异常与韧性 | `docx/异常场景与系统韧性设计.md` |
| 运营 SOP | `docx/运营SOP-邮件自动预报.md` |
| 产品规则(指令拆分) | `docx/产品规则-邮件指令识别与拆分.md` |
| 指令提取规则(对照代码,含覆盖缺口) | `docs/邮件指令提取规则.md` |
| CC 接口 V1.72 | `docx/接口/carriercentral客户端通用接口V1.72.md` |
| 实施计划(Cursor) | `.cursor/rules/implementation-plan.mdc` |
| 快速 README | `README.md` |

@ -0,0 +1,784 @@
# 项目全貌与开发指南
> **文档定位**:`X:\work\yx`(包名 `email-forecast`)后续开发的唯一权威参考(系统核心知识库)。
> **产品名**:邮件自动预报系统
> **形态**:Next.js 运营 Web + IMAP Worker + MySQL,**不是**桌面助手、不是客服渠道。
> **代码根目录**:`X:\work\yx`
> **文档版本**:v1.0
> **编写日期**:2026-08-18
> **对照材料**:`README.md`、`docs/邮箱项目.md`、`docx/` PRD/技术设计、当前源码。
> **密钥纪律**:只写配置**键名与路径**,不写 IMAP 授权码、CC 密码、OAuth secret 的具体值。
与 `carriercentral-ai-assistant` 的关系:yx 是**运营后台**(拉邮件 → 解析 → 人工确认 → 写 CarrierCentral SaaS);助手仓是**桌面查询 Demo**(读仓)。两边打同一套海外仓 HTTP,但进程、UI、状态机完全独立。把 yx 能力迁到助手仓时,以本文模块名为地图,**禁止**把 Worker 的 SaveContainer 禁令或 IMAP 轮询直接塞进 Tkinter。
---
## 0. 阅读路径与权威优先级
材料冲突时,优先级从高到低:
1. **正在运行的代码**:`src/app/**`、`src/services/**`、`src/worker/index.ts`、`prisma/schema.prisma`
2. **行为契约**:`docx/需求规格-邮件自动预报-v0.2.md`、`docx/接口/carriercentral客户端通用接口V1.72.md`
3. **协作入口**:`docs/邮箱项目.md`(红线、排障、上市清单)
4. **指令规则**:`docx/产品规则-邮件指令识别与拆分.md`、`docs/邮件指令提取规则.md`
5. **速查**:`README.md`
改识别规则先改 `docx/产品规则-*` 再改 `src/services/parse/`。改 CC 路径以 V1.72 为准;文档外 endpoint 默认 mock。
---
## 1. 技术栈与全局依赖
### 1.1 系统形态
三进程,无独立消息队列、无 Redis、无 GraphQL。
| 组件 | 本仓库状态 |
|------|------------|
| 前端 | **Next.js 15 App Router** + **React 18** + **Ant Design 5** |
| 后端 | 同进程:`src/app/api/**/route.ts`(Route Handlers) |
| Worker | 独立进程 `src/worker/index.ts`(`tsx` 跑源码) |
| ORM | **Prisma 5** + **mysql2** |
| 数据库 | **MySQL 8**(库名 `email_forecast`,宿主机端口 **7023**) |
| 缓存 | **无 Redis**。CC token 在表 `cc_token_cache`;会话在 Cookie |
| 消息队列 | **无**。补偿/自动执行靠 Worker 轮询 `import_compensation.next_retry_at` |
| 状态管理 | **无 Redux/Pinia**。客户端 `useState` + hooks(`useMails` / `useMailDetail`)+ `AuthSessionProvider` |
| 包管理 | **仅 pnpm**(不要 npm/yarn) |
### 1.2 关键三方库(`package.json`)
| 包 | 用途 |
|----|------|
| `next` ^15.1.3 | App Router、API、生产 `next start -p 3100` |
| `react` / `react-dom` 18 | UI |
| `antd` ^5.22 + `@ant-design/icons` + `cssinjs` + React19 patch | 运营后台组件库 |
| `@prisma/client` / `prisma` | schema → MySQL |
| `mysql2` | Prisma 驱动;`db-lock.ts` 独立连接做 `GET_LOCK` |
| `iron-session` | Cookie 会话 `email_forecast_session` |
| `imapflow` | IMAP 拉取 / IDLE |
| `mailparser` | 解析 eml |
| `exceljs` | 装箱清单 / 转仓表 xlsx |
| `adm-zip` | zip 解一层 |
| `tesseract.js` | `OCR_PROVIDER=local` 启发式 OCR |
| `undici` | CC HTTP(`src/services/cc/http.ts`) |
| `zod` | env 与 API 入参校验 |
| `pino` | 结构化日志 `src/lib/logger.ts` |
| `uuid` | 幂等 key、补偿 `retry_token`、trace |
| `@tanstack/react-virtual` | 确认页大表 `ShipmentVirtualTable` |
开发:`vitest`、`@playwright/test`、`tsx`、`typescript`。Lint:`next lint`(无独立 ESLint/Prettier 配置文件)。
### 1.3 本仓库明确没有的技术
- Vue / 桌面 Tkinter / LangChain Agent
- Redis / Kafka / RabbitMQ
- 多租户 SaaS 账号体系(只有 admin/ops 两角色)
- SMTP 自动回信、真实大模型指令识别(词表/规则)
---
## 2. 目录结构详解
组织逻辑:**按运行时分层 + 按业务域分子目录**。
```
页面/API(src/app)
→ hooks / components(UI)
→ services(imap / parse / import / cc)
→ lib(env / session / prisma 辅助)
→ prisma + MySQL
Worker(src/worker)直接调同一套 services,禁止走 ConfirmImport 写新增预报。
```
### 2.1 仓库根
| 路径 | 职责 |
|------|------|
| `package.json` | 脚本与依赖;`name: email-forecast` |
| `pnpm-lock.yaml` | 锁文件 |
| `docker-compose.yml` | `mysql` + `web` + `worker` |
| `Dockerfile` | 多阶段 Node 20 bookworm;web 入口 `docker-entrypoint-web.sh` |
| `next.config.ts` | Antd transpile、关 dev 指示标、client 禁 `async_hooks` |
| `tsconfig.json` | `@/*` → `./src/*`,strict |
| `tsconfig.worker.json` | Worker 编译(compose 实际用 tsx 跑源码) |
| `vitest.config.ts` | 单测 + coverage |
| `playwright.config.ts` | e2e,baseURL `3100` |
| `playwright.test-mode.config.ts` | TEST_MODE 端口 3110 |
| `.env.example` | 环境变量模板 |
| `prisma/schema.prisma` | 全表 |
| `prisma/seed.ts` | 仅账号 + 部分 write capability |
| `scripts/` | 启动栈、IMAP/CC 烟雾、retention、样例导入 |
| `fixtures/emails/` | 01–16 金样例(spec + expected) |
| `docx/` | PRD / 技术设计 / UI / 韧性 / 金样例 JSON / CC 接口 |
| `docs/` | 协作文档 + 本知识库 + Windows 一键脚本 |
| `data/` | 运行时 eml/附件/日志(gitignore);compose 挂载 `./data` |
| `tests/unit` `tests/integration` `tests/e2e` | 测试 |
### 2.2 `src/app/` — 页面与 API
| 路径 | 职责 |
|------|------|
| `layout.tsx` | 根:`lang=zh-CN`、`AntdProvider`、标题「邮件自动预报」 |
| `globals.css` | 全局样式 |
| `error.tsx` | 错误页 |
| `page.tsx` | `/` → `redirect("/mails")` |
| `(auth)/login/page.tsx` | 登录 |
| `(ops)/layout.tsx` | `AuthGuard` + `TopNav` + `main.ops-main` |
| `(ops)/mails/page.tsx` | 邮件列表 |
| `(ops)/mails/[id]/page.tsx` | 详情 |
| `(ops)/mails/[id]/confirm/page.tsx` | 确认导入(按 instruction 分发视图) |
| `(ops)/logs/page.tsx` | 导入日志 / 拉取记录 |
| `(ops)/settings/page.tsx` | Admin 设置 |
| `(ops)/test-workbench/page.tsx` | `TEST_MODE=1` 测试台 |
| `api/**/route.ts` | 42 个 Route Handler(见第 6 节) |
**无** `middleware.ts`。鉴权在各 route 的 `requireSession` / `requireAdmin`,页面靠客户端 `AuthSessionProvider`。
### 2.3 `src/services/` — 业务核
| 目录 | 职责 |
|------|------|
| `imap/` | `poller.ts` 轮询;`idle-supervisor.ts` IDLE;`snapshot.ts` 落 eml;`pull-filter.ts` 过滤;`mailbox-config.ts` 多箱;`stale-reaper.ts` 超时回收;`runtime-status.ts` 连续失败 |
| `parse/` | `pipeline.ts` 主解析;`classify.ts` 类型;`mail-intent.ts` 意图文案;`packing-list.ts` 装箱表;`split-instructions.ts` 指令拆分;`extract-container-header.ts` 柜头 |
| `import/` | `confirm.ts` **ConfirmImport**(唯一新增预报 SaveContainer);`compensation.ts` 补偿;`auto-exec.ts` 指令自动写;`confirm-do-upload.ts` / `confirm-batch-transfer.ts` / `confirm-work-order.ts` |
| `cc/` | `http.ts` `auth.ts` `save-container.ts` `do-upload.ts` `conflict.ts` `shipping-line.ts` `mock-*` `write-capability.ts` `settings-config.ts` |
| `ocr/` | `index.ts` local/aliyun/off;`sanitize.ts` |
| `oauth/` | `providers.ts` Google/MS;state HMAC |
| `customer/` | `resolve-sender.ts` 发件人→客户编码 |
| `alert/` | `webhook.ts` 企微/飞书 JSON POST |
| `retention/` | `cleanup.ts` |
| `test/` | TEST_MODE 工作台、fixture 评测 |
| `db.ts` | Prisma 单例 |
| `db-lock.ts` | **独立 mysql2 连接** `GET_LOCK` / `RELEASE_LOCK` |
| `state-machine.ts` | `assertTransition` / `resolveAfterParse` |
| `audit.ts` | 审计日志 |
### 2.4 `src/lib/` `src/components/` `src/hooks/` `src/constants/` `src/types/`
| 路径 | 职责 |
|------|------|
| `lib/env.ts` | Zod 校验环境;生产弱密钥拒绝启动 |
| `lib/session.ts` | iron-session;`verifyCredentials` **读 .env 账号,不读 `app_user` 表** |
| `lib/api.ts` | `ok`/`fail`、`requireSession`、`requireAdmin`、BigInt JSON |
| `lib/safe-path.ts` | 附件/快照必须在 `data/` 下 |
| `lib/cc-api-base.ts` | CC `api_base` 主机白名单(防 SSRF) |
| `lib/rate-limit.ts` | 进程内滑动窗口 |
| `lib/secret-crypto.ts` | AES-GCM,密钥派生自 `SESSION_SECRET` |
| `lib/logger.ts` | pino |
| `lib/mail-trace.ts` | 单封 `traceId` |
| `lib/test-mode.ts` / `test-mode-guard.ts` | TEST_MODE;测试 API 非测试环境返回 404 |
| `components/TopNav.tsx` | 顶栏:邮件 / 日志 / 设置(admin) / 测试台 |
| `components/MailTable.tsx` | 列表 |
| `components/confirm/*` | 确认页:预报 / DO / 转仓 / 工单 |
| `components/Cc*.tsx` | 对照 CC 表单的运营控件 |
| `hooks/useMails.ts` `useMailDetail.ts` `useImport.ts` `useCcUiMode.ts` | 客户端数据 |
| `constants/ui-copy.ts` `error-copy.ts` | 中文文案 |
| `types/mail.ts` | `MailType` / `MailStatus` / 柜头货件字段 |
### 2.5 `src/worker/index.ts`
常驻循环 `scheduleLoop`:
1. `ImapPoller.tick()`
2. `staleReaper.run()`(`PARSING`/`IMPORTING` 超时打回)
3. `drainFetchedMails(10)` 消化 `FETCHED`
4. `processDueAutoExec(10)`
5. `processDueCompensations(10)`
启动:`ImapPoller.clearStaleLock()`、`ensureCcWriteCapabilities()`、`IdleSupervisor.start()`。
吞掉 IMAP `ETIMEOUT` 的 `uncaughtException`,避免整进程退出。
---
## 3. 入口与启动流程
### 3.1 入口一览
| 入口 | 实质 |
|------|------|
| `pnpm dev` / `dev:stack` | `scripts/dev-stack.mjs`:同时 `next dev -p 3100` + `tsx src/worker/index.ts` |
| `pnpm dev:web` | 仅 Web,**不拉信** |
| `pnpm worker` | 仅 Worker |
| `pnpm start` | 生产 Next `:3100` |
| `.\docs\start-system.ps1` | Windows:mysql 容器 + 本机 web/worker(默认不拉 node 镜像) |
| `pnpm compose:up` | docker 三件套 |
| `prisma/seed.ts` | 账号种子 |
无 FastAPI `app.include_router`。Next 按文件系统挂载路由。
### 3.2 Web 启动全过程
```
pnpm dev:web
→ Next 加载 next.config.ts
→ 首次请求触发 getEnv()(读 process.env,Zod)
→ prisma 单例连 DATABASE_URL
→ App Router:
/ → redirect /mails
(ops)/* → 客户端 AuthSessionProvider 调 GET /api/auth/me
未登录 → /login
→ API:各 route 内 requireSession / requireAdmin
```
**没有**全局中间件链。登录:`POST /api/auth/login` → `verifyCredentials`(env)→ `session.save()`。
Docker web:`scripts/docker-entrypoint-web.sh`(`SEED_ON_START` 时可 db push + seed)→ `pnpm start`。compose 把 `DATABASE_URL` 改成 `mysql://app:app@mysql:3306/email_forecast`。
注意:`Dockerfile` `EXPOSE 3000`,实际监听 **3100**(`package.json` `start`)。
### 3.3 Worker 启动全过程
```
tsx src/worker/index.ts
→ getEnv()
→ clearStaleLock(imap_poll)
→ ensureCcWriteCapabilities() upsert 写能力行
→ IdleSupervisor.start() 各箱 IDLE(IMAP_IDLE_ENABLED 且账号 idle_enabled)
→ scheduleLoop:
tick → 按 imap_settings.pollIntervalMs(否则 POLL_INTERVAL_MS)+ 连续失败退避
```
数据库:compose `mysql` healthcheck 通过后 web/worker 才起。本地需先 `docker compose up -d mysql` + `pnpm exec prisma db push`。
---
## 4. 核心模块与依赖关系
### 4.1 模块划分
| 模块 | 关键文件 | 职责 |
|------|----------|------|
| 认证 | `lib/session.ts` `api/auth/*` | admin/ops Cookie |
| 邮箱绑定 | `imap/mailbox-config.ts` `api/settings/mailboxes*` `oauth/*` | 多箱、授权码/OAuth |
| IMAP 拉取 | `imap/poller.ts` `pull-filter.ts` `snapshot.ts` | 入库 `mail_message` |
| 解析 | `parse/pipeline.ts` `classify.ts` | 类型 + 柜头 + 货件 + 指令 |
| 状态机 | `state-machine.ts` | 合法迁移 |
| 确认导入 | `import/confirm.ts` | 预报 SaveContainer(仅 web) |
| 指令写 CC | `confirm-do-upload` / `confirm-batch-transfer` / `confirm-work-order` / `auto-exec` | DO/转仓/工单 |
| CC HTTP | `cc/http.ts` `auth.ts` | customerLogin + 写接口 |
| 冲突锁 | `cc/conflict.ts` `container_active_lock` | 同柜互斥 |
| 补偿 | `import/compensation.ts` | TIMEOUT_UNKNOWN 重试 ≤3 |
| 设置 | `api/settings/*` | IMAP 间隔、过滤、CC、OAuth |
| 可观测 | `api/health` `alert/webhook.ts` `imap-pull-log` | 健康与告警 |
| TEST | `api/test/*` `TestWorkbench` | 仅 TEST_MODE |
调用方向(禁止反向):
```
页面 → hooks/client-api → Route Handler → services.* → prisma / CC / IMAP
Worker → services.imap|parse|import.compensation|auto-exec
Worker ──X── ConfirmImport.execute(新增预报)
```
### 4.2 公共层
| 能力 | 文件 |
|------|------|
| API 信封 `{ok, data}` / `{ok:false, error}` | `lib/api.ts` |
| 限流 | `lib/rate-limit.ts` |
| 命名锁 | `db-lock.ts`(禁止 Prisma 池直接 GET_LOCK) |
| 路径安全 | `safe-path.ts` |
| 审计 | `audit.ts` |
| 中文错误 | `constants/error-copy.ts` `formatOperatorError` |
### 4.3 页面层级与组件树
```
RootLayout (AntdProvider)
/login 无 TopNav
OpsLayout (AuthGuard + TopNav)
/mails MailTable + 筛选
/mails/[id] 详情:摘要、附件、重新解析、改类型(admin)
/mails/[id]/confirm 按 instruction_id:
ForecastConfirmView
DoUploadConfirmView
TransferConfirmView
WorkOrderConfirmView
InstructionConfirmPicker
/logs 导入 + ImapPullLogsPanel
/settings Admin:ImapPullSettingsCard、邮箱、CcSettingsCard、OauthSettingsCard
/test-workbench TestWorkbench(TEST_MODE)
```
顶栏 `TopNav`:`ImapStatusBanner` + 菜单 + 角色 Tag + 登出。
确认页 `confirm/page.tsx`:`extractInstructionsFromMail` → `resolveConfirmInstruction`;无 `instruction_id` 时 `router.replace(confirmHref(...))`。
---
## 5. 数据模型与数据流
### 5.1 表结构(`prisma/schema.prisma` → MySQL)
```
mailbox_account 1──* mail_message 1──* mail_attachment
│
├──1 parse_result(柜头/货件 JSON)
└──* container_import 1──* import_compensation
container_active_lock(柜号主键,跨邮件互斥)
cc_token_cache / cc_settings / cc_write_capability / oauth_settings
imap_settings / imap_filter_rule / imap_pull_log
sender_customer_map
app_user ← seed 写入;登录当前不查此表
```
**MailboxAccount** `mailbox_account`:IMAP 主机/端口/用户;`password_enc` AES-GCM;`auth_type` `PASSWORD|OAUTH`;OAuth 令牌密文与过期;`idle_enabled`;`username` 唯一。
**MailMessage** `mail_message`:幂等 `message_id`、`(mailbox, folder, imap_uid)`、`raw_hash`;`mail_type`;`status`;`type_evidence` JSON;`snapshot_path`;`trace_id`;`version`(乐观锁,导入用);正文/OCR/线程字段。
**MailAttachment**:`sha256`、`path`(相对 `data/`)、`rejected`、`template_id`。
**ParseResult**:`container_header` / `shipments` / `lineage` JSON,与 `types/mail.ts` 的 `ContainerHeader`、`ShipmentRow` 对齐。
**ContainerImport**:每次向 CC 提交一柜;`shipments_hash` 参与唯一键 `uk_mail_container_shiphash`;`external_id` 为 CC 返回 id。
**ImportCompensation**:`reason`、`retry_count`/`max_retry`、`next_retry_at`、`retry_token` UUID。
**CcSettings** id=1:mock、api_base、saas、login_mark、username、`password_enc`。优先于 `.env`。
**CcWriteCapability**:`save_container` / `do_upload` / `transfer` / `batch_transfer` / `hold_split` / `label` / `customer_message`,mode `live|mock|off`。
**ImapSettings** id=1:`poll_interval_ms` 默认 1800000(30min),合法 3min~7d。
**ImapFilterRule**:`kind` + `value`(关键词/白名单/黑名单等)。
**ImapPullLog**:每封拉取结果,最多约 1000 条。
**AppUser**:username + sha256 `password_hash` + role。**现行登录走 env,此表不参与鉴权**(隐式债务)。
### 5.2 邮件状态机(`state-machine.ts`)
| from | 允许 to |
|------|---------|
| FETCHED | PARSING, IGNORED |
| PARSING | PENDING_CONFIRM, PARSED, PARSE_FAILED, REJECTED_VALIDATION, AUTO_EXECUTING, FETCHED |
| PARSED | PARSING, PENDING_CONFIRM, AUTO_EXECUTING, IGNORED |
| PENDING_CONFIRM | IMPORTING, PARSING, IGNORED, PENDING_CONFIRM |
| IMPORTING | SUCCESS, PARTIAL_SUCCESS, FAILED |
| AUTO_EXECUTING | SUCCESS, FAILED, PARSED |
| SUCCESS | (终态) |
| PARTIAL_SUCCESS | IMPORTING |
| FAILED | IMPORTING, PENDING_CONFIRM, PARSING, AUTO_EXECUTING, IGNORED |
| PARSE_FAILED | PARSING, IGNORED |
| REJECTED_VALIDATION | PARSING, IGNORED, PENDING_CONFIRM |
| IGNORED | (终态) |
非法迁移抛 `IllegalTransition`。`IMPORTING`/`SUCCESS`/`PARTIAL_SUCCESS` 禁止重新解析。
`resolveAfterParse`:`NEW_CONTAINER` 且有效货件≥1 → `PENDING_CONFIRM`;`WORK_ORDER`/`DO_UPLOAD`/`TRANSFER` → `PENDING_CONFIRM`;`autoExecEligible` → `AUTO_EXECUTING`;否则 `PARSED`。
### 5.3 邮件类型 `MailType`(`types/mail.ts`)
`NEW_CONTAINER` | `TRANSFER` | `DO_UPLOAD` | `INSTRUCTION_HOLD_SPLIT` | `INSTRUCTION_LABEL` | `WORK_ORDER` | `UNKNOWN`。
分类在 `classify.ts`(主题/正文/附件名打分),不是 LLM。
### 5.4 主数据流
```
IMAP UID
→ pull-filter(黑名单拒绝 → 白名单必拉 → 关键词;皆空则 isBusinessRelevantMail)
→ snapshot 落 data/ + Prisma upsert(幂等)
→ 成功后 IMAP \Seen(失败且未入库则不标已读)
→ status FETCHED
→ ParsePipeline(Worker drain 或拉取后立即 parse)
→ parse_result + PENDING_CONFIRM / PARSED / ...
→ 运营打开 /mails/[id]/confirm
→ POST /api/mails/:id/import
ConfirmImport.execute
version 校验 → 柜冲突检查 → 状态 IMPORTING
→ saveContainer(CC 或 mock)
→ SUCCESS / 补偿 TIMEOUT_UNKNOWN
→ 浏览器列表刷新
```
前端:`useMails` → `GET /api/mails`;详情 `useMailDetail` → `GET /api/mails/:id`。无全局 store。
导入状态 `ImportStatus`:`PENDING|IMPORTING|SUCCESS|FAILED|CONFLICT|TIMEOUT_UNKNOWN`。
---
## 6. API 接口与页面路由
统一响应:`{ ok: true, data }` 或 `{ ok: false, error: { code, message, details? } }`。BigInt 转字符串。
权限:`public` / `session`(admin|ops)/ `admin`。`/api/test/*` 另需 `TEST_MODE` 否则 404。
### 6.1 认证与健康
| 方法 | 路径 | 权限 | 说明 |
|------|------|------|------|
| POST | `/api/auth/login` | public | 限流 10/分/IP+用户名;写 Cookie |
| POST | `/api/auth/logout` | Cookie | 销毁会话 |
| GET | `/api/auth/me` | session | 当前用户 |
| GET | `/api/health` | **public** | mysql、IMAP 告警、`cc_mode`/`cc_live_ready`、`test_mode`;勿塞密钥 |
### 6.2 邮件
| 方法 | 路径 | 权限 | 说明 |
|------|------|------|------|
| GET | `/api/mails` | session | 分页筛选 `mail_type`/`status`/`content_kind`/`q`/`sort` |
| DELETE | `/api/mails` | session | `ids` 或 `oldest` 批量删 |
| GET | `/api/mails/[id]` | session | 详情+解析+附件 |
| POST | `/api/mails/[id]/import` | session | ConfirmImport;10/分/用户;`force_skip_conflict` 仅 admin |
| POST | `/api/mails/[id]/reparse` | session | 重新解析 |
| POST | `/api/mails/[id]/type` | admin | `ENABLE_TYPE_OVERRIDE`;改 `mail_type` |
| POST | `/api/mails/[id]/ignore` | admin | → IGNORED |
| GET | `/api/mails/[id]/attachments/[attId]/download` | session | `resolveDataFile` 读盘 |
| POST | `/api/mails/[id]/ocr-attachments` | session | 触发附件 OCR |
| POST | `/api/mails/[id]/do/confirm` | session | DO 确认上传 |
| POST | `/api/mails/[id]/transfer/confirm` | session | 转仓确认 |
| GET | `/api/mails/[id]/transfer/gate` | session | 转仓门禁 |
| POST | `/api/mails/[id]/work-order/confirm` | session | 工单确认 |
### 6.3 导入 / 补偿 / 船司 / 拉取日志
| 方法 | 路径 | 权限 | 说明 |
|------|------|------|------|
| GET | `/api/imports` | session | 导入日志 |
| POST | `/api/imports/[importId]/retry` | session;部分 admin | 失败重试 |
| POST | `/api/compensations/[id]/retry` | session / admin 分支 | 补偿重试 |
| GET | `/api/shipping-lines` | session | CC `GetShippingLineList` |
| GET | `/api/imap-pull-logs` | session | 拉取记录 |
| POST | `/api/admin/imap/poll` | admin | 手动拉一轮,锁等待 20s |
### 6.4 设置(均 admin)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET/PUT | `/api/settings/imap` | 拉取间隔 |
| GET/POST | `/api/settings/imap/filters` | 过滤规则 |
| PATCH/DELETE | `/api/settings/imap/filters/[id]` | 单条规则 |
| GET/POST | `/api/settings/mailboxes` | 邮箱列表/新增 |
| PATCH/DELETE | `/api/settings/mailboxes/[id]` | 改/删 |
| POST | `/api/settings/mailboxes/[id]/test` | 测连通 |
| POST | `/api/settings/mailboxes/probe` | 探测 |
| GET/PUT | `/api/settings/cc` | CC 连接(api_base 白名单) |
| POST | `/api/settings/cc/test` | CC 测试 |
| GET/PUT | `/api/settings/oauth` | OAuth 应用凭证 |
### 6.5 OAuth
| 方法 | 路径 | 权限 | 说明 |
|------|------|------|------|
| GET | `/api/oauth/providers` | (见源码) | 提供商是否配置 |
| GET/POST | `/api/oauth/start` | admin | 发起绑定,带 HMAC state |
| GET | `/api/oauth/callback/[provider]` | **无登录 Cookie** | 靠 state.actor;写 mailbox 后重定向 `/settings?oauth=` |
### 6.6 TEST_MODE(`requireTestMode`)
`/api/test/faults` `inject` `pipeline` `fixtures` `reset` `run-all` `target-requests`:注入样例、切故障、跑流水线。生产必须 `TEST_MODE=false`。
### 6.7 页面路由
| 路径 | 文件 | 说明 |
|------|------|------|
| `/` | `app/page.tsx` | 跳转 `/mails` |
| `/login` | `(auth)/login/page.tsx` | 登录 |
| `/mails` | `(ops)/mails/page.tsx` | 列表 |
| `/mails/[id]` | `(ops)/mails/[id]/page.tsx` | 详情 |
| `/mails/[id]/confirm` | `confirm/page.tsx` | 确认;query `instruction_id` |
| `/logs` | `(ops)/logs/page.tsx` | 日志 |
| `/settings` | `(ops)/settings/page.tsx` | Admin |
| `/test-workbench` | `(ops)/test-workbench/page.tsx` | 测试台 |
---
## 7. 关键业务流程
### 7.1 登录
1. `POST /api/auth/login` `{username, password}`
2. `checkSlidingWindow` → `verifyCredentials` 比对 `APP_ADMIN_*` / `APP_OPS_*`
3. iron-session 写入 `username`+`role`
4. 客户端进 `(ops)`,`AuthSessionProvider` 调 `/api/auth/me`
状态:未登录只能 `/login`;Cookie `HttpOnly`,生产 `secure`。
### 7.2 IMAP 拉取 → 解析(Worker)
```
Idle 或 定时 tick
→ withMysqlNamedLock("imap_poll")
→ resolveEnabledMailboxes(DB 优先,否则 .env IMAP_*)
→ SEARCH SINCE lookback(默认 3 天,已读+未读)
→ 每 UID:decidePull → 跳过则 stub + pull_log
→ FETCH + parseMailSource + saveMailSnapshot
→ upsert mail_message(P2002 幂等)
→ ParsePipeline / 或 FETCHED 待 drain
→ markSeen
```
关键函数:`ImapPoller.tick`、`pollMailbox`、`ParsePipeline`(`pipeline.ts`)、`drainFetchedMails`。
### 7.3 新增预报确认导入(核心交易)
```
运营:有效货件 + PENDING_CONFIRM
→ /mails/[id]/confirm → ForecastConfirmView
→ POST /api/mails/:id/import
version + idempotency_key + F_TransMode + F_OperationType
+ container_header 白名单 + selected_row_indexes
→ ConfirmImport.execute(import/confirm.ts)
assertTransition → IMPORTING
checkContainerConflict / acquireContainerActiveLock
buildSaveContainerEntity → saveContainer
live: ccAuth + POST /Container/SaveContainer
mock: mockRegisterContainer
成功 SUCCESS + external_id
超时 TIMEOUT_UNKNOWN → import_compensation
```
Worker **禁止**走这条 SaveContainer。门禁:`forecast-confirm-gate.ts` / `canEnterForecastConfirm`。
渠道未映射须 `ack_unmapped_channels`。强制跳冲突:Admin + `ENABLE_FORCE_IMPORT` + 审计。
### 7.4 DO / 转仓 / 工单
确认页按 `mail_type` 与 `instruction_id` 分发:
- DO → `confirm-do-upload.ts` → `cc/do-upload.ts`(`SaveFieldValue` + `/learun/adms/annexes/upload`)
- 转仓 → `confirm-batch-transfer.ts` / `batch-transfer.ts`(默认 capability **mock**)
- 工单 → `confirm-work-order.ts`
自动执行:`auto-exec.ts` + `ENABLE_AUTO_EXEC_*`;新增预报仍以人工确认为准。
### 7.5 补偿与超时回收
- `IMPORTING` 超过 `IMPORTING_TIMEOUT_MS`(默认 180s)→ reaper 打回
- `PARSING` 超过 `PARSING_STALE_MS`(默认 600s)→ 回 FETCHED
- 补偿 due:`processDueCompensations` 最多 `COMPENSATION_MAX_RETRY=3`
- IMAP 连续失败 ≥3 且配置了 `ALERT_WEBHOOK_URL` → POST JSON(15 分钟去重)
---
## 8. 配置与环境管理
### 8.1 切换方式
无独立 `config/dev.yml`。环境 = `.env` + 设置页 DB(IMAP/CC/OAuth **DB 优先于 env**)。
| 场景 | 做法 |
|------|------|
| 本地 | `.env` 指向 `localhost:7023`;`CC_MOCK=true` |
| Docker | compose 覆盖 `DATABASE_URL=@mysql:3306` |
| 生产 | `NODE_ENV=production` + 强密钥;弱口令 **拒绝启动**(`env.ts` `enforceProductionSecrets`) |
| TEST | `TEST_MODE=1`;与 `CC_MOCK` 正交 |
换 `SESSION_SECRET`:全员登出,且 **邮箱密码密文无法解密,须重新保存**。
### 8.2 环境变量(`getEnv` / `.env.example`)
| 键 | 含义 |
|----|------|
| `DATABASE_URL` | Prisma MySQL |
| `POLL_INTERVAL_MS` | Worker 间隔回退;设置页优先 |
| `DATA_RETENTION_DAYS` | 保留天数;Worker 不再自动删,用 `pnpm retention:cleanup` |
| `ENABLE_TYPE_OVERRIDE` | Admin 改邮件类型 |
| `ENABLE_FORCE_IMPORT` | 强制跳过柜冲突 |
| `ENABLE_ISO_CHECK` | 柜号 ISO6346 |
| `IMAP_*` | 主机/端口/用户/超时/lookback/每轮上限/IDLE/代理 |
| `CC_API_BASE` `CC_SAAS_HEADER` `CC_LOGIN_MARK` | SaaS |
| `CC_USERNAME` `CC_PASSWORD_PLAIN` `CC_PASSWORD_MD5` | 回退凭证 |
| `CC_MOCK` | 全局写 mock |
| `CC_HTTP_TIMEOUT_MS` `CC_READ_TIMEOUT_MS` | 写/读超时 |
| `APP_ADMIN_*` `APP_OPS_*` | **实际登录账号** |
| `SESSION_SECRET` | ≥32;会话 + AES 派生 |
| `TEST_MODE` | Mock IMAP/CC 故障注入 |
| `ALERT_WEBHOOK_URL` | 告警 |
| `OCR_PROVIDER` | `off\|local\|aliyun` |
| `OAUTH_*` | Gmail/MS 应用 |
| `ENABLE_AUTO_EXEC_*` `AUTO_EXEC_REQUIRE_CONFIRM` | 自动执行开关 |
| `SHIPMENT_ROW_SOFT/HARD_LIMIT` `ATTACHMENT_MAX_BYTES` | 清单/附件上限 |
### 8.3 端口约定(红线)
Web **3100**;MySQL 对外 **7023**。不要改成 3306/3000 当本地默认。
---
## 9. 构建、测试与部署
### 9.1 脚本(`package.json`)
| 脚本 | 作用 |
|------|------|
| `dev` / `dev:stack` | web+worker |
| `dev:web` / `dev:turbo` | 仅 Next |
| `build` | `next build` + `tsc -p tsconfig.worker.json` |
| `start` | `next start -p 3100` |
| `worker` | `tsx src/worker/index.ts` |
| `lint` | `next lint` |
| `test` / `test:coverage` / `test:all` | vitest |
| `test:e2e` / `test:e2e:test-mode` | Playwright |
| `db:*` | generate / migrate / seed / push |
| `cc:smoke` `imap:smoke` `imap:poll` | 联调 |
| `compose:*` | docker |
| `retention:cleanup` | 清旧数据 |
| `postinstall` | `prisma generate` |
### 9.2 日常命令
```bash
pnpm install
cp .env.example .env
docker compose up -d mysql
pnpm exec prisma db push
pnpm db:seed
pnpm dev # http://localhost:3100 admin/admin123
pnpm test
pnpm test:e2e # 需已起服务或交给 webServer 拉 pnpm dev
pnpm build && pnpm start
```
Windows:`.\docs\start-system.ps1`(`-Mode compose` 需 Docker Hub)。
### 9.3 部署要点
- 必须 **web + worker** 都活;worker 建议**单副本**(全局 `imap_poll` 锁)
- 持久化:MySQL 卷 + `./data`(eml、附件、`imap-runtime.json`)
- 生产:`pnpm db:migrate`(`migrate deploy`),不要用弱口令 seed 覆盖生产
- 健康检查:`GET /api/health`
- compose worker 命令必须是 `tsx src/worker/index.ts`(dist 里 `@/` 别名会 MODULE_NOT_FOUND)
测试目录:`src/**/*.test.ts`、`tests/unit`、`tests/integration/pipeline-fixtures.test.ts`、`tests/e2e`。覆盖率排除 `src/app` 与 `src/components`。
---
## 10. 代码规范与约定
来源:`docs/邮箱项目.md` §4–5、源码实践。
| 项 | 约定 |
|----|------|
| 语言 | TypeScript strict;API 入参 Zod |
| 命名 | 文件 kebab-case;函数 camelCase;Prisma model PascalCase;表 snake_case |
| 路径别名 | `@/` = `src/` |
| API | `ok`/`fail`;错误码大写下划线(`UNAUTHORIZED`、`VERSION_CONFLICT`) |
| 日志 | pino 对象字段 + 短消息(`worker.imap_tick`);禁止打完整密码/token |
| UI 文案 | `src/constants/ui-copy.ts`;中文 |
| CC 柜头覆盖 | 仅 `pickContainerHeaderPatch` 白名单 |
| 读盘 | 只走 `resolveDataFile` / `assertUnderRoot` |
| 锁 | 只走 `db-lock.ts` |
| 状态 | 只走 `assertTransition` |
| 包管理 | 仅 pnpm |
| 提交 | 用户明确要求才 git commit |
| Lint | `next lint`;无 Prettier/Ruff 工程配置 |
| 破坏性变更 | 先改 `docx` 再改代码 |
角色:ops 列表/详情/确认/重解析/下载;admin 另含改类型、设置、立即拉取、忽略、FORCE 导入。
---
## 11. 现有问题与注意事项
源码几乎无 `TODO/FIXME/HACK` 标记。已知项来自 README、协作文档与代码对照。
| ID | 说明 |
|----|------|
| AUTH-TABLE | `app_user` 被 seed,登录却只认 env。改密码易误改库表无效 |
| MOCK≠TEST | `CC_MOCK` 或设置页 Mock 的成功 **不算** 上线验收 |
| CAP-MOCK | `transfer`/`hold_split`/`label`/`customer_message` 默认 mock,V1.72 无正式表 |
| OCR-LOCAL | `OCR_PROVIDER=local` 是启发式,不是商用 OCR;aliyun 路径未当正式 SDK 宣传 |
| RATE-LIMIT | 限流是进程内 Map,多副本不共享 |
| LOCK | Prisma 池 GET_LOCK 会泄漏 → 永久 `lock_busy`;用 `scripts/clear-imap-lock.ts` |
| UNIT-DRIFT | README:`mail-intent` / `extract-header` / `four-business` / `work-order-dedupe` 与 expected 不完全一致 |
| COMP-SCOPE | 工单/DO/转仓 5xx 未统一进预报补偿表,靠详情一键重试 |
| DOCKER-PORT | Dockerfile EXPOSE 3000 vs 应用 3100 |
| HEALTH-PUBLIC | `/api/health` 无鉴权(docker 依赖) |
| FILTER | 关键字未命中 `IGNORED`/`SKIP_FILTER` 不进列表;`UNKNOWN` 仍可能 `PARSED` 供人工处理 |
| PARTIAL-CC | CC 对象有 data 但仍可能 `TIMEOUT_UNKNOWN` 进补偿(缺 external id) |
高耦合:`ParsePipeline` 体量大(分类+xlsx+zip+OCR+船司匹配+指令拆分);`ConfirmImport` 与 CC 字段、状态机、锁强绑定。
风险区:`SESSION_SECRET` 兼会话与邮箱密文;worker 多开抢锁;附件路径穿越;CC `api_base` SSRF(已有白名单)。
红线(事故级,见 `docs/邮箱项目.md` §5):Worker 禁止新增预报 SaveContainer;不绕过状态机;不用 Prisma 池 GET_LOCK;mock 不当验收;zip 只解一层;冲突默认阻断;先落库再 `\Seen`;端口 7023/3100;密钥不入库。
---
## 12. 外部集成
### 12.1 CarrierCentral SaaS(核心)
| 项 | 位置 |
|----|------|
| 基址 | `CC_API_BASE` 或 `cc_settings.api_base`(默认 `https://test.saas.carriercentral.vip/api`) |
| 头 | `Saas`、登录 token(`cc/auth.ts` `customerLogin`) |
| 契约 | `docx/接口/carriercentral客户端通用接口V1.72.md` |
| 登录 | `customerLogin`;token 缓存 `cc_token_cache` |
| 预报写入 | `POST /Container/SaveContainer`(`save-container.ts`) |
| 船司 | `GET GetShippingLineList`(`shipping-line.ts`) |
| DO | `SaveFieldValue` + `/learun/adms/annexes/upload` |
| Mock | `cc/mock-target-api.ts` `mock-state.ts`;设置页或 `CC_MOCK` |
| 主机约束 | `lib/cc-api-base.ts`:`*.carriercentral.vip` / localhost / 与 env 同主机 |
CC 密码协议为 MD5;设置页明文则服务端哈希。Token 可能出现在 GET query,日志须脱敏。
### 12.2 IMAP 邮箱
| 项 | 位置 |
|----|------|
| 协议 | IMAPS 993(预设 `lib/imap-presets.ts`:QQ/163/Gmail/Outlook) |
| 客户端 | `imapflow`(`imap/client.ts`) |
| 凭证 | 设置页 AES 密文,或 `.env IMAP_USER/PASS` |
| OAuth | Google / Microsoft(`oauth/providers.ts`);回调写 `mailbox_account` |
| 代理 | `IMAP_PROXY`;默认仅 Gmail/Outlook 主机走代理 |
### 12.3 OCR
`OCR_PROVIDER`:`off` | `local`(tesseract.js)| `aliyun`(endpoint/ak 环境变量)。解析辅助,不是主识别。
### 12.4 告警 Webhook
`ALERT_WEBHOOK_URL`:IMAP 连续失败或 CC 鉴权失败 POST JSON。空则只打日志。实现 `services/alert/webhook.ts`。
### 12.5 未集成
支付、短信、邮件 SMTP 发送、对象存储 SDK、LangChain、微信客服。附件只落本地 `data/`。
---
## 附录 A. 限制常量
| 常量 | 默认 | 位置 |
|------|------|------|
| 拉取间隔 | 30min(设置页) | `imap_settings` / `POLL_INTERVAL_MS` |
| lookback | 3 天 | `IMAP_LOOKBACK_DAYS` |
| 每箱每轮最多新信 | 100 | `IMAP_MAX_FETCH_PER_TICK` |
| 解析过期 | 600s | `PARSING_STALE_MS` |
| 导入超时 | 180s | `IMPORTING_TIMEOUT_MS` |
| 补偿次数 | 3 | `COMPENSATION_MAX_RETRY` |
| 货件行软/硬上限 | 1000 / 5000 | env |
| 附件 | 20MiB | `ATTACHMENT_MAX_BYTES` |
| 登录/导入限流 | 10/分钟 | login / import route |
| pull_log | ~1000 | `imap/pull-log.ts` |
| 间隔合法范围 | 3min~7d | 设置页 IMAP |
---
## 附录 B. 与助手仓对照(迁移功能时用)
| 维度 | yx(本文) | carriercentral-ai-assistant |
|------|------------|------------------------------|
| 形态 | Web 运营后台 | Windows Tkinter Demo |
| 主路径 | IMAP → 解析 → 确认 → SaveContainer | 自然语言 → ReAct → GET intelligent |
| CC 写 | 有(确认后) | 查询为主,附件下载 |
| 状态机 | 邮件 12 态 | 无邮件态 |
| 鉴权 | admin/ops Cookie | 无登录 |
| 禁止 | Worker 写新增预报 | 改 kf-ai、伪装客服 |
迁功能原则:复用 **CC 契约与字段名**(`F_ContainerNo`、`SaveContainer`),不要把 Next/Prisma/IMAP 整包塞进助手;UI 保持助手现有 `ui_main.py` 分层,除非用户确认换栈。
---
## 附录 C. 后续开发引用协议
之后任何需求(含「把 yx 功能加到助手仓」):
1. 先引用本文的**模块路径与函数名**,再给修改方案。
2. 遵循现有分层、Zod 入参、状态机、pnpm、中文 `ui-copy`。
3. 未经明确允许,不引入 Redis/新 ORM/第二套前端框架。
4. Worker 与 ConfirmImport 职责拆分不破。
5. 行为变更同步 `docx` 与本文对应章节;协作入口 `docs/邮箱项目.md` 红线保持。

@ -36,7 +36,9 @@
| UI kind | 典型关键词(见词表) | 确认动作 |
|---------|----------------------|----------|
| `forecast` | 新增预报、请查收新增预报 | 预报表单(人工确认) |
| `transfer` | 新增转仓、转仓(「不转仓」除外) | 批量转仓 / 到仓后降级工单 |
| `forecast` | 柜号+派送单,请查收(首票卡派清单) | 预报表单(只读历史) |
| `transfer` | 新增转仓、转仓(「不转仓」除外) | 详情先展示转仓表单;**确认时**再查 CC 到仓闸门,不可转则自动降级工单 |
| `transfer` 截图 | 正文仅「新增转仓请留意」+ 内嵌 JPG 表格 | 对每张截图 OCR → 解析「地址仓→仓库重定向」;按时间轮与附件顺序一一对应展示 |
| `work_order` | 贴标/换标/覆盖贴/操作指令、拍照、**指令性**拦截、快递单号;mail1 仅柜号+预约码 | 客户留言 SaveForm(人工确认) |
| `do_upload` | DO请查收;或真实 `*DO*.pdf` 等 | 上传 DO(人工确认) |
@ -112,8 +114,18 @@
- **提拆派**:预报时 `F_OperationType=0` 表示提拆派服务包,**不是**三个独立管理端「提柜/拆柜/派送」界面。
- **mail1 类**(仅柜号+预约码、无标准关键词):`classify` → `WORK_ORDER`;详情用 `work_order_actions` 补当前工单单元(见 `extractMailInstructions({ workOrderActions })`)。
- 模板表尾「注意:…」是填写说明,**不是**货件行。
- **预报未映射标签进备注**:主题/正文 `+` 分段里,已有 `名称:值` 前缀但对不上预报 UI 的字段(如 `封号:EMCEJJ0635`)写入 `F_MemoRemark`,**不得**当作客户名与 `YWZD` 一类短码竞争。
- 卡派清单列:附件有则填;UI **隐藏整列皆空**的 FormOrder 列,避免满屏「-」。
- 工单留言内容:取当前指令段,并从 `Dear` / `原箱号` 起,去掉转发头;**禁止**用整封线程正文盖住分段留言。
- **附件 ↔ 指令段(全邮件通用,禁止按单封特判)**:业务附件挂到**本段**表单(工单 `F_AttachFile` / 出库 `F_LabelFile`),按优先级:
1. **主键交叉**:附件名与本段正文/主题抽出同一业务主键则挂本段。主键:`FBA…`、原箱号 `YT…`、FNSKU `X0…`、唛头 `WL…`。**不用 ISO 柜号**分发工单附件(避免历史换标/拦截卡误挂当前文件)。
2. **文件名原文**:正文/主题出现该附件主文件名(去扩展名、去 `(1)` 副本后缀)→ 挂本段。
3. **角色兜底(仅当前可确认段)**:附件名无主键,但含 `卡转海` / `换标|覆盖贴|操作指令|贴标交付`,且本段也有对应角色词 → 挂当前段。历史只读段不用角色兜底。
4. **DO 文件**只进上传 DO 卡,不进工单附件。
5. **卡派资料/装箱清单 xlsx**是预报/转仓解析源,不进工单附件,除非规则 2 正文点名该文件。
6. 跳过 `*邮箱.pdf` / QQ 截图。一封多轮时每段独立匹配,**不得**把整封附件列表复制到每一张卡。
- **贴标指令单 ≠ 卡派预报行**:`lineage.template=label-instruction` 时,货件行只服务工单/出库核对;**新增预报**货件列表必须为空,不得把换标行(仓码当 Address、无体积毛重)当成 SaveContainer 预报展示。预报柜号优先主题/正文,不以贴标单内其它柜号覆盖。
- **同封多表格**:同时有卡派/提拆派 xlsx 与换标指令 xlsx 时,解析**优先卡派预报表**生成 `shipments`;换标文件仍作附件挂工单。
---
@ -128,6 +140,7 @@
| UID 幂等拉取 | `poller.ts` `knownUids` | 集成/烟雾 |
| 空 DO 不出指令 | `split-instructions` + `do-upload-extract` | `mail4-time-rounds`、`do-upload-extract` |
| 工单留言清洗 | `CcWorkOrderForm` `extractWorkOrderMessageBody` | `four-business.test.ts` |
| 附件↔指令段 | `match-segment-attachments.ts` | `match-segment-attachments.test.ts`、`four-business.test.ts` |
改口径流程:**改本页 → 改词表/拆分 → 改/补单测 → `pnpm test` 相关文件通过**。

@ -9,7 +9,7 @@
| **邮件1** | `QQ邮箱.pdf` | 根目录截图;无业务附件 | 一致 | ingest:`none` |
| **邮件2** | `2邮箱.pdf` + `柜号:MATU2745683卡派资料.xlsx` | 根目录 | 一致 | ingest:`packing_xlsx` |
| **邮件3** | `3邮箱.pdf`;卡转海 PDF 在子目录 `WHSU5574991 卡转海/` | 业务 PDF **平铺到 `邮件3/` 根** | 嵌套子目录;ingest 原先不挂附件 | 平铺 + ingest:`business` |
| **邮件4** | `4邮箱.pdf`;换标 xlsx/PDF 在子目录 `原箱号YT…换标操作指令/` | 业务文件 **平铺到 `邮件4/` 根** | 嵌套子目录;ingest 原先不挂附件 | 平铺 + ingest:`label_instruction` |
| **邮件4** | 换标 xlsx/PDF;预报卡派与 DO 原在 `模板/` | 业务文件平铺到 `邮件4/`:卡派 `智鸿2…提拆派.xlsx` + `WHSU8127240-DO.pdf` + 换标文件 | 预报真实附件未入邮件4 | 拷贝模板预报附件 + ingest 优先卡派表 |
| **模板** | 预报/DO 共享模板 | `docx/邮件/模板/`(不属于某一封邮件) | 正确 | `ingest-forecast-ui.ts` 单独用 |
## 修正后期望树
@ -29,12 +29,16 @@ docx/邮件/
WL99583-LAX9.pdf
邮件4/
4邮箱.pdf
智鸿2+WHLC027G597465+WHSU8127240+洛杉矶+40HQ+EDT2026.04-25 ETA2026.05-15船名航次HMM EMERALD 013E+提拆派.xlsx
WHSU8127240-DO.pdf
FBA19HW52S0L-76CTN-HIA1-覆盖贴1箱贴2张.pdf
X003UHDF7B-304PCS-覆盖贴1箱贴1张.pdf
YT2604021091=76件换标走HIA1卡派出库操作指令.xlsx
模板/
数据模版.xlsx
数据模版-新辰泽-WHSU8127240.xlsx
WHSU8127240-DO.pdf
智鸿2+…提拆派.xlsx
…
```
@ -45,7 +49,7 @@ docx/邮件/
| mail1 | `none` | 无 |
| mail2 | `packing_xlsx` | MATU 卡派 xlsx |
| mail3 | `business` | 全部非截图业务文件(卡转海 PDF);解析仍主题短路 |
| mail4 | `label_instruction` | 换标 xlsx + 指令 PDF;pipeline 先试卡派再试贴标指令单 |
| mail4 | `packing+do+label` | 卡派提拆派 xlsx(预报货件)+ DO.pdf + 换标 xlsx/PDF;pipeline **优先卡派表** |
## 维护命令

Binary file not shown.

@ -0,0 +1,26 @@
{
"mail_type": "NEW_CONTAINER",
"status": "PENDING_CONFIRM",
"last_error": null,
"container_no": "WHSU8127240",
"container_no_valid": true,
"selected_attachment": "数据模版-WHSU8127240.xlsx",
"shipment_count": 2,
"valid_shipment_count": 2,
"shipments": [
{
"F_FBACode": "LAS1",
"F_Transporter": "TRUCK",
"F_CTNS": 10,
"row_status": "VALID"
},
{
"F_FBACode": "FTW1",
"F_Transporter": "TRUCK",
"F_CTNS": 4,
"row_status": "VALID"
}
],
"idempotent_with": null,
"pull": true
}

@ -0,0 +1,47 @@
{
"id": "01-normal-forecast",
"category": "normal",
"from": "ops@example.com",
"subject": "请查收新增预报 智鸿2+WHLC027G597465+WHSU8127240+洛杉矶+40HQ",
"text": "请查收新增预报:WHSU8127240 40HQ EDT2026.04-25 ETA2026.05-15 船名航次HMM EMERALD 013E+提拆派",
"messageId": "<01-normal-forecast@fixtures.local>",
"date": "2026-04-14T00:00:00.000Z",
"attachments": [
{
"filename": "数据模版-WHSU8127240.xlsx",
"kind": "packing_xlsx",
"rows": [
{
"仓库ID": "LAS1",
"渠道": "卡派",
"件数": 10,
"总体积": 2.5,
"毛重": 100,
"FBA ID": "FBA16SQQ7PTF",
"Amazon reference ID": "REF-001",
"分货标识": "MK-1",
"派送地址": "/",
"备注": "/",
"柜号": "WHSU8127240",
"最早送仓日期": "2026-05-20",
"最晚送仓日期": "2026-05-25"
},
{
"仓库ID": "FTW1",
"渠道": "TRUCK",
"件数": 4,
"总体积": 1.2,
"毛重": 40,
"FBA ID": "FBA16AAAAAAA",
"Amazon reference ID": "REF-002",
"分货标识": "MK-2",
"派送地址": "/",
"备注": "/",
"柜号": "WHSU8127240",
"最早送仓日期": "2026-05-20",
"最晚送仓日期": "2026-05-25"
}
]
}
]
}

@ -0,0 +1,11 @@
{
"mail_type": "TRANSFER",
"status": "PENDING_CONFIRM",
"last_error": null,
"container_no": "MATU2745683",
"container_no_valid": true,
"selected_attachment": "柜号:MATU2745683卡派资料.xlsx",
"shipment_count": 1,
"valid_shipment_count": 1,
"pull": true
}

@ -0,0 +1,25 @@
{
"id": "02-normal-transfer",
"category": "normal",
"from": "ops@example.com",
"subject": "MATU2745683 转仓指令",
"text": "Dear, 请将 MATU2745683 新增转仓:原仓 ABQ2 转到 FTW1,谢谢。",
"messageId": "<02-normal-transfer@fixtures.local>",
"date": "2026-04-14T01:00:00.000Z",
"attachments": [
{
"filename": "柜号:MATU2745683卡派资料.xlsx",
"kind": "packing_xlsx",
"rows": [
{
"仓库ID": "FTW1",
"渠道": "卡派",
"件数": 8,
"总体积": 3,
"毛重": 80,
"柜号": "MATU2745683"
}
]
}
]
}

@ -0,0 +1,10 @@
{
"mail_type": "DO_UPLOAD",
"status": "PENDING_CONFIRM",
"last_error": null,
"container_no": "WHSU8127240",
"container_no_valid": true,
"selected_attachment": "WHSU8127240-DO.pdf",
"shipment_count": 0,
"pull": true
}

@ -0,0 +1,16 @@
{
"id": "03-normal-do-upload",
"category": "normal",
"from": "ops@example.com",
"subject": "WHSU8127240 DO请查收",
"text": "DO也同步上传至附件请注意查收!",
"messageId": "<03-normal-do-upload@fixtures.local>",
"date": "2026-04-14T02:00:00.000Z",
"attachments": [
{
"filename": "WHSU8127240-DO.pdf",
"kind": "pdf",
"text": "DELIVERY ORDER WHSU8127240"
}
]
}

@ -0,0 +1,10 @@
{
"mail_type": "NEW_CONTAINER",
"status": "PENDING_CONFIRM",
"last_error": null,
"container_no": "WHSU8127240",
"selected_attachment": "数据模版.xlsx",
"shipment_count": 2,
"valid_shipment_count": 2,
"pull": true
}

@ -0,0 +1,47 @@
{
"id": "04-keyword-subject",
"category": "boundary",
"from": "ops@example.com",
"subject": "新增预报 WHSU8127240",
"text": "附件是数据模版,请处理。",
"messageId": "<04-keyword-subject@fixtures.local>",
"date": "2026-04-14T03:00:00.000Z",
"attachments": [
{
"filename": "数据模版.xlsx",
"kind": "packing_xlsx",
"rows": [
{
"仓库ID": "LAS1",
"渠道": "卡派",
"件数": 10,
"总体积": 2.5,
"毛重": 100,
"FBA ID": "FBA16SQQ7PTF",
"Amazon reference ID": "REF-001",
"分货标识": "MK-1",
"派送地址": "/",
"备注": "/",
"柜号": "WHSU8127240",
"最早送仓日期": "2026-05-20",
"最晚送仓日期": "2026-05-25"
},
{
"仓库ID": "FTW1",
"渠道": "TRUCK",
"件数": 4,
"总体积": 1.2,
"毛重": 40,
"FBA ID": "FBA16AAAAAAA",
"Amazon reference ID": "REF-002",
"分货标识": "MK-2",
"派送地址": "/",
"备注": "/",
"柜号": "WHSU8127240",
"最早送仓日期": "2026-05-20",
"最晚送仓日期": "2026-05-25"
}
]
}
]
}

@ -0,0 +1,9 @@
{
"mail_type": "NEW_CONTAINER",
"status": "PENDING_CONFIRM",
"last_error": null,
"container_no": "WHSU8127240",
"selected_attachment": "数据模版.xlsx",
"shipment_count": 2,
"pull": true
}

@ -0,0 +1,47 @@
{
"id": "05-keyword-body",
"category": "boundary",
"from": "ops@example.com",
"subject": "WHSU8127240 资料",
"text": "请查收新增预报,柜号 WHSU8127240,40HQ。",
"messageId": "<05-keyword-body@fixtures.local>",
"date": "2026-04-14T04:00:00.000Z",
"attachments": [
{
"filename": "数据模版.xlsx",
"kind": "packing_xlsx",
"rows": [
{
"仓库ID": "LAS1",
"渠道": "卡派",
"件数": 10,
"总体积": 2.5,
"毛重": 100,
"FBA ID": "FBA16SQQ7PTF",
"Amazon reference ID": "REF-001",
"分货标识": "MK-1",
"派送地址": "/",
"备注": "/",
"柜号": "WHSU8127240",
"最早送仓日期": "2026-05-20",
"最晚送仓日期": "2026-05-25"
},
{
"仓库ID": "FTW1",
"渠道": "TRUCK",
"件数": 4,
"总体积": 1.2,
"毛重": 40,
"FBA ID": "FBA16AAAAAAA",
"Amazon reference ID": "REF-002",
"分货标识": "MK-2",
"派送地址": "/",
"备注": "/",
"柜号": "WHSU8127240",
"最早送仓日期": "2026-05-20",
"最晚送仓日期": "2026-05-25"
}
]
}
]
}

@ -0,0 +1,9 @@
{
"mail_type": "NEW_CONTAINER",
"status": "PENDING_CONFIRM",
"last_error": null,
"container_no": "WHSU8127240",
"selected_attachment": "数据模版.xlsx",
"shipment_count": 2,
"pull": true
}

@ -0,0 +1,47 @@
{
"id": "06-mixed-en-zh",
"category": "boundary",
"from": "ops@example.com",
"subject": "Please check 新增预报 WHSU8127240 / 40HQ ETA 2026-05-15",
"text": "Hi team, 请查收新增预报 packing list attached. Vessel HMM EMERALD 013E.",
"messageId": "<06-mixed-en-zh@fixtures.local>",
"date": "2026-04-14T05:00:00.000Z",
"attachments": [
{
"filename": "数据模版.xlsx",
"kind": "packing_xlsx",
"rows": [
{
"仓库ID": "LAS1",
"渠道": "卡派",
"件数": 10,
"总体积": 2.5,
"毛重": 100,
"FBA ID": "FBA16SQQ7PTF",
"Amazon reference ID": "REF-001",
"分货标识": "MK-1",
"派送地址": "/",
"备注": "/",
"柜号": "WHSU8127240",
"最早送仓日期": "2026-05-20",
"最晚送仓日期": "2026-05-25"
},
{
"仓库ID": "FTW1",
"渠道": "TRUCK",
"件数": 4,
"总体积": 1.2,
"毛重": 40,
"FBA ID": "FBA16AAAAAAA",
"Amazon reference ID": "REF-002",
"分货标识": "MK-2",
"派送地址": "/",
"备注": "/",
"柜号": "WHSU8127240",
"最早送仓日期": "2026-05-20",
"最晚送仓日期": "2026-05-25"
}
]
}
]
}

@ -0,0 +1,9 @@
{
"mail_type": "NEW_CONTAINER",
"status": "PENDING_CONFIRM",
"last_error": null,
"container_no": "WHSU8127240",
"selected_attachment": "数据模版.xlsx",
"shipment_count": 2,
"pull": true
}

@ -0,0 +1,48 @@
{
"id": "07-gbk-body",
"category": "boundary",
"from": "ops@example.com",
"subject": "Forecast WHSU8127240",
"text": "请查收新增预报 WHSU8127240",
"charset": "gbk",
"messageId": "<07-gbk-body@fixtures.local>",
"date": "2026-04-14T06:00:00.000Z",
"attachments": [
{
"filename": "数据模版.xlsx",
"kind": "packing_xlsx",
"rows": [
{
"仓库ID": "LAS1",
"渠道": "卡派",
"件数": 10,
"总体积": 2.5,
"毛重": 100,
"FBA ID": "FBA16SQQ7PTF",
"Amazon reference ID": "REF-001",
"分货标识": "MK-1",
"派送地址": "/",
"备注": "/",
"柜号": "WHSU8127240",
"最早送仓日期": "2026-05-20",
"最晚送仓日期": "2026-05-25"
},
{
"仓库ID": "FTW1",
"渠道": "TRUCK",
"件数": 4,
"总体积": 1.2,
"毛重": 40,
"FBA ID": "FBA16AAAAAAA",
"Amazon reference ID": "REF-002",
"分货标识": "MK-2",
"派送地址": "/",
"备注": "/",
"柜号": "WHSU8127240",
"最早送仓日期": "2026-05-20",
"最晚送仓日期": "2026-05-25"
}
]
}
]
}

@ -0,0 +1,8 @@
{
"mail_type": "NEW_CONTAINER",
"status": "REJECTED_VALIDATION",
"last_error": "ATTACHMENT_TOO_LARGE",
"container_no": "WHSU8127240",
"selected_attachment": null,
"pull": true
}

@ -0,0 +1,16 @@
{
"id": "08-oversized-attachment",
"category": "boundary",
"from": "ops@example.com",
"subject": "请查收新增预报 WHSU8127240",
"text": "请查收新增预报,附件较大。",
"messageId": "<08-oversized-attachment@fixtures.local>",
"date": "2026-04-14T07:00:00.000Z",
"attachments": [
{
"filename": "数据模版.xlsx",
"kind": "oversized",
"bytes": 20971521
}
]
}

@ -0,0 +1,9 @@
{
"mail_type": "NEW_CONTAINER",
"status": "REJECTED_VALIDATION",
"last_error": "NO_SHIPMENT_ROWS",
"container_no": "WHSU8127240",
"selected_attachment": null,
"shipment_count": 0,
"pull": true
}

@ -0,0 +1,10 @@
{
"id": "09-no-attachment",
"category": "abnormal",
"from": "ops@example.com",
"subject": "请查收新增预报 WHSU8127240 40HQ",
"text": "请查收新增预报,稍后补附件。",
"messageId": "<09-no-attachment@fixtures.local>",
"date": "2026-04-14T08:00:00.000Z",
"attachments": []
}

@ -0,0 +1,9 @@
{
"mail_type": "NEW_CONTAINER",
"pipeline_mail_type": "UNKNOWN",
"status": "PARSE_FAILED",
"last_error": "MIME_PARSE_ERROR",
"container_no": "WHSU8127240",
"selected_attachment": "数据模版.xlsx",
"pull": true
}

@ -0,0 +1,15 @@
{
"id": "10-corrupt-xlsx",
"category": "abnormal",
"from": "ops@example.com",
"subject": "请查收新增预报 WHSU8127240",
"text": "请查收新增预报,附件已损坏。",
"messageId": "<10-corrupt-xlsx@fixtures.local>",
"date": "2026-04-14T09:00:00.000Z",
"attachments": [
{
"filename": "数据模版.xlsx",
"kind": "corrupt_xlsx"
}
]
}

@ -0,0 +1,8 @@
{
"mail_type": "NEW_CONTAINER",
"status": "PARSE_FAILED",
"last_error": "NO_SUPPORTED_SPREADSHEET",
"container_no": "WHSU8127240",
"selected_attachment": null,
"pull": true
}

@ -0,0 +1,17 @@
{
"id": "11-wrong-format",
"category": "abnormal",
"from": "ops@example.com",
"subject": "请查收新增预报 WHSU8127240",
"text": "请查收新增预报,附件是说明文档。",
"messageId": "<11-wrong-format@fixtures.local>",
"date": "2026-04-14T10:00:00.000Z",
"attachments": [
{
"filename": "说明.docx",
"kind": "raw",
"content": "this is not a spreadsheet",
"contentType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
}
]
}

@ -0,0 +1,10 @@
{
"mail_type": "NEW_CONTAINER",
"status": "PARSE_FAILED",
"last_error": "missing_columns",
"container_no": "WHSU8127240",
"selected_attachment": "数据模版.xlsx",
"missing_columns": true,
"shipment_count": 0,
"pull": true
}

@ -0,0 +1,25 @@
{
"id": "12-missing-required",
"category": "abnormal",
"from": "ops@example.com",
"subject": "请查收新增预报 WHSU8127240",
"text": "请查收新增预报。",
"messageId": "<12-missing-required@fixtures.local>",
"date": "2026-04-14T11:00:00.000Z",
"attachments": [
{
"filename": "数据模版.xlsx",
"kind": "packing_xlsx",
"headers": [
"备注",
"柜号"
],
"rows": [
{
"备注": "只有备注没有必填列",
"柜号": "WHSU8127240"
}
]
}
]
}

@ -0,0 +1,19 @@
{
"mail_type": "NEW_CONTAINER",
"status": "PENDING_CONFIRM",
"last_error": null,
"container_no": "WHSU8127240",
"selected_attachment": "数据模版.xlsx",
"shipment_count": 1,
"valid_shipment_count": 1,
"shipments": [
{
"F_FBACode": "LAS1",
"F_Transporter": "TRUCK",
"F_CTNS": 2,
"row_status": "VALID",
"F_Expected_DeliveryDateB": null
}
],
"pull": true
}

@ -0,0 +1,27 @@
{
"id": "13-illegal-date",
"category": "abnormal",
"from": "ops@example.com",
"subject": "请查收新增预报 WHSU8127240",
"text": "请查收新增预报。",
"messageId": "<13-illegal-date@fixtures.local>",
"date": "2026-04-14T12:00:00.000Z",
"attachments": [
{
"filename": "数据模版.xlsx",
"kind": "packing_xlsx",
"rows": [
{
"仓库ID": "LAS1",
"渠道": "卡派",
"件数": 2,
"总体积": 1,
"毛重": 20,
"柜号": "WHSU8127240",
"最早送仓日期": "not-a-date",
"最晚送仓日期": "99/99/99"
}
]
}
]
}

@ -0,0 +1,9 @@
{
"mail_type": "NEW_CONTAINER",
"status": "PENDING_CONFIRM",
"last_error": null,
"container_no": "WHSU8127240",
"selected_attachment": "数据模版-WHSU8127240.xlsx",
"idempotent_with": "01-normal-forecast",
"pull": true
}

@ -0,0 +1,47 @@
{
"id": "14-duplicate-pull",
"category": "duplicate",
"from": "ops@example.com",
"subject": "请查收新增预报 智鸿2+WHLC027G597465+WHSU8127240+洛杉矶+40HQ",
"text": "请查收新增预报:WHSU8127240 40HQ EDT2026.04-25 ETA2026.05-15 船名航次HMM EMERALD 013E+提拆派",
"messageId": "<01-normal-forecast@fixtures.local>",
"date": "2026-04-14T00:00:00.000Z",
"attachments": [
{
"filename": "数据模版-WHSU8127240.xlsx",
"kind": "packing_xlsx",
"rows": [
{
"仓库ID": "LAS1",
"渠道": "卡派",
"件数": 10,
"总体积": 2.5,
"毛重": 100,
"FBA ID": "FBA16SQQ7PTF",
"Amazon reference ID": "REF-001",
"分货标识": "MK-1",
"派送地址": "/",
"备注": "/",
"柜号": "WHSU8127240",
"最早送仓日期": "2026-05-20",
"最晚送仓日期": "2026-05-25"
},
{
"仓库ID": "FTW1",
"渠道": "TRUCK",
"件数": 4,
"总体积": 1.2,
"毛重": 40,
"FBA ID": "FBA16AAAAAAA",
"Amazon reference ID": "REF-002",
"分货标识": "MK-2",
"派送地址": "/",
"备注": "/",
"柜号": "WHSU8127240",
"最早送仓日期": "2026-05-20",
"最晚送仓日期": "2026-05-25"
}
]
}
]
}

@ -0,0 +1,16 @@
{
"mail_type": "WORK_ORDER",
"status": "PENDING_CONFIRM",
"last_error": null,
"container_no": "WHSU8127240",
"selected_attachment": "数据模版-WHSU8127240.xlsx",
"selected_attachments": [
"YT2604021091=76件换标走HIA1卡派出库操作指令.xlsx",
"WHSU8127240-DO.pdf",
"数据模版-WHSU8127240.xlsx",
"4邮箱.pdf"
],
"shipment_count": 2,
"valid_shipment_count": 2,
"pull": true
}

@ -0,0 +1,69 @@
{
"id": "15-multi-attachment",
"category": "multi_attachment",
"from": "ops@example.com",
"subject": "请查收新增预报 WHSU8127240,DO请查收",
"text": "请查收新增预报。DO也同步上传至附件。",
"messageId": "<15-multi-attachment@fixtures.local>",
"date": "2026-04-14T13:00:00.000Z",
"attachments": [
{
"filename": "YT2604021091=76件换标走HIA1卡派出库操作指令.xlsx",
"kind": "packing_xlsx",
"rows": [
{
"仓库ID": "HIA1",
"渠道": "换标",
"件数": 76,
"柜号": "XXXX0000000"
}
]
},
{
"filename": "WHSU8127240-DO.pdf",
"kind": "pdf",
"text": "DO"
},
{
"filename": "数据模版-WHSU8127240.xlsx",
"kind": "packing_xlsx",
"rows": [
{
"仓库ID": "LAS1",
"渠道": "卡派",
"件数": 10,
"总体积": 2.5,
"毛重": 100,
"FBA ID": "FBA16SQQ7PTF",
"Amazon reference ID": "REF-001",
"分货标识": "MK-1",
"派送地址": "/",
"备注": "/",
"柜号": "WHSU8127240",
"最早送仓日期": "2026-05-20",
"最晚送仓日期": "2026-05-25"
},
{
"仓库ID": "FTW1",
"渠道": "TRUCK",
"件数": 4,
"总体积": 1.2,
"毛重": 40,
"FBA ID": "FBA16AAAAAAA",
"Amazon reference ID": "REF-002",
"分货标识": "MK-2",
"派送地址": "/",
"备注": "/",
"柜号": "WHSU8127240",
"最早送仓日期": "2026-05-20",
"最晚送仓日期": "2026-05-25"
}
]
},
{
"filename": "4邮箱.pdf",
"kind": "pdf",
"text": "screenshot"
}
]
}

@ -0,0 +1,7 @@
{
"mail_type": "UNKNOWN",
"status": "IGNORED",
"last_error": "SKIP_FILTER",
"selected_attachment": null,
"pull": false
}

@ -0,0 +1,10 @@
{
"id": "16-unknown-format",
"category": "abnormal",
"from": "random@example.com",
"subject": "Weekly newsletter",
"text": "Hello, this is a marketing newsletter about office snacks.",
"messageId": "<16-unknown-format@fixtures.local>",
"date": "2026-04-14T14:00:00.000Z",
"attachments": []
}

@ -14,6 +14,15 @@ const nextConfig: NextConfig = {
experimental: {
optimizePackageImports: ["antd", "@ant-design/icons"],
},
webpack: (config, { isServer }) => {
if (!isServer) {
config.resolve.fallback = {
...(config.resolve.fallback || {}),
async_hooks: false,
};
}
return config;
},
};
export default nextConfig;

@ -18,7 +18,10 @@
"lint": "next lint",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:all": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:test-mode": "playwright test -c playwright.test-mode.config.ts",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate deploy",
"db:migrate:dev": "prisma migrate dev",
@ -53,6 +56,7 @@
"pino": "^9.6.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tesseract.js": "^7.0.0",
"undici": "^6.21.0",
"uuid": "^11.0.3",
"zod": "^3.24.1"
@ -66,6 +70,7 @@
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^2.1.9",
"prisma": "^5.22.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2",

@ -2,6 +2,7 @@ import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
testIgnore: "test-mode-pipeline.spec.ts",
timeout: 60_000,
use: {
baseURL: "http://127.0.0.1:3100",

@ -0,0 +1,31 @@
import { defineConfig, devices } from "@playwright/test";
/**
* TEST_MODE=1 专用 E2E:注入 fixture → UI → 提交 MockTargetAPI。
* 需要本机 MySQL(docker compose up -d mysql)。
*/
export default defineConfig({
testDir: "./tests/e2e",
testMatch: "test-mode-pipeline.spec.ts",
timeout: 90_000,
use: {
baseURL: "http://127.0.0.1:3110",
trace: "on-first-retry",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: {
command: "pnpm exec next dev -p 3110",
url: "http://127.0.0.1:3110",
reuseExistingServer: false,
timeout: 120_000,
env: {
...process.env,
TEST_MODE: "1",
OCR_PROVIDER: "off",
ENABLE_FORCE_IMPORT: "1",
DATABASE_URL:
process.env.DATABASE_URL ||
"mysql://app:app@localhost:7023/email_forecast",
},
},
});

File diff suppressed because it is too large Load Diff

@ -9,6 +9,8 @@ datasource db {
model MailboxAccount {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
/// 归属客户;空=管理端公共邮箱
ownerUserId BigInt? @map("owner_user_id") @db.UnsignedBigInt
name String @db.VarChar(128)
host String @default("imap.qq.com") @db.VarChar(255)
port Int @default(993) @db.UnsignedInt
@ -30,10 +32,12 @@ model MailboxAccount {
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.DateTime(3)
owner AppUser? @relation(fields: [ownerUserId], references: [id], onDelete: SetNull)
mails MailMessage[]
@@unique([username], map: "uk_mailbox_username")
@@index([enabled], map: "idx_mailbox_enabled")
@@index([ownerUserId], map: "idx_mailbox_owner")
@@map("mailbox_account")
}
@ -61,6 +65,8 @@ model MailMessage {
snapshotPath String? @map("snapshot_path") @db.VarChar(512)
rawHash String? @unique @map("raw_hash") @db.Char(64)
lastError String? @map("last_error") @db.VarChar(1000)
/// 贯穿拉取/解析/暂存/提交日志
traceId String? @map("trace_id") @db.VarChar(36)
version Int @default(1) @db.UnsignedInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.DateTime(3)
@ -75,6 +81,7 @@ model MailMessage {
@@index([mailType], map: "idx_mail_type")
@@index([mailboxAccountId], map: "idx_mailbox")
@@index([threadId], map: "idx_thread")
@@index([traceId], map: "idx_trace")
@@map("mail_message")
}
@ -169,9 +176,10 @@ model CcTokenCache {
@@map("cc_token_cache")
}
/// Singleton CC 连接配置(id 固定为 1);优先于 .env,密码 AES 加密
/// CC 连接配置:userId 为空为管理端默认(原 id=1);客户各有一行
model CcSettings {
id Int @id @default(1)
id Int @id @default(autoincrement())
userId BigInt? @unique @map("user_id") @db.UnsignedBigInt
mock Boolean @default(true)
apiBase String @map("api_base") @db.VarChar(512)
saasHeader String @map("saas_header") @db.VarChar(64)
@ -181,6 +189,8 @@ model CcSettings {
passwordEnc String? @map("password_enc") @db.VarChar(1024)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.DateTime(3)
user AppUser? @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("cc_settings")
}
@ -223,9 +233,18 @@ model AppUser {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
username String @unique @db.VarChar(64)
passwordHash String @map("password_hash") @db.VarChar(128)
/// AES-GCM,管理端可回显明文密码
passwordEnc String @default("") @map("password_enc") @db.VarChar(1024)
role String @default("ops") @db.VarChar(16)
displayName String @default("") @map("display_name") @db.VarChar(128)
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.DateTime(3)
mailboxes MailboxAccount[]
ccSettings CcSettings?
@@index([role], map: "idx_app_user_role")
@@map("app_user")
}

@ -21,19 +21,27 @@ async function main() {
where: { username: adminUser },
create: {
username: adminUser,
passwordHash: hashPassword(adminPass),
passwordEnc: "",
role: "admin",
},
update: {
passwordHash: hashPassword(adminPass),
role: "admin",
},
update: { passwordHash: hashPassword(adminPass), role: "admin" },
});
await prisma.appUser.upsert({
where: { username: opsUser },
create: {
username: opsUser,
passwordHash: hashPassword(opsPass),
passwordEnc: "",
role: "ops",
},
update: {
passwordHash: hashPassword(opsPass),
role: "ops",
},
update: { passwordHash: hashPassword(opsPass), role: "ops" },
});
const caps = [

@ -0,0 +1,64 @@
/**
* 删除 TEST_MODE / fixture 注入的邮件(ops@example.com、@p3.fixtures.local)。
* 不碰 IMAP 真实邮件、不碰 sample-mail* 金样。
* 用法: pnpm exec tsx scripts/cleanup-test-fixture-mails.ts
*/
import fs from "fs/promises";
import path from "path";
import { prisma } from "@/services/db";
async function main() {
const rows = await prisma.mailMessage.findMany({
where: {
OR: [
{ messageId: { contains: "@p3.fixtures.local" } },
{ fromAddr: "ops@example.com" },
],
},
select: { id: true, messageId: true, subject: true, status: true },
});
const samples = await prisma.mailMessage.findMany({
where: { messageId: { contains: "@local.test" } },
select: { id: true, messageId: true, subject: true, status: true, fromAddr: true },
});
console.log(`fixture mails to delete: ${rows.length}`);
for (const r of rows) {
console.log(` #${r.id} ${r.status} ${r.messageId} ${r.subject.slice(0, 60)}`);
}
console.log(`sample-mail* still in db: ${samples.length}`);
for (const s of samples) {
console.log(` #${s.id} ${s.status} ${s.fromAddr} ${s.subject.slice(0, 60)}`);
}
if (!rows.length) {
console.log("nothing to delete");
return;
}
const ids = rows.map((r) => r.id);
await prisma.importCompensation.deleteMany({
where: { import: { mailId: { in: ids } } },
});
await prisma.containerActiveLock.deleteMany({ where: { mailId: { in: ids } } });
await prisma.containerImport.deleteMany({ where: { mailId: { in: ids } } });
await prisma.parseResult.deleteMany({ where: { mailId: { in: ids } } });
await prisma.mailAttachment.deleteMany({ where: { mailId: { in: ids } } });
await prisma.imapPullLog.deleteMany({ where: { mailId: { in: ids } } });
await prisma.mailMessage.deleteMany({ where: { id: { in: ids } } });
for (const id of ids) {
const dir = path.join(process.cwd(), "data", "mails", String(id));
await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
}
console.log(`deleted ${ids.length} fixture mails`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());

@ -1,9 +1,9 @@
#!/bin/sh
set -e
echo "[web] prisma db push…"
echo "[web] prisma db push..."
pnpm exec prisma db push --skip-generate
if [ "${SEED_ON_START:-false}" = "true" ]; then
echo "[web] seed…"
echo "[web] seed..."
pnpm db:seed || true
fi
echo "[web] next start"

@ -1,13 +1,28 @@
/**
* 手动跑数据保留清理:pnpm retention:cleanup
* dry-run: pnpm retention:cleanup -- --dry-run
* 手动清理已关闭自动过期删除。
* 默认拒绝执行;仅 --force 时按 DATA_RETENTION_DAYS 删除过期邮件。
* 日常请在列表里勾选删除,或满 1000 封时按弹窗处理。
*/
import { getEnv, resetEnvCache } from "../src/lib/env";
import { runDataRetentionCleanup } from "../src/services/retention/cleanup";
async function main() {
resetEnvCache();
const force = process.argv.includes("--force");
const dryRun = process.argv.includes("--dry-run");
if (!force) {
console.log(
JSON.stringify(
{
skipped: true,
reason: "auto retention disabled; pass --force to run DATA_RETENTION_DAYS cleanup",
},
null,
2,
),
);
return;
}
const env = getEnv();
console.log(
JSON.stringify(

@ -0,0 +1,500 @@
/**
* One-shot writer for fixtures/emails spec.json + expected.json
*/
import fs from "fs/promises";
import path from "path";
const ROOT = path.join(process.cwd(), "fixtures", "emails");
const PACKING_TWO = [
{
仓库ID: "LAS1",
渠道: "卡派",
件数: 10,
总体积: 2.5,
毛重: 100,
"FBA ID": "FBA16SQQ7PTF",
"Amazon reference ID": "REF-001",
分货标识: "MK-1",
派送地址: "/",
备注: "/",
柜号: "WHSU8127240",
最早送仓日期: "2026-05-20",
最晚送仓日期: "2026-05-25",
},
{
仓库ID: "FTW1",
渠道: "TRUCK",
件数: 4,
总体积: 1.2,
毛重: 40,
"FBA ID": "FBA16AAAAAAA",
"Amazon reference ID": "REF-002",
分货标识: "MK-2",
派送地址: "/",
备注: "/",
柜号: "WHSU8127240",
最早送仓日期: "2026-05-20",
最晚送仓日期: "2026-05-25",
},
];
const fixtures: Array<{ spec: Record<string, unknown>; expected: Record<string, unknown> }> = [
{
spec: {
id: "01-normal-forecast",
category: "normal",
from: "ops@example.com",
subject: "请查收新增预报 智鸿2+WHLC027G597465+WHSU8127240+洛杉矶+40HQ",
text: "请查收新增预报:WHSU8127240 40HQ EDT2026.04-25 ETA2026.05-15 船名航次HMM EMERALD 013E+提拆派",
messageId: "<01-normal-forecast@fixtures.local>",
date: "2026-04-14T00:00:00.000Z",
attachments: [
{ filename: "数据模版-WHSU8127240.xlsx", kind: "packing_xlsx", rows: PACKING_TWO },
],
},
expected: {
mail_type: "NEW_CONTAINER",
status: "PENDING_CONFIRM",
last_error: null,
container_no: "WHSU8127240",
container_no_valid: true,
selected_attachment: "数据模版-WHSU8127240.xlsx",
shipment_count: 2,
valid_shipment_count: 2,
shipments: [
{ F_FBACode: "LAS1", F_Transporter: "TRUCK", F_CTNS: 10, row_status: "VALID" },
{ F_FBACode: "FTW1", F_Transporter: "TRUCK", F_CTNS: 4, row_status: "VALID" },
],
idempotent_with: null,
pull: true,
},
},
{
spec: {
id: "02-normal-transfer",
category: "normal",
from: "ops@example.com",
subject: "MATU2745683 转仓指令",
text: "Dear, 请将 MATU2745683 新增转仓:原仓 ABQ2 转到 FTW1,谢谢。",
messageId: "<02-normal-transfer@fixtures.local>",
date: "2026-04-14T01:00:00.000Z",
attachments: [
{
filename: "柜号:MATU2745683卡派资料.xlsx",
kind: "packing_xlsx",
rows: [
{
仓库ID: "FTW1",
渠道: "卡派",
件数: 8,
总体积: 3,
毛重: 80,
柜号: "MATU2745683",
},
],
},
],
},
expected: {
mail_type: "TRANSFER",
status: "PENDING_CONFIRM",
last_error: null,
container_no: "MATU2745683",
container_no_valid: true,
selected_attachment: "柜号:MATU2745683卡派资料.xlsx",
shipment_count: 1,
valid_shipment_count: 1,
pull: true,
},
},
{
spec: {
id: "03-normal-do-upload",
category: "normal",
from: "ops@example.com",
subject: "WHSU8127240 DO请查收",
text: "DO也同步上传至附件请注意查收!",
messageId: "<03-normal-do-upload@fixtures.local>",
date: "2026-04-14T02:00:00.000Z",
attachments: [
{ filename: "WHSU8127240-DO.pdf", kind: "pdf", text: "DELIVERY ORDER WHSU8127240" },
],
},
expected: {
mail_type: "DO_UPLOAD",
status: "PENDING_CONFIRM",
last_error: null,
container_no: "WHSU8127240",
container_no_valid: true,
selected_attachment: "WHSU8127240-DO.pdf",
shipment_count: 0,
pull: true,
},
},
{
spec: {
id: "04-keyword-subject",
category: "boundary",
from: "ops@example.com",
subject: "新增预报 WHSU8127240",
text: "附件是数据模版,请处理。",
messageId: "<04-keyword-subject@fixtures.local>",
date: "2026-04-14T03:00:00.000Z",
attachments: [
{ filename: "数据模版.xlsx", kind: "packing_xlsx", rows: PACKING_TWO },
],
},
expected: {
mail_type: "NEW_CONTAINER",
status: "PENDING_CONFIRM",
last_error: null,
container_no: "WHSU8127240",
selected_attachment: "数据模版.xlsx",
shipment_count: 2,
valid_shipment_count: 2,
pull: true,
},
},
{
spec: {
id: "05-keyword-body",
category: "boundary",
from: "ops@example.com",
subject: "WHSU8127240 资料",
text: "请查收新增预报,柜号 WHSU8127240,40HQ。",
messageId: "<05-keyword-body@fixtures.local>",
date: "2026-04-14T04:00:00.000Z",
attachments: [
{ filename: "数据模版.xlsx", kind: "packing_xlsx", rows: PACKING_TWO },
],
},
expected: {
mail_type: "NEW_CONTAINER",
status: "PENDING_CONFIRM",
last_error: null,
container_no: "WHSU8127240",
selected_attachment: "数据模版.xlsx",
shipment_count: 2,
pull: true,
},
},
{
spec: {
id: "06-mixed-en-zh",
category: "boundary",
from: "ops@example.com",
subject: "Please check 新增预报 WHSU8127240 / 40HQ ETA 2026-05-15",
text: "Hi team, 请查收新增预报 packing list attached. Vessel HMM EMERALD 013E.",
messageId: "<06-mixed-en-zh@fixtures.local>",
date: "2026-04-14T05:00:00.000Z",
attachments: [
{ filename: "数据模版.xlsx", kind: "packing_xlsx", rows: PACKING_TWO },
],
},
expected: {
mail_type: "NEW_CONTAINER",
status: "PENDING_CONFIRM",
last_error: null,
container_no: "WHSU8127240",
selected_attachment: "数据模版.xlsx",
shipment_count: 2,
pull: true,
},
},
{
spec: {
id: "07-gbk-body",
category: "boundary",
from: "ops@example.com",
subject: "Forecast WHSU8127240",
text: "请查收新增预报 WHSU8127240",
charset: "gbk",
messageId: "<07-gbk-body@fixtures.local>",
date: "2026-04-14T06:00:00.000Z",
attachments: [
{ filename: "数据模版.xlsx", kind: "packing_xlsx", rows: PACKING_TWO },
],
},
expected: {
mail_type: "NEW_CONTAINER",
status: "PENDING_CONFIRM",
last_error: null,
container_no: "WHSU8127240",
selected_attachment: "数据模版.xlsx",
shipment_count: 2,
pull: true,
},
},
{
spec: {
id: "08-oversized-attachment",
category: "boundary",
from: "ops@example.com",
subject: "请查收新增预报 WHSU8127240",
text: "请查收新增预报,附件较大。",
messageId: "<08-oversized-attachment@fixtures.local>",
date: "2026-04-14T07:00:00.000Z",
attachments: [
{ filename: "数据模版.xlsx", kind: "oversized", bytes: 20971521 },
],
},
expected: {
mail_type: "NEW_CONTAINER",
status: "REJECTED_VALIDATION",
last_error: "ATTACHMENT_TOO_LARGE",
container_no: "WHSU8127240",
selected_attachment: null,
pull: true,
},
},
{
spec: {
id: "09-no-attachment",
category: "abnormal",
from: "ops@example.com",
subject: "请查收新增预报 WHSU8127240 40HQ",
text: "请查收新增预报,稍后补附件。",
messageId: "<09-no-attachment@fixtures.local>",
date: "2026-04-14T08:00:00.000Z",
attachments: [],
},
expected: {
mail_type: "NEW_CONTAINER",
status: "REJECTED_VALIDATION",
last_error: "NO_SHIPMENT_ROWS",
container_no: "WHSU8127240",
selected_attachment: null,
shipment_count: 0,
pull: true,
},
},
{
spec: {
id: "10-corrupt-xlsx",
category: "abnormal",
from: "ops@example.com",
subject: "请查收新增预报 WHSU8127240",
text: "请查收新增预报,附件已损坏。",
messageId: "<10-corrupt-xlsx@fixtures.local>",
date: "2026-04-14T09:00:00.000Z",
attachments: [{ filename: "数据模版.xlsx", kind: "corrupt_xlsx" }],
},
expected: {
mail_type: "NEW_CONTAINER",
pipeline_mail_type: "UNKNOWN",
status: "PARSE_FAILED",
last_error: "MIME_PARSE_ERROR",
container_no: "WHSU8127240",
selected_attachment: "数据模版.xlsx",
pull: true,
},
},
{
spec: {
id: "11-wrong-format",
category: "abnormal",
from: "ops@example.com",
subject: "请查收新增预报 WHSU8127240",
text: "请查收新增预报,附件是说明文档。",
messageId: "<11-wrong-format@fixtures.local>",
date: "2026-04-14T10:00:00.000Z",
attachments: [
{
filename: "说明.docx",
kind: "raw",
content: "this is not a spreadsheet",
contentType:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
},
],
},
expected: {
mail_type: "NEW_CONTAINER",
status: "PARSE_FAILED",
last_error: "NO_SUPPORTED_SPREADSHEET",
container_no: "WHSU8127240",
selected_attachment: null,
pull: true,
},
},
{
spec: {
id: "12-missing-required",
category: "abnormal",
from: "ops@example.com",
subject: "请查收新增预报 WHSU8127240",
text: "请查收新增预报。",
messageId: "<12-missing-required@fixtures.local>",
date: "2026-04-14T11:00:00.000Z",
attachments: [
{
filename: "数据模版.xlsx",
kind: "packing_xlsx",
headers: ["备注", "柜号"],
rows: [{ 备注: "只有备注没有必填列", 柜号: "WHSU8127240" }],
},
],
},
expected: {
mail_type: "NEW_CONTAINER",
status: "PARSE_FAILED",
last_error: "missing_columns",
container_no: "WHSU8127240",
selected_attachment: "数据模版.xlsx",
missing_columns: true,
shipment_count: 0,
pull: true,
},
},
{
spec: {
id: "13-illegal-date",
category: "abnormal",
from: "ops@example.com",
subject: "请查收新增预报 WHSU8127240",
text: "请查收新增预报。",
messageId: "<13-illegal-date@fixtures.local>",
date: "2026-04-14T12:00:00.000Z",
attachments: [
{
filename: "数据模版.xlsx",
kind: "packing_xlsx",
rows: [
{
仓库ID: "LAS1",
渠道: "卡派",
件数: 2,
总体积: 1,
毛重: 20,
柜号: "WHSU8127240",
最早送仓日期: "not-a-date",
最晚送仓日期: "99/99/99",
},
],
},
],
},
expected: {
mail_type: "NEW_CONTAINER",
status: "PENDING_CONFIRM",
last_error: null,
container_no: "WHSU8127240",
selected_attachment: "数据模版.xlsx",
shipment_count: 1,
valid_shipment_count: 1,
shipments: [
{
F_FBACode: "LAS1",
F_Transporter: "TRUCK",
F_CTNS: 2,
row_status: "VALID",
F_Expected_DeliveryDateB: null,
},
],
pull: true,
},
},
{
spec: {
id: "14-duplicate-pull",
category: "duplicate",
from: "ops@example.com",
subject: "请查收新增预报 智鸿2+WHLC027G597465+WHSU8127240+洛杉矶+40HQ",
text: "请查收新增预报:WHSU8127240 40HQ EDT2026.04-25 ETA2026.05-15 船名航次HMM EMERALD 013E+提拆派",
messageId: "<01-normal-forecast@fixtures.local>",
date: "2026-04-14T00:00:00.000Z",
attachments: [
{ filename: "数据模版-WHSU8127240.xlsx", kind: "packing_xlsx", rows: PACKING_TWO },
],
},
expected: {
mail_type: "NEW_CONTAINER",
status: "PENDING_CONFIRM",
last_error: null,
container_no: "WHSU8127240",
selected_attachment: "数据模版-WHSU8127240.xlsx",
idempotent_with: "01-normal-forecast",
pull: true,
},
},
{
spec: {
id: "15-multi-attachment",
category: "multi_attachment",
from: "ops@example.com",
subject: "请查收新增预报 WHSU8127240,DO请查收",
text: "请查收新增预报。DO也同步上传至附件。",
messageId: "<15-multi-attachment@fixtures.local>",
date: "2026-04-14T13:00:00.000Z",
attachments: [
{
filename: "YT2604021091=76件换标走HIA1卡派出库操作指令.xlsx",
kind: "packing_xlsx",
rows: [
{ 仓库ID: "HIA1", 渠道: "换标", 件数: 76, 柜号: "XXXX0000000" },
],
},
{ filename: "WHSU8127240-DO.pdf", kind: "pdf", text: "DO" },
{ filename: "数据模版-WHSU8127240.xlsx", kind: "packing_xlsx", rows: PACKING_TWO },
{ filename: "4邮箱.pdf", kind: "pdf", text: "screenshot" },
],
},
expected: {
// 附件名含「换标」走硬工单分(45+30=75),压过新增预报(65)与 DO(70);不改生产分类
mail_type: "WORK_ORDER",
status: "PENDING_CONFIRM",
last_error: null,
container_no: "WHSU8127240",
selected_attachment: "数据模版-WHSU8127240.xlsx",
selected_attachments: [
"YT2604021091=76件换标走HIA1卡派出库操作指令.xlsx",
"WHSU8127240-DO.pdf",
"数据模版-WHSU8127240.xlsx",
"4邮箱.pdf",
],
shipment_count: 2,
valid_shipment_count: 2,
pull: true,
},
},
{
spec: {
id: "16-unknown-format",
category: "abnormal",
from: "random@example.com",
subject: "Weekly newsletter",
text: "Hello, this is a marketing newsletter about office snacks.",
messageId: "<16-unknown-format@fixtures.local>",
date: "2026-04-14T14:00:00.000Z",
attachments: [],
},
expected: {
mail_type: "UNKNOWN",
status: "IGNORED",
last_error: "SKIP_FILTER",
selected_attachment: null,
pull: false,
},
},
];
async function main() {
await fs.mkdir(ROOT, { recursive: true });
for (const { spec, expected } of fixtures) {
const id = String(spec.id);
const dir = path.join(ROOT, id);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, "spec.json"), JSON.stringify(spec, null, 2) + "\n");
await fs.writeFile(
path.join(dir, "expected.json"),
JSON.stringify(expected, null, 2) + "\n",
);
}
console.log(`wrote ${fixtures.length} fixtures under ${ROOT}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

@ -46,7 +46,7 @@ export default function LoginPage() {
<Form
form={form}
layout="vertical"
initialValues={{ username: "admin", password: "admin123" }}
initialValues={{ username: "", password: "" }}
onFinish={onFinish}
>
<Form.Item
@ -73,16 +73,6 @@ export default function LoginPage() {
{UI_COPY.btnLogin}
</Button>
</Form>
<p
style={{
marginTop: 16,
textAlign: "center",
fontSize: 12,
color: "var(--color-text-secondary)",
}}
>
admin/admin123(Admin)· ops/ops123(运营)
</p>
</Card>
</div>
);

@ -0,0 +1,344 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import {
App,
Button,
Card,
Drawer,
Form,
Input,
Modal,
Space,
Switch,
Table,
Tag,
Typography,
} from "antd";
import { ErrorBanner } from "@/components/ErrorBanner";
import { IMPORT_STATUS_LABEL, MAIL_STATUS_LABEL, MAIL_TYPE_LABEL } from "@/constants/ui-copy";
import {
createCustomerApi,
listCustomerImportsApi,
listCustomersApi,
updateCustomerApi,
type CustomerAccount,
type CustomerImportRecord,
} from "@/lib/client-api";
export default function CustomersPage() {
const { message } = App.useApp();
const [items, setItems] = useState<CustomerAccount[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<CustomerAccount | null>(null);
const [saving, setSaving] = useState(false);
const [form] = Form.useForm<{
username: string;
password: string;
display_name: string;
}>();
const [importsOpen, setImportsOpen] = useState(false);
const [importsFor, setImportsFor] = useState<CustomerAccount | null>(null);
const [imports, setImports] = useState<CustomerImportRecord[]>([]);
const [importsTotal, setImportsTotal] = useState(0);
const [importsLoading, setImportsLoading] = useState(false);
const [importPage, setImportPage] = useState(1);
const reload = useCallback(async () => {
setLoading(true);
const res = await listCustomersApi();
setLoading(false);
if (!res.ok) {
setError(res.error.message);
return;
}
setError(null);
setItems(res.data.items);
}, []);
useEffect(() => {
void reload();
}, [reload]);
const openCreate = () => {
setEditing(null);
form.resetFields();
setModalOpen(true);
};
const openEdit = (row: CustomerAccount) => {
setEditing(row);
form.setFieldsValue({
username: row.username,
password: row.password,
display_name: row.display_name,
});
setModalOpen(true);
};
const onSave = async () => {
const values = await form.validateFields();
setSaving(true);
try {
if (editing) {
const res = await updateCustomerApi(editing.id, {
password: values.password,
display_name: values.display_name,
});
if (!res.ok) {
message.error(res.error.message);
return;
}
message.success("已更新客户账密");
} else {
const res = await createCustomerApi({
username: values.username,
password: values.password,
display_name: values.display_name,
});
if (!res.ok) {
message.error(res.error.message);
return;
}
message.success("已创建客户账号");
}
setModalOpen(false);
await reload();
} finally {
setSaving(false);
}
};
const onToggle = async (row: CustomerAccount, enabled: boolean) => {
const res = await updateCustomerApi(row.id, { enabled });
if (!res.ok) {
message.error(res.error.message);
return;
}
await reload();
};
const openImports = async (row: CustomerAccount) => {
setImportsFor(row);
setImportsOpen(true);
setImportPage(1);
setImportsLoading(true);
const res = await listCustomerImportsApi(row.id, { page: 1, pageSize: 20 });
setImportsLoading(false);
if (!res.ok) {
message.error(res.error.message);
return;
}
setImports(res.data.items);
setImportsTotal(res.data.total);
};
const loadImportPage = async (page: number) => {
if (!importsFor) return;
setImportPage(page);
setImportsLoading(true);
const res = await listCustomerImportsApi(importsFor.id, {
page,
pageSize: 20,
});
setImportsLoading(false);
if (!res.ok) {
message.error(res.error.message);
return;
}
setImports(res.data.items);
setImportsTotal(res.data.total);
};
return (
<Space direction="vertical" size={16} style={{ width: "100%" }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>
客户账号
</Typography.Title>
<Typography.Paragraph type="secondary" style={{ margin: "8px 0 0" }}>
配置客户登录账密。客户登录后自行绑定 CarrierCentral 与邮箱,即可只看到自己的邮件与导入。
</Typography.Paragraph>
</div>
{error ? <ErrorBanner message={error} /> : null}
<Card
extra={
<Button type="primary" onClick={openCreate}>
新增客户
</Button>
}
>
<Table
rowKey="id"
loading={loading}
dataSource={items}
pagination={false}
columns={[
{ title: "用户名", dataIndex: "username", width: 140 },
{
title: "密码",
dataIndex: "password",
width: 180,
render: (v: string) => (
<Typography.Text copyable={Boolean(v)}>{v || "—"}</Typography.Text>
),
},
{ title: "显示名", dataIndex: "display_name", width: 140 },
{
title: "邮箱数",
dataIndex: "mailbox_count",
width: 80,
},
{
title: "CC 账号",
dataIndex: "cc_username",
width: 140,
render: (v: string, r: CustomerAccount) =>
v ? (
<Space>
<span>{v}</span>
{r.cc_mock ? <Tag>mock</Tag> : <Tag color="green">live</Tag>}
</Space>
) : (
"未绑定"
),
},
{
title: "启用",
dataIndex: "enabled",
width: 80,
render: (v: boolean, r) => (
<Switch checked={v} onChange={(on) => void onToggle(r, on)} />
),
},
{
title: "操作",
width: 200,
render: (_, r) => (
<Space>
<Button size="small" onClick={() => openEdit(r)}>
改账密
</Button>
<Button size="small" onClick={() => void openImports(r)}>
导入记录
</Button>
</Space>
),
},
]}
/>
</Card>
<Modal
title={editing ? "修改客户账密" : "新增客户"}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => void onSave()}
confirmLoading={saving}
destroyOnClose
>
<Form form={form} layout="vertical" style={{ marginTop: 8 }}>
<Form.Item
name="username"
label="用户名"
rules={[{ required: true, message: "必填" }]}
>
<Input disabled={Boolean(editing)} autoComplete="off" />
</Form.Item>
<Form.Item
name="password"
label="密码"
rules={[{ required: true, min: 6, message: "至少 6 位" }]}
>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item name="display_name" label="显示名">
<Input placeholder="可选" />
</Form.Item>
</Form>
</Modal>
<Drawer
title={importsFor ? `${importsFor.username} 的导入记录` : "导入记录"}
open={importsOpen}
onClose={() => setImportsOpen(false)}
width={920}
>
<Table
rowKey="id"
loading={importsLoading}
dataSource={imports}
pagination={{
current: importPage,
total: importsTotal,
pageSize: 20,
onChange: (p) => void loadImportPage(p),
}}
expandable={{
expandedRowRender: (r) => (
<Space direction="vertical" style={{ width: "100%" }}>
<Typography.Text type="secondary">
邮件 {r.mail_id} · {MAIL_TYPE_LABEL[r.mail_type] || r.mail_type} ·{" "}
{MAIL_STATUS_LABEL[r.mail_status] || r.mail_status}
{r.trace_id ? ` · trace ${r.trace_id}` : ""}
</Typography.Text>
{r.last_error || r.mail_last_error ? (
<Typography.Text type="danger">
{r.last_error || r.mail_last_error}
</Typography.Text>
) : null}
{r.compensations.length ? (
<Typography.Text>
补偿:
{r.compensations
.map(
(c) =>
`${c.status} ${c.retry_count}/${c.max_retry} (${c.reason})`,
)
.join(";")}
</Typography.Text>
) : null}
<Typography.Paragraph
copyable
ellipsis={{ rows: 6, expandable: true }}
style={{ marginBottom: 0, fontFamily: "monospace", fontSize: 12 }}
>
请求:{r.request_body || "—"}
</Typography.Paragraph>
<Typography.Paragraph
copyable
ellipsis={{ rows: 6, expandable: true }}
style={{ marginBottom: 0, fontFamily: "monospace", fontSize: 12 }}
>
响应:{r.response_body || "—"}
</Typography.Paragraph>
</Space>
),
}}
columns={[
{ title: "柜号", dataIndex: "container_no", width: 140 },
{
title: "邮件",
dataIndex: "mail_subject",
ellipsis: true,
},
{
title: "导入状态",
dataIndex: "status",
width: 120,
render: (v: string) => IMPORT_STATUS_LABEL[v] || v,
},
{
title: "时间",
dataIndex: "created_at",
width: 180,
render: (v: string) => new Date(v).toLocaleString(),
},
]}
/>
</Drawer>
</Space>
);
}

@ -21,6 +21,7 @@ import { EmptyState } from "@/components/EmptyState";
import { ErrorBanner } from "@/components/ErrorBanner";
import { useAuthSession } from "@/components/providers/AuthSessionProvider";
import { UI_COPY, IMPORT_STATUS_LABEL } from "@/constants/ui-copy";
import { formatMailLastError } from "@/constants/error-copy";
import {
listImportsApi,
retryCompensationApi,
@ -121,6 +122,7 @@ function ImportLogsPanel() {
loading={loading}
dataSource={items}
pagination={false}
scroll={{ x: 1180 }}
columns={[
{
title: "时间",
@ -153,11 +155,14 @@ function ImportLogsPanel() {
),
},
{
title: "external_id",
title: "CC柜ID",
dataIndex: "external_id",
width: 160,
width: 220,
ellipsis: true,
render: (v: string | null) => (
<span className="mono">{v || "-"}</span>
<span className="mono" title={v || undefined}>
{v || "—"}
</span>
),
},
{
@ -165,7 +170,9 @@ function ImportLogsPanel() {
dataIndex: "last_error",
ellipsis: true,
render: (v: string | null) => (
<span style={{ color: "var(--color-error)" }}>{v || "-"}</span>
<span style={{ color: "var(--color-error)" }}>
{v ? formatMailLastError(v) : "-"}
</span>
),
},
{

@ -1,562 +1,128 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import {
Alert,
App,
Button,
Checkbox,
Radio,
Space,
Spin,
Typography,
} from "antd";
import { useEffect, useMemo } from "react";
import Link from "next/link";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { Alert, Button, Space, Spin, Typography } from "antd";
import { LeftOutlined } from "@ant-design/icons";
import { ForecastConfirmView } from "@/components/confirm/ForecastConfirmView";
import { InstructionConfirmPicker } from "@/components/confirm/InstructionConfirmPicker";
import {
CcForecastFormOrder,
buildCcForecastFormValue,
type CcForecastFormValue,
} from "@/components/CcForecastFormOrder";
import { ConfirmImportModal, ConflictModal } from "@/components/ConfirmImportModal";
import { CcUiModeSwitch } from "@/components/CcUiModeSwitch";
import { useAuthSession } from "@/components/providers/AuthSessionProvider";
DoUploadConfirmView,
TransferConfirmView,
WorkOrderConfirmView,
} from "@/components/confirm/OpsInstructionConfirmView";
import { UI_COPY } from "@/constants/ui-copy";
import { ccUiModeLabel } from "@/constants/cc-ui-mode";
import { useImport } from "@/hooks/useImport";
import { useMailDetail } from "@/hooks/useMailDetail";
import { useCcUiMode } from "@/hooks/useCcUiMode";
import { healthApi, listShippingLinesApi } from "@/lib/client-api";
import { canEnterForecastConfirm } from "@/services/cc/forecast-confirm-gate";
import {
matchShippingLineId,
shippingLineHintFromBl,
} from "@/services/parse/extract-container-header";
import type { MailRecordModules } from "@/types/mail";
import { isValidContainerNo } from "@/utils/iso6346";
confirmHref,
extractInstructionsFromMail,
resolveConfirmInstruction,
} from "@/services/parse/resolve-confirm-instruction";
export default function MailConfirmPage() {
const params = useParams<{ id: string }>();
const id = params.id;
const router = useRouter();
const { message } = App.useApp();
const { user } = useAuthSession();
const { mail, loading } = useMailDetail(id);
const { submitting, submit } = useImport(id);
const { mode: ccUiMode, setMode: setCcUiMode } = useCcUiMode();
const header = mail?.parse_result?.container_header;
const shipments = useMemo(
() => mail?.parse_result?.shipments ?? [],
[mail?.parse_result?.shipments],
);
const customerName =
mail?.parse_result?.mail_record?.modules?.customer_name ||
(
mail?.parse_result?.lineage?.mail_record as
| { modules?: { customer_name?: string } }
| undefined
)?.modules?.customer_name ||
"";
const [form, setForm] = useState<CcForecastFormValue>(() =>
buildCcForecastFormValue({}),
);
const [step, setStep] = useState(0);
const [selected, setSelected] = useState<Set<number>>(new Set());
const [ackUnmapped, setAckUnmapped] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [conflictOpen, setConflictOpen] = useState(false);
const [conflictReason, setConflictReason] = useState("CONFLICT_CONTAINER");
const [errors, setErrors] = useState<Record<string, string>>({});
const [containerSource, setContainerSource] = useState<
"body" | "attachment" | null
>(null);
const [shippingLines, setShippingLines] = useState<
{ value: string; label: string }[]
>([]);
/** 完整船司记录,用于提单前缀匹配 */
const [shippingLineRows, setShippingLineRows] = useState<
Array<{ id: string; name: string; code?: string; prefix?: string }>
>([]);
const [shippingLineHint, setShippingLineHint] = useState<string | null>(
null,
);
const [shippingLineUnmatched, setShippingLineUnmatched] = useState(false);
const isAdmin = user.role === "admin";
const [forceImportEnabled, setForceImportEnabled] = useState(false);
const softLimitExceeded = Boolean(
mail?.parse_result?.lineage?.soft_limit_exceeded,
const searchParams = useSearchParams();
const instructionId = searchParams.get("instruction_id") || undefined;
const omitOcr = !instructionId || instructionId.startsWith("forecast-");
const { mail, loading } = useMailDetail(id, { omitOcr });
const units = useMemo(
() => (mail ? extractInstructionsFromMail(mail) : []),
[mail],
);
const containerConflict = mail?.parse_result?.lineage?.CONTAINER_NO_CONFLICT as
| { body?: string; attachment?: string }
| undefined;
const hasContainerConflict = Boolean(
containerConflict?.body && containerConflict?.attachment,
);
useEffect(() => {
void healthApi().then((healthRes) => {
if (healthRes.ok) {
setForceImportEnabled(Boolean(healthRes.data.enable_force_import));
}
});
}, []);
useEffect(() => {
void listShippingLinesApi().then((res) => {
if (res.ok) {
setShippingLineRows(res.data.items);
setShippingLines(
res.data.items.map((item) => ({ value: item.id, label: item.name })),
);
}
});
}, []);
// 提单前缀 → 船司列表自动选择(V1.72 F_Prefix / ShippingLineCode)
useEffect(() => {
if (!form.F_BLCopyCode || !shippingLineRows.length) {
setShippingLineUnmatched(false);
return;
}
const hint = shippingLineHintFromBl(form.F_BLCopyCode);
setShippingLineHint(hint);
if (form.F_ShippingLineId) {
setShippingLineUnmatched(false);
return;
}
if (!hint) {
setShippingLineUnmatched(false);
return;
}
const id = matchShippingLineId(
hint,
shippingLineRows.map((l) => ({
F_Id: l.id,
F_FullName: l.name,
F_EnCode: l.code,
F_Prefix: l.prefix,
})),
{ blNo: form.F_BLCopyCode },
);
if (id) {
setShippingLineUnmatched(false);
setForm((p) =>
p.F_ShippingLineId === id ? p : { ...p, F_ShippingLineId: id },
);
} else {
setShippingLineUnmatched(true);
}
}, [form.F_BLCopyCode, form.F_ShippingLineId, shippingLineRows]);
useEffect(() => {
if (!mail) return;
const record =
mail.parse_result?.mail_record ||
(
mail.parse_result?.lineage as
| { mail_record?: { modules?: MailRecordModules } }
| undefined
)?.mail_record;
setForm(
buildCcForecastFormValue({
customerName,
header: mail.parse_result?.container_header,
modules: record?.modules,
const resolved = useMemo(
() =>
resolveConfirmInstruction({
units,
instructionId,
lineage: mail?.parse_result?.lineage,
}),
[units, instructionId, mail?.parse_result?.lineage],
);
}, [mail, customerName]);
const validRows = useMemo(
() => shipments.filter((s) => s.row_status === "VALID"),
[shipments],
);
const validRowKey = useMemo(
() => validRows.map((r) => r.row_index).join(","),
[validRows],
);
useEffect(() => {
if (softLimitExceeded) {
setSelected((prev) => (prev.size === 0 ? prev : new Set()));
return;
}
setSelected((prev) => {
const nextIds = validRowKey
? validRowKey.split(",").map((x) => Number(x))
: [];
if (
prev.size === nextIds.length &&
nextIds.every((id) => prev.has(id))
) {
return prev;
}
return new Set(nextIds);
});
}, [mail?.id, validRowKey, softLimitExceeded]);
useEffect(() => {
setContainerSource(null);
setStep(0);
}, [mail?.id]);
useEffect(() => {
if (!hasContainerConflict || !containerConflict || !containerSource) return;
const next =
containerSource === "body"
? containerConflict.body || ""
: containerConflict.attachment || "";
const upper = next.toUpperCase();
setForm((p) =>
p.F_ContainerNo === upper ? p : { ...p, F_ContainerNo: upper },
);
}, [
containerSource,
hasContainerConflict,
containerConflict?.body,
containerConflict?.attachment,
]);
useEffect(() => {
if (!mail || loading) return;
const valid =
mail.parse_result?.shipments.filter((s) => s.row_status === "VALID")
.length || 0;
const allowed = canEnterForecastConfirm({
mailType: mail.mail_type,
status: mail.status,
validShipmentCount: valid,
allowReadonlyStatuses: true,
});
if (!mail || loading || instructionId || !resolved.unit) return;
router.replace(confirmHref(mail.id, resolved.unit.id));
}, [mail, loading, instructionId, resolved.unit, router]);
if (!allowed) {
message.warning(UI_COPY.confirmNotAllowed);
router.replace(`/mails/${id}`);
}
}, [mail, loading, id, router, message]);
const selectedValid = Array.from(selected).filter((idx) =>
validRows.some((r) => r.row_index === idx),
if (loading || !mail) {
return (
<div style={{ padding: "80px 0", textAlign: "center" }}>
<Spin tip="加载确认页…">
<div style={{ minHeight: 160 }} />
</Spin>
</div>
);
const hasUnmappedSelected = selectedValid.some((idx) => {
const row = shipments.find((s) => s.row_index === idx);
return row?.warnings?.includes("CHANNEL_UNMAPPED");
});
const pageReadonly =
mail?.status === "IMPORTING" || mail?.status === "SUCCESS";
const validateStep1 = () => {
const next: Record<string, string> = {};
const { F_TransMode, F_OperationType, F_ContainerNo, F_CabinetType } = form;
if (F_TransMode !== 0 && F_TransMode !== 1 && F_TransMode !== 3) {
next.transMode = "请选择运输方式";
}
if (
F_OperationType !== 0 &&
F_OperationType !== 2 &&
F_OperationType !== 4
) {
next.operationType = "请选择业务类型";
}
if (hasContainerConflict && !containerSource) {
next.containerSource = "请选定正文或附件柜号";
}
if (!F_ContainerNo.trim()) next.containerNo = "柜号必填";
else if (!isValidContainerNo(F_ContainerNo))
next.containerNo = "柜号不符合 ISO 6346 格式";
if (!F_CabinetType.trim()) next.cabinetType = "请选择柜型";
setErrors(next);
return Object.keys(next).length === 0;
};
const validateStep2 = () => {
const next: Record<string, string> = {};
if (selectedValid.length === 0) next.rows = "请至少勾选一行有效货件";
if (hasUnmappedSelected && !ackUnmapped)
next.ack = "请勾选已知晓未映射渠道";
setErrors((p) => ({ ...p, ...next }));
return Object.keys(next).length === 0;
};
const onStepChange = (s: number) => {
if (pageReadonly) {
setStep(s);
return;
}
if (s === 1 && !validateStep1()) {
message.error("请先修正基础信息");
return;
}
setStep(s);
};
const onPrimary = () => {
if (pageReadonly || !mail) return;
if (!validateStep1() || !validateStep2()) {
message.error("请先修正表单校验错误");
return;
}
setConfirmOpen(true);
};
const onConfirmSubmit = async (forceSkip = false) => {
if (!mail) return;
const containerNo = form.F_ContainerNo.toUpperCase();
const basePayload = {
// 服务端会以库内 version 对齐;此处仍传当前页 version
version: mail.version,
idempotency_key: `${mail.id}:${containerNo}:${selectedValid.join(",")}${forceSkip ? ":force" : ""}`,
F_TransMode: form.F_TransMode,
F_OperationType: form.F_OperationType,
container_header: {
F_ContainerNo: containerNo,
F_CabinetType: form.F_CabinetType,
F_Classis: form.F_Classis,
F_ETA: form.F_ETA || null,
F_ETD: form.F_ETD || null,
F_LoadPort: form.F_LoadPort,
F_Dock: form.F_Dock,
F_BLCopyCode: form.F_BLCopyCode,
F_MemoRemark: form.F_MemoRemark,
F_Instruction: form.F_Instruction,
F_ShippingLineId: form.F_ShippingLineId,
},
selected_row_indexes: selectedValid,
ack_unmapped_channels: ackUnmapped,
...(forceSkip ? { force_skip_conflict: true } : {}),
};
const res = await submit(basePayload);
if (!res.ok) {
if (
res.error.code === "CONFLICT_CONTAINER" ||
res.error.code === "CONFLICT_CHECK_FAILED"
) {
setConfirmOpen(false);
setConflictReason(res.error.code);
setConflictOpen(true);
return;
}
message.error(res.error.message);
setConfirmOpen(false);
setConflictOpen(false);
return;
}
setConfirmOpen(false);
setConflictOpen(false);
if (res.data.mail_status === "SUCCESS") {
message.success("导入成功");
router.push(`/logs?mail_id=${mail.id}`);
} else if (res.data.mail_status === "PARTIAL_SUCCESS") {
message.warning("部分成功,请查看导入日志");
router.push(`/logs?mail_id=${mail.id}`);
} else {
// 业务失败(如 CC 校验)— 展示后端写入的具体原因
const importErr =
res.data.imports?.[0]?.last_error ||
mail.last_error ||
"导入失败,可修改后重试";
message.error(importErr);
router.refresh();
}
};
const canForceSkip =
isAdmin &&
forceImportEnabled &&
(conflictReason === "CONFLICT_CONTAINER" ||
conflictReason === "CONFLICT_CHECK_FAILED");
if (loading || !mail) {
if (!instructionId) {
if (resolved.unit) {
return (
<div style={{ padding: "80px 0", textAlign: "center" }}>
<Spin tip="加载确认页…">
<Spin tip="正在打开对应指令确认页…">
<div style={{ minHeight: 160 }} />
</Spin>
</div>
);
}
return (
<InstructionConfirmPicker
mailId={mail.id}
subject={mail.subject}
units={resolved.candidates}
missingId
/>
);
}
if (
mail.mail_type !== "NEW_CONTAINER" &&
mail.mail_type !== "DO_UPLOAD"
) {
return null;
if (resolved.reason === "not_found" || !resolved.unit) {
return (
<InstructionConfirmPicker
mailId={mail.id}
subject={mail.subject}
units={resolved.candidates}
/>
);
}
if (resolved.reason === "not_confirmable") {
return (
<Space
direction="vertical"
size={24}
style={{ width: "100%", paddingBottom: 96 }}
>
<Space
style={{ width: "100%", justifyContent: "space-between" }}
align="start"
>
<Space direction="vertical" size={8}>
<Button
type="default"
icon={<LeftOutlined />}
onClick={() => router.push(`/mails/${mail.id}`)}
>
<Space direction="vertical" size={24} style={{ width: "100%" }}>
<Link href={`/mails/${mail.id}`} prefetch>
<Button type="default" icon={<LeftOutlined />}>
{UI_COPY.backToDetail}
</Button>
<Typography.Title
level={2}
style={{ margin: 0, maxWidth: "100%" }}
ellipsis={{ tooltip: mail.subject }}
>
{mail.subject}
</Typography.Title>
<Typography.Text
style={{
fontFamily: "monospace",
fontSize: 20,
color: "var(--color-primary)",
}}
>
{form.F_ContainerNo || "-"}
</Typography.Text>
</Space>
<Space direction="vertical" align="end" size={4}>
<CcUiModeSwitch mode={ccUiMode} onChange={setCcUiMode} />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{ccUiModeLabel(ccUiMode)} · SaveContainer
</Typography.Text>
</Space>
</Space>
{mail.status === "IMPORTING" ? (
<Alert type="warning" showIcon message={UI_COPY.alertImporting} />
) : null}
{shippingLineUnmatched && shippingLineHint ? (
</Link>
<Alert
type="info"
showIcon
message={`提单号前缀「${shippingLineHint}」未在船司列表匹配到记录`}
description="已按接口文档用提单号前缀检索 GetShippingLineList(F_Prefix / 船司代码)。请手动选择船司,或在 CC 维护该前缀后重新解析。"
message="该指令无需确认页"
description="客户指令只在详情展示,请返回邮件详情查看。"
/>
) : null}
{softLimitExceeded ? (
<Alert type="warning" showIcon message={UI_COPY.softLimitAlert} />
) : null}
{hasContainerConflict ? (
<Alert
type="error"
showIcon
message={UI_COPY.containerConflictAlert}
description={
<Radio.Group
disabled={pageReadonly}
value={containerSource || undefined}
onChange={(e) => setContainerSource(e.target.value)}
style={{ marginTop: 8 }}
>
<Space direction="vertical">
<Radio value="body">
正文柜号:
<span className="mono">{containerConflict?.body}</span>
</Radio>
<Radio value="attachment">
附件柜号:
<span className="mono">{containerConflict?.attachment}</span>
</Radio>
</Space>
</Radio.Group>
);
}
/>
) : null}
{errors.containerSource ? (
<Typography.Text type="danger" style={{ fontSize: 12 }}>
{errors.containerSource}
</Typography.Text>
) : null}
<CcForecastFormOrder
mode={pageReadonly ? "readonly" : "edit"}
portal={ccUiMode}
value={form}
shipments={shipments}
shippingLines={shippingLines}
selected={selected}
onSelectedChange={setSelected}
onChange={(patch) => setForm((p) => ({ ...p, ...patch }))}
errors={errors}
step={step}
onStepChange={onStepChange}
softLimitExceeded={softLimitExceeded}
containerNoLocked={hasContainerConflict}
onFinish={onPrimary}
finishLoading={submitting}
finishDisabled={
selectedValid.length === 0 ||
(hasContainerConflict && !containerSource)
if (resolved.unit.uiKind === "forecast") {
return <ForecastConfirmView mail={mail} unit={resolved.unit} />;
}
if (resolved.unit.uiKind === "transfer") {
return <TransferConfirmView mail={mail} unit={resolved.unit} />;
}
if (resolved.unit.uiKind === "do_upload") {
return <DoUploadConfirmView mail={mail} unit={resolved.unit} />;
}
if (resolved.unit.uiKind === "work_order") {
return <WorkOrderConfirmView mail={mail} unit={resolved.unit} />;
}
/>
{hasUnmappedSelected ? (
<div>
<Checkbox
disabled={pageReadonly}
checked={ackUnmapped}
onChange={(e) => setAckUnmapped(e.target.checked)}
>
{UI_COPY.ackUnmapped}
</Checkbox>
{errors.ack ? (
<div
style={{
marginTop: 4,
fontSize: 12,
color: "var(--color-error)",
}}
>
{errors.ack}
</div>
) : null}
</div>
) : null}
{errors.rows ? (
<div style={{ fontSize: 12, color: "var(--color-error)" }}>
{errors.rows}
</div>
) : null}
<div className="sticky-footer">
<Typography.Text>
已选 <b>{selectedValid.length}</b> / 有效 <b>{validRows.length}</b>
</Typography.Text>
<Button onClick={() => router.push(`/mails/${mail.id}`)}>取消</Button>
</div>
<ConfirmImportModal
open={confirmOpen}
loading={submitting}
containerNo={form.F_ContainerNo}
selectedCount={selectedValid.length}
transMode={form.F_TransMode}
operationType={form.F_OperationType}
onCancel={() => setConfirmOpen(false)}
onConfirm={() => void onConfirmSubmit(false)}
/>
<ConflictModal
open={conflictOpen}
reason={conflictReason}
canForceSkip={canForceSkip}
loading={submitting}
onClose={() => setConflictOpen(false)}
onForceSkip={() => void onConfirmSubmit(true)}
/>
return (
<Space direction="vertical" size={16} style={{ padding: 24 }}>
<Typography.Text>未知指令类型</Typography.Text>
<Link href={`/mails/${mail.id}`}>返回详情</Link>
</Space>
);
}

@ -1,6 +1,7 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import {
Alert,
@ -27,13 +28,16 @@ import { useCcUiMode } from "@/hooks/useCcUiMode";
import {
healthApi,
ignoreMailApi,
deleteMailsApi,
overrideTypeApi,
reparseMailApi,
} from "@/lib/client-api";
import { buildMailIntent } from "@/services/parse/mail-intent";
import { sanitizeOcrDisplayText } from "@/services/ocr";
import { sanitizeOcrDisplayText } from "@/services/ocr/sanitize";
import { canTransition } from "@/services/state-machine";
import { canEnterForecastConfirm } from "@/services/cc/forecast-confirm-gate";
import { extractMailInstructions } from "@/services/parse/split-instructions";
import { confirmHref } from "@/services/parse/resolve-confirm-instruction";
import type { MailType } from "@/types/mail";
export default function MailDetailPage() {
@ -47,6 +51,7 @@ export default function MailDetailPage() {
const [reparseLoading, setReparseLoading] = useState(false);
const [ignoreLoading, setIgnoreLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [overrideOpen, setOverrideOpen] = useState(false);
const [overrideLoading, setOverrideLoading] = useState(false);
const [enableOverride, setEnableOverride] = useState(false);
@ -58,39 +63,51 @@ export default function MailDetailPage() {
});
}, []);
useEffect(() => {
if (!id) return;
router.prefetch(`/mails/${id}/confirm`);
}, [id, router]);
const validCount =
mail?.parse_result?.shipments.filter((s) => s.row_status === "VALID")
.length || 0;
const hasContainerHeader = Boolean(
(
mail?.parse_result?.container_header?.F_ContainerNo ||
mail?.container_no ||
""
).trim(),
);
const forecastUnit = useMemo(() => {
if (!mail) return null;
return extractMailInstructions({
subject: mail.subject,
body: mail.body_text,
filenames: mail.attachments.map((a) => a.filename),
}).find((u) => u.uiKind === "forecast") || null;
}, [mail]);
const canImport = mail
const canImport = mail && forecastUnit
? canEnterForecastConfirm({
mailType: mail.mail_type,
status: mail.status,
validShipmentCount: validCount,
hasContainerHeader,
})
: false;
const disableReason = useMemo(() => {
if (!mail) return "";
if (canImport) return "";
if (mail.mail_type === "WORK_ORDER" || mail.mail_type === "TRANSFER") {
return "";
}
if (mail.mail_type === "DO_UPLOAD" && validCount < 1) {
return "本封无有效货件行,仅可确认上传 DO";
}
if (!mail || canImport || !forecastUnit) return "";
if (
mail.mail_type !== "NEW_CONTAINER" &&
mail.mail_type !== "DO_UPLOAD"
mail.status !== "PENDING_CONFIRM" &&
mail.status !== "FAILED" &&
mail.status !== "PARTIAL_SUCCESS"
) {
return UI_COPY.disableNotNew(mail.mail_type);
}
if (mail.status !== "PENDING_CONFIRM" && mail.status !== "FAILED") {
return UI_COPY.disableBadStatus(mail.status);
}
if (validCount < 1) return UI_COPY.disableNoRows;
if (validCount < 1 && !hasContainerHeader) return UI_COPY.disableNoRows;
return "";
}, [mail, validCount, canImport]);
}, [mail, validCount, canImport, hasContainerHeader, forecastUnit]);
const showReparse = mail
? [
@ -204,6 +221,16 @@ export default function MailDetailPage() {
) : null}
<span>{new Date(mail.received_at).toLocaleString("zh-CN")}</span>
<span>#{mail.id}</span>
{mail.trace_id ? (
<Typography.Text
copyable={{ text: mail.trace_id }}
type="secondary"
className="mono"
style={{ fontSize: 12 }}
>
trace {mail.trace_id.slice(0, 8)}
</Typography.Text>
) : null}
</div>
</div>
@ -264,12 +291,43 @@ export default function MailDetailPage() {
</Button>
) : null}
<Button
type="primary"
disabled={!canImport}
onClick={() => router.push(`/mails/${mail.id}/confirm`)}
danger
loading={deleteLoading}
disabled={deleteLoading}
onClick={() => {
modal.confirm({
title: UI_COPY.btnDelete,
content: UI_COPY.deleteConfirm,
okText: UI_COPY.btnDelete,
okButtonProps: { danger: true },
onOk: async () => {
setDeleteLoading(true);
const res = await deleteMailsApi({ ids: [mail.id] });
setDeleteLoading(false);
if (res.ok) {
message.success("已删除");
router.push("/mails");
} else {
message.error(res.error.message);
return Promise.reject();
}
},
});
}}
>
{UI_COPY.btnDelete}
</Button>
{forecastUnit ? (
canImport ? (
<Link href={confirmHref(mail.id, forecastUnit.id)} prefetch>
<Button type="primary">{UI_COPY.btnConfirmImport}</Button>
</Link>
) : (
<Button type="primary" disabled>
{UI_COPY.btnConfirmImport}
</Button>
)
) : null}
</Space>
{!canImport && disableReason ? (
<div className="mail-detail-actions-hint">{disableReason}</div>
@ -346,17 +404,12 @@ export default function MailDetailPage() {
extra={
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
共 {mail.parse_result?.shipments.length || 0} 行
{canImport ? (
{canImport && forecastUnit ? (
<>
{" · "}
<Button
type="link"
size="small"
style={{ padding: 0 }}
onClick={() => router.push(`/mails/${mail.id}/confirm`)}
>
<Link href={confirmHref(mail.id, forecastUnit.id)} prefetch>
确认导入页查看全部
</Button>
</Link>
</>
) : null}
</Typography.Text>
@ -377,7 +430,11 @@ export default function MailDetailPage() {
title: "仓库",
dataIndex: "F_FBACode",
width: 88,
render: (v) => v || "—",
render: (v, row) =>
v ||
(/^(UPS|FEDEX|DHL|USPS)$/i.test(row.F_Transporter || "")
? String(row.F_Transporter).toUpperCase()
: "—"),
},
{
title: "渠道",

@ -1,21 +1,40 @@
"use client";
import { useEffect, useState } from "react";
import { App, Button, Card, Input, Select, Space, Typography } from "antd";
import { App, Button, Card, Input, Modal, Select, Space, Typography } from "antd";
import { ErrorBanner } from "@/components/ErrorBanner";
import { EmptyState } from "@/components/EmptyState";
import { MailTable } from "@/components/MailTable";
import { UI_COPY, MAIL_STATUS_LABEL, MAIL_TYPE_LABEL } from "@/constants/ui-copy";
import {
UI_COPY,
MAIL_STATUS_LABEL,
MAIL_TYPE_LABEL,
} from "@/constants/ui-copy";
import { MAIL_CONTENT_KIND_LABEL } from "@/utils/mailContent";
import {
MAIL_PRUNE_OLDEST_BATCH,
MAIL_STORAGE_SOFT_LIMIT,
} from "@/constants/mail-storage";
import { useMails } from "@/hooks/useMails";
import { deleteMailsApi } from "@/lib/client-api";
const STORAGE_PROMPT_KEY = "yx.mail-storage-prompt-dismissed";
export default function MailsPage() {
const { message } = App.useApp();
const [mailType, setMailType] = useState<string>("");
const [status, setStatus] = useState<string>("");
const [contentKind, setContentKind] = useState<string>("");
const [sort, setSort] = useState<"pulled_at" | "imported_at" | "received_at">(
"pulled_at",
);
const [q, setQ] = useState("");
const [qDebounced, setQDebounced] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [deleting, setDeleting] = useState(false);
const [storagePromptOpen, setStoragePromptOpen] = useState(false);
useEffect(() => {
const id = window.setTimeout(() => setQDebounced(q), 300);
@ -24,12 +43,23 @@ export default function MailsPage() {
useEffect(() => {
setPage(1);
}, [mailType, status, qDebounced]);
setSelectedIds([]);
}, [mailType, status, contentKind, sort, qDebounced]);
const { items, total, loading, error, reload } = useMails({
const {
items,
total,
storageTotal,
storageLimit,
loading,
error,
reload,
} = useMails({
mail_type: mailType || undefined,
status: status || undefined,
content_kind: contentKind || undefined,
q: qDebounced || undefined,
sort,
page,
pageSize,
});
@ -38,6 +68,41 @@ export default function MailsPage() {
if (error) message.error(error);
}, [error, message]);
useEffect(() => {
if (loading) return;
const limit = storageLimit || MAIL_STORAGE_SOFT_LIMIT;
if (storageTotal < limit) return;
try {
if (sessionStorage.getItem(STORAGE_PROMPT_KEY) === "1") return;
} catch {
/* ignore */
}
setStoragePromptOpen(true);
}, [loading, storageTotal, storageLimit]);
const dismissStoragePrompt = () => {
try {
sessionStorage.setItem(STORAGE_PROMPT_KEY, "1");
} catch {
/* ignore */
}
setStoragePromptOpen(false);
};
const runDelete = async (payload: { ids?: string[]; oldest?: number }) => {
setDeleting(true);
const res = await deleteMailsApi(payload);
setDeleting(false);
if (!res.ok) {
message.error(res.error.message);
return false;
}
message.success(`已删除 ${res.data.deleted} 封`);
setSelectedIds([]);
await reload();
return true;
};
return (
<Space direction="vertical" size={16} style={{ width: "100%" }}>
<div
@ -51,9 +116,11 @@ export default function MailsPage() {
<Typography.Title level={2} style={{ margin: 0 }}>
邮件预报
</Typography.Title>
<Space>
<Button loading={loading} disabled={loading} onClick={() => reload()}>
{UI_COPY.btnRefresh}
</Button>
</Space>
</div>
<Card styles={{ body: { paddingTop: 16 } }}>
@ -69,6 +136,16 @@ export default function MailsPage() {
label,
}))}
/>
<Select
allowClear
placeholder="内容类型"
style={{ width: 160 }}
value={contentKind || undefined}
onChange={(v) => setContentKind(v || "")}
options={Object.entries(MAIL_CONTENT_KIND_LABEL).map(
([value, label]) => ({ value, label }),
)}
/>
<Select
allowClear
placeholder="导入CC状态"
@ -80,6 +157,17 @@ export default function MailsPage() {
label,
}))}
/>
<Select
placeholder="排布"
style={{ width: 160 }}
value={sort}
onChange={(v) => setSort(v)}
options={[
{ value: "pulled_at", label: "按拉取时间" },
{ value: "imported_at", label: "按导入时间" },
{ value: "received_at", label: "按邮件时间" },
]}
/>
<Input
placeholder="来源 / 主题 / 正文"
style={{ width: 280 }}
@ -95,6 +183,24 @@ export default function MailsPage() {
>
{UI_COPY.btnQuery}
</Button>
{selectedIds.length > 0 ? (
<Button
danger
disabled={deleting}
loading={deleting}
onClick={() => {
Modal.confirm({
title: `删除选中的 ${selectedIds.length} 封邮件?`,
content: "删除后不可恢复,含附件与导入记录。",
okText: "删除",
okButtonProps: { danger: true },
onOk: () => runDelete({ ids: selectedIds }),
});
}}
>
批量删除 ({selectedIds.length})
</Button>
) : null}
</Space>
{error ? <ErrorBanner message={error} /> : null}
@ -112,6 +218,10 @@ export default function MailsPage() {
total={total}
page={page}
pageSize={pageSize}
selectedIds={selectedIds}
deleting={deleting}
onSelectedChange={setSelectedIds}
onDelete={(ids) => void runDelete({ ids })}
onPageChange={(p, ps) => {
setPage(p);
setPageSize(ps);
@ -119,6 +229,33 @@ export default function MailsPage() {
/>
)}
</Card>
<Modal
open={storagePromptOpen}
title={`已保存 ${storageTotal} 封邮件`}
onCancel={dismissStoragePrompt}
footer={[
<Button key="keep" onClick={dismissStoragePrompt}>
暂不删除
</Button>,
<Button
key="prune"
danger
loading={deleting}
onClick={async () => {
const ok = await runDelete({ oldest: MAIL_PRUNE_OLDEST_BATCH });
if (ok) dismissStoragePrompt();
}}
>
删除最早 {MAIL_PRUNE_OLDEST_BATCH} 封
</Button>,
]}
>
<p>
存储达到 {storageLimit || MAIL_STORAGE_SOFT_LIMIT}{" "}
封后建议清理较早拉取的邮件,以免磁盘占用过大。也可以先不删,之后在列表里勾选批量删除。
</p>
</Modal>
</Space>
);
}

@ -2,7 +2,6 @@
import { useCallback, useEffect, useState } from "react";
import {
Alert,
App,
Button,
Card,
@ -19,11 +18,13 @@ import {
} from "antd";
import { ErrorBanner } from "@/components/ErrorBanner";
import { CcSettingsCard } from "@/components/CcSettingsCard";
import { ChangePasswordCard } from "@/components/ChangePasswordCard";
import { ImapPullSettingsCard } from "@/components/ImapPullSettingsCard";
import { MailboxBindHelpIcon } from "@/components/MailboxBindHelp";
import { OauthSettingsButton } from "@/components/OauthSettingsCard";
import { useAuthSession } from "@/components/providers/AuthSessionProvider";
import { UI_COPY } from "@/constants/ui-copy";
import { formatOAuthCallbackError } from "@/constants/error-copy";
import {
createMailboxApi,
deleteMailboxApi,
@ -90,10 +91,10 @@ export default function SettingsPage() {
const oauth = new URLSearchParams(window.location.search).get("oauth");
if (oauth?.startsWith("ok:")) message.success("OAuth 邮箱已绑定");
else if (oauth?.startsWith("error:"))
message.error(`OAuth 失败:${decodeURIComponent(oauth.slice(6))}`);
message.error(formatOAuthCallbackError(decodeURIComponent(oauth.slice(6))));
}, [message]);
useEffect(() => {
if (user.role !== "admin") {
if (user.role !== "admin" && user.role !== "customer") {
setAllowed(false);
router.replace("/mails");
return;
@ -313,13 +314,16 @@ export default function SettingsPage() {
{error ? <ErrorBanner message={error} /> : null}
<CcSettingsCard />
<ImapPullSettingsCard />
{user.role === "customer" ? <ChangePasswordCard /> : null}
{user.role === "admin" ? <ImapPullSettingsCard /> : null}
<Card
title="邮箱绑定"
extra={
<Space wrap>
{user.role === "admin" ? (
<OauthSettingsButton onSaved={() => void reload()} />
) : null}
{oauthProviders.map((p) => (
<Button
key={p.id}
@ -459,12 +463,6 @@ export default function SettingsPage() {
</Button>,
]}
>
<Alert
type="info"
showIcon
style={{ marginTop: 12, marginBottom: 8 }}
message="仅「测试连接」通过或「保存并验证」联通成功后,才视为绑定可用;保存会自动再测一次。"
/>
<Form form={form} layout="vertical" style={{ marginTop: 8 }}>
<Form.Item
name="name"

@ -0,0 +1,8 @@
import { notFound } from "next/navigation";
import { isTestMode } from "@/lib/test-mode";
import { TestWorkbench } from "@/components/TestWorkbench";
export default function TestWorkbenchPage() {
if (!isTestMode()) notFound();
return <TestWorkbench />;
}

@ -0,0 +1,109 @@
import { z } from "zod";
import { fail, ok, parseBigIntId, requireAdmin } from "@/lib/api";
import { prisma } from "@/services/db";
type Ctx = { params: Promise<{ id: string }> };
const QuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
});
export async function GET(req: Request, ctx: Ctx) {
const guard = await requireAdmin();
if (guard.response) return guard.response;
const customerId = parseBigIntId((await ctx.params).id);
if (!customerId) return fail("VALIDATION", "Invalid id", 400);
const user = await prisma.appUser.findFirst({
where: { id: customerId, role: "customer" },
select: { id: true, username: true, displayName: true },
});
if (!user) return fail("NOT_FOUND", "客户不存在", 404);
const url = new URL(req.url);
const parsed = QuerySchema.safeParse(Object.fromEntries(url.searchParams));
if (!parsed.success) {
return fail("VALIDATION", "Invalid query", 400);
}
const { page, pageSize } = parsed.data;
const where = {
mail: { mailboxAccount: { ownerUserId: customerId } },
};
const [total, rows] = await Promise.all([
prisma.containerImport.count({ where }),
prisma.containerImport.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
mail: {
select: {
id: true,
subject: true,
status: true,
mailType: true,
lastError: true,
receivedAt: true,
createdAt: true,
updatedAt: true,
traceId: true,
},
},
compensations: {
select: {
id: true,
status: true,
retryCount: true,
maxRetry: true,
reason: true,
createdAt: true,
updatedAt: true,
},
},
},
}),
]);
return ok({
customer: {
id: user.id.toString(),
username: user.username,
display_name: user.displayName,
},
items: rows.map((r) => ({
id: r.id.toString(),
mail_id: r.mailId.toString(),
mail_subject: r.mail.subject,
mail_status: r.mail.status,
mail_type: r.mail.mailType,
mail_last_error: r.mail.lastError,
mail_received_at: r.mail.receivedAt,
mail_created_at: r.mail.createdAt,
mail_updated_at: r.mail.updatedAt,
trace_id: r.mail.traceId,
container_no: r.containerNo,
external_id: r.externalId,
status: r.status,
last_error: r.lastError,
request_body: r.requestBody,
response_body: r.responseBody,
created_at: r.createdAt,
updated_at: r.updatedAt,
compensations: r.compensations.map((c) => ({
id: c.id.toString(),
status: c.status,
retry_count: c.retryCount,
max_retry: c.maxRetry,
reason: c.reason,
created_at: c.createdAt,
updated_at: c.updatedAt,
})),
})),
total,
page,
pageSize,
});
}

@ -0,0 +1,50 @@
import { z } from "zod";
import { fail, ok, parseBigIntId, requireAdmin } from "@/lib/api";
import { writeAudit } from "@/services/audit";
import {
CustomerAccountError,
updateCustomer,
} from "@/services/auth/customers";
type Ctx = { params: Promise<{ id: string }> };
const PatchSchema = z.object({
password: z.string().min(6).max(128).optional(),
display_name: z.string().max(128).optional(),
enabled: z.boolean().optional(),
});
export async function PATCH(req: Request, ctx: Ctx) {
const guard = await requireAdmin();
if (guard.response) return guard.response;
const id = parseBigIntId((await ctx.params).id);
if (!id) return fail("VALIDATION", "Invalid id", 400);
let body: unknown;
try {
body = await req.json();
} catch {
return fail("VALIDATION", "Invalid JSON", 400);
}
const parsed = PatchSchema.safeParse(body);
if (!parsed.success) {
return fail("VALIDATION", parsed.error.issues[0]?.message ?? "Invalid", 400);
}
try {
const item = await updateCustomer(id, {
password: parsed.data.password,
displayName: parsed.data.display_name,
enabled: parsed.data.enabled,
});
await writeAudit({
actor: guard.session.username!,
action: "CUSTOMER_UPDATE",
payload: { id: item.id, username: item.username },
});
return ok(item);
} catch (err) {
if (err instanceof CustomerAccountError) {
return fail(err.code, err.message, err.status);
}
throw err;
}
}

@ -0,0 +1,54 @@
import { z } from "zod";
import { fail, ok, requireAdmin } from "@/lib/api";
import { writeAudit } from "@/services/audit";
import {
CustomerAccountError,
createCustomer,
listCustomers,
} from "@/services/auth/customers";
const CreateSchema = z.object({
username: z.string().min(3).max(64),
password: z.string().min(6).max(128),
display_name: z.string().max(128).optional(),
});
export async function GET() {
const guard = await requireAdmin();
if (guard.response) return guard.response;
const items = await listCustomers();
return ok({ items });
}
export async function POST(req: Request) {
const guard = await requireAdmin();
if (guard.response) return guard.response;
let body: unknown;
try {
body = await req.json();
} catch {
return fail("VALIDATION", "Invalid JSON", 400);
}
const parsed = CreateSchema.safeParse(body);
if (!parsed.success) {
return fail("VALIDATION", parsed.error.issues[0]?.message ?? "Invalid", 400);
}
try {
const item = await createCustomer({
username: parsed.data.username,
password: parsed.data.password,
displayName: parsed.data.display_name,
});
await writeAudit({
actor: guard.session.username!,
action: "CUSTOMER_CREATE",
payload: { id: item.id, username: item.username },
});
return ok(item, 201);
} catch (err) {
if (err instanceof CustomerAccountError) {
return fail(err.code, err.message, err.status);
}
throw err;
}
}

@ -35,7 +35,10 @@ export async function POST(req: Request) {
return fail("RATE_LIMITED", "Too many login attempts, try again later", 429);
}
const cred = verifyCredentials(parsed.data.username, parsed.data.password);
const cred = await verifyCredentials(
parsed.data.username,
parsed.data.password,
);
if (!cred) {
return fail("INVALID_CREDENTIALS", "Invalid username or password", 401);
}
@ -43,7 +46,12 @@ export async function POST(req: Request) {
const session = await getSession();
session.username = cred.username;
session.role = cred.role;
session.userId = cred.userId;
await session.save();
return ok({ username: cred.username, role: cred.role });
return ok({
username: cred.username,
role: cred.role,
user_id: cred.userId ?? null,
});
}

@ -6,5 +6,6 @@ export async function GET() {
return ok({
username: guard.session.username,
role: guard.session.role,
user_id: guard.session.userId ?? null,
});
}

@ -0,0 +1,49 @@
import { z } from "zod";
import { fail, ok, requireSession } from "@/lib/api";
import { writeAudit } from "@/services/audit";
import {
CustomerAccountError,
changeOwnPassword,
} from "@/services/auth/customers";
const BodySchema = z.object({
current_password: z.string().min(1).max(128),
new_password: z.string().min(6).max(128),
});
export async function PUT(req: Request) {
const guard = await requireSession();
if (guard.response) return guard.response;
if (guard.session.role !== "customer" || !guard.session.userId) {
return fail("FORBIDDEN", "仅客户账号可在此修改密码", 403);
}
let body: unknown;
try {
body = await req.json();
} catch {
return fail("VALIDATION", "Invalid JSON", 400);
}
const parsed = BodySchema.safeParse(body);
if (!parsed.success) {
return fail("VALIDATION", parsed.error.issues[0]?.message ?? "Invalid", 400);
}
try {
await changeOwnPassword({
userId: BigInt(guard.session.userId),
currentPassword: parsed.data.current_password,
newPassword: parsed.data.new_password,
});
await writeAudit({
actor: guard.session.username!,
action: "CUSTOMER_PASSWORD_CHANGE",
payload: { userId: guard.session.userId },
});
return ok({ ok: true });
} catch (err) {
if (err instanceof CustomerAccountError) {
return fail(err.code, err.message, err.status);
}
throw err;
}
}

@ -51,5 +51,7 @@ export async function GET() {
cc_reason: cc.reason,
enable_force_import: env.ENABLE_FORCE_IMPORT,
enable_type_override: env.ENABLE_TYPE_OVERRIDE,
test_mode: env.TEST_MODE,
alert_webhook_configured: Boolean(env.ALERT_WEBHOOK_URL?.trim()),
});
}

@ -1,6 +1,7 @@
import { z } from "zod";
import { fail, ok, requireSession } from "@/lib/api";
import { prisma } from "@/services/db";
import { mailboxIdsForOwner, sessionUserId } from "@/services/tenant/access";
const QuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
@ -19,7 +20,15 @@ export async function GET(req: Request) {
}
const { page, pageSize, result } = parsed.data;
const where = result ? { result } : {};
const where: {
result?: string;
mailboxAccountId?: { in: bigint[] };
} = result ? { result } : {};
if (guard.session.role === "customer") {
const uid = sessionUserId(guard.session);
const ids = uid ? await mailboxIdsForOwner(uid) : [];
where.mailboxAccountId = { in: ids.length ? ids : [BigInt(0)] };
}
const [total, rows] = await Promise.all([
prisma.imapPullLog.count({ where }),

@ -2,6 +2,7 @@ import { z } from "zod";
import { Prisma } from "@prisma/client";
import { prisma } from "@/services/db";
import { fail, ok, parseBigIntId, requireSession } from "@/lib/api";
import { importWhereForSession } from "@/services/tenant/access";
const querySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
@ -28,11 +29,15 @@ export async function GET(req: Request) {
if (!mailId) return fail("VALIDATION", "Invalid mail_id", 400);
where.mailId = mailId;
}
const scope = importWhereForSession(guard.session);
const scopedWhere: Prisma.ContainerImportWhereInput = scope
? { AND: [where, scope] }
: where;
const [total, rows] = await Promise.all([
prisma.containerImport.count({ where }),
prisma.containerImport.count({ where: scopedWhere }),
prisma.containerImport.findMany({
where,
where: scopedWhere,
orderBy: { createdAt: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,

@ -9,6 +9,7 @@ type RouteContext = { params: Promise<{ id: string }> };
const bodySchema = z.object({
version: z.number().int(),
instruction_id: z.string().optional(),
container_no: z.string().min(1),
container_task_id: z.string().optional(),
filenames: z.array(z.string()).optional(),
@ -33,6 +34,7 @@ export async function POST(req: Request, ctx: RouteContext) {
mailId,
version: parsed.data.version,
actor: guard.session.username,
instructionId: parsed.data.instruction_id,
containerNo: parsed.data.container_no,
containerTaskId: parsed.data.container_task_id,
filenames: parsed.data.filenames,
@ -40,7 +42,7 @@ export async function POST(req: Request, ctx: RouteContext) {
return ok(result);
} catch (err) {
if (err instanceof DoConfirmError) {
return fail(err.code, err.message, err.httpStatus);
return fail(err.code, err.message, err.httpStatus, err.details);
}
throw err;
}

@ -12,15 +12,18 @@ import {
requireSession,
} from "@/lib/api";
import { emitResilience } from "@/lib/resilience-metrics";
import { assertMailReadable } from "@/services/tenant/access";
import type { OperationType, TransMode } from "@/types/mail";
const bodySchema = z.object({
version: z.number().int().min(1),
instruction_id: z.string().optional(),
idempotency_key: z.string().min(1).max(128),
F_TransMode: z.union([z.literal(0), z.literal(1), z.literal(3)]),
F_OperationType: z.union([z.literal(0), z.literal(2), z.literal(4)]),
container_header: z.record(z.unknown()).default({}),
selected_row_indexes: z.array(z.number().int().min(0)).min(1),
/** 仅柜头预报允许空数组 */
selected_row_indexes: z.array(z.number().int().min(0)).default([]),
ack_unmapped_channels: z.boolean().optional(),
force_skip_conflict: z.boolean().optional(),
});
@ -49,6 +52,9 @@ export async function POST(req: Request, ctx: RouteContext) {
const { id } = await ctx.params;
const mailId = parseBigIntId(id);
if (!mailId) return fail("VALIDATION", "Invalid mail id", 400);
if (!(await assertMailReadable(guard.session, mailId))) {
return fail("NOT_FOUND", "Mail not found", 404);
}
let json: unknown;
try {
@ -59,7 +65,12 @@ export async function POST(req: Request, ctx: RouteContext) {
const parsed = bodySchema.safeParse(json);
if (!parsed.success) {
return fail("VALIDATION", "Invalid body", 400, parsed.error.flatten());
const issue = parsed.error.issues[0];
const path = issue?.path?.join(".") || "";
const hint = path
? `请求参数无效(${path})`
: "请求参数无效";
return fail("VALIDATION", hint, 400, parsed.error.flatten());
}
if (parsed.data.force_skip_conflict && !isAdmin(guard.session)) {
@ -71,6 +82,7 @@ export async function POST(req: Request, ctx: RouteContext) {
mailId,
actor: username,
version: parsed.data.version,
instructionId: parsed.data.instruction_id,
idempotencyKey: parsed.data.idempotency_key,
F_TransMode: parsed.data.F_TransMode as TransMode,
F_OperationType: parsed.data.F_OperationType as OperationType,
@ -82,6 +94,7 @@ export async function POST(req: Request, ctx: RouteContext) {
return ok({
mail_status: result.mailStatus,
accepted_count: result.acceptedCount,
version: result.version,
imports: result.imports.map((r) => ({
id: r.id.toString(),
mail_id: r.mailId.toString(),

@ -0,0 +1,54 @@
import fs from "fs/promises";
import { fail, ok, parseBigIntId, requireSession } from "@/lib/api";
import { resolveDataFile } from "@/lib/safe-path";
import { ocrTransferScreenshotBuffers } from "@/services/ocr";
import { prisma } from "@/services/db";
type RouteContext = { params: Promise<{ id: string }> };
/** 对内嵌截图附件重新 OCR,写入 mail_message.ocr_text(按文件名分块) */
export async function POST(_req: Request, ctx: RouteContext) {
const guard = await requireSession();
if (guard.response) return guard.response;
const { id } = await ctx.params;
const mailId = parseBigIntId(id);
if (!mailId) return fail("VALIDATION", "Invalid mail id", 400);
const mail = await prisma.mailMessage.findUnique({
where: { id: mailId },
include: { attachments: true },
});
if (!mail) return fail("NOT_FOUND", "邮件不存在", 404);
const files = [];
for (const att of mail.attachments.filter((a) => !a.rejected)) {
if (!/\.(jpe?g|png|webp|bmp)$/i.test(att.filename)) continue;
try {
files.push({
buffer: await fs.readFile(resolveDataFile(att.path)),
filename: att.filename,
contentType: att.contentType,
});
} catch {
/* skip missing file */
}
}
const ocr = await ocrTransferScreenshotBuffers(files);
if (!ocr?.text) {
return fail("OCR_EMPTY", "未能从图片/PDF 识别到文字", 422);
}
await prisma.mailMessage.update({
where: { id: mailId },
data: { ocrText: ocr.text },
});
return ok({
ocr_text: ocr.text,
provider: ocr.provider,
confidence: ocr.confidence,
pages: ocr.pages,
});
}

@ -1,19 +1,22 @@
import { prisma } from "@/services/db";
import { fail, ok, parseBigIntId, requireSession } from "@/lib/api";
import { assertMailReadable } from "@/services/tenant/access";
import {
deriveIngestSource,
formatIngestSourceLabel,
} from "@/utils/ingest-source";
import { fillShipmentFbaCodes } from "@/services/parse/normalize-fba-code";
type RouteContext = { params: Promise<{ id: string }> };
export async function GET(_req: Request, ctx: RouteContext) {
export async function GET(req: Request, ctx: RouteContext) {
const guard = await requireSession();
if (guard.response) return guard.response;
const { id } = await ctx.params;
const mailId = parseBigIntId(id);
if (!mailId) return fail("VALIDATION", "Invalid mail id", 400);
const omitOcr = new URL(req.url).searchParams.get("omit_ocr") === "1";
const mail = await prisma.mailMessage.findUnique({
where: { id: mailId },
@ -26,6 +29,9 @@ export async function GET(_req: Request, ctx: RouteContext) {
});
if (!mail) return fail("NOT_FOUND", "Mail not found", 404);
if (!(await assertMailReadable(guard.session, mailId))) {
return fail("NOT_FOUND", "Mail not found", 404);
}
const ingest_source = deriveIngestSource();
const mailboxHint =
@ -42,13 +48,14 @@ export async function GET(_req: Request, ctx: RouteContext) {
ingest_source_label: formatIngestSourceLabel(ingest_source, mailboxHint),
received_at: mail.receivedAt,
body_text: mail.bodyText,
ocr_text: mail.ocrText,
ocr_text: omitOcr ? null : mail.ocrText,
mail_type: mail.mailType,
status: mail.status,
type_evidence: mail.typeEvidence,
snapshot_path: mail.snapshotPath,
raw_hash: mail.rawHash,
last_error: mail.lastError,
trace_id: mail.traceId,
version: mail.version,
created_at: mail.createdAt,
updated_at: mail.updatedAt,
@ -67,7 +74,7 @@ export async function GET(_req: Request, ctx: RouteContext) {
? {
id: mail.parseResult.id.toString(),
container_header: mail.parseResult.containerHeader,
shipments: mail.parseResult.shipments,
shipments: fillShipmentFbaCodes(mail.parseResult.shipments),
mail_record:
(
mail.parseResult.lineage as

@ -9,6 +9,7 @@ type RouteContext = { params: Promise<{ id: string }> };
const bodySchema = z.object({
version: z.number().int(),
instruction_id: z.string().optional(),
container_no: z.string().min(1),
payload: z.object({
keyValue: z.string(),
@ -43,13 +44,14 @@ export async function POST(req: Request, ctx: RouteContext) {
mailId,
version: parsed.data.version,
actor: guard.session.username,
instructionId: parsed.data.instruction_id,
containerNo: parsed.data.container_no,
payload: parsed.data.payload,
});
return ok(result);
} catch (err) {
if (err instanceof TransferConfirmError) {
return fail(err.code, err.message, err.httpStatus);
return fail(err.code, err.message, err.httpStatus, err.details);
}
throw err;
}

@ -1,6 +1,8 @@
import { fail, ok, parseBigIntId, requireSession } from "@/lib/api";
import { runWithMailCcOwner } from "@/lib/cc-owner";
import { evaluateTransferGate } from "@/services/cc/container-status";
import { prisma } from "@/services/db";
import { assertMailReadable } from "@/services/tenant/access";
type RouteContext = { params: Promise<{ id: string }> };
@ -17,11 +19,14 @@ export async function GET(_req: Request, ctx: RouteContext) {
include: { parseResult: true },
});
if (!mail) return fail("NOT_FOUND", "邮件不存在", 404);
if (!(await assertMailReadable(guard.session, mailId))) {
return fail("NOT_FOUND", "邮件不存在", 404);
}
const header = mail.parseResult?.containerHeader as
| { F_ContainerNo?: string }
| undefined;
const cn = (header?.F_ContainerNo || "").trim();
const gate = await evaluateTransferGate(cn);
const gate = await runWithMailCcOwner(mailId, () => evaluateTransferGate(cn));
return ok({ container_no: cn, gate });
}

@ -9,6 +9,7 @@ type RouteContext = { params: Promise<{ id: string }> };
const bodySchema = z.object({
version: z.number().int(),
instruction_id: z.string().optional(),
entity: z.object({
F_ReferCode: z.string().optional(),
F_MessageType: z.string().min(1),
@ -40,12 +41,13 @@ export async function POST(req: Request, ctx: RouteContext) {
mailId,
version: parsed.data.version,
actor: guard.session.username,
instructionId: parsed.data.instruction_id,
entity: parsed.data.entity,
});
return ok(result);
} catch (err) {
if (err instanceof WorkOrderConfirmError) {
return fail(err.code, err.message, err.httpStatus);
return fail(err.code, err.message, err.httpStatus, err.details);
}
throw err;
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save