Files
2026-08-14 15:23:46 +08:00

487 lines
18 KiB
Python

from __future__ import annotations
import csv
import io
from datetime import datetime, timedelta, timezone
from urllib.parse import quote
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import StreamingResponse
from app.dependencies import get_current_user, get_db, get_optional_current_user
from app.enums.team import TEAM_STATUS_LABELS, TeamStatus
from app.enums.user import UserType
from app.models.team import Team
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 SetManagerRequest
from app.schemas.team_subscription import TeamSeatCreateRequest, TeamSeatUpdateRequest
from app.services import team_invitation_service
from app.services.credit.team_subscription_service import (
cancel_seat,
create_seat,
list_member_period_usage,
list_team_subscriptions_for_management,
update_seat,
)
from app.services.team_credit_record_service import list_team_credit_records as query_team_credit_records
from app.services.team_manager_service import (
get_managed_team,
get_manager_history,
get_team_members,
list_manager_access_teams,
set_team_manager,
)
router = APIRouter(prefix="/team", tags=["team"])
CST = timezone(timedelta(hours=8))
def _team_payload(team: Team, *, manager_name: str | None, member_count: int) -> dict:
status = team.status or TeamStatus.ACTIVE.value
return {
"id": team.id,
"name": team.name,
"code": team.code,
"description": team.description,
"status": status,
"status_label": TEAM_STATUS_LABELS.get(status, "其他状态"),
"is_read_only": status == TeamStatus.DISABLED.value,
"team_credit_frozen": status == TeamStatus.DISABLED.value,
"member_count": int(member_count),
"manager_id": team.manager_id,
"manager_name": manager_name,
"first_subscription_paid_at": team.first_subscription_paid_at,
}
async def _resolve_flow_team_id(db: AsyncSession, *, current_user: User, team_id: str | None) -> str:
if team_id:
# 真正的当前/历史队长权限由流水 Service 根据 TeamManagerHistory 再校验。
return team_id
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=400, detail="请指定需要查看的历史团队")
return team.id
@router.get("/managed")
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="您当前不是任何团队的队长")
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 _team_payload(team, manager_name=current_user.username, member_count=int(member_count))
@router.get("/manager-access")
async def list_manager_access(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""当前及历史队长可访问的团队列表,用于历史团队流水入口。"""
return await list_manager_access_teams(db, current_user.id)
@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.put("/manager")
async def transfer_manager(
req: SetManagerRequest,
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="只有当前团队队长可以转让队长")
await set_team_manager(db, team.id, req.user_id)
return {"message": "团队队长已更换"}
@router.post("/members/{member_id}/credits")
async def transfer_credits(
member_id: str,
req: dict = Body(default_factory=dict),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
del member_id, req, current_user, db
raise HTTPException(status_code=409, detail="当前版本不支持团队积分转账,请使用团队订阅席位额度")
@router.get("/subscriptions")
async def list_team_subscriptions(
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 list_team_subscriptions_for_management(db, team_id=team.id)
@router.post("/subscriptions/{subscription_id}/seats")
async def create_subscription_seat(
subscription_id: str,
req: TeamSeatCreateRequest,
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="只有当前团队队长可以管理团队订阅席位")
seat = await create_seat(
db,
team_id=team.id,
subscription_id=subscription_id,
manager_user_id=current_user.id,
user_id=req.user_id,
monthly_allocated_credits=req.monthly_allocated_credits,
)
return {"message": "席位已创建", "seat_id": seat.id}
@router.put("/seats/{seat_id}")
async def update_subscription_seat(
seat_id: str,
req: TeamSeatUpdateRequest,
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="只有当前团队队长可以管理团队订阅席位")
seat = await update_seat(
db,
team_id=team.id,
seat_id=seat_id,
manager_user_id=current_user.id,
monthly_allocated_credits=req.monthly_allocated_credits,
)
return {"message": "席位额度已更新", "seat_id": seat.id}
@router.delete("/seats/{seat_id}")
async def cancel_subscription_seat(
seat_id: str,
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="只有当前团队队长可以管理团队订阅席位")
await cancel_seat(db, team_id=team.id, seat_id=seat_id, manager_user_id=current_user.id)
return {"message": "席位已取消"}
@router.get("/member-usage")
async def get_member_usage(
subscription_id: str | None = Query(None),
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 list_member_period_usage(db, team_id=team.id, subscription_id=subscription_id)
@router.get("/manager-history")
async def manager_history(
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_manager_history(db, team.id)
@router.post("/invitations")
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 as exc:
raise HTTPException(status_code=400, detail="过期时间格式错误") from exc
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,
"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,
"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": "邀请码已撤销"}
@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": "申请已提交,请等待团队队长审批"}
async def _join_info_payload(
db: AsyncSession,
*,
code: str,
current_user: User | None,
) -> JoinTeamInfoOut:
invitation = await team_invitation_service.get_invitation_by_code(db, code)
if not invitation:
return JoinTeamInfoOut(team_name="", team_id="", valid=False, already_in_team=False, has_pending_request=False)
team_result = await db.execute(
select(Team).where(Team.id == invitation.team_id, Team.deleted_at.is_(None)).limit(1)
)
team = team_result.scalar_one_or_none()
if not team or team.status != TeamStatus.ACTIVE.value:
return JoinTeamInfoOut(
team_name=team.name if team else "",
team_id=invitation.team_id,
valid=False,
already_in_team=False,
has_pending_request=False,
)
already_in_team = bool(current_user and current_user.team_id == invitation.team_id)
has_pending_request = False
if current_user:
pending = await db.execute(
select(TeamJoinRequest.id).where(
TeamJoinRequest.user_id == current_user.id,
TeamJoinRequest.team_id == invitation.team_id,
TeamJoinRequest.status == "pending",
).limit(1)
)
has_pending_request = pending.scalar_one_or_none() is not None
return JoinTeamInfoOut(
team_name=team.name,
team_id=invitation.team_id,
valid=True,
already_in_team=already_in_team,
has_pending_request=has_pending_request,
)
@router.get("/join-info")
async def get_join_info(
code: str = Query(...),
current_user: User | None = Depends(get_optional_current_user),
db: AsyncSession = Depends(get_db),
):
return await _join_info_payload(db, code=code, current_user=current_user)
@router.get("/join-info/public")
async def get_join_info_public(code: str = Query(...), db: AsyncSession = Depends(get_db)):
return await _join_info_payload(db, code=code, current_user=None)
@router.get("/join-requests")
async def list_join_requests(
status: str | None = Query(None),
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_all_requests(db, team.id, status)
return [
JoinRequestOut(
id=item["id"],
team_id=item["team_id"],
team_name=team.name,
user_id=item["user_id"],
username=item["username"],
phone=item.get("phone"),
status=item["status"],
note=item.get("note"),
created_at=item["created_at"],
handled_at=item.get("handled_at"),
)
for item 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": "申请已处理"}
@router.get("/credit-records")
async def list_team_credit_records(
team_id: str | None = Query(None, description="历史队长查看旧团队时传团队ID"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user_id: str | None = Query(None),
phone: str | None = Query(None, description="按手机号搜索"),
subscription_id: str | None = Query(None),
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal|expire|revoke)$"),
start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD"),
end_date: str | None = Query(None, description="截止日期 YYYY-MM-DD"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
resolved_team_id = await _resolve_flow_team_id(db, current_user=current_user, team_id=team_id)
resolved_user_id = user_id
if phone and not resolved_user_id:
phone_result = await db.execute(select(User.id).where(User.phone == phone).limit(1))
resolved_user_id = phone_result.scalar_one_or_none()
if not resolved_user_id:
return {"items": [], "total": 0, "page": page, "page_size": page_size}
return await query_team_credit_records(
db,
team_id=resolved_team_id,
viewer_user_id=current_user.id,
page=page,
page_size=page_size,
member_user_id=resolved_user_id,
subscription_id=subscription_id,
record_type=record_type,
start_date=start_date,
end_date=end_date,
)
@router.get("/credit-records/export")
async def export_team_credit_records(
team_id: str | None = Query(None),
user_id: str | None = Query(None),
phone: str | None = Query(None),
subscription_id: str | None = Query(None),
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal|expire|revoke)$"),
start_date: str | None = Query(None),
end_date: str | None = Query(None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
resolved_team_id = await _resolve_flow_team_id(db, current_user=current_user, team_id=team_id)
resolved_user_id = user_id
if phone and not resolved_user_id:
phone_result = await db.execute(select(User.id).where(User.phone == phone).limit(1))
resolved_user_id = phone_result.scalar_one_or_none()
if not resolved_user_id:
# 导出筛选手机号不存在时必须返回空结果,不能因为 user_id=None 退化成导出整个团队流水。
resolved_user_id = "__not_found__"
result = await query_team_credit_records(
db,
team_id=resolved_team_id,
viewer_user_id=current_user.id,
page=1,
page_size=10000,
member_user_id=resolved_user_id,
subscription_id=subscription_id,
record_type=record_type,
start_date=start_date,
end_date=end_date,
)
team_result = await db.execute(select(Team).where(Team.id == resolved_team_id).limit(1))
team = team_result.scalar_one_or_none()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["用户名", "流水类型", "团队积分变动", "说明", "订阅实例", "周期ID", "席位ID", "时间"])
for item in result.get("items", []):
created_at = item.get("created_at")
if isinstance(created_at, datetime):
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=CST)
else:
created_at = created_at.astimezone(CST)
created_at = created_at.strftime("%Y-%m-%d %H:%M:%S")
writer.writerow([
item.get("username") or "-",
item.get("record_type_label") or "-",
item.get("team_amount", 0),
item.get("description") or "-",
item.get("subscription_no") or "历史订阅",
item.get("subscription_period_id") or "-",
item.get("seat_id") or "-",
created_at or "-",
])
filename = f"团队积分_{(team.name if team else resolved_team_id)}_{datetime.now(CST).strftime('%Y%m%d_%H%M%S')}.csv"
output.seek(0)
return StreamingResponse(
iter(["\ufeff" + output.getvalue()]),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
)