Quellcode durchsuchen

修复元数据审核的bug。
完善数据订单的处理逻辑。

maxiaolong vor 2 Tagen
Ursprung
Commit
24b1e7fa5c

+ 233 - 0
.cursor/plans/数据订单流程优化_74e37faf.plan.md

@@ -0,0 +1,233 @@
+---
+name: 数据订单流程优化
+overview: 根据新的业务流程需求,对数据订单相关接口进行优化,新增审批状态和onboard状态,实现完整的订单分析-审批-生成-完成流程。
+todos:
+  - id: model-status
+    content: 模型层:新增 STATUS_PENDING_APPROVAL 和 STATUS_ONBOARD 状态常量及标签映射
+    status: completed
+  - id: service-analyze
+    content: 服务层:修改 analyze_order 方法,分析通过后转为 pending_approval 状态
+    status: completed
+  - id: service-update
+    content: 服务层:新增 update_order 方法,支持修改订单描述和提取结果
+    status: completed
+  - id: service-generate
+    content: 服务层:新增 generate_order_resources 方法,实现自动创建 BusinessDomain 和 DataFlow
+    status: completed
+  - id: service-approve
+    content: 服务层:修改 approve_order 方法,集成自动生成逻辑
+    status: completed
+  - id: service-onboard
+    content: 服务层:新增 set_order_onboard 方法,供数据工厂回调
+    status: completed
+  - id: service-complete
+    content: 服务层:修改 complete_order 方法,只允许从 onboard 状态完成
+    status: completed
+  - id: route-update
+    content: 路由层:新增 PUT /orders/{id} 更新订单接口
+    status: completed
+  - id: route-onboard
+    content: 路由层:新增 POST /orders/{id}/onboard 回调接口
+    status: completed
+  - id: route-modify
+    content: 路由层:修改 approve_order 和 complete_order 接口逻辑
+    status: completed
+---
+
+# 数据订单流程优化计划
+
+## 流程概览
+
+```mermaid
+stateDiagram-v2
+    [*] --> pending: 新增订单
+    pending --> analyzing: 发起分析
+    analyzing --> pending_approval: 分析通过
+    analyzing --> manual_review: 分析有问题
+    manual_review --> analyzing: 修改后重新分析
+    pending_approval --> processing: 审批通过
+    pending_approval --> rejected: 审批驳回
+    processing --> onboard: 数据流程完成回调
+    onboard --> completed: 标记完成
+    rejected --> [*]
+    completed --> [*]
+```
+
+## 状态定义变更
+
+| 状态值 | 状态名称 | 说明 |
+
+|--------|----------|------|
+
+| pending | 待处理 | 新增订单初始状态 |
+
+| analyzing | 分析中 | 正在进行 LLM 分析 |
+
+| pending_approval | 待审批 | 分析通过,等待人工审批 |
+
+| manual_review | 待人工处理 | 分析有问题,需人工修改 |
+
+| processing | 加工中 | 审批通过,自动生成资源中 |
+
+| onboard | 数据产品就绪 | 数据流程完成,产品可用 |
+
+| completed | 已完成 | 订单最终完成 |
+
+| rejected | 已驳回 | 审批被驳回 |
+
+## 涉及文件修改
+
+### 1. 模型层 - [app/models/data_product.py](app/models/data_product.py)
+
+**修改内容:**
+
+- 新增状态常量 `STATUS_PENDING_APPROVAL = "pending_approval"` 和 `STATUS_ONBOARD = "onboard"`
+- 更新 `STATUS_LABELS` 映射
+```python
+# 新增状态常量
+STATUS_PENDING_APPROVAL = "pending_approval"  # 待审批
+STATUS_ONBOARD = "onboard"  # 数据产品就绪
+```
+
+
+### 2. 服务层 - [app/core/data_service/data_product_service.py](app/core/data_service/data_product_service.py)
+
+**修改内容:**
+
+**(A) 修改 `analyze_order` 方法**
+
+- 分析通过时状态改为 `pending_approval` (而非 `processing`)
+- 分析有问题时保持 `manual_review`
+```python
+# 修改第 1530-1535 行的逻辑
+if can_connect:
+    order.update_status(DataOrder.STATUS_PENDING_APPROVAL)  # 改为待审批
+else:
+    order.update_status(DataOrder.STATUS_MANUAL_REVIEW)
+```
+
+
+**(B) 修改 `approve_order` 方法**
+
+- 接受来自 `pending_approval` 状态的审批
+- 审批通过后调用自动生成逻辑
+- 返回生成的 BusinessDomain 和 DataFlow 信息
+
+**(C) 新增 `update_order` 方法**
+
+- 支持修改订单的 title、description
+- 支持修改 extracted_domains、extracted_fields、extraction_purpose
+- 修改后状态重置为 `pending` 或保持 `manual_review`
+
+**(D) 新增 `generate_order_resources` 方法**
+
+- 根据订单分析结果自动创建 BusinessDomain 节点
+- 自动创建 DataFlow 节点
+- 建立节点间的关系 (INPUT/OUTPUT)
+- 关联到数据订单
+
+**(E) 新增 `set_order_onboard` 方法**
+
+- 供数据工厂回调,将订单状态更新为 `onboard`
+- 更新关联的 dataflow_id 和 product_id
+
+**(F) 新增 `mark_order_completed` 方法**
+
+- 从 `onboard` 状态标记为最终完成
+
+### 3. 路由层 - [app/api/data_service/routes.py](app/api/data_service/routes.py)
+
+**修改内容:**
+
+**(A) 修改 `approve_order` 路由**
+
+- 接受 `pending_approval` 状态
+- 调用自动生成逻辑
+
+**(B) 新增 `PUT /orders/<order_id>` 更新订单接口**
+
+```python
+@bp.route("/orders/<int:order_id>", methods=["PUT"])
+def update_order(order_id: int):
+    """更新数据订单(支持修改描述和提取结果)"""
+```
+
+请求体:
+
+```json
+{
+    "title": "新标题(可选)",
+    "description": "新描述(可选)",
+    "extracted_domains": ["域1", "域2"],
+    "extracted_fields": ["字段1", "字段2"],
+    "extraction_purpose": "用途(可选)"
+}
+```
+
+**(C) 新增 `POST /orders/<order_id>/onboard` 回调接口**
+
+```python
+@bp.route("/orders/<int:order_id>/onboard", methods=["POST"])
+def onboard_order(order_id: int):
+    """数据工厂回调:设置订单为数据产品就绪状态"""
+```
+
+请求体:
+
+```json
+{
+    "product_id": 123,
+    "dataflow_id": 456,
+    "processed_by": "n8n-workflow"
+}
+```
+
+**(D) 修改 `complete_order` 接口逻辑**
+
+- 只允许从 `onboard` 状态标记完成
+- 添加状态校验
+
+### 4. 数据库迁移 (可选)
+
+数据库表 `data_orders` 的 `status` 字段已经是 VARCHAR(50),可以直接存储新的状态值,无需 DDL 变更。但建议更新表注释:
+
+```sql
+-- 更新状态字段注释
+COMMENT ON COLUMN public.data_orders.status IS 
+    '订单状态:pending/analyzing/pending_approval/manual_review/processing/onboard/completed/rejected';
+```
+
+## 接口变更汇总
+
+| 操作 | 方法 | 路径 | 变更类型 |
+
+|------|------|------|----------|
+
+| 更新订单 | PUT | /orders/{id} | 新增 |
+
+| 设置onboard | POST | /orders/{id}/onboard | 新增 |
+
+| 审批通过 | POST | /orders/{id}/approve | 修改逻辑 |
+
+| 标记完成 | POST | /orders/{id}/complete | 修改逻辑 |
+
+| 订单分析 | POST | /orders/{id}/analyze | 修改逻辑 |
+
+## 自动生成资源逻辑
+
+审批通过后,`generate_order_resources` 方法将:
+
+1. 根据 `graph_analysis.matched_domains` 确定输入的 BusinessDomain 节点
+2. 创建新的目标 BusinessDomain 节点(作为数据产品的承载)
+3. 创建 DataFlow 节点,设置 script_requirement 等属性
+4. 建立 INPUT 关系(源 BusinessDomain -> DataFlow)
+5. 建立 OUTPUT 关系(DataFlow -> 目标 BusinessDomain)
+6. 更新订单的 `result_dataflow_id`
+
+## 实施顺序
+
+1. 先修改模型层,新增状态常量
+2. 修改服务层,实现核心业务逻辑
+3. 修改路由层,暴露新接口
+4. 更新数据库注释(可选)
+5. 测试完整流程

+ 253 - 0
DEPLOYMENT_GUIDE.md

@@ -0,0 +1,253 @@
+# DataOps Platform 部署指南
+
+## 📋 本次更新内容
+
+### 时区修正
+将所有时间字段从 UTC 时间改为东八区(Asia/Shanghai)时间。
+
+### 修改的文件
+- ✅ `app/core/common/timezone_utils.py` - 新增时区工具模块(兼容 Python 3.8+)
+- ✅ `app/models/data_product.py` - 时间字段修正
+- ✅ `app/models/metadata_review.py` - 时间字段修正
+- ✅ `app/core/data_service/data_product_service.py` - 时间处理修正
+- ✅ `app/core/meta_data/redundancy_check.py` - 时间处理修正
+- ✅ `app/core/business_domain/business_domain.py` - 时间处理修正
+
+### 新增的文件
+- 📄 `scripts/fix_startup.sh` - 自动修复脚本
+- 📄 `scripts/diagnose_issue.sh` - 问题诊断脚本
+- 📄 `scripts/TROUBLESHOOTING.md` - 故障排查指南
+- 📄 `QUICK_FIX.md` - 快速修复指南
+- 📄 `PYTHON38_COMPATIBILITY.md` - Python 3.8 兼容性说明
+- 📄 `docs/timezone_fix_summary.md` - 时区修正总结
+
+## 🚀 部署步骤(生产环境 Python 3.8)
+
+### 前置条件检查
+
+```bash
+# 1. 检查 Python 版本
+python --version
+# 应该显示: Python 3.8.x
+
+# 2. 检查 backports.zoneinfo 是否已安装
+cd /opt/dataops-platform
+source venv/bin/activate
+python -c "import backports.zoneinfo; print('backports.zoneinfo 已安装')"
+# 如果已安装,应该显示: backports.zoneinfo 已安装
+```
+
+### 步骤 1: 上传代码到服务器
+
+**方法 A: 使用 Git(推荐)**
+```bash
+cd /opt/dataops-platform
+git pull origin main
+```
+
+**方法 B: 使用 SCP 上传**
+```bash
+# 在本地执行
+scp -r app/ scripts/ docs/ *.md ubuntu@your-server:/opt/dataops-platform/
+```
+
+### 步骤 2: 运行自动修复脚本
+
+```bash
+# 在服务器上执行
+cd /opt/dataops-platform/scripts
+sudo chmod +x fix_startup.sh diagnose_issue.sh
+sudo ./fix_startup.sh
+```
+
+### 步骤 3: 验证部署
+
+```bash
+# 1. 检查服务状态
+sudo supervisorctl status dataops-platform
+# 预期输出: dataops-platform RUNNING pid xxx, uptime x:xx:xx
+
+# 2. 测试健康检查
+curl http://localhost:5500/api/system/health
+# 预期输出: {"status":"healthy",...}
+
+# 3. 测试时区功能
+cd /opt/dataops-platform
+source venv/bin/activate
+python -c "from app.core.common.timezone_utils import now_china_naive; print('当前东八区时间:', now_china_naive())"
+# 预期输出: 当前东八区时间: 2026-01-12 18:30:45.123456
+```
+
+## 🔧 技术细节
+
+### Python 3.8 兼容性
+
+代码已自动适配 Python 3.8 和 3.9+:
+
+```python
+# app/core/common/timezone_utils.py
+try:
+    # Python 3.9+
+    from zoneinfo import ZoneInfo
+except ImportError:
+    # Python 3.8 使用 backports
+    from backports.zoneinfo import ZoneInfo
+```
+
+### 依赖要求
+
+| Python 版本 | zoneinfo 模块 | 系统依赖 |
+|------------|--------------|---------|
+| 3.8 | `backports.zoneinfo` (pip) | `tzdata` (apt) |
+| 3.9+ | 标准库 `zoneinfo` | `tzdata` (apt) |
+
+### 当前生产环境配置
+
+- **Python 版本**: 3.8
+- **已安装**: `backports.zoneinfo`
+- **需确认**: 系统 `tzdata` 包
+
+## ⚠️ 可能遇到的问题
+
+### 问题 1: 应用启动失败
+
+**症状**:
+```bash
+[ERROR] dataops-platform 重启失败!
+tail: cannot open '/opt/dataops-platform/logs/gunicorn_error.log' for reading: No such file or directory
+```
+
+**原因**: 缺少系统时区数据
+
+**解决**:
+```bash
+sudo apt-get update
+sudo apt-get install -y tzdata
+sudo supervisorctl restart dataops-platform
+```
+
+### 问题 2: ModuleNotFoundError: No module named 'zoneinfo'
+
+**症状**:
+```python
+ModuleNotFoundError: No module named 'zoneinfo'
+```
+
+**原因**: Python 3.8 环境未安装 `backports.zoneinfo`
+
+**解决**:
+```bash
+cd /opt/dataops-platform
+source venv/bin/activate
+pip install backports.zoneinfo
+sudo supervisorctl restart dataops-platform
+```
+
+### 问题 3: ZoneInfoNotFoundError
+
+**症状**:
+```python
+ZoneInfoNotFoundError: 'No time zone found with key Asia/Shanghai'
+```
+
+**原因**: 系统缺少时区数据库
+
+**解决**:
+```bash
+sudo apt-get update
+sudo apt-get install -y tzdata
+sudo supervisorctl restart dataops-platform
+```
+
+## 📊 验证清单
+
+部署完成后,请验证以下功能:
+
+- [ ] 服务启动成功(`supervisorctl status` 显示 RUNNING)
+- [ ] 健康检查接口返回 200
+- [ ] 时区模块正常工作
+- [ ] 创建新数据订单,检查 `created_at` 字段
+- [ ] 更新订单状态,检查 `updated_at` 和 `processed_at` 字段
+- [ ] 注册数据产品,检查时间字段
+- [ ] 查看数据产品,检查 `last_viewed_at` 字段
+
+## 🔍 故障排查
+
+### 查看实时日志
+
+```bash
+# Supervisor 错误日志(最重要)
+sudo tail -f /var/log/supervisor/dataops-platform-stderr.log
+
+# Gunicorn 错误日志
+tail -f /opt/dataops-platform/logs/gunicorn_error.log
+
+# Gunicorn 访问日志
+tail -f /opt/dataops-platform/logs/gunicorn_access.log
+```
+
+### 运行诊断脚本
+
+```bash
+cd /opt/dataops-platform/scripts
+sudo ./diagnose_issue.sh
+```
+
+### 手动启动测试
+
+```bash
+cd /opt/dataops-platform
+source venv/bin/activate
+gunicorn -c gunicorn_config.py 'app:create_app()'
+# 按 Ctrl+C 停止
+```
+
+## 📚 相关文档
+
+- **快速修复**: `QUICK_FIX.md`
+- **Python 3.8 兼容性**: `PYTHON38_COMPATIBILITY.md`
+- **故障排查**: `scripts/TROUBLESHOOTING.md`
+- **时区修正总结**: `docs/timezone_fix_summary.md`
+
+## 🎯 回滚方案
+
+如果部署后出现严重问题,可以回滚到之前的版本:
+
+```bash
+# 1. 回滚代码
+cd /opt/dataops-platform
+git reset --hard HEAD~1
+
+# 2. 重启服务
+sudo supervisorctl restart dataops-platform
+
+# 3. 验证服务
+sudo supervisorctl status dataops-platform
+```
+
+## 📞 技术支持
+
+如果遇到问题,请提供以下信息:
+
+1. **诊断输出**:
+   ```bash
+   cd /opt/dataops-platform/scripts
+   sudo ./diagnose_issue.sh > ~/diagnosis.log 2>&1
+   ```
+
+2. **错误日志**:
+   ```bash
+   sudo tail -100 /var/log/supervisor/dataops-platform-stderr.log > ~/error.log
+   ```
+
+3. **系统信息**:
+   ```bash
+   python --version
+   cat /etc/os-release
+   ```
+
+---
+
+**部署日期**: 2026-01-12  
+**版本**: v1.0 - 时区修正  
+**兼容性**: Python 3.8+

+ 154 - 0
PYTHON38_COMPATIBILITY.md

@@ -0,0 +1,154 @@
+# Python 3.8 兼容性说明
+
+## 概述
+
+DataOps Platform 的时区功能已兼容 Python 3.8 和 Python 3.9+。
+
+## 时区模块兼容性
+
+### 代码实现
+
+`app/core/common/timezone_utils.py` 使用了兼容性导入:
+
+```python
+try:
+    # Python 3.9+
+    from zoneinfo import ZoneInfo
+except ImportError:
+    # Python 3.8 使用 backports
+    from backports.zoneinfo import ZoneInfo
+```
+
+### 依赖要求
+
+#### Python 3.8 环境
+
+1. **必须安装**: `backports.zoneinfo`
+   ```bash
+   pip install backports.zoneinfo
+   ```
+
+2. **系统依赖**: `tzdata`
+   ```bash
+   sudo apt-get update
+   sudo apt-get install -y tzdata
+   ```
+
+#### Python 3.9+ 环境
+
+1. **标准库**: 内置 `zoneinfo`,无需额外安装
+2. **系统依赖**: `tzdata`
+   ```bash
+   sudo apt-get update
+   sudo apt-get install -y tzdata
+   ```
+
+## 生产环境配置
+
+### 当前生产环境
+
+- **Python 版本**: 3.8
+- **已安装**: `backports.zoneinfo`
+- **需要**: 确保系统已安装 `tzdata`
+
+### 验证安装
+
+```bash
+# 进入虚拟环境
+cd /opt/dataops-platform
+source venv/bin/activate
+
+# 测试时区模块
+python -c "
+try:
+    from zoneinfo import ZoneInfo
+    print('使用标准库 zoneinfo (Python 3.9+)')
+except ImportError:
+    from backports.zoneinfo import ZoneInfo
+    print('使用 backports.zoneinfo (Python 3.8)')
+
+from datetime import datetime
+tz = ZoneInfo('Asia/Shanghai')
+now = datetime.now(tz)
+print(f'当前东八区时间: {now}')
+"
+```
+
+预期输出:
+```
+使用 backports.zoneinfo (Python 3.8)
+当前东八区时间: 2026-01-12 18:30:45.123456+08:00
+```
+
+## requirements.txt 更新
+
+确保 `requirements.txt` 包含以下内容:
+
+```txt
+# Python 3.8 时区支持
+backports.zoneinfo>=0.2.1; python_version < "3.9"
+```
+
+这样可以:
+- Python 3.8 环境自动安装 `backports.zoneinfo`
+- Python 3.9+ 环境跳过安装(使用标准库)
+
+## 常见问题
+
+### Q1: ModuleNotFoundError: No module named 'zoneinfo'
+
+**原因**: Python 3.8 环境未安装 `backports.zoneinfo`
+
+**解决**:
+```bash
+pip install backports.zoneinfo
+```
+
+### Q2: ZoneInfoNotFoundError: 'No time zone found with key Asia/Shanghai'
+
+**原因**: 系统缺少时区数据
+
+**解决**:
+```bash
+sudo apt-get update
+sudo apt-get install -y tzdata
+```
+
+### Q3: 如何确认当前使用的是哪个模块?
+
+**测试代码**:
+```python
+import sys
+try:
+    from zoneinfo import ZoneInfo
+    print(f"使用标准库 zoneinfo: {sys.modules['zoneinfo']}")
+except ImportError:
+    from backports.zoneinfo import ZoneInfo
+    print(f"使用 backports.zoneinfo: {sys.modules['backports.zoneinfo']}")
+```
+
+## 升级路径
+
+### 从 Python 3.8 升级到 3.9+
+
+当升级 Python 版本时:
+
+1. **无需修改代码**: 兼容性导入会自动使用标准库
+2. **可选清理**: 卸载 `backports.zoneinfo`
+   ```bash
+   pip uninstall backports.zoneinfo
+   ```
+3. **验证**: 运行测试确保时区功能正常
+
+## 性能考虑
+
+- `backports.zoneinfo` 和标准库 `zoneinfo` 性能基本相同
+- 两者都使用系统的 IANA 时区数据库
+- 升级到 Python 3.9+ 后无性能差异
+
+## 相关文件
+
+- 时区工具模块: `app/core/common/timezone_utils.py`
+- 修复脚本: `scripts/fix_startup.sh`
+- 故障排查: `scripts/TROUBLESHOOTING.md`
+- 快速修复: `QUICK_FIX.md`

+ 123 - 0
QUICK_FIX.md

@@ -0,0 +1,123 @@
+# 🚨 DataOps Platform 启动失败快速修复
+
+## 问题现象
+
+```bash
+[ERROR] dataops-platform 重启失败!
+tail: cannot open '/opt/dataops-platform/logs/gunicorn_error.log' for reading: No such file or directory
+```
+
+## 🔧 快速修复(3 步)
+
+### 在服务器上执行以下命令:
+
+```bash
+# 1. 上传最新代码到服务器
+# (在本地执行,或使用 git pull)
+
+# 2. 进入脚本目录
+cd /opt/dataops-platform/scripts
+
+# 3. 运行自动修复脚本
+sudo chmod +x fix_startup.sh
+sudo ./fix_startup.sh
+```
+
+## ✅ 如果修复成功
+
+你会看到:
+```
+✓ dataops-platform 启动成功!
+✓ 健康检查通过! HTTP 状态码: 200
+访问地址: http://localhost:5500
+```
+
+## ❌ 如果修复失败
+
+### 方案 A: 查看详细诊断
+
+```bash
+cd /opt/dataops-platform/scripts
+sudo chmod +x diagnose_issue.sh
+sudo ./diagnose_issue.sh
+```
+
+### 方案 B: 查看错误日志
+
+```bash
+# 最重要的日志(通常能看到真正的错误)
+sudo tail -50 /var/log/supervisor/dataops-platform-stderr.log
+```
+
+### 方案 C: 手动修复核心问题
+
+最可能的原因是缺少时区数据(因为我们刚添加了 `zoneinfo` 模块):
+
+```bash
+# 安装时区数据
+sudo apt-get update
+sudo apt-get install -y tzdata
+
+# 创建日志目录
+sudo mkdir -p /opt/dataops-platform/logs
+sudo chown ubuntu:ubuntu /opt/dataops-platform/logs
+
+# 重启服务
+sudo supervisorctl restart dataops-platform
+
+# 查看状态
+sudo supervisorctl status dataops-platform
+```
+
+## 📋 验证修复
+
+```bash
+# 1. 检查服务状态(应该显示 RUNNING)
+sudo supervisorctl status dataops-platform
+
+# 2. 测试健康检查接口
+curl http://localhost:5500/api/system/health
+
+# 3. 测试时区功能
+cd /opt/dataops-platform
+source venv/bin/activate
+python -c "from app.core.common.timezone_utils import now_china_naive; print('当前东八区时间:', now_china_naive())"
+```
+
+## 🔍 根本原因
+
+我们最近的代码修改引入了 `zoneinfo` 模块用于处理东八区时间。
+
+**重要**: 
+- Python 3.9+ 使用标准库 `zoneinfo`
+- Python 3.8 需要使用 `backports.zoneinfo` 包
+- 两者都需要系统安装 `tzdata` 包
+
+修改的文件:
+- `app/core/common/timezone_utils.py` - 新增的时区工具模块(已兼容 Python 3.8)
+- `app/models/data_product.py` - 使用东八区时间
+- `app/models/metadata_review.py` - 使用东八区时间
+- 其他服务层文件
+
+## 📞 需要帮助?
+
+如果以上方法都无法解决,请提供:
+
+1. 诊断输出:
+   ```bash
+   cd /opt/dataops-platform/scripts
+   sudo ./diagnose_issue.sh > ~/diagnosis.log 2>&1
+   cat ~/diagnosis.log
+   ```
+
+2. 错误日志:
+   ```bash
+   sudo tail -100 /var/log/supervisor/dataops-platform-stderr.log
+   ```
+
+## 📚 详细文档
+
+查看完整的故障排查指南:
+```bash
+cat /opt/dataops-platform/scripts/TROUBLESHOOTING.md
+```

+ 239 - 0
RELEASE_NOTES.md

@@ -0,0 +1,239 @@
+# Release Notes - 时区修正版本
+
+## 版本信息
+
+- **版本号**: v1.0-timezone-fix
+- **发布日期**: 2026-01-12
+- **兼容性**: Python 3.8+
+
+## 🎯 主要变更
+
+### 时区统一修正
+
+将所有时间字段从 UTC 时间改为东八区(Asia/Shanghai)时间,确保时间显示符合中国用户习惯。
+
+#### 影响的数据表
+
+| 表名 | 修正的字段 |
+|-----|-----------|
+| `data_orders` | `created_at`, `updated_at`, `processed_at` |
+| `data_products` | `created_at`, `updated_at`, `last_updated_at`, `last_viewed_at` |
+| `metadata_review_records` | `created_at`, `updated_at`, `resolved_at` |
+| `metadata_version_history` | `created_at` |
+
+## ✨ 新增功能
+
+### 1. 时区工具模块
+
+新增 `app/core/common/timezone_utils.py`,提供统一的时区处理:
+
+- `now_china()` - 获取当前东八区时间(带时区信息)
+- `now_china_naive()` - 获取当前东八区时间(用于数据库存储)
+- `to_china_time()` - 转换任意时区到东八区
+- `utc_to_china_naive()` - UTC 转东八区(不带时区信息)
+
+### 2. 自动化运维脚本
+
+新增多个运维脚本,简化部署和故障排查:
+
+- `scripts/fix_startup.sh` - 自动修复启动问题
+- `scripts/diagnose_issue.sh` - 全面的问题诊断
+- `scripts/TROUBLESHOOTING.md` - 详细的故障排查指南
+
+### 3. 完整的文档
+
+- `QUICK_FIX.md` - 快速修复指南
+- `PYTHON38_COMPATIBILITY.md` - Python 3.8 兼容性说明
+- `DEPLOYMENT_GUIDE.md` - 完整部署指南
+- `docs/timezone_fix_summary.md` - 技术实现总结
+
+## 🔧 技术改进
+
+### Python 版本兼容性
+
+代码已完全兼容 Python 3.8 和 3.9+:
+
+```python
+try:
+    from zoneinfo import ZoneInfo  # Python 3.9+
+except ImportError:
+    from backports.zoneinfo import ZoneInfo  # Python 3.8
+```
+
+### 代码质量提升
+
+- ✅ 类型注解优化:`Optional[X]` → `X | None`
+- ✅ 异常处理优化:`try-except-pass` → `contextlib.suppress()`
+- ✅ 集合创建优化:`set(generator)` → `{set comprehension}`
+- ✅ 通过所有 linter 检查
+
+## 📝 修改的文件
+
+### 核心代码(6 个文件)
+
+1. `app/core/common/timezone_utils.py` - **新建**
+2. `app/models/data_product.py` - 修改 DataProduct 和 DataOrder 模型
+3. `app/models/metadata_review.py` - 修改两个模型和一个函数
+4. `app/core/data_service/data_product_service.py` - 修改 9 处时间处理
+5. `app/core/meta_data/redundancy_check.py` - 修改时间处理 + 代码优化
+6. `app/core/business_domain/business_domain.py` - 修改时间处理
+
+### 运维脚本(3 个文件)
+
+1. `scripts/fix_startup.sh` - **新建**
+2. `scripts/diagnose_issue.sh` - **新建**
+3. `scripts/TROUBLESHOOTING.md` - **新建**
+
+### 文档(5 个文件)
+
+1. `QUICK_FIX.md` - **新建**
+2. `PYTHON38_COMPATIBILITY.md` - **新建**
+3. `DEPLOYMENT_GUIDE.md` - **新建**
+4. `RELEASE_NOTES.md` - **新建**(本文件)
+5. `docs/timezone_fix_summary.md` - **新建**
+
+## ⚙️ 部署要求
+
+### 系统依赖
+
+```bash
+sudo apt-get install -y tzdata
+```
+
+### Python 依赖
+
+**Python 3.8**:
+```bash
+pip install backports.zoneinfo
+```
+
+**Python 3.9+**:
+无需额外安装(使用标准库)
+
+## 🚀 部署步骤
+
+### 快速部署(推荐)
+
+```bash
+# 1. 上传代码
+cd /opt/dataops-platform
+git pull origin main
+
+# 2. 运行自动修复脚本
+cd scripts
+sudo chmod +x fix_startup.sh
+sudo ./fix_startup.sh
+
+# 3. 验证部署
+sudo supervisorctl status dataops-platform
+curl http://localhost:5500/api/system/health
+```
+
+详细步骤请参考 `DEPLOYMENT_GUIDE.md`
+
+## ⚠️ 重要提示
+
+### 已有数据
+
+数据库中已存在的记录可能使用 UTC 时间。新旧数据会混合存在。
+
+**可选的数据迁移**(将历史数据统一为东八区时间):
+
+```sql
+-- 将 UTC 时间转换为东八区时间(+8小时)
+UPDATE data_orders 
+SET created_at = created_at + INTERVAL '8 hours',
+    updated_at = updated_at + INTERVAL '8 hours',
+    processed_at = processed_at + INTERVAL '8 hours'
+WHERE created_at < '2026-01-12 00:00:00';
+```
+
+完整的迁移 SQL 请参考 `docs/timezone_fix_summary.md`
+
+### API 变更
+
+所有 API 返回的时间字段现在表示**东八区时间**,而不是 UTC 时间。
+
+**示例**:
+```json
+{
+  "created_at": "2026-01-12T18:30:45",  // 东八区时间
+  "updated_at": "2026-01-12T18:30:45"   // 东八区时间
+}
+```
+
+## 🐛 已知问题
+
+无
+
+## 🔄 回滚方案
+
+如果需要回滚:
+
+```bash
+cd /opt/dataops-platform
+git reset --hard HEAD~1
+sudo supervisorctl restart dataops-platform
+```
+
+## 📊 测试建议
+
+部署后请验证以下功能:
+
+1. ✅ 创建新数据订单,检查 `created_at` 是否为东八区当前时间
+2. ✅ 更新订单状态,检查 `updated_at` 和 `processed_at`
+3. ✅ 注册数据产品,检查时间字段
+4. ✅ 查看数据产品,检查 `last_viewed_at` 更新
+5. ✅ 创建元数据审核记录,检查时间字段
+
+## 📞 支持
+
+### 快速修复
+
+遇到问题?运行快速修复脚本:
+
+```bash
+cd /opt/dataops-platform/scripts
+sudo ./fix_startup.sh
+```
+
+### 问题诊断
+
+运行诊断脚本获取详细信息:
+
+```bash
+cd /opt/dataops-platform/scripts
+sudo ./diagnose_issue.sh
+```
+
+### 查看日志
+
+```bash
+# 最重要的日志
+sudo tail -f /var/log/supervisor/dataops-platform-stderr.log
+```
+
+### 文档索引
+
+- 🚀 **快速开始**: `QUICK_FIX.md`
+- 📖 **部署指南**: `DEPLOYMENT_GUIDE.md`
+- 🐍 **Python 3.8**: `PYTHON38_COMPATIBILITY.md`
+- 🔧 **故障排查**: `scripts/TROUBLESHOOTING.md`
+- 📚 **技术细节**: `docs/timezone_fix_summary.md`
+
+## 👥 贡献者
+
+- AI Assistant - 时区修正实现
+- 项目团队 - 需求提出和测试
+
+## 📅 下一步计划
+
+- [ ] 监控生产环境运行情况
+- [ ] 收集用户反馈
+- [ ] 考虑是否需要迁移历史数据
+
+---
+
+**发布人**: AI Assistant  
+**发布日期**: 2026-01-12  
+**文档版本**: 1.0

+ 18 - 1
app/api/business_domain/routes.py

@@ -580,7 +580,10 @@ def bd_search():
 
 @bp.route("/compose", methods=["POST"])
 def bd_compose():
-    """从已有业务领域中组合创建新的业务领域"""
+    """从已有业务领域中组合创建新的业务领域
+
+    id_list: 选中的元数据ID列表,格式为 [id1, id2, ...] 或 [{"id": id1}, {"id": id2}, ...]
+    """
     try:
         data = request.json
         if not data:
@@ -591,6 +594,20 @@ def bd_compose():
         if not data.get("id_list"):
             return jsonify(failed("id_list 为必填项"))
 
+        # 简化 id_list 格式:直接提取元数据ID列表
+        raw_id_list = data.get("id_list", [])
+        meta_ids = []
+        for item in raw_id_list:
+            if isinstance(item, int):
+                # 直接是ID数字
+                meta_ids.append(item)
+            elif isinstance(item, dict) and "id" in item:
+                # {"id": xxx} 格式
+                meta_ids.append(item["id"])
+
+        # 将处理后的元数据ID列表放入data中
+        data["meta_ids"] = meta_ids
+
         result_data = business_domain_compose(data)
         response_data = {"business_domain": result_data}
         return jsonify(success(response_data))

+ 112 - 17
app/api/data_service/routes.py

@@ -380,7 +380,7 @@ def get_orders():
         return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
 
 
-@bp.route("/orders/<int:order_id>", methods=["GET"])
+@bp.route("/orders/<int:order_id>/detail", methods=["GET"])
 def get_order(order_id: int):
     """
     获取数据订单详情
@@ -442,6 +442,55 @@ def create_order():
         return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
 
 
+@bp.route("/orders/<int:order_id>/update", methods=["PUT"])
+def update_order(order_id: int):
+    """
+    更新数据订单(支持修改描述和提取结果)
+
+    只允许在 pending、manual_review、need_supplement 状态下修改
+
+    Path Parameters:
+        order_id: 数据订单ID
+
+    Request Body:
+        title: 订单标题(可选)
+        description: 需求描述(可选)
+        extracted_domains: 提取的业务领域列表(可选)
+        extracted_fields: 提取的数据字段列表(可选)
+        extraction_purpose: 数据用途(可选)
+    """
+    try:
+        data = request.get_json()
+        if not data:
+            res = failed("请求数据不能为空", code=400)
+            return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
+
+        order = DataOrderService.update_order(
+            order_id=order_id,
+            title=data.get("title"),
+            description=data.get("description"),
+            extracted_domains=data.get("extracted_domains"),
+            extracted_fields=data.get("extracted_fields"),
+            extraction_purpose=data.get("extraction_purpose"),
+        )
+
+        if not order:
+            res = failed("数据订单不存在", code=404)
+            return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
+
+        res = success(order.to_dict(), "更新数据订单成功")
+        return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
+
+    except ValueError as ve:
+        logger.warning(f"更新数据订单参数错误: {str(ve)}")
+        res = failed(str(ve), code=400)
+        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("/orders/<int:order_id>/analyze", methods=["POST"])
 def analyze_order(order_id: int):
     """
@@ -469,25 +518,27 @@ def analyze_order(order_id: int):
 @bp.route("/orders/<int:order_id>/approve", methods=["POST"])
 def approve_order(order_id: int):
     """
-    审批通过数据订单
+    审批通过数据订单,并自动生成 BusinessDomain 和 DataFlow 资源
+
+    只允许从 pending_approval 或 manual_review 状态审批
 
     Path Parameters:
         order_id: 数据订单ID
 
     Request Body:
         processed_by: 处理人(可选,默认admin)
+
+    Returns:
+        order: 更新后的订单信息
+        generated_resources: 生成的资源信息(包含 dataflow_id、target_business_domain_id 等)
     """
     try:
         data = request.get_json() or {}
         processed_by = data.get("processed_by", "admin")
 
-        order = DataOrderService.approve_order(order_id, processed_by)
-
-        if not order:
-            res = failed("数据订单不存在", code=404)
-            return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
+        result = DataOrderService.approve_order(order_id, processed_by)
 
-        res = success(order.to_dict(), "数据订单审批通过")
+        res = success(result, "数据订单审批通过,资源已生成")
         return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
 
     except ValueError as ve:
@@ -540,27 +591,67 @@ def reject_order(order_id: int):
         return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
 
 
-@bp.route("/orders/<int:order_id>/complete", methods=["POST"])
-def complete_order(order_id: int):
+@bp.route("/orders/<int:order_id>/onboard", methods=["POST"])
+def onboard_order(order_id: int):
     """
-    完成数据订单
+    数据工厂回调:设置订单为数据产品就绪状态
+
+    只允许从 processing 状态转换为 onboard 状态
 
     Path Parameters:
         order_id: 数据订单ID
 
     Request Body:
         product_id: 生成的数据产品ID(可选)
-        dataflow_id: 生成的数据流ID(可选)
-        processed_by: 处理人(可选,默认system
+        dataflow_id: 数据流ID(可选)
+        processed_by: 处理人(可选,默认n8n-workflow
     """
     try:
         data = request.get_json() or {}
 
-        order = DataOrderService.complete_order(
+        order = DataOrderService.set_order_onboard(
             order_id=order_id,
             product_id=data.get("product_id"),
             dataflow_id=data.get("dataflow_id"),
-            processed_by=data.get("processed_by", "system"),
+            processed_by=data.get("processed_by", "n8n-workflow"),
+        )
+
+        if not order:
+            res = failed("数据订单不存在", code=404)
+            return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
+
+        res = success(order.to_dict(), "数据订单已设置为数据产品就绪状态")
+        return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
+
+    except ValueError as ve:
+        logger.warning(f"设置订单onboard状态参数错误: {str(ve)}")
+        res = failed(str(ve), code=400)
+        return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
+    except Exception as e:
+        logger.error(f"设置订单onboard状态失败: {str(e)}")
+        res = failed(f"设置订单onboard状态失败: {str(e)}")
+        return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
+
+
+@bp.route("/orders/<int:order_id>/complete", methods=["POST"])
+def complete_order(order_id: int):
+    """
+    标记数据订单为最终完成状态
+
+    只允许从 onboard(数据产品就绪)状态标记完成
+
+    Path Parameters:
+        order_id: 数据订单ID
+
+    Request Body:
+        processed_by: 处理人(可选,默认user)
+    """
+    try:
+        data = request.get_json() or {}
+
+        order = DataOrderService.complete_order(
+            order_id=order_id,
+            processed_by=data.get("processed_by", "user"),
         )
 
         if not order:
@@ -570,16 +661,20 @@ def complete_order(order_id: int):
         res = success(order.to_dict(), "数据订单已完成")
         return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
 
+    except ValueError as ve:
+        logger.warning(f"完成数据订单参数错误: {str(ve)}")
+        res = failed(str(ve), code=400)
+        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("/orders/<int:order_id>", methods=["DELETE"])
+@bp.route("/orders/<int:order_id>/delete", methods=["PUT"])
 def delete_order(order_id: int):
     """
-    删除数据订单
+    删除数据订单(软删除)
 
     Path Parameters:
         order_id: 数据订单ID

+ 98 - 47
app/api/meta_data/routes.py

@@ -354,6 +354,9 @@ def meta_node_add():
         tag_ids = normalize_tag_inputs(node_tag)
 
         # ========== 冗余检测 ==========
+        has_suspicious_duplicates = False
+        suspicious_candidates = []
+
         if not force_create:
             redundancy_result = check_redundancy_for_add(
                 name_zh=node_name_zh,
@@ -362,43 +365,29 @@ def meta_node_add():
                 tag_ids=tag_ids,
             )
 
-            # 存在完全匹配的元数据
+            # 存在完全匹配的元数据,直接返回,不做任何操作
             if redundancy_result["has_exact_match"]:
                 exact_id = redundancy_result["exact_match_id"]
                 logger.info(
                     f"元数据已存在(完全匹配): name_zh={node_name_zh}, "
                     f"existing_id={exact_id}"
                 )
-                # 返回已存在的节点信息
-                with neo4j_driver.get_session() as session:
-                    existing = session.run(
-                        "MATCH (n:DataMeta) WHERE id(n) = $id RETURN n",
-                        {"id": exact_id},
-                    ).single()
-                    if existing and existing["n"]:
-                        existing_data = dict(existing["n"])
-                        existing_data["id"] = existing["n"].id
-                        return jsonify(
-                            success(existing_data, message="元数据已存在,返回已有节点")
-                        )
-                return jsonify(failed(f"元数据已存在(ID={exact_id}),请勿重复创建"))
-
-            # 存在疑似重复的元数据,已创建审核记录
-            if redundancy_result["review_created"]:
-                candidates = redundancy_result["candidates"]
-                candidate_names = [c.get("name_zh", "") for c in candidates[:3]]
-                logger.info(
-                    f"发现疑似重复元数据: name_zh={node_name_zh}, "
-                    f"candidates={candidate_names}"
-                )
                 return jsonify(
                     failed(
-                        f"发现疑似重复元数据,已创建审核记录。"
-                        f"疑似重复: {', '.join(candidate_names)}。"
-                        f"请前往元数据审核页面处理,或使用 force_create=true 强制创建。"
+                        f"元数据已存在(完全匹配),无需重复创建。"
+                        f"已存在的元数据ID: {exact_id}"
                     )
                 )
 
+            # 存在疑似重复的元数据,标记状态,稍后创建节点后再写入审核记录
+            if redundancy_result["has_candidates"]:
+                has_suspicious_duplicates = True
+                suspicious_candidates = redundancy_result["candidates"]
+                logger.info(
+                    f"发现疑似重复元数据: name_zh={node_name_zh}, "
+                    f"候选数量={len(suspicious_candidates)}"
+                )
+
         # ========== 创建节点 ==========
         with neo4j_driver.get_session() as session:
             cypher = """
@@ -478,6 +467,45 @@ def meta_node_add():
                     f"成功创建或更新元数据节点: "
                     f"ID={node_data['id']}, name={node_name_zh}"
                 )
+
+                # ========== 处理疑似重复情况 ==========
+                # 如果存在疑似重复,创建审核记录
+                if has_suspicious_duplicates and suspicious_candidates:
+                    from app.core.meta_data.redundancy_check import (
+                        write_redundancy_review_record_with_new_id,
+                    )
+
+                    # 构建新元数据快照(包含新创建的节点ID)
+                    new_meta_snapshot = {
+                        "id": node_data["id"],
+                        "name_zh": node_name_zh,
+                        "name_en": node_name_en or "",
+                        "data_type": node_type,
+                        "tag_ids": tag_ids,
+                    }
+
+                    # 写入审核记录
+                    write_redundancy_review_record_with_new_id(
+                        new_meta=new_meta_snapshot,
+                        candidates=suspicious_candidates,
+                        source="api",
+                    )
+
+                    # 返回成功创建,但提示疑似重复
+                    candidate_names = [
+                        c.get("name_zh", "") for c in suspicious_candidates[:3]
+                    ]
+                    return jsonify(
+                        success(
+                            node_data,
+                            message=(
+                                f"元数据创建成功,但发现疑似重复元数据。"
+                                f"疑似重复: {', '.join(candidate_names)}。"
+                                f"已创建审核记录,请前往元数据审核页面进行处理。"
+                            ),
+                        )
+                    )
+
                 return jsonify(success(node_data))
             else:
                 logger.error(f"创建元数据节点失败: {node_name_zh}")
@@ -1282,8 +1310,12 @@ def metadata_review_resolve():
       - notes: 备注(可选)
 
     action=alias:
-      payload: { candidate_meta_id: int }
-      行为:为业务领域建立 INCLUDES 到 candidate_meta_id,关系上写入 alias_name_zh/alias_name_en
+      payload: { primary_meta_id: int, alias_meta_id: int }
+      行为:在 DataMeta 节点之间重建 ALIAS 关系
+        - 创建 (alias_meta)-[:ALIAS]->(primary_meta) 关系
+        - 将所有指向 alias_meta 的 ALIAS 关系转移到 primary_meta
+        - primary_meta 已有的 ALIAS 关系保持不变
+        - BusinessDomain 的 INCLUDES 关系不受影响
 
     action=create_new:
       payload: { new_name_zh: str }
@@ -1324,38 +1356,57 @@ def metadata_review_resolve():
         new_meta = record.new_meta or {}
 
         if action == "alias":
-            candidate_meta_id = action_payload.get("candidate_meta_id")
-            if not bd_id:
-                return jsonify(failed("记录缺少 business_domain_id,无法执行 alias"))
-            if not candidate_meta_id:
-                return jsonify(failed("payload.candidate_meta_id 不能为空"))
-
-            # 写入 Neo4j:建立 INCLUDES,并记录别名
+            primary_meta_id = action_payload.get("primary_meta_id")
+            alias_meta_id = action_payload.get("alias_meta_id")
+            if not primary_meta_id:
+                return jsonify(failed("payload.primary_meta_id 不能为空"))
+            if not alias_meta_id:
+                return jsonify(failed("payload.alias_meta_id 不能为空"))
+            if int(primary_meta_id) == int(alias_meta_id):
+                return jsonify(failed("primary_meta_id 和 alias_meta_id 不能相同"))
+
+            # 写入 Neo4j:重建 DataMeta 节点间的 ALIAS 关系
             from app.services.neo4j_driver import neo4j_driver
 
-            alias_name_zh = (new_meta.get("name_zh") or "").strip()
-            alias_name_en = (new_meta.get("name_en") or "").strip()
             with neo4j_driver.get_session() as session:
+                # Step 1: 将所有指向 alias_meta 的 ALIAS 关系转移到 primary_meta
+                # 查找所有以 alias_meta 为目标的 ALIAS 关系,创建新关系指向 primary_meta,然后删除旧关系
                 session.run(
                     """
-                    MATCH (n:BusinessDomain), (m:DataMeta)
-                    WHERE id(n) = $domain_id AND id(m) = $meta_id
-                    MERGE (n)-[r:INCLUDES]->(m)
-                    SET r.alias_name_zh = $alias_name_zh,
-                        r.alias_name_en = $alias_name_en
+                    MATCH (other:DataMeta)-[old_rel:ALIAS]->(alias_meta:DataMeta)
+                    WHERE id(alias_meta) = $alias_meta_id
+                    WITH other, old_rel
+                    MATCH (primary_meta:DataMeta)
+                    WHERE id(primary_meta) = $primary_meta_id
+                    MERGE (other)-[:ALIAS]->(primary_meta)
+                    DELETE old_rel
+                    """,
+                    {
+                        "alias_meta_id": int(alias_meta_id),
+                        "primary_meta_id": int(primary_meta_id),
+                    },
+                )
+
+                # Step 2: 创建 alias_meta 指向 primary_meta 的 ALIAS 关系
+                session.run(
+                    """
+                    MATCH (alias_meta:DataMeta), (primary_meta:DataMeta)
+                    WHERE id(alias_meta) = $alias_meta_id AND id(primary_meta) = $primary_meta_id
+                    MERGE (alias_meta)-[:ALIAS]->(primary_meta)
                     """,
                     {
-                        "domain_id": int(bd_id),
-                        "meta_id": int(candidate_meta_id),
-                        "alias_name_zh": alias_name_zh,
-                        "alias_name_en": alias_name_en,
+                        "alias_meta_id": int(alias_meta_id),
+                        "primary_meta_id": int(primary_meta_id),
                     },
                 )
 
             update_review_record_resolution(
                 record,
                 action="alias",
-                payload={"candidate_meta_id": int(candidate_meta_id)},
+                payload={
+                    "primary_meta_id": int(primary_meta_id),
+                    "alias_meta_id": int(alias_meta_id),
+                },
                 resolved_by=resolved_by,
                 notes=notes,
             )

+ 315 - 66
app/core/business_domain/business_domain.py

@@ -4,10 +4,10 @@ Business Domain 核心业务逻辑模块
 """
 
 import logging
-from datetime import datetime
 from typing import Any, Dict, List, Optional, Tuple
 
 from app import db
+from app.core.common.timezone_utils import now_china_naive
 from app.models.metadata_review import MetadataReviewRecord
 from app.services.neo4j_driver import neo4j_driver
 
@@ -62,6 +62,7 @@ def _get_meta_snapshot(session, meta_id: int) -> Dict[str, Any]:
         "name_zh": props.get("name_zh", ""),
         "name_en": props.get("name_en", ""),
         "data_type": props.get("data_type", ""),
+        "status": props.get("status", True),
         "tag_ids": _get_meta_tag_ids(session, int(meta_id)),
     }
 
@@ -71,7 +72,7 @@ def _build_new_meta_snapshot(item: Dict[str, Any]) -> Dict[str, Any]:
     name_en = _norm_str(item.get("name_en"))
     data_type = _norm_data_type(item.get("data_type", "varchar(255)"))
     tag_ids = _extract_tag_ids_from_item(item)
-    tag_ids_sorted = sorted(set(int(t) for t in tag_ids if t is not None))
+    tag_ids_sorted = sorted({int(t) for t in tag_ids if t is not None})
     return {
         "name_zh": name_zh,
         "name_en": name_en,
@@ -90,6 +91,41 @@ def _is_exact_match(new_meta: Dict[str, Any], cand: Dict[str, Any]) -> bool:
     )
 
 
+def _match_name_and_type(new_meta: Dict[str, Any], cand: Dict[str, Any]) -> bool:
+    """
+    检查 name_zh、name_en 和 data_type 是否都匹配
+    """
+    return (
+        _norm_str(new_meta.get("name_zh")) == _norm_str(cand.get("name_zh"))
+        and _norm_str(new_meta.get("name_en")) == _norm_str(cand.get("name_en"))
+        and _norm_data_type(new_meta.get("data_type"))
+        == _norm_data_type(cand.get("data_type"))
+    )
+
+
+def _match_names_only(new_meta: Dict[str, Any], cand: Dict[str, Any]) -> bool:
+    """
+    检查 name_zh 和 name_en 是否都匹配(data_type 不匹配)
+    """
+    return (
+        _norm_str(new_meta.get("name_zh")) == _norm_str(cand.get("name_zh"))
+        and _norm_str(new_meta.get("name_en")) == _norm_str(cand.get("name_en"))
+        and _norm_data_type(new_meta.get("data_type"))
+        != _norm_data_type(cand.get("data_type"))
+    )
+
+
+def _match_partial_name(new_meta: Dict[str, Any], cand: Dict[str, Any]) -> bool:
+    """
+    检查是否只有 name_zh 或 name_en 其中一个匹配
+    """
+    name_zh_match = _norm_str(new_meta.get("name_zh")) == _norm_str(cand.get("name_zh"))
+    name_en_match = _norm_str(new_meta.get("name_en")) == _norm_str(cand.get("name_en"))
+
+    # 只有一个匹配(异或)
+    return (name_zh_match or name_en_match) and not (name_zh_match and name_en_match)
+
+
 def _diff_fields(new_meta: Dict[str, Any], cand: Dict[str, Any]) -> List[str]:
     diffs: List[str] = []
     if _norm_str(new_meta.get("name_zh")) != _norm_str(cand.get("name_zh")):
@@ -138,6 +174,7 @@ def _find_candidate_metas(
                 "name_zh": props.get("name_zh", ""),
                 "name_en": props.get("name_en", ""),
                 "data_type": props.get("data_type", ""),
+                "status": props.get("status", True),
                 "tag_ids": _get_meta_tag_ids(session, meta_id),
             }
         )
@@ -160,11 +197,128 @@ def _write_review_record(
     review.candidates = candidates or []
     review.old_meta = old_meta
     review.status = "pending"
-    review.created_at = datetime.utcnow()
-    review.updated_at = datetime.utcnow()
+    review.created_at = now_china_naive()
+    review.updated_at = now_china_naive()
     db.session.add(review)
 
 
+def _create_new_meta_and_link(
+    session,
+    domain_id: int,
+    new_meta: Dict[str, Any],
+    alias_name_zh: Optional[str] = None,
+    alias_name_en: Optional[str] = None,
+) -> int:
+    """
+    创建新的 DataMeta 节点并建立 BusinessDomain-[:INCLUDES]->DataMeta 关系。
+    总是创建新节点,不检查是否已存在。
+    """
+    from app.core.meta_data import get_formatted_time
+
+    meta_create = """
+    CREATE (m:DataMeta {
+        name_zh: $name_zh,
+        name_en: $name_en,
+        create_time: $create_time,
+        data_type: $data_type,
+        status: true
+    })
+    RETURN m
+    """
+    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)"),
+        },
+    ).single()
+    if not meta_result or not meta_result.get("m"):
+        raise ValueError("创建 DataMeta 失败")
+    meta_id = int(meta_result["m"].id)
+
+    # 标签关系(若提供 tag_ids)
+    tag_ids = sorted({int(t) for t in (new_meta.get("tag_ids") or []) if t is not None})
+    if tag_ids:
+        tag_rel = """
+        MATCH (m:DataMeta)
+        WHERE id(m) = $meta_id
+        WITH m
+        UNWIND $tag_ids AS tid
+        MATCH (t:DataLabel) WHERE id(t) = tid
+        MERGE (m)-[:LABEL]->(t)
+        """
+        session.run(tag_rel, {"meta_id": meta_id, "tag_ids": tag_ids})
+
+    # 建立 INCLUDES 关系(可选写入别名信息)
+    if alias_name_zh or alias_name_en:
+        rel_cypher = """
+        MATCH (n:BusinessDomain), (m:DataMeta)
+        WHERE id(n) = $domain_id AND id(m) = $meta_id
+        MERGE (n)-[r:INCLUDES]->(m)
+        SET r.alias_name_zh = $alias_name_zh,
+            r.alias_name_en = $alias_name_en
+        RETURN r
+        """
+        session.run(
+            rel_cypher,
+            {
+                "domain_id": int(domain_id),
+                "meta_id": meta_id,
+                "alias_name_zh": _norm_str(alias_name_zh),
+                "alias_name_en": _norm_str(alias_name_en),
+            },
+        )
+    else:
+        rel_cypher = """
+        MATCH (n:BusinessDomain), (m:DataMeta)
+        WHERE id(n) = $domain_id AND id(m) = $meta_id
+        MERGE (n)-[:INCLUDES]->(m)
+        """
+        session.run(rel_cypher, {"domain_id": int(domain_id), "meta_id": meta_id})
+
+    return meta_id
+
+
+def _link_existing_meta(
+    session,
+    domain_id: int,
+    meta_id: int,
+    alias_name_zh: Optional[str] = None,
+    alias_name_en: Optional[str] = None,
+) -> None:
+    """
+    将已存在的 DataMeta 节点关联到 BusinessDomain。
+    """
+    # 建立 INCLUDES 关系(可选写入别名信息)
+    if alias_name_zh or alias_name_en:
+        rel_cypher = """
+        MATCH (n:BusinessDomain), (m:DataMeta)
+        WHERE id(n) = $domain_id AND id(m) = $meta_id
+        MERGE (n)-[r:INCLUDES]->(m)
+        SET r.alias_name_zh = $alias_name_zh,
+            r.alias_name_en = $alias_name_en
+        RETURN r
+        """
+        session.run(
+            rel_cypher,
+            {
+                "domain_id": int(domain_id),
+                "meta_id": int(meta_id),
+                "alias_name_zh": _norm_str(alias_name_zh),
+                "alias_name_en": _norm_str(alias_name_en),
+            },
+        )
+    else:
+        rel_cypher = """
+        MATCH (n:BusinessDomain), (m:DataMeta)
+        WHERE id(n) = $domain_id AND id(m) = $meta_id
+        MERGE (n)-[:INCLUDES]->(m)
+        """
+        session.run(rel_cypher, {"domain_id": int(domain_id), "meta_id": int(meta_id)})
+
+
 def _create_meta_if_absent_and_link(
     session,
     domain_id: int,
@@ -200,9 +354,7 @@ def _create_meta_if_absent_and_link(
     meta_id = int(meta_result["m"].id)
 
     # 标签关系(若提供 tag_ids)
-    tag_ids = sorted(
-        set(int(t) for t in (new_meta.get("tag_ids") or []) if t is not None)
-    )
+    tag_ids = sorted({int(t) for t in (new_meta.get("tag_ids") or []) if t is not None})
     if tag_ids:
         tag_rel = """
         MATCH (m:DataMeta)
