417 lines
14 KiB
Python
417 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone, timedelta
|
|
|
|
CST = timezone(timedelta(hours=8))
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from starlette.responses import StreamingResponse
|
|
|
|
from app.config import settings
|
|
from app.dependencies import get_current_user, get_db, get_optional_current_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.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"])
|
|
|
|
|
|
# ── 获取当前用户管理的团队 ──────────────────────────────
|
|
@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="您不是任何团队的管理人")
|
|
|
|
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.direction or "increase",
|
|
req.description,
|
|
)
|
|
return {"message": "ok"}
|
|
|
|
|
|
# ── 邀请码管理 ────────────────────────────────────────
|
|
@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:
|
|
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,
|
|
"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": "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", )
|
|
async def get_join_info(
|
|
code: str = Query(...),
|
|
current_user: User | None = Depends(get_optional_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, already_in_team=False, has_pending_request=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 and current_user.team_id == invitation.team_id
|
|
|
|
has_pending_request = False
|
|
if current_user:
|
|
from app.models.team_join_request import TeamJoinRequest
|
|
pending = await db.execute(
|
|
select(TeamJoinRequest).where(
|
|
TeamJoinRequest.user_id == current_user.id,
|
|
TeamJoinRequest.team_id == invitation.team_id,
|
|
TeamJoinRequest.status == "pending",
|
|
).limit(1)
|
|
)
|
|
has_pending = pending.scalar_one_or_none()
|
|
has_pending_request = has_pending 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/public", )
|
|
async def get_join_info_public(
|
|
code: str = Query(...),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""公开接口:验证邀请码并返回团队信息(无需登录)。"""
|
|
invitation = await team_invitation_service.get_invitation_by_code(db, code)
|
|
if not invitation:
|
|
return {"team_name": "", "team_id": "", "valid": False, "already_in_team": False, "has_pending_request": 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 ""
|
|
|
|
return {
|
|
"team_name": team_name,
|
|
"team_id": invitation.team_id,
|
|
"valid": True,
|
|
"already_in_team": False,
|
|
"has_pending_request": False,
|
|
}
|
|
|
|
|
|
@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)
|
|
|
|
# 获取团队名
|
|
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["created_at"],
|
|
handled_at=r.get("handled_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"}
|
|
|
|
|
|
# ── 团队积分变动记录 ────────────────────────────────────
|
|
@router.get("/credit-records")
|
|
async def list_team_credit_records(
|
|
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="按手机号搜索"),
|
|
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal)$", description="流水类型"),
|
|
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),
|
|
):
|
|
"""查看团队所有成员的积分变动记录(仅管理人)。"""
|
|
team = await get_managed_team(db, current_user.id)
|
|
if not team:
|
|
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
|
|
|
|
from app.services.admin_credit_record_service import list_admin_credit_records
|
|
|
|
# 如果传了 phone,先找到对应的 user_id
|
|
resolved_user_id = user_id
|
|
if phone and not user_id:
|
|
phone_result = await db.execute(
|
|
select(User.id).where(
|
|
User.team_id == team.id,
|
|
User.phone == phone,
|
|
User.is_active.is_(True),
|
|
).limit(1)
|
|
)
|
|
resolved_user_id = phone_result.scalar_one_or_none()
|
|
if not resolved_user_id:
|
|
return {"items": [], "total": 0, "summary": {}}
|
|
|
|
return await list_admin_credit_records(
|
|
db,
|
|
page=page,
|
|
page_size=page_size,
|
|
team_id=team.id,
|
|
user_id=resolved_user_id,
|
|
record_type=record_type,
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
)
|
|
|
|
|
|
# ── 团队积分导出 Excel ──────────────────────────────────
|
|
@router.get("/credit-records/export")
|
|
async def export_team_credit_records(
|
|
user_id: str | None = Query(None),
|
|
phone: str | None = Query(None),
|
|
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal)$", description="流水类型"),
|
|
start_date: str | None = Query(None),
|
|
end_date: str | None = Query(None),
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""导出团队积分变动记录为 Excel(仅管理人)。"""
|
|
team = await get_managed_team(db, current_user.id)
|
|
if not team:
|
|
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
|
|
|
|
from app.services.admin_credit_record_service import list_admin_credit_records
|
|
|
|
resolved_user_id = user_id
|
|
if phone and not user_id:
|
|
phone_result = await db.execute(
|
|
select(User.id).where(
|
|
User.team_id == team.id,
|
|
User.phone == phone,
|
|
User.is_active.is_(True),
|
|
).limit(1)
|
|
)
|
|
resolved_user_id = phone_result.scalar_one_or_none()
|
|
|
|
# 拉取全部记录(不分页)
|
|
result = await list_admin_credit_records(
|
|
db,
|
|
page=1,
|
|
page_size=10000,
|
|
team_id=team.id,
|
|
user_id=resolved_user_id,
|
|
record_type=record_type,
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
)
|
|
|
|
# 生成 CSV(兼容 Excel 打开)
|
|
import csv
|
|
import io
|
|
|
|
def _format_dt(val):
|
|
if val is None:
|
|
return "-"
|
|
|
|
return str(datetime.fromtimestamp(val).strftime("%Y-%m-%d %H:%M:%S"))
|
|
|
|
output = io.StringIO()
|
|
writer = csv.writer(output)
|
|
writer.writerow(["用户名", "手机号", "类型", "积分变动", "余额", "说明", "时间"])
|
|
for item in result.get("items", []):
|
|
writer.writerow([
|
|
item.get("username") or "-",
|
|
item.get("phone") or "-",
|
|
item.get("record_type_label") or item.get("type") or "-",
|
|
item.get("amount", 0),
|
|
item.get("balance_after", 0),
|
|
item.get("description") or "-",
|
|
_format_dt(item.get("created_at")),
|
|
])
|
|
|
|
from starlette.responses import StreamingResponse
|
|
from urllib.parse import quote
|
|
output.seek(0)
|
|
safe_team_name = team.name or "team"
|
|
filename = f"团队积分_{safe_team_name}_{datetime.now(CST).strftime('%Y%m%d_%H%M%S')}.csv"
|
|
encoded_filename = quote(filename)
|
|
return StreamingResponse(
|
|
iter([output.getvalue()]),
|
|
media_type="text/csv; charset=utf-8-sig",
|
|
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"},
|
|
)
|