Initial commit: Happy Up monorepo through Sprint 5.
Document-driven MVP with FastAPI backend, Vue H5, WeChat mini shell, product demo, and Docker dev stack. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
"""Pose analysis pipeline — MediaPipe frame extraction with mock fallback."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.ai.pose_metrics import FrameLandmarks, build_movement_report, build_screening_report
|
||||
from app.mock_data import MOCK_MOVEMENT_REPORT, MOCK_REPORT
|
||||
|
||||
MAX_FRAMES = 32
|
||||
FRAME_STRIDE = 6
|
||||
|
||||
|
||||
def analyze_task(task_type: str, video_path: Path | None = None) -> dict:
|
||||
if video_path and video_path.exists():
|
||||
mediapipe_result = _analyze_video(video_path, task_type)
|
||||
if mediapipe_result:
|
||||
return mediapipe_result
|
||||
|
||||
return _mock_result(task_type)
|
||||
|
||||
|
||||
def _mock_result(task_type: str) -> dict:
|
||||
if task_type == "movement_scoring":
|
||||
return {
|
||||
"engine": "mock",
|
||||
"confidence": 0.88,
|
||||
"report": {**MOCK_MOVEMENT_REPORT},
|
||||
"modelVersion": "mock-movement-v1",
|
||||
}
|
||||
return {
|
||||
"engine": "mock",
|
||||
"confidence": 0.88,
|
||||
"report": {**MOCK_REPORT},
|
||||
"modelVersion": "mock-screening-v1",
|
||||
}
|
||||
|
||||
|
||||
def _analyze_video(video_path: Path, task_type: str) -> dict | None:
|
||||
frames = _extract_pose_frames(video_path)
|
||||
if not frames:
|
||||
return None
|
||||
|
||||
try:
|
||||
if task_type == "movement_scoring":
|
||||
report = build_movement_report(frames)
|
||||
else:
|
||||
report = build_screening_report(frames)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
confidences = []
|
||||
if task_type != "movement_scoring":
|
||||
for metric in report.get("metrics", []):
|
||||
if "confidence" in metric:
|
||||
confidences.append(metric["confidence"])
|
||||
|
||||
return {
|
||||
"engine": "mediapipe",
|
||||
"confidence": round(mean(confidences) if confidences else 0.9, 2),
|
||||
"report": report,
|
||||
"modelVersion": "mediapipe-pose-v2",
|
||||
"frameCount": len(frames),
|
||||
"sourceFile": video_path.name,
|
||||
}
|
||||
|
||||
|
||||
def _extract_pose_frames(video_path: Path) -> list[FrameLandmarks]:
|
||||
try:
|
||||
import cv2
|
||||
import mediapipe as mp
|
||||
except ImportError:
|
||||
return []
|
||||
|
||||
capture = cv2.VideoCapture(str(video_path))
|
||||
if not capture.isOpened():
|
||||
return []
|
||||
|
||||
pose = mp.solutions.pose.Pose(
|
||||
static_image_mode=False,
|
||||
model_complexity=1,
|
||||
min_detection_confidence=0.5,
|
||||
min_tracking_confidence=0.5,
|
||||
)
|
||||
|
||||
frames: list[FrameLandmarks] = []
|
||||
index = 0
|
||||
try:
|
||||
while capture.isOpened() and len(frames) < MAX_FRAMES:
|
||||
ok, image = capture.read()
|
||||
if not ok:
|
||||
break
|
||||
if index % FRAME_STRIDE != 0:
|
||||
index += 1
|
||||
continue
|
||||
index += 1
|
||||
|
||||
rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
||||
result = pose.process(rgb)
|
||||
if not result.pose_landmarks:
|
||||
continue
|
||||
|
||||
frame: FrameLandmarks = {}
|
||||
for idx, landmark in enumerate(result.pose_landmarks.landmark):
|
||||
frame[idx] = {
|
||||
"x": landmark.x,
|
||||
"y": landmark.y,
|
||||
"visibility": landmark.visibility,
|
||||
}
|
||||
frames.append(frame)
|
||||
finally:
|
||||
pose.close()
|
||||
capture.release()
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
def mean(values: list[float]) -> float:
|
||||
return sum(values) / len(values) if values else 0.0
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Pure posture metric helpers — testable without MediaPipe/OpenCV."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from statistics import mean, pstdev
|
||||
|
||||
# MediaPipe Pose landmark indices
|
||||
NOSE = 0
|
||||
LEFT_SHOULDER = 11
|
||||
RIGHT_SHOULDER = 12
|
||||
LEFT_HIP = 23
|
||||
RIGHT_HIP = 24
|
||||
|
||||
Landmark = dict[str, float]
|
||||
FrameLandmarks = dict[int, Landmark]
|
||||
|
||||
METRIC_NAMES = ("头前伸", "高低肩", "骨盆倾斜")
|
||||
|
||||
|
||||
def _point(frame: FrameLandmarks, idx: int) -> Landmark | None:
|
||||
point = frame.get(idx)
|
||||
if not point or point.get("visibility", 0) < 0.5:
|
||||
return None
|
||||
return point
|
||||
|
||||
|
||||
def _shoulder_width(frame: FrameLandmarks) -> float | None:
|
||||
left = _point(frame, LEFT_SHOULDER)
|
||||
right = _point(frame, RIGHT_SHOULDER)
|
||||
if not left or not right:
|
||||
return None
|
||||
width = abs(right["x"] - left["x"])
|
||||
return width if width > 1e-4 else None
|
||||
|
||||
|
||||
def score_head_forward(frame: FrameLandmarks) -> float | None:
|
||||
nose = _point(frame, NOSE)
|
||||
left = _point(frame, LEFT_SHOULDER)
|
||||
right = _point(frame, RIGHT_SHOULDER)
|
||||
width = _shoulder_width(frame)
|
||||
if not nose or not left or not right or not width:
|
||||
return None
|
||||
mid_x = (left["x"] + right["x"]) / 2
|
||||
offset = abs(nose["x"] - mid_x) / width
|
||||
return min(100.0, max(0.0, offset * 180))
|
||||
|
||||
|
||||
def score_shoulder_asymmetry(frame: FrameLandmarks) -> float | None:
|
||||
left = _point(frame, LEFT_SHOULDER)
|
||||
right = _point(frame, RIGHT_SHOULDER)
|
||||
width = _shoulder_width(frame)
|
||||
if not left or not right or not width:
|
||||
return None
|
||||
diff = abs(left["y"] - right["y"]) / width
|
||||
return min(100.0, max(0.0, diff * 220))
|
||||
|
||||
|
||||
def score_pelvic_tilt(frame: FrameLandmarks) -> float | None:
|
||||
left = _point(frame, LEFT_HIP)
|
||||
right = _point(frame, RIGHT_HIP)
|
||||
if not left or not right:
|
||||
return None
|
||||
angle = abs(math.degrees(math.atan2(right["y"] - left["y"], right["x"] - left["x"])))
|
||||
tilt = min(angle, 180 - angle)
|
||||
return min(100.0, max(0.0, tilt * 4.5))
|
||||
|
||||
|
||||
def value_to_level(value: float) -> str:
|
||||
if value < 35:
|
||||
return "normal"
|
||||
if value < 55:
|
||||
return "low"
|
||||
if value < 75:
|
||||
return "medium"
|
||||
return "high"
|
||||
|
||||
|
||||
def aggregate_metric(values: list[float]) -> tuple[float, str, float]:
|
||||
avg = mean(values)
|
||||
level = value_to_level(avg)
|
||||
confidence = min(0.98, 0.72 + min(len(values), 24) * 0.01)
|
||||
return round(avg, 1), level, round(confidence, 2)
|
||||
|
||||
|
||||
def build_screening_report(frames: list[FrameLandmarks]) -> dict:
|
||||
head_vals: list[float] = []
|
||||
shoulder_vals: list[float] = []
|
||||
pelvic_vals: list[float] = []
|
||||
|
||||
for frame in frames:
|
||||
head = score_head_forward(frame)
|
||||
shoulder = score_shoulder_asymmetry(frame)
|
||||
pelvic = score_pelvic_tilt(frame)
|
||||
if head is not None:
|
||||
head_vals.append(head)
|
||||
if shoulder is not None:
|
||||
shoulder_vals.append(shoulder)
|
||||
if pelvic is not None:
|
||||
pelvic_vals.append(pelvic)
|
||||
|
||||
if not head_vals and not shoulder_vals and not pelvic_vals:
|
||||
raise ValueError("insufficient_pose_frames")
|
||||
|
||||
metrics = []
|
||||
for name, values in zip(METRIC_NAMES, (head_vals, shoulder_vals, pelvic_vals), strict=True):
|
||||
if not values:
|
||||
continue
|
||||
value, level, confidence = aggregate_metric(values)
|
||||
metrics.append({"name": name, "value": value, "level": level, "confidence": confidence})
|
||||
|
||||
worst = max((m["value"] for m in metrics), default=0)
|
||||
risk_level = value_to_level(worst)
|
||||
if risk_level == "normal":
|
||||
summary = "体态指标整体正常,建议保持日常活动与姿势习惯"
|
||||
elif risk_level == "low":
|
||||
summary = "存在轻度体态偏差,建议开始基础纠正训练"
|
||||
elif risk_level == "medium":
|
||||
summary = "建议关注头前伸与高低肩,开始针对性训练"
|
||||
else:
|
||||
summary = "多项指标偏高,建议尽快安排专业评估与干预"
|
||||
|
||||
recommendations = []
|
||||
head_metric = next((m for m in metrics if m["name"] == "头前伸"), None)
|
||||
shoulder_metric = next((m for m in metrics if m["name"] == "高低肩"), None)
|
||||
if head_metric and head_metric["level"] in ("medium", "high"):
|
||||
recommendations.append("每日肩胛稳定训练 5 分钟")
|
||||
recommendations.append("颈后肌群拉伸 3 组")
|
||||
if shoulder_metric and shoulder_metric["level"] in ("medium", "high"):
|
||||
recommendations.append("对称性肩带激活训练 2 组")
|
||||
if not recommendations:
|
||||
recommendations.append("保持每日 20 分钟户外活动")
|
||||
recommendations.append("28 天后建议复测对比")
|
||||
|
||||
return {
|
||||
"riskLevel": risk_level,
|
||||
"summary": summary,
|
||||
"metrics": metrics,
|
||||
"recommendations": recommendations,
|
||||
"disclaimer": "本报告用于健康管理建议,不构成医疗诊断。",
|
||||
}
|
||||
|
||||
|
||||
def build_movement_report(frames: list[FrameLandmarks]) -> dict:
|
||||
if len(frames) < 3:
|
||||
raise ValueError("insufficient_pose_frames")
|
||||
|
||||
nose_y = []
|
||||
shoulder_angles = []
|
||||
for frame in frames:
|
||||
nose = _point(frame, NOSE)
|
||||
left = _point(frame, LEFT_SHOULDER)
|
||||
right = _point(frame, RIGHT_SHOULDER)
|
||||
if nose:
|
||||
nose_y.append(nose["y"])
|
||||
if left and right:
|
||||
shoulder_angles.append(
|
||||
math.degrees(math.atan2(right["y"] - left["y"], right["x"] - left["x"]))
|
||||
)
|
||||
|
||||
stability = 90.0
|
||||
if len(nose_y) >= 3:
|
||||
stability = max(55.0, 100.0 - pstdev(nose_y) * 900)
|
||||
|
||||
angle_score = 85.0
|
||||
if len(shoulder_angles) >= 3:
|
||||
angle_score = max(50.0, 100.0 - pstdev(shoulder_angles) * 2.5)
|
||||
|
||||
rhythm = min(100.0, 70 + len(frames) * 1.2)
|
||||
trajectory = min(100.0, stability + 5)
|
||||
completion = min(100.0, 60 + len(frames) * 2)
|
||||
score = round((trajectory + angle_score + rhythm + stability + completion) / 5)
|
||||
|
||||
return {
|
||||
"taskType": "movement_scoring",
|
||||
"score": score,
|
||||
"repsCompleted": max(1, len(frames) // 4),
|
||||
"durationSeconds": len(frames) * 8,
|
||||
"dimensions": {
|
||||
"trajectory": round(trajectory),
|
||||
"angle": round(angle_score),
|
||||
"rhythm": round(rhythm),
|
||||
"stability": round(stability),
|
||||
"completion": round(completion),
|
||||
},
|
||||
"baselineCompare": {
|
||||
"headNeckAngle": {"screening": 21, "current": max(8, 21 - score // 10), "delta": -(score // 10)},
|
||||
"shoulderDiffMm": {"screening": 12, "current": max(3, 12 - score // 12), "delta": -(score // 12)},
|
||||
},
|
||||
"disclaimer": "本报告用于运动训练反馈,不构成医疗诊断。",
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_name: str = "Kids AI Posture Platform API"
|
||||
app_env: str = "development"
|
||||
app_debug: bool = True
|
||||
database_url: str = "mysql+pymysql://happy_up:happy_up@127.0.0.1:3306/happy_up?charset=utf8mb4"
|
||||
redis_url: str = "redis://127.0.0.1:6379/0"
|
||||
jwt_secret: str = "change-me-in-production-use-32-char-min"
|
||||
jwt_expire_minutes: int = 60 * 24 * 7
|
||||
demo_sms_code: str = "682139"
|
||||
analysis_inline_process: bool = True
|
||||
analysis_queue_key: str = "happy_up:analysis_tasks"
|
||||
webhook_secret: str = "dev-webhook-secret-change-me"
|
||||
cors_origins: str = "http://localhost:5173,http://127.0.0.1:5500,null"
|
||||
api_public_url: str = "http://127.0.0.1:8000"
|
||||
upload_local_dir: str = "data/uploads"
|
||||
upload_token_expire_minutes: int = 15
|
||||
oss_enabled: bool = False
|
||||
oss_provider: str = "minio" # minio | aliyun
|
||||
oss_endpoint: str = "http://127.0.0.1:9000"
|
||||
oss_access_key: str = "minioadmin"
|
||||
oss_secret_key: str = "minioadmin"
|
||||
oss_bucket: str = "happy-up-videos"
|
||||
oss_region: str = "us-east-1"
|
||||
oss_cdn_base_url: str = ""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,43 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ApiMeta(BaseModel):
|
||||
request_id: str = Field(alias="requestId")
|
||||
timestamp: str
|
||||
trace_id: str | None = Field(default=None, alias="traceId")
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
class StandardResponse(BaseModel):
|
||||
code: int = 0
|
||||
message: str = "ok"
|
||||
data: Any | None = None
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
def build_meta(request_id: str | None = None) -> ApiMeta:
|
||||
return ApiMeta(
|
||||
requestId=request_id or uuid4().hex,
|
||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
traceId=uuid4().hex[:16],
|
||||
)
|
||||
|
||||
|
||||
def ok(data: Any = None, message: str = "ok", request_id: str | None = None) -> dict[str, Any]:
|
||||
return StandardResponse(code=0, message=message, data=data, meta=build_meta(request_id)).model_dump(
|
||||
by_alias=True
|
||||
)
|
||||
|
||||
|
||||
def error(code: int, message: str, request_id: str | None = None, **details: Any) -> dict[str, Any]:
|
||||
payload = {"errorCode": message, **details} if details else {"errorCode": message}
|
||||
return StandardResponse(code=code, message=message, data=payload, meta=build_meta(request_id)).model_dump(
|
||||
by_alias=True
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import jwt
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def hash_phone(phone: str) -> str:
|
||||
normalized = "".join(ch for ch in phone if ch.isdigit())
|
||||
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def mask_phone(phone: str) -> str:
|
||||
digits = "".join(ch for ch in phone if ch.isdigit())
|
||||
if len(digits) < 7:
|
||||
return phone
|
||||
return f"{digits[:3]}****{digits[-4:]}"
|
||||
|
||||
|
||||
def create_access_token(user_id: int, role: str) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
|
||||
payload = {"sub": str(user_id), "role": role, "exp": expire}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm="HS256")
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict:
|
||||
return jwt.decode(token, settings.jwt_secret, algorithms=["HS256"])
|
||||
@@ -0,0 +1,151 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import JSON, Boolean, Date, DateTime, Numeric, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
class Tenant(Base):
|
||||
__tablename__ = "tenants"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
type: Mapped[str] = mapped_column(String(32), nullable=False, default="organization")
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="active")
|
||||
retention_days: Mapped[int] = mapped_column(default=1095)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int | None] = mapped_column(nullable=True)
|
||||
phone: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
phone_hash: Mapped[str | None] = mapped_column(String(128), nullable=True, unique=True)
|
||||
wechat_openid: Mapped[str | None] = mapped_column(String(128), nullable=True, unique=True)
|
||||
role: Mapped[str] = mapped_column(String(32), nullable=False, default="parent")
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="active")
|
||||
consent_signed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
privacy_version: Mapped[str | None] = mapped_column(String(16), default="v1")
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class Child(Base):
|
||||
__tablename__ = "children"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int | None] = mapped_column(nullable=True)
|
||||
parent_user_id: Mapped[int] = mapped_column(nullable=False, index=True)
|
||||
coach_user_id: Mapped[int | None] = mapped_column(nullable=True)
|
||||
name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
gender: Mapped[str] = mapped_column(String(16), nullable=False, default="unknown")
|
||||
birthday: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
height: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True)
|
||||
weight: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True)
|
||||
contraindications: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="active")
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class Video(Base):
|
||||
__tablename__ = "videos"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
child_id: Mapped[int] = mapped_column(nullable=False, index=True)
|
||||
uploaded_by: Mapped[int] = mapped_column(nullable=False)
|
||||
scene: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
object_key: Mapped[str] = mapped_column(String(512), nullable=False, unique=True)
|
||||
duration_seconds: Mapped[int | None] = mapped_column(nullable=True)
|
||||
size_bytes: Mapped[int | None] = mapped_column(nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="uploaded")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class AnalysisTask(Base):
|
||||
__tablename__ = "analysis_tasks"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
child_id: Mapped[int] = mapped_column(nullable=False, index=True)
|
||||
video_id: Mapped[int] = mapped_column(nullable=False, index=True)
|
||||
task_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="CREATED")
|
||||
progress: Mapped[int] = mapped_column(default=0)
|
||||
model_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
error_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
result: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
idempotency_key: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
retry_count: Mapped[int] = mapped_column(default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class Report(Base):
|
||||
__tablename__ = "reports"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
child_id: Mapped[int] = mapped_column(nullable=False, index=True)
|
||||
task_id: Mapped[int] = mapped_column(nullable=False, unique=True)
|
||||
report_type: Mapped[str] = mapped_column(String(64), nullable=False, default="posture_screening")
|
||||
risk_level: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
summary: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
metrics: Mapped[list] = mapped_column(JSON, nullable=False)
|
||||
recommendations: Mapped[list] = mapped_column(JSON, nullable=False)
|
||||
disclaimer: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="published")
|
||||
reviewed_by: Mapped[int | None] = mapped_column(nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class Exercise(Base):
|
||||
__tablename__ = "exercises"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
category: Mapped[str] = mapped_column(String(64), nullable=False, default="posture")
|
||||
target_issue: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
difficulty: Mapped[str] = mapped_column(String(32), nullable=False, default="basic")
|
||||
duration_seconds: Mapped[int] = mapped_column(default=300)
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="active")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class TrainingPlan(Base):
|
||||
__tablename__ = "training_plans"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
child_id: Mapped[int] = mapped_column(nullable=False, index=True)
|
||||
report_id: Mapped[int | None] = mapped_column(nullable=True)
|
||||
coach_user_id: Mapped[int | None] = mapped_column(nullable=True)
|
||||
goal: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
cycle_days: Mapped[int] = mapped_column(default=28)
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="active")
|
||||
plan_detail: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
ended_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class TrainingRecord(Base):
|
||||
__tablename__ = "training_records"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
plan_id: Mapped[int] = mapped_column(nullable=False, index=True)
|
||||
child_id: Mapped[int] = mapped_column(nullable=False, index=True)
|
||||
exercise_id: Mapped[int] = mapped_column(nullable=False)
|
||||
completed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
score: Mapped[int | None] = mapped_column(nullable=True)
|
||||
duration_seconds: Mapped[int | None] = mapped_column(nullable=True)
|
||||
feedback: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
note: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Seed demo tenant, parent user and child for local development."""
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.security import hash_phone
|
||||
from app.db.models import Child, Tenant, TrainingPlan, User
|
||||
from app.db.session import SessionLocal
|
||||
from app.services.training import build_plan_detail, ensure_default_exercises
|
||||
from app.schemas.models import TrainingPlanCreateRequest
|
||||
|
||||
|
||||
def seed() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
tenant = db.scalar(select(Tenant).where(Tenant.name == "Demo Organization"))
|
||||
if not tenant:
|
||||
tenant = Tenant(name="Demo Organization", type="organization", status="active")
|
||||
db.add(tenant)
|
||||
db.flush()
|
||||
|
||||
phone = "18600000000"
|
||||
phone_hash = hash_phone(phone)
|
||||
user = db.scalar(select(User).where(User.phone_hash == phone_hash))
|
||||
if not user:
|
||||
user = User(
|
||||
tenant_id=tenant.id,
|
||||
phone=phone,
|
||||
phone_hash=phone_hash,
|
||||
role="parent",
|
||||
status="active",
|
||||
consent_signed=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
|
||||
child = db.scalar(
|
||||
select(Child).where(Child.parent_user_id == user.id, Child.name == "小明")
|
||||
)
|
||||
if not child:
|
||||
child = Child(
|
||||
parent_user_id=user.id,
|
||||
tenant_id=tenant.id,
|
||||
name="小明",
|
||||
gender="male",
|
||||
birthday=date(2016, 3, 15),
|
||||
height=142.5,
|
||||
weight=36.0,
|
||||
status="active",
|
||||
)
|
||||
db.add(child)
|
||||
db.flush()
|
||||
|
||||
ensure_default_exercises(db)
|
||||
|
||||
plan = db.scalar(
|
||||
select(TrainingPlan).where(
|
||||
TrainingPlan.child_id == child.id, TrainingPlan.status == "active"
|
||||
)
|
||||
)
|
||||
if not plan:
|
||||
body = TrainingPlanCreateRequest.model_validate(
|
||||
{"childId": child.id, "goal": "改善头前伸与高低肩", "cycleDays": 28}
|
||||
)
|
||||
detail = build_plan_detail(body)
|
||||
detail["completedDays"] = 2
|
||||
detail["currentDay"] = 3
|
||||
plan = TrainingPlan(
|
||||
child_id=child.id,
|
||||
goal=body.goal,
|
||||
cycle_days=28,
|
||||
status="active",
|
||||
plan_detail=detail,
|
||||
started_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(plan)
|
||||
|
||||
db.commit()
|
||||
child_row = db.scalar(
|
||||
select(Child).where(Child.parent_user_id == user.id, Child.name == "小明")
|
||||
)
|
||||
print(f"Seed OK · tenant={tenant.id} user={user.id} child={child_row.id if child_row else '-'}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed()
|
||||
@@ -0,0 +1,23 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
|
||||
from app.config import settings
|
||||
|
||||
engine = create_engine(settings.database_url, pool_pre_ping=True, future=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# Ensure models are registered on metadata for Alembic/tests.
|
||||
from app.db import models as _models # noqa: E402,F401
|
||||
@@ -0,0 +1,54 @@
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.response import error
|
||||
from app.core.security import decode_access_token
|
||||
from app.db.models import User
|
||||
from app.db.session import get_db
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=error(10001, "unauthorized", request.state.request_id),
|
||||
)
|
||||
try:
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
user_id = int(payload["sub"])
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=error(10001, "invalid_token", request.state.request_id),
|
||||
) from exc
|
||||
|
||||
user = db.get(User, user_id)
|
||||
if not user or user.status != "active":
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=error(10001, "user_inactive", request.state.request_id),
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def require_staff(
|
||||
request: Request,
|
||||
user: User = Depends(get_current_user),
|
||||
) -> User:
|
||||
if user.role in ("coach", "org_admin", "platform_admin"):
|
||||
return user
|
||||
from app.config import settings
|
||||
|
||||
if settings.app_debug:
|
||||
return user
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=error(10009, "forbidden_staff_only", request.state.request_id),
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
from app.middleware import RequestContextMiddleware
|
||||
from app.routers import admin, analysis, auth, children, reports, training, videos
|
||||
|
||||
CONTRACTS_OPENAPI = Path(__file__).resolve().parents[3] / "contracts" / "openapi.yaml"
|
||||
|
||||
app = FastAPI(
|
||||
title="Kids AI Posture Platform API",
|
||||
version="0.6.0",
|
||||
description=(
|
||||
"儿童 AI 体态管理平台 API · Sprint 5。"
|
||||
"MediaPipe 帧级评分 + PDF 导出 + OSS 生产配置 + H5/小程序对接。"
|
||||
),
|
||||
)
|
||||
|
||||
app.add_middleware(RequestContextMiddleware)
|
||||
|
||||
_cors_origins = ["*"] if settings.app_debug else settings.cors_origin_list
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(children.router)
|
||||
app.include_router(videos.router)
|
||||
app.include_router(analysis.router)
|
||||
app.include_router(reports.router)
|
||||
app.include_router(training.router)
|
||||
app.include_router(admin.router)
|
||||
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||
if isinstance(exc.detail, dict) and "meta" in exc.detail:
|
||||
return JSONResponse(status_code=exc.status_code, content=exc.detail)
|
||||
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok", "env": settings.app_env, "stage": "sprint5"}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {
|
||||
"name": settings.app_name,
|
||||
"docs": "/docs",
|
||||
"contract": str(CONTRACTS_OPENAPI),
|
||||
"stage": "sprint5-mediapipe-pdf-oss",
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
class RequestContextMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
request.state.request_id = request.headers.get("X-Request-Id") or uuid4().hex
|
||||
response = await call_next(request)
|
||||
response.headers["X-Request-Id"] = request.state.request_id
|
||||
return response
|
||||
@@ -0,0 +1,82 @@
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from app.schemas.models import Child, User, UserRole
|
||||
|
||||
MOCK_USER = User(id=1, phoneMasked="186****0000", role=UserRole.parent)
|
||||
|
||||
MOCK_CHILD = Child(
|
||||
id=1001,
|
||||
name="小明",
|
||||
birthday=date(2016, 3, 15),
|
||||
age=10,
|
||||
height=142.5,
|
||||
weight=36.0,
|
||||
status="active",
|
||||
)
|
||||
|
||||
MOCK_REPORT = {
|
||||
"id": 2001,
|
||||
"childId": 1001,
|
||||
"taskId": 3001,
|
||||
"riskLevel": "medium",
|
||||
"summary": "建议关注头前伸与高低肩,开始针对性训练",
|
||||
"metrics": [
|
||||
{"name": "头前伸", "value": 78, "level": "medium", "confidence": 0.91},
|
||||
{"name": "高低肩", "value": 65, "level": "low", "confidence": 0.88},
|
||||
{"name": "骨盆倾斜", "value": 92, "level": "normal", "confidence": 0.94},
|
||||
],
|
||||
"recommendations": [
|
||||
"每日肩胛稳定训练 5 分钟",
|
||||
"颈后肌群拉伸 3 组",
|
||||
"28 天后建议复测对比",
|
||||
],
|
||||
"disclaimer": "本报告用于健康管理建议,不构成医疗诊断。",
|
||||
"reviewedBy": None,
|
||||
}
|
||||
|
||||
MOCK_MOVEMENT_REPORT = {
|
||||
"id": 2002,
|
||||
"childId": 1001,
|
||||
"taskId": 3002,
|
||||
"taskType": "movement_scoring",
|
||||
"score": 81,
|
||||
"repsCompleted": 8,
|
||||
"durationSeconds": 272,
|
||||
"dimensions": {
|
||||
"trajectory": 85,
|
||||
"angle": 78,
|
||||
"rhythm": 82,
|
||||
"stability": 90,
|
||||
"completion": 100,
|
||||
},
|
||||
"baselineCompare": {
|
||||
"headNeckAngle": {"screening": 21, "current": 15, "delta": -6},
|
||||
"shoulderDiffMm": {"screening": 12, "current": 6, "delta": -6},
|
||||
},
|
||||
"disclaimer": "本报告用于运动训练反馈,不构成医疗诊断。",
|
||||
}
|
||||
|
||||
MOCK_PLAN = {
|
||||
"id": 4001,
|
||||
"status": "active",
|
||||
"detail": {
|
||||
"goal": "改善头前伸与高低肩",
|
||||
"cycleDays": 28,
|
||||
"currentDay": 3,
|
||||
"exercises": [
|
||||
{"id": 1, "name": "肩胛稳定训练", "durationMinutes": 5},
|
||||
{"id": 2, "name": "颈后肌群拉伸", "sets": 3},
|
||||
],
|
||||
},
|
||||
"startedAt": (datetime.now(timezone.utc) - timedelta(days=2)).isoformat(),
|
||||
"endedAt": None,
|
||||
}
|
||||
|
||||
MOCK_ADMIN_DASHBOARD = {
|
||||
"newChildren": 12,
|
||||
"uploadedVideos": 48,
|
||||
"completedReports": 39,
|
||||
"activePlans": 27,
|
||||
"conversionRate": 0.22,
|
||||
"reassessmentCompletionRate": 0.51,
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class AnalysisQueue:
|
||||
def push(self, task_id: int) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def pop(self, timeout: int = 5) -> int | None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
_memory_queue: list[int] = []
|
||||
|
||||
|
||||
class MemoryAnalysisQueue(AnalysisQueue):
|
||||
def push(self, task_id: int) -> None:
|
||||
_memory_queue.append(task_id)
|
||||
|
||||
def pop(self, timeout: int = 5) -> int | None:
|
||||
return _memory_queue.pop(0) if _memory_queue else None
|
||||
|
||||
|
||||
class RedisAnalysisQueue(AnalysisQueue):
|
||||
def __init__(self) -> None:
|
||||
import redis
|
||||
|
||||
self._client = redis.from_url(settings.redis_url, decode_responses=True)
|
||||
|
||||
def push(self, task_id: int) -> None:
|
||||
self._client.lpush(settings.analysis_queue_key, str(task_id))
|
||||
|
||||
def pop(self, timeout: int = 5) -> int | None:
|
||||
item = self._client.brpop(settings.analysis_queue_key, timeout=timeout)
|
||||
if not item:
|
||||
return None
|
||||
return int(item[1])
|
||||
|
||||
|
||||
_queue: AnalysisQueue | None = None
|
||||
|
||||
|
||||
def get_analysis_queue() -> AnalysisQueue:
|
||||
global _queue
|
||||
if _queue is not None:
|
||||
return _queue
|
||||
try:
|
||||
_queue = RedisAnalysisQueue()
|
||||
_queue._client.ping()
|
||||
except Exception:
|
||||
_queue = MemoryAnalysisQueue()
|
||||
return _queue
|
||||
|
||||
|
||||
def reset_analysis_queue_for_tests() -> None:
|
||||
global _queue
|
||||
_queue = MemoryAnalysisQueue()
|
||||
_memory_queue.clear()
|
||||
@@ -0,0 +1,20 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.response import ok
|
||||
from app.db.models import User
|
||||
from app.db.session import get_db
|
||||
from app.deps import require_staff
|
||||
from app.services import admin as admin_service
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["Admin"])
|
||||
|
||||
|
||||
@router.get("/dashboard")
|
||||
def admin_dashboard(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_staff),
|
||||
):
|
||||
metrics = admin_service.get_dashboard_metrics(db, tenant_id=current_user.tenant_id)
|
||||
return ok(metrics, request_id=request.state.request_id)
|
||||
@@ -0,0 +1,90 @@
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.response import error, ok
|
||||
from app.db.models import User
|
||||
from app.db.session import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.schemas.models import AnalysisTaskCreateRequest, AnalysisTaskWebhook
|
||||
from app.services import analysis as analysis_service
|
||||
|
||||
router = APIRouter(prefix="/api/analysis", tags=["Analysis"])
|
||||
|
||||
|
||||
@router.post("/tasks", status_code=201)
|
||||
def create_task(
|
||||
body: AnalysisTaskCreateRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
idempotency_key = request.headers.get("Idempotency-Key")
|
||||
task, err, duplicate = analysis_service.create_analysis_task(
|
||||
db, current_user.id, body, idempotency_key
|
||||
)
|
||||
if err == "child_not_found":
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content=error(10004, "child_not_found", request.state.request_id),
|
||||
)
|
||||
if err == "video_not_found":
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content=error(10006, "video_not_found", request.state.request_id),
|
||||
)
|
||||
if err == "duplicate" and duplicate:
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content=ok(
|
||||
analysis_service.task_to_dict(duplicate),
|
||||
message="duplicate_task",
|
||||
request_id=request.state.request_id,
|
||||
),
|
||||
)
|
||||
return ok(
|
||||
analysis_service.task_to_dict(task),
|
||||
message="created",
|
||||
request_id=request.state.request_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}")
|
||||
def get_task(
|
||||
task_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
task = analysis_service.get_task_for_user(db, current_user.id, task_id)
|
||||
if not task:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content=error(10007, "task_not_found", request.state.request_id),
|
||||
)
|
||||
return ok(analysis_service.task_to_dict(task), request_id=request.state.request_id)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/cancel")
|
||||
def cancel_task(
|
||||
task_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
task = analysis_service.cancel_task(db, current_user.id, task_id)
|
||||
if not task:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content=error(10007, "task_not_found", request.state.request_id),
|
||||
)
|
||||
return ok(
|
||||
{"taskId": task_id, "status": task.status},
|
||||
request_id=request.state.request_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/webhook", status_code=204)
|
||||
def analysis_webhook(body: AnalysisTaskWebhook, db: Session = Depends(get_db)):
|
||||
analysis_service.handle_webhook(db, body.task_id, body.status.value)
|
||||
return Response(status_code=204)
|
||||
@@ -0,0 +1,32 @@
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.core.response import error, ok
|
||||
from app.db.session import get_db
|
||||
from app.schemas.models import LoginRequest
|
||||
from app.services import auth as auth_service
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import Depends
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["Auth"])
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(body: LoginRequest, request: Request, db: Session = Depends(get_db)):
|
||||
try:
|
||||
user, token = auth_service.authenticate(db, body)
|
||||
except ValueError as exc:
|
||||
if str(exc) == "invalid_code":
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content=error(10002, "invalid_verification_code", request.state.request_id),
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content=error(10003, str(exc), request.state.request_id),
|
||||
)
|
||||
|
||||
return ok(
|
||||
{"token": token, "user": auth_service.user_to_dict(user)},
|
||||
request_id=request.state.request_id,
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.response import error, ok
|
||||
from app.db.models import User
|
||||
from app.db.session import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.schemas.models import ChildCreateRequest, ChildUpdateRequest
|
||||
from app.services import children as child_service
|
||||
|
||||
router = APIRouter(prefix="/api/children", tags=["Children"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_children(
|
||||
request: Request,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
items, total = child_service.list_children(db, current_user.id, page, page_size)
|
||||
has_more = page * page_size < total
|
||||
return ok(
|
||||
{
|
||||
"list": [child_service.child_to_dict(c) for c in items],
|
||||
"page": {
|
||||
"page": page,
|
||||
"pageSize": page_size,
|
||||
"total": total,
|
||||
"hasMore": has_more,
|
||||
},
|
||||
},
|
||||
request_id=request.state.request_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
def create_child(
|
||||
body: ChildCreateRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
idempotency = request.headers.get("Idempotency-Key")
|
||||
if idempotency:
|
||||
response.headers["Idempotency-Key"] = idempotency
|
||||
child = child_service.create_child(db, current_user.id, current_user.tenant_id, body)
|
||||
return ok(
|
||||
child_service.child_to_dict(child),
|
||||
message="created",
|
||||
request_id=request.state.request_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{child_id}")
|
||||
def get_child(
|
||||
child_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
child = child_service.get_child(db, current_user.id, child_id)
|
||||
if not child:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content=error(10004, "child_not_found", request.state.request_id),
|
||||
)
|
||||
return ok(child_service.child_to_dict(child), request_id=request.state.request_id)
|
||||
|
||||
|
||||
@router.patch("/{child_id}")
|
||||
def update_child(
|
||||
child_id: int,
|
||||
body: ChildUpdateRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
child = child_service.get_child(db, current_user.id, child_id)
|
||||
if not child:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content=error(10004, "child_not_found", request.state.request_id),
|
||||
)
|
||||
child = child_service.update_child(db, child, body)
|
||||
return ok(
|
||||
child_service.child_to_dict(child),
|
||||
message="updated",
|
||||
request_id=request.state.request_id,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{child_id}", status_code=204)
|
||||
def archive_child(
|
||||
child_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
child = child_service.get_child(db, current_user.id, child_id)
|
||||
if not child:
|
||||
return Response(status_code=404)
|
||||
child_service.archive_child(db, child)
|
||||
return Response(status_code=204)
|
||||
@@ -0,0 +1,67 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.response import error, ok
|
||||
from app.db.models import Child, User
|
||||
from app.db.session import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.services import pdf_export, reports as report_service
|
||||
|
||||
router = APIRouter(prefix="/api/reports", tags=["Reports"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_reports(
|
||||
childId: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
reports = report_service.list_reports_for_child(db, current_user.id, childId)
|
||||
if reports is None:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content=error(10004, "child_not_found", request.state.request_id),
|
||||
)
|
||||
items = [report_service.report_to_dict(report) for report in reports]
|
||||
return ok({"list": items}, request_id=request.state.request_id)
|
||||
|
||||
|
||||
@router.get("/{report_id}")
|
||||
def get_report(
|
||||
report_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
report = report_service.get_report_for_user(db, current_user.id, report_id)
|
||||
if not report:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content=error(10008, "report_not_found", request.state.request_id),
|
||||
)
|
||||
return ok(report_service.report_to_dict(report), request_id=request.state.request_id)
|
||||
|
||||
|
||||
@router.get("/{report_id}/pdf")
|
||||
def export_report_pdf(
|
||||
report_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
report = report_service.get_report_for_user(db, current_user.id, report_id)
|
||||
if not report:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content=error(10008, "report_not_found", request.state.request_id),
|
||||
)
|
||||
child = db.get(Child, report.child_id)
|
||||
pdf_bytes = pdf_export.build_report_pdf(report, child)
|
||||
filename = f"happy-up-report-{report_id}.pdf"
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.response import error, ok
|
||||
from app.db.models import User
|
||||
from app.db.session import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.schemas.models import TrainingPlanCreateRequest, TrainingRecordCreateRequest
|
||||
from app.services import training as training_service
|
||||
|
||||
router = APIRouter(prefix="/api/training", tags=["Training"])
|
||||
|
||||
|
||||
@router.get("/plans")
|
||||
def list_plans(
|
||||
childId: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
plans = training_service.list_plans_for_child(db, current_user.id, childId)
|
||||
if plans is None:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content=error(10004, "child_not_found", request.state.request_id),
|
||||
)
|
||||
return ok(
|
||||
{"list": [training_service.plan_to_dict(plan) for plan in plans]},
|
||||
request_id=request.state.request_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/plans", status_code=201)
|
||||
def create_plan(
|
||||
body: TrainingPlanCreateRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
plan = training_service.create_plan(db, current_user.id, body)
|
||||
if not plan:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content=error(10004, "child_not_found", request.state.request_id),
|
||||
)
|
||||
return ok(
|
||||
training_service.plan_to_dict(plan),
|
||||
message="created",
|
||||
request_id=request.state.request_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/plans/{plan_id}")
|
||||
def get_plan(
|
||||
plan_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
plan = training_service.get_plan_for_user(db, current_user.id, plan_id)
|
||||
if not plan:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content=error(10010, "plan_not_found", request.state.request_id),
|
||||
)
|
||||
return ok(training_service.plan_to_dict(plan), request_id=request.state.request_id)
|
||||
|
||||
|
||||
@router.post("/plans/{plan_id}/records", status_code=201)
|
||||
def create_record(
|
||||
plan_id: int,
|
||||
body: TrainingRecordCreateRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
record = training_service.create_record(db, current_user.id, plan_id, body)
|
||||
if not record:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content=error(10010, "plan_not_found", request.state.request_id),
|
||||
)
|
||||
return ok(
|
||||
{
|
||||
"id": record.id,
|
||||
"planId": plan_id,
|
||||
"exerciseId": record.exercise_id,
|
||||
"score": record.score,
|
||||
"completed": record.completed,
|
||||
"createdAt": record.created_at.isoformat(),
|
||||
},
|
||||
message="created",
|
||||
request_id=request.state.request_id,
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
from fastapi import APIRouter, Depends, Header, Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.response import error, ok
|
||||
from app.db.models import User
|
||||
from app.db.session import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.schemas.models import UploadTokenRequest, VideoCreateRequest
|
||||
from app.services import videos as video_service
|
||||
|
||||
router = APIRouter(prefix="/api/videos", tags=["Videos"])
|
||||
|
||||
|
||||
@router.post("/upload-token")
|
||||
def create_upload_token(
|
||||
body: UploadTokenRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if not video_service.verify_child_owner(db, current_user.id, body.child_id):
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content=error(10004, "child_not_found", request.state.request_id),
|
||||
)
|
||||
data = video_service.create_upload_token(body)
|
||||
return ok(data, request_id=request.state.request_id)
|
||||
|
||||
|
||||
@router.put("/direct-upload")
|
||||
async def direct_upload(
|
||||
request: Request,
|
||||
x_upload_token: str = Header(..., alias="X-Upload-Token"),
|
||||
x_object_key: str = Header(..., alias="X-Object-Key"),
|
||||
):
|
||||
data = await request.body()
|
||||
try:
|
||||
video_service.save_direct_upload(x_upload_token, x_object_key, data)
|
||||
except ValueError:
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content=error(10011, "invalid_upload_token", request.state.request_id),
|
||||
)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
def register_video(
|
||||
body: VideoCreateRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
video, err = video_service.register_video(db, current_user.id, body)
|
||||
if err == "child_not_found":
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content=error(10004, "child_not_found", request.state.request_id),
|
||||
)
|
||||
if err == "duplicate":
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content=error(10005, "duplicate_video", request.state.request_id),
|
||||
)
|
||||
return ok(
|
||||
{"id": video.id, "status": video.status},
|
||||
message="created",
|
||||
request_id=request.state.request_id,
|
||||
)
|
||||
@@ -0,0 +1,165 @@
|
||||
from datetime import date, datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LoginType(str, Enum):
|
||||
phone_code = "phone_code"
|
||||
wechat = "wechat"
|
||||
|
||||
|
||||
class UserRole(str, Enum):
|
||||
parent = "parent"
|
||||
coach = "coach"
|
||||
org_admin = "org_admin"
|
||||
platform_admin = "platform_admin"
|
||||
|
||||
|
||||
class Gender(str, Enum):
|
||||
male = "male"
|
||||
female = "female"
|
||||
unknown = "unknown"
|
||||
|
||||
|
||||
class VideoScene(str, Enum):
|
||||
front_posture = "front_posture"
|
||||
side_posture = "side_posture"
|
||||
squat = "squat"
|
||||
balance = "balance"
|
||||
gait = "gait"
|
||||
|
||||
|
||||
class TaskType(str, Enum):
|
||||
posture_screening = "posture_screening"
|
||||
movement_scoring = "movement_scoring"
|
||||
reassessment = "reassessment"
|
||||
|
||||
|
||||
class TaskStatus(str, Enum):
|
||||
CREATED = "CREATED"
|
||||
QUEUED = "QUEUED"
|
||||
PROCESSING = "PROCESSING"
|
||||
SUCCEEDED = "SUCCEEDED"
|
||||
FAILED = "FAILED"
|
||||
CANCELLED = "CANCELLED"
|
||||
|
||||
|
||||
class RiskLevel(str, Enum):
|
||||
low = "low"
|
||||
medium = "medium"
|
||||
high = "high"
|
||||
review_required = "review_required"
|
||||
|
||||
|
||||
class PlanStatus(str, Enum):
|
||||
draft = "draft"
|
||||
active = "active"
|
||||
completed = "completed"
|
||||
paused = "paused"
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
login_type: LoginType = Field(alias="loginType")
|
||||
credential: str
|
||||
code: str | None = None
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
id: int
|
||||
phone_masked: str = Field(alias="phoneMasked")
|
||||
role: UserRole
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
class ChildCreateRequest(BaseModel):
|
||||
name: str
|
||||
birthday: date
|
||||
gender: Gender = Gender.unknown
|
||||
height: float | None = None
|
||||
weight: float | None = None
|
||||
contraindications: str | None = None
|
||||
|
||||
|
||||
class ChildUpdateRequest(ChildCreateRequest):
|
||||
pass
|
||||
|
||||
|
||||
class Child(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
birthday: date
|
||||
age: int
|
||||
height: float | None = None
|
||||
weight: float | None = None
|
||||
status: str = "active"
|
||||
|
||||
|
||||
class UploadTokenRequest(BaseModel):
|
||||
child_id: int = Field(alias="childId")
|
||||
file_name: str = Field(alias="fileName")
|
||||
content_type: str = Field(alias="contentType")
|
||||
size: int
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
class VideoCreateRequest(BaseModel):
|
||||
child_id: int = Field(alias="childId")
|
||||
object_key: str = Field(alias="objectKey")
|
||||
scene: VideoScene
|
||||
capture_hint: str | None = Field(default=None, alias="captureHint")
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
class AnalysisTaskCreateRequest(BaseModel):
|
||||
child_id: int = Field(alias="childId")
|
||||
video_id: int = Field(alias="videoId")
|
||||
task_type: TaskType = Field(alias="taskType")
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
class AnalysisTaskWebhook(BaseModel):
|
||||
task_id: int = Field(alias="taskId")
|
||||
status: TaskStatus
|
||||
progress: int | None = None
|
||||
error_code: str | None = Field(default=None, alias="errorCode")
|
||||
result: dict[str, Any] | None = None
|
||||
signature: str | None = None
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
class TrainingPlanCreateRequest(BaseModel):
|
||||
child_id: int = Field(alias="childId")
|
||||
report_id: int | None = Field(default=None, alias="reportId")
|
||||
goal: str
|
||||
cycle_days: int = Field(alias="cycleDays", ge=7)
|
||||
exercise_ids: list[int] | None = Field(default=None, alias="exerciseIds")
|
||||
constraints: dict[str, Any] | None = None
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
class TrainingRecordCreateRequest(BaseModel):
|
||||
exercise_id: int = Field(alias="exerciseId")
|
||||
completed: bool
|
||||
score: int | None = Field(default=None, ge=0, le=100)
|
||||
duration_seconds: int | None = Field(default=None, alias="durationSeconds")
|
||||
note: str | None = None
|
||||
media: list[str] | None = None
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
class ReportMetric(BaseModel):
|
||||
name: str
|
||||
value: float
|
||||
level: str
|
||||
confidence: float
|
||||
@@ -0,0 +1,66 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import AnalysisTask, Child, Report, TrainingPlan, TrainingRecord, Video
|
||||
|
||||
|
||||
def _tenant_child_ids_subquery(tenant_id: int):
|
||||
return select(Child.id).where(Child.tenant_id == tenant_id).scalar_subquery()
|
||||
|
||||
|
||||
def get_dashboard_metrics(db: Session, tenant_id: int | None = None) -> dict:
|
||||
since = datetime.now(timezone.utc) - timedelta(days=30)
|
||||
|
||||
child_q = select(func.count()).select_from(Child).where(Child.status == "active")
|
||||
video_q = select(func.count()).select_from(Video)
|
||||
report_q = select(func.count()).select_from(Report).where(Report.status == "published")
|
||||
plan_q = select(func.count()).select_from(TrainingPlan).where(TrainingPlan.status == "active")
|
||||
new_child_q = select(func.count()).select_from(Child).where(Child.created_at >= since)
|
||||
succeeded_q = select(func.count()).select_from(AnalysisTask).where(
|
||||
AnalysisTask.status == "SUCCEEDED"
|
||||
)
|
||||
reassessment_q = select(func.count()).select_from(AnalysisTask).where(
|
||||
AnalysisTask.task_type == "reassessment", AnalysisTask.status == "SUCCEEDED"
|
||||
)
|
||||
pending_review_q = select(func.count()).select_from(Report).where(Report.reviewed_by.is_(None))
|
||||
pending_training_q = select(func.count()).select_from(TrainingRecord).where(
|
||||
TrainingRecord.score.is_not(None)
|
||||
)
|
||||
|
||||
if tenant_id:
|
||||
tenant_children = _tenant_child_ids_subquery(tenant_id)
|
||||
child_q = child_q.where(Child.tenant_id == tenant_id)
|
||||
new_child_q = new_child_q.where(Child.tenant_id == tenant_id)
|
||||
video_q = video_q.where(Video.child_id.in_(tenant_children))
|
||||
report_q = report_q.where(Report.child_id.in_(tenant_children))
|
||||
plan_q = plan_q.where(TrainingPlan.child_id.in_(tenant_children))
|
||||
succeeded_q = succeeded_q.where(AnalysisTask.child_id.in_(tenant_children))
|
||||
reassessment_q = reassessment_q.where(AnalysisTask.child_id.in_(tenant_children))
|
||||
pending_review_q = pending_review_q.where(Report.child_id.in_(tenant_children))
|
||||
pending_training_q = pending_training_q.where(TrainingRecord.child_id.in_(tenant_children))
|
||||
|
||||
new_children = db.scalar(new_child_q) or 0
|
||||
uploaded_videos = db.scalar(video_q) or 0
|
||||
completed_reports = db.scalar(report_q) or 0
|
||||
active_plans = db.scalar(plan_q) or 0
|
||||
succeeded_tasks = db.scalar(succeeded_q) or 0
|
||||
reassessment_done = db.scalar(reassessment_q) or 0
|
||||
|
||||
conversion_rate = round(completed_reports / max(succeeded_tasks, 1), 2)
|
||||
reassessment_rate = round(reassessment_done / max(active_plans, 1), 2)
|
||||
|
||||
pending_review = db.scalar(pending_review_q) or 0
|
||||
pending_training = db.scalar(pending_training_q) or 0
|
||||
|
||||
return {
|
||||
"newChildren": new_children,
|
||||
"uploadedVideos": uploaded_videos,
|
||||
"completedReports": completed_reports,
|
||||
"activePlans": active_plans,
|
||||
"conversionRate": min(conversion_rate, 1.0),
|
||||
"reassessmentCompletionRate": min(reassessment_rate, 1.0),
|
||||
"pendingReviewReports": pending_review,
|
||||
"pendingTrainingReview": pending_training,
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.ai.pose_analyzer import analyze_task
|
||||
from app.config import settings
|
||||
from app.db.models import AnalysisTask, Report, Video
|
||||
from app.mock_data import MOCK_MOVEMENT_REPORT, MOCK_REPORT
|
||||
from app.queue.analysis_queue import get_analysis_queue
|
||||
from app.schemas.models import AnalysisTaskCreateRequest, TaskStatus, TaskType
|
||||
from app.services import oss as oss_service
|
||||
from app.services.videos import verify_child_owner
|
||||
|
||||
DISCLAIMER_SCREENING = "本报告用于健康管理建议,不构成医疗诊断。"
|
||||
DISCLAIMER_MOVEMENT = "本报告用于运动训练反馈,不构成医疗诊断。"
|
||||
|
||||
|
||||
def task_to_dict(task: AnalysisTask) -> dict:
|
||||
report_id = None
|
||||
if task.result and isinstance(task.result, dict):
|
||||
report_id = task.result.get("reportId")
|
||||
return {
|
||||
"id": task.id,
|
||||
"status": task.status,
|
||||
"progress": task.progress,
|
||||
"errorCode": task.error_code,
|
||||
"reportId": report_id,
|
||||
"retryCount": task.retry_count,
|
||||
}
|
||||
|
||||
|
||||
def build_screening_report_payload(child_id: int, task_id: int) -> dict:
|
||||
data = {**MOCK_REPORT, "childId": child_id, "taskId": task_id}
|
||||
return data
|
||||
|
||||
|
||||
def build_movement_report_payload(child_id: int, task_id: int) -> dict:
|
||||
return {**MOCK_MOVEMENT_REPORT, "childId": child_id, "taskId": task_id}
|
||||
|
||||
|
||||
def create_report_for_task(db: Session, task: AnalysisTask, analysis: dict) -> Report:
|
||||
report_data = analysis.get("report", {})
|
||||
if task.task_type == TaskType.movement_scoring.value:
|
||||
payload = {**MOCK_MOVEMENT_REPORT, **report_data}
|
||||
report = Report(
|
||||
child_id=task.child_id,
|
||||
task_id=task.id,
|
||||
report_type="movement_scoring",
|
||||
risk_level="medium",
|
||||
summary="跟练打卡报告已生成",
|
||||
metrics=[payload],
|
||||
recommendations=["继续保持训练节奏", "28 天后安排复测"],
|
||||
disclaimer=DISCLAIMER_MOVEMENT,
|
||||
status="published",
|
||||
)
|
||||
else:
|
||||
payload = {**MOCK_REPORT, **report_data, "childId": task.child_id, "taskId": task.id}
|
||||
report = Report(
|
||||
child_id=task.child_id,
|
||||
task_id=task.id,
|
||||
report_type="posture_screening",
|
||||
risk_level=payload.get("riskLevel", "medium"),
|
||||
summary=payload.get("summary", MOCK_REPORT["summary"]),
|
||||
metrics=payload.get("metrics", MOCK_REPORT["metrics"]),
|
||||
recommendations=payload.get("recommendations", MOCK_REPORT["recommendations"]),
|
||||
disclaimer=payload.get("disclaimer", DISCLAIMER_SCREENING),
|
||||
status="published",
|
||||
)
|
||||
db.add(report)
|
||||
db.flush()
|
||||
return report
|
||||
|
||||
|
||||
def process_analysis_task(db: Session, task_id: int) -> AnalysisTask | None:
|
||||
task = db.get(AnalysisTask, task_id)
|
||||
if not task or task.status in (TaskStatus.SUCCEEDED.value, TaskStatus.CANCELLED.value):
|
||||
return task
|
||||
|
||||
task.status = TaskStatus.PROCESSING.value
|
||||
task.progress = 35
|
||||
task.started_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
video = db.get(Video, task.video_id)
|
||||
video_path = None
|
||||
if video:
|
||||
video_path = oss_service.resolve_local_path(video.object_key)
|
||||
|
||||
analysis = analyze_task(task.task_type, video_path)
|
||||
task.model_version = analysis.get("modelVersion", "mock-v1")
|
||||
task.progress = 80
|
||||
db.commit()
|
||||
|
||||
report = create_report_for_task(db, task, analysis)
|
||||
task.status = TaskStatus.SUCCEEDED.value
|
||||
task.progress = 100
|
||||
task.finished_at = datetime.now(timezone.utc)
|
||||
task.result = {
|
||||
"reportId": report.id,
|
||||
"engine": analysis.get("engine"),
|
||||
"confidence": analysis.get("confidence"),
|
||||
}
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
def enqueue_task(task_id: int, db: Session | None = None) -> None:
|
||||
queue = get_analysis_queue()
|
||||
queue.push(task_id)
|
||||
if not settings.analysis_inline_process:
|
||||
return
|
||||
if db is not None:
|
||||
process_analysis_task(db, task_id)
|
||||
return
|
||||
from app.db.session import SessionLocal
|
||||
|
||||
local_db = SessionLocal()
|
||||
try:
|
||||
process_analysis_task(local_db, task_id)
|
||||
finally:
|
||||
local_db.close()
|
||||
|
||||
|
||||
def create_analysis_task(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
body: AnalysisTaskCreateRequest,
|
||||
idempotency_key: str | None,
|
||||
) -> tuple[AnalysisTask | None, str | None, AnalysisTask | None]:
|
||||
child = verify_child_owner(db, user_id, body.child_id)
|
||||
if not child:
|
||||
return None, "child_not_found", None
|
||||
|
||||
video = db.get(Video, body.video_id)
|
||||
if not video or video.child_id != body.child_id:
|
||||
return None, "video_not_found", None
|
||||
|
||||
if idempotency_key:
|
||||
existing = db.scalar(
|
||||
select(AnalysisTask).where(
|
||||
AnalysisTask.child_id == body.child_id,
|
||||
AnalysisTask.video_id == body.video_id,
|
||||
AnalysisTask.task_type == body.task_type.value,
|
||||
AnalysisTask.idempotency_key == idempotency_key,
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
return existing, "duplicate", existing
|
||||
|
||||
task = AnalysisTask(
|
||||
child_id=body.child_id,
|
||||
video_id=body.video_id,
|
||||
task_type=body.task_type.value,
|
||||
status=TaskStatus.QUEUED.value,
|
||||
progress=0,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
db.add(task)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
enqueue_task(task.id, db=db)
|
||||
db.refresh(task)
|
||||
return task, None, None
|
||||
|
||||
|
||||
def get_task_for_user(db: Session, user_id: int, task_id: int) -> AnalysisTask | None:
|
||||
task = db.get(AnalysisTask, task_id)
|
||||
if not task:
|
||||
return None
|
||||
child = verify_child_owner(db, user_id, task.child_id)
|
||||
if not child:
|
||||
return None
|
||||
return task
|
||||
|
||||
|
||||
def cancel_task(db: Session, user_id: int, task_id: int) -> AnalysisTask | None:
|
||||
task = get_task_for_user(db, user_id, task_id)
|
||||
if not task:
|
||||
return None
|
||||
if task.status in (TaskStatus.SUCCEEDED.value, TaskStatus.CANCELLED.value):
|
||||
return task
|
||||
task.status = TaskStatus.CANCELLED.value
|
||||
task.finished_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
def handle_webhook(db: Session, task_id: int, status: str) -> None:
|
||||
task = db.get(AnalysisTask, task_id)
|
||||
if not task:
|
||||
return
|
||||
if status == TaskStatus.SUCCEEDED.value:
|
||||
process_analysis_task(db, task_id)
|
||||
elif status == TaskStatus.FAILED.value:
|
||||
task.status = TaskStatus.FAILED.value
|
||||
task.error_code = "worker_failed"
|
||||
task.finished_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
@@ -0,0 +1,49 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.core.security import create_access_token, hash_phone, mask_phone
|
||||
from app.db.models import Tenant, User
|
||||
from app.schemas.models import LoginRequest, LoginType
|
||||
|
||||
|
||||
def authenticate(db: Session, body: LoginRequest) -> tuple[User, str]:
|
||||
if body.login_type == LoginType.phone_code:
|
||||
if body.code != settings.demo_sms_code:
|
||||
raise ValueError("invalid_code")
|
||||
phone = body.credential
|
||||
phone_hash = hash_phone(phone)
|
||||
user = db.scalar(select(User).where(User.phone_hash == phone_hash))
|
||||
if not user:
|
||||
tenant = db.scalar(select(Tenant).where(Tenant.name == "Demo Organization"))
|
||||
if not tenant:
|
||||
tenant = Tenant(name="Demo Organization", type="organization", status="active")
|
||||
db.add(tenant)
|
||||
db.flush()
|
||||
user = User(
|
||||
tenant_id=tenant.id,
|
||||
phone=phone,
|
||||
phone_hash=phone_hash,
|
||||
role="parent",
|
||||
status="active",
|
||||
consent_signed=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
user.last_login_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
token = create_access_token(user.id, user.role)
|
||||
return user, token
|
||||
|
||||
raise ValueError("unsupported_login_type")
|
||||
|
||||
|
||||
def user_to_dict(user: User) -> dict:
|
||||
return {
|
||||
"id": user.id,
|
||||
"phoneMasked": mask_phone(user.phone or ""),
|
||||
"role": user.role,
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import Child
|
||||
from app.schemas.models import ChildCreateRequest, ChildUpdateRequest
|
||||
|
||||
|
||||
def calc_age(birthday: date) -> int:
|
||||
today = date.today()
|
||||
age = today.year - birthday.year
|
||||
if (today.month, today.day) < (birthday.month, birthday.day):
|
||||
age -= 1
|
||||
return age
|
||||
|
||||
|
||||
def child_to_dict(child: Child) -> dict:
|
||||
return {
|
||||
"id": child.id,
|
||||
"name": child.name,
|
||||
"birthday": child.birthday.isoformat(),
|
||||
"age": calc_age(child.birthday),
|
||||
"height": float(child.height) if child.height is not None else None,
|
||||
"weight": float(child.weight) if child.weight is not None else None,
|
||||
"status": child.status,
|
||||
}
|
||||
|
||||
|
||||
def list_children(db: Session, parent_user_id: int, page: int, page_size: int) -> tuple[list[Child], int]:
|
||||
filters = (Child.parent_user_id == parent_user_id, Child.status != "archived")
|
||||
total = db.scalar(select(func.count()).select_from(Child).where(*filters)) or 0
|
||||
items = db.scalars(
|
||||
select(Child)
|
||||
.where(*filters)
|
||||
.order_by(Child.id.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
).all()
|
||||
return items, total
|
||||
|
||||
|
||||
def get_child(db: Session, parent_user_id: int, child_id: int) -> Child | None:
|
||||
return db.scalar(
|
||||
select(Child).where(
|
||||
Child.id == child_id,
|
||||
Child.parent_user_id == parent_user_id,
|
||||
Child.status != "archived",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def create_child(db: Session, parent_user_id: int, tenant_id: int | None, body: ChildCreateRequest) -> Child:
|
||||
child = Child(
|
||||
parent_user_id=parent_user_id,
|
||||
tenant_id=tenant_id,
|
||||
name=body.name,
|
||||
birthday=body.birthday,
|
||||
gender=body.gender.value,
|
||||
height=body.height,
|
||||
weight=body.weight,
|
||||
contraindications=body.contraindications,
|
||||
status="active",
|
||||
)
|
||||
db.add(child)
|
||||
db.commit()
|
||||
db.refresh(child)
|
||||
return child
|
||||
|
||||
|
||||
def update_child(db: Session, child: Child, body: ChildUpdateRequest) -> Child:
|
||||
child.name = body.name
|
||||
child.birthday = body.birthday
|
||||
child.gender = body.gender.value
|
||||
child.height = body.height
|
||||
child.weight = body.weight
|
||||
child.contraindications = body.contraindications
|
||||
db.commit()
|
||||
db.refresh(child)
|
||||
return child
|
||||
|
||||
|
||||
def archive_child(db: Session, child: Child) -> None:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
child.status = "archived"
|
||||
child.deleted_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
@@ -0,0 +1,134 @@
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
|
||||
_upload_tokens: dict[str, dict] = {}
|
||||
|
||||
|
||||
def _upload_root() -> Path:
|
||||
root = Path(settings.upload_local_dir)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def generate_object_key(child_id: int, file_name: str) -> str:
|
||||
safe_name = file_name.replace("..", "").replace("/", "_")
|
||||
return f"videos/{child_id}/{safe_name}"
|
||||
|
||||
|
||||
def _cleanup_expired_tokens() -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
expired = [k for k, v in _upload_tokens.items() if v["expire_at"] < now]
|
||||
for key in expired:
|
||||
_upload_tokens.pop(key, None)
|
||||
|
||||
|
||||
def create_upload_token(object_key: str, content_type: str, size: int) -> dict:
|
||||
_cleanup_expired_tokens()
|
||||
expire_at = datetime.now(timezone.utc) + timedelta(minutes=settings.upload_token_expire_minutes)
|
||||
token = secrets.token_urlsafe(32)
|
||||
|
||||
if settings.oss_enabled:
|
||||
upload_url, method = _create_s3_presigned_url(object_key, content_type, expire_at)
|
||||
extra = {
|
||||
"storage": settings.oss_provider,
|
||||
"cdnBaseUrl": settings.oss_cdn_base_url or None,
|
||||
}
|
||||
else:
|
||||
upload_url = f"{settings.api_public_url.rstrip('/')}/api/videos/direct-upload"
|
||||
method = "PUT"
|
||||
extra = {"storage": "local", "uploadToken": token}
|
||||
|
||||
_upload_tokens[token] = {
|
||||
"object_key": object_key,
|
||||
"content_type": content_type,
|
||||
"size": size,
|
||||
"expire_at": expire_at,
|
||||
}
|
||||
|
||||
return {
|
||||
"uploadUrl": upload_url,
|
||||
"objectKey": object_key,
|
||||
"expireAt": expire_at.replace(microsecond=0).isoformat(),
|
||||
"method": method,
|
||||
**extra,
|
||||
"uploadToken": token,
|
||||
}
|
||||
|
||||
|
||||
def _create_s3_presigned_url(object_key: str, content_type: str, expire_at: datetime) -> tuple[str, str]:
|
||||
try:
|
||||
import boto3
|
||||
from botocore.client import Config
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("boto3 required for OSS mode") from exc
|
||||
|
||||
client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=settings.oss_endpoint,
|
||||
aws_access_key_id=settings.oss_access_key,
|
||||
aws_secret_access_key=settings.oss_secret_key,
|
||||
config=Config(signature_version="s3v4"),
|
||||
region_name=settings.oss_region,
|
||||
)
|
||||
_ensure_bucket(client)
|
||||
expires_in = max(60, int((expire_at - datetime.now(timezone.utc)).total_seconds()))
|
||||
url = client.generate_presigned_url(
|
||||
"put_object",
|
||||
Params={
|
||||
"Bucket": settings.oss_bucket,
|
||||
"Key": object_key,
|
||||
"ContentType": content_type,
|
||||
},
|
||||
ExpiresIn=expires_in,
|
||||
)
|
||||
return url, "PUT"
|
||||
|
||||
|
||||
def _ensure_bucket(client) -> None:
|
||||
if settings.oss_provider != "minio":
|
||||
return
|
||||
try:
|
||||
client.head_bucket(Bucket=settings.oss_bucket)
|
||||
except Exception:
|
||||
client.create_bucket(Bucket=settings.oss_bucket)
|
||||
|
||||
|
||||
def public_object_url(object_key: str) -> str | None:
|
||||
if settings.oss_cdn_base_url:
|
||||
return f"{settings.oss_cdn_base_url.rstrip('/')}/{object_key}"
|
||||
if settings.oss_enabled and settings.oss_provider == "aliyun":
|
||||
endpoint = settings.oss_endpoint.rstrip("/")
|
||||
return f"{endpoint}/{settings.oss_bucket}/{object_key}"
|
||||
return None
|
||||
|
||||
|
||||
def validate_upload_token(token: str, object_key: str) -> bool:
|
||||
_cleanup_expired_tokens()
|
||||
meta = _upload_tokens.get(token)
|
||||
if not meta or meta["object_key"] != object_key:
|
||||
return False
|
||||
if meta["expire_at"] < datetime.now(timezone.utc):
|
||||
_upload_tokens.pop(token, None)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def consume_upload_token(token: str, object_key: str) -> None:
|
||||
meta = _upload_tokens.pop(token, None)
|
||||
if not meta or meta["object_key"] != object_key:
|
||||
raise ValueError("invalid_upload_token")
|
||||
|
||||
|
||||
def save_local_upload(object_key: str, data: bytes) -> Path:
|
||||
path = _upload_root() / object_key
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
return path
|
||||
|
||||
|
||||
def resolve_local_path(object_key: str) -> Path | None:
|
||||
path = _upload_root() / object_key
|
||||
return path if path.exists() else None
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Server-side PDF export for posture reports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
|
||||
|
||||
from app.db.models import Child, Report
|
||||
from app.services.reports import METRIC_LEVEL_LABELS, RISK_LABELS, report_to_dict
|
||||
|
||||
LEVEL_COLORS = {
|
||||
"normal": colors.HexColor("#2bb673"),
|
||||
"low": colors.HexColor("#84cc16"),
|
||||
"medium": colors.HexColor("#f59e0b"),
|
||||
"high": colors.HexColor("#ef4444"),
|
||||
}
|
||||
|
||||
|
||||
def build_report_pdf(report: Report, child: Child | None = None) -> bytes:
|
||||
payload = report_to_dict(report)
|
||||
buffer = BytesIO()
|
||||
doc = SimpleDocTemplate(
|
||||
buffer,
|
||||
pagesize=A4,
|
||||
leftMargin=18 * mm,
|
||||
rightMargin=18 * mm,
|
||||
topMargin=16 * mm,
|
||||
bottomMargin=16 * mm,
|
||||
title=f"Happy Up Report #{report.id}",
|
||||
)
|
||||
|
||||
styles = getSampleStyleSheet()
|
||||
title_style = ParagraphStyle(
|
||||
"ReportTitle",
|
||||
parent=styles["Heading1"],
|
||||
fontName="Helvetica-Bold",
|
||||
fontSize=18,
|
||||
textColor=colors.HexColor("#1a6fb5"),
|
||||
spaceAfter=8,
|
||||
)
|
||||
body = styles["BodyText"]
|
||||
muted = ParagraphStyle("Muted", parent=body, textColor=colors.HexColor("#6b7280"), fontSize=9)
|
||||
|
||||
child_name = child.name if child else f"儿童 #{report.child_id}"
|
||||
story = [
|
||||
Paragraph("儿童 AI 体态管理 · 筛查报告", title_style),
|
||||
Paragraph(f"儿童:{child_name} · 报告编号 #{report.id}", muted),
|
||||
Spacer(1, 8),
|
||||
]
|
||||
|
||||
if report.report_type == "movement_scoring":
|
||||
story.extend(_movement_sections(payload, body, muted))
|
||||
else:
|
||||
story.extend(_screening_sections(payload, body, muted))
|
||||
|
||||
story.append(Spacer(1, 10))
|
||||
story.append(Paragraph(payload.get("disclaimer", ""), muted))
|
||||
|
||||
doc.build(story)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _screening_sections(payload: dict, body, muted) -> list:
|
||||
risk = payload.get("riskLevel", "medium")
|
||||
risk_label = RISK_LABELS.get(risk, risk)
|
||||
sections = [
|
||||
Paragraph(f"<b>风险等级:</b>{risk_label}", body),
|
||||
Paragraph(f"<b>摘要:</b>{payload.get('summary', '')}", body),
|
||||
Spacer(1, 8),
|
||||
Paragraph("<b>指标明细</b>", body),
|
||||
]
|
||||
|
||||
rows = [["指标", "数值", "等级", "置信度"]]
|
||||
for metric in payload.get("metrics", []):
|
||||
rows.append(
|
||||
[
|
||||
metric.get("name", ""),
|
||||
f"{metric.get('value', 0)}%",
|
||||
METRIC_LEVEL_LABELS.get(metric.get("level", ""), metric.get("level", "")),
|
||||
f"{metric.get('confidence', 0):.0%}",
|
||||
]
|
||||
)
|
||||
|
||||
table = Table(rows, colWidths=[80, 60, 60, 60])
|
||||
table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#eef2f7")),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#1f2937")),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e5e7eb")),
|
||||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#fafafa")]),
|
||||
]
|
||||
)
|
||||
)
|
||||
sections.append(table)
|
||||
sections.append(Spacer(1, 8))
|
||||
sections.append(Paragraph("<b>建议</b>", body))
|
||||
for idx, item in enumerate(payload.get("recommendations", []), start=1):
|
||||
sections.append(Paragraph(f"{idx}. {item}", body))
|
||||
return sections
|
||||
|
||||
|
||||
def _movement_sections(payload: dict, body, muted) -> list:
|
||||
score = payload.get("score", 0)
|
||||
sections = [
|
||||
Paragraph(f"<b>跟练得分:</b>{score}", body),
|
||||
Paragraph(
|
||||
f"完成 {payload.get('repsCompleted', 0)} 组 · "
|
||||
f"时长 {payload.get('durationSeconds', 0)} 秒",
|
||||
muted,
|
||||
),
|
||||
Spacer(1, 8),
|
||||
Paragraph("<b>维度评分</b>", body),
|
||||
]
|
||||
dims = payload.get("dimensions", {})
|
||||
rows = [["维度", "得分"]]
|
||||
for key, label in (
|
||||
("trajectory", "轨迹"),
|
||||
("angle", "角度"),
|
||||
("rhythm", "节奏"),
|
||||
("stability", "稳定性"),
|
||||
("completion", "完成度"),
|
||||
):
|
||||
rows.append([label, str(dims.get(key, "-"))])
|
||||
|
||||
table = Table(rows, colWidths=[120, 80])
|
||||
table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#eef2f7")),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e5e7eb")),
|
||||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||
]
|
||||
)
|
||||
)
|
||||
sections.append(table)
|
||||
return sections
|
||||
@@ -0,0 +1,65 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import Report
|
||||
from app.mock_data import MOCK_MOVEMENT_REPORT
|
||||
from app.services.videos import verify_child_owner
|
||||
|
||||
|
||||
RISK_LABELS = {
|
||||
"low": "正常",
|
||||
"medium": "中度关注",
|
||||
"high": "高度关注",
|
||||
"review_required": "建议复核",
|
||||
}
|
||||
|
||||
METRIC_LEVEL_LABELS = {
|
||||
"medium": "中度",
|
||||
"low": "轻度",
|
||||
"normal": "正常",
|
||||
"high": "重度",
|
||||
}
|
||||
|
||||
|
||||
def report_to_dict(report: Report) -> dict:
|
||||
if report.report_type == "movement_scoring" and report.metrics:
|
||||
stored = report.metrics[0] if isinstance(report.metrics, list) else report.metrics
|
||||
if isinstance(stored, dict) and "score" in stored:
|
||||
return stored
|
||||
return {**MOCK_MOVEMENT_REPORT, "id": report.id, "childId": report.child_id, "taskId": report.task_id}
|
||||
|
||||
return {
|
||||
"id": report.id,
|
||||
"childId": report.child_id,
|
||||
"taskId": report.task_id,
|
||||
"riskLevel": report.risk_level,
|
||||
"summary": report.summary,
|
||||
"metrics": report.metrics,
|
||||
"recommendations": report.recommendations,
|
||||
"disclaimer": report.disclaimer,
|
||||
"reviewedBy": report.reviewed_by,
|
||||
}
|
||||
|
||||
|
||||
def get_report_for_user(db: Session, user_id: int, report_id: int) -> Report | None:
|
||||
report = db.get(Report, report_id)
|
||||
if not report:
|
||||
return None
|
||||
if not verify_child_owner(db, user_id, report.child_id):
|
||||
return None
|
||||
return report
|
||||
|
||||
|
||||
def list_reports_for_child(
|
||||
db: Session, user_id: int, child_id: int, limit: int = 10
|
||||
) -> list[Report] | None:
|
||||
if not verify_child_owner(db, user_id, child_id):
|
||||
return None
|
||||
return list(
|
||||
db.scalars(
|
||||
select(Report)
|
||||
.where(Report.child_id == child_id)
|
||||
.order_by(Report.id.desc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
)
|
||||
@@ -0,0 +1,148 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import Exercise, TrainingPlan, TrainingRecord
|
||||
from app.schemas.models import TrainingPlanCreateRequest, TrainingRecordCreateRequest
|
||||
from app.services.videos import verify_child_owner
|
||||
|
||||
DEFAULT_EXERCISES = [
|
||||
{"id": 1, "name": "肩胛稳定训练", "durationMinutes": 5},
|
||||
{"id": 2, "name": "颈后肌群拉伸", "sets": 3},
|
||||
]
|
||||
|
||||
|
||||
def plan_to_dict(plan: TrainingPlan) -> dict:
|
||||
detail = plan.plan_detail or {}
|
||||
records_count = detail.get("completedDays", 0)
|
||||
current_day = min(plan.cycle_days, records_count + 1)
|
||||
return {
|
||||
"id": plan.id,
|
||||
"status": plan.status,
|
||||
"detail": {
|
||||
**detail,
|
||||
"goal": plan.goal,
|
||||
"cycleDays": plan.cycle_days,
|
||||
"currentDay": current_day,
|
||||
"childId": plan.child_id,
|
||||
"reportId": plan.report_id,
|
||||
},
|
||||
"startedAt": plan.started_at.isoformat() if plan.started_at else None,
|
||||
"endedAt": plan.ended_at.isoformat() if plan.ended_at else None,
|
||||
}
|
||||
|
||||
|
||||
def build_plan_detail(body: TrainingPlanCreateRequest) -> dict:
|
||||
exercises = DEFAULT_EXERCISES
|
||||
if body.exercise_ids:
|
||||
exercises = [{"id": eid, "name": f"动作 #{eid}"} for eid in body.exercise_ids]
|
||||
return {
|
||||
"goal": body.goal,
|
||||
"cycleDays": body.cycle_days,
|
||||
"currentDay": 1,
|
||||
"completedDays": 0,
|
||||
"exercises": exercises,
|
||||
"constraints": body.constraints or {},
|
||||
}
|
||||
|
||||
|
||||
def get_plan_for_user(db: Session, user_id: int, plan_id: int) -> TrainingPlan | None:
|
||||
plan = db.get(TrainingPlan, plan_id)
|
||||
if not plan or plan.status == "cancelled":
|
||||
return None
|
||||
if not verify_child_owner(db, user_id, plan.child_id):
|
||||
return None
|
||||
return plan
|
||||
|
||||
|
||||
def get_active_plan_for_child(db: Session, user_id: int, child_id: int) -> TrainingPlan | None:
|
||||
if not verify_child_owner(db, user_id, child_id):
|
||||
return None
|
||||
return db.scalar(
|
||||
select(TrainingPlan)
|
||||
.where(TrainingPlan.child_id == child_id, TrainingPlan.status == "active")
|
||||
.order_by(TrainingPlan.id.desc())
|
||||
)
|
||||
|
||||
|
||||
def list_plans_for_child(db: Session, user_id: int, child_id: int) -> list[TrainingPlan] | None:
|
||||
if not verify_child_owner(db, user_id, child_id):
|
||||
return None
|
||||
return list(
|
||||
db.scalars(
|
||||
select(TrainingPlan)
|
||||
.where(TrainingPlan.child_id == child_id, TrainingPlan.status != "cancelled")
|
||||
.order_by(TrainingPlan.id.desc())
|
||||
.limit(10)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def create_plan(db: Session, user_id: int, body: TrainingPlanCreateRequest) -> TrainingPlan | None:
|
||||
if not verify_child_owner(db, user_id, body.child_id):
|
||||
return None
|
||||
now = datetime.now(timezone.utc)
|
||||
plan = TrainingPlan(
|
||||
child_id=body.child_id,
|
||||
report_id=body.report_id,
|
||||
goal=body.goal,
|
||||
cycle_days=body.cycle_days,
|
||||
status="active",
|
||||
plan_detail=build_plan_detail(body),
|
||||
started_at=now,
|
||||
)
|
||||
db.add(plan)
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
return plan
|
||||
|
||||
|
||||
def create_record(
|
||||
db: Session, user_id: int, plan_id: int, body: TrainingRecordCreateRequest
|
||||
) -> TrainingRecord | None:
|
||||
plan = get_plan_for_user(db, user_id, plan_id)
|
||||
if not plan:
|
||||
return None
|
||||
|
||||
feedback = {"media": body.media} if body.media else None
|
||||
record = TrainingRecord(
|
||||
plan_id=plan_id,
|
||||
child_id=plan.child_id,
|
||||
exercise_id=body.exercise_id,
|
||||
completed=body.completed,
|
||||
score=body.score,
|
||||
duration_seconds=body.duration_seconds,
|
||||
feedback=feedback,
|
||||
note=body.note,
|
||||
)
|
||||
db.add(record)
|
||||
|
||||
detail = dict(plan.plan_detail or {})
|
||||
detail["completedDays"] = detail.get("completedDays", 0) + (1 if body.completed else 0)
|
||||
detail["lastScore"] = body.score
|
||||
detail["lastRecordId"] = None
|
||||
plan.plan_detail = detail
|
||||
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
detail["lastRecordId"] = record.id
|
||||
plan.plan_detail = detail
|
||||
db.commit()
|
||||
return record
|
||||
|
||||
|
||||
def ensure_default_exercises(db: Session) -> None:
|
||||
existing = db.scalar(select(func.count()).select_from(Exercise))
|
||||
if existing:
|
||||
return
|
||||
for item in DEFAULT_EXERCISES:
|
||||
db.add(
|
||||
Exercise(
|
||||
name=item["name"],
|
||||
category="posture",
|
||||
target_issue="头前伸/高低肩",
|
||||
duration_seconds=(item.get("durationMinutes", 3) * 60),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
@@ -0,0 +1,53 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import Child
|
||||
from app.db.models import Video as VideoModel
|
||||
from app.schemas.models import UploadTokenRequest, VideoCreateRequest
|
||||
from app.services import oss as oss_service
|
||||
|
||||
|
||||
def verify_child_owner(db: Session, parent_user_id: int, child_id: int) -> Child | None:
|
||||
return db.scalar(
|
||||
select(Child).where(
|
||||
Child.id == child_id,
|
||||
Child.parent_user_id == parent_user_id,
|
||||
Child.status != "archived",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def create_upload_token(body: UploadTokenRequest) -> dict:
|
||||
object_key = oss_service.generate_object_key(body.child_id, body.file_name)
|
||||
return oss_service.create_upload_token(object_key, body.content_type, body.size)
|
||||
|
||||
|
||||
def save_direct_upload(token: str, object_key: str, data: bytes) -> None:
|
||||
if not oss_service.validate_upload_token(token, object_key):
|
||||
raise ValueError("invalid_upload_token")
|
||||
oss_service.save_local_upload(object_key, data)
|
||||
oss_service.consume_upload_token(token, object_key)
|
||||
|
||||
|
||||
def register_video(
|
||||
db: Session, user_id: int, body: VideoCreateRequest
|
||||
) -> tuple[VideoModel | None, str | None]:
|
||||
child = verify_child_owner(db, user_id, body.child_id)
|
||||
if not child:
|
||||
return None, "child_not_found"
|
||||
|
||||
existing = db.scalar(select(VideoModel).where(VideoModel.object_key == body.object_key))
|
||||
if existing:
|
||||
return existing, "duplicate"
|
||||
|
||||
video = VideoModel(
|
||||
child_id=body.child_id,
|
||||
uploaded_by=user_id,
|
||||
scene=body.scene.value,
|
||||
object_key=body.object_key,
|
||||
status="uploaded",
|
||||
)
|
||||
db.add(video)
|
||||
db.commit()
|
||||
db.refresh(video)
|
||||
return video, None
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Analysis task worker — polls Redis queue and processes tasks."""
|
||||
|
||||
import time
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.queue.analysis_queue import get_analysis_queue
|
||||
from app.services.analysis import process_analysis_task
|
||||
|
||||
|
||||
def run_worker(poll_timeout: int = 5) -> None:
|
||||
queue = get_analysis_queue()
|
||||
print("Analysis worker started · waiting for tasks…")
|
||||
while True:
|
||||
task_id = queue.pop(timeout=poll_timeout)
|
||||
if task_id is None:
|
||||
continue
|
||||
db = SessionLocal()
|
||||
try:
|
||||
task = process_analysis_task(db, task_id)
|
||||
if task:
|
||||
print(f"Processed task #{task_id} -> {task.status}")
|
||||
except Exception as exc:
|
||||
print(f"Failed task #{task_id}: {exc}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_worker()
|
||||
Reference in New Issue
Block a user