@@ -297,11 +449,7 @@ def normalize_tag_inputs(tag_data):
     items = tag_data if isinstance(tag_data, list) else [tag_data]
 
     for item in items:
-        candidate = None
-        if isinstance(item, dict):
-            candidate = item.get("id")
-        else:
-            candidate = item
+        candidate = item.get("id") if isinstance(item, dict) else item
 
         if candidate is None:
             continue
@@ -713,7 +861,7 @@ def save_business_domain(data):
                     node_props[field] = data[field]
 
             # 构建CREATE语句
-            props_str = ", ".join([f"{k}: ${k}" for k in node_props.keys()])
+            props_str = ", ".join([f"{k}: ${k}" for k in node_props])
             cypher = f"""
             CREATE (n:BusinessDomain {{{props_str}}})
             RETURN n
@@ -743,58 +891,171 @@ def save_business_domain(data):
                     if not new_meta.get("name_zh"):
                         continue
 
+                    # 查找候选 metadata
                     candidates = _find_candidate_metas(
                         session,
                         new_meta.get("name_zh", ""),
                         new_meta.get("name_en", ""),
                     )
 
-                    exact = None
+                    # 场景1: name_zh、name_en、data_type 都匹配
+                    name_type_match = None
                     for cand in candidates:
-                        if _is_exact_match(new_meta, cand):
-                            exact = cand
+                        if _match_name_and_type(new_meta, cand):
+                            name_type_match = cand
                             break
 
-                    if exact:
-                        _create_meta_if_absent_and_link(
+                    if name_type_match:
+                        # 检查 status 字段
+                        matched_id = name_type_match.get("id")
+                        if matched_id is None:
+                            logger.warning(
+                                f"匹配到的metadata缺少id字段: {name_type_match}"
+                            )
+                            continue
+
+                        if name_type_match.get("status", True):
+                            # status=true: 直接关联已存在的 metadata
+                            _link_existing_meta(
+                                session=session,
+                                domain_id=domain_id,
+                                meta_id=int(matched_id),
+                            )
+                            created_or_linked_count += 1
+                            logger.info(
+                                f"关联已存在的metadata (status=true): "
+                                f"id={matched_id}, "
+                                f"name_zh={name_type_match.get('name_zh')}"
+                            )
+                        else:
+                            # status=false: 创建新的 metadata,并写入审核表
+                            new_meta_id = _create_new_meta_and_link(
+                                session=session,
+                                domain_id=domain_id,
+                                new_meta=new_meta,
+                            )
+                            created_or_linked_count += 1
+
+                            # 写入审核表
+                            _write_review_record(
+                                record_type="redundancy",
+                                business_domain_id=domain_id,
+                                new_meta={
+                                    **new_meta,
+                                    "id": new_meta_id,
+                                },
+                                candidates=[
+                                    {
+                                        "candidate_meta_id": name_type_match.get("id"),
+                                        "snapshot": name_type_match,
+                                        "diff_fields": ["status"],
+                                    }
+                                ],
+                                old_meta=None,
+                                source="ddl",
+                            )
+                            review_count += 1
+                            logger.info(
+                                f"创建新metadata (旧status=false): "
+                                f"new_id={new_meta_id}, "
+                                f"old_id={name_type_match.get('id')}"
+                            )
+                        continue
+
+                    # 场景2: name_zh、name_en 都匹配,但 data_type 不匹配
+                    names_match = None
+                    for cand in candidates:
+                        if _match_names_only(new_meta, cand):
+                            names_match = cand
+                            break
+
+                    if names_match:
+                        # 创建新的 metadata,并写入审核表
+                        new_meta_id = _create_new_meta_and_link(
                             session=session,
                             domain_id=domain_id,
-                            new_meta=exact,
+                            new_meta=new_meta,
                         )
                         created_or_linked_count += 1
-                        continue
 
-                    if candidates:
-                        candidates_payload = []
-                        for cand in candidates:
-                            candidates_payload.append(
+                        # 写入审核表
+                        _write_review_record(
+                            record_type="redundancy",
+                            business_domain_id=domain_id,
+                            new_meta={
+                                **new_meta,
+                                "id": new_meta_id,
+                            },
+                            candidates=[
                                 {
-                                    "candidate_meta_id": cand.get("id"),
-                                    "snapshot": cand,
-                                    "diff_fields": _diff_fields(new_meta, cand),
+                                    "candidate_meta_id": names_match.get("id"),
+                                    "snapshot": names_match,
+                                    "diff_fields": _diff_fields(new_meta, names_match),
                                 }
-                            )
+                            ],
+                            old_meta=None,
+                            source="ddl",
+                        )
+                        review_count += 1
+                        logger.info(
+                            f"创建新metadata (data_type不匹配): "
+                            f"new_id={new_meta_id}, "
+                            f"old_id={names_match.get('id')}"
+                        )
+                        continue
+
+                    # 场景3: 只有 name_zh 或 name_en 其中一个匹配
+                    partial_match = None
+                    for cand in candidates:
+                        if _match_partial_name(new_meta, cand):
+                            partial_match = cand
+                            break
+
+                    if partial_match:
+                        # 创建新的 metadata,并写入审核表
+                        new_meta_id = _create_new_meta_and_link(
+                            session=session,
+                            domain_id=domain_id,
+                            new_meta=new_meta,
+                        )
+                        created_or_linked_count += 1
+
+                        # 写入审核表
                         _write_review_record(
                             record_type="redundancy",
                             business_domain_id=domain_id,
-                            new_meta=new_meta,
-                            candidates=candidates_payload,
+                            new_meta={
+                                **new_meta,
+                                "id": new_meta_id,
+                            },
+                            candidates=[
+                                {
+                                    "candidate_meta_id": partial_match.get("id"),
+                                    "snapshot": partial_match,
+                                    "diff_fields": _diff_fields(
+                                        new_meta, partial_match
+                                    ),
+                                }
+                            ],
                             old_meta=None,
                             source="ddl",
                         )
                         review_count += 1
+                        logger.info(
+                            f"创建新metadata (部分名称匹配): "
+                            f"new_id={new_meta_id}, "
+                            f"old_id={partial_match.get('id')}"
+                        )
                         continue
 
-                    # 无候选:创建新 DataMeta 并关联
-                    _create_meta_if_absent_and_link(
+                    # 场景4: 没有任何候选 metadata,直接创建新的
+                    new_meta_id = _create_new_meta_and_link(
                         session=session,
                         domain_id=domain_id,
-                        new_meta={
-                            **new_meta,
-                            "create_time": get_formatted_time(),
-                        },
+                        new_meta=new_meta,
                     )
                     created_or_linked_count += 1
+                    logger.info(f"创建新metadata (无候选): new_id={new_meta_id}")
 
                 # 提交PG审核记录
                 if review_count > 0:
@@ -884,8 +1145,8 @@ def update_business_domain(data):
         # 确保domain_id为整数
         try:
             domain_id_int = int(domain_id)
-        except (ValueError, TypeError):
-            raise ValueError(f"业务领域ID不是有效的整数: {domain_id}")
+        except (ValueError, TypeError) as err:
+            raise ValueError(f"业务领域ID不是有效的整数: {domain_id}") from err
 
         with neo4j_driver.get_session() as session:
             # 构建更新字段(过滤掉特殊字段和 None 值)
@@ -901,7 +1162,7 @@ def update_business_domain(data):
 
             # 构建更新语句
             if update_fields:
-                set_clause = ", ".join([f"n.{k} = ${k}" for k in update_fields.keys()])
+                set_clause = ", ".join([f"n.{k} = ${k}" for k in update_fields])
                 cypher = f"""
                 MATCH (n:BusinessDomain)
                 WHERE id(n) = $domain_id
@@ -1384,8 +1645,9 @@ def business_domain_compose(data):
         data: 包含业务领域信息的字典
             - name_zh: 中文名称(必填)
             - name_en: 英文名称(可选,不提供则自动翻译)
-            - id_list: 关联的业务领域和元数据列表(必填)
-                格式: [{"domain_id": 123, "metaData": [{"id": 456}, ...]}]
+            - id_list: 选中的元数据ID列表(必填)
+                格式: [id1, id2, ...] 或 [{"id": id1}, {"id": id2}, ...]
+            - meta_ids: 由路由层预处理后的元数据ID列表(内部使用)
             - describe: 描述(可选)
             - type: 类型(可选)
             - category: 分类(可选)
@@ -1442,7 +1704,7 @@ def business_domain_compose(data):
             # 注意: parsed_data 通过 INCLUDES 关系处理,不存储为节点属性
 
             # 构建CREATE语句
-            props_str = ", ".join([f"{k}: ${k}" for k in node_props.keys()])
+            props_str = ", ".join([f"{k}: ${k}" for k in node_props])
             cypher = f"""
             CREATE (n:BusinessDomain {{{props_str}}})
             RETURN n
@@ -1456,18 +1718,18 @@ def business_domain_compose(data):
             domain_id = created_node["n"].id
             logger.info(f"成功创建业务领域节点,ID: {domain_id}")
 
-            # 处理与已有业务领域的关系
-            domain_ids = [
-                record["domain_id"] for record in id_list if "domain_id" in record
-            ]
-            meta_ids = [
-                record["id"]
-                for id_item in id_list
-                for record in id_item.get("metaData", [])
-                if "id" in record
-            ]
+            # 获取元数据ID列表(优先使用路由层预处理的meta_ids)
+            meta_ids = data.get("meta_ids", [])
 
-            # 创建与 DataMeta 的关系(component)
+            # 如果没有预处理的meta_ids,兼容旧格式进行提取
+            if not meta_ids and id_list:
+                for item in id_list:
+                    if isinstance(item, int):
+                        meta_ids.append(item)
+                    elif isinstance(item, dict) and "id" in item:
+                        meta_ids.append(item["id"])
+
+            # 创建与 DataMeta 的关系
             if meta_ids:
                 meta_cypher = """
                 MATCH (source:BusinessDomain), (target:DataMeta)
@@ -1480,19 +1742,6 @@ def business_domain_compose(data):
                     f"domain_id={domain_id}, meta_ids={meta_ids}"
                 )
 
-            # 创建与已有 BusinessDomain 的关系(use)
-            if domain_ids:
-                domain_cypher = """
-                MATCH (source:BusinessDomain), (target:BusinessDomain)
-                WHERE id(source) = $source_id AND id(target) IN $target_ids
-                MERGE (source)-[:USE]->(target)
-                """
-                session.run(domain_cypher, source_id=domain_id, target_ids=domain_ids)
-                logger.info(
-                    f"创建 BusinessDomain -> BusinessDomain 关系: "
-                    f"source={domain_id}, targets={domain_ids}"
-                )
-
             # 处理标签关系(支持多个标签)
             tag_inputs = data.get("tag")
             tag_ids = normalize_tag_inputs(tag_inputs)

+ 67 - 0
app/core/common/timezone_utils.py

@@ -0,0 +1,67 @@
+"""
+时区工具模块
+提供东八区(Asia/Shanghai)时间处理功能
+"""
+
+from datetime import datetime
+
+try:
+    # Python 3.9+
+    from zoneinfo import ZoneInfo
+except ImportError:
+    # Python 3.8 使用 backports
+    from backports.zoneinfo import ZoneInfo
+
+# 东八区时区
+CHINA_TZ = ZoneInfo("Asia/Shanghai")
+
+
+def now_china() -> datetime:
+    """
+    获取当前东八区时间(带时区信息)
+
+    Returns:
+        datetime: 当前东八区时间
+    """
+    return datetime.now(CHINA_TZ)
+
+
+def now_china_naive() -> datetime:
+    """
+    获取当前东八区时间(不带时区信息,用于数据库存储)
+
+    Returns:
+        datetime: 当前东八区时间(naive datetime)
+    """
+    return datetime.now(CHINA_TZ).replace(tzinfo=None)
+
+
+def to_china_time(dt: datetime) -> datetime:
+    """
+    将任意时区的时间转换为东八区时间
+
+    Args:
+        dt: 输入的datetime对象
+
+    Returns:
+        datetime: 转换后的东八区时间
+    """
+    if dt.tzinfo is None:
+        # 如果是naive datetime,假设它是UTC时间
+        dt = dt.replace(tzinfo=ZoneInfo("UTC"))
+    return dt.astimezone(CHINA_TZ)
+
+
+def utc_to_china_naive(dt: datetime) -> datetime:
+    """
+    将UTC时间转换为东八区时间(不带时区信息)
+
+    Args:
+        dt: UTC时间
+
+    Returns:
+        datetime: 东八区时间(naive datetime)
+    """
+    if dt.tzinfo is None:
+        dt = dt.replace(tzinfo=ZoneInfo("UTC"))
+    return dt.astimezone(CHINA_TZ).replace(tzinfo=None)

+ 589 - 27
app/core/data_service/data_product_service.py

@@ -17,6 +17,7 @@ from openai import OpenAI
 from sqlalchemy import text
 
 from app import db
+from app.core.common.timezone_utils import now_china_naive
 from app.models.data_product import DataOrder, DataProduct
 from app.services.neo4j_driver import neo4j_driver
 
@@ -547,8 +548,8 @@ class DataProductService:
                 existing.description = description
                 existing.source_dataflow_id = source_dataflow_id
                 existing.source_dataflow_name = source_dataflow_name
-                existing.updated_at = datetime.utcnow()
-                existing.last_updated_at = datetime.utcnow()
+                existing.updated_at = now_china_naive()
+                existing.last_updated_at = now_china_naive()
                 db.session.commit()
 
                 logger.info(
@@ -568,7 +569,7 @@ class DataProductService:
                 source_dataflow_id=source_dataflow_id,  # type: ignore[arg-type]
                 source_dataflow_name=source_dataflow_name,  # type: ignore[arg-type]
                 created_by=created_by,  # type: ignore[arg-type]
-                last_updated_at=datetime.utcnow(),  # type: ignore[arg-type]
+                last_updated_at=now_china_naive(),  # type: ignore[arg-type]
             )
 
             db.session.add(product)
@@ -611,8 +612,8 @@ class DataProductService:
             if column_count is not None:
                 product.column_count = column_count
 
-            product.last_updated_at = datetime.utcnow()
-            product.updated_at = datetime.utcnow()
+            product.last_updated_at = now_china_naive()
+            product.updated_at = now_china_naive()
 
             db.session.commit()
 
@@ -662,7 +663,7 @@ class DataProductService:
 
             if not exists:
                 product.status = "error"
-                product.updated_at = datetime.utcnow()
+                product.updated_at = now_china_naive()
                 db.session.commit()
                 return product
 
@@ -688,8 +689,8 @@ class DataProductService:
             # 更新统计信息
             product.record_count = record_count
             product.column_count = column_count
-            product.last_updated_at = datetime.utcnow()
-            product.updated_at = datetime.utcnow()
+            product.last_updated_at = now_china_naive()
+            product.updated_at = now_china_naive()
             product.status = "active"
 
             db.session.commit()
@@ -1251,6 +1252,131 @@ class DataOrderService:
                 "error": str(e),
             }
 
+    @staticmethod
+    def extract_output_domain_and_logic(
+        description: str,
+        input_domains: list[dict[str, Any]] | None = None,
+    ) -> dict[str, Any]:
+        """
+        使用 LLM 从描述中提取输出 BusinessDomain 信息和数据加工处理逻辑
+
+        Args:
+            description: 需求描述
+            input_domains: 已匹配的输入 BusinessDomain 列表(用于提供上下文)
+
+        Returns:
+            提取结果,包含:
+            - output_domain: 输出 BusinessDomain 的信息
+                - name_zh: 中文名称
+                - name_en: 英文名称
+                - describe: 描述
+            - processing_logic: 数据加工处理逻辑描述
+        """
+        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")
+
+            # 构建输入域上下文信息
+            input_context = ""
+            if input_domains:
+                domain_names = [
+                    d.get("name_zh", d.get("name_en", "未知")) for d in input_domains
+                ]
+                input_context = f"\n已确定的输入数据源:{', '.join(domain_names)}"
+
+            prompt = f"""分析以下数据需求描述,提取输出数据产品信息和数据加工处理逻辑。
+{input_context}
+
+需求描述:{description}
+
+请严格按照以下JSON格式返回,不要添加任何解释或其他内容:
+{{
+    "output_domain": {{
+        "name_zh": "输出数据产品的中文名称",
+        "name_en": "output_product_english_name",
+        "describe": "输出数据产品的描述,说明这个数据产品包含什么内容"
+    }},
+    "processing_logic": "详细的数据加工处理逻辑,包括:1.需要从哪些源数据中提取什么字段;2.需要进行什么样的数据转换或计算;3.数据的过滤条件或筛选规则;4.最终输出数据的格式和字段"
+}}
+
+注意:
+1. output_domain.name_zh 应该是一个简洁明了的数据产品名称,如"会员消费分析报表"、"销售业绩汇总表"等
+2. output_domain.name_en 应该是英文名称,使用下划线连接,如"member_consumption_analysis"
+3. processing_logic 应该详细描述数据加工的完整流程,便于后续生成数据处理脚本
+"""
+
+            completion = client.chat.completions.create(
+                model=model,  # type: ignore[arg-type]
+                messages=[
+                    {
+                        "role": "system",
+                        "content": "你是一个专业的数据架构师,擅长从自然语言描述中提取数据产品定义和数据加工逻辑。"
+                        "请严格按照要求的JSON格式返回结果。",
+                    },
+                    {"role": "user", "content": prompt},
+                ],
+                temperature=0.1,
+                max_tokens=2048,
+            )
+
+            response_text = (
+                completion.choices[0].message.content.strip()  # type: ignore[union-attr]
+            )
+
+            # 尝试解析 JSON
+            # 清理可能的 markdown 代码块标记
+            if response_text.startswith("```"):
+                lines = response_text.split("\n")
+                # 移除首尾的代码块标记
+                if lines[0].startswith("```"):
+                    lines = lines[1:]
+                if lines and lines[-1].strip() == "```":
+                    lines = lines[:-1]
+                response_text = "\n".join(lines)
+
+            result = json.loads(response_text)
+
+            # 验证必要字段
+            if "output_domain" not in result:
+                result["output_domain"] = {
+                    "name_zh": "数据产品",
+                    "name_en": "data_product",
+                    "describe": description[:200] if description else "",
+                }
+            if "processing_logic" not in result:
+                result["processing_logic"] = description
+
+            logger.info(f"LLM 输出域和处理逻辑提取成功: {result}")
+            return result
+
+        except json.JSONDecodeError as e:
+            logger.error(f"LLM 返回结果解析失败: {str(e)}, response: {response_text}")
+            # 返回默认值
+            return {
+                "output_domain": {
+                    "name_zh": "数据产品",
+                    "name_en": "data_product",
+                    "describe": description[:200] if description else "",
+                },
+                "processing_logic": description,
+                "error": "解析失败",
+            }
+        except Exception as e:
+            logger.error(f"LLM 输出域和处理逻辑提取失败: {str(e)}")
+            return {
+                "output_domain": {
+                    "name_zh": "数据产品",
+                    "name_en": "data_product",
+                    "describe": description[:200] if description else "",
+                },
+                "processing_logic": description,
+                "error": str(e),
+            }
+
     @staticmethod
     def find_matching_domains(domain_names: list[str]) -> list[dict[str, Any]]:
         """
@@ -1528,8 +1654,8 @@ class DataOrderService:
 
             # 根据连通性结果更新状态
             if can_connect:
-                # 可连通,自动进入加工状态
-                order.update_status(DataOrder.STATUS_PROCESSING)
+                # 可连通,进入待审批状态
+                order.update_status(DataOrder.STATUS_PENDING_APPROVAL)
             else:
                 # 不可连通,需要人工处理
                 order.update_status(DataOrder.STATUS_MANUAL_REVIEW)
@@ -1547,32 +1673,55 @@ class DataOrderService:
     def approve_order(
         order_id: int,
         processed_by: str = "admin",
-    ) -> DataOrder | None:
+    ) -> dict[str, Any]:
         """
-        审批通过订单
+        审批通过订单,并自动生成 BusinessDomain 和 DataFlow 资源
 
         Args:
             order_id: 订单ID
             processed_by: 处理人
 
         Returns:
