package vip.mate.audit.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import vip.mate.audit.model.AuditEventEntity;
import vip.mate.audit.repository.AuditEventMapper;
import vip.mate.auth.model.UserEntity;
import vip.mate.auth.service.AuthService;
import java.time.LocalDateTime;
/**
* 操作审计服务
*
* 异步记录用户对资源的 CRUD 操作,不阻塞业务请求。
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuditEventService {
private final AuditEventMapper auditEventMapper;
private final AuthService authService;
/**
* 异步记录审计事件。
*
* 在调用线程(请求线程)中捕获完整上下文,然后交给异步线程写库。
* 这样避免了 SecurityContext/RequestContext 在异步线程中丢失的问题。
*/
public void record(String action, String resourceType, String resourceId,
String resourceName, String detailJson) {
record(action, resourceType, resourceId, resourceName, detailJson, null);
}
/**
* 异步记录审计事件(显式指定 workspace ID)。
*
* 当调用方已知 workspace ID 时,优先使用此方法以避免依赖 request header 解析。
*/
public void record(String action, String resourceType, String resourceId,
String resourceName, String detailJson, Long workspaceId) {
// 在请求线程中构建事件(可以访问 SecurityContext 和 RequestContext)
AuditEventEntity event = buildEvent(action, resourceType, resourceId, resourceName, detailJson);
if (event != null) {
// 显式传入的 workspaceId 优先于 header 解析结果
if (workspaceId != null) {
event.setWorkspaceId(workspaceId);
}
insertAsync(event);
}
}
/**
* 异步记录审计事件,显式指定 actor(而非从 SecurityContext 推导)。
*
用于非 MateClaw 用户的写操作 —— 当前主要是 webchat 访客。actor 形如
* {@code "webchat::"},{@code userId} 落 0(访客没有 MateClaw 账户)。
* IP / User-Agent 仍尽量从当前请求抓取(webEnvironment=NONE 下为 null,可接受)。
*/
public void recordAs(String actor, Long workspaceId, String action, String resourceType,
String resourceId, String resourceName, String detailJson) {
AuditEventEntity event = new AuditEventEntity();
event.setUsername(actor != null ? actor : "system");
event.setUserId(0L);
event.setAction(action);
event.setResourceType(resourceType);
event.setResourceId(resourceId);
event.setResourceName(resourceName);
event.setDetailJson(detailJson);
event.setWorkspaceId(workspaceId);
event.setCreateTime(LocalDateTime.now());
try {
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs != null) {
HttpServletRequest request = attrs.getRequest();
event.setIpAddress(getClientIp(request));
event.setUserAgent(truncate(request.getHeader("User-Agent"), 256));
}
} catch (Exception ignored) {
// 异步或非 web 上下文:跳过 IP/UA
}
insertAsync(event);
}
@Async
void insertAsync(AuditEventEntity event) {
try {
auditEventMapper.insert(event);
} catch (Exception e) {
log.warn("Failed to insert audit event: {}/{}", event.getAction(), event.getResourceType(), e);
}
}
/**
* 同步记录(用于必须确保落库的场景,如登录/登出)
*/
public void recordSync(String action, String resourceType, String resourceId,
String resourceName, String detailJson) {
AuditEventEntity event = buildEvent(action, resourceType, resourceId, resourceName, detailJson);
if (event != null) {
auditEventMapper.insert(event);
}
}
/**
* 分页查询审计事件
*/
public IPage listEvents(Long workspaceId, String action, String resourceType,
LocalDateTime startTime, LocalDateTime endTime,
int page, int size) {
LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
if (workspaceId != null) {
wrapper.eq(AuditEventEntity::getWorkspaceId, workspaceId);
}
if (action != null && !action.isBlank()) {
wrapper.eq(AuditEventEntity::getAction, action);
}
if (resourceType != null && !resourceType.isBlank()) {
wrapper.eq(AuditEventEntity::getResourceType, resourceType);
}
if (startTime != null) {
wrapper.ge(AuditEventEntity::getCreateTime, startTime);
}
if (endTime != null) {
wrapper.le(AuditEventEntity::getCreateTime, endTime);
}
wrapper.orderByDesc(AuditEventEntity::getCreateTime);
return auditEventMapper.selectPage(new Page<>(page, size), wrapper);
}
private AuditEventEntity buildEvent(String action, String resourceType, String resourceId,
String resourceName, String detailJson) {
AuditEventEntity event = new AuditEventEntity();
event.setAction(action);
event.setResourceType(resourceType);
event.setResourceId(resourceId);
event.setResourceName(resourceName);
event.setDetailJson(detailJson);
event.setCreateTime(LocalDateTime.now());
// 从 SecurityContext 获取用户信息
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getPrincipal())) {
String username = auth.getName();
event.setUsername(username);
UserEntity user = authService.findByUsername(username);
if (user != null) {
event.setUserId(user.getId());
} else {
event.setUserId(0L);
}
} else {
event.setUsername("system");
event.setUserId(0L);
}
// 从 Request 获取 IP 和 User-Agent
try {
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs != null) {
HttpServletRequest request = attrs.getRequest();
event.setIpAddress(getClientIp(request));
event.setUserAgent(truncate(request.getHeader("User-Agent"), 256));
// 从 header 获取 workspace ID
String wsHeader = request.getHeader("X-Workspace-Id");
if (wsHeader != null && !wsHeader.isBlank()) {
try {
event.setWorkspaceId(Long.parseLong(wsHeader.trim()));
} catch (NumberFormatException ignored) {
event.setWorkspaceId(1L);
}
} else {
event.setWorkspaceId(1L);
}
}
} catch (Exception ignored) {
// 异步上下文可能无法获取 request
}
return event;
}
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isBlank() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isBlank() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
// X-Forwarded-For 可能包含多个 IP,取第一个
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return truncate(ip, 64);
}
private String truncate(String s, int maxLen) {
if (s == null) return null;
return s.length() > maxLen ? s.substring(0, maxLen) : s;
}
}