刘光辉
昨天 bb638871a7fb692d80f1b7a758f991dc0879002c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
package jnpf.bizcommon.audit.service.entry;
 
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.Map;
import java.util.concurrent.ConcurrentHashMap;
 
/**
 * 「操作入口」解析:用户是从哪个菜单点进来的。
 *
 * <p><b>为什么不按 modelId 反查</b>:一个表单模型可以挂多个菜单——实测 modelId
 * {@code 846320770681526725} 对应 4 个菜单(待取样沉降菌/浮游菌/悬浮粒子/表面微生物测试)。
 * 反查只能取其中一个,写进审计就是<b>记错事实</b>。所以入口必须由请求携带
 * (前端全局拦截器注入 {@code Jnpf-Menu-Id} → 平台 AUDIT-P1 补丁塞进 {@code auditCtx.menuId}),
 * 本类只负责把 id 翻译成当时的名字。
 *
 * <p>缓存口径与 {@code AuditFormRegistry} 同族:只缓存确定性结果(空串 = 确定地查不到),
 * 瞬时故障不缓存(L061 ①)——一次连接抖动若被永久负缓存,该菜单从此到重启前都没有名字。
 *
 * <p><b>返回 null 的三种成因由调用方区分</b>: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<String, CacheValue> cache = new ConcurrentHashMap<String, CacheValue>();
 
    @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;
    }
 
    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;
        }
    }
}