mirror of
https://github.com/This-weiyuqing/mi-fitness-health.git
synced 2026-10-18 03:03:49 +08:00
qa: validate server queries and prepare offline backend web update
This commit is contained in:
1 parent
6292755ff9
commit
c076bbc0d6
11 files changed
+640
-1
No files matched your search
+9
-1
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
2026-10-17:backend/worker为20261017-audit3,web为20261017-ui7;UI7已成功部署。包含小米中国区云同步、SQLite长期保存与原始归档、备份恢复、密码鉴权、八类健康查询、紧凑参考线、纵轴下限和工作日/非工作日标记。ChatGPT隧道仍未启用。
|
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只作来源,未创建或推送任何网络仓库。
|
此仓库不包含.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依赖本机基础镜像;完整可复现构建将在后端优化实施时统一整理,不能把旧覆盖镜像文件当成从零构建方案。
|
首先修改本目录并按影响运行本机测试,再执行兼容副本同步并核对Git差异。NAS部署需使用已验证的版本和明确批准的容器范围。现有旧版增量Dockerfile依赖本机基础镜像;完整可复现构建将在后端优化实施时统一整理,不能把旧覆盖镜像文件当成从零构建方案。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* text eol=lf
|
||||||
@@ -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
|
||||||
@@ -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 '部署未通过,请保留终端输出和回退备份。' }
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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'
|
||||||
@@ -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就绪脚本只访问资源/代理状态、匿名认证、日历和无效日期路由,不进行健康导出、合成/压力测试。
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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('<svg'),kind+'缺图表');
|
||||||
|
const relevant=requests.slice(before).filter(r=>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)fs.writeFileSync(path.join(OUT,'download-failure.json'),JSON.stringify({mode,downloads,requests:requests.slice(-8),responses:responses.slice(-8),errors},null,2));
|
||||||
|
assert(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;});
|
||||||
Reference in new issue
Block a user