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(签名置 bodysign)/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_eventRLS 同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)保证并发下不透支。