1
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
package com.ruoyi.jarvis.config;
|
||||
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
|
||||
/**
|
||||
* 金山文档开放平台(个人云)配置
|
||||
*
|
||||
* @see <a href="https://developer.kdocs.cn">developer.kdocs.cn</a>
|
||||
*/
|
||||
@Configuration
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "kdocs")
|
||||
public class KdocsCloudConfig {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(KdocsCloudConfig.class);
|
||||
|
||||
/** API 与授权页主机,默认 https://developer.kdocs.cn */
|
||||
private String apiHost = "https://developer.kdocs.cn";
|
||||
|
||||
/** 应用 app_id */
|
||||
private String appId;
|
||||
|
||||
/** 应用 app_key */
|
||||
private String appKey;
|
||||
|
||||
/** OAuth 回调地址(须在开发者后台登记) */
|
||||
private String redirectUri;
|
||||
|
||||
/**
|
||||
* 授权 scope,逗号分隔。
|
||||
* 参考:https://developer.kdocs.cn/server/guide/permission.html
|
||||
*/
|
||||
private String scope = "user_basic,access_personal_files,edit_personal_files";
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
log.info("Kdocs 配置加载 - apiHost: {}, redirectUri: {}, appId: {}",
|
||||
apiHost,
|
||||
redirectUri,
|
||||
appId != null && appId.length() > 8 ? appId.substring(0, 8) + "..." : appId);
|
||||
if (StringUtils.isBlank(appId)) {
|
||||
log.warn("kdocs.app-id 未配置,请在 application.yml 中填写金山文档开放平台应用信息");
|
||||
}
|
||||
if (StringUtils.isBlank(appKey)) {
|
||||
log.warn("kdocs.app-key 未配置");
|
||||
}
|
||||
if (StringUtils.isBlank(redirectUri)) {
|
||||
log.warn("kdocs.redirect-uri 未配置");
|
||||
}
|
||||
}
|
||||
|
||||
public String getApiHost() {
|
||||
return apiHost;
|
||||
}
|
||||
|
||||
public void setApiHost(String apiHost) {
|
||||
this.apiHost = apiHost;
|
||||
}
|
||||
|
||||
public String getAppId() {
|
||||
return appId;
|
||||
}
|
||||
|
||||
public void setAppId(String appId) {
|
||||
this.appId = appId;
|
||||
}
|
||||
|
||||
public String getAppKey() {
|
||||
return appKey;
|
||||
}
|
||||
|
||||
public void setAppKey(String appKey) {
|
||||
this.appKey = appKey;
|
||||
}
|
||||
|
||||
public String getRedirectUri() {
|
||||
return redirectUri;
|
||||
}
|
||||
|
||||
public void setRedirectUri(String redirectUri) {
|
||||
this.redirectUri = redirectUri;
|
||||
}
|
||||
|
||||
public String getScope() {
|
||||
return scope;
|
||||
}
|
||||
|
||||
public void setScope(String scope) {
|
||||
this.scope = scope;
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package com.ruoyi.jarvis.config;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
|
||||
/**
|
||||
* WPS365开放平台配置
|
||||
*
|
||||
* @author system
|
||||
*/
|
||||
@Configuration
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "wps365")
|
||||
public class WPS365Config {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(WPS365Config.class);
|
||||
|
||||
/** 应用ID(AppId) */
|
||||
private String appId;
|
||||
|
||||
/** 应用密钥(AppKey) */
|
||||
private String appKey;
|
||||
|
||||
/** 授权回调地址 */
|
||||
private String redirectUri;
|
||||
|
||||
/** API基础地址 */
|
||||
private String apiBaseUrl = "https://openapi.wps.cn/api/v1";
|
||||
|
||||
/** OAuth授权地址 */
|
||||
private String oauthUrl = "https://openapi.wps.cn/oauth2/auth";
|
||||
|
||||
/** 获取Token地址 */
|
||||
private String tokenUrl = "https://openapi.wps.cn/oauth2/token";
|
||||
|
||||
/** 刷新Token地址 */
|
||||
private String refreshTokenUrl = "https://openapi.wps.cn/oauth2/token";
|
||||
|
||||
/** OAuth授权请求的scope权限(可选,如果不配置则使用默认值) */
|
||||
private String scope;
|
||||
|
||||
/**
|
||||
* 配置初始化后验证
|
||||
*/
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
log.info("WPS365配置加载 - appId: {}, redirectUri: {}, apiBaseUrl: {}",
|
||||
appId != null && appId.length() > 10 ? appId.substring(0, 10) + "..." : (appId != null ? appId : "null"),
|
||||
redirectUri != null ? redirectUri : "null",
|
||||
apiBaseUrl);
|
||||
|
||||
if (appId == null || appId.trim().isEmpty()) {
|
||||
log.warn("WPS365应用ID未配置!请检查application.yml中的wps365.app-id");
|
||||
}
|
||||
if (appKey == null || appKey.trim().isEmpty()) {
|
||||
log.warn("WPS365应用密钥未配置!请检查application.yml中的wps365.app-key");
|
||||
}
|
||||
if (redirectUri == null || redirectUri.trim().isEmpty()) {
|
||||
log.warn("WPS365回调地址未配置!请检查application.yml中的wps365.redirect-uri");
|
||||
}
|
||||
}
|
||||
|
||||
public String getAppId() {
|
||||
return appId;
|
||||
}
|
||||
|
||||
public void setAppId(String appId) {
|
||||
this.appId = appId;
|
||||
}
|
||||
|
||||
public String getAppKey() {
|
||||
return appKey;
|
||||
}
|
||||
|
||||
public void setAppKey(String appKey) {
|
||||
this.appKey = appKey;
|
||||
}
|
||||
|
||||
public String getRedirectUri() {
|
||||
return redirectUri;
|
||||
}
|
||||
|
||||
public void setRedirectUri(String redirectUri) {
|
||||
this.redirectUri = redirectUri;
|
||||
}
|
||||
|
||||
public String getApiBaseUrl() {
|
||||
return apiBaseUrl;
|
||||
}
|
||||
|
||||
public void setApiBaseUrl(String apiBaseUrl) {
|
||||
this.apiBaseUrl = apiBaseUrl;
|
||||
}
|
||||
|
||||
public String getOauthUrl() {
|
||||
return oauthUrl;
|
||||
}
|
||||
|
||||
public void setOauthUrl(String oauthUrl) {
|
||||
this.oauthUrl = oauthUrl;
|
||||
}
|
||||
|
||||
public String getTokenUrl() {
|
||||
return tokenUrl;
|
||||
}
|
||||
|
||||
public void setTokenUrl(String tokenUrl) {
|
||||
this.tokenUrl = tokenUrl;
|
||||
}
|
||||
|
||||
public String getRefreshTokenUrl() {
|
||||
return refreshTokenUrl;
|
||||
}
|
||||
|
||||
public void setRefreshTokenUrl(String refreshTokenUrl) {
|
||||
this.refreshTokenUrl = refreshTokenUrl;
|
||||
}
|
||||
|
||||
public String getScope() {
|
||||
return scope;
|
||||
}
|
||||
|
||||
public void setScope(String scope) {
|
||||
this.scope = scope;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,39 +3,33 @@ package com.ruoyi.jarvis.domain.dto;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* WPS365 Token信息
|
||||
*
|
||||
* @author system
|
||||
* 金山文档 OAuth 令牌(存 Redis)
|
||||
*/
|
||||
public class WPS365TokenInfo implements Serializable {
|
||||
|
||||
public class KdocsTokenInfo implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** 访问令牌 */
|
||||
|
||||
private String accessToken;
|
||||
|
||||
/** 刷新令牌 */
|
||||
private String refreshToken;
|
||||
|
||||
/** 令牌类型 */
|
||||
private String tokenType;
|
||||
|
||||
/** 过期时间(秒) */
|
||||
private Integer expiresIn;
|
||||
|
||||
/** 作用域 */
|
||||
private String scope;
|
||||
|
||||
/** 用户ID */
|
||||
/** 本系统用于关联 Redis 的用户标识,通常取金山 open_id */
|
||||
private String userId;
|
||||
|
||||
/** 创建时间戳(毫秒) */
|
||||
private Long createTime;
|
||||
|
||||
public WPS365TokenInfo() {
|
||||
public KdocsTokenInfo() {
|
||||
this.createTime = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
public boolean isExpired() {
|
||||
if (expiresIn == null || createTime == null) {
|
||||
return true;
|
||||
}
|
||||
long expireTime = createTime + (expiresIn * 1000L);
|
||||
return System.currentTimeMillis() >= (expireTime - 5 * 60 * 1000L);
|
||||
}
|
||||
|
||||
public String getAccessToken() {
|
||||
return accessToken;
|
||||
}
|
||||
@@ -91,18 +85,4 @@ public class WPS365TokenInfo implements Serializable {
|
||||
public void setCreateTime(Long createTime) {
|
||||
this.createTime = createTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查token是否过期
|
||||
*/
|
||||
public boolean isExpired() {
|
||||
if (expiresIn == null || createTime == null) {
|
||||
return true;
|
||||
}
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long expireTime = createTime + (expiresIn * 1000L);
|
||||
// 提前5分钟认为过期,留出刷新时间
|
||||
return currentTime >= (expireTime - 5 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.ruoyi.jarvis.service;
|
||||
|
||||
import com.ruoyi.jarvis.domain.dto.KdocsTokenInfo;
|
||||
|
||||
public interface IKdocsOAuthService {
|
||||
|
||||
String getAuthUrl(String state);
|
||||
|
||||
KdocsTokenInfo getAccessTokenByCode(String code);
|
||||
|
||||
KdocsTokenInfo refreshAccessToken(String refreshToken, String existingUserId);
|
||||
|
||||
KdocsTokenInfo getCurrentToken();
|
||||
|
||||
void saveToken(String userId, KdocsTokenInfo tokenInfo);
|
||||
|
||||
void clearToken(String userId);
|
||||
|
||||
boolean isTokenValid(KdocsTokenInfo tokenInfo);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.ruoyi.jarvis.service;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 金山文档个人云 Open API(developer.kdocs.cn)
|
||||
*/
|
||||
public interface IKdocsOpenApiService {
|
||||
|
||||
JSONObject getUserInfoFlat(String accessToken);
|
||||
|
||||
JSONObject getFileList(String accessToken, Map<String, Object> params);
|
||||
|
||||
JSONObject getFileInfo(String accessToken, String fileToken);
|
||||
|
||||
JSONObject getSheetList(String accessToken, String fileToken);
|
||||
|
||||
JSONObject readCells(String accessToken, String fileToken, int sheetIdx, String range);
|
||||
|
||||
JSONObject updateCells(String accessToken, String fileToken, int sheetIdx, String range, List<List<Object>> values);
|
||||
|
||||
JSONObject createSheet(String accessToken, String fileToken, String sheetName);
|
||||
|
||||
JSONObject batchUpdateCells(String accessToken, String fileToken, int sheetIdx, List<Map<String, Object>> updates);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package com.ruoyi.jarvis.service;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* WPS365 API服务接口
|
||||
*
|
||||
* @author system
|
||||
*/
|
||||
public interface IWPS365ApiService {
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
*
|
||||
* @param accessToken 访问令牌
|
||||
* @return 用户信息
|
||||
*/
|
||||
JSONObject getUserInfo(String accessToken);
|
||||
|
||||
/**
|
||||
* 获取文件列表
|
||||
*
|
||||
* @param accessToken 访问令牌
|
||||
* @param params 查询参数(page, page_size等)
|
||||
* @return 文件列表
|
||||
*/
|
||||
JSONObject getFileList(String accessToken, Map<String, Object> params);
|
||||
|
||||
/**
|
||||
* 获取文件信息
|
||||
*
|
||||
* @param accessToken 访问令牌
|
||||
* @param fileToken 文件token
|
||||
* @return 文件信息
|
||||
*/
|
||||
JSONObject getFileInfo(String accessToken, String fileToken);
|
||||
|
||||
/**
|
||||
* 更新单元格数据(KSheet - 在线表格)
|
||||
*
|
||||
* @param accessToken 访问令牌
|
||||
* @param fileToken 文件token
|
||||
* @param sheetIdx 工作表索引(从0开始)
|
||||
* @param range 单元格范围(如:A1:B2)
|
||||
* @param values 单元格值(二维数组,第一维是行,第二维是列)
|
||||
* @return 更新结果
|
||||
*/
|
||||
JSONObject updateCells(String accessToken, String fileToken, int sheetIdx, String range, List<List<Object>> values);
|
||||
|
||||
/**
|
||||
* 读取单元格数据
|
||||
*
|
||||
* @param accessToken 访问令牌
|
||||
* @param fileToken 文件token
|
||||
* @param sheetIdx 工作表索引
|
||||
* @param range 单元格范围
|
||||
* @return 单元格数据
|
||||
*/
|
||||
JSONObject readCells(String accessToken, String fileToken, int sheetIdx, String range);
|
||||
|
||||
/**
|
||||
* 获取工作表列表
|
||||
*
|
||||
* @param accessToken 访问令牌
|
||||
* @param fileToken 文件token
|
||||
* @return 工作表列表
|
||||
*/
|
||||
JSONObject getSheetList(String accessToken, String fileToken);
|
||||
|
||||
/**
|
||||
* 创建数据表
|
||||
*
|
||||
* @param accessToken 访问令牌
|
||||
* @param fileToken 文件token
|
||||
* @param sheetName 工作表名称
|
||||
* @return 创建结果
|
||||
*/
|
||||
JSONObject createSheet(String accessToken, String fileToken, String sheetName);
|
||||
|
||||
/**
|
||||
* 批量更新单元格数据
|
||||
*
|
||||
* @param accessToken 访问令牌
|
||||
* @param fileToken 文件token
|
||||
* @param sheetIdx 工作表索引
|
||||
* @param updates 更新列表,每个元素包含range和values
|
||||
* @return 更新结果
|
||||
*/
|
||||
JSONObject batchUpdateCells(String accessToken, String fileToken, int sheetIdx, List<Map<String, Object>> updates);
|
||||
|
||||
/**
|
||||
* 读取AirSheet工作表数据
|
||||
*
|
||||
* @param accessToken 访问令牌
|
||||
* @param fileId 文件ID
|
||||
* @param worksheetId 工作表ID(整数,通常为0表示第一个工作表)
|
||||
* @param range 单元格范围(如:A1:B10,可选)
|
||||
* @return 单元格数据
|
||||
*/
|
||||
JSONObject readAirSheetCells(String accessToken, String fileId, String worksheetId, String range);
|
||||
|
||||
/**
|
||||
* 更新AirSheet工作表数据
|
||||
*
|
||||
* @param accessToken 访问令牌
|
||||
* @param fileId 文件ID
|
||||
* @param worksheetId 工作表ID(整数,通常为0表示第一个工作表)
|
||||
* @param range 单元格范围(如:A1:B2)
|
||||
* @param values 单元格值(二维数组,第一维是行,第二维是列)
|
||||
* @return 更新结果
|
||||
*/
|
||||
JSONObject updateAirSheetCells(String accessToken, String fileId, String worksheetId, String range, List<List<Object>> values);
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
package com.ruoyi.jarvis.service;
|
||||
|
||||
import com.ruoyi.jarvis.domain.dto.WPS365TokenInfo;
|
||||
|
||||
/**
|
||||
* WPS365 OAuth授权服务接口
|
||||
*
|
||||
* @author system
|
||||
*/
|
||||
public interface IWPS365OAuthService {
|
||||
|
||||
/**
|
||||
* 获取授权URL
|
||||
*
|
||||
* @param state 状态参数(可选,用于防止CSRF攻击)
|
||||
* @return 授权URL
|
||||
*/
|
||||
String getAuthUrl(String state);
|
||||
|
||||
/**
|
||||
* 通过授权码获取访问令牌
|
||||
*
|
||||
* @param code 授权码
|
||||
* @return Token信息
|
||||
*/
|
||||
WPS365TokenInfo getAccessTokenByCode(String code);
|
||||
|
||||
/**
|
||||
* 刷新访问令牌
|
||||
*
|
||||
* @param refreshToken 刷新令牌
|
||||
* @return 新的Token信息
|
||||
*/
|
||||
WPS365TokenInfo refreshAccessToken(String refreshToken);
|
||||
|
||||
/**
|
||||
* 获取当前用户的访问令牌
|
||||
*
|
||||
* @return Token信息,如果未授权则返回null
|
||||
*/
|
||||
WPS365TokenInfo getCurrentToken();
|
||||
|
||||
/**
|
||||
* 保存用户令牌信息
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param tokenInfo Token信息
|
||||
*/
|
||||
void saveToken(String userId, WPS365TokenInfo tokenInfo);
|
||||
|
||||
/**
|
||||
* 清除用户令牌信息
|
||||
*
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
void clearToken(String userId);
|
||||
|
||||
/**
|
||||
* 检查令牌是否有效
|
||||
*
|
||||
* @param tokenInfo Token信息
|
||||
* @return true表示有效,false表示需要刷新或重新授权
|
||||
*/
|
||||
boolean isTokenValid(WPS365TokenInfo tokenInfo);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package com.ruoyi.jarvis.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.ruoyi.common.core.redis.RedisCache;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.jarvis.config.KdocsCloudConfig;
|
||||
import com.ruoyi.jarvis.domain.dto.KdocsTokenInfo;
|
||||
import com.ruoyi.jarvis.service.IKdocsOAuthService;
|
||||
import com.ruoyi.jarvis.service.IKdocsOpenApiService;
|
||||
import com.ruoyi.jarvis.util.KdocsOpenApiClient;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.util.Collection;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Service
|
||||
public class KdocsOAuthServiceImpl implements IKdocsOAuthService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(KdocsOAuthServiceImpl.class);
|
||||
private static final String TOKEN_KEY_PREFIX = "kdocs:token:";
|
||||
private static final long TOKEN_EXPIRE_SECONDS = 30L * 24 * 60 * 60;
|
||||
|
||||
@Autowired
|
||||
private KdocsCloudConfig kdocsCloudConfig;
|
||||
@Autowired
|
||||
private RedisCache redisCache;
|
||||
@Autowired
|
||||
private IKdocsOpenApiService kdocsOpenApiService;
|
||||
|
||||
private static String enc(String s) {
|
||||
try {
|
||||
return URLEncoder.encode(s == null ? "" : s, "UTF-8").replace("+", "%20");
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAuthUrl(String state) {
|
||||
if (StringUtils.isBlank(kdocsCloudConfig.getAppId())) {
|
||||
throw new RuntimeException("kdocs.app-id 未配置");
|
||||
}
|
||||
if (StringUtils.isBlank(kdocsCloudConfig.getRedirectUri())) {
|
||||
throw new RuntimeException("kdocs.redirect-uri 未配置");
|
||||
}
|
||||
String redirect = kdocsCloudConfig.getRedirectUri().trim();
|
||||
if (redirect.endsWith("/") && redirect.length() > "https://".length()) {
|
||||
redirect = redirect.substring(0, redirect.length() - 1);
|
||||
}
|
||||
if (StringUtils.isBlank(state)) {
|
||||
state = UUID.randomUUID().toString();
|
||||
}
|
||||
String scope = kdocsCloudConfig.getScope() != null ? kdocsCloudConfig.getScope().trim() : "user_basic,access_personal_files,edit_personal_files";
|
||||
return kdocsCloudConfig.getApiHost().replaceAll("/$", "")
|
||||
+ "/h5/auth?app_id=" + enc(kdocsCloudConfig.getAppId())
|
||||
+ "&scope=" + enc(scope)
|
||||
+ "&redirect_uri=" + enc(redirect)
|
||||
+ "&state=" + enc(state);
|
||||
}
|
||||
|
||||
@Override
|
||||
public KdocsTokenInfo getAccessTokenByCode(String code) {
|
||||
String host = kdocsCloudConfig.getApiHost().replaceAll("/$", "");
|
||||
String url = host + "/api/v1/oauth2/access_token?code=" + enc(code)
|
||||
+ "&app_id=" + enc(kdocsCloudConfig.getAppId())
|
||||
+ "&app_key=" + enc(kdocsCloudConfig.getAppKey());
|
||||
JSONObject root = KdocsOpenApiClient.exchange("GET", url, null, true);
|
||||
JSONObject data = KdocsOpenApiClient.requireBusinessData(root);
|
||||
|
||||
KdocsTokenInfo info = new KdocsTokenInfo();
|
||||
info.setAccessToken(data.getString("access_token"));
|
||||
info.setRefreshToken(data.getString("refresh_token"));
|
||||
info.setExpiresIn(data.getInteger("expires_in"));
|
||||
|
||||
String openId = null;
|
||||
try {
|
||||
JSONObject user = kdocsOpenApiService.getUserInfoFlat(info.getAccessToken());
|
||||
openId = user != null ? user.getString("open_id") : null;
|
||||
if (openId == null && user != null) {
|
||||
openId = user.getString("user_id");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("换取 token 后拉取用户信息失败: {}", e.getMessage());
|
||||
}
|
||||
if (StringUtils.isBlank(openId)) {
|
||||
String at = info.getAccessToken();
|
||||
openId = at != null && at.length() > 12 ? "kdocs_" + at.substring(0, 12) : "kdocs_default";
|
||||
}
|
||||
info.setUserId(openId);
|
||||
return info;
|
||||
}
|
||||
|
||||
@Override
|
||||
public KdocsTokenInfo refreshAccessToken(String refreshToken, String existingUserId) {
|
||||
String host = kdocsCloudConfig.getApiHost().replaceAll("/$", "");
|
||||
String url = host + "/api/v1/oauth2/refresh_token?app_id=" + enc(kdocsCloudConfig.getAppId());
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("app_key", kdocsCloudConfig.getAppKey());
|
||||
body.put("refresh_token", refreshToken);
|
||||
JSONObject root = KdocsOpenApiClient.exchange("POST", url, body.toJSONString(), false);
|
||||
JSONObject data = KdocsOpenApiClient.requireBusinessData(root);
|
||||
|
||||
KdocsTokenInfo info = new KdocsTokenInfo();
|
||||
info.setAccessToken(data.getString("access_token"));
|
||||
info.setRefreshToken(data.getString("refresh_token"));
|
||||
info.setExpiresIn(data.getInteger("expires_in"));
|
||||
info.setUserId(existingUserId);
|
||||
return info;
|
||||
}
|
||||
|
||||
@Override
|
||||
public KdocsTokenInfo getCurrentToken() {
|
||||
try {
|
||||
String pattern = TOKEN_KEY_PREFIX + "*";
|
||||
Collection<String> keys = redisCache.keys(pattern);
|
||||
if (keys != null) {
|
||||
for (String key : keys) {
|
||||
KdocsTokenInfo t = redisCache.getCacheObject(key);
|
||||
if (t != null && isTokenValid(t)) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
for (String key : keys) {
|
||||
KdocsTokenInfo t = redisCache.getCacheObject(key);
|
||||
if (t != null) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("扫描 kdocs token 失败: {}", e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveToken(String userId, KdocsTokenInfo tokenInfo) {
|
||||
if (StringUtils.isBlank(userId)) {
|
||||
throw new IllegalArgumentException("userId 不能为空");
|
||||
}
|
||||
if (tokenInfo == null || tokenInfo.getAccessToken() == null) {
|
||||
throw new IllegalArgumentException("token 不能为空");
|
||||
}
|
||||
redisCache.setCacheObject(TOKEN_KEY_PREFIX + userId, tokenInfo, (int) TOKEN_EXPIRE_SECONDS, TimeUnit.SECONDS);
|
||||
log.info("已保存 Kdocs token userId={}", userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearToken(String userId) {
|
||||
if (StringUtils.isBlank(userId)) {
|
||||
return;
|
||||
}
|
||||
redisCache.deleteObject(TOKEN_KEY_PREFIX + userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isTokenValid(KdocsTokenInfo tokenInfo) {
|
||||
return tokenInfo != null
|
||||
&& tokenInfo.getAccessToken() != null
|
||||
&& StringUtils.isNotBlank(tokenInfo.getAccessToken())
|
||||
&& !tokenInfo.isExpired();
|
||||
}
|
||||
|
||||
public KdocsTokenInfo getTokenByUserId(String userId) {
|
||||
if (StringUtils.isBlank(userId)) {
|
||||
return null;
|
||||
}
|
||||
return redisCache.getCacheObject(TOKEN_KEY_PREFIX + userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package com.ruoyi.jarvis.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.jarvis.config.KdocsCloudConfig;
|
||||
import com.ruoyi.jarvis.service.IKdocsOpenApiService;
|
||||
import com.ruoyi.jarvis.util.KdocsOpenApiClient;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.URLEncoder;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class KdocsOpenApiServiceImpl implements IKdocsOpenApiService {
|
||||
|
||||
@Autowired
|
||||
private KdocsCloudConfig kdocsCloudConfig;
|
||||
|
||||
private static String enc(String s) {
|
||||
try {
|
||||
return URLEncoder.encode(s == null ? "" : s, "UTF-8").replace("+", "%20");
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private String host() {
|
||||
return kdocsCloudConfig.getApiHost().replaceAll("/$", "");
|
||||
}
|
||||
|
||||
private JSONObject getOpenApi(String pathWithLeadingSlash, String accessToken) {
|
||||
String url = host() + pathWithLeadingSlash
|
||||
+ (pathWithLeadingSlash.contains("?") ? "&" : "?")
|
||||
+ "access_token=" + enc(accessToken);
|
||||
JSONObject root = KdocsOpenApiClient.exchange("GET", url, null, false);
|
||||
return KdocsOpenApiClient.requireBusinessData(root);
|
||||
}
|
||||
|
||||
private JSONObject postOpenApi(String pathWithLeadingSlash, String accessToken, String jsonBody) {
|
||||
String url = host() + pathWithLeadingSlash
|
||||
+ (pathWithLeadingSlash.contains("?") ? "&" : "?")
|
||||
+ "access_token=" + enc(accessToken);
|
||||
JSONObject root = KdocsOpenApiClient.exchange("POST", url, jsonBody, false);
|
||||
return KdocsOpenApiClient.requireBusinessData(root);
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject getUserInfoFlat(String accessToken) {
|
||||
JSONObject data = getOpenApi("/api/v1/openapi/user/basic", accessToken);
|
||||
JSONObject flat = new JSONObject();
|
||||
JSONObject id = data.getJSONObject("id");
|
||||
if (id != null) {
|
||||
flat.put("open_id", id.getString("open_id"));
|
||||
flat.put("user_id", id.getString("open_id"));
|
||||
}
|
||||
flat.put("name", data.getString("nickname"));
|
||||
flat.put("nickname", data.getString("nickname"));
|
||||
flat.put("avatar", data.getString("avatar"));
|
||||
return flat;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject getFileList(String accessToken, Map<String, Object> params) {
|
||||
int count = 20;
|
||||
if (params != null) {
|
||||
if (params.get("page_size") != null) {
|
||||
count = ((Number) params.get("page_size")).intValue();
|
||||
} else if (params.get("pageSize") != null) {
|
||||
count = ((Number) params.get("pageSize")).intValue();
|
||||
}
|
||||
}
|
||||
StringBuilder path = new StringBuilder("/api/v1/openapi/personal/files/flat?count=")
|
||||
.append(count)
|
||||
.append("&order=desc&order_by=mtime&ignore=group");
|
||||
if (params != null) {
|
||||
Object off = params.get("next_offset");
|
||||
if (off == null) {
|
||||
off = params.get("cursor");
|
||||
}
|
||||
if (off != null) {
|
||||
path.append("&offset=").append(off.toString());
|
||||
}
|
||||
Object nf = params.get("next_filter");
|
||||
if (nf != null && StringUtils.isNotBlank(nf.toString())) {
|
||||
path.append("&filter=").append(enc(nf.toString()));
|
||||
}
|
||||
}
|
||||
JSONObject data = getOpenApi(path.toString(), accessToken);
|
||||
JSONArray files = data.getJSONArray("files");
|
||||
JSONArray normalized = new JSONArray();
|
||||
if (files != null) {
|
||||
for (int i = 0; i < files.size(); i++) {
|
||||
JSONObject f = files.getJSONObject(i);
|
||||
JSONObject id = f.getJSONObject("id");
|
||||
String openId = id != null ? id.getString("open_id") : null;
|
||||
JSONObject row = new JSONObject();
|
||||
row.put("file_token", openId);
|
||||
row.put("file_name", f.getString("fname"));
|
||||
row.put("file_type", f.getString("ftype"));
|
||||
row.put("mtime", f.get("mtime"));
|
||||
normalized.add(row);
|
||||
}
|
||||
}
|
||||
JSONObject out = new JSONObject();
|
||||
out.put("files", normalized);
|
||||
out.put("next_offset", data.get("next_offset"));
|
||||
out.put("next_filter", data.getString("next_filter"));
|
||||
Integer no = data.getInteger("next_offset");
|
||||
out.put("has_more", no != null && no != -1);
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject getFileInfo(String accessToken, String fileToken) {
|
||||
String path = "/api/v1/openapi/personal/files/" + enc(fileToken) + "/simple/info";
|
||||
return getOpenApi(path, accessToken);
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject getSheetList(String accessToken, String fileToken) {
|
||||
String path = "/api/v1/openapi/ksheet/" + enc(fileToken) + "/sheets";
|
||||
JSONObject data = getOpenApi(path, accessToken);
|
||||
JSONArray infos = data.getJSONArray("sheets_info");
|
||||
JSONArray sheets = new JSONArray();
|
||||
if (infos != null) {
|
||||
for (int i = 0; i < infos.size(); i++) {
|
||||
JSONObject si = infos.getJSONObject(i);
|
||||
JSONObject s = new JSONObject();
|
||||
s.put("name", si.getString("sheet_name"));
|
||||
s.put("sheet_idx", si.getInteger("sheet_idx"));
|
||||
s.put("sheet_id", si.get("sheet_id"));
|
||||
s.put("sheet_type", si.getString("sheet_type"));
|
||||
sheets.add(s);
|
||||
}
|
||||
}
|
||||
JSONObject out = new JSONObject();
|
||||
out.put("sheets", sheets);
|
||||
out.put("sheets_info", infos);
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject readCells(String accessToken, String fileToken, int sheetIdx, String range) {
|
||||
String path = "/api/v1/openapi/ksheet/" + enc(fileToken) + "/sheets/" + sheetIdx + "/cells";
|
||||
if (StringUtils.isNotBlank(range)) {
|
||||
path += "?range=" + enc(range.trim());
|
||||
}
|
||||
return getOpenApi(path, accessToken);
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject updateCells(String accessToken, String fileToken, int sheetIdx, String range, List<List<Object>> values) {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("range", range);
|
||||
JSONArray valuesArray = new JSONArray();
|
||||
if (values != null) {
|
||||
for (List<Object> row : values) {
|
||||
JSONArray rowArray = new JSONArray();
|
||||
if (row != null) {
|
||||
for (Object cell : row) {
|
||||
rowArray.add(cell != null ? cell : "");
|
||||
}
|
||||
}
|
||||
valuesArray.add(rowArray);
|
||||
}
|
||||
}
|
||||
body.put("values", valuesArray);
|
||||
String path = "/api/v1/openapi/ksheet/" + enc(fileToken) + "/sheets/" + sheetIdx + "/cells";
|
||||
return postOpenApi(path, accessToken, body.toJSONString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject createSheet(String accessToken, String fileToken, String sheetName) {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("name", sheetName);
|
||||
String path = "/api/v1/openapi/ksheet/" + enc(fileToken) + "/sheets";
|
||||
return postOpenApi(path, accessToken, body.toJSONString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject batchUpdateCells(String accessToken, String fileToken, int sheetIdx, List<Map<String, Object>> updates) {
|
||||
JSONObject body = new JSONObject();
|
||||
JSONArray updatesArray = new JSONArray();
|
||||
if (updates != null) {
|
||||
for (Map<String, Object> update : updates) {
|
||||
JSONObject u = new JSONObject();
|
||||
u.put("range", update.get("range"));
|
||||
@SuppressWarnings("unchecked")
|
||||
List<List<Object>> values = (List<List<Object>>) update.get("values");
|
||||
JSONArray valuesArray = new JSONArray();
|
||||
if (values != null) {
|
||||
for (List<Object> row : values) {
|
||||
JSONArray rowArray = new JSONArray();
|
||||
if (row != null) {
|
||||
for (Object cell : row) {
|
||||
rowArray.add(cell != null ? cell : "");
|
||||
}
|
||||
}
|
||||
valuesArray.add(rowArray);
|
||||
}
|
||||
}
|
||||
u.put("values", valuesArray);
|
||||
updatesArray.add(u);
|
||||
}
|
||||
}
|
||||
body.put("updates", updatesArray);
|
||||
String path = "/api/v1/openapi/ksheet/" + enc(fileToken) + "/sheets/" + sheetIdx + "/cells/batch";
|
||||
return postOpenApi(path, accessToken, body.toJSONString());
|
||||
}
|
||||
}
|
||||
@@ -1,473 +0,0 @@
|
||||
package com.ruoyi.jarvis.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.ruoyi.jarvis.config.WPS365Config;
|
||||
import com.ruoyi.jarvis.service.IWPS365ApiService;
|
||||
import com.ruoyi.jarvis.util.WPS365ApiUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* WPS365 API服务实现类
|
||||
*
|
||||
* @author system
|
||||
*/
|
||||
@Service
|
||||
public class WPS365ApiServiceImpl implements IWPS365ApiService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(WPS365ApiServiceImpl.class);
|
||||
|
||||
@Autowired
|
||||
private WPS365Config wps365Config;
|
||||
|
||||
@Override
|
||||
public JSONObject getUserInfo(String accessToken) {
|
||||
// 官方文档(open.wps.cn 用户授权流程)仅写“通过 access_token 获取用户信息”,未给出具体路径;
|
||||
// /api/v1/user/info 返回 404,以下按常见惯例尝试,若均不可用则返回 null,由 Controller 降级返回
|
||||
String[] urlsToTry = {
|
||||
"https://openapi.wps.cn/v7/user",
|
||||
"https://openapi.wps.cn/v7/userinfo",
|
||||
wps365Config.getApiBaseUrl() + "/userinfo",
|
||||
wps365Config.getApiBaseUrl() + "/user/info"
|
||||
};
|
||||
for (String url : urlsToTry) {
|
||||
try {
|
||||
log.debug("尝试用户信息API: {}", url);
|
||||
JSONObject result = WPS365ApiUtil.httpRequest("GET", url, accessToken, null);
|
||||
if (result != null) {
|
||||
log.debug("用户信息API成功 - url: {}", url);
|
||||
return result;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("用户信息API失败 - url: {}, error: {}", url, e.getMessage());
|
||||
}
|
||||
}
|
||||
log.warn("所有用户信息接口均不可用,请以 open.wps.cn 文档为准确认正确路径");
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject getFileList(String accessToken, Map<String, Object> params) {
|
||||
// 官方文档:云文档文件在「驱动盘」下,路径为 GET https://openapi.wps.cn/v7/drives/{drive_id}/files
|
||||
// /api/v1/yundoc/files 会 404,需先取 drive_id 再请求 v7/drives/{drive_id}/files
|
||||
String baseV7 = "https://openapi.wps.cn/v7";
|
||||
int page = params != null && params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
|
||||
int pageSize = params != null && params.get("page_size") != null ? ((Number) params.get("page_size")).intValue() : 20;
|
||||
if (params != null && params.get("pageSize") != null) {
|
||||
pageSize = ((Number) params.get("pageSize")).intValue();
|
||||
}
|
||||
|
||||
String driveId = null;
|
||||
// 1) 尝试获取驱动盘列表:GET /v7/drives
|
||||
try {
|
||||
JSONObject drivesRes = WPS365ApiUtil.httpRequest("GET", baseV7 + "/drives", accessToken, null);
|
||||
if (drivesRes != null) {
|
||||
JSONArray items = drivesRes.getJSONArray("items");
|
||||
if (items == null) {
|
||||
items = drivesRes.getJSONArray("drives");
|
||||
}
|
||||
if (items == null && drivesRes.get("data") != null) {
|
||||
Object data = drivesRes.get("data");
|
||||
if (data instanceof JSONArray) {
|
||||
items = (JSONArray) data;
|
||||
} else if (data instanceof JSONObject) {
|
||||
JSONObject dataObj = (JSONObject) data;
|
||||
items = dataObj.getJSONArray("items");
|
||||
if (items == null) {
|
||||
items = dataObj.getJSONArray("drives");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (items != null && !items.isEmpty()) {
|
||||
for (int i = 0; i < items.size(); i++) {
|
||||
JSONObject item = items.getJSONObject(i);
|
||||
String id = item.getString("id");
|
||||
if (id != null && !id.isEmpty()) {
|
||||
// 优先个人盘 allotee_type=user(我的云文档)
|
||||
if ("user".equalsIgnoreCase(item.getString("allotee_type"))) {
|
||||
driveId = id;
|
||||
break;
|
||||
}
|
||||
if (driveId == null) {
|
||||
driveId = id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("获取驱动盘列表失败,尝试其他方式: {}", e.getMessage());
|
||||
}
|
||||
|
||||
// 2) 若没有 drive_id,尝试「当前用户默认盘」常见路径
|
||||
if (driveId == null) {
|
||||
try {
|
||||
JSONObject meRes = WPS365ApiUtil.httpRequest("GET", baseV7 + "/drives/me", accessToken, null);
|
||||
if (meRes != null && meRes.getString("id") != null) {
|
||||
driveId = meRes.getString("id");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("v7/drives/me 失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (driveId == null) {
|
||||
throw new RuntimeException("获取文件列表失败:无法获取驱动盘ID(drive_id)。请确认已授权且 WPS 开放平台云文档接口可用,参见 open.wps.cn 云文档业务域文档。");
|
||||
}
|
||||
|
||||
// 3) 获取该盘下的文件列表
|
||||
String filesUrl = baseV7 + "/drives/" + driveId + "/files?page=" + page + "&page_size=" + pageSize;
|
||||
try {
|
||||
log.debug("调用文件列表API: {}", filesUrl);
|
||||
JSONObject result = WPS365ApiUtil.httpRequest("GET", filesUrl, accessToken, null);
|
||||
// 前端期望 data.files;v7 可能返回 items,统一成 files
|
||||
if (result != null && result.get("files") == null && result.getJSONArray("items") != null) {
|
||||
result.put("files", result.getJSONArray("items"));
|
||||
}
|
||||
return result != null ? result : new JSONObject();
|
||||
} catch (Exception e) {
|
||||
log.error("获取文件列表失败 - url: {}", filesUrl, e);
|
||||
throw new RuntimeException("获取文件列表失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject getFileInfo(String accessToken, String fileToken) {
|
||||
try {
|
||||
// WPS365文件信息API路径:/yundoc/files/{fileToken}
|
||||
String url = wps365Config.getApiBaseUrl() + "/yundoc/files/" + fileToken;
|
||||
log.debug("调用文件信息API: {}", url);
|
||||
return WPS365ApiUtil.httpRequest("GET", url, accessToken, null);
|
||||
} catch (Exception e) {
|
||||
log.error("获取文件信息失败 - fileToken: {}, url: {}", fileToken,
|
||||
wps365Config.getApiBaseUrl() + "/yundoc/files/" + fileToken, e);
|
||||
throw new RuntimeException("获取文件信息失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject updateCells(String accessToken, String fileToken, int sheetIdx, String range, List<List<Object>> values) {
|
||||
try {
|
||||
// WPS365 KSheet API: /api/v1/openapi/ksheet/:file_token/sheets/:sheet_idx/cells
|
||||
String url = wps365Config.getApiBaseUrl() + "/openapi/ksheet/" + fileToken + "/sheets/" + sheetIdx + "/cells";
|
||||
|
||||
// 构建请求体
|
||||
JSONObject requestBody = new JSONObject();
|
||||
requestBody.put("range", range);
|
||||
|
||||
// 将values转换为JSONArray
|
||||
JSONArray valuesArray = new JSONArray();
|
||||
if (values != null) {
|
||||
for (List<Object> row : values) {
|
||||
JSONArray rowArray = new JSONArray();
|
||||
if (row != null) {
|
||||
for (Object cell : row) {
|
||||
rowArray.add(cell != null ? cell : "");
|
||||
}
|
||||
}
|
||||
valuesArray.add(rowArray);
|
||||
}
|
||||
}
|
||||
requestBody.put("values", valuesArray);
|
||||
|
||||
String bodyStr = requestBody.toJSONString();
|
||||
log.debug("更新单元格数据 - url: {}, range: {}, values: {}", url, range, bodyStr);
|
||||
|
||||
return WPS365ApiUtil.httpRequest("POST", url, accessToken, bodyStr);
|
||||
} catch (Exception e) {
|
||||
log.error("更新单元格数据失败 - fileToken: {}, sheetIdx: {}, range: {}", fileToken, sheetIdx, range, e);
|
||||
throw new RuntimeException("更新单元格数据失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject readCells(String accessToken, String fileToken, int sheetIdx, String range) {
|
||||
try {
|
||||
// WPS365 KSheet API: GET /api/v1/openapi/ksheet/:file_token/sheets/:sheet_idx/cells
|
||||
String url = wps365Config.getApiBaseUrl() + "/openapi/ksheet/" + fileToken + "/sheets/" + sheetIdx + "/cells";
|
||||
if (range != null && !range.trim().isEmpty()) {
|
||||
url += "?range=" + java.net.URLEncoder.encode(range, "UTF-8");
|
||||
}
|
||||
|
||||
return WPS365ApiUtil.httpRequest("GET", url, accessToken, null);
|
||||
} catch (Exception e) {
|
||||
log.error("读取单元格数据失败 - fileToken: {}, sheetIdx: {}, range: {}", fileToken, sheetIdx, range, e);
|
||||
throw new RuntimeException("读取单元格数据失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject getSheetList(String accessToken, String fileToken) {
|
||||
try {
|
||||
// WPS365 KSheet API: GET /api/v1/openapi/ksheet/:file_token/sheets
|
||||
String url = wps365Config.getApiBaseUrl() + "/openapi/ksheet/" + fileToken + "/sheets";
|
||||
return WPS365ApiUtil.httpRequest("GET", url, accessToken, null);
|
||||
} catch (Exception e) {
|
||||
log.error("获取工作表列表失败 - fileToken: {}", fileToken, e);
|
||||
throw new RuntimeException("获取工作表列表失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject createSheet(String accessToken, String fileToken, String sheetName) {
|
||||
try {
|
||||
// WPS365 KSheet API: POST /api/v1/openapi/ksheet/:file_token/sheets
|
||||
String url = wps365Config.getApiBaseUrl() + "/openapi/ksheet/" + fileToken + "/sheets";
|
||||
|
||||
JSONObject requestBody = new JSONObject();
|
||||
requestBody.put("name", sheetName);
|
||||
|
||||
String bodyStr = requestBody.toJSONString();
|
||||
return WPS365ApiUtil.httpRequest("POST", url, accessToken, bodyStr);
|
||||
} catch (Exception e) {
|
||||
log.error("创建数据表失败 - fileToken: {}, sheetName: {}", fileToken, sheetName, e);
|
||||
throw new RuntimeException("创建数据表失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject batchUpdateCells(String accessToken, String fileToken, int sheetIdx, List<Map<String, Object>> updates) {
|
||||
try {
|
||||
// WPS365 KSheet API: POST /api/v1/openapi/ksheet/:file_token/sheets/:sheet_idx/cells/batch
|
||||
String url = wps365Config.getApiBaseUrl() + "/openapi/ksheet/" + fileToken + "/sheets/" + sheetIdx + "/cells/batch";
|
||||
|
||||
JSONObject requestBody = new JSONObject();
|
||||
JSONArray updatesArray = new JSONArray();
|
||||
|
||||
if (updates != null) {
|
||||
for (Map<String, Object> update : updates) {
|
||||
JSONObject updateObj = new JSONObject();
|
||||
updateObj.put("range", update.get("range"));
|
||||
|
||||
// 将values转换为JSONArray
|
||||
@SuppressWarnings("unchecked")
|
||||
List<List<Object>> values = (List<List<Object>>) update.get("values");
|
||||
JSONArray valuesArray = new JSONArray();
|
||||
if (values != null) {
|
||||
for (List<Object> row : values) {
|
||||
JSONArray rowArray = new JSONArray();
|
||||
if (row != null) {
|
||||
for (Object cell : row) {
|
||||
rowArray.add(cell != null ? cell : "");
|
||||
}
|
||||
}
|
||||
valuesArray.add(rowArray);
|
||||
}
|
||||
}
|
||||
updateObj.put("values", valuesArray);
|
||||
updatesArray.add(updateObj);
|
||||
}
|
||||
}
|
||||
|
||||
requestBody.put("updates", updatesArray);
|
||||
String bodyStr = requestBody.toJSONString();
|
||||
|
||||
return WPS365ApiUtil.httpRequest("POST", url, accessToken, bodyStr);
|
||||
} catch (Exception e) {
|
||||
log.error("批量更新单元格数据失败 - fileToken: {}, sheetIdx: {}", fileToken, sheetIdx, e);
|
||||
throw new RuntimeException("批量更新单元格数据失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject readAirSheetCells(String accessToken, String fileId, String worksheetId, String range) {
|
||||
// 智能表格(AirSheet):openapi.wps.cn 下 airsheet 接口目前均返回 404,先试 AirSheet 再回退到 KSheet(同一 fileId 作为 file_token 试读)
|
||||
String baseUrl = wps365Config.getApiBaseUrl();
|
||||
int sheetIdx = parseSheetIndex(worksheetId, fileId);
|
||||
|
||||
// 方案1:AirSheet 路径 GET /openapi/airsheet/{fileId}/sheets/{sheetIdx}/cells
|
||||
try {
|
||||
String url = baseUrl + "/openapi/airsheet/" + fileId + "/sheets/" + sheetIdx + "/cells";
|
||||
if (range != null && !range.trim().isEmpty()) {
|
||||
url += "?range=" + java.net.URLEncoder.encode(range, "UTF-8");
|
||||
}
|
||||
log.debug("读取AirSheet - url: {}, fileId: {}, sheetIdx: {}", url, fileId, sheetIdx);
|
||||
return WPS365ApiUtil.httpRequest("GET", url, accessToken, null);
|
||||
} catch (Exception e) {
|
||||
log.warn("AirSheet 接口 404 或失败,尝试 KSheet 回退 - {}", e.getMessage());
|
||||
}
|
||||
|
||||
// 方案2:用同一 ID 走 KSheet(在线表格)接口——若文档在「文件列表」里以 KSheet 形式存在则可用
|
||||
try {
|
||||
String url = baseUrl + "/openapi/ksheet/" + fileId + "/sheets/" + sheetIdx + "/cells";
|
||||
if (range != null && !range.trim().isEmpty()) {
|
||||
url += "?range=" + java.net.URLEncoder.encode(range, "UTF-8");
|
||||
}
|
||||
log.debug("回退到 KSheet 读取 - url: {}, fileId 作 file_token: {}", url, fileId);
|
||||
return WPS365ApiUtil.httpRequest("GET", url, accessToken, null);
|
||||
} catch (Exception e2) {
|
||||
log.warn("KSheet 回退也失败 - {}", e2.getMessage());
|
||||
}
|
||||
|
||||
// 方案3:兼容旧版 v7 airsheet(若官方恢复)
|
||||
try {
|
||||
String url = "https://openapi.wps.cn/v7/airsheet/" + fileId + "/sheets/" + sheetIdx + "/cells";
|
||||
if (range != null && !range.trim().isEmpty()) {
|
||||
url += "?range=" + java.net.URLEncoder.encode(range, "UTF-8");
|
||||
}
|
||||
return WPS365ApiUtil.httpRequest("GET", url, accessToken, null);
|
||||
} catch (Exception e3) {
|
||||
throw new RuntimeException(
|
||||
"读取智能表格失败:AirSheet 与 KSheet 接口均不可用(404)。请确认:1) 使用「文件列表」接口返回的 file_token 再试;2) 若文档在金山文档(kdocs.cn)创建,需用金山文档开放平台(developer.kdocs.cn)的 KSheet API。原始错误: " + e3.getMessage(),
|
||||
e3);
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析工作表索引:0、空或与 fileId 相同时为 0,否则按数字解析 */
|
||||
private int parseSheetIndex(String worksheetId, String fileId) {
|
||||
if (worksheetId == null || worksheetId.trim().isEmpty() || "0".equals(worksheetId) || (fileId != null && fileId.equals(worksheetId))) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(worksheetId.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject updateAirSheetCells(String accessToken, String fileId, String worksheetId, String range, List<List<Object>> values) {
|
||||
String baseUrl = wps365Config.getApiBaseUrl();
|
||||
int sheetIdx = parseSheetIndex(worksheetId, fileId);
|
||||
|
||||
JSONObject requestBody = new JSONObject();
|
||||
if (range != null && !range.trim().isEmpty()) {
|
||||
requestBody.put("range", range);
|
||||
}
|
||||
JSONArray valuesArray = new JSONArray();
|
||||
if (values != null) {
|
||||
for (List<Object> row : values) {
|
||||
JSONArray rowArray = new JSONArray();
|
||||
if (row != null) {
|
||||
for (Object cell : row) {
|
||||
rowArray.add(cell != null ? cell : "");
|
||||
}
|
||||
}
|
||||
valuesArray.add(rowArray);
|
||||
}
|
||||
}
|
||||
requestBody.put("values", valuesArray);
|
||||
String bodyStr = requestBody.toJSONString();
|
||||
|
||||
// 方案1:AirSheet 路径 POST /openapi/airsheet/{fileId}/sheets/{sheetIdx}/cells
|
||||
try {
|
||||
String url = baseUrl + "/openapi/airsheet/" + fileId + "/sheets/" + sheetIdx + "/cells";
|
||||
log.debug("更新AirSheet - url: {}, fileId: {}, sheetIdx: {}", url, fileId, sheetIdx);
|
||||
return WPS365ApiUtil.httpRequest("POST", url, accessToken, bodyStr);
|
||||
} catch (Exception e) {
|
||||
log.warn("AirSheet 写入失败,尝试 KSheet 回退 - {}", e.getMessage());
|
||||
}
|
||||
|
||||
// 方案2:用同一 ID 走 KSheet 写入接口
|
||||
try {
|
||||
String url = baseUrl + "/openapi/ksheet/" + fileId + "/sheets/" + sheetIdx + "/cells";
|
||||
log.debug("回退到 KSheet 写入 - url: {}", url);
|
||||
return WPS365ApiUtil.httpRequest("POST", url, accessToken, bodyStr);
|
||||
} catch (Exception e2) {
|
||||
log.warn("KSheet 回退写入也失败 - {}", e2.getMessage());
|
||||
}
|
||||
|
||||
// 方案3:兼容 v7 PUT /airsheet/{file_id}/worksheets
|
||||
try {
|
||||
String url = "https://openapi.wps.cn/v7/airsheet/" + fileId + "/worksheets";
|
||||
return WPS365ApiUtil.httpRequest("PUT", url, accessToken, bodyStr);
|
||||
} catch (Exception e3) {
|
||||
throw new RuntimeException(
|
||||
"更新智能表格失败:AirSheet 与 KSheet 接口均不可用。请使用「文件列表」返回的 file_token 调用 KSheet 写入接口,或确认文档类型。原始错误: " + e3.getMessage(),
|
||||
e3);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析A1:B5格式的range,转换为行列参数
|
||||
* 返回数组:[row_from, row_to, col_from, col_to]
|
||||
* 注意:WPS365的行列索引可能从0开始或从1开始,这里假设从1开始(Excel标准)
|
||||
*
|
||||
* @param range 单元格范围,如 "A1:B5"
|
||||
* @return 行列参数数组,如果解析失败返回null
|
||||
*/
|
||||
private int[] parseRangeToRowCol(String range) {
|
||||
if (range == null || range.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 解析A1:B5格式
|
||||
String[] parts = range.split(":");
|
||||
if (parts.length != 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String startCell = parts[0].trim();
|
||||
String endCell = parts[1].trim();
|
||||
|
||||
// 解析起始单元格,如 "A1" -> row=1, col=1
|
||||
int[] start = parseCellAddress(startCell);
|
||||
int[] end = parseCellAddress(endCell);
|
||||
|
||||
if (start == null || end == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 返回 [row_from, row_to, col_from, col_to]
|
||||
// 注意:WPS365可能从0开始索引,这里先使用从1开始的索引(Excel标准)
|
||||
// 如果API要求从0开始,需要减1
|
||||
return new int[]{start[0], end[0], start[1], end[1]};
|
||||
} catch (Exception e) {
|
||||
log.warn("解析range失败: {}", range, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析单元格地址,如 "A1" -> [row=1, col=1]
|
||||
*
|
||||
* @param cellAddress 单元格地址,如 "A1", "B5"
|
||||
* @return [row, col] 数组,如果解析失败返回null
|
||||
*/
|
||||
private int[] parseCellAddress(String cellAddress) {
|
||||
if (cellAddress == null || cellAddress.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 分离字母部分(列)和数字部分(行)
|
||||
// 例如 "A1" -> col="A", row="1"
|
||||
String colStr = "";
|
||||
String rowStr = "";
|
||||
|
||||
for (char c : cellAddress.toCharArray()) {
|
||||
if (Character.isLetter(c)) {
|
||||
colStr += c;
|
||||
} else if (Character.isDigit(c)) {
|
||||
rowStr += c;
|
||||
}
|
||||
}
|
||||
|
||||
if (colStr.isEmpty() || rowStr.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 转换列字母为数字,A=1, B=2, ..., Z=26, AA=27, ...
|
||||
int col = 0;
|
||||
for (char c : colStr.toUpperCase().toCharArray()) {
|
||||
col = col * 26 + (c - 'A' + 1);
|
||||
}
|
||||
|
||||
// 转换行号为整数
|
||||
int row = Integer.parseInt(rowStr);
|
||||
|
||||
return new int[]{row, col};
|
||||
} catch (Exception e) {
|
||||
log.warn("解析单元格地址失败: {}", cellAddress, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,365 +0,0 @@
|
||||
package com.ruoyi.jarvis.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.ruoyi.jarvis.config.WPS365Config;
|
||||
import com.ruoyi.jarvis.domain.dto.WPS365TokenInfo;
|
||||
import com.ruoyi.jarvis.service.IWPS365OAuthService;
|
||||
import com.ruoyi.jarvis.service.IWPS365ApiService;
|
||||
import com.ruoyi.jarvis.util.WPS365ApiUtil;
|
||||
import com.ruoyi.common.core.redis.RedisCache;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* WPS365 OAuth授权服务实现类
|
||||
*
|
||||
* @author system
|
||||
*/
|
||||
@Service
|
||||
public class WPS365OAuthServiceImpl implements IWPS365OAuthService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(WPS365OAuthServiceImpl.class);
|
||||
|
||||
/** Redis key前缀,用于存储用户token */
|
||||
private static final String TOKEN_KEY_PREFIX = "wps365:token:";
|
||||
|
||||
/** Token过期时间(秒),默认30天 */
|
||||
private static final long TOKEN_EXPIRE_TIME = 30 * 24 * 60 * 60;
|
||||
|
||||
@Autowired
|
||||
private WPS365Config wps365Config;
|
||||
|
||||
@Autowired
|
||||
private RedisCache redisCache;
|
||||
|
||||
@Autowired
|
||||
private IWPS365ApiService wps365ApiService;
|
||||
|
||||
@Override
|
||||
public String getAuthUrl(String state) {
|
||||
if (wps365Config == null) {
|
||||
throw new RuntimeException("WPS365配置未加载,请检查WPS365Config是否正确注入");
|
||||
}
|
||||
|
||||
String appId = wps365Config.getAppId();
|
||||
String redirectUri = wps365Config.getRedirectUri();
|
||||
String oauthUrl = wps365Config.getOauthUrl();
|
||||
|
||||
log.debug("获取授权URL - appId: {}, redirectUri: {}, oauthUrl: {}", appId, redirectUri, oauthUrl);
|
||||
|
||||
// 验证配置参数
|
||||
if (appId == null || appId.trim().isEmpty()) {
|
||||
throw new RuntimeException("WPS365应用ID未配置,请检查application.yml中的wps365.app-id配置");
|
||||
}
|
||||
if (redirectUri == null || redirectUri.trim().isEmpty()) {
|
||||
throw new RuntimeException("WPS365回调地址未配置,请检查application.yml中的wps365.redirect-uri配置");
|
||||
}
|
||||
|
||||
// 构建授权URL
|
||||
StringBuilder authUrl = new StringBuilder();
|
||||
authUrl.append(oauthUrl);
|
||||
|
||||
// WPS365可能使用 app_id 而不是 client_id,先尝试 client_id(标准OAuth2参数)
|
||||
// 如果失败,可能需要改为 app_id
|
||||
authUrl.append("?client_id=").append(appId);
|
||||
log.debug("授权URL参数 - client_id: {}", appId);
|
||||
|
||||
// 重要:redirect_uri必须与WPS365开放平台配置的回调地址完全一致
|
||||
// 包括协议(https)、域名、路径,不能有多余的斜杠
|
||||
String finalRedirectUri = redirectUri.trim();
|
||||
// 确保URL末尾没有斜杠(除非是根路径)
|
||||
if (finalRedirectUri.endsWith("/") && !finalRedirectUri.equals("https://")) {
|
||||
finalRedirectUri = finalRedirectUri.substring(0, finalRedirectUri.length() - 1);
|
||||
}
|
||||
|
||||
// 验证redirect_uri不为空
|
||||
if (finalRedirectUri == null || finalRedirectUri.isEmpty()) {
|
||||
throw new RuntimeException("redirect_uri不能为空,请检查application.yml中的wps365.redirect-uri配置");
|
||||
}
|
||||
|
||||
try {
|
||||
String encodedRedirectUri = java.net.URLEncoder.encode(finalRedirectUri, "UTF-8");
|
||||
authUrl.append("&redirect_uri=").append(encodedRedirectUri);
|
||||
log.info("使用回调地址: {} (编码后: {})", finalRedirectUri, encodedRedirectUri);
|
||||
} catch (java.io.UnsupportedEncodingException e) {
|
||||
log.error("URL编码失败", e);
|
||||
authUrl.append("&redirect_uri=").append(finalRedirectUri);
|
||||
}
|
||||
|
||||
// response_type参数(必需)
|
||||
authUrl.append("&response_type=code");
|
||||
log.debug("授权URL参数 - response_type: code");
|
||||
|
||||
// scope参数(必需,根据WPS365官方文档)
|
||||
// 优先使用配置文件中指定的scope,如果没有配置则使用默认值
|
||||
// 重要:WPS365官方文档明确要求使用英文逗号分隔,且权限名称必须与后台注册的完全一致
|
||||
// 根据官方文档:https://open.wps.cn/documents/app-integration-dev/wps365/server/
|
||||
// 权限名称格式为:kso.xxx.read 或 kso.xxx.readwrite(不是 file.read)
|
||||
String scope = wps365Config.getScope();
|
||||
if (scope == null || scope.trim().isEmpty()) {
|
||||
// 默认scope,根据WPS365官方文档:
|
||||
// 1. 必须使用英文逗号分隔(不是空格)
|
||||
// 2. 权限名称必须以 kso. 开头,格式如:kso.file.read, kso.file.readwrite
|
||||
// 3. 常见权限名称(根据官方文档):
|
||||
// - kso.file.read (文件读取)
|
||||
// - kso.file.readwrite (文件读写)
|
||||
// - kso.doclib.readwrite (文档库读写)
|
||||
// - kso.wiki.readwrite (知识库读写)
|
||||
// - 对于在线表格(KSheet),可能需要 kso.file.readwrite
|
||||
// - 对于智能表格(AirSheet)读写,需要 kso.airsheet.readwrite
|
||||
//
|
||||
// 如果报错invalid_scope,请:
|
||||
// 1. 登录WPS365开放平台:https://open.wps.cn/
|
||||
// 2. 进入"开发配置" > "权限管理"
|
||||
// 3. 查看已申请权限的准确名称(必须以 kso. 开头)
|
||||
// 4. 在application.yml中配置scope参数,使用逗号分隔
|
||||
scope = "kso.file.readwrite,kso.airsheet.readwrite"; // 文件读写 + 智能表格读写(后端写入智能表格必须含 kso.airsheet.readwrite)
|
||||
}
|
||||
scope = scope.trim();
|
||||
|
||||
// URL编码scope参数
|
||||
try {
|
||||
String encodedScope = java.net.URLEncoder.encode(scope, "UTF-8");
|
||||
authUrl.append("&scope=").append(encodedScope);
|
||||
log.debug("授权URL参数 - scope: {} (编码后: {})", scope, encodedScope);
|
||||
} catch (java.io.UnsupportedEncodingException e) {
|
||||
log.error("Scope URL编码失败", e);
|
||||
authUrl.append("&scope=").append(scope);
|
||||
}
|
||||
|
||||
// state参数(推荐,用于防止CSRF攻击)
|
||||
if (state == null || state.trim().isEmpty()) {
|
||||
state = UUID.randomUUID().toString();
|
||||
}
|
||||
authUrl.append("&state=").append(state);
|
||||
log.debug("授权URL参数 - state: {}", state);
|
||||
|
||||
// prompt参数(可选,用于控制授权页面显示)
|
||||
// prompt=consent: 强制显示授权确认页面,即使用户已授权过
|
||||
// prompt=login: 强制显示登录页面
|
||||
// 如果不添加此参数,已登录且已授权的用户会直接跳过授权页面
|
||||
// 注意:WPS365可能不支持此参数,如果不支持会被忽略
|
||||
authUrl.append("&prompt=consent");
|
||||
log.debug("授权URL参数 - prompt: consent (强制显示授权确认页面)");
|
||||
|
||||
String result = authUrl.toString();
|
||||
log.info("生成授权URL: {}", result);
|
||||
log.warn("⚠️ 请确保WPS365开放平台配置的回调地址与以下地址完全一致(包括协议、域名、路径):");
|
||||
log.warn("⚠️ 回调地址: {}", finalRedirectUri);
|
||||
log.info("📋 授权请求参数清单:");
|
||||
log.info(" - client_id: {}", appId);
|
||||
log.info(" - redirect_uri: {}", finalRedirectUri);
|
||||
log.info(" - response_type: code");
|
||||
log.info(" - scope: {}", scope);
|
||||
log.info(" - state: {}", state);
|
||||
log.info(" - prompt: consent (强制显示授权确认页面)");
|
||||
log.info("💡 说明:已添加 prompt=consent 参数,强制显示授权确认页面");
|
||||
log.info(" 如果用户已登录且已授权过,WPS365可能会跳过授权页面直接返回code");
|
||||
log.info(" 这是正常的OAuth2行为,不是安全问题");
|
||||
log.info("如果仍然报错,请检查:");
|
||||
log.info(" 1. WPS365平台配置的回调地址是否与上述redirect_uri完全一致");
|
||||
log.info(" 2. 参数名是否正确(WPS365可能使用app_id而不是client_id)");
|
||||
log.info(" 3. scope权限是否已在WPS365平台申请");
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public WPS365TokenInfo getAccessTokenByCode(String code) {
|
||||
try {
|
||||
JSONObject result = WPS365ApiUtil.getAccessToken(
|
||||
wps365Config.getAppId(),
|
||||
wps365Config.getAppKey(),
|
||||
code,
|
||||
wps365Config.getRedirectUri(),
|
||||
wps365Config.getTokenUrl()
|
||||
);
|
||||
|
||||
// 解析响应并创建TokenInfo对象
|
||||
WPS365TokenInfo tokenInfo = new WPS365TokenInfo();
|
||||
tokenInfo.setAccessToken(result.getString("access_token"));
|
||||
tokenInfo.setRefreshToken(result.getString("refresh_token"));
|
||||
tokenInfo.setTokenType(result.getString("token_type"));
|
||||
tokenInfo.setExpiresIn(result.getInteger("expires_in"));
|
||||
tokenInfo.setScope(result.getString("scope"));
|
||||
|
||||
// WPS365的token响应中可能不包含user_id,需要调用用户信息API获取
|
||||
String userId = result.getString("user_id");
|
||||
if (userId == null || userId.trim().isEmpty()) {
|
||||
// 尝试通过用户信息API获取用户ID
|
||||
try {
|
||||
JSONObject userInfo = wps365ApiService.getUserInfo(tokenInfo.getAccessToken());
|
||||
if (userInfo != null) {
|
||||
// 尝试多种可能的用户ID字段名
|
||||
userId = userInfo.getString("id");
|
||||
if (userId == null || userId.trim().isEmpty()) {
|
||||
userId = userInfo.getString("user_id");
|
||||
}
|
||||
if (userId == null || userId.trim().isEmpty()) {
|
||||
userId = userInfo.getString("open_id");
|
||||
}
|
||||
if (userId == null || userId.trim().isEmpty()) {
|
||||
userId = userInfo.getString("uid");
|
||||
}
|
||||
// 如果还是获取不到,使用access_token的前16位作为标识(临时方案)
|
||||
if (userId == null || userId.trim().isEmpty()) {
|
||||
String accessToken = tokenInfo.getAccessToken();
|
||||
if (accessToken != null && accessToken.length() > 16) {
|
||||
userId = "wps365_" + accessToken.substring(0, 16);
|
||||
log.warn("无法从用户信息API获取用户ID,使用access_token前16位作为标识: {}", userId);
|
||||
} else {
|
||||
userId = "wps365_default";
|
||||
log.warn("无法获取用户ID,使用默认值: {}", userId);
|
||||
}
|
||||
} else {
|
||||
log.info("通过用户信息API获取到用户ID: {}", userId);
|
||||
}
|
||||
} else {
|
||||
userId = "wps365_default";
|
||||
log.warn("用户信息API返回为空,使用默认用户ID: {}", userId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("调用用户信息API失败,使用默认用户ID: {}", e.getMessage());
|
||||
// 使用access_token的前16位作为标识(临时方案)
|
||||
String accessToken = tokenInfo.getAccessToken();
|
||||
if (accessToken != null && accessToken.length() > 16) {
|
||||
userId = "wps365_" + accessToken.substring(0, 16);
|
||||
} else {
|
||||
userId = "wps365_default";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokenInfo.setUserId(userId);
|
||||
log.info("成功获取访问令牌 - userId: {}", userId);
|
||||
|
||||
return tokenInfo;
|
||||
} catch (Exception e) {
|
||||
log.error("通过授权码获取访问令牌失败", e);
|
||||
throw new RuntimeException("获取访问令牌失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public WPS365TokenInfo refreshAccessToken(String refreshToken) {
|
||||
try {
|
||||
JSONObject result = WPS365ApiUtil.refreshAccessToken(
|
||||
wps365Config.getAppId(),
|
||||
wps365Config.getAppKey(),
|
||||
refreshToken,
|
||||
wps365Config.getRefreshTokenUrl()
|
||||
);
|
||||
|
||||
// 解析响应并创建TokenInfo对象
|
||||
WPS365TokenInfo tokenInfo = new WPS365TokenInfo();
|
||||
tokenInfo.setAccessToken(result.getString("access_token"));
|
||||
tokenInfo.setRefreshToken(result.getString("refresh_token"));
|
||||
tokenInfo.setTokenType(result.getString("token_type"));
|
||||
tokenInfo.setExpiresIn(result.getInteger("expires_in"));
|
||||
tokenInfo.setScope(result.getString("scope"));
|
||||
tokenInfo.setUserId(result.getString("user_id"));
|
||||
|
||||
log.info("成功刷新访问令牌 - userId: {}", tokenInfo.getUserId());
|
||||
|
||||
return tokenInfo;
|
||||
} catch (Exception e) {
|
||||
log.error("刷新访问令牌失败", e);
|
||||
throw new RuntimeException("刷新访问令牌失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public WPS365TokenInfo getCurrentToken() {
|
||||
// 尝试查找所有WPS365 token(通常只有一个)
|
||||
// 使用Redis的keys命令查找所有匹配的token key
|
||||
try {
|
||||
String pattern = TOKEN_KEY_PREFIX + "*";
|
||||
// 注意:keys命令在生产环境可能性能较差,但这里token数量通常很少
|
||||
java.util.Collection<String> keys = redisCache.keys(pattern);
|
||||
if (keys != null && !keys.isEmpty()) {
|
||||
// 返回第一个找到的有效token
|
||||
for (String key : keys) {
|
||||
WPS365TokenInfo tokenInfo = redisCache.getCacheObject(key);
|
||||
if (tokenInfo != null && isTokenValid(tokenInfo)) {
|
||||
log.debug("找到有效的WPS365 token: {}", key);
|
||||
return tokenInfo;
|
||||
}
|
||||
}
|
||||
// 如果没有有效的token,返回第一个(即使过期)
|
||||
for (String key : keys) {
|
||||
WPS365TokenInfo tokenInfo = redisCache.getCacheObject(key);
|
||||
if (tokenInfo != null) {
|
||||
log.debug("找到WPS365 token(可能已过期): {}", key);
|
||||
return tokenInfo;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("查找WPS365 token失败", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveToken(String userId, WPS365TokenInfo tokenInfo) {
|
||||
if (userId == null || userId.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("用户ID不能为空");
|
||||
}
|
||||
if (tokenInfo == null || tokenInfo.getAccessToken() == null) {
|
||||
throw new IllegalArgumentException("Token信息不能为空");
|
||||
}
|
||||
|
||||
String key = TOKEN_KEY_PREFIX + userId;
|
||||
redisCache.setCacheObject(key, tokenInfo, (int) TOKEN_EXPIRE_TIME, TimeUnit.SECONDS);
|
||||
log.info("保存用户Token - userId: {}", userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearToken(String userId) {
|
||||
if (userId == null || userId.trim().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String key = TOKEN_KEY_PREFIX + userId;
|
||||
redisCache.deleteObject(key);
|
||||
log.info("清除用户Token - userId: {}", userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isTokenValid(WPS365TokenInfo tokenInfo) {
|
||||
if (tokenInfo == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if (tokenInfo.isExpired()) {
|
||||
log.debug("Token已过期");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查必要字段
|
||||
if (tokenInfo.getAccessToken() == null || tokenInfo.getAccessToken().trim().isEmpty()) {
|
||||
log.debug("Token缺少accessToken");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户ID获取Token(从Redis)
|
||||
*/
|
||||
public WPS365TokenInfo getTokenByUserId(String userId) {
|
||||
if (userId == null || userId.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String key = TOKEN_KEY_PREFIX + userId;
|
||||
return redisCache.getCacheObject(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.ruoyi.jarvis.util;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 调用金山文档开放平台 HTTP 接口(含 OAuth 换票与 Open API)
|
||||
*/
|
||||
public final class KdocsOpenApiClient {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(KdocsOpenApiClient.class);
|
||||
|
||||
static {
|
||||
System.setProperty("java.net.useSystemProxies", "false");
|
||||
}
|
||||
|
||||
private KdocsOpenApiClient() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求 JSON 接口,返回根对象(含 code、data)
|
||||
*
|
||||
* @param jsonContentTypeForGet 部分 GET(如 oauth2/access_token)要求 Content-Type: application/json
|
||||
*/
|
||||
public static JSONObject exchange(String method, String fullUrl, String jsonBody, boolean jsonContentTypeForGet) {
|
||||
try {
|
||||
URL u = new URL(fullUrl);
|
||||
HttpURLConnection conn = (HttpURLConnection) u.openConnection(java.net.Proxy.NO_PROXY);
|
||||
conn.setRequestMethod(method);
|
||||
conn.setConnectTimeout(15000);
|
||||
conn.setReadTimeout(60000);
|
||||
conn.setRequestProperty("Accept", "application/json");
|
||||
if ("GET".equals(method) && jsonContentTypeForGet) {
|
||||
conn.setRequestProperty("Content-Type", "application/json");
|
||||
}
|
||||
if ("POST".equals(method) || "PUT".equals(method)) {
|
||||
conn.setRequestProperty("Content-Type", "application/json");
|
||||
conn.setDoOutput(true);
|
||||
if (jsonBody != null && !jsonBody.isEmpty()) {
|
||||
try (OutputStreamWriter w = new OutputStreamWriter(conn.getOutputStream(), StandardCharsets.UTF_8)) {
|
||||
w.write(jsonBody);
|
||||
w.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int status = conn.getResponseCode();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
InputStream stream = status >= 200 && status < 300 ? conn.getInputStream() : conn.getErrorStream();
|
||||
if (stream != null) {
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
sb.append(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
String text = sb.toString();
|
||||
log.debug("Kdocs HTTP {} {} -> {} body: {}", method, fullUrl, status, text.length() > 500 ? text.substring(0, 500) + "..." : text);
|
||||
if (status < 200 || status >= 300) {
|
||||
throw new RuntimeException("HTTP " + status + ": " + text);
|
||||
}
|
||||
if (StringUtils.isBlank(text)) {
|
||||
return new JSONObject();
|
||||
}
|
||||
return JSON.parseObject(text);
|
||||
} catch (RuntimeException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Kdocs 请求失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务 Open API:code 必须为 0,返回 data
|
||||
*/
|
||||
public static JSONObject requireBusinessData(JSONObject root) {
|
||||
if (root == null) {
|
||||
throw new RuntimeException("Kdocs 响应为空");
|
||||
}
|
||||
Integer c = root.getInteger("code");
|
||||
if (c == null || c != 0) {
|
||||
throw new RuntimeException("Kdocs 业务错误: " + root.toJSONString());
|
||||
}
|
||||
JSONObject data = root.getJSONObject("data");
|
||||
return data != null ? data : new JSONObject();
|
||||
}
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
package com.ruoyi.jarvis.util;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* WPS365 API工具类
|
||||
*
|
||||
* @author system
|
||||
*/
|
||||
public class WPS365ApiUtil {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(WPS365ApiUtil.class);
|
||||
|
||||
// 静态初始化块:禁用系统代理
|
||||
static {
|
||||
System.setProperty("java.net.useSystemProxies", "false");
|
||||
System.clearProperty("http.proxyHost");
|
||||
System.clearProperty("http.proxyPort");
|
||||
System.clearProperty("https.proxyHost");
|
||||
System.clearProperty("https.proxyPort");
|
||||
log.info("已禁用系统代理设置,WPS365 API将直接连接");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取访问令牌
|
||||
*
|
||||
* @param appId 应用ID
|
||||
* @param appKey 应用密钥
|
||||
* @param code 授权码
|
||||
* @param redirectUri 回调地址
|
||||
* @param tokenUrl Token地址
|
||||
* @return 包含access_token和refresh_token的JSON对象
|
||||
*/
|
||||
public static JSONObject getAccessToken(String appId, String appKey, String code, String redirectUri, String tokenUrl) {
|
||||
try {
|
||||
// 构建请求参数
|
||||
StringBuilder params = new StringBuilder();
|
||||
params.append("grant_type=authorization_code");
|
||||
params.append("&client_id=").append(appId);
|
||||
params.append("&client_secret=").append(appKey);
|
||||
params.append("&code=").append(code);
|
||||
params.append("&redirect_uri=").append(java.net.URLEncoder.encode(redirectUri, "UTF-8"));
|
||||
|
||||
// 使用HttpURLConnection,不使用代理
|
||||
URL url = new URL(tokenUrl);
|
||||
java.net.Proxy proxy = java.net.Proxy.NO_PROXY;
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection(proxy);
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
|
||||
conn.setRequestProperty("Accept", "application/json");
|
||||
conn.setDoOutput(true);
|
||||
conn.setDoInput(true);
|
||||
conn.setConnectTimeout(10000);
|
||||
conn.setReadTimeout(30000);
|
||||
|
||||
// 写入请求参数
|
||||
try (OutputStream os = conn.getOutputStream();
|
||||
OutputStreamWriter osw = new OutputStreamWriter(os, StandardCharsets.UTF_8)) {
|
||||
osw.write(params.toString());
|
||||
osw.flush();
|
||||
}
|
||||
|
||||
// 读取响应
|
||||
int statusCode = conn.getResponseCode();
|
||||
BufferedReader reader;
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
reader = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8));
|
||||
} else {
|
||||
reader = new BufferedReader(new InputStreamReader(conn.getErrorStream(), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
StringBuilder response = new StringBuilder();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
response.append(line);
|
||||
}
|
||||
reader.close();
|
||||
|
||||
String responseStr = response.toString();
|
||||
log.info("获取访问令牌响应: statusCode={}, response={}", statusCode, responseStr);
|
||||
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
throw new RuntimeException("获取访问令牌失败: HTTP " + statusCode + ", response=" + responseStr);
|
||||
}
|
||||
|
||||
return JSON.parseObject(responseStr);
|
||||
} catch (Exception e) {
|
||||
log.error("获取访问令牌失败", e);
|
||||
throw new RuntimeException("获取访问令牌失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新访问令牌
|
||||
*
|
||||
* @param appId 应用ID
|
||||
* @param appKey 应用密钥
|
||||
* @param refreshToken 刷新令牌
|
||||
* @param refreshTokenUrl 刷新令牌地址
|
||||
* @return 包含新的access_token和refresh_token的JSON对象
|
||||
*/
|
||||
public static JSONObject refreshAccessToken(String appId, String appKey, String refreshToken, String refreshTokenUrl) {
|
||||
try {
|
||||
// 构建请求参数
|
||||
StringBuilder params = new StringBuilder();
|
||||
params.append("grant_type=refresh_token");
|
||||
params.append("&client_id=").append(appId);
|
||||
params.append("&client_secret=").append(appKey);
|
||||
params.append("&refresh_token=").append(refreshToken);
|
||||
|
||||
// 使用HttpURLConnection,不使用代理
|
||||
URL url = new URL(refreshTokenUrl);
|
||||
java.net.Proxy proxy = java.net.Proxy.NO_PROXY;
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection(proxy);
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
|
||||
conn.setRequestProperty("Accept", "application/json");
|
||||
conn.setDoOutput(true);
|
||||
conn.setDoInput(true);
|
||||
conn.setConnectTimeout(10000);
|
||||
conn.setReadTimeout(30000);
|
||||
|
||||
// 写入请求参数
|
||||
try (OutputStream os = conn.getOutputStream();
|
||||
OutputStreamWriter osw = new OutputStreamWriter(os, StandardCharsets.UTF_8)) {
|
||||
osw.write(params.toString());
|
||||
osw.flush();
|
||||
}
|
||||
|
||||
// 读取响应
|
||||
int statusCode = conn.getResponseCode();
|
||||
StringBuilder response = new StringBuilder();
|
||||
|
||||
try {
|
||||
BufferedReader reader;
|
||||
InputStream inputStream;
|
||||
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
inputStream = conn.getInputStream();
|
||||
} else {
|
||||
// 对于错误响应,尝试读取错误流
|
||||
inputStream = conn.getErrorStream();
|
||||
// 如果错误流为null,尝试读取正常流(某些服务器可能将错误信息放在正常流中)
|
||||
if (inputStream == null) {
|
||||
inputStream = conn.getInputStream();
|
||||
}
|
||||
}
|
||||
|
||||
// 如果输入流仍然为null,说明无法读取响应
|
||||
if (inputStream == null) {
|
||||
log.warn("无法读取HTTP响应流,状态码: {}", statusCode);
|
||||
response.append("无法读取响应内容");
|
||||
} else {
|
||||
reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
response.append(line);
|
||||
}
|
||||
reader.close();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("读取HTTP响应失败", e);
|
||||
response.append("读取响应失败: ").append(e.getMessage());
|
||||
}
|
||||
|
||||
String responseStr = response.toString();
|
||||
log.info("刷新访问令牌响应: statusCode={}, response={}", statusCode, responseStr);
|
||||
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
throw new RuntimeException("刷新访问令牌失败: HTTP " + statusCode + ", response=" + responseStr);
|
||||
}
|
||||
|
||||
return JSON.parseObject(responseStr);
|
||||
} catch (Exception e) {
|
||||
log.error("刷新访问令牌失败", e);
|
||||
throw new RuntimeException("刷新访问令牌失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送HTTP请求
|
||||
*
|
||||
* @param method 请求方法(GET/POST/PUT/DELETE)
|
||||
* @param url 请求URL
|
||||
* @param accessToken 访问令牌
|
||||
* @param body 请求体(JSON字符串,GET请求可为null)
|
||||
* @return 响应JSON对象
|
||||
*/
|
||||
public static JSONObject httpRequest(String method, String url, String accessToken, String body) {
|
||||
try {
|
||||
log.debug("发送HTTP请求: method={}, url={}", method, url);
|
||||
|
||||
URL requestUrl = new URL(url);
|
||||
java.net.Proxy proxy = java.net.Proxy.NO_PROXY;
|
||||
HttpURLConnection conn = (HttpURLConnection) requestUrl.openConnection(proxy);
|
||||
conn.setRequestMethod(method);
|
||||
conn.setRequestProperty("Accept", "application/json");
|
||||
conn.setRequestProperty("Authorization", "Bearer " + accessToken);
|
||||
conn.setConnectTimeout(10000);
|
||||
conn.setReadTimeout(30000);
|
||||
|
||||
if ("POST".equals(method) || "PUT".equals(method) || "PATCH".equals(method)) {
|
||||
conn.setRequestProperty("Content-Type", "application/json");
|
||||
conn.setDoOutput(true);
|
||||
|
||||
if (body != null && !body.isEmpty()) {
|
||||
try (OutputStream os = conn.getOutputStream();
|
||||
OutputStreamWriter osw = new OutputStreamWriter(os, StandardCharsets.UTF_8)) {
|
||||
osw.write(body);
|
||||
osw.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 读取响应
|
||||
int statusCode = conn.getResponseCode();
|
||||
StringBuilder response = new StringBuilder();
|
||||
|
||||
try {
|
||||
BufferedReader reader;
|
||||
InputStream inputStream;
|
||||
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
inputStream = conn.getInputStream();
|
||||
} else {
|
||||
// 对于错误响应,尝试读取错误流
|
||||
inputStream = conn.getErrorStream();
|
||||
// 如果错误流为null,尝试读取正常流(某些服务器可能将错误信息放在正常流中)
|
||||
if (inputStream == null) {
|
||||
inputStream = conn.getInputStream();
|
||||
}
|
||||
}
|
||||
|
||||
// 如果输入流仍然为null,说明无法读取响应
|
||||
if (inputStream == null) {
|
||||
log.warn("无法读取HTTP响应流,状态码: {}", statusCode);
|
||||
response.append("无法读取响应内容");
|
||||
} else {
|
||||
reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
response.append(line);
|
||||
}
|
||||
reader.close();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("读取HTTP响应失败", e);
|
||||
response.append("读取响应失败: ").append(e.getMessage());
|
||||
}
|
||||
|
||||
String responseStr = response.toString();
|
||||
log.info("HTTP响应: statusCode={}, url={}, response={}", statusCode, url, responseStr);
|
||||
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
String errorMsg = String.format("HTTP请求失败: statusCode=%d, url=%s, response=%s",
|
||||
statusCode, url, responseStr);
|
||||
log.error(errorMsg);
|
||||
throw new RuntimeException(errorMsg);
|
||||
}
|
||||
|
||||
if (responseStr == null || responseStr.trim().isEmpty()) {
|
||||
return new JSONObject();
|
||||
}
|
||||
|
||||
return JSON.parseObject(responseStr);
|
||||
} catch (Exception e) {
|
||||
log.error("HTTP请求失败: method={}, url={}", method, url, e);
|
||||
throw new RuntimeException("HTTP请求失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user