소스 검색

feat: ETL pipeline for Apple Health XML + 3 correlational analyses (workout-sleep, HRV-fatigue, menstrual profile)

17846405080 1 개월 전
부모
커밋
6058723154

+ 12 - 0
campus_health_app/analytics/_init_daily_metrics.py

@@ -0,0 +1,12 @@
+import pymysql
+conn = pymysql.connect(host='localhost', port=3306, user='analytics', password='123456', database='campus_health', charset='utf8mb4')
+with open(r"D:\workspace\Agi\zrd-nb666\campus_health_app\backend\database\daily_metrics.sql", encoding='utf-8') as f:
+    sql = f.read()
+with conn.cursor() as cur:
+    for stmt in sql.split(';'):
+        s = stmt.strip()
+        if s:
+            cur.execute(s)
+conn.commit()
+print('table daily_health_metrics created')
+conn.close()

+ 180 - 0
campus_health_app/analytics/analyze_health.py

@@ -0,0 +1,180 @@
+"""
+analyze_health.py
+=================
+三个分析:
+1. 运动与睡眠的关系
+2. HRV / 静息心率作为疲劳指标
+3. 经期周期对心率/睡眠/运动的影响
+
+输出:终端报告 + output/ 下的 PNG 图
+"""
+from __future__ import annotations
+from pathlib import Path
+import pymysql
+import pandas as pd
+import numpy as np
+import matplotlib.pyplot as plt
+import matplotlib
+matplotlib.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei']
+matplotlib.rcParams['axes.unicode_minus'] = False
+
+OUT = Path(__file__).parent / "output"
+OUT.mkdir(exist_ok=True)
+
+c = pymysql.connect(host='localhost', port=3306, user='analytics', password='123456', database='campus_health')
+df = pd.read_sql("SELECT * FROM daily_health_metrics WHERE metric_date >= '2024-09-01'", c)
+c.close()
+df['metric_date'] = pd.to_datetime(df['metric_date'])
+df = df.sort_values('metric_date').reset_index(drop=True)
+print(f"加载 {len(df)} 天数据 ({df['metric_date'].min().date()} ~ {df['metric_date'].max().date()})")
+
+# 只保留有静息心率的天作为有效样本
+valid = df[df['resting_heart_rate'].notna() & df['hrv_sdnn'].notna()].copy()
+print(f"有完整 HR 数据的天: {len(valid)}")
+
+# ============ 分析 1:运动 vs 睡眠 ============
+print("\n" + "="*60)
+print("分析 1:运动与睡眠的关系")
+print("="*60)
+
+# 当晚睡眠 vs 当天是否锻炼
+df['is_workout'] = df['workout_count'] > 0
+sleep_valid = df[df['sleep_duration_hours'] > 0]
+g = sleep_valid.groupby('is_workout').agg(
+    nights=('metric_date', 'count'),
+    sleep_h=('sleep_duration_hours', 'mean'),
+    deep_h=('deep_sleep_hours', 'mean'),
+    rem_h=('rem_sleep_hours', 'mean'),
+    deep_pct=('deep_sleep_hours', lambda x: (x / sleep_valid.loc[x.index, 'sleep_duration_hours'] * 100).mean()),
+)
+g.index = ['非锻炼日', '锻炼日']
+print("\n[1a] 锻炼日 vs 非锻炼日(睡眠指标):")
+print(g.round(2).to_string())
+
+# 相关性:当天活跃能量 vs 当晚深睡占比
+df['deep_pct'] = df['deep_sleep_hours'] / df['sleep_duration_hours'].replace(0, np.nan)
+corr_df = df[['active_energy_kcal', 'exercise_minutes', 'deep_pct', 'sleep_duration_hours',
+              'resting_heart_rate', 'hrv_sdnn']].dropna()
+print("\n[1b] 关键指标 Pearson 相关系数(n=%d):" % len(corr_df))
+cols = ['active_energy_kcal', 'exercise_minutes', 'deep_pct', 'sleep_duration_hours', 'resting_heart_rate', 'hrv_sdnn']
+print(corr_df[cols].corr().round(3).to_string())
+
+# 图 1:锻炼日 vs 非锻炼日对比柱状图
+fig, axes = plt.subplots(1, 2, figsize=(12, 5))
+axes[0].bar(['非锻炼日', '锻炼日'], [g.loc['非锻炼日','deep_h'], g.loc['锻炼日','deep_h']], color=['#888', '#e74c3c'])
+axes[0].set_title('深睡时长(小时)')
+axes[0].set_ylabel('小时')
+for i, v in enumerate([g.loc['非锻炼日','deep_h'], g.loc['锻炼日','deep_h']]):
+    axes[0].text(i, v+0.02, f'{v:.2f}h', ha='center')
+
+axes[1].bar(['非锻炼日', '锻炼日'], [g.loc['非锻炼日','rem_h'], g.loc['锻炼日','rem_h']], color=['#888', '#3498db'])
+axes[1].set_title('REM 时长(小时)')
+for i, v in enumerate([g.loc['非锻炼日','rem_h'], g.loc['锻炼日','rem_h']]):
+    axes[1].text(i, v+0.02, f'{v:.2f}h', ha='center')
+plt.suptitle('锻炼日 vs 非锻炼日的睡眠结构对比')
+plt.tight_layout()
+plt.savefig(OUT / '01_workout_vs_sleep.png', dpi=120)
+plt.close()
+print(f"\n[图] 保存: output/01_workout_vs_sleep.png")
+
+# ============ 分析 2:HRV / 疲劳 ============
+print("\n" + "="*60)
+print("分析 2:HRV / 静息心率 作为疲劳指标")
+print("="*60)
+
+hrv_valid = df[df['hrv_sdnn'].notna()]
+print(f"\n[2a] HRV 分布(n={len(hrv_valid)}):")
+print(f"  均值={hrv_valid['hrv_sdnn'].mean():.1f} ms  中位={hrv_valid['hrv_sdnn'].median():.1f}  "
+      f"P25={hrv_valid['hrv_sdnn'].quantile(0.25):.1f}  P75={hrv_valid['hrv_sdnn'].quantile(0.75):.1f}")
+
+# 疲劳日 vs 非疲劳日
+fatigue_days = df[df['fatigue_reported'] == True]
+non_fatigue = df[(df['fatigue_reported'] == False) & df['hrv_sdnn'].notna()]
+print(f"\n[2b] 你记录了 {len(fatigue_days)} 个疲劳日")
+if len(fatigue_days) >= 2:
+    print(f"  疲劳日 HRV 均值: {fatigue_days['hrv_sdnn'].mean():.1f} ms (n={fatigue_days['hrv_sdnn'].notna().sum()})")
+    print(f"  非疲劳日 HRV 均值: {non_fatigue['hrv_sdnn'].mean():.1f} ms (n={len(non_fatigue)})")
+    print(f"  疲劳日静息心率均值: {fatigue_days['resting_heart_rate'].mean():.1f} bpm")
+    print(f"  非疲劳日静息心率均值: {non_fatigue['resting_heart_rate'].mean():.1f} bpm")
+
+# 图 2:HRV 时间序列 + 疲劳日标注
+fig, ax = plt.subplots(figsize=(14, 5))
+ax.plot(hrv_valid['metric_date'], hrv_valid['hrv_sdnn'], color='#2ecc71', linewidth=1, alpha=0.7, label='HRV (ms)')
+ax.scatter(fatigue_days['metric_date'], fatigue_days['hrv_sdnn'], color='red', s=80, zorder=5, label='记录为疲劳')
+ax.set_title('HRV 时间序列(红点 = 你标记为疲劳的日子)')
+ax.set_ylabel('HRV SDNN (ms)')
+ax.legend()
+plt.tight_layout()
+plt.savefig(OUT / '02_hrv_fatigue.png', dpi=120)
+plt.close()
+print(f"[图] 保存: output/02_hrv_fatigue.png")
+
+# ============ 分析 3:经期影响 ============
+print("\n" + "="*60)
+print("分析 3:经期周期对心率/睡眠/运动的影响")
+print("="*60)
+
+# 找经期的起始日(从非经期到经期的跳变)
+df_sorted = df.sort_values('metric_date').reset_index(drop=True)
+periods = []
+in_period = False
+for i, row in df_sorted.iterrows():
+    if row['is_menstruating'] and not in_period:
+        periods.append(row['metric_date'])
+        in_period = True
+    elif not row['is_menstruating']:
+        in_period = False
+print(f"\n[3a] 识别到 {len(periods)} 次经期开始")
+
+# 对每次经期,计算前后各 7 天的指标
+window_records = []
+for start in periods:
+    for offset in range(-7, 8):
+        d = start + pd.Timedelta(days=offset)
+        row = df_sorted[df_sorted['metric_date'] == d]
+        if len(row):
+            r = row.iloc[0]
+            window_records.append({
+                'offset': offset,
+                'resting_hr': r['resting_heart_rate'],
+                'hrv': r['hrv_sdnn'],
+                'sleep_h': r['sleep_duration_hours'],
+                'workout': r['workout_count'],
+                'active_kcal': r['active_energy_kcal'],
+            })
+w = pd.DataFrame(window_records)
+if len(w):
+    profile = w.groupby('offset').agg(
+        n=('resting_hr', 'count'),
+        resting_hr=('resting_hr', 'mean'),
+        hrv=('hrv', 'mean'),
+        sleep_h=('sleep_h', 'mean'),
+        workout=('workout', 'mean'),
+    ).round(2)
+    print("\n[3b] 经期前后 7 天平均指标(offset=0 为经期第 1 天):")
+    print(profile.to_string())
+
+    # 图 3:经期 profile
+    fig, axes = plt.subplots(2, 2, figsize=(13, 8))
+    axes[0,0].plot(profile.index, profile['resting_hr'], 'o-', color='#e74c3c')
+    axes[0,0].axvline(0, color='red', ls='--', alpha=0.5)
+    axes[0,0].set_title('静息心率 (bpm)')
+    axes[0,1].plot(profile.index, profile['hrv'], 'o-', color='#2ecc71')
+    axes[0,1].axvline(0, color='red', ls='--', alpha=0.5)
+    axes[0,1].set_title('HRV (ms)')
+    axes[1,0].plot(profile.index, profile['sleep_h'], 'o-', color='#3498db')
+    axes[1,0].axvline(0, color='red', ls='--', alpha=0.5)
+    axes[1,0].set_title('睡眠时长 (h)')
+    axes[1,1].plot(profile.index, profile['workout'], 'o-', color='#f39c12')
+    axes[1,1].axvline(0, color='red', ls='--', alpha=0.5)
+    axes[1,1].set_title('当日锻炼次数')
+    for ax in axes.flat:
+        ax.set_xlabel('距经期开始天数')
+        ax.grid(alpha=0.3)
+    plt.suptitle('经期前后 7 天生理指标变化曲线')
+    plt.tight_layout()
+    plt.savefig(OUT / '03_menstrual_profile.png', dpi=120)
+    plt.close()
+    print(f"[图] 保存: output/03_menstrual_profile.png")
+
+print("\n✓ 全部分析完成")

