diff --git a/AGENTS.md b/AGENTS.md index 89b7d36..c948554 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,8 +40,8 @@ ### 启动与界面 - `App.xaml`:应用资源、HandyControl 主题和 `MainViewModel` 资源注册;启动页是 `Views/HomeWindow.xaml`。 -- `App.xaml.cs`:Velopack 初始化、后台/手动更新、更新前后 `data/` 与 `pre/` 备份恢复、全局异常日志。 -- `Views/HomeWindow.xaml`:单窗口主界面,包含四个迁移 Tab、连接状态、SQL 编辑区和批量操作。 +- `App.xaml.cs`:Velopack 初始化、后台/手动更新、更新前后 `config.ini`、`data/` 与 `pre/` 备份恢复、全局异常日志。 +- `Views/HomeWindow.xaml`:单窗口主界面,包含五个迁移 Tab、连接状态、SQL 编辑区和批量操作。 - `Views/HomeWindow.xaml.cs`:只处理窗口关闭和手动检查更新等窗口事件。 - `Views/ConnectionDialog.*`:源库/目标库连接配置与测试。 - `Views/ErrorDialog.*`:错误详情展示和复制。 @@ -49,7 +49,7 @@ ### 状态与编排 -- `ViewModels/MainViewModel.cs`:主状态容器和命令编排;初始化四个 `TabServices`,管理数据库连接、单行/批量读写、SQL 编辑与断点续写。 +- `ViewModels/MainViewModel.cs`:主状态容器和命令编排;初始化五个 `TabServices`,管理数据库连接、单行/批量读写、SQL 编辑与断点续写。 - `Models/TableRowModel.cs`:每个迁移对象的名称、所属 Tab、读写状态。 - `Enums/`:Tab、状态、工作类型和输出扩展名枚举。 @@ -60,34 +60,41 @@ - `Services/DbServices.cs`:Oracle SQL 的提取与执行核心: - `GetTableSql`:读取表 DDL、注释、主外键、唯一键和索引; - `GetTableBos`:读取 BOS 配置; + - `GetTableBosField`:按 `TABLE.COLUMN` 读取单个 BOS 字段配置,生成目标物理字段补充 SQL,并包含关联限定值/引用元数据; - `GetPrcSql` / `GetAllPrc`:读取存储过程或函数源码; - `GetTableData`:生成表数据 INSERT SQL; - - `ExecuteCreate`:按 `\n/` 分隔 SQL,在目标库逐条执行并检查 `USER_ERRORS`; - - `PreProcess`:在源库执行 `pre/` 下全部脚本。 + - `ExecuteCreate`:按 `\n/` 分隔 SQL,在目标库逐条执行并检查 `USER_ERRORS`。 - `Common/SqlSugarHelper.cs`:维护 `orig`(源库)和 `dest`(目标库)两个 SqlSugar 连接。 - `Common/SysConfig.cs`:读取和写入运行目录下的 `config.ini`。 - `Common/CommonHelper.cs`:运行目录、`data/`、`data/from/`、`pre/` 路径及文本读写。 ## 真实数据流 -四个 Tab 的映射由 `MainViewModel` 构造函数定义: +五个 Tab 的映射由 `MainViewModel` 构造函数定义: | Tab | 索引文件 | 内容文件 | 源库读取方法 | | --- | --- | --- | --- | | 表配置 | `data/from/table.txt` | `data/{对象名}_creat.txt` | `DbServices.GetTableSql` | | BOS 配置 | `data/from/bos.txt` | `data/{对象名}_bos.txt` | `DbServices.GetTableBos` | +| BOS 字段配置 | `data/from/bos_field.txt` | `data/{表名}.{字段名}_bos_field.txt` | `DbServices.GetTableBosField` | | 存储过程 | `data/from/prc.txt` | `data/{对象名}_prc.txt` | `DbServices.GetPrcSql` | | 表数据 | `data/from/data.txt` | `data/{对象名}_data.txt` | `DbServices.GetTableData` | 读取流程:索引项 -> 调用源库读取委托 -> 生成 SQL 文本 -> 保存到 `data/` -> 更新读取状态并显示内容。 +读取 BOS 配置前会检查源库当前用户下的辅助过程。整表读取使用 `HMIG_GET_TABLE_BOS_31`,单字段读取使用 `HMIG_GET_TABLE_BOS_FIELD_31`;若过程不存在,程序从运行目录的同名 `pre/*.prc` 脚本安装,检查 `USER_ERRORS` 和对象状态后再读取。单字段列表项必须采用 `TABLE.COLUMN` 格式。界面不提供手动“预处理”入口。 + +单字段 SQL 写入目标库时,限定值组按名称、限定值按组和值判断,只补充缺失项;引用元数据按引用表名和字段 `DBNAME` 映射,目标引用字段不存在或不唯一时直接报错,不自动创建。 + +单字段读取还会查询源库 `USER_TABLES`、`USER_TAB_COLUMNS`。源对象是物理表且源字段存在时,输出文件首先检查目标库物理表和字段:目标表不存在时报错,目标字段不存在时按源字段的数据类型、长度、精度和字符语义执行 `ALTER TABLE ... ADD`。源对象是视图或 BOS 计算字段时不生成物理字段 DDL。 + 写入流程:索引项 -> 读取对应 `data/*_{后缀}.txt` -> `DbServices.ExecuteCreate` -> 在目标库执行 -> 更新写入状态。批量写入遇到第一条失败即停止,并在内存中记录断点;断点不会跨进程持久化。 删除列表项会同时删除对应内容文件。调整顺序会立即覆盖索引文件。新增对象名会被转为小写,数据库读取时再按需要转大写。 ## 配置和运行时文件 -- `config.ini` 保存源库和目标库的 IP/服务名、用户名、密码。当前实现是明文读写,不要把注释或 UI 文案误当成加密能力。 +- `config.ini` 保存源库和目标库的 IP/服务名、用户名、密码。当前实现是明文读写,首次运行缺失时创建空配置;发布包不得携带本机配置。 - 不要在日志、回答、补丁或测试输出中展示真实连接串和密码。 - 修改或发布时不要用样例配置覆盖用户现有 `config.ini`。 - `data/` 和 `pre/` 是用户可编辑、更新时需要保留的运行数据;不要无故清空、批量改名或删除。 @@ -113,11 +120,11 @@ 源库和目标库不是等价环境: - `Get*` 方法通常查询源库;`ExecuteCreate` 明确写目标库。 -- `PreProcess` 会在源库执行 `pre/` 下的 SQL/PLSQL,虽然名为预处理,但不是只读操作。 +- 首次读取整表或单字段 BOS 配置时可能在源库自动创建对应的 `HMIG_GET_TABLE_BOS_31` 或 `HMIG_GET_TABLE_BOS_FIELD_31`;这不是只读操作,源库账号需要相应的 `CREATE PROCEDURE` 权限。 - 连接按钮会把界面中的连接信息写回 `config.ini`,并重建同时包含源库和目标库的 SqlSugarScope。 - 界面的“断开”当前只更新连接状态,并未释放或关闭底层连接对象。 -除非用户明确要求并确认数据库、对象和影响范围,否则不要连接真实数据库、执行预处理、执行写入、清理数据或测试破坏性 SQL。需要验证数据库写入逻辑时优先使用代码审查、可控测试库或事务化的最小复现。真实目标库写入前必须提醒备份并确认权限。 +除非用户明确要求并确认数据库、对象和影响范围,否则不要连接真实数据库、触发 BOS 辅助过程安装、执行目标库写入、清理数据或测试破坏性 SQL。需要验证数据库写入逻辑时优先使用代码审查、可控测试库或事务化的最小复现。真实目标库写入前必须提醒备份并确认权限。 ## 构建与验证 @@ -138,7 +145,7 @@ dotnet build .\henlo_migration_wpf10\henlo_migration_wpf10.csproj -c Debug --no- 1. 所有 C#/XAML 修改至少执行一次主项目构建。 2. 仅文档修改可不构建,但要重新读取文件检查 Markdown、路径和命令。 -3. XAML/绑定修改除构建外,应在 Windows 上启动应用,检查四个 Tab、连接弹窗、按钮可用性和错误弹窗;不要因此连接生产库。 +3. XAML/绑定修改除构建外,应在 Windows 上启动应用,检查五个 Tab、连接弹窗、按钮可用性和错误弹窗;不要因此连接生产库。 4. SQL 生成/拆分修改需用不含真实数据的代表性 SQL 验证表 DDL、注释、约束、PL/SQL 和 INSERT。 5. 更新逻辑修改需分别考虑普通 `dotnet run`(非 Velopack 安装)和 Velopack 安装态。 @@ -154,12 +161,11 @@ dotnet run --project .\henlo_migration_wpf10\henlo_migration_wpf10.csproj ## 版本与发布 -- 版本号只修改 `.csproj` 中的 ``,必须单调递增;显示版本来自程序集元数据。 -- 正式发布入口是 `henlo_migration_wpf10/pack.bat`。脚本会清理 `publish/`、执行 `dotnet publish -c Release --self-contained false -r win-x64`,再调用 `vpk pack --noInst` 输出到 `releases/`。 -- `pack.bat` 依赖本机 `dotnet`、`vpk` 和 Windows 批处理环境;不要把“构建通过”等同于“打包通过”。 -- 发布是显式任务。未经要求不要运行打包、删除发布目录、修改版本号或上传更新服务器。 -- 打包后核对便携 ZIP、full/delta nupkg、`RELEASES`/JSON 元数据以及实际版本号。上传服务器属于外部写操作,必须另行获得明确授权。 -- 若修改运行时必需文件,检查 `.csproj` 的 `CopyToOutputDirectory` 规则和 `App.xaml.cs` 的更新备份/恢复范围。 +- 仓库根目录的 `RELEASE.md` 是当前发版流程、产物、上传和自动更新风险的权威说明。凡任务涉及版本号、Release 构建、Velopack、打包、便携包、自动更新、服务器部署或上传,必须先完整读取 `RELEASE.md`,再执行或给出结论。 +- 正式版本的用户可见变更记录维护在仓库根目录 `CHANGELOG.md`,发版时必须新增对应版本条目。 +- 版本号只修改 `.csproj` 中的 ``,必须单调递增;正式发布入口是 `henlo_migration_wpf10/pack.bat`。 +- 发布是显式任务。未经要求不要运行打包、删除发布目录、修改版本号或上传更新服务器;构建通过不等于打包或自动更新验证通过。 +- 若代码、脚本和 `RELEASE.md` 不一致,以实际代码和 `pack.bat` 为准,并在同一任务中同步修正文档。 ## 完成任务时的交付说明 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..62039d8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +# 更新记录 + +## 1.0.17 - 2026-08-26 + +### 新增 + +- 新增“BOS字段配置”页签,使用 `TABLE.COLUMN` 单独迁移一个 BOS 字段配置。 +- 单字段迁移包含限定值组及限定值:目标库不存在时自动补充,已有数据不覆盖。 +- 单字段迁移支持引用元数据重新映射;目标引用字段不存在或不唯一时明确报错。 +- 单字段写入前检查目标物理表字段;目标字段缺失时按源库字段类型、长度、精度和字符语义自动新增。 +- 新增 `HMIG_GET_TABLE_BOS_FIELD_31` 辅助过程,并使用 Oracle CLOB 返回完整迁移 SQL。 + +### 调整 + +- 取消界面“预处理”按钮。读取整表或单字段 BOS 配置时,缺少对应辅助过程才从 `pre/` 自动安装并检查编译状态。 +- 整理项目、构建和发版约定,新增仓库级 `AGENTS.md` 与 `RELEASE.md`。 +- 发布包不再携带本机 `config.ini` 和 `data/from/*.txt`;首次运行创建空配置和运行目录,后续更新备份并恢复用户的 `config.ini`、`data/` 和 `pre/`。 + +### 兼容性 + +- 保留原有整表 BOS 配置、表结构、存储过程/函数和表数据迁移流程。 +- 仍为 Windows x64、.NET 10 framework-dependent 便携发布,不包含安装器。 diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..bff4b90 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,142 @@ +# 恒诺迁移工具构建与发版说明 + +本文是 `henlo_migration_wpf10/` 的构建、Velopack 打包、更新服务器部署和客户端自动更新说明。涉及版本、发布或更新的任务必须先完整读取本文;若本文与实际代码冲突,以 `.csproj`、`pack.bat` 和 `App.xaml.cs` 为准,并同步更新本文。 + +## 权限边界 + +- 普通 Debug/Release 构建可以用于本地验证。 +- 修改版本号、运行 `pack.bat`、删除或覆盖发布产物、上传更新服务器都属于显式发布动作,必须有用户当前任务的明确授权。 +- 打包脚本不会上传任何文件;服务器部署是独立的外部写操作,需要另行授权。 +- 不要把“项目构建成功”描述成“发版成功”或“自动更新已验证”。 + +## 版本来源 + +- 唯一版本来源是 `henlo_migration_wpf10/henlo_migration_wpf10.csproj` 的 ``。 +- 正式发版前必须把版本号单调递增,不要重新打包已存在的版本号。 +- 程序显示版本来自程序集元数据,不在脚本或文档中另设版本常量。 + +## 日常构建 + +在仓库根目录执行: + +```powershell +dotnet restore .\henlo_migration_wpf10\henlo_migration_wpf10.csproj +dotnet build .\henlo_migration_wpf10\henlo_migration_wpf10.csproj -c Debug +``` + +依赖已经恢复且不希望访问包源时: + +```powershell +dotnet build .\henlo_migration_wpf10\henlo_migration_wpf10.csproj -c Debug --no-restore +``` + +Debug 产物位于 `henlo_migration_wpf10/bin/Debug/net10.0-windows/`,只用于开发验证,不是 Velopack 发布包。项目现有 `CS8632` 和 `NU1701` 警告不等于构建错误,但不得新增编译错误。 + +## 正式打包流程 + +唯一正式入口: + +```powershell +cd .\henlo_migration_wpf10 +.\pack.bat +``` + +`pack.bat` 当前执行顺序: + +1. 从 `.csproj` 读取 ``。 +2. 删除并重建 `publish/`。 +3. 执行: + + ```powershell + dotnet publish henlo_migration_wpf10.csproj -c Release -o publish --self-contained false -r win-x64 + ``` + +4. 删除 `publish/config.ini`,确保本机数据库配置不会进入产物。 +5. 确保 `publish/data/`、`publish/data/from/`、`publish/pre/` 存在。 +6. 执行: + + ```powershell + vpk pack -u henlo_migration -v -p publish -e "恒诺迁移工具.exe" -o releases --noInst + ``` + +7. 保留 `releases/` 中的历史包,供 Velopack 生成差量包;脚本只清理 `publish/`,不会清理 `releases/`。 + +打包依赖 Windows、对应 .NET SDK、可调用的 `dotnet` 和 `vpk`。不要使用 `_build*`、`_make_pack.bat`、`_write_pack.ps1` 等历史辅助文件作为正式入口。 + +## 当前发布形态 + +- 目标平台:Windows x64。 +- 发布方式:framework-dependent,`--self-contained false`;目标机器需要匹配的 .NET 10 Desktop Runtime。 +- Velopack 参数包含 `--noInst`,因此不生成 Setup 安装器,只生成便携包和更新源文件。 +- `Properties/PublishProfiles/FolderProfile.pubxml` 虽配置 `PublishSingleFile=true`,但 `pack.bat` 没有使用该 Profile;正式产物不是单文件,会包含 EXE、DLL 和 runtimeconfig/deps 文件。 + +## 运行时文件进入发布包的规则 + +- `.csproj` 只复制 `pre/*.txt|*.fnc|*.prc` 等程序辅助脚本,不复制本机 `config.ini` 或 `data/from/*.txt`;`pack.bat` 还会在打包前再次删除 `publish/config.ini`,并创建空的 `data/from/` 目录。 +- `pre/hmig_get_table_bos_31.prc`、`pre/hmig_get_table_bos_field_31.prc` 等运行时辅助过程必须出现在发布输出中。 +- 新安装的索引列表为空;程序添加第一条对应配置时会在 `data/from/` 创建 `table.txt`、`bos.txt`、`bos_field.txt`、`prc.txt` 或 `data.txt`。 +- 新安装首次运行时会创建值为空的 `config.ini`,由用户在界面配置;不得把真实或样例账号密码放入发布产物。 + +## Velopack 产物 + +正常情况下 `releases/` 至少包含: + +```text +henlo_migration-win-Portable.zip +henlo_migration--full.nupkg +henlo_migration--delta.nupkg +RELEASES +releases.win.json +assets.win.json +``` + +便携 ZIP 内应包含 `.portable`、根启动器、`Update.exe`、`current/` 应用文件以及需要的 `data/`、`pre/` 内容。差量包是否生成取决于 `releases/` 中是否有可用的历史版本。 + +## 更新服务器部署 + +客户端更新地址硬编码在 `App.xaml.cs`: + +```text +http://zjhenlo.henlo.net:8878/henlo_migration/auto_update/ +``` + +当前没有自动上传脚本。获得上传授权后,应部署并核对: + +- 新版本 full nupkg; +- 新版本 delta nupkg(若生成); +- `RELEASES`; +- `releases.win.json`; +- `assets.win.json`; +- 需要提供便携下载时再上传 `henlo_migration-win-Portable.zip`。 + +上传后至少验证服务器能直接访问元数据文件和其中引用的包,文件大小及版本号与本地产物一致。不要只上传 nupkg 而遗漏更新后的元数据。 + +## 客户端自动更新流程 + +`App.xaml.cs` 当前行为: + +1. UI 启动前调用 `VelopackApp.Build().Run()`。 +2. 从系统临时目录恢复上次更新前备份的 `config.ini`、`data/`、`pre/`。 +3. 创建指向更新服务器的 `UpdateManager`;非 Velopack 环境创建失败时禁用自动更新。 +4. 后台静默检查并下载更新;手动“检查更新”使用相同更新源。 +5. 用户同意立即重启时,先备份 `config.ini`、`data/`、`pre/`,再调用 `ApplyUpdatesAndRestart()`。 +6. 新版本首次启动时恢复备份并删除临时备份目录。 + +当前限制和未验证项: + +- 更新备份范围包含 `config.ini`、`data/` 和 `pre/`;恢复失败时会记录到 `error.log`,发版验收仍需验证用户配置未丢失。 +- 用户下载完成后选择“不立即重启”时,代码没有明确实现下次启动自动应用已下载更新的流程。 +- 直接运行普通 `publish/` 中的 EXE 时通常不属于 Velopack 安装态,程序会禁用更新或把检查异常写入 `error.log`。 +- `--noInst` 生成的是便携模式;便携包的检查、下载、应用和重启更新链路尚未完成实际端到端回归,不能声称自动更新可靠可用。 + +## 发版验收清单 + +1. 确认工作区改动和准备发布的提交范围,避免把无关或敏感文件带入。 +2. 递增并核对 `.csproj` 版本号。 +3. 先执行主项目构建,确保 `0` 个错误。 +4. 获得明确授权后运行 `pack.bat`,确认 publish 和 vpk 两阶段都成功。 +5. 检查便携 ZIP、full/delta 包、`RELEASES` 和两个 JSON 的版本、大小及时间。 +6. 检查发布包中的 `pre/` 和必要运行文件,并确认产物中不存在 `config.ini` 及开发机 `data/from/*.txt`。 +7. 在未安装态测试普通 EXE/便携包启动;若任务涉及自动更新,再在对应 Velopack 运行形态下单独验证更新。 +8. 只有获得上传授权后才能部署服务器;上传后验证 URL 和包完整性。 +9. 交付说明必须区分:构建验证、打包验证、便携运行验证、自动更新验证和服务器部署验证。 diff --git a/henlo_migration_wpf10/App.xaml.cs b/henlo_migration_wpf10/App.xaml.cs index 578c0e8..041b59e 100644 --- a/henlo_migration_wpf10/App.xaml.cs +++ b/henlo_migration_wpf10/App.xaml.cs @@ -164,8 +164,9 @@ namespace henlo_migration var rootDir = AppDomain.CurrentDomain.BaseDirectory; var dataDir = Path.Combine(rootDir, "data"); var preDir = Path.Combine(rootDir, "pre"); + var configPath = Path.Combine(rootDir, "config.ini"); - if (!Directory.Exists(dataDir) && !Directory.Exists(preDir)) return; + if (!Directory.Exists(dataDir) && !Directory.Exists(preDir) && !File.Exists(configPath)) return; if (Directory.Exists(BackupDir)) Directory.Delete(BackupDir, true); @@ -176,6 +177,9 @@ namespace henlo_migration if (Directory.Exists(preDir)) CopyDirectory(preDir, Path.Combine(BackupDir, "pre")); + + if (File.Exists(configPath)) + File.Copy(configPath, Path.Combine(BackupDir, "config.ini"), true); } catch (Exception ex) { @@ -200,6 +204,10 @@ namespace henlo_migration if (Directory.Exists(backupPre)) CopyDirectory(backupPre, Path.Combine(rootDir, "pre")); + var backupConfig = Path.Combine(BackupDir, "config.ini"); + if (File.Exists(backupConfig)) + File.Copy(backupConfig, Path.Combine(rootDir, "config.ini"), true); + Directory.Delete(BackupDir, true); } catch (Exception ex) @@ -227,4 +235,4 @@ namespace henlo_migration catch { } } } -} \ No newline at end of file +} diff --git a/henlo_migration_wpf10/Common/SysConfig.cs b/henlo_migration_wpf10/Common/SysConfig.cs index 91140df..b4b08fb 100644 --- a/henlo_migration_wpf10/Common/SysConfig.cs +++ b/henlo_migration_wpf10/Common/SysConfig.cs @@ -17,14 +17,14 @@ namespace henlo_migration.Common if (!System.IO.File.Exists(ConfigPath)) { System.IO.File.WriteAllText(ConfigPath, @"[oracle] -orig_ip=127.0.0.1 -orig_exam=orcl -orig_user=system -orig_password=system -desc_ip=127.0.0.1 -desc_exam=orcl -desc_user=system -desc_password=system +orig_ip= +orig_exam= +orig_user= +orig_password= +desc_ip= +desc_exam= +desc_user= +desc_password= "); } _init = parser.ReadFile(ConfigPath); diff --git a/henlo_migration_wpf10/Enums/CodeExt.cs b/henlo_migration_wpf10/Enums/CodeExt.cs index 9f71ec7..426ac01 100644 --- a/henlo_migration_wpf10/Enums/CodeExt.cs +++ b/henlo_migration_wpf10/Enums/CodeExt.cs @@ -3,6 +3,7 @@ namespace henlo_migration.Enums public enum CodeExt { bos, + bos_field, creat, data, prc diff --git a/henlo_migration_wpf10/Enums/TabsEnum.cs b/henlo_migration_wpf10/Enums/TabsEnum.cs index 118ed7c..291cab0 100644 --- a/henlo_migration_wpf10/Enums/TabsEnum.cs +++ b/henlo_migration_wpf10/Enums/TabsEnum.cs @@ -4,7 +4,8 @@ namespace henlo_migration.Enums { 表 = 0, BOS表配置 = 1, - 存储过程 = 2, - 表数据 = 3 + BOS字段配置 = 2, + 存储过程 = 3, + 表数据 = 4 } } diff --git a/henlo_migration_wpf10/README.md b/henlo_migration_wpf10/README.md index 9626acd..2380207 100644 --- a/henlo_migration_wpf10/README.md +++ b/henlo_migration_wpf10/README.md @@ -13,6 +13,7 @@ | **表结构迁移** | 读取源库表结构(DDL)、表注释、字段注释、主键/外键/索引,写入目标库 | | **存储过程迁移** | 读取/写入存储过程和函数源码 | | **BOS 配置迁移** | 读取 BOS 表配置写入目标库 | +| **BOS 字段配置迁移** | 按 `TABLE.COLUMN` 补充目标物理字段并迁移字段配置、限定值和引用元数据 | | **表数据迁移** | 支持表数据的导出/导入 | | **自动更新** | 支持通过私有服务器 Velopack 自动检查更新 | | **断点续写** | 批量写入失败后,下次从失败行继续,无需从头开始 | @@ -57,7 +58,7 @@ henlo_migration_wpf10/ │ ├── HomeWindow.xaml # 主窗口 │ └── ConnectionDialog.xaml # 连接配置 ├── data/from/ # 索引文件(table.txt 等) -├── pre/ # 预处理脚本 +├── pre/ # 运行时辅助过程/函数脚本(BOS 读取缺失时自动安装) ├── pack.bat # Velopack 打包脚本 └── henlo_migration_wpf10.csproj ``` @@ -89,7 +90,7 @@ henlo_migration_wpf10/ - **源库**:读取数据的数据库 - **目标库**:写入数据的数据库 -连接信息保存在 `config.ini` 中。 +连接信息保存在运行目录的 `config.ini` 中。发布包不携带该文件,首次运行会创建空配置。 ### 2. 索引文件 @@ -99,10 +100,16 @@ henlo_migration_wpf10/ |------|------| | `table.txt` | 表结构列表 | | `bos.txt` | BOS 配置列表 | +| `bos_field.txt` | BOS 单字段配置列表,每行使用 `TABLE.COLUMN` 格式 | | `prc.txt` | 存储过程/函数列表 | | `data.txt` | 表数据列表 | -每行一个对象名称,读取数据库时会自动更新内容。 +每行一个对象名称,读取数据库时会自动更新内容。BOS 单字段配置生成 +`data/{表名}.{字段名}_bos_field.txt`;源库缺少单字段读取过程时,程序会从 +`pre/hmig_get_table_bos_field_31.prc` 自动安装后再读取。 + +单字段配置写入时,目标库缺少的限定值组和限定值会自动补充;引用字段只做存在性和唯一性检查,不存在或不唯一时停止并报错。 +若源对象是物理表且存在同名物理字段,生成的 SQL 还会检查目标表字段,并在字段缺失时按源字段类型执行 `ALTER TABLE ... ADD`;目标物理表不存在时直接报错。视图字段和 BOS 计算字段不会创建物理字段。 ### 3. 基本操作流程 diff --git a/henlo_migration_wpf10/Services/DbServices.cs b/henlo_migration_wpf10/Services/DbServices.cs index e5a0033..3819529 100644 --- a/henlo_migration_wpf10/Services/DbServices.cs +++ b/henlo_migration_wpf10/Services/DbServices.cs @@ -1,4 +1,6 @@ using SqlSugar; +using Oracle.ManagedDataAccess.Client; +using Oracle.ManagedDataAccess.Types; using System; using System.Collections.Generic; using System.Data; @@ -132,12 +134,77 @@ namespace henlo_migration.Services private static string EscapeQuote(string s) => s?.Replace("'", "''") ?? ""; - /// 获取BOS表配置 - 调用 hmig_get_table_bos_31 存储过程获取 r_table,再查 tabletext + private const string BosReaderProcedureName = "HMIG_GET_TABLE_BOS_31"; + private const string BosReaderProcedureFileName = "hmig_get_table_bos_31.prc"; + private const string BosFieldReaderProcedureName = "HMIG_GET_TABLE_BOS_FIELD_31"; + private const string BosFieldReaderProcedureFileName = "hmig_get_table_bos_field_31.prc"; + + /// 确保源库存在且启用了指定辅助过程;缺失时从 pre 目录安装 + private static void EnsureReaderProcedure(string procedureName, string procedureFileName) + { + var db = SqlSugarHelper.DbOrig; + var objectResult = db.Ado.GetDataTable( + "SELECT STATUS FROM USER_OBJECTS WHERE OBJECT_NAME = :name AND OBJECT_TYPE = 'PROCEDURE'", + new SugarParameter("name", procedureName)); + + if (objectResult.Rows.Count > 0) + { + if (string.Equals(objectResult.Rows[0]["STATUS"]?.ToString(), "VALID", StringComparison.OrdinalIgnoreCase)) + return; + + ThrowProcedureErrors(procedureName, $"源库过程 {procedureName} 状态无效"); + throw new InvalidOperationException($"源库过程 {procedureName} 状态无效,请检查后重试。"); + } + + string scriptPath = Path.Combine(CommonHelper.PrePath, procedureFileName); + if (!File.Exists(scriptPath)) + throw new FileNotFoundException($"缺少 BOS 配置读取过程脚本:{scriptPath}", scriptPath); + + string script = File.ReadAllText(scriptPath).TrimEnd(); + // 兼容带 SQL*Plus 结束符的脚本;ODP.NET 执行时不能包含末尾的独立斜杠。 + if (script.EndsWith("/", StringComparison.Ordinal)) + script = script.Substring(0, script.Length - 1).TrimEnd(); + + if (string.IsNullOrWhiteSpace(script)) + throw new InvalidDataException($"BOS 配置读取过程脚本为空:{scriptPath}"); + + db.Ado.ExecuteCommand(script); + + ThrowProcedureErrors(procedureName, $"自动安装 {procedureName} 后存在编译错误"); + + var installedResult = db.Ado.GetDataTable( + "SELECT STATUS FROM USER_OBJECTS WHERE OBJECT_NAME = :name AND OBJECT_TYPE = 'PROCEDURE'", + new SugarParameter("name", procedureName)); + if (installedResult.Rows.Count == 0 || + !string.Equals(installedResult.Rows[0]["STATUS"]?.ToString(), "VALID", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"自动安装 {procedureName} 失败,源库中未找到有效过程。"); + } + } + + private static void ThrowProcedureErrors(string procedureName, string title) + { + var errors = SqlSugarHelper.DbOrig.Ado.GetDataTable( + "SELECT LINE, POSITION, TEXT FROM USER_ERRORS WHERE NAME = :name AND TYPE = 'PROCEDURE' ORDER BY SEQUENCE", + new SugarParameter("name", procedureName)); + if (errors.Rows.Count == 0) + return; + + var message = new StringBuilder(); + message.AppendLine(title + ":"); + foreach (DataRow row in errors.Rows) + message.AppendLine($"Line {row["LINE"]}, Pos {row["POSITION"]}: {row["TEXT"]}"); + throw new InvalidOperationException(message.ToString()); + } + + /// 获取BOS表配置 - 确保过程存在后,调用过程获取 r_table,再查 tabletext public static string GetTableBos(string tableName) { var db = SqlSugarHelper.DbOrig; string t = tableName.Trim().ToUpperInvariant(); + EnsureReaderProcedure(BosReaderProcedureName, BosReaderProcedureFileName); + // 1. 调用存储过程,获取 r_id 和 r_table var pRId = new SugarParameter("r_id", null, true) { DbType = System.Data.DbType.Decimal }; var pRTable = new SugarParameter("r_table", null, true) { DbType = System.Data.DbType.String, Size = 200 }; @@ -162,6 +229,165 @@ namespace henlo_migration.Services return textResult.Rows.Count > 0 ? textResult.Rows[0][colName]?.ToString() ?? "" : ""; } + /// 解析 TABLE.COLUMN 格式的 BOS 字段名称 + public static bool TryParseBosFieldName(string qualifiedFieldName, out string tableName, out string columnName) + { + tableName = ""; + columnName = ""; + if (string.IsNullOrWhiteSpace(qualifiedFieldName)) + return false; + + string value = qualifiedFieldName.Trim(); + int separator = value.IndexOf('.'); + if (separator <= 0 || separator != value.LastIndexOf('.') || separator >= value.Length - 1) + return false; + + tableName = value.Substring(0, separator).Trim().ToUpperInvariant(); + columnName = value.Substring(separator + 1).Trim().ToUpperInvariant(); + return tableName.Length > 0 && columnName.Length > 0; + } + + /// 获取单个 BOS 字段配置,返回可在目标库执行的完整 PL/SQL + public static string GetTableBosField(string qualifiedFieldName) + { + if (!TryParseBosFieldName(qualifiedFieldName, out string tableName, out string columnName)) + throw new ArgumentException("BOS 字段配置必须使用 TABLE.COLUMN 格式。", nameof(qualifiedFieldName)); + + string physicalColumnSql = BuildPhysicalColumnEnsureSql(tableName, columnName); + EnsureReaderProcedure(BosFieldReaderProcedureName, BosFieldReaderProcedureFileName); + + var connection = SqlSugarHelper.DbOrig.Ado.Connection as OracleConnection + ?? throw new InvalidOperationException("源数据库连接不是 OracleConnection,无法读取 CLOB 输出参数。"); + bool closeAfterRead = connection.State != ConnectionState.Open; + + try + { + if (closeAfterRead) + connection.Open(); + + using var command = connection.CreateCommand(); + command.BindByName = true; + command.CommandText = + "BEGIN hmig_get_table_bos_field_31(:p_table_name, :p_column_name, :r_sql); END;"; + command.Parameters.Add(new OracleParameter("p_table_name", OracleDbType.Varchar2) + { + Direction = ParameterDirection.Input, + Value = tableName + }); + command.Parameters.Add(new OracleParameter("p_column_name", OracleDbType.Varchar2) + { + Direction = ParameterDirection.Input, + Value = columnName + }); + var output = new OracleParameter("r_sql", OracleDbType.Clob) + { + Direction = ParameterDirection.Output + }; + command.Parameters.Add(output); + + command.ExecuteNonQuery(); + string bosSql; + if (output.Value == null || output.Value == DBNull.Value) + bosSql = ""; + else if (output.Value is OracleClob clob) + bosSql = clob.IsNull ? "" : clob.Value; + else + bosSql = output.Value.ToString() ?? ""; + + // 旧版过程输出中的说明只针对其自身生成的 BOS 块;最终文件已在开头加入物理字段检查。 + bosSql = bosSql.Replace( + "-- 注意:本文件不包含 Oracle 物理字段 DDL。", + "-- BOS 字段配置块;物理字段检查位于本文件开头。", + StringComparison.Ordinal); + return physicalColumnSql + bosSql; + } + finally + { + if (closeAfterRead && connection.State != ConnectionState.Closed) + connection.Close(); + } + } + + /// 生成目标库物理字段存在性检查;源对象不是物理表或字段时只输出说明 + private static string BuildPhysicalColumnEnsureSql(string tableName, string columnName) + { + var db = SqlSugarHelper.DbOrig; + int sourceTableCount = db.Ado.GetInt( + "SELECT COUNT(*) FROM USER_TABLES WHERE TABLE_NAME = :table_name", + new SugarParameter("table_name", tableName)); + if (sourceTableCount == 0) + return $"-- 源库对象 {tableName} 不是物理表,不生成物理字段 DDL。\n"; + + var columnResult = db.Ado.GetDataTable(@" + SELECT DATA_TYPE, DATA_LENGTH, DATA_PRECISION, DATA_SCALE, + CHAR_LENGTH, CHAR_USED + FROM USER_TAB_COLUMNS + WHERE TABLE_NAME = :table_name + AND COLUMN_NAME = :column_name", + new SugarParameter("table_name", tableName), + new SugarParameter("column_name", columnName)); + if (columnResult.Rows.Count == 0) + return $"-- 源库物理表 {tableName} 不存在字段 {columnName},仅导出 BOS 字段配置。\n"; + + string columnType = BuildOracleColumnType(columnResult.Rows[0]); + string quotedTable = QuoteOracleIdentifier(tableName); + string quotedColumn = QuoteOracleIdentifier(columnName); + string alterSql = $"ALTER TABLE {quotedTable} ADD ({quotedColumn} {columnType})"; + + var sql = new StringBuilder(); + sql.AppendLine($"-- 确保目标库物理字段存在:{tableName}.{columnName}"); + sql.AppendLine("DECLARE"); + sql.AppendLine(" v_table_count NUMBER;"); + sql.AppendLine(" v_column_count NUMBER;"); + sql.AppendLine("BEGIN"); + sql.AppendLine($" SELECT COUNT(*) INTO v_table_count FROM user_tables WHERE table_name = '{EscapeQuote(tableName)}';"); + sql.AppendLine(" IF v_table_count = 0 THEN"); + sql.AppendLine($" RAISE_APPLICATION_ERROR(-20201, '目标库缺少物理表:{EscapeQuote(tableName)}');"); + sql.AppendLine(" END IF;"); + sql.AppendLine($" SELECT COUNT(*) INTO v_column_count FROM user_tab_columns WHERE table_name = '{EscapeQuote(tableName)}' AND column_name = '{EscapeQuote(columnName)}';"); + sql.AppendLine(" IF v_column_count = 0 THEN"); + sql.AppendLine($" EXECUTE IMMEDIATE '{EscapeQuote(alterSql)}';"); + sql.AppendLine(" END IF;"); + sql.AppendLine("END;"); + sql.AppendLine("/"); + return sql.ToString(); + } + + private static string BuildOracleColumnType(DataRow row) + { + string dataType = row["DATA_TYPE"]?.ToString()?.ToUpperInvariant() ?? ""; + if (string.IsNullOrWhiteSpace(dataType)) + throw new InvalidDataException("无法读取源库物理字段的数据类型。"); + + long dataLength = Convert.ToInt64(row["DATA_LENGTH"]); + long? precision = row["DATA_PRECISION"] == DBNull.Value + ? null + : Convert.ToInt64(row["DATA_PRECISION"]); + long? scale = row["DATA_SCALE"] == DBNull.Value + ? null + : Convert.ToInt64(row["DATA_SCALE"]); + long charLength = row["CHAR_LENGTH"] == DBNull.Value + ? dataLength + : Convert.ToInt64(row["CHAR_LENGTH"]); + string charUsed = row["CHAR_USED"]?.ToString()?.ToUpperInvariant() ?? ""; + + return dataType switch + { + "CHAR" or "VARCHAR2" => + $"{dataType}({(charUsed == "C" ? charLength : dataLength)} {(charUsed == "C" ? "CHAR" : "BYTE")})", + "NCHAR" or "NVARCHAR2" => $"{dataType}({charLength})", + "NUMBER" when precision.HasValue && scale.HasValue => $"NUMBER({precision.Value},{scale.Value})", + "NUMBER" when precision.HasValue => $"NUMBER({precision.Value})", + "FLOAT" when precision.HasValue => $"FLOAT({precision.Value})", + "RAW" => $"RAW({dataLength})", + "UROWID" => $"UROWID({dataLength})", + _ => dataType + }; + } + + private static string QuoteOracleIdentifier(string value) + => $"\"{value.Replace("\"", "\"\"")}\""; + /// 获取所有存储过程和函数列表 - 查 USER_OBJECTS,返回纯名称(不带前缀),不依赖存储过程 public static string GetAllPrc() { @@ -396,18 +622,6 @@ namespace henlo_migration.Services } } - /// 执行预处理存储过程 - public static void PreProcess() - { - var dbOrig = SqlSugarHelper.DbOrig; - foreach (var fname in Directory.GetFiles(CommonHelper.PrePath)) - { - string name = Path.GetFileNameWithoutExtension(fname); - string content = File.ReadAllText(fname); - dbOrig.Ado.ExecuteCommand(content); - } - } - private static string[] SplitStatements(string sql) { // Oracle PL/SQL 块用 \n/ 分隔 @@ -423,4 +637,4 @@ namespace henlo_migration.Services return result.ToArray(); } } -} \ No newline at end of file +} diff --git a/henlo_migration_wpf10/ViewModels/MainViewModel.cs b/henlo_migration_wpf10/ViewModels/MainViewModel.cs index 5b82509..6cc19d1 100644 --- a/henlo_migration_wpf10/ViewModels/MainViewModel.cs +++ b/henlo_migration_wpf10/ViewModels/MainViewModel.cs @@ -16,9 +16,10 @@ namespace henlo_migration.ViewModels { public class MainViewModel : INotifyPropertyChanged { - // 四个 Tab 的数据 + // 五个 Tab 的数据 public TabServices TableTab { get; } public TabServices BosTab { get; } + public TabServices BosFieldTab { get; } public TabServices PrcTab { get; } public TabServices DataTab { get; } @@ -33,6 +34,7 @@ namespace henlo_migration.ViewModels OnPropertyChanged(); OnPropertyChanged(nameof(TablesData)); OnPropertyChanged(nameof(BosTablesData)); + OnPropertyChanged(nameof(BosFieldsData)); OnPropertyChanged(nameof(StoredProcsData)); OnPropertyChanged(nameof(CurrentDataData)); } @@ -50,13 +52,15 @@ namespace henlo_migration.ViewModels { 0 => TableTab, 1 => BosTab, - 2 => PrcTab, - 3 => DataTab, + 2 => BosFieldTab, + 3 => PrcTab, + 4 => DataTab, _ => TableTab }; OnPropertyChanged(); OnPropertyChanged(nameof(TablesData)); OnPropertyChanged(nameof(BosTablesData)); + OnPropertyChanged(nameof(BosFieldsData)); OnPropertyChanged(nameof(StoredProcsData)); OnPropertyChanged(nameof(CurrentDataData)); OnPropertyChanged(nameof(CurrentTabName)); @@ -64,9 +68,10 @@ namespace henlo_migration.ViewModels } } - // 四个Tab数据源别名(供DataGrid绑定) + // 五个Tab数据源别名(供DataGrid绑定) public ObservableCollection TablesData => TableTab.List; public ObservableCollection BosTablesData => BosTab.List; + public ObservableCollection BosFieldsData => BosFieldTab.List; public ObservableCollection StoredProcsData => PrcTab.List; public ObservableCollection CurrentDataData => DataTab.List; @@ -74,8 +79,9 @@ namespace henlo_migration.ViewModels { 0 => "表配置", 1 => "BOS配置", - 2 => "存储过程", - 3 => "表数据", + 2 => "BOS字段配置", + 3 => "存储过程", + 4 => "表数据", _ => "" }; @@ -240,7 +246,6 @@ namespace henlo_migration.ViewModels public ICommand DisconnectDestCommand { get; } public ICommand AddRowCommand { get; } public ICommand OpenDataFolderCommand { get; } - public ICommand PreProcessCommand { get; } public ICommand BatchReadCommand { get; } public ICommand BatchWriteCommand { get; } @@ -264,13 +269,16 @@ namespace henlo_migration.ViewModels DestUser = SysConfig.GetDestUser(); DestPwd = SysConfig.GetDestPwd(); - // 初始化四个 TabServices(索引文件在 from/,内容文件在 data/) + // 初始化五个 TabServices(索引文件在 from/,内容文件在 data/) TableTab = new TabServices(new TabConfig(TabsEnum.表, CommonHelper.DataFromPath, CommonHelper.DataPath, "table.txt", CodeExt.creat, DbServices.GetTableSql, (p, c) => SqlCode = c)); BosTab = new TabServices(new TabConfig(TabsEnum.BOS表配置, CommonHelper.DataFromPath, CommonHelper.DataPath, "bos.txt", CodeExt.bos, DbServices.GetTableBos, (p, c) => SqlCode = c)); + BosFieldTab = new TabServices(new TabConfig(TabsEnum.BOS字段配置, CommonHelper.DataFromPath, CommonHelper.DataPath, "bos_field.txt", CodeExt.bos_field, + DbServices.GetTableBosField, (p, c) => SqlCode = c)); + PrcTab = new TabServices(new TabConfig(TabsEnum.存储过程, CommonHelper.DataFromPath, CommonHelper.DataPath, "prc.txt", CodeExt.prc, name => DbServices.GetPrcSql(name), (p, c) => SqlCode = c)); @@ -278,7 +286,7 @@ namespace henlo_migration.ViewModels DbServices.GetTableData, (p, c) => SqlCode = c)); // Tab行点击 -> 显示SQL - foreach (var tab in new[] { TableTab, BosTab, PrcTab, DataTab }) + foreach (var tab in new[] { TableTab, BosTab, BosFieldTab, PrcTab, DataTab }) { tab.RowClickEvent += (path, code) => { @@ -297,7 +305,6 @@ namespace henlo_migration.ViewModels DisconnectDestCommand = new RelayCommand(DisconnectDest, () => DestConnected); AddRowCommand = new RelayCommand(AddRow, () => CurrentTab != null && !string.IsNullOrWhiteSpace(InputText)); OpenDataFolderCommand = new RelayCommand(OpenDataFolder); - PreProcessCommand = new RelayCommand(PreProcess); BatchReadCommand = new RelayCommand(async () => await BatchReadAsync()); BatchWriteCommand = new RelayCommand(async () => await BatchWriteAsync()); @@ -372,7 +379,18 @@ namespace henlo_migration.ViewModels { if (!string.IsNullOrWhiteSpace(InputText)) { - CurrentTab?.AddRow(InputText.Trim().ToLower()); + string itemName = InputText.Trim(); + if (CurrentTab == BosFieldTab) + { + if (!DbServices.TryParseBosFieldName(itemName, out string tableName, out string columnName)) + { + HandyControl.Controls.Growl.Warning("BOS 字段配置必须使用 TABLE.COLUMN 格式"); + return; + } + itemName = $"{tableName}.{columnName}"; + } + + CurrentTab?.AddRow(itemName.ToLowerInvariant()); InputText = ""; } } @@ -396,12 +414,6 @@ namespace henlo_migration.ViewModels Process.Start("explorer.exe", CommonHelper.DataPath); } - private void PreProcess() - { - try { DbServices.PreProcess(); } - catch { } - } - private async Task BatchReadAsync() { if (CurrentTab == null) return; diff --git a/henlo_migration_wpf10/Views/HomeWindow.xaml b/henlo_migration_wpf10/Views/HomeWindow.xaml index 4261392..e721d66 100644 --- a/henlo_migration_wpf10/Views/HomeWindow.xaml +++ b/henlo_migration_wpf10/Views/HomeWindow.xaml @@ -55,7 +55,7 @@ @@ -86,9 +86,6 @@ -