feat: 新增告警、成本、配额、微信模块及缓存服务
All checks were successful
continuous-integration/drone/push Build is passing

- 新增告警模块 (alerts): 告警规则配置与触发
- 新增成本管理模块 (cost): 成本统计与分析
- 新增配额模块 (quota): 配额管理与限制
- 新增微信模块 (wechat): 微信相关功能接口
- 新增缓存服务 (cache): Redis 缓存封装
- 新增请求日志中间件 (request_logger)
- 新增异常处理和链路追踪中间件
- 更新 dashboard 前端展示
- 更新 SDK stats_client 功能
This commit is contained in:
111
2026-01-24 16:53:47 +08:00
parent eab2533c36
commit 6c6c48cf71
29 changed files with 4607 additions and 41 deletions

View File

@@ -0,0 +1,430 @@
"""告警管理路由"""
from typing import Optional, List
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import desc, func
from ..database import get_db
from ..models.alert import AlertRule, AlertRecord, NotificationChannel
from ..services.alert import AlertService
from .auth import get_current_user, require_operator
from ..models.user import User
router = APIRouter(prefix="/alerts", tags=["告警管理"])
# ============= Schemas =============
class AlertRuleCreate(BaseModel):
name: str
description: Optional[str] = None
rule_type: str
scope_type: str = "global"
scope_value: Optional[str] = None
condition: dict
notification_channels: Optional[List[dict]] = None
cooldown_minutes: int = 30
max_alerts_per_day: int = 10
priority: str = "medium"
class AlertRuleUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
condition: Optional[dict] = None
notification_channels: Optional[List[dict]] = None
cooldown_minutes: Optional[int] = None
max_alerts_per_day: Optional[int] = None
priority: Optional[str] = None
status: Optional[int] = None
class NotificationChannelCreate(BaseModel):
name: str
channel_type: str
config: dict
class NotificationChannelUpdate(BaseModel):
name: Optional[str] = None
config: Optional[dict] = None
status: Optional[int] = None
# ============= Alert Rules API =============
@router.get("/rules")
async def list_alert_rules(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
rule_type: Optional[str] = None,
status: Optional[int] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取告警规则列表"""
query = db.query(AlertRule)
if rule_type:
query = query.filter(AlertRule.rule_type == rule_type)
if status is not None:
query = query.filter(AlertRule.status == status)
total = query.count()
rules = query.order_by(desc(AlertRule.created_at)).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [format_rule(r) for r in rules]
}
@router.get("/rules/{rule_id}")
async def get_alert_rule(
rule_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取告警规则详情"""
rule = db.query(AlertRule).filter(AlertRule.id == rule_id).first()
if not rule:
raise HTTPException(status_code=404, detail="告警规则不存在")
return format_rule(rule)
@router.post("/rules")
async def create_alert_rule(
data: AlertRuleCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建告警规则"""
rule = AlertRule(
name=data.name,
description=data.description,
rule_type=data.rule_type,
scope_type=data.scope_type,
scope_value=data.scope_value,
condition=data.condition,
notification_channels=data.notification_channels,
cooldown_minutes=data.cooldown_minutes,
max_alerts_per_day=data.max_alerts_per_day,
priority=data.priority,
status=1
)
db.add(rule)
db.commit()
db.refresh(rule)
return {"success": True, "id": rule.id}
@router.put("/rules/{rule_id}")
async def update_alert_rule(
rule_id: int,
data: AlertRuleUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新告警规则"""
rule = db.query(AlertRule).filter(AlertRule.id == rule_id).first()
if not rule:
raise HTTPException(status_code=404, detail="告警规则不存在")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(rule, key, value)
db.commit()
return {"success": True}
@router.delete("/rules/{rule_id}")
async def delete_alert_rule(
rule_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除告警规则"""
rule = db.query(AlertRule).filter(AlertRule.id == rule_id).first()
if not rule:
raise HTTPException(status_code=404, detail="告警规则不存在")
db.delete(rule)
db.commit()
return {"success": True}
# ============= Alert Records API =============
@router.get("/records")
async def list_alert_records(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
status: Optional[str] = None,
severity: Optional[str] = None,
alert_type: Optional[str] = None,
tenant_id: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取告警记录列表"""
query = db.query(AlertRecord)
if status:
query = query.filter(AlertRecord.status == status)
if severity:
query = query.filter(AlertRecord.severity == severity)
if alert_type:
query = query.filter(AlertRecord.alert_type == alert_type)
if tenant_id:
query = query.filter(AlertRecord.tenant_id == tenant_id)
if start_date:
query = query.filter(AlertRecord.created_at >= start_date)
if end_date:
query = query.filter(AlertRecord.created_at <= end_date + " 23:59:59")
total = query.count()
records = query.order_by(desc(AlertRecord.created_at)).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [format_record(r) for r in records]
}
@router.get("/records/summary")
async def get_alert_summary(
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取告警摘要统计"""
today = datetime.now().date()
week_start = today - timedelta(days=7)
# 今日告警数
today_count = db.query(func.count(AlertRecord.id)).filter(
func.date(AlertRecord.created_at) == today
).scalar()
# 本周告警数
week_count = db.query(func.count(AlertRecord.id)).filter(
func.date(AlertRecord.created_at) >= week_start
).scalar()
# 活跃告警数
active_count = db.query(func.count(AlertRecord.id)).filter(
AlertRecord.status == 'active'
).scalar()
# 按严重程度统计
severity_stats = db.query(
AlertRecord.severity,
func.count(AlertRecord.id)
).filter(
func.date(AlertRecord.created_at) >= week_start
).group_by(AlertRecord.severity).all()
return {
"today_count": today_count,
"week_count": week_count,
"active_count": active_count,
"by_severity": {s: c for s, c in severity_stats}
}
@router.get("/records/{record_id}")
async def get_alert_record(
record_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取告警记录详情"""
record = db.query(AlertRecord).filter(AlertRecord.id == record_id).first()
if not record:
raise HTTPException(status_code=404, detail="告警记录不存在")
return format_record(record)
@router.post("/records/{record_id}/acknowledge")
async def acknowledge_alert(
record_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""确认告警"""
service = AlertService(db)
record = service.acknowledge_alert(record_id, user.username)
if not record:
raise HTTPException(status_code=404, detail="告警记录不存在")
return {"success": True}
@router.post("/records/{record_id}/resolve")
async def resolve_alert(
record_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""解决告警"""
service = AlertService(db)
record = service.resolve_alert(record_id)
if not record:
raise HTTPException(status_code=404, detail="告警记录不存在")
return {"success": True}
# ============= Check Alerts API =============
@router.post("/check")
async def trigger_alert_check(
background_tasks: BackgroundTasks,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""手动触发告警检查"""
service = AlertService(db)
alerts = await service.check_all_rules()
# 异步发送通知
for alert in alerts:
rule = db.query(AlertRule).filter(AlertRule.id == alert.rule_id).first()
if rule:
background_tasks.add_task(service.send_notification, alert, rule)
return {
"success": True,
"triggered_count": len(alerts),
"alerts": [format_record(a) for a in alerts]
}
# ============= Notification Channels API =============
@router.get("/channels")
async def list_notification_channels(
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取通知渠道列表"""
channels = db.query(NotificationChannel).order_by(desc(NotificationChannel.created_at)).all()
return [format_channel(c) for c in channels]
@router.post("/channels")
async def create_notification_channel(
data: NotificationChannelCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建通知渠道"""
channel = NotificationChannel(
name=data.name,
channel_type=data.channel_type,
config=data.config,
status=1
)
db.add(channel)
db.commit()
db.refresh(channel)
return {"success": True, "id": channel.id}
@router.put("/channels/{channel_id}")
async def update_notification_channel(
channel_id: int,
data: NotificationChannelUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新通知渠道"""
channel = db.query(NotificationChannel).filter(NotificationChannel.id == channel_id).first()
if not channel:
raise HTTPException(status_code=404, detail="通知渠道不存在")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(channel, key, value)
db.commit()
return {"success": True}
@router.delete("/channels/{channel_id}")
async def delete_notification_channel(
channel_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除通知渠道"""
channel = db.query(NotificationChannel).filter(NotificationChannel.id == channel_id).first()
if not channel:
raise HTTPException(status_code=404, detail="通知渠道不存在")
db.delete(channel)
db.commit()
return {"success": True}
# ============= Helper Functions =============
def format_rule(rule: AlertRule) -> dict:
return {
"id": rule.id,
"name": rule.name,
"description": rule.description,
"rule_type": rule.rule_type,
"scope_type": rule.scope_type,
"scope_value": rule.scope_value,
"condition": rule.condition,
"notification_channels": rule.notification_channels,
"cooldown_minutes": rule.cooldown_minutes,
"max_alerts_per_day": rule.max_alerts_per_day,
"priority": rule.priority,
"status": rule.status,
"created_at": rule.created_at,
"updated_at": rule.updated_at
}
def format_record(record: AlertRecord) -> dict:
return {
"id": record.id,
"rule_id": record.rule_id,
"rule_name": record.rule_name,
"alert_type": record.alert_type,
"severity": record.severity,
"title": record.title,
"message": record.message,
"tenant_id": record.tenant_id,
"app_code": record.app_code,
"metric_value": record.metric_value,
"threshold_value": record.threshold_value,
"notification_status": record.notification_status,
"status": record.status,
"acknowledged_by": record.acknowledged_by,
"acknowledged_at": record.acknowledged_at,
"resolved_at": record.resolved_at,
"created_at": record.created_at
}
def format_channel(channel: NotificationChannel) -> dict:
return {
"id": channel.id,
"name": channel.name,
"channel_type": channel.channel_type,
"config": channel.config,
"status": channel.status,
"created_at": channel.created_at,
"updated_at": channel.updated_at
}

333
backend/app/routers/cost.py Normal file
View File

@@ -0,0 +1,333 @@
"""费用管理路由"""
from typing import Optional, List
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import desc
from ..database import get_db
from ..models.pricing import ModelPricing, TenantBilling
from ..services.cost import CostCalculator
from .auth import get_current_user, require_operator
from ..models.user import User
router = APIRouter(prefix="/cost", tags=["费用管理"])
# ============= Schemas =============
class ModelPricingCreate(BaseModel):
model_name: str
provider: Optional[str] = None
display_name: Optional[str] = None
input_price_per_1k: float = 0
output_price_per_1k: float = 0
fixed_price_per_call: float = 0
pricing_type: str = "token"
description: Optional[str] = None
class ModelPricingUpdate(BaseModel):
provider: Optional[str] = None
display_name: Optional[str] = None
input_price_per_1k: Optional[float] = None
output_price_per_1k: Optional[float] = None
fixed_price_per_call: Optional[float] = None
pricing_type: Optional[str] = None
description: Optional[str] = None
status: Optional[int] = None
class CostCalculateRequest(BaseModel):
model_name: str
input_tokens: int = 0
output_tokens: int = 0
# ============= Model Pricing API =============
@router.get("/pricing")
async def list_model_pricing(
page: int = Query(1, ge=1),
size: int = Query(50, ge=1, le=100),
provider: Optional[str] = None,
status: Optional[int] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取模型价格配置列表"""
query = db.query(ModelPricing)
if provider:
query = query.filter(ModelPricing.provider == provider)
if status is not None:
query = query.filter(ModelPricing.status == status)
total = query.count()
items = query.order_by(ModelPricing.model_name).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [format_pricing(p) for p in items]
}
@router.get("/pricing/{pricing_id}")
async def get_model_pricing(
pricing_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取模型价格详情"""
pricing = db.query(ModelPricing).filter(ModelPricing.id == pricing_id).first()
if not pricing:
raise HTTPException(status_code=404, detail="模型价格配置不存在")
return format_pricing(pricing)
@router.post("/pricing")
async def create_model_pricing(
data: ModelPricingCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建模型价格配置"""
# 检查是否已存在
existing = db.query(ModelPricing).filter(ModelPricing.model_name == data.model_name).first()
if existing:
raise HTTPException(status_code=400, detail="该模型价格配置已存在")
pricing = ModelPricing(
model_name=data.model_name,
provider=data.provider,
display_name=data.display_name,
input_price_per_1k=Decimal(str(data.input_price_per_1k)),
output_price_per_1k=Decimal(str(data.output_price_per_1k)),
fixed_price_per_call=Decimal(str(data.fixed_price_per_call)),
pricing_type=data.pricing_type,
description=data.description,
status=1
)
db.add(pricing)
db.commit()
db.refresh(pricing)
return {"success": True, "id": pricing.id}
@router.put("/pricing/{pricing_id}")
async def update_model_pricing(
pricing_id: int,
data: ModelPricingUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新模型价格配置"""
pricing = db.query(ModelPricing).filter(ModelPricing.id == pricing_id).first()
if not pricing:
raise HTTPException(status_code=404, detail="模型价格配置不存在")
update_data = data.model_dump(exclude_unset=True)
# 转换价格字段
for field in ['input_price_per_1k', 'output_price_per_1k', 'fixed_price_per_call']:
if field in update_data and update_data[field] is not None:
update_data[field] = Decimal(str(update_data[field]))
for key, value in update_data.items():
setattr(pricing, key, value)
db.commit()
return {"success": True}
@router.delete("/pricing/{pricing_id}")
async def delete_model_pricing(
pricing_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除模型价格配置"""
pricing = db.query(ModelPricing).filter(ModelPricing.id == pricing_id).first()
if not pricing:
raise HTTPException(status_code=404, detail="模型价格配置不存在")
db.delete(pricing)
db.commit()
return {"success": True}
# ============= Cost Calculation API =============
@router.post("/calculate")
async def calculate_cost(
request: CostCalculateRequest,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""计算调用费用"""
calculator = CostCalculator(db)
cost = calculator.calculate_cost(
model_name=request.model_name,
input_tokens=request.input_tokens,
output_tokens=request.output_tokens
)
return {
"model": request.model_name,
"input_tokens": request.input_tokens,
"output_tokens": request.output_tokens,
"cost": float(cost)
}
@router.get("/summary")
async def get_cost_summary(
tenant_id: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取费用汇总"""
calculator = CostCalculator(db)
return calculator.get_cost_summary(
tenant_id=tenant_id,
start_date=start_date,
end_date=end_date
)
@router.get("/by-tenant")
async def get_cost_by_tenant(
start_date: Optional[str] = None,
end_date: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""按租户统计费用"""
calculator = CostCalculator(db)
return calculator.get_cost_by_tenant(
start_date=start_date,
end_date=end_date
)
@router.get("/by-model")
async def get_cost_by_model(
tenant_id: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""按模型统计费用"""
calculator = CostCalculator(db)
return calculator.get_cost_by_model(
tenant_id=tenant_id,
start_date=start_date,
end_date=end_date
)
# ============= Billing API =============
@router.get("/billing")
async def list_billing(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
tenant_id: Optional[str] = None,
billing_month: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取账单列表"""
query = db.query(TenantBilling)
if tenant_id:
query = query.filter(TenantBilling.tenant_id == tenant_id)
if billing_month:
query = query.filter(TenantBilling.billing_month == billing_month)
total = query.count()
items = query.order_by(desc(TenantBilling.billing_month)).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [format_billing(b) for b in items]
}
@router.post("/billing/generate")
async def generate_billing(
tenant_id: str = Query(...),
billing_month: str = Query(..., description="格式: YYYY-MM"),
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""生成月度账单"""
calculator = CostCalculator(db)
billing = calculator.generate_monthly_billing(tenant_id, billing_month)
return {
"success": True,
"billing": format_billing(billing)
}
@router.post("/recalculate")
async def recalculate_costs(
start_date: Optional[str] = None,
end_date: Optional[str] = None,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""重新计算事件费用"""
calculator = CostCalculator(db)
updated = calculator.update_event_costs(start_date, end_date)
return {
"success": True,
"updated_count": updated
}
# ============= Helper Functions =============
def format_pricing(pricing: ModelPricing) -> dict:
return {
"id": pricing.id,
"model_name": pricing.model_name,
"provider": pricing.provider,
"display_name": pricing.display_name,
"input_price_per_1k": float(pricing.input_price_per_1k or 0),
"output_price_per_1k": float(pricing.output_price_per_1k or 0),
"fixed_price_per_call": float(pricing.fixed_price_per_call or 0),
"pricing_type": pricing.pricing_type,
"description": pricing.description,
"status": pricing.status,
"created_at": pricing.created_at,
"updated_at": pricing.updated_at
}
def format_billing(billing: TenantBilling) -> dict:
return {
"id": billing.id,
"tenant_id": billing.tenant_id,
"billing_month": billing.billing_month,
"total_calls": billing.total_calls,
"total_input_tokens": billing.total_input_tokens,
"total_output_tokens": billing.total_output_tokens,
"total_cost": float(billing.total_cost or 0),
"cost_by_model": billing.cost_by_model,
"cost_by_app": billing.cost_by_app,
"status": billing.status,
"created_at": billing.created_at,
"updated_at": billing.updated_at
}

View File

@@ -1,6 +1,10 @@
"""日志路由"""
import csv
import io
from typing import Optional
from datetime import datetime
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import desc
@@ -13,6 +17,14 @@ from ..services.auth import decode_token
router = APIRouter(prefix="/logs", tags=["logs"])
settings = get_settings()
# 尝试导入openpyxl
try:
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill
OPENPYXL_AVAILABLE = True
except ImportError:
OPENPYXL_AVAILABLE = False
def get_current_user_optional(authorization: Optional[str] = Header(None)):
"""可选的用户认证"""
@@ -113,3 +125,154 @@ async def query_logs(
for item in items
]
}
@router.get("/export")
async def export_logs(
format: str = Query("csv", description="导出格式: csv 或 excel"),
log_type: Optional[str] = None,
level: Optional[str] = None,
app_code: Optional[str] = None,
tenant_id: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
limit: int = Query(10000, ge=1, le=100000, description="最大导出记录数"),
db: Session = Depends(get_db),
user = Depends(get_current_user_optional)
):
"""导出日志
支持CSV和Excel格式最多导出10万条记录
"""
query = db.query(PlatformLog)
if log_type:
query = query.filter(PlatformLog.log_type == log_type)
if level:
query = query.filter(PlatformLog.level == level)
if app_code:
query = query.filter(PlatformLog.app_code == app_code)
if tenant_id:
query = query.filter(PlatformLog.tenant_id == tenant_id)
if start_date:
query = query.filter(PlatformLog.log_time >= start_date)
if end_date:
query = query.filter(PlatformLog.log_time <= end_date + " 23:59:59")
items = query.order_by(desc(PlatformLog.log_time)).limit(limit).all()
if format.lower() == "excel":
return export_excel(items)
else:
return export_csv(items)
def export_csv(logs: list) -> StreamingResponse:
"""导出为CSV格式"""
output = io.StringIO()
writer = csv.writer(output)
# 写入表头
headers = [
"ID", "类型", "级别", "应用", "租户", "Trace ID",
"消息", "路径", "方法", "状态码", "耗时(ms)",
"IP地址", "时间"
]
writer.writerow(headers)
# 写入数据
for log in logs:
writer.writerow([
log.id,
log.log_type,
log.level,
log.app_code or "",
log.tenant_id or "",
log.trace_id or "",
log.message or "",
log.path or "",
log.method or "",
log.status_code or "",
log.duration_ms or "",
log.ip_address or "",
str(log.log_time) if log.log_time else ""
])
output.seek(0)
# 生成文件名
filename = f"logs_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Type": "text/csv; charset=utf-8-sig"
}
)
def export_excel(logs: list) -> StreamingResponse:
"""导出为Excel格式"""
if not OPENPYXL_AVAILABLE:
raise HTTPException(status_code=400, detail="Excel导出功能不可用请安装openpyxl")
wb = Workbook()
ws = wb.active
ws.title = "日志导出"
# 表头样式
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_alignment = Alignment(horizontal="center", vertical="center")
# 写入表头
headers = [
"ID", "类型", "级别", "应用", "租户", "Trace ID",
"消息", "路径", "方法", "状态码", "耗时(ms)",
"IP地址", "时间"
]
for col, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
# 写入数据
for row, log in enumerate(logs, 2):
ws.cell(row=row, column=1, value=log.id)
ws.cell(row=row, column=2, value=log.log_type)
ws.cell(row=row, column=3, value=log.level)
ws.cell(row=row, column=4, value=log.app_code or "")
ws.cell(row=row, column=5, value=log.tenant_id or "")
ws.cell(row=row, column=6, value=log.trace_id or "")
ws.cell(row=row, column=7, value=log.message or "")
ws.cell(row=row, column=8, value=log.path or "")
ws.cell(row=row, column=9, value=log.method or "")
ws.cell(row=row, column=10, value=log.status_code or "")
ws.cell(row=row, column=11, value=log.duration_ms or "")
ws.cell(row=row, column=12, value=log.ip_address or "")
ws.cell(row=row, column=13, value=str(log.log_time) if log.log_time else "")
# 调整列宽
column_widths = [8, 10, 10, 12, 12, 36, 50, 30, 8, 10, 10, 15, 20]
for col, width in enumerate(column_widths, 1):
ws.column_dimensions[chr(64 + col)].width = width
# 保存到内存
output = io.BytesIO()
wb.save(output)
output.seek(0)
# 生成文件名
filename = f"logs_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
return StreamingResponse(
iter([output.getvalue()]),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f'attachment; filename="{filename}"'
}
)

View File

@@ -0,0 +1,264 @@
"""配额管理路由"""
from typing import Optional, Dict, Any
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import desc
from ..database import get_db
from ..models.tenant import Subscription
from ..services.quota import QuotaService
from .auth import get_current_user, require_operator
from ..models.user import User
router = APIRouter(prefix="/quota", tags=["配额管理"])
# ============= Schemas =============
class QuotaConfigUpdate(BaseModel):
daily_calls: int = 0
daily_tokens: int = 0
monthly_calls: int = 0
monthly_tokens: int = 0
monthly_cost: float = 0
concurrent_calls: int = 0
class SubscriptionCreate(BaseModel):
tenant_id: str
app_code: str
start_date: Optional[str] = None
end_date: Optional[str] = None
quota: QuotaConfigUpdate
class SubscriptionUpdate(BaseModel):
start_date: Optional[str] = None
end_date: Optional[str] = None
quota: Optional[QuotaConfigUpdate] = None
status: Optional[str] = None
# ============= Quota Check API =============
@router.get("/check")
async def check_quota(
tenant_id: str = Query(..., alias="tid"),
app_code: str = Query(..., alias="aid"),
estimated_tokens: int = Query(0),
db: Session = Depends(get_db)
):
"""检查配额是否足够
用于调用前检查,返回是否允许继续调用
"""
service = QuotaService(db)
result = service.check_quota(tenant_id, app_code, estimated_tokens)
return {
"allowed": result.allowed,
"reason": result.reason,
"quota_type": result.quota_type,
"limit": result.limit,
"used": result.used,
"remaining": result.remaining
}
@router.get("/summary")
async def get_quota_summary(
tenant_id: str = Query(...),
app_code: str = Query(...),
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取配额使用汇总"""
service = QuotaService(db)
return service.get_quota_summary(tenant_id, app_code)
@router.get("/usage")
async def get_quota_usage(
tenant_id: str = Query(...),
app_code: str = Query(...),
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取配额使用情况"""
service = QuotaService(db)
usage = service.get_usage(tenant_id, app_code)
return {
"daily_calls": usage.daily_calls,
"daily_tokens": usage.daily_tokens,
"monthly_calls": usage.monthly_calls,
"monthly_tokens": usage.monthly_tokens,
"monthly_cost": round(usage.monthly_cost, 2)
}
# ============= Subscription API =============
@router.get("/subscriptions")
async def list_subscriptions(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
tenant_id: Optional[str] = None,
app_code: Optional[str] = None,
status: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取订阅列表"""
query = db.query(Subscription)
if tenant_id:
query = query.filter(Subscription.tenant_id == tenant_id)
if app_code:
query = query.filter(Subscription.app_code == app_code)
if status:
query = query.filter(Subscription.status == status)
total = query.count()
items = query.order_by(desc(Subscription.created_at)).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [format_subscription(s) for s in items]
}
@router.get("/subscriptions/{subscription_id}")
async def get_subscription(
subscription_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取订阅详情"""
subscription = db.query(Subscription).filter(Subscription.id == subscription_id).first()
if not subscription:
raise HTTPException(status_code=404, detail="订阅不存在")
return format_subscription(subscription)
@router.post("/subscriptions")
async def create_subscription(
data: SubscriptionCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建订阅"""
# 检查是否已存在
existing = db.query(Subscription).filter(
Subscription.tenant_id == data.tenant_id,
Subscription.app_code == data.app_code,
Subscription.status == 'active'
).first()
if existing:
raise HTTPException(status_code=400, detail="该租户应用已有活跃订阅")
subscription = Subscription(
tenant_id=data.tenant_id,
app_code=data.app_code,
start_date=data.start_date or date.today(),
end_date=data.end_date,
quota=data.quota.model_dump() if data.quota else {},
status='active'
)
db.add(subscription)
db.commit()
db.refresh(subscription)
return {"success": True, "id": subscription.id}
@router.put("/subscriptions/{subscription_id}")
async def update_subscription(
subscription_id: int,
data: SubscriptionUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新订阅"""
subscription = db.query(Subscription).filter(Subscription.id == subscription_id).first()
if not subscription:
raise HTTPException(status_code=404, detail="订阅不存在")
if data.start_date:
subscription.start_date = data.start_date
if data.end_date:
subscription.end_date = data.end_date
if data.quota:
subscription.quota = data.quota.model_dump()
if data.status:
subscription.status = data.status
db.commit()
# 清除缓存
service = QuotaService(db)
cache_key = f"quota:config:{subscription.tenant_id}:{subscription.app_code}"
service._cache.delete(cache_key)
return {"success": True}
@router.delete("/subscriptions/{subscription_id}")
async def delete_subscription(
subscription_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除订阅"""
subscription = db.query(Subscription).filter(Subscription.id == subscription_id).first()
if not subscription:
raise HTTPException(status_code=404, detail="订阅不存在")
db.delete(subscription)
db.commit()
return {"success": True}
@router.put("/subscriptions/{subscription_id}/quota")
async def update_quota(
subscription_id: int,
data: QuotaConfigUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新订阅配额"""
subscription = db.query(Subscription).filter(Subscription.id == subscription_id).first()
if not subscription:
raise HTTPException(status_code=404, detail="订阅不存在")
subscription.quota = data.model_dump()
db.commit()
# 清除缓存
service = QuotaService(db)
cache_key = f"quota:config:{subscription.tenant_id}:{subscription.app_code}"
service._cache.delete(cache_key)
return {"success": True}
# ============= Helper Functions =============
def format_subscription(subscription: Subscription) -> dict:
return {
"id": subscription.id,
"tenant_id": subscription.tenant_id,
"app_code": subscription.app_code,
"start_date": str(subscription.start_date) if subscription.start_date else None,
"end_date": str(subscription.end_date) if subscription.end_date else None,
"quota": subscription.quota or {},
"status": subscription.status,
"created_at": subscription.created_at,
"updated_at": subscription.updated_at
}

View File

@@ -0,0 +1,264 @@
"""企业微信JS-SDK路由"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.tenant_app import TenantApp
from ..models.tenant_wechat_app import TenantWechatApp
from ..services.wechat import WechatService, get_wechat_service_by_id
router = APIRouter(prefix="/wechat", tags=["企业微信"])
class JssdkSignatureRequest(BaseModel):
"""JS-SDK签名请求"""
url: str # 当前页面URL不含#及其后面部分)
class JssdkSignatureResponse(BaseModel):
"""JS-SDK签名响应"""
appId: str
agentId: str
timestamp: int
nonceStr: str
signature: str
class OAuth2UrlRequest(BaseModel):
"""OAuth2授权URL请求"""
redirect_uri: str
scope: str = "snsapi_base"
state: str = ""
class UserInfoRequest(BaseModel):
"""用户信息请求"""
code: str
@router.post("/jssdk/signature")
async def get_jssdk_signature(
request: JssdkSignatureRequest,
tenant_id: str = Query(..., alias="tid"),
app_code: str = Query(..., alias="aid"),
db: Session = Depends(get_db)
):
"""获取JS-SDK签名
用于前端初始化企业微信JS-SDK
Args:
request: 包含当前页面URL
tenant_id: 租户ID
app_code: 应用代码
Returns:
JS-SDK签名信息
"""
# 查找租户应用配置
tenant_app = db.query(TenantApp).filter(
TenantApp.tenant_id == tenant_id,
TenantApp.app_code == app_code,
TenantApp.status == 1
).first()
if not tenant_app:
raise HTTPException(status_code=404, detail="租户应用配置不存在")
if not tenant_app.wechat_app_id:
raise HTTPException(status_code=400, detail="该应用未配置企业微信")
# 获取企微服务
wechat_service = await get_wechat_service_by_id(tenant_app.wechat_app_id, db)
if not wechat_service:
raise HTTPException(status_code=404, detail="企业微信应用不存在或已禁用")
# 生成签名
signature_data = await wechat_service.get_jssdk_signature(request.url)
if not signature_data:
raise HTTPException(status_code=500, detail="获取JS-SDK签名失败")
return signature_data
@router.get("/jssdk/signature")
async def get_jssdk_signature_get(
url: str = Query(..., description="当前页面URL"),
tenant_id: str = Query(..., alias="tid"),
app_code: str = Query(..., alias="aid"),
db: Session = Depends(get_db)
):
"""获取JS-SDK签名GET方式
方便前端JSONP调用
"""
# 查找租户应用配置
tenant_app = db.query(TenantApp).filter(
TenantApp.tenant_id == tenant_id,
TenantApp.app_code == app_code,
TenantApp.status == 1
).first()
if not tenant_app:
raise HTTPException(status_code=404, detail="租户应用配置不存在")
if not tenant_app.wechat_app_id:
raise HTTPException(status_code=400, detail="该应用未配置企业微信")
# 获取企微服务
wechat_service = await get_wechat_service_by_id(tenant_app.wechat_app_id, db)
if not wechat_service:
raise HTTPException(status_code=404, detail="企业微信应用不存在或已禁用")
# 生成签名
signature_data = await wechat_service.get_jssdk_signature(url)
if not signature_data:
raise HTTPException(status_code=500, detail="获取JS-SDK签名失败")
return signature_data
@router.post("/oauth2/url")
async def get_oauth2_url(
request: OAuth2UrlRequest,
tenant_id: str = Query(..., alias="tid"),
app_code: str = Query(..., alias="aid"),
db: Session = Depends(get_db)
):
"""获取OAuth2授权URL
用于企业微信内网页获取用户身份
"""
# 查找租户应用配置
tenant_app = db.query(TenantApp).filter(
TenantApp.tenant_id == tenant_id,
TenantApp.app_code == app_code,
TenantApp.status == 1
).first()
if not tenant_app:
raise HTTPException(status_code=404, detail="租户应用配置不存在")
if not tenant_app.wechat_app_id:
raise HTTPException(status_code=400, detail="该应用未配置企业微信")
# 获取企微服务
wechat_service = await get_wechat_service_by_id(tenant_app.wechat_app_id, db)
if not wechat_service:
raise HTTPException(status_code=404, detail="企业微信应用不存在或已禁用")
# 生成OAuth2 URL
oauth_url = wechat_service.get_oauth2_url(
redirect_uri=request.redirect_uri,
scope=request.scope,
state=request.state
)
return {"url": oauth_url}
@router.post("/oauth2/userinfo")
async def get_user_info(
request: UserInfoRequest,
tenant_id: str = Query(..., alias="tid"),
app_code: str = Query(..., alias="aid"),
db: Session = Depends(get_db)
):
"""通过OAuth2 code获取用户信息
在OAuth2回调后用code换取用户信息
"""
# 查找租户应用配置
tenant_app = db.query(TenantApp).filter(
TenantApp.tenant_id == tenant_id,
TenantApp.app_code == app_code,
TenantApp.status == 1
).first()
if not tenant_app:
raise HTTPException(status_code=404, detail="租户应用配置不存在")
if not tenant_app.wechat_app_id:
raise HTTPException(status_code=400, detail="该应用未配置企业微信")
# 获取企微服务
wechat_service = await get_wechat_service_by_id(tenant_app.wechat_app_id, db)
if not wechat_service:
raise HTTPException(status_code=404, detail="企业微信应用不存在或已禁用")
# 获取用户信息
user_info = await wechat_service.get_user_info_by_code(request.code)
if not user_info:
raise HTTPException(status_code=400, detail="获取用户信息失败code可能已过期")
return user_info
@router.get("/oauth2/userinfo")
async def get_user_info_get(
code: str = Query(..., description="OAuth2回调的code"),
tenant_id: str = Query(..., alias="tid"),
app_code: str = Query(..., alias="aid"),
db: Session = Depends(get_db)
):
"""通过OAuth2 code获取用户信息GET方式"""
# 查找租户应用配置
tenant_app = db.query(TenantApp).filter(
TenantApp.tenant_id == tenant_id,
TenantApp.app_code == app_code,
TenantApp.status == 1
).first()
if not tenant_app:
raise HTTPException(status_code=404, detail="租户应用配置不存在")
if not tenant_app.wechat_app_id:
raise HTTPException(status_code=400, detail="该应用未配置企业微信")
# 获取企微服务
wechat_service = await get_wechat_service_by_id(tenant_app.wechat_app_id, db)
if not wechat_service:
raise HTTPException(status_code=404, detail="企业微信应用不存在或已禁用")
# 获取用户信息
user_info = await wechat_service.get_user_info_by_code(code)
if not user_info:
raise HTTPException(status_code=400, detail="获取用户信息失败code可能已过期")
return user_info
@router.get("/user/{user_id}")
async def get_user_detail(
user_id: str,
tenant_id: str = Query(..., alias="tid"),
app_code: str = Query(..., alias="aid"),
db: Session = Depends(get_db)
):
"""获取企业微信成员详细信息"""
# 查找租户应用配置
tenant_app = db.query(TenantApp).filter(
TenantApp.tenant_id == tenant_id,
TenantApp.app_code == app_code,
TenantApp.status == 1
).first()
if not tenant_app:
raise HTTPException(status_code=404, detail="租户应用配置不存在")
if not tenant_app.wechat_app_id:
raise HTTPException(status_code=400, detail="该应用未配置企业微信")
# 获取企微服务
wechat_service = await get_wechat_service_by_id(tenant_app.wechat_app_id, db)
if not wechat_service:
raise HTTPException(status_code=404, detail="企业微信应用不存在或已禁用")
# 获取用户详情
user_detail = await wechat_service.get_user_detail(user_id)
if not user_detail:
raise HTTPException(status_code=404, detail="用户不存在")
return user_detail