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:
john
2026-07-23 11:42:40 +08:00
commit 1aaef71f52
116 changed files with 10550 additions and 0 deletions
View File
+66
View File
@@ -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,
}
+201
View File
@@ -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()
+49
View File
@@ -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,
}
+88
View File
@@ -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()
+134
View File
@@ -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
+143
View File
@@ -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
+65
View File
@@ -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()
)
+148
View File
@@ -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()
+53
View File
@@ -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