You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

672 lines
23 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import random
from datetime import datetime, timedelta
from sqlalchemy import select
from app.core.base_schema import AuthSchema
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.utils.payment import create_payment_gateway
from .crud import OrderCRUD, PaymentRecordCRUD, RefundCRUD
from .model import OrderModel
from .schema import (
OrderCreateInternalSchema,
OrderCreateSchema,
OrderOutSchema,
OrderQueryParam,
OrderStatusMessage,
OrderUpdateInternalSchema,
PaymentCreateOut,
PaymentRecordCreateSchema,
PaymentRecordOutSchema,
PaymentStatusOut,
RefundApplySchema,
RefundCreateSchema,
RefundOutSchema,
RefundReviewSchema,
RefundUpdateSchema,
)
def _generate_order_no() -> str:
"""生成订单号YYYYMMDD + 6位随机数"""
today = datetime.now().strftime("%Y%m%d")
suffix = str(random.randint(100000, 999999))
return f"{today}{suffix}"
def _generate_refund_no() -> str:
"""生成退款单号"""
today = datetime.now().strftime("%Y%m%d")
suffix = str(random.randint(100000, 999999))
return f"RF{today}{suffix}"
class OrderService:
"""
订单管理服务
"""
@classmethod
async def create_order(cls, auth: AuthSchema, data: OrderCreateSchema, amount: int | None = None) -> OrderOutSchema:
"""
创建订单
套餐订单amount 从套餐价格自动计算
插件订单amount 从插件价格自动计算
免费订单amount=0自动激活
参数:
- auth (AuthSchema): 认证信息模型
- data (OrderCreateSchema): 订单创建模型
- amount (int | None): 订单金额(分)None 时自动计算
返回:
- OrderOutSchema: 新创建的订单详情
"""
if amount is None:
if data.order_type == "plugin":
from app.api.v1.module_platform.plugin.model import PluginModel
plugin = await auth.db.get(PluginModel, data.plugin_id)
amount = plugin.price if plugin else 0
else:
from app.api.v1.module_platform.package.model import PackageModel
pkg = await auth.db.get(PackageModel, data.package_id)
amount = pkg.price if pkg else 0
order = await OrderCRUD(auth).create(
OrderCreateInternalSchema(
order_no=_generate_order_no(),
tenant_id=data.tenant_id,
package_id=data.package_id,
plugin_id=data.plugin_id,
order_type=data.order_type,
amount=amount,
expire_time=datetime.now() + timedelta(minutes=15),
)
)
# 免费订单自动激活
if amount == 0:
await OrderCRUD(auth).update(
order.id,
OrderUpdateInternalSchema(status=1, pay_method="free", pay_time=datetime.now()),
)
await PaymentService._activate_tenant_package(auth, order)
await auth.db.refresh(order)
return OrderOutSchema.model_validate(order)
@classmethod
async def get_detail(cls, auth: AuthSchema, order_id: int) -> OrderOutSchema | None:
"""
订单详情
参数:
- auth (AuthSchema): 认证信息模型
- order_id (int): 订单ID
返回:
- OrderOutSchema | None: 订单详情,不存在时返回 None
"""
order = await OrderCRUD(auth).get_by_id(order_id)
return OrderOutSchema.model_validate(order) if order else None
@classmethod
async def get_list(
cls,
auth: AuthSchema,
page_no: int,
page_size: int,
search: OrderQueryParam,
order_by: list[dict[str, str]] | None = None,
) -> tuple[list, int]:
"""
订单列表
参数:
- auth (AuthSchema): 认证信息模型
- page_no (int): 当前页码
- page_size (int): 每页数量
- search (OrderQueryParam): 查询参数
- order_by (list[dict] | None): 排序字段
返回:
- tuple[list, int]: (订单列表, 总数)
"""
offset = (page_no - 1) * page_size
rows, total = await OrderCRUD(auth).query(
tenant_id=search.tenant_id,
status=search.status,
order_type=search.order_type,
offset=offset,
limit=page_size,
)
items = [OrderOutSchema.model_validate(r) for r in rows]
return items, total
@classmethod
async def cancel_order(cls, auth: AuthSchema, order_id: int) -> OrderStatusMessage:
"""
取消订单
参数:
- auth (AuthSchema): 认证信息模型
- order_id (int): 订单ID
返回:
- OrderStatusMessage: 取消结果
"""
crud = OrderCRUD(auth)
order = await crud.get_by_id(order_id)
if not order:
raise CustomException(msg="该数据不存在")
if order.status != 0:
raise CustomException(msg="仅待支付订单可取消")
await crud.update(order_id, OrderUpdateInternalSchema(status=2))
return OrderStatusMessage(id=order.id, status=2, message="已取消")
@classmethod
async def check_payment_status(cls, auth: AuthSchema, order_id: int) -> PaymentStatusOut:
"""
查询订单支付状态(供前端轮询用)
参数:
- auth (AuthSchema): 认证信息模型
- order_id (int): 订单ID
返回:
- PaymentStatusOut: 支付状态信息
"""
order = await OrderCRUD(auth).get_by_id(order_id)
if not order:
return PaymentStatusOut(exists=False)
return PaymentStatusOut(
exists=True,
order_id=order.id,
status=order.status,
paid=order.status == 1,
pay_method=order.pay_method,
pay_time=order.pay_time.isoformat() if order.pay_time else None,
)
@staticmethod
async def cancel_expired_orders() -> None:
from sqlalchemy import update as sa_update
from app.core.database import async_db_session
now = datetime.now()
async with async_db_session() as session:
async with session.begin():
result = await session.execute(
sa_update(OrderModel)
.where(OrderModel.status == 0)
.where(OrderModel.expire_time < now)
.where(OrderModel.is_deleted == False) # noqa: E712
.values(status=2)
)
logger.info(f"超时订单取消: 已取消 {result.rowcount} 条订单")
class PaymentService:
"""
支付管理服务
"""
@classmethod
async def create_payment(cls, auth: AuthSchema, order_id: int, method: str, notify_base_url: str) -> PaymentCreateOut:
"""
创建支付(调用支付网关)
参数:
- auth (AuthSchema): 认证信息模型
- order_id (int): 订单ID
- method (str): 支付方式(alipay/wxpay)
- notify_base_url (str): 回调基础URL
返回:
- PaymentCreateOut: 支付创建结果支付URL/二维码)
"""
from app.api.v1.module_platform.package.model import PackageModel
order = await OrderCRUD(auth).get_by_id(order_id)
if not order:
raise CustomException(msg="该数据不存在")
if order.status != 0:
raise CustomException(msg="订单状态异常,无法支付")
if order.amount <= 0:
raise CustomException(msg="免费订单无需支付")
if order.order_type == "plugin":
from app.api.v1.module_platform.plugin.model import PluginModel
plugin = await auth.db.get(PluginModel, order.plugin_id)
subject = f"FastapiAdmin - 插件 {plugin.name}" if plugin else "FastapiAdmin 插件"
else:
pkg = await auth.db.get(PackageModel, order.package_id)
subject = f"FastapiAdmin - {pkg.name}" if pkg else "FastapiAdmin 套餐"
notify_url = f"{notify_base_url}/api/v1/platform/payment/callback/{method}" if method else ""
gateway = create_payment_gateway(method)
info = await gateway.create_payment(
order_no=order.order_no,
amount=order.amount,
subject=subject,
notify_url=notify_url,
)
return PaymentCreateOut(
pay_url=info.pay_url,
qr_code_url=info.qr_code_url,
trade_no=info.trade_no,
order_id=order.id,
order_no=order.order_no,
amount=order.amount,
)
@classmethod
async def handle_callback(cls, auth: AuthSchema, method: str, callback_data: dict) -> dict:
"""
处理支付回调
参数:
- auth (AuthSchema): 认证信息模型
- method (str): 支付方式
- callback_data (dict): 支付网关回调数据
返回:
- dict: 处理结果
"""
gateway = create_payment_gateway(method)
callback_result = await gateway.verify_callback(callback_data)
if not callback_result.verified:
logger.warning(f"支付回调验签失败: method={method} data={callback_data}")
raise CustomException(msg="支付回调验签失败")
order_no = callback_data.get("order_no") or callback_data.get("out_trade_no", "")
o_crud = OrderCRUD(auth)
order = None
if order_no:
order = await o_crud.get_by_order_no(order_no)
elif callback_result.order_id:
order = await o_crud.get_by_id(callback_result.order_id)
if not order:
raise CustomException(msg="该数据不存在")
if order.status != 0:
raise CustomException(msg="订单状态异常")
if order.amount != callback_result.amount and callback_result.amount > 0:
raise CustomException(msg="金额不一致")
pid = order.package_id
tid = order.tenant_id
otype = order.order_type
oid = order.id
await o_crud.update(
oid,
OrderUpdateInternalSchema(status=1, pay_method=method, pay_time=datetime.now()),
)
await PaymentRecordCRUD(auth).create(
PaymentRecordCreateSchema(
order_id=oid,
transaction_id=callback_result.transaction_id,
pay_method=method,
amount=callback_result.amount,
raw_response=str(callback_result.raw) if callback_result.raw else None,
pay_time=datetime.now(),
)
)
order.package_id = pid
order.tenant_id = tid
order.order_type = otype
await PaymentService._activate_tenant_package(auth, order)
logger.info(f"支付回调处理完成: order_id={oid} method={method} tenant_id={tid} type={otype}")
return {"order_id": oid, "status": 1, "message": "支付成功"}
@classmethod
async def _activate_tenant_package(cls, auth: AuthSchema, order: OrderModel) -> None:
"""
支付成功后激活套餐
参数:
- auth (AuthSchema): 认证信息模型
- order (OrderModel): 订单模型
返回:
- None
"""
from app.api.v1.module_platform.package.model import PackageModel
from app.api.v1.module_platform.tenant.model import TenantModel
if order.order_type == "plugin":
await PaymentService._activate_plugin(auth, order)
return
pkg = await auth.db.get(PackageModel, order.package_id)
if not pkg:
logger.warning(f"支付回调:套餐 {order.package_id} 不存在,跳过激活")
return
tenant = await auth.db.get(TenantModel, order.tenant_id)
if not tenant:
logger.warning(f"支付回调:租户 {order.tenant_id} 不存在,跳过激活")
return
now = datetime.now()
period_months = order.period_count or 1
duration = timedelta(days=30 * period_months)
if order.order_type == "new":
tenant.package_id = order.package_id
tenant.start_time = now
tenant.end_time = now + duration
tenant.status = 0
logger.info(f"租户[{tenant.name}]新开通 {pkg.name},有效期至 {tenant.end_time}")
elif order.order_type == "renew":
base = tenant.end_time if tenant.end_time and tenant.end_time > now else now
tenant.end_time = base + duration
tenant.status = 0
logger.info(f"租户[{tenant.name}]续费 {pkg.name},续至 {tenant.end_time}")
elif order.order_type in ("upgrade", "downgrade"):
if order.order_type == "downgrade":
await PaymentService._check_downgrade_quota(auth, order.tenant_id, pkg)
tenant.package_id = order.package_id
tenant.status = 0
logger.info(f"租户[{tenant.name}]套餐变更 {'升级' if order.order_type == 'upgrade' else '降级'}{pkg.name}")
await auth.db.flush()
if tenant.contact_email:
await PaymentService._send_order_email(auth, order, pkg, tenant)
@classmethod
async def _activate_plugin(cls, auth: AuthSchema, order: OrderModel) -> None:
"""
支付成功后标记插件为已购买
参数:
- auth (AuthSchema): 认证信息模型
- order (OrderModel): 订单模型
返回:
- None
"""
from app.api.v1.module_platform.plugin.model import PluginModel, TenantPluginModel
plugin = await auth.db.get(PluginModel, order.plugin_id)
if not plugin:
logger.warning(f"支付回调:插件 {order.plugin_id} 不存在")
return
result = await auth.db.execute(
select(TenantPluginModel)
.where(
TenantPluginModel.tenant_id == order.tenant_id,
TenantPluginModel.plugin_id == order.plugin_id,
)
.limit(1)
)
existing = result.scalar_one_or_none()
if existing:
existing.purchased = "1"
else:
tp = TenantPluginModel(
tenant_id=order.tenant_id,
plugin_id=order.plugin_id,
purchased="1",
enabled="0",
installed_time=datetime.now(),
)
auth.db.add(tp)
await auth.db.flush()
logger.info(f"租户[{order.tenant_id}]已购买插件[{plugin.name}]")
from app.api.v1.module_platform.tenant.model import TenantModel
tenant = await auth.db.get(TenantModel, order.tenant_id)
if tenant and tenant.contact_email:
await PaymentService._send_order_email(auth, order, plugin, tenant, order_type_label="购买")
@classmethod
async def _check_downgrade_quota(cls, auth: AuthSchema, tenant_id: int, new_pkg: object) -> None:
"""
降级前检查:租户当前资源数是否超过新套餐限额
参数:
- auth (AuthSchema): 认证信息模型
- tenant_id (int): 租户ID
- new_pkg (object): 目标套餐
返回:
- None
"""
from sqlalchemy import func, select
from app.api.v1.module_system.dept.model import DeptModel
from app.api.v1.module_system.role.model import RoleModel
from app.api.v1.module_system.user.model import UserModel
checks = {
"用户": (UserModel, new_pkg.max_users),
"角色": (RoleModel, new_pkg.max_roles),
"部门": (DeptModel, new_pkg.max_depts),
}
for label, (model, limit) in checks.items():
if limit <= 0:
continue
count_stmt = (
select(func.count())
.select_from(model)
.where(
model.tenant_id == tenant_id,
model.is_deleted.is_(False),
)
)
result = await auth.db.execute(count_stmt)
current = result.scalar() or 0
if current > limit:
raise CustomException(msg=f"降级失败:当前租户已有 {current}{label},超过目标套餐限额 {limit}")
@classmethod
async def _send_order_email(cls, auth: AuthSchema, order: OrderModel, product: object, tenant: object, order_type_label: str = "") -> None:
"""
发送购买确认邮件(失败静默降级)
参数:
- order (OrderModel): 订单模型
- product (object): 商品(套餐/插件)
- tenant (object): 租户模型
- order_type_label (str): 订单类型文案
返回:
- None
"""
try:
from app.api.v1.module_platform.email.service import EmailSendService
if not order_type_label:
order_type_labels = {
"new": "开通",
"renew": "续费",
"upgrade": "升级",
"downgrade": "降级",
"plugin": "购买",
}
order_type_label = order_type_labels.get(order.order_type, order.order_type)
product_name = getattr(product, "name", "") if product else ""
amount_str = f"{order.amount / 100:.2f}" if order.amount else "0.00"
await EmailSendService(auth).send_by_template(
to_email=tenant.contact_email,
to_name=tenant.contact_name or tenant.name,
template_code="order_confirmation",
variables={
"tenant_name": tenant.name,
"order_no": order.order_no,
"package_name": product_name,
"order_type": order_type_label,
"amount": amount_str,
},
biz_type="order",
tenant_id=tenant.id,
)
except Exception:
pass # 邮件发送失败不阻塞业务流程
@classmethod
async def get_records(cls, auth: AuthSchema, offset: int, limit: int) -> tuple[list, int]:
"""
支付记录列表
参数:
- auth (AuthSchema): 认证信息模型
- offset (int): 偏移量
- limit (int): 每页数量
返回:
- tuple[list, int]: (支付记录列表, 总数)
"""
rows, total = await PaymentRecordCRUD(auth).query(offset, limit)
items = [PaymentRecordOutSchema.model_validate(r) for r in rows]
return items, total
class RefundService:
"""
退款管理服务
"""
@classmethod
async def apply(cls, auth: AuthSchema, data: RefundApplySchema, order_id: int) -> RefundOutSchema:
"""
申请退款
参数:
- auth (AuthSchema): 认证信息模型
- data (RefundApplySchema): 退款申请模型
- order_id (int): 订单ID
返回:
- RefundOutSchema: 退款记录详情
"""
o_crud = OrderCRUD(auth)
order = await o_crud.get_by_id(order_id)
if not order:
raise CustomException(msg="该数据不存在")
if order.status != 1:
raise CustomException(msg="仅已支付订单可退款")
if order.amount == 0:
raise CustomException(msg="免费套餐不支持退款")
if order.pay_time and (datetime.now() - order.pay_time).days > 7:
raise CustomException(msg="已超过 7 天退款时限")
existing = await RefundCRUD(auth).get_by_order_id(order_id)
if existing:
raise CustomException(msg="已存在退款申请")
refund = await RefundCRUD(auth).create(
RefundCreateSchema(
order_id=order.id,
refund_no=_generate_refund_no(),
amount=order.amount,
reason=data.reason,
)
)
return RefundOutSchema.model_validate(refund)
@classmethod
async def get_list(cls, auth: AuthSchema, status: int | None, offset: int, limit: int) -> tuple[list, int]:
"""
退款列表
参数:
- auth (AuthSchema): 认证信息模型
- status (int | None): 退款状态筛选
- offset (int): 偏移量
- limit (int): 每页数量
返回:
- tuple[list, int]: (退款列表, 总数)
"""
rows, total = await RefundCRUD(auth).query(status, offset, limit)
items = [RefundOutSchema.model_validate(r) for r in rows]
return items, total
@classmethod
async def approve(cls, auth: AuthSchema, refund_id: int, reviewer_id: int, operator_name: str = "") -> OrderStatusMessage:
"""
批准退款
参数:
- auth (AuthSchema): 认证信息模型
- refund_id (int): 退款ID
- reviewer_id (int): 审核人ID
- operator_name (str): 操作人名称
返回:
- OrderStatusMessage: 审核结果
"""
crud = RefundCRUD(auth)
refund = await crud.get_by_id(refund_id)
if not refund:
raise CustomException(msg="该数据不存在")
if refund.status != 1:
raise CustomException(msg="仅申请中可审核")
await crud.update(
refund_id,
RefundUpdateSchema(status=2, reviewer_id=reviewer_id, review_time=datetime.now()),
)
await OrderCRUD(auth).mark_refunded(refund.order_id)
return OrderStatusMessage(id=refund.id, status=2, message="已批准退款")
@classmethod
async def reject(
cls,
auth: AuthSchema,
refund_id: int,
reviewer_id: int,
data: RefundReviewSchema,
operator_name: str = "",
) -> OrderStatusMessage:
"""
驳回退款
参数:
- auth (AuthSchema): 认证信息模型
- refund_id (int): 退款ID
- reviewer_id (int): 审核人ID
- data (RefundReviewSchema): 驳回原因
- operator_name (str): 操作人名称
返回:
- OrderStatusMessage: 审核结果
"""
crud = RefundCRUD(auth)
refund = await crud.get_by_id(refund_id)
if not refund:
raise CustomException(msg="该数据不存在")
if refund.status != 1:
raise CustomException(msg="仅申请中可审核")
await crud.update(
refund_id,
RefundUpdateSchema(
status=3,
reviewer_id=reviewer_id,
review_time=datetime.now(),
reject_reason=data.reject_reason,
),
)
return OrderStatusMessage(id=refund.id, status=3, message="已驳回")