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 打开,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}"}, )