跳转至

04 - 数据库设计

本文件是 Web SaaS 版的数据库契约。数据库使用 PostgreSQL 16 + Prisma Migrate;packages/db/prisma/schema.prisma 是实现唯一来源,所有 schema 变更必须生成 migration 并与代码同一 commit。旧 SQLite 数据仅作为一次性导入源,不再扩展。

金额一律以**分(integer)**存储;发票业务日期保留 ISO YYYY-MM-DD 字符串;ocr_raw_json 必须完整原样保存。生命周期时间以 UTC ISO 8601 经 API 输出。

1. 隔离模型

学校内网部署是「单组织、多用户」,但仍保留 organization 作为数据根:当前安装只需一条组织记录,未来多个独立部署单元也无需重构。业务数据的直接隔离根是 workspace(报销工作区),不是用户。

organization
├─ organization_member ─ user(better-auth)
└─ workspace
   └─ workspace_member ─ user
      └─ invoice / attachment / parse_task / batch / ...
  • 新用户开户时,在同一事务创建一个 PERSONAL 工作区和 OWNER 成员关系;单人使用体验与旧版一致。
  • 出差项目、课题组或部门统一报销使用 SHARED 工作区。发票保留提交人,批次和原件可以协作处理。
  • 一条业务记录只属于一个工作区;禁止跨工作区关联、入批、下载或导出。
  • 系统级 user.role=admin 用于用户管理和组织配置;它不替代工作区成员关系。管理员排障读取他人数据必须走受审计的显式路径。

2. 表清单

分组 表 职责
认证 user、session、account、verification、InviteCode、InviteRedemption better-auth 四表 + 平台邀请码/核销流水
租户与协作 organization、organization_member、workspace、workspace_member 组织、工作区和成员权限
文件与解析 file_blob、workspace_file、parse_task 物理文件、空间内文件引用及异步解析状态
核心报销 invoice、invoice_dedup_key、attachment、reimburse_batch、export_job 发票、组织级业务防重、附件、报销批次、导出任务
外部输入 mail_account、mail_message IMAP 配置、增量水位与邮件审计
规则与额度 category_rule、quota、quota_snapshot 多层分类规则、额度计划和快照
通知与审计 notification_event、notification_event_recipient、notification、notification_delivery、notify_channel、access_audit 可靠事件、个人/共享频道投递、越权排障审计
提醒快照 invoice_requirement、finance_project_snapshot、finance_claim_snapshot 发票待办与门户差异检测
派生数据 v_batch_summary 批次金额与数量聚合,禁止冗余写入

3. 认证、组织与工作区

better-auth 的 user、session、account、verification 由 @better-auth/cli generate 生成后纳入 Prisma migration。业务不另建 user 映射表;user.id 为 text,所有指向用户的外键使用相同类型。

CREATE TABLE organization (
    id          uuid PRIMARY KEY,
    name        text NOT NULL,
    slug        text NOT NULL UNIQUE,
    status      text NOT NULL DEFAULT 'active', -- active / suspended
    created_at  timestamptz NOT NULL,
    updated_at  timestamptz NOT NULL
);

CREATE TABLE organization_member (
    organization_id uuid NOT NULL REFERENCES organization(id),
    user_id         text NOT NULL REFERENCES "user"(id),
    employee_no     text,
    department_name text,
    status          text NOT NULL DEFAULT 'active', -- active / disabled
    created_at      timestamptz NOT NULL,
    PRIMARY KEY (organization_id, user_id)
);

CREATE TABLE workspace (
    id              uuid PRIMARY KEY,
    organization_id uuid NOT NULL REFERENCES organization(id),
    name            text NOT NULL,
    kind            text NOT NULL DEFAULT 'personal', -- personal / shared
    owner_user_id   text NOT NULL REFERENCES "user"(id),
    status          text NOT NULL DEFAULT 'active', -- active / archived
    created_at      timestamptz NOT NULL,
    updated_at      timestamptz NOT NULL,
    UNIQUE (organization_id, id)
);

CREATE TABLE workspace_member (
    workspace_id uuid NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
    user_id      text NOT NULL REFERENCES "user"(id),
    role         text NOT NULL, -- owner / manager / member / viewer
    status       text NOT NULL DEFAULT 'active',
    joined_at    timestamptz NOT NULL,
    PRIMARY KEY (workspace_id, user_id)
);
CREATE INDEX idx_workspace_member_user ON workspace_member(user_id, workspace_id);

