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 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().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}"}, )