package jnpf.bizcommon.audit.service.entry; import jnpf.util.JsonUtil; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** * 「操作入口」解析:用户是从哪个菜单点进来的。 * *

为什么不按 modelId 反查:一个表单模型可以挂多个菜单——实测 modelId * {@code 846320770681526725} 对应 4 个菜单(待取样沉降菌/浮游菌/悬浮粒子/表面微生物测试)。 * 反查只能取其中一个,写进审计就是记错事实。所以入口必须由请求携带 * (前端全局拦截器注入 {@code Jnpf-Menu-Id} → 平台 AUDIT-P1 补丁塞进 {@code auditCtx.menuId}), * 本类只负责把 id 翻译成当时的名字。 * *

缓存口径与 {@code AuditFormRegistry} 同族:只缓存确定性结果(空串 = 确定地查不到), * 瞬时故障不缓存(L061 ①)——一次连接抖动若被永久负缓存,该菜单从此到重启前都没有名字。 * *

返回 null 的三种成因由调用方区分:menuId 为空(请求没带)、确定地查不到、瞬时故障。 * 本类只保证「查不到就返回 null,绝不编一个名字」;「没带」与「带了但查不到」写成两种 * {@code entrySource} 留痕,是编排侧(Task 6)的事——共用一个表示就会遮住一处漏记。 */ @Slf4j @Component public class AuditEntryResolver { /** 入口类型:菜单。预留 FORM / FLOW 等,用于解释 entry_id 指向哪张表。 */ public static final String TYPE_MENU = "MENU"; private final Map cache = new ConcurrentHashMap(); @Autowired private JdbcTemplate jdbcTemplate; @Value("${audit.layer0.metadata-cache-ttl-ms:300000}") private long ttlMs = 300_000L; /** 菜单名;menuId 为空、查不到、或瞬时故障时返回 null(调用方据此写 entrySource)。 */ public String resolveMenuName(String menuId) { if (menuId == null || menuId.trim().isEmpty()) { return null; } String key = menuId.trim(); CacheValue cached = cache.get(key); if (cached != null && ttlMs > 0L && System.currentTimeMillis() - cached.createdAt < ttlMs) { return cached.value.isEmpty() ? null : cached.value; } String name; try { // 排除已删菜单:菜单被删后 f_id 仍是可追溯的事实,但它的名字不该再被当成"当时的入口名" // 写进新记录。f_delete_mark 在 PG 里是 integer,未删除时为 NULL 或 0。 name = jdbcTemplate.queryForObject( "SELECT f_full_name FROM base_module WHERE f_id = ? " + "AND (f_delete_mark IS NULL OR f_delete_mark <> 1) LIMIT 1", String.class, key); } catch (org.springframework.dao.EmptyResultDataAccessException e) { // 确定性:这个 menuId 没有菜单行,再查一万次也一样 put(key, ""); return null; } catch (Throwable t) { log.warn("[AUDIT-L0] 反查菜单名失败(瞬时,不缓存)menuId={} err={}", key, t.toString()); return null; } String normalized = name == null ? "" : name.trim(); put(key, normalized); return normalized.isEmpty() ? null : normalized; } /** Resolve only the closest marked ancestor of the actual entry menu. */ public String resolveParentName(String menuId, String requestedParentId) { if (menuId == null || requestedParentId == null || menuId.trim().isEmpty() || requestedParentId.trim().isEmpty()) { return null; } try { List> rows = closestMarkedParent(menuId.trim()); if (rows.isEmpty() || !requestedParentId.trim().equals(String.valueOf(rows.get(0).get("f_id")))) { return null; } Object name = rows.get(0).get("f_full_name"); return name == null ? null : String.valueOf(name); } catch (RuntimeException ex) { log.warn("[AUDIT] Parent module lookup failed menuId={} err={}", menuId, ex.getClass().getSimpleName()); return null; } } /** A workflow task identifies a template, while the current route may be the generic inbox. */ public String resolveFlowParentName(String taskId, String requestedMenuId, String sourceModelId) { if (taskId == null || taskId.trim().isEmpty()) { return null; } try { List> tasks = jdbcTemplate.queryForList( "SELECT f_template_id, f_flow_id FROM workflow_task WHERE f_id = ?", taskId.trim()); if (tasks.isEmpty() || tasks.get(0).get("f_template_id") == null) { return null; } String templateId = String.valueOf(tasks.get(0).get("f_template_id")); if (sourceModelId != null && !sourceModelId.trim().isEmpty()) { Object flowId = tasks.get(0).get("f_flow_id"); if (flowId == null || jdbcTemplate.queryForList( "SELECT f_id FROM workflow_node WHERE f_flow_id = ? AND f_form_id = ? LIMIT 1", String.valueOf(flowId), sourceModelId.trim()).isEmpty()) { return null; } } List> menus = jdbcTemplate.queryForList( "SELECT f_id, f_property_json FROM base_module WHERE f_type = 9" + " AND f_enabled_mark = 1 AND (f_delete_mark IS NULL OR f_delete_mark <> 1)"); List matches = new ArrayList<>(); for (Map menu : menus) { Object property = menu.get("f_property_json"); if (property == null) { continue; } try { Map values = JsonUtil.stringToMap(String.valueOf(property)); if (values != null && templateId.equals(String.valueOf(values.get("moduleId")))) { matches.add(String.valueOf(menu.get("f_id"))); } } catch (RuntimeException ignored) { // A malformed menu configuration cannot establish workflow ownership. } } String menuId = requestedMenuId != null && matches.contains(requestedMenuId.trim()) ? requestedMenuId.trim() : matches.size() == 1 ? matches.get(0) : null; if (menuId == null) { return null; } List> parents = closestMarkedParent(menuId); Object name = parents.isEmpty() ? null : parents.get(0).get("f_full_name"); return name == null ? null : String.valueOf(name); } catch (RuntimeException ex) { log.warn("[AUDIT] Workflow parent module lookup failed taskId={} err={}", taskId, ex.getClass().getSimpleName()); return null; } } private List> closestMarkedParent(String menuId) { String sql = "WITH RECURSIVE ancestors AS (" + " SELECT f_id, f_parent_id, f_system_id, f_category, f_type," + " f_is_parent_biz_module, f_full_name, 0 AS depth FROM base_module" + " WHERE f_id = ? AND f_enabled_mark = 1" + " AND (f_delete_mark IS NULL OR f_delete_mark <> 1)" + " UNION ALL" + " SELECT parent.f_id, parent.f_parent_id, parent.f_system_id, parent.f_category," + " parent.f_type, parent.f_is_parent_biz_module, parent.f_full_name, child.depth + 1" + " FROM ancestors child JOIN base_module parent ON parent.f_id = child.f_parent_id" + " AND parent.f_system_id = child.f_system_id" + " AND parent.f_category = child.f_category" + " WHERE child.depth < 32 AND parent.f_enabled_mark = 1" + " AND (parent.f_delete_mark IS NULL OR parent.f_delete_mark <> 1)" + ") SELECT f_id, f_full_name FROM ancestors" + " WHERE depth > 0 AND f_type = 1 AND f_is_parent_biz_module = 1" + " ORDER BY depth LIMIT 1"; return jdbcTemplate.queryForList(sql, menuId); } private void put(String key, String value) { if (ttlMs > 0L) { cache.put(key, new CacheValue(value)); } } private static final class CacheValue { private final String value; private final long createdAt = System.currentTimeMillis(); private CacheValue(String value) { this.value = value; } } }