2026-10-10 早上搭建记录。一条链路:华为手表 → 华为运动健康 App → 手机 Health Connect → 自建 App → Tailscale HTTPS → 家里 Mac mini → 每日 JSON。不装付费同步 App,不过 Google Drive,数据每 2 小时自己回家。
一、为什么要重做这条管道 华为运动健康是封闭系统。官方只给两条出口:
导出运动轨迹(GPX/KML/TCX);
隐私中心申请全量数据副本(重型操作,不适合每天跑)。
我之前用的是第三方 App Health Sync 方案:
1 2 华为运动健康 → Health Sync(付费 App)→ Health Connect → Google Drive CSV → Mac 定时拉取 → 解析 → 写日记
手机侧前三跳(华为运动健康 → Health Kit → Health Sync → Health Connect)全程在本地,没有任何网络问题。致命的是最后两跳:Health Sync 把数据传到 Google Drive,Mac 再从 Google Drive 拉 CSV——Google Drive 在国内网络环境下裸连几乎不可达 :DNS 解析被污染,443 连接直接被重置。不是慢,是根本不通。
也就是说,这条管道能不能跑通,不取决于健康数据,取决于家里的代理稳不稳。路由器规则一变、节点抖动、订阅过期,当晚数据就缺,第二天早上还得手动补。一个号称「自动」的日记管道,命门捏在一个跟健康完全无关的网络环节上。
除此之外还有几个附带问题:
多一个付费第三方 ,数据要先经过别人的云盘;
CSV 是文件轮询模型 ,晚上 10 点拉一次,手机没同步就缺数据,要靠第二天早上补一轮;
中文文件名靠猜 ,解析器要认「步数 2026.10.09 Health Connect.csv」这类名字,月汇总文件还得排除;
Google OAuth 令牌 会过期,维护成本不低。
今天换的思路很直白:Health Connect 是 Android 的健康数据总线,华为健康开授权后数据就在里面。那我直接装一个读 Health Connect 的开源 App,让它主动 POST 到我自己的服务器 ,把跨境云盘这一段整个删掉——出口从「Google 服务」换成「Tailscale 私有网络里的一台家庭 Mac」,国内任何网络下都通。
二、最终架构 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 ┌─────────────┐ Health Kit 授权 ┌──────────────────┐ │ 华为运动健康 │ ───────────────────→ │ Health Connect │ │ (手表同步) │ │ (Android 系统层)│ └─────────────┘ └────────┬─────────┘ │ 读取(约20类数据) ▼ ┌──────────────────────┐ │ HealthConnect Export │ 每2小时 │ (自构建开源 App) │ ────────┐ └──────────────────────┘ │ 手机(vivo,设备代号 V2436A) │ HTTPS POST │ Bearer Token ┌──────────────────────▼───────────────┐ │ Tailscale tailnet(手机走移动网络也通)│ │ https://mini.xxx.ts.net:8786/health │ └──────────────────────┬───────────────┘ │ tailscale serve 反代 ▼ ┌──────────────────────────────────────┐ │ Mac mini:Python webhook(仅环回监听) │ │ 127.0.0.1:18786 │ │ 鉴权 → 校验 → 深度合并 → 原子写 │ └──────────────────────┬───────────────┘ ▼ knowledge1/01_AREAS/health/healthsync/data/ └─ health_2026-10-10.json(权限 0600)
和旧方案比:没有 Google Drive,没有付费 App,没有定时轮询 。手机有新数据就推,Mac 只负责收。
三、前置条件
组件
说明
Android 手机
能装华为运动健康,系统支持 Health Connect(Android 14 内置)
华为手表/手环
日常佩戴,运动健康 App 正常同步
一台常开的 Mac/Linux
我用 Mac mini 做家庭服务器
Tailscale
手机和 Mac 同一 tailnet,外网走蜂窝也能回家
构建环境(仅一次)
JDK 21 + Android SDK,用来出 APK
四、搭建步骤 第 1 步:手机端打通华为健康 → Health Connect 安装华为运动健康并完成手表配对后,在 App 内找到 Health Connect 同步入口并授权(华为 Health Kit → 同步至健康/Health Connect)。
授权后在系统 健康(Health Connect) 设置里应能看到数据源是华为运动健康,步数、心率、睡眠、血氧等类型已有记录。这是整条链的数据根基——Health Connect 里没数据,后面全是空的。
第 2 步:构建并安装导出 App 上游项目是 kas-cor/healthconnect-export (Kotlin + Jetpack Compose,MIT,397 个单元测试),功能是读 Health Connect、按天导出 JSON、可发 Webhook、可用 WorkManager 定时。
我做了一个简体中文 Fork(加完整中文界面、独立包名、本地 webhook 接收服务):
1 2 3 4 5 git clone https://github.com/fanzhh/healthconnect-export.git cd healthconnect-export./gradlew testDebugUnitTest assembleDebug adb install -r app/build/outputs/apk/debug/app-debug.apk
安装后打开 App,在「数据类型」页勾选要导出的指标(步数、心率、血压、血氧、消耗、距离、睡眠、运动等),并按提示逐项授予 Health Connect 读取权限。
注意:汉化版用了独立包名和自己的签名,App 内的 Google Drive OAuth 不能用(需要各自的 Android Client ID)。新方案根本不用 Drive,无影响。
第 3 步:Mac 端部署 webhook 接收服务 接收端只有一个零第三方依赖的 Python 标准库文件,位于仓库 tools/local-webhook/server.py。部署到专用目录并准备 token(至少 32 字符):
1 2 3 4 5 6 7 8 9 mkdir -p "$HOME /Library/Application Support/HealthConnectExportWebhook" mkdir -p "$HOME /Library/Logs/HealthConnectExportWebhook" cp tools/local -webhook/server.py "$HOME /Library/Application Support/HealthConnectExportWebhook/server.py" openssl rand -hex 32 > "$HOME /Library/Application Support/HealthConnectExportWebhook/token" chmod 600 "$HOME /Library/Application Support/HealthConnectExportWebhook/token"
手动跑一次验证:
1 2 3 4 5 python3 "$HOME /Library/Application Support/HealthConnectExportWebhook/server.py" \ --host 127.0.0.1 \ --port 18786 \ --data-dir /Users/mac/knowledge1/01_AREAS/health/healthsync/data \ --token-file "$HOME /Library/Application Support/HealthConnectExportWebhook/token"
收到的数据按天落成一个文件:health_2026-10-10.json,权限 0600。今天到手的真实样本:
1 2 3 4 5 6 7 8 9 10 11 12 13 { "date" : "2026-10-10" , "steps" : { "total_steps" : 5928 , "records_count" : 86 }, "heart_rate" : { "avg_bpm" : 102.3 , "min_bpm" : 51 , "max_bpm" : 126 }, "blood_pressure" : { "systolic_mmhg" : 131 , "diastolic_mmhg" : 91 }, "oxygen_saturation" : { "percentage" : 98.75 , "records_count" : 28 }, "exercises" : [ { "exercise_type" : "Badminton" , "start_time" : "2026-10-10T07:02:54" , "duration_minutes" : 25 } ], "_webhook_import" : { "import_count" : 5 } }
用 LaunchAgent 让它常驻后台、崩溃自拉起、开机自启(~/Library/LaunchAgents/com.fanzhh.health-webhook.plist):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version ="1.0" > <dict > <key > Label</key > <string > com.fanzhh.health-webhook</string > <key > ProgramArguments</key > <array > <string > /opt/homebrew/bin/python3</string > <string > /Users/mac/Library/Application Support/HealthConnectExportWebhook/server.py</string > <string > --host</string > <string > 127.0.0.1</string > <string > --port</string > <string > 18786</string > <string > --data-dir</string > <string > /Users/mac/knowledge1/01_AREAS/health/healthsync/data</string > <string > --token-file</string > <string > /Users/mac/Library/Application Support/HealthConnectExportWebhook/token</string > </array > <key > RunAtLoad</key > <true /> <key > KeepAlive</key > <true /> <key > ProcessType</key > <string > Background</string > <key > StandardOutPath</key > <string > /Users/mac/Library/Logs/HealthConnectExportWebhook/server.log</string > <key > StandardErrorPath</key > <string > /Users/mac/Library/Logs/HealthConnectExportWebhook/server-error.log</string > </dict > </plist >
加载并启动服务:
1 launchctl load ~/Library/LaunchAgents/com.fanzhh.health-webhook.plist
日志自动落在 ~/Library/Logs/HealthConnectExportWebhook/。
第 4 步:Tailscale serve 开一个 HTTPS 入口 后端只监听 127.0.0.1 ,手机碰不到。用 Tailscale serve 把 tailnet 内的 HTTPS 端点反代到环回端口:
1 tailscale serve --https=8786 http://127.0.0.1:18786
手机上填的地址就是:
1 https://mini.<你的tailnet>.ts.net:8786/health
检查:
1 2 3 tailscale serve status
这里有个安全细节值得说清楚:经过 tailscale serve 反代后,后端看到的来源地址永远是 127.0.0.1,所以服务端的 --allow-cidr 网段限制在这种拓扑下不生效。真正的两道门是:Tailscale tailnet 本身(设备要认证入网)+ Bearer Token 。不要把端点开成 Funnel(对公网开放),保持 tailnet only。
第 5 步:App 配置 Webhook 并开启定时上报 手机 App 的「集成 / Webhook」卡片:
URL:https://mini.xxx.ts.net:8786/health
Token:第 3 步生成的那串
先点「测试」,看到服务器返回 {"ok":true,...} 再继续
在「计划」页开启每 2 小时 Webhook 上报
App 端做了三重保活(这是我选它而不是自己写 App 的原因):
Every2HoursWebhookWorker:WorkManager 周期任务,只读当天数据直接 POST,不落地文件;
DeliveryWatchdog:每 6 小时一个 Doze 允许的不精确闹钟,负责重新入队两个任务;
CatchUpWebhookWorker:重启或漏传后按投递日志补传缺的天数(窗口 7 天,上限 30 天)。
国产 ROM 杀后台凶,还要在系统设置里给这个 App 关闭电池优化、允许后台运行、允许自启动 。
五、自建 HTTP Hook 服务 整条链里唯一需要自己写代码的是接收端。它是一个零第三方依赖的 Python 标准库服务(手机端 App 是现成开源的,Mac 端这个 hook 是我写的),完整生产版在项目 tools/local-webhook/server.py(273 行,含网段限制、请求体上限、完整测试)。
请求契约
项
值
方法/路径
POST /health(健康检查 GET /healthz)
Content-Type
application/json
鉴权头
Authorization: Bearer <32字符以上token>
请求体
{"messages": [ {每日记录}, ... ]},一条消息一天
成功响应
200 {"ok":true,"saved":["health_2026-10-10.json"],"messages":1}
失败码
401 token 错 · 400 报文非法 · 413 超 10MB · 404 路径错
每条消息必须有合法的 YYYY-MM-DD 日期字段,服务端按日期落一个文件:health_YYYY-MM-DD.json。手机侧实测报文(精简):
1 2 3 4 5 6 7 8 9 10 { "messages" : [{ "date" : "2026-10-10" , "steps" : { "total_steps" : 5928 , "records_count" : 86 }, "heart_rate" : { "min_bpm" : 51 , "max_bpm" : 126 , "avg_bpm" : 102.3 }, "oxygen_saturation" : { "percentage" : 98.75 , "records_count" : 28 }, "exercises" : [{ "exercise_type" : "Badminton" , "duration_minutes" : 25 }], "metadata" : { "app_version" : "1.10-zh" , "source_device" : "V2436A" } }] }
联调三条命令(第三条就是今早 06:20 撞 401 的场景——token 少一个字符):
1 2 3 4 5 6 7 8 9 10 curl https://mini.xxx.ts.net:8786/healthz curl -X POST https://mini.xxx.ts.net:8786/health \ -H "Authorization: Bearer $TOKEN " -H "Content-Type: application/json" \ -d '{"messages":[{"date":"2026-10-10","steps":{"total_steps":42}}]}' curl -X POST .../health -H "Authorization: Bearer 少了字符" -d '{...}'
最小可运行实现 下面是从生产版抽出的最小骨架,只用标准库,保存为 hook.py 后 HEALTH_WEBHOOK_TOKEN=$(openssl rand -hex 32) python3 hook.py 即可跑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 """健康数据 webhook 最小接收端(仅标准库)。生产版见 tools/local-webhook/server.py。""" import hmac, json, os, tempfile, threadingfrom datetime import datetime, timezonefrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServerfrom pathlib import PathDATA_DIR = Path("./health-data" ) TOKEN = os.environ["HEALTH_WEBHOOK_TOKEN" ] _LOCK = threading.Lock() def deep_merge (old, new) : """快照覆盖:以旧字典为基底,同键新值替换旧值;列表整体替换;None 不擦旧数据。绝不累加。""" if isinstance(old, dict) and isinstance(new, dict): merged = dict(old) for k, v in new.items(): merged[k] = deep_merge(merged.get(k), v) return merged if isinstance(new, list): return new if new else old return new if new is not None else old class Handler (BaseHTTPRequestHandler) : def _reply (self, code, obj) : body = json.dumps(obj, ensure_ascii=False ).encode() self.send_response(code) self.send_header("Content-Type" , "application/json" ) self.send_header("Content-Length" , str(len(body))) self.end_headers(); self.wfile.write(body) def do_GET (self) : self._reply(200 , {"status" : "ok" }) if self.path == "/healthz" \ else self._reply(404 , {"error" : "not found" }) def do_POST (self) : if self.path != "/health" : return self._reply(404 , {"error" : "not found" }) if not hmac.compare_digest(self.headers.get("Authorization" , "" ), f"Bearer {TOKEN} " ): return self._reply(401 , {"error" : "unauthorized" }) try : payload = json.loads(self.rfile.read(int(self.headers["Content-Length" ]))) msgs = payload["messages" ] assert isinstance(msgs, list) and msgs except Exception: return self._reply(400 , {"error" : "bad request" }) DATA_DIR.mkdir(mode=0o700 , parents=True , exist_ok=True ) saved = [] with _LOCK: for m in msgs: p = DATA_DIR / f"health_{m['date' ]} .json" old = json.loads(p.read_text(encoding="utf-8" )) if p.exists() else {} cnt = int(old.get("_webhook_import" , {}).get("import_count" , 0 )) + 1 merged = deep_merge(old, m) merged["_webhook_import" ] = { "import_count" : cnt, "last_received_at" : datetime.now(timezone.utc).isoformat()} fd, tmp = tempfile.mkstemp(prefix=".incoming-" , dir=DATA_DIR) with os.fdopen(fd, "w" , encoding="utf-8" ) as f: json.dump(merged, f, ensure_ascii=False , indent=2 ) f.write("\n" ) f.flush() os.fsync(f.fileno()) os.replace(tmp, p); os.chmod(p, 0o600 ) saved.append(p.name) self._reply(200 , {"ok" : True , "saved" : saved, "messages" : len(msgs)}) ThreadingHTTPServer(("127.0.0.1" , 18786 ), Handler).serve_forever()
生产版相比骨架多了:客户端网段白名单(直连部署时用)、10MB 请求体上限、日期合法性逐条校验、首次/末次接收时间、token 文件读取(不进环境变量/不进 Git)、访问日志。
三个关键设计 1. 深度合并,绝不能累加(今早踩的坑) 手机每 2 小时推一次,推的是当天截至当时的累计快照 :早上 8 点步数 1200,10 点是 3400——10 点这条已经包含 8 点的量。
接收端如果傻做加法,一天下来步数翻几倍。正确做法是递归合并:字典按键覆盖、列表整体替换、空值不擦除已有数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 def deep_merge (existing, incoming) : if incoming is None : return existing if isinstance(existing, dict) and isinstance(incoming, dict): merged = dict(existing) for key, value in incoming.items(): if key == "_webhook_import" : continue merged[key] = deep_merge(merged.get(key), value) return merged if isinstance(incoming, list): return incoming if incoming else existing return incoming
这是今早第二版提交修的核心问题。第一次联调就发现同一天文件被连续推了多次,累计值口径必须在服务端钉死,不能信客户端发送频率。
合并还有第二个坑(本文骨架 E2E 时实测踩到):每次推送通常只含部分指标 (比如某一轮只有血氧)。合并时必须以旧字典为基底(merged = dict(old))再用新键覆盖;若直接用新报文的键重建字典,其余指标会被整个擦掉。判断合并不对,看两点就够:步数既不能变成两次之和,也不能在一次只推血氧后消失。
2. 原子写,防并发损坏 多个 Worker 任务可能同时到达(补传任务和周期任务撞车)。写文件走 mkstemp 临时文件 + fsync + os.replace 同目录改名,外加进程锁串行化「读旧文件→合并→写回」全过程,保证不会出现写了一半的 JSON。
3. 鉴权与校验顺序 每个 POST 依次过:路径必须是 /health → Bearer Token 用 hmac.compare_digest 常量时间比较 → Content-Type 必须是 JSON → Body 不超过 10 MB → 每条消息必须带合法的 YYYY-MM-DD 日期。任何一关不过直接 4xx,不落盘。
六、一个入口,多个出口:消费者扩展 到目前为止 hook 只做一件事:把手机数据可靠地接回家、落成规范 JSON 。这恰好是整个设计里最值的部分——health_YYYY-MM-DD.json 是一个中立、自描述、带接收元数据的格式,一旦它稳定躺在本地目录里,之后想拿它干什么都行,而且和手机、和网络彻底无关 。
1 2 3 4 5 6 7 手机 App ──POST──▶ hook(唯一入口,只管接收+合并+落盘) │ data/health_*.json(中立格式) ┌────────────┼────────────┬────────────┐ ▼ ▼ ▼ ▼ 写入知识库 发邮件日报 同步云盘备份 阈值告警/电子表格… (已落地) (可选) (可选) (可选)
关键原则:消费者和接收入口解耦 。hook 进程里不要内嵌发邮件、传云盘这些动作——网络抖动会拖慢甚至拖垮入库。让 hook 永远只写文件;由独立的消费者进程去读目录、各自处理,单个消费者失败不影响数据到家,也不影响其他消费者。
消费者怎么被触发 两种方式,都不碰 hook:
定时轮询 (最简单):cron / launchd 每天定点跑消费者,读当天 JSON。我的知识库写作就是这个路子,和既有日记时间点对齐。
文件变化即触发 :macOS launchd 的 WatchPaths 盯着 data/ 目录,新文件一落盘就唤醒消费者,适合「推送即告警」。检测增量用文件里现成的 _webhook_import.import_count:处理后记下已见的计数,只在计数增长时动作。
出口 1:写入本地知识库(我的案例,已落地) 需求不是看仪表盘,是每晚把当天健康数据写进 Markdown 日记,日后和情绪、工作、运动对照 。消费者只读 JSON、生成一行汇总(睡眠按醒来日归次日,是既有口径)。下面是在两天真实数据上跑通的核心:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 def summarize (rec) : parts = [] st = rec.get("steps" , {}) if st.get("total_steps" ) is not None : parts.append(f"步数 {int(st['total_steps' ]):,} " ) hr = rec.get("heart_rate" , {}) if hr.get("records_count" ): parts.append(f"心率 {hr['min_bpm' ]} -{hr['max_bpm' ]} (均{round(hr['avg_bpm' ])} )" ) bp = rec.get("blood_pressure" , {}) if bp.get("systolic_mmhg" ) is not None and bp.get("diastolic_mmhg" ) is not None : parts.append(f"血压 {round(bp['systolic_mmhg' ])} /{round(bp['diastolic_mmhg' ])} " ) cal = rec.get("active_calories" , {}) if cal.get("total_calories_kcal" ) is not None : parts.append(f"消耗 {round(cal['total_calories_kcal' ])} kcal" ) o2 = rec.get("oxygen_saturation" , {}) if o2.get("records_count" ): parts.append(f"血氧 {o2['percentage' ]:.1 f} %" ) lines = ["- **Health Sync**:" + " · " .join(parts)] for ex in rec.get("exercises" , []): t = (ex.get("start_time" ) or "" )[11 :16 ] lines.append(f"- {t} {ex.get('exercise_type' )} {ex.get('duration_minutes' )} 分" ) return "\n" .join(lines)
今天(2026-10-10)真机输出:
1 2 - **Health Sync**:步数 5,928 · 心率 51-126(均102) · 血压 131/91 · 消耗 249 kcal · 血氧 98.8% - 07:02 Badminton 25分
均心率 102 是早上打羽毛球拉高的,静息段仍在 51–60,数据可信。这个消费者取代旧管道里最脆弱的「猜中文 CSV 文件名 + 逐格式解析」整段——JSON 字段固定,解析器短到不用维护。
出口 2:邮件日报(可选) 用标准库 smtplib,把汇总行当正文发给自己,适合「每天早上一封昨日健康邮件」。复用任意支持 SMTP 授权码的邮箱(QQ/Gmail 等),授权码走环境变量,不写进代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import os, smtplibfrom email.mime.text import MIMETextuser = os.environ["MAIL_USER" ] date = rec.get("date" , "今日" ) body = summarize(rec) msg = MIMEText(body, "plain" , "utf-8" ) msg["Subject" ] = f"健康日报 {date} " msg["From" ] = user msg["To" ] = user with smtplib.SMTP_SSL("smtp.qq.com" , 465 ) as s: s.login(user, os.environ["MAIL_AUTHCODE" ]) s.send_message(msg)
出口 3:云盘备份(可选) data/ 目录本身就是一份干净的按天 JSON,直接用 rclone 或 WebDAV 客户端同步到坚果云/自建 WebDAV/NAS,等于多一份异地副本:
1 2 3 4 rclone sync /Users/mac/knowledge1/01_AREAS/health/healthsync/data \ remote:health-backup --include 'health_*.json'
注意这一步和旧方案的本质区别:备份是「到家之后」的可选项,不是数据能不能到家的前提 。云盘不通,入库和写日记照常;哪天云盘恢复,sync 一把补齐。
其他顺手的出口
阈值告警 :消费者读到静息心率、血压越界就推一条消息(比日报更及时);
电子表格/数据库 :每天一行追加到 CSV 或 SQLite,长期趋势随便查;
给本地大模型 :JSON 目录直接作为 RAG/分析的数据源,做周/月健康总结。
所有这些都只依赖 data/ 里的文件,互不干扰、可随时增删——这就是把「采集」和「消费」分开的回报。
七、今早的真实排障时间线
本地时间
现象
处理
05:56
APK 构建完成(58 MB)
装机、授 Health Connect 权限
06:16
手机首次 GET /healthz 200
Tailscale 网络通
06:19
本机 curl POST 200
服务端逻辑通
06:20
POST 返回 401
App 里 Token 少贴了字符,重新粘贴后通过
06:28
10-04 至 10-09 六天历史 JSON 一次回填
补传 Worker 生效
06:30
发现同日重复推送会累加
提交深度合并修复,重启服务
06:33
手机实时 POST 200,文件合并正常
链路打通
之后
约每 2 小时一条,到 13:10 已 5 次
无人值守运行
401 那条现在看是低级错误,但它恰好验证了鉴权链真的在工作——少一个字符都进不来。
八、验证与日常运维 服务端健康检查:
1 curl https://mini.xxx.ts.net:8786/healthz
看最近收到的数据:
1 2 ls -lt /Users/mac/knowledge1/01_AREAS/health/healthsync/data/ | head python3 -c "import json,glob,os; f=sorted(glob.glob('/Users/mac/knowledge1/01_AREAS/health/healthsync/data/health_*.json'))[-1]; d=json.load(open(f)); print(os.path.basename(f), d['steps'])"
看服务日志确认推送节奏:
1 2 tail -f ~/Library/Logs/HealthConnectExportWebhook/server-error.log
重启服务:
1 launchctl kickstart -k gui/$(id -u)/com.fanzhh.health-webhook
九、和旧管道的关系(当前状态)
数据获取 :今天起走新 webhook,实时性从「每晚一次」变成「每 2 小时」;
写日记 :暂时仍由旧的 Google Drive CSV 管道负责(早上 8 点写睡眠、晚上 10 点写当日汇总)。今天日记里的睡眠行还是它写的;
下一步 :写一个读 data/health_*.json 的消费脚本,替代 CSV 解析器写日记,然后把 Health Sync 订阅和 Drive 授权彻底撤掉。
十、隐私与安全清单
Token 文件和数据文件权限都是 0600,数据目录 0700;
端点只在 Tailscale tailnet 内可达,不开 Funnel,不暴露公网;
传输全程 Tailscale HTTPS;
后端只监听环回,不主动向任何外部服务发送数据;
Token 不进 Git(.gitignore 已排除),健康数据不进任何仓库;
手机是走家里 Wi-Fi 还是蜂窝网络都行,Tailscale 会处理组网。
项目地址:github.com/fanzhh/healthconnect-export (简体中文 Fork,含本地 webhook 接收端),上游:kas-cor/healthconnect-export 。
记录于 2026-10-10,Mac mini (M4) / vivo X200 Pro mini / Tailscale 1.x。