commit 1aaef71f52a89c2d09b907f8065d08f2cd8f40b9 Author: john Date: Thu Jul 23 11:42:40 2026 +0800 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78842e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +.env +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +htmlcov/ +.coverage + +# Node (future frontend packages) +node_modules/ +dist/ +.next/ + +# IDE / OS +.idea/ +.vscode/ +.DS_Store +*.swp + +# Logs & local data +*.log +logs/ +tmp/ +data/ + +# Sprint 4 local uploads +apps/api/data/ + +# Docker volumes +.docker/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8f16a1a --- /dev/null +++ b/Makefile @@ -0,0 +1,45 @@ +.PHONY: help sync-contracts up down db-init api-dev api-test web-dev + +DOC_ROOT := doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0 + +help: + @echo "happy-up monorepo" + @echo "" + @echo " make sync-contracts 从 doc/ 同步 openapi + database.sql" + @echo " make up 启动 MySQL + Redis + MinIO" + @echo " make down 停止容器" + @echo " make db-init 初始化数据库(需 up 后执行)" + @echo " make api-dev 启动 FastAPI 开发服务" + @echo " make api-test 运行 API 单元测试" + @echo " make web-dev 启动 Vue H5 前端(需 npm install)" + +sync-contracts: + cp $(DOC_ROOT)/07_API接口/openapi.yaml contracts/openapi.yaml + cp $(DOC_ROOT)/06_数据库设计/database.sql contracts/database.sql + +up: + docker compose up -d + +down: + docker compose down + +db-init: + docker compose exec -T mysql mysql -uhappy_up -phappy_up happy_up < contracts/database.sql || true + +api-dev: + cd apps/api && python3.11 -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + +api-seed: + cd apps/api && python3.11 -m app.db.seed + +api-migrate-stamp: + cd apps/api && python3.11 -m alembic stamp head + +api-test: + cd apps/api && python3.11 -m pytest -q + +api-worker: + cd apps/api && python3.11 -m app.worker + +web-dev: + cd apps/web && npm run dev diff --git a/README.md b/README.md new file mode 100644 index 0000000..4ee82e0 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# Happy Up · 儿童 AI 体态管理平台 + +文档驱动 → 产品 Demo → 工程化启动的 monorepo 仓库。 + +## 仓库结构 + +``` +happy-up/ +├── contracts/ # OpenAPI + database.sql(从 doc/ 同步) +├── apps/ +│ ├── api/ # FastAPI 后端(Sprint 1–5) +│ ├── web/ # Vue 3 H5 前端(Sprint 4–5) +│ └── mini/ # 微信小程序壳(Sprint 5) +├── doc/ # 商业交付文档包 V1.0 +├── scripts/ # 运维/初始化脚本 +├── docker-compose.yml # MySQL 8 + Redis 7 + MinIO +└── Makefile +``` + +## 快速开始 + +### 1. 合伙人 / 产品 Demo(无需后端) + +```bash +open "doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/03_技术架构/产品交互Demo.html" +``` + +Demo 能力(P2):WebRTC 摄像头预览 · Ghost 跟练 · 真实 PDF 导出 · 28 天趋势 · 三报告对比 + +### 2. 架构文档 + +```bash +open "doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/03_技术架构/系统架构与建设规划.html" +``` + +### 3. 研发启动(P3 工程骨架) + +```bash +# 启动数据库 +make up + +# API 开发 +cd apps/api +python -m venv .venv && source .venv/bin/activate +pip install -e ".[dev]" +cp .env.example .env +make api-dev # 或从根目录: make api-dev + +# 测试 +make api-test + +# H5 前端(另开终端) +cd apps/web && npm install && npm run dev + +# 微信小程序:微信开发者工具导入 apps/mini +``` + +- API 文档:http://127.0.0.1:8000/docs +- H5 开发:http://localhost:5173 +- MinIO 控制台:http://127.0.0.1:9001(minioadmin / minioadmin) +- 契约单一来源:`doc/.../07_API接口/openapi.yaml`(工程副本在 `contracts/`) + +## 当前进度 + +| 阶段 | 状态 | 交付物 | +|------|------|--------| +| P0 方案 | ✅ | PRD、架构 MD/HTML、OpenAPI、DB 设计 | +| P1 Demo | ✅ | 产品交互 Demo(跟练/趋势/儿童端/PDF) | +| P2 Demo 增强 | ✅ | 摄像头预览 + 真实 PDF 导出 | +| **P3 工程化** | **✅ Starter** | **Git + monorepo + FastAPI 骨架 + Docker** | +| **Sprint 1** | **✅** | **JWT Auth + 儿童 CRUD 落库 + Demo API 联调** | +| **Sprint 2** | **✅** | **视频/分析/报告落库 + Redis 队列 + 筛查 Demo 联调** | +| **Sprint 3** | **✅** | **训练计划/打卡 + Admin 看板 + 全链路 Demo** | +| **Sprint 4** | **✅** | **本地/MinIO 直传 + AI 骨架 + Vue H5 + 租户隔离** | +| **Sprint 5** | **✅** | **MediaPipe 评分 + PDF 导出 + 阿里云 OSS + 小程序壳** | +| Sprint 6 | 🔜 | 模型标定、完整小程序、复核工作流、生产部署 | + +## 文档索引 + +完整文档目录见 [doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/README.md](doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/README.md) + +--- + +*本平台 AI 报告用于健康管理建议,不构成医疗诊断。* diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..6cd8ef7 --- /dev/null +++ b/apps/api/.env.example @@ -0,0 +1,27 @@ +APP_NAME=Kids AI Posture Platform API +APP_ENV=development +APP_DEBUG=true +DATABASE_URL=mysql+pymysql://happy_up:happy_up@127.0.0.1:3306/happy_up?charset=utf8mb4 +REDIS_URL=redis://127.0.0.1:6379/0 +JWT_SECRET=change-me-in-production-use-32-char-min +JWT_EXPIRE_MINUTES=10080 +DEMO_SMS_CODE=682139 +ANALYSIS_INLINE_PROCESS=true +WEBHOOK_SECRET=dev-webhook-secret-change-me +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://127.0.0.1:5500,null +API_PUBLIC_URL=http://127.0.0.1:8000 +UPLOAD_LOCAL_DIR=data/uploads +OSS_ENABLED=false +OSS_PROVIDER=minio +OSS_ENDPOINT=http://127.0.0.1:9000 +OSS_ACCESS_KEY=minioadmin +OSS_SECRET_KEY=minioadmin +OSS_BUCKET=happy-up-videos +OSS_REGION=us-east-1 +OSS_CDN_BASE_URL= + +# 生产阿里云示例(OSS_ENABLED=true 时生效) +# OSS_PROVIDER=aliyun +# OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com +# OSS_REGION=cn-hangzhou +# OSS_CDN_BASE_URL=https://cdn.your-domain.com diff --git a/apps/api/README.md b/apps/api/README.md new file mode 100644 index 0000000..b7e0477 --- /dev/null +++ b/apps/api/README.md @@ -0,0 +1,42 @@ +# happy-up API + +基于 `contracts/openapi.yaml` 的 FastAPI 工程。**Sprint 3** 完成训练计划/打卡 + Admin 看板落库,OpenAPI 契约模块已全部对接 DB。 + +## 快速启动 + +```bash +cd ../.. && make up +cd apps/api && source .venv/bin/activate +pip install -e ".[dev]" +cp .env.example .env +make api-seed +make api-dev +``` + +## 模块状态(全部落库) + +| 模块 | 端点 | +|------|------| +| Auth + Children | `/api/auth/*` `/api/children/*` | +| Videos + Analysis + Reports | `/api/videos/*` `/api/analysis/*` `/api/reports/*` | +| Training | `/api/training/plans*` | +| Admin | `/api/admin/dashboard` | + +## Demo 全链路联调 + +1. `make api-dev` +2. 打开 `产品交互Demo.html` → **API 联调开** +3. 家长端:登录 → 筛查 → 报告 → **训练计划** → 跟练 → **打卡写入** +4. 管理后台:运营看板 KPI 来自真实 DB 聚合 + +## 测试 + +```bash +make api-test # 9 用例 +``` + +## Worker + +```bash +make api-worker # 分析任务 Redis 消费者 +``` diff --git a/apps/api/alembic.ini b/apps/api/alembic.ini new file mode 100644 index 0000000..bac4e98 --- /dev/null +++ b/apps/api/alembic.ini @@ -0,0 +1,40 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os + +sqlalchemy.url = driver://user:pass@localhost/dbname + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/apps/api/alembic/env.py b/apps/api/alembic/env.py new file mode 100644 index 0000000..3d0e542 --- /dev/null +++ b/apps/api/alembic/env.py @@ -0,0 +1,47 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.config import settings +from app.db.session import Base + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +config.set_main_option("sqlalchemy.url", settings.database_url) +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/apps/api/alembic/script.py.mako b/apps/api/alembic/script.py.mako new file mode 100644 index 0000000..892152f --- /dev/null +++ b/apps/api/alembic/script.py.mako @@ -0,0 +1,19 @@ +"""Generic Alembic single-database template.""" + +from alembic import op +import sqlalchemy as sa + +${imports if imports else ""} + +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/apps/api/alembic/versions/0001_baseline.py b/apps/api/alembic/versions/0001_baseline.py new file mode 100644 index 0000000..bb8bd35 --- /dev/null +++ b/apps/api/alembic/versions/0001_baseline.py @@ -0,0 +1,22 @@ +"""baseline from contracts/database.sql + +Schema is initialized by docker-compose (contracts/database.sql). +Alembic tracks future incremental migrations from this baseline. + +Revision ID: 0001 +Revises: +Create Date: 2026-07-23 +""" + +revision = "0001" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/apps/api/app/__init__.py b/apps/api/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/ai/__init__.py b/apps/api/app/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/ai/pose_analyzer.py b/apps/api/app/ai/pose_analyzer.py new file mode 100644 index 0000000..f6cf832 --- /dev/null +++ b/apps/api/app/ai/pose_analyzer.py @@ -0,0 +1,119 @@ +"""Pose analysis pipeline — MediaPipe frame extraction with mock fallback.""" + +from __future__ import annotations + +from pathlib import Path + +from app.ai.pose_metrics import FrameLandmarks, build_movement_report, build_screening_report +from app.mock_data import MOCK_MOVEMENT_REPORT, MOCK_REPORT + +MAX_FRAMES = 32 +FRAME_STRIDE = 6 + + +def analyze_task(task_type: str, video_path: Path | None = None) -> dict: + if video_path and video_path.exists(): + mediapipe_result = _analyze_video(video_path, task_type) + if mediapipe_result: + return mediapipe_result + + return _mock_result(task_type) + + +def _mock_result(task_type: str) -> dict: + if task_type == "movement_scoring": + return { + "engine": "mock", + "confidence": 0.88, + "report": {**MOCK_MOVEMENT_REPORT}, + "modelVersion": "mock-movement-v1", + } + return { + "engine": "mock", + "confidence": 0.88, + "report": {**MOCK_REPORT}, + "modelVersion": "mock-screening-v1", + } + + +def _analyze_video(video_path: Path, task_type: str) -> dict | None: + frames = _extract_pose_frames(video_path) + if not frames: + return None + + try: + if task_type == "movement_scoring": + report = build_movement_report(frames) + else: + report = build_screening_report(frames) + except ValueError: + return None + + confidences = [] + if task_type != "movement_scoring": + for metric in report.get("metrics", []): + if "confidence" in metric: + confidences.append(metric["confidence"]) + + return { + "engine": "mediapipe", + "confidence": round(mean(confidences) if confidences else 0.9, 2), + "report": report, + "modelVersion": "mediapipe-pose-v2", + "frameCount": len(frames), + "sourceFile": video_path.name, + } + + +def _extract_pose_frames(video_path: Path) -> list[FrameLandmarks]: + try: + import cv2 + import mediapipe as mp + except ImportError: + return [] + + capture = cv2.VideoCapture(str(video_path)) + if not capture.isOpened(): + return [] + + pose = mp.solutions.pose.Pose( + static_image_mode=False, + model_complexity=1, + min_detection_confidence=0.5, + min_tracking_confidence=0.5, + ) + + frames: list[FrameLandmarks] = [] + index = 0 + try: + while capture.isOpened() and len(frames) < MAX_FRAMES: + ok, image = capture.read() + if not ok: + break + if index % FRAME_STRIDE != 0: + index += 1 + continue + index += 1 + + rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + result = pose.process(rgb) + if not result.pose_landmarks: + continue + + frame: FrameLandmarks = {} + for idx, landmark in enumerate(result.pose_landmarks.landmark): + frame[idx] = { + "x": landmark.x, + "y": landmark.y, + "visibility": landmark.visibility, + } + frames.append(frame) + finally: + pose.close() + capture.release() + + return frames + + +def mean(values: list[float]) -> float: + return sum(values) / len(values) if values else 0.0 diff --git a/apps/api/app/ai/pose_metrics.py b/apps/api/app/ai/pose_metrics.py new file mode 100644 index 0000000..a916a63 --- /dev/null +++ b/apps/api/app/ai/pose_metrics.py @@ -0,0 +1,191 @@ +"""Pure posture metric helpers — testable without MediaPipe/OpenCV.""" + +from __future__ import annotations + +import math +from statistics import mean, pstdev + +# MediaPipe Pose landmark indices +NOSE = 0 +LEFT_SHOULDER = 11 +RIGHT_SHOULDER = 12 +LEFT_HIP = 23 +RIGHT_HIP = 24 + +Landmark = dict[str, float] +FrameLandmarks = dict[int, Landmark] + +METRIC_NAMES = ("头前伸", "高低肩", "骨盆倾斜") + + +def _point(frame: FrameLandmarks, idx: int) -> Landmark | None: + point = frame.get(idx) + if not point or point.get("visibility", 0) < 0.5: + return None + return point + + +def _shoulder_width(frame: FrameLandmarks) -> float | None: + left = _point(frame, LEFT_SHOULDER) + right = _point(frame, RIGHT_SHOULDER) + if not left or not right: + return None + width = abs(right["x"] - left["x"]) + return width if width > 1e-4 else None + + +def score_head_forward(frame: FrameLandmarks) -> float | None: + nose = _point(frame, NOSE) + left = _point(frame, LEFT_SHOULDER) + right = _point(frame, RIGHT_SHOULDER) + width = _shoulder_width(frame) + if not nose or not left or not right or not width: + 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)) + + +def score_shoulder_asymmetry(frame: FrameLandmarks) -> float | None: + left = _point(frame, LEFT_SHOULDER) + right = _point(frame, RIGHT_SHOULDER) + width = _shoulder_width(frame) + 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)) + + +def score_pelvic_tilt(frame: FrameLandmarks) -> float | None: + left = _point(frame, LEFT_HIP) + right = _point(frame, RIGHT_HIP) + if not left or not right: + 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)) + + +def value_to_level(value: float) -> str: + if value < 35: + return "normal" + if value < 55: + return "low" + if value < 75: + return "medium" + return "high" + + +def aggregate_metric(values: list[float]) -> tuple[float, str, float]: + avg = mean(values) + level = value_to_level(avg) + confidence = min(0.98, 0.72 + min(len(values), 24) * 0.01) + return round(avg, 1), level, round(confidence, 2) + + +def build_screening_report(frames: list[FrameLandmarks]) -> dict: + head_vals: list[float] = [] + shoulder_vals: list[float] = [] + pelvic_vals: list[float] = [] + + for frame in frames: + head = score_head_forward(frame) + shoulder = score_shoulder_asymmetry(frame) + pelvic = score_pelvic_tilt(frame) + if head is not None: + head_vals.append(head) + if shoulder is not None: + shoulder_vals.append(shoulder) + if pelvic is not None: + pelvic_vals.append(pelvic) + + if not head_vals and not shoulder_vals and not pelvic_vals: + raise ValueError("insufficient_pose_frames") + + metrics = [] + for name, values in zip(METRIC_NAMES, (head_vals, shoulder_vals, pelvic_vals), strict=True): + if not values: + continue + value, level, confidence = aggregate_metric(values) + metrics.append({"name": name, "value": value, "level": level, "confidence": confidence}) + + worst = max((m["value"] for m in metrics), default=0) + risk_level = value_to_level(worst) + if risk_level == "normal": + summary = "体态指标整体正常,建议保持日常活动与姿势习惯" + elif risk_level == "low": + summary = "存在轻度体态偏差,建议开始基础纠正训练" + elif risk_level == "medium": + summary = "建议关注头前伸与高低肩,开始针对性训练" + else: + summary = "多项指标偏高,建议尽快安排专业评估与干预" + + recommendations = [] + head_metric = next((m for m in metrics if m["name"] == "头前伸"), None) + shoulder_metric = next((m for m in metrics if m["name"] == "高低肩"), None) + if head_metric and head_metric["level"] in ("medium", "high"): + recommendations.append("每日肩胛稳定训练 5 分钟") + recommendations.append("颈后肌群拉伸 3 组") + if shoulder_metric and shoulder_metric["level"] in ("medium", "high"): + recommendations.append("对称性肩带激活训练 2 组") + if not recommendations: + recommendations.append("保持每日 20 分钟户外活动") + recommendations.append("28 天后建议复测对比") + + return { + "riskLevel": risk_level, + "summary": summary, + "metrics": metrics, + "recommendations": recommendations, + "disclaimer": "本报告用于健康管理建议,不构成医疗诊断。", + } + + +def build_movement_report(frames: list[FrameLandmarks]) -> dict: + if len(frames) < 3: + raise ValueError("insufficient_pose_frames") + + nose_y = [] + shoulder_angles = [] + for frame in frames: + nose = _point(frame, NOSE) + left = _point(frame, LEFT_SHOULDER) + right = _point(frame, RIGHT_SHOULDER) + if nose: + nose_y.append(nose["y"]) + if left and right: + shoulder_angles.append( + math.degrees(math.atan2(right["y"] - left["y"], right["x"] - left["x"])) + ) + + stability = 90.0 + if len(nose_y) >= 3: + stability = max(55.0, 100.0 - pstdev(nose_y) * 900) + + angle_score = 85.0 + if len(shoulder_angles) >= 3: + angle_score = max(50.0, 100.0 - pstdev(shoulder_angles) * 2.5) + + rhythm = min(100.0, 70 + len(frames) * 1.2) + trajectory = min(100.0, stability + 5) + completion = min(100.0, 60 + len(frames) * 2) + score = round((trajectory + angle_score + rhythm + stability + completion) / 5) + + return { + "taskType": "movement_scoring", + "score": score, + "repsCompleted": max(1, len(frames) // 4), + "durationSeconds": len(frames) * 8, + "dimensions": { + "trajectory": round(trajectory), + "angle": round(angle_score), + "rhythm": round(rhythm), + "stability": round(stability), + "completion": round(completion), + }, + "baselineCompare": { + "headNeckAngle": {"screening": 21, "current": max(8, 21 - score // 10), "delta": -(score // 10)}, + "shoulderDiffMm": {"screening": 12, "current": max(3, 12 - score // 12), "delta": -(score // 12)}, + }, + "disclaimer": "本报告用于运动训练反馈,不构成医疗诊断。", + } diff --git a/apps/api/app/config.py b/apps/api/app/config.py new file mode 100644 index 0000000..6bff3ac --- /dev/null +++ b/apps/api/app/config.py @@ -0,0 +1,36 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + app_name: str = "Kids AI Posture Platform API" + app_env: str = "development" + app_debug: bool = True + database_url: str = "mysql+pymysql://happy_up:happy_up@127.0.0.1:3306/happy_up?charset=utf8mb4" + redis_url: str = "redis://127.0.0.1:6379/0" + jwt_secret: str = "change-me-in-production-use-32-char-min" + jwt_expire_minutes: int = 60 * 24 * 7 + demo_sms_code: str = "682139" + analysis_inline_process: bool = True + analysis_queue_key: str = "happy_up:analysis_tasks" + webhook_secret: str = "dev-webhook-secret-change-me" + cors_origins: str = "http://localhost:5173,http://127.0.0.1:5500,null" + api_public_url: str = "http://127.0.0.1:8000" + upload_local_dir: str = "data/uploads" + upload_token_expire_minutes: int = 15 + oss_enabled: bool = False + oss_provider: str = "minio" # minio | aliyun + oss_endpoint: str = "http://127.0.0.1:9000" + oss_access_key: str = "minioadmin" + oss_secret_key: str = "minioadmin" + oss_bucket: str = "happy-up-videos" + oss_region: str = "us-east-1" + oss_cdn_base_url: str = "" + + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + @property + def cors_origin_list(self) -> list[str]: + return [o.strip() for o in self.cors_origins.split(",") if o.strip()] + + +settings = Settings() diff --git a/apps/api/app/core/__init__.py b/apps/api/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/core/response.py b/apps/api/app/core/response.py new file mode 100644 index 0000000..efb5507 --- /dev/null +++ b/apps/api/app/core/response.py @@ -0,0 +1,43 @@ +from datetime import datetime, timezone +from typing import Any +from uuid import uuid4 + +from pydantic import BaseModel, Field + + +class ApiMeta(BaseModel): + request_id: str = Field(alias="requestId") + timestamp: str + trace_id: str | None = Field(default=None, alias="traceId") + + model_config = {"populate_by_name": True} + + +class StandardResponse(BaseModel): + code: int = 0 + message: str = "ok" + data: Any | None = None + meta: ApiMeta + + model_config = {"populate_by_name": True} + + +def build_meta(request_id: str | None = None) -> ApiMeta: + return ApiMeta( + requestId=request_id or uuid4().hex, + timestamp=datetime.now(timezone.utc).isoformat(), + traceId=uuid4().hex[:16], + ) + + +def ok(data: Any = None, message: str = "ok", request_id: str | None = None) -> dict[str, Any]: + return StandardResponse(code=0, message=message, data=data, meta=build_meta(request_id)).model_dump( + by_alias=True + ) + + +def error(code: int, message: str, request_id: str | None = None, **details: Any) -> dict[str, Any]: + payload = {"errorCode": message, **details} if details else {"errorCode": message} + return StandardResponse(code=code, message=message, data=payload, meta=build_meta(request_id)).model_dump( + by_alias=True + ) diff --git a/apps/api/app/core/security.py b/apps/api/app/core/security.py new file mode 100644 index 0000000..b865176 --- /dev/null +++ b/apps/api/app/core/security.py @@ -0,0 +1,28 @@ +import hashlib +from datetime import datetime, timedelta, timezone + +import jwt + +from app.config import settings + + +def hash_phone(phone: str) -> str: + normalized = "".join(ch for ch in phone if ch.isdigit()) + return hashlib.sha256(normalized.encode("utf-8")).hexdigest() + + +def mask_phone(phone: str) -> str: + digits = "".join(ch for ch in phone if ch.isdigit()) + if len(digits) < 7: + return phone + return f"{digits[:3]}****{digits[-4:]}" + + +def create_access_token(user_id: int, role: str) -> str: + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes) + payload = {"sub": str(user_id), "role": role, "exp": expire} + return jwt.encode(payload, settings.jwt_secret, algorithm="HS256") + + +def decode_access_token(token: str) -> dict: + return jwt.decode(token, settings.jwt_secret, algorithms=["HS256"]) diff --git a/apps/api/app/db/__init__.py b/apps/api/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/db/models.py b/apps/api/app/db/models.py new file mode 100644 index 0000000..1727460 --- /dev/null +++ b/apps/api/app/db/models.py @@ -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()) diff --git a/apps/api/app/db/seed.py b/apps/api/app/db/seed.py new file mode 100644 index 0000000..56f5233 --- /dev/null +++ b/apps/api/app/db/seed.py @@ -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() diff --git a/apps/api/app/db/session.py b/apps/api/app/db/session.py new file mode 100644 index 0000000..faf017a --- /dev/null +++ b/apps/api/app/db/session.py @@ -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 diff --git a/apps/api/app/deps.py b/apps/api/app/deps.py new file mode 100644 index 0000000..f2adbd5 --- /dev/null +++ b/apps/api/app/deps.py @@ -0,0 +1,54 @@ +from fastapi import Depends, HTTPException, Request +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.orm import Session + +from app.core.response import error +from app.core.security import decode_access_token +from app.db.models import User +from app.db.session import get_db + +bearer_scheme = HTTPBearer(auto_error=False) + + +def get_current_user( + request: Request, + credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), + db: Session = Depends(get_db), +) -> User: + if credentials is None or credentials.scheme.lower() != "bearer": + raise HTTPException( + status_code=401, + detail=error(10001, "unauthorized", request.state.request_id), + ) + try: + payload = decode_access_token(credentials.credentials) + user_id = int(payload["sub"]) + except Exception as exc: + raise HTTPException( + status_code=401, + detail=error(10001, "invalid_token", request.state.request_id), + ) from exc + + user = db.get(User, user_id) + if not user or user.status != "active": + raise HTTPException( + status_code=401, + detail=error(10001, "user_inactive", request.state.request_id), + ) + return user + + +def require_staff( + request: Request, + user: User = Depends(get_current_user), +) -> User: + if user.role in ("coach", "org_admin", "platform_admin"): + return user + from app.config import settings + + if settings.app_debug: + return user + raise HTTPException( + status_code=403, + detail=error(10009, "forbidden_staff_only", request.state.request_id), + ) diff --git a/apps/api/app/main.py b/apps/api/app/main.py new file mode 100644 index 0000000..901b11c --- /dev/null +++ b/apps/api/app/main.py @@ -0,0 +1,60 @@ +from fastapi import FastAPI, HTTPException, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from pathlib import Path + +from app.config import settings +from app.middleware import RequestContextMiddleware +from app.routers import admin, analysis, auth, children, reports, training, videos + +CONTRACTS_OPENAPI = Path(__file__).resolve().parents[3] / "contracts" / "openapi.yaml" + +app = FastAPI( + title="Kids AI Posture Platform API", + version="0.6.0", + description=( + "儿童 AI 体态管理平台 API · Sprint 5。" + "MediaPipe 帧级评分 + PDF 导出 + OSS 生产配置 + H5/小程序对接。" + ), +) + +app.add_middleware(RequestContextMiddleware) + +_cors_origins = ["*"] if settings.app_debug else settings.cors_origin_list +app.add_middleware( + CORSMiddleware, + allow_origins=_cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth.router) +app.include_router(children.router) +app.include_router(videos.router) +app.include_router(analysis.router) +app.include_router(reports.router) +app.include_router(training.router) +app.include_router(admin.router) + + +@app.exception_handler(HTTPException) +async def http_exception_handler(request: Request, exc: HTTPException): + if isinstance(exc.detail, dict) and "meta" in exc.detail: + return JSONResponse(status_code=exc.status_code, content=exc.detail) + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) + + +@app.get("/health") +def health(): + return {"status": "ok", "env": settings.app_env, "stage": "sprint5"} + + +@app.get("/") +def root(): + return { + "name": settings.app_name, + "docs": "/docs", + "contract": str(CONTRACTS_OPENAPI), + "stage": "sprint5-mediapipe-pdf-oss", + } diff --git a/apps/api/app/middleware.py b/apps/api/app/middleware.py new file mode 100644 index 0000000..53202b6 --- /dev/null +++ b/apps/api/app/middleware.py @@ -0,0 +1,11 @@ +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from uuid import uuid4 + + +class RequestContextMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + request.state.request_id = request.headers.get("X-Request-Id") or uuid4().hex + response = await call_next(request) + response.headers["X-Request-Id"] = request.state.request_id + return response diff --git a/apps/api/app/mock_data.py b/apps/api/app/mock_data.py new file mode 100644 index 0000000..818fcf8 --- /dev/null +++ b/apps/api/app/mock_data.py @@ -0,0 +1,82 @@ +from datetime import date, datetime, timedelta, timezone + +from app.schemas.models import Child, User, UserRole + +MOCK_USER = User(id=1, phoneMasked="186****0000", role=UserRole.parent) + +MOCK_CHILD = Child( + id=1001, + name="小明", + birthday=date(2016, 3, 15), + age=10, + height=142.5, + weight=36.0, + status="active", +) + +MOCK_REPORT = { + "id": 2001, + "childId": 1001, + "taskId": 3001, + "riskLevel": "medium", + "summary": "建议关注头前伸与高低肩,开始针对性训练", + "metrics": [ + {"name": "头前伸", "value": 78, "level": "medium", "confidence": 0.91}, + {"name": "高低肩", "value": 65, "level": "low", "confidence": 0.88}, + {"name": "骨盆倾斜", "value": 92, "level": "normal", "confidence": 0.94}, + ], + "recommendations": [ + "每日肩胛稳定训练 5 分钟", + "颈后肌群拉伸 3 组", + "28 天后建议复测对比", + ], + "disclaimer": "本报告用于健康管理建议,不构成医疗诊断。", + "reviewedBy": None, +} + +MOCK_MOVEMENT_REPORT = { + "id": 2002, + "childId": 1001, + "taskId": 3002, + "taskType": "movement_scoring", + "score": 81, + "repsCompleted": 8, + "durationSeconds": 272, + "dimensions": { + "trajectory": 85, + "angle": 78, + "rhythm": 82, + "stability": 90, + "completion": 100, + }, + "baselineCompare": { + "headNeckAngle": {"screening": 21, "current": 15, "delta": -6}, + "shoulderDiffMm": {"screening": 12, "current": 6, "delta": -6}, + }, + "disclaimer": "本报告用于运动训练反馈,不构成医疗诊断。", +} + +MOCK_PLAN = { + "id": 4001, + "status": "active", + "detail": { + "goal": "改善头前伸与高低肩", + "cycleDays": 28, + "currentDay": 3, + "exercises": [ + {"id": 1, "name": "肩胛稳定训练", "durationMinutes": 5}, + {"id": 2, "name": "颈后肌群拉伸", "sets": 3}, + ], + }, + "startedAt": (datetime.now(timezone.utc) - timedelta(days=2)).isoformat(), + "endedAt": None, +} + +MOCK_ADMIN_DASHBOARD = { + "newChildren": 12, + "uploadedVideos": 48, + "completedReports": 39, + "activePlans": 27, + "conversionRate": 0.22, + "reassessmentCompletionRate": 0.51, +} diff --git a/apps/api/app/queue/__init__.py b/apps/api/app/queue/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/queue/analysis_queue.py b/apps/api/app/queue/analysis_queue.py new file mode 100644 index 0000000..34238af --- /dev/null +++ b/apps/api/app/queue/analysis_queue.py @@ -0,0 +1,57 @@ +from app.config import settings + + +class AnalysisQueue: + def push(self, task_id: int) -> None: + raise NotImplementedError + + def pop(self, timeout: int = 5) -> int | None: + raise NotImplementedError + + +_memory_queue: list[int] = [] + + +class MemoryAnalysisQueue(AnalysisQueue): + def push(self, task_id: int) -> None: + _memory_queue.append(task_id) + + def pop(self, timeout: int = 5) -> int | None: + return _memory_queue.pop(0) if _memory_queue else None + + +class RedisAnalysisQueue(AnalysisQueue): + def __init__(self) -> None: + import redis + + self._client = redis.from_url(settings.redis_url, decode_responses=True) + + def push(self, task_id: int) -> None: + self._client.lpush(settings.analysis_queue_key, str(task_id)) + + def pop(self, timeout: int = 5) -> int | None: + item = self._client.brpop(settings.analysis_queue_key, timeout=timeout) + if not item: + return None + return int(item[1]) + + +_queue: AnalysisQueue | None = None + + +def get_analysis_queue() -> AnalysisQueue: + global _queue + if _queue is not None: + return _queue + try: + _queue = RedisAnalysisQueue() + _queue._client.ping() + except Exception: + _queue = MemoryAnalysisQueue() + return _queue + + +def reset_analysis_queue_for_tests() -> None: + global _queue + _queue = MemoryAnalysisQueue() + _memory_queue.clear() diff --git a/apps/api/app/routers/__init__.py b/apps/api/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/routers/admin.py b/apps/api/app/routers/admin.py new file mode 100644 index 0000000..db34c0a --- /dev/null +++ b/apps/api/app/routers/admin.py @@ -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) diff --git a/apps/api/app/routers/analysis.py b/apps/api/app/routers/analysis.py new file mode 100644 index 0000000..78770b5 --- /dev/null +++ b/apps/api/app/routers/analysis.py @@ -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) diff --git a/apps/api/app/routers/auth.py b/apps/api/app/routers/auth.py new file mode 100644 index 0000000..b0e265c --- /dev/null +++ b/apps/api/app/routers/auth.py @@ -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, + ) diff --git a/apps/api/app/routers/children.py b/apps/api/app/routers/children.py new file mode 100644 index 0000000..5faf1f3 --- /dev/null +++ b/apps/api/app/routers/children.py @@ -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) diff --git a/apps/api/app/routers/reports.py b/apps/api/app/routers/reports.py new file mode 100644 index 0000000..2d536d0 --- /dev/null +++ b/apps/api/app/routers/reports.py @@ -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}"'}, + ) diff --git a/apps/api/app/routers/training.py b/apps/api/app/routers/training.py new file mode 100644 index 0000000..672b5ce --- /dev/null +++ b/apps/api/app/routers/training.py @@ -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, + ) diff --git a/apps/api/app/routers/videos.py b/apps/api/app/routers/videos.py new file mode 100644 index 0000000..9a14b37 --- /dev/null +++ b/apps/api/app/routers/videos.py @@ -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, + ) diff --git a/apps/api/app/schemas/__init__.py b/apps/api/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/schemas/models.py b/apps/api/app/schemas/models.py new file mode 100644 index 0000000..d4c9309 --- /dev/null +++ b/apps/api/app/schemas/models.py @@ -0,0 +1,165 @@ +from datetime import date, datetime +from enum import Enum +from typing import Any + +from pydantic import BaseModel, Field + + +class LoginType(str, Enum): + phone_code = "phone_code" + wechat = "wechat" + + +class UserRole(str, Enum): + parent = "parent" + coach = "coach" + org_admin = "org_admin" + platform_admin = "platform_admin" + + +class Gender(str, Enum): + male = "male" + female = "female" + unknown = "unknown" + + +class VideoScene(str, Enum): + front_posture = "front_posture" + side_posture = "side_posture" + squat = "squat" + balance = "balance" + gait = "gait" + + +class TaskType(str, Enum): + posture_screening = "posture_screening" + movement_scoring = "movement_scoring" + reassessment = "reassessment" + + +class TaskStatus(str, Enum): + CREATED = "CREATED" + QUEUED = "QUEUED" + PROCESSING = "PROCESSING" + SUCCEEDED = "SUCCEEDED" + FAILED = "FAILED" + CANCELLED = "CANCELLED" + + +class RiskLevel(str, Enum): + low = "low" + medium = "medium" + high = "high" + review_required = "review_required" + + +class PlanStatus(str, Enum): + draft = "draft" + active = "active" + completed = "completed" + paused = "paused" + + +class LoginRequest(BaseModel): + login_type: LoginType = Field(alias="loginType") + credential: str + code: str | None = None + + model_config = {"populate_by_name": True} + + +class User(BaseModel): + id: int + phone_masked: str = Field(alias="phoneMasked") + role: UserRole + + model_config = {"populate_by_name": True} + + +class ChildCreateRequest(BaseModel): + name: str + birthday: date + gender: Gender = Gender.unknown + height: float | None = None + weight: float | None = None + contraindications: str | None = None + + +class ChildUpdateRequest(ChildCreateRequest): + pass + + +class Child(BaseModel): + id: int + name: str + birthday: date + age: int + height: float | None = None + weight: float | None = None + status: str = "active" + + +class UploadTokenRequest(BaseModel): + child_id: int = Field(alias="childId") + file_name: str = Field(alias="fileName") + content_type: str = Field(alias="contentType") + size: int + + model_config = {"populate_by_name": True} + + +class VideoCreateRequest(BaseModel): + child_id: int = Field(alias="childId") + object_key: str = Field(alias="objectKey") + scene: VideoScene + capture_hint: str | None = Field(default=None, alias="captureHint") + + model_config = {"populate_by_name": True} + + +class AnalysisTaskCreateRequest(BaseModel): + child_id: int = Field(alias="childId") + video_id: int = Field(alias="videoId") + task_type: TaskType = Field(alias="taskType") + + model_config = {"populate_by_name": True} + + +class AnalysisTaskWebhook(BaseModel): + task_id: int = Field(alias="taskId") + status: TaskStatus + progress: int | None = None + error_code: str | None = Field(default=None, alias="errorCode") + result: dict[str, Any] | None = None + signature: str | None = None + + model_config = {"populate_by_name": True} + + +class TrainingPlanCreateRequest(BaseModel): + child_id: int = Field(alias="childId") + report_id: int | None = Field(default=None, alias="reportId") + goal: str + cycle_days: int = Field(alias="cycleDays", ge=7) + exercise_ids: list[int] | None = Field(default=None, alias="exerciseIds") + constraints: dict[str, Any] | None = None + + model_config = {"populate_by_name": True} + + +class TrainingRecordCreateRequest(BaseModel): + exercise_id: int = Field(alias="exerciseId") + completed: bool + score: int | None = Field(default=None, ge=0, le=100) + duration_seconds: int | None = Field(default=None, alias="durationSeconds") + note: str | None = None + media: list[str] | None = None + + model_config = {"populate_by_name": True} + + +class ReportMetric(BaseModel): + name: str + value: float + level: str + confidence: float diff --git a/apps/api/app/services/__init__.py b/apps/api/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/services/admin.py b/apps/api/app/services/admin.py new file mode 100644 index 0000000..60663a9 --- /dev/null +++ b/apps/api/app/services/admin.py @@ -0,0 +1,66 @@ +from datetime import datetime, timedelta, timezone + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.db.models import AnalysisTask, Child, Report, TrainingPlan, TrainingRecord, Video + + +def _tenant_child_ids_subquery(tenant_id: int): + return select(Child.id).where(Child.tenant_id == tenant_id).scalar_subquery() + + +def get_dashboard_metrics(db: Session, tenant_id: int | None = None) -> dict: + since = datetime.now(timezone.utc) - timedelta(days=30) + + child_q = select(func.count()).select_from(Child).where(Child.status == "active") + video_q = select(func.count()).select_from(Video) + report_q = select(func.count()).select_from(Report).where(Report.status == "published") + plan_q = select(func.count()).select_from(TrainingPlan).where(TrainingPlan.status == "active") + new_child_q = select(func.count()).select_from(Child).where(Child.created_at >= since) + succeeded_q = select(func.count()).select_from(AnalysisTask).where( + AnalysisTask.status == "SUCCEEDED" + ) + reassessment_q = select(func.count()).select_from(AnalysisTask).where( + AnalysisTask.task_type == "reassessment", AnalysisTask.status == "SUCCEEDED" + ) + pending_review_q = select(func.count()).select_from(Report).where(Report.reviewed_by.is_(None)) + pending_training_q = select(func.count()).select_from(TrainingRecord).where( + TrainingRecord.score.is_not(None) + ) + + if tenant_id: + tenant_children = _tenant_child_ids_subquery(tenant_id) + child_q = child_q.where(Child.tenant_id == tenant_id) + new_child_q = new_child_q.where(Child.tenant_id == tenant_id) + video_q = video_q.where(Video.child_id.in_(tenant_children)) + report_q = report_q.where(Report.child_id.in_(tenant_children)) + plan_q = plan_q.where(TrainingPlan.child_id.in_(tenant_children)) + succeeded_q = succeeded_q.where(AnalysisTask.child_id.in_(tenant_children)) + reassessment_q = reassessment_q.where(AnalysisTask.child_id.in_(tenant_children)) + pending_review_q = pending_review_q.where(Report.child_id.in_(tenant_children)) + pending_training_q = pending_training_q.where(TrainingRecord.child_id.in_(tenant_children)) + + new_children = db.scalar(new_child_q) or 0 + uploaded_videos = db.scalar(video_q) or 0 + completed_reports = db.scalar(report_q) or 0 + active_plans = db.scalar(plan_q) or 0 + succeeded_tasks = db.scalar(succeeded_q) or 0 + reassessment_done = db.scalar(reassessment_q) or 0 + + conversion_rate = round(completed_reports / max(succeeded_tasks, 1), 2) + reassessment_rate = round(reassessment_done / max(active_plans, 1), 2) + + pending_review = db.scalar(pending_review_q) or 0 + pending_training = db.scalar(pending_training_q) or 0 + + return { + "newChildren": new_children, + "uploadedVideos": uploaded_videos, + "completedReports": completed_reports, + "activePlans": active_plans, + "conversionRate": min(conversion_rate, 1.0), + "reassessmentCompletionRate": min(reassessment_rate, 1.0), + "pendingReviewReports": pending_review, + "pendingTrainingReview": pending_training, + } diff --git a/apps/api/app/services/analysis.py b/apps/api/app/services/analysis.py new file mode 100644 index 0000000..2a3912a --- /dev/null +++ b/apps/api/app/services/analysis.py @@ -0,0 +1,201 @@ +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.ai.pose_analyzer import analyze_task +from app.config import settings +from app.db.models import AnalysisTask, Report, Video +from app.mock_data import MOCK_MOVEMENT_REPORT, MOCK_REPORT +from app.queue.analysis_queue import get_analysis_queue +from app.schemas.models import AnalysisTaskCreateRequest, TaskStatus, TaskType +from app.services import oss as oss_service +from app.services.videos import verify_child_owner + +DISCLAIMER_SCREENING = "本报告用于健康管理建议,不构成医疗诊断。" +DISCLAIMER_MOVEMENT = "本报告用于运动训练反馈,不构成医疗诊断。" + + +def task_to_dict(task: AnalysisTask) -> dict: + report_id = None + if task.result and isinstance(task.result, dict): + report_id = task.result.get("reportId") + return { + "id": task.id, + "status": task.status, + "progress": task.progress, + "errorCode": task.error_code, + "reportId": report_id, + "retryCount": task.retry_count, + } + + +def build_screening_report_payload(child_id: int, task_id: int) -> dict: + data = {**MOCK_REPORT, "childId": child_id, "taskId": task_id} + return data + + +def build_movement_report_payload(child_id: int, task_id: int) -> dict: + return {**MOCK_MOVEMENT_REPORT, "childId": child_id, "taskId": task_id} + + +def create_report_for_task(db: Session, task: AnalysisTask, analysis: dict) -> Report: + report_data = analysis.get("report", {}) + if task.task_type == TaskType.movement_scoring.value: + payload = {**MOCK_MOVEMENT_REPORT, **report_data} + report = Report( + child_id=task.child_id, + task_id=task.id, + report_type="movement_scoring", + risk_level="medium", + summary="跟练打卡报告已生成", + metrics=[payload], + recommendations=["继续保持训练节奏", "28 天后安排复测"], + disclaimer=DISCLAIMER_MOVEMENT, + status="published", + ) + else: + payload = {**MOCK_REPORT, **report_data, "childId": task.child_id, "taskId": task.id} + report = Report( + child_id=task.child_id, + task_id=task.id, + report_type="posture_screening", + risk_level=payload.get("riskLevel", "medium"), + 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", + ) + db.add(report) + db.flush() + return report + + +def process_analysis_task(db: Session, task_id: int) -> AnalysisTask | None: + task = db.get(AnalysisTask, task_id) + if not task or task.status in (TaskStatus.SUCCEEDED.value, TaskStatus.CANCELLED.value): + return task + + task.status = TaskStatus.PROCESSING.value + task.progress = 35 + task.started_at = datetime.now(timezone.utc) + db.commit() + + video = db.get(Video, task.video_id) + video_path = None + if video: + video_path = oss_service.resolve_local_path(video.object_key) + + analysis = analyze_task(task.task_type, video_path) + task.model_version = analysis.get("modelVersion", "mock-v1") + task.progress = 80 + db.commit() + + report = create_report_for_task(db, task, analysis) + task.status = TaskStatus.SUCCEEDED.value + task.progress = 100 + task.finished_at = datetime.now(timezone.utc) + task.result = { + "reportId": report.id, + "engine": analysis.get("engine"), + "confidence": analysis.get("confidence"), + } + db.commit() + db.refresh(task) + return task + + +def enqueue_task(task_id: int, db: Session | None = None) -> None: + queue = get_analysis_queue() + queue.push(task_id) + if not settings.analysis_inline_process: + return + if db is not None: + process_analysis_task(db, task_id) + return + from app.db.session import SessionLocal + + local_db = SessionLocal() + try: + process_analysis_task(local_db, task_id) + finally: + local_db.close() + + +def create_analysis_task( + db: Session, + user_id: int, + body: AnalysisTaskCreateRequest, + idempotency_key: str | None, +) -> tuple[AnalysisTask | None, str | None, AnalysisTask | None]: + child = verify_child_owner(db, user_id, body.child_id) + if not child: + return None, "child_not_found", None + + video = db.get(Video, body.video_id) + if not video or video.child_id != body.child_id: + return None, "video_not_found", None + + if idempotency_key: + existing = db.scalar( + select(AnalysisTask).where( + AnalysisTask.child_id == body.child_id, + AnalysisTask.video_id == body.video_id, + AnalysisTask.task_type == body.task_type.value, + AnalysisTask.idempotency_key == idempotency_key, + ) + ) + if existing: + return existing, "duplicate", existing + + task = AnalysisTask( + child_id=body.child_id, + video_id=body.video_id, + task_type=body.task_type.value, + status=TaskStatus.QUEUED.value, + progress=0, + idempotency_key=idempotency_key, + ) + db.add(task) + db.commit() + db.refresh(task) + enqueue_task(task.id, db=db) + db.refresh(task) + return task, None, None + + +def get_task_for_user(db: Session, user_id: int, task_id: int) -> AnalysisTask | None: + task = db.get(AnalysisTask, task_id) + if not task: + return None + child = verify_child_owner(db, user_id, task.child_id) + if not child: + return None + return task + + +def cancel_task(db: Session, user_id: int, task_id: int) -> AnalysisTask | None: + task = get_task_for_user(db, user_id, task_id) + if not task: + return None + if task.status in (TaskStatus.SUCCEEDED.value, TaskStatus.CANCELLED.value): + return task + task.status = TaskStatus.CANCELLED.value + task.finished_at = datetime.now(timezone.utc) + db.commit() + db.refresh(task) + return task + + +def handle_webhook(db: Session, task_id: int, status: str) -> None: + task = db.get(AnalysisTask, task_id) + if not task: + return + if status == TaskStatus.SUCCEEDED.value: + process_analysis_task(db, task_id) + elif status == TaskStatus.FAILED.value: + task.status = TaskStatus.FAILED.value + task.error_code = "worker_failed" + task.finished_at = datetime.now(timezone.utc) + db.commit() diff --git a/apps/api/app/services/auth.py b/apps/api/app/services/auth.py new file mode 100644 index 0000000..f50f8a5 --- /dev/null +++ b/apps/api/app/services/auth.py @@ -0,0 +1,49 @@ +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.config import settings +from app.core.security import create_access_token, hash_phone, mask_phone +from app.db.models import Tenant, User +from app.schemas.models import LoginRequest, LoginType + + +def authenticate(db: Session, body: LoginRequest) -> tuple[User, str]: + if body.login_type == LoginType.phone_code: + if body.code != settings.demo_sms_code: + raise ValueError("invalid_code") + phone = body.credential + phone_hash = hash_phone(phone) + user = db.scalar(select(User).where(User.phone_hash == phone_hash)) + if not user: + 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() + user = User( + tenant_id=tenant.id, + phone=phone, + phone_hash=phone_hash, + role="parent", + status="active", + consent_signed=True, + ) + db.add(user) + db.flush() + user.last_login_at = datetime.now(timezone.utc) + db.commit() + db.refresh(user) + token = create_access_token(user.id, user.role) + return user, token + + raise ValueError("unsupported_login_type") + + +def user_to_dict(user: User) -> dict: + return { + "id": user.id, + "phoneMasked": mask_phone(user.phone or ""), + "role": user.role, + } diff --git a/apps/api/app/services/children.py b/apps/api/app/services/children.py new file mode 100644 index 0000000..3aee3e2 --- /dev/null +++ b/apps/api/app/services/children.py @@ -0,0 +1,88 @@ +from datetime import date + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.db.models import Child +from app.schemas.models import ChildCreateRequest, ChildUpdateRequest + + +def calc_age(birthday: date) -> int: + today = date.today() + age = today.year - birthday.year + if (today.month, today.day) < (birthday.month, birthday.day): + age -= 1 + return age + + +def child_to_dict(child: Child) -> dict: + return { + "id": child.id, + "name": child.name, + "birthday": child.birthday.isoformat(), + "age": calc_age(child.birthday), + "height": float(child.height) if child.height is not None else None, + "weight": float(child.weight) if child.weight is not None else None, + "status": child.status, + } + + +def list_children(db: Session, parent_user_id: int, page: int, page_size: int) -> tuple[list[Child], int]: + filters = (Child.parent_user_id == parent_user_id, Child.status != "archived") + total = db.scalar(select(func.count()).select_from(Child).where(*filters)) or 0 + items = db.scalars( + select(Child) + .where(*filters) + .order_by(Child.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ).all() + return items, total + + +def get_child(db: Session, parent_user_id: int, child_id: int) -> Child | None: + return db.scalar( + select(Child).where( + Child.id == child_id, + Child.parent_user_id == parent_user_id, + Child.status != "archived", + ) + ) + + +def create_child(db: Session, parent_user_id: int, tenant_id: int | None, body: ChildCreateRequest) -> Child: + child = Child( + parent_user_id=parent_user_id, + tenant_id=tenant_id, + name=body.name, + birthday=body.birthday, + gender=body.gender.value, + height=body.height, + weight=body.weight, + contraindications=body.contraindications, + status="active", + ) + db.add(child) + db.commit() + db.refresh(child) + return child + + +def update_child(db: Session, child: Child, body: ChildUpdateRequest) -> Child: + child.name = body.name + child.birthday = body.birthday + child.gender = body.gender.value + child.height = body.height + child.weight = body.weight + child.contraindications = body.contraindications + db.commit() + db.refresh(child) + return child + + +def archive_child(db: Session, child: Child) -> None: + from datetime import datetime, timezone + + child.status = "archived" + child.deleted_at = datetime.now(timezone.utc) + db.commit() diff --git a/apps/api/app/services/oss.py b/apps/api/app/services/oss.py new file mode 100644 index 0000000..fbf7ba1 --- /dev/null +++ b/apps/api/app/services/oss.py @@ -0,0 +1,134 @@ +import secrets +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from app.config import settings + +_upload_tokens: dict[str, dict] = {} + + +def _upload_root() -> Path: + root = Path(settings.upload_local_dir) + root.mkdir(parents=True, exist_ok=True) + return root + + +def generate_object_key(child_id: int, file_name: str) -> str: + safe_name = file_name.replace("..", "").replace("/", "_") + return f"videos/{child_id}/{safe_name}" + + +def _cleanup_expired_tokens() -> None: + now = datetime.now(timezone.utc) + expired = [k for k, v in _upload_tokens.items() if v["expire_at"] < now] + for key in expired: + _upload_tokens.pop(key, None) + + +def create_upload_token(object_key: str, content_type: str, size: int) -> dict: + _cleanup_expired_tokens() + expire_at = datetime.now(timezone.utc) + timedelta(minutes=settings.upload_token_expire_minutes) + token = secrets.token_urlsafe(32) + + if settings.oss_enabled: + upload_url, method = _create_s3_presigned_url(object_key, content_type, expire_at) + extra = { + "storage": settings.oss_provider, + "cdnBaseUrl": settings.oss_cdn_base_url or None, + } + else: + upload_url = f"{settings.api_public_url.rstrip('/')}/api/videos/direct-upload" + method = "PUT" + extra = {"storage": "local", "uploadToken": token} + + _upload_tokens[token] = { + "object_key": object_key, + "content_type": content_type, + "size": size, + "expire_at": expire_at, + } + + return { + "uploadUrl": upload_url, + "objectKey": object_key, + "expireAt": expire_at.replace(microsecond=0).isoformat(), + "method": method, + **extra, + "uploadToken": token, + } + + +def _create_s3_presigned_url(object_key: str, content_type: str, expire_at: datetime) -> tuple[str, str]: + try: + import boto3 + from botocore.client import Config + except ImportError as exc: + raise RuntimeError("boto3 required for OSS mode") from exc + + client = boto3.client( + "s3", + endpoint_url=settings.oss_endpoint, + aws_access_key_id=settings.oss_access_key, + aws_secret_access_key=settings.oss_secret_key, + config=Config(signature_version="s3v4"), + region_name=settings.oss_region, + ) + _ensure_bucket(client) + expires_in = max(60, int((expire_at - datetime.now(timezone.utc)).total_seconds())) + url = client.generate_presigned_url( + "put_object", + Params={ + "Bucket": settings.oss_bucket, + "Key": object_key, + "ContentType": content_type, + }, + ExpiresIn=expires_in, + ) + return url, "PUT" + + +def _ensure_bucket(client) -> None: + if settings.oss_provider != "minio": + return + try: + client.head_bucket(Bucket=settings.oss_bucket) + except Exception: + client.create_bucket(Bucket=settings.oss_bucket) + + +def public_object_url(object_key: str) -> str | None: + if settings.oss_cdn_base_url: + return f"{settings.oss_cdn_base_url.rstrip('/')}/{object_key}" + if settings.oss_enabled and settings.oss_provider == "aliyun": + endpoint = settings.oss_endpoint.rstrip("/") + return f"{endpoint}/{settings.oss_bucket}/{object_key}" + return None + + +def validate_upload_token(token: str, object_key: str) -> bool: + _cleanup_expired_tokens() + meta = _upload_tokens.get(token) + if not meta or meta["object_key"] != object_key: + return False + if meta["expire_at"] < datetime.now(timezone.utc): + _upload_tokens.pop(token, None) + return False + return True + + +def consume_upload_token(token: str, object_key: str) -> None: + meta = _upload_tokens.pop(token, None) + if not meta or meta["object_key"] != object_key: + raise ValueError("invalid_upload_token") + + +def save_local_upload(object_key: str, data: bytes) -> Path: + path = _upload_root() / object_key + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return path + + +def resolve_local_path(object_key: str) -> Path | None: + path = _upload_root() / object_key + return path if path.exists() else None diff --git a/apps/api/app/services/pdf_export.py b/apps/api/app/services/pdf_export.py new file mode 100644 index 0000000..7a7341a --- /dev/null +++ b/apps/api/app/services/pdf_export.py @@ -0,0 +1,143 @@ +"""Server-side PDF export for posture reports.""" + +from __future__ import annotations + +from io import BytesIO + +from reportlab.lib import colors +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import mm +from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle + +from app.db.models import Child, Report +from app.services.reports import METRIC_LEVEL_LABELS, RISK_LABELS, report_to_dict + +LEVEL_COLORS = { + "normal": colors.HexColor("#2bb673"), + "low": colors.HexColor("#84cc16"), + "medium": colors.HexColor("#f59e0b"), + "high": colors.HexColor("#ef4444"), +} + + +def build_report_pdf(report: Report, child: Child | None = None) -> bytes: + payload = report_to_dict(report) + buffer = BytesIO() + doc = SimpleDocTemplate( + buffer, + pagesize=A4, + leftMargin=18 * mm, + rightMargin=18 * mm, + topMargin=16 * mm, + bottomMargin=16 * mm, + title=f"Happy Up Report #{report.id}", + ) + + styles = getSampleStyleSheet() + title_style = ParagraphStyle( + "ReportTitle", + parent=styles["Heading1"], + fontName="Helvetica-Bold", + fontSize=18, + textColor=colors.HexColor("#1a6fb5"), + spaceAfter=8, + ) + body = styles["BodyText"] + muted = ParagraphStyle("Muted", parent=body, textColor=colors.HexColor("#6b7280"), fontSize=9) + + child_name = child.name if child else f"儿童 #{report.child_id}" + story = [ + Paragraph("儿童 AI 体态管理 · 筛查报告", title_style), + Paragraph(f"儿童:{child_name} · 报告编号 #{report.id}", muted), + Spacer(1, 8), + ] + + if report.report_type == "movement_scoring": + story.extend(_movement_sections(payload, body, muted)) + else: + story.extend(_screening_sections(payload, body, muted)) + + story.append(Spacer(1, 10)) + story.append(Paragraph(payload.get("disclaimer", ""), muted)) + + doc.build(story) + return buffer.getvalue() + + +def _screening_sections(payload: dict, body, muted) -> list: + risk = payload.get("riskLevel", "medium") + risk_label = RISK_LABELS.get(risk, risk) + sections = [ + Paragraph(f"风险等级:{risk_label}", body), + Paragraph(f"摘要:{payload.get('summary', '')}", body), + Spacer(1, 8), + Paragraph("指标明细", body), + ] + + rows = [["指标", "数值", "等级", "置信度"]] + for metric in payload.get("metrics", []): + rows.append( + [ + metric.get("name", ""), + f"{metric.get('value', 0)}%", + METRIC_LEVEL_LABELS.get(metric.get("level", ""), metric.get("level", "")), + f"{metric.get('confidence', 0):.0%}", + ] + ) + + table = Table(rows, colWidths=[80, 60, 60, 60]) + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#eef2f7")), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#1f2937")), + ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e5e7eb")), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#fafafa")]), + ] + ) + ) + sections.append(table) + sections.append(Spacer(1, 8)) + sections.append(Paragraph("建议", body)) + for idx, item in enumerate(payload.get("recommendations", []), start=1): + sections.append(Paragraph(f"{idx}. {item}", body)) + return sections + + +def _movement_sections(payload: dict, body, muted) -> list: + score = payload.get("score", 0) + sections = [ + Paragraph(f"跟练得分:{score}", body), + Paragraph( + f"完成 {payload.get('repsCompleted', 0)} 组 · " + f"时长 {payload.get('durationSeconds', 0)} 秒", + muted, + ), + Spacer(1, 8), + Paragraph("维度评分", body), + ] + dims = payload.get("dimensions", {}) + rows = [["维度", "得分"]] + for key, label in ( + ("trajectory", "轨迹"), + ("angle", "角度"), + ("rhythm", "节奏"), + ("stability", "稳定性"), + ("completion", "完成度"), + ): + rows.append([label, str(dims.get(key, "-"))]) + + table = Table(rows, colWidths=[120, 80]) + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#eef2f7")), + ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e5e7eb")), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ] + ) + ) + sections.append(table) + return sections diff --git a/apps/api/app/services/reports.py b/apps/api/app/services/reports.py new file mode 100644 index 0000000..5b7eb90 --- /dev/null +++ b/apps/api/app/services/reports.py @@ -0,0 +1,65 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.db.models import Report +from app.mock_data import MOCK_MOVEMENT_REPORT +from app.services.videos import verify_child_owner + + +RISK_LABELS = { + "low": "正常", + "medium": "中度关注", + "high": "高度关注", + "review_required": "建议复核", +} + +METRIC_LEVEL_LABELS = { + "medium": "中度", + "low": "轻度", + "normal": "正常", + "high": "重度", +} + + +def report_to_dict(report: Report) -> dict: + if report.report_type == "movement_scoring" and report.metrics: + stored = report.metrics[0] if isinstance(report.metrics, list) else report.metrics + if isinstance(stored, dict) and "score" in stored: + return stored + return {**MOCK_MOVEMENT_REPORT, "id": report.id, "childId": report.child_id, "taskId": report.task_id} + + return { + "id": report.id, + "childId": report.child_id, + "taskId": report.task_id, + "riskLevel": report.risk_level, + "summary": report.summary, + "metrics": report.metrics, + "recommendations": report.recommendations, + "disclaimer": report.disclaimer, + "reviewedBy": report.reviewed_by, + } + + +def get_report_for_user(db: Session, user_id: int, report_id: int) -> Report | None: + report = db.get(Report, report_id) + if not report: + return None + if not verify_child_owner(db, user_id, report.child_id): + return None + return report + + +def list_reports_for_child( + db: Session, user_id: int, child_id: int, limit: int = 10 +) -> list[Report] | None: + if not verify_child_owner(db, user_id, child_id): + return None + return list( + db.scalars( + select(Report) + .where(Report.child_id == child_id) + .order_by(Report.id.desc()) + .limit(limit) + ).all() + ) diff --git a/apps/api/app/services/training.py b/apps/api/app/services/training.py new file mode 100644 index 0000000..816dbbe --- /dev/null +++ b/apps/api/app/services/training.py @@ -0,0 +1,148 @@ +from datetime import datetime, timezone + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.db.models import Exercise, TrainingPlan, TrainingRecord +from app.schemas.models import TrainingPlanCreateRequest, TrainingRecordCreateRequest +from app.services.videos import verify_child_owner + +DEFAULT_EXERCISES = [ + {"id": 1, "name": "肩胛稳定训练", "durationMinutes": 5}, + {"id": 2, "name": "颈后肌群拉伸", "sets": 3}, +] + + +def plan_to_dict(plan: TrainingPlan) -> dict: + detail = plan.plan_detail or {} + records_count = detail.get("completedDays", 0) + current_day = min(plan.cycle_days, records_count + 1) + return { + "id": plan.id, + "status": plan.status, + "detail": { + **detail, + "goal": plan.goal, + "cycleDays": plan.cycle_days, + "currentDay": current_day, + "childId": plan.child_id, + "reportId": plan.report_id, + }, + "startedAt": plan.started_at.isoformat() if plan.started_at else None, + "endedAt": plan.ended_at.isoformat() if plan.ended_at else None, + } + + +def build_plan_detail(body: TrainingPlanCreateRequest) -> dict: + exercises = DEFAULT_EXERCISES + if body.exercise_ids: + exercises = [{"id": eid, "name": f"动作 #{eid}"} for eid in body.exercise_ids] + return { + "goal": body.goal, + "cycleDays": body.cycle_days, + "currentDay": 1, + "completedDays": 0, + "exercises": exercises, + "constraints": body.constraints or {}, + } + + +def get_plan_for_user(db: Session, user_id: int, plan_id: int) -> TrainingPlan | None: + plan = db.get(TrainingPlan, plan_id) + if not plan or plan.status == "cancelled": + return None + if not verify_child_owner(db, user_id, plan.child_id): + return None + return plan + + +def get_active_plan_for_child(db: Session, user_id: int, child_id: int) -> TrainingPlan | None: + if not verify_child_owner(db, user_id, child_id): + return None + return db.scalar( + select(TrainingPlan) + .where(TrainingPlan.child_id == child_id, TrainingPlan.status == "active") + .order_by(TrainingPlan.id.desc()) + ) + + +def list_plans_for_child(db: Session, user_id: int, child_id: int) -> list[TrainingPlan] | None: + if not verify_child_owner(db, user_id, child_id): + return None + return list( + db.scalars( + select(TrainingPlan) + .where(TrainingPlan.child_id == child_id, TrainingPlan.status != "cancelled") + .order_by(TrainingPlan.id.desc()) + .limit(10) + ).all() + ) + + +def create_plan(db: Session, user_id: int, body: TrainingPlanCreateRequest) -> TrainingPlan | None: + if not verify_child_owner(db, user_id, body.child_id): + return None + now = datetime.now(timezone.utc) + plan = TrainingPlan( + child_id=body.child_id, + report_id=body.report_id, + goal=body.goal, + cycle_days=body.cycle_days, + status="active", + plan_detail=build_plan_detail(body), + started_at=now, + ) + db.add(plan) + db.commit() + db.refresh(plan) + return plan + + +def create_record( + db: Session, user_id: int, plan_id: int, body: TrainingRecordCreateRequest +) -> TrainingRecord | None: + plan = get_plan_for_user(db, user_id, plan_id) + if not plan: + return None + + feedback = {"media": body.media} if body.media else None + record = TrainingRecord( + plan_id=plan_id, + child_id=plan.child_id, + exercise_id=body.exercise_id, + completed=body.completed, + score=body.score, + duration_seconds=body.duration_seconds, + feedback=feedback, + note=body.note, + ) + db.add(record) + + detail = dict(plan.plan_detail or {}) + detail["completedDays"] = detail.get("completedDays", 0) + (1 if body.completed else 0) + detail["lastScore"] = body.score + detail["lastRecordId"] = None + plan.plan_detail = detail + + db.commit() + db.refresh(record) + detail["lastRecordId"] = record.id + plan.plan_detail = detail + db.commit() + return record + + +def ensure_default_exercises(db: Session) -> None: + existing = db.scalar(select(func.count()).select_from(Exercise)) + if existing: + return + for item in DEFAULT_EXERCISES: + db.add( + Exercise( + name=item["name"], + category="posture", + target_issue="头前伸/高低肩", + duration_seconds=(item.get("durationMinutes", 3) * 60), + ) + ) + db.commit() diff --git a/apps/api/app/services/videos.py b/apps/api/app/services/videos.py new file mode 100644 index 0000000..459b203 --- /dev/null +++ b/apps/api/app/services/videos.py @@ -0,0 +1,53 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.db.models import Child +from app.db.models import Video as VideoModel +from app.schemas.models import UploadTokenRequest, VideoCreateRequest +from app.services import oss as oss_service + + +def verify_child_owner(db: Session, parent_user_id: int, child_id: int) -> Child | None: + return db.scalar( + select(Child).where( + Child.id == child_id, + Child.parent_user_id == parent_user_id, + Child.status != "archived", + ) + ) + + +def create_upload_token(body: UploadTokenRequest) -> dict: + object_key = oss_service.generate_object_key(body.child_id, body.file_name) + return oss_service.create_upload_token(object_key, body.content_type, body.size) + + +def save_direct_upload(token: str, object_key: str, data: bytes) -> None: + if not oss_service.validate_upload_token(token, object_key): + raise ValueError("invalid_upload_token") + oss_service.save_local_upload(object_key, data) + oss_service.consume_upload_token(token, object_key) + + +def register_video( + db: Session, user_id: int, body: VideoCreateRequest +) -> tuple[VideoModel | None, str | None]: + child = verify_child_owner(db, user_id, body.child_id) + if not child: + return None, "child_not_found" + + existing = db.scalar(select(VideoModel).where(VideoModel.object_key == body.object_key)) + if existing: + return existing, "duplicate" + + video = VideoModel( + child_id=body.child_id, + uploaded_by=user_id, + scene=body.scene.value, + object_key=body.object_key, + status="uploaded", + ) + db.add(video) + db.commit() + db.refresh(video) + return video, None diff --git a/apps/api/app/worker.py b/apps/api/app/worker.py new file mode 100644 index 0000000..80ba430 --- /dev/null +++ b/apps/api/app/worker.py @@ -0,0 +1,29 @@ +"""Analysis task worker — polls Redis queue and processes tasks.""" + +import time + +from app.db.session import SessionLocal +from app.queue.analysis_queue import get_analysis_queue +from app.services.analysis import process_analysis_task + + +def run_worker(poll_timeout: int = 5) -> None: + queue = get_analysis_queue() + print("Analysis worker started · waiting for tasks…") + while True: + task_id = queue.pop(timeout=poll_timeout) + if task_id is None: + continue + db = SessionLocal() + try: + task = process_analysis_task(db, task_id) + if task: + print(f"Processed task #{task_id} -> {task.status}") + except Exception as exc: + print(f"Failed task #{task_id}: {exc}") + finally: + db.close() + + +if __name__ == "__main__": + run_worker() diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml new file mode 100644 index 0000000..ca37b81 --- /dev/null +++ b/apps/api/pyproject.toml @@ -0,0 +1,45 @@ +[project] +name = "happy-up-api" +version = "0.1.0" +description = "Kids AI Posture Platform API starter" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.32.0", + "pydantic>=2.9.0", + "pydantic-settings>=2.6.0", + "sqlalchemy>=2.0.36", + "pymysql>=1.1.1", + "python-multipart>=0.0.12", + "httpx>=0.27.0", + "PyJWT>=2.9.0", + "alembic>=1.14.0", + "redis>=5.2.0", + "boto3>=1.35.0", + "reportlab>=4.2.0", +] + +[project.optional-dependencies] +ai = [ + "mediapipe>=0.10.14", + "opencv-python-headless>=4.10.0", +] +dev = [ + "pytest>=8.3.0", + "pytest-asyncio>=0.24.0", +] + +[build-system] +requires = ["setuptools>=75.0.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["app"] + +[tool.setuptools.package-dir] +"" = "." + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/apps/api/tests/conftest.py b/apps/api/tests/conftest.py new file mode 100644 index 0000000..c8c11a9 --- /dev/null +++ b/apps/api/tests/conftest.py @@ -0,0 +1,46 @@ +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.db import models # noqa: F401 +from app.db.session import Base, get_db +from app.main import app +from app.queue.analysis_queue import reset_analysis_queue_for_tests + + +@pytest.fixture() +def client(): + reset_analysis_queue_for_tests() + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) + + def override_get_db(): + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = override_get_db + with TestClient(app) as test_client: + yield test_client + app.dependency_overrides.clear() + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture() +def auth_headers(client): + resp = client.post( + "/api/auth/login", + json={"loginType": "phone_code", "credential": "18600000000", "code": "682139"}, + ) + assert resp.status_code == 200, resp.text + token = resp.json()["data"]["token"] + return {"Authorization": f"Bearer {token}"} diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py new file mode 100644 index 0000000..79ccf53 --- /dev/null +++ b/apps/api/tests/test_api.py @@ -0,0 +1,69 @@ +from fastapi.testclient import TestClient + + +def test_health(client): + resp = client.get("/health") + assert resp.status_code == 200 + assert resp.json()["stage"] == "sprint5" + + +def test_login_invalid_code(client): + resp = client.post( + "/api/auth/login", + json={"loginType": "phone_code", "credential": "18600000000", "code": "000000"}, + ) + assert resp.status_code == 401 + assert resp.json()["code"] == 10002 + + +def test_login_and_children_crud(client, auth_headers): + resp = client.get("/api/children", headers=auth_headers) + assert resp.status_code == 200 + assert resp.json()["data"]["page"]["total"] == 0 + + create = client.post( + "/api/children", + headers={**auth_headers, "Idempotency-Key": "demo-child-1"}, + json={ + "name": "小明", + "birthday": "2016-03-15", + "gender": "male", + "height": 142.5, + "weight": 36.0, + }, + ) + assert create.status_code == 201 + child_id = create.json()["data"]["id"] + assert create.json()["data"]["age"] >= 9 + + detail = client.get(f"/api/children/{child_id}", headers=auth_headers) + assert detail.status_code == 200 + assert detail.json()["data"]["name"] == "小明" + + updated = client.patch( + f"/api/children/{child_id}", + headers=auth_headers, + json={ + "name": "小明同学", + "birthday": "2016-03-15", + "gender": "male", + }, + ) + assert updated.status_code == 200 + assert updated.json()["data"]["name"] == "小明同学" + + archived = client.delete(f"/api/children/{child_id}", headers=auth_headers) + assert archived.status_code == 204 + + missing = client.get(f"/api/children/{child_id}", headers=auth_headers) + assert missing.status_code == 404 + + +def test_children_requires_auth(client): + resp = client.get("/api/children") + assert resp.status_code == 401 + + +def test_reports_requires_auth(client): + resp = client.get("/api/reports/1") + assert resp.status_code == 401 diff --git a/apps/api/tests/test_sprint2.py b/apps/api/tests/test_sprint2.py new file mode 100644 index 0000000..fdf2d05 --- /dev/null +++ b/apps/api/tests/test_sprint2.py @@ -0,0 +1,94 @@ +def test_screening_pipeline(client, auth_headers): + child = client.post( + "/api/children", + headers=auth_headers, + json={"name": "小红", "birthday": "2017-05-01", "gender": "female"}, + ).json()["data"] + child_id = child["id"] + + token_resp = client.post( + "/api/videos/upload-token", + headers=auth_headers, + json={ + "childId": child_id, + "fileName": "front.mp4", + "contentType": "video/mp4", + "size": 1024000, + }, + ) + assert token_resp.status_code == 200 + object_key = token_resp.json()["data"]["objectKey"] + + video_resp = client.post( + "/api/videos", + headers=auth_headers, + json={ + "childId": child_id, + "objectKey": object_key, + "scene": "front_posture", + }, + ) + assert video_resp.status_code == 201 + video_id = video_resp.json()["data"]["id"] + + task_resp = client.post( + "/api/analysis/tasks", + headers={**auth_headers, "Idempotency-Key": "screening-task-1"}, + json={ + "childId": child_id, + "videoId": video_id, + "taskType": "posture_screening", + }, + ) + assert task_resp.status_code == 201 + task = task_resp.json()["data"] + assert task["status"] == "SUCCEEDED" + assert task["reportId"] + + dup_resp = client.post( + "/api/analysis/tasks", + headers={**auth_headers, "Idempotency-Key": "screening-task-1"}, + json={ + "childId": child_id, + "videoId": video_id, + "taskType": "posture_screening", + }, + ) + assert dup_resp.status_code == 409 + + report_resp = client.get( + f"/api/reports/{task['reportId']}", + headers=auth_headers, + ) + assert report_resp.status_code == 200 + report = report_resp.json()["data"] + assert report["riskLevel"] == "medium" + assert len(report["metrics"]) >= 3 + + +def test_movement_report_pipeline(client, auth_headers): + child_id = client.post( + "/api/children", + headers=auth_headers, + json={"name": "小刚", "birthday": "2015-08-12", "gender": "male"}, + ).json()["data"]["id"] + + object_key = f"videos/{child_id}/live.mp4" + video_id = client.post( + "/api/videos", + headers=auth_headers, + json={"childId": child_id, "objectKey": object_key, "scene": "side_posture"}, + ).json()["data"]["id"] + + task = client.post( + "/api/analysis/tasks", + headers=auth_headers, + json={ + "childId": child_id, + "videoId": video_id, + "taskType": "movement_scoring", + }, + ).json()["data"] + + report = client.get(f"/api/reports/{task['reportId']}", headers=auth_headers).json()["data"] + assert report["score"] == 81 diff --git a/apps/api/tests/test_sprint3.py b/apps/api/tests/test_sprint3.py new file mode 100644 index 0000000..e2db5c3 --- /dev/null +++ b/apps/api/tests/test_sprint3.py @@ -0,0 +1,54 @@ +def test_training_plan_and_record(client, auth_headers): + child_id = client.post( + "/api/children", + headers=auth_headers, + json={"name": "训练测试", "birthday": "2016-01-01", "gender": "male"}, + ).json()["data"]["id"] + + plan_resp = client.post( + "/api/training/plans", + headers=auth_headers, + json={ + "childId": child_id, + "goal": "改善头前伸", + "cycleDays": 28, + }, + ) + assert plan_resp.status_code == 201 + plan = plan_resp.json()["data"] + plan_id = plan["id"] + assert plan["status"] == "active" + assert plan["detail"]["cycleDays"] == 28 + + detail = client.get(f"/api/training/plans/{plan_id}", headers=auth_headers) + assert detail.status_code == 200 + + record_resp = client.post( + f"/api/training/plans/{plan_id}/records", + headers=auth_headers, + json={ + "exerciseId": 1, + "completed": True, + "score": 81, + "durationSeconds": 272, + }, + ) + assert record_resp.status_code == 201 + assert record_resp.json()["data"]["score"] == 81 + + updated = client.get(f"/api/training/plans/{plan_id}", headers=auth_headers).json()["data"] + assert updated["detail"]["completedDays"] == 1 + + +def test_admin_dashboard(client, auth_headers): + client.post( + "/api/children", + headers=auth_headers, + json={"name": "看板测试", "birthday": "2015-06-01", "gender": "female"}, + ) + dash = client.get("/api/admin/dashboard", headers=auth_headers) + assert dash.status_code == 200 + data = dash.json()["data"] + assert "newChildren" in data + assert "activePlans" in data + assert data["newChildren"] >= 1 diff --git a/apps/api/tests/test_sprint4.py b/apps/api/tests/test_sprint4.py new file mode 100644 index 0000000..fa1548c --- /dev/null +++ b/apps/api/tests/test_sprint4.py @@ -0,0 +1,85 @@ +def test_local_direct_upload(client, auth_headers): + child_id = client.post( + "/api/children", + headers=auth_headers, + json={"name": "小直传", "birthday": "2016-03-10", "gender": "female"}, + ).json()["data"]["id"] + + token_resp = client.post( + "/api/videos/upload-token", + headers=auth_headers, + json={ + "childId": child_id, + "fileName": "direct.mp4", + "contentType": "video/mp4", + "size": 128, + }, + ) + assert token_resp.status_code == 200 + payload = token_resp.json()["data"] + assert payload["storage"] == "local" + assert payload["method"] == "PUT" + assert payload["uploadToken"] + + put_resp = client.put( + "/api/videos/direct-upload", + headers={ + "X-Upload-Token": payload["uploadToken"], + "X-Object-Key": payload["objectKey"], + "Content-Type": "video/mp4", + }, + content=b"\x00\x00\x00\x18ftypmp42", + ) + assert put_resp.status_code == 204 + + bad_put = client.put( + "/api/videos/direct-upload", + headers={ + "X-Upload-Token": "invalid-token", + "X-Object-Key": payload["objectKey"], + }, + content=b"bad", + ) + assert bad_put.status_code == 403 + + video_resp = client.post( + "/api/videos", + headers=auth_headers, + json={ + "childId": child_id, + "objectKey": payload["objectKey"], + "scene": "front_posture", + }, + ) + assert video_resp.status_code == 201 + video_id = video_resp.json()["data"]["id"] + + task_resp = client.post( + "/api/analysis/tasks", + headers={**auth_headers, "Idempotency-Key": "sprint4-direct-upload"}, + json={ + "childId": child_id, + "videoId": video_id, + "taskType": "posture_screening", + }, + ) + assert task_resp.status_code == 201 + task = task_resp.json()["data"] + assert task["status"] == "SUCCEEDED" + assert task["reportId"] + + report_resp = client.get(f"/api/reports/{task['reportId']}", headers=auth_headers) + assert report_resp.status_code == 200 + + +def test_pose_analyzer_mock(): + from pathlib import Path + + from app.ai.pose_analyzer import analyze_task + + out = analyze_task("posture_screening", None) + assert out["engine"] == "mock" + assert out["report"] + + missing = analyze_task("posture_screening", Path("/tmp/does-not-exist.mp4")) + assert missing["engine"] == "mock" diff --git a/apps/api/tests/test_sprint5.py b/apps/api/tests/test_sprint5.py new file mode 100644 index 0000000..9d6e546 --- /dev/null +++ b/apps/api/tests/test_sprint5.py @@ -0,0 +1,85 @@ +def test_pose_metrics_screening(): + from app.ai.pose_metrics import LEFT_HIP, LEFT_SHOULDER, NOSE, RIGHT_HIP, RIGHT_SHOULDER, build_screening_report + + good_frame = { + NOSE: {"x": 0.5, "y": 0.25, "visibility": 0.95}, + LEFT_SHOULDER: {"x": 0.42, "y": 0.35, "visibility": 0.95}, + RIGHT_SHOULDER: {"x": 0.58, "y": 0.35, "visibility": 0.95}, + LEFT_HIP: {"x": 0.44, "y": 0.55, "visibility": 0.95}, + RIGHT_HIP: {"x": 0.56, "y": 0.55, "visibility": 0.95}, + } + bad_frame = { + NOSE: {"x": 0.72, "y": 0.28, "visibility": 0.95}, + LEFT_SHOULDER: {"x": 0.42, "y": 0.35, "visibility": 0.95}, + RIGHT_SHOULDER: {"x": 0.58, "y": 0.42, "visibility": 0.95}, + LEFT_HIP: {"x": 0.44, "y": 0.58, "visibility": 0.95}, + RIGHT_HIP: {"x": 0.56, "y": 0.52, "visibility": 0.95}, + } + + report = build_screening_report([good_frame] * 4 + [bad_frame] * 4) + assert report["riskLevel"] in ("low", "medium", "high") + assert len(report["metrics"]) >= 2 + assert report["summary"] + + +def test_pose_metrics_movement(): + from app.ai.pose_metrics import LEFT_SHOULDER, NOSE, RIGHT_SHOULDER, build_movement_report + + frame = { + NOSE: {"x": 0.5, "y": 0.25, "visibility": 0.95}, + LEFT_SHOULDER: {"x": 0.42, "y": 0.35, "visibility": 0.95}, + RIGHT_SHOULDER: {"x": 0.58, "y": 0.35, "visibility": 0.95}, + } + report = build_movement_report([frame] * 8) + assert 50 <= report["score"] <= 100 + assert report["dimensions"]["stability"] >= 50 + + +def test_report_list_and_pdf(client, auth_headers): + child_id = client.post( + "/api/children", + headers=auth_headers, + json={"name": "PDF测试", "birthday": "2016-01-01", "gender": "male"}, + ).json()["data"]["id"] + + object_key = f"videos/{child_id}/pdf-test.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": "sprint5-pdf"}, + json={"childId": child_id, "videoId": video_id, "taskType": "posture_screening"}, + ).json()["data"] + assert task["reportId"] + + list_resp = client.get(f"/api/reports?childId={child_id}", headers=auth_headers) + assert list_resp.status_code == 200 + assert any(item["id"] == task["reportId"] for item in list_resp.json()["data"]["list"]) + + pdf_resp = client.get(f"/api/reports/{task['reportId']}/pdf", headers=auth_headers) + assert pdf_resp.status_code == 200 + assert pdf_resp.headers["content-type"] == "application/pdf" + assert pdf_resp.content[:4] == b"%PDF" + + +def test_oss_public_url(monkeypatch): + from app.config import settings + from app.services import oss as oss_service + + monkeypatch.setattr(settings, "oss_enabled", True) + monkeypatch.setattr(settings, "oss_provider", "aliyun") + monkeypatch.setattr(settings, "oss_endpoint", "https://oss-cn-hangzhou.aliyuncs.com") + monkeypatch.setattr(settings, "oss_bucket", "happy-up-prod") + monkeypatch.setattr(settings, "oss_cdn_base_url", "https://cdn.example.com") + + assert oss_service.public_object_url("videos/1/a.mp4") == "https://cdn.example.com/videos/1/a.mp4" + + monkeypatch.setattr(settings, "oss_cdn_base_url", "") + assert ( + oss_service.public_object_url("videos/1/a.mp4") + == "https://oss-cn-hangzhou.aliyuncs.com/happy-up-prod/videos/1/a.mp4" + ) diff --git a/apps/mini/README.md b/apps/mini/README.md new file mode 100644 index 0000000..221efec --- /dev/null +++ b/apps/mini/README.md @@ -0,0 +1,20 @@ +# Happy Up 微信小程序壳 + +Sprint 5 最小可用壳,复用 FastAPI 后端,与 H5 共用同一套 API。 + +## 开发 + +1. 启动 API:`make api-dev` +2. 微信开发者工具导入本目录 `apps/mini` +3. 详情 → 本地设置 → 勾选「不校验合法域名」 +4. 默认 API:`http://127.0.0.1:8000`(可在 `app.js` 修改 `apiBase`) + +## 页面 + +| 页面 | 说明 | +|------|------| +| login | 手机验证码登录(682139) | +| home | 儿童档案列表 | +| screening | 直传 + 分析 + 报告摘要 | + +完整 UI 与 Tab 导航在 `apps/web` H5;小程序后续可嵌入 web-view 或继续原生迭代。 diff --git a/apps/mini/app.js b/apps/mini/app.js new file mode 100644 index 0000000..260bbd3 --- /dev/null +++ b/apps/mini/app.js @@ -0,0 +1,12 @@ +App({ + globalData: { + apiBase: 'http://127.0.0.1:8000', + token: '', + }, + onLaunch() { + const token = wx.getStorageSync('happy_up_token') + if (token) { + this.globalData.token = token + } + }, +}) diff --git a/apps/mini/app.json b/apps/mini/app.json new file mode 100644 index 0000000..d66e576 --- /dev/null +++ b/apps/mini/app.json @@ -0,0 +1,15 @@ +{ + "pages": [ + "pages/login/login", + "pages/home/home", + "pages/screening/screening" + ], + "window": { + "navigationBarTitleText": "Happy Up", + "navigationBarBackgroundColor": "#1a6fb5", + "navigationBarTextStyle": "white", + "backgroundColor": "#eef2f7" + }, + "style": "v2", + "sitemapLocation": "sitemap.json" +} diff --git a/apps/mini/app.wxss b/apps/mini/app.wxss new file mode 100644 index 0000000..6ab94da --- /dev/null +++ b/apps/mini/app.wxss @@ -0,0 +1,24 @@ +page { + background: #eef2f7; + color: #1f2937; + font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif; +} + +.card { + margin: 24rpx; + padding: 32rpx; + background: #fff; + border-radius: 24rpx; + box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.06); +} + +.btn-primary { + margin-top: 24rpx; + background: #1a6fb5; + color: #fff; +} + +.muted { + color: #6b7280; + font-size: 24rpx; +} diff --git a/apps/mini/pages/home/home.js b/apps/mini/pages/home/home.js new file mode 100644 index 0000000..0e17e3a --- /dev/null +++ b/apps/mini/pages/home/home.js @@ -0,0 +1,22 @@ +const api = require('../../utils/api') + +Page({ + data: { + children: [], + error: '', + }, + onShow() { + this.load() + }, + async load() { + try { + const data = await api.listChildren() + this.setData({ children: data.list || [], error: '' }) + } catch (err) { + this.setData({ error: err.message || '加载失败' }) + } + }, + goScreening() { + wx.navigateTo({ url: '/pages/screening/screening' }) + }, +}) diff --git a/apps/mini/pages/home/home.json b/apps/mini/pages/home/home.json new file mode 100644 index 0000000..dac4751 --- /dev/null +++ b/apps/mini/pages/home/home.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "首页" +} diff --git a/apps/mini/pages/home/home.wxml b/apps/mini/pages/home/home.wxml new file mode 100644 index 0000000..aa388d2 --- /dev/null +++ b/apps/mini/pages/home/home.wxml @@ -0,0 +1,8 @@ + + 儿童档案 + {{error}} + + {{item.name}} · {{item.birthday}} + + + diff --git a/apps/mini/pages/home/home.wxss b/apps/mini/pages/home/home.wxss new file mode 100644 index 0000000..e69de29 diff --git a/apps/mini/pages/login/login.js b/apps/mini/pages/login/login.js new file mode 100644 index 0000000..d9aec7d --- /dev/null +++ b/apps/mini/pages/login/login.js @@ -0,0 +1,23 @@ +const api = require('../../utils/api') + +Page({ + data: { + phone: '18600000000', + code: '682139', + error: '', + }, + onPhone(e) { + this.setData({ phone: e.detail.value }) + }, + onCode(e) { + this.setData({ code: e.detail.value }) + }, + async submit() { + try { + await api.login(this.data.phone, this.data.code) + wx.redirectTo({ url: '/pages/home/home' }) + } catch (err) { + this.setData({ error: err.message || '登录失败' }) + } + }, +}) diff --git a/apps/mini/pages/login/login.json b/apps/mini/pages/login/login.json new file mode 100644 index 0000000..c5a131f --- /dev/null +++ b/apps/mini/pages/login/login.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "登录" +} diff --git a/apps/mini/pages/login/login.wxml b/apps/mini/pages/login/login.wxml new file mode 100644 index 0000000..8b80054 --- /dev/null +++ b/apps/mini/pages/login/login.wxml @@ -0,0 +1,8 @@ + + 手机号验证码登录 + 开发验证码 682139 + + + {{error}} + + diff --git a/apps/mini/pages/login/login.wxss b/apps/mini/pages/login/login.wxss new file mode 100644 index 0000000..09e9db8 --- /dev/null +++ b/apps/mini/pages/login/login.wxss @@ -0,0 +1,6 @@ +input { + margin-top: 16rpx; + padding: 16rpx; + background: #f9fafb; + border-radius: 12rpx; +} diff --git a/apps/mini/pages/screening/screening.js b/apps/mini/pages/screening/screening.js new file mode 100644 index 0000000..52784e0 --- /dev/null +++ b/apps/mini/pages/screening/screening.js @@ -0,0 +1,76 @@ +const api = require('../../utils/api') + +Page({ + data: { + status: '准备就绪', + reportSummary: '', + }, + async run() { + this.setData({ status: '读取档案…' }) + try { + const children = await api.listChildren() + const child = (children.list || [])[0] + if (!child) throw new Error('暂无儿童档案') + + this.setData({ status: '获取上传凭证…' }) + const tokenData = await api.request('/api/videos/upload-token', { + method: 'POST', + data: { + childId: child.id, + fileName: 'mini.mp4', + contentType: 'video/mp4', + size: 1024, + }, + }) + + if (tokenData.storage === 'local') { + this.setData({ status: '直传本地存储…' }) + await new Promise((resolve, reject) => { + wx.request({ + url: tokenData.uploadUrl, + method: 'PUT', + header: { + 'Content-Type': 'video/mp4', + 'X-Upload-Token': tokenData.uploadToken, + 'X-Object-Key': tokenData.objectKey, + }, + data: new Uint8Array([0, 0, 0, 24, 102, 116, 121, 112, 105, 115, 111, 109]).buffer, + success(res) { + if (res.statusCode === 204) resolve() + else reject(new Error('直传失败')) + }, + fail: reject, + }) + }) + } + + this.setData({ status: '提交分析…' }) + const video = await api.request('/api/videos', { + method: 'POST', + data: { + childId: child.id, + objectKey: tokenData.objectKey, + scene: 'front_posture', + }, + }) + + const task = await api.request('/api/analysis/tasks', { + method: 'POST', + header: { 'Idempotency-Key': `mini-${Date.now()}` }, + data: { + childId: child.id, + videoId: video.id, + taskType: 'posture_screening', + }, + }) + + const report = await api.request(`/api/reports/${task.reportId}`) + this.setData({ + status: '完成', + reportSummary: report.summary, + }) + } catch (err) { + this.setData({ status: err.message || '失败' }) + } + }, +}) diff --git a/apps/mini/pages/screening/screening.json b/apps/mini/pages/screening/screening.json new file mode 100644 index 0000000..3bea463 --- /dev/null +++ b/apps/mini/pages/screening/screening.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "筛查联调" +} diff --git a/apps/mini/pages/screening/screening.wxml b/apps/mini/pages/screening/screening.wxml new file mode 100644 index 0000000..d2e1d80 --- /dev/null +++ b/apps/mini/pages/screening/screening.wxml @@ -0,0 +1,6 @@ + + 筛查流程 + {{status}} + {{reportSummary}} + + diff --git a/apps/mini/pages/screening/screening.wxss b/apps/mini/pages/screening/screening.wxss new file mode 100644 index 0000000..e69de29 diff --git a/apps/mini/project.config.json b/apps/mini/project.config.json new file mode 100644 index 0000000..d1dbd5f --- /dev/null +++ b/apps/mini/project.config.json @@ -0,0 +1,16 @@ +{ + "description": "Happy Up 微信小程序壳 · Sprint 5", + "packOptions": { + "ignore": [] + }, + "setting": { + "urlCheck": false, + "es6": true, + "postcss": true, + "minified": true + }, + "compileType": "miniprogram", + "appid": "touristappid", + "projectname": "happy-up-mini", + "libVersion": "3.6.0" +} diff --git a/apps/mini/sitemap.json b/apps/mini/sitemap.json new file mode 100644 index 0000000..0998ac3 --- /dev/null +++ b/apps/mini/sitemap.json @@ -0,0 +1,4 @@ +{ + "desc": "Happy Up mini program sitemap", + "rules": [{ "action": "allow", "page": "*" }] +} diff --git a/apps/mini/utils/api.js b/apps/mini/utils/api.js new file mode 100644 index 0000000..659afde --- /dev/null +++ b/apps/mini/utils/api.js @@ -0,0 +1,50 @@ +const app = getApp() + +function request(path, options = {}) { + return new Promise((resolve, reject) => { + wx.request({ + url: `${app.globalData.apiBase}${path}`, + method: options.method || 'GET', + data: options.data, + header: { + 'Content-Type': 'application/json', + Authorization: app.globalData.token ? `Bearer ${app.globalData.token}` : '', + ...(options.header || {}), + }, + success(res) { + const body = res.data + if (res.statusCode >= 200 && res.statusCode < 300 && body.code === 0) { + resolve(body.data) + return + } + reject(new Error(body.message || `HTTP ${res.statusCode}`)) + }, + fail: reject, + }) + }) +} + +function login(phone, code) { + return request('/api/auth/login', { + method: 'POST', + data: { + loginType: 'phone_code', + credential: phone, + code, + }, + }).then((data) => { + app.globalData.token = data.token + wx.setStorageSync('happy_up_token', data.token) + return data + }) +} + +function listChildren() { + return request('/api/children') +} + +module.exports = { + request, + login, + listChildren, +} diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..eaab6a6 --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,32 @@ +# Happy Up Web (H5) + +Vue 3 + Vite 移动端 H5,对接 FastAPI 后端。Sprint 5 起四 Tab 导航对齐产品 Demo 配色。 + +## 开发 + +```bash +# 根目录先启动 API +make up && make api-seed && make api-dev + +# 另开终端 +cd apps/web +npm install +npm run dev +``` + +访问 http://localhost:5173 + +- 登录验证码:`682139` +- 代理:`/api` → `http://127.0.0.1:8000` + +## 页面 + +| 路由 | 说明 | +|------|------| +| `/login` | 手机验证码登录 | +| `/` | 儿童档案列表 | +| `/screening` | 直传 + 分析 + 报告联调 | +| `/reports` | 报告列表 + PDF 下载 | +| `/training` | 训练计划列表 | + +小程序壳见 `apps/mini/`。 diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..d29ab11 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,12 @@ + + + + + + Happy Up · 儿童体态管理 + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..97a93f4 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,21 @@ +{ + "name": "happy-up-web", + "private": true, + "version": "0.5.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "~5.7.2", + "vite": "^6.0.7", + "vue-tsc": "^2.2.0" + } +} diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue new file mode 100644 index 0000000..e2a5e23 --- /dev/null +++ b/apps/web/src/App.vue @@ -0,0 +1,37 @@ + + + diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts new file mode 100644 index 0000000..b1fa634 --- /dev/null +++ b/apps/web/src/api.ts @@ -0,0 +1,161 @@ +const TOKEN_KEY = 'happy_up_token' + +export function getToken(): string | null { + return localStorage.getItem(TOKEN_KEY) +} + +export function setToken(token: string) { + localStorage.setItem(TOKEN_KEY, token) +} + +export function clearToken() { + localStorage.removeItem(TOKEN_KEY) +} + +type ApiResponse = { + code: number + message: string + data: T +} + +export async function apiFetch(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers) + if (!headers.has('Content-Type') && init.body) { + headers.set('Content-Type', 'application/json') + } + const token = getToken() + if (token) { + headers.set('Authorization', `Bearer ${token}`) + } + + const resp = await fetch(path, { ...init, headers }) + const json = (await resp.json()) as ApiResponse + if (!resp.ok || json.code !== 0) { + throw new Error(json.message || `HTTP ${resp.status}`) + } + return json.data +} + +export async function login(phone: string, code: string) { + const data = await apiFetch<{ token: string }>('/api/auth/login', { + method: 'POST', + body: JSON.stringify({ + loginType: 'phone_code', + credential: phone, + code, + }), + }) + setToken(data.token) + return data +} + +export type Child = { + id: number + name: string + birthday: string + gender: string +} + +export async function listChildren() { + return apiFetch<{ list: Child[] }>('/api/children') +} + +export async function createUploadToken(childId: number) { + return apiFetch<{ + uploadUrl: string + objectKey: string + uploadToken: string + storage: string + method: string + }>('/api/videos/upload-token', { + method: 'POST', + body: JSON.stringify({ + childId, + fileName: 'screening.mp4', + contentType: 'video/mp4', + size: 2048, + }), + }) +} + +export async function directUpload( + uploadUrl: string, + uploadToken: string, + objectKey: string, + blob: Blob, +) { + const resp = await fetch(uploadUrl, { + method: 'PUT', + headers: { + 'Content-Type': 'video/mp4', + 'X-Upload-Token': uploadToken, + 'X-Object-Key': objectKey, + }, + body: blob, + }) + if (!resp.ok) { + throw new Error('直传失败') + } +} + +export async function registerVideo(childId: number, objectKey: string) { + return apiFetch<{ id: number }>('/api/videos', { + method: 'POST', + body: JSON.stringify({ + childId, + objectKey, + scene: 'front_posture', + }), + }) +} + +export async function createAnalysisTask(childId: number, videoId: number) { + return apiFetch<{ id: number; status: string; reportId?: number }>('/api/analysis/tasks', { + method: 'POST', + headers: { 'Idempotency-Key': `h5-${Date.now()}` }, + body: JSON.stringify({ + childId, + videoId, + taskType: 'posture_screening', + }), + }) +} + +export async function getReport(reportId: number) { + return apiFetch<{ + id?: number + summary: string + riskLevel: string + metrics: Array<{ name: string; level: string; value: number }> + recommendations?: string[] + }>(`/api/reports/${reportId}`) +} + +export async function listReports(childId: number) { + return apiFetch<{ list: Array<{ id: number; summary: string; riskLevel: string }> }>( + `/api/reports?childId=${childId}`, + ) +} + +export function reportPdfUrl(reportId: number) { + return `/api/reports/${reportId}/pdf` +} + +export async function listTrainingPlans(childId: number) { + return apiFetch<{ + list: Array<{ + id: number + status: string + detail: { goal: string; cycleDays: number; currentDay: number; exercises: Array<{ name: string }> } + }> + }>(`/api/training/plans?childId=${childId}`) +} + +export function saveLastReportId(reportId: number) { + sessionStorage.setItem('happy_up_last_report', String(reportId)) +} + +export function getLastReportId(): number | null { + const raw = sessionStorage.getItem('happy_up_last_report') + return raw ? Number(raw) : null +} diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts new file mode 100644 index 0000000..54ce204 --- /dev/null +++ b/apps/web/src/main.ts @@ -0,0 +1,6 @@ +import { createApp } from 'vue' +import App from './App.vue' +import router from './router' +import './style.css' + +createApp(App).use(router).mount('#app') diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts new file mode 100644 index 0000000..6ac7666 --- /dev/null +++ b/apps/web/src/router/index.ts @@ -0,0 +1,26 @@ +import { createRouter, createWebHistory } from 'vue-router' +import HomeView from '../views/HomeView.vue' +import LoginView from '../views/LoginView.vue' +import ReportView from '../views/ReportView.vue' +import ScreeningView from '../views/ScreeningView.vue' +import TrainingView from '../views/TrainingView.vue' +import { getToken } from '../api' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/login', component: LoginView }, + { path: '/', component: HomeView }, + { path: '/screening', component: ScreeningView }, + { path: '/reports', component: ReportView }, + { path: '/training', component: TrainingView }, + ], +}) + +router.beforeEach((to) => { + if (to.path !== '/login' && !getToken()) { + return '/login' + } +}) + +export default router diff --git a/apps/web/src/style.css b/apps/web/src/style.css new file mode 100644 index 0000000..5461ce2 --- /dev/null +++ b/apps/web/src/style.css @@ -0,0 +1,191 @@ +:root { + --primary: #1a6fb5; + --primary-dark: #0f4f86; + --accent: #2bb673; + --accent-soft: #e8f7ef; + --warn: #f59e0b; + --danger: #ef4444; + --bg: #eef2f7; + --surface: #fff; + --text: #1f2937; + --muted: #6b7280; + --border: #e5e7eb; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif; + color: var(--text); + background: var(--bg); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: linear-gradient(160deg, #0f4f86 0%, #1a6fb5 50%, #2bb673 100%); + min-height: 100vh; +} + +.app-shell { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.topbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + color: #fff; +} + +.stage { + font-size: 11px; + background: rgba(255, 255, 255, 0.2); + padding: 4px 10px; + border-radius: 999px; +} + +.page { + flex: 1; + padding: 12px 16px 88px; + max-width: 480px; + width: 100%; + margin: 0 auto; +} + +.card { + background: var(--surface); + border-radius: 16px; + padding: 16px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); + margin-bottom: 12px; +} + +.card h2 { + margin: 0 0 8px; + font-size: 18px; +} + +.btn { + width: 100%; + border: none; + border-radius: 12px; + padding: 12px; + font-size: 15px; + background: var(--primary); + color: #fff; + cursor: pointer; +} + +.btn.secondary { + background: var(--accent-soft); + color: var(--primary-dark); +} + +.btn:disabled { + opacity: 0.6; +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 12px; +} + +.field input { + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 10px; +} + +.list-item { + padding: 12px 0; + border-bottom: 1px solid var(--border); +} + +.list-item:last-child { + border-bottom: none; +} + +.muted { + color: var(--muted); + font-size: 13px; +} + +.progress { + height: 8px; + background: #e5e7eb; + border-radius: 999px; + overflow: hidden; + margin: 12px 0; +} + +.progress > span { + display: block; + height: 100%; + background: var(--accent); + transition: width 0.3s ease; +} + +.error { + color: var(--danger); + font-size: 14px; +} + +.metric-row { + display: flex; + justify-content: space-between; + padding: 8px 0; + border-bottom: 1px dashed var(--border); +} + +.risk-badge { + display: inline-block; + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + background: var(--accent-soft); + color: var(--primary-dark); +} + +.bottom-nav { + position: fixed; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.96); + border-top: 1px solid var(--border); + display: flex; + justify-content: space-around; + padding: 8px 0 calc(8px + env(safe-area-inset-bottom)); + backdrop-filter: blur(8px); +} + +.bottom-nav a { + text-decoration: none; + color: var(--muted); + font-size: 12px; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + min-width: 56px; +} + +.bottom-nav a.active { + color: var(--primary); + font-weight: 600; +} + +.bottom-nav .dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: transparent; +} + +.bottom-nav a.active .dot { + background: var(--accent); +} diff --git a/apps/web/src/views/HomeView.vue b/apps/web/src/views/HomeView.vue new file mode 100644 index 0000000..a3cba74 --- /dev/null +++ b/apps/web/src/views/HomeView.vue @@ -0,0 +1,44 @@ + + + diff --git a/apps/web/src/views/LoginView.vue b/apps/web/src/views/LoginView.vue new file mode 100644 index 0000000..f25bc9f --- /dev/null +++ b/apps/web/src/views/LoginView.vue @@ -0,0 +1,41 @@ + + + diff --git a/apps/web/src/views/ReportView.vue b/apps/web/src/views/ReportView.vue new file mode 100644 index 0000000..d28f861 --- /dev/null +++ b/apps/web/src/views/ReportView.vue @@ -0,0 +1,77 @@ + + + diff --git a/apps/web/src/views/ScreeningView.vue b/apps/web/src/views/ScreeningView.vue new file mode 100644 index 0000000..8b7d34e --- /dev/null +++ b/apps/web/src/views/ScreeningView.vue @@ -0,0 +1,95 @@ + + + diff --git a/apps/web/src/views/TrainingView.vue b/apps/web/src/views/TrainingView.vue new file mode 100644 index 0000000..56455a0 --- /dev/null +++ b/apps/web/src/views/TrainingView.vue @@ -0,0 +1,45 @@ + + + diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts new file mode 100644 index 0000000..65c7311 --- /dev/null +++ b/apps/web/src/vite-env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent + export default component +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..64e4186 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/apps/web/tsconfig.node.json b/apps/web/tsconfig.node.json new file mode 100644 index 0000000..7366cef --- /dev/null +++ b/apps/web/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler" + }, + "include": ["vite.config.ts"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..c1442d0 --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://127.0.0.1:8000', + changeOrigin: true, + }, + }, + }, +}) diff --git a/contracts/README.md b/contracts/README.md new file mode 100644 index 0000000..46f74e9 --- /dev/null +++ b/contracts/README.md @@ -0,0 +1,14 @@ +# API & Database Contracts + +本目录为工程化使用的**契约副本**,单一事实来源仍为文档包: + +| 文件 | 文档源路径 | +|------|-----------| +| `openapi.yaml` | `doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/07_API接口/openapi.yaml` | +| `database.sql` | `doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/06_数据库设计/database.sql` | + +更新契约时请先改文档包,再执行: + +```bash +make sync-contracts +``` diff --git a/contracts/database.sql b/contracts/database.sql new file mode 100644 index 0000000..3d03bdb --- /dev/null +++ b/contracts/database.sql @@ -0,0 +1,316 @@ +-- Kids AI Posture Platform V1.0 database schema +-- MySQL 8.x +-- 目标:满足审计、幂等、治理、隐私与可追溯性要求 + +CREATE TABLE tenants ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(128) NOT NULL, + type VARCHAR(32) NOT NULL DEFAULT 'organization', + status VARCHAR(32) NOT NULL DEFAULT 'active', + retention_days INT NOT NULL DEFAULT 1095, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CHECK (type IN ('organization','school','clinic','mall','other')), + CHECK (status IN ('active','inactive','frozen','closed')), + CHECK (retention_days BETWEEN 30 AND 3650) +); + +CREATE TABLE users ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT NULL, + phone VARCHAR(32), + phone_hash VARCHAR(128), + wechat_openid VARCHAR(128), + role VARCHAR(32) NOT NULL DEFAULT 'parent', + status VARCHAR(32) NOT NULL DEFAULT 'active', + consent_signed BOOLEAN NOT NULL DEFAULT FALSE, + privacy_version VARCHAR(16) DEFAULT 'v1', + last_login_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_users_tenant_role (tenant_id, role), + UNIQUE KEY uk_users_phone_hash (phone_hash), + UNIQUE KEY uk_users_wechat_openid (wechat_openid), + CHECK (role IN ('parent','coach','org_admin','platform_admin')), + CHECK (status IN ('active','inactive','blocked')) +); + +CREATE TABLE users_passwords ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT NOT NULL, + password_hash VARCHAR(256) NOT NULL, + salt VARCHAR(64) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_user_password (user_id) +); + +CREATE TABLE children ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT NULL, + parent_user_id BIGINT NOT NULL, + coach_user_id BIGINT NULL, + name VARCHAR(64) NOT NULL, + gender VARCHAR(16) NOT NULL DEFAULT 'unknown', + birthday DATE NOT NULL, + height DECIMAL(5,2), + weight DECIMAL(5,2), + contraindications TEXT, + status VARCHAR(32) NOT NULL DEFAULT 'active', + deleted_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_children_parent (parent_user_id), + KEY idx_children_tenant_coach (tenant_id, coach_user_id), + KEY idx_children_status (status), + CHECK (gender IN ('male','female','unknown')), + CHECK (status IN ('active','inactive','archived')), + CHECK (height IS NULL OR (height > 20 AND height < 240)), + CHECK (weight IS NULL OR (weight > 5 AND weight < 250)) +); + +CREATE TABLE child_measurements ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + child_id BIGINT NOT NULL, + height DECIMAL(5,2), + weight DECIMAL(5,2), + source VARCHAR(32) NOT NULL DEFAULT 'manual', + measured_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_measurements_child_time (child_id, measured_at), + CHECK (source IN ('manual','device','coach','report')), + CHECK (height IS NULL OR (height > 20 AND height < 240)), + CHECK (weight IS NULL OR (weight > 5 AND weight < 250)) +); + +CREATE TABLE videos ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + child_id BIGINT NOT NULL, + uploaded_by BIGINT NOT NULL, + scene VARCHAR(64) NOT NULL, + object_key VARCHAR(512) NOT NULL, + duration_seconds INT, + width INT, + height INT, + size_bytes BIGINT, + codec VARCHAR(32), + quality_result JSON, + status VARCHAR(32) NOT NULL DEFAULT 'uploaded', + archive_until_date DATE NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_videos_child_scene (child_id, scene), + KEY idx_videos_status (status), + UNIQUE KEY uk_videos_object_key (object_key), + CHECK (scene IN ('front_posture','side_posture','squat','balance','gait','custom')), + CHECK (status IN ('uploaded','transcoding','failed','ready','archived')), + CHECK (duration_seconds IS NULL OR duration_seconds BETWEEN 1 AND 1800), + CHECK (size_bytes IS NULL OR size_bytes BETWEEN 1 AND 10737418240) +); + +CREATE TABLE analysis_tasks ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + child_id BIGINT NOT NULL, + video_id BIGINT NOT NULL, + task_type VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'CREATED', + progress INT NOT NULL DEFAULT 0, + model_version VARCHAR(64), + error_code VARCHAR(64), + error_message VARCHAR(512), + result JSON, + idempotency_key VARCHAR(128), + retry_count INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + started_at TIMESTAMP NULL, + finished_at TIMESTAMP NULL, + UNIQUE KEY uk_analysis_idempotency (child_id, video_id, task_type, idempotency_key), + KEY idx_analysis_child_status (child_id, status), + KEY idx_analysis_video (video_id), + KEY idx_analysis_created (created_at), + CHECK (status IN ('CREATED','QUEUED','PROCESSING','SUCCEEDED','FAILED','CANCELLED')), + CHECK (task_type IN ('posture_screening','movement_scoring','reassessment')), + CHECK (progress BETWEEN 0 AND 100), + CHECK (retry_count BETWEEN 0 AND 20) +); + +CREATE TABLE reports ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + child_id BIGINT NOT NULL, + task_id BIGINT NOT NULL, + report_type VARCHAR(64) NOT NULL DEFAULT 'posture_screening', + risk_level VARCHAR(32) NOT NULL, + summary TEXT NOT NULL, + metrics JSON NOT NULL, + recommendations JSON NOT NULL, + disclaimer TEXT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'draft', + reviewed_by BIGINT NULL, + reviewed_at TIMESTAMP NULL, + published_at TIMESTAMP NULL, + expires_at DATE NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_reports_task (task_id), + KEY idx_reports_child_status (child_id, status), + CHECK (risk_level IN ('low','medium','high','review_required')), + CHECK (status IN ('draft','published','retracted','archived')) +); + +CREATE TABLE exercises ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(128) NOT NULL, + category VARCHAR(64) NOT NULL, + target_issue VARCHAR(128), + difficulty VARCHAR(32) NOT NULL DEFAULT 'basic', + duration_seconds INT NOT NULL DEFAULT 60, + media_url VARCHAR(512), + thumbnail_url VARCHAR(512), + rules JSON, + status VARCHAR(32) NOT NULL DEFAULT 'active', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_exercises_category (category, status), + CHECK (difficulty IN ('basic','intermediate','advanced')), + CHECK (status IN ('active','offline','deprecated')), + CHECK (duration_seconds BETWEEN 20 AND 1800) +); + +CREATE TABLE training_plans ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + child_id BIGINT NOT NULL, + report_id BIGINT NULL, + coach_user_id BIGINT NULL, + goal VARCHAR(256) NOT NULL, + cycle_days INT NOT NULL DEFAULT 28, + status VARCHAR(32) NOT NULL DEFAULT 'draft', + plan_detail JSON NOT NULL, + started_at TIMESTAMP NULL, + ended_at TIMESTAMP NULL, + deleted_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_training_plans_child_status (child_id, status), + CHECK (cycle_days BETWEEN 7 AND 180), + CHECK (status IN ('draft','active','completed','paused','cancelled')) +); + +CREATE TABLE training_records ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + plan_id BIGINT NOT NULL, + child_id BIGINT NOT NULL, + exercise_id BIGINT NOT NULL, + completed BOOLEAN NOT NULL DEFAULT FALSE, + score INT, + duration_seconds INT, + feedback JSON, + note VARCHAR(512), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_training_records_plan (plan_id), + KEY idx_training_records_child_time (child_id, created_at), + CHECK (score IS NULL OR score BETWEEN 0 AND 100), + CHECK (duration_seconds IS NULL OR duration_seconds BETWEEN 1 AND 7200) +); + +CREATE TABLE products ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT NULL, + name VARCHAR(128) NOT NULL, + product_type VARCHAR(32) NOT NULL, + price_cents INT NOT NULL, + duration_days INT NULL, + benefits JSON NOT NULL, + limit_child INTEGER NOT NULL DEFAULT 1, + status VARCHAR(32) NOT NULL DEFAULT 'active', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (product_type IN ('video_pack','training_plan','membership','assessment_bundle','course_package')), + CHECK (status IN ('active','inactive','discontinued')), + CHECK (price_cents >= 0), + CHECK (duration_days IS NULL OR duration_days BETWEEN 1 AND 3650) +); + +CREATE TABLE orders ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT NOT NULL, + child_id BIGINT NULL, + product_id BIGINT NOT NULL, + amount_cents INT NOT NULL, + pay_status VARCHAR(32) NOT NULL DEFAULT 'pending', + payment_channel VARCHAR(32), + paid_at TIMESTAMP NULL, + canceled_at TIMESTAMP NULL, + refund_status VARCHAR(32) DEFAULT 'none', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_orders_user_status (user_id, pay_status), + KEY idx_orders_child (child_id), + CHECK (pay_status IN ('pending','paid','failed','closed','refunding','refunded')), + CHECK (refund_status IN ('none','requested','processing','completed','rejected')), + CHECK (amount_cents >= 0) +); + +CREATE TABLE operation_leads ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + child_id BIGINT NULL, + source VARCHAR(64) NOT NULL, + stage VARCHAR(32) NOT NULL DEFAULT 'new', + owner_user_id BIGINT NULL, + score INT DEFAULT 0, + priority TINYINT DEFAULT 3, + next_follow_at TIMESTAMP NULL, + note TEXT, + touched_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_leads_tenant_stage (tenant_id, stage), + KEY idx_leads_owner_next (owner_user_id, next_follow_at), + CHECK (source IN ('wechat','h5','miniapp','partner','referral','offline_campaign')), + CHECK (stage IN ('new','contacted','qualified','closed','lost','churned')), + CHECK (priority BETWEEN 1 AND 5), + CHECK (score BETWEEN 0 AND 100) +); + +CREATE TABLE audit_logs ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT NULL, + actor_user_id BIGINT NOT NULL, + action VARCHAR(128) NOT NULL, + resource_type VARCHAR(64) NOT NULL, + resource_id BIGINT NULL, + request_id VARCHAR(64), + ip VARCHAR(64), + user_agent VARCHAR(256), + payload JSON, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_audit_actor_time (actor_user_id, created_at), + KEY idx_audit_resource (resource_type, resource_id) +); + +CREATE TABLE data_retention_rules ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT NULL, + resource_type VARCHAR(64) NOT NULL, + archive_after_days INT NOT NULL, + delete_after_days INT NOT NULL, + legal_basis VARCHAR(128), + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_retention (tenant_id, resource_type), + CHECK (delete_after_days >= archive_after_days), + CHECK (archive_after_days >= 30) +); + +CREATE TABLE api_idempotency_keys ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + scope VARCHAR(32) NOT NULL, + idempotency_key VARCHAR(128) NOT NULL, + request_path VARCHAR(255) NOT NULL, + request_hash CHAR(64) NOT NULL, + response_status INT, + response_body JSON, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_idempotency_scope_key (scope, idempotency_key), + KEY idx_idempotency_created (created_at) +); + +-- 运行时可选索引补充说明(按需执行) +-- CREATE INDEX idx_analysis_status_updated ON analysis_tasks(status, updated_at); +-- CREATE INDEX idx_reports_risk_level ON reports(risk_level, created_at); +-- CREATE INDEX idx_orders_pay_status_updated ON orders(pay_status, updated_at); diff --git a/contracts/openapi.yaml b/contracts/openapi.yaml new file mode 100644 index 0000000..e474e46 --- /dev/null +++ b/contracts/openapi.yaml @@ -0,0 +1,670 @@ +openapi: 3.0.3 +info: + title: Kids AI Posture Platform API + version: 1.0.0 + description: | + 儿童AI体态管理与运动康复平台 V1.0 API 契约。 + 所有报告用于健康管理建议,不构成医疗诊断。接口返回统一包含 requestId 以便追踪。 +servers: + - url: https://api.example.com + description: production + - url: https://staging-api.example.com + description: staging +security: + - bearerAuth: [] + +x-error-model: &error-model + code: integer + message: string + details: + type: object + additionalProperties: true + requestId: string + +tags: + - name: Auth + - name: Children + - name: Videos + - name: Analysis + - name: Reports + - name: Training + - name: Admin + +paths: + /api/auth/login: + post: + tags: [Auth] + summary: 手机号或微信授权登录 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + examples: + phone: + summary: 手机验证码 + value: + loginType: phone_code + credential: "186xxxx0000" + code: "682139" + wechat: + summary: 微信授权码 + value: + loginType: wechat + credential: "wx_auth_code" + responses: + '200': + description: 登录成功 + content: + application/json: + schema: + $ref: '#/components/schemas/LoginResponse' + '401': + description: 登录失败 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/children: + get: + tags: [Children] + summary: 获取当前用户可访问的儿童档案 + parameters: + - name: page + in: query + schema: { type: integer, minimum: 1, default: 1 } + - name: pageSize + in: query + schema: { type: integer, minimum: 1, maximum: 100, default: 20 } + responses: + '200': + description: 儿童档案分页列表 + content: + application/json: + schema: + $ref: '#/components/schemas/ChildrenPagedResponse' + post: + tags: [Children] + summary: 创建儿童档案 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChildCreateRequest' + responses: + '201': + description: 创建成功 + headers: + Idempotency-Key: + description: 冪等键回显,用于重试去重核对 + schema: { type: string } + content: + application/json: + schema: + $ref: '#/components/schemas/ChildResponse' + '400': + description: 请求参数错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/children/{childId}: + parameters: + - $ref: '#/components/parameters/ChildId' + get: + tags: [Children] + summary: 获取儿童档案详情 + responses: + '200': + description: 档案详情 + content: + application/json: + schema: + $ref: '#/components/schemas/ChildResponse' + '404': + description: 资源不存在 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + patch: + tags: [Children] + summary: 更新儿童档案 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChildUpdateRequest' + responses: + '200': + description: 更新成功 + content: + application/json: + schema: + $ref: '#/components/schemas/ChildResponse' + '409': + description: 更新冲突(数据并发) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + tags: [Children] + summary: 注销/归档儿童档案 + responses: + '204': + description: 归档成功 + + /api/videos/upload-token: + post: + tags: [Videos] + summary: 获取视频直传凭证 + description: 建议请求头携带 Idempotency-Key,避免重试产生重复录像对象。 + parameters: + - name: Idempotency-Key + in: header + required: false + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UploadTokenRequest' + responses: + '200': + description: 上传凭证 + content: + application/json: + schema: + $ref: '#/components/schemas/UploadTokenResponse' + '429': + description: 请求过于频繁 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/videos: + post: + tags: [Videos] + summary: 登记已上传视频 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VideoCreateRequest' + responses: + '201': + description: 视频登记成功 + content: + application/json: + schema: + $ref: '#/components/schemas/VideoResponse' + '409': + description: 去重/重复提交 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/analysis/tasks: + post: + tags: [Analysis] + summary: 创建AI分析任务 + description: 同一 childId+videoId+taskType 可复用幂等键创建。 + parameters: + - name: Idempotency-Key + in: header + required: false + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AnalysisTaskCreateRequest' + responses: + '201': + description: 任务创建成功 + content: + application/json: + schema: + $ref: '#/components/schemas/AnalysisTaskResponse' + '409': + description: 重复任务,返回已有任务 + content: + application/json: + schema: + $ref: '#/components/schemas/AnalysisTaskResponse' + + /api/analysis/tasks/{taskId}: + get: + tags: [Analysis] + summary: 查询分析任务状态 + parameters: + - $ref: '#/components/parameters/TaskId' + responses: + '200': + description: 任务状态 + content: + application/json: + schema: + $ref: '#/components/schemas/AnalysisTaskResponse' + '404': + description: 任务不存在 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/analysis/tasks/{taskId}/cancel: + post: + tags: [Analysis] + summary: 取消分析任务 + parameters: + - $ref: '#/components/parameters/TaskId' + responses: + '200': + description: 取消成功 + content: + application/json: + schema: + $ref: '#/components/schemas/StandardResponse' + + /api/analysis/webhook: + post: + tags: [Analysis] + summary: 分析结果回调(异步) + description: 系统内部回调给后端事件网关,需使用 HMAC 签名。 + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AnalysisTaskWebhook' + responses: + '204': + description: 接收成功 + + /api/reports/{reportId}: + get: + tags: [Reports] + summary: 获取体态分析报告 + parameters: + - $ref: '#/components/parameters/ReportId' + responses: + '200': + description: 报告详情 + content: + application/json: + schema: + $ref: '#/components/schemas/ReportResponse' + + /api/training/plans: + post: + tags: [Training] + summary: 创建训练计划 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TrainingPlanCreateRequest' + responses: + '201': + description: 训练计划创建成功 + content: + application/json: + schema: + $ref: '#/components/schemas/TrainingPlanResponse' + + /api/training/plans/{planId}: + parameters: + - name: planId + in: path + required: true + schema: + type: integer + get: + tags: [Training] + summary: 获取训练计划 + responses: + '200': + description: 训练计划详情 + content: + application/json: + schema: + $ref: '#/components/schemas/TrainingPlanResponse' + + /api/training/plans/{planId}/records: + post: + tags: [Training] + summary: 提交训练打卡记录 + parameters: + - name: planId + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TrainingRecordCreateRequest' + responses: + '201': + description: 打卡成功 + content: + application/json: + schema: + $ref: '#/components/schemas/TrainingRecordResponse' + + /api/admin/dashboard: + get: + tags: [Admin] + summary: 获取机构运营看板 + responses: + '200': + description: 看板指标 + content: + application/json: + schema: + $ref: '#/components/schemas/AdminDashboardResponse' + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + parameters: + ChildId: + name: childId + in: path + required: true + schema: { type: integer } + TaskId: + name: taskId + in: path + required: true + schema: { type: integer } + ReportId: + name: reportId + in: path + required: true + schema: { type: integer } + schemas: + ApiMeta: + type: object + properties: + requestId: { type: string } + timestamp: { type: string, format: date-time } + traceId: { type: string } + StandardResponse: + type: object + properties: + code: { type: integer } + message: { type: string } + data: { type: object, nullable: true } + meta: { $ref: '#/components/schemas/ApiMeta' } + ErrorResponse: + allOf: + - $ref: '#/components/schemas/StandardResponse' + - type: object + properties: + code: { type: integer, example: 10003 } + message: { type: string, example: parameter_validation_failed } + data: + type: object + properties: + errorCode: { type: string } + path: { type: string } + PagedMeta: + type: object + properties: + page: { type: integer } + pageSize: { type: integer } + total: { type: integer } + hasMore: { type: boolean } + LoginRequest: + type: object + required: [loginType, credential] + properties: + loginType: { type: string, enum: [phone_code, wechat] } + credential: { type: string } + code: { type: string } + LoginResponse: + allOf: + - $ref: '#/components/schemas/StandardResponse' + - type: object + properties: + data: + type: object + properties: + token: { type: string } + user: { $ref: '#/components/schemas/User' } + User: + type: object + properties: + id: { type: integer } + phoneMasked: { type: string } + role: { type: string, enum: [parent, coach, org_admin, platform_admin] } + ChildCreateRequest: + type: object + required: [name, birthday] + properties: + name: { type: string } + birthday: { type: string, format: date } + gender: { type: string, enum: [male, female, unknown] } + height: { type: number } + weight: { type: number } + contraindications: { type: string } + ChildUpdateRequest: + allOf: + - $ref: '#/components/schemas/ChildCreateRequest' + Child: + type: object + properties: + id: { type: integer } + name: { type: string } + birthday: { type: string, format: date } + age: { type: integer } + height: { type: number } + weight: { type: number } + status: { type: string } + ChildResponse: + allOf: + - $ref: '#/components/schemas/StandardResponse' + - type: object + properties: + data: { $ref: '#/components/schemas/Child' } + ChildrenPagedResponse: + allOf: + - $ref: '#/components/schemas/StandardResponse' + - type: object + properties: + data: + type: object + properties: + list: + type: array + items: { $ref: '#/components/schemas/Child' } + page: { $ref: '#/components/schemas/PagedMeta' } + UploadTokenRequest: + type: object + required: [childId, fileName, contentType, size] + properties: + childId: { type: integer } + fileName: { type: string } + contentType: { type: string, example: video/mp4 } + size: { type: integer } + UploadTokenResponse: + allOf: + - $ref: '#/components/schemas/StandardResponse' + - type: object + properties: + data: + type: object + properties: + uploadUrl: { type: string } + objectKey: { type: string } + expireAt: { type: string, format: date-time } + VideoCreateRequest: + type: object + required: [childId, objectKey, scene] + properties: + childId: { type: integer } + objectKey: { type: string } + scene: + type: string + enum: [front_posture, side_posture, squat, balance, gait] + captureHint: { type: string } + VideoResponse: + allOf: + - $ref: '#/components/schemas/StandardResponse' + - type: object + properties: + data: + type: object + properties: + id: { type: integer } + status: { type: string, enum: [uploaded, rejected, archived] } + AnalysisTaskCreateRequest: + type: object + required: [childId, videoId, taskType] + properties: + childId: { type: integer } + videoId: { type: integer } + taskType: { type: string, enum: [posture_screening, movement_scoring, reassessment] } + AnalysisTaskResponse: + allOf: + - $ref: '#/components/schemas/StandardResponse' + - type: object + properties: + data: + type: object + properties: + id: { type: integer } + status: + type: string + enum: [CREATED, QUEUED, PROCESSING, SUCCEEDED, FAILED, CANCELLED] + progress: { type: integer, minimum: 0, maximum: 100 } + errorCode: { type: string } + reportId: { type: integer } + retryCount: { type: integer } + AnalysisTaskWebhook: + type: object + required: [taskId, status] + properties: + taskId: { type: integer } + status: + type: string + enum: [SUCCEEDED, FAILED, CANCELLED] + progress: { type: integer } + errorCode: { type: string } + result: { type: object } + signature: { type: string } + ReportResponse: + allOf: + - $ref: '#/components/schemas/StandardResponse' + - type: object + properties: + data: + type: object + properties: + id: { type: integer } + childId: { type: integer } + taskId: { type: integer } + riskLevel: { type: string, enum: [low, medium, high, review_required] } + summary: { type: string } + metrics: + type: array + items: + type: object + properties: + name: { type: string } + value: { type: number } + level: { type: string } + confidence: { type: number } + recommendations: + type: array + items: { type: string } + disclaimer: { type: string } + reviewedBy: { type: integer, nullable: true } + TrainingPlanCreateRequest: + type: object + required: [childId, goal, cycleDays] + properties: + childId: { type: integer } + reportId: { type: integer } + goal: { type: string } + cycleDays: { type: integer, minimum: 7 } + exerciseIds: + type: array + items: { type: integer } + constraints: + type: object + properties: + maxDailyMinutes: { type: integer } + coachNotes: { type: string } + TrainingPlanResponse: + allOf: + - $ref: '#/components/schemas/StandardResponse' + - type: object + properties: + data: + type: object + properties: + id: { type: integer } + status: { type: string, enum: [draft, active, completed, paused] } + detail: { type: object } + startedAt: { type: string, format: date-time } + endedAt: { type: string, format: date-time, nullable: true } + TrainingRecordCreateRequest: + type: object + required: [exerciseId, completed] + properties: + exerciseId: { type: integer } + completed: { type: boolean } + score: { type: integer, minimum: 0, maximum: 100 } + durationSeconds: { type: integer } + note: { type: string } + media: + type: array + items: + type: string + TrainingRecordResponse: + allOf: + - $ref: '#/components/schemas/StandardResponse' + - type: object + properties: + data: + type: object + properties: + id: { type: integer } + planId: { type: integer } + createdAt: { type: string, format: date-time } + AdminDashboardResponse: + allOf: + - $ref: '#/components/schemas/StandardResponse' + - type: object + properties: + data: + type: object + properties: + newChildren: { type: integer } + uploadedVideos: { type: integer } + completedReports: { type: integer } + activePlans: { type: integer } + conversionRate: { type: number } + reassessmentCompletionRate: { type: number } diff --git a/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0.zip b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0.zip new file mode 100644 index 0000000..35852c5 Binary files /dev/null and b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0.zip differ diff --git a/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/01_商业计划书/儿童AI体态管理商业计划书.docx b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/01_商业计划书/儿童AI体态管理商业计划书.docx new file mode 100644 index 0000000..51063df Binary files /dev/null and b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/01_商业计划书/儿童AI体态管理商业计划书.docx differ diff --git a/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/02_企业级PRD/PRD完整版.docx b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/02_企业级PRD/PRD完整版.docx new file mode 100644 index 0000000..31a1cfe Binary files /dev/null and b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/02_企业级PRD/PRD完整版.docx differ 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 new file mode 100644 index 0000000..317db7d --- /dev/null +++ b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/03_技术架构/产品交互Demo.html @@ -0,0 +1,2563 @@ + + + + + + 儿童 AI 体态管理平台 · 产品交互 Demo + + + +
+
+
+ 产品交互 Demo + Sprint 5 · AI 评分 / PDF / H5 +
+
+ + ← 返回架构文档 +
+
+ +
+ +
+

