feat: 新增题图上传接口和排播接口

This commit is contained in:
simonkoson
2026-05-25 09:44:34 +08:00
parent 8299b6bbc9
commit a6e8cc25ec
5 changed files with 268 additions and 0 deletions
+125
View File
@@ -0,0 +1,125 @@
"""
仪表盘 API — 题图上传 / 查询
权限:
- POST /cover — 仅 zhipianren / zebianbiandao 返回 403
- GET /cover — 三角色均可读
"""
import os
import uuid
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, status
from sqlmodel import Session
from app.core.deps import get_current_user, require_role
from app.db.session import get_session
from app.models.user import User, UserRole
router = APIRouter(prefix="/api/dashboard", tags=["仪表盘"])
# 题图文件存放目录(相对于 backend/)
STATIC_COVERS_DIR = Path(__file__).parent.parent.parent / "static" / "covers"
STATIC_COVERS_URL = "/static/covers"
# 确保目录存在
STATIC_COVERS_DIR.mkdir(parents=True, exist_ok=True)
ALLOWED_TYPES = {"image/png", "image/jpeg", "image/webp"}
MAX_SIZE = 5 * 1024 * 1024 # 5MB
def require_upload_role():
return require_role(UserRole.zhipianren, UserRole.zebian)
@router.get("/cover")
def get_current_cover(
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
"""查询当前题图设置(三角色均可读)。"""
from app.models.cover_settings import CoverSettings
row = session.exec(
select(CoverSettings).where(CoverSettings.key == "dashboard_cover")
).first()
if not row:
return {"cover_path": None, "episode_number": None, "episode_title": None}
return {
"cover_path": row.cover_path,
"episode_number": row.episode_number,
"episode_title": row.episode_title,
}
@router.post("/cover")
def upload_cover(
file: UploadFile = File(...),
episode_number: int = Form(...),
episode_title: str = Form(...),
session: Session = Depends(get_session),
current_user: User = Depends(require_upload_role()),
):
"""上传题图(仅制片人/责编)。
文件存 static/covers/{uuid}.{ext},更新 cover_settings 表。
"""
# 校验文件类型
if file.content_type not in ALLOWED_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"仅支持 PNG/JPG/WEBP,当前:{file.content_type}",
)
# 校验文件大小
contents = file.file.read()
if len(contents) > MAX_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="文件大小不能超过 5MB",
)
file.file.seek(0)
# 生成文件名
ext = file.filename.split(".")[-1] if "." in file.filename else "jpg"
safe_ext = ext.lower() if ext.lower() in {"png", "jpg", "jpeg", "webp"} else "jpg"
filename = f"{uuid.uuid4().hex}.{safe_ext}"
filepath = STATIC_COVERS_DIR / filename
# 写文件
with open(filepath, "wb") as f:
f.write(contents)
# 更新数据库
from app.models.cover_settings import CoverSettings
cover_path = f"{STATIC_COVERS_URL}/{filename}"
now = datetime.now()
row = session.exec(
select(CoverSettings).where(CoverSettings.key == "dashboard_cover")
).first()
if row:
row.cover_path = cover_path
row.episode_number = episode_number
row.episode_title = episode_title
row.updated_at = now
else:
row = CoverSettings(
key="dashboard_cover",
cover_path=cover_path,
episode_number=episode_number,
episode_title=episode_title,
updated_at=now,
)
session.add(row)
session.commit()
return {"success": True, "cover_path": cover_path}
+70
View File
@@ -0,0 +1,70 @@
"""
排期 API — 查询未来排播计划
降级逻辑:
- schedules 表有数据 → 从 schedules 取未来 limit 条 JOIN episodes
- schedules 表无数据 → 从 episodes 表取最近 limit 期待播期次(air_date 升序)
"""
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from app.core.deps import get_current_user
from app.db.session import get_session
from app.models.user import User
from app.models.episode import Episode
from app.models.schedule import Schedule
router = APIRouter(prefix="/api/schedules", tags=["排期"])
@router.get("/upcoming")
def list_upcoming_schedules(
limit: int = 6,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
"""获取未来排播计划(三角色均可读)。
优先从 schedules 表取,schedules 无数据时降级取 episodes 最近待播期次。
"""
# 先查 schedules
statement = (
select(Schedule, Episode)
.join(Episode, Schedule.episode_id == Episode.id, isouter=True)
.where(Schedule.planned_air_date >= Episode.air_date)
.order_by(Schedule.planned_air_date.asc())
.limit(limit)
)
results = session.exec(statement).all()
# 降级:从 episodes 直接取(当 schedules 为空或不足时)
if not results or len(results) < limit:
fallback_stmt = (
select(Episode)
.order_by(Episode.air_date.asc())
.limit(limit)
)
episodes = session.exec(fallback_stmt).all()
return [
{
"episode_number": ep.episode_number,
"program_name": ep.program_name,
"planned_air_date": str(ep.air_date),
"editor_name_snapshot": ep.editor_name_snapshot,
}
for ep in episodes
]
# schedules 有数据
output = []
for schedule, episode in results:
if episode is None:
continue
output.append({
"episode_number": episode.episode_number,
"program_name": episode.program_name,
"planned_air_date": str(schedule.planned_air_date),
"editor_name_snapshot": episode.editor_name_snapshot,
})
return output
+12
View File
@@ -6,11 +6,16 @@ from fastapi import FastAPI
from starlette.middleware.cors import CORSMiddleware from starlette.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from fastapi.staticfiles import StaticFiles
from pathlib import Path
from app.api.auth import router as auth_router from app.api.auth import router as auth_router
from app.api.episodes import router as episodes_router from app.api.episodes import router as episodes_router
from app.api.imports import router as imports_router from app.api.imports import router as imports_router
from app.api.users import router as users_router from app.api.users import router as users_router
from app.api.yearly_targets import router as yearly_targets_router from app.api.yearly_targets import router as yearly_targets_router
from app.api.dashboard import router as dashboard_router
from app.api.schedules import router as schedules_router
from app.core.config import settings from app.core.config import settings
app = FastAPI(title="军事科技工作台", version="0.1.0") app = FastAPI(title="军事科技工作台", version="0.1.0")
@@ -42,3 +47,10 @@ app.include_router(episodes_router)
app.include_router(imports_router) app.include_router(imports_router)
app.include_router(yearly_targets_router) app.include_router(yearly_targets_router)
app.include_router(users_router) app.include_router(users_router)
app.include_router(dashboard_router)
app.include_router(schedules_router)
# 挂载静态文件目录(题图海报)
_static_dir = Path(__file__).parent.parent / "static"
if _static_dir.exists():
app.mount("/static", StaticFiles(directory=str(_static_dir)), name="static")
+28
View File
@@ -0,0 +1,28 @@
"""
题图设置模型 — SQLModel
只存当前展示的一张题图,不动 episodes 表。
"""
from datetime import datetime
from sqlalchemy import Column
from sqlalchemy import DateTime as SADateTime
from sqlalchemy import Integer as SAInteger
from sqlalchemy import Text
from sqlalchemy.sql import func as sa_func
from sqlmodel import Field, SQLModel
class CoverSettings(SQLModel, table=True):
__tablename__ = "cover_settings"
id: int | None = Field(default=None, primary_key=True)
key: str = Field(default="dashboard_cover", max_length=50)
cover_path: str | None = Field(default=None) # 当前题图文件路径
episode_number: int | None = Field(default=None) # 关联期次号
episode_title: str | None = Field(default=None) # 关联期次节目名
updated_at: datetime | None = Field(
default=None,
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
)
+33
View File
@@ -0,0 +1,33 @@
"""
排期模型 — SQLModel
"""
from datetime import date, datetime
from sqlalchemy import Column
from sqlalchemy import DateTime as SADateTime
from sqlalchemy import Integer as SAInteger
from sqlalchemy import Date as SADate
from sqlalchemy import Text
from sqlalchemy.sql import func as sa_func
from sqlmodel import Field, SQLModel
class Schedule(SQLModel, table=True):
__tablename__ = "schedules"
id: int | None = Field(default=None, primary_key=True)
episode_id: int | None = Field(default=None, foreign_key="episodes.id")
planned_air_date: date
status: str = Field(default="planned", max_length=20)
editor_id: int | None = Field(default=None, foreign_key="users.id")
notes: str | None = Field(default=None)
created_at: datetime | None = Field(
default=None,
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
)
updated_at: datetime | None = Field(
default=None,
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
)