OA 印章管理系统实战(四):印章业务建模——8 张表的故事

调研文档里写的是"从零设计 8 张表",实际写 DDL 时才发现:好享购物的旧 OA 给我留了一个烂摊子当反面教材——一张表 20 个字段但全是 NULL,另一张表只存 id 不存名称。这一章讲的是从旧表的废墟上搭新表的过程。
事故现场:一张表的野心 vs 三张表的现实
一开始我想偷懒把印章全塞进一张 oa_seal 表——20 个字段搞定实体章和电子章。跑起来发现三个硬伤:
硬伤 1:保管人字段冲突。 实体章有"保管人"(admin_id)和"保管部门"(dept_id),电子章不需要这些字段(它是文件,存在服务器)。一张表里存两个字段,电子章那两行永远是 NULL。
硬伤 2:授权和使用逻辑不同。 实体章要管"借用归还"(seal_borrow 表),电子章要管"签章次数"(seal_use_record 里存 sign_hash)。一张表没法同时挂两种外键。
硬伤 3:状态枚举不够用。 一开始 seal_status 只设了 3 种(在用/停用/作废),后来发现还需要:借用中、归档、待启用——5 种状态是真实业务需要,不是我想多了。
然后我重新拆,最终落地 8 张表:
印章管理 8 张表
├── oa_seal_info 印章台账(实体章 + 电子章,seal_type 区分)
├── oa_seal_auth 三维度授权(角色/部门/用户 × 固定/临时)
├── oa_seal_apply 用印申请单(BusinessKey 载体)
├── oa_seal_use_record 用印执行记录(每次用印一条)
├── oa_seal_borrow_record 实体章借用归还台账
├── oa_form_def 动态表单定义(VForm JSON schema)
├── oa_form_instance 动态表单实例(用户填写的 JSON 数据)
└── oa_audit_log AOP 审计日志(只增不改不删)
排查:每张表的关键字段决策
oa_seal_info(印章台账)
CREATE TABLE oa_seal_info (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
seal_name VARCHAR(128) NOT NULL COMMENT '印章名称',
seal_type CHAR(1) NOT NULL COMMENT '1=实体 2=电子',
seal_code VARCHAR(64) COMMENT '印章编号(公司内部编号)',
category VARCHAR(32) COMMENT '公章/合同章/财务章/发票章/法人章',
seal_image VARCHAR(512) COMMENT '印章图片路径(电子章存图片URL)',
keeper_id BIGINT COMMENT '保管人ID(实体章用)',
keeper_dept BIGINT COMMENT '保管部门ID(实体章用)',
seal_status CHAR(1) DEFAULT '1' COMMENT '1=在用 2=借用中 3=停用 4=作废 5=归档',
remark VARCHAR(512),
create_by VARCHAR(64),
create_time DATETIME,
update_by VARCHAR(64),
update_time DATETIME
);
为什么 seal_type 是 CHAR(1) 不是 TINYINT? 因为 SQL 里写 where seal_type = '1' 比 where seal_type = 1 可读性好——"1=实体 2=电子"一眼能看懂。好享购物旧系统用 TINYINT 0/1,没人知道 0 是什么。
oa_seal_auth(三维度授权)
CREATE TABLE oa_seal_auth (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
seal_id BIGINT NOT NULL COMMENT '印章ID',
auth_type CHAR(1) NOT NULL COMMENT '1=角色 2=部门 3=用户',
auth_target_id BIGINT NOT NULL COMMENT '角色ID/部门ID/用户ID',
auth_target_name VARCHAR(128) COMMENT '冗余名称,避免每次联查',
remark VARCHAR(512),
create_by VARCHAR(64),
create_time DATETIME,
INDEX idx_seal_id (seal_id),
INDEX idx_auth_target (auth_type, auth_target_id)
);
为什么 auth_type 是 CHAR(1) + CASE 联查? 因为若依的 sys_role、sys_dept、sys_user 是三张不同的表,id 都叫 role_id/dept_id/user_id。在 Mapper XML 里用 CASE WHEN 联查:
<sql id="selectOaSealAuthVo">
select a.id, a.seal_id, a.auth_type, a.auth_target_id,
case a.auth_type
when '1' then (select r.role_name from sys_role r where r.role_id = a.auth_target_id)
when '2' then (select d.dept_name from sys_dept d where d.dept_id = a.auth_target_id)
when '3' then (select u.user_name from sys_user u where u.user_id = a.auth_target_id)
else null
end as auth_target_name,
a.create_by, a.create_time, a.remark
from oa_seal_auth a
</sql>
这是我从若依骨架里学的套路——不造轮子,复用若依的组织架构表。前端显示名称时不需要二次发 SQL,一次 CASE 联查搞定。
oa_seal_apply(用印申请单)
CREATE TABLE oa_seal_apply (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
apply_no VARCHAR(32) NOT NULL COMMENT '申请单号 SA+时间戳+随机',
seal_id BIGINT NOT NULL COMMENT '印章ID',
seal_name VARCHAR(128) COMMENT '冗余印章名称',
applicant VARCHAR(64) NOT NULL COMMENT '申请人用户名',
dept_id BIGINT COMMENT '申请人部门',
purpose VARCHAR(512) NOT NULL COMMENT '用印用途',
file_name VARCHAR(256) COMMENT '用印文件名(合同/报告)',
use_count INT DEFAULT 1 COMMENT '用印次数',
attachment VARCHAR(512) COMMENT '附件路径',
process_instance_id VARCHAR(64) COMMENT 'Camunda 流程实例ID',
approval_status CHAR(1) DEFAULT '0' COMMENT '0=草稿 1=审批中 2=通过 3=驳回',
create_by VARCHAR(64),
create_time DATETIME,
update_by VARCHAR(64),
update_time DATETIME,
INDEX idx_apply_no (apply_no),
INDEX idx_applicant (applicant),
INDEX idx_approval_status (approval_status)
);
为什么 seal_name 是冗余字段? 因为列表页高频展示申请单时,每次 LEFT JOIN oa_seal_info 会慢。冗余一个 seal_name,查询直接读值,不需要联查。这是典型的读多写少场景下的反范式优化——若依骨架里 sys_user.dept_name 也是这么做的。
底层原理:为什么授权要做三维度?
好享购物的印章授权场景:
| 印章 | 授权对象 | 类型 | |---|---|---| | 合同章 | 财务部全体员工 | 部门(auth_type=2) | | 合同章 | 销售主管(角色) | 角色(auth_type=1) | | 发票章 | 出纳(用户) | 用户(auth_type=3) | | 项目专用章 | 张三 + 临时借调李四 | 用户 × 2 |
旧 OA 的 seal_permission(seal_id, user_id) 只能表示"张三能用合同章",表示不了"财务部全体能用合同章"——如果财务部有 20 人,就要插 20 行。更要命的是:财务部招了新人,你得手动给新人加合同章授权。
三维度授权的好处:
auth_type=2+auth_target_id=财务部ID→ 财务部所有人自动获得授权- 新人入职 → 自动挂到部门 → 自动获得部门级授权
- 部门级授权变更 → 改一行,不用改 N 行
授权校验逻辑(伪代码):
boolean hasAuth(Long sealId, String username) {
// 1. 用户级授权(auth_type=3)
if (exists("select 1 from oa_seal_auth where seal_id=? and auth_type='3' and auth_target_id in (select user_id from sys_user where user_name=?)", sealId, username))
return true;
// 2. 角色级授权(auth_type=1)
if (exists("select 1 from oa_seal_auth where seal_id=? and auth_type='1' and auth_target_id in (select role_id from sys_user_role where user_id=(select user_id from sys_user where user_name=?))", sealId, username))
return true;
// 3. 部门级授权(auth_type=2)
if (exists("select 1 from oa_seal_auth where seal_id=? and auth_type='2' and auth_target_id=(select dept_id from sys_user where user_name=?)", sealId, username))
return true;
return false;
}
正确姿势:8 张表 DDL 设计原则
| 原则 | 体现 | |---|---| | 复用若依组织架构 | auth_type + CASE 联查 sys_role/sys_dept/sys_user,不造轮子 | | 读多写少反范式 | seal_name、seal_type 冗余,列表页直接读不联查 | | 状态用 CHAR(1) + 注释 | seal_type/seal_status/auth_type/approval_status 全是 CHAR(1),字段注释里写清楚枚举值 | | 索引按查询模式建 | oa_seal_auth 建 (seal_id) 和 (auth_type, auth_target_id) 两个索引——前者按印章查授权,后者按授权对象反查 | | 审计留痕只增不改 | oa_audit_log 没有 update/delete 操作,数据库层可以加 trigger 限制 |
面试怎么答:"你做过权限设计吗?"
"我做的印章授权是三维度的——印章 × 授权对象(角色/部门/用户)× 授权类型(固定/临时),对比传统 RBAC 的二维度(角色 × 权限),印章需要多一维是因为同一角色在不同部门能授权的章不同。
具体实现上,oa_seal_auth 表用 auth_type CHAR(1) 区分三种授权对象,Mapper XML 里用 CASE WHEN 联查 sys_role/sys_dept/sys_user 三张若依表展示名称——不造轮子,复用若依的组织架构。授权校验时按用户→角色→部门三个维度依次检查,任一命中即通过。
读多写少场景下做了反范式优化:oa_seal_apply 里冗余了 seal_name,列表页直接读不需要每次 LEFT JOIN oa_seal_info。这是若依骨架里 sys_user.dept_name 的同款思路。
另外我们还踩了一个反范式的坑:一开始 seal_type 用 TINYINT 0/1,后来改成 CHAR(1) 加注释——SQL 里 where seal_type='1' 比 where seal_type=1 可读性好太多,TINYINT 的 0/1 在没人维护注释的情况下很容易变成不可读的魔法数字。"
落地清单
8 张表 DDL 骨架(核心字段):
| 表 | 核心字段 | 索引 | |---|---|---| | oa_seal_info | seal_id, seal_type(1实体/2电子), seal_status(5状态) | seal_type | | oa_seal_auth | seal_id, auth_type(1角色/2部门/3用户), auth_target_id | seal_id + (auth_type, auth_target_id) | | oa_seal_apply | apply_no, seal_id, applicant, approval_status(0草稿/1审批中/2通过/3驳回), process_instance_id | apply_no + approval_status | | oa_seal_use_record | apply_id, seal_id, user_name, use_time, sign_hash | apply_id + seal_id | | oa_seal_borrow_record | seal_id, borrower_id, borrow_time, return_time, status | seal_id + status | | oa_form_def | form_code, form_name, form_schema(JSON) | form_code | | oa_form_instance | form_def_id, form_data(JSON), business_id | form_def_id + business_id | | oa_audit_log | module, operation, before_data(JSON), status, cost_time | module + create_time |
下一章第 05 章讲动态表单引擎——VForm JSON schema 怎么接 Java 后端。
