392 lines
20 KiB
Python
392 lines
20 KiB
Python
import io
|
||
import os
|
||
import re
|
||
import base64
|
||
import logging
|
||
import tempfile
|
||
from datetime import datetime
|
||
from docx import Document
|
||
from docx.shared import Cm
|
||
import lib.db as db
|
||
from lib.image_processor import process_image
|
||
from lib import field_codes
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _new_trace_id():
|
||
return datetime.now().strftime('%Y%m%d%H%M%S%f') + f'_{os.getpid()}'
|
||
|
||
|
||
def sign_word(docx_data, params, log_source='前端操作', trace_id=None, client_ip=None):
|
||
if trace_id is None:
|
||
trace_id = _new_trace_id()
|
||
logger.info("[签章流程] ====== 开始处理 ====== trace_id=%s", trace_id,
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
|
||
try:
|
||
docx_name = params.get('docx_name', '')
|
||
logger.info("[签章流程] 文件名: %s", docx_name,
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
|
||
if not docx_name.lower().endswith('.docx'):
|
||
logger.error("[参数校验] 文件类型错误: %s, 仅支持.docx", docx_name,
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
return None, {'success': False, 'code': 'INVALID_FILE_TYPE',
|
||
'message': f'仅支持.docx格式,当前: {docx_name}',
|
||
'trace_id': trace_id}
|
||
|
||
match_mode = params.get('match_mode', '')
|
||
if match_mode not in ('upload', 'id', 'name', 'auto'):
|
||
logger.error("[参数校验] 匹配模式无效: %s", match_mode,
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
return None, {'success': False, 'code': 'INVALID_MATCH_MODE',
|
||
'message': f'匹配模式无效: {match_mode}', 'trace_id': trace_id}
|
||
|
||
logger.info("[签章流程] 匹配模式: %s", match_mode,
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
|
||
try:
|
||
doc = Document(io.BytesIO(docx_data))
|
||
except Exception as e:
|
||
logger.error("[参数校验] Word文档解析失败: %s", str(e),
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
return None, {'success': False, 'code': 'INVALID_DOCX',
|
||
'message': f'Word文档解析失败或已损坏: {e}', 'trace_id': trace_id}
|
||
logger.info("[签章流程] Word文档加载成功",
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
|
||
stamps = params.get('stamps', [])
|
||
use_global_align = params.get('use_global_align', True)
|
||
align_h = params.get('align_h', 'left')
|
||
align_v = params.get('align_v', 'center')
|
||
use_global_height = params.get('use_global_height', True)
|
||
global_height = params.get('height')
|
||
use_global_noise = params.get('use_global_noise', True)
|
||
global_noise = params.get('noise_level', 0)
|
||
use_global_rotate = params.get('use_global_rotate', True)
|
||
global_rotate_min = params.get('rotate_min', 0)
|
||
global_rotate_max = params.get('rotate_max', 0)
|
||
relax_layout = params.get('relax_layout', True)
|
||
signature_mode = params.get('signature_mode', 'inline') # 'inline' | 'anchor'
|
||
stamp_mode = params.get('stamp_mode', 'anchor') # 'inline' | 'anchor'
|
||
|
||
if match_mode == 'auto':
|
||
stamps = [s for s in stamps if (s.get('marker') or '').strip()]
|
||
if not stamps:
|
||
scanned = field_codes.scan_user_markers(doc, log_source=log_source)
|
||
stamps = [{'marker': item['marker']} for item in scanned]
|
||
logger.info("[签章流程] auto模式扫描到 %d 个标记", len(stamps),
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
|
||
if len(stamps) > 100:
|
||
logger.error("[参数校验] 盖章项超过100个限制: %d", len(stamps),
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
return None, {'success': False, 'code': 'TOO_MANY_STAMPS',
|
||
'message': '盖章项超过100个限制', 'trace_id': trace_id}
|
||
|
||
warnings = []
|
||
success_count = 0
|
||
|
||
for i, stamp in enumerate(stamps):
|
||
logger.info("[签章流程] ---- 处理盖章项 %d/%d ----", i + 1, len(stamps),
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
try:
|
||
marker = stamp.get('marker', '')
|
||
image_data = None
|
||
stamp_is_signature = False
|
||
|
||
if match_mode == 'upload':
|
||
image_b64 = stamp.get('image_base64', '')
|
||
if image_b64:
|
||
image_data = base64.b64decode(image_b64)
|
||
logger.info("[图片匹配] 使用上传图片: %s", stamp.get('image_filename', ''),
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
else:
|
||
warnings.append(f"盖章项{i + 1}: 缺少图片数据")
|
||
logger.warning("[图片匹配] 盖章项%d缺少图片数据", i + 1,
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
continue
|
||
|
||
elif match_mode == 'id':
|
||
image_id = stamp.get('image_id', '')
|
||
if not image_id:
|
||
# SRS 3.1.3:空字符串改用 image_name 匹配
|
||
image_name = stamp.get('image_name', '')
|
||
if image_name:
|
||
logger.info("[图片匹配] image_id 为空,改用姓名查询: %s", image_name,
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
records = db.get_image_by_user_name(image_name)
|
||
if records:
|
||
if len(records) > 1:
|
||
warnings.append(f"盖章项{i + 1}: 姓名={image_name} 匹配到 {len(records)} 条记录,使用第一条")
|
||
logger.warning("[图片匹配] 姓名匹配多条: %s -> %d 条,使用第一条",
|
||
image_name, len(records),
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
image_data = records[0]['sign_image']
|
||
stamp_is_signature = bool(records[0]['is_signature'])
|
||
else:
|
||
warnings.append(f"盖章项{i + 1}: 未找到姓名={image_name} 的记录")
|
||
logger.warning("[图片匹配] 未找到姓名=%s", image_name,
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
continue
|
||
else:
|
||
warnings.append(f"盖章项{i + 1}: 缺少人员ID和姓名")
|
||
logger.warning("[图片匹配] 盖章项%d缺少人员ID和姓名", i + 1,
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
continue
|
||
else:
|
||
logger.info("[图片匹配] 按ID查询: %s", image_id,
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
record = db.get_image_by_user_id(image_id)
|
||
if record:
|
||
image_data = record['sign_image']
|
||
stamp_is_signature = bool(record['is_signature'])
|
||
logger.info("[图片匹配] ID匹配成功: %s", image_id,
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
else:
|
||
warnings.append(f"盖章项{i + 1}: 未找到ID={image_id} 的记录")
|
||
logger.warning("[图片匹配] 未找到ID=%s", image_id,
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
continue
|
||
|
||
elif match_mode == 'name':
|
||
image_name = stamp.get('image_name', '')
|
||
if not image_name:
|
||
warnings.append(f"盖章项{i + 1}: 缺少人员姓名")
|
||
logger.warning("[图片匹配] 盖章项%d缺少人员姓名", i + 1,
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
continue
|
||
logger.info("[图片匹配] 按姓名查询: %s", image_name,
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
records = db.get_image_by_user_name(image_name)
|
||
if records:
|
||
if len(records) > 1:
|
||
warnings.append(f"盖章项{i + 1}: 姓名={image_name} 匹配到 {len(records)} 条记录,使用第一条")
|
||
logger.warning("[图片匹配] 姓名匹配多条: %s -> %d 条,使用第一条",
|
||
image_name, len(records),
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
image_data = records[0]['sign_image']
|
||
stamp_is_signature = bool(records[0]['is_signature'])
|
||
logger.info("[图片匹配] 姓名匹配成功: %s", image_name,
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
else:
|
||
warnings.append(f"盖章项{i + 1}: 未找到姓名={image_name} 的记录")
|
||
logger.warning("[图片匹配] 未找到姓名=%s", image_name,
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
continue
|
||
|
||
elif match_mode == 'auto':
|
||
name_from_marker = marker
|
||
if name_from_marker.startswith('USER_'):
|
||
name_from_marker = name_from_marker[5:]
|
||
name_from_marker = field_codes._strip_switches(name_from_marker)
|
||
logger.info("[图片匹配] auto模式, 标记=%s, 查询姓名=%s", marker, name_from_marker,
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
records = db.get_image_by_user_name(name_from_marker)
|
||
if records:
|
||
if len(records) > 1:
|
||
warnings.append(f"盖章项{i + 1}: 姓名={name_from_marker} 匹配多条,使用第一条")
|
||
logger.warning("[图片匹配] auto 姓名匹配多条: %s -> %d", name_from_marker, len(records),
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
image_data = records[0]['sign_image']
|
||
stamp_is_signature = bool(records[0]['is_signature'])
|
||
logger.info("[图片匹配] auto匹配成功: %s", name_from_marker,
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
else:
|
||
warnings.append(f"盖章项{i + 1}: auto模式未找到姓名={name_from_marker} 的记录")
|
||
logger.warning("[图片匹配] auto未找到姓名=%s", name_from_marker,
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
continue
|
||
|
||
if not image_data:
|
||
warnings.append(f"盖章项{i + 1}: 无有效图片数据")
|
||
continue
|
||
|
||
is_sig = stamp.get('is_signature', stamp_is_signature)
|
||
# 按图片类型选模式:签名用 signature_mode,盖章用 stamp_mode
|
||
image_mode = signature_mode if is_sig else stamp_mode
|
||
h = _resolve_height(stamp, use_global_height, global_height)
|
||
noise = _resolve_noise(stamp, use_global_noise, global_noise)
|
||
rot_min, rot_max = _resolve_rotate(stamp, use_global_rotate, global_rotate_min, global_rotate_max)
|
||
s_align_h = stamp.get('align_h') if not use_global_align else align_h
|
||
s_align_v = stamp.get('align_v') if not use_global_align else align_v
|
||
|
||
processed = process_image(
|
||
image_data,
|
||
is_signature=is_sig,
|
||
noise_level=noise,
|
||
rotate_min=rot_min,
|
||
rotate_max=rot_max,
|
||
log_source=log_source,
|
||
)
|
||
|
||
# ============= 调试:把"即将插入文档的图"落到磁盘 =============
|
||
# 目的:直接对比项目实际产出的图 vs 测试脚本(image_test/process.py)的输出
|
||
# 落盘位置: image_test/output/project_run/<trace_id>_<stamp_idx>_*.png
|
||
try:
|
||
import os as _os
|
||
_debug_dir = _os.path.join(
|
||
_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))),
|
||
'image_test', 'output', 'project_run'
|
||
)
|
||
_os.makedirs(_debug_dir, exist_ok=True)
|
||
_tag = f"{trace_id or 'no_trace'}_stamp{i + 1}"
|
||
with open(_os.path.join(_debug_dir, f"{_tag}_1_input.png"), 'wb') as _f:
|
||
_f.write(image_data)
|
||
with open(_os.path.join(_debug_dir, f"{_tag}_2_processed.png"), 'wb') as _f:
|
||
_f.write(processed)
|
||
logger.info("[签章流程][DEBUG] 已保存处理前后对比图到 %s (tag=%s)",
|
||
_debug_dir, _tag,
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
except Exception as _e:
|
||
logger.warning("[签章流程][DEBUG] 保存调试图失败: %s", str(_e),
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
# ============= 调试结束 =============
|
||
|
||
inserted = _insert_signature_image(doc, marker, processed, s_align_h, s_align_v,
|
||
height_cm=h,
|
||
relax_layout=relax_layout,
|
||
image_mode=image_mode,
|
||
log_source=log_source, trace_id=trace_id)
|
||
if inserted > 0:
|
||
success_count += inserted
|
||
logger.info("[签章流程] 盖章项%d签章成功: marker=%s, 插入%d处",
|
||
i + 1, marker, inserted,
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
else:
|
||
warnings.append(f"盖章项{i + 1}: 未找到标记 '{marker}'")
|
||
logger.warning("[签章流程] 未找到标记: %s", marker,
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
|
||
except Exception as e:
|
||
warnings.append(f"盖章项{i + 1}: 处理异常 - {str(e)}")
|
||
logger.error("[签章流程] 盖章项%d处理异常: %s", i + 1, str(e),
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
|
||
output_buf = io.BytesIO()
|
||
doc.save(output_buf)
|
||
output_buf.seek(0)
|
||
result_data = output_buf.read()
|
||
|
||
base_name = os.path.splitext(docx_name)[0]
|
||
output_name = f"{base_name}_signed.docx"
|
||
|
||
logger.info("[签章流程] ====== 处理完成 ====== 成功%d项, 告警%d条, trace_id=%s",
|
||
success_count, len(warnings), trace_id,
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
|
||
return {'data': result_data, 'filename': output_name,
|
||
'warnings': warnings, 'trace_id': trace_id,
|
||
'success_count': success_count}, None
|
||
|
||
except Exception as e:
|
||
logger.error("[签章流程] 处理失败: %s", str(e),
|
||
extra={'log_source': log_source, 'trace_id': trace_id})
|
||
return None, {'success': False, 'code': 'INTERNAL_ERROR',
|
||
'message': str(e), 'trace_id': trace_id}
|
||
|
||
|
||
def _insert_signature_image(doc, marker, image_data, align_h, align_v, height_cm=None,
|
||
relax_layout=True, image_mode='inline',
|
||
log_source='前端操作', trace_id=None):
|
||
"""插入签名图片到 marker 对应的 SET 域后。
|
||
|
||
``height_cm`` 为用户配置的目标高度(厘米)。若为 None,则**透传 None 给
|
||
insert_image_after_field**,由后者根据插入位置的段落行高 + 字号自动推算
|
||
(`_auto_size_height_cm`)。
|
||
|
||
``relax_layout`` 透传给 :func:`field_codes.insert_image_after_field`,
|
||
控制是否自动放宽段落 / 表格行的固定高度约束以避免签名被裁剪。
|
||
|
||
``image_mode`` 透传给 :func:`field_codes.insert_image_after_field`:
|
||
``'inline'`` 嵌入式(默认,撑大当前行)或 ``'anchor'`` 浮动覆盖(不撑大,
|
||
浮在文字上方)。
|
||
"""
|
||
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
|
||
tmp.write(image_data)
|
||
tmp_path = tmp.name
|
||
|
||
try:
|
||
width_cm = None
|
||
if height_cm and height_cm > 0:
|
||
from PIL import Image as PILImage
|
||
img = PILImage.open(io.BytesIO(image_data))
|
||
img_w, img_h = img.size
|
||
width_cm = img_w / img_h * height_cm if img_h else height_cm
|
||
|
||
count = field_codes.insert_image_after_field(
|
||
doc, marker, tmp_path, width_cm, height_cm,
|
||
log_source=log_source, relax_layout=relax_layout,
|
||
mode=image_mode,
|
||
)
|
||
return count
|
||
finally:
|
||
try:
|
||
os.unlink(tmp_path)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _resolve_height(stamp, use_global, global_val):
|
||
if use_global:
|
||
if global_val in (None, ""):
|
||
return None
|
||
try:
|
||
h = float(global_val)
|
||
if 0.1 <= h <= 10.0:
|
||
return round(h, 1)
|
||
except (ValueError, TypeError):
|
||
return None
|
||
return None
|
||
h = stamp.get('height')
|
||
if h not in (None, ""):
|
||
try:
|
||
h = float(h)
|
||
if 0.1 <= h <= 10.0:
|
||
return round(h, 1)
|
||
except (ValueError, TypeError):
|
||
pass
|
||
return None
|
||
|
||
|
||
def _resolve_noise(stamp, use_global, global_val):
|
||
if use_global:
|
||
try:
|
||
return int(global_val) if global_val not in (None, "") else 0
|
||
except (ValueError, TypeError):
|
||
return 0
|
||
n = stamp.get('noise_level')
|
||
if n not in (None, ""):
|
||
try:
|
||
n = int(n)
|
||
if 0 <= n <= 10:
|
||
return n
|
||
except (ValueError, TypeError):
|
||
pass
|
||
return 0
|
||
|
||
|
||
def _resolve_rotate(stamp, use_global, global_min, global_max):
|
||
if use_global:
|
||
try:
|
||
rmin = int(global_min) if global_min not in (None, "") else 0
|
||
except (ValueError, TypeError):
|
||
rmin = 0
|
||
try:
|
||
rmax = int(global_max) if global_max not in (None, "") else 0
|
||
except (ValueError, TypeError):
|
||
rmax = 0
|
||
return max(-180, min(180, rmin)), max(-180, min(180, rmax))
|
||
r_min = stamp.get('rotate_min', 0)
|
||
r_max = stamp.get('rotate_max', 0)
|
||
try:
|
||
r_min = max(-180, min(180, int(r_min)))
|
||
except (ValueError, TypeError):
|
||
r_min = 0
|
||
try:
|
||
r_max = max(-180, min(180, int(r_max)))
|
||
except (ValueError, TypeError):
|
||
r_max = 0
|
||
return r_min, r_max
|