487 lines
18 KiB
Python
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)}"},
|
|
)
|