refactor: 移除 Python 后端源码,全面迁移至 Rust gateway 架构

- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/
- 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层
- 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构
- 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations)
- 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image
- 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
This commit is contained in:
fawney19
2026-04-03 16:26:16 +08:00
parent 8f26e1a31f
commit 1d9c77522a
868 changed files with 1735 additions and 2433 deletions

View File

@@ -0,0 +1,5 @@
"""Billing 配置管理 API 模块billing_rules / dimension_collectors"""
from .routes import router
__all__ = ["router"]

View File

@@ -0,0 +1,588 @@
"""Billing 配置管理 API 路由。
包含:
- billing_rules: 计费规则(公式/变量/维度映射)
- dimension_collectors: 维度采集器request/response/metadata/computed
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Literal
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, Field
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import get_pipeline
from src.core.exceptions import InvalidRequestException, NotFoundException
from src.database import get_db
from src.models.database import BillingRule, DimensionCollector
from src.services.billing.formula_engine import SafeExpressionEvaluator, UnsafeExpressionError
from src.services.billing.presets import BillingPresetService, PresetApplyMode, list_preset_packs
router = APIRouter(prefix="/api/admin/billing", tags=["Admin - Billing"])
pipeline = get_pipeline()
_expr_validator = SafeExpressionEvaluator()
AllowedTaskType = Literal["chat", "video", "image", "audio"]
AllowedCollectorSourceType = Literal["request", "response", "metadata", "computed"]
AllowedValueType = Literal["float", "int", "string"]
class BillingRuleUpsertRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
task_type: AllowedTaskType = "chat"
global_model_id: str | None = None
model_id: str | None = None
expression: str = Field(..., min_length=1)
variables: dict[str, Any] = Field(default_factory=dict)
dimension_mappings: dict[str, Any] = Field(default_factory=dict)
is_enabled: bool = True
class BillingRuleResponse(BaseModel):
id: str
name: str
task_type: str
global_model_id: str | None
model_id: str | None
expression: str
variables: dict[str, Any]
dimension_mappings: dict[str, Any]
is_enabled: bool
created_at: datetime
updated_at: datetime
@classmethod
def from_orm_obj(cls, rule: BillingRule) -> "BillingRuleResponse":
return cls(
id=rule.id,
name=rule.name,
task_type=rule.task_type,
global_model_id=rule.global_model_id,
model_id=rule.model_id,
expression=rule.expression,
variables=rule.variables or {},
dimension_mappings=rule.dimension_mappings or {},
is_enabled=bool(rule.is_enabled),
created_at=rule.created_at,
updated_at=rule.updated_at,
)
class DimensionCollectorUpsertRequest(BaseModel):
api_format: str = Field(..., min_length=1, max_length=50)
task_type: str = Field(..., min_length=1, max_length=20)
dimension_name: str = Field(..., min_length=1, max_length=100)
source_type: AllowedCollectorSourceType
source_path: str | None = None
value_type: AllowedValueType = "float"
transform_expression: str | None = None
default_value: str | None = None
priority: int = 0
is_enabled: bool = True
class DimensionCollectorResponse(BaseModel):
id: str
api_format: str
task_type: str
dimension_name: str
source_type: str
source_path: str | None
value_type: str
transform_expression: str | None
default_value: str | None
priority: int
is_enabled: bool
created_at: datetime
updated_at: datetime
@classmethod
def from_orm_obj(cls, c: DimensionCollector) -> "DimensionCollectorResponse":
return cls(
id=c.id,
api_format=c.api_format,
task_type=c.task_type,
dimension_name=c.dimension_name,
source_type=c.source_type,
source_path=c.source_path,
value_type=c.value_type,
transform_expression=c.transform_expression,
default_value=c.default_value,
priority=int(c.priority or 0),
is_enabled=bool(c.is_enabled),
created_at=c.created_at,
updated_at=c.updated_at,
)
class BillingPresetInfoResponse(BaseModel):
name: str
version: str
description: str
collector_count: int
class ApplyBillingPresetRequest(BaseModel):
preset: str = Field(..., min_length=1, max_length=100)
mode: PresetApplyMode = "merge"
@router.get("/presets")
async def list_billing_presets(request: Request, db: Session = Depends(get_db)) -> Any:
adapter = BillingPresetListAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/presets/apply")
async def apply_billing_preset(request: Request, db: Session = Depends(get_db)) -> Any:
adapter = BillingPresetApplyAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/rules")
async def list_billing_rules(
request: Request,
task_type: str | None = Query(None),
is_enabled: bool | None = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
) -> Any:
adapter = BillingRuleListAdapter(
task_type=task_type,
is_enabled=is_enabled,
page=page,
page_size=page_size,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/rules/{rule_id}")
async def get_billing_rule(rule_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
adapter = BillingRuleDetailAdapter(rule_id=rule_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/rules")
async def create_billing_rule(request: Request, db: Session = Depends(get_db)) -> Any:
adapter = BillingRuleCreateAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.put("/rules/{rule_id}")
async def update_billing_rule(rule_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
adapter = BillingRuleUpdateAdapter(rule_id=rule_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/collectors")
async def list_dimension_collectors(
request: Request,
api_format: str | None = Query(None),
task_type: str | None = Query(None),
dimension_name: str | None = Query(None),
is_enabled: bool | None = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
) -> Any:
adapter = DimensionCollectorListAdapter(
api_format=api_format,
task_type=task_type,
dimension_name=dimension_name,
is_enabled=is_enabled,
page=page,
page_size=page_size,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/collectors/{collector_id}")
async def get_dimension_collector(
collector_id: str, request: Request, db: Session = Depends(get_db)
) -> Any:
adapter = DimensionCollectorDetailAdapter(collector_id=collector_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/collectors")
async def create_dimension_collector(request: Request, db: Session = Depends(get_db)) -> Any:
adapter = DimensionCollectorCreateAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.put("/collectors/{collector_id}")
async def update_dimension_collector(
collector_id: str, request: Request, db: Session = Depends(get_db)
) -> Any:
adapter = DimensionCollectorUpdateAdapter(collector_id=collector_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
# ---------------------------------------------------------------------------
# Adapters
# ---------------------------------------------------------------------------
@dataclass
class BillingRuleListAdapter(AdminApiAdapter):
page: int
page_size: int
task_type: str | None = None
is_enabled: bool | None = None
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
q = context.db.query(BillingRule)
if self.task_type:
q = q.filter(BillingRule.task_type == self.task_type.lower())
if self.is_enabled is not None:
q = q.filter(BillingRule.is_enabled == self.is_enabled)
total = int(q.with_entities(func.count(BillingRule.id)).scalar() or 0)
items = (
q.order_by(BillingRule.updated_at.desc())
.offset((self.page - 1) * self.page_size)
.limit(self.page_size)
.all()
)
return {
"items": [BillingRuleResponse.from_orm_obj(r).model_dump() for r in items],
"total": total,
"page": self.page,
"page_size": self.page_size,
"pages": (total + self.page_size - 1) // self.page_size,
}
@dataclass
class BillingRuleDetailAdapter(AdminApiAdapter):
rule_id: str
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
rule = context.db.query(BillingRule).filter(BillingRule.id == self.rule_id).first()
if not rule:
raise NotFoundException("Billing rule not found")
return BillingRuleResponse.from_orm_obj(rule).model_dump()
class BillingRuleCreateAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
payload = context.ensure_json_body()
try:
req = BillingRuleUpsertRequest.model_validate(payload)
except Exception as exc:
raise InvalidRequestException(f"Invalid request body: {exc}")
_validate_billing_rule_request(req)
rule = BillingRule(
name=req.name,
task_type=req.task_type,
global_model_id=req.global_model_id,
model_id=req.model_id,
expression=req.expression,
variables=req.variables,
dimension_mappings=req.dimension_mappings,
is_enabled=req.is_enabled,
)
context.db.add(rule)
try:
context.db.commit()
except IntegrityError as exc:
context.db.rollback()
raise InvalidRequestException(f"Integrity error: {exc}")
context.db.refresh(rule)
return BillingRuleResponse.from_orm_obj(rule).model_dump()
@dataclass
class BillingRuleUpdateAdapter(AdminApiAdapter):
rule_id: str
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
rule = context.db.query(BillingRule).filter(BillingRule.id == self.rule_id).first()
if not rule:
raise NotFoundException("Billing rule not found")
payload = context.ensure_json_body()
try:
req = BillingRuleUpsertRequest.model_validate(payload)
except Exception as exc:
raise InvalidRequestException(f"Invalid request body: {exc}")
_validate_billing_rule_request(req)
rule.name = req.name
rule.task_type = req.task_type
rule.global_model_id = req.global_model_id
rule.model_id = req.model_id
rule.expression = req.expression
rule.variables = req.variables
rule.dimension_mappings = req.dimension_mappings
rule.is_enabled = req.is_enabled
try:
context.db.commit()
except IntegrityError as exc:
context.db.rollback()
raise InvalidRequestException(f"Integrity error: {exc}")
context.db.refresh(rule)
return BillingRuleResponse.from_orm_obj(rule).model_dump()
@dataclass
class DimensionCollectorListAdapter(AdminApiAdapter):
page: int
page_size: int
api_format: str | None = None
task_type: str | None = None
dimension_name: str | None = None
is_enabled: bool | None = None
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
q = context.db.query(DimensionCollector)
if self.api_format:
q = q.filter(DimensionCollector.api_format == self.api_format.upper())
if self.task_type:
q = q.filter(DimensionCollector.task_type == self.task_type.lower())
if self.dimension_name:
q = q.filter(DimensionCollector.dimension_name == self.dimension_name)
if self.is_enabled is not None:
q = q.filter(DimensionCollector.is_enabled == self.is_enabled)
total = int(q.with_entities(func.count(DimensionCollector.id)).scalar() or 0)
items = (
q.order_by(DimensionCollector.updated_at.desc())
.offset((self.page - 1) * self.page_size)
.limit(self.page_size)
.all()
)
return {
"items": [DimensionCollectorResponse.from_orm_obj(c).model_dump() for c in items],
"total": total,
"page": self.page,
"page_size": self.page_size,
"pages": (total + self.page_size - 1) // self.page_size,
}
@dataclass
class DimensionCollectorDetailAdapter(AdminApiAdapter):
collector_id: str
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
c = (
context.db.query(DimensionCollector)
.filter(DimensionCollector.id == self.collector_id)
.first()
)
if not c:
raise NotFoundException("Dimension collector not found")
return DimensionCollectorResponse.from_orm_obj(c).model_dump()
class DimensionCollectorCreateAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
payload = context.ensure_json_body()
try:
req = DimensionCollectorUpsertRequest.model_validate(payload)
except Exception as exc:
raise InvalidRequestException(f"Invalid request body: {exc}")
_validate_dimension_collector_request(context.db, req, existing_id=None)
c = DimensionCollector(
api_format=req.api_format.upper(),
task_type=req.task_type.lower(),
dimension_name=req.dimension_name,
source_type=req.source_type,
source_path=req.source_path,
value_type=req.value_type,
transform_expression=req.transform_expression,
default_value=req.default_value,
priority=req.priority,
is_enabled=req.is_enabled,
)
context.db.add(c)
try:
context.db.commit()
except IntegrityError as exc:
context.db.rollback()
raise InvalidRequestException(f"Integrity error: {exc}")
context.db.refresh(c)
return DimensionCollectorResponse.from_orm_obj(c).model_dump()
@dataclass
class DimensionCollectorUpdateAdapter(AdminApiAdapter):
collector_id: str
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
c = (
context.db.query(DimensionCollector)
.filter(DimensionCollector.id == self.collector_id)
.first()
)
if not c:
raise NotFoundException("Dimension collector not found")
payload = context.ensure_json_body()
try:
req = DimensionCollectorUpsertRequest.model_validate(payload)
except Exception as exc:
raise InvalidRequestException(f"Invalid request body: {exc}")
_validate_dimension_collector_request(context.db, req, existing_id=self.collector_id)
c.api_format = req.api_format.upper()
c.task_type = req.task_type.lower()
c.dimension_name = req.dimension_name
c.source_type = req.source_type
c.source_path = req.source_path
c.value_type = req.value_type
c.transform_expression = req.transform_expression
c.default_value = req.default_value
c.priority = req.priority
c.is_enabled = req.is_enabled
try:
context.db.commit()
except IntegrityError as exc:
context.db.rollback()
raise InvalidRequestException(f"Integrity error: {exc}")
context.db.refresh(c)
return DimensionCollectorResponse.from_orm_obj(c).model_dump()
# ---------------------------------------------------------------------------
# Validation helpers
# ---------------------------------------------------------------------------
def _validate_billing_rule_request(req: BillingRuleUpsertRequest) -> None:
# model/global_model 二选一
if bool(req.global_model_id) == bool(req.model_id):
raise InvalidRequestException("Exactly one of global_model_id or model_id must be provided")
# task_type 校验Pydantic Literal 已限制为 "chat", "video", "image", "audio"
# 注CLI 在计费域等同于 chatbilling_rules 不存储 "cli"
# expression 安全校验
try:
_expr_validator.validate(req.expression)
except UnsafeExpressionError as exc:
raise InvalidRequestException(f"Invalid expression: {exc}")
# variables 必须为数值JSON 可包含 int/float
if not isinstance(req.variables, dict):
raise InvalidRequestException("variables must be a JSON object")
for k, v in req.variables.items():
if not isinstance(k, str) or not k:
raise InvalidRequestException("variables keys must be non-empty strings")
if isinstance(v, bool) or not isinstance(v, (int, float)):
raise InvalidRequestException(f"variables['{k}'] must be a number")
# dimension_mappings 结构做轻量校验(详细 schema 由业务侧保障)
if not isinstance(req.dimension_mappings, dict):
raise InvalidRequestException("dimension_mappings must be a JSON object")
for var_name, mapping in req.dimension_mappings.items():
if not isinstance(var_name, str) or not var_name:
raise InvalidRequestException("dimension_mappings keys must be non-empty strings")
if not isinstance(mapping, dict):
raise InvalidRequestException(f"dimension_mappings['{var_name}'] must be an object")
if "source" not in mapping:
raise InvalidRequestException(f"dimension_mappings['{var_name}'].source is required")
def _validate_dimension_collector_request(
db: Session,
req: DimensionCollectorUpsertRequest,
*,
existing_id: str | None,
) -> None:
src = req.source_type
if src == "computed":
if req.source_path is not None:
raise InvalidRequestException("computed collector must have source_path=null")
if not req.transform_expression:
raise InvalidRequestException("computed collector must have transform_expression")
else:
if not req.source_path:
raise InvalidRequestException("non-computed collector must have source_path")
# transform_expression 安全校验(如配置)
if req.transform_expression:
try:
_expr_validator.validate(req.transform_expression)
except UnsafeExpressionError as exc:
raise InvalidRequestException(f"Invalid transform_expression: {exc}")
# default_value 仅允许同一维度一条enabled=true
if req.default_value is not None and req.is_enabled:
q = db.query(DimensionCollector).filter(
DimensionCollector.api_format == req.api_format.upper(),
DimensionCollector.task_type == req.task_type.lower(),
DimensionCollector.dimension_name == req.dimension_name,
DimensionCollector.is_enabled.is_(True),
DimensionCollector.default_value.isnot(None),
)
if existing_id:
q = q.filter(DimensionCollector.id != existing_id)
exists = db.query(q.exists()).scalar()
if exists:
raise InvalidRequestException(
"default_value already exists for this (api_format, task_type, dimension_name)"
)
@dataclass
class BillingPresetListAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
items = []
for p in list_preset_packs():
items.append(
BillingPresetInfoResponse(
name=p.name,
version=p.version,
description=p.description,
collector_count=len(p.collectors or []),
).model_dump()
)
return {"items": items}
class BillingPresetApplyAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
payload = context.ensure_json_body()
try:
req = ApplyBillingPresetRequest.model_validate(payload)
except Exception as exc:
raise InvalidRequestException(f"Invalid request body: {exc}")
result = BillingPresetService.apply_preset(
context.db,
preset_name=req.preset,
mode=req.mode,
)
if result.errors:
# still return counts; caller can display partial results
return {"ok": False, **result.to_dict()}
return {"ok": True, **result.to_dict()}