权限由工作区角色映射为能力:owner 可管理成员与删除空间,manager 可维护批次、邮箱和频道,member 可上传与编辑,viewer 只读。需要审批流时另加审批领域表,不把审批状态混入角色。

4. 文件、上传与解析任务

file_blob 与 workspace_file 分离:前者是物理对象,后者才是一个工作区可见的文件。这样可做底层内容去重,又不会让用户通过 SHA256 探知其他空间是否保存过某文件。storage_key 指向存储根下的内容寻址对象 blobs/<2位>/<sha256>(不属于任何工作区目录),跨工作区复用同一物理字节;该表仅对服务角色可见。preview_storage_key 指向同根下的派生缩略图 previews/<2位>/<sha256>.png(解析 PDF 时渲染首页,失败置空回退文件图标),随 blob 去重、跨工作区共享。历史数据缺缩略图时用 pnpm db:backfill-previews 按 blob 补生成(幂等,可重复运行)。workspace_file.source 记录文件来源(upload 手动上传 / mail 邮箱拉取 / ocr 派生),发票列表据此展示「邮件导入」标识。

CREATE TABLE file_blob (
    id                  uuid PRIMARY KEY,
    sha256              char(64) NOT NULL UNIQUE,
    storage_key         text NOT NULL UNIQUE, -- blobs/<2位>/<sha256>,不可直接作为公网 URL
    size_bytes          bigint NOT NULL,
    mime_type           text,
    preview_storage_key text, -- previews/<2位>/<sha256>.png(PDF 首页缩略图,服务端渲染)
    preview_mime_type   text,
    preview_width       integer,
    preview_height      integer,
    created_at          timestamptz NOT NULL
);

CREATE TABLE workspace_file (
    id                 uuid PRIMARY KEY,
    workspace_id       uuid NOT NULL REFERENCES workspace(id),
    blob_id            uuid NOT NULL REFERENCES file_blob(id),
    original_name      text NOT NULL,
    source             text NOT NULL DEFAULT 'upload', -- 文件来源:upload / mail / ocr
    archive_path       text, -- 虚拟归档路径(Archive/... 前缀,无物理目录对应)
    created_by_user_id text NOT NULL REFERENCES "user"(id),
    created_at         timestamptz NOT NULL,
    UNIQUE (workspace_id, blob_id),
    UNIQUE (workspace_id, id)
);

CREATE TABLE parse_task (
    id                   uuid PRIMARY KEY,
    workspace_id         uuid NOT NULL REFERENCES workspace(id),
    workspace_file_id    uuid NOT NULL,
    requested_by_user_id text NOT NULL REFERENCES "user"(id),
    status               text NOT NULL DEFAULT 'pending',
                         -- pending / processing / done / failed / duplicate / skipped
    worker_id            text,
    retry_count          integer NOT NULL DEFAULT 0,
    next_retry_at        timestamptz,
    error_code           text,
    error_msg            text,
    invoice_id           uuid,
    attachment_id        uuid,
    created_at           timestamptz NOT NULL,
    updated_at           timestamptz NOT NULL,
    FOREIGN KEY (workspace_id, workspace_file_id)
      REFERENCES workspace_file(workspace_id, id),
    UNIQUE (workspace_id, workspace_file_id),
    UNIQUE (workspace_id, id)
);
CREATE INDEX idx_task_claim ON parse_task(status, next_retry_at, created_at);
CREATE INDEX idx_task_workspace ON parse_task(workspace_id, created_at DESC);

Worker 只从任务行获得 workspace_id 和文件引用;物理文件永驻根级 blob 池,磁盘上不存在工作区归档目录(docs/03 §10)。文件下载必须先校验成员资格,不能由反向代理静态直出。

5. 发票、附件和批次

invoice 保留 OCR 票面字段;以下仅列出隔离、关联与约束字段,字段映射仍以 05-ocr-fields.md 为准。

