diff --git a/lawrisk/api/v2.py b/lawrisk/api/v2.py index aedc532..e89a297 100644 --- a/lawrisk/api/v2.py +++ b/lawrisk/api/v2.py @@ -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//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.""" diff --git a/lawrisk/services/licensing_repo.py b/lawrisk/services/licensing_repo.py index 3ea959c..be78160 100644 --- a/lawrisk/services/licensing_repo.py +++ b/lawrisk/services/licensing_repo.py @@ -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: diff --git a/static/db_admin.html b/static/db_admin.html index 0e7d2ea..4d60a35 100644 --- a/static/db_admin.html +++ b/static/db_admin.html @@ -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; + } }
-

🗃️ 数据库维护系统

-

LawRisk 法律风险提示系统 - 数据库维护与查询工具

+
+

🗃️ 数据库维护系统

+

LawRisk 法律风险提示系统 - 数据库维护与查询工具

+
+
+
U
+ + +
@@ -1368,6 +1610,96 @@