diff --git a/README-NAS.md b/README-NAS.md index fe3708f..a0be008 100644 --- a/README-NAS.md +++ b/README-NAS.md @@ -6,7 +6,7 @@ 2026-10-17:backend/worker为20261017-audit3,web为20261017-ui7;UI7已成功部署。包含小米中国区云同步、SQLite长期保存与原始归档、备份恢复、密码鉴权、八类健康查询、紧凑参考线、纵轴下限和工作日/非工作日标记。ChatGPT隧道仍未启用。 -新的后端汇总与分页优化处于设计审阅阶段,尚未实现或部署。 +后端汇总与分页优化已在本机以合成数据完成集成验证,离线候选镜像为 `20261017-summary8`;NAS仍为上述audit3/ui7,尚未部署。八类查询的统计、最多120个图表桶与每页10条明细由后端返回,CSV按流传输。 ## 目录 @@ -24,6 +24,14 @@ 此仓库不包含.env、密钥环、数据库、真实健康记录、镜像包和第三方依赖缓存;不向Git提交这些内容。上游历史保持,远程upstream只作来源,未创建或推送任何网络仓库。 +汇总优化的合成数据、旧查询对照和独立Edge验收入口为 `qa/serve_query_fixture.py`、`qa/benchmark_server_queries.py`、`qa/verify_server_queries_browser.cjs`。全部数据库、浏览器profile、截图、日志和离线包仅放忽略的 `artifacts/query-summary/`。实际测量、限制与复现步骤见 [后端查询验证报告](docs/nas/backend-query-validation.md)。 + +## summary8候选更新包 + +`deploy/query-summary/`包含离线增量Dockerfile、仅替换backend/web镜像的覆盖配置、哈希防覆盖和回退脚本。基础镜像audit3必须在本机已有,构建不安装依赖;worker继续audit3。发布包为本机 `artifacts/query-summary/release/`,不提交Git。 + +正式更新前须明确确认仅替换健康项目backend和web。确认后由用户在交互PowerShell运行 `deploy/query-summary/Update-Nas.ps1 -Package artifacts/query-summary/release -Deploy` 并在终端输入sudo密码;不加 `-Deploy` 只校验本地发布包。脚本沿用原端口、密码来源和数据/日历挂载,不处理其他NAS服务。就绪检查只检查资源、代理、鉴权和无效日期的新路由,不运行合成测试或读取健康记录。 + ## 后续维护 首先修改本目录并按影响运行本机测试,再执行兼容副本同步并核对Git差异。NAS部署需使用已验证的版本和明确批准的容器范围。现有旧版增量Dockerfile依赖本机基础镜像;完整可复现构建将在后端优化实施时统一整理,不能把旧覆盖镜像文件当成从零构建方案。 diff --git a/deploy/query-summary/.gitattributes b/deploy/query-summary/.gitattributes new file mode 100644 index 0000000..fcadb2c --- /dev/null +++ b/deploy/query-summary/.gitattributes @@ -0,0 +1 @@ +* text eol=lf diff --git a/deploy/query-summary/Dockerfile b/deploy/query-summary/Dockerfile new file mode 100644 index 0000000..d03fde0 --- /dev/null +++ b/deploy/query-summary/Dockerfile @@ -0,0 +1,6 @@ +# 离线增量镜像;发布清单另固定基础镜像digest,不联网安装依赖。 +FROM local/mi-fitness-nas:20261017-audit3 +USER root +COPY src/mi_fitness_mcp/ /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/query-summary/Update-Nas.ps1 b/deploy/query-summary/Update-Nas.ps1 new file mode 100644 index 0000000..66009fa --- /dev/null +++ b/deploy/query-summary/Update-Nas.ps1 @@ -0,0 +1,30 @@ +param( + [Parameter(Mandatory=$true)][string]$Package, + [string]$SshHost = 'fn', + [switch]$Deploy +) +$ErrorActionPreference = 'Stop' +$release = (Resolve-Path -LiteralPath $Package).Path +$manifest = Join-Path $release 'manifest.sha256' +foreach ($line in Get-Content -LiteralPath $manifest -Encoding utf8) { + if ($line -notmatch '^([a-f0-9]{64}) ([A-Za-z0-9._-]+)$') { throw '发布清单格式不符' } + if ((Get-FileHash -LiteralPath (Join-Path $release $Matches[2]) -Algorithm SHA256).Hash.ToLowerInvariant() -ne $Matches[1]) { throw '本地发布文件校验失败' } +} +if (-not $Deploy) { + Write-Output '本地发布包校验通过。仅更新backend/web,worker、端口、密码和数据挂载沿用。' + Write-Output '得到明确部署确认后,在交互PowerShell使用同一命令加 -Deploy;sudo密码只在终端输入。' + return +} +if ($SshHost -notmatch '^[A-Za-z0-9._-]+$') { throw 'SSH别名无效' } +$remote = '/tmp/mi-fitness-summary8-' + (Get-Date -Format 'yyyyMMdd-HHmmss') +& ssh $SshHost "umask 077; mkdir '$remote'" +if ($LASTEXITCODE -ne 0) { throw '创建远程发布目录失败' } +foreach ($line in Get-Content -LiteralPath $manifest -Encoding utf8) { + $name = ($line -split ' ',2)[1] + & scp (Join-Path $release $name) "${SshHost}:$remote/$name" + if ($LASTEXITCODE -ne 0) { throw '发布文件传输失败' } +} +& scp $manifest "${SshHost}:$remote/manifest.sha256" +if ($LASTEXITCODE -ne 0) { throw '发布清单传输失败' } +& ssh -t $SshHost "sudo sh '$remote/update-health.sh'" +if ($LASTEXITCODE -ne 0) { throw '部署未通过,请保留终端输出和回退备份。' } diff --git a/deploy/query-summary/check-ready.py b/deploy/query-summary/check-ready.py new file mode 100644 index 0000000..d2a7f24 --- /dev/null +++ b/deploy/query-summary/check-ready.py @@ -0,0 +1,40 @@ +"""部署后最小就绪核对;不查询健康记录,不输出凭据。""" +import json +import subprocess +import sys + +IMAGE = 'local/mi-fitness-nas:20261017-summary8' +items = json.loads(subprocess.check_output(['docker', 'inspect', 'mi-fitness-health-backend-1', 'mi-fitness-health-web-1', 'mi-fitness-health-worker-1'])) +backend, web, worker = items +assert backend['Config']['Image'] == web['Config']['Image'] == IMAGE +assert worker['Config']['Image'] == 'local/mi-fitness-nas:20261017-audit3' +if len(sys.argv) > 1: + assert backend['Image'] == web['Image'] == sys.argv[1] +assert backend['HostConfig']['ReadonlyRootfs'] and web['HostConfig']['ReadonlyRootfs'] +assert any(m['Destination'] == '/state' for m in backend['Mounts']) +assert any(m['Destination'] == '/run/secrets/keyring_master' and not m['RW'] for m in backend['Mounts']) +assert any(m['Destination'] == '/calendar' for m in web['Mounts']) +assert set(web['HostConfig']['PortBindings']) == {'8322/tcp'} +assert {(p['HostIp'], p['HostPort']) for p in web['HostConfig']['PortBindings']['8322/tcp']} == {('127.0.0.1', '18322'), ('192.168.31.201', '18322')} +# 密码只在容器内部使用,不读取到控制端、不打印、不放在命令行。 +probe = r''' +import base64,json,os,urllib.error,urllib.request +opener=urllib.request.build_opener(urllib.request.ProxyHandler({})) +token=base64.b64encode(('admin:'+os.environ['MI_FITNESS_WEB_PASSWORD']).encode()).decode() +def get(path,auth=True): + req=urllib.request.Request('http://127.0.0.1:8322'+path,headers={'Authorization':'Basic '+token} if auth else {}) + try: + with opener.open(req,timeout=10) as r:return r.status,r.read(300000).decode() + except urllib.error.HTTPError as e:return e.code,'' +assert get('/',False)[0]==401 +assert '20261017-summary8' in get('/')[1] +for path,marker in [('/static/js/health-views.js','fromSummary'),('/static/js/health-ui.js','/api/views/'),('/static/js/app.js','/api/overview')]: + status,body=get(path);assert status==200 and marker in body +assert json.loads(get('/api/proxy_status')[1])['backend_reachable'] +for path in ['/proxy/api/views/stress','/proxy/api/views/stress/records','/proxy/api/views/stress/export','/proxy/api/overview']: + assert get(path+'?start_date=invalid&end_date=invalid')[0]==422 +assert os.environ.get('MI_FITNESS_CALENDAR_DIR')=='/calendar' +assert json.loads(get('/api/calendar?start=2026&end=2026')[1])['years']['2026']['days'] +print('backend/web资源、代理、匿名拒绝和新路由就绪;未查询健康记录。') +''' +subprocess.run(['docker', 'exec', '-i', 'mi-fitness-health-web-1', 'python', '-'], input=probe.encode('utf-8'), check=True) diff --git a/deploy/query-summary/compose.override.yml b/deploy/query-summary/compose.override.yml new file mode 100644 index 0000000..e84866e --- /dev/null +++ b/deploy/query-summary/compose.override.yml @@ -0,0 +1,17 @@ +# 沿用当前NAS的端口、密码来源和日历挂载,只升级backend/web。 +services: + backend: + image: local/mi-fitness-nas:20261017-summary8 + web: + image: local/mi-fitness-nas:20261017-summary8 + 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/query-summary/update-health.sh b/deploy/query-summary/update-health.sh new file mode 100644 index 0000000..a70e645 --- /dev/null +++ b/deploy/query-summary/update-health.sh @@ -0,0 +1,47 @@ +#!/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 manifest.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 +[ -d "$target/calendar" ] && [ ! -L "$target/calendar" ] || { echo '日历挂载目录缺失或异常,停止。'; exit 1; } +cd "$target" +compose() { docker compose --project-name mi-fitness-health --env-file .env -f compose.yml -f compose.override.yml "$@"; } +snapshot() { docker ps -aq --filter label=com.docker.compose.project=mi-fitness-health | sort | while read -r id; do docker inspect --format '{{index .Config.Labels "com.docker.compose.service"}} {{.Id}} {{.Image}}' "$id"; done | grep -v -E '^(backend|web) ' | sort; } +before=$(snapshot) +[ "$(docker inspect --format '{{.Config.Image}}' mi-fitness-health-worker-1)" = local/mi-fitness-nas:20261017-audit3 ] || { echo 'worker版本已变化,停止。'; exit 1; } +backup="$target/compose.override.pre-summary8-$(date +%Y%m%d-%H%M%S).yml" +cp compose.override.yml "$backup" +docker inspect --format '{{.Config.Image}} {{.Image}}' mi-fitness-health-backend-1 mi-fitness-health-web-1 > "$backup.images" +# 不清理旧镜像;覆盖配置和镜像ID均保留以便回退。 +docker load -i "$package/health-summary8.tar" +expected=$(cat "$package/image-id.txt") +[ "$(docker image inspect --format '{{.Id}}' local/mi-fitness-nas:20261017-summary8)" = "$expected" ] || { echo '导入镜像ID不符。'; exit 1; } +docker compose --project-name mi-fitness-health --env-file .env -f compose.yml -f "$package/compose.override.yml" config --quiet +complete=0 +recover() { + if [ "$complete" = 0 ]; then + cp "$backup" "$target/compose.override.yml" + chown 1000:1001 "$target/compose.override.yml" + chmod 600 "$target/compose.override.yml" + compose up -d --no-deps --no-build --pull never backend web || true + echo "更新未通过,已恢复原配置并尝试回退backend/web。备份:$backup" + fi +} +trap recover EXIT +cp "$package/compose.override.yml" compose.override.yml +chown 1000:1001 compose.override.yml +chmod 600 compose.override.yml +compose up -d --no-deps --no-build --pull never --wait --wait-timeout 90 backend web +python3 "$package/check-ready.py" "$expected" +[ "$before" = "$(snapshot)" ] || { echo 'worker或其他健康服务发生变化,停止验收。'; exit 1; } +complete=1 +trap - EXIT +printf '\nbackend/web更新完成;worker及其他健康服务未重建,原覆盖配置和旧镜像已保留。\n' diff --git a/docs/nas/backend-query-validation.md b/docs/nas/backend-query-validation.md new file mode 100644 index 0000000..2f21f1e --- /dev/null +++ b/docs/nas/backend-query-validation.md @@ -0,0 +1,62 @@ +# 后端汇总与分页:本机验证记录 + +日期:2026-10-17。候选镜像 `local/mi-fitness-nas:20261017-summary8`。本报告全部结果来自本机生成的SQLite和独立浏览器/Linux容器,**NAS未部署,没有NAS性能实测**。没有读取真实凭据、健康库、原始归档或导出。 + +## 数据与统计一致性 + +合成范围2026-09-18至2026-10-17,共30天。1x每天3000条心率、1000条血氧、1000条压力;另有每日两设备活动/体重、夜间睡眠与小睡、两条运动/异常样本。10x仅增加三个高频类别密度,分别90万/30万/30万条,不改变日期范围。 + +同一个只读SQLite通过旧QueryService+冻结前端oracle和新ViewQueryService核对7/30天的八类卡片、图表点、覆盖天数;完整CSV的共有业务字段集合与旧查询相同。每类另对带limit=31的完整筛选结果核对分页并集、唯一id和重复读取次序;相同时间的体重设备、运动和异常记录均覆盖。没有用大库的数千次OFFSET翻页冒充现实浏览过程。 + +同刻多设备体重是明确的规格差异:旧接口没有主键次序,冻结oracle最后一天取72.9kg;新接口按真实时间及id倒序取72.91kg。旧oracle及其输入不改,新的latest/每日点单独按稳定主键规则断言;记录集合和均值不变。睡眠CSV的小睡值True/False按布尔语义与旧库0/1核对,原CSV字节不改。 + +## 实测体积、耗时与内存 + +JSON均按UTF-8紧凑编码计字节。耗时是一次本机服务调用,不含浏览器渲染和网络;两条路径都启用tracemalloc。内存为Python分配峰值,**不包含SQLite原生内存、进程RSS或浏览器内存**。旧全量响应只在本机测量,未交给浏览器渲染。 + +|密度/类型|记录数|旧JSON字节|新首屏字节|旧/新秒|旧/新Python峰值字节| +|---|---:|---:|---:|---:|---:| +|1x心率|90,000|7,255,988|3,470|0.9609 / 0.8096|106,300,937 / 33,031| +|1x血氧|30,000|1,851,368|2,863|0.2890 / 0.2311|33,815,314 / 30,301| +|1x压力|30,000|2,775,578|3,162|0.3056 / 0.2509|35,601,460 / 31,109| +|10x心率|900,000|73,569,039|3,570|9.6272 / 8.3072|1,062,972,329 / 32,869| +|10x血氧|300,000|18,849,369|2,911|3.0202 / 2.5436|336,293,170 / 30,147| +|10x压力|300,000|28,092,399|3,239|3.2429 / 2.6703|356,123,564 / 30,963| + +六个新首屏均只有10条明细和30个图表桶,没有原始全量数组。密度10倍时首屏只增加100/48/77字节;`test_payload_does_not_scale_with_raw_samples`断言响应低于1.25倍并验证固定明细/桶数。SQL仍须扫描所选记录;本机90万心率的新汇总需要8.3072秒,不能据此宣称NAS查询会瞬时完成。 + +## 真实浏览器与Linux容器 + +独立无头Edge profile实际访问总览和八页,7/30天共16视图;每次首屏恰一条views请求,翻页只取records且保留图表。完整数据模式下首屏JSON为1571–6599字节,整个文档节点812–3093(包括已打开但隐藏的页面)。本机30天首屏心率约0.8秒、血氧/压力约0.25秒;具体每页耗时在忽略目录的browser/report.json,属于本机单次观测。 + +心率参考线、40bpm纵轴下限、工作日橙色/非工作日绿色已在真实SVG断言;深浅主题和1440px/390px截图已检查。窄屏沿用已有布局,头部控件较拥挤,本任务没有改样式。控制台、失败空态和日期/页面取消均覆盖;最终完整验收0个页面异常、0条旧全量接口请求。迟到/筛选/翻页竞态另由现有Node测试覆盖。 + +手填Key、扫码state Key、仅server Key、server Key优先于不同浏览器Key四种身份的原生附件均保存31条,1403字节且内容逐字节相同;POST表单立即清除,Key不在URL,没有CSV fetch或Blob缓冲。server-only是原生新标签GET附件,由Browser下载事件观测,容器HTTP再独立确认GET。QA最初用请求头模拟Basic,原生新标签GET未带该头而401;改成真实Basic挑战缓存后四模式通过,产品未改。 + +首轮浏览器只保存到一条text为Uncaught的异常,缺少stack,无法定位或宣称修复。随后增强exceptionDetails并完成两轮验收,未复现;原失败摘要仍保留在artifacts,后续若再出现须收集完整stack继续定位。 + +本机Linux两个独立容器使用summary8安装包代码,通过内部网络互通,无宿主机端口,CPU各0.5、内存512/256MiB、根文件系统只读;仅挂载QA脚本和合成库(只读)。GET/POST导出均200/chunked、31条1403字节;8次读首块后关闭,再次查询成功。匿名web为401,无默认上下文backend为503,错误Key为401,另一合成用户查询0条,外源Origin为403。取消后backend文件描述符8、RSS约68MiB;资源释放的精确路径由streaming专项测试覆盖,不把单次RSS当内存泄漏证明。audit3与summary8的 `/opt/health/runtime.py` SHA256同为 `1ac5a53cdbd59376a07a0021c145e55ce03e36e6890278291bed9298e03a54a5`。 + +相关Python验证85项通过(1条Starlette/httpx既有弃用警告),全部Node验证53项通过。部署shell和PowerShell语法通过;用虚构环境渲染前后Compose逐字段比较,恰只有backend/web镜像变化,worker、端口、环境和挂载一致。 + +## 复现与离线交付 + +在主仓库使用现有Python依赖环境并设置 `PYTHONPATH=src`。下列命令只处理本仓库artifacts中的合成数据,不得替换成真实库路径: + +```powershell +python qa/benchmark_server_queries.py +python qa/serve_query_fixture.py backend --port 18431 +python qa/serve_query_fixture.py web --port 18432 +python qa/serve_query_fixture.py web --port 18433 --server-key +node qa/verify_server_queries_browser.cjs +python -m pytest tests/test_view_queries.py tests/test_view_statistics.py tests/test_view_api.py tests/test_view_export.py tests/test_web_streaming.py tests/test_web.py -q +node --test qa/*.test.cjs +``` + +三个fixture各在独立本机终端启动;自动启动时必须隐藏窗口。浏览器脚本自行创建独立Edge profile并退出。密度库已存在时benchmark复用它;重新生成时使用新的artifacts输出目录,generate拒绝覆盖现有库。 + +离线构建使用 `deploy/query-summary/Dockerfile`,上下文只复制当前 `src/mi_fitness_mcp/` 源码(排除pycache),不把仓库运行文件送入构建。基础镜像须核对为audit3 `sha256:f4cbe187a98e3d3223e28e79b6edd115446ba62a78931f54f3bb0a6991a9395d`,构建选 `--network none --pull=false`;同时覆盖源码目录与installed purelib,保持audit3运行入口,无联网安装。 + +本机离线发布目录为 `artifacts/query-summary/release/`:镜像tar、image-id、基础镜像id、覆盖配置、就绪/更新脚本、预期当前配置哈希及manifest.sha256。摘要和镜像包不提交Git。`Update-Nas.ps1`不带Deploy仅校验;正式执行必须先取得仅更新backend/web的明确确认,由用户交互输入sudo密码。更新脚本核对manifest和现有覆盖配置SHA256、保留旧覆盖与镜像、不变更.env,使用 `up -d --no-deps --no-build --pull never backend web`,比较其余健康服务容器id/镜像。失败恢复旧覆盖并尝试回退这两个服务。 + +当前NAS覆盖配置防覆盖哈希由controller在部署前只读取得:`3cdbdc882b169f5785d6d1c3c46ceb159e209c567d6e28ceb31b15d4280fcdd8`。实际正式更新时脚本再次验证;若有漂移即停止。此记录不是部署确认。NAS就绪脚本只访问资源/代理状态、匿名认证、日历和无效日期路由,不进行健康导出、合成/压力测试。 diff --git a/qa/benchmark_server_queries.py b/qa/benchmark_server_queries.py new file mode 100644 index 0000000..6971126 --- /dev/null +++ b/qa/benchmark_server_queries.py @@ -0,0 +1,161 @@ +"""合成只读库的旧查询基线、新汇总、分页与 CSV 对照;输出到 artifacts。""" +import argparse +import csv +import gc +import io +import json +import math +import shutil +import subprocess +import time +import tracemalloc +from collections import Counter +from dataclasses import replace + +from serve_query_fixture import KINDS, OWNER, ROOT, ReadOnlyDatabase, artifact_path, generate +from mi_fitness_mcp.services.query_service import QueryService +from mi_fitness_mcp.services.view_query_service import ViewQueryService +from mi_fitness_mcp.storage.view_queries import KINDS as SPECS, ViewSelection +from mi_fitness_mcp.api import VIEW_CSV_COLUMNS, _view_csv_chunks + +METHODS = dict(zip(KINDS, ['get_daily_summaries', 'get_heart_rate_samples', 'get_sleep_sessions', 'get_workouts', 'get_body_measurements', 'get_spo2_samples', 'get_stress_samples', 'get_abnormal_heart_beat_events'])) + + +def measured(call): + gc.collect() + tracemalloc.start() + started = time.perf_counter() + value = call() + elapsed = time.perf_counter() - started + _, peak = tracemalloc.get_traced_memory() + tracemalloc.stop() + return value, dict(seconds=round(elapsed, 4), python_peak_bytes=peak) + + +def encoded(value): + return json.dumps(value, ensure_ascii=False, separators=(',', ':')).encode('utf-8') + + +def test_payload_does_not_scale_with_raw_samples(results): + for kind in ('heartrate', 'spo2', 'stress'): + a, b = [next(row for row in results if row['density'] == density and row['kind'] == kind) for density in (1, 10)] + assert b['raw_count'] == a['raw_count'] * 10 + assert b['summary_bytes'] < a['summary_bytes'] * 1.25, (kind, a, b) + assert a['detail_rows'] == b['detail_rows'] == 10 + assert a['point_count'] == b['point_count'] == 30 + + +def cards(kind, result): + s = result['statistics'] + return { + 'activity': [s.get('primary_sum'), s.get('total_active_kcal'), (s.get('total_distance_m') or 0) / 1000, result['coverage']], + 'heartrate': [s.get('passive_mean_bpm'), s.get('resting_mean_bpm'), s.get('primary_min'), s.get('primary_max')], + 'sleep': [s.get('mean_daily_asleep_minutes'), result['coverage'], s.get('mean_daily_deep_minutes'), s.get('nap_count')], + 'workouts': [s['count'], s.get('primary_sum'), (s.get('total_distance_m') or 0) / 1000, s.get('total_calories_kcal')], + 'body': [(s.get('latest') or {}).get('weight_kg'), (s.get('latest') or {}).get('bmi'), (s.get('latest') or {}).get('body_fat_pct'), s['count']], + 'spo2': [s.get('primary_mean'), s.get('primary_min'), s.get('primary_max'), result['coverage']], + 'stress': [s.get('primary_mean'), s.get('primary_min'), s.get('primary_max'), result['coverage']], + 'abnormal': [s['count'], s.get('primary_sum'), result['coverage'], None], + }[kind] + + +def close_enough(actual, expected): + if isinstance(expected, (int, float)): + assert math.isclose(actual, expected, abs_tol=1e-8), (actual, expected) + elif isinstance(expected, list): + assert len(actual) == len(expected) + for a, b in zip(actual, expected): + close_enough(a, b) + elif isinstance(expected, dict): + for key, value in expected.items(): + close_enough(actual[key], value) + else: + assert actual == expected, (actual, expected) + + +def consistency(db, node): + old, new = QueryService(db, OWNER), ViewQueryService(db, OWNER) + checked = [] + for days, start in [(7, '2026-10-11'), (30, '2026-09-18')]: + for kind in KINDS: + selection = ViewSelection(kind, start, '2026-10-17') + rows = getattr(old, METHODS[kind])(start, selection.end_date) + expected = json.loads(subprocess.run([node, str(ROOT / 'qa/view-statistics-oracle.cjs')], cwd=ROOT, + input=encoded(dict(kind=kind, rows=rows, range=dict(start=start, end=selection.end_date))).decode('utf-8'), + text=True, encoding='utf-8', capture_output=True, check=True).stdout) + result = new.get_view(selection) + if kind == 'body': + # 多设备同刻的旧接口次序未定义,冻结oracle保持原样;单独按批准规格验新主键次序。 + with db._get_connection() as conn: + stable = dict(conn.execute('SELECT id,weight_kg,bmi,body_fat_pct FROM body_measurements WHERE user_id=? ORDER BY timestamp DESC,id DESC LIMIT 1', (OWNER,)).fetchone()) + assert result['statistics']['latest']['id'] == stable['id'] + close_enough(cards(kind, result)[:3], [stable['weight_kg'], stable['bmi'], stable['body_fat_pct']]) + assert cards(kind, result)[3] == expected['cards'][3] + close_enough(result['statistics']['primary_mean'], sum(row['weight_kg'] for row in rows) / len(rows)) + for point in result['points']: + with db._get_connection() as conn: + wanted = conn.execute('SELECT weight_kg FROM body_measurements WHERE user_id=? AND substr(timestamp,1,10)=? ORDER BY timestamp DESC,id DESC LIMIT 1', (OWNER, point['date'])).fetchone()[0] + close_enough(point['values'], [wanted]) + else: + close_enough(cards(kind, result), expected['cards']) + close_enough(result['points'], expected['points']) + assert result['coverage'] == expected['coverage'] + assert result['statistics']['count'] == expected['count'] + # CSV 与旧查询逐行业务字段集合核对;投影附加字段另由专项测试验证。 + data = b''.join(_view_csv_chunks(new, selection)).decode('utf-8-sig') + parsed = list(csv.reader(io.StringIO(data))) + columns = [field for field, _ in VIEW_CSV_COLUMNS[kind]] + shared = [field for field in columns if all(field in row for row in rows)] + def cell(value): + return '' if value is None else json.dumps(value, ensure_ascii=False) if isinstance(value, (list, dict)) else str(int(value)) if isinstance(value, bool) else str(value) + actual = Counter(tuple(row[columns.index(field)] for field in shared) for row in parsed[1:]) + wanted = Counter(tuple(str(bool(row.get(field))) if field == 'is_nap' else cell(row.get(field)) for field in shared) for row in rows) + assert actual == wanted, (kind, days, 'CSV集合不一致') + # 有界31条检查分页并集;body/workouts/abnormal 包含相同时间的稳定id次序。 + limited = replace(selection, limit=31) + exported = list(new.iter_export(limited)) + pages = sum([new.get_page(limited, page)['page']['rows'] for page in range(1, (len(exported) + 9) // 10 + 1)], []) + assert pages == exported + assert len({row['id'] for row in pages}) == len(pages) + assert new.get_page(limited, 1)['page']['rows'] == pages[:10] + checked.append(dict(kind=kind, days=days, count=len(rows), csv_rows=len(parsed) - 1, pagination_rows=len(pages))) + return checked + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', default=str(ROOT / 'artifacts/query-summary/benchmark.json')) + parser.add_argument('--node', default=shutil.which('node') or 'D:/nodejs/node.exe') + args = parser.parse_args() + output = artifact_path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + report = dict(scope='本机合成数据;没有NAS性能实测', measurements=[], consistency=[]) + for density in (1, 10): + path = output.parent / f'synthetic-{density}x.db' + if not path.exists(): + generate(path, density) + db = ReadOnlyDatabase(path) + old, new = QueryService(db, OWNER), ViewQueryService(db, OWNER) + for kind in ('heartrate', 'spo2', 'stress'): + rows, baseline = measured(lambda: getattr(old, METHODS[kind])('2026-09-18', '2026-10-17')) + raw_bytes = len(encoded(dict(status='ok', count=len(rows), data=rows))) + raw_count = len(rows) + del rows + result, summary = measured(lambda: new.get_view(ViewSelection(kind, '2026-09-18', '2026-10-17'))) + assert set(result) == {'status', 'kind', 'range', 'statistics', 'points', 'coverage', 'stride', 'page'} + assert len(result['page']['rows']) == 10 and len(result['points']) <= 120 + row = dict(density=density, kind=kind, raw_count=raw_count, raw_json_bytes=raw_bytes, summary_bytes=len(encoded(result)), detail_rows=len(result['page']['rows']), point_count=len(result['points']), baseline=baseline, summary=summary) + report['measurements'].append(row) + print(json.dumps(row), flush=True) + output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') + if density == 1: + report['consistency'] = consistency(db, args.node) + output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') + test_payload_does_not_scale_with_raw_samples(report['measurements']) + report['test_payload_does_not_scale_with_raw_samples'] = 'PASS' + output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') + print('PASS: 16组统计/CSV/分页对照;1x/10x首屏有界。') + + +if __name__ == '__main__': + main() diff --git a/qa/serve_query_fixture.py b/qa/serve_query_fixture.py new file mode 100644 index 0000000..41ee9f2 --- /dev/null +++ b/qa/serve_query_fixture.py @@ -0,0 +1,139 @@ +"""仅本机合成数据入口;不加载用户配置、凭据或同步适配器。""" +import argparse +import json +import os +import sqlite3 +import threading +import time +from contextlib import asynccontextmanager, contextmanager +from datetime import date, datetime, timedelta, timezone +from pathlib import Path + +from mi_fitness_mcp.storage import Database + +ROOT = Path(__file__).resolve().parents[1] +KINDS = ('activity', 'heartrate', 'sleep', 'workouts', 'body', 'spo2', 'stress', 'abnormal') +OWNER = 'synthetic-owner' +KEY = 'synthetic-query-key' +PASSWORD = 'synthetic-web-password' + + +def artifact_path(value): + path = Path(value).resolve() + if not path.is_relative_to(ROOT / 'artifacts'): + raise ValueError('合成数据和输出必须位于本仓库 artifacts 目录') + return path + + +def generate(path, density=1): + path = artifact_path(path) + if path.exists(): + raise FileExistsError('拒绝覆盖已有文件:' + str(path)) + db = Database(path) + tz = timezone(timedelta(hours=8)) + with db._get_connection() as conn: + def insert(table, rows): + fields = ('id', 'provider', 'source_type', 'user_id') + tuple(rows[0]) + conn.executemany(f"INSERT INTO {table} ({','.join(fields)}) VALUES ({','.join('?' for _ in fields)})", + [(f'{table}-{offset:08}', 'synthetic', 'generated', OWNER, *row.values()) for offset, row in enumerate(rows)]) + for table, count, field in [('heart_rate_samples', 3000, 'bpm'), ('spo2_samples', 1000, 'spo2_pct'), ('stress_samples', 1000, 'stress_score')]: + rows = [] + for day in range(30): + base = datetime(2026, 9, 18, tzinfo=tz) + timedelta(days=day) + for n in range(count * density): + stamp = (base + timedelta(seconds=n * 86400 / (count * density))).isoformat() + row = dict(timestamp=stamp) + row[field] = 55 + n % 70 if field == 'bpm' else 92 + n % 9 if field == 'spo2_pct' else n % 102 + if field == 'bpm': + row['sample_type'] = ('passive', 'resting', 'active')[n % 3] + if field == 'stress_score': + row['level'] = 'synthetic-source' + rows.append(row) + insert(table, rows) + for table in ('daily_activity', 'sleep_sessions', 'workouts', 'body_measurements', 'abnormal_heart_beat_events'): + rows = [] + for day in range(30): + d = date(2026, 9, 18) + timedelta(days=day) + stamp = d.isoformat() + 'T08:00:00+08:00' + for n in range(2): + if table == 'daily_activity': + row = dict(date=d.isoformat(), device_id=f'device-{n}', steps=2000 + day * 10 + n, distance_m=1800 + day, active_kcal=100 + n, total_kcal=1000 + day) + elif table == 'sleep_sessions': + row = dict(sleep_id=f's-{day}-{n}', start_at=(d - timedelta(days=1)).isoformat() + 'T23:00:00+08:00', end_at=stamp, duration_minutes=495 if n == 0 else 30, time_asleep_minutes=480 if n == 0 else 30, time_awake_minutes=15 if n == 0 else 0, is_nap=n, stages=json.dumps([dict(stage='deep', minutes=120), dict(stage='light', minutes=280), dict(stage='rem', minutes=80)]) if n == 0 else None) + elif table == 'workouts': + row = dict(workout_id=f'w-{day}-{n}', activity_type='run', start_at=stamp, end_at=d.isoformat() + 'T09:00:00+08:00', duration_minutes=60, distance_m=8000, calories_kcal=400) + elif table == 'body_measurements': + row = dict(timestamp=stamp, device_id=f'device-{n}', weight_kg=70 + day / 10 + n / 100, bmi=22, body_fat_pct=20) + else: + row = dict(event_id=f'a-{day}-{n}', start_at=stamp, end_at=d.isoformat() + 'T08:01:00+08:00', duration_seconds=60) + rows.append(row) + insert(table, rows) + conn.execute('CREATE TABLE api_keys (key TEXT PRIMARY KEY,user_id TEXT,pass_token TEXT,region TEXT,revoked INTEGER DEFAULT 0)') + conn.executemany('INSERT INTO api_keys VALUES (?,?,?,?,0)', [(KEY, OWNER, '', 'cn'), ('synthetic-other-key', 'synthetic-other', '', 'cn')]) + conn.commit() + return path + + +class ReadOnlyDatabase(Database): + def __init__(self, path): + self.db_path = artifact_path(path) + if not self.db_path.is_file(): + raise FileNotFoundError(self.db_path) + + @contextmanager + def _get_connection(self): + conn = sqlite3.connect(self.db_path.as_uri() + '?mode=ro', uri=True) + conn.row_factory = sqlite3.Row + try: + yield conn + finally: + conn.close() + + +def backend(path): + # 替换 lifespan 只为隔离配置和密钥环;路由、鉴权、查询服务使用产品实现。 + from mi_fitness_mcp import api + from mi_fitness_mcp.services.query_service import QueryService + db = ReadOnlyDatabase(path) + @asynccontextmanager + async def lifespan(app): + app.state.db = db + app.state.default_context = None + app.state.key_contexts = {} + app.state.key_context_lock = threading.RLock() + for key, owner in [(KEY, OWNER), ('synthetic-other-key', 'synthetic-other')]: + ctx = api.UserContext(owner, 'synthetic-unused', 'cn', query_service=QueryService(db, owner)) + app.state.key_contexts[key] = dict(expires=time.monotonic() + 86400, identity=(owner, 'cn', ''), context=ctx) + yield + api.app.router.lifespan_context = lifespan + return api.app + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('mode', choices=['generate', 'backend', 'web']) + parser.add_argument('--db', default=str(ROOT / 'artifacts/query-summary/synthetic-1x.db')) + parser.add_argument('--density', type=int, choices=[1, 10], default=1) + parser.add_argument('--port', type=int, default=18431) + parser.add_argument('--backend-url', default='http://127.0.0.1:18431') + parser.add_argument('--server-key', action='store_true') + parser.add_argument('--host', default='127.0.0.1', choices=['127.0.0.1', '0.0.0.0']) + args = parser.parse_args() + for name in ('MI_FITNESS_API_KEY', 'MI_FITNESS_ADMIN_KEY'): + os.environ.pop(name, None) + os.environ['MI_FITNESS_ALLOWED_HOSTS'] = '127.0.0.1,localhost,backend,web' + os.environ['MI_FITNESS_WEB_PASSWORD'] = PASSWORD + os.environ['MI_FITNESS_CALENDAR_DIR'] = str(ROOT / 'artifacts/query-summary/calendar') + if args.mode == 'generate': + print(generate(args.db, args.density)) + elif args.mode == 'backend': + import uvicorn + uvicorn.run(backend(args.db), host=args.host, port=args.port, access_log=False) + else: + from mi_fitness_mcp.web import create_app + app = create_app(backend_url=args.backend_url, api_key=KEY if args.server_key else None) + app.run(host=args.host, port=args.port, debug=False, use_reloader=False) + + +if __name__ == '__main__': + main() diff --git a/qa/verify_server_queries_browser.cjs b/qa/verify_server_queries_browser.cjs new file mode 100644 index 0000000..328c3b0 --- /dev/null +++ b/qa/verify_server_queries_browser.cjs @@ -0,0 +1,128 @@ +/* 本机 Edge 独立profile真实DOM验收,无第三方依赖。仅访问合成fixture。 */ +const assert=require('node:assert/strict'); +const fs=require('node:fs'); +const path=require('node:path'); +const {spawn}=require('node:child_process'); +const ROOT=path.resolve(__dirname,'..'); +const OUT=path.resolve(process.env.QUERY_QA_OUTPUT||path.join(ROOT,'artifacts/query-summary/browser')); +assert(OUT.startsWith(path.join(ROOT,'artifacts')+path.sep)); +const EDGE=process.env.QUERY_QA_EDGE||'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe'; +const BASE=process.env.QUERY_QA_URL||'http://127.0.0.1:18432'; +assert(['127.0.0.1','localhost'].includes(new URL(BASE).hostname),'只允许本机fixture'); +const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms)); +class CDP{ + constructor(ws){this.ws=ws;this.next=0;this.pending=new Map();this.handlers=new Map();ws.addEventListener('message',event=>{const m=JSON.parse(event.data);if(m.id){const p=this.pending.get(m.id);this.pending.delete(m.id);m.error?p.reject(Error(JSON.stringify(m.error))):p.resolve(m.result);}else for(const fn of this.handlers.get(m.method)||[])fn(m.params,m.sessionId);});} + send(method,params={},sessionId){return new Promise((resolve,reject)=>{const id=++this.next;this.pending.set(id,{resolve,reject});this.ws.send(JSON.stringify({id,method,params,sessionId}));});} + on(method,fn){if(!this.handlers.has(method))this.handlers.set(method,[]);this.handlers.get(method).push(fn);} +} +async function main(){ + fs.mkdirSync(OUT,{recursive:true}); + const profile=path.join(OUT,'profile-'+Date.now()); + const edge=spawn(EDGE,['--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check','--disable-background-networking','--remote-debugging-port=0','--remote-debugging-address=127.0.0.1','--user-data-dir='+profile,'about:blank'],{windowsHide:true,stdio:'ignore'}); + let cdp; + try{ + let port;for(let n=0;n<100;n++){try{port=fs.readFileSync(path.join(profile,'DevToolsActivePort'),'utf8').split('\n')[0];break;}catch{await sleep(100);}} + assert(port,'Edge未就绪'); + const info=await(await fetch('http://127.0.0.1:'+port+'/json/version')).json(); + const ws=new WebSocket(info.webSocketDebuggerUrl);await new Promise((resolve,reject)=>{ws.addEventListener('open',resolve,{once:true});ws.addEventListener('error',reject,{once:true});});cdp=new CDP(ws); + const {targetId}=await cdp.send('Target.createTarget',{url:'about:blank'}); + const {sessionId:sid}=await cdp.send('Target.attachToTarget',{targetId,flatten:true}); + const send=(m,p)=>cdp.send(m,p,sid); + const evaluate=async expression=>{const r=await send('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});assert(!r.exceptionDetails,JSON.stringify(r.exceptionDetails));return r.result.value;}; + const requests=[],errors=[],downloads=[],downloadStarts=[],responses=[],report={base:BASE,scope:'本机合成fixture;未接触用户浏览器或NAS',views:[],downloads:[],failures:[]}; + cdp.on('Network.requestWillBeSent',(p,s)=>{if(s===sid)requests.push({id:p.requestId,url:p.request.url,method:p.request.method});}); + cdp.on('Network.responseReceived',(p,s)=>{if(s===sid)responses.push({id:p.requestId,url:p.response.url,status:p.response.status,bytes:p.response.encodedDataLength});}); + cdp.on('Runtime.exceptionThrown',(p,s)=>{if(s===sid)errors.push(p.exceptionDetails);}); + cdp.on('Browser.downloadProgress',p=>downloads.push(p)); + cdp.on('Browser.downloadWillBegin',p=>downloadStarts.push(p)); + await send('Runtime.enable');await send('Network.enable');await send('Page.enable'); + // 通过真实Basic挑战建立浏览器凭据缓存;注入请求头无法代表原生GET附件认证。 + cdp.on('Fetch.requestPaused',(p,s)=>{if(s===sid)send('Fetch.continueRequest',{requestId:p.requestId}).catch(()=>{});}); + cdp.on('Fetch.authRequired',(p,s)=>{if(s===sid)send('Fetch.continueWithAuth',{requestId:p.requestId,authChallengeResponse:{response:'ProvideCredentials',username:'admin',password:'synthetic-web-password'}}).catch(()=>{});}); + await send('Fetch.enable',{handleAuthRequests:true}); + await cdp.send('Browser.setDownloadBehavior',{behavior:'allow',downloadPath:OUT,eventsEnabled:true}); + await send('Page.addScriptToEvaluateOnNewDocument',{source:"localStorage.setItem('mi_fitness_api_key','synthetic-query-key');"}); + await send('Page.navigate',{url:BASE}); + for(let n=0;n<100;n++){if(await evaluate("typeof switchTab==='function' && document.readyState==='complete'"))break;await sleep(100);} + if(process.env.QUERY_QA_SCREENSHOTS_ONLY==='1'){ + await send('Emulation.setDeviceMetricsOverride',{width:1440,height:1100,deviceScaleFactor:1,mobile:false}); + await evaluate("(async()=>{$('startDate').value='2026-09-18';$('endDate').value='2026-10-17';$('hrLimit').value='';await switchTab('heartrate');})()"); + await sleep(4500); + for(const theme of ['dark','light']){ + await evaluate(`state.theme='${theme}';document.documentElement.setAttribute('data-theme','${theme}');`); + fs.writeFileSync(path.join(OUT,theme+'.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64')); + } + await send('Emulation.setDeviceMetricsOverride',{width:390,height:1100,deviceScaleFactor:1,mobile:false}); + fs.writeFileSync(path.join(OUT,'narrow.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64')); + console.log('PASS: 1440px深浅主题与390px窄屏截图;只重拍截图,未重复性能/整体验证。');return; + } + await evaluate("$('startDate').value='2026-10-11';$('endDate').value='2026-10-17';loadOverview()"); + assert.equal(await evaluate("$('statSteps').textContent.includes('--')"),false); + for(const days of [7,30])for(const kind of ['activity','heartrate','sleep','workouts','body','spo2','stress','abnormal']){ + const start=days===7?'2026-10-11':'2026-09-18',before=requests.length; + const elapsed=await evaluate(`(async()=>{$('startDate').value='${start}';$('endDate').value='2026-10-17';$('hrLimit').value='';$('spo2Limit').value='';$('stressLimit').value='';$('latestOnlyCheck').checked=false;const t=performance.now();await switchTab('${kind}');return performance.now()-t;})()`); + const value=await evaluate(`(()=>{const m=state.healthViews.${kind}?.model;return m?{rows:m.records.length,total:m.total,points:m.points.length,dom:document.querySelectorAll('*').length,html:$('healthView-${kind}').innerHTML,cards:m.cards.map(c=>c.raw),references:m.references}:null;})()`); + assert(value,kind+'未生成模型');assert(value.rows<=10);assert(value.points<=120);assert(value.html.includes('new URL(r.url).pathname==='/proxy/api/views/'+kind); + assert.equal(relevant.length,1,kind+'首屏请求次数'); + const response=responses.find(r=>r.id===relevant[0].id);assert.equal(response.status,200); + const body=await send('Network.getResponseBody',{requestId:relevant[0].id});const bytes=Buffer.byteLength(body.body); + report.views.push({days,kind,detailRows:value.rows,total:value.total,points:value.points,domNodes:value.dom,summaryBytes:bytes,loadMs:Math.round(elapsed*100)/100}); + if(value.total>10){const chart=await evaluate(`$('healthView-${kind}').querySelector('svg').outerHTML`);await evaluate(`healthPage('${kind}',2)`);assert.equal(await evaluate(`state.healthViews.${kind}.page`),2);assert.equal(await evaluate(`$('healthView-${kind}').querySelector('svg').outerHTML`),chart);assert(requests.slice(before).some(r=>r.url.includes('/records?')&&r.url.includes('page=2')));} + } + await evaluate("(async()=>{$('hrLimit').value='31';await switchTab('heartrate');})()"); + assert(await evaluate("document.querySelector('#healthView-heartrate svg').innerHTML.includes('40')"),'心率纵轴下限'); + assert(await evaluate("state.healthViews.heartrate.model.references.length>0"),'参考线'); + for(let n=0;n<30;n++){if(await evaluate("Object.keys(state.healthViews.heartrate.model.range.calendar||{}).length>0"))break;await sleep(100);} + assert(await evaluate("Object.keys(state.healthViews.heartrate.model.range.calendar||{}).length>0"),'日历未载入'); + assert(await evaluate("document.querySelector('#healthView-heartrate svg').innerHTML.includes('#22a06b')"),'非工作日颜色'); + for(const theme of ['light','dark']){ + await evaluate(`if(state.theme!=='${theme}')toggleTheme();`); + assert.equal(await evaluate("document.documentElement.dataset.theme"),theme); + const image=await send('Page.captureScreenshot',{format:'png',captureBeyondViewport:false});fs.writeFileSync(path.join(OUT,theme+'.png'),Buffer.from(image.data,'base64')); + } + await send('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:false}); + fs.writeFileSync(path.join(OUT,'narrow.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64')); + await send('Emulation.clearDeviceMetricsOverride'); + // 手填、扫码state、server优先(独立fixture端口)均使用原生附件,不在浏览器读取CSV。 + for(const mode of ['input','state']){ + await evaluate(`(async()=>{state.apiKey=${mode==='state'?"'synthetic-query-key'":"''"};$('apiKeyInput').value='synthetic-query-key';$('hrLimit').value='31';await loadHealthView('heartrate');exportHealthRecords('heartrate');})()`); + let completed;for(let n=0;n<150;n++){completed=downloads.filter(d=>d.state==='completed').length;if(completed>=report.downloads.length+1)break;await sleep(100);} + assert(completed>=report.downloads.length+1,'原生附件未完成'); + const file=path.join(OUT,'mi_fitness_heartrate_2026-09-18_2026-10-17.csv');assert(fs.existsSync(file)); + const csv=fs.readFileSync(file,'utf8');assert.equal(csv.trim().split('\n').length,32); + assert.equal(await evaluate("document.querySelectorAll('form input[name=api_key]').length"),0); + const request=requests.filter(r=>r.url.includes('/heartrate/export?')).at(-1);assert.equal(request.method,'POST');assert(!request.url.includes('synthetic')); + fs.renameSync(file,path.join(OUT,mode+'.csv'));report.downloads.push({mode,method:request.method,bytes:Buffer.byteLength(csv),rows:31}); + } + const serverBase=process.env.QUERY_QA_SERVER_URL||'http://127.0.0.1:18433'; + assert(['127.0.0.1','localhost'].includes(new URL(serverBase).hostname)); + await send('Page.navigate',{url:serverBase}); + for(let n=0;n<100;n++){if(await evaluate("typeof switchTab==='function' && document.readyState==='complete' && location.port==='"+new URL(serverBase).port+"'"))break;await sleep(100);} + for(const mode of ['server-only','server-priority']){ + await evaluate(`(async()=>{state.apiKey=${mode==='server-priority'?"'synthetic-other-key'":"''"};$('apiKeyInput').value='';$('startDate').value='2026-09-18';$('endDate').value='2026-10-17';$('hrLimit').value='31';await switchTab('heartrate');exportHealthRecords('heartrate');})()`); + assert.equal(await evaluate('state.healthViews.heartrate.model.total'),31,'服务端Key身份应优先'); + let completed;for(let n=0;n<150;n++){completed=downloads.filter(d=>d.state==='completed').length;if(completed>=report.downloads.length+1)break;await sleep(100);} + if(completed=report.downloads.length+1,'server Key原生附件未完成'); + const file=path.join(OUT,'mi_fitness_heartrate_2026-09-18_2026-10-17.csv'),csv=fs.readFileSync(file,'utf8'); + assert.equal(csv,fs.readFileSync(path.join(OUT,'input.csv'),'utf8'),'摘要/附件合成身份不一致'); + const download=downloadStarts.at(-1);assert(new URL(download.url).pathname==='/proxy/api/views/heartrate/export');assert(!download.url.includes('synthetic')); + // GET原生链接target=_blank,下载由Browser事件观测;POST在当前页另验真实method。 + const method=mode==='server-only'?'GET (native anchor)':'POST'; + if(mode==='server-priority')assert.equal(requests.filter(r=>r.url.includes('/heartrate/export?')).at(-1).method,'POST'); + fs.renameSync(file,path.join(OUT,mode+'.csv'));report.downloads.push({mode,method,bytes:Buffer.byteLength(csv),rows:31}); + } + // 真实代理故障和取消;取消只应留下当前范围的模型。 + await evaluate("(async()=>{const saved=window.fetch;window.fetch=(url,options)=>url.includes('/views/stress?')?Promise.resolve(new Response('{}',{status:503})):saved(url,options);await switchTab('stress');window.fetch=saved;})()"); + assert(await evaluate("$('healthView-stress').textContent.includes('查询失败')")); + await evaluate("(async()=>{const original=window.fetch;window.fetch=(url,opt)=>url.includes('/views/stress?')?new Promise(resolve=>setTimeout(()=>resolve(new Response(JSON.stringify({status:'error'}))),300)):original(url,opt);const old=switchTab('stress');await switchTab('body');await old;window.fetch=original;})()"); + assert.equal(await evaluate('state.activeTab'),'body');assert(await evaluate('!!state.healthViews.body?.model')); + const raw=requests.filter(r=>/\/proxy\/api\/(heart-rate|sleep|workouts|body-measurements|spo2|stress|abnormal-heart-beat|summary|metric-series)(\?|$)/.test(r.url));assert.deepEqual(raw,[],'浏览器不应调用全量原始接口'); + report.pageExceptions=errors;report.rawRequests=raw;report.requestCount=requests.length; + fs.writeFileSync(path.join(OUT,'report.json'),JSON.stringify(report,null,2)+'\n','utf8'); + assert.deepEqual(errors,[]); + console.log(JSON.stringify(report,null,2)); + }finally{if(cdp){await cdp.send('Browser.close').catch(()=>{});cdp.ws.close();}else edge.kill();} +} +main().catch(error=>{console.error(error);process.exitCode=1;});