刘光辉
15 小时以前 34981c30a78e8bbd7791131059a9210f9928b62c
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
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
package jnpf.audit.sdk;
 
import cn.hutool.http.useragent.UserAgent;
import cn.hutool.http.useragent.UserAgentUtil;
import jnpf.audit.AuditApiConsts;
import jnpf.audit.model.AuditEventDTO;
import jnpf.base.UserInfo;
import jnpf.util.IpUtil;
import jnpf.util.RandomUtil;
import jnpf.util.ServletUtil;
import jnpf.util.UserProvider;
import org.springframework.web.context.request.RequestContextHolder;
 
import java.util.Date;
 
/**
 * 审计上下文采集器(Task 7 Step 1):只填 DTO 的空缺字段,不覆盖调用方已给值。
 *
 * <p>操作人/租户取自 {@link UserProvider}(Sa-Token 登录上下文);IP/浏览器/OS 取自请求上下文,
 * 工具与平台 {@code jnpf.RequestLogAspect} 同款({@link IpUtil#getIpAddr()} / {@link IpUtil#getIpCity(String)} /
 * {@link ServletUtil#getUserAgent()} + hutool {@link UserAgentUtil#parse(String)})。
 *
 * <p>关键约束(终验#6):operationId **不由本采集器生成、也不写 MDC**——生成与 MDC 写入统一归
 * {@link AuditTxHolder};采集器若先写 MDC,会让 holder 读到的 previousMdc 变成新值本身,恢复等于没恢复。
 */
public class AuditContextCollector {
 
    /** 无登录上下文时的操作人占位(异步/系统触发链路) */
    private static final String OPERATOR_SYSTEM = "SYSTEM";
 
    private final String appName;
 
    public AuditContextCollector(String appName) {
        this.appName = appName;
    }
 
    /** 填充空缺字段并返回同一 dto(供 AuditClient 冻结后交 holder 缓冲)。 */
    public AuditEventDTO fill(AuditEventDTO dto) {
        if (isBlank(dto.getClientEventId())) {
            dto.setClientEventId(RandomUtil.uuId());   // 平台雪花(= String.valueOf(YitIdHelper.nextId()))
        }
        if (dto.getEventTime() == null) {
            dto.setEventTime(new Date());
        }
        if (isBlank(dto.getAppName())) {
            dto.setAppName(appName);
        }
        fillOperator(dto);
        fillRequest(dto);
        return dto;
    }
 
    /**
     * 操作人/租户:有登录用户则填 userId/userName/organizeId/tenantId;无则 operator=SYSTEM。
     *
     * <p>租户回落(M2 阻断项修复):单租户部署下 {@code user.getTenantId()} 返回**空串**,
     * 无会话链路更是无从取得——两种情况都在最后统一回落到 {@link AuditApiConsts#DEFAULT_TENANT_ID},
     * 否则服务端 validate 会以「tenantId 不能为空」拒收,事件进 %RETRY% 直至 DLQ。
     * 回落只在空白时发生,多租户部署下真实租户号不受影响。
     */
    private void fillOperator(AuditEventDTO dto) {
        UserInfo user = currentUser();
        if (user != null && !isBlank(user.getUserId())) {
            if (isBlank(dto.getOperatorId())) {
                dto.setOperatorId(user.getUserId());
            }
            if (isBlank(dto.getOperatorName())) {
                dto.setOperatorName(user.getUserName());
            }
            if (isBlank(dto.getOperatorOrgId())) {
                dto.setOperatorOrgId(user.getOrganizeId());
            }
            if (isBlank(dto.getTenantId())) {
                dto.setTenantId(user.getTenantId());
            }
        } else if (isBlank(dto.getOperatorId())) {
            // 无登录上下文:operator=SYSTEM(tenantId 由下方哨兵回落兜底)
            dto.setOperatorId(OPERATOR_SYSTEM);
        }
        if (isBlank(dto.getTenantId())) {
            dto.setTenantId(AuditApiConsts.DEFAULT_TENANT_ID);
        }
    }
 
    /** IP/浏览器/OS:仅在有请求上下文时采集,best-effort,采集失败静默降级不影响审计主流程。 */
    private void fillRequest(AuditEventDTO dto) {
        if (RequestContextHolder.getRequestAttributes() == null) {
            return;   // 无请求上下文(异步/系统触发)
        }
        try {
            if (isBlank(dto.getIp())) {
                String ip = IpUtil.getIpAddr();
                dto.setIp(ip);
                if (isBlank(dto.getIpRegion())) {
                    dto.setIpRegion(IpUtil.getIpCity(ip));
                }
            }
            String ua = ServletUtil.getUserAgent();
            if (!isBlank(ua)) {
                UserAgent agent = UserAgentUtil.parse(ua);
                if (isBlank(dto.getBrowser())) {
                    dto.setBrowser(join(agent.getBrowser().getName(), agent.getVersion()));
                }
                if (isBlank(dto.getOs())) {
                    dto.setOs(join(agent.getPlatform().getName(), agent.getOsVersion()));
                }
            }
        } catch (Exception ignore) {
            // 上下文采集属 best-effort,任何异常不得冒泡影响业务与审计主链路
        }
    }
 
    private static UserInfo currentUser() {
        try {
            return UserProvider.getUser();
        } catch (Exception e) {
            return null;   // 未登录/无 token(Sa-Token 可能抛异常),视为无用户
        }
    }
 
    private static String join(String a, String b) {
        String x = a == null ? "" : a.trim();
        String y = b == null ? "" : b.trim();
        String r = (x + " " + y).trim();
        return r.isEmpty() ? null : r;
    }
 
    private static boolean isBlank(String s) {
        return s == null || s.trim().isEmpty();
    }
}