团队积分V1
This commit is contained in:
+267
-208
@@ -1,38 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from urllib.parse import quote
|
||||
|
||||
CST = timezone(timedelta(hours=8))
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
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 (
|
||||
ManagerTransferRequest,
|
||||
)
|
||||
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),
|
||||
@@ -40,30 +76,25 @@ async def get_managed_team_info(
|
||||
):
|
||||
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
|
||||
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))
|
||||
|
||||
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("/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),
|
||||
@@ -73,23 +104,123 @@ async def list_team_members(
|
||||
):
|
||||
team = await get_managed_team(db, current_user.id)
|
||||
if not team:
|
||||
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
|
||||
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,
|
||||
@router.put("/manager")
|
||||
async def transfer_manager(
|
||||
req: SetManagerRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
raise HTTPException(status_code=409, detail="当前版本积分暂未开放团队转账功能")
|
||||
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("/invitations", )
|
||||
@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),
|
||||
@@ -97,15 +228,13 @@ async def create_invitation(
|
||||
):
|
||||
team = await get_managed_team(db, current_user.id)
|
||||
if not team:
|
||||
raise HTTPException(status_code=403, detail="只有团队管理人可创建邀请码")
|
||||
|
||||
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="过期时间格式错误")
|
||||
|
||||
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
|
||||
)
|
||||
@@ -128,7 +257,7 @@ async def list_invitations(
|
||||
):
|
||||
team = await get_managed_team(db, current_user.id)
|
||||
if not team:
|
||||
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
|
||||
raise HTTPException(status_code=403, detail="只有团队队长可查看邀请码")
|
||||
invitations = await team_invitation_service.get_invitations_for_team(db, team.id)
|
||||
return [
|
||||
{
|
||||
@@ -152,10 +281,9 @@ async def revoke_invitation(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await team_invitation_service.revoke_invitation(db, invitation_id, current_user.id)
|
||||
return {"message": "ok"}
|
||||
return {"message": "邀请码已撤销"}
|
||||
|
||||
|
||||
# ── 加入申请 ──────────────────────────────────────────
|
||||
@router.post("/join")
|
||||
async def join_by_code(
|
||||
req: JoinByCodeRequest,
|
||||
@@ -163,42 +291,43 @@ async def join_by_code(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await team_invitation_service.create_join_request(db, current_user.id, req.invitation_code)
|
||||
return {"message": "申请已提交,请等待团队管理人审批"}
|
||||
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),
|
||||
):
|
||||
"""验证邀请码并返回团队信息(用于加入页面展示)。"""
|
||||
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 = await db.execute(
|
||||
select(Team.name).where(Team.id == invitation.team_id, Team.deleted_at.is_(None)).limit(1)
|
||||
team_result = await db.execute(
|
||||
select(Team).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
|
||||
|
||||
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:
|
||||
from app.models.team_join_request import TeamJoinRequest
|
||||
pending = await db.execute(
|
||||
select(TeamJoinRequest).where(
|
||||
select(TeamJoinRequest.id).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
|
||||
|
||||
has_pending_request = pending.scalar_one_or_none() is not None
|
||||
return JoinTeamInfoOut(
|
||||
team_name=team_name,
|
||||
team_name=team.name,
|
||||
team_id=invitation.team_id,
|
||||
valid=True,
|
||||
already_in_team=already_in_team,
|
||||
@@ -206,31 +335,21 @@ async def get_join_info(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/join-info/public", )
|
||||
async def get_join_info_public(
|
||||
@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 {"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,
|
||||
}
|
||||
return await _join_info_payload(db, code=code, current_user=current_user)
|
||||
|
||||
|
||||
@router.get("/join-requests", )
|
||||
@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),
|
||||
@@ -238,29 +357,22 @@ async def list_join_requests(
|
||||
):
|
||||
team = await get_managed_team(db, current_user.id)
|
||||
if not team:
|
||||
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
|
||||
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"),
|
||||
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 r in requests
|
||||
for item in requests
|
||||
]
|
||||
|
||||
|
||||
@@ -271,157 +383,104 @@ async def handle_join_request(
|
||||
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"}
|
||||
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="按手机号搜索"),
|
||||
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal)$", 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),
|
||||
):
|
||||
"""查看团队所有成员的积分变动记录(仅管理人)。"""
|
||||
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_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 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)
|
||||
)
|
||||
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, "summary": {}}
|
||||
|
||||
return await list_admin_credit_records(
|
||||
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,
|
||||
team_id=team.id,
|
||||
user_id=resolved_user_id,
|
||||
member_user_id=resolved_user_id,
|
||||
subscription_id=subscription_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(
|
||||
team_id: str | None = Query(None),
|
||||
user_id: str | None = Query(None),
|
||||
phone: str | None = Query(None),
|
||||
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal)$", 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),
|
||||
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_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 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)
|
||||
)
|
||||
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()
|
||||
|
||||
# 拉取全部记录(不分页)
|
||||
result = await list_admin_credit_records(
|
||||
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,
|
||||
team_id=team.id,
|
||||
user_id=resolved_user_id,
|
||||
member_user_id=resolved_user_id,
|
||||
subscription_id=subscription_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 "-"
|
||||
|
||||
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(["用户名", "手机号", "类型", "积分变动", "余额", "说明", "时间"])
|
||||
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("phone") or "-",
|
||||
item.get("record_type_label") or item.get("type") or "-",
|
||||
item.get("amount", 0),
|
||||
item.get("balance_after", 0),
|
||||
item.get("record_type_label") or "-",
|
||||
item.get("team_amount", 0),
|
||||
item.get("description") or "-",
|
||||
_format_dt(item.get("created_at")),
|
||||
item.get("subscription_no") or "历史订阅",
|
||||
item.get("subscription_period_id") or "-",
|
||||
item.get("seat_id") or "-",
|
||||
created_at or "-",
|
||||
])
|
||||
|
||||
from starlette.responses import StreamingResponse
|
||||
from urllib.parse import quote
|
||||
filename = f"团队积分_{(team.name if team else resolved_team_id)}_{datetime.now(CST).strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
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}"},
|
||||
iter(["\ufeff" + output.getvalue()]),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user