CREATE TABLE invoice (
    id                     uuid PRIMARY KEY,
    organization_id        uuid NOT NULL,
    workspace_id           uuid NOT NULL,
    source_file_id         uuid NOT NULL,
    submitted_by_user_id   text NOT NULL REFERENCES "user"(id),
    created_by_user_id     text NOT NULL REFERENCES "user"(id),

    invoice_num            text NOT NULL,
    invoice_code           text,
    invoice_type           text,
    invoice_type_org       text,
    invoice_kind           text, -- 规范化票种:专票/普票/卷票/全电/通行费/货运/机动车/铁路/航空(docs/12 §2.4)
    service_type           text,
    invoice_tag            text,
    invoice_date           varchar(10), -- YYYY-MM-DD
    check_code             text,
    invoice_num_digit      text,
    seller_name            text,
    seller_register_num    text,
    seller_address         text,
    seller_bank            text,
    purchaser_name         text,
    purchaser_register_num text,
    amount_fen             integer NOT NULL,
    total_amount_fen       integer,
    total_tax_fen          integer,
    amount_in_words        text,
    items_raw              text,
    commodity_count        integer,
    detail                 jsonb, -- 票种专属字段(车辆信息/通行费起讫/铁路乘客…),按 invoice_kind 解释(docs/12 §3)
    ocr_raw_json           text NOT NULL, -- 完整原始响应;不转 jsonb,避免重排或丢失字面内容

    category               text NOT NULL DEFAULT '未分类',
    category_source        text,               -- 分类结论来源:service_type / rule:user|workspace|organization / model:*
    status                 text NOT NULL DEFAULT 'parsed',
                            -- parsed / archived / in_batch / reimbursed
    batch_id               uuid,
    batch_order            integer,
    archive_path           text, -- 交付包内虚拟路径(docs/03 §10),物理文件不动
    user_remark            text,
    is_manual_edited       boolean NOT NULL DEFAULT false,
    created_at             timestamptz NOT NULL,
    updated_at             timestamptz NOT NULL,
    deleted_at             timestamptz, -- 软删;列表过滤 deleted_at IS NULL
    FOREIGN KEY (organization_id, workspace_id)
      REFERENCES workspace(organization_id, id),
    FOREIGN KEY (workspace_id, source_file_id)
      REFERENCES workspace_file(workspace_id, id),
    UNIQUE (workspace_id, id),
    UNIQUE (organization_id, id)
);

CREATE TABLE reimburse_batch (
    id                 uuid PRIMARY KEY,
    workspace_id       uuid NOT NULL REFERENCES workspace(id),
    seq                integer NOT NULL, -- 工作区内建批序号,仅用于路径前缀稳定
    batch_name         text NOT NULL,
    total_limit_fen    integer,          -- 目标额度(分):智能组单提示,不硬拦截
    status             text NOT NULL DEFAULT 'collecting',
                       -- collecting / paid(归集中 → 已打款单步终态,无审批环节)
    kind               text NOT NULL DEFAULT 'daily',
                       -- daily / trip / fixed_asset(日常报销 / 出差 / 固定资产,docs/02 §4)
    note               text,
    created_by_user_id text NOT NULL REFERENCES "user"(id),
    created_at         timestamptz NOT NULL,
    updated_at         timestamptz NOT NULL,
    UNIQUE (workspace_id, id)
);

ALTER TABLE invoice
  ADD CONSTRAINT fk_invoice_batch_same_workspace
  FOREIGN KEY (workspace_id, batch_id)
  REFERENCES reimburse_batch(workspace_id, id);

CREATE TABLE attachment (
    id                 uuid PRIMARY KEY,
    workspace_id       uuid NOT NULL REFERENCES workspace(id),
    invoice_id         uuid,
    workspace_file_id  uuid NOT NULL,
    status             text NOT NULL DEFAULT 'matched', -- matched / unmatched
    attachment_type    text NOT NULL DEFAULT 'other',
    source             text NOT NULL DEFAULT 'upload', -- mail / upload / ocr
    ocr_raw_text       text,
    ocr_raw_json       jsonb,                  -- 结构化端点完整原始返回(红线 5)
    documents          jsonb,                  -- 归一化 RecognizedDocument[](判别键 kind;混贴多张多项,docs/12 §3)
    receipt_kind       text,                   -- 主文档种类(docs/12 §3);发票时为 null
    amount_fen         integer,                -- 锚点金额(分):金额精确相等才进候选集
    match_detail       jsonb,
    note               text,
    created_by_user_id text NOT NULL REFERENCES "user"(id),
    created_at         timestamptz NOT NULL,
    deleted_at         timestamptz,            -- 软删:删附件或删主发票时置位;同文件重传可恢复
    FOREIGN KEY (workspace_id, workspace_file_id)
      REFERENCES workspace_file(workspace_id, id),
    UNIQUE (workspace_id, id)
);

