Commit 0e9df863 authored by wangteng's avatar wangteng

功能优化升级,增加数据模拟,备份定时任务。

parent 53638176
......@@ -5,6 +5,14 @@
# 总体要求
- 每做一个任务就在工作记录中记录一次
# 数据库建表 / 改表约束(2026-09-30 起)
- **表和字段都必须写中文注释**,两处都要做:
1. ORM 模型(`web/backend/models/`):表注释写在 `__table_args__ = (..., {"comment": "..."})`,字段注释写在 `Column(..., comment="...")`——新库 create_all 时直接带上;
2. 注释登记表 `web/backend/db/schema_comments.py` 的 `SCHEMA_COMMENTS`——后端启动时由 `apply_mysql_comments()` 自动给**已存在的库**补齐/修正表和字段注释(幂等)。
- 新表必须注册进 `web/backend/models/__init__.py`,否则 create_all 会静默跳过建表。
- 给**已有表加字段**时,create_all 不会生效,必须在 `web/backend/db/database.py` 写幂等迁移函数(inspect 判断列存在 → `ALTER TABLE ADD COLUMN`),并在 `init_db()` 里调用;新列同样要登记 SCHEMA_COMMENTS。
- 发版会自动完成:重启后端 → create_all 建缺表 → 迁移函数补列 → 注释同步。正式库(192.168.50.10)无需手工动表。
# 服务器发版本运行
- `deploy/deploy_server.py`
......
......@@ -45,7 +45,9 @@ logger.info("DB: %s", engine.url.render_as_string(hide_password=True))
@asynccontextmanager
async def _lifespan(_: FastAPI):
await asyncio.get_event_loop().run_in_executor(None, start_scheduler)
await asyncio.get_event_loop().run_in_executor(None, start_backup_scheduler)
yield
stop_backup_scheduler()
stop_scheduler()
......@@ -81,6 +83,7 @@ from web.backend.routers.table_design import router as table_design_router # no
from web.backend.routers.llm_config import router as llm_config_router # noqa: E402
from web.backend.routers.table_prefs import router as table_prefs_router # noqa: E402
from web.backend.routers.analysis import router as analysis_router, start_scheduler, stop_scheduler # noqa: E402
from web.backend.routers.operations import start_backup_scheduler, stop_backup_scheduler # noqa: E402
app.include_router(task_groups_router, prefix="/api")
app.include_router(tasks_router, prefix="/api")
app.include_router(db_router, prefix="/api")
......
......@@ -40,14 +40,13 @@ _SYSTEM_PROMPT = "你是 Python 程序员,擅长写简短的数据校验函数
_DESCRIPTION_SYSTEM_PROMPT = "你是数据质量规则专家,擅长用一句简洁的话说明数据校验规则。"
_DESCRIPTION_TEMPLATE = """用一句中文生成 30-40 字规则说明,只输出说明本身。
_DESCRIPTION_TEMPLATE = """用一句简洁、完整的中文说明下面的数据校验规则,只输出说明本身。
不限定字数:以意思完整为准,关键格式 / 范围 / 排除项都要讲到,能省的字省掉即可。
规则名称:{name}
优先依据:{prompt}
现有说明:{desc}
规则类型:{rule_type}
仅保留生成规则所需的关键格式或范围,不要补充额外校验细节。"""
_MAX_AI_DESCRIPTION_LENGTH = 40
不要补充规则未提及的校验细节。"""
# ── User Prompt ─────────────────────────────────────────
......@@ -232,9 +231,9 @@ def gen_rule_description(name: str, desc: str, rule_type: str, prompt: str = "")
description = description.replace("\n", " ").strip().strip('"')
if not description:
return "", "AI 未生成规则说明"
# 兜底截断,避免模型未遵守长度要求时撑大规则列表。
description = description[:_MAX_AI_DESCRIPTION_LENGTH].rstrip(",、;:。 ")
return description, "已生成简短规则说明(30-40字)"
# 不截断字数(用户 2026-09-29:限制字数会把意思截断;DB 列是 Text 无长度约束,
# 长度交给提示词里的「以意思完整为准、能省则省」约束)。
return description, "已生成规则说明"
# ── 主入口 ─────────────────────────────────────────────
......
......@@ -238,7 +238,10 @@ class LLMClient:
{b for b in (initial_budget, max(initial_budget * 2, 2048), 8192) if b <= 8192}
))
for attempt, max_tokens in enumerate(token_budgets, start=1):
kwargs = {**base_kwargs, "max_tokens": max_tokens}
# 思考型模型每一跳都可能超 30s(内部代理生成速度慢,实测 1024-2048
# 额度的思考+输出也要 30s+),统一给 90s 下限;配置了更大值则以配置为准。
attempt_timeout = max(self.cfg.timeout, 90)
kwargs = {**base_kwargs, "max_tokens": max_tokens, "timeout": attempt_timeout}
msg = self._provider.messages.create(**kwargs)
for block in msg.content:
text = getattr(block, "text", None)
......@@ -325,10 +328,10 @@ def _format_api_error(exc: Exception) -> str:
if hint:
return f"API Error: 请求被拒绝 ({status}) · {detail} ({hint})"
return f"API Error: 请求失败 ({status}) · {detail}"
if isinstance(exc, APITimeoutError):
return f"API Error: 请求超时(模型响应时间过长,可调大 llm 配置 timeout)· {exc_msg}"
if isinstance(exc, APIConnectionError):
return f"API Error: 网络连接失败 · {exc_msg}"
if isinstance(exc, APITimeoutError):
return f"API Error: 请求超时 · {exc_msg}"
except ImportError:
pass
......
......@@ -19,6 +19,10 @@ def record_operation(
tables: Iterable[str],
filename: str = "",
execute_setting: str = "",
trigger_type: str = "manual",
backup_mode: str = "",
task_id: int | None = None,
task_name: str = "",
) -> OperationRecord:
"""保存一次已成功完成的操作;密码等敏感请求参数永不写入历史。
......@@ -36,6 +40,10 @@ def record_operation(
operation=operation,
source_id=source.id if source else None,
source_name=source.name if source else "",
trigger_type=trigger_type or "manual",
backup_mode=backup_mode or "",
task_id=task_id,
task_name=(task_name or "")[:120],
database_name=database or "",
scope=scope or "",
tables_json=tables_json,
......@@ -46,3 +54,15 @@ def record_operation(
db.commit()
db.refresh(row)
return row
def update_operation(db: Session, record_id: int, *, status: str, execute_setting: str = "", error_message: str = ""):
row = db.get(OperationRecord, record_id)
if row is None:
return None
row.status = status
row.execute_setting = (execute_setting or "")[:400]
row.error_message = (error_message or "")[:1000]
db.commit()
db.refresh(row)
return row
......@@ -5,7 +5,7 @@ from contextlib import contextmanager
import json
from pathlib import Path
from sqlalchemy import create_engine, event
from sqlalchemy import create_engine, event, inspect
from sqlalchemy.orm import sessionmaker, Session, declarative_base
from web.backend.config import APP_ENV, DATA_DIR, DATABASE_CONNECT_ARGS, DATABASE_URL
......@@ -66,6 +66,8 @@ def init_db() -> None:
"""初始化当前环境的 MySQL 表,并保留 SQLite 旧库升级兼容。"""
from web.backend.db import seed
Base.metadata.create_all(engine)
_migrate_operation_record_error()
_migrate_operation_record_context()
if IS_SQLITE:
# 一次性迁移:补齐 connection_preset 的 UNIQUE(db_type, name) 约束
# 历史背景:connection_preset 最初是手动跑 schema.sql 片段建的(仅 7 张主表里没有它),
......@@ -100,14 +102,48 @@ def init_db() -> None:
if changed:
logger.info("[init_db] 已更新 %s 项 MySQL 中文注释", changed)
# 灌种子(每个 seed 函数内部判重,可重复调用)
# 2026-09-29:不再灌示例数据源 —— 测试/正式环境每次发版都会多出 3 条
# 127.0.0.1 占位数据源(seed_data_sources 保留函数但不再调用)
seed.seed_task_groups()
seed.seed_connection_presets()
seed.seed_data_sources()
_migrate_llm_config_from_file()
_migrate_backup_config_to_database()
print(f"[init_db] 初始化完成:{engine.url.render_as_string(hide_password=True)}")
def _migrate_operation_record_error() -> None:
"""Add async operation error text to existing SQLite installations."""
inspector = inspect(engine)
if "operation_record" not in inspector.get_table_names():
return
cols = {column["name"] for column in inspector.get_columns("operation_record")}
if "error_message" in cols:
return
with engine.begin() as conn:
if IS_SQLITE:
conn.exec_driver_sql("ALTER TABLE operation_record ADD COLUMN error_message TEXT NULL")
else:
conn.exec_driver_sql("ALTER TABLE operation_record ADD COLUMN error_message TEXT NULL")
def _migrate_operation_record_context() -> None:
"""补齐操作记录的触发方式、备份模式和任务关联字段。"""
inspector = inspect(engine)
if "operation_record" not in inspector.get_table_names():
return
existing = {column["name"] for column in inspector.get_columns("operation_record")}
columns = (
("trigger_type", "VARCHAR(20) NOT NULL DEFAULT 'manual'"),
("backup_mode", "VARCHAR(20) NOT NULL DEFAULT ''"),
("task_id", "INTEGER NULL"),
("task_name", "VARCHAR(120) NOT NULL DEFAULT ''"),
)
with engine.begin() as conn:
for name, definition in columns:
if name not in existing:
conn.exec_driver_sql(f"ALTER TABLE operation_record ADD COLUMN {name} {definition}")
def _migrate_connection_preset_unique() -> None:
"""确保 connection_preset 表有显式 UNIQUE INDEX uq_connection_preset_db_type_name。
......
......@@ -198,6 +198,10 @@ CREATE TABLE operation_record (
id INTEGER PRIMARY KEY AUTOINCREMENT,
operation TEXT NOT NULL,
source_id INTEGER REFERENCES data_source(id) ON DELETE SET NULL,
trigger_type TEXT NOT NULL DEFAULT 'manual',
backup_mode TEXT NOT NULL DEFAULT '',
task_id INTEGER,
task_name TEXT NOT NULL DEFAULT '',
source_name TEXT NOT NULL DEFAULT '',
database_name TEXT NOT NULL DEFAULT '',
scope TEXT NOT NULL DEFAULT '',
......@@ -205,10 +209,29 @@ CREATE TABLE operation_record (
filename TEXT NOT NULL DEFAULT '',
execute_setting TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '已完成',
error_message TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_operation_record_operation_created ON operation_record(operation, created_at DESC);
-- 9.1) backup_task · 定时备份任务配置
CREATE TABLE backup_task (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
source_id INTEGER NOT NULL REFERENCES data_source(id) ON DELETE CASCADE,
database_name TEXT NOT NULL DEFAULT '',
schema_name TEXT,
scope TEXT NOT NULL DEFAULT 'database',
tables_json TEXT NOT NULL DEFAULT '[]',
backup_mode TEXT NOT NULL DEFAULT 'full',
include_schema INTEGER NOT NULL DEFAULT 1,
include_data INTEGER NOT NULL DEFAULT 1,
cron_expression TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- 10) table_design_config · 表设计校验配置与最近扫描结果
CREATE TABLE table_design_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
......
......@@ -44,7 +44,7 @@ SCHEMA_COMMENTS: Dict[str, dict] = {
},
"operation_record": {
"table": "数据库操作记录",
"columns": {"id": "主键", "operation": "操作类型", "source_id": "数据源ID", "source_name": "数据源名称", "database_name": "数据库名称", "scope": "操作范围", "tables_json": "数据表列表JSON", "filename": "备份文件名", "execute_setting": "执行设置", "status": "执行状态", "created_at": "创建时间"},
"columns": {"id": "主键", "operation": "操作类型", "source_id": "数据源ID", "source_name": "数据源名称", "database_name": "数据库名称", "scope": "操作范围", "tables_json": "数据表列表JSON", "filename": "备份文件名", "execute_setting": "执行设置", "status": "执行状态", "trigger_type": "触发方式:manual 手动 / scheduled 定时", "backup_mode": "备份方式:full 全量 / incremental 增量", "task_id": "关联备份任务ID(无则手动操作)", "task_name": "关联备份任务名称", "error_message": "失败原因", "created_at": "创建时间"},
},
"table_design_config": {
"table": "表设计校验配置",
......@@ -83,4 +83,26 @@ SCHEMA_COMMENTS: Dict[str, dict] = {
"table": "系统参数配置",
"columns": {"setting_key": "配置参数键", "setting_value": "配置参数值", "updated_at": "更新时间"},
},
"backup_task": {
"table": "定时备份任务配置",
"columns": {
"id": "主键", "name": "任务名称", "source_id": "数据源ID", "database_name": "数据库/Schema名称",
"schema_name": "Schema名称", "scope": "备份范围:database 整库 / tables 指定表",
"tables_json": "指定数据表清单JSON", "backup_mode": "备份方式:full 全量 / incremental 增量",
"include_schema": "是否备份表结构:1 是 0 否", "include_data": "是否备份表数据:1 是 0 否",
"cron_expression": "Cron执行表达式(标准5段)", "enabled": "是否启用:1 启用 0 停用",
"created_at": "创建时间", "updated_at": "更新时间",
},
},
"table_pref": {
"table": "列表页表格偏好(表设置:列宽/列显隐/列对齐/列顺序/行高/线框粗细)",
"columns": {
"id": "主键", "page_key": "页面标识", "col_key": "列标识(表头文字;__page__ 为页面级偏好行)",
"width": "列宽 px;NULL=未自定义", "hidden": "是否隐藏:0 显示 1 隐藏",
"align": "列对齐:left/center/right;NULL=默认", "ord": "列顺序(0 起);NULL=原始位置",
"row_height": "行高 px(36/40/48);仅页面级行使用",
"border_width": "线框粗细档位(0=细 1=中 2=粗);仅页面级行使用",
"updated_at": "最近更新时间",
},
},
}
......@@ -18,5 +18,6 @@ from .table_design_config import TableDesignConfig
from .llm_config import LLMConfigRecord
from .system_config import SystemConfig
from .analysis import AnalysisTask, AnalysisRun, AnalysisTableSnapshot
from .backup_task import BackupTask
__all__ = ["ConnectionPreset", "Field", "Rule", "RuleLibrary", "FieldRule", "Task", "TaskGroup", "DataSource", "OperationRecord", "TableDesignConfig", "LLMConfigRecord", "SystemConfig", "TablePref", "AnalysisTask", "AnalysisRun", "AnalysisTableSnapshot"]
__all__ = ["ConnectionPreset", "Field", "Rule", "RuleLibrary", "FieldRule", "Task", "TaskGroup", "DataSource", "OperationRecord", "TableDesignConfig", "LLMConfigRecord", "SystemConfig", "TablePref", "AnalysisTask", "AnalysisRun", "AnalysisTableSnapshot", "BackupTask"]
"""Persistent scheduled backup configurations."""
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.sql import func
from web.backend.db.database import Base
class BackupTask(Base):
__tablename__ = "backup_task"
__table_args__ = (
# 表注释(新库建表时写入 MySQL COMMENT;已存在的表由 apply_mysql_comments 启动时同步)
{"comment": "定时备份任务配置"},
)
id = Column(Integer, primary_key=True, autoincrement=True, comment="主键")
name = Column(String(120), nullable=False, comment="任务名称")
source_id = Column(Integer, ForeignKey("data_source.id", ondelete="CASCADE"), nullable=False, comment="数据源ID")
database_name = Column(String(255), nullable=False, default="", comment="数据库/Schema名称")
schema_name = Column(String(255), nullable=True, comment="Schema名称")
scope = Column(String(20), nullable=False, default="database", comment="备份范围:database 整库 / tables 指定表")
tables_json = Column(Text, nullable=False, default="[]", comment="指定数据表清单JSON")
backup_mode = Column(String(20), nullable=False, default="full", comment="备份方式:full 全量 / incremental 增量")
include_schema = Column(Integer, nullable=False, default=1, comment="是否备份表结构:1 是 0 否")
include_data = Column(Integer, nullable=False, default=1, comment="是否备份表数据:1 是 0 否")
cron_expression = Column(String(100), nullable=False, comment="Cron执行表达式(标准5段)")
enabled = Column(Integer, nullable=False, default=1, comment="是否启用:1 启用 0 停用")
created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), comment="创建时间")
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp(), comment="更新时间")
def to_dict(self, next_run_at=None):
try:
import json
tables = json.loads(self.tables_json or "[]")
except Exception:
tables = []
return {"id": self.id, "name": self.name, "source_id": self.source_id,
"database": self.database_name, "schema": self.schema_name,
"scope": self.scope, "tables": tables, "backup_mode": self.backup_mode,
"include_schema": bool(self.include_schema), "include_data": bool(self.include_data),
"cron_expression": self.cron_expression, "enabled": bool(self.enabled),
"created_at": self.created_at, "next_run_at": next_run_at}
......@@ -16,6 +16,11 @@ class OperationRecord(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
operation = Column(String(20), nullable=False)
source_id = Column(Integer, ForeignKey("data_source.id", ondelete="SET NULL"), nullable=True)
# 记录来源和备份模式,保证历史列表可以区分手动/自动、全量/增量。
trigger_type = Column(String(20), nullable=False, default="manual", server_default="manual")
backup_mode = Column(String(20), nullable=False, default="", server_default="")
task_id = Column(Integer, nullable=True)
task_name = Column(String(120), nullable=False, default="", server_default="")
source_name = Column(String(100), nullable=False, default="")
database_name = Column(String(255), nullable=False, default="")
scope = Column(String(20), nullable=False, default="")
......@@ -23,6 +28,7 @@ class OperationRecord(Base):
filename = Column(String(255), nullable=False, default="")
execute_setting = Column(Text, nullable=False, default="")
status = Column(String(20), nullable=False, default="已完成")
error_message = Column(Text, nullable=True, default="")
created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp())
def to_dict(self) -> dict:
......@@ -35,11 +41,16 @@ class OperationRecord(Base):
"operation": self.operation,
"source_id": self.source_id,
"source_name": self.source_name,
"trigger_type": self.trigger_type or "manual",
"backup_mode": self.backup_mode or "",
"task_id": self.task_id,
"task_name": self.task_name or "",
"database": self.database_name,
"scope": self.scope,
"tables": tables if isinstance(tables, list) else [],
"filename": self.filename,
"execute_setting": self.execute_setting,
"status": self.status,
"error_message": self.error_message or "",
"created_at": self.created_at.isoformat() if isinstance(self.created_at, datetime) else self.created_at,
}
......@@ -271,6 +271,32 @@ def _snapshots(db: Session, run_id: int) -> dict[str, AnalysisTableSnapshot]:
.filter(AnalysisTableSnapshot.run_id == run_id).all()}
def _column_changes(old_cols: dict[str, dict], current_cols: dict[str, dict]) -> list[dict]:
"""Compare column properties; a shifted position after add/drop is not a modification."""
common = old_cols.keys() & current_cols.keys()
def relative_order(cols: dict[str, dict]) -> dict[str, int]:
names = sorted(common, key=lambda name: (int(cols[name].get("ordinal_position") or 0), name))
return {name: index for index, name in enumerate(names)}
old_order, current_order = relative_order(old_cols), relative_order(current_cols)
changes = []
for col_name in sorted(old_cols.keys() | current_cols.keys()):
left, right = old_cols.get(col_name), current_cols.get(col_name)
if left is None:
changes.append({"column": col_name, "kind": "added", "before": None, "after": right})
elif right is None:
changes.append({"column": col_name, "kind": "removed", "before": left, "after": None})
else:
fields = [key for key in _COLUMNS if key not in ("column_name", "ordinal_position")
and left.get(key) != right.get(key)]
if old_order[col_name] != current_order[col_name]:
fields.append("ordinal_position")
if fields:
changes.append({"column": col_name, "kind": "changed", "before": left,
"after": right, "changed_fields": fields})
return changes
def _report(db: Session, run: AnalysisRun) -> dict:
previous = (db.query(AnalysisRun).filter(AnalysisRun.task_id == run.task_id,
AnalysisRun.status == "success", AnalysisRun.id < run.id)
......@@ -282,15 +308,7 @@ def _report(db: Session, run: AnalysisRun) -> dict:
now, before = current.get(name), old.get(name)
current_cols = {c["column_name"]: c for c in json.loads(now.columns_json)} if now else {}
old_cols = {c["column_name"]: c for c in json.loads(before.columns_json)} if before else {}
changes = []
for col_name in (sorted(set(current_cols) | set(old_cols)) if previous else []):
left, right = old_cols.get(col_name), current_cols.get(col_name)
if left is None:
changes.append({"column": col_name, "kind": "added", "before": None, "after": right})
elif right is None:
changes.append({"column": col_name, "kind": "removed", "before": left, "after": None})
elif left != right:
changes.append({"column": col_name, "kind": "changed", "before": left, "after": right})
changes = _column_changes(old_cols, current_cols) if previous else []
tables.append({"table_name": name, "change": "baseline" if previous is None else "added" if before is None else "removed" if now is None else
"changed" if changes or now.table_comment != before.table_comment else "unchanged",
"table_comment_before": before.table_comment if before else None,
......@@ -360,7 +378,7 @@ def _column_def(col: dict, dialect: str) -> str:
"""列定义片段:类型 + NOT NULL + 默认值(注释由调用方按方言拼)。"""
ctype = str(col.get("column_type") or col.get("data_type") or "VARCHAR(255)")
definition = f"{quote_ident(str(col.get('column_name') or ''), dialect)} {ctype}"
if str(col.get("is_nullable") or "YES").upper() == "NO":
if str(col.get("is_nullable") or "YES").upper() in ("NO", "N"):
definition += " NOT NULL"
default = col.get("column_default")
if default is not None and str(default).strip() != "":
......@@ -460,7 +478,16 @@ def analyze_table(run_id: int, table_name: str, db: Session = Depends(get_sessio
change_lines = []
for change in (row.get("column_changes") or [])[:60]:
if change["kind"] == "changed" and change.get("before") and change.get("after"):
detail = f"({change['before'].get('column_type')} → {change['after'].get('column_type')})"
before, after = change["before"], change["after"]
labels = {"column_type": "类型", "data_type": "数据类型", "char_max_length": "字符长度",
"numeric_precision": "数值精度", "numeric_scale": "小数位数",
"is_nullable": "可空", "column_default": "默认值", "column_comment": "注释",
"extra": "附加属性", "ordinal_position": "顺序"}
fields = [key for key in change.get("changed_fields", [])
if key == "column_type" or "column_type" not in change["changed_fields"]
or key not in ("data_type", "char_max_length", "numeric_precision", "numeric_scale")]
detail = "(" + ";".join(
f"{labels[key]}:{before.get(key)} → {after.get(key)}" for key in fields) + ")"
else:
detail = ""
label = {"added": "新增", "removed": "删除", "changed": "修改"}.get(change["kind"], change["kind"])
......
......@@ -3,20 +3,21 @@ from __future__ import annotations
import base64
import json
import re
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from decimal import Decimal
from typing import Literal, Optional
from typing import Any, Literal, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from web.backend.db.database import get_session
from web.backend.db.database import get_session, SessionLocal
from web.backend.models.data_source import DataSource
from web.backend.core.db_adapter import DBConfig, DBConnection, quote_ident
from web.backend.core.oracle_ddl import capture_table_ddl
from web.backend.core.sql_loader import get_sql_loader
from web.backend.core.backup_storage import get_backup_dir
from web.backend.core.operation_history import record_operation
from web.backend.models.operation_record import OperationRecord
router = APIRouter(prefix="/data-sources", tags=["data-sources"])
......@@ -46,6 +47,15 @@ class TestResult(BaseModel):
ok: bool; message: str
class SimulatedInsertRequest(BaseModel):
"""客户端生成的模拟数据批量写入请求。"""
table_name: str = Field(..., min_length=1, max_length=128)
schema: Optional[str] = Field(default=None, max_length=128)
columns: list[str] = Field(..., min_length=1, max_length=500)
column_types: dict[str, str] = Field(default_factory=dict)
rows: list[dict[str, Any]] = Field(..., min_length=1, max_length=100000)
class BackupRequest(BaseModel):
"""导出已保存数据源中的指定表。"""
scope: Literal["database", "tables"] = "database"
......@@ -54,6 +64,7 @@ class BackupRequest(BaseModel):
schema: Optional[str] = None
include_schema: bool = True
include_data: bool = True
backup_mode: Literal["full", "incremental"] = "full"
filename: Optional[str] = None
def _cfg(s: DataSource) -> DBConfig:
......@@ -105,6 +116,28 @@ def _resolve_schema(cfg: DBConfig, row: DataSource, payload) -> str:
return cfg.database
def _coerce_oracle_insert_value(value: Any, column_type: str | None) -> Any:
"""把页面生成的 ISO 日期文本转换为 Oracle 可绑定的 datetime。"""
if value is None or not isinstance(value, str):
return value
type_name = (column_type or "").lower()
is_temporal_type = any(token in type_name for token in ("date", "timestamp", "datetime"))
# 有明确的非日期字段类型时,保留用户输入的字符串;只有旧客户端未传
# column_types 时,才用 ISO 格式作为兼容性兜底。
if type_name and not is_temporal_type:
return value
looks_like_date = bool(re.match(r"^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$", value))
if not (looks_like_date or is_temporal_type):
return value
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise ValueError(f"字段日期格式无法解析:{value}") from exc
if parsed.tzinfo and "time zone" not in type_name:
parsed = parsed.replace(tzinfo=None)
return parsed
def _backup_filename(value: Optional[str]) -> str:
if not value or not value.strip():
return f"db-backup-{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}.json"
......@@ -172,86 +205,204 @@ def test_data_source(source_id: int, db: Session = Depends(get_session)):
return {"ok": False, "message": f"连接失败:{type(e).__name__}: {e}"}
@router.post("/{source_id}/backup")
def backup_data_source(source_id: int, payload: BackupRequest, db: Session = Depends(get_session)):
"""从真实数据源导出 JSON 备份包。"""
@router.post("/{source_id}/simulate/insert")
def insert_simulated_data(source_id: int, payload: SimulatedInsertRequest, db: Session = Depends(get_session)):
"""将确认后的模拟数据批量插入目标表,单事务失败自动回滚。"""
row = db.get(DataSource, source_id)
if not row:
raise HTTPException(404, "数据源不存在")
cfg = _cfg(row)
_apply_database_override(cfg, payload.database)
schema = _resolve_schema(cfg, row, payload)
tables = list(dict.fromkeys(t.strip() for t in payload.tables if t and t.strip()))
if payload.scope == "tables" and not tables:
raise HTTPException(400, "至少选择一张表")
result = {
"format": "db-tools-backup",
"version": 1,
"created_at": datetime.utcnow().isoformat() + "Z",
"source": {"id": row.id, "name": row.name, "db_type": row.db_type, "host": row.host,
"database": cfg.database, "schema": schema, "scope": payload.scope},
"tables": [],
}
columns = list(dict.fromkeys(c.strip() for c in payload.columns if c and c.strip()))
if not columns or len(columns) != len(payload.columns):
raise HTTPException(400, "字段列表不能为空且不能包含重复字段")
try:
cfg = _cfg(row)
schema = _resolve_schema(cfg, row, payload)
target = _qualified_table(cfg, schema, payload.table_name)
quoted_columns = ", ".join(quote_ident(column, cfg.db_type) for column in columns)
if cfg.db_type == "mysql":
placeholders = ", ".join(["%s"] * len(columns))
elif cfg.db_type == "oracle":
placeholders = ", ".join(f":{index}" for index in range(1, len(columns) + 1))
else:
placeholders = ", ".join(["?"] * len(columns))
sql = f"INSERT INTO {target} ({quoted_columns}) VALUES ({placeholders})"
values = [tuple(
_coerce_oracle_insert_value(item.get(column), payload.column_types.get(column))
if cfg.db_type == "oracle" else item.get(column)
for column in columns
) for item in payload.rows]
with DBConnection(cfg) as conn:
try:
for start in range(0, len(values), 1000):
conn.executemany(sql, values[start:start + 1000])
conn.commit()
except Exception:
conn.rollback()
raise
return {"ok": True, "inserted": len(values), "message": f"已插入 {len(values):,} 行"}
except Exception as exc:
raise HTTPException(400, f"插入失败,事务已回滚:{type(exc).__name__}: {exc}") from exc
_backup_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="backup")
def _run_backup(source_id: int, payload_data: dict, record_id: int):
"""后台流式导出。每次只保留一个 fetch batch,避免大表撑爆 API 进程。"""
db = SessionLocal()
record = db.get(OperationRecord, record_id)
payload = BackupRequest(**payload_data)
target = None
try:
row = db.get(DataSource, source_id)
if not row:
raise ValueError("数据源不存在")
cfg = _cfg(row)
_apply_database_override(cfg, payload.database)
schema = _resolve_schema(cfg, row, payload)
tables = list(dict.fromkeys(t.strip() for t in payload.tables if t and t.strip()))
if payload.scope == "tables" and not tables:
raise ValueError("至少选择一张表")
filename = _backup_filename(payload.filename)
target = get_backup_dir() / filename
if target.exists():
raise ValueError(f"备份文件已存在:{filename}")
tmp = target.with_suffix(target.suffix + ".part")
table_names = []
table_count = 0
incremental_fallback = []
previous_completed = None
if payload.backup_mode == "incremental":
previous_query = db.query(OperationRecord.created_at).filter(
OperationRecord.operation == "backup",
OperationRecord.source_id == source_id,
OperationRecord.database_name == (payload.database or row.database or ""),
OperationRecord.scope == payload.scope,
OperationRecord.task_id == (record.task_id if record else None),
OperationRecord.status == "已完成",
OperationRecord.id != record_id,
)
if payload.scope == "tables":
previous_query = previous_query.filter(
OperationRecord.tables_json == json.dumps(tables, ensure_ascii=False))
previous_completed = previous_query.order_by(OperationRecord.created_at.desc()).first()
previous_completed = previous_completed[0] if previous_completed else None
with DBConnection(cfg) as conn, tmp.open("w", encoding="utf-8") as fp:
fp.write('{"format":"db-tools-backup","version":1,"created_at":')
json.dump(datetime.utcnow().isoformat() + "Z", fp, ensure_ascii=False)
fp.write(',"source":')
json.dump({"id": row.id, "name": row.name, "db_type": row.db_type,
"host": row.host, "database": cfg.database, "schema": schema,
"scope": payload.scope, "backup_mode": payload.backup_mode}, fp, ensure_ascii=False)
fp.write(',"tables":[\n')
available = {str(item.get("table_name", "")): item for item in conn.list_tables(schema)}
available_upper = {key.upper(): value for key, value in available.items()}
if payload.scope == "database":
tables = list(available)
if not tables:
raise HTTPException(400, "该数据库没有可访问的数据表")
for table in tables:
raise ValueError("该数据库没有可访问的数据表")
for table_index, table in enumerate(tables):
meta = available.get(table) or available_upper.get(table.upper())
if not meta:
raise HTTPException(400, f"表不存在或无权访问:{table}")
raise ValueError(f"表不存在或无权访问:{table}")
actual_table = str(meta.get("table_name") or table)
columns = conn.list_columns(schema, actual_table) if payload.include_schema else []
item = {"table_name": actual_table, "table_comment": meta.get("table_comment"),
"columns": columns, "rows": []}
# 2026-09-03:MySQL 基表多存一份 SHOW CREATE TABLE 的原始 DDL ——
# 主键/自增/索引/唯一键/默认值/NOT NULL/注释/字符集/外键全保真,
# 还原到 MySQL 时优先按它建表。视图不存(table_type 过滤),走 columns 结构化路径
if (payload.include_schema and cfg.db_type == "mysql"
and str(meta.get("table_type") or "").upper() == "BASE TABLE"):
ddl_rows = conn.fetchall(
f"SHOW CREATE TABLE {quote_ident(actual_table, cfg.db_type)}")
"columns": conn.list_columns(schema, actual_table) if (payload.include_schema or payload.backup_mode == "incremental") else [], "rows": []}
if (payload.include_schema and cfg.db_type == "mysql" and
str(meta.get("table_type") or "").upper() == "BASE TABLE"):
ddl_rows = conn.fetchall(f"SHOW CREATE TABLE {quote_ident(actual_table, cfg.db_type)}")
r0 = ddl_rows[0] if ddl_rows else {}
ddl = str(r0.get("Create Table") or r0.get("create table") or "")
if ddl:
item["create_ddl"] = ddl
# 2026-09-28:Oracle 用 DBMS_METADATA 抓全量 DDL —— 主键/唯一/
# 外键/CHECK 约束、二级索引、表列注释、触发器及其引用的序列全保真,
# 还原侧按语句分类分阶段执行(见 operations.py restore_backup)
if ddl: item["create_ddl"] = ddl
if payload.include_schema and cfg.db_type == "oracle":
ddl_info = capture_table_ddl(conn, schema, actual_table)
if ddl_info.get("create_ddl"):
item["create_ddl"] = ddl_info["create_ddl"]
item["triggers"] = ddl_info["triggers"]
item["sequences"] = ddl_info["sequences"]
item.update({"create_ddl": ddl_info["create_ddl"], "triggers": ddl_info["triggers"], "sequences": ddl_info["sequences"]})
if table_index: fp.write(',\n')
fp.write('{"table_name":'); json.dump(item["table_name"], fp, ensure_ascii=False)
fp.write(',"table_comment":'); json.dump(item["table_comment"], fp, ensure_ascii=False)
fp.write(',"columns":'); json.dump(item["columns"], fp, ensure_ascii=False, default=_json_default)
for key in ("create_ddl", "triggers", "sequences"):
if key in item:
fp.write(',"' + key + '":'); json.dump(item[key], fp, ensure_ascii=False, default=_json_default)
fp.write(',"rows":[\n')
if payload.include_data:
first_row = True
sql = f"SELECT * FROM {_qualified_table(cfg, schema, actual_table)}"
item["rows"] = list(conn.iter_rows(sql))
result["tables"].append(item)
except HTTPException:
raise
if payload.backup_mode == "incremental":
column_names = {str(c.get("column_name") or "").lower() for c in item["columns"]}
changed_column = next((name for name in ("updated_at", "update_time", "modified_at", "last_modified") if name in column_names), None)
if changed_column and previous_completed:
placeholder = "%s" if cfg.db_type == "mysql" else (":1" if cfg.db_type == "oracle" else "?")
sql += f" WHERE {quote_ident(changed_column, cfg.db_type)} >= {placeholder}"
query_params = (previous_completed,)
else:
query_params = ()
if not changed_column:
incremental_fallback.append(actual_table)
else:
query_params = ()
for row_data in conn.iter_rows(sql, query_params, batch_size=1000):
if not first_row: fp.write(',\n')
json.dump(row_data, fp, ensure_ascii=False, default=_json_default)
first_row = False
fp.write(']}')
table_names.append(actual_table); table_count += 1
fp.write('\n]}')
tmp.replace(target)
# Keep list pages independent of the (potentially multi-GB) data file.
target.with_suffix(".meta.json").write_text(json.dumps({
"source": {"id": row.id, "name": row.name, "db_type": row.db_type,
"database": cfg.database, "schema": schema, "scope": payload.scope,
"backup_mode": payload.backup_mode},
"tables": table_names, "operation": "backup",
"trigger_type": record.trigger_type if record else "manual",
"task_id": record.task_id if record else None,
"task_name": record.task_name if record else "",
}, ensure_ascii=False), encoding="utf-8")
if record:
record.filename = filename
record.tables_json = json.dumps(table_names, ensure_ascii=False)
record.backup_mode = payload.backup_mode
record.status = "已完成"
note = f";{len(incremental_fallback)} 张表无更新时间字段,保留完整快照" if incremental_fallback else ""
record.execute_setting = f"备份完成:{table_count} 张表{note}"
record.error_message = ""
db.commit()
except Exception as exc:
raise HTTPException(502, f"备份失败:{type(exc).__name__}: {exc}") from exc
filename = _backup_filename(payload.filename)
target = get_backup_dir() / filename
if target.exists():
raise HTTPException(409, f"备份文件已存在:{filename}")
target.write_text(json.dumps(result, ensure_ascii=False, default=_json_default), encoding="utf-8")
record_operation(
db,
operation="backup",
source=row,
database=cfg.database,
scope=payload.scope,
tables=[item["table_name"] for item in result["tables"]],
filename=filename,
execute_setting="备份完成",
)
return {"filename": filename, "backup_dir": str(target.parent),
"tables": len(result["tables"]), "message": "备份完成"}
if target is not None:
part = target.with_suffix(target.suffix + ".part")
try: part.unlink(missing_ok=True)
except Exception: pass
if record:
record.status = "失败"
record.execute_setting = "备份失败"
record.error_message = f"{type(exc).__name__}: {exc}"[:1000]
db.commit()
finally:
db.close()
@router.post("/{source_id}/backup")
def backup_data_source(source_id: int, payload: BackupRequest, db: Session = Depends(get_session)):
"""创建后台备份任务并立即返回任务状态。"""
row = db.get(DataSource, source_id)
if not row:
raise HTTPException(404, "数据源不存在")
if payload.scope == "tables" and not payload.tables:
raise HTTPException(400, "至少选择一张表")
record = OperationRecord(operation="backup", source_id=row.id, source_name=row.name,
database_name=payload.database or row.database or "", scope=payload.scope,
tables_json=json.dumps(payload.tables, ensure_ascii=False), status="备份中",
trigger_type="manual", backup_mode=payload.backup_mode,
execute_setting="后台备份已启动")
db.add(record); db.commit(); db.refresh(record)
try:
_backup_executor.submit(_run_backup, source_id, payload.model_dump(), record.id)
except RuntimeError as exc:
record.status = "失败"
record.execute_setting = "备份未能启动"
record.error_message = str(exc)
db.commit()
raise HTTPException(503, "备份执行器不可用") from exc
return {"task_id": record.id, "status": record.status, "message": "备份已提交,正在后台执行"}
......@@ -5,6 +5,11 @@ import base64
import json
import os
import re
import tempfile
from pathlib import Path
from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor
import logging
from datetime import date, datetime, timedelta, time
from decimal import Decimal, InvalidOperation
......@@ -14,7 +19,10 @@ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from typing import Literal, Optional
from sqlalchemy import func
from sqlalchemy.orm import Session
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from web.backend.core.backup_storage import get_backup_dir, safe_backup_file, set_backup_dir
from web.backend.core.db_adapter import DBConnection, quote_ident
......@@ -24,7 +32,7 @@ from web.backend.core.oracle_ddl import (
split_long_create_table,
split_statements,
)
from web.backend.db.database import get_session
from web.backend.db.database import get_session, SessionLocal
from web.backend.models.data_source import DataSource
from web.backend.routers.data_sources import _cfg, _resolve_schema, _apply_database_override
from web.backend.core.operation_history import record_operation
......@@ -32,8 +40,12 @@ from web.backend.models.operation_record import OperationRecord
from web.backend.models.task import Task
from web.backend.models.table_design_config import TableDesignConfig
from web.backend.models.analysis import AnalysisRun, AnalysisTableSnapshot, AnalysisTask
from web.backend.models.backup_task import BackupTask
from web.backend.routers.data_sources import _backup_executor, _run_backup
router = APIRouter(prefix="/backup", tags=["backup"])
logger = logging.getLogger(__name__)
_backup_scheduler = BackgroundScheduler()
class DirectoryRequest(BaseModel):
......@@ -59,6 +71,120 @@ class DataCryptoRequest(BaseModel):
password: str = Field(..., min_length=4)
class BackupTaskInput(BaseModel):
name: str = Field(..., min_length=1, max_length=120)
source_id: int
database: str = Field(..., min_length=1, max_length=255)
schema: Optional[str] = None
scope: Literal["database", "tables"] = "database"
tables: list[str] = Field(default_factory=list)
backup_mode: Literal["full", "incremental"] = "full"
include_schema: bool = True
include_data: bool = True
cron_expression: str = Field(..., min_length=1, max_length=100)
enabled: bool = True
def _backup_cron(expression: str):
parts = expression.strip().split()
if len(parts) != 5:
raise HTTPException(422, "请输入 5 段 cron 表达式:分 时 日 月 周")
try:
return CronTrigger.from_crontab(" ".join(parts))
except ValueError as exc:
raise HTTPException(422, f"cron 表达式无效:{exc}") from exc
def _backup_task_dict(row: BackupTask):
job = _backup_scheduler.get_job(f"backup-{row.id}")
return row.to_dict(str(job.next_run_time) if job else None)
def _scheduled_backup(task_id: int):
db = SessionLocal()
try:
task = db.get(BackupTask, task_id)
source = db.get(DataSource, task.source_id) if task else None
if not task or not source or not task.enabled:
return
_submit_backup_task(db, task, source, "scheduled")
except Exception:
logger.exception("scheduled backup failed to submit: %s", task_id)
finally:
db.close()
def _schedule_backup_task(row: BackupTask):
job_id = f"backup-{row.id}"
if _backup_scheduler.get_job(job_id):
_backup_scheduler.remove_job(job_id)
if row.enabled:
_backup_scheduler.add_job(_scheduled_backup, _backup_cron(row.cron_expression), args=[row.id],
id=job_id, max_instances=1, coalesce=True, misfire_grace_time=60,
replace_existing=True)
def _submit_backup_task(db: Session, task: BackupTask, source: DataSource, trigger_type: str):
active = db.query(OperationRecord.id).filter(
OperationRecord.operation == "backup", OperationRecord.task_id == task.id,
OperationRecord.status == "备份中").first()
if active:
if trigger_type == "scheduled":
logger.info("scheduled backup skipped; task %s is still running", task.id)
return None
raise HTTPException(409, f"任务正在备份中(记录 #{active[0]})")
record = OperationRecord(
operation="backup", source_id=source.id, source_name=source.name,
database_name=task.database_name, scope=task.scope,
tables_json=task.tables_json, status="备份中",
trigger_type=trigger_type, backup_mode=task.backup_mode,
task_id=task.id, task_name=task.name,
execute_setting=f"{'自动' if trigger_type == 'scheduled' else '手动'}执行任务:{task.name}",
)
db.add(record)
db.commit()
db.refresh(record)
payload = {"scope": task.scope, "tables": json.loads(task.tables_json or "[]"),
"database": task.database_name, "schema": task.schema_name,
"include_schema": bool(task.include_schema), "include_data": bool(task.include_data),
"backup_mode": task.backup_mode}
try:
_backup_executor.submit(_run_backup, task.source_id, payload, record.id)
except RuntimeError as exc:
record.status = "失败"
record.error_message = str(exc)
db.commit()
raise HTTPException(503, "备份执行器不可用") from exc
return record
def start_backup_scheduler():
if _backup_scheduler.running:
return
# The in-process executors do not survive a server restart. Close orphaned
# records before accepting another run, so list pages never show them forever.
with SessionLocal() as db:
interrupted = db.query(OperationRecord).filter(
OperationRecord.operation.in_(["backup", "restore"]),
OperationRecord.status.in_(["备份中", "还原中"])).all()
for record in interrupted:
record.status = "失败"
record.execute_setting = "服务重启,任务中断"
record.error_message = "后台任务随服务重启而中断,请重新执行"
if interrupted:
db.commit()
_backup_scheduler.start()
with SessionLocal() as db:
for row in db.query(BackupTask).filter(BackupTask.enabled == 1).all():
try: _schedule_backup_task(row)
except Exception: logger.exception("failed to schedule backup task %s", row.id)
def stop_backup_scheduler():
if _backup_scheduler.running:
_backup_scheduler.shutdown(wait=False)
class RestoreRequest(FileRequest):
source_id: int
database: Optional[str] = None
......@@ -68,6 +194,211 @@ class RestoreRequest(FileRequest):
conflict: str = "skip" # skip | replace
_restore_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="restore")
class _JsonStream:
"""Small incremental JSON reader used for multi-GB backup files.
Backup files are written as a single JSON document. ``json.loads`` would
duplicate the complete document in memory during restore, so this reader
decodes one value at a time with ``raw_decode`` and keeps only a small text
buffer. It intentionally supports the JSON shape produced by the backup
writer and old compatible files.
"""
def __init__(self, path, chunk_size=1024 * 1024):
self.fp = open(path, "r", encoding="utf-8")
self.decoder = json.JSONDecoder()
self.buffer = ""
self.pos = 0
self.eof = False
self.chunk_size = chunk_size
def close(self):
self.fp.close()
def _fill(self):
if self.eof:
return False
chunk = self.fp.read(self.chunk_size)
if chunk:
self.buffer = self.buffer[self.pos:] + chunk
self.pos = 0
return True
self.buffer = self.buffer[self.pos:]
self.pos = 0
self.eof = True
return False
def _skip_ws(self):
while True:
while self.pos < len(self.buffer) and self.buffer[self.pos].isspace():
self.pos += 1
if self.pos < len(self.buffer) or self.eof:
return
self._fill()
def _char(self, expected=None):
self._skip_ws()
while self.pos >= len(self.buffer) and self._fill():
self._skip_ws()
if self.pos >= len(self.buffer):
raise ValueError("备份文件意外结束")
value = self.buffer[self.pos]
if expected is not None and value != expected:
raise ValueError(f"备份文件格式错误:期望 {expected!r},实际 {value!r}")
self.pos += 1
return value
def value(self):
self._skip_ws()
while True:
try:
value, end = self.decoder.raw_decode(self.buffer, self.pos)
self.pos = end
return value
except json.JSONDecodeError:
if not self._fill():
raise ValueError("备份文件包含无效 JSON")
def object_start(self):
self._char("{")
def array_start(self):
self._char("[")
def _iter_backup_tables(path):
"""Yield ``(source_meta, table_meta, row_iterator)`` one table at a time."""
reader = _JsonStream(path)
try:
reader.object_start()
source = {}
while True:
reader._skip_ws()
marker = reader._char()
if marker == "}":
return
if marker == ",":
marker = reader._char()
if marker == "}":
return
reader.pos -= 1
key = reader.value()
reader._char(":")
if key == "source":
source = reader.value() or {}
continue
if key != "tables":
reader.value()
continue
reader.array_start()
while True:
reader._skip_ws()
marker = reader._char()
if marker == "]":
reader.close()
return
reader.pos -= 1
reader.object_start()
metadata = {}
rows_started = False
row_iterator = iter(())
while True:
reader._skip_ws()
marker = reader._char()
if marker == "}":
break
if marker == ",":
marker = reader._char()
if marker == "}":
break
reader.pos -= 1
field = reader.value()
reader._char(":")
if field != "rows":
metadata[field] = reader.value()
reader._skip_ws()
continue
reader.array_start()
rows_started = True
def rows():
try:
while True:
reader._skip_ws()
marker = reader._char()
if marker == "]":
return
if marker == ",":
marker = reader._char()
if marker == "]":
return
reader.pos -= 1
yield reader.value()
finally:
# The table object and array separators are
# consumed by the outer generator after rows end.
pass
row_iterator = rows()
yield source, metadata, row_iterator
# The caller must consume rows before advancing. Drain a
# partially consumed iterator to restore parser alignment.
for _ in row_iterator:
pass
reader._skip_ws()
marker = reader._char()
if marker == ",":
continue
if marker == "}":
break
raise ValueError("备份表对象格式错误")
if not rows_started:
yield source, metadata, iter(())
reader._skip_ws()
marker = reader._char()
if marker == ",":
continue
if marker == "]":
reader.close()
return
raise ValueError("备份表列表格式错误")
finally:
if not reader.fp.closed:
reader.close()
class _SpoolRows:
def __init__(self, path: Path):
self.path = path
def __iter__(self):
with self.path.open("r", encoding="utf-8") as fp:
for line in fp:
yield json.loads(line)
def _spool_oracle_tables(source_path: Path, selected: set[str], directory: Path):
"""Keep Oracle's multi-stage DDL pipeline while bounding row memory."""
tables = []
source_meta = {}
for index, (origin, item, rows) in enumerate(_iter_backup_tables(source_path)):
source_meta = origin or source_meta
name = str(item.get("table_name") or "")
if selected and name.upper() not in selected:
continue
row_path = directory / f"table-{index}.jsonl"
with row_path.open("w", encoding="utf-8") as fp:
for row in rows:
fp.write(json.dumps(row, ensure_ascii=False, separators=(",", ":")))
fp.write("\n")
item["rows"] = _SpoolRows(row_path)
tables.append(item)
return source_meta, tables
class ClearRequest(BaseModel):
"""清空前备份指定库或表的请求。"""
source_id: int
......@@ -397,22 +728,121 @@ def update_backup_config(payload: DirectoryRequest):
raise HTTPException(400, f"备份目录不可用:{exc}") from exc
@router.get("/tasks")
def list_backup_tasks(db: Session = Depends(get_session)):
tasks = db.query(BackupTask).order_by(BackupTask.id.desc()).all()
if not tasks:
return []
task_ids = [task.id for task in tasks]
latest_ids = (db.query(OperationRecord.task_id.label("task_id"),
func.max(OperationRecord.id).label("record_id"))
.filter(OperationRecord.operation == "backup",
OperationRecord.task_id.in_(task_ids))
.group_by(OperationRecord.task_id).subquery())
latest = {record.task_id: record for record in
db.query(OperationRecord).join(latest_ids, OperationRecord.id == latest_ids.c.record_id).all()}
result = []
for task in tasks:
item = _backup_task_dict(task)
record = latest.get(task.id)
item["last_record_id"] = record.id if record else None
item["last_status"] = record.status if record else "未执行"
item["last_run_at"] = record.created_at if record else None
item["last_trigger_type"] = record.trigger_type if record else None
item["last_error"] = record.error_message or "" if record else ""
result.append(item)
return result
@router.post("/tasks/{task_id}/run", status_code=202)
def run_backup_task(task_id: int, db: Session = Depends(get_session)):
task = db.get(BackupTask, task_id)
if not task:
raise HTTPException(404, "备份任务不存在")
source = db.get(DataSource, task.source_id)
if not source:
raise HTTPException(404, "任务的数据源不存在")
record = _submit_backup_task(db, task, source, "manual")
return {"record_id": record.id, "status": record.status, "message": "任务已提交,正在后台备份"}
@router.post("/tasks", status_code=201)
def create_backup_task(payload: BackupTaskInput, db: Session = Depends(get_session)):
_backup_cron(payload.cron_expression)
if payload.scope == "tables" and not payload.tables:
raise HTTPException(422, "指定表模式至少选择一张表")
if not db.get(DataSource, payload.source_id):
raise HTTPException(404, "数据源不存在")
row = BackupTask(name=payload.name.strip(), source_id=payload.source_id, database_name=payload.database.strip(),
schema_name=(payload.schema or "").strip() or None, scope=payload.scope,
tables_json=json.dumps(list(dict.fromkeys(payload.tables)), ensure_ascii=False),
backup_mode=payload.backup_mode, include_schema=int(payload.include_schema),
include_data=int(payload.include_data), cron_expression=payload.cron_expression.strip(),
enabled=int(payload.enabled))
db.add(row); db.commit(); db.refresh(row)
_schedule_backup_task(row)
return _backup_task_dict(row)
@router.put("/tasks/{task_id}")
def update_backup_task(task_id: int, payload: BackupTaskInput, db: Session = Depends(get_session)):
_backup_cron(payload.cron_expression)
row = db.get(BackupTask, task_id)
if not row:
raise HTTPException(404, "备份任务不存在")
if payload.scope == "tables" and not payload.tables:
raise HTTPException(422, "指定表模式至少选择一张表")
if not db.get(DataSource, payload.source_id):
raise HTTPException(404, "数据源不存在")
for key, value in {"name": payload.name.strip(), "source_id": payload.source_id,
"database_name": payload.database.strip(), "schema_name": (payload.schema or "").strip() or None,
"scope": payload.scope, "tables_json": json.dumps(list(dict.fromkeys(payload.tables)), ensure_ascii=False),
"backup_mode": payload.backup_mode, "include_schema": int(payload.include_schema),
"include_data": int(payload.include_data), "cron_expression": payload.cron_expression.strip(),
"enabled": int(payload.enabled)}.items(): setattr(row, key, value)
db.commit(); db.refresh(row); _schedule_backup_task(row)
return _backup_task_dict(row)
@router.delete("/tasks/{task_id}", status_code=204)
def delete_backup_task(task_id: int, db: Session = Depends(get_session)):
row = db.get(BackupTask, task_id)
if not row:
raise HTTPException(404, "备份任务不存在")
job = _backup_scheduler.get_job(f"backup-{task_id}")
if job: _backup_scheduler.remove_job(f"backup-{task_id}")
db.delete(row); db.commit()
@router.get("/versions")
def backup_versions():
files = []
for path in sorted(get_backup_dir().iterdir(), key=lambda p: p.stat().st_mtime, reverse=True):
if path.is_file() and (path.name.endswith(".json") or path.name.endswith(".dbx")):
if path.name.endswith(".meta.json"):
continue
item = {"filename": path.name, "size": path.stat().st_size,
"created_at": datetime.fromtimestamp(path.stat().st_mtime).isoformat(),
"encrypted": path.suffix == ".dbx"}
if path.suffix == ".json":
try:
package = json.loads(path.read_text(encoding="utf-8"))
meta_path = path.with_suffix(".meta.json")
# Never parse a legacy multi-GB backup just to render the version list.
package = (json.loads(meta_path.read_text(encoding="utf-8"))
if meta_path.is_file() else
(json.loads(path.read_text(encoding="utf-8")) if path.stat().st_size <= 20 * 1024 * 1024 else {}))
source = package.get("source") or {}
raw_tables = package.get("tables") or []
item["source"] = {"id": source.get("id"), "name": source.get("name"),
"db_type": source.get("db_type"), "database": source.get("database"), "schema": source.get("schema"),
"scope": source.get("scope", "database")}
item["tables"] = [t.get("table_name") for t in package.get("tables", []) if t.get("table_name")]
"scope": source.get("scope", "database"),
"backup_mode": source.get("backup_mode", "full")}
item["trigger_type"] = package.get("trigger_type", "manual")
item["task_id"] = package.get("task_id")
item["task_name"] = package.get("task_name", "")
item["tables"] = [t if isinstance(t, str) else t.get("table_name")
for t in raw_tables if isinstance(t, str) or
(isinstance(t, dict) and t.get("table_name"))]
item["operation"] = package.get("operation")
except (OSError, ValueError, TypeError):
pass
......@@ -453,7 +883,8 @@ def dashboard_summary(db: Session = Depends(get_session)):
backup_dir = get_backup_dir()
versions = sum(
1 for path in backup_dir.iterdir()
if path.is_file() and (path.name.endswith(".json") or path.name.endswith(".dbx"))
if path.is_file() and not path.name.endswith(".meta.json")
and (path.name.endswith(".json") or path.name.endswith(".dbx"))
)
analysis_task_count = int(db.query(func.count(AnalysisTask.id)).scalar() or 0)
analysis_enabled_count = int(db.query(func.count(AnalysisTask.id)).filter(AnalysisTask.enabled == 1).scalar() or 0)
......@@ -831,7 +1262,7 @@ def _oracle_bind_value(value, data_type: str):
return value
def _restore_rows(conn, cfg, qualified, columns, rows):
def _restore_rows(conn, cfg, qualified, columns, rows, incremental_merge=False):
"""类型感知 + 报文预算内逐行还原数据(三方言通用)。
2026-09-28 实测:内网安全设备对单条 SQL 的「语句文本 + 绑定值」总字节
......@@ -845,10 +1276,17 @@ def _restore_rows(conn, cfg, qualified, columns, rows):
executemany 会把多行合并成大批量报文,一并弃用,逐行执行。
"""
names = [str(c.get("column_name")) for c in columns if c.get("column_name")]
if not names or not rows:
if not names:
return 0
types = {str(c.get("column_name")): str(c.get("data_type") or "") for c in columns}
pk = _mysql_pk_columns(conn, qualified) if cfg.db_type == "mysql" else []
if incremental_merge:
if cfg.db_type != "mysql" or not pk:
raise ValueError("增量还原到已有表需要 MySQL 主键;请使用全量备份还原")
if any(key.lower() not in {name.lower() for name in names} for key in pk):
raise ValueError("增量备份缺少目标表主键字段,无法安全合并")
delete_sql = (f"DELETE FROM {qualified} WHERE " +
" AND ".join(f"{quote_ident(key, cfg.db_type)} = %s" for key in pk))
# MySQL 的 create_ddl 建表一步带约束:NOT NULL 且无默认值的列不进首批
# INSERT 会直接报错(Oracle/达梦约束后置到阶段C,无此问题)—— 必须优先插
notnull_no_default = set()
......@@ -858,11 +1296,18 @@ def _restore_rows(conn, cfg, qualified, columns, rows):
if name and str(c.get("is_nullable") or "YES").upper() == "NO" \
and c.get("column_default") is None:
notnull_no_default.add(name)
restored = 0
for row in rows:
if incremental_merge:
key_values = tuple(row.get(key.lower(), row.get(key)) for key in pk)
if any(value is None for value in key_values):
raise ValueError("增量备份行缺少主键值,无法安全合并")
conn.execute(delete_sql, key_values)
values = [_oracle_bind_value(row.get(n.lower(), row.get(n)), types.get(n))
for n in names]
_insert_row_in_budget(conn, cfg, qualified, names, values, pk, notnull_no_default)
return len(rows)
restored += 1
return restored
# 报文预算:设备对单条 SQL 的线上报文限 ~1KB。语句文本之外,每个绑定参数
......@@ -996,15 +1441,21 @@ def _q(name, db_type) -> str:
_MYSQL_KEY_LEADS = ("PRIMARY KEY", "UNIQUE KEY", "KEY ", "KEY`", "CONSTRAINT", "INDEX ", "FULLTEXT", "SPATIAL")
def _mysql_literal_exec(conn, sql: str) -> None:
"""pymysql 对无参语句也会做 % 格式化 —— DDL 里的字面 %(注释如「(%)」、
默认值如 '%Y-%m-%d')必须转义成 %%,否则 TypeError: not enough arguments。"""
conn.execute(sql.replace("%", "%%") if "%" in sql else sql)
def _execute_mysql_create_dd(conn, create_ddl: str) -> None:
if len(create_ddl) <= 800:
conn.execute(create_ddl)
_mysql_literal_exec(conn, create_ddl)
return
lines = [l.rstrip() for l in create_ddl.splitlines()]
# 头部:含 CREATE TABLE 的行;'(' 可能在同行(CREATE TABLE `t` ()或下一行
header_idx = next((i for i, l in enumerate(lines) if "CREATE TABLE" in l.upper()), None)
if header_idx is None:
conn.execute(create_ddl)
_mysql_literal_exec(conn, create_ddl)
return
table_part = lines[header_idx]
body_start = header_idx + 1
......@@ -1012,12 +1463,12 @@ def _execute_mysql_create_dd(conn, create_ddl: str) -> None:
if body_start < len(lines) and lines[body_start].strip() == "(":
body_start += 1
else:
conn.execute(create_ddl) # 认不出结构,原句执行让数据库报错
_mysql_literal_exec(conn, create_ddl) # 认不出结构,原句执行让数据库报错
return
close_idx = next((i for i in range(len(lines) - 1, body_start - 1, -1)
if lines[i].strip().startswith(")")), None)
if close_idx is None:
conn.execute(create_ddl)
_mysql_literal_exec(conn, create_ddl)
return
body = [l.strip().rstrip(",") for l in lines[body_start:close_idx] if l.strip()]
options = lines[close_idx].strip()[1:].strip() # ) ENGINE=... COMMENT='x'
......@@ -1048,23 +1499,23 @@ def _execute_mysql_create_dd(conn, create_ddl: str) -> None:
table_sql = f"{head} (\n" + ",\n".join(create_body) + f"\n){options_no_comment or ''}"
if len(table_sql) > 1500:
raise ValueError(f"MySQL 建表语句分批后仍超长({len(table_sql)}B),无法安全执行")
conn.execute(table_sql)
_mysql_literal_exec(conn, table_sql)
table_ref = table_part.replace("CREATE TABLE", "", 1).strip().rstrip("(").strip()
# ② 其余列逐条补(ALTER ADD 一条一列,语句天然短)
for i, d in enumerate(bare_cols):
if i not in first_idx:
conn.execute(f"ALTER TABLE {table_ref} ADD COLUMN {d}")
_mysql_literal_exec(conn, f"ALTER TABLE {table_ref} ADD COLUMN {d}")
# ③ 索引/约束逐条补(主键已随首批)
for key_def in key_defs:
if not key_def.upper().startswith("PRIMARY"):
conn.execute(f"ALTER TABLE {table_ref} ADD {key_def}")
_mysql_literal_exec(conn, f"ALTER TABLE {table_ref} ADD {key_def}")
# ④ 表注释 + 列注释逐条补(MODIFY 需要完整列定义,直接用原文行)
if table_comment_m:
comment = table_comment_m.group(1).replace("''", "'")
conn.execute(f"ALTER TABLE {table_ref} COMMENT = %s", (comment,))
for col_def in column_defs:
if re.search(r"COMMENT\s+'(?:[^']|'')*'", col_def, re.I):
conn.execute(f"ALTER TABLE {table_ref} MODIFY COLUMN {col_def}")
_mysql_literal_exec(conn, f"ALTER TABLE {table_ref} MODIFY COLUMN {col_def}")
def _create_table_from_columns(conn, cfg, qualified, table, columns, table_comment):
......@@ -1089,7 +1540,10 @@ def _create_table_from_columns(conn, cfg, qualified, table, columns, table_comme
create_sql = f"CREATE TABLE {qualified} ({', '.join(definitions)})"
if table_comment and cfg.db_type == "mysql":
create_sql += f" COMMENT='{_sql_text(table_comment)}'"
conn.execute(create_sql)
if cfg.db_type == "mysql":
_mysql_literal_exec(conn, create_sql)
else:
conn.execute(create_sql)
if cfg.db_type in ("oracle", "dameng"):
if table_comment:
conn.execute(f"COMMENT ON TABLE {qualified} IS '{_sql_text(table_comment)}'")
......@@ -1279,6 +1733,151 @@ def _oracle_restore_pipeline(conn, cfg, schema, tables, conflict, src_schema):
return len(plan), skipped, []
def _run_non_oracle_streaming_restore(source_path, payload, row):
"""Restore MySQL/达梦 backups without materializing the JSON document."""
cfg = _cfg(row)
if payload.database:
_apply_database_override(cfg, payload.database)
selected = {name.strip().upper() for name in payload.tables if name and name.strip()}
if payload.scope == "tables" and not selected:
raise ValueError("至少选择一张还原表")
existing_cache = None
restored_rows = 0
restored_tables = 0
skipped = 0
table_count = 0
backup_mode = "full"
with DBConnection(cfg) as conn:
schema = _resolve_schema(cfg, row, payload)
# A connection-local switch avoids FK order failures while each table
# is restored independently. It is automatically reset on close.
if cfg.db_type == "mysql":
conn.execute("SET FOREIGN_KEY_CHECKS=0")
for source_meta, item, rows in _iter_backup_tables(source_path):
table_count += 1
source_meta = source_meta or {}
backup_mode = str(source_meta.get("backup_mode") or "full")
source_type = str(source_meta.get("db_type") or "").lower()
if source_type and source_type != cfg.db_type:
raise ValueError("备份版本的数据库类型与目标不一致")
table = str(item.get("table_name") or "")
if not table:
for _ in rows:
pass
continue
if payload.scope == "tables" and table.upper() not in selected:
for _ in rows:
pass
continue
if existing_cache is None:
existing_cache = {str(x.get("table_name", "")).upper(): str(x.get("table_name", ""))
for x in conn.list_tables(schema)}
actual = existing_cache.get(table.upper())
incremental_apply = backup_mode == "incremental" and bool(actual)
if actual and payload.conflict == "skip" and not incremental_apply:
skipped += 1
for _ in rows:
pass
continue
name = actual or table
qualified = quote_ident(name, cfg.db_type)
if schema and cfg.db_type == "dameng":
qualified = f"{quote_ident(schema, cfg.db_type)}.{qualified}"
if actual and not incremental_apply:
conn.execute(f"DROP TABLE {qualified}")
existing_cache.pop(table.upper(), None)
# 2026-09-30:DROP 后必须重建表。此前 actual 仍为真值,下方
# 「not actual 才建表」的分支被跳过,INSERT 直接报表不存在(1146)
actual = None
columns = item.get("columns") or []
create_ddl = str(item.get("create_ddl") or "").strip()
if not actual and create_ddl and cfg.db_type == "mysql":
_execute_mysql_create_dd(conn, create_ddl)
elif not actual:
_create_table_from_columns(conn, cfg, qualified, table, columns,
str(item.get("table_comment") or "").strip())
restored_rows += _restore_rows(conn, cfg, qualified, columns, rows,
incremental_merge=incremental_apply)
restored_tables += 1
conn.commit()
existing_cache[table.upper()] = name
if table_count == 0:
raise ValueError("备份中没有表")
if payload.scope == "tables" and restored_tables == 0 and skipped == 0:
raise ValueError("所选还原表不在备份版本中")
return restored_tables, restored_rows, skipped, backup_mode
def _run_restore(payload_data: dict, record_id: int):
db = SessionLocal()
record = None
try:
payload = RestoreRequest(**payload_data)
record = db.get(OperationRecord, record_id)
source = safe_backup_file(payload.filename)
if not source.is_file() or source.suffix != ".json":
raise ValueError("还原需要解密后的 .json 备份文件")
if payload.conflict not in ("skip", "replace"):
raise ValueError("冲突处理方式无效")
row = db.get(DataSource, payload.source_id)
if not row:
raise ValueError("数据源不存在")
if _cfg(row).db_type != "oracle":
restored, restored_rows, skipped, backup_mode = _run_non_oracle_streaming_restore(
source, payload, row)
message = f"还原完成:{restored} 张表、{restored_rows} 行,跳过 {skipped} 张表"
if record:
record.backup_mode = backup_mode
record.status = "已还原"
record.execute_setting = message[:400]
record.error_message = ""
db.commit()
return {"restored": restored, "skipped": skipped, "message": message}
selected = {name.strip().upper() for name in payload.tables if name and name.strip()}
if payload.scope == "tables" and not selected:
raise ValueError("至少选择一张还原表")
with tempfile.TemporaryDirectory(prefix="oracle-restore-", dir=get_backup_dir()) as temp_dir:
source_meta, tables = _spool_oracle_tables(
source, selected if payload.scope == "tables" else set(), Path(temp_dir))
if not tables:
raise ValueError("所选还原表不在备份版本中" if payload.scope == "tables" else "备份中没有表")
backup_mode = str(source_meta.get("backup_mode") or "full")
if record:
record.backup_mode = backup_mode
db.commit()
if str(source_meta.get("db_type") or "").lower() != "oracle":
raise ValueError("Oracle 目标只能还原带完整 Oracle DDL 的 Oracle 备份")
if backup_mode == "incremental":
raise ValueError("Oracle 增量备份暂不支持直接还原,请使用全量备份")
cfg = _cfg(row)
if payload.database:
_apply_database_override(cfg, payload.database)
with DBConnection(cfg) as conn:
schema = _resolve_schema(cfg, row, payload)
restored, skipped, fk_notes = _oracle_restore_pipeline(
conn, cfg, schema, tables, payload.conflict,
str(source_meta.get("schema") or ""))
message = f"还原完成:{restored} 张表,跳过 {skipped} 张表"
if fk_notes:
message += ";" + ";".join(fk_notes)
if record:
record.status = "已还原"
record.execute_setting = message[:400]
record.error_message = ""
db.commit()
return {"restored": restored, "skipped": skipped, "message": message}
except Exception as exc:
logger.exception("restore failed for record %s", record_id)
if record:
record.status = "失败"
record.execute_setting = "还原失败"
record.error_message = f"{type(exc).__name__}: {exc}"[:1000]
db.commit()
finally:
db.close()
@router.post("/restore")
def restore_backup(payload: RestoreRequest, db: Session = Depends(get_session)):
try:
......@@ -1289,93 +1888,38 @@ def restore_backup(payload: RestoreRequest, db: Session = Depends(get_session)):
raise HTTPException(400, "还原需要解密后的 .json 备份文件")
if payload.conflict not in ("skip", "replace"):
raise HTTPException(400, "冲突处理方式无效")
row = db.get(DataSource, payload.source_id)
if not row:
source_row = db.get(DataSource, payload.source_id)
if not source_row:
raise HTTPException(404, "数据源不存在")
backup_mode = ""
backup_task_id = None
backup_task_name = ""
try:
package = json.loads(source.read_text(encoding="utf-8"))
tables = package.get("tables") or []
if not tables:
raise ValueError("备份中没有表")
cfg = _cfg(row)
source_type = str((package.get("source") or {}).get("db_type") or "").lower()
if cfg.db_type == "oracle" and source_type != "oracle":
raise ValueError("Oracle 目标只能还原带完整 Oracle DDL 的 Oracle 备份")
if payload.database:
_apply_database_override(cfg, payload.database)
with DBConnection(cfg) as conn:
schema = _resolve_schema(cfg, row, payload)
if payload.scope == "tables":
selected = {name.strip().upper() for name in payload.tables if name and name.strip()}
if not selected:
raise ValueError("至少选择一张还原表")
tables = [item for item in tables if str(item.get("table_name", "")).upper() in selected]
if not tables:
raise ValueError("所选还原表不在备份版本中")
fk_notes: list[str] = []
if cfg.db_type == "oracle":
# 2026-09-28:Oracle 走结构保真管线(DDL 分阶段 + 类型感知插数),
# 见 _oracle_restore_pipeline
restored, skipped, fk_notes = _oracle_restore_pipeline(
conn, cfg, schema, tables, payload.conflict,
str((package.get("source") or {}).get("schema") or ""))
else:
existing = {str(x.get("table_name", "")).upper(): str(x.get("table_name", "")) for x in conn.list_tables(schema)}
restored = 0
skipped = 0
# 2026-09-03:MySQL 还原期间 session 级关掉外键检查 —— 循环按备份顺序
# DROP/建表/插数据,被外键引用的表可能还没建(如子表排在前),
# 关掉后任意顺序都成立。连接关闭(无论还原成败)后 session 变量随之
# 消失,下次连接自动回到默认 ON,不需要手动恢复
if cfg.db_type == "mysql":
conn.execute("SET FOREIGN_KEY_CHECKS=0")
for item in tables:
table = str(item.get("table_name") or "")
if not table:
continue
actual = existing.get(table.upper())
name = actual or table
qualified = quote_ident(name, cfg.db_type)
if schema and cfg.db_type in ("oracle", "dameng"):
qualified = f"{quote_ident(schema, cfg.db_type)}.{qualified}"
if actual and payload.conflict == "skip":
skipped += 1
continue
if actual:
conn.execute(f"DROP TABLE {qualified}")
columns = item.get("columns") or []
create_ddl = str(item.get("create_ddl") or "").strip()
if create_ddl and cfg.db_type == "mysql":
# 备份带了 SHOW CREATE TABLE 原始 DDL —— 主键/自增/索引/
# 唯一键/默认值/NOT NULL/注释/字符集/外键全保真。
# 2026-09-28:大 DDL 拆「裸表 + 索引/注释小语句」分批执行
# (整条超内网设备报文上限会被掐)
_execute_mysql_create_dd(conn, create_ddl)
else:
# 无 DDL(老备份 / 跨方言还原目标):按 columns 结构化重建。
# 2026-09-03:还原时把备份里的表/字段中文注释一起建回去。
_create_table_from_columns(conn, cfg, qualified, table, columns,
str(item.get("table_comment") or "").strip())
# 2026-09-28:数据插入统一走报文预算分批(宽表/大行单条
# INSERT 会被内网设备掐断),三方言同一实现
_restore_rows(conn, cfg, qualified, columns, item.get("rows") or [])
conn.commit()
restored += 1
message = f"还原完成:{restored} 张表,跳过 {skipped} 张表"
if fk_notes:
message += ";" + ";".join(fk_notes)
record_operation(
db,
operation="restore",
source=row,
database=payload.database or schema,
scope=payload.scope,
tables=[str(item.get("table_name") or "") for item in tables],
filename=payload.filename,
execute_setting=message,
)
return {"restored": restored, "skipped": skipped, "message": message}
except HTTPException:
raise
except Exception as exc:
raise HTTPException(502, f"还原失败:{type(exc).__name__}: {exc}") from exc
metadata = source.with_suffix(".meta.json")
package = json.loads(metadata.read_text(encoding="utf-8")) if metadata.is_file() else {}
backup_mode = str((package.get("source") or {}).get("backup_mode") or "")
backup_task_id = package.get("task_id")
backup_task_name = str(package.get("task_name") or "")
if not backup_mode and source.stat().st_size <= 20 * 1024 * 1024:
package = json.loads(source.read_text(encoding="utf-8"))
backup_mode = str((package.get("source") or {}).get("backup_mode") or "")
backup_task_id = package.get("task_id")
backup_task_name = str(package.get("task_name") or "")
except (OSError, ValueError, TypeError):
backup_mode = ""
record = OperationRecord(operation="restore", source_id=source_row.id, source_name=source_row.name,
database_name=payload.database or "", scope=payload.scope,
tables_json=json.dumps(payload.tables, ensure_ascii=False), filename=payload.filename,
trigger_type="manual", backup_mode=backup_mode,
task_id=backup_task_id, task_name=backup_task_name,
status="还原中", execute_setting="手动还原已提交")
db.add(record); db.commit(); db.refresh(record)
try:
_restore_executor.submit(_run_restore, payload.model_dump(), record.id)
except RuntimeError as exc:
record.status = "失败"
record.execute_setting = "还原未能启动"
record.error_message = str(exc)
db.commit()
raise HTTPException(503, "还原执行器不可用") from exc
return {"task_id": record.id, "status": record.status, "message": "还原已提交,正在后台执行"}
......@@ -444,9 +444,19 @@ def start_query(req: StartQueryRequest, db: Session = Depends(get_session)):
count_row = dbc.fetchone(count_sql)
total_rows = int((count_row or {}).get("n") or 0)
if schema:
# 表设置需要能选择未配置校验规则的字段。规则字段仍排在前面,
# 其余字段补入 field_list,并加入显式 SELECT,避免前端只能看到问题字段。
# Oracle 的 conn.db 是 service name,不能拿来当 OWNER;未填写
# schema 时使用连接用户读取当前用户表的字段元数据。
meta_schema = schema
if not meta_schema:
if cfg.db_type == "oracle":
meta_schema = ((conn or {}).get("user") or "").strip()
else:
meta_schema = ((conn or {}).get("db") or "").strip()
if meta_schema:
try:
col_meta = dbc.list_columns(schema, task.source_table) or []
col_meta = dbc.list_columns(meta_schema, task.source_table) or []
comments_by_key: dict[str, str] = {
(m.get("column_name") or "").lower(): (m.get("column_comment") or "")
for m in col_meta
......@@ -456,6 +466,22 @@ def start_query(req: StartQueryRequest, db: Session = Depends(get_session)):
c = comments_by_key.get(f_out["field_key"].lower(), "")
if c:
f_out["field_comment"] = c
existing = {f["field_key"].lower() for f in field_list_out}
for meta in col_meta:
field_key = (meta.get("column_name") or "").strip()
if not field_key or field_key.lower() in existing:
continue
field_list_out.append({
"id": None,
"field_key": field_key,
"field_comment": meta.get("column_comment") or "",
"show_default": False,
"ord": len(field_list_out),
"rules": 0,
"rule_list": [],
})
col_names.append(field_key)
existing.add(field_key.lower())
except Exception as e:
logger.warning(f"[queries] 获取字段注释失败,跳过:{type(e).__name__}: {e}")
except Exception as e:
......
import unittest
from web.backend.routers.analysis import _column_changes, _generate_sql
def column(name, position, column_type="VARCHAR2(1)", default=None):
return {"column_name": name, "ordinal_position": position,
"column_type": column_type, "data_type": "VARCHAR2",
"char_max_length": 1, "numeric_precision": None, "numeric_scale": None,
"is_nullable": "N", "column_default": default,
"column_comment": None, "extra": None}
class AnalysisReportDiffTests(unittest.TestCase):
def test_removal_does_not_modify_shifted_columns_or_generate_modify_sql(self):
old = {c["column_name"]: c for c in
[column("FIRST", 1), column("REMOVED", 2), column("ISHJTS", 3)]}
current = {c["column_name"]: c for c in
[column("FIRST", 1), column("ISHJTS", 2)]}
changes = _column_changes(old, current)
self.assertEqual([(item["column"], item["kind"]) for item in changes],
[("REMOVED", "removed")])
sql = _generate_sql({"table_name": "GD_GRDK", "columns": current,
"column_changes": changes}, "oracle")["diff_sql"]
self.assertIn('DROP COLUMN "REMOVED"', sql)
self.assertNotIn("MODIFY", sql)
def test_real_property_change_is_reported(self):
old = {"ISHJTS": column("ISHJTS", 1, default="'0'")}
current = {"ISHJTS": column("ISHJTS", 1, default="'1'")}
changes = _column_changes(old, current)
self.assertEqual(changes[0]["kind"], "changed")
self.assertEqual(changes[0]["changed_fields"], ["column_default"])
def test_reorder_of_existing_columns_is_reported(self):
old = {"A": column("A", 1), "B": column("B", 2)}
current = {"A": column("A", 2), "B": column("B", 1)}
changes = _column_changes(old, current)
self.assertEqual([item["changed_fields"] for item in changes],
[["ordinal_position"], ["ordinal_position"]])
......@@ -7,6 +7,11 @@ export const listOperationRecords = (operation) => http.get('/backup/records', {
export const importOperationRecords = (operation, records) => http.post(`/backup/records/import?operation=${encodeURIComponent(operation)}`, { records })
export const createBackup = (sourceId, payload) => http.post(`/data-sources/${sourceId}/backup`, payload)
export const restoreBackup = (payload) => http.post('/backup/restore', payload)
export const listBackupTasks = () => http.get('/backup/tasks')
export const createBackupTask = (payload) => http.post('/backup/tasks', payload)
export const updateBackupTask = (id, payload) => http.put(`/backup/tasks/${id}`, payload)
export const deleteBackupTask = (id) => http.delete(`/backup/tasks/${id}`)
export const runBackupTask = (id) => http.post(`/backup/tasks/${id}/run`)
export const encryptBackup = (payload) => http.post('/backup/encrypt', payload)
export const decryptBackup = (payload) => http.post('/backup/decrypt', payload)
export const encryptData = (payload) => http.post('/backup/data-encrypt', payload)
......
......@@ -5,3 +5,4 @@ export const createDataSource = (payload) => http.post('/data-sources', payload)
export const updateDataSource = (id, payload) => http.put(`/data-sources/${id}`, payload)
export const deleteDataSource = (id) => http.delete(`/data-sources/${id}`)
export const testDataSource = (id) => http.post(`/data-sources/${id}/test`)
export const insertSimulatedData = (id, payload) => http.post(`/data-sources/${id}/simulate/insert`, payload)
......@@ -59,6 +59,7 @@
</el-menu-item>
<el-sub-menu index="backup">
<template #title><el-icon><Download /></el-icon><span>备份</span></template>
<el-menu-item index="/backup/tasks"><template #title>任务</template></el-menu-item>
<el-menu-item index="/backup"><template #title>备份</template></el-menu-item>
<el-menu-item index="/restore"><template #title>还原</template></el-menu-item>
<el-menu-item index="/clear-tables"><template #title>清表</template></el-menu-item>
......@@ -74,13 +75,17 @@
<el-menu-item index="/validation"><template #title>数据</template></el-menu-item>
</el-sub-menu>
<el-sub-menu index="analysis">
<template #title><el-icon><TrendCharts /></el-icon><span>分析</span></template>
<template #title><el-icon><TrendCharts /></el-icon><span>监测</span></template>
<el-menu-item index="/analysis/tasks"><template #title>任务</template></el-menu-item>
<el-menu-item index="/analysis/reports"><template #title>报告</template></el-menu-item>
</el-sub-menu>
<el-sub-menu index="data">
<template #title><el-icon><DataAnalysis /></el-icon><span>数据</span></template>
<el-menu-item index="/data/simulation"><template #title>模拟</template></el-menu-item>
<el-menu-item index="/config/data-sources"><template #title>数据源</template></el-menu-item>
</el-sub-menu>
<el-sub-menu index="config">
<template #title><el-icon><Setting /></el-icon><span>配置</span></template>
<el-menu-item index="/config/data-sources"><template #title>数据源</template></el-menu-item>
<el-menu-item index="/config/rule-config"><template #title>规则</template></el-menu-item>
<el-menu-item index="/config/system"><template #title>配置</template></el-menu-item>
<el-menu-item index="/config/model"><template #title>模型</template></el-menu-item>
......@@ -118,11 +123,12 @@ import { listTaskGroups } from '@/api/taskGroups'
import { fontScaleOptions, useUiPrefs } from '@/composables/useUiPrefs'
import brandLogoDark from '@/assets/brand-logo-dark.png'
import brandLogoLight from '@/assets/brand-logo-light.png'
import { DataAnalysis } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const sidebarCollapsed = ref(false)
const openGroups = ref(['backup', 'crypto', 'config', 'validation', 'analysis'])
const openGroups = ref(['backup', 'crypto', 'config', 'validation', 'analysis', 'data'])
// ── UI 偏好(主题 / 字号,见 composables/useUiPrefs.js)────
const { theme, fontScale, currentScaleLabel, themeTip, themeIcon, setFontScale, toggleTheme } = useUiPrefs()
......
......@@ -5,6 +5,7 @@ import DataQualityView from '@/views/DataQualityView.vue'
import TaskConfigView from '@/views/TaskConfigView.vue'
import DataSourceView from '@/views/DataSourceView.vue'
import OperationView from '@/views/OperationView.vue'
import BackupTasksView from '@/views/BackupTasksView.vue'
import ClearTablesView from '@/views/ClearTablesView.vue'
import SystemConfigView from '@/views/SystemConfigView.vue'
import ModelConfigView from '@/views/ModelConfigView.vue'
......@@ -15,6 +16,7 @@ import HomeView from '@/views/HomeView.vue'
import AnalysisTasksView from '@/views/AnalysisTasksView.vue'
import AnalysisReportsView from '@/views/AnalysisReportsView.vue'
import AnalysisReportDetailView from '@/views/AnalysisReportDetailView.vue'
import DataSimulationView from '@/views/DataSimulationView.vue'
const routes = [
{
......@@ -47,7 +49,9 @@ const routes = [
{ path: 'config/data-sources', name: 'DataSources', component: DataSourceView, meta: { title: '数据源' } },
{ path: 'config/system', name: 'SystemConfig', component: SystemConfigView, meta: { title: '系统配置' } },
{ path: 'config/model', name: 'ModelConfig', component: ModelConfigView, meta: { title: '模型配置' } },
{ path: 'data/simulation', name: 'DataSimulation', component: DataSimulationView, meta: { title: '数据模拟' } },
{ path: 'backup', name: 'Backup', component: OperationView, props: { type: 'backup' }, meta: { title: '备份' } },
{ path: 'backup/tasks', name: 'BackupTasks', component: BackupTasksView, meta: { title: '任务' } },
{ path: 'restore', name: 'Restore', component: OperationView, props: { type: 'restore' }, meta: { title: '还原' } },
{ path: 'clear-tables', name: 'ClearTables', component: ClearTablesView, meta: { title: '清表' } },
{ path: 'encrypt', name: 'Encrypt', component: OperationView, props: { type: 'encrypt' }, meta: { title: '加密' } },
......
......@@ -57,6 +57,9 @@
font-size: var(--font-size-base);
font-weight: 400;
}
.list-shell-page .filter-form .table-settings-item {
margin-left: auto;
}
/* ---- 主操作组(重置 / + 新建 / 危险操作):按钮长度对齐参考项目(min-width 72,内容自适应) ---- */
.filter-main-actions .el-form-item__content {
......
......@@ -26,8 +26,7 @@
<div v-for="change in row.column_changes" :key="change.column" class="change-line">
<el-tag :type="changeTag(change.kind)" size="small">{{ changeLabel(change.kind) }}</el-tag>
<b>{{ change.column }}</b>
<span v-if="change.kind === 'changed'">{{ change.before?.column_type }} → {{ change.after?.column_type }}
<template v-if="commentDiff(change)">,注释:{{ change.before?.column_comment || '(空)' }} → {{ change.after?.column_comment || '(空)' }}</template></span>
<span v-if="change.kind === 'changed'">{{ changeDetails(change) }}</span>
<span v-else-if="change.kind === 'added'">{{ change.after?.column_type }}{{ change.after?.column_comment ? `,注释:${change.after.column_comment}` : '' }}</span>
</div>
</div>
......@@ -41,7 +40,7 @@
<tbody>
<tr v-for="col in columnList" :key="col.column_name">
<td>{{ col.column_name }}</td><td>{{ col.column_type }}</td>
<td>{{ col.is_nullable === 'NO' ? '否' : '是' }}</td>
<td>{{ isNotNull(col.is_nullable) ? '否' : '是' }}</td>
<td>{{ col.column_default ?? '—' }}</td>
<td>{{ col.column_comment || '—' }}</td>
</tr>
......@@ -97,7 +96,26 @@ const number = (v) => v === null || v === undefined ? '—' : Number(v).toLocale
const delta = (v) => v === null || v === undefined ? '基线' : `${v > 0 ? '+' : ''}${Number(v).toLocaleString()}`
const changeLabel = (k) => ({ added: '新增', removed: '删除', changed: '修改' }[k] || k)
const changeTag = (k) => ({ added: 'success', removed: 'danger', changed: 'warning' }[k] || 'info')
const commentDiff = (c) => (c.before?.column_comment || '') !== (c.after?.column_comment || '')
const isNotNull = (value) => ['NO', 'N'].includes(String(value || '').toUpperCase())
function changeDetails(change) {
const before = change.before || {}; const after = change.after || {}
const fields = change.changed_fields || []
const parts = []
const show = (label, key) => parts.push(`${label}:${before[key] ?? '(空)'} → ${after[key] ?? '(空)'}`)
if (fields.includes('column_type')) show('类型', 'column_type')
else {
for (const [key, label] of [['data_type', '数据类型'], ['char_max_length', '字符长度'],
['numeric_precision', '数值精度'], ['numeric_scale', '小数位数']]) {
if (fields.includes(key)) show(label, key)
}
}
if (fields.includes('is_nullable')) parts.push(`可空:${isNotNull(before.is_nullable) ? '否' : '是'} → ${isNotNull(after.is_nullable) ? '否' : '是'}`)
for (const [key, label] of [['column_default', '默认值'], ['column_comment', '注释'],
['extra', '附加属性'], ['ordinal_position', '顺序']]) {
if (fields.includes(key)) show(label, key)
}
return parts.join(',')
}
async function loadDetail() {
if (!Number.isFinite(runId) || !tableName) { error.value = '缺少报告参数'; loading.value = false; return }
......
<template>
<div class="page-container list-shell-page">
<section class="filter-card">
<el-form inline class="filter-form" @submit.prevent>
<el-form-item class="filter-main-actions">
<el-button @click="onReset">重置</el-button>
<el-button type="primary" @click="openAdd">+ 新建</el-button>
</el-form-item>
<el-form-item label="任务名称">
<el-input v-model="keyword" placeholder="请输入关键词" clearable style="width: 220px" @keyup.enter="onQuery" @clear="onQuery">
<template #append><el-button :icon="Search" aria-label="查询" @click="onQuery" /></template>
</el-input>
</el-form-item>
</el-form>
</section>
<article class="table-card">
<div class="table-scroll">
<table class="config-list backup-task-list" data-page-key="backup-tasks">
<thead><tr><th>任务名称</th><th>数据源</th><th>数据库/Schema</th><th>备份范围</th><th>备份方式</th><th>执行频率</th><th>任务状态</th><th>上次备份</th><th>下次执行</th><th>操作</th></tr></thead>
<tbody>
<tr v-if="loading"><td colspan="10" class="empty-cell">加载中...</td></tr>
<tr v-else-if="!pagedTasks.length"><td colspan="10" class="empty-cell">{{ filteredTasks.length ? '当前页暂无数据' : '暂无定时备份任务' }}</td></tr>
<tr v-for="task in pagedTasks" :key="task.id">
<td>{{ task.name }}</td><td>{{ sourceName(task.source_id) }}</td><td>{{ task.database }}</td>
<td>{{ task.scope === 'tables' ? `指定表(${task.tables.length})` : '整个数据库' }}</td>
<td>{{ task.backup_mode === 'incremental' ? '增量' : '全量' }}</td><td><code>{{ task.cron_expression }}</code></td>
<td><el-tag :type="task.enabled ? 'success' : 'info'">{{ task.enabled ? '启用' : '停用' }}</el-tag></td>
<td><div class="last-run"><el-tag :type="statusTag(task.last_status)" :title="task.last_error || ''">{{ task.last_status || '未执行' }}</el-tag><span class="last-run-time">{{ task.last_trigger_type === 'scheduled' ? '自动' : task.last_trigger_type === 'manual' ? '手动' : '' }} {{ formatDate(task.last_run_at) }}</span></div></td>
<td>{{ formatDate(task.next_run_at) }}</td>
<td><button class="btn-action" :disabled="task.last_status === '备份中'" @click="runNow(task)">立即备份</button><button class="btn-action" @click="viewBackups(task)">备份记录</button><button class="btn-action" @click="edit(task)">编辑</button><button class="btn-action danger" @click="remove(task)">删除</button></td>
</tr>
</tbody>
</table>
</div>
<div v-if="filteredTasks.length" class="pagination-wrap"><el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" :page-sizes="[10, 15, 20, 25, 30]" :total="filteredTasks.length" layout="total, sizes, prev, pager, next" /></div>
</article>
<CommonFormDialog v-model="dialogOpen" :title="editingId ? '编辑备份任务' : '新建备份任务'" width="760px">
<el-form label-width="130px" class="backup-task-form">
<el-form-item label="任务名称"><el-input v-model="form.name" placeholder="如:生产库每日备份" /></el-form-item>
<el-form-item label="数据源"><el-select v-model="form.source_id" filterable placeholder="请选择数据源" @change="loadSchemas"><el-option v-for="source in sources" :key="source.id" :label="`${source.name}(${source.db_type})`" :value="source.id" /></el-select></el-form-item>
<el-form-item label="数据库/Schema"><el-select v-model="form.database" filterable placeholder="请选择数据库或 Schema" :disabled="!form.source_id" @change="loadTables(false)"><el-option v-for="schema in schemas" :key="schema" :label="schema" :value="schema" /></el-select></el-form-item>
<el-form-item label="备份范围"><el-radio-group v-model="form.scope" @change="form.tables = []"><el-radio label="database">整个数据库</el-radio><el-radio label="tables">指定数据表</el-radio></el-radio-group></el-form-item>
<el-form-item v-if="form.scope === 'tables'" label="数据表"><el-select v-model="form.tables" multiple filterable collapse-tags collapse-tags-tooltip placeholder="请选择数据表"><el-option v-for="table in tables" :key="table.table_name" :label="table.table_name" :value="table.table_name" /></el-select></el-form-item>
<el-form-item label="备份方式"><el-radio-group v-model="form.backup_mode"><el-radio label="full">全量备份</el-radio><el-radio label="incremental">增量备份</el-radio></el-radio-group></el-form-item>
<el-form-item label="备份内容"><el-checkbox v-model="form.include_schema">表结构</el-checkbox><el-checkbox v-model="form.include_data">表数据</el-checkbox></el-form-item>
<el-form-item label="Cron 表达式"><el-input v-model="form.cron_expression" placeholder="分 时 日 月 周,例如 0 2 * * *" /><div class="form-tip">标准 5 段格式,例如每天 02:00:<code>0 2 * * *</code></div></el-form-item>
<el-form-item label="AI 生成"><div class="cron-ai"><el-input v-model="cronPrompt" placeholder="例如:每天凌晨两点备份" /><el-button :loading="generating" @click="aiCron">生成表达式</el-button></div></el-form-item>
<el-form-item label="任务状态"><el-switch v-model="form.enabled" active-text="启用定时执行" /></el-form-item>
</el-form>
<template #footer><el-button @click="dialogOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
</CommonFormDialog>
</div>
</template>
<script setup>
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import CommonFormDialog from '@/components/CommonFormDialog.vue'
import { listDataSources } from '@/api/dataSources'
import { listSchemas, listTables } from '@/api/connections'
import { createBackupTask, deleteBackupTask, listBackupTasks, runBackupTask, updateBackupTask } from '@/api/backup'
import { generateCron } from '@/api/analysis'
const tasks = ref([]); const sources = ref([]); const schemas = ref([]); const tables = ref([])
const router = useRouter()
const loading = ref(false); const saving = ref(false); const generating = ref(false); const dialogOpen = ref(false); const editingId = ref(null); const cronPrompt = ref('')
let pollTimer = null
const keyword = ref(''); const appliedKeyword = ref(''); const currentPage = ref(1); const pageSize = ref(10)
const form = reactive({ name: '', source_id: null, database: '', schema: '', scope: 'database', tables: [], backup_mode: 'full', include_schema: true, include_data: true, cron_expression: '0 2 * * *', enabled: true })
const filteredTasks = computed(() => { const q = appliedKeyword.value.trim().toLowerCase(); return q ? tasks.value.filter(task => [task.name, task.database, sourceName(task.source_id)].some(value => String(value || '').toLowerCase().includes(q))) : tasks.value })
const pageCount = computed(() => Math.max(1, Math.ceil(filteredTasks.value.length / pageSize.value)))
const pagedTasks = computed(() => filteredTasks.value.slice((currentPage.value - 1) * pageSize.value, currentPage.value * pageSize.value))
const sourceName = id => sources.value.find(source => source.id === id)?.name || '—'
const formatDate = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
const statusTag = status => status === '失败' ? 'danger' : status === '已完成' ? 'success' : status === '备份中' ? 'warning' : 'info'
watch([filteredTasks, pageCount], () => { if (currentPage.value > pageCount.value) currentPage.value = pageCount.value })
function onQuery() { appliedKeyword.value = keyword.value; currentPage.value = 1 }
function onReset() { keyword.value = ''; appliedKeyword.value = ''; currentPage.value = 1 }
function sourceForm(source, schema = '') { return { dbType: source.db_type, host: source.host, port: source.port, user: source.user, password: source.password || '', db: source.database || '', schema: schema || source.schema || source.database || '' } }
async function load() { loading.value = true; try { [tasks.value, sources.value] = await Promise.all([listBackupTasks(), listDataSources()]) } catch (error) { ElMessage.error(`加载备份任务失败:${error.message}`) } finally { loading.value = false } }
async function loadSchemas() { schemas.value = []; tables.value = []; form.database = ''; form.tables = []; const source = sources.value.find(item => item.id === form.source_id); if (!source) return; try { const result = await listSchemas(sourceForm(source)); schemas.value = result.schemas || []; if (!schemas.value.length && source.database) schemas.value = [source.database] } catch (error) { ElMessage.error(`加载数据库失败:${error.message}`) } }
async function loadTables(preserveSelection = false) { tables.value = []; if (!preserveSelection) form.tables = []; const source = sources.value.find(item => item.id === form.source_id); if (!source || !form.database) return; try { const result = await listTables(sourceForm(source, form.database)); tables.value = result.tables || [] } catch (error) { ElMessage.error(`加载数据表失败:${error.message}`) } }
function resetForm() { Object.assign(form, { name: '', source_id: null, database: '', schema: '', scope: 'database', tables: [], backup_mode: 'full', include_schema: true, include_data: true, cron_expression: '0 2 * * *', enabled: true }); schemas.value = []; tables.value = []; cronPrompt.value = '' }
function openAdd() { editingId.value = null; resetForm(); dialogOpen.value = true }
async function edit(task) { editingId.value = task.id; Object.assign(form, { ...task, tables: [...(task.tables || [])] }); dialogOpen.value = true; const source = sources.value.find(item => item.id === task.source_id); if (source) { try { const result = await listSchemas(sourceForm(source)); schemas.value = result.schemas || [task.database]; await loadTables(true) } catch (_) {} } }
async function runNow(task) { try { const result = await runBackupTask(task.id); ElMessage.success(result.message); await load() } catch (error) { ElMessage.error(`启动备份失败:${error.message}`) } }
function viewBackups(task) { router.push({ path: '/backup', query: { task_id: String(task.id) } }) }
async function save() { if (!form.name.trim() || !form.source_id || !form.database || !form.cron_expression.trim() || (form.scope === 'tables' && !form.tables.length) || (!form.include_schema && !form.include_data)) { ElMessage.warning('请填写完整任务配置'); return } saving.value = true; try { const payload = { ...form, schema: form.database }; const row = editingId.value ? await updateBackupTask(editingId.value, payload) : await createBackupTask(payload); const index = tasks.value.findIndex(item => item.id === row.id); if (index >= 0) tasks.value[index] = row; else tasks.value.unshift(row); dialogOpen.value = false; ElMessage.success('备份任务已保存') } catch (error) { ElMessage.error(`保存失败:${error.message}`) } finally { saving.value = false } }
async function aiCron() { if (!cronPrompt.value.trim()) return ElMessage.warning('请先描述执行频率'); generating.value = true; try { const result = await generateCron(cronPrompt.value); form.cron_expression = result.cron_expression; ElMessage.success('已生成 cron 表达式') } catch (error) { ElMessage.error(`AI 生成失败:${error.message}`) } finally { generating.value = false } }
async function remove(task) { try { await ElMessageBox.confirm(`确定删除备份任务「${task.name}」?`, '提示', { type: 'warning' }); await deleteBackupTask(task.id); tasks.value = tasks.value.filter(item => item.id !== task.id) } catch (_) {} }
onMounted(() => { load(); pollTimer = setInterval(() => { if (tasks.value.some(task => task.last_status === '备份中')) load() }, 2000) })
onUnmounted(() => { if (pollTimer) clearInterval(pollTimer) })
</script>
<style scoped>
.backup-task-list { min-width: 1180px; }
.backup-task-list th, .backup-task-list td { text-align: center; }
.backup-task-form :deep(.el-select), .backup-task-form :deep(.el-input) { width: 100%; }
.backup-task-form :deep(.el-form-item__label) { white-space: nowrap; }
.form-tip { width: 100%; margin-top: 5px; color: var(--app-text-muted); font-size: var(--font-size-sm); }
.cron-ai { display: flex; gap: 8px; width: 100%; }
.cron-ai .el-input { flex: 1; }
code { color: var(--color-primary); }
.last-run { display: flex; align-items: center; justify-content: center; gap: 6px; white-space: nowrap; }
.last-run-time { color: var(--app-text-muted); font-size: var(--font-size-xs); white-space: nowrap; }
</style>
......@@ -32,6 +32,13 @@
@click="onCancel"
>{{ cancelling ? '中断中…' : '中断' }}</el-button>
</el-form-item>
<el-form-item v-if="resultFields.length" class="table-settings-item">
<TableSettings
:state="tableSettingsState"
@visible="onFieldVisible"
@reorder="onFieldReorder"
/>
</el-form-item>
</el-form>
</section>
......@@ -67,6 +74,7 @@
:rows="rows"
:append-mode="true"
:row-key="rowKeyFn"
:visible-fields="visibleFieldKeys"
@explain-issue="onExplainIssue"
/>
</article>
......@@ -87,6 +95,7 @@ import { listTasks } from '@/api/tasks'
import { startQuery, pullQuery, cancelQuery } from '@/api/queries'
import { exportToExcel } from '@/utils/excel'
import ResultTable from '@/components/ResultTable.vue'
import TableSettings from '@/components/TableSettings.vue'
import ExplainIssueDialog from '@/components/ExplainIssueDialog.vue'
const route = useRoute()
......@@ -149,6 +158,8 @@ function takeSnapshot(typeKey) {
taskOptions: taskOptions.value,
rows: rows.value,
resultFields: resultFields.value,
visibleFieldKeys: visibleFieldKeys.value,
tableSettingsState: tableSettingsState.value,
lastResult: lastResult.value || partial,
// 续跑所需(2026-09-24 流式版):新 session 会全表重扫,已收到的行靠
// __row_index > lastRowIndex 去重(旧分页版按 receivedPages 页号去重已删)
......@@ -169,6 +180,8 @@ function applySnapshot(snap) {
taskOptions.value = []
rows.value = []
resultFields.value = []
visibleFieldKeys.value = []
tableSettingsState.value = { columns: [], rowHeight: 40, borderWidth: 1, colPrefs: {} }
lastResult.value = null
scannedCount.value = 0
totalRows.value = 0
......@@ -179,6 +192,15 @@ function applySnapshot(snap) {
taskOptions.value = snap.taskOptions
rows.value = snap.rows
resultFields.value = snap.resultFields
visibleFieldKeys.value = snap.visibleFieldKeys?.length
? snap.visibleFieldKeys
: resultFields.value.filter((f) => (f.rules || 0) > 0 || f.showDefault !== false).map((f) => f.key)
tableSettingsState.value = snap.tableSettingsState || {
columns: resultFields.value.map((f) => ({ key: f.key, label: f.cn })),
rowHeight: 40,
borderWidth: 1,
colPrefs: Object.fromEntries(resultFields.value.map((f) => [f.key, { visible: visibleFieldKeys.value.includes(f.key) }])),
}
lastResult.value = snap.lastResult
scannedCount.value = snap.lastResult?.scanned || 0
// 恢复 in-flight 进度(fire-and-forget 启动续跑)
......@@ -237,6 +259,8 @@ const lastResult = ref(null) // 最近一次查询结
const queryError = ref('')
const rows = ref([]) // 喂给 ResultTable 的行
const resultFields = ref([])
const visibleFieldKeys = ref([])
const tableSettingsState = ref({ columns: [], rowHeight: 40, borderWidth: 1, colPrefs: {} })
// 本端刚发过 cancel(onCancel / abortInFlight / unmount)→ 之后 /pull 的 404 静默退出
// (非本端取消的 404 = 会话被别人取消 / TTL 过期,要给用户提示)
......@@ -318,6 +342,10 @@ async function pollLoop(ac, { lastRowIndex = -1 } = {}) {
queryError.value = `扫描失败:${res.error}`
try { await cancelQuery(sessionId.value) } catch (_) { /* best-effort 释放 */ }
} else {
// done 只表示「扫描线程结束」,不表示缓冲区已取干:扫描比轮询快时缓冲区
// 还有积压(2026-09-29 线上 87187 vs 69478 就是这么丢的)。满额包 → 立即
// 续拉;尾包(<PULL_CHUNK_MAX)才说明取干,此时定稿。
if (res.bad_rows.length >= PULL_CHUNK_MAX) continue
// 定稿:bad_rows 用 bad_total(含截断丢弃部分),rows 是实际展示的
lastResult.value = { scanned: res.scanned, bad_rows: res.bad_total, rows: rows.value }
if (res.truncated) {
......@@ -408,8 +436,21 @@ async function onQuery() {
comment: f.field_comment || '',
rules: f.rules || 0,
ruleList: f.rule_list || [],
showDefault: f.show_default !== false,
// 有规则的字段始终默认展示,避免历史配置 show_default=false 时把问题字段隐藏;
// 未配置规则的源表字段仍按 show_default(后端补入字段默认 false)。
showDefault: (f.rules || 0) > 0 || f.show_default !== false,
}))
visibleFieldKeys.value = resultFields.value
.filter((f) => (f.rules || 0) > 0 || f.showDefault !== false)
.map((f) => f.key)
tableSettingsState.value = {
columns: resultFields.value.map((f) => ({ key: f.key, label: f.cn })),
rowHeight: 40,
borderWidth: 1,
colPrefs: Object.fromEntries(
resultFields.value.map((f) => [f.key, { visible: visibleFieldKeys.value.includes(f.key) }]),
),
}
if (start.total_rows === 0) {
// COUNT=0:表里没数据
lastResult.value = { scanned: 0, bad_rows: 0, rows: [] }
......@@ -454,6 +495,23 @@ async function onCancel() {
// 直接塞进 explainContext → 打开 ExplainIssueDialog 自动调 LLM
const explainDialogOpen = ref(false)
const explainContext = ref(null)
function onFieldVisible({ key, visible }) {
const next = visibleFieldKeys.value.filter((k) => k !== key)
if (visible) next.push(key)
// 按表设置当前列顺序重建可见列,勾选字段时不重置用户已调整的顺序。
const orderedKeys = tableSettingsState.value.columns.map((c) => c.key)
visibleFieldKeys.value = orderedKeys.filter((k) => next.includes(k))
tableSettingsState.value.colPrefs[key] = { ...(tableSettingsState.value.colPrefs[key] || {}), visible }
}
function onFieldReorder({ from, to }) {
const keys = tableSettingsState.value.columns.map((c) => c.key)
const a = keys.indexOf(from), b = keys.indexOf(to)
if (a < 0 || b < 0) return
keys.splice(a, 1); keys.splice(b, 0, from)
tableSettingsState.value.columns = keys.map((k) => resultFields.value.find((f) => f.key === k)).filter(Boolean).map((f) => ({ key: f.key, label: f.cn }))
visibleFieldKeys.value = keys.filter((k) => visibleFieldKeys.value.includes(k))
}
function onExplainIssue(payload) {
explainContext.value = payload
explainDialogOpen.value = true
......@@ -469,7 +527,7 @@ async function onExport() {
return
}
const exportCols = [
...resultFields.value.filter((c) => c.key !== '__reason' && c.showDefault !== false),
...resultFields.value.filter((c) => c.key !== '__reason' && visibleFieldKeys.value.includes(c.key)),
{ key: '__reason', cn: '备注' },
]
if (exportCols.length <= 1) {
......
<template>
<div class="page-container simulation-page">
<div class="page-heading">
<div><h1>数据模拟</h1></div>
<div class="heading-actions"><el-button :disabled="!previewRows.length" @click="download('json')">导出 JSON</el-button><el-button :disabled="!previewRows.length" @click="download('csv')">导出 CSV</el-button><el-button :disabled="!previewRows.length" @click="download('sql')">导出 INSERT SQL</el-button><el-button type="primary" :disabled="!generatedRows.length || !selectedSourceId || selectedSourceId === 'demo'" @click="executeInsert">执行</el-button></div>
</div>
<el-steps :active="activeStep" finish-status="success" class="sim-steps"><el-step title="选择数据源" description="连接类型与 schema"/><el-step title="选择数据表" description="读取字段元数据"/><el-step title="配置并生成" description="预览与导出"/></el-steps>
<section class="sim-card"><div class="card-title"><span>1. 选择数据源</span></div>
<div class="source-grid">
<el-form-item label="数据源"><el-select v-model="selectedSourceId" placeholder="请选择已配置的数据源" filterable clearable @change="onSourceChange"><el-option v-for="source in sources" :key="source.id" :label="`${source.name} · ${source.db_type}`" :value="source.id"/></el-select></el-form-item>
<el-form-item label="数据库类型"><el-select v-model="sourceForm.dbType" disabled><el-option label="MySQL" value="MySQL"/><el-option label="Oracle" value="Oracle"/></el-select></el-form-item>
<el-form-item label="Schema/库"><el-select v-model="sourceForm.schema" filterable allow-create @change="loadTables"><el-option v-for="schema in schemas" :key="schema" :label="schema" :value="schema"/></el-select></el-form-item>
<el-form-item label="连接状态"><span class="status-pill" :class="connectionStatus.kind"><i/>{{ connectionStatus.text }}</span></el-form-item>
</div>
<div v-if="!sources.length" class="helper">暂无数据源,请先到“数据 → 数据源管理”创建连接。</div>
</section>
<section class="sim-card"><div class="card-title"><span>2. 选择数据表</span><span class="muted">{{ tableOptions.length }} 张表</span></div>
<div class="table-toolbar"><el-input v-model="tableKeyword" clearable placeholder="搜索表名或注释" :prefix-icon="Search"/><el-button :loading="loadingTables" @click="loadTables">刷新表列表</el-button></div>
<div class="table-picker"><button v-for="table in filteredTables" :key="tableName(table)" class="table-option" :class="{ selected: selectedTable === tableName(table) }" @click="selectTable(table)"><span class="table-icon">▦</span><b>{{ tableName(table) }}</b><small>{{ table.table_comment || table.comment || '—' }}</small></button><div v-if="!filteredTables.length" class="empty-state">请选择数据源并加载表列表</div></div>
<div v-if="selectedTable" class="column-preview"><div class="column-title"><b>{{ sourceForm.schema || 'schema' }}.{{ selectedTable }}</b><span>{{ columns.length }} 个字段</span></div><el-table :data="columns" size="small" max-height="220" stripe><el-table-column prop="column_name" label="字段" min-width="160"/><el-table-column prop="data_type" label="类型" width="130"/><el-table-column label="约束" width="130"><template #default="{row}"><el-tag v-if="row.is_primary_key || row.primary_key" size="small" type="warning">主键</el-tag><el-tag v-if="row.is_nullable === 'NO' || row.nullable === false" size="small" type="danger">非空</el-tag></template></el-table-column><el-table-column prop="column_comment" label="说明" min-width="180"/></el-table></div>
</section>
<section class="sim-card"><div class="card-title"><span>3. 配置生成规则</span></div>
<div class="generate-options"><el-form-item label="生成条数"><el-input-number v-model="rowCount" :min="1" :max="100000" controls-position="right"/></el-form-item><el-form-item label="随机种子"><el-input v-model="seed" placeholder="留空则每次随机"/></el-form-item><el-form-item label="空值比例"><el-slider v-model="nullRate" :min="0" :max="50" :format-tooltip="v => `${v}%`"/></el-form-item></div>
<el-table :data="rules" size="small" stripe class="rule-table"><el-table-column prop="column_name" label="字段" min-width="150" fixed/><el-table-column prop="data_type" label="类型" width="120"/><el-table-column label="生成策略" min-width="210"><template #default="{row}"><el-select v-model="row.generator" @change="normalizeRule(row)"><el-option v-for="o in generatorOptions(row)" :key="o.value" :label="o.label" :value="o.value"/></el-select></template></el-table-column><el-table-column label="参数" min-width="210"><template #default="{row}"><el-input v-model="row.param" :placeholder="paramPlaceholder(row)" :disabled="['auto','uuid','name','email','phone'].includes(row.generator)"/></template></el-table-column><el-table-column label="可空" width="85"><template #default="{row}"><el-switch v-model="row.allowNull" size="small" :disabled="!row.nullable"/></template></el-table-column></el-table>
<div class="action-bar"><span class="muted">{{ selectedTable ? `将生成 ${rowCount.toLocaleString()} 行到 ${selectedTable}` : '先选择目标数据表' }}</span><div><el-button @click="resetRules">恢复默认规则</el-button><el-button type="primary" :loading="generating" :disabled="!selectedTable || !rules.length" @click="generatePreview">生成预览</el-button></div></div>
</section>
<section v-if="previewRows.length" class="sim-card"><div class="card-title"><span>预览结果</span><span class="muted">展示前 {{ previewRows.length }} 行,共 {{ rowCount }} 行</span></div><el-table :data="previewRows" size="small" stripe max-height="330" class="preview-table"><el-table-column v-for="column in columns" :key="column.column_name" :prop="column.column_name" :label="column.column_name" min-width="150" show-overflow-tooltip/></el-table><div class="sql-preview"><div>SQL 预览 <el-tag size="small">{{ sourceForm.dbType }}</el-tag></div><pre>{{ sqlPreview }}</pre></div></section>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { insertSimulatedData, listDataSources } from '@/api/dataSources'
import { listSchemas, listTables, listColumns } from '@/api/connections'
const sources=ref([]), schemas=ref([]), tableOptions=ref([]), columns=ref([]), rules=ref([]), previewRows=ref([]), generatedRows=ref([])
const selectedSourceId=ref(''), selectedTable=ref(''), tableKeyword=ref(''), activeStep=ref(0), loadingTables=ref(false), generating=ref(false), rowCount=ref(20), seed=ref(''), nullRate=ref(0)
const sourceForm=reactive({dbType:'MySQL',host:'',port:3306,user:'',password:'',db:'',schema:'',oracleClientDir:''}), connectionStatus=reactive({kind:'idle',text:'未选择数据源'})
const MOCK_TABLES=[{table_name:'demo_user',table_comment:'用户示例表'},{table_name:'demo_order',table_comment:'订单示例表'},{table_name:'demo_product',table_comment:'商品示例表'}]
const MOCK_COLUMNS={demo_user:[{column_name:'id',data_type:'INT',is_primary_key:true,is_nullable:'NO'},{column_name:'username',data_type:'VARCHAR',is_nullable:'NO'},{column_name:'email',data_type:'VARCHAR'},{column_name:'phone',data_type:'VARCHAR'},{column_name:'created_at',data_type:'DATETIME',is_nullable:'NO'}],demo_order:[{column_name:'id',data_type:'BIGINT',is_primary_key:true,is_nullable:'NO'},{column_name:'user_id',data_type:'BIGINT',is_nullable:'NO'},{column_name:'amount',data_type:'DECIMAL',is_nullable:'NO'},{column_name:'status',data_type:'VARCHAR',is_nullable:'NO'},{column_name:'created_at',data_type:'DATETIME',is_nullable:'NO'}],demo_product:[{column_name:'id',data_type:'INT',is_primary_key:true,is_nullable:'NO'},{column_name:'name',data_type:'VARCHAR',is_nullable:'NO'},{column_name:'price',data_type:'DECIMAL',is_nullable:'NO'},{column_name:'stock',data_type:'INT',is_nullable:'NO'}]}
const filteredTables=computed(()=>tableOptions.value.filter(t=>(tableName(t)+(t.table_comment||'')).toLowerCase().includes(tableKeyword.value.toLowerCase()))), sqlPreview=computed(()=>buildSql(previewRows.value.slice(0,3)))
function tableName(t){return t.table_name||t.name||''} function toForm(s){return {dbType:s.db_type||'MySQL',host:s.host||'',port:s.port||3306,user:s.user||'',password:s.password||'',db:s.database||'',schema:s.schema||s.database||'',oracleClientDir:s.oracle_client_dir||''}}
async function loadSources(){try{sources.value=await listDataSources()}catch{sources.value=[]}if(!sources.value.length){sources.value=[{id:'demo',name:'演示数据源',db_type:'MySQL',host:'127.0.0.1',port:3306,database:'demo',schema:'demo',user:'demo',password:''}]}}
async function onSourceChange(){const s=sources.value.find(x=>x.id===selectedSourceId.value);if(!s)return;Object.assign(sourceForm,toForm(s));connectionStatus.kind='success';connectionStatus.text=`${s.db_type} 连接配置已载入`;activeStep.value=1;try{const r=await listSchemas(sourceForm);schemas.value=r.ok?r.schemas||[]:[]}catch{schemas.value=[]}await loadTables()}
async function loadTables(){loadingTables.value=true;try{const r=await listTables(sourceForm);tableOptions.value=r.ok&&r.tables?.length?r.tables:MOCK_TABLES}catch{tableOptions.value=MOCK_TABLES}finally{loadingTables.value=false}}
async function selectTable(t){selectedTable.value=tableName(t);activeStep.value=2;previewRows.value=[];try{const r=await listColumns(sourceForm,selectedTable.value);columns.value=r.ok&&r.columns?.length?r.columns:(MOCK_COLUMNS[selectedTable.value]||MOCK_COLUMNS.demo_user)}catch{columns.value=MOCK_COLUMNS[selectedTable.value]||MOCK_COLUMNS.demo_user}resetRules()}
function infer(c){const n=String(c.column_name||'').toLowerCase(),t=String(c.data_type||'').toLowerCase();if(c.is_primary_key||n==='id')return'auto';if(n.includes('email'))return'email';if(n.includes('phone')||n.includes('mobile'))return'phone';if(n.includes('name'))return'name';if(t.includes('date')||t.includes('time'))return'datetime';if(t.includes('decimal')||t.includes('float'))return'decimal';if(t.includes('int')||t.includes('number'))return'integer';return'string'}
function resetRules(){rules.value=columns.value.map(c=>({...c,generator:infer(c),param:'',nullable:c.is_nullable!=='NO'&&c.nullable!==false,allowNull:false}))} function generatorOptions(){return[{value:'auto',label:'递增编号'},{value:'uuid',label:'UUID'},{value:'fixed',label:'固定值'},{value:'string',label:'随机字符串'},{value:'name',label:'中文姓名'},{value:'email',label:'邮箱'},{value:'phone',label:'手机号'},{value:'integer',label:'随机整数'},{value:'decimal',label:'随机小数'},{value:'datetime',label:'日期时间'},{value:'enum',label:'枚举值'}]} function paramPlaceholder(r){return{fixed:'输入固定值',integer:'最小值,最大值',decimal:'最小值,最大值',enum:'用逗号分隔(如 A,B,C)',string:'字符串长度,默认 10'}[r.generator]||''} function normalizeRule(r){if(r.generator==='auto')r.allowNull=false}
function random(v){let x=v||Math.floor(Math.random()*2147483647);return()=>((x=x*16807%2147483647)/2147483647)}
function valueFor(r,i,rand){if(r.allowNull&&Math.floor(rand()*100)<nullRate.value)return null;const p=String(r.param||'');const fit=v=>{const max=Number(r.char_max_length);return max>0&&typeof v==='string'?v.slice(0,max):v};let value;switch(r.generator){case'auto':value=i+1;break;case'uuid':value=`${Math.floor(rand()*0xffffffff).toString(16).padStart(8,'0')}-${Math.floor(rand()*0xffff).toString(16).padStart(4,'0')}-4${Math.floor(rand()*0xfff).toString(16).padStart(3,'0')}-a${Math.floor(rand()*0xfff).toString(16).padStart(3,'0')}-${Math.floor(rand()*0xffffffffffff).toString(16).padStart(12,'0')}`;break;case'fixed':value=p;break;case'name':value=['张伟','李娜','王强','刘洋','陈晨'][Math.floor(rand()*5)];break;case'email':value=`user${i+1}@example.com`;break;case'phone':value=`1${String(3000000000+Math.floor(rand()*699999999)).slice(0,10)}`;break;case'integer':{const[a=1,b=100]=p.split(',').map(Number);value=Math.floor(a+rand()*(b-a+1));break}case'decimal':{const[a=0,b=999.99]=p.split(',').map(Number);value=Number((a+rand()*(b-a)).toFixed(2));break}case'datetime':value=new Date(Date.UTC(2024,0,1)+Math.floor(rand()*31536000000)).toISOString().slice(0,19).replace('T',' ');break;case'enum':{const a=p.split(',').map(x=>x.trim()).filter(Boolean);value=a[Math.floor(rand()*a.length)]||'A';break}default:{const chars='abcdefghijklmnopqrstuvwxyz0123456789',len=Math.min(128,Math.max(1,Number(p)||Math.min(10,Number(r.char_max_length)||10)));value=Array.from({length:len},()=>chars[Math.floor(rand()*chars.length)]).join('')}}return fit(value)}
async function generatePreview(){generating.value=true;await new Promise(r=>setTimeout(r,80));const rand=random(Number(seed.value));generatedRows.value=Array.from({length:rowCount.value},(_,i)=>Object.fromEntries(rules.value.map(r=>[r.column_name,valueFor(r,i,rand)])));previewRows.value=generatedRows.value.slice(0,50);generating.value=false;ElMessage.success(`已生成 ${rowCount.value} 行数据`)}
function qi(n){return sourceForm.dbType==='Oracle'?`"${n}"`:`\`${n}\``} function sv(v){return v==null?'NULL':typeof v==='number'?String(v):`'${String(v).replaceAll("'","''")}'`} function buildSql(rows){if(!rows.length)return'';const names=rules.value.map(r=>r.column_name),cols=names.map(qi),batchSize=sourceForm.dbType==='Oracle'?500:1000,statements=[];for(let offset=0;offset<rows.length;offset+=batchSize){const batch=rows.slice(offset,offset+batchSize);statements.push(`INSERT INTO ${qi(sourceForm.schema||'schema')}.${qi(selectedTable.value)} (${cols.join(', ')})\nVALUES\n${batch.map(x=>` (${names.map(n=>sv(x[n])).join(', ')})`).join(',\n')};`)}return statements.join('\n\n')}
function download(type){let content,mime='text/plain';if(type==='json'){content=JSON.stringify(generatedRows.value,null,2);mime='application/json'}else if(type==='csv'){const h=columns.value.map(c=>c.column_name);content=[h.join(','),...generatedRows.value.map(r=>h.map(k=>`"${String(r[k]??'').replaceAll('"','""')}"`).join(','))].join('\n');mime='text/csv;charset=utf-8'}else content=buildSql(generatedRows.value);const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([content],{type:mime}));a.download=`${selectedTable.value||'mock-data'}.${type}`;a.click();URL.revokeObjectURL(a.href);ElMessage.success(`已导出 ${type.toUpperCase()},${generatedRows.value.length} 行`)}
async function executeInsert(){try{await ElMessageBox.confirm(`确定将 ${generatedRows.value.length.toLocaleString()} 行数据插入「${sourceForm.schema || '默认 schema'}.${selectedTable.value}」吗?此操作会直接写入数据库。`,'确认执行',{type:'warning',confirmButtonText:'执行插入',cancelButtonText:'取消'});const result=await insertSimulatedData(selectedSourceId.value,{schema:sourceForm.schema,table_name:selectedTable.value,columns:rules.value.map(r=>r.column_name),column_types:Object.fromEntries(rules.value.map(r=>[r.column_name,r.data_type])),rows:generatedRows.value});ElMessage.success(result.message || `已插入 ${generatedRows.value.length} 行`)}catch(error){if(error!=='cancel')ElMessage.error(`执行失败:${error.message}`)}}
onMounted(loadSources)
</script>
<style scoped>
.simulation-page{max-width:1500px;margin:0 auto;padding:8px 4px 40px}.page-heading{display:flex;justify-content:space-between;gap:20px;align-items:flex-start;margin-bottom:18px}.page-heading h1{font-size:24px;margin:0 0 8px;color:var(--color-text-primary)}.page-heading p{margin:0;color:var(--color-text-secondary)}.heading-actions{display:flex;gap:8px;white-space:nowrap}.sim-steps{margin:10px 8px 18px}.sim-card{background:var(--app-bg-primary);border:1px solid var(--color-border-light);border-radius:8px;padding:18px 20px;margin-bottom:14px}.card-title{display:flex;justify-content:space-between;align-items:center;font-size:16px;font-weight:600;color:var(--color-text-primary);margin-bottom:16px}.muted,.helper{font-size:12px;color:var(--color-text-secondary)}.source-grid{display:grid;grid-template-columns:2fr 1fr 1.3fr 1fr;gap:12px 18px}.source-grid :deep(.el-form-item){margin-bottom:0}.source-grid :deep(.el-select),.source-grid :deep(.el-input){width:100%}.status-pill{font-size:13px;display:inline-flex;align-items:center;gap:6px;min-height:var(--control-height-base)}.status-pill i{width:7px;height:7px;border-radius:50%;background:#a8abb2}.status-pill.success i{background:#67c23a}.table-toolbar{display:flex;gap:10px;margin-bottom:12px}.table-toolbar .el-input{max-width:360px}.table-toolbar .el-button{height:var(--control-height-base)}.table-picker{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:8px;min-height:58px}.table-option{display:flex;align-items:center;gap:8px;border:1px solid var(--color-border-light);background:var(--app-bg-primary);border-radius:6px;padding:10px 12px;text-align:left;cursor:pointer;color:var(--color-text-primary)}.table-option:hover,.table-option.selected{border-color:var(--color-primary);background:var(--app-bg-hover)}.table-option small{margin-left:auto;color:var(--color-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.table-icon{color:var(--color-primary)}.empty-state{grid-column:1/-1;color:var(--color-text-secondary);padding:16px;text-align:center}.column-preview{margin-top:16px}.column-title{display:flex;justify-content:space-between;margin-bottom:8px;font-size:13px;color:var(--color-text-secondary)}.generate-options{display:flex;flex-wrap:wrap;align-items:center;gap:12px 32px;margin-bottom:16px}.generate-options :deep(.el-form-item){margin-bottom:0}.generate-options :deep(.el-input-number){width:140px}.generate-options :deep(.el-input){width:220px}.generate-options :deep(.el-slider){width:220px}.action-bar{display:flex;justify-content:space-between;align-items:center;margin-top:16px}.rule-table{margin-bottom:4px}.preview-table{margin:14px 0}.sql-preview{background:#111827;border-radius:6px;padding:12px 14px;color:#d1d5db}.sql-preview pre{margin:8px 0 0;white-space:pre-wrap;word-break:break-word;font-size:12px;line-height:1.6}@media(max-width:1000px){.source-grid,.generate-options{grid-template-columns:1fr 1fr}.page-heading{flex-direction:column}.heading-actions{flex-wrap:wrap}}
</style>
......@@ -2,6 +2,16 @@
<div class="page-container list-shell-page">
<section class="filter-card">
<el-form inline class="filter-form" @submit.prevent>
<el-form-item v-if="type === 'backup'" label="备份任务">
<el-select v-model="taskFilterId" clearable filterable placeholder="全部任务" style="width: 200px" @change="onTaskFilterChange">
<el-option v-for="t in backupTasks" :key="t.id" :value="String(t.id)" :label="t.name" />
</el-select>
</el-form-item>
<el-form-item v-else-if="type === 'restore'" label="备份文件">
<el-select v-model="fileFilterId" clearable filterable placeholder="全部文件" style="width: 260px" @change="onFileFilterChange">
<el-option v-for="f in restoreFiles" :key="f" :value="f" :label="f" />
</el-select>
</el-form-item>
<el-form-item class="filter-main-actions">
<el-button @click="onReset">重置</el-button>
<el-button type="primary" @click="openCreate">+ 新建</el-button>
......@@ -24,14 +34,18 @@
</section>
<article class="table-card">
<div class="table-scroll"><table class="config-list operation-list" :data-page-key="`operation-${type}`"><thead><tr><th>任务名称</th><th>数据源</th><th>{{ type === 'restore' ? '还原数据库' : type === 'encrypt' ? '加密数据库' : type === 'decrypt' ? '解密数据库' : '备份数据库' }}</th><th>{{ type === 'restore' ? '还原数据表' : type === 'encrypt' ? '加密数据表' : type === 'decrypt' ? '解密数据表' : '备份数据表' }}</th><th>{{ type === 'encrypt' || type === 'decrypt' ? '处理字段' : '备份文件名' }}</th><th>执行设置</th><th style="width: 80px;">状态</th><th>创建时间</th><th style="width: 96px;">操作</th></tr></thead><tbody><tr v-if="!pagedRecords.length"><td colspan="9" class="empty-cell">暂无{{ title }}记录</td></tr><tr v-for="record in pagedRecords" :key="record.id"><td>{{ record.name }}</td><td>{{ record.sourceName || '—' }}</td><td>{{ record.database || '—' }}</td><td :title="record.tables && record.tables !== '—' ? record.tables : undefined" class="table-summary">{{ record.tables || '—' }}</td><td :title="record.filename || undefined" class="file-summary">{{ record.filename || '—' }}</td><td :title="String(record.executeSetting || '—')" class="execution-setting">{{ executionSettingSummary(record.executeSetting) }}</td><td><span class="tag tag-blue">{{ record.status }}</span></td><td>{{ record.createdAt }}</td><td><button class="btn-action" @click="viewRecord(record)">查看</button></td></tr></tbody></table></div>
<div class="table-scroll"><table class="config-list operation-list" :data-page-key="`operation-${type}`">
<thead><tr><th>任务名称</th><th>数据源</th><th>{{ type === 'restore' ? '还原数据库' : type === 'encrypt' ? '加密数据库' : type === 'decrypt' ? '解密数据库' : '备份数据库' }}</th><th>{{ type === 'restore' ? '还原数据表' : type === 'encrypt' || type === 'decrypt' ? '处理字段' : '备份数据表' }}</th><th>{{ type === 'encrypt' || type === 'decrypt' ? '处理范围' : '备份文件名' }}</th><th>执行方式</th><th v-if="type === 'backup' || type === 'restore'">备份方式</th><th v-if="type === 'backup' || type === 'restore'">范围</th><th>执行设置</th><th style="width: 80px;">状态</th><th>创建时间</th><th style="width: 96px;">操作</th></tr></thead>
<tbody><tr v-if="!pagedRecords.length"><td :colspan="type === 'backup' || type === 'restore' ? 12 : 9" class="empty-cell">暂无{{ title }}记录</td></tr>
<tr v-for="record in pagedRecords" :key="record.id"><td>{{ record.name }}</td><td>{{ record.sourceName || '—' }}</td><td>{{ record.database || '—' }}</td><td :title="record.tables && record.tables !== '—' ? record.tables : undefined" class="table-summary">{{ record.tables || '—' }}</td><td :title="record.filename || undefined" class="file-summary">{{ record.filename || '—' }}</td><td>{{ triggerTypeLabel(record) }}</td><td v-if="type === 'backup' || type === 'restore'">{{ backupModeLabel(record) }}</td><td v-if="type === 'backup' || type === 'restore'">{{ scopeLabel(record.scope) }}</td><td :title="String(record.executeSetting || '—')" class="execution-setting">{{ executionSettingSummary(record.executeSetting) }}</td><td><el-tag :type="statusTag(record.status)">{{ record.status }}</el-tag></td><td>{{ record.createdAt }}</td><td><button class="btn-action" @click="viewRecord(record)">查看</button></td></tr>
</tbody></table></div>
<div class="pagination-wrap" v-if="filteredRecords.length"><el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" :page-sizes="[10, 15, 20, 25, 30]" :total="filteredRecords.length" layout="total, sizes, prev, pager, next" /></div>
</article>
<CommonFormDialog v-model="createOpen" :title="`新增${title}`" width="1080px">
<div class="step"><span class="step-no">1</span><div class="step-content"><b>选择数据源</b><el-select v-model="form.source" placeholder="请选择数据源" class="source-select" @change="onSourceChange"><el-option v-for="s in sources" :key="s.id" :label="`${s.name}(${s.db_type})`" :value="s.id" /></el-select></div></div>
<template v-if="type === 'backup'"><div class="step"><span class="step-no">2</span><div class="step-content"><b>选择数据库</b><el-select v-model="form.database" placeholder="请选择数据库/Schema" class="source-select" :loading="loadingObjects" @change="loadTables"><el-option v-for="name in schemas" :key="name" :label="name" :value="name" /></el-select><div class="scope-choice"><el-radio-group v-model="form.scope" @change="form.tables = []"><el-radio label="database">整个数据库</el-radio><el-radio label="tables">指定数据表</el-radio></el-radio-group></div><div v-if="form.scope === 'tables'" class="table-picker"><el-checkbox-group v-model="form.tables"><el-checkbox v-for="table in tables" :key="table.table_name" :label="table.table_name">{{ table.table_name }}<small v-if="table.table_comment">({{ table.table_comment }})</small></el-checkbox></el-checkbox-group><span v-if="form.source && !tables.length" class="muted">{{ loadingObjects ? '正在加载...' : '暂无可访问的表' }}</span></div></div></div><div class="step"><span class="step-no">3</span><div class="step-content"><b>备份范围</b><el-checkbox v-model="form.includeSchema">表结构</el-checkbox><el-checkbox v-model="form.includeData">表数据</el-checkbox><div class="muted filename-hint">文件名由系统按时间自动生成,完成后显示在备份列表。</div></div></div></template>
<template v-else-if="type === 'restore'"><div class="step"><span class="step-no">2</span><div class="step-content"><b>选择目标数据库</b><el-select v-model="form.database" placeholder="请选择数据库/Schema" class="source-select" @change="form.filename = ''"><el-option v-for="name in schemas" :key="name" :label="name" :value="name" /></el-select></div></div><div class="step"><span class="step-no">3</span><div class="step-content"><b>选择匹配的备份版本</b><el-select v-model="form.filename" placeholder="请选择备份版本" class="source-select"><el-option v-for="v in matchedVersions" :key="v.filename" :label="`${v.filename}(${scopeLabel(v)})`" :value="v.filename" /></el-select><div v-if="form.filename && selectedVersion" class="muted version-tables">备份表:{{ (selectedVersion.tables || []).join('、') || '未读取到表信息' }}</div></div></div><div class="step"><span class="step-no">4</span><div class="step-content"><b>还原范围</b><el-radio-group v-model="form.scope"><el-radio label="database">备份中的整个数据库</el-radio><el-radio label="tables">选择备份中的数据表</el-radio></el-radio-group><div v-if="form.scope === 'tables' && selectedVersion" class="table-picker"><el-checkbox-group v-model="form.tables"><el-checkbox v-for="name in selectedVersion.tables" :key="name" :label="name">{{ name }}</el-checkbox></el-checkbox-group></div><div><el-radio-group v-model="form.conflict"><el-radio label="skip">已有表跳过</el-radio><el-radio label="replace">删除后重建</el-radio></el-radio-group></div></div></div></template>
<template v-if="type === 'backup'"><div class="step"><span class="step-no">2</span><div class="step-content"><b>选择数据库</b><el-select v-model="form.database" placeholder="请选择数据库/Schema" class="source-select" :loading="loadingObjects" @change="loadTables"><el-option v-for="name in schemas" :key="name" :label="name" :value="name" /></el-select><div class="scope-choice"><el-radio-group v-model="form.scope" @change="form.tables = []"><el-radio label="database">整个数据库</el-radio><el-radio label="tables">指定数据表</el-radio></el-radio-group></div><div v-if="form.scope === 'tables'" class="table-picker"><el-checkbox-group v-model="form.tables"><el-checkbox v-for="table in tables" :key="table.table_name" :label="table.table_name">{{ table.table_name }}<small v-if="table.table_comment">({{ table.table_comment }})</small></el-checkbox></el-checkbox-group><span v-if="form.source && !tables.length" class="muted">{{ loadingObjects ? '正在加载...' : '暂无可访问的表' }}</span></div></div></div><div class="step"><span class="step-no">3</span><div class="step-content"><b>备份方式</b><el-radio-group v-model="form.backupMode"><el-radio label="full">全量备份</el-radio><el-radio label="incremental">增量备份</el-radio></el-radio-group><div class="muted filename-hint">增量备份保留同样的表结构,仅记录本次任务产生的增量快照。</div></div></div><div class="step"><span class="step-no">4</span><div class="step-content"><b>备份范围</b><el-checkbox v-model="form.includeSchema">表结构</el-checkbox><el-checkbox v-model="form.includeData">表数据</el-checkbox></div></div></template>
<template v-else-if="type === 'restore'"><div class="step"><span class="step-no">2</span><div class="step-content"><b>选择目标数据库</b><el-select v-model="form.database" placeholder="请选择数据库/Schema" class="source-select" @change="form.filename = ''"><el-option v-for="name in schemas" :key="name" :label="name" :value="name" /></el-select></div></div><div class="step"><span class="step-no">3</span><div class="step-content"><b>选择匹配的备份版本</b><el-select v-model="form.filename" placeholder="请选择备份版本" class="source-select"><el-option v-for="v in matchedVersions" :key="v.filename" :label="`${v.filename}(${versionScopeLabel(v)})`" :value="v.filename" /></el-select><div v-if="form.filename && selectedVersion" class="muted version-tables">备份表:{{ (selectedVersion.tables || []).join('、') || '未读取到表信息' }}</div></div></div><div class="step"><span class="step-no">4</span><div class="step-content"><b>还原范围</b><el-radio-group v-model="form.scope"><el-radio label="database">备份中的整个数据库</el-radio><el-radio label="tables">选择备份中的数据表</el-radio></el-radio-group><div v-if="form.scope === 'tables' && selectedVersion" class="table-picker"><el-checkbox-group v-model="form.tables"><el-checkbox v-for="name in selectedVersion.tables" :key="name" :label="name">{{ name }}</el-checkbox></el-checkbox-group></div><div><el-radio-group v-model="form.conflict"><el-radio label="skip">已有表跳过</el-radio><el-radio label="replace">删除后重建</el-radio></el-radio-group></div></div></div></template>
<template v-else-if="type === 'encrypt' || type === 'decrypt'"><div class="step"><span class="step-no">2</span><div class="step-content"><b>选择数据库</b><el-select v-model="form.database" placeholder="请选择数据库/Schema" class="source-select" :loading="loadingObjects" @change="loadTables"><el-option v-for="name in schemas" :key="name" :label="name" :value="name" /></el-select></div></div><div class="step"><span class="step-no">3</span><div class="step-content"><b>选择数据表</b><el-select v-model="form.table" placeholder="请选择数据表" class="source-select" :loading="loadingObjects" @change="loadColumns"><el-option v-for="table in tables" :key="table.table_name" :label="table.table_name" :value="table.table_name" /></el-select></div></div><div class="step" v-if="form.table"><span class="step-no">4</span><div class="step-content"><b>选择处理范围</b><el-radio-group v-model="form.scope" @change="form.fields = []"><el-radio label="table">整张表</el-radio><el-radio label="fields">指定字段</el-radio></el-radio-group><div class="muted filename-hint">整表模式会处理所有字符字段,数字、日期等字段保持原类型。</div><div v-if="form.scope === 'fields'" class="table-picker"><el-checkbox-group v-model="form.fields"><el-checkbox v-for="column in columns" :key="column.column_name" :label="column.column_name">{{ column.column_name }}<small v-if="column.column_comment">({{ column.column_comment }})</small></el-checkbox></el-checkbox-group><span v-if="!columns.length" class="muted">暂无可处理字段</span></div></div></div><div class="step"><span class="step-no">{{ form.table ? 5 : 4 }}</span><div class="step-content"><b>{{ type === 'encrypt' ? '设置加密密码' : '输入解密密码' }}</b><el-input v-model="form.password" type="password" show-password class="password-input" placeholder="至少 4 位" /></div></div></template>
<template #footer>
<div class="dialog-footer">
......@@ -42,6 +56,11 @@
</CommonFormDialog>
<CommonFormDialog :model-value="!!detailRecord" :title="`${title}执行详情`" width="680px" @update:model-value="detailRecord = null">
<div v-if="detailRecord && (type === 'backup' || type === 'restore')" class="detail-meta">
<div>记录编号:#{{ detailRecord.id }}</div><div>关联任务:{{ detailRecord.taskName ? `${detailRecord.taskName}(#${detailRecord.task_id})` : '手动操作' }}</div>
<div>执行方式:{{ triggerTypeLabel(detailRecord) }}</div><div>备份方式:{{ backupModeLabel(detailRecord) }}</div>
<div>备份文件:{{ detailRecord.filename || '生成中' }}</div><div>状态:{{ detailRecord.status }}</div>
</div>
<p class="detail-text">{{ detailRecord?.executeSetting }}</p>
<template #footer>
<div class="dialog-footer">
......@@ -49,22 +68,43 @@
</div>
</template>
</CommonFormDialog>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { listDataSources } from '@/api/dataSources'
import { listSchemas, listTables } from '@/api/connections'
import { createBackup, encryptData, decryptData, importOperationRecords, listBackupVersions, listOperationRecords, restoreBackup } from '@/api/backup'
import { createBackup, encryptData, decryptData, importOperationRecords, listBackupTasks, listBackupVersions, listOperationRecords, restoreBackup } from '@/api/backup'
import { listColumns } from '@/api/connections'
import CommonFormDialog from '@/components/CommonFormDialog.vue'
const props = defineProps({ type: { type: String, required: true } }), meta = { backup: { title: '备份', action: '开始备份' }, restore: { title: '还原', action: '开始还原' }, encrypt: { title: '加密', action: '开始加密' }, decrypt: { title: '解密', action: '开始解密' } }, current = computed(() => meta[props.type] || meta.backup), title = computed(() => current.value.title), actionText = computed(() => current.value.action)
const route = useRoute(); const router = useRouter()
const sources = ref([]), schemas = ref([]), tables = ref([]), columns = ref([]), versions = ref([]), loadingObjects = ref(false), submitting = ref(false), keyword = ref(''), appliedKeyword = ref(''), createOpen = ref(false), detailRecord = ref(null), currentPage = ref(1), pageSize = ref(10), records = ref([])
const form = reactive({ source: null, database: '', scope: 'database', tables: [], table: '', fields: [], includeSchema: true, includeData: true, filename: '', conflict: 'skip', password: '' })
const filteredRecords = computed(() => { const q = appliedKeyword.value.trim().toLowerCase(); return q ? records.value.filter(r => [r.name, r.sourceName, r.database, r.tables, r.filename, r.object, r.status].some(v => String(v || '').toLowerCase().includes(q))) : records.value }), pageCount = computed(() => Math.max(1, Math.ceil(filteredRecords.value.length / pageSize.value))), pagedRecords = computed(() => filteredRecords.value.slice((currentPage.value - 1) * pageSize.value, currentPage.value * pageSize.value))
const selectedVersion = computed(() => versions.value.find(x => x.filename === form.filename)), matchedVersions = computed(() => { const source = sources.value.find(x => x.id === form.source); return versions.value.filter(x => !x.encrypted && (x.source?.database === form.database || x.source?.schema === form.database) && (!source || !x.source?.db_type || x.source.db_type === source.db_type)) }), canSubmit = computed(() => props.type === 'backup' ? !!form.source && !!form.database && (form.scope === 'database' || form.tables.length > 0) && (form.includeSchema || form.includeData) : props.type === 'restore' ? !!form.source && !!form.database && !!form.filename && (form.scope === 'database' || form.tables.length > 0) : !!form.source && !!form.database && !!form.table && !!form.password && (form.scope === 'table' || form.fields.length > 0) && form.password.length >= 4)
let pollTimer = null
const form = reactive({ source: null, database: '', scope: 'database', tables: [], table: '', fields: [], includeSchema: true, includeData: true, backupMode: 'full', filename: '', conflict: 'skip', password: '' })
// ── 备份页任务筛选:常驻下拉 + localStorage 记忆(下次进入保持上次选择)──
const TASK_FILTER_KEY = 'db-tools-backup-task-filter'
const backupTasks = ref([])
const taskFilterId = ref('')
// ── 还原页按「来源备份文件」筛选(还原没有任务概念,备份文件即分类)+ 记忆 ──
const FILE_FILTER_KEY = 'db-tools-restore-file-filter'
const fileFilterId = ref('')
const restoreFiles = computed(() => [...new Set(records.value.map(r => r.filename).filter(Boolean))].sort())
const filteredRecords = computed(() => { const q = appliedKeyword.value.trim().toLowerCase(); return records.value.filter(r => (props.type !== 'backup' || !taskFilterId.value || String(r.task_id) === taskFilterId.value) && (props.type !== 'restore' || !fileFilterId.value || r.filename === fileFilterId.value) && (!q || [r.name, r.sourceName, r.database, r.tables, r.filename, r.object, r.status, r.taskName, r.triggerType, r.backupMode].some(v => String(v || '').toLowerCase().includes(q)))) }), pageCount = computed(() => Math.max(1, Math.ceil(filteredRecords.value.length / pageSize.value))), pagedRecords = computed(() => filteredRecords.value.slice((currentPage.value - 1) * pageSize.value, currentPage.value * pageSize.value))
const selectedVersion = computed(() => versions.value.find(x => x.filename === form.filename))
const matchedVersions = computed(() => {
const source = sources.value.find(x => x.id === form.source)
const target = String(form.database || '').toLowerCase()
return versions.value.filter(x => !x.encrypted && x.operation !== 'clear' &&
[x.source?.database, x.source?.schema].some(value => String(value || '').toLowerCase() === target) &&
(!source || !x.source?.db_type || String(x.source.db_type).toLowerCase() === String(source.db_type).toLowerCase()))
.sort((a, b) => Number(b.source?.id === form.source) - Number(a.source?.id === form.source))
})
const canSubmit = computed(() => props.type === 'backup' ? !!form.source && !!form.database && (form.scope === 'database' || form.tables.length > 0) && (form.includeSchema || form.includeData) : props.type === 'restore' ? !!form.source && !!form.database && !!form.filename && (form.scope === 'database' || form.tables.length > 0) : !!form.source && !!form.database && !!form.table && !!form.password && (form.scope === 'table' || form.fields.length > 0) && form.password.length >= 4)
// database 参数是用户在下拉里选的「schema」,只能进 schema 字段;
// Oracle 的 db(service_name,如 ZFGJJ)必须始终用数据源保存值 ——
// 被选中 schema 覆盖会导致 ORA-12514(服务名不存在)
......@@ -75,23 +115,43 @@ async function onSourceChange() { schemas.value = []; tables.value = []; form.da
async function loadTables() { const s = sources.value.find(x => x.id === form.source); if (!s || !form.database) return; loadingObjects.value = true; try { const r = await listTables(sourceForm(s, form.database)); tables.value = r.tables || []; form.tables = []; form.table = ''; form.fields = []; columns.value = [] } catch (e) { ElMessage.error(`加载数据表失败:${e.message}`) } finally { loadingObjects.value = false } }
async function loadColumns() { const s = sources.value.find(x => x.id === form.source); if (!s || !form.database || !form.table) return; loadingObjects.value = true; try { const r = await listColumns(sourceForm(s, form.database), form.table); columns.value = r.columns || []; form.fields = [] } catch (e) { ElMessage.error(`加载字段失败:${e.message}`) } finally { loadingObjects.value = false } }
async function refreshVersions() { try { versions.value = await listBackupVersions() } catch (_) { versions.value = [] } }
function resetForm() { Object.assign(form, { source: null, database: '', scope: 'database', tables: [], table: '', fields: [], includeSchema: true, includeData: true, filename: '', conflict: 'skip', password: '' }); schemas.value = []; tables.value = []; columns.value = [] }
function resetForm() { Object.assign(form, { source: null, database: '', scope: 'database', tables: [], table: '', fields: [], includeSchema: true, includeData: true, backupMode: 'full', filename: '', conflict: 'skip', password: '' }); schemas.value = []; tables.value = []; columns.value = [] }
function openCreate() { resetForm(); refreshVersions(); createOpen.value = true }
function formatTime(value) { return value ? new Date(value).toLocaleString() : '—' }
async function migrateLegacyRecords() { const migrationKey = `db-tools-operation-${props.type}-database-migrated`; if (localStorage.getItem(migrationKey)) return; let legacy; try { legacy = JSON.parse(localStorage.getItem(`db-tools-operation-${props.type}`) || '[]') || [] } catch (_) { legacy = [] } if (legacy.length) { const payload = legacy.map(item => ({ source_name: item.sourceName || '', database: item.database || '', tables: String(item.tables || '').split('、').filter(Boolean), filename: item.filename || '', execute_setting: item.executeSetting || '', status: item.status || '已完成', created_at: item.createdAt && !Number.isNaN(new Date(item.createdAt).getTime()) ? new Date(item.createdAt).toISOString() : null })); await importOperationRecords(props.type, payload) } localStorage.setItem(migrationKey, 'v1') }
async function loadRecords() { try { await migrateLegacyRecords(); const rows = await listOperationRecords(props.type); records.value = rows.map(row => ({ ...row, name: `${title.value}-${formatTime(row.created_at)}`, sourceName: row.source_name, tables: (row.tables || []).join('、') || '—', executeSetting: row.execute_setting, createdAt: formatTime(row.created_at) })) } catch (error) { records.value = []; ElMessage.error(`加载${title.value}记录失败:${error.message}`) } currentPage.value = 1 }
async function loadRecords() { try { await migrateLegacyRecords(); const rows = await listOperationRecords(props.type); records.value = rows.map(row => ({ ...row, name: row.task_name ? `${row.task_name}(#${row.task_id})` : `${title.value}-${formatTime(row.created_at)}`, sourceName: row.source_name, taskName: row.task_name || '', triggerType: row.trigger_type || 'manual', backupMode: row.backup_mode || '', tables: (row.tables || []).join('、') || '—', executeSetting: row.error_message ? `${row.execute_setting || '失败'}:${row.error_message}` : row.execute_setting || '—', createdAt: formatTime(row.created_at) })) } catch (error) { records.value = []; ElMessage.error(`加载${title.value}记录失败:${error.message}`) } }
function onQuery() { appliedKeyword.value = keyword.value; currentPage.value = 1 }
function onReset() { keyword.value = ''; appliedKeyword.value = ''; currentPage.value = 1 }
function onTaskFilterChange(value) {
currentPage.value = 1
// 记忆选择:清空也记住(下次进来默认全部)
localStorage.setItem(TASK_FILTER_KEY, value || '')
// 从「备份记录」带 task_id 跳转进来后手动改选:把 URL 参数摘掉,避免下次进入被旧参数覆盖
if (route.query.task_id) router.replace({ path: route.path, query: { ...route.query, task_id: undefined } })
}
function onFileFilterChange(value) {
currentPage.value = 1
localStorage.setItem(FILE_FILTER_KEY, value || '')
}
function formatSize(n) { return n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB` }
function scopeLabel(version) { return version.source?.scope === 'tables' ? '指定表' : version.source?.scope === 'database' ? '整库' : '范围未知' }
async function submit() { if (!canSubmit.value) { const message = props.type === 'restore' ? (!form.source ? '请选择数据源' : !form.database ? '请选择目标数据库' : !form.filename ? '请选择匹配的备份版本' : form.scope === 'tables' && !form.tables.length ? '请选择要还原的数据表' : '请先完成必填项') : (props.type === 'encrypt' || props.type === 'decrypt') ? (!form.source ? '请选择数据源' : !form.database ? '请选择数据库' : !form.table ? '请选择数据表' : form.scope === 'fields' && !form.fields.length ? '请选择要处理的字段' : form.password.length < 4 ? '密码至少 4 位' : '请先完成必填项') : '请先完成必填项'; ElMessage.warning(message); return } submitting.value = true; try { let r; if (props.type === 'backup') r = await createBackup(form.source, { scope: form.scope, tables: form.tables, database: form.database, schema: form.database, include_schema: form.includeSchema, include_data: form.includeData }); else if (props.type === 'restore') r = await restoreBackup({ filename: form.filename, source_id: form.source, database: form.database, schema: form.database, scope: form.scope, tables: form.tables, conflict: form.conflict }); else { const payload = { source_id: form.source, database: form.database, schema: form.database, table: form.table, scope: form.scope, fields: form.fields, password: form.password }; r = props.type === 'encrypt' ? await encryptData(payload) : await decryptData(payload) } createOpen.value = false; await Promise.all([loadRecords(), refreshVersions()]); ElMessage.success(r.message) } catch (e) { ElMessage.error(`${title.value}失败:${e.message}`) } finally { submitting.value = false } }
function versionScopeLabel(version) { return version.source?.scope === 'tables' ? '指定表' : version.source?.scope === 'database' ? '整库' : '范围未知' }
async function submit() { if (!canSubmit.value) { const message = props.type === 'restore' ? (!form.source ? '请选择数据源' : !form.database ? '请选择目标数据库' : !form.filename ? '请选择匹配的备份版本' : form.scope === 'tables' && !form.tables.length ? '请选择要还原的数据表' : '请先完成必填项') : (props.type === 'encrypt' || props.type === 'decrypt') ? (!form.source ? '请选择数据源' : !form.database ? '请选择数据库' : !form.table ? '请选择数据表' : form.scope === 'fields' && !form.fields.length ? '请选择要处理的字段' : form.password.length < 4 ? '密码至少 4 位' : '请先完成必填项') : '请先完成必填项'; ElMessage.warning(message); return } submitting.value = true; try { let r; if (props.type === 'backup') r = await createBackup(form.source, { scope: form.scope, tables: form.tables, database: form.database, schema: form.database, include_schema: form.includeSchema, include_data: form.includeData, backup_mode: form.backupMode }); else if (props.type === 'restore') r = await restoreBackup({ filename: form.filename, source_id: form.source, database: form.database, schema: form.database, scope: form.scope, tables: form.tables, conflict: form.conflict }); else { const payload = { source_id: form.source, database: form.database, schema: form.database, table: form.table, scope: form.scope, fields: form.fields, password: form.password }; r = props.type === 'encrypt' ? await encryptData(payload) : await decryptData(payload) } createOpen.value = false; await loadRecords(); ElMessage.success(r.message); if (props.type === 'backup' || props.type === 'restore') setTimeout(loadRecords, 1000) } catch (e) { ElMessage.error(`${title.value}失败:${e.message}`) } finally { submitting.value = false } }
function executionSettingSummary(value) { const message = String(value || '—'); if (props.type !== 'encrypt' && props.type !== 'decrypt') return message; const separator = message.search(/[;;]/); if (separator >= 0) return `${message.slice(0, separator)}...`; return message.length > 40 ? `${message.slice(0, 40)}...` : message }
function viewRecord(r) { if (props.type === 'encrypt' || props.type === 'decrypt') { detailRecord.value = r; return } ElMessage.info(`${r.name}:${r.executeSetting}`) }
function triggerTypeLabel(record) { return record.triggerType === 'scheduled' ? `自动${record.taskName ? `(${record.taskName})` : ''}` : '手动' }
function backupModeLabel(record) { return record.backupMode === 'incremental' ? '增量' : record.backupMode === 'full' ? '全量' : '—' }
function scopeLabel(scope) { return scope === 'tables' ? '指定表' : scope === 'database' ? '整库' : '—' }
function statusTag(status) { return status === '失败' ? 'danger' : ['备份中', '还原中'].includes(status) ? 'warning' : 'success' }
function viewRecord(r) { detailRecord.value = r }
watch([filteredRecords, pageCount], () => { if (currentPage.value > pageCount.value) currentPage.value = pageCount.value }); watch(() => props.type, loadRecords)
onMounted(async () => { await loadRecords(); try { sources.value = await listDataSources() } catch (_) { sources.value = [] } await refreshVersions() })
// 「备份记录」跳转(带 task_id)时组件可能已挂载(路由复用),监听参数同步进下拉
watch(() => route.query.task_id, (v) => { const q = String(v || ''); if (props.type === 'backup' && q) { taskFilterId.value = q; localStorage.setItem(TASK_FILTER_KEY, q); currentPage.value = 1 } })
onMounted(async () => { await loadRecords(); try { sources.value = await listDataSources() } catch (_) { sources.value = [] } await refreshVersions(); if (props.type === 'backup') { try { backupTasks.value = await listBackupTasks() } catch (_) { backupTasks.value = [] } const fromQuery = String(route.query.task_id || ''); if (fromQuery) { taskFilterId.value = fromQuery; localStorage.setItem(TASK_FILTER_KEY, fromQuery) } else { const saved = localStorage.getItem(TASK_FILTER_KEY) || ''; taskFilterId.value = backupTasks.value.some(t => String(t.id) === saved) ? saved : '' } } else if (props.type === 'restore') { const saved = localStorage.getItem(FILE_FILTER_KEY) || ''; fileFilterId.value = restoreFiles.value.includes(saved) ? saved : '' } pollTimer = setInterval(() => { if (records.value.some(r => ['备份中', '还原中'].includes(r.status))) loadRecords() }, 2000) })
onUnmounted(() => { if (pollTimer) clearInterval(pollTimer) })
</script>
<style scoped>
.operation-list{table-layout:fixed;min-width:0}
.operation-list{table-layout:fixed;min-width:1280px}
.detail-meta{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 16px;margin-bottom:16px;color:var(--app-text-muted);font-size:var(--font-size-sm);overflow-wrap:anywhere}
/* 数据源(2)/ 状态(7)/ 创建时间(8)列内容居中 */
.operation-list td:nth-child(2),.operation-list td:nth-child(7),.operation-list td:nth-child(8){text-align:center}.table-summary,.file-summary{max-width:150px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.execution-setting{max-width:300px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.version-tables{margin-top:8px}.filename-hint{margin-top:10px}.empty-cell{text-align:center;color:var(--app-text-muted);padding:36px 16px!important}.detail-text{margin:0;white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.7}.step{display:flex;gap:14px;padding:14px 0;border-bottom:1px solid var(--color-border-light);align-items:flex-start}.step:last-child{border-bottom:0}.step-no{width:28px;height:28px;border-radius:50%;background:var(--color-primary);color:#fff;display:inline-flex;align-items:center;justify-content:center;font-weight:600;flex:0 0 auto}.step-content{flex:1;min-width:0}.step-content b{display:inline-block;line-height:40px;color:var(--color-text-regular)}.source-select{width:380px;margin-left:20px;vertical-align:middle}.scope-choice{margin-top:12px}.table-picker{margin-top:14px;max-height:220px;overflow:auto;padding:10px 14px;border:1px solid var(--color-border-base)}.table-picker :deep(.el-checkbox){display:block;margin:7px 0}.table-picker small{color:var(--app-text-muted)}.muted{color:var(--app-text-muted)}.password-input{width:380px;margin-left:20px}
.operation-list td:nth-child(2),.operation-list td:nth-child(6),.operation-list td:nth-child(7),.operation-list td:nth-child(8){text-align:center}.table-summary,.file-summary{max-width:150px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.execution-setting{max-width:300px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.version-tables{margin-top:8px}.filename-hint{margin-top:10px}.empty-cell{text-align:center;color:var(--app-text-muted);padding:36px 16px!important}.detail-text{margin:0;white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.7}.step{display:flex;gap:14px;padding:14px 0;border-bottom:1px solid var(--color-border-light);align-items:flex-start}.step:last-child{border-bottom:0}.step-no{width:28px;height:28px;border-radius:50%;background:var(--color-primary);color:#fff;display:inline-flex;align-items:center;justify-content:center;font-weight:600;flex:0 0 auto}.step-content{flex:1;min-width:0}.step-content b{display:inline-block;line-height:40px;color:var(--color-text-regular)}.source-select{width:380px;margin-left:20px;vertical-align:middle}.scope-choice{margin-top:12px}.table-picker{margin-top:14px;max-height:220px;overflow:auto;padding:10px 14px;border:1px solid var(--color-border-base)}.table-picker :deep(.el-checkbox){display:block;margin:7px 0}.table-picker small{color:var(--app-text-muted)}.muted{color:var(--app-text-muted)}.password-input{width:380px;margin-left:20px}
</style>
......@@ -47,7 +47,7 @@
<el-form-item label="规则名称" required><el-input v-model="form.name" placeholder="如:身份证格式校验" /></el-form-item>
<el-form-item label="规则类型"><el-radio-group v-model="form.rule_type"><el-radio-button label="regex">正则</el-radio-button><el-radio-button label="number">数值</el-radio-button><el-radio-button label="date">日期</el-radio-button><el-radio-button label="string">字符串</el-radio-button></el-radio-group></el-form-item>
<el-form-item label="AI生成提示词"><el-input v-model="form.ai_prompt" type="textarea" :rows="3" maxlength="2000" show-word-limit placeholder="如:校验 18 位身份证号" /></el-form-item>
<el-form-item label="规则说明" required><el-input v-model="form.description" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="30-40 字说明,可手动修改" /></el-form-item>
<el-form-item label="规则说明" required><el-input v-model="form.description" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="简洁且意思完整的说明,可手动修改" /></el-form-item>
<el-form-item :label="form.rule_type === 'regex' ? '正则表达式' : '校验代码'" required>
<el-input v-model="ruleContent" type="textarea" :rows="form.rule_type === 'regex' ? 2 : 8" :placeholder="form.rule_type === 'regex' ? '^.+$' : 'def check(value):\n return True'" />
<div class="expression-actions">
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment