diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/service/RejectReasonCatalog.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/service/RejectReasonCatalog.java index 7db156d..244c83d 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/service/RejectReasonCatalog.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/service/RejectReasonCatalog.java @@ -54,6 +54,17 @@ public final class RejectReasonCatalog { return out; } + /** + * 全部原因码(字典管理页要用)。 + * + *

⚠️ 返回 code 而不是文案:字典页面要列"有哪些原因可停用/追加", + * 而审核页要按 code **校验** —— code 是这两处的公共键。 + * 文案在 {@link #list()} 里,两处引用同一份 {@code TEXTS},不复制。 + */ + public static List codes() { + return ORDER; + } + public static String textOf(String code) { return code == null ? null : TEXTS.get(code); } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/dict/controller/DictController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/dict/controller/DictController.java new file mode 100644 index 0000000..9f74a3f --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/dict/controller/DictController.java @@ -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)。 + * + *

权限用 {@code task:manage},不是新造一个"字典管理"码

+ * + *

理由:字典影响的是**所有任务的下拉选项与导出内容**,改错一个会让 + * 已提交的数据指向不存在的项。所以它应当只有"能建与配置收集任务"的那批人能动 —— + * 这与 {@code task:manage} 恰好是同一批人。新造权限码会导致 + * 某个角色"能改任务配置但改不了字典",而那两件事的风险是同一级别。 + * + *

