1、后台/teams需要可以设置管理人,后台只给这个管理人分配积分,然后前台管理员自己分配积分
2、前台判断用户是管理人,左下角显示团队管理 3、团队管理可以看到团队的人员、分配人员积分、人员的积分情况的功能 4、团队管理还可以邀请用户,比如生成个链接,未注册需要注册绑定团队,已注册用户访问弹窗是否加入这个团队,然后都需要管理同意才可以加入团队
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
"""add team manager and invite system
|
||||
|
||||
Revision ID: 20260706_20260706
|
||||
Revises: 20260703_20260703
|
||||
Create Date: 2026-07-06 10:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '20260706_20260706'
|
||||
down_revision: Union[str, None] = '20260703_20260703'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# teams 表新增 manager_id 列
|
||||
op.add_column('teams', sa.Column('manager_id', sa.String(length=32), nullable=True))
|
||||
op.create_index(op.f('ix_teams_manager_id'), 'teams', ['manager_id'], unique=False)
|
||||
op.create_foreign_key('fk_teams_manager_id', 'teams', 'users', ['manager_id'], ['id'])
|
||||
|
||||
# 邀请码表
|
||||
op.create_table('team_invitations',
|
||||
sa.Column('id', sa.String(length=32), nullable=False),
|
||||
sa.Column('team_id', sa.String(length=32), nullable=False),
|
||||
sa.Column('code', sa.String(length=64), nullable=False),
|
||||
sa.Column('created_by', sa.String(length=32), nullable=False),
|
||||
sa.Column('status', sa.String(length=16), server_default='active', nullable=False),
|
||||
sa.Column('max_uses', sa.Integer(), nullable=True),
|
||||
sa.Column('use_count', sa.Integer(), server_default='0', nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||
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.ForeignKeyConstraint(['team_id'], ['teams.id'], ),
|
||||
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('code', name='uq_team_invitations_code')
|
||||
)
|
||||
op.create_index(op.f('ix_team_invitations_team_id'), 'team_invitations', ['team_id'], unique=False)
|
||||
op.create_index(op.f('ix_team_invitations_code'), 'team_invitations', ['code'], unique=False)
|
||||
op.create_index('ix_team_invitations_team', 'team_invitations', ['team_id', 'status'], unique=False)
|
||||
|
||||
# 加入申请表
|
||||
op.create_table('team_join_requests',
|
||||
sa.Column('id', sa.String(length=32), nullable=False),
|
||||
sa.Column('team_id', sa.String(length=32), nullable=False),
|
||||
sa.Column('user_id', sa.String(length=32), nullable=False),
|
||||
sa.Column('invitation_id', sa.String(length=32), nullable=True),
|
||||
sa.Column('status', sa.String(length=16), server_default='pending', nullable=False),
|
||||
sa.Column('note', sa.String(length=256), nullable=True),
|
||||
sa.Column('handled_by', sa.String(length=32), nullable=True),
|
||||
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.ForeignKeyConstraint(['team_id'], ['teams.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['invitation_id'], ['team_invitations.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_team_join_requests_team_id'), 'team_join_requests', ['team_id'], unique=False)
|
||||
op.create_index(op.f('ix_team_join_requests_user_id'), 'team_join_requests', ['user_id'], unique=False)
|
||||
op.create_index('ix_tjr_team_status', 'team_join_requests', ['team_id', 'status'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_tjr_team_status', table_name='team_join_requests')
|
||||
op.drop_index(op.f('ix_team_join_requests_user_id'), table_name='team_join_requests')
|
||||
op.drop_index(op.f('ix_team_join_requests_team_id'), table_name='team_join_requests')
|
||||
op.drop_table('team_join_requests')
|
||||
|
||||
op.drop_index('ix_team_invitations_team', table_name='team_invitations')
|
||||
op.drop_index(op.f('ix_team_invitations_code'), table_name='team_invitations')
|
||||
op.drop_index(op.f('ix_team_invitations_team_id'), table_name='team_invitations')
|
||||
op.drop_table('team_invitations')
|
||||
|
||||
op.drop_constraint('fk_teams_manager_id', 'teams', type_='foreignkey')
|
||||
op.drop_index(op.f('ix_teams_manager_id'), table_name='teams')
|
||||
op.drop_column('teams', 'manager_id')
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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()}",
|
||||
)
|
||||
Reference in New Issue
Block a user