Quellcode durchsuchen

feat(huawei-health): D1-D5 华为运动健康开放平台接入(令牌表/授权/同步/5接口/mock通道)

- D1: huawei_health_tokens 表(迁移452)+schema.sql同步; 复用 health_data_sources 不建重复表
- D2: HuaweiAuthService 授权URL/code换token/刷新/撤销; token 仅后端 AES/GCM 加密存库
- D3: HealthSyncService 定时拉取(步数/距离/心率/睡眠/体重), NormCompareEngine P5/P25/P75/P95 归一化
- D4: HuaweiHealthController 5个POST接口(auth-url/callback/sync/revoke/status), Result+JWT
- D5: mock 通道默认开启(huawei.health.mock-enabled), real 骨架留 TODO; 每日03:15定时任务
- API_REFERENCE.md 同步 Controller 总览与4.43接口清单
Agnes Agent vor 5 Tagen
Ursprung
Commit
978d1bc95
18 geänderte Dateien mit 1561 neuen und 0 gelöschten Zeilen
  1. 64 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 193 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/huawei/HuaweiHealthController.java
  3. 49 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HuaweiHealthToken.java
  4. 77 0
      cfc-backend/src/main/java/com/etotem/cfc/job/HuaweiHealthSyncJob.java
  5. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/HuaweiHealthTokenMapper.java
  6. 31 0
      cfc-backend/src/main/java/com/etotem/cfc/service/huawei/HealthSyncService.java
  7. 265 0
      cfc-backend/src/main/java/com/etotem/cfc/service/huawei/HuaweiAuthService.java
  8. 32 0
      cfc-backend/src/main/java/com/etotem/cfc/service/huawei/HuaweiHealthApiClient.java
  9. 101 0
      cfc-backend/src/main/java/com/etotem/cfc/service/huawei/HuaweiHealthDataPoint.java
  10. 52 0
      cfc-backend/src/main/java/com/etotem/cfc/service/huawei/HuaweiHealthDataType.java
  11. 73 0
      cfc-backend/src/main/java/com/etotem/cfc/service/huawei/HuaweiHealthTokenCrypto.java
  12. 88 0
      cfc-backend/src/main/java/com/etotem/cfc/service/huawei/SyncResult.java
  13. 248 0
      cfc-backend/src/main/java/com/etotem/cfc/service/huawei/impl/HealthSyncServiceImpl.java
  14. 138 0
      cfc-backend/src/main/java/com/etotem/cfc/service/huawei/impl/HuaweiMockHealthApiClient.java
  15. 51 0
      cfc-backend/src/main/java/com/etotem/cfc/service/huawei/impl/HuaweiRealHealthApiClient.java
  16. 21 0
      cfc-backend/src/main/resources/application.yml
  17. 27 0
      cfc-backend/src/main/resources/schema.sql
  18. 42 0
      docs/superpowers/api/API_REFERENCE.md

+ 64 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3454,6 +3454,12 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
         // 迁移451: 首页引导浮层已读状态按账号记录(新账号必弹、内容变化重弹)
         migrateUserOnboardingGuide();
