"""批量 Word 转换与自动签名核心模块。 实现 SRS 第 11、14 章: - 加载/保存 rules.config(JSON 格式),损坏文件不自动覆盖。 - 递归扫描 Word 文件夹,只处理 .docx。 - 多条正则规则:捕获组序号 >=1,cell_mode (single/horizontal/vertical),cell_match (partial/whole)。 - 表格逻辑网格:识别 gridSpan 横向合并 / vMerge 纵向合并。 - 命中后将可见姓名替换为隐藏的 ``SET USER_ \\* MERGEFORMAT`` 域代码 + 补偿空格。 - 调用 word_signer.sign_word 以 auto 模式完成自动签名。 - 输出 _signed.docx;冲突时自动生成 _signed_001、_signed_002 等不冲突文件名。 - 单文件失败跳过 + WARN,批量任务继续。 """ import io import os import re import json import logging import shutil import tempfile from copy import deepcopy from datetime import datetime from docx import Document from docx.oxml.ns import qn from docx.oxml import OxmlElement from lib import field_codes from lib.validators import validate_rule from lib.word_signer import sign_word logger = logging.getLogger(__name__) DEFAULT_RULES = [ { 'name': '编制:姓名/标识', 'pattern': r'编制[::]\s*([一-龥A-Za-z0-9._-]{1,20})', 'capture_group': 1, 'cell_mode': 'single', 'cell_match': 'partial', 'enabled': True, }, { 'name': '审核:姓名/标识', 'pattern': r'审核[::]\s*([一-龥A-Za-z0-9._-]{1,20})', 'capture_group': 1, 'cell_mode': 'single', 'cell_match': 'partial', 'enabled': True, }, { 'name': '签字人:姓名/标识', 'pattern': r'签字人[::]\s*([一-龥A-Za-z0-9._-]{1,20})', 'capture_group': 1, 'cell_mode': 'single', 'cell_match': 'partial', 'enabled': True, }, { 'name': '表格值整格', 'pattern': r'^([一-龥A-Za-z0-9._-]{1,20})$', 'capture_group': 1, 'cell_mode': 'single', 'cell_match': 'whole', 'enabled': False, }, ] REGEX_SNIPPETS = [ { 'id': 'single_compiled_name', 'label': '编制:姓名/标识', 'help': '适用于正文中"编制:张三"或"编制: zhang_san1"的标签加姓名组合;表格方式选 single,部分匹配。', 'pattern': r'编制[::]\s*([一-龥A-Za-z0-9._-]{1,20})', 'cell_mode': 'single', 'cell_match': 'partial', }, { 'id': 'audit_compiled_name', 'label': '审核:姓名/标识', 'help': '适用于"审核:李四"组合;表格方式选 single,部分匹配。', 'pattern': r'审核[::]\s*([一-龥A-Za-z0-9._-]{1,20})', 'cell_mode': 'single', 'cell_match': 'partial', }, { 'id': 'signer_compiled_name', 'label': '签字人:姓名/标识', 'help': '适用于"签字人:王五"组合;表格方式选 single,部分匹配。', 'pattern': r'签字人[::]\s*([一-龥A-Za-z0-9._-]{1,20})', 'cell_mode': 'single', 'cell_match': 'partial', }, { 'id': 'horizontal_label_value', 'label': '表格横向相邻两格', 'help': '同一行:左格"编制:",右格"张三"。规则匹配标签,捕获组指向右值;表格方式选 horizontal,整格匹配。', 'pattern': r'编制[::]', 'cell_mode': 'horizontal', 'cell_match': 'whole', 'capture_group_hint': '在表格中通过相邻右格文本另用通用片段提取', }, { 'id': 'vertical_label_value', 'label': '表格纵向相邻两格', 'help': '同一列:上格"审核:",下格"李四"。规则匹配标签,从下格提取;表格方式选 vertical,整格匹配。', 'pattern': r'审核[::]', 'cell_mode': 'vertical', 'cell_match': 'whole', }, { 'id': 'whole_name_in_cell', 'label': '表格值整格', 'help': '姓名/标识单独占据整个单元格时使用;表格方式选 single,整格匹配。', 'pattern': r'^([一-龥A-Za-z0-9._-]{1,20})$', 'cell_mode': 'single', 'cell_match': 'whole', }, { 'id': 'partial_name_in_cell', 'label': '表格值部分', 'help': '姓名/标识只是单元格部分文本时使用;表格方式选 single,部分匹配。', 'pattern': r'([一-龥A-Za-z0-9._-]{1,20})', 'cell_mode': 'single', 'cell_match': 'partial', }, { 'id': 'chinese_only_2_4', 'label': '纯中文姓名 2-4 字', 'help': '仅匹配 2-4 字中文姓名;表格方式选 single,整格或部分均可。', 'pattern': r'([一-龥]{2,4})', 'cell_mode': 'single', 'cell_match': 'partial', }, ] def get_default_rules_path(): project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) return os.path.join(project_root, 'rules.config') def resolve_rules_path(path=None): if not path: return get_default_rules_path() if not os.path.isabs(path): project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) return os.path.join(project_root, path) return path def load_rules(path=None): """加载规则文件;损坏或不存在时返回默认规则并提示,不自动覆盖原文件。""" target = resolve_rules_path(path) if not os.path.exists(target): logger.warning("规则文件不存在,加载内置默认规则: %s", target, extra={'log_source': '批量'}) return {'rules': deepcopy(DEFAULT_RULES), 'warning': f'规则文件不存在,已加载默认规则:{target}'} try: with open(target, 'r', encoding='utf-8') as f: data = json.load(f) rules = data.get('rules') if isinstance(data, dict) else data if not isinstance(rules, list): raise ValueError("规则文件格式非法:缺少 rules 列表") normalized, errors = _normalize_rules(rules) warning = '; '.join(errors) if errors else None return {'rules': normalized, 'warning': warning} except Exception as e: logger.warning("规则文件损坏或非法 (%s): %s", target, str(e), extra={'log_source': '批量'}) return {'rules': deepcopy(DEFAULT_RULES), 'warning': f'规则文件损坏或非法:{e}。已加载内置默认规则,请修复或重新选择。'} def save_rules(rules, path=None): """保存规则文件;若文件已存在且解析失败则不覆盖。""" target = resolve_rules_path(path) normalized, errors = _normalize_rules(rules) if os.path.exists(target): try: with open(target, 'r', encoding='utf-8') as f: json.load(f) except Exception: logger.error("规则文件已损坏,拒绝覆盖: %s", target, extra={'log_source': '批量'}) raise RuntimeError(f'规则文件已损坏,未覆盖:{target}') os.makedirs(os.path.dirname(target), exist_ok=True) with open(target, 'w', encoding='utf-8') as f: json.dump({'rules': normalized}, f, ensure_ascii=False, indent=2) logger.info("规则已保存到 %s", target, extra={'log_source': '批量'}) return {'rules': normalized, 'path': target, 'warnings': errors} def _normalize_rules(rules): normalized = [] errors = [] for idx, rule in enumerate(rules): if not isinstance(rule, dict): errors.append(f"规则{idx + 1}: 非对象,跳过") continue norm_rule = { 'name': str(rule.get('name') or '').strip(), 'pattern': str(rule.get('pattern') or '').strip(), 'capture_group': rule.get('capture_group', 1), 'cell_mode': rule.get('cell_mode', 'single'), 'cell_match': rule.get('cell_match', 'partial'), 'enabled': bool(rule.get('enabled', True)), } rule_errors = validate_rule(norm_rule) if rule_errors: errors.append(f"规则{idx + 1} ({norm_rule['name'] or '未命名'}): {'; '.join(rule_errors)}") norm_rule['enabled'] = False normalized.append(norm_rule) return normalized, errors def get_regex_snippets(): return {'snippets': REGEX_SNIPPETS} def test_rules(rules, text): """在纯文本上测试规则,不修改 Word 文件。""" normalized, errors = _normalize_rules(rules) matches = [] matched_spans = [] for idx, rule in enumerate(normalized): if not rule.get('enabled'): continue try: pattern = re.compile(rule['pattern']) except re.error as e: errors.append(f"规则{idx + 1}: 正则错误 {e}") continue for m in pattern.finditer(text or ''): try: captured = m.group(rule['capture_group']) except (IndexError, error_group_error_class()): errors.append(f"规则{idx + 1}: 捕获组 {rule['capture_group']} 越界") continue if captured is None: continue name = captured.strip() if not name: continue span = (m.start(rule['capture_group']), m.end(rule['capture_group'])) if any(_spans_overlap(span, existing) for existing in matched_spans): errors.append(f"规则{idx + 1} 命中 '{name}' 与已有命中重叠,跳过") continue matched_spans.append(span) field_code = f'{{{{ SET USER_{name} \\* MERGEFORMAT }}}}' preview_head = text[max(0, m.start(rule['capture_group']) - 6):m.start(rule['capture_group'])] preview_tail = text[m.end(rule['capture_group']):m.end(rule['capture_group']) + 6] matches.append({ 'rule_index': idx, 'rule_name': rule['name'], 'match_text': m.group(0), 'captured': name, 'user_marker': f'USER_{name}', 'field_code': field_code, 'cell_mode': rule['cell_mode'], 'cell_match': rule['cell_match'], 'preview': f"{preview_head}<{name}>{preview_tail}", }) return {'matches': matches, 'match_count': len(matches), 'errors': errors} def error_group_error_class(): return IndexError def _spans_overlap(a, b): return not (a[1] <= b[0] or b[1] <= a[0]) def scan_docx_files(folder, recursive=True): """递归扫描 .docx 文件,跳过 .doc 与其他格式。""" folder = os.path.abspath(folder) if not os.path.isdir(folder): return [], [f"文件夹不存在: {folder}"] files = [] skipped = [] if recursive: for root, dirs, names in os.walk(folder): for name in names: full = os.path.join(root, name) if name.lower().endswith('.docx'): files.append(full) elif name.lower().endswith('.doc'): skipped.append(full) elif name.lower().endswith(('.wps', '.pdf', '.rtf')): skipped.append(full) else: for name in os.listdir(folder): full = os.path.join(folder, name) if os.path.isfile(full): if name.lower().endswith('.docx'): files.append(full) elif name.lower().endswith(('.doc', '.wps', '.pdf', '.rtf')): skipped.append(full) return files, skipped def _build_table_grid(table): """基于 gridSpan 和 vMerge 构建"逻辑网格"。 返回 ``(rows, grid)``,grid[row][col] = { 'tc': , 'text': str, 'is_origin': bool (vMerge origin), 'is_continue': bool (vMerge continue) } """ tbl = table if hasattr(table, '_element') else table if hasattr(tbl, '_element'): tbl = tbl._element rows = tbl.findall(qn('w:tr')) grid = [] pending_vmerge = {} for tr in rows: cells_in_row = tr.findall(qn('w:tc')) row_cells = [] col_index = 0 cell_iter = iter(cells_in_row) col_fill = {} while True: if col_index in col_fill: row_cells.append(col_fill[col_index]) col_index += 1 continue try: tc = next(cell_iter) except StopIteration: break tcPr = tc.find(qn('w:tcPr')) grid_span = 1 vmerge_kind = None if tcPr is not None: gs = tcPr.find(qn('w:gridSpan')) if gs is not None: try: grid_span = int(gs.get(qn('w:val')) or '1') except ValueError: grid_span = 1 vm = tcPr.find(qn('w:vMerge')) if vm is not None: val = vm.get(qn('w:val')) vmerge_kind = 'restart' if val == 'restart' else 'continue' text = _extract_cell_text(tc) cell_info = { 'tc': tc, 'text': text, 'grid_span': grid_span, 'vmerge_kind': vmerge_kind, 'row': len(grid), 'col': col_index, } if vmerge_kind == 'restart': pending_vmerge[col_index] = { 'origin_tc': tc, 'origin_text': text, 'origin_row': len(grid), 'rows': [cell_info], } cell_info['is_origin'] = True cell_info['is_continue'] = False elif vmerge_kind == 'continue': if col_index in pending_vmerge: origin = pending_vmerge[col_index] origin['rows'].append(cell_info) cell_info['merged_origin'] = origin['origin_tc'] cell_info['merged_origin_text'] = origin['origin_text'] cell_info['is_origin'] = False cell_info['is_continue'] = True else: if col_index in pending_vmerge: pending_vmerge.pop(col_index, None) cell_info['is_origin'] = True cell_info['is_continue'] = False row_cells.append(cell_info) for k in range(1, grid_span): col_fill[col_index + k] = cell_info row_cells.append(cell_info) col_index += grid_span grid.append(row_cells) return rows, grid def _extract_cell_text(tc): parts = [] for p in tc.findall(qn('w:p')): buf = [] for t in p.iter(qn('w:t')): if t.text: buf.append(t.text) parts.append(''.join(buf)) return '\n'.join(parts).strip() def _iter_paragraphs_in_doc(doc): yield from field_codes._iter_all_paragraph_elements(doc) def _iter_tables_in_doc(doc): yield from field_codes._iter_all_tables(doc) def process_document(doc, rules, log_source='批量', trace_id=None): """对单个 Document 应用规则,返回 (替换条数, 告警列表)。""" normalized, errors = _normalize_rules(rules) warnings = list(errors) replaced = 0 consumed_paragraph_runs = set() consumed_table_cells = set() for rule in normalized: if not rule.get('enabled'): continue try: pattern = re.compile(rule['pattern']) except re.error as e: warnings.append(f"规则 '{rule['name']}' 正则错误: {e}") continue if rule['cell_mode'] == 'single': replaced += _apply_rule_to_paragraphs(doc, pattern, rule, consumed_paragraph_runs, log_source=log_source, trace_id=trace_id, warnings=warnings) replaced += _apply_rule_single_cell_tables(doc, pattern, rule, consumed_table_cells, log_source=log_source, trace_id=trace_id, warnings=warnings) elif rule['cell_mode'] == 'horizontal': replaced += _apply_rule_horizontal_cells(doc, pattern, rule, consumed_table_cells, log_source=log_source, trace_id=trace_id, warnings=warnings) elif rule['cell_mode'] == 'vertical': replaced += _apply_rule_vertical_cells(doc, pattern, rule, consumed_table_cells, log_source=log_source, trace_id=trace_id, warnings=warnings) return replaced, warnings def _apply_rule_to_paragraphs(doc, pattern, rule, consumed, log_source, trace_id, warnings): """对正文段落与文本框/页眉页脚段落应用 single 模式规则。""" count = 0 capture_group = int(rule.get('capture_group', 1)) cell_match = rule.get('cell_match', 'partial') for para in _iter_paragraphs_in_doc(doc): runs = para.findall(qn('w:r')) run_texts = [] for r in runs: t = r.find(qn('w:t')) run_texts.append(t.text if t is not None and t.text else '') full_text = ''.join(run_texts) if not full_text: continue if cell_match == 'whole': m = pattern.fullmatch(full_text) matches = [m] if m else [] else: matches = list(pattern.finditer(full_text)) for m in matches: try: captured = m.group(capture_group) except IndexError: warnings.append(f"规则 '{rule['name']}' 捕获组 {capture_group} 越界") continue if not captured: continue name = captured.strip() if not name: continue start_offset = m.start(capture_group) end_offset = m.end(capture_group) start_run_idx, start_off, end_run_idx, end_off = _map_text_to_runs(run_texts, start_offset, end_offset) if start_run_idx is None: continue key = (id(para), start_run_idx, start_off, end_run_idx, end_off) if key in consumed: continue consumed.add(key) ok = field_codes.replace_text_with_set_field( para, start_run_idx, start_off, end_run_idx, end_off, captured, name, compensate_spaces=True ) if ok: count += 1 logger.info("[批量] 段落匹配替换为 USER_%s (规则=%s)", name, rule['name'], extra={'log_source': log_source, 'trace_id': trace_id}) return count def _map_text_to_runs(run_texts, start_offset, end_offset): """将整段字符串中的字符偏移映射回 (run_index, offset_in_run) 对。""" cumulative = 0 start_run_idx = None start_off = None end_run_idx = None end_off = None for idx, text in enumerate(run_texts): new_cum = cumulative + len(text) if start_run_idx is None and start_offset < new_cum: start_run_idx = idx start_off = start_offset - cumulative if end_offset <= new_cum and end_run_idx is None: end_run_idx = idx end_off = end_offset - cumulative break cumulative = new_cum if start_run_idx is None or end_run_idx is None: return None, None, None, None return start_run_idx, start_off, end_run_idx, end_off def _apply_rule_single_cell_tables(doc, pattern, rule, consumed, log_source, trace_id, warnings): """single 模式下补充:表格单元格内/整格匹配(基于段落处理已能覆盖单格,这里仅对合并单元格特例)。""" return 0 def _apply_rule_horizontal_cells(doc, pattern, rule, consumed, log_source, trace_id, warnings): """横向相邻两格:左格匹配标签 + 右格作为值。 假设规则 pattern 仅匹配标签(如 ``编制[::]``),值在右相邻格中。 捕获组在右格文本上重新应用通用片段 ``([一-龥A-Za-z0-9._-]{1,20})`` 提取。 """ count = 0 value_pattern = re.compile(r'([一-龥A-Za-z0-9._-]{1,20})') cell_match = rule.get('cell_match', 'whole') for tbl in _iter_tables_in_doc(doc): try: _rows, grid = _build_table_grid(tbl) except Exception as e: warnings.append(f"表格逻辑网格构建失败: {e}") continue for r in grid: for c in range(len(r) - 1): left = r[c] right = r[c + 1] if left is right or right is None: continue left_text = left.get('text', '') or '' if not pattern.search(left_text): continue right_text = right.get('text', '') or '' if not right_text: continue m_val = value_pattern.fullmatch(right_text) if cell_match == 'whole' else value_pattern.search(right_text) if not m_val: continue name = m_val.group(1).strip() if not name: continue key = ('h', id(right['tc'])) if key in consumed: continue consumed.add(key) ok = _replace_cell_text_with_set_field(right['tc'], m_val.group(1), name, partial=(cell_match == 'partial'), match_text=m_val.group(1)) if ok: count += 1 logger.info("[批量] 横向相邻匹配替换为 USER_%s (规则=%s)", name, rule['name'], extra={'log_source': log_source, 'trace_id': trace_id}) return count def _apply_rule_vertical_cells(doc, pattern, rule, consumed, log_source, trace_id, warnings): """纵向相邻两格:上格匹配标签 + 下格作为值。""" count = 0 value_pattern = re.compile(r'([一-龥A-Za-z0-9._-]{1,20})') cell_match = rule.get('cell_match', 'whole') for tbl in _iter_tables_in_doc(doc): try: _rows, grid = _build_table_grid(tbl) except Exception as e: warnings.append(f"表格逻辑网格构建失败: {e}") continue max_cols = max((len(r) for r in grid), default=0) for c in range(max_cols): for r in range(len(grid) - 1): if c >= len(grid[r]) or c >= len(grid[r + 1]): continue top = grid[r][c] bottom = grid[r + 1][c] if top is bottom or bottom is None: continue top_text = top.get('text', '') or '' if not pattern.search(top_text): continue bottom_text = bottom.get('text', '') or '' if not bottom_text: continue m_val = value_pattern.fullmatch(bottom_text) if cell_match == 'whole' else value_pattern.search(bottom_text) if not m_val: continue name = m_val.group(1).strip() if not name: continue key = ('v', id(bottom['tc'])) if key in consumed: continue consumed.add(key) ok = _replace_cell_text_with_set_field(bottom['tc'], m_val.group(1), name, partial=(cell_match == 'partial'), match_text=m_val.group(1)) if ok: count += 1 logger.info("[批量] 纵向相邻匹配替换为 USER_%s (规则=%s)", name, rule['name'], extra={'log_source': log_source, 'trace_id': trace_id}) return count def _replace_cell_text_with_set_field(tc, captured, name, partial, match_text): """将单元格中的 match_text 替换为 SET USER_ 域 + 补偿空格。 简化策略:定位单元格中第一个包含 match_text 的段落与 run。 """ for p in tc.findall(qn('w:p')): runs = p.findall(qn('w:r')) for r_idx, r in enumerate(runs): t = r.find(qn('w:t')) if t is None or not t.text: continue if match_text not in t.text: continue text = t.text pos = text.find(match_text) head = text[:pos] tail = text[pos + len(match_text):] field_runs = field_codes.build_set_field_runs(name) compensate_run = field_codes._make_text_run(' ' * len(captured)) parent = p anchor = r idx = list(parent).index(anchor) parent.remove(anchor) head_run = field_codes._make_text_run(head) tail_run = field_codes._make_text_run(tail) insert_idx = idx for elem in [head_run] + field_runs + [compensate_run, tail_run]: if elem is None: continue parent.insert(insert_idx, elem) insert_idx += 1 return True return False def _next_available_path(base_path): """生成不冲突的输出文件路径:a/_signed.docx → a/_signed_001.docx → ...""" if not os.path.exists(base_path): return base_path directory = os.path.dirname(base_path) name, ext = os.path.splitext(os.path.basename(base_path)) if not ext: ext = '.docx' counter = 1 while True: candidate = os.path.join(directory, f"{name}_{counter:03d}{ext}") if not os.path.exists(candidate): return candidate counter += 1 if counter > 999: return os.path.join(directory, f"{name}_{datetime.now().strftime('%Y%m%d%H%M%S')}{ext}") def process_folder(folder, recursive=True, auto_sign=True, rules=None, request_options=None, log_source='批量', trace_id=None): """批量处理整个文件夹。""" files, skipped = scan_docx_files(folder, recursive=recursive) summary = { 'scanned': len(files), 'processed': 0, 'skipped': len(skipped), 'failed': 0, 'files': [], } logger.info("[批量] 开始处理文件夹 %s (扫描 %d 个 docx, 跳过 %d 个非 docx)", folder, len(files), len(skipped), extra={'log_source': log_source, 'trace_id': trace_id}) for path in skipped: summary['files'].append({ 'path': path, 'status': 'skipped', 'markers': 0, 'output': '', 'warnings': ['类型不支持,仅处理 .docx'] }) for docx_path in files: file_entry = { 'path': docx_path, 'status': 'running', 'markers': 0, 'output': '', 'warnings': [] } try: with open(docx_path, 'rb') as f: docx_bytes = f.read() doc = Document(io.BytesIO(docx_bytes)) replaced, warnings = process_document( doc, rules or [], log_source=log_source, trace_id=trace_id ) file_entry['markers'] = replaced file_entry['warnings'].extend(warnings) converted_buf = io.BytesIO() doc.save(converted_buf) converted_bytes = converted_buf.getvalue() if auto_sign and replaced > 0: base_name = os.path.splitext(os.path.basename(docx_path))[0] docx_name = f"{base_name}_signed.docx" sign_params = { 'docx_name': docx_name, 'match_mode': 'auto', 'stamps': [], } sign_params.update(request_options or {}) result, error = sign_word( converted_bytes, sign_params, log_source=log_source, trace_id=trace_id ) if error: file_entry['status'] = 'partial' file_entry['warnings'].append(f"自动签名失败: {error.get('message')}") final_bytes = converted_bytes output_name = f"{base_name}_signed.docx" else: final_bytes = result['data'] output_name = result['filename'] file_entry['warnings'].extend(result.get('warnings', [])) file_entry['status'] = 'success' if not result.get('warnings') else 'partial' else: final_bytes = converted_bytes output_name = f"{os.path.splitext(os.path.basename(docx_path))[0]}_signed.docx" file_entry['status'] = 'converted' output_path = os.path.join(os.path.dirname(docx_path), output_name) output_path = _next_available_path(output_path) try: with open(output_path, 'wb') as f: f.write(final_bytes) file_entry['output'] = output_path summary['processed'] += 1 logger.info("[批量] 文件处理完成: %s -> %s (标记 %d)", docx_path, output_path, replaced, extra={'log_source': log_source, 'trace_id': trace_id}) except PermissionError: file_entry['status'] = 'skipped' file_entry['warnings'].append('输出文件被锁定,跳过') summary['skipped'] += 1 logger.warning("[批量] 输出文件锁定: %s", output_path, extra={'log_source': log_source, 'trace_id': trace_id}) except PermissionError: file_entry['status'] = 'skipped' file_entry['warnings'].append('源文件被锁定,跳过') summary['skipped'] += 1 logger.warning("[批量] 源文件锁定: %s", docx_path, extra={'log_source': log_source, 'trace_id': trace_id}) except Exception as e: file_entry['status'] = 'failed' file_entry['warnings'].append(f"处理异常: {e}") summary['failed'] += 1 logger.error("[批量] 文件处理失败 %s: %s", docx_path, str(e), extra={'log_source': log_source, 'trace_id': trace_id}) summary['files'].append(file_entry) summary['success'] = summary['failed'] == 0 logger.info("[批量] 文件夹处理完成: 扫描 %d, 处理 %d, 跳过 %d, 失败 %d", summary['scanned'], summary['processed'], summary['skipped'], summary['failed'], extra={'log_source': log_source, 'trace_id': trace_id}) return summary