头中提取
+ *
+ * @param request HTTP 请求
+ * @return token 字符串,解析失败返回 null
+ */
+ public String resolveToken(HttpServletRequest request) {
+ String authHeader = request.getHeader("Authorization");
+ if (StrUtil.isBlank(authHeader)) {
+ return null;
+ }
+
+ // 支持 "Bearer xxx" 和 "bearer xxx" 格式
+ String trimmed = authHeader.trim();
+ if (trimmed.length() > 7
+ && "bearer".equalsIgnoreCase(trimmed.substring(0, 6))
+ && trimmed.charAt(6) == ' ') {
+ return trimmed.substring(7).trim();
+ }
+
+ return null;
+ }
+
+ /**
+ * 刷新 token 有效期(延长使用时间)
+ *
+ * @param token Bearer Token
+ */
+ public void refreshToken(String token) {
+ if (StrUtil.isBlank(token)) return;
+ String cacheValue = webConfig.loginuserCache().get(CACHE_PREFIX + token);
+ if (StrUtil.isBlank(cacheValue)) return;
+
+ JSONObject payload = JSON.parseObject(cacheValue);
+ long newExpireAt = System.currentTimeMillis() + DEFAULT_EXPIRE_MILLIS;
+ payload.put("expireAt", newExpireAt);
+ webConfig.loginuserCache().put(CACHE_PREFIX + token, payload.toJSONString(), DEFAULT_EXPIRE_MILLIS);
+ }
+
+ /**
+ * 撤销 token
+ *
+ * @param token Bearer Token
+ */
+ public void revokeToken(String token) {
+ if (StrUtil.isNotBlank(token)) {
+ webConfig.loginuserCache().remove(CACHE_PREFIX + token);
+ log.info("Bearer Token 已撤销");
+ }
+ }
+
+ private String getClientIp(HttpServletRequest request) {
+ String ip = request.getHeader("X-Forwarded-For");
+ if (StrUtil.isNotBlank(ip)) {
+ return ip.split(",")[0].trim();
+ }
+ return request.getRemoteAddr();
+ }
+
+ private String maskToken(String token) {
+ if (token == null || token.length() <= 8) return "***";
+ return token.substring(0, 4) + "***" + token.substring(token.length() - 4);
+ }
+
+ /**
+ * Bearer Token 创建结果
+ */
+ public static class BearerTokenResult {
+ /** 访问令牌 */
+ private String accessToken;
+ /** 令牌类型 */
+ private String tokenType;
+ /** 有效期(秒) */
+ private long expiresIn;
+ /** 过期时间戳 */
+ private long expireAt;
+
+ public String getAccessToken() { return accessToken; }
+ public void setAccessToken(String accessToken) { this.accessToken = accessToken; }
+ public String getTokenType() { return tokenType; }
+ public void setTokenType(String tokenType) { this.tokenType = tokenType; }
+ public long getExpiresIn() { return expiresIn; }
+ public void setExpiresIn(long expiresIn) { this.expiresIn = expiresIn; }
+ public long getExpireAt() { return expireAt; }
+ public void setExpireAt(long expireAt) { this.expireAt = expireAt; }
+ }
+}
diff --git a/backend/src/main/java/com/yfd/platform/config/OAuth2TokenController.java b/backend/src/main/java/com/yfd/platform/config/OAuth2TokenController.java
new file mode 100644
index 00000000..9be0d6b9
--- /dev/null
+++ b/backend/src/main/java/com/yfd/platform/config/OAuth2TokenController.java
@@ -0,0 +1,127 @@
+package com.yfd.platform.config;
+
+import cn.hutool.core.util.StrUtil;
+import com.yfd.platform.config.BearerTokenService.BearerTokenResult;
+import jakarta.annotation.Resource;
+import jakarta.servlet.http.HttpServletRequest;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * OAuth2 Token 端点
+ *
+ * 供外部系统通过 client_credentials 模式获取 Bearer Token。
+ *
+ *
请求格式(参考 鉴权与文件上传接口说明.md 3.2 节):
+ *
+ * POST /oauth2/token
+ * Content-Type: application/x-www-form-urlencoded
+ * Authorization: Basic
+ *
+ * grant_type=client_credentials
+ *
+ *
+ * 返回格式:
+ *
+ * {
+ * "success": true,
+ * "errorCode": 0,
+ * "message": "获取token成功",
+ * "data": {
+ * "access_token": "xxx",
+ * "token_type": "bearer",
+ * "expires_in": 7200
+ * }
+ * }
+ *
+ */
+@RestController
+@RequestMapping("/api/oauth2")
+public class OAuth2TokenController {
+
+ private static final Logger log = LoggerFactory.getLogger(OAuth2TokenController.class);
+
+ @Resource
+ private BearerTokenService bearerTokenService;
+
+ /**
+ * 获取 Access Token(client_credentials 模式)
+ */
+ @PostMapping("/oauth/token")
+ public Map getToken(HttpServletRequest request) {
+ Map result = new HashMap<>();
+
+ // 解析 Authorization: Basic
+ String authHeader = request.getHeader("Authorization");
+ if (StrUtil.isBlank(authHeader) || !authHeader.trim().toLowerCase().startsWith("basic ")) {
+ log.warn("获取 Token 失败: 缺少有效的 Basic Authorization 头");
+ result.put("success", false);
+ result.put("errorCode", -1);
+ result.put("message", "缺少有效的 Authorization: Basic 认证信息");
+ return result;
+ }
+
+ String clientId = resolveClientId(authHeader);
+ if (StrUtil.isBlank(clientId)|| !"client".equals(clientId)) {
+ log.warn("获取 Token 失败: 无法解析 client");
+ result.put("success", false);
+ result.put("errorCode", -1);
+ result.put("message", "无法解析客户端标识");
+ return result;
+ }
+
+ // 验证 grant_type
+ String grantType = request.getParameter("grant_type");
+ if (StrUtil.isBlank(grantType) || !"client_credentials".equals(grantType)) {
+ log.warn("获取 Token 失败: 不支持的 grant_type={}", grantType);
+ result.put("success", false);
+ result.put("errorCode", -1);
+ result.put("message", "仅支持 client_credentials 授权模式");
+ return result;
+ }
+
+ BearerTokenResult tokenResult = bearerTokenService.createToken(request, clientId);
+
+ Map data = new HashMap<>();
+ data.put("access_token", tokenResult.getAccessToken());
+ data.put("token_type", tokenResult.getTokenType());
+ data.put("expires_in", tokenResult.getExpiresIn());
+
+ result.put("success", true);
+ result.put("errorCode", 0);
+ result.put("message", "获取token成功");
+ result.put("data", data);
+
+ log.info("OAuth2 Token 签发成功, clientId={}", clientId);
+ return result;
+ }
+
+ /**
+ * 从 Authorization: Basic 头中解析 client
+ */
+ private String resolveClientId(String authHeader) {
+ try {
+ String base64 = authHeader.substring(6).trim();
+ byte[] decoded = Base64.getDecoder().decode(base64);
+ String credentials = new String(decoded, StandardCharsets.UTF_8);
+ // 格式: clientId:clientSecret
+ int colonIdx = credentials.indexOf(':');
+ if (colonIdx > 0) {
+ return credentials.substring(0, colonIdx);
+ }
+ return credentials;
+ } catch (Exception e) {
+ log.warn("解析 Basic 认证信息失败: {}", e.getMessage());
+ return null;
+ }
+ }
+
+}
diff --git a/backend/src/main/java/com/yfd/platform/config/SecurityConfig.java b/backend/src/main/java/com/yfd/platform/config/SecurityConfig.java
index 8fe0309f..a1178822 100644
--- a/backend/src/main/java/com/yfd/platform/config/SecurityConfig.java
+++ b/backend/src/main/java/com/yfd/platform/config/SecurityConfig.java
@@ -42,6 +42,9 @@ public class SecurityConfig {
@Autowired
private RegisterAccessTokenFilter registerAccessTokenFilter;
+ @Autowired
+ private BearerTokenFilter bearerTokenFilter;
+
@Autowired
private AuthenticationException authenticationException;
@@ -60,7 +63,7 @@ public class SecurityConfig {
.requestMatchers("/data/fishDraft/previewFile").permitAll()
.requestMatchers("/tempFile/**").permitAll()
.requestMatchers("/system/user/auditUser").permitAll()
- .requestMatchers("/register/accessToken").permitAll()
+ .requestMatchers("/api/oauth2/oauth/token").permitAll()
.requestMatchers("/sys/psbmodulelbb/**").permitAll()
.requestMatchers("/base/operationLog/**").permitAll()
// .requestMatchers("/eng/**").permitAll()
@@ -114,6 +117,7 @@ public class SecurityConfig {
.cors(cors -> {});
http.addFilterBefore(registerAccessTokenFilter, UsernamePasswordAuthenticationFilter.class);
+ http.addFilterBefore(bearerTokenFilter, UsernamePasswordAuthenticationFilter.class);
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
http.exceptionHandling(ex -> ex