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
+20
View File
@@ -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)
+90
View File
@@ -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)
+32
View File
@@ -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,
)
+106
View File
@@ -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)
+67
View File
@@ -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}"'},
)
+95
View File
@@ -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,
)
+70
View File
@@ -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,
)