团队管理设置

This commit is contained in:
2026-06-30 11:31:54 +08:00
parent 152392e3d9
commit 9fb81dcfc1
24 changed files with 1231 additions and 127 deletions
@@ -17,6 +17,7 @@ from app.enums.credit_record import (
CreditRecordSubject,
)
from app.enums.user import FRONTEND_USER_KIND_LABELS, USER_TYPE_LABELS, UserType
from app.enums.team import TEAM_UNASSIGNED_VALUE
from app.models.chat_generation_task import ChatGenerationTask
from app.models.credit_record import CreditRecord
from app.models.generation_record import GenerationRecord
@@ -73,6 +74,7 @@ def _build_filters(
user_name: str | None = None,
user_type: str | None = None,
frontend_user_kind: str | None = None,
team_id: str | None = None,
record_type: str | None = None,
credit_subject: str | None = None,
media_type: str | None = None,
@@ -94,6 +96,11 @@ def _build_filters(
if frontend_user_kind:
filters.append(CreditRecord.frontend_user_kind_snapshot == frontend_user_kind)
filters.append(CreditRecord.user_type_snapshot == UserType.FRONTEND.value)
if team_id:
if team_id == TEAM_UNASSIGNED_VALUE:
filters.append(CreditRecord.team_id_snapshot.is_(None))
else:
filters.append(CreditRecord.team_id_snapshot == team_id)
if record_type:
filters.append(CreditRecord.type == record_type)
if credit_subject:
@@ -162,6 +169,8 @@ def _record_to_item(record: CreditRecord, user: User | None, deleted_map: dict[t
"user_type_label": _label(USER_TYPE_LABELS, user_type),
"frontend_user_kind": frontend_kind,
"frontend_user_kind_label": _label(FRONTEND_USER_KIND_LABELS, frontend_kind),
"team_id_snapshot": record.team_id_snapshot,
"team_name_snapshot": record.team_name_snapshot,
"type": record.type,
"record_type": record.type,
"record_type_label": _label(CREDIT_RECORD_TYPE_LABELS, record.type),
@@ -213,6 +222,7 @@ async def list_admin_credit_records(
user_name: str | None = None,
user_type: str | None = None,
frontend_user_kind: str | None = None,
team_id: str | None = None,
record_type: str | None = None,
credit_subject: str | None = None,
media_type: str | None = None,
@@ -230,6 +240,7 @@ async def list_admin_credit_records(
user_name=user_name,
user_type=user_type,
frontend_user_kind=frontend_user_kind,
team_id=team_id,
record_type=record_type,
credit_subject=credit_subject,
media_type=media_type,
@@ -19,6 +19,7 @@ from app.enums.user import FrontendUserKind
from app.models.image_engine import ImageEngine
from app.models.model_config import ModelConfig
from app.models.module_generation_step import ModuleGenerationStep
from app.models.team import Team
from app.models.user import User
from app.models.video_engine import VideoEngine
@@ -57,6 +58,8 @@ class CreditRecordMeta:
user_type_snapshot: str | None = None
frontend_user_kind_snapshot: str | None = None
team_id_snapshot: str | None = None
team_name_snapshot: str | None = None
def to_record_kwargs(self) -> dict[str, Any]:
return {k: v for k, v in asdict(self).items() if v is not None}
@@ -81,6 +84,11 @@ async def with_user_snapshot(db: AsyncSession, meta: CreditRecordMeta, user_id:
if user:
meta.user_type_snapshot = user.user_type
meta.frontend_user_kind_snapshot = _normalize_frontend_kind(getattr(user, "frontend_user_kind", None))
team_id = getattr(user, "team_id", None)
if team_id:
meta.team_id_snapshot = team_id
team_result = await db.execute(select(Team.name).where(Team.id == team_id).limit(1))
meta.team_name_snapshot = team_result.scalar_one_or_none()
return meta
+259
View File
@@ -0,0 +1,259 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from fastapi import HTTPException
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.team import TeamStatus
from app.enums.user import UserType
from app.models.team import Team
from app.models.user import User
from app.schemas.team import TeamCreate, TeamUpdate
from app.utils.id_gen import generate_id
def _clean_text(value: str | None) -> str | None:
if value is None:
return None
value = value.strip()
return value or None
def _team_snapshot(team: Team | None) -> dict[str, Any]:
if not team:
return {"team_id": None, "team_name": None}
return {
"team_id": team.id,
"team_name": getattr(team, "name", None),
}
def _team_out_payload(team: Team, member_count: int = 0) -> dict[str, Any]:
return {
"id": team.id,
"name": team.name,
"code": getattr(team, "code", None),
"description": getattr(team, "description", None),
"status": getattr(team, "status", TeamStatus.ACTIVE.value),
"sort_order": getattr(team, "sort_order", 0) or 0,
"member_count": int(member_count or 0),
"created_at": team.created_at,
"updated_at": team.updated_at,
}
async def _get_team(db: AsyncSession, team_id: str, *, include_deleted: bool = False) -> Team | None:
query = select(Team).where(Team.id == team_id).limit(1)
if not include_deleted:
query = query.where(Team.deleted_at.is_(None))
result = await db.execute(query)
return result.scalar_one_or_none()
async def _get_team_name(db: AsyncSession, team_id: str | None) -> str | None:
if not team_id:
return None
result = await db.execute(select(Team.name).where(Team.id == team_id).limit(1))
return result.scalar_one_or_none()
async def _assert_unique_team(db: AsyncSession, *, name: str, code: str | None, exclude_id: str | None = None) -> None:
conditions = [Team.deleted_at.is_(None)]
duplicate_filters = [Team.name == name]
if code:
duplicate_filters.append(Team.code == code)
conditions.append(or_(*duplicate_filters))
if exclude_id:
conditions.append(Team.id != exclude_id)
result = await db.execute(select(Team.id).where(and_(*conditions)).limit(1))
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="团队名称或编码已存在")
async def list_teams(
db: AsyncSession,
*,
page: int = 1,
page_size: int = 20,
keyword: str | None = None,
status: str | None = None,
) -> dict[str, Any]:
page = max(int(page or 1), 1)
page_size = min(max(int(page_size or 20), 1), 500)
filters: list[Any] = [Team.deleted_at.is_(None)]
kw = _clean_text(keyword)
if kw:
like = f"%{kw}%"
filters.append(or_(Team.name.ilike(like), Team.code.ilike(like), Team.description.ilike(like)))
if status:
filters.append(Team.status == status)
where_clause = and_(*filters)
total = (await db.execute(select(func.count(Team.id)).where(where_clause))).scalar() or 0
result = await db.execute(
select(Team)
.where(where_clause)
.order_by(Team.sort_order.asc(), Team.created_at.desc(), Team.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
teams = list(result.scalars().all())
if not teams:
return {"items": [], "total": total}
team_ids = [team.id for team in teams]
member_result = await db.execute(
select(User.team_id, func.count(User.id))
.where(User.user_type == UserType.FRONTEND.value, User.team_id.in_(team_ids))
.group_by(User.team_id)
)
member_map = {row[0]: int(row[1] or 0) for row in member_result.all()}
return {
"items": [_team_out_payload(team, member_map.get(team.id, 0)) for team in teams],
"total": total,
}
async def list_team_options(db: AsyncSession, *, include_disabled: bool = True) -> list[dict[str, Any]]:
filters: list[Any] = [Team.deleted_at.is_(None)]
if not include_disabled:
filters.append(Team.status == TeamStatus.ACTIVE.value)
result = await db.execute(
select(Team)
.where(and_(*filters))
.order_by(Team.sort_order.asc(), Team.created_at.desc(), Team.id.desc())
)
return [
{
"id": team.id,
"name": team.name,
"code": getattr(team, "code", None),
"status": getattr(team, "status", TeamStatus.ACTIVE.value),
}
for team in result.scalars().all()
]
async def batch_get_team_name_map(db: AsyncSession, team_ids: list[str] | set[str] | tuple[str, ...]) -> dict[str, str]:
"""Batch load team names for list pages. Avoid joining teams in high-frequency user queries."""
ids = [team_id for team_id in dict.fromkeys(team_ids or []) if team_id]
if not ids:
return {}
result = await db.execute(
select(Team.id, Team.name)
.where(Team.id.in_(ids), Team.deleted_at.is_(None))
)
return {row[0]: row[1] for row in result.all()}
async def create_team(db: AsyncSession, req: TeamCreate) -> Team:
name = req.name.strip()
code = _clean_text(req.code)
await _assert_unique_team(db, name=name, code=code)
team = Team(
id=generate_id(),
name=name,
code=code,
description=_clean_text(req.description),
status=req.status or TeamStatus.ACTIVE.value,
sort_order=req.sort_order or 0,
)
db.add(team)
await db.flush()
return team
async def update_team(db: AsyncSession, team_id: str, req: TeamUpdate) -> tuple[Team, dict[str, Any], dict[str, Any]]:
team = await _get_team(db, team_id)
if not team:
raise HTTPException(status_code=404, detail="团队不存在")
before = {
"id": team.id,
"name": team.name,
"code": getattr(team, "code", None),
"description": getattr(team, "description", None),
"status": getattr(team, "status", TeamStatus.ACTIVE.value),
"sort_order": getattr(team, "sort_order", 0) or 0,
}
name = req.name.strip()
code = _clean_text(req.code)
await _assert_unique_team(db, name=name, code=code, exclude_id=team_id)
team.name = name
team.code = code
team.description = _clean_text(req.description)
team.status = req.status or TeamStatus.ACTIVE.value
team.sort_order = req.sort_order or 0
await db.flush()
after = {
"id": team.id,
"name": team.name,
"code": getattr(team, "code", None),
"description": getattr(team, "description", None),
"status": getattr(team, "status", TeamStatus.ACTIVE.value),
"sort_order": getattr(team, "sort_order", 0) or 0,
}
return team, before, after
async def soft_delete_team(db: AsyncSession, team_id: str) -> tuple[Team, dict[str, Any]]:
team = await _get_team(db, team_id)
if not team:
raise HTTPException(status_code=404, detail="团队不存在")
member_count = (await db.execute(
select(func.count(User.id)).where(
User.user_type == UserType.FRONTEND.value,
User.team_id == team_id,
)
)).scalar() or 0
if member_count > 0:
raise HTTPException(status_code=400, detail="该团队下仍有前台用户,请先迁移或取消团队归属")
before = {
"id": team.id,
"name": team.name,
"code": getattr(team, "code", None),
"status": getattr(team, "status", TeamStatus.ACTIVE.value),
"member_count": int(member_count or 0),
}
team.deleted_at = datetime.now(timezone.utc)
await db.flush()
return team, before
async def set_frontend_user_team(
db: AsyncSession,
*,
user_id: str,
team_id: str | None,
) -> tuple[User, dict[str, Any], dict[str, Any]]:
result = await db.execute(select(User).where(User.id == user_id).limit(1))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if user.user_type != UserType.FRONTEND.value:
raise HTTPException(status_code=400, detail="仅前台用户支持设置团队")
old_team_id = getattr(user, "team_id", None)
old_team = await _get_team(db, old_team_id, include_deleted=True) if old_team_id else None
before = _team_snapshot(old_team)
new_team: Team | None = None
if team_id:
new_team = await _get_team(db, team_id)
if not new_team:
raise HTTPException(status_code=404, detail="团队不存在")
if getattr(new_team, "status", TeamStatus.ACTIVE.value) != TeamStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="禁用团队不能设置给用户")
user.team_id = team_id or None
await db.flush()
after = _team_snapshot(new_team)
return user, before, after