+
+        // 迁移452: 华为运动健康开放平台接入(D1)—— 令牌表(token 仅后端持有、加密存库)
+        createHuaweiHealthTokensTable();
+
+        // 迁移453: 小米运动健康云服务接口接入(X1)—— 令牌表(token 仅后端持有、加密存库)
+        createXiaomiHealthTokensTable();
     }
 
     /**
@@ -3505,6 +3511,64 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         }
     }
 
+    /**
+     * 迁移452: 创建华为运动健康令牌表(D1 接入)。
+     *
+     * <p>token 仅后端持有(AES/GCM 加密存库),不落小程序。字段与方案 v1.2 第六节 D1
+     * 保持一致:member_id/access_token/refresh_token/expires_at/scope/created_at/updated_at;
+     * 附加 status 用于撤销授权合规(ACTIVE/REVOKED)。一个成员仅保留一套有效令牌
+     * (uk_member),重复授权时旧令牌置 REVOKED 后插入新记录。</p>
+     */
+    private void createHuaweiHealthTokensTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS huawei_health_tokens ("
+                    + "id BIGINT AUTO_INCREMENT PRIMARY KEY,"
+                    + "member_id BIGINT NOT NULL COMMENT '家庭成员ID(family_members.id,仅能登录用户/成人)',"
+                    + "access_token TEXT NOT NULL COMMENT '加密后的访问令牌(AES/GCM,仅后端持有)',"
+                    + "refresh_token TEXT NOT NULL COMMENT '加密后的刷新令牌(AES/GCM,仅后端持有)',"
+                    + "expires_at DATETIME NULL COMMENT '访问令牌过期时间',"
+                    + "scope VARCHAR(255) NULL COMMENT '授权范围(华为数据类型逗号分隔)',"
+                    + "status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态: ACTIVE=有效/REVOKED=已撤销',"
+                    + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',"
+                    + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',"
+                    + "UNIQUE KEY uk_member (member_id)"
+                    + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='华为运动健康OAuth令牌'");
+            log.info("迁移452: huawei_health_tokens 表已就绪");
+        } catch (Exception e) {
+            log.warn("迁移452: huawei_health_tokens 创建失败: {}", e.getMessage());
+        }
+    }
+
+    /**
+     * 迁移453: 创建小米运动健康令牌表(X1 接入)。
+     *
+     * <p>token 仅后端持有(AES 加密存库),不落小程序。字段与方案 v1.2 第十一节 X1
+     * 保持一致:member_id/access_token/refresh_token/expires_at/scope/created_at/updated_at。
+     * 一个成员仅保留一套有效令牌(uk_member),重复授权时覆盖更新(幂等)。
+     * refresh_token 可空(小米云服务可能不下发刷新令牌,mock 一定下发)。</p>
+     *
+     * <p>合规红线:仅官方云服务认证鉴权(hlthopen.io.mi.com),不碰社区逆向接口
+     * (userId/passToken 仿冒),不引入境外聚合中转(如 Terra)。</p>
+     */
+    private void createXiaomiHealthTokensTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS xiaomi_health_tokens ("
+                    + "id BIGINT AUTO_INCREMENT PRIMARY KEY,"
+                    + "member_id BIGINT NOT NULL COMMENT '家庭成员ID(family_members.id,仅能登录用户/成人)',"
+                    + "access_token TEXT NOT NULL COMMENT '加密后的访问令牌(AES/CBC,仅后端持有)',"
+                    + "refresh_token TEXT NULL COMMENT '加密后的刷新令牌(AES/CBC,可空:小米可能不下发)',"
+                    + "expires_at DATETIME NULL COMMENT '访问令牌过期时间',"
+                    + "scope VARCHAR(255) NULL COMMENT '授权范围(小米数据类型逗号分隔)',"
+                    + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',"
+                    + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',"
+                    + "UNIQUE KEY uk_member (member_id)"
+                    + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='小米运动健康OAuth令牌'");
+            log.info("迁移453: xiaomi_health_tokens 表已就绪");
+        } catch (Exception e) {
+            log.warn("迁移453: xiaomi_health_tokens 创建失败: {}", e.getMessage());
+        }
+    }
+
     /**
      * 迁移430: 商品成本价改为整型(分)(P点口径调整)。
      *

+ 193 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/huawei/HuaweiHealthController.java

@@ -0,0 +1,193 @@
+package com.etotem.cfc.controller.huawei;
+
+import com.alibaba.fastjson.JSONObject;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.huawei.HealthSyncService;
+import com.etotem.cfc.service.huawei.HuaweiAuthService;
+import com.etotem.cfc.service.huawei.HuaweiHealthDataType;
+import com.etotem.cfc.service.huawei.SyncResult;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 华为运动健康接入控制器(D4)。
+ *
+ * <p>5 个接口,全部 {@code @PostMapping},响应 {@link Result};JWT 拦截 {@code /api/**}
+ *(8 个公开路径除外)。令牌仅后端持有,不落小程序。</p>
+ *
+ * <p>接口清单(方案 v1.2 第六节):
+ * <ul>
+ *   <li>{@code POST /api/huawei-health/auth-url}  获取授权 URL</li>
+ *   <li>{@code POST /api/huawei-health/callback}  授权回调,code 换 token</li>
+ *   <li>{@code POST /api/huawei-health/sync}      触发数据同步</li>
+ *   <li>{@code POST /api/huawei-health/revoke}    撤销授权(合规必需)</li>
+ *   <li>{@code POST /api/huawei-health/status}    绑定/同步状态查询</li>
+ * </ul></p>
+ */
+@Slf4j
+@Tag(name = "华为运动健康")
+@RestController
+@RequestMapping("/api/huawei-health")
+public class HuaweiHealthController {
+
+    @Resource
+    private HuaweiAuthService huaweiAuthService;
+
+    @Resource
+    private HealthSyncService healthSyncService;
+
+    // ==================== 授权 URL 获取 ====================
+
+    /**
+     * 获取华为账号授权 URL。
+     *
+     * <p>请求体示例:{ "memberId": 123, "scope": "step,distance,heart_rate,sleep,body_weight" }</p>
+     */
+    @Operation(summary = "获取华为运动健康授权 URL")
+    @PostMapping("/auth-url")
+    public Result<Map<String, Object>> authUrl(@RequestBody Map<String, Object> params,
+                                               @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long memberId = com.etotem.cfc.util.ParamUtils.getLong(params.get("memberId"), currentMemberId);
+        if (memberId == null) {
+            return Result.badRequest("memberId不能为空");
+        }
+        String scope = String.valueOf(params.getOrDefault("scope", ""));
+        String url = huaweiAuthService.generateAuthUrl(memberId, scope);
+        Map<String, Object> data = new java.util.LinkedHashMap<>();
+        data.put("authUrl", url);
+        data.put("memberId", memberId);
+        data.put("scope", scope);
+        data.put("generateAt", new Date().getTime());
+        return Result.success(data);
+    }
+
+    // ==================== 授权回调:code 换 token ====================
+
+    /**
+     * 授权回调:用华为返回的 code 换取访问/刷新令牌并加密持久化。
+     *
+     * <p>请求体示例:{ "code": "xxx", "memberId": 123 }</p>
+     */
+    @Operation(summary = "华为授权回调,code 换 token")
+    @PostMapping("/callback")
+    public Result<Map<String, Object>> callback(@RequestBody Map<String, Object> params,
+                                                @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long memberId = com.etotem.cfc.util.ParamUtils.getLong(params.get("memberId"), currentMemberId);
+        String code = String.valueOf(params.getOrDefault("code", ""));
+        if (memberId == null) {
+            return Result.badRequest("memberId不能为空");
+        }
+        if (code == null || code.isEmpty()) {
+            return Result.badRequest("code不能为空");
+        }
+
+        com.etotem.cfc.entity.HuaweiHealthToken token = huaweiAuthService.exchangeCode(code, memberId);
+        Map<String, Object> data = new java.util.LinkedHashMap<>();
+        data.put("memberId", memberId);
+        data.put("bound", token != null);
+        data.put("status", token == null ? "fail" : token.getStatus());
+        data.put("expiresAt", token == null ? null : token.getExpiresAt());
+        if (token == null) {
+            return Result.error("code 换 token 失败,请重新授权");
+        }
+        return Result.success(data);
+    }
+
+    // ==================== 数据同步 ====================
+
+    /**
+     * 触发华为健康数据同步。
+     *
+     * <p>请求体示例:{ "memberId": 123, "dataType": "step" };dataType 为空时同步全部类型。</p>
+     */
+    @Operation(summary = "触发华为健康数据同步")
+    @PostMapping("/sync")
+    public Result<Map<String, Object>> sync(@RequestBody Map<String, Object> params,
+                                            @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long memberId = com.etotem.cfc.util.ParamUtils.getLong(params.get("memberId"), currentMemberId);
+        String dataType = String.valueOf(params.getOrDefault("dataType", ""));
+        if (memberId == null) {
+            return Result.badRequest("memberId不能为空");
+        }
+
+        Map<String, Object> data = new java.util.LinkedHashMap<>();
+        data.put("memberId", memberId);
+        data.put("syncAt", new Date().getTime());
+
+        if (dataType != null && !dataType.isEmpty()) {
+            // 单类型同步
+            SyncResult result = healthSyncService.sync(memberId, dataType);
+            data.put("dataType", dataType);
+            data.put("result", result);
+            if (!result.isSuccess()) {
+                return Result.error(result.getError());
+            }
+        } else {
+            // 全量同步
+            List<SyncResult> results = healthSyncService.syncAll(memberId);
+            data.put("results", results);
+            boolean allOk = results.stream().allMatch(SyncResult::isSuccess);
+            if (!allOk) {
+                return Result.error("部分数据类型同步失败,详见 results");
+            }
+        }
+        return Result.success(data);
+    }
+
+    // ==================== 撤销授权 ====================
+
+    /**
+     * 撤销华为运动健康授权(合规必需):标记本地令牌 REVOKED。
+     *
+     * <p>请求体示例:{ "memberId": 123 }</p>
+     */
+    @Operation(summary = "撤销华为运动健康授权")
+    @PostMapping("/revoke")
+    public Result<Map<String, Object>> revoke(@RequestBody Map<String, Object> params,
+                                              @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long memberId = com.etotem.cfc.util.ParamUtils.getLong(params.get("memberId"), currentMemberId);
+        if (memberId == null) {
+            return Result.badRequest("memberId不能为空");
+        }
+        boolean ok = huaweiAuthService.revoke(memberId);
+        Map<String, Object> data = new java.util.LinkedHashMap<>();
+        data.put("memberId", memberId);
+        data.put("revoked", ok);
+        if (!ok) {
+            return Result.error("撤销失败,未找到有效授权");
+        }
+        return Result.success(data);
+    }
+
+    // ==================== 绑定/状态查询 ====================
+
+    /**
+     * 查询华为运动健康绑定与同步状态。
+     *
+     * <p>请求体示例:{ "memberId": 123 };memberId 为空时取当前登录成员。</p>
+     */
+    @Operation(summary = "查询华为运动健康绑定/同步状态")
+    @PostMapping("/status")
+    public Result<Map<String, Object>> status(@RequestBody Map<String, Object> params,
+                                              @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long memberId = com.etotem.cfc.util.ParamUtils.getLong(params.get("memberId"), currentMemberId);
+        if (memberId == null) {
+            return Result.badRequest("memberId不能为空");
+        }
+        JSONObject statusJson = huaweiAuthService.statusJson(memberId);
+        Map<String, Object> data = new java.util.LinkedHashMap<>();
+        data.put("memberId", memberId);
+        data.put("bound", statusJson.getBoolean("bound"));
+        data.put("channel", statusJson.getString("channel"));
+        data.put("mock", statusJson.getBoolean("mock"));
+        data.put("configured", statusJson.getBoolean("configured"));
+        return Result.success(data);
+    }
+}

+ 49 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HuaweiHealthToken.java

@@ -0,0 +1,49 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 华为运动健康 OAuth 令牌持久化记录。
+ *
+ * <p>token 仅后端持有(加密存库),不落小程序。仅面向「能登录用户(成人)」绑定,
+ *  member_id 指向 family_members.id;不能登录用户(儿童/代管)走手动导入通道,
+ *  不在此表产生令牌。</p>
+ *
+ * <p>字段与方案 v1.2 第六节 D1 保持一致:member_id/access_token/refresh_token/expires_at/scope/
+ * created_at/updated_at,附加 status 用于撤销授权合规。</p>
+ */
+@Data
+@TableName("huawei_health_tokens")
+public class HuaweiHealthToken implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 家庭成员ID(family_members.id,唯一,一个成员一套华为令牌) */
+    private Long memberId;
+
+    /** 加密后的访问令牌(AES/GCM,仅后端持有) */
+    private String accessToken;
+
+    /** 加密后的刷新令牌(AES/GCM,仅后端持有) */
+    private String refreshToken;
+
+    /** 访问令牌过期时间 */
+    private Date expiresAt;
+
+    /** 授权范围(华为数据类型逗号分隔,如 step,distance,heart_rate,sleep,body_weight) */
+    private String scope;
+
+    /** 状态:ACTIVE=有效,REVOKED=已撤销 */
+    private String status;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 77 - 0
cfc-backend/src/main/java/com/etotem/cfc/job/HuaweiHealthSyncJob.java

