From 7090ee7d65e139045ddd2551ea07eb22f68a096f Mon Sep 17 00:00:00 2001 From: lovife <474239041@qq.com> Date: Wed, 30 Sep 2026 09:02:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(ops):=20=E6=93=8D=E4=BD=9C=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E7=95=99=E7=97=95=E3=80=81=E5=AD=97=E5=85=B8=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E3=80=81=E5=90=8D=E5=86=8C=E6=94=B9=E5=88=A0=EF=BC=88?= =?UTF-8?q?V9=20/=20V10=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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。 --- .../audit/service/RejectReasonCatalog.java | 11 + .../dict/controller/DictController.java | 111 +++++ .../fill/module/dict/dto/DictDtos.java | 117 +++++ .../module/dict/mapper/DictItemMapper.java | 246 ++++++++++ .../fill/module/dict/service/DictCatalog.java | 103 ++++ .../fill/module/dict/service/DictService.java | 308 ++++++++++++ .../module/form/service/BuiltinFields.java | 16 + .../module/log/controller/LogController.java | 138 ++++++ .../fill/module/log/dto/LogDtos.java | 111 +++++ .../module/log/entity/SysOperationLog.java | 52 +- .../module/log/mapper/LoginLogMapper.java | 92 ++++ .../module/log/mapper/OperationLogMapper.java | 128 +++++ .../log/service/OperationLogService.java | 240 ++++++++++ .../roster/controller/RosterController.java | 71 +++ .../module/roster/dto/RosterEditDtos.java | 97 ++++ .../roster/mapper/CollectRosterMapper.java | 19 + .../roster/service/RosterEditService.java | 301 ++++++++++++ .../main/resources/db/migration/V10__dict.sql | 78 +++ .../V9__operation_log_audit_columns.sql | 42 ++ frontend-pc/src/apis/xt/dict.ts | 104 ++++ frontend-pc/src/apis/xt/log.ts | 124 +++++ frontend-pc/src/views/dict/index.vue | 445 ++++++++++++++++++ frontend-pc/src/views/log/index.vue | 291 ++++++++++++ 23 files changed, 3237 insertions(+), 8 deletions(-) create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/dict/controller/DictController.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/dict/dto/DictDtos.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/dict/mapper/DictItemMapper.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/dict/service/DictCatalog.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/dict/service/DictService.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/controller/LogController.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/dto/LogDtos.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/service/OperationLogService.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/dto/RosterEditDtos.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/service/RosterEditService.java create mode 100644 backend/xt-server/src/main/resources/db/migration/V10__dict.sql create mode 100644 backend/xt-server/src/main/resources/db/migration/V9__operation_log_audit_columns.sql create mode 100644 frontend-pc/src/apis/xt/dict.ts create mode 100644 frontend-pc/src/apis/xt/log.ts create mode 100644 frontend-pc/src/views/dict/index.vue create mode 100644 frontend-pc/src/views/log/index.vue diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/service/RejectReasonCatalog.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/service/RejectReasonCatalog.java index 7db156d..244c83d 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/service/RejectReasonCatalog.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/service/RejectReasonCatalog.java @@ -54,6 +54,17 @@ public final class RejectReasonCatalog { return out; } + /** + * 全部原因码(字典管理页要用)。 + * + *
⚠️ 返回 code 而不是文案:字典页面要列"有哪些原因可停用/追加",
+ * 而审核页要按 code **校验** —— code 是这两处的公共键。
+ * 文案在 {@link #list()} 里,两处引用同一份 {@code TEXTS},不复制。
+ */
+ public static List 理由:字典影响的是**所有任务的下拉选项与导出内容**,改错一个会让
+ * 已提交的数据指向不存在的项。所以它应当只有"能建与配置收集任务"的那批人能动 ——
+ * 这与 {@code task:manage} 恰好是同一批人。新造权限码会导致
+ * 某个角色"能改任务配置但改不了字典",而那两件事的风险是同一级别。
+ *
+ * ⚠️ 内置字典的项名**改不了**(契约 §13.3 的 {@code builtin: true}):
+ * 只可停用单项 + 追加自定义项。想改内置项名得改代码发布 —— 这是刻意的,
+ * 因为内置选项照抄官方表格,改名会让导出的汇总表与官方模板对不上。
+ */
+@Tag(name = "字典管理")
+@RestController
+@RequestMapping("/api/v1/dicts")
+@RequiredArgsConstructor
+public class DictController {
+
+ private final DictService dictService;
+
+ @GetMapping
+ @RequirePerm(PermissionDef.TASK_VIEW)
+ @Operation(summary = "字典列表",
+ description = "`[{ dictCode, dictName, itemCount, builtin }]`。"
+ + "⚠️ `itemCount` 只算**启用中**的项 —— 把停用的也算进去,"
+ + "管理员会看到「学历 11 项」却只数出 10 个,以为界面漏了")
+ public ApiResult ⚠️ 只算**启用中**的项:停用的项在界面上不该被计入"这个字典有多少项",
+ * 否则管理员看到"学历 11 项"却只数出 10 个,会以为界面漏了。
+ */
+ private int itemCount;
+
+ /**
+ * {@code true} = 内置字典:**项名不可改、不可删**,只可停用单项 + 追加自定义项。
+ *
+ * 置 true 的理由:内置选项照抄官方表格,改名会让导出的汇总表与官方模板对不上,
+ * 而那时没有任何地方会报错。
+ */
+ private boolean builtin;
+ }
+
+ /** 一个字典项。 */
+ @Getter
+ @Setter
+ @Schema(description = "字典项")
+ public static class DictItemVO {
+
+ /** 自定义项有 id;内置项没有(它在代码里,没有行) */
+ private Long id;
+
+ private String itemCode;
+
+ private String itemName;
+
+ private String parentItemCode;
+
+ private Integer sortNo;
+
+ /** {@code 0} 停用 / {@code 1} 启用 */
+ private Integer status;
+
+ /** {@code true} = 内置项(代码里定义,不可改名/删除) */
+ private Boolean builtin;
+
+ private LocalDateTime createTime;
+ }
+
+ /** 字典项列表。 */
+ @Getter
+ @Setter
+ public static class DictItemListVO {
+
+ private String dictCode;
+
+ private String dictName;
+
+ private boolean builtin;
+
+ /** 这个字典**是否支持**层级(如职业-工种)。前端据此决定要不要显示父项列 */
+ private boolean hierarchical;
+
+ private List 手写 SQL + 显式 {@code tenant_id}:字典项要按"字典码 + 是否启用"过滤,
+ * 而"停用的项"与"没这个项"在排查时完全是两回事,显式条件更容易看出来。
+ */
+public interface DictItemMapper {
+
+ /** 一行字典项。 */
+ class ItemRow {
+ private Long id;
+ private String dictCode;
+ private String itemCode;
+ private String itemName;
+ private String parentItemCode;
+ private Integer sortNo;
+ private Integer status;
+ private java.time.LocalDateTime createTime;
+
+ public Long getId() { return id; }
+ public void setId(Long v) { this.id = v; }
+ public String getDictCode() { return dictCode; }
+ public void setDictCode(String v) { this.dictCode = v; }
+ public String getItemCode() { return itemCode; }
+ public void setItemCode(String v) { this.itemCode = v; }
+ public String getItemName() { return itemName; }
+ public void setItemName(String v) { this.itemName = v; }
+ public String getParentItemCode() { return parentItemCode; }
+ public void setParentItemCode(String v) { this.parentItemCode = v; }
+ public Integer getSortNo() { return sortNo; }
+ public void setSortNo(Integer v) { this.sortNo = v; }
+ public Integer getStatus() { return status; }
+ public void setStatus(Integer v) { this.status = v; }
+ public java.time.LocalDateTime getCreateTime() { return createTime; }
+ public void setCreateTime(java.time.LocalDateTime v) { this.createTime = v; }
+ }
+
+ /** 某个字典下的自定义项。 */
+ @InterceptorIgnore(tenantLine = "true")
+ @Select("""
+ SELECT id, dict_code AS dictCode, item_code AS itemCode, item_name AS itemName,
+ parent_item_code AS parentItemCode, sort_no AS sortNo, status, create_time AS createTime
+ FROM sys_dict_item
+ WHERE tenant_id = #{tenantId} AND dict_code = #{dictCode} AND deleted = 0
+ ORDER BY sort_no, id
+ """)
+ List 用途:字典列表要显示"客户自己加的字典"(例如"专业"),
+ * 而它们没有代码里的定义,只能从数据里发现。
+ */
+ @InterceptorIgnore(tenantLine = "true")
+ @Select("""
+ SELECT DISTINCT dict_code FROM sys_dict_item
+ WHERE tenant_id = #{tenantId} AND deleted = 0
+ ORDER BY dict_code
+ """)
+ List ⚠️ {@code sys_occupation_ref} **没有 tenant_id** —— 它是国标数据,
+ * 全平台共用。所以这里既不 {@code @InterceptorIgnore} 也不带租户条件,
+ * 但必须靠**表本身没有租户列**这一点来保证不会越权(而不是靠"我记得不加")。
+ *
+ * 一期这张表是空的(国标库要客户提供或去买),所以字典页会显示 0 项 ——
+ * 这是**已知情况**,界面要说明,不能让人以为功能坏了。
+ */
+ @InterceptorIgnore(tenantLine = "true")
+ @Select("""
+ SELECT id, parent_id AS parentId, name, aliases, has_child AS hasChild, status
+ FROM sys_occupation_ref
+ ORDER BY parent_id, id
+ LIMIT #{limit} OFFSET #{offset}
+ """)
+ List 因为它们的选项**照抄官方表格、不许擅改**({@code BuiltinFields} 的类注释里
+ * 写了这条)。放进表里会带来一个具体风险:有人从界面上把"四级/中级工"改名成"中级",
+ * 导出的汇总表就与官方模板对不上了 —— 而那时**没有任何地方会报错**。
+ *
+ * 这和权限码是同一个思路:不能改的放代码,改它必须走一次发布;
+ * 能改的(客户自己加项)放 {@code sys_dict_item}。
+ *
+ * ⚠️ 一个必须说清的边界:{@code OCCUPATION} 在官方 Excel 模板里
+ * 是自由填写、没有下拉(PRD FR-312)。所以这个字典**只做拼写校验与联想建议
+ * (P2 级),不做强约束** —— 凡发现自由填写值不在字典中,记信息级提示,**不阻断**。
+ */
+public final class DictCatalog {
+
+ private DictCatalog() {
+ }
+
+ /** 一个内置字典的定义。 */
+ public record Def(String code, String name, List ⚠️ 与 {@code BuiltinFields.EDU_LEVEL} 必须完全一致 —— 表单下拉与字典
+ * 是两个入口看同一份口径,任何一处改了另一处不改,就会出现
+ * "字典里有的学历,表单里选不到"(用户会当成 bug 报,而排查时两边各自看都"没错")。
+ * 所以这里**只引用那一份**,不复制。
+ */
+ public static final Def EDU_LEVEL = new Def("EDU_LEVEL", "学历",
+ cn.tianbaotong.fill.module.form.service.BuiltinFields.eduLevels(), true);
+
+ public static final Def JOB_LEVEL = new Def("JOB_LEVEL", "职业技能等级",
+ cn.tianbaotong.fill.module.form.service.BuiltinFields.jobLevels(), true);
+
+ /**
+ * 认定类型(3 项)。
+ *
+ * 一期只做自主认定({@code certType = 2, 3}),但字典要列全 ——
+ * 它是"取值域"的定义,不是"本期可选项"的定义。用别的值提交会被 40044 拦下,
+ * 那才是范围控制该在的地方。
+ */
+ public static final Def CERT_TYPE = new Def("CERT_TYPE", "认定类型",
+ List.of("职业技能等级认定", "专项职业能力考核", "其他"), true);
+
+ public static final Def RETARGET_SUBJECT = new Def("RETARGET_SUBJECT", "补考科目",
+ List.of("理论", "实操", "综合评审"), true);
+
+ /**
+ * 驳回原因(10 项)。
+ *
+ * ⚠️ 这里的 code(R001…R010)才是关键,文案在
+ * {@code RejectReasonCatalog} 里 —— 两者是同一份数据的两个视角:
+ * 字典管理页要能列出"有哪些原因可停用",而审核页要按 code 校验。
+ * 所以这里只引用 code 列表,不重复文案。
+ */
+ public static final Def REJECT_REASON = new Def("REJECT_REASON", "驳回原因",
+ cn.tianbaotong.fill.module.audit.service.RejectReasonCatalog.codes(), true);
+
+ /**
+ * 职业(国标)。
+ *
+ * ⚠️ {@code items} 是**空列表**:它的项来自 {@code sys_occupation_ref}
+ * (可能几千条、有层级),塞进这个 record 没有意义。
+ * {@code DictService} 会为它单独查表。
+ */
+ public static final Def OCCUPATION = new Def("OCCUPATION", "职业(国标)", List.of(), true);
+
+ /** 全部内置字典,顺序即界面顺序。 */
+ public static final List ⚠️ 第 1 条里的"停用"为什么不能复用 {@code sys_dict_item.status}:
+ * 那张表是"客户自己加的项",而停用是"官方项被客户关掉了"。混在一起之后
+ * **"恢复默认"就没法做了** —— 分不清哪些是官方项、哪些是客户加的。
+ * 这个区分在界面上的表现是:内置项那行的"删除"按钮文案是**"停用"**,
+ * 而且停用后可以一键恢复。
+ *
+ * ⚠️ 内置项要分两种改法:
+ * 停用父项而子项还启用着,界面上会出现"选了父项却选不到子项",
+ * 而数据层面看不出任何矛盾 —— 这类问题排查起来最费时间。
+ */
+ private void requireNoChildren(Long tenantId, String dictCode, String itemCode) {
+ if (mapper.countChildren(tenantId, dictCode, itemCode) > 0) {
+ throw new BizException(ErrorCode.DICT_HAS_CHILDREN);
+ }
+ }
+
+ // ── 内部 ──────────────────────────────────────
+
+ private DictDtos.DictItemVO toVo(DictItemMapper.ItemRow r) {
+ DictDtos.DictItemVO vo = new DictDtos.DictItemVO();
+ vo.setId(r.getId());
+ vo.setItemCode(r.getItemCode());
+ vo.setItemName(r.getItemName());
+ vo.setParentItemCode(r.getParentItemCode());
+ vo.setSortNo(r.getSortNo());
+ vo.setStatus(r.getStatus());
+ vo.setBuiltin(false);
+ vo.setCreateTime(r.getCreateTime());
+ return vo;
+ }
+
+ private boolean isBlank(String s) {
+ return s == null || s.isBlank();
+ }
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/service/BuiltinFields.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/service/BuiltinFields.java
index 43333d7..e51e6e0 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/service/BuiltinFields.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/service/BuiltinFields.java
@@ -207,6 +207,22 @@ public final class BuiltinFields {
// ── 对外 ──────────────────────────────────────
+ /**
+ * 学历选项(字典管理页要用)。
+ *
+ * ⚠️ 暴露这两个访问器而不是让字典模块自己复制一份:表单下拉与字典管理
+ * 是**两个入口看同一份口径**,任何一处改了另一处不改,就会出现
+ * "字典里有的学历、表单里选不到" —— 而排查时两边各自看都"没错"。
+ */
+ public static List 权限用 {@code log:viewTenant}(本租户操作日志)。
+ * 平台运维要看全量日志时用 {@code log:viewAll} —— 那是另一个接口(P2)。
+ * 日志里含**别人的操作轨迹与 IP**,不是所有管理员都该看到。
+ *
+ * ⚠️ 一期只做**查询**,不做导出:把操作日志导出成一个 Excel
+ * 就等于把"谁在什么时候看了谁的身份证"这份名单落到本地磁盘上。
+ * 真要做导出,得先有"导出日志本身也要留痕"的机制(否则循环)。
+ */
+@Tag(name = "日志")
+@RestController
+@RequestMapping("/api/v1/logs")
+@RequiredArgsConstructor
+public class LogController {
+
+ private final OperationLogMapper operationLogMapper;
+ private final LoginLogMapper loginLogMapper;
+
+ @GetMapping("/operations")
+ @RequirePerm(PermissionDef.LOG_VIEW_TENANT)
+ @Operation(summary = "操作日志",
+ description = "Query:`operatorId` `module` `action` `targetType` `targetId` `from` `to` + 分页。"
+ + "**按 targetType+targetId 查**是这个接口最重要的用法 —— "
+ + "「这张名册项的证件号被谁从什么改成了什么」要靠它回答,"
+ + "而不是拿 request_param 全文搜(那会把「改了它」和「顺带提到它」混在一起)")
+ public ApiResult ⚠️ {@code to} 给的是**日期**时必须补到当天 23:59:59 ——
+ * 直接当 00:00:00 用会让"查 9 月 30 日"查不到 9 月 30 日的任何数据,
+ * 而用户看到空列表只会以为"那天没人操作过"。
+ */
+ private LocalDateTime parseFrom(String v) {
+ if (v == null || v.isBlank()) {
+ return null;
+ }
+ return parse(v, LocalTime.MIN);
+ }
+
+ private LocalDateTime parseTo(String v) {
+ if (v == null || v.isBlank()) {
+ return null;
+ }
+ return parse(v, LocalTime.of(23, 59, 59));
+ }
+
+ private LocalDateTime parse(String v, LocalTime fallbackTime) {
+ String s = v.trim();
+ try {
+ if (s.length() == 10) {
+ return LocalDateTime.of(LocalDate.parse(s), fallbackTime);
+ }
+ return LocalDateTime.parse(s.replace(' ', 'T'));
+ } catch (RuntimeException e) {
+ // 传了看不懂的时间就当没传(不报错):查询条件写错不该让人看不到任何日志
+ return null;
+ }
+ }
+
+ @GetMapping("/logins")
+ @RequirePerm(PermissionDef.LOG_VIEW_TENANT)
+ @Operation(summary = "登录日志",
+ description = "Query:`userId` `userType` `result` `from` `to` + 分页。"
+ + "⚠️ 一期的登录日志**只有 PC 账号密码登录**(`loginType=PASSWORD`)—— "
+ + "学生从小程序/H5 进来走的是微信授权,目前不写这张表(见 F-31)。"
+ + "所以「学生登录记录为空」是已知情况,不是查询条件写错了")
+ public ApiResult PRD §10.3 要求记录:登录、查看明文、下载、导出、代传、删除。
- * 「查看明文」单独记一条是刻意的——身份证号明文是被内部人泄露的首要风险点,
- * 有这个日志才能回答"谁在什么时候看了谁的材料"。
+ * 两者回答的是**不同的问题**:
+ * ⚠️ {@code beforeVal}/{@code afterVal} 只许放脱敏值或摘要
+ * (契约 §13.4 明确要求)。写入口 {@code OperationLogService} 统一兜底 ——
+ * 日志泄露不该比业务库泄露更严重。
+ *
+ * 只增不改(同 {@link SysLoginLog}):没有任何地方应该 UPDATE / DELETE 这张表。
*/
@Getter
@Setter
@@ -29,10 +43,16 @@ public class SysOperationLog {
private String username;
- /** 业务模块,如 {@code tenant} / {@code org} / {@code user} / {@code task}。 */
+ /** 业务模块,如 {@code ROSTER} / {@code TASK} / {@code AUDIT} / {@code EXPORT}。 */
private String module;
- /** 动作,如 {@code create} / {@code update} / {@code delete} / {@code export} / {@code approve}。 */
+ /**
+ * 动作。敏感动作见契约 §13.4 的表:
+ * {@code UPDATE_IDCARD} / {@code UPDATE_ROSTER_NAME} / {@code DELETE_ROSTER} /
+ * {@code PUBLISH_TASK} / {@code PAUSE_TASK} / {@code ARCHIVE_TASK} /
+ * {@code REVOKE_AUDIT} / {@code EXPORT_CREATE} / {@code PROXY_UPLOAD} /
+ * {@code VIEW_IDCARD_FULL} / {@code CONFIG_COMPRESS}。
+ */
private String action;
private String requestMethod;
@@ -40,10 +60,10 @@ public class SysOperationLog {
private String requestUri;
/**
- * 请求参数。
+ * 请求参数(原始上下文)。
*
* ⚠️ 入库前必须脱敏:去掉 {@code password}、证件号明文等。
- * 操作日志的目的是追责,不是复制一份业务数据——把敏感信息抄进来
+ * 操作日志的目的是追责,不是复制一份业务数据 —— 把敏感信息抄进来
* 等于给系统加了一个无鉴权的高危数据副本。
*/
private String requestParam;
@@ -60,5 +80,21 @@ public class SysOperationLog {
private String traceId;
+ // ── V9 补的结构化留痕字段 ──────────────────────
+
+ /** {@code ROSTER} / {@code TASK} / {@code SUBMISSION} / {@code FILE} / {@code EXPORT} / … */
+ private String targetType;
+
+ private Long targetId;
+
+ /** 变更前值(脱敏)。 */
+ private String beforeVal;
+
+ /** 变更后值(脱敏)。 */
+ private String afterVal;
+
+ /** 补充上下文(JSON)。 */
+ private String extraJson;
+
private LocalDateTime createTime;
}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/LoginLogMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/LoginLogMapper.java
index a9c2989..5c10f51 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/LoginLogMapper.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/LoginLogMapper.java
@@ -1,10 +1,16 @@
package cn.tianbaotong.fill.module.log.mapper;
+import cn.tianbaotong.fill.module.log.dto.LogDtos;
import cn.tianbaotong.fill.module.log.entity.SysLoginLog;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Options;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.time.LocalDateTime;
+import java.util.List;
public interface LoginLogMapper extends BaseMapper ⚠️ 登录日志表里 {@code success} 是 {@code smallint}(0/1),
+ * 而接口对外是布尔 {@code result} —— 转换在 SQL 里做,
+ * 让"哪个值算成功"只定义一次。
+ */
+ @InterceptorIgnore(tenantLine = "true")
+ @Select("""
+
+ """)
+ List 查询走手写 SQL 并 {@code @InterceptorIgnore} + 显式 {@code tenant_id}:
+ * 靠拦截器隐式补条件会让"查不到数据"和"确实没有数据"分不清,
+ * 而日志查询恰恰是要能对客户说清"这段时间有没有人动过这张名册"的场景。
+ */
public interface OperationLogMapper extends BaseMapper 理由同 {@code LoginLogMapper.insertIgnoreTenant}:敏感动作有时发生在
+ * 上下文不完整的时候(异步导出线程、定时任务),靠拦截器补 {@code tenant_id}
+ * 会直接抛 40101 —— 于是"越需要留痕的场景越写不进日志"。
+ *
+ * 风险可控:{@code tenantId} 由 {@code OperationLogService} 显式传入,
+ * 不依赖隐式上下文。
+ */
+ @InterceptorIgnore(tenantLine = "true")
+ @Insert("""
+ INSERT INTO sys_operation_log
+ (tenant_id, user_id, username, module, action, request_method, request_uri,
+ request_param, biz_code, duration_ms, ip, user_agent, trace_id,
+ target_type, target_id, before_val, after_val, extra_json, create_time)
+ VALUES
+ (#{tenantId}, #{userId}, #{username}, #{module}, #{action}, #{requestMethod}, #{requestUri},
+ #{requestParam}, #{bizCode}, #{durationMs}, #{ip}, #{userAgent}, #{traceId},
+ #{targetType}, #{targetId}, #{beforeVal}, #{afterVal}, #{extraJson}, CURRENT_TIMESTAMP)
+ """)
+ @Options(useGeneratedKeys = true, keyProperty = "id")
+ int insertIgnoreTenant(SysOperationLog log);
+
+ /** 操作日志分页查询。 */
+ @InterceptorIgnore(tenantLine = "true")
+ @Select("""
+
+ """)
+ List 注解式({@code @OperationLog})看起来优雅,但对**合规留痕**这个用途有两个硬伤:
+ * 契约 §13.4 要求 {@code beforeVal}/{@code afterVal} 里的证件号**一律脱敏**。
+ * 把这件事放在这里做(而不是要求每个调用方自觉),是因为"忘记脱敏"的后果
+ * 与"多脱敏一次"完全不对称:前者往库里塞了一份明文身份证号,后者只是少看到几位。
+ * 所以这里**主动扫一遍**:任何看起来像 18 位身份证号的串都会被替换成脱敏形式。
+ *
+ * {@code record()} **不抛异常**。理由:留痕失败是运维问题,
+ * 而业务失败是用户问题 —— 让用户为运维问题买单(点保存报错)是不可接受的。
+ * 但**日志级别是 WARN 而不是 DEBUG**:留痕失败必须能在生产日志里看见。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class OperationLogService {
+
+ /** 18 位身份证号(末位可能是 X)—— 用于兜底脱敏。 */
+ private static final Pattern ID_CARD = Pattern.compile("\\b(\\d{6})(\\d{8})(\\d{3})([\\dXx])\\b");
+
+ /** 手机号。 */
+ private static final Pattern PHONE = Pattern.compile("\\b(1[3-9]\\d)(\\d{4})(\\d{4})\\b");
+
+ private final OperationLogMapper operationLogMapper;
+ private final SensitiveCodec codec;
+
+ // ── 敏感动作的常量(契约 §13.4 的表,别自造名字)──
+
+ public static final String MODULE_ROSTER = "ROSTER";
+ public static final String MODULE_TASK = "TASK";
+ public static final String MODULE_AUDIT = "AUDIT";
+ public static final String MODULE_EXPORT = "EXPORT";
+ public static final String MODULE_JOIN = "JOIN";
+ public static final String MODULE_USER = "USER";
+ public static final String MODULE_FILE = "FILE";
+
+ public static final String ACTION_UPDATE_IDCARD = "UPDATE_IDCARD";
+ public static final String ACTION_UPDATE_ROSTER_NAME = "UPDATE_ROSTER_NAME";
+ public static final String ACTION_DELETE_ROSTER = "DELETE_ROSTER";
+ public static final String ACTION_PUBLISH_TASK = "PUBLISH_TASK";
+ public static final String ACTION_PAUSE_TASK = "PAUSE_TASK";
+ /** 恢复任务。⚠️ 契约 §13.4 的敏感动作表里没有它,但它必须与"发布"分开记 ——
+ * "恢复"意味着这个任务曾经被暂停过,而"发布"是第一次开始收集。 */
+ public static final String ACTION_RESUME_TASK = "RESUME_TASK";
+ /** 截止任务。同理:契约表里没有,但它是不可逆的时点,必须留痕。 */
+ public static final String ACTION_CLOSE_TASK = "CLOSE_TASK";
+ public static final String ACTION_ARCHIVE_TASK = "ARCHIVE_TASK";
+ public static final String ACTION_REVOKE_AUDIT = "REVOKE_AUDIT";
+ public static final String ACTION_EXPORT_CREATE = "EXPORT_CREATE";
+ public static final String ACTION_EXPORT_DOWNLOAD = "EXPORT_DOWNLOAD";
+ public static final String ACTION_PROXY_UPLOAD = "PROXY_UPLOAD";
+ public static final String ACTION_VIEW_IDCARD_FULL = "VIEW_IDCARD_FULL";
+ public static final String ACTION_CONFIG_COMPRESS = "CONFIG_COMPRESS";
+ public static final String ACTION_JOIN_APPROVE = "JOIN_APPROVE";
+ public static final String ACTION_JOIN_REJECT = "JOIN_REJECT";
+
+ public static final String TARGET_ROSTER = "ROSTER";
+ public static final String TARGET_TASK = "TASK";
+ public static final String TARGET_SUBMISSION = "SUBMISSION";
+ public static final String TARGET_FILE = "FILE";
+ public static final String TARGET_EXPORT = "EXPORT";
+ public static final String TARGET_JOIN_REQUEST = "JOIN_REQUEST";
+ public static final String TARGET_USER = "USER";
+
+ /**
+ * 记一条操作日志(**不抛异常**)。
+ *
+ * @param module 业务模块,用本类的 {@code MODULE_*} 常量
+ * @param action 动作,用本类的 {@code ACTION_*} 常量
+ * @param targetType 对象类型
+ * @param targetId 对象主键(合规查询靠它)
+ * @param beforeVal 变更前值(**本方法会再脱敏一次**)
+ * @param afterVal 变更后值
+ */
+ public void record(String module, String action, String targetType, Long targetId,
+ String beforeVal, String afterVal) {
+ record(module, action, targetType, targetId, beforeVal, afterVal, null);
+ }
+
+ /**
+ * 记一条操作日志(带补充上下文)。
+ *
+ * @param extraJson 补充上下文(JSON 串),如批量操作命中的 id 列表
+ */
+ public void record(String module, String action, String targetType, Long targetId,
+ String beforeVal, String afterVal, String extraJson) {
+ try {
+ SysOperationLog logRow = new SysOperationLog();
+ // 上下文可能不完整(异步线程/匿名),所以不 require(),拿不到就记 0
+ Long tenantId = safeTenantId();
+ Long uid = safeUid();
+ logRow.setTenantId(tenantId == null ? 0L : tenantId);
+ logRow.setUserId(uid);
+ logRow.setUsername(safeUsername());
+ logRow.setModule(module);
+ logRow.setAction(action);
+ logRow.setTargetType(targetType);
+ logRow.setTargetId(targetId);
+ logRow.setBeforeVal(mask(beforeVal));
+ logRow.setAfterVal(mask(afterVal));
+ logRow.setExtraJson(extraJson);
+ logRow.setBizCode(0);
+ fillRequestContext(logRow);
+ operationLogMapper.insertIgnoreTenant(logRow);
+ log.debug("操作日志已记:{} {} target={}#{}", module, action, targetType, targetId);
+ } catch (RuntimeException e) {
+ // ⚠️ WARN 而不是 DEBUG:留痕失败必须能在生产日志里看见。
+ // 但也绝不抛出 —— 用户不该为运维问题买单(点保存报错)。
+ log.warn("写操作日志失败(业务不受影响):{} {} target={}#{} 原因={}",
+ module, action, targetType, targetId, e.toString());
+ }
+ }
+
+ /**
+ * 脱敏:把任何看起来像身份证号/手机号的串替换掉,并截断到列宽。
+ *
+ * ⚠️ 这是**兜底**,不是唯一防线:调用方本该传脱敏值。
+ * 两层都做,是因为"忘记脱敏"的后果(库里多一份明文身份证号)不可逆。
+ */
+ String mask(String raw) {
+ if (raw == null || raw.isBlank()) {
+ return raw;
+ }
+ String s = ID_CARD.matcher(raw).replaceAll(m -> m.group(1) + "********" + m.group(3) + m.group(4));
+ s = PHONE.matcher(s).replaceAll(m -> m.group(1) + "****" + m.group(3));
+ return s.length() <= 512 ? s : s.substring(0, 512);
+ }
+
+ /** 证件号 → 脱敏值(给调用方用的显式入口,比让人自己拼更不容易错)。 */
+ public String maskIdCard(String plainOrMasked) {
+ return mask(plainOrMasked);
+ }
+
+ /** 算一下证件号的 hash(要显示"改成了哪个号"但不想落明文时,用 hash 前 8 位做指纹)。 */
+ public String fingerprint(String plainIdCard) {
+ if (plainIdCard == null || plainIdCard.isBlank()) {
+ return null;
+ }
+ String h = codec.hash(plainIdCard);
+ return h == null ? null : "…" + h.substring(Math.max(0, h.length() - 8));
+ }
+
+ // ── 上下文 ────────────────────────────────────
+
+ private Long safeTenantId() {
+ try {
+ return UserContext.tenantId();
+ } catch (RuntimeException e) {
+ return null;
+ }
+ }
+
+ private Long safeUid() {
+ try {
+ return UserContext.uid();
+ } catch (RuntimeException e) {
+ return null;
+ }
+ }
+
+ private String safeUsername() {
+ try {
+ // LoginUser 里**没有** username(只有 uid/tenantId/orgIds/roles/userType),
+ // 所以这里不强求写名字:留空即可,查询时会 JOIN sys_user 取 real_name。
+ // 早期登录日志里写的 username 是登录名,两者口径不同,不要混。
+ return null;
+ } catch (RuntimeException e) {
+ return null;
+ }
+ }
+
+ /** 从当前请求里取 IP / UA / 方法 / URI(不在请求线程里时全部留空)。 */
+ private void fillRequestContext(SysOperationLog row) {
+ try {
+ // traceId 由 TraceIdFilter 放进 MDC(**不是** request attribute),
+ // 所以这里从 MDC 取 —— 写错取法会得到一个永远为空的 traceId,
+ // 而"日志里没有 traceId"会让排查时无法把操作与请求对上。
+ String traceId = org.slf4j.MDC.get("traceId");
+ if (traceId != null && !traceId.isBlank()) {
+ row.setTraceId(traceId);
+ }
+ ServletRequestAttributes attrs =
+ (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+ if (attrs == null) {
+ return;
+ }
+ HttpServletRequest req = attrs.getRequest();
+ row.setRequestMethod(req.getMethod());
+ row.setRequestUri(clip(req.getRequestURI(), 512));
+ // 复用 WebUtil(登录日志也用它):两处各写一套 IP 解析必然分叉,
+ // 而"同一个请求在两份日志里 IP 不一样"会让排查直接卡住
+ row.setIp(clip(WebUtil.clientIp(req), 64));
+ row.setUserAgent(clip(WebUtil.userAgent(req), 512));
+ } catch (RuntimeException e) {
+ // 拿不到请求上下文不影响留痕本身(module/action/target 才是合规要点)
+ log.debug("取请求上下文失败(不影响留痕):{}", e.toString());
+ }
+ }
+
+ private String clip(String s, int max) {
+ if (s == null) {
+ return null;
+ }
+ return s.length() <= max ? s : s.substring(0, max);
+ }
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/controller/RosterController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/controller/RosterController.java
index aa09c8e..68643b9 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/controller/RosterController.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/controller/RosterController.java
@@ -2,8 +2,10 @@ package cn.tianbaotong.fill.module.roster.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.common.api.PageResult;
+import cn.tianbaotong.fill.module.roster.dto.RosterEditDtos;
import cn.tianbaotong.fill.module.roster.dto.RosterImportReport;
import cn.tianbaotong.fill.module.roster.dto.RosterVO;
+import cn.tianbaotong.fill.module.roster.service.RosterEditService;
import cn.tianbaotong.fill.module.roster.service.RosterImportService;
import cn.tianbaotong.fill.module.roster.service.RosterService;
import cn.tianbaotong.fill.security.PermissionDef;
@@ -12,9 +14,12 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@@ -26,6 +31,9 @@ import org.springframework.web.multipart.MultipartFile;
* 权限口径与契约一致:导入是管理动作({@code roster:manage}),
* 查看是管理 + 教师({@code roster:view})—— 班主任要看得到自己班的名单,
* 但不该能把整份名册换掉。
+ *
+ * 修改与删除也是 {@code roster:manage}:它们能改的是**认定资格的基准数据**
+ * (姓名、证件号、报考三项),改错一个字段就会让一个学生的认定结论站不住。
*/
@Tag(name = "名册")
@RestController
@@ -33,8 +41,11 @@ import org.springframework.web.multipart.MultipartFile;
@RequiredArgsConstructor
public class RosterController {
+ private final cn.tianbaotong.fill.module.roster.service.RosterTemplateService templateService;
+
private final RosterImportService importService;
private final RosterService rosterService;
+ private final RosterEditService rosterEditService;
@PostMapping(value = "/tasks/{id}/roster/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RequirePerm(PermissionDef.ROSTER_MANAGE)
@@ -68,4 +79,64 @@ public class RosterController {
return ApiResult.ok(rosterService.list(id, keyword, orgId, submitStatus, bindStatus,
pageNum, pageSize));
}
+
+ @PutMapping("/roster/{rid}")
+ @RequirePerm(PermissionDef.ROSTER_MANAGE)
+ @Operation(summary = "修改名册项",
+ description = "**只改传了的字段**。⚠️ 不要把列表里那一行整行回传 —— "
+ + "列表的证件号是**脱敏值**,覆盖式更新会把真号写成星号,"
+ + "那个学生从此过不了门禁(而且谁也不会想到是「保存一次」导致的)。"
+ + "改证件号会强制留痕(`UPDATE_IDCARD`,PRD FR-506),"
+ + "日志里记的是脱敏值 + 指纹,不记明文")
+ public ApiResult ⚠️ 全部字段**选填**:只传要改的那些。用"整行覆盖"的写法(前端把整行传回来)
+ * 会把列表里显示不到的字段(例如证件号的密文列)一起冲掉 ——
+ * 而名册列表返回的证件号本来就是**脱敏值**,回传过来会把真号覆盖成星号。
+ * 这类事故一旦发生,那个学生就再也过不了门禁了。
+ */
+ @Getter
+ @Setter
+ @Schema(description = "名册修改(只传要改的字段)")
+ public static class RosterUpdateReq {
+
+ private String realName;
+
+ /** 身份证号(**改它必留痕**,PRD FR-506)。传明文,服务端转密文 + hash + 脱敏值 */
+ private String idCardNo;
+
+ private String phone;
+
+ private Long orgId;
+
+ private String occupation;
+
+ private String worktype;
+
+ private String jobLevel;
+
+ private String education;
+
+ private Integer isStudent;
+
+ private String school;
+
+ private String major;
+
+ private String enrollDate;
+
+ private String workUnit;
+
+ private Integer isRetake;
+
+ private String retakeTheory;
+
+ private String retakePractice;
+
+ private String retakeOverall;
+
+ private String certOccupation;
+
+ private String certLevel;
+
+ private String certNo;
+
+ /** 报考三项等扩展字段(整体替换) */
+ private Object extJson;
+ }
+
+ /** 删除名册项的结果。 */
+ @Getter
+ @Setter
+ public static class RosterDeleteVO {
+
+ private Long rosterId;
+
+ /** {@code true} = 没删,只标记"不参与本次认定" */
+ private Boolean deactivated;
+
+ private String message;
+ }
+
+ /** 批量删除(§8.4 的批量形态)。 */
+ @Getter
+ @Setter
+ public static class RosterBatchDeleteReq {
+
+ @NotBlank
+ private List 返回**列表**而不是单个 id:同名班级(不同年级/专业下都叫"1 班")是常见的,
+ * 这时不能猜 —— 调用方看到 >1 条就要求管理员手选。
+ * 匹配用"包含"而不是"相等":学生自述常写成"24中技数控2班",
+ * 而组织名可能是"24中技数控2班(2024级)"。
+ */
+ @InterceptorIgnore(tenantLine = "true")
+ @Select("""
+ SELECT id FROM sys_org
+ WHERE tenant_id = #{tenantId} AND deleted = 0 AND status <> 0
+ AND (org_name = #{name} OR org_name LIKE '%' || #{name} || '%'
+ OR #{name} LIKE '%' || org_name || '%')
+ ORDER BY id
+ LIMIT 5
+ """)
+ List ⚠️ 另一个容易出事的点:更新一律用"只改传了的字段"。
+ * 名册列表返回的证件号是**脱敏值**,如果前端把整行回传(很自然的做法),
+ * 覆盖式更新会把真号写成 {@code 361029********1234} —— 那个学生从此过不了门禁,
+ * 而且谁也不会想到是"保存一次"导致的。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class RosterEditService {
+
+ private final CollectRosterMapper rosterMapper;
+ private final SensitiveCodec codec;
+ private final OperationLogService operationLog;
+
+ /** 修改名册项(§8.3)。 */
+ @Transactional(rollbackFor = Exception.class)
+ public void update(Long rosterId, RosterEditDtos.RosterUpdateReq req) {
+ LoginUser me = UserContext.require();
+ CollectRoster r = rosterMapper.selectById(rosterId);
+ if (r == null) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED, "名册项不存在或已删除");
+ }
+ // 范围:只能改自己租户的(拦截器已保证),但"已提交"的要额外警惕 ——
+ // 改了已提交的人的报考三项,会和填报单里的内容对不上
+ boolean submitted = r.getSubmitStatus() != null && r.getSubmitStatus() >= 2;
+
+ var u = Wrappers. 已绑定或已提交的**不允许删除**({@code 40029})。
+ * 但那不是"什么都做不了":调用方可以改用
+ * {@link #deactivate(Long)} 标记"不参与本次认定"。
+ * 两个动作的差别是**可逆性** —— 删掉会让已填材料变孤儿,标记则可逆。
+ */
+ @Transactional(rollbackFor = Exception.class)
+ public void delete(Long rosterId) {
+ LoginUser me = UserContext.require();
+ CollectRoster r = rosterMapper.selectById(rosterId);
+ if (r == null) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED, "名册项不存在或已删除");
+ }
+ if (r.getBindUserId() != null) {
+ // ⚠️ 必须带上自定义 message:只抛错误码的话,前端只会显示
+ // 「名册项已绑定或已提交,不可删除」—— 用户看到"不行"却不知道**该做什么**。
+ // 真正可用的替代动作是"标记不参与本次认定",这句话得说出来。
+ throw new BizException(ErrorCode.ROSTER_ITEM_IN_USE,
+ "这个学生已经核对过身份(已绑定),不能删除;如确需移出请标记「不参与本次认定」");
+ }
+ if (r.getSubmitStatus() != null && r.getSubmitStatus() > 0) {
+ throw new BizException(ErrorCode.ROSTER_ITEM_IN_USE,
+ "这个学生已经开始填报(或已提交),不能删除;如确需移出请标记「不参与本次认定」");
+ }
+ // 软删除(deleted = 1)
+ rosterMapper.deleteById(rosterId);
+ operationLog.record(OperationLogService.MODULE_ROSTER,
+ OperationLogService.ACTION_DELETE_ROSTER,
+ OperationLogService.TARGET_ROSTER, rosterId,
+ r.getRealName(), null,
+ "{\"orgId\":" + r.getOrgId() + ",\"taskId\":" + r.getTaskId() + "}");
+ log.info("名册删除:roster={} 姓名={} 经办人={}", rosterId, r.getRealName(), me.uid());
+ }
+
+ /**
+ * 标记"不参与本次认定"(§8.4 的替代动作)。
+ *
+ * 实现方式是软删除 + 把扩展字段里打一个标记 —— 不做硬删,
+ * 因为"这个人一开始在名单里"本身就是需要保留的事实
+ * (否则导出时无法解释"为什么名单上少了一个人")。
+ */
+ @Transactional(rollbackFor = Exception.class)
+ public RosterEditDtos.RosterDeleteVO deactivate(Long rosterId) {
+ loginRequired();
+ CollectRoster r = rosterMapper.selectById(rosterId);
+ if (r == null) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED, "名册项不存在或已删除");
+ }
+ var u = Wrappers. 留痕时**不记号码本身**(哪怕脱敏的),只记它的 hash 末 8 位:
+ * 这样能回答"确实换成了另一个号"(两次指纹不同),
+ * 同时不给出任何可用于猜号的信息。
+ * 传入的可能是脱敏值({@code 361029********1234})—— 那也算一种"指纹",
+ * 但语义弱一些;调用方传明文时得到的指纹更强。
+ */
+ private String fingerprintOf(String maskOrPlain) {
+ if (maskOrPlain == null || maskOrPlain.isBlank()) {
+ return null;
+ }
+ return operationLog.fingerprint(maskOrPlain);
+ }
+}
diff --git a/backend/xt-server/src/main/resources/db/migration/V10__dict.sql b/backend/xt-server/src/main/resources/db/migration/V10__dict.sql
new file mode 100644
index 0000000..f6a16a1
--- /dev/null
+++ b/backend/xt-server/src/main/resources/db/migration/V10__dict.sql
@@ -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);
diff --git a/backend/xt-server/src/main/resources/db/migration/V9__operation_log_audit_columns.sql b/backend/xt-server/src/main/resources/db/migration/V9__operation_log_audit_columns.sql
new file mode 100644
index 0000000..064e853
--- /dev/null
+++ b/backend/xt-server/src/main/resources/db/migration/V9__operation_log_audit_columns.sql
@@ -0,0 +1,42 @@
+-- ============================================================================
+-- V9:操作日志补审计列(结构化留痕)
+--
+-- 为什么不能只用 request_param / biz_code 凑合
+-- ---------------------------------------------------------------------------
+-- 契约 §13.4 的响应体里,操作日志**有独立的业务字段**:
+-- { module, action, targetType, targetId, beforeVal, afterVal, ip, userAgent }
+-- 而 V1 建的 sys_operation_log 只有 request_uri / request_param / biz_code ——
+-- 它可以回答"谁在什么时候调了哪个接口",但**回答不了**这两个问题:
+--
+-- ① 「**这张名册项**的证件号被谁从什么改成了什么」——
+-- request_param 里是一整坨 JSON,按 target_id 查要全表 LIKE,
+-- 而「改名册证件号留痕」(PRD FR-506)是**必须能按对象查**的合规要求;
+-- ② 「谁看过**这个学生**的完整身份证号」——
+-- 同上,全文检索撑不住,而这恰恰是 PRD §10.3 点名的高危动作。
+--
+-- 所以补 4 列结构化字段,并把 request_param 保留为"原始上下文"
+-- (排查时看它,合规查询看新列)。两者分工不同,不是冗余。
+--
+-- before_val / after_val 里**只许放脱敏值或摘要**(契约 §13.4 明确要求):
+-- 日志泄露不该比业务库泄露更严重。写入侧在 OperationLogService 里统一兜底。
+-- ============================================================================
+
+ALTER TABLE sys_operation_log ADD COLUMN IF NOT EXISTS target_type VARCHAR(32);
+ALTER TABLE sys_operation_log ADD COLUMN IF NOT EXISTS target_id BIGINT;
+ALTER TABLE sys_operation_log ADD COLUMN IF NOT EXISTS before_val VARCHAR(512);
+ALTER TABLE sys_operation_log ADD COLUMN IF NOT EXISTS after_val VARCHAR(512);
+ALTER TABLE sys_operation_log ADD COLUMN IF NOT EXISTS extra_json TEXT;
+
+COMMENT ON COLUMN sys_operation_log.target_type IS '被操作对象的类型:ROSTER/TASK/SUBMISSION/FILE/EXPORT/JOIN_REQUEST/USER/ORG';
+COMMENT ON COLUMN sys_operation_log.target_id IS '被操作对象的主键 —— 「这个对象的变更历史」靠它查,不能靠 request_param 全文搜';
+COMMENT ON COLUMN sys_operation_log.before_val IS '变更前值(**只放脱敏值或摘要**:日志泄露不该比业务库更严重)';
+COMMENT ON COLUMN sys_operation_log.after_val IS '变更后值(同上,只放脱敏值)';
+COMMENT ON COLUMN sys_operation_log.extra_json IS '补充上下文(JSON),如批量操作命中的 id 列表';
+
+-- 「这个对象被谁改过」是最高频的合规查询
+CREATE INDEX IF NOT EXISTS idx_oplog_target
+ ON sys_operation_log (tenant_id, target_type, target_id, create_time DESC);
+
+-- 「谁看了明文证件号」按人+动作查
+CREATE INDEX IF NOT EXISTS idx_oplog_action
+ ON sys_operation_log (tenant_id, module, action, create_time DESC);
diff --git a/frontend-pc/src/apis/xt/dict.ts b/frontend-pc/src/apis/xt/dict.ts
new file mode 100644
index 0000000..218ccbc
--- /dev/null
+++ b/frontend-pc/src/apis/xt/dict.ts
@@ -0,0 +1,104 @@
+/**
+ * 字典管理(《03-接口设计》§13.3)。
+ *
+ * ## 三种来源,界面上必须让用户分得清
+ *
+ * | 来源 | `builtin` | 能做什么 |
+ * |---|---|---|
+ * | 内置项(代码里,照抄官方表格) | `true` 且**没有 id** | 只能**停用 / 恢复**,不能改名、不能删 |
+ * | 自定义项(客户自己加的) | `false` | 可改名、可停用、可删、可排序 |
+ * | 国标职业(`sys_occupation_ref`) | `true` 且**只读** | 什么都不能改(那是国标) |
+ *
+ * ⚠️ 内置项**没有 id** 这件事是界面上的一个具体分叉:对它的操作只能按
+ * `itemCode` 走 `toggle` 接口,不能走 `PUT /dicts/items/{id}`。
+ * 所以列表里"编辑/删除"按钮对内 builtin 行要换成"停用/恢复"。
+ *
+ * ## 为什么内置项不能改名(这条一定要在界面上说)
+ *
+ * 因为内置选项**照抄官方表格**。有人把"四级/中级工"改成"中级"之后,
+ * 导出的汇总表就与官方模板对不上了 —— 而那时**没有任何地方会报错**。
+ * 所以要改内置项名只能改代码发布;界面上的正确做法是**追加一个自定义项**。
+ *
+ * ## 项码建后不可改
+ *
+ * 历史数据里存的是项码。改了它,过去填过的值会指向一个不存在的项,
+ * 而查询**不报错、只显示空白** —— 这种问题只能靠"一开始就不允许改"来避免。
+ * 所以修改接口的请求体里根本没有 `itemCode` 字段(不是靠界面藏起来)。
+ */
+import http from '@/utils/http'
+
+export interface DictVO {
+ dictCode: string
+ dictName: string
+ /** ⚠️ 只算**启用中**的项 */
+ itemCount: number
+ builtin: boolean
+}
+
+export interface DictItemVO {
+ /** 内置项为 null(它在代码里,没有数据库行) */
+ id: number | null
+ itemCode: string
+ itemName: string
+ parentItemCode?: string | null
+ sortNo?: number
+ /** 0 停用 / 1 启用 */
+ status?: number
+ /** true = 内置项:不可改名、不可删,只能停用/恢复 */
+ builtin?: boolean
+ createTime?: string
+}
+
+export interface DictItemListVO {
+ dictCode: string
+ dictName: string
+ builtin: boolean
+ /** 这个字典是否支持层级(如职业-工种) */
+ hierarchical: boolean
+ list: DictItemVO[]
+}
+
+export function getDicts() {
+ return http.get权限用 {@code task:manage},不是新造一个"字典管理"码
+ *
+ * > list() {
+ return ApiResult.ok(dictService.listDicts());
+ }
+
+ @GetMapping("/{code}/items")
+ @RequirePerm(PermissionDef.TASK_VIEW)
+ @Operation(summary = "字典项",
+ description = "内置项**没有 id**(它们在代码里,没有数据库行),所以对内置项的"
+ + "停用/恢复请走 `POST /dicts/{code}/items/{itemCode}/toggle`。"
+ + "`OCCUPATION` 来自国标库 `sys_occupation_ref`,一期该表为空时返回 0 项")
+ public ApiResult
为什么内置字典写在代码里,而不是插进 `sys_dict_item`
+ *
+ * 三种来源,取舍不同
+ *
+ *
+ *
+ *
+ *
+ * 字典 来源 能不能改
+ * EDU_LEVEL / JOB_LEVEL / CERT_TYPE / RETARGET_SUBJECT / REJECT_REASON
+ * 本类(照抄官方) 不可改项名;只可停用单项 + 追加自定义项
+ * OCCUPATION {@code sys_occupation_ref}(国标职业库,有层级与别名)
+ * 不可改(那是国标) 三种来源合成一份视图,是这一层的全部工作
+ *
+ *
+ *
+ *
+ * 两条契约明写的约束
+ *
+ *
+ *
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class DictService {
+
+ private final DictItemMapper mapper;
+
+ // ── 字典列表 ──────────────────────────────────
+
+ /** 全部字典(内置 + 客户自定义出来的)。 */
+ public List
+ *
+ * 界面上的表现:内置项那行只有"停用/恢复",没有"编辑名称"。
+ * 但**直接调接口**的人不会知道这条,所以这里必须再拦一次。
+ */
+ @Transactional(rollbackFor = Exception.class)
+ public void update(Long id, DictDtos.DictItemUpdateReq req) {
+ Long tenantId = UserContext.tenantId();
+ Long uid = UserContext.uid();
+ DictItemMapper.ItemRow row = mapper.findById(tenantId, id);
+ if (row == null) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED, "字典项不存在或已删除");
+ }
+ Integer newStatus = req == null ? null : req.getStatus();
+ if (newStatus != null && newStatus == 0) {
+ requireNoChildren(tenantId, row.getDictCode(), row.getItemCode());
+ }
+ mapper.update(tenantId, id, req == null ? null : req.getItemName(),
+ req == null ? null : req.getSortNo(), newStatus, uid);
+ log.info("修改字典项:id={} 状态={} 经办人={}", id, newStatus, uid);
+ }
+
+ /**
+ * 停用 / 恢复一个**内置**项(契约 §13.3:"内置字典不可删,只可停用单项")。
+ *
+ * @param enabled {@code false} = 停用,{@code true} = 恢复默认
+ */
+ @Transactional(rollbackFor = Exception.class)
+ public void toggleBuiltin(String dictCode, String itemCode, boolean enabled) {
+ Long tenantId = UserContext.tenantId();
+ Long uid = UserContext.uid();
+ DictCatalog.Def def = DictCatalog.byCode(dictCode);
+ if (def == null || !def.items().contains(itemCode)) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED,
+ "「" + itemCode + "」不是内置项,不能按内置项停用");
+ }
+ if (!enabled) {
+ requireNoChildren(tenantId, dictCode, itemCode);
+ mapper.disableBuiltin(tenantId, dictCode, itemCode, uid);
+ } else {
+ mapper.enableBuiltin(tenantId, dictCode, itemCode);
+ }
+ log.info("{}内置字典项:{} / {} 经办人={}", enabled ? "恢复" : "停用", dictCode, itemCode, uid);
+ }
+
+ /** 删除一个**自定义**字典项(软删)。内置项走 {@link #toggleBuiltin}。 */
+ @Transactional(rollbackFor = Exception.class)
+ public void delete(Long id) {
+ Long tenantId = UserContext.tenantId();
+ DictItemMapper.ItemRow row = mapper.findById(tenantId, id);
+ if (row == null) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED, "字典项不存在或已删除");
+ }
+ requireNoChildren(tenantId, row.getDictCode(), row.getItemCode());
+ mapper.softDelete(tenantId, id, UserContext.uid());
+ log.info("删除字典项:id={} {} / {}", id, row.getDictCode(), row.getItemCode());
+ }
+
+ /**
+ * 契约 §13.3 的 {@code 40036}:有下级项的字典项不可停用/删除。
+ *
+ * 为什么有 request_param 还要 target_type / target_id / before_val / after_val
+ *
+ *
+ *
+ * 后者如果只能靠 {@code request_param LIKE '%9012%'} 去搜,
+ * 就会把"改了 9012"和"顺带提到了 9012"混在一起,而合规查询要的是前者。
+ * 这就是 V9 补这四列的原因。
+ *
+ * 为什么写日志要单独一个 Service,而不是注解 + AOP
+ *
+ *
+ *
+ * 所以这里用**显式调用**:业务方法自己说"我改了什么、从什么改成什么"。
+ * 代价是多一行代码,换来的是留痕内容准确、失败可见。
+ *
+ * 脱敏是这一层的责任,不是调用方的
+ *
+ * 写日志绝不能让业务失败
+ *
+ * 这个类里最重要的两件事,都不是"怎么改"
+ *
+ *
+ *
+ *
+ *