434 lines
18 KiB
Python
434 lines
18 KiB
Python
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_<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
|