@@ -0,0 +1,77 @@
+package com.etotem.cfc.job;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.HuaweiHealthToken;
+import com.etotem.cfc.mapper.HuaweiHealthTokenMapper;
+import com.etotem.cfc.service.huawei.HealthSyncService;
+import com.etotem.cfc.service.huawei.SyncResult;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+/**
+ * 华为运动健康每日定时同步(D3 定时拉取)。
+ *
+ * <p>遍历所有持有 ACTIVE 令牌的成员,逐一拉取步数/距离/心率/睡眠/体重数据,
+ * 经 {@link HealthSyncService} 归一化后入库 {@code health_data_sources}。
+ * 单成员失败不阻塞其他成员(幂等、可重复执行)。</p>
+ */
+@Component
+public class HuaweiHealthSyncJob {
+
+    private static final Logger log = LoggerFactory.getLogger(HuaweiHealthSyncJob.class);
+
+    @Resource
+    private HuaweiHealthTokenMapper tokenMapper;
+
+    @Resource
+    private HealthSyncService healthSyncService;
+
+    @Value("${huawei.health.mock-enabled:true}")
+    private boolean mockEnabled;
+
+    /**
+     * 每日 03:15 拉取全部有效授权成员的华为健康数据。
+     */
+    @Scheduled(cron = "${huawei.health.sync-cron:0 15 3 * * ?}")
+    public void syncAllBoundMembers() {
+        List<HuaweiHealthToken> tokens = tokenMapper.selectList(
+                new LambdaQueryWrapper<HuaweiHealthToken>()
+                        .eq(HuaweiHealthToken::getStatus, "ACTIVE"));
+        if (tokens == null || tokens.isEmpty()) {
+            log.info("[{}] 华为健康定时同步:无有效授权成员,跳过", channel());
+            return;
+        }
+        int ok = 0;
+        int fail = 0;
+        for (HuaweiHealthToken token : tokens) {
+            Long memberId = token.getMemberId();
+            try {
+                List<SyncResult> results = healthSyncService.syncAll(memberId);
+                boolean allOk = results.stream().allMatch(SyncResult::isSuccess);
+                if (allOk) {
+                    ok++;
+                } else {
+                    fail++;
+                    log.warn("[{}] 华为健康定时同步部分失败 memberId={}: {}",
+                            channel(), memberId, results.stream().filter(r -> !r.isSuccess())
+                                    .map(r -> r.getDataType() + "=" + r.getError())
+                                    .reduce((a, b) -> a + "; " + b).orElse(""));
+                }
+            } catch (Exception e) {
+                fail++;
+                log.error("[{}] 华为健康定时同步异常 memberId={}: {}", channel(), memberId, e.getMessage(), e);
+            }
+        }
+        log.info("[{}] 华为健康定时同步完成:共 {} 个成员,成功 {} 失败 {}", channel(), tokens.size(), ok, fail);
+    }
+
+    private String channel() {
+        return mockEnabled ? "mock" : "real";
+    }
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/HuaweiHealthTokenMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.HuaweiHealthToken;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface HuaweiHealthTokenMapper extends BaseMapper<HuaweiHealthToken> {
+}

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/huawei/HealthSyncService.java

@@ -0,0 +1,31 @@
+package com.etotem.cfc.service.huawei;
+
+import java.util.List;
+
+/**
+ * 华为运动健康数据同步引擎(D3)。
+ *
+ * <p>按数据类型定时拉取(步数/距离/心率/睡眠/体重),复用 {@link NormCompareEngine}
+ * 百分位带(P5/P25/P75/P95 → 偏低/正常/偏高)进行归一化,映射入库至
+ * {@code health_data_sources}(sourceType='huawei_health',dimensionCodes 为 body.* 子维,
+ * parsedResult 保留原始数据 + 归一化得分 + 置信度)。</p>
+ */
+public interface HealthSyncService {
+
+    /**
+     * 同步指定类型的华为健康数据。
+     *
+     * @param memberId 家庭成员ID(family_members.id,仅能登录用户/成人)
+     * @param dataType 数据类型编码(HuaweiHealthDataType.type)
+     * @return 同步结果
+     */
+    SyncResult sync(Long memberId, String dataType);
+
+    /**
+     * 同步全部本期数据类型。
+     *
+     * @param memberId 家庭成员ID
+     * @return 各类型同步结果列表
+     */
+    List<SyncResult> syncAll(Long memberId);
+}

+ 265 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/huawei/HuaweiAuthService.java

@@ -0,0 +1,265 @@
+package com.etotem.cfc.service.huawei;
+
+import com.alibaba.fastjson.JSONObject;
+import com.etotem.cfc.entity.HuaweiHealthToken;
+import com.etotem.cfc.mapper.HuaweiHealthTokenMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.Date;
+
+/**
+ * 华为运动健康 OAuth 服务(D2)。
+ *
+ * <p>职责:授权 URL 生成、code 换 token、token 刷新、撤销授权。令牌仅后端持有、
+ * 加密存库,不落小程序。</p>
+ *
+ * <p>mock 模式下:授权 URL 返回模拟地址、任意 code 均可换取模拟令牌;real 模式下
+ * 走华为 Health Service Kit 云侧 REST 接口(凭证批复后替换,当前处标注 TODO)。</p>
+ */
+@Service
+public class HuaweiAuthService {
+
+    private static final Logger log = LoggerFactory.getLogger(HuaweiAuthService.class);
+
+    @Resource
+    private HuaweiHealthTokenMapper tokenMapper;
+
+    @Resource
+    private HuaweiHealthTokenCrypto tokenCrypto;
+
+    @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:}")
+    private String authorizeUrl;
+
+    @Value("${huawei.health.token-url:}")
+    private String tokenUrl;
+
+    @Value("${huawei.health.revoke-url:}")
+    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;
+
+    @Value("${huawei.health.mock-enabled:true}")
+    private boolean mockEnabled;
+
+    // ==================== 授权 URL 生成 ====================
+
+    /**
+     * 构造华为账号授权 URL(前端打开此 URL 引导用户授权)。
+     *
+     * @param memberId  家庭成员ID(作为 state 原样带回,防 CSRF)
+     * @param scope     授权范围;为空时使用默认 scope
+     * @return 授权 URL(mock 模式返回模拟地址)
+     */
+    public String generateAuthUrl(Long memberId, String 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)
+                + "&scope=" + encode(effectiveScope)
+                + "&state=" + memberId;
+    }
+
+    // ==================== code 换 token ====================
+
+    /**
+     * 用授权码换取访问/刷新令牌并加密持久化。
+     *
+     * @param code    授权码
+     * @param memberId 家庭成员ID
+     * @return 存储后的 HuaweiHealthToken(accessToken/refreshToken 已加密,外部不应直接明文使用)
+     */
+    public HuaweiHealthToken exchangeCode(String code, Long memberId) {
+        if (memberId == null) {
+            return null;
+        }
+        String accessToken;
+        String refreshToken;
+        Integer expiresInSec;
+        String grantedScope;
+
+        if (mockEnabled) {
+            accessToken = "mock-access-token-" + memberId + "-" + System.currentTimeMillis();
+            refreshToken = "mock-refresh-token-" + memberId + "-" + System.currentTimeMillis();
+            expiresInSec = 7200; // 2 小时
+            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;
+        }
+
+        if (accessToken == null || refreshToken == null) {
+            log.warn("华为 OAuth 未获取到有效令牌(mock={}),不落库", mockEnabled);
+            return null;
+        }
+
+        Date now = new Date();
+        Date expiresAt = expiresInSec != null
+                ? new Date(now.getTime() + expiresInSec.longValue() * 1000L) : null;
+
+        HuaweiHealthToken token = new HuaweiHealthToken();
+        token.setMemberId(memberId);
+        token.setAccessToken(tokenCrypto.encrypt(accessToken));
+        token.setRefreshToken(tokenCrypto.encrypt(refreshToken));
+        token.setExpiresAt(expiresAt);
+        token.setScope(grantedScope);
+        token.setStatus("ACTIVE");
+        token.setCreatedAt(now);
+        token.setUpdatedAt(now);
+
+        // 幂等:同一成员仅保留一条有效令牌,先置旧令牌 REVOKED,再插入新记录
+        tokenMapper.update(null,
+                new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<HuaweiHealthToken>()
+                        .eq(HuaweiHealthToken::getMemberId, memberId)
+                        .eq(HuaweiHealthToken::getStatus, "ACTIVE")
+                        .set(HuaweiHealthToken::getStatus, "REVOKED")
+                        .set(HuaweiHealthToken::getUpdatedAt, now));
+        tokenMapper.insert(token);
+        log.info("华为 OAuth 令牌已持久化 memberId={} status=ACTIVE", memberId);
+        return token;
+    }
+
+    // ==================== token 刷新 ====================
+
+    /**
+     * 刷新访问令牌。
+     *
+     * @param memberId 家庭成员ID
+     * @return 刷新后的令牌(加密持久化);无可用刷新令牌返回 null
+     */
+    public HuaweiHealthToken refreshToken(Long memberId) {
+        HuaweiHealthToken existing = tokenMapper.selectOne(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<HuaweiHealthToken>()
+                        .eq(HuaweiHealthToken::getMemberId, memberId)
+                        .eq(HuaweiHealthToken::getStatus, "ACTIVE"));
+        if (existing == null) {
+            log.warn("未找到 memberId={} 的有效华为令牌,无法刷新", memberId);
+            return null;
+        }
+        String refreshTokenPlain = tokenCrypto.decrypt(existing.getRefreshToken());
+
+        String newAccessToken;
+        String newRefreshToken;
+        Integer expiresInSec;
+
+        if (mockEnabled) {
+            newAccessToken = "mock-access-token-refreshed-" + memberId;
+            newRefreshToken = refreshTokenPlain; // mock 刷新令牌不变
+            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;
+        }
+
+        Date now = new Date();
+        Date expiresAt = new Date(now.getTime() + expiresInSec.longValue() * 1000L);
+
+        existing.setAccessToken(tokenCrypto.encrypt(newAccessToken));
+        existing.setRefreshToken(tokenCrypto.encrypt(newRefreshToken));
+        existing.setExpiresAt(expiresAt);
+        existing.setUpdatedAt(now);
+        tokenMapper.updateById(existing);
+        log.info("华为 OAuth 令牌已刷新 memberId={}", memberId);
+        return existing;
+    }
+
+    // ==================== 撤销授权 ====================
+
+    /**
+     * 撤销授权(合规必需):标记本地令牌 REVOKED,并(real 模式)调用华为撤销端点。
+     *
+     * @param memberId 家庭成员ID
+     * @return 是否成功
+     */
+    public boolean revoke(Long memberId) {
+        if (memberId == null) {
+            return false;
+        }
+        int updated = tokenMapper.update(null,
+                new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<HuaweiHealthToken>()
+                        .eq(HuaweiHealthToken::getMemberId, memberId)
+                        .eq(HuaweiHealthToken::getStatus, "ACTIVE")
+                        .set(HuaweiHealthToken::getStatus, "REVOKED")
+                        .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;
+        }
+        Long cnt = tokenMapper.selectCount(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<HuaweiHealthToken>()
+                        .eq(HuaweiHealthToken::getMemberId, memberId)
+                        .eq(HuaweiHealthToken::getStatus, "ACTIVE"));
+        return cnt != null && cnt > 0;
+    }
+
+    private String encode(String s) {
+        if (s == null) {
+            return "";
+        }
+        return java.net.URLEncoder.encode(s, java.nio.charset.StandardCharsets.UTF_8);
+    }
+
+    // ==================== 调试/状态用(避免明文泄露) ====================
+
+    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());
+        }
+        return obj;
+    }
+}

