mirror of
https://github.com/This-weiyuqing/mi-fitness-health.git
synced 2026-10-18 03:03:49 +08:00
feat: expose health view API and streaming CSV exports
This commit is contained in:
1 parent
9dbfcd73f8
commit
c9ca625de1
5 files changed
+489
-1
No files matched your search
+164
-1
@@ -18,12 +18,15 @@ login_url 确认 → GET /api/auth/qr/poll 轮询到 confirmed 并自动发放 A
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
@@ -32,7 +35,7 @@ from typing import Annotated, Any
|
||||
import httpx
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import Response
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mi_fitness_mcp.adapters.mi_fitness_cloud import MiFitnessCloudAdapter
|
||||
@@ -48,7 +51,9 @@ from mi_fitness_mcp.security import (
|
||||
)
|
||||
from mi_fitness_mcp.services.query_service import QueryService
|
||||
from mi_fitness_mcp.services.sync_service import SyncService
|
||||
from mi_fitness_mcp.services.view_query_service import ViewQueryService
|
||||
from mi_fitness_mcp.storage import Database
|
||||
from mi_fitness_mcp.storage.view_queries import ViewSelection
|
||||
|
||||
DATA_TYPES = [
|
||||
"daily_activity",
|
||||
@@ -857,6 +862,164 @@ def get_coverage(
|
||||
return {"status": "ok", "count": len(data), "data": data}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 健康视图只读查询
|
||||
|
||||
|
||||
def _view_selection(
|
||||
kind: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
sample_type: str | None = None,
|
||||
include_naps: bool = True,
|
||||
pressure_level: str | None = None,
|
||||
latest_only: bool = False,
|
||||
limit: Annotated[int | None, Query(ge=1, le=9223372036854775807)] = None,
|
||||
) -> ViewSelection:
|
||||
try:
|
||||
return ViewSelection(kind, start_date, end_date, sample_type or None,
|
||||
include_naps, pressure_level or None, latest_only, limit)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def _view_service(request: Request) -> ViewQueryService:
|
||||
context = _resolve_context(request)
|
||||
return ViewQueryService(request.app.state.db, context.user_id)
|
||||
|
||||
|
||||
@app.get("/api/views/{kind}")
|
||||
def get_view(
|
||||
request: Request,
|
||||
selection: Annotated[ViewSelection, Depends(_view_selection)],
|
||||
page: Annotated[int, Query(ge=1, le=1000000)] = 1,
|
||||
) -> dict:
|
||||
return _view_service(request).get_view(selection, page)
|
||||
|
||||
|
||||
@app.get("/api/views/{kind}/records")
|
||||
def get_view_records(
|
||||
request: Request,
|
||||
selection: Annotated[ViewSelection, Depends(_view_selection)],
|
||||
page: Annotated[int, Query(ge=1, le=1000000)] = 1,
|
||||
) -> dict:
|
||||
return _view_service(request).get_page(selection, page)
|
||||
|
||||
|
||||
@app.get("/api/overview")
|
||||
def get_overview(request: Request, start_date: str, end_date: str) -> dict:
|
||||
# 复用相同日期约束;总览始终读取完整范围。
|
||||
selection = _view_selection("activity", start_date, end_date)
|
||||
return {"status": "ok", "range": {"start": start_date, "end": end_date},
|
||||
"data": _view_service(request).get_overview(selection.start_date, selection.end_date)}
|
||||
|
||||
|
||||
# 仅导出既有规范化业务字段;稳定分页 id 以及账号、设备、鉴权字段不进入 CSV。
|
||||
VIEW_CSV_COLUMNS = {
|
||||
"activity": [("date", "日期"), ("steps", "步数(步)"), ("distance_m", "距离(米)"),
|
||||
("active_kcal", "活动热量(kcal)"), ("total_kcal", "总热量(kcal)"),
|
||||
("floors", "楼层数"), ("active_minutes", "活动时长(分钟)")],
|
||||
"heartrate": [("timestamp", "记录时间"), ("bpm", "心率(bpm)"), ("sample_type", "采样类型")],
|
||||
"sleep": [("sleep_id", "睡眠记录标识"), ("start_at", "入睡时间"), ("end_at", "醒来时间"),
|
||||
("duration_minutes", "源时长(分钟)"), ("time_asleep_minutes", "源实睡时长(分钟)"),
|
||||
("time_awake_minutes", "清醒(分钟)"), ("sleep_score", "评分"), ("is_nap", "小睡"),
|
||||
("stages", "睡眠阶段(源字段)"), ("_asleep", "实睡时长(分钟)"),
|
||||
("_deep", "深睡(分钟)"), ("_light", "浅睡(分钟)"), ("_rem", "REM(分钟)")],
|
||||
"workouts": [("workout_id", "运动记录标识"), ("activity_type", "运动类型"),
|
||||
("start_at", "开始时间"), ("end_at", "结束时间"), ("duration_minutes", "时长(分钟)"),
|
||||
("distance_m", "距离(米)"), ("calories_kcal", "热量(kcal)"),
|
||||
("avg_heart_rate_bpm", "平均心率(bpm)"), ("max_heart_rate_bpm", "最高心率(bpm)"),
|
||||
("avg_pace_sec_per_km", "平均配速(秒/公里)"), ("max_pace_sec_per_km", "最快配速(秒/公里)"),
|
||||
("total_steps", "总步数")],
|
||||
"body": [("timestamp", "测量时间"), ("weight_kg", "体重(kg)"), ("bmi", "BMI"),
|
||||
("body_fat_pct", "体脂率(%)"), ("muscle_mass_kg", "肌肉量(kg,源字段)"),
|
||||
("water_pct", "水分(%)"), ("bone_mass_kg", "骨量(kg)"),
|
||||
("visceral_fat_score", "内脏脂肪等级"), ("basal_metabolism_kcal", "基础代谢(kcal/日)"),
|
||||
("metabolic_age", "代谢年龄(岁)")],
|
||||
"spo2": [("timestamp", "记录时间"), ("spo2_pct", "血氧(%)")],
|
||||
"stress": [("timestamp", "记录时间"), ("stress_score", "压力指数"),
|
||||
("level", "源等级(原值)"), ("_pressureBand", "压力等级(当前分界)")],
|
||||
"abnormal": [("event_id", "事件记录标识"), ("start_at", "开始时间"),
|
||||
("end_at", "结束时间"), ("duration_seconds", "持续时长(秒)")],
|
||||
}
|
||||
|
||||
|
||||
def _pressure_label(value) -> str:
|
||||
if not isinstance(value, (int, float)) or value < 1 or value > 100:
|
||||
return "未分类"
|
||||
return "放松" if value <= 25 else "轻度" if value <= 50 else "中度" if value <= 80 else "重度"
|
||||
|
||||
|
||||
def _csv_cell(value):
|
||||
if isinstance(value, (list, dict)):
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
if isinstance(value, str) and value.lstrip().startswith(("=", "+", "-", "@")):
|
||||
return "'" + value
|
||||
return value
|
||||
|
||||
|
||||
def _view_csv_chunks(service: ViewQueryService, selection: ViewSelection):
|
||||
columns = VIEW_CSV_COLUMNS[selection.kind]
|
||||
buffer = io.StringIO(newline="")
|
||||
writer = csv.writer(buffer)
|
||||
writer.writerow([label for _, label in columns])
|
||||
yield ("\ufeff" + buffer.getvalue()).encode("utf-8")
|
||||
records = service.iter_export(selection)
|
||||
try:
|
||||
for record in records:
|
||||
buffer.seek(0)
|
||||
buffer.truncate(0)
|
||||
writer.writerow([_csv_cell(_pressure_label(record.get("stress_score"))
|
||||
if field == "_pressureBand" else record.get(field)) for field, _ in columns])
|
||||
yield buffer.getvalue().encode("utf-8")
|
||||
finally:
|
||||
records.close()
|
||||
|
||||
|
||||
async def _view_csv_stream(service: ViewQueryService, selection: ViewSelection):
|
||||
# sqlite3 游标有线程亲和性:创建、读取、关闭均由本次导出的单一线程执行。
|
||||
executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="health-csv")
|
||||
loop = asyncio.get_running_loop()
|
||||
state = {}
|
||||
|
||||
def advance():
|
||||
if "iterator" not in state:
|
||||
state["iterator"] = _view_csv_chunks(service, selection)
|
||||
return next(state["iterator"], None)
|
||||
|
||||
def close():
|
||||
if "iterator" in state:
|
||||
state["iterator"].close()
|
||||
|
||||
try:
|
||||
while (chunk := await loop.run_in_executor(executor, advance)) is not None:
|
||||
yield chunk
|
||||
finally:
|
||||
# 先排入关闭任务;即使当前请求被取消,任务也不能被 cancel_futures 丢弃。
|
||||
closing = executor.submit(close)
|
||||
executor.shutdown(wait=False)
|
||||
await asyncio.shield(asyncio.wrap_future(closing))
|
||||
|
||||
|
||||
class _ViewCSVResponse(StreamingResponse):
|
||||
async def stream_response(self, send):
|
||||
try:
|
||||
await super().stream_response(send)
|
||||
finally:
|
||||
# 发送失败或断开时 async for 不保证及时 aclose,显式释放数据库迭代器。
|
||||
await self.body_iterator.aclose()
|
||||
|
||||
|
||||
@app.get("/api/views/{kind}/export")
|
||||
def get_view_export(
|
||||
request: Request,
|
||||
selection: Annotated[ViewSelection, Depends(_view_selection)],
|
||||
) -> StreamingResponse:
|
||||
return _ViewCSVResponse(_view_csv_stream(_view_service(request), selection),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Cache-Control": "no-store", "Content-Disposition":
|
||||
f'attachment; filename="mi_fitness_{selection.kind}_{selection.start_date}_{selection.end_date}.csv"'})
|
||||
|
||||
|
||||
def _to_csv(rows: list[dict[str, Any]]) -> str:
|
||||
"""把查询结果扁平化为 CSV(嵌套结构转 JSON 字符串,加 BOM 便于 Excel 打开中文)。"""
|
||||
import csv
|
||||
|
||||
@@ -170,6 +170,27 @@ def create_app(
|
||||
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
parts = clean_subpath.split("/")
|
||||
if request.method == "GET" and len(parts) == 4 and parts[:2] == ["api", "views"] and parts[3] == "export":
|
||||
upstream_request = http_client.build_request("GET", target_url, headers=forward_headers)
|
||||
upstream_resp = http_client.send(upstream_request, stream=True)
|
||||
elapsed_ms = round((time.perf_counter() - started) * 1000, 2)
|
||||
# 导出只保留下载必需的响应头,不能把服务端鉴权头带回浏览器。
|
||||
response_headers = [(key, value) for key, value in upstream_resp.headers.items()
|
||||
if key.lower() in {"content-type", "content-disposition"}]
|
||||
response_headers.append(("X-Upstream-Time-Ms", str(int(elapsed_ms))))
|
||||
|
||||
def chunks():
|
||||
try:
|
||||
yield from upstream_resp.iter_bytes()
|
||||
finally:
|
||||
upstream_resp.close()
|
||||
|
||||
response = Response(chunks(), status=upstream_resp.status_code, headers=response_headers)
|
||||
# 也覆盖尚未开始消费就关闭响应的路径。
|
||||
response.call_on_close(upstream_resp.close)
|
||||
return response
|
||||
|
||||
upstream_resp = http_client.request(
|
||||
method=request.method,
|
||||
url=target_url,
|
||||
|
||||
Reference in new issue
Block a user