422 lines
18 KiB
Python
422 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import timedelta
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.dependencies import get_admin_user, get_db
|
|
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, 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.time_policy import add_natural_months, last_usable_at
|
|
from app.services.credit.utils import utc_now
|
|
from app.services.notification import create_notification
|
|
from app.services.operation_log import log_operation
|
|
from app.services.operation_log_service import log_operation_event
|
|
from app.utils.id_gen import generate_id
|
|
|
|
router = APIRouter(prefix="/admin/credit-management", tags=["admin-credit-management"])
|
|
|
|
|
|
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 in {
|
|
CreditProductType.SUBSCRIPTION.value,
|
|
CreditProductType.TEAM_SUBSCRIPTION.value,
|
|
}:
|
|
product.price = product.regular_price or 0
|
|
product.grant_credits = None
|
|
product.validity_months = None
|
|
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
|
|
product.monthly_grant_credits = None
|
|
product.first_purchase_price = None
|
|
product.regular_price = None
|
|
product.activity_price = None
|
|
product.activity_start_at = None
|
|
product.activity_end_at = None
|
|
|
|
|
|
def _validate_product_entity(product: CreditProduct) -> None:
|
|
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,
|
|
}
|
|
missing = [label for label, value in required.items() if value is None]
|
|
if missing:
|
|
raise HTTPException(status_code=400, detail=f"订阅套餐缺少字段:{'、'.join(missing)}")
|
|
if product.activity_price is None:
|
|
if product.activity_start_at is not None or product.activity_end_at is not None:
|
|
raise HTTPException(status_code=400, detail="未配置活动价时不能单独配置活动周期")
|
|
elif product.activity_start_at is None or product.activity_end_at is 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 == 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:
|
|
raise HTTPException(status_code=400, detail="不支持的积分商品类型")
|
|
|
|
|
|
@router.get("/products")
|
|
async def list_products(
|
|
product_type: str | None = Query(default=None),
|
|
admin: User = Depends(get_admin_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
stmt = select(CreditProduct)
|
|
if product_type:
|
|
stmt = stmt.where(CreditProduct.product_type == product_type)
|
|
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()]
|
|
|
|
|
|
@router.post("/products")
|
|
async def create_product(
|
|
data: CreditProductCreate,
|
|
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)
|
|
)
|
|
if exists.scalar_one_or_none():
|
|
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, message="积分商品创建成功", detail={"product_id": product.id},
|
|
)
|
|
await db.commit()
|
|
return snapshot
|
|
|
|
|
|
@router.put("/products/{product_id}")
|
|
async def update_product(
|
|
product_id: str,
|
|
data: CreditProductUpdate,
|
|
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()
|
|
)
|
|
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)
|
|
_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),
|
|
)
|
|
await db.commit()
|
|
return after
|
|
|
|
|
|
@router.put("/products/{product_id}/renewal")
|
|
async def update_product_renewal(
|
|
product_id: str,
|
|
data: CreditProductRenewalUpdate,
|
|
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()
|
|
)
|
|
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="商品已软删除,不能修改续费开关")
|
|
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),
|
|
)
|
|
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
|
|
|
|
|
|
@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()
|
|
)
|
|
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="已软删除商品不能重新上架")
|
|
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 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()
|
|
)
|
|
product = result.scalar_one_or_none()
|
|
if not product:
|
|
raise HTTPException(status_code=404, detail="商品不存在")
|
|
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")
|
|
async def get_user_credit_summary(
|
|
user_id: str,
|
|
admin: User = Depends(get_admin_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
return (await get_balance_summary(db, user_id)).to_dict()
|
|
|
|
|
|
@router.get("/users/{user_id}/balances")
|
|
async def list_user_credit_balances(
|
|
user_id: str,
|
|
status: str | None = Query(default=None),
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(50, ge=1, le=200),
|
|
admin: User = Depends(get_admin_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
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)
|
|
)
|
|
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, "其他积分等级"),
|
|
"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),
|
|
"consumed_amount": float(item.consumed_amount),
|
|
"expired_amount": float(item.expired_amount),
|
|
"revoked_amount": float(item.revoked_amount),
|
|
"valid_from": item.valid_from,
|
|
"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, "其他状态"),
|
|
}
|
|
for item in result.scalars().all()
|
|
]
|
|
|
|
|
|
@router.post("/users/{user_id}/grant")
|
|
async def admin_grant_credit(
|
|
user_id: str,
|
|
data: AdminCreditGrantRequest,
|
|
admin: User = Depends(get_admin_user),
|
|
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)
|
|
)
|
|
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")
|
|
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}
|
|
|
|
|
|
@router.post("/users/{user_id}/deduct")
|
|
async def admin_deduct_credit(
|
|
user_id: str,
|
|
data: AdminCreditDeductRequest,
|
|
admin: User = Depends(get_admin_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
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()}",
|
|
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 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}
|