+ 32 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/huawei/HuaweiHealthApiClient.java

@@ -0,0 +1,32 @@
+package com.etotem.cfc.service.huawei;
+
+import java.util.List;
+
+/**
+ * 华为运动健康云端数据拉取通道(mock/real 二选一,配置文件开关切换)。
+ *
+ * <p>凭证未批复前默认启用 mock({@code huawei.health.mock-enabled=true});
+ * 凭证到位后切换为 false 走真实 Health Service Kit REST 接口。</p>
+ */
+public interface HuaweiHealthApiClient {
+
+    /**
+     * 按数据类型拉取某成员的最新周期数据。
+     *
+     * @param memberId 家庭成员ID(family_members.id,仅能登录用户/成人)
+     * @param dataType 数据类型编码(HuaweiHealthDataType.type)
+     * @return 该类型数据点;无数据返回空列表
+     */
+    List<HuaweiHealthDataPoint> fetchByType(Long memberId, String dataType);
+
+    /**
+     * 拉取全部本期数据类型。
+     *
+     * @param memberId 家庭成员ID
+     * @return 各类型数据点列表
+     */
+    List<HuaweiHealthDataPoint> fetchAll(Long memberId);
+
+    /** 通道是否就绪(real 模式凭证齐备才为 true) */
+    boolean isAvailable();
+}

+ 101 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/huawei/HuaweiHealthDataPoint.java

@@ -0,0 +1,101 @@
+package com.etotem.cfc.service.huawei;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 华为运动健康单条数据点(归一化输入)。
+ *
+ * <p>值与单位按数据类型区分:步数=步、距离=km、心率=bpm、睡眠=小时、体重=kg;
+ * 归一化在 {@link HealthSyncService} 中按类型取相应百分位常模完成。</p>
+ */
+public class HuaweiHealthDataPoint implements Serializable {
+
+    /** 数据类型(华为编码:step/distance/heart_rate/sleep/body_weight) */
+    private String dataType;
+
+    /** 数值(含单位换算后) */
+    private double value;
+
+    /** 单位(步/km/bpm/h/kg) */
+    private String unit;
+
+    /** 采集日期(yyyy-MM-dd;云端接口按日聚合) */
+    private String collectDate;
+
+    /** 采集时刻(精确到分钟的原始时间,可为 null) */
+    private Date collectedAt;
+
+    /** 原始华为返回(JSON 字符串,保留审计) */
+    private String rawJson;
+
+    /** 归一化附加输入(如体重类型的 BMI;无则 null) */
+    private Double auxiliaryValue;
+
+    /** 归一化附加值说明(如 "BMI(kg/m²)") */
+    private String auxiliaryLabel;
+
+    public String getDataType() {
+        return dataType;
+    }
+
+    public void setDataType(String dataType) {
+        this.dataType = dataType;
+    }
+
+    public double getValue() {
+        return value;
+    }
+
+    public void setValue(double value) {
+        this.value = value;
+    }
+
+    public String getUnit() {
+        return unit;
+    }
+
+    public void setUnit(String unit) {
+        this.unit = unit;
+    }
+
+    public String getCollectDate() {
+        return collectDate;
+    }
+
+    public void setCollectDate(String collectDate) {
+        this.collectDate = collectDate;
+    }
+
+    public Date getCollectedAt() {
+        return collectedAt;
+    }
+
+    public void setCollectedAt(Date collectedAt) {
+        this.collectedAt = collectedAt;
+    }
+
+    public String getRawJson() {
+        return rawJson;
+    }
+
+    public void setRawJson(String rawJson) {
+        this.rawJson = rawJson;
+    }
+
+    public Double getAuxiliaryValue() {
+        return auxiliaryValue;
+    }
+
+    public void setAuxiliaryValue(Double auxiliaryValue) {
+        this.auxiliaryValue = auxiliaryValue;
+    }
+
+    public String getAuxiliaryLabel() {
+        return auxiliaryLabel;
+    }
+
+    public void setAuxiliaryLabel(String auxiliaryLabel) {
+        this.auxiliaryLabel = auxiliaryLabel;
+    }
+}

+ 52 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/huawei/HuaweiHealthDataType.java

@@ -0,0 +1,52 @@
+package com.etotem.cfc.service.huawei;
+
+/**
+ * 华为运动健康开放数据类型(附录 A)。
+ *
+ * <p>本期接入:步数/距离/心率/睡眠/体重;血氧/热量为备选;实时心率等扩展能力本期不接入。
+ * 映射关系见方案 v1.2 第五节(body.* 子维)。</p>
+ */
+public enum HuaweiHealthDataType {
+    STEP("step", "步数", "body.exercise", "steps"),
+    DISTANCE("distance", "距离", "body.exercise", "km"),
+    HEART_RATE("heart_rate", "心率", "body.immunity", "bpm"),
+    SLEEP("sleep", "睡眠", "body.sleep", "h"),
+    BODY_WEIGHT("body_weight", "体重", "body.nutrition,body.growth", "kg");
+
+    private final String type;
+    private final String label;
+    private final String dimensionCodes;
+    private final String unit;
+
+    HuaweiHealthDataType(String type, String label, String dimensionCodes, String unit) {
+        this.type = type;
+        this.label = label;
+        this.dimensionCodes = dimensionCodes;
+        this.unit = unit;
+    }
+
+    public String getType() {
+        return type;
+    }
+
+    public String getLabel() {
+        return label;
+    }
+
+    public String getDimensionCodes() {
+        return dimensionCodes;
+    }
+
+    public String getUnit() {
+        return unit;
+    }
+
+    public static HuaweiHealthDataType of(String type) {
+        for (HuaweiHealthDataType t : values()) {
+            if (t.type.equalsIgnoreCase(type)) {
+                return t;
+            }
+        }
+        return null;
+    }
+}

