|
|
@@ -0,0 +1,239 @@
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.etotem.cfc.entity.XiaomiHealthToken;
|
|
|
+import org.slf4j.Logger;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
+import org.springframework.beans.factory.annotation.Value;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+import org.springframework.util.StringUtils;
|
|
|
+import org.springframework.web.client.RestTemplate;
|
|
|
+import org.springframework.http.HttpEntity;
|
|
|
+import org.springframework.http.HttpHeaders;
|
|
|
+import org.springframework.http.ResponseEntity;
|
|
|
+import org.springframework.http.MediaType;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.net.URLEncoder;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 小米云服务 OAuth 模块(X2)。
|
|
|
+ *
|
|
|
+ * <p>职责:生成授权 URL / code 换 token / 刷新 / 撤销授权。token 仅后端持有,经加密存
|
|
|
+ * {@code xiaomi_health_tokens} 表。凭证未批复时走 mock 通道({@code mockEnabled=true})跑通全链路。</p>
|
|
|
+ *
|
|
|
+ * @author XiaomiHealthService(X2 OAuth 模块)
|
|
|
+ */
|
|
|
+@Service
|
|
|
+public class XiaomiAuthService {
|
|
|
+
|
|
|
+ private static final Logger log = LoggerFactory.getLogger(XiaomiAuthService.class);
|
|
|
+
|
|
|
+ @Value("${xiaomi.health.mock-enabled:true}")
|
|
|
+ private boolean mockEnabled;
|
|
|
+
|
|
|
+ @Value("${xiaomi.health.client-id:}")
|
|
|
+ private String clientId;
|
|
|
+
|
|
|
+ @Value("${xiaomi.health.client-secret:}")
|
|
|
+ private String clientSecret;
|
|
|
+
|
|
|
+ @Value("${xiaomi.health.redirect-uri:}")
|
|
|
+ private String redirectUri;
|
|
|
+
|
|
|
+ @Value("${xiaomi.health.authorize-url:}")
|
|
|
+ private String authorizeUrl;
|
|
|
+
|
|
|
+ @Value("${xiaomi.health.token-url:}")
|
|
|
+ private String tokenUrl;
|
|
|
+
|
|
|
+ @Value("${xiaomi.health.revoke-url:}")
|
|
|
+ private String revokeUrl;
|
|
|
+
|
|
|
+ @Value("${xiaomi.health.scope:}")
|
|
|
+ private String scope;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private XiaomiHealthTokenService xiaomiHealthTokenService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private RestTemplate restTemplate;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 生成小米云服务 OAuth 授权 URL(X2)。
|
|
|
+ *
|
|
|
+ * <p>真实凭证下发后,前端跳转该 URL 授权 → 携带 code 到 {@code /callback} 换 token。</p>
|
|
|
+ *
|
|
|
+ * @param memberId 家庭成员 ID(用于生成一次性 state 参数防 CSRF)
|
|
|
+ * @return 授权 URL(mock 通道下返回带 mock 标记的占位 URL)
|
|
|
+ */
|
|
|
+ public Map<String, Object> buildAuthUrl(Long memberId) {
|
|
|
+ Map<String, Object> result = new HashMap<>();
|
|
|
+ String state = "member-" + memberId + "-" + System.currentTimeMillis();
|
|
|
+ if (mockEnabled) {
|
|
|
+ result.put("mode", "mock");
|
|
|
+ result.put("authUrl", "https://xiaomi-mock.local/authorize?memberId=" + memberId + "&state=" + state);
|
|
|
+ result.put("redirectUri", redirectUri);
|
|
|
+ result.put("message", "小米云服务凭证未批复,使用 mock 授权通道");
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+ // 真实授权 URL(OAuth2 authorization code flow)
|
|
|
+ try {
|
|
|
+ String authUrl = authorizeUrl
|
|
|
+ + "?response_type=code"
|
|
|
+ + "&client_id=" + URLEncoder.encode(clientId, StandardCharsets.UTF_8.name())
|
|
|
+ + "&redirect_uri=" + URLEncoder.encode(redirectUri, StandardCharsets.UTF_8.name())
|
|
|
+ + "&scope=" + URLEncoder.encode(scope, StandardCharsets.UTF_8.name())
|
|
|
+ + "&state=" + state;
|
|
|
+ result.put("mode", "real");
|
|
|
+ result.put("authUrl", authUrl);
|
|
|
+ result.put("state", state);
|
|
|
+ result.put("redirectUri", redirectUri);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("构建小米授权 URL 失败: {}", e.getMessage());
|
|
|
+ result.put("mode", "error");
|
|
|
+ result.put("message", "构建授权 URL 失败: " + e.getMessage());
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 授权回调:code 换 token(X2),加密落库。
|
|
|
+ *
|
|
|
+ * @param code 小米 OAuth 授权码
|
|
|
+ * @param memberId 家庭成员 ID
|
|
|
+ * @return 存储后的 token 记录(明文返回,供本方法内后续处理使用;不落日志)
|
|
|
+ */
|
|
|
+ public XiaomiHealthToken exchangeCode(String code, Long memberId) {
|
|
|
+ if (!StringUtils.hasText(code)) {
|
|
|
+ throw new IllegalArgumentException("缺少授权码 code");
|
|
|
+ }
|
|
|
+ if (mockEnabled) {
|
|
|
+ log.info("mock 通道:直接生成占位 token: memberId={}", memberId);
|
|
|
+ String mockAccess = "mock-access-" + System.currentTimeMillis();
|
|
|
+ String mockRefresh = "mock-refresh-" + System.currentTimeMillis();
|
|
|
+ Date expires = new Date(System.currentTimeMillis() + 30L * 24 * 3600 * 1000); // mock 有效期 30 天
|
|
|
+ return xiaomiHealthTokenService.saveToken(memberId, mockAccess, mockRefresh, expires, scope);
|
|
|
+ }
|
|
|
+ // 真实 code → token(POST 到 token-url)
|
|
|
+ try {
|
|
|
+ HttpHeaders headers = new HttpHeaders();
|
|
|
+ headers.setContentType(MediaType.APPLICATION_JSON);
|
|
|
+ Map<String, String> 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();
|
|
|
+ String accessToken = String.valueOf(data.get("access_token"));
|
|
|
+ String refreshToken = data.get("refresh_token") != null ? String.valueOf(data.get("refresh_token")) : null;
|
|
|
+ // expires_in 单位秒,无则默认 2 小时
|
|
|
+ long expiresInSec = data.get("expires_in") != null ? Long.parseLong(String.valueOf(data.get("expires_in"))) : 7200L;
|
|
|
+ Date expires = new Date(System.currentTimeMillis() + expiresInSec * 1000);
|
|
|
+ return xiaomiHealthTokenService.saveToken(memberId, accessToken, refreshToken, expires, scope);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("小米 code 换 token 失败: memberId={}, error={}", memberId, e.getMessage());
|
|
|
+ throw new RuntimeException("小米 code 换 token 失败: " + e.getMessage(), e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 刷新 token(X2):用 refresh_token 换新 access_token(mock 直接续期)。
|
|
|
+ */
|
|
|
+ public XiaomiHealthToken refreshToken(Long memberId) {
|
|
|
+ XiaomiHealthToken stored = xiaomiHealthTokenService.getLatestPlain(memberId);
|
|
|
+ if (stored == null || !StringUtils.hasText(stored.getRefreshToken())) {
|
|
|
+ throw new IllegalStateException("无可用 refresh_token,请重新授权");
|
|
|
+ }
|
|
|
+ if (mockEnabled) {
|
|
|
+ log.info("mock 通道:直接续期 token: memberId={}", memberId);
|
|
|
+ String refreshedAccess = "mock-access-renewed-" + System.currentTimeMillis();
|
|
|
+ Date expires = new Date(System.currentTimeMillis() + 30L * 24 * 3600 * 1000);
|
|
|
+ return xiaomiHealthTokenService.saveToken(memberId, refreshedAccess,
|
|
|
+ stored.getRefreshToken(), expires, stored.getScope());
|
|
|
+ }
|
|
|
+ // 真实刷新(grant_type=refresh_token)
|
|
|
+ try {
|
|
|
+ HttpHeaders headers = new HttpHeaders();
|
|
|
+ headers.setContentType(MediaType.APPLICATION_JSON);
|
|
|
+ Map<String, String> body = new HashMap<>();
|
|
|
+ body.put("grant_type", "refresh_token");
|
|
|
+ body.put("refresh_token", stored.getRefreshToken());
|
|
|
+ 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();
|
|
|
+ String newAccess = String.valueOf(data.get("access_token"));
|
|
|
+ String newRefresh = data.get("refresh_token") != null ? String.valueOf(data.get("refresh_token")) : stored.getRefreshToken();
|
|
|
+ long expiresInSec = data.get("expires_in") != null ? Long.parseLong(String.valueOf(data.get("expires_in"))) : 7200L;
|
|
|
+ Date expires = new Date(System.currentTimeMillis() + expiresInSec * 1000);
|
|
|
+ return xiaomiHealthTokenService.saveToken(memberId, newAccess, newRefresh, expires, stored.getScope());
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("小米 token 刷新失败: memberId={}, error={}", memberId, e.getMessage());
|
|
|
+ throw new RuntimeException("小米 token 刷新失败: " + e.getMessage(), e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 撤销授权(X2):清除本地 token 记录 + 调用远端 revoke(若未批复则仅清本地)。
|
|
|
+ */
|
|
|
+ public boolean revoke(Long memberId) {
|
|
|
+ XiaomiHealthToken stored = xiaomiHealthTokenService.getLatestPlain(memberId);
|
|
|
+ if (stored != null && !mockEnabled) {
|
|
|
+ // TODO 真实联调:调用 revoke-url(小米云服务授权撤销接口),携带 client 凭证
|
|
|
+ try {
|
|
|
+ HttpHeaders headers = new HttpHeaders();
|
|
|
+ headers.setContentType(MediaType.APPLICATION_JSON);
|
|
|
+ Map<String, String> body = new HashMap<>();
|
|
|
+ body.put("client_id", clientId);
|
|
|
+ body.put("client_secret", clientSecret);
|
|
|
+ restTemplate.postForEntity(revokeUrl, new HttpEntity<>(body, headers), Map.class);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("小米远端撤销失败(非阻断,本地仍清除): memberId={}, error={}", memberId, e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ xiaomiHealthTokenService.revoke(memberId);
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 绑定/通道状态 JSON(X4 status 接口数据源;避免明文泄露)。
|
|
|
+ *
|
|
|
+ * @param memberId 家庭成员ID
|
|
|
+ * @return 含 bound/mock/channel/configured 的状态对象
|
|
|
+ */
|
|
|
+ public com.alibaba.fastjson.JSONObject statusJson(Long memberId) {
|
|
|
+ com.alibaba.fastjson.JSONObject obj = new com.alibaba.fastjson.JSONObject();
|
|
|
+ obj.put("memberId", memberId);
|
|
|
+ obj.put("bound", xiaomiHealthTokenService.isAuthorized(memberId));
|
|
|
+ obj.put("mock", mockEnabled);
|
|
|
+ if (mockEnabled) {
|
|
|
+ obj.put("channel", "mock");
|
|
|
+ } else {
|
|
|
+ obj.put("channel", "real");
|
|
|
+ obj.put("configured", StringUtils.hasText(clientId) && StringUtils.hasText(clientSecret));
|
|
|
+ }
|
|
|
+ // 有效授权过期时间(供前端展示;不涉及 token 明文)
|
|
|
+ try {
|
|
|
+ com.etotem.cfc.entity.XiaomiHealthToken latest = xiaomiHealthTokenService.getLatestPlain(memberId);
|
|
|
+ obj.put("expiresAt", latest == null ? null : latest.getExpiresAt());
|
|
|
+ obj.put("scope", latest == null ? null : latest.getScope());
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("小米状态查询 token 记录失败(忽略): memberId={}, error={}", memberId, e.getMessage());
|
|
|
+ }
|
|
|
+ return obj;
|
|
|
+ }
|
|
|
+}
|