-            更新后的订单对象
+            包含订单信息和生成资源的字典:
+            - order: 更新后的订单对象字典
+            - generated_resources: 生成的资源信息
         """
         try:
             order = DataOrder.query.get(order_id)
             if not order:
-                return None
+                raise ValueError(f"订单不存在: order_id={order_id}")
 
-            if order.status != DataOrder.STATUS_MANUAL_REVIEW:
-                raise ValueError(f"订单状态不允许审批: {order.status}")
+            # 允许从 pending_approval 或 manual_review 状态审批
+            allowed_statuses = [
+                DataOrder.STATUS_PENDING_APPROVAL,
+                DataOrder.STATUS_MANUAL_REVIEW,
+            ]
+            if order.status not in allowed_statuses:
+                raise ValueError(
+                    f"订单状态 {order.status} 不允许审批,"
+                    f"只有 {allowed_statuses} 状态可以审批"
+                )
+
+            # 自动生成资源
+            generated_resources = DataOrderService.generate_order_resources(order)
+
+            # 更新订单关联的 dataflow_id
+            order.result_dataflow_id = generated_resources["dataflow_id"]
 
+            # 更新状态为 processing
             order.update_status(DataOrder.STATUS_PROCESSING, processed_by)
             db.session.commit()
 
             logger.info(
-                f"订单审批通过: order_id={order_id}, processed_by={processed_by}"
+                f"订单审批通过并生成资源: order_id={order_id}, "
+                f"dataflow_id={generated_resources['dataflow_id']}, "
+                f"processed_by={processed_by}"
             )
-            return order
+
+            return {
+                "order": order.to_dict(),
+                "generated_resources": generated_resources,
+            }
 
         except Exception as e:
             db.session.rollback()
@@ -1617,18 +1766,419 @@ class DataOrderService:
 
     @staticmethod
     def complete_order(
+        order_id: int,
+        processed_by: str = "user",
+    ) -> DataOrder | None:
+        """
+        标记订单为最终完成状态
+
+        只允许从 onboard(数据产品就绪)状态标记完成
+
+        Args:
+            order_id: 订单ID
+            processed_by: 处理人
+
+        Returns:
+            更新后的订单对象
+        """
+        try:
+            order = DataOrder.query.get(order_id)
+            if not order:
+                return None
+
+            # 只允许从 onboard 状态标记完成
+            if order.status != DataOrder.STATUS_ONBOARD:
+                raise ValueError(
+                    f"订单状态 {order.status} 不允许标记完成,"
+                    f"只有 onboard 状态可以标记完成"
+                )
+
+            order.update_status(DataOrder.STATUS_COMPLETED, processed_by)
+            db.session.commit()
+
+            logger.info(f"订单已完成: order_id={order_id}, processed_by={processed_by}")
+            return order
+
+        except Exception as e:
+            db.session.rollback()
+            logger.error(f"完成订单失败: {str(e)}")
+            raise
+
+    @staticmethod
+    def update_order(
+        order_id: int,
+        title: str | None = None,
+        description: str | None = None,
+        extracted_domains: list[str] | None = None,
+        extracted_fields: list[str] | None = None,
+        extraction_purpose: str | None = None,
+    ) -> DataOrder | None:
+        """
+        更新数据订单(支持修改描述和提取结果)
+
+        Args:
+            order_id: 订单ID
+            title: 订单标题(可选)
+            description: 需求描述(可选)
+            extracted_domains: 提取的业务领域列表(可选)
+            extracted_fields: 提取的数据字段列表(可选)
+            extraction_purpose: 数据用途(可选)
+
+        Returns:
+            更新后的订单对象
+        """
+        try:
+            order = DataOrder.query.get(order_id)
+            if not order:
+                return None
+
+            # 只允许在特定状态下修改订单
+            allowed_statuses = [
+                DataOrder.STATUS_PENDING,
+                DataOrder.STATUS_MANUAL_REVIEW,
+                DataOrder.STATUS_NEED_SUPPLEMENT,
+            ]
+            if order.status not in allowed_statuses:
+                raise ValueError(
+                    f"订单状态 {order.status} 不允许修改,"
+                    f"只有 {allowed_statuses} 状态可以修改"
+                )
+
+            # 更新基本信息
+            if title is not None:
+                order.title = title
+            if description is not None:
+                order.description = description
+
+            # 更新提取结果
+            if extracted_domains is not None:
+                order.extracted_domains = extracted_domains
+            if extracted_fields is not None:
+                order.extracted_fields = extracted_fields
+            if extraction_purpose is not None:
+                order.extraction_purpose = extraction_purpose
+
+            # 更新状态为待处理,重新进入处理流程
+            order.status = DataOrder.STATUS_PENDING
+            order.updated_at = now_china_naive()
+            db.session.commit()
+
+            logger.info(f"更新数据订单成功: order_id={order_id}")
+            return order
+
+        except Exception as e:
+            db.session.rollback()
+            logger.error(f"更新数据订单失败: {str(e)}")
+            raise
+
+    @staticmethod
+    def generate_order_resources(order: DataOrder) -> dict[str, Any]:
+        """
+        根据订单分析结果自动生成 BusinessDomain 和 DataFlow 资源
+
+        流程:
+        1. 使用 LLM 从 description 提取输出 BusinessDomain 信息和处理逻辑
+        2. 创建输出 BusinessDomain 节点
+        3. 创建 DataFlow 节点
+        4. 建立 INPUT/OUTPUT 关系
+        5. 在 task_list 表中创建任务记录
+
+        Args:
+            order: 数据订单对象
+
+        Returns:
+            包含生成的资源信息的字典:
+            - target_business_domain_id: 目标 BusinessDomain 节点 ID
+            - dataflow_id: DataFlow 节点 ID
+            - input_domain_ids: 输入 BusinessDomain 节点 ID 列表
+            - task_id: task_list 表中的任务 ID
+        """
+        try:
+            graph_analysis = order.graph_analysis or {}
+            matched_domains = graph_analysis.get("matched_domains", [])
+
+            if not matched_domains:
+                raise ValueError("订单没有匹配的业务领域,无法生成资源")
+
+            # 1. 使用 LLM 提取输出 BusinessDomain 信息和处理逻辑
+            extraction_result = DataOrderService.extract_output_domain_and_logic(
+                description=order.description,
+                input_domains=matched_domains,
+            )
+
+            output_domain_info = extraction_result.get("output_domain", {})
+            processing_logic = extraction_result.get("processing_logic", "")
+
+            # 获取输出域名称,使用 LLM 提取结果或回退到默认值
+            target_bd_name_zh = output_domain_info.get("name_zh") or order.title
+            target_bd_name_en = output_domain_info.get(
+                "name_en", f"DP_{order.order_no}"
+            )
+            target_bd_describe = output_domain_info.get(
+                "describe", order.extraction_purpose or order.description
+            )
+
+            with neo4j_driver.get_session() as session:
+                # 2. 创建目标 BusinessDomain 节点(数据产品承载)
+                create_target_bd_query = """
+                CREATE (bd:BusinessDomain {
+                    name_en: $name_en,
+                    name_zh: $name_zh,
+                    describe: $describe,
+                    type: 'data_product',
+                    created_at: datetime(),
+                    created_by: $created_by,
+                    source_order_id: $order_id
+                })
+                RETURN id(bd) as bd_id
+                """
+                result = session.run(
+                    create_target_bd_query,
+                    {
+                        "name_en": target_bd_name_en,
+                        "name_zh": target_bd_name_zh,
+                        "describe": target_bd_describe,
+                        "created_by": "system",
+                        "order_id": order.id,
+                    },
+                ).single()
+                if result is None:
+                    raise ValueError("创建目标 BusinessDomain 失败")
+                target_bd_id = result["bd_id"]
+
+                logger.info(
+                    f"创建目标 BusinessDomain: id={target_bd_id}, "
+                    f"name_zh={target_bd_name_zh}, name_en={target_bd_name_en}"
+                )
+
+                # 3. 创建 DataFlow 节点
+                dataflow_name_en = f"DF_{order.order_no}"
+                dataflow_name_zh = f"{target_bd_name_zh}_数据流程"
+
+                # 构建 script_requirement(包含完整的数据加工定义)
+                input_domain_names = [
+                    d.get("name_zh", d.get("name_en", "")) for d in matched_domains
+                ]
+                input_domain_ids = [d["id"] for d in matched_domains]
+
+                # 构建结构化的 script_requirement(JSON 格式)
+                script_requirement_dict = {
+                    "source_table": input_domain_ids,
+                    "target_table": [target_bd_id],
+                    "rule": processing_logic,
+                    "description": order.description,
+                    "purpose": order.extraction_purpose or "",
+                    "fields": order.extracted_fields or [],
+                }
+                script_requirement_str = json.dumps(
+                    script_requirement_dict, ensure_ascii=False
+                )
+
+                create_dataflow_query = """
+                CREATE (df:DataFlow {
+                    name_en: $name_en,
+                    name_zh: $name_zh,
+                    script_requirement: $script_requirement,
+                    script_type: 'pending',
+                    update_mode: 'full',
+                    status: 'inactive',
+                    created_at: datetime(),
+                    created_by: $created_by,
+                    source_order_id: $order_id
+                })
+                RETURN id(df) as df_id
+                """
+                result = session.run(
+                    create_dataflow_query,
+                    {
+                        "name_en": dataflow_name_en,
+                        "name_zh": dataflow_name_zh,
+                        "script_requirement": script_requirement_str,
+                        "created_by": "system",
+                        "order_id": order.id,
+                    },
+                ).single()
+                if result is None:
+                    raise ValueError("创建 DataFlow 失败")
+                dataflow_id = result["df_id"]
+
+                logger.info(f"创建 DataFlow: id={dataflow_id}, name={dataflow_name_en}")
+
+                # 4. 建立 INPUT 关系(源 BusinessDomain -> DataFlow)
+                for domain_id in input_domain_ids:
+                    create_input_rel_query = """
+                    MATCH (bd:BusinessDomain), (df:DataFlow)
+                    WHERE id(bd) = $bd_id AND id(df) = $df_id
+                    CREATE (bd)-[:INPUT]->(df)
+                    """
+                    session.run(
+                        create_input_rel_query,
+                        {"bd_id": domain_id, "df_id": dataflow_id},
+                    )
+
+                logger.info(f"建立 INPUT 关系: {input_domain_ids} -> {dataflow_id}")
+
+                # 5. 建立 OUTPUT 关系(DataFlow -> 目标 BusinessDomain)
+                create_output_rel_query = """
+                MATCH (df:DataFlow), (bd:BusinessDomain)
+                WHERE id(df) = $df_id AND id(bd) = $bd_id
+                CREATE (df)-[:OUTPUT]->(bd)
+                """
+                session.run(
+                    create_output_rel_query,
+                    {"df_id": dataflow_id, "bd_id": target_bd_id},
+                )
+
+                logger.info(f"建立 OUTPUT 关系: {dataflow_id} -> {target_bd_id}")
+
+            # 6. 在 task_list 表中创建任务记录
+            task_id = DataOrderService._create_task_record(
+                order=order,
+                dataflow_name_en=dataflow_name_en,
+                dataflow_name_zh=dataflow_name_zh,
+                dataflow_id=dataflow_id,
+                input_domain_names=input_domain_names,
+                target_bd_name_zh=target_bd_name_zh,
+                processing_logic=processing_logic,
+            )
+
+            return {
+                "target_business_domain_id": target_bd_id,
+                "target_business_domain_name": target_bd_name_zh,
+                "dataflow_id": dataflow_id,
+                "dataflow_name": dataflow_name_en,
+                "input_domain_ids": input_domain_ids,
+                "task_id": task_id,
+            }
+
+        except Exception as e:
+            logger.error(f"生成订单资源失败: {str(e)}")
+            raise
+
+    @staticmethod
+    def _create_task_record(
+        order: DataOrder,
+        dataflow_name_en: str,
+        dataflow_name_zh: str,
+        dataflow_id: int,
+        input_domain_names: list[str],
+        target_bd_name_zh: str,
+        processing_logic: str,
+    ) -> int | None:
+        """
+        在 task_list 表中创建任务记录
+
+        Args:
+            order: 数据订单对象
+            dataflow_name_en: DataFlow 英文名称
+            dataflow_name_zh: DataFlow 中文名称
+            dataflow_id: DataFlow 节点 ID
+            input_domain_names: 输入域名称列表
+            target_bd_name_zh: 目标 BusinessDomain 中文名称
+            processing_logic: 数据加工处理逻辑
+
+        Returns:
+            创建的任务 ID
+        """
+        from datetime import datetime
+
+        from sqlalchemy import text
+
+        try:
+            current_time = datetime.now()
+
+            # 构建 Markdown 格式的任务描述
+            task_description_parts = [
+                f"# Task: {dataflow_name_en}\n",
+                "## DataFlow Configuration",
+                f"- **DataFlow ID**: {dataflow_id}",
+                f"- **DataFlow Name**: {dataflow_name_zh}",
+                f"- **Order ID**: {order.id}",
+                f"- **Order No**: {order.order_no}\n",
+                "## Source Tables",
+            ]
+
+            # 添加输入域信息
+            for name in input_domain_names:
+                task_description_parts.append(f"- {name}")
+
+            task_description_parts.extend(
+                [
+                    "",
+                    "## Target Table",
+                    f"- {target_bd_name_zh}\n",
+                    "## Update Mode",
+                    "- **Mode**: Full Refresh (全量更新)",
+                    "- **Description**: 目标表将被清空后重新写入数据\n",
+                    "## Request Content",
+                    processing_logic or order.description,
+                    "",
+                    "## Implementation Steps",
+                    "1. 连接数据源,读取源数据表",
+                    "2. 根据处理逻辑执行数据转换",
+                    "3. 写入目标数据表",
+                    "4. 完成后回调更新订单状态为 onboard",
+                ]
+            )
+
+            task_description_md = "\n".join(task_description_parts)
+
+            # 脚本路径和名称
+            code_path = "datafactory/scripts"
+            code_name = f"{dataflow_name_en}.py"
+
+            # 插入 task_list 表
+            task_insert_sql = text(
+                "INSERT INTO public.task_list "
+                "(task_name, task_description, status, code_name, "
+                "code_path, create_by, create_time, update_time) "
+                "VALUES "
+                "(:task_name, :task_description, :status, :code_name, "
+                ":code_path, :create_by, :create_time, :update_time) "
+                "RETURNING task_id"
+            )
+
+            task_params = {
+                "task_name": dataflow_name_en,
+                "task_description": task_description_md,
+                "status": "pending",
+                "code_name": code_name,
+                "code_path": code_path,
+                "create_by": "system",
+                "create_time": current_time,
+                "update_time": current_time,
+            }
+
+            result = db.session.execute(task_insert_sql, task_params)
+            row = result.fetchone()
+            task_id = row[0] if row else None
+            db.session.commit()
+
+            logger.info(
+                f"成功创建任务记录: task_id={task_id}, task_name={dataflow_name_en}"
+            )
+            return task_id
+
+        except Exception as e:
+            db.session.rollback()
+            logger.error(f"创建任务记录失败: {str(e)}")
+            # 任务记录创建失败不阻塞主流程,返回 None
+            return None
+
+    @staticmethod
+    def set_order_onboard(
         order_id: int,
         product_id: int | None = None,
         dataflow_id: int | None = None,
-        processed_by: str = "system",
+        processed_by: str = "n8n-workflow",
     ) -> DataOrder | None:
         """
-        完成订单
+        设置订单为数据产品就绪状态(供数据工厂回调)
 
         Args:
             order_id: 订单ID
-            product_id: 生成的数据产品ID
-            dataflow_id: 生成的数据流ID
+            product_id: 生成的数据产品ID(可选)
+            dataflow_id: 数据流ID(可选)
             processed_by: 处理人
 
         Returns:
@@ -1639,19 +2189,31 @@ class DataOrderService:
             if not order:
                 return None
 
-            order.set_result(product_id, dataflow_id)
-            order.update_status(DataOrder.STATUS_COMPLETED, processed_by)
+            # 只允许从 processing 状态转换
+            if order.status != DataOrder.STATUS_PROCESSING:
+                raise ValueError(
+                    f"订单状态 {order.status} 不允许设置为 onboard,"
+                    f"只有 processing 状态可以转换"
+                )
+
+            # 更新关联信息
+            if product_id is not None:
+                order.result_product_id = product_id
+            if dataflow_id is not None:
+                order.result_dataflow_id = dataflow_id
+
+            order.update_status(DataOrder.STATUS_ONBOARD, processed_by)
             db.session.commit()
 
             logger.info(
-                f"订单已完成: order_id={order_id}, product_id={product_id}, "
-                f"dataflow_id={dataflow_id}"
+                f"订单设置为 onboard: order_id={order_id}, "
+                f"product_id={product_id}, dataflow_id={dataflow_id}"
             )
             return order
 
         except Exception as e:
             db.session.rollback()
-            logger.error(f"完成订单失败: {str(e)}")
+            logger.error(f"设置订单 onboard 状态失败: {str(e)}")
             raise
 
     @staticmethod

+ 57 - 19
app/core/meta_data/redundancy_check.py

@@ -5,11 +5,12 @@
 与 business_domain 模块共享相同的比对规则。
 """
 
+import contextlib
 import logging
-from datetime import datetime
 from typing import Any, Dict, List, Optional
 
 from app import db
+from app.core.common.timezone_utils import now_china_naive
 from app.models.metadata_review import MetadataReviewRecord
 from app.services.neo4j_driver import neo4j_driver
 
@@ -42,10 +43,8 @@ def normalize_tag_inputs(tag_data: Any) -> List[int]:
     if isinstance(tag_data, dict):
         tid = tag_data.get("id")
         if tid is not None:
-            try:
+            with contextlib.suppress(TypeError, ValueError):
                 return [int(tid)]
-            except (TypeError, ValueError):
-                return []
         return []
     if isinstance(tag_data, (list, tuple)):
         result = []
@@ -55,15 +54,11 @@ def normalize_tag_inputs(tag_data: Any) -> List[int]:
             elif isinstance(item, dict):
                 tid = item.get("id")
                 if tid is not None:
-                    try:
+                    with contextlib.suppress(TypeError, ValueError):
                         result.append(int(tid))
-                    except (TypeError, ValueError):
-                        pass
             else:
-                try:
+                with contextlib.suppress(TypeError, ValueError):
                     result.append(int(item))
-                except (TypeError, ValueError):
-                    pass
         return result
     return []
 
@@ -97,7 +92,7 @@ def build_meta_snapshot(item: Dict[str, Any]) -> Dict[str, Any]:
     name_en = _norm_str(item.get("name_en"))
     data_type = _norm_data_type(item.get("data_type", "varchar(255)"))
     tag_ids = normalize_tag_inputs(item.get("tag") or item.get("tag_ids") or [])
-    tag_ids_sorted = sorted(set(int(t) for t in tag_ids if t is not None))
+    tag_ids_sorted = sorted({int(t) for t in tag_ids if t is not None})
     return {
         "name_zh": name_zh,
         "name_en": name_en,
@@ -256,13 +251,58 @@ def write_redundancy_review_record(
     review.candidates = candidates_payload
     review.old_meta = None
     review.status = "pending"
-    review.created_at = datetime.utcnow()
-    review.updated_at = datetime.utcnow()
+    review.created_at = now_china_naive()
+    review.updated_at = now_china_naive()
     db.session.add(review)
     db.session.commit()
     logger.info(f"已创建疑似冗余审核记录: new_meta.name_zh={new_meta.get('name_zh')}")
 
 
+def write_redundancy_review_record_with_new_id(
+    new_meta: Dict[str, Any],
+    candidates: List[Dict[str, Any]],
+    source: str = "api",
+) -> None:
+    """
+    写入疑似冗余审核记录到 PostgreSQL(新元数据已创建,包含ID)
+
+    与 write_redundancy_review_record 的区别:
+    - 此函数用于新元数据节点已创建后的场景
+    - new_meta 中包含已创建的节点 ID
+
+    Args:
+        new_meta: 新元数据快照(包含已创建的节点ID)
+        candidates: 疑似重复的候选元数据列表
+        source: 来源标识(api / ddl)
+    """
+    candidates_payload = []
+    for cand in candidates:
+        candidates_payload.append(
+            {
+                "candidate_meta_id": cand.get("id"),
+                "snapshot": cand,
+                "diff_fields": diff_fields(new_meta, cand),
+            }
+        )
+
+    review = MetadataReviewRecord()
+    review.record_type = "redundancy"
+    review.source = source
+    review.business_domain_id = 0  # 单独新增元数据时无业务领域关联
+    review.new_meta = new_meta  # 包含新创建的节点ID
+    review.candidates = candidates_payload
+    review.old_meta = None
+    review.status = "pending"
+    review.created_at = now_china_naive()
+    review.updated_at = now_china_naive()
+    db.session.add(review)
+    db.session.commit()
+    logger.info(
+        f"已创建疑似冗余审核记录(新节点已创建): "
+        f"new_meta_id={new_meta.get('id')}, name_zh={new_meta.get('name_zh')}"
+    )
+
+
 def check_redundancy_for_add(
     name_zh: str,
     name_en: str,
@@ -272,13 +312,15 @@ def check_redundancy_for_add(
     """
     新增元数据时的冗余检测
 
+    注意:此函数只进行检测,不创建审核记录。
+    调用方应根据返回结果决定是否创建节点和审核记录。
+
     Returns:
         {
             "has_exact_match": bool,      # 是否有完全匹配
             "exact_match_id": int|None,   # 完全匹配的节点ID
             "has_candidates": bool,       # 是否有疑似重复
             "candidates": list,           # 疑似重复候选列表
-            "review_created": bool,       # 是否已创建审核记录
         }
     """
     new_meta = {
@@ -301,7 +343,6 @@ def check_redundancy_for_add(
                 "exact_match_id": None,
                 "has_candidates": False,
                 "candidates": [],
-                "review_created": False,
             }
 
         # 检查是否有完全匹配
@@ -312,17 +353,14 @@ def check_redundancy_for_add(
                     "exact_match_id": cand.get("id"),
                     "has_candidates": True,
                     "candidates": candidates,
-                    "review_created": False,
                 }
 
-        # 有疑似重复但无完全匹配,写入审核记录
-        write_redundancy_review_record(new_meta, candidates, source="api")
+        # 有疑似重复但无完全匹配,返回候选列表
         return {
             "has_exact_match": False,
             "exact_match_id": None,
             "has_candidates": True,
             "candidates": candidates,
-            "review_created": True,
         }
 
 

+ 20 - 16
app/models/data_product.py

@@ -5,10 +5,10 @@
 
 from __future__ import annotations
 
-from datetime import datetime
 from typing import Any
 
 from app import db
+from app.core.common.timezone_utils import now_china_naive
 
 
 class DataProduct(db.Model):
@@ -44,9 +44,9 @@ class DataProduct(db.Model):
     status = db.Column(db.String(50), nullable=False, default="active")
 
     # 审计字段
-    created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
+    created_at = db.Column(db.DateTime, nullable=False, default=now_china_naive)
     created_by = db.Column(db.String(100), nullable=False, default="system")
-    updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
+    updated_at = db.Column(db.DateTime, nullable=False, default=now_china_naive)
 
     def to_dict(self) -> dict[str, Any]:
         """
@@ -94,8 +94,8 @@ class DataProduct(db.Model):
 
     def mark_as_viewed(self) -> None:
         """标记为已查看,更新 last_viewed_at 时间"""
