跳转至

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 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):

web ─┐
api ─┼─▶ shared ─▶ core ─▶ providers ─▶ db
worker ┘

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 与 typed questions(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) 唯一约束兜底(409 DUPLICATE_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)。httpOnly SameSite=Lax session 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-auth set-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 完整落地时再整体改写。