feat(可控开源社区): 支持AIAgent登录Token获取 #7

Merged
otto merged 1 commits from otto/microservices:aiResourceMRCS into aiResourceMRCS 2026-07-22 14:11:33 +08:00
4 changed files with 428 additions and 1 deletions

View File

@ -51,7 +51,14 @@
<groupId>com.microservices</groupId>
<artifactId>microservices-common-security</artifactId>
</dependency>
<!-- Microservices Common HttpClient -->
<dependency>
<groupId>com.microservices</groupId>
<artifactId>microservices-common-httpClient</artifactId>
<version>3.6.2</version>
</dependency>
</dependencies>
<build>

View File

@ -0,0 +1,30 @@
package com.microservices.auth.controller;
import com.microservices.auth.service.AiAgentTokenService;
import com.microservices.auth.vo.AIAgentTokenVo;
import com.microservices.common.core.domain.R;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Casdoor 预置账号 token 中转接口
*
* @author microservices
*/
@RestController
public class AiAgentController
{
@Autowired
private AiAgentTokenService aiAgentTokenService;
/**
* 获取 Astron AgentCasdooraccess_token前端凭此 token 自带 Bearer 头直连 Astron 业务接口
* 网关路由后完整路径为 /auth/casdoor/token
*/
@PostMapping("/aiAgent/login")
public R<AIAgentTokenVo> token()
{
return R.ok(aiAgentTokenService.getAccessToken());
}
}

View File

