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; } }