Browse Source

feat: modernize platform and add datasource pools

马小龙 5 ngày trước cách đây
mục cha
commit
38326506f0
100 tập tin đã thay đổi với 6464 bổ sung3054 xóa
  1. 15 0
      .dockerignore
  2. 20 0
      .github/workflows/contracts.yml
  3. 38 0
      alembic.ini
  4. 47 18
      app/__init__.py
  5. 20 15
      app/api/business_domain/routes.py
  6. 59 48
      app/api/data_flow/routes.py
  7. 198 318
      app/api/data_source/routes.py
  8. 40 25
      app/api/meta_data/routes.py
  9. 4 34
      app/api/system/README.md
  10. 2 0
      app/api/system/__init__.py
  11. 29 72
      app/api/system/routes.py
  12. 183 0
      app/api/system/users.py
  13. 44 0
      app/api/system/workbench.py
  14. 1 0
      app/commands/__init__.py
  15. 93 0
      app/commands/bootstrap_admin.py
  16. 150 0
      app/commands/migrate_datasource_credentials.py
  17. 75 0
      app/commands/process_outbox.py
  18. 129 0
      app/commands/reconcile_datasource_credentials.py
  19. 78 0
      app/commands/reconcile_governance_uids.py
  20. 35 0
      app/commands/reconcile_workflow_versions.py
  21. 76 0
      app/commands/report_datasource_credentials.py
  22. 26 0
      app/commands/report_legacy_accounts.py
  23. 383 53
      app/config/config.py
  24. 24 13
      app/core/business_domain/business_domain.py
  25. 4 9
      app/core/common/functions.py
  26. 38 0
      app/core/common/identifiers.py
  27. 7 14
      app/core/common/timezone_utils.py
  28. 16 12
      app/core/data_factory/n8n_client.py
  29. 9 149
      app/core/data_flow/dataflows.py
  30. 73 0
      app/core/data_flow/workflow_activation.py
  31. 14 0
      app/core/data_flow/workflow_models.py
  32. 113 0
      app/core/data_flow/workflow_repository.py
  33. 10 15
      app/core/data_service/data_product_service.py
  34. 5 0
      app/core/data_source/__init__.py
  35. 22 0
      app/core/data_source/adapters/__init__.py
  36. 148 0
      app/core/data_source/adapters/base.py
  37. 21 0
      app/core/data_source/adapters/mysql.py
  38. 41 0
      app/core/data_source/adapters/postgresql.py
  39. 101 0
      app/core/data_source/circuit_breaker.py
  40. 382 0
      app/core/data_source/credentials.py
  41. 233 0
      app/core/data_source/definitions.py
  42. 64 0
      app/core/data_source/errors.py
  43. 160 0
      app/core/data_source/manager.py
  44. 133 0
      app/core/data_source/models.py
  45. 289 0
      app/core/data_source/pool_registry.py
  46. 105 0
      app/core/data_source/redaction.py
  47. 112 0
      app/core/data_source/runtime.py
  48. 381 0
      app/core/data_source/service.py
  49. 5 0
      app/core/events/__init__.py
  50. 79 0
      app/core/events/consumer.py
  51. 171 0
      app/core/events/outbox.py
  52. 1 0
      app/core/knowledge/__init__.py
  53. 30 0
      app/core/knowledge/document_builder.py
  54. 243 46
      app/core/llm/ddl_parser.py
  55. 181 130
      app/core/llm/llm_service.py
  56. 6 16
      app/core/meta_data/meta_data.py
  57. 3 3
      app/core/system/__init__.py
  58. 177 351
      app/core/system/auth.py
  59. 33 0
      app/core/system/health.py
  60. 41 0
      app/core/system/models.py
  61. 135 0
      app/core/system/permissions.py
  62. 56 0
      app/core/system/tokens.py
  63. 1 0
      app/core/workbench/__init__.py
  64. 40 0
      app/core/workbench/registry.py
  65. 0 235
      app/scripts/create_calendar_records_table.py
  66. 0 242
      app/scripts/migrate_wechat_users.py
  67. 3 5
      app/services/db_healthcheck.py
  68. 1 0
      app/services/embedding/__init__.py
  69. 30 0
      app/services/embedding/qwen.py
  70. 6 2
      app/services/neo4j_driver.py
  71. 0 24
      database/add_color_field_to_calendar_info.sql
  72. 0 63
      database/add_origin_source_field.sql
  73. 0 32
      database/alter_business_cards_simple.sql
  74. 0 61
      database/alter_business_cards_table.sql
  75. 0 118
      database/check_business_cards_table.sql
  76. 0 42
      database/create_calendar_info.sql
  77. 0 68
      database/create_calendar_records.sql
  78. 0 36
      database/create_duplicate_business_cards_table.sql
  79. 0 57
      database/create_wechat_users.sql
  80. 0 65
      database/hotel_group_brands_ddl.sql
  81. 0 75
      database/hotel_positions_ddl.sql
  82. 0 35
      database/migrate_wechat_code_to_openid.sql
  83. 0 52
      database/rollback_business_cards_table.sql
  84. 0 61
      database/step_by_step_alter.sql
  85. 17 0
      deploy/docker/.env.example
  86. 224 0
      deploy/docker/README.md
  87. 33 0
      deploy/docker/backend.Dockerfile
  88. 11 0
      deploy/docker/datasources/mysql/init.sql
  89. 11 0
      deploy/docker/datasources/postgres/init.sql
  90. 251 0
      deploy/docker/docker-compose.yml
  91. 25 0
      deploy/docker/frontend.Dockerfile
  92. 21 0
      deploy/docker/nginx.conf
  93. 12 0
      deploy/docker/postgres/init/000-init.sql
  94. 24 15
      deployment/.env.production.example
  95. 38 0
      deployment/alembic.ini
  96. 4 0
      deployment/app/__init__.py
  97. 59 48
      deployment/app/api/data_flow/routes.py
  98. 198 318
      deployment/app/api/data_source/routes.py
  99. 40 25
      deployment/app/api/meta_data/routes.py
  100. 4 34
      deployment/app/api/system/README.md

+ 15 - 0
.dockerignore

@@ -0,0 +1,15 @@
+.git
+.venv
+.pytest_cache
+**/__pycache__
+**/*.pyc
+frontend/node_modules
+frontend/dist
+deployment
+docs
+work
+logs
+*.log
+*.tar.gz
+.env
+.env.*.local

+ 20 - 0
.github/workflows/contracts.yml

@@ -0,0 +1,20 @@
+name: API contracts
+
+on:
+  pull_request:
+  push:
+    branches: [main]
+
+jobs:
+  openapi:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v5
+        with:
+          python-version: "3.11"
+          cache: pip
+      - run: pip install -r requirements.txt pytest
+      - run: python scripts/generate_openapi.py
+      - run: git diff --exit-code -- docs/architecture/OPENAPI.yaml
+      - run: python -m pytest tests/test_architecture_artifacts.py -q

+ 38 - 0
alembic.ini

@@ -0,0 +1,38 @@
+[alembic]
+script_location = migrations
+prepend_sys_path = .
+version_path_separator = os
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S

+ 47 - 18
app/__init__.py

@@ -1,10 +1,17 @@
 import logging
+import os
 
 from flask import Flask, jsonify
 from flask_cors import CORS
 from flask_sqlalchemy import SQLAlchemy
 
-from app.config.config import config, current_env
+from app.config.config import (
+    apply_runtime_env_config,
+    config,
+    current_env,
+    log_llm_env_status,
+    log_service_env_status,
+)
 from app.config.cors import CORS_OPTIONS
 
 db = SQLAlchemy()
@@ -16,6 +23,7 @@ def create_app():
 
     # 加载配置
     app.config.from_object(config[current_env])
+    apply_runtime_env_config(app)
 
     # 初始化扩展
     # 配置CORS以解决跨域问题
@@ -43,18 +51,27 @@ def create_app():
     app.register_blueprint(data_factory_bp, url_prefix="/api/datafactory")
     app.register_blueprint(data_service_bp, url_prefix="/api/dataservice")
 
+    from app.core.system.permissions import configure_api_authorization
+
+    configure_api_authorization(app)
+
     # Configure global response headers
     configure_response_headers(app)
 
     # Configure logging
     configure_logging(app)
+    log_llm_env_status(app)
+    log_service_env_status(app)
 
     # 添加全局异常处理器
     configure_error_handlers(app)
 
-    # 输出启动信息
+    # 输出启动信息(生产环境由 Gunicorn 按 LISTEN_PORT 监听,此处 PORT 与配置一致)
     port = app.config["PORT"]
-    app.logger.info(f"Starting server in {current_env} mode on port {port}")
+    app.logger.info(
+        f"Starting server in {current_env} mode on port {port} "
+        f"(LISTEN_PORT={os.environ.get('LISTEN_PORT', port)})"
+    )
 
     return app
 
@@ -147,6 +164,14 @@ def configure_response_headers(app):
             if "X-XSS-Protection" not in response.headers:
                 response.headers["X-XSS-Protection"] = "1; mode=block"
 
+        if request.path.startswith("/api/") and request.path != "/api/system/health":
+            app.logger.info(
+                "%s %s -> %s",
+                request.method,
+                request.path,
+                response.status_code,
+            )
+
         return response
 
 
@@ -155,7 +180,13 @@ def configure_logging(app):
     if not app.config.get("LOG_ENABLED", True):
         return None
 
-    log_file = app.config.get("LOG_FILE", f"flask_{app.config['FLASK_ENV']}.log")
+    log_file = os.path.abspath(
+        app.config.get("LOG_FILE", f"flask_{app.config['FLASK_ENV']}.log")
+    )
+    log_dir = os.path.dirname(log_file)
+    if log_dir:
+        os.makedirs(log_dir, exist_ok=True)
+
     log_level_name = app.config.get("LOG_LEVEL", "INFO")
     log_level = getattr(logging, log_level_name)
     log_format = app.config.get(
@@ -166,38 +197,36 @@ def configure_logging(app):
     log_encoding = app.config.get("LOG_ENCODING", "UTF-8")
     log_to_console = app.config.get("LOG_TO_CONSOLE", True)
 
-    # 配置根日志器
+    logging_format = logging.Formatter(log_format)
+
     root_logger = logging.getLogger()
     root_logger.setLevel(log_level)
-
-    # 清除所有现有处理器
     root_logger.handlers.clear()
 
-    # 文件处理器 - 只添加到根日志器
     file_handler = logging.FileHandler(log_file, encoding=log_encoding)
     file_handler.setLevel(log_level)
-    logging_format = logging.Formatter(log_format)
     file_handler.setFormatter(logging_format)
     root_logger.addHandler(file_handler)
 
-    # 控制台处理器 - 只添加到根日志器
     if log_to_console:
         console = logging.StreamHandler()
         console.setLevel(log_level)
         console.setFormatter(logging_format)
         root_logger.addHandler(console)
 
-    # 确保Flask内部日志器使用我们的配置
-    app.logger.handlers.clear()  # 移除Flask默认处理器
+    # Flask 默认 logger 关闭 propagate,清空 handler 后需要显式开启
+    app.logger.handlers.clear()
+    app.logger.propagate = True
+    app.logger.setLevel(log_level)
 
-    # 配置app日志器,但禁止传播到根日志器
-    logger = logging.getLogger("app")
-    logger.setLevel(log_level)
-    logger.handlers.clear()  # 清除现有处理器
-    logger.propagate = True  # 通过根日志器处理
+    for logger_name in ("app", "flask.app"):
+        named_logger = logging.getLogger(logger_name)
+        named_logger.handlers.clear()
+        named_logger.propagate = True
+        named_logger.setLevel(log_level)
 
     app.logger.info(f"日志配置完成: 级别={log_level_name}, 文件={log_file}")
-    return logger
+    return logging.getLogger("app")
 
 
 def configure_error_handlers(app):

+ 20 - 15
app/api/business_domain/routes.py

@@ -319,6 +319,13 @@ def bd_upload():
         )
     except S3Error as e:
         logger.error(f"MinIO 存储失败: {str(e)}")
+        if getattr(e, "code", "") == "InvalidAccessKeyId":
+            return jsonify(
+                failed(
+                    "MinIO 访问密钥无效,请检查 /etc/dataops-platform/dataops.env 中 "
+                    "MINIO_HOST、MINIO_USER、MINIO_PASSWORD 是否与 MinIO 服务一致"
+                )
+            )
         return jsonify(failed("文件存储失败,请稍后重试", error=str(e)))
     except Exception as e:
         logger.error(f"文件上传失败: {str(e)}")
@@ -495,9 +502,16 @@ def bd_ddl_parse():
 
         # 根据文件类型选择不同的解析方法
         if file_ext == "sql":
-            # SQL 文件直接解析 DDL
             sql_content = file_content.decode("utf-8")
-            ddl_list = parser.parse_ddl(sql_content)
+            raw_result = parser.parse_ddl(sql_content)
+            if isinstance(raw_result, dict) and raw_result.get("code") == 500:
+                message = raw_result.get("message", "DDL解析失败")
+                return jsonify(failed(message))
+            ddl_list = (
+                raw_result
+                if isinstance(raw_result, list)
+                else DDLParser.normalize_ddl_parse_result(raw_result)
+            )
 
         elif file_ext in {"xlsx", "xls"}:
             # Excel 文件解析
@@ -519,20 +533,11 @@ def bd_ddl_parse():
         if not ddl_list:
             return jsonify(failed("未找到有效的数据表定义信息"))
 
-        # 确保结果是列表格式
         if isinstance(ddl_list, dict):
-            if "table_info" in ddl_list:
-                ddl_list = [ddl_list]
-            else:
-                # 兼容旧格式(字典形式的多表)
-                table_names = list(ddl_list.keys())
-                converted_list = []
-                for table_name in table_names:
-                    table_data = ddl_list[table_name]
-                    if isinstance(table_data, dict):
-                        table_data["exist"] = False
-                        converted_list.append(table_data)
-                ddl_list = converted_list
+            ddl_list = DDLParser.normalize_ddl_parse_result(ddl_list)
+
+        if not ddl_list:
+            return jsonify(failed("未找到有效的数据表定义信息"))
 
         # 检查表在 Neo4j 中的存在状态
         ddl_list = _check_table_existence(ddl_list)

+ 59 - 48
app/api/data_flow/routes.py

@@ -1,16 +1,74 @@
 import json
 import logging
 
-from flask import request
+from flask import g, jsonify, request
 
+from app import db
 from app.api.data_flow import bp
 from app.core.data_flow.dataflows import DataFlowService
 from app.core.graph.graph_operations import MyEncoder
 from app.models.result import failed, success
+from app.core.data_factory.n8n_client import N8nClient, N8nClientError
+from app.core.data_flow.workflow_activation import activate_version
+from app.core.data_flow.workflow_repository import create_version, list_versions
+from app.core.system.permissions import ACTIVATE_WORKFLOW, EDIT_GOVERNANCE, READ_GOVERNANCE, require_permissions
 
 logger = logging.getLogger(__name__)
 
 
+@bp.route("/<dataflow_uid>/workflow-versions", methods=["GET"])
+@require_permissions(READ_GOVERNANCE)
+def get_workflow_versions(dataflow_uid):
+    try:
+        return jsonify(success(list_versions(db.session, dataflow_uid)))
+    except ValueError as exc:
+        return jsonify(failed(str(exc), code=400)), 400
+
+
+@bp.route("/<dataflow_uid>/workflow-versions", methods=["POST"])
+@require_permissions(EDIT_GOVERNANCE)
+def add_workflow_version(dataflow_uid):
+    body = request.get_json(silent=True) or {}
+    n8n_workflow_id = str(body.get("n8n_workflow_id") or "").strip()
+    if not n8n_workflow_id:
+        return jsonify(failed("n8n_workflow_id 不能为空", code=400)), 400
+    try:
+        workflow = N8nClient().get_workflow(n8n_workflow_id)
+        version_id = create_version(
+            db.session,
+            dataflow_uid=dataflow_uid,
+            environment=body.get("environment", "development"),
+            workflow=workflow,
+            created_by=g.current_user["id"],
+        )
+        db.session.commit()
+        return jsonify(success({"id": version_id}, "版本创建成功", code=201)), 201
+    except (ValueError, N8nClientError) as exc:
+        db.session.rollback()
+        message = exc.message if isinstance(exc, N8nClientError) else str(exc)
+        return jsonify(failed(message, code=400)), 400
+
+
+@bp.route("/<dataflow_uid>/workflow-versions/<version_id>/activate", methods=["POST"])
+@require_permissions(ACTIVATE_WORKFLOW)
+def activate_workflow_version(dataflow_uid, version_id):
+    try:
+        versions = list_versions(db.session, dataflow_uid)
+        if not any(version["id"] == version_id for version in versions):
+            return jsonify(failed("版本不存在", code=404)), 404
+        result = activate_version(
+            db.session,
+            version_id=version_id,
+            actor_id=g.current_user["id"],
+            n8n_client=N8nClient(),
+        )
+        return jsonify(success(result, "版本激活成功"))
+    except (ValueError, RuntimeError, N8nClientError) as exc:
+        db.session.rollback()
+        message = exc.message if isinstance(exc, N8nClientError) else str(exc)
+        return jsonify(failed(message, code=409)), 409
+
+
 @bp.route("/get-dataflows-list", methods=["GET"])
 def get_dataflows():
     """获取数据流列表"""
@@ -110,53 +168,6 @@ def delete_dataflow(dataflow_id):
         return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
 
 
-@bp.route("/execute-dataflow/<int:dataflow_id>", methods=["POST"])
-def execute_dataflow(dataflow_id):
-    """执行数据流"""
-    try:
-        data = request.get_json() or {}
-        result = DataFlowService.execute_dataflow(dataflow_id, data)
-        res = success(result, "数据流执行成功")
-        return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
-    except Exception as e:
-        logger.error(f"执行数据流失败: {str(e)}")
-        res = failed(f"执行数据流失败: {str(e)}")
-        return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
-
-
-@bp.route("/get-dataflow-status/<int:dataflow_id>", methods=["GET"])
-def get_dataflow_status(dataflow_id):
-    """获取数据流执行状态"""
-    try:
-        result = DataFlowService.get_dataflow_status(dataflow_id)
-        res = success(result, "success")
-        return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
-    except Exception as e:
-        logger.error(f"获取数据流状态失败: {str(e)}")
-        res = failed(f"获取数据流状态失败: {str(e)}")
-        return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
-
-
-@bp.route("/get-dataflow-logs/<int:dataflow_id>", methods=["GET"])
-def get_dataflow_logs(dataflow_id):
-    """获取数据流执行日志"""
-    try:
-        page = request.args.get("page", 1, type=int)
-        page_size = request.args.get("page_size", 50, type=int)
-
-        result = DataFlowService.get_dataflow_logs(
-            dataflow_id,
-            page=page,
-            page_size=page_size,
-        )
-        res = success(result, "success")
-        return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
-    except Exception as e:
-        logger.error(f"获取数据流日志失败: {str(e)}")
-        res = failed(f"获取数据流日志失败: {str(e)}")
-        return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
-
-
 @bp.route("/get-BD-list", methods=["GET"])
 def get_business_domain_list():
     """获取BusinessDomain节点列表"""

+ 198 - 318
app/api/data_source/routes.py

@@ -1,347 +1,227 @@
-from flask import request, jsonify
+"""HTTP boundary for secret-free external data-source management."""
+
 import logging
-import json
-from datetime import datetime
-from app.models.result import success, failed
+
+from flask import g, jsonify, request
+
 from app.api.data_source import bp
-from app.core.graph.graph_operations import (
-    create_or_get_node, execute_cypher_query
+from app.core.data_source.errors import DataSourceError
+from app.core.data_source.redaction import (
+    redact_mapping,
+    sanitize_exception,
 )
-from sqlalchemy import create_engine, text, URL
+from app.models.result import failed, success
+
 
 logger = logging.getLogger(__name__)
 
 
-# 创建数据源时使用此api
-@bp.route('/save', methods=['POST'])
+def get_data_source_service():
+    from app.core.data_source.runtime import get_data_source_manager
+    from app.core.data_source.service import build_data_source_service
+
+    return build_data_source_service(get_data_source_manager())
+
+
+def _actor_uid():
+    identity = getattr(g, "current_user", {}) or {}
+    return identity.get("id") or identity.get("sub")
+
+
+def _error_response(error):
+    if isinstance(error, DataSourceError):
+        logger.warning(
+            "数据源操作失败: code=%s message=%s",
+            error.code,
+            sanitize_exception(error),
+        )
+        return (
+            jsonify(
+                failed(
+                    str(error),
+                    code=error.http_status,
+                    error={"code": error.code},
+                )
+            ),
+            error.http_status,
+        )
+    logger.error(
+        "数据源操作异常: %s",
+        sanitize_exception(error),
+    )
+    return (
+        jsonify(
+            failed(
+                "数据源操作失败",
+                code=500,
+                error={"code": "DATASOURCE_ERROR"},
+            )
+        ),
+        500,
+    )
+
+
+@bp.route("/save", methods=["POST"])
 def data_source_save():
-    """保存数据源"""
+    payload = request.get_json(silent=True) or {}
+    logger.debug("保存数据源请求: %s", redact_mapping(payload))
     try:
-        # 获取表单数据
-        data = request.json
-        log_data = json.dumps(data, ensure_ascii=False) if data else 'None'
-        logger.debug(f"保存数据源请求数据: {log_data}")
-
-        # 检查必填参数
-        required_fields = [
-            'database', 'host', 'port', 'username',
-            'password', 'name_en', 'type'
-        ]
-        if not data:
-            missing_fields = required_fields
-        else:
-            missing_fields = [
-                field for field in required_fields if not data.get(field)
-            ]
-
-        if missing_fields:
-            error_msg = f"缺少必填参数: {', '.join(missing_fields)}"
-            logger.error(error_msg)
-            return jsonify(failed(error_msg))
-
-        # 此时 data 一定不为 None
-        assert data is not None
-
-        # 检查name_en是否已存在
-        check_query = """
-        MATCH (n:DataSource)
-        WHERE n.name_en = $name_en
-        RETURN n
-        """
-        result = execute_cypher_query(
-            check_query, {'name_en': data['name_en']}
+        service = get_data_source_service()
+        definition, created = service.save(
+            payload,
+            actor_uid=_actor_uid(),
         )
+        status = 201 if created else 200
+        return jsonify(success(service.serialize(definition))), status
+    except Exception as error:
+        return _error_response(error)
 
-        # 添加创建时间
-        data['create_dt'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
-
-        if result:
-            # 如果存在,更新节点
-            node = result[0]['n']
-            node_id = node['_id']
-            # 更新属性
-            update_query = """
-            MATCH (n:DataSource)
-            WHERE id(n) = $node_id
-            SET n += $properties
-            RETURN id(n) as node_id
-            """
-            result = execute_cypher_query(update_query, {
-                'node_id': node_id,
-                'properties': data
-            })
-            message = "数据源更新成功"
-        else:
-            # 如果不存在,创建新节点
-            node_id = create_or_get_node('DataSource', **data)
-            message = "数据源创建成功"
-
-        # 返回成功结果
-        return jsonify(success({
-            "id": node_id,
-            "message": message
-        }))
-    except Exception as e:
-        logger.error(f"保存数据源失败: {str(e)}")
-        return jsonify(failed(str(e)))
-
-
-# 获取数据源列表 或根据id获取数据源信息
-@bp.route('/list', methods=['POST'])
+
+@bp.route("/list", methods=["POST"])
 def data_source_list():
-    """获取数据源列表或指定数据源信息"""
+    payload = request.get_json(silent=True) or {}
     try:
-        # 获取请求参数
-        data = request.json
-
-        # 构建查询条件
-        where_conditions = []
-        params = {}
-
-        # 如果指定了id
-        if data and 'id' in data:
-            where_conditions.append("id(n) = $id")
-            params['id'] = int(data['id'])
-        # 如果有其他属性
-        elif data:
-            for key, value in data.items():
-                if value:  # 只处理非空值
-                    where_conditions.append(f"n.{key} = ${key}")
-                    params[key] = value
-
-        # 构建WHERE子句
-        if where_conditions:
-            where_clause = " WHERE " + " AND ".join(where_conditions)
-        else:
-            where_clause = ""
-
-        # 构建查询语句
-        cypher = f"""
-        MATCH (n:DataSource)
-        {where_clause}
-        RETURN n
-        """
-
-        # 执行查询
-        result = execute_cypher_query(cypher, params)
-
-        # 格式化结果
-        data_sources = []
-        for record in result:
-            node = record['n']
-            node['id'] = node['_id']
-            data_sources.append(node)
-
-        # 返回结果
-        return jsonify(success({
-            "data_source": data_sources,
-            "total": len(data_sources)
-        }))
-
-    except Exception as e:
-        logger.error(f"获取数据源列表失败: {str(e)}")
-        return jsonify(failed(str(e)))
-
-
-@bp.route('/delete', methods=['POST'])
+        service = get_data_source_service()
+        definitions = service.list(payload)
+        items = [service.serialize(item) for item in definitions]
+        return jsonify(
+            success({"data_source": items, "total": len(items)})
+        ), 200
+    except Exception as error:
+        return _error_response(error)
+
+
+@bp.route("/delete", methods=["POST"])
 def data_source_delete():
-    """删除数据源"""
+    payload = request.get_json(silent=True) or {}
+    logger.debug("删除数据源请求: %s", redact_mapping(payload))
     try:
-        # 获取请求参数
-        data = request.json
-        log_data = json.dumps(data, ensure_ascii=False) if data else 'None'
-        logger.debug(f"删除数据源请求数据: {log_data}")
-
-        # 检查参数
-        if not data or ('id' not in data and 'name_en' not in data):
-            error_msg = "必须提供id或name_en参数"
-            logger.error(error_msg)
-            return jsonify(failed(error_msg))
-
-        # 构建删除条件
-        if 'id' in data:
-            where_clause = "id(n) = $id"
-            params = {'id': int(data['id'])}
-        else:
-            where_clause = "n.name_en = $name_en"
-            params = {'name_en': data['name_en']}
-
-        # 构建删除语句
-        delete_query = f"""
-        MATCH (n:DataSource)
-        WHERE {where_clause}
-        WITH n
-        OPTIONAL MATCH (n)-[r]-()
-        DELETE r, n
-        RETURN count(n) as deleted_count
-        """
-
-        # 执行删除
-        result = execute_cypher_query(delete_query, params)
-
-        if result and result[0]['deleted_count'] > 0:
-            return jsonify(success({
-                "message": "数据源删除成功",
-                "deleted_count": result[0]['deleted_count']
-            }))
-        else:
-            error_msg = "未找到指定的数据源"
-            logger.error(error_msg)
-            return jsonify(failed(error_msg))
-
-    except Exception as e:
-        logger.error(f"删除数据源失败: {str(e)}")
-        return jsonify(failed(str(e)))
-
-
-@bp.route('/parse', methods=['POST'])
-def data_source_connstr_parse():
-    """解析数据源连接字符串"""
+        result = get_data_source_service().delete(
+            payload.get("uid"),
+            actor_uid=_actor_uid(),
+        )
+        return jsonify(success(result)), 200
+    except Exception as error:
+        return _error_response(error)
+
+
+@bp.route("/conntest", methods=["POST"])
+def data_source_conn_test():
+    payload = request.get_json(silent=True) or {}
+    logger.debug("测试数据源连接请求: %s", redact_mapping(payload))
     try:
-        # 获取请求参数
-        data = request.json
-        log_data = json.dumps(data, ensure_ascii=False) if data else 'None'
-        logger.debug(f"解析连接字符串请求数据: {log_data}")
-
-        # 检查参数
-        if not data or 'conn_str' not in data:
-            error_msg = "缺少连接字符串参数"
-            logger.error(error_msg)
-            return jsonify(failed(error_msg))
-
-        # 创建DDLParser实例并解析连接字符串
-        from app.core.llm.ddl_parser import DDLParser
-        parser = DDLParser()
-        result = parser.parse_db_conn_str(data['conn_str'])
-
-        # 检查解析结果
-        is_error = (isinstance(result, dict) and
-                    'code' in result and result['code'] == 500)
-        if is_error:
-            msg = result.get('message', '未知错误')
-            error_msg = f"解析连接字符串失败: {msg}"
-            logger.error(error_msg)
-            return jsonify(failed(error_msg))
-
-        # 返回成功结果
-        return jsonify(success(result))
-
-    except Exception as e:
-        logger.error(f"解析连接字符串失败: {str(e)}")
-        return jsonify(failed(str(e)))
-
-
-@bp.route('/valid', methods=['POST'])
+        result = get_data_source_service().test_connection(payload)
+        return jsonify(success(result)), 200
+    except Exception as error:
+        return _error_response(error)
+
+
+@bp.route("/valid", methods=["POST"])
 def data_source_connstr_valid():
-    """验证数据源连接信息"""
+    payload = request.get_json(silent=True) or {}
+    logger.debug("验证数据源连接请求: %s", redact_mapping(payload))
     try:
-        # 获取请求参数
-        data = request.json
-        log_data = json.dumps(data, ensure_ascii=False) if data else 'None'
-        logger.debug(f"验证连接信息请求数据: {log_data}")
-
-        # 检查参数
-        if not data:
-            error_msg = "缺少连接信息参数"
-            logger.error(error_msg)
-            return jsonify(failed(error_msg))
-
-        # 检查密码是否为空
-        if not data.get('password'):
-            error_msg = "密码不能为空"
-            logger.error(error_msg)
-            return jsonify(failed(error_msg))
-
-        # 创建DDLParser实例并验证连接信息
-        from app.core.llm.ddl_parser import DDLParser
-        parser = DDLParser()
-        result = parser.valid_db_conn_str(data)
-
-        # 根据验证结果返回响应
-        if result == "success":
-            # 检查数据源是否已存在
-            check_query = """
-            MATCH (n:DataSource)
-            WHERE n.name_en = $name_en
-            RETURN n
-            """
-            existing_source = execute_cypher_query(
-                check_query, {'name_en': data['name_en']}
-            )
+        result = get_data_source_service().test_connection(payload)
+        return jsonify(success({"exists": False, **result})), 200
+    except Exception as error:
+        return _error_response(error)
 
-            if existing_source:
-                msg = "连接信息验证通过,但该数据源的定义已经存在,如果保存则会更新该数据源"
-                return jsonify(success(msg))
-            else:
-                return jsonify(success("连接信息验证通过"))
-        else:
-            return jsonify(failed("连接信息验证失败"))
 
-    except Exception as e:
-        logger.error(f"验证连接信息失败: {str(e)}")
-        return jsonify(failed(str(e)))
+@bp.route("/parse", methods=["POST"])
+def data_source_connstr_parse():
+    return (
+        jsonify(
+            failed(
+                "连接字符串快捷解析已停用",
+                code=410,
+                error={"code": "DATASOURCE_PARSE_RETIRED"},
+            )
+        ),
+        410,
+    )
 
 
-@bp.route('/conntest', methods=['POST'])
-def data_source_conn_test():
-    """测试数据源连接"""
+@bp.route("/pools", methods=["GET"])
+def data_source_pool_list():
     try:
-        # 获取请求参数
-        data = request.json
-        log_data = json.dumps(data, ensure_ascii=False) if data else 'None'
-        logger.debug(f"测试连接请求数据: {log_data}")
-
-        # 检查必需参数
-        required_fields = [
-            'type', 'username', 'host', 'port', 'database', 'password'
+        service = get_data_source_service()
+        items = [
+            service.serialize_pool_status(status)
+            for status in service.pool_statuses()
         ]
-        if not data:
-            missing_fields = required_fields
-        else:
-            missing_fields = [
-                field for field in required_fields if not data.get(field)
-            ]
-
-        if missing_fields:
-            error_msg = f"缺少必需参数: {', '.join(missing_fields)}"
-            logger.error(error_msg)
-            return jsonify(failed(error_msg))
-
-        # 此时 data 一定不为 None
-        assert data is not None
-
-        # 构建数据库URL
-        db_url = URL.create(
-            drivername=data['type'],
-            username=data['username'],
-            password=data.get('password', ''),
-            host=data['host'],
-            port=data['port'],
-            database=data['database']
-        )
-
-        # 创建数据库引擎
-        engine = create_engine(db_url, connect_args={'connect_timeout': 5})
+        return jsonify(success({"pools": items, "total": len(items)})), 200
+    except Exception as error:
+        return _error_response(error)
 
-        # 测试连接
-        try:
-            with engine.connect() as conn:
-                # 执行一个简单的查询来测试连接
-                conn.execute(text("SELECT 1"))
-                return jsonify(success({
-                    "message": f"{data['type']}连接测试成功",
-                    "connected": True
-                }))
-        except Exception as e:
-            return jsonify(failed(f"连接测试失败: {str(e)}"))
 
-    except Exception as e:
-        logger.error(f"测试连接失败: {str(e)}")
-        return jsonify(failed(str(e)))
+@bp.route("/<data_source_uid>/pool", methods=["GET"])
+def data_source_pool_status(data_source_uid):
+    try:
+        service = get_data_source_service()
+        status = service.pool_status(data_source_uid)
+        serializer = getattr(
+            service,
+            "serialize_pool_status",
+            None,
+        )
+        data = (
+            serializer(status)
+            if serializer is not None
+            else {
+                "data_source_uid": status.data_source_uid,
+                "credential_version": status.credential_version,
+                "pool_state": status.pool_state,
+                "pool_size": status.pool_size,
+                "checked_out": status.checked_out,
+                "checked_in": status.checked_in,
+                "overflow": status.overflow,
+                "leases": status.leases,
+            }
+        )
+        return jsonify(success(data)), 200
+    except Exception as error:
+        return _error_response(error)
+
+
+@bp.route("/<data_source_uid>/pool/invalidate", methods=["POST"])
+def data_source_pool_invalidate(data_source_uid):
+    payload = request.get_json(silent=True) or {}
+    reason = payload.get("reason")
+    if reason not in {
+        "admin_reset",
+        "configuration_changed",
+        "credential_rotated",
+    }:
+        return (
+            jsonify(
+                failed(
+                    "连接池失效原因无效",
+                    code=400,
+                    error={"code": "DATASOURCE_CONFIGURATION_INVALID"},
+                )
+            ),
+            400,
+        )
+    try:
+        result = get_data_source_service().invalidate_pool(
+            data_source_uid,
+            reason=reason,
+            actor_uid=_actor_uid(),
+        )
+        return jsonify(success(result)), 200
+    except Exception as error:
+        return _error_response(error)
 
 
-@bp.route('/graph', methods=['POST'])
+@bp.route("/graph", methods=["POST"])
 def data_source_graph_relationship():
-    """获取数据源关系图"""
-    # TODO: 待实现
-    return jsonify(failed("该功能尚未实现"))
+    return (
+        jsonify(
+            failed(
+                "该功能尚未实现",
+                code=501,
+                error={"code": "DATASOURCE_GRAPH_NOT_IMPLEMENTED"},
+            )
+        ),
+        501,
+    )

+ 40 - 25
app/api/meta_data/routes.py

@@ -8,6 +8,7 @@ from sqlalchemy import or_
 
 from app import db
 from app.api.meta_data import bp
+from app.core.common.identifiers import ensure_governance_uid
 from app.core.meta_data import (
     check_redundancy_for_add,
     check_redundancy_for_update,
@@ -399,7 +400,8 @@ def meta_node_add():
         with neo4j_driver.get_session() as session:
             cypher = """
             MERGE (n:DataMeta {name_zh: $name_zh})
-            ON CREATE SET n.name_en = $name_en,
+            ON CREATE SET n.uid = $uid,
+                        n.name_en = $name_en,
                         n.data_type = $data_type,
                         n.category = $category,
                         n.alias = $alias,
@@ -409,7 +411,8 @@ def meta_node_add():
                         n.updateTime = $update_time,
                         n.status = $status,
                         n.name_en = $name_en
-            ON MATCH SET n.data_type = $data_type,
+            ON MATCH SET n.uid = coalesce(n.uid, $uid),
+                        n.data_type = $data_type,
                         n.category = $category,
                         n.alias = $alias,
                         n.affiliation = $affiliation,
@@ -420,18 +423,22 @@ def meta_node_add():
             RETURN n
             """
             create_time = update_time = get_formatted_time()
+            node_properties = {
+                "name_zh": node_name_zh,
+                "data_type": node_type,
+                "category": node_category,
+                "alias": node_alias,
+                "affiliation": node_affiliation,
+                "describe": node_desc,
+                "create_time": create_time,
+                "update_time": update_time,
+                "status": node_status,
+                "name_en": node_name_en,
+            }
+            ensure_governance_uid(node_properties)
             result = session.run(
                 cypher,
-                name_zh=node_name_zh,
-                data_type=node_type,
-                category=node_category,
-                alias=node_alias,
-                affiliation=node_affiliation,
-                describe=node_desc,
-                create_time=create_time,
-                update_time=update_time,
-                status=node_status,
-                name_en=node_name_en,
+                **node_properties,
             )
 
             node = result.single()
@@ -861,6 +868,7 @@ def text_resource_node():
             # 创建资源节点
             cypher = """
             CREATE (n:DataMeta {
+                uid: $uid,
                 name_zh: $name_zh,
                 name_en: $name_en,
                 keywords: $keywords,
@@ -873,15 +881,19 @@ def text_resource_node():
             """
 
             create_time = update_time = get_formatted_time()
+            node_properties = {
+                "name_zh": name_zh,
+                "name_en": name_en,
+                "keywords": keywords,
+                "keywords_en": keywords_en,
+                "object_name": object_name,
+                "create_time": create_time,
+                "update_time": update_time,
+            }
+            ensure_governance_uid(node_properties)
             result = session.run(
                 cypher,
-                name_zh=name_zh,
-                name_en=name_en,
-                keywords=keywords,
-                keywords_en=keywords_en,
-                object_name=object_name,
-                create_time=create_time,
-                update_time=update_time,
+                **node_properties,
             )
 
             record = result.single()
@@ -1556,9 +1568,17 @@ def metadata_review_resolve():
 
             with neo4j_driver.get_session() as session:
                 # 创建新 DataMeta(避免覆盖旧节点)
+                node_properties = {
+                    "name_zh": new_name_zh,
+                    "name_en": (new_meta.get("name_en") or "").strip(),
+                    "data_type": (new_meta.get("data_type") or "varchar(255)"),
+                    "create_time": get_formatted_time(),
+                }
+                ensure_governance_uid(node_properties)
                 result = session.run(
                     """
                     CREATE (m:DataMeta {
+                        uid: $uid,
                         name_zh: $name_zh,
                         name_en: $name_en,
                         data_type: $data_type,
@@ -1567,12 +1587,7 @@ def metadata_review_resolve():
                     })
                     RETURN m
                     """,
-                    {
-                        "name_zh": new_name_zh,
-                        "name_en": (new_meta.get("name_en") or "").strip(),
-                        "data_type": (new_meta.get("data_type") or "varchar(255)"),
-                        "create_time": get_formatted_time(),
-                    },
+                    node_properties,
                 ).single()
 
                 if not result or not result.get("m"):

+ 4 - 34
app/api/system/README.md

@@ -9,7 +9,7 @@
 1. **系统健康检查**:提供系统和依赖组件的健康状态监控
 2. **配置管理**:获取和验证系统配置信息
 3. **系统信息**:获取系统运行环境的详细信息
-4. **用户认证**:提供用户注册、登录等功能
+4. **用户认证**:提供管理员预置账号登录和用户信息查询;不开放自助注册
 
 ## API接口
 
@@ -139,36 +139,7 @@
   }
   ```
 
-### 5. 用户注册接口 (/system/auth/register)
-
-- **URL**: `/system/auth/register`
-- **方法**: POST
-- **描述**: 注册新用户
-- **请求参数**:
-  ```json
-  {
-    "username": "用户名",
-    "password": "密码"
-  }
-  ```
-- **返回数据**:
-  ```json
-  {
-    "code": 200,
-    "message": "注册成功",
-    "data": null
-  }
-  ```
-- **错误响应**:
-  ```json
-  {
-    "code": 400,
-    "message": "用户名已存在",
-    "data": null
-  }
-  ```
-
-### 6. 用户登录接口 (/system/auth/login)
+### 5. 用户登录接口 (/system/auth/login)
 
 - **URL**: `/system/auth/login`
 - **方法**: POST
@@ -202,7 +173,7 @@
   }
   ```
 
-### 7. 获取用户信息接口 (/system/auth/user/{username})
+### 6. 获取用户信息接口 (/system/auth/user/{username})
 
 - **URL**: `/system/auth/user/{username}`
 - **方法**: GET
@@ -252,8 +223,7 @@ from app.core.system import (
     get_system_info,
     get_system_config,
     validate_config,
-    register_user,
     login_user,
     get_user_by_username
 )
-``` 
+```

+ 2 - 0
app/api/system/__init__.py

@@ -3,3 +3,5 @@ from flask import Blueprint
 bp = Blueprint("system", __name__)
 
 from app.api.system import routes  # noqa: E402, F401
+from app.api.system import users  # noqa: E402, F401
+from app.api.system import workbench  # noqa: E402, F401

+ 29 - 72
app/api/system/routes.py

@@ -3,7 +3,7 @@ System API Module
 提供系统管理相关的API接口
 """
 
-from flask import jsonify, request
+from flask import g, jsonify, request
 from app.api.system import bp
 from app.models.result import success, failed
 import logging
@@ -12,11 +12,10 @@ from app.core.system import (
     get_system_info,
     get_system_config,
     validate_config,
-    register_user,
-    login_user,
-    get_user_by_username
+    authenticate_user,
 )
 from app.core.common.functions import translate_and_parse
+from app.core.system.permissions import READ_GOVERNANCE, permissions_for_roles, require_permissions
 
 logger = logging.getLogger("app")
 
@@ -100,44 +99,6 @@ def config_validate():
         return jsonify(failed(str(e)))
 
 
-# 用户注册接口
-@bp.route('/auth/register', methods=['POST'])
-def user_register():
-    """
-    用户注册
-
-    请求参数:
-        username: 用户名
-        password: 密码
-
-    Returns:
-        JSON: 注册结果
-    """
-    try:
-        # 获取请求参数
-        data = request.json
-        if not data:
-            return jsonify(failed("请求数据不能为空", code=400))
-
-        username = data.get('username')
-        password = data.get('password')
-
-        # 参数验证
-        if not username or not password:
-            return jsonify(failed("用户名和密码不能为空", code=400))
-
-        # 注册用户
-        success_flag, message = register_user(username, password)
-
-        if success_flag:
-            return jsonify(success(message="注册成功"))
-        else:
-            return jsonify(failed(message, code=400))
-    except Exception as e:
-        logger.error(f"用户注册失败: {str(e)}")
-        return jsonify(failed(str(e)))
-
-
 # 用户登录接口
 @bp.route('/auth/login', methods=['POST'])
 def user_login():
@@ -164,39 +125,30 @@ def user_login():
         if not username or not password:
             return jsonify(failed("用户名和密码不能为空", code=400))
 
-        # 登录验证
-        success_flag, result = login_user(username, password)
+        result = authenticate_user(
+            username,
+            password,
+            ip_address=request.headers.get("X-Forwarded-For", request.remote_addr),
+            user_agent=request.headers.get("User-Agent"),
+        )
 
-        if success_flag:
+        if result:
+            result["permissions"] = sorted(permissions_for_roles(result["roles"]))
             return jsonify(success(result, "登录成功"))
-        else:
-            return jsonify(failed(str(result), code=401))
+        return jsonify(failed("用户名或密码错误", code=401)), 401
+    except PermissionError:
+        return jsonify(failed("登录尝试过于频繁,请稍后重试", code=429)), 429
     except Exception as e:
         logger.error(f"用户登录失败: {str(e)}")
         return jsonify(failed(str(e)))
 
 
-# 获取用户信息接口
-@bp.route('/auth/user/<username>', methods=['GET'])
-def get_user(username):
-    """
-    获取用户信息
-
-    Args:
-        username: 用户名
-
-    Returns:
-        JSON: 用户信息
-    """
-    try:
-        user = get_user_by_username(username)
-        if user:
-            return jsonify(success(user))
-        else:
-            return jsonify(failed("用户不存在", code=404))
-    except Exception as e:
-        logger.error(f"获取用户信息失败: {str(e)}")
-        return jsonify(failed(str(e)))
+@bp.route('/auth/me', methods=['GET'])
+@require_permissions(READ_GOVERNANCE)
+def current_user():
+    identity = dict(g.current_user)
+    identity["permissions"] = sorted(permissions_for_roles(identity["roles"]))
+    return jsonify(success(identity))
 
 
 # 翻译接口
@@ -225,13 +177,18 @@ def translate():
 
         # 调用翻译函数
         translated_result = translate_and_parse(node_name)
+        if not translated_result:
+            return jsonify(
+                failed(
+                    "翻译失败,未能生成有效的英文标识符,请检查 DEEPSEEK_API_KEY 配置",
+                    code=500,
+                )
+            )
 
-        # 返回翻译结果
-        translated = translated_result if translated_result else node_name
         result = {
             "original": node_name,
-            "translated": translated,
-            "translated_list": translated_result
+            "translated": translated_result,
+            "translated_list": translated_result,
         }
 
         return jsonify(success(result, "翻译成功"))

+ 183 - 0
app/api/system/users.py

@@ -0,0 +1,183 @@
+from __future__ import annotations
+
+from flask import g, jsonify, request
+from sqlalchemy import text
+from sqlalchemy.exc import IntegrityError
+
+from app import db
+from app.api.system import bp
+from app.commands.bootstrap_admin import validate_password
+from app.core.common.identifiers import new_governance_uid
+from app.core.system.auth import hash_password
+from app.core.system.permissions import MANAGE_USERS, require_permissions
+from app.models.result import failed, success
+
+
+VALID_ROLES = {"admin", "editor", "viewer"}
+
+
+def _active_admin_count(session) -> int:
+    return int(
+        session.execute(
+            text(
+                "SELECT COUNT(DISTINCT u.id) FROM public.users u "
+                "JOIN public.user_roles ur ON ur.user_id = u.id "
+                "JOIN public.roles r ON r.id = ur.role_id "
+                "WHERE u.status = 'active' AND r.name = 'admin'"
+            )
+        ).scalar_one()
+    )
+
+
+def _is_active_admin(session, user_id: str) -> bool:
+    return bool(
+        session.execute(
+            text(
+                "SELECT 1 FROM public.users u "
+                "JOIN public.user_roles ur ON ur.user_id = u.id "
+                "JOIN public.roles r ON r.id = ur.role_id "
+                "WHERE u.id = CAST(:id AS uuid) AND u.status = 'active' "
+                "AND r.name = 'admin'"
+            ),
+            {"id": user_id},
+        ).scalar()
+    )
+
+
+def _serialize_users(session) -> list[dict]:
+    rows = session.execute(
+        text(
+            "SELECT u.id::text, u.username, u.display_name, u.status, "
+            "u.created_at, u.last_login_at, "
+            "COALESCE(array_agg(r.name ORDER BY r.name) FILTER "
+            "(WHERE r.name IS NOT NULL), ARRAY[]::varchar[]) "
+            "FROM public.users u "
+            "LEFT JOIN public.user_roles ur ON ur.user_id = u.id "
+            "LEFT JOIN public.roles r ON r.id = ur.role_id "
+            "GROUP BY u.id ORDER BY u.created_at, u.username"
+        )
+    )
+    return [
+        {
+            "id": row[0],
+            "username": row[1],
+            "display_name": row[2],
+            "status": row[3],
+            "created_at": row[4].isoformat(),
+            "last_login_at": row[5].isoformat() if row[5] else None,
+            "roles": list(row[6]),
+        }
+        for row in rows
+    ]
+
+
+@bp.route("/users", methods=["GET"])
+@require_permissions(MANAGE_USERS)
+def list_users():
+    return jsonify(success(_serialize_users(db.session)))
+
+
+@bp.route("/users", methods=["POST"])
+@require_permissions(MANAGE_USERS)
+def create_user():
+    body = request.get_json(silent=True) or {}
+    username = str(body.get("username") or "").strip()
+    password = str(body.get("password") or "")
+    roles = set(body.get("roles") or ["viewer"])
+    if not username or not roles or not roles <= VALID_ROLES:
+        return jsonify(failed("用户名或角色无效", code=400)), 400
+    try:
+        validate_password(password)
+        user_id = new_governance_uid()
+        db.session.execute(
+            text(
+                "INSERT INTO public.users (id, username, display_name, password_hash) "
+                "VALUES (CAST(:id AS uuid), :username, :display_name, :password_hash)"
+            ),
+            {
+                "id": user_id,
+                "username": username,
+                "display_name": body.get("display_name") or username,
+                "password_hash": hash_password(password),
+            },
+        )
+        db.session.execute(
+            text(
+                "INSERT INTO public.user_roles (user_id, role_id, assigned_by) "
+                "SELECT CAST(:user_id AS uuid), id, CAST(:assigned_by AS uuid) "
+                "FROM public.roles WHERE name = ANY(:roles)"
+            ),
+            {"user_id": user_id, "assigned_by": g.current_user["id"], "roles": sorted(roles)},
+        )
+        db.session.commit()
+        return jsonify(success({"id": user_id}, "用户创建成功", code=201)), 201
+    except (ValueError, IntegrityError) as exc:
+        db.session.rollback()
+        return jsonify(failed(str(exc), code=400)), 400
+
+
+@bp.route("/users/<user_id>", methods=["PUT"])
+@require_permissions(MANAGE_USERS)
+def update_user(user_id: str):
+    body = request.get_json(silent=True) or {}
+    status = body.get("status")
+    if status not in (None, "active", "disabled"):
+        return jsonify(failed("用户状态无效", code=400)), 400
+    if status == "disabled" and _is_active_admin(db.session, user_id):
+        if _active_admin_count(db.session) <= 1:
+            return jsonify(failed("不能停用最后一个有效管理员", code=409)), 409
+    values = {"id": user_id}
+    assignments = []
+    if status is not None:
+        assignments.append("status = :status")
+        values["status"] = status
+    if "display_name" in body:
+        assignments.append("display_name = :display_name")
+        values["display_name"] = str(body.get("display_name") or "")[:100] or None
+    if "password" in body:
+        validate_password(str(body["password"]))
+        assignments.append("password_hash = :password_hash")
+        values["password_hash"] = hash_password(str(body["password"]))
+    if not assignments:
+        return jsonify(failed("没有可更新字段", code=400)), 400
+    result = db.session.execute(
+        text(
+            "UPDATE public.users SET " + ", ".join(assignments) +
+            ", updated_at = CURRENT_TIMESTAMP WHERE id = CAST(:id AS uuid)"
+        ),
+        values,
+    )
+    if not result.rowcount:
+        db.session.rollback()
+        return jsonify(failed("用户不存在", code=404)), 404
+    db.session.commit()
+    return jsonify(success(message="用户更新成功"))
+
+
+@bp.route("/users/<user_id>/roles", methods=["PUT"])
+@require_permissions(MANAGE_USERS)
+def update_user_roles(user_id: str):
+    body = request.get_json(silent=True) or {}
+    roles = set(body.get("roles") or [])
+    if not roles or not roles <= VALID_ROLES:
+        return jsonify(failed("角色无效", code=400)), 400
+    if _is_active_admin(db.session, user_id) and "admin" not in roles:
+        if _active_admin_count(db.session) <= 1:
+            return jsonify(failed("不能移除最后一个有效管理员角色", code=409)), 409
+    db.session.execute(
+        text("DELETE FROM public.user_roles WHERE user_id = CAST(:id AS uuid)"),
+        {"id": user_id},
+    )
+    result = db.session.execute(
+        text(
+            "INSERT INTO public.user_roles (user_id, role_id, assigned_by) "
+            "SELECT CAST(:user_id AS uuid), id, CAST(:assigned_by AS uuid) "
+            "FROM public.roles WHERE name = ANY(:roles)"
+        ),
+        {"user_id": user_id, "assigned_by": g.current_user["id"], "roles": sorted(roles)},
+    )
+    if result.rowcount != len(roles):
+        db.session.rollback()
+        return jsonify(failed("用户或角色不存在", code=404)), 404
+    db.session.commit()
+    return jsonify(success(message="角色更新成功"))

+ 44 - 0
app/api/system/workbench.py

@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+import json
+from flask import g, jsonify, request
+from sqlalchemy import text
+
+from app import db
+from app.api.system import bp
+from app.core.system.permissions import READ_GOVERNANCE, require_permissions
+from app.core.workbench.registry import DEFAULT_LAYOUT, WIDGET_REGISTRY, validate_layout
+from app.models.result import failed, success
+
+
+@bp.route("/workbench/registry", methods=["GET"])
+@require_permissions(READ_GOVERNANCE)
+def workbench_registry():
+    return jsonify(success(WIDGET_REGISTRY))
+
+
+@bp.route("/workbench/layout", methods=["GET"])
+@require_permissions(READ_GOVERNANCE)
+def get_workbench_layout():
+    row = db.session.execute(text("SELECT layout_version, widgets FROM public.workbench_layouts WHERE user_id = CAST(:id AS uuid)"), {"id": g.current_user["id"]}).one_or_none()
+    data = {"version": row[0], "widgets": row[1]} if row else {"version": 0, "widgets": DEFAULT_LAYOUT}
+    return jsonify(success(data))
+
+
+@bp.route("/workbench/layout", methods=["PUT"])
+@require_permissions(READ_GOVERNANCE)
+def put_workbench_layout():
+    body = request.get_json(silent=True) or {}
+    try:
+        widgets = validate_layout(body.get("widgets"))
+        expected = int(request.headers.get("If-Match", body.get("version", 0)))
+    except (ValueError, TypeError) as exc:
+        return jsonify(failed(str(exc), code=400)), 400
+    current = db.session.execute(text("SELECT layout_version FROM public.workbench_layouts WHERE user_id = CAST(:id AS uuid) FOR UPDATE"), {"id": g.current_user["id"]}).scalar_one_or_none()
+    if (current or 0) != expected:
+        db.session.rollback()
+        return jsonify(failed("布局已被其他会话修改", code=409, data={"current_version": current or 0})), 409
+    new_version = expected + 1
+    db.session.execute(text("INSERT INTO public.workbench_layouts (user_id, layout_version, widgets) VALUES (CAST(:id AS uuid), :version, CAST(:widgets AS jsonb)) ON CONFLICT (user_id) DO UPDATE SET layout_version = EXCLUDED.layout_version, widgets = EXCLUDED.widgets, updated_at = CURRENT_TIMESTAMP"), {"id": g.current_user["id"], "version": new_version, "widgets": json.dumps(widgets)})
+    db.session.commit()
+    return jsonify(success({"version": new_version, "widgets": widgets}))

+ 1 - 0
app/commands/__init__.py

@@ -0,0 +1 @@
+"""Operational commands for the DataOps Platform."""

+ 93 - 0
app/commands/bootstrap_admin.py

@@ -0,0 +1,93 @@
+from __future__ import annotations
+
+import argparse
+import getpass
+import logging
+import os
+import re
+
+from sqlalchemy import text
+
+from app import create_app, db
+from app.core.common.identifiers import new_governance_uid
+from app.core.system.auth import hash_password
+
+
+logger = logging.getLogger(__name__)
+
+
+def validate_password(password: str) -> None:
+    if len(password) < 10:
+        raise ValueError("password must contain at least 10 characters")
+    if not re.search(r"[a-z]", password):
+        raise ValueError("password must contain a lowercase letter")
+    if not re.search(r"[A-Z]", password):
+        raise ValueError("password must contain an uppercase letter")
+    if not re.search(r"[0-9]", password):
+        raise ValueError("password must contain a digit")
+
+
+def log_bootstrap_result(username: str, *, created: bool) -> None:
+    if created:
+        logger.info("Created first platform administrator: %s", username)
+    else:
+        logger.info("Platform administrator already exists; bootstrap skipped")
+
+
+def bootstrap_first_admin(session, *, username: str, password: str) -> str:
+    validate_password(password)
+    existing = session.execute(
+        text(
+            "SELECT COUNT(*) FROM public.users u "
+            "JOIN public.user_roles ur ON ur.user_id = u.id "
+            "JOIN public.roles r ON r.id = ur.role_id "
+            "WHERE r.name = 'admin' AND u.status = 'active'"
+        )
+    ).scalar_one()
+    if existing:
+        raise RuntimeError("an active administrator already exists")
+
+    user_id = new_governance_uid()
+    session.execute(
+        text(
+            "INSERT INTO public.users "
+            "(id, username, display_name, password_hash) "
+            "VALUES (CAST(:id AS uuid), :username, :username, :password_hash)"
+        ),
+        {"id": user_id, "username": username.strip(), "password_hash": hash_password(password)},
+    )
+    session.execute(
+        text(
+            "INSERT INTO public.user_roles (user_id, role_id, assigned_by) "
+            "SELECT CAST(:id AS uuid), id, CAST(:id AS uuid) "
+            "FROM public.roles WHERE name = 'admin'"
+        ),
+        {"id": user_id},
+    )
+    return user_id
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Create the first DataOps administrator")
+    parser.add_argument("--username", default=os.environ.get("BOOTSTRAP_ADMIN_USERNAME"))
+    parser.add_argument("--password", default=os.environ.get("BOOTSTRAP_ADMIN_PASSWORD"))
+    parser.add_argument("--if-missing", action="store_true")
+    args = parser.parse_args()
+    username = args.username or input("Administrator username: ").strip()
+    password = args.password or getpass.getpass("Administrator password: ")
+
+    app = create_app()
+    with app.app_context():
+        try:
+            bootstrap_first_admin(db.session, username=username, password=password)
+            db.session.commit()
+            log_bootstrap_result(username, created=True)
+        except RuntimeError:
+            db.session.rollback()
+            if not args.if_missing:
+                raise
+            log_bootstrap_result(username, created=False)
+
+
+if __name__ == "__main__":
+    main()

+ 150 - 0
app/commands/migrate_datasource_credentials.py

@@ -0,0 +1,150 @@
+"""Explicitly encrypt one legacy DataSource credential and remove plaintext."""
+
+import argparse
+import json
+from dataclasses import dataclass
+
+from app.core.data_source.models import DataSourceCredential
+
+
+@dataclass(frozen=True)
+class MigrationResult:
+    data_source_uid: str
+    credential_version: int
+    status: str
+
+
+def migrate_one(
+    data_source_uid,
+    graph_session,
+    credential_repository,
+    platform_session,
+    *,
+    actor_uid,
+):
+    record = graph_session.run(
+        """
+        MATCH (n:DataSource {uid: $uid})
+        RETURN properties(n) AS properties
+        """,
+        {"uid": str(data_source_uid)},
+    ).single()
+    if record is None:
+        raise ValueError("data source was not found")
+    properties = dict(record["properties"])
+    username = properties.get("username")
+    password = properties.get("password")
+    if not username or not password:
+        raise ValueError("legacy plaintext credential is incomplete")
+
+    credential = DataSourceCredential(str(username), str(password))
+    try:
+        sealed = credential_repository.create_version(
+            platform_session,
+            data_source_uid=str(data_source_uid),
+            credential=credential,
+            actor_uid=actor_uid,
+        )
+        verified = credential_repository.codec.decrypt(sealed)
+        if verified != credential:
+            raise ValueError("credential round-trip verification failed")
+        platform_session.commit()
+    except Exception:
+        platform_session.rollback()
+        raise
+
+    try:
+        graph_session.run(
+            """
+            MATCH (n:DataSource {uid: $uid})
+            SET n.credential_ref = $credential_ref,
+                n.credential_version = $credential_version
+            """,
+            {
+                "uid": str(data_source_uid),
+                "credential_ref": str(data_source_uid),
+                "credential_version": sealed.credential_version,
+            },
+        )
+        graph_session.run(
+            """
+            MATCH (n:DataSource {uid: $uid})
+            REMOVE n.username, n.password, n.conn_str,
+                   n.connection_string, n.connection_url
+            """,
+            {"uid": str(data_source_uid)},
+        )
+    except Exception as graph_error:
+        try:
+            credential_repository.compensate_failed_activation(
+                platform_session,
+                data_source_uid=str(data_source_uid),
+                failed_version=sealed.credential_version,
+                restore_version=None,
+                actor_uid=actor_uid,
+            )
+            platform_session.commit()
+        except Exception:
+            platform_session.rollback()
+        raise ValueError(
+            "credential migration requires reconciliation"
+        ) from graph_error
+
+    return MigrationResult(
+        data_source_uid=str(data_source_uid),
+        credential_version=sealed.credential_version,
+        status="migrated",
+    )
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="Encrypt one legacy DataSource credential"
+    )
+    parser.add_argument("--uid", required=True)
+    parser.add_argument(
+        "--confirm-encrypt-and-remove-plaintext",
+        action="store_true",
+    )
+    args = parser.parse_args()
+    if not args.confirm_encrypt_and_remove_plaintext:
+        parser.error(
+            "--confirm-encrypt-and-remove-plaintext is required"
+        )
+
+    from app import create_app, db
+    from app.core.data_source.credentials import (
+        CredentialCodec,
+        DataSourceCredentialRepository,
+    )
+    from app.services.neo4j_driver import neo4j_driver
+
+    app = create_app()
+    with app.app_context():
+        codec = CredentialCodec.from_base64(
+            app.config.get("DATASOURCE_CREDENTIAL_MASTER_KEY", ""),
+            app.config.get("DATASOURCE_CREDENTIAL_KEY_VERSION", "v1"),
+        )
+        repository = DataSourceCredentialRepository(codec)
+        with neo4j_driver.get_session() as graph_session:
+            result = migrate_one(
+                args.uid,
+                graph_session,
+                repository,
+                db.session,
+                actor_uid=None,
+            )
+        print(
+            json.dumps(
+                {
+                    "data_source_uid": result.data_source_uid,
+                    "credential_version": result.credential_version,
+                    "status": result.status,
+                },
+                ensure_ascii=False,
+            )
+        )
+
+
+if __name__ == "__main__":
+    main()

+ 75 - 0
app/commands/process_outbox.py

@@ -0,0 +1,75 @@
+from __future__ import annotations
+
+import argparse
+import logging
+from typing import Any, Callable
+
+from app import create_app, db
+from app.core.events.consumer import dispatch_event
+from app.core.events.outbox import (
+    apply_dispatch_result,
+    claim_outbox,
+    consumed,
+    reset_stale_processing,
+)
+
+
+logger = logging.getLogger(__name__)
+
+
+def process_available_events(
+    session: Any,
+    handlers: dict[str, Callable[[dict[str, Any]], None]],
+    *,
+    consumer_name: str = "dataops-default",
+    batch_size: int = 50,
+    max_attempts: int = 5,
+) -> int:
+    if not handlers:
+        logger.info("No outbox handlers registered; leaving events pending")
+        return 0
+
+    events = claim_outbox(session, limit=batch_size)
+    for event in events:
+        handler = handlers.get(event.event_type)
+        if handler is None:
+            handler = lambda _payload: (_ for _ in ()).throw(
+                ValueError(f"no handler registered for {event.event_type}")
+            )
+        result = dispatch_event(
+            event,
+            handler,
+            already_processed=consumed(session, consumer_name, event.event_id),
+            max_attempts=max_attempts,
+        )
+        apply_dispatch_result(
+            session,
+            consumer_name=consumer_name,
+            event=event,
+            result=result,
+        )
+    session.commit()
+    return len(events)
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Process DataOps outbox events")
+    parser.add_argument("--batch-size", type=int, default=50)
+    parser.add_argument("--reset-stale-seconds", type=int, default=300)
+    args = parser.parse_args()
+
+    app = create_app()
+    with app.app_context():
+        reset_stale_processing(
+            db.session, older_than_seconds=args.reset_stale_seconds
+        )
+        processed = process_available_events(
+            db.session,
+            handlers={},
+            batch_size=args.batch_size,
+        )
+        logger.info("Processed %s outbox events", processed)
+
+
+if __name__ == "__main__":
+    main()

+ 129 - 0
app/commands/reconcile_datasource_credentials.py

@@ -0,0 +1,129 @@
+"""Report and narrowly repair Neo4j/PostgreSQL credential references."""
+
+import argparse
+import json
+
+from sqlalchemy import text
+
+
+def inspect_reconciliation(graph_session, platform_session):
+    graph_records = graph_session.run(
+        """
+        MATCH (n:DataSource)
+        RETURN n.uid AS uid, n.credential_ref AS credential_ref,
+               n.credential_version AS credential_version,
+               n.username IS NOT NULL OR n.password IS NOT NULL
+                   AS plaintext_remaining
+        """
+    )
+    graph = {
+        str(record["uid"]): dict(record)
+        for record in graph_records
+        if record.get("uid")
+    }
+    rows = platform_session.execute(
+        text(
+            """
+            SELECT data_source_uid::text AS uid, credential_version, status
+            FROM public.datasource_credentials
+            ORDER BY data_source_uid, credential_version
+            """
+        )
+    ).mappings()
+    credentials = {}
+    for row in rows:
+        credentials.setdefault(str(row["uid"]), []).append(dict(row))
+
+    report = []
+    for uid in sorted(set(graph) | set(credentials)):
+        issues = []
+        node = graph.get(uid)
+        versions = credentials.get(uid, [])
+        active = [
+            row for row in versions if row["status"] == "active"
+        ]
+        if node is None:
+            issues.append("orphaned_credential")
+        else:
+            if node.get("plaintext_remaining"):
+                issues.append("plaintext_remaining")
+            referenced = node.get("credential_version")
+            matching = [
+                row
+                for row in versions
+                if row["credential_version"] == referenced
+            ]
+            if not matching:
+                issues.append("missing_credential")
+            elif matching[0]["status"] == "revoked":
+                issues.append("revoked_credential_referenced")
+            if active and referenced != active[-1]["credential_version"]:
+                issues.append("stale_credential_version")
+        if issues:
+            report.append({"data_source_uid": uid, "issues": issues})
+    return report
+
+
+def repair_one(data_source_uid, graph_session, platform_session):
+    row = (
+        platform_session.execute(
+            text(
+                """
+                SELECT credential_version
+                FROM public.datasource_credentials
+                WHERE data_source_uid = CAST(:uid AS uuid)
+                  AND status = 'active'
+                ORDER BY credential_version DESC
+                LIMIT 1
+                """
+            ),
+            {"uid": str(data_source_uid)},
+        )
+        .mappings()
+        .one_or_none()
+    )
+    if row is None:
+        raise ValueError("no active credential is available for repair")
+    graph_session.run(
+        """
+        MATCH (n:DataSource {uid: $uid})
+        SET n.credential_ref = $uid,
+            n.credential_version = $credential_version
+        """,
+        {
+            "uid": str(data_source_uid),
+            "credential_version": int(row["credential_version"]),
+        },
+    )
+    return {
+        "data_source_uid": str(data_source_uid),
+        "status": "reference_repaired",
+    }
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="Reconcile DataSource credential references"
+    )
+    parser.add_argument("--repair", action="store_true")
+    parser.add_argument("--uid")
+    args = parser.parse_args()
+    if args.repair and not args.uid:
+        parser.error("--repair requires --uid")
+
+    from app import create_app, db
+    from app.services.neo4j_driver import neo4j_driver
+
+    app = create_app()
+    with app.app_context():
+        with neo4j_driver.get_session() as graph_session:
+            report = inspect_reconciliation(graph_session, db.session)
+            for item in report:
+                print(json.dumps(item, ensure_ascii=False))
+            if args.repair:
+                result = repair_one(args.uid, graph_session, db.session)
+                print(json.dumps(result, ensure_ascii=False))
+
+
+if __name__ == "__main__":
+    main()

+ 78 - 0
app/commands/reconcile_governance_uids.py

@@ -0,0 +1,78 @@
+from __future__ import annotations
+
+import argparse
+import json
+from typing import Any
+
+from app.core.common.identifiers import new_governance_uid
+
+
+GOVERNANCE_LABELS = ("BusinessDomain", "DataMeta", "DataFlow", "DataSource")
+
+
+def ensure_uid_constraints(session: Any) -> None:
+    constraints = {
+        "BusinessDomain": "business_domain_uid",
+        "DataMeta": "data_meta_uid",
+        "DataFlow": "data_flow_uid",
+        "DataSource": "datasource_uid",
+    }
+    for label, constraint in constraints.items():
+        session.run(
+            f"CREATE CONSTRAINT {constraint} IF NOT EXISTS "
+            f"FOR (n:{label}) REQUIRE n.uid IS UNIQUE"
+        )
+
+
+def report_missing_uids(session: Any, limit: int = 100) -> dict[str, list[dict[str, Any]]]:
+    report: dict[str, list[dict[str, Any]]] = {}
+    for label in GOVERNANCE_LABELS:
+        result = session.run(
+            f"MATCH (n:{label}) WHERE n.uid IS NULL "
+            "RETURN elementId(n) AS legacy_id, n.name_zh AS name_zh "
+            "ORDER BY legacy_id LIMIT $limit",
+            {"limit": int(limit)},
+        )
+        report[label] = [dict(record) for record in result]
+    return report
+
+
+def backfill_missing_uids(session: Any, limit: int = 100) -> dict[str, int]:
+    report = report_missing_uids(session, limit=limit)
+    counts: dict[str, int] = {}
+    for label, records in report.items():
+        counts[label] = 0
+        for record in records:
+            session.run(
+                f"MATCH (n:{label}) WHERE elementId(n) = $legacy_id AND n.uid IS NULL "
+                "SET n.uid = $uid",
+                {
+                    "legacy_id": str(record["legacy_id"]),
+                    "uid": new_governance_uid(),
+                },
+            )
+            counts[label] += 1
+    return counts
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Audit stable governance UIDs")
+    parser.add_argument("--limit", type=int, default=100)
+    parser.add_argument("--backfill", action="store_true")
+    parser.add_argument("--ensure-constraints", action="store_true")
+    args = parser.parse_args()
+
+    from app.services.neo4j_driver import neo4j_driver
+
+    with neo4j_driver.get_session() as session:
+        report = report_missing_uids(session, limit=args.limit)
+        print(json.dumps(report, ensure_ascii=False, indent=2))
+        if args.backfill:
+            counts = backfill_missing_uids(session, limit=args.limit)
+            print(json.dumps({"backfilled": counts}, ensure_ascii=False))
+        if args.ensure_constraints:
+            ensure_uid_constraints(session)
+
+
+if __name__ == "__main__":
+    main()

+ 35 - 0
app/commands/reconcile_workflow_versions.py

@@ -0,0 +1,35 @@
+from __future__ import annotations
+
+from sqlalchemy import text
+
+from app import create_app, db
+from app.core.data_factory.n8n_client import N8nClient, N8nClientError
+
+
+def main() -> None:
+    app = create_app()
+    with app.app_context():
+        rows = db.session.execute(
+            text(
+                "SELECT dataflow_uid::text, environment, version_no, n8n_workflow_id "
+                "FROM public.dataflow_workflow_versions WHERE status = 'active' "
+                "ORDER BY dataflow_uid, environment"
+            )
+        )
+        client = N8nClient()
+        mismatch_count = 0
+        for uid, environment, version_no, workflow_id in rows:
+            try:
+                workflow = client.get_workflow(workflow_id)
+                remote_active = bool(workflow.get("active"))
+                state = "match" if remote_active else "mismatch_remote_inactive"
+            except N8nClientError as exc:
+                state = f"unavailable:{exc.message}"
+            if state != "match":
+                mismatch_count += 1
+            print(f"{uid}\t{environment}\tv{version_no}\t{workflow_id}\t{state}")
+        print(f"mismatches={mismatch_count}; repair_performed=false")
+
+
+if __name__ == "__main__":
+    main()

+ 76 - 0
app/commands/report_datasource_credentials.py

@@ -0,0 +1,76 @@
+"""Read-only audit of legacy Neo4j DataSource credential state."""
+
+import argparse
+import json
+
+
+SUPPORTED_TYPES = {"postgresql", "postgres", "mysql"}
+
+
+def inspect_legacy_datasources(session):
+    records = session.run(
+        """
+        MATCH (n:DataSource)
+        RETURN elementId(n) AS legacy_id, properties(n) AS properties
+        ORDER BY legacy_id
+        """
+    )
+    report = []
+    for record in records:
+        properties = dict(record["properties"])
+        issues = []
+        uid = properties.get("uid")
+        has_plaintext = any(
+            properties.get(key)
+            for key in (
+                "username",
+                "password",
+                "conn_str",
+                "connection_string",
+                "connection_url",
+            )
+        )
+        if not uid:
+            issues.append("missing_uid")
+        if has_plaintext:
+            issues.append("plaintext_credentials")
+        if (
+            not has_plaintext
+            and (
+                not properties.get("credential_ref")
+                or not properties.get("credential_version")
+            )
+        ):
+            issues.append("missing_credential_reference")
+        if str(properties.get("type") or "").lower() not in SUPPORTED_TYPES:
+            issues.append("unsupported_database_type")
+        if any(
+            not properties.get(key)
+            for key in ("host", "port", "database")
+        ):
+            issues.append("incomplete_connection_definition")
+        if issues:
+            report.append(
+                {
+                    "data_source_uid": str(uid) if uid else None,
+                    "issues": issues,
+                }
+            )
+    return report
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="Report legacy DataSource credential issues"
+    )
+    parser.parse_args()
+
+    from app.services.neo4j_driver import neo4j_driver
+
+    with neo4j_driver.get_session() as session:
+        for item in inspect_legacy_datasources(session):
+            print(json.dumps(item, ensure_ascii=False))
+
+
+if __name__ == "__main__":
+    main()

+ 26 - 0
app/commands/report_legacy_accounts.py

@@ -0,0 +1,26 @@
+from __future__ import annotations
+
+from sqlalchemy import text
+
+from app import create_app, db
+
+
+def main() -> None:
+    app = create_app()
+    with app.app_context():
+        exists = db.session.execute(
+            text("SELECT to_regclass('public.users_legacy') IS NOT NULL")
+        ).scalar_one()
+        if not exists:
+            print("No legacy account table found.")
+            return
+        rows = db.session.execute(
+            text("SELECT username, is_admin FROM public.users_legacy ORDER BY username")
+        )
+        for username, is_admin in rows:
+            role = "admin" if is_admin else "viewer"
+            print(f"{username}\tproposed_role={role}\tpassword_reset_required=true")
+
+
+if __name__ == "__main__":
+    main()

+ 383 - 53
app/config/config.py

@@ -1,5 +1,70 @@
 import os
 import platform
+from typing import Mapping
+
+
+def get_bool_env(name: str, default: bool = False) -> bool:
+    """Return a boolean value from an environment variable."""
+    value = os.environ.get(name)
+    if value is None:
+        return default
+    return value.strip().lower() in {"1", "true", "yes", "on"}
+
+
+def _bounded_int(name: str, default: int, minimum: int, maximum: int) -> int:
+    try:
+        value = int(os.environ.get(name, str(default)))
+    except (TypeError, ValueError) as exc:
+        raise ValueError(f"{name} must be an integer") from exc
+    if value < minimum or value > maximum:
+        raise ValueError(f"{name} must be between {minimum} and {maximum}")
+    return value
+
+
+def _apply_datasource_pool_overrides(
+    settings: dict, overrides: Mapping[str, int]
+) -> dict:
+    bounds = {
+        "pool_size": (1, 10),
+        "max_overflow": (0, 10),
+    }
+    unsupported = sorted(set(overrides) - set(bounds))
+    if unsupported:
+        raise ValueError(
+            f"Unsupported data source pool overrides: {', '.join(unsupported)}"
+        )
+
+    resolved = dict(settings)
+    for name, value in overrides.items():
+        minimum, maximum = bounds[name]
+        if type(value) is not int or value < minimum or value > maximum:
+            raise ValueError(f"{name} must be between {minimum} and {maximum}")
+        resolved[name] = value
+    return resolved
+
+
+def datasource_pool_settings(
+    overrides: Mapping[str, int] = None,
+) -> dict:
+    """Return bounded settings for external business data-source pools."""
+    settings = {
+        "pool_size": _bounded_int("DATASOURCE_POOL_SIZE", 2, 1, 10),
+        "max_overflow": _bounded_int("DATASOURCE_MAX_OVERFLOW", 3, 0, 10),
+        "pool_timeout": _bounded_int("DATASOURCE_POOL_TIMEOUT", 10, 1, 60),
+        "pool_recycle": _bounded_int(
+            "DATASOURCE_POOL_RECYCLE", 1800, 60, 86400
+        ),
+        "idle_ttl": _bounded_int(
+            "DATASOURCE_POOL_IDLE_TTL", 900, 60, 86400
+        ),
+        "max_idle_pools": _bounded_int(
+            "DATASOURCE_MAX_IDLE_POOLS", 20, 1, 100
+        ),
+        "query_timeout": _bounded_int(
+            "DATASOURCE_QUERY_TIMEOUT", 30, 1, 300
+        ),
+    }
+    return _apply_datasource_pool_overrides(settings, overrides or {})
 
 
 def get_environment():
@@ -24,6 +89,277 @@ def get_environment():
         return "development"  # 其他系统默认使用开发环境
 
 
+def resolve_log_file(default_filename: str = "flask_production.log") -> str:
+    """Resolve application log path to an absolute file under LOG_DIR."""
+    raw = os.environ.get("LOG_FILE", default_filename)
+    if os.path.isabs(raw):
+        return raw
+
+    app_dir = os.environ.get("APP_DIR", os.getcwd())
+    log_dir = os.environ.get("LOG_DIR", os.path.join(app_dir, "logs"))
+    return os.path.join(log_dir, os.path.basename(raw))
+
+
+def _clean_env(name: str, default: str = "") -> str:
+    value = os.environ.get(name)
+    if value is None:
+        return default
+    return value.strip().strip("\r\n\t")
+
+
+def load_production_env_file() -> None:
+    """Load production env file into os.environ without overriding existing keys."""
+    env_file = os.environ.get(
+        "APP_ENV_FILE",
+        os.environ.get("ENV_FILE", "/etc/dataops-platform/dataops.env"),
+    )
+    if not env_file or not os.path.isfile(env_file):
+        return
+    if not os.access(env_file, os.R_OK):
+        return
+    try:
+        from dotenv import load_dotenv
+
+        load_dotenv(env_file, override=False)
+    except Exception:
+        return
+
+
+def is_placeholder_env_value(value: str) -> bool:
+    """Detect template placeholders that should not override production defaults."""
+    if not value:
+        return True
+    lower = value.lower()
+    if lower.startswith("replace-"):
+        return True
+    placeholders = (
+        "replace-password",
+        "replace-neo4j-password",
+        "replace-minio",
+        "replace-with-a-long-random-secret",
+        "replace-n8n-api-key",
+        "your-api-key",
+    )
+    if any(item in lower for item in placeholders):
+        return True
+    if "dataops_user@" in lower and "127.0.0.1" in lower:
+        return True
+    if lower in {"127.0.0.1:9000", "localhost:9000"}:
+        return True
+    return False
+
+
+def _is_local_minio_host(host: str) -> bool:
+    if not host:
+        return False
+    return host.split(":")[0].lower() in {"127.0.0.1", "localhost"}
+
+
+def _apply_minio_config(app, defaults: dict) -> None:
+    """Apply MinIO settings as a whole to avoid host/user/password mismatch."""
+    host = _clean_env("MINIO_HOST")
+    user = _clean_env("MINIO_USER")
+    password = _clean_env("MINIO_PASSWORD")
+    bucket = _clean_env("MINIO_BUCKET")
+    prefix = _clean_env("MINIO_PREFIX")
+    is_production = os.environ.get("FLASK_ENV", "").lower() == "production"
+
+    use_defaults = (
+        is_placeholder_env_value(user)
+        or is_placeholder_env_value(password)
+        or is_placeholder_env_value(host)
+        or (is_production and _is_local_minio_host(host))
+    )
+
+    if use_defaults:
+        app.config["MINIO_HOST"] = defaults["MINIO_HOST"]
+        app.config["MINIO_USER"] = defaults["MINIO_USER"]
+        app.config["MINIO_PASSWORD"] = defaults["MINIO_PASSWORD"]
+        app.config["MINIO_BUCKET"] = defaults["MINIO_BUCKET"]
+        app.config["PREFIX"] = defaults["MINIO_PREFIX"]
+        app.config["MINIO_SECURE"] = defaults["MINIO_SECURE"]
+        return
+
+    app.config["MINIO_HOST"] = host or defaults["MINIO_HOST"]
+    app.config["MINIO_USER"] = user or defaults["MINIO_USER"]
+    app.config["MINIO_PASSWORD"] = password or defaults["MINIO_PASSWORD"]
+    app.config["MINIO_BUCKET"] = bucket or defaults["MINIO_BUCKET"]
+    app.config["PREFIX"] = prefix if prefix else defaults["MINIO_PREFIX"]
+    app.config["MINIO_SECURE"] = get_bool_env("MINIO_SECURE", defaults["MINIO_SECURE"])
+
+
+PRODUCTION_SERVICE_DEFAULTS = {
+    "SQLALCHEMY_DATABASE_URI": "postgresql://postgres:postgres@127.0.0.1:5432/dataops",
+    "NEO4J_URI": "bolt://127.0.0.1:7687",
+    "NEO4J_HTTP_URI": "http://127.0.0.1:7474",
+    "NEO4J_USER": "neo4j",
+    "NEO4J_PASSWORD": "Passw0rd",
+    "NEO4J_ENCRYPTED": False,
+    "MINIO_HOST": "127.0.0.1:9000",
+    "MINIO_USER": "citu-test",
+    "MINIO_PASSWORD": "citu-test",
+    "MINIO_SECURE": False,
+    "MINIO_BUCKET": "dataops-bucket",
+    "MINIO_PREFIX": "",
+}
+
+
+def _apply_config_from_env(app, config_key: str, env_name: str, default):
+    value = _clean_env(env_name)
+    if value and not is_placeholder_env_value(value):
+        app.config[config_key] = value
+        return value
+    app.config[config_key] = default
+    return default
+
+
+def apply_runtime_env_config(app) -> None:
+    """Re-read env-backed settings when the worker starts (after dataops.env is loaded)."""
+    load_production_env_file()
+    defaults = PRODUCTION_SERVICE_DEFAULTS
+
+    _apply_config_from_env(
+        app, "SQLALCHEMY_DATABASE_URI", "DATABASE_URL", defaults["SQLALCHEMY_DATABASE_URI"]
+    )
+    _apply_config_from_env(app, "NEO4J_URI", "NEO4J_URI", defaults["NEO4J_URI"])
+    _apply_config_from_env(
+        app, "NEO4J_HTTP_URI", "NEO4J_HTTP_URI", defaults["NEO4J_HTTP_URI"]
+    )
+    _apply_config_from_env(app, "NEO4J_USER", "NEO4J_USER", defaults["NEO4J_USER"])
+    _apply_config_from_env(
+        app, "NEO4J_PASSWORD", "NEO4J_PASSWORD", defaults["NEO4J_PASSWORD"]
+    )
+    app.config["NEO4J_ENCRYPTED"] = get_bool_env(
+        "NEO4J_ENCRYPTED", defaults["NEO4J_ENCRYPTED"]
+    )
+    _apply_minio_config(app, defaults)
+
+    secret_key = _clean_env("SECRET_KEY")
+    if secret_key and not is_placeholder_env_value(secret_key):
+        app.config["SECRET_KEY"] = secret_key
+
+    deepseek_key = _clean_env("DEEPSEEK_API_KEY")
+    llm_key = deepseek_key or _clean_env("LLM_API_KEY")
+    if deepseek_key and not is_placeholder_env_value(deepseek_key):
+        app.config["DEEPSEEK_API_KEY"] = deepseek_key
+        app.config["LLM_API_KEY"] = deepseek_key
+    elif llm_key and not is_placeholder_env_value(llm_key):
+        app.config["LLM_API_KEY"] = llm_key
+
+    _apply_config_from_env(
+        app,
+        "LLM_BASE_URL",
+        "LLM_BASE_URL",
+        "https://api.deepseek.com",
+    )
+
+    llm_model = _clean_env("LLM_MODEL_NAME")
+    if llm_model:
+        app.config["LLM_MODEL_NAME"] = llm_model
+
+    llm_reasoning = _clean_env("LLM_REASONING_EFFORT")
+    if llm_reasoning:
+        app.config["LLM_REASONING_EFFORT"] = llm_reasoning
+
+    pool_settings = datasource_pool_settings()
+    app.config.update(
+        {
+            "DATASOURCE_POOL_SIZE": pool_settings["pool_size"],
+            "DATASOURCE_MAX_OVERFLOW": pool_settings["max_overflow"],
+            "DATASOURCE_POOL_TIMEOUT": pool_settings["pool_timeout"],
+            "DATASOURCE_POOL_RECYCLE": pool_settings["pool_recycle"],
+            "DATASOURCE_POOL_IDLE_TTL": pool_settings["idle_ttl"],
+            "DATASOURCE_MAX_IDLE_POOLS": pool_settings["max_idle_pools"],
+            "DATASOURCE_QUERY_TIMEOUT": pool_settings["query_timeout"],
+            "DATASOURCE_CREDENTIAL_MASTER_KEY": _clean_env(
+                "DATASOURCE_CREDENTIAL_MASTER_KEY"
+            ),
+            "DATASOURCE_CREDENTIAL_KEY_VERSION": _clean_env(
+                "DATASOURCE_CREDENTIAL_KEY_VERSION", "v1"
+            ),
+            "DATASOURCE_CERT_DIR": _clean_env(
+                "DATASOURCE_CERT_DIR",
+                "/etc/dataops-platform/datasource-certs",
+            ),
+        }
+    )
+
+
+def log_llm_env_status(app) -> None:
+    """Log LLM env load result after logging is configured."""
+    deepseek_key = _clean_env("DEEPSEEK_API_KEY") or _clean_env("LLM_API_KEY")
+    if not deepseek_key:
+        deepseek_key = str(
+            app.config.get("DEEPSEEK_API_KEY") or app.config.get("LLM_API_KEY") or ""
+        ).strip().strip("\r\n\t")
+    env_file = os.environ.get("APP_ENV_FILE", "/etc/dataops-platform/dataops.env")
+
+    if deepseek_key:
+        app.logger.info(
+            "DeepSeek API Key 已加载 (长度=%s, base_url=%s, model=%s)",
+            len(deepseek_key),
+            app.config.get("LLM_BASE_URL", "https://api.deepseek.com"),
+            app.config.get("LLM_MODEL_NAME", "deepseek-chat"),
+        )
+        return
+
+    if os.path.isfile(env_file) and not os.access(env_file, os.R_OK):
+        app.logger.error(
+            f"无法读取 {env_file},Supervisor 用户需有读权限。"
+            f"请执行: sudo chown root:{os.environ.get('APP_USER', 'ubuntu')} "
+            f"{env_file} && sudo chmod 640 {env_file}"
+        )
+        return
+
+    app.logger.warning(
+        f"DeepSeek API Key 未配置,请在 {env_file} 中设置 DEEPSEEK_API_KEY"
+    )
+
+
+def log_service_env_status(app) -> None:
+    """Log database/Neo4j endpoints after logging is configured."""
+    db_uri = str(app.config.get("SQLALCHEMY_DATABASE_URI", ""))
+    db_host = db_uri.split("@")[-1] if "@" in db_uri else db_uri
+    app.logger.info(
+        "服务连接配置: PostgreSQL=%s, Neo4j=%s, MinIO=%s (user=%s)",
+        db_host,
+        app.config.get("NEO4J_URI"),
+        app.config.get("MINIO_HOST"),
+        _mask_secret(str(app.config.get("MINIO_USER", ""))),
+    )
+    if is_placeholder_env_value(_clean_env("DATABASE_URL")):
+        app.logger.warning(
+            "DATABASE_URL 仍为模板占位符,已回退到默认生产库 "
+            f"{PRODUCTION_SERVICE_DEFAULTS['SQLALCHEMY_DATABASE_URI'].split('@')[-1]}"
+        )
+    if is_placeholder_env_value(_clean_env("NEO4J_PASSWORD")):
+        app.logger.warning(
+            "NEO4J_PASSWORD 仍为模板占位符,已回退到 config.py 中的默认生产配置"
+        )
+    minio_user_env = _clean_env("MINIO_USER")
+    minio_host_env = _clean_env("MINIO_HOST")
+    if (
+        is_placeholder_env_value(minio_user_env)
+        or is_placeholder_env_value(minio_host_env)
+        or (
+            os.environ.get("FLASK_ENV", "").lower() == "production"
+            and _is_local_minio_host(minio_host_env)
+        )
+    ):
+        app.logger.warning(
+            "MinIO 配置仍为模板或 localhost,已回退到默认生产 MinIO "
+            f"{PRODUCTION_SERVICE_DEFAULTS['MINIO_HOST']}"
+        )
+
+
+def _mask_secret(value: str) -> str:
+    if not value:
+        return "(empty)"
+    if len(value) <= 4:
+        return "****"
+    return f"{value[:4]}****"
+
+
 class BaseConfig:
     """基础配置类,包含所有环境共享的配置"""
 
@@ -63,22 +399,32 @@ class BaseConfig:
         "max_overflow": 20,
     }
 
-    # LLM基础配置
-    LLM_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
-    LLM_MODEL_NAME = "qwen-turbo"
-    # LLM_API_KEY = os.environ.get('LLM_API_KEY', "sk-86d4622141d74e9a8d7c38ee873c4d91")
-    LLM_API_KEY = os.environ.get("LLM_API_KEY", "sk-db68e37f00974031935395315bfe07f0")
-
-    # Qwen VL模型配置 - 用于图片解析
-    QWEN_API_KEY = os.environ.get("QWEN_API_KEY", "sk-db68e37f00974031935395315bfe07f0")
-    QWEN_API_URL = "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
-
-    # Qwen API配置 - 用于文本生成(替代Deepseek)
-    QWEN_TEXT_API_KEY = os.environ.get(
-        "QWEN_TEXT_API_KEY", "sk-db68e37f00974031935395315bfe07f0"
+    _DATASOURCE_POOL_SETTINGS = datasource_pool_settings()
+    DATASOURCE_POOL_SIZE = _DATASOURCE_POOL_SETTINGS["pool_size"]
+    DATASOURCE_MAX_OVERFLOW = _DATASOURCE_POOL_SETTINGS["max_overflow"]
+    DATASOURCE_POOL_TIMEOUT = _DATASOURCE_POOL_SETTINGS["pool_timeout"]
+    DATASOURCE_POOL_RECYCLE = _DATASOURCE_POOL_SETTINGS["pool_recycle"]
+    DATASOURCE_POOL_IDLE_TTL = _DATASOURCE_POOL_SETTINGS["idle_ttl"]
+    DATASOURCE_MAX_IDLE_POOLS = _DATASOURCE_POOL_SETTINGS["max_idle_pools"]
+    DATASOURCE_QUERY_TIMEOUT = _DATASOURCE_POOL_SETTINGS["query_timeout"]
+    DATASOURCE_CREDENTIAL_MASTER_KEY = os.environ.get(
+        "DATASOURCE_CREDENTIAL_MASTER_KEY", ""
+    )
+    DATASOURCE_CREDENTIAL_KEY_VERSION = os.environ.get(
+        "DATASOURCE_CREDENTIAL_KEY_VERSION", "v1"
+    )
+    DATASOURCE_CERT_DIR = os.environ.get(
+        "DATASOURCE_CERT_DIR",
+        "/etc/dataops-platform/datasource-certs",
     )
-    QWEN_TEXT_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
-    QWEN_TEXT_MODEL = "qwen-turbo"
+
+    # DeepSeek LLM(OpenAI 兼容接口)
+    DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "")
+    LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "https://api.deepseek.com")
+    LLM_MODEL_NAME = os.environ.get("LLM_MODEL_NAME", "deepseek-chat")
+    LLM_REASONING_EFFORT = os.environ.get("LLM_REASONING_EFFORT", "high")
+    # 兼容旧环境变量名 LLM_API_KEY
+    LLM_API_KEY = DEEPSEEK_API_KEY or os.environ.get("LLM_API_KEY", "")
 
     # 日志基础配置
     LOG_FORMAT = "%(asctime)s - %(levelname)s - %(filename)s - %(funcName)s - %(lineno)s - %(message)s"
@@ -89,17 +435,12 @@ class BaseConfig:
     DATAFLOW_SCHEMA = os.environ.get("DATAFLOW_SCHEMA", "dags")
 
     # n8n 工作流引擎配置
-    N8N_API_URL = os.environ.get("N8N_API_URL", "https://n8n.citupro.com")
-    N8N_API_KEY = os.environ.get(
-        "N8N_API_KEY",
-        "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI4MTcyNzlmMC1jNTQwLTQyMTEtYjczYy1mNjU4OTI5NTZhMmUiLCJpc3MiOiJuOG4iLCJhdWQiOiJwdWJsaWMtYXBpIiwiaWF0IjoxNzY2NTcyMDg0fQ.QgiUa5tEM1IGZSxhqFaWtdKvwk1SvoRmqdRovTT254M",
-    )
+    N8N_API_URL = os.environ.get("N8N_API_URL", "")
+    N8N_API_KEY = os.environ.get("N8N_API_KEY", "")
     N8N_API_TIMEOUT = int(os.environ.get("N8N_API_TIMEOUT", "30"))
 
     # DataOps 平台 API 基础 URL(用于 n8n 工作流回调等)
-    API_BASE_URL = os.environ.get(
-        "API_BASE_URL", "https://company.citupro.com:18183/api"
-    )
+    API_BASE_URL = os.environ.get("API_BASE_URL", "http://127.0.0.1:5500/api")
 
 
 class DevelopmentConfig(BaseConfig):
@@ -133,56 +474,45 @@ class DevelopmentConfig(BaseConfig):
 
     # 开发环境日志配置
     LOG_LEVEL = "DEBUG"
-    LOG_FILE = "flask_development.log"
+    LOG_FILE = resolve_log_file("flask_development.log")
     LOG_TO_CONSOLE = True
 
-    # 开发环境 Airflow 配置
-    AIRFLOW_BASE_URL = "http://localhost:8080"
-    AIRFLOW_AUTH_USER = "admin"
-    AIRFLOW_AUTH_PASSWORD = "admin"
-
-
 class ProductionConfig(BaseConfig):
     """Linux 生产环境配置"""
 
     FLASK_ENV = "production"
     DEBUG = False
-    PORT = 80
+    # 与 run_dataops.sh / dataops.env 中 LISTEN_PORT 保持一致(默认 5500,供 Nginx 反代)
+    PORT = int(os.environ.get("LISTEN_PORT", os.environ.get("PORT", "5500")))
 
     # 生产环境 MinIO 配置
-    MINIO_HOST = "192.168.3.143:9000"
-    MINIO_USER = "citu-dataops-acc-key"
-    MINIO_PASSWORD = "citu-dataops-secret-key"
-    MINIO_SECURE = False
-    MINIO_BUCKET = "dataops-bucket"
-    PREFIX = ""
+    MINIO_HOST = os.environ.get("MINIO_HOST", "127.0.0.1:9000")
+    MINIO_USER = os.environ.get("MINIO_USER", "citu-test")
+    MINIO_PASSWORD = os.environ.get("MINIO_PASSWORD", "citu-test")
+    MINIO_SECURE = get_bool_env("MINIO_SECURE", False)
+    MINIO_BUCKET = os.environ.get("MINIO_BUCKET", "dataops-bucket")
+    PREFIX = os.environ.get("MINIO_PREFIX", "")
 
     # 生产环境 PostgreSQL 配置
     SQLALCHEMY_DATABASE_URI = os.environ.get(
-        "DATABASE_URL", "postgresql://postgres:dataOps@192.168.3.143:5432/dataops"
+        "DATABASE_URL", "postgresql://postgres:postgres@127.0.0.1:5432/dataops"
     )
 
     # 生产环境 Neo4j 配置
-    NEO4J_URI = os.environ.get("NEO4J_URI", "bolt://192.168.3.143:7687")
-    NEO4J_HTTP_URI = os.environ.get("NEO4J_HTTP_URI", "http://192.168.3.143:7474")
+    NEO4J_URI = os.environ.get("NEO4J_URI", "bolt://127.0.0.1:7687")
+    NEO4J_HTTP_URI = os.environ.get("NEO4J_HTTP_URI", "http://127.0.0.1:7474")
     NEO4J_USER = os.environ.get("NEO4J_USER", "neo4j")
-    NEO4J_PASSWORD = os.environ.get("NEO4J_PASSWORD", "cituneo4j")
-    NEO4J_ENCRYPTED = False
+    NEO4J_PASSWORD = os.environ.get("NEO4J_PASSWORD", "Passw0rd")
+    NEO4J_ENCRYPTED = get_bool_env("NEO4J_ENCRYPTED", False)
 
     # 生产环境文件路径配置
-    UPLOAD_BASE_PATH = "/data/upload"
-    ARCHIVE_BASE_PATH = "/data/archive"
+    UPLOAD_BASE_PATH = os.environ.get("UPLOAD_BASE_PATH", "/data/upload")
+    ARCHIVE_BASE_PATH = os.environ.get("ARCHIVE_BASE_PATH", "/data/archive")
 
     # 生产环境日志配置
-    LOG_LEVEL = "INFO"
-    LOG_FILE = "flask_production.log"
-    LOG_TO_CONSOLE = False
-
-    # 生产环境 Airflow 配置
-    AIRFLOW_BASE_URL = os.environ.get("AIRFLOW_BASE_URL", "http://192.168.3.143:8080")
-    AIRFLOW_AUTH_USER = os.environ.get("AIRFLOW_AUTH_USER", "admin")
-    AIRFLOW_AUTH_PASSWORD = os.environ.get("AIRFLOW_AUTH_PASSWORD", "admin")
-
+    LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO")
+    LOG_FILE = resolve_log_file("flask_production.log")
+    LOG_TO_CONSOLE = get_bool_env("LOG_TO_CONSOLE", False)
 
 # 配置字典
 config = {

+ 24 - 13
app/core/business_domain/business_domain.py

@@ -6,6 +6,8 @@ Business Domain 核心业务逻辑模块
 import logging
 from typing import Any, Dict, List, Optional, Tuple
 
+from app.core.common.identifiers import ensure_governance_uid
+
 from app import db
 from app.core.common.timezone_utils import now_china_naive
 from app.models.metadata_review import MetadataReviewRecord
@@ -217,6 +219,7 @@ def _create_new_meta_and_link(
 
     meta_create = """
     CREATE (m:DataMeta {
+        uid: $uid,
         name_zh: $name_zh,
         name_en: $name_en,
         create_time: $create_time,
@@ -225,14 +228,16 @@ def _create_new_meta_and_link(
     })
     RETURN m
     """
+    meta_properties = {
+        "name_zh": _norm_str(new_meta.get("name_zh")),
+        "name_en": _norm_str(new_meta.get("name_en")),
+        "create_time": get_formatted_time(),
+        "data_type": _norm_data_type(new_meta.get("data_type") or "varchar(255)"),
+    }
+    ensure_governance_uid(meta_properties)
     meta_result = session.run(
         meta_create,
-        {
-            "name_zh": _norm_str(new_meta.get("name_zh")),
-            "name_en": _norm_str(new_meta.get("name_en")),
-            "create_time": get_formatted_time(),
-            "data_type": _norm_data_type(new_meta.get("data_type") or "varchar(255)"),
-        },
+        meta_properties,
     ).single()
     if not meta_result or not meta_result.get("m"):
         raise ValueError("创建 DataMeta 失败")
@@ -334,20 +339,24 @@ def _create_meta_if_absent_and_link(
 
     meta_merge = """
     MERGE (m:DataMeta {name_zh: $name_zh})
-    ON CREATE SET m.name_en = $name_en,
+    ON CREATE SET m.uid = $uid,
+                m.name_en = $name_en,
                 m.create_time = $create_time,
                 m.data_type = $data_type,
                 m.status = true
+    ON MATCH SET m.uid = coalesce(m.uid, $uid)
     RETURN m
     """
+    meta_properties = {
+        "name_zh": _norm_str(new_meta.get("name_zh")),
+        "name_en": _norm_str(new_meta.get("name_en")),
+        "create_time": get_formatted_time(),
+        "data_type": _norm_data_type(new_meta.get("data_type") or "varchar(255)"),
+    }
+    ensure_governance_uid(meta_properties)
     meta_result = session.run(
         meta_merge,
-        {
-            "name_zh": _norm_str(new_meta.get("name_zh")),
-            "name_en": _norm_str(new_meta.get("name_en")),
-            "create_time": get_formatted_time(),
-            "data_type": _norm_data_type(new_meta.get("data_type") or "varchar(255)"),
-        },
+        meta_properties,
     ).single()
     if not meta_result or not meta_result.get("m"):
         raise ValueError("创建/获取 DataMeta 失败")
@@ -867,6 +876,7 @@ def save_business_domain(data):
                 "create_time": get_formatted_time(),
                 "update_time": get_formatted_time(),
             }
+            ensure_governance_uid(node_props)
 
             # 添加可选字段(不包含 parsed_data,它通过关系处理)
             optional_fields = [
@@ -1764,6 +1774,7 @@ def business_domain_compose(data):
                 "create_time": get_formatted_time(),
                 "update_time": get_formatted_time(),
             }
+            ensure_governance_uid(node_props)
 
             # 添加可选字段
             optional_fields = [

+ 4 - 9
app/core/common/functions.py

@@ -103,14 +103,9 @@ def translate_and_parse(content):
         content: 需要翻译的内容
 
     Returns:
-        str: 包含翻译结果
+        str: 英文数据库标识符;失败时返回空字符串
     """
-    # 调用LLM服务进行翻译
     translated_text = llm_call(content)
-
-    # 如果翻译失败,返回原文
-    if translated_text is None:
-        return content
-
-    # 确保返回格式为字符串
-    return translated_text
+    if not translated_text:
+        return ""
+    return str(translated_text).strip()

+ 38 - 0
app/core/common/identifiers.py

@@ -0,0 +1,38 @@
+from __future__ import annotations
+
+import secrets
+import time
+import uuid
+from typing import Any, MutableMapping
+
+
+def new_governance_uid() -> str:
+    """Return an RFC 9562 UUIDv7-compatible identifier."""
+    timestamp_ms = int(time.time_ns() // 1_000_000) & ((1 << 48) - 1)
+    random_a = secrets.randbits(12)
+    random_b = secrets.randbits(62)
+    value = (
+        (timestamp_ms << 80)
+        | (0x7 << 76)
+        | (random_a << 64)
+        | (0b10 << 62)
+        | random_b
+    )
+    return str(uuid.UUID(int=value))
+
+
+def ensure_governance_uid(properties: MutableMapping[str, Any]) -> str:
+    """Attach a UUIDv7 to mutable node properties and preserve it on retries."""
+    existing = properties.get("uid")
+    if existing:
+        try:
+            parsed = uuid.UUID(str(existing))
+        except (ValueError, AttributeError, TypeError) as exc:
+            raise ValueError("governance uid must be a valid UUIDv7") from exc
+        if parsed.version != 7 or parsed.variant != uuid.RFC_4122:
+            raise ValueError("governance uid must be a valid UUIDv7")
+        value = str(parsed)
+    else:
+        value = new_governance_uid()
+    properties["uid"] = value
+    return value

+ 7 - 14
app/core/common/timezone_utils.py

@@ -1,19 +1,13 @@
 """
 时区工具模块
-提供东八区(Asia/Shanghai)时间处理功能
+提供东八区(Asia/Shanghai,UTC+8,无夏令时)时间处理功能
 """
 
-from datetime import datetime
+from datetime import datetime, timedelta, timezone
 
-try:
-    # Python 3.9+
-    from zoneinfo import ZoneInfo
-except ImportError:
-    # Python 3.8 使用 backports
-    from backports.zoneinfo import ZoneInfo
-
-# 东八区时区
-CHINA_TZ = ZoneInfo("Asia/Shanghai")
+# 东八区:Asia/Shanghai 不使用夏令时,固定 UTC+8 即可
+CHINA_TZ = timezone(timedelta(hours=8))
+UTC = timezone.utc
 
 
 def now_china() -> datetime:
@@ -47,8 +41,7 @@ def to_china_time(dt: datetime) -> datetime:
         datetime: 转换后的东八区时间
     """
     if dt.tzinfo is None:
-        # 如果是naive datetime,假设它是UTC时间
-        dt = dt.replace(tzinfo=ZoneInfo("UTC"))
+        dt = dt.replace(tzinfo=UTC)
     return dt.astimezone(CHINA_TZ)
 
 
@@ -63,5 +56,5 @@ def utc_to_china_naive(dt: datetime) -> datetime:
         datetime: 东八区时间(naive datetime)
     """
     if dt.tzinfo is None:
-        dt = dt.replace(tzinfo=ZoneInfo("UTC"))
+        dt = dt.replace(tzinfo=UTC)
     return dt.astimezone(CHINA_TZ).replace(tzinfo=None)

+ 16 - 12
app/core/data_factory/n8n_client.py

@@ -11,12 +11,7 @@ from flask import current_app
 
 logger = logging.getLogger(__name__)
 
-DEFAULT_N8N_API_URL = "https://n8n.citupro.com"
-DEFAULT_N8N_API_KEY = (
-    "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
-    "eyJzdWIiOiI4MTcyNzlmMC1jNTQwLTQyMTEtYjczYy1mNjU4OTI5NTZhMmUiLCJpc3MiOiJuOG4iLCJhdWQiOiJwdWJsaWMtYXBpIiwiaWF0IjoxNzY2NTcyMDg0fQ."
-    "QgiUa5tEM1IGZSxhqFaWtdKvwk1SvoRmqdRovTT254M"
-)
+DEFAULT_N8N_API_URL = ""
 PLACEHOLDER_N8N_API_URLS = {
     "",
     "https://n8n.example.com",
@@ -69,32 +64,41 @@ class N8nClient:
         """从 Flask 配置获取 n8n 配置"""
         if self.api_url is None:
             configured_url = current_app.config.get("N8N_API_URL", DEFAULT_N8N_API_URL)
-            self.api_url = self._normalize_api_url(configured_url)
+            self.api_url = configured_url
         if self.api_key is None:
             configured_key = current_app.config.get("N8N_API_KEY", "")
-            self.api_key = self._normalize_api_key(configured_key)
+            self.api_key = configured_key
         if self.timeout is None:
             self.timeout = current_app.config.get("N8N_API_TIMEOUT", 30)
+        self.api_url = self._normalize_api_url(self.api_url)
+        self.api_key = self._normalize_api_key(self.api_key)
 
     @staticmethod
     def _normalize_api_url(api_url: Optional[str]) -> str:
-        """Treat placeholder n8n URLs as unset and fall back to production n8n."""
+        """Treat placeholder n8n URLs as unset."""
         normalized = (api_url or "").strip().rstrip("/")
         if normalized in PLACEHOLDER_N8N_API_URLS:
-            return DEFAULT_N8N_API_URL
+            return ""
         return normalized
 
     @staticmethod
     def _normalize_api_key(api_key: Optional[str]) -> str:
-        """Treat placeholder n8n API keys as unset so app defaults can apply."""
+        """Treat placeholder n8n API keys as unset."""
         normalized = (api_key or "").strip()
         if normalized in PLACEHOLDER_N8N_API_KEYS:
-            return DEFAULT_N8N_API_KEY
+            return ""
         return normalized
 
+    def _validate_config(self) -> None:
+        if not self.api_url:
+            raise N8nClientError("N8N_API_URL 未配置")
+        if not self.api_key:
+            raise N8nClientError("N8N_API_KEY 未配置")
+
     def _get_headers(self) -> Dict[str, str]:
         """获取请求头"""
         self._get_config()
+        self._validate_config()
         return {
             "X-N8N-API-KEY": self.api_key or "",
             "Content-Type": "application/json",

+ 9 - 149
app/core/data_flow/dataflows.py

@@ -1,6 +1,7 @@
 import contextlib
 import json
 import logging
+import os
 import uuid
 from datetime import datetime
 from pathlib import Path
@@ -9,6 +10,7 @@ from typing import Any, Dict, List, Optional, Union
 from sqlalchemy import text
 
 from app import db
+from app.core.common.identifiers import ensure_governance_uid
 from app.core.data_service.data_product_service import DataProductService
 from app.core.graph.graph_operations import (
     connect_graph,
@@ -264,6 +266,7 @@ class DataFlowService:
                 "created_at": get_formatted_time(),
                 "updated_at": get_formatted_time(),
             }
+            ensure_governance_uid(node_data)
 
             # 创建或获取数据流节点
             dataflow_id = get_node("DataFlow", name=dataflow_name)
@@ -1277,156 +1280,13 @@ class DataFlowService:
             logger.error(f"删除数据流失败: {str(e)}")
             raise e
 
-    @staticmethod
-    def execute_dataflow(
-        dataflow_id: int,
-        params: Optional[Dict[str, Any]] = None,
-    ) -> Dict[str, Any]:
-        """
-        执行数据流
-
-        Args:
-            dataflow_id: 数据流ID
-            params: 执行参数
-
-        Returns:
-            执行结果信息
-        """
-        try:
-            # 检查数据流是否存在
-            query = "MATCH (n:DataFlow) WHERE id(n) = $dataflow_id RETURN n"
-            with connect_graph().session() as session:
-                result = session.run(query, dataflow_id=dataflow_id).data()
-
-                if not result:
-                    raise ValueError(f"数据流不存在: ID={dataflow_id}")
-
-            execution_id = f"exec_{dataflow_id}_{int(datetime.now().timestamp())}"
-
-            # TODO: 这里应该实际执行数据流
-            # 目前返回模拟结果
-            result = {
-                "execution_id": execution_id,
-                "dataflow_id": dataflow_id,
-                "status": "running",
-                "started_at": datetime.now().isoformat(),
-                "params": params or {},
-                "progress": 0,
-            }
-
-            logger.info(
-                "开始执行数据流: ID=%s, execution_id=%s",
-                dataflow_id,
-                execution_id,
-            )
-            return result
-        except Exception as e:
-            logger.error(f"执行数据流失败: {str(e)}")
-            raise e
-
-    @staticmethod
-    def get_dataflow_status(dataflow_id: int) -> Dict[str, Any]:
-        """
-        获取数据流执行状态
-
-        Args:
-            dataflow_id: 数据流ID
-
-        Returns:
-            执行状态信息
-        """
-        try:
-            # TODO: 这里应该查询实际的执行状态
-            # 目前返回模拟状态
-            query = "MATCH (n:DataFlow) WHERE id(n) = $dataflow_id RETURN n"
-            with connect_graph().session() as session:
-                result = session.run(query, dataflow_id=dataflow_id).data()
-
-                if not result:
-                    raise ValueError(f"数据流不存在: ID={dataflow_id}")
-
-            status = ["running", "completed", "failed", "pending"][dataflow_id % 4]
-
-            return {
-                "dataflow_id": dataflow_id,
-                "status": status,
-                "progress": (
-                    100 if status == "completed" else (dataflow_id * 10) % 100
-                ),
-                "started_at": datetime.now().isoformat(),
-                "completed_at": (
-                    datetime.now().isoformat() if status == "completed" else None
-                ),
-                "error_message": ("执行过程中发生错误" if status == "failed" else None),
-            }
-        except Exception as e:
-            logger.error(f"获取数据流状态失败: {str(e)}")
-            raise e
-
-    @staticmethod
-    def get_dataflow_logs(
-        dataflow_id: int,
-        page: int = 1,
-        page_size: int = 50,
-    ) -> Dict[str, Any]:
-        """
-        获取数据流执行日志
-
-        Args:
-            dataflow_id: 数据流ID
-            page: 页码
-            page_size: 每页大小
-
-        Returns:
-            执行日志列表和分页信息
-        """
-        try:
-            # TODO: 这里应该查询实际的执行日志
-            # 目前返回模拟日志
-            query = "MATCH (n:DataFlow) WHERE id(n) = $dataflow_id RETURN n"
-            with connect_graph().session() as session:
-                result = session.run(query, dataflow_id=dataflow_id).data()
-
-                if not result:
-                    raise ValueError(f"数据流不存在: ID={dataflow_id}")
-
-            mock_logs = [
-                {
-                    "id": i,
-                    "timestamp": datetime.now().isoformat(),
-                    "level": ["INFO", "WARNING", "ERROR"][i % 3],
-                    "message": f"数据流执行日志消息 {i}",
-                    "component": ["source", "transform", "target"][i % 3],
-                }
-                for i in range(1, 101)
-            ]
-
-            # 分页处理
-            total = len(mock_logs)
-            start = (page - 1) * page_size
-            end = start + page_size
-            logs = mock_logs[start:end]
-
-            return {
-                "logs": logs,
-                "pagination": {
-                    "page": page,
-                    "page_size": page_size,
-                    "total": total,
-                    "total_pages": (total + page_size - 1) // page_size,
-                },
-            }
-        except Exception as e:
-            logger.error(f"获取数据流日志失败: {str(e)}")
-            raise e
-
-    # 默认生产环境数据源配置
+    # 无 COME_FROM 关系时使用显式环境配置;默认仅指向本机,禁止回退内网地址。
     DEFAULT_DATA_SOURCE = {
         "type": "postgresql",
-        "host": "192.168.3.143",
-        "port": 5432,
-        "database": "dataops",
-        "schema": "dags",
+        "host": os.environ.get("DATAFLOW_DEFAULT_DB_HOST", "127.0.0.1"),
+        "port": int(os.environ.get("DATAFLOW_DEFAULT_DB_PORT", "5432")),
+        "database": os.environ.get("DATAFLOW_DEFAULT_DB_NAME", "dataops"),
+        "schema": os.environ.get("DATAFLOW_SCHEMA", "dags"),
     }
 
     @staticmethod
@@ -1447,7 +1307,7 @@ class DataFlowService:
 
         Returns:
             包含ddl和data_source信息的字典,如果节点不存在则返回None
-            data_source始终返回,如果没有COME_FROM关系则使用默认生产环境配置
+            data_source始终返回,如果没有COME_FROM关系则使用显式环境配置
         """
         try:
             # 查询BusinessDomain节点、元数据、标签关系和数据源关系

+ 73 - 0
app/core/data_flow/workflow_activation.py

@@ -0,0 +1,73 @@
+from __future__ import annotations
+
+import re
+
+from sqlalchemy import text
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.events.outbox import enqueue_outbox
+
+
+WORKFLOW_STATES = {"draft", "validating", "active", "superseded", "activation_failed"}
+
+
+def activation_error(error: Exception) -> str:
+    message = re.sub(
+        r"(?i)(api[-_ ]?key|authorization|bearer|password|token)(?:[=: ]+\S+)?",
+        "[redacted]",
+        str(error),
+    )
+    return message[:1000]
+
+
+def activate_version(session, *, version_id: str, actor_id: str, n8n_client) -> dict:
+    row = session.execute(
+        text(
+            "SELECT id::text, dataflow_uid::text, environment, n8n_workflow_id, status "
+            "FROM public.dataflow_workflow_versions WHERE id = CAST(:id AS uuid) FOR UPDATE"
+        ),
+        {"id": version_id},
+    ).one_or_none()
+    if not row:
+        raise ValueError("workflow version not found")
+    if row[4] == "active":
+        return {"id": row[0], "status": "active", "idempotent": True}
+    session.execute(text("SELECT pg_advisory_xact_lock(hashtext(:key))"), {"key": f"{row[1]}:{row[2]}"})
+    correlation_id = new_governance_uid()
+    session.execute(
+        text("UPDATE public.dataflow_workflow_versions SET status = 'validating', activation_error = NULL, activation_correlation_id = CAST(:correlation AS uuid) WHERE id = CAST(:id AS uuid)"),
+        {"id": version_id, "correlation": correlation_id},
+    )
+    active_ids = session.execute(
+        text("SELECT n8n_workflow_id FROM public.dataflow_workflow_versions WHERE dataflow_uid = CAST(:uid AS uuid) AND environment = :environment AND status = 'active' FOR UPDATE"),
+        {"uid": row[1], "environment": row[2]},
+    ).scalars().all()
+    try:
+        for active_id in active_ids:
+            if active_id != row[3]:
+                n8n_client.deactivate_workflow(active_id)
+        n8n_client.activate_workflow(row[3])
+    except Exception as exc:
+        message = activation_error(exc)
+        session.execute(
+            text("UPDATE public.dataflow_workflow_versions SET status = 'activation_failed', activation_error = :error, updated_at = CURRENT_TIMESTAMP WHERE id = CAST(:id AS uuid)"),
+            {"id": version_id, "error": message},
+        )
+        session.commit()
+        raise RuntimeError(message) from exc
+    session.execute(
+        text("UPDATE public.dataflow_workflow_versions SET status = 'superseded', updated_at = CURRENT_TIMESTAMP WHERE dataflow_uid = CAST(:uid AS uuid) AND environment = :environment AND status = 'active' AND id <> CAST(:id AS uuid)"),
+        {"uid": row[1], "environment": row[2], "id": version_id},
+    )
+    session.execute(
+        text("UPDATE public.dataflow_workflow_versions SET status = 'active', activated_by = CAST(:actor AS uuid), activated_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = CAST(:id AS uuid)"),
+        {"id": version_id, "actor": actor_id},
+    )
+    enqueue_outbox(
+        session, aggregate_type="dataflow", aggregate_id=row[1],
+        event_type="dataflow.workflow_version_activated",
+        payload={"dataflow_uid": row[1], "workflow_version_id": version_id, "environment": row[2]},
+        correlation_id=correlation_id,
+    )
+    session.commit()
+    return {"id": version_id, "status": "active", "correlation_id": correlation_id}

+ 14 - 0
app/core/data_flow/workflow_models.py

@@ -0,0 +1,14 @@
+from dataclasses import dataclass
+from datetime import datetime
+
+
+@dataclass(frozen=True)
+class WorkflowVersion:
+    id: str
+    dataflow_uid: str
+    environment: str
+    version_no: int
+    n8n_workflow_id: str
+    definition_hash: str
+    status: str
+    created_at: datetime

+ 113 - 0
app/core/data_flow/workflow_repository.py

@@ -0,0 +1,113 @@
+from __future__ import annotations
+
+import copy
+import hashlib
+import json
+from typing import Any
+
+from sqlalchemy import text
+from sqlalchemy.exc import IntegrityError
+
+from app.core.common.identifiers import ensure_governance_uid, new_governance_uid
+
+
+ENVIRONMENTS = {"development", "test", "production"}
+VOLATILE_KEYS = {"active", "createdAt", "updatedAt", "versionId"}
+SECRET_KEYS = {"credentials", "credential", "apiKey", "password", "token", "authorization"}
+
+
+def validate_dataflow_uid(value: str) -> str:
+    properties = {"uid": str(value)}
+    return ensure_governance_uid(properties)
+
+
+def _sanitize(value: Any, key: str | None = None) -> Any:
+    if key in VOLATILE_KEYS:
+        return None
+    if key in SECRET_KEYS:
+        return "[redacted]"
+    if isinstance(value, dict):
+        return {
+            item_key: sanitized
+            for item_key, item_value in sorted(value.items())
+            if item_key not in VOLATILE_KEYS
+            for sanitized in [_sanitize(item_value, item_key)]
+        }
+    if isinstance(value, list):
+        return [_sanitize(item) for item in value]
+    return copy.deepcopy(value)
+
+
+def safe_workflow_snapshot(workflow: dict[str, Any]) -> dict[str, Any]:
+    return _sanitize(workflow)
+
+
+def workflow_hash(workflow: dict[str, Any]) -> str:
+    canonical = json.dumps(
+        safe_workflow_snapshot(workflow), sort_keys=True, separators=(",", ":"), ensure_ascii=False
+    )
+    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
+
+
+def create_version(
+    session,
+    *,
+    dataflow_uid: str,
+    environment: str,
+    workflow: dict[str, Any],
+    created_by: str,
+) -> str:
+    dataflow_uid = validate_dataflow_uid(dataflow_uid)
+    if environment not in ENVIRONMENTS:
+        raise ValueError("unsupported environment")
+    n8n_id = str(workflow.get("id") or "").strip()
+    if not n8n_id:
+        raise ValueError("n8n workflow id is required")
+    snapshot = safe_workflow_snapshot(workflow)
+    digest = workflow_hash(workflow)
+    session.execute(
+        text("SELECT pg_advisory_xact_lock(hashtext(:key))"),
+        {"key": f"{dataflow_uid}:{environment}"},
+    )
+    version_no = session.execute(
+        text(
+            "SELECT COALESCE(MAX(version_no), 0) + 1 FROM public.dataflow_workflow_versions "
+            "WHERE dataflow_uid = CAST(:uid AS uuid) AND environment = :environment"
+        ),
+        {"uid": dataflow_uid, "environment": environment},
+    ).scalar_one()
+    version_id = new_governance_uid()
+    try:
+        session.execute(
+            text(
+                "INSERT INTO public.dataflow_workflow_versions "
+                "(id, dataflow_uid, environment, version_no, n8n_workflow_id, "
+                "n8n_workflow_name, definition_hash, definition_snapshot, created_by) "
+                "VALUES (CAST(:id AS uuid), CAST(:uid AS uuid), :environment, :version_no, "
+                ":n8n_id, :name, :digest, CAST(:snapshot AS jsonb), CAST(:created_by AS uuid))"
+            ),
+            {
+                "id": version_id, "uid": dataflow_uid, "environment": environment,
+                "version_no": version_no, "n8n_id": n8n_id, "name": workflow.get("name"),
+                "digest": digest, "snapshot": json.dumps(snapshot, ensure_ascii=False),
+                "created_by": created_by,
+            },
+        )
+    except IntegrityError as exc:
+        raise ValueError("this workflow definition already has a version") from exc
+    return version_id
+
+
+def list_versions(session, dataflow_uid: str) -> list[dict[str, Any]]:
+    uid = validate_dataflow_uid(dataflow_uid)
+    rows = session.execute(
+        text(
+            "SELECT id::text, dataflow_uid::text, environment, version_no, n8n_workflow_id, "
+            "n8n_workflow_name, definition_hash, status, activation_error, created_at, activated_at "
+            "FROM public.dataflow_workflow_versions WHERE dataflow_uid = CAST(:uid AS uuid) "
+            "ORDER BY environment, version_no DESC"
+        ),
+        {"uid": uid},
+    )
+    keys = ("id", "dataflow_uid", "environment", "version_no", "n8n_workflow_id", "n8n_workflow_name", "definition_hash", "status", "activation_error", "created_at", "activated_at")
+    return [{key: (value.isoformat() if hasattr(value, "isoformat") else value) for key, value in zip(keys, row)} for row in rows]

+ 10 - 15
app/core/data_service/data_product_service.py

@@ -13,7 +13,6 @@ from datetime import datetime
 from typing import Any
 
 from flask import current_app
-from openai import OpenAI
 from sqlalchemy import text
 
 from app import db
@@ -1538,12 +1537,9 @@ class DataOrderService:
             提取结果,包含 business_domains, data_fields, purpose, tags
         """
         try:
-            client = OpenAI(
-                api_key=current_app.config.get("LLM_API_KEY"),
-                base_url=current_app.config.get("LLM_BASE_URL"),
-            )
+            from app.core.llm.deepseek_client import chat_completions_create, create_llm_client
 
-            model = current_app.config.get("LLM_MODEL_NAME")
+            client = create_llm_client()
 
             prompt = f"""分析以下数据需求描述,提取其中涉及的业务领域、数据字段和标签信息。
 
@@ -1580,8 +1576,8 @@ class DataOrderService:
    - "从产品库存表里提取库存量和仓库名称信息" → tags: [](没有明确标签表述)
 """
 
-            completion = client.chat.completions.create(
-                model=model,  # type: ignore[arg-type]
+            completion = chat_completions_create(
+                client,
                 messages=[
                     {
                         "role": "system",
@@ -1592,6 +1588,7 @@ class DataOrderService:
                 ],
                 temperature=0.1,
                 max_tokens=1024,
+                use_thinking=True,
             )
 
             response_text = (
@@ -1660,12 +1657,9 @@ class DataOrderService:
             - processing_logic: 数据加工处理逻辑描述
         """
         try:
-            client = OpenAI(
-                api_key=current_app.config.get("LLM_API_KEY"),
-                base_url=current_app.config.get("LLM_BASE_URL"),
-            )
+            from app.core.llm.deepseek_client import chat_completions_create, create_llm_client
 
-            model = current_app.config.get("LLM_MODEL_NAME")
+            client = create_llm_client()
 
             # 构建输入域上下文信息
             input_context = ""
@@ -1719,8 +1713,8 @@ class DataOrderService:
 - 因为"仓库名称"是分组维度,可用于检索特定仓库的库存统计数据
 """
 
-            completion = client.chat.completions.create(
-                model=model,  # type: ignore[arg-type]
+            completion = chat_completions_create(
+                client,
                 messages=[
                     {
                         "role": "system",
@@ -1731,6 +1725,7 @@ class DataOrderService:
                 ],
                 temperature=0.1,
                 max_tokens=2048,
+                use_thinking=True,
             )
 
             response_text = (

+ 5 - 0
app/core/data_source/__init__.py

@@ -0,0 +1,5 @@
+"""External business data-source connection management."""
+
+from app.core.data_source.errors import DataSourceError
+
+__all__ = ["DataSourceError"]

+ 22 - 0
app/core/data_source/adapters/__init__.py

@@ -0,0 +1,22 @@
+"""Allowlisted database adapters for external data sources."""
+
+from pathlib import Path
+
+from app.core.data_source.adapters.mysql import MySQLAdapter
+from app.core.data_source.adapters.postgresql import PostgreSQLAdapter
+from app.core.data_source.errors import DataSourceTypeUnsupported
+
+
+def adapter_for(database_type: str, certificate_dir=None):
+    normalized = str(database_type or "").strip().lower()
+    options = {}
+    if certificate_dir is not None:
+        options["certificate_dir"] = Path(certificate_dir)
+    if normalized in {"postgresql", "postgres"}:
+        return PostgreSQLAdapter(**options)
+    if normalized == "mysql":
+        return MySQLAdapter(**options)
+    raise DataSourceTypeUnsupported()
+
+
+__all__ = ["adapter_for", "PostgreSQLAdapter", "MySQLAdapter"]

+ 148 - 0
app/core/data_source/adapters/base.py

@@ -0,0 +1,148 @@
+"""Shared adapter behavior and temporary connection testing."""
+
+import os
+from pathlib import Path
+
+from sqlalchemy import URL, create_engine, text
+from sqlalchemy.pool import NullPool
+
+from app.core.data_source.errors import (
+    DataSourceConfigurationInvalid,
+    DataSourceConnectionFailed,
+)
+
+
+READ_ONLY_PURPOSES = {
+    "metadata_collection",
+    "metadata_preview",
+    "quality_check",
+    "dataflow_read",
+}
+PURPOSES = READ_ONLY_PURPOSES | {"connection_test", "dataflow_write"}
+
+
+class BaseDataSourceAdapter:
+    database_type = ""
+    drivername = ""
+    allowed_tls_options = frozenset()
+    certificate_options = frozenset()
+
+    def __init__(self, certificate_dir=None):
+        self.certificate_dir = Path(
+            certificate_dir
+            or os.environ.get(
+                "DATASOURCE_CERT_DIR",
+                "/etc/dataops-platform/datasource-certs",
+            )
+        ).resolve()
+
+    def validate_options(self, options):
+        options = dict(options or {})
+        unsupported = sorted(set(options) - set(self.allowed_tls_options))
+        if unsupported:
+            raise DataSourceConfigurationInvalid(
+                "unsupported TLS option"
+            )
+        normalized = {}
+        for key, value in options.items():
+            if key in self.certificate_options:
+                path = Path(str(value)).expanduser().resolve()
+                try:
+                    path.relative_to(self.certificate_dir)
+                except ValueError as exc:
+                    raise DataSourceConfigurationInvalid(
+                        "certificate path is outside the configured directory"
+                    ) from exc
+                if not path.is_file():
+                    raise DataSourceConfigurationInvalid(
+                        "certificate file does not exist"
+                    )
+                normalized[key] = str(path)
+            else:
+                normalized[key] = self._validate_scalar_option(key, value)
+        return normalized
+
+    def _validate_scalar_option(self, key, value):
+        return str(value)
+
+    def validate_definition(self, definition):
+        if definition.database_type not in {
+            self.database_type,
+            "postgres" if self.database_type == "postgresql" else self.database_type,
+        }:
+            raise DataSourceConfigurationInvalid(
+                "data source type does not match adapter"
+            )
+        if not str(definition.host).strip():
+            raise DataSourceConfigurationInvalid("host is required")
+        if definition.port < 1 or definition.port > 65535:
+            raise DataSourceConfigurationInvalid("port is invalid")
+        if not str(definition.database).strip():
+            raise DataSourceConfigurationInvalid("database is required")
+        self.validate_options(definition.tls_options)
+
+    def build_url(self, definition, credential):
+        self.validate_definition(definition)
+        return URL.create(
+            drivername=self.drivername,
+            username=credential.username,
+            password=credential.password,
+            host=definition.host,
+            port=definition.port,
+            database=definition.database,
+        )
+
+    def connect_args(self, definition, credential, query_timeout):
+        raise NotImplementedError
+
+    def probe(self, connection):
+        connection.execute(text("SELECT 1"))
+
+    def configure_transaction(self, connection, purpose):
+        if purpose not in PURPOSES:
+            raise DataSourceConfigurationInvalid(
+                "unsupported data source purpose"
+            )
+        if purpose in READ_ONLY_PURPOSES:
+            connection.execute(text("SET TRANSACTION READ ONLY"))
+
+    def test_connection(
+        self,
+        definition,
+        credential,
+        *,
+        query_timeout,
+    ):
+        engine = create_engine(
+            self.build_url(definition, credential),
+            poolclass=NullPool,
+            connect_args=self.connect_args(
+                definition,
+                credential,
+                query_timeout=query_timeout,
+            ),
+        )
+        try:
+            with engine.connect() as connection:
+                self.probe(connection)
+        except Exception as exc:
+            raise DataSourceConnectionFailed() from exc
+        finally:
+            engine.dispose()
+
+    def create_pooled_engine(self, definition, credential, settings):
+        return create_engine(
+            self.build_url(definition, credential),
+            pool_size=int(settings["pool_size"]),
+            max_overflow=int(settings["max_overflow"]),
+            pool_timeout=int(settings["pool_timeout"]),
+            pool_recycle=int(settings["pool_recycle"]),
+            pool_pre_ping=True,
+            pool_use_lifo=True,
+            pool_reset_on_return="rollback",
+            connect_args=self.connect_args(
+                definition,
+                credential,
+                query_timeout=int(settings["query_timeout"]),
+            ),
+        )

+ 21 - 0
app/core/data_source/adapters/mysql.py

@@ -0,0 +1,21 @@
+"""MySQL external data-source adapter."""
+
+from app.core.data_source.adapters.base import BaseDataSourceAdapter
+
+
+class MySQLAdapter(BaseDataSourceAdapter):
+    database_type = "mysql"
+    drivername = "mysql+pymysql"
+    allowed_tls_options = frozenset({"ssl_ca", "ssl_cert", "ssl_key"})
+    certificate_options = allowed_tls_options
+
+    def connect_args(self, definition, credential, query_timeout):
+        timeout = int(query_timeout)
+        options = {
+            "connect_timeout": 5,
+            "read_timeout": timeout,
+            "write_timeout": timeout,
+            "charset": "utf8mb4",
+        }
+        options.update(self.validate_options(definition.tls_options))
+        return options

+ 41 - 0
app/core/data_source/adapters/postgresql.py

@@ -0,0 +1,41 @@
+"""PostgreSQL external data-source adapter."""
+
+from app.core.data_source.adapters.base import BaseDataSourceAdapter
+from app.core.data_source.errors import DataSourceConfigurationInvalid
+
+
+class PostgreSQLAdapter(BaseDataSourceAdapter):
+    database_type = "postgresql"
+    drivername = "postgresql+psycopg2"
+    allowed_tls_options = frozenset(
+        {"sslmode", "sslrootcert", "sslcert", "sslkey"}
+    )
+    certificate_options = frozenset({"sslrootcert", "sslcert", "sslkey"})
+    _SSL_MODES = {
+        "disable",
+        "allow",
+        "prefer",
+        "require",
+        "verify-ca",
+        "verify-full",
+    }
+
+    def _validate_scalar_option(self, key, value):
+        normalized = str(value).strip().lower()
+        if key == "sslmode" and normalized not in self._SSL_MODES:
+            raise DataSourceConfigurationInvalid("sslmode is invalid")
+        return normalized
+
+    def connect_args(self, definition, credential, query_timeout):
+        timeout = int(query_timeout)
+        options = {
+            "connect_timeout": 5,
+            "keepalives": 1,
+            "keepalives_idle": 30,
+            "keepalives_interval": 10,
+            "keepalives_count": 3,
+            "application_name": "dataops-platform-datasource",
+            "options": f"-c statement_timeout={timeout * 1000}",
+        }
+        options.update(self.validate_options(definition.tls_options))
+        return options

+ 101 - 0
app/core/data_source/circuit_breaker.py

@@ -0,0 +1,101 @@
+"""Thread-safe per-data-source circuit breaker."""
+
+import threading
+import time
+from contextlib import contextmanager
+
+from app.core.data_source.errors import DataSourceCircuitOpen
+
+
+class CircuitBreaker:
+    def __init__(
+        self,
+        *,
+        failure_threshold=3,
+        recovery_seconds=30,
+        clock=None,
+    ):
+        self.failure_threshold = int(failure_threshold)
+        self.recovery_seconds = float(recovery_seconds)
+        if self.failure_threshold < 1 or self.recovery_seconds <= 0:
+            raise ValueError("invalid circuit breaker settings")
+        self._clock = clock or time.monotonic
+        self._lock = threading.Lock()
+        self._state = "closed"
+        self._consecutive_failures = 0
+        self._open_until = 0.0
+        self._half_open_in_flight = False
+
+    @property
+    def state(self):
+        with self._lock:
+            return self._state
+
+    @property
+    def consecutive_failures(self):
+        with self._lock:
+            return self._consecutive_failures
+
+    @property
+    def open_until(self):
+        with self._lock:
+            return self._open_until
+
+    def record_success(self):
+        with self._lock:
+            self._state = "closed"
+            self._consecutive_failures = 0
+            self._open_until = 0.0
+            self._half_open_in_flight = False
+
+    def record_failure(self):
+        with self._lock:
+            self._consecutive_failures += 1
+            if (
+                self._state == "half_open"
+                or self._consecutive_failures >= self.failure_threshold
+            ):
+                self._state = "open"
+                self._open_until = self._clock() + self.recovery_seconds
+                self._half_open_in_flight = False
+
+    @contextmanager
+    def probe_permission(self):
+        half_open_owner = False
+        with self._lock:
+            now = self._clock()
+            if self._state == "open":
+                if now < self._open_until:
+                    raise DataSourceCircuitOpen()
+                if self._half_open_in_flight:
+                    raise DataSourceCircuitOpen()
+                self._state = "half_open"
+                self._half_open_in_flight = True
+                half_open_owner = True
+            elif self._state == "half_open":
+                raise DataSourceCircuitOpen()
+
+        try:
+            yield
+        except Exception:
+            if half_open_owner:
+                self.record_failure()
+            raise
+        finally:
+            if half_open_owner:
+                with self._lock:
+                    if self._state == "half_open":
+                        self._state = "open"
+                        self._open_until = (
+                            self._clock() + self.recovery_seconds
+                        )
+                        self._half_open_in_flight = False
+
+    def snapshot(self):
+        with self._lock:
+            return {
+                "state": self._state,
+                "consecutive_failures": self._consecutive_failures,
+                "open_until": self._open_until,
+                "half_open_in_flight": self._half_open_in_flight,
+            }

+ 382 - 0
app/core/data_source/credentials.py

@@ -0,0 +1,382 @@
+"""AES-GCM codec and platform PostgreSQL repository for data-source secrets."""
+
+import base64
+import binascii
+import json
+from typing import Optional
+
+from cryptography.exceptions import InvalidTag
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+from sqlalchemy import text
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.data_source.errors import DataSourceCredentialUnavailable
+from app.core.data_source.models import DataSourceCredential, SealedCredential
+
+
+class CredentialCodec:
+    """Encrypt credentials with data-source identity bound as AES-GCM AAD."""
+
+    def __init__(self, master_key: bytes, key_version: str):
+        if len(master_key) != 32:
+            raise DataSourceCredentialUnavailable(
+                "master key must decode to 32 bytes"
+            )
+        if not str(key_version).strip():
+            raise DataSourceCredentialUnavailable(
+                "credential key version is not configured"
+            )
+        self._master_key = bytes(master_key)
+        self.key_version = str(key_version).strip()
+
+    @classmethod
+    def from_base64(cls, encoded_key: str, key_version: str):
+        if not str(encoded_key or "").strip():
+            raise DataSourceCredentialUnavailable(
+                "master key is not configured"
+            )
+        value = str(encoded_key).strip()
+        padding = "=" * (-len(value) % 4)
+        try:
+            decoded = base64.b64decode(
+                value + padding,
+                altchars=b"-_",
+                validate=True,
+            )
+        except (binascii.Error, ValueError) as exc:
+            raise DataSourceCredentialUnavailable(
+                "master key is not valid base64"
+            ) from exc
+        return cls(decoded, key_version)
+
+    @staticmethod
+    def _aad(data_source_uid: str, credential_version: int) -> bytes:
+        return f"{data_source_uid}:{int(credential_version)}".encode("utf-8")
+
+    def encrypt(
+        self,
+        data_source_uid: str,
+        credential_version: int,
+        credential: DataSourceCredential,
+    ) -> SealedCredential:
+        import os
+
+        nonce = os.urandom(12)
+        payload = json.dumps(
+            {
+                "username": credential.username,
+                "password": credential.password,
+                "options": dict(credential.options),
+            },
+            sort_keys=True,
+            separators=(",", ":"),
+        ).encode("utf-8")
+        encrypted_payload = AESGCM(self._master_key).encrypt(
+            nonce,
+            payload,
+            self._aad(data_source_uid, credential_version),
+        )
+        return SealedCredential(
+            id=new_governance_uid(),
+            data_source_uid=str(data_source_uid),
+            credential_version=int(credential_version),
+            encrypted_payload=encrypted_payload,
+            nonce=nonce,
+            key_version=self.key_version,
+        )
+
+    def decrypt(self, sealed: SealedCredential) -> DataSourceCredential:
+        if sealed.key_version != self.key_version:
+            raise DataSourceCredentialUnavailable(
+                "credential key version is unavailable"
+            )
+        try:
+            plaintext = AESGCM(self._master_key).decrypt(
+                bytes(sealed.nonce),
+                bytes(sealed.encrypted_payload),
+                self._aad(
+                    sealed.data_source_uid,
+                    sealed.credential_version,
+                ),
+            )
+            payload = json.loads(plaintext.decode("utf-8"))
+            username = payload["username"]
+            password = payload["password"]
+            options = payload.get("options", {})
+            if (
+                not isinstance(username, str)
+                or not isinstance(password, str)
+                or not isinstance(options, dict)
+            ):
+                raise ValueError("malformed credential payload")
+            return DataSourceCredential(username, password, options)
+        except (
+            InvalidTag,
+            UnicodeDecodeError,
+            json.JSONDecodeError,
+            KeyError,
+            TypeError,
+            ValueError,
+        ) as exc:
+            raise DataSourceCredentialUnavailable(
+                "credential cannot be decrypted"
+            ) from exc
+
+
+class DataSourceCredentialRepository:
+    """Store immutable encrypted credential versions in the platform DB."""
+
+    def __init__(self, codec: CredentialCodec):
+        self.codec = codec
+
+    def create_version(
+        self,
+        session,
+        *,
+        data_source_uid: str,
+        credential: DataSourceCredential,
+        actor_uid: Optional[str],
+    ) -> SealedCredential:
+        session.execute(
+            text("SELECT pg_advisory_xact_lock(hashtext(:key))"),
+            {"key": f"datasource-credential:{data_source_uid}"},
+        )
+        version = int(
+            session.execute(
+                text(
+                    """
+                    SELECT COALESCE(MAX(credential_version), 0) + 1
+                    FROM public.datasource_credentials
+                    WHERE data_source_uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": data_source_uid},
+            ).scalar_one()
+        )
+        sealed = self.codec.encrypt(data_source_uid, version, credential)
+        session.execute(
+            text(
+                """
+                UPDATE public.datasource_credentials
+                SET status = 'retired', retired_at = CURRENT_TIMESTAMP
+                WHERE data_source_uid = CAST(:uid AS uuid)
+                  AND status = 'active'
+                """
+            ),
+            {"uid": data_source_uid},
+        )
+        session.execute(
+            text(
+                """
+                INSERT INTO public.datasource_credentials (
+                    id, data_source_uid, credential_version,
+                    encrypted_payload, nonce, key_version, status
+                ) VALUES (
+                    CAST(:id AS uuid), CAST(:uid AS uuid), :version,
+                    :encrypted_payload, :nonce, :key_version, 'active'
+                )
+                """
+            ),
+            {
+                "id": sealed.id,
+                "uid": sealed.data_source_uid,
+                "version": sealed.credential_version,
+                "encrypted_payload": sealed.encrypted_payload,
+                "nonce": sealed.nonce,
+                "key_version": sealed.key_version,
+            },
+        )
+        self._audit(
+            session,
+            data_source_uid=data_source_uid,
+            credential_version=version,
+            actor_uid=actor_uid,
+            event_type="credential_version_created",
+            success=True,
+            safe_detail="encrypted credential version created",
+        )
+        return sealed
+
+    def get_active(
+        self,
+        session,
+        data_source_uid: str,
+        credential_version: Optional[int] = None,
+    ) -> DataSourceCredential:
+        version_clause = (
+            "AND credential_version = :version"
+            if credential_version is not None
+            else ""
+        )
+        parameters = {"uid": data_source_uid}
+        if credential_version is not None:
+            parameters["version"] = int(credential_version)
+        row = (
+            session.execute(
+                text(
+                    f"""
+                    SELECT id::text, data_source_uid::text,
+                           credential_version, encrypted_payload,
+                           nonce, key_version, status
+                    FROM public.datasource_credentials
+                    WHERE data_source_uid = CAST(:uid AS uuid)
+                      AND status = 'active'
+                      {version_clause}
+                    ORDER BY credential_version DESC
+                    LIMIT 1
+                    """
+                ),
+                parameters,
+            )
+            .mappings()
+            .one_or_none()
+        )
+        if row is None:
+            raise DataSourceCredentialUnavailable()
+        sealed = SealedCredential(
+            id=row["id"],
+            data_source_uid=row["data_source_uid"],
+            credential_version=int(row["credential_version"]),
+            encrypted_payload=bytes(row["encrypted_payload"]),
+            nonce=bytes(row["nonce"]),
+            key_version=row["key_version"],
+            status=row["status"],
+        )
+        return self.codec.decrypt(sealed)
+
+    def revoke_all(
+        self,
+        session,
+        *,
+        data_source_uid: str,
+        actor_uid: Optional[str],
+    ) -> int:
+        result = session.execute(
+            text(
+                """
+                UPDATE public.datasource_credentials
+                SET status = 'revoked',
+                    retired_at = COALESCE(retired_at, CURRENT_TIMESTAMP)
+                WHERE data_source_uid = CAST(:uid AS uuid)
+                  AND status <> 'revoked'
+                """
+            ),
+            {"uid": data_source_uid},
+        )
+        count = int(result.rowcount or 0)
+        self._audit(
+            session,
+            data_source_uid=data_source_uid,
+            credential_version=None,
+            actor_uid=actor_uid,
+            event_type="credentials_revoked",
+            success=True,
+            safe_detail=f"revoked credential versions: {count}",
+        )
+        return count
+
+    def compensate_failed_activation(
+        self,
+        session,
+        *,
+        data_source_uid: str,
+        failed_version: int,
+        restore_version: Optional[int],
+        actor_uid: Optional[str],
+    ) -> None:
+        session.execute(
+            text("SELECT pg_advisory_xact_lock(hashtext(:key))"),
+            {"key": f"datasource-credential:{data_source_uid}"},
+        )
+        session.execute(
+            text(
+                """
+                UPDATE public.datasource_credentials
+                SET status = 'revoked',
+                    retired_at = COALESCE(retired_at, CURRENT_TIMESTAMP)
+                WHERE data_source_uid = CAST(:uid AS uuid)
+                  AND credential_version = :failed_version
+                """
+            ),
+            {
+                "uid": data_source_uid,
+                "failed_version": int(failed_version),
+            },
+        )
+        if restore_version is not None:
+            session.execute(
+                text(
+                    """
+                    UPDATE public.datasource_credentials
+                    SET status = 'active', retired_at = NULL
+                    WHERE data_source_uid = CAST(:uid AS uuid)
+                      AND credential_version = :restore_version
+                      AND status = 'retired'
+                    """
+                ),
+                {
+                    "uid": data_source_uid,
+                    "restore_version": int(restore_version),
+                },
+            )
+        self._audit(
+            session,
+            data_source_uid=data_source_uid,
+            credential_version=failed_version,
+            actor_uid=actor_uid,
+            event_type="credential_activation_compensated",
+            success=True,
+            safe_detail="failed version revoked and prior version restored",
+        )
+
+    def record_pool_event(
+        self,
+        session,
+        *,
+        data_source_uid: str,
+        actor_uid: Optional[str],
+        event_type: str,
+        safe_detail: str,
+    ) -> None:
+        self._audit(
+            session,
+            data_source_uid=data_source_uid,
+            credential_version=None,
+            actor_uid=actor_uid,
+            event_type=event_type,
+            success=True,
+            safe_detail=safe_detail,
+        )
+
+    @staticmethod
+    def _audit(
+        session,
+        *,
+        data_source_uid: str,
+        credential_version: Optional[int],
+        actor_uid: Optional[str],
+        event_type: str,
+        success: bool,
+        safe_detail: str,
+    ) -> None:
+        session.execute(
+            text(
+                """
+                INSERT INTO public.datasource_credential_audit_events (
+                    data_source_uid, credential_version, actor_uid,
+                    event_type, success, safe_detail
+                ) VALUES (
+                    CAST(:uid AS uuid), :version, CAST(:actor_uid AS uuid),
+                    :event_type, :success, :safe_detail
+                )
+                """
+            ),
+            {
+                "uid": data_source_uid,
+                "version": credential_version,
+                "actor_uid": actor_uid,
+                "event_type": event_type,
+                "success": bool(success),
+                "safe_detail": safe_detail[:500],
+            },
+        )

+ 233 - 0
app/core/data_source/definitions.py

@@ -0,0 +1,233 @@
+"""Secret-free Neo4j repository for external data-source definitions."""
+
+import json
+from dataclasses import dataclass
+from typing import Optional
+
+from app.core.common.identifiers import ensure_governance_uid
+from app.core.data_source.models import DataSourceDefinition
+
+
+NEO4J_SECRET_KEYS = {
+    "username",
+    "password",
+    "passwd",
+    "credential",
+    "credentials",
+    "encrypted_payload",
+    "nonce",
+    "api_key",
+    "token",
+    "authorization",
+    "conn_str",
+    "connection_string",
+    "connection_url",
+}
+
+
+@dataclass(frozen=True)
+class DataSourceFilters:
+    uid: Optional[str] = None
+    name_en: Optional[str] = None
+    name_zh: Optional[str] = None
+    database_type: Optional[str] = None
+    status: Optional[bool] = None
+
+
+class DataSourceDefinitionRepository:
+    """Read and write DataSource nodes using stable UIDs only."""
+
+    def __init__(self, session):
+        self.session = session
+
+    @staticmethod
+    def _assert_secret_free(properties: dict) -> None:
+        def visit(value):
+            if isinstance(value, dict):
+                for key, item in value.items():
+                    if str(key).strip().lower() in NEO4J_SECRET_KEYS:
+                        raise ValueError(
+                            "secret properties cannot be stored in DataSource"
+                        )
+                    visit(item)
+            elif isinstance(value, (list, tuple)):
+                for item in value:
+                    visit(item)
+
+        visit(properties)
+
+    @classmethod
+    def _to_properties(cls, definition: DataSourceDefinition) -> dict:
+        properties = {
+            "uid": definition.uid,
+            "name_en": definition.name_en,
+            "name_zh": definition.name_zh,
+            "type": definition.database_type,
+            "host": definition.host,
+            "port": definition.port,
+            "database": definition.database,
+            "schema": definition.schema,
+            "credential_ref": definition.credential_ref,
+            "credential_version": definition.credential_version,
+            "pool_size": definition.pool_size,
+            "max_overflow": definition.max_overflow,
+            "tls_options_json": json.dumps(
+                dict(definition.tls_options),
+                sort_keys=True,
+                separators=(",", ":"),
+            ),
+            "status": definition.status,
+            "desc": definition.description,
+        }
+        properties.update(dict(definition.extra_properties))
+        properties = {
+            key: value for key, value in properties.items() if value is not None
+        }
+        cls._assert_secret_free(properties)
+        return properties
+
+    @staticmethod
+    def _from_properties(properties: dict) -> DataSourceDefinition:
+        values = dict(properties)
+        tls_raw = values.pop("tls_options_json", "{}") or "{}"
+        try:
+            tls_options = json.loads(tls_raw)
+        except (TypeError, ValueError):
+            tls_options = {}
+        known = {
+            "uid",
+            "name_en",
+            "name_zh",
+            "type",
+            "host",
+            "port",
+            "database",
+            "schema",
+            "credential_ref",
+            "credential_version",
+            "pool_size",
+            "max_overflow",
+            "status",
+            "desc",
+        }
+        return DataSourceDefinition(
+            uid=values.get("uid"),
+            name_en=values.get("name_en", ""),
+            name_zh=values.get("name_zh"),
+            database_type=values.get("type", ""),
+            host=values.get("host", ""),
+            port=values.get("port", 0),
+            database=values.get("database", ""),
+            schema=values.get("schema"),
+            credential_ref=values.get("credential_ref"),
+            credential_version=values.get("credential_version"),
+            pool_size=values.get("pool_size"),
+            max_overflow=values.get("max_overflow"),
+            tls_options=tls_options if isinstance(tls_options, dict) else {},
+            status=bool(values.get("status", True)),
+            description=values.get("desc"),
+            extra_properties={
+                key: value
+                for key, value in values.items()
+                if key not in known and key != "_id"
+            },
+        )
+
+    def get(self, uid: str) -> Optional[DataSourceDefinition]:
+        record = self.session.run(
+            """
+            MATCH (n:DataSource {uid: $uid})
+            RETURN properties(n) AS properties
+            """,
+            {"uid": str(uid)},
+        ).single()
+        if record is None:
+            return None
+        return self._from_properties(dict(record["properties"]))
+
+    def list(self, filters: DataSourceFilters = None):
+        filters = filters or DataSourceFilters()
+        clauses = []
+        parameters = {}
+        mapping = {
+            "uid": "n.uid",
+            "name_en": "n.name_en",
+            "name_zh": "n.name_zh",
+            "database_type": "n.type",
+            "status": "n.status",
+        }
+        for field_name, property_name in mapping.items():
+            value = getattr(filters, field_name)
+            if value is not None:
+                clauses.append(f"{property_name} = ${field_name}")
+                parameters[field_name] = value
+        where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
+        records = self.session.run(
+            f"""
+            MATCH (n:DataSource)
+            {where}
+            RETURN properties(n) AS properties
+            ORDER BY n.name_en
+            """,
+            parameters,
+        )
+        return [
+            self._from_properties(dict(record["properties"]))
+            for record in records
+        ]
+
+    def save(self, definition: DataSourceDefinition) -> DataSourceDefinition:
+        uid_holder = {"uid": definition.uid} if definition.uid else {}
+        uid = ensure_governance_uid(uid_holder)
+        saved = definition.with_uid(uid)
+        properties = self._to_properties(saved)
+        self.session.run(
+            """
+            MERGE (n:DataSource {uid: $uid})
+            SET n = $properties
+            RETURN properties(n) AS properties
+            """,
+            {"uid": uid, "properties": properties},
+        )
+        return saved
+
+    def delete(self, uid: str) -> bool:
+        record = self.session.run(
+            """
+            MATCH (n:DataSource {uid: $uid})
+            WITH n, count(n) AS found
+            DETACH DELETE n
+            RETURN found AS deleted_count
+            """,
+            {"uid": str(uid)},
+        ).single()
+        return bool(record and record["deleted_count"])
+
+    def credential_references(self):
+        records = self.session.run(
+            """
+            MATCH (n:DataSource)
+            WHERE n.uid IS NOT NULL
+            RETURN n.uid AS uid, n.credential_ref AS credential_ref,
+                   n.credential_version AS credential_version
+            """
+        )
+        return {
+            str(record["uid"]): (
+                record["credential_ref"],
+                int(record["credential_version"]),
+            )
+            for record in records
+            if record.get("credential_ref") is not None
+            and record.get("credential_version") is not None
+        }
+
+    def remove_legacy_secret_fields(self, uid: str) -> None:
+        self.session.run(
+            """
+            MATCH (n:DataSource {uid: $uid})
+            REMOVE n.username, n.password, n.conn_str,
+                   n.connection_string, n.connection_url
+            """,
+            {"uid": str(uid)},
+        )

+ 64 - 0
app/core/data_source/errors.py

@@ -0,0 +1,64 @@
+"""Safe public errors for external data-source operations."""
+
+
+class DataSourceError(RuntimeError):
+    code = "DATASOURCE_ERROR"
+    http_status = 500
+    default_message = "data source operation failed"
+
+    def __init__(self, message=None):
+        super().__init__(message or self.default_message)
+
+
+class DataSourceNotFound(DataSourceError):
+    code = "DATASOURCE_NOT_FOUND"
+    http_status = 404
+    default_message = "data source was not found"
+
+
+class DataSourceTypeUnsupported(DataSourceError):
+    code = "DATASOURCE_TYPE_UNSUPPORTED"
+    http_status = 400
+    default_message = "data source type is not supported"
+
+
+class DataSourceConfigurationInvalid(DataSourceError):
+    code = "DATASOURCE_CONFIGURATION_INVALID"
+    http_status = 400
+    default_message = "data source configuration is invalid"
+
+
+class DataSourceCredentialUnavailable(DataSourceError):
+    code = "DATASOURCE_CREDENTIAL_UNAVAILABLE"
+    http_status = 503
+    default_message = "data source credential is unavailable"
+
+
+class DataSourceConnectionFailed(DataSourceError):
+    code = "DATASOURCE_CONNECTION_FAILED"
+    http_status = 503
+    default_message = "data source connection failed"
+
+
+class DataSourcePoolTimeout(DataSourceError):
+    code = "DATASOURCE_POOL_TIMEOUT"
+    http_status = 503
+    default_message = "data source connection pool timed out"
+
+
+class DataSourceCircuitOpen(DataSourceError):
+    code = "DATASOURCE_CIRCUIT_OPEN"
+    http_status = 503
+    default_message = "data source circuit is open"
+
+
+class DataSourceQueryTimeout(DataSourceError):
+    code = "DATASOURCE_QUERY_TIMEOUT"
+    http_status = 504
+    default_message = "data source query timed out"
+
+
+class DataSourceReadOnlyViolation(DataSourceError):
+    code = "DATASOURCE_READ_ONLY_VIOLATION"
+    http_status = 409
+    default_message = "data source operation violates read-only policy"

+ 160 - 0
app/core/data_source/manager.py

@@ -0,0 +1,160 @@
+"""Public context-managed access to external business data sources."""
+
+import time
+from contextlib import contextmanager
+
+from sqlalchemy.exc import DBAPIError, OperationalError, TimeoutError
+
+from app.core.data_source.adapters.base import PURPOSES
+from app.core.data_source.errors import (
+    DataSourceConfigurationInvalid,
+    DataSourceConnectionFailed,
+    DataSourceNotFound,
+    DataSourcePoolTimeout,
+    DataSourceQueryTimeout,
+    DataSourceReadOnlyViolation,
+)
+from app.core.data_source.models import PoolKey
+
+
+def _driver_error_code(error):
+    original = getattr(error, "orig", error)
+    code = getattr(original, "pgcode", None)
+    if code:
+        return str(code)
+    args = getattr(original, "args", ())
+    return str(args[0]) if args else ""
+
+
+def _translate_query_error(error):
+    code = _driver_error_code(error)
+    if code in {"57014", "3024", "1317"}:
+        return DataSourceQueryTimeout()
+    if code in {"25006", "1792"}:
+        return DataSourceReadOnlyViolation()
+    return error
+
+
+class ManagedConnection:
+    def __init__(self, connection, *, key, registry, clock=None):
+        self._connection = connection
+        self._key = key
+        self._registry = registry
+        self._clock = clock or time.monotonic
+
+    def execute(self, statement, *args, **kwargs):
+        started = self._clock()
+        try:
+            return self._connection.execute(statement, *args, **kwargs)
+        except DBAPIError as exc:
+            translated = _translate_query_error(exc)
+            if translated is exc:
+                raise
+            raise translated from exc
+        finally:
+            self._registry.record_query_duration(
+                self._key,
+                (self._clock() - started) * 1000,
+            )
+
+    def __getattr__(self, name):
+        return getattr(self._connection, name)
+
+
+class DataSourceConnectionManager:
+    def __init__(
+        self,
+        *,
+        definitions,
+        credentials,
+        platform_session,
+        adapter_resolver,
+        registry,
+        settings_resolver,
+        clock=None,
+    ):
+        self.definitions = definitions
+        self.credentials = credentials
+        self.platform_session = platform_session
+        self.adapter_resolver = adapter_resolver
+        self.registry = registry
+        self.settings_resolver = settings_resolver
+        self._clock = clock or time.monotonic
+
+    @contextmanager
+    def connect(self, data_source_uid, purpose):
+        if purpose not in PURPOSES or purpose == "connection_test":
+            raise DataSourceConfigurationInvalid(
+                "unsupported pooled connection purpose"
+            )
+        definition = self.definitions.get(data_source_uid)
+        if not definition:
+            raise DataSourceNotFound()
+        credential = self.credentials.get_active(
+            self.platform_session(),
+            data_source_uid,
+            definition.credential_version,
+        )
+        adapter = self.adapter_resolver(definition.database_type)
+        settings = self.settings_resolver(definition.pool_overrides())
+        key = PoolKey(
+            data_source_uid=str(data_source_uid),
+            credential_version=int(definition.credential_version),
+            config_fingerprint=definition.connection_fingerprint(),
+        )
+
+        with self.registry.lease(
+            key,
+            lambda: adapter.create_pooled_engine(
+                definition,
+                credential,
+                settings,
+            ),
+        ) as engine:
+            breaker = self.registry.breaker_for(key)
+            try:
+                with breaker.probe_permission():
+                    started = self._clock()
+                    connection = engine.connect()
+                    self.registry.add_checkout_wait(
+                        key,
+                        (self._clock() - started) * 1000,
+                    )
+                    self.registry.record_connection_success(key)
+            except TimeoutError as exc:
+                self.registry.record_pool_timeout(key)
+                self.registry.record_connection_failure(key)
+                raise DataSourcePoolTimeout() from exc
+            except (OperationalError, DBAPIError) as exc:
+                self.registry.record_connection_failure(key)
+                raise DataSourceConnectionFailed() from exc
+
+            with connection:
+                transaction = connection.begin()
+                try:
+                    adapter.configure_transaction(connection, purpose)
+                    yield ManagedConnection(
+                        connection,
+                        key=key,
+                        registry=self.registry,
+                        clock=self._clock,
+                    )
+                    if purpose == "dataflow_write":
+                        transaction.commit()
+                    else:
+                        transaction.rollback()
+                except Exception:
+                    try:
+                        transaction.rollback()
+                    except Exception:
+                        pass
+                    raise
+
+    def invalidate(self, data_source_uid, reason):
+        self.registry.invalidate(data_source_uid, reason)
+
+    def snapshot(self):
+        return self.registry.snapshot()
+
+    def close(self):
+        self.registry.close_all()

+ 133 - 0
app/core/data_source/models.py

@@ -0,0 +1,133 @@
+"""Immutable value objects used by external data-source services."""
+
+import hashlib
+import json
+from dataclasses import dataclass, field, replace
+from types import MappingProxyType
+from typing import Mapping, Optional
+
+
+@dataclass(frozen=True)
+class DataSourceCredential:
+    username: str
+    password: str = field(repr=False)
+    options: Mapping[str, str] = field(default_factory=dict)
+
+    def __post_init__(self):
+        object.__setattr__(
+            self,
+            "options",
+            MappingProxyType(dict(self.options)),
+        )
+
+
+@dataclass(frozen=True)
+class SealedCredential:
+    id: str
+    data_source_uid: str
+    credential_version: int
+    encrypted_payload: bytes = field(repr=False)
+    nonce: bytes = field(repr=False)
+    key_version: str
+    status: str = "active"
+
+
+@dataclass(frozen=True)
+class DataSourceDefinition:
+    uid: Optional[str]
+    name_en: str
+    database_type: str
+    host: str
+    port: int
+    database: str
+    name_zh: Optional[str] = None
+    schema: Optional[str] = None
+    credential_ref: Optional[str] = None
+    credential_version: Optional[int] = None
+    pool_size: Optional[int] = None
+    max_overflow: Optional[int] = None
+    tls_options: Mapping[str, str] = field(default_factory=dict)
+    status: bool = True
+    description: Optional[str] = None
+    extra_properties: Mapping[str, object] = field(default_factory=dict)
+
+    def __post_init__(self):
+        object.__setattr__(
+            self,
+            "database_type",
+            str(self.database_type).strip().lower(),
+        )
+        object.__setattr__(self, "port", int(self.port))
+        object.__setattr__(
+            self,
+            "tls_options",
+            MappingProxyType(dict(self.tls_options)),
+        )
+        object.__setattr__(
+            self,
+            "extra_properties",
+            MappingProxyType(dict(self.extra_properties)),
+        )
+
+    def with_uid(self, uid: str):
+        return replace(self, uid=uid)
+
+    def pool_overrides(self) -> dict:
+        return {
+            key: value
+            for key, value in {
+                "pool_size": self.pool_size,
+                "max_overflow": self.max_overflow,
+            }.items()
+            if value is not None
+        }
+
+    def connection_fingerprint(self) -> str:
+        payload = {
+            "database_type": self.database_type,
+            "host": self.host,
+            "port": self.port,
+            "database": self.database,
+            "schema": self.schema,
+            "pool_overrides": self.pool_overrides(),
+            "tls_options": dict(self.tls_options),
+        }
+        canonical = json.dumps(
+            payload,
+            sort_keys=True,
+            separators=(",", ":"),
+        )
+        return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
+
+
+@dataclass(frozen=True)
+class PoolKey:
+    data_source_uid: str
+    credential_version: int
+    config_fingerprint: str
+
+
+@dataclass(frozen=True)
+class PoolStatus:
+    data_source_uid: str
+    credential_version: int
+    config_fingerprint: str
+    pool_state: str
+    leases: int
+    draining: bool
+    created_at: float
+    last_used_at: float
+    pool_size: int = 0
+    checked_out: int = 0
+    checked_out_peak: int = 0
+    checked_in: int = 0
+    overflow: int = 0
+    checkout_wait_ms: float = 0.0
+    connection_created_total: int = 0
+    connection_failed_total: int = 0
+    pool_timeout_total: int = 0
+    invalidated_total: int = 0
+    query_total: int = 0
+    last_query_duration_ms: float = 0.0
+    consecutive_failures: int = 0
+    circuit_open_until: float = 0.0

+ 289 - 0
app/core/data_source/pool_registry.py

@@ -0,0 +1,289 @@
+"""Worker-local lazy SQLAlchemy Engine registry."""
+
+import threading
+import time
+from contextlib import contextmanager
+from dataclasses import dataclass, field
+
+from sqlalchemy import event
+from sqlalchemy.engine import Engine
+
+from app.core.data_source.circuit_breaker import CircuitBreaker
+from app.core.data_source.models import PoolKey, PoolStatus
+
+
+@dataclass
+class PoolEntry:
+    key: PoolKey
+    engine: object
+    breaker: CircuitBreaker
+    created_at: float
+    last_used_at: float
+    leases: int = 0
+    draining: bool = False
+    drain_deadline: float = 0.0
+    checkout_wait_ms: float = 0.0
+    metrics: dict = field(
+        default_factory=lambda: {
+            "connection_created_total": 0,
+            "connection_failed_total": 0,
+            "pool_timeout_total": 0,
+            "invalidated_total": 0,
+            "checkout_total": 0,
+            "checkin_total": 0,
+            "checked_out_peak": 0,
+            "query_total": 0,
+            "last_query_duration_ms": 0.0,
+        }
+    )
+
+
+class PoolRegistry:
+    def __init__(self, settings, *, clock=None):
+        self.settings = dict(settings)
+        self._clock = clock or time.monotonic
+        self._lock = threading.RLock()
+        self._entries = {}
+        self._draining = []
+
+    def _attach_metrics(self, entry):
+        if not isinstance(entry.engine, Engine):
+            return
+
+        def increment(name):
+            with self._lock:
+                entry.metrics[name] += 1
+
+        def record_checkout(*_args):
+            with self._lock:
+                entry.metrics["checkout_total"] += 1
+                entry.metrics["checked_out_peak"] = max(
+                    entry.metrics["checked_out_peak"],
+                    self._pool_value(entry.engine, "checkedout"),
+                )
+
+        event.listen(
+            entry.engine,
+            "connect",
+            lambda *_args: increment("connection_created_total"),
+        )
+        event.listen(
+            entry.engine,
+            "checkout",
+            record_checkout,
+        )
+        event.listen(
+            entry.engine,
+            "checkin",
+            lambda *_args: increment("checkin_total"),
+        )
+        event.listen(
+            entry.engine,
+            "invalidate",
+            lambda *_args: increment("invalidated_total"),
+        )
+
+    def _dispose(self, entry):
+        if getattr(entry.engine, "_dataops_disposed", False):
+            return
+        entry.engine.dispose()
+        try:
+            setattr(entry.engine, "_dataops_disposed", True)
+        except Exception:
+            pass
+
+    def _start_draining_locked(self, entry):
+        if entry.draining:
+            return
+        self._entries.pop(entry.key, None)
+        entry.draining = True
+        entry.drain_deadline = self._clock() + float(
+            self.settings.get("drain_timeout", 30)
+        )
+        entry.metrics["invalidated_total"] += 1
+        if entry.leases == 0:
+            self._dispose(entry)
+        else:
+            self._draining.append(entry)
+
+    def _entry_locked(self, key, engine_factory):
+        entry = self._entries.get(key)
+        if entry is not None:
+            return entry
+        for old in list(self._entries.values()):
+            if old.key.data_source_uid == key.data_source_uid:
+                self._start_draining_locked(old)
+        now = self._clock()
+        entry = PoolEntry(
+            key=key,
+            engine=engine_factory(),
+            breaker=CircuitBreaker(clock=self._clock),
+            created_at=now,
+            last_used_at=now,
+        )
+        self._entries[key] = entry
+        self._attach_metrics(entry)
+        return entry
+
+    @contextmanager
+    def lease(self, key, engine_factory):
+        with self._lock:
+            entry = self._entry_locked(key, engine_factory)
+            entry.leases += 1
+            entry.last_used_at = self._clock()
+        try:
+            yield entry.engine
+        finally:
+            with self._lock:
+                entry.leases = max(0, entry.leases - 1)
+                entry.last_used_at = self._clock()
+                if entry.draining and entry.leases == 0:
+                    self._dispose(entry)
+                    if entry in self._draining:
+                        self._draining.remove(entry)
+
+    def invalidate(self, data_source_uid, reason):
+        del reason  # The caller records the bounded audit reason.
+        with self._lock:
+            for entry in list(self._entries.values()):
+                if entry.key.data_source_uid == data_source_uid:
+                    self._start_draining_locked(entry)
+
+    def expire_draining(self, now=None):
+        now = self._clock() if now is None else float(now)
+        disposed = 0
+        with self._lock:
+            for entry in list(self._draining):
+                if now >= entry.drain_deadline:
+                    self._dispose(entry)
+                    self._draining.remove(entry)
+                    disposed += 1
+        return disposed
+
+    def evict_idle(self, now=None):
+        now = self._clock() if now is None else float(now)
+        evicted = []
+        with self._lock:
+            idle = [
+                entry
+                for entry in self._entries.values()
+                if entry.leases == 0 and not entry.draining
+            ]
+            ttl = float(self.settings.get("idle_ttl", 900))
+            for entry in idle:
+                if now - entry.last_used_at >= ttl:
+                    evicted.append(entry)
+
+            remaining = [
+                entry for entry in idle if entry not in evicted
+            ]
+            maximum = int(self.settings.get("max_idle_pools", 20))
+            overflow = max(0, len(remaining) - maximum)
+            if overflow:
+                evicted.extend(
+                    sorted(remaining, key=lambda item: item.last_used_at)[
+                        :overflow
+                    ]
+                )
+
+            for entry in evicted:
+                self._entries.pop(entry.key, None)
+                self._dispose(entry)
+        return len(evicted)
+
+    def close_all(self):
+        with self._lock:
+            entries = list(self._entries.values()) + list(self._draining)
+            self._entries.clear()
+            self._draining.clear()
+            for entry in entries:
+                self._dispose(entry)
+
+    def breaker_for(self, key):
+        with self._lock:
+            entry = self._entries.get(key)
+            return entry.breaker if entry is not None else None
+
+    def add_checkout_wait(self, key, elapsed_ms):
+        with self._lock:
+            entry = self._entries.get(key)
+            if entry is not None:
+                entry.checkout_wait_ms = float(elapsed_ms)
+
+    def record_connection_failure(self, key):
+        with self._lock:
+            entry = self._entries.get(key)
+            if entry is not None:
+                entry.metrics["connection_failed_total"] += 1
+                entry.breaker.record_failure()
+
+    def record_connection_success(self, key):
+        with self._lock:
+            entry = self._entries.get(key)
+            if entry is not None:
+                entry.breaker.record_success()
+
+    def record_pool_timeout(self, key):
+        with self._lock:
+            entry = self._entries.get(key)
+            if entry is not None:
+                entry.metrics["pool_timeout_total"] += 1
+
+    def record_query_duration(self, key, elapsed_ms):
+        with self._lock:
+            entry = self._entries.get(key)
+            if entry is not None:
+                entry.metrics["query_total"] += 1
+                entry.metrics["last_query_duration_ms"] = float(elapsed_ms)
+
+    @staticmethod
+    def _pool_value(engine, name):
+        pool = getattr(engine, "pool", None)
+        method = getattr(pool, name, None)
+        if not callable(method):
+            return 0
+        try:
+            return int(method())
+        except Exception:
+            return 0
+
+    def _status(self, entry):
+        breaker = entry.breaker.snapshot()
+        if entry.draining:
+            state = "draining"
+        elif breaker["state"] == "open":
+            state = "open"
+        elif breaker["consecutive_failures"]:
+            state = "degraded"
+        else:
+            state = "healthy"
+        metrics = entry.metrics
+        return PoolStatus(
+            data_source_uid=entry.key.data_source_uid,
+            credential_version=entry.key.credential_version,
+            config_fingerprint=entry.key.config_fingerprint,
+            pool_state=state,
+            leases=entry.leases,
+            draining=entry.draining,
+            created_at=entry.created_at,
+            last_used_at=entry.last_used_at,
+            pool_size=self._pool_value(entry.engine, "size"),
+            checked_out=self._pool_value(entry.engine, "checkedout"),
+            checked_out_peak=metrics["checked_out_peak"],
+            checked_in=self._pool_value(entry.engine, "checkedin"),
+            overflow=self._pool_value(entry.engine, "overflow"),
+            checkout_wait_ms=entry.checkout_wait_ms,
+            connection_created_total=metrics["connection_created_total"],
+            connection_failed_total=metrics["connection_failed_total"],
+            pool_timeout_total=metrics["pool_timeout_total"],
+            invalidated_total=metrics["invalidated_total"],
+            query_total=metrics["query_total"],
+            last_query_duration_ms=metrics["last_query_duration_ms"],
+            consecutive_failures=breaker["consecutive_failures"],
+            circuit_open_until=breaker["open_until"],
+        )
+
+    def snapshot(self):
+        with self._lock:
+            entries = list(self._entries.values()) + list(self._draining)
+            return [self._status(entry) for entry in entries]

+ 105 - 0
app/core/data_source/redaction.py

@@ -0,0 +1,105 @@
+"""Central redaction helpers for data-source requests, logs and responses."""
+
+import re
+from collections.abc import Mapping
+from urllib.parse import urlsplit, urlunsplit
+
+
+REDACTED = "[redacted]"
+SENSITIVE_KEYS = {
+    "password",
+    "passwd",
+    "credential",
+    "credentials",
+    "credential_ref",
+    "api_key",
+    "apikey",
+    "token",
+    "authorization",
+    "encrypted_payload",
+    "nonce",
+    "conn_str",
+    "connection_string",
+    "connection_url",
+}
+RESPONSE_SECRET_KEYS = SENSITIVE_KEYS | {"username"}
+_URL_IN_TEXT = re.compile(
+    r"(?P<scheme>[a-zA-Z][a-zA-Z0-9+.-]*://)"
+    r"(?P<userinfo>[^\s/@]+)@"
+)
+_NAMED_SECRET = re.compile(
+    r"(?i)(password|passwd|api[_-]?key|token|authorization)"
+    r"(\s*[=:]\s*)([^\s,;]+)"
+)
+
+
+def _redact_url(value: str) -> str:
+    try:
+        parsed = urlsplit(value)
+    except (TypeError, ValueError):
+        return value
+    if not parsed.scheme or parsed.hostname is None or "@" not in parsed.netloc:
+        return _URL_IN_TEXT.sub(r"\g<scheme>[redacted]@", value)
+
+    host = parsed.hostname
+    if ":" in host and not host.startswith("["):
+        host = f"[{host}]"
+    port = f":{parsed.port}" if parsed.port is not None else ""
+    return urlunsplit(
+        (
+            parsed.scheme,
+            f"{REDACTED}@{host}{port}",
+            parsed.path,
+            parsed.query,
+            parsed.fragment,
+        )
+    )
+
+
+def redact_mapping(value):
+    """Return a recursively redacted copy suitable for logs."""
+    if isinstance(value, Mapping):
+        redacted = {}
+        for key, item in value.items():
+            normalized = str(key).strip().lower()
+            redacted[key] = (
+                REDACTED
+                if normalized in SENSITIVE_KEYS
+                else redact_mapping(item)
+            )
+        return redacted
+    if isinstance(value, list):
+        return [redact_mapping(item) for item in value]
+    if isinstance(value, tuple):
+        return tuple(redact_mapping(item) for item in value)
+    if isinstance(value, str):
+        return _redact_url(value)
+    return value
+
+
+def strip_sensitive_fields(value):
+    """Remove secret field names and values from API response structures."""
+    if isinstance(value, Mapping):
+        return {
+            key: strip_sensitive_fields(item)
+            for key, item in value.items()
+            if str(key).strip().lower() not in RESPONSE_SECRET_KEYS
+        }
+    if isinstance(value, list):
+        return [strip_sensitive_fields(item) for item in value]
+    if isinstance(value, tuple):
+        return tuple(strip_sensitive_fields(item) for item in value)
+    if isinstance(value, str):
+        return _redact_url(value)
+    return value
+
+
+def sanitize_exception(error: BaseException, limit: int = 1000) -> str:
+    """Return a bounded error classification with credentials removed."""
+    value = str(error)
+    value = _URL_IN_TEXT.sub(r"\g<scheme>[redacted]@", value)
+    value = _NAMED_SECRET.sub(
+        lambda match: f"{match.group(1)}{match.group(2)}{REDACTED}",
+        value,
+    )
+    return value[: max(0, int(limit))]

+ 112 - 0
app/core/data_source/runtime.py

@@ -0,0 +1,112 @@
+"""One lazily constructed data-source manager per backend Worker process."""
+
+import threading
+
+
+_lock = threading.Lock()
+_manager = None
+_factory = None
+
+
+class _Neo4jDefinitionGateway:
+    def _call(self, method, *args, **kwargs):
+        from app.core.data_source.definitions import (
+            DataSourceDefinitionRepository,
+        )
+        from app.services.neo4j_driver import neo4j_driver
+
+        with neo4j_driver.get_session() as session:
+            repository = DataSourceDefinitionRepository(session)
+            return getattr(repository, method)(*args, **kwargs)
+
+    def get(self, *args, **kwargs):
+        return self._call("get", *args, **kwargs)
+
+    def list(self, *args, **kwargs):
+        return self._call("list", *args, **kwargs)
+
+    def save(self, *args, **kwargs):
+        return self._call("save", *args, **kwargs)
+
+    def delete(self, *args, **kwargs):
+        return self._call("delete", *args, **kwargs)
+
+    def credential_references(self, *args, **kwargs):
+        return self._call("credential_references", *args, **kwargs)
+
+    def remove_legacy_secret_fields(self, *args, **kwargs):
+        return self._call(
+            "remove_legacy_secret_fields",
+            *args,
+            **kwargs,
+        )
+
+
+def _build_default_manager():
+    from flask import current_app
+
+    from app import db
+    from app.config.config import datasource_pool_settings
+    from app.core.data_source.adapters import adapter_for
+    from app.core.data_source.credentials import (
+        CredentialCodec,
+        DataSourceCredentialRepository,
+    )
+    from app.core.data_source.manager import DataSourceConnectionManager
+    from app.core.data_source.pool_registry import PoolRegistry
+
+    codec = CredentialCodec.from_base64(
+        current_app.config.get("DATASOURCE_CREDENTIAL_MASTER_KEY", ""),
+        current_app.config.get("DATASOURCE_CREDENTIAL_KEY_VERSION", "v1"),
+    )
+    base_settings = datasource_pool_settings()
+    registry_settings = {
+        **base_settings,
+        "drain_timeout": 30,
+    }
+    certificate_dir = current_app.config.get("DATASOURCE_CERT_DIR")
+    return DataSourceConnectionManager(
+        definitions=_Neo4jDefinitionGateway(),
+        credentials=DataSourceCredentialRepository(codec),
+        platform_session=lambda: db.session,
+        adapter_resolver=lambda database_type: adapter_for(
+            database_type,
+            certificate_dir=certificate_dir,
+        ),
+        registry=PoolRegistry(registry_settings),
+        settings_resolver=datasource_pool_settings,
+    )
+
+
+def configure_data_source_runtime(factory):
+    """Replace the process-local factory and clear any current manager."""
+    global _factory, _manager
+    with _lock:
+        if _manager is not None:
+            _manager.close()
+        _manager = None
+        _factory = factory
+
+
+def get_data_source_manager():
+    global _manager
+    if _manager is not None:
+        return _manager
+    with _lock:
+        if _manager is None:
+            _manager = (_factory or _build_default_manager)()
+        return _manager
+
+
+def peek_data_source_manager():
+    """Return the current Worker manager without constructing one."""
+    return _manager
+
+
+def close_data_source_runtime():
+    global _manager
+    with _lock:
+        manager = _manager
+        _manager = None
+    if manager is not None:
+        manager.close()

+ 381 - 0
app/core/data_source/service.py

@@ -0,0 +1,381 @@
+"""Secure cross-store lifecycle orchestration for external data sources."""
+
+from dataclasses import replace
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.data_source.definitions import DataSourceFilters
+from app.core.data_source.errors import (
+    DataSourceConfigurationInvalid,
+    DataSourceError,
+    DataSourceNotFound,
+)
+from app.core.data_source.models import (
+    DataSourceCredential,
+    DataSourceDefinition,
+)
+
+
+TYPE_ALIASES = {
+    "postgres": "postgresql",
+    "postgresql": "postgresql",
+    "mysql": "mysql",
+}
+POOL_INVALIDATION_REASONS = {
+    "admin_reset",
+    "configuration_changed",
+    "credential_rotated",
+}
+
+
+class DataSourceService:
+    def __init__(
+        self,
+        *,
+        definitions,
+        credentials,
+        platform_session,
+        adapter_resolver,
+        connection_manager,
+        settings_resolver,
+        outbox_enqueuer,
+    ):
+        self.definitions = definitions
+        self.credentials = credentials
+        self.platform_session = platform_session
+        self.adapter_resolver = adapter_resolver
+        self.connection_manager = connection_manager
+        self.settings_resolver = settings_resolver
+        self.outbox_enqueuer = outbox_enqueuer
+
+    @staticmethod
+    def _database_type(payload):
+        requested = str(payload.get("type") or "").strip().lower()
+        database_type = TYPE_ALIASES.get(requested)
+        if database_type is None:
+            raise DataSourceConfigurationInvalid(
+                "only PostgreSQL and MySQL data sources are supported"
+            )
+        return database_type
+
+    @staticmethod
+    def _required_text(payload, name):
+        value = str(payload.get(name) or "").strip()
+        if not value:
+            raise DataSourceConfigurationInvalid(f"{name} is required")
+        return value
+
+    @classmethod
+    def _definition(
+        cls,
+        payload,
+        *,
+        uid,
+        credential_version,
+        existing=None,
+    ):
+        database_type = cls._database_type(payload)
+        try:
+            port = int(payload.get("port"))
+        except (TypeError, ValueError) as exc:
+            raise DataSourceConfigurationInvalid("port is invalid") from exc
+        if port < 1 or port > 65535:
+            raise DataSourceConfigurationInvalid("port is invalid")
+
+        tls_options = payload.get("tls_options") or {}
+        if not isinstance(tls_options, dict):
+            raise DataSourceConfigurationInvalid("tls_options is invalid")
+        return DataSourceDefinition(
+            uid=uid,
+            name_en=cls._required_text(payload, "name_en"),
+            name_zh=payload.get("name_zh"),
+            database_type=database_type,
+            host=cls._required_text(payload, "host"),
+            port=port,
+            database=cls._required_text(payload, "database"),
+            schema=payload.get("schema"),
+            credential_ref=uid,
+            credential_version=credential_version,
+            pool_size=payload.get("pool_size"),
+            max_overflow=payload.get("max_overflow"),
+            tls_options=tls_options,
+            status=bool(payload.get("status", True)),
+            description=payload.get("desc"),
+            extra_properties=(
+                dict(existing.extra_properties) if existing else {}
+            ),
+        )
+
+    def _effective_credential(self, payload, existing):
+        username = payload.get("username")
+        password = payload.get("password")
+        current = None
+        if existing is not None and (not username or not password):
+            current = self.credentials.get_active(
+                self.platform_session(),
+                existing.uid,
+                existing.credential_version,
+            )
+        username = str(username or (current.username if current else "")).strip()
+        password = str(password or (current.password if current else ""))
+        if not username or not password:
+            raise DataSourceConfigurationInvalid(
+                "username and password are required"
+            )
+        options = payload.get("credential_options") or (
+            dict(current.options) if current else {}
+        )
+        if not isinstance(options, dict):
+            raise DataSourceConfigurationInvalid(
+                "credential options are invalid"
+            )
+        return DataSourceCredential(username, password, options)
+
+    def save(self, payload, *, actor_uid):
+        if not isinstance(payload, dict):
+            raise DataSourceConfigurationInvalid()
+        requested_uid = str(payload.get("uid") or "").strip() or None
+        existing = (
+            self.definitions.get(requested_uid)
+            if requested_uid is not None
+            else None
+        )
+        if requested_uid is not None and existing is None:
+            raise DataSourceNotFound()
+        uid = requested_uid or new_governance_uid()
+        credential = self._effective_credential(payload, existing)
+
+        candidate = self._definition(
+            payload,
+            uid=uid,
+            credential_version=(
+                existing.credential_version if existing else 1
+            ),
+            existing=existing,
+        )
+        adapter = self.adapter_resolver(candidate.database_type)
+        adapter.validate_options(candidate.tls_options)
+        settings = self.settings_resolver(candidate.pool_overrides())
+        adapter.test_connection(
+            candidate,
+            credential,
+            query_timeout=settings["query_timeout"],
+        )
+
+        session = self.platform_session()
+        previous_version = (
+            existing.credential_version if existing is not None else None
+        )
+        try:
+            sealed = self.credentials.create_version(
+                session,
+                data_source_uid=uid,
+                credential=credential,
+                actor_uid=actor_uid,
+            )
+            self.outbox_enqueuer(
+                session,
+                aggregate_type="datasource",
+                aggregate_id=uid,
+                event_type="datasource.credential_version_created",
+                payload={
+                    "uid": uid,
+                    "database_type": candidate.database_type,
+                    "credential_version": sealed.credential_version,
+                },
+            )
+            session.commit()
+        except Exception:
+            session.rollback()
+            raise
+
+        candidate = replace(
+            candidate,
+            credential_version=sealed.credential_version,
+        )
+        try:
+            saved = self.definitions.save(candidate)
+        except Exception as graph_error:
+            try:
+                self.credentials.compensate_failed_activation(
+                    session,
+                    data_source_uid=uid,
+                    failed_version=sealed.credential_version,
+                    restore_version=previous_version,
+                    actor_uid=actor_uid,
+                )
+                session.commit()
+            except Exception as compensation_error:
+                session.rollback()
+                raise DataSourceError(
+                    "data source save requires reconciliation"
+                ) from compensation_error
+            raise DataSourceError(
+                "data source definition could not be saved"
+            ) from graph_error
+
+        if existing is not None:
+            self.connection_manager.invalidate(
+                uid,
+                "configuration_changed",
+            )
+        return saved, existing is None
+
+    def list(self, payload):
+        payload = payload if isinstance(payload, dict) else {}
+        filters = DataSourceFilters(
+            uid=payload.get("uid"),
+            name_en=payload.get("name_en"),
+            name_zh=payload.get("name_zh"),
+            database_type=payload.get("type"),
+            status=payload.get("status"),
+        )
+        return self.definitions.list(filters)
+
+    def test_connection(self, payload):
+        if not isinstance(payload, dict):
+            raise DataSourceConfigurationInvalid()
+        uid = str(payload.get("uid") or "").strip() or None
+        existing = self.definitions.get(uid) if uid else None
+        if uid and existing is None:
+            raise DataSourceNotFound()
+        effective_uid = uid or new_governance_uid()
+        credential = self._effective_credential(payload, existing)
+        candidate = self._definition(
+            payload,
+            uid=effective_uid,
+            credential_version=(
+                existing.credential_version if existing else 1
+            ),
+            existing=existing,
+        )
+        adapter = self.adapter_resolver(candidate.database_type)
+        settings = self.settings_resolver(candidate.pool_overrides())
+        adapter.test_connection(
+            candidate,
+            credential,
+            query_timeout=settings["query_timeout"],
+        )
+        return {"connected": True, "message": "连接测试成功"}
+
+    def delete(self, uid, *, actor_uid):
+        uid = str(uid or "").strip()
+        existing = self.definitions.get(uid)
+        if existing is None:
+            raise DataSourceNotFound()
+        self.connection_manager.invalidate(uid, "datasource_deleted")
+        if not self.definitions.delete(uid):
+            raise DataSourceNotFound()
+
+        session = self.platform_session()
+        try:
+            revoked = self.credentials.revoke_all(
+                session,
+                data_source_uid=uid,
+                actor_uid=actor_uid,
+            )
+            self.outbox_enqueuer(
+                session,
+                aggregate_type="datasource",
+                aggregate_id=uid,
+                event_type="datasource.deleted",
+                payload={"uid": uid},
+            )
+            session.commit()
+        except Exception as exc:
+            session.rollback()
+            raise DataSourceError(
+                "data source deletion requires reconciliation"
+            ) from exc
+        return {"uid": uid, "revoked_credential_versions": revoked}
+
+    def pool_statuses(self):
+        return self.connection_manager.snapshot()
+
+    def pool_status(self, uid):
+        uid = str(uid or "").strip()
+        for status in self.pool_statuses():
+            if status.data_source_uid == uid:
+                return status
+        raise DataSourceNotFound()
+
+    def invalidate_pool(self, uid, *, reason, actor_uid):
+        uid = str(uid or "").strip()
+        if reason not in POOL_INVALIDATION_REASONS:
+            raise DataSourceConfigurationInvalid(
+                "pool invalidation reason is invalid"
+            )
+        if self.definitions.get(uid) is None:
+            raise DataSourceNotFound()
+        self.connection_manager.invalidate(uid, reason)
+        session = self.platform_session()
+        try:
+            self.credentials.record_pool_event(
+                session,
+                data_source_uid=uid,
+                actor_uid=actor_uid,
+                event_type="pool_invalidated",
+                safe_detail=f"pool invalidated: {reason}",
+            )
+            session.commit()
+        except Exception:
+            session.rollback()
+            raise
+        return {"uid": uid, "invalidated": True, "reason": reason}
+
+    @staticmethod
+    def serialize(definition):
+        return {
+            "uid": definition.uid,
+            "name_en": definition.name_en,
+            "name_zh": definition.name_zh,
+            "type": definition.database_type,
+            "host": definition.host,
+            "port": definition.port,
+            "database": definition.database,
+            "schema": definition.schema,
+            "pool_size": definition.pool_size,
+            "max_overflow": definition.max_overflow,
+            "status": definition.status,
+            "desc": definition.description,
+            "credential_configured": bool(
+                definition.credential_version
+            ),
+        }
+
+    @staticmethod
+    def serialize_pool_status(status):
+        return {
+            "data_source_uid": status.data_source_uid,
+            "credential_version": status.credential_version,
+            "pool_state": status.pool_state,
+            "pool_size": status.pool_size,
+            "checked_out": status.checked_out,
+            "checked_out_peak": status.checked_out_peak,
+            "checked_in": status.checked_in,
+            "overflow": status.overflow,
+            "leases": status.leases,
+            "checkout_wait_ms": status.checkout_wait_ms,
+            "last_used_at": status.last_used_at,
+            "connection_created_total": status.connection_created_total,
+            "connection_failed_total": status.connection_failed_total,
+            "pool_timeout_total": status.pool_timeout_total,
+            "invalidated_total": status.invalidated_total,
+            "query_total": status.query_total,
+            "last_query_duration_ms": status.last_query_duration_ms,
+            "consecutive_failures": status.consecutive_failures,
+            "circuit_open_until": status.circuit_open_until,
+        }
+
+
+def build_data_source_service(manager):
+    from app.core.events.outbox import enqueue_outbox
+
+    return DataSourceService(
+        definitions=manager.definitions,
+        credentials=manager.credentials,
+        platform_session=manager.platform_session,
+        adapter_resolver=manager.adapter_resolver,
+        connection_manager=manager,
+        settings_resolver=manager.settings_resolver,
+        outbox_enqueuer=enqueue_outbox,
+    )

+ 5 - 0
app/core/events/__init__.py

@@ -0,0 +1,5 @@
+"""Domain event and transactional outbox primitives."""
+
+from app.core.events.outbox import enqueue_outbox, outbox_health
+
+__all__ = ["enqueue_outbox", "outbox_health"]

+ 79 - 0
app/core/events/consumer.py

@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from datetime import datetime, timedelta, timezone
+from typing import Any, Callable
+
+
+@dataclass(frozen=True)
+class ClaimedEvent:
+    event_id: str
+    event_type: str
+    payload: dict[str, Any]
+    attempts: int
+
+
+@dataclass(frozen=True)
+class DispatchResult:
+    status: str
+    attempts: int
+    available_at: datetime | None = None
+    last_error: str | None = None
+    record_consumption: bool = False
+
+
+def retry_delay(
+    attempts: int,
+    *,
+    base_seconds: int = 2,
+    maximum_seconds: int = 300,
+) -> timedelta:
+    seconds = min(maximum_seconds, base_seconds * (2 ** max(0, attempts - 1)))
+    return timedelta(seconds=seconds)
+
+
+def _redact_error(error: Exception) -> str:
+    message = str(error)
+    message = re.sub(
+        r"(?i)(secret[-_ ]?token|api[-_ ]?key|authorization|bearer)(?:[=: ]+\S+)?",
+        "[redacted]",
+        message,
+    )
+    return message[:1000]
+
+
+def dispatch_event(
+    event: ClaimedEvent,
+    handler: Callable[[dict[str, Any]], None],
+    *,
+    already_processed: bool = False,
+    max_attempts: int = 5,
+    now: datetime | None = None,
+) -> DispatchResult:
+    if already_processed:
+        return DispatchResult(status="published", attempts=event.attempts)
+
+    now = now or datetime.now(timezone.utc)
+    try:
+        handler(event.payload)
+    except Exception as exc:
+        attempts = event.attempts + 1
+        if attempts >= max_attempts:
+            return DispatchResult(
+                status="dead_letter",
+                attempts=attempts,
+                last_error=_redact_error(exc),
+            )
+        return DispatchResult(
+            status="pending",
+            attempts=attempts,
+            available_at=now + retry_delay(attempts),
+            last_error=_redact_error(exc),
+        )
+
+    return DispatchResult(
+        status="published",
+        attempts=event.attempts + 1,
+        record_consumption=True,
+    )

+ 171 - 0
app/core/events/outbox.py

@@ -0,0 +1,171 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import Any
+
+from sqlalchemy import text
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.events.consumer import ClaimedEvent, DispatchResult
+
+
+def enqueue_outbox(
+    session: Any,
+    *,
+    aggregate_type: str,
+    aggregate_id: str,
+    event_type: str,
+    payload: dict[str, Any],
+    correlation_id: str | None = None,
+    event_id: str | None = None,
+) -> str:
+    event_id = event_id or new_governance_uid()
+    session.execute(
+        text(
+            """
+            INSERT INTO public.outbox_events (
+                event_id, aggregate_type, aggregate_id, event_type,
+                payload, correlation_id
+            ) VALUES (
+                CAST(:event_id AS uuid), :aggregate_type, :aggregate_id,
+                :event_type, CAST(:payload AS jsonb), :correlation_id
+            )
+            """
+        ),
+        {
+            "event_id": event_id,
+            "aggregate_type": aggregate_type,
+            "aggregate_id": aggregate_id,
+            "event_type": event_type,
+            "payload": __import__("json").dumps(payload, ensure_ascii=False),
+            "correlation_id": correlation_id,
+        },
+    )
+    return event_id
+
+
+def claim_outbox(session: Any, limit: int = 50) -> list[ClaimedEvent]:
+    rows = session.execute(
+        text(
+            """
+            WITH selected AS (
+                SELECT event_id
+                FROM public.outbox_events
+                WHERE status = 'pending' AND available_at <= CURRENT_TIMESTAMP
+                ORDER BY available_at, created_at
+                FOR UPDATE SKIP LOCKED
+                LIMIT :limit
+            )
+            UPDATE public.outbox_events AS event
+            SET status = 'processing'
+            FROM selected
+            WHERE event.event_id = selected.event_id
+            RETURNING event.event_id::text, event.event_type,
+                      event.payload, event.attempts
+            """
+        ),
+        {"limit": int(limit)},
+    )
+    return [
+        ClaimedEvent(
+            event_id=row[0],
+            event_type=row[1],
+            payload=dict(row[2]),
+            attempts=int(row[3]),
+        )
+        for row in rows
+    ]
+
+
+def consumed(session: Any, consumer_name: str, event_id: str) -> bool:
+    return bool(
+        session.execute(
+            text(
+                "SELECT 1 FROM public.outbox_consumptions "
+                "WHERE consumer_name = :consumer_name "
+                "AND event_id = CAST(:event_id AS uuid)"
+            ),
+            {"consumer_name": consumer_name, "event_id": event_id},
+        ).scalar()
+    )
+
+
+def apply_dispatch_result(
+    session: Any,
+    *,
+    consumer_name: str,
+    event: ClaimedEvent,
+    result: DispatchResult,
+) -> None:
+    if result.record_consumption:
+        session.execute(
+            text(
+                """
+                INSERT INTO public.outbox_consumptions (consumer_name, event_id)
+                VALUES (:consumer_name, CAST(:event_id AS uuid))
+                ON CONFLICT (consumer_name, event_id) DO NOTHING
+                """
+            ),
+            {"consumer_name": consumer_name, "event_id": event.event_id},
+        )
+
+    session.execute(
+        text(
+            """
+            UPDATE public.outbox_events
+            SET status = :status,
+                attempts = :attempts,
+                available_at = COALESCE(:available_at, available_at),
+                published_at = CASE WHEN :status = 'published'
+                    THEN CURRENT_TIMESTAMP ELSE published_at END,
+                last_error = :last_error
+            WHERE event_id = CAST(:event_id AS uuid)
+            """
+        ),
+        {
+            "status": result.status,
+            "attempts": result.attempts,
+            "available_at": result.available_at,
+            "last_error": result.last_error,
+            "event_id": event.event_id,
+        },
+    )
+
+
+def outbox_health(session: Any) -> dict[str, Any]:
+    row = session.execute(
+        text(
+            """
+            SELECT
+                COUNT(*) FILTER (WHERE status IN ('pending', 'processing')) AS backlog,
+                COUNT(*) FILTER (WHERE status IN ('failed', 'dead_letter')) AS failed,
+                EXTRACT(EPOCH FROM (
+                    CURRENT_TIMESTAMP - MIN(created_at)
+                        FILTER (WHERE status IN ('pending', 'processing'))
+                )) AS oldest_age_seconds
+            FROM public.outbox_events
+            """
+        )
+    ).one()
+    return {
+        "backlog": int(row[0] or 0),
+        "failed": int(row[1] or 0),
+        "oldest_age_seconds": float(row[2] or 0),
+        "checked_at": datetime.now(timezone.utc).isoformat(),
+    }
+
+
+def reset_stale_processing(session: Any, *, older_than_seconds: int = 300) -> int:
+    result = session.execute(
+        text(
+            """
+            UPDATE public.outbox_events
+            SET status = 'pending', available_at = CURRENT_TIMESTAMP
+            WHERE status = 'processing'
+              AND updated_at < CURRENT_TIMESTAMP
+                  - (:older_than_seconds * INTERVAL '1 second')
+            """
+        ),
+        {"older_than_seconds": int(older_than_seconds)},
+    )
+    return int(result.rowcount or 0)

+ 1 - 0
app/core/knowledge/__init__.py

@@ -0,0 +1 @@
+"""Data governance knowledge base."""

+ 30 - 0
app/core/knowledge/document_builder.py

@@ -0,0 +1,30 @@
+from __future__ import annotations
+import hashlib
+import json
+from typing import Any
+
+SECRET_KEYS = {"password", "credentials", "api_key", "token", "authorization", "connection_string"}
+
+
+def _safe(value: Any, key: str = "") -> Any:
+    if key.lower() in SECRET_KEYS:
+        return "[redacted]"
+    if isinstance(value, dict):
+        return {k: _safe(v, k) for k, v in sorted(value.items()) if k not in {"updated_at", "created_at"}}
+    if isinstance(value, list):
+        items = [_safe(item) for item in value]
+        return sorted(items, key=lambda item: json.dumps(item, sort_keys=True, ensure_ascii=False))
+    return value
+
+
+def build_document(object_type: str, source: dict[str, Any]) -> dict[str, Any]:
+    uid = source.get("uid")
+    if not uid:
+        raise ValueError("governance object uid is required")
+    safe = _safe(source)
+    content = json.dumps({"object_type": object_type, "source": safe}, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
+    return {"object_type": object_type, "object_uid": uid, "object_version": int(source.get("version", 1)), "object_name": source.get("name_zh") or source.get("name") or uid, "business_domain_uid": source.get("business_domain_uid"), "content": content, "content_hash": hashlib.sha256(content.encode()).hexdigest(), "source_updated_at": source.get("updated_at")}
+
+
+def chunk_document(content: str, max_chars: int = 1200) -> list[str]:
+    return [content[index:index + max_chars] for index in range(0, len(content), max_chars)] or [""]

+ 243 - 46
app/core/llm/ddl_parser.py

@@ -8,7 +8,12 @@ import time
 from typing import Any
 
 import requests
-from flask import current_app
+
+from app.core.llm.deepseek_client import (
+    get_llm_api_key,
+    get_llm_chat_completions_url,
+    get_llm_model,
+)
 
 logger = logging.getLogger(__name__)
 
@@ -25,12 +30,18 @@ class DDLParser:
         """
         # 如果在Flask应用上下文中,则从应用配置获取参数
 
-        self.api_key = api_key or current_app.config.get("LLM_API_KEY")
-        self.base_url = current_app.config.get("LLM_BASE_URL")
-        self.model_name = current_app.config.get("LLM_MODEL_NAME")
+        self.api_key = api_key or get_llm_api_key()
+        self.chat_completions_url = get_llm_chat_completions_url()
+        self.model_name = get_llm_model()
         self.timeout = timeout
         self.max_retries = max_retries
 
+        if not self.api_key:
+            logger.error(
+                "DeepSeek API Key 未配置,请在 /etc/dataops-platform/dataops.env "
+                "中设置 DEEPSEEK_API_KEY 后重启服务"
+            )
+
         self.headers = {
             "Authorization": f"Bearer {self.api_key}",
             "Content-Type": "application/json",
@@ -49,6 +60,10 @@ class DDLParser:
         """
         last_error = None
 
+        if not self.api_key:
+            logger.error(f"{operation_name} 跳过: DeepSeek API Key 未配置")
+            return None
+
         for attempt in range(self.max_retries):
             try:
                 if attempt > 0:
@@ -63,7 +78,7 @@ class DDLParser:
                 )
 
                 response = requests.post(
-                    f"{self.base_url}/chat/completions",
+                    self.chat_completions_url,
                     headers=self.headers,
                     json=payload,
                     timeout=self.timeout,
@@ -79,8 +94,15 @@ class DDLParser:
                 logger.warning(f"{operation_name} 超时: {str(e)}")
 
             except requests.RequestException as e:
-                last_error = f"API请求失败: {str(e)}"
-                logger.warning(f"{operation_name} 失败: {str(e)}")
+                status_code = getattr(getattr(e, "response", None), "status_code", None)
+                if status_code == 401:
+                    last_error = (
+                        "DeepSeek API 鉴权失败(401),请检查 DEEPSEEK_API_KEY 是否正确、"
+                        "是否已开通余额,并重载服务"
+                    )
+                else:
+                    last_error = f"API请求失败: {str(e)}"
+                logger.warning(f"{operation_name} 失败: {last_error}")
 
             except Exception as e:
                 last_error = f"未知错误: {str(e)}"
@@ -91,64 +113,238 @@ class DDLParser:
         logger.error(f"{operation_name} 在{self.max_retries}次尝试后失败: {last_error}")
         return None
 
-    def parse_ddl(self, sql_content):
-        """
-        解析DDL语句,返回标准化的结构
+    @staticmethod
+    def _split_sql_identifier_list(section: str) -> list[str]:
+        names: list[str] = []
+        for part in re.split(r",\s*", section.strip()):
+            cleaned = part.strip().strip('"').strip("'")
+            if cleaned:
+                names.append(cleaned)
+        return names
+
+    @staticmethod
+    def _build_column_defs(column_names: list[str]) -> list[dict[str, str]]:
+        return [
+            {
+                "name_zh": "",
+                "name_en": name,
+                "data_type": "VARCHAR(255)",
+                "is_primary": "否",
+                "comment": "",
+                "nullable": "是",
+            }
+            for name in column_names
+        ]
 
-        参数:
-            sql_content: 要解析的DDL语句
+    def _parse_create_views(self, sql_content: str) -> list[dict]:
+        view_pattern = re.compile(
+            r'CREATE\s+(?:OR\s+REPLACE\s+)?(?:\w+\s+)*VIEW\s+'
+            r'(?:"?(?:[\w$#]+)"?\.)?"?([\w$#]+)"?\s*\(([^)]+)\)',
+            re.IGNORECASE | re.DOTALL,
+        )
+        results: list[dict] = []
+        for match in view_pattern.finditer(sql_content):
+            table_name = match.group(1)
+            column_names = self._split_sql_identifier_list(match.group(2))
+            if not column_names:
+                continue
+            results.append(
+                {
+                    "table_info": {
+                        "name_zh": "",
+                        "name_en": table_name,
+                    },
+                    "columns": self._build_column_defs(column_names),
+                }
+            )
+        return results
+
+    def _parse_create_tables(self, sql_content: str) -> list[dict]:
+        table_pattern = re.compile(
+            r'CREATE\s+TABLE\s+(?:"?(?:[\w$#]+)"?\.)?"?([\w$#]+)"?\s*\(',
+            re.IGNORECASE | re.DOTALL,
+        )
+        results: list[dict] = []
+        for match in table_pattern.finditer(sql_content):
+            start = match.end()
+            depth = 1
+            index = start
+            while index < len(sql_content) and depth > 0:
+                char = sql_content[index]
+                if char == "(":
+                    depth += 1
+                elif char == ")":
+                    depth -= 1
+                index += 1
+            if depth != 0:
+                continue
+
+            body = sql_content[start : index - 1]
+            columns: list[dict[str, str]] = []
+            for line in body.splitlines():
+                line = line.strip().rstrip(",")
+                if not line or line.upper().startswith(
+                    ("CONSTRAINT", "PRIMARY", "UNIQUE", "FOREIGN", "CHECK", "INDEX")
+                ):
+                    continue
+                col_match = re.match(
+                    r'^"?([\w$#]+)"?\s+([A-Za-z][\w$#()]*(?:\([^)]*\))?)',
+                    line,
+                    re.IGNORECASE,
+                )
+                if not col_match:
+                    continue
+                col_name = col_match.group(1)
+                data_type = col_match.group(2).upper()
+                upper_line = line.upper()
+                columns.append(
+                    {
+                        "name_zh": "",
+                        "name_en": col_name,
+                        "data_type": data_type,
+                        "is_primary": "是" if "PRIMARY KEY" in upper_line else "否",
+                        "comment": "",
+                        "nullable": "否" if "NOT NULL" in upper_line else "是",
+                    }
+                )
 
-        返回:
-            解析结果的JSON对象
-        """
+            if columns:
+                results.append(
+                    {
+                        "table_info": {
+                            "name_zh": "",
+                            "name_en": match.group(1),
+                        },
+                        "columns": columns,
+                    }
+                )
+        return results
+
+    def _parse_sql_ddl_fallback(self, sql_content: str) -> list[dict]:
+        """Parse CREATE VIEW / CREATE TABLE locally when LLM output is empty or invalid."""
+        results = self._parse_create_views(sql_content)
+        if results:
+            return results
+        return self._parse_create_tables(sql_content)
+
+    @staticmethod
+    def normalize_ddl_parse_result(raw: Any) -> list[dict]:
+        """Normalize LLM or legacy parser output into standard table list."""
+        if raw is None:
+            return []
+        if isinstance(raw, list):
+            return [
+                item
+                for item in raw
+                if isinstance(item, dict) and isinstance(item.get("table_info"), dict)
+            ]
+        if not isinstance(raw, dict):
+            return []
+
+        if raw.get("code") == 500 and "table_info" not in raw:
+            return []
+
+        if "table_info" in raw:
+            return [raw]
+
+        converted: list[dict] = []
+        for table_name, table_data in raw.items():
+            if not isinstance(table_data, dict):
+                continue
+            if "table_info" in table_data:
+                converted.append(table_data)
+                continue
+            columns = table_data.get("columns")
+            if isinstance(columns, list):
+                converted.append(
+                    {
+                        "table_info": {
+                            "name_zh": table_data.get("name_zh", ""),
+                            "name_en": table_data.get("name_en", table_name),
+                        },
+                        "columns": columns,
+                    }
+                )
+        return converted
+
+    def _parse_ddl_with_llm(self, sql_content: str) -> Any:
         prompt = self._optimize_ddl_prompt()
         payload = {
             "model": self.model_name,
             "messages": [
                 {
                     "role": "system",
-                    "content": "你是一个专业的SQL DDL语句解析专家,擅长从DDL语句中提取表结构信息并转换为结构化的JSON格式。",
+                    "content": (
+                        "你是一个专业的SQL DDL语句解析专家,擅长从DDL建表语句和"
+                        "CREATE VIEW视图定义中提取表结构信息并转换为结构化的JSON格式。"
+                    ),
                 },
                 {"role": "user", "content": f"{prompt}\n\n{sql_content}"},
             ],
         }
 
-        try:
-            result = self._make_llm_request(payload, "DDL解析")
-
-            if not result:
-                return {
-                    "code": 500,
-                    "message": f"API请求失败: 在{self.max_retries}次尝试后仍然失败",
-                }
-
-            if "choices" in result and len(result["choices"]) > 0:
-                content = result["choices"][0]["message"]["content"]
-
-                try:
-                    json_match = re.search(r"```json\s*([\s\S]*?)\s*```", content)
-                    if json_match:
-                        json_content = json_match.group(1)
-                    else:
-                        json_content = content
-
-                    parsed_result = json.loads(json_content)
-                    return parsed_result
-                except json.JSONDecodeError as e:
-                    return {
-                        "code": 500,
-                        "message": f"无法解析返回的JSON: {str(e)}",
-                        "original_response": content,
-                    }
+        result = self._make_llm_request(payload, "DDL解析")
+        if not result:
+            return {
+                "code": 500,
+                "message": f"API请求失败: 在{self.max_retries}次尝试后仍然失败",
+            }
 
+        if "choices" not in result or not result["choices"]:
             return {
                 "code": 500,
                 "message": "无法获取有效响应",
                 "original_response": result,
             }
 
+        content = result["choices"][0]["message"]["content"]
+        try:
+            json_match = re.search(r"```json\s*([\s\S]*?)\s*```", content)
+            json_content = json_match.group(1) if json_match else content
+            return json.loads(json_content)
+        except json.JSONDecodeError as exc:
+            return {
+                "code": 500,
+                "message": f"无法解析返回的JSON: {str(exc)}",
+                "original_response": content,
+            }
+
+    def parse_ddl(self, sql_content):
+        """
+        解析DDL语句,返回标准化的结构
+
+        参数:
+            sql_content: 要解析的DDL语句
+
+        返回:
+            标准表结构数组;优先本地解析,复杂语句再调用 LLM
+        """
+        try:
+            fallback_list = self._parse_sql_ddl_fallback(sql_content)
+            if fallback_list:
+                logger.info(
+                    f"DDL 本地SQL解析成功,识别 {len(fallback_list)} 个表/视图"
+                )
+                return fallback_list
+
+            llm_raw = self._parse_ddl_with_llm(sql_content)
+            ddl_list = self.normalize_ddl_parse_result(llm_raw)
+            if ddl_list:
+                logger.info(f"DDL LLM解析成功,识别 {len(ddl_list)} 个表/视图")
+                return ddl_list
+
+            if isinstance(llm_raw, dict) and llm_raw.get("message"):
+                return llm_raw
+
+            return []
         except Exception as e:
             logger.error(f"DDL解析异常: {str(e)}")
+            fallback_list = self._parse_sql_ddl_fallback(sql_content)
+            if fallback_list:
+                logger.info(
+                    f"DDL解析异常后使用本地SQL解析,识别 {len(fallback_list)} 个表/视图"
+                )
+                return fallback_list
             return {"code": 500, "message": f"解析失败: {str(e)}"}
 
     def parse_db_conn_str(self, conn_str):
@@ -214,11 +410,12 @@ class DDLParser:
     def _optimize_ddl_prompt(self):
         """返回优化后的提示词模板"""
         return """
-请解析以下DDL建表语句,并按照指定的JSON格式返回结果:
+请解析以下DDL建表语句或CREATE VIEW视图定义,并按照指定的JSON格式返回结果:
 
 规则说明:
-1. 从DDL语句中识别所有表,可能会有多个表。将所有表放在一个数组中返回。
-2. 表的英文名称(name_en)使用原始大小写,不要转换为小写。
+1. 从DDL语句中识别所有表和视图,可能会有多个对象。将所有对象放在一个数组中返回。
+2. CREATE VIEW 视图的列名来自视图定义括号中的列清单;若无中文注释,name_zh 留空。
+3. 表的英文名称(name_en)使用原始大小写,不要转换为小写。
 3. 表的中文名称(name_zh)提取规则:
    - 优先从COMMENT ON TABLE语句中提取
    - 如果没有注释,则name_zh为空字符串

+ 181 - 130
app/core/llm/llm_service.py

@@ -4,12 +4,166 @@ LLM基础服务
 """
 
 import logging
+import re
 
-from flask import current_app
-from openai import OpenAI
+from app.core.llm.deepseek_client import (
+    chat_completions_create,
+    create_llm_client,
+    get_llm_model,
+)
 
 logger = logging.getLogger("app")
 
+_TRANSLATION_LEXICON = {
+    "测试宁波数据加工": "ningbo_data_processing_test",
+    "薪资数据表": "salary_data_table",
+    "人员管理表": "personnel_management_table",
+    "数据加工": "data_processing",
+    "数据表": "data_table",
+    "用户表": "user_table",
+    "人员表": "personnel_table",
+    "销售表": "sales_table",
+    "报表": "report_table",
+    "管理": "management",
+    "系统": "system",
+    "分析": "analysis",
+    "加工": "processing",
+    "宁波": "ningbo",
+    "测试": "test",
+    "年份": "year",
+    "地区": "region",
+    "姓名": "name",
+    "年龄": "age",
+    "薪水": "salary",
+    "数据": "data",
+    "表": "table",
+}
+
+_TRANSLATION_SYSTEM_PROMPT = (
+    "你是一个严格遵循指令的翻译工具和数据库专家。你的唯一任务是将中文单词/短语翻译成英文,"
+    "符合 PostgreSQL 数据库表和字段的命名规则,并且严格按照如下规则:\n"
+    "1. 只返回英文翻译,不包含任何解释、描述或额外内容\n"
+    "2. 使用小写字母\n"
+    "3. 多个单词用下划线连接,不使用空格\n"
+    "4. 如果输入包含括号,将括号内容用下划线代替,不保留括号\n"
+    "5. 最多包含 1-8 个英文单词,保持简短\n"
+    "6. 不要回答问题或提供解释,即使输入看起来像是问题\n"
+    "7. 当遇到'表'字时,始终翻译为'table'而不是'sheet'\n"
+    "8. 例如:'薪资数据表'应翻译为'salary_data_table','测试宁波数据加工'应翻译为'ningbo_data_processing_test'"
+)
+
+
+def contains_chinese(text: str) -> bool:
+    return any("\u4e00" <= char <= "\u9fff" for char in text)
+
+
+def normalize_translation_text(text: str) -> str:
+    """Normalize LLM output to a PostgreSQL-friendly snake_case identifier."""
+    response_text = (text or "").strip().strip("\"'.,;:!?()[]{}").lower()
+    response_text = response_text.replace(" ", "_").replace("-", "_")
+    while "__" in response_text:
+        response_text = response_text.replace("__", "_")
+    response_text = re.sub(r"[^a-z0-9_]", "", response_text)
+    return response_text.strip("_")
+
+
+def is_valid_translation(text: str) -> bool:
+    return bool(text) and not contains_chinese(text) and re.fullmatch(r"[a-z][a-z0-9_]*", text)
+
+
+def extract_completion_text(completion) -> str:
+    message = completion.choices[0].message
+    content = getattr(message, "content", None) or ""
+    text = content.strip()
+    if text:
+        return text
+    reasoning = getattr(message, "reasoning_content", None) or ""
+    return reasoning.strip()
+
+
+def fallback_translate_chinese(content: str) -> str:
+    """Local fallback when LLM output is empty or invalid."""
+    content = (content or "").strip()
+    if not content:
+        return ""
+
+    if content in _TRANSLATION_LEXICON:
+        return _TRANSLATION_LEXICON[content]
+
+    parts: list[str] = []
+    lexicon_keys = sorted(_TRANSLATION_LEXICON.keys(), key=len, reverse=True)
+    index = 0
+    while index < len(content):
+        matched = False
+        for key in lexicon_keys:
+            if content.startswith(key, index):
+                parts.append(_TRANSLATION_LEXICON[key])
+                index += len(key)
+                matched = True
+                break
+        if not matched:
+            index += 1
+
+    if parts:
+        result = normalize_translation_text("_".join(parts))
+        if is_valid_translation(result):
+            return result
+
+    if "表" in content:
+        return "data_table"
+    return "translated_text"
+
+
+def _translate_with_llm(client, content: str) -> str:
+    completion = chat_completions_create(
+        client,
+        messages=[
+            {"role": "system", "content": _TRANSLATION_SYSTEM_PROMPT},
+            {
+                "role": "user",
+                "content": f"将以下内容翻译为英文数据库标识符:{content}",
+            },
+        ],
+        temperature=0,
+        max_tokens=64,
+    )
+    raw_text = extract_completion_text(completion)
+    normalized = normalize_translation_text(raw_text)
+
+    if "表" in content and "table" not in normalized and "sheet" in normalized:
+        normalized = normalized.replace("sheet", "table")
+
+    if is_valid_translation(normalized):
+        logger.debug(f"LLM翻译成功: {content} -> {normalized}")
+        return normalized
+
+    logger.warning(
+        f"LLM翻译结果无效: input={content!r}, raw={raw_text!r}, normalized={normalized!r}"
+    )
+    return ""
+
+
+def translate_chinese_identifier(content: str) -> str:
+    """Translate Chinese text to an English database identifier."""
+    content = (content or "").strip()
+    if not content:
+        return ""
+    if not contains_chinese(content):
+        normalized = normalize_translation_text(content)
+        return normalized if is_valid_translation(normalized) else content
+
+    try:
+        client = create_llm_client()
+        translated = _translate_with_llm(client, content)
+        if translated:
+            return translated
+    except Exception as exc:
+        logger.error(f"LLM翻译调用失败: {exc}")
+
+    fallback = fallback_translate_chinese(content)
+    logger.info(f"使用本地词典回退翻译: {content} -> {fallback}")
+    return fallback
+
 
 def llm_client(content):
     """
@@ -21,129 +175,31 @@ def llm_client(content):
     Returns:
         str: LLM响应内容
     """
-    try:
-        # 优先使用配置文件中的参数
-        client = OpenAI(
-            api_key=current_app.config.get("LLM_API_KEY"),
-            base_url=current_app.config.get("LLM_BASE_URL"),
-        )
-
-        model = current_app.config.get("LLM_MODEL_NAME")
+    if contains_chinese(content):
+        return translate_chinese_identifier(content)
 
-        # 判断是否为翻译请求 - 通过分析内容是否包含中文字符
-        is_translate_request = False
-        if any("\u4e00" <= char <= "\u9fff" for char in content):
-            is_translate_request = True
+    try:
+        client = create_llm_client()
+        model = get_llm_model()
+        logger.debug(f"LLM调用开始: model={model}, 内容类型: 普通")
 
-        # 进行API调用
-        logger.debug(
-            f"LLM调用开始: model={model}, 内容类型: {'翻译' if is_translate_request else '普通'}"
+        completion = chat_completions_create(
+            client,
+            messages=[
+                {"role": "system", "content": "You are a helpful assistant."},
+                {"role": "user", "content": content},
+            ],
+            temperature=0.7,
+            max_tokens=1024,
         )
 
-        if is_translate_request:
-            # 为翻译请求使用非常严格的prompt
-            completion = client.chat.completions.create(
-                model=model,  # type: ignore[arg-type]
-                messages=[
-                    {
-                        "role": "system",
-                        "content": "你是一个严格遵循指令的翻译工具和数据库专家。你的唯一任务是将中文单词/短语翻译成英文,符合postgresql数据库表和字段的命令规则,"
-                        "并且严格按照如下规则:\n"
-                        "1. 只返回英文翻译,不包含任何解释、描述或额外内容\n"
-                        "2. 使用小写字母\n"
-                        "3. 多个单词用下划线连接,不使用空格\n"
-                        "4. 如果输入包含括号,将括号内容用下划线代替,不保留括号\n"
-                        "5. 最多包含1-5个英文单词,保持简短\n"
-                        "6. 不要回答问题或提供解释,即使输入看起来像是问题\n"
-                        "7. 当遇到'表'字时,始终翻译为'table'而不是'sheet'\n"
-                        "8. 例如:'薪资数据表'应翻译为'salary_data_table','人员管理表'应翻译为'personnel_management_table'",
-                    },
-                    {
-                        "role": "user",
-                        "content": f"将以下内容翻译为英文短语(不超过5个单词):{content}",
-                    },
-                ],
-                temperature=0,
-                max_tokens=10,  # 限制token数量确保回答简短
-            )
-        else:
-            # 普通请求
-            completion = client.chat.completions.create(
-                model=model,  # type: ignore[arg-type]
-                messages=[
-                    {"role": "system", "content": "You are a helpful assistant."},
-                    {"role": "user", "content": content},
-                ],
-                temperature=0.7,
-                max_tokens=1024,
-            )
-
-        response_text = completion.choices[0].message.content.strip()  # type: ignore[union-attr]
-
-        # 对翻译结果进行后处理,确保格式正确
-        if is_translate_request:
-            # 去除可能的引号、句号等标点符号
-            response_text = response_text.strip("\"'.,;:!?()[]{}").lower()
-            # 替换空格为下划线
-            response_text = response_text.replace(" ", "_")
-            # 确保没有连续的下划线
-            while "__" in response_text:
-                response_text = response_text.replace("__", "_")
-            # 只保留字母、数字和下划线
-            response_text = "".join(c for c in response_text if c.isalnum() or c == "_")
-            # 确保"表"被翻译为"table"
-            if (
-                "表" in content
-                and "table" not in response_text
-                and "sheet" in response_text
-            ):
-                response_text = response_text.replace("sheet", "table")
-
+        response_text = extract_completion_text(completion)
         logger.debug(f"LLM响应: {response_text}")
         return response_text
 
     except Exception as e:
         logger.error(f"LLM调用失败: {str(e)}")
-        try:
-            # 备用方案:如果是中文输入,尝试简单翻译映射
-            if any("\u4e00" <= char <= "\u9fff" for char in content):
-                # 常见中文词汇映射
-                common_translations = {
-                    "薪资数据表": "salary_data_table",
-                    "数据表": "data_table",
-                    "用户表": "user_table",
-                    "人员表": "personnel_table",
-                    "销售表": "sales_table",
-                    "年份": "year",
-                    "地区": "region",
-                    "姓名": "name",
-                    "年龄": "age",
-                    "薪水": "salary",
-                    "数据": "data",
-                    "管理": "management",
-                    "系统": "system",
-                    "分析": "analysis",
-                    "报表": "report_table",
-                }
-
-                # 检查是否有精确匹配
-                if content in common_translations:
-                    return common_translations[content]
-
-                # 检查是否包含某些关键词
-                for key, value in common_translations.items():
-                    if key in content:
-                        return value
-
-                # 如果包含"表"字,确保返回包含"table"
-                if "表" in content:
-                    return "data_table"
-
-                # 无法匹配时返回默认值
-                return "translated_text"
-            return content
-        except Exception as e:
-            return content
+        return content
 
 
 def llm_sql(request_data):
@@ -157,20 +213,14 @@ def llm_sql(request_data):
         str: Deepseek模型返回的SQL脚本内容
     """
     try:
-        # 使用配置文件中的参数连接Deepseek
-        client = OpenAI(
-            api_key=current_app.config.get("LLM_API_KEY"),
-            base_url=current_app.config.get("LLM_BASE_URL"),
-        )
-
-        model = current_app.config.get("LLM_MODEL_NAME")
+        client = create_llm_client()
+        model = get_llm_model()
 
-        logger.info(f"开始调用Deepseek模型生成SQL脚本: model={model}")
+        logger.info(f"开始调用 DeepSeek 模型生成 SQL 脚本: model={model}")
         logger.debug(f"输入提示语: {request_data}")
 
-        # 调用Deepseek API生成SQL脚本
-        completion = client.chat.completions.create(
-            model=model,  # type: ignore[arg-type]
+        completion = chat_completions_create(
+            client,
             messages=[
                 {
                     "role": "system",
@@ -180,12 +230,13 @@ def llm_sql(request_data):
                 },
                 {"role": "user", "content": request_data},
             ],
-            temperature=0.1,  # 使用较低的温度确保结果的一致性和准确性
-            max_tokens=4096,  # 为SQL脚本提供足够的token空间
+            temperature=0.1,
+            max_tokens=4096,
             top_p=0.9,
+            use_thinking=True,
         )
 
-        response_text = completion.choices[0].message.content.strip()  # type: ignore[union-attr]
+        response_text = extract_completion_text(completion)
 
         logger.info(f"Deepseek模型成功返回SQL脚本,长度: {len(response_text)} 字符")
         logger.debug(f"生成的SQL脚本: {response_text}")

+ 6 - 16
app/core/meta_data/meta_data.py

@@ -13,8 +13,6 @@ from typing import Any
 
 from flask import current_app
 from minio import S3Error
-from openai import OpenAI
-
 from app.core.llm.llm_service import llm_client as llm_call  # 导入core/llm模块的函数
 from app.services.neo4j_driver import neo4j_driver
 
@@ -153,15 +151,11 @@ def translate_and_parse(content):
     Returns:
         list: 包含翻译结果的列表
     """
-    # 调用LLM服务进行翻译
     translated_text = llm_call(content)
-
-    # 如果翻译失败,返回原文
-    if translated_text is None:
+    if not translated_text:
         return [content]
 
-    # 确保返回格式为列表
-    return [translated_text]
+    return [str(translated_text).strip()]
 
 
 # 为保持原有功能,保留旧的llm_client函数
@@ -177,14 +171,11 @@ def infer_column_type(df):
         res = df.columns.to_list()
         columns = ",".join(res)
 
-        # 使用配置中的LLM参数
-        api_k = current_app.config.get("LLM_API_KEY")
-        base_u = current_app.config.get("LLM_BASE_URL")
-        model = current_app.config.get("LLM_MODEL_NAME") or "gpt-4o-mini"
+        from app.core.llm.deepseek_client import chat_completions_create, create_llm_client
 
-        client = OpenAI(api_key=api_k, base_url=base_u)
-        response = client.chat.completions.create(  # type: ignore[arg-type]
-            model=model,
+        client = create_llm_client()
+        response = chat_completions_create(
+            client,
             messages=[
                 {
                     "role": "system",
@@ -223,7 +214,6 @@ def infer_column_type(df):
             ],
             max_tokens=1024,
             temperature=0.1,
-            stream=False,
         )
         content = response.choices[0].message.content
         if not content:

+ 3 - 3
app/core/system/__init__.py

@@ -16,7 +16,7 @@ from app.core.system.config import (
 )
 
 from app.core.system.auth import (
-    register_user,
+    authenticate_user,
     login_user,
     get_user_by_username
 )
@@ -28,7 +28,7 @@ __all__ = [
     'get_system_config',
     'validate_config',
     'get_config_file_paths',
-    'register_user',
+    'authenticate_user',
     'login_user',
     'get_user_by_username'
-] 
+]

+ 177 - 351
app/core/system/auth.py

@@ -1,378 +1,204 @@
-"""
-系统用户认证模块
-提供用户注册、登录验证等功能
-"""
+"""PostgreSQL-backed authentication using Argon2id and short-lived bearer tokens."""
+
+from __future__ import annotations
 
-import base64
 import logging
+import threading
 import time
-import uuid
-from functools import wraps
-from urllib.parse import unquote, urlparse
-
-import psycopg2
-import psycopg2.pool
-from flask import current_app, jsonify, request
+from collections import defaultdict, deque
+from typing import Any
 
-logger = logging.getLogger(__name__)
+from argon2 import PasswordHasher
+from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
+from flask import current_app
+from sqlalchemy import text
 
-# PostgreSQL连接池
-pg_pool = None
-
-
-def get_pg_connection():
-    """
-    获取PostgreSQL数据库连接
-
-    Returns:
-        connection: PostgreSQL连接对象
-    """
-    global pg_pool
-
-    if pg_pool is None:
-        try:
-            # 解析SQLAlchemy URI,处理包含特殊字符的密码
-            db_uri = current_app.config["SQLALCHEMY_DATABASE_URI"]
-
-            # 尝试使用urlparse解析
-            uri = urlparse(db_uri)
-
-            # 如果解析失败(缺少用户名或主机名)或密码包含特殊字符导致解析错误,使用手动解析
-            if uri.username is None or uri.hostname is None:
-                # 手动解析URI: postgresql://username:password@host:port/database
-                scheme_end = db_uri.find("://")
-                if scheme_end == -1:
-                    raise ValueError("Invalid database URI format")
-
-                auth_and_host = db_uri[scheme_end + 3 :]  # 跳过 '://'
-                at_pos = auth_and_host.rfind("@")  # 从右向左查找最后一个@
-
-                if at_pos == -1:
-                    raise ValueError("Invalid database URI: missing @ separator")
-
-                auth_part = auth_and_host[:at_pos]
-                host_part = auth_and_host[at_pos + 1 :]
-
-                # 解析用户名和密码(可能包含特殊字符)
-                colon_pos = auth_part.find(":")
-                if colon_pos == -1:
-                    username = unquote(auth_part)
-                    password = None
-                else:
-                    username = unquote(auth_part[:colon_pos])
-                    password = unquote(auth_part[colon_pos + 1 :])
-
-                # 解析主机、端口和数据库
-                slash_pos = host_part.find("/")
-                if slash_pos == -1:
-                    raise ValueError("Invalid database URI: missing database name")
-
-                host_port = host_part[:slash_pos]
-                database = unquote(host_part[slash_pos + 1 :])
-
-                # 解析主机和端口
-                colon_pos = host_port.find(":")
-                if colon_pos == -1:
-                    hostname = host_port
-                    port = 5432
-                else:
-                    hostname = host_port[:colon_pos]
-                    port = int(host_port[colon_pos + 1 :])
-            else:
-                # urlparse解析成功,解码可能被URL编码的字段
-                username = unquote(uri.username) if uri.username else None
-                password = unquote(uri.password) if uri.password else None
-                database = (
-                    unquote(uri.path[1:]) if uri.path and len(uri.path) > 1 else None
-                )
-                hostname = uri.hostname
-                port = uri.port or 5432
-
-            # 验证必需的字段(username, database, hostname 是必需的,password 是可选的)
-            if not all([username, database, hostname]):
-                raise ValueError(
-                    "Missing required database connection parameters: username, database, and hostname are required"
-                )
-
-            # 创建连接池
-            pg_pool = psycopg2.pool.SimpleConnectionPool(  # type: ignore[attr-defined]
-                1,
-                20,
-                host=hostname,
-                database=database,
-                user=username,
-                password=password,
-                port=str(port),
-            )
-            logger.info("PostgreSQL连接池初始化成功")
-        except Exception as e:
-            logger.error(f"PostgreSQL连接池初始化失败: {str(e)}")
-            raise
-
-    return pg_pool.getconn()
-
-
-def release_pg_connection(conn):
-    """
-    释放PostgreSQL连接到连接池
-
-    Args:
-        conn: 数据库连接对象
-    """
-    global pg_pool
-    if pg_pool and conn:
-        pg_pool.putconn(conn)
-
-
-def encode_password(password):
-    """
-    对密码进行base64编码
-
-    Args:
-        password: 原始密码
-
-    Returns:
-        str: 编码后的密码
-    """
-    return base64.b64encode(password.encode("utf-8")).decode("utf-8")
-
-
-def create_user_table():
-    """
-    创建用户表,如果不存在
-
-    Returns:
-        bool: 是否成功创建
-    """
-    conn = None
-    try:
-        conn = get_pg_connection()
-        cursor = conn.cursor()
-
-        # 创建用户表
-        create_table_query = """
-        CREATE TABLE IF NOT EXISTS users (
-            id VARCHAR(100) PRIMARY KEY,
-            username VARCHAR(50) UNIQUE NOT NULL,
-            password VARCHAR(100) NOT NULL,
-            created_at FLOAT NOT NULL,
-            last_login FLOAT,
-            is_admin BOOLEAN DEFAULT FALSE
-        );
-        """
-        cursor.execute(create_table_query)
-
-        # 创建索引加速查询
-        create_index_query = """
-        CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
-        """
-        cursor.execute(create_index_query)
-
-        conn.commit()
-        cursor.close()
-
-        logger.info("用户表创建成功")
-        return True
-    except Exception as e:
-        logger.error(f"创建用户表失败: {str(e)}")
-        if conn:
-            conn.rollback()
-        return False
-    finally:
-        if conn:
-            release_pg_connection(conn)
+from app import db
+from app.core.system.tokens import TokenError, decode_access_token, issue_access_token
 
 
-def register_user(username, password):
-    """
-    注册新用户
-
-    Args:
-        username: 用户名
-        password: 密码
-
-    Returns:
-        tuple: (是否成功, 消息)
-    """
-    conn = None
-    try:
-        # 确保表已创建
-        create_user_table()
+logger = logging.getLogger(__name__)
+_password_hasher = PasswordHasher()
+_failure_lock = threading.Lock()
+_login_failures: dict[str, deque[float]] = defaultdict(deque)
+_FAILURE_WINDOW_SECONDS = 300
+_FAILURE_LIMIT = 5
 
-        # 对密码进行编码
-        encoded_password = encode_password(password)
 
-        # 生成用户ID
-        user_id = str(uuid.uuid4())
+def hash_password(password: str) -> str:
+    return _password_hasher.hash(password)
 
-        conn = get_pg_connection()
-        cursor = conn.cursor()
 
-        # 检查用户名是否存在
-        check_query = "SELECT username FROM users WHERE username = %s"
-        cursor.execute(check_query, (username,))
+def verify_password(encoded: str, password: str) -> bool:
+    try:
+        return bool(_password_hasher.verify(encoded, password))
+    except (VerifyMismatchError, VerificationError, InvalidHashError):
+        return False
 
-        if cursor.fetchone():
-            return False, "用户名已存在"
 
-        # 创建用户
-        insert_query = """
-        INSERT INTO users (id, username, password, created_at, last_login)
-        VALUES (%s, %s, %s, %s, %s)
-        """
-        cursor.execute(
-            insert_query, (user_id, username, encoded_password, time.time(), None)
+def _rate_key(username: str, ip_address: str | None) -> str:
+    return f"{username.strip().lower()}|{ip_address or '-'}"
+
+
+def _check_rate_limit(key: str, *, now: float | None = None) -> None:
+    now = now or time.time()
+    with _failure_lock:
+        failures = _login_failures[key]
+        while failures and failures[0] <= now - _FAILURE_WINDOW_SECONDS:
+            failures.popleft()
+        if len(failures) >= _FAILURE_LIMIT:
+            raise PermissionError("too many login attempts")
+
+
+def _record_failure(key: str, *, now: float | None = None) -> None:
+    with _failure_lock:
+        _login_failures[key].append(now or time.time())
+
+
+def _clear_failures(key: str) -> None:
+    with _failure_lock:
+        _login_failures.pop(key, None)
+
+
+def _audit(
+    *,
+    user_id: str | None,
+    username: str,
+    event_type: str,
+    success: bool,
+    ip_address: str | None,
+    user_agent: str | None,
+    detail: str | None = None,
+) -> None:
+    db.session.execute(
+        text(
+            "INSERT INTO public.auth_audit_events "
+            "(user_id, username, event_type, success, ip_address, user_agent, detail) "
+            "VALUES (CAST(:user_id AS uuid), :username, :event_type, :success, "
+            ":ip_address, :user_agent, :detail)"
+        ),
+        {
+            "user_id": user_id,
+            "username": username[:64],
+            "event_type": event_type,
+            "success": success,
+            "ip_address": (ip_address or "")[:64] or None,
+            "user_agent": (user_agent or "")[:300] or None,
+            "detail": (detail or "")[:500] or None,
+        },
+    )
+
+
+def authenticate_user(
+    username: str,
+    password: str,
+    *,
+    ip_address: str | None = None,
+    user_agent: str | None = None,
+) -> dict[str, Any] | None:
+    key = _rate_key(username, ip_address)
+    _check_rate_limit(key)
+    row = db.session.execute(
+        text(
+            "SELECT u.id::text, u.username, u.display_name, u.password_hash, "
+            "u.status, COALESCE(array_agg(r.name ORDER BY r.name) "
+            "FILTER (WHERE r.name IS NOT NULL), ARRAY[]::varchar[]) AS roles "
+            "FROM public.users u "
+            "LEFT JOIN public.user_roles ur ON ur.user_id = u.id "
+            "LEFT JOIN public.roles r ON r.id = ur.role_id "
+            "WHERE lower(u.username) = lower(:username) "
+            "GROUP BY u.id"
+        ),
+        {"username": username.strip()},
+    ).one_or_none()
+
+    valid = bool(row and row[4] == "active" and verify_password(row[3], password))
+    if not valid:
+        _record_failure(key)
+        _audit(
+            user_id=row[0] if row else None,
+            username=username,
+            event_type="login",
+            success=False,
+            ip_address=ip_address,
+            user_agent=user_agent,
+            detail="invalid credentials or disabled account",
         )
+        db.session.commit()
+        return None
 
-        conn.commit()
-        cursor.close()
-
-        return True, "注册成功"
-    except Exception as e:
-        logger.error(f"用户注册失败: {str(e)}")
-        if conn:
-            conn.rollback()
-        return False, f"注册失败: {str(e)}"
-    finally:
-        if conn:
-            release_pg_connection(conn)
-
-
-def login_user(username, password):
-    """
-    用户登录验证
+    db.session.execute(
+        text("UPDATE public.users SET last_login_at = CURRENT_TIMESTAMP WHERE id = CAST(:id AS uuid)"),
+        {"id": row[0]},
+    )
+    _audit(
+        user_id=row[0],
+        username=row[1],
+        event_type="login",
+        success=True,
+        ip_address=ip_address,
+        user_agent=user_agent,
+    )
+    db.session.commit()
+    _clear_failures(key)
+    roles = list(row[5])
+    return {
+        "id": row[0],
+        "username": row[1],
+        "display_name": row[2],
+        "roles": roles,
+        "token": issue_access_token(
+            user_id=row[0], roles=roles, secret=current_app.config["SECRET_KEY"]
+        ),
+    }
+
+
+def load_identity(user_id: str) -> dict[str, Any] | None:
+    row = db.session.execute(
+        text(
+            "SELECT u.id::text, u.username, u.display_name, u.status, "
+            "COALESCE(array_agg(r.name ORDER BY r.name) FILTER "
+            "(WHERE r.name IS NOT NULL), ARRAY[]::varchar[]) "
+            "FROM public.users u "
+            "LEFT JOIN public.user_roles ur ON ur.user_id = u.id "
+            "LEFT JOIN public.roles r ON r.id = ur.role_id "
+            "WHERE u.id = CAST(:id AS uuid) GROUP BY u.id"
+        ),
+        {"id": user_id},
+    ).one_or_none()
+    if not row or row[3] != "active":
+        return None
+    return {"id": row[0], "username": row[1], "display_name": row[2], "roles": list(row[4])}
 
-    Args:
-        username: 用户名
-        password: 密码
 
-    Returns:
-        tuple: (是否成功, 用户信息/错误消息)
-    """
-    conn = None
+def load_identity_from_token(token: str, *, secret: str) -> dict[str, Any] | None:
     try:
-        # 对输入的密码进行编码
-        encoded_password = encode_password(password)
-
-        conn = get_pg_connection()
-        cursor = conn.cursor()
-
-        # 查询用户
-        query = """
-        SELECT id, username, password, created_at, last_login, is_admin
-        FROM users WHERE username = %s
-        """
-        cursor.execute(query, (username,))
-
-        user = cursor.fetchone()
-
-        # 检查用户是否存在
-        if not user:
-            return False, "用户名或密码错误"
-
-        # 验证密码
-        if user[2] != encoded_password:
-            return False, "用户名或密码错误"
-
-        # 更新最后登录时间
-        current_time = time.time()
-        update_query = """
-        UPDATE users SET last_login = %s WHERE username = %s
-        """
-        cursor.execute(update_query, (current_time, username))
-
-        conn.commit()
-
-        # 构建用户信息
-        user_info = {
-            "id": user[0],
-            "username": user[1],
-            "created_at": user[3],
-            "last_login": current_time,
-            "is_admin": user[5] if len(user) > 5 else False,
-        }
-
-        cursor.close()
-
-        return True, user_info
-    except Exception as e:
-        logger.error(f"用户登录失败: {str(e)}")
-        if conn:
-            conn.rollback()
-        return False, f"登录失败: {str(e)}"
-    finally:
-        if conn:
-            release_pg_connection(conn)
-
-
-def get_user_by_username(username):
-    """
-    根据用户名获取用户信息
-
-    Args:
-        username: 用户名
-
-    Returns:
-        dict: 用户信息(不包含密码)
-    """
-    conn = None
-    try:
-        conn = get_pg_connection()
-        cursor = conn.cursor()
-
-        query = """
-        SELECT id, username, created_at, last_login, is_admin
-        FROM users WHERE username = %s
-        """
-        cursor.execute(query, (username,))
-
-        user = cursor.fetchone()
-        cursor.close()
-
-        if not user:
-            return None
-
-        user_info = {
-            "id": user[0],
-            "username": user[1],
-            "created_at": user[2],
-            "last_login": user[3],
-            "is_admin": user[4] if user[4] is not None else False,
-        }
-
-        return user_info
-    except Exception as e:
-        logger.error(f"获取用户信息失败: {str(e)}")
+        claims = decode_access_token(token, secret=secret)
+    except TokenError:
         return None
-    finally:
-        if conn:
-            release_pg_connection(conn)
+    return load_identity(claims["sub"])
+
 
+# Compatibility aliases retained only for imports during the route migration.
+def login_user(username: str, password: str):
+    result = authenticate_user(username, password)
+    return (True, result) if result else (False, "用户名或密码错误")
 
-def init_db():
-    """
-    初始化数据库,创建用户表
 
-    Returns:
-        bool: 是否成功初始化
-    """
-    return create_user_table()
+def get_user_by_username(username: str):
+    row = db.session.execute(
+        text("SELECT id::text FROM public.users WHERE lower(username) = lower(:username)"),
+        {"username": username},
+    ).scalar_one_or_none()
+    return load_identity(row) if row else None
 
 
-def require_auth(f):
-    @wraps(f)
-    def decorated(*args, **kwargs):
-        auth_header = request.headers.get("Authorization")
-        if not auth_header:
-            return jsonify({"message": "缺少认证头"}), 401
+def init_db() -> bool:
+    logger.warning("init_db is deprecated; use Alembic migrations")
+    return True
 
-        try:
-            # 验证认证头
-            if auth_header != current_app.config["SECRET_KEY"]:
-                return jsonify({"message": "无效的认证信息"}), 401
 
-            return f(*args, **kwargs)
-        except Exception:
-            return jsonify({"message": "认证失败"}), 401
+def require_auth(view):
+    from app.core.system.permissions import require_permissions, READ_GOVERNANCE
 
-    return decorated
+    return require_permissions(READ_GOVERNANCE)(view)

+ 33 - 0
app/core/system/health.py

@@ -10,12 +10,39 @@ import socket
 import psutil
 from flask import current_app
 
+from app import db
+from app.core.events import outbox_health
 from app.services.db_healthcheck import check_database_connection
 from app.services.neo4j_driver import Neo4jDriver
 
 logger = logging.getLogger(__name__)
 
 
+def data_source_pool_health():
+    """Aggregate existing Worker pools without creating the runtime."""
+    from app.core.data_source.runtime import peek_data_source_manager
+
+    manager = peek_data_source_manager()
+    statuses = manager.snapshot() if manager is not None else []
+    return {
+        "active_pool_count": sum(
+            1 for status in statuses if not status.draining
+        ),
+        "degraded_pool_count": sum(
+            1 for status in statuses if status.pool_state == "degraded"
+        ),
+        "open_circuit_count": sum(
+            1 for status in statuses if status.pool_state == "open"
+        ),
+        "checked_out_total": sum(
+            int(status.checked_out) for status in statuses
+        ),
+        "pool_timeout_total": sum(
+            int(status.pool_timeout_total) for status in statuses
+        ),
+    }
+
+
 def check_neo4j_connection():
     """
     检查Neo4j数据库连接状态
@@ -40,7 +67,13 @@ def check_system_health():
         "neo4j": Neo4jDriver().verify_connectivity(),
         "environment": current_app.config["FLASK_ENV"],
         "platform": current_app.config["PLATFORM"],
+        "datasource_pools": data_source_pool_health(),
     }
+    try:
+        health_status["outbox"] = outbox_health(db.session)
+    except Exception as exc:
+        logger.warning("Outbox health unavailable: %s", exc)
+        health_status["outbox"] = {"status": "unavailable"}
 
     # 检查所有组件是否都正常
     all_healthy = all([health_status["database"], health_status["neo4j"]])

+ 41 - 0
app/core/system/models.py

@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+from app import db
+
+
+class User(db.Model):
+    __tablename__ = "users"
+
+    id = db.Column(db.Uuid(as_uuid=False), primary_key=True)
+    username = db.Column(db.String(64), unique=True, nullable=False)
+    display_name = db.Column(db.String(100))
+    password_hash = db.Column(db.Text, nullable=False)
+    status = db.Column(db.String(20), nullable=False, default="active")
+    created_at = db.Column(db.DateTime(timezone=True), nullable=False)
+    updated_at = db.Column(db.DateTime(timezone=True), nullable=False)
+    last_login_at = db.Column(db.DateTime(timezone=True))
+
+
+class Role(db.Model):
+    __tablename__ = "roles"
+
+    id = db.Column(db.Uuid(as_uuid=False), primary_key=True)
+    name = db.Column(db.String(32), unique=True, nullable=False)
+    description = db.Column(db.String(200), nullable=False)
+
+
+class UserRole(db.Model):
+    __tablename__ = "user_roles"
+
+    user_id = db.Column(db.Uuid(as_uuid=False), primary_key=True)
+    role_id = db.Column(db.Uuid(as_uuid=False), primary_key=True)
+
+
+class AuthAuditEvent(db.Model):
+    __tablename__ = "auth_audit_events"
+
+    id = db.Column(db.BigInteger, primary_key=True)
+    user_id = db.Column(db.Uuid(as_uuid=False))
+    username = db.Column(db.String(64))
+    event_type = db.Column(db.String(40), nullable=False)
+    success = db.Column(db.Boolean, nullable=False)

+ 135 - 0
app/core/system/permissions.py

@@ -0,0 +1,135 @@
+from __future__ import annotations
+
+from functools import wraps
+from typing import Iterable
+
+from flask import current_app, g, jsonify, request
+
+from app.models.result import failed
+
+
+READ_GOVERNANCE = "governance:read"
+EDIT_GOVERNANCE = "governance:edit"
+APPROVE_REVIEW = "review:approve"
+MANAGE_USERS = "users:manage"
+ACTIVATE_WORKFLOW = "workflow:activate"
+OPERATE_ORDERS = "orders:operate"
+DATASOURCE_POOL_MANAGE = "datasources:pools:manage"
+
+ROLE_PERMISSIONS = {
+    "viewer": frozenset({READ_GOVERNANCE}),
+    "editor": frozenset(
+        {READ_GOVERNANCE, EDIT_GOVERNANCE, APPROVE_REVIEW, OPERATE_ORDERS}
+    ),
+    "admin": frozenset(
+        {
+            READ_GOVERNANCE,
+            EDIT_GOVERNANCE,
+            APPROVE_REVIEW,
+            MANAGE_USERS,
+            ACTIVATE_WORKFLOW,
+            OPERATE_ORDERS,
+            DATASOURCE_POOL_MANAGE,
+        }
+    ),
+}
+
+PUBLIC = "public"
+
+
+def permission_for_request(path: str, method: str) -> tuple[str, ...]:
+    """Classify every API request in one auditable, deny-by-default policy."""
+    method = method.upper()
+    if path in {"/api/system/health", "/api/system/auth/login"}:
+        return (PUBLIC,)
+    if path.startswith("/api/system/users"):
+        return (MANAGE_USERS,)
+    if path.startswith("/api/system/workbench"):
+        return (READ_GOVERNANCE,)
+    if (
+        path == "/api/datasource/pools"
+        or (
+            path.startswith("/api/datasource/")
+            and (
+                path.endswith("/pool")
+                or path.endswith("/pool/invalidate")
+            )
+        )
+    ):
+        return (DATASOURCE_POOL_MANAGE,)
+    if path == "/api/datasource/list":
+        return (READ_GOVERNANCE,)
+    if path.startswith("/api/system/") and not path.startswith("/api/system/auth/me"):
+        return (MANAGE_USERS,)
+    if path.startswith("/api/dataflow/") and any(
+        marker in path.lower() for marker in ("activate", "publish", "execute")
+    ):
+        return (ACTIVATE_WORKFLOW,)
+    if "order" in path.lower() and method != "GET":
+        return (OPERATE_ORDERS,)
+    if method == "GET":
+        return (READ_GOVERNANCE,)
+    if method in {"POST", "PUT", "PATCH", "DELETE"}:
+        return (EDIT_GOVERNANCE,)
+    return (MANAGE_USERS,)
+
+
+def permissions_for_roles(roles: Iterable[str]) -> frozenset[str]:
+    result: set[str] = set()
+    for role in roles:
+        result.update(ROLE_PERMISSIONS.get(role, ()))
+    return frozenset(result)
+
+
+def authenticate_request() -> dict | None:
+    from app.core.system.auth import load_identity_from_token
+
+    existing = getattr(g, "current_user", None)
+    if existing:
+        return existing
+    header = request.headers.get("Authorization", "")
+    if not header.startswith("Bearer "):
+        return None
+    token = header[7:].strip()
+    if not token:
+        return None
+    return load_identity_from_token(token, secret=current_app.config["SECRET_KEY"])
+
+
+def configure_api_authorization(app) -> None:
+    @app.before_request
+    def enforce_api_policy():
+        if not request.path.startswith("/api/") or request.method == "OPTIONS":
+            return None
+        required = permission_for_request(request.path, request.method)
+        if required == (PUBLIC,):
+            return None
+        identity = authenticate_request()
+        if identity is None:
+            return jsonify(failed("未登录或登录已过期", code=401)), 401
+        permissions = permissions_for_roles(identity["roles"])
+        if not set(required).issubset(permissions):
+            return jsonify(failed("权限不足", code=403)), 403
+        identity["permissions"] = sorted(permissions)
+        g.current_user = identity
+        return None
+
+
+def require_permissions(*required: str):
+    def decorator(view):
+        @wraps(view)
+        def wrapped(*args, **kwargs):
+            identity = authenticate_request()
+            if identity is None:
+                return jsonify(failed("未登录或登录已过期", code=401)), 401
+            permissions = permissions_for_roles(identity["roles"])
+            if not set(required).issubset(permissions):
+                return jsonify(failed("权限不足", code=403)), 403
+            identity["permissions"] = sorted(permissions)
+            g.current_user = identity
+            return view(*args, **kwargs)
+
+        wrapped.required_permissions = tuple(required)
+        return wrapped
+
+    return decorator

+ 56 - 0
app/core/system/tokens.py

@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+
+import jwt
+
+from app.core.common.identifiers import new_governance_uid
+
+
+class TokenError(ValueError):
+    pass
+
+
+def issue_access_token(
+    *,
+    user_id: str,
+    roles: list[str],
+    secret: str,
+    now: datetime | None = None,
+    lifetime: timedelta = timedelta(minutes=30),
+) -> str:
+    now = now or datetime.now(timezone.utc)
+    claims = {
+        "sub": str(user_id),
+        "roles": sorted(set(roles)),
+        "iat": int(now.timestamp()),
+        "exp": int((now + lifetime).timestamp()),
+        "jti": new_governance_uid(),
+    }
+    return jwt.encode(claims, secret, algorithm="HS256")
+
+
+def decode_access_token(
+    token: str,
+    *,
+    secret: str,
+    now: datetime | None = None,
+) -> dict:
+    try:
+        claims = jwt.decode(
+            token,
+            secret,
+            algorithms=["HS256"],
+            options={"verify_exp": False, "require": ["sub", "roles", "iat", "exp", "jti"]},
+        )
+    except jwt.PyJWTError as exc:
+        raise TokenError("invalid access token") from exc
+
+    current = int((now or datetime.now(timezone.utc)).timestamp())
+    if int(claims["exp"]) <= current:
+        raise TokenError("access token expired")
+    if int(claims["iat"]) > current + 30:
+        raise TokenError("access token issued in the future")
+    if not isinstance(claims.get("roles"), list):
+        raise TokenError("invalid role claims")
+    return {key: claims[key] for key in ("sub", "roles", "iat", "exp", "jti")}

+ 1 - 0
app/core/workbench/__init__.py

@@ -0,0 +1 @@
+"""User workbench registry and persistence."""

+ 40 - 0
app/core/workbench/registry.py

@@ -0,0 +1,40 @@
+from __future__ import annotations
+
+from typing import Any
+
+from app.core.system.permissions import READ_GOVERNANCE
+
+
+WIDGET_REGISTRY = {
+    "pending_reviews": {"title": "待审核数量", "min_w": 2, "max_w": 6, "min_h": 2, "max_h": 4, "permission": READ_GOVERNANCE},
+    "order_status": {"title": "数据订单状态", "min_w": 3, "max_w": 8, "min_h": 2, "max_h": 6, "permission": READ_GOVERNANCE},
+    "product_stats": {"title": "数据产品统计", "min_w": 2, "max_w": 6, "min_h": 2, "max_h": 4, "permission": READ_GOVERNANCE},
+    "datasource_health": {"title": "数据源健康", "min_w": 3, "max_w": 8, "min_h": 2, "max_h": 6, "permission": READ_GOVERNANCE},
+    "n8n_executions": {"title": "n8n 执行概览", "min_w": 3, "max_w": 8, "min_h": 2, "max_h": 6, "permission": READ_GOVERNANCE},
+}
+
+DEFAULT_LAYOUT = [
+    {"id": "pending_reviews", "x": 0, "y": 0, "w": 3, "h": 2},
+    {"id": "order_status", "x": 3, "y": 0, "w": 5, "h": 3},
+    {"id": "product_stats", "x": 8, "y": 0, "w": 3, "h": 2},
+    {"id": "datasource_health", "x": 0, "y": 3, "w": 5, "h": 3},
+    {"id": "n8n_executions", "x": 5, "y": 3, "w": 6, "h": 3},
+]
+
+
+def validate_layout(widgets: list[dict[str, Any]]) -> list[dict[str, Any]]:
+    if not isinstance(widgets, list):
+        raise ValueError("widgets must be a list")
+    seen = set()
+    cleaned = []
+    for item in widgets:
+        widget_id = item.get("id")
+        spec = WIDGET_REGISTRY.get(widget_id)
+        if not spec or widget_id in seen:
+            raise ValueError("unknown or duplicate widget")
+        seen.add(widget_id)
+        values = {key: int(item.get(key, 0)) for key in ("x", "y", "w", "h")}
+        if values["x"] < 0 or values["y"] < 0 or not (spec["min_w"] <= values["w"] <= spec["max_w"]) or not (spec["min_h"] <= values["h"] <= spec["max_h"]):
+            raise ValueError(f"invalid size or position for {widget_id}")
+        cleaned.append({"id": widget_id, **values})
+    return cleaned

+ 0 - 235
app/scripts/create_calendar_records_table.py

@@ -1,235 +0,0 @@
-#!/usr/bin/env python3
-"""
-日历内容记录表创建脚本
-用于创建、检查和删除calendar_records表
-"""
-
-import logging
-import os
-import sys
-
-# 添加项目根目录到路径
-sys.path.append(
-    os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-)
-
-from sqlalchemy import text
-
-from app import create_app, db
-
-# 配置日志
-logging.basicConfig(
-    level=logging.INFO,
-    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
-    handlers=[
-        logging.FileHandler("calendar_records_migration.log", encoding="utf-8"),
-        logging.StreamHandler(),
-    ],
-)
-
-logger = logging.getLogger(__name__)
-
-
-def create_calendar_records_table():
-    """
-    创建日历内容记录表
-
-    Returns:
-        bool: 创建成功返回True,失败返回False
-    """
-    try:
-        app = create_app()
-        with app.app_context():
-            logger.info("开始创建日历内容记录表...")
-
-            # 读取DDL脚本
-            sql_file_path = os.path.join(
-                os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
-                "database",
-                "create_calendar_records.sql",
-            )
-
-            if not os.path.exists(sql_file_path):
-                logger.error(f"DDL脚本文件不存在: {sql_file_path}")
-                return False
-
-            with open(sql_file_path, "r", encoding="utf-8") as f:
-                sql_content = f.read()
-
-            # 执行DDL脚本
-            with db.engine.connect() as connection:
-                # 分割SQL语句并逐个执行
-                sql_statements = [
-                    stmt.strip() for stmt in sql_content.split(";") if stmt.strip()
-                ]
-
-                for statement in sql_statements:
-                    if statement:
-                        logger.debug(f"执行SQL: {statement[:100]}...")
-                        connection.execute(text(statement))
-
-                connection.commit()
-
-            logger.info("日历内容记录表创建成功")
-            return True
-
-    except Exception as e:
-        logger.error(f"创建日历内容记录表失败: {str(e)}", exc_info=True)
-        return False
-
-
-def check_calendar_records_table():
-    """
-    检查日历内容记录表是否存在
-
-    Returns:
-        bool: 表存在返回True,不存在返回False
-    """
-    try:
-        app = create_app()
-        with app.app_context():
-            logger.info("检查日历内容记录表是否存在...")
-
-            with db.engine.connect() as connection:
-                result = connection.execute(
-                    text("""
-                    SELECT EXISTS (
-                        SELECT FROM information_schema.tables 
-                        WHERE table_schema = 'public' 
-                        AND table_name = 'calendar_records'
-                    );
-                """)
-                )
-
-                exists = result.scalar()
-
-                if exists:
-                    logger.info("日历内容记录表已存在")
-
-                    # 获取表结构信息
-                    result = connection.execute(
-                        text("""
-                        SELECT column_name, data_type, is_nullable, column_default
-                        FROM information_schema.columns
-                        WHERE table_schema = 'public' AND table_name = 'calendar_records'
-                        ORDER BY ordinal_position;
-                    """)
-                    )
-
-                    columns = result.fetchall()
-                    logger.info("表结构:")
-                    for col in columns:
-                        logger.info(
-                            f"  {col[0]}: {col[1]} ({'NULL' if col[2] == 'YES' else 'NOT NULL'}) {col[3] or ''}"
-                        )
-
-                    # 获取索引信息
-                    result = connection.execute(
-                        text("""
-                        SELECT indexname, indexdef
-                        FROM pg_indexes
-                        WHERE tablename = 'calendar_records' AND schemaname = 'public';
-                    """)
-                    )
-
-                    indexes = result.fetchall()
-                    if indexes:
-                        logger.info("索引:")
-                        for idx in indexes:
-                            logger.info(f"  {idx[0]}: {idx[1]}")
-                else:
-                    logger.info("日历内容记录表不存在")
-
-                return exists
-
-    except Exception as e:
-        logger.error(f"检查日历内容记录表失败: {str(e)}", exc_info=True)
-        return False
-
-
-def drop_calendar_records_table():
-    """
-    删除日历内容记录表
-
-    Returns:
-        bool: 删除成功返回True,失败返回False
-    """
-    try:
-        app = create_app()
-        with app.app_context():
-            logger.info("开始删除日历内容记录表...")
-
-            with db.engine.connect() as connection:
-                # 删除触发器
-                connection.execute(
-                    text(
-                        "DROP TRIGGER IF EXISTS trigger_update_calendar_records_updated_at ON public.calendar_records;"
-                    )
-                )
-
-                # 删除触发器函数
-                connection.execute(
-                    text(
-                        "DROP FUNCTION IF EXISTS update_calendar_records_updated_at();"
-                    )
-                )
-
-                # 删除表
-                connection.execute(
-                    text("DROP TABLE IF EXISTS public.calendar_records CASCADE;")
-                )
-
-                connection.commit()
-
-            logger.info("日历内容记录表删除成功")
-            return True
-
-    except Exception as e:
-        logger.error(f"删除日历内容记录表失败: {str(e)}", exc_info=True)
-        return False
-
-
-def main():
-    """
-    主函数
-    """
-    if len(sys.argv) != 2:
-        print("使用方法:")
-        print("  python create_calendar_records_table.py create   # 创建表")
-        print("  python create_calendar_records_table.py check    # 检查表")
-        print("  python create_calendar_records_table.py drop     # 删除表")
-        sys.exit(1)
-
-    action = sys.argv[1].lower()
-
-    if action == "create":
-        success = create_calendar_records_table()
-        if success:
-            print("✅ 日历内容记录表创建成功")
-        else:
-            print("❌ 日历内容记录表创建失败")
-            sys.exit(1)
-
-    elif action == "check":
-        exists = check_calendar_records_table()
-        if exists:
-            print("✅ 日历内容记录表存在")
-        else:
-            print("❌ 日历内容记录表不存在")
-
-    elif action == "drop":
-        success = drop_calendar_records_table()
-        if success:
-            print("✅ 日历内容记录表删除成功")
-        else:
-            print("❌ 日历内容记录表删除失败")
-            sys.exit(1)
-
-    else:
-        print(f"未知操作: {action}")
-        print("支持的操作: create, check, drop")
-        sys.exit(1)
-
-
-if __name__ == "__main__":
-    main()

+ 0 - 242
app/scripts/migrate_wechat_users.py

@@ -1,242 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-
-"""
-微信用户表迁移脚本
-创建微信用户表和相关索引
-"""
-
-import logging
-import os
-import sys
-
-import psycopg2
-
-# 添加项目根目录到Python路径
-sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
-
-from app.config.config import config, current_env
-
-# 获取配置
-app_config = config[current_env]
-
-# 配置日志
-log_level_name = getattr(app_config, "LOG_LEVEL", "INFO")
-log_level = getattr(logging, log_level_name)
-log_format = getattr(
-    app_config, "LOG_FORMAT", "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
-)
-
-logging.basicConfig(level=log_level, format=log_format)
-logger = logging.getLogger(__name__)
-
-
-def get_database_connection():
-    """
-    获取数据库连接
-
-    Returns:
-        psycopg2.connection: 数据库连接对象
-    """
-    try:
-        # 从配置中获取数据库连接信息
-        db_config = {
-            "host": app_config.PG_HOST,
-            "port": app_config.PG_PORT,
-            "database": app_config.PG_DATABASE,
-            "user": app_config.PG_USERNAME,
-            "password": app_config.PG_PASSWORD,
-        }
-
-        connection = psycopg2.connect(**db_config)
-        logger.info("成功连接到数据库")
-        return connection
-
-    except Exception as e:
-        logger.error(f"连接数据库失败: {str(e)}")
-        raise
-
-
-def check_table_exists(connection, table_name, schema="public"):
-    """
-    检查表是否存在
-
-    Args:
-        connection: 数据库连接
-        table_name (str): 表名
-        schema (str): 模式名,默认为public
-
-    Returns:
-        bool: 表存在返回True,否则返回False
-    """
-    try:
-        with connection.cursor() as cursor:
-            cursor.execute(
-                """
-                SELECT EXISTS (
-                    SELECT FROM information_schema.tables 
-                    WHERE table_schema = %s AND table_name = %s
-                );
-            """,
-                (schema, table_name),
-            )
-
-            result = cursor.fetchone()
-            return result[0] if result else False
-
-    except Exception as e:
-        logger.error(f"检查表是否存在时发生错误: {str(e)}")
-        return False
-
-
-def create_wechat_users_table(connection):
-    """
-    创建微信用户表
-
-    Args:
-        connection: 数据库连接
-
-    Returns:
-        bool: 创建成功返回True,否则返回False
-    """
-    try:
-        # 读取SQL DDL文件
-        sql_file_path = os.path.join(
-            os.path.dirname(__file__), "../../database/create_wechat_users.sql"
-        )
-
-        if not os.path.exists(sql_file_path):
-            logger.error(f"SQL文件不存在: {sql_file_path}")
-            return False
-
-        with open(sql_file_path, "r", encoding="utf-8") as file:
-            sql_content = file.read()
-
-        with connection.cursor() as cursor:
-            # 执行SQL脚本
-            cursor.execute(sql_content)
-            connection.commit()
-
-        logger.info("微信用户表创建成功")
-        return True
-
-    except Exception as e:
-        logger.error(f"创建微信用户表失败: {str(e)}")
-        connection.rollback()
-        return False
-
-
-def migrate_wechat_users():
-    """
-    执行微信用户表迁移
-
-    Returns:
-        bool: 迁移成功返回True,否则返回False
-    """
-    connection = None
-
-    try:
-        # 获取数据库连接
-        connection = get_database_connection()
-
-        # 检查表是否已存在
-        if check_table_exists(connection, "wechat_users"):
-            logger.warning("微信用户表已存在,跳过创建")
-            return True
-
-        logger.info("开始创建微信用户表...")
-
-        # 创建微信用户表
-        if create_wechat_users_table(connection):
-            logger.info("微信用户表迁移完成")
-            return True
-        else:
-            logger.error("微信用户表迁移失败")
-            return False
-
-    except Exception as e:
-        logger.error(f"迁移过程中发生错误: {str(e)}")
-        return False
-
-    finally:
-        if connection:
-            connection.close()
-            logger.info("数据库连接已关闭")
-
-
-def rollback_wechat_users():
-    """
-    回滚微信用户表迁移(删除表)
-
-    Returns:
-        bool: 回滚成功返回True,否则返回False
-    """
-    connection = None
-
-    try:
-        # 获取数据库连接
-        connection = get_database_connection()
-
-        # 检查表是否存在
-        if not check_table_exists(connection, "wechat_users"):
-            logger.warning("微信用户表不存在,无需回滚")
-            return True
-
-        logger.info("开始回滚微信用户表...")
-
-        with connection.cursor() as cursor:
-            # 删除表
-            cursor.execute("DROP TABLE IF EXISTS public.wechat_users CASCADE;")
-            connection.commit()
-
-        logger.info("微信用户表回滚完成")
-        return True
-
-    except Exception as e:
-        logger.error(f"回滚过程中发生错误: {str(e)}")
-        if connection:
-            connection.rollback()
-        return False
-
-    finally:
-        if connection:
-            connection.close()
-            logger.info("数据库连接已关闭")
-
-
-def main():
-    """
-    主函数,根据命令行参数执行相应操作
-    """
-    import argparse
-
-    parser = argparse.ArgumentParser(description="微信用户表迁移脚本")
-    parser.add_argument(
-        "--action",
-        choices=["migrate", "rollback"],
-        default="migrate",
-        help="执行的操作:migrate(迁移)或 rollback(回滚)",
-    )
-
-    args = parser.parse_args()
-
-    if args.action == "migrate":
-        logger.info("开始执行微信用户表迁移...")
-        success = migrate_wechat_users()
-    elif args.action == "rollback":
-        logger.info("开始执行微信用户表回滚...")
-        success = rollback_wechat_users()
-    else:
-        logger.error("未知的操作类型")
-        sys.exit(1)
-
-    if success:
-        logger.info("操作完成")
-        sys.exit(0)
-    else:
-        logger.error("操作失败")
-        sys.exit(1)
-
-
-if __name__ == "__main__":
-    main()

+ 3 - 5
app/services/db_healthcheck.py

@@ -1,7 +1,6 @@
 import logging
 
-from flask import current_app
-from sqlalchemy import create_engine, text
+from sqlalchemy import text
 from sqlalchemy.exc import OperationalError
 
 from app import db
@@ -13,9 +12,8 @@ logger = logging.getLogger(__name__)
 def check_database_connection():
     """检查数据库连接状态"""
     try:
-        engine = create_engine(current_app.config["SQLALCHEMY_DATABASE_URI"])
-        connection = engine.connect()
-        connection.close()
+        with db.engine.connect() as connection:
+            connection.execute(text("SELECT 1"))
         return True
     except OperationalError as e:
         logger.error(f"数据库连接失败: {str(e)}")

+ 1 - 0
app/services/embedding/__init__.py

@@ -0,0 +1 @@
+"""Embedding provider adapters."""

+ 30 - 0
app/services/embedding/qwen.py

@@ -0,0 +1,30 @@
+from __future__ import annotations
+import hashlib
+from typing import Any
+import requests
+
+
+class QwenEmbeddingError(RuntimeError):
+    pass
+
+
+class QwenEmbeddingClient:
+    def __init__(self, *, api_key: str, base_url: str, model: str, dimension: int = 1024, timeout: int = 30):
+        if not api_key or not base_url or not model:
+            raise QwenEmbeddingError("Qwen embedding configuration is incomplete")
+        self.api_key, self.base_url, self.model = api_key, base_url.rstrip("/"), model
+        self.dimension, self.timeout = dimension, timeout
+
+    def cache_key(self, text: str) -> str:
+        return hashlib.sha256(f"{self.model}:{self.dimension}:{text}".encode()).hexdigest()
+
+    def embed(self, texts: list[str]) -> list[list[float]]:
+        try:
+            response = requests.post(f"{self.base_url}/embeddings", headers={"Authorization": "Bearer " + self.api_key}, json={"model": self.model, "input": texts, "dimensions": self.dimension}, timeout=self.timeout)
+            response.raise_for_status()
+            vectors = [item["embedding"] for item in sorted(response.json()["data"], key=lambda item: item["index"])]
+        except requests.RequestException as exc:
+            raise QwenEmbeddingError("Qwen embedding request failed") from exc
+        if len(vectors) != len(texts) or any(len(vector) != self.dimension for vector in vectors):
+            raise QwenEmbeddingError("Qwen embedding dimension mismatch")
+        return vectors

+ 6 - 2
app/services/neo4j_driver.py

@@ -1,7 +1,10 @@
+import logging
 import os
 
 from neo4j import GraphDatabase
-from neo4j.exceptions import ServiceUnavailable
+from neo4j.exceptions import AuthError, ServiceUnavailable
+
+logger = logging.getLogger(__name__)
 
 
 class Neo4jDriver:
@@ -109,7 +112,8 @@ class Neo4jDriver:
         try:
             self.connect().verify_connectivity()
             return True
-        except ServiceUnavailable:
+        except (ServiceUnavailable, AuthError) as exc:
+            logger.error(f"Neo4j连接失败: {exc}")
             return False
 
     def get_session(self):

+ 0 - 24
database/add_color_field_to_calendar_info.sql

@@ -1,24 +0,0 @@
--- 为calendar_info表添加color字段的迁移脚本
--- 执行时间: 2025-01-20
-
-BEGIN;
-
--- 添加color字段到calendar_info表
-ALTER TABLE public.calendar_info 
-ADD COLUMN color varchar(10);
-
--- 添加字段注释
-COMMENT ON COLUMN public.calendar_info.color IS '颜色';
-
--- 可选:设置默认值(根据需要取消注释)
--- UPDATE public.calendar_info SET color = 'default' WHERE color IS NULL;
-
-COMMIT;
-
--- 验证字段是否成功添加
-SELECT column_name, data_type, character_maximum_length, is_nullable 
-FROM information_schema.columns 
-WHERE table_name = 'calendar_info' 
-  AND table_schema = 'public' 
-  AND column_name = 'color';
-

+ 0 - 63
database/add_origin_source_field.sql

@@ -1,63 +0,0 @@
--- ===============================================
--- 修改business_cards表,新增origin_source字段
--- 执行日期: 2024年(请在执行前填写实际日期)
--- 修改说明: 为名片表新增原始资料记录字段,采用JSON格式保存原始资料信息
--- ===============================================
-
--- 1. 新增origin_source字段 (原始资料记录字段)
-ALTER TABLE business_cards 
-ADD COLUMN origin_source JSON;
-
--- 2. 为新增字段添加注释
-COMMENT ON COLUMN business_cards.origin_source IS '原始资料记录字段 - 采用JSON格式保存原始资料信息,包括数据来源、MinIO路径等';
-
--- 3. 验证字段是否成功添加
-SELECT 
-    column_name,
-    data_type,
-    is_nullable,
-    column_default
-FROM information_schema.columns 
-WHERE table_name = 'business_cards' 
-    AND column_name = 'origin_source'
-ORDER BY column_name;
-
--- 4. 查看字段注释
-SELECT 
-    a.attname AS column_name,
-    format_type(a.atttypid, a.atttypmod) AS data_type,
-    COALESCE(pg_catalog.col_description(a.attrelid, a.attnum), '无注释') AS description
-FROM 
-    pg_catalog.pg_attribute a
-JOIN 
-    pg_catalog.pg_class c ON a.attrelid = c.oid
-JOIN 
-    pg_catalog.pg_namespace n ON c.relnamespace = n.oid
-WHERE 
-    c.relname = 'business_cards' 
-    AND a.attname = 'origin_source'
-    AND a.attnum > 0 
-    AND NOT a.attisdropped
-ORDER BY a.attname;
-
--- ===============================================
--- 执行说明:
--- 1. 请在生产环境执行前先在测试环境验证
--- 2. 建议在业务低峰期执行此脚本
--- 3. 执行前请备份相关数据
--- 4. 新增字段允许NULL值,不会影响现有数据
--- 5. origin_source字段用于存储原始资料的JSON数据
--- ===============================================
-
--- 可选:示例数据格式说明
--- origin_source字段的JSON格式示例:
--- {
---   "type": "webpage_talent",
---   "minio_path": "webpage_talent/webpage_talent_20240101_12345.md",
---   "source_date": "2024-01-01 12:00:00",
---   "talent_data": {...},
---   "web_md_content": "部分网页内容..."
--- }
-
--- 执行完成后检查表结构
-\d business_cards; 

+ 0 - 32
database/alter_business_cards_simple.sql

@@ -1,32 +0,0 @@
--- ===============================================
--- 修改business_cards表,新增age和native_place字段 (简化版本)
--- 执行日期: 请在执行前填写实际日期
--- 修改说明: 为名片表新增年龄和籍贯字段
--- ===============================================
-
--- 步骤1: 添加age字段
-ALTER TABLE business_cards ADD COLUMN age INTEGER;
-
--- 步骤2: 添加native_place字段
-ALTER TABLE business_cards ADD COLUMN native_place TEXT;
-
--- 步骤3: 添加字段注释
-COMMENT ON COLUMN business_cards.age IS '年龄字段 - 存储人员年龄信息,取值范围1-150';
-COMMENT ON COLUMN business_cards.native_place IS '籍贯字段 - 存储人员籍贯或出生地信息';
-
--- 步骤4: 验证字段是否添加成功
-SELECT column_name, data_type, is_nullable
-FROM information_schema.columns 
-WHERE table_name = 'business_cards' 
-  AND column_name IN ('age', 'native_place')
-ORDER BY column_name;
-
--- 步骤5: 查看表结构(需要在psql客户端中执行)
--- \d business_cards
-
--- ===============================================
--- 执行说明:
--- 1. 一次执行一个语句,避免批量执行出现问题
--- 2. 新增字段允许NULL值,不会影响现有数据
--- 3. 执行前请备份数据库
--- =============================================== 

+ 0 - 61
database/alter_business_cards_table.sql

@@ -1,61 +0,0 @@
--- ===============================================
--- 修改business_cards表,新增age和native_place字段
--- 执行日期: 请在执行前填写实际日期
--- 修改说明: 为名片表新增年龄和籍贯字段,支持更完整的人员信息管理
--- ===============================================
-
--- 1. 新增age字段 (年龄字段)
-ALTER TABLE business_cards 
-ADD COLUMN age INTEGER;
-
--- 2. 新增native_place字段 (籍贯字段)  
-ALTER TABLE business_cards 
-ADD COLUMN native_place TEXT;
-
--- 3. 为新增字段添加注释
-COMMENT ON COLUMN business_cards.age IS '年龄字段 - 存储人员年龄信息,取值范围1-150';
-COMMENT ON COLUMN business_cards.native_place IS '籍贯字段 - 存储人员籍贯或出生地信息';
-
--- 4. 验证字段是否成功添加
-SELECT 
-    column_name,
-    data_type,
-    is_nullable,
-    column_default
-FROM information_schema.columns 
-WHERE table_name = 'business_cards' 
-    AND column_name IN ('age', 'native_place')
-ORDER BY column_name;
-
--- 5. 查看字段注释
-SELECT 
-    a.attname AS column_name,
-    format_type(a.atttypid, a.atttypmod) AS data_type,
-    COALESCE(pg_catalog.col_description(a.attrelid, a.attnum), '无注释') AS description
-FROM 
-    pg_catalog.pg_attribute a
-JOIN 
-    pg_catalog.pg_class c ON a.attrelid = c.oid
-JOIN 
-    pg_catalog.pg_namespace n ON c.relnamespace = n.oid
-WHERE 
-    c.relname = 'business_cards' 
-    AND a.attname IN ('age', 'native_place')
-    AND a.attnum > 0 
-    AND NOT a.attisdropped
-ORDER BY a.attname;
-
--- ===============================================
--- 执行说明:
--- 1. 请在生产环境执行前先在测试环境验证
--- 2. 建议在业务低峰期执行此脚本
--- 3. 执行前请备份相关数据
--- 4. 新增字段允许NULL值,不会影响现有数据
--- ===============================================
-
--- 可选:如果需要为现有记录设置默认值,可以执行以下语句
--- UPDATE business_cards SET age = NULL WHERE age IS NULL;
--- UPDATE business_cards SET native_place = '' WHERE native_place IS NULL;
-
--- 执行完成后检查表结构
-\d business_cards; 

+ 0 - 118
database/check_business_cards_table.sql

@@ -1,118 +0,0 @@
--- ===============================================
--- 检查business_cards表结构和字段状态
--- 用途: 验证age和native_place字段的添加情况
--- ===============================================
-
--- 1. 检查表是否存在
-SELECT 
-    table_name,
-    table_type,
-    table_schema
-FROM information_schema.tables 
-WHERE table_name = 'business_cards';
-
--- 2. 查看完整的表结构
-SELECT 
-    column_name,
-    data_type,
-    character_maximum_length,
-    is_nullable,
-    column_default,
-    ordinal_position
-FROM information_schema.columns 
-WHERE table_name = 'business_cards'
-ORDER BY ordinal_position;
-
--- 3. 专门检查age和native_place字段
-SELECT 
-    column_name,
-    data_type,
-    character_maximum_length,
-    is_nullable,
-    column_default,
-    CASE 
-        WHEN column_name = 'age' THEN '年龄字段'
-        WHEN column_name = 'native_place' THEN '籍贯字段'
-        ELSE '其他字段'
-    END as field_description
-FROM information_schema.columns 
-WHERE table_name = 'business_cards' 
-    AND column_name IN ('age', 'native_place')
-ORDER BY column_name;
-
--- 4. 查看字段注释
-SELECT 
-    a.attname AS column_name,
-    format_type(a.atttypid, a.atttypmod) AS data_type,
-    COALESCE(pg_catalog.col_description(a.attrelid, a.attnum), '无注释') AS comment
-FROM 
-    pg_catalog.pg_attribute a
-JOIN 
-    pg_catalog.pg_class c ON a.attrelid = c.oid
-JOIN 
-    pg_catalog.pg_namespace n ON c.relnamespace = n.oid
-WHERE 
-    c.relname = 'business_cards' 
-    AND a.attname IN ('age', 'native_place')
-    AND a.attnum > 0 
-    AND NOT a.attisdropped
-ORDER BY a.attname;
-
--- 5. 检查是否有数据使用了新字段
-SELECT 
-    COUNT(*) as total_records,
-    COUNT(age) as records_with_age,
-    COUNT(native_place) as records_with_native_place,
-    COUNT(CASE WHEN age IS NOT NULL THEN 1 END) as non_null_age,
-    COUNT(CASE WHEN native_place IS NOT NULL AND native_place != '' THEN 1 END) as non_empty_native_place
-FROM business_cards;
-
--- 6. 如果有数据,显示样本
-SELECT 
-    id,
-    name_zh,
-    age,
-    native_place,
-    created_at
-FROM business_cards 
-WHERE age IS NOT NULL OR (native_place IS NOT NULL AND native_place != '')
-LIMIT 5;
-
--- 7. 检查age字段的数据范围(如果有数据)
-SELECT 
-    MIN(age) as min_age,
-    MAX(age) as max_age,
-    AVG(age) as avg_age,
-    COUNT(DISTINCT age) as distinct_age_values
-FROM business_cards 
-WHERE age IS NOT NULL;
-
--- 8. 检查native_place字段的数据统计(如果有数据)
-SELECT 
-    COUNT(DISTINCT native_place) as distinct_native_places,
-    LENGTH(MAX(native_place)) as max_length,
-    LENGTH(MIN(native_place)) as min_length
-FROM business_cards 
-WHERE native_place IS NOT NULL AND native_place != '';
-
--- 9. 使用psql命令查看表结构(需要在psql中执行)
--- \d business_cards
-
--- 10. 检查表的所有约束
-SELECT 
-    tc.constraint_name,
-    tc.constraint_type,
-    tc.table_name,
-    kcu.column_name
-FROM information_schema.table_constraints tc
-JOIN information_schema.key_column_usage kcu 
-    ON tc.constraint_name = kcu.constraint_name
-WHERE tc.table_name = 'business_cards'
-ORDER BY tc.constraint_type, tc.constraint_name;
-
--- ===============================================
--- 说明:
--- 1. 此脚本用于验证字段是否正确添加
--- 2. 可以多次执行,不会修改数据
--- 3. 帮助确认数据库结构变更是否成功
--- =============================================== 

+ 0 - 42
database/create_calendar_info.sql

@@ -1,42 +0,0 @@
-create table public.calendar_info
-(
-    id        serial
-        primary key,
-    yangli    date not null,
-    yinli     text not null,
-    wuxing    text,
-    chongsha  text,
-    baiji     text,
-    jishen    text,
-    yi        text,
-    xiongshen text,
-    ji        text,
-    color     varchar(10)
-);
-
-comment on table public.calendar_info is '黄历信息表';
-
-comment on column public.calendar_info.id is '主键ID';
-
-comment on column public.calendar_info.yangli is '阳历日期';
-
-comment on column public.calendar_info.yinli is '阴历日期';
-
-comment on column public.calendar_info.wuxing is '五行';
-
-comment on column public.calendar_info.chongsha is '冲煞';
-
-comment on column public.calendar_info.baiji is '彭祖百忌';
-
-comment on column public.calendar_info.jishen is '吉神宜趋';
-
-comment on column public.calendar_info.yi is '宜';
-
-comment on column public.calendar_info.xiongshen is '凶神宜忌';
-
-comment on column public.calendar_info.ji is '忌';
-
-comment on column public.calendar_info.color is '颜色';
-
-
-

+ 0 - 68
database/create_calendar_records.sql

@@ -1,68 +0,0 @@
--- 日历内容记录表DDL脚本
--- 用于存储用户的日历内容记录信息
-
-create table public.calendar_records
-(
-    id              serial
-        primary key,
-    openid          varchar(255) not null,
-    month_key       varchar(7)   not null,
-    calendar_content jsonb       not null,
-    created_at      timestamp with time zone default current_timestamp not null,
-    updated_at      timestamp with time zone default current_timestamp not null
-);
-
-comment on table public.calendar_records is '日历内容记录表';
-
-comment on column public.calendar_records.id is '主键ID';
-
-comment on column public.calendar_records.openid is '微信用户openid';
-
-comment on column public.calendar_records.month_key is '月份标识,格式为YYYY-MM';
-
-comment on column public.calendar_records.calendar_content is '日历内容,JSON数组格式';
-
-comment on column public.calendar_records.created_at is '记录创建时间';
-
-comment on column public.calendar_records.updated_at is '记录更新时间';
-
--- 创建索引以提高查询性能
-create index idx_calendar_records_openid on public.calendar_records(openid);
-create index idx_calendar_records_month_key on public.calendar_records(month_key);
-create index idx_calendar_records_openid_month on public.calendar_records(openid, month_key);
-create index idx_calendar_records_created_at on public.calendar_records(created_at);
-create index idx_calendar_records_updated_at on public.calendar_records(updated_at);
-
--- 创建更新时间触发器函数
-create or replace function update_calendar_records_updated_at()
-returns trigger as $$
-begin
-    new.updated_at = current_timestamp;
-    return new;
-end;
-$$ language plpgsql;
-
--- 创建触发器
-create trigger trigger_update_calendar_records_updated_at
-    before update on public.calendar_records
-    for each row
-    execute function update_calendar_records_updated_at();
-
--- 创建唯一约束(一个用户在同一个月份只能有一条记录)
-create unique index idx_calendar_records_openid_month_unique 
-    on public.calendar_records(openid, month_key);
-
--- 添加约束检查月份格式
-alter table public.calendar_records 
-add constraint chk_calendar_records_month_format 
-check (month_key ~ '^\d{4}-\d{2}$');
-
--- 添加约束检查openid格式(微信openid通常是28位字符串)
-alter table public.calendar_records 
-add constraint chk_calendar_records_openid_format 
-check (length(openid) = 28 and openid ~ '^[a-zA-Z0-9_-]+$');
-
--- 添加约束检查JSON内容不为空
-alter table public.calendar_records 
-add constraint chk_calendar_records_content_not_empty 
-check (jsonb_array_length(calendar_content) >= 0);

+ 0 - 36
database/create_duplicate_business_cards_table.sql

@@ -1,36 +0,0 @@
--- ================================================================
--- 创建 duplicate_business_cards 表脚本
--- 用于存储重复名片处理记录
--- 创建日期: 2024年
--- ================================================================
-
--- 创建 duplicate_business_cards 表
-CREATE TABLE duplicate_business_cards (
-    id SERIAL PRIMARY KEY,
-    main_card_id INTEGER NOT NULL,
-    suspected_duplicates JSONB NOT NULL,
-    duplicate_reason VARCHAR(200) NOT NULL,
-    processing_status VARCHAR(20) DEFAULT 'pending',
-    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-    processed_at TIMESTAMP,
-    processed_by VARCHAR(50),
-    processing_notes TEXT
-);
-
--- 添加外键约束
-ALTER TABLE duplicate_business_cards 
-ADD CONSTRAINT fk_duplicate_business_cards_main_card_id 
-FOREIGN KEY (main_card_id) REFERENCES business_cards(id) ON DELETE CASCADE;
-
--- 添加表和字段注释
-COMMENT ON TABLE duplicate_business_cards IS '重复名片处理记录表,用于存储发现的疑似重复名片信息和处理状态';
-
-COMMENT ON COLUMN duplicate_business_cards.id IS '主键ID,自增序列';
-COMMENT ON COLUMN duplicate_business_cards.main_card_id IS '新创建的主记录ID,关联business_cards表';
-COMMENT ON COLUMN duplicate_business_cards.suspected_duplicates IS '疑似重复记录列表,JSON格式存储';
-COMMENT ON COLUMN duplicate_business_cards.duplicate_reason IS '重复原因描述,最大200字符';
-COMMENT ON COLUMN duplicate_business_cards.processing_status IS '处理状态:pending(待处理)/processed(已处理)/ignored(已忽略)';
-COMMENT ON COLUMN duplicate_business_cards.created_at IS '记录创建时间';
-COMMENT ON COLUMN duplicate_business_cards.processed_at IS '处理时间,记录被处理时的时间戳';
-COMMENT ON COLUMN duplicate_business_cards.processed_by IS '处理人员标识,最大50字符';
-COMMENT ON COLUMN duplicate_business_cards.processing_notes IS '处理备注,记录处理过程中的详细说明';

+ 0 - 57
database/create_wechat_users.sql

@@ -1,57 +0,0 @@
--- 微信用户表DDL脚本
--- 用于存储微信注册登录用户信息
-
-create table public.wechat_users
-(
-    id              serial
-        primary key,
-    openid          varchar(255) not null unique,
-    phone_number    varchar(20),
-    id_card_number  varchar(18),
-    login_status    boolean default false not null,
-    login_time      timestamp with time zone,
-    user_status     varchar(20) default 'active' not null,
-    created_at      timestamp with time zone default current_timestamp not null,
-    updated_at      timestamp with time zone default current_timestamp not null
-);
-
-comment on table public.wechat_users is '微信用户信息表';
-
-comment on column public.wechat_users.id is '主键ID';
-
-comment on column public.wechat_users.openid is '微信用户openid,唯一标识';
-
-comment on column public.wechat_users.phone_number is '用户手机号码';
-
-comment on column public.wechat_users.id_card_number is '用户身份证号码';
-
-comment on column public.wechat_users.login_status is '当前登录状态,true表示已登录,false表示未登录';
-
-comment on column public.wechat_users.login_time is '最后登录时间';
-
-comment on column public.wechat_users.user_status is '用户账户状态:active-活跃,inactive-非活跃,suspended-暂停,deleted-已删除';
-
-comment on column public.wechat_users.created_at is '账户创建时间';
-
-comment on column public.wechat_users.updated_at is '信息更新时间';
-
--- 创建索引以提高查询性能
-create index idx_wechat_users_openid on public.wechat_users(openid);
-create index idx_wechat_users_phone_number on public.wechat_users(phone_number);
-create index idx_wechat_users_login_status on public.wechat_users(login_status);
-create index idx_wechat_users_user_status on public.wechat_users(user_status);
-
--- 创建更新时间触发器函数
-create or replace function update_updated_at_column()
-returns trigger as $$
-begin
-    new.updated_at = current_timestamp;
-    return new;
-end;
-$$ language plpgsql;
-
--- 为表添加更新时间触发器
-create trigger update_wechat_users_updated_at
-    before update on public.wechat_users
-    for each row
-    execute function update_updated_at_column();

+ 0 - 65
database/hotel_group_brands_ddl.sql

@@ -1,65 +0,0 @@
--- ============================================================================
--- 酒店集团子品牌数据表 DDL
--- 基于文件: 人才地图-字典20250519.xlsx - 各集团子品牌 sheet
--- 创建时间: 2025-01-19
--- 数据规模: 295条记录,27个集团,290个品牌,6个定位级别
--- ============================================================================
-
-CREATE TABLE hotel_group_brands (
-    -- 主键ID,自增序列
-    id SERIAL PRIMARY KEY,
-  
-    -- 集团信息
-    group_name_en VARCHAR(60) NOT NULL,
-    
-    group_name_zh VARCHAR(20) NOT NULL,
-    
-    -- 品牌信息
-    brand_name_en VARCHAR(40) NOT NULL,
-    
-    brand_name_zh VARCHAR(40) NOT NULL,
-    
-    -- 定位级别信息
-    positioning_level_en VARCHAR(20) NOT NULL,
-    
-    positioning_level_zh VARCHAR(5) NOT NULL,
-    
-    -- 审计字段
-    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
-    
-    updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
-    
-    created_by VARCHAR(50) DEFAULT 'system',
-    
-    updated_by VARCHAR(50) DEFAULT 'system',
-    
-    status VARCHAR(20) DEFAULT 'active'
-);
-
--- ============================================================================
--- 表注释
--- ============================================================================
-
-COMMENT ON TABLE hotel_group_brands IS '酒店集团子品牌数据表
-用途:存储全球酒店集团及其子品牌的标准化信息
-数据来源:人才地图-字典20250519.xlsx
-包含内容:27个酒店集团,290个品牌,6个定位级别
-维护说明:定期更新以保持与行业标准一致';
-
--- ============================================================================
--- 字段注释
--- ============================================================================
-
-COMMENT ON COLUMN hotel_group_brands.id IS '主键ID,系统自动生成';
-COMMENT ON COLUMN hotel_group_brands.sequence_no IS '序号,对应Excel中的原始序号,共34个不同序号';
-COMMENT ON COLUMN hotel_group_brands.group_name_en IS '集团英文名称,最大长度50个字符,如:IHG Hotels & Resorts,共27个不同集团';
-COMMENT ON COLUMN hotel_group_brands.group_name_zh IS '集团中文名称,最大长度12个字符,如:洲际酒店集团,共27个不同集团';
-COMMENT ON COLUMN hotel_group_brands.brand_name_en IS '品牌英文名称,最大长度35个字符,如:InterContinental,共290个不同品牌';
-COMMENT ON COLUMN hotel_group_brands.brand_name_zh IS '品牌中文名称,最大长度31个字符,如:洲际酒店及度假村,共294个不同品牌';
-COMMENT ON COLUMN hotel_group_brands.positioning_level_en IS '定位级别英文名称,最大长度14个字符,如:Upper Upscale,共6个级别';
-COMMENT ON COLUMN hotel_group_brands.positioning_level_zh IS '定位级别中文名称,最大长度3个字符,如:超高端,共6个级别';
-COMMENT ON COLUMN hotel_group_brands.created_at IS '记录创建时间,带时区';
-COMMENT ON COLUMN hotel_group_brands.updated_at IS '记录最后更新时间,带时区';
-COMMENT ON COLUMN hotel_group_brands.created_by IS '记录创建者用户标识';
-COMMENT ON COLUMN hotel_group_brands.updated_by IS '记录最后更新者用户标识';
-COMMENT ON COLUMN hotel_group_brands.status IS '记录状态,active=有效,inactive=无效'; 

+ 0 - 75
database/hotel_positions_ddl.sql

@@ -1,75 +0,0 @@
--- ============================================================================
--- 酒店职位名称数据表 DDL
--- 基于文件: 酒店职位名称20250519.xlsx - 汇总版 sheet
--- 创建时间: 2025-01-19
--- 数据规模: 150条记录,9个部门,144个不同职位
--- ============================================================================
-
-CREATE TABLE hotel_positions (
-    -- 主键ID,自增序列
-    id SERIAL PRIMARY KEY,
-    
-    -- 部门信息
-    department_zh VARCHAR(10) NOT NULL,
-    
-    department_en VARCHAR(50) NOT NULL,
-    
-    -- 职位信息  
-    position_zh VARCHAR(20) NOT NULL,
-    
-    position_en VARCHAR(100) NOT NULL,
-    
-    position_abbr VARCHAR(20) NULL,
-    
-    -- 职级信息
-    level_zh VARCHAR(10) NOT NULL,
-    
-    level_en VARCHAR(30) NOT NULL,
-    
-    -- 审计字段
-    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
-    
-    updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
-    
-    created_by VARCHAR(50) DEFAULT 'system',
-    
-    updated_by VARCHAR(50) DEFAULT 'system',
-    
-    status VARCHAR(20) DEFAULT 'active'
-);
-
--- ============================================================================
--- 索引定义
--- ============================================================================
-
--- 职位索引
-CREATE INDEX idx_hotel_positions_position_zh 
-    ON hotel_positions(position_zh);
-
--- ============================================================================
--- 表注释
--- ============================================================================
-
-COMMENT ON TABLE hotel_positions IS '酒店职位名称标准数据表
-用途:存储酒店行业标准化的部门和职位信息
-数据来源:酒店职位名称20250519.xlsx
-包含内容:9个部门,144个不同职位,3个职级
-维护说明:定期更新以保持与行业标准一致';
-
--- ============================================================================
--- 字段详细注释
--- ============================================================================
-
-COMMENT ON COLUMN hotel_positions.id IS '主键ID,系统自动生成';
-COMMENT ON COLUMN hotel_positions.department_zh IS '部门中文名称,最大长度5个字符,如:餐饮部、房务部、市场销售部等。共9个部门:餐饮部(61)、房务部(23)、市场销售部(17)、人力资源部(13)、财务部(13)、行政办公室(8)、工程部(8)、水疗部(4)、保安部(3)';
-COMMENT ON COLUMN hotel_positions.department_en IS '部门英文名称,最大长度28个字符,如:Food & Beverage Department';
-COMMENT ON COLUMN hotel_positions.position_zh IS '职位中文名称,最大长度13个字符,如:总经理、行政助理经理等。共144个不同职位';
-COMMENT ON COLUMN hotel_positions.position_en IS '职位英文名称,最大长度57个字符,如:General Manager, Executive Assistant Manager。共143个不同英文职位名称';
-COMMENT ON COLUMN hotel_positions.position_abbr IS '职位英文缩写,最大长度11个字符,如:GM、EAM、DGM等,允许为空。共33个不同缩写,115条记录为空值';
-COMMENT ON COLUMN hotel_positions.level_zh IS '职级中文名称,最大长度4个字符,包含:经理级(120人)、总监级(23人)、总经理级(7人)';
-COMMENT ON COLUMN hotel_positions.level_en IS '职级英文名称,最大长度22个字符,如:Manager Level、Director Level、General Manager Level';
-COMMENT ON COLUMN hotel_positions.created_at IS '记录创建时间,带时区';
-COMMENT ON COLUMN hotel_positions.updated_at IS '记录最后更新时间,带时区';
-COMMENT ON COLUMN hotel_positions.created_by IS '记录创建者用户标识';
-COMMENT ON COLUMN hotel_positions.updated_by IS '记录最后更新者用户标识';
-COMMENT ON COLUMN hotel_positions.status IS '记录状态,active=有效,inactive=无效';

+ 0 - 35
database/migrate_wechat_code_to_openid.sql

@@ -1,35 +0,0 @@
--- 微信用户表字段迁移脚本
--- 将 wechat_code 字段重命名为 openid
--- 注意:执行前请备份数据!
-
--- 检查表是否存在
-DO $$
-BEGIN
-    IF EXISTS (SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'wechat_users') THEN
-        -- 检查是否已经有openid字段
-        IF NOT EXISTS (SELECT FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'wechat_users' AND column_name = 'openid') THEN
-            -- 如果存在wechat_code字段,则重命名
-            IF EXISTS (SELECT FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'wechat_users' AND column_name = 'wechat_code') THEN
-                -- 重命名字段
-                ALTER TABLE public.wechat_users RENAME COLUMN wechat_code TO openid;
-                
-                -- 更新字段注释
-                COMMENT ON COLUMN public.wechat_users.openid IS '微信用户openid,唯一标识';
-                
-                -- 重命名相关索引
-                IF EXISTS (SELECT FROM pg_class WHERE relname = 'idx_wechat_users_wechat_code') THEN
-                    DROP INDEX IF EXISTS public.idx_wechat_users_wechat_code;
-                    CREATE INDEX idx_wechat_users_openid ON public.wechat_users(openid);
-                END IF;
-                
-                RAISE NOTICE '成功将 wechat_code 字段重命名为 openid';
-            ELSE
-                RAISE NOTICE 'wechat_code 字段不存在,无需迁移';
-            END IF;
-        ELSE
-            RAISE NOTICE 'openid 字段已存在,无需迁移';
-        END IF;
-    ELSE
-        RAISE NOTICE 'wechat_users 表不存在,请先创建表';
-    END IF;
-END $$;

+ 0 - 52
database/rollback_business_cards_table.sql

@@ -1,52 +0,0 @@
--- ===============================================
--- 回滚business_cards表的age和native_place字段修改
--- 执行日期: 请在执行前填写实际日期
--- 回滚说明: 删除之前新增的age和native_place字段
--- ===============================================
-
--- 警告:执行此脚本将永久删除age和native_place字段及其数据
--- 请确保已备份相关数据!
-
--- 1. 检查字段是否存在
-SELECT 
-    column_name,
-    data_type,
-    is_nullable
-FROM information_schema.columns 
-WHERE table_name = 'business_cards' 
-    AND column_name IN ('age', 'native_place')
-ORDER BY column_name;
-
--- 2. 如果需要保留数据,可先导出这些字段的数据
--- SELECT id, name_zh, age, native_place 
--- FROM business_cards 
--- WHERE age IS NOT NULL OR native_place IS NOT NULL;
-
--- 3. 删除native_place字段
-ALTER TABLE business_cards 
-DROP COLUMN IF EXISTS native_place;
-
--- 4. 删除age字段  
-ALTER TABLE business_cards 
-DROP COLUMN IF EXISTS age;
-
--- 5. 验证字段是否已删除
-SELECT 
-    column_name,
-    data_type,
-    is_nullable
-FROM information_schema.columns 
-WHERE table_name = 'business_cards' 
-    AND column_name IN ('age', 'native_place')
-ORDER BY column_name;
-
--- 6. 检查表结构
-\d business_cards;
-
--- ===============================================
--- 执行说明:
--- 1. 此操作不可逆,请谨慎执行
--- 2. 执行前请确保已备份相关数据
--- 3. 建议在业务低峰期执行
--- 4. 如果有应用程序依赖这些字段,请先更新应用代码
--- =============================================== 

+ 0 - 61
database/step_by_step_alter.sql

@@ -1,61 +0,0 @@
--- ===============================================
--- 分步骤修改business_cards表 - 逐步执行
--- 说明:请一步一步执行,每执行一步后检查结果
--- ===============================================
-
--- 第1步:检查表是否存在
-SELECT table_name FROM information_schema.tables WHERE table_name = 'business_cards';
--- 期望结果:应该返回 business_cards
-
--- 第2步:查看当前表结构
-SELECT column_name, data_type FROM information_schema.columns 
-WHERE table_name = 'business_cards' ORDER BY ordinal_position;
--- 查看当前字段列表
-
--- 第3步:添加age字段
-ALTER TABLE business_cards ADD COLUMN age INTEGER;
--- 执行后应该返回:ALTER TABLE
-
--- 第4步:验证age字段是否添加成功
-SELECT column_name, data_type FROM information_schema.columns 
-WHERE table_name = 'business_cards' AND column_name = 'age';
--- 期望结果:age | integer
-
--- 第5步:添加native_place字段
-ALTER TABLE business_cards ADD COLUMN native_place TEXT;
--- 执行后应该返回:ALTER TABLE
-
--- 第6步:验证native_place字段是否添加成功
-SELECT column_name, data_type FROM information_schema.columns 
-WHERE table_name = 'business_cards' AND column_name = 'native_place';
--- 期望结果:native_place | text
-
--- 第7步:为age字段添加注释
-COMMENT ON COLUMN business_cards.age IS '年龄字段 - 存储人员年龄信息,取值范围1-150';
--- 执行后应该返回:COMMENT
-
--- 第8步:为native_place字段添加注释
-COMMENT ON COLUMN business_cards.native_place IS '籍贯字段 - 存储人员籍贯或出生地信息';
--- 执行后应该返回:COMMENT
-
--- 第9步:最终验证 - 查看两个新字段
-SELECT column_name, data_type, is_nullable 
-FROM information_schema.columns 
-WHERE table_name = 'business_cards' 
-  AND column_name IN ('age', 'native_place')
-ORDER BY column_name;
--- 期望结果:
--- age          | integer | YES
--- native_place | text    | YES
-
--- 第10步:测试插入数据(可选)
--- INSERT INTO business_cards (name_zh, age, native_place) 
--- VALUES ('测试', 30, '北京市') RETURNING id, name_zh, age, native_place;
-
--- ===============================================
--- 执行指南:
--- 1. 复制每个步骤单独执行
--- 2. 检查每步的执行结果
--- 3. 如果某步失败,停止并检查错误原因
--- 4. 全部完成后,新字段就可以正常使用了
--- =============================================== 

+ 17 - 0
deploy/docker/.env.example

@@ -0,0 +1,17 @@
+# Optional external generative model. Leave empty for infrastructure-only tests.
+DEEPSEEK_API_KEY=
+
+# Create this key in the local n8n UI after owner setup, then restart backend.
+N8N_API_KEY=
+
+# External business data-source pools. This does not configure the platform DB.
+DATASOURCE_CREDENTIAL_MASTER_KEY=replace-with-base64-32-byte-key
+DATASOURCE_CREDENTIAL_KEY_VERSION=v1
+DATASOURCE_POOL_SIZE=2
+DATASOURCE_MAX_OVERFLOW=3
+DATASOURCE_POOL_TIMEOUT=10
+DATASOURCE_POOL_RECYCLE=1800
+DATASOURCE_POOL_IDLE_TTL=900
+DATASOURCE_MAX_IDLE_POOLS=20
+DATASOURCE_QUERY_TIMEOUT=30
+DATASOURCE_CERT_DIR=/etc/dataops-platform/datasource-certs

+ 224 - 0
deploy/docker/README.md

@@ -0,0 +1,224 @@
+# DataOps Platform 本地隔离测试环境
+
+本目录启动平台 PostgreSQL、两个外部验收数据源、Neo4j、MinIO、n8n、Flask
+后端和 Vue 前端。所有数据使用 `dataops-test-*` Docker 卷,不连接生产服务。
+
+## 启动
+
+```bash
+cp deploy/docker/.env.example deploy/docker/.env.local
+docker compose --env-file deploy/docker/.env.local \
+  -f deploy/docker/docker-compose.yml up -d --build
+docker compose -f deploy/docker/docker-compose.yml ps
+```
+
+不需要验证 DeepSeek 或 n8n 管理 API 时,可以不创建 `.env.local`,直接启动。基础设施和页面仍可使用。
+
+## 本地地址
+
+| 能力 | 地址 / 凭据 |
+|---|---|
+| 前端 | <http://localhost:18183> |
+| 后端健康 | <http://localhost:15500/api/system/health> |
+| Neo4j Browser | <http://localhost:17474>,`neo4j` / `Passw0rd` |
+| MinIO Console | <http://localhost:19001>,`dataops-test` / `dataops-test-password` |
+| n8n | <http://localhost:15678> |
+| PostgreSQL | `localhost:15432/dataops`,`dataops` / `dataops-test-password` |
+| 外部 PostgreSQL 数据源 | `localhost:25432/acceptance`,`source_reader` / `source-test-password` |
+| 外部 MySQL 数据源 | `localhost:23306/acceptance`,`source_reader` / `source-test-password` |
+| 平台本地登录 | 首次迁移后按下方命令显式创建;默认验收账号为 `admin` |
+
+平台 PostgreSQL 仅保存平台自身状态;`source-postgres` 和 `source-mysql`
+模拟由“数据源”定义接入的业务数据库。三者的端口、账号和 Docker 卷相互隔离。
+
+## n8n 首次配置
+
+1. 打开本地 n8n,完成 owner 初始化。
+2. 在 n8n 设置中生成 API Key。
+3. 把 Key 写入未跟踪的 `deploy/docker/.env.local`:`N8N_API_KEY=...`。
+4. 执行 `docker compose --env-file deploy/docker/.env.local -f deploy/docker/docker-compose.yml up -d backend`。
+
+未配置 Key 时,n8n 自身健康检查仍通过,平台 `/api/datafactory/health` 会返回明确的未配置/未授权状态,不会回退线上地址。
+
+## 首位管理员
+
+平台不提供自助注册。首次完成数据库迁移后,由部署人员显式创建唯一的首位管理员;
+后续账号只能由管理员在用户管理页面创建。密码不会写入 SQL、镜像或日志。
+
+```bash
+docker compose -f deploy/docker/docker-compose.yml exec -T \
+  -e BOOTSTRAP_ADMIN_USERNAME=admin \
+  -e BOOTSTRAP_ADMIN_PASSWORD='AdminPass123' \
+  backend python -m app.commands.bootstrap_admin --if-missing
+```
+
+`AdminPass123` 仅是全本地隔离栈的验收样例。非本地环境必须通过密钥注入替换,且
+重复执行命令只会跳过,不能覆盖已有管理员。
+
+## 日常命令
+
+```bash
+# 状态
+docker compose -f deploy/docker/docker-compose.yml ps
+
+# 日志
+docker compose -f deploy/docker/docker-compose.yml logs -f backend frontend n8n
+
+# 停止但保留数据
+docker compose -f deploy/docker/docker-compose.yml down
+
+# 完全重置本地测试数据(不可恢复)
+docker compose -f deploy/docker/docker-compose.yml down -v
+```
+
+## 数据库迁移与事件一致性
+
+后端容器每次启动时都会先执行 `alembic upgrade head`,迁移成功后才启动
+Gunicorn。初始化 SQL 只负责新测试卷的基础建库;后续结构变化统一由 Alembic
+增量迁移管理。
+
+```bash
+# 查看当前版本
+docker compose -f deploy/docker/docker-compose.yml exec -T backend \
+  alembic -c alembic.ini current
+
+# 手工升级(正常启动时无需重复执行)
+docker compose -f deploy/docker/docker-compose.yml exec -T backend \
+  alembic -c alembic.ini upgrade head
+
+# 查看 Outbox 积压、失败数和最老事件年龄(不返回事件载荷)
+curl -fsS http://localhost:15500/api/system/health
+
+# 当前阶段尚未注册业务事件处理器;命令会安全退出并保留待处理事件
+docker compose -f deploy/docker/docker-compose.yml exec -T backend \
+  python -m app.commands.process_outbox
+```
+
+治理对象使用 UUIDv7 作为跨 PostgreSQL、Neo4j、n8n 的稳定标识。核查或补齐
+历史 Neo4j 节点前,应先执行只读报告,再显式选择回填:
+
+```bash
+docker compose -f deploy/docker/docker-compose.yml exec -T backend \
+  python -m app.commands.reconcile_governance_uids --ensure-constraints
+
+docker compose -f deploy/docker/docker-compose.yml exec -T backend \
+  python -m app.commands.reconcile_governance_uids --backfill --ensure-constraints
+```
+
+## 冒烟验证
+
+```bash
+curl -fsS http://localhost:18183/ >/dev/null
+curl -fsS http://localhost:18183/api/system/health
+curl -fsS http://localhost:15678/healthz
+
+docker compose -f deploy/docker/docker-compose.yml exec -T postgres \
+  psql -U dataops -d dataops -c '\\dt public.*'
+
+docker compose -f deploy/docker/docker-compose.yml run --rm minio-init
+```
+
+## 外部数据源连接池验收
+
+两个外部数据源都会初始化 `acceptance_customers` 表及 Alpha、Beta 两条固定数据。
+以下测试对 PostgreSQL 和 MySQL 分别执行 100 次并发读取,并验证连接复用、最大
+并发连接数、空闲回收、重建和删除失效:
+
+```bash
+PYTHONPATH=. \
+TEST_SOURCE_POSTGRES_URL=postgresql://source_reader:source-test-password@127.0.0.1:25432/acceptance \
+TEST_SOURCE_MYSQL_URL=mysql+pymysql://source_reader:source-test-password@127.0.0.1:23306/acceptance \
+.venv/bin/pytest -q tests/integration/test_datasource_pools.py
+```
+
+### 运行参数
+
+连接池只管理通过数据源定义接入的外部业务数据库,不管理平台自身的 PostgreSQL。
+中型单实例默认值为:基础连接 2、临时溢出 3、取连接等待 10 秒、连接回收
+1800 秒、空闲池回收 900 秒、每个 Worker 最多保留 20 个空闲池、查询超时
+30 秒。可通过 `.env.local` 中以下变量调整,服务端会执行上下限校验:
+
+```dotenv
+DATASOURCE_POOL_SIZE=2
+DATASOURCE_MAX_OVERFLOW=3
+DATASOURCE_POOL_TIMEOUT=10
+DATASOURCE_POOL_RECYCLE=1800
+DATASOURCE_POOL_IDLE_TTL=900
+DATASOURCE_MAX_IDLE_POOLS=20
+DATASOURCE_QUERY_TIMEOUT=30
+DATASOURCE_CREDENTIAL_MASTER_KEY=<Base64 编码的 32 字节密钥>
+DATASOURCE_CREDENTIAL_KEY_VERSION=v1
+DATASOURCE_CERT_DIR=/etc/dataops-platform/datasource-certs
+```
+
+每个数据源只允许覆盖 `pool_size` 和 `max_overflow`,其余参数由平台统一控制。
+修改连接定义或凭据版本后,旧池进入失效/排空流程,下一次访问按新版本惰性创建。
+
+### 凭据迁移和一致性核查
+
+先执行只读报告;确认单个 UID 后才允许加密迁移。迁移会先验证密文可解密,再从
+Neo4j 删除旧明文:
+
+```bash
+docker compose -f deploy/docker/docker-compose.yml exec -T backend \
+  python -m app.commands.report_datasource_credentials
+
+docker compose -f deploy/docker/docker-compose.yml exec -T backend \
+  python -m app.commands.migrate_datasource_credentials \
+  --uid <数据源UID> --confirm-encrypt-and-remove-plaintext
+
+docker compose -f deploy/docker/docker-compose.yml exec -T backend \
+  python -m app.commands.reconcile_datasource_credentials
+
+docker compose -f deploy/docker/docker-compose.yml exec -T backend \
+  python -m app.commands.reconcile_datasource_credentials \
+  --repair --uid <数据源UID>
+```
+
+### 管理员诊断和安全失效
+
+仅管理员可读取 `/api/datasource/pools`、`/api/datasource/<UID>/pool`,或向
+`/api/datasource/<UID>/pool/invalidate` 提交固定原因 `admin_reset`、
+`configuration_changed`、`credential_rotated`。接口只返回池状态和聚合指标,
+不返回主机、账号、密文、连接串或配置指纹。强制失效会记录安全审计事件。
+
+### 故障与恢复演练
+
+默认测试只会跳过,不控制 Docker。显式设置开关后,测试会停止外部 MySQL,
+验证三次失败后只对该数据源熔断,并确认外部 PostgreSQL和平台 PostgreSQL继续
+可用;随后恢复 MySQL、执行半开探测并验证凭据版本轮换。`finally` 清理保证
+MySQL 一定被重新启动:
+
+```bash
+RUN_DATASOURCE_OUTAGE_TEST=1 PYTHONPATH=. \
+.venv/bin/pytest -q tests/integration/test_datasource_pool_failures.py
+```
+
+若修改了 `database/*.sql`,已有 PostgreSQL 卷不会自动重放初始化脚本。已存在的
+测试环境应新增 Alembic 迁移;只有明确需要丢弃全部本地数据时才执行 `down -v`。
+
+## 2026-07-16 已验证基线
+
+| 检查 | 结果 |
+|---|---|
+| Compose 服务 | backend、frontend、PostgreSQL、Neo4j、MinIO、n8n 均 healthy;`minio-init` 正常退出 0 |
+| HTTP | 前端、前端代理的后端健康接口、n8n `/healthz` 均返回 200 |
+| 平台健康 | PostgreSQL 与 Neo4j 检查为 true,总状态 healthy |
+| 本地认证 | 新 RBAC 管理员 `admin` / `AdminPass123`;Argon2id 哈希,不提供注册接口 |
+| PostgreSQL | 6 张当前业务表和本地 users 表已创建 |
+| Neo4j | `RETURN 1` 成功 |
+| MinIO | `dataops-bucket` 创建成功 |
+| 隔离扫描 | 活跃源码、构建产物、渲染配置与运行日志未发现历史生产服务地址或密钥 |
+| 数据库迁移 | Alembic 基线、治理 UID 与 Outbox 三组迁移可在空库和既有本地库重复执行 |
+| 跨存储一致性 | 业务事务与事件同事务回滚;下游中断后可重试恢复;重复投递不重复执行处理器 |
+
+镜像基线:
+
+- backend:本仓库构建,Python 3.11,image ID `215df46dacc2`。
+- frontend:本仓库构建,Node 24 + Nginx 1.27,image ID `42de39689f54`。
+- PostgreSQL:`pgvector/pgvector:pg16`(PostgreSQL 16 + vector 扩展)。
+- Neo4j:`neo4j:5.26-community`。
+- MinIO:`minio/minio:RELEASE.2025-04-22T22-12-26Z`。
+- n8n:`n8nio/n8n:1.100.1`。
+
+已知非阻塞技术债:Vue 2 构建仍有 console、CSS 顺序和大包体警告,npm audit 报告 72 个传递依赖问题;这些与 Vue 3/依赖治理阶段统一处理,不在本次业务边界清理中升级。n8n 管理 API 需要完成本地 owner 初始化并配置本地 API Key 后才能验收。未配置 `VUE_APP_N8N_CHAT_URL` 和预览服务时,聊天助手与文件在线预览失败关闭,不会连接历史线上服务。

+ 33 - 0
deploy/docker/backend.Dockerfile

@@ -0,0 +1,33 @@
+FROM python:3.11-slim
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+    PYTHONUNBUFFERED=1 \
+    APP_DIR=/app
+
+WORKDIR /app
+
+RUN apt-get update \
+    && apt-get install --no-install-recommends -y \
+        gcc \
+        libc6-dev \
+        libgomp1 \
+        linux-libc-dev \
+    && rm -rf /var/lib/apt/lists/*
+
+COPY requirements.txt ./requirements.txt
+RUN pip install --no-cache-dir -r requirements.txt \
+    && apt-get purge -y gcc libc6-dev linux-libc-dev \
+    && apt-get autoremove -y \
+    && rm -rf /var/lib/apt/lists/*
+
+COPY app/ ./app/
+COPY migrations/ ./migrations/
+COPY alembic.ini ./alembic.ini
+COPY datafactory/ ./datafactory/
+COPY wsgi.py gunicorn_config.py ./
+
+RUN mkdir -p /app/logs /data/upload /data/archive
+
+EXPOSE 5500
+
+CMD ["/bin/sh", "-c", "alembic -c alembic.ini upgrade head && exec gunicorn --config gunicorn_config.py wsgi:application"]

+ 11 - 0
deploy/docker/datasources/mysql/init.sql

@@ -0,0 +1,11 @@
+CREATE TABLE IF NOT EXISTS acceptance_customers (
+    id BIGINT PRIMARY KEY,
+    customer_name VARCHAR(100) NOT NULL
+);
+
+INSERT INTO acceptance_customers (id, customer_name)
+VALUES
+    (1, 'Alpha'),
+    (2, 'Beta')
+ON DUPLICATE KEY UPDATE
+    customer_name = VALUES(customer_name);

+ 11 - 0
deploy/docker/datasources/postgres/init.sql

@@ -0,0 +1,11 @@
+CREATE TABLE IF NOT EXISTS acceptance_customers (
+    id BIGINT PRIMARY KEY,
+    customer_name VARCHAR(100) NOT NULL
+);
+
+INSERT INTO acceptance_customers (id, customer_name)
+VALUES
+    (1, 'Alpha'),
+    (2, 'Beta')
+ON CONFLICT (id) DO UPDATE
+SET customer_name = EXCLUDED.customer_name;

+ 251 - 0
deploy/docker/docker-compose.yml

@@ -0,0 +1,251 @@
+name: dataops-test
+
+services:
+  postgres:
+    image: pgvector/pgvector:pg16
+    environment:
+      POSTGRES_DB: dataops
+      POSTGRES_USER: dataops
+      POSTGRES_PASSWORD: dataops-test-password
+    ports:
+      - "15432:5432"
+    volumes:
+      - dataops-test-postgres:/var/lib/postgresql/data
+      - ./postgres/init:/docker-entrypoint-initdb.d:ro
+      - ../../database:/dataops-schema:ro
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U dataops -d dataops"]
+      interval: 5s
+      timeout: 5s
+      retries: 20
+    networks:
+      - dataops-test-net
+
+  source-postgres:
+    image: postgres:16-alpine
+    environment:
+      POSTGRES_DB: acceptance
+      POSTGRES_USER: source_reader
+      POSTGRES_PASSWORD: source-test-password
+    ports:
+      - "25432:5432"
+    volumes:
+      - dataops-test-source-postgres:/var/lib/postgresql/data
+      - ./datasources/postgres/init.sql:/docker-entrypoint-initdb.d/010-acceptance.sql:ro
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U source_reader -d acceptance"]
+      interval: 5s
+      timeout: 5s
+      retries: 20
+    networks:
+      - dataops-test-net
+
+  source-mysql:
+    image: mysql:8.4
+    environment:
+      MYSQL_DATABASE: acceptance
+      MYSQL_USER: source_reader
+      MYSQL_PASSWORD: source-test-password
+      MYSQL_ROOT_PASSWORD: source-root-test-password
+    ports:
+      - "23306:3306"
+    volumes:
+      - dataops-test-source-mysql:/var/lib/mysql
+      - ./datasources/mysql/init.sql:/docker-entrypoint-initdb.d/010-acceptance.sql:ro
+    healthcheck:
+      test:
+        - CMD-SHELL
+        - mysqladmin ping -h 127.0.0.1 -u source_reader -psource-test-password --silent
+      interval: 5s
+      timeout: 5s
+      retries: 30
+      start_period: 20s
+    networks:
+      - dataops-test-net
+
+  neo4j:
+    image: neo4j:5.26-community
+    environment:
+      NEO4J_AUTH: neo4j/Passw0rd
+      NEO4J_server_memory_heap_initial__size: 256m
+      NEO4J_server_memory_heap_max__size: 512m
+      NEO4J_server_memory_pagecache_size: 256m
+    ports:
+      - "17474:7474"
+      - "17687:7687"
+    volumes:
+      - dataops-test-neo4j:/data
+    healthcheck:
+      test: ["CMD-SHELL", "cypher-shell -u neo4j -p Passw0rd 'RETURN 1' >/dev/null 2>&1"]
+      interval: 10s
+      timeout: 10s
+      retries: 30
+      start_period: 20s
+    networks:
+      - dataops-test-net
+
+  minio:
+    image: minio/minio:RELEASE.2025-04-22T22-12-26Z
+    command: server /data --console-address ":9001"
+    environment:
+      MINIO_ROOT_USER: dataops-test
+      MINIO_ROOT_PASSWORD: dataops-test-password
+    ports:
+      - "19000:9000"
+      - "19001:9001"
+    volumes:
+      - dataops-test-minio:/data
+    healthcheck:
+      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
+      interval: 5s
+      timeout: 5s
+      retries: 20
+    networks:
+      - dataops-test-net
+
+  minio-init:
+    image: minio/mc:RELEASE.2025-04-16T18-13-26Z
+    depends_on:
+      minio:
+        condition: service_healthy
+    entrypoint:
+      - /bin/sh
+      - -c
+      - >-
+        mc alias set local http://minio:9000 dataops-test dataops-test-password &&
+        mc mb --ignore-existing local/dataops-bucket
+    restart: "no"
+    networks:
+      - dataops-test-net
+
+  n8n:
+    image: n8nio/n8n:1.100.1
+    environment:
+      DB_TYPE: postgresdb
+      DB_POSTGRESDB_HOST: postgres
+      DB_POSTGRESDB_PORT: 5432
+      DB_POSTGRESDB_DATABASE: n8n
+      DB_POSTGRESDB_USER: dataops
+      DB_POSTGRESDB_PASSWORD: dataops-test-password
+      N8N_HOST: localhost
+      N8N_PORT: 5678
+      N8N_PROTOCOL: http
+      N8N_SECURE_COOKIE: "false"
+      N8N_ENCRYPTION_KEY: dataops-local-test-encryption-key
+      N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: "true"
+      WEBHOOK_URL: http://localhost:15678/
+      GENERIC_TIMEZONE: Asia/Shanghai
+      TZ: Asia/Shanghai
+    ports:
+      - "15678:5678"
+    volumes:
+      - dataops-test-n8n:/home/node/.n8n
+    depends_on:
+      postgres:
+        condition: service_healthy
+    healthcheck:
+      test: ["CMD-SHELL", "wget --spider -q http://127.0.0.1:5678/healthz"]
+      interval: 10s
+      timeout: 5s
+      retries: 30
+      start_period: 30s
+    networks:
+      - dataops-test-net
+
+  backend:
+    build:
+      context: ../..
+      dockerfile: deploy/docker/backend.Dockerfile
+    environment:
+      FLASK_ENV: production
+      LISTEN_HOST: 0.0.0.0
+      LISTEN_PORT: 5500
+      GUNICORN_WORKERS: 2
+      DATABASE_URL: postgresql://dataops:dataops-test-password@postgres:5432/dataops
+      NEO4J_URI: bolt://neo4j:7687
+      NEO4J_HTTP_URI: http://neo4j:7474
+      NEO4J_USER: neo4j
+      NEO4J_PASSWORD: Passw0rd
+      NEO4J_ENCRYPTED: "false"
+      MINIO_HOST: minio:9000
+      MINIO_USER: dataops-test
+      MINIO_PASSWORD: dataops-test-password
+      MINIO_BUCKET: dataops-bucket
+      MINIO_SECURE: "false"
+      DATAFLOW_DEFAULT_DB_HOST: postgres
+      DATAFLOW_DEFAULT_DB_PORT: 5432
+      DATAFLOW_DEFAULT_DB_NAME: dataops
+      DATAFLOW_SCHEMA: dags
+      N8N_API_URL: http://n8n:5678
+      N8N_API_KEY: ${N8N_API_KEY:-}
+      API_BASE_URL: http://backend:5500/api
+      DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
+      LLM_BASE_URL: https://api.deepseek.com
+      LLM_MODEL_NAME: deepseek-chat
+      SECRET_KEY: dataops-local-test-secret-key
+      DATASOURCE_CREDENTIAL_MASTER_KEY: ${DATASOURCE_CREDENTIAL_MASTER_KEY:-MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=}
+      DATASOURCE_CREDENTIAL_KEY_VERSION: ${DATASOURCE_CREDENTIAL_KEY_VERSION:-v1}
+      LOG_DIR: /app/logs
+      LOG_TO_CONSOLE: "true"
+    ports:
+      - "15500:5500"
+    volumes:
+      - dataops-test-backend-logs:/app/logs
+      - dataops-test-upload:/data/upload
+      - dataops-test-archive:/data/archive
+    depends_on:
+      postgres:
+        condition: service_healthy
+      neo4j:
+        condition: service_healthy
+      minio-init:
+        condition: service_completed_successfully
+    healthcheck:
+      test:
+        - CMD
+        - python
+        - -c
+        - import urllib.request; urllib.request.urlopen('http://127.0.0.1:5500/api/system/health', timeout=5)
+      interval: 10s
+      timeout: 10s
+      retries: 20
+      start_period: 20s
+    networks:
+      - dataops-test-net
+
+  frontend:
+    build:
+      context: ../..
+      dockerfile: deploy/docker/frontend.Dockerfile
+      args:
+        VUE_APP_N8N_URL: http://localhost:15678
+        VUE_APP_N8N_CHAT_URL: ""
+        VUE_APP_MINIO_PUBLIC_URL: http://localhost:19000/dataops-bucket
+        VUE_APP_PREVIEW_URL: ""
+    ports:
+      - "18183:80"
+    depends_on:
+      backend:
+        condition: service_healthy
+    healthcheck:
+      test: ["CMD-SHELL", "wget --spider -q http://127.0.0.1/"]
+      interval: 10s
+      timeout: 5s
+      retries: 20
+    networks:
+      - dataops-test-net
+
+networks:
+  dataops-test-net:
+    name: dataops-test-net
+
+volumes:
+  dataops-test-postgres:
+  dataops-test-source-postgres:
+  dataops-test-source-mysql:
+  dataops-test-neo4j:
+  dataops-test-minio:
+  dataops-test-n8n:
+  dataops-test-backend-logs:
+  dataops-test-upload:
+  dataops-test-archive:

+ 25 - 0
deploy/docker/frontend.Dockerfile

@@ -0,0 +1,25 @@
+FROM node:24-bookworm-slim AS build
+
+WORKDIR /workspace
+
+ARG VUE_APP_N8N_URL=http://localhost:15678
+ARG VUE_APP_N8N_CHAT_URL=
+ARG VUE_APP_MINIO_PUBLIC_URL=http://localhost:19000/dataops-bucket
+ARG VUE_APP_PREVIEW_URL=
+ENV VUE_APP_N8N_URL=${VUE_APP_N8N_URL}
+ENV VUE_APP_N8N_CHAT_URL=${VUE_APP_N8N_CHAT_URL}
+ENV VUE_APP_MINIO_PUBLIC_URL=${VUE_APP_MINIO_PUBLIC_URL}
+ENV VUE_APP_PREVIEW_URL=${VUE_APP_PREVIEW_URL}
+
+COPY frontend/package.json frontend/package-lock.json ./
+RUN npm ci --legacy-peer-deps
+
+COPY frontend/ ./
+RUN npm run build
+
+FROM nginx:1.27-alpine
+
+COPY deploy/docker/nginx.conf /etc/nginx/conf.d/default.conf
+COPY --from=build /workspace/dist/ /usr/share/nginx/html/
+
+EXPOSE 80

+ 21 - 0
deploy/docker/nginx.conf

@@ -0,0 +1,21 @@
+server {
+    listen 80;
+    server_name _;
+
+    root /usr/share/nginx/html;
+    index index.html;
+
+    location /api/ {
+        proxy_pass http://backend:5500/api/;
+        proxy_http_version 1.1;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_read_timeout 120s;
+    }
+
+    location / {
+        try_files $uri $uri/ /index.html;
+    }
+}

+ 12 - 0
deploy/docker/postgres/init/000-init.sql

@@ -0,0 +1,12 @@
+-- DataOps local test bootstrap. Executed only for a fresh Docker volume.
+SELECT 'CREATE DATABASE n8n'
+WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'n8n')\gexec
+
+\connect dataops
+CREATE EXTENSION IF NOT EXISTS vector;
+\ir /dataops-schema/create_data_orders_table.sql
+\ir /dataops-schema/add_data_source_to_data_orders.sql
+\ir /dataops-schema/create_data_products_table.sql
+\ir /dataops-schema/create_metadata_review_records_table.sql
+\ir /dataops-schema/create_metadata_version_history_table.sql
+\ir /dataops-schema/create_task_list_table.sql

+ 24 - 15
deployment/.env.production.example

@@ -12,43 +12,52 @@ PORT=5500
 GUNICORN_WORKERS=4
 GUNICORN_TIMEOUT=120
 
-# PostgreSQL
-DATABASE_URL=postgresql://postgres:dataOps@192.168.3.143:5432/dataops
+# 平台 PostgreSQL(可与平台同机;部署前替换密码)
+DATABASE_URL=postgresql://dataops_user:replace-password@127.0.0.1:5432/dataops
 
 # Neo4j
-NEO4J_URI=bolt://192.168.3.143:7687
-NEO4J_HTTP_URI=http://192.168.3.143:7474
+NEO4J_URI=bolt://127.0.0.1:7687
+NEO4J_HTTP_URI=http://127.0.0.1:7474
 NEO4J_USER=neo4j
-NEO4J_PASSWORD=cituneo4j
+NEO4J_PASSWORD=replace-neo4j-password
 
 # MinIO
-MINIO_HOST=192.168.3.143:9000
-MINIO_USER=citu-dataops-acc-key
-MINIO_PASSWORD=citu-dataops-secret-key
+MINIO_HOST=127.0.0.1:9000
+MINIO_USER=replace-minio-user
+MINIO_PASSWORD=replace-minio-password
 MINIO_SECURE=false
 MINIO_BUCKET=dataops-bucket
 MINIO_PREFIX=
 
 # n8n
-N8N_API_URL=https://n8n.citupro.com
+N8N_API_URL=http://127.0.0.1:5678
 N8N_API_KEY=replace-n8n-api-key
 N8N_API_TIMEOUT=30
 
 # DeepSeek LLM(OpenAI 兼容)
 DEEPSEEK_API_KEY=replace-with-your-deepseek-api-key
 LLM_BASE_URL=https://api.deepseek.com
-LLM_MODEL_NAME=deepseek-v4-pro
+LLM_MODEL_NAME=deepseek-chat
 LLM_REASONING_EFFORT=high
 # 兼容旧变量名(可选)
 # LLM_API_KEY=
 
-# Optional services
-API_BASE_URL=https://company.citupro.com:18183/api
-AIRFLOW_BASE_URL=http://127.0.0.1:8080
-AIRFLOW_AUTH_USER=admin
-AIRFLOW_AUTH_PASSWORD=replace-airflow-password
+# 服务配置
+API_BASE_URL=http://127.0.0.1:5500/api
 DATAFLOW_SCHEMA=dags
 
+# 外部业务数据源连接池(不用于平台自身 PostgreSQL)
+DATASOURCE_CREDENTIAL_MASTER_KEY=replace-with-base64-32-byte-key
+DATASOURCE_CREDENTIAL_KEY_VERSION=v1
+DATASOURCE_POOL_SIZE=2
+DATASOURCE_MAX_OVERFLOW=3
+DATASOURCE_POOL_TIMEOUT=10
+DATASOURCE_POOL_RECYCLE=1800
+DATASOURCE_POOL_IDLE_TTL=900
+DATASOURCE_MAX_IDLE_POOLS=20
+DATASOURCE_QUERY_TIMEOUT=30
+DATASOURCE_CERT_DIR=/etc/dataops-platform/datasource-certs
+
 # Runtime paths
 UPLOAD_BASE_PATH=/data/upload
 ARCHIVE_BASE_PATH=/data/archive

+ 38 - 0
deployment/alembic.ini

@@ -0,0 +1,38 @@
+[alembic]
+script_location = migrations
+prepend_sys_path = .
+version_path_separator = os
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S

+ 4 - 0
deployment/app/__init__.py

@@ -51,6 +51,10 @@ def create_app():
     app.register_blueprint(data_factory_bp, url_prefix="/api/datafactory")
     app.register_blueprint(data_service_bp, url_prefix="/api/dataservice")
 
+    from app.core.system.permissions import configure_api_authorization
+
+    configure_api_authorization(app)
+
     # Configure global response headers
     configure_response_headers(app)
 

+ 59 - 48
deployment/app/api/data_flow/routes.py

@@ -1,16 +1,74 @@
 import json
 import logging
 
-from flask import request
+from flask import g, jsonify, request
 
+from app import db
 from app.api.data_flow import bp
 from app.core.data_flow.dataflows import DataFlowService
 from app.core.graph.graph_operations import MyEncoder
 from app.models.result import failed, success
+from app.core.data_factory.n8n_client import N8nClient, N8nClientError
+from app.core.data_flow.workflow_activation import activate_version
+from app.core.data_flow.workflow_repository import create_version, list_versions
+from app.core.system.permissions import ACTIVATE_WORKFLOW, EDIT_GOVERNANCE, READ_GOVERNANCE, require_permissions
 
 logger = logging.getLogger(__name__)
 
 
+@bp.route("/<dataflow_uid>/workflow-versions", methods=["GET"])
+@require_permissions(READ_GOVERNANCE)
+def get_workflow_versions(dataflow_uid):
+    try:
+        return jsonify(success(list_versions(db.session, dataflow_uid)))
+    except ValueError as exc:
+        return jsonify(failed(str(exc), code=400)), 400
+
+
+@bp.route("/<dataflow_uid>/workflow-versions", methods=["POST"])
+@require_permissions(EDIT_GOVERNANCE)
+def add_workflow_version(dataflow_uid):
+    body = request.get_json(silent=True) or {}
+    n8n_workflow_id = str(body.get("n8n_workflow_id") or "").strip()
+    if not n8n_workflow_id:
+        return jsonify(failed("n8n_workflow_id 不能为空", code=400)), 400
+    try:
+        workflow = N8nClient().get_workflow(n8n_workflow_id)
+        version_id = create_version(
+            db.session,
+            dataflow_uid=dataflow_uid,
+            environment=body.get("environment", "development"),
+            workflow=workflow,
+            created_by=g.current_user["id"],
+        )
+        db.session.commit()
+        return jsonify(success({"id": version_id}, "版本创建成功", code=201)), 201
+    except (ValueError, N8nClientError) as exc:
+        db.session.rollback()
+        message = exc.message if isinstance(exc, N8nClientError) else str(exc)
+        return jsonify(failed(message, code=400)), 400
+
+
+@bp.route("/<dataflow_uid>/workflow-versions/<version_id>/activate", methods=["POST"])
+@require_permissions(ACTIVATE_WORKFLOW)
+def activate_workflow_version(dataflow_uid, version_id):
+    try:
+        versions = list_versions(db.session, dataflow_uid)
+        if not any(version["id"] == version_id for version in versions):
+            return jsonify(failed("版本不存在", code=404)), 404
+        result = activate_version(
+            db.session,
+            version_id=version_id,
+            actor_id=g.current_user["id"],
+            n8n_client=N8nClient(),
+        )
+        return jsonify(success(result, "版本激活成功"))
+    except (ValueError, RuntimeError, N8nClientError) as exc:
+        db.session.rollback()
+        message = exc.message if isinstance(exc, N8nClientError) else str(exc)
+        return jsonify(failed(message, code=409)), 409
+
+
 @bp.route("/get-dataflows-list", methods=["GET"])
 def get_dataflows():
     """获取数据流列表"""
@@ -110,53 +168,6 @@ def delete_dataflow(dataflow_id):
         return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
 
 
-@bp.route("/execute-dataflow/<int:dataflow_id>", methods=["POST"])
-def execute_dataflow(dataflow_id):
-    """执行数据流"""
-    try:
-        data = request.get_json() or {}
-        result = DataFlowService.execute_dataflow(dataflow_id, data)
-        res = success(result, "数据流执行成功")
-        return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
-    except Exception as e:
-        logger.error(f"执行数据流失败: {str(e)}")
-        res = failed(f"执行数据流失败: {str(e)}")
-        return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
-
-
-@bp.route("/get-dataflow-status/<int:dataflow_id>", methods=["GET"])
-def get_dataflow_status(dataflow_id):
-    """获取数据流执行状态"""
-    try:
-        result = DataFlowService.get_dataflow_status(dataflow_id)
-        res = success(result, "success")
-        return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
-    except Exception as e:
-        logger.error(f"获取数据流状态失败: {str(e)}")
-        res = failed(f"获取数据流状态失败: {str(e)}")
-        return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
-
-
-@bp.route("/get-dataflow-logs/<int:dataflow_id>", methods=["GET"])
-def get_dataflow_logs(dataflow_id):
-    """获取数据流执行日志"""
-    try:
-        page = request.args.get("page", 1, type=int)
-        page_size = request.args.get("page_size", 50, type=int)
-
-        result = DataFlowService.get_dataflow_logs(
-            dataflow_id,
-            page=page,
-            page_size=page_size,
-        )
-        res = success(result, "success")
-        return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
-    except Exception as e:
-        logger.error(f"获取数据流日志失败: {str(e)}")
-        res = failed(f"获取数据流日志失败: {str(e)}")
-        return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
-
-
 @bp.route("/get-BD-list", methods=["GET"])
 def get_business_domain_list():
     """获取BusinessDomain节点列表"""

+ 198 - 318
deployment/app/api/data_source/routes.py

@@ -1,347 +1,227 @@
-from flask import request, jsonify
+"""HTTP boundary for secret-free external data-source management."""
+
 import logging
-import json
-from datetime import datetime
-from app.models.result import success, failed
+
+from flask import g, jsonify, request
+
 from app.api.data_source import bp
-from app.core.graph.graph_operations import (
-    create_or_get_node, execute_cypher_query
+from app.core.data_source.errors import DataSourceError
+from app.core.data_source.redaction import (
+    redact_mapping,
+    sanitize_exception,
 )
-from sqlalchemy import create_engine, text, URL
+from app.models.result import failed, success
+
 
 logger = logging.getLogger(__name__)
 
 
-# 创建数据源时使用此api
-@bp.route('/save', methods=['POST'])
+def get_data_source_service():
+    from app.core.data_source.runtime import get_data_source_manager
+    from app.core.data_source.service import build_data_source_service
+
+    return build_data_source_service(get_data_source_manager())
+
+
+def _actor_uid():
+    identity = getattr(g, "current_user", {}) or {}
+    return identity.get("id") or identity.get("sub")
+
+
+def _error_response(error):
+    if isinstance(error, DataSourceError):
+        logger.warning(
+            "数据源操作失败: code=%s message=%s",
+            error.code,
+            sanitize_exception(error),
+        )
+        return (
+            jsonify(
+                failed(
+                    str(error),
+                    code=error.http_status,
+                    error={"code": error.code},
+                )
+            ),
+            error.http_status,
+        )
+    logger.error(
+        "数据源操作异常: %s",
+        sanitize_exception(error),
+    )
+    return (
+        jsonify(
+            failed(
+                "数据源操作失败",
+                code=500,
+                error={"code": "DATASOURCE_ERROR"},
+            )
+        ),
+        500,
+    )
+
+
+@bp.route("/save", methods=["POST"])
 def data_source_save():
-    """保存数据源"""
+    payload = request.get_json(silent=True) or {}
+    logger.debug("保存数据源请求: %s", redact_mapping(payload))
     try:
-        # 获取表单数据
-        data = request.json
-        log_data = json.dumps(data, ensure_ascii=False) if data else 'None'
-        logger.debug(f"保存数据源请求数据: {log_data}")
-
-        # 检查必填参数
-        required_fields = [
-            'database', 'host', 'port', 'username',
-            'password', 'name_en', 'type'
-        ]
-        if not data:
-            missing_fields = required_fields
-        else:
-            missing_fields = [
-                field for field in required_fields if not data.get(field)
-            ]
-
-        if missing_fields:
-            error_msg = f"缺少必填参数: {', '.join(missing_fields)}"
-            logger.error(error_msg)
-            return jsonify(failed(error_msg))
-
-        # 此时 data 一定不为 None
-        assert data is not None
-
-        # 检查name_en是否已存在
-        check_query = """
-        MATCH (n:DataSource)
-        WHERE n.name_en = $name_en
-        RETURN n
-        """
-        result = execute_cypher_query(
-            check_query, {'name_en': data['name_en']}
+        service = get_data_source_service()
+        definition, created = service.save(
+            payload,
+            actor_uid=_actor_uid(),
         )
+        status = 201 if created else 200
+        return jsonify(success(service.serialize(definition))), status
+    except Exception as error:
+        return _error_response(error)
 
-        # 添加创建时间
-        data['create_dt'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
-
-        if result:
-            # 如果存在,更新节点
-            node = result[0]['n']
-            node_id = node['_id']
-            # 更新属性
-            update_query = """
-            MATCH (n:DataSource)
-            WHERE id(n) = $node_id
-            SET n += $properties
-            RETURN id(n) as node_id
-            """
-            result = execute_cypher_query(update_query, {
-                'node_id': node_id,
-                'properties': data
-            })
-            message = "数据源更新成功"
-        else:
-            # 如果不存在,创建新节点
-            node_id = create_or_get_node('DataSource', **data)
-            message = "数据源创建成功"
-
-        # 返回成功结果
-        return jsonify(success({
-            "id": node_id,
-            "message": message
-        }))
-    except Exception as e:
-        logger.error(f"保存数据源失败: {str(e)}")
-        return jsonify(failed(str(e)))
-
-
-# 获取数据源列表 或根据id获取数据源信息
-@bp.route('/list', methods=['POST'])
+
+@bp.route("/list", methods=["POST"])
 def data_source_list():
-    """获取数据源列表或指定数据源信息"""
+    payload = request.get_json(silent=True) or {}
     try:
-        # 获取请求参数
-        data = request.json
-
-        # 构建查询条件
-        where_conditions = []
-        params = {}
-
-        # 如果指定了id
-        if data and 'id' in data:
-            where_conditions.append("id(n) = $id")
-            params['id'] = int(data['id'])
-        # 如果有其他属性
-        elif data:
-            for key, value in data.items():
-                if value:  # 只处理非空值
-                    where_conditions.append(f"n.{key} = ${key}")
-                    params[key] = value
-
-        # 构建WHERE子句
-        if where_conditions:
-            where_clause = " WHERE " + " AND ".join(where_conditions)
-        else:
-            where_clause = ""
-
-        # 构建查询语句
-        cypher = f"""
-        MATCH (n:DataSource)
-        {where_clause}
-        RETURN n
-        """
-
-        # 执行查询
-        result = execute_cypher_query(cypher, params)
-
-        # 格式化结果
-        data_sources = []
-        for record in result:
-            node = record['n']
-            node['id'] = node['_id']
-            data_sources.append(node)
-
-        # 返回结果
-        return jsonify(success({
-            "data_source": data_sources,
-            "total": len(data_sources)
-        }))
-
-    except Exception as e:
-        logger.error(f"获取数据源列表失败: {str(e)}")
-        return jsonify(failed(str(e)))
-
-
-@bp.route('/delete', methods=['POST'])
+        service = get_data_source_service()
+        definitions = service.list(payload)
+        items = [service.serialize(item) for item in definitions]
+        return jsonify(
+            success({"data_source": items, "total": len(items)})
+        ), 200
+    except Exception as error:
+        return _error_response(error)
+
+
+@bp.route("/delete", methods=["POST"])
 def data_source_delete():
-    """删除数据源"""
+    payload = request.get_json(silent=True) or {}
+    logger.debug("删除数据源请求: %s", redact_mapping(payload))
     try:
-        # 获取请求参数
-        data = request.json
-        log_data = json.dumps(data, ensure_ascii=False) if data else 'None'
-        logger.debug(f"删除数据源请求数据: {log_data}")
-
-        # 检查参数
-        if not data or ('id' not in data and 'name_en' not in data):
-            error_msg = "必须提供id或name_en参数"
-            logger.error(error_msg)
-            return jsonify(failed(error_msg))
-
-        # 构建删除条件
-        if 'id' in data:
-            where_clause = "id(n) = $id"
-            params = {'id': int(data['id'])}
-        else:
-            where_clause = "n.name_en = $name_en"
-            params = {'name_en': data['name_en']}
-
-        # 构建删除语句
-        delete_query = f"""
-        MATCH (n:DataSource)
-        WHERE {where_clause}
-        WITH n
-        OPTIONAL MATCH (n)-[r]-()
-        DELETE r, n
-        RETURN count(n) as deleted_count
-        """
-
-        # 执行删除
-        result = execute_cypher_query(delete_query, params)
-
-        if result and result[0]['deleted_count'] > 0:
-            return jsonify(success({
-                "message": "数据源删除成功",
-                "deleted_count": result[0]['deleted_count']
-            }))
-        else:
-            error_msg = "未找到指定的数据源"
-            logger.error(error_msg)
-            return jsonify(failed(error_msg))
-
-    except Exception as e:
-        logger.error(f"删除数据源失败: {str(e)}")
-        return jsonify(failed(str(e)))
-
-
-@bp.route('/parse', methods=['POST'])
-def data_source_connstr_parse():
-    """解析数据源连接字符串"""
+        result = get_data_source_service().delete(
+            payload.get("uid"),
+            actor_uid=_actor_uid(),
+        )
+        return jsonify(success(result)), 200
+    except Exception as error:
+        return _error_response(error)
+
+
+@bp.route("/conntest", methods=["POST"])
+def data_source_conn_test():
+    payload = request.get_json(silent=True) or {}
+    logger.debug("测试数据源连接请求: %s", redact_mapping(payload))
     try:
-        # 获取请求参数
-        data = request.json
-        log_data = json.dumps(data, ensure_ascii=False) if data else 'None'
-        logger.debug(f"解析连接字符串请求数据: {log_data}")
-
-        # 检查参数
-        if not data or 'conn_str' not in data:
-            error_msg = "缺少连接字符串参数"
-            logger.error(error_msg)
-            return jsonify(failed(error_msg))
-
-        # 创建DDLParser实例并解析连接字符串
-        from app.core.llm.ddl_parser import DDLParser
-        parser = DDLParser()
-        result = parser.parse_db_conn_str(data['conn_str'])
-
-        # 检查解析结果
-        is_error = (isinstance(result, dict) and
-                    'code' in result and result['code'] == 500)
-        if is_error:
-            msg = result.get('message', '未知错误')
-            error_msg = f"解析连接字符串失败: {msg}"
-            logger.error(error_msg)
-            return jsonify(failed(error_msg))
-
-        # 返回成功结果
-        return jsonify(success(result))
-
-    except Exception as e:
-        logger.error(f"解析连接字符串失败: {str(e)}")
-        return jsonify(failed(str(e)))
-
-
-@bp.route('/valid', methods=['POST'])
+        result = get_data_source_service().test_connection(payload)
+        return jsonify(success(result)), 200
+    except Exception as error:
+        return _error_response(error)
+
+
+@bp.route("/valid", methods=["POST"])
 def data_source_connstr_valid():
-    """验证数据源连接信息"""
+    payload = request.get_json(silent=True) or {}
+    logger.debug("验证数据源连接请求: %s", redact_mapping(payload))
     try:
-        # 获取请求参数
-        data = request.json
-        log_data = json.dumps(data, ensure_ascii=False) if data else 'None'
-        logger.debug(f"验证连接信息请求数据: {log_data}")
-
-        # 检查参数
-        if not data:
-            error_msg = "缺少连接信息参数"
-            logger.error(error_msg)
-            return jsonify(failed(error_msg))
-
-        # 检查密码是否为空
-        if not data.get('password'):
-            error_msg = "密码不能为空"
-            logger.error(error_msg)
-            return jsonify(failed(error_msg))
-
-        # 创建DDLParser实例并验证连接信息
-        from app.core.llm.ddl_parser import DDLParser
-        parser = DDLParser()
-        result = parser.valid_db_conn_str(data)
-
-        # 根据验证结果返回响应
-        if result == "success":
-            # 检查数据源是否已存在
-            check_query = """
-            MATCH (n:DataSource)
-            WHERE n.name_en = $name_en
-            RETURN n
-            """
-            existing_source = execute_cypher_query(
-                check_query, {'name_en': data['name_en']}
-            )
+        result = get_data_source_service().test_connection(payload)
+        return jsonify(success({"exists": False, **result})), 200
+    except Exception as error:
+        return _error_response(error)
 
-            if existing_source:
-                msg = "连接信息验证通过,但该数据源的定义已经存在,如果保存则会更新该数据源"
-                return jsonify(success(msg))
-            else:
-                return jsonify(success("连接信息验证通过"))
-        else:
-            return jsonify(failed("连接信息验证失败"))
 
-    except Exception as e:
-        logger.error(f"验证连接信息失败: {str(e)}")
-        return jsonify(failed(str(e)))
+@bp.route("/parse", methods=["POST"])
+def data_source_connstr_parse():
+    return (
+        jsonify(
+            failed(
+                "连接字符串快捷解析已停用",
+                code=410,
+                error={"code": "DATASOURCE_PARSE_RETIRED"},
+            )
+        ),
+        410,
+    )
 
 
-@bp.route('/conntest', methods=['POST'])
-def data_source_conn_test():
-    """测试数据源连接"""
+@bp.route("/pools", methods=["GET"])
+def data_source_pool_list():
     try:
-        # 获取请求参数
-        data = request.json
-        log_data = json.dumps(data, ensure_ascii=False) if data else 'None'
-        logger.debug(f"测试连接请求数据: {log_data}")
-
-        # 检查必需参数
-        required_fields = [
-            'type', 'username', 'host', 'port', 'database', 'password'
+        service = get_data_source_service()
+        items = [
+            service.serialize_pool_status(status)
+            for status in service.pool_statuses()
         ]
-        if not data:
-            missing_fields = required_fields
-        else:
-            missing_fields = [
-                field for field in required_fields if not data.get(field)
-            ]
-
-        if missing_fields:
-            error_msg = f"缺少必需参数: {', '.join(missing_fields)}"
-            logger.error(error_msg)
-            return jsonify(failed(error_msg))
-
-        # 此时 data 一定不为 None
-        assert data is not None
-
-        # 构建数据库URL
-        db_url = URL.create(
-            drivername=data['type'],
-            username=data['username'],
-            password=data.get('password', ''),
-            host=data['host'],
-            port=data['port'],
-            database=data['database']
-        )
-
-        # 创建数据库引擎
-        engine = create_engine(db_url, connect_args={'connect_timeout': 5})
+        return jsonify(success({"pools": items, "total": len(items)})), 200
+    except Exception as error:
+        return _error_response(error)
 
-        # 测试连接
-        try:
-            with engine.connect() as conn:
-                # 执行一个简单的查询来测试连接
-                conn.execute(text("SELECT 1"))
-                return jsonify(success({
-                    "message": f"{data['type']}连接测试成功",
-                    "connected": True
-                }))
-        except Exception as e:
-            return jsonify(failed(f"连接测试失败: {str(e)}"))
 
-    except Exception as e:
-        logger.error(f"测试连接失败: {str(e)}")
-        return jsonify(failed(str(e)))
+@bp.route("/<data_source_uid>/pool", methods=["GET"])
+def data_source_pool_status(data_source_uid):
+    try:
+        service = get_data_source_service()
+        status = service.pool_status(data_source_uid)
+        serializer = getattr(
+            service,
+            "serialize_pool_status",
+            None,
+        )
+        data = (
+            serializer(status)
+            if serializer is not None
+            else {
+                "data_source_uid": status.data_source_uid,
+                "credential_version": status.credential_version,
+                "pool_state": status.pool_state,
+                "pool_size": status.pool_size,
+                "checked_out": status.checked_out,
+                "checked_in": status.checked_in,
+                "overflow": status.overflow,
+                "leases": status.leases,
+            }
+        )
+        return jsonify(success(data)), 200
+    except Exception as error:
+        return _error_response(error)
+
+
+@bp.route("/<data_source_uid>/pool/invalidate", methods=["POST"])
+def data_source_pool_invalidate(data_source_uid):
+    payload = request.get_json(silent=True) or {}
+    reason = payload.get("reason")
+    if reason not in {
+        "admin_reset",
+        "configuration_changed",
+        "credential_rotated",
+    }:
+        return (
+            jsonify(
+                failed(
+                    "连接池失效原因无效",
+                    code=400,
+                    error={"code": "DATASOURCE_CONFIGURATION_INVALID"},
+                )
+            ),
+            400,
+        )
+    try:
+        result = get_data_source_service().invalidate_pool(
+            data_source_uid,
+            reason=reason,
+            actor_uid=_actor_uid(),
+        )
+        return jsonify(success(result)), 200
+    except Exception as error:
+        return _error_response(error)
 
 
-@bp.route('/graph', methods=['POST'])
+@bp.route("/graph", methods=["POST"])
 def data_source_graph_relationship():
-    """获取数据源关系图"""
-    # TODO: 待实现
-    return jsonify(failed("该功能尚未实现"))
+    return (
+        jsonify(
+            failed(
+                "该功能尚未实现",
+                code=501,
+                error={"code": "DATASOURCE_GRAPH_NOT_IMPLEMENTED"},
+            )
+        ),
+        501,
+    )

+ 40 - 25
deployment/app/api/meta_data/routes.py

@@ -8,6 +8,7 @@ from sqlalchemy import or_
 
 from app import db
 from app.api.meta_data import bp
+from app.core.common.identifiers import ensure_governance_uid
 from app.core.meta_data import (
     check_redundancy_for_add,
     check_redundancy_for_update,
@@ -399,7 +400,8 @@ def meta_node_add():
         with neo4j_driver.get_session() as session:
             cypher = """
             MERGE (n:DataMeta {name_zh: $name_zh})
-            ON CREATE SET n.name_en = $name_en,
+            ON CREATE SET n.uid = $uid,
+                        n.name_en = $name_en,
                         n.data_type = $data_type,
                         n.category = $category,
                         n.alias = $alias,
@@ -409,7 +411,8 @@ def meta_node_add():
                         n.updateTime = $update_time,
                         n.status = $status,
                         n.name_en = $name_en
-            ON MATCH SET n.data_type = $data_type,
+            ON MATCH SET n.uid = coalesce(n.uid, $uid),
+                        n.data_type = $data_type,
                         n.category = $category,
                         n.alias = $alias,
                         n.affiliation = $affiliation,
@@ -420,18 +423,22 @@ def meta_node_add():
             RETURN n
             """
             create_time = update_time = get_formatted_time()
+            node_properties = {
+                "name_zh": node_name_zh,
+                "data_type": node_type,
+                "category": node_category,
+                "alias": node_alias,
+                "affiliation": node_affiliation,
+                "describe": node_desc,
+                "create_time": create_time,
+                "update_time": update_time,
+                "status": node_status,
+                "name_en": node_name_en,
+            }
+            ensure_governance_uid(node_properties)
             result = session.run(
                 cypher,
-                name_zh=node_name_zh,
-                data_type=node_type,
-                category=node_category,
-                alias=node_alias,
-                affiliation=node_affiliation,
-                describe=node_desc,
-                create_time=create_time,
-                update_time=update_time,
-                status=node_status,
-                name_en=node_name_en,
+                **node_properties,
             )
 
             node = result.single()
@@ -861,6 +868,7 @@ def text_resource_node():
             # 创建资源节点
             cypher = """
             CREATE (n:DataMeta {
+                uid: $uid,
                 name_zh: $name_zh,
                 name_en: $name_en,
                 keywords: $keywords,
@@ -873,15 +881,19 @@ def text_resource_node():
             """
 
             create_time = update_time = get_formatted_time()
+            node_properties = {
+                "name_zh": name_zh,
+                "name_en": name_en,
+                "keywords": keywords,
+                "keywords_en": keywords_en,
+                "object_name": object_name,
+                "create_time": create_time,
+                "update_time": update_time,
+            }
+            ensure_governance_uid(node_properties)
             result = session.run(
                 cypher,
-                name_zh=name_zh,
-                name_en=name_en,
-                keywords=keywords,
-                keywords_en=keywords_en,
-                object_name=object_name,
-                create_time=create_time,
-                update_time=update_time,
+                **node_properties,
             )
 
             record = result.single()
@@ -1556,9 +1568,17 @@ def metadata_review_resolve():
 
             with neo4j_driver.get_session() as session:
                 # 创建新 DataMeta(避免覆盖旧节点)
+                node_properties = {
+                    "name_zh": new_name_zh,
+                    "name_en": (new_meta.get("name_en") or "").strip(),
+                    "data_type": (new_meta.get("data_type") or "varchar(255)"),
+                    "create_time": get_formatted_time(),
+                }
+                ensure_governance_uid(node_properties)
                 result = session.run(
                     """
                     CREATE (m:DataMeta {
+                        uid: $uid,
                         name_zh: $name_zh,
                         name_en: $name_en,
                         data_type: $data_type,
@@ -1567,12 +1587,7 @@ def metadata_review_resolve():
                     })
                     RETURN m
                     """,
-                    {
-                        "name_zh": new_name_zh,
-                        "name_en": (new_meta.get("name_en") or "").strip(),
-                        "data_type": (new_meta.get("data_type") or "varchar(255)"),
-                        "create_time": get_formatted_time(),
-                    },
+                    node_properties,
                 ).single()
 
                 if not result or not result.get("m"):

+ 4 - 34
deployment/app/api/system/README.md

@@ -9,7 +9,7 @@
 1. **系统健康检查**:提供系统和依赖组件的健康状态监控
 2. **配置管理**:获取和验证系统配置信息
 3. **系统信息**:获取系统运行环境的详细信息
-4. **用户认证**:提供用户注册、登录等功能
+4. **用户认证**:提供管理员预置账号登录和用户信息查询;不开放自助注册
 
 ## API接口
 
@@ -139,36 +139,7 @@
   }
   ```
 
-### 5. 用户注册接口 (/system/auth/register)
-
-- **URL**: `/system/auth/register`
-- **方法**: POST
-- **描述**: 注册新用户
-- **请求参数**:
-  ```json
-  {
-    "username": "用户名",
-    "password": "密码"
-  }
-  ```
-- **返回数据**:
-  ```json
-  {
-    "code": 200,
-    "message": "注册成功",
-    "data": null
-  }
-  ```
-- **错误响应**:
-  ```json
-  {
-    "code": 400,
-    "message": "用户名已存在",
-    "data": null
-  }
-  ```
-
-### 6. 用户登录接口 (/system/auth/login)
+### 5. 用户登录接口 (/system/auth/login)
 
 - **URL**: `/system/auth/login`
 - **方法**: POST
@@ -202,7 +173,7 @@
   }
   ```
 
-### 7. 获取用户信息接口 (/system/auth/user/{username})
+### 6. 获取用户信息接口 (/system/auth/user/{username})
 
 - **URL**: `/system/auth/user/{username}`
 - **方法**: GET
@@ -252,8 +223,7 @@ from app.core.system import (
     get_system_info,
     get_system_config,
     validate_config,
-    register_user,
     login_user,
     get_user_by_username
 )
-``` 
+```

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác