团队积分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": "团队已删除"}