Files
myaps_api/apps/data_opt/routers.py
T
chaoge 78269d8d74 新增: binlog监听器高可用增强模块
实现功能:
- Prometheus指标暴露(/metrics端点)
- 背压控制(主动限流机制)
- 事件去重(Redis+文件降级)
- 配置热更新(审计日志)
- 主备故障转移(心跳检测)
- 分布式锁安全降级
- 连接池监控(泄漏检测)
- 重试策略优化(指数退避)

新增接口:
- GET /metrics, /health, /binlog/status
- GET /binlog/backpressure/status
- POST /binlog/config/update, GET /binlog/config/audit
- GET /binlog/dedup/stats
- GET /binlog/failover/status

测试覆盖:
- 单元测试71个,全部通过
- 压测验证:吞吐量499事件/秒,达标率99.91%
2026-05-22 07:08:49 +08:00

575 lines
20 KiB
Python

# from datetime import datetime
import os#, importlib#, uuid
from pathlib import Path
from typing import Optional#, Dict, Any
# from datetime import datetime
import pandas as pd
from fastapi import APIRouter, Query, Body, Header, File, UploadFile#, HTTPException
from fastapi.responses import HTMLResponse#, StreamingResponse
from core.settings import BASE_DIR
from project_files import project_client, hap_conn
# from .schemas import SupplyOperationBody, SupplyAction
# from apps.io_api.models import TSupply
from .utils.barcode_qrcode_generator import generate_qrcode, generate_barcode
from apps.io_api.utils.common import standard_response
from apps.data_opt.utils.bomchecker import BOMChecker
from apps.data_opt.utils.routechecker import RouteChecker
# 创建路由器实例
rt = APIRouter()
@rt.post("/generate/qrcode",
tags=["数据操作 - 二维码生成"],
summary="生成二维码",
description="生成二维码并返回BASE64格式数据"
)
async def generate_qrcode_api(
content: str = Body(..., description="二维码内容"),
version: Optional[int] = Body(1, ge=1, le=40, description="二维码版本,1-40"),
box_size: Optional[int] = Body(10, ge=1, description="二维码每个小方格的像素大小"),
border: Optional[int] = Body(4, ge=1, description="二维码边框的小方格数"),
error_correction: Optional[str] = Body("H", pattern="^(L|M|Q|H)$", description="纠错级别:L(7%), M(15%), Q(25%), H(30%)"),
back_color: Optional[str] = Body("#FFFFFF", pattern="^#[0-9A-Fa-f]{6}$", description="背景颜色,十六进制颜色码"),
fill_color: Optional[str] = Body("#000000", pattern="^#[0-9A-Fa-f]{6}$", description="填充颜色,十六进制颜色码"),
image_format: Optional[str] = Body("SVG", pattern="^(PNG|JPEG|GIF|SVG)$", description="图片格式"),
show_content: Optional[bool] = Body(True, description="是否在图片底部显示原字符串内容"),
content_font_size: Optional[int] = Body(12, ge=8, description="内容文字大小"),
x_api_key: str = Header(None, description="API密钥")
):
try:
result = generate_qrcode(
content=content,
version=version,
box_size=box_size,
border=border,
error_correction=error_correction,
back_color=back_color,
fill_color=fill_color,
image_format=image_format,
show_content=show_content,
content_font_size=content_font_size,
output_type="BASE64"
)
return standard_response(
data={
"base64": result["base64"],
"content": content,
"image_format": image_format
}
)
except Exception as e:
return standard_response(
status_code=500,
success=0,
message=f"执行失败: {str(e)}"
)
# ========== Binlog Listener HA Enhancement ==========
# 以下接口为 Binlog 监听器高可用增强模块新增
# 与现有监控模块 (apps.common.monitor) 接口互不影响,向后兼容
from apps.data_opt.utils.binlog_ha import (
prometheus_metrics,
health_checker,
HealthResponse,
backpressure_controller,
event_deduplicator,
config_manager,
failover_manager,
)
@rt.get("/metrics",
tags=["Binlog HA - 监控指标"],
summary="Prometheus 指标暴露",
description="返回 Prometheus 格式的监控指标,支持 Counter、Gauge、Histogram 类型"
)
async def prometheus_metrics_endpoint():
"""
Prometheus 指标暴露端点
指标类型:
- binlog_events_processed_total: 已处理事件总数 (Counter)
- binlog_queue_size: 当前队列大小 (Gauge)
- binlog_processing_delay_seconds: 处理延迟分布 (Histogram)
- binlog_listener_role: 监听器角色 (Gauge: 1=master, 2=slave, 3=standalone)
"""
return await prometheus_metrics.expose_endpoint()
@rt.get("/health",
tags=["Binlog HA - 健康检查"],
summary="增强健康检查",
description="返回全面的健康检查结果,包含 MySQL、Redis、Binlog 位置、背压状态等",
response_model=HealthResponse
)
async def health_check_endpoint():
"""
增强健康检查端点
检查项:
- mysql_connection: MySQL 连接状态
- redis_connection: Redis 连接状态
- binlog_position: Binlog 位置同步状态
- listener_role: 监听器角色状态
- backpressure: 背压状态
- event_loop: 事件循环状态
- connection_pool: 连接池状态
响应状态:
- healthy: 所有检查项通过
- degraded: 存在警告项
- unhealthy: 存在失败项
"""
return await health_checker.check_all()
@rt.get("/binlog/status",
tags=["Binlog HA - 状态查询"],
summary="Binlog 监听器状态查询(增强)",
description="返回监听器详细状态,包含角色、背压、故障转移等信息"
)
async def binlog_status_endpoint():
"""
Binlog 监听器状态查询(增强版)
说明:
- 此接口为新增接口,与现有 /monitor/binlog-listener 接口并存
- /monitor/binlog-listener 保持原有实现,向后兼容
- 本接口提供增强的状态信息
返回字段:
- 基础状态:is_running, connection_status, current_position
- 性能指标:events_processed, queue_size
- 高可用信息:role, failover_count, backpressure
"""
from apps.data_opt.utils.binlog_listener import binlog_listener
try:
status = binlog_listener.get_status()
return {
"success": True,
"data": {
"is_running": status.get("running", False),
"connection_status": "connected" if status.get("healthy") else "disconnected",
"current_position": status.get("current_position"),
"events_processed": status.get("pending_events", 0),
"role": status.get("role", "standalone"),
"failover_count": status.get("failover_count", 0),
"backpressure": {
"state": "normal",
"queue_size": status.get("pending_events", 0),
"throttle_count": 0,
"threshold": status.get("backpressure_threshold", 10000),
"percent": status.get("backpressure_percent", 0),
},
"event_loop_healthy": status.get("event_loop_healthy", None),
"consecutive_errors": status.get("consecutive_errors", 0),
}
}
except Exception as e:
return {
"success": False,
"error": str(e),
"data": None
}
@rt.get("/binlog/backpressure/status",
tags=["Binlog HA - 背压控制"],
summary="背压状态查询",
description="返回当前背压状态、队列指标、限流统计"
)
async def backpressure_status_endpoint():
"""
背压状态查询端点
返回字段:
- state: 背压状态 (normal/warning/critical)
- queue_size: 当前队列大小
- queue_capacity: 队列容量(限流阈值)
- processing_delay_avg: 平均处理延迟
- processing_delay_max: 最大处理延迟
- throttle_count: 限流次数累计
- throttle_duration_total: 限流总时长
"""
try:
metrics = backpressure_controller.get_queue_metrics()
state = backpressure_controller.get_state()
return {
"success": True,
"data": {
"state": state.value,
"queue_size": metrics.current_size,
"queue_capacity": backpressure_controller.limit_threshold,
"processing_delay_avg": metrics.avg_delay,
"processing_delay_max": metrics.max_delay,
"throttle_count": metrics.throttle_count,
"throttle_duration_total": metrics.throttle_duration_total,
"warning_threshold": backpressure_controller.warning_threshold,
"limit_threshold": backpressure_controller.limit_threshold,
}
}
except Exception as e:
return {
"success": False,
"error": str(e),
"data": None
}
@rt.post("/binlog/config/update",
tags=["Binlog HA - 配置管理"],
summary="配置热更新",
description="更新 Binlog 监听器配置,支持热更新和需重启项区分"
)
async def config_update_endpoint(
config: dict = Body(..., description="配置项字典"),
operator: str = Body(..., description="操作者"),
reason: str = Body(None, description="操作原因")
):
"""
配置热更新端点
热更新配置项(立即生效):
- max_retry_attempts: 最大重试次数
- base_retry_delay_seconds: 基础重试延迟
- heartbeat_interval_seconds: 心跳间隔
- backpressure_warning_threshold: 背压告警阈值
- backpressure_limit_threshold: 背压限流阈值
- dedup_ttl_hours: 去重TTL
需重启配置项:
- turnon_binlog_listener: 监听器开关
- enable_binlog_position: 位置持久化开关
- redis_host, redis_port: Redis连接配置
返回字段:
- applied: 已应用的配置项
- requires_restart: 需重启才能生效的配置项
- audit_id: 审计ID
"""
result = config_manager.apply_config(config, operator, reason)
return result
@rt.get("/binlog/config/audit",
tags=["Binlog HA - 配置管理"],
summary="获取审计日志",
description="返回配置变更审计日志"
)
async def config_audit_endpoint(
limit: int = Query(100, ge=1, le=1000, description="返回条数限制")
):
"""
审计日志查询端点
返回字段:
- audit_id: 审计ID
- timestamp: 操作时间
- operator: 操作者
- action: 操作类型
- changes: 变更内容
- result: 操作结果
- reason: 操作原因
"""
entries = config_manager.get_audit_log(limit)
return {
"success": True,
"data": [entry.model_dump() for entry in entries],
"total_count": len(entries)
}
@rt.get("/binlog/dedup/stats",
tags=["Binlog HA - 事件去重"],
summary="去重统计信息",
description="返回事件去重统计信息"
)
async def dedup_stats_endpoint():
"""
去重统计信息端点
返回字段:
- total_checked: 检查总数
- total_duplicates: 重复事件数
- duplicate_rate: 重复率 (%)
- ttl_hours: TTL时长
- use_redis: 是否使用Redis
"""
stats = event_deduplicator.get_stats()
return {
"success": True,
"data": stats
}
@rt.get("/binlog/failover/status",
tags=["Binlog HA - 主备故障转移"],
summary="主备状态查询",
description="返回主备角色、心跳信息、故障转移统计"
)
async def failover_status_endpoint():
"""
主备状态查询端点
返回字段:
- role: 当前角色 (master/slave/standalone)
- master_info: 主节点信息(备节点视角)
- failover_count: 故障转移次数
- last_failover_time: 上次故障转移时间
"""
try:
role = failover_manager.get_role()
failover_count = failover_manager.get_failover_count()
master_info = None
if role.value == "slave":
master_info = failover_manager.get_master_info()
return {
"success": True,
"data": {
"role": role.value,
"master_info": master_info,
"failover_count": failover_count,
"last_failover_time": failover_manager._promoted_time,
"heartbeat_interval": failover_manager.heartbeat_interval,
"heartbeat_timeout": failover_manager.heartbeat_timeout,
}
}
except Exception as e:
return {
"success": False,
"error": str(e),
"data": None
}
@rt.post("/generate/barcode",
tags=["数据操作 - 条形码生成"],
summary="生成条形码",
description="生成条形码并返回BASE64格式数据"
)
async def generate_barcode_api(
content: str = Body(..., description="条形码内容"),
barcode_type: Optional[str] = Body("code128", description="条形码类型"),
width: Optional[int] = Body(500, ge=50, description="条形码宽度(像素)", examples=[200]),
height: Optional[int] = Body(150, ge=30, description="条形码高度(像素)", examples=[100]),
margin: Optional[int] = Body(20, ge=0, description="条形码边距(像素)", examples=[10]),
font_size: Optional[int] = Body(14, ge=6, description="条形码文字大小", examples=[10]),
add_text: Optional[bool] = Body(True, description="是否在条形码下方添加文字"),
fill_color: Optional[str] = Body("#000000", pattern="^#[0-9A-Fa-f]{6}$", description="条形码颜色"),
back_color: Optional[str] = Body("#FFFFFF", pattern="^#[0-9A-Fa-f]{6}$", description="条形码背景颜色"),
image_format: Optional[str] = Body("SVG", pattern="^(PNG|JPEG|GIF|SVG)$", description="图片格式"),
show_content: Optional[bool] = Body(True, description="是否在图片底部显示原字符串内容"),
content_font_size: Optional[int] = Body(20, ge=8, description="内容文字大小"),
x_api_key: str = Header(None, description="API密钥")
):
try:
result = generate_barcode(
content=content,
barcode_type=barcode_type,
width=width,
height=height,
margin=margin,
font_size=font_size,
add_text=add_text,
fill_color=fill_color,
back_color=back_color,
image_format=image_format,
show_content=show_content,
content_font_size=content_font_size,
output_type="BASE64"
)
return standard_response(
data={
"base64": result["base64"],
"content": content,
"barcode_type": barcode_type,
"image_format": image_format
}
)
except Exception as e:
return standard_response(
status_code=500,
success=0,
message=f"条形码生成失败: {str(e)}"
)
@rt.post("/check/bom",
tags=["数据操作 - 校验BOM"],
summary="校验BOM",
description="校验传入的 BOM excel,结果输出至 新的 excel 文件"
)
async def check_bom_excel(
file: UploadFile = File(..., description="BOM excel 文件,必须为 xlsx 格式"),
parentversion_col: str = Query(None, example="MatVer", description="版本号"),
parent_col: str = Query(..., example="ProductNo", description="父料号"),
child_col: str = Query(..., example="MaterialNo", description="子料号"),
numerator_col: str = Query(..., example="Qty", description="数量"),
denominator_col: str = Query(None, example=None, description="分母"),
parentunit_col: str = Query(None, example=None, description="父单位"),
childunit_col: str = Query(None, example=None, description="子单位"),
x_api_key: str = Header(None, description="API密钥")
):
try:
# 验证文件格式是否为 xlsx
if not file.filename.lower().endswith('.xlsx'):
return standard_response(
status_code=400,
success=0,
message="文件格式错误:请上传 xlsx 格式的 Excel 文件"
)
bom_df = pd.read_excel(file.file)
checker = BOMChecker(
numerator_col=numerator_col,
denominator_col=denominator_col,
parent_col=parent_col,
child_col=child_col,
parentversion_col=parentversion_col,
parentunit_col=parentunit_col,
childunit_col=childunit_col,
# dtofield_mapper=dtofield_mapper,
)
checker.start_check(bom_df)
return checker.export_results_as_excel()
except Exception as e:
return standard_response(
status_code=500,
success=0,
message=f"执行失败: {str(e)}"
)
@rt.get("/tools", tags=["数据操作 - 校验工具页面"])
async def bom_check_page():
"""
校验工具页面
提供用户友好的Web界面来上传Excel文件并进行BOM和工序数据校验
"""
html_path = os.path.join(BASE_DIR, "static", "tools.html")
if os.path.exists(html_path):
html_content = open(html_path, 'r', encoding='utf-8').read()
status_code = 200
else:
html_content = """
<html>
<body>
<h1>BOM校验页面未找到</h1>
<p>请确保static/tools.html文件存在</p>
<a href="/">返回首页</a>
</body>
</html>
"""
status_code = 404
return HTMLResponse(content=html_content, status_code=status_code)
@rt.get("/check/bom",
tags=["数据操作 - 校验BOM"],
summary="获取校验BOM结果",
description="校验三方系统的BOM,结果输出至 excel 文件 或 HAP"
)
async def get_bom_check_result_api(
output_method: str = Query(..., example="EXCEL", enum=["EXCEL", "HAP"], description="输出方式"),
x_api_key: str = Header(None, description="API密钥")
):
try:
bom_json_data = await project_client.ScheduleTasks.get_bom()
mainfield_mapper = {
"id": None,
"pn": "matnr", # 产品料号
"pu": "bmein", # 产品单位
"cn": "idnrk", # 物料料号
"cu": "meins", # 物料单位
"n": "menge", # 数量
"d": "bmeng", # 分母
"pv": "stlal" # 产品版本号
}
dtofield_mapper = {
"productno": "matnr",
"materialno": "idnrk",
"matver": "stlal",
}
checker = BOMChecker(
mainfield_mapper=mainfield_mapper,
dtofield_mapper=dtofield_mapper,
)
checker.start_check(bom_json_data)
output_method = output_method.strip().upper()
if output_method == "EXCEL":
return checker.export_results_as_excel()
elif output_method == "HAP":
if hap_conn is None:
return standard_response(
status_code=500,
success=0,
message="HAP 配置未完成,无法连接"
)
return standard_response(**checker.output_results_to_hap(hap_conn))
except Exception as e:
return standard_response(
status_code=500,
success=0,
message=f"BOM校验失败: {str(e)}"
)
@rt.post("/check/route",
tags=["数据操作 - 校验工艺路线"],
summary="校验工艺路线问题",
description="校验传入的 工艺路线 excel,返回问题列表"
)
def check_route_excel(
file: UploadFile = File(..., description="工艺路线 excel 文件,必须为 xlsx 格式"),
product_col: str = Query(..., example="MaterialNo", description="产品料号"),
productversion_col: str = Query(None, example="MatVer", description="产品版本号"),
sortno_col: str = Query(..., example="SortNo", description="顺序号"),
itemno_col: str = Query(..., example="ItemNo", description="工序项"),
workcenter_col: str = Query(None, example="WorkCenter", description="工作中心"),
x_api_key: str = Header(None, description="API密钥")
):
try:
# 验证文件格式是否为 xlsx
if not file.filename.lower().endswith('.xlsx'):
return standard_response(
status_code=400,
success=0,
message="文件格式错误:请上传 xlsx 格式的 Excel 文件"
)
route_df = pd.read_excel(file.file)
checker = RouteChecker(
product_col=product_col,
productversion_col=productversion_col,
sortno_col=sortno_col,
itemno_col=itemno_col,
workcenter_col=workcenter_col,
)
checker.start_check(route_df)
return checker.export_results_as_excel()
except Exception as e:
return standard_response(
status_code=500,
success=0,
message=f"执行失败: {str(e)}"
)