保存 NAS 健康系统 UI7 代码与本机维护基线

This commit is contained in:
weiyuqing committed 2026-10-17 13:29:12 +08:00
1 parent 1767b4921a
commit 8546ba23ff
80 files changed
+4984 -381

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
# 保留已授权的局域网绑定,为网页单独设置访问密码。
services:
backend:
image: local/mi-fitness-nas:20261017-audit3
web:
image: local/mi-fitness-nas:20261017-ui7
environment:
MI_FITNESS_CALENDAR_DIR: /calendar
MI_FITNESS_ALLOWED_HOSTS: localhost,127.0.0.1,192.168.31.201
MI_FITNESS_WEB_PASSWORD: ${MI_FITNESS_WEB_PASSWORD:?尚未初始化网页访问密码}
volumes:
- ./calendar:/calendar
ports: !override
- '127.0.0.1:18322:8322'
- '192.168.31.201:18322:8322'
worker:
image: local/mi-fitness-nas:20261017-audit3
+79
View File
@@ -0,0 +1,79 @@
# 长期运行配置:仅回环访问;不修改现有容器或 Tailscale。
name: mi-fitness-health
services:
backend:
image: local/mi-fitness-nas:20261016
command: ["api"]
environment:
MI_FITNESS_ADMIN_KEY: ${MI_FITNESS_ADMIN_KEY:?需在本地生成管理员密钥}
MI_FITNESS_ALLOWED_HOSTS: localhost,127.0.0.1,backend
volumes:
- ./state:/state
- ./secrets/keyring_master:/run/secrets/keyring_master:ro
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8321/', timeout=3).close()"]
interval: 15s
timeout: 5s
retries: 5
start_period: 30s
user: '1000:1001'
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=32m,mode=1777
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
mem_limit: 512m
cpus: 0.5
restart: unless-stopped
web:
image: local/mi-fitness-nas:20261016
command: ["web"]
environment:
MI_FITNESS_ADMIN_KEY: ${MI_FITNESS_ADMIN_KEY:?需在本地生成管理员密钥}
MI_FITNESS_ALLOWED_HOSTS: localhost,127.0.0.1
ports:
- '127.0.0.1:18322:8322'
depends_on:
backend:
condition: service_healthy
user: '1000:1001'
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=32m,mode=1777
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
mem_limit: 256m
cpus: 0.5
restart: unless-stopped
worker:
image: local/mi-fitness-nas:20261016
command: ["worker"]
volumes:
- ./state:/state
depends_on:
backend:
condition: service_healthy
user: '1000:1001'
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=32m,mode=1777
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
mem_limit: 192m
cpus: 0.25
restart: unless-stopped
readonly:
image: local/mi-fitness-nas:20261016
command: ["mcp"]
profiles: [chatgpt]
volumes:
- ./state/data:/health:ro
user: '1000:1001'
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=32m,mode=1777
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
mem_limit: 256m
cpus: 0.5
restart: unless-stopped
+5
View File
@@ -0,0 +1,5 @@
FROM local/mi-fitness-nas:20261017-audit3
USER root
COPY overlay/ /src/src/mi_fitness_mcp/
RUN python -c "import shutil,sysconfig; shutil.copytree('/src/src/mi_fitness_mcp',sysconfig.get_paths()['purelib']+'/mi_fitness_mcp',dirs_exist_ok=True)"
USER 1000:1001
+36
View File
@@ -0,0 +1,36 @@
"""实际部署验证:从运行容器内部读取鉴权配置,不输出秘密。"""
import base64
import json
import subprocess
import time
import urllib.error
import urllib.request
item=json.loads(subprocess.check_output(['docker','inspect','mi-fitness-health-web-1']))[0]
assert item['Config']['Image']=='local/mi-fitness-nas:20261017-ui7','网页镜像版本不符'
env=dict(line.split('=',1) for line in item['Config']['Env'])
token=base64.b64encode(('admin:'+env['MI_FITNESS_WEB_PASSWORD']).encode('utf-8')).decode('ascii')
opener=urllib.request.build_opener(urllib.request.ProxyHandler({}))
def fetch(path,auth=True):
request=urllib.request.Request('http://127.0.0.1:18322'+path,headers={'Authorization':'Basic '+token} if auth else {})
with opener.open(request,timeout=5) as response:return response.status,response.read(2000000).decode('utf-8')
for attempt in range(30):
try:
status,html=fetch('/')
assert status==200 and '20261017-ui7' in html
for path,marker in [('/static/js/health-views.js','HealthViews'),('/static/js/health-ui.js','loadHealthView'),('/static/css/health.css','health-cards')]:
assert marker in fetch(path)[1]
break
except (OSError,ValueError,AssertionError):
if attempt==29:raise RuntimeError('网页版本或资源就绪验证失败') from None
time.sleep(2)
try:
fetch('/',False)
except urllib.error.HTTPError as exc:
assert exc.code==401,'匿名访问权限异常'
else:
raise RuntimeError('匿名访问未被拒绝')
assert json.loads(fetch('/api/calendar?start=2026&end=2026')[1])['years']['2026']['days']
assert env.get('MI_FITNESS_CALENDAR_DIR')=='/calendar'
assert any(m['Destination']=='/calendar' and m['RW'] for m in item['Mounts'])
print('新版网页与三项新增资源就绪;当前自设密码验证通过,匿名访问仍需认证。')
+44
View File
@@ -0,0 +1,44 @@
#!/bin/sh
set -eu
umask 077
[ "$(id -u)" = 0 ] || { echo '请通过 sudo 执行。'; exit 1; }
package=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
target=/vol2/1000/Soft/mi-fitness-health
cd "$package"
sha256sum -c health-ui7.tar.sha256
python3 - "$target/compose.override.yml" "$package/expected-current.sha256" <<'PY'
import hashlib,sys
from pathlib import Path
assert hashlib.sha256(Path(sys.argv[1]).read_bytes()).hexdigest()==Path(sys.argv[2]).read_text().strip(), '现有配置已变化,停止避免覆盖'
PY
before=$(docker inspect --format '{{.Id}}' mi-fitness-health-backend-1 mi-fitness-health-worker-1)
[ ! -L "$target/calendar" ] || { echo "日历目录不能是符号链接"; exit 1; }
mkdir -p "$target/calendar"
chown 1000:1001 "$target/calendar"
chmod 700 "$target/calendar"
docker load -i "$package/health-ui7.tar"
cd "$target"
docker compose --project-name mi-fitness-health --env-file .env -f compose.yml -f "$package/compose.override.yml" config --quiet
old="$target/compose.override.pre-ui7-$(date +%Y%m%d-%H%M%S).yml"
cp compose.override.yml "$old"
complete=0
recover() {
if [ "$complete" = 0 ]; then
cp "$old" "$target/compose.override.yml"
chown 1000:1001 "$target/compose.override.yml"
chmod 600 "$target/compose.override.yml"
docker compose --project-name mi-fitness-health up -d --no-deps --no-build --pull never web || true
echo '新版未通过验证,已恢复原网页配置并尝试回退。'
fi
}
trap recover EXIT
cp "$package/compose.override.yml" compose.override.yml
chown 1000:1001 compose.override.yml
chmod 600 compose.override.yml
docker compose --project-name mi-fitness-health up -d --no-deps --no-build --pull never --wait --wait-timeout 60 web
python3 "$package/check-web.py"
after=$(docker inspect --format '{{.Id}}' mi-fitness-health-backend-1 mi-fitness-health-worker-1)
[ "$before" = "$after" ] || { echo '后端或同步容器标识发生变化,停止验收。'; exit 1; }
complete=1
trap - EXIT
printf '\n网页更新完成。后端和 worker 未重建,登录密码及数据目录未修改。\n'
+98
View File
@@ -0,0 +1,98 @@
"""受限只读 MCP:复用查询服务,数据库与凭据表双重隔离。"""
import os
import sqlite3
from contextlib import contextmanager
from datetime import date
from pathlib import Path
from mi_fitness_mcp.services.query_service import QueryService
from mi_fitness_mcp.storage import Database
TABLES = {'daily_activity', 'sleep_sessions', 'body_measurements', 'heart_rate_samples',
'workouts', 'spo2_samples', 'stress_samples', 'abnormal_heart_beat_events'}
METHODS = {'daily_activity': 'get_daily_summaries', 'sleep': 'get_sleep_sessions',
'body_measurements': 'get_body_measurements', 'heart_rate': 'get_heart_rate_samples',
'workouts': 'get_workouts', 'spo2': 'get_spo2_samples', 'stress': 'get_stress_samples',
'abnormal_heart_beat': 'get_abnormal_heart_beat_events'}
PRIVATE_FIELDS = {'user_id', 'device_id', 'id', 'source_record_id', 'sleep_id', 'workout_id', 'event_id'}
class ReadOnlyDatabase(Database):
def __init__(self, path):
self.db_path = Path(path).resolve()
@contextmanager
def _get_connection(self):
conn = sqlite3.connect(self.db_path.as_uri() + '?mode=ro', uri=True)
conn.row_factory = sqlite3.Row
conn.execute('PRAGMA query_only=ON')
def authorize(action, table, column, database, trigger):
if action == sqlite3.SQLITE_READ:
return sqlite3.SQLITE_OK if table in TABLES else sqlite3.SQLITE_DENY
if action in (sqlite3.SQLITE_SELECT, sqlite3.SQLITE_FUNCTION, sqlite3.SQLITE_RECURSIVE):
return sqlite3.SQLITE_OK
return sqlite3.SQLITE_DENY
conn.set_authorizer(authorize)
try:
yield conn
finally:
conn.close()
def strip_private(value):
if isinstance(value, dict):
return {k: strip_private(v) for k, v in value.items() if k not in PRIVATE_FIELDS}
if isinstance(value, list):
return [strip_private(v) for v in value]
return value
def query_records(path, kind, start_date, end_date):
if kind not in METHODS:
raise ValueError('不支持的健康数据类型')
start, end = date.fromisoformat(start_date), date.fromisoformat(end_date)
if not 0 <= (end - start).days < 366:
raise ValueError('单次查询范围必须为 1 至 366 天')
db = ReadOnlyDatabase(path)
with db._get_connection() as conn:
users = set()
for table in sorted(TABLES):
users.update(r[0] for r in conn.execute('SELECT DISTINCT user_id FROM ' + table))
if len(users) != 1:
raise ValueError('数据库中需要恰好一个健康数据账号')
service = QueryService(db, next(iter(users)))
records = getattr(service, METHODS[kind])(start.isoformat(), end.isoformat())
if len(records) > 2000:
raise ValueError('结果超过 2000 条,请缩小日期范围;未截断或省略数据')
return strip_private(records)
def main(transport='stdio'):
from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings
database = Path(os.environ.get('HEALTH_READONLY_DB', '/health/mi_fitness.db'))
mcp = FastMCP('NAS Health Read Only', host='0.0.0.0', port=8323, log_level='WARNING',
stateless_http=True, json_response=True,
transport_security=TransportSecuritySettings(
allowed_hosts=['localhost:*', '127.0.0.1:*', 'readonly:8323'],
allowed_origins=[]))
@mcp.tool(annotations={'readOnlyHint': True, 'destructiveHint': False, 'openWorldHint': False})
def get_health_records(kind: str, start_date: str, end_date: str) -> dict:
"""查询 NAS 已保存的健康记录,不触发同步。日期为北京时间 YYYY-MM-DD。
kind: daily_activity、sleep、body_measurements、heart_rate、workouts、spo2、stress、abnormal_heart_beat。
单次最多 366 天和 2000 条,超限请缩小范围。单位:步、米、千卡、分钟、kg、bpm、百分比。
"""
records = query_records(database, kind, start_date, end_date)
return {'timezone': 'Asia/Shanghai', 'kind': kind, 'count': len(records), 'data': records}
mcp.run(transport=transport)
if __name__ == '__main__':
main()
+183
View File
@@ -0,0 +1,183 @@
"""部署补充:复用上游同步与查询,增加归档、凭据保护及定时维护。"""
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()