mirror of
https://github.com/This-weiyuqing/mi-fitness-health.git
synced 2026-10-17 18:55:37 +08:00
184 lines
7.4 KiB
Python
184 lines
7.4 KiB
Python
"""部署补充:复用上游同步与查询,增加归档、凭据保护及定时维护。"""
|
|
|
|
import gzip
|
|
import json
|
|
import logging
|
|
import os
|
|
import sqlite3
|
|
import sys
|
|
import time
|
|
from contextlib import closing
|
|
from datetime import date, datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
STATE = Path(os.environ.get('HEALTH_STATE', '/state'))
|
|
HEALTH_PATHS = {
|
|
'/app/v1/data/get_fitness_data_by_time',
|
|
'/app/v1/data/get_aggregated_fitness_data_by_time',
|
|
'/app/v1/data/get_sport_records_by_time',
|
|
}
|
|
|
|
|
|
def archive_response(directory, api_path, arguments, result):
|
|
if api_path not in HEALTH_PATHS:
|
|
raise ValueError('拒绝归档非健康数据接口')
|
|
directory = Path(directory)
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
now = datetime.now(timezone.utc)
|
|
record = {'received_at': now.isoformat(), 'path': api_path, 'arguments': arguments, 'result': result}
|
|
with gzip.open(directory / (now.date().isoformat() + '.jsonl.gz'), 'at', encoding='utf-8') as f:
|
|
f.write(json.dumps(record, ensure_ascii=False, separators=(',', ':')) + '\n')
|
|
|
|
|
|
def sync_start(state, today):
|
|
# 留三天重叠窗口;离线后从上次完整成功前开始补齐,不只取最近几天。
|
|
last = state.get('last_success_date')
|
|
return (date.fromisoformat(last) - timedelta(days=2) if last else today - timedelta(days=29)).isoformat()
|
|
|
|
|
|
def finish_sync(state, result, today):
|
|
state['last_attempt_at'] = datetime.now(timezone.utc).isoformat()
|
|
state['status'] = result.get('status', 'error')
|
|
if result.get('status') == 'ok':
|
|
state['last_success_date'] = today.isoformat()
|
|
state.pop('error_type', None)
|
|
|
|
|
|
def backup_database(source, directory):
|
|
directory = Path(directory)
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S%fZ')
|
|
target = directory / ('health-' + stamp + '.sqlite')
|
|
pending = target.with_suffix('.pending')
|
|
with closing(sqlite3.connect(Path(source).resolve().as_uri() + '?mode=ro', uri=True)) as src:
|
|
with closing(sqlite3.connect(pending)) as dst:
|
|
src.backup(dst)
|
|
dst.execute('PRAGMA secure_delete=ON')
|
|
if dst.execute("SELECT 1 FROM sqlite_master WHERE name='api_keys'").fetchone():
|
|
dst.execute('DELETE FROM api_keys')
|
|
dst.commit()
|
|
dst.execute('VACUUM')
|
|
if dst.execute('PRAGMA quick_check').fetchone()[0] != 'ok':
|
|
raise RuntimeError('备份完整性检查失败')
|
|
pending.replace(target)
|
|
# 保留最近 30 份每日备份;只处理本程序命名的备份文件。
|
|
for old in sorted(directory.glob('health-*.sqlite'), reverse=True)[30:]:
|
|
old.unlink()
|
|
return target
|
|
|
|
|
|
def setup_credentials():
|
|
import keyring
|
|
from keyrings.cryptfile.cryptfile import CryptFileKeyring
|
|
from mi_fitness_mcp import security
|
|
|
|
master_file = Path(os.environ.get('HEALTH_KEY_FILE', '/run/secrets/keyring_master'))
|
|
master = master_file.read_text(encoding='utf-8').strip()
|
|
if len(master) < 32:
|
|
raise RuntimeError('缺少有效的独立密钥环解锁文件')
|
|
ring = CryptFileKeyring()
|
|
ring.file_path = str(STATE / 'credentials' / 'encrypted-keyring.cfg')
|
|
ring.keyring_key = master
|
|
keyring.set_keyring(ring)
|
|
|
|
def encrypted_store(api_key, token):
|
|
# 加密失败直接中止,不沿用上游降级为 SQLite 明文的行为。
|
|
keyring.set_password(security.SERVICE_NAME, security._account_for(api_key), token)
|
|
return True
|
|
|
|
security.store_api_key_secret = encrypted_store
|
|
|
|
|
|
def enable_archive():
|
|
from mi_fitness_mcp.adapters.mi_fitness_cloud import MiFitnessCloudAdapter
|
|
|
|
original = MiFitnessCloudAdapter._request
|
|
|
|
async def request_and_archive(self, base_url, api_path, payload):
|
|
result = await original(self, base_url, api_path, payload)
|
|
archive_response(STATE / 'raw', api_path, payload, result)
|
|
return result
|
|
|
|
MiFitnessCloudAdapter._request = request_and_archive
|
|
|
|
|
|
def load_single_key(db_path):
|
|
with closing(sqlite3.connect(Path(db_path).resolve().as_uri() + '?mode=ro', uri=True)) as c:
|
|
rows = c.execute('SELECT key,user_id,region FROM api_keys WHERE revoked=0 ORDER BY created_at DESC,rowid DESC').fetchall()
|
|
if not rows or len({(row[1], row[2]) for row in rows}) != 1:
|
|
raise RuntimeError('仅允许一个账号和地区的有效授权')
|
|
return rows[0][0]
|
|
|
|
|
|
def atomic_json(path, data):
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
pending = path.with_suffix('.pending')
|
|
pending.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8')
|
|
pending.replace(path)
|
|
|
|
|
|
def worker():
|
|
import httpx
|
|
|
|
state_file = STATE / 'automation' / 'status.json'
|
|
state = json.loads(state_file.read_text(encoding='utf-8')) if state_file.exists() else {}
|
|
database = STATE / 'data' / 'mi_fitness.db'
|
|
while True:
|
|
today = datetime.now(timezone(timedelta(hours=8))).date()
|
|
try:
|
|
if database.exists() and state.get('backup_date') != today.isoformat():
|
|
backup_database(database, STATE / 'backups')
|
|
state['backup_date'] = today.isoformat()
|
|
key = load_single_key(database)
|
|
with httpx.Client(base_url='http://backend:8321', headers={'X-API-Key': key}, timeout=20, trust_env=False) as client:
|
|
response = client.post('/api/sync', json={
|
|
'start_date': sync_start(state, today), 'end_date': today.isoformat(), 'background': True,
|
|
})
|
|
response.raise_for_status()
|
|
task = response.json()['sync_id']
|
|
deadline = time.monotonic() + 2400
|
|
while time.monotonic() < deadline:
|
|
response = client.get('/api/sync/' + task)
|
|
response.raise_for_status()
|
|
result = response.json()
|
|
if result.get('status') not in ('accepted', 'queued', 'running'):
|
|
finish_sync(state, result, today)
|
|
break
|
|
time.sleep(10)
|
|
else:
|
|
finish_sync(state, {'status': 'timeout'}, today)
|
|
except Exception as exc:
|
|
# 不将云端错误正文、账号标识或认证参数写入日志。
|
|
finish_sync(state, {'status': 'error'}, today)
|
|
state['error_type'] = type(exc).__name__
|
|
atomic_json(state_file, state)
|
|
print(json.dumps({'event': 'maintenance', 'status': state.get('status'), 'backup_date': state.get('backup_date')}), flush=True)
|
|
time.sleep(1800 if state.get('status') == 'ok' else 300)
|
|
|
|
|
|
def main():
|
|
os.umask(0o077)
|
|
mode = sys.argv[1]
|
|
if mode == 'api':
|
|
setup_credentials()
|
|
enable_archive()
|
|
import uvicorn
|
|
from mi_fitness_mcp.api import app
|
|
uvicorn.run(app, host='0.0.0.0', port=8321, access_log=False)
|
|
elif mode == 'web':
|
|
logging.getLogger('werkzeug').disabled = True
|
|
from mi_fitness_mcp.web import create_app
|
|
app = create_app(backend_url='http://backend:8321', admin_key=os.environ['MI_FITNESS_ADMIN_KEY'])
|
|
app.run(host='0.0.0.0', port=8322, debug=False, use_reloader=False)
|
|
elif mode == 'worker':
|
|
worker()
|
|
elif mode == 'mcp':
|
|
from readonly import main as readonly_main
|
|
readonly_main('streamable-http')
|
|
else:
|
|
raise ValueError('未知运行模式')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|