日志记录提交

This commit is contained in:
2026-08-05 14:20:04 +08:00
parent 264458920b
commit 06f7f9d2f2
83 changed files with 3256 additions and 27 deletions
@@ -3,11 +3,13 @@ package com.baiyue.audit;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 审计微服务启动入口,初始化 Spring Boot 应用并扫描审计日志 Mapper。
*/
@MapperScan("com.baiyue.audit.mapper")
@EnableScheduling
@SpringBootApplication
public class BaiyueAuditApplication {
@@ -8,11 +8,15 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
/**
@@ -25,8 +29,13 @@ import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/internal/audit/events")
public class AuditIngestController {
private static final String INTERNAL_SERVICE_TOKEN_HEADER = "X-Internal-Service-Token";
@Resource
private AuditIngestService service;
@Value("${baiyue.security.internal-service-token:}")
private String internalServiceToken;
@Operation(
summary = "Ingest business audit event / 写入业务审计事件",
description = "Validates and stores one sanitized event in baiyue_audit.sys_audit_log. / 校验并将一条脱敏事件写入 baiyue_audit.sys_audit_log。"
@@ -35,9 +44,25 @@ public class AuditIngestController {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "202", description = "Event accepted and stored / 事件已接收并存储"),
})
@PostMapping
@ResponseStatus(HttpStatus.ACCEPTED)
public ApiResponse<Void> ingest(@Valid @RequestBody AuditIngestRequest event) {
public ResponseEntity<ApiResponse<Void>> ingest(
@RequestHeader(value = INTERNAL_SERVICE_TOKEN_HEADER, required = false) String providedToken,
@Valid @RequestBody AuditIngestRequest event) {
if (!tokenMatches(providedToken)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.failure(com.baiyue.common.core.api.ResultCode.FORBIDDEN,
"Internal service token is invalid", null, null));
}
service.ingest(event);
return ApiResponse.success(null);
return ResponseEntity.status(HttpStatus.ACCEPTED).body(ApiResponse.success(null));
}
private boolean tokenMatches(String providedToken) {
if (providedToken == null || providedToken.isBlank()
|| internalServiceToken == null || internalServiceToken.isBlank()) {
return false;
}
return MessageDigest.isEqual(
internalServiceToken.getBytes(StandardCharsets.UTF_8),
providedToken.getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,65 @@
package com.baiyue.audit.controller;
import com.baiyue.audit.dto.AuditLogQuery;
import com.baiyue.audit.dto.AuditLogResponses;
import com.baiyue.audit.service.AuditLogQueryService;
import com.baiyue.audit.service.AuditPlatformScopeGuard;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
/** 平台端统一操作日志查询入口。 */
@RestController
@Validated
@RequestMapping("/api/platform/v1/audit/logs")
@Tag(name = "Platform audit logs / 平台操作日志")
public class PlatformAuditLogController {
@Resource
private AuditLogQueryService service;
@GetMapping
@PreAuthorize("hasAuthority('platform:audit:list')")
@Operation(summary = "分页查询操作日志", description = "仅平台端可查询,列表不返回变更前后快照。默认按发生时间、日志 ID 倒序。")
public ApiResponse<PageResult<AuditLogResponses.ListItem>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(defaultValue = "1") @Min(1) long page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) long size,
@RequestParam(required = false) Long accountId,
@RequestParam(required = false) Long operatorUserId,
@RequestParam(required = false) String eventType,
@RequestParam(required = false) String resourceType,
@RequestParam(required = false) String resourceId,
@RequestParam(required = false) Boolean success,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant occurredFrom,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant occurredTo,
@RequestParam(required = false) String keyword) {
AuditPlatformScopeGuard.requirePlatform(user);
return ApiResponse.success(service.page(new AuditLogQuery(accountId, operatorUserId, eventType,
resourceType, resourceId, success, occurredFrom, occurredTo, keyword), page, size));
}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('platform:audit:list')")
@Operation(summary = "查看操作日志详情", description = "仅平台端可查询,详情返回已脱敏的变更前后快照。")
public ApiResponse<AuditLogResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Min(1) Long id) {
AuditPlatformScopeGuard.requirePlatform(user);
return ApiResponse.success(service.detail(id));
}
}
@@ -13,6 +13,8 @@ import java.time.Instant;
*/
@Schema(name = "AuditIngestRequest", description = "Internal business audit event / 内部业务审计事件")
public record AuditIngestRequest(
@Schema(description = "Global idempotency event ID / 全局幂等事件ID", example = "b150c69d-8813-46b7-9a7c-81bfa5b91021")
@Size(max = 64) String eventId,
@Schema(description = "Event code / 事件编码", example = "WAREHOUSE_UPDATE", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @Size(max = 64) String eventType,
@Schema(description = "Resource type / 资源类型", example = "WAREHOUSE", requiredMode = Schema.RequiredMode.REQUIRED)
@@ -0,0 +1,19 @@
package com.baiyue.audit.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
/** 平台操作日志筛选条件。accountId 对应审计库中的 tenant_id。 */
@Schema(name = "AuditLogQuery", description = "平台操作日志分页筛选条件")
public record AuditLogQuery(
Long accountId,
Long operatorUserId,
String eventType,
String resourceType,
String resourceId,
Boolean success,
Instant occurredFrom,
Instant occurredTo,
String keyword) {
}
@@ -0,0 +1,66 @@
package com.baiyue.audit.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
import java.time.LocalDateTime;
/** 平台审计日志查询响应。列表刻意不包含前后快照,详情才返回完整证据。 */
public final class AuditLogResponses {
private AuditLogResponses() {
}
@Schema(name = "AuditLogListItem", description = "操作日志列表项,不包含变更前后快照")
public record ListItem(
Long id,
String eventId,
String eventType,
String resourceType,
String resourceId,
String description,
Long accountId,
Long operatorTenantId,
Long userId,
String username,
String appCode,
String clientType,
String requestId,
String traceId,
String serviceName,
Boolean success,
String errorCode,
String errorMessage,
Instant occurredAt,
LocalDateTime createdAt) {
}
@Schema(name = "AuditLogDetail", description = "操作日志详情,包含已脱敏的变更前后快照")
public record Detail(
Long id,
String eventId,
String eventType,
String resourceType,
String resourceId,
String description,
String requestId,
String traceId,
Long accountId,
Long operatorTenantId,
Long userId,
String username,
String appCode,
String clientType,
String httpMethod,
String requestUri,
String clientIp,
Long durationMs,
Boolean success,
String errorCode,
String errorMessage,
String beforeData,
String afterData,
String serviceName,
Instant occurredAt,
LocalDateTime createdAt) {
}
}
@@ -23,6 +23,7 @@ public class AuditLogEntity {
@Schema(description = "Audit log ID / 审计日志 ID", example = "190000000000000201")
@TableId(type = IdType.AUTO)
private Long id;
private String eventId;
private String eventType;
private String resourceType;
private String resourceId;
@@ -0,0 +1,40 @@
package com.baiyue.audit.mq;
import com.baiyue.audit.repository.AuditDeadLetterRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
/** 将 RocketMQ 最终死信转存数据库,供补偿任务恢复。 */
@Component
@ConditionalOnProperty(prefix = "baiyue.audit.rocketmq", name = "dead-letter-consumer-enabled", havingValue = "true")
@RocketMQMessageListener(
topic = "${baiyue.audit.rocketmq.dead-letter-topic}",
consumerGroup = "${baiyue.audit.rocketmq.dead-letter-group}")
public class AuditDeadLetterConsumer implements RocketMQListener<String> {
@Resource
private ObjectMapper json;
@Resource
private AuditDeadLetterRepository repository;
@Override
public void onMessage(String payload) {
repository.save(eventId(payload), payload);
}
private String eventId(String payload) {
try {
String value = json.readTree(payload).path("eventId").asText();
if (!value.isBlank()) return value;
} catch (Exception ignored) {
// 无法解析的毒消息也必须使用确定性 ID 收入死信表。
}
return "invalid-" + UUID.nameUUIDFromBytes(payload.getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,34 @@
package com.baiyue.audit.mq;
import com.baiyue.audit.dto.AuditIngestRequest;
import com.baiyue.audit.service.AuditIngestService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
/** RocketMQ 审计事件消费者;数据库 event_id 唯一键保证重复消息安全。 */
@Component
@ConditionalOnProperty(prefix = "baiyue.audit.rocketmq", name = "consumer-enabled", havingValue = "true")
@RocketMQMessageListener(
topic = "${baiyue.audit.rocketmq.topic}",
selectorExpression = "AUDIT",
consumerGroup = "${baiyue.audit.rocketmq.consumer-group}")
public class AuditRocketMqConsumer implements RocketMQListener<String> {
@Resource
private ObjectMapper json;
@Resource
private AuditIngestService service;
@Override
public void onMessage(String payload) {
try {
service.ingest(json.readValue(payload, AuditIngestRequest.class));
} catch (JsonProcessingException exception) {
throw new IllegalArgumentException("审计消息格式无效", exception);
}
}
}
@@ -0,0 +1,73 @@
package com.baiyue.audit.repository;
import jakarta.annotation.Resource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 审计 RocketMQ 死信及补偿状态仓储。 */
@Repository
public class AuditDeadLetterRepository {
@Resource
private JdbcTemplate jdbc;
public void save(String eventId, String payload) {
jdbc.update("""
INSERT INTO sys_audit_dead_letter(event_id,payload_text,status,retry_count,next_retry_at,created_at)
VALUES (?,?,'PENDING',0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
ON DUPLICATE KEY UPDATE payload_text=VALUES(payload_text)
""", eventId, payload);
}
public List<Long> pendingIds(int limit) {
return jdbc.queryForList("""
SELECT id FROM sys_audit_dead_letter
WHERE status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3)
ORDER BY next_retry_at,id LIMIT ?
""", Long.class, limit);
}
public boolean claim(long id, String owner, int leaseSeconds) {
return jdbc.update("""
UPDATE sys_audit_dead_letter SET status='PROCESSING',lease_owner=?,
lease_until=DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL ? SECOND),retry_count=retry_count+1
WHERE id=? AND status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3)
""", owner, leaseSeconds, id) == 1;
}
public DeadRow find(long id) {
List<DeadRow> rows = jdbc.query(
"SELECT id,event_id,payload_text,retry_count FROM sys_audit_dead_letter WHERE id=?",
(rs, rowNum) -> new DeadRow(rs.getLong(1), rs.getString(2), rs.getString(3), rs.getInt(4)), id);
return rows.isEmpty() ? null : rows.get(0);
}
public void processed(long id, String owner) {
jdbc.update("""
UPDATE sys_audit_dead_letter SET status='PROCESSED',processed_at=CURRENT_TIMESTAMP(3),
lease_owner=NULL,lease_until=NULL,last_error=NULL
WHERE id=? AND status='PROCESSING' AND lease_owner=?
""", id, owner);
}
public void failed(long id, String owner, int maxAttempts, String error) {
jdbc.update("""
UPDATE sys_audit_dead_letter
SET status=IF(retry_count>=?,'DEAD','PENDING'),
next_retry_at=DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL 60 SECOND),
lease_owner=NULL,lease_until=NULL,last_error=?
WHERE id=? AND status='PROCESSING' AND lease_owner=?
""", maxAttempts, error, id, owner);
}
public int releaseExpired() {
return jdbc.update("""
UPDATE sys_audit_dead_letter SET status='PENDING',next_retry_at=CURRENT_TIMESTAMP(3),
lease_owner=NULL,lease_until=NULL
WHERE status='PROCESSING' AND lease_until<=CURRENT_TIMESTAMP(3) LIMIT 100
""");
}
public record DeadRow(long id, String eventId, String payload, int retryCount) { }
}
@@ -5,7 +5,13 @@ import jakarta.annotation.Resource;
import com.baiyue.audit.dto.AuditIngestRequest;
import com.baiyue.audit.entity.AuditLogEntity;
import com.baiyue.audit.mapper.AuditLogMapper;
import com.baiyue.audit.dto.AuditLogQuery;
import com.baiyue.common.core.api.PageResult;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.stereotype.Repository;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.util.StringUtils;
/**
* 审计事件持久化仓储,负责将内部审计请求映射并追加写入审计日志表。
@@ -15,8 +21,9 @@ public class AuditEventRepository {
@Resource
private AuditLogMapper mapper;
public void save(AuditIngestRequest event) {
public boolean save(AuditIngestRequest event, String eventId) {
AuditLogEntity entity = new AuditLogEntity();
entity.setEventId(eventId);
entity.setEventType(event.eventType());
entity.setResourceType(event.resourceType());
entity.setResourceId(event.resourceId());
@@ -40,7 +47,40 @@ public class AuditEventRepository {
entity.setAfterData(jsonOrNull(event.afterData()));
entity.setOccurredAt(event.occurredAt());
entity.setServiceName(event.serviceName());
mapper.insert(entity);
try {
mapper.insert(entity);
return true;
} catch (DuplicateKeyException ignored) {
return false;
}
}
public PageResult<AuditLogEntity> page(AuditLogQuery query, long page, long size) {
LambdaQueryWrapper<AuditLogEntity> wrapper = new LambdaQueryWrapper<AuditLogEntity>()
.eq(query.accountId() != null, AuditLogEntity::getTenantId, query.accountId())
.eq(query.operatorUserId() != null, AuditLogEntity::getUserId, query.operatorUserId())
.eq(StringUtils.hasText(query.eventType()), AuditLogEntity::getEventType, query.eventType())
.eq(StringUtils.hasText(query.resourceType()), AuditLogEntity::getResourceType, query.resourceType())
.eq(StringUtils.hasText(query.resourceId()), AuditLogEntity::getResourceId, query.resourceId())
.eq(query.success() != null, AuditLogEntity::getSuccess, query.success())
.ge(query.occurredFrom() != null, AuditLogEntity::getOccurredAt, query.occurredFrom())
.le(query.occurredTo() != null, AuditLogEntity::getOccurredAt, query.occurredTo())
.orderByDesc(AuditLogEntity::getOccurredAt)
.orderByDesc(AuditLogEntity::getId);
if (StringUtils.hasText(query.keyword())) {
String keyword = query.keyword().trim();
wrapper.and(condition -> condition
.like(AuditLogEntity::getDescription, keyword)
.or().like(AuditLogEntity::getUsername, keyword)
.or().like(AuditLogEntity::getResourceId, keyword)
.or().like(AuditLogEntity::getRequestId, keyword)
.or().like(AuditLogEntity::getTraceId, keyword));
}
return PageResult.from(mapper.selectPage(new Page<>(page, size), wrapper));
}
public AuditLogEntity findById(Long id) {
return mapper.selectById(id);
}
private static String jsonOrNull(String value) {
@@ -0,0 +1,47 @@
package com.baiyue.audit.service;
import com.baiyue.audit.dto.AuditIngestRequest;
import com.baiyue.audit.repository.AuditDeadLetterRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/** 定时重放已持久化的审计死信,达到上限后保留为 DEAD 供人工处理。 */
@Component
@ConditionalOnProperty(prefix = "baiyue.audit.rocketmq", name = "dead-letter-compensation-enabled", havingValue = "true")
public class AuditDeadLetterCompensator {
@Resource
private AuditDeadLetterRepository repository;
@Resource
private AuditIngestService service;
@Resource
private ObjectMapper json;
@Value("${spring.application.name:audit-service}-${random.uuid}")
private String owner;
@Value("${baiyue.audit.rocketmq.dead-letter-max-attempts:5}")
private int maxAttempts;
@Scheduled(fixedDelayString = "${baiyue.audit.rocketmq.dead-letter-poll-ms:10000}")
public void compensate() {
repository.releaseExpired();
for (Long id : repository.pendingIds(50)) {
if (!repository.claim(id, owner, 60)) continue;
AuditDeadLetterRepository.DeadRow row = repository.find(id);
if (row == null) continue;
try {
service.ingest(json.readValue(row.payload(), AuditIngestRequest.class));
repository.processed(id, owner);
} catch (Exception exception) {
repository.failed(id, owner, maxAttempts, safe(exception.getMessage()));
}
}
}
private static String safe(String value) {
String message = value == null ? "死信补偿失败" : value;
return message.length() <= 1000 ? message : message.substring(0, 1000);
}
}
@@ -5,6 +5,9 @@ import com.baiyue.audit.dto.AuditIngestRequest;
import com.baiyue.audit.repository.AuditEventRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.UUID;
/**
* 审计事件写入应用服务,在事务内接收并持久化不可变业务审计记录。
@@ -16,7 +19,8 @@ public class AuditIngestService {
@Transactional
public void ingest(AuditIngestRequest event) {
repository.save(event);
public boolean ingest(AuditIngestRequest event) {
String eventId = StringUtils.hasText(event.eventId()) ? event.eventId() : UUID.randomUUID().toString();
return repository.save(event, eventId);
}
}
@@ -0,0 +1,48 @@
package com.baiyue.audit.service;
import com.baiyue.audit.dto.AuditLogQuery;
import com.baiyue.audit.dto.AuditLogResponses;
import com.baiyue.audit.entity.AuditLogEntity;
import com.baiyue.audit.repository.AuditEventRepository;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
/** 平台统一操作日志查询服务。 */
@Service
public class AuditLogQueryService {
@Resource
private AuditEventRepository repository;
public PageResult<AuditLogResponses.ListItem> page(AuditLogQuery query, long page, long size) {
PageResult<AuditLogEntity> source = repository.page(query, page, size);
return new PageResult<>(source.records().stream().map(this::toListItem).toList(),
source.page(), source.size(), source.total(), source.pages());
}
public AuditLogResponses.Detail detail(Long id) {
AuditLogEntity entity = repository.findById(id);
if (entity == null) {
throw new BizException(ResultCode.NOT_FOUND, "操作日志不存在");
}
return new AuditLogResponses.Detail(
entity.getId(), entity.getEventId(), entity.getEventType(), entity.getResourceType(), entity.getResourceId(),
entity.getDescription(), entity.getRequestId(), entity.getTraceId(), entity.getTenantId(),
entity.getOperatorTenantId(), entity.getUserId(), entity.getUsername(), entity.getAppCode(),
entity.getClientType(), entity.getHttpMethod(), entity.getRequestUri(), entity.getClientIp(),
entity.getDurationMs(), entity.getSuccess(), entity.getErrorCode(), entity.getErrorMessage(),
entity.getBeforeData(), entity.getAfterData(), entity.getServiceName(), entity.getOccurredAt(),
entity.getCreatedAt());
}
private AuditLogResponses.ListItem toListItem(AuditLogEntity entity) {
return new AuditLogResponses.ListItem(
entity.getId(), entity.getEventId(), entity.getEventType(), entity.getResourceType(), entity.getResourceId(),
entity.getDescription(), entity.getTenantId(), entity.getOperatorTenantId(), entity.getUserId(),
entity.getUsername(), entity.getAppCode(), entity.getClientType(), entity.getRequestId(),
entity.getTraceId(), entity.getServiceName(), entity.getSuccess(), entity.getErrorCode(),
entity.getErrorMessage(), entity.getOccurredAt(), entity.getCreatedAt());
}
}
@@ -0,0 +1,20 @@
package com.baiyue.audit.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
/** 审计日志只允许平台端账号读取,避免商户端通过通用日志接口获取跨账号记录。 */
public final class AuditPlatformScopeGuard {
private AuditPlatformScopeGuard() {
}
public static void requirePlatform(AuthenticatedUser user) {
if (user == null || user.userId() == null) {
throw new BizException(ResultCode.UNAUTHORIZED, "请先登录");
}
if (!"PLATFORM".equals(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "仅平台端可查看操作日志");
}
}
}