|
|
@@ -6,20 +6,21 @@ import com.etotem.cfc.mapper.HuaweiHealthTokenMapper;
|
|
|
import org.slf4j.Logger;
|
|
|
import org.slf4j.LoggerFactory;
|
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
|
+import org.springframework.http.HttpEntity;
|
|
|
+import org.springframework.http.HttpHeaders;
|
|
|
+import org.springframework.http.MediaType;
|
|
|
+import org.springframework.http.ResponseEntity;
|
|
|
import org.springframework.stereotype.Service;
|
|
|
+import org.springframework.web.client.RestTemplate;
|
|
|
|
|
|
import javax.annotation.Resource;
|
|
|
import java.util.Date;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.concurrent.TimeUnit;
|
|
|
+
|
|
|
+import com.etotem.cfc.util.ParamUtils;
|
|
|
|
|
|
-/**
|
|
|
- * 华为运动健康 OAuth 服务(D2)。
|
|
|
- *
|
|
|
- * <p>职责:授权 URL 生成、code 换 token、token 刷新、撤销授权。令牌仅后端持有、
|
|
|
- * 加密存库,不落小程序。</p>
|
|
|
- *
|
|
|
- * <p>mock 模式下:授权 URL 返回模拟地址、任意 code 均可换取模拟令牌;real 模式下
|
|
|
- * 走华为 Health Service Kit 云侧 REST 接口(凭证批复后替换,当前处标注 TODO)。</p>
|
|
|
- */
|
|
|
@Service
|
|
|
public class HuaweiAuthService {
|
|
|
|
|
|
@@ -31,24 +32,27 @@ public class HuaweiAuthService {
|
|
|
@Resource
|
|
|
private HuaweiHealthTokenCrypto tokenCrypto;
|
|
|
|
|
|
+ @Resource
|
|
|
+ private RestTemplate restTemplate;
|
|
|
+
|
|
|
@Value("${huawei.health.client-id:mock-client-id}")
|
|
|
private String clientId;
|
|
|
|
|
|
@Value("${huawei.health.client-secret:mock-client-secret}")
|
|
|
private String clientSecret;
|
|
|
|
|
|
- @Value("${huawei.health.authorize-url:}")
|
|
|
+ @Value("${huawei.health.redirect-uri:https://cfc.etotem.com.cn/api/huawei-health/callback}")
|
|
|
+ private String redirectUri;
|
|
|
+
|
|
|
+ @Value("${huawei.health.authorize-url:https://oauth-login.cloud.huawei.com/oauth2/v3/authorize}")
|
|
|
private String authorizeUrl;
|
|
|
|
|
|
- @Value("${huawei.health.token-url:}")
|
|
|
+ @Value("${huawei.health.token-url:https://oauth-login.cloud.huawei.com/oauth2/v3/token}")
|
|
|
private String tokenUrl;
|
|
|
|
|
|
- @Value("${huawei.health.revoke-url:}")
|
|
|
+ @Value("${huawei.health.revoke-url:https://health-api.cloud.huawei.com/oauth/v1/token/revoke}")
|
|
|
private String revokeUrl;
|
|
|
|
|
|
- @Value("${huawei.health.redirect-uri:}")
|
|
|
- private String redirectUri;
|
|
|
-
|
|
|
@Value("${huawei.health.scope:step,distance,heart_rate,sleep,body_weight}")
|
|
|
private String scope;
|
|
|
|
|
|
@@ -58,26 +62,17 @@ public class HuaweiAuthService {
|
|
|
// ==================== 授权 URL 生成 ====================
|
|
|
|
|
|
/**
|
|
|
- * 构造华为账号授权 URL(前端打开此 URL 引导用户授权)。
|
|
|
- *
|
|
|
- * @param memberId 家庭成员ID(作为 state 原样带回,防 CSRF)
|
|
|
- * @param scope 授权范围;为空时使用默认 scope
|
|
|
- * @return 授权 URL(mock 模式返回模拟地址)
|
|
|
+ * 获取华为账号授权 URL。
|
|
|
+ *
|
|
|
+ * @param memberId 家庭成员 ID(作为 state 原样带回,防 CSRF)
|
|
|
+ * @param scope 授权范围;为空时使用默认 scope
|
|
|
+ * @return 授权 URL(real 模式返回华为账号服务 OAuth2 端点)
|
|
|
*/
|
|
|
public String generateAuthUrl(Long memberId, String scope) {
|
|
|
+ String effectiveScope = (scope == null || scope.isEmpty()) ? this.scope : scope;
|
|
|
if (memberId == null) {
|
|
|
return "";
|
|
|
}
|
|
|
- String effectiveScope = (scope == null || scope.isEmpty()) ? this.scope : scope;
|
|
|
- if (mockEnabled) {
|
|
|
- // mock:返回可被前端“模拟点击”的地址;code 由 callback 端直接传入
|
|
|
- return "mock://huawei-health/authorize?response_type=code&client_id=" + encode(clientId)
|
|
|
- + "&redirect_uri=" + encode(redirectUri)
|
|
|
- + "&scope=" + encode(effectiveScope)
|
|
|
- + "&state=" + memberId;
|
|
|
- }
|
|
|
- // real:TODO 替换为华为账号服务 OAuth2 授权端点
|
|
|
- // 示例:https://oauth-login.cloud.huawei.com/oauth2/v3/authorize?response_type=code&...
|
|
|
return authorizeUrl
|
|
|
+ "?response_type=code&client_id=" + encode(clientId)
|
|
|
+ "&redirect_uri=" + encode(redirectUri)
|
|
|
@@ -89,7 +84,7 @@ public class HuaweiAuthService {
|
|
|
|
|
|
/**
|
|
|
* 用授权码换取访问/刷新令牌并加密持久化。
|
|
|
- *
|
|
|
+ *
|
|
|
* @param code 授权码
|
|
|
* @param memberId 家庭成员ID
|
|
|
* @return 存储后的 HuaweiHealthToken(accessToken/refreshToken 已加密,外部不应直接明文使用)
|
|
|
@@ -110,12 +105,35 @@ public class HuaweiAuthService {
|
|
|
grantedScope = scope;
|
|
|
log.info("[Mock] 华为 OAuth code 换 token memberId={} code={}", memberId, code);
|
|
|
} else {
|
|
|
- // real:TODO 向 {tokenUrl} POST client_id/client_secret/code/redirect_uri,
|
|
|
- // 解析 access_token/refresh_token/expires_in/scope;凭证未批前保持模拟分支。
|
|
|
- accessToken = null;
|
|
|
- refreshToken = null;
|
|
|
- expiresInSec = null;
|
|
|
- grantedScope = scope;
|
|
|
+ // real:OAuth2 授权码流程,向 {tokenUrl} POST 换取访问/刷新令牌(Health Service Kit 云侧)。
|
|
|
+ try {
|
|
|
+ HttpHeaders headers = new HttpHeaders();
|
|
|
+ headers.setContentType(MediaType.APPLICATION_JSON);
|
|
|
+ Map<String, Object> body = new HashMap<>();
|
|
|
+ body.put("grant_type", "authorization_code");
|
|
|
+ body.put("code", code);
|
|
|
+ body.put("client_id", clientId);
|
|
|
+ body.put("client_secret", clientSecret);
|
|
|
+ body.put("redirect_uri", redirectUri);
|
|
|
+ ResponseEntity<Map> resp = restTemplate.postForEntity(tokenUrl,
|
|
|
+ new HttpEntity<>(body, headers), Map.class);
|
|
|
+ if (!resp.getStatusCode().is2xxSuccessful() || resp.getBody() == null) {
|
|
|
+ throw new RuntimeException("华为 token 交换失败: " + resp.getStatusCode());
|
|
|
+ }
|
|
|
+ Map<String, Object> data = resp.getBody();
|
|
|
+ accessToken = data.get("access_token") != null ? String.valueOf(data.get("access_token")) : null;
|
|
|
+ refreshToken = data.get("refresh_token") != null ? String.valueOf(data.get("refresh_token")) : null;
|
|
|
+ // expires_in 单位秒,无则默认 2 小时
|
|
|
+ Object exp = data.get("expires_in");
|
|
|
+ expiresInSec = exp != null ? Integer.valueOf(String.valueOf(exp)) : 7200;
|
|
|
+ Object scp = data.get("scope");
|
|
|
+ grantedScope = (scp != null && !String.valueOf(scp).isEmpty()) ? String.valueOf(scp) : scope;
|
|
|
+ log.info("[Real] 华为 OAuth code 换 token 成功 memberId={}", memberId);
|
|
|
+ } catch (Exception e) {
|
|
|
+ // 保持 D-2 契约:失败不带异常明细,由 controller 统一返回「重新授权」提示
|
|
|
+ log.error("华为 code 换 token 失败 memberId={}: {}", memberId, e.getMessage());
|
|
|
+ return null;
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
if (accessToken == null || refreshToken == null) {
|
|
|
@@ -153,7 +171,7 @@ public class HuaweiAuthService {
|
|
|
|
|
|
/**
|
|
|
* 刷新访问令牌。
|
|
|
- *
|
|
|
+ *
|
|
|
* @param memberId 家庭成员ID
|
|
|
* @return 刷新后的令牌(加密持久化);无可用刷新令牌返回 null
|
|
|
*/
|
|
|
@@ -178,15 +196,32 @@ public class HuaweiAuthService {
|
|
|
expiresInSec = 7200;
|
|
|
log.info("[Mock] 华为 OAuth 刷新 token memberId={}", memberId);
|
|
|
} else {
|
|
|
- // real:TODO 向 {tokenUrl} POST grant_type=refresh_token&refresh_token=...
|
|
|
- newAccessToken = null;
|
|
|
- newRefreshToken = null;
|
|
|
- expiresInSec = null;
|
|
|
- }
|
|
|
-
|
|
|
- if (newAccessToken == null || newRefreshToken == null) {
|
|
|
- log.warn("华为令牌刷新未获取到有效令牌(mock={})", mockEnabled);
|
|
|
- return null;
|
|
|
+ // real:OAuth2 刷新流程,向 {tokenUrl} POST grant_type=refresh_token 换取新访问令牌。
|
|
|
+ try {
|
|
|
+ HttpHeaders headers = new HttpHeaders();
|
|
|
+ headers.setContentType(MediaType.APPLICATION_JSON);
|
|
|
+ Map<String, Object> body = new HashMap<>();
|
|
|
+ body.put("grant_type", "refresh_token");
|
|
|
+ body.put("refresh_token", refreshTokenPlain);
|
|
|
+ body.put("client_id", clientId);
|
|
|
+ body.put("client_secret", clientSecret);
|
|
|
+ ResponseEntity<Map> resp = restTemplate.postForEntity(tokenUrl,
|
|
|
+ new HttpEntity<>(body, headers), Map.class);
|
|
|
+ if (!resp.getStatusCode().is2xxSuccessful() || resp.getBody() == null) {
|
|
|
+ throw new RuntimeException("华为 token 刷新失败: " + resp.getStatusCode());
|
|
|
+ }
|
|
|
+ Map<String, Object> data = resp.getBody();
|
|
|
+ newAccessToken = data.get("access_token") != null ? String.valueOf(data.get("access_token")) : null;
|
|
|
+ newRefreshToken = data.get("refresh_token") != null
|
|
|
+ ? String.valueOf(data.get("refresh_token")) : refreshTokenPlain;
|
|
|
+ // expires_in 单位秒,无则默认 2 小时
|
|
|
+ Object exp = data.get("expires_in");
|
|
|
+ expiresInSec = exp != null ? Integer.valueOf(String.valueOf(exp)) : 7200;
|
|
|
+ log.info("[Real] 华为 OAuth 刷新 token 成功 memberId={}", memberId);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("华为 token 刷新失败 memberId={}: {}", memberId, e.getMessage());
|
|
|
+ return null;
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
Date now = new Date();
|
|
|
@@ -204,8 +239,8 @@ public class HuaweiAuthService {
|
|
|
// ==================== 撤销授权 ====================
|
|
|
|
|
|
/**
|
|
|
- * 撤销授权(合规必需):标记本地令牌 REVOKED,并(real 模式)调用华为撤销端点。
|
|
|
- *
|
|
|
+ * 撤销华为运动健康授权(合规必需):标记本地令牌 REVOKED,并(real 模式)调用云端撤销端点。
|
|
|
+ *
|
|
|
* @param memberId 家庭成员ID
|
|
|
* @return 是否成功
|
|
|
*/
|
|
|
@@ -213,6 +248,27 @@ public class HuaweiAuthService {
|
|
|
if (memberId == null) {
|
|
|
return false;
|
|
|
}
|
|
|
+ // 先取有效令牌(供 real 云端撤销;取不到也继续本地标记,保证幂等)
|
|
|
+ HuaweiHealthToken active = tokenMapper.selectOne(
|
|
|
+ new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<HuaweiHealthToken>()
|
|
|
+ .eq(HuaweiHealthToken::getMemberId, memberId)
|
|
|
+ .eq(HuaweiHealthToken::getStatus, "ACTIVE"));
|
|
|
+ if (!mockEnabled && active != null) {
|
|
|
+ // real:调用 {revokeUrl} 撤销云端授权;失败不阻塞本地标记(合规必需)
|
|
|
+ try {
|
|
|
+ HttpHeaders headers = new HttpHeaders();
|
|
|
+ headers.setContentType(MediaType.APPLICATION_JSON);
|
|
|
+ Map<String, Object> body = new HashMap<>();
|
|
|
+ body.put("client_id", clientId);
|
|
|
+ body.put("client_secret", clientSecret);
|
|
|
+ body.put("access_token", tokenCrypto.decrypt(active.getAccessToken()));
|
|
|
+ restTemplate.postForEntity(revokeUrl, new HttpEntity<>(body, headers), Map.class);
|
|
|
+ log.info("[Real] 华为 OAuth 云端撤销成功 memberId={}", memberId);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("[Real] 华为 OAuth 云端撤销失败(非阻断,本地仍标记 REVOKED)memberId={}: {}",
|
|
|
+ memberId, e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
int updated = tokenMapper.update(null,
|
|
|
new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<HuaweiHealthToken>()
|
|
|
.eq(HuaweiHealthToken::getMemberId, memberId)
|
|
|
@@ -221,13 +277,11 @@ public class HuaweiAuthService {
|
|
|
.set(HuaweiHealthToken::getUpdatedAt, new Date()));
|
|
|
if (mockEnabled) {
|
|
|
log.info("[Mock] 华为 OAuth 撤销授权 memberId={} updated={}", memberId, updated);
|
|
|
- } else {
|
|
|
- // real:TODO 调用 {revokeUrl} 撤销云端授权;失败不阻塞本地标记
|
|
|
}
|
|
|
return updated > 0;
|
|
|
}
|
|
|
|
|
|
- /** 判断指定成员当前是否持有有效华为授权(供状态接口使用)。 */
|
|
|
+ /** 判断指定成员当前是否持有有效华为授权。 */
|
|
|
public boolean isBound(Long memberId) {
|
|
|
if (memberId == null) {
|
|
|
return false;
|
|
|
@@ -236,30 +290,17 @@ public class HuaweiAuthService {
|
|
|
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<HuaweiHealthToken>()
|
|
|
.eq(HuaweiHealthToken::getMemberId, memberId)
|
|
|
.eq(HuaweiHealthToken::getStatus, "ACTIVE"));
|
|
|
- return cnt != null && cnt > 0;
|
|
|
+ return cnt != null && cnt.intValue() > 0;
|
|
|
}
|
|
|
|
|
|
private String encode(String s) {
|
|
|
if (s == null) {
|
|
|
return "";
|
|
|
}
|
|
|
- try { return java.net.URLEncoder.encode(s, java.nio.charset.StandardCharsets.UTF_8.name()); } catch (java.io.UnsupportedEncodingException e) { throw new IllegalStateException(e); }
|
|
|
- }
|
|
|
-
|
|
|
- // ==================== 调试/状态用(避免明文泄露) ====================
|
|
|
-
|
|
|
- public JSONObject statusJson(Long memberId) {
|
|
|
- JSONObject obj = new JSONObject();
|
|
|
- obj.put("memberId", memberId);
|
|
|
- obj.put("bound", isBound(memberId));
|
|
|
- obj.put("mock", mockEnabled);
|
|
|
- if (mockEnabled) {
|
|
|
- obj.put("channel", "mock");
|
|
|
- } else {
|
|
|
- obj.put("channel", "real");
|
|
|
- obj.put("configured", clientId != null && !clientId.isEmpty()
|
|
|
- && clientSecret != null && !clientSecret.isEmpty());
|
|
|
+ try {
|
|
|
+ return java.net.URLEncoder.encode(s, java.nio.charset.StandardCharsets.UTF_8.name());
|
|
|
+ } catch (java.io.UnsupportedEncodingException e) {
|
|
|
+ throw new IllegalStateException(e);
|
|
|
}
|
|
|
- return obj;
|
|
|
}
|
|
|
}
|