- Add company module (3-tier org, CEO planning, parallel departments) - Add company orchestrator, knowledge extractor, presets, scheduler - Add company API endpoints, models, and frontend views - Add 今天吃啥 PWA app (69 dishes, real images, offline support) - Add team_projects output directory structure - Add unified manage.ps1 for service lifecycle - Add Windows startup guide v1.0 - Add TTS troubleshooting doc - Update frontend (AgentChat UX overhaul, new views) - Update backend (voice engine fix, multi-tenant, RBAC) - Remove deprecated startup scripts and old docs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1264 lines
38 KiB
Python
1264 lines
38 KiB
Python
#!/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": <error_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,
|
||
)
|