+ 73 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/huawei/HuaweiHealthTokenCrypto.java

@@ -0,0 +1,73 @@
+package com.etotem.cfc.service.huawei;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+/**
+ * 华为 OAuth 令牌加解密(AES/GCM)。
+ *
+ * <p>token 仅后端持有、加密存库,不落小程序。生产环境务必通过环境变量
+ * {@code huawei.health.encrypt-key} 配置强随机密钥,默认仅供开发/mock 使用。</p>
+ */
+@Component
+public class HuaweiHealthTokenCrypto {
+
+    private static final int GCM_TAG_BITS = 128;
+    private static final int IV_BYTES = 12;
+
+    private final byte[] secretKeyBytes;
+
+    public HuaweiHealthTokenCrypto(@Value("${huawei.health.encrypt-key:huaweiDevKey1234}") String encryptKey) {
+        byte[] key = encryptKey.getBytes(StandardCharsets.UTF_8);
+        if (key.length != 16 && key.length != 24 && key.length != 32) {
+            throw new IllegalArgumentException(
+                    "huawei.health.encrypt-key 必须为 16/24/32 字节,当前 " + key.length + " 字节");
+        }
+        this.secretKeyBytes = key;
+    }
+
+    /** 加密,返回 Base64(IV + ciphertext) */
+    public String encrypt(String plaintext) {
+        try {
+            SecretKeySpec keySpec = new SecretKeySpec(secretKeyBytes, "AES");
+            byte[] iv = new byte[IV_BYTES];
+            java.security.SecureRandom random = new java.security.SecureRandom();
+            random.nextBytes(iv);
+            Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+            cipher.init(Cipher.ENCRYPT_MODE, keySpec, new GCMParameterSpec(GCM_TAG_BITS, iv));
+            byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
+            byte[] combined = new byte[IV_BYTES + ciphertext.length];
+            System.arraycopy(iv, 0, combined, 0, IV_BYTES);
+            System.arraycopy(ciphertext, 0, combined, IV_BYTES, ciphertext.length);
+            return Base64.getEncoder().encodeToString(combined);
+        } catch (Exception e) {
+            throw new IllegalStateException("华为令牌加密失败", e);
+        }
+    }
+
+    /** 解密,输入 Base64(IV + ciphertext) */
+    public String decrypt(String ciphertextBase64) {
+        try {
+            byte[] combined = Base64.getDecoder().decode(ciphertextBase64);
+            if (combined.length <= IV_BYTES) {
+                throw new IllegalArgumentException("密文长度异常");
+            }
+            byte[] iv = new byte[IV_BYTES];
+            System.arraycopy(combined, 0, iv, 0, IV_BYTES);
+            byte[] ciphertext = new byte[combined.length - IV_BYTES];
+            System.arraycopy(combined, IV_BYTES, ciphertext, 0, ciphertext.length);
+            SecretKeySpec keySpec = new SecretKeySpec(secretKeyBytes, "AES");
+            Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+            cipher.init(Cipher.DECRYPT_MODE, keySpec, new GCMParameterSpec(GCM_TAG_BITS, iv));
+            return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);
+        } catch (Exception e) {
+            throw new IllegalStateException("华为令牌解密失败", e);
+        }
+    }
+}

+ 88 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/huawei/SyncResult.java

@@ -0,0 +1,88 @@
+package com.etotem.cfc.service.huawei;
+
+/**
+ * 数据同步单条结果。
+ */
+public class SyncResult {
+
+    /** 数据类型 */
+    private String dataType;
+    /** 是否成功 */
+    private boolean success;
+    /** 成功拉取的数据点条数 */
+    private int pulledCount;
+    /** 实际写入 health_data_sources 的条数 */
+    private int storedCount;
+    /** 归一化后的体能得分(0-100,统一量纲,含在 parsedResult 中) */
+    private Integer normalizedScore;
+    /** 百分位带(veryLow/low/mid/high/veryHigh) */
+    private String band;
+    /** 状态(偏低/正常/偏高) */
+    private String status;
+    /** 错误信息(success=false 时填充) */
+    private String error;
+
+    public String getDataType() {
+        return dataType;
+    }
+
+    public void setDataType(String dataType) {
+        this.dataType = dataType;
+    }
+
+    public boolean isSuccess() {
+        return success;
+    }
+
+    public void setSuccess(boolean success) {
+        this.success = success;
+    }
+
+    public int getPulledCount() {
+        return pulledCount;
+    }
+
+    public void setPulledCount(int pulledCount) {
+        this.pulledCount = pulledCount;
+    }
+
+    public int getStoredCount() {
+        return storedCount;
+    }
+
+    public void setStoredCount(int storedCount) {
+        this.storedCount = storedCount;
+    }
+
+    public Integer getNormalizedScore() {
+        return normalizedScore;
+    }
+
+    public void setNormalizedScore(Integer normalizedScore) {
+        this.normalizedScore = normalizedScore;
+    }
+
+    public String getBand() {
+        return band;
+    }
+
+    public void setBand(String band) {
+        this.band = band;
+    }
+
+    public String getStatus() {
+        return status;
+    }
+
+    public void setStatus(String status) {
+        this.status = status;
+    }
+
+    public String getError() {
+        return error;
+    }
+
+    public void setError(String error) {
+        this.error = error;
+    }
+}

+ 248 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/huawei/impl/HealthSyncServiceImpl.java