@ -0,0 +1,302 @@
package com.microservices.auth.service;
import com.alibaba.fastjson2.JSONObject;
import com.microservices.auth.vo.AIAgentTokenVo;
import com.microservices.common.core.exception.ServiceException;
import com.microservices.common.core.utils.StringUtils;
import com.microservices.common.httpClient.service.HttpAPIService;
import com.microservices.common.redis.service.RedisService;
import org.apache.http.HttpEntity;
import org.apache.http.NameValuePair;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.message.BasicNameValuePair;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* AIAgent 预置账号 token 中转服务Token 中转形态
* <p>
* 托管预置账号向 AIAgent 换取 access_token前端凭此 token 直连 Astron Agent 业务接口
* 7 天有效期的 token Redis 缓存并发首登通过 SETNX 分布式锁 + 双重检查防止击穿
*
* @author microservices
*/
@Service
public class AiAgentTokenService {
private static final Logger log = LoggerFactory.getLogger(AiAgentTokenService.class);
/**
* AIAgent 服务地址
*/
@Value("${aiAgent.url:}")
private String url;
/**
* token 端点留空则取 ${url}/api/login/oauth/access_token
*/
@Value("${aiAgent.token-endpoint:}")
private String tokenEndpoint;
/**
* OAuth client_id
*/
@Value("${aiAgent.client-id:4b25ca036879216a020a}")
private String clientId;
/**
* 预置账号
*/
@Value("${aiAgent.username:}")
private String username;
/**
* 预置密码敏感走配置中心勿入库
*/
@Value("${aiAgent.password:}")
private String password;
/**
* 授权范围
*/
@Value("${aiAgent.scope:profile}")
private String scope = "profile";
/**
* 提前 N 秒视为过期避免边界
*/
@Value("${aiAgent.token-advance-expire-seconds:60}")
private long tokenAdvanceExpireSeconds = 60L;
/**
* token 缓存 key 前缀完整 keyAIAgent:token:{clientId}:{username}
*/
private static final String CACHE_KEY_PREFIX = "AIAgent:token:";
/**
* 分布式锁 key 前缀完整 keyAIAgent:lock:{username}
*/
private static final String LOCK_KEY_PREFIX = "AIAgent:lock:";
/**
* 锁自动释放时间防止持锁线程异常导致死锁
*/
private static final long LOCK_TTL_SECONDS = 30L;
/**
* 未抢到锁时的单次等待时间毫秒
*/
private static final long LOCK_WAIT_MILLIS = 300L;
/**
* 未抢到锁时的最大等待次数
*/
private static final int LOCK_WAIT_MAX_RETRIES = 10;
@Autowired
private HttpAPIService httpAPIService;
@Autowired
private RedisService redisService;
/**
* 对外主入口缓存命中且未过期直接返回已过期则 refresh无缓存则 password 登录
*/
public AIAgentTokenVo getAccessToken() {
AIAgentTokenVo cached = getCache();
if (cached != null && !isExpired(cached)) {
return cached;
}
String lockKey = lockKey();
String lockValue = UUID.randomUUID().toString();
if (tryLock(lockKey, lockValue)) {
try {
// 双重检查抢锁期间可能已有其他线程写入缓存
cached = getCache();
if (cached != null && !isExpired(cached)) {
return cached;
}
// 已有缓存但临近过期 优先 refresh失败回退 password 登录
if (cached != null && StringUtils.isNotEmpty(cached.getRefreshToken())) {
try {
return refreshByRefreshToken(cached.getRefreshToken());
} catch (Exception e) {
log.warn("AIAgent refresh 续期失败,回退到密码登录:{}", e.getMessage());
}
}
return loginByPassword();
} finally {
unlock(lockKey, lockValue);
}
}
// 未抢到锁等待持锁线程写入缓存后复用避免并发重复登录
for (int i = 0; i < LOCK_WAIT_MAX_RETRIES; i++) {
sleep(LOCK_WAIT_MILLIS);
cached = getCache();
if (cached != null && !isExpired(cached)) {
return cached;
}
}
// 极端情况下仍未拿到缓存降级直接登录保证请求可用
log.warn("AIAgent token 锁等待超时,降级直接登录");
return loginByPassword();
}
/**
* password grant 换取 token 并写缓存
*/
public AIAgentTokenVo loginByPassword() {
List<NameValuePair> form = new ArrayList<>();
form.add(new BasicNameValuePair("grant_type", "password"));
form.add(new BasicNameValuePair("client_id", clientId));
form.add(new BasicNameValuePair("username", username));
form.add(new BasicNameValuePair("password", password));
form.add(new BasicNameValuePair("scope", scope));
return requestAndCache(form, "password");
}
/**
* refresh grant 续期并更新缓存失败抛异常由调用方回退处理
*/
public AIAgentTokenVo refreshByRefreshToken(String refreshToken) {
List<NameValuePair> form = new ArrayList<>();
form.add(new BasicNameValuePair("grant_type", "refresh_token"));
form.add(new BasicNameValuePair("client_id", clientId));
form.add(new BasicNameValuePair("refresh_token", refreshToken));
form.add(new BasicNameValuePair("scope", scope));
return requestAndCache(form, "refresh_token");
}
/**
* 调用 AIAgent token 端点并写缓存
*/
private AIAgentTokenVo requestAndCache(List<NameValuePair> form, String grantType) {
AIAgentTokenVo token = requestToken(form, grantType);
setCache(token);
return token;
}
private String resolveTokenEndpoint()
{
return StringUtils.isNotEmpty(tokenEndpoint) ? tokenEndpoint : url + "/api/login/oauth/access_token";
}
/**
* 调用 AIAgent token 端点解析响应
* <p>
* 注意请求体为 form-urlencodedAIAgent 即便业务失败也常返回 HTTP 200
* 必须解析 body 中的 error 字段判断成败
*/
private AIAgentTokenVo requestToken(List<NameValuePair> form, String grantType) {
ensureConfigured();
HttpEntity entity = new UrlEncodedFormEntity(form, StandardCharsets.UTF_8);
JSONObject resp;
try {
resp = httpAPIService.doPostFormData(resolveTokenEndpoint(), entity, null);
} catch (Exception e) {
log.error("调用 AIAgent {} 端点失败:{}", grantType, e.getMessage());
throw new ServiceException("第三方登录服务暂不可用");
}
if (resp == null) {
throw new ServiceException("第三方登录服务响应为空");
}
if (resp.containsKey("error")) {
String desc = resp.getString("error_description");
if (StringUtils.isEmpty(desc)) {
desc = resp.getString("error");
}
log.error("AIAgent {} 失败:{}", grantType, resp);
throw new ServiceException("第三方登录失败:" + desc);
}
String accessToken = resp.getString("access_token");
if (StringUtils.isEmpty(accessToken)) {
log.error("AIAgent {} 响应缺少 access_token{}", grantType, resp);
throw new ServiceException("第三方登录失败:未获取到 access_token");
}
Long expiresInObj = resp.getLong("expires_in");
long expiresIn = expiresInObj == null ? 0L : expiresInObj;
String tokenType = resp.getString("token_type");
if (StringUtils.isEmpty(tokenType)) {
tokenType = "Bearer";
}
long expiresAt = System.currentTimeMillis() + (expiresIn > 0 ? expiresIn * 1000L : 0L);
return new AIAgentTokenVo(accessToken, resp.getString("refresh_token"), tokenType, expiresAt);
}
private void ensureConfigured() {
if (StringUtils.isAnyBlank(url, clientId,
username, password)) {
throw new ServiceException("AIAgent 登录配置不完整,请检查配置中心");
}
}
// ---- 缓存 ----
private String cacheKey() {
return CACHE_KEY_PREFIX + clientId + ":" + username;
}
private AIAgentTokenVo getCache() {
try {
return redisService.getCacheObject(cacheKey());
} catch (Exception e) {
log.warn("读取 AIAgent token 缓存失败:{}", e.getMessage());
return null;
}
}
private void setCache(AIAgentTokenVo token) {
// TTL = expires_in - 提前量"视为过期"的时间点对齐
long ttl = Math.max(1L, token.getExpiresIn() - tokenAdvanceExpireSeconds);
redisService.setCacheObject(cacheKey(), token, ttl, TimeUnit.SECONDS);
}
private boolean isExpired(AIAgentTokenVo token) {
if (token == null || token.getExpiresAt() == null) {
return true;
}
long boundary = token.getExpiresAt() - tokenAdvanceExpireSeconds * 1000L;
return System.currentTimeMillis() >= boundary;
}
// ---- 分布式锁基于 Redis SETNX + TTL----
private String lockKey() {
return LOCK_KEY_PREFIX + username;
}
@SuppressWarnings("unchecked")
private boolean tryLock(String key, String value) {
Boolean ok = redisService.redisTemplate.opsForValue()
.setIfAbsent(key, value, LOCK_TTL_SECONDS, TimeUnit.SECONDS);
return Boolean.TRUE.equals(ok);
}
@SuppressWarnings("unchecked")
private void unlock(String key, String value) {
try {
Object current = redisService.redisTemplate.opsForValue().get(key);
if (value.equals(current)) {
redisService.redisTemplate.delete(key);
}
} catch (Exception e) {
log.warn("释放 AIAgent token 锁失败:{}", e.getMessage());
}
}
private void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}

