From 3dc1a1cf3cd4fb4e9da96c46e06ded1cf26d3b09 Mon Sep 17 00:00:00 2001 From: simonkoson <28867558@qq.com> Date: Fri, 15 May 2026 12:17:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=8E=E7=AB=AF=20CRUD=20=E5=AE=8C?= =?UTF-8?q?=E6=88=90=EF=BC=88episodes=20+=20yearly=5Ftargets=20+=20users?= =?UTF-8?q?=20=E7=AE=A1=E7=90=86=20API=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/episodes.py | 82 +++++++++++++++++++++ backend/app/api/users.py | 106 +++++++++++++++++++++++++++ backend/app/api/yearly_targets.py | 96 ++++++++++++++++++++++++ backend/app/main.py | 8 +- backend/app/models/episode.py | 65 ++++++++++++++++ backend/app/models/yearly_target.py | 34 +++++++++ backend/app/schemas/auth.py | 5 +- backend/app/schemas/episode.py | 41 +++++++++++ backend/app/schemas/user_admin.py | 33 +++++++++ backend/app/schemas/yearly_target.py | 31 ++++++++ 10 files changed, 497 insertions(+), 4 deletions(-) create mode 100644 backend/app/api/episodes.py create mode 100644 backend/app/api/users.py create mode 100644 backend/app/api/yearly_targets.py create mode 100644 backend/app/models/episode.py create mode 100644 backend/app/models/yearly_target.py create mode 100644 backend/app/schemas/episode.py create mode 100644 backend/app/schemas/user_admin.py create mode 100644 backend/app/schemas/yearly_target.py diff --git a/backend/app/api/episodes.py b/backend/app/api/episodes.py new file mode 100644 index 0000000..6f583b1 --- /dev/null +++ b/backend/app/api/episodes.py @@ -0,0 +1,82 @@ +""" +节目期次 API — 增改查(责编和管理员可操作) +""" + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlmodel import Session, select + +from app.core.deps import get_current_user, require_role +from app.db.session import get_session +from app.models.user import User, UserRole +from app.schemas.episode import EpisodeCreate, EpisodeResponse, EpisodeUpdate + +router = APIRouter(prefix="/api/episodes", tags=["节目期次"]) + + +@router.get("", response_model=list[EpisodeResponse]) +def list_episodes( + limit: int = 50, + offset: int = 0, + session: Session = Depends(get_session), + current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)), +): + """获取节目期次列表(仅管理员/责编)。按播出日期倒序。""" + statement = select(Episode).order_by(Episode.air_date.desc()).offset(offset).limit(limit) + episodes = session.exec(statement).all() + return episodes + + +@router.get("/{episode_id}", response_model=EpisodeResponse) +def get_episode( + episode_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)), +): + """根据 ID 获取单期节目(仅管理员/责编)。""" + episode = session.get(Episode, episode_id) + if episode is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="期次不存在") + return episode + + +@router.post("", response_model=EpisodeResponse, status_code=status.HTTP_201_CREATED) +def create_episode( + body: EpisodeCreate, + session: Session = Depends(get_session), + current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)), +): + """新增一期节目(仅管理员/责编)。""" + # 检查期次号唯一 + existing = session.exec( + select(Episode).where(Episode.episode_number == body.episode_number) + ).first() + if existing: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="期次号已存在") + + episode = Episode.model_validate(body.model_dump()) + session.add(episode) + session.commit() + session.refresh(episode) + return episode + + +@router.patch("/{episode_id}", response_model=EpisodeResponse) +def update_episode( + episode_id: int, + body: EpisodeUpdate, + session: Session = Depends(get_session), + current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)), +): + """更新一期节目信息(仅管理员/责编)。""" + episode = session.get(Episode, episode_id) + if episode is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="期次不存在") + + update_data = body.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(episode, field, value) + + session.add(episode) + session.commit() + session.refresh(episode) + return episode \ No newline at end of file diff --git a/backend/app/api/users.py b/backend/app/api/users.py new file mode 100644 index 0000000..9a12eeb --- /dev/null +++ b/backend/app/api/users.py @@ -0,0 +1,106 @@ +""" +用户管理 API — 管理员 CRUD(仅 zhipianren 可操作) +""" + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlmodel import Session, select + +from app.core.deps import get_current_user, require_role +from app.core.security import hash_password, verify_password +from app.db.session import get_session +from app.models.user import User, UserRole +from app.schemas.user_admin import UserCreate, UserResponse, UserUpdate + +router = APIRouter(prefix="/api/users", tags=["用户管理"]) + + +@router.get("", response_model=list[UserResponse]) +def list_users( + session: Session = Depends(get_session), + current_user: User = Depends(require_role(UserRole.zhipianren)), +): + """获取所有用户列表(仅管理员)。""" + statement = select(User).order_by(User.id) + users = session.exec(statement).all() + return users + + +@router.get("/{user_id}", response_model=UserResponse) +def get_user( + user_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(require_role(UserRole.zhipianren)), +): + """根据 ID 获取单个用户(仅管理员)。""" + user = session.get(User, user_id) + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") + return user + + +@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +def create_user( + body: UserCreate, + session: Session = Depends(get_session), + current_user: User = Depends(require_role(UserRole.zhipianren)), +): + """创建新用户(仅管理员)。""" + # 检查用户名唯一 + existing = session.exec(select(User).where(User.username == body.username)).first() + if existing: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="用户名已存在") + + user = User( + username=body.username, + display_name=body.display_name, + password_hash=hash_password(body.password), + role=body.role, + profile_text=body.profile_text, + ) + session.add(user) + session.commit() + session.refresh(user) + return user + + +@router.patch("/{user_id}", response_model=UserResponse) +def update_user( + user_id: int, + body: UserUpdate, + session: Session = Depends(get_session), + current_user: User = Depends(require_role(UserRole.zhipianren)), +): + """更新用户信息(仅管理员)。可更新 display_name/role/profile_text/is_active。""" + user = session.get(User, user_id) + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") + + update_data = body.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(user, field, value) + + session.add(user) + session.commit() + session.refresh(user) + return user + + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +def deactivate_user( + user_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(require_role(UserRole.zhipianren)), +): + """停用用户(软删除,仅管理员)。""" + user = session.get(User, user_id) + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") + + # 不能停用自己的账号 + if user.id == current_user.id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能停用自己的账号") + + user.is_active = False + session.add(user) + session.commit() + return None \ No newline at end of file diff --git a/backend/app/api/yearly_targets.py b/backend/app/api/yearly_targets.py new file mode 100644 index 0000000..beb4c61 --- /dev/null +++ b/backend/app/api/yearly_targets.py @@ -0,0 +1,96 @@ +""" +年度收视目标 API — 增改查 + +业务规则(.clinerules 5.2 只增不改): + - yearly_targets.year 有 UNIQUE 约束 + - INSERT 时 year 冲突 → 直接报错,阻止静默覆盖 + - 禁止 ON CONFLICT UPDATE +""" + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlmodel import Session, select + +from app.core.deps import get_current_user, require_role +from app.db.session import get_session +from app.models.user import User, UserRole +from app.schemas.yearly_target import YearlyTargetCreate, YearlyTargetResponse, YearlyTargetUpdate + +router = APIRouter(prefix="/api/yearly_targets", tags=["年度目标"]) + + +@router.get("", response_model=list[YearlyTargetResponse]) +def list_targets( + session: Session = Depends(get_session), + current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)), +): + """获取所有年度目标(仅管理员/责编)。""" + statement = select(YearlyTarget).order_by(YearlyTarget.year.desc()) + targets = session.exec(statement).all() + return targets + + +@router.get("/{target_id}", response_model=YearlyTargetResponse) +def get_target( + target_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)), +): + """根据 ID 获取单个年度目标(仅管理员/责编)。""" + target = session.get(YearlyTarget, target_id) + if target is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标不存在") + return target + + +@router.post("", response_model=YearlyTargetResponse, status_code=status.HTTP_201_CREATED) +def create_target( + body: YearlyTargetCreate, + session: Session = Depends(get_session), + current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)), +): + """新增年度目标(仅管理员/责编)。 + + 注意:year 有 UNIQUE 约束,重复年份会直接报错。 + 这是预期行为——历史年份目标不可覆盖。 + """ + # 先查是否已存在(让报错更友好,而不是数据库层直接崩溃) + existing = session.exec(select(YearlyTarget).where(YearlyTarget.year == body.year)).first() + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"{body.year} 年目标已存在,历史年份不可覆盖。请新增其他年份。", + ) + + target = YearlyTarget.model_validate(body.model_dump()) + session.add(target) + session.commit() + session.refresh(target) + return target + + +@router.patch("/{target_id}", response_model=YearlyTargetResponse) +def update_target( + target_id: int, + body: YearlyTargetUpdate, + session: Session = Depends(get_session), + current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)), +): + """更新年度目标(仅管理员/责编)。 + + 注意:此接口仅用于修正错字等小修,year 字段不可修改。 + """ + target = session.get(YearlyTarget, target_id) + if target is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标不存在") + + update_data = body.model_dump(exclude_unset=True) + # 禁止修改 year 字段 + update_data.pop("year", None) + + for field, value in update_data.items(): + setattr(target, field, value) + + session.add(target) + session.commit() + session.refresh(target) + return target \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index 7ff8baf..78beabe 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,6 +7,9 @@ from starlette.middleware.cors import CORSMiddleware from starlette.middleware.sessions import SessionMiddleware from app.api.auth import router as auth_router +from app.api.episodes import router as episodes_router +from app.api.users import router as users_router +from app.api.yearly_targets import router as yearly_targets_router from app.core.config import settings app = FastAPI(title="军事科技工作台", version="0.1.0") @@ -33,4 +36,7 @@ app.add_middleware( ) # ---- 注册路由 ---- -app.include_router(auth_router) \ No newline at end of file +app.include_router(auth_router) +app.include_router(episodes_router) +app.include_router(yearly_targets_router) +app.include_router(users_router) \ No newline at end of file diff --git a/backend/app/models/episode.py b/backend/app/models/episode.py new file mode 100644 index 0000000..36544fe --- /dev/null +++ b/backend/app/models/episode.py @@ -0,0 +1,65 @@ +""" +节目期次模型 — SQLModel + +业务规则(.clinerules 5.3 软引用 + 快照模式): + - editor_id 可空(离职编导) + - editor_name_snapshot 必填(历史导入也走快照) + - 查询历史作品:先 editor_id,回退 editor_name_snapshot 文本匹配 +""" + +from datetime import date, datetime + +from sqlalchemy import Column +from sqlalchemy import DateTime as SADateTime +from sqlalchemy import Text +from sqlalchemy import Boolean as SABoolean +from sqlalchemy import Integer as SAInteger +from sqlalchemy import Numeric as SANumeric +from sqlalchemy.sql import func as sa_func +from sqlmodel import Field, SQLModel + + +class Episode(SQLModel, table=True): + __tablename__ = "episodes" + + id: int | None = Field(default=None, primary_key=True) + episode_number: int = Field(unique=True, index=True) + program_name: str = Field(max_length=200) + air_date: date + + # 编导(软引用 + 快照双字段) + # 在职编导:editor_id 和 editor_name_snapshot 都填 + # 离职编导:editor_id 留空,editor_name_snapshot 填姓名 + editor_id: int | None = Field(default=None, foreign_key="users.id") + editor_name_snapshot: str = Field(max_length=100) + + # 收视数据 + # 注意:.clinerules 5.1 颜色只对 audience_share 上色 + # audience_rating 只展示数字,不参与颜色判定 + audience_share: float | None = Field(default=None) + audience_rating: float | None = Field(default=None) + + # 重播相关 + is_rerun: bool = Field(default=False) + original_episode_id: int | None = Field(default=None, foreign_key="episodes.id") + is_time_sensitive: bool = Field(default=False) + + # 外拍信息 + is_outdoor_shoot: bool = Field(default=False) + outdoor_contact_person: str | None = Field(default=None, max_length=100) + outdoor_contact_info: str | None = Field(default=None, max_length=200) + + # 线索 + lead_source: str | None = Field(default=None, max_length=300) + + # 责编备注 + 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()), + ) \ No newline at end of file diff --git a/backend/app/models/yearly_target.py b/backend/app/models/yearly_target.py new file mode 100644 index 0000000..44883d6 --- /dev/null +++ b/backend/app/models/yearly_target.py @@ -0,0 +1,34 @@ +""" +年度收视目标模型 — SQLModel + +业务规则(.clinerules 5.2 年度目标只增不改): + - yearly_targets.year 有 UNIQUE 约束,数据库层已保证 + - 代码端 INSERT 时只许 ON CONFLICT DO NOTHING,禁止 ON CONFLICT UPDATE + - 历史年份目标行绝不可被覆盖或删除 + - 前端判颜色时按播出年份取对应年的目标,不是当前年份 +""" + +from datetime import date, datetime + +from sqlalchemy import Column +from sqlalchemy import DateTime as SADateTime +from sqlalchemy import Integer as SAInteger +from sqlalchemy import Numeric as SANumeric +from sqlalchemy.sql import func as sa_func +from sqlmodel import Field, SQLModel + + +class YearlyTarget(SQLModel, table=True): + __tablename__ = "yearly_targets" + + id: int | None = Field(default=None, primary_key=True) + year: int = Field(unique=True, index=True) + base_target: float # 基础目标 + stretch_target: float # 摸高目标 + issued_date: date | None = Field(default=None) + 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()), + ) \ No newline at end of file diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 3ae772d..bdab0f7 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -2,7 +2,7 @@ 认证相关 Pydantic Schema — 请求 / 响应模型 """ -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from app.models.user import UserRole @@ -21,5 +21,4 @@ class UserResponse(BaseModel): profile_text: str | None = None is_active: bool = True - class Config: - from_attributes = True \ No newline at end of file + model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/backend/app/schemas/episode.py b/backend/app/schemas/episode.py new file mode 100644 index 0000000..f8366ea --- /dev/null +++ b/backend/app/schemas/episode.py @@ -0,0 +1,41 @@ +""" +节目期次 Schema — 请求 / 响应模型 +""" + +from datetime import date + +from pydantic import BaseModel, ConfigDict + +from app.models.user import UserRole + + +class EpisodeBase(BaseModel): + episode_number: int + program_name: str + air_date: date + editor_id: int | None = None + editor_name_snapshot: str + audience_share: float | None = None + audience_rating: float | None = None + is_rerun: bool = False + original_episode_id: int | None = None + is_time_sensitive: bool = False + is_outdoor_shoot: bool = False + outdoor_contact_person: str | None = None + outdoor_contact_info: str | None = None + lead_source: str | None = None + notes: str | None = None + + +class EpisodeCreate(EpisodeBase): + pass + + +class EpisodeUpdate(EpisodeBase): + pass + + +class EpisodeResponse(EpisodeBase): + id: int + + model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/backend/app/schemas/user_admin.py b/backend/app/schemas/user_admin.py new file mode 100644 index 0000000..287d554 --- /dev/null +++ b/backend/app/schemas/user_admin.py @@ -0,0 +1,33 @@ +""" +用户管理 Schema — 管理员 CRUD(仅 zhipianren 可用) +""" + +from pydantic import BaseModel, ConfigDict + +from app.models.user import UserRole + + +class UserCreate(BaseModel): + username: str + display_name: str + password: str + role: UserRole + profile_text: str | None = None + + +class UserUpdate(BaseModel): + display_name: str | None = None + role: UserRole | None = None + profile_text: str | None = None + is_active: bool | None = None + + +class UserResponse(BaseModel): + id: int + username: str + display_name: str + role: UserRole + profile_text: str | None = None + is_active: bool = True + + model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/backend/app/schemas/yearly_target.py b/backend/app/schemas/yearly_target.py new file mode 100644 index 0000000..b874a29 --- /dev/null +++ b/backend/app/schemas/yearly_target.py @@ -0,0 +1,31 @@ +""" +年度收视目标 Schema — 请求 / 响应模型 + +业务规则:yearly_targets 只增不改,ON CONFLICT DO NOTHING +""" + +from datetime import date + +from pydantic import BaseModel, ConfigDict + + +class YearlyTargetBase(BaseModel): + year: int + base_target: float + stretch_target: float + issued_date: date | None = None + notes: str | None = None + + +class YearlyTargetCreate(YearlyTargetBase): + pass + + +class YearlyTargetUpdate(YearlyTargetBase): + pass + + +class YearlyTargetResponse(YearlyTargetBase): + id: int + + model_config = ConfigDict(from_attributes=True) \ No newline at end of file