From 83e150b8c9f1141a8c639acb5b7492c610e8aef7 Mon Sep 17 00:00:00 2001 From: mediabot-pt <295750538+mediabot-pt@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:36:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=80=82=E9=85=8D?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E6=8E=A5=E5=8F=A3=EF=BC=8Cmediabot=20?= =?UTF-8?q?=E5=8F=AF=E6=8F=90=E4=BA=A4=E7=AB=99=E7=82=B9=E8=AE=BF=E9=97=AE?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=EF=BC=8C=E7=AE=A1=E7=90=86=E5=91=98=E5=8F=AF?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E9=80=82=E9=85=8D=E6=98=AF=E5=90=A6=E6=AD=A3?= =?UTF-8?q?=E7=A1=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/AccessResultController.java | 110 ++++++++ .../java/com/par/core/config/WebConfig.java | 1 + .../com/par/core/dto/AccessResultDTO.java | 26 ++ .../com/par/core/entity/AccessResult.java | 53 ++++ .../par/core/mapper/AccessResultMapper.java | 22 ++ .../par/core/service/AccessResultService.java | 44 ++++ .../service/impl/AccessResultServiceImpl.java | 86 +++++++ .../db/migration/V10__access_results.sql | 20 ++ web/src/api/index.js | 6 + web/src/router/index.js | 6 + web/src/views/AccessResults.vue | 235 ++++++++++++++++++ web/src/views/Layout.vue | 4 + 12 files changed, 613 insertions(+) create mode 100644 par-api/src/main/java/com/par/api/controller/AccessResultController.java create mode 100644 par-core/src/main/java/com/par/core/dto/AccessResultDTO.java create mode 100644 par-core/src/main/java/com/par/core/entity/AccessResult.java create mode 100644 par-core/src/main/java/com/par/core/mapper/AccessResultMapper.java create mode 100644 par-core/src/main/java/com/par/core/service/AccessResultService.java create mode 100644 par-core/src/main/java/com/par/core/service/impl/AccessResultServiceImpl.java create mode 100644 par-core/src/main/resources/db/migration/V10__access_results.sql create mode 100644 web/src/views/AccessResults.vue diff --git a/par-api/src/main/java/com/par/api/controller/AccessResultController.java b/par-api/src/main/java/com/par/api/controller/AccessResultController.java new file mode 100644 index 0000000..e13e09d --- /dev/null +++ b/par-api/src/main/java/com/par/api/controller/AccessResultController.java @@ -0,0 +1,110 @@ +package com.par.api.controller; + +import com.par.core.dto.AccessResultDTO; +import com.par.core.dto.ApiResponse; +import com.par.core.entity.AccessResult; +import com.par.core.service.AccessResultService; +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 AccessResultController { + + private final AccessResultService accessResultService; + + // ==================== mediabot 提交接口(PSK 鉴权) ==================== + + /** + * mediabot 提交访问站点的返回数据 + */ + @PostMapping("/api/v1/sites/{siteId}/access-result") + public ApiResponse submitAccessResult( + @PathVariable("siteId") String siteId, + @RequestBody Map payload, + HttpServletRequest request) { + + String configVersion = (String) payload.get("configVersion"); + String contentType = (String) payload.getOrDefault("contentType", "html"); + String content = (String) payload.get("content"); + String url = (String) payload.get("url"); + Integer statusCode = payload.get("statusCode") instanceof Integer i ? i : null; + Integer responseTimeMs = payload.get("responseTimeMs") instanceof Integer i ? i : null; + + if (content == null || content.isBlank()) { + return ApiResponse.error(400, "content is required"); + } + + String clientId = (String) request.getAttribute("clientId"); + Long submitterId = (Long) request.getAttribute("accountId"); + + AccessResult result = accessResultService.submit( + siteId, configVersion, clientId, contentType, content, + url, statusCode, responseTimeMs, submitterId); + + return ApiResponse.success(accessResultService.toDTO(result)); + } + + // ==================== 管理员查看接口(JWT 鉴权) ==================== + + /** + * 获取所有访问结果列表 + */ + @GetMapping("/api/v1/admin/access-results") + public ApiResponse> listAll() { + return ApiResponse.success(accessResultService.listAll()); + } + + /** + * 获取指定站点的访问结果 + */ + @GetMapping("/api/v1/admin/access-results/by-site/{siteId}") + public ApiResponse> listBySiteId(@PathVariable("siteId") String siteId) { + return ApiResponse.success(accessResultService.listBySiteId(siteId)); + } + + /** + * 获取单个访问结果详情(含完整内容) + */ + @GetMapping("/api/v1/admin/access-results/{id}") + public ApiResponse getById(@PathVariable("id") Long id) { + AccessResult result = accessResultService.getById(id); + if (result == null) { + return ApiResponse.error(404, "访问结果不存在"); + } + return ApiResponse.success(accessResultService.toDTO(result)); + } + + /** + * 管理员验证适配结果 + */ + @PostMapping("/api/v1/admin/access-results/{id}/validate") + public ApiResponse validate( + @PathVariable("id") Long id, + @RequestBody Map payload) { + + Boolean isValid = payload.get("isValid") instanceof Boolean b ? b : null; + String note = (String) payload.getOrDefault("note", ""); + + if (isValid == null) { + return ApiResponse.error(400, "isValid is required"); + } + + try { + AccessResult result = accessResultService.validate(id, isValid, note); + return ApiResponse.success(accessResultService.toDTO(result)); + } catch (IllegalArgumentException e) { + return ApiResponse.error(404, e.getMessage()); + } + } +} 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 30b2a09..f5da0dd 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,6 +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/admin/**", "/api/v1/account/**"); } } diff --git a/par-core/src/main/java/com/par/core/dto/AccessResultDTO.java b/par-core/src/main/java/com/par/core/dto/AccessResultDTO.java new file mode 100644 index 0000000..5b181e1 --- /dev/null +++ b/par-core/src/main/java/com/par/core/dto/AccessResultDTO.java @@ -0,0 +1,26 @@ +package com.par.core.dto; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 站点访问结果 DTO + */ +@Data +public class AccessResultDTO { + + private Long id; + private String siteId; + private String configVersion; + private String clientId; + private String contentType; + private String content; + private String url; + private Integer statusCode; + private Integer responseTimeMs; + private Boolean isValid; + private String validationNote; + private Long submitterId; + private LocalDateTime createdAt; +} diff --git a/par-core/src/main/java/com/par/core/entity/AccessResult.java b/par-core/src/main/java/com/par/core/entity/AccessResult.java new file mode 100644 index 0000000..888bf91 --- /dev/null +++ b/par-core/src/main/java/com/par/core/entity/AccessResult.java @@ -0,0 +1,53 @@ +package com.par.core.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 站点访问结果实体(mediabot 提交访问站点返回的 HTML/JSON 数据) + */ +@Data +@TableName("access_results") +public class AccessResult { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("site_id") + private String siteId; + + @TableField("config_version") + private String configVersion; + + @TableField("client_id") + private String clientId; + + @TableField("content_type") + private String contentType; + + @TableField("content") + private String content; + + @TableField("url") + private String url; + + @TableField("status_code") + private Integer statusCode; + + @TableField("response_time_ms") + private Integer responseTimeMs; + + @TableField("is_valid") + private Boolean isValid; + + @TableField("validation_note") + private String validationNote; + + @TableField("submitter_id") + private Long submitterId; + + @TableField(value = "created_at", fill = FieldFill.INSERT) + private LocalDateTime createdAt; +} diff --git a/par-core/src/main/java/com/par/core/mapper/AccessResultMapper.java b/par-core/src/main/java/com/par/core/mapper/AccessResultMapper.java new file mode 100644 index 0000000..5511a63 --- /dev/null +++ b/par-core/src/main/java/com/par/core/mapper/AccessResultMapper.java @@ -0,0 +1,22 @@ +package com.par.core.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.par.core.entity.AccessResult; +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 AccessResultMapper extends BaseMapper { + + @Select("SELECT * FROM access_results WHERE site_id = #{siteId} ORDER BY created_at DESC") + List selectBySiteId(@Param("siteId") String siteId); + + @Select("SELECT * FROM access_results ORDER BY created_at DESC") + List selectAll(); +} diff --git a/par-core/src/main/java/com/par/core/service/AccessResultService.java b/par-core/src/main/java/com/par/core/service/AccessResultService.java new file mode 100644 index 0000000..3e38ecd --- /dev/null +++ b/par-core/src/main/java/com/par/core/service/AccessResultService.java @@ -0,0 +1,44 @@ +package com.par.core.service; + +import com.par.core.dto.AccessResultDTO; +import com.par.core.entity.AccessResult; + +import java.util.List; + +/** + * 站点访问结果服务接口 + */ +public interface AccessResultService { + + /** + * 提交访问结果(mediabot 调用) + */ + AccessResult submit(String siteId, String configVersion, String clientId, + String contentType, String content, String url, + Integer statusCode, Integer responseTimeMs, Long submitterId); + + /** + * 获取所有访问结果 + */ + List listAll(); + + /** + * 获取指定站点的访问结果 + */ + List listBySiteId(String siteId); + + /** + * 获取单个访问结果详情 + */ + AccessResult getById(Long id); + + /** + * 标记验证结果(有效/无效) + */ + AccessResult validate(Long id, Boolean isValid, String note); + + /** + * 实体转 DTO + */ + AccessResultDTO toDTO(AccessResult entity); +} diff --git a/par-core/src/main/java/com/par/core/service/impl/AccessResultServiceImpl.java b/par-core/src/main/java/com/par/core/service/impl/AccessResultServiceImpl.java new file mode 100644 index 0000000..0c8b1da --- /dev/null +++ b/par-core/src/main/java/com/par/core/service/impl/AccessResultServiceImpl.java @@ -0,0 +1,86 @@ +package com.par.core.service.impl; + +import com.par.core.dto.AccessResultDTO; +import com.par.core.entity.AccessResult; +import com.par.core.mapper.AccessResultMapper; +import com.par.core.service.AccessResultService; +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 AccessResultServiceImpl implements AccessResultService { + + private final AccessResultMapper mapper; + + @Override + public AccessResult submit(String siteId, String configVersion, String clientId, + String contentType, String content, String url, + Integer statusCode, Integer responseTimeMs, Long submitterId) { + AccessResult result = new AccessResult(); + result.setSiteId(siteId); + result.setConfigVersion(configVersion); + result.setClientId(clientId); + result.setContentType(contentType != null ? contentType : "html"); + result.setContent(content); + result.setUrl(url); + result.setStatusCode(statusCode); + result.setResponseTimeMs(responseTimeMs); + result.setSubmitterId(submitterId != null ? submitterId : 0L); + mapper.insert(result); + log.info("Access result submitted: siteId={}, version={}, clientId={}, id={}", + siteId, configVersion, clientId, result.getId()); + return result; + } + + @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 AccessResult getById(Long id) { + return mapper.selectById(id); + } + + @Override + public AccessResult validate(Long id, Boolean isValid, String note) { + AccessResult result = mapper.selectById(id); + if (result == null) throw new IllegalArgumentException("访问结果不存在: " + id); + result.setIsValid(isValid); + result.setValidationNote(note); + mapper.updateById(result); + log.info("Access result validated: id={}, isValid={}", id, isValid); + return result; + } + + @Override + public AccessResultDTO toDTO(AccessResult entity) { + if (entity == null) return null; + AccessResultDTO dto = new AccessResultDTO(); + dto.setId(entity.getId()); + dto.setSiteId(entity.getSiteId()); + dto.setConfigVersion(entity.getConfigVersion()); + dto.setClientId(entity.getClientId()); + dto.setContentType(entity.getContentType()); + dto.setContent(entity.getContent()); + dto.setUrl(entity.getUrl()); + dto.setStatusCode(entity.getStatusCode()); + dto.setResponseTimeMs(entity.getResponseTimeMs()); + dto.setIsValid(entity.getIsValid()); + dto.setValidationNote(entity.getValidationNote()); + dto.setSubmitterId(entity.getSubmitterId()); + dto.setCreatedAt(entity.getCreatedAt()); + return dto; + } +} diff --git a/par-core/src/main/resources/db/migration/V10__access_results.sql b/par-core/src/main/resources/db/migration/V10__access_results.sql new file mode 100644 index 0000000..313c34e --- /dev/null +++ b/par-core/src/main/resources/db/migration/V10__access_results.sql @@ -0,0 +1,20 @@ +-- PAR Flyway V10: 站点访问结果表(mediabot 提交访问站点返回的 HTML/JSON 数据,用于验证适配是否正确) +CREATE TABLE access_results ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', + site_id VARCHAR(64) NOT NULL COMMENT '站点ID', + config_version VARCHAR(20) COMMENT '使用的配置版本号', + client_id VARCHAR(128) COMMENT '提交者标识(mediabot 客户端 ID)', + content_type VARCHAR(10) NOT NULL DEFAULT 'html' COMMENT '数据类型:html / json', + content MEDIUMTEXT NOT NULL COMMENT '访问站点返回的内容', + url VARCHAR(500) COMMENT '访问的站点 URL', + status_code INT COMMENT 'HTTP 状态码', + response_time_ms INT COMMENT '响应耗时(毫秒)', + is_valid TINYINT(1) COMMENT '适配是否有效(null=未验证, 1=有效, 0=无效)', + validation_note VARCHAR(500) COMMENT '管理员验证备注', + submitter_id BIGINT UNSIGNED COMMENT '提交者账户 ID(PSK 提交时为 0)', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '提交时间', + PRIMARY KEY (id), + KEY idx_site_id (site_id), + KEY idx_created (created_at), + KEY idx_valid (is_valid) +) 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 72ca3ae..523a3ef 100644 --- a/web/src/api/index.js +++ b/web/src/api/index.js @@ -66,5 +66,11 @@ export default { emailStats: () => api.get('/admin/logs/stats/email'), pathStats: () => api.get('/admin/logs/stats/path'), }, + accessResults: { + list: () => api.get('/admin/access-results'), + bySite: (siteId) => api.get(`/admin/access-results/by-site/${siteId}`), + get: (id) => api.get(`/admin/access-results/${id}`), + validate: (id, isValid, note) => api.post(`/admin/access-results/${id}/validate`, { isValid, note: note || '' }), + }, health: () => api.get('/health', { baseURL: '' }) } diff --git a/web/src/router/index.js b/web/src/router/index.js index fc03fbb..152e4fa 100644 --- a/web/src/router/index.js +++ b/web/src/router/index.js @@ -53,6 +53,12 @@ const routes = [ name: 'ApiLogs', component: () => import('../views/ApiLogs.vue'), meta: { title: '访问日志' } + }, + { + path: 'access-results', + name: 'AccessResults', + component: () => import('../views/AccessResults.vue'), + meta: { title: '适配验证' } } ] } diff --git a/web/src/views/AccessResults.vue b/web/src/views/AccessResults.vue new file mode 100644 index 0000000..3cb9a45 --- /dev/null +++ b/web/src/views/AccessResults.vue @@ -0,0 +1,235 @@ + + + + + diff --git a/web/src/views/Layout.vue b/web/src/views/Layout.vue index 6b0e1f3..06ad45d 100644 --- a/web/src/views/Layout.vue +++ b/web/src/views/Layout.vue @@ -38,6 +38,10 @@ 访问日志 + + + 适配验证 +