@@ -0,0 +1,248 @@
+package com.etotem.cfc.service.huawei.impl;
+
+import com.alibaba.fastjson.JSONObject;
+import com.etotem.cfc.entity.HealthDataSourceRecord;
+import com.etotem.cfc.entity.HuaweiHealthToken;
+import com.etotem.cfc.mapper.HuaweiHealthTokenMapper;
+import com.etotem.cfc.service.HealthDataSourceRecordService;
+import com.etotem.cfc.service.attribution.model.IndicatorReading;
+import com.etotem.cfc.service.attribution.model.NormEntry;
+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 com.etotem.cfc.service.huawei.HealthSyncService;
+import com.etotem.cfc.service.huawei.SyncResult;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 华为运动健康数据同步引擎实现(D3)。
+ *
+ * <p>按数据类型拉取数据,复用 {@code NormCompareEngine} 百分位带归一化,
+ * 映射体.* 子维入库 {@code health_data_sources}(sourceType='huawei_health')。</p>
+ */
+@Slf4j
+@Service
+public class HealthSyncServiceImpl implements HealthSyncService {
+
+    @Resource
+    private HuaweiHealthApiClient huaweiHealthApiClient;
+
+    @Resource
+    private HuaweiHealthTokenMapper tokenMapper;
+
+    @Resource
+    private HuaweiHealthTokenCrypto tokenCrypto;
+
+    @Resource
+    private HealthDataSourceRecordService dataSourceRecordService;
+
+    @Resource
+    private com.etotem.cfc.service.attribution.NormCompareEngine normCompareEngine;
+
+    /** 每条记录保留的最近数据点上限(防止 parsedResult 过大) */
+    private static final int MAX_RAW_POINTS = 20;
+
+    /** 归一化置信度:有数据则 0.9,常模缺失时下调至 0.6 */
+    private static final BigDecimal CONFIDENCE_NORMAL = new BigDecimal("0.90");
+    private static final BigDecimal CONFIDENCE_LOW = new BigDecimal("0.60");
+
+    // ==================== 各数据类型默认百分位常模(成人) ====================
+    // 若后续接入 health_norm_reference / attribution_norm_values,此处可替换为 DB 常模。
+    private static final Map<String, double[]> DEFAULT_NORMS = new LinkedHashMap<>();
+    static {
+        // STEP(步数):P5/P25/P50/P75/P95
+        DEFAULT_NORMS.put("step", new double[]{3000, 5000, 7000, 10000, 15000});
+        // DISTANCE(距离,km)
+        DEFAULT_NORMS.put("distance", new double[]{2.0, 3.5, 5.0, 7.5, 12.0});
+        // HEART_RATE(心率,bpm,静息)
+        DEFAULT_NORMS.put("heart_rate", new double[]{50.0, 58.0, 65.0, 75.0, 90.0});
+        // SLEEP(睡眠,小时)
+        DEFAULT_NORMS.put("sleep", new double[]{4.5, 5.5, 6.5, 7.5, 8.5});
+        // BODY_WEIGHT:按 BMI(kg/m²)归一化,mock 返回 auxiliaryValue=BMI
+        DEFAULT_NORMS.put("body_weight", new double[]{18.5, 20.0, 22.0, 24.0, 26.0});
+    }
+
+    // ==================== 百分位带 → 得分映射(0-100 统一量纲) ====================
+    private static final Map<String, Integer> BAND_SCORE = new LinkedHashMap<>();
+    static {
+        BAND_SCORE.put(IndicatorReading.BAND_VERY_LOW, 20);
+        BAND_SCORE.put(IndicatorReading.BAND_LOW, 40);
+        BAND_SCORE.put(IndicatorReading.BAND_MID, 65);
+        BAND_SCORE.put(IndicatorReading.BAND_HIGH, 80);
+        BAND_SCORE.put(IndicatorReading.BAND_VERY_HIGH, 90);
+        BAND_SCORE.put(IndicatorReading.BAND_UNKNOWN, 50);
+    }
+
+    @Override
+    public SyncResult sync(Long memberId, String dataType) {
+        SyncResult result = new SyncResult();
+        result.setDataType(dataType);
+
+        HuaweiHealthDataType type = HuaweiHealthDataType.of(dataType);
+        if (type == null) {
+            result.setSuccess(false);
+            result.setError("不支持的数据类型: " + dataType);
+            return result;
+        }
+
+        // 权限/授权校验:仅已绑定成员可拉取
+        if (!isBound(memberId)) {
+            result.setSuccess(false);
+            result.setError("成员未绑定华为账号,请先获取授权");
+            return result;
+        }
+
+        List<HuaweiHealthDataPoint> points;
+        try {
+            points = huaweiHealthApiClient.fetchByType(memberId, dataType);
+        } catch (Exception e) {
+            log.error("华为健康数据拉取失败 memberId={} type={}: {}", memberId, dataType, e.getMessage(), e);
+            result.setSuccess(false);
+            result.setError("拉取失败: " + e.getMessage());
+            return result;
+        }
+        result.setPulledCount(points == null ? 0 : points.size());
+
+        if (points == null || points.isEmpty()) {
+            result.setSuccess(true);
+            result.setStoredCount(0);
+            result.setNormalizedScore(null);
+            log.info("华为健康数据为空 memberId={} type={}", memberId, dataType);
+            return result;
+        }
+
+        // 取最新一条数据点作为本期代表值(云端按日聚合)
+        HuaweiHealthDataPoint latest = points.get(points.size() - 1);
+
+        // 归一化:复用 NormCompareEngine 百分位带
+        double numericValue = selectNumericValue(type, latest);
+        List<NormEntry> norms = buildNorms(type);
+        List<IndicatorReading> readings = new ArrayList<>();
+        IndicatorReading reading = new IndicatorReading();
+        reading.setMemberId(memberId);
+        reading.setMemberName("member");
+        reading.setSource("huawei_health");
+        reading.setCode(type.getType());
+        reading.setDisplayName(type.getLabel());
+        reading.setNumericValue(numericValue);
+        reading.setUnit(type.getUnit());
+        reading.setValue(String.valueOf(numericValue));
+        readings.add(reading);
+
+        com.etotem.cfc.service.attribution.model.CompareResult compare =
+                normCompareEngine.compare(new ArrayList<>(), readings, norms, new java.util.LinkedHashMap<>());
+        IndicatorReading banded = compare.getReadings().get(0);
+
+        String band = banded.getBand() == null ? IndicatorReading.BAND_UNKNOWN : banded.getBand();
+        String status = banded.getStatus() == null ? IndicatorReading.STATUS_PENDING : banded.getStatus();
+        Integer score = BAND_SCORE.getOrDefault(band, 50);
+        BigDecimal confidence = IndicatorReading.STATUS_PENDING.equals(status) ? CONFIDENCE_LOW : CONFIDENCE_NORMAL;
+
+        result.setNormalizedScore(score);
+        result.setBand(band);
+        result.setStatus(status);
+
+        // 构建 parsedResult JSON(保留原始 + 归一化,供审计与前端展示)
+        JSONObject parsedResult = new JSONObject();
+        parsedResult.put("dataType", type.getType());
+        parsedResult.put("label", type.getLabel());
+        parsedResult.put("sourceType", "huawei_health");
+        parsedResult.put("memberId", memberId);
+        parsedResult.put("collectDate", latest.getCollectDate());
+        parsedResult.put("collectedAt", latest.getCollectedAt() != null
+                ? latest.getCollectedAt().getTime() : null);
+        parsedResult.put("value", latest.getValue());
+        parsedResult.put("unit", latest.getUnit());
+        if (latest.getAuxiliaryValue() != null) {
+            parsedResult.put("auxiliaryValue", latest.getAuxiliaryValue());
+            parsedResult.put("auxiliaryLabel", latest.getAuxiliaryLabel());
+        }
+        if (latest.getRawJson() != null && !latest.getRawJson().isEmpty()) {
+            parsedResult.put("raw", latest.getRawJson());
+        }
+        JSONObject normalized = new JSONObject();
+        normalized.put("band", band);
+        normalized.put("status", status);
+        normalized.put("percentile", banded.getPercentile());
+        normalized.put("score", score);
+        normalized.put("normSource", banded.getNormSource());
+        parsedResult.put("normalized", normalized);
+        parsedResult.put("dimensionCodes", type.getDimensionCodes());
+        parsedResult.put("syncedAt", System.currentTimeMillis());
+
+        // 入库 health_data_sources(一条记录代表本期该类型最新数据)
+        HealthDataSourceRecord record = new HealthDataSourceRecord();
+        record.setMemberId(memberId);
+        record.setSourceType("huawei_health");
+        record.setDimensionCodes(type.getDimensionCodes());
+        record.setParsedResult(parsedResult.toJSONString());
+        record.setConfidence(confidence);
+        record.setCreatedAt(new Date());
+        dataSourceRecordService.saveRecord(record);
+        result.setStoredCount(1);
+
+        result.setSuccess(true);
+        log.info("华为健康数据同步完成 memberId={} type={} value={} band={} status={} score={}",
+                memberId, dataType, numericValue, band, status, score);
+        return result;
+    }
+
+    @Override
+    public List<SyncResult> syncAll(Long memberId) {
+        List<SyncResult> results = new ArrayList<>();
+        for (HuaweiHealthDataType type : HuaweiHealthDataType.values()) {
+            results.add(sync(memberId, type.getType()));
+        }
+        return results;
+    }
+
+    /** 判断指定成员当前是否持有有效华为授权。 */
+    private boolean isBound(Long memberId) {
+        if (memberId == null) {
+            return false;
+        }
+        Long cnt = tokenMapper.selectCount(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<HuaweiHealthToken>()
+                        .eq(HuaweiHealthToken::getMemberId, memberId)
+                        .eq(HuaweiHealthToken::getStatus, "ACTIVE"));
+        return cnt != null && cnt > 0;
+    }
+
+    /** 按数据类型选取用于归一化的数值:体重使用 BMI(auxiliaryValue),其余使用 value。 */
+    private double selectNumericValue(HuaweiHealthDataType type, HuaweiHealthDataPoint point) {
+        if (type == HuaweiHealthDataType.BODY_WEIGHT && point.getAuxiliaryValue() != null) {
+            return point.getAuxiliaryValue();
+        }
+        return point.getValue();
+    }
+
+    private List<NormEntry> buildNorms(HuaweiHealthDataType type) {
+        double[] bands = DEFAULT_NORMS.get(type.getType());
+        List<NormEntry> norms = new ArrayList<>();
+        NormEntry n = new NormEntry();
+        n.setNormType("huawei_health");
+        n.setCode(type.getType());
+        n.setDisplayName(type.getLabel());
+        n.setAgeBand("all");
+        n.setGender("all");
+        n.setP5(bands[0]);
+        n.setP25(bands[1]);
+        n.setP50(bands[2]);
+        n.setP75(bands[3]);
+        n.setP95(bands[4]);
+        n.setUnit(type.getUnit());
+        n.setSource("huawei_health_default");
+        norms.add(n);
+        return norms;
+    }
+}

