From 8546ba23ff6384c21fa91184539b6e057b1e16e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AD=8F=E9=81=87=E5=8D=BF?= <358614401@qq.com> Date: Sat, 17 Oct 2026 13:29:12 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E5=AD=98=20NAS=20=E5=81=A5=E5=BA=B7?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=20UI7=20=E4=BB=A3=E7=A0=81=E4=B8=8E=E6=9C=AC?= =?UTF-8?q?=E6=9C=BA=E7=BB=B4=E6=8A=A4=E5=9F=BA=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 23 ++ AGENTS.md | 10 + README-NAS.md | 29 +++ deploy/compose.override.yml | 17 ++ deploy/compose.yml | 79 ++++++ deploy/legacy-ui7/Dockerfile | 5 + deploy/legacy-ui7/check-web.py | 36 +++ deploy/legacy-ui7/update-web.sh | 44 ++++ deploy/readonly.py | 98 ++++++++ deploy/runtime.py | 183 ++++++++++++++ docs/nas/backend-query-design.md | 62 +++++ docs/nas/source-migration.md | 9 + .../legacy-tests/apply-health-layout.py | 49 ++++ .../legacy-tests/benchmark_dashboard.cjs | 61 +++++ .../legacy-tests/check_audit2_container.py | 30 +++ .../legacy-tests/check_audit2_maintenance.py | 26 ++ .../legacy-tests/check_audit3_edges.py | 58 +++++ .../legacy-tests/check_bulk_container.py | 55 ++++ maintenance/legacy-tests/check_linux.py | 53 ++++ maintenance/legacy-tests/check_mcp.py | 34 +++ .../legacy-tests/check_production_cards.cjs | 21 ++ .../legacy-tests/check_ui4_container.py | 56 +++++ .../legacy-tests/check_ui5_container.py | 56 +++++ .../legacy-tests/check_ui6_container.py | 56 +++++ .../legacy-tests/check_ui7_container.py | 58 +++++ .../dashboard-performance.test.cjs | 58 +++++ maintenance/legacy-tests/dashboard.test.cjs | 119 +++++++++ .../legacy-tests/health-axis-minimum.test.cjs | 32 +++ .../legacy-tests/health-calendar.test.cjs | 30 +++ .../legacy-tests/health-interaction.test.cjs | 46 ++++ .../legacy-tests/health-references.test.cjs | 54 ++++ .../legacy-tests/health-views.test.cjs | 48 ++++ .../import_local_authorization.py | 48 ++++ .../legacy-tests/prepare_audit2_fixture.py | 28 +++ maintenance/legacy-tests/review_data_edges.py | 95 +++++++ maintenance/legacy-tests/review_remaining.py | 55 ++++ .../legacy-tests/serve_dashboard_fixture.py | 40 +++ .../serve_health_views_fixture.py | 46 ++++ .../legacy-tests/serve_performance_fixture.py | 52 ++++ .../legacy-tests/test_audit_findings.py | 97 +++++++ maintenance/legacy-tests/test_bulk_sync.py | 69 +++++ .../legacy-tests/test_data_edge_fixes.py | 184 ++++++++++++++ maintenance/legacy-tests/test_readonly.py | 44 ++++ .../legacy-tests/test_remaining_fixes.py | 155 ++++++++++++ maintenance/legacy-tests/test_runtime.py | 104 ++++++++ .../legacy-tests/test_web_readiness.py | 46 ++++ .../legacy-tests/test_workday_calendar.py | 77 ++++++ .../verify_axis_minimum_browser.cjs | 22 ++ .../legacy-tests/verify_calendar_browser.cjs | 30 +++ .../verify_health_views_browser.cjs | 43 ++++ .../verify_reference_lines_browser.cjs | 30 +++ qa/dashboard-performance.test.cjs | 58 +++++ qa/dashboard.test.cjs | 119 +++++++++ qa/health-axis-minimum.test.cjs | 32 +++ qa/health-calendar.test.cjs | 30 +++ qa/health-interaction.test.cjs | 46 ++++ qa/health-references.test.cjs | 54 ++++ qa/health-views.test.cjs | 48 ++++ qa/test_workday_calendar.py | 77 ++++++ scripts/sync_legacy_workspace.py | 39 +++ .../adapters/mi_fitness_cloud.py | 225 +++++++++-------- src/mi_fitness_mcp/api.py | 131 ++++++---- src/mi_fitness_mcp/models/__init__.py | 2 +- src/mi_fitness_mcp/server.py | 3 +- src/mi_fitness_mcp/services/query_service.py | 8 +- src/mi_fitness_mcp/services/sync_service.py | 59 +++-- src/mi_fitness_mcp/storage/__init__.py | 168 ++++++++++--- src/mi_fitness_mcp/web.py | 34 ++- .../web_assets/static/calendars/2025.json | 175 +++++++++++++ .../web_assets/static/calendars/2026.json | 205 +++++++++++++++ .../static/calendars/LICENSE.holiday-cn.txt | 21 ++ .../web_assets/static/css/health.css | 58 +++++ .../web_assets/static/css/style.css | 12 +- .../web_assets/static/js/app.js | 237 ++++++++++++------ .../web_assets/static/js/health-ui.js | 114 +++++++++ .../web_assets/static/js/health-views.js | 196 +++++++++++++++ .../web_assets/templates/index.html | 118 +++++---- src/mi_fitness_mcp/workday_calendar.py | 134 ++++++++++ tests/test_mi_fitness_cloud.py | 100 ++++++-- tests/test_web.py | 2 +- 80 files changed, 4984 insertions(+), 381 deletions(-) create mode 100644 AGENTS.md create mode 100644 README-NAS.md create mode 100644 deploy/compose.override.yml create mode 100644 deploy/compose.yml create mode 100644 deploy/legacy-ui7/Dockerfile create mode 100644 deploy/legacy-ui7/check-web.py create mode 100644 deploy/legacy-ui7/update-web.sh create mode 100644 deploy/readonly.py create mode 100644 deploy/runtime.py create mode 100644 docs/nas/backend-query-design.md create mode 100644 docs/nas/source-migration.md create mode 100644 maintenance/legacy-tests/apply-health-layout.py create mode 100644 maintenance/legacy-tests/benchmark_dashboard.cjs create mode 100644 maintenance/legacy-tests/check_audit2_container.py create mode 100644 maintenance/legacy-tests/check_audit2_maintenance.py create mode 100644 maintenance/legacy-tests/check_audit3_edges.py create mode 100644 maintenance/legacy-tests/check_bulk_container.py create mode 100644 maintenance/legacy-tests/check_linux.py create mode 100644 maintenance/legacy-tests/check_mcp.py create mode 100644 maintenance/legacy-tests/check_production_cards.cjs create mode 100644 maintenance/legacy-tests/check_ui4_container.py create mode 100644 maintenance/legacy-tests/check_ui5_container.py create mode 100644 maintenance/legacy-tests/check_ui6_container.py create mode 100644 maintenance/legacy-tests/check_ui7_container.py create mode 100644 maintenance/legacy-tests/dashboard-performance.test.cjs create mode 100644 maintenance/legacy-tests/dashboard.test.cjs create mode 100644 maintenance/legacy-tests/health-axis-minimum.test.cjs create mode 100644 maintenance/legacy-tests/health-calendar.test.cjs create mode 100644 maintenance/legacy-tests/health-interaction.test.cjs create mode 100644 maintenance/legacy-tests/health-references.test.cjs create mode 100644 maintenance/legacy-tests/health-views.test.cjs create mode 100644 maintenance/legacy-tests/import_local_authorization.py create mode 100644 maintenance/legacy-tests/prepare_audit2_fixture.py create mode 100644 maintenance/legacy-tests/review_data_edges.py create mode 100644 maintenance/legacy-tests/review_remaining.py create mode 100644 maintenance/legacy-tests/serve_dashboard_fixture.py create mode 100644 maintenance/legacy-tests/serve_health_views_fixture.py create mode 100644 maintenance/legacy-tests/serve_performance_fixture.py create mode 100644 maintenance/legacy-tests/test_audit_findings.py create mode 100644 maintenance/legacy-tests/test_bulk_sync.py create mode 100644 maintenance/legacy-tests/test_data_edge_fixes.py create mode 100644 maintenance/legacy-tests/test_readonly.py create mode 100644 maintenance/legacy-tests/test_remaining_fixes.py create mode 100644 maintenance/legacy-tests/test_runtime.py create mode 100644 maintenance/legacy-tests/test_web_readiness.py create mode 100644 maintenance/legacy-tests/test_workday_calendar.py create mode 100644 maintenance/legacy-tests/verify_axis_minimum_browser.cjs create mode 100644 maintenance/legacy-tests/verify_calendar_browser.cjs create mode 100644 maintenance/legacy-tests/verify_health_views_browser.cjs create mode 100644 maintenance/legacy-tests/verify_reference_lines_browser.cjs create mode 100644 qa/dashboard-performance.test.cjs create mode 100644 qa/dashboard.test.cjs create mode 100644 qa/health-axis-minimum.test.cjs create mode 100644 qa/health-calendar.test.cjs create mode 100644 qa/health-interaction.test.cjs create mode 100644 qa/health-references.test.cjs create mode 100644 qa/health-views.test.cjs create mode 100644 qa/test_workday_calendar.py create mode 100644 scripts/sync_legacy_workspace.py create mode 100644 src/mi_fitness_mcp/web_assets/static/calendars/2025.json create mode 100644 src/mi_fitness_mcp/web_assets/static/calendars/2026.json create mode 100644 src/mi_fitness_mcp/web_assets/static/calendars/LICENSE.holiday-cn.txt create mode 100644 src/mi_fitness_mcp/web_assets/static/css/health.css create mode 100644 src/mi_fitness_mcp/web_assets/static/js/health-ui.js create mode 100644 src/mi_fitness_mcp/web_assets/static/js/health-views.js create mode 100644 src/mi_fitness_mcp/workday_calendar.py diff --git a/.gitignore b/.gitignore index a6e01df..08b05ec 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,26 @@ config.json *.db-* *.zip .backup/ + +# NAS 私有运行数据与本机验证产物 +.env.* +!.env.example +state/ +secrets/ +calendar/ +*.tar +*.tar.gz +*.pem +*.key +*.p12 +*.pfx +*.jsonl +*.jsonl.gz +*.xlsx +*.pdf +artifacts/ +test-results/ +deploy/releases/ +deploy/private/ +maintenance/legacy-outputs/ +*.local.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2be5366 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,10 @@ +# 本健康项目工作约定 + +- 默认简体中文,UTF-8无BOM,保护无关改动。 +- 主代码目录是 D:\CodeProject\mi-fitness-health,后续修改首先保存在本仓库。 +- 本仓库保留上游Git历史;当前本地部署修复保存在 nas-health 分支。本地提交不包含推送授权。 +- 所有合成数据、性能、浏览器和Linux容器测试只在本机执行,不在生产NAS上测试。 +- 修改NAS既有容器、删除数据、改变网络暴露范围前,说明具体内容并取得用户确认。 +- 不读取或提交真实密码、Token、私钥、健康数据库、原始归档、健康导出或.env。 +- 后续产品代码改动完成后执行 python scripts/sync_legacy_workspace.py,更新旧Codex工作区的兼容副本。脚本如报冲突,停止并核对,不覆盖用户另外的改动。 +- 迁移后的有效测试在 tests/ 和 qa/;maintenance/legacy-tests/是留存的旧环境测试脚本,路径尚未迁移,不作为当前验收入口。 diff --git a/README-NAS.md b/README-NAS.md new file mode 100644 index 0000000..fe3708f --- /dev/null +++ b/README-NAS.md @@ -0,0 +1,29 @@ +# NAS健康同步项目 + +本目录是项目主代码目录。上游介绍保留在README.md,本项目部署与维护说明以本文件及docs/nas为准。 + +## 当前版本 + +2026-10-17:backend/worker为20261017-audit3,web为20261017-ui7;UI7已成功部署。包含小米中国区云同步、SQLite长期保存与原始归档、备份恢复、密码鉴权、八类健康查询、紧凑参考线、纵轴下限和工作日/非工作日标记。ChatGPT隧道仍未启用。 + +新的后端汇总与分页优化处于设计审阅阶段,尚未实现或部署。 + +## 目录 + +- src/mi_fitness_mcp:当前全部产品代码(包含前后端)。 +- tests:上游及已修改的模块测试;qa:当前网页统计、交互与日历回归测试。 +- deploy/runtime.py:归档、加密凭据与同步worker运行支持;deploy/readonly.py:未启用的只读MCP支持。 +- deploy/compose.yml、deploy/compose.override.yml:部署配置源文件,无密码。 +- maintenance/legacy-tests:旧验证脚本源码,仅作历史留存。 +- docs/nas:当前版本、代码迁移与后端优化设计。 +- scripts/sync_legacy_workspace.py:把本仓库产品代码同步回旧工作区,逐文件检测冲突。 + +## 本机验证 + +沿用pyproject.toml中的Python依赖,建议uv创建独立开发环境。已有环境可执行 `python -m pytest tests/test_web.py -q` 和 `python qa/test_workday_calendar.py`;前端使用 `node --test qa/*.test.cjs`。测试只使用临时或合成数据。 + +此仓库不包含.env、密钥环、数据库、真实健康记录、镜像包和第三方依赖缓存;不向Git提交这些内容。上游历史保持,远程upstream只作来源,未创建或推送任何网络仓库。 + +## 后续维护 + +首先修改本目录并按影响运行本机测试,再执行兼容副本同步并核对Git差异。NAS部署需使用已验证的版本和明确批准的容器范围。现有旧版增量Dockerfile依赖本机基础镜像;完整可复现构建将在后端优化实施时统一整理,不能把旧覆盖镜像文件当成从零构建方案。 diff --git a/deploy/compose.override.yml b/deploy/compose.override.yml new file mode 100644 index 0000000..e94ef42 --- /dev/null +++ b/deploy/compose.override.yml @@ -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 diff --git a/deploy/compose.yml b/deploy/compose.yml new file mode 100644 index 0000000..c5322bd --- /dev/null +++ b/deploy/compose.yml @@ -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 diff --git a/deploy/legacy-ui7/Dockerfile b/deploy/legacy-ui7/Dockerfile new file mode 100644 index 0000000..6b6f0b6 --- /dev/null +++ b/deploy/legacy-ui7/Dockerfile @@ -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 diff --git a/deploy/legacy-ui7/check-web.py b/deploy/legacy-ui7/check-web.py new file mode 100644 index 0000000..20e0ef4 --- /dev/null +++ b/deploy/legacy-ui7/check-web.py @@ -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('新版网页与三项新增资源就绪;当前自设密码验证通过,匿名访问仍需认证。') diff --git a/deploy/legacy-ui7/update-web.sh b/deploy/legacy-ui7/update-web.sh new file mode 100644 index 0000000..bbe4bf5 --- /dev/null +++ b/deploy/legacy-ui7/update-web.sh @@ -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' diff --git a/deploy/readonly.py b/deploy/readonly.py new file mode 100644 index 0000000..4ac0e51 --- /dev/null +++ b/deploy/readonly.py @@ -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() diff --git a/deploy/runtime.py b/deploy/runtime.py new file mode 100644 index 0000000..fa05487 --- /dev/null +++ b/deploy/runtime.py @@ -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() diff --git a/docs/nas/backend-query-design.md b/docs/nas/backend-query-design.md new file mode 100644 index 0000000..bbbfa8e --- /dev/null +++ b/docs/nas/backend-query-design.md @@ -0,0 +1,62 @@ +# 后端汇总与分页查询设计 + +日期:2026-10-17。状态:待用户审阅;尚未修改查询接口或部署后端。 + +## 目标与范围 + +减少近7天、30天查询时浏览器接收、解析和遍历密集健康样本的负担。保持现有八类健康数据查询页面、指标口径、参考线、纵轴下限、工作日颜色与分页交互。所有开发、合成数据和性能测试在本机执行。NAS只做正式部署和必要的就绪核对。 + +代码主目录为 `D:\CodeProject\mi-fitness-health`,保留上游Git历史及此前已经完成的本地修复,建立当前版本的本地基线提交。后续修改首先写入此目录;旧Codex工作区通过受控镜像保留兼容副本,冲突时停止,不覆盖用户另外改动。不上传密码、数据库、健康导出、镜像包、运行日志或密钥。未经另行授权不推送远端。 + +## 方案选择 + +采用后端按范围汇总、服务端明细分页和服务端CSV导出。浏览器只接收图表点、数值统计、参考线所需均值,以及当前页最多10条明细。保留已有原始查询接口,避免破坏其他客户端或未来只读接入。 + +不采用仅把JavaScript循环平移到后端并继续返回全部样本的方式:传输和JSON解析负担仍在。也不引入新数据库、预计算历史表或常驻任务,以免增加同步一致性、存储迁移和维护成本。 + +## 数据读取与接口 + +新增只读查询入口,例如 `GET /api/views/{kind}`,kind仅允许八类已支持的数据。接收北京时间日期范围、原有类型/压力/小睡/最新记录/数量限制,以及页码。返回: + +- 统计数值、记录数、覆盖日期数、参考线均值。 +- 按天图表数据;长范围沿用最多120个汇总桶。 +- 当前页的规范化明细、总条数、页码和固定页长10。 +- 查询范围及筛选说明,便于前端拒绝晚到的旧请求。 + +分页换页只读取新一页明细,不重复下载全部原始样本,也不必重新汇总。汇总与首次明细采用同一SQLite读取事务;后续分页采用稳定的时间和主键排序,避免相同时间记录随机换位。同步期间记录更新时允许下次查询看到新数据,不长期持有数据库快照。 + +新增带相同筛选参数的只读CSV导出入口。用户点击导出时由服务器生成附件,不在浏览器重建完整样本数组;服务端采用游标分批写出,限制内存峰值。保留中文表头、全部查询结果、源字段和公式注入防护。 + +总览页也使用后端汇总,不再为了六个卡片拉取六份完整明细。原有API、同步端、worker和历史数据保持兼容。 + +## 统计与筛选口径 + +- 所有时间戳按北京时间归入日期,明确处理带UTC/时区偏移的记录。睡眠依醒来日期汇总;实际睡眠时长优先,缺失时才用旧时长字段回退。 +- 步数按有数值记录的日期求日均;缺失日期不补0。距离与热量使用原有单位。 +- 心率保留被动、静息、其他类型的每日均值;总平均使用筛选后的全部样本,静息平均仅用静息样本,不以每日均值再平均冒充样本均值。 +- 睡眠包含或排除小睡,阶段缺失与阶段合计不一致仍按现有逻辑显示未分期。均值按有记录醒来日计算。 +- 体重图保留每日最近一次测量,均值按全部筛选体重测量记录计算。 +- 血氧、压力均值按全部筛选样本计算。压力使用1–25放松、26–50轻度、51–80中度、81–100重度,保留源level字段;0和越界值继续未分类。 +- 数量限制先按北京时间日期和类型筛选,再选最近N条;统计、图表、明细和导出使用同一集合。最新体重筛选遵守同一原则。 +- 固定参考值仍由现有前端配置控制;服务端返回统计均值。前端只做格式化、图表坐标与布局,不扫描全部健康样本。 + +## 查询与安全 + +使用SQLite聚合、条件筛选和LIMIT/OFFSET读取,优先复用user_id与时间字段已有索引;必要时先以宽时间范围缩小候选,再按北京时间精确过滤。不要为本次变化重写历史时间戳或改变数据表结构。所有动态排序、字段、数据类型均采用白名单,条件参数化;样本详情白名单投影,不泄露设备标识、账号或鉴权信息。 + +新增接口沿用现有API授权与网页代理。数据库不增加对外端口,不改Tailscale/Nginx。请求日期范围和页码设上限,避免异常查询放大负载。日历仍由网页服务缓存,更新不会阻塞健康查询。 + +## 验证与验收 + +1. 用同一批合成样本对照现有前端统计,覆盖八类数据、缺失/零值、跨午夜、睡眠小睡、相同时间记录、筛选与最近N条;新旧总数、均值、分期与导出一致。 +2. 验证分页无重复漏项,非法参数/未授权请求被拒绝;原有接口仍可调用。 +3. 在本机SQLite准备30天密集样本,记录旧版与新版响应体积、服务端耗时、浏览器加载耗时和页面节点。验收重点:前端首屏明细不超过10条、图表不超过120桶;随着原始样本增加,首屏传输不随样本数量线性增加。 +4. 浏览器检查近7天/30天、翻页、导出、日期切换取消旧响应,确认页面布局与图例不变;本机容器验证认证、持久化挂载与升级回退。 + +## 部署与回退 + +本机验证完成后制作可审阅的离线包,明确后端和网页镜像版本、变更范围、对照结果。正式更新前按用户要求再次确认将替换的现有容器。默认仅替换健康项目backend和web;worker继续原镜像,其他生产服务、密码、端口、数据库和数据目录不改。保留旧镜像与覆盖配置,失败恢复原配置。没有数据结构迁移,回退不需恢复或删除健康数据。 + +## 已知边界 + +SQLite仍需读取/聚合选中范围;目标是降低浏览器负担与传输,而非承诺任意规模下固定响应时间。极长范围和大导出可能耗时,应通过实测说明。年份日历更新和健康数据统计独立。本设计审阅通过后再形成实施顺序并开始修改接口。 diff --git a/docs/nas/source-migration.md b/docs/nas/source-migration.md new file mode 100644 index 0000000..204f960 --- /dev/null +++ b/docs/nas/source-migration.md @@ -0,0 +1,9 @@ +# 代码迁移记录 + +日期:2026-10-17。 + +从旧工作区复制 51 个产品及上游文件,逐文件SHA256一致,保留上游Git提交历史与本地未提交修复。旧工作区保留;主目录改为 D:\CodeProject\mi-fitness-health。 + +此仓库不包含旧环境的授权状态、真实健康样本、密码、.env、密钥、数据库、归档、导出、镜像包或依赖目录。旧测试源码留存在maintenance/legacy-tests中;有效网页回归已迁移到qa并调整定位路径。 + +兼容同步脚本只从本仓库的src/、tests/及明确列出的根配置文件向旧代码目录复制;新增/更新前核对上一轮哈希,不删除旧文件,不覆盖并发修改。不向NAS同步或执行部署。 diff --git a/maintenance/legacy-tests/apply-health-layout.py b/maintenance/legacy-tests/apply-health-layout.py new file mode 100644 index 0000000..ff1fb8c --- /dev/null +++ b/maintenance/legacy-tests/apply-health-layout.py @@ -0,0 +1,49 @@ +"""本机修改网页资源,保留文件编码和换行。""" +from pathlib import Path +import re + +base=Path('work/mi-fitness-mcp-cn/src/mi_fitness_mcp/web_assets') +def edit(path,fn): + raw=path.read_bytes();crlf=b'\r\n' in raw + text=raw.decode('utf-8').replace('\r\n','\n') + updated=fn(text) + path.write_bytes((updated.replace('\n','\r\n') if crlf else updated).encode('utf-8')) + +def app(s): + s=s.replace(" inspectorRequestId: 0,", " inspectorRequestId: 0,\n healthRequestId: 0,\n healthViews: {},\n healthController: null,") + s=s.replace(" if (state.activeTab === 'overview') return loadOverview();", " return reloadActiveView();",1) + s=s.replace("function switchTab(tabId) {\n state.activeTab = tabId;", "function switchTab(tabId) {\n invalidateHealthQuery();\n clearInspector();\n state.activeTab = tabId;") + s=s.replace(" if (tabId === 'overview') return loadOverview();", " return reloadActiveView();",1) + s=s.replace(" headers: getHeaders(),\n };", " headers: getHeaders(),\n ...(view.signal ? {signal: view.signal} : {}),\n };",1) + replacements={ + 'querySummary':"return loadHealthView('activity');", + 'queryHeartRate':"return loadHealthView('heartrate');", + 'querySleep':"return loadHealthView('sleep');", + 'queryWorkouts':"return loadHealthView('workouts');", + 'queryBody':"return loadHealthView('body');", + 'querySpo2':"return loadHealthView('spo2');", + 'queryStress':"return loadHealthView('stress');", + 'queryAbnormalHr':"return loadHealthView('abnormal');", + } + for name,body in replacements.items(): + s,n=re.subn(r'async function '+name+r'\(\) \{.*?\n\}',f'async function {name}() {{\n {body}\n}}',s,count=1,flags=re.S) + assert n==1,name + s=s.replace(' initTheme();\n initDates();',' initTheme();\n initHealthViews();\n initDates();',1) + s=s.replace(" if (state.activeTab === 'overview') loadOverview();", " clearInspector();\n reloadActiveView();",1) + return s + +def html(s): + s=s.replace('20261017-ui3','20261017-ui4') + s=s.replace('