ALTER TABLE attachment
  ADD CONSTRAINT fk_attachment_invoice_same_workspace
  FOREIGN KEY (workspace_id, invoice_id)
  REFERENCES invoice(workspace_id, id);

ALTER TABLE parse_task
  ADD CONSTRAINT fk_task_invoice_same_workspace
  FOREIGN KEY (workspace_id, invoice_id)
  REFERENCES invoice(workspace_id, id),
  ADD CONSTRAINT fk_task_attachment_same_workspace
  FOREIGN KEY (workspace_id, attachment_id)
  REFERENCES attachment(workspace_id, id);

attachment.workspace_id 不能由 invoice_id 间接推导:未匹配附件在 invoice_id IS NULL 时仍必须有隔离归属。parse_task.invoice_id 和 parse_task.attachment_id 同样使用 (workspace_id, id) 复合外键,防止 worker 将结果挂到别的空间。

批次总额不冗余存储。security_invoker = true 让视图继承查询者的 RLS;API 聚合另行走 Prisma groupBy(受限角色 + where workspaceId 双保险),视图供 SQL 排障与报表:

CREATE VIEW v_batch_summary WITH (security_invoker = true) AS
SELECT b.workspace_id, b.id, b.batch_name, b.status,
       COUNT(i.id) AS invoice_count,
       COALESCE(SUM(i.amount_fen), 0) AS current_total_fen
FROM reimburse_batch b
LEFT JOIN invoice i ON i.batch_id = b.id
                   AND i.workspace_id = b.workspace_id
                   AND i.deleted_at IS NULL
                   AND i.status = 'in_batch'
GROUP BY b.workspace_id, b.id, b.batch_name, b.status;

异步导出任务(docs/08 §3.5):api 建 processing 行并入队,worker 物化产物到 blob 层(导出物化是红线 6 允许的三种文件副作用之一),产物以 workspace_file 挂入批次目录(result_file_id 复合外键同空间):

CREATE TABLE export_job (
    id                   uuid PRIMARY KEY,
    workspace_id         uuid NOT NULL REFERENCES workspace(id),
    batch_id             uuid NOT NULL,
    kind                 text NOT NULL,    -- xlsx / pdf / zip
    status               text NOT NULL DEFAULT 'processing',
                         -- processing / done / failed
    requested_by_user_id text NOT NULL REFERENCES "user"(id),
    error_code           text,
    error_message        text,
    result_file_id       uuid,             -- 产物 workspace_file(done 时非空)
    finished_at          timestamptz,
    created_at           timestamptz NOT NULL,
    updated_at           timestamptz NOT NULL,
    UNIQUE (workspace_id, id),
    FOREIGN KEY (workspace_id, batch_id)
      REFERENCES reimburse_batch(workspace_id, id),
    FOREIGN KEY (workspace_id, result_file_id)
      REFERENCES workspace_file(workspace_id, id)
);
CREATE INDEX idx_export_job_batch ON export_job(workspace_id, batch_id, created_at DESC);

产物命名固定(标准报销单.xlsx / 打印版.pdf / {批次名}.zip),虚拟路径随批次目录(Archive/batches/{seq}_{batch_name}/);重复导出生成新 blob 与新 workspace_file 行,旧产物保留可追溯。

6. 组织级业务防重

空间内的文件重传由 workspace_file(workspace_id, blob_id) 拦截。发票号码重复的防重边界是组织,而不是工作区:同一学校的两名员工不能凭不同空间重复报销同一张票。

CREATE TABLE invoice_dedup_key (
    organization_id   uuid NOT NULL REFERENCES organization(id),
    invoice_num_norm  text NOT NULL,
    invoice_code_norm text NOT NULL DEFAULT '',
    invoice_id        uuid NOT NULL UNIQUE,
    created_at        timestamptz NOT NULL,
    PRIMARY KEY (organization_id, invoice_num_norm, invoice_code_norm)
);

