import base64 import io import logging import time from urllib.parse import quote from flask import Blueprint, request, jsonify, Response from PIL import Image as PILImage from docx import Document from lib.word_signer import sign_word, _new_trace_id from lib.image_processor import process_image from lib import field_codes from lib.validators import validate_sign_request, validate_docx_size, validate_image_size from lib.config import get_limits_config, get_oa_config from lib import monitor import lib.db as db api_bp = Blueprint('api', __name__) logger = logging.getLogger(__name__) def _error_body(code, message, trace_id, **extra): body = {'success': False, 'code': code, 'message': message, 'trace_id': trace_id} if extra: body['data'] = extra return body @api_bp.route('/api/word/sign', methods=['POST']) def word_sign(): log_source = '外部API' trace_id = _new_trace_id() client_ip = request.remote_addr start_ts = time.time() limits = get_limits_config() monitor.register_request( trace_id, client_ip, '/api/word/sign', request_body_summary={'content_type': request.content_type} ) logger.info("[请求接入] 客户端IP=%s, 接口=/api/word/sign, trace_id=%s", client_ip, trace_id, extra={'log_source': log_source, 'trace_id': trace_id}) content_type = request.content_type or '' if 'application/json' in content_type: json_data = request.get_json(silent=True) if not json_data: logger.error("[参数校验] 请求体JSON解析失败", extra={'log_source': log_source, 'trace_id': trace_id}) monitor.finish_request(trace_id, 'error', response_status=400, elapsed_ms=int((time.time() - start_ts) * 1000), error_message='INVALID_JSON') return jsonify(_error_body('INVALID_JSON', '请求体JSON解析失败', trace_id)), 400 params = json_data docx_base64 = params.get('docx_base64', '') if not docx_base64: logger.error("[参数校验] 缺少docx_base64字段", extra={'log_source': log_source, 'trace_id': trace_id}) monitor.finish_request(trace_id, 'error', response_status=400, elapsed_ms=int((time.time() - start_ts) * 1000), error_message='MISSING_DOCX') return jsonify(_error_body('MISSING_DOCX', '缺少docx_base64字段', trace_id)), 400 try: docx_data = base64.b64decode(docx_base64) except Exception as e: logger.error("[参数校验] docx_base64解码失败: %s", str(e), extra={'log_source': log_source, 'trace_id': trace_id}) monitor.finish_request(trace_id, 'error', response_status=400, elapsed_ms=int((time.time() - start_ts) * 1000), error_message='INVALID_BASE64') return jsonify(_error_body('INVALID_BASE64', f'docx_base64解码失败: {e}', trace_id)), 400 elif 'multipart/form-data' in content_type: docx_file = request.files.get('docx') if not docx_file: logger.error("[参数校验] multipart请求缺少docx文件", extra={'log_source': log_source, 'trace_id': trace_id}) monitor.finish_request(trace_id, 'error', response_status=400, elapsed_ms=int((time.time() - start_ts) * 1000), error_message='MISSING_DOCX') return jsonify(_error_body('MISSING_DOCX', '缺少docx文件', trace_id)), 400 docx_data = docx_file.read() params = { 'docx_name': docx_file.filename or 'upload.docx', 'match_mode': request.form.get('match_mode', 'upload'), 'stamps': [], } import json try: params['stamps'] = json.loads(request.form.get('stamps', '[]')) except Exception: params['stamps'] = [] image_file = request.files.get('image') if image_file: img_bytes = image_file.read() valid, msg = validate_image_size(img_bytes, limits['image_max_bytes']) if not valid: logger.error("[参数校验] 上传图片过大: %s", msg, extra={'log_source': log_source, 'trace_id': trace_id}) monitor.finish_request(trace_id, 'error', response_status=400, elapsed_ms=int((time.time() - start_ts) * 1000), error_message='IMAGE_TOO_LARGE') return jsonify(_error_body('IMAGE_TOO_LARGE', msg, trace_id)), 400 img_b64 = base64.b64encode(img_bytes).decode('utf-8') for s in params['stamps']: s['image_base64'] = img_b64 s['image_filename'] = image_file.filename or 'image.png' for key in ['use_global_align', 'align_h', 'align_v', 'use_global_height', 'height', 'use_global_noise', 'noise_level', 'use_global_rotate', 'rotate_min', 'rotate_max']: if key in request.form: val = request.form[key] if val.lower() in ('true', 'false'): params[key] = val.lower() == 'true' else: try: if '.' in val: params[key] = float(val) else: params[key] = int(val) except ValueError: params[key] = val else: logger.error("[参数校验] 不支持的Content-Type: %s", content_type, extra={'log_source': log_source, 'trace_id': trace_id}) monitor.finish_request(trace_id, 'error', response_status=400, elapsed_ms=int((time.time() - start_ts) * 1000), error_message='INVALID_CONTENT_TYPE') return jsonify(_error_body('INVALID_CONTENT_TYPE', f'不支持的Content-Type: {content_type}', trace_id)), 400 # SRS 9.1/15.3.3:docx 文档 ≤ 1 MB valid, msg = validate_docx_size(docx_data, limits['docx_max_bytes']) if not valid: logger.error("[参数校验] %s", msg, extra={'log_source': log_source, 'trace_id': trace_id}) monitor.finish_request(trace_id, 'error', response_status=400, elapsed_ms=int((time.time() - start_ts) * 1000), error_message='DOCX_TOO_LARGE') return jsonify(_error_body('DOCX_TOO_LARGE', msg, trace_id)), 400 # SRS 15.3.3:upload 模式下图片大小校验 if params.get('match_mode') == 'upload': for idx, s in enumerate(params.get('stamps', [])): b64 = s.get('image_base64') or '' if b64: try: img_bytes = base64.b64decode(b64) valid, msg = validate_image_size(img_bytes, limits['image_max_bytes']) if not valid: logger.error("[参数校验] 盖章项%d图片过大: %s", idx + 1, msg, extra={'log_source': log_source, 'trace_id': trace_id}) monitor.finish_request(trace_id, 'error', response_status=400, elapsed_ms=int((time.time() - start_ts) * 1000), error_message='IMAGE_TOO_LARGE') return jsonify(_error_body('IMAGE_TOO_LARGE', f"盖章项{idx + 1}: {msg}", trace_id)), 400 except Exception as e: monitor.finish_request(trace_id, 'error', response_status=400, elapsed_ms=int((time.time() - start_ts) * 1000), error_message='INVALID_BASE64') return jsonify(_error_body('INVALID_BASE64', f"盖章项{idx + 1}: 图片Base64解码失败", trace_id)), 400 errors = validate_sign_request(params) if errors: logger.error("[参数校验] 校验失败: %s", '; '.join(errors), extra={'log_source': log_source, 'trace_id': trace_id}) monitor.finish_request(trace_id, 'error', response_status=400, elapsed_ms=int((time.time() - start_ts) * 1000), error_message='VALIDATION_ERROR') return jsonify(_error_body('VALIDATION_ERROR', '; '.join(errors), trace_id)), 400 logger.info("[参数校验] 校验通过", extra={'log_source': log_source, 'trace_id': trace_id}) # SRS 15.3.4:需要数据库的模式下 MySQL 未启动或连接失败,返回 5xx if params.get('match_mode') in ('id', 'name', 'auto'): if not db.ensure_mysql_available(): logger.error("[数据库] MySQL 不可用,按 SRS 15.3.4 返回 5xx", extra={'log_source': log_source, 'trace_id': trace_id}) monitor.finish_request(trace_id, 'error', response_status=503, elapsed_ms=int((time.time() - start_ts) * 1000), error_message='MYSQL_UNAVAILABLE') return jsonify(_error_body('MYSQL_UNAVAILABLE', 'MySQL 不可用,请检查数据库配置', trace_id)), 503 result, error = sign_word(docx_data, params, log_source=log_source, trace_id=trace_id, client_ip=client_ip) if error: code = error.get('code', 'INTERNAL_ERROR') status_code = 400 if code in ('INVALID_FILE_TYPE', 'INVALID_MATCH_MODE', 'TOO_MANY_STAMPS', 'INVALID_DOCX', 'DOCX_TOO_LARGE', 'IMAGE_TOO_LARGE', 'VALIDATION_ERROR') else 500 monitor.finish_request(trace_id, 'error', response_status=status_code, elapsed_ms=int((time.time() - start_ts) * 1000), error_message=code) return jsonify(error), status_code filename = result['filename'] response = Response( result['data'], mimetype='application/vnd.openxmlformats-officedocument.wordprocessingml.document', ) ascii_fallback = filename.encode('ascii', errors='replace').decode('ascii') response.headers['Content-Disposition'] = ( f"attachment; filename=\"{ascii_fallback}\"; " f"filename*=UTF-8''{quote(filename, safe='')}" ) response.headers['X-SEAL-Trace-Id'] = result.get('trace_id', trace_id) response.headers['X-SEAL-Warnings'] = str(len(result.get('warnings', []))) if result.get('warnings'): joined = '; '.join(result['warnings']) encoded = quote(joined, safe='') encoded_bytes = encoded.encode('latin-1', errors='replace') while len(encoded_bytes) > 1024: encoded = encoded[:-1] encoded_bytes = encoded.encode('latin-1', errors='replace') response.headers['X-SEAL-Warning-Detail'] = encoded elapsed_ms = int((time.time() - start_ts) * 1000) monitor.finish_request( trace_id, 'success', response_status=200, elapsed_ms=elapsed_ms, response_summary={'filename': filename, 'success_count': result.get('success_count', 0)}, warnings=result.get('warnings', []) ) return response def _image_data_url(data): """根据魔数判断图片格式并构造 data URL。""" if data[:8] == b'\x89PNG\r\n\x1a\n': mime = 'image/png' elif data[:3] == b'\xff\xd8\xff': mime = 'image/jpeg' elif data[:6] in (b'GIF87a', b'GIF89a'): mime = 'image/gif' else: mime = 'image/png' return f'data:{mime};base64,' + base64.b64encode(data).decode('ascii') def _resolve_preview_image(payload): """根据 payload 解析出 (image_bytes, is_signature) 或 (None, error_msg)。""" match_mode = payload.get('match_mode', 'upload') if match_mode == 'upload': b64 = payload.get('image_base64') or '' if not b64: return None, 'upload 模式缺少 image_base64' try: return base64.b64decode(b64), None except Exception as e: return None, f'Base64 解码失败: {e}' if not db.ensure_mysql_available(): return None, 'MySQL 不可用,无法按人员查询' is_sig_from_db = None if match_mode == 'id': image_id = (payload.get('image_id') or '').strip() if not image_id: return None, 'id 模式缺少 image_id' record = db.get_image_by_user_id(image_id) if not record: return None, f'未找到 image_id={image_id}' return record['sign_image'], None if match_mode == 'name': name = (payload.get('image_name') or '').strip() if not name: return None, 'name 模式缺少 image_name' records = db.get_image_by_user_name(name) if not records: return None, f'未找到 image_name={name}' return records[0]['sign_image'], None if match_mode == 'auto': marker = (payload.get('marker') or '').strip() if not marker: return None, 'auto 模式缺少 marker' name = marker[5:] if marker.startswith('USER_') else marker name = field_codes._strip_switches(name) if not name: return None, f'marker={marker} 无法解析出姓名' records = db.get_image_by_user_name(name) if not records: return None, f'未找到姓名={name}' return records[0]['sign_image'], None return None, f'不支持的 match_mode: {match_mode}' def _parse_float(value, default=None, lo=None, hi=None): if value in (None, ''): return default try: v = float(value) except (ValueError, TypeError): return default if lo is not None and v < lo: return default if hi is not None and v > hi: return default return v def _parse_int(value, default=0, lo=None, hi=None): if value in (None, ''): return default try: v = int(value) except (ValueError, TypeError): return default if lo is not None and v < lo: return default if hi is not None and v > hi: return default return v @api_bp.route('/api/preview/sign-image', methods=['POST']) def preview_sign_image(): """预览:原签名图 vs 处理后效果(走完整 process_image 流水线)。""" log_source = '前端操作' payload = request.get_json(silent=True) or {} image_data, err = _resolve_preview_image(payload) if err: logger.warning("[图片预览] 解析图片失败: %s", err, extra={'log_source': log_source}) return jsonify({'success': False, 'message': err}), 400 is_sig = bool(payload.get('is_signature', False)) height_cm = _parse_float(payload.get('height_cm'), default=None, lo=0.1, hi=10.0) noise_level = _parse_int(payload.get('noise_level'), default=0, lo=0, hi=10) rotate_min = _parse_int(payload.get('rotate_min'), default=0, lo=-180, hi=180) rotate_max = _parse_int(payload.get('rotate_max'), default=0, lo=-180, hi=180) try: processed = process_image( image_data, is_signature=is_sig, noise_level=noise_level, rotate_min=rotate_min, rotate_max=rotate_max, log_source=log_source, ) except Exception as e: logger.error("[图片预览] 处理失败: %s", str(e), extra={'log_source': log_source}) return jsonify({'success': False, 'message': f'图片处理失败: {e}'}), 500 try: pimg = PILImage.open(io.BytesIO(processed)) p_w, p_h = pimg.size except Exception: p_w, p_h = 0, 0 try: oimg = PILImage.open(io.BytesIO(image_data)) o_w, o_h = oimg.size except Exception: o_w, o_h = 0, 0 return jsonify({ 'success': True, 'original': _image_data_url(image_data), 'processed': _image_data_url(processed), 'original_size': len(image_data), 'processed_size': len(processed), 'original_dimensions': [o_w, o_h], 'processed_dimensions': [p_w, p_h], 'is_signature': is_sig, 'params': { 'height_cm': height_cm, 'noise_level': noise_level, 'rotate_min': rotate_min, 'rotate_max': rotate_max, }, }) @api_bp.route('/api/doc/scan-markers', methods=['POST']) def doc_scan_markers(): """扫描 docx 中所有 SET USER_ 标记,用于 auto 模式预览。""" log_source = '前端操作' payload = request.get_json(silent=True) or {} docx_b64 = payload.get('docx_base64') or '' if not docx_b64: return jsonify({'success': False, 'message': '缺少 docx_base64'}), 400 try: docx_data = base64.b64decode(docx_b64) except Exception as e: return jsonify({'success': False, 'message': f'docx Base64 解码失败: {e}'}), 400 try: doc = Document(io.BytesIO(docx_data)) except Exception as e: return jsonify({'success': False, 'message': f'Word 解析失败: {e}'}), 400 markers = field_codes.scan_user_markers(doc, log_source=log_source) return jsonify({ 'success': True, 'markers': [{'marker': m['marker'], 'name': m['name']} for m in markers], }) @api_bp.route('/api/health', methods=['GET']) def health(): limits = get_limits_config() oa = get_oa_config() mysql_enabled = (db.get_active_db_type() == 'mysql') return jsonify({ 'success': True, 'mysql_enabled': mysql_enabled, 'docx_max_bytes': limits['docx_max_bytes'], 'image_max_bytes': limits['image_max_bytes'], 'stamp_max_count': limits['stamp_max_count'], 'action_timeout_seconds': oa['action_timeout_seconds'], 'db_type': db.get_active_db_type(), }) @api_bp.route('/api/db/init', methods=['POST']) def init_db_endpoint(): try: ok = db.init_database(strict_mysql=False) if not ok: return jsonify({'success': False, 'message': 'MySQL 不可用'}), 500 return jsonify({'success': True, 'message': f"数据库已初始化: {db.get_active_db_type()}"}) except Exception as e: logger.error("数据库初始化失败: %s", str(e)) return jsonify({'success': False, 'message': str(e)}), 500