From 88728ce2c29b56b9a3572d77b725491e7824be00 Mon Sep 17 00:00:00 2001 From: mediabot-pt <295750538+mediabot-pt@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:32:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20API=20=E8=AE=BF=E9=97=AE=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E7=B3=BB=E7=BB=9F=EF=BC=88=E6=8B=A6=E6=88=AA=E5=99=A8?= =?UTF-8?q?=20+=20=E7=BB=9F=E8=AE=A1=E5=88=86=E6=9E=90=20+=20=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=A1=B5=E9=9D=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - V6: api_access_logs 表(method/path/email/IP/status/耗时/错误) - ApiLogInterceptor: 记录所有 /api/v1/** 请求,order(0) 最优先 - ApiLogController: 分页查询 + 按邮箱/路径统计(24h 窗口) - 前端 ApiLogs.vue: 筛选/统计面板/日志列表/分页 - 导航新增「访问日志」菜单项 --- .../par/api/controller/ApiLogController.java | 62 ++++++++++++ .../java/com/par/core/config/WebConfig.java | 7 ++ .../com/par/core/entity/ApiAccessLog.java | 50 ++++++++++ .../core/interceptor/ApiLogInterceptor.java | 52 ++++++++++ .../par/core/mapper/ApiAccessLogMapper.java | 30 ++++++ .../db/migration/V6__api_access_logs.sql | 20 ++++ web/src/api/index.js | 5 + web/src/router/index.js | 6 ++ web/src/views/ApiLogs.vue | 95 +++++++++++++++++++ web/src/views/Layout.vue | 4 + 10 files changed, 331 insertions(+) create mode 100644 par-api/src/main/java/com/par/api/controller/ApiLogController.java create mode 100644 par-core/src/main/java/com/par/core/entity/ApiAccessLog.java create mode 100644 par-core/src/main/java/com/par/core/interceptor/ApiLogInterceptor.java create mode 100644 par-core/src/main/java/com/par/core/mapper/ApiAccessLogMapper.java create mode 100644 par-core/src/main/resources/db/migration/V6__api_access_logs.sql create mode 100644 web/src/views/ApiLogs.vue diff --git a/par-api/src/main/java/com/par/api/controller/ApiLogController.java b/par-api/src/main/java/com/par/api/controller/ApiLogController.java new file mode 100644 index 0000000..bb65f46 --- /dev/null +++ b/par-api/src/main/java/com/par/api/controller/ApiLogController.java @@ -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> 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 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 p = mapper.selectPage(new Page<>(page, size), q); + Map result = Map.of( + "records", p.getRecords(), + "total", p.getTotal(), + "page", p.getCurrent(), + "size", p.getSize() + ); + return ApiResponse.success(result); + } + + @GetMapping("/stats/email") + public ApiResponse statsByEmail(@RequestParam(defaultValue = "24") int hours) { + String since = LocalDateTime.now().minusHours(hours).toString(); + return ApiResponse.success(mapper.statsByEmail(since)); + } + + @GetMapping("/stats/path") + public ApiResponse statsByPath(@RequestParam(defaultValue = "24") int hours) { + String since = LocalDateTime.now().minusHours(hours).toString(); + return ApiResponse.success(mapper.statsByPath(since)); + } +} 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 2d7e94b..30b2a09 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 @@ -1,6 +1,7 @@ package com.par.core.config; import com.par.core.interceptor.AnonymousStatInterceptor; +import com.par.core.interceptor.ApiLogInterceptor; import com.par.core.interceptor.HmacAuthInterceptor; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; @@ -16,9 +17,15 @@ public class WebConfig implements WebMvcConfigurer { private final HmacAuthInterceptor hmacAuthInterceptor; private final AnonymousStatInterceptor anonymousStatInterceptor; + private final ApiLogInterceptor apiLogInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { + // API 日志拦截器:记录所有 API 请求(最优先) + registry.addInterceptor(apiLogInterceptor) + .addPathPatterns("/api/v1/**") + .order(0); + // 匿名统计拦截器:拦截所有读取端点 registry.addInterceptor(anonymousStatInterceptor) .addPathPatterns("/api/v1/sites/**", "/api/v1/configs/**", "/api/v1/manifest"); diff --git a/par-core/src/main/java/com/par/core/entity/ApiAccessLog.java b/par-core/src/main/java/com/par/core/entity/ApiAccessLog.java new file mode 100644 index 0000000..fc9d2fd --- /dev/null +++ b/par-core/src/main/java/com/par/core/entity/ApiAccessLog.java @@ -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; +} diff --git a/par-core/src/main/java/com/par/core/interceptor/ApiLogInterceptor.java b/par-core/src/main/java/com/par/core/interceptor/ApiLogInterceptor.java new file mode 100644 index 0000000..be99818 --- /dev/null +++ b/par-core/src/main/java/com/par/core/interceptor/ApiLogInterceptor.java @@ -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) { + // 日志写入失败不影响正常请求 + } + } +} diff --git a/par-core/src/main/java/com/par/core/mapper/ApiAccessLogMapper.java b/par-core/src/main/java/com/par/core/mapper/ApiAccessLogMapper.java new file mode 100644 index 0000000..2df884c --- /dev/null +++ b/par-core/src/main/java/com/par/core/mapper/ApiAccessLogMapper.java @@ -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 { + + @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> 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> 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> statsByDay(@Param("since") String since); +} diff --git a/par-core/src/main/resources/db/migration/V6__api_access_logs.sql b/par-core/src/main/resources/db/migration/V6__api_access_logs.sql new file mode 100644 index 0000000..ad6bafc --- /dev/null +++ b/par-core/src/main/resources/db/migration/V6__api_access_logs.sql @@ -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 访问日志表'; diff --git a/web/src/api/index.js b/web/src/api/index.js index 6dabeaf..45162c9 100644 --- a/web/src/api/index.js +++ b/web/src/api/index.js @@ -61,5 +61,10 @@ export default { add: (data) => api.post('/admin/psks', data), 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: '' }) } diff --git a/web/src/router/index.js b/web/src/router/index.js index dca7835..fc03fbb 100644 --- a/web/src/router/index.js +++ b/web/src/router/index.js @@ -47,6 +47,12 @@ const routes = [ name: 'Psks', component: () => import('../views/Psks.vue'), meta: { title: 'PSK 管理' } + }, + { + path: 'logs', + name: 'ApiLogs', + component: () => import('../views/ApiLogs.vue'), + meta: { title: '访问日志' } } ] } diff --git a/web/src/views/ApiLogs.vue b/web/src/views/ApiLogs.vue new file mode 100644 index 0000000..62ae254 --- /dev/null +++ b/web/src/views/ApiLogs.vue @@ -0,0 +1,95 @@ + + + diff --git a/web/src/views/Layout.vue b/web/src/views/Layout.vue index d93be69..6b0e1f3 100644 --- a/web/src/views/Layout.vue +++ b/web/src/views/Layout.vue @@ -34,6 +34,10 @@ PSK 管理 + + + 访问日志 +