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,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
|
||||
Reference in New Issue
Block a user