feat: add signed open API for task deletion

This commit is contained in:
Your Name 2026-02-23 21:35:46 +08:00
parent fae8247132
commit c902f475c8
2 changed files with 191 additions and 1 deletions

View File

@ -0,0 +1,184 @@
package com.designhub.controller;
import com.designhub.common.Result;
import com.designhub.service.SystemConfigService;
import com.designhub.service.TaskService;
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.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
@Slf4j
@Tag(name = "开放任务接口", description = "对外机器人调用接口")
@RestController
@RequestMapping("/api/open/tasks")
@RequiredArgsConstructor
public class OpenTaskController {
private static final String KEY_ENABLED = "openapi.task_delete_enabled";
private static final String KEY_APP_KEY = "openapi.task_delete_app_key";
private static final String KEY_APP_SECRET = "openapi.task_delete_app_secret";
private static final String KEY_SIGN_EXPIRE_SECONDS = "openapi.task_delete_sign_expire_seconds";
private static final String KEY_IP_WHITELIST = "openapi.task_delete_ip_whitelist";
private final TaskService taskService;
private final SystemConfigService systemConfigService;
@Operation(summary = "开放接口删除任务", description = "供外部机器人按任务编号删除任务")
@PostMapping("/delete")
public Result<Map<String, Object>> deleteTaskByTaskNo(
@RequestBody OpenDeleteTaskRequest requestBody,
HttpServletRequest request
) {
String traceId = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
String clientIp = resolveClientIp(request);
try {
if (!"true".equalsIgnoreCase(systemConfigService.getConfigValue(KEY_ENABLED, "false"))) {
return Result.error("开放删除接口未启用");
}
if (requestBody == null || !StringUtils.hasText(requestBody.getTaskNo())
|| !StringUtils.hasText(requestBody.getAppKey())
|| !StringUtils.hasText(requestBody.getTimestamp())
|| !StringUtils.hasText(requestBody.getSign())) {
return Result.error("参数不完整");
}
String configuredKey = systemConfigService.getConfigValue(KEY_APP_KEY, "");
String configuredSecret = systemConfigService.getConfigValue(KEY_APP_SECRET, "");
if (!StringUtils.hasText(configuredKey) || !StringUtils.hasText(configuredSecret)) {
return Result.error("开放接口密钥未配置");
}
if (!configuredKey.equals(requestBody.getAppKey())) {
log.warn("开放删除接口appKey不匹配: traceId={}, ip={}", traceId, clientIp);
return Result.error("鉴权失败");
}
if (!isIpAllowed(clientIp)) {
log.warn("开放删除接口IP不在白名单: traceId={}, ip={}", traceId, clientIp);
return Result.error("IP未授权");
}
long nowMillis = Instant.now().toEpochMilli();
long requestMillis = parseTimestampMillis(requestBody.getTimestamp());
int expireSeconds = systemConfigService.getIntValue(KEY_SIGN_EXPIRE_SECONDS, 300);
long allowedSkew = Math.max(30, expireSeconds) * 1000L;
if (Math.abs(nowMillis - requestMillis) > allowedSkew) {
return Result.error("签名已过期");
}
String payload = requestBody.getAppKey() + "|" + requestBody.getTaskNo().trim() + "|" + requestBody.getTimestamp();
String expectedSign = hmacSha256Hex(configuredSecret, payload);
if (!secureEquals(expectedSign, requestBody.getSign().trim().toLowerCase(Locale.ROOT))) {
log.warn("开放删除接口签名不匹配: traceId={}, ip={}, taskNo={}",
traceId, clientIp, requestBody.getTaskNo());
return Result.error("签名校验失败");
}
taskService.deleteTaskByTaskNo(requestBody.getTaskNo().trim());
Map<String, Object> result = new LinkedHashMap<>();
result.put("taskNo", requestBody.getTaskNo().trim());
result.put("deleted", true);
result.put("traceId", traceId);
result.put("time", Instant.now().toString());
log.info("开放删除接口执行成功: traceId={}, ip={}, taskNo={}", traceId, clientIp, requestBody.getTaskNo());
return Result.success(result);
} catch (IllegalArgumentException e) {
return Result.error(e.getMessage());
} catch (RuntimeException e) {
log.error("开放删除接口执行失败: traceId={}, ip={}, taskNo={}, error={}",
traceId, clientIp, requestBody != null ? requestBody.getTaskNo() : null, e.getMessage(), e);
return Result.error(e.getMessage());
} catch (Exception e) {
log.error("开放删除接口系统异常: traceId={}, ip={}", traceId, clientIp, e);
return Result.error("系统异常,请联系管理员");
}
}
private boolean isIpAllowed(String ip) {
String whitelist = systemConfigService.getConfigValue(KEY_IP_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 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);
}
@Data
public static class OpenDeleteTaskRequest {
private String appKey;
private String taskNo;
private String timestamp;
private String sign;
}
}

View File

@ -241,6 +241,13 @@ public class SystemConfigService {
createConfigIfNotExists("notification.dingtalk_enabled", "false", "notification", "启用钉钉通知", "是否启用钉钉消息推送", "boolean", 1);
createConfigIfNotExists("notification.email_enabled", "false", "notification", "启用邮件通知", "是否启用邮件通知", "boolean", 1);
// 开放接口配置(机器人)
createConfigIfNotExists("openapi.task_delete_enabled", "false", "openapi", "启用开放删除任务接口", "开启后允许外部机器人调用删除任务接口", "boolean", 1);
createConfigIfNotExists("openapi.task_delete_app_key", "", "openapi", "开放删除接口AppKey", "机器人调用删除任务接口的appKey", "string", 1);
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("incentive.enabled", "true", "incentive", "启用激励系统", "是否启用激励功能", "boolean", 1);
createConfigIfNotExists("incentive.daily_checkin_score", "5", "incentive", "每日签到奖励积分", "设计师每日签到可获得的积分", "number", 1);
@ -289,4 +296,3 @@ public class SystemConfigService {
}
}
}