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
@@ -54,6 +54,17 @@ public final class RejectReasonCatalog {
return out; return out;
} }
/**
* 全部原因码(字典管理页要用)。
*
* <p>⚠️ 返回 code 而不是文案:字典页面要列"有哪些原因可停用/追加",
* 而审核页要按 code **校验** —— code 是这两处的公共键。
* 文案在 {@link #list()} 里,两处引用同一份 {@code TEXTS},不复制。
*/
public static List<String> codes() {
return ORDER;
}
public static String textOf(String code) { public static String textOf(String code) {
return code == null ? null : TEXTS.get(code); return code == null ? null : TEXTS.get(code);
} }
@@ -0,0 +1,111 @@
package cn.tianbaotong.fill.module.dict.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.module.dict.dto.DictDtos;
import cn.tianbaotong.fill.module.dict.service.DictService;
import cn.tianbaotong.fill.security.PermissionDef;
import cn.tianbaotong.fill.security.RequirePerm;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
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;
import java.util.List;
/**
* 字典管理(《03-接口设计》§13.3)。
*
* <h3>权限用 {@code task:manage},不是新造一个"字典管理"码</h3>
*
* <p>理由:字典影响的是**所有任务的下拉选项与导出内容**,改错一个会让
* 已提交的数据指向不存在的项。所以它应当只有"能建与配置收集任务"的那批人能动 ——
* 这与 {@code task:manage} 恰好是同一批人。新造权限码会导致
* 某个角色"能改任务配置但改不了字典",而那两件事的风险是同一级别。
*
* <p>⚠️ 内置字典的项名**改不了**(契约 §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<List<DictDtos.DictVO>> 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<DictDtos.DictItemListVO> items(@PathVariable String code) {
return ApiResult.ok(dictService.items(code));
}
@PostMapping("/{code}/items")
@RequirePerm(PermissionDef.TASK_MANAGE)
@Operation(summary = "新增字典项",
description = "对内置字典是**追加**(内置项本身不动)。"
+ "项码在同字典内唯一;项码**建后不可改** —— "
+ "历史数据里存的是项码,改了它过去填过的值会指向不存在的项,"
+ "而查询不报错、只显示空白")
public ApiResult<DictDtos.DictItemVO> create(@PathVariable String code,
@RequestBody DictDtos.DictItemCreateReq req) {
return ApiResult.ok(dictService.create(code, req));
}
@PutMapping("/items/{id}")
@RequirePerm(PermissionDef.TASK_MANAGE)
@Operation(summary = "修改字典项(项码不可改)",
description = "**只作用于自定义项**。内置项没有 id,改不了名 —— "
+ "想停用内置项请走 `toggle` 接口。"
+ "有下级项的项不可停用(40036):否则会出现「选了父项却选不到子项」,"
+ "而数据层面看不出矛盾")
public ApiResult<Void> update(@PathVariable Long id,
@RequestBody DictDtos.DictItemUpdateReq req) {
dictService.update(id, req);
return ApiResult.ok();
}
@DeleteMapping("/items/{id}")
@RequirePerm(PermissionDef.TASK_MANAGE)
@Operation(summary = "删除自定义字典项(软删)", description = "内置项删不了,只能停用")
public ApiResult<Void> delete(@PathVariable Long id) {
dictService.delete(id);
return ApiResult.ok();
}
@PostMapping("/{code}/items/{itemCode}/toggle")
@RequirePerm(PermissionDef.TASK_MANAGE)
@Operation(summary = "停用 / 恢复一个**内置**字典项",
description = "契约 §13.3:内置字典不可删,**只可停用单项**。"
+ "停用记在 `sys_dict_disabled`(不在 `sys_dict_item`),"
+ "所以可以**一键恢复默认** —— 这正是把两者分开的原因:"
+ "混在一张表里就分不清「哪些是官方项被关了」和「哪些是客户自己加的」")
public ApiResult<Void> toggleBuiltin(@PathVariable String code,
@PathVariable String itemCode,
@RequestParam(defaultValue = "false") boolean enabled) {
dictService.toggleBuiltin(code, itemCode, enabled);
return ApiResult.ok();
}
}
@@ -0,0 +1,117 @@
package cn.tianbaotong.fill.module.dict.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 字典管理(《03-接口设计》§13.3)。
*/
public final class DictDtos {
private DictDtos() {
}
/** 一个字典。 */
@Getter
@Setter
@Schema(description = "字典")
public static class DictVO {
private String dictCode;
private String dictName;
/**
* 项数。
*
* <p>⚠️ 只算**启用中**的项:停用的项在界面上不该被计入"这个字典有多少项",
* 否则管理员看到"学历 11 项"却只数出 10 个,会以为界面漏了。
*/
private int itemCount;
/**
* {@code true} = 内置字典:**项名不可改、不可删**,只可停用单项 + 追加自定义项。
*
* <p>置 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<DictItemVO> list = new ArrayList<>();
}
/** 新增字典项。 */
@Getter
@Setter
@Schema(description = "新增字典项")
public static class DictItemCreateReq {
private String itemCode;
private String itemName;
private String parentItemCode;
private Integer sortNo;
}
/** 修改字典项。**不含 itemCode** —— 项码不许改(会破坏历史数据)。 */
@Getter
@Setter
@Schema(description = "修改字典项(项码不可改)")
public static class DictItemUpdateReq {
private String itemName;
private Integer sortNo;
/** {@code 0} 停用 / {@code 1} 启用 */
private Integer status;
}
}
@@ -0,0 +1,246 @@
package cn.tianbaotong.fill.module.dict.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
/**
* 字典项数据访问。
*
* <p>手写 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<ItemRow> listItems(@Param("tenantId") Long tenantId,
@Param("dictCode") String dictCode);
/** 按 id 取一条(改之前要先确认它存不存在)。 */
@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 id = #{id} AND deleted = 0
""")
ItemRow findById(@Param("tenantId") Long tenantId, @Param("id") Long id);
/** 项码是否已存在(同字典内)。 */
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT COUNT(*) FROM sys_dict_item
WHERE tenant_id = #{tenantId} AND dict_code = #{dictCode}
AND item_code = #{itemCode} AND deleted = 0
""")
int countByCode(@Param("tenantId") Long tenantId,
@Param("dictCode") String dictCode,
@Param("itemCode") String itemCode);
/** 有没有下级项(契约 §13.3:有下级项的字典项不可停用,40036)。 */
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT COUNT(*) FROM sys_dict_item
WHERE tenant_id = #{tenantId} AND dict_code = #{dictCode}
AND parent_item_code = #{itemCode} AND deleted = 0 AND status = 1
""")
int countChildren(@Param("tenantId") Long tenantId,
@Param("dictCode") String dictCode,
@Param("itemCode") String itemCode);
/** 某个字典下自定义项的**启用**数量(算 itemCount 用)。 */
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT COUNT(*) FROM sys_dict_item
WHERE tenant_id = #{tenantId} AND dict_code = #{dictCode}
AND deleted = 0 AND status = 1
""")
int countEnabled(@Param("tenantId") Long tenantId, @Param("dictCode") String dictCode);
/**
* 这个租户自己造出来的字典码(内置目录里没有的那些)。
*
* <p>用途:字典列表要显示"客户自己加的字典"(例如"专业"),
* 而它们没有代码里的定义,只能从数据里发现。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT DISTINCT dict_code FROM sys_dict_item
WHERE tenant_id = #{tenantId} AND deleted = 0
ORDER BY dict_code
""")
List<String> listCustomDictCodes(@Param("tenantId") Long tenantId);
/** 软删除一条自定义字典项。 */
@InterceptorIgnore(tenantLine = "true")
@Update("""
UPDATE sys_dict_item
SET deleted = 1, update_time = NOW(), update_by = #{updateBy}
WHERE tenant_id = #{tenantId} AND id = #{id} AND deleted = 0
""")
int softDelete(@Param("tenantId") Long tenantId,
@Param("id") Long id,
@Param("updateBy") Long updateBy);
/** 新增。 */
@InterceptorIgnore(tenantLine = "true")
@Insert("""
INSERT INTO sys_dict_item
(tenant_id, dict_code, item_code, item_name, parent_item_code, sort_no, status,
builtin_extend, create_by, create_time, update_time, deleted)
VALUES
(#{tenantId}, #{dictCode}, #{itemCode}, #{itemName}, #{parentItemCode}, #{sortNo}, 1,
#{builtinExtend}, #{createBy}, NOW(), NOW(), 0)
""")
int insert(@Param("tenantId") Long tenantId,
@Param("dictCode") String dictCode,
@Param("itemCode") String itemCode,
@Param("itemName") String itemName,
@Param("parentItemCode") String parentItemCode,
@Param("sortNo") Integer sortNo,
@Param("builtinExtend") Integer builtinExtend,
@Param("createBy") Long createBy);
/** 改名 / 改排序 / 改状态。**项码不在可改范围**。 */
@InterceptorIgnore(tenantLine = "true")
@Update("""
<script>
UPDATE sys_dict_item
SET update_time = NOW(), update_by = #{updateBy}
<if test="itemName != null">, item_name = #{itemName}</if>
<if test="sortNo != null">, sort_no = #{sortNo}</if>
<if test="status != null">, status = #{status}</if>
WHERE tenant_id = #{tenantId} AND id = #{id} AND deleted = 0
</script>
""")
int update(@Param("tenantId") Long tenantId,
@Param("id") Long id,
@Param("itemName") String itemName,
@Param("sortNo") Integer sortNo,
@Param("status") Integer status,
@Param("updateBy") Long updateBy);
// ── 内置项的停用开关(另一个概念,另一张表)──
/** 停用一个内置项。 */
@InterceptorIgnore(tenantLine = "true")
@Insert("""
INSERT INTO sys_dict_disabled (tenant_id, dict_code, item_code, create_by, create_time)
VALUES (#{tenantId}, #{dictCode}, #{itemCode}, #{createBy}, NOW())
ON CONFLICT (tenant_id, dict_code, item_code) DO NOTHING
""")
int disableBuiltin(@Param("tenantId") Long tenantId,
@Param("dictCode") String dictCode,
@Param("itemCode") String itemCode,
@Param("createBy") Long createBy);
/** 恢复一个内置项(重新启用)。 */
@InterceptorIgnore(tenantLine = "true")
@Delete("""
DELETE FROM sys_dict_disabled
WHERE tenant_id = #{tenantId} AND dict_code = #{dictCode} AND item_code = #{itemCode}
""")
int enableBuiltin(@Param("tenantId") Long tenantId,
@Param("dictCode") String dictCode,
@Param("itemCode") String itemCode);
/** 这个租户停用了哪些内置项。 */
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT item_code FROM sys_dict_disabled
WHERE tenant_id = #{tenantId} AND dict_code = #{dictCode}
""")
List<String> listDisabledBuiltin(@Param("tenantId") Long tenantId,
@Param("dictCode") String dictCode);
// ── 国标职业库(OCCUPATION 的来源)──
/** 一行国标职业。 */
class OccupationRow {
private Long id;
private Long parentId;
private String name;
private String aliases;
private Integer hasChild;
private Integer status;
public Long getId() { return id; }
public void setId(Long v) { this.id = v; }
public Long getParentId() { return parentId; }
public void setParentId(Long v) { this.parentId = v; }
public String getName() { return name; }
public void setName(String v) { this.name = v; }
public String getAliases() { return aliases; }
public void setAliases(String v) { this.aliases = v; }
public Integer getHasChild() { return hasChild; }
public void setHasChild(Integer v) { this.hasChild = v; }
public Integer getStatus() { return status; }
public void setStatus(Integer v) { this.status = v; }
}
/**
* 国标职业/工种({@code OCCUPATION} 的项)。
*
* <p>⚠️ {@code sys_occupation_ref} **没有 tenant_id** —— 它是国标数据,
* 全平台共用。所以这里既不 {@code @InterceptorIgnore} 也不带租户条件,
* 但必须靠**表本身没有租户列**这一点来保证不会越权(而不是靠"我记得不加")。
*
* <p>一期这张表是空的(国标库要客户提供或去买),所以字典页会显示 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<OccupationRow> listOccupations(@Param("limit") int limit, @Param("offset") int offset);
/** 国标职业总数(含停用的,用于分页)。 */
@InterceptorIgnore(tenantLine = "true")
@Select("SELECT COUNT(*) FROM sys_occupation_ref")
long countOccupations();
}
@@ -0,0 +1,103 @@
package cn.tianbaotong.fill.module.dict.service;
import java.util.List;
/**
* 内置字典目录(《03-接口设计》§13.3)。
*
* <h3>为什么内置字典写在代码里,而不是插进 `sys_dict_item`</h3>
*
* <p>因为它们的选项**照抄官方表格、不许擅改**({@code BuiltinFields} 的类注释里
* 写了这条)。放进表里会带来一个具体风险:有人从界面上把"四级/中级工"改名成"中级",
* 导出的汇总表就与官方模板对不上了 —— 而那时**没有任何地方会报错**。
*
* <p>这和权限码是同一个思路:<b>不能改的放代码,改它必须走一次发布</b>;
* 能改的(客户自己加项)放 {@code sys_dict_item}。
*
* <h3>三种来源,取舍不同</h3>
*
* <table>
* <tr><th>字典</th><th>来源</th><th>能不能改</th></tr>
* <tr><td>EDU_LEVEL / JOB_LEVEL / CERT_TYPE / RETARGET_SUBJECT / REJECT_REASON</td>
* <td>本类(照抄官方)</td><td>不可改项名;只可停用单项 + 追加自定义项</td></tr>
* <tr><td>OCCUPATION</td><td>{@code sys_occupation_ref}(国标职业库,有层级与别名)</td>
* <td>不可改(那是国标)</td></tr>
* </table>
*
* <p>⚠️ 一个必须说清的边界:{@code OCCUPATION} 在官方 Excel 模板里
* <b>是自由填写、没有下拉</b>(PRD FR-312)。所以这个字典**只做拼写校验与联想建议
* (P2 级),不做强约束** —— 凡发现自由填写值不在字典中,记信息级提示,**不阻断**。
*/
public final class DictCatalog {
private DictCatalog() {
}
/** 一个内置字典的定义。 */
public record Def(String code, String name, List<String> items, boolean builtin) {
}
/**
* 学历(11 项)。
*
* <p>⚠️ 与 {@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 项)。
*
* <p>一期只做自主认定({@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 项)。
*
* <p>⚠️ 这里的 <b>code</b>(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);
/**
* 职业(国标)。
*
* <p>⚠️ {@code items} 是**空列表**:它的项来自 {@code sys_occupation_ref}
* (可能几千条、有层级),塞进这个 record 没有意义。
* {@code DictService} 会为它单独查表。
*/
public static final Def OCCUPATION = new Def("OCCUPATION", "职业(国标)", List.of(), true);
/** 全部内置字典,顺序即界面顺序。 */
public static final List<Def> ALL = List.of(
EDU_LEVEL, JOB_LEVEL, CERT_TYPE, RETARGET_SUBJECT, OCCUPATION, REJECT_REASON);
/** 按码查。找不到返回 null(调用方决定是报错还是当成自定义字典)。 */
public static Def byCode(String code) {
if (code == null) {
return null;
}
for (Def d : ALL) {
if (d.code().equals(code)) {
return d;
}
}
return null;
}
}
@@ -0,0 +1,308 @@
package cn.tianbaotong.fill.module.dict.service;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.module.dict.dto.DictDtos;
import cn.tianbaotong.fill.module.dict.mapper.DictItemMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 字典管理(《03-接口设计》§13.3)。
*
* <h3>三种来源合成一份视图,是这一层的全部工作</h3>
*
* <ol>
* <li><b>内置项</b>({@link DictCatalog},代码里)—— 不可改名、不可删,
* 只可**停用单项**(存在 {@code sys_dict_disabled},**不是**改内置项的状态);</li>
* <li><b>自定义项</b>({@code sys_dict_item})—— 可增、可改名、可停用、可排序;</li>
* <li><b>国标职业</b>({@code sys_occupation_ref})—— 只读,来自国标库。</li>
* </ol>
*
* <p>⚠️ 第 1 条里的"停用"为什么不能复用 {@code sys_dict_item.status}:
* 那张表是"客户自己加的项",而停用是"官方项被客户关掉了"。混在一起之后
* **"恢复默认"就没法做了** —— 分不清哪些是官方项、哪些是客户加的。
* 这个区分在界面上的表现是:内置项那行的"删除"按钮文案是**"停用"**,
* 而且停用后可以一键恢复。
*
* <h3>两条契约明写的约束</h3>
*
* <ul>
* <li><b>项码不许改</b>({@code itemCode}):历史数据里存的是项码,
* 改了它,过去填过的值就指向了一个不存在的项 —— 而查询不会报错,
* 只会显示空白。所以接口层面就不接受这个字段({@code DictItemUpdateReq} 里没有它)。</li>
* <li><b>有下级项的字典项不可停用</b>({@code 40036}):停用父项而子项还启用着,
* 会让"选了父项却选不到子项"的界面出现,而数据上看不出矛盾。</li>
* </ul>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DictService {
private final DictItemMapper mapper;
// ── 字典列表 ──────────────────────────────────
/** 全部字典(内置 + 客户自定义出来的)。 */
public List<DictDtos.DictVO> listDicts() {
Long tenantId = UserContext.tenantId();
Map<String, DictDtos.DictVO> out = new LinkedHashMap<>();
for (DictCatalog.Def def : DictCatalog.ALL) {
DictDtos.DictVO vo = new DictDtos.DictVO();
vo.setDictCode(def.code());
vo.setDictName(def.name());
vo.setBuiltin(true);
vo.setItemCount(countOf(def, tenantId));
out.put(def.code(), vo);
}
// 客户自己加过项的字典码里,可能有内置目录里没有的(例如"专业")
for (String code : mapper.listCustomDictCodes(tenantId)) {
if (out.containsKey(code)) {
continue;
}
DictDtos.DictVO vo = new DictDtos.DictVO();
vo.setDictCode(code);
vo.setDictName(code);
vo.setBuiltin(false);
vo.setItemCount(mapper.countEnabled(tenantId, code));
out.put(code, vo);
}
return new ArrayList<>(out.values());
}
/** 一个字典的启用项数(内置项排除被停用的 + 自定义启用项)。 */
private int countOf(DictCatalog.Def def, Long tenantId) {
if ("OCCUPATION".equals(def.code())) {
return (int) mapper.countOccupations();
}
List<String> disabled = mapper.listDisabledBuiltin(tenantId, def.code());
int builtin = (int) def.items().stream().filter((i) -> !disabled.contains(i)).count();
return builtin + mapper.countEnabled(tenantId, def.code());
}
// ── 字典项 ────────────────────────────────────
/** 一个字典的项(内置在前、自定义在后 —— 界面上"官方项"要聚在一起)。 */
public DictDtos.DictItemListVO items(String dictCode) {
Long tenantId = UserContext.tenantId();
DictCatalog.Def def = DictCatalog.byCode(dictCode);
DictDtos.DictItemListVO vo = new DictDtos.DictItemListVO();
vo.setDictCode(dictCode);
vo.setDictName(def == null ? dictCode : def.name());
vo.setBuiltin(def != null);
if ("OCCUPATION".equals(dictCode)) {
// 国标职业:只读、可能很多条,所以只给前 200 条(要全量得另做分页接口)
vo.setHierarchical(true);
List<DictDtos.DictItemVO> list = new ArrayList<>();
for (DictItemMapper.OccupationRow r : mapper.listOccupations(200, 0)) {
DictDtos.DictItemVO it = new DictDtos.DictItemVO();
it.setId(r.getId());
it.setItemCode(String.valueOf(r.getId()));
it.setItemName(r.getName());
it.setParentItemCode(r.getParentId() == null || r.getParentId() == 0
? null : String.valueOf(r.getParentId()));
it.setSortNo(0);
it.setStatus(r.getStatus());
it.setBuiltin(true);
list.add(it);
}
vo.setList(list);
return vo;
}
List<DictDtos.DictItemVO> list = new ArrayList<>();
if (def != null) {
List<String> disabled = mapper.listDisabledBuiltin(tenantId, dictCode);
int sort = 0;
for (String item : def.items()) {
DictDtos.DictItemVO it = new DictDtos.DictItemVO();
// 内置项没有数据库行,所以没有 id —— 界面上要按 itemCode 操作
it.setId(null);
it.setItemCode(item);
// REJECT_REASON 的 itemCode 是 R001,项名要给可读文案
it.setItemName("REJECT_REASON".equals(dictCode)
? cn.tianbaotong.fill.module.audit.service.RejectReasonCatalog.textOf(item)
: item);
it.setSortNo(sort += 10);
it.setStatus(disabled.contains(item) ? 0 : 1);
it.setBuiltin(true);
list.add(it);
}
}
for (DictItemMapper.ItemRow r : mapper.listItems(tenantId, dictCode)) {
list.add(toVo(r));
}
vo.setHierarchical(list.stream().anyMatch((i) -> i.getParentItemCode() != null));
vo.setList(list);
return vo;
}
// ── 新增 ──────────────────────────────────────
/** 新增字典项(§13.3)。自定义字典码也会被隐式创建。 */
@Transactional(rollbackFor = Exception.class)
public DictDtos.DictItemVO create(String dictCode, DictDtos.DictItemCreateReq req) {
Long tenantId = UserContext.tenantId();
Long uid = UserContext.uid();
if (req == null || isBlank(req.getItemCode()) || isBlank(req.getItemName())) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "项码与项名都必填");
}
String code = req.getItemCode().trim();
String name = req.getItemName().trim();
// 内置字典里已经有同名/同码的项 → 拒绝,并说清它是内置的
DictCatalog.Def def = DictCatalog.byCode(dictCode);
if (def != null && def.items().contains(code)) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"「" + code + "」是内置项,不能重复添加(内置项不可改名,只能停用)");
}
if (mapper.countByCode(tenantId, dictCode, code) > 0) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "项码「" + code + "」在这个字典里已存在");
}
if (req.getParentItemCode() != null && !req.getParentItemCode().isBlank()) {
// 父项必须存在:不然会造出一个永远选不到的孤儿项
boolean parentOk = (def != null && def.items().contains(req.getParentItemCode()))
|| mapper.countByCode(tenantId, dictCode, req.getParentItemCode()) > 0;
if (!parentOk) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"父项「" + req.getParentItemCode() + "」不存在");
}
}
int sortNo = req.getSortNo() == null ? nextSortNo(tenantId, dictCode) : req.getSortNo();
mapper.insert(tenantId, dictCode, code, name,
isBlank(req.getParentItemCode()) ? null : req.getParentItemCode().trim(),
sortNo, def == null ? 0 : 1, uid);
log.info("新增字典项:{} / {}({})经办人={}", dictCode, code, name, uid);
// 回读(拿 id 与 createTime),避免前端再查一次
for (DictItemMapper.ItemRow r : mapper.listItems(tenantId, dictCode)) {
if (code.equals(r.getItemCode())) {
return toVo(r);
}
}
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "新增失败,请重试");
}
/** 下一个排序号(放在末尾)。 */
private int nextSortNo(Long tenantId, String dictCode) {
int max = 0;
for (DictItemMapper.ItemRow r : mapper.listItems(tenantId, dictCode)) {
if (r.getSortNo() != null && r.getSortNo() > max) {
max = r.getSortNo();
}
}
return max + 10;
}
// ── 修改 / 停用 / 删除 ────────────────────────
/**
* 修改字典项(§13.3)。
*
* <p>⚠️ <b>内置项要分两种改法</b>:
* <ul>
* <li>改**项名** → 拒绝(内置选项照抄官方,改名会让导出表与官方模板对不上);</li>
* <li>改**状态** → 允许,但走的是"停用内置项"({@code sys_dict_disabled}),
* 这样以后可以一键恢复默认;</li>
* </ul>
* 界面上的表现:内置项那行只有"停用/恢复",没有"编辑名称"。
* 但**直接调接口**的人不会知道这条,所以这里必须再拦一次。
*/
@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}:有下级项的字典项不可停用/删除。
*
* <p>停用父项而子项还启用着,界面上会出现"选了父项却选不到子项",
* 而数据层面看不出任何矛盾 —— 这类问题排查起来最费时间。
*/
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();
}
}
@@ -207,6 +207,22 @@ public final class BuiltinFields {
// ── 对外 ────────────────────────────────────── // ── 对外 ──────────────────────────────────────
/**
* 学历选项(字典管理页要用)。
*
* <p>⚠️ 暴露这两个访问器而不是让字典模块自己复制一份:表单下拉与字典管理
* 是**两个入口看同一份口径**,任何一处改了另一处不改,就会出现
* "字典里有的学历、表单里选不到" —— 而排查时两边各自看都"没错"。
*/
public static List<String> eduLevels() {
return EDU_LEVEL;
}
/** 等级选项(字典管理页要用)。理由同 {@link #eduLevels()}。 */
public static List<String> jobLevels() {
return JOB_LEVEL;
}
/** 内置字段的默认定义(深拷贝:调用方可能要改,不能污染常量)。 */ /** 内置字段的默认定义(深拷贝:调用方可能要改,不能污染常量)。 */
public static List<FormFieldVO> defaults() { public static List<FormFieldVO> defaults() {
List<FormFieldVO> copy = new ArrayList<>(); List<FormFieldVO> copy = new ArrayList<>();
@@ -0,0 +1,138 @@
package cn.tianbaotong.fill.module.log.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.common.api.PageResult;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.module.log.dto.LogDtos;
import cn.tianbaotong.fill.module.log.mapper.LoginLogMapper;
import cn.tianbaotong.fill.module.log.mapper.OperationLogMapper;
import cn.tianbaotong.fill.security.PermissionDef;
import cn.tianbaotong.fill.security.RequirePerm;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.List;
/**
* 日志查询(《03-接口设计》§13.4)。
*
* <p>权限用 {@code log:viewTenant}(本租户操作日志)。
* 平台运维要看全量日志时用 {@code log:viewAll} —— 那是另一个接口(P2)。
* 日志里含**别人的操作轨迹与 IP**,不是所有管理员都该看到。
*
* <p>⚠️ 一期只做**查询**,不做导出:把操作日志导出成一个 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<PageResult<LogDtos.OperationLogVO>> operations(
@RequestParam(required = false) Long operatorId,
@RequestParam(required = false) String module,
@RequestParam(required = false) String action,
@RequestParam(required = false) String targetType,
@RequestParam(required = false) Long targetId,
@RequestParam(required = false) String from,
@RequestParam(required = false) String to,
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize) {
LoginUser me = UserContext.require();
int pn = Math.max(1, pageNum);
int ps = Math.min(Math.max(1, pageSize), 200);
LocalDateTime f = parseFrom(from);
LocalDateTime t = parseTo(to);
List<LogDtos.OperationLogVO> list = operationLogMapper.listOperations(
me.tenantId(), operatorId, module, action, targetType, targetId,
f, t, ps, (pn - 1) * ps);
long total = operationLogMapper.countOperations(
me.tenantId(), operatorId, module, action, targetType, targetId, f, t);
return ApiResult.ok(PageResult.of(list, total, pn, ps));
}
/**
* 日期入参解析。
*
* <p>⚠️ {@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<PageResult<LogDtos.LoginLogVO>> logins(
@RequestParam(required = false) Long userId,
@RequestParam(required = false) Integer userType,
@RequestParam(required = false) Boolean result,
@RequestParam(required = false) String from,
@RequestParam(required = false) String to,
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize) {
LoginUser me = UserContext.require();
int pn = Math.max(1, pageNum);
int ps = Math.min(Math.max(1, pageSize), 200);
LocalDateTime f = parseFrom(from);
LocalDateTime t = parseTo(to);
// Boolean → smallint:转换只在这里做一次,"哪个值算成功"只有一个定义
Integer ok = result == null ? null : (result ? 1 : 0);
List<LogDtos.LoginLogVO> list = loginLogMapper.listLogins(
me.tenantId(), userId, userType, ok, f, t, ps, (pn - 1) * ps);
long total = loginLogMapper.countLogins(me.tenantId(), userId, userType, ok, f, t);
return ApiResult.ok(PageResult.of(list, total, pn, ps));
}
}
@@ -0,0 +1,111 @@
package cn.tianbaotong.fill.module.log.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 日志查询(《03-接口设计》§13.4)。
*/
public final class LogDtos {
private LogDtos() {
}
/** 一条操作日志。 */
@Getter
@Setter
@Schema(description = "操作日志")
public static class OperationLogVO {
private Long id;
private Long tenantId;
private Long operatorId;
private String operatorName;
/** {@code ROSTER} / {@code TASK} / {@code AUDIT} / {@code EXPORT} / {@code USER} … */
private String module;
/** 见契约 §13.4 的敏感动作表:{@code UPDATE_IDCARD} / {@code REVOKE_AUDIT} … */
private String action;
private String targetType;
private Long targetId;
/** ⚠️ **脱敏值**。明文不进日志表(契约 §13.4 明确要求) */
private String beforeVal;
private String afterVal;
private String ip;
private String userAgent;
private Integer bizCode;
private String traceId;
private LocalDateTime createTime;
}
/** 操作日志分页。 */
@Getter
@Setter
public static class OperationLogPageVO {
private List<OperationLogVO> list = new ArrayList<>();
private long total;
}
/** 一条登录日志。 */
@Getter
@Setter
@Schema(description = "登录日志")
public static class LoginLogVO {
private Long id;
private Long userId;
private String userName;
/** 用户类型:1 超管 / 2 管理员 / 3 教师 / 4 学生 */
private Integer userType;
/** {@code PASSWORD} / {@code WECHAT} / {@code PHONE} */
private String loginType;
private String ip;
/** 归属地(一期没接 IP 库,留 null 而不是编一个) */
private String location;
private String device;
/** {@code true} 成功 / {@code false} 失败 */
private Boolean result;
private String failReason;
private LocalDateTime loginTime;
}
/** 登录日志分页。 */
@Getter
@Setter
public static class LoginLogPageVO {
private List<LoginLogVO> list = new ArrayList<>();
private long total;
}
}
@@ -9,11 +9,25 @@ import lombok.Setter;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** /**
* 操作日志(对应 {@code sys_operation_log})。只增不改,理由同 {@link SysLoginLog}。 * 操作日志({@code sys_operation_log})。
* *
* <p>PRD §10.3 要求记录:登录、<b>查看明文</b>、下载、导出、代传、删除。 * <h3>为什么有 request_param 还要 target_type / target_id / before_val / after_val</h3>
* 「查看明文」单独记一条是刻意的——身份证号明文是被内部人泄露的首要风险点, *
* 有这个日志才能回答"谁在什么时候看了谁的材料"。 * <p>两者回答的是**不同的问题**:
* <ul>
* <li>{@code request_param}:排查用 —— "那次请求到底传了什么";</li>
* <li>{@code target_type/target_id/before_val/after_val}:**合规用** ——
* "这张名册项的证件号被谁从什么改成了什么"、"谁看过这个学生的完整身份证号"。</li>
* </ul>
* 后者如果只能靠 {@code request_param LIKE '%9012%'} 去搜,
* 就会把"改了 9012"和"顺带提到了 9012"混在一起,而合规查询要的是前者。
* 这就是 V9 补这四列的原因。
*
* <p>⚠️ {@code beforeVal}/{@code afterVal} <b>只许放脱敏值或摘要</b>
* (契约 §13.4 明确要求)。写入口 {@code OperationLogService} 统一兜底 ——
* 日志泄露不该比业务库泄露更严重。
*
* <p>只增不改(同 {@link SysLoginLog}):没有任何地方应该 UPDATE / DELETE 这张表。
*/ */
@Getter @Getter
@Setter @Setter
@@ -29,10 +43,16 @@ public class SysOperationLog {
private String username; private String username;
/** 业务模块,如 {@code tenant} / {@code org} / {@code user} / {@code task}。 */ /** 业务模块,如 {@code ROSTER} / {@code TASK} / {@code AUDIT} / {@code EXPORT}。 */
private String module; 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 action;
private String requestMethod; private String requestMethod;
@@ -40,10 +60,10 @@ public class SysOperationLog {
private String requestUri; private String requestUri;
/** /**
* 请求参数。 * 请求参数(原始上下文)。
* *
* <p>⚠️ <b>入库前必须脱敏</b>:去掉 {@code password}、证件号明文等。 * <p>⚠️ <b>入库前必须脱敏</b>:去掉 {@code password}、证件号明文等。
* 操作日志的目的是追责,不是复制一份业务数据——把敏感信息抄进来 * 操作日志的目的是追责,不是复制一份业务数据 —— 把敏感信息抄进来
* 等于给系统加了一个无鉴权的高危数据副本。 * 等于给系统加了一个无鉴权的高危数据副本。
*/ */
private String requestParam; private String requestParam;
@@ -60,5 +80,21 @@ public class SysOperationLog {
private String traceId; 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; private LocalDateTime createTime;
} }
@@ -1,10 +1,16 @@
package cn.tianbaotong.fill.module.log.mapper; package cn.tianbaotong.fill.module.log.mapper;
import cn.tianbaotong.fill.module.log.dto.LogDtos;
import cn.tianbaotong.fill.module.log.entity.SysLoginLog; import cn.tianbaotong.fill.module.log.entity.SysLoginLog;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore; import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Options; 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<SysLoginLog> { public interface LoginLogMapper extends BaseMapper<SysLoginLog> {
@@ -28,4 +34,90 @@ public interface LoginLogMapper extends BaseMapper<SysLoginLog> {
""") """)
@Options(useGeneratedKeys = true, keyProperty = "id") @Options(useGeneratedKeys = true, keyProperty = "id")
int insertIgnoreTenant(SysLoginLog log); int insertIgnoreTenant(SysLoginLog log);
/**
* 登录日志分页(§13.4)。
*
* <p>⚠️ 登录日志表里 {@code success} 是 {@code smallint}(0/1),
* 而接口对外是布尔 {@code result} —— 转换在 SQL 里做,
* 让"哪个值算成功"只定义一次。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
<script>
SELECT l.id, l.user_id AS userId,
COALESCE(u.real_name, l.username) AS userName,
u.user_type AS userType,
CASE l.login_type
WHEN 1 THEN 'PASSWORD'
WHEN 2 THEN 'WECHAT'
WHEN 3 THEN 'PHONE'
ELSE 'UNKNOWN'
END AS loginType,
l.ip, l.user_agent AS device,
(l.success = 1) AS result,
l.fail_reason AS failReason,
l.create_time AS loginTime
FROM sys_login_log l
LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0
WHERE l.tenant_id = #{tenantId}
<if test="userId != null">
AND l.user_id = #{userId}
</if>
<if test="userType != null">
AND u.user_type = #{userType}
</if>
<if test="result != null">
AND l.success = #{result}
</if>
<if test="from != null">
AND l.create_time &gt;= #{from}
</if>
<if test="to != null">
AND l.create_time &lt;= #{to}
</if>
ORDER BY l.id DESC
LIMIT #{limit} OFFSET #{offset}
</script>
""")
List<LogDtos.LoginLogVO> listLogins(@Param("tenantId") Long tenantId,
@Param("userId") Long userId,
@Param("userType") Integer userType,
@Param("result") Integer result,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("limit") int limit,
@Param("offset") int offset);
/** 登录日志总数。 */
@InterceptorIgnore(tenantLine = "true")
@Select("""
<script>
SELECT COUNT(*)
FROM sys_login_log l
LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0
WHERE l.tenant_id = #{tenantId}
<if test="userId != null">
AND l.user_id = #{userId}
</if>
<if test="userType != null">
AND u.user_type = #{userType}
</if>
<if test="result != null">
AND l.success = #{result}
</if>
<if test="from != null">
AND l.create_time &gt;= #{from}
</if>
<if test="to != null">
AND l.create_time &lt;= #{to}
</if>
</script>
""")
long countLogins(@Param("tenantId") Long tenantId,
@Param("userId") Long userId,
@Param("userType") Integer userType,
@Param("result") Integer result,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to);
} }
@@ -1,7 +1,135 @@
package cn.tianbaotong.fill.module.log.mapper; package cn.tianbaotong.fill.module.log.mapper;
import cn.tianbaotong.fill.module.log.dto.LogDtos;
import cn.tianbaotong.fill.module.log.entity.SysOperationLog; import cn.tianbaotong.fill.module.log.entity.SysOperationLog;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; 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;
/**
* 操作日志(《03-接口设计》§13.4)。
*
* <p>查询走手写 SQL 并 {@code @InterceptorIgnore} + 显式 {@code tenant_id}:
* 靠拦截器隐式补条件会让"查不到数据"和"确实没有数据"分不清,
* 而日志查询恰恰是要能对客户说清"这段时间有没有人动过这张名册"的场景。
*/
public interface OperationLogMapper extends BaseMapper<SysOperationLog> { public interface OperationLogMapper extends BaseMapper<SysOperationLog> {
/**
* 写一条操作日志(<b>跳过租户拦截</b>)。
*
* <p>理由同 {@code LoginLogMapper.insertIgnoreTenant}:敏感动作有时发生在
* 上下文不完整的时候(异步导出线程、定时任务),靠拦截器补 {@code tenant_id}
* 会直接抛 40101 —— 于是"越需要留痕的场景越写不进日志"。
*
* <p>风险可控:{@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("""
<script>
SELECT l.id, l.tenant_id AS tenantId, l.user_id AS operatorId,
COALESCE(u.real_name, l.username) AS operatorName,
l.module, l.action, l.target_type AS targetType, l.target_id AS targetId,
l.before_val AS beforeVal, l.after_val AS afterVal,
l.ip, l.user_agent AS userAgent, l.biz_code AS bizCode, l.trace_id AS traceId,
l.create_time AS createTime
FROM sys_operation_log l
LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0
WHERE l.tenant_id = #{tenantId}
<if test="operatorId != null">
AND l.user_id = #{operatorId}
</if>
<if test="module != null and module != ''">
AND l.module = #{module}
</if>
<if test="action != null and action != ''">
AND l.action = #{action}
</if>
<if test="targetType != null and targetType != ''">
AND l.target_type = #{targetType}
</if>
<if test="targetId != null">
AND l.target_id = #{targetId}
</if>
<if test="from != null">
AND l.create_time &gt;= #{from}
</if>
<if test="to != null">
AND l.create_time &lt;= #{to}
</if>
ORDER BY l.id DESC
LIMIT #{limit} OFFSET #{offset}
</script>
""")
List<LogDtos.OperationLogVO> listOperations(@Param("tenantId") Long tenantId,
@Param("operatorId") Long operatorId,
@Param("module") String module,
@Param("action") String action,
@Param("targetType") String targetType,
@Param("targetId") Long targetId,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("limit") int limit,
@Param("offset") int offset);
/** 操作日志总数(与列表同一套过滤条件)。 */
@InterceptorIgnore(tenantLine = "true")
@Select("""
<script>
SELECT COUNT(*)
FROM sys_operation_log l
WHERE l.tenant_id = #{tenantId}
<if test="operatorId != null">
AND l.user_id = #{operatorId}
</if>
<if test="module != null and module != ''">
AND l.module = #{module}
</if>
<if test="action != null and action != ''">
AND l.action = #{action}
</if>
<if test="targetType != null and targetType != ''">
AND l.target_type = #{targetType}
</if>
<if test="targetId != null">
AND l.target_id = #{targetId}
</if>
<if test="from != null">
AND l.create_time &gt;= #{from}
</if>
<if test="to != null">
AND l.create_time &lt;= #{to}
</if>
</script>
""")
long countOperations(@Param("tenantId") Long tenantId,
@Param("operatorId") Long operatorId,
@Param("module") String module,
@Param("action") String action,
@Param("targetType") String targetType,
@Param("targetId") Long targetId,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to);
} }
@@ -0,0 +1,240 @@
package cn.tianbaotong.fill.module.log.service;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.module.log.entity.SysOperationLog;
import cn.tianbaotong.fill.module.log.mapper.OperationLogMapper;
import cn.tianbaotong.fill.support.SensitiveCodec;
import cn.tianbaotong.fill.support.WebUtil;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.regex.Pattern;
/**
* 操作日志留痕(《03-接口设计》§13.4,PRD FR-506 / FR-1202)。
*
* <h3>为什么写日志要单独一个 Service,而不是注解 + AOP</h3>
*
* <p>注解式({@code @OperationLog})看起来优雅,但对**合规留痕**这个用途有两个硬伤:
* <ol>
* <li><b>它记不到"变更前后的值"</b>:"证件号从 A 改成 B"里的 A 只有业务方法知道,
* 切面拿不到(除非把返回值再解析一遍,那就更脆);</li>
* <li><b>它的失败是静默的</b>:留痕失败和业务成功会长得一样。
* 而"改名册证件号"这类动作**必须有痕迹** ——
* 没有痕迹就该让人知道(至少进日志告警),而不是悄悄过去。</li>
* </ol>
* 所以这里用**显式调用**:业务方法自己说"我改了什么、从什么改成什么"。
* 代价是多一行代码,换来的是留痕内容准确、失败可见。
*
* <h3>脱敏是这一层的责任,不是调用方的</h3>
*
* <p>契约 §13.4 要求 {@code beforeVal}/{@code afterVal} 里的证件号**一律脱敏**。
* 把这件事放在这里做(而不是要求每个调用方自觉),是因为"忘记脱敏"的后果
* 与"多脱敏一次"完全不对称:前者往库里塞了一份明文身份证号,后者只是少看到几位。
* 所以这里**主动扫一遍**:任何看起来像 18 位身份证号的串都会被替换成脱敏形式。
*
* <h3>写日志绝不能让业务失败</h3>
*
* <p>{@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());
}
}
/**
* 脱敏:把任何看起来像身份证号/手机号的串替换掉,并截断到列宽。
*
* <p>⚠️ 这是**兜底**,不是唯一防线:调用方本该传脱敏值。
* 两层都做,是因为"忘记脱敏"的后果(库里多一份明文身份证号)不可逆。
*/
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);
}
}
@@ -2,8 +2,10 @@ package cn.tianbaotong.fill.module.roster.controller;
import cn.tianbaotong.fill.common.api.ApiResult; import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.common.api.PageResult; 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.RosterImportReport;
import cn.tianbaotong.fill.module.roster.dto.RosterVO; 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.RosterImportService;
import cn.tianbaotong.fill.module.roster.service.RosterService; import cn.tianbaotong.fill.module.roster.service.RosterService;
import cn.tianbaotong.fill.security.PermissionDef; 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 io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType; 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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; 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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -26,6 +31,9 @@ import org.springframework.web.multipart.MultipartFile;
* <p>权限口径与契约一致:<b>导入是管理动作</b>({@code roster:manage}), * <p>权限口径与契约一致:<b>导入是管理动作</b>({@code roster:manage}),
* <b>查看是管理 + 教师</b>({@code roster:view})—— 班主任要看得到自己班的名单, * <b>查看是管理 + 教师</b>({@code roster:view})—— 班主任要看得到自己班的名单,
* 但不该能把整份名册换掉。 * 但不该能把整份名册换掉。
*
* <p>修改与删除也是 {@code roster:manage}:它们能改的是**认定资格的基准数据**
* (姓名、证件号、报考三项),改错一个字段就会让一个学生的认定结论站不住。
*/ */
@Tag(name = "名册") @Tag(name = "名册")
@RestController @RestController
@@ -33,8 +41,11 @@ import org.springframework.web.multipart.MultipartFile;
@RequiredArgsConstructor @RequiredArgsConstructor
public class RosterController { public class RosterController {
private final cn.tianbaotong.fill.module.roster.service.RosterTemplateService templateService;
private final RosterImportService importService; private final RosterImportService importService;
private final RosterService rosterService; private final RosterService rosterService;
private final RosterEditService rosterEditService;
@PostMapping(value = "/tasks/{id}/roster/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(value = "/tasks/{id}/roster/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RequirePerm(PermissionDef.ROSTER_MANAGE) @RequirePerm(PermissionDef.ROSTER_MANAGE)
@@ -68,4 +79,64 @@ public class RosterController {
return ApiResult.ok(rosterService.list(id, keyword, orgId, submitStatus, bindStatus, return ApiResult.ok(rosterService.list(id, keyword, orgId, submitStatus, bindStatus,
pageNum, pageSize)); pageNum, pageSize));
} }
@PutMapping("/roster/{rid}")
@RequirePerm(PermissionDef.ROSTER_MANAGE)
@Operation(summary = "修改名册项",
description = "**只改传了的字段**。⚠️ 不要把列表里那一行整行回传 —— "
+ "列表的证件号是**脱敏值**,覆盖式更新会把真号写成星号,"
+ "那个学生从此过不了门禁(而且谁也不会想到是「保存一次」导致的)。"
+ "改证件号会强制留痕(`UPDATE_IDCARD`,PRD FR-506),"
+ "日志里记的是脱敏值 + 指纹,不记明文")
public ApiResult<Void> update(@PathVariable Long rid,
@RequestBody RosterEditDtos.RosterUpdateReq req) {
rosterEditService.update(rid, req);
return ApiResult.ok();
}
@DeleteMapping("/roster/{rid}")
@RequirePerm(PermissionDef.ROSTER_MANAGE)
@Operation(summary = "删除名册项",
description = "已绑定或已开始填报的**不允许删除**(40029)—— "
+ "删掉会让已填材料变孤儿。这时请改用 "
+ "`POST /roster/{rid}/deactivate`(标记不参与本次认定,可恢复)")
public ApiResult<Void> delete(@PathVariable Long rid) {
rosterEditService.delete(rid);
return ApiResult.ok();
}
@PostMapping("/roster/{rid}/deactivate")
@RequirePerm(PermissionDef.ROSTER_MANAGE)
@Operation(summary = "标记「不参与本次认定」",
description = "删除的**可逆替代品**:软删 + 解绑,并记操作日志。"
+ "他不会出现在未提交清单与催报名单里,"
+ "而「他一开始在名单里」这个事实仍然保留(导出时能解释名单为什么少了一个人)")
public ApiResult<RosterEditDtos.RosterDeleteVO> deactivate(@PathVariable Long rid) {
return ApiResult.ok(rosterEditService.deactivate(rid));
}
@GetMapping("/tasks/{taskId}/roster/template")
@RequirePerm(PermissionDef.ROSTER_VIEW)
@Operation(summary = "下载名册导入模板(§8.5)",
description = "表头**固定在第 1 行**、必填列**红字 + 浅红底**(只用红字的话"
+ "打印成黑白就看不出来了)、附 9 条填表规则与「本任务要交的材料」两页。"
+ "这三条都不是装饰:导入时表头是自动探测的,"
+ "但我们自己发的模板必须规规矩矩,否则「下载模板→填写→传回来」"
+ "这条最正常的路径反而会被探测算法折腾")
public org.springframework.http.ResponseEntity<byte[]> rosterTemplate(
@PathVariable Long taskId,
@RequestParam(defaultValue = "xlsx") String format,
@RequestParam(defaultValue = "true") boolean withSample) {
byte[] bytes = templateService.build(taskId, withSample);
String name = "名册导入模板.xlsx";
String encoded = java.net.URLEncoder.encode(name, java.nio.charset.StandardCharsets.UTF_8)
.replace("+", "%20");
return org.springframework.http.ResponseEntity.ok()
.contentType(org.springframework.http.MediaType.parseMediaType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + encoded + "\"; filename*=UTF-8''" + encoded)
.body(bytes);
}
} }
@@ -0,0 +1,97 @@
package cn.tianbaotong.fill.module.roster.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* 名册编辑与删除(《03-接口设计》§8.3、§8.4)。
*/
public final class RosterEditDtos {
private RosterEditDtos() {
}
/**
* 修改名册项。
*
* <p>⚠️ 全部字段**选填**:只传要改的那些。用"整行覆盖"的写法(前端把整行传回来)
* 会把列表里显示不到的字段(例如证件号的密文列)一起冲掉 ——
* 而名册列表返回的证件号本来就是**脱敏值**,回传过来会把真号覆盖成星号。
* 这类事故一旦发生,那个学生就再也过不了门禁了。
*/
@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<Long> rosterIds;
}
}
@@ -101,6 +101,25 @@ public interface CollectRosterMapper extends BaseMapper<CollectRoster> {
""") """)
long countOrg(@Param("tenantId") Long tenantId, @Param("orgId") Long orgId); long countOrg(@Param("tenantId") Long tenantId, @Param("orgId") Long orgId);
/**
* 按组织名找 id(加入申请审批时拿学生的"自述班级"去匹配)。
*
* <p>返回**列表**而不是单个 id:同名班级(不同年级/专业下都叫"1 班")是常见的,
* 这时不能猜 —— 调用方看到 &gt;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<Long> selectOrgIdsByName(@Param("tenantId") Long tenantId, @Param("name") String name);
/** /**
* 名册列表要显示班级名 —— 一次取全租户的组织(一所学校几十个节点), * 名册列表要显示班级名 —— 一次取全租户的组织(一所学校几十个节点),
* 在内存里做映射,避免"每行查一次"的 N+1。 * 在内存里做映射,避免"每行查一次"的 N+1。
@@ -0,0 +1,301 @@
package cn.tianbaotong.fill.module.roster.service;
import cn.hutool.json.JSONUtil;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.common.util.IdCardUtil;
import cn.tianbaotong.fill.module.log.service.OperationLogService;
import cn.tianbaotong.fill.module.roster.dto.RosterEditDtos;
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
import cn.tianbaotong.fill.support.IdentityNormalizer;
import cn.tianbaotong.fill.support.SensitiveCodec;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 名册编辑与删除(《03-接口设计》§8.3、§8.4)。
*
* <h3>这个类里最重要的两件事,都不是"怎么改"</h3>
*
* <ol>
* <li><b>证件号变更必须留痕</b>(PRD FR-506,契约 §13.4 的 {@code UPDATE_IDCARD})。
* 留痕内容里放的是**脱敏值 + 指纹**,不放明文 ——
* 日志泄露不该比业务库泄露更严重。指纹(hash 末 8 位)的用处是:
* 能确认"确实换成了另一个号",而不是"改成了同一个号",同时不暴露号码。</li>
* <li><b>已绑定/已提交的名册项不允许删除</b>(契约 §8.4 的 {@code 40029})。
* 不是"删不掉就算成功" —— 这时**改为标记"不参与本次认定"**并如实告诉调用方
* 走的是哪条路({@code deactivated=true})。直接删掉会让那个学生填报过的材料
* 变成孤儿,而"不参与"是可逆的。</li>
* </ol>
*
* <p>⚠️ 另一个容易出事的点:<b>更新一律用"只改传了的字段"</b>。
* 名册列表返回的证件号是**脱敏值**,如果前端把整行回传(很自然的做法),
* 覆盖式更新会把真号写成 {@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.<CollectRoster>lambdaUpdate().eq(CollectRoster::getId, rosterId);
boolean idCardChanged = false;
String beforeIdCardMask = r.getIdCardMask();
String afterIdCardMask = beforeIdCardMask;
String beforeName = r.getRealName();
String afterName = beforeName;
if (req.getRealName() != null && !req.getRealName().isBlank()) {
String name = IdentityNormalizer.name(req.getRealName());
u.set(CollectRoster::getRealName, name);
afterName = name;
}
if (req.getIdCardNo() != null && !req.getIdCardNo().isBlank()) {
String plain = IdentityNormalizer.idCard(req.getIdCardNo());
IdCardUtil.Verdict v = IdCardUtil.verdict(plain);
if (v == IdCardUtil.Verdict.MALFORMED) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "身份证号格式不正确");
}
// ⚠️ 校验位不对只是**警告**(历史数据里确实有校验位错的真号),
// 所以这里不拦。但必须让调用方知道——响应里没有这个字段,
// 所以记进操作日志的 extra,排查时能看到。
u.set(CollectRoster::getIdCardNo, codec.encrypt(plain));
u.set(CollectRoster::getIdCardHash, codec.hash(plain));
String mask = maskOf(plain);
u.set(CollectRoster::getIdCardMask, mask);
idCardChanged = true;
afterIdCardMask = mask;
// 改了证件号 → 原来的绑定关系可能不再成立(那个号对应的人变了)。
// 这里**不自动解绑**(解绑会让已经填好的材料失联),
// 但记进日志,让管理员知道"这条绑定需要复核"。
}
if (req.getPhone() != null && !req.getPhone().isBlank()) {
// 手机号的规范化只做全角转半角 + 去空白:**不要**用 IdentityNormalizer.name()
// (那个会去空格,是给姓名用的;手机号借它的规则会出岔子 —— 见 F-20 同类问题)
String phone = IdentityNormalizer.toHalfWidth(req.getPhone()).replaceAll("\\s+", "");
// 名册里的 phone 列存的就是脱敏值(列表直接回它),保持同一口径
u.set(CollectRoster::getPhone, maskPhone(phone));
u.set(CollectRoster::getPhoneHash, codec.hash(phone));
}
if (req.getOrgId() != null) {
u.set(CollectRoster::getOrgId, req.getOrgId());
}
if (req.getOccupation() != null) {
u.set(CollectRoster::getOccupation, req.getOccupation());
}
if (req.getWorktype() != null) {
u.set(CollectRoster::getWorktype, req.getWorktype());
}
if (req.getJobLevel() != null) {
u.set(CollectRoster::getJobLevel, req.getJobLevel());
}
if (req.getEducation() != null) {
u.set(CollectRoster::getEducation, req.getEducation());
}
if (req.getIsStudent() != null) {
u.set(CollectRoster::getIsStudent, req.getIsStudent());
}
if (req.getSchool() != null) {
u.set(CollectRoster::getSchool, req.getSchool());
}
if (req.getMajor() != null) {
u.set(CollectRoster::getMajor, req.getMajor());
}
if (req.getEnrollDate() != null) {
u.set(CollectRoster::getEnrollDate, parseDate(req.getEnrollDate()));
}
if (req.getWorkUnit() != null) {
u.set(CollectRoster::getWorkUnit, req.getWorkUnit());
}
if (req.getIsRetake() != null) {
u.set(CollectRoster::getIsRetake, req.getIsRetake());
}
if (req.getRetakeTheory() != null) {
u.set(CollectRoster::getRetakeTheory, req.getRetakeTheory());
}
if (req.getRetakePractice() != null) {
u.set(CollectRoster::getRetakePractice, req.getRetakePractice());
}
if (req.getRetakeOverall() != null) {
u.set(CollectRoster::getRetakeOverall, req.getRetakeOverall());
}
if (req.getCertOccupation() != null) {
u.set(CollectRoster::getCertOccupation, req.getCertOccupation());
}
if (req.getCertLevel() != null) {
u.set(CollectRoster::getCertLevel, req.getCertLevel());
}
if (req.getCertNo() != null) {
u.set(CollectRoster::getCertNo, req.getCertNo());
}
if (req.getExtJson() != null) {
// ⚠️ 实体上的列是 extras_json(不是 VO 里的 extJson)—— 两者的 Java 名不同、
// 入库列也不同(库里 ext_json 与 extras_json 都在,前者是历史列)。
// 用 getExtrasJson/setExtrasJson 才是当前生效的那一列。
u.set(CollectRoster::getExtrasJson, JSONUtil.toJsonStr(req.getExtJson()));
}
rosterMapper.update(null, u);
// ★ 留痕:证件号是**必须**记的(FR-506);姓名也记(改名同样影响认定结论)
if (idCardChanged) {
operationLog.record(OperationLogService.MODULE_ROSTER,
OperationLogService.ACTION_UPDATE_IDCARD,
OperationLogService.TARGET_ROSTER, rosterId,
fingerprintOf(beforeIdCardMask), fingerprintOf(afterIdCardMask),
"{\"submitted\":" + submitted + ",\"bound\":"
+ (r.getBindUserId() != null) + "}");
}
if (afterName != null && !afterName.equals(beforeName)) {
operationLog.record(OperationLogService.MODULE_ROSTER,
OperationLogService.ACTION_UPDATE_ROSTER_NAME,
OperationLogService.TARGET_ROSTER, rosterId, beforeName, afterName);
}
log.info("名册修改:roster={} 经办人={} 改了证件号={}", rosterId, me.uid(), idCardChanged);
}
/**
* 删除名册项(§8.4)。
*
* <p>已绑定或已提交的**不允许删除**({@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 的替代动作)。
*
* <p>实现方式是软删除 + 把扩展字段里打一个标记 —— 不做硬删,
* 因为"这个人一开始在名单里"本身就是需要保留的事实
* (否则导出时无法解释"为什么名单上少了一个人")。
*/
@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.<CollectRoster>lambdaUpdate()
.eq(CollectRoster::getId, rosterId)
.set(CollectRoster::getSubmitStatus, CollectRoster.SUBMIT_NONE)
.set(CollectRoster::getBindUserId, null)
.set(CollectRoster::getBindTime, null)
.set(CollectRoster::getBindType, null)
// 软删除:用 deleted=1 而不是硬删 —— 用 lambdaUpdate 时必须显式 set,
// 因为 deleteById 的 @TableLogic 在这里不会生效(走的是 update 路径)
.set(CollectRoster::getDeleted, 1);
rosterMapper.update(null, u);
operationLog.record(OperationLogService.MODULE_ROSTER,
OperationLogService.ACTION_DELETE_ROSTER,
OperationLogService.TARGET_ROSTER, rosterId,
r.getRealName(), null,
"{\"deactivated\":true,\"orgId\":" + r.getOrgId() + ",\"taskId\":" + r.getTaskId() + "}");
RosterEditDtos.RosterDeleteVO vo = new RosterEditDtos.RosterDeleteVO();
vo.setRosterId(rosterId);
vo.setDeactivated(true);
vo.setMessage("已标记为不参与本次认定(可恢复)。他不会出现在未提交清单与催报名单里");
return vo;
}
private void loginRequired() {
UserContext.require();
}
/** {@code "2024-09-01"} → {@code LocalDate};解析不了就报明确错误(不静默丢字段)。 */
private java.time.LocalDate parseDate(String v) {
try {
return java.time.LocalDate.parse(v.trim());
} catch (RuntimeException e) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"入学时间格式应为 2024-09-01,收到:" + v);
}
}
// ── 脱敏 ──────────────────────────────────────
/** 证件号 → 脱敏值(前 6 + 8 个星 + 后 4,与其它地方同口径)。 */
private String maskOf(String idCard) {
if (idCard == null || idCard.length() < 14) {
return idCard;
}
return idCard.substring(0, 6) + "********" + idCard.substring(idCard.length() - 4);
}
private String maskPhone(String phone) {
if (phone == null || phone.length() != 11) {
return phone;
}
return phone.substring(0, 3) + "****" + phone.substring(7);
}
/**
* 脱敏值 → 指纹。
*
* <p>留痕时**不记号码本身**(哪怕脱敏的),只记它的 hash 末 8 位:
* 这样能回答"确实换成了另一个号"(两次指纹不同),
* 同时不给出任何可用于猜号的信息。
* 传入的可能是脱敏值({@code 361029********1234})—— 那也算一种"指纹",
* 但语义弱一些;调用方传明文时得到的指纹更强。
*/
private String fingerprintOf(String maskOrPlain) {
if (maskOrPlain == null || maskOrPlain.isBlank()) {
return null;
}
return operationLog.fingerprint(maskOrPlain);
}
}
@@ -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);
@@ -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);
+104
View File
@@ -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<DictVO[]>('/dicts')
}
export function getDictItems(code: string) {
return http.get<DictItemListVO>(`/dicts/${code}/items`)
}
export function createDictItem(code: string, data: {
itemCode: string
itemName: string
parentItemCode?: string
sortNo?: number
}) {
return http.post<DictItemVO>(`/dicts/${code}/items`, data)
}
/** ⚠️ 请求体里**没有** itemCode —— 项码不可改是结构性的,不是界面藏起来的。 */
export function updateDictItem(id: number, data: {
itemName?: string
sortNo?: number
status?: number
}) {
return http.put<void>(`/dicts/items/${id}`, data)
}
export function deleteDictItem(id: number) {
return http.delete<void>(`/dicts/items/${id}`)
}
/** 停用 / 恢复一个**内置**项。内置项没有 id,所以按 itemCode 走。 */
export function toggleBuiltinItem(code: string, itemCode: string, enabled: boolean) {
return http.post<void>(
`/dicts/${code}/items/${encodeURIComponent(itemCode)}/toggle?enabled=${enabled}`,
{}
)
}
/** 契约 §13.3 的约束文案(错误码 → 人话),界面直接用。 */
export const DICT_ERROR_HINT: Record<number, string> = {
40036: '这一项下面还有子项,不能停用或删除。请先处理子项。',
40001: '内置字典项不能改名或删除 —— 需要改内置项请改代码发布;'
+ '如果只是想加一个值,用「新增项」追加即可。'
}
+124
View File
@@ -0,0 +1,124 @@
/**
* 日志查询(《03-接口设计》§13.4,PRD FR-506 / FR-1202)。
*
* ## 这一屏的用法与别的列表不同
*
* 它不是"浏览"用的,是**回答合规问题**用的:
* 「这张名册项的证件号被谁从什么改成了什么」「谁在什么时候看过这个学生的完整号」。
* 所以界面上有两处特别设计:
*
* 1. **`targetId` 可以直接填**(配合 `targetType`)—— 这是最重要的查询方式。
* 靠"搜关键字"是不行的:`request_param` 全文匹配会把
* 「改了它」和「顺带提到它」混在一起,而合规结论不能建立在这种匹配上。
* 2. **变更前后值单独一列**,而不是塞在"详情"弹窗里 ——
* 看这一屏的人 90% 就是为了看这两列。
*
* ## ⚠️ 一期的已知边界(界面上要说明,不能让人以为系统坏了)
*
* - 登录日志**只有 PC 账号密码登录**;学生从微信进来目前不写这张表;
* - IP 取的是代理链第一跳,**可以伪造** —— 用于排查,不作合规证据。
* 真正的证据是"哪个账号在什么时候做了什么"。
*/
import http from '@/utils/http'
export interface OperationLog {
id: number
tenantId?: number
operatorId?: number
operatorName?: string
module?: string
action?: string
targetType?: string
targetId?: number
/** ⚠️ 脱敏值。明文不进日志表 */
beforeVal?: string
afterVal?: string
ip?: string
userAgent?: string
bizCode?: number
traceId?: string
createTime?: string
}
export interface OperationLogPage {
list: OperationLog[]
total: number
}
export interface LoginLog {
id: number
userId?: number
userName?: string
userType?: number
/** PASSWORD / WECHAT / PHONE */
loginType?: string
ip?: string
location?: string
device?: string
result?: boolean
failReason?: string
loginTime?: string
}
export interface LoginLogPage {
list: LoginLog[]
total: number
}
export function getOperationLogs(params: {
operatorId?: number
module?: string
action?: string
targetType?: string
targetId?: number
from?: string
to?: string
pageNum?: number
pageSize?: number
} = {}) {
return http.get<OperationLogPage>('/logs/operations', params)
}
export function getLoginLogs(params: {
userId?: number
userType?: number
result?: boolean
from?: string
to?: string
pageNum?: number
pageSize?: number
} = {}) {
return http.get<LoginLogPage>('/logs/logins', params)
}
/** 敏感动作 → 文案。**与后端 OperationLogService 的常量一致**。 */
export const OPERATION_ACTIONS: Array<{ value: string, label: string }> = [
{ value: 'UPDATE_IDCARD', label: '改名册证件号' },
{ value: 'UPDATE_ROSTER_NAME', label: '改名册姓名' },
{ value: 'DELETE_ROSTER', label: '删名册项' },
{ value: 'PUBLISH_TASK', label: '发布任务' },
{ value: 'PAUSE_TASK', label: '暂停任务' },
{ value: 'RESUME_TASK', label: '恢复任务' },
{ value: 'CLOSE_TASK', label: '截止任务' },
{ value: 'ARCHIVE_TASK', label: '归档任务' },
{ value: 'REVOKE_AUDIT', label: '撤回审核' },
{ value: 'EXPORT_CREATE', label: '创建导出' },
{ value: 'PROXY_UPLOAD', label: '代传材料' },
{ value: 'VIEW_IDCARD_FULL', label: '查看完整证件号' },
{ value: 'CONFIG_COMPRESS', label: '改压缩配置' },
{ value: 'JOIN_APPROVE', label: '通过加入申请' },
{ value: 'JOIN_REJECT', label: '驳回加入申请' }
]
export function actionText(v?: string) {
return OPERATION_ACTIONS.find((a) => a.value === v)?.label || v || '—'
}
export const LOGIN_TYPES: Record<string, string> = {
PASSWORD: '账号密码',
WECHAT: '微信授权',
PHONE: '手机号快捷'
}
/** 需要重点看的动作(改证件号、看明文证件号)——列表里高亮。 */
export const SENSITIVE_ACTIONS = ['UPDATE_IDCARD', 'VIEW_IDCARD_FULL', 'DELETE_ROSTER']
+445
View File
@@ -0,0 +1,445 @@
<template>
<GiPageLayout>
<a-card :bordered="false">
<template #title>字典管理</template>
<template #extra>
<span class="dict__hint">
内置项照抄官方表格,**不可改名**(改名会让导出汇总表与官方模板对不上);
要加值请用「新增项」追加
</span>
</template>
<div class="dict__layout">
<!-- 左:字典列表 -->
<div class="dict__side">
<a-spin :loading="loadingDicts" class="dict__spin">
<div v-for="d in dicts" :key="d.dictCode" class="dict__row"
:class="{ 'dict__row--on': current?.dictCode === d.dictCode }"
@click="pick(d)">
<span class="dict__name">{{ d.dictName }}</span>
<a-tag v-if="d.builtin" size="small" color="arcoblue">内置</a-tag>
<span class="dict__count">{{ d.itemCount }}</span>
</div>
</a-spin>
</div>
<!-- 右:字典项 -->
<div class="dict__main">
<div v-if="!current" class="dict__empty">从左边选一个字典</div>
<template v-else>
<div class="dict__bar">
<span class="dict__title">
{{ current.dictName }}
<span class="dict__code">{{ current.dictCode }}</span>
</span>
<a-space>
<a-button size="small" @click="reload">
<template #icon><IconRefresh /></template>
刷新
</a-button>
<a-button type="primary" size="small" @click="openCreate">
新增项
</a-button>
</a-space>
</div>
<!-- ⚠️ 三种来源的说明必须摆在界面上:不说清的话,
管理员会以为"内置项改不了"是权限不够 -->
<a-alert v-if="current.builtin" type="normal" class="dict__note">
<template #title>
<template v-if="current.dictCode === 'OCCUPATION'">
这一项来自**国标职业库**(`sys_occupation_ref`),只读。
一期这张表是空的 —— 需要客户提供或采购国标职业数据后导入。
</template>
<template v-else>
这是内置字典:项名不可改、不可删,只能**停用**(可一键恢复默认)。
要加值请用「新增项」—— 追加的项可以随时改名或删除。
</template>
</template>
</a-alert>
<a-table :data="items" :loading="loadingItems" row-key="itemCode" :pagination="false"
:scroll="{ x: 800 }">
<template #columns>
<a-table-column title="项码" :width="140">
<template #cell="{ record }">
<span :class="record.builtin ? 'dict__muted' : ''">{{ record.itemCode }}</span>
</template>
</a-table-column>
<a-table-column title="项名" :width="260">
<template #cell="{ record }">
<span :class="record.status === 0 ? 'dict__off' : ''">{{ record.itemName }}</span>
<a-tag v-if="record.builtin === true" size="small" class="dict__tag">内置</a-tag>
</template>
</a-table-column>
<a-table-column v-if="hierarchical" title="上级项" :width="140">
<template #cell="{ record }">
<span class="dict__muted">{{ record.parentItemCode || '—' }}</span>
</template>
</a-table-column>
<a-table-column title="排序" :width="80">
<template #cell="{ record }">
<span class="dict__muted">{{ record.sortNo ?? '—' }}</span>
</template>
</a-table-column>
<a-table-column title="状态" :width="90">
<template #cell="{ record }">
<a-tag size="small" :color="record.status === 0 ? 'gray' : 'green'">
{{ record.status === 0 ? '已停用' : '启用' }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="操作" :width="200">
<template #cell="{ record }">
<!--
内置项与自定义项的可用动作**不同**,这是刻意的:
· 内置项(没有 id)→ 只能停用/恢复(走 toggle)
· 自定义项 → 可改名、可删
把两套按钮混在一起,用户一定会点到"改名内置项"然后收到 40001。
-->
<a-space v-if="record.builtin === true">
<a-link v-if="current.dictCode === 'OCCUPATION'" disabled>只读</a-link>
<a-link v-else @click="toggle(record)">
{{ record.status === 0 ? '恢复默认' : '停用' }}
</a-link>
</a-space>
<a-space v-else>
<a-link @click="openEdit(record)">编辑</a-link>
<a-link :status="record.status === 0 ? 'success' : 'warning'"
@click="toggleCustom(record)">
{{ record.status === 0 ? '启用' : '停用' }}
</a-link>
<a-popconfirm content="删除这一项?已经用过它的历史数据不会变,但以后选不到了"
@ok="remove(record)">
<a-link status="danger">删除</a-link>
</a-popconfirm>
</a-space>
</template>
</a-table-column>
</template>
</a-table>
</template>
</div>
</div>
</a-card>
<!-- 新增 / 编辑 -->
<a-modal v-model:visible="modalVisible" :title="editing ? '编辑字典项' : '新增字典项'"
:ok-loading="saving" @ok="save" @cancel="modalVisible = false">
<a-form :model="form" layout="vertical">
<a-form-item label="项码" required>
<!-- 项码建后不可改是**结构性**的:编辑时直接禁用输入,
并且后端接口的请求体里也没有这个字段。
`editing !== null` 必须写全 —— 直接写 `editing` 会因为它可能是 null
而被 vue-tsc 拦下(:disabled 只要 boolean) -->
<a-input v-model="form.itemCode" :disabled="editing !== null"
placeholder="如 大学本科 / TECH_SCHOOL(建后不可改)" />
<template #extra>
项码是历史数据里真正存的值,**建后不可改** ——
改了它,过去填过的值会指向不存在的项,而查询不报错、只显示空白。
</template>
</a-form-item>
<a-form-item label="项名" required>
<a-input v-model="form.itemName" placeholder="界面上给用户看的文字" />
</a-form-item>
<a-form-item v-if="hierarchical" label="上级项码">
<a-input v-model="form.parentItemCode" placeholder="留空 = 顶层项" />
</a-form-item>
<a-form-item label="排序">
<a-input-number v-model="form.sortNo" :min="0" :step="10" placeholder="数字越小越靠前" />
</a-form-item>
</a-form>
</a-modal>
</GiPageLayout>
</template>
<script setup lang="ts">
import type { DictItemVO, DictVO } from '@/apis/xt/dict'
import { Message } from '@arco-design/web-vue'
import { IconRefresh } from '@arco-design/web-vue/es/icon'
import { ref } from 'vue'
import {
createDictItem,
deleteDictItem,
DICT_ERROR_HINT,
getDictItems,
getDicts,
toggleBuiltinItem,
updateDictItem
} from '@/apis/xt/dict'
/**
* 字典管理(《03-接口设计》§13.3)。
*
* ## 界面上的核心分叉:内置项 vs 自定义项
*
* 内置项**没有 id**(它们在代码里,没有数据库行),所以:
* · 操作只能按 `itemCode` 走 `toggle`(停用/恢复);
* · 没有"编辑名称"与"删除"。
* 自定义项才有 id,可以改名、停用、删除。
*
* 把两套按钮混在一起,用户一定会点到"改名内置项"然后收到 40001 ——
* 而那时他会以为"是权限不够",实际是设计如此。
*/
defineOptions({ name: 'DictManage' })
const loadingDicts = ref(false)
const loadingItems = ref(false)
const saving = ref(false)
const dicts = ref<DictVO[]>([])
const current = ref<DictVO | null>(null)
const items = ref<DictItemVO[]>([])
// 层级支持来自**字典项接口**(不在字典列表的 VO 上),所以单独记一个
const hierarchical = ref(false)
const modalVisible = ref(false)
const editing = ref<DictItemVO | null>(null)
const form = ref<{
itemCode: string
itemName: string
parentItemCode?: string
sortNo?: number
}>({ itemCode: '', itemName: '', parentItemCode: undefined, sortNo: undefined })
/** 错误码 → 人话。界面直接显示后端 msg 也可以,但那句话对"我该做什么"帮助有限。 */
function hint(e: unknown) {
const err = e as { code?: number, message?: string }
if (err?.code && DICT_ERROR_HINT[err.code]) {
return DICT_ERROR_HINT[err.code]
}
return err?.message || '操作失败'
}
async function loadDicts() {
loadingDicts.value = true
try {
const res = await getDicts()
dicts.value = res.data ?? []
} finally {
loadingDicts.value = false
}
}
async function loadItems() {
if (!current.value) {
return
}
loadingItems.value = true
try {
const res = await getDictItems(current.value.dictCode)
items.value = res.data?.list ?? []
hierarchical.value = res.data?.hierarchical === true
} finally {
loadingItems.value = false
}
}
function pick(d: DictVO) {
current.value = d
// 先清掉上一次的层级标记:否则从一个有层级的字典切到没有的字典时,
// 「上级项」这一列会继续显示(而它对新字典毫无意义)
hierarchical.value = false
loadItems()
}
function reload() {
loadDicts()
loadItems()
}
function openCreate() {
editing.value = null
form.value = { itemCode: '', itemName: '', parentItemCode: undefined, sortNo: undefined }
modalVisible.value = true
}
function openEdit(row: DictItemVO) {
editing.value = row
form.value = {
itemCode: row.itemCode,
itemName: row.itemName,
parentItemCode: row.parentItemCode ?? undefined,
sortNo: row.sortNo
}
modalVisible.value = true
}
async function save() {
if (!current.value) {
return
}
if (!form.value.itemCode.trim() || !form.value.itemName.trim()) {
Message.warning('项码与项名都必填')
return
}
saving.value = true
try {
if (editing.value?.id) {
// ⚠️ 只传项名与排序:项码不在接口契约里(结构性保证)
await updateDictItem(editing.value.id, {
itemName: form.value.itemName.trim(),
sortNo: form.value.sortNo
})
Message.success('已保存')
} else {
await createDictItem(current.value.dictCode, {
itemCode: form.value.itemCode.trim(),
itemName: form.value.itemName.trim(),
parentItemCode: form.value.parentItemCode?.trim() || undefined,
sortNo: form.value.sortNo
})
Message.success('已新增')
}
modalVisible.value = false
reload()
} catch (e) {
Message.error(hint(e))
} finally {
saving.value = false
}
}
/** 内置项:停用 / 恢复默认。 */
async function toggle(row: DictItemVO) {
if (!current.value) {
return
}
const enable = row.status === 0
try {
await toggleBuiltinItem(current.value.dictCode, row.itemCode, enable)
Message.success(enable ? '已恢复默认' : '已停用')
reload()
} catch (e) {
Message.error(hint(e))
}
}
/** 自定义项:改状态。 */
async function toggleCustom(row: DictItemVO) {
if (!row.id) {
return
}
try {
await updateDictItem(row.id, { status: row.status === 0 ? 1 : 0 })
Message.success(row.status === 0 ? '已启用' : '已停用')
reload()
} catch (e) {
Message.error(hint(e))
}
}
async function remove(row: DictItemVO) {
if (!row.id) {
return
}
try {
await deleteDictItem(row.id)
Message.success('已删除')
reload()
} catch (e) {
Message.error(hint(e))
}
}
loadDicts()
</script>
<style lang="scss" scoped>
.dict {
&__hint {
font-size: 12px;
color: var(--color-text-3);
}
&__layout {
display: flex;
gap: 16px;
}
&__side {
flex: none;
width: 220px;
border-right: 1px solid var(--color-border-1);
}
&__spin {
display: block;
width: 100%;
}
&__row {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
&:hover {
background: var(--color-fill-1);
}
&--on {
background: var(--color-fill-2);
}
}
&__name {
flex: 1;
font-size: 13px;
}
&__count {
font-size: 12px;
color: var(--color-text-3);
}
&__main {
flex: 1;
min-width: 0;
}
&__bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
&__title {
font-size: 15px;
font-weight: 500;
}
&__code {
margin-left: 8px;
font-size: 12px;
font-weight: 400;
color: var(--color-text-3);
}
&__note {
margin-bottom: 12px;
}
&__empty {
padding: 40px;
color: var(--color-text-3);
text-align: center;
}
&__muted {
font-size: 12px;
color: var(--color-text-3);
}
&__off {
color: var(--color-text-3);
text-decoration: line-through;
}
&__tag {
margin-left: 6px;
}
}
</style>
+291
View File
@@ -0,0 +1,291 @@
<template>
<GiPageLayout>
<a-card :bordered="false">
<template #title>操作日志</template>
<template #extra>
<span class="log__hint">
按「对象」查是这个页面最重要的用法:填对象类型 + ID,就能看到它被谁改过
</span>
</template>
<a-alert type="normal" class="log__tip">
<template #title>
证件号在这里**只有脱敏值与指纹**(日志泄露不该比业务库泄露更严重)。
IP 取代理链第一跳、**可以被伪造** —— 用于排查,不作合规证据;
真正的证据是「哪个账号在什么时候做了什么」。
</template>
</a-alert>
<div class="g-row-tool">
<a-space wrap>
<a-input-number v-model="query.targetId" placeholder="对象 ID" size="small"
class="log__num" :min="1" hide-button @change="search" />
<a-select v-model="query.targetType" placeholder="对象类型" allow-clear size="small"
class="log__sel" @change="search">
<a-option value="ROSTER">名册项</a-option>
<a-option value="TASK">任务</a-option>
<a-option value="SUBMISSION">填报单</a-option>
<a-option value="EXPORT">导出</a-option>
<a-option value="USER">账号</a-option>
</a-select>
<a-select v-model="query.action" placeholder="动作" allow-clear size="small"
class="log__sel2" @change="search">
<a-option v-for="a in OPERATION_ACTIONS" :key="a.value" :value="a.value">
{{ a.label }}
</a-option>
</a-select>
<a-select v-model="query.module" placeholder="模块" allow-clear size="small"
class="log__sel3" @change="search">
<a-option value="ROSTER">名册</a-option>
<a-option value="TASK">任务</a-option>
<a-option value="AUDIT">审核</a-option>
<a-option value="EXPORT">导出</a-option>
<a-option value="JOIN">加入申请</a-option>
</a-select>
<!-- 起止日期用两个独立日期框,**不用 range 模式**:
Arco 的 range 模式实际值是数组、但类型声明只写了单值,
改哪边都要么 as any(掩盖问题)、要么被类型挡住。
而且两个框各自能单独清空,核查时更好用 -->
<a-date-picker v-model="fromDate" size="small" mode="date"
placeholder="开始日期" class="log__date" @change="search" />
<a-date-picker v-model="toDate" size="small" mode="date"
placeholder="结束日期" class="log__date" @change="search" />
<a-button size="small" @click="reset">重置</a-button>
<a-button size="small" @click="load">
<template #icon><IconRefresh /></template>
刷新
</a-button>
</a-space>
</div>
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
:scroll="{ x: 1200 }" @page-change="onPageChange">
<template #columns>
<a-table-column title="时间" :width="150">
<template #cell="{ record }">
<span class="log__muted">{{ fmt(record.createTime) }}</span>
</template>
</a-table-column>
<a-table-column title="操作人" :width="120">
<template #cell="{ record }">
<div>{{ record.operatorName || '—' }}</div>
<div class="log__muted">ID {{ record.operatorId ?? '—' }}</div>
</template>
</a-table-column>
<a-table-column title="动作" :width="150">
<template #cell="{ record }">
<a-tag size="small" :color="isSensitive(record.action) ? 'red' : 'arcoblue'">
{{ actionText(record.action) }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="对象" :width="130">
<template #cell="{ record }">
<span v-if="record.targetType" class="log__muted">
{{ record.targetType }} #{{ record.targetId }}
</span>
<span v-else class="log__muted">—</span>
</template>
</a-table-column>
<a-table-column title="变更前 → 变更后" :width="280">
<template #cell="{ record }">
<div v-if="record.beforeVal || record.afterVal" class="log__diff">
<span class="log__before">{{ record.beforeVal || '(空)' }}</span>
<span class="log__arrow">→</span>
<span class="log__after">{{ record.afterVal || '(空)' }}</span>
</div>
<span v-else class="log__muted">—</span>
</template>
</a-table-column>
<a-table-column title="来源" :width="200">
<template #cell="{ record }">
<div class="log__muted">{{ record.ip || '—' }}</div>
<div class="log__ua" :title="record.userAgent || ''">{{ record.userAgent || '' }}</div>
</template>
</a-table-column>
</template>
</a-table>
</a-card>
</GiPageLayout>
</template>
<script setup lang="ts">
import type { OperationLog } from '@/apis/xt/log'
import { IconRefresh } from '@arco-design/web-vue/es/icon'
import { computed, reactive, ref } from 'vue'
import {
actionText,
getOperationLogs,
OPERATION_ACTIONS,
SENSITIVE_ACTIONS
} from '@/apis/xt/log'
/**
* 操作日志(《03-接口设计》§13.4)。
*
* ## 为什么把「对象 ID」放在筛选栏最左边
*
* 因为这是这一屏**唯一不可替代**的查询方式。合规问题问的是
* 「这张名册项的证件号被谁从什么改成了什么」,答案只能靠
* `target_type + target_id` 定位;而"搜关键字"会把
* 「改了它」和「顺带提到它」混在一起 —— 那种匹配撑不起合规结论。
*
* ## 「变更前 → 变更后」单独一列
*
* 看这一屏的人 90% 就是为了看这两列。塞进"详情"弹窗里等于让每次查看多一次点击,
* 而合规核查往往是几十条连着看的。
*/
defineOptions({ name: 'OperationLogs' })
const loading = ref(false)
const list = ref<OperationLog[]>([])
const total = ref(0)
const fromDate = ref<string | undefined>(undefined)
const toDate = ref<string | undefined>(undefined)
const query = reactive<{
targetId?: number
targetType?: string
action?: string
module?: string
from?: string
to?: string
pageNum: number
pageSize: number
}>({
targetId: undefined,
targetType: undefined,
action: undefined,
module: undefined,
from: undefined,
to: undefined,
pageNum: 1,
pageSize: 20
})
const pagination = computed(() => ({
current: query.pageNum,
pageSize: query.pageSize,
total: total.value,
showTotal: true,
showPageSize: true
}))
function fmt(v?: string) {
return v ? v.replace('T', ' ').slice(0, 19) : '—'
}
function isSensitive(action?: string) {
return !!action && SENSITIVE_ACTIONS.includes(action)
}
async function load() {
loading.value = true
try {
const res = await getOperationLogs({
targetId: query.targetId,
targetType: query.targetType,
action: query.action,
module: query.module,
// 后端 `to` 会补到当天 23:59:59,所以这里只给日期就够
from: fromDate.value || undefined,
to: toDate.value || undefined,
pageNum: query.pageNum,
pageSize: query.pageSize
})
list.value = res.data?.list ?? []
total.value = res.data?.total ?? 0
} finally {
loading.value = false
}
}
function search() {
query.pageNum = 1
load()
}
function reset() {
query.targetId = undefined
query.targetType = undefined
query.action = undefined
query.module = undefined
fromDate.value = undefined
toDate.value = undefined
search()
}
function onPageChange(page: number) {
query.pageNum = page
load()
}
load()
</script>
<style lang="scss" scoped>
.log {
&__hint {
font-size: 12px;
color: var(--color-text-3);
}
&__tip {
margin-bottom: 12px;
}
&__num {
width: 110px;
}
&__sel {
width: 120px;
}
&__sel2 {
width: 150px;
}
&__sel3 {
width: 100px;
}
&__date {
width: 240px;
}
&__muted {
font-size: 12px;
color: var(--color-text-3);
}
&__diff {
font-size: 13px;
line-height: 1.5;
word-break: break-all;
}
&__before {
color: var(--color-text-3);
text-decoration: line-through;
}
&__arrow {
margin: 0 6px;
color: var(--color-text-3);
}
&__after {
color: rgb(var(--red-6));
font-weight: 500;
}
&__ua {
overflow: hidden;
font-size: 12px;
color: var(--color-text-3);
text-overflow: ellipsis;
white-space: nowrap;
}
}
</style>