ALTER TABLE invoice_dedup_key
  ADD CONSTRAINT fk_dedup_invoice_same_organization
  FOREIGN KEY (organization_id, invoice_id)
  REFERENCES invoice(organization_id, id) ON DELETE CASCADE;

在插入 invoice 的同一事务内规范化号码、争抢写入本表。唯一冲突时任务置 duplicate;普通用户只得到“该发票已报销或处理中”的通用提示,不能获得另一空间的记录编号、用户或路径。invoice_code 为空时规范化为 '',保留全电票兼容性。规范化规则:去全部空白并大写(packages/core 的 normalizeInvoiceCode)。本表启用并强制 RLS,策略为服务角色专用(app_is_service_role())——用户不直接查询防重键,冲突信息只经任务状态透出。

7. 邮箱、规则、额度与通知

表 工作区/组织归属与关键约束
mail_account workspace_id、created_by_user_id;UNIQUE(workspace_id, email, folder)。授权码以 AES-256-GCM 信封加密列保存,永不返回明文。
mail_message workspace_id 冗余(RLS 直判 + parse_task 复合外键);UNIQUE(mail_account_id, message_id) 保留邮件级防重。
finance_portal_account workspace_id、created_by_user_id;UNIQUE(workspace_id, provider, label);provider 枚举 zju。门户密码以 AES-256-GCM 信封加密列保存,永不返回明文。
category_rule organization_id 必填,scope=organization/workspace/user,按 scope 只允许对应 target 列非空。规则优先级:用户 → 工作区 → 组织;每层内部按 priority 升序。
quota workspace_id 必填;个人额度设 beneficiary_user_id,共享项目额度为空。金额、适用科目和周期口径保持不变。
quota_snapshot 经 quota_id 归属,按 (quota_id, fetched_at DESC) 索引。
notification recipient_user_id 必填,workspace_id 可空;用于 SSE 的定向推送与已读状态。
notify_channel scope=user/workspace;user 频道全局归本人,workspace 为共享频道,organization 仅保留停用历史;topics[] 控制缺材料、审核进度、新项目订阅。
invoice_requirement (workspace_id, invoice_id, rule_id) 唯一;状态 open/resolved/waived,记录解决人、时间、说明与可选证明附件。
finance_project_snapshot (finance_account_id, project_code) 唯一;保留首次/最后出现时间,项目消失后不删除,避免重新出现时误报新项目。
finance_claim_snapshot (finance_account_id, req_no) 唯一;保存当前报销单的状态组合、项目号、最后出现时间及变更序号,用于差异检测。物流详情尚未进入轮询快照。

实现字段:FinancePortalAccount.projectNotificationBaselineAt 与 claimNotificationBaselineAt 分别标记项目、报销单首次完整取数成功,避免项目空列表阻塞报销进度基线;FinanceClaimSnapshot.revision 每次状态变化加一,使状态往返时仍有独立事件键;InvoiceRequirement.generation 用于重新打开后的事件版本,history 保存状态、操作人、时间、备注和附件 ID。事件及快照均使用服务角色事务写入,六张新表全部启用 FORCE RLS;材料要求允许当前工作区 active 成员读取,事件收件人只允许本人读取,投递与财务快照只允许服务角色读取。

NotificationDelivery.status 使用已有 text 列:pending / delivering / failed / delivered / dead / skipped;20261012000100_notification_delivery_skipped migration 扩展现有 CHECK 约束以允许 skipped。skipped 表示投递前当前订阅、频道作用域或个人成员权限已失效,deliveredAt 保持 null;不会作为成功投递。租约由 nextAttemptAt 表示,完成和失败更新必须匹配持有的租约与尝试次数。达到 6 次的过期任务转 dead,站内通知独立保留,其他字段与 RLS 不变。

FinanceClaimSnapshot.lastStatus 保存去除各状态段首尾空白和空字段后的组合字符串。比较旧快照时也做同样归一化,仅格式差异不增加 revision;后续完整同步会写回规范值,无需增加字段或 migration。