⚠️ 内置字典的项名**改不了**(契约 §13.3 的 {@code builtin: true}): + * 只可停用单项 + 追加自定义项。想改内置项名得改代码发布 —— 这是刻意的, + * 因为内置选项照抄官方表格,改名会让导出的汇总表与官方模板对不上。 + */ +@Tag(name = "字典管理") +@RestController +@RequestMapping("/api/v1/dicts") +@RequiredArgsConstructor +public class DictController { + + private final DictService dictService; + + @GetMapping + @RequirePerm(PermissionDef.TASK_VIEW) + @Operation(summary = "字典列表", + description = "`[{ dictCode, dictName, itemCount, builtin }]`。" + + "⚠️ `itemCount` 只算**启用中**的项 —— 把停用的也算进去," + + "管理员会看到「学历 11 项」却只数出 10 个,以为界面漏了") + public ApiResult> 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 items(@PathVariable String code) { + return ApiResult.ok(dictService.items(code)); + } + + @PostMapping("/{code}/items") + @RequirePerm(PermissionDef.TASK_MANAGE) + @Operation(summary = "新增字典项", + description = "对内置字典是**追加**(内置项本身不动)。" + + "项码在同字典内唯一;项码**建后不可改** —— " + + "历史数据里存的是项码,改了它过去填过的值会指向不存在的项," + + "而查询不报错、只显示空白") + public ApiResult 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 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 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 toggleBuiltin(@PathVariable String code, + @PathVariable String itemCode, + @RequestParam(defaultValue = "false") boolean enabled) { + dictService.toggleBuiltin(code, itemCode, enabled); + return ApiResult.ok(); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/dict/dto/DictDtos.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/dict/dto/DictDtos.java new file mode 100644 index 0000000..0c75c7c --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/dict/dto/DictDtos.java @@ -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; + + /** + * 项数。 + * + *

⚠️ 只算**启用中**的项:停用的项在界面上不该被计入"这个字典有多少项", + * 否则管理员看到"学历 11 项"却只数出 10 个,会以为界面漏了。 + */ + private int itemCount; + + /** + * {@code true} = 内置字典:**项名不可改、不可删**,只可停用单项 + 追加自定义项。 + * + *

置 true 的理由:内置选项照抄官方表格,改名会让导出的汇总表与官方模板对不上, + * 而那时没有任何地方会报错。 + */ + private boolean builtin; + } + + /** 一个字典项。 */ + @Getter + @Setter + @Schema(description = "字典项") + public static class DictItemVO { + + /** 自定义项有 id;内置项没有(它在代码里,没有行) */ + private Long id; + + private String itemCode; + + private String itemName; + + private String parentItemCode; + + private Integer sortNo; + + /** {@code 0} 停用 / {@code 1} 启用 */ + private Integer status; + + /** {@code true} = 内置项(代码里定义,不可改名/删除) */ + private Boolean builtin; + + private LocalDateTime createTime; + } + + /** 字典项列表。 */ + @Getter + @Setter + public static class DictItemListVO { + + private String dictCode; + + private String dictName; + + private boolean builtin; + + /** 这个字典**是否支持**层级(如职业-工种)。前端据此决定要不要显示父项列 */ + private boolean hierarchical; + + private List 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; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/dict/mapper/DictItemMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/dict/mapper/DictItemMapper.java new file mode 100644 index 0000000..26b4d7a --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/dict/mapper/DictItemMapper.java @@ -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; + +/** + * 字典项数据访问。 + * + *

手写 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 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); + + /** + * 这个租户自己造出来的字典码(内置目录里没有的那些)。 + * + *

用途:字典列表要显示"客户自己加的字典"(例如"专业"), + * 而它们没有代码里的定义,只能从数据里发现。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT DISTINCT dict_code FROM sys_dict_item + WHERE tenant_id = #{tenantId} AND deleted = 0 + ORDER BY dict_code + """) + List 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(""" + + """) + 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 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} 的项)。 + * + *

⚠️ {@code sys_occupation_ref} **没有 tenant_id** —— 它是国标数据, + * 全平台共用。所以这里既不 {@code @InterceptorIgnore} 也不带租户条件, + * 但必须靠**表本身没有租户列**这一点来保证不会越权(而不是靠"我记得不加")。 + * + *

一期这张表是空的(国标库要客户提供或去买),所以字典页会显示 0 项 —— + * 这是**已知情况**,界面要说明,不能让人以为功能坏了。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT id, parent_id AS parentId, name, aliases, has_child AS hasChild, status + FROM sys_occupation_ref + ORDER BY parent_id, id + LIMIT #{limit} OFFSET #{offset} + """) + List listOccupations(@Param("limit") int limit, @Param("offset") int offset); + + /** 国标职业总数(含停用的,用于分页)。 */ + @InterceptorIgnore(tenantLine = "true") + @Select("SELECT COUNT(*) FROM sys_occupation_ref") + long countOccupations(); +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/dict/service/DictCatalog.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/dict/service/DictCatalog.java new file mode 100644 index 0000000..4d5ef1b --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/dict/service/DictCatalog.java @@ -0,0 +1,103 @@ +package cn.tianbaotong.fill.module.dict.service; + +import java.util.List; + +/** + * 内置字典目录(《03-接口设计》§13.3)。 + * + *

为什么内置字典写在代码里,而不是插进 `sys_dict_item`

+ * + *

因为它们的选项**照抄官方表格、不许擅改**({@code BuiltinFields} 的类注释里 + * 写了这条)。放进表里会带来一个具体风险:有人从界面上把"四级/中级工"改名成"中级", + * 导出的汇总表就与官方模板对不上了 —— 而那时**没有任何地方会报错**。 + * + *

这和权限码是同一个思路:不能改的放代码,改它必须走一次发布; + * 能改的(客户自己加项)放 {@code sys_dict_item}。 + * + *

三种来源,取舍不同

+ * + * + * + * + * + * + * + *
字典来源能不能改
EDU_LEVEL / JOB_LEVEL / CERT_TYPE / RETARGET_SUBJECT / REJECT_REASON本类(照抄官方)不可改项名;只可停用单项 + 追加自定义项
OCCUPATION{@code sys_occupation_ref}(国标职业库,有层级与别名)不可改(那是国标)
+ * + *

⚠️ 一个必须说清的边界:{@code OCCUPATION} 在官方 Excel 模板里 + * 是自由填写、没有下拉(PRD FR-312)。所以这个字典**只做拼写校验与联想建议 + * (P2 级),不做强约束** —— 凡发现自由填写值不在字典中,记信息级提示,**不阻断**。 + */ +public final class DictCatalog { + + private DictCatalog() { + } + + /** 一个内置字典的定义。 */ + public record Def(String code, String name, List items, boolean builtin) { + } + + /** + * 学历(11 项)。 + * + *

⚠️ 与 {@code BuiltinFields.EDU_LEVEL} 必须完全一致 —— 表单下拉与字典 + * 是两个入口看同一份口径,任何一处改了另一处不改,就会出现 + * "字典里有的学历,表单里选不到"(用户会当成 bug 报,而排查时两边各自看都"没错")。 + * 所以这里**只引用那一份**,不复制。 + */ + public static final Def EDU_LEVEL = new Def("EDU_LEVEL", "学历", + cn.tianbaotong.fill.module.form.service.BuiltinFields.eduLevels(), true); + + public static final Def JOB_LEVEL = new Def("JOB_LEVEL", "职业技能等级", + cn.tianbaotong.fill.module.form.service.BuiltinFields.jobLevels(), true); + + /** + * 认定类型(3 项)。 + * + *

一期只做自主认定({@code certType = 2, 3}),但字典要列全 —— + * 它是"取值域"的定义,不是"本期可选项"的定义。用别的值提交会被 40044 拦下, + * 那才是范围控制该在的地方。 + */ + public static final Def CERT_TYPE = new Def("CERT_TYPE", "认定类型", + List.of("职业技能等级认定", "专项职业能力考核", "其他"), true); + + public static final Def RETARGET_SUBJECT = new Def("RETARGET_SUBJECT", "补考科目", + List.of("理论", "实操", "综合评审"), true); + + /** + * 驳回原因(10 项)。 + * + *

⚠️ 这里的 code(R001…R010)才是关键,文案在 + * {@code RejectReasonCatalog} 里 —— 两者是同一份数据的两个视角: + * 字典管理页要能列出"有哪些原因可停用",而审核页要按 code 校验。 + * 所以这里只引用 code 列表,不重复文案。 + */ + public static final Def REJECT_REASON = new Def("REJECT_REASON", "驳回原因", + cn.tianbaotong.fill.module.audit.service.RejectReasonCatalog.codes(), true); + + /** + * 职业(国标)。 + * + *

⚠️ {@code items} 是**空列表**:它的项来自 {@code sys_occupation_ref} + * (可能几千条、有层级),塞进这个 record 没有意义。 + * {@code DictService} 会为它单独查表。 + */ + public static final Def OCCUPATION = new Def("OCCUPATION", "职业(国标)", List.of(), true); + + /** 全部内置字典,顺序即界面顺序。 */ + public static final List 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; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/dict/service/DictService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/dict/service/DictService.java new file mode 100644 index 0000000..8abd740 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/dict/service/DictService.java @@ -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)。 + * + *

三种来源合成一份视图,是这一层的全部工作

+ * + *
    + *
  1. 内置项({@link DictCatalog},代码里)—— 不可改名、不可删, + * 只可**停用单项**(存在 {@code sys_dict_disabled},**不是**改内置项的状态);
  2. + *
  3. 自定义项({@code sys_dict_item})—— 可增、可改名、可停用、可排序;
  4. + *
  5. 国标职业({@code sys_occupation_ref})—— 只读,来自国标库。
  6. + *
+ * + *

⚠️ 第 1 条里的"停用"为什么不能复用 {@code sys_dict_item.status}: + * 那张表是"客户自己加的项",而停用是"官方项被客户关掉了"。混在一起之后 + * **"恢复默认"就没法做了** —— 分不清哪些是官方项、哪些是客户加的。 + * 这个区分在界面上的表现是:内置项那行的"删除"按钮文案是**"停用"**, + * 而且停用后可以一键恢复。 + * + *

两条契约明写的约束

+ * + * + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class DictService { + + private final DictItemMapper mapper; + + // ── 字典列表 ────────────────────────────────── + + /** 全部字典(内置 + 客户自定义出来的)。 */ + public List listDicts() { + Long tenantId = UserContext.tenantId(); + Map 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 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 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 list = new ArrayList<>(); + if (def != null) { + List 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)。 + * + *

⚠️ 内置项要分两种改法: + *

    + *
  • 改**项名** → 拒绝(内置选项照抄官方,改名会让导出表与官方模板对不上);
  • + *
  • 改**状态** → 允许,但走的是"停用内置项"({@code sys_dict_disabled}), + * 这样以后可以一键恢复默认;
  • + *
+ * 界面上的表现:内置项那行只有"停用/恢复",没有"编辑名称"。 + * 但**直接调接口**的人不会知道这条,所以这里必须再拦一次。 + */ + @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}:有下级项的字典项不可停用/删除。 + * + *

停用父项而子项还启用着,界面上会出现"选了父项却选不到子项", + * 而数据层面看不出任何矛盾 —— 这类问题排查起来最费时间。 + */ + private void requireNoChildren(Long tenantId, String dictCode, String itemCode) { + if (mapper.countChildren(tenantId, dictCode, itemCode) > 0) { + throw new BizException(ErrorCode.DICT_HAS_CHILDREN); + } + } + + // ── 内部 ────────────────────────────────────── + + private DictDtos.DictItemVO toVo(DictItemMapper.ItemRow r) { + DictDtos.DictItemVO vo = new DictDtos.DictItemVO(); + vo.setId(r.getId()); + vo.setItemCode(r.getItemCode()); + vo.setItemName(r.getItemName()); + vo.setParentItemCode(r.getParentItemCode()); + vo.setSortNo(r.getSortNo()); + vo.setStatus(r.getStatus()); + vo.setBuiltin(false); + vo.setCreateTime(r.getCreateTime()); + return vo; + } + + private boolean isBlank(String s) { + return s == null || s.isBlank(); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/service/BuiltinFields.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/service/BuiltinFields.java index 43333d7..e51e6e0 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/service/BuiltinFields.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/service/BuiltinFields.java @@ -207,6 +207,22 @@ public final class BuiltinFields { // ── 对外 ────────────────────────────────────── + /** + * 学历选项(字典管理页要用)。 + * + *

⚠️ 暴露这两个访问器而不是让字典模块自己复制一份:表单下拉与字典管理 + * 是**两个入口看同一份口径**,任何一处改了另一处不改,就会出现 + * "字典里有的学历、表单里选不到" —— 而排查时两边各自看都"没错"。 + */ + public static List eduLevels() { + return EDU_LEVEL; + } + + /** 等级选项(字典管理页要用)。理由同 {@link #eduLevels()}。 */ + public static List jobLevels() { + return JOB_LEVEL; + } + /** 内置字段的默认定义(深拷贝:调用方可能要改,不能污染常量)。 */ public static List defaults() { List copy = new ArrayList<>(); diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/controller/LogController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/controller/LogController.java new file mode 100644 index 0000000..00a98cc --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/controller/LogController.java @@ -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)。 + * + *

权限用 {@code log:viewTenant}(本租户操作日志)。 + * 平台运维要看全量日志时用 {@code log:viewAll} —— 那是另一个接口(P2)。 + * 日志里含**别人的操作轨迹与 IP**,不是所有管理员都该看到。 + * + *

⚠️ 一期只做**查询**,不做导出:把操作日志导出成一个 Excel + * 就等于把"谁在什么时候看了谁的身份证"这份名单落到本地磁盘上。 + * 真要做导出,得先有"导出日志本身也要留痕"的机制(否则循环)。 + */ +@Tag(name = "日志") +@RestController +@RequestMapping("/api/v1/logs") +@RequiredArgsConstructor +public class LogController { + + private final OperationLogMapper operationLogMapper; + private final LoginLogMapper loginLogMapper; + + @GetMapping("/operations") + @RequirePerm(PermissionDef.LOG_VIEW_TENANT) + @Operation(summary = "操作日志", + description = "Query:`operatorId` `module` `action` `targetType` `targetId` `from` `to` + 分页。" + + "**按 targetType+targetId 查**是这个接口最重要的用法 —— " + + "「这张名册项的证件号被谁从什么改成了什么」要靠它回答," + + "而不是拿 request_param 全文搜(那会把「改了它」和「顺带提到它」混在一起)") + public ApiResult> 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 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)); + } + + /** + * 日期入参解析。 + * + *

⚠️ {@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> 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 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)); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/dto/LogDtos.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/dto/LogDtos.java new file mode 100644 index 0000000..2df49a3 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/dto/LogDtos.java @@ -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 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 list = new ArrayList<>(); + + private long total; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/entity/SysOperationLog.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/entity/SysOperationLog.java index 82bb30c..1b781ec 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/entity/SysOperationLog.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/entity/SysOperationLog.java @@ -9,11 +9,25 @@ import lombok.Setter; import java.time.LocalDateTime; /** - * 操作日志(对应 {@code sys_operation_log})。只增不改,理由同 {@link SysLoginLog}。 + * 操作日志({@code sys_operation_log})。 * - *

PRD §10.3 要求记录:登录、查看明文、下载、导出、代传、删除。 - * 「查看明文」单独记一条是刻意的——身份证号明文是被内部人泄露的首要风险点, - * 有这个日志才能回答"谁在什么时候看了谁的材料"。 + *

为什么有 request_param 还要 target_type / target_id / before_val / after_val

+ * + *

两者回答的是**不同的问题**: + *

    + *
  • {@code request_param}:排查用 —— "那次请求到底传了什么";
  • + *
  • {@code target_type/target_id/before_val/after_val}:**合规用** —— + * "这张名册项的证件号被谁从什么改成了什么"、"谁看过这个学生的完整身份证号"。
  • + *
+ * 后者如果只能靠 {@code request_param LIKE '%9012%'} 去搜, + * 就会把"改了 9012"和"顺带提到了 9012"混在一起,而合规查询要的是前者。 + * 这就是 V9 补这四列的原因。 + * + *

⚠️ {@code beforeVal}/{@code afterVal} 只许放脱敏值或摘要 + * (契约 §13.4 明确要求)。写入口 {@code OperationLogService} 统一兜底 —— + * 日志泄露不该比业务库泄露更严重。 + * + *

只增不改(同 {@link SysLoginLog}):没有任何地方应该 UPDATE / DELETE 这张表。 */ @Getter @Setter @@ -29,10 +43,16 @@ public class SysOperationLog { private String username; - /** 业务模块,如 {@code tenant} / {@code org} / {@code user} / {@code task}。 */ + /** 业务模块,如 {@code ROSTER} / {@code TASK} / {@code AUDIT} / {@code EXPORT}。 */ private String module; - /** 动作,如 {@code create} / {@code update} / {@code delete} / {@code export} / {@code approve}。 */ + /** + * 动作。敏感动作见契约 §13.4 的表: + * {@code UPDATE_IDCARD} / {@code UPDATE_ROSTER_NAME} / {@code DELETE_ROSTER} / + * {@code PUBLISH_TASK} / {@code PAUSE_TASK} / {@code ARCHIVE_TASK} / + * {@code REVOKE_AUDIT} / {@code EXPORT_CREATE} / {@code PROXY_UPLOAD} / + * {@code VIEW_IDCARD_FULL} / {@code CONFIG_COMPRESS}。 + */ private String action; private String requestMethod; @@ -40,10 +60,10 @@ public class SysOperationLog { private String requestUri; /** - * 请求参数。 + * 请求参数(原始上下文)。 * *

⚠️ 入库前必须脱敏:去掉 {@code password}、证件号明文等。 - * 操作日志的目的是追责,不是复制一份业务数据——把敏感信息抄进来 + * 操作日志的目的是追责,不是复制一份业务数据 —— 把敏感信息抄进来 * 等于给系统加了一个无鉴权的高危数据副本。 */ private String requestParam; @@ -60,5 +80,21 @@ public class SysOperationLog { private String traceId; + // ── V9 补的结构化留痕字段 ────────────────────── + + /** {@code ROSTER} / {@code TASK} / {@code SUBMISSION} / {@code FILE} / {@code EXPORT} / … */ + private String targetType; + + private Long targetId; + + /** 变更前值(脱敏)。 */ + private String beforeVal; + + /** 变更后值(脱敏)。 */ + private String afterVal; + + /** 补充上下文(JSON)。 */ + private String extraJson; + private LocalDateTime createTime; } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/LoginLogMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/LoginLogMapper.java index a9c2989..5c10f51 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/LoginLogMapper.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/LoginLogMapper.java @@ -1,10 +1,16 @@ package cn.tianbaotong.fill.module.log.mapper; +import cn.tianbaotong.fill.module.log.dto.LogDtos; import cn.tianbaotong.fill.module.log.entity.SysLoginLog; import com.baomidou.mybatisplus.annotation.InterceptorIgnore; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.time.LocalDateTime; +import java.util.List; public interface LoginLogMapper extends BaseMapper { @@ -28,4 +34,90 @@ public interface LoginLogMapper extends BaseMapper { """) @Options(useGeneratedKeys = true, keyProperty = "id") int insertIgnoreTenant(SysLoginLog log); + + /** + * 登录日志分页(§13.4)。 + * + *

⚠️ 登录日志表里 {@code success} 是 {@code smallint}(0/1), + * 而接口对外是布尔 {@code result} —— 转换在 SQL 里做, + * 让"哪个值算成功"只定义一次。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + + """) + List 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(""" + + """) + 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); } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/OperationLogMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/OperationLogMapper.java index 4d4cf4d..841166e 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/OperationLogMapper.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/OperationLogMapper.java @@ -1,7 +1,135 @@ package cn.tianbaotong.fill.module.log.mapper; +import cn.tianbaotong.fill.module.log.dto.LogDtos; import cn.tianbaotong.fill.module.log.entity.SysOperationLog; +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 操作日志(《03-接口设计》§13.4)。 + * + *

查询走手写 SQL 并 {@code @InterceptorIgnore} + 显式 {@code tenant_id}: + * 靠拦截器隐式补条件会让"查不到数据"和"确实没有数据"分不清, + * 而日志查询恰恰是要能对客户说清"这段时间有没有人动过这张名册"的场景。 + */ public interface OperationLogMapper extends BaseMapper { + + /** + * 写一条操作日志(跳过租户拦截)。 + * + *

理由同 {@code LoginLogMapper.insertIgnoreTenant}:敏感动作有时发生在 + * 上下文不完整的时候(异步导出线程、定时任务),靠拦截器补 {@code tenant_id} + * 会直接抛 40101 —— 于是"越需要留痕的场景越写不进日志"。 + * + *

风险可控:{@code tenantId} 由 {@code OperationLogService} 显式传入, + * 不依赖隐式上下文。 + */ + @InterceptorIgnore(tenantLine = "true") + @Insert(""" + INSERT INTO sys_operation_log + (tenant_id, user_id, username, module, action, request_method, request_uri, + request_param, biz_code, duration_ms, ip, user_agent, trace_id, + target_type, target_id, before_val, after_val, extra_json, create_time) + VALUES + (#{tenantId}, #{userId}, #{username}, #{module}, #{action}, #{requestMethod}, #{requestUri}, + #{requestParam}, #{bizCode}, #{durationMs}, #{ip}, #{userAgent}, #{traceId}, + #{targetType}, #{targetId}, #{beforeVal}, #{afterVal}, #{extraJson}, CURRENT_TIMESTAMP) + """) + @Options(useGeneratedKeys = true, keyProperty = "id") + int insertIgnoreTenant(SysOperationLog log); + + /** 操作日志分页查询。 */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + + """) + List 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(""" + + """) + 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); } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/service/OperationLogService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/service/OperationLogService.java new file mode 100644 index 0000000..42463c7 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/service/OperationLogService.java @@ -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)。 + * + *

为什么写日志要单独一个 Service,而不是注解 + AOP

+ * + *

注解式({@code @OperationLog})看起来优雅,但对**合规留痕**这个用途有两个硬伤: + *

    + *
  1. 它记不到"变更前后的值":"证件号从 A 改成 B"里的 A 只有业务方法知道, + * 切面拿不到(除非把返回值再解析一遍,那就更脆);
  2. + *
  3. 它的失败是静默的:留痕失败和业务成功会长得一样。 + * 而"改名册证件号"这类动作**必须有痕迹** —— + * 没有痕迹就该让人知道(至少进日志告警),而不是悄悄过去。
  4. + *
+ * 所以这里用**显式调用**:业务方法自己说"我改了什么、从什么改成什么"。 + * 代价是多一行代码,换来的是留痕内容准确、失败可见。 + * + *

脱敏是这一层的责任,不是调用方的

+ * + *

契约 §13.4 要求 {@code beforeVal}/{@code afterVal} 里的证件号**一律脱敏**。 + * 把这件事放在这里做(而不是要求每个调用方自觉),是因为"忘记脱敏"的后果 + * 与"多脱敏一次"完全不对称:前者往库里塞了一份明文身份证号,后者只是少看到几位。 + * 所以这里**主动扫一遍**:任何看起来像 18 位身份证号的串都会被替换成脱敏形式。 + * + *

写日志绝不能让业务失败

+ * + *

{@code record()} **不抛异常**。理由:留痕失败是运维问题, + * 而业务失败是用户问题 —— 让用户为运维问题买单(点保存报错)是不可接受的。 + * 但**日志级别是 WARN 而不是 DEBUG**:留痕失败必须能在生产日志里看见。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OperationLogService { + + /** 18 位身份证号(末位可能是 X)—— 用于兜底脱敏。 */ + private static final Pattern ID_CARD = Pattern.compile("\\b(\\d{6})(\\d{8})(\\d{3})([\\dXx])\\b"); + + /** 手机号。 */ + private static final Pattern PHONE = Pattern.compile("\\b(1[3-9]\\d)(\\d{4})(\\d{4})\\b"); + + private final OperationLogMapper operationLogMapper; + private final SensitiveCodec codec; + + // ── 敏感动作的常量(契约 §13.4 的表,别自造名字)── + + public static final String MODULE_ROSTER = "ROSTER"; + public static final String MODULE_TASK = "TASK"; + public static final String MODULE_AUDIT = "AUDIT"; + public static final String MODULE_EXPORT = "EXPORT"; + public static final String MODULE_JOIN = "JOIN"; + public static final String MODULE_USER = "USER"; + public static final String MODULE_FILE = "FILE"; + + public static final String ACTION_UPDATE_IDCARD = "UPDATE_IDCARD"; + public static final String ACTION_UPDATE_ROSTER_NAME = "UPDATE_ROSTER_NAME"; + public static final String ACTION_DELETE_ROSTER = "DELETE_ROSTER"; + public static final String ACTION_PUBLISH_TASK = "PUBLISH_TASK"; + public static final String ACTION_PAUSE_TASK = "PAUSE_TASK"; + /** 恢复任务。⚠️ 契约 §13.4 的敏感动作表里没有它,但它必须与"发布"分开记 —— + * "恢复"意味着这个任务曾经被暂停过,而"发布"是第一次开始收集。 */ + public static final String ACTION_RESUME_TASK = "RESUME_TASK"; + /** 截止任务。同理:契约表里没有,但它是不可逆的时点,必须留痕。 */ + public static final String ACTION_CLOSE_TASK = "CLOSE_TASK"; + public static final String ACTION_ARCHIVE_TASK = "ARCHIVE_TASK"; + public static final String ACTION_REVOKE_AUDIT = "REVOKE_AUDIT"; + public static final String ACTION_EXPORT_CREATE = "EXPORT_CREATE"; + public static final String ACTION_EXPORT_DOWNLOAD = "EXPORT_DOWNLOAD"; + public static final String ACTION_PROXY_UPLOAD = "PROXY_UPLOAD"; + public static final String ACTION_VIEW_IDCARD_FULL = "VIEW_IDCARD_FULL"; + public static final String ACTION_CONFIG_COMPRESS = "CONFIG_COMPRESS"; + public static final String ACTION_JOIN_APPROVE = "JOIN_APPROVE"; + public static final String ACTION_JOIN_REJECT = "JOIN_REJECT"; + + public static final String TARGET_ROSTER = "ROSTER"; + public static final String TARGET_TASK = "TASK"; + public static final String TARGET_SUBMISSION = "SUBMISSION"; + public static final String TARGET_FILE = "FILE"; + public static final String TARGET_EXPORT = "EXPORT"; + public static final String TARGET_JOIN_REQUEST = "JOIN_REQUEST"; + public static final String TARGET_USER = "USER"; + + /** + * 记一条操作日志(**不抛异常**)。 + * + * @param module 业务模块,用本类的 {@code MODULE_*} 常量 + * @param action 动作,用本类的 {@code ACTION_*} 常量 + * @param targetType 对象类型 + * @param targetId 对象主键(合规查询靠它) + * @param beforeVal 变更前值(**本方法会再脱敏一次**) + * @param afterVal 变更后值 + */ + public void record(String module, String action, String targetType, Long targetId, + String beforeVal, String afterVal) { + record(module, action, targetType, targetId, beforeVal, afterVal, null); + } + + /** + * 记一条操作日志(带补充上下文)。 + * + * @param extraJson 补充上下文(JSON 串),如批量操作命中的 id 列表 + */ + public void record(String module, String action, String targetType, Long targetId, + String beforeVal, String afterVal, String extraJson) { + try { + SysOperationLog logRow = new SysOperationLog(); + // 上下文可能不完整(异步线程/匿名),所以不 require(),拿不到就记 0 + Long tenantId = safeTenantId(); + Long uid = safeUid(); + logRow.setTenantId(tenantId == null ? 0L : tenantId); + logRow.setUserId(uid); + logRow.setUsername(safeUsername()); + logRow.setModule(module); + logRow.setAction(action); + logRow.setTargetType(targetType); + logRow.setTargetId(targetId); + logRow.setBeforeVal(mask(beforeVal)); + logRow.setAfterVal(mask(afterVal)); + logRow.setExtraJson(extraJson); + logRow.setBizCode(0); + fillRequestContext(logRow); + operationLogMapper.insertIgnoreTenant(logRow); + log.debug("操作日志已记:{} {} target={}#{}", module, action, targetType, targetId); + } catch (RuntimeException e) { + // ⚠️ WARN 而不是 DEBUG:留痕失败必须能在生产日志里看见。 + // 但也绝不抛出 —— 用户不该为运维问题买单(点保存报错)。 + log.warn("写操作日志失败(业务不受影响):{} {} target={}#{} 原因={}", + module, action, targetType, targetId, e.toString()); + } + } + + /** + * 脱敏:把任何看起来像身份证号/手机号的串替换掉,并截断到列宽。 + * + *

⚠️ 这是**兜底**,不是唯一防线:调用方本该传脱敏值。 + * 两层都做,是因为"忘记脱敏"的后果(库里多一份明文身份证号)不可逆。 + */ + String mask(String raw) { + if (raw == null || raw.isBlank()) { + return raw; + } + String s = ID_CARD.matcher(raw).replaceAll(m -> m.group(1) + "********" + m.group(3) + m.group(4)); + s = PHONE.matcher(s).replaceAll(m -> m.group(1) + "****" + m.group(3)); + return s.length() <= 512 ? s : s.substring(0, 512); + } + + /** 证件号 → 脱敏值(给调用方用的显式入口,比让人自己拼更不容易错)。 */ + public String maskIdCard(String plainOrMasked) { + return mask(plainOrMasked); + } + + /** 算一下证件号的 hash(要显示"改成了哪个号"但不想落明文时,用 hash 前 8 位做指纹)。 */ + public String fingerprint(String plainIdCard) { + if (plainIdCard == null || plainIdCard.isBlank()) { + return null; + } + String h = codec.hash(plainIdCard); + return h == null ? null : "…" + h.substring(Math.max(0, h.length() - 8)); + } + + // ── 上下文 ──────────────────────────────────── + + private Long safeTenantId() { + try { + return UserContext.tenantId(); + } catch (RuntimeException e) { + return null; + } + } + + private Long safeUid() { + try { + return UserContext.uid(); + } catch (RuntimeException e) { + return null; + } + } + + private String safeUsername() { + try { + // LoginUser 里**没有** username(只有 uid/tenantId/orgIds/roles/userType), + // 所以这里不强求写名字:留空即可,查询时会 JOIN sys_user 取 real_name。 + // 早期登录日志里写的 username 是登录名,两者口径不同,不要混。 + return null; + } catch (RuntimeException e) { + return null; + } + } + + /** 从当前请求里取 IP / UA / 方法 / URI(不在请求线程里时全部留空)。 */ + private void fillRequestContext(SysOperationLog row) { + try { + // traceId 由 TraceIdFilter 放进 MDC(**不是** request attribute), + // 所以这里从 MDC 取 —— 写错取法会得到一个永远为空的 traceId, + // 而"日志里没有 traceId"会让排查时无法把操作与请求对上。 + String traceId = org.slf4j.MDC.get("traceId"); + if (traceId != null && !traceId.isBlank()) { + row.setTraceId(traceId); + } + ServletRequestAttributes attrs = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attrs == null) { + return; + } + HttpServletRequest req = attrs.getRequest(); + row.setRequestMethod(req.getMethod()); + row.setRequestUri(clip(req.getRequestURI(), 512)); + // 复用 WebUtil(登录日志也用它):两处各写一套 IP 解析必然分叉, + // 而"同一个请求在两份日志里 IP 不一样"会让排查直接卡住 + row.setIp(clip(WebUtil.clientIp(req), 64)); + row.setUserAgent(clip(WebUtil.userAgent(req), 512)); + } catch (RuntimeException e) { + // 拿不到请求上下文不影响留痕本身(module/action/target 才是合规要点) + log.debug("取请求上下文失败(不影响留痕):{}", e.toString()); + } + } + + private String clip(String s, int max) { + if (s == null) { + return null; + } + return s.length() <= max ? s : s.substring(0, max); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/controller/RosterController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/controller/RosterController.java index aa09c8e..68643b9 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/controller/RosterController.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/controller/RosterController.java @@ -2,8 +2,10 @@ package cn.tianbaotong.fill.module.roster.controller; import cn.tianbaotong.fill.common.api.ApiResult; import cn.tianbaotong.fill.common.api.PageResult; +import cn.tianbaotong.fill.module.roster.dto.RosterEditDtos; import cn.tianbaotong.fill.module.roster.dto.RosterImportReport; import cn.tianbaotong.fill.module.roster.dto.RosterVO; +import cn.tianbaotong.fill.module.roster.service.RosterEditService; import cn.tianbaotong.fill.module.roster.service.RosterImportService; import cn.tianbaotong.fill.module.roster.service.RosterService; import cn.tianbaotong.fill.security.PermissionDef; @@ -12,9 +14,12 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -26,6 +31,9 @@ import org.springframework.web.multipart.MultipartFile; *

权限口径与契约一致:导入是管理动作({@code roster:manage}), * 查看是管理 + 教师({@code roster:view})—— 班主任要看得到自己班的名单, * 但不该能把整份名册换掉。 + * + *

修改与删除也是 {@code roster:manage}:它们能改的是**认定资格的基准数据** + * (姓名、证件号、报考三项),改错一个字段就会让一个学生的认定结论站不住。 */ @Tag(name = "名册") @RestController @@ -33,8 +41,11 @@ import org.springframework.web.multipart.MultipartFile; @RequiredArgsConstructor public class RosterController { + private final cn.tianbaotong.fill.module.roster.service.RosterTemplateService templateService; + private final RosterImportService importService; private final RosterService rosterService; + private final RosterEditService rosterEditService; @PostMapping(value = "/tasks/{id}/roster/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @RequirePerm(PermissionDef.ROSTER_MANAGE) @@ -68,4 +79,64 @@ public class RosterController { return ApiResult.ok(rosterService.list(id, keyword, orgId, submitStatus, bindStatus, pageNum, pageSize)); } + + @PutMapping("/roster/{rid}") + @RequirePerm(PermissionDef.ROSTER_MANAGE) + @Operation(summary = "修改名册项", + description = "**只改传了的字段**。⚠️ 不要把列表里那一行整行回传 —— " + + "列表的证件号是**脱敏值**,覆盖式更新会把真号写成星号," + + "那个学生从此过不了门禁(而且谁也不会想到是「保存一次」导致的)。" + + "改证件号会强制留痕(`UPDATE_IDCARD`,PRD FR-506)," + + "日志里记的是脱敏值 + 指纹,不记明文") + public ApiResult 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 delete(@PathVariable Long rid) { + rosterEditService.delete(rid); + return ApiResult.ok(); + } + + @PostMapping("/roster/{rid}/deactivate") + @RequirePerm(PermissionDef.ROSTER_MANAGE) + @Operation(summary = "标记「不参与本次认定」", + description = "删除的**可逆替代品**:软删 + 解绑,并记操作日志。" + + "他不会出现在未提交清单与催报名单里," + + "而「他一开始在名单里」这个事实仍然保留(导出时能解释名单为什么少了一个人)") + public ApiResult 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 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); + } } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/dto/RosterEditDtos.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/dto/RosterEditDtos.java new file mode 100644 index 0000000..7066cd9 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/dto/RosterEditDtos.java @@ -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() { + } + + /** + * 修改名册项。 + * + *

⚠️ 全部字段**选填**:只传要改的那些。用"整行覆盖"的写法(前端把整行传回来) + * 会把列表里显示不到的字段(例如证件号的密文列)一起冲掉 —— + * 而名册列表返回的证件号本来就是**脱敏值**,回传过来会把真号覆盖成星号。 + * 这类事故一旦发生,那个学生就再也过不了门禁了。 + */ + @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 rosterIds; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/mapper/CollectRosterMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/mapper/CollectRosterMapper.java index de10bab..340c4a4 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/mapper/CollectRosterMapper.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/mapper/CollectRosterMapper.java @@ -101,6 +101,25 @@ public interface CollectRosterMapper extends BaseMapper { """) long countOrg(@Param("tenantId") Long tenantId, @Param("orgId") Long orgId); + /** + * 按组织名找 id(加入申请审批时拿学生的"自述班级"去匹配)。 + * + *

返回**列表**而不是单个 id:同名班级(不同年级/专业下都叫"1 班")是常见的, + * 这时不能猜 —— 调用方看到 >1 条就要求管理员手选。 + * 匹配用"包含"而不是"相等":学生自述常写成"24中技数控2班", + * 而组织名可能是"24中技数控2班(2024级)"。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT id FROM sys_org + WHERE tenant_id = #{tenantId} AND deleted = 0 AND status <> 0 + AND (org_name = #{name} OR org_name LIKE '%' || #{name} || '%' + OR #{name} LIKE '%' || org_name || '%') + ORDER BY id + LIMIT 5 + """) + List selectOrgIdsByName(@Param("tenantId") Long tenantId, @Param("name") String name); + /** * 名册列表要显示班级名 —— 一次取全租户的组织(一所学校几十个节点), * 在内存里做映射,避免"每行查一次"的 N+1。 diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/service/RosterEditService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/service/RosterEditService.java new file mode 100644 index 0000000..cc1c7da --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/service/RosterEditService.java @@ -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)。 + * + *

这个类里最重要的两件事,都不是"怎么改"

+ * + *
    + *
  1. 证件号变更必须留痕(PRD FR-506,契约 §13.4 的 {@code UPDATE_IDCARD})。 + * 留痕内容里放的是**脱敏值 + 指纹**,不放明文 —— + * 日志泄露不该比业务库泄露更严重。指纹(hash 末 8 位)的用处是: + * 能确认"确实换成了另一个号",而不是"改成了同一个号",同时不暴露号码。
  2. + *
  3. 已绑定/已提交的名册项不允许删除(契约 §8.4 的 {@code 40029})。 + * 不是"删不掉就算成功" —— 这时**改为标记"不参与本次认定"**并如实告诉调用方 + * 走的是哪条路({@code deactivated=true})。直接删掉会让那个学生填报过的材料 + * 变成孤儿,而"不参与"是可逆的。
  4. + *
+ * + *

⚠️ 另一个容易出事的点:更新一律用"只改传了的字段"。 + * 名册列表返回的证件号是**脱敏值**,如果前端把整行回传(很自然的做法), + * 覆盖式更新会把真号写成 {@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.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)。 + * + *

已绑定或已提交的**不允许删除**({@code 40029})。 + * 但那不是"什么都做不了":调用方可以改用 + * {@link #deactivate(Long)} 标记"不参与本次认定"。 + * 两个动作的差别是**可逆性** —— 删掉会让已填材料变孤儿,标记则可逆。 + */ + @Transactional(rollbackFor = Exception.class) + public void delete(Long rosterId) { + LoginUser me = UserContext.require(); + CollectRoster r = rosterMapper.selectById(rosterId); + if (r == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "名册项不存在或已删除"); + } + if (r.getBindUserId() != null) { + // ⚠️ 必须带上自定义 message:只抛错误码的话,前端只会显示 + // 「名册项已绑定或已提交,不可删除」—— 用户看到"不行"却不知道**该做什么**。 + // 真正可用的替代动作是"标记不参与本次认定",这句话得说出来。 + throw new BizException(ErrorCode.ROSTER_ITEM_IN_USE, + "这个学生已经核对过身份(已绑定),不能删除;如确需移出请标记「不参与本次认定」"); + } + if (r.getSubmitStatus() != null && r.getSubmitStatus() > 0) { + throw new BizException(ErrorCode.ROSTER_ITEM_IN_USE, + "这个学生已经开始填报(或已提交),不能删除;如确需移出请标记「不参与本次认定」"); + } + // 软删除(deleted = 1) + rosterMapper.deleteById(rosterId); + operationLog.record(OperationLogService.MODULE_ROSTER, + OperationLogService.ACTION_DELETE_ROSTER, + OperationLogService.TARGET_ROSTER, rosterId, + r.getRealName(), null, + "{\"orgId\":" + r.getOrgId() + ",\"taskId\":" + r.getTaskId() + "}"); + log.info("名册删除:roster={} 姓名={} 经办人={}", rosterId, r.getRealName(), me.uid()); + } + + /** + * 标记"不参与本次认定"(§8.4 的替代动作)。 + * + *

实现方式是软删除 + 把扩展字段里打一个标记 —— 不做硬删, + * 因为"这个人一开始在名单里"本身就是需要保留的事实 + * (否则导出时无法解释"为什么名单上少了一个人")。 + */ + @Transactional(rollbackFor = Exception.class) + public RosterEditDtos.RosterDeleteVO deactivate(Long rosterId) { + loginRequired(); + CollectRoster r = rosterMapper.selectById(rosterId); + if (r == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "名册项不存在或已删除"); + } + var u = Wrappers.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); + } + + /** + * 脱敏值 → 指纹。 + * + *

留痕时**不记号码本身**(哪怕脱敏的),只记它的 hash 末 8 位: + * 这样能回答"确实换成了另一个号"(两次指纹不同), + * 同时不给出任何可用于猜号的信息。 + * 传入的可能是脱敏值({@code 361029********1234})—— 那也算一种"指纹", + * 但语义弱一些;调用方传明文时得到的指纹更强。 + */ + private String fingerprintOf(String maskOrPlain) { + if (maskOrPlain == null || maskOrPlain.isBlank()) { + return null; + } + return operationLog.fingerprint(maskOrPlain); + } +} diff --git a/backend/xt-server/src/main/resources/db/migration/V10__dict.sql b/backend/xt-server/src/main/resources/db/migration/V10__dict.sql new file mode 100644 index 0000000..f6a16a1 --- /dev/null +++ b/backend/xt-server/src/main/resources/db/migration/V10__dict.sql @@ -0,0 +1,78 @@ +-- ============================================================================ +-- V10:数据字典(《03-接口设计》§13.3) +-- +-- 为什么内置字典**不进这张表** +-- --------------------------------------------------------------------------- +-- 契约 §13.3 把字典分成两类,而这个区分是刻意的: +-- +-- · **内置字典**(`builtin: true`):EDU_LEVEL / JOB_LEVEL / CERT_TYPE / +-- RETARGET_SUBJECT / REJECT_REASON 五项。它们的选项**照抄官方表格**, +-- 不许擅改(`BuiltinFields` 的类注释里写了这条)。所以它们定义在**代码里** —— +-- 和权限码同样的思路:能改的放库里,不能改的放代码里,改它必须走一次发布。 +-- 放进表里会带来一个具体风险:有人从界面上把"四级/中级工"改名成"中级", +-- 导出的汇总表就与官方模板对不上了,而那时没有任何地方会报错。 +-- +-- · **可维护的字典**:客户填表时遇到的内置项不够用(例如多了个"技校"学历), +-- 由管理员自己加。这类进 `sys_dict_item` —— 它们**允许**被改, +-- 因为它们是客户自己的口径,不是官方的。 +-- +-- OCCUPATION(职业/工种)是第三种情况:它**来自 `sys_occupation_ref`** +-- (国标职业库,有自己的层级与别名),既不进代码也不进这张表。 +-- 三处来源的取舍都写在 `DictCatalog` 的注释里。 +-- ============================================================================ + +-- 自定义字典项(含对内置字典的"追加") +CREATE TABLE IF NOT EXISTS sys_dict_item ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + -- 字典码,如 EDU_LEVEL / JOB_LEVEL / CERT_TYPE + dict_code VARCHAR(64) NOT NULL, + item_code VARCHAR(64) NOT NULL, + item_name VARCHAR(128) NOT NULL, + -- 层级(如职业-工种):指向同字典内另一个 item_code + parent_item_code VARCHAR(64), + sort_no INTEGER NOT NULL DEFAULT 0, + -- 0 停用 / 1 启用 + status SMALLINT NOT NULL DEFAULT 1, + -- true = 这个项是对**内置字典**的追加(内置项本身仍在代码里) + builtin_extend SMALLINT NOT NULL DEFAULT 0, + create_by BIGINT, + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by BIGINT, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); + +COMMENT ON TABLE sys_dict_item IS '自定义字典项(内置字典的选项在代码里,见 DictCatalog;OCCUPATION 来自 sys_occupation_ref)'; +COMMENT ON COLUMN sys_dict_item.dict_code IS '字典码:EDU_LEVEL/JOB_LEVEL/CERT_TYPE/RETARGET_SUBJECT/REJECT_REASON/OCCUPATION + 客户自定义'; +COMMENT ON COLUMN sys_dict_item.item_code IS '项码。⚠️ **不许改**(改了会破坏历史数据里已经存下的值)'; +COMMENT ON COLUMN sys_dict_item.parent_item_code IS '层级父项(如职业-工种);空表示顶层'; +COMMENT ON COLUMN sys_dict_item.builtin_extend IS '1 = 对内置字典的追加项(内置项本身不出来在这里)'; + +-- 同一个字典里项码唯一(软删的不算)—— 手工建一条部分唯一索引: +-- 普通 UNIQUE 会把软删的行也算进去,于是"删过再想加回来"会失败, +-- 而用户看到的是"这个编码已存在",但他明明刚删掉它 +CREATE UNIQUE INDEX IF NOT EXISTS uk_dict_item_code + ON sys_dict_item (tenant_id, dict_code, item_code) + WHERE deleted = 0; + +CREATE INDEX IF NOT EXISTS idx_dict_item_code + ON sys_dict_item (tenant_id, dict_code, status, sort_no); + +-- 内置字典的"停用单项"(契约 §13.3:内置字典不可删,只可停用单项)。 +-- ⚠️ 不复用 sys_dict_item.status:那张表是"客户自己加的项", +-- 而这里是"官方项被客户停用了"。混在一起之后, +-- "恢复默认"就没法做了(分不清哪些是官方项、哪些是客户加的)。 +CREATE TABLE IF NOT EXISTS sys_dict_disabled ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + dict_code VARCHAR(64) NOT NULL, + item_code VARCHAR(64) NOT NULL, + create_by BIGINT, + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE sys_dict_disabled IS '被停用的**内置**字典项(契约 §13.3:内置字典不可删、只可停用单项)'; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_dict_disabled + ON sys_dict_disabled (tenant_id, dict_code, item_code); diff --git a/backend/xt-server/src/main/resources/db/migration/V9__operation_log_audit_columns.sql b/backend/xt-server/src/main/resources/db/migration/V9__operation_log_audit_columns.sql new file mode 100644 index 0000000..064e853 --- /dev/null +++ b/backend/xt-server/src/main/resources/db/migration/V9__operation_log_audit_columns.sql @@ -0,0 +1,42 @@ +-- ============================================================================ +-- V9:操作日志补审计列(结构化留痕) +-- +-- 为什么不能只用 request_param / biz_code 凑合 +-- --------------------------------------------------------------------------- +-- 契约 §13.4 的响应体里,操作日志**有独立的业务字段**: +-- { module, action, targetType, targetId, beforeVal, afterVal, ip, userAgent } +-- 而 V1 建的 sys_operation_log 只有 request_uri / request_param / biz_code —— +-- 它可以回答"谁在什么时候调了哪个接口",但**回答不了**这两个问题: +-- +-- ① 「**这张名册项**的证件号被谁从什么改成了什么」—— +-- request_param 里是一整坨 JSON,按 target_id 查要全表 LIKE, +-- 而「改名册证件号留痕」(PRD FR-506)是**必须能按对象查**的合规要求; +-- ② 「谁看过**这个学生**的完整身份证号」—— +-- 同上,全文检索撑不住,而这恰恰是 PRD §10.3 点名的高危动作。 +-- +-- 所以补 4 列结构化字段,并把 request_param 保留为"原始上下文" +-- (排查时看它,合规查询看新列)。两者分工不同,不是冗余。 +-- +-- before_val / after_val 里**只许放脱敏值或摘要**(契约 §13.4 明确要求): +-- 日志泄露不该比业务库泄露更严重。写入侧在 OperationLogService 里统一兜底。 +-- ============================================================================ + +ALTER TABLE sys_operation_log ADD COLUMN IF NOT EXISTS target_type VARCHAR(32); +ALTER TABLE sys_operation_log ADD COLUMN IF NOT EXISTS target_id BIGINT; +ALTER TABLE sys_operation_log ADD COLUMN IF NOT EXISTS before_val VARCHAR(512); +ALTER TABLE sys_operation_log ADD COLUMN IF NOT EXISTS after_val VARCHAR(512); +ALTER TABLE sys_operation_log ADD COLUMN IF NOT EXISTS extra_json TEXT; + +COMMENT ON COLUMN sys_operation_log.target_type IS '被操作对象的类型:ROSTER/TASK/SUBMISSION/FILE/EXPORT/JOIN_REQUEST/USER/ORG'; +COMMENT ON COLUMN sys_operation_log.target_id IS '被操作对象的主键 —— 「这个对象的变更历史」靠它查,不能靠 request_param 全文搜'; +COMMENT ON COLUMN sys_operation_log.before_val IS '变更前值(**只放脱敏值或摘要**:日志泄露不该比业务库更严重)'; +COMMENT ON COLUMN sys_operation_log.after_val IS '变更后值(同上,只放脱敏值)'; +COMMENT ON COLUMN sys_operation_log.extra_json IS '补充上下文(JSON),如批量操作命中的 id 列表'; + +-- 「这个对象被谁改过」是最高频的合规查询 +CREATE INDEX IF NOT EXISTS idx_oplog_target + ON sys_operation_log (tenant_id, target_type, target_id, create_time DESC); + +-- 「谁看了明文证件号」按人+动作查 +CREATE INDEX IF NOT EXISTS idx_oplog_action + ON sys_operation_log (tenant_id, module, action, create_time DESC); diff --git a/frontend-pc/src/apis/xt/dict.ts b/frontend-pc/src/apis/xt/dict.ts new file mode 100644 index 0000000..218ccbc --- /dev/null +++ b/frontend-pc/src/apis/xt/dict.ts @@ -0,0 +1,104 @@ +/** + * 字典管理(《03-接口设计》§13.3)。 + * + * ## 三种来源,界面上必须让用户分得清 + * + * | 来源 | `builtin` | 能做什么 | + * |---|---|---| + * | 内置项(代码里,照抄官方表格) | `true` 且**没有 id** | 只能**停用 / 恢复**,不能改名、不能删 | + * | 自定义项(客户自己加的) | `false` | 可改名、可停用、可删、可排序 | + * | 国标职业(`sys_occupation_ref`) | `true` 且**只读** | 什么都不能改(那是国标) | + * + * ⚠️ 内置项**没有 id** 这件事是界面上的一个具体分叉:对它的操作只能按 + * `itemCode` 走 `toggle` 接口,不能走 `PUT /dicts/items/{id}`。 + * 所以列表里"编辑/删除"按钮对内 builtin 行要换成"停用/恢复"。 + * + * ## 为什么内置项不能改名(这条一定要在界面上说) + * + * 因为内置选项**照抄官方表格**。有人把"四级/中级工"改成"中级"之后, + * 导出的汇总表就与官方模板对不上了 —— 而那时**没有任何地方会报错**。 + * 所以要改内置项名只能改代码发布;界面上的正确做法是**追加一个自定义项**。 + * + * ## 项码建后不可改 + * + * 历史数据里存的是项码。改了它,过去填过的值会指向一个不存在的项, + * 而查询**不报错、只显示空白** —— 这种问题只能靠"一开始就不允许改"来避免。 + * 所以修改接口的请求体里根本没有 `itemCode` 字段(不是靠界面藏起来)。 + */ +import http from '@/utils/http' + +export interface DictVO { + dictCode: string + dictName: string + /** ⚠️ 只算**启用中**的项 */ + itemCount: number + builtin: boolean +} + +export interface DictItemVO { + /** 内置项为 null(它在代码里,没有数据库行) */ + id: number | null + itemCode: string + itemName: string + parentItemCode?: string | null + sortNo?: number + /** 0 停用 / 1 启用 */ + status?: number + /** true = 内置项:不可改名、不可删,只能停用/恢复 */ + builtin?: boolean + createTime?: string +} + +export interface DictItemListVO { + dictCode: string + dictName: string + builtin: boolean + /** 这个字典是否支持层级(如职业-工种) */ + hierarchical: boolean + list: DictItemVO[] +} + +export function getDicts() { + return http.get('/dicts') +} + +export function getDictItems(code: string) { + return http.get(`/dicts/${code}/items`) +} + +export function createDictItem(code: string, data: { + itemCode: string + itemName: string + parentItemCode?: string + sortNo?: number +}) { + return http.post(`/dicts/${code}/items`, data) +} + +/** ⚠️ 请求体里**没有** itemCode —— 项码不可改是结构性的,不是界面藏起来的。 */ +export function updateDictItem(id: number, data: { + itemName?: string + sortNo?: number + status?: number +}) { + return http.put(`/dicts/items/${id}`, data) +} + +export function deleteDictItem(id: number) { + return http.delete(`/dicts/items/${id}`) +} + +/** 停用 / 恢复一个**内置**项。内置项没有 id,所以按 itemCode 走。 */ +export function toggleBuiltinItem(code: string, itemCode: string, enabled: boolean) { + return http.post( + `/dicts/${code}/items/${encodeURIComponent(itemCode)}/toggle?enabled=${enabled}`, + {} + ) +} + +/** 契约 §13.3 的约束文案(错误码 → 人话),界面直接用。 */ +export const DICT_ERROR_HINT: Record = { + 40036: '这一项下面还有子项,不能停用或删除。请先处理子项。', + 40001: '内置字典项不能改名或删除 —— 需要改内置项请改代码发布;' + + '如果只是想加一个值,用「新增项」追加即可。' +} diff --git a/frontend-pc/src/apis/xt/log.ts b/frontend-pc/src/apis/xt/log.ts new file mode 100644 index 0000000..58c8f67 --- /dev/null +++ b/frontend-pc/src/apis/xt/log.ts @@ -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('/logs/operations', params) +} + +export function getLoginLogs(params: { + userId?: number + userType?: number + result?: boolean + from?: string + to?: string + pageNum?: number + pageSize?: number +} = {}) { + return http.get('/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 = { + PASSWORD: '账号密码', + WECHAT: '微信授权', + PHONE: '手机号快捷' +} + +/** 需要重点看的动作(改证件号、看明文证件号)——列表里高亮。 */ +export const SENSITIVE_ACTIONS = ['UPDATE_IDCARD', 'VIEW_IDCARD_FULL', 'DELETE_ROSTER'] diff --git a/frontend-pc/src/views/dict/index.vue b/frontend-pc/src/views/dict/index.vue new file mode 100644 index 0000000..34d8c1f --- /dev/null +++ b/frontend-pc/src/views/dict/index.vue @@ -0,0 +1,445 @@ + + + + + diff --git a/frontend-pc/src/views/log/index.vue b/frontend-pc/src/views/log/index.vue new file mode 100644 index 0000000..f245c0e --- /dev/null +++ b/frontend-pc/src/views/log/index.vue @@ -0,0 +1,291 @@ + + + + +