团队管理设置
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
"""add team management
|
||||
|
||||
Revision ID: 170005531deb
|
||||
Revises: 78fb32c26a6e
|
||||
Create Date: 2026-06-30 11:07:14.278871
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '170005531deb'
|
||||
down_revision: Union[str, None] = '78fb32c26a6e'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('teams',
|
||||
sa.Column('id', sa.String(length=32), nullable=False),
|
||||
sa.Column('name', sa.String(length=128), nullable=False, comment='团队名称'),
|
||||
sa.Column('code', sa.String(length=64), nullable=True, comment='团队编码'),
|
||||
sa.Column('description', sa.String(length=512), nullable=True, comment='团队备注'),
|
||||
sa.Column('status', sa.String(length=16), server_default='active', nullable=False, comment='团队状态:active启用,disabled禁用'),
|
||||
sa.Column('sort_order', sa.Integer(), server_default='0', nullable=False, comment='排序值,越小越靠前'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('ix_teams_code', 'teams', ['code'], unique=False)
|
||||
op.create_index(op.f('ix_teams_deleted_at'), 'teams', ['deleted_at'], unique=False)
|
||||
op.create_index(op.f('ix_teams_name'), 'teams', ['name'], unique=False)
|
||||
op.create_index(op.f('ix_teams_sort_order'), 'teams', ['sort_order'], unique=False)
|
||||
op.create_index(op.f('ix_teams_status'), 'teams', ['status'], unique=False)
|
||||
op.create_index('ix_teams_status_sort', 'teams', ['status', 'sort_order', 'created_at'], unique=False)
|
||||
op.drop_index(op.f('ix_contact_requests_phone'), table_name='contact_requests')
|
||||
op.drop_index(op.f('ix_contact_requests_user_id'), table_name='contact_requests')
|
||||
op.drop_table('contact_requests')
|
||||
op.add_column('credit_records', sa.Column('team_id_snapshot', sa.String(length=32), nullable=True))
|
||||
op.add_column('credit_records', sa.Column('team_name_snapshot', sa.String(length=128), nullable=True))
|
||||
op.drop_index(op.f('ix_credit_records_engine_type'), table_name='credit_records')
|
||||
op.create_index(op.f('ix_credit_records_team_id_snapshot'), 'credit_records', ['team_id_snapshot'], unique=False)
|
||||
op.create_index('ix_credit_records_team_time', 'credit_records', ['team_id_snapshot', 'created_at'], unique=False)
|
||||
op.create_index('ix_credit_records_user_kind_team_time', 'credit_records', ['user_type_snapshot', 'frontend_user_kind_snapshot', 'team_id_snapshot', 'created_at'], unique=False)
|
||||
op.create_index('idx_genrec_project_status', 'generation_records', ['project_id', 'status'], unique=False)
|
||||
op.create_index('idx_genrec_user_status_created', 'generation_records', ['user_id', 'status', 'created_at'], unique=False)
|
||||
op.create_index('idx_menu_target_active_sort', 'menu_configs', ['menu_target', 'is_active', 'sort_order'], unique=False)
|
||||
op.create_index(op.f('ix_menu_configs_parent_id'), 'menu_configs', ['parent_id'], unique=False)
|
||||
op.create_index('idx_oplog_created', 'operation_logs', ['created_at'], unique=False)
|
||||
op.create_index('idx_oplog_user_created', 'operation_logs', ['user_id', 'created_at'], unique=False)
|
||||
op.create_index('idx_payorder_status_created', 'payment_orders', ['status', 'created_at'], unique=False)
|
||||
op.create_index('idx_payorder_user_status_created', 'payment_orders', ['user_id', 'status', 'created_at'], unique=False)
|
||||
op.create_index('idx_upload_oauth_status', 'upload_task', ['oauth_id', 'status'], unique=False)
|
||||
op.create_index('idx_upload_user_status_created', 'upload_task', ['user_id', 'status', 'created_at'], unique=False)
|
||||
op.add_column('users', sa.Column('team_id', sa.String(length=32), nullable=True))
|
||||
op.create_index(op.f('ix_users_team_id'), 'users', ['team_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_users_team_id'), table_name='users')
|
||||
op.drop_column('users', 'team_id')
|
||||
op.drop_index('idx_upload_user_status_created', table_name='upload_task')
|
||||
op.drop_index('idx_upload_oauth_status', table_name='upload_task')
|
||||
op.drop_index('idx_payorder_user_status_created', table_name='payment_orders')
|
||||
op.drop_index('idx_payorder_status_created', table_name='payment_orders')
|
||||
op.drop_index('idx_oplog_user_created', table_name='operation_logs')
|
||||
op.drop_index('idx_oplog_created', table_name='operation_logs')
|
||||
op.drop_index(op.f('ix_menu_configs_parent_id'), table_name='menu_configs')
|
||||
op.drop_index('idx_menu_target_active_sort', table_name='menu_configs')
|
||||
op.drop_index('idx_genrec_user_status_created', table_name='generation_records')
|
||||
op.drop_index('idx_genrec_project_status', table_name='generation_records')
|
||||
op.drop_index('ix_credit_records_user_kind_team_time', table_name='credit_records')
|
||||
op.drop_index('ix_credit_records_team_time', table_name='credit_records')
|
||||
op.drop_index(op.f('ix_credit_records_team_id_snapshot'), table_name='credit_records')
|
||||
op.create_index(op.f('ix_credit_records_engine_type'), 'credit_records', ['engine_type'], unique=False)
|
||||
op.drop_column('credit_records', 'team_name_snapshot')
|
||||
op.drop_column('credit_records', 'team_id_snapshot')
|
||||
op.create_table('contact_requests',
|
||||
sa.Column('id', sa.VARCHAR(length=32), autoincrement=False, nullable=False),
|
||||
sa.Column('user_id', sa.VARCHAR(length=32), autoincrement=False, nullable=False),
|
||||
sa.Column('phone', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
|
||||
sa.Column('company_name', sa.VARCHAR(length=128), autoincrement=False, nullable=False),
|
||||
sa.Column('industry', sa.VARCHAR(length=64), autoincrement=False, nullable=False),
|
||||
sa.Column('name', sa.VARCHAR(length=64), autoincrement=False, nullable=False),
|
||||
sa.Column('message', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('is_handled', sa.BOOLEAN(), autoincrement=False, nullable=False),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True, precision=6), server_default=sa.text('now()'), autoincrement=False, nullable=False),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True, precision=6), server_default=sa.text('now()'), autoincrement=False, nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('contact_requests_user_id_fkey')),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('contact_requests_pkey'))
|
||||
)
|
||||
op.create_index(op.f('ix_contact_requests_user_id'), 'contact_requests', ['user_id'], unique=False)
|
||||
op.create_index(op.f('ix_contact_requests_phone'), 'contact_requests', ['phone'], unique=False)
|
||||
op.drop_index('ix_teams_status_sort', table_name='teams')
|
||||
op.drop_index(op.f('ix_teams_status'), table_name='teams')
|
||||
op.drop_index(op.f('ix_teams_sort_order'), table_name='teams')
|
||||
op.drop_index(op.f('ix_teams_name'), table_name='teams')
|
||||
op.drop_index(op.f('ix_teams_deleted_at'), table_name='teams')
|
||||
op.drop_index('ix_teams_code', table_name='teams')
|
||||
op.drop_table('teams')
|
||||
# ### end Alembic commands ###
|
||||
@@ -2,7 +2,9 @@ from fastapi import APIRouter
|
||||
|
||||
from app.api.admin.video_prompt_schema_config import router as video_prompt_schema_config_router
|
||||
from app.api.admin.resource_capacity import router as resource_capacity_router
|
||||
from app.api.admin.team import router as team_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(video_prompt_schema_config_router)
|
||||
router.include_router(resource_capacity_router)
|
||||
router.include_router(team_router)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_admin_user, get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.team import TeamCreate, TeamListOut, TeamOptionOut, TeamOut, TeamUpdate
|
||||
from app.services.operation_log import log_operation
|
||||
from app.services.team_service import create_team, list_team_options, list_teams, soft_delete_team, update_team
|
||||
|
||||
router = APIRouter(prefix="/admin/teams", tags=["admin-teams"])
|
||||
|
||||
|
||||
@router.get("", response_model=TeamListOut)
|
||||
async def list_admin_teams(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=500),
|
||||
keyword: str | None = Query(None),
|
||||
status: str | None = Query(None, pattern="^(active|disabled)$"),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await list_teams(db, page=page, page_size=page_size, keyword=keyword, status=status)
|
||||
|
||||
|
||||
@router.get("/options", response_model=list[TeamOptionOut])
|
||||
async def list_admin_team_options(
|
||||
include_disabled: bool = Query(True),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await list_team_options(db, include_disabled=include_disabled)
|
||||
|
||||
|
||||
@router.post("", response_model=TeamOut)
|
||||
async def create_admin_team(
|
||||
req: TeamCreate,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
team = await create_team(db, req)
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"创建团队 {team.name}",
|
||||
"POST",
|
||||
"/admin/teams",
|
||||
detail=json.dumps({"after": req.model_dump()}, ensure_ascii=False),
|
||||
)
|
||||
return {
|
||||
"id": team.id,
|
||||
"name": team.name,
|
||||
"code": team.code,
|
||||
"description": team.description,
|
||||
"status": team.status,
|
||||
"sort_order": team.sort_order,
|
||||
"member_count": 0,
|
||||
"created_at": team.created_at,
|
||||
"updated_at": team.updated_at,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{team_id}", response_model=TeamOut)
|
||||
async def update_admin_team(
|
||||
team_id: str,
|
||||
req: TeamUpdate,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
team, before, after = await update_team(db, team_id, req)
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"修改团队 {team.name}",
|
||||
"PUT",
|
||||
f"/admin/teams/{team_id}",
|
||||
detail=json.dumps({"before": before, "after": after}, ensure_ascii=False),
|
||||
)
|
||||
return {
|
||||
"id": team.id,
|
||||
"name": team.name,
|
||||
"code": team.code,
|
||||
"description": team.description,
|
||||
"status": team.status,
|
||||
"sort_order": team.sort_order,
|
||||
"member_count": 0,
|
||||
"created_at": team.created_at,
|
||||
"updated_at": team.updated_at,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{team_id}")
|
||||
async def delete_admin_team(
|
||||
team_id: str,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
team, before = await soft_delete_team(db, team_id)
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"删除团队 {team.name}",
|
||||
"DELETE",
|
||||
f"/admin/teams/{team_id}",
|
||||
detail=json.dumps({"before": before, "after": {"deleted_at": str(team.deleted_at)}}, ensure_ascii=False),
|
||||
)
|
||||
return {"message": "ok"}
|
||||
@@ -23,6 +23,7 @@ from app.models.image_engine import ImageEngine
|
||||
from app.models.credit_ratio import CreditRatio
|
||||
from app.models.operation_log import OperationLog
|
||||
from app.enums.user import FrontendUserKind, UserType
|
||||
from app.enums.team import TEAM_UNASSIGNED_VALUE
|
||||
from app.schemas.admin import (
|
||||
CreditAdjustRequest,
|
||||
ModelConfigCreate,
|
||||
@@ -37,6 +38,7 @@ from app.schemas.admin import (
|
||||
UpdateFrontendUserKindRequest,
|
||||
OperationLogOut,
|
||||
)
|
||||
from app.schemas.team import UpdateUserTeamRequest
|
||||
from app.schemas.industry import IndustryConfigCreate, IndustryConfigOut
|
||||
from app.schemas.video_engine import VideoEngineCreate, VideoEngineOut
|
||||
from app.schemas.image_engine import ImageEngineCreate, ImageEngineOut
|
||||
@@ -50,6 +52,7 @@ from app.services.operation_log import log_operation
|
||||
from app.services.resource_signed_url_service import build_resource_signed_url
|
||||
from app.services.payment import sync_pending_orders, process_refund
|
||||
from app.services.resource_capacity_service import batch_get_user_resource_capacity_usage, get_user_resource_capacity_usage
|
||||
from app.services.team_service import batch_get_team_name_map, set_frontend_user_team
|
||||
|
||||
from app.services.generation_billing_service import (
|
||||
OWNER_GENERATION_RECORD,
|
||||
@@ -89,6 +92,7 @@ async def list_users(
|
||||
search: str = Query(""),
|
||||
user_type: str | None = Query(None, pattern="^(frontend|admin)$"),
|
||||
frontend_user_kind: str | None = Query(None, pattern="^(internal|external)$"),
|
||||
team_id: str | None = Query(None),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@@ -105,16 +109,28 @@ async def list_users(
|
||||
if frontend_user_kind:
|
||||
query = query.where(User.user_type == UserType.FRONTEND.value, User.frontend_user_kind == frontend_user_kind)
|
||||
count_query = count_query.where(User.user_type == UserType.FRONTEND.value, User.frontend_user_kind == frontend_user_kind)
|
||||
if team_id:
|
||||
team_filter = User.team_id.is_(None) if team_id == TEAM_UNASSIGNED_VALUE else (User.team_id == team_id)
|
||||
query = query.where(team_filter)
|
||||
count_query = count_query.where(team_filter)
|
||||
total = (await db.execute(count_query)).scalar() or 0
|
||||
result = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
|
||||
items = result.scalars().all()
|
||||
capacity_map = await batch_get_user_resource_capacity_usage(db, [u.id for u in items])
|
||||
users = list(result.scalars().all())
|
||||
user_ids = [u.id for u in users]
|
||||
team_ids = [getattr(u, "team_id", None) for u in users if getattr(u, "team_id", None)]
|
||||
capacity_map = await batch_get_user_resource_capacity_usage(db, user_ids)
|
||||
team_name_map = await batch_get_team_name_map(db, team_ids)
|
||||
return {
|
||||
"items": [
|
||||
AdminUserOut.model_validate(u)
|
||||
.model_copy(update={"resource_capacity": capacity_map.get(u.id)})
|
||||
AdminUserOut.model_validate(user)
|
||||
.model_copy(
|
||||
update={
|
||||
"resource_capacity": capacity_map.get(user.id),
|
||||
"team_name": team_name_map.get(getattr(user, "team_id", None)),
|
||||
}
|
||||
)
|
||||
.model_dump(mode="json")
|
||||
for u in items
|
||||
for user in users
|
||||
],
|
||||
"total": total,
|
||||
}
|
||||
@@ -194,8 +210,12 @@ async def get_user(
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
user.credits = round(user.credits, 2)
|
||||
resource_capacity = await get_user_resource_capacity_usage(db, user.id)
|
||||
team_name_map = await batch_get_team_name_map(db, [getattr(user, "team_id", None)])
|
||||
return AdminUserOut.model_validate(user).model_copy(
|
||||
update={"resource_capacity": resource_capacity}
|
||||
update={
|
||||
"resource_capacity": resource_capacity,
|
||||
"team_name": team_name_map.get(getattr(user, "team_id", None)),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -254,6 +274,29 @@ async def update_user_frontend_kind(
|
||||
return user
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/team", response_model=AdminUserOut)
|
||||
async def update_user_team(
|
||||
user_id: str,
|
||||
req: UpdateUserTeamRequest,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user, before, after = await set_frontend_user_team(db, user_id=user_id, team_id=req.team_id)
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"设置用户团队 {user.username}",
|
||||
"PUT",
|
||||
f"/admin/users/{user_id}/team",
|
||||
detail=json.dumps({"before": before, "after": after}, ensure_ascii=False),
|
||||
)
|
||||
resource_capacity = await get_user_resource_capacity_usage(db, user.id)
|
||||
return AdminUserOut.model_validate(user).model_copy(
|
||||
update={"resource_capacity": resource_capacity, "team_name": after.get("team_name")}
|
||||
)
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/reset-password")
|
||||
async def reset_user_password(
|
||||
user_id: str,
|
||||
@@ -299,6 +342,7 @@ async def list_credit_records(
|
||||
user_name: str | None = Query(None),
|
||||
user_type: str | None = Query(None),
|
||||
frontend_user_kind: str | None = Query(None),
|
||||
team_id: str | None = Query(None),
|
||||
record_type: str | None = Query(None),
|
||||
type: str | None = Query(None),
|
||||
credit_subject: str | None = Query(None),
|
||||
@@ -321,6 +365,7 @@ async def list_credit_records(
|
||||
user_name=user_name,
|
||||
user_type=user_type,
|
||||
frontend_user_kind=frontend_user_kind,
|
||||
team_id=team_id,
|
||||
record_type=record_type or type,
|
||||
credit_subject=credit_subject,
|
||||
media_type=media_type,
|
||||
|
||||
@@ -11,3 +11,4 @@ from app.enums.generation_status import *
|
||||
from app.enums.sms import *
|
||||
from app.enums.notification import *
|
||||
from app.enums.resource_capacity import *
|
||||
from app.enums.team import *
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class TeamStatus(str, Enum):
|
||||
ACTIVE = "active"
|
||||
DISABLED = "disabled"
|
||||
|
||||
|
||||
TEAM_STATUS_LABELS = {
|
||||
TeamStatus.ACTIVE.value: "启用",
|
||||
TeamStatus.DISABLED.value: "禁用",
|
||||
}
|
||||
|
||||
# 前端筛选“未分配团队”时使用的稳定哨兵值,不与真实团队ID混用。
|
||||
TEAM_UNASSIGNED_VALUE = "__none__"
|
||||
@@ -1,5 +1,6 @@
|
||||
from app.models.base import Base, TimestampMixin, SoftDeleteMixin, engine, async_session, init_database, close_database
|
||||
from app.models.user import User
|
||||
from app.models.team import Team
|
||||
from app.models.project import Project
|
||||
from app.models.generation_record import GenerationRecord
|
||||
from app.models.credit_record import CreditRecord
|
||||
@@ -33,7 +34,7 @@ from app.models.user_oauth_app import UserOAuthApp
|
||||
__all__ = [
|
||||
"Base", "TimestampMixin", "SoftDeleteMixin", "engine", "async_session",
|
||||
"init_database", "close_database",
|
||||
"User", "Project", "GenerationRecord", "CreditRecord",
|
||||
"User", "Team", "Project", "GenerationRecord", "CreditRecord",
|
||||
"ModelConfig", "SystemConfig", "Notification", "PaymentOrder",
|
||||
"TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio",
|
||||
"MenuConfig", "RechargePackage", "OperationLog",
|
||||
|
||||
@@ -16,6 +16,8 @@ class CreditRecord(Base, TimestampMixin):
|
||||
Index("ix_credit_records_subject_media", "credit_subject", "media_type"),
|
||||
Index("ix_credit_records_source_module_scene", "source_module", "billing_scene"),
|
||||
Index("ix_credit_records_user_kind_time", "user_type_snapshot", "frontend_user_kind_snapshot", "created_at"),
|
||||
Index("ix_credit_records_team_time", "team_id_snapshot", "created_at"),
|
||||
Index("ix_credit_records_user_kind_team_time", "user_type_snapshot", "frontend_user_kind_snapshot", "team_id_snapshot", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
@@ -66,3 +68,6 @@ class CreditRecord(Base, TimestampMixin):
|
||||
|
||||
user_type_snapshot: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
|
||||
frontend_user_kind_snapshot: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
|
||||
# 交易流水发生时的团队归属冷备快照;用户后续改团队不影响历史流水展示与筛选。
|
||||
team_id_snapshot: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
team_name_snapshot: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
from sqlalchemy import Index, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.enums.team import TeamStatus
|
||||
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
|
||||
class Team(Base, TimestampMixin, SoftDeleteMixin):
|
||||
__tablename__ = "teams"
|
||||
__table_args__ = (
|
||||
Index("ix_teams_status_sort", "status", "sort_order", "created_at"),
|
||||
Index("ix_teams_code", "code"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="团队名称")
|
||||
code: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="团队编码")
|
||||
description: Mapped[str | None] = mapped_column(String(512), nullable=True, comment="团队备注")
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16),
|
||||
default=TeamStatus.ACTIVE.value,
|
||||
server_default=TeamStatus.ACTIVE.value,
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="团队状态:active启用,disabled禁用",
|
||||
)
|
||||
sort_order: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=0,
|
||||
server_default="0",
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="排序值,越小越靠前",
|
||||
)
|
||||
@@ -29,6 +29,8 @@ class User(Base, TimestampMixin):
|
||||
index=True,
|
||||
nullable=False,
|
||||
)
|
||||
# 当前归属团队,仅前台用户有业务意义;不影响 frontend_user_kind 内部/外部设置。
|
||||
team_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
@@ -52,6 +52,8 @@ class AdminUserOut(BaseModel):
|
||||
is_admin: bool
|
||||
user_type: str = "frontend"
|
||||
frontend_user_kind: str = "external"
|
||||
team_id: str | None = None
|
||||
team_name: str | None = None
|
||||
created_at: NaiveDatetime
|
||||
last_login_at: NaiveDatetimeOptional = None
|
||||
allowed_menus: list | None = None
|
||||
@@ -142,6 +144,8 @@ class AdminCreditRecordOut(BaseModel):
|
||||
user_type_label: str | None = None
|
||||
frontend_user_kind: str | None = None
|
||||
frontend_user_kind_label: str | None = None
|
||||
team_id_snapshot: str | None = None
|
||||
team_name_snapshot: str | None = None
|
||||
type: str
|
||||
record_type: str
|
||||
record_type_label: str | None = None
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.enums.team import TeamStatus
|
||||
from app.schemas.common import NaiveDatetime, NaiveDatetimeOptional
|
||||
|
||||
|
||||
class TeamBase(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=128, description="团队名称")
|
||||
code: str | None = Field(None, max_length=64, description="团队编码,可选,建议用于内部标识")
|
||||
description: str | None = Field(None, max_length=512, description="团队备注")
|
||||
status: str = Field(default=TeamStatus.ACTIVE.value, pattern="^(active|disabled)$", description="团队状态:active启用,disabled禁用")
|
||||
sort_order: int = Field(default=0, ge=0, le=999999, description="排序值,越小越靠前")
|
||||
|
||||
|
||||
class TeamCreate(TeamBase):
|
||||
pass
|
||||
|
||||
|
||||
class TeamUpdate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=128, description="团队名称")
|
||||
code: str | None = Field(None, max_length=64, description="团队编码,可选,建议用于内部标识")
|
||||
description: str | None = Field(None, max_length=512, description="团队备注")
|
||||
status: str = Field(default=TeamStatus.ACTIVE.value, pattern="^(active|disabled)$", description="团队状态:active启用,disabled禁用")
|
||||
sort_order: int = Field(default=0, ge=0, le=999999, description="排序值,越小越靠前")
|
||||
|
||||
|
||||
class TeamOut(TeamBase):
|
||||
id: str
|
||||
member_count: int = 0
|
||||
created_at: NaiveDatetime
|
||||
updated_at: NaiveDatetimeOptional = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TeamOptionOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
code: str | None = None
|
||||
status: str = TeamStatus.ACTIVE.value
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TeamListOut(BaseModel):
|
||||
items: list[TeamOut]
|
||||
total: int
|
||||
|
||||
|
||||
class UpdateUserTeamRequest(BaseModel):
|
||||
team_id: str | None = Field(None, description="团队ID;传 null 表示取消团队归属")
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user