first commit

This commit is contained in:
lihansani
2026-07-20 13:16:17 +08:00
commit 7affeaa253
235 changed files with 42351 additions and 0 deletions

0
routes/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

433
routes/api.py Normal file
View File

@@ -0,0 +1,433 @@
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

104
routes/batch_api.py Normal file
View File

@@ -0,0 +1,104 @@
import logging
import threading
from flask import Blueprint, request, jsonify
from lib import batch_processor
from lib.word_signer import _new_trace_id
batch_api_bp = Blueprint('batch_api', __name__)
logger = logging.getLogger(__name__)
_picker_lock = threading.Lock()
@batch_api_bp.route('/api/batch/rules', methods=['GET'])
def batch_rules_get():
path = request.args.get('path') or None
try:
result = batch_processor.load_rules(path)
return jsonify({'success': True, 'rules': result['rules'],
'warning': result.get('warning'),
'path': batch_processor.resolve_rules_path(path)})
except Exception as e:
logger.error("加载规则失败: %s", str(e))
return jsonify({'success': False, 'message': str(e), 'rules': []}), 500
@batch_api_bp.route('/api/batch/rules', methods=['POST'])
def batch_rules_save():
payload = request.get_json(silent=True) or {}
rules = payload.get('rules') or []
path = payload.get('path') or None
try:
result = batch_processor.save_rules(rules, path)
return jsonify({'success': True, 'rules': result['rules'],
'path': result['path'], 'warnings': result.get('warnings', [])})
except RuntimeError as e:
return jsonify({'success': False, 'message': str(e)}), 400
except Exception as e:
logger.error("保存规则失败: %s", str(e))
return jsonify({'success': False, 'message': str(e)}), 500
@batch_api_bp.route('/api/batch/regex-snippets', methods=['GET'])
def batch_snippets():
return jsonify(batch_processor.get_regex_snippets())
@batch_api_bp.route('/api/batch/rules/test', methods=['POST'])
def batch_rules_test():
payload = request.get_json(silent=True) or {}
rules = payload.get('rules') or []
text = payload.get('text') or ''
try:
result = batch_processor.test_rules(rules, text)
return jsonify({'success': True, **result})
except Exception as e:
logger.error("测试规则失败: %s", str(e))
return jsonify({'success': False, 'message': str(e)}), 500
@batch_api_bp.route('/api/batch/pick-folder', methods=['GET'])
def batch_pick_folder():
title = request.args.get('title') or '选择文件夹'
initialdir = request.args.get('initialdir') or None
try:
with _picker_lock:
import tkinter as tk
from tkinter import filedialog
root = tk.Tk()
root.withdraw()
root.attributes('-topmost', True)
folder = filedialog.askdirectory(
title=title, initialdir=initialdir, mustexist=True
)
root.destroy()
return jsonify({'success': True, 'folder': folder or ''})
except Exception as e:
logger.error("选择目录失败: %s", str(e))
return jsonify({'success': False, 'message': str(e), 'folder': ''}), 500
@batch_api_bp.route('/api/batch/process', methods=['POST'])
def batch_process():
payload = request.get_json(silent=True) or {}
folder = payload.get('folder')
if not folder:
return jsonify({'success': False, 'message': '请提供 Word 根目录路径'}), 400
recursive = bool(payload.get('recursive', True))
auto_sign = bool(payload.get('auto_sign', True))
rules = payload.get('rules') or []
request_options = payload.get('request_options') or {}
trace_id = _new_trace_id()
try:
summary = batch_processor.process_folder(
folder, recursive=recursive, auto_sign=auto_sign,
rules=rules, request_options=request_options,
log_source='批量', trace_id=trace_id,
)
return jsonify({'success': summary['success'], **summary, 'trace_id': trace_id})
except Exception as e:
logger.error("批量处理失败: %s", str(e), extra={'log_source': '批量', 'trace_id': trace_id})
return jsonify({'success': False, 'message': str(e), 'trace_id': trace_id}), 500

438
routes/db_api.py Normal file
View File

