parseRoleIds(Object raw) {
+ if (!(raw instanceof List> list)) {
+ return List.of();
+ }
+ return list.stream()
+ .filter(Objects::nonNull)
+ .map(item -> item instanceof Number n ? n.longValue() : Long.parseLong(item.toString()))
+ .filter(id -> id > 0)
+ .distinct()
+ .toList();
+ }
}
diff --git a/src/main/java/com/wok/supportbot/controller/AuthController.java b/src/main/java/com/wok/supportbot/controller/AuthController.java
new file mode 100644
index 0000000..e20f60e
--- /dev/null
+++ b/src/main/java/com/wok/supportbot/controller/AuthController.java
@@ -0,0 +1,127 @@
+package com.wok.supportbot.controller;
+
+import com.wok.supportbot.entity.ApiKey;
+import com.wok.supportbot.security.SdkJwtTokenProvider;
+import com.wok.supportbot.service.ApiKeyService;
+import com.wok.supportbot.service.CustomerServiceRoleService;
+import com.wok.supportbot.service.CustomerServiceRoleService.RoleBrief;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.ResponseEntity;
+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 java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * SDK 认证控制器
+ * 提供 Token 换取接口,客户后端用 API Key 换取 SDK JWT Token + 角色列表。
+ *
+ * 路径在 /open-api/ 下,请求首先由 ApiKeyAuthFilter 校验 X-API-Key,
+ * 校验通过后 ApiKey 信息存入 request.attribute("apiKey"),本控制器直接使用。
+ *
+ * 角色来源: 优先使用 API Key 绑定的角色列表(role_ids),
+ * 未绑定时返回所有启用角色(向后兼容)。
+ */
+@Slf4j
+@RestController("sdkAuthController")
+@RequestMapping("/open-api/auth")
+public class AuthController {
+
+ private final CustomerServiceRoleService roleService;
+ private final SdkJwtTokenProvider sdkJwtTokenProvider;
+ private final ApiKeyService apiKeyService;
+
+ public AuthController(CustomerServiceRoleService roleService,
+ SdkJwtTokenProvider sdkJwtTokenProvider,
+ ApiKeyService apiKeyService) {
+ this.roleService = roleService;
+ this.sdkJwtTokenProvider = sdkJwtTokenProvider;
+ this.apiKeyService = apiKeyService;
+ }
+
+ /**
+ * Token 换取接口
+ *
+ * 前置条件: ApiKeyAuthFilter 已校验 X-API-Key,存入 request.attribute("apiKey")
+ *
+ * 返回: SDK JWT Token、过期时间(秒)、可用角色列表
+ *
+ * @param request HTTP 请求(含已鉴权的 API Key 信息)
+ * @param body 请求体(可选,支持自定义 ttl 秒数)
+ * @return {token, expiresIn, roles[]}
+ */
+ @PostMapping("/token")
+ public ResponseEntity> getToken(HttpServletRequest request,
+ @RequestBody(required = false) TokenRequest body) {
+ // 1. 获取已鉴权的 API Key(由 ApiKeyAuthFilter 注入)
+ ApiKey apiKey = (ApiKey) request.getAttribute("apiKey");
+ if (apiKey == null) {
+ return ResponseEntity.status(401).body(Map.of(
+ "success", false,
+ "message", "API Key 鉴权失败"
+ ));
+ }
+
+ // 2. 读取 API Key 绑定的角色列表
+ List boundRoleIds = apiKeyService.parseRoleIds(apiKey);
+
+ // 3. 查询角色:有绑定则用绑定列表,否则返回所有启用角色(向后兼容)
+ List roles;
+ if (!boundRoleIds.isEmpty()) {
+ roles = roleService.listRolesByIds(boundRoleIds);
+ } else {
+ roles = roleService.listEnabledRolesBrief();
+ }
+ if (roles.isEmpty()) {
+ return ResponseEntity.status(403).body(Map.of(
+ "success", false,
+ "message", "无可用客服角色"
+ ));
+ }
+
+ List roleIds = roles.stream()
+ .map(RoleBrief::id)
+ .collect(Collectors.toList());
+
+ // 4. 计算过期时间(默认 2 小时,钳制到 [5min, 24h])
+ long ttlMs = 7200000L;
+ if (body != null && body.ttl() != null) {
+ ttlMs = Math.max(body.ttl() * 1000L, 300000L);
+ ttlMs = Math.min(ttlMs, 86400000L);
+ }
+
+ // 5. 签发 SDK JWT(subject = apiKeyId)
+ String token = sdkJwtTokenProvider.generateToken(
+ apiKey.getId().toString(),
+ SdkJwtTokenProvider.maskApiKey(apiKey.getKeyValue()),
+ roleIds,
+ ttlMs
+ );
+
+ // 6. 返回 Token + 角色列表
+ log.info("SDK Token 签发成功: apiKeyId={}, roleCount={}", apiKey.getId(), roles.size());
+ return ResponseEntity.ok(Map.of(
+ "success", true,
+ "token", token,
+ "expiresIn", ttlMs / 1000,
+ "roles", roles.stream().map(r -> Map.of(
+ "id", r.id().toString(),
+ "key", r.roleKey(),
+ "name", r.name()
+ )).collect(Collectors.toList())
+ ));
+ }
+
+ /**
+ * 请求体 DTO
+ *
+ * @param ttl 期望的 Token 有效期(秒),会被钳制到 [300, 86400]
+ */
+ public record TokenRequest(Long ttl) {
+ }
+}
diff --git a/src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java b/src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java
index 6946f3c..51bcf31 100644
--- a/src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java
+++ b/src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java
@@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@@ -27,7 +28,7 @@ public class CustomerServiceRoleController {
public ResponseEntity