分类结论溯源:invoice.category_source 记录给出结论的分类器(rule:user / rule:workspace / rule:organization / taxonomy:default / service_type,预留 model:*),链序见 03-architecture.md §5.2。规则匹配语义:关键词对 seller_name 或 items_raw 做大小写不敏感的包含匹配,同层按 priority 升序取首条命中。category 目标取值是浙大日常报销 48 个叶子的 一级/二级 名称(如 交通费/市内交通费)。门户 bCode / buCode 挂在 CAMPUS_TAXONOMY 上,不另存发票列。内置词典与国税别名归一见 03 文档 §5.2;规则与词典未覆盖、别名未映射时保留原生类别字符串。

报销规则仍由 packages/core 实时求值;其中五类可操作材料要求(行程单、票面完整性、购货明细、扫码验证、固定资产认定)同步到 invoice_requirement。列表只把 open 要求作为待办,详情保留 resolved/waived 审计;规则变更后可重算历史发票。

个人 Webhook 是用户自有凭据,只允许本人读写;共享 Webhook 由 workspace owner 管理。所有 secret 只进不出、信封加密,日志不得包含完整 URL 查询参数。

mail_account / mail_message(M2,IMAP 拉取管道见 03-architecture.md §6):

  • mail_account:拉取配置与水位。筛选三项 sender_whitelist / subject_keywords / attachment_extensions 各自非空才生效,三项全空 = 全收;poll_interval_seconds(默认 600)驱动 worker 轮询;uid_validity 变更或首次拉取时 last_seen_uid 水位作废,按 lookback_days(默认 30)回扫;consecutive_failures ≥ 2 仅在设置页展示(不发通知),成功一轮即清零并清空 last_error;拉到新增发票时发 info 通知。授权码信封加密(ENVELOPE_MASTER_KEY),API 只回 authConfigured 掩码。
  • mail_message:邮件级审计与防重。status 三态——imported(附件已入库并指向 parse_task)/ fetched_no_attachment(命中筛选但无可取附件:无附件、扩展名不符或超 10MB)/ skipped_no_match(发件人/主题未命中)。workspace_id 冗余自账户,RLS 与 mail_account 同判 app_has_workspace_access;parse_task_id 走 (workspace_id, parse_task_id) 复合外键(红线 2)。

finance_portal_account(财务门户同步管道,见 03-architecture.md §6.1):

  • provider 枚举(zju)标记门户厂商,协议实现只存在于 packages/providers(红线 7);uid(学工号)为明文展示列,password_encrypted 信封加密(ENVELOPE_MASTER_KEY),API 只回 credentialsConfigured 掩码(红线 4)。
  • poll_interval_seconds(默认 3600,下限 300)驱动 worker 轮询;下限约束源于门户「4 次登录失败锁定」策略,避免高频登录触发风控。
  • last_sync_at / last_error / consecutive_failures 与 mail_account 同语义:连续失败 ≥2 仅在设置页展示(不发通知),成功一轮即清零,并发出 info「同步完成」通知。
  • RLS 同 app_has_workspace_access("workspaceId") 模式(ENABLE + FORCE)。

notify_channel(M2,hub 路由投递见 03-architecture.md §8.2):

  • scope=user 必须挂 user_id 且不挂工作区;scope=workspace 必须挂工作区。个人频道跨工作区接收发给本人的事件,共享频道仅按事件工作区匹配。历史 scope=organization 行保留但停用;迁移 20261014000100_retire_organization_notify_channels 将其 enabled 设为 false、未完成投递设为 skipped,并增加 CHECK 禁止组织渠道重新启用,不删除历史已投递记录。
  • type 三种:dingtalk(加签置 URL 参数)/ feishu(签名置 body sign)/ webhook(通用 JSON,覆盖企微/Bark/ntfy/Gotify);签名纯函数在 @poco/shared/webhook。
  • secret_encrypted 信封加密,API 只回 secretConfigured;个人频道的机器人 URL 含访问令牌,写入 target_url_encrypted,target_url 仅存占位地址,API 不回显真实地址。topics 默认全选三类业务提醒。notification_delivery 唯一键 (event_id, channel_id) 保证共享频道不会因多收件人重复发送。
  • RLS 与 category_rule 同模式:读 = 服务角色或组织 active 成员;写 = 仅服务角色(CRUD 走 api 按 scope 鉴权——workspace 须 owner,organization 已停用)。

notification 是个人数据而非工作区数据(RLS 按收件人本人判定,不走成员表);workspace_id 仅为来源标注,工作区删除时置空(SET NULL),通知本身保留:

