团队积分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": "团队已删除"}
|
||||
|
||||
+174
-134
@@ -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 ──────────────────────────────────────
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -1,38 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from urllib.parse import quote
|
||||
|
||||
CST = timezone(timedelta(hours=8))
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from app.dependencies import get_current_user, get_db, get_optional_current_user
|
||||
from app.enums.team import TEAM_STATUS_LABELS, TeamStatus
|
||||
from app.enums.user import UserType
|
||||
from app.models.team import Team
|
||||
from app.models.team_join_request import TeamJoinRequest
|
||||
from app.models.user import User
|
||||
from app.schemas.team_invitation import TeamInvitationCreate, TeamInvitationOut
|
||||
from app.schemas.team_join_request import (
|
||||
JoinByCodeRequest,
|
||||
JoinRequestHandle,
|
||||
JoinRequestOut,
|
||||
JoinTeamInfoOut,
|
||||
)
|
||||
from app.schemas.team_manager import (
|
||||
ManagerTransferRequest,
|
||||
)
|
||||
from app.schemas.team_join_request import JoinByCodeRequest, JoinRequestHandle, JoinRequestOut, JoinTeamInfoOut
|
||||
from app.schemas.team_manager import SetManagerRequest
|
||||
from app.schemas.team_subscription import TeamSeatCreateRequest, TeamSeatUpdateRequest
|
||||
from app.services import team_invitation_service
|
||||
from app.services.credit.team_subscription_service import (
|
||||
cancel_seat,
|
||||
create_seat,
|
||||
list_member_period_usage,
|
||||
list_team_subscriptions_for_management,
|
||||
update_seat,
|
||||
)
|
||||
from app.services.team_credit_record_service import list_team_credit_records as query_team_credit_records
|
||||
from app.services.team_manager_service import (
|
||||
get_managed_team,
|
||||
get_manager_history,
|
||||
get_team_members,
|
||||
list_manager_access_teams,
|
||||
set_team_manager,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/team", tags=["team"])
|
||||
CST = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def _team_payload(team: Team, *, manager_name: str | None, member_count: int) -> dict:
|
||||
status = team.status or TeamStatus.ACTIVE.value
|
||||
return {
|
||||
"id": team.id,
|
||||
"name": team.name,
|
||||
"code": team.code,
|
||||
"description": team.description,
|
||||
"status": status,
|
||||
"status_label": TEAM_STATUS_LABELS.get(status, "其他状态"),
|
||||
"is_read_only": status == TeamStatus.DISABLED.value,
|
||||
"team_credit_frozen": status == TeamStatus.DISABLED.value,
|
||||
"member_count": int(member_count),
|
||||
"manager_id": team.manager_id,
|
||||
"manager_name": manager_name,
|
||||
"first_subscription_paid_at": team.first_subscription_paid_at,
|
||||
}
|
||||
|
||||
|
||||
async def _resolve_flow_team_id(db: AsyncSession, *, current_user: User, team_id: str | None) -> str:
|
||||
if team_id:
|
||||
# 真正的当前/历史队长权限由流水 Service 根据 TeamManagerHistory 再校验。
|
||||
return team_id
|
||||
team = await get_managed_team(db, current_user.id)
|
||||
if not team:
|
||||
raise HTTPException(status_code=400, detail="请指定需要查看的历史团队")
|
||||
return team.id
|
||||
|
||||
|
||||
# ── 获取当前用户管理的团队 ──────────────────────────────
|
||||
@router.get("/managed")
|
||||
async def get_managed_team_info(
|
||||
current_user: User = Depends(get_current_user),
|
||||
@@ -40,30 +76,25 @@ async def get_managed_team_info(
|
||||
):
|
||||
team = await get_managed_team(db, current_user.id)
|
||||
if not team:
|
||||
raise HTTPException(status_code=404, detail="您不是任何团队的管理人")
|
||||
|
||||
from sqlalchemy import func
|
||||
from app.enums.user import UserType
|
||||
raise HTTPException(status_code=404, detail="您当前不是任何团队的队长")
|
||||
member_count = (await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
User.user_type == UserType.FRONTEND.value,
|
||||
User.team_id == team.id,
|
||||
)
|
||||
)).scalar() or 0
|
||||
return _team_payload(team, manager_name=current_user.username, member_count=int(member_count))
|
||||
|
||||
return {
|
||||
"id": team.id,
|
||||
"name": team.name,
|
||||
"code": team.code,
|
||||
"description": team.description,
|
||||
"status": team.status,
|
||||
"member_count": int(member_count),
|
||||
"manager_id": team.manager_id,
|
||||
"manager_name": current_user.username,
|
||||
}
|
||||
|
||||
@router.get("/manager-access")
|
||||
async def list_manager_access(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""当前及历史队长可访问的团队列表,用于历史团队流水入口。"""
|
||||
return await list_manager_access_teams(db, current_user.id)
|
||||
|
||||
|
||||
# ── 团队成员列表 ──────────────────────────────────────
|
||||
@router.get("/members")
|
||||
async def list_team_members(
|
||||
page: int = Query(1, ge=1),
|
||||
@@ -73,23 +104,123 @@ async def list_team_members(
|
||||
):
|
||||
team = await get_managed_team(db, current_user.id)
|
||||
if not team:
|
||||
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
|
||||
raise HTTPException(status_code=403, detail="只有当前团队队长可以查看成员列表")
|
||||
return await get_team_members(db, team.id, page=page, page_size=page_size)
|
||||
|
||||
|
||||
# ── 转账积分给成员 ────────────────────────────────────
|
||||
@router.post("/members/{member_id}/credits")
|
||||
async def transfer_credits(
|
||||
member_id: str,
|
||||
req: ManagerTransferRequest,
|
||||
@router.put("/manager")
|
||||
async def transfer_manager(
|
||||
req: SetManagerRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
raise HTTPException(status_code=409, detail="当前版本积分暂未开放团队转账功能")
|
||||
team = await get_managed_team(db, current_user.id)
|
||||
if not team:
|
||||
raise HTTPException(status_code=403, detail="只有当前团队队长可以转让队长")
|
||||
await set_team_manager(db, team.id, req.user_id)
|
||||
return {"message": "团队队长已更换"}
|
||||
|
||||
|
||||
# ── 邀请码管理 ────────────────────────────────────────
|
||||
@router.post("/invitations", )
|
||||
@router.post("/members/{member_id}/credits")
|
||||
async def transfer_credits(
|
||||
member_id: str,
|
||||
req: dict = Body(default_factory=dict),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
del member_id, req, current_user, db
|
||||
raise HTTPException(status_code=409, detail="当前版本不支持团队积分转账,请使用团队订阅席位额度")
|
||||
|
||||
|
||||
@router.get("/subscriptions")
|
||||
async def list_team_subscriptions(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
team = await get_managed_team(db, current_user.id)
|
||||
if not team:
|
||||
raise HTTPException(status_code=403, detail="只有当前团队队长可以管理团队订阅席位")
|
||||
return await list_team_subscriptions_for_management(db, team_id=team.id)
|
||||
|
||||
|
||||
@router.post("/subscriptions/{subscription_id}/seats")
|
||||
async def create_subscription_seat(
|
||||
subscription_id: str,
|
||||
req: TeamSeatCreateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
team = await get_managed_team(db, current_user.id)
|
||||
if not team:
|
||||
raise HTTPException(status_code=403, detail="只有当前团队队长可以管理团队订阅席位")
|
||||
seat = await create_seat(
|
||||
db,
|
||||
team_id=team.id,
|
||||
subscription_id=subscription_id,
|
||||
manager_user_id=current_user.id,
|
||||
user_id=req.user_id,
|
||||
monthly_allocated_credits=req.monthly_allocated_credits,
|
||||
)
|
||||
return {"message": "席位已创建", "seat_id": seat.id}
|
||||
|
||||
|
||||
@router.put("/seats/{seat_id}")
|
||||
async def update_subscription_seat(
|
||||
seat_id: str,
|
||||
req: TeamSeatUpdateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
team = await get_managed_team(db, current_user.id)
|
||||
if not team:
|
||||
raise HTTPException(status_code=403, detail="只有当前团队队长可以管理团队订阅席位")
|
||||
seat = await update_seat(
|
||||
db,
|
||||
team_id=team.id,
|
||||
seat_id=seat_id,
|
||||
manager_user_id=current_user.id,
|
||||
monthly_allocated_credits=req.monthly_allocated_credits,
|
||||
)
|
||||
return {"message": "席位额度已更新", "seat_id": seat.id}
|
||||
|
||||
|
||||
@router.delete("/seats/{seat_id}")
|
||||
async def cancel_subscription_seat(
|
||||
seat_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
team = await get_managed_team(db, current_user.id)
|
||||
if not team:
|
||||
raise HTTPException(status_code=403, detail="只有当前团队队长可以管理团队订阅席位")
|
||||
await cancel_seat(db, team_id=team.id, seat_id=seat_id, manager_user_id=current_user.id)
|
||||
return {"message": "席位已取消"}
|
||||
|
||||
|
||||
@router.get("/member-usage")
|
||||
async def get_member_usage(
|
||||
subscription_id: str | None = Query(None),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
team = await get_managed_team(db, current_user.id)
|
||||
if not team:
|
||||
raise HTTPException(status_code=403, detail="只有当前团队队长可以查看成员团队积分消耗")
|
||||
return await list_member_period_usage(db, team_id=team.id, subscription_id=subscription_id)
|
||||
|
||||
|
||||
@router.get("/manager-history")
|
||||
async def manager_history(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
team = await get_managed_team(db, current_user.id)
|
||||
if not team:
|
||||
raise HTTPException(status_code=403, detail="只有当前团队队长可以查看完整队长任期历史")
|
||||
return await get_manager_history(db, team.id)
|
||||
|
||||
|
||||
@router.post("/invitations")
|
||||
async def create_invitation(
|
||||
req: TeamInvitationCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
@@ -97,15 +228,13 @@ async def create_invitation(
|
||||
):
|
||||
team = await get_managed_team(db, current_user.id)
|
||||
if not team:
|
||||
raise HTTPException(status_code=403, detail="只有团队管理人可创建邀请码")
|
||||
|
||||
raise HTTPException(status_code=403, detail="只有团队队长可创建邀请码")
|
||||
expires_at = None
|
||||
if req.expires_at:
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(req.expires_at)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="过期时间格式错误")
|
||||
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="过期时间格式错误") from exc
|
||||
invitation = await team_invitation_service.create_invitation(
|
||||
db, team.id, current_user.id, req.max_uses, expires_at
|
||||
)
|
||||
@@ -128,7 +257,7 @@ async def list_invitations(
|
||||
):
|
||||
team = await get_managed_team(db, current_user.id)
|
||||
if not team:
|
||||
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
|
||||
raise HTTPException(status_code=403, detail="只有团队队长可查看邀请码")
|
||||
invitations = await team_invitation_service.get_invitations_for_team(db, team.id)
|
||||
return [
|
||||
{
|
||||
@@ -152,10 +281,9 @@ async def revoke_invitation(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await team_invitation_service.revoke_invitation(db, invitation_id, current_user.id)
|
||||
return {"message": "ok"}
|
||||
return {"message": "邀请码已撤销"}
|
||||
|
||||
|
||||
# ── 加入申请 ──────────────────────────────────────────
|
||||
@router.post("/join")
|
||||
async def join_by_code(
|
||||
req: JoinByCodeRequest,
|
||||
@@ -163,42 +291,43 @@ async def join_by_code(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await team_invitation_service.create_join_request(db, current_user.id, req.invitation_code)
|
||||
return {"message": "申请已提交,请等待团队管理人审批"}
|
||||
return {"message": "申请已提交,请等待团队队长审批"}
|
||||
|
||||
|
||||
@router.get("/join-info", )
|
||||
async def get_join_info(
|
||||
code: str = Query(...),
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""验证邀请码并返回团队信息(用于加入页面展示)。"""
|
||||
async def _join_info_payload(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
code: str,
|
||||
current_user: User | None,
|
||||
) -> JoinTeamInfoOut:
|
||||
invitation = await team_invitation_service.get_invitation_by_code(db, code)
|
||||
if not invitation:
|
||||
return JoinTeamInfoOut(team_name="", team_id="", valid=False, already_in_team=False, has_pending_request=False)
|
||||
|
||||
team = await db.execute(
|
||||
select(Team.name).where(Team.id == invitation.team_id, Team.deleted_at.is_(None)).limit(1)
|
||||
team_result = await db.execute(
|
||||
select(Team).where(Team.id == invitation.team_id, Team.deleted_at.is_(None)).limit(1)
|
||||
)
|
||||
team_name = team.scalar_one_or_none() or ""
|
||||
|
||||
already_in_team = current_user and current_user.team_id == invitation.team_id
|
||||
|
||||
team = team_result.scalar_one_or_none()
|
||||
if not team or team.status != TeamStatus.ACTIVE.value:
|
||||
return JoinTeamInfoOut(
|
||||
team_name=team.name if team else "",
|
||||
team_id=invitation.team_id,
|
||||
valid=False,
|
||||
already_in_team=False,
|
||||
has_pending_request=False,
|
||||
)
|
||||
already_in_team = bool(current_user and current_user.team_id == invitation.team_id)
|
||||
has_pending_request = False
|
||||
if current_user:
|
||||
from app.models.team_join_request import TeamJoinRequest
|
||||
pending = await db.execute(
|
||||
select(TeamJoinRequest).where(
|
||||
select(TeamJoinRequest.id).where(
|
||||
TeamJoinRequest.user_id == current_user.id,
|
||||
TeamJoinRequest.team_id == invitation.team_id,
|
||||
TeamJoinRequest.status == "pending",
|
||||
).limit(1)
|
||||
)
|
||||
has_pending = pending.scalar_one_or_none()
|
||||
has_pending_request = has_pending is not None
|
||||
|
||||
has_pending_request = pending.scalar_one_or_none() is not None
|
||||
return JoinTeamInfoOut(
|
||||
team_name=team_name,
|
||||
team_name=team.name,
|
||||
team_id=invitation.team_id,
|
||||
valid=True,
|
||||
already_in_team=already_in_team,
|
||||
@@ -206,31 +335,21 @@ async def get_join_info(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/join-info/public", )
|
||||
async def get_join_info_public(
|
||||
@router.get("/join-info")
|
||||
async def get_join_info(
|
||||
code: str = Query(...),
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""公开接口:验证邀请码并返回团队信息(无需登录)。"""
|
||||
invitation = await team_invitation_service.get_invitation_by_code(db, code)
|
||||
if not invitation:
|
||||
return {"team_name": "", "team_id": "", "valid": False, "already_in_team": False, "has_pending_request": False}
|
||||
|
||||
team = await db.execute(
|
||||
select(Team.name).where(Team.id == invitation.team_id, Team.deleted_at.is_(None)).limit(1)
|
||||
)
|
||||
team_name = team.scalar_one_or_none() or ""
|
||||
|
||||
return {
|
||||
"team_name": team_name,
|
||||
"team_id": invitation.team_id,
|
||||
"valid": True,
|
||||
"already_in_team": False,
|
||||
"has_pending_request": False,
|
||||
}
|
||||
return await _join_info_payload(db, code=code, current_user=current_user)
|
||||
|
||||
|
||||
@router.get("/join-requests", )
|
||||
@router.get("/join-info/public")
|
||||
async def get_join_info_public(code: str = Query(...), db: AsyncSession = Depends(get_db)):
|
||||
return await _join_info_payload(db, code=code, current_user=None)
|
||||
|
||||
|
||||
@router.get("/join-requests")
|
||||
async def list_join_requests(
|
||||
status: str | None = Query(None),
|
||||
current_user: User = Depends(get_current_user),
|
||||
@@ -238,29 +357,22 @@ async def list_join_requests(
|
||||
):
|
||||
team = await get_managed_team(db, current_user.id)
|
||||
if not team:
|
||||
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
|
||||
raise HTTPException(status_code=403, detail="只有团队队长可查看加入申请")
|
||||
requests = await team_invitation_service.get_all_requests(db, team.id, status)
|
||||
|
||||
# 获取团队名
|
||||
team_name_result = await db.execute(
|
||||
select(Team.name).where(Team.id == team.id).limit(1)
|
||||
)
|
||||
team_name = team_name_result.scalar_one_or_none() or ""
|
||||
|
||||
return [
|
||||
JoinRequestOut(
|
||||
id=r["id"],
|
||||
team_id=r["team_id"],
|
||||
team_name=team_name,
|
||||
user_id=r["user_id"],
|
||||
username=r["username"],
|
||||
phone=r.get("phone"),
|
||||
status=r["status"],
|
||||
note=r.get("note"),
|
||||
created_at=r["created_at"],
|
||||
handled_at=r.get("handled_at"),
|
||||
id=item["id"],
|
||||
team_id=item["team_id"],
|
||||
team_name=team.name,
|
||||
user_id=item["user_id"],
|
||||
username=item["username"],
|
||||
phone=item.get("phone"),
|
||||
status=item["status"],
|
||||
note=item.get("note"),
|
||||
created_at=item["created_at"],
|
||||
handled_at=item.get("handled_at"),
|
||||
)
|
||||
for r in requests
|
||||
for item in requests
|
||||
]
|
||||
|
||||
|
||||
@@ -271,157 +383,104 @@ async def handle_join_request(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await team_invitation_service.handle_join_request(
|
||||
db, request_id, current_user.id, req.action, req.note
|
||||
)
|
||||
return {"message": "ok"}
|
||||
await team_invitation_service.handle_join_request(db, request_id, current_user.id, req.action, req.note)
|
||||
return {"message": "申请已处理"}
|
||||
|
||||
|
||||
# ── 团队积分变动记录 ────────────────────────────────────
|
||||
@router.get("/credit-records")
|
||||
async def list_team_credit_records(
|
||||
team_id: str | None = Query(None, description="历史队长查看旧团队时传团队ID"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user_id: str | None = Query(None),
|
||||
phone: str | None = Query(None, description="按手机号搜索"),
|
||||
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal)$", description="流水类型"),
|
||||
subscription_id: str | None = Query(None),
|
||||
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal|expire|revoke)$"),
|
||||
start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD"),
|
||||
end_date: str | None = Query(None, description="截止日期 YYYY-MM-DD"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""查看团队所有成员的积分变动记录(仅管理人)。"""
|
||||
team = await get_managed_team(db, current_user.id)
|
||||
if not team:
|
||||
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
|
||||
|
||||
from app.services.admin_credit_record_service import list_admin_credit_records
|
||||
|
||||
# 如果传了 phone,先找到对应的 user_id
|
||||
resolved_team_id = await _resolve_flow_team_id(db, current_user=current_user, team_id=team_id)
|
||||
resolved_user_id = user_id
|
||||
if phone and not user_id:
|
||||
phone_result = await db.execute(
|
||||
select(User.id).where(
|
||||
User.team_id == team.id,
|
||||
User.phone == phone,
|
||||
User.is_active.is_(True),
|
||||
).limit(1)
|
||||
)
|
||||
if phone and not resolved_user_id:
|
||||
phone_result = await db.execute(select(User.id).where(User.phone == phone).limit(1))
|
||||
resolved_user_id = phone_result.scalar_one_or_none()
|
||||
if not resolved_user_id:
|
||||
return {"items": [], "total": 0, "summary": {}}
|
||||
|
||||
return await list_admin_credit_records(
|
||||
return {"items": [], "total": 0, "page": page, "page_size": page_size}
|
||||
return await query_team_credit_records(
|
||||
db,
|
||||
team_id=resolved_team_id,
|
||||
viewer_user_id=current_user.id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
team_id=team.id,
|
||||
user_id=resolved_user_id,
|
||||
member_user_id=resolved_user_id,
|
||||
subscription_id=subscription_id,
|
||||
record_type=record_type,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
)
|
||||
|
||||
|
||||
# ── 团队积分导出 Excel ──────────────────────────────────
|
||||
@router.get("/credit-records/export")
|
||||
async def export_team_credit_records(
|
||||
team_id: str | None = Query(None),
|
||||
user_id: str | None = Query(None),
|
||||
phone: str | None = Query(None),
|
||||
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal)$", description="流水类型"),
|
||||
subscription_id: str | None = Query(None),
|
||||
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal|expire|revoke)$"),
|
||||
start_date: str | None = Query(None),
|
||||
end_date: str | None = Query(None),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""导出团队积分变动记录为 Excel(仅管理人)。"""
|
||||
team = await get_managed_team(db, current_user.id)
|
||||
if not team:
|
||||
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
|
||||
|
||||
from app.services.admin_credit_record_service import list_admin_credit_records
|
||||
|
||||
resolved_team_id = await _resolve_flow_team_id(db, current_user=current_user, team_id=team_id)
|
||||
resolved_user_id = user_id
|
||||
if phone and not user_id:
|
||||
phone_result = await db.execute(
|
||||
select(User.id).where(
|
||||
User.team_id == team.id,
|
||||
User.phone == phone,
|
||||
User.is_active.is_(True),
|
||||
).limit(1)
|
||||
)
|
||||
if phone and not resolved_user_id:
|
||||
phone_result = await db.execute(select(User.id).where(User.phone == phone).limit(1))
|
||||
resolved_user_id = phone_result.scalar_one_or_none()
|
||||
|
||||
# 拉取全部记录(不分页)
|
||||
result = await list_admin_credit_records(
|
||||
if not resolved_user_id:
|
||||
# 导出筛选手机号不存在时必须返回空结果,不能因为 user_id=None 退化成导出整个团队流水。
|
||||
resolved_user_id = "__not_found__"
|
||||
result = await query_team_credit_records(
|
||||
db,
|
||||
team_id=resolved_team_id,
|
||||
viewer_user_id=current_user.id,
|
||||
page=1,
|
||||
page_size=10000,
|
||||
team_id=team.id,
|
||||
user_id=resolved_user_id,
|
||||
member_user_id=resolved_user_id,
|
||||
subscription_id=subscription_id,
|
||||
record_type=record_type,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
)
|
||||
|
||||
# 生成 CSV(兼容 Excel 打开,UTF-8 BOM)
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime as _dt
|
||||
|
||||
def _format_dt(val):
|
||||
if val is None:
|
||||
return "-"
|
||||
try:
|
||||
# 情况 1:已经是 datetime
|
||||
if isinstance(val, _dt):
|
||||
dt = val
|
||||
elif isinstance(val, (int, float)):
|
||||
# 情况 2:Unix 时间戳(极少,兼容旧代码)
|
||||
dt = _dt.fromtimestamp(val)
|
||||
elif isinstance(val, str):
|
||||
# 情况 3:ISO 字符串(admin_credit_record_service._iso 返回的格式)
|
||||
s = val.strip()
|
||||
if s.endswith("Z"):
|
||||
s = s[:-1] + "+00:00"
|
||||
try:
|
||||
dt = _dt.fromisoformat(s)
|
||||
except ValueError:
|
||||
# 兼容旧格式 YYYY-MM-DD HH:MM:SS
|
||||
dt = _dt.strptime(s, "%Y-%m-%d %H:%M:%S")
|
||||
else:
|
||||
return str(val)
|
||||
# 统一转东八区展示
|
||||
if getattr(dt, "tzinfo", None) is None:
|
||||
dt = dt.replace(tzinfo=CST)
|
||||
else:
|
||||
dt = dt.astimezone(CST)
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except Exception: # noqa: BLE001
|
||||
return str(val) if val else "-"
|
||||
|
||||
team_result = await db.execute(select(Team).where(Team.id == resolved_team_id).limit(1))
|
||||
team = team_result.scalar_one_or_none()
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["用户名", "手机号", "类型", "积分变动", "余额", "说明", "时间"])
|
||||
writer.writerow(["用户名", "流水类型", "团队积分变动", "说明", "订阅实例", "周期ID", "席位ID", "时间"])
|
||||
for item in result.get("items", []):
|
||||
created_at = item.get("created_at")
|
||||
if isinstance(created_at, datetime):
|
||||
if created_at.tzinfo is None:
|
||||
created_at = created_at.replace(tzinfo=CST)
|
||||
else:
|
||||
created_at = created_at.astimezone(CST)
|
||||
created_at = created_at.strftime("%Y-%m-%d %H:%M:%S")
|
||||
writer.writerow([
|
||||
item.get("username") or "-",
|
||||
item.get("phone") or "-",
|
||||
item.get("record_type_label") or item.get("type") or "-",
|
||||
item.get("amount", 0),
|
||||
item.get("balance_after", 0),
|
||||
item.get("record_type_label") or "-",
|
||||
item.get("team_amount", 0),
|
||||
item.get("description") or "-",
|
||||
_format_dt(item.get("created_at")),
|
||||
item.get("subscription_no") or "历史订阅",
|
||||
item.get("subscription_period_id") or "-",
|
||||
item.get("seat_id") or "-",
|
||||
created_at or "-",
|
||||
])
|
||||
|
||||
from starlette.responses import StreamingResponse
|
||||
from urllib.parse import quote
|
||||
filename = f"团队积分_{(team.name if team else resolved_team_id)}_{datetime.now(CST).strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
output.seek(0)
|
||||
safe_team_name = team.name or "team"
|
||||
filename = f"团队积分_{safe_team_name}_{datetime.now(CST).strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
encoded_filename = quote(filename)
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv; charset=utf-8-sig",
|
||||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"},
|
||||
iter(["\ufeff" + output.getvalue()]),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user