Files
SignatureSystem/routes/api.py

434 lines
18 KiB
Python
Raw Normal View History

2026-07-20 13:16:17 +08:00
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.3docx 文档 ≤ 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.3upload 模式下图片大小校验
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_<name> 标记,用于 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