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 @@ + + 训练计划 + {{goal}} + {{exerciseName}} + {{status}} + + diff --git a/apps/mini/pages/training/training.wxss b/apps/mini/pages/training/training.wxss new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue index e2a5e23..baa5fcf 100644 --- a/apps/web/src/App.vue +++ b/apps/web/src/App.vue @@ -2,7 +2,7 @@
Happy Up - Sprint 5 H5 + Sprint 6 H5
@@ -20,6 +20,10 @@ 报告 + + + 跟练 + 训练 diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index 6ac7666..6075287 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -1,6 +1,7 @@ import { createRouter, createWebHistory } from 'vue-router' import HomeView from '../views/HomeView.vue' import LoginView from '../views/LoginView.vue' +import PracticeView from '../views/PracticeView.vue' import ReportView from '../views/ReportView.vue' import ScreeningView from '../views/ScreeningView.vue' import TrainingView from '../views/TrainingView.vue' @@ -14,6 +15,7 @@ const router = createRouter({ { path: '/screening', component: ScreeningView }, { path: '/reports', component: ReportView }, { path: '/training', component: TrainingView }, + { path: '/practice', component: PracticeView }, ], }) diff --git a/apps/web/src/views/HomeView.vue b/apps/web/src/views/HomeView.vue index a3cba74..ffbcd83 100644 --- a/apps/web/src/views/HomeView.vue +++ b/apps/web/src/views/HomeView.vue @@ -9,6 +9,7 @@
{{ child.birthday }} · {{ child.gender }}
+
@@ -41,4 +42,8 @@ function goScreening() { } router.push('/screening') } + +function goPractice() { + router.push('/practice') +} diff --git a/apps/web/src/views/PracticeView.vue b/apps/web/src/views/PracticeView.vue new file mode 100644 index 0000000..69093e8 --- /dev/null +++ b/apps/web/src/views/PracticeView.vue @@ -0,0 +1,82 @@ + + + diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..0b6e3e8 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,34 @@ +# Kubernetes 部署骨架(Sprint 6) + +## 前置 + +- 集群已安装 Ingress Controller +- 镜像推送到私有 Registry +- MySQL / Redis / MinIO 可用(云 RDS 或集群内 Helm) + +## 部署 + +```bash +# 1. 创建 Secret(勿提交明文) +kubectl create secret generic happy-up-api-secret \ + --from-literal=DATABASE_URL='mysql+pymysql://...' \ + --from-literal=REDIS_URL='redis://...' \ + --from-literal=JWT_SECRET='...' \ + --from-literal=OSS_ACCESS_KEY='...' \ + --from-literal=OSS_SECRET_KEY='...' + +# 2. 应用清单 +kubectl apply -f deploy/k8s/api.yaml + +# 3. 验证 +kubectl rollout status deployment/happy-up-api +curl https://api.happy-up.example.com/health +``` + +## Worker + +分析 Worker 可单独 Deployment,环境变量与 API 相同,`command: ["python", "-m", "app.worker"]`。 + +## CI + +Gitea Actions 见 `.gitea/workflows/ci.yaml`,推送 main 自动跑 API 测试与 Web 构建。 diff --git a/deploy/k8s/api.yaml b/deploy/k8s/api.yaml new file mode 100644 index 0000000..dd71743 --- /dev/null +++ b/deploy/k8s/api.yaml @@ -0,0 +1,84 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: happy-up-api + labels: + app: happy-up-api +spec: + replicas: 2 + selector: + matchLabels: + app: happy-up-api + template: + metadata: + labels: + app: happy-up-api + spec: + containers: + - name: api + image: registry.example.com/happy-up/api:latest + ports: + - containerPort: 8000 + envFrom: + - configMapRef: + name: happy-up-api-config + - secretRef: + name: happy-up-api-secret + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 15 + periodSeconds: 20 + resources: + requests: + cpu: 200m + memory: 256Mi + limits: + cpu: "1" + memory: 512Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: happy-up-api +spec: + selector: + app: happy-up-api + ports: + - port: 80 + targetPort: 8000 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: happy-up-api-config +data: + APP_ENV: production + APP_DEBUG: "false" + ANALYSIS_INLINE_PROCESS: "false" + OSS_ENABLED: "true" + OSS_PROVIDER: aliyun +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: happy-up-api +spec: + rules: + - host: api.happy-up.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: happy-up-api + port: + number: 80 diff --git a/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/03_技术架构/产品交互Demo.html b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/03_技术架构/产品交互Demo.html index 317db7d..f01500d 100644 --- a/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/03_技术架构/产品交互Demo.html +++ b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/03_技术架构/产品交互Demo.html @@ -662,7 +662,7 @@
产品交互 Demo - Sprint 5 · AI 评分 / PDF / H5 + Sprint 6 · 复核 / CI / K8s
diff --git a/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/03_技术架构/系统架构与建设规划.md b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/03_技术架构/系统架构与建设规划.md index affa5d8..f7d59d9 100644 --- a/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/03_技术架构/系统架构与建设规划.md +++ b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/03_技术架构/系统架构与建设规划.md @@ -377,12 +377,19 @@ happy-up/ - H5 四 Tab UI(首页/筛查/报告/训练)对齐 Demo 配色 - 微信小程序壳 `apps/mini` -### 10.9 Sprint 6 下一步 +### 10.9 Sprint 6 已完成 -1. 真实视频数据集标定与模型精度调优 -2. 小程序完整 Tab + 跟练页 -3. 报告复核工作流 + 消息通知 -4. 生产部署(K8s / CI-CD) +- 报告复核工作流(高风险 → `pending_review` → 教练 approve/reject) +- 姿态指标标定配置 `app/ai/calibration.py` +- Gitea CI(`.gitea/workflows/ci.yaml`)+ K8s 部署骨架(`deploy/k8s/`) +- H5 跟练页 + 小程序 TabBar(首页/筛查/训练) + +### 10.10 Sprint 7 下一步 + +1. 生产镜像构建与 Registry 推送 +2. 报告复核后台 UI(Admin Web) +3. 消息通知(短信/模板消息) +4. 真实数据集标定与 A/B 指标对比 ---