团队积分V1

This commit is contained in:
2026-08-14 15:23:46 +08:00
parent 516106c045
commit fac54b5667
70 changed files with 6670 additions and 4649 deletions
+195 -104
View File
@@ -12,20 +12,22 @@ from app.enums.credit_balance import (
CREDIT_BALANCE_SOURCE_TYPE_LABELS,
CREDIT_BALANCE_STATUS_LABELS,
CREDIT_LEVEL_LABELS,
CREDIT_SCOPE_LABELS,
CreditBalanceSourceType,
CreditScope,
)
from app.enums.credit_product import CreditProductType
from app.models.credit.balance import UserCreditBalance
from app.models.credit.product import CreditProduct
from app.models.credit.subscription import UserCreditSubscription
from app.models.user import User
from app.schemas.credit_balance import AdminCreditDeductRequest, AdminCreditGrantRequest
from app.schemas.credit_product import CreditProductCreate, CreditProductRenewalUpdate, CreditProductUpdate
from app.schemas.credit_product import CreditProductCreate, CreditProductRenewalUpdate, CreditProductStatusUpdate, CreditProductUpdate
from app.schemas.credit_subscription import AdminOfflineSubscriptionCreate
from app.services.credit.ledger_service import deduct_credits, grant_credits
from app.services.credit.offline_subscription_service import create_offline_subscription_order
from app.services.credit.product_service import product_to_dict
from app.services.credit.query_service import (
apply_balance_status_filter,
effective_balance_status,
get_balance_summary,
)
from app.services.credit.query_service import apply_balance_status_filter, effective_balance_status, get_balance_summary
from app.services.credit.time_policy import add_natural_months, last_usable_at
from app.services.credit.utils import utc_now
from app.services.notification import create_notification
@@ -40,16 +42,17 @@ def _apply_product_payload(product: CreditProduct, payload: dict) -> None:
mapping = {"features": "features_json"}
for key, value in payload.items():
setattr(product, mapping.get(key, key), value)
if product.product_type == "credit_addon" and product.validity_months is None:
# 兼容旧管理端未提交有效期的请求,新建增值包仍默认1个月;
# 更新时未提交该字段则保留原值。
product.validity_months = 1
if product.product_type == "subscription":
if product.product_type in {
CreditProductType.SUBSCRIPTION.value,
CreditProductType.TEAM_SUBSCRIPTION.value,
}:
product.price = product.regular_price or 0
product.grant_credits = None
product.validity_months = None
else:
elif product.product_type == CreditProductType.CREDIT_ADDON.value:
product.renewal_enabled = False
if product.validity_months is None:
product.validity_months = 1
product.tier_code = None
product.tier_rank = None
product.billing_cycle = None
@@ -62,14 +65,17 @@ def _apply_product_payload(product: CreditProduct, payload: dict) -> None:
def _validate_product_entity(product: CreditProduct) -> None:
if product.product_type == "subscription":
if product.product_type in {
CreditProductType.SUBSCRIPTION.value,
CreditProductType.TEAM_SUBSCRIPTION.value,
}:
required = {
"套餐等级编码": product.tier_code,
"套餐等级顺序": product.tier_rank,
"订阅周期": product.billing_cycle,
"每月积分": product.monthly_grant_credits,
"价格": product.first_purchase_price,
"原价": product.regular_price,
"价格": product.first_purchase_price,
"常规价格": product.regular_price,
}
missing = [label for label, value in required.items() if value is None]
if missing:
@@ -81,9 +87,9 @@ def _validate_product_entity(product: CreditProduct) -> None:
raise HTTPException(status_code=400, detail="配置活动价时必须同时配置活动开始和结束时间")
elif product.activity_end_at <= product.activity_start_at:
raise HTTPException(status_code=400, detail="活动结束时间必须晚于开始时间")
elif product.product_type == "credit_addon":
if product.grant_credits is None or product.price is None:
raise HTTPException(status_code=400, detail="积分增值包必须配置价格和积分数量")
elif product.product_type == CreditProductType.CREDIT_ADDON.value:
if product.grant_credits is None:
raise HTTPException(status_code=400, detail="积分增值包必须配置积分数量")
if product.validity_months is None or not 1 <= int(product.validity_months) <= 36:
raise HTTPException(status_code=400, detail="积分增值包有效期必须为1-36个月")
else:
@@ -99,7 +105,9 @@ async def list_products(
stmt = select(CreditProduct)
if product_type:
stmt = stmt.where(CreditProduct.product_type == product_type)
result = await db.execute(stmt.order_by(CreditProduct.product_type, CreditProduct.sort_order, CreditProduct.id))
result = await db.execute(
stmt.order_by(CreditProduct.deleted_at.asc(), CreditProduct.product_type, CreditProduct.sort_order, CreditProduct.id)
)
return [product_to_dict(item) for item in result.scalars().all()]
@@ -109,19 +117,25 @@ async def create_product(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
exists = await db.execute(select(CreditProduct.id).where(CreditProduct.product_code == data.product_code).limit(1))
exists = await db.execute(
select(CreditProduct.id).where(CreditProduct.product_code == data.product_code).limit(1)
)
if exists.scalar_one_or_none():
raise HTTPException(status_code=409, detail="商品编码已存在")
raise HTTPException(status_code=409, detail="商品编码已被使用,商品编码永久唯一且不可复用")
product = CreditProduct(id=generate_id())
_apply_product_payload(product, data.model_dump())
_validate_product_entity(product)
db.add(product)
await db.flush()
snapshot = product_to_dict(product)
await log_operation(db, admin.id, admin.username, f"创建积分商品 {product.name}", "POST", "/admin/credit-management/products", detail=json.dumps(snapshot, ensure_ascii=False, default=str))
log_operation_event(domain="credit_product", module="credit", event_type="CREDIT_PRODUCT_CREATED", user_id=admin.id, detail=snapshot)
# 商品保存后前端会立即使用返回值刷新列表。这里显式提交,避免依赖
# yield 依赖退出阶段提交时出现紧随其后的 GET 读到旧状态。
await log_operation(
db, admin.id, admin.username, f"创建积分商品 {product.name}", "POST",
"/admin/credit-management/products", detail=json.dumps(snapshot, ensure_ascii=False, default=str),
)
log_operation_event(
domain="credit_product", module="credit", event_type="CREDIT_PRODUCT_CREATED",
user_id=admin.id, message="积分商品创建成功", detail={"product_id": product.id},
)
await db.commit()
return snapshot
@@ -133,33 +147,26 @@ async def update_product(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(CreditProduct).where(CreditProduct.id == product_id).limit(1).with_for_update())
result = await db.execute(
select(CreditProduct).where(CreditProduct.id == product_id).limit(1).with_for_update()
)
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="商品不存在")
if product.deleted_at is not None:
raise HTTPException(status_code=409, detail="商品已软删除,不能恢复或继续编辑")
before = product_to_dict(product)
payload = data.model_dump(exclude_unset=True)
new_code = payload.get("product_code")
if new_code and new_code != product.product_code:
duplicate = await db.execute(
select(CreditProduct.id).where(
CreditProduct.product_code == new_code, CreditProduct.id != product.id
).limit(1)
)
if duplicate.scalar_one_or_none():
raise HTTPException(status_code=409, detail="商品编码已存在")
_apply_product_payload(product, payload)
_apply_product_payload(product, data.model_dump(exclude_unset=True))
_validate_product_entity(product)
await db.flush()
after = product_to_dict(product)
await log_operation(db, admin.id, admin.username, f"更新积分商品 {product.name}", "PUT", f"/admin/credit-management/products/{product_id}", detail=json.dumps({"before": before, "after": after}, ensure_ascii=False, default=str))
log_operation_event(domain="credit_product", module="credit", event_type="CREDIT_PRODUCT_UPDATED", user_id=admin.id, detail={"product_id": product_id})
await log_operation(
db, admin.id, admin.username, f"更新积分商品 {product.name}", "PUT",
f"/admin/credit-management/products/{product_id}",
detail=json.dumps({"before": before, "after": after}, ensure_ascii=False, default=str),
)
await db.commit()
refreshed = await db.execute(select(CreditProduct).where(CreditProduct.id == product_id).limit(1))
persisted = refreshed.scalar_one_or_none()
if persisted is None:
raise HTTPException(status_code=404, detail="商品不存在")
return product_to_dict(persisted)
return after
@router.put("/products/{product_id}/renewal")
@@ -170,61 +177,151 @@ async def update_product_renewal(
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(CreditProduct)
.where(CreditProduct.id == product_id)
.limit(1)
.with_for_update()
select(CreditProduct).where(CreditProduct.id == product_id).limit(1).with_for_update()
)
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="商品不存在")
if product.product_type != "subscription":
if product.deleted_at is not None:
raise HTTPException(status_code=409, detail="商品已软删除,不能修改续费开关")
if product.product_type not in {
CreditProductType.SUBSCRIPTION.value,
CreditProductType.TEAM_SUBSCRIPTION.value,
}:
raise HTTPException(status_code=400, detail="积分增值包不支持续费开关")
before = bool(product.renewal_enabled)
product.renewal_enabled = bool(data.renewal_enabled)
await db.flush()
after = product_to_dict(product)
await log_operation(
db,
admin.id,
admin.username,
f"{'开启' if product.renewal_enabled else '关闭'}积分商品续费 {product.name}",
"PUT",
f"/admin/credit-management/products/{product_id}/renewal",
detail=json.dumps(
{"before": before, "after": bool(product.renewal_enabled)},
ensure_ascii=False,
),
db, admin.id, admin.username,
f"{'开启' if product.renewal_enabled else '关闭'}积分套餐续费 {product.name}",
"PUT", f"/admin/credit-management/products/{product_id}/renewal",
detail=json.dumps({"before": before, "after": bool(product.renewal_enabled)}, ensure_ascii=False),
)
log_operation_event(
domain="credit_product", module="credit", event_type="CREDIT_PRODUCT_RENEWAL_UPDATED",
user_id=admin.id, message="积分套餐续费开关已更新",
detail={"product_id": product.id, "renewal_enabled": bool(product.renewal_enabled)},
)
await db.commit()
return after
# 提交后重新查询,返回数据库真实持久化结果,避免前端误用事务内快照。
refreshed = await db.execute(
select(CreditProduct).where(CreditProduct.id == product_id).limit(1)
@router.put("/products/{product_id}/status")
async def update_product_status(
product_id: str,
data: CreditProductStatusUpdate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(CreditProduct).where(CreditProduct.id == product_id).limit(1).with_for_update()
)
persisted = refreshed.scalar_one_or_none()
if persisted is None:
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="商品不存在")
if bool(persisted.renewal_enabled) != bool(data.renewal_enabled):
raise HTTPException(status_code=500, detail="续费状态保存后校验失败")
return product_to_dict(persisted)
if product.deleted_at is not None:
raise HTTPException(status_code=409, detail="已软删除商品不能重新上架")
product.is_active = bool(data.is_active)
await db.flush()
await log_operation(
db, admin.id, admin.username,
f"{'上架' if product.is_active else '下架'}积分商品 {product.name}", "PUT",
f"/admin/credit-management/products/{product_id}/status",
)
await db.commit()
return product_to_dict(product)
@router.delete("/products/{product_id}")
async def disable_product(
async def soft_delete_product(
product_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(CreditProduct).where(CreditProduct.id == product_id).limit(1).with_for_update())
result = await db.execute(
select(CreditProduct).where(CreditProduct.id == product_id).limit(1).with_for_update()
)
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="商品不存在")
product.is_active = False
await db.flush()
await log_operation(db, admin.id, admin.username, f"下架积分商品 {product.name}", "DELETE", f"/admin/credit-management/products/{product_id}")
await db.commit()
return {"ok": True}
if product.deleted_at is None:
product.is_active = False
product.deleted_at = utc_now()
await db.flush()
await log_operation(
db, admin.id, admin.username, f"软删除积分商品 {product.name}", "DELETE",
f"/admin/credit-management/products/{product_id}",
)
await db.commit()
return {"ok": True, "message": "商品已软删除,商品编码永久保留且不能恢复"}
@router.post("/users/{user_id}/offline-subscriptions")
async def create_offline_subscription(
user_id: str,
data: AdminOfflineSubscriptionCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
try:
order = await create_offline_subscription_order(
db,
target_user_id=user_id,
product_id=data.product_id,
operator_admin_id=admin.id,
payment_method=data.payment_method,
quantity=data.quantity,
actual_paid_amount=data.actual_paid_amount,
offline_trade_no=data.offline_trade_no,
offline_payment_detail=data.offline_payment_detail,
remark=data.remark,
)
order_no = str(order.order_no)
subscription_id = order.subscription_id
amount = float(order.amount)
await log_operation(
db, admin.id, admin.username, f"为用户 {user_id} 创建线下真实订阅成交", "POST",
f"/admin/credit-management/users/{user_id}/offline-subscriptions",
detail=json.dumps({"order_no": order_no, "subscription_id": subscription_id, "actual_paid_amount": amount}, ensure_ascii=False),
)
await db.commit()
return {"ok": True, "order_no": order_no, "subscription_id": subscription_id, "actual_paid_amount": amount}
except Exception:
await db.rollback()
raise
@router.get("/users/{user_id}/subscriptions")
async def list_user_subscriptions(
user_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(UserCreditSubscription)
.where(UserCreditSubscription.user_id == user_id)
.order_by(UserCreditSubscription.created_at.desc(), UserCreditSubscription.id.desc())
)
return [
{
"id": item.id,
"product_name": item.product_name_snapshot,
"product_type": item.product_type_snapshot,
"product_type_label": "团队订阅套餐" if item.product_type_snapshot == "team_subscription" else "个人订阅套餐",
"team_id": item.team_id,
"status": item.status,
"status_label": {"active": "有效", "expired": "已过期", "cancelled": "已取消", "pending": "待生效"}.get(item.status, "其他状态"),
"quantity": item.quantity_snapshot,
"monthly_total_credits": float(item.monthly_total_credits_snapshot),
"paid_amount": float(item.paid_amount_snapshot),
"start_at": item.start_at,
"expires_at": item.expires_at,
}
for item in result.scalars().all()
]
@router.get("/users/{user_id}/summary")
@@ -248,14 +345,20 @@ async def list_user_credit_balances(
checked_at = utc_now()
stmt = select(UserCreditBalance).where(UserCreditBalance.user_id == user_id)
stmt = apply_balance_status_filter(stmt, status, request_time=checked_at)
result = await db.execute(stmt.order_by(UserCreditBalance.expires_at.asc(), UserCreditBalance.id.asc()).offset((page - 1) * page_size).limit(page_size))
result = await db.execute(
stmt.order_by(UserCreditBalance.expires_at.asc(), UserCreditBalance.id.asc())
.offset((page - 1) * page_size).limit(page_size)
)
return [
{
"id": item.id,
"credit_scope": item.credit_scope,
"credit_scope_label": CREDIT_SCOPE_LABELS.get(item.credit_scope, "其他积分"),
"team_id": item.team_id,
"credit_level": item.credit_level,
"credit_level_label": CREDIT_LEVEL_LABELS.get(item.credit_level, item.credit_level),
"credit_level_label": CREDIT_LEVEL_LABELS.get(item.credit_level, "其他积分等级"),
"source_type": item.source_type,
"source_type_label": CREDIT_BALANCE_SOURCE_TYPE_LABELS.get(item.source_type, item.source_type),
"source_type_label": CREDIT_BALANCE_SOURCE_TYPE_LABELS.get(item.source_type, "其他来源"),
"source_id": item.source_id,
"grant_amount": float(item.grant_amount),
"unspent_amount": float(item.unspent_amount),
@@ -266,7 +369,7 @@ async def list_user_credit_balances(
"expires_at": item.expires_at,
"last_usable_at": last_usable_at(item.expires_at),
"status": (status_value := effective_balance_status(item, request_time=checked_at)),
"status_label": CREDIT_BALANCE_STATUS_LABELS.get(status_value, status_value),
"status_label": CREDIT_BALANCE_STATUS_LABELS.get(status_value, "其他状态"),
}
for item in result.scalars().all()
]
@@ -280,24 +383,18 @@ async def admin_grant_credit(
db: AsyncSession = Depends(get_db),
):
starts_at = data.valid_from or utc_now()
ends_at = starts_at + timedelta(days=data.validity_value) if data.validity_unit == "day" else add_natural_months(starts_at, data.validity_value)
ends_at = (
starts_at + timedelta(days=data.validity_value)
if data.validity_unit == "day"
else add_natural_months(starts_at, data.validity_value)
)
result = await grant_credits(
db,
user_id=user_id,
amount=data.amount,
description=data.description,
source_type=CreditBalanceSourceType.ADMIN_GRANT.value,
source_id=admin.id,
valid_from=starts_at,
expires_at=ends_at,
credit_level=data.credit_level,
related_id=admin.id,
biz_key=f"admin-grant:{admin.id}:{generate_id()}",
)
await create_notification(
db, user_id, "积分变动通知",
f"您的积分已增加{data.amount}积分。原因:{data.description}", "credit",
db, user_id=user_id, amount=data.amount, description=data.description,
source_type=CreditBalanceSourceType.ADMIN_GRANT.value, source_id=admin.id,
valid_from=starts_at, expires_at=ends_at, credit_level=data.credit_level,
related_id=admin.id, biz_key=f"admin-grant:{admin.id}:{generate_id()}",
)
await create_notification(db, user_id, "积分变动通知", f"您的积分已增加{data.amount}积分。原因:{data.description}", "credit")
await log_operation(db, admin.id, admin.username, f"给用户 {user_id} 增加积分 {data.amount}", "POST", f"/admin/credit-management/users/{user_id}/grant")
return {"ok": True, "credits": result.balance_after}
@@ -311,20 +408,14 @@ async def admin_deduct_credit(
):
try:
result = await deduct_credits(
db,
user_id=user_id,
amount=data.amount,
description=data.description,
related_id=admin.id,
biz_key=f"admin-deduct:{admin.id}:{generate_id()}",
db, user_id=user_id, amount=data.amount, description=data.description,
related_id=admin.id, biz_key=f"admin-deduct:{admin.id}:{generate_id()}",
allowed_scopes={CreditScope.PERSONAL.value},
)
except Exception as exc:
if exc.__class__.__name__ == "InsufficientCreditsError":
raise HTTPException(status_code=400, detail="用户有效积分不足") from exc
raise
await create_notification(
db, user_id, "积分变动通知",
f"您的积分已扣除{data.amount}积分。原因:{data.description}", "credit",
)
await create_notification(db, user_id, "积分变动通知", f"您的积分已扣除{data.amount}积分。原因:{data.description}", "credit")
await log_operation(db, admin.id, admin.username, f"扣除用户 {user_id} 积分 {data.amount}", "POST", f"/admin/credit-management/users/{user_id}/deduct")
return {"ok": True, "credits": result.balance_after}
@@ -18,7 +18,10 @@ async def admin_list_packages(
):
result = await db.execute(
select(CreditProduct)
.where(CreditProduct.product_type == CreditProductType.CREDIT_ADDON.value)
.where(
CreditProduct.product_type == CreditProductType.CREDIT_ADDON.value,
CreditProduct.deleted_at.is_(None),
)
.order_by(CreditProduct.sort_order, CreditProduct.id)
)
return [product_to_dict(item) for item in result.scalars().all()]
+86 -40
View File
@@ -3,54 +3,57 @@ from __future__ import annotations
import json
from fastapi import APIRouter, Body, Depends, Query
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.enums.team import TEAM_STATUS_LABELS, TeamStatus
from app.enums.user import UserType
from app.models.team import Team
from app.models.user import User
from app.schemas.team import TeamCreate, TeamListOut, TeamOptionOut, TeamUpdate
from app.schemas.team_manager import SetManagerRequest
from app.services.credit.team_subscription_service import (
list_member_period_usage,
list_team_subscriptions_for_management,
)
from app.services.operation_log import log_operation
from app.services.team_manager_service import set_team_manager
from app.services.team_manager_service import get_manager_history, set_team_manager
from app.services.team_service import create_team, list_team_options, list_teams, soft_delete_team, update_team
router = APIRouter(prefix="/admin/teams", tags=["admin-teams"])
async def _team_detail_payload(db: AsyncSession, team: Team) -> dict:
"""构造返回团队详情,包含 manager_name。"""
payload = {
"id": team.id,
"name": team.name,
"code": getattr(team, "code", None),
"description": getattr(team, "description", None),
"status": getattr(team, "status", "active"),
"sort_order": getattr(team, "sort_order", 0) or 0,
"member_count": 0,
"created_at": team.created_at,
"updated_at": team.updated_at,
"manager_id": getattr(team, "manager_id", None),
"manager_name": None,
}
# 查询成员数和管理人用户名
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
payload["member_count"] = int(member_count)
if getattr(team, "manager_id", None):
mgr = await db.execute(
manager_name = None
if team.manager_id:
manager_name = (await db.execute(
select(User.username).where(User.id == team.manager_id).limit(1)
)
payload["manager_name"] = mgr.scalar_one_or_none()
return payload
)).scalar_one_or_none()
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,
"sort_order": team.sort_order or 0,
"member_count": int(member_count),
"created_at": team.created_at,
"updated_at": team.updated_at,
"manager_id": team.manager_id,
"manager_name": manager_name,
"first_subscription_paid_at": team.first_subscription_paid_at,
}
@router.get("", response_model=TeamListOut)
@@ -62,6 +65,7 @@ async def list_admin_teams(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
return await list_teams(db, page=page, page_size=page_size, keyword=keyword, status=status)
@@ -71,10 +75,11 @@ async def list_admin_team_options(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
return await list_team_options(db, include_disabled=include_disabled)
@router.post("", )
@router.post("")
async def create_admin_team(
req: TeamCreate,
admin: User = Depends(get_admin_user),
@@ -93,7 +98,22 @@ async def create_admin_team(
return await _team_detail_payload(db, team)
@router.put("/{team_id}", )
@router.get("/{team_id}")
async def get_admin_team(
team_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
result = await db.execute(select(Team).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1))
team = result.scalar_one_or_none()
if not team:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="团队不存在")
return await _team_detail_payload(db, team)
@router.put("/{team_id}")
async def update_admin_team(
team_id: str,
req: TeamUpdate,
@@ -113,7 +133,7 @@ async def update_admin_team(
return await _team_detail_payload(db, team)
@router.put("/{team_id}/manager", )
@router.put("/{team_id}/manager")
async def set_team_manager_endpoint(
team_id: str,
req: SetManagerRequest = Body(...),
@@ -121,19 +141,14 @@ async def set_team_manager_endpoint(
db: AsyncSession = Depends(get_db),
):
team = await set_team_manager(db, team_id, req.user_id)
manager_name = None
# 使用 req.user_id 避免访问 team.manager_id 触发懒加载
if req.user_id:
mgr = await db.execute(
select(User.username).where(User.id == req.user_id).limit(1)
)
manager_name = mgr.scalar_one_or_none()
team_name = team.name
manager_name = (await db.execute(
select(User.username).where(User.id == req.user_id).limit(1)
)).scalar_one_or_none()
await log_operation(
db,
admin.id,
admin.username,
f"设置团队管理人 {team_name}: {manager_name or '取消'}",
f"更换团队队长 {team.name}: {manager_name or req.user_id}",
"PUT",
f"/admin/teams/{team_id}/manager",
detail=json.dumps({"manager_id": req.user_id}, ensure_ascii=False),
@@ -141,6 +156,37 @@ async def set_team_manager_endpoint(
return await _team_detail_payload(db, team)
@router.get("/{team_id}/subscriptions")
async def list_admin_team_subscriptions(
team_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
return await list_team_subscriptions_for_management(db, team_id=team_id)
@router.get("/{team_id}/member-usage")
async def list_admin_team_member_usage(
team_id: str,
subscription_id: str | None = Query(None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
return await list_member_period_usage(db, team_id=team_id, subscription_id=subscription_id)
@router.get("/{team_id}/manager-history")
async def list_admin_team_manager_history(
team_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
return await get_manager_history(db, team_id)
@router.delete("/{team_id}")
async def delete_admin_team(
team_id: str,
@@ -157,4 +203,4 @@ async def delete_admin_team(
f"/admin/teams/{team_id}",
detail=json.dumps({"before": before, "after": {"deleted_at": str(team.deleted_at)}}, ensure_ascii=False),
)
return {"message": "ok"}
return {"message": "团队已删除"}
+174 -134
View File
@@ -24,6 +24,8 @@ from app.models.credit_ratio import CreditRatio
from app.models.operation_log import OperationLog
from app.enums.user import FrontendUserKind, UserType
from app.enums.team import TEAM_UNASSIGNED_VALUE
from app.enums.common import PAYMENT_ORDER_SOURCE_LABELS
from app.schemas.payment import PAYMENT_METHOD_LABELS, PAYMENT_STATUS_LABELS, FULFILLMENT_STATUS_LABELS
from app.schemas.admin import (
CreditAdjustRequest,
ModelConfigCreate,
@@ -49,7 +51,7 @@ from app.schemas.image_engine import ImageEngineCreate, ImageEngineOut
from app.schemas.credit_ratio import CreditRatioCreate, CreditRatioOut
from app.services.credits import add_credits, deduct_credits
from app.enums.credit_balance import CreditBalanceSourceType, CreditLevel
from app.services.credit.query_service import attach_credit_snapshot, get_available_credits, get_user_credit_map
from app.services.credit.query_service import attach_credit_snapshot, get_balance_summary, get_user_credit_summary_map
from app.services.credit.time_policy import add_natural_months
from app.services.credit.utils import utc_now
from app.services.credit_record_meta_service import build_admin_adjust_meta
@@ -60,7 +62,6 @@ from app.services.auth import hash_password, verify_password
from app.services.operation_log import log_operation
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls
from app.services.resource_signed_url_service import build_resource_signed_url
from app.services.payment import process_refund
from app.services.resource_capacity_service import batch_get_user_resource_capacity_usage, get_user_resource_capacity_usage
from app.services.team_service import batch_get_team_name_map, set_frontend_user_team
from app.schemas.invoice import InvoiceStatusUpdateRequest
@@ -134,9 +135,10 @@ async def list_users(
team_ids = [getattr(u, "team_id", None) for u in users if getattr(u, "team_id", None)]
capacity_map = await batch_get_user_resource_capacity_usage(db, user_ids)
team_name_map = await batch_get_team_name_map(db, team_ids)
credit_map = await get_user_credit_map(db, user_ids)
credit_summary_map = await get_user_credit_summary_map(db, user_ids)
for item in users:
attach_credit_snapshot(item, credit_map.get(item.id, 0.0))
summary = credit_summary_map.get(item.id)
attach_credit_snapshot(item, summary.available_credits if summary else 0)
return {
"items": [
AdminUserOut.model_validate(user)
@@ -144,6 +146,9 @@ async def list_users(
update={
"resource_capacity": capacity_map.get(user.id),
"team_name": team_name_map.get(getattr(user, "team_id", None)),
"personal_credits": float(credit_summary_map[user.id].personal_credits) if user.id in credit_summary_map else 0.0,
"team_available_credits": float(credit_summary_map[user.id].team_available_credits) if user.id in credit_summary_map else 0.0,
"team_frozen_credits": float(credit_summary_map[user.id].team_frozen_credits) if user.id in credit_summary_map else 0.0,
}
)
.model_dump(mode="json")
@@ -277,13 +282,17 @@ async def get_user(
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
attach_credit_snapshot(user, await get_available_credits(db, user.id))
credit_summary = await get_balance_summary(db, user.id)
attach_credit_snapshot(user, credit_summary.available_credits)
resource_capacity = await get_user_resource_capacity_usage(db, user.id)
team_name_map = await batch_get_team_name_map(db, [getattr(user, "team_id", None)])
return AdminUserOut.model_validate(user).model_copy(
update={
"resource_capacity": resource_capacity,
"team_name": team_name_map.get(getattr(user, "team_id", None)),
"personal_credits": float(credit_summary.personal_credits),
"team_available_credits": float(credit_summary.team_available_credits),
"team_frozen_credits": float(credit_summary.team_frozen_credits),
}
)
@@ -308,7 +317,14 @@ async def adjust_credits(
biz_key=f"admin-adjust-credit:{admin.id}:{generate_id()}",
)
else:
await deduct_credits(db, user_id, abs(req.amount), f"管理员调整: {req.description}", record_meta=build_admin_adjust_meta())
await deduct_credits(
db,
user_id,
abs(req.amount),
f"管理员调整: {req.description}",
record_meta=build_admin_adjust_meta(),
allowed_scopes={"personal"},
)
await create_notification(
db, user_id, "积分变动通知",
f"您的积分已{'增加' if req.amount > 0 else '扣除'}{abs(req.amount)}积分。原因:{req.description}",
@@ -568,6 +584,7 @@ async def list_credit_records(
user_type: str | None = Query(None),
frontend_user_kind: str | None = Query(None),
team_id: str | None = Query(None),
subscription_no: str | None = Query(None),
record_type: str | None = Query(None),
type: str | None = Query(None),
credit_subject: str | None = Query(None),
@@ -592,6 +609,7 @@ async def list_credit_records(
user_type=user_type,
frontend_user_kind=frontend_user_kind,
team_id=team_id,
subscription_no=subscription_no,
record_type=record_type or type,
credit_subject=credit_subject,
media_type=media_type,
@@ -806,107 +824,112 @@ async def batch_update_payment_configs(
@router.get("/payment-stats")
async def get_payment_stats(
payment_method: str | None = Query(None),
order_source: str | None = Query(None, pattern="^(online_payment|admin_offline)$"),
status: str | None = Query(None),
start_date: str | None = Query(None),
end_date: str | None = Query(None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Return payment statistics for admin dashboard with filters."""
from sqlalchemy import func
# Ensure by_status has all expected statuses with defaults
"""支付统计:线上、后台线下和总真实收入可分别统计。"""
del admin
by_status = {
"pending": {"count": 0, "amount": 0.0},
"paid": {"count": 0, "amount": 0.0},
"cancelled": {"count": 0, "amount": 0.0},
"refunded": {"count": 0, "amount": 0.0},
"pending": {"label": "待支付", "count": 0, "amount": 0.0},
"paid": {"label": "已支付", "count": 0, "amount": 0.0},
"cancelled": {"label": "已取消", "count": 0, "amount": 0.0},
"expired": {"label": "已过期", "count": 0, "amount": 0.0},
"failed": {"label": "失败", "count": 0, "amount": 0.0},
"refunded": {"label": "已退款", "count": 0, "amount": 0.0},
}
# Parse dates and build base query filters
now_cst = datetime.now(CST)
today_start = now_cst.replace(hour=0, minute=0, second=0, microsecond=0)
today_end = today_start + timedelta(days=1)
query_start = datetime.fromisoformat(start_date).replace(tzinfo=CST) if start_date else today_start
query_end = (
(datetime.fromisoformat(end_date) + timedelta(days=1)).replace(tzinfo=CST)
if end_date else today_end
)
# Default to today if no date range provided
query_start = today_start
query_end = today_end
if start_date:
query_start = datetime.fromisoformat(start_date).replace(tzinfo=CST)
if end_date:
query_end = (datetime.fromisoformat(end_date) + timedelta(days=1)).replace(tzinfo=CST)
# Build filter list for status breakdown
breakdown_filters = []
filters = [PaymentOrder.created_at >= query_start, PaymentOrder.created_at < query_end]
if payment_method:
breakdown_filters.append(PaymentOrder.payment_method == payment_method)
filters.append(PaymentOrder.payment_method == payment_method)
if order_source:
filters.append(PaymentOrder.order_source == order_source)
if status:
breakdown_filters.append(PaymentOrder.status == status)
# Always apply date range to breakdown
breakdown_filters.append(PaymentOrder.created_at >= query_start)
breakdown_filters.append(PaymentOrder.created_at < query_end)
filters.append(PaymentOrder.status == status)
# Status breakdown
status_result = await db.execute(
select(
PaymentOrder.status,
func.count().label("count"),
func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"),
)
.where(*breakdown_filters)
select(PaymentOrder.status, func.count().label("count"), func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"))
.where(*filters)
.group_by(PaymentOrder.status)
)
for row in status_result.all():
if row.status in by_status:
if row.status not in by_status:
by_status[row.status] = {
"count": row.count,
"amount": round(float(row.amount), 2)
"label": PAYMENT_STATUS_LABELS.get(row.status, "其他状态"),
"count": 0,
"amount": 0.0,
}
else:
# Map any unexpected status to cancelled
by_status["cancelled"]["count"] += row.count
by_status["cancelled"]["amount"] += round(float(row.amount), 2)
by_status[row.status]["count"] = int(row.count or 0)
by_status[row.status]["amount"] = round(float(row.amount or 0), 2)
# Today's stats (CST time zone) - independent of filter
today_result = await db.execute(
select(
func.count().label("paid_count"),
func.coalesce(func.sum(PaymentOrder.amount), 0).label("paid_amount"),
).where(
PaymentOrder.status == "paid",
PaymentOrder.paid_at >= today_start,
PaymentOrder.paid_at < today_end,
)
source_filters = [PaymentOrder.status == "paid", PaymentOrder.created_at >= query_start, PaymentOrder.created_at < query_end]
if payment_method:
source_filters.append(PaymentOrder.payment_method == payment_method)
if order_source:
source_filters.append(PaymentOrder.order_source == order_source)
source_result = await db.execute(
select(PaymentOrder.order_source, func.count().label("count"), func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"))
.where(*source_filters)
.group_by(PaymentOrder.order_source)
)
today_row = today_result.one()
by_source = {
"online_payment": {"label": "线上支付", "count": 0, "amount": 0.0},
"admin_offline": {"label": "后台线下成交", "count": 0, "amount": 0.0},
}
for row in source_result.all():
target = by_source.setdefault(
row.order_source,
{"label": PAYMENT_ORDER_SOURCE_LABELS.get(row.order_source, "其他订单来源"), "count": 0, "amount": 0.0},
)
target["count"] = int(row.count or 0)
target["amount"] = round(float(row.amount or 0), 2)
total_income = {
"label": "总真实收入",
"count": sum(int(item["count"]) for item in by_source.values()),
"amount": round(sum(float(item["amount"]) for item in by_source.values()), 2),
}
async def _period_income(start_at: datetime, end_at: datetime) -> dict:
result = await db.execute(
select(PaymentOrder.order_source, func.count().label("count"), func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"))
.where(
PaymentOrder.status == "paid",
PaymentOrder.paid_at >= start_at,
PaymentOrder.paid_at < end_at,
)
.group_by(PaymentOrder.order_source)
)
source_map = {row.order_source: (int(row.count or 0), round(float(row.amount or 0), 2)) for row in result.all()}
online_count, online_amount = source_map.get("online_payment", (0, 0.0))
offline_count, offline_amount = source_map.get("admin_offline", (0, 0.0))
return {
"paid_count": online_count + offline_count,
"paid_amount": round(online_amount + offline_amount, 2),
"online_paid_count": online_count,
"online_paid_amount": online_amount,
"offline_paid_count": offline_count,
"offline_paid_amount": offline_amount,
}
# Monthly cumulative stats
month_start = now_cst.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
month_end = (month_start + timedelta(days=32)).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
month_result = await db.execute(
select(
func.count().label("paid_count"),
func.coalesce(func.sum(PaymentOrder.amount), 0).label("paid_amount"),
).where(
PaymentOrder.status == "paid",
PaymentOrder.paid_at >= month_start,
PaymentOrder.paid_at < month_end,
)
)
month_row = month_result.one()
return {
"by_status": by_status,
"today": {
"paid_count": today_row.paid_count,
"paid_amount": round(float(today_row.paid_amount), 2),
},
"month": {
"paid_count": month_row.paid_count,
"paid_amount": round(float(month_row.paid_amount), 2),
},
"by_source": by_source,
"total_income": total_income,
"today": await _period_income(today_start, today_end),
"month": await _period_income(month_start, month_end),
}
@@ -915,6 +938,7 @@ async def list_payment_orders(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=500),
payment_method: str | None = Query(None),
order_source: str | None = Query(None, pattern="^(online_payment|admin_offline)$"),
status: str | None = Query(None),
phone: str | None = Query(None, description="按用户手机号模糊搜索"),
start_date: str | None = Query(None),
@@ -922,13 +946,15 @@ async def list_payment_orders(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Return paginated payment orders for admin dashboard."""
"""管理后台统一订单列表;线上和后台线下成交均来自 payment_orders"""
del admin
query = select(PaymentOrder, User.username, User.phone).join(User, PaymentOrder.user_id == User.id)
count_query = select(func.count(PaymentOrder.id))
count_query = select(func.count(PaymentOrder.id)).join(User, PaymentOrder.user_id == User.id)
filters = []
if payment_method:
filters.append(PaymentOrder.payment_method == payment_method)
if order_source:
filters.append(PaymentOrder.order_source == order_source)
if status:
filters.append(PaymentOrder.status == status)
if phone:
@@ -937,43 +963,68 @@ async def list_payment_orders(
filters.append(PaymentOrder.created_at >= datetime.fromisoformat(start_date).replace(tzinfo=CST))
if end_date:
filters.append(PaymentOrder.created_at < (datetime.fromisoformat(end_date) + timedelta(days=1)).replace(tzinfo=CST))
if filters:
query = query.where(*filters)
count_query = count_query.where(*filters)
for f in filters:
query = query.where(f)
count_query = count_query.where(f)
total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(
query.order_by(PaymentOrder.created_at.desc()).offset((page - 1) * page_size).limit(page_size)
)
rows = result.all()
items = [
{
"id": o.id,
"orderNo": o.order_no,
"order_no": o.order_no,
"userId": o.user_id,
"user_id": o.user_id,
"username": username,
"phone": user_phone,
"amount": round(float(o.amount), 2),
"credits": round(float(o.credits), 2),
"paymentMethod": o.payment_method,
"payment_method": o.payment_method,
"status": o.status,
"tradeNo": o.trade_no,
"trade_no": o.trade_no,
"paidAt": o.paid_at.isoformat() if o.paid_at else None,
"paid_at": o.paid_at.isoformat() if o.paid_at else None,
"createdAt": o.created_at.isoformat() if o.created_at else None,
"created_at": o.created_at.isoformat() if o.created_at else None,
}
for o, username, user_phone in rows
]
total = int((await db.execute(count_query)).scalar() or 0)
rows = (await db.execute(
query.order_by(PaymentOrder.created_at.desc(), PaymentOrder.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)).all()
items = []
for order, username, user_phone in rows:
payment_label = PAYMENT_METHOD_LABELS.get(order.payment_method, "其他支付方式")
if order.payment_method == "other" and order.offline_payment_detail:
payment_label = f"其他-线下收款({order.offline_payment_detail}"
items.append(
{
"id": order.id,
"orderNo": order.order_no,
"order_no": order.order_no,
"userId": order.user_id,
"user_id": order.user_id,
"username": username,
"phone": user_phone,
"amount": round(float(order.amount), 2),
"credits": round(float(order.credits), 2),
"quantity": int(order.quantity or 1),
"quoted_unit_price_snapshot": float(order.quoted_unit_price_snapshot) if order.quoted_unit_price_snapshot is not None else None,
"quoted_amount_snapshot": float(order.quoted_amount_snapshot) if order.quoted_amount_snapshot is not None else None,
"actual_unit_price_snapshot": float(order.actual_unit_price_snapshot) if order.actual_unit_price_snapshot is not None else None,
"paymentMethod": order.payment_method,
"payment_method": order.payment_method,
"payment_method_label": payment_label,
"order_source": order.order_source,
"order_source_label": PAYMENT_ORDER_SOURCE_LABELS.get(order.order_source, "其他订单来源"),
"status": order.status,
"status_label": PAYMENT_STATUS_LABELS.get(order.status, "其他状态"),
"product_id": order.product_id,
"product_type": order.product_type,
"product_name_snapshot": order.product_name_snapshot,
"team_id_snapshot": order.team_id_snapshot,
"fulfillment_status": order.fulfillment_status,
"fulfillment_status_label": FULFILLMENT_STATUS_LABELS.get(order.fulfillment_status, "其他履约状态") if order.fulfillment_status else None,
"tradeNo": order.trade_no,
"trade_no": order.trade_no,
"offline_trade_no": order.offline_trade_no,
"offline_payment_detail": order.offline_payment_detail,
"remark": order.remark,
"refund_amount": float(order.refund_amount) if order.refund_amount is not None else None,
"refund_trade_no": order.refund_trade_no,
"refund_entitlement_status": order.refund_entitlement_status,
"paidAt": order.paid_at.isoformat() if order.paid_at else None,
"paid_at": order.paid_at.isoformat() if order.paid_at else None,
"refunded_at": order.refunded_at.isoformat() if order.refunded_at else None,
"createdAt": order.created_at.isoformat() if order.created_at else None,
"created_at": order.created_at.isoformat() if order.created_at else None,
}
)
return {"items": items, "total": total, "page": page, "page_size": page_size}
@router.put("/payment-configs/{config_id}")
async def update_payment_config(
config_id: str,
@@ -1024,25 +1075,14 @@ async def refund_payment_order(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Refund a paid payment order."""
result = await process_refund(db, order_no)
if not result.get("success"):
raise HTTPException(status_code=400, detail=result.get("message", "退款失败"))
await log_operation(
db,
admin.id,
admin.username,
f"订单退款: {order_no}",
"POST",
f"/admin/payment-orders/{order_no}/refund",
detail=json.dumps(
{
"order_no": order_no,
},
ensure_ascii=False,
),
)
return result
"""保留退款 API 路由兼容旧客户端,但本版本明确不开放主动订单退款。"""
del admin
exists = (await db.execute(
select(PaymentOrder.id).where(PaymentOrder.order_no == order_no).limit(1)
)).scalar_one_or_none()
if not exists:
raise HTTPException(status_code=404, detail="订单不存在")
raise HTTPException(status_code=409, detail="当前版本暂未开放订单退款")
# ── Industry Config ──────────────────────────────────────
+69 -2
View File
@@ -4,7 +4,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.models.credit.balance import UserCreditBalance
from app.models.credit.allocation import CreditRecordAllocation
from app.models.credit_record import CreditRecord
from app.enums.credit_balance import (
CREDIT_BALANCE_SOURCE_TYPE_LABELS,
CREDIT_BALANCE_STATUS_LABELS,
CREDIT_LEVEL_LABELS,
CREDIT_SCOPE_LABELS,
CreditScope,
)
from app.enums.credit_record import CREDIT_RECORD_BILLING_SCENE_LABELS, CREDIT_RECORD_TYPE_LABELS
from app.models.credit_ratio import CreditRatio
from app.models.image_engine import ImageEngine
from app.models.user import User
@@ -28,8 +37,13 @@ router = APIRouter(prefix="/credits", tags=["credits"])
def _balance_to_out(item: UserCreditBalance, *, checked_at) -> CreditBalanceItemOut:
return CreditBalanceItemOut(
id=item.id,
credit_scope=item.credit_scope,
credit_scope_label=CREDIT_SCOPE_LABELS.get(item.credit_scope, "其他积分"),
team_id=item.team_id,
credit_level=item.credit_level,
credit_level_label=CREDIT_LEVEL_LABELS.get(item.credit_level, "其他积分等级"),
source_type=item.source_type,
source_type_label=CREDIT_BALANCE_SOURCE_TYPE_LABELS.get(item.source_type, "其他来源"),
source_id=item.source_id,
product_id=item.product_id,
payment_order_id=item.payment_order_id,
@@ -44,10 +58,58 @@ def _balance_to_out(item: UserCreditBalance, *, checked_at) -> CreditBalanceItem
expires_at=item.expires_at,
last_usable_at=last_usable_at(item.expires_at),
status=effective_balance_status(item, request_time=checked_at),
status_label=CREDIT_BALANCE_STATUS_LABELS.get(
effective_balance_status(item, request_time=checked_at), "其他状态"
),
created_at=item.created_at,
)
async def _records_with_scope_amounts(db: AsyncSession, records: list[CreditRecord]) -> list[dict]:
if not records:
return []
ids = [item.id for item in records]
result = await db.execute(
select(
CreditRecordAllocation.credit_record_id,
CreditRecordAllocation.credit_scope_snapshot,
func.coalesce(func.sum(CreditRecordAllocation.amount), 0).label("amount"),
)
.where(CreditRecordAllocation.credit_record_id.in_(ids))
.group_by(CreditRecordAllocation.credit_record_id, CreditRecordAllocation.credit_scope_snapshot)
)
scope_map: dict[str, dict[str, float]] = {}
for row in result.all():
scope_map.setdefault(str(row.credit_record_id), {})[str(row.credit_scope_snapshot)] = float(row.amount or 0)
output = []
for record in records:
parts = scope_map.get(record.id, {})
sign = -1.0 if float(record.amount or 0) < 0 else 1.0
output.append({
"id": record.id,
"type": record.type,
"type_label": CREDIT_RECORD_TYPE_LABELS.get(record.type, "其他"),
"amount": float(record.amount),
"personal_amount": sign * float(parts.get("personal", 0)),
"team_amount": sign * float(parts.get("team", 0)),
"balance_delta": float(record.balance_delta or 0),
"expired_amount": float(record.expired_amount or 0),
"balance_after": float(record.balance_after or 0),
"description": record.description,
"billing_scene": record.billing_scene,
"billing_scene_label": CREDIT_RECORD_BILLING_SCENE_LABELS.get(record.billing_scene, "其他场景") if record.billing_scene else None,
"scene_name_snapshot": record.scene_name_snapshot,
"input_tokens": record.input_tokens,
"output_tokens": record.output_tokens,
"total_tokens": record.total_tokens,
"llm_call_count": record.llm_call_count,
"llm_success_call_count": record.llm_success_call_count,
"llm_failed_call_count": record.llm_failed_call_count,
"created_at": record.created_at,
})
return output
@router.get("")
async def get_credits(
page: int = Query(1, ge=1),
@@ -68,7 +130,7 @@ async def get_credits(
total_granted, total_consumed, total_refunded, total_expired = totals_result.one()
return {
**summary.to_dict(),
"records": [CreditRecordOut.model_validate(r) for r in records],
"records": await _records_with_scope_amounts(db, records),
"total": total,
"total_granted": float(total_granted or 0),
"total_consumed": float(total_consumed or 0),
@@ -86,7 +148,12 @@ async def list_credit_balances(
db: AsyncSession = Depends(get_db),
):
checked_at = utc_now()
stmt = select(UserCreditBalance).where(UserCreditBalance.user_id == current_user.id)
# 通用积分页只展示用户自己的个人积分批次。团队资金池归属成交时队长,
# 不能因为 Balance.owner 是队长就在这里展示整个团队资金池;团队席位与资金池明细统一在团队管理页查看。
stmt = select(UserCreditBalance).where(
UserCreditBalance.user_id == current_user.id,
UserCreditBalance.credit_scope == CreditScope.PERSONAL.value,
)
stmt = apply_balance_status_filter(stmt, status, request_time=checked_at)
stmt = stmt.order_by(UserCreditBalance.expires_at.asc(), UserCreditBalance.id.asc())
result = await db.execute(stmt.offset((page - 1) * page_size).limit(page_size))
+26 -15
View File
@@ -10,7 +10,6 @@ from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.models.payment_order import PaymentOrder
from app.models.credit.product import CreditProduct
from app.services.credit.upgrade_service import release_upgrade_reservation
from app.services.credit.utils import utc_now
from app.schemas.payment import RechargeRequest, PaymentOrderOut
from app.services.payment import (
@@ -60,6 +59,7 @@ async def recharge(
select(CreditProduct).where(
CreditProduct.id == req.plan,
CreditProduct.is_active.is_(True),
CreditProduct.deleted_at.is_(None),
).limit(1)
)
product = result.scalar_one_or_none()
@@ -71,6 +71,7 @@ async def recharge(
current_user.id,
method=req.method,
product_id=product.id,
quantity=req.quantity,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@@ -243,28 +244,39 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
)
return {"code": "SUCCESS", "message": "OK"}
# 处理退款回调
# 处理退款回调:本版本只记录渠道退款事实,不撤销订阅、Period、Seat、积分或首购资格。
elif event_type == "REFUND.SUCCESS":
order_no = decrypted_data.get("out_trade_no", "")
refund_id = decrypted_data.get("refund_id", "")
refund_status = decrypted_data.get("status", "")
refund_amount_info = decrypted_data.get("amount", {}) or {}
refund_amount = refund_amount_info.get("refund", 0) / 100
if order_no and refund_status == "SUCCESS":
# 更新订单状态为已退款
from app.models import PaymentOrder
from sqlalchemy import select
result = await db.execute(select(PaymentOrder).where(PaymentOrder.order_no == order_no))
result = await db.execute(
select(PaymentOrder)
.where(PaymentOrder.order_no == order_no)
.with_for_update()
.limit(1)
)
order = result.scalar_one_or_none()
if order and order.status == "refunding":
if order:
# 幂等记录渠道退款事实:即便升级前本地已经写成 refunded,
# 也要补齐渠道退款号/金额/时间;本版本绝不触碰任何订阅或积分权益。
order.status = "refunded"
order.transaction_id = refund_id
if refund_id:
order.refund_trade_no = refund_id
if refund_amount > 0:
order.refund_amount = refund_amount
elif order.refund_amount is None:
order.refund_amount = order.amount
if order.refunded_at is None:
order.refunded_at = utc_now()
order.refund_entitlement_status = "record_only"
await db.commit()
logger.info(
f"WeChat refund callback processed: order_no={order_no}, "
f"refund_id={refund_id}, status={refund_status}"
f"WECHAT_REFUND_CALLBACK_RECORDED order_no={order_no} "
f"refund_id={refund_id} amount={refund_amount} entitlement=record_only"
)
return {"code": "SUCCESS", "message": "OK"}
@@ -325,6 +337,7 @@ async def list_orders(
conditions.append(PaymentOrder.status == status_filter)
if invoice_mode:
conditions.append(PaymentOrder.status == "paid")
conditions.append(PaymentOrder.order_source == "online_payment")
if start_date:
start_dt = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
conditions.append(PaymentOrder.created_at >= start_dt)
@@ -451,8 +464,6 @@ async def cancel_order(
)
order.status = "cancelled"
if order.upgrade_period_ids_json:
await release_upgrade_reservation(db, order=order, released_at=utc_now())
await db.flush()
logger.info(
f"ORDER_CANCELLED order_no={order_no} user={current_user.id} amount={order.amount}"
@@ -22,6 +22,7 @@ async def list_active_packages(
.where(
CreditProduct.product_type == CreditProductType.CREDIT_ADDON.value,
CreditProduct.is_active.is_(True),
CreditProduct.deleted_at.is_(None),
)
.order_by(CreditProduct.sort_order.asc(), CreditProduct.id.asc())
)
+267 -208
View File
@@ -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):
# 情况 3ISO 字符串(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)}"},
)