This commit is contained in:
2026-08-13 09:38:36 +08:00
124 changed files with 11236 additions and 5549 deletions
+4
View File
@@ -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}
+148
View File
@@ -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}
+10 -134
View File
@@ -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()]
+2
View File
@@ -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)
+38 -13
View File
@@ -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(
+39 -30
View File
@@ -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)
+76 -21
View File
@@ -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 "爆款开头复刻视频提词优化"
+101 -70
View File
@@ -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}"
+25 -23
View File
@@ -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
+3 -10
View File
@@ -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))
+1 -16
View File
@@ -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="当前版本积分暂未开放团队转账功能")
# ── 邀请码管理 ────────────────────────────────────────