package jnpf.bizcommon.audit.service.value; import com.alibaba.fastjson.JSON; import jnpf.bizcommon.audit.entity.model.AuditFieldDiffViewVO; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.annotation.Order; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Component; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; /** * 把 {@code userSelect} 控件存的用户 id 解析成「真实姓名/账号」。 * *

为什么需要它:{@code userSelect} 的 valueType 是 {@code reference}(见 * {@link jnpf.bizcommon.audit.service.AuditFormRegistry} 的 valueType 映射),而 * {@link jnpf.bizcommon.audit.service.AuditFieldDiffViewService} 对没人认领的 reference 一律回落成 * 「名称暂不可用」。此前只有字典解析器和请验单专用解析器,用户 id 无人认领——2026-08-02 * 实测删除标题 {@code ys0001/ys0001修改下备注/名称暂不可用},最后那段就是提交人。 * 这是既有缺陷而不是删除场景引入的:以前标题只勾了编号所以没暴露,D6 让列表勾选 * (含提交人)进标题才第一次现形。 * *

展示格式跟随前端:{@code realName/account}(如「管理员/admin」)。平台自己在 * {@code FormInfoUtils}/{@code OnlineDevInfoUtils} 等十几处都是这个拼法,审计里换个写法 * 只会让人怀疑是不是另一个人。带上账号是审计的刚需——重名的两个人靠姓名分不开。 * *

不过滤 f_delete_mark / f_enabled_mark:审计追的是「当时是谁」。人离职了、账号 * 停用了、用户被删了,事件里那个 id 指向的仍然是同一个人,这时候把名字换成「名称暂不可用」 * 恰恰是把最需要追溯的那条记录变成不可追溯。租户边界仍然照约束——那是数据隔离,不是历史。 * *

与 {@link LimsQingyandanFieldValueResolver} 的分工:那个是表专用解析器({@code @Order} * 更靠前),{@code lims_qingyandan.qingyan_ren} 由它按自己的格式解析完,本解析器看到 * display 已经有值就不再插手({@code displaysResolved}),两者不会打架。 */ @Slf4j @Component @Order(JnpfUserFieldValueResolver.ORDER) public class JnpfUserFieldValueResolver implements AuditFieldValueResolver { /** 通用兜底解析器,排在表专用(100)与字典(200)之后。 */ public static final int ORDER = 300; private static final String UNAVAILABLE = "名称暂不可用"; /** 认领的控件类型(小写比对)。只认人员选择器,部门/角色/岗位各有各的名称表,不在本类范围。 */ private static final String USER_COMPONENT = "userselect"; private final UserLookup lookup; @Autowired public JnpfUserFieldValueResolver(JdbcTemplate jdbcTemplate) { this(new JdbcUserLookup(jdbcTemplate)); } JnpfUserFieldValueResolver(UserLookup lookup) { this.lookup = lookup; } @Override public boolean supports(String targetTable) { return true; // userSelect 是平台控件,哪张业务表都可能有 } @Override public void resolve(AuditFieldValueContext context, List fields) { if (context == null || blank(context.getTenantId()) || fields == null || fields.isEmpty()) { return; } Set userIds = new LinkedHashSet<>(); for (AuditFieldDiffViewVO field : fields) { if (!isUserField(field) || displaysResolved(field)) { continue; } userIds.addAll(parseValues(field.getOldData())); userIds.addAll(parseValues(field.getNewData())); } if (userIds.isEmpty()) { return; } // 整批一次 IN 查询(SPI 约定:层 0 跑在 MQ 消费线程上,逐 id 查会拖垮消费速率) Map names = lookup.names(context.getTenantId().trim(), userIds); for (AuditFieldDiffViewVO field : fields) { if (!isUserField(field)) { continue; } if (field.getOldDisplay() == null) { field.setOldDisplay(display(field.getOldData(), names)); } if (field.getNewDisplay() == null) { field.setNewDisplay(display(field.getNewData(), names)); } } } /** * 判据是控件类型而不是字段名:字段名是业务起的({@code tijiao_ren}/{@code fuhe_ren}/ * {@code qingyan_ren}…),穷举不完;{@code jnpfKey} 才是「这一列存的是用户 id」的权威声明。 */ private static boolean isUserField(AuditFieldDiffViewVO field) { return field != null && field.getComponentType() != null && USER_COMPONENT.equals(field.getComponentType().trim().toLowerCase(Locale.ROOT)); } /** 两侧要么本来就空、要么已被前序解析器填过,就不必再查库。 */ private static boolean displaysResolved(AuditFieldDiffViewVO field) { return (blank(field.getOldData()) || field.getOldDisplay() != null) && (blank(field.getNewData()) || field.getNewDisplay() != null); } private static String display(String raw, Map names) { if (blank(raw)) { return ""; } List ids = parseValues(raw); if (ids.isEmpty()) { return UNAVAILABLE; } List result = new ArrayList<>(ids.size()); for (String id : ids) { String name = names.get(id); result.add(blank(name) ? UNAVAILABLE : name); } return String.join("、", result); } /** 多选(multiple=true)的 userSelect 存的是 JSON 数组,单选存裸 id。 */ @SuppressWarnings("unchecked") private static List parseValues(String raw) { if (blank(raw)) { return Collections.emptyList(); } String trimmed = raw.trim(); if (!trimmed.startsWith("[")) { return Collections.singletonList(trimmed); } try { Object parsed = JSON.parse(trimmed); if (!(parsed instanceof Collection)) { return Collections.emptyList(); } List ids = new ArrayList<>(); for (Object value : (Collection) parsed) { if (value != null && !String.valueOf(value).trim().isEmpty()) { ids.add(String.valueOf(value).trim()); } } return ids; } catch (Throwable ignored) { return Collections.emptyList(); } } /** * 前端展示格式即 {@code realName/account}(如「管理员/admin」);缺一边时退化成还剩的 * 那一边,不留孤零零的斜杠。 * *

放在外层类而不是 {@link JdbcUserLookup} 里,是为了让探针够得着——格式本身就是 * 本次修复的验收标准之一(「删除标题里提交人显示管理员/admin」),埋在 JDBC 实现里 * 就只能靠端到端才验得出来。 */ static String userDisplay(String realName, String account) { if (blank(realName)) { return blank(account) ? null : account.trim(); } return blank(account) ? realName.trim() : realName.trim() + "/" + account.trim(); } private static boolean blank(String value) { return value == null || value.trim().isEmpty(); } interface UserLookup { Map names(String tenantId, Set ids); } /** 表名和列名固定;id 与租户全部参数化。 */ private static final class JdbcUserLookup implements UserLookup { private final NamedParameterJdbcTemplate jdbc; private JdbcUserLookup(JdbcTemplate jdbcTemplate) { this.jdbc = new NamedParameterJdbcTemplate(jdbcTemplate); } @Override public Map names(String tenantId, Set ids) { if (ids.isEmpty()) { return Collections.emptyMap(); } try { MapSqlParameterSource params = new MapSqlParameterSource() .addValue("ids", ids) .addValue("tenantId", tenantId); List> rows = jdbc.queryForList( "SELECT f_id, f_real_name, f_account FROM base_user " + "WHERE f_id IN (:ids) " + "AND COALESCE(NULLIF(f_tenant_id, ''), '0') = :tenantId", params); Map result = new LinkedHashMap<>(); for (Map row : rows) { String id = text(row.get("f_id")); String display = userDisplay(text(row.get("f_real_name")), text(row.get("f_account"))); if (id != null && display != null) { result.put(id, display); } } return result; } catch (RuntimeException ex) { log.warn("[AUDIT-QUERY] 用户名称批量解析失败 count={} err={}", ids.size(), ex.getClass().getSimpleName()); return Collections.emptyMap(); } } private static String text(Object value) { if (value == null) { return null; } String text = String.valueOf(value).trim(); return text.isEmpty() ? null : text; } } }