@@ -0,0 +1,438 @@
import base64
import logging
from flask import Blueprint, request, jsonify, Response
import lib.db as db
from lib.validators import validate_user_id, validate_user_name, validate_image_size
from lib.config import get_limits_config
from lib.image_processor import preview_remove_white, preview_binarize
db_api_bp = Blueprint('db_api', __name__)
logger = logging.getLogger(__name__)
@db_api_bp.route('/api/db/list', methods=['GET'])
def db_list():
page = request.args.get('page', 1, type=int)
page_size = request.args.get('page_size', 20, type=int)
keyword = request.args.get('keyword', '')
try:
result = db.query_all(page=page, page_size=page_size, keyword=keyword or None)
return jsonify({'success': True, 'data': result})
except Exception as e:
logger.error("查询列表失败: %s", str(e))
return jsonify({'success': False, 'message': str(e)}), 500
@db_api_bp.route('/api/db/get/<user_id>', methods=['GET'])
def db_get(user_id):
try:
record = db.query_by_user_id(user_id)
if not record:
return jsonify({'success': False, 'message': f'未找到人员ID={user_id}的记录'}), 404
record['is_signature'] = bool(record['is_signature'])
record.pop('sign_image', None)
return jsonify({'success': True, 'data': record})
except Exception as e:
logger.error("查询记录失败: %s", str(e))
return jsonify({'success': False, 'message': str(e)}), 500
@db_api_bp.route('/api/db/image/<user_id>', methods=['GET'])
def db_get_image(user_id):
try:
record = db.query_by_user_id(user_id)
if not record:
return jsonify({'success': False, 'message': '未找到记录'}), 404
image_data = record['sign_image']
return Response(image_data, mimetype='image/png')
except Exception as e:
logger.error("获取图片失败: %s", str(e))
return jsonify({'success': False, 'message': str(e)}), 500
@db_api_bp.route('/api/db/preview/<user_id>', methods=['GET'])
def db_preview_image(user_id):
try:
record = db.query_by_user_id(user_id)
if not record:
return jsonify({'success': False, 'message': '未找到记录'}), 404
image_data = record['sign_image']
is_sig = bool(record['is_signature'])
if is_sig:
processed = preview_binarize(image_data)
else:
processed = preview_remove_white(image_data)
return Response(processed, mimetype='image/png')
except Exception as e:
logger.error("预览图片失败: %s", str(e))
return jsonify({'success': False, 'message': str(e)}), 500
@db_api_bp.route('/api/db/add', methods=['POST'])
def db_add():
try:
content_type = request.content_type or ''
if 'multipart/form-data' in content_type:
user_id = request.form.get('user_id', '')
user_name = request.form.get('user_name', '')
is_signature = request.form.get('is_signature', '0')
image_file = request.files.get('sign_image')
valid, msg = validate_user_id(user_id)
if not valid:
return jsonify({'success': False, 'message': msg}), 400
valid, msg = validate_user_name(user_name)
if not valid:
return jsonify({'success': False, 'message': msg}), 400
if not image_file:
return jsonify({'success': False, 'message': '缺少签章图片'}), 400
image_data = image_file.read()
is_sig = 1 if is_signature in ('1', 'true', 'True') else 0
db.insert_record(user_id, user_name, image_data, is_sig)
return jsonify({'success': True, 'message': '新增成功'})
json_data = request.get_json(silent=True)
if not json_data:
return jsonify({'success': False, 'message': '无效请求数据'}), 400
user_id = json_data.get('user_id', '')
user_name = json_data.get('user_name', '')
is_signature = json_data.get('is_signature', 0)
image_b64 = json_data.get('sign_image_base64', '')
valid, msg = validate_user_id(user_id)
if not valid:
return jsonify({'success': False, 'message': msg}), 400
valid, msg = validate_user_name(user_name)
if not valid:
return jsonify({'success': False, 'message': msg}), 400
if not image_b64:
return jsonify({'success': False, 'message': '缺少签章图片'}), 400
image_data = base64.b64decode(image_b64)
is_sig = 1 if is_signature in (1, '1', True, 'true') else 0
db.insert_record(user_id, user_name, image_data, is_sig)
return jsonify({'success': True, 'message': '新增成功'})
except ValueError as e:
return jsonify({'success': False, 'message': str(e)}), 400
except Exception as e:
logger.error("新增记录失败: %s", str(e))
return jsonify({'success': False, 'message': str(e)}), 500
@db_api_bp.route('/api/db/update/<user_id>', methods=['PUT'])
def db_update(user_id):
try:
content_type = request.content_type or ''
user_name = None
sign_image = None
is_signature = None
if 'multipart/form-data' in content_type:
user_name = request.form.get('user_name')
is_sig_str = request.form.get('is_signature')
if is_sig_str is not None:
is_signature = 1 if is_sig_str in ('1', 'true', 'True') else 0
image_file = request.files.get('sign_image')
if image_file:
sign_image = image_file.read()
else:
json_data = request.get_json(silent=True)
if json_data:
user_name = json_data.get('user_name')
is_sig_val = json_data.get('is_signature')
if is_sig_val is not None:
is_signature = 1 if is_sig_val in (1, '1', True, 'true') else 0
image_b64 = json_data.get('sign_image_base64')
if image_b64:
sign_image = base64.b64decode(image_b64)
db.update_record(user_id, user_name=user_name, sign_image=sign_image, is_signature=is_signature)
return jsonify({'success': True, 'message': '更新成功'})
except Exception as e:
logger.error("更新记录失败: %s", str(e))
return jsonify({'success': False, 'message': str(e)}), 500
@db_api_bp.route('/api/db/delete/<user_id>', methods=['DELETE'])
def db_delete(user_id):
try:
db.delete_record(user_id)
return jsonify({'success': True, 'message': '删除成功'})
except Exception as e:
logger.error("删除记录失败: %s", str(e))
return jsonify({'success': False, 'message': str(e)}), 500
@db_api_bp.route('/api/db/batch_import', methods=['POST'])
def db_batch_import():
try:
files = request.files.getlist('files')
auto_id = request.form.get('auto_id', '0') == '1'
id_prefix = request.form.get('id_prefix', 'USER')
is_signature = request.form.get('is_signature', '0')
is_sig = 1 if is_signature in ('1', 'true', 'True') else 0
results = []
success_count = 0
fail_count = 0
id_counter = 1
for f in files:
filename = f.filename or ''
name_part = filename.rsplit('.', 1)[0] if '.' in filename else filename
if auto_id:
user_id = f"{id_prefix}{id_counter:04d}"
id_counter += 1
else:
user_id = name_part
user_name = name_part
image_data = f.read()
try:
db.insert_record(user_id, user_name, image_data, is_sig)
results.append({'filename': filename, 'user_id': user_id, 'status': 'success'})
success_count += 1
except ValueError as e:
results.append({'filename': filename, 'user_id': user_id, 'status': 'fail', 'message': str(e)})
fail_count += 1
except Exception as e:
results.append({'filename': filename, 'user_id': user_id, 'status': 'fail', 'message': str(e)})
fail_count += 1
return jsonify({
'success': True,
'data': {
'total': len(files),
'success': success_count,
'fail': fail_count,
'results': results,
}
})
except Exception as e:
logger.error("批量导入失败: %s", str(e))
return jsonify({'success': False, 'message': str(e)}), 500
# ============================================================================
# SPA 兼容端点:/api/db/records* (供 templates/index.html 调用)
# 与上方原 /api/db/* 端点并存,不互相影响。
# ============================================================================
def _records_to_spa(record):
"""将内部记录转换为 SPA 期望的列表项格式。"""
image_size = 0
if record.get('sign_image'):
try:
image_size = len(record['sign_image'])
except Exception:
image_size = 0
return {
'user_id': record.get('user_id'),
'user_name': record.get('user_name'),
'is_signature': bool(record.get('is_signature')),
'image_size': image_size,
'create_time': str(record.get('create_time') or ''),
'update_time': str(record.get('update_time') or ''),
}
@db_api_bp.route('/api/db/records', methods=['GET'])
def db_records_list_spa():
try:
keyword = request.args.get('q') or None
page = request.args.get('page', 1, type=int)
page_size = request.args.get('page_size', 200, type=int)
result = db.query_all(page=page, page_size=page_size, keyword=keyword)
records = [_records_to_spa(r) for r in result.get('data', [])]
return jsonify({'success': True, 'records': records,
'total': result.get('total', len(records))})
except Exception as e:
logger.error("查询记录列表失败: %s", str(e))
return jsonify({'success': False, 'message': str(e), 'records': []}), 500
@db_api_bp.route('/api/db/records', methods=['POST'])
def db_records_add_spa():
payload = request.get_json(silent=True) or {}
user_id = (payload.get('user_id') or '').strip()
user_name = (payload.get('user_name') or '').strip()
is_signature = payload.get('is_signature', False)
image_b64 = payload.get('image_base64') or ''
valid, msg = validate_user_id(user_id)
if not valid:
return jsonify({'success': False, 'message': msg}), 400
valid, msg = validate_user_name(user_name)
if not valid:
return jsonify({'success': False, 'message': msg}), 400
if not image_b64:
return jsonify({'success': False, 'message': '缺少图片'}), 400
try:
image_data = base64.b64decode(image_b64)
except Exception as e:
return jsonify({'success': False, 'message': f'图片 Base64 解码失败: {e}'}), 400
limits = get_limits_config()
valid, msg = validate_image_size(image_data, limits['image_max_bytes'])
if not valid:
return jsonify({'success': False, 'message': msg}), 400
is_sig = 1 if is_signature else 0
try:
db.insert_record(user_id, user_name, image_data, is_sig)
except ValueError as e:
return jsonify({'success': False, 'message': str(e)}), 400
except Exception as e:
logger.error("新增记录失败: %s", str(e))
return jsonify({'success': False, 'message': str(e)}), 500
return jsonify({'success': True,
'record': {'user_id': user_id, 'user_name': user_name,
'is_signature': bool(is_sig)}})
@db_api_bp.route('/api/db/records/<user_id>', methods=['PUT'])
def db_records_update_spa(user_id):
payload = request.get_json(silent=True) or {}
user_name = payload.get('user_name')
is_signature = payload.get('is_signature')
image_b64 = payload.get('image_base64')
sign_image = None
if image_b64:
try:
sign_image = base64.b64decode(image_b64)
except Exception as e:
return jsonify({'success': False, 'message': f'图片 Base64 解码失败: {e}'}), 400
limits = get_limits_config()
valid, msg = validate_image_size(sign_image, limits['image_max_bytes'])
if not valid:
return jsonify({'success': False, 'message': msg}), 400
if user_name is not None:
valid, msg = validate_user_name(user_name)
if not valid:
return jsonify({'success': False, 'message': msg}), 400
is_sig_val = None
if is_signature is not None:
is_sig_val = 1 if is_signature else 0
try:
db.update_record(user_id, user_name=user_name, sign_image=sign_image, is_signature=is_sig_val)
except Exception as e:
logger.error("更新记录失败: %s", str(e))
return jsonify({'success': False, 'message': str(e)}), 500
return jsonify({'success': True,
'record': {'user_id': user_id, 'user_name': user_name,
'is_signature': bool(is_sig_val) if is_sig_val is not None else None}})
@db_api_bp.route('/api/db/records/<user_id>', methods=['DELETE'])
def db_records_delete_spa(user_id):
try:
db.delete_record(user_id)
return jsonify({'success': True})
except Exception as e:
logger.error("删除记录失败: %s", str(e))
return jsonify({'success': False, 'message': str(e)}), 500
@db_api_bp.route('/api/db/records/import', methods=['POST'])
def db_records_import_spa():
"""SPA 批量导入JSON + Base64。
返回结构需让前端能识别失败条目(含 index以便保留待二次处理。
"""
payload = request.get_json(silent=True) or {}
items = payload.get('items') or []
id_prefix = (payload.get('auto_id_prefix') or 'U').strip() or 'U'
id_start = int(payload.get('auto_id_start') or 1)
id_padding = max(1, min(int(payload.get('auto_id_padding') or 4), 20))
default_is_signature = bool(payload.get('default_is_signature', False))
default_is_sig = 1 if default_is_signature else 0
limits = get_limits_config()
imported = 0
failed = 0
failures = []
for idx, item in enumerate(items):
filename = item.get('image_filename') or f"{id_prefix}{idx + 1:0{id_padding}d}.png"
name_part = filename.rsplit('.', 1)[0] if '.' in filename else filename
user_name = item.get('user_name') or name_part
user_id = f"{id_prefix}{(id_start + idx):0{id_padding}d}"
is_signature = item.get('is_signature', default_is_signature)
is_sig = 1 if is_signature else 0
image_b64 = item.get('image_base64') or ''
if not image_b64:
failed += 1
failures.append({'index': idx, 'code': 'MISSING_IMAGE',
'message': '缺少图片 Base64', 'filename': filename})
continue
try:
image_data = base64.b64decode(image_b64)
except Exception as e:
failed += 1
failures.append({'index': idx, 'code': 'INVALID_BASE64',
'message': f'Base64 解码失败: {e}', 'filename': filename})
continue
valid, msg = validate_image_size(image_data, limits['image_max_bytes'])
if not valid:
failed += 1
failures.append({'index': idx, 'code': 'IMAGE_TOO_LARGE',
'message': msg, 'filename': filename})
continue
try:
db.insert_record(user_id, user_name, image_data, is_sig)
imported += 1
except ValueError as e:
failed += 1
failures.append({'index': idx, 'code': 'DUPLICATE',
'message': str(e), 'filename': filename})
except Exception as e:
failed += 1
failures.append({'index': idx, 'code': 'ERROR',
'message': str(e), 'filename': filename})
return jsonify({
'success': True,
'imported': imported,
'failed': failed,
'failures': failures,
})
@db_api_bp.route('/api/db/records/<user_id>/image', methods=['GET'])
def db_records_image_spa(user_id):
try:
record = db.query_by_user_id(user_id)
if not record:
return jsonify({'success': False, 'message': '未找到记录'}), 404
processed = request.args.get('processed') in ('1', 'true', 'True', 'yes')
image_data = record['sign_image']
if processed:
if bool(record.get('is_signature')):
payload = preview_binarize(image_data)
else:
payload = preview_remove_white(image_data)
else:
payload = image_data
return Response(payload, mimetype='image/png')
except Exception as e:
logger.error("获取图片失败: %s", str(e))
return jsonify({'success': False, 'message': str(e)}), 500

