Browse Source

feat(xiaomi-health): X1-X5 小米运动健康云服务接口接入

- X1 数据模型: xiaomi_health_tokens 表(迁移453, 令牌仅后端持有、AES加密存库、不落小程序)
- X2 OAuth: XiaomiAuthService 授权URL/code换token/刷新/撤销 + XiaomiHealthTokenService
- X3 同步引擎: XiaomiHealthServiceImpl 复用 NormCompareEngine 百分位带归一化, 写 health_data_sources(sourceType=xiaomi_health)
- X4 控制器: XiaomiHealthController 5接口(auth-url/callback/sync/revoke/status), 全部 @PostMapping
- X5 通道: XiaomiHealthApiClientImpl mock(凭证未批复默认) / 真实云服务接口 TODO, 合规: 仅官方云服务、禁境外聚合中转、禁逆向接口
- JwtInterceptor 增加 /api/xiaomi-health/ 登录拦截; application.yml 增加 xiaomi.health.* 配置
Agnes Agent 5 ngày trước cách đây
mục cha
commit
4344688d2

+ 101 - 0
cfc-backend/src/main/java/com/etotem/cfc/common/crypto/AesUtil.java

@@ -0,0 +1,101 @@
+package com.etotem.cfc.common.crypto;
+
+import javax.crypto.BadPaddingException;
+import javax.crypto.Cipher;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.Base64;
+
+/**
+ * 极简 AES 工具(AES/CBC/PKCS5Padding),用于小米健康 token 的加密存库。
+ * <p>
+ * 实现约束:
+ * <ul>
+ *   <li>Java 8 环境——不使用 {@code HexFormat} 等 JDK17+ 特性;</li>
+ *   <li>key 派生:{@code MD5(xiaomi.encrypt-key 配置字符串)} 取前 16 字节作为 AES-128 key;</li>
+ *   <li>输出格式:{@code base64(IV):base64(ciphertext)}——IV 固定 16 字节全 0(测试/mock 场景)。</li>
+ * </ul>
+ * 合规红线:仅在后端服务内部使用,不在前端/小程序执行加密操作;生产环境请替换为 AES-GCM +
+ * 硬件安全模块(HSM)或 KMS 密钥管理系统进行密钥轮换与审计。
+ */
+public class AesUtil {
+
+    /** MD5 后 16 字节作 AES-128 key(从 xiaomi.encrypt-key 配置派生) */
+    private static final byte[] KEY;
+
+    /** 默认 CBC IV(全 0),mock 场景固定;生产应使用随机 IV。 */
+    private static final byte[] IV = new byte[16];
+
+    static {
+        String raw = System.getProperty("xiaomi.encrypt-key", "cfc-xiaomi-token-key-2026");
+        MessageDigest md;
+        try {
+            md = MessageDigest.getInstance("MD5");
+        } catch (Exception e) {
+            throw new RuntimeException("MD5 not available", e);
+        }
+        KEY = md.digest(raw.getBytes(StandardCharsets.UTF_8));
+    }
+
+    private static Cipher newEncryptCipher() {
+        try {
+            Cipher c = Cipher.getInstance("AES/CBC/PKCS5Padding");
+            c.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(KEY, "AES"), new IvParameterSpec(IV));
+            return c;
+        } catch (Exception e) {
+            throw new RuntimeException("AES init failed", e);
+        }
+    }
+
+    private static Cipher newDecryptCipher(byte[] ivBytes) {
+        try {
+            Cipher c = Cipher.getInstance("AES/CBC/PKCS5Padding");
+            c.init(Cipher.DECRYPT_MODE, new SecretKeySpec(KEY, "AES"), new IvParameterSpec(ivBytes));
+            return c;
+        } catch (Exception e) {
+            throw new RuntimeException("AES init failed", e);
+        }
+    }
+
+    /**
+     * 加密明文为 {@code base64(IV):base64(ciphertext)}。
+     *
+     * @param plaintext 明文(token 字符串)
+     * @return 密文(base64 段 + 冒号分隔的 IV/ciphertext 两段)
+     * @throws Exception 加密失败
+     */
+    public static String encrypt(String plaintext) throws Exception {
+        if (plaintext == null || plaintext.isEmpty()) {
+            return null;
+        }
+        Cipher c = newEncryptCipher();
+        byte[] encrypted = c.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
+        String ivB64 = Base64.getEncoder().encodeToString(IV);
+        String ctB64 = Base64.getEncoder().encodeToString(encrypted);
+        return ivB64 + ":" + ctB64;
+    }
+
+    /**
+     * 解密 {@code base64(IV):base64(ciphertext)} 为明文。
+     *
+     * @param base64IvCiphertext 密文(base64 段 + 冒号分隔)
+     * @return 明文 token 字符串
+     * @throws Exception 解密失败
+     */
+    public static String decrypt(String base64IvCiphertext) throws Exception {
+        if (base64IvCiphertext == null || base64IvCiphertext.isEmpty()) {
+            return null;
+        }
+        String[] parts = base64IvCiphertext.split(":", 2);
+        if (parts.length != 2) {
+            throw new BadPaddingException("Invalid AES format: " + base64IvCiphertext);
+        }
+        byte[] ivBytes = Base64.getDecoder().decode(parts[0]);
+        byte[] ctBytes = Base64.getDecoder().decode(parts[1]);
+        Cipher c = newDecryptCipher(ivBytes);
+        byte[] plaintext = c.doFinal(ctBytes);
+        return new String(plaintext, StandardCharsets.UTF_8);
+    }
+}

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java

@@ -112,6 +112,7 @@ private static final String[] PUBLIC_PATHS = {
         "/api/family-platform/",            // 家庭资金平台:余额/流水/兑换,家庭私有资金数据
         "/api/health/report/",              // 健康报告:家庭私有健康数据(上传/解析/列表/详情/指标/删除)
         "/api/health/checkin/",             // 健康打卡:成员私有数据
+        "/api/xiaomi-health/",              // 小米运动健康:OAuth 授权绑定/同步/撤销,成员私有凭证与数据
         "/api/tasks/",                      // 任务:家庭私有数据(今日/历史/审核/完成)
         "/api/energy/",                     // 五维能量:用户私有数据(sandbox/logs/grant/score-history...)
         "/api/wishes/",                     // 心愿:家庭私有数据

+ 213 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/xiaomi/XiaomiHealthController.java

@@ -0,0 +1,213 @@
+package com.etotem.cfc.controller.xiaomi;
+
+import com.alibaba.fastjson.JSONObject;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.XiaomiHealthToken;
+import com.etotem.cfc.service.XiaomiAuthService;
+import com.etotem.cfc.service.xiaomi.XiaomiHealthService;
+import com.etotem.cfc.service.xiaomi.XiaomiSyncResult;
+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.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 小米运动健康云服务接口接入控制器(X4)。
+ *
+ * <p>5 个接口,全部 {@code @PostMapping},响应 {@link Result};JWT 拦截 {@code /api/**}。
+ * 令牌仅后端持有(加密存库),不落小程序。</p>
+ *
+ * <p>接口清单(方案 v1.2 第十一节):
+ * <ul>
+ *   <li>{@code POST /api/xiaomi-health/auth-url}  获取授权 URL</li>
+ *   <li>{@code POST /api/xiaomi-health/callback}  授权回调,code 换 token</li>
+ *   <li>{@code POST /api/xiaomi-health/sync}      触发数据同步</li>
+ *   <li>{@code POST /api/xiaomi-health/revoke}    撤销授权(合规必需)</li>
+ *   <li>{@code POST /api/xiaomi-health/status}    绑定/同步状态查询</li>
+ * </ul></p>
+ *
+ * <p>memberId 为空时取当前登录成员({@code currentMemberId},切换视角场景可显式传
+ * {@code memberId} 覆盖)。</p>
+ */
+@Slf4j
+@Tag(name = "小米运动健康")
+@RestController
+@RequestMapping("/api/xiaomi-health")
+public class XiaomiHealthController {
+
+    @Resource
+    private XiaomiAuthService xiaomiAuthService;
+
+    @Resource
+    private XiaomiHealthService xiaomiHealthService;
+
+    // ==================== 授权 URL 获取 ====================
+
+    /**
+     * 获取小米云服务授权 URL。
+     *
+     * <p>请求体示例:{ "memberId": 123, "scope": "steps,sleep,heart_rate,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", ""));
+        Map<String, Object> built = xiaomiAuthService.buildAuthUrl(memberId);
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("memberId", memberId);
+        data.put("authUrl", built.get("authUrl"));
+        data.put("mode", built.get("mode"));
+        if (built.get("redirectUri") != null) {
+            data.put("redirectUri", built.get("redirectUri"));
+        }
+        if (built.get("state") != null) {
+            data.put("state", built.get("state"));
+        }
+        data.put("scope", scope);
+        data.put("message", built.get("message"));
+        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() || "null".equals(code)) {
+            return Result.badRequest("code不能为空");
+        }
+
+        try {
+            XiaomiHealthToken token = xiaomiAuthService.exchangeCode(code, memberId);
+            Map<String, Object> data = new LinkedHashMap<>();
+            data.put("memberId", memberId);
+            data.put("bound", token != null);
+            data.put("status", token == null ? "fail" : "bound");
+            data.put("expiresAt", token == null ? null : token.getExpiresAt());
+            return Result.success(data);
+        } catch (Exception e) {
+            log.error("小米 code 换 token 失败 memberId={}: {}", memberId, e.getMessage());
+            return Result.error("code 换 token 失败,请重新授权: " + e.getMessage());
+        }
+    }
+
+    // ==================== 数据同步 ====================
+
+    /**
+     * 触发小米健康数据同步。
+     *
+     * <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 LinkedHashMap<>();
+        data.put("memberId", memberId);
+        data.put("syncAt", new Date().getTime());
+
+        if (dataType != null && !dataType.isEmpty() && !"null".equals(dataType)) {
+            // 单类型同步
+            XiaomiSyncResult result = xiaomiHealthService.sync(memberId, dataType);
+            data.put("dataType", dataType);
+            data.put("result", result);
+            if (!result.isSuccess()) {
+                return Result.error(result.getError());
+            }
+        } else {
+            // 全量同步
+            List<XiaomiSyncResult> results = xiaomiHealthService.syncAll(memberId);
+            data.put("results", results);
+            boolean allOk = results.stream().allMatch(XiaomiSyncResult::isSuccess);
+            if (!allOk) {
+                return Result.error("部分数据类型同步失败,详见 results");
+            }
+        }
+        return Result.success(data);
+    }
+
+    // ==================== 撤销授权 ====================
+
+    /**
+     * 撤销小米运动健康授权(合规必需):清除本地 token 记录(远端撤销由 XiaomiAuthService 处理)。
+     *
+     * <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 = xiaomiAuthService.revoke(memberId);
+        Map<String, Object> data = new 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 = xiaomiAuthService.statusJson(memberId);
+        Map<String, Object> data = new 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"));
+        data.put("expiresAt", statusJson.get("expiresAt"));
+        data.put("scope", statusJson.get("scope"));
+        return Result.success(data);
+    }
+}

+ 43 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/XiaomiHealthToken.java

@@ -0,0 +1,43 @@
+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;
+
+/**
+ * 小米运动健康云服务接口凭证表(X1 数据模型)。
+ *
+ * <p>仅后端持有 token(加密存库),不落小程序。小米运动健康数据服务(云服务接口)
+ * 使用此凭证访问 hlthopen.io.mi.com 的采样数据与订阅事件(SAMPLESET_EVENT)。
+ *
+ * @author XiaomiHealthService(X2 OAuth 模块)
+ */
+@Data
+@TableName("xiaomi_health_tokens")
+public class XiaomiHealthToken implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 家庭成员ID(family_members.id),唯一绑定 */
+    private Long memberId;
+
+    /** 接口访问凭证(加密存库,未批复前为 mock 生成的占位 token) */
+    private String accessToken;
+
+    /** 刷新令牌(加密存库) */
+    private String refreshToken;
+
+    /** 过期时间 */
+    private Date expiresAt;
+
+    /** 授权范围(scope,如 steps,sleep,heart_rate,weight) */
+    private String scope;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 25 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/XiaomiHealthTokenMapper.java

@@ -0,0 +1,25 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.XiaomiHealthToken;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import java.util.List;
+
+/**
+ * 小米健康 token Mapper(X1 数据模型)。
+ */
+@Mapper
+public interface XiaomiHealthTokenMapper extends BaseMapper<XiaomiHealthToken> {
+
+    /** 根据家庭成员ID查找最新的 token 记录 */
+    @Select("SELECT * FROM xiaomi_health_tokens WHERE member_id = #{memberId} " +
+            "ORDER BY id DESC LIMIT 1")
+    XiaomiHealthToken selectLatestByMemberId(@Param("memberId") Long memberId);
+
+    /** 根据家庭成员ID查找所有 token 记录(按创建时间倒序) */
+    @Select("SELECT * FROM xiaomi_health_tokens WHERE member_id = #{memberId} " +
+            "ORDER BY id DESC")
+    List<XiaomiHealthToken> selectByMemberId(@Param("memberId") Long memberId);
+}

+ 239 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/XiaomiAuthService.java

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

+ 35 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/XiaomiHealthTokenService.java

@@ -0,0 +1,35 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.XiaomiHealthToken;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 小米运动健康云服务接口 token 存取(X1 数据模型)。
+ *
+ * <p>凭证仅后端持有:存取层统一在此封装「加密落库」,调用方(XiaomiAuthService)
+ * 只接触明文凭证,本接口负责把明文加密为密文后写入 {@code xiaomi_health_tokens},
+ * 读取时解密回明文。不落小程序、不进日志。</p>
+ */
+public interface XiaomiHealthTokenService {
+
+    /**
+     * 覆盖/保存某成员的小米健康 token(幂等:同一 memberId 只保留最新一条)。
+     * 入参均为明文,本方法负责加密后落库。
+     */
+    XiaomiHealthToken saveToken(Long memberId, String accessToken, String refreshToken,
+                                Date expiresAt, String scope);
+
+    /** 获取某成员最新 token(自动解密返回明文;无记录返回 null) */
+    XiaomiHealthToken getLatestPlain(Long memberId);
+
+    /** 是否有有效授权(存在记录且未过期) */
+    boolean isAuthorized(Long memberId);
+
+    /** 撤销:删除该成员的全部 token 记录(调用方负责远端撤销) */
+    void revoke(Long memberId);
+
+    /** 列出来源为 xiaomi_health 的健康数据源记录列表 */
+    List<XiaomiHealthToken> listTokens(Long memberId);
+}

+ 108 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/XiaomiHealthTokenServiceImpl.java

@@ -0,0 +1,108 @@
+package com.etotem.cfc.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.crypto.AesUtil;
+import com.etotem.cfc.entity.XiaomiHealthToken;
+import com.etotem.cfc.mapper.XiaomiHealthTokenMapper;
+import com.etotem.cfc.service.XiaomiHealthTokenService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 小米健康 token 存取实现(X1 数据模型)。
+ *
+ * <p>token 仅后端持有:写入前经 {@link AesUtil} 加密,读取时解密;不落小程序、不进日志。</p>
+ */
+@Service
+@Slf4j
+public class XiaomiHealthTokenServiceImpl implements XiaomiHealthTokenService {
+
+    @Resource
+    private XiaomiHealthTokenMapper tokenMapper;
+
+    @Override
+    @Transactional
+    public XiaomiHealthToken saveToken(Long memberId, String accessToken, String refreshToken,
+                                       Date expiresAt, String scope) {
+        XiaomiHealthToken existing = tokenMapper.selectLatestByMemberId(memberId);
+        Date now = new Date();
+        try {
+            String encryptedAccess = AesUtil.encrypt(accessToken);
+            String encryptedRefresh = AesUtil.encrypt(refreshToken);
+            if (existing != null) {
+                existing.setAccessToken(encryptedAccess);
+                existing.setRefreshToken(encryptedRefresh);
+                existing.setExpiresAt(expiresAt);
+                existing.setScope(scope);
+                existing.setUpdatedAt(now);
+                tokenMapper.updateById(existing);
+                return existing;
+            }
+            XiaomiHealthToken token = new XiaomiHealthToken();
+            token.setMemberId(memberId);
+            token.setAccessToken(encryptedAccess);
+            token.setRefreshToken(encryptedRefresh);
+            token.setExpiresAt(expiresAt);
+            token.setScope(scope);
+            token.setCreatedAt(now);
+            token.setUpdatedAt(now);
+            tokenMapper.insert(token);
+            return token;
+        } catch (Exception e) {
+            log.error("小米 token 加密/存储失败: memberId={}, error={}", memberId, e.getMessage());
+            throw new RuntimeException("token 存储异常", e);
+        }
+    }
+
+    @Override
+    public XiaomiHealthToken getLatestPlain(Long memberId) {
+        XiaomiHealthToken encrypted = tokenMapper.selectLatestByMemberId(memberId);
+        if (encrypted == null) {
+            return null;
+        }
+        try {
+            XiaomiHealthToken plain = new XiaomiHealthToken();
+            plain.setId(encrypted.getId());
+            plain.setMemberId(encrypted.getMemberId());
+            plain.setAccessToken(AesUtil.decrypt(encrypted.getAccessToken()));
+            plain.setRefreshToken(AesUtil.decrypt(encrypted.getRefreshToken()));
+            plain.setExpiresAt(encrypted.getExpiresAt());
+            plain.setScope(encrypted.getScope());
+            plain.setCreatedAt(encrypted.getCreatedAt());
+            plain.setUpdatedAt(encrypted.getUpdatedAt());
+            return plain;
+        } catch (Exception e) {
+            log.error("小米 token 解密失败: memberId={}, error={}", memberId, e.getMessage());
+            return null;
+        }
+    }
+
+    @Override
+    public boolean isAuthorized(Long memberId) {
+        XiaomiHealthToken token = getLatestPlain(memberId);
+        if (token == null) {
+            return false;
+        }
+        // 无过期时间视为长期有效;有则必须晚于当前时间
+        return token.getExpiresAt() == null || token.getExpiresAt().after(new Date());
+    }
+
+    @Override
+    @Transactional
+    public void revoke(Long memberId) {
+        // 删除该成员全部本地凭证(远端撤销由 XiaomiAuthService 负责)
+        tokenMapper.delete(new LambdaQueryWrapper<XiaomiHealthToken>()
+                .eq(XiaomiHealthToken::getMemberId, memberId));
+        log.info("已清除小米健康 token 记录: memberId={}", memberId);
+    }
+
+    @Override
+    public List<XiaomiHealthToken> listTokens(Long memberId) {
+        return tokenMapper.selectByMemberId(memberId);
+    }
+}

+ 38 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/xiaomi/XiaomiHealthApiClient.java

@@ -0,0 +1,38 @@
+package com.etotem.cfc.service.xiaomi;
+
+import java.util.List;
+
+/**
+ * 小米运动健康云端数据拉取通道(mock/real 二选一,配置文件开关切换,X5)。
+ *
+ * <p>凭证未批复前默认启用 mock({@code xiaomi.health.mock-enabled=true})生成模拟采样数据,
+ * 端到端全流程可测(授权 → token → 拉取 → 归一化 → 入库 → 前端展示)。</p>
+ *
+ * <p>凭证到位后切换为 false,走小米运动健康数据服务云服务接口(hlthopen.io.mi.com,
+ * HTTPS 标准 API + 分页 cursor + 订阅事件 SAMPLESET_EVENT)。</p>
+ *
+ * <p>合规红线:仅官方云服务接口;不引入境外聚合中转(如 Terra),不碰社区逆向接口
+ * (userId/passToken 仿冒 App)。</p>
+ */
+public interface XiaomiHealthApiClient {
+
+    /**
+     * 按数据类型拉取某成员的最新周期采样数据。
+     *
+     * @param memberId 家庭成员ID(family_members.id,仅能登录用户/成人)
+     * @param dataType 数据类型编码(XiaomiHealthDataType.type)
+     * @return 该类型数据点;无数据返回空列表
+     */
+    List<XiaomiHealthDataPoint> fetchByType(Long memberId, String dataType);
+
+    /**
+     * 拉取全部本期数据类型。
+     *
+     * @param memberId 家庭成员ID
+     * @return 各类型数据点列表
+     */
+    List<XiaomiHealthDataPoint> fetchAll(Long memberId);
+
+    /** 通道是否就绪(real 模式凭证齐备才为 true,mock 模式恒 true) */
+    boolean isAvailable();
+}

+ 101 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/xiaomi/XiaomiHealthDataPoint.java

@@ -0,0 +1,101 @@
+package com.etotem.cfc.service.xiaomi;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 小米运动健康单条采样数据点(归一化输入)。
+ *
+ * <p>值与单位按数据类型区分:步数=步、距离=km、心率=bpm、睡眠=小时、体重=kg;
+ * 归一化在 {@link XiaomiHealthService} 中按类型取相应百分位常模完成。</p>
+ */
+public class XiaomiHealthDataPoint 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;
+    }
+}

+ 55 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/xiaomi/XiaomiHealthDataType.java

@@ -0,0 +1,55 @@
+package com.etotem.cfc.service.xiaomi;
+
+/**
+ * 小米运动健康云服务开放数据类型。
+ *
+ * <p>小米手环(小米运动健康 App 承载新款)云服务接口返回的采样/订阅事件数据类型
+ * (SAMPLESET_EVENT)。映射关系见方案 v1.2 第十一节 11.3(body.* 子维,与华为一致)。</p>
+ *
+ * <p>本期接入:步数/睡眠/心率/体重;血氧/距离为备选。合规红线:仅官方云服务接口,
+ * 不碰社区逆向接口(userId/passToken 仿冒)。</p>
+ */
+public enum XiaomiHealthDataType {
+    STEP("step", "步数", "body.exercise", "steps"),
+    SLEEP("sleep", "睡眠", "body.sleep", "h"),
+    HEART_RATE("heart_rate", "心率", "body.immunity", "bpm"),
+    BODY_WEIGHT("body_weight", "体重", "body.nutrition,body.growth", "kg"),
+    DISTANCE("distance", "距离", "body.exercise", "km");
+
+    private final String type;
+    private final String label;
+    private final String dimensionCodes;
+    private final String unit;
+
+    XiaomiHealthDataType(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 XiaomiHealthDataType of(String type) {
+        for (XiaomiHealthDataType t : values()) {
+            if (t.type.equalsIgnoreCase(type)) {
+                return t;
+            }
+        }
+        return null;
+    }
+}

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/xiaomi/XiaomiHealthService.java

@@ -0,0 +1,31 @@
+package com.etotem.cfc.service.xiaomi;
+
+import java.util.List;
+
+/**
+ * 小米运动健康数据同步引擎(X3)。
+ *
+ * <p>云服务接口拉取(hlthopen.io.mi.com,采样数据/订阅事件 SAMPLESET_EVENT),
+ * 复用 {@code NormCompareEngine} 百分位带(P5/P25/P75/P95 → 偏低/正常/偏高)归一化,
+ * 映射 body.exercise/body.sleep/body.immunity/body.nutrition 入库
+ * {@code health_data_sources}(sourceType='xiaomi_health',dimensionCodes + parsedResult JSON)。</p>
+ */
+public interface XiaomiHealthService {
+
+    /**
+     * 同步指定类型的小米健康数据。
+     *
+     * @param memberId 家庭成员ID(family_members.id,仅能登录用户/成人)
+     * @param dataType 数据类型编码(XiaomiHealthDataType.type)
+     * @return 同步结果
+     */
+    XiaomiSyncResult sync(Long memberId, String dataType);
+
+    /**
+     * 同步全部本期数据类型。
+     *
+     * @param memberId 家庭成员ID
+     * @return 各类型同步结果列表
+     */
+    List<XiaomiSyncResult> syncAll(Long memberId);
+}

+ 88 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/xiaomi/XiaomiSyncResult.java

@@ -0,0 +1,88 @@
+package com.etotem.cfc.service.xiaomi;
+
+/**
+ * 小米健康数据同步单条结果。
+ */
+public class XiaomiSyncResult {
+
+    /** 数据类型 */
+    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;
+    }
+}

+ 205 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/xiaomi/impl/XiaomiHealthApiClientImpl.java

@@ -0,0 +1,205 @@
+package com.etotem.cfc.service.xiaomi.impl;
+
+import com.alibaba.fastjson.JSONObject;
+import com.etotem.cfc.service.XiaomiAuthService;
+import com.etotem.cfc.service.XiaomiHealthTokenService;
+import com.etotem.cfc.service.xiaomi.XiaomiHealthApiClient;
+import com.etotem.cfc.service.xiaomi.XiaomiHealthDataPoint;
+import com.etotem.cfc.service.xiaomi.XiaomiHealthDataType;
+import com.etotem.cfc.entity.XiaomiHealthToken;
+import lombok.extern.slf4j.Slf4j;
+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.util.StringUtils;
+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.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+
+/**
+ * 小米运动健康云端数据拉取通道实现(X5 Mock / 真实云服务接口)。
+ *
+ * <p>mock 模式:凭证未批复,生成确定性的模拟采样数据(步数/睡眠/心率/体重/距离),
+ * 并附加 BMI(体重归一化用)与 rawJson 审计。</p>
+ *
+ * <p>real 模式:调用 hlthopen.io.mi.com 云服务接口(采样数据 SAMPLESET_EVENT),
+ * 需携带 access_token(Bearer)。凭证到位前不启用;留 TODO 供真实联调。</p>
+ */
+@Slf4j
+@Service
+public class XiaomiHealthApiClientImpl implements XiaomiHealthApiClient {
+
+    @Value("${xiaomi.health.mock-enabled:true}")
+    private boolean mockEnabled;
+
+    @Value("${xiaomi.health.api-base-url:}")
+    private String apiBaseUrl;
+
+    @Value("${xiaomi.health.sync-days:7}")
+    private int syncDays;
+
+    @Resource
+    private XiaomiHealthTokenService tokenService;
+
+    @Resource
+    private XiaomiAuthService xiaomiAuthService;
+
+    @Resource
+    private RestTemplate restTemplate;
+
+    private static final String SOURCE_TYPE = "xiaomi_health";
+
+    @Override
+    public List<XiaomiHealthDataPoint> fetchByType(Long memberId, String dataType) {
+        if (mockEnabled) {
+            return mockFetch(memberId, dataType);
+        }
+        return realFetch(memberId, dataType);
+    }
+
+    @Override
+    public List<XiaomiHealthDataPoint> fetchAll(Long memberId) {
+        List<XiaomiHealthDataPoint> all = new ArrayList<>();
+        for (XiaomiHealthDataType type : XiaomiHealthDataType.values()) {
+            all.addAll(fetchByType(memberId, type.getType()));
+        }
+        return all;
+    }
+
+    @Override
+    public boolean isAvailable() {
+        if (mockEnabled) {
+            return true;
+        }
+        return StringUtils.hasText(apiBaseUrl);
+    }
+
+    // ==================== Mock 数据源(X5) ====================
+
+    private List<XiaomiHealthDataPoint> mockFetch(Long memberId, String dataType) {
+        XiaomiHealthDataType type = XiaomiHealthDataType.of(dataType);
+        if (type == null) {
+            return new ArrayList<>();
+        }
+        List<XiaomiHealthDataPoint> points = new ArrayList<>();
+        Random random = new Random(memberId == null ? 1 : memberId);
+        Calendar cal = Calendar.getInstance();
+        SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd");
+        for (int i = syncDays - 1; i >= 0; i--) {
+            cal.setTime(new Date());
+            cal.add(Calendar.DAY_OF_MONTH, -i);
+            Date day = cal.getTime();
+
+            XiaomiHealthDataPoint point = new XiaomiHealthDataPoint();
+            point.setDataType(dataType);
+            point.setCollectDate(df.format(day));
+            point.setCollectedAt(day);
+
+            // 确定性随机(以 memberId+日期为种子,保证同一天重复同步结果稳定)
+            Random dayRandom = new Random(memberId * 1000L + day.getTime() / 86400000L);
+            switch (type) {
+                case STEP:
+                    point.setValue(3000 + dayRandom.nextInt(12000));
+                    point.setUnit("steps");
+                    break;
+                case SLEEP:
+                    point.setValue(5.0 + dayRandom.nextInt(40) / 10.0); // 5.0 - 8.9 小时
+                    point.setUnit("h");
+                    break;
+                case HEART_RATE:
+                    point.setValue(55 + dayRandom.nextInt(35)); // 55 - 89 bpm 静息
+                    point.setUnit("bpm");
+                    break;
+                case BODY_WEIGHT:
+                    double weightKg = 55.0 + dayRandom.nextInt(20); // 55 - 74 kg
+                    point.setValue(weightKg);
+                    point.setUnit("kg");
+                    // 附加 BMI(体重归一化用),身高假设 1.68m
+                    double bmi = weightKg / (1.68 * 1.68);
+                    point.setAuxiliaryValue(Math.round(bmi * 10.0) / 10.0);
+                    point.setAuxiliaryLabel("BMI(kg/m²)");
+                    break;
+                case DISTANCE:
+                    point.setValue(2.0 + dayRandom.nextInt(80) / 10.0); // 2.0 - 9.9 km
+                    point.setUnit("km");
+                    break;
+                default:
+                    point.setValue(0);
+                    point.setUnit("");
+            }
+
+            // 原始审计 JSON(mock 标记 + 原始值)
+            JSONObject raw = new JSONObject();
+            raw.put("source", "mock");
+            raw.put("dataType", dataType);
+            raw.put("value", point.getValue());
+            raw.put("unit", point.getUnit());
+            raw.put("collectDate", point.getCollectDate());
+            point.setRawJson(raw.toJSONString());
+            points.add(point);
+        }
+        return points;
+    }
+
+    // ==================== 真实云服务接口(TODO 凭证批复后启用) ====================
+
+    private List<XiaomiHealthDataPoint> realFetch(Long memberId, String dataType) {
+        XiaomiHealthToken token = tokenService.getLatestPlain(memberId);
+        if (token == null || !StringUtils.hasText(token.getAccessToken())) {
+            log.warn("xiaomi 真实通道:成员无有效 token: memberId={}", memberId);
+            return new ArrayList<>();
+        }
+        // 若即将过期则尝试刷新(幂等)
+        if (token.getExpiresAt() != null && token.getExpiresAt().getTime() - System.currentTimeMillis() < 5 * 60 * 1000L) {
+            try {
+                xiaomiAuthService.refreshToken(memberId);
+                token = tokenService.getLatestPlain(memberId);
+            } catch (Exception e) {
+                log.warn("xiaomi token 刷新失败(继续用旧 token 尝试): {}", e.getMessage());
+            }
+        }
+
+        List<XiaomiHealthDataPoint> points = new ArrayList<>();
+        try {
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+            headers.setBearerAuth(token.getAccessToken());
+
+            Map<String, Object> body = new HashMap<>();
+            body.put("dataType", dataType);
+            body.put("startDate", daysAgo(syncDays - 1));
+            body.put("endDate", daysAgo(0));
+
+            // TODO 真实联调:hlthopen.io.mi.com 云服务接口(采样数据/订阅事件 SAMPLESET_EVENT)
+            // 响应结构按小米方接口文档解析(分页 cursor 拉取),此处以 JSON 占位。
+            String url = apiBaseUrl + "/v1/health/data";
+            ResponseEntity<String> resp = restTemplate.postForEntity(url,
+                    new HttpEntity<>(body, headers), String.class);
+            log.info("xiaomi 真实通道拉取: memberId={}, type={}, status={}(解析待小米接口文档)",
+                    memberId, dataType, resp.getStatusCode());
+            // 解析逻辑:TODO 凭证批复后按小米响应 JSON 结构填充 points
+            // 合规红线:仅官方云服务接口;不碰社区逆向接口。
+        } catch (Exception e) {
+            log.error("xiaomi 真实通道拉取失败: memberId={}, type={}, error={}",
+                    memberId, dataType, e.getMessage());
+        }
+        return points;
+    }
+
+    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());
+    }
+}

+ 243 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/xiaomi/impl/XiaomiHealthServiceImpl.java

@@ -0,0 +1,243 @@
+package com.etotem.cfc.service.xiaomi.impl;
+
+import com.alibaba.fastjson.JSONObject;
+import com.etotem.cfc.entity.HealthDataSourceRecord;
+import com.etotem.cfc.service.HealthDataSourceRecordService;
+import com.etotem.cfc.service.XiaomiHealthTokenService;
+import com.etotem.cfc.service.attribution.model.IndicatorReading;
+import com.etotem.cfc.service.attribution.model.NormEntry;
+import com.etotem.cfc.service.xiaomi.XiaomiHealthApiClient;
+import com.etotem.cfc.service.xiaomi.XiaomiHealthDataPoint;
+import com.etotem.cfc.service.xiaomi.XiaomiHealthDataType;
+import com.etotem.cfc.service.xiaomi.XiaomiHealthService;
+import com.etotem.cfc.service.xiaomi.XiaomiSyncResult;
+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;
+
+/**
+ * 小米运动健康数据同步引擎实现(X3)。
+ *
+ * <p>按数据类型拉取采样数据,复用 {@code NormCompareEngine} 百分位带(P5/P25/P50/P75/P95)
+ * 归一化,映射 body.* 子维入库 {@code health_data_sources}(sourceType='xiaomi_health',
+ * dimensionCodes + parsedResult JSON)。复用表,未新建。</p>
+ *
+ * <p>归一化口径与华为接入(HealthSyncServiceImpl)一致:同类型同常模、百分位带 → 0-100
+ * 体能得分(BAND_SCORE),保证多生态数据可比。凭证未批复时默认 mock 通道(X5)。</p>
+ */
+@Slf4j
+@Service
+public class XiaomiHealthServiceImpl implements XiaomiHealthService {
+
+    @Resource
+    private XiaomiHealthApiClient xiaomiHealthApiClient;
+
+    @Resource
+    private XiaomiHealthTokenService xiaomiHealthTokenService;
+
+    @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");
+
+    // ==================== 各数据类型默认百分位常模(成人) ====================
+    // 与华为接入口径一致(同类型同常模),保证多生态数据可比。
+    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 XiaomiSyncResult sync(Long memberId, String dataType) {
+        XiaomiSyncResult result = new XiaomiSyncResult();
+        result.setDataType(dataType);
+
+        XiaomiHealthDataType type = XiaomiHealthDataType.of(dataType);
+        if (type == null) {
+            result.setSuccess(false);
+            result.setError("不支持的数据类型: " + dataType);
+            return result;
+        }
+
+        // 权限/授权校验:仅已绑定成员可拉取
+        if (!isBound(memberId)) {
+            result.setSuccess(false);
+            result.setError("成员未绑定小米账号,请先获取授权");
+            return result;
+        }
+
+        List<XiaomiHealthDataPoint> points;
+        try {
+            points = xiaomiHealthApiClient.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;
+        }
+
+        // 取最新一条数据点作为本期代表值(云端按日聚合)
+        XiaomiHealthDataPoint 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("xiaomi_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", "xiaomi_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("xiaomi_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<XiaomiSyncResult> syncAll(Long memberId) {
+        List<XiaomiSyncResult> results = new ArrayList<>();
+        for (XiaomiHealthDataType type : XiaomiHealthDataType.values()) {
+            results.add(sync(memberId, type.getType()));
+        }
+        return results;
+    }
+
+    /** 判断指定成员当前是否持有有效小米授权(token 记录存在且未过期)。 */
+    private boolean isBound(Long memberId) {
+        if (memberId == null) {
+            return false;
+        }
+        return xiaomiHealthTokenService.isAuthorized(memberId);
+    }
+
+    /** 按数据类型选取用于归一化的数值:体重使用 BMI(auxiliaryValue),其余使用 value。 */
+    private double selectNumericValue(XiaomiHealthDataType type, XiaomiHealthDataPoint point) {
+        if (type == XiaomiHealthDataType.BODY_WEIGHT && point.getAuxiliaryValue() != null) {
+            return point.getAuxiliaryValue();
+        }
+        return point.getValue();
+    }
+
+    private List<NormEntry> buildNorms(XiaomiHealthDataType type) {
+        double[] bands = DEFAULT_NORMS.get(type.getType());
+        List<NormEntry> norms = new ArrayList<>();
+        NormEntry n = new NormEntry();
+        n.setNormType("xiaomi_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("xiaomi_health_default");
+        norms.add(n);
+        return norms;
+    }
+}

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

@@ -214,6 +214,32 @@ app:
   attribution:
     enabled: false  # 家庭归因全流程开关(E12 合规后置:默认 false,仅测试/mock 数据;真实用户放行=阶段四 D1)
 
+# 小米运动健康云服务接口(XiaomiHealthService,方案 v1.2 第十一节)
+# 凭证未批复前用 mock 通道跑通全链路;批复后 mock-enabled=false 切真实云服务接口(hlthopen.io.mi.com)。
+# 合规红线:仅用官方云服务认证鉴权,不碰社区逆向接口(userId/passToken 仿冒),不引入境外聚合中转。
+xiaomi:
+  health:
+    # 通道开关:true=mock(凭证未批复前的模拟数据源),false=真实云服务接口
+    mock-enabled: ${XIAOMI_HEALTH_MOCK_ENABLED:true}
+    # 云服务认证鉴权(OAuth2)参数——待小米凭证批复后由环境变量注入
+    client-id: ${XIAOMI_CLIENT_ID:}
+    client-secret: ${XIAOMI_CLIENT_SECRET:}
+    redirect-uri: ${XIAOMI_REDIRECT_URI:https://cfc.etotem.com.cn/api/xiaomi-health/callback}
+    authorize-url: ${XIAOMI_AUTHORIZE_URL:https://account.xiaomi.com/oauth2/authorize}
+    token-url: ${XIAOMI_TOKEN_URL:https://account.xiaomi.com/oauth2/token}
+    revoke-url: ${XIAOMI_REVOKE_URL:https://hlthopen.io.mi.com/v1/authorization/revoke}
+    # 云服务接口(采样数据/订阅事件 SAMPLESET_EVENT)
+    api-base-url: ${XIAOMI_API_BASE_URL:https://hlthopen.io.mi.com}
+    # 需要申请的数据权限 scope(以小米方批复为准)
+    scope: ${XIAOMI_SCOPE:steps,sleep,heart_rate,weight}
+    # token 落库加密密钥(AES-128,仅后端持有;生产环境必须覆盖)
+    encrypt-key: ${XIAOMI_TOKEN_ENCRYPT_KEY:cfc-xiaomi-token-key-2026}
+    # 拉取窗口(天):每次同步拉取最近 N 天的采样数据
+    sync-days: ${XIAOMI_SYNC_DAYS:7}
+    # 同步请求超时(毫秒)
+    connect-timeout-ms: ${XIAOMI_CONNECT_TIMEOUT_MS:5000}
+    read-timeout-ms: ${XIAOMI_READ_TIMEOUT_MS:15000}
+
 # 华为运动健康开放平台(HuaweiHealthService,方案 v1.2 第六节 D1-D5)
 # 凭证未批复前用 mock 通道跑通全链路;批复后 mock-enabled=false 切真实 Health Service Kit 云侧 REST 接口。
 # token 仅后端持有(AES/GCM 加密存库),不落小程序。