View File

@ -0,0 +1,88 @@
package com.microservices.auth.vo;
import java.io.Serializable;
/**
* CasdoorAstron Agenttoken 中转返回对象
* <p>
* 同时作为 Redis 缓存值accessToken/refreshToken/tokenType/expiresAt 持久化
* expiresIn expiresAt 实时计算
*
* @author microservices
*/
public class AIAgentTokenVo implements Serializable
{
private static final long serialVersionUID = 1L;
/** 访问令牌 */
private String accessToken;
/** 刷新令牌 */
private String refreshToken;
/** 令牌类型,恒为 Bearer */
private String tokenType;
/** 过期绝对时间戳(毫秒) */
private Long expiresAt;
public AIAgentTokenVo()
{
}
public AIAgentTokenVo(String accessToken, String refreshToken, String tokenType, Long expiresAt)
{
this.accessToken = accessToken;
this.refreshToken = refreshToken;
this.tokenType = tokenType;
this.expiresAt = expiresAt;
}
public String getAccessToken()
{
return accessToken;
}
public void setAccessToken(String accessToken)
{
this.accessToken = accessToken;
}
public String getRefreshToken()
{
return refreshToken;
}
public void setRefreshToken(String refreshToken)
{
this.refreshToken = refreshToken;
}
public String getTokenType()
{
return tokenType;
}
public void setTokenType(String tokenType)
{
this.tokenType = tokenType;
}
public Long getExpiresAt()
{
return expiresAt;
}
public void setExpiresAt(Long expiresAt)
{
this.expiresAt = expiresAt;
}
/**
* 距过期剩余秒数实时计算避免缓存命中后下发陈旧的剩余时间
*/
public long getExpiresIn()
{
return expiresAt == null ? 0L : Math.max(0L, (expiresAt - System.currentTimeMillis()) / 1000L);
}
}