+ 138 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/huawei/impl/HuaweiMockHealthApiClient.java

@@ -0,0 +1,138 @@
+package com.etotem.cfc.service.huawei.impl;
+
+import com.alibaba.fastjson.JSONObject;
+import com.etotem.cfc.service.huawei.HuaweiHealthApiClient;
+import com.etotem.cfc.service.huawei.HuaweiHealthDataPoint;
+import com.etotem.cfc.service.huawei.HuaweiHealthDataType;
+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.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 华为运动健康 Mock 数据通道(凭证未批复前使用)。
+ *
+ * <p>返回与真实云侧同结构的模拟原子数据,用于跑通「授权→令牌→拉取→归一化→入库→前端展示」
+ * 全链路;数值与真实设备采集数据无关,仅供开发验证。</p>
+ */
+@Component
+@ConditionalOnProperty(prefix = "huawei.health", name = "mock-enabled",
+        havingValue = "true", matchIfMissing = true)
+public class HuaweiMockHealthApiClient implements HuaweiHealthApiClient {
+
+    private static final Logger log = LoggerFactory.getLogger(HuaweiMockHealthApiClient.class);
+
+    private final boolean mockEnabled;
+
+    public HuaweiMockHealthApiClient(
+            @Value("${huawei.health.mock-enabled:true}") boolean mockEnabled) {
+        this.mockEnabled = mockEnabled;
+    }
+
+    @Override
+    public List<HuaweiHealthDataPoint> fetchByType(Long memberId, String dataType) {
+        if (!mockEnabled) {
+            return new ArrayList<>();
+        }
+        HuaweiHealthDataType type = HuaweiHealthDataType.of(dataType);
+        if (type == null) {
+            return new ArrayList<>();
+        }
+        List<HuaweiHealthDataPoint> points = new ArrayList<>();
+        HuaweiHealthDataPoint p = new HuaweiHealthDataPoint();
+        p.setDataType(type.getType());
+        p.setUnit(type.getUnit());
+        p.setCollectDate(java.time.LocalDate.now().toString());
+        p.setCollectedAt(new java.util.Date());
+        p.setAuxiliaryLabel("mock");
+        switch (type) {
+            case STEP:
+                p.setValue(8321);
+                p.setRawJson("{\"type\":\"STEP\",\"value\":8321,\"unit\":\"步\",\"date\":\""
+                        + p.getCollectDate() + "\",\"source\":\"mock\"}");
+                break;
+            case DISTANCE:
+                p.setValue(5.4);
+                p.setRawJson("{\"type\":\"DISTANCE\",\"value\":5.4,\"unit\":\"km\",\"date\":\""
+                        + p.getCollectDate() + "\",\"source\":\"mock\"}");
+                break;
+            case HEART_RATE:
+                p.setValue(72);
+                p.setRawJson("{\"type\":\"HEART_RATE\",\"value\":72,\"unit\":\"bpm\",\"date\":\""
+                        + p.getCollectDate() + "\",\"source\":\"mock\"}");
+                break;
+            case SLEEP:
+                p.setValue(6.5);
+                p.setRawJson("{\"type\":\"SLEEP\",\"value\":6.5,\"unit\":\"h\",\"date\":\""
+                        + p.getCollectDate() + "\",\"source\":\"mock\"}");
+                break;
+            case BODY_WEIGHT:
+                // 体重归一化走体质指数(BMI):mock 假设身高 1.70m,体重 65kg → BMI≈22.5
+                p.setValue(65.0);
+                p.setAuxiliaryValue(65.0 / (1.70 * 1.70)); // 22.5
+                p.setAuxiliaryLabel("BMI(kg/m²)");
+                p.setRawJson("{\"type\":\"BODY_WEIGHT\",\"value\":65.0,\"unit\":\"kg\",\"bmi\":"
+                        + String.format("%.1f", p.getAuxiliaryValue()) + ",\"date\":\""
+                        + p.getCollectDate() + "\",\"source\":\"mock\"}");
+                break;
+        }
+        points.add(p);
+        log.debug("[Mock] 华为健康数据拉取 memberId={} type={} value={}", memberId, dataType, p.getValue());
+        return points;
+    }
+
+    @Override
+    public List<HuaweiHealthDataPoint> fetchAll(Long memberId) {
+        if (!mockEnabled) {
+            return new ArrayList<>();
+        }
+        List<HuaweiHealthDataPoint> all = new ArrayList<>();
+        for (HuaweiHealthDataType type : HuaweiHealthDataType.values()) {
+            all.addAll(fetchByType(memberId, type.getType()));
+        }
+        return all;
+    }
+
+    @Override
+    public boolean isAvailable() {
+        return mockEnabled;
+    }
+
+    /**
+     * 构造模拟授权 URL(mock 通道供前端「模拟点击授权」跳转至 callback)。
+     */
+    public String buildMockAuthUrl(Long memberId, String scope) {
+        return "mock://huawei-health/authorize?response_type=code&client_id=mock-client-id"
+                + "&redirect_uri=" + encode("/api/huawei-health/callback")
+                + "&scope=" + encode(scope)
+                + "&state=" + memberId;
+    }
+
+    /** 模拟交换 token:任意 code 均返回模拟令牌。 */
+    public String mockExchangeCode(String code, Long memberId) {
+        return "mock-access-token-" + memberId;
+    }
+
+    public String mockRefreshToken(String refreshToken) {
+        return "mock-access-token-refreshed";
+    }
+
+    private String encode(String s) {
+        return s.replace("/", "%2F").replace(":", "%3A");
+    }
+
+    /**
+     * 供外部快速判断 mock 通道是否被启用(JSON 格式,便于状态接口返回)。
+     */
+    public JSONObject statusJson() {
+        JSONObject obj = new JSONObject();
+        obj.put("channel", "mock");
+        obj.put("mockEnabled", mockEnabled);
+        obj.put("available", isAvailable());
+        return obj;
+    }
+}

+ 51 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/huawei/impl/HuaweiRealHealthApiClient.java

@@ -0,0 +1,51 @@
+package com.etotem.cfc.service.huawei.impl;
+
+import com.etotem.cfc.service.huawei.HuaweiHealthApiClient;
+import com.etotem.cfc.service.huawei.HuaweiHealthDataPoint;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 华为运动健康真实 REST 通道(凭证到位后切换 {@code huawei.health.mock-enabled=false} 启用)。
+ *
+ * <p>当前为骨架实现:凭证(clientId/clientSecret/authorizeUrl/tokenUrl)尚未批复,
+ * 所有实际 HTTP 调用处标注 TODO,待联调时替换。不在 mock 模式下返回空数据,
+ * 由调用方按“空数据不阻塞”处理。</p>
+ */
+@Component
+@ConditionalOnProperty(prefix = "huawei.health", name = "mock-enabled",
+        havingValue = "false")
+public class HuaweiRealHealthApiClient implements HuaweiHealthApiClient {
+
+    private final String clientId;
+    private final String clientSecret;
+
+    public HuaweiRealHealthApiClient(
+            @Value("${huawei.health.client-id:}") String clientId,
+            @Value("${huawei.health.client-secret:}") String clientSecret) {
+        this.clientId = clientId;
+        this.clientSecret = clientSecret;
+    }
+
+    @Override
+    public List<HuaweiHealthDataPoint> fetchByType(Long memberId, String dataType) {
+        // TODO(华为凭证批复后):替换为 Health Service Kit 云侧 REST 调用,
+        // 按数据类型返回原子数据(步数/距离/心率/睡眠/体重)。
+        return new ArrayList<>();
+    }
+
+    @Override
+    public List<HuaweiHealthDataPoint> fetchAll(Long memberId) {
+        return new ArrayList<>();
+    }
+
+    @Override
+    public boolean isAvailable() {
+        return clientId != null && !clientId.isEmpty()
+                && clientSecret != null && !clientSecret.isEmpty();
+    }
+}

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

@@ -214,6 +214,27 @@ app:
   attribution:
     enabled: false  # 家庭归因全流程开关(E12 合规后置:默认 false,仅测试/mock 数据;真实用户放行=阶段四 D1)
 
+# 华为运动健康开放平台(HuaweiHealthService,方案 v1.2 第六节 D1-D5)
+# 凭证未批复前用 mock 通道跑通全链路;批复后 mock-enabled=false 切真实 Health Service Kit 云侧 REST 接口。
+# token 仅后端持有(AES/GCM 加密存库),不落小程序。
+huawei:
+  health:
+    # 通道开关:true=mock(凭证未批复前的模拟数据源),false=真实云侧接口
+    mock-enabled: ${HUAWEI_HEALTH_MOCK_ENABLED:true}
+    # 云服务认证鉴权(OAuth2)参数——待华为凭证批复后由环境变量注入
+    client-id: ${HUAWEI_CLIENT_ID:mock-client-id}
+    client-secret: ${HUAWEI_CLIENT_SECRET:mock-client-secret}
+    redirect-uri: ${HUAWEI_REDIRECT_URI:https://cfc.etotem.com.cn/api/huawei-health/callback}
+    authorize-url: ${HUAWEI_AUTHORIZE_URL:https://oauth-login.cloud.huawei.com/oauth2/v3/authorize}
+    token-url: ${HUAWEI_TOKEN_URL:https://oauth-login.cloud.huawei.com/oauth2/v3/token}
+    revoke-url: ${HUAWEI_REVOKE_URL:https://health-api.cloud.huawei.com/oauth/v1/token/revoke}
+    # 需要申请的数据权限 scope(以华为方批复为准)
+    scope: ${HUAWEI_SCOPE:step,distance,heart_rate,sleep,body_weight}
+    # token 落库加密密钥(AES-128,仅后端持有;生产环境必须覆盖为强随机密钥)
+    encrypt-key: ${HUAWEI_TOKEN_ENCRYPT_KEY:huaweiDevKey1234}
+    # 定时同步 cron(每日 03:15 拉取所有 ACTIVE 令牌成员数据)
+    sync-cron: ${HUAWEI_SYNC_CRON:0 15 3 * * ?}
+
 # Admin 登录配置
 admin:
   skip-captcha: true  # 测试环境跳过验证码,方便自动化测试

+ 27 - 0
cfc-backend/src/main/resources/schema.sql

@@ -7064,3 +7064,30 @@ CREATE TABLE IF NOT EXISTS user_onboarding_guide (
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     UNIQUE KEY uk_uog_user_position (user_id, position)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='首页引导浮层已读状态';
+
+-- 华为运动健康OAuth令牌(D1接入,token 仅后端持有、加密存库,不落小程序)
+CREATE TABLE IF NOT EXISTS huawei_health_tokens (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    member_id BIGINT NOT NULL COMMENT '家庭成员ID(family_members.id,仅能登录用户/成人)',
+    access_token TEXT NOT NULL COMMENT '加密后的访问令牌(AES/GCM,仅后端持有)',
+    refresh_token TEXT NOT NULL COMMENT '加密后的刷新令牌(AES/GCM,仅后端持有)',
+    expires_at DATETIME NULL COMMENT '访问令牌过期时间',
+    scope VARCHAR(255) NULL COMMENT '授权范围(华为数据类型逗号分隔)',
+    status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态: ACTIVE=有效/REVOKED=已撤销',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    UNIQUE KEY uk_member (member_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='华为运动健康OAuth令牌';
+
+-- 小米运动健康OAuth令牌(X1接入,token 仅后端持有、加密存库,不落小程序)
+CREATE TABLE IF NOT EXISTS xiaomi_health_tokens (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    member_id BIGINT NOT NULL COMMENT '家庭成员ID(family_members.id,仅能登录用户/成人)',
+    access_token TEXT NOT NULL COMMENT '加密后的访问令牌(AES/CBC,仅后端持有)',
+    refresh_token TEXT NULL COMMENT '加密后的刷新令牌(AES/CBC,可空:小米可能不下发)',
+    expires_at DATETIME NULL COMMENT '访问令牌过期时间',
+    scope VARCHAR(255) NULL COMMENT '授权范围(小米数据类型逗号分隔)',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    UNIQUE KEY uk_member (member_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='小米运动健康OAuth令牌';

+ 42 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -89,6 +89,8 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `AdminActivityCheckinController` | `/api/admin/activity-checkins` | 管理端-签到记录/手动补录 | — |
 | `AdminPlannerExamController` | `/api/admin/planner-exam` | 管理端-试卷/题库 CRUD | — |
 | `AdminPlannerCertificationController` | `/api/admin/planner-certifications` | 管理端-认证看板/同步课时/存量补登 | — |
+| `HuaweiHealthController` | `/api/huawei-health` | 华为运动健康接入(授权URL/callback/同步/撤销/状态) | — |
+| `XiaomiHealthController` | `/api/xiaomi-health` | 小米运动健康云服务接口接入(授权URL/callback/同步/撤销/状态) | — |
 
 ---
 
@@ -1449,6 +1451,46 @@ interactionType 取值:`PHONE` / `WECHAT` / `MEETING` / `OTHER`。
 
 > **说明**:本功能将小程序弹窗引导页从硬编码改为配置化管理,支持管理员在后台配置弹出条件与内容。旧方案(sys_config onboarding_guide_config)保留,新方案通过 4 张新表 + 9 接口实现。
 
+### 4.43 华为运动健康接入(`/api/huawei-health/*`)
+
+> **设计文档**:`docs/2026-11-06-华为运动健康开放平台接入方案.md`(D1-D5)
+>
+> 令牌仅后端持有(AES/GCM 加密存库),不落小程序;`memberId` 为空时取当前登录成员(切换视角场景可显式传 `memberId` 覆盖)。全部 `@PostMapping`,需 JWT。
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/huawei-health/auth-url` | 获取华为账号授权 URL(body: memberId?, scope?) |
+| `POST /api/huawei-health/callback` | 授权回调,code 换 token(body: code + memberId?) |
+| `POST /api/huawei-health/sync` | 触发数据同步(body: memberId? + dataType?,dataType 空=全部类型) |
+| `POST /api/huawei-health/revoke` | 撤销授权(合规必需,body: memberId?) |
+| `POST /api/huawei-health/status` | 查询绑定/同步状态(body: memberId?) |
+
+> **数据表**:`huawei_health_tokens`(D1,迁移452);同步入库 `health_data_sources`(sourceType='huawei_health',复用表,未新建)。
+>
+> **通道开关**:`huawei.health.mock-enabled`(默认 true=mock;凭证批复后 false=真实 Health Service Kit 云侧 REST,真实调用处标注 TODO)。
+
+---
+
+### 4.44 小米运动健康云服务接口接入(`/api/xiaomi-health/*`)
+
+> **设计文档**:`docs/2026-11-06-华为运动健康开放平台接入方案.md`(X1-X5)
+>
+> 令牌仅后端持有(AES 加密存库),不落小程序;`memberId` 为空时取当前登录成员(切换视角场景可显式传 `memberId` 覆盖)。全部 `@PostMapping`,需 JWT。
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/xiaomi-health/auth-url` | 获取小米云服务授权 URL(body: memberId?, scope?) |
+| `POST /api/xiaomi-health/callback` | 授权回调,code 换 token(body: code + memberId?) |
+| `POST /api/xiaomi-health/sync` | 触发数据同步(body: memberId? + dataType?,dataType 空=全部类型) |
+| `POST /api/xiaomi-health/revoke` | 撤销授权(合规必需,body: memberId?) |
+| `POST /api/xiaomi-health/status` | 查询绑定/同步状态(body: memberId?) |
+
+> **数据表**:`xiaomi_health_tokens`(X1,迁移453);同步入库 `health_data_sources`(sourceType='xiaomi_health',复用表,未新建)。
+>
+> **通道开关**:`xiaomi.health.mock-enabled`(默认 true=mock;凭证批复后 false=真实云服务接口 hlthopen.io.mi.com,真实调用处标注 TODO)。
+>
+> **合规红线**:仅官方云服务认证鉴权;不引入境外聚合中转(如 Terra),不碰社区逆向接口(userId/passToken 仿冒)。
+
 ---
 
 ## 六、待清理的废弃接口