24
routes/monitor_api.py Normal file
View File

@@ -0,0 +1,24 @@
import logging
from flask import Blueprint, request, jsonify
from lib import monitor
monitor_api_bp = Blueprint('monitor_api', __name__)
logger = logging.getLogger(__name__)
@monitor_api_bp.route('/api/monitor/requests', methods=['GET'])
def monitor_requests():
status = request.args.get('status') or None
keyword = request.args.get('q') or None
limit = request.args.get('limit', 200)
items = monitor.query_requests(status=status, keyword=keyword, limit=limit)
return jsonify({'success': True, 'requests': items})
@monitor_api_bp.route('/api/monitor/requests/<trace_id>', methods=['GET'])
def monitor_request_detail(trace_id):
entry = monitor.get_request(trace_id)
if not entry:
return jsonify({'success': False, 'message': '未找到 trace_id 对应记录'}), 404
return jsonify({'success': True, 'request': entry})

85
routes/pages.py Normal file
View File

@@ -0,0 +1,85 @@
import json
import logging
import time
from flask import Blueprint, render_template, request, Response, stream_with_context, jsonify
from lib.log_handler import subscribe, unsubscribe, get_all_logs, query_logs
pages_bp = Blueprint('pages', __name__)
logger = logging.getLogger(__name__)
@pages_bp.route('/')
def index():
return render_template('index.html')
@pages_bp.route('/db-manager')
def db_manager():
return render_template('db_manager.html')
@pages_bp.route('/logs')
def logs_page():
return render_template('logs.html')
@pages_bp.route('/api/logs/stream')
def logs_stream():
sub_id, q = subscribe()
def generate():
try:
for entry in get_all_logs():
yield f"data: {json.dumps(entry, ensure_ascii=False)}\n\n"
while True:
if q:
while q:
entry = q.popleft()
yield f"data: {json.dumps(entry, ensure_ascii=False)}\n\n"
else:
time.sleep(0.1)
except GeneratorExit:
pass
finally:
unsubscribe(sub_id)
return Response(
stream_with_context(generate()),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no',
'Connection': 'keep-alive',
}
)
@pages_bp.route('/api/logs', methods=['GET'])
def logs_poll():
"""SPA 兼容的轮询接口:按 after_id 增量返回,并支持 level/source/q 过滤。"""
after_id = request.args.get('after_id', 0)
limit = request.args.get('limit', 200)
level = request.args.get('level') or None
source = request.args.get('source') or None
keyword = request.args.get('q') or None
result = query_logs(after_id=after_id, level=level, source=source,
keyword=keyword, limit=limit)
return jsonify({'success': True, **result})
@pages_bp.route('/api/logs/history')
def logs_history():
source = request.args.get('source', 'all')
level = request.args.get('level', 'all')
keyword = request.args.get('keyword', '')
logs = get_all_logs()
if source != 'all':
logs = [l for l in logs if l.get('source') == source]
if level != 'all':
logs = [l for l in logs if l.get('level') == level]
if keyword:
logs = [l for l in logs if keyword.lower() in l.get('message', '').lower()]
return jsonify({'success': True, 'data': logs})