#!/usr/bin/env python3 """ Phase 5: REST API Server — FastAPI 集成层 ============================================ 将 Phase 1 (DAL) + Phase 2 (业务引擎) + Phase 3 (照片服务) 封装为 RESTful API, 供 Phase 4 Vue 前端消费。 启动方式: pip install fastapi uvicorn Pillow python 11-api_server.py # 默认 0.0.0.0:8080 python 11-api_server.py --port 9000 python 11-api_server.py --reload # 开发模式热重载 API 设计原则: - 统一 JSON 响应格式: {"code": 0, "data": ..., "message": "ok"} - 错误响应: {"code": , "data": null, "message": "<描述>"} - 所有 child 相关操作需要 X-Child-Id 请求头 (多孩子隔离) - 家长端操作通过 /parent/* 路由,不使用 X-Child-Id """ import os import sys import json import logging from datetime import date, datetime from pathlib import Path from typing import Optional, List from contextlib import asynccontextmanager # ---------- 确保项目根在 sys.path ---------- PROJECT_DIR = Path(__file__).resolve().parent sys.path.insert(0, str(PROJECT_DIR)) # ---------- FastAPI ---------- from fastapi import FastAPI, HTTPException, Request, Query, UploadFile, File, Form, Depends from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, FileResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field, field_validator # ---------- 日志 ---------- logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") logger = logging.getLogger("api_server") # ============================================================ # 应用生命周期: 初始化 DB + 照片目录 # ============================================================ DB_PATH: Optional[str] = None PHOTO_BASE_DIR: Optional[Path] = None def init_dependencies(): """幂等初始化:建表、迁移、种子数据""" global DB_PATH, PHOTO_BASE_DIR DB_PATH = os.environ.get("DB_PATH", str(PROJECT_DIR / "reading_app.db")) PHOTO_BASE_DIR = Path(os.environ.get("PHOTO_DIR", str(PROJECT_DIR / "photos"))) from db_config import init_db init_db(DB_PATH) # 幂等迁移 from migrations import migrate migrate(DB_PATH) # 幂等种子 from seed_data import seed seed(DB_PATH) # 照片目录 PHOTO_BASE_DIR.mkdir(parents=True, exist_ok=True) logger.info(f"数据库: {DB_PATH}") logger.info(f"照片目录: {PHOTO_BASE_DIR}") @asynccontextmanager async def lifespan(app: FastAPI): init_dependencies() yield app = FastAPI( title="家庭阅读激励工具 API", version="1.0.0", lifespan=lifespan, ) # CORS — 允许前端本地开发 app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], expose_headers=["X-Child-Id"], ) # 静态文件 — 照片访问 @app.on_event("startup") def mount_photos(): if PHOTO_BASE_DIR and PHOTO_BASE_DIR.exists(): app.mount("/photos", StaticFiles(directory=str(PHOTO_BASE_DIR)), name="photos") # ============================================================ # 统一响应模型 # ============================================================ class APIResponse(BaseModel): code: int = 0 data: Optional[dict | list] = None message: str = "ok" class Config: json_schema_extra = { "example": {"code": 0, "data": {"id": 1, "name": "小明"}, "message": "ok"} } def ok(data=None, message="ok"): return JSONResponse({"code": 0, "data": data, "message": message}) def fail(code: int, message: str, status_code: int = 400): return JSONResponse( status_code=status_code, content={"code": code, "data": None, "message": message}, ) # ============================================================ # 依赖注入: DB 连接 + 孩子上下文 # ============================================================ def get_conn(): """获取数据库连接(每个请求一条连接)""" from db_config import get_connection conn = get_connection() conn.row_factory = None # 使用标准 tuple 返回 try: yield conn finally: conn.close() def get_child_id(request: Request) -> Optional[int]: """ 从 X-Child-Id 请求头解析孩子 ID。 返回 None 表示家长端全局视角(不隔离)。 """ header = request.headers.get("X-Child-Id") if header is None: return None try: return int(header) except ValueError: raise HTTPException(status_code=400, detail="X-Child-Id 必须为整数") def require_child_id(request: Request) -> int: """强制要求 X-Child-Id 请求头""" child_id = get_child_id(request) if child_id is None: raise HTTPException(status_code=400, detail="缺少 X-Child-Id 请求头") return child_id # ============================================================ # Pydantic 请求/响应模型 # ============================================================ class ChildCreate(BaseModel): name: str = Field(..., min_length=1, max_length=50) age: int = Field(..., ge=0, le=18) avatar_url: Optional[str] = Field(None, max_length=500) class ChildUpdate(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=50) age: Optional[int] = Field(None, ge=0, le=18) avatar_url: Optional[str] = Field(None, max_length=500) is_active: Optional[bool] = None class BookCreate(BaseModel): title: str = Field(..., min_length=1, max_length=200) author: Optional[str] = Field(None, max_length=100) page_count: int = Field(..., gt=0) cover_image_path: Optional[str] = None class BookUpdate(BaseModel): title: Optional[str] = Field(None, min_length=1, max_length=200) author: Optional[str] = Field(None, max_length=100) page_count: Optional[int] = Field(None, gt=0) cover_image_path: Optional[str] = None is_active: Optional[bool] = None class ReadingRecordCreate(BaseModel): book_id: int read_date: str = Field(..., pattern=r"^\d{4}-\d{2}-\d{2}$") duration_minutes: int = Field(..., gt=0) pages_read: Optional[int] = Field(None, ge=0) photo_id: Optional[int] = None # 关联照片 note: Optional[str] = Field(None, max_length=500) class RedemptionCreate(BaseModel): reward_id: int @field_validator("reward_id") @classmethod def positive(cls, v): if v <= 0: raise ValueError("reward_id 必须大于 0") return v class PointsRuleUpdate(BaseModel): base_points_per_minute: Optional[float] = None base_points_per_page: Optional[float] = None streak_bonus_3_days: Optional[float] = None streak_bonus_7_days: Optional[float] = None streak_bonus_14_days: Optional[float] = None streak_bonus_30_days: Optional[float] = None streak_bonus_100_days: Optional[float] = None weekend_multiplier: Optional[float] = None first_read_multiplier: Optional[float] = None max_points_per_session: Optional[int] = None min_duration_minutes: Optional[int] = None class RewardCreate(BaseModel): name: str = Field(..., min_length=1, max_length=100) description: Optional[str] = Field(None, max_length=500) points_required: int = Field(..., gt=0) stock: int = Field(-1, ge=-1) # -1=无限 image_url: Optional[str] = None daily_limit: int = Field(3, ge=1) cooldown_minutes: int = Field(0, ge=0) class RewardUpdate(BaseModel): name: Optional[str] = None description: Optional[str] = None points_required: Optional[int] = None stock: Optional[int] = None image_url: Optional[str] = None daily_limit: Optional[int] = None cooldown_minutes: Optional[int] = None is_active: Optional[bool] = None class RedemptionFulfill(BaseModel): record_id: int # ============================================================ # 辅助: 将 sqlite3.Row 转为 dict # ============================================================ def row_to_dict(row) -> dict: """将 sqlite3.Row 或 tuple 转为 dict""" if row is None: return None if hasattr(row, "keys"): return dict(row) return dict(row) # ============================================================ # ==================== API 路由 ============================ # ============================================================ # ---- 健康检查 ---- @app.get("/api/health") async def health(): return ok({"status": "healthy", "version": "1.0.0"}) # ============================================================ # 1. 孩子管理 API (children) # ============================================================ @app.get("/api/children") async def list_children(active_only: bool = Query(True)): """获取所有孩子列表(家长端)""" from dal import get_child_dao conn = next(get_conn()) try: dao = get_child_dao(conn) children = dao.list_all(active_only=active_only) return ok([row_to_dict(c) for c in children]) finally: conn.close() @app.post("/api/children") async def create_child(body: ChildCreate): """创建孩子""" from dal import get_child_dao conn = next(get_conn()) try: dao = get_child_dao(conn) child = dao.create(body.name, body.age, body.avatar_url) conn.commit() return ok(row_to_dict(child)) except Exception as e: conn.rollback() return fail(1001, str(e)) finally: conn.close() @app.get("/api/children/{child_id}") async def get_child(child_id: int): """获取单个孩子详情""" from dal import get_child_dao conn = next(get_conn()) try: dao = get_child_dao(conn) child = dao.get_by_id(child_id) if not child: return fail(1002, f"孩子 {child_id} 不存在", 404) return ok(row_to_dict(child)) finally: conn.close() @app.put("/api/children/{child_id}") async def update_child(child_id: int, body: ChildUpdate): """更新孩子信息""" from dal import get_child_dao conn = next(get_conn()) try: dao = get_child_dao(conn) updates = body.model_dump(exclude_none=True) child = dao.update(child_id, **updates) if not child: return fail(1002, f"孩子 {child_id} 不存在", 404) conn.commit() return ok(row_to_dict(child)) except Exception as e: conn.rollback() return fail(1003, str(e)) finally: conn.close() @app.delete("/api/children/{child_id}") async def deactivate_child(child_id: int): """停用孩子(软删除)""" from dal import get_child_dao conn = next(get_conn()) try: dao = get_child_dao(conn) success = dao.deactivate(child_id) conn.commit() if not success: return fail(1002, f"孩子 {child_id} 不存在", 404) return ok(None, "已停用") except Exception as e: conn.rollback() return fail(1003, str(e)) finally: conn.close() # ============================================================ # 2. 书籍管理 API (books) # ============================================================ @app.get("/api/books") async def list_books( active_only: bool = Query(True), keyword: Optional[str] = Query(None), ): """获取书籍列表""" from dal import get_book_dao conn = next(get_conn()) try: dao = get_book_dao(conn) if keyword: books = dao.search(keyword, active_only=active_only) else: books = dao.list_all(active_only=active_only) return ok([row_to_dict(b) for b in books]) finally: conn.close() @app.post("/api/books") async def create_book(body: BookCreate): """录入书籍""" from dal import get_book_dao conn = next(get_conn()) try: dao = get_book_dao(conn) book = dao.create( title=body.title, author=body.author, page_count=body.page_count, cover_image_path=body.cover_image_path, ) conn.commit() return ok(row_to_dict(book)) except Exception as e: conn.rollback() return fail(2001, str(e)) finally: conn.close() @app.get("/api/books/{book_id}") async def get_book(book_id: int): """获取书籍详情""" from dal import get_book_dao conn = next(get_conn()) try: dao = get_book_dao(conn) book = dao.get_by_id(book_id) if not book: return fail(2002, f"书籍 {book_id} 不存在", 404) return ok(row_to_dict(book)) finally: conn.close() @app.put("/api/books/{book_id}") async def update_book(book_id: int, body: BookUpdate): """更新书籍""" from dal import get_book_dao conn = next(get_conn()) try: dao = get_book_dao(conn) updates = body.model_dump(exclude_none=True) book = dao.update(book_id, **updates) conn.commit() if not book: return fail(2002, f"书籍 {book_id} 不存在", 404) return ok(row_to_dict(book)) except Exception as e: conn.rollback() return fail(2003, str(e)) finally: conn.close() @app.delete("/api/books/{book_id}") async def delete_book(book_id: int): """软删除书籍""" from dal import get_book_dao conn = next(get_conn()) try: dao = get_book_dao(conn) success = dao.deactivate(book_id) conn.commit() if not success: return fail(2002, f"书籍 {book_id} 不存在", 404) return ok(None, "已删除") except Exception as e: conn.rollback() return fail(2003, str(e)) finally: conn.close() # ============================================================ # 3. 阅读打卡 API (reading-records) — 核心闭环 # ============================================================ @app.get("/api/reading-records") async def list_reading_records( request: Request, child_id: Optional[int] = Query(None), book_id: Optional[int] = Query(None), date_from: Optional[str] = Query(None), date_to: Optional[str] = Query(None), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), ): """ 获取打卡记录列表。 - 孩子端使用 X-Child-Id 请求头自动过滤 - 家长端可传 child_id 参数查询特定孩子 """ # 孩子隔离:优先 X-Child-Id,其次参数 effective_child = get_child_id(request) or child_id from dal import get_reading_dao conn = next(get_conn()) try: dao = get_reading_dao(conn) records = dao.list_all( child_id=effective_child, book_id=book_id, date_from=date_from, date_to=date_to, limit=limit, offset=offset, ) return ok([row_to_dict(r) for r in records]) finally: conn.close() @app.post("/api/reading-records") async def create_reading_record(request: Request, body: ReadingRecordCreate): """ 创建打卡记录(核心闭环第2步: 打卡 → 积分累计)。 使用 X-Child-Id 指定孩子。 """ child_id = require_child_id(request) from dal import get_reading_dao conn = next(get_conn()) try: dao = get_reading_dao(conn) record = dao.create( child_id=child_id, book_id=body.book_id, read_date=body.read_date, duration_minutes=body.duration_minutes, pages_read=body.pages_read, note=body.note, ) conn.commit() # 如果关联了照片,更新照片的 reading_record_id if body.photo_id: from photo_service import PhotoService ps = PhotoService(str(PHOTO_BASE_DIR), conn) ps.link_to_record(body.photo_id, record["id"]) return ok(row_to_dict(record)) except Exception as e: conn.rollback() error_msg = str(e) if "UNIQUE constraint" in error_msg: return fail(3001, "同一天同一孩子同一本书已打卡") return fail(3002, error_msg) finally: conn.close() @app.get("/api/reading-records/stats") async def get_reading_stats( request: Request, child_id: Optional[int] = Query(None), ): """获取阅读统计""" effective_child = get_child_id(request) or child_id if effective_child is None: return fail(3003, "请指定孩子(X-Child-Id 或 child_id 参数)") from dal import get_reading_dao conn = next(get_conn()) try: dao = get_reading_dao(conn) stats = dao.get_child_stats(effective_child) monthly = dao.get_monthly_summary(effective_child) return ok({ "stats": row_to_dict(stats) if stats else {}, "monthly": [row_to_dict(m) for m in monthly], }) finally: conn.close() @app.get("/api/reading-records/today") async def get_today_records(request: Request): """获取今日打卡记录""" child_id = require_child_id(request) today_str = date.today().isoformat() from dal import get_reading_dao conn = next(get_conn()) try: dao = get_reading_dao(conn) records = dao.list_all( child_id=child_id, date_from=today_str, date_to=today_str, ) return ok([row_to_dict(r) for r in records]) finally: conn.close() # ============================================================ # 4. 积分 API (points) — 使用 PointsEngine # ============================================================ @app.get("/api/points/balance") async def get_points_balance(request: Request): """获取积分余额 + 连续打卡 + 汇总""" child_id = require_child_id(request) from points_engine import PointsEngine conn = next(get_conn()) try: engine = PointsEngine(conn) summary = engine.get_child_points_summary(child_id) streak = engine.get_streak(child_id) return ok({ "balance": summary.get("balance", 0), "total_earned": summary.get("total_earned", 0), "total_spent": summary.get("total_spent", 0), "current_streak": streak.get("current_streak", 0), "longest_streak": streak.get("longest_streak", 0), "streak_bonus_rate": streak.get("bonus_rate", 0), }) finally: conn.close() @app.get("/api/points/history") async def get_points_history( request: Request, date_from: Optional[str] = Query(None), date_to: Optional[str] = Query(None), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), ): """获取积分流水""" child_id = require_child_id(request) from dal import get_point_dao conn = next(get_conn()) try: dao = get_point_dao(conn) records = dao.list_by_date_range(child_id, date_from, date_to, limit, offset) return ok([row_to_dict(r) for r in records]) finally: conn.close() @app.get("/api/points/preview") async def preview_points( request: Request, minutes: int = Query(..., gt=0), pages: int = Query(0, ge=0), book_id: Optional[int] = Query(None), read_date: Optional[str] = Query(None), ): """ 积分预览(不写库)。 前端打卡前调用,展示预计获得积分。 """ child_id = require_child_id(request) from points_engine import PointsEngine conn = next(get_conn()) try: engine = PointsEngine(conn) result = engine.calculate( child_id=child_id, minutes=minutes, pages=pages, book_id=book_id, read_date=read_date, ) return ok({ "base_points": result.base_points, "streak_bonus": result.streak_bonus, "weekend_bonus": result.weekend_bonus, "first_read_bonus": result.first_read_bonus, "total_points": result.total_points, "breakdown": result.breakdown, }) finally: conn.close() # ============================================================ # 5. 积分规则 API # ============================================================ @app.get("/api/points/rules") async def get_points_rules(): """获取当前积分规则配置""" from points_engine import PointsEngine conn = next(get_conn()) try: engine = PointsEngine(conn) rules = engine.get_all_rules() return ok(rules) finally: conn.close() @app.put("/api/points/rules") async def update_points_rules(body: PointsRuleUpdate): """家长自定义积分规则""" from points_engine import PointsEngine conn = next(get_conn()) try: engine = PointsEngine(conn) updates = body.model_dump(exclude_none=True) for key, value in updates.items(): engine.set_rule(key, value) conn.commit() return ok(None, "规则已更新") except Exception as e: conn.rollback() return fail(4001, str(e)) finally: conn.close() # ============================================================ # 6. 奖品管理 API (rewards) # ============================================================ @app.get("/api/rewards") async def list_rewards(active_only: bool = Query(True)): """获取奖品列表""" from dal import get_reward_dao conn = next(get_conn()) try: dao = get_reward_dao(conn) rewards = dao.list_all(active_only=active_only) return ok([row_to_dict(r) for r in rewards]) finally: conn.close() @app.post("/api/rewards") async def create_reward(body: RewardCreate): """创建奖品""" from dal import get_reward_dao conn = next(get_conn()) try: dao = get_reward_dao(conn) reward = dao.create( name=body.name, description=body.description, points_required=body.points_required, stock=body.stock, image_url=body.image_url, daily_limit=body.daily_limit, cooldown_minutes=body.cooldown_minutes, ) conn.commit() return ok(row_to_dict(reward)) except Exception as e: conn.rollback() return fail(5001, str(e)) finally: conn.close() @app.get("/api/rewards/{reward_id}") async def get_reward(reward_id: int): """获取奖品详情""" from dal import get_reward_dao conn = next(get_conn()) try: dao = get_reward_dao(conn) reward = dao.get_by_id(reward_id) if not reward: return fail(5002, f"奖品 {reward_id} 不存在", 404) return ok(row_to_dict(reward)) finally: conn.close() @app.put("/api/rewards/{reward_id}") async def update_reward(reward_id: int, body: RewardUpdate): """更新奖品""" from dal import get_reward_dao conn = next(get_conn()) try: dao = get_reward_dao(conn) updates = body.model_dump(exclude_none=True) reward = dao.update(reward_id, **updates) conn.commit() if not reward: return fail(5002, f"奖品 {reward_id} 不存在", 404) return ok(row_to_dict(reward)) except Exception as e: conn.rollback() return fail(5003, str(e)) finally: conn.close() @app.delete("/api/rewards/{reward_id}") async def delete_reward(reward_id: int): """软删除奖品""" from dal import get_reward_dao conn = next(get_conn()) try: dao = get_reward_dao(conn) success = dao.deactivate(reward_id) conn.commit() if not success: return fail(5002, f"奖品 {reward_id} 不存在", 404) return ok(None, "已删除") finally: conn.close() @app.get("/api/rewards/affordable/{child_id}") async def get_affordable_rewards(child_id: int): """获取孩子积分够换的奖品列表""" from dal import get_reward_dao conn = next(get_conn()) try: dao = get_reward_dao(conn) rewards = dao.list_affordable(child_id) return ok([row_to_dict(r) for r in rewards]) finally: conn.close() # ============================================================ # 7. 兑换 API (redemption) — 使用 RedemptionService # ============================================================ @app.get("/api/redemption/check/{child_id}/{reward_id}") async def check_redemption_eligibility(child_id: int, reward_id: int): """检查兑换资格(不实际兑换)""" from redemption_service import RedemptionService conn = next(get_conn()) try: svc = RedemptionService(conn) result = svc.check_eligibility(child_id, reward_id) return ok({ "eligible": result.eligible, "reason": result.reason, "balance": result.balance, "points_required": result.points_required, "stock_remaining": result.stock_remaining, }) finally: conn.close() @app.post("/api/redemption") async def redeem(request: Request, body: RedemptionCreate): """ 兑换奖品(核心闭环第4步: 积分扣减 + 库存扣减)。 使用 X-Child-Id 指定孩子。 完整原子事务: 校验→扣库存→扣积分→写流水→建记录。 """ child_id = require_child_id(request) from redemption_service import RedemptionService conn = next(get_conn()) try: svc = RedemptionService(conn) result = svc.redeem(child_id, body.reward_id) conn.commit() if not result.success: return fail(6001, result.message) return ok({ "record_id": result.record_id, "reward_name": result.reward_name, "points_spent": result.points_spent, "balance_after": result.balance_after, "status": result.status, }) except Exception as e: conn.rollback() return fail(6002, str(e)) finally: conn.close() @app.get("/api/redemption") async def list_redemptions( request: Request, child_id: Optional[int] = Query(None), status: Optional[str] = Query(None), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), ): """获取兑换记录""" effective_child = get_child_id(request) or child_id from dal import get_redemption_dao conn = next(get_conn()) try: dao = get_redemption_dao(conn) records = dao.list_all( child_id=effective_child, status=status, limit=limit, offset=offset, ) return ok([row_to_dict(r) for r in records]) finally: conn.close() @app.get("/api/redemption/pending") async def list_pending_redemptions(request: Request): """家长端:获取待兑现列表""" from redemption_service import RedemptionService conn = next(get_conn()) try: svc = RedemptionService(conn) pending = svc.list_pending() return ok([row_to_dict(p) for p in pending]) finally: conn.close() @app.post("/api/redemption/{record_id}/fulfill") async def fulfill_redemption(record_id: int): """家长兑现奖品: pending → fulfilled""" from redemption_service import RedemptionService conn = next(get_conn()) try: svc = RedemptionService(conn) result = svc.fulfill(record_id) conn.commit() if not result.success: return fail(6003, result.message) return ok(None, "已兑现") except Exception as e: conn.rollback() return fail(6004, str(e)) finally: conn.close() @app.post("/api/redemption/{record_id}/cancel") async def cancel_redemption(record_id: int): """取消兑换: 退还积分 + 返还库存""" from redemption_service import RedemptionService conn = next(get_conn()) try: svc = RedemptionService(conn) result = svc.cancel(record_id, refund=True) conn.commit() if not result.success: return fail(6005, result.message) return ok(None, "已取消,积分已退还") except Exception as e: conn.rollback() return fail(6006, str(e)) finally: conn.close() @app.get("/api/redemption/summary/{child_id}") async def get_redemption_summary(child_id: int): """获取兑换汇总""" from redemption_service import RedemptionService conn = next(get_conn()) try: svc = RedemptionService(conn) summary = svc.get_summary(child_id) return ok(row_to_dict(summary)) finally: conn.close() # ============================================================ # 8. 照片管理 API (photos) # ============================================================ @app.post("/api/photos/upload") async def upload_photo( request: Request, file: UploadFile = File(...), child_id: Optional[int] = Form(None), ): """ 上传照片(拍照/相册选取)。 流程: 接收 → 压缩(1024px, JPEG q=80) → 本地存储 → 缩略图(256×256) → 入库。 使用 X-Child-Id 或 Form child_id 指定孩子。 """ effective_child = child_id or require_child_id(request) from photo_service import PhotoService conn = next(get_conn()) try: ps = PhotoService(str(PHOTO_BASE_DIR), conn) # 读取上传的字节 image_bytes = await file.read() if len(image_bytes) == 0: return fail(7001, "上传文件为空") # 导入(自动压缩 + 存储 + 缩略图 + 去重) photo = ps.import_photo_from_bytes( child_id=effective_child, image_bytes=image_bytes, original_filename=file.filename or "upload.jpg", ) photo_dict = row_to_dict(photo) # 添加可访问的 URL(前端通过 /photos/ 静态路由访问) photo_dict["url"] = f"/photos/{photo_dict['file_path']}" photo_dict["thumbnail_url"] = f"/photos/{photo_dict['thumbnail_path']}" return ok(photo_dict) except Exception as e: conn.rollback() return fail(7002, str(e)) finally: conn.close() @app.get("/api/photos") async def list_photos( request: Request, child_id: Optional[int] = Query(None), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), ): """获取照片列表""" effective_child = get_child_id(request) or child_id from photo_service import PhotoService conn = next(get_conn()) try: ps = PhotoService(str(PHOTO_BASE_DIR), conn) if effective_child: photos = ps.get_photos_by_child(effective_child, limit, offset) else: # 家长端全量 photos = ps.get_photos_all(limit, offset) result = [] for p in photos: d = row_to_dict(p) d["url"] = f"/photos/{d['file_path']}" d["thumbnail_url"] = f"/photos/{d['thumbnail_path']}" result.append(d) return ok(result) finally: conn.close() @app.get("/api/photos/{photo_id}") async def get_photo(photo_id: int): """获取单张照片详情""" from photo_service import PhotoService conn = next(get_conn()) try: ps = PhotoService(str(PHOTO_BASE_DIR), conn) photo = ps.get_photo(photo_id) if not photo: return fail(7003, f"照片 {photo_id} 不存在", 404) d = row_to_dict(photo) d["url"] = f"/photos/{d['file_path']}" d["thumbnail_url"] = f"/photos/{d['thumbnail_path']}" return ok(d) finally: conn.close() @app.delete("/api/photos/{photo_id}") async def delete_photo(photo_id: int): """删除照片(数据库 + 物理文件)""" from photo_service import PhotoService conn = next(get_conn()) try: ps = PhotoService(str(PHOTO_BASE_DIR), conn) success = ps.delete_photo(photo_id) conn.commit() if not success: return fail(7003, f"照片 {photo_id} 不存在", 404) return ok(None, "已删除") except Exception as e: conn.rollback() return fail(7004, str(e)) finally: conn.close() @app.put("/api/photos/{photo_id}/link") async def link_photo_to_record(photo_id: int, reading_record_id: int = Query(...)): """关联照片到打卡记录""" from photo_service import PhotoService conn = next(get_conn()) try: ps = PhotoService(str(PHOTO_BASE_DIR), conn) success = ps.link_to_record(photo_id, reading_record_id) conn.commit() if not success: return fail(7005, "关联失败,检查 photo_id 和 record_id") return ok(None, "已关联") except Exception as e: conn.rollback() return fail(7006, str(e)) finally: conn.close() @app.get("/api/photos/stats/summary") async def get_photo_stats(request: Request): """照片存储统计""" from photo_service import PhotoService conn = next(get_conn()) try: ps = PhotoService(str(PHOTO_BASE_DIR), conn) count = ps.get_photo_count() size_bytes = ps.get_total_storage_size() return ok({ "total_count": count, "total_size_bytes": size_bytes, "total_size_mb": round(size_bytes / (1024 * 1024), 2) if size_bytes else 0, }) finally: conn.close() # ============================================================ # 9. 多孩子管理 API (parent endpoints) # ============================================================ @app.get("/api/parent/dashboard") async def parent_dashboard(): """家长仪表盘:所有孩子的汇总数据""" from dal import get_child_dao, get_reading_dao from points_engine import PointsEngine conn = next(get_conn()) try: child_dao = get_child_dao(conn) read_dao = get_reading_dao(conn) engine = PointsEngine(conn) children = child_dao.list_all(active_only=True) dashboard = [] for c in children: cdict = row_to_dict(c) child_id = cdict["id"] # 阅读统计 stats = read_dao.get_child_stats(child_id) cdict["stats"] = row_to_dict(stats) if stats else {} # 积分 pts = engine.get_child_points_summary(child_id) cdict["points"] = { "balance": pts.get("balance", 0), "current_streak": pts.get("current_streak", 0), } dashboard.append(cdict) return ok(dashboard) finally: conn.close() @app.post("/api/parent/transfer-points") async def transfer_points( from_child_id: int = Query(...), to_child_id: int = Query(...), amount: int = Query(..., gt=0), ): """ 积分转移(家长端):从一个孩子转给另一个孩子。 双向流水确保可追溯。 """ from child_isolation import ParentSession conn = next(get_conn()) try: session = ParentSession(conn) result = session.transfer_points(from_child_id, to_child_id, amount) conn.commit() if not result["success"]: return fail(8001, result["message"]) return ok({ "from_balance": result["from_balance"], "to_balance": result["to_balance"], }) except Exception as e: conn.rollback() return fail(8002, str(e)) finally: conn.close() @app.get("/api/parent/compare-children") async def compare_children(): """多孩子横向对比""" from child_isolation import ParentSession conn = next(get_conn()) try: session = ParentSession(conn) comparison = session.compare_children() return ok(comparison) finally: conn.close() # ============================================================ # 10. 设置 API (app_settings) # ============================================================ @app.get("/api/settings") async def get_settings(): """获取所有应用设置""" from dal import get_setting_dao conn = next(get_conn()) try: dao = get_setting_dao(conn) settings = dao.list_all() return ok({s["key"]: s["value"] for s in settings}) finally: conn.close() @app.put("/api/settings") async def update_settings(settings: dict): """批量更新设置""" from dal import get_setting_dao conn = next(get_conn()) try: dao = get_setting_dao(conn) dao.set_batch(settings) conn.commit() return ok(None, "设置已更新") except Exception as e: conn.rollback() return fail(9001, str(e)) finally: conn.close() # ============================================================ # 全局异常处理 # ============================================================ @app.exception_handler(HTTPException) async def http_exception_handler(request: Request, exc: HTTPException): return fail(exc.status_code, exc.detail, exc.status_code) @app.exception_handler(Exception) async def generic_exception_handler(request: Request, exc: Exception): logger.exception("未捕获异常") return fail(9999, f"服务器内部错误: {str(exc)}", 500) # ============================================================ # 主入口 # ============================================================ if __name__ == "__main__": import uvicorn import argparse parser = argparse.ArgumentParser(description="家庭阅读激励工具 API Server") parser.add_argument("--host", default="0.0.0.0", help="监听地址") parser.add_argument("--port", type=int, default=8080, help="监听端口") parser.add_argument("--reload", action="store_true", help="开发模式热重载") parser.add_argument("--db", default=None, help="数据库路径") args = parser.parse_args() if args.db: os.environ["DB_PATH"] = args.db print(f""" ╔══════════════════════════════════════════════════╗ ║ 家庭阅读激励工具 API Server v1.0.0 ║ ║ ║ ║ 地址: http://{args.host}:{args.port} ║ ║ 文档: http://{args.host}:{args.port}/docs ║ ║ 数据库: {os.environ.get('DB_PATH', PROJECT_DIR / 'reading_app.db')} ║ 照片目录: {os.environ.get('PHOTO_DIR', PROJECT_DIR / 'photos')} ╚══════════════════════════════════════════════════╝ """) uvicorn.run( "11-api_server:app", host=args.host, port=args.port, reload=args.reload, )