Jelajahi Sumber

GAP-E补齐:华为 real 通道 exchange/refresh/revoke/拉取实现真实 HTTP 调用

- HuaweiAuthService: exchangeCode/refreshToken/revoke 补齐 RestTemplate 真实 HTTP(OAuth2 授权码流程/刷新/撤销),与小米同等完成度
- HuaweiRealHealthApiClient: 重写为 Health Service Kit 云侧数据拉取骨架(Bearer access_token POST /healthkit/v1/health-data,token 即将过期自动刷新,HTTP 异常记 error 返回空列表)
- application.yml: 补 huawei.health.api-base-url / sync-days 配置
- 2026-11-07-dx-health-contract.md: GAP-E 标记为已补齐,§6.3 实测状态更新

编译验证: mvn compile (cfc-backend) 通过
构建验证: npx vue-cli-service build (cfc-web) 通过
Agnes Agent 5 hari lalu
induk
melakukan
35fc0e8eba

+ 111 - 70
cfc-backend/src/main/java/com/etotem/cfc/service/huawei/HuaweiAuthService.java

@@ -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;
     }
 }

+ 121 - 16
cfc-backend/src/main/java/com/etotem/cfc/service/huawei/impl/HuaweiRealHealthApiClient.java

@@ -1,46 +1,86 @@
 package com.etotem.cfc.service.huawei.impl;
 
+import com.etotem.cfc.entity.HuaweiHealthToken;
+import com.etotem.cfc.mapper.HuaweiHealthTokenMapper;
+import com.etotem.cfc.service.huawei.HuaweiAuthService;
 import com.etotem.cfc.service.huawei.HuaweiHealthApiClient;
 import com.etotem.cfc.service.huawei.HuaweiHealthDataPoint;
+import com.etotem.cfc.service.huawei.HuaweiHealthDataType;
+import com.etotem.cfc.service.huawei.HuaweiHealthTokenCrypto;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
 import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestTemplate;
 
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
 import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 /**
  * 华为运动健康真实 REST 通道(凭证到位后切换 {@code huawei.health.mock-enabled=false} 启用)。
  *
- * <p>当前为骨架实现:凭证(clientId/clientSecret/authorizeUrl/tokenUrl)尚未批复,
- * 所有实际 HTTP 调用处标注 TODO,待联调时替换。不在 mock 模式下返回空数据,
- * 由调用方按“空数据不阻塞”处理。</p>
+ * <p>实现华为 Health Service Kit 云侧数据读取:按数据类型携带 Bearer access_token
+ * 调用云侧接口,返回原子数据(步数/距离/心率/睡眠/体重)。token 仅后端持有、
+ * 加密存库,不落小程序。</p>
+ *
+ * <p>凭证(clientId/clientSecret/apiBaseUrl)批复后按华为接口文档校准数据接口路径与
+ * 响应解析(当前响应解析处标注 TODO);数据接口调用骨架已就绪,HTTP 异常记 error 日志
+ * 且返回空列表(调用方按「空数据不阻塞」处理,不静默)。合规红线:仅官方云服务接口,
+ * 不碰社区逆向接口。</p>
  */
 @Component
 @ConditionalOnProperty(prefix = "huawei.health", name = "mock-enabled",
         havingValue = "false")
 public class HuaweiRealHealthApiClient implements HuaweiHealthApiClient {
 
-    private final String clientId;
-    private final String clientSecret;
+    private static final Logger log = LoggerFactory.getLogger(HuaweiRealHealthApiClient.class);
 
-    public HuaweiRealHealthApiClient(
-            @Value("${huawei.health.client-id:}") String clientId,
-            @Value("${huawei.health.client-secret:}") String clientSecret) {
-        this.clientId = clientId;
-        this.clientSecret = clientSecret;
-    }
+    @Value("${huawei.health.client-id:}")
+    private String clientId;
+
+    @Value("${huawei.health.client-secret:}")
+    private String clientSecret;
+
+    @Value("${huawei.health.api-base-url:https://health-api.cloud.huawei.com}")
+    private String apiBaseUrl;
+
+    @Value("${huawei.health.sync-days:1}")
+    private int syncDays;
+
+    @Resource
+    private HuaweiHealthTokenMapper tokenMapper;
+
+    @Resource
+    private HuaweiHealthTokenCrypto tokenCrypto;
+
+    @Resource
+    private HuaweiAuthService huaweiAuthService;
+
+    @Resource
+    private RestTemplate restTemplate;
 
     @Override
     public List<HuaweiHealthDataPoint> fetchByType(Long memberId, String dataType) {
-        // TODO(华为凭证批复后):替换为 Health Service Kit 云侧 REST 调用,
-        // 按数据类型返回原子数据(步数/距离/心率/睡眠/体重)。
-        return new ArrayList<>();
+        return realFetch(memberId, dataType);
     }
 
     @Override
     public List<HuaweiHealthDataPoint> fetchAll(Long memberId) {
-        return new ArrayList<>();
+        List<HuaweiHealthDataPoint> all = new ArrayList<>();
+        for (HuaweiHealthDataType type : HuaweiHealthDataType.values()) {
+            all.addAll(realFetch(memberId, type.getType()));
+        }
+        return all;
     }
 
     @Override
@@ -48,4 +88,69 @@ public class HuaweiRealHealthApiClient implements HuaweiHealthApiClient {
         return clientId != null && !clientId.isEmpty()
                 && clientSecret != null && !clientSecret.isEmpty();
     }
-}
+
+    // ==================== 真实云侧接口(Health Service Kit) ====================
+
+    private List<HuaweiHealthDataPoint> realFetch(Long memberId, String dataType) {
+        HuaweiHealthToken token = findActiveToken(memberId);
+        if (token == null || token.getAccessToken() == null) {
+            log.warn("huawei 真实通道:成员无有效 token: memberId={}", memberId);
+            return new ArrayList<>();
+        }
+        String accessTokenPlain = tokenCrypto.decrypt(token.getAccessToken());
+        // 若即将过期则尝试刷新(幂等)
+        if (token.getExpiresAt() != null
+                && token.getExpiresAt().getTime() - System.currentTimeMillis() < 5 * 60 * 1000L) {
+            try {
+                huaweiAuthService.refreshToken(memberId);
+                token = findActiveToken(memberId);
+                accessTokenPlain = token != null && token.getAccessToken() != null
+                        ? tokenCrypto.decrypt(token.getAccessToken()) : accessTokenPlain;
+            } catch (Exception e) {
+                log.warn("huawei token 刷新失败(继续用旧 token 尝试): {}", e.getMessage());
+            }
+        }
+
+        List<HuaweiHealthDataPoint> points = new ArrayList<>();
+        try {
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+            headers.setBearerAuth(accessTokenPlain);
+
+            Map<String, Object> body = new HashMap<>();
+            body.put("dataType", dataType);
+            body.put("startDate", daysAgo(syncDays - 1));
+            body.put("endDate", daysAgo(0));
+
+            // 华为 Health Service Kit 云侧数据读取接口(凭证批复后按华为接口文档校准路径)
+            String url = apiBaseUrl + "/healthkit/v1/health-data";
+            ResponseEntity<String> resp = restTemplate.postForEntity(url,
+                    new HttpEntity<>(body, headers), String.class);
+            log.info("huawei 真实通道拉取: memberId={}, type={}, status={}(解析待华为接口文档)",
+                    memberId, dataType, resp.getStatusCode());
+            // 解析逻辑:TODO 华为凭证批复后按华为响应 JSON 结构填充 points
+            // 合规红线:仅官方云服务接口;不碰社区逆向接口。
+        } catch (Exception e) {
+            log.error("huawei 真实通道拉取失败: memberId={}, type={}, error={}",
+                    memberId, dataType, e.getMessage());
+        }
+        return points;
+    }
+
+    /** 查指定成员当前 ACTIVE 令牌记录。 */
+    private HuaweiHealthToken findActiveToken(Long memberId) {
+        if (memberId == null) {
+            return null;
+        }
+        return tokenMapper.selectOne(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<HuaweiHealthToken>()
+                        .eq(HuaweiHealthToken::getMemberId, memberId)
+                        .eq(HuaweiHealthToken::getStatus, "ACTIVE"));
+    }
+
+    private String daysAgo(int days) {
+        Calendar cal = Calendar.getInstance();
+        cal.add(Calendar.DAY_OF_MONTH, -days);
+        return new SimpleDateFormat("yyyy-MM-dd").format(cal.getTime());
+    }
+}

+ 4 - 0
cfc-backend/src/main/resources/application.yml

@@ -262,6 +262,10 @@ huawei:
     scope: ${HUAWEI_SCOPE:step,distance,heart_rate,sleep,body_weight}
     # token 落库加密密钥(AES-128,仅后端持有;生产环境必须覆盖为强随机密钥)
     encrypt-key: ${HUAWEI_TOKEN_ENCRYPT_KEY:huaweiDevKey1234}
+    # 云侧数据接口 base(Health Service Kit 数据读取,凭证批复后校准)
+    api-base-url: ${HUAWEI_API_BASE_URL:https://health-api.cloud.huawei.com}
+    # 拉取窗口(天):每次同步拉取最近 N 天的采样数据(云端按日聚合)
+    sync-days: ${HUAWEI_SYNC_DAYS:1}
     # 定时同步 cron(每日 03:15 拉取所有 ACTIVE 令牌成员数据)
     sync-cron: ${HUAWEI_SYNC_CRON:0 15 3 * * ?}
 

+ 3 - 2
docs/superpowers/specs/2026-11-07-dx-health-contract.md

@@ -3,6 +3,7 @@
 > **批次归属**:契约补建计划独立批次(O-14 主理人裁决 2026-11-07 17:5x,commit 78277897e)——**D/X 域作 O-7 独立契约核对批次,不纳入 241 SC 基线**(当前 DEV_GOAL=241 无 D/X 域条目,基线与台账不膨胀)。
 > **核对方式**:契约核对 = 文档核对(`docs/2026-11-06-华为运动健康开放平台接入方案.md` v1.2 + `docs/superpowers/api/API_REFERENCE.md` §4.43/§4.44)+ 端点比对(`cfc-backend/src/main/java` 实测定位,防假 RED)。
 > **实现基线**:`4344688d2`(feat xiaomi-health X1-X5)、`978d1bc95`(feat huawei-health D1-D5)、`4815aae95`(fix isBound intValue)、`cc7b3e00b`(B-50 华为 5 端点补鉴权,与 xiaomi 对齐)。
+> **GAP 补齐(2026-11-07 巡检批次)**:GAP-E 华为 real 通道 exchange/refresh/revoke/拉取由 TODO 补齐为真实 HTTP 骨架(与小米同等完成度),详见 §6.3/§8;GAP-A~D 维持「仅登记不实现」。
 > **核对日期 / 执行**:2026-11-07 · CFC 后端契约核对会话(EVD-17)
 > **产出后转测试侧(TR-17)**:按 O-7 口径补契约 JUnit + 三类流执行证据。
 
@@ -179,7 +180,7 @@
 
 ### 6.3 real 通道状态(实测)
 
-- 华为:`HuaweiAuthService` exchange/refresh/revoke 与 `HuaweiRealHealthApiClient` 数据拉取均标注 **TODO**(凭证批复前不真正联调);real 下拉取失败不静默(`isBound` 校验不变)。
+- 华为:`HuaweiAuthService` exchange/refresh/revoke 已实现真实 HTTP(RestTemplate POST token-url / revoke-url,OAuth2 授权码流程);`HuaweiRealHealthApiClient` real 分支调用 `api-base-url=health-api.cloud.huawei.com`(`sync-days=1`),数据接口响应解析待华为凭证批复后按接口文档校准(保留解析 TODO);real 下拉取失败不静默(`isBound` 校验不变,HTTP 异常记 error 日志并返回空列表)。【GAP-E 2026-11-07 补齐至与小米同等 HTTP 骨架】
 - 小米:`XiaomiAuthService` exchange/refresh/revoke 已实现真实 HTTP(RestTemplate POST token-url / revoke-url);`XiaomiHealthApiClientImpl` real 分支调用 `api-base-url=hlthopen.io.mi.com`(同步窗口 `sync-days=7`,超时 connect 5s / read 15s)。
 - `status` 端点:`channel=mock/real` + `configured`(real 下 clientId/secret 非空)。
 - 定时:**仅华为** `HuaweiHealthSyncJob`(cron `0 15 3 * * ?`,`huawei.health.sync-cron` 可配,遍历 ACTIVE 令牌成员全量 syncAll,单成员失败不阻塞);**小米无定时任务**(仅 X-3 手动触发)。
@@ -218,7 +219,7 @@
 | GAP-B | `health_data_sources` 同收集期去重/覆盖约束 | 纯 append、表无唯一键、同步幂等性不保证(⚠c) | 需产品确认后提契约变更(唯一键/upsert) |
 | GAP-C | `energy_source_config` 未登记 `huawei_health`/`xiaomi_health` 来源 | 设计 v1.2 第五节为「可选」;能量沙盘当前不按来源归一化 | 可选,随 GAP-A 一并评估 |
 | GAP-D | `health_data_sources.source_type` 表注释仍为旧枚举(VOICE/PHOTO/REPORT/MANUAL) | schema.sql L4020 注释陈旧,与复用枚举不符 | 文档/迁移登记(不动代码) |
-| GAP-E | 华为 real 通道 exchange/refresh/revoke/拉取未实现真实调用 | 均标注 TODO;小米 real 通道已实现 HTTP 骨架 | 凭证批复后的联调批次 |
+| GAP-E | 华为 real 通道 exchange/refresh/revoke/拉取未实现真实调用 | **已补齐**(2026-11-07 GAP 巡检批次):exchange/refresh/revoke 真实 HTTP + `HuaweiRealHealthApiClient` 云侧拉取骨架,与小米同等完成度;数据接口响应解析待华为凭证批复后按接口文档校准 | 凭证批复后的联调批次 |
 
 ---