feat: support permit file uploads

This commit is contained in:
Codex Agent 2025-11-13 15:28:08 +08:00
parent 5b86bd8799
commit 772354bd01
3 changed files with 1122 additions and 32 deletions

View File

@ -1,10 +1,14 @@
"""V2 API routes - Enhanced implementation with structured results."""
from __future__ import annotations
import os
import time
from flask import Blueprint, jsonify, request
from io import BytesIO
from flask import Blueprint, jsonify, request, send_file
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict
from lawrisk.api.auth import login_required, get_current_user
from lawrisk.services.lawrisk_v2_service import search_v2, list_regions
from lawrisk.services.licensing_repo import (
list_permits_for_region,
@ -15,6 +19,13 @@ from lawrisk.services.licensing_repo import (
list_checkpoints,
restore_checkpoint,
delete_checkpoint,
list_permit_risk_snapshot_summaries,
count_permit_risk_snapshots,
delete_region_permit,
restore_permit_risk_snapshot_batch,
start_permit_import_session,
commit_permit_import_session,
fetch_permit_file,
)
from lawrisk.services.lawrisk_service import suggest_questions_embed
@ -147,6 +158,18 @@ def test_simple():
"""Very simple test."""
return jsonify({"status": "ok"})
@v2_bp.route('/db_admin', methods=['GET'])
@login_required
def db_admin_page():
"""Serve the database administration UI."""
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
html_path = os.path.join(project_root, 'static', 'db_admin.html')
if not os.path.exists(html_path):
return jsonify({"success": False, "message": "DB admin page not found"}), 404
return send_file(html_path, mimetype='text/html')
@v2_bp.route('/admin/regions', methods=['GET'])
def admin_regions():
@ -223,6 +246,119 @@ def admin_permits():
return jsonify({"success": False, "message": str(exc)}), 500
@v2_bp.route('/admin/permit-import/upload', methods=['POST'])
def admin_permit_import_upload():
"""Upload Excel workbook and start an import session."""
if 'file' not in request.files:
return jsonify({"success": False, "message": "请选择要上传的Excel文件"}), 400
file_storage = request.files['file']
filename = file_storage.filename or 'import.xlsx'
file_bytes = file_storage.read()
user = get_current_user() or {}
uploaded_by = user.get("display_name") or user.get("username") or user.get("id")
content_type = file_storage.mimetype or "application/octet-stream"
if not file_bytes:
return jsonify({"success": False, "message": "上传的文件为空"}), 400
try:
data = start_permit_import_session(
file_bytes,
filename,
content_type=content_type,
uploaded_by=str(uploaded_by) if uploaded_by else None,
)
return jsonify({"success": True, "data": data})
except ValueError as exc:
return jsonify({"success": False, "message": str(exc)}), 400
except Exception as exc:
print(f"admin_permit_import_upload error: {exc}")
return jsonify({"success": False, "message": str(exc)}), 500
@v2_bp.route('/admin/permit-import/template', methods=['GET'])
def admin_permit_import_template():
"""Provide the Excel import template for download."""
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
template_path = os.path.join(project_root, 'data', 'template', '风险提示表 模板.xlsx')
if not os.path.exists(template_path):
return jsonify({"success": False, "message": "模板文件不存在,请联系管理员"}), 404
try:
return send_file(
template_path,
as_attachment=True,
download_name='风险提示表 模板.xlsx',
)
except Exception as exc:
print(f"admin_permit_import_template error: {exc}")
return jsonify({"success": False, "message": "模板文件暂时无法下载"}), 500
@v2_bp.route('/admin/permit-import/commit', methods=['POST'])
def admin_permit_import_commit():
"""Commit an import session with selected sheets."""
payload = request.get_json(silent=True) or {}
session_id = payload.get('session_id') or payload.get('sessionId')
sheet_names = payload.get('sheet_names') or payload.get('sheets') or payload.get('selectedSheets')
overrides = payload.get('overrides') or {}
edited_by = payload.get('edited_by') or payload.get('editedBy')
change_summary = payload.get('change_summary') or payload.get('changeSummary')
if isinstance(sheet_names, str):
sheet_names = [sheet_names]
try:
data = commit_permit_import_session(
session_id,
sheet_names or [],
overrides=overrides,
edited_by=edited_by,
change_summary=change_summary,
)
return jsonify({"success": True, "data": data})
except ValueError as exc:
return jsonify({"success": False, "message": str(exc)}), 400
except Exception as exc:
print(f"admin_permit_import_commit error: {exc}")
return jsonify({"success": False, "message": str(exc)}), 500
@v2_bp.route('/admin/permit-file/download', methods=['GET'])
@login_required
def admin_permit_file_download():
"""Download the original Excel file associated with a permit."""
region_value = request.args.get("region") or request.args.get("region_id")
permit_value = request.args.get("permit") or request.args.get("permit_id")
if not region_value or not permit_value:
return jsonify({"success": False, "message": "region 和 permit 参数必填"}), 400
region_token = region_value.strip() if isinstance(region_value, str) else str(region_value)
permit_token = permit_value.strip() if isinstance(permit_value, str) else str(permit_value)
try:
file_payload = fetch_permit_file(region_token, permit_token)
if not file_payload:
return jsonify({"success": False, "message": "当前许可没有关联的原始文件"}), 404
buffer = BytesIO(file_payload["file_data"])
buffer.seek(0)
download_name = file_payload.get("filename") or "许可导入.xlsx"
mimetype = file_payload.get("content_type") or "application/octet-stream"
return send_file(
buffer,
as_attachment=True,
download_name=download_name,
mimetype=mimetype,
)
except Exception as exc:
print(f"admin_permit_file_download error: {exc}")
return jsonify({"success": False, "message": "暂无法下载原始文件"}), 500
@v2_bp.route('/admin/permit-details', methods=['GET'])
def admin_permit_details():
"""Get detailed information for a specific permit."""
@ -256,6 +392,63 @@ def admin_permit_details():
return jsonify({"success": False, "message": str(exc)}), 500
@v2_bp.route('/admin/permits', methods=['DELETE'])
def admin_delete_permit():
"""Delete a permit for a specific region-theme combination after snapshotting risks."""
payload: Dict[str, Any] = {}
if request.is_json:
payload = request.get_json(silent=True) or {}
elif request.form:
payload = request.form.to_dict(flat=True)
region_value = (
payload.get("region_id")
or payload.get("region")
or request.args.get("region_id")
or request.args.get("region")
)
theme_value = (
payload.get("theme_id")
or payload.get("theme")
or request.args.get("theme_id")
or request.args.get("theme")
)
permit_value = (
payload.get("permit_id")
or payload.get("permit")
or request.args.get("permit_id")
or request.args.get("permit")
)
edited_by = (payload.get("edited_by") or request.args.get("edited_by") or "").strip() or None
change_summary = payload.get("change_summary") or request.args.get("change_summary")
if change_summary is not None:
change_summary = str(change_summary).strip()
if not change_summary:
change_summary = None
if not region_value or not theme_value or not permit_value:
return jsonify({"success": False, "message": "region_id, theme_id, permit_id 均为必填"}), 400
region_id = region_value.strip() if isinstance(region_value, str) else str(region_value)
theme_id = theme_value.strip() if isinstance(theme_value, str) else str(theme_value)
permit_id = permit_value.strip() if isinstance(permit_value, str) else str(permit_value)
try:
result = delete_region_permit(
region_id,
theme_id,
permit_id,
edited_by=edited_by,
change_summary=change_summary,
)
return jsonify({"success": True, "data": result})
except ValueError as exc:
return jsonify({"success": False, "message": str(exc)}), 404
except Exception as exc:
print(f"admin_delete_permit error: {exc}")
return jsonify({"success": False, "message": str(exc)}), 500
@v2_bp.route('/admin/checkpoints', methods=['GET'])
def admin_list_checkpoints():
"""List all available checkpoints."""
@ -267,6 +460,88 @@ def admin_list_checkpoints():
return jsonify({"success": False, "message": str(exc)}), 500
@v2_bp.route('/admin/permit-risk-snapshots', methods=['GET'])
def admin_permit_risk_snapshots():
"""List permit risk checkpoint history entries for the management UI."""
try:
args = request.args
region_id = args.get("region_id") or args.get("region")
permit_id = args.get("permit_id") or args.get("permit")
edited_by = args.get("edited_by")
try:
limit = int(args.get("limit", 20))
except (TypeError, ValueError):
limit = 20
limit = max(1, min(limit, 200))
try:
offset = int(args.get("offset", 0))
except (TypeError, ValueError):
offset = 0
offset = max(0, offset)
snapshots = list_permit_risk_snapshot_summaries(
region_id=region_id,
permit_id=permit_id,
edited_by=edited_by,
limit=limit,
offset=offset,
)
total = count_permit_risk_snapshots(
region_id=region_id,
permit_id=permit_id,
edited_by=edited_by,
)
return jsonify(
{
"success": True,
"data": {
"snapshots": snapshots,
"pagination": {
"limit": limit,
"offset": offset,
"total": total,
},
},
}
)
except Exception as exc:
print(f"admin_permit_risk_snapshots error: {exc}")
return jsonify({"success": False, "message": str(exc)}), 500
@v2_bp.route('/admin/permit-risk-snapshots/<batch_id>/restore', methods=['POST'])
def admin_restore_permit_risk_snapshot(batch_id):
"""Restore permit risk relations from a snapshot batch."""
payload: Dict[str, Any] = {}
if request.is_json:
payload = request.get_json(silent=True) or {}
elif request.form:
payload = request.form.to_dict(flat=True)
edited_by = (payload.get("edited_by") or "").strip() or None
change_summary = payload.get("change_summary")
if change_summary is not None:
change_summary = str(change_summary).strip()
if not change_summary:
change_summary = None
try:
result = restore_permit_risk_snapshot_batch(
batch_id,
edited_by=edited_by,
change_summary=change_summary,
)
return jsonify({"success": True, "data": result})
except ValueError as exc:
return jsonify({"success": False, "message": str(exc)}), 404
except Exception as exc:
print(f"admin_restore_permit_risk_snapshot error: {exc}")
return jsonify({"success": False, "message": str(exc)}), 500
@v2_bp.route('/admin/checkpoints', methods=['POST'])
def admin_create_checkpoint():
"""Create a new checkpoint."""

View File

@ -5,6 +5,7 @@ import logging
import os
import re
from collections import OrderedDict, defaultdict
import hashlib
from datetime import datetime, date
from decimal import Decimal
from io import BytesIO
@ -47,6 +48,7 @@ EXTRA_NEWLINES_RE = re.compile(r"\n{3,}")
TEXT_SPLIT_PATTERN = re.compile(r"[,\uff0c;\uff1b、\n\r]+")
PERMIT_IMPORT_TTL_SECONDS = 1800
MAX_PERMIT_FILE_SIZE_BYTES = 500 * 1024 # 500 KB limit for uploaded Excel files
_PERMIT_IMPORT_SESSIONS: Dict[str, Dict[str, Any]] = {}
_PERMIT_IMPORT_LOCK = threading.Lock()
@ -154,6 +156,9 @@ _IMPORT_HEADER_KEYWORDS: List[Tuple[str, Tuple[str, ...]]] = [
_PERMIT_SOURCES_TABLE_PRESENT: Optional[bool] = None
_PERMIT_SOURCES_TABLE_LOCK = threading.Lock()
_PERMIT_FILE_SCHEMA_READY: Optional[bool] = None
_PERMIT_FILE_SCHEMA_LOCK = threading.Lock()
_CANONICAL_REGION_KEYWORDS: Dict[str, Tuple[str, ...]] = {
"市级": ("市级", "全市", "佛山市本级", "佛山市市级"),
"禅城区": ("禅城区", "禅城"),
@ -552,8 +557,19 @@ def _cleanup_expired_import_sessions() -> None:
_PERMIT_IMPORT_SESSIONS.pop(session_id, None)
def start_permit_import_session(file_bytes: bytes, filename: str) -> Dict[str, Any]:
def start_permit_import_session(
file_bytes: bytes,
filename: str,
*,
content_type: Optional[str] = None,
uploaded_by: Optional[str] = None,
) -> Dict[str, Any]:
"""Parse the uploaded workbook and create an import session."""
if not file_bytes:
raise ValueError("上传的文件为空")
if len(file_bytes) > MAX_PERMIT_FILE_SIZE_BYTES:
raise ValueError("上传的文件超过 500KB 限制,请拆分或压缩内容后重试")
parsed = _parse_import_workbook(file_bytes, filename)
workbook_filename = parsed.get("filename") or os.path.basename(filename or "")
raw_sheet_payloads: Dict[str, Dict[str, Any]] = parsed.get("sheets", {})
@ -702,6 +718,10 @@ def start_permit_import_session(file_bytes: bytes, filename: str) -> Dict[str, A
"filename": workbook_filename,
"created_at": time.time(),
"sheets": session_sheets,
"file_bytes": bytes(file_bytes),
"file_size": len(file_bytes),
"content_type": content_type or "application/octet-stream",
"uploaded_by": uploaded_by,
}
with _PERMIT_IMPORT_LOCK:
@ -713,6 +733,8 @@ def start_permit_import_session(file_bytes: bytes, filename: str) -> Dict[str, A
"sheet_summaries": sheet_summaries,
"total_rows": parsed.get("total_rows", 0),
"expires_in": PERMIT_IMPORT_TTL_SECONDS,
"file_size": len(file_bytes),
"content_type": content_type or "application/octet-stream",
}
@ -959,6 +981,10 @@ def commit_permit_import_session(
if not session_payload:
raise ValueError("导入会话不存在或已过期请重新上传Excel文件")
session_file_bytes: Optional[bytes] = session_payload.get("file_bytes")
session_file_content_type: str = session_payload.get("content_type") or "application/octet-stream"
session_uploaded_by: Optional[str] = session_payload.get("uploaded_by")
session_sheets: Dict[str, Dict[str, Any]] = session_payload.get("sheets", {})
workbook_filename = session_payload.get("filename") or ""
@ -992,9 +1018,22 @@ def commit_permit_import_session(
", ".join(selected_sheets),
)
stored_file_meta: Optional[Dict[str, Any]] = None
stored_file_id: Optional[str] = None
with _lic_pg_conn(autocommit=False) as conn:
try:
_ensure_permit_sources_table(conn)
if session_file_bytes:
_ensure_permit_file_schema(conn)
stored_file_meta = _insert_permit_file_record(
conn,
file_bytes=session_file_bytes,
filename=workbook_filename or "许可导入.xlsx",
content_type=session_file_content_type,
uploaded_by=session_uploaded_by,
)
stored_file_id = stored_file_meta.get("file_id")
cur = conn.cursor()
for sheet_name in selected_sheets:
@ -1030,6 +1069,7 @@ def commit_permit_import_session(
permit_id = existing_permits.get(canonical_permit_name)
should_override = canonical_permit_name in override_set
permit_modified = False
if permit_id and not should_override:
sheet_skipped.append(canonical_permit_name)
@ -1067,6 +1107,7 @@ def commit_permit_import_session(
"snapshot_batch_id": backup_info.get("batch_id", ""),
}
)
permit_modified = True
else:
permit_id = _ensure_permit(conn, canonical_permit_name)
existing_permits[canonical_permit_name] = permit_id
@ -1078,6 +1119,7 @@ def commit_permit_import_session(
"region_id": region_id,
}
)
permit_modified = True
theme_names: Set[str] = set()
scope_descriptions: Set[str] = set()
@ -1231,6 +1273,14 @@ def commit_permit_import_session(
json.dumps(source_detail_payload, ensure_ascii=False),
),
)
if stored_file_id and permit_modified:
_link_file_to_permit(
conn,
file_id=stored_file_id,
region_id=region_id,
permit_id=permit_id,
created_by=session_uploaded_by or edited_by,
)
result["processed_sheets"].append(
{
@ -1261,6 +1311,8 @@ def commit_permit_import_session(
conn.rollback()
raise
result["file_attachment"] = stored_file_meta
with _PERMIT_IMPORT_LOCK:
_PERMIT_IMPORT_SESSIONS.pop(session_id, None)
@ -1353,6 +1405,220 @@ def _ensure_permit_sources_table(conn: Optional[pg.Connection] = None) -> None:
_PERMIT_SOURCES_TABLE_PRESENT = True
def _create_permit_file_schema(conn: pg.Connection) -> None:
"""Create permit file storage tables on demand."""
cur = conn.cursor()
cur.execute(
"""
CREATE TABLE IF NOT EXISTS permit_files (
id uuid PRIMARY KEY,
filename text NOT NULL,
content_type text,
file_size integer NOT NULL,
file_data bytea NOT NULL,
checksum text,
uploaded_by text,
created_at timestamptz NOT NULL DEFAULT now()
)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS permit_file_links (
id uuid PRIMARY KEY,
region_id uuid NOT NULL REFERENCES regions(id) ON DELETE CASCADE,
permit_id uuid NOT NULL REFERENCES permits(id) ON DELETE CASCADE,
file_id uuid NOT NULL REFERENCES permit_files(id) ON DELETE CASCADE,
created_by text,
created_at timestamptz NOT NULL DEFAULT now()
)
"""
)
cur.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS permit_file_links_region_permit_idx
ON permit_file_links (region_id, permit_id)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS permit_file_links_permit_idx
ON permit_file_links (permit_id)
"""
)
def _ensure_permit_file_schema(conn: Optional[pg.Connection] = None) -> None:
"""Ensure permit file tables exist (lazy creation, thread safe)."""
global _PERMIT_FILE_SCHEMA_READY
if _PERMIT_FILE_SCHEMA_READY:
return
with _PERMIT_FILE_SCHEMA_LOCK:
if _PERMIT_FILE_SCHEMA_READY:
return
if conn is not None:
original_autocommit = conn.autocommit
try:
conn.autocommit = True
_create_permit_file_schema(conn)
finally:
conn.autocommit = original_autocommit
else:
with _lic_pg_conn(autocommit=True) as ensure_conn:
_create_permit_file_schema(ensure_conn)
_PERMIT_FILE_SCHEMA_READY = True
def _insert_permit_file_record(
conn: pg.Connection,
*,
file_bytes: bytes,
filename: str,
content_type: Optional[str],
uploaded_by: Optional[str],
) -> Dict[str, Any]:
"""Persist an uploaded file and return its metadata."""
normalized_name = filename or "许可导入.xlsx"
content_type = content_type or "application/octet-stream"
file_id = uuid.uuid4()
checksum = hashlib.sha256(file_bytes).hexdigest()
cur = conn.cursor()
cur.execute(
"""
INSERT INTO permit_files (
id,
filename,
content_type,
file_size,
file_data,
checksum,
uploaded_by
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(
file_id,
normalized_name,
content_type,
len(file_bytes),
file_bytes,
checksum,
uploaded_by,
),
)
return {
"file_id": str(file_id),
"filename": normalized_name,
"content_type": content_type,
"file_size": len(file_bytes),
"checksum": checksum,
"uploaded_by": uploaded_by,
}
def _link_file_to_permit(
conn: pg.Connection,
*,
file_id: str,
region_id: str,
permit_id: str,
created_by: Optional[str],
) -> None:
"""Associate a stored file with a region-permit pair."""
if not (file_id and region_id and permit_id):
return
rid = str(region_id)
pid = str(permit_id)
cur = conn.cursor()
cur.execute(
"""
INSERT INTO permit_file_links (id, region_id, permit_id, file_id, created_by)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (region_id, permit_id)
DO UPDATE SET
file_id = EXCLUDED.file_id,
created_by = EXCLUDED.created_by,
created_at = now()
""",
(uuid.uuid4(), rid, pid, file_id, created_by),
)
def _load_permit_file_metadata(
conn: pg.Connection,
region_id: str,
permit_ids: Iterable[str],
) -> Dict[str, Dict[str, Any]]:
"""Load file metadata for a batch of permits."""
ids = [str(pid) for pid in permit_ids if pid]
if not ids:
return {}
_ensure_permit_file_schema(conn)
rows = _select_permit_files(conn, region_id, ids)
out: Dict[str, Dict[str, Any]] = {}
for row in rows:
(
permit_id,
file_id,
filename,
content_type,
file_size,
created_at,
uploaded_by,
) = row
out[str(permit_id)] = {
"file_id": str(file_id),
"filename": filename or "",
"content_type": content_type or "",
"file_size": int(file_size or 0),
"created_at": created_at.isoformat() if created_at else None,
"uploaded_by": uploaded_by or "",
}
return out
def _select_permit_files(conn: pg.Connection, region_id: str, permit_ids: Iterable[str]):
"""Execute the permit file metadata query, recreating tables if missing."""
sql = """
SELECT
pfl.permit_id,
pf.id,
pf.filename,
pf.content_type,
pf.file_size,
pf.created_at,
pf.uploaded_by
FROM permit_file_links pfl
JOIN permit_files pf ON pf.id = pfl.file_id
WHERE pfl.region_id = %s
AND pfl.permit_id = ANY(%s)
"""
attempts = 0
while attempts < 2:
try:
cur = conn.cursor()
cur.execute(sql, (region_id, permit_ids))
return cur.fetchall()
except pg.DatabaseError as exc: # type: ignore[attr-defined]
sqlstate = getattr(exc, "sqlstate", "")
if sqlstate != "42P01":
raise
attempts += 1
try:
conn.rollback()
except Exception:
pass
global _PERMIT_FILE_SCHEMA_READY
_PERMIT_FILE_SCHEMA_READY = None
_ensure_permit_file_schema(conn)
if attempts >= 2:
logger.warning("[PERMIT-FILES] permit_file_links table missing after recreate attempt, skipping metadata fetch")
return []
return []
def _lic_pg_conn(autocommit: bool = False) -> pg.Connection:
host = os.getenv("LIC_PG_HOST", "172.24.240.1")
port = int(os.getenv("LIC_PG_PORT", os.getenv("PG_PORT", "5432")))
@ -1503,6 +1769,11 @@ def load_permits_and_risks(
region_id: str, theme_id: str, permit_id: Optional[str] = None
) -> List[Dict[str, object]]:
"""Return permits with attached risk entries for a region-theme pair."""
# Ensure optional permit file tables exist before running user queries.
try:
_ensure_permit_file_schema()
except Exception as exc:
logger.warning("[PERMIT-FILES] Failed to ensure permit file schema before loading permits: %s", exc)
sql = """
SELECT
p.id AS permit_id,
@ -1590,6 +1861,18 @@ def load_permits_and_risks(
permit_ids = list(permits.keys())
scope_map = _load_permit_scopes_for_region(conn, region_id, permit_ids)
source_map = _load_permit_sources_for_region(conn, region_id, permit_ids)
try:
file_meta_map = _load_permit_file_metadata(conn, region_id, permit_ids)
except pg.DatabaseError as exc: # type: ignore[attr-defined]
sqlstate = getattr(exc, "sqlstate", "")
if sqlstate == "42P01":
logger.warning("[PERMIT-FILES] permit_file_links missing while loading permits, recreating schema lazily")
global _PERMIT_FILE_SCHEMA_READY
_PERMIT_FILE_SCHEMA_READY = None
_ensure_permit_file_schema()
file_meta_map = {}
else:
raise
for pid in permit_ids:
permits[pid]["business_scopes"] = scope_map.get(pid, [])
if pid in source_map:
@ -1601,9 +1884,110 @@ def load_permits_and_risks(
"source_detail": "",
"updated_at": None,
}
if pid in file_meta_map:
permits[pid]["permit_file"] = file_meta_map[pid]
else:
permits[pid]["permit_file"] = {
"file_id": "",
"filename": "",
"content_type": "",
"file_size": 0,
"created_at": None,
"uploaded_by": "",
}
return list(permits.values())
def fetch_permit_file(region_id: str, permit_id: str) -> Optional[Dict[str, Any]]:
"""Return file payload for a region-permit pair if available."""
if not region_id or not permit_id:
return None
with _lic_pg_conn() as conn:
_ensure_permit_file_schema(conn)
try:
row = _select_permit_file_blob(conn, region_id, permit_id)
except pg.DatabaseError as exc: # type: ignore[attr-defined]
sqlstate = getattr(exc, "sqlstate", "")
if sqlstate == "42P01":
logger.warning(
"[PERMIT-FILES] permit_file_links missing when downloading file (region=%s permit=%s); recreating schema",
region_id,
permit_id,
)
global _PERMIT_FILE_SCHEMA_READY
_PERMIT_FILE_SCHEMA_READY = None
_ensure_permit_file_schema()
return None
raise
if not row:
return None
(
file_id,
filename,
content_type,
file_size,
file_data,
created_at,
uploaded_by,
) = row
return {
"file_id": str(file_id),
"filename": filename or "",
"content_type": content_type or "application/octet-stream",
"file_size": int(file_size or 0),
"file_data": bytes(file_data) if file_data is not None else b"",
"created_at": created_at.isoformat() if created_at else None,
"uploaded_by": uploaded_by or "",
}
def _select_permit_file_blob(conn: pg.Connection, region_id: str, permit_id: str):
"""Fetch a single permit file with binary content, recreating tables if needed."""
sql = """
SELECT
pf.id,
pf.filename,
pf.content_type,
pf.file_size,
pf.file_data,
pf.created_at,
pf.uploaded_by
FROM permit_file_links pfl
JOIN permit_files pf ON pf.id = pfl.file_id
WHERE pfl.region_id = %s
AND pfl.permit_id = %s
LIMIT 1
"""
attempts = 0
while attempts < 2:
try:
cur = conn.cursor()
cur.execute(sql, (region_id, permit_id))
return cur.fetchone()
except pg.DatabaseError as exc: # type: ignore[attr-defined]
sqlstate = getattr(exc, "sqlstate", "")
if sqlstate != "42P01":
raise
attempts += 1
try:
conn.rollback()
except Exception:
pass
global _PERMIT_FILE_SCHEMA_READY
_PERMIT_FILE_SCHEMA_READY = None
_ensure_permit_file_schema(conn)
if attempts >= 2:
logger.warning(
"[PERMIT-FILES] permit_file_links table missing after recreate attempt when downloading file (region=%s permit=%s)",
region_id,
permit_id,
)
return None
return None
def find_permit_contexts_by_name(permit_name: str) -> List[Dict[str, str]]:
"""Return region/theme contexts for permits with an exact name match."""
if not permit_name:

View File

@ -28,12 +28,18 @@
}
.header {
position: relative;
text-align: center;
margin-bottom: 30px;
padding-bottom: 20px;
padding-bottom: 24px;
border-bottom: 3px solid #667eea;
}
.header-info {
max-width: 720px;
margin: 0 auto;
}
.header h1 {
color: #333;
font-size: 28px;
@ -45,6 +51,115 @@
font-size: 14px;
}
.user-bar {
position: absolute;
right: 24px;
top: 50%;
transform: translateY(-50%);
padding: 12px 16px;
border: 1px solid #e5e7eb;
border-radius: 16px;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
display: flex;
align-items: center;
gap: 14px;
transition: all 0.3s ease;
}
.user-bar:hover {
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.16);
border-color: #d1d5db;
}
.user-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 18px;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
flex-shrink: 0;
}
.user-info {
display: flex;
flex-direction: column;
gap: 3px;
color: #1f2937;
font-size: 14px;
}
.user-name {
font-weight: 600;
font-size: 15px;
color: #111827;
display: flex;
align-items: center;
gap: 8px;
}
.user-role {
display: inline-flex;
align-items: center;
gap: 6px;
background: linear-gradient(135deg, #e0e7ff 0%, #ddd6fe 100%);
color: #4f46e5;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.02em;
}
.user-time {
font-size: 11px;
color: #6b7280;
display: flex;
align-items: center;
gap: 4px;
}
.user-alert {
font-size: 12px;
color: #f59e0b;
margin-top: 2px;
font-weight: 500;
}
.user-actions {
display: flex;
align-items: center;
gap: 8px;
}
.user-actions button {
border: none;
border-radius: 10px;
padding: 8px 14px;
font-size: 13px;
cursor: pointer;
transition: all 0.25s ease;
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
color: white;
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
font-weight: 500;
}
.user-actions button:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(239, 68, 68, 0.4);
}
.user-actions button:active {
transform: translateY(0);
}
.step-indicator {
display: flex;
justify-content: center;
@ -801,6 +916,76 @@
gap: 8px;
}
.import-template-wrapper {
display: flex;
justify-content: flex-start;
margin-top: 4px;
}
.import-template-button {
display: inline-flex;
align-items: center;
gap: 12px;
padding: 12px 18px;
border-radius: 10px;
background: linear-gradient(135deg, #5c6bc0, #3949ab);
color: #fff;
font-size: 14px;
font-weight: 600;
text-decoration: none;
box-shadow: 0 8px 22px rgba(57, 73, 171, 0.35);
border: 1px solid rgba(255, 255, 255, 0.3);
transform: translateY(0);
transition: transform 0.2s ease, box-shadow 0.3s ease, background 0.3s ease;
}
.import-template-button:hover {
background: linear-gradient(135deg, #283593, #1a237e);
text-decoration: none;
box-shadow: 0 10px 26px rgba(26, 35, 126, 0.45);
transform: translateY(-1px);
}
.import-template-icon {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.25);
font-size: 18px;
}
.import-template-text {
display: flex;
flex-direction: column;
gap: 4px;
line-height: 1.2;
}
.import-template-title {
font-size: 15px;
font-weight: 700;
}
.import-template-subtitle {
font-size: 12px;
font-weight: 400;
opacity: 0.85;
}
.import-template-badge {
margin-left: auto;
padding: 4px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.25);
color: #fff;
font-size: 12px;
font-weight: 600;
letter-spacing: 1px;
}
.import-upload-area input[type="file"] {
background: #fff;
padding: 10px;
@ -1219,6 +1404,22 @@
color: #c62828;
}
.status-unknown {
background: #f1f5f9;
color: #475569;
}
.permit-file-name {
font-weight: 600;
color: #1d4ed8;
}
.muted-text {
color: #94a3b8;
font-size: 14px;
margin: 4px 0;
}
.loading {
display: inline-block;
width: 20px;
@ -1253,14 +1454,55 @@
max-height: 300px;
overflow-y: auto;
}
.user-bar {
position: static;
transform: none;
width: 100%;
justify-content: center;
padding: 14px 16px;
}
.user-info {
align-items: center;
text-align: center;
}
.user-actions {
justify-content: center;
}
.user-time {
justify-content: center;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🗃️ 数据库维护系统</h1>
<p>LawRisk 法律风险提示系统 - 数据库维护与查询工具</p>
<div class="header-info">
<h1>🗃️ 数据库维护系统</h1>
<p>LawRisk 法律风险提示系统 - 数据库维护与查询工具</p>
</div>
<div class="user-bar" id="userBar">
<div class="user-avatar" id="userAvatar">U</div>
<div class="user-info">
<div class="user-name" id="userDisplayName">--</div>
<span class="user-role" id="userRole">UNAUTH</span>
<div class="user-time" id="userTime" style="display: none;">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style="flex-shrink: 0;">
<circle cx="6" cy="6" r="5.5" stroke="#9ca3af" stroke-width="1"/>
<path d="M6 3.5V6.5L8 8" stroke="#9ca3af" stroke-width="1" stroke-linecap="round"/>
</svg>
<span id="loginTime">--</span>
</div>
<div class="user-alert" id="userStatus"></div>
</div>
<div class="user-actions">
<button type="button" class="btn-logout" id="logoutBtn">退出登录</button>
</div>
</div>
</div>
<div class="step-indicator">
@ -1368,6 +1610,96 @@
</div>
<script>
const LOGIN_PATH = '/fs-ai-asistant/lawrisk/login';
let currentUserProfile = null;
let loginTime = null;
const userNameEl = document.getElementById('userDisplayName');
const userRoleEl = document.getElementById('userRole');
const userStatusEl = document.getElementById('userStatus');
const userAvatarEl = document.getElementById('userAvatar');
const userTimeEl = document.getElementById('userTime');
const loginTimeEl = document.getElementById('loginTime');
const logoutBtn = document.getElementById('logoutBtn');
function buildLoginRedirectUrl() {
const next = encodeURIComponent(window.location.pathname + window.location.search);
return `${LOGIN_PATH}?next=${next || '%2F'}`;
}
function updateUserBanner(user, message = '') {
if (!user) {
userNameEl.textContent = '访客';
userRoleEl.textContent = 'LOGIN';
userStatusEl.textContent = message || '登录状态失效,请重新登录';
userAvatarEl.textContent = '?';
userTimeEl.style.display = 'none';
return;
}
const displayName = user.display_name || user.username;
userNameEl.textContent = displayName || user.username;
userRoleEl.textContent = (user.role || 'user').toUpperCase();
userStatusEl.textContent = message || '';
// Update avatar with first letter of username
const initial = (displayName || user.username || '?').charAt(0).toUpperCase();
userAvatarEl.textContent = initial;
// Set login time if not already set
if (!loginTime) {
loginTime = new Date();
const timeStr = loginTime.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
});
loginTimeEl.textContent = `登录于 ${timeStr}`;
userTimeEl.style.display = 'flex';
}
}
async function fetchCurrentUser(redirectOnFail = true) {
try {
const resp = await fetch('/auth/me', { headers: { Accept: 'application/json' } });
if (!resp.ok) {
throw new Error('未登录或登录已过期');
}
const data = await resp.json();
if (!data.authenticated) {
throw new Error('未登录或登录已过期');
}
currentUserProfile = data.user;
updateUserBanner(currentUserProfile);
return currentUserProfile;
} catch (error) {
currentUserProfile = null;
updateUserBanner(null, error.message);
if (redirectOnFail) {
window.location.href = buildLoginRedirectUrl();
}
return null;
}
}
async function handleLogout() {
try {
await fetch('/auth/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: 'user_action' }),
});
} finally {
loginTime = null; // Reset login time
window.location.href = buildLoginRedirectUrl();
}
}
if (logoutBtn) {
logoutBtn.addEventListener('click', (event) => {
event.preventDefault();
handleLogout();
});
}
// 导航状态管理
let currentStep = 1; // 1=区域, 2=主题, 3=许可, 4=详情
let historyStack = []; // 历史记录栈
@ -1400,6 +1732,8 @@
let checkpointListError = '';
let expandedSnapshotGroups = new Set();
const PERMIT_FILE_MAX_BYTES = 500 * 1024; // 500KB
const permitImportState = {
uploading: false,
sessionId: '',
@ -1412,9 +1746,49 @@
success: '',
commitLoading: false,
editedBy: '',
changeSummary: ''
changeSummary: '',
fileSize: 0
};
const EMPTY_PLACEHOLDER = '(未填写)';
function formatNullableText(value, placeholder = EMPTY_PLACEHOLDER) {
if (value === null || value === undefined) {
return placeholder;
}
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed === '' ? placeholder : trimmed;
}
if (typeof value === 'number') {
return Number.isNaN(value) ? placeholder : String(value);
}
if (typeof value === 'object') {
try {
return JSON.stringify(value);
} catch (error) {
return placeholder;
}
}
return String(value);
}
function formatFileSize(bytes) {
const size = Number(bytes);
if (!Number.isFinite(size) || size <= 0) {
return '0 B';
}
if (size < 1024) {
return `${size.toFixed(0)} B`;
}
if (size < 1024 * 1024) {
const kb = size / 1024;
return `${Number.isInteger(kb) ? kb.toFixed(0) : kb.toFixed(1)} KB`;
}
const mb = size / (1024 * 1024);
return `${Number.isInteger(mb) ? mb.toFixed(0) : mb.toFixed(2)} MB`;
}
// 步骤配置
const steps = {
1: { title: '选择区域', loadData: loadRegions },
@ -1751,6 +2125,22 @@
}
const deleteButtonLabel = isDeletingPermit ? '删除中...' : '删除许可';
const deleteButtonDisabled = isDeletingPermit ? 'disabled' : '';
const permitSourceName = permit && permit.permit_source && permit.permit_source.source_name
? permit.permit_source.source_name
: '';
const permitSourceDisplay = permitSourceName ? escapeHtml(permitSourceName) : EMPTY_PLACEHOLDER;
const permitStatusClass = permit && permit.permit_status
? (permit.permit_status === 'active' ? 'status-active' : 'status-inactive')
: 'status-unknown';
const permitStatusLabel = formatNullableText(permit.permit_status, '未设置');
const permitFile = (permit && permit.permit_file) ? permit.permit_file : {};
const hasPermitFile = Boolean(permitFile.file_id);
const fileUploadedAt = permitFile.created_at ? formatIsoDatetime(permitFile.created_at) : '';
const fileUploadedBy = permitFile.uploaded_by ? escapeHtml(permitFile.uploaded_by) : '';
const downloadDisabledAttr = hasPermitFile ? '' : 'disabled';
const fileInfoText = hasPermitFile
? `<span class="permit-file-name">${escapeHtml(permitFile.filename || '原始文件')}</span>${formatFileSize(permitFile.file_size)}${fileUploadedAt ? ` ${fileUploadedAt}` : ''}${fileUploadedBy ? ` 上传:${fileUploadedBy}` : ''}`
: '<span class="muted-text">暂无关联文件</span>';
let html = '<div class="details-content">';
html += `
@ -1759,6 +2149,7 @@
风险条目:<strong>${riskCount}</strong>
</div>
<div class="detail-actions">
<button class="btn" id="downloadPermitFileBtn" ${downloadDisabledAttr} onclick="downloadPermitFile()" title="${hasPermitFile ? '下载原文件' : '暂无原始文件'}">下载原文件</button>
<button class="btn btn-danger" id="deletePermitBtn" ${deleteButtonDisabled} onclick="confirmDeleteCurrentPermit()">${deleteButtonLabel}</button>
</div>
</div>
@ -1769,55 +2160,65 @@
<div class="detail-section">
<h3>许可信息</h3>
<div class="detail-content">
<p><strong>许可名称:</strong>${permit.name}</p>
${permit.permit_source && permit.permit_source.source_name ? `<p style="margin-top: 10px;"><strong>数据来源:</strong>${escapeHtml(permit.permit_source.source_name)}</p>` : ''}
${permit.permit_status ? `<p style="margin-top: 10px;"><strong>许可状态:</strong><span class="permit-status ${permit.permit_status === 'active' ? 'status-active' : 'status-inactive'}">${permit.permit_status}</span></p>` : ''}
${permit.subitem_summary ? `<p style="margin-top: 10px;"><strong>子项说明:</strong>${permit.subitem_summary}</p>` : ''}
${permit.responsible_contact ? `<p style="margin-top: 10px;"><strong>负责部门:</strong>${permit.responsible_contact}</p>` : ''}
${permit.jurisdiction_scope ? `<p style="margin-top: 10px;"><strong>权限划分:</strong>${permit.jurisdiction_scope}</p>` : ''}
<p><strong>许可名称:</strong>${formatNullableText(permit.name, '(未命名)')}</p>
<p style="margin-top: 10px;"><strong>数据来源:</strong>${permitSourceDisplay}</p>
<p style="margin-top: 10px;"><strong>许可状态:</strong><span class="permit-status ${permitStatusClass}">${permitStatusLabel}</span></p>
<p style="margin-top: 10px;"><strong>原始文件:</strong>${fileInfoText}</p>
<p style="margin-top: 10px;"><strong>子项说明:</strong>${formatNullableText(permit.subitem_summary)}</p>
<p style="margin-top: 10px;"><strong>负责部门:</strong>${formatNullableText(permit.responsible_contact)}</p>
<p style="margin-top: 10px;"><strong>权限划分:</strong>${formatNullableText(permit.jurisdiction_scope)}</p>
</div>
</div>
`;
// 经营范围
html += '<div class="detail-section"><h3>经营范围</h3><div class="detail-content">';
if (permit.business_scopes && permit.business_scopes.length > 0) {
html += '<div class="detail-section"><h3>经营范围</h3><div class="detail-content">';
permit.business_scopes.forEach(scope => {
html += `<div class="scope-item">${scope.description}</div>`;
html += `<div class="scope-item">${formatNullableText(scope.description)}</div>`;
});
html += '</div></div>';
} else {
html += '<p class="muted-text">暂无经营范围信息</p>';
}
html += '</div></div>';
// 法律风险
html += '<div class="detail-section"><h3>法律风险</h3><div class="detail-content">';
if (permit.risks && permit.risks.length > 0) {
html += '<div class="detail-section"><h3>法律风险</h3><div class="detail-content">';
permit.risks.forEach(risk => {
permit.risks.forEach((risk, index) => {
const riskIdentifier = formatNullableText(risk.id, index + 1);
html += `
<div class="risk-item">
<h4>风险 ${risk.id}</h4>
${risk.risk_content ? `<div class="risk-field"><strong>风险内容:</strong><p>${risk.risk_content}</p></div>` : ''}
${risk.legal_basis ? `<div class="risk-field"><strong>法律依据:</strong><p>${risk.legal_basis}</p></div>` : ''}
${risk.document_no ? `<div class="risk-field"><strong>文件编号:</strong><p>${risk.document_no}</p></div>` : ''}
${risk.summary ? `<div class="risk-field"><strong>摘要:</strong><div style="margin-top: 5px;">${risk.summary}</div></div>` : ''}
<h4>风险 ${riskIdentifier}</h4>
<div class="risk-field"><strong>风险内容:</strong><p>${formatNullableText(risk.risk_content)}</p></div>
<div class="risk-field"><strong>法律依据:</strong><p>${formatNullableText(risk.legal_basis)}</p></div>
<div class="risk-field"><strong>文件编号:</strong><p>${formatNullableText(risk.document_no)}</p></div>
<div class="risk-field"><strong>摘要:</strong><div style="margin-top: 5px;">${formatNullableText(risk.summary)}</div></div>
</div>
`;
});
html += '</div></div>';
} else {
html += `
<div class="detail-section">
<h3>法律风险</h3>
<div class="detail-content">
<p style="color: #999;">暂无法律风险信息</p>
</div>
</div>
`;
html += '<p class="muted-text">暂无法律风险记录</p>';
}
html += '</div></div>';
html += '</div>';
detailsArea.innerHTML = html;
}
function downloadPermitFile() {
if (!currentRegion || !currentPermit || !currentPermitDetails) {
alert('请先选择许可');
return;
}
if (!currentPermitDetails.permit_file || !currentPermitDetails.permit_file.file_id) {
alert('当前许可没有可下载的原始文件');
return;
}
const url = `/fs-ai-asistant/api/workflow/lawrisk/admin/permit-file/download?region=${encodeURIComponent(currentRegion.id)}&permit=${encodeURIComponent(currentPermit.id)}`;
window.open(url, '_blank');
}
function confirmDeleteCurrentPermit() {
if (isDeletingPermit) {
return;
@ -1953,6 +2354,9 @@
permitImportState.overrides = new Map();
permitImportState.error = '';
permitImportState.success = '';
permitImportState.filename = '';
permitImportState.totalRows = 0;
permitImportState.fileSize = 0;
}
renderImportModal();
}
@ -2021,6 +2425,17 @@
return;
}
const file = input.files[0];
if (!file) {
return;
}
if (file.size > PERMIT_FILE_MAX_BYTES) {
permitImportState.error = `文件过大(最大 ${formatFileSize(PERMIT_FILE_MAX_BYTES)}),当前 ${formatFileSize(file.size)}`;
permitImportState.success = '';
permitImportState.fileSize = 0;
input.value = '';
renderImportModal();
return;
}
const formData = new FormData();
formData.append('file', file);
@ -2041,6 +2456,7 @@
permitImportState.filename = payload.filename || (file && file.name) || '';
permitImportState.totalRows = payload.total_rows || payload.totalRows || 0;
permitImportState.sheetSummaries = payload.sheet_summaries || payload.sheetSummaries || [];
permitImportState.fileSize = payload.file_size || payload.fileSize || file.size || 0;
permitImportState.selectedSheets = new Set(
(permitImportState.sheetSummaries || []).map(item => item.sheet_name)
);
@ -2050,6 +2466,7 @@
permitImportState.error = data.message || '解析失败请检查Excel格式';
permitImportState.sessionId = '';
permitImportState.sheetSummaries = [];
permitImportState.fileSize = 0;
permitImportState.selectedSheets = new Set();
permitImportState.overrides = new Map();
}
@ -2059,6 +2476,7 @@
permitImportState.sheetSummaries = [];
permitImportState.selectedSheets = new Set();
permitImportState.overrides = new Map();
permitImportState.fileSize = 0;
} finally {
permitImportState.uploading = false;
renderImportModal();
@ -2118,6 +2536,9 @@
permitImportState.sheetSummaries = [];
permitImportState.selectedSheets = new Set();
permitImportState.overrides = new Map();
permitImportState.filename = '';
permitImportState.totalRows = 0;
permitImportState.fileSize = 0;
await Promise.all([
refreshPermitRiskSnapshots(false),
@ -2148,12 +2569,21 @@
html += '<h3><span>📄</span> 上传 Excel</h3>';
html += '<div class="import-upload-area">';
html += '<input type="file" accept=".xlsx,.xlsm" onchange="handleImportFile(this)">';
html += '<div class="import-template-wrapper">';
html += '<a class="import-template-button" href="/fs-ai-asistant/api/workflow/lawrisk/admin/permit-import/template" download>';
html += '<span class="import-template-icon">📥</span>';
html += '<span class="import-template-text"><span class="import-template-title">下载导入模板</span><span class="import-template-subtitle">包含示例字段与填写说明</span></span>';
html += '<span class="import-template-badge">推荐</span>';
html += '</a>';
html += '</div>';
if (state.sessionId) {
const sheetCount = state.sheetSummaries ? state.sheetSummaries.length : 0;
html += `<div class="import-meta">当前会话:${escapeHtml(state.filename || '(未命名)')} Sheet ${sheetCount} 个 风险 ${state.totalRows || 0} 条</div>`;
} else {
html += '<div class="import-meta">请选择包含许可数据的 Excel 文件,系统会自动解析所有 Sheet并以区划为单位生成导入任务。</div>';
}
const currentSizeLabel = state.fileSize ? formatFileSize(state.fileSize) : '未选择文件';
html += `<div class="import-meta">文件大小限制:≤ ${formatFileSize(PERMIT_FILE_MAX_BYTES)} 当前:${currentSizeLabel}</div>`;
html += '</div></div>';
if (state.error) {
@ -3233,7 +3663,8 @@
}
// 页面加载时初始化
window.addEventListener('DOMContentLoaded', () => {
window.addEventListener('DOMContentLoaded', async () => {
await fetchCurrentUser(true);
goToStep(1);
});
</script>