03 - 技术架构设计(Web SaaS 版)¶
用户帮助页 /help/webhooks 只做静态跳转:正文与截图都由文档站持有(docs/usage/webhooks.md 与 docs/usage/assets/webhooks/,见 13-docs-site.md),跳转目标在构建期由 NEXT_PUBLIC_DOCS_URL 注入,从而保留旧链接、书签与通知设置入口。Web 侧不再持有帮助 Markdown 与截图,也不需要 react-markdown,无需新增 API 或数据库表。
通知渠道仅开放个人与工作区级;多个工作区可各自由 owner 建立渠道,填写同一个机器人地址及签名密钥,每个渠道单独订阅和停用。organization 渠道退役:API 不列出、不创建、不编辑、不测试;数据库迁移停用历史渠道并将未完成投递记 skipped,保留历史记录,数据库约束禁止重新启用。事件匹配与旧 hub 都不再路由组织级频道,投递 worker 也拒绝旧组织级任务;不自动将组织渠道复制到各工作区。
架构形态:从「双进程桌面应用(daemon + Flet UI + SQLite)」转型为「B/S 架构 Web SaaS」,部署于学校内网单机(Docker Compose)。核心领域资产(解析管道状态机、防重三层、附件匹配、批次归档、金额存分等红线)全部保留,执行载体从「线程 + SQLite 队列」换成「BullMQ + PostgreSQL」。
1. 技术选型¶
| 层 | 选型 | 说明 |
|---|---|---|
| Monorepo | pnpm workspace + Turborepo |
前后端同仓,共享类型与领域包;任务编排带缓存 |
| 语言 | TypeScript 5(strict 全开,含 noUncheckedIndexedAccess) |
全栈统一语言,前后端 API 契约可共享 |
| 前端框架 | Next.js(App Router)+ React |
仅作为 React 应用框架与路由方案,纯客户端渲染,不使用 API Route / SSR 数据能力;output: 'standalone' 容器化 |
| UI 组件 | shadcn/ui + Tailwind CSS |
组件代码进仓可改;表格密集型业务以 Table/Form/Dialog/Sheet 为主 |
| 主题 | tweakcn |
设计 token(CSS 变量、亮暗色)由 tweakcn 生成并维护,落 globals.css,不引第三方主题库;运行时明暗切换由自持 ThemeProvider 管理(localStorage 键 poco.theme,默认深色,支持跟随系统) |
| 服务端状态 | TanStack Query + Zustand |
Query 管服务端数据(发票/批次/任务),Zustand 只放轻量客户端状态 |
| 表格/表单 | TanStack Table + react-hook-form + zod |
zod schema 放 packages/shared,与后端校验同一份定义 |
| 后端框架 | NestJS(REST,模块化) |
模块边界清晰,适合中等规模业务域;统一依赖注入与守卫/管道 |
| ORM | Prisma + PostgreSQL 16 |
类型安全、Prisma Migrate 承担 schema 迁移(替代 Alembic) |
| 队列/缓存 | Redis 7 + BullMQ |
解析/导出异步任务;解析 worker 并发 2 + 队列级限速 max: 2 / duration: 1000ms 双保险匹配百度 QPS=2,导出独立队列并发 1 + 限速 1/s(§5.4) |
| 定时任务 | worker 进程内轮询器(60s 间隔) | 邮箱 IMAP 轮询(扫描 due 账户,§6) |
| 实时推送 | SSE(Server-Sent Events) | 任务进度、站内通知、仪表盘指标增量刷新 |
| 认证 | better-auth(自托管,username + admin 插件,Prisma adapter,httpOnly session cookie) |
数据进本仓 PG;预留 CAS/LDAP/OIDC 接入(genericOAuth),见 §11 |
| OCR | 百度智能云「增值税发票识别」(requests 直调 → undici/axios 直调),Provider 抽象不变 |
字段映射见 05-ocr-fields.md |
| 邮箱拉取 | imapflow(IMAP over SSL:993) |
比 node-imap 现代、流式解析;QQ/163/Gmail/企业邮箱通用 |
| Excel | ExcelJS(模板读取 + 填充) |
替代 pandas + openpyxl,模板可替换 |
pdf-lib(合并 + 图片转页 + 封面清单页生成) |
替代 pypdf + img2pdf + reportlab | |
| 密钥存储 | AES-256-GCM 信封加密落库(主密钥在 .env) |
SaaS 多用户下 keyring 不可用(单机桌面方案),见 §12 |
| 通知子系统 | 站内通知(notification 表 + SSE)+ 机器人 Webhook(钉钉/飞书/自定义,node:crypto HMAC 加签) |
桌面通知频道随桌面形态一起移除 |
| 自动化填报 | Ego-lite(P2/P3,半自动:人工确认提交) | 不变 |
| Lint / 格式化 | ESLint 9(flat config)+ Prettier + commitlint + husky |
共享配置收敛在 packages/eslint-config,见 §9.4 |
| 部署 | Docker Compose(web + api + worker + postgres + redis;Caddy 反代在宿主机) | 学校内网单机;HTTPS 用内部 CA / 自签证书 |
2. 总体架构¶
单机多容器:api(HTTP)与 worker(队列消费 + 定时任务)分离部署、同仓同构;PostgreSQL 是唯一事实源,Redis 承担队列与跨进程事件总线,文件落在宿主机挂载卷。
校园内网(HTTPS,内部 CA 签发)
┌──────────┐
│ 浏览器 │ 教职工/学生 用户
└────┬─────┘
│ / → web(Next.js,静态资源 + SPA)
│ /api/v1/* → api(NestJS,REST + SSE)
▼
┌─────────────────────────────────────────────────────────────┐
│ Caddy(宿主机 80/443 反向代理;SSE 需即时 flush) │
└──────────┬──────────────────────────────┬───────────────────┘
│ │
▼ ▼
┌────────────────────┐ ┌────────────────────┐
│ api(NestJS) │ │ web(Next.js) │
│ REST / SSE / 鉴权 │ │ standalone 静态 │
└───┬─────────┬──────┘ └────────────────────┘
│ │ publish/subscribe(任务事件、通知)
│ ▼
│ ┌──────────┐ enqueue / dequeue
│ │ redis │◀──────────────────┐
│ └──────────┘ │
▼ │
┌──────────────┐ ┌─────────┴──────────────┐
│ postgres │◀───── Prisma │ worker(Node 入口) │
│ (事实源) │ │ • BullMQ 解析 ×2 并发 │
└──────────────┘ │ (限速 QPS=2) │
│ • 附件子管道 │
│ • 导出任务(xlsx/pdf) │
│ • 邮箱 IMAP 拉取 │
└─────┬───────────┬──────┘
│ │
┌────────────▼──┐ ┌─────▼─────────┐
│ ddddocr │ │ 百度 OCR │
│ sidecar │ │(出站 HTTPS, │
│(验证码本地 │ │ 可配代理) │
│ onnx 推理; │ └───────────────┘
│ api/worker │
│ 内网访问) │
└───────────────┘
┌──────────────┐
│ /data 文件卷 │
│ 内容寻址 blob │
└──────────────┘
职责边界:
- api:只做 HTTP。鉴权、CRUD、上传落盘、入队、SSE 下发。不直接做 OCR 调用(
re-parse也走队列)、不做任何文件副作用。 - worker:全部长耗时副作用——OCR 解析、附件匹配、Excel/PDF 导出、邮箱轮询。完成后
PUBLISH事件到 Redis,api 订阅后经 SSE 推给前端。 - web:纯前端。所有数据经
/api/v1,不直连数据库、不直连 Redis(原「UI 是遥控器」红线的 Web 版)。 - postgres:唯一事实源;跨进程一致性一律以库为准,Redis 可丢可重建(队列任务在库中有
parse_task行兜底)。 - ddddocr sidecar:验证码求解专用伴生容器(Python + onnx 本地推理)。无凭据、无出站、无 QPS 限制;仅 compose 内网供 api(test 端点现场登录)与 worker(finance-sync)访问,不暴露端口。
2.1 单用户 → 多用户协作(数据隔离模型)¶
学校内网部署 = 单组织、多用户(非公有云多租户 SaaS)。为保留个人账本的简洁性、同时支持课题组/部门协作,隔离根采用「组织 → 报销工作区 → 成员」,而非在每张表上直接挂 user_id:
organization:部署数据根;当前学校只有一条记录,但从首版保留,以免未来多个独立单位需要重构。workspace:业务与文件的直接隔离边界,分personal(开户时自动创建)和shared(项目、部门或出差协作)。发票、附件、任务、批次、邮箱账户、额度计划均挂workspace_id;发票另留submitted_by_user_id以保留责任归属。workspace_member:用户与工作区多对多关系,角色为owner/manager/member/viewer。系统admin只管理用户和组织级配置,不以隐式全库访问替代成员关系;排障访问必须记录审计。- 共享域:用户目录与组织级分类规则/通知频道由系统管理员管理;分类规则和通知频道还可在工作区范围覆盖,工作区 manager 按能力维护,优先级见 04 文档。
- Prisma middleware 自动加入当前
workspace_id,但 PostgreSQL RLS 才是最终访问控制边界;复合外键保证附件、任务、发票、批次不能跨工作区关联。 - 原桌面版「一个工作区文件夹 = 一个库」收敛为:一个工作区 = 一棵虚拟归档树(
archive_path取值空间,见 §10;物理文件永驻根级 blob 池,磁盘上无工作区目录)。
完整 schema、RLS、组织级防重与导入方案见 04-database.md。
3. Monorepo 结构¶
pnpm workspace + Turborepo,apps/* 可部署单元、packages/* 共享库:
better-call-poco/
├── apps/
│ ├── web/ # Next.js 前端(§9)
│ │ ├── app/ # App Router:路由与页面
│ │ ├── components/ # shadcn/ui 生成组件 + 业务组件
│ │ ├── lib/ # api client(由 shared 生成)、utils
│ │ └── globals.css # tweakcn 主题 token(亮/暗)
│ ├── api/ # NestJS HTTP API(§4)
│ │ └── src/
│ │ ├── auth/ users/ invoices/ attachments/ batches/
│ │ ├── uploads/ mail-accounts/ rules/ quota/
│ │ ├── notifications/ notify-channels/ stats/ events/(SSE)
│ │ └── main.ts
│ ├── worker/ # 队列消费者 + 轮询器(§5~§7)
│ └── src/
│ ├── parse/ # processor(抢占/状态机)+ recovery
│ │ # (§5.3 启动补偿:孤儿重置 + pending 重投)
│ ├── export/ # xlsx/pdf/zip 产物生成 + 物化(§5.4)
│ ├── mail/ # fetcher + imapflow 邮源 + 账户锁
│ │ # + poller(60s 扫描 due 账户,§6)
│ ├── notify/ # 通知 hub(§8.2 收口:inapp 落库/SSE/
│ │ # webhook 路由)+ Redis 聚合存储
│ └── main.ts # Worker 实例、事件日志、优雅退出
│ └── ddddocr-sidecar/ # 验证码求解 sidecar(Python,§6.1):
│ # pnpm dev 与 compose 自动拉起,uv 缺失
│ # 时 dev 打印警告跳过
├── packages/
│ ├── shared/ # API 契约:zod schema、TS 类型、错误码枚举、
│ │ # 金额/日期格式化工具(前后端同一份)
│ ├── core/ # 领域纯逻辑(无 IO,100% 可单测):
│ │ # 清洗(金额转分/日期解析)、分类器链、
│ │ # 附件信号匹配、归档路径/命名计算、
│ │ # decision/(通用决策层)+ reminders/(报销规则)
│ ├── providers/ # InvoiceProvider 接口 + baidu 实现
│ │ # (token 管理、429 退避、字段映射)
│ ├── db/ # Prisma schema + migrations + client
│ └── eslint-config/ # 共享 ESLint flat config + Prettier
├── docker/ # compose.yml、各 Dockerfile、Caddyfile
├── turbo.json
├── pnpm-workspace.yaml
└── package.json
依赖方向(turbo 按此拓扑编排 build/lint/test):
core 不依赖任何 app;shared 不依赖 core(契约独立演进);db 只被 api/worker 引用。
3.1 常用命令¶
pnpm install # 安装依赖
pnpm dev # turbo run dev --parallel(web:3000 api:4000)
pnpm build # 全量构建(带 turbo 缓存)
pnpm lint / pnpm format # ESLint / Prettier(全仓)
pnpm test # Vitest 单测(core/shared/providers 必测)
pnpm db:migrate # prisma migrate deploy(生产)
pnpm db:dev # prisma migrate dev(开发,生成 migration)
docker compose -f docker/compose.yml up -d # 内网部署
4. 后端架构(NestJS)¶
4.1 模块划分¶
| 模块 | 职责 | 备注 |
|---|---|---|
auth |
better-auth 实例装配(prismaAdapter / 插件 / trustedOrigins)+ 薄桥接路由 /auth/*、/admin/*;用户与角色管理由 admin 插件承担 |
见 §11;预留 CAS/LDAP |
workspaces |
工作区与成员 CRUD、owner 转让(隔离根,§2.1) | 端点见 08 文档 §3.2 |
uploads |
multipart 上传 → 原子写入内容寻址 blob 层 → 建 parse_task + 入队 |
§5.2 |
invoices |
发票查询/修正/删除/重解析 | re-parse 投队列,不同步调 OCR |
attachments |
附件列表、人工配对、上传/删除 | 待配对页后端 |
batches |
建批/入批/拆批/排序/状态流转 | 迁移文件的动作由 worker 执行(经队列) |
mail-accounts |
邮箱账户 CRUD、测试连接、手动触发拉取 | 授权码信封加密落库 §12 |
rules |
分类规则 CRUD(组织 admin / 工作区 manager / 个人 user) | 规则是分类器链的规则环(§5.1),优先级见 04 文档 |
notifications |
站内通知列表/已读 | |
notify-channels |
Webhook 频道 CRUD、测试发送 | 加签密钥信封加密落库 |
quota |
额度计划/统计/快照 | M2+ |
usage |
平台用量计量与配额执行(调用计数/token、成本、组织配额,见 §5.7) | M2+ |
stats |
仪表盘指标聚合 | |
admin-dashboard |
管理大屏全局聚合(system admin,见 §5.8) | M2+ |
events |
SSE 端点、Redis 订阅下发 | §8.3;事件格式见 08 文档 §4 |
4.2 通用约定¶
- 分层:
controller(HTTP 契约,参数经 zod pipe 校验)→service(用例编排,调core领域包与 Prisma)→core/providers(纯逻辑与外部 IO 抽象)。controller 不写业务分支。 - 文件副作用一律不在 api 进程执行,且范围收窄为三种:上传写入 blob 层、导出时物化交付包、引用计数归零后的 blob 物理回收。归档、入批、挂载、软删均只写库(状态 +
archive_path虚拟路径),不产生任何磁盘动作——「先写库、后动文件」仅对上述三种副作用生效。 - Prisma 全局 middleware:业务查询自动追加已验证的
workspace_id;PostgreSQL RLS 与同工作区复合外键是最终越权兜底,事务惯例见 04 文档。
5. 解析管道(状态机保留,执行器换 BullMQ)¶
5.1 输入源与汇入点¶
桌面版的两个输入源(本地拖入 / 邮箱拉取)在 Web 版对应为:
| 桌面版 | Web 版 | 汇入点 |
|---|---|---|
| watchdog 监听 Inbox | 网页上传(文件/文件夹拖拽或多选,api POST /uploads) |
浏览器递归展平目录 → blob 层落盘 → parse_task |
| MailFetcher 附件落地 Inbox | worker 轮询拉取,附件原子写入 blob 层 | 收敛至同一 blob 层 → parse_task |
与桌面版「邮箱汇入 Inbox 目录」同理:两个输入源汇入同一个 blob 层 + parse_task 管道(原始字节按 sha256 内容寻址全局去重),稳定检测(2s 大小不变)改为上传完成事件天然触发,哈希/票号防重、OCR、归档全复用,零管道分叉。网页上传与邮箱附件同口径:'wx' 独占创建原子写入 blob 层(撞名即同内容去重),崩溃只留无库引用的孤儿字节。
5.2 任务生命周期(状态机)¶
上传完成 / 邮箱附件落地
│
▼
扩展名白名单(pdf/jpg/png)+ 基础校验(大小上限、空文件)
│
▼
计算 SHA256 → 查 workspace_file(workspace_id, blob_id)
│ └─ 命中且其发票/附件已软删 ──▶ 撤销软删(恢复原记录)+ 前端 toast
│ └─ 命中且非软删 ──▶ 跳过 + 通知"文件已存在"
│ 未命中
▼
INSERT workspace_file + parse_task(status=pending) + BullMQ 入队
← `(workspace_id, blob_id)` 唯一约束兜底同空间并发重复
│
▼ (BullMQ 派发:并发 2 + 队列限速 max=2/duration=1000ms)
┌────────────────────────────────────────────┐
│ processing │
│ 1. POST 百度 OCR(vat_invoice) │
│ └ 282103 非发票模板 → 附件子管道 §7 │
│ 2. Adapter → StandardInvoice(金额转分) │
│ 3. 业务级查重:invoice_num (+code) │
│ ├─ 命中 → task=duplicate(不发通知) │
│ 4. 分类器链 → INSERT invoice(parsed, │
│ category + category_source) │
│ 5. 写 archive_path(Archive/unbatched/ 内 │
│ 虚拟路径,纯写库不动文件) │
│ 6. UPDATE invoice(archived), task=done │
│ 7. PUBLISH task.done / invoice.created │
│ → SSE + info「新增发票」通知 │
└────────────────────────────────────────────┘
│ 任一步异常
▼
BullMQ attempts(退避 30s/2m/10m)
├─ <3 次:重试(attempt 退避与桌面版一致)
└─ ≥3 次:task=failed(文件不动,物理上仍在 blob 层),PUBLISH SSE(不发站内通知)
与桌面版的映射:threading.Thread × 2 → BullMQ worker concurrency=2;Lock + SELECT pending → UPDATE processing → BullMQ 原子领取(Redis);retry_count 列 → BullMQ attempts/attemptsStarted(parse_task 行仍留状态字段,供 UI 与补偿查询)。
消费侧约定(apps/worker/src/parse/):
- 抢占幂等:processor 先以服务角色
UPDATE ... WHERE status IN (pending/failed/processing)抢占(写worker_id),终态(done/duplicate)任务重复投递直接跳过——stalled 重派、启动补偿重投、队列双路入队都在此收敛为单次执行。 - 错误分类:
OCR_PROVIDER_NOT_CONFIGURED(Provider 未接入/未配置)与FILE_BLOB_MISSING(物理对象缺失)为永久性错误,落failed并抛UnrecoverableError跳过退避重试;其余按OCR_PROVIDER_ERROR可重试(next_retry_at按三档退避预估),由 BullMQ 依parse-retry策略驱动。
分类器链(category 归属判定,packages/core):分类是可插拔的有序分类器管道,而非一次性 if-else。契约:
interface CategoryClassifier {
readonly name: string; // 'rule:user' | 'rule:workspace' | 'rule:organization' | 'taxonomy:default' | 'service_type' | 'model:*'
classify(ctx: InvoiceSignal): Promise<ClassifierResult | null>; // null = 放弃,交下一环
}
- 链上第一个给出结论的生效,全部放弃 →
未分类;InvoiceSignal(serviceType/sellerName/itemsRaw/金额/日期)是各环共享的输入。 - 链序(越靠前越权威):用户规则 → 工作区规则 → 组织规则(各层
priority升序,见 04 文档 §7)→ 内置科目词典(taxonomy:default)→ServiceType直映(兜底)。规则口径先于 OCR 原生类别:目标分类体系是组织报销科目,用户/组织显式规则必须能覆盖税收分类直映的结论。 - 结论落库带
category_source(分类器name):各环结论可区分、可对比评估、可重放。 - 模型判别环(预留):LLM/ML 分类器实现同一契约插入链尾兜底未分类(插入位置届时按效果调整,属配置不属架构);
ocr_raw_json完整留存使其可零成本重放历史发票。
分类科目体系(一级/二级 受控取值):标准分类是浙大预约报销日常报销(bxOrd=1)金额页 2360 的 48 个叶子。category 仍取 一级/二级 名称(如 交通费/市内交通费);同名二级(两处 图书资料费)靠层级前缀消歧。金额页另有一条与 B0902 同名同预算科目的印刷费 B0904,分类字符串写成 印刷费/印刷费(B0904),关键词留空,自动命中仍落 B0902。每个叶子同时带门户编码:bCode(报销项,如 B0101)和 buCode(预算科目,如 AA47020201)。写入预约金额行时用这两个编码,发票分类字符串不改成编码。差旅、出国、版面费是别的 bxOrd,不在这 48 个叶子里。
packages/core 的 CAMPUS_TAXONOMY 与这 48 个叶子一一对应(条目 = 一级 + 二级 + bCode + buCode + 说明 + 匹配关键词)。关键词沿用规则环匹配语义,只挂在能从票面稳定认出的叶子上;劳务、津贴、工资、工会、博士后等叶子编码在册,关键词留空,避免把普通发票误归进去。taxonomy:default 环按条目序取首个关键词命中。组织级自定义科目表后续以配置/表替换词典,链结构不变。ServiceType 直映环产出的国税税收分类简称经别名表归一到同一套叶子(如 电信服务 → 邮电费/邮电费、经营租赁 → 其他/租赁费),无对应叶子保留原值——未强行归类的诚实结论留给规则或模型环补齐。
5.3 崩溃自愈(补偿收敛)¶
- stalled 检测:BullMQ
stalledInterval(默认 30s)识别 worker 崩溃滞留的任务自动重派;maxStalledCount=1,二次 stalled 直接 failed(防止无限重派烧配额)。 - 启动补偿(worker 启动时执行):库中滞留
processing超过 10 分钟且无对应 BullMQ 活跃 job 的任务 → 重置pending并重新入队。 - 归档/入批/挂载均为纯写库,不存在文件态补偿;唯二的文件副作用(上传写 blob、导出物化)各自天然崩溃安全(blob 写入即原子,导出临时文件丢弃重导即可)。
5.4 异步导出物化(M2)¶
导出走独立 export 队列(attempts=1 无重试:导出是一次性物化,失败即终态 failed,用户重新触发即可),worker 以并发 1 + 队列限速 1/s 消费(CPU/IO 密集,与 OCR 限速互不挤占):
api:POST /batches/:id/export/:kind
│ RLS 校验批次归属 → INSERT export_job(processing) → 入队
▼
worker:job 行仍为 processing 且 workspace 一致才执行(终态/错位 → skipped)
│ 汇集批内发票(in_batch、按 batch_order)与各自 matched 附件
▼
按 kind 生成产物字节
├─ xlsx:标准报销单.xlsx(固定三 sheet:汇总 / 价税分离明细 / 附件清单;金额列走整数算术后格式化,单元格不引入 float)
├─ pdf :打印版.pdf(主票与附件按批次顺序合并,无封面页;图片自动转 A4 页;主票 blob 缺失 → EXPORT_INVOICE_BLOB_MISSING 终态)
└─ zip :{批次名}.zip(批次目录下发票、附件与既有导出物按 archive_path 虚拟结构物化)
│ 产物字节 → blob 物理对象('wx' 内容寻址)——失败只留孤儿字节
▼
同事务:file_blob(sha256 命中去重)+ workspace_file(挂批次目录)+ export_job 终态
│ 终态写以 status='processing' 为条件:并发重复投递只有赢家发布事件
▼
PUBLISH export.completed(done 携 downloadUrl / failed 携 error)
打印版 PDF 不生成封面页,主票与附件直接按批次顺序合并;图片按原始比例等比缩放到 A4 页并居中,避免页面尺寸不一导致打印裁切。重复导出生成新 blob 与新 workspace_file 行,旧产物保留可追溯。
5.5 决策层与报销规则提醒¶
packages/core/src/decision 提供一个与业务无关的通用决策层:把「二选一/多选一/打分」的判断收敛成一次 decision——给定 decisionId 与 state,由注册的 provider 给出带来源的 action。规则(确定性)与 System One 决策模型(概率性:TypeSafe Jev / Cloudflare Clef)是平等的 provider,每个 decision 在注册时显式声明 routing,不做全局自动路由(抽象参考 Agentero#598,落到本仓的纯函数/可测试形态)。
interface DecisionProvider {
// rule / System One(jev、clef)/ 未来 LLM 同一契约
readonly name: string;
decide(call: ProviderCall): DecisionOutcome | null | Promise<DecisionOutcome | null>;
}
type DecisionRouting = { primary: string; fallback?: string; fallbackThreshold?: number };
- 引擎按
routing调用主 provider:无意见(null)、置信度低于阈值、或报错且配了 fallback 时转 fallback;都没有则用defaultAction。结果标注provider(rule/jev/clef/default/none),便于排查与 fallback 调试。 RuleProvider按注册顺序执行规则链,首个非空 action 生效、无置信度。- System One 决策模型(
packages/core/src/decision/model):统一契约把一次 decision 的state与 typedquestions(noul是/否概率、choice多选、score打分)发给模型,再按 schema 的resolve把answers裁决成带confidence的 action。SystemOneProvider只做胶水(纯逻辑、client 注入),厂商 HTTP/凭据在packages/providers的JevClient(https://api.typesafe.ai/v1/systemone)与ClefClient(Cloudflare Workers AI/ai/run/@cf/cloudflare/clef,支持clef/clef-flash与内联图片);业务层禁止直连模型 HTTP(红线 7)。DECISION_MODEL=off|jev|clef|clef-flash选择后端,未配置即不启用。createDecisionModelClient默认给客户端套进程内结果缓存CachedSystemOneClient:键 =sha256(厂商:模型 + 稳定序列化的 state/questions/images),LRU + TTL(DECISION_MODEL_CACHE_TTL_MS默认 15 分钟、DECISION_MODEL_CACHE_MAX_ENTRIES默认 5000,任一设为 0 关闭),只缓存成功结果——避免GET /invoices逐票实时求值同一张发票时重复计费。
报销规则提醒(packages/core/src/reminders)是决策层的第一个落地用例:每条报销规则注册成一个 decision(invoice.reminder.*),缺省统一走 RuleProvider,命中即产出提醒;配置决策模型后,invoice.reminder.transversal-funds、invoice.reminder.entertainment-endpoint 与 invoice.reminder.entertainment-gift-review 升级为「模型主 + 规则兜底」(见下)。
| decision | 规则 | 级别 |
|---|---|---|
invoice.reminder.taxi-itinerary |
打车发票 > 200 元且未挂行程单 | warning |
invoice.reminder.qr-verify |
单张发票 > 500 元需扫码验证 | warning |
invoice.reminder.fixed-asset |
单张发票 > 1000 元需固定资产认定 | critical |
invoice.reminder.prohibited-entertainment-gift |
明确命中休闲娱乐业或礼品关键词(普通餐饮排除),原则上不予报销 | critical |
invoice.reminder.entertainment-gift-review |
模糊休闲消费词仅由 System One 提示人工复核,不直接判违规 | warning |
invoice.reminder.invoice-completeness |
发票号码、开票日期、金额、购方名称、销方名称缺失或无法确认 | critical |
invoice.reminder.purchase-detail |
办公用品、材料、耗材、设备、家具、图书、货物等购货类发票未识别到商品明细 | warning |
invoice.reminder.transversal-funds |
日用品/消耗品(含抽纸、印章、挂钩等)需走横向经费 | info |
invoice.reminder.reimburse-deadline |
超过报销时限:当年票据应在当年内报销;当年 12 月票据可顺延至次年第一季度(3 月 31 日) | warning |
invoice.reminder.taxi-out-of-city |
打车发生地不在本市(杭州市);城市取自行程单结构化结果 | warning |
invoice.reminder.entertainment-endpoint |
行程起点或终点疑似饭店/娱乐场所(饭店、台球厅、KTV 等);起终点取自行程单结构化结果 | warning |
提醒实时计算、不落库:GET /invoices 在服务层对每张发票求值 evaluateInvoiceReminders(输入含附件类型与行程起点/终点,用于判定行程单是否已挂载、非本市打车、起终点场所),reminders 随发票列表下发(契约见 08 §3.4),前端在发票库用徽章 + 悬浮明细提示。规则可随时扩充,历史发票自动重算。
决策模型接线:DECISION_MODEL 选定后端后,API 启动时为引擎注册 SystemOneProvider,并给 transversal-funds、entertainment-endpoint、entertainment-gift-review 附加 providerConfigs(把发票信号构造成 state,各问一个 noul 问题;概率经 noulConfidence = |p-0.5|*2 折算成置信度),routing 统一为「主 = 模型、fallback = 规则」:
transversal-funds:fallback 是关键词规则。模型高置信「是」产出jev/clef提醒;不确定或倾向「否」时回退关键词规则;规则也无意见则保留模型弱结果(引擎既有语义)。entertainment-endpoint:起终点取自行程单结构化结果,先剔除浙大校内/食堂地点(/浙江大学|浙大|食堂/),全为校内则不调用模型;resolve内置下限noul≥0.65(ENTERTAINMENT_ENDPOINT_CONFIDENCE_THRESHOLD),低于则无意见。该规则无确定性 fallback,未启用模型时保持静默。entertainment-gift-review:只在票面命中电影、演出、健身、养生等模糊休闲词时调用;普通餐饮及常规业务票据先排除。模型命中只产生“待人工复核” warning;KTV、酒吧、棋牌、礼品卡等明确关键词永远由纯规则直接产出 critical,不调用模型。- 模型报错被引擎吞掉后回退规则;未配置模型时整体保持纯规则。其余 decision 不受影响,后续可把更多规则按同一契约升级,只换 routing 与 provider config。
5.6 批次选票策略层¶
建批时的「智能组单」是决策层的姊妹能力:把「选哪些发票入批」建模成可解释的策略规划,落在 packages/core/src/strategies(纯函数),API 经 POST /batches/suggest 调用,只产出建议、不自动入批(用户确认后再走既有入批,见 11 §suggest_batch)。
- 判定复用
packages/core/src/detection(打车 / 3C / 日用品),与报销规则提醒同一套关键词,避免散落。 - 策略口径:
maxSingleAmountFen单张上限硬过滤(「不要有大额」);priorities(优先打车 / 优先3C / 小额优先)按序排序,同级金额从大到小以尽快凑满;targetAmountFen目标额度贪心装满,装不下的跳过并给出原因。 - 输出
selected / excluded / skipped(各自带reason)与selectedTotalFen:前端在建批弹窗配置策略,创建后带进入批弹窗预勾选,用户可改。 - 目标装满为启发式(非精确子集和),仅供确认;额度感知与精确解(docs/06 M3)后续再接。
批次的报销类型(daily / trip / fixed_asset,见 04-database §5)与选票策略相邻:入批时按发票推断类型并自动分流——packages/core/src/batch-kind.ts 的 inferBatchKind 把 category 一级为「固定资产」的发票归 fixed_asset,其余归 daily;目标类型之外的发票由 Web 端复用好同类型的 collecting 批次、没有则新建(纯写库,红线 6)。出差(trip)的自动判断暂未实现(TODO:行程单城市/差旅科目等信号),需用户在入批时显式选择,选中后本批发票全部归入。
5.7 平台用量计量与配额执行¶
计量层统计外部付费调用(OCR、Jev/Clef),与业务「报销额度(quota)」无关。分层原则:计费只对真实外部调用,缓存命中不计费但可观测。
SystemOneProvider
└─ CachedSystemOneClient # 外层:LRU+TTL,命中返回缓存(§5.5)
└─ MeteredSystemOneClient # 内层:只有真实 HTTP 调用到此
└─ JevClient/ClefClient
OCR: withUsageMetering(BaiduProvider) # parse/recognizeText/recognizeDocument 各记 1 次 call
- 归因上下文:
@poco/providers用AsyncLocalStorage(runWithUsageContext)承载organizationId/workspaceId/userId/sourceType/sourceId;API 在发票列表求值前注入、worker 在解析 job 内注入,不改领域类型。 - 计量事件(
UsageRecord→UsageSink):成功调用记call+ 模型返回的input_token/output_token;失败记failed;配额拦截记blocked。落库实现PrismaUsageSink(幂等写usage_event)。无上下文(CLI/单测)时静默跳过。 - 配额执行(
UsageGuard):createContextUsageGuard(reserveUsage)在调用前预检——按(organization, provider, 当期 period)命中原配额,超限且overagePolicy=block抛UsageLimitExceededError,否则原子自增used_units。未配置配额 = 放行。模型侧超限被决策引擎吞掉后回退规则(§5.5);OCR 侧映射为终态USAGE_LIMIT_EXCEEDED。 - 成本/计费分离:
provider_price记供应商成本,客户价格(套餐/席位/超额)后续接入;金额全程整数 micros(红线 1)。 - 组织级配额之外的「个人免费层」(OCR):每个用户每月 10 次免费识别(按上传文件 =
parse_task计,无新计数表);无自备 key 且超额时,上传前置校验抛OcrQuotaExceededError(上传结果QUOTA_EXCEEDED_OCR)。用户在设置页填写的百度 Key 信封加密落库ocr_credential,worker 解密后并入全局号池(§5.7),额度对所有用户共享(docs/05 §4.1)。
5.8 管理大屏(系统管理员全局只读)¶
面向系统管理员(user.role=admin,AdminGuard)的平台运营大屏:用户量、各 API 用量与成本、各功能使用。跨组织聚合,故服务端以服务角色(withServiceRole)穿透 RLS,只读、不落库、不写审计(无单资源访问)。
- 数据源:
user/organization/workspace/invoice/parse_task/reimburse_batch/export_job/attachment/mail_account/finance_portal_account/notify_channel计数,叠加计量层usage_event(按 provider 的 call/token/成本,见 §5.7)。 - 口径:活跃用户 = 窗口内
parse_task.requestedByUserId去重;按日趋势用to_char(col AT TIME ZONE 'UTC','YYYY-MM-DD')归日并补零;金额整数(发票 fen、用量 micros,红线 1)。 - 端点:
GET /admin/dashboard?days=7|14|30(默认 14),返回 08 §3.6 的聚合契约。 - 前端:
/admin路由 +apps/web/components/admin-dashboard.tsx,零第三方图表依赖(纯 SVG 折线/条形),每 15s 自动刷新;账户菜单对 admin 显示「管理大屏」入口。
6. 邮箱拉取管道(cron 化)¶
管道逻辑(增量水位、UIDVALIDITY、筛选、防重)与桌面版 §3.4 完全一致,差异仅在执行载体与凭据存储:
worker 轮询器(60s 间隔)扫描 due 账户
(enabled 且 last_fetched_at + poll_interval 到期)→ 入 mail-fetch 队列
│ POST /mail-accounts/:id/fetch 手动触发同队列(入队不带 jobId,账户级锁防并发重扫)
▼
账户 Redis 锁(SET NX PX 300s,compare-and-del 释放)
│ cron 与手动并发同账户 → 后到者直接返回 locked
▼
imapflow 连接(授权码 AES-256-GCM 解密自库,不进日志/异常栈)
│
▼
增量查询 UID > mail_account.last_seen_uid(先校验 UIDVALIDITY;
变更/首次按 lookback_days 回扫,Message-ID 去重)
│
▼
逐封筛选:发件人白名单 → 主题关键词(附件扩展名属取附件环节)
│ 不命中 → INSERT mail_message(skipped_no_match)
▼ 命中
取合规附件(扩展名白名单 + 10MB 上限 + 文件名收敛 basename;`.ofd`/`.xml` 附属格式直接跳过不入库)
│ 无合规附件 → INSERT mail_message(fetched_no_attachment)
▼ 有
附件原子写 blob 层('wx',撞名即同内容去重)→ 同事务
file_blob + workspace_file + parse_task(pending) + mail_message(imported)
→ 入 parse 队列 → 打 \Seen(可关,失败不致命)
│
▼
整轮完成:推进 last_seen_uid / uid_validity 水位,清零 consecutive_failures
│
▼
parse_task → §5 管道(物理/业务防重照常生效)
- 崩溃安全顺序不变:已处理邮件的
mail_message行先于水位提交;崩溃后重扫由UNIQUE(mail_account_id, message_id)与workspace_file(workspace_id, blob_id)唯一约束双拦截,最终一致。 - 失败处理:连续 2 轮连接/鉴权失败只累加
consecutive_failures、错误落mail_account.last_error供设置页展示,不发站内通知;成功一轮清零。每轮拉到新增发票时发一条info通知(收件人 = 账户创建者)。 - 协议边界不变:仅 IMAP over SSL(993);
POST /mail-accounts/:id/fetch可手动触发(入轻量队列,避免轮询与手动并发重扫——以账户为键加 Redis 锁)。
6.1 财务门户同步管道(finance-sync)¶
以浙大计财处门户(cwcx.zju.edu.cn/WFManager)为首接入源的自动登录与会话建立管道,形态与邮箱管道同构(轮询 + 队列 + 账户锁 + 失败记账):
worker 轮询器(60s 间隔)扫描 due 账户
(enabled 且 last_sync_at + poll_interval 到期)→ 入 finance-sync 队列
│ POST /finance-accounts/:id/sync 手动触发同队列(入队不带 jobId,账户级锁防并发重扫)
▼
账户 Redis 锁(scope=finance-sync,SET NX PX 300s)
│
▼
providers 层 ZjuFinanceClient 现场登录(会话即弃,不持久化):
GET code_validateCode.action(绑定 JSESSIONID 会话)
→ 验证码求解(缺省 DdddocrCaptchaSolver:POST 本地 ddddocr sidecar
/recognize,onnx 本地推理,无凭据无 QPS;备选 BaiduCaptchaSolver
复用百度 OCR 凭据走 general_basic,号池/熔断与 vat_invoice 同一套)
→ POST loginAction_doLogin.action(uid/pwd 走 jiami.js transInfo 混淆,
bdate 校验本地日期)
│ 验证码识别错 → 换码重试(上限 4 次,对齐门户「4 次锁定」策略)
│ 密码错/弱密码(resflag=powwer)/okupd/锁定 → ZjuCredentialError 立即终止
▼
登录态探测 GET home.jsp(门户会话播种页,为子系统下发 cookie;
客户端维护完整 cookie jar,仅记 JSESSIONID 不足以取子系统数据)
│ 302 回 login.jsp / 页面内嵌登录表单 → ZjuSessionError
▼
成功:顺序拉取日常报销项目、当前报销单和最近 3 个月历史报销单,
校验稳定项目号/报销单号;同一事务写入快照、差异事件、last_sync_at,
并清零 consecutive_failures / last_error
失败:不更新 last_sync_at 或快照;consecutive_failures +1,错误落 last_error,
连续失败达到阈值时向账户创建者发 critical 通知
- 错误分类(worker 退避决策依据):
ZjuCredentialError凭据类(不重试,错误落last_error供设置页展示);ZjuSessionError/ZjuUnexpectedResponseError门户侧异常;ZjuNetworkError网络类;ZjuCaptchaSolverError求解服务类(ddddocr sidecar 不可达/超时;备选百度时额度/限流沿用 docs/05 §4.1 熔断)。 - 凭据红线:门户密码 AES-256-GCM 信封加密落库(
finance_portal_account.password_encrypted),只在 worker 内存中解密后经ZjuFinanceClient上送,不进日志与异常栈(红线 4)。 - Provider 边界:门户协议实现只存在于
packages/providers(zju-finance/),业务层(api/worker)仅消费ZjuFinanceClient与CaptchaSolverProvider抽象(红线 7);新增门户厂商照此模式扩展。 - worker 同步日常报销项目、当前报销单和最近 3 个月历史报销单,保存项目号与报销状态快照。项目和报销单分别建立首次静默基线;项目列表首次为空时延后项目基线,避免门户异常空响应后误报历史项目。状态变化和新项目生成持久化通知事件。仪表盘的
GET /finance-accounts/:id/portal仍现场只读登录人、项目预算、当前及历史报销单;每张单可再取窗口 1921「预约物流情况」(funcno 1937)的流转明细并归约成进度摘要。物流查询共享服务端会话上下文,必须串行,每类最多预取 15 张,超出部分由GET /finance-accounts/:id/claims/:source/:reqNo/logistics点击兜底。物流当前不进入 worker 快照。现场读取不发号、不订窗口。日常报销的窗口流程与函数契约见 10-zju-yb-daily.md。
7. 附件识别与匹配子管道¶
vat_invoice 返回 282103(模板不匹配) 仍不是失败,而是「这是附件」的信号,自动转投附件子管道。匹配算法、得分规则、unmatched 暂存、发票入库反查补挂、人工兜底全部保留(细节见 05-ocr-fields.md §6 与 12-receipt-ocr.md):
BullMQ parse job → vat_invoice 282103
▼
general_basic(复用 BaiduProvider token 与退避;PDF 仅识别首页)
▼
(行程单等)结构化财务票据端点(docs/12):online_taxi_itinerary 等经 provider 注册表
→ 归一化 RecognizedDocument[],城市/起终点/每段金额落 attachment.documents + ocr_raw_json;
失败静默回退,绝不拖垮附件任务
▼
信号提取(金额/商户/日期/商品词/类型;原文件名参与金额与消歧)→ core/matcher:
① 金额精确相等为必要锚点(提不到金额不猜;文件名裸小数/`X.XX元` 一并入池,
平台导出名保留被 OCR 吃掉小数点的真值,如“合计7691元”实为 76.91;
结构化行程单的“总金额 + 每段金额”一并入池)
② 唯一候选 → 挂载;多候选 → 卖方互含 +3 / 日期差≤3天 +2 / 商品词 +2 /
文件名核心片段互含 +3
唯一最高且领先 ≥2 分 → 挂载;否则 unmatched
▼
挂载:INSERT attachment(matched) + archive_path = 主发票旁 {主发票stem}.a{序号}_{原名}(虚拟路径)
未挂:INSERT attachment(unmatched) + archive_path 落 Archive/unmatched/(原名保留)
▼
task=done + PUBLISH → SSE(待配对提醒)
发票侧主票仍在 vat_invoice 命中后落库,并按票面名称归一出 invoice.invoice_kind 与 invoice.detail(专票/普票/卷票/全电/通行费/货运/机动车/铁路/航空,见 docs/12 §2.4)。
存量 unmatched 附件可经 POST /attachments/rematch 用已存 ocr_raw_text + 原文件名回放重匹配(同一 core/matcher 纯函数,不重新调 OCR、不额外付费),命中唯一候选自动挂载;这同时是匹配算法演进后修复历史数据的入口。
general_basic 与 vat_invoice 免费额度独立计费(各 1000 次/月、QPS=2);结构化财务票据端点属财务票据资源包(独立额度/计费,docs/12 §2.1);附件队列与发票解析共用 worker 池与全局限速。
8. API 设计¶
完整契约——通用约定、错误码表、端点定义(含请求/响应示例与角色矩阵)、SSE 事件格式、版本管理——独立维护于 08-api.md,机器可读事实源为
packages/shared的 zod schema。本节仅保留契约速览与两个横切子系统。
8.1 契约速览¶
- 前缀
/api/v1;鉴权 better-auth session cookie;业务端点必须带X-Workspace-Id头——api 校验活动workspace_member后注入 service、Prisma scope 与 RLS 事务变量,不信任 body/query 中的workspaceId。 - 响应不包 envelope;错误统一
{ code, message, details? }(错误码表见 08 文档 §2);金额契约层传分(integer);时间戳 ISO 8601 UTC、发票业务日期YYYY-MM-DD。 - 分页
?page&pageSize&sort→{ items, total, page, pageSize };重复上传由workspace_file(workspace_id, blob_id)唯一约束兜底(409DUPLICATE_FILE),跨工作区哈希命中不暴露文件存在性。
8.2 通知子系统(持久化事件 → 收件人/频道扇出)¶
业务点只写一次 notification_event:事件带稳定 topic、工作区、资源定位与唯一 event_key,并在同一事务写入收件人、个人站内通知和频道投递任务。事务提交后发布 SSE;Webhook worker 扫描 notification_delivery 并领取待投递任务。个人频道只匹配其所有者,共享 workspace 频道每个业务事件仅生成一份投递,避免 owner/manager 数量放大群机器人消息。投递失败记录脱敏错误并指数退避,超过最大次数进入 dead;PostgreSQL 是事实源,Redis 仅做实时通知。
事件 topic 固定为 invoice_material、finance_claim_progress、finance_project。发票缺材料通知上传者与 active owner/manager;财务事件通知门户账户创建者与 active owner/manager,同一用户去重。项目和报销单各自首次同步只建基线,之后才产生差异事件;项目首次空列表不建立项目基线。
财务报销状态先去除各段首尾空白与空附加字段,再比较当前 status/arrangeStatus/accountStatus 组合及历史 status。旧快照同样归一化,活动单转入历史列表时不会仅因尾部空分隔符产生通知;同一账户的相同 reqNo 在活动列表与历史列表重叠时采用活动列表优先。任一取数阶段失败,整轮不推进快照/基线,恢复后再产生真实差异;账户间快照和事件键独立。
材料提醒的事件正文使用可直接阅读的多行摘要:工作区、发票号码、商户或票面内容、报销金额(由整数分格式化)和待处理问题。缺失的票面字段使用明确占位,不凭空补值;长 OCR 文本截断,避免机器人刷屏。站内通知和所有 Webhook 频道共用该摘要。Webhook 投递时,工作区/工作区共享频道以及收件人在事件工作区仍为 active owner/manager 的个人频道,额外显示发票 submittedByUserId 对应的上传人姓名;普通个人频道不附加上传人。管理员信息只在投递时按频道权限拼入消息,不写入所有收件人共享的站内正文。
飞书 invoice_material 使用自定义机器人 interactive Card JSON 2.0 卡片,带彩色标题区「📌 发票材料提醒」;正文首个 div 的 lark_md 文本设 text_size=heading-4(约 16px)并加粗「问题类型 · 发票内容」,内容缺失时回退商户,长度适当截断。后续 div 用普通字号的 lark_md 逐行排版,每项前有语义表情,顺序为工作区、商户、金额、问题、可见的上传人、上传时间;问题字段展示规则返回的完整触发理由,金额数值与具体问题加粗,其余字段保持普通字重。动态值先转义 Markdown,避免 OCR 文本改变格式。卡片不重复内容或冗长票号。事件正文记录源文件 WorkspaceFile.createdAt,以北京时间显示到分钟。卡片底部蓝色主按钮「查看详情」使用 worker 的 CLIP_PUBLIC_URL(缺省 https://clip.philfan.cn)生成 /?workspaceId=<id>&invoiceId=<id>;首页先核对当前用户有权访问工作区,再通过带 workspace context 的 GET /invoices/:id 获取发票并显示材料详情。财务主题仍发文本;站内、钉钉和通用 Webhook 沿用无表情正文。卡片只改变展现,原持久化事件和签名/重试链路不变。
钉钉同时支持旧群机器人和自动化流程 Webhook。https://connector.dingtalk.com/webhook/flow/<id> 单独识别为流程入口,发送机器人兼容的 JSON(普通主题 Markdown、材料主题见下),不附加旧机器人的 timestamp/sign;个人频道白名单仅允许该主机的明确流程路径。流程侧设置关键词,参数格式选 Text,后续「发送消息到该群组」选择「源数据解析」并引用 Webhook 节点出参。旧 oapi.dingtalk.com / api.dingtalk.com 机器人继续按配置密钥加签;不能仅凭任意主机名以 dingtalk.com 结尾就推断协议。
钉钉材料提醒与飞书复用同一份展示摘要:标题「📌 发票材料提醒」、加粗的问题类型与票面内容、带表情的工作区/商户/金额/具体问题/可见上传人/上传时间。有详情链接时钉钉使用 actionCard,singleTitle=查看详情、singleURL 指向对应发票;无链接时回退 Markdown。字段可见性继续由投递权限决定;钉钉使用原生 Markdown 标题与按钮,无法复刻飞书彩色标题背景。财务主题继续使用原有 Markdown 格式。
首次规则命中与人工重新打开材料要求共用 core 的发票正文格式化函数。重新打开时读取当前票面和源文件上传时间,问题说明明确写为人工重新打开及可选处理备注,不把旧规则理由当成新的规则评估结果;事件生成和去重仍按材料要求 generation 执行。
发票深链接在密码登录和第三方登录回跳后保留;网址参数必须为规范 UUID,且工作区仍在当前用户可访问列表。单票详情绑定来源工作区,切换工作区或退出登录时清除旧详情。viewer 的材料详情只读,状态修改仍由 API 核验上传者或 owner/manager 权限。
可靠投递每轮先检查当前频道启用状态、订阅、级别、作用域及个人收件人的 active 成员关系;不符合条件记 skipped,站内历史通知保留。机器人端点仅在合法 JSON 含明确成功码时判定成功;流程/通用 Webhook 保留其 HTTP 与业务码兼容规则。失败退避从 30 秒起翻倍,最多 6 次;过期租约可接管,但达到上限转 dead,结果只能由当前租约持有者落库。失败记录只保存安全的 HTTP/机器人业务码,不保存地址、签名或上游原文。
材料要求的证明文件可直接从发票检查面板上传:API 先校验当前发票及上传者/manager 权限,再按现有内容寻址 blob 方式保存 PDF/JPG/PNG,事务内建立工作区文件和已挂载的 proof 附件;不启动 OCR 解析。上传接口返回附件 ID,用户在材料要求状态操作中引用该 ID,服务端再次核验附件仍属于当前工作区和发票。重复提交相同文件时只复用当前发票已有的活动证明附件;其他用途已占用该文件时明确拒绝。
财务轮询登录后调用预约子系统的日常报销项目、当前报销单与近三个月历史报销单接口。网格接口完整翻页;任何一页失败、会话失效或响应结构异常都视为整轮失败,不提交快照、水位或事件。项目以 uniPrjCode、报销单以 reqNo 作稳定标识;同一账户历史出现过的项目号永久保留。报销进度目前比较当前列表的 status/arrangeStatus/accountStatus 与历史列表的 status;当前单若带预约时间、地点,通知附上摘要,物流详情尚未进入轮询。首次完整同步建立静默基线;若项目列表为空,保留项目提醒的基线待首轮非空列表建立,避免之后把历史项目当作新项目。通知中心的业务项可切换到所属工作区的发票或计财处面板。
| 频道 | Web 版实现 | 凭据存储 |
|---|---|---|
inapp(站内,始终启用,兜底频道) |
outbox dispatcher 幂等 INSERT notification + PUBLISH → api SSE + 未读角标 |
— |
dingtalk |
POST webhook,加签 HMAC-SHA256(timestamp+"\n"+secret) 置 URL 参数(node:crypto) |
secret 信封加密落库 |
feishu |
POST webhook,签名校验置 body sign 字段 |
同上 |
webhook |
通用 JSON 模板({{title}}/{{body}}/{{level}}),覆盖企微/Bark/ntfy/Gotify |
凭据信封加密落库 |
个人机器人是用户全局配置,一次配置跨工作区生效,消息正文必须标明工作区名称;页面打开时前端把 SSE 站内消息同步交给浏览器 Notification API。
notification、事件与投递完整 schema 见 04 文档。
8.3 SSE(实时事件流)¶
GET /events:text/event-stream;事件类型、payload 格式、心跳与重连语义见 08-api.md §4。- 鉴权:session cookie 同域自动携带(
EventSource天然带 cookie,无需一次性 ticket);断线由前端自动重连,重连后补拉一次/tasks兜底可能错过的事件。 - 链路:worker 完成任务
PUBLISH {workspaceId, type, payload}→ api(每实例一个订阅者)只向该工作区活动成员的 SSE 连接下发;个人通知使用recipientUserId定向下发。单机部署无跨实例扇出问题(多实例时引入按实例的 channel 即可,暂不设计)。 - 反向代理必须对该路径即时转发(Caddy
reverse_proxy的flush_interval -1),否则事件被攒住。
9. 前端架构(Next.js)¶
9.1 定位与形态¶
- Next.js 仅作 React 框架与路由(App Router、文件路由、代码分割、standalone 产物);不做 SSR 数据获取、不用 API Route、不挂数据库——后端一律是 NestJS。
- 纯客户端渲染:页面组件
"use client",数据全部 TanStack Query 从/api/v1拉取;构建期不请求任何运行时数据(内网部署不依赖出站)。 - 可选行为分析(匿名,隐私优先):配置
POSTHOG_KEY时按需动态加载posthog-js初始化 PostHog(POSTHOG_HOST默认https://us.i.posthog.com,可选 EU/自建)。不做identify(不上报用户 id/邮箱/昵称),关闭 autocapture、页面 URL、录屏与调查,事件级$geoip_disable且经sanitize_properties剥离 PII 字段;登出reset()。会话回放与热图默认关闭,仅当POSTHOG_REPLAY=1/POSTHOG_HEATMAP=1时开启,且回放强制全遮蔽(文本/输入/元素属性打码、屏蔽图片、关闭 network 录制)。浏览器直连外部域名,需公网可达;配置在构建期编入静态页面,Docker 下改值需重建 web 镜像。
9.2 路由与页面¶
app/
├── (auth)/login/ # 登录
├── (app)/ # 需登录的布局(侧栏 + 页头指标 + 通知铃铛)
│ ├── page.tsx # 仪表盘:上传投递区 + 明细列表 + 指标
│ ├── invoices/[id]/ # 发票详情(字段修正 / OCR JSON / 附件管理)
│ ├── attachments/unmatched/ # 附件待配对页
│ ├── batches/ batches/[id]/ # 批次列表 / 批次详情(拖拽排序 / 导出)
│ ├── mail-accounts/ # 邮箱账户管理
│ ├── rules/ # 分类规则(个人 / 工作区 / 组织)
│ ├── quota/ # 额度计划与剩余
│ ├── settings/ # 邮箱、额度与组织/工作区通知频道等
│ └── admin/ # 管理大屏(system admin,docs §5.8)
└── layout.tsx / globals.css # tweakcn 主题 token(亮/暗)
9.3 数据与交互¶
- api client:手写薄封装(基于
packages/shared的 zod schema 类型 + 解析响应),统一错误 toast;认证交互不进 api client,直接用 better-auth 官方 React client(createAuthClient+useSession,登录/登出/改密开箱即用)。 - 上传体验:仪表盘虚线投递区接收文件或文件夹;文件夹在浏览器内递归扫描并展平,目录名与相对路径不上传、不影响分类和归档 → 每 20 个文件一批依次
POST /uploads(总字节进度条)→ 任务卡片展示pending → processing → done/failed(SSE 实时更新,/tasks轮询兜底)→ 完成后 Query 失效刷新列表。扫描目录与上传是两个连续可见状态,大目录展开期间不得无反馈。 - 表格:TanStack Table(列排序/筛选/列显隐),行展开显示物品明细与附件;双击单元格修正(PATCH + 乐观更新,失败回滚)。
- 表单:react-hook-form + zodResolver(schema 来自 shared,前后端校验同源)。
9.4 Lint / 格式化 / 提交规范¶
| 工具 | 约定 |
|---|---|
| ESLint 9(flat config) | 配置收敛在 packages/eslint-config:typescript-eslint(strict 规则集)全仓生效;eslint-plugin-react-hooks / next(web);import 排序全仓 |
| Prettier | 全仓统一格式化,pnpm format;与 ESLint 职责分离(ESLint 只管质量不管风格) |
| husky + lint-staged | pre-commit:改动文件 lint + format + typecheck 快速通道 |
| commitlint | 强制既有 Conventional Commits 规范(scope 更新为 web api worker db shared core provider deploy 等,见 AGENTS.md 同步) |
| CI 最小门禁 | turbo run lint typecheck test build(typecheck = tsc --noEmit,独立 turbo task) |
10. 文件存储与归档结构(虚拟归档树)¶
归档模型(一次报销 = 一个完整文件夹;附件同前缀平铺)保留为领域语义,但不再物化为磁盘目录:物理文件一经写入 blob 层永不移动、永不改名(blob 跨工作区共享物理对象,任何移动都会弄悬空其他空间的引用);「归档树」退化为 archive_path 字符串的取值空间,仅在导出交付包与下载时物化。个人工作区由系统自动创建,共享工作区由成员协作。
磁盘物理布局(仅此而已):
/data/invoice/
├── blobs/ # 原始字节层:内容寻址 blobs/<2位>/<sha256>
│ # file_blob 全局 sha256 去重,跨工作区共享物理对象
├── previews/ # 派生缩略图层:内容寻址 previews/<2位>/<sha256>.png
│ # PDF 首页渲染图,随 file_blob 引用;同 blob 跨工作区共享
└── {workspace_id}/
└── uploads/ # 邮箱附件半截暂存(原 Inbox;网页上传直落 blob 层)
└── .emlpart-*.tmp # 半截文件(原子 rename 后消失)
archive_path 取值空间(虚拟归档树,导出 zip 时按此物化):
Archive/
├── unbatched/ # 待归集区
│ ├── 20260912_交通_滴滴出行_54元_812345.pdf
│ └── 20260912_交通_滴滴出行_54元_812345.a01_订单截图.png
├── unmatched/ # 附件待配对暂存(原名保留)
├── .trash/ # 软删暂存(导出排除)
└── batches/
└── 3_2026-09北京出差/ # 一次报销 = 一个完整文件夹
├── …(主发票 + 同前缀附件)
└── 标准报销单.xlsx # 导出结果随批写入交付包
- 原始字节与归档语义分离:
file_blob.storage_key指向根级blobs/<2位>/<sha256>内容寻址对象(写入即'wx'独占创建,已存在即去重命中)。下载与导出一律从 blob 层受控流出,按库中archive_path拼装交付结构。 - 派生缩略图:解析 PDF 时 worker 用 poppler
pdftoppm渲染首页 PNG,写入根级previews/<2位>/<sha256>.png,并把虚拟引用固化到file_blob(preview_storage_key等)。与 blob 同为内容寻址、跨工作区共享;渲染失败(poppler 缺失/加密/损坏)静默跳过,前端回退文件图标。缩略图经 api 受控端点读取(08 §3.5),仍是文件副作用(§14),但派生自既有 blob、可随时重生成,不参与归档与回收关键路径。历史数据用pnpm db:backfill-previews补生成(按 blob 幂等扫描)。 - 归档 = 纯写库:状态迁移 +
archive_path固化(发票路径记于invoice.archive_path,附件路径记于其workspace_file.archive_path),无磁盘动作、无补偿。归档时固化而非导出时现算:交付物确定(命名规则后续演进不改历史归档),归档决定留痕可审计。 - 附件命名规则不变:
{主文件名去扩展名}.a{两位序号}_{附件原文件名}.{原扩展名},序号发票维度递增。 - 入批/拆批 = 改库:更新批次关联与
archive_path前缀(batches/{batch_order}_{name}/),主发票与同前缀附件随行;导出时按新路径物化。 - 物理回收:blob 仅当全部
workspace_file引用(软删亦计入)清除后才可由后台任务物理删除——先判库、后删文件。 - 访问控制:文件不经反向代理静态直出;下载一律走 api 受控端点(见 08-api.md §3.5),api 校验活动
workspace_member归属后以X-Accel-Redirect/ 流式响应返回。 - 存储抽象:文件访问收敛在
core的FileStore接口(本地卷实现);预留 MinIO/S3 实现(多机或扩容时替换,不影响领域逻辑)。 - 卷备份与迁移即
/data目录整体备份(§13)。
11. 认证与权限(better-auth)¶
- 形态:better-auth 以库形态嵌在 api 进程内自托管——实例装配
prismaAdapter(复用packages/db)+username+admin插件,baseURL = /api/v1/auth,trustedOrigins限定内网域;经一个薄桥接把/auth/*、/admin/*直接交给库的 Node handler(绕过 Nest 全局守卫与管道,因此这组端点不进手写契约表)。 - 登录方式:学号/工号(
username插件,主登录标识)+ 密码,哈希由库托管(默认 scrypt)。httpOnlySameSite=Laxsession cookie——同域反代下浏览器请求与 SSE 自动携带,不再手写 JWT access/refresh 轮换;session 落库(session表)可随时吊销,有效期 7 天滑动续期。程序化客户端(脚本/CI)后期可开jwt插件签发 Bearer token。 - 表结构:better-auth 所需
user(业务字段直接在此表扩展列,不建映射表)/session/account/verification四表进 Prisma schema,由@better-auth/cli generate生成后走 Prisma Migrate。 - 开户:首启用
ADMIN_INITIAL_PASSWORD种子一个 admin;emailAndPassword.disableSignUp=true关闭公开注册。新用户仅能通过 8 位邀请码(POST /invites/register或 OAuth 前POST /invites/bind)或 admin 插件建号。邀请码由 system admin 在/admin/invites批量生成(POST /admin/invites/batch),库内只存 HMAC,明文仅创建时返回一次。 - 两层角色模型:better-auth admin 插件的
role=admin/user是系统角色;业务权限由workspace_member.role=owner/manager/member/viewer决定。个人工作区默认仅本人可见,协作空间按成员关系授权。
| 能力 | system admin | workspace owner / manager | member | viewer |
|---|---|---|---|---|
| 已加入工作区的数据 | 按成员资格 | ✅ | ✅(受能力限制) | 只读 |
| 工作区成员、批次、邮箱、频道 | — | owner 全部;manager 按能力 | ❌ | ❌ |
| 组织级分类规则、组织通知频道 | ✅ | ❌ | ❌ | ❌ |
| 工作区规则、工作区通知频道 | — | owner 全部;manager 按能力 | ❌ | ❌ |
| 用户目录与系统角色管理 | ✅ | ❌ | ❌ | ❌ |
| 他人未加入工作区的数据 | 仅显式排障、只读、记录 access_audit |
❌ | ❌ | ❌ |
- CC98 登录(标准 OIDC,已接入):经
better-auth的genericOAuth插件对接浙大 CC98(openid.cc98.org)。CC98 发布标准 discovery 文档(https://openid.cc98.org/.well-known/openid-configuration),因此配置discoveryUrl自动发现authorization/token/userinfo端点并校验id_token(RS256 + JWKS + nonce);同时保留显式端点作为 discovery 拉取失败时的兜底。scopes=openid profile、启用 PKCE,回调BETTER_AUTH_URL/api/v1/auth/callback/cc98。CC98 userinfo/claims 不含email,沿用 TokenArena 做法用sub合成{sub}@cc98.org(emailVerified=true),既满足user.email非空唯一,也不会与真实邮箱账号自动合并;跨 provider 合并仍只按 verified email 判定(account.accountLinking)。env:CC98_CLIENT_ID/CC98_CLIENT_SECRET,缺省即关闭并回退到用户名密码/GitHub。 - 多登录方式与账号合并:一个账号可绑定「邮箱密码 + 多个 OAuth」,任意一种都能登录同一 user,数据互通。个人资料「登录方式」经
list-accounts/link-social/unlink-account管理绑定与解绑(解绑须保留至少一个,且要求 session 新鲜)。OAuth-only 用户补设密码走 Nest 桥接POST /account/password(better-authset-password为 serverOnly)。跨账号自动归并仍只按 verified email;CC98 合成邮箱不会自动合并,需在个人资料里显式绑定。 - 学校统一身份(预留):
genericOAuth插件对接 CAS/OIDC 上游(LDAP 走社区插件);接入后 username+密码降级为兜底登录,外部身份映射进account表。CC98 为 OIDC 首例,CAS/LDAP 仍不在本期范围。
12. 配置与密钥管理(内网版红线)¶
桌面版「凭据只进 keyring」在服务器多用户场景不成立,替换为信封加密:
.env(部署机,权限 600,不进 git):DATABASE_URL、REDIS_URL、BETTER_AUTH_SECRET(session 加密密钥)、BETTER_AUTH_URL(=https://内网域名)、ENVELOPE_MASTER_KEY(32 字节 base64,部署时一次性生成)、BAIDU_OCR_API_KEY/SECRET_KEY、HTTP_PROXY(出站可选)、ADMIN_INITIAL_PASSWORD(首启用后移除)。- 用户级敏感凭据(邮箱授权码、通知加签 secret、Webhook Basic 认证)AES-256-GCM 加密后落库(列存
iv + ciphertext + tag),主密钥只在.env;任何层不得解密后进日志/异常栈/接口响应(设置页一律掩码回显,提交空值不覆盖)。 - 红线更新:原「keyring 存取」改为「信封加密落库 + 主密钥环境变量」;
ocr_raw_json原样保存、金额存分、先写库后动文件等其余红线全部保留(§14)。
13. 部署(学校内网,Docker Compose 单机)¶
本节是架构层面的部署形态;逐步操作、环境变量/密钥清单、备份恢复、升级回滚与部署待办见 09-deployment.md。
# docker/compose.yml(示意)
services:
web: # apps/web standalone 产物
api: # apps/api(依赖 db、redis、ddddocr)
worker: # apps/worker(依赖 db、redis、ddddocr;挂 /data 卷;镜像内置 poppler pdftoppm 渲染 PDF 缩略图)
ddddocr: # 验证码求解 sidecar(python:3.11-slim + ddddocr,无凭据;内网访问)
postgres: # pg:16,数据卷 pgdata,定期归档备份
redis: # redis:7 --appendonly yes(队列可恢复)
volumes: { pgdata, data } # /data = 发票文件卷,挂 api 与 worker
# Caddy 不在 compose 内:宿主机反代 80/443 → api:4000 / web:3000(见 docker/Caddyfile)
- 出站依赖:仅百度 OCR(发票解析)与用户邮箱 IMAP(443/993);两者均可配
HTTP_PROXY;验证码求解为本地 sidecar,不出站。完全断网时解析任务排队等待(QUOTA_EXCEEDED_OCR/网络错误按退避重试),Web 功能不受影响。 - 备份:cron
pg_dump(每日,保留 30 份)+/data卷 rsync 快照(发票原件);恢复演练进 M1 验收。 - 升级:拉新镜像 →
pnpm db:migrate(prisma migrate deploy,只前滚)→ 滚动重启 api/worker;worker 重启触发 §5.3 启动补偿。 - 日志:容器 stdout → json-file 轮转;api/worker 结构化日志(pino),凭据字段强制脱敏。
14. 数据流约定(红线,全数保留并映射)¶
| 桌面版红线 | Web 版落点 |
|---|---|
| 金额一律存分(Integer),无 float | 不变,且延伸到 API 契约与前端展示层(shared 格式化工具) |
日期 ISO YYYY-MM-DD,清洗集中在 Provider |
不变(packages/providers 清洗函数) |
ocr_raw_json 原样完整保存 |
不变(重放免付费的唯一依据) |
| SQLite WAL | PG 默认隔离级别;归档纯写库后,文件副作用仅剩上传/导出/缩略图/回收(§10) |
| schema 只走 migration | Alembic → Prisma Migrate(db:dev 生成、db:migrate 部署,与代码同 commit) |
| 凭据不落明文 | keyring → 信封加密(§12) |
| 先写库后动文件 | 不变;磁盘动作收敛到 worker |
| QPS=2,Worker=2 | BullMQ concurrency=2 + 队列 limiter max=2/1000ms 双保险 |
防重索引 (invoice_num, ifnull(invoice_code,'')) |
空间内文件重传用 (workspace_id, blob_id);票号防重升级为 invoice_dedup_key 的组织级 (organization_id, invoice_num_norm, invoice_code_norm),全电票代码仍规范化为 '' |
| 附件命名/批次归档规则 | 不变(§10) |
| 新厂商走 Provider 接口 | 不变(packages/providers/base.ts) |
| UI 是遥控器,不承载解析逻辑 | Web 版:前端只走 REST/SSE,不直连库/Redis |
| 单用户数据隔离 | 工作区成为业务与文件隔离根;Prisma scope + PostgreSQL RLS + 复合外键三层兜底,管理员排障写 access_audit |
15. 选型风险与预案¶
| 风险 | 影响 | 预案 |
|---|---|---|
| 百度 OCR 出站在内网受限 | 解析停摆 | HTTP_PROXY 配置;任务排队 + 退避重试不丢数据;极端情况评估内网 OCR 服务商接入(Provider 接口已预留) |
| SSE 被反向代理缓冲/断连 | 实时性失效 | Caddy flush_interval -1 + 心跳注释帧;前端自动重连 + /tasks 轮询兜底 |
| 信封加密主密钥丢失 | 全部用户凭据不可解 | 部署文档强制密钥备份进交接清单;密钥轮换支持双密钥宽限期(P2) |
| Prisma 复杂查询表达力 | 组单 DP、批次聚合 SQL | prisma.$queryRaw 收口在 db 包内;必要时建 PG 视图(v_batch_summary 保留) |
| 单机 Compose 无高可用 | 重启窗口服务中断 | 内网规模可接受;worker/api 分容器使解析不受 api 发版影响;数据双备份(§13) |
| better-auth 版本迭代快、表结构随升级漂移 | 升级破坏登录 | 锁次版本升级;schema 变更一律经 @better-auth/cli generate → Prisma Migrate(与代码同 commit);升级前跑 auth 集成测试(登录/会话/角色三链路) |
| 邮箱服务商兼容性(授权码过期等) | 拉取停摆 | 测试连接端点 + last_error + 连续失败计数(不发通知) |
| exceljs/pdf-lib 与原模板/版式差异 | 导出不符预期 | 模板可替换文件不变;M1 用真实模板做像素级校验,必要时模板改用占位符填充模式 |
| 机器人频道限频(钉钉 20 条/min) | 重要事件推不出去 | 聚合窗口 + 发送间隔下限不变;站内 inapp 频道永远兜底 |
16. 迁移说明(桌面版 → Web 版)¶
- 旧 Python 桌面版实现已从主仓库移除;其领域能力按下列清单移植为
packages/core/packages/providers的 TS 实现,测试用例同步平移: - 金额转分、日期清洗、字段 Adapter(05 文档口径不变);
- 规则引擎 → 升级为分类器链(ServiceType 直映 → keyword 规则 → 未分类)与求值顺序;
- 附件匹配算法(金额锚点 + 三信号消歧 + 反查补挂);
- 防重三层(Message-ID/UID 水位、工作区文件哈希、组织级发票号唯一索引);
- 归档路径与附件命名计算(纯函数,最易平移);
- 通知分级路由与聚合窗口(hub 移植为 worker 内服务)。
- 旧 SQLite 工作区数据提供一次性导入脚本(P2,可选):读导出的
smart_invoice.db→ 映射写入指定 PG 工作区 → 文件直接写入 blob 层、按原目录换算archive_path虚拟路径(不建物理目录)。 - 关联文档状态:02、03、04、06、08、根 README 与 AGENTS.md 已同步为 Web SaaS 工作区模型;01、07 保留为早期产品与 UI 基线,待 Web UI 完整落地时再整体改写。