feat: 新增题图上传接口和排播接口
This commit is contained in:
@@ -0,0 +1,125 @@
|
|||||||
|
"""
|
||||||
|
仪表盘 API — 题图上传 / 查询
|
||||||
|
|
||||||
|
权限:
|
||||||
|
- POST /cover — 仅 zhipianren / zebian(biandao 返回 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}
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
)
|
||||||
@@ -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()),
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user