+ 257 - 0
campus_health_app/analytics/build_daily_metrics.py

@@ -0,0 +1,257 @@
+"""
+build_daily_metrics.py
+======================
+流式解析 Apple Health export.xml,按日聚合,UPSERT 到 daily_health_metrics 表。
+
+幂等:重复跑只覆盖当天数据,不会产生重复行。
+"""
+from __future__ import annotations
+
+from collections import defaultdict
+from datetime import datetime, timedelta
+from pathlib import Path
+import sys
+
+import pymysql
+from rich.console import Console
+
+console = Console()
+
+XML_PATH = Path(r"D:\workspace\Agi\zrd-nb666\campus_health_app\analytics\apple_health\apple_health_export\瀵煎嚭.xml")
+
+DB = dict(host='localhost', port=3306, user='analytics', password='123456',
+          database='campus_health', charset='utf8mb4', autocommit=False)
+
+# 关心的 Record type 到聚合字段的映射
+QUANTITY_TYPES = {
+    "HKQuantityTypeIdentifierStepCount": ("step_count", "sum"),
+    "HKQuantityTypeIdentifierActiveEnergyBurned": ("active_energy_kcal", "sum"),
+    "HKQuantityTypeIdentifierBasalEnergyBurned": ("basal_energy_kcal", "sum"),
+    "HKQuantityTypeIdentifierAppleExerciseTime": ("exercise_minutes", "sum"),
+    "HKQuantityTypeIdentifierRestingHeartRate": ("resting_heart_rate", "avg"),
+    "HKQuantityTypeIdentifierHeartRateVariabilitySDNN": ("hrv_sdnn", "avg"),
+    "HKQuantityTypeIdentifierHeartRate": ("avg_heart_rate", "avg"),
+    "HKQuantityTypeIdentifierOxygenSaturation": ("avg_oxygen_saturation", "avg_pct"),
+    "HKQuantityTypeIdentifierRespiratoryRate": ("avg_respiratory_rate", "avg"),
+    "HKQuantityTypeIdentifierAppleSleepingWristTemperature": ("wrist_temp_c", "avg"),
+}
+
+SLEEP_VALUES = {
+    "HKCategoryValueSleepAnalysisAsleepDeep": "deep_sleep_hours",
+    "HKCategoryValueSleepAnalysisAsleepREM": "rem_sleep_hours",
+    "HKCategoryValueSleepAnalysisAsleepCore": "core_sleep_hours",
+    "HKCategoryValueSleepAnalysisAsleepUnspecified": "core_sleep_hours",
+    "HKCategoryValueSleepAnalysisInBed": "sleep_in_bed_hours",
+}
+
+
+def parse_dt(s: str | None) -> datetime | None:
+    if not s:
+        return None
+    # "2026-09-11 14:30:52 +0800"
+    try:
+        return datetime.strptime(s, "%Y-%m-%d %H:%M:%S %z")
+    except ValueError:
+        return None
+
+
+def main():
+    console.print(f"[bold]Parsing[/bold] {XML_PATH.name} ({XML_PATH.stat().st_size/1024/1024:.0f} MB)...")
+
+    # daily[date_str][field] = value (sum) 或 (sum, count) 用于 avg
+    daily: dict[str, dict] = defaultdict(lambda: defaultdict(float))
+    counts: dict[str, dict] = defaultdict(lambda: defaultdict(int))
+    sleep_hours: dict[str, dict] = defaultdict(lambda: defaultdict(float))
+    workouts: dict[str, set] = defaultdict(set)
+    workout_days: dict[str, int] = defaultdict(int)
+    menstrual_days: dict[str, str] = {}
+    flag_days: dict[str, set] = defaultdict(set)  # fatigue, bloating, mood
+
+    from xml.etree.ElementTree import iterparse
+    context = iterparse(str(XML_PATH), events=("end",))
+    n_records = 0
+    n_workouts = 0
+
+    for event, elem in context:
+        tag = elem.tag
+        if tag == "Record":
+            rtype = elem.get("type", "")
+            # 跳过非关心类型
+            if rtype in QUANTITY_TYPES:
+                field, agg = QUANTITY_TYPES[rtype]
+                try:
+                    val = float(elem.get("value", "0"))
+                except ValueError:
+                    elem.clear()
+                    continue
+                dt = parse_dt(elem.get("startDate"))
+                if not dt:
+                    elem.clear()
+                    continue
+                d = dt.strftime("%Y-%m-%d")
+                if agg == "sum":
+                    daily[d][field] += val
+                elif agg == "avg_pct":
+                    # 血氧 value 是 0.96 这种,转百分比;clip 到 0-100 防异常值
+                    pct = max(0.0, min(100.0, val * 100))
+                    daily[d][field] += pct
+                    counts[d][field] += 1
+                else:  # avg
+                    daily[d][field] += val
+                    counts[d][field] += 1
+                n_records += 1
+
+            elif rtype == "HKCategoryTypeIdentifierSleepAnalysis":
+                val = elem.get("value", "")
+                bucket = SLEEP_VALUES.get(val)
+                if not bucket:
+                    elem.clear()
+                    continue
+                sd = parse_dt(elem.get("startDate"))
+                ed = parse_dt(elem.get("endDate"))
+                if not sd or not ed:
+                    elem.clear()
+                    continue
+                hours = (ed - sd).total_seconds() / 3600
+                if hours <= 0 or hours > 24:
+                    elem.clear()
+                    continue
+                # 睡眠归到醒来那天(endDate 的日期)
+                d = ed.strftime("%Y-%m-%d")
+                sleep_hours[d][bucket] += hours
+                if bucket != "sleep_in_bed_hours":
+                    sleep_hours[d]["sleep_duration_hours"] += hours
+                n_records += 1
+
+            elif rtype == "HKCategoryTypeIdentifierMenstrualFlow":
+                val = elem.get("value", "")
+                sd = parse_dt(elem.get("startDate"))
+                if not sd:
+                    elem.clear()
+                    continue
+                d = sd.strftime("%Y-%m-%d")
+                # 按流量映射
+                flow_map = {
+                    "HKCategoryValueMenstrualFlowUnspecified": "unspecified",
+                    "HKCategoryValueMenstrualFlowLight": "light",
+                    "HKCategoryValueMenstrualFlowMedium": "medium",
+                    "HKCategoryValueMenstrualFlowHeavy": "heavy",
+                }
+                menstrual_days[d] = flow_map.get(val, "unspecified")
+                n_records += 1
+
+            elif rtype == "HKCategoryTypeIdentifierFatigue":
+                sd = parse_dt(elem.get("startDate"))
+                if sd:
+                    flag_days[sd.strftime("%Y-%m-%d")].add("fatigue")
+                n_records += 1
+            elif rtype == "HKCategoryTypeIdentifierBloating":
+                sd = parse_dt(elem.get("startDate"))
+                if sd:
+                    flag_days[sd.strftime("%Y-%m-%d")].add("bloating")
+                n_records += 1
+            elif rtype == "HKCategoryTypeIdentifierMoodChanges":
+                sd = parse_dt(elem.get("startDate"))
+                if sd:
+                    flag_days[sd.strftime("%Y-%m-%d")].add("mood")
+                n_records += 1
+
+            elem.clear()
+
+        elif tag == "Workout":
+            wtype = elem.get("workoutActivityType", "").replace("HKWorkoutActivityType", "")
+            sd = parse_dt(elem.get("startDate"))
+            if sd:
+                d = sd.strftime("%Y-%m-%d")
+                workouts[d].add(wtype)
+                workout_days[d] += 1
+            n_workouts += 1
+            elem.clear()
+
+        # 定期报告进度
+        if n_records % 100000 == 0 and n_records:
+            console.print(f"  ... {n_records} records parsed, {len(daily)} days aggregated")
+
+    console.print(f"[green]✓ Parsed {n_records} records, {n_workouts} workouts, {len(daily)} days[/green]")
+
+    # 求 avg 字段的平均值
+    for d, fields in daily.items():
+        for field, total in list(fields.items()):
+            n = counts[d].get(field, 0)
+            if n > 0 and field in ("resting_heart_rate", "hrv_sdnn", "avg_heart_rate",
+                                   "avg_oxygen_saturation", "avg_respiratory_rate", "wrist_temp_c"):
+                fields[field] = round(total / n, 2)
+            else:
+                fields[field] = round(total, 2)
+
+    # 合并所有日期
+    all_dates = set(daily) | set(sleep_hours) | set(workout_days) | set(menstrual_days) | set(flag_days)
+    console.print(f"[green]✓ Total {len(all_dates)} unique days to upsert[/green]")
+
+    # 写库
+    conn = pymysql.connect(**DB)
+    sql = """
+    INSERT INTO daily_health_metrics (
+        metric_date, step_count, active_energy_kcal, basal_energy_kcal, exercise_minutes,
+        workout_count, workout_types, resting_heart_rate, hrv_sdnn, avg_heart_rate,
+        avg_oxygen_saturation, avg_respiratory_rate,
+        sleep_duration_hours, sleep_in_bed_hours, deep_sleep_hours, rem_sleep_hours, core_sleep_hours,
+        wrist_temp_c, is_menstruating, menstrual_flow,
+        fatigue_reported, bloating_reported, mood_changes
+    ) VALUES (
+        %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
+    )
+    ON DUPLICATE KEY UPDATE
+        step_count=VALUES(step_count), active_energy_kcal=VALUES(active_energy_kcal),
+        basal_energy_kcal=VALUES(basal_energy_kcal), exercise_minutes=VALUES(exercise_minutes),
+        workout_count=VALUES(workout_count), workout_types=VALUES(workout_types),
+        resting_heart_rate=VALUES(resting_heart_rate), hrv_sdnn=VALUES(hrv_sdnn),
+        avg_heart_rate=VALUES(avg_heart_rate), avg_oxygen_saturation=VALUES(avg_oxygen_saturation),
+        avg_respiratory_rate=VALUES(avg_respiratory_rate),
+        sleep_duration_hours=VALUES(sleep_duration_hours), sleep_in_bed_hours=VALUES(sleep_in_bed_hours),
+        deep_sleep_hours=VALUES(deep_sleep_hours), rem_sleep_hours=VALUES(rem_sleep_hours),
+        core_sleep_hours=VALUES(core_sleep_hours), wrist_temp_c=VALUES(wrist_temp_c),
+        is_menstruating=VALUES(is_menstruating), menstrual_flow=VALUES(menstrual_flow),
+        fatigue_reported=VALUES(fatigue_reported), bloating_reported=VALUES(bloating_reported),
+        mood_changes=VALUES(mood_changes)
+    """
+
+    rows = []
+    for d in sorted(all_dates):
+        f = daily.get(d, {})
+        s = sleep_hours.get(d, {})
+        rows.append((
+            d,
+            int(f.get("step_count", 0)),
+            f.get("active_energy_kcal", 0),
+            f.get("basal_energy_kcal", 0),
+            int(f.get("exercise_minutes", 0)),
+            workout_days.get(d, 0),
+            ",".join(sorted(workouts.get(d, []))) or None,
+            f.get("resting_heart_rate"),
+            f.get("hrv_sdnn"),
+            f.get("avg_heart_rate"),
+            f.get("avg_oxygen_saturation"),
+            f.get("avg_respiratory_rate"),
+            round(s.get("sleep_duration_hours", 0), 2),
+            round(s.get("sleep_in_bed_hours", 0), 2),
+            round(s.get("deep_sleep_hours", 0), 2),
+            round(s.get("rem_sleep_hours", 0), 2),
+            round(s.get("core_sleep_hours", 0), 2),
+            f.get("wrist_temp_c"),
+            d in menstrual_days,
+            menstrual_days.get(d),
+            "fatigue" in flag_days.get(d, set()),
+            "bloating" in flag_days.get(d, set()),
+            "mood" in flag_days.get(d, set()),
+        ))
+
+    with conn.cursor() as cur:
+        cur.executemany(sql, rows)
+    conn.commit()
+    console.print(f"[green bold]✓ Upserted {len(rows)} days into daily_health_metrics[/green bold]")
+    conn.close()
+
+
+if __name__ == "__main__":
+    main()

