团队积分V1
This commit is contained in:
@@ -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()]
|
||||
|
||||
@@ -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": "团队已删除"}
|
||||
|
||||
Reference in New Issue
Block a user