个人数据仪表盘
将多 API 个人数据聚合到自托管仪表盘,配备同步、图表和隐私。
设计与数据源
选择数据源、设计同步管道、选图表库。
为什么
个人仪表盘的价值在数据新鲜度和源覆盖。连接 5 个源(银行、健身、日历、GitHub、屏幕时间)配统一同步计划,提供单一应用无法给的视角。选错图表库(重 D3 包 vs 轻 uPlot)拖垮移动端加载性能。
何时使用
构建图表之前。花 1 周列源、测 API、设计统一数据模型。
如何做
1. 列出并优先排序数据源
- 一级(每日):银行交易、步数、屏幕时间
- 二级(每小时):GitHub 提交、日历事件、天气
- 三级(每周):邮件统计、音乐收听、睡眠摘要
- MVP 从 2-3 个一级源开始
2. 测试每个源的 API
- 认证:OAuth(Strava、Google)、API key(GitHub)、屏幕抓取(银行无 API 时)
- 限流:记录每源限制(Strava 100/15 分钟、GitHub 5000/小时)
- 分页和增量同步:支持 ?since=lastSync 游标
3. 设计统一数据模型
- 每源映射到通用事件:{ source, timestamp, metric, value, meta }
- 银行 -> { metric: "spend", value: -42.50, meta: { merchant, category } }
- 健身 -> { metric: "steps", value: 8234, meta: {} }
- 存 SQLite,索引 (source, timestamp)
4. 选图表库
- uPlot:40KB,时序最快,无交互(仅缩放)
- ECharts:800KB 但全功能(地图、热力图、交互)
- Recharts:React 原生,200KB,适合可组合仪表盘
- 按包预算选,非按功能数
5. 设计同步计划
- 每源按自然频率 cron(银行:6 小时、健身:1 小时)
- 错开任务避免同时打所有 API
- 每源标记 lastSync 和 nextSync 供 UI 用
示例
// 统一事件模型
interface Event {
source: "bank" | "fitness" | "github" | "calendar" | "screentime";
timestamp: number; // unix 毫秒
metric: string; // "spend" | "steps" | "commits" | "focus_minutes"
value: number;
meta: Record<string, string | number>;
}
// SQLite schema
CREATE TABLE events (
source TEXT NOT NULL,
timestamp INTEGER NOT NULL,
metric TEXT NOT NULL,
value REAL NOT NULL,
meta TEXT, -- JSON
UNIQUE(source, timestamp, metric)
);
CREATE INDEX idx_events_query ON events(source, timestamp);
// 同步计划(错开)
cron: [
{ source: "fitness", every: "1h", offset: "0m" },
{ source: "github", every: "1h", offset: "15m" },
{ source: "bank", every: "6h", offset: "30m" },
{ source: "screentime", every: "6h", offset: "45m" },
];
常见问题
- ⚠一次连 10 个源——各有认证怪癖,MVP 永不上线;从 2-3 个一级源开始
- ⚠移动端用重图表库——ECharts 800KB 包手机加载 3 秒,用户在首图渲染前离开
- ⚠无增量同步——每次拉全历史,撞限流浪费 API 预算
最佳实践
- ✓从 2-3 个高价值一级源(银行 + 健身)开始,每周加一个——第 2 周上线 MVP
- ✓按包预算选图表库(移动端 uPlot < 50KB)非功能数——加载时间决定使用
- ✓每源支持增量同步(?since=lastSync)以尊重限流并保持同步快
实现与同步
构建同步任务、数据存储和响应式图表,配加载和过期态。
为什么
同步是难点——API 限流、token 过期、部分同步留旧数据。无幂等增量同步和清晰'最后更新'指示,仪表盘静默显示错误数据,用户对每个数字失去信任。
何时使用
数据模型设计后。建同步 + 存储 + 图表,再建调度和过期数据告警。
如何做
1. 建每源同步适配器
- 每适配器:{ auth, fetch(since): Event[], rateLimitInfo }
- 适配器内处理 token 刷新(OAuth)
- 返回限流 header 让调度器退避
2. 使同步幂等且增量
- 按 (source, timestamp, metric) upsert——重跑同步安全
- 传最后同步时间戳作游标:fetch(since = lastSync)
- 每次限 1000 事件;源返回更多则分页
3. 优雅处理失败
- 401:刷新 token,重试一次,然后标记源"auth_expired"
- 429:读 Retry-After header,重排任务,指数退避
- 5xx:带抖动重试 3 次,然后标记源"degraded"(显示旧数据带警告)
4. 建响应式图表
- 按时间范围查存储;订阅存储变更获实时更新
- 每源显示"2 分钟前更新"——过期指示建信任
- 首次同步时显示加载骨架;源降级时显示错误态
5. 加同步调度器
- BullMQ + Redis:每源一队列,按源频率重复任务
- 错开偏移使所有源不同秒触发
- 仪表盘:显示下次同步时间和每源"立即同步"按钮
示例
// 幂等增量同步(Strava 适配器)
async function syncStrava(since: number) {
const token = await refreshIfExpired("strava");
const activities = await fetch(
`https://api.strava.com/athlete/activities?after=${since}`,
{ headers: { Authorization: `Bearer ${token}` } }
).then(r => r.json());
const events = activities.map(a => ({
source: "fitness", timestamp: a.start_date * 1000,
metric: "steps", value: a.steps, meta: { type: a.type },
}));
// upsert——重跑安全(UNIQUE 约束)
await db.events.upsertMany(events, ["source", "timestamp", "metric"]);
return { synced: events.length, lastSync: Math.max(...events.map(e => e.timestamp)) };
}
// UI 中的过期指示
function SourceBadge({ source }) {
const lastSync = useLastSync(source); // 响应式
const stale = Date.now() - lastSync > 2 * 3600_000; // 2 小时
return <span className={stale ? "stale" : "fresh"}>
{stale ? "⚠️" : "✓"} 更新于 {timeAgo(lastSync)}
</span>;
}
常见问题
- ⚠非幂等同步——重试任务插入重复事件,图表显示双倍计数
- ⚠无过期指示——源静默失败显示 3 天前数据,用户信了错误数字
- ⚠所有源同一 cron 点同步——5 个 API 同时打,限流触发同步失败
最佳实践
- ✓按 (source, timestamp, metric) upsert 使重试同步幂等——重复是最常见仪表盘 bug
- ✓每源显示'X 前更新'并标记过期(> 2 倍预期间隔)——信任需对新鲜度透明
- ✓每源错开同步偏移使永不同时触发——尊重限流并平滑负载
部署与隐私
部署为自托管应用或本地优先站点,敏感个人数据加密存储。
为什么
个人仪表盘聚合敏感数据(银行、健康、位置)。多租户云主机是泄露目标和责任;自托管(Fly.io 单实例)或本地优先(SQLite + 同步)让数据留在你的基础设施。金融和健康指标的静态加密不可妥协。
何时使用
仪表盘用实时数据工作时。连接敏感源(银行、健康)前部署自托管。
如何做
1. 选部署模型
- 自托管 VPS(Hetzner $5/月):完全控制,数据归你
- Fly.io 单实例:易部署,缩到一,数据在挂载卷
- 本地优先(Tauri 应用 + SQLite):数据不离机,经用户云同步
2. 敏感数据静态加密
- events 表用 SQLCipher(SQLite + AES-256)
- 加密密钥来自用户主密码(argon2id KDF)
- 非敏感元数据(源名、同步时间)可不加密以快查询
3. 保护 OAuth token
- 刷新 token 加密存数据库,绝不存 env 文件或明文
- 用每部署密钥(openssl rand 32)作加密密钥
- 按计划轮换密钥;轮换时重新加密 token
4. 锁定实例
- 前置 Cloudflare:WAF、限流和 TLS
- 认证:单用户 Basic auth 或强密码会话 cookie
- 无公开注册——这是个人应用非 SaaS
5. 备份和灾难恢复
- 每晚:加密 SQLite 文件上传到 Backblaze B2 或 S3
- 每月测恢复:下载、解密、对备份跑应用
- 文档化恢复流程,使丢 VFS 是 30 分钟恢复非数据丢失
示例
// SQLCipher:加密 SQLite
import Database from "better-sqlite3";
const db = new Database("data.db");
db.pragma(`key = "${process.env.DB_ENCRYPTION_KEY}"`); // AES-256
// events 表现在静态加密
// Fly.io 部署(单实例,挂载卷)
# fly.toml
app = "my-dashboard"
[mounts]
source = "dashboard_data"
destination = "/data"
# 数据在卷上跨部署持久
// 每晚加密备份(定时)
const dump = db.backup("/data/snapshot.db");
const encrypted = await encrypt(dump, process.env.BACKUP_KEY);
await b2.upload("dashboard-backups/snapshot-" + date() + ".enc", encrypted);
// 前置 Cloudflare:WAF + 限流 + TLS
// 认证:单用户会话 cookie(无公开注册)
app.use(session({ secret: process.env.SESSION_SECRET }));
常见问题
- ⚠多租户云托管无加密——泄露暴露每个用户的银行和健康数据
- ⚠OAuth 刷新 token 明文存 env 文件——泄露 .env 给银行 API 完全访问
- ⚠无测试备份——VPS 磁盘死,2 年个人数据无恢复路径丢失
最佳实践
- ✓events 表用 SQLCipher(AES-256),密钥从用户主密码派生——敏感数据静态加密
- ✓OAuth 刷新 token 加密存数据库,绝不存 env 文件——泄露配置不得授予 API 访问
- ✓每月测试备份恢复——未测试的备份是希望非恢复计划