package jnpf.service; import cn.hutool.crypto.digest.DigestUtil; import jnpf.base.LogApi; import jnpf.base.UserInfo; import jnpf.base.model.logmodel.LoginSessionEventModel; import jnpf.base.model.logmodel.LoginSessionEventResult; import jnpf.util.DateUtil; import jnpf.util.IpUtil; import jnpf.util.JsonUtil; import jnpf.util.RandomUtil; import jnpf.util.RedisUtil; import jnpf.util.ServletUtil; import jnpf.util.StringUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.DisposableBean; import org.springframework.beans.factory.InitializingBean; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import java.util.Date; import java.util.Set; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; /** * Authentication-side login session event recorder with Redis retry fallback. */ @Service @Slf4j @RequiredArgsConstructor public class LoginSessionLogRecorder implements InitializingBean, DisposableBean { private static final String PENDING_PREFIX = "jnpf:login-session:pending:"; public static final String TOKEN_MAPPING_PREFIX = "jnpf:login-session:token:"; public static final String TENANT_REGISTRY_KEY = "jnpf:login-session:tenants"; private static final long PENDING_TTL_SECONDS = 7 * 24 * 60 * 60L; private final LogApi logApi; private final RedisUtil redisUtil; private final RedisTemplate redisTemplate; private ScheduledExecutorService retryExecutor; public void open(UserInfo userInfo, Integer loginMode) { if (userInfo == null || StringUtil.isEmpty(userInfo.getToken())) { return; } LoginSessionEventModel event = baseEvent(userInfo.getToken(), userInfo) .eventType(LoginSessionEventModel.OPEN) .loginMode(loginMode) .source("NEW_LOGIN") .eventTime(DateUtil.getNowDate()) .expireTime(userInfo.getOverdueTime()) .build(); saveTokenMapping(event.getSessionId(), userInfo, userInfo.getOverdueTime()); record(event); } public void close(String token, UserInfo userInfo, String reason, String description) { if (StringUtil.isEmpty(token)) { return; } LoginSessionEventModel event = baseEvent(token, userInfo) .eventType(LoginSessionEventModel.CLOSE) .logoutReason(reason) .description(description) .eventTime(DateUtil.getNowDate()) .build(); record(event); redisUtil.remove(TOKEN_MAPPING_PREFIX + event.getSessionId()); } public void touch(String token, UserInfo userInfo, Date expireTime) { if (StringUtil.isEmpty(token)) { return; } LoginSessionEventModel event = baseEvent(token, userInfo) .eventType(LoginSessionEventModel.TOUCH) .eventTime(DateUtil.getNowDate()) .expireTime(expireTime) .build(); saveTokenMapping(event.getSessionId(), userInfo, expireTime); record(event); } public String sessionId(String token) { String value = token == null ? "" : token.trim(); if (value.regionMatches(true, 0, "bearer", 0, "bearer".length())) { value = value.substring("bearer".length()).trim(); } return DigestUtil.sha256Hex(value); } private LoginSessionEventModel.LoginSessionEventModelBuilder baseEvent(String token, UserInfo userInfo) { String eventId = RandomUtil.uuId(); LoginSessionEventModel.LoginSessionEventModelBuilder builder = LoginSessionEventModel.builder() .eventId(eventId) .transitionId(eventId) .sessionId(sessionId(token)) .userInfo(userInfo) .requestUrl(safeServletPath()) .requestMethod(safeRequestMethod()); if (userInfo != null) { builder.ipAddress(userInfo.getLoginIpAddress()) .ipAddressName(userInfo.getLoginIpAddressName()) .browser(userInfo.getBrowser()) .platform(userInfo.getLoginPlatForm()); } else { builder.ipAddress(safeIp()); } return builder; } private void record(LoginSessionEventModel event) { try { LoginSessionEventResult result = logApi.recordLoginSessionEvent(event); if (result != null && result.isSuccess()) { redisUtil.remove(pendingKey(event.getEventId())); return; } } catch (Exception e) { log.warn("登录会话日志写入失败,已进入补偿队列,sessionId: {}, eventType: {}", event.getSessionId(), event.getEventType(), e); } enqueue(event); } private void enqueue(LoginSessionEventModel event) { try { redisUtil.insert(pendingKey(event.getEventId()), JsonUtil.getObjectToStringAsDate(event), PENDING_TTL_SECONDS); } catch (Exception e) { log.error("登录会话日志主通道和补偿通道均失败,sessionId: {}, eventType: {}", event.getSessionId(), event.getEventType(), e); } } private void retryPending() { try { Set keys = redisUtil.findKeysContaining(PENDING_PREFIX + "*"); if (keys == null || keys.isEmpty()) { return; } int count = 0; for (String key : keys) { if (count++ >= 200) { break; } Object json = redisUtil.getString(key); if (json == null) { continue; } LoginSessionEventModel event = JsonUtil.getJsonToBean(String.valueOf(json), LoginSessionEventModel.class); LoginSessionEventResult result = logApi.recordLoginSessionEvent(event); if (result != null && result.isSuccess()) { redisUtil.remove(key); } } } catch (Exception e) { log.warn("重试登录会话日志失败", e); } } @Override public void afterPropertiesSet() { ThreadFactory factory = runnable -> { Thread thread = new Thread(runnable, "login-session-log-retry"); thread.setDaemon(true); return thread; }; retryExecutor = Executors.newSingleThreadScheduledExecutor(factory); retryExecutor.scheduleWithFixedDelay(this::retryPending, 30, 30, TimeUnit.SECONDS); } @Override public void destroy() { if (retryExecutor != null) { retryExecutor.shutdownNow(); } } private String pendingKey(String eventId) { return PENDING_PREFIX + eventId; } private void saveTokenMapping(String sessionId, UserInfo userInfo, Date expireTime) { if (expireTime == null) { return; } long ttl = Math.max(1, (expireTime.getTime() - System.currentTimeMillis()) / 1000L + 120L); try { redisUtil.insert(TOKEN_MAPPING_PREFIX + sessionId, "1", ttl); if (userInfo != null && StringUtil.isNotEmpty(userInfo.getTenantId())) { redisTemplate.opsForSet().add(TENANT_REGISTRY_KEY, userInfo.getTenantId()); } } catch (Exception e) { log.warn("保存登录会话 Token 映射失败,sessionId: {}", sessionId, e); } } private String safeServletPath() { try { return ServletUtil.getServletPath(); } catch (Exception ignored) { return null; } } private String safeRequestMethod() { try { return ServletUtil.getRequest().getMethod(); } catch (Exception ignored) { return null; } } private String safeIp() { try { return IpUtil.getIpAddr(); } catch (Exception ignored) { return null; } } }