当前流程

+
    +
    + + +
    +
    +
    + + +
    + +
    +
    + +
    +
    📱
    +

    家长端完整流程

    +

    登录 → 建档 → 体态筛查 → 报告 → 训练计划

    +
    +
    +
    🧒
    +

    儿童训练端

    +

    大按钮跟练 · 星星奖励 · 家长陪同模式

    +
    +
    +
    🖥️
    +

    教练 / 管理后台

    +

    看板 → 报告复核 → 训练记录 → 打卡数据

    +
    +
    +
    +
    + + +
    + +
    +
    + + +
    +
    + + +
    +

    + 登录即表示同意《隐私协议》 +

    +
    + +
    + + +
    + +
    +
    +

    + 本平台收集儿童体态视频用于 AI 健康筛查,数据加密存储,保留期限不超过 3 年。 + 报告仅用于健康管理建议,不构成医疗诊断。您可随时申请删除数据。 +

    +
    +
    + +
    + + +
    + +
    +
    +
    +
    +

    小明 · 10 岁

    +

    身高 142cm · 体重 36kg

    +
    + 需关注 +
    +
    +

    📋 开始新的体态检测

    +

    按引导拍摄正面/侧面站姿视频,约 2 分钟

    + +
    +
    +

    📊 最近报告

    +

    2026-07-20 体态筛查 · 中度关注

    + +
    +
    +

    🎥 今日 AI 跟练打卡

    +

    肩胛稳定训练 · 摄像头实时跟练 + 语音纠正

    + + +
    +
    +
    +
    🏠首页
    +
    📊报告
    +
    📅计划
    +
    👤我的
    +
    +
    + + +
    + +
    +
    +
    +
    +
    +
    +
    + +
    + + +
    + +
    +
    + + +
    +

    正面:评估高低肩、骨盆对称 · 参考 Posture AI 双视角筛查

    +
    1
    距离手机 2–3 米,全身入镜
    +
    2
    光线充足,避免逆光
    +
    3
    穿贴身衣物,赤脚或穿袜
    +
    4
    自然站立 10–15 秒
    +
    + + 模拟画面 +
    +
    +
    📷
    +

    摄像头预览已开启 · 点击下方模拟录制

    +
    +
    + +
    + + +
    + +
    +
    +

    正在上传… 0%

    +
    + +
    + + +
    + +
    +
    +

    姿态识别中…

    +
    +

    MediaPipe Pose · 33 关键点

    +
    + +
    + + +
    + +
    +
    +

    综合评估

    +

    中度关注

    +

    建议关注头前伸与高低肩,开始针对性训练

    +
    +
    +

    关键指标

    +
    +
    头前伸中度 · 78%
    +
    高低肩轻度 · 65%
    +
    骨盆倾斜正常 · 92%
    +
    膝内扣正常 · 88%
    +
    +
    +
    +

    训练建议

    +

    + ① 每日肩胛稳定训练 5 分钟
    + ② 颈后肌群拉伸 3 组
    + ③ 28 天后建议复测对比 +

    +
    +
    + ⚠️ 本报告用于健康管理建议,不构成医疗诊断。如有疼痛或明显不适,请咨询专业医生。 +
    +
    + +
    +
    🏠首页
    +
    📊报告
    +
    📅计划
    +
    👤我的
    +
    +
    + + +
    + +
    +
    +
    +

    进度

    第 3 / 28 天 +
    +
    +
    +
    +

    今日任务 · Day 3

    +
    +

    ① 肩胛稳定训练 · 5 min

    +

    ② 颈后肌群拉伸 · 3 组

    +
    + +
    +
    +

    复测提醒

    +

    计划第 28 天自动提醒复测,对比改善效果

    + + + +
    +
    +
    +
    🏠首页
    +
    📊报告
    +
    📅计划
    +
    👤我的
    +
    +
    + + +
    + +
    +
    + + +
    +

    侧面视角:专测头前伸/颈后角 · 与筛查报告头颈指标对齐

    +
    +

    标准动作示范(Ghost 参考)

    +
    +
    +
    +

    + 动作要点:双肩向后收、下沉,保持颈后延展,避免头前伸。节奏 2 秒收、2 秒放,共 8 次。 +

    +
    +
    +

    关联筛查基线 · posture_screening

    +
    头前伸中度 · 78%
    +
    高低肩轻度 · 65%
    +
    10岁男童参考头颈角 ≤15°
    +
    +
    +

    本次训练模型

    +
    Pose 引擎MediaPipe · 33关键点 · 30fps
    +
    任务类型movement_scoring v1
    +
    推理方式🔒 端侧实时 · 结束后上传
    +
    +
    + 训练过程将实时分析动作轨迹并给出纠正建议,不构成医疗诊断。如有疼痛请立即停止。 +
    +
    + +
    + + +
    + +
    +
    +
    + + 模拟画面 +
    +
    +
    +

    正在检测机位…

    + +
    +
    +

    校准检查项

    +
    + ⏳ 距离 2–3m + ⏳ 摄像头高度 + ⏳ 全身入镜 + ⏳ 侧面角度 +
    +
    +
    +

    + 参考 Kemtai / Move Sense:机位不对会导致关键点置信度下降,系统将在置信度 < 70% 时降级输出,仅提示「建议调整机位/教练复核」。 +

    +
    +
    + +
    + + +
    +
    +
    + + 模拟画面 +
    +
    跟练中
    +
    +
    00:00
    +
    肩胛后缩 · 第 0/8 次
    +
    +
    + +
    + 🔒 端侧推理 + 🔊 语音提醒开 + 10岁参考 ≤15° +
    + +
    +
    头颈角 18° 目标 ≤15°
    +
    肩高差 8mm 目标 ≤5mm
    +
    + +
    + 72 + 实时分 +
    +
    置信度 91%
    + +
    + + + + + + + + + + + Ghost 标准 +
    + + + + + + + + + + + + + + + + +
    +
    + +
    +
    Coach Agent · 实时纠正 🔊
    +
    准备开始,请站直面对摄像头
    +
    规则来源:肩胛稳定训练 · 关节角度阈值
    +
    + +
    +
    五维评分
    +
    +
    轨迹 30%
    70
    +
    角度 30%
    58
    +
    节奏 20%
    75
    +
    稳定 10%
    82
    +
    完成 10%
    65
    +
    +
    + +
    + + + +
    +
    +
    +
    + + +
    + +
    +
    +

    抽帧 & 关键点识别…

    +
    +

    + 任务类型 movement_scoring
    + 保留帧级关键指标 · 写入 training_records +

    +
    + +
    + + +
    + +
    +
    +

    综合动作评分 · movement_scoring

    +

    81

    +

    比上次跟练 +6 分 · 完成 8/8 次 · 置信度 89%

    +
    + +
    +

    📊 对比筛查基线 · posture_screening

    +

    2026-07-20 筛查报告 vs 本次跟练 — 核心差异化能力

    +
    +
    + 头前伸 + 筛查 78 中度 + 本次均 15° + ↓ 改善 3° · 接近 10岁标准 ≤15° +
    +
    + 高低肩 + 筛查 65 轻度 + 轨迹偏差 6mm + ↓ 改善 50% · 由 12mm 降至 6mm +
    +
    + 训练完成 + 计划 Day 3 + 8/8 次 + 连续打卡 3 天 · 14天目标 ≥60% +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + +
    +

    五维评分(算法文档加权)

    +
    +
    78
    动作轨迹
    权重 30%
    +
    72
    关节角度
    权重 30%
    +
    85
    节奏控制
    权重 20%
    +
    88
    稳定性
    权重 10%
    +
    80
    完成度
    权重 10%
    +
    +
    + +
    +

    帧级关键片段(可回放 · 引用输入字段)

    +
    +
    00:42 · 关节角度 · 置信度 87%
    + 头颈角 18° > 阈值 15° → 「收下巴、颈后延展」 +
    +
    +
    01:15 · 动作轨迹 · 置信度 91%
    + 左肩轨迹偏高 12mm → 「两侧肩胛同步后收」 +
    +
    +
    02:30 · 置信度 58% · 降级输出
    + 关键点遮挡 → 仅提示「建议调整机位/教练复核」,不给强结论 +
    +
    +
    03:20 · 节奏控制 · 置信度 94%
    + 2 秒收放周期达标 · 稳定性 92 分 +
    +
    + +
    +

    Parent Agent · 家长可读总结

    +

    + 今天整体表现不错!主要需要关注练习时不要低头,想象头顶有一本书要保持水平。 + 左右肩膀尽量一起动,明天继续同样的训练,28 天后我们会安排复测对比。 +

    +
    + +
    + ⚠️ 本报告用于运动训练反馈,不构成医疗诊断。低置信度片段已标记,建议教练复核。 +
    +
    + +
    + + +
    + +
    +
    +

    跟练评分趋势

    +
    + + + + +
    +
    + 跟练综合分 + 头颈角 (°) +
    +

    Day1: 68 → Day3: 81 · 头颈角 21° → 15° · 参考 PhysioAI ROM 进度追踪

    +
    +
    +

    打卡日历(14 天完成率目标 ≥60%)

    +
    +
    1
    2
    3
    +
    4
    5
    6
    7
    +
    8
    9
    10
    11
    +
    12
    13
    14
    +
    15
    16
    17
    18
    +
    19
    20
    21
    22
    +
    23
    24
    25
    26
    +
    27
    28
    +
    +

    当前完成率 100%(3/3 天)· 超过 14 天目标

    +
    +
    +

    关键指标变化

    +
    头颈角均值21° → 15° ↓
    +
    肩轨迹偏差12mm → 6mm ↓
    +
    跟练均分68 → 81 ↑
    +
    +
    + +
    + + +
    + +
    +
    +
    +
    posture_screening · 2026-07-20
    +

    ① 初筛报告

    +
    头前伸中度 78%
    +
    高低肩轻度 65%
    +
    综合中度关注
    + +
    +
    ↓ 下发 28 天训练计划 · Day 1–3 跟练
    +
    +
    movement_scoring · 2026-07-23 · Day 3
    +

    ② 跟练打卡报告

    +
    动作评分81 分
    +
    头颈角均15°(↓3°)
    +
    肩轨迹6mm(↓50%)
    + +
    +
    ↓ 计划 Day 28 自动提醒复测
    +
    +
    posture_screening · 复测预览 · Day 28
    +

    ③ 复测报告(预期)

    +
    头前伸预期 轻度 55%
    +
    高低肩预期 正常 85%
    +
    综合轻度关注 → 正常
    + +
    +
    +
    +

    闭环价值(合伙人重点)

    +

    + 初筛定位问题 → 跟练过程量化改善 → 复测验证效果。机构可据此做续费/进阶课程转化,家长可见可量化进步。 +

    +
    +
    + +
    + + +
    + +
    +
    🧒
    +

    已连续打卡 3 天!

    +
    ⭐ ⭐ ⭐
    + + +

    家长陪同模式 · 大按钮 · 少文字

    +
    +
    + + +
    +
    +
    +

    AI 跟练打卡成功!

    +
    ⭐ ⭐ ⭐
    + 连续打卡 3 天 · 获得「姿态小达人」 +

    跟练报告已归档 · 教练将收到复核通知

    +
    +

    本次跟练摘要

    +
    动作评分81 分 (+6)
    +
    完成次数8 / 8 次
    +
    实时纠正5 次(含 1 次降级)
    +
    对比筛查头前伸 ↓3°
    +
    数据归档training_records #3847
    +
    +
    +

    教练点评

    +

    头前伸有改善趋势,继续保持节奏练习。下次注意左右肩同步。

    +
    +
    + +
    + +
    +
    + + +
    + +
    +
    +
    + +
      +
    • 📊 运营看板
    • +
    • 👶 儿童档案
    • +
    • 📋 报告复核
    • +
    • 📅 训练计划
    • +
    • 🎥 跟练打卡记录
    • +
    • ⚙️ 系统设置
    • +
    +
    +
    +
    +
    运营看板 · 阳光运动中心
    + +
    +
    +
    +
    128
    新增档案(月)
    +
    356
    上传视频
    +
    312
    完成报告
    +
    89
    活跃计划
    +
    +
    +

    待办事项

    +
    待复核报告12 份 →
    +
    跟练打卡待复核3 条 →
    +
    待分配教练5 人 →
    +
    复测到期提醒8 人
    +
    +
    +
    +
    +
    + + +
    +
    +
    + +
      +
    • 📊 运营看板
    • +
    • 👶 儿童档案
    • +
    • 📋 报告复核
    • +
    • 📅 训练计划
    • +
    • 🎥 跟练打卡记录
    • +
    • ⚙️ 系统设置
    • +
    +
    +
    +
    +
    儿童档案
    + +
    +
    + + + + + + + +
    姓名年龄家长最近报告教练状态
    小明10186****0000中度关注李教练活跃
    小红8139****1234正常王教练活跃
    小华12158****5678需复核待分配
    +
    +
    +
    +
    + + +
    +
    +
    + +
      +
    • 📊 运营看板
    • +
    • 👶 儿童档案
    • +
    • 📋 报告复核
    • +
    • 📅 训练计划
    • +
    • 🎥 跟练打卡记录
    • +
    • ⚙️ 系统设置
    • +
    +
    +
    +
    报告复核 · 待处理 12 份
    +
    + + + + + + +
    儿童检测日期风险等级AI 置信度状态
    小明2026-07-23中度关注87%待复核
    小华2026-07-22需复核62%待复核
    +
    +
    +
    +
    + + +
    +
    +
    + +
      +
    • 📊 运营看板
    • +
    • 👶 儿童档案
    • +
    • 📋 报告复核
    • +
    • 📅 训练计划
    • +
    +
    +
    +
    +
    ← 返回列表 · 小明 · 报告复核
    +
    +
    +
    +
    +

    AI 分析结果

    +
    头前伸中度 78%
    +
    高低肩轻度 65%
    +
    骨盆倾斜正常 92%
    +

    模型版本:mediapipe-pose-v1 · Agent v1.2

    +
    +
    +

    教练复核意见

    + +
    +
    +
    + + +
    +
    +
    +
    +
    + + +
    +
    +
    + +
      +
    • 📊 运营看板
    • +
    • 👶 儿童档案
    • +
    • 📋 报告复核
    • +
    • 📅 训练计划
    • +
    • 🎥 跟练打卡记录
    • +
    +
    +
    +
    +
    +

    报告已发布

    +

    已通知家长端 · 训练计划已自动关联下发

    +
    + + +
    +
    +
    +
    +
    + + +
    +
    +
    + +
      +
    • 📊 运营看板
    • +
    • 👶 儿童档案
    • +
    • 📋 报告复核
    • +
    • 📅 训练计划
    • +
    • 🎥 跟练打卡记录
    • +
    +
    +
    +
    训练计划
    +
    + + + + + + +
    儿童计划目标进度教练状态
    小明改善头前伸/高低肩3/28 天李教练进行中
    小红姿态维持14/28 天王教练进行中
    +

    💡 跟练打卡记录在侧栏「🎥 跟练打卡记录」· 点击小明跳转家长端

    +
    +
    +
    +
    + + +
    +
    +
    + +
      +
    • 📊 运营看板
    • +
    • 👶 儿童档案
    • +
    • 📋 报告复核
    • +
    • 📅 训练计划
    • +
    • 🎥 跟练打卡记录
    • +
    +
    +
    +
    +
    ← 返回 · 小明 · 跟练打卡 #3847
    + +
    +
    +
    +
    +

    训练概况

    +
    动作肩胛稳定训练
    +
    时长4:32
    +
    综合评分81 分
    +
    置信度89%
    +
    模型版本mediapipe-pose-v1
    +
    +
    +

    五维评分明细

    +
    动作轨迹 (30%)78
    +
    关节角度 (30%)72
    +
    节奏控制 (20%)85
    +
    稳定性 (10%)88
    +
    完成度 (10%)80
    +
    +
    +
    +

    实时纠正记录(帧级)

    + + + + + + + + + +
    时间点触发维度检测问题实时提醒置信度
    00:42关节角度头前伸 18°收下巴、颈后延展87%
    01:15动作轨迹左肩偏高 12mm两侧肩胛同步后收91%
    02:08节奏控制收放周期 3.2s放慢至 2 秒一拍85%
    02:30置信度降级遮挡 · 58%建议调整机位/教练复核58%
    03:20稳定性✓ 表现良好,保持94%
    +
    +
    +

    对比筛查基线

    +
    头前伸筛查78 → 本次15° ↓3°
    +
    高低肩轨迹12mm → 6mm ↓50%
    +
    完成次数8/8 次
    +
    +
    +

    Coach Agent 复核建议

    + +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + + + + diff --git a/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/03_技术架构/企业级系统架构.docx b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/03_技术架构/企业级系统架构.docx new file mode 100644 index 0000000..1873a19 Binary files /dev/null and b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/03_技术架构/企业级系统架构.docx differ diff --git a/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/03_技术架构/系统架构与建设规划.html b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/03_技术架构/系统架构与建设规划.html new file mode 100644 index 0000000..f1fe668 --- /dev/null +++ b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/03_技术架构/系统架构与建设规划.html @@ -0,0 +1,922 @@ + + + + + + 儿童 AI 体态管理平台 · 系统架构与建设规划 V1.0 + + + + + + +
    +
    商业交付包 V1.0 · 合伙人讨论稿
    +

    儿童 AI 体态管理平台

    +

    系统架构与建设规划 — 从 MVP 到商业试点的完整技术路线与落地节奏

    +
    + 版本 V1.0 + 2026-07-23 + 目标用户 3–16 岁 + 非医疗诊断 · 健康管理 +
    + +
    + + + + +
    + + +
    +
    +
    1
    +

    项目定位与建设目标

    +
    + +
    +

    我们在做什么

    +

    面向 3–16 岁儿童青少年 的 AI 体态管理与运动康复平台,围绕含胸驼背、头前伸、高低肩、骨盆姿态、膝内扣、足弓与运动协调等常见问题,提供 AI 筛查、结构化报告、训练计划、打卡复测与机构 SaaS 运营闭环。

    +
    + ⚠️ + 合规边界:平台输出用于健康管理与运动训练,不替代医院诊断。涉及疼痛、明显畸形、外伤或医生已明确限制运动的情况,必须提示线下专业评估。 +
    +
    + +
    +

    试点阶段核心指标

    +
    +
    ≥90%
    分析任务达成率
    +
    ≥80%
    报告 30 分钟内查看率
    +
    ≥60%
    14 天训练完成率
    +
    ≥50%
    60 天复测率
    +
    ≥20%
    报告→课程转化率
    +
    +
    +
    + + +
    +
    +
    2
    +

    总体系统架构

    +
    + +
    +

    分层架构

    +
    +
    +flowchart TB
    +    subgraph CLIENT["客户端层"]
    +        MP["微信小程序 / H5 家长端"]
    +        TR["儿童训练端"]
    +        AD["Web 管理后台"]
    +    end
    +
    +    subgraph GATEWAY["网关层"]
    +        GW["鉴权 JWT · 限流 · 审计 · requestId"]
    +    end
    +
    +    subgraph BIZ["业务服务层"]
    +        AUTH["Auth / User"]
    +        CHILD["Child Profile"]
    +        VIDEO["Video / Analysis"]
    +        TRAIN["Training"]
    +        COMM["Commerce"]
    +        OPS["Admin / Ops"]
    +    end
    +
    +    subgraph AI["AI 服务层(异步)"]
    +        VW["Video Worker"]
    +        POSE["Pose 服务"]
    +        SCORE["评分服务"]
    +        AGENT["Agent 解释"]
    +    end
    +
    +    subgraph DATA["数据设施层"]
    +        MYSQL[("MySQL 8.x")]
    +        REDIS[("Redis")]
    +        OSS[("对象存储 OSS")]
    +        LOG["日志 / 监控"]
    +    end
    +
    +    CLIENT --> GATEWAY --> BIZ
    +    BIZ --> AI
    +    BIZ --> DATA
    +    AI --> DATA
    +        
    +
    +
    + +
    +

    五大设计原则

    +
    +
    +

    ① 业务与 AI 解耦
    长任务异步化,HTTP 不阻塞,前端轮询/WebSocket 查状态。

    +
    +
    +

    ② 全链路可追溯
    保留关键点、指标、模型版本、Agent 提示词版本。

    +
    +
    +

    ③ 多租户隔离
    机构数据按 tenant_id 隔离,教练仅访问授权儿童。

    +
    +
    +

    ④ 接口契约优先
    以 openapi.yaml 为单一事实来源,向后兼容扩展。

    +
    +
    +

    ⑤ 幂等与审计
    写接口 Idempotency-Key;复核/退款/权限变更全量审计。

    +
    +
    +
    +
    + + +
    +
    +
    3
    +

    模块划分与职责

    +
    + +
    +

    业务服务模块

    + + + + + + + + + + + + +
    模块职责核心数据
    Auth/User手机/微信登录、角色、租户users, tenants
    Child Profile儿童档案、成长指标、授权children, child_measurements
    Video/Analysis直传签名、任务、报告videos, analysis_tasks, reports
    Training动作库、计划、打卡、复测exercises, training_plans
    Commerce套餐、订单、会员权益products, orders
    Admin/Ops线索、看板、审计operation_leads, audit_logs
    +
    + +
    +
    +

    AI 服务组件

    + + + + + + + + +
    组件技术
    Video WorkerFFmpeg + 队列
    Pose 服务MediaPipe Pose(33 关键点)
    评分服务规则引擎 + 加权算法
    Agent 服务LLM + 模板约束
    +
    +
    +

    Agent 角色分工

    + + + + + + + + +
    Agent输出
    Assessment风险摘要、复核建议
    Coach周期计划、每日动作
    Parent家长可读报告文案
    Operation线索分层、触达话术
    +
    +
    +
    + + +
    +
    +
    4
    +

    核心业务流程

    +
    + +
    +

    家长端主流程

    +
    +
    +flowchart LR
    +    A["授权登录"] --> B["创建档案"]
    +    B --> C["拍摄上传"]
    +    C --> D["创建分析任务"]
    +    D --> E["AI 异步处理"]
    +    E --> F["生成报告"]
    +    F --> G["查看建议"]
    +    G --> H["训练打卡"]
    +    H --> I["复测对比"]
    +        
    +
    +
    + +
    +

    AI 分析异步链路

    +
    +
    +sequenceDiagram
    +    participant P as 家长端
    +    participant API as 后端 API
    +    participant Q as 消息队列
    +    participant W as AI Worker
    +    participant A as Agent
    +
    +    P->>API: POST /api/analysis/tasks
    +    API->>Q: 入队 QUEUED
    +    API-->>P: 返回 taskId
    +
    +    W->>Q: 拉取任务
    +    W->>W: 抽帧 + Pose + 指标计算
    +    alt 置信度不足
    +        W->>API: FAILED + 建议重拍
    +    else 置信度合格
    +        W->>A: 生成报告文案
    +        A->>API: 写入 report
    +        API-->>P: 推送/轮询 SUCCEEDED
    +    end
    +        
    +
    +
    + +
    + 💡 + MVP 最小闭环:家长 2 分钟内完成建档 + 视频上传 → 5 分钟内收到 AI 报告 → 报告含风险等级、指标解释、建议动作、免责声明。 +
    +
    + + +
    +
    +
    5
    +

    技术选型建议

    +
    + +
    +

    MVP 推荐技术栈

    + + + + + + + + + + + + +
    层级推荐方案说明
    后端 APIPython FastAPI / Node.js NestJS与 OpenAPI 契约对齐
    任务队列Redis + Celery / BullMQ分析任务削峰、重试
    数据库MySQL 8.x已有完整 schema
    对象存储阿里云 OSS / MinIO视频直传、生命周期归档
    AI 推理MediaPipe Pose + Python Worker一期快速落地
    Agent大模型 API + Prompt 模板输出受控、版本化
    家长端微信小程序(Taro/uni-app)覆盖微信生态
    管理后台Vue 3 + Element Plus高密度信息布局
    +
    + +
    +

    环境规划

    + + + + + + + + +
    环境用途要求
    dev日常开发联调模拟支付、测试 OSS
    test测试验收回归数据可重置
    staging预发布/客户演示与生产同拓扑
    prod正式服务备份、监控、告警
    +
    +
    + + +
    +
    +
    6
    +

    分阶段建设规划

    +
    + +
    +
    +
    +

    P0 方案确认

    +
    1 周
    +

    范围、架构、数据、合规边界对齐。交付:PRD 签字、架构文档、接口/DB 契约、Git 工程初始化。

    +
    +
    +

    P1 MVP 研发

    +
    4–6 周
    +

    家长端 + 视频分析 + 报告 + 基础后台。交付:可演示版本、测试报告、staging 部署。

    +
    +
    +

    P2 试点交付

    +
    4 周
    +

    接入 1–3 家机构,跑通筛查→课程闭环。交付:试点数据、运营 SOP、教练培训。

    +
    +
    +

    P3 商业化增强

    +
    6–8 周
    +

    支付、权限、模型评估、生产运维。交付:商业版 V1.0、监控告警、备份演练。

    +
    +
    +
    + +
    +

    P1 MVP 六周排期

    + + + + + + + + + + +
    周次模块交付标准优先级
    W1工程初始化 + Auth + 儿童档案登录、建档 CRUD、DB 迁移Must
    W2视频上传链路OSS 直传、视频登记、拍摄引导 UIMust
    W3AI Worker 骨架抽帧 + MediaPipe + 基础指标Must
    W4报告生成 + Agent结构化 report + 家长文案Must
    W5家长端小程序建档→上传→看报告完整流程Must
    W6基础管理后台 + 联调报告列表、任务监控、冒烟测试Should
    +
    + 📌 + MVP 可后置:支付与会员、完整训练打卡、运营线索漏斗、多机构租户管理 → 放入 P2/P3。 +
    +
    +
    + + +
    +
    +
    7
    +

    团队分工建议

    +
    + +
    + + + + + + + + + +
    角色人数核心职责
    产品负责人1需求拆解、验收标准、试点反馈闭环
    前端工程师1小程序/H5、儿童训练页、Web 后台
    后端工程师1API、任务队列、权限、数据模型
    算法工程师1关键点、评分规则、质量检测、Agent 约束
    测试/交付0.5–1场景测试、部署验证、SOP 落地
    +
    +
    + + +
    +
    +
    8
    +

    安全与合规架构

    +
    + +
    +
    +

    数据分级

    + + + + + + + + +
    等级类型策略
    L1 敏感手机号、姓名、视频加密/哈希、脱敏
    L2 业务报告、训练、订单MySQL + 租户隔离
    L3 衍生关键点、指标 JSON绑定任务、保留版本
    L4 运营线索、看板聚合脱敏聚合
    +
    +
    +

    合规硬性约束

    +
      +
    • 报告不得出现确诊、治疗、治愈等医疗表述
    • +
    • Agent 输出引用输入字段,保留提示词版本
    • +
    • 低置信度 → 仅「建议重拍/教练复核」
    • +
    • 审计日志保留 ≥ 180 天
    • +
    • 写接口幂等保护(Idempotency-Key)
    • +
    +
    +
    +
    + + +
    +
    +
    9
    +

    风险与应对

    +
    + +
    + + + + + + + + + +
    风险影响应对策略
    AI 置信度不足报告不可用、信任低拍摄质量前置检测 + 降级文案
    医疗合规边界模糊法律风险PRD 约束 + Agent 过滤 + 人工复核
    MVP 范围膨胀延期交付严格 MoSCoW,支付/运营后置
    机构数据隔离合作受阻一期引入 tenant_id
    儿童个体差异大误报/漏报保留教练复核,不做全自动闭环
    +
    +
    + + +
    +
    +
    10
    +

    工程化进展(P3)

    +
    + +
    +
    +

    Monorepo 结构

    +
    happy-up/
    +├── contracts/     openapi + database.sql
    +├── apps/api/      FastAPI Starter
    +├── doc/           商业交付文档包
    +├── docker-compose.yml
    +└── Makefile
    +

    + make up 启动 MySQL/Redis · make api-dev 启动 API · Swagger /docs +

    +
    +
    +

    产品 Demo(P2 能力)

    +
      +
    • AI 实时跟练 · Ghost · 五维评分
    • +
    • WebRTC 摄像头预览(校准/跟练/拍摄)
    • +
    • 真实 PDF 导出(4 类报告)
    • +
    • 28 天趋势 · 三报告对比 · 儿童端
    • +
    + 进入 Demo +
    +
    + +
    +

    Sprint 1 下一步

    + + + + + + + + +
    序号任务交付标准
    1Alembic 迁移对接 database.sql 核心表
    2Auth JWT登录签发 + 鉴权中间件
    3儿童档案 CRUD落库 + 单元测试
    4分析任务队列Redis 入队 + Worker 骨架
    +
    +
    + + +
    +
    +
    11
    +

    近期行动清单(合伙人讨论)

    +
    + +
    +
    +

    本周 P0 — 需要合伙人拍板

    +
      +
    • 确认 MVP 范围与 6 周交付目标
    • +
    • 确认技术选型(FastAPI vs NestJS、云厂商)
    • +
    • 确认团队配置与外包边界
    • +
    • Git 初始化 + monorepo 目录结构
    • +
    • 从 openapi.yaml / database.sql 生成工程骨架
    • +
    +
    +
    +

    下周起 P1 — 研发启动

    +
      +
    • Sprint 1:Auth + 儿童档案 + DB 迁移
    • +
    • 确定 1 家种子机构作为试点需求来源
    • +
    • 算法完成 MediaPipe 本地验证(正/侧面站姿)
    • +
    • staging 环境搭建与 CI 流水线
    • +
    • 每周需求评审 + 风险评审机制启动
    • +
    +
    +
    +
    + +
    + + + 📱 产品 Demo + + +
    + 儿童 AI 体态管理与运动康复平台 · 商业交付包 V1.0 · 系统架构与建设规划
    + 配套 Markdown 文档:03_技术架构/系统架构与建设规划.md · 生成日期 2026-07-23 +
    + + + + 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 new file mode 100644 index 0000000..affa5d8 --- /dev/null +++ b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/03_技术架构/系统架构与建设规划.md @@ -0,0 +1,424 @@ +# 儿童 AI 体态管理平台 · 系统架构与建设规划 + +> **版本**:V1.0 · **日期**:2026-07-23 +> **适用阶段**:MVP → 商业试点 → 商业交付 V1.0 +> **主要读者**:创始团队、合伙人、产品、研发、运营、机构合作方 + +--- + +## 1. 项目定位与建设目标 + +### 1.1 产品定位 + +面向 **3–16 岁儿童青少年** 的 AI 体态管理与运动康复平台,围绕含胸驼背、头前伸、高低肩、骨盆姿态、膝内扣、足弓与运动协调等常见问题,提供: + +- **AI 筛查**(非医疗诊断) +- **结构化报告**与可解释建议 +- **训练计划**与打卡复测 +- **机构 SaaS** 运营与转化闭环 + +> **合规边界**:平台输出用于健康管理与运动训练,不替代医院诊断;涉及疼痛、明显畸形、外伤或医生已明确限制运动的情况,必须提示线下专业评估。 + +### 1.2 本期建设目标 + +| 维度 | 目标 | +|------|------| +| 业务闭环 | 建档 → 视频采集 → AI 分析 → 报告 → 训练 → 复测 → 机构转化 | +| 技术目标 | 业务与 AI 解耦、多租户隔离、全链路可追溯、异步分析 ≤ 5 分钟 | +| 商业目标 | 4–6 周可演示 MVP,8–10 周接入 1–3 家机构试点 | + +### 1.3 核心成功指标(试点阶段) + +| 指标 | 定义 | 目标 | +|------|------|------| +| 分析任务达成率 | 任务进入 PROCESSING 并生成报告或可解释失败 | ≥ 90% | +| 报告查看率 | 报告产生后 30 分钟内家长首次查看 | ≥ 80% | +| 训练任务完成率 | 14 天内计划任务完成比例 | ≥ 60% | +| 训练后复测率 | 60 天内完成复测的儿童比例 | ≥ 50% | +| 课程转化率 | 看完报告后进入课程/会员 | ≥ 20% | + +--- + +## 2. 总体系统架构 + +### 2.1 分层架构概览 + +系统采用 **云原生分层架构**:客户端层 → 网关层 → 业务服务层 → AI 服务层 → 数据设施层。 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 客户端层 (Client) │ +│ 微信小程序/H5 家长端 │ 儿童训练端 │ Web 管理后台 │ +└───────────────────────────────┬─────────────────────────────────┘ + │ HTTPS +┌───────────────────────────────▼─────────────────────────────────┐ +│ 网关层 (API Gateway) │ +│ 鉴权 JWT · 限流 · 审计 · 请求追踪 (requestId) │ +└───────────────────────────────┬─────────────────────────────────┘ + │ +┌───────────────────────────────▼─────────────────────────────────┐ +│ 业务服务层 (Business Services) │ +│ Auth/User │ Child Profile │ Video/Analysis │ Training │ Commerce │ +│ Admin/Ops │ +└───────────────┬─────────────────────────────┬───────────────────┘ + │ REST + 任务创建 │ 异步回调 +┌───────────────▼───────────────┐ ┌───────────▼───────────────────┐ +│ AI 服务层 (AI Layer) │ │ 消息队列 (Queue) │ +│ Video Worker │ Pose │ Score │◄──│ Redis/RabbitMQ │ +│ Agent 解释服务 │ └───────────────────────────────┘ +└───────────────┬───────────────┘ + │ +┌───────────────▼─────────────────────────────────────────────────┐ +│ 数据设施层 (Data Layer) │ +│ MySQL 8.x │ Redis │ 对象存储 OSS │ 向量记忆 │ 日志/监控平台 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 关键设计原则 + +1. **业务与 AI 解耦**:长任务异步化,前端只创建任务并轮询/WebSocket 查状态,不阻塞 HTTP 链路。 +2. **全链路可追溯**:保留原始关键点、计算指标、模型版本、Agent 提示词版本与审核状态。 +3. **多租户隔离**:机构数据按 `tenant_id` 隔离,教练仅访问授权儿童。 +4. **接口契约优先**:以 `openapi.yaml` 为单一事实来源,向后兼容扩展。 +5. **幂等与审计**:写接口支持 `Idempotency-Key`;报告复核、退款、权限变更全量审计。 + +--- + +## 3. 模块划分与职责 + +### 3.1 业务服务模块 + +| 模块 | 职责 | 核心数据表 | +|------|------|-----------| +| **Auth/User** | 手机/微信登录、角色、租户、隐私协议 | `users`, `tenants`, `users_passwords` | +| **Child Profile** | 儿童档案、成长指标、授权关系 | `children`, `child_measurements` | +| **Video/Analysis** | 直传签名、视频登记、分析任务、报告 | `videos`, `analysis_tasks`, `reports` | +| **Training** | 动作库、训练计划、打卡、复测 | `exercises`, `training_plans`, `training_records` | +| **Commerce** | 套餐、订单、会员权益 | `products`, `orders` | +| **Admin/Ops** | 运营线索、看板、审计、配置 | `operation_leads`, `audit_logs`, `data_retention_rules` | + +### 3.2 AI 服务模块 + +| 组件 | 输入 | 输出 | 技术选型 | +|------|------|------|----------| +| **Video Worker** | 对象存储视频 | 帧序列、质量检测结果 | FFmpeg + 队列消费 | +| **Pose 服务** | 图片/视频帧 | 33 关键点坐标 + 置信度 | MediaPipe Pose(一期) | +| **评分服务** | 关键点序列 | 体态指标、动作评分、风险等级 | 规则引擎 + 加权算法 | +| **Agent 服务** | 结构化指标 | 报告文案、训练建议、家长话术 | LLM + 模板约束 | + +#### Agent 角色分工 + +| Agent | 职责 | 输出 | +|-------|------|------| +| Assessment Agent | 解读视觉指标与置信度 | 风险摘要、重点指标、复核建议 | +| Coach Agent | 结合年龄与目标生成计划 | 周期计划、每日动作、进阶规则 | +| Parent Agent | 专业内容家长化翻译 | 报告文案、提醒、复测建议 | +| Operation Agent | 机构线索分层与触达 | 人群标签、话术、任务建议 | + +### 3.3 客户端模块 + +| 端 | 用户 | 核心页面/流程 | +|----|------|--------------| +| **小程序/H5 家长端** | 家长 | 登录 → 建档 → 拍摄引导上传 → 报告 → 训练计划 → 订单 | +| **儿童训练端** | 儿童/家长陪同 | 今日训练 → 动作演示 → 跟练计时 → 打卡反馈 | +| **Web 管理后台** | 教练/运营/管理员 | 线索 → 档案 → 报告复核 → 课程 → 数据看板 | + +--- + +## 4. 核心业务流程 + +### 4.1 家长端主流程 + +``` +授权登录 → 隐私协议确认 → 创建儿童档案 + → 按引导拍摄/upload-token 直传 OSS + → 登记视频 → 创建 analysis_task + → 异步 AI 处理(Pose + 评分 + Agent) + → 生成 report → 家长查看 + → 购买/领取 training_plan → 每日打卡 → 复测 +``` + +### 4.2 AI 分析异步链路 + +``` +POST /api/analysis/tasks (返回 taskId) + │ + ▼ + 队列 QUEUED → Worker 拉取 + │ + ▼ + PROCESSING: 抽帧 → Pose → 指标计算 → 质量校验 + │ + ├── 置信度不足 → FAILED + 建议重拍 + │ + ▼ + Agent 生成报告文案 → 写入 reports + │ + ▼ + Webhook 回调后端 → status=SUCCEEDED + reportId + │ + ▼ + 家长端轮询/推送 → 展示报告 +``` + +### 4.3 教练复核流程(试点阶段) + +``` +AI 报告 (draft) → 教练后台复核 → 调整建议/关联课程 + → 报告 published → 下发家长端 → 跟踪训练与复测 +``` + +--- + +## 5. 技术选型建议 + +### 5.1 推荐技术栈(MVP 阶段) + +| 层级 | 推荐方案 | 说明 | +|------|----------|------| +| **后端 API** | Python FastAPI 或 Node.js NestJS | 与 OpenAPI 契约对齐,异步友好 | +| **任务队列** | Redis + Celery / BullMQ | 分析任务削峰、重试 | +| **数据库** | MySQL 8.x | 已有完整 schema(`database.sql`) | +| **缓存** | Redis | Session、限流、队列、热点数据 | +| **对象存储** | 阿里云 OSS / MinIO(dev) | 视频直传、生命周期归档 | +| **AI 推理** | MediaPipe Pose + Python Worker | 一期快速落地,后续可替换模型 | +| **Agent** | OpenAI API / 国产大模型 + Prompt 模板 | 输出受控,版本化管理 | +| **家长端** | 微信小程序(Taro/uni-app) | 覆盖微信生态 | +| **管理后台** | Vue 3 + Element Plus | 高密度信息布局 | +| **网关** | Nginx / Kong | 鉴权、限流、TLS 终结 | +| **监控** | Prometheus + Grafana / 云监控 | 业务 + AI 任务双维度 | + +### 5.2 环境规划 + +| 环境 | 用途 | 要求 | +|------|------|------| +| dev | 日常开发联调 | 模拟支付、测试 OSS | +| test | 测试验收回归 | 数据可重置,配置接近生产 | +| staging | 预发布/客户演示 | 与生产同版本、同拓扑 | +| prod | 正式服务 | 备份、监控、告警、权限收敛 | + +--- + +## 6. 分阶段建设规划 + +### 6.1 阶段总览 + +| 阶段 | 周期 | 目标 | 关键交付物 | +|------|------|------|-----------| +| **P0 方案确认** | 1 周 | 范围、架构、数据、合规边界对齐 | PRD 签字、架构文档、接口/DB 契约、Git 工程初始化 | +| **P1 MVP 研发** | 4–6 周 | 家长端 + 视频分析 + 报告 + 基础后台 | 可演示版本、测试报告、staging 部署 | +| **P2 试点交付** | 4 周 | 接入 1–3 家机构,跑通筛查→课程闭环 | 试点数据、运营 SOP、教练培训材料 | +| **P3 商业化增强** | 6–8 周 | 支付、权限、模型评估、生产运维 | 商业版 V1.0、监控告警、备份演练 | + +### 6.2 P1 MVP 详细排期(建议) + +| 周次 | 模块 | 交付标准 | +|------|------|----------| +| W1 | 工程初始化 + Auth + 儿童档案 | 登录、建档 CRUD、DB 迁移跑通 | +| W2 | 视频上传链路 | OSS 直传、视频登记、质量预检 UI | +| W3 | AI Worker 骨架 | 抽帧 + MediaPipe + 基础指标输出 | +| W4 | 报告生成 + Agent | 结构化 report + 家长可读文案 | +| W5 | 家长端小程序 | 建档→上传→看报告完整流程 | +| W6 | 基础管理后台 + 联调 | 报告列表、任务监控、冒烟测试 | + +**MVP 最小闭环(Must Have):** + +> 家长 2 分钟内完成建档 + 视频上传 → 5 分钟内收到 AI 报告 → 报告含风险等级、指标解释、建议动作、免责声明 + +**MVP 可后置(Should/Could):** + +- 支付与会员 +- 完整训练打卡 +- 运营线索漏斗 +- 多机构租户管理 + +### 6.3 团队分工建议 + +| 角色 | 人数 | 职责 | +|------|------|------| +| 产品负责人 | 1 | 需求拆解、验收标准、试点反馈闭环 | +| 前端工程师 | 1 | 小程序/H5、儿童训练页、Web 后台 | +| 后端工程师 | 1 | API、任务队列、权限、数据模型 | +| 算法工程师 | 1 | 关键点、评分规则、质量检测、Agent 约束 | +| 测试/交付 | 0.5–1 | 场景测试、部署验证、SOP 落地 | + +--- + +## 7. 数据与安全架构 + +### 7.1 数据分层 + +| 等级 | 数据类型 | 存储策略 | +|------|----------|----------| +| L1 敏感 | 手机号、儿童姓名、视频原片 | 加密/哈希、脱敏展示、访问审计 | +| L2 业务 | 报告、训练记录、订单 | MySQL + 租户隔离 | +| L3 衍生 | 关键点、指标 JSON、Agent 输出 | 与任务绑定,保留模型版本 | +| L4 运营 | 线索、看板聚合 | 脱敏聚合,按租户隔离 | + +### 7.2 安全与合规要点 + +- 报告 **不得** 出现确诊、治疗、治愈等医疗表述 +- Agent 输出引用输入字段,保留提示词版本 +- 低置信度 → 仅输出「建议重拍/教练复核」,不给强结论 +- 审计日志保留 ≥ 180 天;视频/报告按 `retention_days` 归档 +- 写接口幂等保护(`api_idempotency_keys` 表) + +--- + +## 8. 部署拓扑 + +### 8.1 MVP 单区域部署 + +``` + ┌─────────────┐ + 用户 ──────────► │ Nginx │ + │ API Gateway│ + └──────┬──────┘ + │ + ┌────────────┼────────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ API 服务 │ │AI Worker │ │ 后台服务 │ + └────┬─────┘ └────┬─────┘ └────┬─────┘ + │ │ │ + ┌────┴────────────┴────────────┴────┐ + │ MySQL │ Redis │ OSS │ 日志 │ + └───────────────────────────────────┘ +``` + +### 8.2 商业化阶段演进 + +- AI Worker、视频转码、报告生成 **拆分为独立队列消费者** +- 引入灰度发布、多环境配置、自动扩缩容 +- 模型/评分规则/报告模板 **版本化独立发布** + +--- + +## 9. 风险与应对 + +| 风险 | 影响 | 应对策略 | +|------|------|----------| +| AI 置信度不足导致报告不可用 | 用户体验差、机构信任低 | 拍摄质量前置检测 + 低置信度降级文案 | +| 医疗合规边界模糊 | 法律风险 | PRD 硬性约束 + Agent 过滤 + 人工复核 | +| MVP 范围膨胀 | 延期交付 | 严格 MoSCoW,支付/运营后置 | +| 机构试点数据隔离 | 合作受阻 | 一期即引入 tenant_id,权限最小化 | +| 算法儿童个体差异 | 误报/漏报 | 保留教练复核环节,不做全自动闭环 | + +--- + +## 10. 工程化 Monorepo(P3) + +### 10.1 仓库结构 + +``` +happy-up/ +├── contracts/ # openapi.yaml + database.sql(从 doc/ 同步) +├── apps/api/ # FastAPI 后端 Starter +├── doc/ # 商业交付文档包 V1.0 +├── docker-compose.yml # MySQL 8 + Redis 7 +└── Makefile +``` + +### 10.2 产品 Demo 能力(P1/P2) + +| 能力 | 状态 | 说明 | +|------|------|------| +| AI 实时跟练打卡 | ✅ | Ghost 叠加 · Coach Agent cue · 五维评分 | +| WebRTC 摄像头预览 | ✅ | 机位校准/跟练/拍摄引导页 | +| 真实 PDF 导出 | ✅ | 筛查/跟练/趋势/三报告对比 | +| 28 天趋势 + 三报告对比 | ✅ | 商业闭环可视化 | +| 儿童训练端 | ✅ | 大按钮 + 儿童版 cue | + +入口:`doc/.../03_技术架构/产品交互Demo.html` + +### 10.3 API Starter + +- **框架**:FastAPI + Pydantic v2 + SQLAlchemy 2 +- **契约对齐**:路由覆盖 `openapi.yaml` 全部 path(当前 Mock 数据,与 Demo 一致) +- **统一响应**:`StandardResponse` + `requestId` 中间件 +- **本地依赖**:`make up` 启动 MySQL/Redis,`make api-dev` 启动 API + +详见仓库根目录 `README.md` 与 `apps/api/README.md`。 + +### 10.4 Sprint 1 已完成 + +- JWT 登录(验证码 682139)+ Bearer 鉴权 +- 儿童档案 CRUD 落库(MySQL) +- Alembic baseline + `make api-seed` +- 产品 Demo **API 联调模式**(登录 + 首页档案) + +### 10.5 Sprint 2 已完成 + +- 视频直传凭证 + 登记落库(`videos` 表) +- 分析任务 Redis 队列 + Worker 骨架 + 内联处理(开发模式) +- 报告生成落库(`reports` 表 · 对齐 Demo 指标) +- Demo **筛查全流程 API 联调**(上传 → 分析 → 报告) + +### 10.6 Sprint 3 已完成 + +- 训练计划 CRUD + 打卡记录落库(`training_plans` / `training_records`) +- Admin 运营看板真实 DB 聚合 +- Demo 全链路 API 联调(含训练计划页 + 打卡写入 + 后台 KPI) +- `apps/web/` 前端工程占位 + +### 10.7 Sprint 4 已完成 + +- 本地直传 + MinIO 预签名 URL(`OSS_ENABLED=true` + `docker compose` MinIO) +- `PUT /api/videos/direct-upload` 本地上传回退 +- MediaPipe 分析骨架(`pip install -e ".[ai]"` 可选) +- `apps/web` Vue 3 H5(登录 / 档案 / 筛查联调) +- Admin 看板按 `tenant_id` 隔离聚合 + +### 10.8 Sprint 5 已完成 + +- MediaPipe 帧级评分(`pose_metrics.py` 纯函数 + OpenCV 抽帧) +- 服务端 PDF 导出 `GET /api/reports/{id}/pdf` +- OSS 生产配置:`OSS_PROVIDER=aliyun` + `OSS_CDN_BASE_URL` +- H5 四 Tab UI(首页/筛查/报告/训练)对齐 Demo 配色 +- 微信小程序壳 `apps/mini` + +### 10.9 Sprint 6 下一步 + +1. 真实视频数据集标定与模型精度调优 +2. 小程序完整 Tab + 跟练页 +3. 报告复核工作流 + 消息通知 +4. 生产部署(K8s / CI-CD) + +--- + +## 11. 近期行动清单(合伙人讨论用) + +### 本周(P0) + +- [ ] 合伙人确认 MVP 范围与 6 周交付目标 +- [ ] 确认技术选型(FastAPI vs NestJS、云厂商) +- [ ] 确认团队配置与外包边界 +- [x] Git 初始化 + monorepo 目录结构 +- [x] 从 `openapi.yaml` / `database.sql` 生成工程骨架 + +### 下周起(P1 启动) + +- [ ] Sprint 1:Auth + 儿童档案 + DB 迁移 +- [ ] 确定 1 家种子机构作为试点需求来源 +- [ ] 算法同学完成 MediaPipe 本地验证(正面/侧面站姿) + +--- + +## 附录:文档索引 + +| 文档 | 路径 | +|------|------| +| 产品 PRD | `02_企业级PRD/PRD完整版.docx` | +| 企业级架构 | `03_技术架构/企业级系统架构.docx` | +| **本架构与建设规划** | `03_技术架构/系统架构与建设规划.md` | +| **合伙人讨论 HTML** | `03_技术架构/系统架构与建设规划.html` | +| 数据库设计 | `06_数据库设计/database.sql` | +| API 契约 | `07_API接口/openapi.yaml` | +| AI 算法设计 | `04_AI视觉算法/AI视觉算法详细设计.docx` | +| Agent 规范 | `08_Agent智能系统/Agent设计规范.docx` | +| 研发计划 | `12_研发计划/研发计划与项目管理.docx` | +| 部署运维 | `14_部署运维/生产部署运维.docx` | + +--- + +*本文档基于商业交付包 V1.0 现有材料整理,用于创始团队与合伙人架构讨论及研发启动对齐。* diff --git a/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/04_AI视觉算法/AI视觉算法详细设计.docx b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/04_AI视觉算法/AI视觉算法详细设计.docx new file mode 100644 index 0000000..f4a7595 Binary files /dev/null and b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/04_AI视觉算法/AI视觉算法详细设计.docx differ diff --git a/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/05_视频处理系统/视频处理工程设计.docx b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/05_视频处理系统/视频处理工程设计.docx new file mode 100644 index 0000000..cc6b53d Binary files /dev/null and b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/05_视频处理系统/视频处理工程设计.docx differ diff --git a/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/06_数据库设计/database.sql b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/06_数据库设计/database.sql new file mode 100644 index 0000000..3d03bdb --- /dev/null +++ b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/06_数据库设计/database.sql @@ -0,0 +1,316 @@ +-- Kids AI Posture Platform V1.0 database schema +-- MySQL 8.x +-- 目标:满足审计、幂等、治理、隐私与可追溯性要求 + +CREATE TABLE tenants ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(128) NOT NULL, + type VARCHAR(32) NOT NULL DEFAULT 'organization', + status VARCHAR(32) NOT NULL DEFAULT 'active', + retention_days INT NOT NULL DEFAULT 1095, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CHECK (type IN ('organization','school','clinic','mall','other')), + CHECK (status IN ('active','inactive','frozen','closed')), + CHECK (retention_days BETWEEN 30 AND 3650) +); + +CREATE TABLE users ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT NULL, + phone VARCHAR(32), + phone_hash VARCHAR(128), + wechat_openid VARCHAR(128), + role VARCHAR(32) NOT NULL DEFAULT 'parent', + status VARCHAR(32) NOT NULL DEFAULT 'active', + consent_signed BOOLEAN NOT NULL DEFAULT FALSE, + privacy_version VARCHAR(16) DEFAULT 'v1', + last_login_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_users_tenant_role (tenant_id, role), + UNIQUE KEY uk_users_phone_hash (phone_hash), + UNIQUE KEY uk_users_wechat_openid (wechat_openid), + CHECK (role IN ('parent','coach','org_admin','platform_admin')), + CHECK (status IN ('active','inactive','blocked')) +); + +CREATE TABLE users_passwords ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT NOT NULL, + password_hash VARCHAR(256) NOT NULL, + salt VARCHAR(64) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_user_password (user_id) +); + +CREATE TABLE children ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT NULL, + parent_user_id BIGINT NOT NULL, + coach_user_id BIGINT NULL, + name VARCHAR(64) NOT NULL, + gender VARCHAR(16) NOT NULL DEFAULT 'unknown', + birthday DATE NOT NULL, + height DECIMAL(5,2), + weight DECIMAL(5,2), + contraindications TEXT, + status VARCHAR(32) NOT NULL DEFAULT 'active', + deleted_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_children_parent (parent_user_id), + KEY idx_children_tenant_coach (tenant_id, coach_user_id), + KEY idx_children_status (status), + CHECK (gender IN ('male','female','unknown')), + CHECK (status IN ('active','inactive','archived')), + CHECK (height IS NULL OR (height > 20 AND height < 240)), + CHECK (weight IS NULL OR (weight > 5 AND weight < 250)) +); + +CREATE TABLE child_measurements ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + child_id BIGINT NOT NULL, + height DECIMAL(5,2), + weight DECIMAL(5,2), + source VARCHAR(32) NOT NULL DEFAULT 'manual', + measured_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_measurements_child_time (child_id, measured_at), + CHECK (source IN ('manual','device','coach','report')), + CHECK (height IS NULL OR (height > 20 AND height < 240)), + CHECK (weight IS NULL OR (weight > 5 AND weight < 250)) +); + +CREATE TABLE videos ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + child_id BIGINT NOT NULL, + uploaded_by BIGINT NOT NULL, + scene VARCHAR(64) NOT NULL, + object_key VARCHAR(512) NOT NULL, + duration_seconds INT, + width INT, + height INT, + size_bytes BIGINT, + codec VARCHAR(32), + quality_result JSON, + status VARCHAR(32) NOT NULL DEFAULT 'uploaded', + archive_until_date DATE NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_videos_child_scene (child_id, scene), + KEY idx_videos_status (status), + UNIQUE KEY uk_videos_object_key (object_key), + CHECK (scene IN ('front_posture','side_posture','squat','balance','gait','custom')), + CHECK (status IN ('uploaded','transcoding','failed','ready','archived')), + CHECK (duration_seconds IS NULL OR duration_seconds BETWEEN 1 AND 1800), + CHECK (size_bytes IS NULL OR size_bytes BETWEEN 1 AND 10737418240) +); + +CREATE TABLE analysis_tasks ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + child_id BIGINT NOT NULL, + video_id BIGINT NOT NULL, + task_type VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'CREATED', + progress INT NOT NULL DEFAULT 0, + model_version VARCHAR(64), + error_code VARCHAR(64), + error_message VARCHAR(512), + result JSON, + idempotency_key VARCHAR(128), + retry_count INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + started_at TIMESTAMP NULL, + finished_at TIMESTAMP NULL, + UNIQUE KEY uk_analysis_idempotency (child_id, video_id, task_type, idempotency_key), + KEY idx_analysis_child_status (child_id, status), + KEY idx_analysis_video (video_id), + KEY idx_analysis_created (created_at), + CHECK (status IN ('CREATED','QUEUED','PROCESSING','SUCCEEDED','FAILED','CANCELLED')), + CHECK (task_type IN ('posture_screening','movement_scoring','reassessment')), + CHECK (progress BETWEEN 0 AND 100), + CHECK (retry_count BETWEEN 0 AND 20) +); + +CREATE TABLE reports ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + child_id BIGINT NOT NULL, + task_id BIGINT NOT NULL, + report_type VARCHAR(64) NOT NULL DEFAULT 'posture_screening', + risk_level VARCHAR(32) NOT NULL, + summary TEXT NOT NULL, + metrics JSON NOT NULL, + recommendations JSON NOT NULL, + disclaimer TEXT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'draft', + reviewed_by BIGINT NULL, + reviewed_at TIMESTAMP NULL, + published_at TIMESTAMP NULL, + expires_at DATE NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_reports_task (task_id), + KEY idx_reports_child_status (child_id, status), + CHECK (risk_level IN ('low','medium','high','review_required')), + CHECK (status IN ('draft','published','retracted','archived')) +); + +CREATE TABLE exercises ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(128) NOT NULL, + category VARCHAR(64) NOT NULL, + target_issue VARCHAR(128), + difficulty VARCHAR(32) NOT NULL DEFAULT 'basic', + duration_seconds INT NOT NULL DEFAULT 60, + media_url VARCHAR(512), + thumbnail_url VARCHAR(512), + rules JSON, + status VARCHAR(32) NOT NULL DEFAULT 'active', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_exercises_category (category, status), + CHECK (difficulty IN ('basic','intermediate','advanced')), + CHECK (status IN ('active','offline','deprecated')), + CHECK (duration_seconds BETWEEN 20 AND 1800) +); + +CREATE TABLE training_plans ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + child_id BIGINT NOT NULL, + report_id BIGINT NULL, + coach_user_id BIGINT NULL, + goal VARCHAR(256) NOT NULL, + cycle_days INT NOT NULL DEFAULT 28, + status VARCHAR(32) NOT NULL DEFAULT 'draft', + plan_detail JSON NOT NULL, + started_at TIMESTAMP NULL, + ended_at TIMESTAMP NULL, + deleted_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_training_plans_child_status (child_id, status), + CHECK (cycle_days BETWEEN 7 AND 180), + CHECK (status IN ('draft','active','completed','paused','cancelled')) +); + +CREATE TABLE training_records ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + plan_id BIGINT NOT NULL, + child_id BIGINT NOT NULL, + exercise_id BIGINT NOT NULL, + completed BOOLEAN NOT NULL DEFAULT FALSE, + score INT, + duration_seconds INT, + feedback JSON, + note VARCHAR(512), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_training_records_plan (plan_id), + KEY idx_training_records_child_time (child_id, created_at), + CHECK (score IS NULL OR score BETWEEN 0 AND 100), + CHECK (duration_seconds IS NULL OR duration_seconds BETWEEN 1 AND 7200) +); + +CREATE TABLE products ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT NULL, + name VARCHAR(128) NOT NULL, + product_type VARCHAR(32) NOT NULL, + price_cents INT NOT NULL, + duration_days INT NULL, + benefits JSON NOT NULL, + limit_child INTEGER NOT NULL DEFAULT 1, + status VARCHAR(32) NOT NULL DEFAULT 'active', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (product_type IN ('video_pack','training_plan','membership','assessment_bundle','course_package')), + CHECK (status IN ('active','inactive','discontinued')), + CHECK (price_cents >= 0), + CHECK (duration_days IS NULL OR duration_days BETWEEN 1 AND 3650) +); + +CREATE TABLE orders ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT NOT NULL, + child_id BIGINT NULL, + product_id BIGINT NOT NULL, + amount_cents INT NOT NULL, + pay_status VARCHAR(32) NOT NULL DEFAULT 'pending', + payment_channel VARCHAR(32), + paid_at TIMESTAMP NULL, + canceled_at TIMESTAMP NULL, + refund_status VARCHAR(32) DEFAULT 'none', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_orders_user_status (user_id, pay_status), + KEY idx_orders_child (child_id), + CHECK (pay_status IN ('pending','paid','failed','closed','refunding','refunded')), + CHECK (refund_status IN ('none','requested','processing','completed','rejected')), + CHECK (amount_cents >= 0) +); + +CREATE TABLE operation_leads ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + child_id BIGINT NULL, + source VARCHAR(64) NOT NULL, + stage VARCHAR(32) NOT NULL DEFAULT 'new', + owner_user_id BIGINT NULL, + score INT DEFAULT 0, + priority TINYINT DEFAULT 3, + next_follow_at TIMESTAMP NULL, + note TEXT, + touched_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_leads_tenant_stage (tenant_id, stage), + KEY idx_leads_owner_next (owner_user_id, next_follow_at), + CHECK (source IN ('wechat','h5','miniapp','partner','referral','offline_campaign')), + CHECK (stage IN ('new','contacted','qualified','closed','lost','churned')), + CHECK (priority BETWEEN 1 AND 5), + CHECK (score BETWEEN 0 AND 100) +); + +CREATE TABLE audit_logs ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT NULL, + actor_user_id BIGINT NOT NULL, + action VARCHAR(128) NOT NULL, + resource_type VARCHAR(64) NOT NULL, + resource_id BIGINT NULL, + request_id VARCHAR(64), + ip VARCHAR(64), + user_agent VARCHAR(256), + payload JSON, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_audit_actor_time (actor_user_id, created_at), + KEY idx_audit_resource (resource_type, resource_id) +); + +CREATE TABLE data_retention_rules ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT NULL, + resource_type VARCHAR(64) NOT NULL, + archive_after_days INT NOT NULL, + delete_after_days INT NOT NULL, + legal_basis VARCHAR(128), + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_retention (tenant_id, resource_type), + CHECK (delete_after_days >= archive_after_days), + CHECK (archive_after_days >= 30) +); + +CREATE TABLE api_idempotency_keys ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + scope VARCHAR(32) NOT NULL, + idempotency_key VARCHAR(128) NOT NULL, + request_path VARCHAR(255) NOT NULL, + request_hash CHAR(64) NOT NULL, + response_status INT, + response_body JSON, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_idempotency_scope_key (scope, idempotency_key), + KEY idx_idempotency_created (created_at) +); + +-- 运行时可选索引补充说明(按需执行) +-- CREATE INDEX idx_analysis_status_updated ON analysis_tasks(status, updated_at); +-- CREATE INDEX idx_reports_risk_level ON reports(risk_level, created_at); +-- CREATE INDEX idx_orders_pay_status_updated ON orders(pay_status, updated_at); diff --git a/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/07_API接口/openapi.yaml b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/07_API接口/openapi.yaml new file mode 100644 index 0000000..e474e46 --- /dev/null +++ b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/07_API接口/openapi.yaml @@ -0,0 +1,670 @@ +openapi: 3.0.3 +info: + title: Kids AI Posture Platform API + version: 1.0.0 + description: | + 儿童AI体态管理与运动康复平台 V1.0 API 契约。 + 所有报告用于健康管理建议,不构成医疗诊断。接口返回统一包含 requestId 以便追踪。 +servers: + - url: https://api.example.com + description: production + - url: https://staging-api.example.com + description: staging +security: + - bearerAuth: [] + +x-error-model: &error-model + code: integer + message: string + details: + type: object + additionalProperties: true + requestId: string + +tags: + - name: Auth + - name: Children + - name: Videos + - name: Analysis + - name: Reports + - name: Training + - name: Admin + +paths: + /api/auth/login: + post: + tags: [Auth] + summary: 手机号或微信授权登录 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + examples: + phone: + summary: 手机验证码 + value: + loginType: phone_code + credential: "186xxxx0000" + code: "682139" + wechat: + summary: 微信授权码 + value: + loginType: wechat + credential: "wx_auth_code" + responses: + '200': + description: 登录成功 + content: + application/json: + schema: + $ref: '#/components/schemas/LoginResponse' + '401': + description: 登录失败 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/children: + get: + tags: [Children] + summary: 获取当前用户可访问的儿童档案 + parameters: + - name: page + in: query + schema: { type: integer, minimum: 1, default: 1 } + - name: pageSize + in: query + schema: { type: integer, minimum: 1, maximum: 100, default: 20 } + responses: + '200': + description: 儿童档案分页列表 + content: + application/json: + schema: + $ref: '#/components/schemas/ChildrenPagedResponse' + post: + tags: [Children] + summary: 创建儿童档案 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChildCreateRequest' + responses: + '201': + description: 创建成功 + headers: + Idempotency-Key: + description: 冪等键回显,用于重试去重核对 + schema: { type: string } + content: + application/json: + schema: + $ref: '#/components/schemas/ChildResponse' + '400': + description: 请求参数错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/children/{childId}: + parameters: + - $ref: '#/components/parameters/ChildId' + get: + tags: [Children] + summary: 获取儿童档案详情 + responses: + '200': + description: 档案详情 + content: + application/json: + schema: + $ref: '#/components/schemas/ChildResponse' + '404': + description: 资源不存在 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + patch: + tags: [Children] + summary: 更新儿童档案 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChildUpdateRequest' + responses: + '200': + description: 更新成功 + content: + application/json: + schema: + $ref: '#/components/schemas/ChildResponse' + '409': + description: 更新冲突(数据并发) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + tags: [Children] + summary: 注销/归档儿童档案 + responses: + '204': + description: 归档成功 + + /api/videos/upload-token: + post: + tags: [Videos] + summary: 获取视频直传凭证 + description: 建议请求头携带 Idempotency-Key,避免重试产生重复录像对象。 + parameters: + - name: Idempotency-Key + in: header + required: false + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UploadTokenRequest' + responses: + '200': + description: 上传凭证 + content: + application/json: + schema: + $ref: '#/components/schemas/UploadTokenResponse' + '429': + description: 请求过于频繁 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/videos: + post: + tags: [Videos] + summary: 登记已上传视频 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VideoCreateRequest' + responses: + '201': + description: 视频登记成功 + content: + application/json: + schema: + $ref: '#/components/schemas/VideoResponse' + '409': + description: 去重/重复提交 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/analysis/tasks: + post: + tags: [Analysis] + summary: 创建AI分析任务 + description: 同一 childId+videoId+taskType 可复用幂等键创建。 + parameters: + - name: Idempotency-Key + in: header + required: false + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AnalysisTaskCreateRequest' + responses: + '201': + description: 任务创建成功 + content: + application/json: + schema: + $ref: '#/components/schemas/AnalysisTaskResponse' + '409': + description: 重复任务,返回已有任务 + content: + application/json: + schema: + $ref: '#/components/schemas/AnalysisTaskResponse' + + /api/analysis/tasks/{taskId}: + get: + tags: [Analysis] + summary: 查询分析任务状态 + parameters: + - $ref: '#/components/parameters/TaskId' + responses: + '200': + description: 任务状态 + content: + application/json: + schema: + $ref: '#/components/schemas/AnalysisTaskResponse' + '404': + description: 任务不存在 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/analysis/tasks/{taskId}/cancel: + post: + tags: [Analysis] + summary: 取消分析任务 + parameters: + - $ref: '#/components/parameters/TaskId' + responses: + '200': + description: 取消成功 + content: + application/json: + schema: + $ref: '#/components/schemas/StandardResponse' + + /api/analysis/webhook: + post: + tags: [Analysis] + summary: 分析结果回调(异步) + description: 系统内部回调给后端事件网关,需使用 HMAC 签名。 + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AnalysisTaskWebhook' + responses: + '204': + description: 接收成功 + + /api/reports/{reportId}: + get: + tags: [Reports] + summary: 获取体态分析报告 + parameters: + - $ref: '#/components/parameters/ReportId' + responses: + '200': + description: 报告详情 + content: + application/json: + schema: + $ref: '#/components/schemas/ReportResponse' + + /api/training/plans: + post: + tags: [Training] + summary: 创建训练计划 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TrainingPlanCreateRequest' + responses: + '201': + description: 训练计划创建成功 + content: + application/json: + schema: + $ref: '#/components/schemas/TrainingPlanResponse' + + /api/training/plans/{planId}: + parameters: + - name: planId + in: path + required: true + schema: + type: integer + get: + tags: [Training] + summary: 获取训练计划 + responses: + '200': + description: 训练计划详情 + content: + application/json: + schema: + $ref: '#/components/schemas/TrainingPlanResponse' + + /api/training/plans/{planId}/records: + post: + tags: [Training] + summary: 提交训练打卡记录 + parameters: + - name: planId + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TrainingRecordCreateRequest' + responses: + '201': + description: 打卡成功 + content: + application/json: + schema: + $ref: '#/components/schemas/TrainingRecordResponse' + + /api/admin/dashboard: + get: + tags: [Admin] + summary: 获取机构运营看板 + responses: + '200': + description: 看板指标 + content: + application/json: + schema: + $ref: '#/components/schemas/AdminDashboardResponse' + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + parameters: + ChildId: + name: childId + in: path + required: true + schema: { type: integer } + TaskId: + name: taskId + in: path + required: true + schema: { type: integer } + ReportId: + name: reportId + in: path + required: true + schema: { type: integer } + schemas: + ApiMeta: + type: object + properties: + requestId: { type: string } + timestamp: { type: string, format: date-time } + traceId: { type: string } + StandardResponse: + type: object + properties: + code: { type: integer } + message: { type: string } + data: { type: object, nullable: true } + meta: { $ref: '#/components/schemas/ApiMeta' } + ErrorResponse: + allOf: + - $ref: '#/components/schemas/StandardResponse' + - type: object + properties: + code: { type: integer, example: 10003 } + message: { type: string, example: parameter_validation_failed } + data: + type: object + properties: + errorCode: { type: string } + path: { type: string } + PagedMeta: + type: object + properties: + page: { type: integer } + pageSize: { type: integer } + total: { type: integer } + hasMore: { type: boolean } + LoginRequest: + type: object + required: [loginType, credential] + properties: + loginType: { type: string, enum: [phone_code, wechat] } + credential: { type: string } + code: { type: string } + LoginResponse: + allOf: + - $ref: '#/components/schemas/StandardResponse' + - type: object + properties: + data: + type: object + properties: + token: { type: string } + user: { $ref: '#/components/schemas/User' } + User: + type: object + properties: + id: { type: integer } + phoneMasked: { type: string } + role: { type: string, enum: [parent, coach, org_admin, platform_admin] } + ChildCreateRequest: + type: object + required: [name, birthday] + properties: + name: { type: string } + birthday: { type: string, format: date } + gender: { type: string, enum: [male, female, unknown] } + height: { type: number } + weight: { type: number } + contraindications: { type: string } + ChildUpdateRequest: + allOf: + - $ref: '#/components/schemas/ChildCreateRequest' + Child: + type: object + properties: + id: { type: integer } + name: { type: string } + birthday: { type: string, format: date } + age: { type: integer } + height: { type: number } + weight: { type: number } + status: { type: string } + ChildResponse: + allOf: + - $ref: '#/components/schemas/StandardResponse' + - type: object + properties: + data: { $ref: '#/components/schemas/Child' } + ChildrenPagedResponse: + allOf: + - $ref: '#/components/schemas/StandardResponse' + - type: object + properties: + data: + type: object + properties: + list: + type: array + items: { $ref: '#/components/schemas/Child' } + page: { $ref: '#/components/schemas/PagedMeta' } + UploadTokenRequest: + type: object + required: [childId, fileName, contentType, size] + properties: + childId: { type: integer } + fileName: { type: string } + contentType: { type: string, example: video/mp4 } + size: { type: integer } + UploadTokenResponse: + allOf: + - $ref: '#/components/schemas/StandardResponse' + - type: object + properties: + data: + type: object + properties: + uploadUrl: { type: string } + objectKey: { type: string } + expireAt: { type: string, format: date-time } + VideoCreateRequest: + type: object + required: [childId, objectKey, scene] + properties: + childId: { type: integer } + objectKey: { type: string } + scene: + type: string + enum: [front_posture, side_posture, squat, balance, gait] + captureHint: { type: string } + VideoResponse: + allOf: + - $ref: '#/components/schemas/StandardResponse' + - type: object + properties: + data: + type: object + properties: + id: { type: integer } + status: { type: string, enum: [uploaded, rejected, archived] } + AnalysisTaskCreateRequest: + type: object + required: [childId, videoId, taskType] + properties: + childId: { type: integer } + videoId: { type: integer } + taskType: { type: string, enum: [posture_screening, movement_scoring, reassessment] } + AnalysisTaskResponse: + allOf: + - $ref: '#/components/schemas/StandardResponse' + - type: object + properties: + data: + type: object + properties: + id: { type: integer } + status: + type: string + enum: [CREATED, QUEUED, PROCESSING, SUCCEEDED, FAILED, CANCELLED] + progress: { type: integer, minimum: 0, maximum: 100 } + errorCode: { type: string } + reportId: { type: integer } + retryCount: { type: integer } + AnalysisTaskWebhook: + type: object + required: [taskId, status] + properties: + taskId: { type: integer } + status: + type: string + enum: [SUCCEEDED, FAILED, CANCELLED] + progress: { type: integer } + errorCode: { type: string } + result: { type: object } + signature: { type: string } + ReportResponse: + allOf: + - $ref: '#/components/schemas/StandardResponse' + - type: object + properties: + data: + type: object + properties: + id: { type: integer } + childId: { type: integer } + taskId: { type: integer } + riskLevel: { type: string, enum: [low, medium, high, review_required] } + summary: { type: string } + metrics: + type: array + items: + type: object + properties: + name: { type: string } + value: { type: number } + level: { type: string } + confidence: { type: number } + recommendations: + type: array + items: { type: string } + disclaimer: { type: string } + reviewedBy: { type: integer, nullable: true } + TrainingPlanCreateRequest: + type: object + required: [childId, goal, cycleDays] + properties: + childId: { type: integer } + reportId: { type: integer } + goal: { type: string } + cycleDays: { type: integer, minimum: 7 } + exerciseIds: + type: array + items: { type: integer } + constraints: + type: object + properties: + maxDailyMinutes: { type: integer } + coachNotes: { type: string } + TrainingPlanResponse: + allOf: + - $ref: '#/components/schemas/StandardResponse' + - type: object + properties: + data: + type: object + properties: + id: { type: integer } + status: { type: string, enum: [draft, active, completed, paused] } + detail: { type: object } + startedAt: { type: string, format: date-time } + endedAt: { type: string, format: date-time, nullable: true } + TrainingRecordCreateRequest: + type: object + required: [exerciseId, completed] + properties: + exerciseId: { type: integer } + completed: { type: boolean } + score: { type: integer, minimum: 0, maximum: 100 } + durationSeconds: { type: integer } + note: { type: string } + media: + type: array + items: + type: string + TrainingRecordResponse: + allOf: + - $ref: '#/components/schemas/StandardResponse' + - type: object + properties: + data: + type: object + properties: + id: { type: integer } + planId: { type: integer } + createdAt: { type: string, format: date-time } + AdminDashboardResponse: + allOf: + - $ref: '#/components/schemas/StandardResponse' + - type: object + properties: + data: + type: object + properties: + newChildren: { type: integer } + uploadedVideos: { type: integer } + completedReports: { type: integer } + activePlans: { type: integer } + conversionRate: { type: number } + reassessmentCompletionRate: { type: number } diff --git a/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/08_Agent智能系统/Agent设计规范.docx b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/08_Agent智能系统/Agent设计规范.docx new file mode 100644 index 0000000..d6b42db Binary files /dev/null and b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/08_Agent智能系统/Agent设计规范.docx differ diff --git a/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/09_前端产品设计/前端产品详细设计.docx b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/09_前端产品设计/前端产品详细设计.docx new file mode 100644 index 0000000..a44516a Binary files /dev/null and b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/09_前端产品设计/前端产品详细设计.docx differ diff --git a/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/10_后端工程设计/后端工程详细设计.docx b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/10_后端工程设计/后端工程详细设计.docx new file mode 100644 index 0000000..45c80fc Binary files /dev/null and b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/10_后端工程设计/后端工程详细设计.docx differ diff --git a/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/11_管理后台/管理后台详细设计.docx b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/11_管理后台/管理后台详细设计.docx new file mode 100644 index 0000000..2d20288 Binary files /dev/null and b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/11_管理后台/管理后台详细设计.docx differ diff --git a/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/12_研发计划/研发计划与项目管理.docx b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/12_研发计划/研发计划与项目管理.docx new file mode 100644 index 0000000..cff359d Binary files /dev/null and b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/12_研发计划/研发计划与项目管理.docx differ diff --git a/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/13_测试验收/测试验收标准.docx b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/13_测试验收/测试验收标准.docx new file mode 100644 index 0000000..56c394f Binary files /dev/null and b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/13_测试验收/测试验收标准.docx differ diff --git a/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/14_部署运维/生产部署运维.docx b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/14_部署运维/生产部署运维.docx new file mode 100644 index 0000000..bdf6d2e Binary files /dev/null and b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/14_部署运维/生产部署运维.docx differ diff --git a/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/15_运营SOP/商业运营与课程SOP.docx b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/15_运营SOP/商业运营与课程SOP.docx new file mode 100644 index 0000000..3908246 Binary files /dev/null and b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/15_运营SOP/商业运营与课程SOP.docx differ diff --git a/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/README.md b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/README.md new file mode 100644 index 0000000..1b5bc50 --- /dev/null +++ b/doc/Kids_AI_Posture_Platform_BUSINESS_DELIVERY_V1.0/README.md @@ -0,0 +1,61 @@ +# 儿童 AI 体态管理与运动康复平台 · 商业交付版本 V1.0 + +> 面向 3–16 岁儿童青少年的 AI 体态管理与运动康复平台(非医疗诊断) + +--- + +## 文档目录 + +| 序号 | 模块 | 文档 | 说明 | +|:----:|------|------|------| +| 01 | 商业计划书 | [儿童AI体态管理商业计划书.docx](../01_商业计划书/儿童AI体态管理商业计划书.docx) | 商业模式与市场分析 | +| 02 | 企业级 PRD | [PRD完整版.docx](../02_企业级PRD/PRD完整版.docx) | 产品需求、指标、用户故事 | +| 03 | 技术架构 | [企业级系统架构.docx](./03_技术架构/企业级系统架构.docx) | 云原生分层架构设计 | +| 03 | **架构与建设规划** | **[系统架构与建设规划.md](./03_技术架构/系统架构与建设规划.md)** | **合伙人讨论用 · 架构 + 排期 + 行动清单** | +| 03 | **架构 HTML 演示** | **[系统架构与建设规划.html](./03_技术架构/系统架构与建设规划.html)** | **浏览器打开 · 可视化架构图与路线图** | +| 03 | **产品交互 Demo** | **[产品交互Demo.html](./03_技术架构/产品交互Demo.html)** | **P2:摄像头 + PDF · 跟练/趋势/儿童端** | +| — | **工程 Starter** | **[apps/api/README.md](../../apps/api/README.md)** | **P3:FastAPI 骨架 · 对齐 OpenAPI** | +| 04 | AI 视觉算法 | [AI视觉算法详细设计.docx](../04_AI视觉算法/AI视觉算法详细设计.docx) | Pose、指标、评分、质量控制 | +| 05 | 视频处理 | [视频处理工程设计.docx](../05_视频处理系统/视频处理工程设计.docx) | 上传、转码、质量检测 | +| 06 | 数据库设计 | [database.sql](../06_数据库设计/database.sql) | MySQL 8.x 完整表结构 | +| 07 | API 接口 | [openapi.yaml](../07_API接口/openapi.yaml) | REST API 契约(OpenAPI 3.0) | +| 08 | Agent 智能系统 | [Agent设计规范.docx](../08_Agent智能系统/Agent设计规范.docx) | 评估/教练/家长/运营 Agent | +| 09 | 前端产品设计 | [前端产品详细设计.docx](../09_前端产品设计/前端产品详细设计.docx) | 小程序、训练端、后台 | +| 10 | 后端工程设计 | [后端工程详细设计.docx](../10_后端工程设计/后端工程详细设计.docx) | 服务模块、权限、安全 | +| 11 | 管理后台 | [管理后台详细设计.docx](../11_管理后台/管理后台详细设计.docx) | 运营、复核、看板 | +| 12 | 研发计划 | [研发计划与项目管理.docx](../12_研发计划/研发计划与项目管理.docx) | P0–P3 里程碑与团队分工 | +| 13 | 测试验收 | [测试验收标准.docx](../13_测试验收/测试验收标准.docx) | 场景测试与验收清单 | +| 14 | 部署运维 | [生产部署运维.docx](../14_部署运维/生产部署运维.docx) | 环境、发布、监控、备份 | +| 15 | 运营 SOP | [商业运营与课程SOP.docx](../15_运营SOP/商业运营与课程SOP.docx) | 机构运营与课程流程 | + +--- + +## 快速开始 + +### 合伙人 / 投资人讨论 + +1. 用浏览器打开 **[系统架构与建设规划.html](./03_技术架构/系统架构与建设规划.html)** +2. 点击 **「进入产品交互 Demo」** 体验完整产品流程(无需开发,纯演示) +3. 配合阅读 **[系统架构与建设规划.md](./03_技术架构/系统架构与建设规划.md)** 获取完整文字版 + +### 研发启动 + +1. 阅读 [PRD完整版.docx](../02_企业级PRD/PRD完整版.docx) 确认 MVP 范围 +2. 以 [openapi.yaml](../07_API接口/openapi.yaml) 为 API 契约单一来源(工程副本:`../../contracts/`) +3. 以 [database.sql](../06_数据库设计/database.sql) 初始化数据库(`make up`) +4. 启动 API Starter:见仓库根 [README.md](../../README.md) +5. 按 [系统架构与建设规划.md](./03_技术架构/系统架构与建设规划.md) Sprint 1 排期 + +--- + +## 版本信息 + +| 字段 | 值 | +|------|-----| +| 版本 | V1.0 | +| 更新日期 | 2026-07-23 | +| 适用阶段 | MVP → 商业试点 → 商业交付 | + +--- + +*本交付包所有 AI 报告输出用于健康管理建议,不构成医疗诊断。* diff --git a/doc/儿童AI体态管理与运动康复平台_PRD_Final_V1.0.docx b/doc/儿童AI体态管理与运动康复平台_PRD_Final_V1.0.docx new file mode 100644 index 0000000..843a066 Binary files /dev/null and b/doc/儿童AI体态管理与运动康复平台_PRD_Final_V1.0.docx differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d27c76a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,48 @@ +services: + mysql: + image: mysql:8.0 + container_name: happy-up-mysql + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: happy_up + MYSQL_USER: happy_up + MYSQL_PASSWORD: happy_up + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + - ./contracts/database.sql:/docker-entrypoint-initdb.d/01_schema.sql:ro + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot"] + interval: 5s + timeout: 5s + retries: 20 + + redis: + image: redis:7-alpine + container_name: happy-up-redis + restart: unless-stopped + ports: + - "6379:6379" + volumes: + - redis_data:/data + + minio: + image: minio/minio:RELEASE.2024-12-18T13-15-44Z + container_name: happy-up-minio + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio_data:/data + +volumes: + mysql_data: + redis_data: + minio_data: