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
+151
View File
@@ -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())
+89
View File
@@ -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()
+23
View File
@@ -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