feat: 新增异常日志收集功能,mediabot 可提交运行时异常,管理端可查看
This commit is contained in:
@@ -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<ErrorLogDTO> submitErrorLog(
|
||||
@PathVariable("siteId") String siteId,
|
||||
@RequestBody Map<String, Object> 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<List<ErrorLogDTO>> listAll() {
|
||||
return ApiResponse.success(errorLogService.listAll());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定站点的异常日志
|
||||
*/
|
||||
@GetMapping("/api/v1/admin/error-logs/by-site/{siteId}")
|
||||
public ApiResponse<List<ErrorLogDTO>> listBySiteId(@PathVariable("siteId") String siteId) {
|
||||
return ApiResponse.success(errorLogService.listBySiteId(siteId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单条异常日志详情
|
||||
*/
|
||||
@GetMapping("/api/v1/admin/error-logs/{id}")
|
||||
public ApiResponse<ErrorLogDTO> getById(@PathVariable("id") Long id) {
|
||||
ErrorLogDTO dto = errorLogService.getById(id);
|
||||
if (dto == null) {
|
||||
return ApiResponse.error(404, "异常日志不存在");
|
||||
}
|
||||
return ApiResponse.success(dto);
|
||||
}
|
||||
}
|
||||
@@ -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/**");
|
||||
}
|
||||
}
|
||||
|
||||
22
par-core/src/main/java/com/par/core/dto/ErrorLogDTO.java
Normal file
22
par-core/src/main/java/com/par/core/dto/ErrorLogDTO.java
Normal file
@@ -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;
|
||||
}
|
||||
41
par-core/src/main/java/com/par/core/entity/ErrorLog.java
Normal file
41
par-core/src/main/java/com/par/core/entity/ErrorLog.java
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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<ErrorLog> {
|
||||
|
||||
@Select("SELECT * FROM error_logs WHERE site_id = #{siteId} ORDER BY created_at DESC")
|
||||
List<ErrorLog> selectBySiteId(@Param("siteId") String siteId);
|
||||
|
||||
@Select("SELECT * FROM error_logs ORDER BY created_at DESC")
|
||||
List<ErrorLog> selectAll();
|
||||
}
|
||||
@@ -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<ErrorLogDTO> listAll();
|
||||
|
||||
/**
|
||||
* 获取指定站点的异常日志
|
||||
*/
|
||||
List<ErrorLogDTO> listBySiteId(String siteId);
|
||||
|
||||
/**
|
||||
* 获取单条详情
|
||||
*/
|
||||
ErrorLogDTO getById(Long id);
|
||||
|
||||
/**
|
||||
* 实体转 DTO
|
||||
*/
|
||||
ErrorLogDTO toDTO(ErrorLog entity);
|
||||
}
|
||||
@@ -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<ErrorLogDTO> listAll() {
|
||||
return mapper.selectAll().stream().map(this::toDTO).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ErrorLogDTO> 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;
|
||||
}
|
||||
}
|
||||
16
par-core/src/main/resources/db/migration/V12__error_logs.sql
Normal file
16
par-core/src/main/resources/db/migration/V12__error_logs.sql
Normal file
@@ -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='异常日志表';
|
||||
@@ -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: '' })
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
133
web/src/views/ErrorLogs.vue
Normal file
133
web/src/views/ErrorLogs.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>异常日志</h2>
|
||||
<el-card style="margin-top: 20px">
|
||||
<div style="margin-bottom: 12px; display: flex; gap: 10px; align-items: center">
|
||||
<el-select v-model="filterSiteId" placeholder="按站点筛选" clearable style="width: 200px">
|
||||
<el-option v-for="s in siteOptions" :key="s" :label="s" :value="s" />
|
||||
</el-select>
|
||||
<el-select v-model="filterErrorType" placeholder="按异常类型筛选" clearable style="width: 220px">
|
||||
<el-option v-for="t in errorTypeOptions" :key="t" :label="t" :value="t" />
|
||||
</el-select>
|
||||
<el-button @click="fetchAll" :icon="Refresh">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="filteredList" v-loading="loading" stripe size="small" empty-text="暂无异常日志">
|
||||
<el-table-column type="index" label="#" width="50" />
|
||||
<el-table-column prop="siteId" label="站点" width="110">
|
||||
<template #default="scope">
|
||||
<span class="text-xs">{{ scope.row.siteId || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="errorType" label="异常类型" width="180">
|
||||
<template #default="scope">
|
||||
<el-tag size="small" type="danger">{{ scope.row.errorType }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="message" label="异常消息" min-width="250" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<span class="text-xs">{{ scope.row.message || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="configVersion" label="版本" width="80">
|
||||
<template #default="scope">
|
||||
<span class="text-xs">{{ scope.row.configVersion || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="url" label="URL" min-width="180" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<span class="text-xs" style="word-break:break-all">{{ scope.row.url || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="clientId" label="来源" width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="createdAt" label="时间" width="160" />
|
||||
<el-table-column label="操作" width="80" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="handleView(scope.row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog v-model="detailVisible" title="异常详情" width="850px" :close-on-click-modal="false">
|
||||
<el-descriptions :column="2" border size="small" style="margin-bottom: 12px">
|
||||
<el-descriptions-item label="站点" :span="1">{{ detail.siteId || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="来源" :span="1">{{ detail.clientId || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="异常类型" :span="2">
|
||||
<el-tag size="small" type="danger">{{ detail.errorType }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="异常消息" :span="2">{{ detail.message || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="配置版本">{{ detail.configVersion || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="URL" :span="1">
|
||||
<span style="word-break:break-all">{{ detail.url || '-' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="时间" :span="2">{{ detail.createdAt }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div v-if="detail.stackTrace" style="margin-top: 10px">
|
||||
<div style="font-size:13px;font-weight:500;color:#606266;margin-bottom:4px">堆栈信息:</div>
|
||||
<pre class="stack-trace">{{ detail.stackTrace }}</pre>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import api from '../api'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const filterSiteId = ref('')
|
||||
const filterErrorType = ref('')
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detail = ref({})
|
||||
|
||||
const siteOptions = computed(() => [...new Set(list.value.map(r => r.siteId).filter(Boolean))].sort())
|
||||
const errorTypeOptions = computed(() => [...new Set(list.value.map(r => r.errorType).filter(Boolean))].sort())
|
||||
|
||||
const filteredList = computed(() => {
|
||||
let items = list.value
|
||||
if (filterSiteId.value) items = items.filter(r => r.siteId === filterSiteId.value)
|
||||
if (filterErrorType.value) items = items.filter(r => r.errorType === filterErrorType.value)
|
||||
return items
|
||||
})
|
||||
|
||||
const fetchAll = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await api.errorLogs.list()
|
||||
list.value = r.data || []
|
||||
} catch { ElMessage.error('获取异常日志失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
const handleView = (row) => {
|
||||
detail.value = { ...row }
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
onMounted(fetchAll)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stack-trace {
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 450px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -38,6 +38,10 @@
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>访问日志</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/error-logs">
|
||||
<el-icon><WarningFilled /></el-icon>
|
||||
<span>异常日志</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-container>
|
||||
|
||||
Reference in New Issue
Block a user