+ 58 - 0
campus_health_app/analytics/scan_health.py

@@ -0,0 +1,58 @@
+"""
+扫描 Apple Health export.xml,统计所有 Record type 和 Workout 的数量与时间范围。
+流式解析,不吃内存。
+"""
+import sys
+from collections import Counter
+from pathlib import Path
+from xml.etree.ElementTree import iterparse
+
+xml_path = Path(r"D:\workspace\Agi\zrd-nb666\campus_health_app\analytics\apple_health\apple_health_export\瀵煎嚭.xml")
+
+print(f"Parsing {xml_path.name} ({xml_path.stat().st_size/1024/1024:.1f} MB)...")
+
+record_types = Counter()
+workout_types = Counter()
+date_min = None
+date_max = None
+# 关心的类型样本
+samples = {}
+
+context = iterparse(str(xml_path), events=("end",))
+for event, elem in context:
+    tag = elem.tag
+    if tag == "Record":
+        rtype = elem.get("type", "")
+        record_types[rtype] += 1
+        # 时间范围
+        sd = elem.get("startDate", "")
+        if sd:
+            if date_min is None or sd < date_min:
+                date_min = sd
+            if date_max is None or sd > date_max:
+                date_max = sd
+        # 样本
+        if rtype not in samples:
+            samples[rtype] = {
+                "source": elem.get("sourceName"),
+                "value": elem.get("value"),
+                "unit": elem.get("unit"),
+                "date": sd,
+            }
+    elif tag == "Workout":
+        wtype = elem.get("workoutActivityType", "")
+        workout_types[wtype] += 1
+    elem.clear()
+
+print(f"\n=== 时间范围 ===")
+print(f"最早: {date_min}")
+print(f"最新: {date_max}")
+
+print(f"\n=== Record 类型(共 {len(record_types)} 种)===")
+for rtype, cnt in record_types.most_common():
+    s = samples.get(rtype, {})
+    print(f"{cnt:>8}  {rtype:60s}  e.g. {s.get('value','')} {s.get('unit','')}  via {s.get('source','')}")
+
+print(f"\n=== Workout 类型(共 {len(workout_types)} 种)===")
+for wtype, cnt in workout_types.most_common():
+    print(f"{cnt:>8}  {wtype}")

