Files
renjianbo f2e65a8fbb feat: virtual company module, team projects, PWA dishes app, and startup scripts overhaul
- 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>
2026-07-10 22:37:56 +08:00

1264 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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,
)