1、后台/teams需要可以设置管理人,后台只给这个管理人分配积分,然后前台管理员自己分配积分

2、前台判断用户是管理人,左下角显示团队管理
3、团队管理可以看到团队的人员、分配人员积分、人员的积分情况的功能
4、团队管理还可以邀请用户,比如生成个链接,未注册需要注册绑定团队,已注册用户访问弹窗是否加入这个团队,然后都需要管理同意才可以加入团队
This commit is contained in:
2026-07-06 13:38:27 +08:00
parent 30db9fb425
commit 16b3d223c1
25 changed files with 1671 additions and 29 deletions
+68 -23
View File
@@ -2,18 +2,57 @@ from __future__ import annotations
import json
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Body, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.models.team import Team
from app.models.user import User
from app.schemas.team import TeamCreate, TeamListOut, TeamOptionOut, TeamOut, TeamUpdate
from app.schemas.team_manager import SetManagerRequest
from app.services.operation_log import log_operation
from app.services.team_manager_service import set_team_manager
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"])
async def _team_detail_payload(db: AsyncSession, team: Team) -> dict:
"""构造返回团队详情,包含 manager_name。"""
payload = {
"id": team.id,
"name": team.name,
"code": getattr(team, "code", None),
"description": getattr(team, "description", None),
"status": getattr(team, "status", "active"),
"sort_order": getattr(team, "sort_order", 0) or 0,
"member_count": 0,
"created_at": team.created_at,
"updated_at": team.updated_at,
"manager_id": getattr(team, "manager_id", None),
"manager_name": None,
}
# 查询成员数和管理人用户名
from sqlalchemy import func
from app.enums.user import UserType
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
payload["member_count"] = int(member_count)
if getattr(team, "manager_id", None):
mgr = await db.execute(
select(User.username).where(User.id == team.manager_id).limit(1)
)
payload["manager_name"] = mgr.scalar_one_or_none()
return payload
@router.get("", response_model=TeamListOut)
async def list_admin_teams(
page: int = Query(1, ge=1),
@@ -51,17 +90,7 @@ async def create_admin_team(
"/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,
}
return await _team_detail_payload(db, team)
@router.put("/{team_id}", response_model=TeamOut)
@@ -81,17 +110,33 @@ async def update_admin_team(
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,
}
return await _team_detail_payload(db, team)
@router.put("/{team_id}/manager", response_model=TeamOut)
async def set_team_manager_endpoint(
team_id: str,
req: SetManagerRequest = Body(...),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
team = await set_team_manager(db, team_id, req.user_id)
manager_name = None
if team.manager_id:
mgr = await db.execute(
select(User.username).where(User.id == team.manager_id).limit(1)
)
manager_name = mgr.scalar_one_or_none()
await log_operation(
db,
admin.id,
admin.username,
f"设置团队管理人 {team.name}: {manager_name or '取消'}",
"PUT",
f"/admin/teams/{team_id}/manager",
detail=json.dumps({"manager_id": req.user_id}, ensure_ascii=False),
)
return await _team_detail_payload(db, team)
@router.delete("/{team_id}")
+2
View File
@@ -28,6 +28,7 @@ from app.api.v1.material_consumption import router as material_consumption_route
from app.api.v1.open_type import router as open_type_router
from app.api.v1.resources_material import router as resources_material_router
from app.api.v1.contact import router as contact_router
from app.api.v1.team import router as team_router
from app.api.v1.home_materials import router as home_materials_router
from app.api.admin import router as admin_module_router
@@ -60,5 +61,6 @@ api_router.include_router(material_consumption_router)
api_router.include_router(open_type_router)
api_router.include_router(resources_material_router)
api_router.include_router(contact_router)
api_router.include_router(team_router)
api_router.include_router(home_materials_router)
api_router.include_router(admin_module_router)
+16 -1
View File
@@ -256,8 +256,23 @@ async def get_me(
current_user.username = "用户"+current_user.username[-4:] if current_user.username == current_user.phone else current_user.username
current_user.credits = round(current_user.credits, 2)
resource_capacity = await get_user_resource_capacity_usage(db, current_user.id)
# 计算 is_team_manager 和 team_name
is_team_manager = False
team_name = None
if current_user.team_id:
from app.services.team_manager_service import is_team_manager, batch_get_team_name_map
is_team_manager = await is_team_manager(db, current_user.id, current_user.team_id)
name_map = await batch_get_team_name_map(db, [current_user.team_id])
team_name = name_map.get(current_user.team_id)
return UserOut.model_validate(current_user).model_copy(
update={"resource_capacity": resource_capacity}
update={
"resource_capacity": resource_capacity,
"is_team_manager": is_team_manager,
"team_id": current_user.team_id,
"team_name": team_name,
}
)
+255
View File
@@ -0,0 +1,255 @@
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.dependencies import get_current_user, get_db
from app.models.team import Team
from app.models.team_invitation import TeamInvitation
from app.models.team_join_request import TeamJoinRequest
from app.models.user import User
from app.schemas.team_invitation import TeamInvitationCreate, TeamInvitationOut
from app.schemas.team_join_request import (
JoinByCodeRequest,
JoinRequestHandle,
JoinRequestOut,
JoinTeamInfoOut,
)
from app.schemas.team_manager import (
ManagedTeamOut,
ManagerTransferRequest,
SetManagerRequest,
TeamMemberOut,
)
from app.services import team_invitation_service
from app.services.team_manager_service import (
get_managed_team,
get_team_members,
is_team_manager,
transfer_credits_to_member,
)
router = APIRouter(prefix="/team", tags=["team"])
def _build_invite_link(code: str) -> str:
base = getattr(settings, "FRONTEND_URL", "") or getattr(settings, "BASE_URL", "")
return f"{base}/join-team?code={code}"
# ── 获取当前用户管理的团队 ──────────────────────────────
@router.get("/managed", response_model=ManagedTeamOut)
async def get_managed_team_info(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=404, detail="您不是任何团队的管理人")
from sqlalchemy import func
from app.enums.user import UserType
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
return {
"id": team.id,
"name": team.name,
"code": team.code,
"description": team.description,
"status": team.status,
"member_count": int(member_count),
"manager_id": team.manager_id,
"manager_name": current_user.username,
}
# ── 团队成员列表 ──────────────────────────────────────
@router.get("/members")
async def list_team_members(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
return await get_team_members(db, team.id, page=page, page_size=page_size)
# ── 转账积分给成员 ────────────────────────────────────
@router.post("/members/{member_id}/credits")
async def transfer_credits(
member_id: str,
req: ManagerTransferRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await transfer_credits_to_member(
db,
current_user.id,
req.target_user_id,
req.amount,
req.description,
)
return {"message": "ok"}
# ── 邀请码管理 ────────────────────────────────────────
@router.post("/invitations", response_model=TeamInvitationOut)
async def create_invitation(
req: TeamInvitationCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可创建邀请码")
expires_at = None
if req.expires_at:
try:
expires_at = datetime.fromisoformat(req.expires_at)
except ValueError:
raise HTTPException(status_code=400, detail="过期时间格式错误")
invitation = await team_invitation_service.create_invitation(
db, team.id, current_user.id, req.max_uses, expires_at
)
return {
"id": invitation.id,
"team_id": invitation.team_id,
"code": invitation.code,
"status": invitation.status,
"max_uses": invitation.max_uses,
"use_count": invitation.use_count,
"expires_at": invitation.expires_at,
"invite_link": _build_invite_link(invitation.code),
"created_at": invitation.created_at,
}
@router.get("/invitations", response_model=list[TeamInvitationOut])
async def list_invitations(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
invitations = await team_invitation_service.get_invitations_for_team(db, team.id)
return [
{
"id": inv.id,
"team_id": inv.team_id,
"code": inv.code,
"status": inv.status,
"max_uses": inv.max_uses,
"use_count": inv.use_count,
"expires_at": inv.expires_at,
"invite_link": _build_invite_link(inv.code),
"created_at": inv.created_at,
}
for inv in invitations
]
@router.delete("/invitations/{invitation_id}")
async def revoke_invitation(
invitation_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await team_invitation_service.revoke_invitation(db, invitation_id, current_user.id)
return {"message": "ok"}
# ── 加入申请 ──────────────────────────────────────────
@router.post("/join")
async def join_by_code(
req: JoinByCodeRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await team_invitation_service.create_join_request(db, current_user.id, req.invitation_code)
return {"message": "申请已提交,请等待团队管理人审批"}
@router.get("/join-info", response_model=JoinTeamInfoOut)
async def get_join_info(
code: str = Query(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""验证邀请码并返回团队信息(用于加入页面展示)。"""
invitation = await team_invitation_service.get_invitation_by_code(db, code)
if not invitation:
return JoinTeamInfoOut(team_name="", team_id="", valid=False)
team = await db.execute(
select(Team.name).where(Team.id == invitation.team_id, Team.deleted_at.is_(None)).limit(1)
)
team_name = team.scalar_one_or_none() or ""
already_in_team = current_user.team_id == invitation.team_id
return JoinTeamInfoOut(
team_name=team_name,
team_id=invitation.team_id,
valid=True,
already_in_team=already_in_team,
)
@router.get("/join-requests", response_model=list[JoinRequestOut])
async def list_join_requests(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
requests = await team_invitation_service.get_pending_requests(db, team.id)
# 获取团队名
team_name_result = await db.execute(
select(Team.name).where(Team.id == team.id).limit(1)
)
team_name = team_name_result.scalar_one_or_none() or ""
return [
JoinRequestOut(
id=r["id"],
team_id=r["team_id"],
team_name=team_name,
user_id=r["user_id"],
username=r["username"],
phone=r.get("phone"),
status=r["status"],
note=r.get("note"),
created_at=r.get("created_at"),
)
for r in requests
]
@router.post("/join-requests/{request_id}")
async def handle_join_request(
request_id: str,
req: JoinRequestHandle,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await team_invitation_service.handle_join_request(
db, request_id, current_user.id, req.action, req.note
)
return {"message": "ok"}
+3 -1
View File
@@ -1,6 +1,8 @@
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.team_invitation import TeamInvitation
from app.models.team_join_request import TeamJoinRequest
from app.models.project import Project
from app.models.generation_record import GenerationRecord
from app.models.credit_record import CreditRecord
@@ -36,7 +38,7 @@ from app.models.contact_request import ContactRequest
__all__ = [
"Base", "TimestampMixin", "SoftDeleteMixin", "engine", "async_session",
"init_database", "close_database",
"User", "Team", "Project", "GenerationRecord", "CreditRecord",
"User", "Team", "TeamInvitation", "TeamJoinRequest", "Project", "GenerationRecord", "CreditRecord",
"ModelConfig", "SystemConfig", "Notification", "PaymentOrder",
"TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio",
"MenuConfig", "RechargePackage", "OperationLog", "ContactRequest",
+4 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Index, Integer, String
from sqlalchemy import ForeignKey, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.team import TeamStatus
@@ -32,3 +32,6 @@ class Team(Base, TimestampMixin, SoftDeleteMixin):
index=True,
comment="排序值,越小越靠前",
)
manager_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("users.id"), nullable=True, index=True, comment="团队管理人ID"
)
@@ -0,0 +1,22 @@
from datetime import datetime
from sqlalchemy import ForeignKey, Index, Integer, String, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
class TeamInvitation(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "team_invitations"
__table_args__ = (
Index("ix_team_invitations_team", "team_id", "status"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
team_id: Mapped[str] = mapped_column(String(32), ForeignKey("teams.id"), index=True, nullable=False)
code: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
created_by: Mapped[str] = mapped_column(String(32), ForeignKey("users.id"), nullable=False)
status: Mapped[str] = mapped_column(String(16), default="active", server_default="active", nullable=False)
max_uses: Mapped[int | None] = mapped_column(Integer, nullable=True)
use_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -0,0 +1,19 @@
from sqlalchemy import ForeignKey, Index, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class TeamJoinRequest(Base, TimestampMixin):
__tablename__ = "team_join_requests"
__table_args__ = (
Index("ix_tjr_team_status", "team_id", "status"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
team_id: Mapped[str] = mapped_column(String(32), ForeignKey("teams.id"), index=True, nullable=False)
user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id"), index=True, nullable=False)
invitation_id: Mapped[str | None] = mapped_column(String(32), ForeignKey("team_invitations.id"), nullable=True)
status: Mapped[str] = mapped_column(String(16), default="pending", server_default="pending", nullable=False)
note: Mapped[str | None] = mapped_column(String(256), nullable=True)
handled_by: Mapped[str | None] = mapped_column(String(32), nullable=True)
+2
View File
@@ -29,6 +29,8 @@ class TeamOut(TeamBase):
member_count: int = 0
created_at: NaiveDatetime
updated_at: NaiveDatetimeOptional = None
manager_id: str | None = None
manager_name: str | None = None
model_config = {"from_attributes": True}
@@ -0,0 +1,22 @@
from pydantic import BaseModel, Field
from app.schemas.common import NaiveDatetimeOptional
class TeamInvitationCreate(BaseModel):
max_uses: int | None = Field(None, ge=1, description="最大使用次数,null 表示不限")
expires_at: str | None = Field(None, description="过期时间 ISO 格式,null 表示永不过期")
class TeamInvitationOut(BaseModel):
id: str
team_id: str
code: str
status: str
max_uses: int | None = None
use_count: int = 0
expires_at: NaiveDatetimeOptional = None
invite_link: str = ""
created_at: NaiveDatetimeOptional = None
model_config = {"from_attributes": True}
@@ -0,0 +1,33 @@
from pydantic import BaseModel, Field
from app.schemas.common import NaiveDatetimeOptional
class JoinByCodeRequest(BaseModel):
invitation_code: str = Field(..., description="邀请码")
class JoinRequestHandle(BaseModel):
action: str = Field(..., pattern="^(approve|reject)$", description="approve 通过 / reject 拒绝")
note: str | None = Field(None, max_length=256, description="拒绝原因")
class JoinRequestOut(BaseModel):
id: str
team_id: str
team_name: str
user_id: str
username: str
phone: str | None = None
status: str
note: str | None = None
created_at: NaiveDatetimeOptional = None
model_config = {"from_attributes": True}
class JoinTeamInfoOut(BaseModel):
team_name: str
team_id: str
valid: bool
already_in_team: bool = False
+37
View File
@@ -0,0 +1,37 @@
from pydantic import BaseModel, Field
from app.schemas.common import NaiveDatetime
class SetManagerRequest(BaseModel):
user_id: str | None = Field(None, description="设为管理人的前台用户ID;传 null 表示取消管理人")
class TeamMemberOut(BaseModel):
id: str
username: str
phone: str | None = None
credits: float
is_active: bool = True
joined_at: NaiveDatetime
model_config = {"from_attributes": True}
class ManagerTransferRequest(BaseModel):
target_user_id: str = Field(..., description="接收积分的成员用户ID")
amount: float = Field(gt=0, description="转账积分数量(正数)")
description: str | None = Field(None, max_length=256, description="转账说明")
class ManagedTeamOut(BaseModel):
id: str
name: str
code: str | None = None
description: str | None = None
status: str
member_count: int = 0
manager_id: str | None = None
manager_name: str | None = None
model_config = {"from_attributes": True}
+3
View File
@@ -15,5 +15,8 @@ class UserOut(BaseModel):
allowed_menus: list | None = None
must_set_password: bool = False
resource_capacity: ResourceCapacityUsageOut | None = None
team_id: str | None = None
team_name: str | None = None
is_team_manager: bool = False
model_config = {"from_attributes": True}
@@ -0,0 +1,235 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.team import Team
from app.models.team_invitation import TeamInvitation
from app.models.team_join_request import TeamJoinRequest
from app.models.user import User
from app.utils.id_gen import generate_id
import secrets
def _generate_invite_code() -> str:
"""生成唯一邀请码。"""
return secrets.token_urlsafe(12)
async def _get_team_or_404(db: AsyncSession, team_id: str) -> Team:
result = await db.execute(
select(Team).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1)
)
team = result.scalar_one_or_none()
if not team:
raise HTTPException(status_code=404, detail="团队不存在")
return team
async def _assert_is_manager(db: AsyncSession, user_id: str, team_id: str) -> None:
team = await _get_team_or_404(db, team_id)
if team.manager_id != user_id:
raise HTTPException(status_code=403, detail="只有团队管理人才能操作")
async def create_invitation(
db: AsyncSession,
team_id: str,
created_by: str,
max_uses: int | None = None,
expires_at: datetime | None = None,
) -> TeamInvitation:
"""创建邀请码(仅团队管理人)。"""
await _assert_is_manager(db, created_by, team_id)
code = _generate_invite_code()
invitation = TeamInvitation(
id=generate_id(),
team_id=team_id,
code=code,
created_by=created_by,
status="active",
max_uses=max_uses,
expires_at=expires_at,
)
db.add(invitation)
await db.flush()
return invitation
async def get_invitation_by_code(db: AsyncSession, code: str) -> TeamInvitation | None:
"""根据邀请码获取邀请记录(验证有效)。"""
result = await db.execute(
select(TeamInvitation).where(
TeamInvitation.code == code,
TeamInvitation.deleted_at.is_(None),
).limit(1)
)
invitation = result.scalar_one_or_none()
if not invitation or invitation.status != "active":
return None
if invitation.expires_at and invitation.expires_at < datetime.now(timezone.utc):
return None
if invitation.max_uses and invitation.use_count >= invitation.max_uses:
return None
return invitation
async def revoke_invitation(db: AsyncSession, invitation_id: str, revoked_by: str) -> None:
"""撤销邀请码。"""
result = await db.execute(
select(TeamInvitation).where(
TeamInvitation.id == invitation_id,
TeamInvitation.deleted_at.is_(None),
).limit(1)
)
invitation = result.scalar_one_or_none()
if not invitation:
raise HTTPException(status_code=404, detail="邀请码不存在")
await _assert_is_manager(db, revoked_by, invitation.team_id)
invitation.status = "revoked"
await db.flush()
async def create_join_request(
db: AsyncSession,
user_id: str,
invitation_code: str,
) -> TeamJoinRequest:
"""通过邀请码创建加入申请。"""
invitation = await get_invitation_by_code(db, invitation_code)
if not invitation:
raise HTTPException(status_code=400, detail="邀请码无效或已过期/已用完")
# 验证用户存在
user_result = await db.execute(
select(User).where(User.id == user_id, User.deleted_at.is_(None)).limit(1)
)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# 检查是否已在该团队
if user.team_id == invitation.team_id:
raise HTTPException(status_code=400, detail="您已在此团队中")
# 检查是否已有待审批的申请
existing = await db.execute(
select(TeamJoinRequest).where(
TeamJoinRequest.user_id == user_id,
TeamJoinRequest.team_id == invitation.team_id,
TeamJoinRequest.status == "pending",
).limit(1)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="您已提交申请,请等待审批")
# 检查是否已有待审批的其他团队申请
existing_any = await db.execute(
select(TeamJoinRequest).where(
TeamJoinRequest.user_id == user_id,
TeamJoinRequest.status == "pending",
).limit(1)
)
if existing_any.scalar_one_or_none():
raise HTTPException(status_code=400, detail="您已有一个待审批的团队申请,请先等待处理")
request = TeamJoinRequest(
id=generate_id(),
team_id=invitation.team_id,
user_id=user_id,
invitation_id=invitation.id,
status="pending",
)
db.add(request)
# 增加邀请码使用次数
invitation.use_count = (invitation.use_count or 0) + 1
if invitation.max_uses and invitation.use_count >= invitation.max_uses:
invitation.status = "revoked"
await db.flush()
return request
async def handle_join_request(
db: AsyncSession,
request_id: str,
manager_id: str,
action: str,
note: str | None = None,
) -> None:
"""审批/拒绝加入申请(仅团队管理人)。"""
result = await db.execute(
select(TeamJoinRequest).where(TeamJoinRequest.id == request_id).limit(1)
)
request = result.scalar_one_or_none()
if not request:
raise HTTPException(status_code=404, detail="申请不存在")
if request.status != "pending":
raise HTTPException(status_code=400, detail="该申请已处理")
await _assert_is_manager(db, manager_id, request.team_id)
if action == "approve":
# 检查用户是否已在其他团队
user_result = await db.execute(
select(User).where(User.id == request.user_id, User.deleted_at.is_(None)).limit(1)
)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if user.team_id and user.team_id != request.team_id:
raise HTTPException(status_code=400, detail="用户已在其他团队中,无法加入")
user.team_id = request.team_id
request.status = "approved"
elif action == "reject":
request.status = "rejected"
request.note = note
else:
raise HTTPException(status_code=400, detail="无效操作")
request.handled_by = manager_id
await db.flush()
async def get_pending_requests(db: AsyncSession, team_id: str) -> list[dict[str, Any]]:
"""获取团队待审批的申请列表。"""
result = await db.execute(
select(TeamJoinRequest, User.username, User.phone)
.join(User, User.id == TeamJoinRequest.user_id)
.where(
TeamJoinRequest.team_id == team_id,
TeamJoinRequest.status == "pending",
)
.order_by(TeamJoinRequest.created_at.asc())
)
rows = result.all()
return [
{
"id": req.id,
"team_id": req.team_id,
"user_id": req.user_id,
"username": username,
"phone": phone,
"status": req.status,
"note": req.note,
"created_at": req.created_at,
}
for req, username, phone in rows
]
async def get_invitations_for_team(db: AsyncSession, team_id: str) -> list[TeamInvitation]:
"""获取团队的所有邀请码。"""
result = await db.execute(
select(TeamInvitation).where(
TeamInvitation.team_id == team_id,
TeamInvitation.deleted_at.is_(None),
).order_by(TeamInvitation.created_at.desc())
)
return list(result.scalars().all())
@@ -0,0 +1,183 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from fastapi import HTTPException
from sqlalchemy import 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.services.credits import add_credits, deduct_credits
from app.utils.id_gen import generate_id
async def set_team_manager(db: AsyncSession, team_id: str, user_id: str | None) -> Team:
"""设置团队管理人。user_id 为 None 表示取消管理人。"""
result = await db.execute(
select(Team).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1)
)
team = result.scalar_one_or_none()
if not team:
raise HTTPException(status_code=404, detail="团队不存在")
if user_id is None:
team.manager_id = None
await db.flush()
return team
# 验证用户存在、是前台用户、属于该团队
user_result = await db.execute(
select(User).where(User.id == user_id, User.deleted_at.is_(None)).limit(1)
)
user = 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="仅前台用户可设为团队管理人")
if user.team_id != team_id:
raise HTTPException(status_code=400, detail="用户不属于该团队,请先将其加入团队")
team.manager_id = user_id
await db.flush()
return team
async def is_team_manager(db: AsyncSession, user_id: str, team_id: str | None) -> bool:
"""判断用户是否是指定团队的管理人。"""
if not team_id:
return False
result = await db.execute(
select(Team.manager_id).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1)
)
manager_id = result.scalar_one_or_none()
return manager_id == user_id
async def get_managed_team(db: AsyncSession, user_id: str) -> Team | None:
"""获取用户管理的团队。"""
result = await db.execute(
select(Team).where(Team.manager_id == user_id, Team.deleted_at.is_(None)).limit(1)
)
return result.scalar_one_or_none()
async def get_team_members(
db: AsyncSession,
team_id: str,
*,
page: int = 1,
page_size: int = 20,
) -> dict[str, Any]:
"""列出团队成员(仅前台用户)。"""
page = max(int(page or 1), 1)
page_size = min(max(int(page_size or 20), 1), 100)
# 验证团队存在
team_result = await db.execute(
select(Team).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1)
)
if not team_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="团队不存在")
# 总数
from sqlalchemy import func
total = (await db.execute(
select(func.count(User.id)).where(
User.user_type == UserType.FRONTEND.value,
User.team_id == team_id,
User.deleted_at.is_(None),
)
)).scalar() or 0
# 列表
result = await db.execute(
select(User).where(
User.user_type == UserType.FRONTEND.value,
User.team_id == team_id,
User.deleted_at.is_(None),
)
.order_by(User.created_at.asc())
.offset((page - 1) * page_size)
.limit(page_size)
)
members = list(result.scalars().all())
return {
"items": [
{
"id": m.id,
"username": m.username,
"phone": m.phone,
"credits": m.credits,
"is_active": m.is_active,
"joined_at": m.created_at,
}
for m in members
],
"total": total,
}
async def transfer_credits_to_member(
db: AsyncSession,
manager_id: str,
target_member_id: str,
amount: float,
description: str | None = None,
) -> None:
"""管理人从自己余额转积分给团队成员。"""
if amount <= 0:
raise HTTPException(status_code=400, description="转账积分必须大于0")
# 获取管理人
manager_result = await db.execute(
select(User).where(User.id == manager_id, User.deleted_at.is_(None)).limit(1)
)
manager = manager_result.scalar_one_or_none()
if not manager:
raise HTTPException(status_code=404, detail="管理员不存在")
# 获取目标成员
member_result = await db.execute(
select(User).where(User.id == target_member_id, User.deleted_at.is_(None)).limit(1)
)
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=404, detail="成员不存在")
# 验证管理人是该团队的管理人且目标是同团队成员
if not manager.team_id:
raise HTTPException(status_code=400, detail="您不在任何团队中")
if member.team_id != manager.team_id:
raise HTTPException(status_code=400, detail="只能转账给同团队成员")
team_result = await db.execute(
select(Team).where(Team.id == manager.team_id, Team.deleted_at.is_(None)).limit(1)
)
team = team_result.scalar_one_or_none()
if not team or team.manager_id != manager_id:
raise HTTPException(status_code=403, detail="只有团队管理人才能分配积分")
desc = description or "团队积分发放"
# 从管理人扣减
await deduct_credits(
db,
manager_id,
amount,
f"分配给成员 {member.username}: {desc}",
biz_key=f"mgr_xfer_out:{manager_id}:{target_member_id}:{generate_id()}",
)
# 给成员增加
await add_credits(
db,
target_member_id,
amount,
f"来自团队管理人: {desc}",
record_type="admin",
biz_key=f"mgr_xfer_in:{manager_id}:{target_member_id}:{generate_id()}",
)