+ 48 - 0
campus_health_app/backend/database/daily_metrics.sql

@@ -0,0 +1,48 @@
+-- ========================================
+-- Apple Health 每日聚合指标表
+-- 每天一行,按日期分区
+-- ========================================
+CREATE TABLE IF NOT EXISTS `daily_health_metrics` (
+  `metric_date` DATE NOT NULL COMMENT '日期(主键)',
+
+  -- 运动
+  `step_count` INT DEFAULT 0 COMMENT '总步数',
+  `active_energy_kcal` DECIMAL(8,2) DEFAULT 0 COMMENT '活跃能量 kcal',
+  `basal_energy_kcal` DECIMAL(8,2) DEFAULT 0 COMMENT '静息能量 kcal',
+  `exercise_minutes` INT DEFAULT 0 COMMENT '锻炼分钟数',
+  `workout_count` INT DEFAULT 0 COMMENT '当日锻炼次数',
+  `workout_types` VARCHAR(255) DEFAULT NULL COMMENT '当日锻炼类型(逗号分隔)',
+
+  -- 心率
+  `resting_heart_rate` DECIMAL(5,1) DEFAULT NULL COMMENT '静息心率 bpm',
+  `hrv_sdnn` DECIMAL(6,1) DEFAULT NULL COMMENT '心率变异性 SDNN ms',
+  `avg_heart_rate` DECIMAL(5,1) DEFAULT NULL COMMENT '日均心率 bpm',
+
+  -- 血氧/呼吸
+  `avg_oxygen_saturation` DECIMAL(5,2) DEFAULT NULL COMMENT '平均血氧 %',
+  `avg_respiratory_rate` DECIMAL(5,1) DEFAULT NULL COMMENT '平均呼吸频率',
+
+  -- 睡眠
+  `sleep_duration_hours` DECIMAL(4,2) DEFAULT NULL COMMENT '总睡眠时长',
+  `sleep_in_bed_hours` DECIMAL(4,2) DEFAULT NULL COMMENT '在床时长',
+  `deep_sleep_hours` DECIMAL(4,2) DEFAULT 0 COMMENT '深睡',
+  `rem_sleep_hours` DECIMAL(4,2) DEFAULT 0 COMMENT 'REM',
+  `core_sleep_hours` DECIMAL(4,2) DEFAULT 0 COMMENT '核心睡眠',
+
+  -- 体温
+  `wrist_temp_c` DECIMAL(4,2) DEFAULT NULL COMMENT '夜间腕温',
+
+  -- 女性健康 / 症状
+  `is_menstruating` BOOLEAN DEFAULT FALSE COMMENT '当天是否经期',
+  `menstrual_flow` VARCHAR(30) DEFAULT NULL COMMENT '流量 light/medium/heavy',
+  `fatigue_reported` BOOLEAN DEFAULT FALSE COMMENT '当天是否记录疲劳',
+  `bloating_reported` BOOLEAN DEFAULT FALSE,
+  `mood_changes` BOOLEAN DEFAULT FALSE,
+
+  -- 元数据
+  `source` VARCHAR(50) DEFAULT 'apple_health' COMMENT '数据来源',
+  `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+
+  PRIMARY KEY (`metric_date`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Apple Health 每日聚合指标';