0%

把华为运动健康数据直接推回家:Webhook 替代 Google Drive 中转

把华为运动健康数据直接推回家:Webhook 替代 Google Drive 中转

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 连接直接被重置。不是慢,是根本不通。

也就是说,这条管道能不能跑通,不取决于健康数据,取决于家里的代理稳不稳。路由器规则一变、节点抖动、订阅过期,当晚数据就缺,第二天早上还得手动补。一个号称「自动」的日记管道,命门捏在一个跟健康完全无关的网络环节上。

除此之外还有几个附带问题:

  1. 多一个付费第三方,数据要先经过别人的云盘;
  2. CSV 是文件轮询模型,晚上 10 点拉一次,手机没同步就缺数据,要靠第二天早上补一轮;
  3. 中文文件名靠猜,解析器要认「步数 2026.10.09 Health Connect.csv」这类名字,月汇总文件还得排除;
  4. 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
# 产物:app/build/outputs/apk/debug/app-debug.apk
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"

# 生成鉴权 token 并设置严格权限
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
# https://mini.xxx.ts.net:8786 (tailnet only)
# |-- / proxy http://127.0.0.1:18786

这里有个安全细节值得说清楚:经过 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
# {"status":"ok"}

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}}]}'
# {"ok":true,"saved":["health_2026-10-10.json"],"messages":1}

curl -X POST .../health -H "Authorization: Bearer 少了字符" -d '{...}'
# {"error":"unauthorized"} ← HTTP 401

最小可运行实现

下面是从生产版抽出的最小骨架,只用标准库,保存为 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
#!/usr/bin/env python3
"""健康数据 webhook 最小接收端(仅标准库)。生产版见 tools/local-webhook/server.py。"""
import hmac, json, os, tempfile, threading
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

DATA_DIR = Path("./health-data")
TOKEN = os.environ["HEALTH_WEBHOOK_TOKEN"] # ≥32 字符
_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']:.1f}%")

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, smtplib
from email.mime.text import MIMEText

user = os.environ["MAIL_USER"] # 发件与收件邮箱
date = rec.get("date", "今日")
body = summarize(rec) # 即上方 summarize() 的输出

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'
# 或走坚果云 WebDAV:curl -T health_2026-10-10.json -u user:pass \
# https://dav.jianguoyun.com/dav/health/

注意这一步和旧方案的本质区别:备份是「到家之后」的可选项,不是数据能不能到家的前提。云盘不通,入库和写日记照常;哪天云盘恢复,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   # {"status":"ok"}

看最近收到的数据:

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
# 127.0.0.1 - - [...] "POST /health HTTP/1.1" 200 - ← stderr 里记的是访问日志

重启服务:

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。