feat: API 访问日志系统(拦截器 + 统计分析 + 管理页面)
- V6: api_access_logs 表(method/path/email/IP/status/耗时/错误) - ApiLogInterceptor: 记录所有 /api/v1/** 请求,order(0) 最优先 - ApiLogController: 分页查询 + 按邮箱/路径统计(24h 窗口) - 前端 ApiLogs.vue: 筛选/统计面板/日志列表/分页 - 导航新增「访问日志」菜单项
This commit is contained in:
@@ -0,0 +1,62 @@
|
|||||||
|
package com.par.api.controller;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.par.core.dto.ApiResponse;
|
||||||
|
import com.par.core.entity.ApiAccessLog;
|
||||||
|
import com.par.core.mapper.ApiAccessLogMapper;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 访问日志控制器
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/admin/logs")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ApiLogController {
|
||||||
|
|
||||||
|
private final ApiAccessLogMapper mapper;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ApiResponse<Map<String, Object>> list(
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@RequestParam(defaultValue = "50") int size,
|
||||||
|
@RequestParam(required = false) String email,
|
||||||
|
@RequestParam(required = false) String path,
|
||||||
|
@RequestParam(required = false) Boolean success,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
// checkAdmin implicitly via interceptor
|
||||||
|
|
||||||
|
LambdaQueryWrapper<ApiAccessLog> q = new LambdaQueryWrapper<>();
|
||||||
|
if (email != null && !email.isBlank()) q.like(ApiAccessLog::getEmail, email);
|
||||||
|
if (path != null && !path.isBlank()) q.like(ApiAccessLog::getPath, path);
|
||||||
|
if (success != null) q.eq(ApiAccessLog::getSuccess, success);
|
||||||
|
q.orderByDesc(ApiAccessLog::getCreatedAt);
|
||||||
|
|
||||||
|
Page<ApiAccessLog> p = mapper.selectPage(new Page<>(page, size), q);
|
||||||
|
Map<String, Object> result = Map.of(
|
||||||
|
"records", p.getRecords(),
|
||||||
|
"total", p.getTotal(),
|
||||||
|
"page", p.getCurrent(),
|
||||||
|
"size", p.getSize()
|
||||||
|
);
|
||||||
|
return ApiResponse.success(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/stats/email")
|
||||||
|
public ApiResponse<Object> statsByEmail(@RequestParam(defaultValue = "24") int hours) {
|
||||||
|
String since = LocalDateTime.now().minusHours(hours).toString();
|
||||||
|
return ApiResponse.success(mapper.statsByEmail(since));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/stats/path")
|
||||||
|
public ApiResponse<Object> statsByPath(@RequestParam(defaultValue = "24") int hours) {
|
||||||
|
String since = LocalDateTime.now().minusHours(hours).toString();
|
||||||
|
return ApiResponse.success(mapper.statsByPath(since));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.par.core.config;
|
package com.par.core.config;
|
||||||
|
|
||||||
import com.par.core.interceptor.AnonymousStatInterceptor;
|
import com.par.core.interceptor.AnonymousStatInterceptor;
|
||||||
|
import com.par.core.interceptor.ApiLogInterceptor;
|
||||||
import com.par.core.interceptor.HmacAuthInterceptor;
|
import com.par.core.interceptor.HmacAuthInterceptor;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
@@ -16,9 +17,15 @@ public class WebConfig implements WebMvcConfigurer {
|
|||||||
|
|
||||||
private final HmacAuthInterceptor hmacAuthInterceptor;
|
private final HmacAuthInterceptor hmacAuthInterceptor;
|
||||||
private final AnonymousStatInterceptor anonymousStatInterceptor;
|
private final AnonymousStatInterceptor anonymousStatInterceptor;
|
||||||
|
private final ApiLogInterceptor apiLogInterceptor;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void addInterceptors(InterceptorRegistry registry) {
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
|
// API 日志拦截器:记录所有 API 请求(最优先)
|
||||||
|
registry.addInterceptor(apiLogInterceptor)
|
||||||
|
.addPathPatterns("/api/v1/**")
|
||||||
|
.order(0);
|
||||||
|
|
||||||
// 匿名统计拦截器:拦截所有读取端点
|
// 匿名统计拦截器:拦截所有读取端点
|
||||||
registry.addInterceptor(anonymousStatInterceptor)
|
registry.addInterceptor(anonymousStatInterceptor)
|
||||||
.addPathPatterns("/api/v1/sites/**", "/api/v1/configs/**", "/api/v1/manifest");
|
.addPathPatterns("/api/v1/sites/**", "/api/v1/configs/**", "/api/v1/manifest");
|
||||||
|
|||||||
50
par-core/src/main/java/com/par/core/entity/ApiAccessLog.java
Normal file
50
par-core/src/main/java/com/par/core/entity/ApiAccessLog.java
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package com.par.core.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.*;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 访问日志实体
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("api_access_logs")
|
||||||
|
public class ApiAccessLog {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@TableField("method")
|
||||||
|
private String method;
|
||||||
|
|
||||||
|
@TableField("path")
|
||||||
|
private String path;
|
||||||
|
|
||||||
|
@TableField("email")
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@TableField("client_ip")
|
||||||
|
private String clientIp;
|
||||||
|
|
||||||
|
@TableField("user_agent")
|
||||||
|
private String userAgent;
|
||||||
|
|
||||||
|
@TableField("status_code")
|
||||||
|
private Integer statusCode;
|
||||||
|
|
||||||
|
@TableField("response_ms")
|
||||||
|
private Integer responseMs;
|
||||||
|
|
||||||
|
@TableField("success")
|
||||||
|
private Boolean success;
|
||||||
|
|
||||||
|
@TableField("error_message")
|
||||||
|
private String errorMessage;
|
||||||
|
|
||||||
|
@TableField("psk_version")
|
||||||
|
private String pskVersion;
|
||||||
|
|
||||||
|
@TableField(value = "created_at", fill = FieldFill.INSERT)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.par.core.interceptor;
|
||||||
|
|
||||||
|
import com.par.core.entity.ApiAccessLog;
|
||||||
|
import com.par.core.mapper.ApiAccessLogMapper;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 访问日志拦截器
|
||||||
|
* 记录所有 API 请求(成功和失败),异步插入忽略写入错误
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ApiLogInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
|
private final ApiAccessLogMapper mapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||||
|
request.setAttribute("_log_start", System.currentTimeMillis());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
|
||||||
|
Object handler, Exception ex) {
|
||||||
|
long elapsed = System.currentTimeMillis() - (long) request.getAttribute("_log_start");
|
||||||
|
String email = request.getHeader("X-Email");
|
||||||
|
String pskVer = (String) request.getAttribute("pskVersion");
|
||||||
|
|
||||||
|
ApiAccessLog log = new ApiAccessLog();
|
||||||
|
log.setMethod(request.getMethod());
|
||||||
|
log.setPath(request.getRequestURI());
|
||||||
|
log.setEmail(email);
|
||||||
|
log.setClientIp(request.getRemoteAddr());
|
||||||
|
log.setUserAgent(request.getHeader("User-Agent"));
|
||||||
|
log.setStatusCode(response.getStatus());
|
||||||
|
log.setResponseMs((int) elapsed);
|
||||||
|
log.setSuccess(response.getStatus() < 400 && ex == null);
|
||||||
|
log.setErrorMessage(ex != null ? ex.getMessage() : null);
|
||||||
|
log.setPskVersion(pskVer);
|
||||||
|
|
||||||
|
try { mapper.insert(log); } catch (Exception e) {
|
||||||
|
// 日志写入失败不影响正常请求
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.par.core.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.par.core.entity.ApiAccessLog;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 访问日志 Mapper
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface ApiAccessLogMapper extends BaseMapper<ApiAccessLog> {
|
||||||
|
|
||||||
|
@Select("SELECT email, COUNT(*) AS cnt, SUM(success) AS ok, " +
|
||||||
|
"SUM(CASE WHEN success=0 THEN 1 ELSE 0 END) AS err " +
|
||||||
|
"FROM api_access_logs WHERE created_at >= #{since} GROUP BY email ORDER BY cnt DESC")
|
||||||
|
List<Map<String, Object>> statsByEmail(@Param("since") String since);
|
||||||
|
|
||||||
|
@Select("SELECT path, COUNT(*) AS cnt, AVG(response_ms) AS avg_ms " +
|
||||||
|
"FROM api_access_logs WHERE created_at >= #{since} GROUP BY path ORDER BY cnt DESC")
|
||||||
|
List<Map<String, Object>> statsByPath(@Param("since") String since);
|
||||||
|
|
||||||
|
@Select("SELECT DATE(created_at) AS dt, COUNT(*) AS cnt, SUM(success) AS ok " +
|
||||||
|
"FROM api_access_logs WHERE created_at >= #{since} GROUP BY dt ORDER BY dt")
|
||||||
|
List<Map<String, Object>> statsByDay(@Param("since") String since);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- PAR Flyway V6: API 访问日志表
|
||||||
|
CREATE TABLE api_access_logs (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
|
method VARCHAR(10) NOT NULL COMMENT '请求方法',
|
||||||
|
path VARCHAR(500) NOT NULL COMMENT '请求路径',
|
||||||
|
email VARCHAR(255) COMMENT 'X-Email 请求头',
|
||||||
|
client_ip VARCHAR(45) COMMENT '客户端 IP',
|
||||||
|
user_agent VARCHAR(500) COMMENT 'User-Agent',
|
||||||
|
status_code INT NOT NULL COMMENT 'HTTP 状态码',
|
||||||
|
response_ms INT NOT NULL COMMENT '响应耗时(毫秒)',
|
||||||
|
success TINYINT(1) NOT NULL COMMENT '是否成功',
|
||||||
|
error_message VARCHAR(500) COMMENT '错误信息',
|
||||||
|
psk_version VARCHAR(20) COMMENT 'PSK 版本号',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '请求时间',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_email (email),
|
||||||
|
KEY idx_path (path(100)),
|
||||||
|
KEY idx_created (created_at),
|
||||||
|
KEY idx_success (success)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='API 访问日志表';
|
||||||
@@ -61,5 +61,10 @@ export default {
|
|||||||
add: (data) => api.post('/admin/psks', data),
|
add: (data) => api.post('/admin/psks', data),
|
||||||
toggle: (id) => api.post(`/admin/psks/${id}/toggle`)
|
toggle: (id) => api.post(`/admin/psks/${id}/toggle`)
|
||||||
},
|
},
|
||||||
|
logs: {
|
||||||
|
list: (params) => api.get('/admin/logs', { params }),
|
||||||
|
emailStats: () => api.get('/admin/logs/stats/email'),
|
||||||
|
pathStats: () => api.get('/admin/logs/stats/path'),
|
||||||
|
},
|
||||||
health: () => api.get('/health', { baseURL: '' })
|
health: () => api.get('/health', { baseURL: '' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ const routes = [
|
|||||||
name: 'Psks',
|
name: 'Psks',
|
||||||
component: () => import('../views/Psks.vue'),
|
component: () => import('../views/Psks.vue'),
|
||||||
meta: { title: 'PSK 管理' }
|
meta: { title: 'PSK 管理' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'logs',
|
||||||
|
name: 'ApiLogs',
|
||||||
|
component: () => import('../views/ApiLogs.vue'),
|
||||||
|
meta: { title: '访问日志' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
95
web/src/views/ApiLogs.vue
Normal file
95
web/src/views/ApiLogs.vue
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2>API 访问日志</h2>
|
||||||
|
|
||||||
|
<!-- 筛选 -->
|
||||||
|
<el-card style="margin-top:16px">
|
||||||
|
<el-form :inline="true" :model="filter" size="small">
|
||||||
|
<el-form-item label="邮箱"><el-input v-model="filter.email" placeholder="支持模糊搜索" clearable /></el-form-item>
|
||||||
|
<el-form-item label="路径"><el-input v-model="filter.path" placeholder="/api/v1/..." clearable /></el-form-item>
|
||||||
|
<el-form-item label="状态"><el-select v-model="filter.success" clearable placeholder="全部"><el-option label="成功" :value="true" /><el-option label="失败" :value="false" /></el-select></el-form-item>
|
||||||
|
<el-form-item><el-button type="primary" @click="fetchLogs">查询</el-button></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 统计 -->
|
||||||
|
<el-row :gutter="16" style="margin-top:16px">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-card header="按调用方统计(24h)">
|
||||||
|
<el-table :data="emailStats" size="small">
|
||||||
|
<el-table-column prop="email" label="邮箱" />
|
||||||
|
<el-table-column prop="cnt" label="总次数" width="80" />
|
||||||
|
<el-table-column prop="ok" label="成功" width="60" />
|
||||||
|
<el-table-column prop="err" label="失败" width="60" />
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-card header="按接口统计(24h)">
|
||||||
|
<el-table :data="pathStats" size="small">
|
||||||
|
<el-table-column prop="path" label="路径" />
|
||||||
|
<el-table-column prop="cnt" label="次数" width="80" />
|
||||||
|
<el-table-column prop="avg_ms" label="平均耗时" width="80">
|
||||||
|
<template #default="scope">{{ Math.round(scope.row.avg_ms) }}ms</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<!-- 日志列表 -->
|
||||||
|
<el-card style="margin-top:16px">
|
||||||
|
<el-table :data="logs" v-loading="loading" size="small" stripe>
|
||||||
|
<el-table-column prop="method" label="方法" width="60" />
|
||||||
|
<el-table-column prop="path" label="路径" min-width="160" />
|
||||||
|
<el-table-column prop="email" label="邮箱" width="160" />
|
||||||
|
<el-table-column prop="clientIp" label="IP" width="130" />
|
||||||
|
<el-table-column label="状态" width="70">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag size="small" :type="scope.row.success ? 'success' : 'danger'">{{ scope.row.statusCode }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="耗时" width="70">
|
||||||
|
<template #default="scope">{{ scope.row.responseMs }}ms</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="错误" min-width="150">
|
||||||
|
<template #default="scope">
|
||||||
|
<span v-if="scope.row.errorMessage" style="color:#f56c6c;font-size:12px">{{ scope.row.errorMessage }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="createdAt" label="时间" width="160" />
|
||||||
|
</el-table>
|
||||||
|
<div style="margin-top:12px;text-align:center">
|
||||||
|
<el-pagination small background layout="prev,next" :total="total" :page-size="filter.size" v-model:current-page="filter.page" @current-change="fetchLogs" />
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import api from '../api'
|
||||||
|
|
||||||
|
const logs = ref([]), loading = ref(false), total = ref(0)
|
||||||
|
const emailStats = ref([]), pathStats = ref([])
|
||||||
|
const filter = reactive({ page: 1, size: 50, email: '', path: '', success: null })
|
||||||
|
|
||||||
|
const fetchLogs = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const r = await api.logs.list(filter)
|
||||||
|
logs.value = r.data.records || []
|
||||||
|
total.value = r.data.total || 0
|
||||||
|
} catch { ElMessage.error('获取日志失败') }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
const [e, p] = await Promise.all([api.logs.emailStats(), api.logs.pathStats()])
|
||||||
|
emailStats.value = e.data || []
|
||||||
|
pathStats.value = p.data || []
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { fetchLogs(); fetchStats() })
|
||||||
|
</script>
|
||||||
@@ -34,6 +34,10 @@
|
|||||||
<el-icon><Key /></el-icon>
|
<el-icon><Key /></el-icon>
|
||||||
<span>PSK 管理</span>
|
<span>PSK 管理</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/logs">
|
||||||
|
<el-icon><Document /></el-icon>
|
||||||
|
<span>访问日志</span>
|
||||||
|
</el-menu-item>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</el-aside>
|
</el-aside>
|
||||||
<el-container>
|
<el-container>
|
||||||
|
|||||||
Reference in New Issue
Block a user