From 2b29fa74979b5fbfbe0ebe0b9f98cefe7dbdec44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=A0=E7=9A=84GitHub=E7=94=A8=E6=88=B7=E5=90=8D?= <3457189498@qq.com> Date: Wed, 12 Aug 2026 10:21:15 +0800 Subject: [PATCH] Initial commit: mail auto-forecast (email-forecast). Ship source, prisma, docs, and gold samples. Ignore local .env, IMAP snapshots, smoke logs, and debug scripts. Co-authored-by: Cursor --- .cursor/rules/implementation-plan.mdc | 64 + .dockerignore | 13 + .env.example | 77 + .gitignore | 40 + Dockerfile | 31 + README.md | 104 + data/.gitkeep | 1 + docker-compose.yml | 67 + docs/start-system.cmd | 7 + docs/start-system.ps1 | 378 ++ docs/邮箱项目.md | 435 ++ ...-UX设计方案-邮件自动预报-v1.0.md | 452 ++ ...º§品规则-邮件指令识别与拆分.md | 140 + docx/异常场景与系统韧性设计.md | 572 +++ ...术设计方案-邮件自动预报-v1.0.md | 650 +++ ...arriercentral客户端通用接口V1.72.md | 1628 +++++++ docx/邮件/样例附件清单.md | 56 + docx/邮件/模板/WHSU8127240-DO.pdf | Bin 0 -> 2218220 bytes .../数据模版-新辰泽-WHSU8127240.xlsx | Bin 0 -> 8906 bytes docx/邮件/模板/数据模版.xlsx | Bin 0 -> 14513 bytes ...ˆ¹名航次HMM EMERALD 013E+提拆派.xlsx | Bin 0 -> 8656 bytes ...ˆ¹名航次HMM EMERALD 013E+提拆派.xlsx | Bin 0 -> 8656 bytes .../邮件识别.docx | Bin 0 -> 547026 bytes .../附件/WHSU8127240-DO.pdf | Bin 0 -> 2218220 bytes .../附件/数据模版.xlsx | Bin 0 -> 14513 bytes docx/邮件/邮件1/QQ邮箱.pdf | Bin 0 -> 97460 bytes docx/邮件/邮件2/2邮箱.pdf | Bin 0 -> 228988 bytes .../柜号:MATU2745683卡派资料.xlsx | Bin 0 -> 35383 bytes docx/邮件/邮件3/3邮箱.pdf | Bin 0 -> 377238 bytes docx/邮件/邮件3/WL103276-SBD1(1).pdf | Bin 0 -> 152020 bytes docx/邮件/邮件3/WL103944-WL103940.pdf | Bin 0 -> 349311 bytes docx/邮件/邮件3/WL98967-VGT2(1).pdf | Bin 0 -> 428726 bytes docx/邮件/邮件3/WL99583-LAX9.pdf | Bin 0 -> 417580 bytes docx/邮件/邮件4/4邮箱.pdf | Bin 0 -> 657570 bytes ...W52S0L-76CTN-HIA1-覆盖贴1箱贴2张.pdf | Bin 0 -> 183876 bytes ...X003UHDF7B-304PCS-覆盖贴1箱贴1张.pdf | Bin 0 -> 21013 bytes ...换标走HIA1卡派出库操作指令.xlsx | Bin 0 -> 1033180 bytes docx/金样例/M1.json | 7 + docx/金样例/M2.json | 15 + docx/金样例/M3.json | 6 + docx/金样例/M4.json | 8 + docx/金样例/M5.json | 22 + docx/金样例/M_BL_TEMPLATE.json | 22 + docx/金样例/M_WORK_ORDER.json | 10 + docx/需求规格-邮件自动预报-v0.2.md | 716 +++ next-env.d.ts | 6 + next.config.ts | 19 + package.json | 77 + playwright.config.ts | 17 + pnpm-lock.yaml | 3955 +++++++++++++++++ prisma/schema.prisma | 271 ++ prisma/seed.ts | 61 + public/.gitkeep | 1 + scripts/analyze-mail-rounds.ts | 38 + scripts/apply-demovip-cc.ts | 73 + scripts/bind-instruction-segment-ui.ts | 103 + scripts/cc-smoke.ts | 165 + scripts/check-template-recognize.ts | 43 + scripts/clear-imap-lock.ts | 19 + scripts/dev-stack.mjs | 50 + scripts/docker-entrypoint-web.sh | 10 + scripts/extract-mail-recognize-docx.py | 16 + scripts/extract-pdf-text.py | 18 + scripts/fill-xcz-packing-template.ts | 119 + scripts/fix-mail-business-summary-zh.ts | 5 + scripts/fix-wo-btn.ts | 34 + scripts/flatten-mail-attachments.ts | 62 + scripts/imap-poll.ts | 52 + scripts/imap-smoke.ts | 53 + scripts/ingest-do-upload-ui.ts | 145 + scripts/ingest-forecast-ui.ts | 170 + scripts/ingest-mail3.ts | 128 + scripts/ingest-recognize-gold.ts | 198 + scripts/ingest-sample-mails.ts | 341 ++ scripts/lib/load-mailbox-pdf-body.ts | 61 + scripts/mark-non-business-ignored.ts | 46 + scripts/patch-classify-gold.ts | 76 + scripts/patch-classify-packing-narrow.ts | 37 + scripts/patch-classify-wo-fallback.ts | 21 + scripts/patch-date-shell-rule.ts | 29 + scripts/patch-detail-full-body-ui.ts | 157 + scripts/patch-do-keep-customer.ts | 63 + scripts/patch-forecast-packing-tips-ui.ts | 47 + scripts/patch-ingest-full-body.ts | 71 + scripts/patch-latest-readonly.ts | 138 + scripts/patch-packing-note-tests.ts | 57 + scripts/patch-packing-skip-notes.ts | 45 + scripts/patch-preserve-body-text.ts | 75 + scripts/patch-resolve-fuller-body.ts | 51 + scripts/patch-shell-subject-noise.ts | 152 + scripts/patch-skip-instruction-rows.ts | 94 + scripts/patch-skip-shell-segments.ts | 96 + scripts/patch-split-segment-kinds.ts | 108 + scripts/patch-strip-conservative.ts | 158 + scripts/patch-strip-subject-block.ts | 114 + scripts/patch-ui-customer-fallback.ts | 83 + scripts/patch-ui-latest-readonly.ts | 148 + scripts/patch-wire-packing-fill-rules.ts | 105 + scripts/purge-all-mails.ts | 48 + scripts/retention-cleanup.ts | 29 + scripts/rewrite-mail-business-summary.ts | 679 +++ scripts/scan-mail-samples.ts | 27 + scripts/verify-reimport-rules.ts | 71 + src/app/(auth)/login/page.tsx | 89 + src/app/(ops)/layout.tsx | 17 + src/app/(ops)/loading.tsx | 20 + src/app/(ops)/logs/page.tsx | 326 ++ src/app/(ops)/mails/[id]/confirm/page.tsx | 562 +++ src/app/(ops)/mails/[id]/page.tsx | 509 +++ src/app/(ops)/mails/page.tsx | 124 + src/app/(ops)/settings/page.tsx | 560 +++ src/app/api/admin/imap/poll/route.ts | 40 + src/app/api/auth/login/route.ts | 49 + src/app/api/auth/logout/route.ts | 8 + src/app/api/auth/me/route.ts | 10 + src/app/api/compensations/[id]/retry/route.ts | 42 + src/app/api/health/route.ts | 55 + src/app/api/imap-pull-logs/route.ts | 54 + src/app/api/imports/[importId]/retry/route.ts | 62 + src/app/api/imports/route.ts | 77 + .../attachments/[attId]/download/route.ts | 50 + src/app/api/mails/[id]/do/confirm/route.ts | 47 + src/app/api/mails/[id]/ignore/route.ts | 68 + src/app/api/mails/[id]/import/route.ts | 108 + src/app/api/mails/[id]/reparse/route.ts | 61 + src/app/api/mails/[id]/route.ts | 93 + .../api/mails/[id]/transfer/confirm/route.ts | 56 + src/app/api/mails/[id]/transfer/gate/route.ts | 27 + src/app/api/mails/[id]/type/route.ts | 111 + .../mails/[id]/work-order/confirm/route.ts | 52 + src/app/api/mails/route.ts | 126 + .../api/oauth/callback/[provider]/route.ts | 70 + src/app/api/oauth/providers/route.ts | 9 + src/app/api/oauth/start/route.ts | 44 + src/app/api/settings/cc/route.ts | 86 + src/app/api/settings/cc/test/route.ts | 38 + .../api/settings/imap/filters/[id]/route.ts | 108 + src/app/api/settings/imap/filters/route.ts | 86 + src/app/api/settings/imap/route.ts | 69 + src/app/api/settings/mailboxes/[id]/route.ts | 135 + .../api/settings/mailboxes/[id]/test/route.ts | 45 + src/app/api/settings/mailboxes/probe/route.ts | 73 + src/app/api/settings/mailboxes/route.ts | 104 + src/app/api/settings/oauth/route.ts | 79 + src/app/api/shipping-lines/route.ts | 10 + src/app/error.tsx | 39 + src/app/globals.css | 918 ++++ src/app/layout.tsx | 22 + src/app/page.tsx | 5 + src/components/AuthGuard.tsx | 8 + src/components/CcCustomerInstructionPanel.tsx | 60 + src/components/CcDoUploadPanel.tsx | 142 + src/components/CcForecastFormOrder.tsx | 755 ++++ src/components/CcHoldMoveTypePanel.tsx | 243 + src/components/CcOutStockPanel.tsx | 385 ++ src/components/CcSettingsCard.tsx | 201 + src/components/CcSplitForm.tsx | 182 + src/components/CcTransferIndexTruck.tsx | 564 +++ src/components/CcUiModeSwitch.tsx | 35 + src/components/CcWorkOrderForm.tsx | 365 ++ src/components/ConfirmImportModal.tsx | 92 + src/components/EmptyState.tsx | 25 + src/components/ErrorBanner.tsx | 25 + src/components/ImapPullLogsPanel.tsx | 176 + src/components/ImapPullSettingsCard.tsx | 289 ++ src/components/ImapStatusBanner.tsx | 139 + src/components/MailBusinessSummary.tsx | 843 ++++ src/components/MailInstructionUnitShell.tsx | 40 + src/components/MailTable.tsx | 196 + src/components/MailboxBindHelp.tsx | 83 + src/components/OauthSettingsCard.tsx | 221 + src/components/ShipmentVirtualTable.tsx | 522 +++ src/components/StatusPill.tsx | 18 + src/components/TopNav.tsx | 101 + src/components/TypeOverrideModal.tsx | 69 + src/components/TypeTag.tsx | 16 + src/components/providers/AntdProvider.tsx | 46 + .../providers/AuthSessionProvider.tsx | 113 + src/constants/cc-message-types.ts | 34 + src/constants/cc-ui-mode.ts | 50 + src/constants/mailbox-bind-guides.ts | 207 + src/constants/ui-copy.ts | 157 + src/hooks/useCcUiMode.ts | 51 + src/hooks/useImport.ts | 25 + src/hooks/useMailDetail.ts | 30 + src/hooks/useMails.ts | 67 + src/lib/api.ts | 83 + src/lib/cc-api-base.ts | 85 + src/lib/client-api.ts | 542 +++ src/lib/env.ts | 128 + src/lib/imap-presets.ts | 123 + src/lib/logger.ts | 6 + src/lib/mails-events.ts | 7 + src/lib/rate-limit.ts | 34 + src/lib/resilience-metrics.ts | 75 + src/lib/safe-path.ts | 43 + src/lib/secret-crypto.test.ts | 15 + src/lib/secret-crypto.ts | 40 + src/lib/session.ts | 44 + src/services/audit.ts | 9 + src/services/cc/auth.ts | 239 + src/services/cc/batch-transfer.ts | 80 + src/services/cc/code.ts | 4 + src/services/cc/conflict.ts | 316 ++ src/services/cc/container-status.ts | 169 + src/services/cc/customer-message-entity.ts | 46 + src/services/cc/customer-message.ts | 86 + src/services/cc/do-upload.ts | 422 ++ src/services/cc/forecast-confirm-gate.ts | 31 + src/services/cc/http.ts | 167 + src/services/cc/instruction-write.ts | 93 + src/services/cc/mock-state.ts | 23 + src/services/cc/mode.ts | 64 + src/services/cc/save-container.ts | 212 + src/services/cc/settings-config.ts | 277 ++ src/services/cc/shipping-line.ts | 177 + src/services/cc/shipping-lines.ts | 31 + src/services/cc/work-order-panel.ts | 75 + src/services/cc/write-capability.ts | 85 + src/services/customer/resolve-sender.ts | 97 + src/services/db-lock.ts | 95 + src/services/db.ts | 15 + src/services/imap/client.ts | 206 + src/services/imap/idle-supervisor.ts | 133 + src/services/imap/lookback.test.ts | 13 + src/services/imap/mailbox-config.ts | 435 ++ src/services/imap/poller.ts | 498 +++ src/services/imap/pull-filter.ts | 97 + src/services/imap/pull-log.ts | 53 + src/services/imap/runtime-status.test.ts | 55 + src/services/imap/runtime-status.ts | 91 + src/services/imap/settings-config.ts | 105 + src/services/imap/snapshot.ts | 159 + src/services/imap/stale-reaper.ts | 111 + src/services/import/auto-exec.ts | 163 + src/services/import/compensation.ts | 419 ++ src/services/import/confirm-batch-transfer.ts | 114 + src/services/import/confirm-do-upload.ts | 239 + src/services/import/confirm-work-order.ts | 109 + src/services/import/confirm.ts | 534 +++ src/services/oauth/providers.ts | 299 ++ src/services/oauth/settings-config.ts | 204 + src/services/ocr/index.ts | 156 + src/services/parse/bl-plus-template.ts | 273 ++ src/services/parse/channel-map.ts | 44 + src/services/parse/classify.ts | 335 ++ src/services/parse/do-upload-extract.ts | 49 + src/services/parse/drain-fetched.ts | 34 + .../parse/extract-container-header.ts | 239 + src/services/parse/instruction-lexicon.ts | 694 +++ src/services/parse/label-instruction.ts | 231 + src/services/parse/mail-intent.test.ts | 103 + src/services/parse/mail-intent.ts | 395 ++ src/services/parse/ops-subject-template.ts | 196 + src/services/parse/packing-fill-rules.ts | 121 + src/services/parse/packing-list.ts | 374 ++ src/services/parse/pipeline.ts | 848 ++++ src/services/parse/split-instructions.ts | 734 +++ src/services/parse/thread.ts | 56 + src/services/parse/work-order-record.ts | 511 +++ src/services/parse/zip-extract.ts | 78 + src/services/retention/cleanup.ts | 122 + src/services/sample/mail-attachment-select.ts | 72 + src/services/state-machine.ts | 89 + src/types/cc-ops.ts | 101 + src/types/cc.ts | 56 + src/types/index.ts | 253 ++ src/types/mail.ts | 216 + src/utils/dates.ts | 54 + src/utils/hash.ts | 40 + src/utils/ingest-source.ts | 18 + src/utils/iso6346.ts | 36 + src/utils/mail-body-text.ts | 94 + src/utils/mailContent.ts | 44 + src/worker/index.ts | 138 + tests/e2e/import-flow.spec.ts | 14 + tests/e2e/resilience-auth.spec.ts | 73 + tests/e2e/sample-mails-detail-smoke.spec.ts | 186 + tests/e2e/ux-timing-smoke.spec.ts | 301 ++ tests/fixtures/m2-shipments.ts | 74 + tests/integration/resilience.test.ts | 107 + tests/unit/bl-plus-template.test.ts | 110 + tests/unit/cc-customer-message-entity.test.ts | 28 + tests/unit/cc-forecast-form-order.test.ts | 60 + tests/unit/cc-hold-split-outstock.test.ts | 144 + tests/unit/cc-message-types.test.ts | 12 + tests/unit/cc-mode.test.ts | 85 + tests/unit/cc-response-speed.test.ts | 53 + tests/unit/cc-save.test.ts | 65 + tests/unit/cc-transfer-index-truck.test.ts | 73 + tests/unit/cc-ui-mode-panels.test.ts | 47 + tests/unit/channel-map.test.ts | 30 + tests/unit/classify.test.ts | 155 + tests/unit/do-upload-extract.test.ts | 49 + tests/unit/extract-header.test.ts | 89 + tests/unit/force-import.test.ts | 57 + tests/unit/forecast-confirm-gate.test.ts | 34 + tests/unit/four-business.test.ts | 153 + tests/unit/hash-dates.test.ts | 28 + tests/unit/instruction-product-rules.test.ts | 97 + tests/unit/iso6346.test.ts | 30 + tests/unit/label-instruction.test.ts | 32 + tests/unit/mail-attachment-select.test.ts | 50 + tests/unit/mail-body-text.test.ts | 41 + tests/unit/mail-intent.test.ts | 37 + tests/unit/mail4-time-rounds.test.ts | 208 + tests/unit/no-spreadsheet-outcome.test.ts | 73 + tests/unit/ocr.test.ts | 35 + tests/unit/ops-subject-mail3.test.ts | 87 + tests/unit/packing-fill-rules.test.ts | 87 + tests/unit/packing-list.test.ts | 180 + tests/unit/pull-filter.test.ts | 95 + tests/unit/recognize-gold.test.ts | 72 + tests/unit/resilience-metrics.test.ts | 61 + tests/unit/resilience-scenarios.test.ts | 459 ++ tests/unit/security-hardening.test.ts | 82 + tests/unit/split-instructions.test.ts | 203 + tests/unit/state-machine.test.ts | 24 + tests/unit/thread.test.ts | 49 + tests/unit/transfer-gate-rate-limit.test.ts | 13 + tests/unit/work-order-dedupe.test.ts | 63 + tests/unit/work-order-record.test.ts | 85 + tests/unit/zip-extract.test.ts | 22 + tsconfig.json | 21 + tsconfig.worker.json | 31 + vitest.config.ts | 14 + 326 files changed, 45731 insertions(+) create mode 100644 .cursor/rules/implementation-plan.mdc create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 data/.gitkeep create mode 100644 docker-compose.yml create mode 100644 docs/start-system.cmd create mode 100644 docs/start-system.ps1 create mode 100644 docs/邮箱项目.md create mode 100644 docx/产品UI-UX设计方案-邮件自动预报-v1.0.md create mode 100644 docx/产品规则-邮件指令识别与拆分.md create mode 100644 docx/异常场景与系统韧性设计.md create mode 100644 docx/技术设计方案-邮件自动预报-v1.0.md create mode 100644 docx/接口/carriercentral客户端通用接口V1.72.md create mode 100644 docx/邮件/样例附件清单.md create mode 100644 docx/邮件/模板/WHSU8127240-DO.pdf create mode 100644 docx/邮件/模板/数据模版-新辰泽-WHSU8127240.xlsx create mode 100644 docx/邮件/模板/数据模版.xlsx create mode 100644 docx/邮件/模板/智鸿2+WHLC027G597465+WHSU8127240+洛杉矶+40HQ+EDT2026.04-25 ETA2026.05-15船名航次HMM EMERALD 013E+提拆派.xlsx create mode 100644 docx/邮件/模板/附件下载_邮件识别/智鸿2+WHLC027G597465+WHSU8127240+洛杉矶+40HQ+EDT2026.04-25 ETA2026.05-15船名航次HMM EMERALD 013E+提拆派.xlsx create mode 100644 docx/邮件/模板/附件下载_邮件识别/邮件识别.docx create mode 100644 docx/邮件/模板/附件下载_邮件识别/附件/WHSU8127240-DO.pdf create mode 100644 docx/邮件/模板/附件下载_邮件识别/附件/数据模版.xlsx create mode 100644 docx/邮件/邮件1/QQ邮箱.pdf create mode 100644 docx/邮件/邮件2/2邮箱.pdf create mode 100644 docx/邮件/邮件2/柜号:MATU2745683卡派资料.xlsx create mode 100644 docx/邮件/邮件3/3邮箱.pdf create mode 100644 docx/邮件/邮件3/WL103276-SBD1(1).pdf create mode 100644 docx/邮件/邮件3/WL103944-WL103940.pdf create mode 100644 docx/邮件/邮件3/WL98967-VGT2(1).pdf create mode 100644 docx/邮件/邮件3/WL99583-LAX9.pdf create mode 100644 docx/邮件/邮件4/4邮箱.pdf create mode 100644 docx/邮件/邮件4/FBA19HW52S0L-76CTN-HIA1-覆盖贴1箱贴2张.pdf create mode 100644 docx/邮件/邮件4/X003UHDF7B-304PCS-覆盖贴1箱贴1张.pdf create mode 100644 docx/邮件/邮件4/YT2604021091=76件换标走HIA1卡派出库操作指令.xlsx create mode 100644 docx/金样例/M1.json create mode 100644 docx/金样例/M2.json create mode 100644 docx/金样例/M3.json create mode 100644 docx/金样例/M4.json create mode 100644 docx/金样例/M5.json create mode 100644 docx/金样例/M_BL_TEMPLATE.json create mode 100644 docx/金样例/M_WORK_ORDER.json create mode 100644 docx/需求规格-邮件自动预报-v0.2.md create mode 100644 next-env.d.ts create mode 100644 next.config.ts create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 pnpm-lock.yaml create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed.ts create mode 100644 public/.gitkeep create mode 100644 scripts/analyze-mail-rounds.ts create mode 100644 scripts/apply-demovip-cc.ts create mode 100644 scripts/bind-instruction-segment-ui.ts create mode 100644 scripts/cc-smoke.ts create mode 100644 scripts/check-template-recognize.ts create mode 100644 scripts/clear-imap-lock.ts create mode 100644 scripts/dev-stack.mjs create mode 100644 scripts/docker-entrypoint-web.sh create mode 100644 scripts/extract-mail-recognize-docx.py create mode 100644 scripts/extract-pdf-text.py create mode 100644 scripts/fill-xcz-packing-template.ts create mode 100644 scripts/fix-mail-business-summary-zh.ts create mode 100644 scripts/fix-wo-btn.ts create mode 100644 scripts/flatten-mail-attachments.ts create mode 100644 scripts/imap-poll.ts create mode 100644 scripts/imap-smoke.ts create mode 100644 scripts/ingest-do-upload-ui.ts create mode 100644 scripts/ingest-forecast-ui.ts create mode 100644 scripts/ingest-mail3.ts create mode 100644 scripts/ingest-recognize-gold.ts create mode 100644 scripts/ingest-sample-mails.ts create mode 100644 scripts/lib/load-mailbox-pdf-body.ts create mode 100644 scripts/mark-non-business-ignored.ts create mode 100644 scripts/patch-classify-gold.ts create mode 100644 scripts/patch-classify-packing-narrow.ts create mode 100644 scripts/patch-classify-wo-fallback.ts create mode 100644 scripts/patch-date-shell-rule.ts create mode 100644 scripts/patch-detail-full-body-ui.ts create mode 100644 scripts/patch-do-keep-customer.ts create mode 100644 scripts/patch-forecast-packing-tips-ui.ts create mode 100644 scripts/patch-ingest-full-body.ts create mode 100644 scripts/patch-latest-readonly.ts create mode 100644 scripts/patch-packing-note-tests.ts create mode 100644 scripts/patch-packing-skip-notes.ts create mode 100644 scripts/patch-preserve-body-text.ts create mode 100644 scripts/patch-resolve-fuller-body.ts create mode 100644 scripts/patch-shell-subject-noise.ts create mode 100644 scripts/patch-skip-instruction-rows.ts create mode 100644 scripts/patch-skip-shell-segments.ts create mode 100644 scripts/patch-split-segment-kinds.ts create mode 100644 scripts/patch-strip-conservative.ts create mode 100644 scripts/patch-strip-subject-block.ts create mode 100644 scripts/patch-ui-customer-fallback.ts create mode 100644 scripts/patch-ui-latest-readonly.ts create mode 100644 scripts/patch-wire-packing-fill-rules.ts create mode 100644 scripts/purge-all-mails.ts create mode 100644 scripts/retention-cleanup.ts create mode 100644 scripts/rewrite-mail-business-summary.ts create mode 100644 scripts/scan-mail-samples.ts create mode 100644 scripts/verify-reimport-rules.ts create mode 100644 src/app/(auth)/login/page.tsx create mode 100644 src/app/(ops)/layout.tsx create mode 100644 src/app/(ops)/loading.tsx create mode 100644 src/app/(ops)/logs/page.tsx create mode 100644 src/app/(ops)/mails/[id]/confirm/page.tsx create mode 100644 src/app/(ops)/mails/[id]/page.tsx create mode 100644 src/app/(ops)/mails/page.tsx create mode 100644 src/app/(ops)/settings/page.tsx create mode 100644 src/app/api/admin/imap/poll/route.ts create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/logout/route.ts create mode 100644 src/app/api/auth/me/route.ts create mode 100644 src/app/api/compensations/[id]/retry/route.ts create mode 100644 src/app/api/health/route.ts create mode 100644 src/app/api/imap-pull-logs/route.ts create mode 100644 src/app/api/imports/[importId]/retry/route.ts create mode 100644 src/app/api/imports/route.ts create mode 100644 src/app/api/mails/[id]/attachments/[attId]/download/route.ts create mode 100644 src/app/api/mails/[id]/do/confirm/route.ts create mode 100644 src/app/api/mails/[id]/ignore/route.ts create mode 100644 src/app/api/mails/[id]/import/route.ts create mode 100644 src/app/api/mails/[id]/reparse/route.ts create mode 100644 src/app/api/mails/[id]/route.ts create mode 100644 src/app/api/mails/[id]/transfer/confirm/route.ts create mode 100644 src/app/api/mails/[id]/transfer/gate/route.ts create mode 100644 src/app/api/mails/[id]/type/route.ts create mode 100644 src/app/api/mails/[id]/work-order/confirm/route.ts create mode 100644 src/app/api/mails/route.ts create mode 100644 src/app/api/oauth/callback/[provider]/route.ts create mode 100644 src/app/api/oauth/providers/route.ts create mode 100644 src/app/api/oauth/start/route.ts create mode 100644 src/app/api/settings/cc/route.ts create mode 100644 src/app/api/settings/cc/test/route.ts create mode 100644 src/app/api/settings/imap/filters/[id]/route.ts create mode 100644 src/app/api/settings/imap/filters/route.ts create mode 100644 src/app/api/settings/imap/route.ts create mode 100644 src/app/api/settings/mailboxes/[id]/route.ts create mode 100644 src/app/api/settings/mailboxes/[id]/test/route.ts create mode 100644 src/app/api/settings/mailboxes/probe/route.ts create mode 100644 src/app/api/settings/mailboxes/route.ts create mode 100644 src/app/api/settings/oauth/route.ts create mode 100644 src/app/api/shipping-lines/route.ts create mode 100644 src/app/error.tsx create mode 100644 src/app/globals.css create mode 100644 src/app/layout.tsx create mode 100644 src/app/page.tsx create mode 100644 src/components/AuthGuard.tsx create mode 100644 src/components/CcCustomerInstructionPanel.tsx create mode 100644 src/components/CcDoUploadPanel.tsx create mode 100644 src/components/CcForecastFormOrder.tsx create mode 100644 src/components/CcHoldMoveTypePanel.tsx create mode 100644 src/components/CcOutStockPanel.tsx create mode 100644 src/components/CcSettingsCard.tsx create mode 100644 src/components/CcSplitForm.tsx create mode 100644 src/components/CcTransferIndexTruck.tsx create mode 100644 src/components/CcUiModeSwitch.tsx create mode 100644 src/components/CcWorkOrderForm.tsx create mode 100644 src/components/ConfirmImportModal.tsx create mode 100644 src/components/EmptyState.tsx create mode 100644 src/components/ErrorBanner.tsx create mode 100644 src/components/ImapPullLogsPanel.tsx create mode 100644 src/components/ImapPullSettingsCard.tsx create mode 100644 src/components/ImapStatusBanner.tsx create mode 100644 src/components/MailBusinessSummary.tsx create mode 100644 src/components/MailInstructionUnitShell.tsx create mode 100644 src/components/MailTable.tsx create mode 100644 src/components/MailboxBindHelp.tsx create mode 100644 src/components/OauthSettingsCard.tsx create mode 100644 src/components/ShipmentVirtualTable.tsx create mode 100644 src/components/StatusPill.tsx create mode 100644 src/components/TopNav.tsx create mode 100644 src/components/TypeOverrideModal.tsx create mode 100644 src/components/TypeTag.tsx create mode 100644 src/components/providers/AntdProvider.tsx create mode 100644 src/components/providers/AuthSessionProvider.tsx create mode 100644 src/constants/cc-message-types.ts create mode 100644 src/constants/cc-ui-mode.ts create mode 100644 src/constants/mailbox-bind-guides.ts create mode 100644 src/constants/ui-copy.ts create mode 100644 src/hooks/useCcUiMode.ts create mode 100644 src/hooks/useImport.ts create mode 100644 src/hooks/useMailDetail.ts create mode 100644 src/hooks/useMails.ts create mode 100644 src/lib/api.ts create mode 100644 src/lib/cc-api-base.ts create mode 100644 src/lib/client-api.ts create mode 100644 src/lib/env.ts create mode 100644 src/lib/imap-presets.ts create mode 100644 src/lib/logger.ts create mode 100644 src/lib/mails-events.ts create mode 100644 src/lib/rate-limit.ts create mode 100644 src/lib/resilience-metrics.ts create mode 100644 src/lib/safe-path.ts create mode 100644 src/lib/secret-crypto.test.ts create mode 100644 src/lib/secret-crypto.ts create mode 100644 src/lib/session.ts create mode 100644 src/services/audit.ts create mode 100644 src/services/cc/auth.ts create mode 100644 src/services/cc/batch-transfer.ts create mode 100644 src/services/cc/code.ts create mode 100644 src/services/cc/conflict.ts create mode 100644 src/services/cc/container-status.ts create mode 100644 src/services/cc/customer-message-entity.ts create mode 100644 src/services/cc/customer-message.ts create mode 100644 src/services/cc/do-upload.ts create mode 100644 src/services/cc/forecast-confirm-gate.ts create mode 100644 src/services/cc/http.ts create mode 100644 src/services/cc/instruction-write.ts create mode 100644 src/services/cc/mock-state.ts create mode 100644 src/services/cc/mode.ts create mode 100644 src/services/cc/save-container.ts create mode 100644 src/services/cc/settings-config.ts create mode 100644 src/services/cc/shipping-line.ts create mode 100644 src/services/cc/shipping-lines.ts create mode 100644 src/services/cc/work-order-panel.ts create mode 100644 src/services/cc/write-capability.ts create mode 100644 src/services/customer/resolve-sender.ts create mode 100644 src/services/db-lock.ts create mode 100644 src/services/db.ts create mode 100644 src/services/imap/client.ts create mode 100644 src/services/imap/idle-supervisor.ts create mode 100644 src/services/imap/lookback.test.ts create mode 100644 src/services/imap/mailbox-config.ts create mode 100644 src/services/imap/poller.ts create mode 100644 src/services/imap/pull-filter.ts create mode 100644 src/services/imap/pull-log.ts create mode 100644 src/services/imap/runtime-status.test.ts create mode 100644 src/services/imap/runtime-status.ts create mode 100644 src/services/imap/settings-config.ts create mode 100644 src/services/imap/snapshot.ts create mode 100644 src/services/imap/stale-reaper.ts create mode 100644 src/services/import/auto-exec.ts create mode 100644 src/services/import/compensation.ts create mode 100644 src/services/import/confirm-batch-transfer.ts create mode 100644 src/services/import/confirm-do-upload.ts create mode 100644 src/services/import/confirm-work-order.ts create mode 100644 src/services/import/confirm.ts create mode 100644 src/services/oauth/providers.ts create mode 100644 src/services/oauth/settings-config.ts create mode 100644 src/services/ocr/index.ts create mode 100644 src/services/parse/bl-plus-template.ts create mode 100644 src/services/parse/channel-map.ts create mode 100644 src/services/parse/classify.ts create mode 100644 src/services/parse/do-upload-extract.ts create mode 100644 src/services/parse/drain-fetched.ts create mode 100644 src/services/parse/extract-container-header.ts create mode 100644 src/services/parse/instruction-lexicon.ts create mode 100644 src/services/parse/label-instruction.ts create mode 100644 src/services/parse/mail-intent.test.ts create mode 100644 src/services/parse/mail-intent.ts create mode 100644 src/services/parse/ops-subject-template.ts create mode 100644 src/services/parse/packing-fill-rules.ts create mode 100644 src/services/parse/packing-list.ts create mode 100644 src/services/parse/pipeline.ts create mode 100644 src/services/parse/split-instructions.ts create mode 100644 src/services/parse/thread.ts create mode 100644 src/services/parse/work-order-record.ts create mode 100644 src/services/parse/zip-extract.ts create mode 100644 src/services/retention/cleanup.ts create mode 100644 src/services/sample/mail-attachment-select.ts create mode 100644 src/services/state-machine.ts create mode 100644 src/types/cc-ops.ts create mode 100644 src/types/cc.ts create mode 100644 src/types/index.ts create mode 100644 src/types/mail.ts create mode 100644 src/utils/dates.ts create mode 100644 src/utils/hash.ts create mode 100644 src/utils/ingest-source.ts create mode 100644 src/utils/iso6346.ts create mode 100644 src/utils/mail-body-text.ts create mode 100644 src/utils/mailContent.ts create mode 100644 src/worker/index.ts create mode 100644 tests/e2e/import-flow.spec.ts create mode 100644 tests/e2e/resilience-auth.spec.ts create mode 100644 tests/e2e/sample-mails-detail-smoke.spec.ts create mode 100644 tests/e2e/ux-timing-smoke.spec.ts create mode 100644 tests/fixtures/m2-shipments.ts create mode 100644 tests/integration/resilience.test.ts create mode 100644 tests/unit/bl-plus-template.test.ts create mode 100644 tests/unit/cc-customer-message-entity.test.ts create mode 100644 tests/unit/cc-forecast-form-order.test.ts create mode 100644 tests/unit/cc-hold-split-outstock.test.ts create mode 100644 tests/unit/cc-message-types.test.ts create mode 100644 tests/unit/cc-mode.test.ts create mode 100644 tests/unit/cc-response-speed.test.ts create mode 100644 tests/unit/cc-save.test.ts create mode 100644 tests/unit/cc-transfer-index-truck.test.ts create mode 100644 tests/unit/cc-ui-mode-panels.test.ts create mode 100644 tests/unit/channel-map.test.ts create mode 100644 tests/unit/classify.test.ts create mode 100644 tests/unit/do-upload-extract.test.ts create mode 100644 tests/unit/extract-header.test.ts create mode 100644 tests/unit/force-import.test.ts create mode 100644 tests/unit/forecast-confirm-gate.test.ts create mode 100644 tests/unit/four-business.test.ts create mode 100644 tests/unit/hash-dates.test.ts create mode 100644 tests/unit/instruction-product-rules.test.ts create mode 100644 tests/unit/iso6346.test.ts create mode 100644 tests/unit/label-instruction.test.ts create mode 100644 tests/unit/mail-attachment-select.test.ts create mode 100644 tests/unit/mail-body-text.test.ts create mode 100644 tests/unit/mail-intent.test.ts create mode 100644 tests/unit/mail4-time-rounds.test.ts create mode 100644 tests/unit/no-spreadsheet-outcome.test.ts create mode 100644 tests/unit/ocr.test.ts create mode 100644 tests/unit/ops-subject-mail3.test.ts create mode 100644 tests/unit/packing-fill-rules.test.ts create mode 100644 tests/unit/packing-list.test.ts create mode 100644 tests/unit/pull-filter.test.ts create mode 100644 tests/unit/recognize-gold.test.ts create mode 100644 tests/unit/resilience-metrics.test.ts create mode 100644 tests/unit/resilience-scenarios.test.ts create mode 100644 tests/unit/security-hardening.test.ts create mode 100644 tests/unit/split-instructions.test.ts create mode 100644 tests/unit/state-machine.test.ts create mode 100644 tests/unit/thread.test.ts create mode 100644 tests/unit/transfer-gate-rate-limit.test.ts create mode 100644 tests/unit/work-order-dedupe.test.ts create mode 100644 tests/unit/work-order-record.test.ts create mode 100644 tests/unit/zip-extract.test.ts create mode 100644 tsconfig.json create mode 100644 tsconfig.worker.json create mode 100644 vitest.config.ts diff --git a/.cursor/rules/implementation-plan.mdc b/.cursor/rules/implementation-plan.mdc new file mode 100644 index 0000000..99f02d3 --- /dev/null +++ b/.cursor/rules/implementation-plan.mdc @@ -0,0 +1,64 @@ +# 邮件自动预报 — 实施计划 + +## 当前步骤 +MailBusinessSummary forecastShippingId TDZ — Done + +## 进度日志 +- forecastShippingId useState 前移到 forecastValue useMemo 之前 — Done +- CC 客户端接口 PDF→MD 全文转换 — Done +- extractForecastInstructionText + stripMobileMailChrome 去发件人/大小/iPhone 元数据 — Done +- 跑 `rewrite-mail-business-summary.ts` 以 Unicode 重写 UTF-8 文案;详情页中文恢复 — Done +- 样例邮件附件平铺 + ingest 寻址统一 — Done(DB 停机时需再跑 pnpm sample:mails) +- 一封邮件多指令拆分:`extractMailInstructions` 收敛为 forecast/work_order/transfer/do_upload — Done +- 留仓/拆分/出库 UI:删除 `CcContainerOpsPanel`;新增 FormMoveType 留仓、Split 拆分、客户端出库指令三面板;意图分流 hold/split/outbound — Done +- 邮件3/4 子目录附件平铺到邮件根;`样例附件清单.md`;ingest attachPolicy — Done +- 拆柜/派送/转仓仿制 UI 样式向 ccnew Learun 靠齐(边框/按钮/表头/密度)— Done +- `CcTransferIndexTruck` 仿制 IndexTruck 查询区+合计+货件表+FormFBACode;TRANSFER/工单转仓详情展示 — Done +- 邮件2(MATU2745683)四段主题解析客户 LINK EVER INC;样例 #76 327 行 — Done +- 转仓 UI 仅展示备注「转XXX」行(邮件2=2 行),全柜清单作范围提示 — Done +- 按填写规范生成 `数据模版-新辰泽-WHSU8127240.xlsx`;多行表头/空仓库ID;#79 待确认 23 行 — Done +- 货件工具条仅保留查询 — Done +- 主题 ellipsis;客户剥 Fw/转发/新增预报N;船司按提单前缀匹配 CC 列表;NO_SHIPMENT 中文说明 — Done +- Learun 蓝条箭头 Steps + 左右标签 + 货件工具条外观 + 绿完成;#79/#74 验收 — Done +- `CcForecastFormOrder` 详情只读+确认页可编辑;预报样例 #79 / 工单 #74 目视验收 — Done +- `pnpm sample:mails` 入库 M1–M4;详情页 Playwright 无 antd Descriptions/Table 告警 — Done +- Table rowKey 改预注入 `__rowKey`,不再用 `(row, index)` — Done +- `pnpm sample:mail3` 入库;主题有柜号时允许无正文/附件 — Done +- 邮件3:`ops-subject-template` 抽柜号/提单/船名/ETA/渠道件数;WORK_ORDER 主题短路 — Done +- bl-plus 提单前缀船司优先;提拆派/车架/海运默认;样例单测 — Done +- confirm 两步审查 UI + CC 货件列名;TransMode/OperationType 文案对齐 ccnew — Done + +## 进度日志 +- 主题 ellipsis;客户剥 Fw/转发/新增预报N;船司按提单前缀匹配 CC 列表;NO_SHIPMENT 中文说明 — Done +- Learun 蓝条箭头 Steps + 左右标签 + 货件工具条外观 + 绿完成;#79/#74 验收 — Done +- `CcForecastFormOrder` 详情只读+确认页可编辑;预报样例 #79 / 工单 #74 目视验收 — Done +- `pnpm sample:mails` 入库 M1–M4;详情页 Playwright 无 antd Descriptions/Table 告警 — Done +- Table rowKey 改预注入 `__rowKey`,不再用 `(row, index)` — Done +- `pnpm sample:mail3` 入库;主题有柜号时允许无正文/附件 — Done +- 邮件3:`ops-subject-template` 抽柜号/提单/船名/ETA/渠道件数;WORK_ORDER 主题短路 — Done +- bl-plus 提单前缀船司优先;提拆派/车架/海运默认;样例单测 — Done +- confirm 两步审查 UI + CC 货件列名;TransMode/OperationType 文案对齐 ccnew — Done + +## 路线(顺序执行) +1–25.(既有)— Done +26. 运营按钮 + 确认门禁 + env 统一 + stale→FETCHED — Done +27. 邮件标准记录模板(无导入扩展)— Done +28. 对照 ccnew 对接面(SaveContainer)— Done +29. CC FormOrder 字段对齐审查页 — Done +30. 邮件3主题工单解析(附件/正文后置)— Done +31. CC FormOrder UI 复刻(详情/确认共用)— Done +32. FormOrder 截图视觉对齐(chevron/工具条)— Done + +## 已确认约束 +- 邮件来源仅 IMAP;无开发种子进库;无操作审计页/`audit_log` +- IMAP lookback 默认 3 天(已读+未读);间隔默认 30min(设置页优先) +- 改类型开关:服务端 `ENABLE_TYPE_OVERRIDE`,前端经 `/api/health.enable_type_override` +- PARSING 卡死回收 → FETCHED,由 worker `drainFetchedMails` 再入队 +- OCR/指令 live 仍后置 +- 工单/转仓只记 `mail_record`,不自动写 CC;导入后置 +- 确认页按 FormOrder 字段对齐(非像素复刻);提拆派→业务类型0;直送仅为类型说明 +- 邮件3类「拆柜清单更新」:仅主题;正文/卡转海附件后置 + +## 设计文档 +- 修订摘要:docx/现行口径-修订说明-v1.md +- PRD / 技术 / UI / 韧性 / SOP:已回写现行口径 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..68da880 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules +.next +out +dist +data +.env +.env*.local +!.env.example +*.log +.DS_Store +coverage +playwright-report +test-results diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d70e143 --- /dev/null +++ b/.env.example @@ -0,0 +1,77 @@ +DATABASE_URL=mysql://app:app@localhost:7023/email_forecast +POLL_INTERVAL_MS=1800000 +# Worker 优先读设置页 imap_settings(默认 30min);上值仅作回退 +DATA_RETENTION_DAYS=30 +ENABLE_TYPE_OVERRIDE=true +ENABLE_FORCE_IMPORT=false +# Admin 强制跳过冲突:设 true 后确认页冲突 Modal 出现「强制跳过并导入」 +ENABLE_ISO_CHECK=true +# CC +# 推荐:Admin「设置 → CarrierCentral」配置(入库优先于下方 .env) +# 无账号保持 mock;有账号后关 mock 并填用户名密码,或 pnpm cc:smoke -- --mode=live +# V1.72 文档 demovip 联调示例: +# CC_MOCK=false +# CC_API_BASE=https://demovip.saas.carriercentral.vip/api +# CC_USERNAME=fj CC_PASSWORD_PLAIN=(见接口文档测试客户密码) +# 然后:pnpm exec tsx scripts/apply-demovip-cc.ts +# 一期 PRD 默认仍是 test.saas + Saas:TEST(有正式 TEST 账号时改回) +CC_MOCK=true + +IMAP_HOST=imap.qq.com +IMAP_PORT=993 +# 可选回退;生产请用 Admin「设置 → 邮箱绑定」写入 DB(AES 加密) +IMAP_USER= +IMAP_PASS= + +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= +# CC_PASSWORD_MD5= + +# 生产必须替换默认值;NODE_ENV=production 时弱口令/默认 SESSION_SECRET 会拒绝启动 +APP_ADMIN_USER=admin +APP_ADMIN_PASS=admin123 +APP_OPS_USER=ops +APP_OPS_PASS=ops123 + +# 至少 32 字符;生产勿使用下列占位 +SESSION_SECRET=change-me-to-a-long-random-string-at-least-32-chars +CC_HTTP_TIMEOUT_MS=60000 +# 查柜 / 船司等读接口;过长会卡住页面 +CC_READ_TIMEOUT_MS=8000 +IMAP_CONNECT_TIMEOUT_MS=30000 +IMAP_READ_TIMEOUT_MS=60000 +# 同步近 N 天(已读+未读),默认 3;每轮每箱最多拉 N 封新信 +IMAP_LOOKBACK_DAYS=3 +IMAP_MAX_FETCH_PER_TICK=100 +IMAP_IDLE_ENABLED=true + +# OCR:off | local | aliyun +OCR_PROVIDER=local +OCR_MIN_CONFIDENCE=40 +OCR_ALIYUN_ENDPOINT= +OCR_ALIYUN_ACCESS_KEY= +OCR_ALIYUN_SECRET_KEY= + +# OAuth(Gmail / Microsoft);未配置时设置页按钮禁用,仍可用授权码 +OAUTH_PUBLIC_BASE_URL=http://localhost:3100 +OAUTH_GOOGLE_CLIENT_ID= +OAUTH_GOOGLE_CLIENT_SECRET= +OAUTH_MS_CLIENT_ID= +OAUTH_MS_CLIENT_SECRET= +OAUTH_MS_TENANT=common + +# 自动执行(TRANSFER / 留仓拆分 / 贴标);须 cc_write_capability live 才算 TEST 完成 +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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..26456a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# deps / build +node_modules +.next +out +dist +coverage +.turbo +*.tsbuildinfo + +# local secrets & runtime +.env +.env*.local +!.env.example +data/* +!data/.gitkeep + +# local test records / smoke reports +*.log +data/logs/ +data/mails/ +data/imap-runtime.json +playwright-report +test-results +.playwright-mcp +node_modules/.vite + +# editor / OS / local AI scratch +.DS_Store +.idea +.vscode/* +!.vscode/extensions.json +.mypy_cache +.cursor/plans +agent-transcripts +*.tmp +_tmp* +scripts/_dump-*.ts +scripts/_check-*.ts +scripts/debug-*.ts +docx/_excel_summary.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..aff844f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM node:20-bookworm AS deps +WORKDIR /app +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate +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 +COPY --from=deps /app/node_modules ./node_modules +COPY . . +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 +COPY --from=builder /app/package.json ./ +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/public ./public +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/dist ./dist +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 +CMD ["pnpm", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..124df28 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# 邮件自动预报系统 + +Next.js 15 + Prisma + MySQL 8 + Ant Design 5 + Worker(IMAP 轮询)。 + +## 快速开始 + +```bash +# Windows 一键(推荐 local,不拉 node 镜像) +# .\docs\start-system.ps1 +# 或 docs\start-system.cmd +# 全量 compose(需 Docker Hub):.\docs\start-system.ps1 -Mode compose + +# 1. 依赖 +pnpm install + +# 2. 环境 +cp .env.example .env +# 本地默认 MySQL 映射 7023(避免与宿主机 3306 冲突) +# DATABASE_URL=mysql://app:app@localhost:7023/email_forecast +# CC_MOCK=true # 无 CC 账号时开 mock + +# 3. 数据库 +docker compose up -d mysql +pnpm exec prisma db push +pnpm db:seed # 仅账号 admin/ops + +# 4. Web + Worker(本地联调推荐;Worker 与系统一起起,才有自动拉取) +pnpm dev:stack +# 等价:pnpm dev 与 pnpm worker 同开 +# http://localhost:3100 账号 admin/admin123 或 ops/ops123 +# Windows 一键(mysql + web + worker):.\docs\start-system.ps1 + +# 4b. 仅 Web(不拉 IMAP) +pnpm dev + +# 4c. 生产形态三件套(mysql + web + worker) +# 需已有 .env;首次会 db push + seed 账号(SEED_ON_START=true) +pnpm compose:up +pnpm compose:ps # 确认 web/worker/mysql 均为 running +# pnpm compose:logs +# 本机已用 pnpm dev 时,可只补 worker 容器:pnpm compose:up:core + +# 5. IMAP 邮箱绑定(推荐:Web 设置页,无需改代码) +# Admin 登录 → 设置 → 新增绑定(填对方邮箱 + QQ 授权码)→ 测试连接 +# 兼容回退:仍可在 .env 填 IMAP_USER / IMAP_PASS +pnpm imap:smoke # 仅连通(.env 回退) +pnpm imap:poll # 拉取一轮(优先 DB 绑定) +pnpm worker # 常驻轮询 +# Admin 顶栏也可点「立即拉取」 +``` + +## 架构 + +| 进程 | 职责 | +|---|---| +| `web` | UI + `/api/*` + ConfirmImport / SaveContainer + **设置/邮箱绑定** | +| `worker` | 仅 IMAP 拉取(读 `mailbox_account`)+ ParsePipeline + 超时回收 + 补偿 due + retention;**禁止**调 SaveContainer | +| `mysql` | 业务库 `email_forecast` | + +> 本地只起 `pnpm dev` + mysql 时 **不会**自动拉信/回收。联调请用 `pnpm dev:stack`、`.\docs\start-system.ps1`,或 `docker compose up` 三件套。 + +## 邮件来源 + +列表数据仅来自 **IMAP 拉取**(设置页绑定邮箱)。开发种子 / 样例导入 / 旧 Demo 应用已移除。 + +## 路由 + +- `/login` `/mails` `/mails/[id]` `/mails/[id]/confirm` `/logs`(导入日志 / 拉取记录) `/settings`(Admin) + +## 测试 + +```bash +pnpm test # vitest 单元/集成 +pnpm test:e2e # playwright(需先起 dev) + +# CC +pnpm cc:smoke # 跟随 .env(默认 mock 假成功,非 TEST 验收) +pnpm cc:smoke -- --mode=mock # 强制 mock +pnpm cc:smoke -- --mode=live # TEST 真烟雾:需 CC_MOCK=false + CC_USERNAME + 密码 +pnpm retention:cleanup # 按 DATA_RETENTION_DAYS 清理(可加 --dry-run) +pnpm retention:cleanup -- --dry-run +``` + +### CC mock vs live(一期验收) + +| 模式 | 条件 | 含义 | +|---|---|---| +| **mock** | 设置页 Mock 开,或 `.env CC_MOCK=true` | SaveContainer/冲突/船司走内存假数据;**不算** PRD TEST 烟雾 | +| **live** | 设置页关 Mock + 用户名/密码(或 `.env`) | 真打 `test.saas...`:`customerLogin` → 冲突/船司/`SaveContainer` | + +配置入口:**设置 → CarrierCentral(CC)连接**(Admin)。DB 配置优先于 `.env`,保存后立即生效。 + +PDF《carriercentral 客户端通用接口》只定义路径与字段;**不能替代租户账号**。无 `customerLogin` token 无法 SaveContainer。 + +顶栏 / `GET /api/health` 会暴露 `cc_mode` / `cc_live_ready`,避免误把 mock 当验收。 + +## 文档 + +- **协作入口(给接手同事)**:`docs/邮箱项目.md` +- PRD:`docx/需求规格-邮件自动预报-v0.2.md` +- 技术设计:`docx/技术设计方案-邮件自动预报-v1.0.md` +- UI/UX:`docx/产品UI-UX设计方案-邮件自动预报-v1.0.md` +- 韧性:`docx/异常场景与系统韧性设计.md` +- 运营 SOP:`docx/运营SOP-邮件自动预报.md` diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/.gitkeep @@ -0,0 +1 @@ + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..022e0de --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,67 @@ +services: + mysql: + image: mysql:8.0 + environment: + MYSQL_DATABASE: email_forecast + MYSQL_USER: app + MYSQL_PASSWORD: app + MYSQL_ROOT_PASSWORD: root + ports: + - "7023:3306" + volumes: + - mysql_data:/var/lib/mysql + command: + - --default-authentication-plugin=mysql_native_password + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uapp", "-papp"] + interval: 5s + timeout: 5s + retries: 20 + + web: + build: . + command: ["sh", "/app/scripts/docker-entrypoint-web.sh"] + ports: + - "3100:3100" + env_file: + - .env + environment: + DATABASE_URL: mysql://app:app@mysql:3306/email_forecast + SEED_ON_START: ${SEED_ON_START:-true} + volumes: + - ./data:/app/data + depends_on: + mysql: + condition: service_healthy + 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: 10s + timeout: 5s + retries: 12 + start_period: 40s + + worker: + build: . + # tsx 跑源码,避免 dist 中 @/ 别名未重写导致 MODULE_NOT_FOUND + command: ["pnpm", "exec", "tsx", "src/worker/index.ts"] + env_file: + - .env + environment: + DATABASE_URL: mysql://app:app@mysql:3306/email_forecast + volumes: + - ./data:/app/data + depends_on: + mysql: + condition: service_healthy + restart: unless-stopped + +volumes: + mysql_data: diff --git a/docs/start-system.cmd b/docs/start-system.cmd new file mode 100644 index 0000000..ea4d933 --- /dev/null +++ b/docs/start-system.cmd @@ -0,0 +1,7 @@ +@echo off +REM One-click start (default: local = mysql docker + host pnpm) +REM Avoids Docker Hub pull of node images (often blocked). +REM Full compose: start-system.cmd -Mode compose +cd /d "%~dp0.." +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0start-system.ps1" %* +if errorlevel 1 pause diff --git a/docs/start-system.ps1 b/docs/start-system.ps1 new file mode 100644 index 0000000..f65987b --- /dev/null +++ b/docs/start-system.ps1 @@ -0,0 +1,378 @@ +# email-forecast one-click start +# Stop old services / free ports, then start stack. +# +# Default: local (mysql container + host pnpm dev/worker) +# - avoids rebuilding web/worker images (Docker Hub often blocked in CN) +# Compose full stack: +# .\docs\start-system.ps1 -Mode compose +# +# NOTE: Keep this file ASCII-only (Windows PowerShell 5.1 + UTF-8 without BOM). + +[CmdletBinding()] +param( + [ValidateSet("compose", "local")] + [string]$Mode = "local", + + [switch]$NoBuild, + + [switch]$NoFallback, + + [int]$WebPort = 3100, + + [int]$MysqlPort = 7023, + + [int]$HealthTimeoutSec = 120 +) + +$ErrorActionPreference = "Stop" + +function Get-RepoRoot { + $here = $PSScriptRoot + if (-not $here) { + $here = Split-Path -Parent $MyInvocation.MyCommand.Path + } + $root = Resolve-Path (Join-Path $here "..") + if (-not (Test-Path (Join-Path $root "package.json"))) { + throw "Repo root not found (missing package.json). Keep this script under docs/." + } + return $root.Path +} + +function Write-Step([string]$msg) { + Write-Host "" + Write-Host "==> $msg" -ForegroundColor Cyan +} + +function Stop-PortListeners([int]$Port) { + try { + $conns = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue + } catch { + $conns = $null + } + if (-not $conns) { + Write-Host " port $Port is free" + return + } + $pids = $conns | Select-Object -ExpandProperty OwningProcess -Unique + foreach ($procId in $pids) { + if (-not $procId -or $procId -eq 0) { continue } + try { + $p = Get-Process -Id $procId -ErrorAction SilentlyContinue + $name = if ($p) { $p.ProcessName } else { "?" } + Write-Host " kill PID=$procId ($name) on port $Port" + Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue + } catch { + Write-Host " cannot kill PID=$procId : $($_.Exception.Message)" -ForegroundColor Yellow + } + } + Start-Sleep -Seconds 1 +} + +function Stop-NodeDevProcesses { + $markers = @( + "next dev", + "next start", + "src/worker/index.ts", + "src\worker\index.ts", + "email-forecast" + ) + Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + $cmd = $_.CommandLine + if (-not $cmd) { return $false } + if ($cmd -notmatch "node|tsx|pnpm") { return $false } + foreach ($m in $markers) { + if ($cmd -like "*$m*") { return $true } + } + return $false + } | + ForEach-Object { + Write-Host " kill node-related PID=$($_.ProcessId)" + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + } +} + +function Ensure-DotEnv([string]$RepoRoot) { + $envFile = Join-Path $RepoRoot ".env" + $example = Join-Path $RepoRoot ".env.example" + if (-not (Test-Path $envFile)) { + if (Test-Path $example) { + Copy-Item $example $envFile + Write-Host " created .env from .env.example" + } else { + Write-Host " WARN: missing .env" -ForegroundColor Yellow + } + } +} + +function Wait-HttpOk([string]$Url, [int]$TimeoutSec) { + $deadline = (Get-Date).AddSeconds($TimeoutSec) + while ((Get-Date) -lt $deadline) { + try { + $resp = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 5 + if ($resp.StatusCode -ge 200 -and $resp.StatusCode -lt 300) { + return $true + } + } catch { + Start-Sleep -Seconds 2 + } + } + return $false +} + +function Wait-MysqlReady([int]$MysqlPort, [int]$TimeoutSec = 120) { + Write-Step "wait MySQL healthy on port $MysqlPort" + $deadline = (Get-Date).AddSeconds($TimeoutSec) + $portReady = $false + $healthReady = $false + + while ((Get-Date) -lt $deadline) { + if (-not $portReady) { + $listening = Get-NetTCPConnection -LocalPort $MysqlPort -State Listen -ErrorAction SilentlyContinue + if ($listening) { + $portReady = $true + Write-Host " port $MysqlPort listening" + } + } + + # Prefer compose healthcheck (accepts connections + auth), not just TCP + $status = "" + try { + $cid = (& docker compose ps -q mysql 2>$null | Select-Object -First 1) + if ($cid) { + $status = (& docker inspect -f "{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}" $cid 2>$null) + if ($status) { $status = $status.Trim() } + } + } catch { + $status = "" + } + + if ($status -eq "healthy" -or $status -eq "running") { + # "running" without health still needs ping; healthy is enough + if ($status -eq "healthy") { + $healthReady = $true + Write-Host " mysql health=healthy" + break + } + } + + # Fallback: mysqladmin inside container (stderr warning must not stop script) + $prevEap = $ErrorActionPreference + $ErrorActionPreference = "Continue" + $null = & docker compose exec -T mysql mysqladmin ping -h 127.0.0.1 -uapp -papp --silent 2>$null + $pingOk = ($LASTEXITCODE -eq 0) + $ErrorActionPreference = $prevEap + if ($pingOk) { + $healthReady = $true + Write-Host " mysqladmin ping ok" + break + } + + Start-Sleep -Seconds 2 + } + + if (-not $portReady) { + throw "MySQL port $MysqlPort not listening" + } + if (-not $healthReady) { + throw "MySQL not healthy within ${TimeoutSec}s (port up but server not ready — prisma P1017)" + } + + # Brief settle: fresh container may still drop first connections + Start-Sleep -Seconds 2 +} + +function Invoke-PrismaDbPushRetry([int]$MaxAttempts = 8) { + Write-Step "prisma db push + seed" + $ok = $false + for ($i = 1; $i -le $MaxAttempts; $i++) { + pnpm exec prisma db push --skip-generate + if ($LASTEXITCODE -eq 0) { + $ok = $true + break + } + Write-Host " prisma db push attempt $i/$MaxAttempts failed (exit=$LASTEXITCODE), retry in 3s..." -ForegroundColor Yellow + Start-Sleep -Seconds 3 + } + if (-not $ok) { throw "prisma db push failed after $MaxAttempts attempts" } + pnpm db:seed + if ($LASTEXITCODE -ne 0) { throw "db:seed failed" } +} + +function Start-LocalStack { + param( + [string]$RepoRoot, + [int]$WebPort, + [int]$MysqlPort, + [int]$HealthTimeoutSec + ) + + Write-Step "start mysql container only (no web/worker image build)" + $prevEap = $ErrorActionPreference + $ErrorActionPreference = "Continue" + & docker compose up -d mysql 2>&1 | ForEach-Object { Write-Host $_ } + $upExit = $LASTEXITCODE + $ErrorActionPreference = $prevEap + if ($upExit -ne 0) { + throw @" +mysql start failed (exit=$upExit). +If pull mysql:8.0 also fails, Docker Hub is unreachable. +Fix: configure registry mirror in Docker Desktop, or use a VPN, then: + docker pull mysql:8.0 +"@ + } + + Wait-MysqlReady -MysqlPort $MysqlPort -TimeoutSec 120 + + $env:DATABASE_URL = "mysql://app:app@localhost:$MysqlPort/email_forecast" + + Invoke-PrismaDbPushRetry -MaxAttempts 8 + + $logDir = Join-Path $RepoRoot "data\logs" + New-Item -ItemType Directory -Force -Path $logDir | Out-Null + # Start-Process forbids the same path for stdout and stderr + $webOut = Join-Path $logDir "web-dev.out.log" + $webErr = Join-Path $logDir "web-dev.err.log" + $workerOut = Join-Path $logDir "worker.out.log" + $workerErr = Join-Path $logDir "worker.err.log" + + Write-Step "start pnpm dev:web / pnpm worker (host Node, no Docker Hub)" + foreach ($f in @($webOut, $webErr, $workerOut, $workerErr)) { + if (Test-Path $f) { Remove-Item $f -Force -ErrorAction SilentlyContinue } + } + + # Turbopack cache mixed with webpack causes /_next/_next/static/chunks 404 + ChunkLoadError + $nextDir = Join-Path $RepoRoot ".next" + $turboMarker = Join-Path $nextDir "static\chunks" + if ((Test-Path $nextDir) -and (Get-ChildItem $turboMarker -Filter "turbopack-*" -ErrorAction SilentlyContinue)) { + Remove-Item $nextDir -Recurse -Force -ErrorAction SilentlyContinue + Write-Host " cleared stale Turbopack .next cache" + } + + # Prefer cmd.exe wrapper: pnpm is often a .CMD shim; Start-Process + redirect is flaky on shims + $devArgs = "/c pnpm run dev:web > `"$webOut`" 2> `"$webErr`"" + $workerArgs = "/c pnpm worker > `"$workerOut`" 2> `"$workerErr`"" + Start-Process -FilePath "cmd.exe" -ArgumentList $devArgs -WorkingDirectory $RepoRoot -WindowStyle Hidden + Start-Process -FilePath "cmd.exe" -ArgumentList $workerArgs -WorkingDirectory $RepoRoot -WindowStyle Hidden + + Write-Step "wait health http://127.0.0.1:$WebPort/api/health" + $ok = Wait-HttpOk -Url "http://127.0.0.1:$WebPort/api/health" -TimeoutSec $HealthTimeoutSec + if (-not $ok) { + Write-Host "health check timeout. logs:" -ForegroundColor Yellow + Write-Host " $webOut / $webErr" + Write-Host " $workerOut / $workerErr" + exit 2 + } + + # Dev 按需编译:登录后再预热运营页,否则 /mails 会落到登录页、切模块仍要编译 10s + Write-Step "warmup Next.js routes (first compile)" + $webSession = $null + $adminUser = if ($env:APP_ADMIN_USER) { $env:APP_ADMIN_USER } else { "admin" } + $adminPass = if ($env:APP_ADMIN_PASS) { $env:APP_ADMIN_PASS } else { "admin123" } + try { + $loginJson = (@{ username = $adminUser; password = $adminPass } | ConvertTo-Json -Compress) + $null = Invoke-WebRequest -Uri "http://127.0.0.1:$WebPort/api/auth/login" -Method POST -Body $loginJson -ContentType "application/json" -UseBasicParsing -TimeoutSec 60 -SessionVariable webSession + Write-Host " warmed login session" + } catch { + Write-Host " login warmup skipped (pages still compile on first click)" + } + $warmupPaths = @( + "/login", + "/mails", + "/logs", + "/settings", + "/api/mails", + "/api/imports", + "/api/settings/mailboxes", + "/api/settings/oauth", + "/api/settings/cc", + "/api/audits" + ) + foreach ($p in $warmupPaths) { + try { + if ($webSession) { + $null = Invoke-WebRequest -Uri "http://127.0.0.1:$WebPort$p" -UseBasicParsing -TimeoutSec 180 -WebSession $webSession + } else { + $null = Invoke-WebRequest -Uri "http://127.0.0.1:$WebPort$p" -UseBasicParsing -TimeoutSec 180 + } + Write-Host " warmed $p" + } catch { + Write-Host " warmup $p done (status may be non-200)" + } + } + + Write-Host " web log: $webOut / $webErr" + Write-Host " worker log: $workerOut / $workerErr" +} + +function Start-ComposeStack { + param( + [switch]$NoBuild, + [int]$WebPort, + [int]$HealthTimeoutSec + ) + + Write-Step "start compose (mysql + web + worker images)" + if ($NoBuild) { + docker compose up -d + } else { + docker compose up --build -d + } + if ($LASTEXITCODE -ne 0) { + throw "docker compose up failed (exit=$LASTEXITCODE)" + } + + Write-Step "wait health http://127.0.0.1:$WebPort/api/health" + $ok = Wait-HttpOk -Url "http://127.0.0.1:$WebPort/api/health" -TimeoutSec $HealthTimeoutSec + docker compose ps + if (-not $ok) { + Write-Host "health check timeout. run: pnpm compose:logs" -ForegroundColor Yellow + exit 2 + } +} + +# ---------- main ---------- +$Root = Get-RepoRoot +Set-Location $Root +Write-Host "repo: $Root" +Write-Host "mode: $Mode (default local = no node image pull)" + +Write-Step "stop old services / free ports" +try { + # stop web/worker containers but keep volume; full down then local mysql up is ok + docker compose -f (Join-Path $Root "docker-compose.yml") down --remove-orphans 2>$null | Out-Null + Write-Host " docker compose down done" +} catch { + Write-Host " docker compose down skipped: $($_.Exception.Message)" -ForegroundColor Yellow +} + +Stop-NodeDevProcesses +Stop-PortListeners -Port $WebPort +# do not kill 7023 if we will reuse mysql quickly; still free stale non-docker holders +Stop-PortListeners -Port $MysqlPort + +Ensure-DotEnv -RepoRoot $Root + +if ($Mode -eq "compose") { + try { + Start-ComposeStack -NoBuild:$NoBuild -WebPort $WebPort -HealthTimeoutSec $HealthTimeoutSec + } catch { + Write-Host "" + Write-Host "compose failed: $($_.Exception.Message)" -ForegroundColor Yellow + Write-Host "Likely cause: cannot reach registry-1.docker.io (node image pull)." -ForegroundColor Yellow + if ($NoFallback) { throw } + Write-Host "Auto-fallback to local mode (mysql + host pnpm)..." -ForegroundColor Cyan + Start-LocalStack -RepoRoot $Root -WebPort $WebPort -MysqlPort $MysqlPort -HealthTimeoutSec $HealthTimeoutSec + } +} else { + Start-LocalStack -RepoRoot $Root -WebPort $WebPort -MysqlPort $MysqlPort -HealthTimeoutSec $HealthTimeoutSec +} + +Write-Host "" +Write-Host "OK - system started (web + worker)" -ForegroundColor Green +Write-Host " open: http://localhost:$WebPort" +Write-Host " login: admin/admin123 or ops/ops123" +Write-Host " worker: IMAP poll / parse / retention (see data\logs\worker.*.log in local mode)" +Write-Host " stop local: kill node on port $WebPort (and worker); docker compose stop mysql" +Write-Host " full compose (needs Docker Hub): .\docs\start-system.ps1 -Mode compose" +Write-Host " clear imap lock: pnpm exec tsx scripts/clear-imap-lock.ts" diff --git a/docs/邮箱项目.md b/docs/邮箱项目.md new file mode 100644 index 0000000..60f21fd --- /dev/null +++ b/docs/邮箱项目.md @@ -0,0 +1,435 @@ +# 邮箱项目(邮件自动预报) + +> 给接手同事看的入口文档。细则以 `docx/` 下 PRD / 技术设计 / 韧性为准;冲突时以 PRD 为准。 +> **更新日期**:2026-08-04(补充开发注意点 + 上市/生产安全清单) + +--- + +## 1. 项目信息 + +| 项 | 说明 | +|---|---| +| 名称 | 邮件自动预报系统(repo: `email-forecast`) | +| 目标 | 从绑定邮箱 IMAP(多箱/IDLE/OAuth|授权码)拉取 → 解析/分类 → **NEW 人工确认 SaveContainer**;DO/转仓/工单等走对应确认或指令写能力 | +| **不做**(一期) | AI 客服、SMTP 自动回信客户、与 CC 双向全量同步、真实大模型识别(指令拆分为词表/规则) | +| 栈 | Next.js 15 App Router · Prisma 5 · MySQL 8 · Ant Design 5 · TypeScript · pnpm | +| 进程 | `web`(UI+API+ConfirmImport)· `worker`(IMAP+解析+reaper+补偿+retention)· `mysql` | +| 本地入口 | http://localhost:3100 | +| 默认账号(**仅本地开发**) | `admin` / `admin123`(Admin);`ops` / `ops123`(运营) | +| MySQL 映射 | 宿主机 **7023** → 容器 3306(避让本机 3306) | +| 包管理 | **仅 pnpm**(不要 npm/yarn) | + +> ⚠️ 生产环境 **禁止** 使用默认口令与默认 `SESSION_SECRET`;`NODE_ENV=production` 时弱口令会 **拒绝启动**。详见 [§10 上市注意点](#10-上市生产注意点)。 + +### 1.1 路由 + +| 路径 | 说明 | +|---|---| +| `/login` | 登录 | +| `/mails` | 邮件列表 | +| `/mails/[id]` | 详情(重新解析 / Admin 改类型) | +| `/mails/[id]/confirm` | 确认导入(预报类:有效货件 + 可确认状态;含部分 DO 路径) | +| `/logs` | 导入日志;拉取记录 | +| `/settings` | Admin:自动拉取间隔/过滤、邮箱绑定、CC、OAuth | + +### 1.2 设计文档索引 + +| 文档 | 路径 | +|---|---| +| 需求规格(PRD) | `docx/需求规格-邮件自动预报-v0.2.md` | +| 技术设计 | `docx/技术设计方案-邮件自动预报-v1.0.md` | +| UI/UX | `docx/产品UI-UX设计方案-邮件自动预报-v1.0.md` | +| 异常与韧性 | `docx/异常场景与系统韧性设计.md` | +| 运营 SOP | `docx/运营SOP-邮件自动预报.md` | +| 产品规则(指令拆分) | `docx/产品规则-邮件指令识别与拆分.md` | +| CC 接口 V1.72 | `docx/接口/carriercentral客户端通用接口V1.72.md` | +| 实施计划(Cursor) | `.cursor/rules/implementation-plan.mdc` | +| 快速 README | `README.md` | + +--- + +## 2. 常用命令 + +### 2.1 日常开发 + +```bash +pnpm install +cp .env.example .env # 首次;DATABASE_URL 指向 localhost:7023;无 CC 账号时 CC_MOCK=true + +docker compose up -d mysql +pnpm exec prisma db push +pnpm db:seed # 仅账号 + +pnpm dev # Web :3100 + Worker(IMAP 自动拉取)一起启 +pnpm dev:web # 仅 Web(不拉信) +pnpm worker # 仅 Worker(IMAP / 解析 / 回收 / 补偿) +``` + +### 2.2 一键三件套 + +```bash +pnpm compose:up # mysql + web + worker +pnpm compose:ps +pnpm compose:logs +``` + +**Windows 一键启停(先杀旧进程/端口再启动,防地址占用):** + +```powershell +# 默认 local:只起 mysql 容器 + 本机 pnpm dev/worker(不拉 node 镜像,避开 Docker Hub) +.\docs\start-system.ps1 +.\docs\start-system.cmd + +# 全量 compose(需能访问 registry-1.docker.io) +.\docs\start-system.ps1 -Mode compose +.\docs\start-system.ps1 -Mode compose -NoBuild + +# compose 失败默认会自动 fallback 到 local;禁止回退加 -NoFallback +``` + +Docker Hub 超时(`registry-1.docker.io` / `node:*-slim`)时用默认 local 即可;Dockerfile 基底已改为本地常见的 `node:20-bookworm`。 + +### 2.3 数据库 + +```bash +pnpm db:generate +pnpm db:push +pnpm db:migrate:dev # 开发迁移 +pnpm db:migrate # 部署 migrate deploy +pnpm db:seed +``` + +### 2.4 IMAP / CC / 运维 + +```bash +pnpm imap:smoke # 连通性(.env 回退凭证) +pnpm imap:poll # 拉一轮(优先设置页 mailbox_account) +pnpm exec tsx scripts/clear-imap-lock.ts # 清残留 imap_poll 锁(lock_busy) + +pnpm cc:smoke # 跟随当前配置(默认 mock ≠ TEST 验收) +pnpm cc:smoke -- --mode=mock +pnpm cc:smoke -- --mode=live # 真烟雾:关 Mock + CC 账号 + +pnpm retention:cleanup +pnpm retention:cleanup -- --dry-run +``` + +### 2.5 测试与构建 + +```bash +pnpm test # vitest(含安全路径 tests/unit/security-hardening.test.ts) +pnpm test:watch +pnpm test:e2e # playwright(需先起服务) +pnpm lint +pnpm build +``` + +### 2.6 默认环境要点 + +```env +DATABASE_URL=mysql://app:app@localhost:7023/email_forecast +CC_MOCK=true +POLL_INTERVAL_MS=1800000 +IMAP_LOOKBACK_DAYS=3 +ENABLE_TYPE_OVERRIDE=true +ENABLE_FORCE_IMPORT=false +SESSION_SECRET=change-me-to-a-long-random-string-at-least-32-chars # 生产必须换强随机 ≥32 +``` + +IMAP / CC 凭证:**优先设置页 DB 配置**,无则回退 `.env`。 +拉取间隔:**设置 → 自动拉取**(`imap_settings`,默认 30min,3min~7d)优先于 `POLL_INTERVAL_MS`。 + +--- + +## 3. 技术方案(摘要) + +### 3.1 职责拆分 + +``` +imap.qq.com ──► worker(ImapPoller → Snapshot → ParsePipeline) ──► MySQL + ▲ +运营浏览器 ──► web(/api + ConfirmImport → CC SaveContainer) ────────┘ +``` + +| 进程 | 允许 | **禁止** | +|---|---|---| +| `web` | UI、API、确认导入、调 CC、设置 | 长期 IMAP 轮询主循环(可 Admin 手动拉一轮) | +| `worker` | IMAP 拉取、解析、stale reaper、补偿 due、retention、受限自动写(见能力表) | **业务 SaveContainer(新增预报)必须在 web 人工确认后调用** | + +### 3.2 邮件主路径 + +1. Worker 按 **可配间隔**(默认 30min,设置页 3min~7d)轮询;Admin「立即拉取」可手动 +2. Lookback:`SINCE IMAP_LOOKBACK_DAYS`(默认 3 天,已读+未读) +3. **过滤**:黑名单拒绝 → 发件人白名单必拉 → 关键词命中才拉;三列表皆空则回退业务相关兜底 +4. 幂等:`message_id` / `folder+uid` / `raw_hash`;入库后标 `\Seen`;每封写 `imap_pull_log`(最多 1000) +5. `ParsePipeline`:分类 → 解 xlsx/csv/zip → 柜头/货件 +6. 预报类有效货件 ≥1 → `PENDING_CONFIRM` → 确认页 → CC `SaveContainer` + +### 3.3 状态机(邮件) + +常用:`FETCHED` → `PARSING` → `PARSED` | `PENDING_CONFIRM` | `PARSE_FAILED` | `REJECTED_VALIDATION` +导入:`PENDING_CONFIRM` → `IMPORTING` → `SUCCESS` | `PARTIAL_SUCCESS` | `FAILED` +重新解析禁止:`IMPORTING` | `SUCCESS` | `PARTIAL_SUCCESS` + +非法迁移抛 `IllegalTransition`;改状态必须走 `assertTransition`。 + +### 3.4 命名锁 + +- `imap_poll` / `cc_login`:MySQL `GET_LOCK` +- **必须**经 `src/services/db-lock.ts` 独立连接 acquire/release(Prisma 连接池会导致锁泄漏 → 永久 `lock_busy`) +- Worker 启动会清残留 `imap_poll` 锁 + +### 3.5 关键目录 + +``` +src/app/ # Next App Router(页面 + API) +src/components/ # UI +src/services/imap/ # 拉取 / lookback / snapshot +src/services/parse/ # 分类 / 卡派 / pipeline +src/services/import/ # 确认导入 / 补偿 +src/services/cc/ # CC HTTP / auth / mock / 写能力 +src/lib/ # env / session / safe-path / rate-limit / cc-api-base +src/worker/ # 常驻 worker 入口 +prisma/ # schema + seed +docx/ # 需求与设计(权威) +docs/ # 协作入口文档(本文) +``` + +### 3.6 CC 写能力(易踩坑) + +表 `cc_write_capability`(启动 `ensureCcWriteCapabilities`): + +| id | 默认 mode | 说明 | +|---|---|---| +| `save_container` | live | V1.72 有正式接口;确认导入路径 | +| `do_upload` | live | `SaveFieldValue` + `annexes/upload`;list 验真接口文档未单列,为客户端同源 | +| `transfer` / `batch_transfer` / `hold_split` / `label` / `customer_message` | **mock** | 多数 **不在 V1.72 正式表**;切 live 前必须对方确认 endpoint + 联调 | + +`CC_MOCK=true` 或设置页 Mock:**所有写视为 mock,不能当 TEST/上线验收**。 + +--- + +## 4. 编码规范 + +1. **语言**:TypeScript strict;API 入参用 `zod`;日志用 `pino`。 +2. **改动范围**:只改任务相关文件;禁止顺手大重构、无关格式化。 +3. **兼容**:保持现有状态机、API 契约、Prisma 字段语义;破坏性变更先改 `docx` 再改代码。 +4. **UI**:运营后台沿用 Antd 5;确认页大表用 TanStack Virtual;中文文案集中 `src/constants/ui-copy.ts`。 +5. **Diff 优先**:补丁级修改;删除代码要确认无引用。 +6. **测试**:状态机 / 分类 / 解析 / 锁 / 安全路径变更需补或更新 `tests/unit`;关键路径跑 `pnpm test`。 +7. **密钥**:不提交真实 IMAP 授权码、CC 密码;用设置页或本地 `.env`(已 gitignore)。 +8. **包管理**:只用 `pnpm`;compose worker 用 `tsx` 跑源码(避免 `dist` 里 `@/` 别名未重写)。 +9. **提交**:不擅自 `git commit` / `push`;用户明确要求再建提交。 +10. **文档**:改行为后同步本文件或对应 `docx`;Cursor 进度记在 `implementation-plan.mdc`(短摘要 + Done)。 +11. **读附件/快照路径**:统一 `src/lib/safe-path.ts`(`resolveDataFile` / `assertUnderRoot`),禁止 `path.startsWith(dataRoot)` 自行拼装。 +12. **导入柜头覆盖**:客户端字段须进 `pickContainerHeaderPatch` 白名单(`confirm.ts`),禁止 `z.record` 原样 merge 进 CC。 + +--- + +## 5. 红线(绝对不能违反) + +| # | 红线 | +|---|---| +| 1 | **Worker 禁止承担新增预报 SaveContainer**(导入只在 web ConfirmImport) | +| 2 | **禁止绕过状态机**非法迁移(含直接改库「修好」状态) | +| 3 | **禁止用 Prisma 连接池直接 GET_LOCK/RELEASE_LOCK**(必须用 `db-lock` 同源连接) | +| 4 | **mock ≠ TEST 验收**:`CC_MOCK=true` / 设置页 Mock 开着时的「成功」不能当上线门禁 | +| 5 | **预报导入走确认页门禁**;指令类/转仓等不可冒充「新增柜」乱走 SaveContainer | +| 6 | **幂等不可丢**:同一邮件不得因重拉产生重复业务柜(依赖 message_id / uid / raw_hash) | +| 7 | **zip 只解一层**;路径穿越条目丢弃;解压后体积受 `ATTACHMENT_MAX_BYTES` 约束 | +| 8 | **冲突柜默认阻断**;强制跳过仅 Admin + `ENABLE_FORCE_IMPORT`,且必须写审计 | +| 9 | **先落库再 `\Seen`**;解析失败仍可「重新解析」,但不得在 `IMPORTING/SUCCESS/PARTIAL_SUCCESS` 重解析 | +| 10 | **不擅自改端口约定**:本地 MySQL 对外 **7023**;Web **3100** | +| 11 | **不把密钥写进仓库**;不在日志打印完整密码/授权码/Session/CC token | +| 12 | SMTP/AI 客服仍不做;OCR/IDLE/OAuth/多邮箱等变更须书面确认后改 PRD | +| 13 | **生产禁止默认口令与默认 SESSION_SECRET**(启动校验) | +| 14 | **附件落盘路径必须在 `data/` 下**;API 下载禁止任意绝对路径读盘 | + +违反以上任一条视为事故级改动,须回滚或补审计与文档后再合入。 + +--- + +## 6. 排障速查 + +| 现象 | 处理 | +|---|---| +| 跳过拉取 `lock_busy` | `pnpm exec tsx scripts/clear-imap-lock.ts`;重启 worker;确认无多实例互抢 | +| 拉取成功列表无信 | 看 Toast「新入库 / 候选」;候选 0 = lookback 内无新信或已入库;点刷新;确认 `pnpm worker` 在跑 | +| IMAP 未配置 | Admin → `/settings` 绑定邮箱 + 测试连接 | +| 解析失败 | 详情「重新解析」;看 `last_error`;对照韧性文档错误码 | +| 不能确认导入 | 状态/类型门禁不满足(见门禁逻辑 `forecast-confirm-gate`);有效货件 ≥1 | +| CC 导入失败 | `/logs`;补偿 ≤3;审计 `/logs?tab=audit` | +| compose worker 起不来 | 看日志是否 `@/` MODULE_NOT_FOUND;应用 `tsx src/worker/index.ts` | +| production 起不来 Invalid environment (production security) | 换强 `SESSION_SECRET` + 非弱 `APP_*_PASS`,admin/ops 口令不可相同 | +| 登录 429 | 同 IP+用户名 1 分钟过多失败;稍后再试或换源 IP | +| 设置 CC 提示 HOST_NOT_ALLOWED | `api_base` 仅允许 `*.carriercentral.vip` / localhost / 与 env `CC_API_BASE` 同主机(防 SSRF) | +| OAuth 回调 `bad_state` | state 15 分钟有效,须从本站 Admin 点「绑定」发起;勿复用旧链接 | + +--- + +## 7. 角色与验收提示 + +| 角色 | 能力 | +|---|---| +| ops | 列表/详情/确认导入/重新解析/附件下载 | +| admin | 上述 + 改类型、设置、立即拉取、审计、FORCE 导入(开关开启时)、忽略邮件 | + +**一期验收要点(摘要)** + +- 列表有 IMAP 真信;详情可确认导入 +- 新增预报 + 清单可进确认导入;勾选行与请求体一致 +- CC live 烟雾(非 mock)`customerLogin` → SaveContainer +- 冲突阻断;失败可重试/补偿;审计可查 +- 生产环境可启动(密钥已换)、登录与导入有限流、附件只能在 data 根下 + +--- + +## 8. 开发注意点(日常易踩) + +### 8.1 起环境 + +| 注意 | 说明 | +|---|---| +| **必须起 worker** | `pnpm dev` 已默认连带 worker;若只用 `pnpm dev:web` 则不拉信、不解析 | +| **MySQL 端口 7023** | `DATABASE_URL` 写错成 3306 会连到别的本机实例 | +| **CC 无账号先 Mock** | `CC_MOCK=true` 可联调 UI;有 demovip/test 账号再关 Mock | +| **多开 worker** | 同库多实例会抢 `imap_poll`(一得锁、一 `lock_busy`);一般只保留一个 worker | +| **Windows 路径** | 附件 path 入库用 `/`;读盘一律走 `resolveDataFile`,不要自己 `join` absolute 逃逸 | + +### 8.2 业务逻辑 + +| 注意 | 说明 | +|---|---| +| **确认门禁** | 以 `canEnterForecastConfirm` / 状态机为准,不是「邮件存在即可点导入」 | +| **version / 幂等** | 导入带 `version` + `idempotency_key`;并发第二请求可能 `VERSION_CONFLICT` / 直接返回已成功 | +| **渠道未映射** | 有 `CHANNEL_UNMAPPED` 须 `ack_unmapped_channels`,否则拒绝 | +| **强制跳冲突** | 须 Admin + `ENABLE_FORCE_IMPORT=true`,默认关 | +| **指令识别无 LLM** | `instruction-lexicon` / `split-instructions`;改规则先对齐 `docx/产品规则-*.md` | +| **OCR** | `OCR_PROVIDER=local` 为启发式兜底,**不是**商用识别;`aliyun` 现为 pending SDK,不能写「已接阿里云 OCR」上线文案 | +| **自动执行** | 新增预报 / DO / 转仓主路径 **人工确认** 优先;`ENABLE_AUTO_EXEC_*` 与 `cc_write_capability` 双重约束;默认 mock endpoint 不等于 CC 真有该接口 | + +### 8.3 安全相关(开发也要守) + +| 注意 | 说明 | +|---|---| +| 登录限流 | 10 次/分钟/IP+用户名(`/api/auth/login`) | +| 导入限流 | 10 次/分钟/登录用户(`/api/mails/:id/import`) | +| 限流实现 | 进程内 Map,**多副本不共享**;要严格限流需前置网关 | +| 健康检查 | `/api/health` **无鉴权**(docker healthcheck 依赖);勿往 response 里塞密钥 | +| 会话 | `iron-session` + Cookie HttpOnly;改 `SESSION_SECRET` 会使所有旧会话失效 | +| 邮箱密码 | DB 中 AES-GCM,密钥派生自 `SESSION_SECRET`——**换 SESSION_SECRET 后旧邮箱密文无法解密,需重新保存密码** | +| OAuth | Admin 发起;state 含 actor + exp;回调无登录 Cookie 也可完成绑定(依赖 state 签名) | + +### 8.4 改 CC / 接口 + +| 注意 | 说明 | +|---|---| +| **以 V1.72 为契约底** | `customerLogin` / `GetShippingLineList`(GET) / `SaveContainer` / `SaveFieldValue` / `annexes/upload` | +| **文档外 endpoint** | 转仓/工单等默认 mock;live 前写清路径与验收入库 | +| **token 可出现在 query** | GET 船司列表等;日志/反向代理勿明文长期存 query log | +| **CC 密码** | 协议要求 MD5;设置页传明文则服务端 md5 后存用 | +| **改 api_base** | 受主机白名单约束;自定义域名须 `.env` `CC_API_BASE` 同主机才能保存 | + +### 8.5 测试建议(改完要跑) + +```bash +pnpm test # 全量单测 +pnpm exec vitest run tests/unit/security-hardening.test.ts +pnpm cc:smoke -- --mode=mock # UI 联调 +pnpm cc:smoke -- --mode=live # 有真账号时 +``` + +--- + +## 9. 上市 / 生产注意点 + +上线前把下表当 **检查清单** 勾选;未勾完不算可对客。 + +### 9.1 必改环境变量 + +| 变量 | 要求 | +|---|---| +| `NODE_ENV` | `production` | +| `SESSION_SECRET` | **≥32 位高强度随机**;禁止 example 占位串 | +| `APP_ADMIN_PASS` / `APP_OPS_PASS` | 强密码;互不相同;禁止 `admin123`/`ops123`/`password`/`123456` | +| `DATABASE_URL` | 生产 MySQL;账号最小权限;勿把开发 7023 写进生产 | +| `CC_MOCK` | 正式业务 **false**;且设置页 Mock 关闭 | +| `CC_API_BASE` / `CC_SAAS_HEADER` / `CC_LOGIN_MARK` | 生产租户正式地址与 Saas 头(非随意 test 残留) | +| `CC_*` 账号 | 生产只读/业务约定账号;轮换策略由运维定 | +| `OAUTH_PUBLIC_BASE_URL` | **公网 HTTPS 根**,与 OAuth 控制台回调一致 | +| `OAUTH_*_CLIENT_*` | 仅在开启 Gmail/MS 绑定时配置;密钥不入库明文日志 | +| `ENABLE_FORCE_IMPORT` | 生产默认 **false**;仅紧急支持场景临时开 | +| `ENABLE_AUTO_EXEC_*` | 生产按业务决定;无 live endpoint 前保持关或 capability mock | +| `ATTACHMENT_MAX_BYTES` / shipment 上下限 | 按磁盘与 CC 限制校准 | + +### 9.2 基础设施 + +| 项 | 建议 | +|---|---| +| 进程 | `web` + `worker` 都要存活;worker 挂了只读历史、不进新信 | +| 实例数 | IMAP 锁全局一把:**worker 建议单副本**(或明确主备切换) | +| 数据盘 | `./data`(或挂载卷)持久化:eml、附件、`imap-runtime.json`;**勿用无状态容器丢盘** | +| 备份 | MySQL + `data/mails` 同步备份策略 | +| HTTPS | 生产强制 HTTPS;Cookie `secure` 在 production 已开启 | +| 反向代理 | 若记 access log,注意 CC token 可能在 query;脱敏或关闭详细 query 日志 | +| 健康检查 | 用 `/api/health` 即可;监控 mysql / imap_sync_alert | +| 对外暴露 | 运营后台勿裸奔公网无 VPN;登录限流不替代 WAF/零信任 | + +### 9.3 安全与合规 + +| 项 | 说明 | +|---|---| +| 启动硬校验 | 生产弱密钥 **直接拒绝启动**(见 `src/lib/env.ts`) | +| 路径安全 | 下载/解析只读 `data/` 下相对路径;防 `../` 与 `data` 前缀绕过 | +| 登录/导入限流 | 单机有效;多副本需 LB 层限流 | +| 角色 | 仅 admin/ops 两账号模型;**不是多租户**;泄露一账号即全站业务数据 | +| 审计 | 导入成功/失败、FORCE、邮箱绑定、CC 设置、OAuth 绑定须可查 | +| 保留期 | `DATA_RETENTION_DAYS` + worker retention;合规要求则加大或关闭清理并外备 | +| 第三方邮件 | 授权码/OAuth 属客户邮箱权限;SOP 告知运营勿绑个人箱当生产总线 | + +### 9.4 业务上线闸门(建议顺序) + +1. **配置**:生产 env + 强密钥 + `CC_MOCK=false` +2. **库**:`pnpm db:migrate`(或等价 migrate deploy);**不要**用生产库跑 seed 默认弱口令覆盖 +3. **IMAP**:设置页绑定业务邮箱并「测试连接」;确认 lookback/filter 符合运营预期 +4. **CC live**:`pnpm cc:smoke -- --mode=live` 或设置页「测试连接」 +5. **抽样邮件**:真拉一封预报类 → 确认导入一柜(低风险测试数据)→ 查 CC 柜与审计 +6. **补偿与冲突**:人为失败/冲突场景各看一次 `/logs` +7. **监控**:worker 连续失败告警、`imap_sync_alert`、磁盘水位、MySQL 连接 +8. **回滚预案**:保留上一镜像 + DB 备份;知悉换 `SESSION_SECRET` 会登出全员并可能需重配邮箱密码 + +### 9.5 明确不要当作上市完成的事项 + +- 仅 Mock 绿色通过 +- 仅 `customerLogin` 成功、未做过 SaveContainer +- OCR local 启发式「识别准确」宣传 +- 文档外写接口未 live 联调就打开自动执行 +- 开发默认口令仍在生产 `.env` +- 只部署 web 未部署 worker + +--- + +## 10. 安全加固摘要(实现侧,便于 Code Review) + +下列已在代码侧落地,改相关模块时请保持不退化: + +| 能力 | 位置(参考) | +|---|---| +| 数据区路径约束 | `src/lib/safe-path.ts` | +| CC api_base 主机约束 | `src/lib/cc-api-base.ts` + `api/settings/cc` | +| 生产弱密钥拒绝启动 | `src/lib/env.ts` | +| 登录/滑动窗口限流 | `src/lib/rate-limit.ts` + `api/auth/login` | +| OAuth state HMAC+TTL+actor | `src/services/oauth/providers.ts` | +| DO 上传 410 最多 1 次重试 | `src/services/cc/do-upload.ts` | +| 柜头字段白名单 merge | `src/services/import/confirm.ts` `pickContainerHeaderPatch` | +| Zip 解压体积上限 | `src/services/parse/zip-extract.ts` | +| 单测 | `tests/unit/security-hardening.test.ts` | + +--- + +## 11. 联系与变更 + +- 需求/行为变更:先改 `docx/需求规格-*.md` 与韧性文档,再改代码。 +- 协作入口文档:即本文件 `docs/邮箱项目.md`。 +- 开发进度:`.cursor/rules/implementation-plan.mdc`。 +- CC 路径/字段:以 `docx/接口/carriercentral客户端通用接口V1.72.md` 为准;缺接口时先 mock + 书面确认。 diff --git a/docx/产品UI-UX设计方案-邮件自动预报-v1.0.md b/docx/产品UI-UX设计方案-邮件自动预报-v1.0.md new file mode 100644 index 0000000..ee3241c --- /dev/null +++ b/docx/产品UI-UX设计方案-邮件自动预报-v1.0.md @@ -0,0 +1,452 @@ +# 产品UI/UX设计方案 v1.0(邮件自动预报系统) + +> 状态:可直接画原型 / 写前端(**2026-07-23 现行口径回写**) +> 依据:`docx/需求规格-邮件自动预报-v0.2.md`、`docx/技术设计方案-邮件自动预报-v1.0.md` +> UI 栈锁定:Ant Design 5 + TanStack Virtual(货件表)+ 本规范 Token +> **回写要点**:`/settings`;`/logs`=导入+拉取(无操作审计);空态无种子;详情业务意图摘要替代得分表;详见 `docx/现行口径-修订说明-v1.md` + +--- + +# 1. 信息架构(Information Architecture) + +## 1.1 页面列表 + +| 页面 | 路由 | 职责 | +|---|---|---| +| 登录页 | `/login` | 账号密码登录;写 session Cookie | +| 邮件列表页 | `/mails` | 时间倒序展示邮件;按类型/状态筛选;进入详情 | +| 邮件详情页 | `/mails/[id]` | 摘要、正文、附件、证据、货件预览;重新解析;Admin 改类型;进入确认导入 | +| 确认导入页 | `/mails/[id]/confirm` | 柜头编辑;TransMode/OperationType;货件勾选(虚拟滚动);二次确认后提交 CC | +| 导入日志/补偿页 | `/logs` | Tab:导入日志、拉取记录;失败重试 | +| 设置 | `/settings` | Admin:邮箱绑定、IMAP 间隔/过滤、CC、OAuth | +| Admin 改类型 | **详情页内 Modal**(非独立路由) | `ENABLE_TYPE_OVERRIDE`(及前端可见开关)+ `role=admin`;一期不写 audit_log | + +不设 `/dashboard`、不设 `/admin/override` 独立页。登录后默认落点:`/mails`。 + +## 1.2 页面关系图 + +``` +/login + → /mails + → /mails/[id] + → /mails/[id]/confirm (mail_type=NEW_CONTAINER 且 status=PENDING_CONFIRM;或 Admin 改类型后满足条件) + → /logs (提交结束:成功跳转;失败可停留 confirm 或跳 logs) + ⇢ Modal「改类型」 (Admin) + ⇢ 动作「重新解析」 (PARSE_FAILED / REJECTED_VALIDATION / 允许重解析的状态) + → /logs (顶栏入口) +``` + +禁用路径: +- `mail_type ≠ NEW_CONTAINER` 且未改类型成功 → 不可进入 confirm(直链访问重定向详情 + Toast) +- `status ∈ {IMPORTING, SUCCESS}` → confirm 只读或重定向详情 + +## 1.3 路由结构(Next.js App Router) + +``` +/login +/mails +/mails/[id] +/mails/[id]/confirm +/logs +``` + +顶栏全局导航(登录后):`邮件列表` | `日志` | `设置`(Admin) | 健康/IMAP/CC 状态 | `用户名` | `Admin` 徽标 | `退出` + +--- + +# 2. 交互原型(Interaction Design) + +## 2.0 全站壳层 + +``` +┌──────────────────────────────────────────────────────────┐ +│ TopNav: Logo「邮件预报」 | 导航链接 | 用户/Admin | 退出 │ +├──────────────────────────────────────────────────────────┤ +│ PageHeader: 标题 + 右侧主操作(按页) │ +├──────────────────────────────────────────────────────────┤ +│ MainContent(白底 Card 或铺满表) │ +└──────────────────────────────────────────────────────────┘ +``` + +- 最小视口宽:1280px +- 内容最大宽:1440px,水平居中 +- Toast:右上角;Modal:居中 + +--- + +## 2.1 登录页 `/login` + +**结构** +- 居中 Card(宽 400px) +- 标题:邮件自动预报 +- 字段:用户名、密码 +- 主按钮:登录 + +**交互** +- 提交 → 按钮 loading → 成功跳转 `/mails`;失败 Toast error + 字段下方文案 +- 已登录访问 `/login` → 重定向 `/mails` + +**空/错** +- 用户名或密码空 → 内联校验,不发请求 + +--- + +## 2.2 邮件列表页 `/mails` + +**结构** +``` +PageHeader: 「邮件列表」 +FilterBar: [类型 Select] [状态 Select] [关键词 Input] [查询 Button] +MailTable(非卡片列表,密度优先用表格): + 列:类型Tag | 状态Pill | 主题 | 发件人 | 接收时间 | 柜号(若有) +Pagination: 底右,默认 pageSize=20 +``` + +**交互** +- 行点击(整行可点)→ `/mails/[id]` +- 筛选变更 → 立即请求列表(防抖 300ms,关键词) +- 手动「刷新」按钮 → 重新拉列表(无 WebSocket) + +**空态** +- 文案:暂无邮件,等待 IMAP 拉取 +- 次按钮:刷新 + +**角标规则** +- 类型:左侧色点 8px + Tag 文字 +- 状态:StatusPill 文字(不只靠颜色) + +--- + +## 2.3 邮件详情页 `/mails/[id]` + +**结构** +``` +PageHeader: + [← 返回列表] 主题(H1) StatusPill TypeTag + 右侧操作组: + [重新解析](条件可见) + [改类型](Admin 条件可见) + [确认导入](主按钮,条件启用) + +SummaryStrip: 发件人 | 时间 | Message 状态 + +Tabs 或 纵向区块: + 1) 正文摘要(等宽可读,max-height 240px 可滚动) + 2) 附件列表:文件名 | 大小 | 下载 + 3) 业务意图摘要(动作/关键字/操作内容/转仓对;不展示得分分值);type_evidence JSON 可选收起 + 4) 货件预览表(前 50 行 + 「共 N 行,确认导入页查看全部」链接) +``` + +**确认导入按钮启用条件(全部满足)** +- `mail_type === NEW_CONTAINER` +- `status === PENDING_CONFIRM` +- `valid_shipment_count >= 1` + +**禁用时 Tooltip / 旁注文案(写死)** +- 类型非 NEW:`类型为 {mail_type},一期不可导入` +- 状态非 PENDING_CONFIRM:`当前状态为 {status},不可导入` +- 无有效行:`无有效货件行` + +**交互** +- 确认导入 → `/mails/[id]/confirm` +- 重新解析 → POST reparse → 按钮 loading → 成功 Toast → 重新拉详情;失败 Toast +- 改类型 → 打开 `TypeOverrideModal`: + - Select 目标类型 + - Input 原因(必填) + - 展示当前 type_evidence 摘要 + - 确认 → loading → 审计成功 Toast → 刷新详情(可能出现确认导入可点) + +**直链保护** +- 非法 id → 404 页:邮件不存在 + 回列表 + +--- + +## 2.4 确认导入页 `/mails/[id]/confirm`(核心) + +**结构** +``` +PageHeader: + [← 返回详情] 主题截断 | 柜号大号展示 + 右侧无第二主按钮(主 CTA 在底栏) + +Section「柜头」Card 两列表单: + 左列: TransMode* | OperationType* | ContainerNo* | CabinetType | Classis + 右列: ETD | ETA | ShippingLineId(Select 可搜索,可空)| MemoRemark | Instruction + * 必填;ContainerNo 失焦触发 ISO 校验 + +Section「货件」Card: + Toolbar: [全选有效] [反选] [SearchInput: FBACode/ShipmentID] [有效 m / 无效 k] + VirtualTable 高度固定 480px: + 列: ☑ | # | 仓库ID | 渠道 | 件数 | FBA ID | Ref | ShipmentID | 行状态 | 警告 + INVALID 行: 背景 #F3F4F6,勾选 disabled,行状态 Tag=INVALID + +StickyFooter(视口底): + 左: 已选 n / 有效 m + 右: [取消] [确认导入 Primary] +``` + +**默认值** +- TransMode = 解析推荐或 `0` +- OperationType = 解析推荐或 `0` +- 勾选 = 全部 `VALID` 行 +- `CHANNEL_UNMAPPED` 行可勾选,提交前若存在未 ack → 额外确认勾选「已知晓未映射渠道」 + +**二次确认 Modal** +- 标题:确认导入至 CarrierCentral +- 正文:`将向 CC 导入柜 {container_no},货件 {n} 行` +- 展示 TransMode / OperationType 文案 +- 按钮:取消 | 确认提交 + +**提交交互** +1. Primary 点击 → 打开 Modal(本地校验先过) +2. Modal 确认 → Primary+Modal 双 loading;`disabled` +3. 成功 → Toast success → 跳转 `/logs?mail_id={id}` +4. 失败 → Toast error(展示 `error.message`)→ 留在本页;可再改再提(若 status 已 IMPORTING 则整页只读 + 「导入中,请稍后刷新」) +5. `409 VERSION_CONFLICT` → Toast + 强制刷新页面数据 + +**校验(内联)** +- TransMode / OperationType 空 → 阻止 Modal +- ContainerNo 空或 ISO 失败 → 字段 Error +- n=0 → Primary disabled,文案:请至少勾选一行有效货件 + +--- + +## 2.5 导入日志/补偿页 `/logs` + +**结构** +``` +PageHeader: 「导入日志」 [返回邮件列表] +Filter: 状态 Select | 柜号/主题 Search +Table 列: + 时间 | 邮件主题(链到详情) | 柜号 | 导入状态Pill | external_id | last_error | 操作 +操作: + FAILED / TIMEOUT_UNKNOWN / CONFLICT(可重试类) → [重试] + SUCCESS → 显示 external_id,无重试 +``` + +**交互** +- 重试 → 按钮 loading → Toast → 刷新行 +- `COMPENSATION_EXHAUSTED` → 重试按钮对普通 ops 隐藏;Admin 显示「重置并重试」 + +**空态** +- 暂无导入记录 + +--- + +## 2.6 统一交互规则(写死) + +1. 所有触发写操作的按钮:点击即 `loading` + `disabled`,防重复提交。 +2. 所有 API 失败:全局 Toast `error`;表单字段错误额外内联。 +3. 所有必填/格式:失焦或提交前校验,不通过不发请求。 +4. 导入类写操作:必须二次确认 Modal。 +5. 货件表 ≥50 行:必须虚拟滚动(本系统确认页固定启用)。 +6. 状态更新:一期无 WS;用户点「刷新」或返回列表重进。 +7. IMPORTING 期间:详情/确认页禁止再次提交;展示 Alert「导入进行中」。 +8. 色标旁必须有文字标签。 + +--- + +# 3. 视觉设计(Visual Design System) + +## 3.1 色彩 Token + +| Token | Hex | 用途 | +|---|---|---| +| `--color-primary` | `#2563EB` | 主按钮、链接、焦点 | +| `--color-primary-hover` | `#1D4ED8` | hover | +| `--color-success` | `#16A34A` | 成功 | +| `--color-warning` | `#F59E0B` | 警告/部分成功 | +| `--color-error` | `#DC2626` | 失败 | +| `--color-bg` | `#F8FAFC` | 页面底 | +| `--color-surface` | `#FFFFFF` | 卡片/表单 | +| `--color-text` | `#111827` | 主文字 | +| `--color-text-secondary` | `#6B7280` | 辅助 | +| `--color-border` | `#E5E7EB` | 边框 | +| `--color-disabled` | `#9CA3AF` | 禁用 | + +**类型色(Tag 背景 / 文字白或深按对比度)** + +| mail_type | 色 | +|---|---| +| NEW_CONTAINER | `#2563EB` | +| TRANSFER | `#F59E0B` | +| INSTRUCTION_HOLD_SPLIT | `#8B5CF6` | +| INSTRUCTION_LABEL | `#EC4899` | +| WORK_ORDER | `#0D9488` | +| UNKNOWN | `#6B7280` | + +**状态色(StatusPill)** + +| status | 色 | +|---|---| +| FETCHED / PARSING / PARSED | `#9CA3AF` | +| PENDING_CONFIRM | `#2563EB` | +| IMPORTING | `#F59E0B` | +| SUCCESS | `#16A34A` | +| PARTIAL_SUCCESS | `#F59E0B` | +| FAILED / PARSE_FAILED / REJECTED_VALIDATION | `#DC2626` | +| IGNORED | `#6B7280` | + +## 3.2 字体 + +- 字体栈:`Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif` +- H1:24px / 700 / 1.2 +- H2:20px / 600 / 1.3 +- Body:14px / 400 / 1.5 +- Caption:12px / 400 / 1.4 +- Small:11px / 400 / 1.3 +- 等宽(证据 JSON):`ui-monospace, SFMono-Regular, Menlo, Consolas, monospace` 12px + +## 3.3 间距 + +`4 / 8 / 12 / 16 / 24 / 32 / 48 / 64`(px) +Card 内边距:16 +区块间距:24 +Filter 与表:16 + +## 3.4 圆角 + +| 元素 | 半径 | +|---|---| +| Button / Input | 6px | +| Card | 8px | +| Modal | 12px | +| Tag / Pill | 12px | + +## 3.5 阴影 + +- Card:`0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)` +- Modal:`0 20px 60px rgba(0,0,0,0.15)` + +## 3.6 组件视觉规格 + +| 组件 | 规格 | +|---|---| +| PrimaryButton | bg primary,文字白,h=36,px=16,radius 6;hover primary-hover;loading 内嵌 Spinner | +| SecondaryButton | bg 白,border primary,文字 primary | +| DangerButton | bg error,文字白 | +| Input / Select | h=36,border 1px border-color,focus:ring 2px primary 30% | +| Card | surface + shadow + radius 8 + p16 | +| Tag | h=22,px=8,文字 12px | +| StatusPill | 同 Tag | +| Table header | bg `#F9FAFB`,文字 secondary,字重 600 | +| INVALID 行 | bg `#F3F4F6`,文字 secondary | +| StickyFooter | 白底,上边框 border,h=56,z-index 10 | +| Toast success/error/warning | 对应色左边条 4px | + +--- + +# 4. 设计规范(Design System for AI Coding) + +## 4.1 React 组件清单(映射 Antd) + +| 设计组件 | 实现映射 | +|---|---| +| `PrimaryButton` | `Button type="primary"` | +| `SecondaryButton` | `Button` | +| `DangerButton` | `Button danger` | +| `InputField` | `Form.Item` + `Input` | +| `SelectField` | `Form.Item` + `Select` | +| `DatePickerField` | `Form.Item` + `DatePicker` | +| `Checkbox` | `Checkbox` | +| `AppCard` | `Card` | +| `MailTable` | `Table` | +| `ShipmentVirtualTable` | 自研:TanStack Virtual + 行渲染 | +| `TypeTag` | `Tag` + 类型色 | +| `StatusPill` | `Tag` + 状态色 | +| `LoadingSpinner` | `Spin` | +| `ErrorBanner` | `Alert type="error"` | +| `AppToast` | `message` / `notification` | +| `ConfirmModal` | `Modal.confirm` 或受控 `Modal` | +| `SearchInput` | `Input.Search` | +| `Pagination` | `Pagination` | +| `TypeOverrideModal` | 受控 `Modal` + Form | +| `TopNav` | `Layout.Header` + `Menu` | +| `EmptyState` | `Empty` | + +页面组件命名: +- `LoginPage` +- `MailListPage` +- `MailDetailPage` +- `MailConfirmPage` +- `ImportLogsPage` + +## 4.2 组件状态 + +每个可交互组件支持:`idle | loading | success | error | disabled | empty`(按上下文取用)。 + +页面级状态机(前端): +- `pageStatus: idle | loading | error` +- 详情另:`actionLoading: reparse | override | none` +- 确认页另:`submitPhase: idle | validating | confirming | submitting` + +## 4.3 命名规范 + +- 组件:PascalCase +- 路由文件:`page.tsx` 于 `app/mails/[id]/confirm/page.tsx` +- 状态布尔:`isLoading` `isSubmitting` `isAdmin` +- 文案常量:`src/constants/ui-copy.ts` +- Token CSS 变量:`--color-*` 写入 `globals.css` + +## 4.4 UI 硬约束(编码强制) + +1. 写操作按钮必须 `loading` + 防重复。 +2. API 失败必须可见错误(禁止静默)。 +3. 表单校验失败禁止发请求。 +4. 导入提交必须二次确认 Modal。 +5. 货件表 ≥50 行必须虚拟滚动(confirm 页强制)。 +6. 空列表必须 EmptyState。 +7. 桌面优先,min-width 1280。 +8. Tag/Pill 必须带文字,不只靠颜色。 +9. confirm 直链:服务端/客户端双重校验 NEW + PENDING_CONFIRM。 +10. Admin 改类型按钮:仅 `isAdmin && enableTypeOverride` 渲染。 + +## 4.5 文案常量(摘录) + +| key | 中文 | +|---|---| +| `empty.mails` | 暂无邮件,等待 IMAP 拉取 | +| `btn.confirmImport` | 确认导入 | +| `btn.reparse` | 重新解析 | +| `btn.overrideType` | 改类型 | +| `btn.retry` | 重试 | +| `modal.import.title` | 确认导入至 CarrierCentral | +| `modal.import.body` | 将向 CC 导入柜 {no},货件 {n} 行 | +| `disable.notNew` | 类型为 {type},一期不可导入 | +| `disable.badStatus` | 当前状态为 {status},不可导入 | +| `alert.importing` | 导入进行中,请稍后刷新查看结果 | +| `ack.unmapped` | 已知晓存在未映射渠道,仍提交原值 | + +## 4.6 线框尺寸速查 + +| 区域 | 尺寸 | +|---|---| +| TopNav 高 | 56px | +| PageHeader 高 | 64px | +| FilterBar 高 | 48px | +| 确认页 VirtualTable | 高 480px | +| StickyFooter | 高 56px | +| 登录 Card | 宽 400px | +| Modal 宽 | 480px(改类型)/ 520px(导入确认) | + +--- + +# 5. 页面 ↔ API 绑定(供前端直接接线) + +| 页面 | 主要 API | +|---|---| +| Login | `POST /api/auth/login` | +| MailList | `GET /api/mails` | +| MailDetail | `GET /api/mails/:id`;`POST .../reparse`;`POST .../type` | +| Confirm | `POST /api/mails/:id/import` | +| Logs | `GET /api/imports`;`POST /api/imports/:id/retry` | + +--- + +# 6. 文档索引 + +| 文档 | 路径 | +|---|---| +| 本设计 | `docx/产品UI-UX设计方案-邮件自动预报-v1.0.md` | +| PRD | `docx/需求规格-邮件自动预报-v0.2.md` | +| 技术设计 | `docx/技术设计方案-邮件自动预报-v1.0.md` | diff --git a/docx/产品规则-邮件指令识别与拆分.md b/docx/产品规则-邮件指令识别与拆分.md new file mode 100644 index 0000000..b2c5211 --- /dev/null +++ b/docx/产品规则-邮件指令识别与拆分.md @@ -0,0 +1,140 @@ +# 产品规则:邮件指令识别与拆分(锁定口径) + +> **地位**:本页为指令「怎么认、怎么拆、谁可确认」的**产品真源**之一。 +> **实现真源**:`src/services/parse/instruction-lexicon.ts`、`split-instructions.ts`、`classify.ts`。 +> **约束**:解析链路**禁止**依赖 LLM / Cursor 临场理解;改规则先改本页 + 词表 + 单测。 +> **日期**:2026-08-03 + +--- + +## 0. 一句话 + +主题与正文**都重要、要分开用**;一封多轮邮件里**只有最新一轮指令可确认**;标准四类走业务表单,非标准/软词走「客户指令」;同线程后续回复按**新 UID**再拉,不重做旧信。 + +--- + +## 1. 信号源(三者并列,来源可区分) + +| 信号源 | 用途 | UI 来源标签 | +|--------|------|-------------| +| **主题** | 柜号/提单/ETA/「新增预报」「DO请查收」等;可单独成指令单元 | 来自主题 | +| **正文** | 按时间轮/转发标签拆段;决定「当前可确认」 | 来自当前正文 / 来自历史引用 | +| **附件名** | 补 DO / 贴标清单等;**不能**仅凭正文「DO也同步上传」造空上传 DO | 来自附件名 | + +冲突原则: + +1. 正文**最新段**已是硬工单(换标/覆盖贴/贴标/拍照/拦截等)→ **压过**主题链路上历史「新增预报」。 +2. **同线程主题未变**(仅 Re:/Fw: 前缀变化)时:后续轮次**只认该时段正文**,不得再用首封主题「新增预报」造当前/补缺预报卡;QQ 转发壳剥壳后的主题残片也不算指令。 +3. 主题有、正文完全无指令时 → 才可用主题补缺(只读或非当前)。 +4. 正文已覆盖同类型 → 主题**不再重复**出一张一样的卡。 +5. 正文写「DO 也同步上传」但本封**无真实 DO 附件** → **不出**空的上传 DO 指令。 + +--- + +## 2. 标准四类业务 ↔ UI + +| UI kind | 典型关键词(见词表) | 确认动作 | +|---------|----------------------|----------| +| `forecast` | 新增预报、请查收新增预报 | 预报表单(人工确认) | +| `transfer` | 新增转仓、转仓(「不转仓」除外) | 批量转仓 / 到仓后降级工单 | +| `work_order` | 贴标/换标/覆盖贴/操作指令、拍照、**指令性**拦截、快递单号;mail1 仅柜号+预约码 | 客户留言 SaveForm(人工确认) | +| `do_upload` | DO请查收;或真实 `*DO*.pdf` 等 | 上传 DO(人工确认) | + +主流程四类均需人工确认,**不**因主题残留「新增预报」自动再写 CC。 + +> **渠道件数 ≠ 整票指令**:主题里的 `拦截-209件` / `FedEx-29件` 只是渠道汇总;整票真实指令以「拆柜清单更新 / 派送要求」为准。禁止把渠道名写成备注「邮件动作:拦截」。 + +--- + +## 3. 客户指令(非标准 / 软词) + +下列**不**硬套工单表单,归 `customer_instruction`(只读展示轮次/正文): + +- 更新派送单 +- 改自提 / 改为仓库自提 / 仓库自提 +- 拆柜清单 / 卡转海 / 派送要求 +- 按照正常预报接受、帮忙安排派单、安排派单 +- 继续暂存 / 暂存等后续 +- 有实质内容但无法归入标准四类的段落 + +--- + +## 4. 多轮线程拆分与「仅最新可确认」 + +### 4.1 如何切开 + +正文按下列边界切段(保留分隔标签到后一段,便于取时间/发件人): + +- `在 yyyy-mm-dd … 写道` / `On … wrote:` +- `原始邮件` / `转发的邮件` / `Original Message` / `Original:` +- 发件人+发送时间 等邮件头块 + +**不用**单独的 `Dear` 作为切段点(避免同轮内误切)。 + +### 4.2 可确认范围 + +| 段 | `isCurrent` | 操作 | +|----|-------------|------| +| 最新一条有实质指令的段 | `true` | 可确认提交 | +| 更早的引用轮次(标准四类) | `false` | 只读(如历史预报) | +| 更早的软词「客户指令」 | `false` | **独立时间轮要出卡**:按正常预报接受 / 仓库「好的」 / 无法接收;仅丢掉无时间轮的「更新派送单」噪声 | +| 纯主题补缺单元 | 一般为 `false` | 只读展示线索 | + +同轮内允许多种 UI 并存(例如当前换标 + 历史预报只读)。 +`work_order_actions` 仅含「转仓」、或正文已拆出转仓/预报等标准单元时,**不得**再补一张工单卡。 + +历史引用若正文指令相同(同线程连转同一份「拦截 + 改自提」等,仅抄送折行不同)→ **只保留最新一条**,禁止出 2/3/4 张一样的工单卡。 +工单备注须列出本段全部动作:硬词(拦截/贴标)与软词(改自提)并存时都写入「邮件动作」,不得只留拦截。 + +### 4.3 跳过 vs 仍展示的轮次 + +- **跳过(不出卡)**:QQ 转发壳、`发自我的iPhone`、空的 `Original:`、主题残片 +- **仍按时间轮出卡(只读、不可确认)**:仓库 ACK「好的 / 收到」;「无法接收 / 产能有限」;「按照正常预报接受 / 安排派单」 +- **同轮内 `====` 分组**(如换标 334 件两段 FBA)**不切开**,仍是这一轮一条工单 + +--- + +## 5. 同线程后续指令(拉取幂等) + +| 情况 | 行为 | +|------|------| +| 客户在同主题下**新回复**(新 IMAP UID) | **会拉**:新邮件入库、重新拆段;仅最新段可确认 | +| 已入库的旧 UID | **不再拉**(`knownUids` / messageId / rawHash 幂等) | +| 超出 `IMAP_LOOKBACK_DAYS`(默认 3 天)的过旧回复 | 可能拉不到 | +| 新回复未过过滤规则 | 记拉取日志为跳过,不进业务列表 | + +旧邮件保持原状态;**不会**因新回复自动把旧预报/旧工单再执行一遍。 + +--- + +## 6. 柜头 / 预报相关(与指令并存) + +- **提拆派**:预报时 `F_OperationType=0` 表示提拆派服务包,**不是**三个独立管理端「提柜/拆柜/派送」界面。 +- **mail1 类**(仅柜号+预约码、无标准关键词):`classify` → `WORK_ORDER`;详情用 `work_order_actions` 补当前工单单元(见 `extractMailInstructions({ workOrderActions })`)。 +- 模板表尾「注意:…」是填写说明,**不是**货件行。 +- 卡派清单列:附件有则填;UI **隐藏整列皆空**的 FormOrder 列,避免满屏「-」。 +- 工单留言内容:取当前指令段,并从 `Dear` / `原箱号` 起,去掉转发头;**禁止**用整封线程正文盖住分段留言。 + +--- + +## 7. 实现与测试对照表 + +| 规则 | 代码 | 单测(示例) | +|------|------|----------------| +| 词表 / 软硬分流 | `instruction-lexicon.ts` | `mail4-time-rounds.test.ts` | +| 拆段 / 最新可确认 / 主题补缺 | `split-instructions.ts` | `split-instructions.test.ts`、`mail4-time-rounds` | +| 正文硬工单压过主题预报 | `classify.ts` | `classify.test.ts`、`instruction-product-rules` | +| 线程拆单(Message 级) | `thread.ts` + poller | `thread.test.ts` | +| UID 幂等拉取 | `poller.ts` `knownUids` | 集成/烟雾 | +| 空 DO 不出指令 | `split-instructions` + `do-upload-extract` | `mail4-time-rounds`、`do-upload-extract` | +| 工单留言清洗 | `CcWorkOrderForm` `extractWorkOrderMessageBody` | `four-business.test.ts` | + +改口径流程:**改本页 → 改词表/拆分 → 改/补单测 → `pnpm test` 相关文件通过**。 + +--- + +## 8. 与旧文档关系 + +- `docx/需求规格-邮件自动预报-v0.2.md` §4 类型打分仍有效;**本页补充**「拆分 / 客户指令 / 主题正文分工 / 仅最新确认」。 +- 若与旧表述冲突,**以本页 + 上述实现文件为准**,并回写需求规格对应章节。 +- Cursor 对话纪要**不是**运行时真源。 diff --git a/docx/异常场景与系统韧性设计.md b/docx/异常场景与系统韧性设计.md new file mode 100644 index 0000000..cb9d162 --- /dev/null +++ b/docx/异常场景与系统韧性设计.md @@ -0,0 +1,572 @@ +# 异常场景与系统韧性设计(邮件自动预报系统专用) + +> 本章为 `docx/需求规格-邮件自动预报-v0.2.md` 第 14 章正文;与 §5–§8 状态机、CC 接口、数据模型、运营 SOP 一一对应。 +> 常量约定:`IMPORT_LOCK_TTL=120s`;`IMPORTING_TIMEOUT=180s`;`PARSING_STALE=10min`;`COMPENSATION_MAX_RETRY=3`;`IMAP_CONNECT_TIMEOUT=30s`;`IMAP_READ_TIMEOUT=60s`;`CC_HTTP_TIMEOUT=60s`;`CC_REPLAY_ON_410=1`;`SHIPMENT_ROW_SOFT_LIMIT=1000`;`SHIPMENT_ROW_HARD_LIMIT=5000`。 +;**`IMAP_LOOKBACK_DAYS=3`**。 +> **2026-07-23 回写**:拉取=近 N 天已读+未读;**一期无 audit_log**;详见 `docx/现行口径-修订说明-v1.md`。 + +--- + +## 1. 输入层异常处理 + +### 1.1 邮件缺失主题 +- **异常描述**:IMAP 入库的 Message `subject` 为空或仅空白。 +- **系统行为**:`mail_message.subject` 存 `"(无主题)"`;类型判定仅用正文+附件名;照常进入 `PARSING`。 +- **是否允许降级**:是(继续解析)。 +- **是否触发告警**:`warn` 日志 `mail.subject_missing`;不计监控告警。 +- **用户看到的结果**:列表主题显示「(无主题)」;详情正常。 + +### 1.2 邮件缺失正文 +- **异常描述**:纯附件邮件或正文 HTML/纯文本均为空。 +- **系统行为**:正文摘要空串;类型与柜头字段仅从附件/主题提取;无附件则 `REJECTED_VALIDATION`,`last_error=NO_BODY_NO_ATTACHMENT`。 +- **是否允许降级**:有附件则继续;无附件则阻断导入路径。 +- **是否触发告警**:无附件 → `error` + 指标 `mail.reject_validation`。 +- **用户看到的结果**:状态「校验拒绝」;提示「无正文且无附件,无法解析」;可下载原始快照。 + +### 1.3 邮件无附件 +- **异常描述**:类型可能为 NEW/TRANSFER,但无 xlsx/csv/zip。 +- **系统行为**:若主题/正文能抽出柜号则写入 `parse_result.container_header`,`shipments=[]`;`mail_type` 仍按 §4 判定;因有效行=0,即使 NEW 也不进 `PENDING_CONFIRM`,停留 `REJECTED_VALIDATION` 或 `PARSED`(非 NEW)+ `last_error=NO_SHIPMENT_ROWS`。 +- **是否允许降级**:是(仅展示,不可导入)。 +- **是否触发告警**:`warn` `parse.no_attachment`。 +- **用户看到的结果**:详情提示「缺少卡派/装箱清单附件」;确认导入入口不可用。 + +### 1.4 附件格式非 xlsx/csv,且 zip 内无有效文件 +- **异常描述**:仅有 pdf/图片/docx,或 zip 解压后无 xlsx/csv/xls。 +- **系统行为**:附件仍写入 `mail_attachment`;解析器标记 `template_id=null`;状态 `PARSE_FAILED`,`last_error=NO_SUPPORTED_SPREADSHEET`。 +- **是否允许降级**:附件可下载;不调用 SaveContainer。 +- **是否触发告警**:`error` `parse.unsupported_attachment`。 +- **用户看到的结果**:「不支持的附件格式」+「重新解析」按钮;运营按 SOP 走客户端预报。 + +### 1.5 附件关键列缺失(仓库ID / 渠道 / 件数) +- **异常描述**:卡派资料表头缺少映射到 `F_FBACode`/`F_Transporter`/`F_CTNS` 的列。 +- **系统行为**:整表无法建有效行 → `PARSE_FAILED`,`lineage.missing_columns=[...]`;若仅部分行缺值见 1.7。 +- **是否允许降级**:否(不可进 `PENDING_CONFIRM`)。 +- **是否触发告警**:`error` `parse.missing_columns`。 +- **用户看到的结果**:展示缺失列名列表;可重新解析。 + +### 1.6 柜号格式非法(非 ISO 6346) +- **异常描述**:抽出柜号未通过校验位(且未关闭 ISO 校验配置)。 +- **系统行为**:`container_header.F_ContainerNo` 保留原值并标 `container_no_valid=false`;状态 `REJECTED_VALIDATION`;禁止确认导入。 +- **是否允许降级**:Admin 可在详情手工改柜号后触发「重新校验」;不自动猜正确柜号。 +- **是否触发告警**:`warn` `parse.invalid_container_no`。 +- **用户看到的结果**:「柜号未通过 ISO 校验:{no}」;编辑柜号后可再校验。 + +### 1.7 货件行半空(件数=0、仓库ID 空等) +- **异常描述**:单行缺少 `F_FBACode` 或 `F_Transporter` 或 `F_CTNS`≤0/空。 +- **系统行为**:该行 `row_status=INVALID`,写入 `shipments[]`;不阻断其他 VALID 行;默认不勾选。 +- **是否允许降级**:是(柜级部分行导入,§5.7)。 +- **是否触发告警**:INVALID 行占比 >30% → `warn` `parse.high_invalid_ratio`。 +- **用户看到的结果**:灰显行 + tooltip 列缺失原因;底栏「已选 n / 有效 m」。 + +### 1.8 超大附件(>20MB) +- **异常描述**:单附件 `size > 20MB`。 +- **系统行为**:不落盘完整文件(或落盘后立即拒绝解析);`mail_attachment` 记 size + `rejected=1`;邮件 `REJECTED_VALIDATION`,`last_error=ATTACHMENT_TOO_LARGE`;仍按 §6.2 标 `\Seen`。 +- **是否允许降级**:否。 +- **是否触发告警**:`error` `mail.attachment_too_large`。 +- **用户看到的结果**:「附件超过 20MB,请客户拆分后重发」;快照仅保留邮件头+正文。 + +### 1.9 渠道映射词不匹配 +- **异常描述**:渠道列值不在 §5.6 黄金表。 +- **系统行为**:`F_Transporter`=原文截断 30 字符;行标 `CHANNEL_UNMAPPED`;**仍可为 VALID**(若三必填齐全);确认页该行黄标警告。 +- **是否允许降级**:是(不阻断提交);CC 若 400 再走补偿。 +- **是否触发告警**:`warn` `map.channel_unmapped`(带原词)。 +- **用户看到的结果**:警告「渠道未映射:{raw},将原样提交 CC」;可手工改下拉为标准枚举。 + +### 1.10 体积/重量含非数字 +- **异常描述**:`总体积`/`毛重` 含单位文字或非法字符。 +- **系统行为**:剥离常见单位(m³/cbm/kg)后 `parseFloat`;失败则该字段 `null`,行仍 VALID(体积重量非必填);`lineage` 记 `FIELD_PARSE_FALLBACK`。 +- **是否允许降级**:是。 +- **是否触发告警**:仅 `debug`/`info`。 +- **用户看到的结果**:对应单元格空;可手工补填。 + +### 1.11 地址/日期格式错误 +- **异常描述**:送仓日期无法解析,或地址超长。 +- **系统行为**:日期统一按 `Asia/Shanghai` 解析;失败则 `F_Expected_DeliveryDateB/E=null`;地址截断至 500 字符并 `warn`。 +- **是否允许降级**:是。 +- **是否触发告警**:日期失败 `warn` `parse.bad_date`。 +- **用户看到的结果**:日期空或截断提示;确认页可改。 + +--- + +## 2. 用户行为与并发控制 + +### 2.1 确认页重复点击提交 +- **异常描述**:用户连点「确认导入」。 +- **系统行为**:前端按钮立即 `disabled` + loading;请求带 `Idempotency-Key=mail_id:container_no:shipments_hash`;后端对 `mail_message` 执行 `UPDATE ... SET status='IMPORTING' WHERE id=? AND status='PENDING_CONFIRM'`,影响行=0 则返回 `409 ALREADY_IMPORTING`。 +- **是否允许降级**:第二次请求直接返回当前状态,不二次调 CC。 +- **是否触发告警**:`info` `import.duplicate_click`。 +- **用户看到的结果**:首次进入「导入中」;再次点击 Toast「正在导入,请勿重复提交」。 + +### 2.2 快速连续提交(并发) +- **异常描述**:两次请求几乎同时到达。 +- **系统行为**:DB 行锁/条件更新串行化;仅赢家进入 CC 调用;输家 409。 +- **是否允许降级**:否(严格单飞)。 +- **是否触发告警**:`warn` 若 1 分钟内同 mail 409>3 → `import.contention`。 +- **用户看到的结果**:同 2.1。 + +### 2.3 页面刷新导致状态丢失或重复请求 +- **异常描述**:提交后刷新,前端本地 state 清空。 +- **系统行为**:一切以 DB `status` 为准;刷新后拉取详情,若 `IMPORTING/SUCCESS/FAILED` 展示对应 UI;不自动重放未完成请求(除非用户点重试)。 +- **是否允许降级**:是(只读恢复)。 +- **是否触发告警**:否。 +- **用户看到的结果**:看到真实状态角标;IMPORTING 显示进度/等待。 + +### 2.4 浏览器回退/前进 +- **异常描述**:确认页回退到详情再前进。 +- **系统行为**:确认页 `beforeunload` 不拦截只读浏览;若 status 已非 `PENDING_CONFIRM`,确认页挂载时重定向详情并禁用提交。 +- **是否允许降级**:是。 +- **是否触发告警**:否。 +- **用户看到的结果**:「当前状态不可导入:{status}」。 + +### 2.5 多标签页同时操作同一邮件 +- **异常描述**:两标签同时改字段或提交。 +- **系统行为**:提交用乐观锁 `mail_message.updated_at` 或 `version` 字段;冲突返回 `409 VERSION_CONFLICT`;改类型接口同锁。 +- **是否允许降级**:后写失败,需刷新。 +- **是否触发告警**:`warn` `mail.version_conflict`。 +- **用户看到的结果**:「数据已被其他页面更新,请刷新后重试」。 + +### 2.6 Admin 改类型误操作 +- **异常描述**:误将 LABEL 改为 NEW 并准备导入。 +- **系统行为**:改类型二次确认弹窗展示旧→新 + 证据分;写入 `audit_log`;支持「改回」若尚未 `IMPORTING`;已 SUCCESS 禁止改类型。 +- **是否允许降级**:人工改回。 +- **是否触发告警**:每次改类型 `info` 审计;1 小时同用户改类型>10 → `warn`。 +- **用户看到的结果**:弹窗确认;一期无审计页。 + +--- + +## 3. 网络与通信容错 + +### 3.1 IMAP 连接断开/超时 +- **异常描述**:connect>30s 或 read>60s,或 TLS 中断。 +- **系统行为**:本次轮询 abort;释放 `GET_LOCK('imap_poll')`;下个 `POLL_INTERVAL` 重试;已 `FETCHED` 的邮件不受影响。 +- **是否允许降级**:retry(无限轮询,指数退避上限 5min 仅当连续失败≥5)。 +- **是否触发告警**:连续失败≥3 → `error` 监控 `imap.poll_fail`;列表顶栏红条「邮件同步异常」。 +- **用户看到的结果**:已有邮件仍可操作;新邮件暂不出现。 + +### 3.2 前端调 CC 超时/断开 +- **异常描述**:Web→自有 API 或自有 API→CC 超过 `CC_HTTP_TIMEOUT`。 +- **系统行为**:若请求已发出且本地未收到响应:柜状态先标 `FAILED` 或入 `import_compensation`(`reason=TIMEOUT_UNKNOWN`);**禁止立即自动重放 SaveContainer**(防双写);需 `GetContainerList` 确认柜是否已存在后再决定重试或标 SUCCESS。 +- **是否允许降级**:补偿队列 + 人工确认重试。 +- **是否触发告警**:`error` `cc.timeout`。 +- **用户看到的结果**:「导入结果不确定,已加入待核查队列」;日志页显示「超时待核对」。 + +### 3.3 请求已发送但响应丢失(部分成功不确定) +- **异常描述**:CC 可能已建柜,客户端读超时。 +- **系统行为**:同 3.2;核对流程:用柜号查 `GetContainerList`;命中近 10 分钟新建 → 回写 `external_id` 并 `SUCCESS`;未命中 → 允许补偿重试。 +- **是否允许降级**:核查后自动收敛。 +- **是否触发告警**:`error` `cc.ambiguous_success`。 +- **用户看到的结果**:状态「待核查」直到收敛。 + +### 3.4 弱网重试导致重复请求 +- **异常描述**:浏览器/代理自动重试 POST。 +- **系统行为**:依赖 `Idempotency-Key` + 条件更新 `PENDING_CONFIRM→IMPORTING`;CC 侧再经 §5.8 冲突检测。 +- **是否允许降级**:幂等吞掉重复。 +- **是否触发告警**:`info`。 +- **用户看到的结果**:单次成功结果。 + +### 3.5 CDN/静态资源加载失败 +- **异常描述**:JS/CSS chunk 404。 +- **系统行为**:Next error boundary 展示「资源加载失败,请强刷」;不影响已运行的 worker/IMAP。 +- **是否允许降级**:用户手动刷新。 +- **是否触发告警**:前端上报 `warn` `ui.chunk_fail`(若有)。 +- **用户看到的结果**:整页错误壳,非静默白屏。 + +--- + +## 4. 后端 API(CarrierCentral)容错策略 + +### 4.1 customerLogin 返回 410 或 5xx +- **异常描述**:登录失败或 token 接口异常。 +- **系统行为**:清 `cc_token_cache` + 进程内存 token;5xx 退避重试登录最多 2 次;仍失败则导入整体 `FAILED`,`last_error=CC_LOGIN_FAILED`。 +- **是否允许降级**:否(无法调用业务 API)。 +- **是否触发告警**:`error` `cc.login_fail`。 +- **用户看到的结果**:§5.10「登录失效/CC 异常」文案;运营检查 `CC_USERNAME`。 + +### 4.2 SaveContainer 业务调用中途 410 +- **异常描述**:token 过期发生在 SaveContainer。 +- **系统行为**:重登 1 次并**重放同一 Idempotency 载荷 1 次**(`CC_REPLAY_ON_410=1`);仍 410 → FAILED。 +- **是否允许降级**:单次重放。 +- **是否触发告警**:重放成功 `info`;失败 `error`。 +- **用户看到的结果**:成功则无感;失败见 §5.10。 + +### 4.3 SaveContainer 返回 400(含柜号已存在) +- **异常描述**:CC 业务校验失败。 +- **系统行为**:该柜 `container_import.status=FAILED`;`last_error=info`;**不自动重试**(业务错误);邮件多柜则其他柜继续 → 可能 `PARTIAL_SUCCESS`。 +- **是否允许降级**:人工改数据后重新确认(若邮件回到可编辑:从 FAILED 允许「重新打开确认」仅当无任何 SUCCESS 柜;已有 SUCCESS 柜则仅补偿失败柜)。 +- **是否触发告警**:`warn` `cc.save_400`。 +- **用户看到的结果**:「业务校验失败:{info}」。 + +### 4.4 GetContainerList 超时或数据不全 +- **异常描述**:冲突检测调用失败。 +- **系统行为**:**默认阻断导入**(`CONFLICT_CHECK_FAILED`),不盲调 SaveContainer;运营可 Admin 强制跳过冲突检测(需 `ENABLE_FORCE_IMPORT=true`)。 +- **是否允许降级**:仅 Admin 强制。 +- **是否触发告警**:`error` `cc.list_timeout`。 +- **用户看到的结果**:「无法校验柜号是否已存在,请稍后重试」。 + +### 4.5 响应结构变化(字段新增/删除) +- **异常描述**:`data` 非预期类型或缺柜 id。 +- **系统行为**:`code=200` 但无法解析 `external_id` → 走 3.2 不确定流程(列表核对);解析层用宽松 schema(忽略未知字段)。 +- **是否允许降级**:核对收敛。 +- **是否触发告警**:`error` `cc.schema_drift`。 +- **用户看到的结果**:「返回异常,已待核查」。 + +### 4.6 相同 loginMark「重复提交」语义 +- **异常描述**:CC 侧会话与多次 SaveContainer。 +- **系统行为**:本系统不轮换 loginMark(进程级固定);重复保护完全靠本库幂等 + 冲突检测,不依赖 CC 对 loginMark 去重。 +- **是否允许降级**:本库幂等。 +- **是否触发告警**:否。 +- **用户看到的结果**:无重复柜(冲突则阻断)。 + +--- + +## 5. 邮件解析与附件处理容错(重点) + +### 5.1 IMAP 拉取成功但无法解析原始内容 +- **异常描述**:损坏 MIME / 编码异常。 +- **系统行为**:原始字节仍写入 `snapshot_path`;`PARSE_FAILED`,`last_error=MIME_PARSE_ERROR`;已 `\Seen`。 +- **是否允许降级**:人工下载快照;「重新解析」可重试。 +- **是否触发告警**:`error` `parse.mime_error`。 +- **用户看到的结果**:解析失败页 + 下载原始邮件。 + +### 5.2 zip 解压失败或嵌套多层 +- **异常描述**:损坏 zip,或有效 xlsx 在二层目录。 +- **系统行为**:仅解压**一层**;失败 → `PARSE_FAILED` `ZIP_EXTRACT_FAILED`;一层内递归目录找 xlsx/csv;超过一层嵌套 zip **不进入**,记 `warn` `zip.nested_ignored`。 +- **是否允许降级**:否(需客户重发扁平附件)。 +- **是否触发告警**:失败 `error`;嵌套忽略 `warn`。 +- **用户看到的结果**:对应错误文案。 + +### 5.3 多 sheet 找不到「卡派」 +- **异常描述**:无名称含「卡派」的 sheet。 +- **系统行为**:回退第一 sheet;若第一 sheet 表头映射失败再 `PARSE_FAILED`。 +- **是否允许降级**:fallback 第一 sheet。 +- **是否触发告警**:`info` `parse.sheet_fallback`。 +- **用户看到的结果**:详情显示「使用 Sheet:{name}」。 + +### 5.4 表头别名匹配失败 +- **异常描述**:列名与映射表无一命中必填语义。 +- **系统行为**:同 1.5 → `PARSE_FAILED` + `missing_columns`。 +- **是否允许降级**:否。 +- **是否触发告警**:`error`。 +- **用户看到的结果**:缺失列列表。 + +### 5.5 货件行数过大(>1000 / >5000) +- **异常描述**:解析行数超过软/硬限制。 +- **系统行为**:`>SHIPMENT_ROW_HARD_LIMIT(5000)` → `REJECTED_VALIDATION` 不落全量 JSON;`>SOFT(1000)` 且 ≤HARD → 落库,确认页强制虚拟滚动,默认**不**全选,需用户筛选后勾选,底栏警告。 +- **是否允许降级**:软限制降级;硬限制拒绝。 +- **是否触发告警**:软 `warn`;硬 `error` `parse.row_limit`。 +- **用户看到的结果**:硬限「行数过多请拆分」;软限「行数较多,请筛选后勾选导入」。 + +### 5.6 柜号提取错误 +- **异常描述**:正文柜号与附件柜号不一致,或正则误匹配。 +- **系统行为**:附件柜号优先(§ 结构化优先);冲突写入 `lineage` `CONTAINER_NO_CONFLICT`;确认页大红警告,**默认阻断提交**直至人工选定柜号(下拉:附件值/正文值/手输)。 +- **是否允许降级**:人工选定。 +- **是否触发告警**:`warn` `parse.container_conflict`。 +- **用户看到的结果**:必须选择柜号后才能确认。 + +### 5.7 类型判定信号冲突 +- **异常描述**:多类型得分接近或同分。 +- **系统行为**:严格按 §4.2 同分优先级执行;`type_evidence` 完整落库;UI 展示各类型得分。 +- **是否允许降级**:Admin 改类型。 +- **是否触发告警**:得分差≤5 → `info` `type.close_call`。 +- **用户看到的结果**:证据面板;非 NEW 导入按钮禁用原因含「当前类型=…」。 + +### 5.8 重新解析 +- **异常描述**:用户/系统对 `PARSE_FAILED`/`REJECTED_VALIDATION`/`PARSED` 点重新解析。 +- **系统行为**:仅当 status∉{IMPORTING,SUCCESS,PARTIAL_SUCCESS};置 `PARSING`;覆盖 `parse_result`;写审计。 +- **是否允许降级**:是。 +- **是否触发告警**:`info`。 +- **用户看到的结果**:解析中 → 新结果。 + +--- + +## 6. 状态机与生命周期保护 + +### 6.1 落库成功但标 `\Seen` 失败 +- **异常描述**:DB 已有 `FETCHED`,IMAP STORE 失败。 +- **系统行为**:邮件保持可解析;下次 lookback 再次命中 UID → 幂等键命中则**跳过新建**,仅重试 `\Seen`;不重复解析除非强制。 +- **是否允许降级**:retry Seen。 +- **是否触发告警**:连续 Seen 失败 `warn` `imap.seen_fail`。 +- **用户看到的结果**:列表仍一条;无重复卡片。 + +### 6.2 进程崩溃卡在 `PARSING` +- **异常描述**:worker 死于解析中。 +- **系统行为**:定时任务(每 1min):`status=PARSING AND updated_at < now-PARSING_STALE` → 目标重置为 `FETCHED` 再入队;现行可先置 `PARSE_FAILED` 后「重新解析」(待收敛)。一期不写 audit_log。 +- **是否允许降级**:自动恢复。 +- **是否触发告警**:`error` `state.stale_parsing`。 +- **用户看到的结果**:短暂后恢复为已解析或失败态。 + +### 6.3 `IMPORTING` 超时未更新 +- **异常描述**:超过 `IMPORTING_TIMEOUT=180s`。 +- **系统行为**:回收任务将状态改为 `FAILED` 或 `TIMEOUT_UNKNOWN` 入补偿;触发 3.2 核对;禁止无限 IMPORTING。 +- **是否允许降级**:补偿+核对。 +- **是否触发告警**:`error` `state.importing_timeout`。 +- **用户看到的结果**:「导入超时,请至日志核查/重试」。 + +### 6.4 解析与 Admin 改类型并发 +- **异常描述**:PARSING 中同时改类型。 +- **系统行为**:改类型 API 拒绝非终态解析中:`409 MAIL_BUSY`;解析结束后以解析器写的 `mail_type` 为准,Admin 需再改。 +- **是否允许降级**:否。 +- **是否触发告警**:`info`。 +- **用户看到的结果**:「邮件正在解析,请稍后再改类型」。 + +### 6.5 补偿队列丢记录或超最大重试 +- **异常描述**:`retry_count >= COMPENSATION_MAX_RETRY(3)` 或行被误删。 +- **系统行为**:超限标 `COMPENSATION_EXHAUSTED`,停止自动重试;监控告警;运营手工重试重置 `retry_count`(Admin)。 +- **是否允许降级**:人工介入。 +- **是否触发告警**:`error` `compensation.exhausted`。 +- **用户看到的结果**:日志「重试已耗尽,请联系值班」;SOP §12。 + +### 6.6 非法状态迁移 +- **异常描述**:客户端伪造 status 回写。 +- **系统行为**:所有迁移仅服务端按 §6.3 表执行;无通用 status PATCH。 +- **是否允许降级**:否。 +- **是否触发告警**:`warn` `state.illegal_transition`。 +- **用户看到的结果**:400。 + +--- + +## 7. 缓存一致性与幂等设计 + +### 7.1 DB token 过期但内存仍有效 +- **异常描述**:`cc_token_cache.expire_at` 已过,进程内存未清。 +- **系统行为**:每次用 token 前比较 DB `expire_at`;以 **DB 为权威**;过期强制重登;内存跟随失效。 +- **是否允许降级**:重登。 +- **是否触发告警**:`info` `cc.token_refresh`。 +- **用户看到的结果**:无感或一次短暂延迟。 + +### 7.2 多实例 token 刷新未同步 +- **异常描述**:实例 A 重登写入 DB,实例 B 仍持旧 token。 +- **系统行为**:B 收到 410 → 读 DB 最新 token;若仍 410 → 抢锁重登(`GET_LOCK('cc_login')`)写 DB。 +- **是否允许降级**:410 重放路径。 +- **是否触发告警**:`warn` 频繁 410 `cc.token_churn`。 +- **用户看到的结果**:同 §5.10。 + +### 7.3 raw_hash 碰撞 +- **异常描述**:不同邮件 hash 相同(极低概率)。 +- **系统行为**:幂等顺序:Message-ID → folder+uid → raw_hash;hash 命中时若 Message-ID 不同则**不合并**,改用 folder+uid 新建并 `error` `idempotency.hash_collision`。 +- **是否允许降级**:分叉新建。 +- **是否触发告警**:`error`(必告警)。 +- **用户看到的结果**:两条独立邮件记录。 + +### 7.4 同一 Message-ID 被多次处理 +- **异常描述**:重复投递/重复拉取。 +- **系统行为**:`UNIQUE(message_id)` 插入失败 → 吞掉;若需更新 `\Seen` 则只做 IMAP。 +- **是否允许降级**:幂等跳过。 +- **是否触发告警**:`info` `idempotency.msgid_dup`。 +- **用户看到的结果**:仍一条。 + +--- + +## 8. 并发一致性与锁策略 + +### 8.1 多 worker 锁竞争失败 +- **异常描述**:`GET_LOCK('imap_poll', 0)` 返回 0。 +- **系统行为**:本轮直接退出,等待下轮;不报错为故障。 +- **是否允许降级**:是(正常互斥)。 +- **是否触发告警**:连续 30 轮拿不到锁且无其他实例心跳 → `error` `imap.lock_stuck`(锁持有方崩溃未释放极罕见,MySQL 连接断即释放)。 +- **用户看到的结果**:无。 + +### 8.2 同一柜号多封邮件同时确认 +- **异常描述**:两封 PENDING 邮件含同柜,两运营同时提交。 +- **系统行为**:提交事务内:`SELECT ... FOR UPDATE` 锁 `container_import` 占位行 `UNIQUE(container_no) WHERE status IN ('IMPORTING','SUCCESS')` 或等价唯一活跃索引;第二人冲突检测失败阻断;第一人 CC 成功后第二人必拦。 +- **是否允许降级**:后提交失败。 +- **是否触发告警**:`warn` `conflict.cross_mail_container`。 +- **用户看到的结果**:「柜号已被邮件#{id}占用/已存在」。 + +### 8.3 多 Admin 同时改类型并提交 +- **异常描述**:改类型+导入竞态。 +- **系统行为**:`version` 乐观锁;导入前再次校验 `mail_type=NEW_CONTAINER`;类型已变则 409。 +- **是否允许降级**:刷新重来。 +- **是否触发告警**:`warn`。 +- **用户看到的结果**:版本冲突提示。 + +### 8.4 补偿队列同一柜重复重试 +- **异常描述**:调度器重复投递补偿任务。 +- **系统行为**:补偿执行前锁 `container_import`;已 SUCCESS 跳过;`IMPORTING` 跳过;用 `retry_token` 幂等。 +- **是否允许降级**:跳过。 +- **是否触发告警**:`info`。 +- **用户看到的结果**:单次结果。 + +--- + +## 9. 数据计算安全 + +### 9.1 CHANNEL_UNMAPPED 未阻断 +- **异常描述**:未知渠道仍提交(见 1.9)。 +- **系统行为**:确认页强制展示警告计数;提交 API 若存在 UNMAPPED 且未传 `ack_unmapped_channels=true` → 400 要求确认。 +- **是否允许降级**:用户显式确认后放行。 +- **是否触发告警**:`warn`。 +- **用户看到的结果**:二次确认「存在未映射渠道,仍要提交?」。 + +### 9.2 浮点精度(体积/重量) +- **异常描述**:Excel 浮点噪声。 +- **系统行为**:CBM/Weight 存 DECIMAL(18,4);展示与提交前 `round(value,4)`;不在服务端做单位换算乘法链。 +- **是否允许降级**:四舍五入。 +- **是否触发告警**:否。 +- **用户看到的结果**:最多 4 位小数。 + +### 9.3 日期时区错误 +- **异常描述**:解析为 UTC 导致差一天。 +- **系统行为**:所有业务日期按 `Asia/Shanghai` 日历日;写入 CC 格式 `YYYY-MM-DD`;`received_at` 存 UTC 瞬时并 UI 转上海。 +- **是否允许降级**:固定时区。 +- **是否触发告警**:跨日边界单测覆盖;运行时否。 +- **用户看到的结果**:与邮件 ETA 日历日一致。 + +### 9.4 F_ShippingLineId 匹配失败 +- **异常描述**:船名无法匹配。 +- **系统行为**:留空;确认页可选船司;**不阻断**提交(字段非 SaveContainer 必填)。 +- **是否允许降级**:是。 +- **是否触发告警**:`info`。 +- **用户看到的结果**:「未匹配船司,可手动选择」。 + +### 9.5 勾选总数与提交数不一致 +- **异常描述**:前端展示 n,实际 payload m。 +- **系统行为**:服务端以请求体 `shipment_ids[]` 为准再查 `parse_result`;过滤非 VALID/非本 mail;响应回传 `accepted_count`;与前端 n 不符则 UI 以服务端为准刷新。 +- **是否允许降级**:服务端权威。 +- **是否触发告警**:不一致 `warn` `import.count_mismatch`。 +- **用户看到的结果**:Toast「实际提交 {accepted_count} 行」。 + +--- + +## 10. 权限与安全控制 + +### 10.1 未登录访问 API +- **异常描述**:无 session 调 `/api/mails`、`/api/import`。 +- **系统行为**:统一 401;不返回业务数据。 +- **是否允许降级**:否。 +- **是否触发告警**:暴力 401 计数 `security.unauthorized`。 +- **用户看到的结果**:跳转登录。 + +### 10.2 越权查看(一期单租户) +- **异常描述**:伪造 mail_id 扫库。 +- **系统行为**:一期单租户:登录即可访问全部邮件;禁止未登录;二期按 `sender_customer_map`/客户隔离时校验 `customer_code`。 +- **是否允许降级**:一期 N/A。 +- **是否触发告警**:否(一期)。 +- **用户看到的结果**:正常列表(仅登录用户)。 + +### 10.3 Admin 改类型未授权 +- **异常描述**:`ENABLE_TYPE_OVERRIDE=false` 但前端暴露按钮;或普通用户调 API。 +- **系统行为**:**服务端**校验 `role=admin AND ENABLE_TYPE_OVERRIDE`;失败 403;前端按同条件隐藏(不可作唯一防线)。 +- **是否允许降级**:否。 +- **是否触发告警**:`warn` `security.type_override_denied`。 +- **用户看到的结果**:无按钮或 403。 + +### 10.4 伪造 loginMark / CC token +- **异常描述**:客户端上传假 token 想直打 CC。 +- **系统行为**:浏览器**永不**持有 CC token;仅服务端 Adapter 使用;用户会话与 CC 凭证隔离。 +- **是否允许降级**:否。 +- **是否触发告警**:出现客户端传 cc_token 字段 → `error` `security.cc_token_probe`。 +- **用户看到的结果**:忽略非法字段。 + +### 10.5 批量刷导入接口 +- **异常描述**:脚本对多邮件狂打确认。 +- **系统行为**:用户级限流:确认导入 **10 次/分钟**;同 mail 条件更新防重;超限 429。 +- **是否允许降级**:限流。 +- **是否触发告警**:`warn` `security.rate_limit_import`。 +- **用户看到的结果**:「操作过于频繁」。 + +### 10.6 审计(一期不做) +- **现行口径**:不建 `audit_log`、无 Admin 审计页;关键操作依赖应用日志与 `/logs`。 +- **系统行为**:原写审计点可空实现,不阻断主流程。 +- **是否允许降级**:是(已降级为不做)。 +- **用户看到的结果**:无审计查询入口。 + + +## 11. 监控指标与告警阈值(落地用) + +| 指标 | 级别 | 阈值 | +|---|---|---| +| `imap.poll_fail` 连续 | error | ≥3 轮 | +| `cc.login_fail` | error | ≥1/5min | +| `cc.ambiguous_success` | error | ≥1 | +| `state.stale_parsing` | error | ≥1 | +| `state.importing_timeout` | error | ≥1 | +| `compensation.exhausted` | error | ≥1 | +| `idempotency.hash_collision` | error | ≥1 | +| `parse.high_invalid_ratio` | warn | 单邮件 INVALID>30% | +| `import.contention` | warn | 同 mail 409>3/min | +| `security.rate_limit_import` | warn | 触发即记 | + +一期告警出口:应用日志 + 管理页顶栏;企微后置。 + +--- + +## 12. QA 异常用例索引(按本章) + +| 编号 | 对应用例要点 | +|---|---| +| A-1.x | 无主题/无正文/无附件/坏格式/缺列/坏柜号/半空行/20MB+/未映射渠道/坏数字日期 | +| A-2.x | 双击提交、双标签 version、刷新恢复、回退、误改类型 | +| A-3.x | IMAP 断网、CC 超时、模糊成功核对 | +| A-4.x | login 5xx、Save 400、List 超时阻断、410 重放 | +| A-5.x | 坏 MIME、坏 zip、sheet 回退、1001 行软限、柜号冲突、重新解析 | +| A-6.x | Seen 失败去重、卡 PARSING 回收、IMPORTING 超时 | +| A-7.x | token DB 权威、Message-ID 重复 | +| A-8.x | 双 worker 锁、双邮件同柜 | +| A-9.x | unmapped 需 ack、时区、accepted_count | +| A-10.x | 401/403 改类型、429 限流、无 CC token 出前端 | + +### 12.1 自动化覆盖(2026-08-12) + +跑法:`pnpm test`(单测/集成)+ `pnpm test:e2e tests/e2e/resilience-auth.spec.ts`(需本机可起 web)。 + +| 编号 | 覆盖 | 自动化入口 | 真人/联调仍需 | +|---|---|---|---| +| A-1.1 空主题 | ✅ | `resilience-scenarios` + `resilience-metrics` | UI 列表展示「(无主题)」 | +| A-1.2/1.3 无正文无附件 / NEW 无清单 | ✅ | `resolveNoSpreadsheetOutcome` | 详情文案 | +| A-1.4 非表格附件 | ✅ | 同上 | 重新解析按钮 | +| A-1.5 缺列 | ✅ | packing-list | — | +| A-1.6 坏柜号 ISO | ✅ | iso6346 + scenarios | Admin 改柜号再校验 | +| A-1.7 半空行 INVALID | ✅ | packing-list scenarios | 确认页灰显 | +| A-1.8 >20MB | ⚠️ 规则在 pipeline | 未造 20MB fixture | 真附件拒收 + `\Seen` | +| A-1.9 未映射渠道 | ✅ | channel-map + packing-list | 确认页黄标 + ack | +| A-1.10 体积重量单位 | ✅ | packing-list | — | +| A-1.11 日期/地址截断 | ✅ | dates + packing-list | — | +| A-2.1/2.2 连点/并发 | ✅ 门禁+409 码 | forecast-gate + ConfirmImportError | 真双击/双请求打 DB CAS | +| A-2.3 刷新恢复 | ⚠️ | 无独立 E2E | 导入中刷新看 DB 状态 | +| A-2.4 回退确认页 | ⚠️ | 无独立 E2E | 浏览器前进后退 | +| A-2.5 version 冲突 | ✅ 错误码 | ConfirmImportError | 双标签真打 | +| A-2.6 误改类型 | ⚠️ API 有 MAIL_BUSY | type/route | Admin 二次确认弹窗 | +| A-3.1 IMAP 超时退避 | ✅ 阈值/退避公式 | runtime-status | 真断网轮询 | +| A-3.2/3.3 模糊成功窗口 | ✅ 10min 窗口 | compensation.isRecentlyCreated | live GetContainerList | +| A-3.4 弱网重试 | ⚠️ 依赖幂等键 | import CAS | 代理重放 POST | +| A-3.5 chunk 404 | ❌ | 无 | 强刷 error boundary | +| A-4.1 login token 解析 | ✅ extractLoginToken | auth.ts | live 5xx/410 | +| A-4.2 Save 410 重放 1 次 | ⚠️ http.ts 有 retried410 | 无单测打真实 410 | live token 过期 | +| A-4.3 Save 400 | ⚠️ mock 冲突 | cc-save + force-import | live 业务 400 文案 | +| A-4.4 List 超时阻断 | ⚠️ 代码默认阻断 | force-import | live timeout | +| A-4.5 schema drift | ✅ token/id 宽松解析 | extractLoginToken | 缺 F_Id → TIMEOUT_UNKNOWN | +| A-5.1 坏 MIME | ⚠️ pipeline MIME_PARSE_ERROR | 无坏 eml fixture | 重新解析+下载快照 | +| A-5.2 zip 一层/嵌套/穿越 | ✅ | zip-extract | — | +| A-5.3 sheet 回退 | ✅ | packing-list sheetFallback | — | +| A-5.4 表头失败 | ✅ 同 1.5 | — | — | +| A-5.5 软/硬行数 | ✅ hardLimit/softLimit | packing-list | 确认页虚拟滚动 UI | +| A-5.6 柜号冲突 | ⚠️ 规则有 | pipeline lineage | 确认页必须选定柜号 | +| A-5.7 类型 close call | ⚠️ classify 有样例 | classify.test | 证据面板 | +| A-5.8 重新解析门禁 | ✅ 状态机 | state-machine | 点按钮 | +| A-6.1 Seen 失败去重 | ❌ 需 IMAP | — | lookback 重试 Seen | +| A-6.2 卡 PARSING 回收 | ✅ 允许 PARSING→FETCHED | state-machine | worker reaper 真跑 | +| A-6.3 IMPORTING 超时 | ✅ 允许 IMPORTING→FAILED | state-machine | reaper + 补偿行 | +| A-6.4 解析中改类型 | ⚠️ API MAIL_BUSY | type/route | 并发点改类型 | +| A-6.5 补偿耗尽 | ⚠️ 代码有 EXHAUSTED | compensation | Admin 手工重置 | +| A-6.6 非法迁移 | ✅ | state-machine | — | +| A-7.1/7.2 token DB 权威 | ⚠️ 实现有 | 无多实例测 | 双 web 410 | +| A-7.3 hash 碰撞分叉 | ✅ | hash + resilience-metrics | — | +| A-7.4 Message-ID 重复/截断 | ✅ truncate 191 | snapshot | UNIQUE 插入失败 | +| A-8.1 双 worker 锁 | ❌ 需双进程+MySQL | db-lock | 双 worker lock_busy | +| A-8.2 双邮件同柜 | ⚠️ conflict mock | cc-save | 两运营同时确认 | +| A-8.3 改类型+导入竞态 | ⚠️ version | ConfirmImportError | — | +| A-8.4 补偿重复投递 | ⚠️ retry_token | compensation | worker 双 tick | +| A-9.1 unmapped ack | ✅ 错误码 + warning | ConfirmImportError | 确认页二次确认 | +| A-9.2 round4 | ✅ | dates | — | +| A-9.3 时区 | ✅ | hash-dates | — | +| A-9.4 船司匹配失败不阻断 | ⚠️ extract-header | extract-header.test | 确认页手选 | +| A-9.5 accepted_count/hash | ✅ shipments_hash | hash | Toast | +| A-10.1 未登录 401 | ✅ E2E | resilience-auth.spec | — | +| A-10.2 单租户 | N/A 一期 | — | — | +| A-10.3 改类型 403 | ⚠️ requireAdmin + ENABLE_TYPE_OVERRIDE | type/route | ops 调 API | +| A-10.4 前端无 CC token | ⚠️ 架构约束 | 无扫 bundle | Network 面板 | +| A-10.5 导入/登录限流 | ✅ 单测 + E2E 登录 429 | rate-limit + e2e | 多副本无效 | +| A-10.6 审计一期不做 | N/A | — | — | + +**图例**:✅ 自动化已断言 · ⚠️ 代码有路径、缺完整 fixture/联调 · ❌ 尚未自动化(需 IMAP/双进程/真人 UI) diff --git a/docx/技术设计方案-邮件自动预报-v1.0.md b/docx/技术设计方案-邮件自动预报-v1.0.md new file mode 100644 index 0000000..c4db54b --- /dev/null +++ b/docx/技术设计方案-邮件自动预报-v1.0.md @@ -0,0 +1,650 @@ +# 技术设计方案 v1.0(邮件自动预报系统) + +> 状态:可直接开发(**2026-07-23 现行口径回写**;**2026-08-03 指令拆分产品规则锁定**) +> 依据:`docx/需求规格-邮件自动预报-v0.2.md`、`docx/异常场景与系统韧性设计.md`、**`docx/产品规则-邮件指令识别与拆分.md`** +> 栈锁定:Next.js App Router + MySQL 8 + docker-compose(`web` / `worker` / `mysql`) +> **回写要点**:`src/worker`;IMAP lookback N 天;无 audit_log;路由 `/confirm` `/logs` `/settings`;本地 MySQL **7023**;seed 仅账号;详见 `docx/现行口径-修订说明-v1.md` +> **指令口径**:主题/正文/附件分工、仅最新段可确认、软词→客户指令 — 见产品规则页;实现见 `instruction-lexicon.ts` / `split-instructions.ts`(**无 LLM**) + +--- + +## 1. 细化技术选型 + +### 1.1 ORM + +| 维度 | Prisma | Drizzle | TypeORM | +|---|---|---|---| +| AI 生成 Schema | 高(schema 声明式) | 高(TS 表定义) | 中 | +| 类型安全 | 生成客户端强类型 | 推断强 | 装饰器弱于前两者 | +| 迁移 | `prisma migrate` 成熟 | `drizzle-kit` 可用 | 易踩坑 | +| JSON 字段 | 原生支持 | 原生支持 | 一般 | + +**唯一推荐:Prisma 5.x** +理由:Cursor 生成/改 schema 成功率最高;迁移命令固定;与 Next.js 官方示例一致;`Json` 字段直接对应 `type_evidence` / `shipments`。 + +### 1.2 UI 组件库 + +| 维度 | shadcn/ui | Ant Design | MUI | +|---|---|---|---| +| 客服后台 | 中(需自拼表格) | 高(Table/Form 开箱) | 高 | +| 虚拟滚动 327+ | `@tanstack/react-virtual` 自接 | `Table virtual` 文档成熟 | 需 DataGrid Pro 或自接 | +| 定制 | 最高 | 中 | 中 | +| 包体/样式冲突 | 低 | 中 | 中 | + +**唯一推荐:Ant Design 5.x + `@tanstack/react-virtual`(仅确认页货件表)** +理由:列表/筛选/Form/Modal 一次齐;确认页用 TanStack Virtual 包 Antd Table 行,避免 Pro 授权;后台信息密度匹配运营场景。 + +### 1.3 Worker + +| 维度 | setInterval 脚本 | BullMQ | PM2 only | +|---|---|---|---| +| 对齐 60s 轮询 | 直接 | 过重 | 仅进程管理 | +| `GET_LOCK` 互斥 | 业务内实现 | 队列锁重复 | 不解决业务锁 | +| compose 复杂度 | 低(第二容器) | 需 Redis | 需宿主机 PM2 | + +**唯一推荐:独立 Node 入口 `worker/index.ts` + `setInterval(POLL_INTERVAL_MS)` + MySQL `GET_LOCK('imap_poll', 0)`** +理由:PRD 明确单连接轮询,无任务扇出;不加 Redis;docker 内 `node dist/worker.js` 常驻即可。Compose 用 `restart: unless-stopped`,不用 PM2。 + +### 1.4 其余锁定 + +| 项 | 选型 | +|---|---| +| 语言 | TypeScript 5.x strict | +| 运行时 | Node.js 20 LTS | +| Next | 15.x App Router | +| 鉴权(一期) | 自建 session:`iron-session` + 环境变量账号;Cookie HttpOnly | +| HTTP(CC) | `undici`(Node 原生 fetch 封装层 `CcHttpClient`) | +| IMAP | `imapflow` | +| 邮件解析 | `mailparser` | +| xlsx | `exceljs` | +| zip | `adm-zip`(只解一层) | +| 密码 MD5 | `crypto.createHash('md5')` | +| 校验 | `zod`(API 入参 + 解析中间态) | +| 日志 | `pino` | +| 包管理 | `pnpm` | +| 测试 | `vitest`(单元)+ `playwright`(E2E,M4+) | + +--- + +## 2. 系统架构设计 + +### 2.1 总体架构(文字) + +``` +imap.qq.com:993 + │ SINCE N天(已读+未读) / STORE \Seen + ▼ +┌────────────────── worker 容器 ──────────────────┐ +│ ImapPoller → SnapshotStore → ParsePipeline │ +│ │ │ │ │ +│ └──────────────┴──────────────┘ │ +│ ▼ │ +│ Prisma → MySQL │ +│ (FETCHED→PARSING→PARSED|PENDING_CONFIRM|…) │ +└─────────────────────────────────────────────────┘ + ▲ + │ 读写同一库 +┌────────────────── web 容器 ─────────────────────┐ +│ Next.js App Router │ +│ UI: 列表/详情/确认导入/日志 │ +│ Route Handlers: /api/* │ +│ ImportService → ConflictCheck → CcAdapter │ +│ │ │ │ +│ │ ▼ │ +│ │ test.saas.carriercentral.vip│ +│ │ customerLogin / SaveContainer│ +│ │ GetContainerList / ShipLine │ +│ └────────── MySQL (status/import/audit) ──┘ +└─────────────────────────────────────────────────┘ +``` + +**硬边界:** +- **人工确认路径**(`NEW_CONTAINER`):仅 Web API 在用户点击确认后调用 `SaveContainer`。 +- **自动执行路径**(`TRANSFER` / `INSTRUCTION_HOLD_SPLIT` / `INSTRUCTION_LABEL`):允许 worker/executor 调用对应 CC 写接口,受 `ENABLE_AUTO_EXEC_*` + `cc_write_capability`(live|mock|off)+ 审计约束;mock ≠ TEST 验收。 +- 禁止对超时/未知态盲重放写接口。 + +### 2.2 模块划分 + +| 模块 | 路径 | 输入 | 输出 | +|---|---|---|---| +| IMAP 拉取 | `src/worker` + `src/services/imap` | 设置页 mailbox + IMAP_LOOKBACK_DAYS | `mail_message` FETCHED + 快照/附件 | `mail_message` FETCHED + 快照/附件 | +| 解析引擎 | `src/services/parse` | mail_id | `mail_type` + `parse_result` + 新 status | +| 类型判定 | `src/services/parse/classify.ts` | subject/body/filenames | `MailType` + `type_evidence` | +| 卡派解析 | `src/services/parse/packing-list.ts` | xlsx/csv buffer | header + shipments[] | +| 状态机 | `src/services/state-machine.ts` | from/to/guard | 合法迁移或抛 `IllegalTransition` | +| CC Adapter | `src/services/cc` | 业务 DTO | CC 响应;token 缓存 | +| 确认导入 | `src/services/import` | mail_id + 勾选行 + 柜头 | `container_import` + 终态 | +| 补偿重试 | `src/services/compensation` | compensation id | 重试 SaveContainer | +| 前端 | `src/app/(ops)/*` | — | 运营 UI | + +### 2.3 数据流 + +``` +1. ImapPoller.GET_LOCK('imap_poll') +2. SEARCH SINCE N天 → 过滤已入库 UID → FETCH → 写 data/mails/{id}/raw.eml + attachments +3. INSERT mail_message status=FETCHED(幂等:message_id / folder+uid / raw_hash) +4. STORE \Seen(失败仅记日志,下轮补 Seen) +5. status→PARSING → classify + packing-list +6. NEW + valid rows≥1 → PENDING_CONFIRM;否则 PARSED / PARSE_FAILED / REJECTED_VALIDATION +7. 用户 POST /api/mails/:id/import +8. CAS: PENDING_CONFIRM→IMPORTING(version 校验) +9. GetContainerList 冲突检测 → 失败则回滚可编辑态并返回 CONFLICT +10. 按柜 SaveContainer(一柜一请求;货件=勾选行) +11. 全成功 SUCCESS;多柜部分 PARTIAL_SUCCESS;失败 FAILED + import_compensation +``` + +### 2.4 第三方依赖 + +| 依赖 | 用途 | 超时 | +|---|---|---| +| `imap.qq.com:993` | 拉取/标已读 | connect 30s / read 60s | +| `https://test.saas.carriercentral.vip/api` | CC | HTTP 60s | +| 本地 `./data` | 快照与附件 | — | +| exceljs / adm-zip | 清单解析 | 单附件 ≤20MB | + +--- + +## 3. 数据库设计 + +Prisma schema 落地;下列 SQL 语义等同。字符集 `utf8mb4`,引擎 InnoDB。 + +### 3.1 `mail_message` + +```sql +CREATE TABLE mail_message ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + message_id VARCHAR(998) NULL, + imap_uid BIGINT UNSIGNED NULL, + folder VARCHAR(64) NOT NULL DEFAULT 'INBOX', + subject VARCHAR(998) NOT NULL DEFAULT '(无主题)', + from_addr VARCHAR(320) NOT NULL DEFAULT '', + received_at DATETIME(3) NULL, + body_text MEDIUMTEXT NULL, + mail_type VARCHAR(32) NOT NULL DEFAULT 'UNKNOWN', + status VARCHAR(32) NOT NULL DEFAULT 'FETCHED', + type_evidence JSON NULL, + snapshot_path VARCHAR(512) NULL, + raw_hash CHAR(64) NULL, + last_error VARCHAR(1000) NULL, + version INT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + UNIQUE KEY uk_message_id (message_id), + UNIQUE KEY uk_folder_uid (folder, imap_uid), + UNIQUE KEY uk_raw_hash (raw_hash), + KEY idx_status_received (status, received_at), + KEY idx_mail_type (mail_type) +); +``` + +`mail_type` 枚举值:`NEW_CONTAINER|TRANSFER|INSTRUCTION_HOLD_SPLIT|INSTRUCTION_LABEL|WORK_ORDER|UNKNOWN` +工单关键词(贴标/拦截/拍照/转仓/快递单号)覆盖为 `WORK_ORDER`;解析产出 `parse_result.mail_record`(提单 `BL_FORECAST` / 工单表);**本期工单不 auto-exec**。 +`status` 枚举值:PRD §6.3 全量。 + +### 3.2 `mail_attachment` + +```sql +CREATE TABLE mail_attachment ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + mail_id BIGINT UNSIGNED NOT NULL, + filename VARCHAR(512) NOT NULL, + content_type VARCHAR(128) NULL, + sha256 CHAR(64) NOT NULL, + path VARCHAR(512) NOT NULL, + size INT UNSIGNED NOT NULL, + rejected TINYINT(1) NOT NULL DEFAULT 0, + template_id VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + KEY idx_mail (mail_id), + CONSTRAINT fk_att_mail FOREIGN KEY (mail_id) REFERENCES mail_message(id) +); +``` + +### 3.3 `parse_result` + +```sql +CREATE TABLE parse_result ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + mail_id BIGINT UNSIGNED NOT NULL, + container_header JSON NOT NULL, + shipments JSON NOT NULL, + lineage JSON NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + UNIQUE KEY uk_mail (mail_id), + CONSTRAINT fk_parse_mail FOREIGN KEY (mail_id) REFERENCES mail_message(id) +); +``` + +`shipments[]` 元素契约: + +```ts +{ + row_index: number; + row_status: 'VALID' | 'INVALID'; + invalid_reasons?: string[]; + warnings?: string[]; // e.g. CHANNEL_UNMAPPED + F_FBACode?: string; + F_Transporter?: string; + F_CTNS?: number; + F_CBM?: number | null; + F_Weight?: number | null; + F_FBAID?: string | null; + F_ReferenceId?: string | null; + F_ShipmentID?: string | null; + F_Address?: string | null; + F_Expected_DeliveryDateB?: string | null; // YYYY-MM-DD + F_Expected_DeliveryDateE?: string | null; + F_Remark?: string | null; + channel_raw?: string; +} +``` + +`container_header` 契约:含 `F_ContainerNo`, `container_no_valid`, `F_CabinetType`, `F_ETA`, `F_ETD`, `F_BLCopyCode`, `F_Classis`, `F_ShippingLineId`, `F_MemoRemark`, `F_Instruction`, 推荐 `F_TransMode`/`F_OperationType`。 + +### 3.4 `container_import` + +```sql +CREATE TABLE container_import ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + mail_id BIGINT UNSIGNED NOT NULL, + container_no VARCHAR(50) NOT NULL, + external_id VARCHAR(64) NULL, + request_body MEDIUMTEXT NULL, + response_body MEDIUMTEXT NULL, + status VARCHAR(32) NOT NULL, -- PENDING|IMPORTING|SUCCESS|FAILED|CONFLICT|TIMEOUT_UNKNOWN + last_error VARCHAR(1000) NULL, + shipments_hash CHAR(64) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + KEY idx_mail (mail_id), + KEY idx_container (container_no), + UNIQUE KEY uk_mail_container_shiphash (mail_id, container_no, shipments_hash), + CONSTRAINT fk_imp_mail FOREIGN KEY (mail_id) REFERENCES mail_message(id) +); +``` + +活跃柜占位(应用层):导入前插入/更新 `status IN ('IMPORTING','SUCCESS')` 同行柜号冲突检查;另建: + +```sql +CREATE TABLE container_active_lock ( + container_no VARCHAR(50) NOT NULL PRIMARY KEY, + mail_id BIGINT UNSIGNED NOT NULL, + import_id BIGINT UNSIGNED NULL, + locked_at DATETIME(3) NOT NULL +); +``` + +### 3.5 `import_compensation` + +```sql +CREATE TABLE import_compensation ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + import_id BIGINT UNSIGNED NOT NULL, + retry_count INT UNSIGNED NOT NULL DEFAULT 0, + max_retry INT UNSIGNED NOT NULL DEFAULT 3, + next_retry_at DATETIME(3) NULL, + reason VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'OPEN', -- OPEN|DONE|EXHAUSTED + retry_token CHAR(36) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + UNIQUE KEY uk_retry_token (retry_token), + KEY idx_open (status, next_retry_at), + CONSTRAINT fk_comp_imp FOREIGN KEY (import_id) REFERENCES container_import(id) +); +``` + +### 3.6 `cc_token_cache` + +```sql +CREATE TABLE cc_token_cache ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + login_mark CHAR(36) NOT NULL, + token VARCHAR(128) NOT NULL, + expire_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + UNIQUE KEY uk_login_mark (login_mark) +); +``` + +### 3.7 `audit_log`(**一期不做 / 表已移除**) + +> 现行口径:不落库、无 Admin 查询页。以下 DDL 仅作历史参考。 + +### 3.7.1 (历史)`audit_log` + +```sql +CREATE TABLE audit_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + actor VARCHAR(64) NOT NULL, + action VARCHAR(64) NOT NULL, + mail_id BIGINT UNSIGNED NULL, + payload JSON NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + KEY idx_mail_time (mail_id, created_at) +); +``` + +### 3.8 其余 + +```sql +CREATE TABLE sender_customer_map ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + match_type VARCHAR(32) NOT NULL, -- EMAIL|DOMAIN|SUBJECT|ATTACHMENT + match_value VARCHAR(320) NOT NULL, + customer_code VARCHAR(64) NOT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, + UNIQUE KEY uk_match (match_type, match_value) +); + +CREATE TABLE app_user ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(64) NOT NULL UNIQUE, + password_hash VARCHAR(128) NOT NULL, + role VARCHAR(16) NOT NULL DEFAULT 'ops', -- ops|admin + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) +); +``` + +--- + +## 4. API 设计 + +Base:`/api`。鉴权:未登录 → `401`。成功包络: + +```ts +{ ok: true, data: T } +{ ok: false, error: { code: string, message: string, details?: unknown } } +``` + +### 4.1 认证 + +| 方法 | 路径 | 入参 | 出参 | 错误 | +|---|---|---|---|---| +| POST | `/api/auth/login` | `{username,password}` | `{role}` + Set-Cookie | `401 INVALID_CREDENTIALS` | +| POST | `/api/auth/logout` | — | `{ok:true}` | — | +| GET | `/api/auth/me` | — | `{username,role}` | `401` | + +### 4.2 邮件列表 + +| 方法 | 路径 | 入参 | 出参 | +|---|---|---|---| +| GET | `/api/mails` | query: `page=1&pageSize=20&mail_type=&status=&q=` | `{items: MailListItem[], total}` | + +`MailListItem`:`id,subject,from_addr,received_at,mail_type,status,container_no?` + +错误:`400 VALIDATION` + +### 4.3 邮件详情 + +| 方法 | 路径 | 出参 | +|---|---|---| +| GET | `/api/mails/:id` | `MailDetail`:邮件字段 + `type_evidence` + `parse_result` + `attachments[]` + `imports[]` | +| GET | `/api/mails/:id/attachments/:attId/download` | file stream | + +错误:`404 NOT_FOUND` + +### 4.4 确认导入 + +| 方法 | 路径 | 入参 | 出参 | +|---|---|---|---| +| POST | `/api/mails/:id/import` | 见下 | `{mail_status, imports[], accepted_count}` | + +```ts +// body +{ + version: number; + idempotency_key: string; // mail_id:container_no:shipments_hash 或整单 UUID + F_TransMode: 0|1|3; + F_OperationType: 0|2|4; + container_header: { /* 可覆盖字段 */ }; + selected_row_indexes: number[]; + ack_unmapped_channels?: boolean; + force_skip_conflict?: boolean; // 需 admin + ENABLE_FORCE_IMPORT +} +``` + +| 错误码 | 含义 | +|---|---| +| `409 ALREADY_IMPORTING` | CAS 失败 | +| `409 VERSION_CONFLICT` | version 不匹配 | +| `400 ACK_UNMAPPED_REQUIRED` | 未确认未映射渠道 | +| `409 CONFLICT_CONTAINER` | GetContainerList / 本地锁冲突 | +| `503 CONFLICT_CHECK_FAILED` | 列表接口失败且未强跳 | +| `429 RATE_LIMITED` | >10/min | +| `403 FORBIDDEN` | 非 NEW / 无权限强跳 | +| `400 INVALID_ROWS` | 勾选含 INVALID | + +限流:每用户 10 次/分钟(内存滑动窗或 DB)。 + +### 4.5 Admin 改类型 + +| 方法 | 路径 | 入参 | 条件 | +|---|---|---|---| +| POST | `/api/mails/:id/type` | `{version, mail_type, reason}` | `role=admin` 且 `ENABLE_TYPE_OVERRIDE=true` | + +成功:更新 `mail_type`;若 NEW 且 valid≥1 → `PENDING_CONFIRM`;写 `audit_log action=TYPE_OVERRIDE`。 +错误:`403` / `409 MAIL_BUSY` / `409 VERSION_CONFLICT` / `409 IMMUTABLE_STATUS` + +### 4.6 重新解析 + +| 方法 | 路径 | 条件 | +|---|---|---| +| POST | `/api/mails/:id/reparse` | status ∉ `IMPORTING|SUCCESS|PARTIAL_SUCCESS` | + +行为:status→`PARSING`;worker 或 web 内同步调用 `ParsePipeline`(一期 **web 同步执行解析** 亦可;生产可投递 DB 标志位由 worker 捞——**一期锁定:API 进程内直接跑 ParsePipeline**,与 worker 共用 `src/services/parse`)。 +错误:`409 ILLEGAL_TRANSITION` + +### 4.7 补偿重试 + +| 方法 | 路径 | 入参 | +|---|---|---| +| GET | `/api/imports` | `page,status,mail_id` | +| POST | `/api/imports/:importId/retry` | `{retry_token?}` | +| POST | `/api/compensations/:id/retry` | — admin 可重置 exhausted | + +行为:锁 `container_import`;SUCCESS 跳过;核对超时单;再 SaveContainer。 +错误:`409` / `400 EXHAUSTED` + +### 4.8 忽略邮件 + +| 方法 | 路径 | +|---|---| +| POST | `/api/mails/:id/ignore` | admin → `IGNORED` + audit | + +### 4.9 健康检查 + +| GET `/api/health` | `{mysql:ok, imap_lock?:string}` | 无鉴权 | + +--- + +## 5. 项目工程结构 + +``` +email-forecast/ +├── docker-compose.yml # web + worker + mysql +├── Dockerfile # 同一镜像,不同 CMD +├── .env.example +├── package.json # pnpm workspaces 可不用,单 package +├── prisma/ +│ ├── schema.prisma +│ └── seed.ts # 仅 AppUser(admin/ops) +├── data/ # volume 挂载(gitignore) +├── docx/ # 需求/设计(已有,不进镜像必填) +├── src/ +│ ├── app/ +│ │ ├── (auth)/login/page.tsx +│ │ ├── (ops)/ +│ │ │ ├── layout.tsx +│ │ │ ├── mails/page.tsx # 列表 +│ │ │ ├── mails/[id]/page.tsx # 详情 +│ │ │ ├── mails/[id]/import/page.tsx # 确认导入 +│ │ │ └── imports/page.tsx # 日志/补偿 +│ │ ├── api/... # Route Handlers +│ │ └── layout.tsx +│ ├── components/ +│ │ ├── MailTable.tsx +│ │ ├── ShipmentVirtualTable.tsx # antd + tanstack virtual +│ │ └── StatusTag.tsx +│ ├── services/ +│ │ ├── db.ts # PrismaClient 单例 +│ │ ├── state-machine.ts +│ │ ├── imap/ +│ │ │ ├── client.ts +│ │ │ └── poller.ts +│ │ ├── parse/ +│ │ │ ├── pipeline.ts +│ │ │ ├── classify.ts +│ │ │ ├── packing-list.ts +│ │ │ └── channel-map.ts +│ │ ├── cc/ +│ │ │ ├── http.ts +│ │ │ ├── auth.ts # token DB+memory +│ │ │ ├── save-container.ts +│ │ │ └── conflict.ts +│ │ ├── import/ +│ │ │ ├── confirm.ts +│ │ │ └── compensation.ts +│ │ └── audit.ts +│ ├── types/ +│ │ ├── mail.ts # MailStatus, MailType +│ │ ├── parse.ts +│ │ └── cc.ts +│ ├── utils/ +│ │ ├── hash.ts +│ │ ├── iso6346.ts +│ │ └── dates.ts # Asia/Shanghai +│ └── worker/ +│ └── index.ts # setInterval 入口 +├── tests/ +│ ├── unit/ +│ └── e2e/ +└── README.md +``` + +### docker-compose 对应 + +| 服务 | 镜像/构建 | 命令 | 职责 | +|---|---|---|---| +| `mysql` | `mysql:8.0` | 官方入口 | 库 `email_forecast` | +| `web` | 本 Dockerfile | `pnpm start`(`next start`) | UI + `/api` + 导入/CC | +| `worker` | 同镜像 | `node dist/worker/index.js` 或 `tsx src/worker/index.ts` | 仅 IMAP+解析+PARSING 回收 | + +共享:`./data` volume、同一 `DATABASE_URL`、同一代码树 `src/services/*`。 + +`.env` 键名严格对齐 PRD §9.1:`CC_SAAS_HEADER`、`POLL_INTERVAL_MS`、`ENABLE_TYPE_OVERRIDE` 等。 +Worker 轮询基线优先读 `imap_settings.poll_interval_ms`(设置页,默认 30min);过滤规则见 `imap_filter_rule`;每封写 `imap_pull_log`(≤1000)。 + +--- + +## 6. MVP 实现路径(PRD §10) + +| 里程碑 | 任务拆分(可直接建 Jira) | DoD | +|---|---|---| +| **M0** | 初始化 Next15+Prisma+Antd;`docker-compose.yml`;`.env.example`;`/api/health`;登录页壳 | `docker compose up` 打开登录页;migrate 成功 | +| **M1** | Prisma 全表;`seed.ts` 写入 M1–M4 元数据;列表+详情只读;状态/类型 Tag | 列表 4 条;类型与金样例一致 | +| **M2** | `classify.ts` 规则;`packing-list.ts` 吃邮件2 xlsx;详情展示 327 行虚拟表;种子挂附件路径 | shipments 行数=327;核心列断言通过 vitest | +| **M3** | `CcAdapter` login/token;SaveContainer mock 单测;可选 TEST 烟雾脚本 `scripts/cc-smoke.ts` | mock `code=200`;TEST 有账号则 1 柜成功 | +| **M4** | 确认导入页;CAS+幂等;冲突检测;补偿表+重试 API;Admin 改类型 | 勾选 2 行 → payload 2 货件;冲突阻断;双击不双写 | +| **M5** | GetShippingLineList 下拉;`/logs` 导入+拉取;README;回收定时;**无审计页** | 可演示全流程 | 可演示全流程 | + +**Cursor 编码顺序:** 严格 M0→M5;每里程碑只改该阶段文件。 + +--- + +## 7. 风险与工程约束 + +| 项 | 约束 | +|---|---| +| 附件 | 单文件 ≤20MB;zip 只解一层;路径禁止 `..`;落盘 `data/mails/{mailId}/` | +| IMAP 锁 | 每轮 `SELECT GET_LOCK('imap_poll',0)`;`finally RELEASE_LOCK`;拿不到锁直接 `return` | +| Token | 读路径:DB `expire_at` 权威 → 内存缓存;写路径:`GET_LOCK('cc_login',5)` 后 login 写 DB | +| 虚拟滚动 | `ShipmentVirtualTable` 固定行高 48px;只渲染可视区±10 行;禁止 Antd Table 默认全量 DOM | +| CC 超时 | 禁止盲重放;`TIMEOUT_UNKNOWN` → 补偿 + GetContainerList 核对 | +| 状态机 | 仅 `state-machine.ts` 允许改 `status`;API 禁止通用 PATCH status | +| 韧性条文 | 实现错误处理对照 `docx/异常场景与系统韧性设计.md` 全文 | + +--- + +## 8. 核心模块 I/O 契约(Cursor 直接实现) + +### 8.1 `ParsePipeline.run(mailId: bigint)` + +- 读 `mail_message` + attachments +- 写:`type_evidence`, `mail_type`, `parse_result`, `status`, `last_error` +- 副作用:无 CC 调用 + +### 8.2 `ImapPoller.tick()` + +- 锁 → fetch → upsert mail → seen → 对每个新 id 调 `ParsePipeline` +- 另:回收 `PARSING` 超时、`IMPORTING` 超时扫描(只改状态/写补偿,不调 CC) + +### 8.3 `ConfirmImport.execute(input)` + +- 校验 NEW + version + 勾选 +- CAS status +- conflict check +- 逐柜 `SaveContainer` +- 更新 mail 终态 + audit + +### 8.4 `CcAuth.getToken()` + +- 返回 `{token, loginMark}`;410 路径由 `CcHttpClient.request` 触发 `refresh+replay once` + +--- + +## 9. docker-compose 骨架(落地原文) + +```yaml +services: + mysql: + image: mysql:8.0 + environment: + MYSQL_DATABASE: email_forecast + MYSQL_USER: app + MYSQL_PASSWORD: app + MYSQL_ROOT_PASSWORD: root + ports: ["7023:3306"] # 本地避让宿主机 3306 + volumes: ["mysql_data:/var/lib/mysql"] + command: ["--default-authentication-plugin=mysql_native_password","--character-set-server=utf8mb4"] + web: + build: . + command: ["pnpm","start"] + ports: ["3100:3100"] + env_file: [.env] + volumes: ["./data:/app/data"] + depends_on: [mysql] + worker: + build: . + command: ["node","dist/worker/index.js"] + env_file: [.env] + volumes: ["./data:/app/data"] + depends_on: [mysql] +volumes: + mysql_data: +``` + +--- + +## 10. 文档索引 + +| 文档 | 路径 | +|---|---| +| PRD | `docx/需求规格-邮件自动预报-v0.2.md` | +| 韧性 | `docx/异常场景与系统韧性设计.md` | +| 本设计 | `docx/技术设计方案-邮件自动预报-v1.0.md` | +| 实施计划 | `.cursor/rules/implementation-plan.mdc` | + +--- + +## 附录 A — CC Write API 矩阵(一期扩范围) + +| capability id | 默认 mode | 拟定 endpoint | 触发 mail_type | 完成标准 | +|---|---|---|---|---| +| `save_container` | live | `/Container/SaveContainer` | NEW_CONTAINER(人工确认) | 已有 | +| `transfer` | mock | `/Container/TransferWarehouse` | TRANSFER | live 真接通 + 烟雾 1 次 | +| `hold_split` | mock | `/Container/HoldSplitInstruction` | INSTRUCTION_HOLD_SPLIT | 同上 | +| `label` | mock | `/Container/ApplyLabelInstruction` | INSTRUCTION_LABEL | 同上 | + +表 `cc_write_capability` 可改 mode:`live|mock|off`。`CC_MOCK=true` 时全局按 mock。mock ≠ TEST 验收。 diff --git a/docx/接口/carriercentral客户端通用接口V1.72.md b/docx/接口/carriercentral客户端通用接口V1.72.md new file mode 100644 index 0000000..f74810f --- /dev/null +++ b/docx/接口/carriercentral客户端通用接口V1.72.md @@ -0,0 +1,1628 @@ +# 客户接口 V1.72 + +> 本文档由 `carriercentral客户端通用接口V1.72docx(4).pdf`(共 15 页)全文转换。 +> +> - **结构化正文**:接口 1–13 的协议、地址、参数表、示例、输出、限流均按 PDF 收录。 +> - **附录 A**:`pypdf` 逐页原文抽取,保证文本层零遗漏,可对照查漏。 +> - 字段名处 PDF 抽取产生的异常空格(如 `F_ TransMode`)在结构化表中还原为合法标识符;说明文字按原文语义收录(含原文笔误,如 `F_Email` 说明写作「邮编」)。 + +--- + +## 环境与账号 + +| 项 | 值 | +| --- | --- | +| 客户端 | https://demovip.saas.carriercentral.vip | +| 测试接口环境 | https://demovip.saas.carriercentral.vip | +| 测试客户账号 | fj | +| 测试客户密码 | fj123qwe! | + +--- + +## 更改历史 + +| 版本 | 更改内容 | +| --- | --- | +| V1.3 | 1. 增加接口:获取船司列表
2. 接口 SaveContainer 增加字段:船司,是否带车架,备注
3. 接口 GetContainerList 的数据返回增加字段:船司,是否带车架,备注
4. 接口 GetContainerList 的数据返回增加字段:报关资料,packingList
5. 增加接口:修改集装箱字段值 SaveFieldValue
6. 增加接口:上传文件 upload
7. 接口 SaveContainer 返回集装箱 id 在 data
8. 接口 GetShipmentInfo 返回参数 status 增加状态说明
9. 接口 GetContainerList 返回参数 status 增加状态说明
10. 接口 SaveContainer 输入参数 sR_Shipments 添加字段:城市,州,邮编,联系人,手机号,邮箱
11. 接口 SaveTruckInfo 输入参数 sR_Shipments 添加字段:城市,州,邮编,联系人,手机号,邮箱 | +| V1.4 | 1. 增加接口:下载 POD 和 ClosePage | +| V1.5 | 1. 接口 SaveContainer 输入参数 sR_Shipments 添加字段:品名,英文名称,PCS
2. 接口 GetShipmentList 返回参数 F_ChineseName 变更为 F_Description
3. 增加接口:下载拆柜图片 DownDevanFile | +| V1.6 | 1. GetShipmentInfo 增加 PlanETA 作为预计送达日期,ISATime 更改定义为 ISA 时间。 | +| V1.7 | 1. 集装箱列表接口访问次数限制:1 分钟 1 次
2. 集装箱货件列表接口访问次数限制:1 分钟 1 次
3. 集装箱状态变更日志接口访问次数限制:5 分钟 1 次
4. 集装箱货件信息接口访问次数限制:30 秒 1 次
5. 集装箱拆箱报告:1 分钟 1 次 | +| V1.71 | 1. 集装箱数据导入接口添加字段:柜号id(F_ContainerId) | +| V1.72 | 1. 集装箱列表接口添加字段:提柜供应商(F_SupplierId) | + +--- + +## 目录 + +1. [登录](#1-登录) +2. [获取船司列表](#2-获取船司列表) +3. [集装箱数据导入](#3-集装箱数据导入) +4. [卡派数据导入](#4-卡派数据导入) +5. [集装箱列表](#5-集装箱列表) +6. [集装箱货件列表](#6-集装箱货件列表) +7. [集装箱状态变更日志](#7-集装箱状态变更日志) +8. [集装箱货件信息](#8-集装箱货件信息) +9. [集装箱拆箱报告](#9-集装箱拆箱报告) +10. [修改集装箱字段值](#10-修改集装箱字段值) +11. [上传文件](#11-上传文件) +12. [下载 POD 和 ClosePage](#12-下载-pod-和-closepage) +13. [下载拆柜图片](#13-下载拆柜图片) + +--- + +## 1. 登录 + +| 项 | 值 | +| --- | --- | +| 接口协议 | HTTP/WebApi | +| 请求方式 | POST | +| 请求地址 | https://demovip.saas.carriercentral.vip/api/learun/adms/user/customerLogin | + +### 输入参数 + +| 输入参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| loginMark | string | 是 | GUID 登录设备标识, 由请求端生成, 同一设备要求值不变 | +| Data (json 字符串) / username | string | 是 | 登录账号(客户账号) | +| Data (json 字符串) / password | string | 是 | 登录密码,32 位 MD5 加密后的字符串 | + +### 输入示例 JSON + +```json +{ + "loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", + "Data": "{\"username\":\"登录账号\",\"password\":\"登录密码,32 位 MD5 加密后的字符串\"}" +} +``` + +### 输出参数 + +| 输出参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| code | int | 是 | 200:成功 400:失败 500:异常 410:没有登录信息 | +| info | string | 是 | 成功或者错误消息 | +| data.token | string | 是 | 通讯票据 | + +--- + +## 2. 获取船司列表 + +| 项 | 值 | +| --- | --- | +| 接口协议 | HTTP/WebApi | +| 请求方式 | GET | +| 请求地址 | https://demovip.saas.carriercentral.vip/api/ConfigModule/GetShippingLineList | + +### 输入参数 + +| 输入参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| loginMark | string | 是 | GUID 登录设备标识, 由请求端生成, 同一设备要求值不变,同登录接口保持一致。 | +| token | string | 是 | 通讯票据(由登录接口返回的值) | + +### 输入示例 JSON + +```json +{ + "loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", + "token": "通讯票据(由登录接口返回的值)" +} +``` + +### 输出参数 + +| 输出参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| code | int | 是 | 200:成功 400:失败 500:异常 410:没有登录信息 | +| info | string | 是 | 成功或者错误消息 | +| data | List data | — | 见下表 | + +#### data 内容 + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| F_ID | String(50) | 是 | 船司 ID | +| F_ShippingLineCode | String(20) | 是 | 船司代码 | +| F_ShippingLineName | String(100) | 是 | 船司名 | +| F_Prefix | String(100) | 否 | 前缀 | +| F_DayLow | Int | 是 | 船司 ETA 后提柜时间(示例:1-2 天,1) | +| F_DayHigh | int | 是 | 船司 ETA 后提柜时间(示例:1-2 天,2) | +| F_Remark | String(1000) | 否 | 备注 | +| F_ShippingLine | String(120) | 是 | 船司 | + +--- + +## 3. 集装箱数据导入 + +| 项 | 值 | +| --- | --- | +| 接口协议 | HTTP/WebApi | +| 请求方式 | POST | +| 请求地址 | https://demovip.saas.carriercentral.vip/api/Container/SaveContainer | + +### 输入参数 + +| 输入参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| loginMark | string | 是 | GUID 登录设备标识, 由请求端生成, 同一设备要求值不变 | +| token | string | 是 | 通讯票据(由登录接口返回的值) | +| data | string | 是 | JSON 字符串 | + +### data 内容 + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| keyValue | string | 否 | SR_ContainerTaskEntity 对象主键,修改时必填 | +| entity | SR_ContainerTaskEntity 对象 | — | 见 entity 表 | +| sR_Shipments | List SR_ShipmentEntity 对象 | — | 见 sR_Shipments 表 | + +#### entity(SR_ContainerTaskEntity) + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| F_TransMode | int | 是 | 运输方式:0-海运 1-空运 3-自送 | +| F_OperationType | int | 是 | 业务类型:0-拆柜 2-直送 4-仅清关 | +| F_ContainerNo | string(50) | 是 | 柜号 | +| F_ContainerId | string(50) | 否 | 柜号id | +| F_CabinetType | string(20) | 否 | 柜型:20GP, 40GP,40HQ,40HR,40HC,45HQ,53HQ | +| F_BLCopyCode | string(15) | 否 | 提单号 | +| F_ETD | datetime? | 否 | ETD(格式:2025/04/24 或 2025-04-24) | +| F_ETA | datetime? | 否 | ETA(格式:2025/04/24 或 2025-04-24) | +| F_LoadPort | string(50) | 否 | 起始港 | +| F_Dock | string(50) | 否 | 目的港 | +| F_ShippingLineId | string(50) | 否 | 船司 ID(由船司列表接口返回的值) | +| F_Classis | int | 否 | 是否自带车架 1-是 0-否 | +| F_MemoRemark | string(1000) | 否 | 备注 | +| F_Instruction | string(1000) | 否 | 拆柜指引 | + +#### sR_Shipments(List SR_ShipmentEntity) + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| F_FBACode | string(30) | 是 | 目的代码 | +| F_Transporter | string(30) | 是 | 派送方式:留仓,自提,扣货,TRUCK, UPS,FEDEX,DHL,USPS | +| F_ShipmentID | string(500) | 否 | 客户参考号 Customer Ref No | +| F_Remark | string(1000) | 否 | 行备注 | +| F_FBAID | string(100) | 否 | FBA id | +| F_PO | string(100) | 否 | PO | +| F_ReferenceId | string(500) | 否 | 参考号 | +| F_Address | string(500) | 否 | 详细地址,商业地址需要填写 | +| F_CTNS | int | 是 | 件数 | +| F_CBM | decimal? | 否 | 体积 | +| F_Weight | decimal? | 否 | 重量 | +| F_SKU | string(50) | 否 | SKU | +| F_Description | string(500) | 否 | 品名 | +| F_EnglishName | string(500) | 否 | 英文名称 | +| F_PCS | int | 否 | PCS | +| F_Expected_DeliveryDateB | datetime? | 否 | 最早送仓日期(格式:2025/04/24 或 2025-04-24) | +| F_Expected_DeliveryDateE | datetime? | 否 | 最晚送仓日期(格式:2025/04/24 或 2025-04-24) | +| F_City | string(50) | 否 | 城市 | +| F_State | string(50) | 否 | 州 | +| F_ZipCode | string(50) | 否 | 邮编 | +| F_Contact | string(50) | 否 | 联系人 | +| F_Mobile | string(50) | 否 | 手机号 | +| F_Email | string(300) | 否 | 邮编(原文参数说明) | + +### 输入示例 JSON + +```json +{ + "loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", + "token": "通讯票据(由登录接口返回的值)", + "data": "{\"keyValue\":null,\"entity\":{\"F_TransMode\":0,\"F_OperationType\":0,\"F_ContainerNo\":\"GH25042416\",\"F_CabinetType\":null,\"F_BLCopyCode\":\"\",\"F_ETD\":\"2025-04-01\",\"F_ETA\":\"2025-04-24\",\"F_LoadPort\":\"SHENZHEN\",\"F_Dock\":\"LAX\",\"F_ShippingLineId\":\"c3f80d26-3174-4eba-9460-735ce33771da\",\"F_Classis\":1,\"F_MemoRemark\":\"remarkk\",\"F_Instruction\":\"这是接口创建集装箱\"},\"sR_Shipments\":[{\"F_FBACode\":\"SMF3\",\"F_Transporter\":\"truck\",\"F_ShipmentID\":\"FBA424POST17\",\"F_Remark\":\"备注1\",\"F_FBAID\":\"FBA424PMAN17\",\"F_PO\":null,\"F_ReferenceId\":null,\"F_Address\":null,\"F_CTNS\":50,\"F_CBM\":2.36,\"F_Weight\":135.6,\"F_SKU\":null,\"F_Description\":\"CHina\",\"F_EnglishName\":\"Eng\",\"F_PCS\":10},{\"F_FBACode\":\"ABE2\",\"F_Transporter\":\"扣货\",\"F_ShipmentID\":\"FBA424POST17\",\"F_Remark\":\"备注2\",\"F_FBAID\":\"FBA424PMAN17\",\"F_PO\":\"\",\"F_ReferenceId\":\"\",\"F_Address\":\"\",\"F_CTNS\":30,\"F_CBM\":1.23,\"F_Weight\":108.3,\"F_SKU\":null,\"F_City\":null,\"F_State\":null,\"F_ZipCode\":null,\"F_Contact\":null,\"F_Mobile\":null,\"F_Email\":null,\"F_Expected_DeliveryDateB\":\"2025-04-01\",\"F_Expected_DeliveryDateE\":\"2025-04-01\"}]}" +} +``` + +### 输出参数 + +| 输出参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| code | int | 是 | 200:成功 400:失败 500:异常 410:没有登录信息 | +| info | string | 是 | 成功或者错误消息 | +| data | string | 是 | 集装箱 id | + +--- + +## 4. 卡派数据导入 + +| 项 | 值 | +| --- | --- | +| 接口协议 | HTTP/WebApi | +| 请求方式 | POST | +| 请求地址 | https://demovip.saas.carriercentral.vip/api/truck/SaveTruckInfo | + +### 输入参数 + +| 输入参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| loginMark | string | 是 | GUID 登录设备标识, 由请求端生成, 同一设备要求值不变,同登录接口保持一致。 | +| token | string | 是 | 通讯票据(由登录接口返回的值) | +| data | string | 是 | JSON 字符串 | + +### data 内容 + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| keyValue | string | 否 | SR_ContainerTaskEntity 对象主键,修改时必填 | +| entity | SR_ContainerTaskEntity 对象 | — | 见 entity 表 | +| sR_Shipments | List SR_ShipmentEntity 对象 | — | 见 sR_Shipments 表 | + +#### entity + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| F_IsPickUp | int | 是 | 是否取货:0-自送 1-提货 2-提货派送 | +| F_PickUpDate | datetime? | 否 | 提货日期(格式:2025/04/24 或 2025-04-24);自送不需要填写 | +| F_PickUpBegin | int? | 否 | 提货起始时间(0-23 点),自送不需要填写 | +| F_PickUpEnd | int? | 否 | 提货截止时间(0-23 点),自送不需要填写 | +| F_PickUpAddress | string | 否 | 提货地址,自送不需要填写。 | + +#### sR_Shipments + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| F_CustomterBolCode | string(50) | 否 | 客户单号 | +| F_MoveType | string(10) | 否 | 移动类型:LTL,FTL,FCL,不传默认 LTL | +| F_FBACode | string(30) | 是 | 目的代码 | +| F_HouseNo | string(30) | 否 | 商业地址的 FBACode,商业地址需要填写 | +| F_Address | string(500) | 否 | 详细地址,商业地址需要填写 | +| F_FBAID | string(100) | 否 | FBA id | +| F_PO | string(100) | 否 | PO | +| F_CTNS | int? | 否 | 件数 | +| F_CBM | decimal? | 否 | 体积 | +| F_Weight | decimal? | 否 | 重量 | +| F_BOLActual Pallet | decimal? | 否 | 板数(PDF 字段名含空格:`F_BOLActual Pallet`) | +| F_Expected_DeliveryDateB | datetime? | 否 | 最早送仓日期(格式:2025/04/24 或 2025-04-24) | +| F_Expected_DeliveryDateE | datetime? | 否 | 最晚送仓日期(格式:2025/04/24 或 2025-04-24) | +| F_City | string(50) | 否 | 城市 | +| F_State | string(50) | 否 | 州 | +| F_ZipCode | string(50) | 否 | 邮编 | +| F_Contact | string(50) | 否 | 联系人 | +| F_Mobile | string(50) | 否 | 手机号 | +| F_Email | string(300) | 否 | 邮编(原文参数说明) | + +### 输入示例 JSON + +```json +{ + "loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", + "token": "通讯票据(由登录接口返回的值)", + "data": "{\"keyValue\":\"\",\"entity\":{\"F_IsPickUp\":5,\"F_PickUpDate\":\"4/24\",\"F_PickUpBegin\":null,\"F_PickUpEnd\":null,\"F_PickUpAddress\":\"pick up address\"},\"sR_Shipments\":[{\"F_CustomterBolCode\":\"JK2504412\",\"F_MoveType\":\"\",\"F_FBACode\":\"ONT8\",\"F_HouseNo\":\"\",\"F_Address\":\"\",\"F_FBAID\":\"\",\"F_PO\":\"\",\"F_CTNS\":null,\"F_CBM\":null,\"F_Weight\":null,\"F_BOLActual Pallet\":0.02},{\"F_CustomterBolCode\":\"JK25042413\",\"F_MoveType\":\"\",\"F_FBACode\":\"PORK\",\"F_HouseNo\":\"\",\"F_Address\":\"\",\"F_FBAID\":\"\",\"F_PO\":\"\",\"F_CTNS\":null,\"F_CBM\":null,\"F_Weight\":null,\"F_BOLActual Pallet\":0.3,\"F_City\":null,\"F_State\":null,\"F_ZipCode\":null,\"F_Contact\":null,\"F_Mobile\":null,\"F_Email\":null,\"F_Expected_DeliveryDateB\":\"2025-04-01\",\"F_Expected_DeliveryDateE\":\"2025-04-01\"}]}" +} +``` + +### 输出参数 + +| 输出参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| code | int | 是 | 200:成功 400:失败 500:异常 410:没有登录信息 | +| info | string | 是 | 成功或者错误消息 | +| data | string | 是 | 无 | + +--- + +## 5. 集装箱列表 + +| 项 | 值 | +| --- | --- | +| 接口协议 | HTTP/WebApi | +| 请求方式 | POST | +| 请求地址 | https://demovip.saas.carriercentral.vip/api/Container/GetContainerList | +| 访问次数限制(V1.7) | 1 分钟 1 次 | + +### 输入参数 + +| 输入参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| loginMark | string | 是 | GUID 登录设备标识, 由请求端生成, 同一设备要求值不变,同登录接口保持一致。 | +| token | string | 是 | 通讯票据(由登录接口返回的值) | +| data | string | 是 | JSON 字符串 | + +### data 内容 + +#### pagination 分页 + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| rows | int | 是 | 一页行数 | +| page | int | 是 | 页码 | +| sidx | string | 是 | 按什么排序,例如 F_CreateDate | +| sord | String | 是 | 排序:desc 或 asc | + +#### queryJson 查询参数 + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| F_ContainerNo | string(50) | 否 | 柜号 | +| F_CabinetType | string(10) | 否 | 柜型:20GP,40GP,40HQ,40HR,45HC,45HQ,53HQ 不传默认查所有 | +| F_TransMode | string(30) | 否 | 运输方式:海运,空运,卡派,自送。不传默认查所有 | +| F_Status | string(30) | 否 | 状态:0-新单,20-清关结束,30-提柜直达,40-待提柜,60-已到仓,65-直送运输中,70-已拆柜,80-已还柜 | +| F_FBACode | string(30) | 否 | FBACode | +| F_FBAID | string(100) | 否 | FBA id | +| F_ShipmentId | string(100) | 否 | Ref No. | +| StartTime | datetime? | 否 | 开始日期(格式:2025/04/24 或 2025-04-24) | +| EndTime | datetime? | 否 | 结束日期(格式:2025/04/24 或 2025-04-24) | + +### 输入示例 JSON + +```json +{ + "loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", + "token": "通讯票据(由登录接口返回的值)", + "data": "{\"pagination\":{\"rows\":10,\"page\":1,\"sidx\":\"F_CreateDate\",\"sord\":\"desc\"},\"queryJson\":\"{\\\"F_ContainerNo\\\":\\\"\\\",\\\"F_CabinetType\\\":\\\"\\\",\\\"F_TransMode\\\":\\\"\\\",\\\"F_Status\\\":\\\"\\\",\\\"F_FBAID\\\":\\\"\\\",\\\"F_ShipmentId\\\":\\\"\\\",\\\"StartTime\\\":\\\"\\\",\\\"EndTime\\\":\\\"\\\",\\\"F_FBACode\\\":\\\"ABE2\\\"}\"}" +} +``` + +### 输出参数 + +| 输出参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| code | int | 是 | 200:成功 400:失败 500:异常 410:没有登录信息 | +| info | string | 是 | 成功或者错误消息 | +| data | string | 是 | JSON 字符串 | + +### data 内容 + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| total | int | 是 | 总数 | +| page | int | 是 | 页码 | +| records | int | 是 | 记录数 | +| rows | List rows 对象 | — | 见 rows 表 | + +#### rows 字段 + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| F_ContainerTaskId | String | 是 | 集装箱唯一主键 id | +| F_ContainerNo | String | 是 | 柜号 | +| F_CustomerId | String | 否 | 柜号 id | +| F_TransMode | Int | 是 | 运输方式 0-海运 1-空运 2-卡派 3-自送柜 | +| F_IsISF | Int | 是 | 是否填写 ISF, 0-不填写 1-填写 | +| F_CabinetType | String | 是 | 柜型 | +| F_ETD | datetime | 否 | ETD | +| F_ETA | datetime | 否 | ETA | +| F_EstimateExtractionTime | datetime | 否 | 预计提柜时间 | +| F_ExtractionTime | datetime | 否 | 实际提柜时间 | +| F_ATA | datetime | 否 | 到港时间 | +| F_UnstowTime | datetime | 否 | 拆柜日期 | +| F_GiveBackTime | datetime | 否 | 还柜日期 | +| F_NotifyGiveBackTime | datetime | 否 | 通知还柜日期 | +| F_Status | Int | 是 | 0-新单 1-已导入 Transload 2-已通知 3-已提柜 4-已到仓 5-已还柜(已结束) 9-异常 | +| F_CreateDate | datetime | 是 | 创建时间 | +| F_LoadPort | string | 否 | 起运港 | +| F_Dock | String | 否 | 目的港 | +| F_ToWhDate | datetime | 否 | 到仓日期 | +| F_InvoiceFile | String | 否 | Commericial Invoice 文件 ID | +| F_BLFile | String | 否 | BL Copy 提单文件 ID | +| F_ANFile | String | 否 | AN 到货通知文件 ID | +| F_DOFile | String | 否 | DO 文件 id | +| F_EntrySummaryFile | String | 否 | Entry Summary 文件 ID | +| F_ISFFile | String | 否 | 客户 ISF 文件 ID | +| F_OperationType | String | 否 | 业务类型:0,1 是拆柜,2 是直送,4 是只清关 | +| F_PackingList | string | 否 | Packing liist 文件 id | +| F_OfficialISF | string | 否 | 正式 ISF 文件 ID | +| F_CustomsFile | String | 否 | 报关资料 | +| F_ATD | datetime | 否 | ATD,实际开船日期 | +| F_BLCopyCode | String | 否 | 提单号 | +| F_AreaCode | String | 否 | 库区 | +| F_MemoRemark | String | 否 | 备注 | +| F_Classis | Int | 否 | 是否自带车架 1-是 0-否 | +| F_ShippingLineId | String | 否 | 船司 ID | +| F_PackingList | String | 否 | Packing list 文件 id(文档表重复列出) | +| F_CustomsFile | String | 否 | 报关资料文件 id(文档表重复列出) | +| F_SupplierId | String | 否 | 提柜供应商id(V1.72) | + +### 输出示例 JSON + +原文输出示例字段完整保留如下(已将 PDF 换行拼接;`info` 原文含空格「响 应 成 功」处规整为「响应成功」,键值全集未删): + +```json +{ + "code": 200, + "info": "响应成功", + "data": { + "rows": [ + { + "F_ContainerTaskId": "e4643726-6930-4a92-b9a8-937d59dbcce0", + "F_ContainerNo": "BC25090910", + "F_CustomerId": "d1d2fc3d-a4de-4281-a728-fc2d6a145d3f", + "F_TransMode": 0, + "F_IsISF": 0, + "F_CabinetType": "20GP", + "F_ETD": null, + "F_ETA": null, + "F_EstimateExtractionTime": "2025-09-19 13:00:00", + "F_ExtractionTime": null, + "F_ATA": null, + "F_UnstowTime": null, + "F_GiveBackTime": null, + "F_NotifyGiveBackTime": null, + "F_Status": 0, + "F_CreateUserId": "System", + "F_CreateDate": "2025-09-09 03:11:11", + "F_CreateUserName": "超级管理员", + "F_ModifyUserId": "System", + "F_ModifyDate": "2025-09-16 20:16:13", + "F_ModifyUserName": "超级管理员", + "F_WhCode": null, + "F_FileName": null, + "F_FullFileName": null, + "F_LoadPort": "", + "F_Dock": "", + "F_ANTime": null, + "F_CustomsBroker": null, + "F_SupplierId": "", + "F_SupplierTrailer": null, + "F_AbNormalTime": null, + "F_AbNormalInfo": "", + "F_ToWhDate": null, + "F_BracketFreeDay": null, + "F_InvoiceFile": null, + "F_BLFile": null, + "F_ANFile": null, + "F_DOFile": null, + "F_EntrySummaryFile": null, + "F_ISFFile": null, + "F_CustomsClearanceTime": null, + "F_OperationType": 0, + "F_SourceType": null, + "F_PackingList": null, + "F_OfficialISF": null, + "F_CustomsFile": null, + "F_OpenPhoto": null, + "F_ATD": null, + "F_FlowId": null, + "F_IsPickUp": null, + "F_PickUpCode": null, + "F_PickUpDate": null, + "F_PickUpBegin": null, + "F_PickUpEnd": null, + "F_PickUpAddress": null, + "F_TotalPalltes": null, + "F_CustomerBolFile": null, + "F_PickAmount": null, + "F_LogAcceptUrl": null, + "F_PrintContent": null, + "F_Instruction": "", + "F_ShipStatus": null, + "F_Report": null, + "F_IsCustoms": null, + "F_CustomsDate": null, + "F_CustomsUserName": null, + "F_AddressName": null, + "F_FullAddress": null, + "F_PODFile": null, + "F_PODTime": null, + "F_IsSecurityFiling": null, + "F_ISFDraft": null, + "F_IsVWH": null, + "F_GW": null, + "F_ToContact": null, + "F_UnstowData": null, + "F_TaskId": null, + "F_BLCopyCode": "", + "F_PickUpCost": null, + "F_Convert": null, + "F_Loader": "", + "F_LoadingCost": null, + "F_IsInner": null, + "F_UnstowImage": null, + "F_ConfirmTime": null, + "F_IsCharge": null, + "F_OverWeight": 0, + "F_LFD": null, + "F_ISA": null, + "F_ISATime": null, + "F_StorageType": null, + "F_TrainTrend": "", + "F_AreaCode": "", + "F_Inspection": 0, + "F_InspectionDesc": "", + "F_ContainerId": "", + "F_MemoRemark": "", + "F_DismantleStatus": null, + "F_New": null, + "F_Classis": 0, + "F_ShippingLineId": "c3f80d26-3174-4eba-9460-735ce33771da", + "F_CustomerCode": null, + "F_CustomerName": null, + "F_ShortName": "Third Party_Usbic", + "F_SupplierName": null, + "CustomsBrokerName": null, + "OpObject": null, + "markCheckList": null, + "shipmentList": null, + "ErrorSortBy": null, + "F_Profit": null, + "F_AreaAddress": null, + "F_UnstowFileList": null, + "F_ShippingLineName": null, + "F_PODFileName": null, + "F_PODFileType": null, + "F_PODFileUrl": "/Common/DownAnnexesFile?file Id=", + "F_PODFileIcon": "/Content/images/filetype/.png", + "F_ChargeFinished": 0, + "F_MinReportTime": null + } + ], + "total": 44, + "page": 1, + "records": 44 + } +} +``` + +--- + +## 6. 集装箱货件列表 + +| 项 | 值 | +| --- | --- | +| 接口协议 | HTTP/WebApi | +| 请求方式 | GET | +| 请求地址 | https://demovip.saas.carriercentral.vip/api/Shipment/GetShipmentList | +| 访问次数限制(V1.7) | 1 分钟 1 次 | + +### 输入参数 + +| 输入参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| loginMark | string | 是 | GUID 登录设备标识, 由请求端生成, 同一设备要求值不变,同登录接口保持一致。 | +| token | string | 是 | 通讯票据(由登录接口返回的值) | +| data | string | 是 | 字符串,集装箱唯一主键 id | + +### 输入示例 JSON + +```json +{ + "loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", + "token": "通讯票据(由登录接口返回的值)", + "data": "集装箱唯一主键 id" +} +``` + +### 输出参数 + +| 输出参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| code | int | 是 | 200:成功 400:失败 500:异常 410:没有登录信息 | +| info | string | 是 | 成功或者错误消息 | +| data | string | 是 | JSON 字符串 | + +#### data 内容 List data + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| F_Transporter | String(30) | 是 | MoveType:TRUCK,留仓,扣货,UPS,FEDEX,DHL,USPS,自提 | +| F_ID | String(50) | 是 | 货件 ID | +| F_FBACode | String(30) | 是 | 仓库代码 FBACode | +| F_ShipmentID | String(1000) | 否 | Customer Ref no. | +| F_CTNS | Int | 是 | 箱数 | +| F_CBM | decimal? | 是 | 体积 | +| F_Weight | decimal? | 是 | 重量 | +| F_Address | String(500) | 否 | 地址 | +| F_FBAID | String(2000) | 否 | FBA Id | +| F_ReferenceId | String(1000) | 否 | Reference Id | +| F_SKU | String(50) | 否 | SKU | +| F_EnglishName | String(500) | 否 | 英文品名 | +| F_Description | String(500) | 否 | 中文品名 | +| F_Expected_DeliveryDateB | datetime? | 否 | 最早送仓日期(格式:2025/04/24 或 2025-04-24) | +| F_Expected_DeliveryDateE | datetime? | 否 | 最晚送仓日期(格式:2025/04/24 或 2025-04-24) | +| F_City | String(50) | 否 | 城市 | +| F_State | String(50) | 否 | 州省 | +| F_ZipCode | String(50) | 否 | 邮编 | +| F_Contact | String(50) | 否 | 收件人 | +| F_Mobile | String(50) | 否 | 联系电话 | +| F_Email | String(100) | 否 | 邮箱 | +| F_Remark | String(2000) | 否 | 行备注 | + +--- + +## 7. 集装箱状态变更日志 + +| 项 | 值 | +| --- | --- | +| 接口协议 | HTTP/WebApi | +| 请求方式 | GET | +| 请求地址 | https://demovip.saas.carriercentral.vip/api/Container/GetStateTrack | +| 访问次数限制(V1.7) | 5 分钟 1 次 | + +### 输入参数 + +| 输入参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| loginMark | string | 是 | GUID 登录设备标识, 由请求端生成, 同一设备要求值不变,同登录接口保持一致。 | +| token | string | 是 | 通讯票据(由登录接口返回的值) | +| data | string | 是 | 字符串,集装箱唯一主键 id | + +### 输入示例 JSON + +```json +{ + "loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", + "token": "通讯票据(由登录接口返回的值)", + "data": "集装箱唯一主键 id" +} +``` + +### 输出参数 + +| 输出参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| code | int | 是 | 200:成功 400:失败 500:异常 410:没有登录信息 | +| info | string | 是 | 成功或者错误消息 | +| data | string | 是 | JSON 字符串 | + +#### data 内容 List data + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| TypeCode | Int | 是 | 状态:0-已到港,10-已提柜,20-已到仓,30-已拆柜,40-已还空 | +| TypeName | String(50) | 是 | 状态名:0-已到港,10-已提柜,20-已到仓,30-已拆柜,40-已还空 | +| F_CreateDate | datetime? | 否 | 创建时间 | +| F_CreateUserName | String | 否 | 创建人 | +| DataJson | String | 否 | JSON 字符串 | + +#### DataJson 内容 + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| date | datetime? | 是 | 时间 | +| image | string | 否 | 图片 | +| remark | String | 否 | 备注 | + +--- + +## 8. 集装箱货件信息 + +| 项 | 值 | +| --- | --- | +| 接口协议 | HTTP/WebApi | +| 请求方式 | GET | +| 请求地址 | https://demovip.saas.carriercentral.vip/api/Container/GetShipmentInfo | +| 访问次数限制(V1.7) | 30 秒 1 次 | + +### 输入参数 + +| 输入参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| loginMark | string | 是 | GUID 登录设备标识, 由请求端生成, 同一设备要求值不变,同登录接口保持一致。 | +| token | string | 是 | 通讯票据(由登录接口返回的值) | +| data | string | 是 | 字符串,集装箱唯一主键 id | + +### 输入示例 JSON + +```json +{ + "loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", + "token": "通讯票据(由登录接口返回的值)", + "data": "集装箱唯一主键 id" +} +``` + +### 输出参数 + +| 输出参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| code | int | 是 | 200:成功 400:失败 500:异常 410:没有登录信息 | +| info | string | 是 | 成功或者错误消息 | +| data | string | 是 | JSON 字符串 | + +#### data 内容 List data + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| Transporter | String(30) | 是 | MoveType:TRUCK,留仓,扣货,UPS,FEDEX,DHL,USPS,自提 | +| Mark | String(30) | 是 | 仓库代码 FBACode | +| ShipmentID | String(1000) | 否 | Customer Ref no. | +| Ctns | Int | 是 | 箱数 | +| Pallets | decimal? | 是 | 板数 | +| Status | decimal? | 是 | 状态:0-预到达 10-在库 15-在库 16-已排货 20-已出仓 26-运输中 30-已送达 90-已取消 | +| PlanDate | datetime? | 否 | 计划发车时间 | +| PlanETA | datetime? | 否 | 预计送达日期 | +| ISATime | datetime? | 否 | ISA 时间 | +| ETA | datetime? | 否 | ETA | +| ISA | String(50) | 否 | ISA | +| PODTime | datetime? | 否 | 签收时间 | +| Closed Page | String(50) | 否 | ClosePage 文件 id(PDF 字段名写作 Closed Page) | +| PODFile | String(50) | 否 | POD 文件 id | +| ISAChangeReason | String | 否 | ISA 变更原因 | +| ProofChange | String | 否 | ISA 变更凭证 | +| Remark | String | 否 | 备注 | + +--- + +## 9. 集装箱拆箱报告 + +| 项 | 值 | +| --- | --- | +| 接口协议 | HTTP/WebApi | +| 请求方式 | GET | +| 请求地址 | https://demovip.saas.carriercentral.vip/api/Container/GetBillReport | +| 访问次数限制(V1.7) | 1 分钟 1 次 | + +### 输入参数 + +| 输入参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| loginMark | string | 是 | GUID 登录设备标识, 由请求端生成, 同一设备要求值不变,同登录接口保持一致。 | +| token | string | 是 | 通讯票据(由登录接口返回的值) | +| data | string | 是 | 字符串,集装箱唯一主键 id | + +### 输入示例 JSON + +```json +{ + "loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", + "token": "通讯票据(由登录接口返回的值)", + "data": "集装箱唯一主键 id" +} +``` + +### 输出参数 + +| 输出参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| code | int | 是 | 200:成功 400:失败 500:异常 410:没有登录信息 | +| info | string | 是 | 成功或者错误消息 | +| data | binary / Blob | 否 | 拆柜报告 PDF 文件 | + +--- + +## 10. 修改集装箱字段值 + +(注意:上传集装箱文件前,若对应字段为空,可使用该接口将此字段设置成文件 Id 或文件夹 Id) + +| 项 | 值 | +| --- | --- | +| 接口协议 | HTTP/WebApi | +| 请求方式 | POST | +| 请求地址 | https://demovip.saas.carriercentral.vip/api/Container/SaveFieldValue | + +### 输入参数 + +| 输入参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| loginMark | string | 是 | GUID 登录设备标识, 由请求端生成, 同一设备要求值不变 | +| token | string | 是 | 通讯票据(由登录接口返回的值) | +| data | string | 是 | JSON 字符串 | + +### data 内容 + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| keyValue | string | 是 | 集装箱主键唯一 id | +| field | String | 是 | 字段名(见映射) | +| fieldValue | String | 是 | 字段值,唯一 Id | + +#### field 字段名映射 + +| 业务含义 | field 取值 | +| --- | --- | +| DO | F_DOFile | +| B/L Copy | F_BLFile | +| Commericial Invoice | F_InvoiceFile | +| Packing list | F_PackingList | +| 报关资料 | F_CustomsFile | +| AN | F_ANFile | + +### 输入示例 JSON + +```json +{ + "loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", + "token": "通讯票据(由登录接口返回的值)", + "data": "{\"keyValue\":\"729dbc73-e5fc-4207-9e7f-a58f9e2c9773\",\"field\":\"F_DOFile\",\"fieldValue\":\"80e0a295-f8f6-47e2-be2b-e8faece90694\"}" +} +``` + +### 输出参数 + +| 输出参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| code | int | 是 | 200:成功 400:失败 500:异常 410:没有登录信息 | +| info | string | 是 | 成功或者错误消息 | +| data | string | 是 | 无 | + +--- + +## 11. 上传文件 + +(注意:上传文件使用 GetContainerList 接口先获取集装箱的文件字段对应 id,如果为空的情况使用 SaveFieldValue 接口为对应文件字段赋值) + +| 项 | 值 | +| --- | --- | +| 接口协议 | HTTP/WebApi | +| 请求方式 | POST | +| 请求地址 | https://demovip.saas.carriercentral.vip/api/learun/adms/annexes/upload | + +### 输入参数 + +| 输入参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| loginMark | string | 是 | GUID 登录设备标识, 由请求端生成, 同一设备要求值不变,同登录接口保持一致。 | +| token | string | 是 | 通讯票据(由登录接口返回的值) | +| data | string | 是 | 集装箱对应文件字段的文件夹 id(举例:集装箱 F_DOFile 对应的 id) | +| file | binary / Blob | 是 | 上传的文件 | + +### 输出参数 + +| 输出参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| code | int | 是 | 200:成功 400:失败 500:异常 410:没有登录信息 | +| info | string | 是 | 成功或者错误消息 | +| data | string | 是 | 文件 id | + +--- + +## 12. 下载 POD 和 ClosePage + +注:相同文件 id 下载成功次数上限 5 次(不区分账户); + +| 项 | 值 | +| --- | --- | +| 接口协议 | HTTP/WebApi | +| 请求方式 | GET | +| 请求地址 | https://demovip.saas.carriercentral.vip/api/learun/adms/annexes/down | + +### 输入参数 + +| 输入参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| loginMark | string | 是 | GUID 登录设备标识, 由请求端生成, 同一设备要求值不变,同登录接口保持一致。 | +| token | string | 是 | 通讯票据(由登录接口返回的值) | +| data | string | 是 | 字符串,POD 文件 id 或 ClosePage 文件 id(提示:POD 文件 id 和 ClosePage 文件 id 在 GetShipmentInfo 接口返回值中) | + +### 输入示例 JSON + +```json +{ + "loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", + "token": "通讯票据(由登录接口返回的值)", + "data": "POD 文件 id 或 ClosePage 文件 id" +} +``` + +### 输出参数 + +| 输出参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| code | int | 是 | 200:成功 400:失败 500:异常 410:没有登录信息 | +| info | string | 是 | 成功或者错误消息 | +| data | binary / Blob | 是 | POD 文件 或 ClosePage 文件 | + +--- + +## 13. 下载拆柜图片 + +注:相同集装箱下载成功次数上限 5 次(不区分账户); + +| 项 | 值 | +| --- | --- | +| 接口协议 | HTTP/WebApi | +| 请求方式 | GET | +| 请求地址 | https://demovip.saas.carriercentral.vip/api/Container/DownDevanFile | + +### 输入参数 + +| 输入参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| loginMark | string | 是 | GUID 登录设备标识, 由请求端生成, 同一设备要求值不变,同登录接口保持一致。 | +| token | string | 是 | 通讯票据(由登录接口返回的值) | +| data | string | 是 | 字符串,集装箱唯一主键 id | + +### 输入示例 JSON + +```json +{ + "loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", + "token": "通讯票据(由登录接口返回的值)", + "data": "集装箱唯一主键 id" +} +``` + +### 输出参数 + +| 输出参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| code | int | 是 | 200:成功 400:失败 500:异常 410:没有登录信息 | +| info | string | 是 | 成功或者错误消息 | +| data | String | 否 | JSON | + +#### data 内容 + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| bytes | String | 是 | 拆柜图片文件压缩包(Base64 编码的字符串) | +| fileName | string | 是 | 文件名 | + +--- + +## 附录 B:接口清单与限流速查 + +| # | 名称 | 方法 | 路径 | +| --- | --- | --- | --- | +| 1 | 登录 | POST | /api/learun/adms/user/customerLogin | +| 2 | 获取船司列表 | GET | /api/ConfigModule/GetShippingLineList | +| 3 | 集装箱数据导入 | POST | /api/Container/SaveContainer | +| 4 | 卡派数据导入 | POST | /api/truck/SaveTruckInfo | +| 5 | 集装箱列表 | POST | /api/Container/GetContainerList | +| 6 | 集装箱货件列表 | GET | /api/Shipment/GetShipmentList | +| 7 | 集装箱状态变更日志 | GET | /api/Container/GetStateTrack | +| 8 | 集装箱货件信息 | GET | /api/Container/GetShipmentInfo | +| 9 | 集装箱拆箱报告 | GET | /api/Container/GetBillReport | +| 10 | 修改集装箱字段值 | POST | /api/Container/SaveFieldValue | +| 11 | 上传文件 | POST | /api/learun/adms/annexes/upload | +| 12 | 下载 POD 和 ClosePage | GET | /api/learun/adms/annexes/down | +| 13 | 下载拆柜图片 | GET | /api/Container/DownDevanFile | + +| 接口 | 限制 | +| --- | --- | +| GetContainerList | 1 分钟 1 次 | +| GetShipmentList | 1 分钟 1 次 | +| GetStateTrack | 5 分钟 1 次 | +| GetShipmentInfo | 30 秒 1 次 | +| GetBillReport | 1 分钟 1 次 | +| 下载 POD/ClosePage | 相同文件 id 下载成功上限 5 次(不区分账户) | +| DownDevanFile | 相同集装箱下载成功上限 5 次(不区分账户) | + +--- + +## 附录 A:PDF 原文抽取(逐页,未删减) + +> 以下为 `pypdf` 对源 PDF 全部 15 页的完整文本抽取,用于对照查漏;行内异常空格为 PDF 文本层排版产物,非编辑删改。 + +### 第 1 页 + +``` +客户接口 V1.72 +客户端 :https://demovip.saas.carriercentral.vip +测试接 口环境 :https://demovip.saas.carriercentral.vip +测试客户账号: fj +fj123qwe! +更改历史 +版本 更改内容 +V1.3 1. 增加接口 :获取船司列表 +2. 接口 SaveContainer 增加字段 :船司,是否带车架,备注 +3. 接口 GetContainerList 的数据返回增加字段:船司,是否带车架,备注 +4. 接口 GetContainerList 的数据返回增加字段:报关资料, packingList +5. 增加接口 :修改集装箱字段值 SaveFieldValue +6. 增加接口:上传文件 upload +7. 接口 SaveContainer 返回集装箱 id 在 data +8. 接口 GetShipmentInfo 返回参数 status 增加状态说明 +9. 接口 GetContainerList 返回参数 status 增加状态说明 +10. 接口 SaveContainer 输入参数 sR_Shipments 添加字段:城市,州 ,邮编,联系 +人 ,手机号, 邮箱 +11. 接口 SaveTruckInfo 输入参数 sR_Shipments 添加字段 :城市,州 ,邮编,联系 +人 ,手机号, 邮箱 +V1.4 1. 增加接口:下载 POD 和 ClosePage +V1.5 1.接口 SaveContainer 输入参数 sR_Shipments 添加字段: 品名,英文名称, PCS +2.接口 GetShipmentList 返回参数 F_ChineseName 变更为 F_Description +3.增加接口:下载拆柜图片 DownDevanFile +V1.6 1.GetShipmentInfo 增加 PlanETA 作为预计送达日期,ISATime 更改定义为 ISA 时间。 +V1.7 1.集装箱列表接口访问次数限制: 1 分钟 1 次 +2.集装箱货件列表接口访问次数限制: 1 分钟 1 次 +3.集装箱状态变更日志接口访问次数限制 :5 分钟 1 次 +4.集装箱货件信息接口访问次数限制 :30 秒 1 次 +5.集装箱拆箱报告: 1 分钟 1 次 +V1.71 1.集装箱数据导入接口添加字段:柜号id(F_ContainerId) +V1.72 1.集装箱列表接口添加字段:提柜供应商(F_SupplierId) +1. 登录 +接口协议 HTTP/WebApi +``` + +### 第 2 页 + +``` +请求方式: POST +请求地址: https://demovip.saas.carriercentral.vip/api/learun/adms/user/customerLogin +输入参数名 类型 必须 参数说明 +loginMark string 是 GUID 登录设备标识 , 由请求端生成, 同一设备要求值 +不变 +Data +(json 字符 +串) +username string 是 登录账号(客户账号) +password string 是 登录密码, 32 位 MD5 加密后的字符串 +输入示例 JSON +{ +"loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", +"Data": "{\"username\":\"登录账号\",\"password\":\"登录密码,32 位 MD5 加密后的字符串\"}" +} +输出参数名 类型 必须 参数说明 +code int 是 200:成功 400:失败 500:异常 410:没有登录信息 +info string 是 成功或者错误消息 +data token string 是 通讯票据 +2. 获取船司列表 +接口协议 HTTP/WebApi +请求方式: GET +请求地址: https://demovip.saas.carriercentral.vip/api/ConfigModule/GetShippingLineList +输入参数名 类型 必须 参数说明 +loginMark string 是 GUID 登录设备标识, 由请求端生成, 同一设备要 +求值不变, 同登录接口保持一致。 +token string 是 通讯票据(由登录接口返回的值) +输入示例 JSON +{ +"loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", +"token": "通讯票据(由登录接口返回的值) " +} +输出参数名 类型 必须 参数说明 +code int 是 200:成功 400: 失败 500: 异常 410:没有登 +录信息 +info string 是 成功或者错误消息 +data 内容 List +F_ID String(50) 是 船司 ID +F_ShippingLineCode String(20) 是 船司代码 +F_ShippingLineName String(100) 是 船司名 +F_Prefix String(100) 否 前缀 +F_DayLow Int 是 船司 ETA 后提柜时间(示例: 1-2 天 , 1) +``` + +### 第 3 页 + +``` +F_DayHigh int 是 船司 ETA 后提柜时间(示例: 1-2 天 ,2) +F_Remark String(1000) 否 备注 +F_ ShippingLine String(120) 是 船司 +3. 集装箱数据导入 +接口协议 HTTP/WebApi +请求方式: POST +请求地址: https://demovip.saas.carriercentral.vip/api/Container/SaveContainer +输入参数名 类型 必须 参数说明 +loginMark string 是 GUID 登录设备标识, 由请求端生成, 同一设备要 +求值不变 +token string 是 通讯票据(由登录接口返回的值) +data string 是 JSON 字符串 +data 内容 +keyValue string 否 SR_ContainerTaskEntity 对象主键,修改 时必填 +entity SR_ContainerTaskEntity 对象 +F _ TransMode int 是 运输方式 :0-海运 1-空运 3-自送 +F_OperationType int 是 业务类型 :0-拆柜 2-直送 4-仅清关 +F_ContainerNo string(50) 是 柜号 +F_ContainerId string(50) 否 柜号id +F_CabinetType string(20) 否 柜型: +20GP, 40GP,40HQ,40HR,40HC,45HQ,53HQ +F_BLCopyCode string(15) 否 提单号 +F_ETD datetime? 否 ETD(格式:2025/04/24 或 2025-04-24) +F_ETA datetime? 否 ETA(格式:2025/04/24 或 2025-04-24) +F _ LoadPort string(50) 否 起始港 +F_Dock string(50) 否 目的港 +F_ShippingLineId string(50) 否 船司 ID(由船司列表接口返回的值) +F Classis int 否 是否自带车架 1-是 0-否 +F MemoRemark string(1000) 否 备注 +F_Instruction string(1000) 否 拆柜指引 +sR_Shipments List对象 +F_ FBACode string(30) 是 目的代码 +F_Transporter string(30) 是 派 送 方 式 : 留 仓 , 自 提 , 扣 货 , TRUCK, +UPS,FEDEX,DHL,USPS +F_ShipmentID string(500) 否 客户参考号 Customer Ref No +F_ Remark string(1000) 否 行备注 +F_ FBAID string(100) 否 FBA id +F_PO string(100) 否 PO +F_ReferenceId string(500) 否 参考号 +F_ Address string(500) 否 详细地址, 商业地址需要填写 +F_CTNS int 是 件数 +``` + +### 第 4 页 + +``` +F_CBM decimal? 否 体积 +F_ Weight decimal? 否 重量 +F_SKU string(50) 否 SKU +F_ Description string(500) 否 品名 +F_ EnglishName string(500) 否 英文名称 +F_PCS int 否 PCS +F_Expected_DeliveryDateB datetime? 否 最 早 送 仓 日 期 ( 格 式 :2025/04/24 或 +2025-04-24) +F_Expected_DeliveryDateE datetime? 否 最 晚 送 仓 日 期 ( 格 式 :2025/04/24 或 +2025-04-24) +F_City string(50) 否 城市 +F_ State string(50) 否 州 +F_ ZipCode string(50) 否 邮编 +F_ Contact string(50) 否 联系人 +F_ Mobile string(50) 否 手机号 +F_ Email string(300) 否 邮编 +输入示例 JSON +{ +"loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", +"token": "通讯票据(由登录接口返回的值) ", +"data":"{\"keyValue\":null,\"entity\":{\"F_TransMode\":0,\"F_OperationType\":0,\"F_ContainerNo\":\"GH25042 +416\",\"F_CabinetType\":null,\"F_BLCopyCode\":\"\",\"F_ETD\":\"2025-04-01\",\"F_ETA\":\"2025-04-24\",\"F_L +oadPort\":\"SHENZHEN\",\"F_Dock\":\"LAX\",\"F_ShippingLineId\":\"c3f80d26-3174-4eba-9460-735ce33771d +a\",\"F_Classis\":1,\"F_MemoRemark\":\"remarkk\",\"F_Instruction\":\"这是接口创建集装箱 +\"},\"sR_Shipments\":[{\"F_FBACode\":\"SMF3\",\"F_Transporter\":\"truck\",\"F_ShipmentID\":\"FBA424POST17\ +",\"F_Remark\":\"备注 +1\",\"F_FBAID\":\"FBA424PMAN17\",\"F_PO\":null,\"F_ReferenceId\":null,\"F_Address\":null,\"F_CTNS\":50,\"F_C +BM\":2.36,\"F_Weight\":135.6,\"F_SKU\":null,\"F_Description\":\"CHina\",\"F_EnglishName\":\"Eng\",\"F_PCS\":1 +0},{\"F_FBACode\":\"ABE2\",\"F_Transporter\":\"扣货\",\"F_ShipmentID\":\"FBA424POST17\",\"F_Remark\":\"备 +注 +2\",\"F_FBAID\":\"FBA424PMAN17\",\"F_PO\":\"\",\"F_ReferenceId\":\"\",\"F_Address\":\"\",\"F_CTNS\":30,\"F_C +BM\":1.23,\"F_Weight\":108.3,\"F_SKU\":null,\"F_City\":null,\"F_State\":null,\"F_ZipCode\":null,\"F_Contact\":null, +\"F_Mobile\":null,\"F_Email\":null, \"F_Expected_DeliveryDateB \":\"2025-04-01\",\"F_Expected_DeliveryDateE +\":\"2025-04-01\"}]}" +} +输出参数名 类型 必须 参数说明 +code int 是 200:成功 400: 失败 500: 异常 410:没有登 +录信息 +info string 是 成功或者错误消息 +data string 是 集装箱 id +``` + +### 第 5 页 + +``` +4. 卡派数据导入 +接口协议 HTTP/WebApi +请求方式: POST +请求地址: https://demovip.saas.carriercentral.vip/api/truck/SaveTruckInfo +输入参数名 类型 必须 参数说明 +loginMark string 是 GUID 登录设备标识, 由请求端生成, 同一设备要 +求值不变, 同登录接口保持一致。 +token string 是 通讯票据(由登录接口返回的值) +data string 是 JSON 字符串 +data 内容 +keyValue string 否 SR_ContainerTaskEntity 对象主键,修改 时必填 +entity SR_ContainerTaskEntity 对象 +F_IsPickUp int 是 是否取货 :0-自送 1-提货 2-提货派送 +F_PickUpDate datetime? 否 提货日期(格式:2025/04/24 或 2025-04-24) +自送不需要填写 +F_ PickUpBegin int? 否 提货起始时间(0-23 点), 自送不需要填写 +F_ PickUpEnd int? 否 提货截止时间(0-23 点), 自送不需要填写 +F _ PickUpAddress string 否 提货地址, 自送不需要填写。 +sR_Shipments List对象 +F_CustomterBolCode string(50) 否 客户单号 +F_ MoveType string(10) 否 移动类型: LTL, FTL, FCL,不传默认 LTL +F_ FBACode string(30) 是 目的代码 +F_HouseNo string(30) 否 商业地址的 FBACode, 商业地址需要填写 +F_Address string(500) 否 详细地址, 商业地址需要填写 +F_FBAID string(100) 否 FBA id +F_PO string(100) 否 PO +F_CTNS int? 否 件数 +F_CBM decimal? 否 体积 +F_ Weight decimal? 否 重量 +F_ BOLActual Pallet decimal? 否 板数 +F_Expected_DeliveryDateB datetime? 否 最 早 送 仓 日 期 ( 格 式 :2025/04/24 或 +2025-04-24) +F_Expected_DeliveryDateE datetime? 否 最 晚 送 仓 日 期 ( 格 式 :2025/04/24 或 +2025-04-24) +F_City string(50) 否 城市 +F_ State string(50) 否 州 +F_ ZipCode string(50) 否 邮编 +F_ Contact string(50) 否 联系人 +F_ Mobile string(50) 否 手机号 +F_ Email string(300) 否 邮编 +输入示例 JSON +{ +``` + +### 第 6 页 + +``` +"loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", +"token": "通讯票据(由登录接口返回的值) ", +"data":"{\"keyValue\":\"\",\"entity\":{\"F_IsPickUp\":5,\"F_Pick UpDate\":\"4/24\",\"F_PickUpBegin\":null,\"F_Pic +k UpEnd\":null,\"F_PickUpAddress\":\"pick up +address\"},\"sR_Shipments\":[{\"F_CustomterBolCode\":\"JK2504412\",\"F_MoveType\":\"\",\"F_FBACode\":\"O +NT8\",\"F_HouseNo\":\"\",\"F_Address\":\"\",\"F_FBAID\":\"\",\"F_PO\":\"\",\"F_CTNS\":null,\"F_CBM\":null,\"F_W +eight\":null,\"F_BOLActual Pallet\":0.02},{\"F_CustomterBolCode\":\"JK25042413\",\"F_MoveType\":\"\",\"F_FBA +Code\":\"PORK\",\"F_HouseNo\":\"\",\"F_Address\":\"\",\"F_FBAID\":\"\",\"F_PO\":\"\",\"F_CTNS\":null,\"F_CBM\ +":null,\"F_Weight\":null,\"F_BOLActual Pallet\":0.3,\"F_City\":null,\"F_State\":null,\"F_ZipCode\":null,\"F_Contact\" +:null,\"F_Mobile\":null,\"F_Email\":null,\"F_Expected_DeliveryDateB\":\"2025-04-01\",\"F_Expected_DeliveryDate +E \":\"2025-04-01\"}]}" +} +输出参数名 类型 必须 参数说明 +code int 是 200:成功 400: 失败 500: 异常 410:没有登 +录信息 +info string 是 成功或者错误消息 +data string 是 无 +5. 集装箱列表 +接口协议 HTTP/WebApi +请求方式: POST +请求地址: https://demovip.saas.carriercentral.vip/api/Container/GetContainerList +输入参数名 类型 必须 参数说明 +loginMark string 是 GUID 登录设备标识, 由请求端生成, 同一设备要 +求值不变, 同登录接口保持一致。 +token string 是 通讯票据(由登录接口返回的值) +data string 是 JSON 字符串 +data 内容 +pagination 分 页 +rows int 是 一页行数 +page int 是 页码 +sidx string 是 按什么排序,例如 F_CreateDate +sord String 是 排序 :desc 或 asc +queryJson 查询参数 +F_ ContainerNo string(50) 否 柜号 +F_ CabinetType string(10) 否 柜型:20GP,40GP,40HQ,40HR,45HC, +45HQ,53HQ 不传默认查所有 +F _ TransMode string(30) 否 运输方式 :海运, 空运, 卡派, 自送 。不传默 +认查所有 +F_Status string(30) 否 状态:0-新单,20-清关结束,30-提柜直达,40-待 +提柜,60-已到仓,65-直送运输中,70-已拆柜,80- +``` + +### 第 7 页 + +``` +已还柜 +F_FBACode string(30) 否 FBACode +F_FBAID string(100) 否 FBA id +F_ ShipmentId string(100) 否 Ref No. +StartTime datetime? 否 开始日期(格式:2025/04/24 或 2025-04-24) +EndTime datetime? 否 结束日期(格式:2025/04/24 或 2025-04-24) +输入示例 JSON +{ +"loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", +"token": "通讯票据(由登录接口返回的值) ", +"data":”{\"pagination\":{\"rows\":10,\"page\":1,\"sidx\":\"F_CreateDate\",\"sord\":\"desc\"},\"queryJson\":"{\"F +_ContainerNo\":\"\",\"F_CabinetType\":\"\",\"F_TransMode\":\"\",\"F_Status\":\"\",\"F_FBAID\":\"\",\"F_Shipment +Id\":\"\",\"StartTime\":\"\",\"EndTime\":\"\",\"F_FBACode\":\"ABE2\"}"} ” +} +输出参数名 类型 必须 参数说明 +code int 是 200:成功 400: 失败 500: 异常 410:没有登 +录信息 +info string 是 成功或者错误消息 +data string 是 JSON 字符串 +data 内容 +total int 是 总数 +page int 是 页码 +records int 是 记录数 +rows List对象 +F_ContainerTaskId String 是 集装箱唯一主键 id +F_ ContainerNo String 是 柜号 +F_ CustomerId String 否 柜号 id +F_ TransMode Int 是 运输方式 0-海运 1-空运 2-卡派 3-自送柜 +F_IsISF Int 是 是否填写 ISF, 0-不填写 1-填写 +F_ CabinetType String 是 柜型 +F_ETD datetime 否 ETD +F_ETA datetime 否 ETA +F_EstimateExtractionTime datetime 否 预计提柜时间 +F _ ExtractionTime datetime 否 实际提柜时间 +F_ATA datetime 否 到港时间 +F_ UnstowTime datetime 否 拆柜日期 +F _ GiveBackTime datetime 否 还柜日期 +F_NotifyGiveBackTime datetime 否 通知还柜日期 +F_Status Int 是 0-新单 1-已导入 Transload 2-已通知 3-已提 +柜 4-已到仓 5-已还柜(已结束) 9 - 异常 +F_ CreateDate datetime 是 创建时间 +F_LoadPort string 否 起运港 +F_Dock String 否 目的港 +``` + +### 第 8 页 + +``` +F_ToWh Date datetime 否 到仓日期 +F_ InvoiceFile String 否 Commericial Invoice 文件 ID +F_BLFile String 否 BL Copy 提单文件 ID +F_ANFile String 否 AN 到货通知文件 ID +F_DOFile String 否 DO 文件 id +F_EntrySummaryFile String 否 Entry Summary 文件 ID +F_ISFFile String 否 客户 ISF 文件 ID +F _ OperationType String 否 业务类型:0 , 1 是拆柜, 2 是直送,4 是只清 +关 +F_ PackingList string 否 Packing liist 文件 id +F_ OfficialISF string 否 正式 ISF 文件 ID +F_ CustomsFile String 否 报关资料 +F_ATD datetime 否 ATD, 实际开船日期 +F_ BLCopyCode String 否 提单号 +F_ AreaCode String 否 库区 +F _ MemoRemark String 否 备注 +F_Classis Int 否 是否自带车架 1-是 0-否 +F _ ShippingLineId String 否 船司 ID +F_ PackingList String 否 Packing list 文件 id +F_ CustomsFile String 否 报关资料文件 id +F_ Su p p li er Id String 否 提柜供应商id +输出示例 JSON +{"code":200,"info":" 响 应 成 功 +","data":{"rows":[{"F_ContainerTaskId":"e4643726-6930-4a92-b9a8-937d59dbcce0","F_ContainerNo":"BC2509 +0910","F_CustomerId":"d1d2fc3d-a4de-4281-a728-fc2d6a145d3f","F_TransMode":0,"F_IsISF":0,"F_CabinetTyp +e":"20GP","F_ETD":null,"F_ETA":null,"F_EstimateExtractionTime":"2025-09-19 +13:00:00","F_ExtractionTime":null,"F_ATA":null,"F_UnstowTime":null,"F_GiveBackTime":null,"F_NotifyGiveBackTi +me":null,"F_Status":0,"F_CreateUserId":"System","F_CreateDate":"2025-09-09 03:11:11","F_CreateUserName":" +超级管理员","F_ModifyUserId":"System","F_ModifyDate":"2025-09-16 20:16:13","F_ModifyUserName":"超级管 +理 员 +","F_WhCode":null,"F_FileName":null,"F_FullFileName":null,"F_Load Port":"","F_Dock":"","F_ANTime":null,"F_Cust +omsBroker":null,"F_SupplierId":"","F_SupplierTrailer":null,"F_AbNormalTime":null,"F_AbNormalInfo":"","F_ToWh +Date":null,"F_BracketFreeDay":null,"F_InvoiceFile":null,"F_BLFile":null,"F_ANFile":null,"F_DOFile":null,"F_EntrySu +mmaryFile":null,"F_ ISFFile":null,"F_CustomsClearanceTime":null,"F_OperationType":0,"F_SourceType":null,"F_Pa +c kingList":null,"F_OfficialISF":null,"F_CustomsFile":null,"F_OpenPhoto":null,"F_ATD":null,"F_FlowId":null,"F_IsPick +Up":null,"F_PickUpCode":null,"F_Pick UpDate":null,"F_PickUpBegin":null,"F_PickUpEnd":null,"F_PickUpAddress":n +ull,"F_ TotalPalltes":null,"F_CustomerBolFile":null,"F_PickAmount":null,"F_ LogAcceptUrl":null,"F_ PrintContent":nu +ll,"F_Instruction":"","F_ShipStatus":null,"F_Report":null,"F_IsCustoms":null,"F_CustomsDate":null,"F_CustomsUser +Name":null,"F_AddressName":null,"F_FullAddress":null,"F_PODFile":null,"F_PODTime":null,"F_IsSecurityFiling":n +ull,"F_ISFDraft":null,"F_IsVWH":null,"F_GW":null,"F_ToContact":null,"F_UnstowData":null,"F_TaskId":null,"F_BLCo +pyCode":"","F_PickUpCost":null,"F_Convert":null,"F_Loader":"","F_LoadingCost":null,"F_IsInner":null,"F_UnstowI +mage":null,"F_ConfirmTime":null,"F_IsCharge":null,"F_OverWeight":0,"F_LFD":null,"F_ISA":null,"F_ISATime":null," +F_StorageType":null,"F_TrainTrend":"","F_AreaCode":"","F_Inspection":0,"F_InspectionDesc":"","F_ContainerId":"" +,"F_MemoRemark":"","F_DismantleStatus":null,"F_New":null,"F_Classis":0,"F_ShippingLineId": +"c3f80d26-3174-4eba-9460-735ce33771da","F_CustomerCode":null,"F_CustomerName":null,"F_ShortName":" +``` + +### 第 9 页 + +``` +Third Party_Usbic","F_SupplierName":null,"CustomsBrokerName":null,"OpObject":null,"markCheckList":null,"shi +pmentList": null," ErrorSortBy":null,"F_ Profit":null,"F_ AreaAddress":null,"F_ UnstowFileList": null,"F_ShippingLineNa +me": +null,"F_ PODFileName":null,"F_PODFileType":null,"F_PODFileUrl":"/Common/DownAnnexesFile?file Id=","F_POD +File Icon":"/Content/images/filetype/.png","F_ChargeFinished":0,"F_MinReportTime":null}],"total":44,"page":1,"r +ecords":44}} +6. 集装箱货件列表 +接口协议 HTTP/WebApi +请求方式: GET +请求地址: https://demovip.saas.carriercentral.vip/api/Shipment/GetShipmentList +输入参数名 类型 必须 参数说明 +loginMark string 是 GUID 登录设备标识, 由请求端生成, 同一设备要 +求值不变, 同登录接口保持一致。 +token string 是 通讯票据(由登录接口返回的值) +data string 是 字符串,集装箱唯一主键 id +输入示例 JSON +{ +"loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", +"token": "通讯票据(由登录接口返回的值) ", +"data":"集装箱唯一主键 id" +} +输出参数名 类型 必须 参数说明 +code int 是 200:成功 400: 失败 500: 异常 410:没有登 +录信息 +info string 是 成功或者错误消息 +data string 是 JSON 字符串 +data 内容 List +F_ Transporter String(30) 是 MoveType:TRUCK,留仓,扣货,UPS,FEDEX, +DHL, USPS, 自提 +F_ID String(50) 是 货件 ID +F_FBACode String(30) 是 仓库代码 FBACode +F_ ShipmentID String(1000) 否 Customer Ref no. +F_CTNS Int 是 箱数 +F_CBM decimal? 是 体积 +F_Weight decimal? 是 重量 +F_Address String(500) 否 地址 +F_FBAID String(2000) 否 FBA Id +F_ ReferenceId String(1000) 否 Reference Id +F_SKU String(50) 否 SKU +``` + +### 第 10 页 + +``` +F_ EnglishName String(500) 否 英文品名 +F_ Description String(500) 否 中文品名 +F_Expected_DeliveryDate +B +datetime? 否 最 早 送 仓 日 期 ( 格 式 :2025/04/24 或 +2025-04-24) +F_Expected_DeliveryDate +E +datetime? 否 最 晚 送 仓 日 期 ( 格 式 :2025/04/24 或 +2025-04-24) +F_City String(50) 否 城市 +F_State String(50) 否 州省 +F_ZipCode String(50) 否 邮编 +F_Contact String(50) 否 收件人 +F_Mobile String(50) 否 联系电话 +F_Email String(100) 否 邮箱 +F_Remark String(2000) 否 行备注 +7. 集装箱状态变更日志 +接口协议 HTTP/WebApi +请求方式: GET +请求地址: https://demovip.saas.carriercentral.vip/api/Container/GetStateTrack +输入参数名 类型 必须 参数说明 +loginMark string 是 GUID 登录设备标识, 由请求端生成, 同一设备要 +求值不变, 同登录接口保持一致。 +token string 是 通讯票据(由登录接口返回的值) +data string 是 字符串,集装箱唯一主键 id +输入示例 JSON +{ +"loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", +"token": "通讯票据(由登录接口返回的值) ", +"data":"集装箱唯一主键 id" +} +输出参数名 类型 必须 参数说明 +code int 是 200:成功 400: 失败 500: 异常 410:没有登 +录信息 +info string 是 成功或者错误消息 +data string 是 JSON 字符串 +data 内容 List +TypeCode Int 是 状态:0-已到港,10-已提柜,20-已到仓,30- +已拆柜,40-已还空 +TypeName String(50) 是 状态名:0-已到港, 10-已提柜, 20-已到仓, +30-已拆柜,40-已还空 +F_ CreateDate datetime? 否 创建时间 +``` + +### 第 11 页 + +``` +F_CreateUserName String 否 创建人 +DataJson String 否 JSON 字符串 +DataJson 内容 +date datetime? 是 时间 +image string 否 图片 +remark String 否 备注 +8. 集装箱货件信息 +接口协议 HTTP/WebApi +请求方式: GET +请求地址: https://demovip.saas.carriercentral.vip/api/Container/GetShipmentInfo +输入参数名 类型 必须 参数说明 +loginMark string 是 GUID 登录设备标识, 由请求端生成, 同一设备要 +求值不变, 同登录接口保持一致。 +token string 是 通讯票据(由登录接口返回的值) +data string 是 字符串,集装箱唯一主键 id +输入示例 JSON +{ +"loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", +"token": "通讯票据(由登录接口返回的值) ", +"data":"集装箱唯一主键 id" +} +输出参数名 类型 必须 参数说明 +code int 是 200:成功 400: 失败 500: 异常 410:没有登 +录信息 +info string 是 成功或者错误消息 +data string 是 JSON 字符串 +data 内容 List +Transporter String(30) 是 MoveType:TRUCK,留仓,扣货,UPS,FEDEX, +DHL, USPS, 自提 +Mark String(30) 是 仓库代码 FBACode +ShipmentID String(1000) 否 Customer Ref no. +Ctns Int 是 箱数 +Pallets decimal? 是 板数 +Status decimal? 是 状态:0-预到达 10-在库 15-在库 16-已排货 +20-已出仓 26-运输中 30-已送达 90-已取消 +PlanDate datetime? 否 计划发车时间 +PlanETA datetime? 否 预计送达日期 +ISATime datetime? 否 ISA 时间 +ETA datetime? 否 ETA +``` + +### 第 12 页 + +``` +ISA String(50) 否 ISA +PODTime datetime? 否 签收时间 +Closed Page String(50) 否 ClosePage 文件 id +PODFile String(50) 否 POD 文件 id +ISAChangeReason String 否 ISA 变更原因 +ProofChange String 否 ISA 变更凭证 +Remark String 否 备注 +9. 集装箱拆箱报告 +接口协议 HTTP/WebApi +请求方式: GET +请求地址: https://demovip.saas.carriercentral.vip/api/Container/GetBillReport +输入参数名 类型 必须 参数说明 +loginMark string 是 GUID 登录设备标识, 由请求端生成, 同一设备要 +求值不变, 同登录接口保持一致。 +token string 是 通讯票据(由登录接口返回的值) +data string 是 字符串,集装箱唯一主键 id +输入示例 JSON +{ +"loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", +"token": "通讯票据(由登录接口返回的值) ", +"data":"集装箱唯一主键 id" +} +输出参数名 类型 必须 参数说明 +code int 是 200:成功 400: 失败 500: 异常 410:没有登 +录信息 +info string 是 成功或者错误消息 +data binary / Blob 否 拆柜报告 PDF 文件 +10. 修改集装箱字段值 +(注意 :上传集装箱文件前 ,若对应字段为空, 可使用该接口将此字段设置成文件 Id 或文 +件夹 Id) +接口协议 HTTP/WebApi +请求方式: POST +请求地址: https://demovip.saas.carriercentral.vip/api/Container/SaveFieldValue +输入参数名 类型 必须 参数说明 +loginMark string 是 GUID 登录设备标识, 由请求端生成, 同一设备要 +``` + +### 第 13 页 + +``` +求值不变 +token string 是 通讯票据(由登录接口返回的值) +data string 是 JSON 字符串 +data 内容 +keyValue string 是 集装箱主键唯一 id +field String 是 字段名 ( +DO: F_DOFile +B/L Copy: F_BLFile +Commericial Invoice: F _ InvoiceFile +Packing list: F_PackingList +报关资料: F_CustomsFile +AN: F_ANFile +) +fieldValue String 是 字段值, 唯一 Id +输入示例 JSON +{ +"loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", +"token": "通讯票据(由登录接口返回的值) ", +"data":"{\"keyValue\":\"729dbc73-e5fc-4207-9e7f-a58f9e2c9773\",\"field\":\"F_DOFile\",\"fieldValue\":\"80e +0a295-f8f6-47e2-be2b-e8faece90694\"}" +} +输出参数名 类型 必须 参数说明 +code int 是 200:成功 400: 失败 500: 异常 410:没有登 +录信息 +info string 是 成功或者错误消息 +data string 是 无 +11. 上传文件 +(注意:上传文件使用 GetContainerList 接口先获取集装箱的文件字段对应 id,如果为空的情 +况使用 SaveFieldValue 接口为对应文件字段赋值) +接口协议 HTTP/WebApi +请求方式: POST +请求地址: https://demovip.saas.carriercentral.vip/api/learun/adms/annexes/upload +输入参数名 类型 必须 参数说明 +loginMark string 是 GUID 登录设备标识, 由请求端生成, 同一设备要 +求值不变, 同登录接口保持一致。 +token string 是 通讯票据(由登录接口返回的值) +data string 是 集装箱对应文件字段的文件夹 id(举例:集装 +箱 F_DOFile 对应的 id) +file binary / Blob 是 上传的文件 +``` + +### 第 14 页 + +``` +输出参数名 类型 必须 参数说明 +code int 是 200:成功 400: 失败 500: 异常 410:没有登 +录信息 +info string 是 成功或者错误消息 +data string 是 文件 id +12. 下载 POD 和 ClosePage +注 :相同文件 id 下载成功次数上限 5 次(不区分账户); +接口协议 HTTP/WebApi +请求方式: GET +请求地址: https://demovip.saas.carriercentral.vip/api/learun/adms/annexes/down +输入参数名 类型 必须 参数说明 +loginMark string 是 GUID 登录设备标识, 由请求端生成, 同一设备要 +求值不变, 同登录接口保持一致。 +token string 是 通讯票据(由登录接口返回的值) +data string 是 字符串,POD 文件 id 或 ClosePage 文件 id(提 +示 : POD 文 件 id 和 ClosePage 文 件 id 在 +GetShipmentInfo 接口返回值中) +输入示例 JSON +{ +"loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", +"token": "通讯票据(由登录接口返回的值) ", +"data":"POD 文件 id 或 ClosePage 文件 id" +} +输出参数名 类型 必须 参数说明 +code int 是 200:成功 400: 失败 500: 异常 410:没有登 +录信息 +info string 是 成功或者错误消息 +data binary / Blob 是 POD 文件 或 ClosePage 文件 +13. 下载拆柜图片 +注 :相同集装箱下载成功次数上限 5 次(不区分账户); +``` + +### 第 15 页 + +``` +接口协议 HTTP/WebApi +请求方式: GET +请求地址: https://demovip.saas.carriercentral.vip/api/Container/DownDevanFile +输入参数名 类型 必须 参数说明 +loginMark string 是 GUID 登录设备标识, 由请求端生成, 同一设备要 +求值不变, 同登录接口保持一致。 +token string 是 通讯票据(由登录接口返回的值) +data string 是 字符串,集装箱唯一主键 id +输入示例 JSON +{ +"loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", +"token": "通讯票据(由登录接口返回的值) ", +"data":"集装箱唯一主键 id" +} +输出参数名 类型 必须 参数说明 +code int 是 200:成功 400: 失败 500: 异常 410:没有登 +录信息 +info string 是 成功或者错误消息 +data String 否 JSON +data 内容 +bytes String 是 拆柜图片文件压缩包(Base64 编码的字符串) +fileName string 是 文件名 +``` + +--- + +*来源 PDF:`carriercentral客户端通用接口V1.72docx(4).pdf`(15 页)* diff --git a/docx/邮件/样例附件清单.md b/docx/邮件/样例附件清单.md new file mode 100644 index 0000000..953f317 --- /dev/null +++ b/docx/邮件/样例附件清单.md @@ -0,0 +1,56 @@ +# 邮件样例附件清单 + +路径约定:`docx/邮件/{邮件N}/` **平铺**业务附件;`*邮箱.pdf` 为 QQ 截图。 + +## 目录 → 实际附件 → 期望位置 + +| 邮件 | 实际(修正前) | 期望位置 | 差异 | 修正 | +|---|---|---|---|---| +| **邮件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` | +| **模板** | 预报/DO 共享模板 | `docx/邮件/模板/`(不属于某一封邮件) | 正确 | `ingest-forecast-ui.ts` 单独用 | + +## 修正后期望树 + +``` +docx/邮件/ + 邮件1/ + QQ邮箱.pdf + 邮件2/ + 2邮箱.pdf + 柜号:MATU2745683卡派资料.xlsx + 邮件3/ + 3邮箱.pdf + WL103276-SBD1(1).pdf + WL103944-WL103940.pdf + WL98967-VGT2(1).pdf + WL99583-LAX9.pdf + 邮件4/ + 4邮箱.pdf + FBA19HW52S0L-76CTN-HIA1-覆盖贴1箱贴2张.pdf + X003UHDF7B-304PCS-覆盖贴1箱贴1张.pdf + YT2604021091=76件换标走HIA1卡派出库操作指令.xlsx + 模板/ + 数据模版.xlsx + 数据模版-新辰泽-WHSU8127240.xlsx + … +``` + +## ingest 策略(`pnpm sample:mails`) + +| key | attachPolicy | 入库附件 | +|---|---|---| +| mail1 | `none` | 无 | +| mail2 | `packing_xlsx` | MATU 卡派 xlsx | +| mail3 | `business` | 全部非截图业务文件(卡转海 PDF);解析仍主题短路 | +| mail4 | `label_instruction` | 换标 xlsx + 指令 PDF;pipeline 先试卡派再试贴标指令单 | + +## 维护命令 + +```bash +pnpm exec tsx scripts/flatten-mail-attachments.ts # 子目录 → 邮件根(幂等) +pnpm exec tsx scripts/scan-mail-samples.ts # 扫描树 +pnpm sample:mails # 按策略入库 +``` diff --git a/docx/邮件/模板/WHSU8127240-DO.pdf b/docx/邮件/模板/WHSU8127240-DO.pdf new file mode 100644 index 0000000000000000000000000000000000000000..316c879518576179a44a1cd4903751524d3eb119 GIT binary patch literal 2218220 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