CREATE TABLE notification (
    id                 uuid PRIMARY KEY,
    recipient_user_id  text NOT NULL REFERENCES "user"(id),
    workspace_id       uuid REFERENCES workspace(id) ON DELETE SET NULL,
    level              text NOT NULL DEFAULT 'info',  -- info / warning / critical
    key                text NOT NULL,                 -- 聚合键(hub 10 分钟窗口去重)
    title              text NOT NULL,
    body               text NOT NULL,
    read_at            timestamptz,
    created_at         timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_notification_recipient ON notification(recipient_user_id, created_at DESC);

ALTER TABLE notification ENABLE ROW LEVEL SECURITY;
ALTER TABLE notification FORCE ROW LEVEL SECURITY;
-- 个人维度:收件人本人可读写自己的通知;写入由 worker 服务角色完成
CREATE POLICY notification_recipient_self ON notification
USING (app_is_service_role() OR recipient_user_id = current_setting('app.user_id', true));

8. 行级安全与审计

Prisma middleware 负责将 workspace_id 自动加入普通业务查询,减少遗漏;它不是访问控制边界。PostgreSQL 对所有带 workspace_id 的业务表启用并强制 RLS,策略以活动 workspace_member 为准。

ALTER TABLE invoice ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoice FORCE ROW LEVEL SECURITY;

CREATE POLICY invoice_workspace_member ON invoice
USING (EXISTS (
  SELECT 1 FROM workspace_member m
  WHERE m.workspace_id = invoice.workspace_id
    AND m.user_id = current_setting('app.user_id', true)
    AND m.status = 'active'
));

API 每个数据库事务开始后必须执行 SELECT set_config('app.user_id', :user_id, true);true 使该值限于当前事务,避免连接池串号。worker 使用受限服务角色,只按已领取任务的 workspace_id 操作。所有 queryRaw 收口在 packages/db,并有跨工作区越权测试。

运行时与迁移使用不同数据库角色:api/worker 以受限角色 poco_app(NOSUPERUSER、无建库建角色能力)连接,仅持有业务表 DML 权限,保证 RLS 不被超级用户绕过;角色与授权由迁移 app_role_and_grants 提供,密码在 compose 首次初始化时由 docker/postgres-init-app-role.sh 写入。建表迁移仍由 owner 角色经 MIGRATION_DATABASE_URL 执行。

策略定义以成员表为信任源:workspace_member / organization_member 的策略只允许“本人可见自身行 / 服务角色全可见”,其余表经 app_has_workspace_access 解析到成员表后不再回环。若成员表策略自身引用 app_has_workspace_access,会在非空表上形成策略互递归(stack depth limit exceeded),该教训见迁移 rls_member_policy_recursion_fix。

管理员排障、成员移除、受控文件下载等敏感行为写入:

CREATE TABLE access_audit (
    id                  uuid PRIMARY KEY,
    actor_user_id       text NOT NULL REFERENCES "user"(id),
    target_workspace_id uuid NOT NULL REFERENCES workspace(id),
    action              text NOT NULL,
    resource_type       text,
    resource_id         text,
    reason              text NOT NULL,
    created_at          timestamptz NOT NULL
);
CREATE INDEX idx_access_audit_workspace
  ON access_audit(target_workspace_id, created_at DESC);

9. 索引、外键与迁移要求

  • 列表查询均以 workspace_id 为首列建索引,例如 invoice(workspace_id, status, invoice_date DESC)、attachment(workspace_id, status)、reimburse_batch(workspace_id, updated_at DESC);按文档种类筛选另加 attachment(workspace_id, receipt_kind)。
  • 财务票据结构化结果用 JSONB:invoice.detail(票种专属字段,按 invoice_kind 解释)、attachment.documents(RecognizedDocument[])、attachment.ocr_raw_json(完整原始返回)。JSONB 内金额仍为分(红线 1);读出时按 kind/invoice_kind 判别联合校验(docs/12 §4)。
  • 涉及两个工作区实体的外键一律使用 (workspace_id, id) 复合外键;单列 ID 关系不足以阻止跨空间串联。
  • archive_path 是交付包内的虚拟路径(Archive/... 前缀),归档、入批、挂载、软删均只写库,不动物理文件;文件副作用仅剩上传写 blob、导出物化与引用归零后的物理回收,均先写库、后动文件。
  • 默认不物理删除已报销发票与审计记录;业务删除为状态迁移或回收站。工作区删除须异步、可审计,并先确认所有成员与批次状态。
  • migration 必测:个人空间隔离、共享空间协作、跨空间关联失败、组织级重复票、RLS 越权读写失败、成员停用后立即失权。

10. 桌面版导入

旧 SQLite 工作区导入到一个指定的个人或共享工作区:先创建 workspace_file/file_blob(文件直接写入根级 blob 层),再导入发票、附件、任务审计和批次,并按原目录换算 archive_path 虚拟路径(不建物理目录)。导入过程同样要写入 invoice_dedup_key;冲突记录保留导入报告而非静默丢弃。

11. 平台用量与配额(计量层,M2+)

多用户数据表,全部启用并强制 RLS;金额一律整数 micros(红线 1),与业务「报销额度(quota)」无关。

  • ocr_credential(用户自备 OCR 凭据,docs/03 §5.7):user_id(unique)、provider、api_key_encrypted/secret_key_encrypted(信封加密,明文不落库)、enabled、last_error。RLS:本人读写自己的行;worker 服务角色读取全部启用行、解密后并入号池(红线 3/4)。
CREATE TABLE usage_event (
    id              uuid PRIMARY KEY,
    organization_id uuid NOT NULL,
    workspace_id    uuid NOT NULL,
    user_id         text REFERENCES "user"(id) ON DELETE SET NULL,
    provider        text NOT NULL,            -- baidu / jev / clef / ddddocr
    operation       text NOT NULL,            -- ocr.vat_invoice / model.systemone ...
    unit_kind       text NOT NULL,            -- call / input_token / output_token
    units           integer NOT NULL DEFAULT 1,
    outcome         text NOT NULL DEFAULT 'success', -- success / failed / blocked
    source_type     text,                     -- parse_task / invoice_list ...
    source_id       text,
    dedupe_key      text NOT NULL UNIQUE,     -- 幂等:重复上报只记一次
    cost_micros     bigint,                   -- 供应商成本(最小货币单位)
    price_version   text,
    captured_at     timestamptz NOT NULL DEFAULT now(),
    FOREIGN KEY (organization_id, workspace_id)
      REFERENCES workspace(organization_id, id)
);
CREATE INDEX idx_usage_event_org ON usage_event(organization_id, captured_at);
CREATE INDEX idx_usage_event_workspace ON usage_event(workspace_id, captured_at);
CREATE INDEX idx_usage_event_provider ON usage_event(provider, operation, captured_at);

CREATE TABLE provider_price (
    id          uuid PRIMARY KEY,
    provider    text NOT NULL,
    operation   text NOT NULL,
    unit_kind   text NOT NULL,
    currency    text NOT NULL DEFAULT 'CNY',
    cost_micros bigint NOT NULL,
    valid_from  timestamptz NOT NULL,
    valid_to    timestamptz,
    UNIQUE (provider, operation, unit_kind, currency, valid_from)
);

CREATE TABLE usage_entitlement (
    id              uuid PRIMARY KEY,
    organization_id uuid NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
    provider        text NOT NULL,
    period          varchar(7) NOT NULL,      -- 计费周期 YYYY-MM(UTC)
    included_units  integer NOT NULL,
    used_units      integer NOT NULL DEFAULT 0, -- reserve 事务内原子自增
    overage_policy  text NOT NULL DEFAULT 'block', -- block / allow
    created_at      timestamptz NOT NULL DEFAULT now(),
    updated_at      timestamptz NOT NULL,
    UNIQUE (organization_id, provider, period)
);
  • usage_event RLS 同 app_has_workspace_access("workspace_id"):计量明细按工作区成员可读。(organization_id, workspace_id) 复合外键阻止跨空间串味(红线 2),因此事件必须归到具体工作区。
  • provider_price 仅服务角色可读写(app_is_service_role()),属全局成本数据。
  • usage_entitlement 读 = 服务角色或组织 active 成员,写 = 仅服务角色(CRUD 走 api 按角色鉴权,写需 owner)。
  • 计量事实(usage_event)与配额执行计数(usage_entitlement.used_units)分离:前者 append-only 供明细/汇总,后者由 reserveUsage 条件自增(used_units <= included_units - units)保证并发下不透支。