Sprint 6: report review workflow, CI/K8s, and client tabs.
CI / api-test (push) Has been cancelled
CI / web-build (push) Has been cancelled

Add coach review APIs, pose calibration thresholds, Gitea CI, Kubernetes skeleton, H5 practice page, and mini program tab bar.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-23 11:44:11 +08:00
parent 1aaef71f52
commit a400130c67
27 changed files with 636 additions and 27 deletions
+33
View File
@@ -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
+11 -1
View File
@@ -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)。
## 文档索引
+13
View File
@@ -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 = ("头前伸", "高低肩", "骨盆倾斜")
+11 -7
View File
@@ -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 = "多项指标偏高,建议尽快安排专业评估与干预"
+18 -1
View File
@@ -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()
+5 -5
View File
@@ -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",
}
+49 -1
View File
@@ -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)
+5
View File
@@ -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
+4 -2
View File
@@ -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()
+82
View File
@@ -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
+1
View File
@@ -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,
}
+33
View File
@@ -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
+1 -1
View File
@@ -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):
+63
View File
@@ -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"
+22 -2
View File
@@ -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"
}
+50
View File
@@ -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 || '打卡失败' })
}
},
})
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "训练跟练"
}
+7
View File
@@ -0,0 +1,7 @@
<view class="card">
<view>训练计划</view>
<view class="muted">{{goal}}</view>
<view>{{exerciseName}}</view>
<view class="muted">{{status}}</view>
<button class="btn-primary" bindtap="submit">提交跟练打卡</button>
</view>
+5 -1
View File
@@ -2,7 +2,7 @@
<div class="app-shell">
<header class="topbar">
<strong>Happy Up</strong>
<span class="stage">Sprint 5 H5</span>
<span class="stage">Sprint 6 H5</span>
</header>
<div class="page">
<RouterView />
@@ -20,6 +20,10 @@
<span class="dot"></span>
报告
</RouterLink>
<RouterLink to="/practice" :class="{ active: route.path === '/practice' }">
<span class="dot"></span>
跟练
</RouterLink>
<RouterLink to="/training" :class="{ active: route.path === '/training' }">
<span class="dot"></span>
训练
+2
View File
@@ -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 },
],
})
+5
View File
@@ -9,6 +9,7 @@
<div class="muted">{{ child.birthday }} · {{ child.gender }}</div>
</div>
<button class="btn" style="margin-top: 16px" @click="goScreening">开始筛查</button>
<button class="btn secondary" style="margin-top: 8px" @click="goPractice">跟练打卡</button>
</div>
</section>
</template>
@@ -41,4 +42,8 @@ function goScreening() {
}
router.push('/screening')
}
function goPractice() {
router.push('/practice')
}
</script>
+82
View File
@@ -0,0 +1,82 @@
<template>
<section class="card">
<h2>跟练打卡</h2>
<p class="muted">{{ planGoal || '加载训练计划…' }}</p>
<div v-if="exercise" class="list-item">
<strong>{{ exercise.name }}</strong>
<p class="muted">目标 {{ durationMinutes }} 分钟 · 得分 {{ score }}</p>
</div>
<div class="progress"><span :style="{ width: progress + '%' }"></span></div>
<p class="muted">{{ statusText }}</p>
<button class="btn" :disabled="running || !planId" @click="startPractice">开始跟练</button>
<button v-if="planId" class="btn secondary" style="margin-top: 8px" :disabled="running" @click="submitRecord">
提交打卡
</button>
</section>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { apiFetch, listChildren, listTrainingPlans } from '../api'
const planId = ref<number | null>(null)
const planGoal = ref('')
const exercise = ref<{ name: string } | null>(null)
const durationMinutes = ref(5)
const progress = ref(0)
const score = ref(80)
const statusText = ref('准备就绪')
const running = ref(false)
onMounted(async () => {
try {
const { list } = await listChildren()
const child = list[0]
if (!child) return
const plans = await listTrainingPlans(child.id)
const plan = plans.list[0]
if (!plan) return
planId.value = plan.id
planGoal.value = plan.detail.goal
exercise.value = plan.detail.exercises[0] || null
durationMinutes.value = plan.detail.exercises[0]?.durationMinutes || 5
} catch {
statusText.value = '暂无训练计划'
}
})
async function startPractice() {
running.value = true
progress.value = 0
statusText.value = '跟练中…'
for (let step = 1; step <= 5; step += 1) {
await new Promise((resolve) => setTimeout(resolve, 400))
progress.value = step * 20
}
score.value = 78 + Math.floor(Math.random() * 15)
statusText.value = '跟练完成,可提交打卡'
running.value = false
}
async function submitRecord() {
if (!planId.value || !exercise.value) return
running.value = true
try {
await apiFetch(`/api/training/plans/${planId.value}/records`, {
method: 'POST',
body: JSON.stringify({
exerciseId: 1,
completed: true,
score: score.value,
durationSeconds: durationMinutes.value * 60,
note: 'H5 跟练打卡',
}),
})
statusText.value = '打卡成功 ✓'
} catch (err) {
statusText.value = err instanceof Error ? err.message : '打卡失败'
} finally {
running.value = false
}
}
</script>
+34
View File
@@ -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 构建。
+84
View File
@@ -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
@@ -662,7 +662,7 @@
<div class="demo-topbar">
<div>
<span class="title">产品交互 Demo</span>
<span class="badge" id="demoStageBadge">Sprint 5 · AI 评分 / PDF / H5</span>
<span class="badge" id="demoStageBadge">Sprint 6 · 复核 / CI / K8s</span>
</div>
<div class="demo-topbar-actions">
<button type="button" class="btn-api-toggle" id="apiModeToggle" title="连接本地 FastAPI">API 联调关</button>
@@ -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. 报告复核后台 UIAdmin Web
3. 消息通知(短信/模板消息)
4. 真实数据集标定与 A/B 指标对比
---