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.
524 lines
18 KiB
Python
524 lines
18 KiB
Python
|
|
from datetime import datetime, timedelta
|
|
|
|
from sqlalchemy import func, select
|
|
|
|
from app.api.v1.module_platform.menu.model import MenuModel
|
|
from app.api.v1.module_platform.order.crud import OrderCRUD
|
|
from app.api.v1.module_platform.order.model import OrderModel
|
|
from app.api.v1.module_platform.order.schema import (
|
|
OrderCreateInternalSchema,
|
|
OrderCreateSchema,
|
|
OrderUpdateInternalSchema,
|
|
)
|
|
from app.api.v1.module_platform.order.service import (
|
|
OrderService,
|
|
PaymentService,
|
|
_generate_order_no,
|
|
)
|
|
from app.api.v1.module_platform.package.model import PackageMenuModel, PackageModel
|
|
from app.api.v1.module_platform.plugin.model import PluginModel, TenantPluginModel
|
|
from app.api.v1.module_platform.tenant.model import TenantModel
|
|
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
|
|
from app.core.base_schema import AuthSchema
|
|
from app.core.exceptions import CustomException
|
|
from app.core.logger import logger
|
|
|
|
from .schema import (
|
|
PackageAvailableItem,
|
|
PackageAvailableOut,
|
|
PackagePreviewOut,
|
|
PluginPurchaseCreate,
|
|
SelfOrderCreate,
|
|
SelfOrderDetailOut,
|
|
SelfOrderListItem,
|
|
SelfOrderListOut,
|
|
SelfOrderOut,
|
|
WorkspaceOrderItem,
|
|
WorkspaceOut,
|
|
WorkspacePackageInfo,
|
|
WorkspaceQuotaInfo,
|
|
WorkspaceTenantInfo,
|
|
WorkspaceUsagePercent,
|
|
)
|
|
|
|
|
|
class SelfService:
|
|
"""
|
|
租户自助服务
|
|
"""
|
|
|
|
@classmethod
|
|
async def get_available_packages(cls, auth: AuthSchema, tenant_id: int) -> PackageAvailableOut:
|
|
"""
|
|
获取可选套餐列表
|
|
|
|
参数:
|
|
- auth (AuthSchema): 认证信息模型
|
|
- tenant_id (int): 租户ID
|
|
|
|
返回:
|
|
- PackageAvailableOut: 可选套餐列表
|
|
"""
|
|
tenant = await auth.db.get(TenantModel, tenant_id)
|
|
current_pkg_id = tenant.package_id if tenant else None
|
|
|
|
# 一次性获取当前套餐价格(可能未启用,不在后续结果中)
|
|
current_price: int | None = None
|
|
if current_pkg_id:
|
|
cp = await auth.db.get(PackageModel, current_pkg_id)
|
|
current_price = cp.price if cp else 0
|
|
|
|
stmt = select(PackageModel).where(PackageModel.status == 0).order_by(PackageModel.price)
|
|
result = await auth.db.execute(stmt)
|
|
packages = result.scalars().all()
|
|
|
|
items: list[PackageAvailableItem] = []
|
|
for pkg in packages:
|
|
is_current = pkg.id == current_pkg_id
|
|
actions: list[str] = []
|
|
if is_current:
|
|
actions = ["renew"]
|
|
elif current_pkg_id is None:
|
|
actions = ["buy"]
|
|
elif current_price is not None:
|
|
actions = ["upgrade"] if pkg.price > current_price else ["downgrade"]
|
|
|
|
items.append(
|
|
PackageAvailableItem(
|
|
id=pkg.id,
|
|
name=pkg.name,
|
|
price=pkg.price,
|
|
period=pkg.period,
|
|
trial_days=pkg.trial_days,
|
|
max_users=pkg.max_users,
|
|
max_roles=pkg.max_roles,
|
|
max_depts=pkg.max_depts,
|
|
max_storage_mb=pkg.max_storage_mb,
|
|
description=pkg.description,
|
|
is_current=is_current,
|
|
available_actions=actions,
|
|
)
|
|
)
|
|
|
|
return PackageAvailableOut(
|
|
current_package_id=current_pkg_id,
|
|
packages=items,
|
|
)
|
|
|
|
@classmethod
|
|
async def preview_package_change(cls, auth: AuthSchema, tenant_id: int, target_package_id: int) -> PackagePreviewOut:
|
|
"""
|
|
套餐变更影响预览
|
|
|
|
参数:
|
|
- auth (AuthSchema): 认证信息模型
|
|
- tenant_id (int): 租户ID
|
|
- target_package_id (int): 目标套餐ID
|
|
|
|
返回:
|
|
- PackagePreviewOut: 套餐变更预览结果
|
|
"""
|
|
tenant = await auth.db.get(TenantModel, tenant_id)
|
|
current_pkg_id = tenant.package_id if tenant else None
|
|
|
|
target_pkg = await auth.db.get(PackageModel, target_package_id)
|
|
if not target_pkg:
|
|
raise CustomException(msg="该数据不存在")
|
|
|
|
current_pkg = None
|
|
if current_pkg_id:
|
|
current_pkg = await auth.db.get(PackageModel, current_pkg_id)
|
|
|
|
# 获取当前套餐的菜单
|
|
current_menus = set()
|
|
if current_pkg_id:
|
|
cm_stmt = select(MenuModel).join(PackageMenuModel, MenuModel.id == PackageMenuModel.menu_id).where(PackageMenuModel.package_id == current_pkg_id)
|
|
result = await auth.db.execute(cm_stmt)
|
|
current_menus = {m.id for m in result.scalars().all()}
|
|
|
|
# 获取目标套餐的菜单
|
|
tm_stmt = select(MenuModel).join(PackageMenuModel, MenuModel.id == PackageMenuModel.menu_id).where(PackageMenuModel.package_id == target_package_id)
|
|
result = await auth.db.execute(tm_stmt)
|
|
target_menus = {m.id for m in result.scalars().all()}
|
|
|
|
# 计算差异
|
|
gained_menus, lost_menus = [], []
|
|
if current_menus:
|
|
lost_ids = current_menus - target_menus
|
|
if lost_ids:
|
|
lost_stmt = select(MenuModel).where(MenuModel.id.in_(lost_ids))
|
|
lost = await auth.db.execute(lost_stmt)
|
|
lost_menus = [{"id": m.id, "name": m.name, "path": m.route_path} for m in lost.scalars().all()]
|
|
|
|
gained_ids = target_menus - (current_menus or set())
|
|
if gained_ids:
|
|
gain_stmt = select(MenuModel).where(MenuModel.id.in_(gained_ids))
|
|
gained = await auth.db.execute(gain_stmt)
|
|
gained_menus = [{"id": m.id, "name": m.name, "path": m.route_path} for m in gained.scalars().all()]
|
|
|
|
# 确定操作类型
|
|
if current_pkg_id is None:
|
|
action = "buy"
|
|
elif current_pkg and target_pkg.price > current_pkg.price:
|
|
action = "upgrade"
|
|
elif current_pkg and target_pkg.price < current_pkg.price:
|
|
action = "downgrade"
|
|
else:
|
|
action = "renew"
|
|
|
|
# 受影响角色
|
|
affected_roles = []
|
|
if current_pkg_id:
|
|
role_stmt = select(RoleModel).where(RoleModel.tenant_id == tenant_id)
|
|
r_result = await auth.db.execute(role_stmt)
|
|
affected_roles = [r.name for r in r_result.scalars().all()]
|
|
|
|
return PackagePreviewOut(
|
|
current_package=current_pkg.name if current_pkg else "",
|
|
target_package=target_pkg.name,
|
|
action=action,
|
|
amount=target_pkg.price,
|
|
period=target_pkg.period,
|
|
gained_menus=gained_menus,
|
|
lost_menus=lost_menus,
|
|
affected_roles=affected_roles,
|
|
affected_users=0,
|
|
)
|
|
|
|
@classmethod
|
|
async def create_self_order(cls, auth: AuthSchema, tenant_id: int, data: SelfOrderCreate) -> SelfOrderOut:
|
|
"""
|
|
创建自助订单(套餐购买/续费/升级/降级;免费订单自动激活)
|
|
|
|
参数:
|
|
- auth (AuthSchema): 认证信息模型
|
|
- tenant_id (int): 租户ID
|
|
- data (SelfOrderCreate): 自助订单创建参数
|
|
|
|
返回:
|
|
- SelfOrderOut: 自助订单创建结果
|
|
"""
|
|
tenant = await auth.db.get(TenantModel, tenant_id)
|
|
if not tenant:
|
|
raise CustomException(msg="该数据不存在")
|
|
if tenant.status not in (0, 1, 2):
|
|
raise CustomException(msg="租户状态不允许操作")
|
|
|
|
pkg = await auth.db.get(PackageModel, data.package_id)
|
|
if not pkg or pkg.status == 1:
|
|
raise CustomException(msg="该数据不存在")
|
|
|
|
amount = pkg.price
|
|
|
|
order = await OrderCRUD(auth).create(
|
|
OrderCreateInternalSchema(
|
|
order_no=_generate_order_no(),
|
|
tenant_id=tenant_id,
|
|
package_id=data.package_id,
|
|
order_type=data.order_type,
|
|
amount=amount,
|
|
expire_time=datetime.now() + timedelta(minutes=15),
|
|
)
|
|
)
|
|
await auth.db.flush()
|
|
|
|
# 免费订单自动激活
|
|
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)
|
|
|
|
logger.info(f"自助订单创建: order_no={order.order_no} tenant={tenant_id} amount={amount}")
|
|
return SelfOrderOut(
|
|
order_id=order.id,
|
|
order_no=order.order_no,
|
|
amount=amount,
|
|
need_pay=amount > 0,
|
|
)
|
|
|
|
@classmethod
|
|
async def create_plugin_purchase_order(cls, auth: AuthSchema, tenant_id: int, data: PluginPurchaseCreate) -> SelfOrderOut:
|
|
"""
|
|
创建插件购买订单(复用套餐订单流程)
|
|
|
|
参数:
|
|
- auth (AuthSchema): 认证信息模型
|
|
- tenant_id (int): 租户ID
|
|
- data (PluginPurchaseCreate): 插件购买参数
|
|
|
|
返回:
|
|
- SelfOrderOut: 订单创建结果
|
|
"""
|
|
# 校验插件存在且可用
|
|
plugin = await auth.db.get(PluginModel, data.plugin_id)
|
|
if not plugin or plugin.status == 1:
|
|
raise CustomException(msg="该数据不存在")
|
|
|
|
if plugin.price == 0:
|
|
raise CustomException(msg="免费插件可直接安装,无需购买")
|
|
|
|
# 校验未重复购买
|
|
existing = await auth.db.execute(
|
|
select(TenantPluginModel)
|
|
.where(
|
|
TenantPluginModel.tenant_id == tenant_id,
|
|
TenantPluginModel.plugin_id == data.plugin_id,
|
|
)
|
|
.limit(1)
|
|
)
|
|
tp = existing.scalar_one_or_none()
|
|
if tp and tp.purchased == "1":
|
|
raise CustomException(msg="您已购买此插件,可直接安装")
|
|
|
|
order_data = OrderCreateSchema(
|
|
tenant_id=tenant_id,
|
|
package_id=None,
|
|
plugin_id=data.plugin_id,
|
|
order_type="plugin",
|
|
pay_method=data.pay_method,
|
|
)
|
|
result = await OrderService.create_order(auth=auth, data=order_data)
|
|
|
|
logger.info(f"插件购买订单创建: order_no={result.order_no} plugin={plugin.name}")
|
|
return SelfOrderOut(
|
|
order_id=result.id,
|
|
order_no=result.order_no,
|
|
amount=result.amount,
|
|
need_pay=result.amount > 0,
|
|
)
|
|
|
|
@classmethod
|
|
async def get_self_order_list(
|
|
cls,
|
|
auth: AuthSchema,
|
|
tenant_id: int,
|
|
page_no: int = 1,
|
|
page_size: int = 20,
|
|
order_by: list[dict] | None = None,
|
|
) -> SelfOrderListOut:
|
|
"""
|
|
我的订单列表
|
|
|
|
参数:
|
|
- auth (AuthSchema): 认证信息模型
|
|
- tenant_id (int): 租户ID
|
|
- page_no (int): 页码
|
|
- page_size (int): 每页数量
|
|
- order_by (list[dict] | None): 排序参数
|
|
|
|
返回:
|
|
- SelfOrderListOut: 订单分页列表
|
|
"""
|
|
offset = (page_no - 1) * page_size
|
|
page_result = await OrderCRUD(auth).page(
|
|
offset=offset,
|
|
limit=page_size,
|
|
order_by=order_by or [{"created_time": "desc"}],
|
|
search={"tenant_id": tenant_id},
|
|
)
|
|
|
|
# 批量查询关联套餐,避免 N+1
|
|
package_ids = [o.package_id for o in page_result.items if hasattr(o, "package_id") and o.package_id]
|
|
pkg_map: dict[int, str] = {}
|
|
if package_ids:
|
|
pkg_result = await auth.db.execute(select(PackageModel.id, PackageModel.name).where(PackageModel.id.in_(package_ids)))
|
|
pkg_map = {row[0]: row[1] for row in pkg_result.all()}
|
|
|
|
items = []
|
|
for o in page_result.items:
|
|
pkg_name = pkg_map.get(o.package_id, "") if hasattr(o, "package_id") and o.package_id else ""
|
|
items.append(
|
|
SelfOrderListItem(
|
|
id=o.id,
|
|
order_no=o.order_no,
|
|
package_name=pkg_name,
|
|
order_type=o.order_type,
|
|
amount=o.amount,
|
|
status=o.status,
|
|
pay_method=o.pay_method,
|
|
pay_time=o.pay_time.isoformat() if o.pay_time else None,
|
|
created_at=o.created_time.isoformat() if o.created_time else None,
|
|
)
|
|
)
|
|
|
|
return SelfOrderListOut(
|
|
items=items,
|
|
total=page_result.total,
|
|
page_no=page_no,
|
|
page_size=page_size,
|
|
)
|
|
|
|
@classmethod
|
|
async def get_self_order_detail(cls, auth: AuthSchema, order_id: int) -> SelfOrderDetailOut:
|
|
"""
|
|
订单详情
|
|
|
|
参数:
|
|
- auth (AuthSchema): 认证信息模型
|
|
- order_id (int): 订单ID
|
|
|
|
返回:
|
|
- SelfOrderDetailOut: 订单详情
|
|
"""
|
|
order = await OrderCRUD(auth).get_or_404(id=order_id, msg="该数据不存在")
|
|
|
|
pkg_name = ""
|
|
if order.package_id:
|
|
p = await auth.db.get(PackageModel, order.package_id)
|
|
if p:
|
|
pkg_name = p.name
|
|
|
|
return SelfOrderDetailOut(
|
|
id=order.id,
|
|
order_no=order.order_no,
|
|
package_id=order.package_id,
|
|
package_name=pkg_name,
|
|
amount=order.amount,
|
|
order_type=order.order_type,
|
|
status=order.status,
|
|
pay_method=order.pay_method,
|
|
pay_time=order.pay_time.isoformat() if order.pay_time else None,
|
|
created_at=order.created_time.isoformat() if order.created_time else None,
|
|
)
|
|
|
|
@classmethod
|
|
async def get_workspace_data(cls, auth: AuthSchema, tenant_id: int) -> WorkspaceOut:
|
|
"""
|
|
获取租户工作台概览(租户信息、套餐、配额用量、近期订单)
|
|
|
|
参数:
|
|
- auth (AuthSchema): 认证信息模型
|
|
- tenant_id (int): 租户ID
|
|
|
|
返回:
|
|
- WorkspaceOut: 工作台概览数据
|
|
"""
|
|
tenant = await auth.db.get(TenantModel, tenant_id)
|
|
if not tenant:
|
|
return WorkspaceOut(
|
|
tenant=WorkspaceTenantInfo(id=0, name="", code="", status=0, status_label="未知"),
|
|
quota=WorkspaceQuotaInfo(),
|
|
)
|
|
|
|
package = None
|
|
if tenant.package_id:
|
|
package = await auth.db.get(PackageModel, tenant.package_id)
|
|
|
|
async def _count(model_cls) -> int:
|
|
stmt = (
|
|
select(func.count())
|
|
.select_from(model_cls)
|
|
.where(
|
|
model_cls.tenant_id == tenant_id,
|
|
model_cls.is_deleted.is_(False),
|
|
)
|
|
)
|
|
return (await auth.db.execute(stmt)).scalar() or 0
|
|
|
|
user_count = await _count(UserModel)
|
|
role_count = await _count(RoleModel)
|
|
dept_count = await _count(DeptModel)
|
|
|
|
now = datetime.now()
|
|
days_remaining = (tenant.end_time - now).days if tenant.end_time else 0
|
|
|
|
status_labels = {
|
|
"0": "正常",
|
|
"1": "宽限期",
|
|
"2": "已暂停",
|
|
"3": "已冻结",
|
|
"4": "已过期",
|
|
"5": "已归档",
|
|
}
|
|
|
|
orders_stmt = select(OrderModel).where(OrderModel.tenant_id == tenant_id).order_by(OrderModel.created_time.desc()).limit(5)
|
|
orders_result = await auth.db.execute(orders_stmt)
|
|
recent_orders = []
|
|
for o in orders_result.scalars().all():
|
|
recent_orders.append(
|
|
WorkspaceOrderItem(
|
|
id=o.id,
|
|
order_no=o.order_no,
|
|
amount=o.amount,
|
|
order_type=o.order_type,
|
|
status=o.status,
|
|
created_at=o.created_time.isoformat() if o.created_time else None,
|
|
)
|
|
)
|
|
|
|
return WorkspaceOut(
|
|
tenant=WorkspaceTenantInfo(
|
|
id=tenant.id,
|
|
name=tenant.name,
|
|
code=tenant.code,
|
|
status=tenant.status,
|
|
status_label=status_labels.get(str(tenant.status), "未知"),
|
|
start_time=tenant.start_time.isoformat() if tenant.start_time else None,
|
|
end_time=tenant.end_time.isoformat() if tenant.end_time else None,
|
|
days_remaining=max(days_remaining, 0),
|
|
),
|
|
package=WorkspacePackageInfo(
|
|
id=package.id,
|
|
name=package.name,
|
|
price=package.price,
|
|
period=package.period,
|
|
max_users=package.max_users,
|
|
max_roles=package.max_roles,
|
|
max_depts=package.max_depts,
|
|
)
|
|
if package
|
|
else None,
|
|
quota=WorkspaceQuotaInfo(
|
|
max_users=package.max_users if package else 0,
|
|
max_roles=package.max_roles if package else 0,
|
|
max_depts=package.max_depts if package else 0,
|
|
current_users=user_count,
|
|
current_roles=role_count,
|
|
current_depts=dept_count,
|
|
usage_percent=WorkspaceUsagePercent(
|
|
users=round(user_count / package.max_users * 100, 1) if package and package.max_users > 0 else 0,
|
|
roles=round(role_count / package.max_roles * 100, 1) if package and package.max_roles > 0 else 0,
|
|
depts=round(dept_count / package.max_depts * 100, 1) if package and package.max_depts > 0 else 0,
|
|
),
|
|
),
|
|
recent_orders=recent_orders,
|
|
)
|
|
|
|
@classmethod
|
|
async def get_tenant_api_usage_today(cls, auth: AuthSchema, tenant_id: int) -> dict:
|
|
"""
|
|
获取租户当日 API 用量汇总(已移除 api_usage 模块)
|
|
|
|
参数:
|
|
- auth (AuthSchema): 认证信息模型
|
|
- tenant_id (int): 租户ID
|
|
|
|
返回:
|
|
- dict: API 用量占位数据
|
|
"""
|
|
return {
|
|
"today": {"request_count": 0, "error_count": 0, "total_duration_ms": 0},
|
|
"month": {"total_calls": 0},
|
|
"top_paths": [],
|
|
}
|
|
|
|
@classmethod
|
|
async def get_tenant_api_usage_daily(cls, auth: AuthSchema, tenant_id: int, days: int = 7) -> dict:
|
|
"""
|
|
获取租户每日 API 用量(已移除 api_usage 模块)
|
|
|
|
参数:
|
|
- auth (AuthSchema): 认证信息模型
|
|
- tenant_id (int): 租户ID
|
|
- days (int): 查询天数
|
|
|
|
返回:
|
|
- dict: 每日用量占位数据
|
|
"""
|
|
return {"daily": []}
|