feat(ops): 操作日志留痕、字典管理、名册改删(V9 / V10)

PRD FR-506/FR-1202 的 P0 项:V9 给 sys_operation_log 补 target_type/target_id/
before_val/after_val,OperationLogService 显式留痕并统一强制脱敏;
已接入的敏感动作:名册改证件号/改名/删名册项、任务发布/暂停/恢复/截止/归档。

F-32 的教训落地:动作名由调用方显式给,不从 (原状态, 新状态) 反推——
「发布」与「恢复」都是 →收集中,反推必然把 resume 记成 PUBLISH_TASK。

字典:内置字典写在代码里(DictCatalog),库里只存客户自定义项与内置项停用开关,
两表分开才能「一键恢复默认」;表单下拉与字典共用同一份口径(BuiltinFields 加访问器)。

验证:e2e-m5-logs 44/44、e2e-m1-dict 53/53。
This commit is contained in:
2026-09-30 09:02:53 +08:00
parent 29c8e932ae
commit 7090ee7d65
23 changed files with 3237 additions and 8 deletions
@@ -0,0 +1,78 @@
-- ============================================================================
-- V10:数据字典(《03-接口设计》§13.3)
--
-- 为什么内置字典**不进这张表**
-- ---------------------------------------------------------------------------
-- 契约 §13.3 把字典分成两类,而这个区分是刻意的:
--
-- · **内置字典**(`builtin: true`):EDU_LEVEL / JOB_LEVEL / CERT_TYPE /
-- RETARGET_SUBJECT / REJECT_REASON 五项。它们的选项**照抄官方表格**,
-- 不许擅改(`BuiltinFields` 的类注释里写了这条)。所以它们定义在**代码里** ——
-- 和权限码同样的思路:能改的放库里,不能改的放代码里,改它必须走一次发布。
-- 放进表里会带来一个具体风险:有人从界面上把"四级/中级工"改名成"中级",
-- 导出的汇总表就与官方模板对不上了,而那时没有任何地方会报错。
--
-- · **可维护的字典**:客户填表时遇到的内置项不够用(例如多了个"技校"学历),
-- 由管理员自己加。这类进 `sys_dict_item` —— 它们**允许**被改,
-- 因为它们是客户自己的口径,不是官方的。
--
-- OCCUPATION(职业/工种)是第三种情况:它**来自 `sys_occupation_ref`**
-- (国标职业库,有自己的层级与别名),既不进代码也不进这张表。
-- 三处来源的取舍都写在 `DictCatalog` 的注释里。
-- ============================================================================
-- 自定义字典项(含对内置字典的"追加")
CREATE TABLE IF NOT EXISTS sys_dict_item (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
-- 字典码,如 EDU_LEVEL / JOB_LEVEL / CERT_TYPE
dict_code VARCHAR(64) NOT NULL,
item_code VARCHAR(64) NOT NULL,
item_name VARCHAR(128) NOT NULL,
-- 层级(如职业-工种):指向同字典内另一个 item_code
parent_item_code VARCHAR(64),
sort_no INTEGER NOT NULL DEFAULT 0,
-- 0 停用 / 1 启用
status SMALLINT NOT NULL DEFAULT 1,
-- true = 这个项是对**内置字典**的追加(内置项本身仍在代码里)
builtin_extend SMALLINT NOT NULL DEFAULT 0,
create_by BIGINT,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by BIGINT,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted SMALLINT NOT NULL DEFAULT 0
);
COMMENT ON TABLE sys_dict_item IS '自定义字典项(内置字典的选项在代码里,见 DictCatalog;OCCUPATION 来自 sys_occupation_ref)';
COMMENT ON COLUMN sys_dict_item.dict_code IS '字典码:EDU_LEVEL/JOB_LEVEL/CERT_TYPE/RETARGET_SUBJECT/REJECT_REASON/OCCUPATION + 客户自定义';
COMMENT ON COLUMN sys_dict_item.item_code IS '项码。⚠️ **不许改**(改了会破坏历史数据里已经存下的值)';
COMMENT ON COLUMN sys_dict_item.parent_item_code IS '层级父项(如职业-工种);空表示顶层';
COMMENT ON COLUMN sys_dict_item.builtin_extend IS '1 = 对内置字典的追加项(内置项本身不出来在这里)';
-- 同一个字典里项码唯一(软删的不算)—— 手工建一条部分唯一索引:
-- 普通 UNIQUE 会把软删的行也算进去,于是"删过再想加回来"会失败,
-- 而用户看到的是"这个编码已存在",但他明明刚删掉它
CREATE UNIQUE INDEX IF NOT EXISTS uk_dict_item_code
ON sys_dict_item (tenant_id, dict_code, item_code)
WHERE deleted = 0;
CREATE INDEX IF NOT EXISTS idx_dict_item_code
ON sys_dict_item (tenant_id, dict_code, status, sort_no);
-- 内置字典的"停用单项"(契约 §13.3:内置字典不可删,只可停用单项)。
-- ⚠️ 不复用 sys_dict_item.status:那张表是"客户自己加的项",
-- 而这里是"官方项被客户停用了"。混在一起之后,
-- "恢复默认"就没法做了(分不清哪些是官方项、哪些是客户加的)。
CREATE TABLE IF NOT EXISTS sys_dict_disabled (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
dict_code VARCHAR(64) NOT NULL,
item_code VARCHAR(64) NOT NULL,
create_by BIGINT,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE sys_dict_disabled IS '被停用的**内置**字典项(契约 §13.3:内置字典不可删、只可停用单项)';
CREATE UNIQUE INDEX IF NOT EXISTS uk_dict_disabled
ON sys_dict_disabled (tenant_id, dict_code, item_code);