ShiErFeng/shierfeng-fastapi-backend/cli/tui/adapters/configs.py
2026-07-24 09:43:10 +08:00

761 lines
29 KiB
Python

from typing import Any
from cli.tui.adapters.base import BaseBrowserAdapter
from cli.tui.adapters.models import (
TUI_ADAPTER_MODEL_RENDERER,
BrowserPageSnapshot,
BrowserRecordSnapshot,
DetailSectionSnapshot,
)
from cli.tui.copy import TUI_COPY
from cli.tui.diagnostics import TUI_DIAGNOSTIC_SERVICE
from cli.tui.search import CONFIG_FILTER_OPTIONS
from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER
CONFIG_RISK_PRIORITY = {
'mismatch': 0,
'missing-cache': 1,
'orphan-cache': 2,
'normal': 3,
}
CONFIG_RISK_LABELS = {
'mismatch': '值不一致',
'missing-cache': '缓存缺失',
'orphan-cache': '缓存孤立',
'normal': '正常',
}
CONFIG_RISK_STATUSES = {
'mismatch': 'fail',
'missing-cache': 'warn',
'orphan-cache': 'warn',
'normal': 'ok',
}
class ConfigRiskSupport:
"""
参数配置风险分类支持对象。
该对象负责从巡检结果提取风险集合、解析单项风险分类,以及执行
风险排序和筛选逻辑,供浏览页分区和记录构建复用。
"""
@staticmethod
def extract_config_issue_keys(payload: dict[str, Any] | None, field_name: str) -> set[str]:
"""
从配置巡检结果中提取指定风险字段对应的配置键集合。
:param payload: `config doctor` JSON 负载
:param field_name: 风险字段名
:return: 配置键集合
"""
items = payload.get(field_name) if isinstance(payload, dict) else None
if not isinstance(items, list):
return set()
return {item.strip() for item in items if isinstance(item, str) and item.strip()}
def build_config_risk_sets(self, payload: dict[str, Any] | None) -> dict[str, set[str]]:
"""
构建配置风险分类集合。
:param payload: `config doctor` JSON 负载
:return: 按风险分类聚合的配置键集合
"""
return {
'mismatch': self.extract_config_issue_keys(payload, 'mismatchKeys'),
'missing-cache': self.extract_config_issue_keys(payload, 'missingInCache'),
'orphan-cache': self.extract_config_issue_keys(payload, 'orphanInCache'),
}
@staticmethod
def resolve_config_risk_key(config_key: str, risk_sets: dict[str, set[str]]) -> str:
"""
解析单个配置项的风险分类。
:param config_key: 配置键
:param risk_sets: 风险集合
:return: 风险分类键
"""
normalized_key = str(config_key).strip()
if normalized_key in risk_sets.get('mismatch', set()):
return 'mismatch'
if normalized_key in risk_sets.get('missing-cache', set()):
return 'missing-cache'
if normalized_key in risk_sets.get('orphan-cache', set()):
return 'orphan-cache'
return 'normal'
def build_config_sort_key(
self,
config_row: dict[str, Any],
risk_sets: dict[str, set[str]],
) -> tuple[int, str]:
"""
构建配置列表排序键。
:param config_row: 配置项行数据
:param risk_sets: 风险集合
:return: 排序键
"""
config_key = str(config_row.get('configKey', '') or '').strip()
risk_key = self.resolve_config_risk_key(config_key, risk_sets)
return (CONFIG_RISK_PRIORITY.get(risk_key, 99), config_key)
def apply_config_filter(
self,
rows: list[dict[str, Any]],
risk_sets: dict[str, set[str]],
filter_key: str,
) -> list[dict[str, Any]]:
"""
按筛选键过滤配置行数据。
:param rows: 原始配置行列表
:param risk_sets: 风险集合
:param filter_key: 当前筛选键
:return: 过滤后的配置行列表
"""
normalized_filter = str(filter_key).strip().lower()
if normalized_filter == 'risky':
return [
row
for row in rows
if self.resolve_config_risk_key(str(row.get('configKey', '') or ''), risk_sets) != 'normal'
]
if normalized_filter == 'mismatch':
return [
row
for row in rows
if self.resolve_config_risk_key(str(row.get('configKey', '') or ''), risk_sets) == 'mismatch'
]
if normalized_filter == 'cache-drift':
return [
row
for row in rows
if self.resolve_config_risk_key(str(row.get('configKey', '') or ''), risk_sets)
in {'missing-cache', 'orphan-cache'}
]
return rows
@staticmethod
def apply_config_query(rows: list[dict[str, Any]], query: str) -> list[dict[str, Any]]:
"""
按配置键或配置名称查询词过滤配置行数据。
:param rows: 原始配置行列表
:param query: 当前搜索词
:return: 过滤后的配置行列表
"""
normalized_query = str(query).strip().lower()
if not normalized_query:
return rows
return [
row
for row in rows
if normalized_query in str(row.get('configKey', '') or '').strip().lower()
or normalized_query in str(row.get('configName', '') or '').strip().lower()
]
@staticmethod
def append_orphan_cache_rows(rows: list[dict[str, Any]], orphan_keys: set[str]) -> list[dict[str, Any]]:
"""
将仅存在于缓存侧的孤立配置补充为可浏览记录。
:param rows: 原始数据库配置行
:param orphan_keys: 缓存孤立配置键集合
:return: 补齐后的配置行列表
"""
merged_rows = list(rows)
existing_keys = {str(row.get('configKey', '') or '').strip() for row in rows}
merged_rows.extend(
{
'configId': '-',
'configKey': orphan_key,
'configName': '缓存孤立配置',
'configType': '-',
'configValue': '缓存侧残留,建议排查来源',
}
for orphan_key in sorted(orphan_keys - existing_keys)
)
return merged_rows
class ConfigSectionBuilder:
"""
参数配置浏览页分区构建器。
该构建器负责构建参数配置页共享分区,以及单个配置项详情分区。
:param page_adapter: 参数配置浏览页适配器
:param risk_support: 参数配置风险分类支持对象
"""
def __init__(
self,
page_adapter: BaseBrowserAdapter,
risk_support: ConfigRiskSupport,
) -> None:
"""
初始化参数配置浏览页分区构建器。
:param page_adapter: 参数配置浏览页适配器
:param risk_support: 参数配置风险分类支持对象
:return: None
"""
self.page_adapter = page_adapter
self.risk_support = risk_support
def build_config_doctor_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建参数配置巡检共享分区。
:param payload: `config doctor` JSON 负载
:return: 分区快照
"""
if not isinstance(payload, dict) or not payload.get('ok', False):
return DetailSectionSnapshot(
title='配置巡检',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='巡检结果', empty_value='不可用'
),
)
return DetailSectionSnapshot(
title='配置巡检',
status='ok',
lines=[
'## 一致性状态',
f'数据库配置: {payload.get("databaseCount", "-")}',
f'缓存同步配置: {payload.get("cacheCount", "-")}',
f'缓存缺失: {payload.get("missingInCacheCount", "-")}',
f'缓存孤立: {payload.get("orphanInCacheCount", "-")}',
f'值不一致: {payload.get("mismatchCount", "-")}',
],
)
def build_config_issue_samples_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建参数配置不一致样本共享分区。
:param payload: `config doctor` JSON 负载
:return: 分区快照
"""
if not isinstance(payload, dict) or not payload.get('ok', False):
return DetailSectionSnapshot(
title='异常样本',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(payload, empty_label='样本', empty_value='不可用'),
)
lines: list[str] = []
missing_in_cache = payload.get('missingInCache')
orphan_in_cache = payload.get('orphanInCache')
mismatch_keys = payload.get('mismatchKeys')
if isinstance(missing_in_cache, list) and missing_in_cache:
lines.extend(
f'缓存缺失示例:{SHELL_TEXT_FORMATTER.truncate_text(item, 64)}'
for item in missing_in_cache[:5]
if isinstance(item, str)
)
if isinstance(orphan_in_cache, list) and orphan_in_cache:
lines.extend(
f'缓存孤立示例:{SHELL_TEXT_FORMATTER.truncate_text(item, 64)}'
for item in orphan_in_cache[:5]
if isinstance(item, str)
)
if isinstance(mismatch_keys, list) and mismatch_keys:
lines.extend(
f'值不一致示例:{SHELL_TEXT_FORMATTER.truncate_text(item, 64)}'
for item in mismatch_keys[:5]
if isinstance(item, str)
)
issue_count = (
int(payload.get('missingInCacheCount', 0))
+ int(payload.get('orphanInCacheCount', 0))
+ int(payload.get('mismatchCount', 0))
)
return DetailSectionSnapshot(
title='异常样本',
status='fail' if issue_count > 0 else 'ok',
lines=lines
if lines
else TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
empty_label='异常样本',
empty_value='0 条',
detail='当前未发现配置异常样本',
),
)
def build_config_set_entry_section(self, rows: list[dict[str, Any]]) -> DetailSectionSnapshot:
"""
构建配置变更入口共享分区。
:param rows: 当前配置行列表
:return: 分区快照
"""
sample_key = ''
sample_value = ''
for row in rows:
config_key = str(row.get('configKey', '') or '').strip()
config_value = str(row.get('configValue', '') or '').strip()
if config_key:
sample_key = config_key
sample_value = config_value or 'new-value'
break
if not sample_key:
sample_key = 'demo.config.key'
sample_value = 'new-value'
return DetailSectionSnapshot(
title='配置变更入口',
status='info',
lines=TUI_COPY.build_command_hint_lines(
scenario='准备修复高风险配置、回填缓存缺失项或校正数据库与缓存值不一致时,应先在终端中明确目标键和值,再执行配置变更命令。',
command=TUI_COPY.build_cli_command_hint('config', 'set', sample_key, sample_value, '--output=text'),
guide='建议先在当前页面核对配置键、来源和现值;变更完成后再回到工作台执行参数缓存刷新,确认巡检结果恢复正常。',
),
)
def build_high_risk_config_section(
self,
rows: list[dict[str, Any]],
risk_sets: dict[str, set[str]],
) -> DetailSectionSnapshot:
"""
构建高风险配置共享分区。
:param rows: 配置行列表
:param risk_sets: 风险集合
:return: 分区快照
"""
risky_rows = [
row
for row in sorted(
rows, key=lambda config_row: self.risk_support.build_config_sort_key(config_row, risk_sets)
)
if self.risk_support.resolve_config_risk_key(str(row.get('configKey', '') or ''), risk_sets) != 'normal'
]
if not risky_rows:
return DetailSectionSnapshot(
title='高风险配置',
status='ok',
lines=TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
empty_label='高风险配置',
empty_value='0 项',
detail='当前巡检结果中没有需要优先处理的高风险配置',
),
)
lines: list[str] = []
for row in risky_rows[:8]:
config_key = str(row.get('configKey', '-') or '-')
config_name = str(row.get('configName', '-') or '-')
risk_key = self.risk_support.resolve_config_risk_key(config_key, risk_sets)
lines.append(
f'[{CONFIG_RISK_LABELS.get(risk_key, "风险")}] '
f'{SHELL_TEXT_FORMATTER.truncate_text(config_key, 40)} · {SHELL_TEXT_FORMATTER.truncate_text(config_name, 20)}'
)
return DetailSectionSnapshot(
title='高风险配置',
status='warn' if risky_rows else 'ok',
lines=lines,
)
def build_configs_overview_section(
self,
rows: list[dict[str, Any]],
filtered_rows: list[dict[str, Any]],
risk_sets: dict[str, set[str]],
filter_label: str,
) -> DetailSectionSnapshot:
"""
构建参数配置页总览判断共享分区。
:param rows: 原始配置行列表
:param filtered_rows: 当前筛选后的配置行列表
:param risk_sets: 风险集合
:param filter_label: 当前筛选标签
:return: 分区快照
"""
mismatch_count = len(risk_sets.get('mismatch', set()))
drift_count = len(risk_sets.get('missing-cache', set())) + len(risk_sets.get('orphan-cache', set()))
risky_count = mismatch_count + drift_count
status = 'ok'
conclusion = '当前配置一致性正常,可继续抽查单项详情与同步状态'
if mismatch_count > 0:
status = 'warn'
conclusion = '存在值不一致配置,优先核对数据库与缓存是否同步'
elif drift_count > 0:
status = 'warn'
conclusion = '存在缓存漂移配置,建议优先确认缺失项与孤立项来源'
return DetailSectionSnapshot(
title='总览判断',
status=status,
lines=[
'## 当前结论',
conclusion,
'',
'## 核心指标',
f'当前筛选: {filter_label}',
f'已加载配置: {len(rows)}',
f'当前匹配: {len(filtered_rows)}',
f'高风险配置: {risky_count}',
f'值不一致: {mismatch_count}',
f'缓存漂移: {drift_count}',
'',
'## 建议入口',
'优先关注:高风险配置 / 值不一致 / 缓存漂移',
],
)
def build_config_consistency_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建单个配置的一致性分区。
:param payload: `config get` JSON 负载
:return: 分区快照
"""
if not isinstance(payload, dict) or not payload.get('ok', False):
return DetailSectionSnapshot(
title='同步状态',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='同步状态', empty_value='不可用'
),
)
lines = [
'## 当前配置',
f'键名: {payload.get("key", "-")}',
f'读取来源: {payload.get("source", "-")}',
]
source = str(payload.get('source', '-') or '-').strip().lower()
section_status = 'ok'
if source == 'both':
lines.append(f'数据库与缓存一致: {"" if payload.get("inSync", False) else ""}')
if not payload.get('inSync', False):
section_status = 'fail'
elif source == 'database':
lines.append('缓存状态: 缺失')
section_status = 'warn'
elif source == 'cache':
lines.append('数据库状态: 缺失')
section_status = 'warn'
return DetailSectionSnapshot(
title='同步状态',
status=section_status,
lines=lines,
)
def build_config_source_section(
self,
title: str,
config_item: dict[str, Any] | None,
*,
missing_text: str,
) -> DetailSectionSnapshot:
"""
构建配置来源详情分区。
:param title: 分区标题
:param config_item: 配置来源详情
:param missing_text: 缺失时提示
:return: 分区快照
"""
if not isinstance(config_item, dict):
return DetailSectionSnapshot(
title=title,
status='info',
lines=TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
empty_label=title,
empty_value='未找到',
detail=missing_text,
),
)
return DetailSectionSnapshot(
title=title,
status='ok',
lines=[
'## 基础信息',
f'配置 ID: {config_item.get("configId", "-")}',
f'键名: {SHELL_TEXT_FORMATTER.truncate_text(config_item.get("configKey", "-"), 48)}',
f'名称: {SHELL_TEXT_FORMATTER.truncate_text(config_item.get("configName", "-"), 32)}',
f'键值: {SHELL_TEXT_FORMATTER.truncate_text(config_item.get("configValue", "-"), 72)}',
f'内置标记: {config_item.get("configType", "-")}',
'',
'## 备注',
SHELL_TEXT_FORMATTER.truncate_text(config_item.get('remark', '-') or '-', 88),
],
)
def load_config_detail_sections(
self,
config_row: dict[str, Any],
env: str,
) -> list[DetailSectionSnapshot]:
"""
按需加载单个配置项详情分区。
:param config_row: 配置项行数据
:param env: 当前运行环境
:return: 详情分区列表
"""
config_key = str(config_row.get('configKey', '-') or '-')
detail_payload = NESTED_CLI_SUPPORT.run(
'config',
'get',
config_key,
f'--env={env}',
'--source=both',
'--output=json',
parse_json=True,
).payload
database_payload = detail_payload.get('database') if isinstance(detail_payload, dict) else None
cache_payload = detail_payload.get('cache') if isinstance(detail_payload, dict) else None
return [
self.build_config_consistency_section(detail_payload),
self.build_config_source_section('数据库配置', database_payload, missing_text='数据库中未找到该配置项'),
self.build_config_source_section('缓存配置', cache_payload, missing_text='缓存中未找到该配置项'),
]
class ConfigRecordBuilder:
"""
参数配置浏览记录构建器。
该构建器负责构建配置浏览记录与失败兜底记录。
:param page_adapter: 参数配置浏览页适配器
:param section_builder: 参数配置浏览页分区构建器
"""
def __init__(
self,
page_adapter: BaseBrowserAdapter,
section_builder: ConfigSectionBuilder,
) -> None:
"""
初始化参数配置浏览记录构建器。
:param page_adapter: 参数配置浏览页适配器
:param section_builder: 参数配置浏览页分区构建器
:return: None
"""
self.page_adapter = page_adapter
self.section_builder = section_builder
def build_record(
self,
config_row: dict[str, Any],
env: str,
*,
risk_key: str,
) -> BrowserRecordSnapshot:
"""
构建单条参数配置浏览记录。
:param config_row: 配置项行数据
:param env: 当前运行环境
:param risk_key: 当前配置项风险键
:return: 浏览记录快照
"""
config_id = config_row.get('configId', '-')
config_key = str(config_row.get('configKey', '-') or '-')
config_name = str(config_row.get('configName', '-') or '-')
config_type = str(config_row.get('configType', '-') or '-')
config_value = str(config_row.get('configValue', '-') or '-')
risk_label = CONFIG_RISK_LABELS.get(risk_key, '正常')
return BrowserRecordSnapshot(
key=f'config:{config_key}',
title=SHELL_TEXT_FORMATTER.truncate_text(config_key, 44),
status=CONFIG_RISK_STATUSES.get(risk_key, 'ok'),
summary=f'{risk_label} · {SHELL_TEXT_FORMATTER.truncate_text(config_name, 18)} · 值 {SHELL_TEXT_FORMATTER.truncate_text(config_value, 24)}',
metadata_lines=[
'## 配置身份',
f'配置 ID: {config_id}',
f'配置名称: {SHELL_TEXT_FORMATTER.truncate_text(config_name, 40)}',
f'系统内置: {config_type}',
f'风险分类: {risk_label}',
'',
'## 当前值',
SHELL_TEXT_FORMATTER.truncate_text(config_value, 88),
],
detail_sections=[],
detail_loader=lambda config_row=config_row, env=env: self.section_builder.load_config_detail_sections(
config_row,
env,
),
)
def build_failure_record(self, payload: dict[str, Any] | None) -> BrowserRecordSnapshot:
"""
构建配置页失败兜底记录。
:param payload: 失败结果负载
:return: 浏览记录快照
"""
return self.page_adapter.build_failure_record(
key='config:unavailable',
subject='参数配置',
section_subject='配置列表',
payload=payload,
)
class ConfigsBrowserAdapter(BaseBrowserAdapter):
"""
参数配置浏览页适配器。
该适配器负责采集配置巡检、配置列表和单项配置详情,并统一完成
风险分类、筛选排序、共享分区构建与浏览记录装配。
:param risk_support: 参数配置风险分类支持对象
:param section_builder: 参数配置浏览页分区构建器
:param record_builder: 参数配置浏览记录构建器
"""
def __init__(
self,
risk_support: ConfigRiskSupport | None = None,
section_builder: ConfigSectionBuilder | None = None,
record_builder: ConfigRecordBuilder | None = None,
) -> None:
"""
初始化参数配置浏览页适配器。
:param risk_support: 参数配置风险分类支持对象
:param section_builder: 参数配置浏览页分区构建器
:param record_builder: 参数配置浏览记录构建器
:return: None
"""
super().__init__(
page_title='参数配置',
search_view_key='configs',
filter_options=CONFIG_FILTER_OPTIONS,
)
self.risk_support = risk_support or ConfigRiskSupport()
self.section_builder = section_builder or ConfigSectionBuilder(self, self.risk_support)
self.record_builder = record_builder or ConfigRecordBuilder(self, self.section_builder)
def collect_snapshot(self, env: str, filter_key: str = 'all', query: str = '') -> BrowserPageSnapshot:
"""
采集参数配置浏览页只读快照。
:param env: 当前运行环境
:param filter_key: 当前筛选键
:param query: 当前搜索词
:return: 浏览页快照
"""
active_filter_option = self.resolve_active_filter(filter_key)
active_filter = active_filter_option.key
active_filter_label = active_filter_option.label
search_context = self.resolve_search_context(query)
doctor_payload = NESTED_CLI_SUPPORT.run(
'config',
'doctor',
f'--env={env}',
'--sample-limit=5',
'--output=json',
parse_json=True,
).payload
list_payload = NESTED_CLI_SUPPORT.run(
'config',
'list',
f'--env={env}',
'--paged',
'--page-size=8',
'--output=json',
parse_json=True,
).payload
risk_sets = self.risk_support.build_config_risk_sets(doctor_payload)
if not isinstance(list_payload, dict) or not list_payload.get('ok', False):
return BrowserPageSnapshot(
title='参数配置',
subtitle=TUI_COPY.build_unavailable_subtitle(
'配置',
SHELL_TEXT_FORMATTER.truncate_text(self.extract_payload_message(list_payload), 72),
),
records=[self.record_builder.build_failure_record(list_payload)],
shared_sections=[
self.section_builder.build_configs_overview_section([], [], risk_sets, active_filter_label),
self.section_builder.build_config_doctor_section(doctor_payload),
self.section_builder.build_high_risk_config_section([], risk_sets),
self.section_builder.build_config_issue_samples_section(doctor_payload),
self.section_builder.build_config_set_entry_section([]),
],
filters=list(self.filter_options),
active_filter_key=active_filter,
search=search_context,
)
rows = self.risk_support.append_orphan_cache_rows(
self.extract_page_rows(list_payload),
risk_sets.get('orphan-cache', set()),
)
sorted_rows = sorted(
rows, key=lambda config_row: self.risk_support.build_config_sort_key(config_row, risk_sets)
)
filtered_rows = self.risk_support.apply_config_query(
self.risk_support.apply_config_filter(sorted_rows, risk_sets, active_filter),
query,
)
records = [
self.record_builder.build_record(
config_row,
env,
risk_key=self.risk_support.resolve_config_risk_key(
str(config_row.get('configKey', '') or ''),
risk_sets,
),
)
for config_row in filtered_rows[:12]
]
if not records:
records = [
self.build_empty_record(
key='config:none',
subject='配置项',
empty_label='配置项',
has_source_rows=bool(sorted_rows),
filtered_summary='当前筛选条件下没有匹配配置项',
empty_summary='当前环境没有可浏览的参数配置项',
filtered_empty_value='暂无数据',
empty_empty_value='暂无配置',
filtered_detail='当前筛选条件下没有匹配配置项',
empty_detail='当前环境没有可浏览的参数配置项',
)
]
subtitle = TUI_DIAGNOSTIC_SERVICE.build_configs_diagnostic_subtitle(
active_filter_label,
len(filtered_rows),
len(risk_sets.get('mismatch', set())),
len(risk_sets.get('missing-cache', set())) + len(risk_sets.get('orphan-cache', set())),
)
if isinstance(doctor_payload, dict) and not doctor_payload.get('ok', False):
subtitle = (
f'{subtitle} | {SHELL_TEXT_FORMATTER.truncate_text(self.extract_payload_message(doctor_payload), 72)}'
)
shared_sections = [
self.section_builder.build_configs_overview_section(rows, filtered_rows, risk_sets, active_filter_label),
self.section_builder.build_config_doctor_section(doctor_payload),
self.section_builder.build_high_risk_config_section(rows, risk_sets),
self.section_builder.build_config_issue_samples_section(doctor_payload),
self.section_builder.build_config_set_entry_section(rows),
]
return BrowserPageSnapshot(
title='参数配置',
subtitle=subtitle,
records=records,
shared_sections=shared_sections,
filters=list(self.filter_options),
active_filter_key=active_filter,
search=search_context,
)
CONFIGS_BROWSER_ADAPTER = ConfigsBrowserAdapter()