diff --git a/backend/src/main/java/com/designhub/controller/OpenAnalyticsController.java b/backend/src/main/java/com/designhub/controller/OpenAnalyticsController.java new file mode 100644 index 0000000..49717d0 --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/OpenAnalyticsController.java @@ -0,0 +1,358 @@ +package com.designhub.controller; + +import com.designhub.common.Result; +import com.designhub.service.AnalyticsService; +import com.designhub.service.SystemConfigService; +import com.designhub.vo.*; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.*; + +@Slf4j +@Tag(name = "开放数据分析接口", description = "对外机器人查询数据分析数据") +@RestController +@RequestMapping("/api/open/analytics") +@RequiredArgsConstructor +public class OpenAnalyticsController { + + private static final String KEY_ENABLED = "openapi.analytics_query_enabled"; + private static final String KEY_APP_KEY = "openapi.analytics_query_app_key"; + private static final String KEY_APP_SECRET = "openapi.analytics_query_app_secret"; + private static final String KEY_SIGN_EXPIRE_SECONDS = "openapi.analytics_query_sign_expire_seconds"; + private static final String KEY_IP_WHITELIST = "openapi.analytics_query_ip_whitelist"; + + private static final String FALLBACK_KEY_APP_KEY = "openapi.task_delete_app_key"; + private static final String FALLBACK_KEY_APP_SECRET = "openapi.task_delete_app_secret"; + private static final String FALLBACK_KEY_SIGN_EXPIRE_SECONDS = "openapi.task_delete_sign_expire_seconds"; + private static final String FALLBACK_KEY_IP_WHITELIST = "openapi.task_delete_ip_whitelist"; + + private final AnalyticsService analyticsService; + private final SystemConfigService systemConfigService; + + @Operation(summary = "开放接口查询数据分析总览", description = "供外部机器人获取数据分析页面核心数据") + @PostMapping("/overview") + public Result> getOverview( + @RequestBody OpenAnalyticsOverviewRequest requestBody, + HttpServletRequest request + ) { + String traceId = UUID.randomUUID().toString().replace("-", "").substring(0, 16); + String clientIp = resolveClientIp(request); + try { + if (!systemConfigService.getBooleanValue(KEY_ENABLED, false)) { + return Result.error("开放数据分析查询接口未启用"); + } + if (requestBody == null + || !StringUtils.hasText(requestBody.getAppKey()) + || !StringUtils.hasText(requestBody.getTimestamp()) + || !StringUtils.hasText(requestBody.getSign())) { + return Result.error("参数不完整"); + } + + OpenApiCredential credential = getCredential(); + if (!StringUtils.hasText(credential.getAppKey()) || !StringUtils.hasText(credential.getAppSecret())) { + return Result.error("开放接口密钥未配置"); + } + if (!credential.getAppKey().equals(requestBody.getAppKey().trim())) { + log.warn("开放数据分析接口appKey不匹配: traceId={}, ip={}", traceId, clientIp); + return Result.error("鉴权失败"); + } + if (!isIpAllowed(clientIp, credential.getIpWhitelist())) { + log.warn("开放数据分析接口IP不在白名单: traceId={}, ip={}", traceId, clientIp); + return Result.error("IP未授权"); + } + + verifyTimestamp(requestBody.getTimestamp(), credential.getSignExpireSeconds()); + + String dateRange = normalizeDateRange(requestBody.getDateRange()); + String month = sanitizeNullableText(requestBody.getMonth()); + String deptId = requestBody.getDeptId() == null ? "" : String.valueOf(requestBody.getDeptId()); + String userId = requestBody.getUserId() == null ? "" : String.valueOf(requestBody.getUserId()); + String includeTaskProgress = String.valueOf(Boolean.TRUE.equals(requestBody.getIncludeTaskProgress())); + String payload = String.join("|", + "analytics_overview", + requestBody.getAppKey().trim(), + dateRange, + month, + deptId, + userId, + includeTaskProgress, + requestBody.getTimestamp().trim()); + + String expectedSign = hmacSha256Hex(credential.getAppSecret(), payload); + if (!secureEquals(expectedSign, requestBody.getSign().trim().toLowerCase(Locale.ROOT))) { + log.warn("开放数据分析接口签名不匹配: traceId={}, ip={}", traceId, clientIp); + return Result.error("签名校验失败"); + } + + Map data = new LinkedHashMap<>(); + data.put("demandStatistics", analyticsService.getDemandStatistics( + null, dateRange, requestBody.getDeptId(), requestBody.getUserId(), month)); + data.put("designerPerformance", analyticsService.getDesignerPerformance( + null, month, requestBody.getDeptId())); + data.put("processEfficiency", analyticsService.getProcessEfficiency(null, month)); + data.put("warningTasks", analyticsService.getWarningTasks(null)); + if (Boolean.TRUE.equals(requestBody.getIncludeTaskProgress())) { + data.put("taskProgressOverview", analyticsService.getTaskProgressOverview( + dateRange, month, requestBody.getDeptId())); + } + + Map result = new LinkedHashMap<>(); + result.put("traceId", traceId); + result.put("time", Instant.now().toString()); + result.put("query", Map.of( + "dateRange", dateRange, + "month", month == null ? "" : month, + "deptId", requestBody.getDeptId() == null ? "" : requestBody.getDeptId(), + "userId", requestBody.getUserId() == null ? "" : requestBody.getUserId() + )); + result.put("data", data); + log.info("开放数据分析总览接口成功: traceId={}, ip={}, deptId={}, month={}", + traceId, clientIp, requestBody.getDeptId(), month); + return Result.success(result); + } catch (IllegalArgumentException e) { + return Result.error(e.getMessage()); + } catch (RuntimeException e) { + log.error("开放数据分析总览接口失败: traceId={}, ip={}, error={}", traceId, clientIp, e.getMessage(), e); + return Result.error(e.getMessage()); + } catch (Exception e) { + log.error("开放数据分析总览接口系统异常: traceId={}, ip={}", traceId, clientIp, e); + return Result.error("系统异常,请联系管理员"); + } + } + + @Operation(summary = "开放接口查询任务进度总览", description = "供外部机器人获取任务进度总览页面数据") + @PostMapping("/task-progress/overview") + public Result> getTaskProgressOverview( + @RequestBody OpenTaskProgressOverviewRequest requestBody, + HttpServletRequest request + ) { + String traceId = UUID.randomUUID().toString().replace("-", "").substring(0, 16); + String clientIp = resolveClientIp(request); + try { + if (!systemConfigService.getBooleanValue(KEY_ENABLED, false)) { + return Result.error("开放数据分析查询接口未启用"); + } + if (requestBody == null + || !StringUtils.hasText(requestBody.getAppKey()) + || !StringUtils.hasText(requestBody.getTimestamp()) + || !StringUtils.hasText(requestBody.getSign())) { + return Result.error("参数不完整"); + } + + OpenApiCredential credential = getCredential(); + if (!StringUtils.hasText(credential.getAppKey()) || !StringUtils.hasText(credential.getAppSecret())) { + return Result.error("开放接口密钥未配置"); + } + if (!credential.getAppKey().equals(requestBody.getAppKey().trim())) { + log.warn("开放任务进度接口appKey不匹配: traceId={}, ip={}", traceId, clientIp); + return Result.error("鉴权失败"); + } + if (!isIpAllowed(clientIp, credential.getIpWhitelist())) { + log.warn("开放任务进度接口IP不在白名单: traceId={}, ip={}", traceId, clientIp); + return Result.error("IP未授权"); + } + + verifyTimestamp(requestBody.getTimestamp(), credential.getSignExpireSeconds()); + + String dateRange = normalizeDateRange(requestBody.getDateRange()); + String month = sanitizeNullableText(requestBody.getMonth()); + String deptId = requestBody.getDeptId() == null ? "" : String.valueOf(requestBody.getDeptId()); + String payload = String.join("|", + "analytics_task_progress_overview", + requestBody.getAppKey().trim(), + dateRange, + month, + deptId, + requestBody.getTimestamp().trim()); + + String expectedSign = hmacSha256Hex(credential.getAppSecret(), payload); + if (!secureEquals(expectedSign, requestBody.getSign().trim().toLowerCase(Locale.ROOT))) { + log.warn("开放任务进度接口签名不匹配: traceId={}, ip={}", traceId, clientIp); + return Result.error("签名校验失败"); + } + + TaskProgressOverviewVO overview = analyticsService.getTaskProgressOverview( + dateRange, month, requestBody.getDeptId()); + + Map result = new LinkedHashMap<>(); + result.put("traceId", traceId); + result.put("time", Instant.now().toString()); + result.put("query", Map.of( + "dateRange", dateRange, + "month", month == null ? "" : month, + "deptId", requestBody.getDeptId() == null ? "" : requestBody.getDeptId() + )); + result.put("data", overview); + log.info("开放任务进度接口成功: traceId={}, ip={}, deptId={}, month={}", + traceId, clientIp, requestBody.getDeptId(), month); + return Result.success(result); + } catch (IllegalArgumentException e) { + return Result.error(e.getMessage()); + } catch (RuntimeException e) { + log.error("开放任务进度接口失败: traceId={}, ip={}, error={}", traceId, clientIp, e.getMessage(), e); + return Result.error(e.getMessage()); + } catch (Exception e) { + log.error("开放任务进度接口系统异常: traceId={}, ip={}", traceId, clientIp, e); + return Result.error("系统异常,请联系管理员"); + } + } + + private OpenApiCredential getCredential() { + String appKey = systemConfigService.getConfigValue(KEY_APP_KEY, ""); + if (!StringUtils.hasText(appKey)) { + appKey = systemConfigService.getConfigValue(FALLBACK_KEY_APP_KEY, ""); + } + + String appSecret = systemConfigService.getConfigValue(KEY_APP_SECRET, ""); + if (!StringUtils.hasText(appSecret)) { + appSecret = systemConfigService.getConfigValue(FALLBACK_KEY_APP_SECRET, ""); + } + + Integer signExpireSeconds = systemConfigService.getIntValue(KEY_SIGN_EXPIRE_SECONDS, null); + if (signExpireSeconds == null || signExpireSeconds <= 0) { + signExpireSeconds = systemConfigService.getIntValue(FALLBACK_KEY_SIGN_EXPIRE_SECONDS, 300); + } + + String ipWhitelist = systemConfigService.getConfigValue(KEY_IP_WHITELIST, ""); + if (!StringUtils.hasText(ipWhitelist)) { + ipWhitelist = systemConfigService.getConfigValue(FALLBACK_KEY_IP_WHITELIST, ""); + } + + OpenApiCredential credential = new OpenApiCredential(); + credential.setAppKey(appKey); + credential.setAppSecret(appSecret); + credential.setSignExpireSeconds(signExpireSeconds == null ? 300 : signExpireSeconds); + credential.setIpWhitelist(ipWhitelist); + return credential; + } + + private boolean isIpAllowed(String ip, String whitelist) { + if (!StringUtils.hasText(whitelist)) { + return true; + } + String normalizedIp = ip == null ? "" : ip.trim(); + for (String item : whitelist.split(",")) { + if (normalizedIp.equals(item.trim())) { + return true; + } + } + return false; + } + + private void verifyTimestamp(String timestamp, int expireSeconds) { + long nowMillis = Instant.now().toEpochMilli(); + long requestMillis = parseTimestampMillis(timestamp); + long allowedSkew = Math.max(30, expireSeconds) * 1000L; + if (Math.abs(nowMillis - requestMillis) > allowedSkew) { + throw new IllegalArgumentException("签名已过期"); + } + } + + private long parseTimestampMillis(String timestamp) { + String value = timestamp == null ? "" : timestamp.trim(); + if (!value.matches("\\d+")) { + throw new IllegalArgumentException("timestamp格式错误"); + } + long ts = Long.parseLong(value); + if (value.length() <= 10) { + return ts * 1000L; + } + if (value.length() == 13) { + return ts; + } + throw new IllegalArgumentException("timestamp长度错误"); + } + + private String resolveClientIp(HttpServletRequest request) { + String xForwardedFor = request.getHeader("X-Forwarded-For"); + if (StringUtils.hasText(xForwardedFor)) { + int idx = xForwardedFor.indexOf(','); + return idx > 0 ? xForwardedFor.substring(0, idx).trim() : xForwardedFor.trim(); + } + String realIp = request.getHeader("X-Real-IP"); + if (StringUtils.hasText(realIp)) { + return realIp.trim(); + } + return request.getRemoteAddr(); + } + + private String hmacSha256Hex(String secret, String payload) throws Exception { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] raw = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(raw.length * 2); + for (byte b : raw) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + private boolean secureEquals(String a, String b) { + if (a == null || b == null) { + return false; + } + byte[] aa = a.getBytes(StandardCharsets.UTF_8); + byte[] bb = b.getBytes(StandardCharsets.UTF_8); + return MessageDigest.isEqual(aa, bb); + } + + private String sanitizeNullableText(String value) { + return StringUtils.hasText(value) ? value.trim() : ""; + } + + private String normalizeDateRange(String dateRange) { + if (!StringUtils.hasText(dateRange)) { + return "month"; + } + String normalized = dateRange.trim().toLowerCase(Locale.ROOT); + if (!Arrays.asList("week", "month", "quarter", "all").contains(normalized)) { + return "month"; + } + return normalized; + } + + @Data + public static class OpenAnalyticsOverviewRequest { + private String appKey; + private String timestamp; + private String sign; + private String dateRange; + private String month; + private Long deptId; + private Long userId; + private Boolean includeTaskProgress; + } + + @Data + public static class OpenTaskProgressOverviewRequest { + private String appKey; + private String timestamp; + private String sign; + private String dateRange; + private String month; + private Long deptId; + } + + @Data + private static class OpenApiCredential { + private String appKey; + private String appSecret; + private Integer signExpireSeconds; + private String ipWhitelist; + } +} diff --git a/backend/src/main/java/com/designhub/service/SystemConfigService.java b/backend/src/main/java/com/designhub/service/SystemConfigService.java index fb4bce8..57c435e 100644 --- a/backend/src/main/java/com/designhub/service/SystemConfigService.java +++ b/backend/src/main/java/com/designhub/service/SystemConfigService.java @@ -247,6 +247,11 @@ public class SystemConfigService { createConfigIfNotExists("openapi.task_delete_app_secret", "", "openapi", "开放删除接口AppSecret", "机器人调用删除任务接口的签名密钥", "string", 1); createConfigIfNotExists("openapi.task_delete_sign_expire_seconds", "300", "openapi", "开放删除接口签名有效期(秒)", "签名允许的时间偏移秒数", "number", 1); createConfigIfNotExists("openapi.task_delete_ip_whitelist", "", "openapi", "开放删除接口IP白名单", "逗号分隔,留空表示不限制", "string", 1); + createConfigIfNotExists("openapi.analytics_query_enabled", "false", "openapi", "启用开放数据分析查询接口", "开启后允许外部机器人查询数据分析页面数据", "boolean", 1); + createConfigIfNotExists("openapi.analytics_query_app_key", "", "openapi", "开放数据分析接口AppKey", "留空则复用删除接口AppKey", "string", 1); + createConfigIfNotExists("openapi.analytics_query_app_secret", "", "openapi", "开放数据分析接口AppSecret", "留空则复用删除接口AppSecret", "string", 1); + createConfigIfNotExists("openapi.analytics_query_sign_expire_seconds", "300", "openapi", "开放数据分析接口签名有效期(秒)", "留空则复用删除接口签名有效期", "number", 1); + createConfigIfNotExists("openapi.analytics_query_ip_whitelist", "", "openapi", "开放数据分析接口IP白名单", "留空则复用删除接口IP白名单", "string", 1); // 激励系统配置 createConfigIfNotExists("incentive.enabled", "true", "incentive", "启用激励系统", "是否启用激励功能", "boolean", 1);