-        self.last_viewed_at = datetime.utcnow()
-        self.updated_at = datetime.utcnow()
+        self.last_viewed_at = now_china_naive()
+        self.updated_at = now_china_naive()
 
     def update_data_stats(
         self,
@@ -112,8 +112,8 @@ class DataProduct(db.Model):
         self.record_count = record_count
         if column_count is not None:
             self.column_count = column_count
-        self.last_updated_at = datetime.utcnow()
-        self.updated_at = datetime.utcnow()
+        self.last_updated_at = now_china_naive()
+        self.updated_at = now_china_naive()
 
     def __repr__(self) -> str:
         return f"<DataProduct {self.product_name} ({self.target_table})>"
@@ -159,15 +159,17 @@ class DataOrder(db.Model):
 
     # 审计字段
     created_by = db.Column(db.String(100), nullable=False, default="user")
-    created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
-    updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
+    created_at = db.Column(db.DateTime, nullable=False, default=now_china_naive)
+    updated_at = db.Column(db.DateTime, nullable=False, default=now_china_naive)
     processed_by = db.Column(db.String(100), nullable=True)  # 处理人
     processed_at = db.Column(db.DateTime, nullable=True)  # 处理时间
 
     # 状态常量
     STATUS_PENDING = "pending"
     STATUS_ANALYZING = "analyzing"
+    STATUS_PENDING_APPROVAL = "pending_approval"  # 待审批
     STATUS_PROCESSING = "processing"
+    STATUS_ONBOARD = "onboard"  # 数据产品就绪
     STATUS_COMPLETED = "completed"
     STATUS_REJECTED = "rejected"
     STATUS_NEED_SUPPLEMENT = "need_supplement"
@@ -178,7 +180,9 @@ class DataOrder(db.Model):
     STATUS_LABELS = {
         "pending": "待处理",
         "analyzing": "分析中",
+        "pending_approval": "待审批",
         "processing": "加工中",
+        "onboard": "数据产品就绪",
         "completed": "已完成",
         "rejected": "已驳回",
         "need_supplement": "待补充",
@@ -227,10 +231,10 @@ class DataOrder(db.Model):
             processed_by: 处理人
         """
         self.status = new_status
-        self.updated_at = datetime.utcnow()
+        self.updated_at = now_china_naive()
         if processed_by:
             self.processed_by = processed_by
-            self.processed_at = datetime.utcnow()
+            self.processed_at = now_china_naive()
 
     def set_extraction_result(
         self,
@@ -249,7 +253,7 @@ class DataOrder(db.Model):
         self.extracted_domains = domains
         self.extracted_fields = fields
         self.extraction_purpose = purpose
-        self.updated_at = datetime.utcnow()
+        self.updated_at = now_china_naive()
 
     def set_graph_analysis(
         self,
@@ -268,7 +272,7 @@ class DataOrder(db.Model):
         self.graph_analysis = analysis
         self.can_connect = can_connect
         self.connection_path = connection_path
-        self.updated_at = datetime.utcnow()
+        self.updated_at = now_china_naive()
 
     def set_result(
         self,
@@ -286,7 +290,7 @@ class DataOrder(db.Model):
             self.result_product_id = product_id
         if dataflow_id is not None:
             self.result_dataflow_id = dataflow_id
-        self.updated_at = datetime.utcnow()
+        self.updated_at = now_china_naive()
 
     def reject(self, reason: str, processed_by: str | None = None) -> None:
         """
@@ -298,10 +302,10 @@ class DataOrder(db.Model):
         """
         self.status = self.STATUS_REJECTED
         self.reject_reason = reason
-        self.updated_at = datetime.utcnow()
+        self.updated_at = now_china_naive()
         if processed_by:
             self.processed_by = processed_by
-            self.processed_at = datetime.utcnow()
+            self.processed_at = now_china_naive()
 
     def __repr__(self) -> str:
         return f"<DataOrder {self.order_no} ({self.status})>"

+ 10 - 12
app/models/metadata_review.py

@@ -1,11 +1,11 @@
 from __future__ import annotations
 
-from datetime import datetime
-from typing import Any, Optional
+from typing import Any
 
 from sqlalchemy.dialects.postgresql import JSONB
 
 from app import db
+from app.core.common.timezone_utils import now_china_naive
 
 
 class MetadataReviewRecord(db.Model):
@@ -26,8 +26,8 @@ class MetadataReviewRecord(db.Model):
     resolution_payload = db.Column(JSONB, nullable=True)
 
     notes = db.Column(db.Text, nullable=True)
-    created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
-    updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
+    created_at = db.Column(db.DateTime, nullable=False, default=now_china_naive)
+    updated_at = db.Column(db.DateTime, nullable=False, default=now_china_naive)
     resolved_at = db.Column(db.DateTime, nullable=True)
     resolved_by = db.Column(db.String(100), nullable=True)
 
@@ -62,7 +62,7 @@ class MetadataVersionHistory(db.Model):
     before_snapshot = db.Column(JSONB, nullable=False)
     after_snapshot = db.Column(JSONB, nullable=False)
 
-    created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
+    created_at = db.Column(db.DateTime, nullable=False, default=now_china_naive)
     created_by = db.Column(db.String(100), nullable=True)
 
     def to_dict(self) -> dict[str, Any]:
@@ -80,17 +80,15 @@ class MetadataVersionHistory(db.Model):
 def update_review_record_resolution(
     record: MetadataReviewRecord,
     action: str,
-    payload: Optional[dict[str, Any]] = None,
-    resolved_by: Optional[str] = None,
-    notes: Optional[str] = None,
+    payload: dict[str, Any] | None = None,
+    resolved_by: str | None = None,
+    notes: str | None = None,
 ) -> None:
     record.status = "resolved" if action != "ignore" else "ignored"
     record.resolution_action = action
     record.resolution_payload = payload or {}
     record.resolved_by = resolved_by
-    record.resolved_at = datetime.utcnow()
-    record.updated_at = datetime.utcnow()
+    record.resolved_at = now_china_naive()
+    record.updated_at = now_china_naive()
     if notes is not None:
         record.notes = notes
-
-

+ 729 - 0
docs/api_bd_compose_guide.md

@@ -0,0 +1,729 @@
+# 业务领域组合创建接口 - 前端开发指南
+
+> 本文档为前端开发人员提供业务领域组合创建 (`/compose`) 接口的详细使用说明。
+
+## 目录
+
+- [接口概述](#接口概述)
+- [接口规格](#接口规格)
+- [请求参数详解](#请求参数详解)
+- [响应格式](#响应格式)
+- [前端实现示例](#前端实现示例)
+- [典型使用场景](#典型使用场景)
+- [错误处理](#错误处理)
+- [最佳实践](#最佳实践)
+
+---
+
+## 接口概述
+
+### 功能说明
+
+该接口用于从已有的元数据中**组合创建新的业务领域**。用户可以从不同的业务领域中选择需要的元数据(DataMeta),将它们组合到一个新的业务领域中。
+
+### 业务流程
+
+```
+┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
+│   用户选择      │     │   提交创建      │     │   返回结果      │
+│   元数据列表    │ ──► │   请求          │ ──► │   新业务领域    │
+└─────────────────┘     └─────────────────┘     └─────────────────┘
+```
+
+### 核心特性
+
+- ✅ 支持选择多个元数据
+- ✅ 自动翻译中文名称为英文(若未提供)
+- ✅ 支持关联标签和数据源
+- ✅ 自动创建业务领域与元数据的 `INCLUDES` 关系
+
+---
+
+## 接口规格
+
+### 基本信息
+
+| 项目 | 说明 |
+|------|------|
+| **请求URL** | `POST /api/bd/compose` |
+| **Content-Type** | `application/json` |
+| **请求方法** | POST |
+
+### 请求头
+
+```http
+Content-Type: application/json
+```
+
+---
+
+## 请求参数详解
+
+### 参数列表
+
+| 参数名 | 类型 | 必填 | 默认值 | 说明 |
+|--------|------|------|--------|------|
+| name_zh | string | ✅ 是 | - | 业务领域中文名称 |
+| name_en | string | 否 | 自动翻译 | 业务领域英文名称 |
+| id_list | array | ✅ 是 | - | 选中的元数据ID列表 |
+| describe | string | 否 | - | 业务领域描述 |
+| type | string | 否 | - | 业务领域类型 |
+| category | string | 否 | - | 业务领域分类 |
+| tag | array/integer | 否 | - | 标签ID或标签对象数组 |
+| data_source | integer | 否 | - | 数据源ID |
+
+### id_list 参数格式
+
+`id_list` 支持两种格式,前端可根据实际情况选择:
+
+#### 格式一:纯ID数组(推荐)
+
+最简洁的格式,直接传递元数据的 ID 列表:
+
+```json
+{
+  "name_zh": "销售分析域",
+  "id_list": [123, 456, 789]
+}
+```
+
+#### 格式二:对象数组
+
+兼容旧格式,传递包含 `id` 字段的对象数组:
+
+```json
+{
+  "name_zh": "销售分析域",
+  "id_list": [
+    { "id": 123 },
+    { "id": 456 },
+    { "id": 789 }
+  ]
+}
+```
+
+### tag 参数格式
+
+`tag` 参数支持多种格式:
+
+```javascript
+// 格式1:单个标签ID
+{ "tag": 100 }
+
+// 格式2:标签ID数组
+{ "tag": [100, 101, 102] }
+
+// 格式3:标签对象数组
+{ "tag": [{ "id": 100 }, { "id": 101 }] }
+
+// 格式4:完整标签对象
+{ "tag": [{ "id": 100, "name_zh": "核心业务", "name_en": "core" }] }
+```
+
+### 可选字段说明
+
+以下字段均为可选,根据业务需求选择性传递:
+
+| 字段名 | 类型 | 说明 |
+|--------|------|------|
+| leader | string | 负责人 |
+| organization | string | 所属组织 |
+| status | string | 状态 |
+| keywords | string | 关键词 |
+| data_sensitivity | string | 数据敏感性 |
+| frequency | string | 更新频率 |
+| url | string | 相关URL |
+| storage_location | string | 存储位置 |
+
+---
+
+## 响应格式
+
+### 成功响应
+
+```json
+{
+  "code": 200,
+  "message": "操作成功",
+  "data": {
+    "business_domain": {
+      "id": 12350,
+      "name_zh": "销售分析域",
+      "name_en": "sales_analysis_domain",
+      "describe": "整合订单和客户数据的销售分析业务领域",
+      "type": "analysis",
+      "category": "业务分析",
+      "create_time": "2025-01-09 10:30:00",
+      "update_time": "2025-01-09 10:30:00",
+      "tag": [
+        {
+          "id": 100,
+          "name_zh": "核心业务",
+          "name_en": "core_business"
+        }
+      ]
+    }
+  }
+}
+```
+
+### 响应字段说明
+
+| 字段路径 | 类型 | 说明 |
+|----------|------|------|
+| code | integer | 状态码,200 表示成功 |
+| message | string | 操作结果消息 |
+| data.business_domain | object | 创建的业务领域详情 |
+| data.business_domain.id | integer | 新创建的业务领域节点ID |
+| data.business_domain.name_zh | string | 中文名称 |
+| data.business_domain.name_en | string | 英文名称 |
+| data.business_domain.tag | array | 关联的标签列表 |
+
+### 失败响应
+
+```json
+{
+  "code": 500,
+  "message": "组合创建业务领域失败",
+  "data": null,
+  "error": "具体错误信息"
+}
+```
+
+---
+
+## 前端实现示例
+
+### 使用 Axios
+
+```javascript
+import axios from 'axios';
+
+/**
+ * 组合创建业务领域
+ * @param {Object} params - 创建参数
+ * @param {string} params.name_zh - 中文名称(必填)
+ * @param {string} [params.name_en] - 英文名称(可选,自动翻译)
+ * @param {number[]} params.id_list - 元数据ID列表(必填)
+ * @param {string} [params.describe] - 描述
+ * @param {string} [params.type] - 类型
+ * @param {string} [params.category] - 分类
+ * @param {number|number[]} [params.tag] - 标签ID
+ * @param {number} [params.data_source] - 数据源ID
+ * @returns {Promise<Object>} 创建结果
+ */
+async function composeBusinessDomain(params) {
+  try {
+    const response = await axios.post('/api/bd/compose', params);
+    
+    if (response.data.code === 200) {
+      return {
+        success: true,
+        data: response.data.data.business_domain
+      };
+    } else {
+      return {
+        success: false,
+        message: response.data.message,
+        error: response.data.error
+      };
+    }
+  } catch (error) {
+    console.error('组合创建业务领域失败:', error);
+    throw error;
+  }
+}
+
+// 使用示例
+const result = await composeBusinessDomain({
+  name_zh: '客户订单分析域',
+  describe: '整合客户信息和订单数据的分析业务领域',
+  id_list: [101, 102, 103, 201, 202],  // 选中的元数据ID
+  tag: [100],  // 标签ID
+  category: '业务分析'
+});
+
+if (result.success) {
+  console.log('创建成功,新业务领域ID:', result.data.id);
+} else {
+  console.error('创建失败:', result.message);
+}
+```
+
+### 使用 Fetch API
+
+```javascript
+/**
+ * 组合创建业务领域
+ */
+async function composeBusinessDomain(params) {
+  const response = await fetch('/api/bd/compose', {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json'
+    },
+    body: JSON.stringify(params)
+  });
+  
+  const data = await response.json();
+  
+  if (data.code !== 200) {
+    throw new Error(data.message || '创建失败');
+  }
+  
+  return data.data.business_domain;
+}
+
+// 使用示例
+try {
+  const newDomain = await composeBusinessDomain({
+    name_zh: '销售报表域',
+    id_list: [301, 302, 303]
+  });
+  console.log('创建成功:', newDomain);
+} catch (error) {
+  console.error('创建失败:', error.message);
+}
+```
+
+### Vue 3 组件示例
+
+```vue
+<template>
+  <div class="compose-form">
+    <el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
+      <el-form-item label="中文名称" prop="name_zh">
+        <el-input v-model="form.name_zh" placeholder="请输入业务领域中文名称" />
+      </el-form-item>
+      
+      <el-form-item label="英文名称" prop="name_en">
+        <el-input v-model="form.name_en" placeholder="可选,不填则自动翻译" />
+      </el-form-item>
+      
+      <el-form-item label="描述" prop="describe">
+        <el-input type="textarea" v-model="form.describe" :rows="3" />
+      </el-form-item>
+      
+      <el-form-item label="元数据" prop="id_list">
+        <div class="selected-meta">
+          <el-tag 
+            v-for="id in form.id_list" 
+            :key="id" 
+            closable 
+            @close="removeMeta(id)"
+          >
+            {{ getMetaName(id) }}
+          </el-tag>
+        </div>
+        <el-button type="primary" text @click="openMetaSelector">
+          选择元数据
+        </el-button>
+      </el-form-item>
+      
+      <el-form-item>
+        <el-button type="primary" @click="handleSubmit" :loading="loading">
+          创建业务领域
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive } from 'vue';
+import { ElMessage } from 'element-plus';
+import axios from 'axios';
+
+const formRef = ref(null);
+const loading = ref(false);
+
+const form = reactive({
+  name_zh: '',
+  name_en: '',
+  describe: '',
+  id_list: []
+});
+
+const rules = {
+  name_zh: [
+    { required: true, message: '请输入中文名称', trigger: 'blur' }
+  ],
+  id_list: [
+    { 
+      required: true, 
+      validator: (rule, value, callback) => {
+        if (!value || value.length === 0) {
+          callback(new Error('请至少选择一个元数据'));
+        } else {
+          callback();
+        }
+      }, 
+      trigger: 'change' 
+    }
+  ]
+};
+
+// 选择元数据
+const openMetaSelector = () => {
+  // 打开元数据选择器对话框
+  // 实现略...
+};
+
+// 移除已选元数据
+const removeMeta = (id) => {
+  const index = form.id_list.indexOf(id);
+  if (index > -1) {
+    form.id_list.splice(index, 1);
+  }
+};
+
+// 获取元数据名称(用于显示)
+const getMetaName = (id) => {
+  // 根据ID获取元数据名称
+  // 实现略...
+  return `元数据-${id}`;
+};
+
+// 提交表单
+const handleSubmit = async () => {
+  try {
+    await formRef.value.validate();
+    
+    loading.value = true;
+    
+    const response = await axios.post('/api/bd/compose', {
+      name_zh: form.name_zh,
+      name_en: form.name_en || undefined,
+      describe: form.describe || undefined,
+      id_list: form.id_list
+    });
+    
+    if (response.data.code === 200) {
+      ElMessage.success('业务领域创建成功');
+      const newDomain = response.data.data.business_domain;
+      console.log('新建业务领域:', newDomain);
+      // 跳转到详情页或刷新列表
+    } else {
+      ElMessage.error(response.data.message || '创建失败');
+    }
+  } catch (error) {
+    if (error.name !== 'Error') {
+      // 非表单验证错误
+      ElMessage.error('创建失败: ' + error.message);
+    }
+  } finally {
+    loading.value = false;
+  }
+};
+</script>
+```
+
+### React 组件示例
+
+```jsx
+import React, { useState } from 'react';
+import axios from 'axios';
+
+function ComposeBusinessDomain({ selectedMetaIds, onSuccess }) {
+  const [form, setForm] = useState({
+    name_zh: '',
+    name_en: '',
+    describe: ''
+  });
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState(null);
+
+  const handleChange = (e) => {
+    const { name, value } = e.target;
+    setForm(prev => ({ ...prev, [name]: value }));
+  };
+
+  const handleSubmit = async (e) => {
+    e.preventDefault();
+    
+    if (!form.name_zh.trim()) {
+      setError('请输入中文名称');
+      return;
+    }
+    
+    if (!selectedMetaIds || selectedMetaIds.length === 0) {
+      setError('请至少选择一个元数据');
+      return;
+    }
+
+    setLoading(true);
+    setError(null);
+
+    try {
+      const response = await axios.post('/api/bd/compose', {
+        name_zh: form.name_zh,
+        name_en: form.name_en || undefined,
+        describe: form.describe || undefined,
+        id_list: selectedMetaIds
+      });
+
+      if (response.data.code === 200) {
+        onSuccess?.(response.data.data.business_domain);
+      } else {
+        setError(response.data.message || '创建失败');
+      }
+    } catch (err) {
+      setError(err.response?.data?.message || err.message);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  return (
+    <form onSubmit={handleSubmit}>
+      <div className="form-group">
+        <label>中文名称 *</label>
+        <input
+          type="text"
+          name="name_zh"
+          value={form.name_zh}
+          onChange={handleChange}
+          placeholder="请输入业务领域中文名称"
+          required
+        />
+      </div>
+
+      <div className="form-group">
+        <label>英文名称</label>
+        <input
+          type="text"
+          name="name_en"
+          value={form.name_en}
+          onChange={handleChange}
+          placeholder="可选,不填则自动翻译"
+        />
+      </div>
+
+      <div className="form-group">
+        <label>描述</label>
+        <textarea
+          name="describe"
+          value={form.describe}
+          onChange={handleChange}
+          rows={3}
+        />
+      </div>
+
+      <div className="form-group">
+        <label>已选元数据</label>
+        <div className="selected-count">
+          已选择 {selectedMetaIds?.length || 0} 个元数据
+        </div>
+      </div>
+
+      {error && <div className="error-message">{error}</div>}
+
+      <button type="submit" disabled={loading}>
+        {loading ? '创建中...' : '创建业务领域'}
+      </button>
+    </form>
+  );
+}
+
+export default ComposeBusinessDomain;
+```
+
+---
+
+## 典型使用场景
+
+### 场景一:从多个业务领域选取元数据
+
+用户浏览多个现有业务领域,从中选取需要的元数据字段,组合成新的业务领域。
+
+```javascript
+// 用户从订单域选取了 order_id, order_amount
+// 从客户域选取了 customer_id, customer_name
+// 组合创建 "订单客户分析域"
+
+const selectedMetaIds = [
+  101,  // order_id
+  102,  // order_amount
+  201,  // customer_id
+  202   // customer_name
+];
+
+await composeBusinessDomain({
+  name_zh: '订单客户分析域',
+  describe: '用于分析订单与客户关联关系',
+  id_list: selectedMetaIds,
+  category: '分析域'
+});
+```
+
+### 场景二:快速创建子域
+
+从一个大的业务领域中选取部分元数据,创建更细粒度的子域。
+
+```javascript
+// 从 "销售管理" 域中选取部分字段创建 "销售统计" 子域
+await composeBusinessDomain({
+  name_zh: '销售统计域',
+  id_list: [301, 302, 303, 304],
+  tag: [100],  // 关联 "核心业务" 标签
+  data_source: 1  // 关联数据源
+});
+```
+
+### 场景三:跨数据源整合
+
+整合来自不同数据源的元数据,创建统一的业务视图。
+
+```javascript
+await composeBusinessDomain({
+  name_zh: '统一客户视图',
+  describe: '整合CRM、ERP、电商系统的客户数据',
+  id_list: [
+    ...crmCustomerFieldIds,
+    ...erpCustomerFieldIds,
+    ...ecommerceCustomerFieldIds
+  ],
+  category: 'MDM',
+  tag: [200]  // "主数据" 标签
+});
+```
+
+---
+
+## 错误处理
+
+### 错误码说明
+
+| 错误消息 | 原因 | 解决方案 |
+|----------|------|----------|
+| 请求数据不能为空 | 未发送请求体 | 检查请求是否包含 JSON body |
+| name_zh 为必填项 | 未提供中文名称 | 添加 name_zh 参数 |
+| id_list 为必填项 | 未提供元数据列表 | 添加 id_list 参数 |
+| 创建业务领域节点失败 | Neo4j 数据库错误 | 联系后端排查 |
+
+### 前端错误处理示例
+
+```javascript
+async function handleComposeError(error, response) {
+  // 网络错误
+  if (!response) {
+    showMessage('网络连接失败,请检查网络');
+    return;
+  }
+  
+  const { code, message, error: errorDetail } = response.data;
+  
+  switch (message) {
+    case 'name_zh 为必填项':
+      showMessage('请输入业务领域中文名称');
+      focusField('name_zh');
+      break;
+      
+    case 'id_list 为必填项':
+      showMessage('请至少选择一个元数据');
+      break;
+      
+    default:
+      showMessage(`创建失败: ${message}`);
+      console.error('详细错误:', errorDetail);
+  }
+}
+```
+
+---
+
+## 最佳实践
+
+### 1. 参数校验
+
+在调用接口前进行前端校验,减少无效请求:
+
+```javascript
+function validateComposeParams(params) {
+  const errors = [];
+  
+  if (!params.name_zh?.trim()) {
+    errors.push('中文名称不能为空');
+  }
+  
+  if (!params.id_list || params.id_list.length === 0) {
+    errors.push('请至少选择一个元数据');
+  }
+  
+  if (params.name_zh && params.name_zh.length > 100) {
+    errors.push('中文名称不能超过100个字符');
+  }
+  
+  return errors;
+}
+```
+
+### 2. 去重处理
+
+确保 id_list 中没有重复的 ID:
+
+```javascript
+const uniqueIds = [...new Set(selectedMetaIds)];
+```
+
+### 3. 用户体验优化
+
+```javascript
+// 1. 添加加载状态
+setLoading(true);
+
+// 2. 成功后给予反馈
+ElMessage.success({
+  message: `业务领域"${result.name_zh}"创建成功`,
+  duration: 3000
+});
+
+// 3. 提供快捷操作
+showDialog({
+  title: '创建成功',
+  message: '是否立即查看新创建的业务领域?',
+  confirmText: '查看详情',
+  cancelText: '继续创建',
+  onConfirm: () => router.push(`/bd/detail/${result.id}`)
+});
+```
+
+### 4. 批量选择元数据
+
+提供高效的元数据选择方式:
+
+```javascript
+// 支持按业务领域批量选择
+async function selectMetaFromDomain(domainId) {
+  const metaList = await fetchDomainMeta(domainId);
+  return metaList.map(meta => meta.id);
+}
+
+// 支持搜索过滤
+async function searchAndSelectMeta(keyword) {
+  const results = await searchMeta(keyword);
+  // 显示选择器...
+}
+```
+
+---
+
+## 更新日志
+
+| 版本 | 日期 | 更新内容 |
+|------|------|----------|
+| 2.0.0 | 2025-01-09 | 简化 id_list 格式,支持纯ID数组;移除 domain_id 参数 |
+| 1.0.0 | 2025-12-06 | 初始版本 |
+
+---
+
+## 相关接口
+
+- [获取业务领域列表](/api/bd/list) - 获取可选择的业务领域
+- [搜索关联元数据](/api/bd/search) - 获取业务领域下的元数据
+- [获取业务领域详情](/api/bd/detail) - 查看创建结果
+- [获取标签列表](/api/bd/labellist) - 获取可关联的标签
+
+---
+
+> 如有问题,请联系后端开发团队。

Datei-Diff unterdrückt, da er zu groß ist
+ 264 - 518
docs/api_data_order_guide.md


+ 15 - 7
docs/api_meta_review_records.md

@@ -178,13 +178,16 @@
 
 注意:后端会拒绝重复处理(当 `status != pending` 时返回失败)。
 
-### action=alias(设为某候选元数据别名)
-用途:处理 `record_type=redundancy` 的记录。
+### action=alias(设置元数据别名关系)
+用途:在 DataMeta 节点之间建立 ALIAS 关系,用于合并重复/相似的元数据。
+
+> **注意**:此接口已更新,详细说明请参阅 [api_review_resolve_guide.md](api_review_resolve_guide.md)
 
 payload:
 | 字段 | 类型 | 必填 | 说明 |
 | --- | --- | --- | --- |
-| candidate_meta_id | int | 是 | 选定的候选元数据 Neo4j ID |
+| primary_meta_id | int | 是 | 主元数据 Neo4j ID(作为别名目标) |
+| alias_meta_id | int | 是 | 别名元数据 Neo4j ID(将成为别名) |
 
 示例:
 
@@ -192,15 +195,20 @@ payload:
 {
   "id": 1001,
   "action": "alias",
-  "payload": { "candidate_meta_id": 789 },
+  "payload": {
+    "primary_meta_id": 100,
+    "alias_meta_id": 200
+  },
   "resolved_by": "alice",
-  "notes": "确认与既有字段一致,设为别名"
+  "notes": "将重复元数据合并为别名"
 }
 ```
 
 后端行为:
-- 为 `business_domain_id` 建立 `(:BusinessDomain)-[:INCLUDES]->(:DataMeta)` 到 `candidate_meta_id`
-- 在关系上写入 `alias_name_zh/alias_name_en`(取自该记录的 `new_meta`)
+- 创建 `(alias_meta)-[:ALIAS]->(primary_meta)` 关系
+- 将所有原先指向 `alias_meta` 的 ALIAS 关系转移到 `primary_meta`
+- `primary_meta` 已有的 ALIAS 关系保持不变
+- BusinessDomain 的 INCLUDES 关系不受影响
 - 将审核记录标记为 `resolved`
 
 ### action=create_new(确认作为新元数据)

+ 840 - 0
docs/api_review_resolve_guide.md

@@ -0,0 +1,840 @@
+# 元数据审核处理接口 API 前端开发指南
+
+本文档面向前端开发人员,用于开发"元数据审核处理"功能。
+
+## 接口基本信息
+
+| 属性 | 值 |
+| --- | --- |
+| 接口路径 | `POST /api/meta/review/resolve` |
+| 完整URL | `http://{host}:{port}/api/meta/review/resolve` |
+| Content-Type | `application/json` |
+| 请求方式 | POST |
+
+## 统一返回格式
+
+### 成功响应
+
+```json
+{
+  "code": 200,
+  "message": "操作成功",
+  "data": {
+    "id": 123,
+    "record_type": "redundancy",
+    "status": "resolved",
+    "resolution_action": "alias",
+    "resolution_payload": {
+      "primary_meta_id": 100,
+      "alias_meta_id": 200
+    },
+    "resolved_by": "admin",
+    "resolved_at": "2025-01-09T10:30:00.000000",
+    "notes": "合并重复元数据"
+  }
+}
+```
+
+### 失败响应
+
+```json
+{
+  "code": 500,
+  "message": "错误原因",
+  "data": null,
+  "error": "详细错误信息(可选)"
+}
+```
+
+## 请求参数
+
+### 公共参数
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| id | int | 是 | 审核记录ID |
+| action | string | 是 | 处理动作,可选值见下方说明 |
+| payload | object | 否 | 动作参数,根据action不同而变化 |
+| resolved_by | string | 否 | 处理人标识(建议传登录用户名/工号) |
+| notes | string | 否 | 处理备注 |
+
+### action 可选值
+
+| action 值 | 用途 | 适用场景 |
+| --- | --- | --- |
+| `alias` | 设置元数据别名关系 | redundancy(疑似冗余) |
+| `create_new` | 创建新元数据 | redundancy(疑似冗余) |
+| `accept_change` | 接受元数据变动 | change(疑似变动) |
+| `reject_change` | 拒绝元数据变动 | change(疑似变动) |
+| `ignore` | 忽略该记录 | 任意类型 |
+
+---
+
+## action=alias(设置元数据别名关系)
+
+### 功能说明
+
+在 Neo4j 的 DataMeta 节点之间建立 ALIAS 关系,用于合并重复/相似的元数据。
+
+**核心行为**:
+- 创建 `(alias_meta)-[:ALIAS]->(primary_meta)` 关系
+- 将所有原先指向 `alias_meta` 的 ALIAS 关系转移到 `primary_meta`
+- `primary_meta` 已有的 ALIAS 关系保持不变
+- BusinessDomain 的 INCLUDES 关系不受影响
+
+```
+操作前:
+  [other_alias_1] --ALIAS--> [alias_meta]
+  [other_alias_2] --ALIAS--> [alias_meta]
+  [existing_alias] --ALIAS--> [primary_meta]
+
+操作后:
+  [other_alias_1] --ALIAS--> [primary_meta]
+  [other_alias_2] --ALIAS--> [primary_meta]
+  [alias_meta] --ALIAS--> [primary_meta]
+  [existing_alias] --ALIAS--> [primary_meta]
+```
+
+### payload 参数
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| primary_meta_id | int | 是 | 主元数据 Neo4j ID(作为别名目标) |
+| alias_meta_id | int | 是 | 别名元数据 Neo4j ID(将成为别名) |
+
+### 请求示例
+
+```json
+{
+  "id": 1001,
+  "action": "alias",
+  "payload": {
+    "primary_meta_id": 100,
+    "alias_meta_id": 200
+  },
+  "resolved_by": "admin",
+  "notes": "将重复元数据合并为别名"
+}
+```
+
+### 响应示例
+
+```json
+{
+  "code": 200,
+  "message": "操作成功",
+  "data": {
+    "id": 1001,
+    "record_type": "redundancy",
+    "business_domain_id": 345,
+    "new_meta": {
+      "name_zh": "科室名称",
+      "name_en": "DEPT_NAME",
+      "data_type": "varchar(50)"
+    },
+    "candidates": [...],
+    "status": "resolved",
+    "resolution_action": "alias",
+    "resolution_payload": {
+      "primary_meta_id": 100,
+      "alias_meta_id": 200
+    },
+    "resolved_by": "admin",
+    "resolved_at": "2025-01-09T10:30:00.000000",
+    "notes": "将重复元数据合并为别名",
+    "created_at": "2025-01-08T09:00:00.000000",
+    "updated_at": "2025-01-09T10:30:00.000000"
+  }
+}
+```
+
+### 错误信息
+
+| 错误信息 | 原因 |
+| --- | --- |
+| `payload.primary_meta_id 不能为空` | 未提供 primary_meta_id |
+| `payload.alias_meta_id 不能为空` | 未提供 alias_meta_id |
+| `primary_meta_id 和 alias_meta_id 不能相同` | 两个ID相同 |
+
+---
+
+## action=create_new(创建新元数据)
+
+### payload 参数
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| new_name_zh | string | 是 | 新元数据中文名(需与现有区分) |
+
+### 请求示例
+
+```json
+{
+  "id": 1002,
+  "action": "create_new",
+  "payload": {
+    "new_name_zh": "HIS科室名称(新)"
+  },
+  "resolved_by": "admin"
+}
+```
+
+### 错误信息
+
+| 错误信息 | 原因 |
+| --- | --- |
+| `记录缺少 business_domain_id,无法执行 create_new` | 审核记录无业务领域关联 |
+| `payload.new_name_zh 不能为空` | 未提供中文名 |
+| `创建新元数据失败` | Neo4j 创建节点失败 |
+
+---
+
+## action=accept_change(接受变动)
+
+### payload 参数
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| meta_id | int | 否 | 目标元数据ID,不传时使用 old_meta.meta_id |
+
+### 请求示例
+
+```json
+{
+  "id": 2001,
+  "action": "accept_change",
+  "payload": {
+    "meta_id": 789
+  },
+  "resolved_by": "admin",
+  "notes": "接受字段长度调整"
+}
+```
+
+### 错误信息
+
+| 错误信息 | 原因 |
+| --- | --- |
+| `无法确定需要更新的 meta_id` | 未提供且记录中无 old_meta.meta_id |
+
+---
+
+## action=reject_change(拒绝变动)
+
+### 请求示例
+
+```json
+{
+  "id": 2002,
+  "action": "reject_change",
+  "resolved_by": "admin",
+  "notes": "变动不合规,暂不更新"
+}
+```
+
+---
+
+## action=ignore(忽略)
+
+### 请求示例
+
+```json
+{
+  "id": 3001,
+  "action": "ignore",
+  "resolved_by": "admin"
+}
+```
+
+---
+
+## 公共错误信息
+
+| 错误信息 | 原因 |
+| --- | --- |
+| `请求数据格式错误,应为 JSON 对象` | 请求体不是有效的 JSON 对象 |
+| `id 不能为空` | 未提供审核记录ID |
+| `action 不能为空` | 未提供处理动作 |
+| `记录不存在` | 审核记录ID不存在 |
+| `记录已处理,无法重复处理` | 审核记录状态非 pending |
+| `不支持的action: xxx` | action 值不在允许列表中 |
+| `处理审核记录失败` | 服务器内部错误 |
+
+---
+
+## Vue 示例代码
+
+### 1. API 封装
+
+```javascript
+// api/metaReview.js
+import axios from 'axios'
+
+const API_BASE = '/api/meta'
+
+/**
+ * 处理审核记录
+ * @param {Object} params - 请求参数
+ * @param {number} params.id - 审核记录ID
+ * @param {string} params.action - 处理动作
+ * @param {Object} params.payload - 动作参数
+ * @param {string} params.resolved_by - 处理人
+ * @param {string} params.notes - 备注
+ * @returns {Promise}
+ */
+export function resolveReviewRecord(params) {
+  return axios.post(`${API_BASE}/review/resolve`, params)
+}
+
+/**
+ * 设置元数据别名关系
+ * @param {number} recordId - 审核记录ID
+ * @param {number} primaryMetaId - 主元数据ID
+ * @param {number} aliasMetaId - 别名元数据ID
+ * @param {string} resolvedBy - 处理人
+ * @param {string} notes - 备注
+ * @returns {Promise}
+ */
+export function setMetaAlias(recordId, primaryMetaId, aliasMetaId, resolvedBy, notes = '') {
+  return resolveReviewRecord({
+    id: recordId,
+    action: 'alias',
+    payload: {
+      primary_meta_id: primaryMetaId,
+      alias_meta_id: aliasMetaId
+    },
+    resolved_by: resolvedBy,
+    notes
+  })
+}
+
+/**
+ * 创建新元数据
+ * @param {number} recordId - 审核记录ID
+ * @param {string} newNameZh - 新元数据中文名
+ * @param {string} resolvedBy - 处理人
+ * @returns {Promise}
+ */
+export function createNewMeta(recordId, newNameZh, resolvedBy) {
+  return resolveReviewRecord({
+    id: recordId,
+    action: 'create_new',
+    payload: {
+      new_name_zh: newNameZh
+    },
+    resolved_by: resolvedBy
+  })
+}
+
+/**
+ * 接受元数据变动
+ * @param {number} recordId - 审核记录ID
+ * @param {number} metaId - 目标元数据ID(可选)
+ * @param {string} resolvedBy - 处理人
+ * @param {string} notes - 备注
+ * @returns {Promise}
+ */
+export function acceptChange(recordId, metaId, resolvedBy, notes = '') {
+  const payload = metaId ? { meta_id: metaId } : {}
+  return resolveReviewRecord({
+    id: recordId,
+    action: 'accept_change',
+    payload,
+    resolved_by: resolvedBy,
+    notes
+  })
+}
+
+/**
+ * 拒绝元数据变动
+ * @param {number} recordId - 审核记录ID
+ * @param {string} resolvedBy - 处理人
+ * @param {string} notes - 备注
+ * @returns {Promise}
+ */
+export function rejectChange(recordId, resolvedBy, notes = '') {
+  return resolveReviewRecord({
+    id: recordId,
+    action: 'reject_change',
+    resolved_by: resolvedBy,
+    notes
+  })
+}
+
+/**
+ * 忽略审核记录
+ * @param {number} recordId - 审核记录ID
+ * @param {string} resolvedBy - 处理人
+ * @returns {Promise}
+ */
+export function ignoreRecord(recordId, resolvedBy) {
+  return resolveReviewRecord({
+    id: recordId,
+    action: 'ignore',
+    resolved_by: resolvedBy
+  })
+}
+```
+
+### 2. 设置别名组件
+
+```vue
+<template>
+  <div class="alias-dialog">
+    <el-dialog
+      title="设置元数据别名"
+      :visible.sync="dialogVisible"
+      width="600px"
+      @close="handleClose"
+    >
+      <el-form
+        ref="formRef"
+        :model="form"
+        :rules="rules"
+        label-width="120px"
+      >
+        <el-form-item label="主元数据" prop="primaryMetaId">
+          <el-select
+            v-model="form.primaryMetaId"
+            placeholder="请选择主元数据"
+            filterable
+            style="width: 100%"
+          >
+            <el-option
+              v-for="item in candidateList"
+              :key="item.candidate_meta_id"
+              :label="`${item.name_zh} (${item.name_en || '-'})`"
+              :value="item.candidate_meta_id"
+            />
+          </el-select>
+          <div class="form-tip">选择作为主元数据的节点,其他节点将成为它的别名</div>
+        </el-form-item>
+
+        <el-form-item label="别名元数据" prop="aliasMetaId">
+          <el-select
+            v-model="form.aliasMetaId"
+            placeholder="请选择别名元数据"
+            filterable
+            style="width: 100%"
+          >
+            <el-option
+              v-for="item in candidateList"
+              :key="item.candidate_meta_id"
+              :label="`${item.name_zh} (${item.name_en || '-'})`"
+              :value="item.candidate_meta_id"
+              :disabled="item.candidate_meta_id === form.primaryMetaId"
+            />
+          </el-select>
+          <div class="form-tip">选择要降级为别名的元数据节点</div>
+        </el-form-item>
+
+        <el-form-item label="备注">
+          <el-input
+            v-model="form.notes"
+            type="textarea"
+            :rows="3"
+            placeholder="请输入处理备注(可选)"
+          />
+        </el-form-item>
+      </el-form>
+
+      <el-alert
+        v-if="form.aliasMetaId"
+        type="warning"
+        :closable="false"
+        style="margin-top: 16px"
+      >
+        <template #title>
+          <span>操作提示</span>
+        </template>
+        <div>
+          <p>执行此操作后:</p>
+          <ul>
+            <li>别名元数据将指向主元数据</li>
+            <li>原先指向别名元数据的所有 ALIAS 关系将转移到主元数据</li>
+            <li>业务领域的 INCLUDES 关系不受影响</li>
+          </ul>
+        </div>
+      </el-alert>
+
+      <template #footer>
+        <el-button @click="handleClose">取消</el-button>
+        <el-button
+          type="primary"
+          :loading="loading"
+          @click="handleSubmit"
+        >
+          确定
+        </el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { setMetaAlias } from '@/api/metaReview'
+
+export default {
+  name: 'AliasDialog',
+  props: {
+    visible: {
+      type: Boolean,
+      default: false
+    },
+    recordId: {
+      type: Number,
+      required: true
+    },
+    candidateList: {
+      type: Array,
+      default: () => []
+    }
+  },
+  data() {
+    return {
+      loading: false,
+      form: {
+        primaryMetaId: null,
+        aliasMetaId: null,
+        notes: ''
+      },
+      rules: {
+        primaryMetaId: [
+          { required: true, message: '请选择主元数据', trigger: 'change' }
+        ],
+        aliasMetaId: [
+          { required: true, message: '请选择别名元数据', trigger: 'change' },
+          {
+            validator: (rule, value, callback) => {
+              if (value && value === this.form.primaryMetaId) {
+                callback(new Error('主元数据和别名元数据不能相同'))
+              } else {
+                callback()
+              }
+            },
+            trigger: 'change'
+          }
+        ]
+      }
+    }
+  },
+  computed: {
+    dialogVisible: {
+      get() {
+        return this.visible
+      },
+      set(val) {
+        this.$emit('update:visible', val)
+      }
+    }
+  },
+  methods: {
+    handleClose() {
+      this.$refs.formRef?.resetFields()
+      this.form = {
+        primaryMetaId: null,
+        aliasMetaId: null,
+        notes: ''
+      }
+      this.dialogVisible = false
+    },
+
+    async handleSubmit() {
+      try {
+        await this.$refs.formRef.validate()
+      } catch {
+        return
+      }
+
+      this.loading = true
+      try {
+        const currentUser = this.$store.getters.userInfo?.username || 'unknown'
+        const res = await setMetaAlias(
+          this.recordId,
+          this.form.primaryMetaId,
+          this.form.aliasMetaId,
+          currentUser,
+          this.form.notes
+        )
+
+        if (res.data.code === 200) {
+          this.$message.success('别名关系设置成功')
+          this.$emit('success', res.data.data)
+          this.handleClose()
+        } else {
+          this.$message.error(res.data.message || '操作失败')
+        }
+      } catch (error) {
+        console.error('设置别名失败:', error)
+        this.$message.error(error.response?.data?.message || '网络错误,请稍后重试')
+      } finally {
+        this.loading = false
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.form-tip {
+  color: #909399;
+  font-size: 12px;
+  line-height: 1.5;
+  margin-top: 4px;
+}
+</style>
+```
+
+### 3. 审核详情页面示例
+
+```vue
+<template>
+  <div class="review-detail">
+    <el-card>
+      <template #header>
+        <div class="card-header">
+          <span>审核记录详情 #{{ record.id }}</span>
+          <el-tag :type="statusTagType">{{ statusText }}</el-tag>
+        </div>
+      </template>
+
+      <!-- 记录基本信息 -->
+      <el-descriptions :column="2" border>
+        <el-descriptions-item label="记录类型">
+          {{ record.record_type === 'redundancy' ? '疑似冗余' : '疑似变动' }}
+        </el-descriptions-item>
+        <el-descriptions-item label="业务领域ID">
+          {{ record.business_domain_id }}
+        </el-descriptions-item>
+        <el-descriptions-item label="创建时间">
+          {{ record.created_at }}
+        </el-descriptions-item>
+        <el-descriptions-item label="更新时间">
+          {{ record.updated_at }}
+        </el-descriptions-item>
+      </el-descriptions>
+
+      <!-- 新元数据信息 -->
+      <h4>新解析元数据</h4>
+      <el-descriptions :column="2" border>
+        <el-descriptions-item label="中文名">
+          {{ record.new_meta?.name_zh }}
+        </el-descriptions-item>
+        <el-descriptions-item label="英文名">
+          {{ record.new_meta?.name_en }}
+        </el-descriptions-item>
+        <el-descriptions-item label="数据类型">
+          {{ record.new_meta?.data_type }}
+        </el-descriptions-item>
+      </el-descriptions>
+
+      <!-- 冗余场景:候选列表 -->
+      <template v-if="record.record_type === 'redundancy'">
+        <h4>候选元数据列表</h4>
+        <el-table :data="record.candidates" border>
+          <el-table-column prop="candidate_meta_id" label="ID" width="80" />
+          <el-table-column prop="name_zh" label="中文名" />
+          <el-table-column prop="name_en" label="英文名" />
+          <el-table-column prop="data_type" label="数据类型" />
+          <el-table-column label="差异字段">
+            <template #default="{ row }">
+              <el-tag
+                v-for="field in row.diff_fields"
+                :key="field"
+                size="small"
+                style="margin-right: 4px"
+              >
+                {{ field }}
+              </el-tag>
+            </template>
+          </el-table-column>
+        </el-table>
+      </template>
+
+      <!-- 操作按钮 -->
+      <div v-if="record.status === 'pending'" class="action-buttons">
+        <template v-if="record.record_type === 'redundancy'">
+          <el-button type="primary" @click="showAliasDialog = true">
+            设为别名
+          </el-button>
+          <el-button type="success" @click="showCreateDialog = true">
+            创建新元数据
+          </el-button>
+        </template>
+        <template v-else>
+          <el-button type="primary" @click="handleAcceptChange">
+            接受变动
+          </el-button>
+          <el-button type="warning" @click="handleRejectChange">
+            拒绝变动
+          </el-button>
+        </template>
+        <el-button @click="handleIgnore">忽略</el-button>
+      </div>
+    </el-card>
+
+    <!-- 别名设置弹窗 -->
+    <alias-dialog
+      :visible.sync="showAliasDialog"
+      :record-id="record.id"
+      :candidate-list="record.candidates"
+      @success="handleSuccess"
+    />
+  </div>
+</template>
+
+<script>
+import AliasDialog from './AliasDialog.vue'
+import { acceptChange, rejectChange, ignoreRecord } from '@/api/metaReview'
+
+export default {
+  name: 'ReviewDetail',
+  components: { AliasDialog },
+  props: {
+    record: {
+      type: Object,
+      required: true
+    }
+  },
+  data() {
+    return {
+      showAliasDialog: false,
+      showCreateDialog: false
+    }
+  },
+  computed: {
+    statusTagType() {
+      const map = {
+        pending: 'warning',
+        resolved: 'success',
+        ignored: 'info'
+      }
+      return map[this.record.status] || 'info'
+    },
+    statusText() {
+      const map = {
+        pending: '待处理',
+        resolved: '已处理',
+        ignored: '已忽略'
+      }
+      return map[this.record.status] || this.record.status
+    }
+  },
+  methods: {
+    getCurrentUser() {
+      return this.$store.getters.userInfo?.username || 'unknown'
+    },
+
+    async handleAcceptChange() {
+      try {
+        await this.$confirm('确定接受此变动吗?', '提示', {
+          type: 'warning'
+        })
+        const res = await acceptChange(
+          this.record.id,
+          this.record.old_meta?.meta_id,
+          this.getCurrentUser()
+        )
+        if (res.data.code === 200) {
+          this.$message.success('操作成功')
+          this.$emit('refresh')
+        } else {
+          this.$message.error(res.data.message)
+        }
+      } catch (e) {
+        if (e !== 'cancel') {
+          this.$message.error('操作失败')
+        }
+      }
+    },
+
+    async handleRejectChange() {
+      try {
+        await this.$confirm('确定拒绝此变动吗?', '提示', {
+          type: 'warning'
+        })
+        const res = await rejectChange(this.record.id, this.getCurrentUser())
+        if (res.data.code === 200) {
+          this.$message.success('操作成功')
+          this.$emit('refresh')
+        } else {
+          this.$message.error(res.data.message)
+        }
+      } catch (e) {
+        if (e !== 'cancel') {
+          this.$message.error('操作失败')
+        }
+      }
+    },
+
+    async handleIgnore() {
+      try {
+        await this.$confirm('确定忽略此记录吗?', '提示', {
+          type: 'warning'
+        })
+        const res = await ignoreRecord(this.record.id, this.getCurrentUser())
+        if (res.data.code === 200) {
+          this.$message.success('操作成功')
+          this.$emit('refresh')
+        } else {
+          this.$message.error(res.data.message)
+        }
+      } catch (e) {
+        if (e !== 'cancel') {
+          this.$message.error('操作失败')
+        }
+      }
+    },
+
+    handleSuccess() {
+      this.$emit('refresh')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+h4 {
+  margin: 20px 0 10px;
+  color: #303133;
+}
+
+.action-buttons {
+  margin-top: 24px;
+  padding-top: 16px;
+  border-top: 1px solid #ebeef5;
+}
+
+.action-buttons .el-button {
+  margin-right: 12px;
+}
+</style>
+```
+
+---
+
+## 注意事项
+
+1. **状态限制**:只有 `status=pending` 的记录才能处理,已处理记录会返回错误。
+
+2. **参数验证**:前端应在提交前验证必填参数,避免无效请求。
+
+3. **别名操作不可逆**:alias 操作会修改 Neo4j 中的关系结构,请确保用户确认后再执行。
+
+4. **ID 类型**:所有 ID 参数(record_id、primary_meta_id、alias_meta_id、meta_id)应为整数。
+
+5. **错误处理**:建议统一封装 axios 拦截器处理错误响应,对 `code !== 200` 的情况进行统一提示。
+
+---
+
+## 更新日志
+
+| 日期 | 版本 | 更新内容 |
+| --- | --- | --- |
+| 2025-01-09 | v2.0 | `action=alias` 参数变更:使用 `primary_meta_id` 和 `alias_meta_id` 替代原 `candidate_meta_id`,支持 ALIAS 关系重建 |

+ 16 - 29
docs/business_domain_api.md

@@ -783,7 +783,9 @@ const { data } = await axios.post('/api/bd/search', {
 
 ### 11. 组合创建业务领域
 
-从已有的业务领域和元数据中组合创建新的业务领域。
+从已有的元数据中组合创建新的业务领域。
+
+> 📖 详细开发指南请参考:[api_bd_compose_guide.md](./api_bd_compose_guide.md)
 
 **请求URL:** `POST /api/bd/compose`
 
@@ -793,25 +795,23 @@ const { data } = await axios.post('/api/bd/search', {
 |--------|------|------|------|
 | name_zh | string | 是 | 中文名称 |
 | name_en | string | 否 | 英文名称(不提供则自动翻译) |
-| id_list | array | 是 | 关联的业务领域和元数据列表 |
+| id_list | array | 是 | 选中的元数据ID列表 |
 | describe | string | 否 | 描述 |
 | type | string | 否 | 类型 |
 | category | string | 否 | 分类 |
-| tag | integer | 否 | 标签ID |
+| tag | integer/array | 否 | 标签ID或标签ID数组 |
 | data_source | integer | 否 | 数据源ID |
 
 **id_list 格式:**
 
+支持两种格式:
+
 ```json
-[
-  {
-    "domain_id": 123,
-    "metaData": [
-      { "id": 456 },
-      { "id": 789 }
-    ]
-  }
-]
+// 格式一:纯ID数组(推荐)
+[123, 456, 789]
+
+// 格式二:对象数组
+[{ "id": 123 }, { "id": 456 }, { "id": 789 }]
 ```
 
 **请求示例:**
@@ -821,21 +821,7 @@ const { data } = await axios.post('/api/bd/compose', {
   name_zh: "销售分析域",
   name_en: "sales_analysis",
   describe: "整合订单和客户数据的销售分析业务领域",
-  id_list: [
-    {
-      domain_id: 12345,
-      metaData: [
-        { id: 23456 },
-        { id: 23457 }
-      ]
-    },
-    {
-      domain_id: 12346,
-      metaData: [
-        { id: 23458 }
-      ]
-    }
-  ]
+  id_list: [23456, 23457, 23458]  // 直接传递元数据ID列表
 });
 ```
 
@@ -851,7 +837,8 @@ const { data } = await axios.post('/api/bd/compose', {
       "name_zh": "销售分析域",
       "name_en": "sales_analysis",
       "describe": "整合订单和客户数据的销售分析业务领域",
-      "create_time": "2024-01-17 11:00:00"
+      "create_time": "2025-01-09 11:00:00",
+      "tag": []
     }
   }
 }
@@ -863,7 +850,7 @@ const { data } = await axios.post('/api/bd/compose', {
 |------|---------|------|
 | 500 | 请求数据不能为空 | 未提供请求体 |
 | 500 | name_zh 为必填项 | 缺少中文名称 |
-| 500 | id_list 为必填项 | 缺少关联列表 |
+| 500 | id_list 为必填项 | 缺少元数据ID列表 |
 
 ---
 

+ 199 - 0
docs/meta_data_add_optimization.md

@@ -0,0 +1,199 @@
+# 元数据新增接口优化文档
+
+## 优化时间
+2026-01-12
+
+## 优化目标
+优化 `meta_node_add` 接口的冗余检测和处理逻辑,改进疑似重复元数据的处理流程。
+
+## 优化内容
+
+### 1. 完全匹配处理(保持不变)
+- **行为**:如果检测到完全匹配的元数据(name_zh、name_en、data_type、tag_ids 全部相同)
+- **操作**:直接返回失败提示,不创建任何节点
+- **返回信息**:提示元数据已存在,返回已存在的元数据ID
+
+### 2. 疑似重复处理(优化重点)
+- **旧逻辑**:
+  - 检测到疑似重复 → 立即创建审核记录 → 返回失败,不创建节点
+  - 用户需要使用 `force_create=true` 强制创建
+
+- **新逻辑**:
+  - 检测到疑似重复 → **先创建新元数据节点** → 创建审核记录(包含新节点ID和疑似重复候选) → 返回成功,提示前往审核页面处理
+  - 新元数据节点已创建,用户可以立即使用
+  - 审核记录中包含新创建的节点ID和所有疑似重复的候选元数据
+
+### 3. 无重复处理(保持不变)
+- **行为**:未检测到任何重复
+- **操作**:直接创建新节点,正常返回
+
+## 代码变更
+
+### 1. `app/api/meta_data/routes.py` - `meta_node_add` 函数
+
+#### 变更点 1:冗余检测逻辑调整
+```python
+# 旧代码:检测到疑似重复时直接返回失败
+if redundancy_result["review_created"]:
+    return jsonify(failed("发现疑似重复元数据,已创建审核记录..."))
+
+# 新代码:检测到疑似重复时标记状态,继续创建节点
+if redundancy_result["has_candidates"]:
+    has_suspicious_duplicates = True
+    suspicious_candidates = redundancy_result["candidates"]
+```
+
+#### 变更点 2:节点创建后处理疑似重复
+```python
+# 节点创建成功后
+if has_suspicious_duplicates and suspicious_candidates:
+    # 构建新元数据快照(包含新创建的节点ID)
+    new_meta_snapshot = {
+        "id": node_data["id"],  # 新创建的节点ID
+        "name_zh": node_name_zh,
+        "name_en": node_name_en or "",
+        "data_type": node_type,
+        "tag_ids": tag_ids,
+    }
+    
+    # 写入审核记录
+    write_redundancy_review_record_with_new_id(
+        new_meta=new_meta_snapshot,
+        candidates=suspicious_candidates,
+        source="api",
+    )
+    
+    # 返回成功,但提示疑似重复
+    return jsonify(success(node_data, message="元数据创建成功,但发现疑似重复..."))
+```
+
+### 2. `app/core/meta_data/redundancy_check.py`
+
+#### 变更点 1:`check_redundancy_for_add` 函数
+- **旧逻辑**:检测到疑似重复时自动创建审核记录
+- **新逻辑**:只进行检测,返回候选列表,不创建审核记录
+- **返回值变化**:移除 `review_created` 字段
+
+```python
+# 旧代码
+write_redundancy_review_record(new_meta, candidates, source="api")
+return {
+    "has_exact_match": False,
+    "exact_match_id": None,
+    "has_candidates": True,
+    "candidates": candidates,
+    "review_created": True,  # 已创建审核记录
+}
+
+# 新代码
+return {
+    "has_exact_match": False,
+    "exact_match_id": None,
+    "has_candidates": True,
+    "candidates": candidates,  # 只返回候选列表,不创建审核记录
+}
+```
+
+#### 变更点 2:新增 `write_redundancy_review_record_with_new_id` 函数
+- **用途**:在新元数据节点已创建后写入审核记录
+- **与原函数的区别**:new_meta 参数中包含已创建的节点ID
+
+```python
+def write_redundancy_review_record_with_new_id(
+    new_meta: Dict[str, Any],  # 包含已创建的节点ID
+    candidates: List[Dict[str, Any]],
+    source: str = "api",
+) -> None:
+    """
+    写入疑似冗余审核记录到 PostgreSQL(新元数据已创建,包含ID)
+    """
+    # 实现逻辑与 write_redundancy_review_record 相同
+    # 区别在于 new_meta 中包含 id 字段
+```
+
+## 业务流程对比
+
+### 旧流程
+```
+用户提交新增请求
+  ↓
+冗余检测
+  ↓
+发现疑似重复
+  ↓
+创建审核记录(不包含新节点ID)
+  ↓
+返回失败,提示使用 force_create=true
+  ↓
+用户需要再次提交请求(force_create=true)
+  ↓
+创建节点
+```
+
+### 新流程
+```
+用户提交新增请求
+  ↓
+冗余检测
+  ↓
+发现疑似重复
+  ↓
+创建新元数据节点
+  ↓
+创建审核记录(包含新节点ID + 疑似重复候选)
+  ↓
+返回成功,提示前往审核页面
+  ↓
+用户可以立即使用新节点,同时在审核页面处理重复问题
+```
+
+## 优势分析
+
+### 1. 用户体验改善
+- **旧方式**:需要两次操作(第一次失败 → 强制创建)
+- **新方式**:一次操作即可完成,节点立即可用
+
+### 2. 审核记录更完整
+- **旧方式**:审核记录中 new_meta 没有 ID,无法直接定位新创建的节点
+- **新方式**:审核记录中包含新节点ID,便于后续处理(如合并、删除等)
+
+### 3. 业务连续性
+- **旧方式**:发现疑似重复时,业务流程被中断
+- **新方式**:业务流程不中断,新节点立即可用,审核可以异步处理
+
+### 4. 灵活的后续处理
+审核人员可以根据实际情况选择:
+- 如果确认是重复:删除新节点,使用已有节点
+- 如果确认不重复:标记审核记录为已处理,保留新节点
+- 如果需要合并:合并新旧节点,更新关联关系
+
+## 注意事项
+
+1. **force_create 参数保留**:仍然支持 `force_create=true` 跳过冗余检测
+2. **完全匹配仍然拦截**:如果是完全匹配,仍然不创建新节点
+3. **审核记录格式兼容**:新的审核记录格式与旧格式兼容,只是 new_meta 中多了 id 字段
+4. **数据库事务**:审核记录写入失败不影响节点创建(已在 try-catch 外)
+
+## 测试建议
+
+### 测试场景 1:完全匹配
+- **输入**:与已有元数据完全相同的数据
+- **预期**:返回失败,提示元数据已存在,不创建新节点
+
+### 测试场景 2:疑似重复
+- **输入**:name_zh 相同但其他字段不同的数据
+- **预期**:创建新节点,创建审核记录,返回成功并提示疑似重复
+
+### 测试场景 3:无重复
+- **输入**:完全新的元数据
+- **预期**:创建新节点,不创建审核记录,正常返回
+
+### 测试场景 4:强制创建
+- **输入**:force_create=true
+- **预期**:跳过冗余检测,直接创建节点
+
+## 相关文件
+
+- `app/api/meta_data/routes.py` - API 路由层
+- `app/core/meta_data/redundancy_check.py` - 冗余检测核心逻辑
+- `app/models/metadata_review.py` - 审核记录数据模型

+ 201 - 0
docs/timezone_fix_summary.md

@@ -0,0 +1,201 @@
+# 时区修正总结
+
+## 修正目标
+
+将 `data_orders` 表的三个 timestamp 字段(`created_at`、`updated_at`、`processed_at`)以及其他相关表的时间字段,从使用 UTC 时间改为使用东八区(Asia/Shanghai)时间。
+
+## 修改内容
+
+### 1. 创建时区工具模块
+
+**文件**: `app/core/common/timezone_utils.py`
+
+创建了统一的时区处理工具模块,提供以下功能:
+
+- `now_china()`: 获取当前东八区时间(带时区信息)
+- `now_china_naive()`: 获取当前东八区时间(不带时区信息,用于数据库存储)
+- `to_china_time()`: 将任意时区的时间转换为东八区时间
+- `utc_to_china_naive()`: 将 UTC 时间转换为东八区时间(不带时区信息)
+
+### 2. 修正数据模型
+
+#### 2.1 `app/models/data_product.py`
+
+**DataProduct 模型**:
+- `created_at`: `datetime.utcnow` → `now_china_naive`
+- `updated_at`: `datetime.utcnow` → `now_china_naive`
+- `mark_as_viewed()`: 使用 `now_china_naive()`
+- `update_data_stats()`: 使用 `now_china_naive()`
+
+**DataOrder 模型**:
+- `created_at`: `datetime.utcnow` → `now_china_naive`
+- `updated_at`: `datetime.utcnow` → `now_china_naive`
+- `processed_at`: 默认值保持 None,但在更新时使用 `now_china_naive()`
+- `update_status()`: 使用 `now_china_naive()`
+- `set_extraction_result()`: 使用 `now_china_naive()`
+- `set_graph_analysis()`: 使用 `now_china_naive()`
+- `set_result()`: 使用 `now_china_naive()`
+- `reject()`: 使用 `now_china_naive()`
+
+#### 2.2 `app/models/metadata_review.py`
+
+**MetadataReviewRecord 模型**:
+- `created_at`: `datetime.utcnow` → `now_china_naive`
+- `updated_at`: `datetime.utcnow` → `now_china_naive`
+
+**MetadataVersionHistory 模型**:
+- `created_at`: `datetime.utcnow` → `now_china_naive`
+
+**update_review_record_resolution 函数**:
+- `resolved_at`: 使用 `now_china_naive()`
+- `updated_at`: 使用 `now_china_naive()`
+
+### 3. 修正服务层
+
+#### 3.1 `app/core/data_service/data_product_service.py`
+
+修正了所有使用 `datetime.utcnow()` 的地方:
+- `register_product()`: 更新现有产品和创建新产品时使用 `now_china_naive()`
+- `update_product_stats()`: 使用 `now_china_naive()`
+- `refresh_product_stats()`: 使用 `now_china_naive()`
+- `update_order()`: 使用 `now_china_naive()`
+
+#### 3.2 `app/core/meta_data/redundancy_check.py`
+
+修正了创建审核记录时的时间字段:
+- `created_at`: 使用 `now_china_naive()`
+- `updated_at`: 使用 `now_china_naive()`
+
+#### 3.3 `app/core/business_domain/business_domain.py`
+
+修正了创建审核记录时的时间字段:
+- `created_at`: 使用 `now_china_naive()`
+- `updated_at`: 使用 `now_china_naive()`
+
+## 技术实现
+
+### 使用 zoneinfo 而非 pytz
+
+本次修正使用了 `zoneinfo` 模块,而不是第三方库 `pytz`。理由如下:
+
+1. **标准库支持**: Python 3.9+ 内置 `zoneinfo`,Python 3.8 使用 `backports.zoneinfo`
+2. **更简洁的 API**: 相比 `pytz`,`zoneinfo` 的 API 更符合 Python 的 datetime 使用习惯
+3. **自动更新**: 使用系统的 IANA 时区数据库,自动获取最新的时区规则
+
+### Python 版本兼容性
+
+代码已兼容 Python 3.8 和 3.9+:
+
+```python
+try:
+    # Python 3.9+
+    from zoneinfo import ZoneInfo
+except ImportError:
+    # Python 3.8 使用 backports
+    from backports.zoneinfo import ZoneInfo
+```
+
+**依赖要求**:
+- Python 3.8: 需要安装 `backports.zoneinfo` 包
+- Python 3.9+: 使用标准库,无需额外安装
+- 所有版本: 需要系统安装 `tzdata` 包
+
+### 数据库存储策略
+
+采用 **naive datetime** 存储策略:
+- 数据库中存储不带时区信息的 datetime(naive datetime)
+- 所有时间统一使用东八区时间
+- 在应用层确保时间的一致性
+
+这种策略的优点:
+1. 数据库字段类型简单(TIMESTAMP 而非 TIMESTAMP WITH TIME ZONE)
+2. 避免时区转换的复杂性
+3. 符合项目现有的数据库设计
+
+## 影响范围
+
+### 直接影响的表
+
+1. **data_orders**: `created_at`, `updated_at`, `processed_at`
+2. **data_products**: `created_at`, `updated_at`, `last_updated_at`, `last_viewed_at`
+3. **metadata_review_records**: `created_at`, `updated_at`, `resolved_at`
+4. **metadata_version_history**: `created_at`
+
+### 间接影响
+
+所有依赖这些时间字段的业务逻辑都将使用东八区时间,包括:
+- 数据订单的创建、更新、处理流程
+- 数据产品的注册、更新、查看记录
+- 元数据审核记录的创建和解决
+
+## 向后兼容性
+
+### 已有数据
+
+数据库中已存在的记录可能使用的是 UTC 时间。建议:
+
+1. **新旧数据混合期**: 在一段时间内,数据库中会同时存在 UTC 时间和东八区时间的记录
+2. **数据迁移**(可选): 如果需要统一历史数据,可以执行以下 SQL:
+
+```sql
+-- 将 UTC 时间转换为东八区时间(+8小时)
+UPDATE data_orders 
+SET created_at = created_at + INTERVAL '8 hours',
+    updated_at = updated_at + INTERVAL '8 hours',
+    processed_at = processed_at + INTERVAL '8 hours'
+WHERE created_at < '2026-01-12 00:00:00';  -- 修正前的数据
+
+UPDATE data_products
+SET created_at = created_at + INTERVAL '8 hours',
+    updated_at = updated_at + INTERVAL '8 hours',
+    last_updated_at = last_updated_at + INTERVAL '8 hours',
+    last_viewed_at = last_viewed_at + INTERVAL '8 hours'
+WHERE created_at < '2026-01-12 00:00:00';
+
+UPDATE metadata_review_records
+SET created_at = created_at + INTERVAL '8 hours',
+    updated_at = updated_at + INTERVAL '8 hours',
+    resolved_at = resolved_at + INTERVAL '8 hours'
+WHERE created_at < '2026-01-12 00:00:00';
+
+UPDATE metadata_version_history
+SET created_at = created_at + INTERVAL '8 hours'
+WHERE created_at < '2026-01-12 00:00:00';
+```
+
+### API 响应
+
+所有 API 返回的时间字段(ISO 8601 格式)现在表示的是东八区时间,而不是 UTC 时间。
+
+## 测试建议
+
+1. **创建新订单**: 验证 `created_at` 字段是否为东八区当前时间
+2. **更新订单状态**: 验证 `updated_at` 和 `processed_at` 是否正确
+3. **注册数据产品**: 验证时间字段是否正确
+4. **查看数据产品**: 验证 `last_viewed_at` 是否更新为东八区时间
+5. **元数据审核**: 验证审核记录的时间字段
+
+## 符合业务规则
+
+本次修正符合 `BUSINESS_RULES.md` 中的规定:
+
+```python
+# Use East Asia timezone for all timestamps
+from datetime import datetime
+import pytz
+```
+
+虽然业务规则中提到了 `pytz`,但我们使用了更现代的 `zoneinfo` 标准库,功能等价且更优。
+
+## 总结
+
+✅ 所有相关的时间字段已统一使用东八区时间  
+✅ 创建了统一的时区工具模块,便于后续维护  
+✅ 修正了 6 个文件,涉及模型层和服务层  
+✅ 代码通过了 linter 检查,无语法错误  
+✅ 符合项目的业务规则要求  
+
+---
+
+**修正日期**: 2026-01-12  
+**修正人**: AI Assistant

+ 271 - 0
scripts/TROUBLESHOOTING.md

@@ -0,0 +1,271 @@
+# DataOps Platform 故障排查指南
+
+## 问题:应用启动失败,日志文件不存在
+
+### 症状
+```bash
+[ERROR] dataops-platform 重启失败!
+tail: cannot open '/opt/dataops-platform/logs/gunicorn_error.log' for reading: No such file or directory
+```
+
+### 可能原因
+
+1. **时区模块问题(最常见)**
+   - Python 3.9+ 使用 `zoneinfo` 模块需要系统时区数据
+   - 缺少 `tzdata` 包会导致应用无法启动
+   - 我们的代码修改引入了 `from zoneinfo import ZoneInfo`
+
+2. **日志目录不存在**
+   - `/opt/dataops-platform/logs` 目录未创建
+   - 权限问题导致无法写入日志
+
+3. **虚拟环境问题**
+   - Python 依赖缺失
+   - 虚拟环境损坏
+
+4. **配置文件问题**
+   - Supervisor 配置错误
+   - 环境变量缺失
+
+## 快速修复步骤
+
+### 方法 1: 使用自动修复脚本(推荐)
+
+```bash
+cd /opt/dataops-platform/scripts
+sudo chmod +x fix_startup.sh
+sudo ./fix_startup.sh
+```
+
+这个脚本会自动:
+- 创建日志目录
+- 安装 tzdata(时区数据)
+- 测试 Python 环境和时区模块
+- 修复文件权限
+- 重新加载 Supervisor 配置
+- 启动应用并进行健康检查
+
+### 方法 2: 使用诊断脚本
+
+如果自动修复失败,先运行诊断脚本查看详细问题:
+
+```bash
+cd /opt/dataops-platform/scripts
+sudo chmod +x diagnose_issue.sh
+sudo ./diagnose_issue.sh
+```
+
+诊断脚本会检查:
+- 目录结构
+- Supervisor 配置
+- Python 环境
+- 应用导入
+- 日志文件
+- 端口占用
+- 配置文件
+
+### 方法 3: 手动修复
+
+#### 步骤 1: 安装时区数据(最重要)
+
+```bash
+sudo apt-get update
+sudo apt-get install -y tzdata
+```
+
+#### 步骤 2: 创建日志目录
+
+```bash
+sudo mkdir -p /opt/dataops-platform/logs
+sudo chown ubuntu:ubuntu /opt/dataops-platform/logs
+```
+
+#### 步骤 3: 测试 Python 环境
+
+```bash
+cd /opt/dataops-platform
+source venv/bin/activate
+python -c "
+try:
+    from zoneinfo import ZoneInfo
+except ImportError:
+    from backports.zoneinfo import ZoneInfo
+print('时区模块正常')
+"
+```
+
+如果报错,安装 backports(Python 3.8 需要):
+```bash
+pip install backports.zoneinfo
+```
+
+#### 步骤 4: 测试应用导入
+
+```bash
+cd /opt/dataops-platform
+source venv/bin/activate
+python -c "from app import create_app; app = create_app(); print('应用导入成功')"
+```
+
+#### 步骤 5: 重启服务
+
+```bash
+sudo supervisorctl reread
+sudo supervisorctl update
+sudo supervisorctl restart dataops-platform
+```
+
+## 查看日志
+
+### Supervisor 日志(最有用)
+
+```bash
+# 查看 stderr(错误输出)
+sudo tail -f /var/log/supervisor/dataops-platform-stderr.log
+
+# 查看 stdout(标准输出)
+sudo tail -f /var/log/supervisor/dataops-platform-stdout.log
+```
+
+### 应用日志
+
+```bash
+# Gunicorn 错误日志
+tail -f /opt/dataops-platform/logs/gunicorn_error.log
+
+# Gunicorn 访问日志
+tail -f /opt/dataops-platform/logs/gunicorn_access.log
+```
+
+### Supervisor 主日志
+
+```bash
+sudo tail -f /var/log/supervisor/supervisord.log
+```
+
+## 常见错误及解决方案
+
+### 错误 1: ModuleNotFoundError: No module named 'zoneinfo'
+
+**原因**: Python < 3.9 需要使用 backports.zoneinfo
+
+**解决方案**:
+```bash
+# 检查 Python 版本
+python --version
+
+# Python 3.8 需要安装 backports.zoneinfo
+cd /opt/dataops-platform
+source venv/bin/activate
+pip install backports.zoneinfo
+
+# 同时安装系统时区数据
+sudo apt-get update
+sudo apt-get install -y tzdata
+```
+
+### 错误 2: ZoneInfoNotFoundError: 'No time zone found with key Asia/Shanghai'
+
+**原因**: 系统缺少时区数据库
+
+**解决方案**:
+```bash
+sudo apt-get update
+sudo apt-get install -y tzdata
+```
+
+### 错误 3: Permission denied
+
+**原因**: 文件权限问题
+
+**解决方案**:
+```bash
+sudo chown -R ubuntu:ubuntu /opt/dataops-platform
+sudo chmod -R 755 /opt/dataops-platform/scripts
+```
+
+### 错误 4: Address already in use
+
+**原因**: 端口 5500 被占用
+
+**解决方案**:
+```bash
+# 查找占用端口的进程
+sudo netstat -tlnp | grep :5500
+
+# 或使用 lsof
+sudo lsof -i :5500
+
+# 停止旧进程
+sudo supervisorctl stop dataops-platform
+sudo pkill -f "gunicorn.*dataops"
+```
+
+## 手动启动测试
+
+如果 Supervisor 启动失败,可以手动启动应用进行测试:
+
+```bash
+cd /opt/dataops-platform
+source venv/bin/activate
+gunicorn -c gunicorn_config.py 'app:create_app()'
+```
+
+这样可以直接看到启动时的错误信息。
+
+## 验证修复
+
+启动成功后,进行以下验证:
+
+### 1. 检查进程状态
+
+```bash
+sudo supervisorctl status dataops-platform
+```
+
+应该显示 `RUNNING`
+
+### 2. 检查端口
+
+```bash
+sudo netstat -tlnp | grep :5500
+```
+
+应该看到 gunicorn 进程监听 5500 端口
+
+### 3. 健康检查
+
+```bash
+curl http://localhost:5500/api/system/health
+```
+
+应该返回 200 状态码
+
+### 4. 测试时区
+
+```bash
+cd /opt/dataops-platform
+source venv/bin/activate
+python -c "from app.core.common.timezone_utils import now_china_naive; print(now_china_naive())"
+```
+
+应该输出当前东八区时间
+
+## 联系支持
+
+如果以上方法都无法解决问题,请提供以下信息:
+
+1. 诊断脚本输出:`sudo ./diagnose_issue.sh > diagnosis.log 2>&1`
+2. Supervisor stderr 日志:`sudo tail -100 /var/log/supervisor/dataops-platform-stderr.log`
+3. Python 版本:`python --version`
+4. 系统版本:`cat /etc/os-release`
+
+## 相关文件
+
+- 部署脚本:`deploy_dataops.sh`
+- 启动脚本:`start_dataops.sh`
+- 重启脚本:`restart_dataops.sh`
+- 诊断脚本:`diagnose_issue.sh`
+- 修复脚本:`fix_startup.sh`
+- Supervisor 配置:`/etc/supervisor/conf.d/dataops-platform.conf`
+- Gunicorn 配置:`/opt/dataops-platform/gunicorn_config.py`

+ 266 - 0
scripts/diagnose_issue.sh

@@ -0,0 +1,266 @@
+#!/bin/bash
+#
+# DataOps Platform 问题诊断脚本
+# 用于排查启动失败的原因
+#
+
+# 配置变量
+APP_NAME="dataops-platform"
+APP_DIR="/opt/dataops-platform"
+VENV_DIR="${APP_DIR}/venv"
+LOG_DIR="${APP_DIR}/logs"
+
+# 颜色输出
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+echo_info() {
+    echo -e "${GREEN}[INFO]${NC} $1"
+}
+
+echo_warn() {
+    echo -e "${YELLOW}[WARN]${NC} $1"
+}
+
+echo_error() {
+    echo -e "${RED}[ERROR]${NC} $1"
+}
+
+echo_section() {
+    echo -e "\n${BLUE}========================================${NC}"
+    echo -e "${BLUE}  $1${NC}"
+    echo -e "${BLUE}========================================${NC}"
+}
+
+# 1. 检查目录结构
+check_directories() {
+    echo_section "1. 检查目录结构"
+    
+    echo_info "应用目录: ${APP_DIR}"
+    if [ -d "${APP_DIR}" ]; then
+        echo_info "✓ 应用目录存在"
+        ls -la "${APP_DIR}" | head -20
+    else
+        echo_error "✗ 应用目录不存在"
+    fi
+    
+    echo ""
+    echo_info "虚拟环境: ${VENV_DIR}"
+    if [ -d "${VENV_DIR}" ]; then
+        echo_info "✓ 虚拟环境存在"
+    else
+        echo_error "✗ 虚拟环境不存在"
+    fi
+    
+    echo ""
+    echo_info "日志目录: ${LOG_DIR}"
+    if [ -d "${LOG_DIR}" ]; then
+        echo_info "✓ 日志目录存在"
+        ls -la "${LOG_DIR}"
+    else
+        echo_error "✗ 日志目录不存在,正在创建..."
+        sudo mkdir -p "${LOG_DIR}"
+        sudo chown ubuntu:ubuntu "${LOG_DIR}"
+    fi
+}
+
+# 2. 检查 supervisor 配置
+check_supervisor() {
+    echo_section "2. 检查 Supervisor 配置"
+    
+    echo_info "Supervisor 配置文件:"
+    if [ -f "/etc/supervisor/conf.d/${APP_NAME}.conf" ]; then
+        echo_info "✓ 配置文件存在"
+        cat "/etc/supervisor/conf.d/${APP_NAME}.conf"
+    else
+        echo_error "✗ 配置文件不存在: /etc/supervisor/conf.d/${APP_NAME}.conf"
+    fi
+    
+    echo ""
+    echo_info "Supervisord 进程状态:"
+    if pgrep -x "supervisord" > /dev/null; then
+        echo_info "✓ supervisord 正在运行"
+        ps aux | grep supervisord | grep -v grep
+    else
+        echo_error "✗ supervisord 未运行"
+    fi
+    
+    echo ""
+    echo_info "应用状态:"
+    sudo supervisorctl status ${APP_NAME} || echo_error "无法获取应用状态"
+}
+
+# 3. 检查 Python 环境
+check_python() {
+    echo_section "3. 检查 Python 环境"
+    
+    if [ -f "${VENV_DIR}/bin/python" ]; then
+        echo_info "Python 版本:"
+        ${VENV_DIR}/bin/python --version
+        
+        echo ""
+        echo_info "检查关键依赖:"
+        ${VENV_DIR}/bin/python -c "import flask; print(f'Flask: {flask.__version__}')" 2>&1
+        ${VENV_DIR}/bin/python -c "import gunicorn; print(f'Gunicorn: {gunicorn.__version__}')" 2>&1
+        
+        echo ""
+        echo_info "检查 zoneinfo (时区模块):"
+        ${VENV_DIR}/bin/python -c "
+try:
+    from zoneinfo import ZoneInfo
+    print('✓ 使用标准库 zoneinfo')
+except ImportError:
+    from backports.zoneinfo import ZoneInfo
+    print('✓ 使用 backports.zoneinfo (Python 3.8)')
+tz = ZoneInfo('Asia/Shanghai')
+print(f'✓ 东八区时区加载成功: {tz}')
+" 2>&1 || echo_error "✗ zoneinfo 不可用或时区数据缺失"
+    else
+        echo_error "Python 虚拟环境不存在"
+    fi
+}
+
+# 4. 测试应用导入
+test_app_import() {
+    echo_section "4. 测试应用导入"
+    
+    echo_info "尝试导入应用模块..."
+    cd "${APP_DIR}"
+    ${VENV_DIR}/bin/python -c "
+import sys
+sys.path.insert(0, '${APP_DIR}')
+try:
+    from app import create_app
+    print('✓ 应用模块导入成功')
+    app = create_app()
+    print('✓ 应用实例创建成功')
+except Exception as e:
+    print(f'✗ 导入失败: {e}')
+    import traceback
+    traceback.print_exc()
+" 2>&1
+}
+
+# 5. 检查日志文件
+check_logs() {
+    echo_section "5. 检查日志文件"
+    
+    echo_info "Supervisor 日志:"
+    if [ -f "/var/log/supervisor/supervisord.log" ]; then
+        echo_info "最近 20 行:"
+        sudo tail -20 /var/log/supervisor/supervisord.log
+    else
+        echo_warn "日志文件不存在"
+    fi
+    
+    echo ""
+    echo_info "应用错误日志:"
+    if [ -f "${LOG_DIR}/gunicorn_error.log" ]; then
+        echo_info "最近 30 行:"
+        tail -30 "${LOG_DIR}/gunicorn_error.log"
+    else
+        echo_warn "应用错误日志不存在: ${LOG_DIR}/gunicorn_error.log"
+    fi
+    
+    echo ""
+    echo_info "应用访问日志:"
+    if [ -f "${LOG_DIR}/gunicorn_access.log" ]; then
+        echo_info "最近 10 行:"
+        tail -10 "${LOG_DIR}/gunicorn_access.log"
+    else
+        echo_warn "应用访问日志不存在: ${LOG_DIR}/gunicorn_access.log"
+    fi
+    
+    echo ""
+    echo_info "Supervisor 应用日志:"
+    if [ -f "/var/log/supervisor/${APP_NAME}-stderr.log" ]; then
+        echo_info "stderr 最近 30 行:"
+        sudo tail -30 "/var/log/supervisor/${APP_NAME}-stderr.log"
+    else
+        echo_warn "Supervisor stderr 日志不存在"
+    fi
+    
+    if [ -f "/var/log/supervisor/${APP_NAME}-stdout.log" ]; then
+        echo_info "stdout 最近 20 行:"
+        sudo tail -20 "/var/log/supervisor/${APP_NAME}-stdout.log"
+    else
+        echo_warn "Supervisor stdout 日志不存在"
+    fi
+}
+
+# 6. 检查端口占用
+check_ports() {
+    echo_section "6. 检查端口占用"
+    
+    echo_info "检查 5500 端口:"
+    if sudo netstat -tlnp | grep :5500; then
+        echo_info "✓ 端口 5500 已被占用"
+    else
+        echo_warn "✗ 端口 5500 未被占用(应用可能未启动)"
+    fi
+}
+
+# 7. 检查环境变量和配置
+check_config() {
+    echo_section "7. 检查配置文件"
+    
+    if [ -f "${APP_DIR}/.env" ]; then
+        echo_info "✓ .env 文件存在"
+        echo_info "环境变量(隐藏敏感信息):"
+        grep -v "PASSWORD\|SECRET\|KEY" "${APP_DIR}/.env" || echo "无非敏感配置"
+    else
+        echo_warn "✗ .env 文件不存在"
+    fi
+}
+
+# 8. 提供修复建议
+provide_suggestions() {
+    echo_section "8. 修复建议"
+    
+    echo_info "基于诊断结果,尝试以下步骤:"
+    echo ""
+    echo "1. 如果是 zoneinfo 问题(Python 3.9+ 时区模块):"
+    echo "   sudo apt-get update"
+    echo "   sudo apt-get install -y tzdata"
+    echo ""
+    echo "2. 如果日志目录不存在:"
+    echo "   sudo mkdir -p ${LOG_DIR}"
+    echo "   sudo chown ubuntu:ubuntu ${LOG_DIR}"
+    echo ""
+    echo "3. 重新加载 supervisor 配置:"
+    echo "   sudo supervisorctl reread"
+    echo "   sudo supervisorctl update"
+    echo ""
+    echo "4. 手动启动应用测试:"
+    echo "   cd ${APP_DIR}"
+    echo "   source ${VENV_DIR}/bin/activate"
+    echo "   gunicorn -c gunicorn_config.py 'app:create_app()'"
+    echo ""
+    echo "5. 查看实时日志:"
+    echo "   sudo tail -f /var/log/supervisor/${APP_NAME}-stderr.log"
+}
+
+# 主函数
+main() {
+    echo "=========================================="
+    echo "  DataOps Platform 问题诊断"
+    echo "=========================================="
+    echo ""
+    
+    check_directories
+    check_supervisor
+    check_python
+    test_app_import
+    check_logs
+    check_ports
+    check_config
+    provide_suggestions
+    
+    echo ""
+    echo_info "诊断完成!"
+}
+
+main "$@"

+ 200 - 0
scripts/fix_startup.sh

@@ -0,0 +1,200 @@
+#!/bin/bash
+#
+# DataOps Platform 启动问题快速修复脚本
+#
+
+set -e
+
+# 配置变量
+APP_NAME="dataops-platform"
+APP_DIR="/opt/dataops-platform"
+VENV_DIR="${APP_DIR}/venv"
+LOG_DIR="${APP_DIR}/logs"
+
+# 颜色输出
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m'
+
+echo_info() {
+    echo -e "${GREEN}[INFO]${NC} $1"
+}
+
+echo_warn() {
+    echo -e "${YELLOW}[WARN]${NC} $1"
+}
+
+echo_error() {
+    echo -e "${RED}[ERROR]${NC} $1"
+}
+
+echo "=========================================="
+echo "  DataOps Platform 快速修复"
+echo "=========================================="
+
+# 1. 创建日志目录
+echo_info "1. 检查并创建日志目录..."
+if [ ! -d "${LOG_DIR}" ]; then
+    sudo mkdir -p "${LOG_DIR}"
+    sudo chown ubuntu:ubuntu "${LOG_DIR}"
+    echo_info "✓ 日志目录已创建: ${LOG_DIR}"
+else
+    echo_info "✓ 日志目录已存在"
+fi
+
+# 2. 安装 tzdata(时区数据)
+echo_info "2. 检查并安装时区数据..."
+if ! dpkg -l | grep -q tzdata; then
+    echo_info "正在安装 tzdata..."
+    sudo DEBIAN_FRONTEND=noninteractive apt-get update
+    sudo DEBIAN_FRONTEND=noninteractive apt-get install -y tzdata
+    echo_info "✓ tzdata 已安装"
+else
+    echo_info "✓ tzdata 已安装"
+fi
+
+# 3. 检查 Python 版本
+echo_info "3. 检查 Python 版本..."
+if [ -f "${VENV_DIR}/bin/python" ]; then
+    PYTHON_VERSION=$(${VENV_DIR}/bin/python --version 2>&1 | awk '{print $2}')
+    echo_info "Python 版本: ${PYTHON_VERSION}"
+    
+    # 检查是否为 Python 3.9+
+    MAJOR=$(echo ${PYTHON_VERSION} | cut -d. -f1)
+    MINOR=$(echo ${PYTHON_VERSION} | cut -d. -f2)
+    
+    if [ "${MAJOR}" -eq 3 ] && [ "${MINOR}" -ge 9 ]; then
+        echo_info "✓ Python 版本支持 zoneinfo"
+    else
+        echo_warn "Python 版本 < 3.9,zoneinfo 可能不可用"
+        echo_info "建议使用 Python 3.9 或更高版本"
+    fi
+else
+    echo_error "✗ Python 虚拟环境不存在"
+    exit 1
+fi
+
+# 4. 测试时区模块
+echo_info "4. 测试时区模块..."
+cd "${APP_DIR}"
+${VENV_DIR}/bin/python -c "
+try:
+    from zoneinfo import ZoneInfo
+except ImportError:
+    from backports.zoneinfo import ZoneInfo
+from datetime import datetime
+tz = ZoneInfo('Asia/Shanghai')
+now = datetime.now(tz)
+print(f'✓ 时区模块正常,当前东八区时间: {now}')
+" 2>&1 || {
+    echo_error "✗ 时区模块测试失败"
+    echo_info "尝试安装 backports.zoneinfo..."
+    ${VENV_DIR}/bin/pip install backports.zoneinfo
+    echo_info "重新测试..."
+    ${VENV_DIR}/bin/python -c "
+try:
+    from zoneinfo import ZoneInfo
+except ImportError:
+    from backports.zoneinfo import ZoneInfo
+from datetime import datetime
+tz = ZoneInfo('Asia/Shanghai')
+now = datetime.now(tz)
+print(f'✓ 时区模块正常,当前东八区时间: {now}')
+" 2>&1
+}
+
+# 5. 测试应用导入
+echo_info "5. 测试应用导入..."
+${VENV_DIR}/bin/python -c "
+import sys
+sys.path.insert(0, '${APP_DIR}')
+from app import create_app
+app = create_app()
+print('✓ 应用导入成功')
+" 2>&1 || {
+    echo_error "✗ 应用导入失败,查看详细错误:"
+    ${VENV_DIR}/bin/python -c "
+import sys
+sys.path.insert(0, '${APP_DIR}')
+try:
+    from app import create_app
+    app = create_app()
+except Exception as e:
+    import traceback
+    traceback.print_exc()
+" 2>&1
+    exit 1
+}
+
+# 6. 修复文件权限
+echo_info "6. 修复文件权限..."
+sudo chown -R ubuntu:ubuntu "${APP_DIR}"
+sudo chmod -R 755 "${APP_DIR}/scripts"
+echo_info "✓ 文件权限已修复"
+
+# 7. 重新加载 supervisor 配置
+echo_info "7. 重新加载 Supervisor 配置..."
+sudo supervisorctl reread
+sudo supervisorctl update
+echo_info "✓ Supervisor 配置已重新加载"
+
+# 8. 停止并清理旧进程
+echo_info "8. 清理旧进程..."
+sudo supervisorctl stop ${APP_NAME} 2>/dev/null || true
+sleep 2
+
+# 检查是否有残留进程
+if pgrep -f "gunicorn.*dataops" > /dev/null; then
+    echo_warn "发现残留的 gunicorn 进程,正在清理..."
+    sudo pkill -f "gunicorn.*dataops" || true
+    sleep 2
+fi
+
+# 9. 启动应用
+echo_info "9. 启动应用..."
+sudo supervisorctl start ${APP_NAME}
+sleep 3
+
+# 10. 检查状态
+echo_info "10. 检查应用状态..."
+status=$(sudo supervisorctl status ${APP_NAME} | awk '{print $2}')
+if [ "$status" = "RUNNING" ]; then
+    echo_info "✓ ${APP_NAME} 启动成功!"
+    sudo supervisorctl status ${APP_NAME}
+else
+    echo_error "✗ ${APP_NAME} 启动失败!"
+    echo_info "查看错误日志:"
+    echo ""
+    
+    if [ -f "/var/log/supervisor/${APP_NAME}-stderr.log" ]; then
+        echo "=== Supervisor stderr 日志 ==="
+        sudo tail -30 "/var/log/supervisor/${APP_NAME}-stderr.log"
+    fi
+    
+    if [ -f "${LOG_DIR}/gunicorn_error.log" ]; then
+        echo ""
+        echo "=== Gunicorn 错误日志 ==="
+        tail -30 "${LOG_DIR}/gunicorn_error.log"
+    fi
+    
+    exit 1
+fi
+
+# 11. 健康检查
+echo_info "11. 进行健康检查..."
+sleep 3
+response=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:5500/api/system/health 2>/dev/null || echo "000")
+if [ "$response" = "200" ]; then
+    echo_info "✓ 健康检查通过! HTTP 状态码: ${response}"
+else
+    echo_warn "健康检查返回: ${response}"
+    echo_info "服务可能需要更多时间启动"
+fi
+
+echo ""
+echo "=========================================="
+echo_info "修复完成!"
+echo "=========================================="
+echo_info "访问地址: http://localhost:5500"
+echo_info "查看日志: sudo tail -f /var/log/supervisor/${APP_NAME}-stderr.log"

+ 1 - 1
tasks/task_trigger.txt

@@ -1,5 +1,5 @@
 CURSOR_AUTO_EXECUTE_TASK_TRIGGER
-生成时间: 2026-01-07 17:02:02
+生成时间: 2026-01-12 10:52:01
 状态: 所有任务已完成
 待处理任务数: 0
 任务ID列表: []

+ 214 - 0
tests/test_approve_order.py

@@ -0,0 +1,214 @@
+"""
+测试 approve_order 功能
+
+测试场景:
+1. 验证 extract_output_domain_and_logic 方法能正确提取输出域和处理逻辑
+2. 验证 generate_order_resources 方法能正确创建资源
+3. 验证 approve_order 完整流程
+"""
+
+import json
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+
+class TestExtractOutputDomainAndLogic:
+    """测试 extract_output_domain_and_logic 方法"""
+
+    @patch("app.core.data_service.data_product_service.current_app")
+    @patch("app.core.data_service.data_product_service.OpenAI")
+    def test_extract_success(self, mock_openai, mock_app):
+        """测试成功提取输出域和处理逻辑"""
+        from app.core.data_service.data_product_service import DataOrderService
+
+        # 模拟配置
+        mock_app.config.get.side_effect = lambda key: {
+            "LLM_API_KEY": "test-key",
+            "LLM_BASE_URL": "http://test-url",
+            "LLM_MODEL_NAME": "test-model",
+        }.get(key)
+
+        # 模拟 LLM 响应
+        mock_response = MagicMock()
+        mock_response.choices = [MagicMock()]
+        mock_response.choices[0].message.content = json.dumps(
+            {
+                "output_domain": {
+                    "name_zh": "会员消费分析报表",
+                    "name_en": "member_consumption_analysis",
+                    "describe": "汇总会员消费数据的分析报表",
+                },
+                "processing_logic": "1. 从会员表提取会员ID、姓名;2. 从消费记录表提取消费金额;3. 按会员汇总消费总额",
+            }
+        )
+        mock_openai.return_value.chat.completions.create.return_value = mock_response
+
+        # 执行测试
+        result = DataOrderService.extract_output_domain_and_logic(
+            description="我需要一个会员消费分析报表,统计每个会员的消费总额",
+            input_domains=[{"name_zh": "会员表"}, {"name_zh": "消费记录表"}],
+        )
+
+        # 验证结果
+        assert "output_domain" in result
+        assert result["output_domain"]["name_zh"] == "会员消费分析报表"
+        assert result["output_domain"]["name_en"] == "member_consumption_analysis"
+        assert "processing_logic" in result
+        assert "消费" in result["processing_logic"]
+
+    @patch("app.core.data_service.data_product_service.current_app")
+    @patch("app.core.data_service.data_product_service.OpenAI")
+    def test_extract_with_markdown_response(self, mock_openai, mock_app):
+        """测试 LLM 返回带 markdown 代码块的响应"""
+        from app.core.data_service.data_product_service import DataOrderService
+
+        mock_app.config.get.side_effect = lambda key: {
+            "LLM_API_KEY": "test-key",
+            "LLM_BASE_URL": "http://test-url",
+            "LLM_MODEL_NAME": "test-model",
+        }.get(key)
+
+        # 模拟带 markdown 代码块的响应
+        mock_response = MagicMock()
+        mock_response.choices = [MagicMock()]
+        mock_response.choices[0].message.content = """```json
+{
+    "output_domain": {
+        "name_zh": "销售报表",
+        "name_en": "sales_report",
+        "describe": "销售数据汇总"
+    },
+    "processing_logic": "汇总销售数据"
+}
+```"""
+        mock_openai.return_value.chat.completions.create.return_value = mock_response
+
+        result = DataOrderService.extract_output_domain_and_logic(
+            description="生成销售报表"
+        )
+
+        assert result["output_domain"]["name_zh"] == "销售报表"
+
+    @patch("app.core.data_service.data_product_service.current_app")
+    @patch("app.core.data_service.data_product_service.OpenAI")
+    def test_extract_fallback_on_error(self, mock_openai, mock_app):
+        """测试 LLM 调用失败时的回退逻辑"""
+        from app.core.data_service.data_product_service import DataOrderService
+
+        mock_app.config.get.side_effect = lambda key: {
+            "LLM_API_KEY": "test-key",
+            "LLM_BASE_URL": "http://test-url",
+            "LLM_MODEL_NAME": "test-model",
+        }.get(key)
+
+        # 模拟 LLM 调用异常
+        mock_openai.return_value.chat.completions.create.side_effect = Exception(
+            "LLM 服务不可用"
+        )
+
+        result = DataOrderService.extract_output_domain_and_logic(
+            description="测试描述内容"
+        )
+
+        # 验证回退到默认值
+        assert result["output_domain"]["name_zh"] == "数据产品"
+        assert result["output_domain"]["name_en"] == "data_product"
+        assert result["processing_logic"] == "测试描述内容"
+        assert "error" in result
+
+
+class TestGenerateOrderResources:
+    """测试 generate_order_resources 方法"""
+
+    @patch("app.core.data_service.data_product_service.db")
+    @patch("app.core.data_service.data_product_service.neo4j_driver")
+    @patch.object(
+        __import__(
+            "app.core.data_service.data_product_service", fromlist=["DataOrderService"]
+        ).DataOrderService,
+        "extract_output_domain_and_logic",
+    )
+    def test_generate_resources_creates_all_components(
+        self, mock_extract, mock_neo4j, mock_db
+    ):
+        """测试 generate_order_resources 创建所有必要的组件"""
+        from app.core.data_service.data_product_service import DataOrderService
+        from app.models.data_product import DataOrder
+
+        # 模拟 LLM 提取结果
+        mock_extract.return_value = {
+            "output_domain": {
+                "name_zh": "测试数据产品",
+                "name_en": "test_data_product",
+                "describe": "测试描述",
+            },
+            "processing_logic": "测试处理逻辑",
+        }
+
+        # 模拟订单对象
+        mock_order = MagicMock(spec=DataOrder)
+        mock_order.id = 1
+        mock_order.order_no = "DO20240101001"
+        mock_order.title = "测试订单"
+        mock_order.description = "测试描述"
+        mock_order.extraction_purpose = "测试用途"
+        mock_order.extracted_fields = ["字段1", "字段2"]
+        mock_order.graph_analysis = {
+            "matched_domains": [
+                {"id": 100, "name_zh": "源表1"},
+                {"id": 101, "name_zh": "源表2"},
+            ]
+        }
+
+        # 模拟 Neo4j session
+        mock_session = MagicMock()
+        mock_neo4j.get_session.return_value.__enter__ = MagicMock(
+            return_value=mock_session
+        )
+        mock_neo4j.get_session.return_value.__exit__ = MagicMock(return_value=False)
+
+        # 模拟创建 BusinessDomain 返回 ID
+        mock_bd_result = MagicMock()
+        mock_bd_result.__getitem__ = lambda self, key: 200 if key == "bd_id" else None
+        mock_session.run.return_value.single.side_effect = [
+            mock_bd_result,  # 创建 BusinessDomain
+            MagicMock(
+                __getitem__=lambda self, key: 300 if key == "df_id" else None
+            ),  # 创建 DataFlow
+        ]
+
+        # 模拟 task_list 插入
+        mock_db.session.execute.return_value.fetchone.return_value = (1,)
+
+        # 执行测试
+        result = DataOrderService.generate_order_resources(mock_order)
+
+        # 验证结果
+        assert result["target_business_domain_id"] == 200
+        assert result["dataflow_id"] == 300
+        assert result["input_domain_ids"] == [100, 101]
+        assert "task_id" in result
+
+        # 验证 LLM 提取被调用
+        mock_extract.assert_called_once()
+
+
+class TestApproveOrderFlow:
+    """测试完整的 approve_order 流程"""
+
+    def test_approve_order_status_validation(self):
+        """测试订单状态验证"""
+        # 这个测试需要在实际环境中运行
+        # 这里只提供测试框架
+        pass
+
+    def test_approve_order_success_flow(self):
+        """测试审批成功的完整流程"""
+        # 这个测试需要在实际环境中运行
+        # 这里只提供测试框架
+        pass
+
+
+if __name__ == "__main__":
+    pytest.main([__file__, "-v"])

+ 317 - 0
tests/test_meta_node_add_optimization.py

@@ -0,0 +1,317 @@
+"""
+元数据新增接口优化测试用例
+
+测试 meta_node_add 接口的冗余检测和处理逻辑
+"""
+
+import json
+from unittest.mock import MagicMock, patch
+
+
+class TestMetaNodeAddOptimization:
+    """测试元数据新增接口的优化逻辑"""
+
+    def test_exact_match_should_not_create_node(self, client):
+        """
+        测试场景1:完全匹配
+        预期:返回失败,提示元数据已存在,不创建新节点
+        """
+        # 模拟冗余检测返回完全匹配
+        with patch("app.api.meta_data.routes.check_redundancy_for_add") as mock_check:
+            mock_check.return_value = {
+                "has_exact_match": True,
+                "exact_match_id": 12345,
+                "has_candidates": True,
+                "candidates": [
+                    {
+                        "id": 12345,
+                        "name_zh": "测试元数据",
+                        "name_en": "test_meta",
+                        "data_type": "varchar(255)",
+                        "tag_ids": [1, 2],
+                    }
+                ],
+            }
+
+            response = client.post(
+                "/api/meta/node/add",
+                json={
+                    "name_zh": "测试元数据",
+                    "name_en": "test_meta",
+                    "data_type": "varchar(255)",
+                    "tag": [1, 2],
+                },
+            )
+
+            data = json.loads(response.data)
+            assert data["code"] != 200
+            assert "已存在" in data["message"]
+            assert "12345" in data["message"]
+
+    def test_suspicious_duplicate_should_create_node_and_review(self, client):
+        """
+        测试场景2:疑似重复
+        预期:创建新节点,创建审核记录,返回成功并提示疑似重复
+        """
+        # 模拟冗余检测返回疑似重复
+        with patch(
+            "app.api.meta_data.routes.check_redundancy_for_add"
+        ) as mock_check, patch(
+            "app.api.meta_data.routes.neo4j_driver.get_session"
+        ) as mock_session, patch(
+            "app.api.meta_data.routes.write_redundancy_review_record_with_new_id"
+        ) as mock_write_review:
+            # 模拟冗余检测结果
+            mock_check.return_value = {
+                "has_exact_match": False,
+                "exact_match_id": None,
+                "has_candidates": True,
+                "candidates": [
+                    {
+                        "id": 12345,
+                        "name_zh": "测试元数据",
+                        "name_en": "test_meta_old",
+                        "data_type": "varchar(255)",
+                        "tag_ids": [1],
+                    }
+                ],
+            }
+
+            # 模拟 Neo4j 创建节点
+            mock_node = MagicMock()
+            mock_node.id = 99999
+            mock_node.__getitem__ = lambda self, key: {
+                "name_zh": "测试元数据",
+                "name_en": "test_meta_new",
+                "data_type": "varchar(255)",
+            }.get(key)
+            mock_node.get = lambda key, default=None: {
+                "name_zh": "测试元数据",
+                "name_en": "test_meta_new",
+                "data_type": "varchar(255)",
+            }.get(key, default)
+
+            mock_result = MagicMock()
+            mock_result.single.return_value = {"n": mock_node}
+
+            mock_session_instance = MagicMock()
+            mock_session_instance.run.return_value = mock_result
+            mock_session.return_value.__enter__.return_value = mock_session_instance
+
+            response = client.post(
+                "/api/meta/node/add",
+                json={
+                    "name_zh": "测试元数据",
+                    "name_en": "test_meta_new",
+                    "data_type": "varchar(255)",
+                    "tag": [1, 2],
+                },
+            )
+
+            data = json.loads(response.data)
+            assert data["code"] == 200
+            assert "疑似重复" in data["message"]
+            assert "审核" in data["message"]
+            assert data["data"]["id"] == 99999
+
+            # 验证审核记录已创建
+            mock_write_review.assert_called_once()
+            call_args = mock_write_review.call_args
+            assert call_args[1]["new_meta"]["id"] == 99999
+            assert len(call_args[1]["candidates"]) == 1
+
+    def test_no_duplicate_should_create_node_normally(self, client):
+        """
+        测试场景3:无重复
+        预期:创建新节点,不创建审核记录,正常返回
+        """
+        with patch(
+            "app.api.meta_data.routes.check_redundancy_for_add"
+        ) as mock_check, patch(
+            "app.api.meta_data.routes.neo4j_driver.get_session"
+        ) as mock_session:
+            # 模拟冗余检测返回无重复
+            mock_check.return_value = {
+                "has_exact_match": False,
+                "exact_match_id": None,
+                "has_candidates": False,
+                "candidates": [],
+            }
+
+            # 模拟 Neo4j 创建节点
+            mock_node = MagicMock()
+            mock_node.id = 88888
+            mock_node.__getitem__ = lambda self, key: {
+                "name_zh": "全新元数据",
+                "name_en": "brand_new_meta",
+                "data_type": "varchar(255)",
+            }.get(key)
+            mock_node.get = lambda key, default=None: {
+                "name_zh": "全新元数据",
+                "name_en": "brand_new_meta",
+                "data_type": "varchar(255)",
+            }.get(key, default)
+
+            mock_result = MagicMock()
+            mock_result.single.return_value = {"n": mock_node}
+
+            mock_session_instance = MagicMock()
+            mock_session_instance.run.return_value = mock_result
+            mock_session.return_value.__enter__.return_value = mock_session_instance
+
+            response = client.post(
+                "/api/meta/node/add",
+                json={
+                    "name_zh": "全新元数据",
+                    "name_en": "brand_new_meta",
+                    "data_type": "varchar(255)",
+                },
+            )
+
+            data = json.loads(response.data)
+            assert data["code"] == 200
+            assert "疑似重复" not in data.get("message", "")
+            assert data["data"]["id"] == 88888
+
+    def test_force_create_should_skip_redundancy_check(self, client):
+        """
+        测试场景4:强制创建
+        预期:跳过冗余检测,直接创建节点
+        """
+        with patch(
+            "app.api.meta_data.routes.check_redundancy_for_add"
+        ) as mock_check, patch(
+            "app.api.meta_data.routes.neo4j_driver.get_session"
+        ) as mock_session:
+            # 模拟 Neo4j 创建节点
+            mock_node = MagicMock()
+            mock_node.id = 77777
+            mock_node.__getitem__ = lambda self, key: {
+                "name_zh": "强制创建元数据",
+                "name_en": "force_create_meta",
+                "data_type": "varchar(255)",
+            }.get(key)
+            mock_node.get = lambda key, default=None: {
+                "name_zh": "强制创建元数据",
+                "name_en": "force_create_meta",
+                "data_type": "varchar(255)",
+            }.get(key, default)
+
+            mock_result = MagicMock()
+            mock_result.single.return_value = {"n": mock_node}
+
+            mock_session_instance = MagicMock()
+            mock_session_instance.run.return_value = mock_result
+            mock_session.return_value.__enter__.return_value = mock_session_instance
+
+            response = client.post(
+                "/api/meta/node/add",
+                json={
+                    "name_zh": "强制创建元数据",
+                    "name_en": "force_create_meta",
+                    "data_type": "varchar(255)",
+                    "force_create": True,
+                },
+            )
+
+            data = json.loads(response.data)
+            assert data["code"] == 200
+            assert data["data"]["id"] == 77777
+
+            # 验证冗余检测未被调用
+            mock_check.assert_not_called()
+
+
+class TestRedundancyCheckFunctions:
+    """测试冗余检测辅助函数"""
+
+    def test_check_redundancy_for_add_should_not_create_review(self):
+        """
+        测试 check_redundancy_for_add 函数
+        预期:只进行检测,不创建审核记录
+        """
+        from app.core.meta_data.redundancy_check import check_redundancy_for_add
+
+        with patch(
+            "app.core.meta_data.redundancy_check.neo4j_driver.get_session"
+        ) as mock_session, patch(
+            "app.core.meta_data.redundancy_check.write_redundancy_review_record"
+        ) as mock_write:
+            # 模拟查询返回疑似重复
+            mock_result = MagicMock()
+            mock_result.__iter__ = lambda self: iter(
+                [
+                    {
+                        "id": 12345,
+                        "m": MagicMock(
+                            get=lambda key, default=None: {
+                                "name_zh": "测试元数据",
+                                "name_en": "test_meta",
+                                "data_type": "varchar(255)",
+                            }.get(key, default)
+                        ),
+                    }
+                ]
+            )
+
+            mock_session_instance = MagicMock()
+            mock_session_instance.run.return_value = mock_result
+            mock_session.return_value.__enter__.return_value = mock_session_instance
+
+            result = check_redundancy_for_add(
+                name_zh="测试元数据",
+                name_en="test_meta_new",
+                data_type="varchar(255)",
+                tag_ids=[1, 2],
+            )
+
+            # 验证返回结果
+            assert result["has_exact_match"] is False
+            assert result["has_candidates"] is True
+            assert len(result["candidates"]) > 0
+
+            # 验证未创建审核记录
+            mock_write.assert_not_called()
+
+    def test_write_redundancy_review_record_with_new_id(self):
+        """
+        测试 write_redundancy_review_record_with_new_id 函数
+        预期:创建包含新节点ID的审核记录
+        """
+        from app.core.meta_data.redundancy_check import (
+            write_redundancy_review_record_with_new_id,
+        )
+
+        with patch("app.core.meta_data.redundancy_check.db.session") as mock_session:
+            new_meta = {
+                "id": 99999,  # 新创建的节点ID
+                "name_zh": "测试元数据",
+                "name_en": "test_meta_new",
+                "data_type": "varchar(255)",
+                "tag_ids": [1, 2],
+            }
+
+            candidates = [
+                {
+                    "id": 12345,
+                    "name_zh": "测试元数据",
+                    "name_en": "test_meta_old",
+                    "data_type": "varchar(255)",
+                    "tag_ids": [1],
+                }
+            ]
+
+            write_redundancy_review_record_with_new_id(
+                new_meta=new_meta, candidates=candidates, source="api"
+            )
+
+            # 验证数据库操作
+            mock_session.add.assert_called_once()
+            mock_session.commit.assert_called_once()
+
+            # 获取添加的审核记录
+            added_review = mock_session.add.call_args[0][0]
+            assert added_review.record_type == "redundancy"
+            assert added_review.source == "api"
+            assert added_review.new_meta["id"] == 99999
+            assert len(added_review.candidates) == 1

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.