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:
+11
@@ -54,6 +54,17 @@ public final class RejectReasonCatalog {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部原因码(字典管理页要用)。
|
||||
*
|
||||
* <p>⚠️ 返回 code 而不是文案:字典页面要列"有哪些原因可停用/追加",
|
||||
* 而审核页要按 code **校验** —— code 是这两处的公共键。
|
||||
* 文案在 {@link #list()} 里,两处引用同一份 {@code TEXTS},不复制。
|
||||
*/
|
||||
public static List<String> codes() {
|
||||
return ORDER;
|
||||
}
|
||||
|
||||
public static String textOf(String code) {
|
||||
return code == null ? null : TEXTS.get(code);
|
||||
}
|
||||
|
||||
+111
@@ -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;
|
||||
}
|
||||
}
|
||||
+246
@@ -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();
|
||||
}
|
||||
+103
@@ -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;
|
||||
}
|
||||
}
|
||||
+308
@@ -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();
|
||||
}
|
||||
}
|
||||
+16
@@ -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() {
|
||||
List<FormFieldVO> copy = new ArrayList<>();
|
||||
|
||||
+138
@@ -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;
|
||||
}
|
||||
}
|
||||
+44
-8
@@ -9,11 +9,25 @@ import lombok.Setter;
|
||||
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
|
||||
@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;
|
||||
|
||||
/**
|
||||
* 请求参数。
|
||||
* 请求参数(原始上下文)。
|
||||
*
|
||||
* <p>⚠️ <b>入库前必须脱敏</b>:去掉 {@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;
|
||||
}
|
||||
|
||||
+92
@@ -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<SysLoginLog> {
|
||||
|
||||
@@ -28,4 +34,90 @@ public interface LoginLogMapper extends BaseMapper<SysLoginLog> {
|
||||
""")
|
||||
@Options(useGeneratedKeys = true, keyProperty = "id")
|
||||
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 >= #{from}
|
||||
</if>
|
||||
<if test="to != null">
|
||||
AND l.create_time <= #{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 >= #{from}
|
||||
</if>
|
||||
<if test="to != null">
|
||||
AND l.create_time <= #{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);
|
||||
}
|
||||
|
||||
+128
@@ -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)。
|
||||
*
|
||||
* <p>查询走手写 SQL 并 {@code @InterceptorIgnore} + 显式 {@code tenant_id}:
|
||||
* 靠拦截器隐式补条件会让"查不到数据"和"确实没有数据"分不清,
|
||||
* 而日志查询恰恰是要能对客户说清"这段时间有没有人动过这张名册"的场景。
|
||||
*/
|
||||
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 >= #{from}
|
||||
</if>
|
||||
<if test="to != null">
|
||||
AND l.create_time <= #{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 >= #{from}
|
||||
</if>
|
||||
<if test="to != null">
|
||||
AND l.create_time <= #{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);
|
||||
}
|
||||
|
||||
+240
@@ -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);
|
||||
}
|
||||
}
|
||||
+71
@@ -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;
|
||||
* <p>权限口径与契约一致:<b>导入是管理动作</b>({@code roster:manage}),
|
||||
* <b>查看是管理 + 教师</b>({@code roster:view})—— 班主任要看得到自己班的名单,
|
||||
* 但不该能把整份名册换掉。
|
||||
*
|
||||
* <p>修改与删除也是 {@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<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);
|
||||
}
|
||||
}
|
||||
|
||||
+97
@@ -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;
|
||||
}
|
||||
}
|
||||
+19
@@ -101,6 +101,25 @@ public interface CollectRosterMapper extends BaseMapper<CollectRoster> {
|
||||
""")
|
||||
long countOrg(@Param("tenantId") Long tenantId, @Param("orgId") Long orgId);
|
||||
|
||||
/**
|
||||
* 按组织名找 id(加入申请审批时拿学生的"自述班级"去匹配)。
|
||||
*
|
||||
* <p>返回**列表**而不是单个 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<Long> selectOrgIdsByName(@Param("tenantId") Long tenantId, @Param("name") String name);
|
||||
|
||||
/**
|
||||
* 名册列表要显示班级名 —— 一次取全租户的组织(一所学校几十个节点),
|
||||
* 在内存里做映射,避免"每行查一次"的 N+1。
|
||||
|
||||
+301
@@ -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);
|
||||
@@ -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: '内置字典项不能改名或删除 —— 需要改内置项请改代码发布;'
|
||||
+ '如果只是想加一个值,用「新增项」追加即可。'
|
||||
}
|
||||
@@ -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']
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user