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 的空缺字段,不覆盖调用方已给值。 * *
操作人/租户取自 {@link UserProvider}(Sa-Token 登录上下文);IP/浏览器/OS 取自请求上下文, * 工具与平台 {@code jnpf.RequestLogAspect} 同款({@link IpUtil#getIpAddr()} / {@link IpUtil#getIpCity(String)} / * {@link ServletUtil#getUserAgent()} + hutool {@link UserAgentUtil#parse(String)})。 * *
关键约束(终验#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。 * *
租户回落(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(); } }