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()