diff --git a/par-api/src/main/java/com/par/api/controller/ErrorLogController.java b/par-api/src/main/java/com/par/api/controller/ErrorLogController.java new file mode 100644 index 0000000..64c84ef --- /dev/null +++ b/par-api/src/main/java/com/par/api/controller/ErrorLogController.java @@ -0,0 +1,84 @@ +package com.par.api.controller; + +import com.par.core.dto.ApiResponse; +import com.par.core.dto.ErrorLogDTO; +import com.par.core.entity.ErrorLog; +import com.par.core.service.ErrorLogService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 异常日志控制器 + * 提交需 HMAC 鉴权(mediabot 调用),查看需管理员权限 + */ +@Slf4j +@RestController +@RequiredArgsConstructor +public class ErrorLogController { + + private final ErrorLogService errorLogService; + + // ==================== mediabot 提交接口(PSK 鉴权) ==================== + + /** + * mediabot 提交异常日志 + */ + @PostMapping("/api/v1/sites/{siteId}/error-log") + public ApiResponse submitErrorLog( + @PathVariable("siteId") String siteId, + @RequestBody Map payload, + HttpServletRequest request) { + + String errorType = (String) payload.get("errorType"); + String message = (String) payload.get("message"); + String stackTrace = (String) payload.get("stackTrace"); + String configVersion = (String) payload.get("configVersion"); + String url = (String) payload.get("url"); + + if (errorType == null || errorType.isBlank()) { + return ApiResponse.error(400, "errorType is required"); + } + + String clientId = (String) request.getAttribute("clientId"); + + ErrorLog result = errorLogService.submit(siteId, clientId, errorType, + message, stackTrace, configVersion, url); + + return ApiResponse.success(errorLogService.toDTO(result)); + } + + // ==================== 管理员查看接口(JWT 鉴权) ==================== + + /** + * 获取所有异常日志 + */ + @GetMapping("/api/v1/admin/error-logs") + public ApiResponse> listAll() { + return ApiResponse.success(errorLogService.listAll()); + } + + /** + * 获取指定站点的异常日志 + */ + @GetMapping("/api/v1/admin/error-logs/by-site/{siteId}") + public ApiResponse> listBySiteId(@PathVariable("siteId") String siteId) { + return ApiResponse.success(errorLogService.listBySiteId(siteId)); + } + + /** + * 获取单条异常日志详情 + */ + @GetMapping("/api/v1/admin/error-logs/{id}") + public ApiResponse getById(@PathVariable("id") Long id) { + ErrorLogDTO dto = errorLogService.getById(id); + if (dto == null) { + return ApiResponse.error(404, "异常日志不存在"); + } + return ApiResponse.success(dto); + } +} diff --git a/par-core/src/main/java/com/par/core/config/WebConfig.java b/par-core/src/main/java/com/par/core/config/WebConfig.java index f5da0dd..d9449ae 100644 --- a/par-core/src/main/java/com/par/core/config/WebConfig.java +++ b/par-core/src/main/java/com/par/core/config/WebConfig.java @@ -33,7 +33,7 @@ public class WebConfig implements WebMvcConfigurer { // HMAC 鉴权拦截器:拦截所有写入端点 registry.addInterceptor(hmacAuthInterceptor) .addPathPatterns("/api/v1/configs/*/submit", "/api/v1/sites/*/submit", - "/api/v1/sites/*/access-result", + "/api/v1/sites/*/access-result", "/api/v1/sites/*/error-log", "/api/v1/admin/**", "/api/v1/account/**"); } } diff --git a/par-core/src/main/java/com/par/core/dto/ErrorLogDTO.java b/par-core/src/main/java/com/par/core/dto/ErrorLogDTO.java new file mode 100644 index 0000000..d831a40 --- /dev/null +++ b/par-core/src/main/java/com/par/core/dto/ErrorLogDTO.java @@ -0,0 +1,22 @@ +package com.par.core.dto; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 异常日志 DTO + */ +@Data +public class ErrorLogDTO { + + private Long id; + private String siteId; + private String clientId; + private String errorType; + private String message; + private String stackTrace; + private String configVersion; + private String url; + private LocalDateTime createdAt; +} diff --git a/par-core/src/main/java/com/par/core/entity/ErrorLog.java b/par-core/src/main/java/com/par/core/entity/ErrorLog.java new file mode 100644 index 0000000..c93e673 --- /dev/null +++ b/par-core/src/main/java/com/par/core/entity/ErrorLog.java @@ -0,0 +1,41 @@ +package com.par.core.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 异常日志实体(mediabot 提交的运行时异常) + */ +@Data +@TableName("error_logs") +public class ErrorLog { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("site_id") + private String siteId; + + @TableField("client_id") + private String clientId; + + @TableField("error_type") + private String errorType; + + @TableField("message") + private String message; + + @TableField("stack_trace") + private String stackTrace; + + @TableField("config_version") + private String configVersion; + + @TableField("url") + private String url; + + @TableField(value = "created_at", fill = FieldFill.INSERT) + private LocalDateTime createdAt; +} diff --git a/par-core/src/main/java/com/par/core/mapper/ErrorLogMapper.java b/par-core/src/main/java/com/par/core/mapper/ErrorLogMapper.java new file mode 100644 index 0000000..dfa4c79 --- /dev/null +++ b/par-core/src/main/java/com/par/core/mapper/ErrorLogMapper.java @@ -0,0 +1,22 @@ +package com.par.core.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.par.core.entity.ErrorLog; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * 异常日志 Mapper + */ +@Mapper +public interface ErrorLogMapper extends BaseMapper { + + @Select("SELECT * FROM error_logs WHERE site_id = #{siteId} ORDER BY created_at DESC") + List selectBySiteId(@Param("siteId") String siteId); + + @Select("SELECT * FROM error_logs ORDER BY created_at DESC") + List selectAll(); +} diff --git a/par-core/src/main/java/com/par/core/service/ErrorLogService.java b/par-core/src/main/java/com/par/core/service/ErrorLogService.java new file mode 100644 index 0000000..5b6dfeb --- /dev/null +++ b/par-core/src/main/java/com/par/core/service/ErrorLogService.java @@ -0,0 +1,38 @@ +package com.par.core.service; + +import com.par.core.dto.ErrorLogDTO; +import com.par.core.entity.ErrorLog; + +import java.util.List; + +/** + * 异常日志服务接口 + */ +public interface ErrorLogService { + + /** + * 提交异常日志(mediabot 调用) + */ + ErrorLog submit(String siteId, String clientId, String errorType, String message, + String stackTrace, String configVersion, String url); + + /** + * 获取所有异常日志 + */ + List listAll(); + + /** + * 获取指定站点的异常日志 + */ + List listBySiteId(String siteId); + + /** + * 获取单条详情 + */ + ErrorLogDTO getById(Long id); + + /** + * 实体转 DTO + */ + ErrorLogDTO toDTO(ErrorLog entity); +} diff --git a/par-core/src/main/java/com/par/core/service/impl/ErrorLogServiceImpl.java b/par-core/src/main/java/com/par/core/service/impl/ErrorLogServiceImpl.java new file mode 100644 index 0000000..f572ee3 --- /dev/null +++ b/par-core/src/main/java/com/par/core/service/impl/ErrorLogServiceImpl.java @@ -0,0 +1,67 @@ +package com.par.core.service.impl; + +import com.par.core.dto.ErrorLogDTO; +import com.par.core.entity.ErrorLog; +import com.par.core.mapper.ErrorLogMapper; +import com.par.core.service.ErrorLogService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ErrorLogServiceImpl implements ErrorLogService { + + private final ErrorLogMapper mapper; + + @Override + public ErrorLog submit(String siteId, String clientId, String errorType, String message, + String stackTrace, String configVersion, String url) { + ErrorLog logEntry = new ErrorLog(); + logEntry.setSiteId(siteId); + logEntry.setClientId(clientId); + logEntry.setErrorType(errorType); + logEntry.setMessage(message); + logEntry.setStackTrace(stackTrace); + logEntry.setConfigVersion(configVersion); + logEntry.setUrl(url); + mapper.insert(logEntry); + log.info("Error log submitted: siteId={}, type={}, clientId={}, id={}", siteId, errorType, clientId, logEntry.getId()); + return logEntry; + } + + @Override + public List listAll() { + return mapper.selectAll().stream().map(this::toDTO).collect(Collectors.toList()); + } + + @Override + public List listBySiteId(String siteId) { + return mapper.selectBySiteId(siteId).stream().map(this::toDTO).collect(Collectors.toList()); + } + + @Override + public ErrorLogDTO getById(Long id) { + return toDTO(mapper.selectById(id)); + } + + @Override + public ErrorLogDTO toDTO(ErrorLog entity) { + if (entity == null) return null; + ErrorLogDTO dto = new ErrorLogDTO(); + dto.setId(entity.getId()); + dto.setSiteId(entity.getSiteId()); + dto.setClientId(entity.getClientId()); + dto.setErrorType(entity.getErrorType()); + dto.setMessage(entity.getMessage()); + dto.setStackTrace(entity.getStackTrace()); + dto.setConfigVersion(entity.getConfigVersion()); + dto.setUrl(entity.getUrl()); + dto.setCreatedAt(entity.getCreatedAt()); + return dto; + } +} diff --git a/par-core/src/main/resources/db/migration/V12__error_logs.sql b/par-core/src/main/resources/db/migration/V12__error_logs.sql new file mode 100644 index 0000000..a1021c7 --- /dev/null +++ b/par-core/src/main/resources/db/migration/V12__error_logs.sql @@ -0,0 +1,16 @@ +-- PAR Flyway V12: 异常日志表(mediabot 提交运行时异常信息) +CREATE TABLE error_logs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', + site_id VARCHAR(64) COMMENT '站点ID', + client_id VARCHAR(128) COMMENT '客户端标识(mediabot ID)', + error_type VARCHAR(200) COMMENT '异常类型(类名)', + message TEXT COMMENT '异常消息', + stack_trace MEDIUMTEXT COMMENT '完整堆栈信息', + config_version VARCHAR(20) COMMENT '使用的配置版本号', + url VARCHAR(500) COMMENT '异常发生时的访问 URL', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '发生时间', + PRIMARY KEY (id), + KEY idx_site_id (site_id), + KEY idx_error_type (error_type(50)), + KEY idx_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常日志表'; diff --git a/web/src/api/index.js b/web/src/api/index.js index 4b921c6..6684e21 100644 --- a/web/src/api/index.js +++ b/web/src/api/index.js @@ -81,5 +81,10 @@ export default { approve: (configId) => api.post(`/admin/reviews/${configId}/approve`), reject: (configId, comment) => api.post(`/admin/reviews/${configId}/reject`, null, { params: { comment: comment || '' } }), }, + errorLogs: { + list: () => api.get('/admin/error-logs'), + bySite: (siteId) => api.get(`/admin/error-logs/by-site/${siteId}`), + get: (id) => api.get(`/admin/error-logs/${id}`), + }, health: () => api.get('/health', { baseURL: '' }) } diff --git a/web/src/router/index.js b/web/src/router/index.js index 7d147d8..d22ac04 100644 --- a/web/src/router/index.js +++ b/web/src/router/index.js @@ -52,6 +52,12 @@ const routes = [ component: () => import('../views/ApiLogs.vue'), meta: { title: '访问日志' } }, + { + path: 'error-logs', + name: 'ErrorLogs', + component: () => import('../views/ErrorLogs.vue'), + meta: { title: '异常日志' } + }, { path: 'access-results', name: 'AccessResults', diff --git a/web/src/views/ErrorLogs.vue b/web/src/views/ErrorLogs.vue new file mode 100644 index 0000000..fb38782 --- /dev/null +++ b/web/src/views/ErrorLogs.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/web/src/views/Layout.vue b/web/src/views/Layout.vue index 57f597e..004fca0 100644 --- a/web/src/views/Layout.vue +++ b/web/src/views/Layout.vue @@ -38,6 +38,10 @@ 访问日志 + + + 异常日志 +