diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml
new file mode 100644
index 0000000..4f56b6f
--- /dev/null
+++ b/.gitea/workflows/ci.yaml
@@ -0,0 +1,33 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ api-test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+ - name: Install API dependencies
+ run: pip install -e "./apps/api[dev]"
+ - name: Run pytest
+ run: cd apps/api && pytest -q
+
+ web-build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ - name: Install and build web
+ run: |
+ cd apps/web
+ npm install
+ npm run build
diff --git a/README.md b/README.md
index 4ee82e0..6d6c581 100644
--- a/README.md
+++ b/README.md
@@ -73,7 +73,17 @@ cd apps/web && npm install && npm run dev
| **Sprint 3** | **✅** | **训练计划/打卡 + Admin 看板 + 全链路 Demo** |
| **Sprint 4** | **✅** | **本地/MinIO 直传 + AI 骨架 + Vue H5 + 租户隔离** |
| **Sprint 5** | **✅** | **MediaPipe 评分 + PDF 导出 + 阿里云 OSS + 小程序壳** |
-| Sprint 6 | 🔜 | 模型标定、完整小程序、复核工作流、生产部署 |
+| **Sprint 6** | **✅** | **报告复核 + 标定 + CI/K8s + H5/小程序 Tab 跟练** |
+| Sprint 7 | 🔜 | 生产镜像、Admin 复核 UI、消息通知 |
+
+## 远程仓库
+
+- **Gitea**:https://git.tkmind.cn/tkmind/happy-up
+- **Clone**:`ssh://git@git.tkmind.cn:2222/tkmind/happy-up.git`
+
+## 部署
+
+Kubernetes 骨架见 [deploy/README.md](deploy/README.md),CI 见 [.gitea/workflows/ci.yaml](.gitea/workflows/ci.yaml)。
## 文档索引
diff --git a/apps/api/app/ai/calibration.py b/apps/api/app/ai/calibration.py
new file mode 100644
index 0000000..93599be
--- /dev/null
+++ b/apps/api/app/ai/calibration.py
@@ -0,0 +1,13 @@
+"""Configurable thresholds for pose metric calibration."""
+
+CALIBRATION = {
+ "head_forward_scale": 180.0,
+ "shoulder_asymmetry_scale": 220.0,
+ "pelvic_tilt_scale": 4.5,
+ "normal_max": 35,
+ "low_max": 55,
+ "medium_max": 75,
+ "review_required_min": 75,
+}
+
+METRIC_NAMES = ("头前伸", "高低肩", "骨盆倾斜")
diff --git a/apps/api/app/ai/pose_metrics.py b/apps/api/app/ai/pose_metrics.py
index a916a63..24fdc5c 100644
--- a/apps/api/app/ai/pose_metrics.py
+++ b/apps/api/app/ai/pose_metrics.py
@@ -15,7 +15,7 @@ RIGHT_HIP = 24
Landmark = dict[str, float]
FrameLandmarks = dict[int, Landmark]
-METRIC_NAMES = ("头前伸", "高低肩", "骨盆倾斜")
+from app.ai.calibration import CALIBRATION, METRIC_NAMES
def _point(frame: FrameLandmarks, idx: int) -> Landmark | None:
@@ -43,7 +43,7 @@ def score_head_forward(frame: FrameLandmarks) -> float | None:
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))
+ return min(100.0, max(0.0, offset * CALIBRATION["head_forward_scale"]))
def score_shoulder_asymmetry(frame: FrameLandmarks) -> float | None:
@@ -53,7 +53,7 @@ def score_shoulder_asymmetry(frame: FrameLandmarks) -> float | None:
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))
+ return min(100.0, max(0.0, diff * CALIBRATION["shoulder_asymmetry_scale"]))
def score_pelvic_tilt(frame: FrameLandmarks) -> float | None:
@@ -63,15 +63,15 @@ def score_pelvic_tilt(frame: FrameLandmarks) -> float | None:
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))
+ return min(100.0, max(0.0, tilt * CALIBRATION["pelvic_tilt_scale"]))
def value_to_level(value: float) -> str:
- if value < 35:
+ if value < CALIBRATION["normal_max"]:
return "normal"
- if value < 55:
+ if value < CALIBRATION["low_max"]:
return "low"
- if value < 75:
+ if value < CALIBRATION["medium_max"]:
return "medium"
return "high"
@@ -111,12 +111,16 @@ def build_screening_report(frames: list[FrameLandmarks]) -> dict:
worst = max((m["value"] for m in metrics), default=0)
risk_level = value_to_level(worst)
+ if worst >= CALIBRATION["review_required_min"]:
+ risk_level = "review_required"
if risk_level == "normal":
summary = "体态指标整体正常,建议保持日常活动与姿势习惯"
elif risk_level == "low":
summary = "存在轻度体态偏差,建议开始基础纠正训练"
elif risk_level == "medium":
summary = "建议关注头前伸与高低肩,开始针对性训练"
+ elif risk_level == "review_required":
+ summary = "指标偏高,已提交机构教练复核"
else:
summary = "多项指标偏高,建议尽快安排专业评估与干预"
diff --git a/apps/api/app/db/seed.py b/apps/api/app/db/seed.py
index 56f5233..73fe452 100644
--- a/apps/api/app/db/seed.py
+++ b/apps/api/app/db/seed.py
@@ -35,6 +35,20 @@ def seed() -> None:
db.add(user)
db.flush()
+ coach_hash = hash_phone("18600000001")
+ coach = db.scalar(select(User).where(User.phone_hash == coach_hash))
+ if not coach:
+ coach = User(
+ tenant_id=tenant.id,
+ phone="18600000001",
+ phone_hash=coach_hash,
+ role="coach",
+ status="active",
+ consent_signed=True,
+ )
+ db.add(coach)
+ db.flush()
+
child = db.scalar(
select(Child).where(Child.parent_user_id == user.id, Child.name == "小明")
)
@@ -80,7 +94,10 @@ def seed() -> None:
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 '-'}")
+ print(
+ f"Seed OK · tenant={tenant.id} user={user.id} coach={coach.id if coach else '-'} "
+ f"child={child_row.id if child_row else '-'}"
+ )
finally:
db.close()
diff --git a/apps/api/app/main.py b/apps/api/app/main.py
index 901b11c..5a8099e 100644
--- a/apps/api/app/main.py
+++ b/apps/api/app/main.py
@@ -11,10 +11,10 @@ CONTRACTS_OPENAPI = Path(__file__).resolve().parents[3] / "contracts" / "openapi
app = FastAPI(
title="Kids AI Posture Platform API",
- version="0.6.0",
+ version="0.7.0",
description=(
- "儿童 AI 体态管理平台 API · Sprint 5。"
- "MediaPipe 帧级评分 + PDF 导出 + OSS 生产配置 + H5/小程序对接。"
+ "儿童 AI 体态管理平台 API · Sprint 6。"
+ "报告复核工作流 + 模型标定 + CI/K8s 部署骨架。"
),
)
@@ -47,7 +47,7 @@ async def http_exception_handler(request: Request, exc: HTTPException):
@app.get("/health")
def health():
- return {"status": "ok", "env": settings.app_env, "stage": "sprint5"}
+ return {"status": "ok", "env": settings.app_env, "stage": "sprint6"}
@app.get("/")
@@ -56,5 +56,5 @@ def root():
"name": settings.app_name,
"docs": "/docs",
"contract": str(CONTRACTS_OPENAPI),
- "stage": "sprint5-mediapipe-pdf-oss",
+ "stage": "sprint6-review-ci-k8s",
}
diff --git a/apps/api/app/routers/admin.py b/apps/api/app/routers/admin.py
index db34c0a..61ea31e 100644
--- a/apps/api/app/routers/admin.py
+++ b/apps/api/app/routers/admin.py
@@ -1,11 +1,15 @@
from fastapi import APIRouter, Depends, Request
+from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
-from app.core.response import ok
+from app.core.response import error, ok
from app.db.models import User
from app.db.session import get_db
from app.deps import require_staff
+from app.schemas.models import ReportReviewRequest
from app.services import admin as admin_service
+from app.services import report_review as review_service
+from app.services.reports import report_to_dict
router = APIRouter(prefix="/api/admin", tags=["Admin"])
@@ -18,3 +22,47 @@ def admin_dashboard(
):
metrics = admin_service.get_dashboard_metrics(db, tenant_id=current_user.tenant_id)
return ok(metrics, request_id=request.state.request_id)
+
+
+@router.get("/reports/pending")
+def pending_reports(
+ request: Request,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_staff),
+):
+ items = review_service.list_pending_reports(db, tenant_id=current_user.tenant_id)
+ return ok({"list": items}, request_id=request.state.request_id)
+
+
+@router.post("/reports/{report_id}/review")
+def review_report(
+ report_id: int,
+ body: ReportReviewRequest,
+ request: Request,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_staff),
+):
+ report, err = review_service.review_report(
+ db,
+ current_user.id,
+ report_id,
+ body.action,
+ tenant_id=current_user.tenant_id,
+ note=body.note,
+ )
+ if err == "report_not_found":
+ return JSONResponse(
+ status_code=404,
+ content=error(10008, "report_not_found", request.state.request_id),
+ )
+ if err == "invalid_status":
+ return JSONResponse(
+ status_code=409,
+ content=error(10012, "invalid_report_status", request.state.request_id),
+ )
+ if err == "invalid_action":
+ return JSONResponse(
+ status_code=400,
+ content=error(10013, "invalid_review_action", request.state.request_id),
+ )
+ return ok(report_to_dict(report), message="reviewed", request_id=request.state.request_id)
diff --git a/apps/api/app/schemas/models.py b/apps/api/app/schemas/models.py
index d4c9309..ccb895a 100644
--- a/apps/api/app/schemas/models.py
+++ b/apps/api/app/schemas/models.py
@@ -163,3 +163,8 @@ class ReportMetric(BaseModel):
value: float
level: str
confidence: float
+
+
+class ReportReviewRequest(BaseModel):
+ action: str = Field(description="approve or reject")
+ note: str | None = None
diff --git a/apps/api/app/services/analysis.py b/apps/api/app/services/analysis.py
index 2a3912a..af2cb1e 100644
--- a/apps/api/app/services/analysis.py
+++ b/apps/api/app/services/analysis.py
@@ -56,16 +56,18 @@ def create_report_for_task(db: Session, task: AnalysisTask, analysis: dict) -> R
)
else:
payload = {**MOCK_REPORT, **report_data, "childId": task.child_id, "taskId": task.id}
+ risk_level = payload.get("riskLevel", "medium")
+ report_status = "pending_review" if risk_level in ("high", "review_required") else "published"
report = Report(
child_id=task.child_id,
task_id=task.id,
report_type="posture_screening",
- risk_level=payload.get("riskLevel", "medium"),
+ risk_level=risk_level,
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",
+ status=report_status,
)
db.add(report)
db.flush()
diff --git a/apps/api/app/services/report_review.py b/apps/api/app/services/report_review.py
new file mode 100644
index 0000000..b2a3402
--- /dev/null
+++ b/apps/api/app/services/report_review.py
@@ -0,0 +1,82 @@
+"""Report review workflow for staff."""
+
+from datetime import datetime, timezone
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.db.models import Child, Report
+
+
+def report_admin_dict(report: Report, child: Child | None = None) -> dict:
+ return {
+ "id": report.id,
+ "childId": report.child_id,
+ "childName": child.name if child else None,
+ "taskId": report.task_id,
+ "reportType": report.report_type,
+ "riskLevel": report.risk_level,
+ "summary": report.summary,
+ "status": report.status,
+ "reviewedBy": report.reviewed_by,
+ "createdAt": report.created_at.isoformat() if report.created_at else None,
+ }
+
+
+def list_pending_reports(db: Session, tenant_id: int | None = None) -> list[dict]:
+ query = (
+ select(Report, Child)
+ .join(Child, Child.id == Report.child_id)
+ .where(Report.status == "pending_review")
+ .order_by(Report.id.desc())
+ )
+ if tenant_id:
+ query = query.where(Child.tenant_id == tenant_id)
+
+ rows = db.execute(query).all()
+ return [report_admin_dict(report, child) for report, child in rows]
+
+
+def get_report_for_review(db: Session, report_id: int, tenant_id: int | None = None) -> Report | None:
+ report = db.get(Report, report_id)
+ if not report:
+ return None
+ child = db.get(Child, report.child_id)
+ if not child:
+ return None
+ if tenant_id and child.tenant_id != tenant_id:
+ return None
+ return report
+
+
+def review_report(
+ db: Session,
+ reviewer_id: int,
+ report_id: int,
+ action: str,
+ tenant_id: int | None = None,
+ note: str | None = None,
+) -> tuple[Report | None, str | None]:
+ report = get_report_for_review(db, report_id, tenant_id)
+ if not report:
+ return None, "report_not_found"
+ if report.status != "pending_review":
+ return None, "invalid_status"
+
+ if action == "approve":
+ report.status = "published"
+ elif action == "reject":
+ report.status = "rejected"
+ else:
+ return None, "invalid_action"
+
+ report.reviewed_by = reviewer_id
+ report.updated_at = datetime.now(timezone.utc)
+ if note:
+ recommendations = list(report.recommendations or [])
+ recommendations.append(f"复核备注:{note}")
+ report.recommendations = recommendations
+
+ db.commit()
+ db.refresh(report)
+ return report, None
diff --git a/apps/api/app/services/reports.py b/apps/api/app/services/reports.py
index 5b7eb90..4f40a88 100644
--- a/apps/api/app/services/reports.py
+++ b/apps/api/app/services/reports.py
@@ -37,6 +37,7 @@ def report_to_dict(report: Report) -> dict:
"metrics": report.metrics,
"recommendations": report.recommendations,
"disclaimer": report.disclaimer,
+ "status": report.status,
"reviewedBy": report.reviewed_by,
}
diff --git a/apps/api/tests/conftest.py b/apps/api/tests/conftest.py
index c8c11a9..c1f390c 100644
--- a/apps/api/tests/conftest.py
+++ b/apps/api/tests/conftest.py
@@ -44,3 +44,36 @@ def auth_headers(client):
assert resp.status_code == 200, resp.text
token = resp.json()["data"]["token"]
return {"Authorization": f"Bearer {token}"}
+
+
+@pytest.fixture()
+def staff_headers(client, db_session):
+ from sqlalchemy import select
+
+ from app.core.security import hash_phone
+ from app.db.models import User
+
+ resp = client.post(
+ "/api/auth/login",
+ json={"loginType": "phone_code", "credential": "18600000001", "code": "682139"},
+ )
+ assert resp.status_code == 200, resp.text
+ token = resp.json()["data"]["token"]
+ user = db_session.scalar(select(User).where(User.phone_hash == hash_phone("18600000001")))
+ if user:
+ user.role = "coach"
+ db_session.commit()
+ return {"Authorization": f"Bearer {token}"}
+
+
+@pytest.fixture()
+def db_session(client):
+ gen = app.dependency_overrides[get_db]()
+ db = next(gen)
+ try:
+ yield db
+ finally:
+ try:
+ next(gen)
+ except StopIteration:
+ pass
diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py
index 79ccf53..7a339ae 100644
--- a/apps/api/tests/test_api.py
+++ b/apps/api/tests/test_api.py
@@ -4,7 +4,7 @@ from fastapi.testclient import TestClient
def test_health(client):
resp = client.get("/health")
assert resp.status_code == 200
- assert resp.json()["stage"] == "sprint5"
+ assert resp.json()["stage"] == "sprint6"
def test_login_invalid_code(client):
diff --git a/apps/api/tests/test_sprint6.py b/apps/api/tests/test_sprint6.py
new file mode 100644
index 0000000..a7f2579
--- /dev/null
+++ b/apps/api/tests/test_sprint6.py
@@ -0,0 +1,63 @@
+def test_review_required_risk_level():
+ from app.ai.pose_metrics import LEFT_HIP, LEFT_SHOULDER, NOSE, RIGHT_HIP, RIGHT_SHOULDER, build_screening_report
+
+ bad_frame = {
+ NOSE: {"x": 0.85, "y": 0.28, "visibility": 0.95},
+ LEFT_SHOULDER: {"x": 0.42, "y": 0.35, "visibility": 0.95},
+ RIGHT_SHOULDER: {"x": 0.58, "y": 0.48, "visibility": 0.95},
+ LEFT_HIP: {"x": 0.44, "y": 0.62, "visibility": 0.95},
+ RIGHT_HIP: {"x": 0.56, "y": 0.50, "visibility": 0.95},
+ }
+ report = build_screening_report([bad_frame] * 6)
+ assert report["riskLevel"] == "review_required"
+
+
+def test_admin_report_review_flow(client, auth_headers, staff_headers, db_session):
+ from app.db.models import Report
+
+ child_id = client.post(
+ "/api/children",
+ headers=auth_headers,
+ json={"name": "复核测试", "birthday": "2016-06-01", "gender": "female"},
+ ).json()["data"]["id"]
+
+ object_key = f"videos/{child_id}/review.mp4"
+ video_id = client.post(
+ "/api/videos",
+ headers=auth_headers,
+ json={"childId": child_id, "objectKey": object_key, "scene": "front_posture"},
+ ).json()["data"]["id"]
+
+ task = client.post(
+ "/api/analysis/tasks",
+ headers={**auth_headers, "Idempotency-Key": "sprint6-review"},
+ json={"childId": child_id, "videoId": video_id, "taskType": "posture_screening"},
+ ).json()["data"]
+ report_id = task["reportId"]
+ assert report_id
+
+ report = db_session.get(Report, report_id)
+ report.status = "pending_review"
+ report.risk_level = "high"
+ db_session.commit()
+
+ pending = client.get("/api/admin/reports/pending", headers=staff_headers)
+ assert pending.status_code == 200
+ assert any(item["id"] == report_id for item in pending.json()["data"]["list"])
+
+ approved = client.post(
+ f"/api/admin/reports/{report_id}/review",
+ headers=staff_headers,
+ json={"action": "approve", "note": "指标可接受,发布报告"},
+ )
+ assert approved.status_code == 200
+ assert approved.json()["data"]["status"] == "published"
+ assert approved.json()["data"]["reviewedBy"]
+
+ detail = client.get(f"/api/reports/{report_id}", headers=auth_headers)
+ assert detail.json()["data"]["status"] == "published"
+
+
+def test_health_sprint6(client):
+ resp = client.get("/health")
+ assert resp.json()["stage"] == "sprint6"
diff --git a/apps/mini/app.json b/apps/mini/app.json
index d66e576..885eb25 100644
--- a/apps/mini/app.json
+++ b/apps/mini/app.json
@@ -1,8 +1,9 @@
{
"pages": [
- "pages/login/login",
"pages/home/home",
- "pages/screening/screening"
+ "pages/screening/screening",
+ "pages/training/training",
+ "pages/login/login"
],
"window": {
"navigationBarTitleText": "Happy Up",
@@ -10,6 +11,25 @@
"navigationBarTextStyle": "white",
"backgroundColor": "#eef2f7"
},
+ "tabBar": {
+ "color": "#6b7280",
+ "selectedColor": "#1a6fb5",
+ "backgroundColor": "#ffffff",
+ "list": [
+ {
+ "pagePath": "pages/home/home",
+ "text": "首页"
+ },
+ {
+ "pagePath": "pages/screening/screening",
+ "text": "筛查"
+ },
+ {
+ "pagePath": "pages/training/training",
+ "text": "训练"
+ }
+ ]
+ },
"style": "v2",
"sitemapLocation": "sitemap.json"
}
diff --git a/apps/mini/pages/training/training.js b/apps/mini/pages/training/training.js
new file mode 100644
index 0000000..796e497
--- /dev/null
+++ b/apps/mini/pages/training/training.js
@@ -0,0 +1,50 @@
+const api = require('../../utils/api')
+
+Page({
+ data: {
+ goal: '',
+ exerciseName: '',
+ planId: null,
+ score: 82,
+ status: '准备就绪',
+ },
+ onShow() {
+ this.loadPlan()
+ },
+ async loadPlan() {
+ try {
+ const children = await api.listChildren()
+ const child = (children.list || [])[0]
+ if (!child) return
+ const plans = await api.request(`/api/training/plans?childId=${child.id}`)
+ const plan = (plans.list || [])[0]
+ if (!plan) return
+ this.setData({
+ planId: plan.id,
+ goal: plan.detail.goal,
+ exerciseName: (plan.detail.exercises[0] || {}).name || '训练动作',
+ })
+ } catch (err) {
+ this.setData({ status: err.message || '加载失败' })
+ }
+ },
+ async submit() {
+ if (!this.data.planId) return
+ this.setData({ status: '提交打卡…' })
+ try {
+ await api.request(`/api/training/plans/${this.data.planId}/records`, {
+ method: 'POST',
+ data: {
+ exerciseId: 1,
+ completed: true,
+ score: this.data.score,
+ durationSeconds: 300,
+ note: '小程序跟练打卡',
+ },
+ })
+ this.setData({ status: '打卡成功 ✓' })
+ } catch (err) {
+ this.setData({ status: err.message || '打卡失败' })
+ }
+ },
+})
diff --git a/apps/mini/pages/training/training.json b/apps/mini/pages/training/training.json
new file mode 100644
index 0000000..a8295c4
--- /dev/null
+++ b/apps/mini/pages/training/training.json
@@ -0,0 +1,3 @@
+{
+ "navigationBarTitleText": "训练跟练"
+}
diff --git a/apps/mini/pages/training/training.wxml b/apps/mini/pages/training/training.wxml
new file mode 100644
index 0000000..1181e4e
--- /dev/null
+++ b/apps/mini/pages/training/training.wxml
@@ -0,0 +1,7 @@
+
{{ planGoal || '加载训练计划…' }}
+目标 {{ durationMinutes }} 分钟 · 得分 {{ score }}
+{{ statusText }}
+ + +