428 lines
15 KiB
Python
428 lines
15 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.dependencies import get_current_user, get_db, get_optional_current_user
|
||
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 (
|
||
ManagerTransferRequest,
|
||
)
|
||
from app.services import team_invitation_service
|
||
from app.services.team_manager_service import (
|
||
get_managed_team,
|
||
get_team_members,
|
||
)
|
||
|
||
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),
|
||
):
|
||
raise HTTPException(status_code=409, detail="当前版本积分暂未开放团队转账功能")
|
||
|
||
|
||
# ── 邀请码管理 ────────────────────────────────────────
|
||
@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 打开,UTF-8 BOM)
|
||
import csv
|
||
import io
|
||
from datetime import datetime as _dt
|
||
|
||
def _format_dt(val):
|
||
if val is None:
|
||
return "-"
|
||
try:
|
||
# 情况 1:已经是 datetime
|
||
if isinstance(val, _dt):
|
||
dt = val
|
||
elif isinstance(val, (int, float)):
|
||
# 情况 2:Unix 时间戳(极少,兼容旧代码)
|
||
dt = _dt.fromtimestamp(val)
|
||
elif isinstance(val, str):
|
||
# 情况 3:ISO 字符串(admin_credit_record_service._iso 返回的格式)
|
||
s = val.strip()
|
||
if s.endswith("Z"):
|
||
s = s[:-1] + "+00:00"
|
||
try:
|
||
dt = _dt.fromisoformat(s)
|
||
except ValueError:
|
||
# 兼容旧格式 YYYY-MM-DD HH:MM:SS
|
||
dt = _dt.strptime(s, "%Y-%m-%d %H:%M:%S")
|
||
else:
|
||
return str(val)
|
||
# 统一转东八区展示
|
||
if getattr(dt, "tzinfo", None) is None:
|
||
dt = dt.replace(tzinfo=CST)
|
||
else:
|
||
dt = dt.astimezone(CST)
|
||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||
except Exception: # noqa: BLE001
|
||
return str(val) if val else "-"
|
||
|
||
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}"},
|
||
)
|