Merge branch 'main' of https://gitee.com/wg123/video-gen
This commit is contained in:
@@ -7,6 +7,8 @@ from app.api.admin.team import router as team_router
|
||||
from app.api.admin.home_material import router as home_material_router
|
||||
from app.api.admin.private_portrait import router as private_portrait_router
|
||||
from app.api.admin.recharge_package import router as recharge_package_router
|
||||
from app.api.admin.credit_management import router as credit_management_router
|
||||
from app.api.admin.llm_billing import router as llm_billing_router
|
||||
from app.api.admin.menu_config import router as menu_config_router
|
||||
from app.api.admin.upload import router as admin_upload_router
|
||||
from app.api.admin.contact import router as admin_contact_router
|
||||
@@ -22,6 +24,8 @@ router.include_router(team_router)
|
||||
router.include_router(home_material_router)
|
||||
router.include_router(private_portrait_router)
|
||||
router.include_router(recharge_package_router)
|
||||
router.include_router(credit_management_router)
|
||||
router.include_router(llm_billing_router)
|
||||
router.include_router(menu_config_router)
|
||||
router.include_router(admin_upload_router)
|
||||
router.include_router(admin_contact_router)
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
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,
|
||||
CreditBalanceSourceType,
|
||||
)
|
||||
from app.models.credit.balance import UserCreditBalance
|
||||
from app.models.credit.product import CreditProduct
|
||||
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.services.credit.ledger_service import deduct_credits, grant_credits
|
||||
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 == "credit_addon" and product.validity_months is None:
|
||||
# 兼容旧管理端未提交有效期的请求,新建增值包仍默认1个月;
|
||||
# 更新时未提交该字段则保留原值。
|
||||
product.validity_months = 1
|
||||
if product.product_type == "subscription":
|
||||
product.price = product.regular_price or 0
|
||||
product.grant_credits = None
|
||||
product.validity_months = None
|
||||
else:
|
||||
product.renewal_enabled = False
|
||||
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 == "subscription":
|
||||
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 == "credit_addon":
|
||||
if product.grant_credits is None or product.price 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.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, detail=snapshot)
|
||||
# 商品保存后前端会立即使用返回值刷新列表。这里显式提交,避免依赖
|
||||
# yield 依赖退出阶段提交时出现紧随其后的 GET 读到旧状态。
|
||||
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="商品不存在")
|
||||
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)
|
||||
_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 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)
|
||||
|
||||
|
||||
@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.product_type != "subscription":
|
||||
raise HTTPException(status_code=400, detail="积分增值包不支持续费开关")
|
||||
|
||||
before = bool(product.renewal_enabled)
|
||||
product.renewal_enabled = bool(data.renewal_enabled)
|
||||
await db.flush()
|
||||
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,
|
||||
),
|
||||
)
|
||||
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="商品不存在")
|
||||
if bool(persisted.renewal_enabled) != bool(data.renewal_enabled):
|
||||
raise HTTPException(status_code=500, detail="续费状态保存后校验失败")
|
||||
return product_to_dict(persisted)
|
||||
|
||||
|
||||
@router.delete("/products/{product_id}")
|
||||
async def disable_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="商品不存在")
|
||||
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}
|
||||
|
||||
|
||||
@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_level": item.credit_level,
|
||||
"credit_level_label": CREDIT_LEVEL_LABELS.get(item.credit_level, item.credit_level),
|
||||
"source_type": item.source_type,
|
||||
"source_type_label": CREDIT_BALANCE_SOURCE_TYPE_LABELS.get(item.source_type, 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, 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()}",
|
||||
)
|
||||
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}
|
||||
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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.llm_billing import LLM_BILLING_SCENE_LABELS
|
||||
from app.models.llm_billing.policy import LlmBillingPolicyModel
|
||||
from app.models.user import User
|
||||
from app.schemas.llm_billing import LlmBillingPolicyCreate, LlmBillingPolicyUpdate
|
||||
from app.services.llm_billing.query_service import list_executions_with_calls
|
||||
from app.services.operation_log_service import log_operation_event
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
router = APIRouter(prefix="/admin/llm-billing", tags=["admin-llm-billing"])
|
||||
|
||||
|
||||
def _policy_out(policy: LlmBillingPolicyModel) -> dict:
|
||||
# 数据库 pre_deduct_credits 是历史物理字段名;Admin API 统一输出 charge_credits。
|
||||
return {
|
||||
"id": policy.id,
|
||||
"scene_code": policy.scene_code,
|
||||
"scene_name": policy.scene_name,
|
||||
"charge_credits": float(policy.pre_deduct_credits),
|
||||
"is_active": policy.is_active,
|
||||
"version": policy.version,
|
||||
"created_by": policy.created_by,
|
||||
"updated_by": policy.updated_by,
|
||||
"created_at": policy.created_at,
|
||||
"updated_at": policy.updated_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/policies")
|
||||
async def list_policies(
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(LlmBillingPolicyModel).order_by(LlmBillingPolicyModel.scene_code))
|
||||
return [_policy_out(item) for item in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/policies")
|
||||
async def create_policy(
|
||||
data: LlmBillingPolicyCreate,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if data.scene_code not in LLM_BILLING_SCENE_LABELS:
|
||||
raise HTTPException(status_code=400, detail="不支持的LLM业务场景")
|
||||
existing = await db.execute(
|
||||
select(LlmBillingPolicyModel.id)
|
||||
.where(LlmBillingPolicyModel.scene_code == data.scene_code)
|
||||
.limit(1)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail="该LLM业务场景已存在")
|
||||
|
||||
policy = LlmBillingPolicyModel(
|
||||
id=generate_id(),
|
||||
scene_code=data.scene_code,
|
||||
scene_name=LLM_BILLING_SCENE_LABELS[data.scene_code],
|
||||
# 保留历史物理字段,不做无业务价值的数据库重命名迁移。
|
||||
pre_deduct_credits=data.charge_credits,
|
||||
is_active=data.is_active,
|
||||
version=1,
|
||||
created_by=admin.id,
|
||||
updated_by=admin.id,
|
||||
)
|
||||
db.add(policy)
|
||||
await db.flush()
|
||||
policy_id = policy.id
|
||||
scene_code = policy.scene_code
|
||||
charge_credits = float(policy.pre_deduct_credits)
|
||||
log_operation_event(
|
||||
domain="llm_billing",
|
||||
module="admin",
|
||||
event_type="LLM_BILLING_POLICY_CREATED",
|
||||
user_id=admin.id,
|
||||
detail={"scene_code": scene_code, "charge_credits": charge_credits, "policy_id": policy_id},
|
||||
)
|
||||
return _policy_out(policy)
|
||||
|
||||
|
||||
@router.put("/policies/{policy_id}")
|
||||
async def update_policy(
|
||||
policy_id: str,
|
||||
data: LlmBillingPolicyUpdate,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(LlmBillingPolicyModel)
|
||||
.where(LlmBillingPolicyModel.id == policy_id)
|
||||
.limit(1)
|
||||
.with_for_update()
|
||||
)
|
||||
policy = result.scalar_one_or_none()
|
||||
if not policy:
|
||||
raise HTTPException(status_code=404, detail="LLM积分场景不存在")
|
||||
|
||||
payload = data.model_dump(exclude_unset=True)
|
||||
payload.pop("scene_name", None)
|
||||
if "charge_credits" in payload:
|
||||
policy.pre_deduct_credits = payload.pop("charge_credits")
|
||||
if "is_active" in payload:
|
||||
policy.is_active = payload["is_active"]
|
||||
policy.scene_name = LLM_BILLING_SCENE_LABELS.get(policy.scene_code, policy.scene_name)
|
||||
policy.version += 1
|
||||
policy.updated_by = admin.id
|
||||
await db.flush()
|
||||
|
||||
log_operation_event(
|
||||
domain="llm_billing",
|
||||
module="admin",
|
||||
event_type="LLM_BILLING_POLICY_UPDATED",
|
||||
user_id=admin.id,
|
||||
detail={
|
||||
"scene_code": policy.scene_code,
|
||||
"policy_id": policy.id,
|
||||
"version": policy.version,
|
||||
"charge_credits": float(policy.pre_deduct_credits),
|
||||
"is_active": policy.is_active,
|
||||
},
|
||||
)
|
||||
return _policy_out(policy)
|
||||
|
||||
|
||||
@router.get("/executions")
|
||||
async def list_executions(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
scene_code: str | None = Query(default=None),
|
||||
status: str | None = Query(default=None),
|
||||
user_id: str | None = Query(default=None),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
items, total = await list_executions_with_calls(
|
||||
db,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
scene_code=scene_code,
|
||||
status=status,
|
||||
user_id=user_id,
|
||||
)
|
||||
return {"items": items, "total": total}
|
||||
@@ -1,148 +1,24 @@
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db, get_admin_user
|
||||
from app.dependencies import get_admin_user, get_db
|
||||
from app.enums.credit_product import CreditProductType
|
||||
from app.models.credit.product import CreditProduct
|
||||
from app.models.user import User
|
||||
from app.models.recharge_package import RechargePackage
|
||||
from app.schemas.recharge_package import (
|
||||
RechargePackageCreate,
|
||||
RechargePackageUpdate,
|
||||
RechargePackageOut,
|
||||
)
|
||||
from app.services.operation_log import log_operation
|
||||
from app.utils.id_gen import generate_id
|
||||
from app.services.credit.product_service import product_to_dict
|
||||
|
||||
router = APIRouter(prefix="/admin/recharge-packages", tags=["admin-recharge-packages"])
|
||||
|
||||
|
||||
def _to_out(pkg: RechargePackage) -> dict:
|
||||
return {
|
||||
"id": pkg.id,
|
||||
"name": pkg.name,
|
||||
"credits": round(pkg.credits, 2),
|
||||
"price": round(pkg.price, 2),
|
||||
"bonus_credits": round(pkg.bonus_credits, 2),
|
||||
"total_credits": round(pkg.credits + pkg.bonus_credits, 2),
|
||||
"description": pkg.description,
|
||||
"package_type": pkg.package_type,
|
||||
"is_gift": pkg.is_gift,
|
||||
"is_active": pkg.is_active,
|
||||
"sort_order": pkg.sort_order,
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_model=list[RechargePackageOut])
|
||||
@router.get("")
|
||||
async def admin_list_packages(
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(RechargePackage).order_by(RechargePackage.sort_order)
|
||||
select(CreditProduct)
|
||||
.where(CreditProduct.product_type == CreditProductType.CREDIT_ADDON.value)
|
||||
.order_by(CreditProduct.sort_order, CreditProduct.id)
|
||||
)
|
||||
return [_to_out(p) for p in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("", response_model=RechargePackageOut)
|
||||
async def create_package(
|
||||
data: RechargePackageCreate,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
pkg = RechargePackage(id=generate_id(), **data.model_dump())
|
||||
db.add(pkg)
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"创建充值套餐 {pkg.name}",
|
||||
"POST",
|
||||
"/admin/recharge-packages",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"id": pkg.id,
|
||||
"name": pkg.name,
|
||||
"price": round(pkg.price, 2),
|
||||
"credits": round(pkg.credits, 2),
|
||||
"bonus_credits": round(pkg.bonus_credits, 2),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
return _to_out(pkg)
|
||||
|
||||
|
||||
@router.put("/{pkg_id}", response_model=RechargePackageOut)
|
||||
async def update_package(
|
||||
pkg_id: str,
|
||||
data: RechargePackageUpdate,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(RechargePackage).where(RechargePackage.id == pkg_id).limit(1)
|
||||
)
|
||||
pkg = result.scalar_one_or_none()
|
||||
if not pkg:
|
||||
raise HTTPException(status_code=404, detail="套餐不存在")
|
||||
before = _to_out(pkg)
|
||||
for k, v in data.model_dump(exclude_unset=True).items():
|
||||
setattr(pkg, k, v)
|
||||
await db.flush()
|
||||
after = _to_out(pkg)
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"更新充值套餐 {pkg.name}",
|
||||
"PUT",
|
||||
f"/admin/recharge-packages/{pkg_id}",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"id": pkg_id,
|
||||
"before": before,
|
||||
"after": after,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
return _to_out(pkg)
|
||||
|
||||
|
||||
@router.delete("/{pkg_id}")
|
||||
async def delete_package(
|
||||
pkg_id: str,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(RechargePackage).where(RechargePackage.id == pkg_id).limit(1)
|
||||
)
|
||||
pkg = result.scalar_one_or_none()
|
||||
if not pkg:
|
||||
raise HTTPException(status_code=404, detail="套餐不存在")
|
||||
pkg_name = pkg.name
|
||||
await db.delete(pkg)
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"删除充值套餐 {pkg_name}",
|
||||
"DELETE",
|
||||
f"/admin/recharge-packages/{pkg_id}",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"id": pkg_id,
|
||||
"name": pkg_name,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
return [product_to_dict(item) for item in result.scalars().all()]
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.api.v1.sms import router as sms_router
|
||||
from app.api.v1.industries import router as industries_router
|
||||
from app.api.v1.menu_configs import router as menu_configs_router
|
||||
from app.api.v1.recharge_packages import router as recharge_packages_router
|
||||
from app.api.v1.credit_products import router as credit_products_router
|
||||
from app.api.v1.video_engines import router as video_engines_router
|
||||
from app.api.v1.image_engines import router as image_engines_router
|
||||
from app.api.v1.generation_ai import router as generation_ai_router
|
||||
@@ -51,6 +52,7 @@ api_router.include_router(sms_router)
|
||||
api_router.include_router(industries_router)
|
||||
api_router.include_router(menu_configs_router)
|
||||
api_router.include_router(recharge_packages_router)
|
||||
api_router.include_router(credit_products_router)
|
||||
api_router.include_router(video_engines_router)
|
||||
api_router.include_router(image_engines_router)
|
||||
api_router.include_router(generation_ai_router)
|
||||
|
||||
@@ -48,10 +48,13 @@ from app.schemas.video_engine import VideoEngineCreate, VideoEngineOut
|
||||
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.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
|
||||
from app.services.admin_credit_record_service import list_admin_credit_records
|
||||
from app.services.system_config_cache import invalidate_system_config_cache
|
||||
from app.services.llm_billing.config import validate_llm_system_config_value
|
||||
from app.services.notification import create_notification
|
||||
from app.services.auth import hash_password, verify_password
|
||||
from app.services.operation_log import log_operation
|
||||
@@ -131,6 +134,9 @@ 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)
|
||||
for item in users:
|
||||
attach_credit_snapshot(item, credit_map.get(item.id, 0.0))
|
||||
return {
|
||||
"items": [
|
||||
AdminUserOut.model_validate(user)
|
||||
@@ -179,16 +185,29 @@ async def create_user(
|
||||
hashed_password=hash_password(req.password),
|
||||
email=req.email,
|
||||
phone=req.phone,
|
||||
credits=req.credits,
|
||||
is_admin=req.is_admin if req.user_type == "admin" else False,
|
||||
user_type=req.user_type,
|
||||
frontend_user_kind=req.frontend_user_kind if req.user_type == "frontend" else FrontendUserKind.EXTERNAL.value,
|
||||
allowed_menus=req.allowed_menus,
|
||||
private_portrait_asset_limit=req.private_portrait_asset_limit,
|
||||
)
|
||||
user.credits = round(user.credits, 2)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
if req.credits > 0:
|
||||
now = utc_now()
|
||||
await add_credits(
|
||||
db, user.id, req.credits, "管理员创建用户初始积分",
|
||||
record_meta=build_admin_adjust_meta(),
|
||||
valid_from=now,
|
||||
expires_at=add_natural_months(now, 1),
|
||||
credit_level=CreditLevel.GENERAL.value,
|
||||
source_type=CreditBalanceSourceType.ADMIN_GRANT.value,
|
||||
source_id=admin.id,
|
||||
related_id=admin.id,
|
||||
biz_key=f"admin-create-user-credit:{user.id}",
|
||||
)
|
||||
else:
|
||||
attach_credit_snapshot(user, 0)
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
@@ -258,7 +277,7 @@ async def get_user(
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
user.credits = round(user.credits, 2)
|
||||
attach_credit_snapshot(user, await get_available_credits(db, user.id))
|
||||
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(
|
||||
@@ -277,7 +296,17 @@ async def adjust_credits(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if req.amount > 0:
|
||||
await add_credits(db, user_id, req.amount, f"管理员调整: {req.description}", record_meta=build_admin_adjust_meta())
|
||||
now = utc_now()
|
||||
await add_credits(
|
||||
db, user_id, req.amount, f"管理员调整: {req.description}",
|
||||
record_meta=build_admin_adjust_meta(),
|
||||
valid_from=now, expires_at=add_natural_months(now, 1),
|
||||
credit_level=CreditLevel.GENERAL.value,
|
||||
source_type=CreditBalanceSourceType.ADMIN_GRANT.value,
|
||||
source_id=admin.id,
|
||||
related_id=admin.id,
|
||||
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 create_notification(
|
||||
@@ -1527,6 +1556,8 @@ async def create_model_config(
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if req.provider == "mock":
|
||||
raise HTTPException(status_code=400, detail="正式LLM计费链路禁止新增Mock模型")
|
||||
config = ModelConfig(id=generate_id(), **req.model_dump())
|
||||
db.add(config)
|
||||
await db.flush()
|
||||
@@ -1555,6 +1586,8 @@ async def update_model_config(
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if req.provider == "mock":
|
||||
raise HTTPException(status_code=400, detail="正式LLM计费链路禁止使用Mock模型")
|
||||
result = await db.execute(select(ModelConfig).where(ModelConfig.id == config_id, ModelConfig.deleted_at.is_(None)).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
if not config:
|
||||
@@ -1630,10 +1663,6 @@ async def create_system_config(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from app.utils.id_gen import generate_id
|
||||
try:
|
||||
await validate_llm_system_config_value(db, key=req.key, value=str(req.value))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
config = SystemConfig(
|
||||
id=generate_id(),
|
||||
key=req.key,
|
||||
@@ -1668,10 +1697,6 @@ async def update_system_config(
|
||||
config = result.scalar_one_or_none()
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail="配置不存在")
|
||||
try:
|
||||
await validate_llm_system_config_value(db, key=str(config.key), value=str(req.value))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
config.value = str(req.value)
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
|
||||
@@ -12,7 +12,6 @@ from app.dependencies import (
|
||||
get_current_user_allow_password_pending,
|
||||
get_db,
|
||||
)
|
||||
from app.models.credit_record import CreditRecord
|
||||
from app.models.system_config import SystemConfig
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import (
|
||||
@@ -34,6 +33,10 @@ from app.services.auth import (
|
||||
)
|
||||
from app.services.sms import verify_sms_code
|
||||
from app.services.resource_capacity_service import get_user_resource_capacity_usage
|
||||
from app.enums.credit_balance import CreditBalanceSourceType, CreditLevel
|
||||
from app.services.credit.ledger_service import grant_credits
|
||||
from app.services.credit.query_service import attach_credit_snapshot, get_available_credits
|
||||
from app.services.credit.time_policy import add_natural_months
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
@@ -95,50 +98,57 @@ async def _get_register_credits(db: AsyncSession) -> int:
|
||||
|
||||
async def _add_register_credit_record(db: AsyncSession, user: User, credits: int) -> None:
|
||||
if credits <= 0:
|
||||
attach_credit_snapshot(user, 0)
|
||||
return
|
||||
record = CreditRecord(
|
||||
id=generate_id(),
|
||||
now = datetime.now(timezone.utc)
|
||||
result = await grant_credits(
|
||||
db,
|
||||
user_id=user.id,
|
||||
type="recharge",
|
||||
amount=credits,
|
||||
balance_after=user.credits,
|
||||
description=f"注册赠送 {credits} 积分",
|
||||
source_type=CreditBalanceSourceType.REGISTER_GIFT.value,
|
||||
source_id=user.id,
|
||||
valid_from=now,
|
||||
expires_at=add_natural_months(now, 1),
|
||||
credit_level=CreditLevel.PROMOTIONAL.value,
|
||||
related_id=user.id,
|
||||
biz_key=f"register-gift:{user.id}",
|
||||
request_time=now,
|
||||
)
|
||||
db.add(record)
|
||||
attach_credit_snapshot(user, result.balance_after)
|
||||
|
||||
|
||||
async def _handle_daily_login_credits(db: AsyncSession, user: User) -> None:
|
||||
enabled_result = await db.execute(
|
||||
select(SystemConfig.value).where(SystemConfig.key == "user_login_credits_enabled").limit(1)
|
||||
)
|
||||
enabled = enabled_result.scalar_one_or_none() == "true"
|
||||
if not enabled:
|
||||
if enabled_result.scalar_one_or_none() != "true":
|
||||
attach_credit_snapshot(user, await get_available_credits(db, user.id))
|
||||
return
|
||||
|
||||
credits_result = await db.execute(
|
||||
select(SystemConfig.value).where(SystemConfig.key == "user_login_credits").limit(1)
|
||||
)
|
||||
credits = int(credits_result.scalar_one_or_none() or "0")
|
||||
if credits <= 0:
|
||||
return
|
||||
|
||||
today = datetime.now(CST).date()
|
||||
if user.last_login_at:
|
||||
last_login_date = user.last_login_at.date()
|
||||
if last_login_date >= today:
|
||||
return
|
||||
|
||||
user.credits += credits
|
||||
|
||||
record = CreditRecord(
|
||||
id=generate_id(),
|
||||
user_id=user.id,
|
||||
type="recharge",
|
||||
amount=credits,
|
||||
balance_after=user.credits,
|
||||
description=f"每日登录赠送 {credits} 积分",
|
||||
)
|
||||
db.add(record)
|
||||
now_cst = datetime.now(CST)
|
||||
if credits > 0:
|
||||
next_midnight_cst = datetime.combine(now_cst.date() + timedelta(days=1), datetime.min.time(), tzinfo=CST)
|
||||
result = await grant_credits(
|
||||
db,
|
||||
user_id=user.id,
|
||||
amount=credits,
|
||||
description=f"每日登录赠送 {credits} 积分",
|
||||
source_type=CreditBalanceSourceType.DAILY_LOGIN.value,
|
||||
source_id=now_cst.date().isoformat(),
|
||||
valid_from=now_cst,
|
||||
expires_at=next_midnight_cst,
|
||||
credit_level=CreditLevel.PROMOTIONAL.value,
|
||||
related_id=user.id,
|
||||
biz_key=f"daily-login:{user.id}:{now_cst.date().isoformat()}",
|
||||
request_time=now_cst,
|
||||
)
|
||||
attach_credit_snapshot(user, result.balance_after)
|
||||
else:
|
||||
attach_credit_snapshot(user, await get_available_credits(db, user.id))
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -228,7 +238,6 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
phone=req.phone,
|
||||
hashed_password=hash_password(req.password),
|
||||
password_set_at=datetime.now(CST),
|
||||
credits=register_credits,
|
||||
is_admin=False,
|
||||
user_type="frontend",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.credit_product import CreditProductCatalogOut
|
||||
from app.services.credit.product_service import build_product_catalog
|
||||
|
||||
router = APIRouter(prefix="/credit-products", tags=["credit-products"])
|
||||
|
||||
|
||||
@router.get("/catalog", response_model=CreditProductCatalogOut)
|
||||
async def get_credit_product_catalog(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await build_product_catalog(db, user=current_user)
|
||||
@@ -1,20 +1,53 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.dependencies import get_db, get_current_user
|
||||
from app.models.user import User
|
||||
from app.dependencies import get_current_user, get_db
|
||||
from app.models.credit.balance import UserCreditBalance
|
||||
from app.models.credit_record import CreditRecord
|
||||
from app.models.credit_ratio import CreditRatio
|
||||
from app.models.video_engine import VideoEngine
|
||||
from app.models.image_engine import ImageEngine
|
||||
from app.schemas.credit import CreditBalanceOut, CreditRecordOut
|
||||
from app.models.user import User
|
||||
from app.models.video_engine import VideoEngine
|
||||
from app.schemas.credit import CreditRecordOut
|
||||
from app.schemas.credit_balance import CreditBalanceItemOut
|
||||
from app.schemas.credit_ratio import CreditRatioOut
|
||||
from app.services.credit.query_service import (
|
||||
apply_balance_status_filter,
|
||||
effective_balance_status,
|
||||
get_balance_summary,
|
||||
)
|
||||
from app.services.credit.utils import utc_now
|
||||
from app.services.credit.time_policy import last_usable_at
|
||||
from app.services.credit_ratio_service import list_all_credit_ratios
|
||||
from app.services.credits import get_records
|
||||
|
||||
router = APIRouter(prefix="/credits", tags=["credits"])
|
||||
|
||||
|
||||
def _balance_to_out(item: UserCreditBalance, *, checked_at) -> CreditBalanceItemOut:
|
||||
return CreditBalanceItemOut(
|
||||
id=item.id,
|
||||
credit_level=item.credit_level,
|
||||
source_type=item.source_type,
|
||||
source_id=item.source_id,
|
||||
product_id=item.product_id,
|
||||
payment_order_id=item.payment_order_id,
|
||||
subscription_id=item.subscription_id,
|
||||
subscription_period_id=item.subscription_period_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=effective_balance_status(item, request_time=checked_at),
|
||||
created_at=item.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_credits(
|
||||
page: int = Query(1, ge=1),
|
||||
@@ -23,18 +56,47 @@ async def get_credits(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
records, total = await get_records(db, current_user.id, page, page_size)
|
||||
summary = await get_balance_summary(db, current_user.id)
|
||||
totals_result = await db.execute(
|
||||
select(
|
||||
func.coalesce(func.sum(case((CreditRecord.type == "recharge", CreditRecord.amount), else_=0)), 0),
|
||||
func.coalesce(func.sum(case((CreditRecord.type == "consume", func.abs(CreditRecord.amount)), else_=0)), 0),
|
||||
func.coalesce(func.sum(case((CreditRecord.type == "refund", CreditRecord.amount), else_=0)), 0),
|
||||
func.coalesce(func.sum(case((CreditRecord.type == "expire", CreditRecord.expired_amount), else_=0)), 0),
|
||||
).where(CreditRecord.user_id == current_user.id)
|
||||
)
|
||||
total_granted, total_consumed, total_refunded, total_expired = totals_result.one()
|
||||
return {
|
||||
"credits": round(current_user.credits, 2),
|
||||
**summary.to_dict(),
|
||||
"records": [CreditRecordOut.model_validate(r) for r in records],
|
||||
"total": total,
|
||||
"total_granted": float(total_granted or 0),
|
||||
"total_consumed": float(total_consumed or 0),
|
||||
"total_refunded": float(total_refunded or 0),
|
||||
"total_expired": float(total_expired or 0),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/balances")
|
||||
async def list_credit_balances(
|
||||
status: str | None = Query(default=None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
checked_at = utc_now()
|
||||
stmt = select(UserCreditBalance).where(UserCreditBalance.user_id == current_user.id)
|
||||
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))
|
||||
return [_balance_to_out(item, checked_at=checked_at) for item in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/credit-ratios",
|
||||
response_model=list[CreditRatioOut],
|
||||
summary="获取积分比例列表",
|
||||
description="客户端获取当前系统配置的积分计费规则列表。普通登录用户可访问,只读返回 credit_ratios 表中的图片/视频积分比例配置。",
|
||||
)
|
||||
async def list_client_credit_ratios(
|
||||
current_user: User = Depends(get_current_user),
|
||||
@@ -59,29 +121,22 @@ async def get_credit_ratios(
|
||||
if ratios:
|
||||
return [CreditRatioOut.model_validate(r) for r in ratios]
|
||||
return []
|
||||
|
||||
video_engines_result = await db.execute(
|
||||
|
||||
video_result = await db.execute(
|
||||
select(VideoEngine.id)
|
||||
.where(VideoEngine.is_active == True, VideoEngine.deleted_at.is_(None))
|
||||
.where(VideoEngine.is_active.is_(True), VideoEngine.deleted_at.is_(None))
|
||||
.order_by(VideoEngine.priority.desc())
|
||||
)
|
||||
video_engine_ids = video_engines_result.scalars().all()
|
||||
|
||||
image_engines_result = await db.execute(
|
||||
image_result = await db.execute(
|
||||
select(ImageEngine.id)
|
||||
.where(ImageEngine.is_active == True, ImageEngine.deleted_at.is_(None))
|
||||
.where(ImageEngine.is_active.is_(True), ImageEngine.deleted_at.is_(None))
|
||||
.order_by(ImageEngine.priority.desc())
|
||||
)
|
||||
image_engine_ids = image_engines_result.scalars().all()
|
||||
|
||||
grouped = {}
|
||||
|
||||
video_ratios = await get_ratios_for_engine_type("video", video_engine_ids)
|
||||
video_ratios = await get_ratios_for_engine_type("video", list(video_result.scalars().all()))
|
||||
image_ratios = await get_ratios_for_engine_type("image", list(image_result.scalars().all()))
|
||||
if video_ratios:
|
||||
grouped["video"] = video_ratios
|
||||
|
||||
image_ratios = await get_ratios_for_engine_type("image", image_engine_ids)
|
||||
if image_ratios:
|
||||
grouped["image"] = image_ratios
|
||||
|
||||
return grouped
|
||||
|
||||
@@ -17,7 +17,6 @@ from app.enums.credit_record import (
|
||||
CreditRecordChargeKind,
|
||||
CreditRecordOwnerType,
|
||||
)
|
||||
from app.enums.llm_billing import LlmBillingConfigKey
|
||||
from app.enums.hot_opening_replicate import HotOpeningLogEventEnum, HotOpeningStepCodeEnum, ModuleCodeEnum
|
||||
from app.schemas.hot_opening_replicate import (
|
||||
HotOpeningActionOut,
|
||||
@@ -36,7 +35,6 @@ from app.schemas.hot_opening_replicate import (
|
||||
)
|
||||
from app.services.hot_opening_replicate_service import (
|
||||
_get_project_for_user,
|
||||
create_hot_opening_project,
|
||||
delete_hot_opening_project,
|
||||
generate_image_from_prompt,
|
||||
generate_video_from_prompt,
|
||||
@@ -66,8 +64,8 @@ from app.services.module_async_recovery_service import (
|
||||
remove_active_task,
|
||||
)
|
||||
from app.tasks.celery_app import celery_app
|
||||
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceSourceModelEnum, UploadResourceTypeEnum
|
||||
from app.services.upload_resource import upload_reference_file, bind_upload_resources, cleanup_upload_resource_files_after_commit
|
||||
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceTypeEnum
|
||||
from app.services.upload_resource import upload_reference_file, cleanup_upload_resource_files_after_commit
|
||||
from app.services.upload_resource.log_service import log_upload_resource_exception, safe_rollback_with_log
|
||||
|
||||
MODULE = ModuleCodeEnum.HOT_OPENING_REPLICATE.value
|
||||
@@ -181,12 +179,7 @@ def _prompt_dispatch_billing_context(
|
||||
source_step_id=step_id,
|
||||
source_step_code=step_code,
|
||||
related_id=step_id,
|
||||
hold_config_key=(
|
||||
LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value
|
||||
if is_image
|
||||
else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value
|
||||
),
|
||||
description_prefix=(
|
||||
description_prefix=(
|
||||
"爆款开头复刻图片AI提词优化"
|
||||
if is_image
|
||||
else "爆款开头复刻视频提词优化"
|
||||
|
||||
@@ -9,17 +9,17 @@ logger = logging.getLogger("payment")
|
||||
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.recharge_package import RechargePackage
|
||||
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 (
|
||||
create_recharge_order,
|
||||
verify_wechat_callback,
|
||||
verify_alipay_callback,
|
||||
process_payment_success_by_order_no,
|
||||
process_refund,
|
||||
_get_payment_configs,
|
||||
_close_alipay_order,
|
||||
_get_order_expire_seconds,
|
||||
PaymentCloseResult,
|
||||
_resolve_order_for_cancellation,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/payments", tags=["payments"])
|
||||
@@ -57,27 +57,23 @@ async def recharge(
|
||||
raise HTTPException(status_code=400, detail="该支付方式未启用")
|
||||
|
||||
result = await db.execute(
|
||||
select(RechargePackage).where(
|
||||
RechargePackage.id == req.plan,
|
||||
RechargePackage.is_active == True,
|
||||
)
|
||||
.limit(1)
|
||||
select(CreditProduct).where(
|
||||
CreditProduct.id == req.plan,
|
||||
CreditProduct.is_active.is_(True),
|
||||
).limit(1)
|
||||
)
|
||||
pkg = result.scalar_one_or_none()
|
||||
if not pkg:
|
||||
raise HTTPException(status_code=400, detail="无效的套餐")
|
||||
product = result.scalar_one_or_none()
|
||||
if not product:
|
||||
raise HTTPException(status_code=400, detail="无效或已下架的积分商品")
|
||||
try:
|
||||
order = await create_recharge_order(
|
||||
db,
|
||||
current_user.id,
|
||||
credits=pkg.credits,
|
||||
price=pkg.price,
|
||||
label=pkg.name,
|
||||
bonus_credits=pkg.bonus_credits,
|
||||
method=req.method,
|
||||
product_id=product.id,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return order
|
||||
|
||||
|
||||
@@ -86,11 +82,11 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
# 读取微信支付回调数据
|
||||
body_bytes = await request.body()
|
||||
body_str = body_bytes.decode("utf-8")
|
||||
|
||||
|
||||
# 获取配置
|
||||
from app.services.payment import _get_payment_configs, _is_mock_mode, _get_wechat_client
|
||||
db_configs = await _get_payment_configs(db)
|
||||
|
||||
|
||||
# 检查 mock 模式
|
||||
if _is_mock_mode(db_configs):
|
||||
try:
|
||||
@@ -104,29 +100,29 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
except Exception as e:
|
||||
logger.exception(f"Mock WeChat callback error: {e}")
|
||||
return {"code": "SUCCESS", "message": "OK"} # 微信要求即使处理失败也返回成功
|
||||
|
||||
|
||||
# 真实模式:使用 wechatpayv3 SDK 工具验证回调并解析数据
|
||||
try:
|
||||
from wechatpayv3.utils import (
|
||||
rsa_verify, load_public_key, sha256, b64decode,
|
||||
AESGCM, InvalidTag
|
||||
)
|
||||
|
||||
|
||||
mch_id = db_configs.get("payment_wechat_mch_id", "")
|
||||
api_v3_key = db_configs.get("payment_wechat_api_v3_key", "")
|
||||
public_key = db_configs.get("payment_wechat_public_key", "")
|
||||
|
||||
|
||||
if not all([mch_id, api_v3_key]):
|
||||
logger.error("WeChat payment config missing for callback")
|
||||
return {"code": "SUCCESS", "message": "OK"}
|
||||
|
||||
|
||||
# 从请求头获取必要信息(不区分大小写)
|
||||
headers = {k.lower(): v for k, v in dict(request.headers).items()}
|
||||
timestamp = headers.get("wechatpay-timestamp", "")
|
||||
nonce = headers.get("wechatpay-nonce", "")
|
||||
signature = headers.get("wechatpay-signature", "")
|
||||
serial_no = headers.get("wechatpay-serial", "")
|
||||
|
||||
|
||||
# 安全要求:非mock模式下必须验证签名,配置缺失直接拒绝
|
||||
if not public_key:
|
||||
logger.error("WeChat platform public key not configured, cannot verify callback signature")
|
||||
@@ -137,7 +133,7 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
if not timestamp or not nonce or not signature:
|
||||
logger.error("WeChat callback missing required signature headers")
|
||||
return {"code": "FAIL", "message": "Missing signature headers"}
|
||||
|
||||
|
||||
# 验证签名:使用平台公钥验证
|
||||
try:
|
||||
is_verified = rsa_verify(
|
||||
@@ -153,27 +149,27 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
except Exception as e:
|
||||
logger.warning(f"WeChat signature verification error: {e}, serial={serial_no}")
|
||||
return {"code": "FAIL", "message": "Signature verification error"}
|
||||
|
||||
|
||||
# 解密回调数据:使用 API v3 key
|
||||
# 官方文档:https://pay.weixin.qq.com/doc/v3/merchant/4012071382
|
||||
import json
|
||||
body_data = json.loads(body_str) if body_str else {}
|
||||
resource = body_data.get("resource", {})
|
||||
|
||||
|
||||
if not resource:
|
||||
logger.error("WeChat callback resource not found")
|
||||
raise HTTPException(status_code=400, detail="数据格式错误")
|
||||
|
||||
|
||||
# 验证加密算法(官方文档要求固定为 AEAD_AES_256_GCM)
|
||||
algorithm = resource.get("algorithm", "")
|
||||
if algorithm != "AEAD_AES_256_GCM":
|
||||
logger.error(f"WeChat callback unsupported algorithm: {algorithm}")
|
||||
raise HTTPException(status_code=400, detail="不支持的加密算法")
|
||||
|
||||
|
||||
ciphertext = resource.get("ciphertext", "")
|
||||
associated_data = resource.get("associated_data", "")
|
||||
nonce_str = resource.get("nonce", "")
|
||||
|
||||
|
||||
# 参数验证
|
||||
if not ciphertext:
|
||||
logger.error("WeChat callback ciphertext is empty")
|
||||
@@ -181,22 +177,22 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
if not nonce_str:
|
||||
logger.error("WeChat callback nonce is empty")
|
||||
raise HTTPException(status_code=400, detail="随机数为空")
|
||||
|
||||
|
||||
# 使用 AES-GCM 解密(符合官方文档规范)
|
||||
# 官方文档:https://pay.weixin.qq.com/doc/v3/merchant/4012071382
|
||||
try:
|
||||
# API v3 key 需要转换为字节串
|
||||
api_v3_key_bytes = api_v3_key.encode('utf-8')
|
||||
|
||||
|
||||
# ciphertext 是 Base64 编码的,需要解码
|
||||
ciphertext_bytes = b64decode(ciphertext)
|
||||
|
||||
|
||||
# nonce 直接使用字符串编码(官方文档方式)
|
||||
nonce_bytes = nonce_str.encode('utf-8')
|
||||
|
||||
|
||||
# associated_data 是字符串,直接编码
|
||||
associated_data_bytes = associated_data.encode('utf-8') if associated_data else b''
|
||||
|
||||
|
||||
aesgcm = AESGCM(api_v3_key_bytes)
|
||||
decrypted_str = aesgcm.decrypt(nonce_bytes, ciphertext_bytes, associated_data_bytes)
|
||||
except InvalidTag:
|
||||
@@ -205,53 +201,72 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
except Exception as e:
|
||||
logger.error(f"WeChat callback decryption failed: {e}")
|
||||
raise HTTPException(status_code=400, detail="数据解密失败")
|
||||
|
||||
|
||||
if not decrypted_str:
|
||||
logger.error("WeChat callback decryption returned empty")
|
||||
raise HTTPException(status_code=400, detail="数据解密失败")
|
||||
|
||||
|
||||
decrypted_data = json.loads(decrypted_str)
|
||||
|
||||
|
||||
event_type = body_data.get("event_type", "")
|
||||
|
||||
|
||||
# 处理支付成功回调
|
||||
if event_type == "TRANSACTION.SUCCESS":
|
||||
order_no = decrypted_data.get("out_trade_no", "")
|
||||
transaction_id = decrypted_data.get("transaction_id", "")
|
||||
amount_info = decrypted_data.get("amount", {})
|
||||
total_amount = amount_info.get("total", 0) / 100 # 转换为元
|
||||
|
||||
|
||||
if order_no:
|
||||
await process_payment_success_by_order_no(db, order_no, transaction_id, total_amount)
|
||||
logger.info(
|
||||
f"WeChat callback processed: order_no={order_no}, "
|
||||
f"transaction_id={transaction_id}, amount={total_amount}"
|
||||
)
|
||||
|
||||
try:
|
||||
processed = await process_payment_success_by_order_no(
|
||||
db, order_no, transaction_id, total_amount
|
||||
)
|
||||
if processed:
|
||||
logger.info(
|
||||
f"WeChat callback processed: order_no={order_no}, "
|
||||
f"transaction_id={transaction_id}, amount={total_amount}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"WeChat payment success not fulfilled locally: order_no={order_no}, "
|
||||
f"transaction_id={transaction_id}, amount={total_amount}"
|
||||
)
|
||||
except Exception as e:
|
||||
# 微信已经通过验签、解密并明确通知支付成功。
|
||||
# 本地履约异常属于项目内部故障:回滚本地事务,但仍向微信返回 SUCCESS,
|
||||
# 后续由 pending 主动查单重试或日志人工对账处理。
|
||||
await db.rollback()
|
||||
logger.exception(
|
||||
f"WeChat payment fulfillment error: order_no={order_no}, "
|
||||
f"transaction_id={transaction_id}, error={e}"
|
||||
)
|
||||
return {"code": "SUCCESS", "message": "OK"}
|
||||
|
||||
# 处理退款回调
|
||||
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", "")
|
||||
|
||||
|
||||
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))
|
||||
order = result.scalar_one_or_none()
|
||||
|
||||
|
||||
if order and order.status == "refunding":
|
||||
order.status = "refunded"
|
||||
order.transaction_id = refund_id
|
||||
await db.commit()
|
||||
|
||||
|
||||
logger.info(
|
||||
f"WeChat refund callback processed: order_no={order_no}, "
|
||||
f"refund_id={refund_id}, status={refund_status}"
|
||||
)
|
||||
|
||||
|
||||
return {"code": "SUCCESS", "message": "OK"}
|
||||
except Exception as e:
|
||||
logger.exception(f"WeChat callback processing error: {e}")
|
||||
@@ -263,7 +278,7 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
async def alipay_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
form_data = await request.form()
|
||||
data = dict(form_data)
|
||||
|
||||
|
||||
logger.info(
|
||||
f"ALIPAY_CALLBACK order_no={data.get('out_trade_no')} "
|
||||
f"data={data}"
|
||||
@@ -283,7 +298,7 @@ async def alipay_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
trade_no = data.get("trade_no", "")
|
||||
total_amount_str = data.get("total_amount", "")
|
||||
total_amount = float(total_amount_str) if total_amount_str else None
|
||||
|
||||
|
||||
if order_no:
|
||||
await process_payment_success_by_order_no(db, order_no, trade_no, total_amount)
|
||||
|
||||
@@ -399,29 +414,45 @@ async def cancel_order(
|
||||
select(PaymentOrder).where(
|
||||
PaymentOrder.order_no == order_no,
|
||||
PaymentOrder.user_id == current_user.id,
|
||||
).limit(1)
|
||||
).with_for_update().limit(1)
|
||||
)
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="订单不存在")
|
||||
if order.status != "pending":
|
||||
raise HTTPException(status_code=400, detail=f"订单状态为{order.status},无法取消")
|
||||
|
||||
# If it's an Alipay or WeChat order, call close API first
|
||||
|
||||
# Resolve the authoritative gateway state before local cancellation.
|
||||
# If WeChat explicitly reports ORDERPAID, the payment service queries only
|
||||
# this order once and runs the normal atomic payment-success fulfillment.
|
||||
db_configs = await _get_payment_configs(db)
|
||||
if order.payment_method == "alipay":
|
||||
try:
|
||||
await _close_alipay_order(db, order, db_configs)
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to close Alipay order {order_no}: {e}")
|
||||
elif order.payment_method == "wechat":
|
||||
try:
|
||||
from app.services.payment import _close_wechat_order
|
||||
await _close_wechat_order(db, order, db_configs)
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to close WeChat order {order_no}: {e}")
|
||||
|
||||
log_user_id = str(current_user.id)
|
||||
payment_method = str(order.payment_method)
|
||||
close_result = await _resolve_order_for_cancellation(db, order, db_configs)
|
||||
|
||||
if close_result == PaymentCloseResult.PAID:
|
||||
logger.info(
|
||||
f"ORDER_CANCEL_PAID_RECOVERED order_no={order_no} "
|
||||
f"user={log_user_id} method={payment_method}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="订单已支付并完成支付处理,无法取消",
|
||||
)
|
||||
|
||||
if close_result != PaymentCloseResult.CLOSED:
|
||||
logger.warning(
|
||||
f"ORDER_CANCEL_CLOSE_PENDING order_no={order_no} "
|
||||
f"user={log_user_id} method={payment_method}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="支付渠道暂未确认订单关闭,请稍后重试",
|
||||
)
|
||||
|
||||
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}"
|
||||
|
||||
@@ -1,39 +1,41 @@
|
||||
"""Legacy endpoint retained for old clients; only active credit add-ons are returned."""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db, get_current_user
|
||||
from app.dependencies import get_current_user, get_db
|
||||
from app.enums.credit_product import CreditProductType
|
||||
from app.models.credit.product import CreditProduct
|
||||
from app.models.user import User
|
||||
from app.models.recharge_package import RechargePackage
|
||||
from app.services.credit.product_service import product_to_dict
|
||||
|
||||
router = APIRouter(tags=["recharge-packages"])
|
||||
|
||||
|
||||
def _to_out(pkg: RechargePackage) -> dict:
|
||||
return {
|
||||
"id": pkg.id,
|
||||
"name": pkg.name,
|
||||
"credits": round(pkg.credits, 2),
|
||||
"price": round(pkg.price, 2),
|
||||
"bonus_credits": round(pkg.bonus_credits, 2),
|
||||
"total_credits": round(pkg.credits + pkg.bonus_credits, 2),
|
||||
"description": pkg.description,
|
||||
"package_type": pkg.package_type,
|
||||
"is_gift": pkg.is_gift,
|
||||
"is_active": pkg.is_active,
|
||||
"sort_order": pkg.sort_order,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/recharge-packages")
|
||||
async def list_active_packages(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Public: list active recharge packages."""
|
||||
result = await db.execute(
|
||||
select(RechargePackage)
|
||||
.where(RechargePackage.is_active == True)
|
||||
.order_by(RechargePackage.sort_order)
|
||||
select(CreditProduct)
|
||||
.where(
|
||||
CreditProduct.product_type == CreditProductType.CREDIT_ADDON.value,
|
||||
CreditProduct.is_active.is_(True),
|
||||
)
|
||||
.order_by(CreditProduct.sort_order.asc(), CreditProduct.id.asc())
|
||||
)
|
||||
return [_to_out(p) for p in result.scalars().all()]
|
||||
items = []
|
||||
for product in result.scalars().all():
|
||||
payload = product_to_dict(product, user_price=product.price, price_type="regular", can_purchase=True)
|
||||
payload.update(
|
||||
{
|
||||
"credits": payload["grant_credits"],
|
||||
"bonus_credits": 0.0,
|
||||
"total_credits": payload["grant_credits"],
|
||||
"package_type": "credit_addon",
|
||||
"is_gift": False,
|
||||
}
|
||||
)
|
||||
items.append(payload)
|
||||
return items
|
||||
|
||||
@@ -20,7 +20,6 @@ from app.enums.credit_record import (
|
||||
CreditRecordOwnerType,
|
||||
CreditRecordSourceStepCode,
|
||||
)
|
||||
from app.enums.llm_billing import LlmBillingConfigKey
|
||||
from app.enums.shot_replicate import (
|
||||
ModuleCodeEnum,
|
||||
ShotAnalysisStatusEnum,
|
||||
@@ -235,12 +234,7 @@ def _prompt_dispatch_billing_context(
|
||||
source_step_id=step_id,
|
||||
source_step_code=step_code,
|
||||
related_id=step_id,
|
||||
hold_config_key=(
|
||||
LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value
|
||||
if is_image
|
||||
else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value
|
||||
),
|
||||
description_prefix=(
|
||||
description_prefix=(
|
||||
"拆镜复刻图片AI提词优化" if is_image else "拆镜复刻视频提词优化"
|
||||
),
|
||||
trace_id=f"shot-replicate-prompt:{step_id}:attempt:{attempt_no}",
|
||||
@@ -277,8 +271,7 @@ def _analysis_dispatch_billing_context(
|
||||
source_step_id=owner_id,
|
||||
source_step_code=CreditRecordSourceStepCode.VIDEO_ANALYSIS.value,
|
||||
related_id=owner_id,
|
||||
hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
|
||||
description_prefix=(
|
||||
description_prefix=(
|
||||
"拆镜复刻片段视频AI分析" if is_segment else "拆镜复刻原视频AI分析"
|
||||
),
|
||||
trace_id=f"shot-analysis:{owner_id}:attempt:{attempt_no}",
|
||||
@@ -539,7 +532,7 @@ async def create_shot_task_set(
|
||||
task_set, created_new = await create_task_set(db, current_user=current_user, req=req)
|
||||
task_set_id = str(task_set.id)
|
||||
if not created_new:
|
||||
# 幂等重复请求不重复预扣和投递;已有 pending 任务由原投递或恢复任务继续处理。
|
||||
# 幂等重复请求不重复消费积分和投递;已有 pending 任务由原投递或恢复任务继续处理。
|
||||
await db.rollback()
|
||||
return await task_set_detail(db, current_user=_user_context(current_user), task_set_id=task_set_id)
|
||||
analysis_attempt_no = max(1, int(task_set.analysis_attempt_no or 1))
|
||||
|
||||
@@ -9,10 +9,8 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from app.config import settings
|
||||
from app.dependencies import get_current_user, get_db, get_optional_current_user
|
||||
from app.models.team import Team
|
||||
from app.models.team_invitation import TeamInvitation
|
||||
from app.models.team_join_request import TeamJoinRequest
|
||||
from app.models.user import User
|
||||
from app.schemas.team_invitation import TeamInvitationCreate, TeamInvitationOut
|
||||
@@ -23,17 +21,12 @@ from app.schemas.team_join_request import (
|
||||
JoinTeamInfoOut,
|
||||
)
|
||||
from app.schemas.team_manager import (
|
||||
ManagedTeamOut,
|
||||
ManagerTransferRequest,
|
||||
SetManagerRequest,
|
||||
TeamMemberOut,
|
||||
)
|
||||
from app.services import team_invitation_service
|
||||
from app.services.team_manager_service import (
|
||||
get_managed_team,
|
||||
get_team_members,
|
||||
is_team_manager,
|
||||
transfer_credits_to_member,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/team", tags=["team"])
|
||||
@@ -92,15 +85,7 @@ async def transfer_credits(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await transfer_credits_to_member(
|
||||
db,
|
||||
current_user.id,
|
||||
req.target_user_id,
|
||||
req.amount,
|
||||
req.direction or "increase",
|
||||
req.description,
|
||||
)
|
||||
return {"message": "ok"}
|
||||
raise HTTPException(status_code=409, detail="当前版本积分暂未开放团队转账功能")
|
||||
|
||||
|
||||
# ── 邀请码管理 ────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user