Compare commits

...

9 Commits

Author SHA1 Message Date
5f6c10b9cb feat(add-ledger): 新增线路类型和设备单位管理功能
- 添加线路类型常量定义(主网0,配网1)及验证逻辑
- 新增设备单位查询和保存接口及实现
- 新增监测点限值查询接口
- 扩展AddLedgerDetailVO和AddLedgerLinePO实体类以支持线路类型字段
- 在测点保存时自动计算并保存限值信息
- 添加设备单位默认配置初始化逻辑
- 新增COverlimitUtil工具类用于限值计算
- 完善相关单元测试用例
2026-05-29 15:15:22 +08:00
66d351afe4 feat(steady): 新增数据校验功能并优化稳态趋势查询
- 在 AddLedgerLineMapper.xml 中添加 lineInterval 字段映射
- 在 AddLedgerLinePathVO 中添加 lineInterval 属性用于存储统计间隔
- 为稳态趋势查询服务添加详细的执行日志记录和性能监控
- 重构 InfluxDB 查询组件,添加诊断信息构建方法和异常处理
- 限制谐波次数最大展示数量从 6 个调整为 3 个
- 新增数据校验相关组件、控制器和服务实现
- 实现数据连续性检查和缺失数据统计功能
- 添加数据校验查询参数和返回结果的数据结构定义
- 完善相关单元测试确保功能正确性
2026-05-27 08:04:49 +08:00
e5369fef5a docs: 规划deploy Linux远程运维实现 2026-05-21 21:13:17 +08:00
ba8bc43377 docs: 设计deploy Linux远程运维方案 2026-05-21 16:44:42 +08:00
9a9614a9e5 feat(system-ops): 新增系统运维模块及稳态数据视图优化
- 添加 system-ops 模块及其子模块 dbms 和 deploy
- 实现数据库监控和系统部署的基础接口和服务
- 更新项目依赖配置和文档说明
- 优化稳态数据视图中线电压相位显示逻辑
- 完善线电压指标的相位解析和测试验证
2026-05-21 14:08:15 +08:00
89efc55119 fix(tools): 修复台账节点查询和数据补录功能
- 修复 QUALITYFLAG 字段默认值从 1 改为 0
- 添加 selectNodeById 查询方法用于精确节点查找
- 重构 requireLedger 方法增加节点名称参数和详细错误提示
- 新增 levelName 辅助方法统一层级名称显示
- 更新 InfluxDB 配置地址从 192.168.1.68 改为 127.0.0.1
- 扩展 add-data 模块支持 InfluxDB 数据补录功能
- 新增 AddDataInfluxTaskController 提供 InfluxDB 补数任务接口
- 实现 AddDataInfluxFieldMapper 完成字段到 InfluxDB 测量值映射
- 添加 AddDataInfluxTaskExecutor 处理 InfluxDB 异步补数任务
- 更新 README 文档说明 InfluxDB 写入功能和配置要求
2026-05-20 08:33:37 +08:00
bff89bede0 微调 2026-05-18 16:42:43 +08:00
1ee94208ae feat(event): 添加暂态事件波形查看与导出功能
- 新增 getTransientEventWave 接口用于查看暂态事件波形
- 新增 exportTransientEventWaves 接口用于批量导出暂态事件波形
- 添加 EventWaveExportParam 参数类支持波形导出
- 在 EventListMapper 中增加 selectTransientDetailsByIds 查询方法
- 更新事件列表查询参数支持毫秒级时间格式
- 移除事件描述模糊查询条件优化查询性能
- 添加波形导出相关的常量和工具类集成
2026-05-18 16:31:01 +08:00
38f910fccd feat(event): 添加暂态事件波形查看与导出功能
- 新增 getTransientEventWave 接口用于查看暂态事件波形
- 新增 exportTransientEventWaves 接口用于批量导出暂态事件波形
- 添加 EventWaveExportParam 参数类支持波形导出
- 在 EventListMapper 中增加 selectTransientDetailsByIds 查询方法
- 更新事件列表查询参数支持毫秒级时间格式
- 移除事件描述模糊查询条件优化查询性能
- 添加波形导出相关的常量和工具类集成
2026-05-18 08:45:05 +08:00
181 changed files with 11163 additions and 1825 deletions

View File

@@ -18,6 +18,7 @@
- 只清理自己造成的问题:可以删除因本次修改而产生的未使用 `import`、变量或方法;不要删除仓库中原本就存在的死代码,除非用户明确要求。 - 只清理自己造成的问题:可以删除因本次修改而产生的未使用 `import`、变量或方法;不要删除仓库中原本就存在的死代码,除非用户明确要求。
- 先定义验证方式:执行方案里要写清楚“改哪里、怎么判断改对了”;默认通过代码路径、配置一致性和受影响范围检查进行验证。 - 先定义验证方式:执行方案里要写清楚“改哪里、怎么判断改对了”;默认通过代码路径、配置一致性和受影响范围检查进行验证。
- 除非用户明确要求,否则不执行任何 `mvn` 编译、打包、测试或其他构建命令。 - 除非用户明确要求,否则不执行任何 `mvn` 编译、打包、测试或其他构建命令。
- 所有导出或生成文件名统一追加日期,格式为“原文件名 + `_` + `yyyyMMdd` + 原扩展名”,例如 `暂态事件列表_20260516.xlsx`;新增下载导出、临时生成或落盘保存文件时,优先复用目标模块已有文件名工具,不要在业务代码中散落手写日期拼接。
## 项目结构与模块划分 ## 项目结构与模块划分
`CN_Tool` 是一个 Maven 多模块后端项目,根目录的 [`pom.xml`](C:/code/gitea/cn_tool/CN_Tool/pom.xml) 聚合了 `entrance``system``user``detection``tools` `CN_Tool` 是一个 Maven 多模块后端项目,根目录的 [`pom.xml`](C:/code/gitea/cn_tool/CN_Tool/pom.xml) 聚合了 `entrance``system``user``detection``tools`
@@ -40,6 +41,8 @@ Java 源码位于 `src/main/java`,配置文件位于 `src/main/resources`My
- 不要假设运行时存在自动数据库迁移;如果代码依赖新表、新字段或新索引,必须同步补齐对应 SQL 与文档说明。 - 不要假设运行时存在自动数据库迁移;如果代码依赖新表、新字段或新索引,必须同步补齐对应 SQL 与文档说明。
- SQL 脚本应放在目标模块的 `src/main/resources/sql/...` 下,并保持可审阅、可单独执行、语义清晰。 - SQL 脚本应放在目标模块的 `src/main/resources/sql/...` 下,并保持可审阅、可单独执行、语义清晰。
- 变更缓存、日志、审计相关逻辑时,优先沿用现有机制,不要绕开现有登录上下文、缓存约定和审计字段填充方式。 - 变更缓存、日志、审计相关逻辑时,优先沿用现有机制,不要绕开现有登录上下文、缓存约定和审计字段填充方式。
- 涉及 `sys_dict_type``sys_dict_data` 的字典类型编码、字典数据编码或固定字典数据 ID 时,必须统一维护在 `system/src/main/java/com/njcn/gather/system/pojo/constant/DictConst.java`后续新增使用点也要先补常量再引用不要在业务代码、SQL 或文档中散落硬编码。
- 新增或保留字典初始化 SQL 前,必须先确认对应字典类型在后端代码、配置或明确页面契约中确实被使用;未使用的字典类型和字典数据不要写入 `sys_dict_type``sys_dict_data` 初始化脚本。
## 注释与编码 ## 注释与编码
- 新增或修改代码时,关键字段、关键分支、关键约束和非直观实现应补充简洁中文注释。 - 新增或修改代码时,关键字段、关键分支、关键约束和非直观实现应补充简洁中文注释。

View File

@@ -15,6 +15,7 @@ CN_Tool 是一个基于 Spring Boot 的多模块后端聚合工程,当前仓
- `entrance` - `entrance`
- `system` - `system`
- `systemmonitor` - `systemmonitor`
- `system-ops`
- `user` - `user`
- `detection` - `detection`
- `tools` - `tools`
@@ -23,6 +24,11 @@ CN_Tool 是一个基于 Spring Boot 的多模块后端聚合工程,当前仓
- `disk-monitor` - `disk-monitor`
其中 `system-ops` 当前包含:
- `dbms`
- `deploy`
其中 `tools` 当前包含: 其中 `tools` 当前包含:
- `activate-tool` - `activate-tool`
@@ -37,7 +43,7 @@ CN_Tool 是一个基于 Spring Boot 的多模块后端聚合工程,当前仓
- `entrance/src/main/java/com/njcn/gather/EntranceApplication.java` - `entrance/src/main/java/com/njcn/gather/EntranceApplication.java`
`entrance` 模块聚合了 `system``disk-monitor``user``detection``activate-tool``add-data``add-ledger``wave-tool``mms-mapping`,是当前运行时主入口。 `entrance` 模块聚合了 `system``disk-monitor``dbms``deploy``user``detection``activate-tool``add-data``add-ledger``wave-tool``mms-mapping`,是当前运行时主入口。
## 技术基线 ## 技术基线
@@ -80,6 +86,10 @@ P0 已补齐基线文档,建议按以下顺序阅读:
- 负责字典、日志、系统配置、注册资源相关能力 - 负责字典、日志、系统配置、注册资源相关能力
- `systemmonitor/disk-monitor` - `systemmonitor/disk-monitor`
- 负责磁盘监控相关能力的独立扩展实现 - 负责磁盘监控相关能力的独立扩展实现
- `system-ops/dbms`
- 负责系统运维下数据库监控基础入口
- `system-ops/deploy`
- 负责系统运维下系统部署基础入口
- `detection` - `detection`
- 当前以通信基础设施为主,包含 WebSocket / Netty 相关组件 - 当前以通信基础设施为主,包含 WebSocket / Netty 相关组件
- `tools/activate-tool` - `tools/activate-tool`

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,514 @@
# Linux 服务器部署运维设计
## 1. 背景
`system-ops/deploy` 当前只提供系统部署菜单的基础入口:
- `GET /deploy/overview`
- `DeployController`
- `DeployService`
- `DeployOverviewVO`
本次需求是在 `deploy` 模块中补充 Linux 服务器远程运维能力。用户可以维护 Linux 服务器连接配置,基于 SSH/SFTP 连接服务器,完成远程文件上传、下载和基础命令终端操作。命令终端目标体验接近 Xshell 的基础能力。
当前仓库没有前端代码,本设计只定义页面布局、接口契约、后端模块拆分、数据存储和验证方式,不实现真实前端页面。
## 2. 范围确认
本期只支持 Linux 服务器。
本期包含:
- Linux SSH 连接配置的新增、编辑、删除、查询。
- SSH 连接测试。
- SFTP 文件列表、上传、下载、删除、新建目录。
- SSH Shell 基础命令交互。
- 前端单页运维工作台布局设计。
- 连接配置使用文件方式存储,不新建数据库表。
本期不包含:
- Windows 服务器。
- FTP 协议。
- 数据库存储连接配置。
- 部署任务编排。
- 命令审批、命令黑名单、命令历史。
- 批量文件压缩下载。
- 数据库专用客户端封装。
- Maven 编译、打包、测试。
说明:需求中提到的 “FPT” 本期按 Linux 服务器常用能力理解为 SFTP。SFTP 复用 SSH 账号、密码和端口,比单独 FTP 更适合本期场景。
## 3. 总体方案
推荐采用 “SSH/SFTP + WebSocket 终端” 方案:
- 服务器连接配置保存到本地 JSON 文件。
- 后端通过 SSH 建立 Linux 连接。
- 文件操作通过 SFTP 通道完成。
- 终端操作通过 SSH Shell 通道完成。
- 前端通过 WebSocket 与后端交换终端输入输出。
该方案可以复用同一份服务器连接配置,不需要引入 Windows 远程协议,也能满足类 Xshell 的基础交互需求。
## 4. 前端页面布局
页面路径建议沿用当前菜单路径:
```text
/systemOps/deploy
```
页面采用三块工作区:
- 左侧:服务器列表。
- 中间:远程文件管理。
- 右侧:连接详情和快捷操作。
- 底部SSH 终端区。
推荐布局:
```text
┌──────────────────────────────────────────────────────────────┐
│ 顶部工具栏:新增连接 测试连接 刷新 当前连接状态 │
├──────────────┬──────────────────────────────┬────────────────┤
│ 服务器列表 │ 远程文件管理 │ 连接详情/操作 │
│ │ │ │
│ Linux-测试 │ 路径栏:/opt/app │ 主机/IP │
│ Linux-生产 │ 上传 下载 新建目录 删除 刷新 │ 用户名 │
│ │ │ 端口 │
│ │ 文件表格 │ 测试连接 │
│ │ │ 打开终端 │
├──────────────┴──────────────────────────────┴────────────────┤
│ 终端 TabsLinux-测试 │
│ $ pwd │
│ /opt/app │
└──────────────────────────────────────────────────────────────┘
```
### 4.1 服务器列表
左侧服务器列表用于选择当前操作目标。
展示字段:
| 字段 | 说明 |
|---|---|
| 名称 | 服务器显示名称 |
| 主机地址 | IP 或域名 |
| SSH 端口 | 默认 22 |
| 连接状态 | 未测试、连接成功、连接失败 |
交互:
- 支持按名称、主机地址搜索。
- 点击服务器后加载连接详情,并将文件管理区切换到该服务器。
- 列表项提供编辑、删除、测试连接入口。
- 删除连接前必须二次确认。
### 4.2 连接配置弹窗
新增和编辑使用同一个弹窗。
字段:
| 字段 | 是否必填 | 说明 |
|---|---|---|
| 名称 | 是 | 页面展示名称 |
| 主机地址 | 是 | Linux 服务器 IP 或域名 |
| SSH 端口 | 是 | 默认 22范围 1-65535 |
| 用户名 | 是 | SSH 登录用户 |
| 密码 | 新增必填 | 编辑时留空表示不修改 |
| 备注 | 否 | 环境说明 |
按钮:
- 测试连接。
- 保存。
- 取消。
密码规则:
- 新增连接时密码必填。
- 编辑连接时密码不回显。
- 编辑时密码为空表示沿用原密码。
- 查询列表和详情接口均不返回密码。
### 4.3 远程文件管理
中间文件管理区基于当前选中的服务器工作。
顶部路径栏:
- 展示当前远程目录,例如 `/opt/app`
- 支持返回上级目录。
- 支持点击面包屑跳转到上级路径。
工具栏:
- 上传。
- 下载。
- 新建目录。
- 删除。
- 刷新。
文件表格字段:
| 字段 | 说明 |
|---|---|
| 名称 | 文件或目录名称 |
| 类型 | 文件、目录、软链接 |
| 大小 | 文件大小,目录可为空 |
| 权限 | Linux 权限字符串 |
| 修改时间 | 远程文件修改时间 |
交互规则:
- 双击目录进入下级目录。
- 下载只支持普通文件。
- 删除文件或目录前必须二次确认。
- 本期支持单文件上传和单文件下载。
- 上传目标目录为当前路径。
- 下载目录、批量压缩下载不在本期范围。
### 4.4 SSH 终端区
底部终端区用于执行 Linux 命令。
交互规则:
- 点击“打开终端”后创建 SSH Shell 会话。
- 前端输入通过 WebSocket 实时发送给后端。
- 后端将 Shell 输出通过 WebSocket 实时推送给前端。
- 本期建议限制为每台服务器最多一个终端会话。
- 关闭终端 Tab 时通知后端释放 SSH 会话。
- 终端断开后显示状态,不自动重连。
用户可以在终端中自行执行数据库命令,例如:
```bash
mysql -uroot -p
psql -h 127.0.0.1 -U postgres
redis-cli
```
后端不解析数据库命令,也不保存命令历史。
## 5. 后端结构设计
`system-ops/deploy` 模块内按职责新增类,保留现有 `DeployController``/deploy/overview`
建议结构:
```text
system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/
├── config/
├── controller/
├── pojo/param/
├── pojo/vo/
├── pojo/dto/
├── repository/
├── service/
├── service/impl/
└── websocket/
```
职责拆分:
| 类 | 职责 |
|---|---|
| `DeployServerController` | 连接配置查询、新增、编辑、删除、测试连接 |
| `DeployFileController` | SFTP 文件列表、上传、下载、删除、新建目录 |
| `DeployTerminalWebSocketHandler` | SSH 终端 WebSocket 输入输出转发 |
| `DeployServerConfigService` | 连接配置业务校验和编排 |
| `DeployServerConfigRepository` | JSON 文件读写 |
| `DeploySftpService` | SFTP 文件操作 |
| `DeploySshTerminalService` | SSH Shell 会话创建、输入、输出、关闭 |
| `DeployCryptoService` | 密码加密和解密 |
| `DeployProperties` | deploy 配置项绑定 |
## 6. 连接配置存储
连接配置不入库,使用 JSON 文件落盘。存储目录通过配置指定。
建议配置:
```yaml
deploy:
storage-dir: ${log.homeDir}/deploy
terminal-idle-timeout-minutes: 30
```
`deploy.crypto-key` 不建议在默认 `application.yml` 中配置明文值。后续实现时可通过环境覆盖或外部配置提供,业务代码只读取配置,不写死密钥。
落盘文件:
```text
D:\logs\deploy\deploy-server-connections.json
```
JSON 结构:
```json
{
"servers": [
{
"id": "uuid",
"name": "测试服务器",
"host": "192.168.1.10",
"sshPort": 22,
"username": "root",
"password": "加密密文",
"description": "测试环境",
"createdTime": "2026-05-21 14:00:00",
"updatedTime": "2026-05-21 14:00:00"
}
]
}
```
写文件规则:
- 启动时如果文件不存在,自动创建空配置文件。
- 读写方法集中在 `DeployServerConfigRepository`
- 写入时先写临时文件,再替换正式文件,避免进程中断导致 JSON 损坏。
- 保存和删除操作需要加进程内锁,避免并发写入互相覆盖。
密码规则:
- 密码必须加密后落盘。
- 接口返回不包含密码。
- 日志不打印密码。
- 优先复用项目已有加密能力;如没有合适工具,则在 `deploy` 内封装 AES 加解密组件。
- 加密密钥通过配置提供,不在业务代码中硬编码。
## 7. 接口设计
接口风格沿用当前仓库常见写法:查询和变更优先使用 `POST`,返回 `HttpResult<T>`
### 7.1 连接配置接口
| 方法 | 路径 | 说明 |
|---|---|---|
| `POST` | `/deploy/server/list` | 查询服务器连接配置列表 |
| `POST` | `/deploy/server/add` | 新增服务器连接配置 |
| `POST` | `/deploy/server/update` | 修改服务器连接配置 |
| `POST` | `/deploy/server/delete` | 删除服务器连接配置 |
| `POST` | `/deploy/server/test` | 测试 SSH 连接 |
列表返回字段:
| 字段 | 说明 |
|---|---|
| `id` | 连接 ID |
| `name` | 服务器名称 |
| `host` | 主机地址 |
| `sshPort` | SSH 端口 |
| `username` | 用户名 |
| `description` | 备注 |
| `createdTime` | 创建时间 |
| `updatedTime` | 更新时间 |
新增参数:
| 字段 | 是否必填 |
|---|---|
| `name` | 是 |
| `host` | 是 |
| `sshPort` | 是 |
| `username` | 是 |
| `password` | 是 |
| `description` | 否 |
编辑参数:
| 字段 | 是否必填 | 说明 |
|---|---|---|
| `id` | 是 | 连接 ID |
| `name` | 是 | 服务器名称 |
| `host` | 是 | 主机地址 |
| `sshPort` | 是 | SSH 端口 |
| `username` | 是 | 用户名 |
| `password` | 否 | 为空表示不修改 |
| `description` | 否 | 备注 |
### 7.2 文件接口
| 方法 | 路径 | 说明 |
|---|---|---|
| `POST` | `/deploy/file/list` | 查询远程目录文件列表 |
| `POST` | `/deploy/file/mkdir` | 新建远程目录 |
| `POST` | `/deploy/file/delete` | 删除远程文件或目录 |
| `POST` | `/deploy/file/upload` | 上传本地文件到远程目录 |
| `POST` | `/deploy/file/download` | 下载远程普通文件 |
文件列表参数:
| 字段 | 是否必填 | 说明 |
|---|---|---|
| `serverId` | 是 | 服务器连接 ID |
| `path` | 是 | 远程目录路径 |
文件列表返回字段:
| 字段 | 说明 |
|---|---|
| `name` | 文件名 |
| `path` | 完整路径 |
| `type` | `FILE``DIRECTORY``LINK` |
| `size` | 文件大小 |
| `permissions` | 权限字符串 |
| `modifiedTime` | 修改时间 |
下载接口直接写入 `HttpServletResponse`。下载文件名沿用远程文件名,不追加日期;仓库“导出或生成文件追加日期”的规则适用于后端生成或导出文件,本功能是下载远程已有文件,不改变原文件名。
### 7.3 终端 WebSocket
终端连接:
```text
WebSocket /deploy/terminal?serverId={serverId}
```
前端发送输入:
```json
{
"type": "input",
"data": "ls -la\n"
}
```
前端发送窗口大小:
```json
{
"type": "resize",
"cols": 120,
"rows": 30
}
```
后端输出:
```json
{
"type": "output",
"data": "total 20\r\n..."
}
```
后端状态:
```json
{
"type": "status",
"status": "CONNECTED"
}
```
异常消息:
```json
{
"type": "error",
"message": "SSH连接失败"
}
```
## 8. 参数校验
后端至少补充以下校验:
- 服务器名称不能为空。
- 主机地址不能为空。
- SSH 端口范围为 `1-65535`
- 用户名不能为空。
- 新增连接时密码不能为空。
- 编辑连接时 `id` 必须存在。
- 删除连接时 `id` 必须存在。
- 同一主机、端口、用户名组合不建议重复保存。
- 文件路径不能为空。
- 文件上传目标必须是远程目录。
- 下载目标必须是远程普通文件。
- 删除路径不能为空,不能删除空路径或根目录 `/`
- 新建目录名称不能为空,不能包含路径分隔符。
## 9. 安全与资源控制
安全规则:
- 密码不明文落盘。
- 接口返回不包含密码。
- 日志不打印密码、终端输入内容、文件内容。
- 终端不保存命令历史。
- 文件路径需要做基础规范化,避免空路径、非法路径和目录穿越。
- 下载只允许下载普通文件。
资源规则:
- SSH 连接测试设置连接超时,例如 5 秒。
- SFTP 操作每次请求创建短连接,操作完成后释放。
- 终端会话保持长连接,关闭 WebSocket 后释放 SSH Session 和 Channel。
- 终端会话设置空闲超时,默认 30 分钟。
- 本期每台服务器最多保留一个终端会话。
## 10. 依赖建议
后续实现 SSH/SFTP 时建议优先选择 Java 8 可用、项目易接入的 SSH 客户端库,例如 JSch 或 sshj。
选择标准:
- 支持 SSH 密码登录。
- 支持 SFTP 文件操作。
- 支持 Shell Channel。
- 能在 Spring Boot 2.3 和 Java 8 下稳定使用。
最终依赖需要写入 `system-ops/deploy/pom.xml`,不影响其他模块。
## 11. 错误处理
连接测试需要区分常见错误:
| 场景 | 返回说明 |
|---|---|
| 主机不可达 | 连接服务器失败 |
| 端口不通 | SSH端口连接失败 |
| 账号或密码错误 | SSH认证失败 |
| SFTP 打开失败 | 文件通道打开失败 |
| 终端打开失败 | Shell通道打开失败 |
接口层仍使用项目现有 `HttpResult``CommonResponseEnum` 风格。具体错误文案由 Service 返回给 Controller不新增全局异常体系。
## 12. 验证方式
默认不执行 Maven 编译、打包、测试命令。后续实现完成后按以下方式验证:
- 检查 `deploy` 新增代码只位于 `system-ops/deploy`
- 新增连接后,接口返回和 JSON 文件内容一致。
- 编辑连接时密码留空不会覆盖原密码。
- 删除连接后JSON 文件同步移除对应记录。
- 查询接口不返回密码。
- JSON 文件中密码不是明文。
- 测试连接能识别成功、主机不可达、端口不通、账号密码错误。
- 文件列表能展示远程目录内容。
- 上传文件后远程目录可见。
- 下载普通文件内容与远程文件一致。
- 删除文件或目录后远程路径不存在。
- 新建目录后远程路径存在。
- 终端能打开 Linux Shell执行 `pwd``ls -la``mysql --version` 等基础命令。
- 关闭终端后,后端 SSH 会话被释放。
## 13. 后续扩展
后续如需求增加,可以在当前方案基础上扩展:
- SSH 私钥登录。
- 多终端 Tab。
- 命令审计和历史记录。
- 命令黑名单或审批。
- 部署脚本编排。
- 文件批量上传和批量下载。
- Windows WinRM 或 PowerShell Remoting。
这些能力不进入本期实现,避免当前 `deploy` 模块从基础入口一次扩张为完整运维平台。

View File

@@ -21,6 +21,16 @@
<artifactId>disk-monitor</artifactId> <artifactId>disk-monitor</artifactId>
<version>1.0.0</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency>
<groupId>com.njcn.gather</groupId>
<artifactId>dbms</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>com.njcn.gather</groupId>
<artifactId>deploy</artifactId>
<version>1.0.0</version>
</dependency>
<dependency> <dependency>
<groupId>com.njcn.gather</groupId> <groupId>com.njcn.gather</groupId>
<artifactId>detection</artifactId> <artifactId>detection</artifactId>

View File

@@ -48,6 +48,16 @@ socket:
webSocket: webSocket:
port: 7777 port: 7777
steady:
influxdb:
url: http://127.0.0.1:18086
database: pqsbase
username: admin
password: 123456
ssl: false
connect-timeout-ms: 5000
read-timeout-ms: 30000
log: log:
homeDir: D:\logs homeDir: D:\logs
commonLevel: info commonLevel: info

View File

@@ -35,5 +35,26 @@
<artifactId>add-ledger</artifactId> <artifactId>add-ledger</artifactId>
<version>1.0.0</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency>
<groupId>com.njcn.gather</groupId>
<artifactId>system</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>com.njcn.gather</groupId>
<artifactId>wave-tool</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>4.1.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>

View File

@@ -9,11 +9,11 @@ import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
/** /**
* 暂态事件时间字段按秒输出避免接口响应携带毫秒 * 暂态事件时间字段按秒输出保持与数据库 datetime(3) 精度一致
*/ */
public class EventSecondTimeSerializer extends JsonSerializer<LocalDateTime> { public class EventMillisecondTimeSerializer extends JsonSerializer<LocalDateTime> {
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
@Override @Override
public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException { public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException {

View File

@@ -8,8 +8,10 @@ import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.response.HttpResult; import com.njcn.common.pojo.response.HttpResult;
import com.njcn.common.utils.LogUtil; import com.njcn.common.utils.LogUtil;
import com.njcn.gather.event.eventlist.pojo.param.EventListQueryParam; import com.njcn.gather.event.eventlist.pojo.param.EventListQueryParam;
import com.njcn.gather.event.eventlist.pojo.param.EventWaveExportParam;
import com.njcn.gather.event.eventlist.pojo.vo.EventListVO; import com.njcn.gather.event.eventlist.pojo.vo.EventListVO;
import com.njcn.gather.event.eventlist.service.EventListService; import com.njcn.gather.event.eventlist.service.EventListService;
import com.njcn.gather.tool.wave.pojo.vo.WaveComtradeResultVO;
import com.njcn.web.controller.BaseController; import com.njcn.web.controller.BaseController;
import com.njcn.web.utils.HttpResultUtil; import com.njcn.web.utils.HttpResultUtil;
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
@@ -64,4 +66,21 @@ public class EventListController extends BaseController {
public void exportTransientEvents(@RequestBody EventListQueryParam param) { public void exportTransientEvents(@RequestBody EventListQueryParam param) {
eventListService.exportTransientEvents(param); eventListService.exportTransientEvents(param);
} }
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("查看暂态事件波形")
@GetMapping("/transient/{eventId}/wave")
public HttpResult<WaveComtradeResultVO> getTransientEventWave(@PathVariable("eventId") String eventId) {
String methodDescribe = getMethodDescribe("getTransientEventWave");
LogUtil.njcnDebug(log, "{}开始查看暂态事件波形eventId={}", methodDescribe, eventId);
WaveComtradeResultVO result = eventListService.getTransientEventWave(eventId);
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS, result, methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.DOWNLOAD)
@ApiOperation("导出选中暂态事件波形")
@PostMapping("/transient/wave/export")
public void exportTransientEventWaves(@RequestBody EventWaveExportParam param) {
eventListService.exportTransientEventWaves(param);
}
} }

View File

@@ -18,4 +18,6 @@ public interface EventListMapper extends BaseMapper<MpEventDetailPO> {
List<MpEventDetailPO> selectTransientExportList(@Param("param") EventListQueryParam param, @Param("limit") Integer limit); List<MpEventDetailPO> selectTransientExportList(@Param("param") EventListQueryParam param, @Param("limit") Integer limit);
MpEventDetailPO selectTransientDetail(@Param("eventId") String eventId); MpEventDetailPO selectTransientDetail(@Param("eventId") String eventId);
List<MpEventDetailPO> selectTransientDetailsByIds(@Param("eventIds") List<String> eventIds);
} }

View File

@@ -47,9 +47,6 @@
<if test="param.phase != null and param.phase != ''"> <if test="param.phase != null and param.phase != ''">
AND phase = #{param.phase} AND phase = #{param.phase}
</if> </if>
<if test="param.eventDescribe != null and param.eventDescribe != ''">
AND event_describe LIKE CONCAT('%', #{param.eventDescribe}, '%')
</if>
<if test="param.durationMin != null"> <if test="param.durationMin != null">
AND duration &gt;= #{param.durationMin} AND duration &gt;= #{param.durationMin}
</if> </if>
@@ -101,4 +98,14 @@
WHERE event_id = #{eventId} WHERE event_id = #{eventId}
LIMIT 1 LIMIT 1
</select> </select>
<select id="selectTransientDetailsByIds" resultType="com.njcn.gather.event.eventlist.pojo.po.MpEventDetailPO">
SELECT
<include refid="EventDetailColumns"/>
FROM r_mp_event_detail
WHERE event_id IN
<foreach collection="eventIds" item="eventId" open="(" separator="," close=")">
#{eventId}
</foreach>
</select>
</mapper> </mapper>

View File

@@ -18,10 +18,10 @@ import java.util.List;
@ApiModel("暂态事件列表查询参数") @ApiModel("暂态事件列表查询参数")
public class EventListQueryParam extends BaseParam { public class EventListQueryParam extends BaseParam {
@ApiModelProperty("发生时刻开始,格式 yyyy-MM-dd HH:mm:ss") @ApiModelProperty("发生时刻开始,格式 yyyy-MM-dd HH:mm:ss.SSS兼容 yyyy-MM-dd HH:mm:ss")
private String startTimeStart; private String startTimeStart;
@ApiModelProperty("发生时刻结束,格式 yyyy-MM-dd HH:mm:ss") @ApiModelProperty("发生时刻结束,格式 yyyy-MM-dd HH:mm:ss.SSS兼容 yyyy-MM-dd HH:mm:ss")
private String startTimeEnd; private String startTimeEnd;
@ApiModelProperty("事件类型") @ApiModelProperty("事件类型")
@@ -30,9 +30,6 @@ public class EventListQueryParam extends BaseParam {
@ApiModelProperty("相别") @ApiModelProperty("相别")
private String phase; private String phase;
@ApiModelProperty("事件描述关键字")
private String eventDescribe;
@ApiModelProperty("持续时间下限,单位秒") @ApiModelProperty("持续时间下限,单位秒")
private BigDecimal durationMin; private BigDecimal durationMin;

View File

@@ -0,0 +1,19 @@
package com.njcn.gather.event.eventlist.pojo.param;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 事件波形批量导出参数。
*/
@Data
@ApiModel("事件波形批量导出参数")
public class EventWaveExportParam {
@ApiModelProperty("页面勾选的事件 ID 列表")
private List<String> eventIds = new ArrayList<String>();
}

View File

@@ -2,11 +2,12 @@ package com.njcn.gather.event.eventlist.pojo.vo;
import cn.afterturn.easypoi.excel.annotation.Excel; import cn.afterturn.easypoi.excel.annotation.Excel;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import com.njcn.gather.event.eventlist.config.EventSecondTimeSerializer; import com.njcn.gather.event.eventlist.config.EventMillisecondTimeSerializer;
import lombok.Data; import lombok.Data;
import java.io.Serializable; import java.io.Serializable;
@@ -25,10 +26,11 @@ public class EventListVO implements Serializable {
private String measurementPointId; private String measurementPointId;
private String eventType; @Excel(name = "发生时刻", width = 25, exportFormat = "yyyy-MM-dd HH:mm:ss.SSS")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss.SSS")
@Excel(name = "设备名称", width = 25) @JsonDeserialize(using = LocalDateTimeDeserializer.class)
private String equipmentName; @JsonSerialize(using = EventMillisecondTimeSerializer.class)
private LocalDateTime startTime;
@Excel(name = "工程名称", width = 25) @Excel(name = "工程名称", width = 25)
private String engineeringName; private String engineeringName;
@@ -36,29 +38,32 @@ public class EventListVO implements Serializable {
@Excel(name = "项目名称", width = 25) @Excel(name = "项目名称", width = 25)
private String projectName; private String projectName;
@Excel(name = "发生时刻", width = 25, exportFormat = "yyyy-MM-dd HH:mm:ss") @Excel(name = "设备名称", width = 25)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private String equipmentName;
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonSerialize(using = EventSecondTimeSerializer.class) private String mac;
private LocalDateTime startTime;
@Excel(name = "监测点名称", width = 25) @Excel(name = "监测点名称", width = 25)
private String lineName; private String lineName;
@Excel(name = "事件描述", width = 25) @Excel(name = "暂降/暂升幅值(%)", width = 20)
private String eventDescribe; private BigDecimal featureAmplitude;
@Excel(name = "事件发生位置", width = 18)
private String sagsource;
@Excel(name = "相别", width = 15)
private String phase;
@Excel(name = "持续时间(s)", width = 18) @Excel(name = "持续时间(s)", width = 18)
private BigDecimal duration; private BigDecimal duration;
@Excel(name = "暂降/暂升幅值(%)", width = 20) @Excel(name = "事件类型", width = 18)
private BigDecimal featureAmplitude; private String eventType;
@Excel(name = "相别", width = 15)
private String phase;
@Excel(name = "事件描述", width = 25)
@JsonProperty("event_describe")
private String eventDescribe;
@Excel(name = "事件发生位置", width = 18)
private String sagsource;
private String wavePath; private String wavePath;

View File

@@ -2,7 +2,9 @@ package com.njcn.gather.event.eventlist.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.njcn.gather.event.eventlist.pojo.param.EventListQueryParam; import com.njcn.gather.event.eventlist.pojo.param.EventListQueryParam;
import com.njcn.gather.event.eventlist.pojo.param.EventWaveExportParam;
import com.njcn.gather.event.eventlist.pojo.vo.EventListVO; import com.njcn.gather.event.eventlist.pojo.vo.EventListVO;
import com.njcn.gather.tool.wave.pojo.vo.WaveComtradeResultVO;
/** /**
* 事件列表服务。 * 事件列表服务。
@@ -14,4 +16,8 @@ public interface EventListService {
EventListVO getTransientEventDetail(String eventId); EventListVO getTransientEventDetail(String eventId);
void exportTransientEvents(EventListQueryParam param); void exportTransientEvents(EventListQueryParam param);
WaveComtradeResultVO getTransientEventWave(String eventId);
void exportTransientEventWaves(EventWaveExportParam param);
} }

View File

@@ -1,30 +1,61 @@
package com.njcn.gather.event.eventlist.service.impl; package com.njcn.gather.event.eventlist.service.impl;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.njcn.common.pojo.enums.common.DataStateEnum;
import com.njcn.common.pojo.enums.response.CommonResponseEnum; import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException; import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.gather.event.eventlist.mapper.EventListMapper; import com.njcn.gather.event.eventlist.mapper.EventListMapper;
import com.njcn.gather.event.eventlist.pojo.param.EventListQueryParam; import com.njcn.gather.event.eventlist.pojo.param.EventListQueryParam;
import com.njcn.gather.event.eventlist.pojo.param.EventWaveExportParam;
import com.njcn.gather.event.eventlist.pojo.po.MpEventDetailPO; import com.njcn.gather.event.eventlist.pojo.po.MpEventDetailPO;
import com.njcn.gather.event.eventlist.pojo.vo.EventListVO; import com.njcn.gather.event.eventlist.pojo.vo.EventListVO;
import com.njcn.gather.event.eventlist.service.EventListService; import com.njcn.gather.event.eventlist.service.EventListService;
import com.njcn.gather.system.cfg.service.ISysConfigService;
import com.njcn.gather.system.dictionary.pojo.po.DictData;
import com.njcn.gather.system.dictionary.pojo.po.DictType;
import com.njcn.gather.system.dictionary.service.IDictDataService;
import com.njcn.gather.system.dictionary.service.IDictTypeService;
import com.njcn.gather.system.pojo.constant.DictConst;
import com.njcn.gather.system.util.ExportFileNameUtil;
import com.njcn.gather.tool.addledger.pojo.param.AddLedgerLinePathQueryParam; import com.njcn.gather.tool.addledger.pojo.param.AddLedgerLinePathQueryParam;
import com.njcn.gather.tool.addledger.pojo.vo.AddLedgerLinePathVO; import com.njcn.gather.tool.addledger.pojo.vo.AddLedgerLinePathVO;
import com.njcn.gather.tool.addledger.service.AddLedgerService; import com.njcn.gather.tool.addledger.service.AddLedgerService;
import com.njcn.gather.tool.wave.pojo.param.WaveComtradeParseParam;
import com.njcn.gather.tool.wave.pojo.vo.WaveComtradeResultVO;
import com.njcn.gather.tool.wave.service.WaveService;
import com.njcn.web.factory.PageFactory; import com.njcn.web.factory.PageFactory;
import com.njcn.web.utils.ExcelUtil; import com.njcn.web.utils.ExcelUtil;
import com.njcn.web.utils.HttpServletUtil;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.net.URLEncoder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException; import java.time.format.DateTimeParseException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/** /**
* 事件列表服务实现。 * 事件列表服务实现。
@@ -37,8 +68,12 @@ public class EventListServiceImpl implements EventListService {
private static final int LEDGER_LINE_QUERY_LIMIT = 1000; private static final int LEDGER_LINE_QUERY_LIMIT = 1000;
private static final int EVENT_LINE_ID_QUERY_LIMIT = 1000; private static final int EVENT_LINE_ID_QUERY_LIMIT = 1000;
private static final int EXPORT_LIMIT = 5000; private static final int EXPORT_LIMIT = 5000;
private static final int WAVE_EXPORT_LIMIT = 500;
private static final String EMPTY_TEXT = "-"; private static final String EMPTY_TEXT = "-";
private static final DateTimeFormatter OUTPUT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private static final String WAVE_EXPORT_SUCCESS = "成功";
private static final String WAVE_EXPORT_FAIL = "失败";
private static final BigDecimal PERCENT_BASE = new BigDecimal("100");
private static final DateTimeFormatter OUTPUT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
private static final DateTimeFormatter[] INPUT_FORMATTERS = new DateTimeFormatter[]{ private static final DateTimeFormatter[] INPUT_FORMATTERS = new DateTimeFormatter[]{
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"),
@@ -48,6 +83,10 @@ public class EventListServiceImpl implements EventListService {
private final EventListMapper eventListMapper; private final EventListMapper eventListMapper;
private final AddLedgerService addLedgerService; private final AddLedgerService addLedgerService;
private final ISysConfigService sysConfigService;
private final IDictTypeService dictTypeService;
private final IDictDataService dictDataService;
private final WaveService waveService;
@Override @Override
public Page<EventListVO> pageTransientEvents(EventListQueryParam param) { public Page<EventListVO> pageTransientEvents(EventListQueryParam param) {
@@ -89,10 +128,298 @@ public class EventListServiceImpl implements EventListService {
throw new BusinessException(CommonResponseEnum.FAIL, "导出数据超过 5000 条,请缩小查询条件"); throw new BusinessException(CommonResponseEnum.FAIL, "导出数据超过 5000 条,请缩小查询条件");
} }
exportRecords = buildEventList(eventDetails); exportRecords = buildEventList(eventDetails);
fillExportEventTypeNames(exportRecords);
} else { } else {
exportRecords = Collections.emptyList(); exportRecords = Collections.emptyList();
} }
ExcelUtil.exportExcel("暂态事件列表.xlsx", "暂态事件列表", EventListVO.class, exportRecords); ExcelUtil.exportExcel(ExportFileNameUtil.appendToday("暂态事件列表.xlsx"), "暂态事件列表", EventListVO.class, exportRecords);
}
@Override
public WaveComtradeResultVO getTransientEventWave(String eventId) {
MpEventDetailPO eventDetail = requireEventDetail(eventId);
AddLedgerLinePathVO linePath = requireLinePath(eventDetail);
EventWavePathResolver.WaveFilePath waveFilePath = resolveWaveFilePath(eventDetail, linePath);
if (!Files.exists(waveFilePath.getCfgPath())) {
throw new BusinessException(CommonResponseEnum.FAIL, "cfg 波形文件不存在");
}
if (!Files.exists(waveFilePath.getDatPath())) {
throw new BusinessException(CommonResponseEnum.FAIL, "dat 波形文件不存在");
}
WaveComtradeParseParam parseParam = new WaveComtradeParseParam();
parseParam.setMonitorName(defaultText(linePath.getLineName()));
try (InputStream cfgInputStream = new FileInputStream(waveFilePath.getCfgPath().toFile());
InputStream datInputStream = new FileInputStream(waveFilePath.getDatPath().toFile())) {
return waveService.parseComtrade(cfgInputStream, datInputStream, parseParam);
} catch (IOException ex) {
log.error("读取暂态事件波形文件失败eventId={}", eventDetail.getEventId(), ex);
throw new BusinessException(CommonResponseEnum.FAIL, "读取波形文件失败");
}
}
@Override
public void exportTransientEventWaves(EventWaveExportParam param) {
List<String> eventIds = normalizeExportEventIds(param);
List<WaveExportItem> exportItems = buildWaveExportItems(eventIds);
writeWaveExportZip(exportItems);
}
private MpEventDetailPO requireEventDetail(String eventId) {
String normalizedEventId = trimToNull(eventId);
if (normalizedEventId == null) {
throw new BusinessException(CommonResponseEnum.FAIL, "事件 ID 不能为空");
}
MpEventDetailPO eventDetail = eventListMapper.selectTransientDetail(normalizedEventId);
if (eventDetail == null) {
throw new BusinessException(CommonResponseEnum.FAIL, "暂态事件不存在");
}
return eventDetail;
}
private AddLedgerLinePathVO requireLinePath(MpEventDetailPO eventDetail) {
List<String> lineIds = new ArrayList<String>();
lineIds.add(eventDetail.getMeasurementPointId());
Map<String, AddLedgerLinePathVO> linePathMap = addLedgerService.listLinePathByLineIds(lineIds);
AddLedgerLinePathVO linePath = linePathMap.get(eventDetail.getMeasurementPointId());
if (linePath == null) {
throw new BusinessException(CommonResponseEnum.FAIL, "监测点台账不存在或已删除");
}
return linePath;
}
private List<String> normalizeExportEventIds(EventWaveExportParam param) {
List<String> eventIds = normalizeIds(param == null ? null : param.getEventIds());
if (eventIds.isEmpty()) {
throw new BusinessException(CommonResponseEnum.FAIL, "请选择需要导出的事件");
}
if (eventIds.size() > WAVE_EXPORT_LIMIT) {
throw new BusinessException(CommonResponseEnum.FAIL, "波形导出事件数量不能超过 500 条");
}
return eventIds;
}
private List<WaveExportItem> buildWaveExportItems(List<String> eventIds) {
List<MpEventDetailPO> eventDetails = eventListMapper.selectTransientDetailsByIds(eventIds);
Map<String, MpEventDetailPO> eventMap = new LinkedHashMap<String, MpEventDetailPO>();
List<String> lineIds = new ArrayList<String>();
for (MpEventDetailPO eventDetail : eventDetails) {
eventMap.put(eventDetail.getEventId(), eventDetail);
String lineId = trimToNull(eventDetail.getMeasurementPointId());
if (lineId != null && !lineIds.contains(lineId)) {
lineIds.add(lineId);
}
}
Map<String, AddLedgerLinePathVO> linePathMap = addLedgerService.listLinePathByLineIds(lineIds);
List<WaveExportItem> exportItems = new ArrayList<WaveExportItem>();
for (String eventId : eventIds) {
WaveExportItem item = new WaveExportItem();
item.setEventId(eventId);
MpEventDetailPO eventDetail = eventMap.get(eventId);
item.setEventDetail(eventDetail);
if (eventDetail == null) {
item.fail("事件不存在");
exportItems.add(item);
continue;
}
AddLedgerLinePathVO linePath = linePathMap.get(eventDetail.getMeasurementPointId());
item.setLinePath(linePath);
item.setEventVO(buildEventVO(eventDetail, linePath));
fillWaveExportFileState(item);
exportItems.add(item);
}
return exportItems;
}
private void fillWaveExportFileState(WaveExportItem item) {
MpEventDetailPO eventDetail = item.getEventDetail();
if (item.getLinePath() == null) {
item.fail("监测点台账不存在或已删除");
return;
}
if (eventDetail.getFileFlag() == null || eventDetail.getFileFlag() != 1) {
item.fail("波形文件未招");
return;
}
try {
EventWavePathResolver.WaveFilePath waveFilePath = resolveWaveFilePath(eventDetail, item.getLinePath());
item.setWaveFilePath(waveFilePath);
boolean cfgExists = Files.exists(waveFilePath.getCfgPath());
boolean datExists = Files.exists(waveFilePath.getDatPath());
if (cfgExists && datExists) {
item.success();
return;
}
if (!cfgExists && !datExists) {
item.fail("cfg/dat 文件不存在");
} else if (!cfgExists) {
item.fail("cfg 文件不存在");
} else {
item.fail("dat 文件不存在");
}
} catch (BusinessException ex) {
item.fail(ex.getMessage());
}
}
private EventWavePathResolver.WaveFilePath resolveWaveFilePath(MpEventDetailPO eventDetail, AddLedgerLinePathVO linePath) {
try {
return EventWavePathResolver.resolve(sysConfigService.getWaveStoragePath(), linePath.getEquipmentMac(), eventDetail.getWavePath());
} catch (IllegalArgumentException ex) {
throw new BusinessException(CommonResponseEnum.FAIL, ex.getMessage());
}
}
private void writeWaveExportZip(List<WaveExportItem> exportItems) {
HttpServletResponse response = HttpServletUtil.getResponse();
try {
String fileName = URLEncoder.encode(ExportFileNameUtil.appendToday("选中事件波形导出.zip"), "UTF-8");
response.reset();
response.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\"");
response.setContentType("application/zip;charset=UTF-8");
try (ServletOutputStream outputStream = response.getOutputStream();
ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream)) {
writeZipEntry(zipOutputStream, ExportFileNameUtil.appendToday("选中事件日志.xlsx"), buildWaveExportWorkbook(exportItems));
for (int i = 0; i < exportItems.size(); i++) {
WaveExportItem item = exportItems.get(i);
EventWavePathResolver.WaveFilePath waveFilePath = item.getWaveFilePath();
if (waveFilePath == null) {
continue;
}
String folder = buildZipFolderName(i + 1, item.getEventId());
writeWaveFileIfExists(zipOutputStream, folder + "/" + waveFilePath.getCfgPath().getFileName(), waveFilePath.getCfgPath());
writeWaveFileIfExists(zipOutputStream, folder + "/" + waveFilePath.getDatPath().getFileName(), waveFilePath.getDatPath());
}
zipOutputStream.flush();
}
} catch (IOException ex) {
log.error("导出暂态事件波形失败", ex);
throw new BusinessException(CommonResponseEnum.FAIL, "导出波形文件失败");
}
}
private byte[] buildWaveExportWorkbook(List<WaveExportItem> exportItems) throws IOException {
XSSFWorkbook workbook = new XSSFWorkbook();
try {
writeEventLogSheet(workbook, exportItems);
writeWaveFileSheet(workbook, exportItems);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
workbook.write(outputStream);
return outputStream.toByteArray();
} finally {
workbook.close();
}
}
private void writeEventLogSheet(XSSFWorkbook workbook, List<WaveExportItem> exportItems) {
XSSFSheet sheet = workbook.createSheet("选中事件列表");
String[] headers = new String[]{"发生时刻", "工程名称", "项目名称", "设备名称", "监测点名称", "暂降/暂升幅值(%)",
"持续时间(s)", "事件类型", "相别", "事件描述", "事件发生位置"};
int[] columnWidths = new int[]{25, 25, 25, 25, 25, 20, 18, 18, 15, 25, 18};
writeHeader(sheet, headers, createHeaderStyle(workbook));
Map<String, String> eventTypeNameMap = buildEventTypeNameMap();
int rowIndex = 1;
for (WaveExportItem item : exportItems) {
EventListVO vo = item.getEventVO();
if (vo == null) {
continue;
}
Row row = sheet.createRow(rowIndex++);
writeCells(row, new Object[]{formatDateTime(vo.getStartTime()), vo.getEngineeringName(), vo.getProjectName(),
vo.getEquipmentName(), vo.getLineName(), vo.getFeatureAmplitude(), vo.getDuration(),
translateEventType(vo.getEventType(), eventTypeNameMap), vo.getPhase(), vo.getEventDescribe(),
vo.getSagsource()});
}
applyColumnWidth(sheet, columnWidths);
}
private void writeWaveFileSheet(XSSFWorkbook workbook, List<WaveExportItem> exportItems) {
XSSFSheet sheet = workbook.createSheet("波形文件清单");
String[] headers = new String[]{"事件ID", "设备MAC", "wavePath", "cfg文件路径", "dat文件路径", "导出状态", "失败原因"};
writeHeader(sheet, headers, createHeaderStyle(workbook));
int rowIndex = 1;
for (WaveExportItem item : exportItems) {
EventWavePathResolver.WaveFilePath waveFilePath = item.getWaveFilePath();
Row row = sheet.createRow(rowIndex++);
writeCells(row, new Object[]{item.getEventId(),
item.getLinePath() == null ? EMPTY_TEXT : item.getLinePath().getEquipmentMac(),
item.getEventDetail() == null ? EMPTY_TEXT : item.getEventDetail().getWavePath(),
waveFilePath == null ? EMPTY_TEXT : waveFilePath.getCfgPath().toString(),
waveFilePath == null ? EMPTY_TEXT : waveFilePath.getDatPath().toString(),
item.getStatus(), defaultText(item.getFailReason())});
}
applyColumnWidth(sheet, headers.length);
}
private CellStyle createHeaderStyle(XSSFWorkbook workbook) {
CellStyle style = workbook.createCellStyle();
Font font = workbook.createFont();
font.setBold(true);
style.setFont(font);
return style;
}
private void writeHeader(XSSFSheet sheet, String[] headers, CellStyle style) {
Row row = sheet.createRow(0);
for (int i = 0; i < headers.length; i++) {
Cell cell = row.createCell(i);
cell.setCellValue(headers[i]);
cell.setCellStyle(style);
}
}
private void writeCells(Row row, Object[] values) {
for (int i = 0; i < values.length; i++) {
Cell cell = row.createCell(i);
Object value = values[i];
cell.setCellValue(value == null ? "" : String.valueOf(value));
}
}
private void applyColumnWidth(XSSFSheet sheet, int columnCount) {
for (int i = 0; i < columnCount; i++) {
sheet.setColumnWidth(i, 20 * 256);
}
}
private void applyColumnWidth(XSSFSheet sheet, int[] columnWidths) {
for (int i = 0; i < columnWidths.length; i++) {
sheet.setColumnWidth(i, columnWidths[i] * 256);
}
}
private void writeZipEntry(ZipOutputStream zipOutputStream, String entryName, byte[] content) throws IOException {
ZipEntry zipEntry = new ZipEntry(entryName);
zipOutputStream.putNextEntry(zipEntry);
zipOutputStream.write(content);
zipOutputStream.closeEntry();
}
private void writeWaveFileIfExists(ZipOutputStream zipOutputStream, String entryName, Path filePath) throws IOException {
if (!Files.exists(filePath)) {
return;
}
zipOutputStream.putNextEntry(new ZipEntry(entryName));
try (InputStream inputStream = new FileInputStream(filePath.toFile())) {
byte[] buffer = new byte[8192];
int length;
while ((length = inputStream.read(buffer)) != -1) {
zipOutputStream.write(buffer, 0, length);
}
}
zipOutputStream.closeEntry();
}
private String buildZipFolderName(int index, String eventId) {
return index + "_" + sanitizeZipSegment(eventId);
}
private String sanitizeZipSegment(String value) {
String text = trimToNull(value);
return text == null ? "unknown" : text.replaceAll("[\\\\/:*?\"<>|]", "_");
}
private String formatDateTime(LocalDateTime value) {
return value == null ? EMPTY_TEXT : OUTPUT_FORMATTER.format(value);
} }
private List<EventListVO> buildEventList(List<MpEventDetailPO> eventDetails) { private List<EventListVO> buildEventList(List<MpEventDetailPO> eventDetails) {
@@ -121,15 +448,16 @@ public class EventListServiceImpl implements EventListService {
vo.setMeasurementPointId(eventDetail.getMeasurementPointId()); vo.setMeasurementPointId(eventDetail.getMeasurementPointId());
vo.setEventType(eventDetail.getEventType()); vo.setEventType(eventDetail.getEventType());
vo.setEquipmentName(linePath == null ? EMPTY_TEXT : defaultText(linePath.getEquipmentName())); vo.setEquipmentName(linePath == null ? EMPTY_TEXT : defaultText(linePath.getEquipmentName()));
vo.setMac(linePath == null ? EMPTY_TEXT : defaultText(linePath.getEquipmentMac()));
vo.setEngineeringName(linePath == null ? EMPTY_TEXT : defaultText(linePath.getEngineeringName())); vo.setEngineeringName(linePath == null ? EMPTY_TEXT : defaultText(linePath.getEngineeringName()));
vo.setProjectName(linePath == null ? EMPTY_TEXT : defaultText(linePath.getProjectName())); vo.setProjectName(linePath == null ? EMPTY_TEXT : defaultText(linePath.getProjectName()));
vo.setStartTime(eventDetail.getStartTime()); vo.setStartTime(eventDetail.getStartTime());
vo.setLineName(linePath == null ? EMPTY_TEXT : defaultText(linePath.getLineName())); vo.setLineName(linePath == null ? EMPTY_TEXT : defaultText(linePath.getLineName()));
vo.setEventDescribe(defaultText(eventDetail.getEventDescribe(), eventDetail.getEventType())); vo.setEventDescribe(trimToNull(eventDetail.getEventDescribe()));
vo.setSagsource(defaultText(eventDetail.getSagsource())); vo.setSagsource(defaultText(eventDetail.getSagsource()));
vo.setPhase(defaultText(eventDetail.getPhase())); vo.setPhase(defaultText(eventDetail.getPhase()));
vo.setDuration(eventDetail.getDuration()); vo.setDuration(eventDetail.getDuration());
vo.setFeatureAmplitude(eventDetail.getFeatureAmplitude()); vo.setFeatureAmplitude(toPercent(eventDetail.getFeatureAmplitude()));
vo.setWavePath(eventDetail.getWavePath()); vo.setWavePath(eventDetail.getWavePath());
vo.setFileFlag(eventDetail.getFileFlag()); vo.setFileFlag(eventDetail.getFileFlag());
vo.setDealFlag(eventDetail.getDealFlag()); vo.setDealFlag(eventDetail.getDealFlag());
@@ -155,11 +483,12 @@ public class EventListServiceImpl implements EventListService {
validateRange(queryParam.getFeatureAmplitudeMin(), queryParam.getFeatureAmplitudeMax(), "幅值下限不能大于上限"); validateRange(queryParam.getFeatureAmplitudeMin(), queryParam.getFeatureAmplitudeMax(), "幅值下限不能大于上限");
validateFlag(queryParam.getFileFlag(), "波形文件状态只能是 0 或 1"); validateFlag(queryParam.getFileFlag(), "波形文件状态只能是 0 或 1");
validateDealFlag(queryParam.getDealFlag()); validateDealFlag(queryParam.getDealFlag());
queryParam.setFeatureAmplitudeMin(fromPercent(queryParam.getFeatureAmplitudeMin()));
queryParam.setFeatureAmplitudeMax(fromPercent(queryParam.getFeatureAmplitudeMax()));
queryParam.setStartTimeStart(OUTPUT_FORMATTER.format(startTime)); queryParam.setStartTimeStart(OUTPUT_FORMATTER.format(startTime));
queryParam.setStartTimeEnd(OUTPUT_FORMATTER.format(endTime)); queryParam.setStartTimeEnd(OUTPUT_FORMATTER.format(endTime));
queryParam.setEventType(trimToNull(queryParam.getEventType())); queryParam.setEventType(trimToNull(queryParam.getEventType()));
queryParam.setPhase(trimToNull(queryParam.getPhase())); queryParam.setPhase(trimToNull(queryParam.getPhase()));
queryParam.setEventDescribe(trimToNull(queryParam.getEventDescribe()));
queryParam.setEngineeringName(trimToNull(queryParam.getEngineeringName())); queryParam.setEngineeringName(trimToNull(queryParam.getEngineeringName()));
queryParam.setProjectName(trimToNull(queryParam.getProjectName())); queryParam.setProjectName(trimToNull(queryParam.getProjectName()));
queryParam.setEquipmentName(trimToNull(queryParam.getEquipmentName())); queryParam.setEquipmentName(trimToNull(queryParam.getEquipmentName()));
@@ -222,10 +551,10 @@ public class EventListServiceImpl implements EventListService {
try { try {
return LocalDateTime.parse(text, formatter); return LocalDateTime.parse(text, formatter);
} catch (DateTimeParseException ignored) { } catch (DateTimeParseException ignored) {
// 尝试下一前端可能传入的时间格式。 // 尝试下一前端可能传入的时间格式。
} }
} }
throw new BusinessException(CommonResponseEnum.FAIL, "时间格式不正确,仅支持 yyyy-MM-dd HH:mm:ss"); throw new BusinessException(CommonResponseEnum.FAIL, "时间格式不正确,仅支持 yyyy-MM-dd HH:mm:ss 或 yyyy-MM-dd HH:mm:ss.SSS");
} }
private List<String> normalizeIds(List<String> ids) { private List<String> normalizeIds(List<String> ids) {
@@ -255,6 +584,59 @@ public class EventListServiceImpl implements EventListService {
} }
} }
private BigDecimal toPercent(BigDecimal value) {
return value == null ? null : value.multiply(PERCENT_BASE);
}
private BigDecimal fromPercent(BigDecimal value) {
return value == null ? null : value.divide(PERCENT_BASE);
}
private void fillExportEventTypeNames(List<EventListVO> exportRecords) {
Map<String, String> eventTypeNameMap = buildEventTypeNameMap();
for (EventListVO record : exportRecords) {
record.setEventType(translateEventType(record.getEventType(), eventTypeNameMap));
}
}
private Map<String, String> buildEventTypeNameMap() {
DictType dictType = dictTypeService.getByCode(DictConst.EVENT_TYPE_DICT);
if (dictType == null) {
dictType = dictTypeService.lambdaQuery()
.eq(DictType::getName, DictConst.EVENT_TYPE_DICT)
.eq(DictType::getState, DataStateEnum.ENABLE.getCode())
.orderByAsc(DictType::getSort)
.orderByDesc(DictType::getId)
.last("LIMIT 1")
.one();
}
if (dictType == null) {
return Collections.emptyMap();
}
List<DictData> dictDataList = dictDataService.listDictDataByTypeId(dictType.getId());
Map<String, String> eventTypeNameMap = new LinkedHashMap<String, String>();
for (DictData dictData : dictDataList) {
String id = trimToNull(dictData.getId());
String code = trimToNull(dictData.getCode());
if (id != null) {
eventTypeNameMap.put(id, defaultText(dictData.getName(), id));
}
if (code != null) {
eventTypeNameMap.put(code, defaultText(dictData.getName(), code));
}
}
return eventTypeNameMap;
}
private String translateEventType(String eventType, Map<String, String> eventTypeNameMap) {
String normalizedEventType = trimToNull(eventType);
if (normalizedEventType == null) {
return EMPTY_TEXT;
}
String eventTypeName = eventTypeNameMap.get(normalizedEventType);
return eventTypeName == null ? normalizedEventType : eventTypeName;
}
private void validateFlag(Integer flag, String message) { private void validateFlag(Integer flag, String message) {
if (flag != null && flag != 0 && flag != 1) { if (flag != null && flag != 0 && flag != 1) {
throw new BusinessException(CommonResponseEnum.FAIL, message); throw new BusinessException(CommonResponseEnum.FAIL, message);
@@ -283,4 +665,79 @@ public class EventListServiceImpl implements EventListService {
String trimmed = value.trim(); String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed; return trimmed.isEmpty() ? null : trimmed;
} }
private static class WaveExportItem {
private String eventId;
private MpEventDetailPO eventDetail;
private AddLedgerLinePathVO linePath;
private EventListVO eventVO;
private EventWavePathResolver.WaveFilePath waveFilePath;
private String status = WAVE_EXPORT_FAIL;
private String failReason;
private String getEventId() {
return eventId;
}
private void setEventId(String eventId) {
this.eventId = eventId;
}
private MpEventDetailPO getEventDetail() {
return eventDetail;
}
private void setEventDetail(MpEventDetailPO eventDetail) {
this.eventDetail = eventDetail;
}
private AddLedgerLinePathVO getLinePath() {
return linePath;
}
private void setLinePath(AddLedgerLinePathVO linePath) {
this.linePath = linePath;
}
private EventListVO getEventVO() {
return eventVO;
}
private void setEventVO(EventListVO eventVO) {
this.eventVO = eventVO;
}
private EventWavePathResolver.WaveFilePath getWaveFilePath() {
return waveFilePath;
}
private void setWaveFilePath(EventWavePathResolver.WaveFilePath waveFilePath) {
this.waveFilePath = waveFilePath;
}
private String getStatus() {
return status;
}
private String getFailReason() {
return failReason;
}
private void success() {
this.status = WAVE_EXPORT_SUCCESS;
this.failReason = null;
}
private void fail(String failReason) {
this.status = WAVE_EXPORT_FAIL;
this.failReason = failReason;
}
}
} }

View File

@@ -0,0 +1,81 @@
package com.njcn.gather.event.eventlist.service.impl;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* 暂态事件波形文件路径解析器。
*/
final class EventWavePathResolver {
private EventWavePathResolver() {
}
static WaveFilePath resolve(String storageRoot, String equipmentMac, String wavePath) {
String rootText = requireText(storageRoot, "波形存储路径未配置");
String mac = requireText(equipmentMac, "设备 MAC 为空");
String normalizedWavePath = normalizeWavePath(wavePath);
Path rootPath = Paths.get(rootText).normalize();
Path rawWavePath = Paths.get(normalizedWavePath).normalize();
Path eventBasePath;
if (rawWavePath.isAbsolute()) {
eventBasePath = rawWavePath;
} else if (startsWithSegment(rawWavePath, mac)) {
eventBasePath = rootPath.resolve(rawWavePath).normalize();
} else {
eventBasePath = rootPath.resolve(mac).resolve(rawWavePath).normalize();
}
if (!eventBasePath.startsWith(rootPath)) {
throw new IllegalArgumentException("波形路径非法");
}
String fileName = eventBasePath.getFileName().toString();
return new WaveFilePath(eventBasePath.resolveSibling(fileName + ".cfg"),
eventBasePath.resolveSibling(fileName + ".dat"));
}
private static String normalizeWavePath(String value) {
String wavePath = requireText(value, "事件 wave_path 为空").replace("\\", "/");
String lowerWavePath = wavePath.toLowerCase();
if (lowerWavePath.endsWith(".cfg") || lowerWavePath.endsWith(".dat")) {
wavePath = wavePath.substring(0, wavePath.length() - 4);
}
if (wavePath.contains("..")) {
throw new IllegalArgumentException("波形路径非法");
}
return wavePath;
}
private static boolean startsWithSegment(Path path, String segment) {
return path.getNameCount() > 0 && path.getName(0).toString().equalsIgnoreCase(segment);
}
private static String requireText(String value, String message) {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException(message);
}
return value.trim();
}
static class WaveFilePath {
private final Path cfgPath;
private final Path datPath;
private WaveFilePath(Path cfgPath, Path datPath) {
this.cfgPath = cfgPath;
this.datPath = datPath;
}
Path getCfgPath() {
return cfgPath;
}
Path getDatPath() {
return datPath;
}
}
}

View File

@@ -0,0 +1,44 @@
package com.njcn.gather.event.eventlist.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.njcn.gather.event.eventlist.pojo.param.EventListQueryParam;
import com.njcn.gather.event.eventlist.pojo.vo.EventListVO;
import org.junit.Test;
import java.lang.reflect.Method;
import java.time.LocalDateTime;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class EventListTimeFormatTest {
@Test
public void startTimeJsonKeepsMilliseconds() throws Exception {
EventListVO vo = new EventListVO();
vo.setStartTime(LocalDateTime.of(2026, 5, 10, 13, 41, 17, 944000000));
String json = new ObjectMapper().writeValueAsString(vo);
assertTrue(json.contains("\"startTime\":\"2026-05-10 13:41:17.944\""));
}
@Test
public void queryTimeNormalizationKeepsMilliseconds() throws Exception {
EventListQueryParam param = new EventListQueryParam();
param.setStartTimeStart("2026-05-10 13:41:17.944");
param.setStartTimeEnd("2026-05-10 13:41:18.123");
normalizeQueryParam(param);
assertEquals("2026-05-10 13:41:17.944", param.getStartTimeStart());
assertEquals("2026-05-10 13:41:18.123", param.getStartTimeEnd());
}
private void normalizeQueryParam(EventListQueryParam param) throws Exception {
EventListServiceImpl service = new EventListServiceImpl(null, null, null, null, null, null);
Method method = EventListServiceImpl.class.getDeclaredMethod("normalizeQueryParam", EventListQueryParam.class);
method.setAccessible(true);
method.invoke(service, param);
}
}

View File

@@ -0,0 +1,40 @@
package com.njcn.gather.event.eventlist.service.impl;
import java.nio.file.Path;
import java.nio.file.Paths;
public class EventWavePathResolverTest {
public static void main(String[] args) {
resolvesAbsoluteWavePath();
resolvesRelativeWavePathWithMacPrefix();
resolvesRelativeWavePathAlreadyContainingMac();
}
private static void resolvesAbsoluteWavePath() {
EventWavePathResolver.WaveFilePath result = EventWavePathResolver.resolve("D:/", "AA-BB", "D:/wave/event-001.cfg");
assertPathEquals(Paths.get("D:/wave/event-001.cfg"), result.getCfgPath(), "absolute cfg");
assertPathEquals(Paths.get("D:/wave/event-001.dat"), result.getDatPath(), "absolute dat");
}
private static void resolvesRelativeWavePathWithMacPrefix() {
EventWavePathResolver.WaveFilePath result = EventWavePathResolver.resolve("D:/wave-root", "AA-BB", "event-001");
assertPathEquals(Paths.get("D:/wave-root/AA-BB/event-001.cfg"), result.getCfgPath(), "relative cfg");
assertPathEquals(Paths.get("D:/wave-root/AA-BB/event-001.dat"), result.getDatPath(), "relative dat");
}
private static void resolvesRelativeWavePathAlreadyContainingMac() {
EventWavePathResolver.WaveFilePath result = EventWavePathResolver.resolve("D:/wave-root", "AA-BB", "AA-BB/event-001.dat");
assertPathEquals(Paths.get("D:/wave-root/AA-BB/event-001.cfg"), result.getCfgPath(), "relative mac cfg");
assertPathEquals(Paths.get("D:/wave-root/AA-BB/event-001.dat"), result.getDatPath(), "relative mac dat");
}
private static void assertPathEquals(Path expected, Path actual, String label) {
if (!expected.normalize().equals(actual.normalize())) {
throw new AssertionError(label + " expected " + expected + " but got " + actual);
}
}
}

View File

@@ -13,6 +13,7 @@
<module>entrance</module> <module>entrance</module>
<module>system</module> <module>system</module>
<module>systemmonitor</module> <module>systemmonitor</module>
<module>system-ops</module>
<module>user</module> <module>user</module>
<module>detection</module> <module>detection</module>
<module>tools</module> <module>tools</module>

View File

@@ -0,0 +1,75 @@
package com.njcn.gather.steady.checksquare.component;
import com.njcn.gather.steady.checksquare.pojo.vo.SteadyChecksquareSegmentVO;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* 数据校验连续性计算组件。
*/
@Component
public class SteadyChecksquareCalculator {
public static final String STATUS_NORMAL = "NORMAL";
public static final String STATUS_MISSING = "MISSING";
private static final DateTimeFormatter OUTPUT_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public List<SteadyChecksquareSegmentVO> buildSegments(List<LocalDateTime> slots, Set<LocalDateTime> actualSlots,
int intervalMinutes) {
List<SteadyChecksquareSegmentVO> result = new ArrayList<SteadyChecksquareSegmentVO>();
if (slots == null || slots.isEmpty()) {
return result;
}
String currentStatus = resolveStatus(slots.get(0), actualSlots);
LocalDateTime segmentStart = slots.get(0);
LocalDateTime previousSlot = slots.get(0);
int pointCount = 1;
for (int i = 1; i < slots.size(); i++) {
LocalDateTime slot = slots.get(i);
String status = resolveStatus(slot, actualSlots);
if (!currentStatus.equals(status)) {
result.add(buildSegment(segmentStart, previousSlot, currentStatus, pointCount, intervalMinutes));
segmentStart = slot;
pointCount = 0;
currentStatus = status;
}
previousSlot = slot;
pointCount++;
}
result.add(buildSegment(segmentStart, previousSlot, currentStatus, pointCount, intervalMinutes));
return result;
}
public int maxContinuousMissingMinutes(List<SteadyChecksquareSegmentVO> segments) {
int result = 0;
if (segments == null) {
return result;
}
for (SteadyChecksquareSegmentVO segment : segments) {
if (segment != null && STATUS_MISSING.equals(segment.getStatus()) && segment.getDurationMinutes() != null) {
result = Math.max(result, segment.getDurationMinutes());
}
}
return result;
}
private SteadyChecksquareSegmentVO buildSegment(LocalDateTime startTime, LocalDateTime endTime, String status,
int pointCount, int intervalMinutes) {
SteadyChecksquareSegmentVO segment = new SteadyChecksquareSegmentVO();
segment.setStartTime(OUTPUT_TIME_FORMATTER.format(startTime));
segment.setEndTime(OUTPUT_TIME_FORMATTER.format(endTime));
segment.setStatus(status);
segment.setMissingPointCount(STATUS_MISSING.equals(status) ? pointCount : 0);
segment.setDurationMinutes(pointCount * intervalMinutes);
return segment;
}
private String resolveStatus(LocalDateTime slot, Set<LocalDateTime> actualSlots) {
return actualSlots != null && actualSlots.contains(slot) ? STATUS_NORMAL : STATUS_MISSING;
}
}

View File

@@ -0,0 +1,201 @@
package com.njcn.gather.steady.checksquare.component;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.gather.steady.datavie.config.SteadyInfluxDbProperties;
import com.njcn.gather.steady.datavie.pojo.bo.SteadyTrendResolvedFieldBO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URLEncoder;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.HashSet;
import java.util.Set;
/**
* 数据校验 InfluxDB 查询组件。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SteadyChecksquareInfluxQueryComponent {
private static final DateTimeFormatter INFLUX_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'");
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final SteadyInfluxDbProperties properties;
public Set<LocalDateTime> queryExistingSlots(SteadyTrendResolvedFieldBO field, LocalDateTime startTime,
LocalDateTime endTime, int intervalMinutes) {
validateConfig();
String query = buildChecksquareQuery(field, startTime, endTime);
long startMillis = System.currentTimeMillis();
log.info("数据校验 InfluxDB 查询开始measurement={}field={}lineId={}phase={}statType={}query={}",
field.getMeasurement(), field.getField(), field.getLineId(), field.getPhase(), field.getStatType(), query);
try {
String body = executeQuery(query);
Set<LocalDateTime> slots = parseExistingSlots(body, intervalMinutes);
log.info("数据校验 InfluxDB 查询结束slotCount={}costMs={}", slots.size(), System.currentTimeMillis() - startMillis);
return slots;
} catch (RuntimeException ex) {
log.warn("数据校验 InfluxDB 查询异常costMs={}error={}", System.currentTimeMillis() - startMillis, ex.getMessage());
throw ex;
}
}
public String buildChecksquareQuery(SteadyTrendResolvedFieldBO field, LocalDateTime startTime, LocalDateTime endTime) {
StringBuilder sql = new StringBuilder();
sql.append("SELECT \"").append(field.getField()).append("\" AS \"value\"");
sql.append(" FROM \"").append(field.getMeasurement()).append("\"");
sql.append(" WHERE time >= '").append(INFLUX_TIME_FORMATTER.format(startTime)).append("'");
sql.append(" AND time <= '").append(INFLUX_TIME_FORMATTER.format(endTime)).append("'");
sql.append(" AND \"line_id\" = '").append(escapeTagValue(field.getLineId())).append("'");
sql.append(" AND \"phasic_type\" = '").append(escapeTagValue(field.getPhase())).append("'");
if (hasValueTypeTag(field.getMeasurement())) {
sql.append(" AND \"value_type\" = '").append(resolveValueType(field.getStatType())).append("'");
}
sql.append(" ORDER BY time ASC");
return sql.toString();
}
private Set<LocalDateTime> parseExistingSlots(String body, int intervalMinutes) {
try {
JsonNode root = OBJECT_MAPPER.readTree(body);
JsonNode values = root.path("results").path(0).path("series").path(0).path("values");
Set<LocalDateTime> result = new HashSet<LocalDateTime>();
if (!values.isArray()) {
return result;
}
for (JsonNode value : values) {
if (value.size() < 2 || value.get(1).isNull()) {
continue;
}
LocalDateTime time = parseInfluxTime(value.get(0).asText());
if (time != null) {
result.add(alignToPreviousSlot(time, intervalMinutes));
}
}
return result;
} catch (IOException ex) {
throw fail("InfluxDB 返回结果解析失败:" + ex.getMessage());
}
}
private LocalDateTime alignToPreviousSlot(LocalDateTime time, int intervalMinutes) {
LocalDateTime minuteFloor = time.withSecond(0).withNano(0);
int minuteOfDay = minuteFloor.getHour() * 60 + minuteFloor.getMinute();
int remainder = minuteOfDay % intervalMinutes;
return minuteFloor.minusMinutes(remainder);
}
private LocalDateTime parseInfluxTime(String value) {
try {
return OffsetDateTime.parse(value).withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime();
} catch (RuntimeException ex) {
return null;
}
}
private String executeQuery(String query) {
HttpURLConnection connection = null;
try {
URL url = new URL(buildQueryUrl(query));
connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
connection.setConnectTimeout(properties.getConnectTimeoutMs());
connection.setReadTimeout(properties.getReadTimeoutMs());
int status = connection.getResponseCode();
InputStream stream = status >= 200 && status < 300 ? connection.getInputStream() : connection.getErrorStream();
String body = readBody(stream);
if (status < 200 || status >= 300) {
throw fail("InfluxDB 查询失败:" + body);
}
return body;
} catch (IOException ex) {
throw fail("InfluxDB 查询异常:" + ex.getMessage());
} finally {
if (connection != null) {
connection.disconnect();
}
}
}
private String buildQueryUrl(String query) throws IOException {
StringBuilder url = new StringBuilder(trimRightSlash(properties.getUrl())).append("/query?");
url.append("db=").append(encode(properties.getDatabase()));
if (properties.getUsername() != null && !properties.getUsername().trim().isEmpty()) {
url.append("&u=").append(encode(properties.getUsername().trim()));
}
if (properties.getPassword() != null && !properties.getPassword().trim().isEmpty()) {
url.append("&p=").append(encode(properties.getPassword()));
}
url.append("&q=").append(encode(query));
return url.toString();
}
private void validateConfig() {
if (properties.getUrl() == null || properties.getUrl().trim().isEmpty()) {
throw fail("InfluxDB 地址未配置");
}
if (properties.getDatabase() == null || properties.getDatabase().trim().isEmpty()) {
throw fail("InfluxDB database 未配置");
}
}
private String readBody(InputStream stream) throws IOException {
if (stream == null) {
return "";
}
BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8));
StringBuilder body = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
body.append(line);
}
return body.toString();
}
private String escapeTagValue(String value) {
return value == null ? "" : value.replace("\\", "\\\\").replace("'", "\\'");
}
private String resolveValueType(String statType) {
if (statType == null || statType.trim().isEmpty()) {
return "AVG";
}
return statType.trim().toUpperCase();
}
private boolean hasValueTypeTag(String measurement) {
return !"data_flicker".equals(measurement) && !"data_fluc".equals(measurement) && !"data_plt".equals(measurement);
}
private String trimRightSlash(String value) {
String text = value.trim();
while (text.endsWith("/")) {
text = text.substring(0, text.length() - 1);
}
return text;
}
private String encode(String value) throws IOException {
return URLEncoder.encode(value, StandardCharsets.UTF_8.name());
}
private BusinessException fail(String message) {
return new BusinessException(CommonResponseEnum.FAIL, message);
}
}

View File

@@ -0,0 +1,43 @@
package com.njcn.gather.steady.checksquare.controller;
import com.njcn.common.pojo.annotation.OperateInfo;
import com.njcn.common.pojo.enums.common.LogEnum;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.response.HttpResult;
import com.njcn.common.utils.LogUtil;
import com.njcn.gather.steady.checksquare.pojo.param.SteadyChecksquareQueryParam;
import com.njcn.gather.steady.checksquare.pojo.vo.SteadyChecksquareQueryVO;
import com.njcn.gather.steady.checksquare.service.SteadyChecksquareService;
import com.njcn.web.controller.BaseController;
import com.njcn.web.utils.HttpResultUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 数据校验接口。
*/
@Slf4j
@Api(tags = "数据校验")
@RestController
@RequestMapping("/steady/data-view/checksquare")
@RequiredArgsConstructor
public class SteadyChecksquareController extends BaseController {
private final SteadyChecksquareService checksquareService;
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("查询数据校验结果")
@PostMapping("/query")
public HttpResult<SteadyChecksquareQueryVO> query(@RequestBody SteadyChecksquareQueryParam param) {
String methodDescribe = getMethodDescribe("query");
LogUtil.njcnDebug(log, "{}开始查询数据校验结果param={}", methodDescribe, param);
SteadyChecksquareQueryVO result = checksquareService.query(param);
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS, result, methodDescribe);
}
}

View File

@@ -0,0 +1,33 @@
package com.njcn.gather.steady.checksquare.pojo.param;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* 数据校验查询参数。
*/
@Data
@ApiModel("数据校验查询参数")
public class SteadyChecksquareQueryParam implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty("监测点 ID")
private String lineId;
@ApiModelProperty("指标编码")
private List<String> indicatorCodes;
@ApiModelProperty("开始时间,格式 yyyy-MM-dd HH:mm:ss")
private String timeStart;
@ApiModelProperty("结束时间,格式 yyyy-MM-dd HH:mm:ss")
private String timeEnd;
@ApiModelProperty("谐波次数,谐波指标按请求次数查询")
private List<Integer> harmonicOrders;
}

View File

@@ -0,0 +1,62 @@
package com.njcn.gather.steady.checksquare.pojo.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
/**
* 数据校验总览项。
*/
@Data
@ApiModel("数据校验总览项")
public class SteadyChecksquareItemVO implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty("校验项唯一键")
private String itemKey;
@ApiModelProperty("指标编码")
private String indicatorCode;
@ApiModelProperty("指标名称")
private String indicatorName;
@ApiModelProperty("谐波次数")
private Integer harmonicOrder;
@ApiModelProperty("当前校验项统计间隔,单位分钟")
private Integer intervalMinutes;
@ApiModelProperty("时间范围内是否存在任意数据")
private Boolean hasData;
@ApiModelProperty("期望点数")
private Integer expectedPointCount;
@ApiModelProperty("实际点数")
private Integer actualPointCount;
@ApiModelProperty("缺失点数")
private Integer missingPointCount;
@ApiModelProperty("缺失率")
private BigDecimal missingRate;
@ApiModelProperty("缺失率文本")
private String missingRateText;
@ApiModelProperty("最大连续缺失时长,单位分钟")
private Integer maxContinuousMissingMinutes;
@ApiModelProperty("统计类型摘要")
private List<SteadyChecksquareStatSummaryVO> statSummaries = new ArrayList<SteadyChecksquareStatSummaryVO>();
@ApiModelProperty("统计类型明细")
private List<SteadyChecksquareStatDetailVO> statDetails = new ArrayList<SteadyChecksquareStatDetailVO>();
}

View File

@@ -0,0 +1,37 @@
package com.njcn.gather.steady.checksquare.pojo.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/**
* 数据校验查询结果。
*/
@Data
@ApiModel("数据校验查询结果")
public class SteadyChecksquareQueryVO implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty("监测点 ID")
private String lineId;
@ApiModelProperty("监测点名称")
private String lineName;
@ApiModelProperty("开始时间")
private String timeStart;
@ApiModelProperty("结束时间")
private String timeEnd;
@ApiModelProperty("统计间隔,单位分钟")
private Integer intervalMinutes;
@ApiModelProperty("校验项")
private List<SteadyChecksquareItemVO> items = new ArrayList<SteadyChecksquareItemVO>();
}

View File

@@ -0,0 +1,32 @@
package com.njcn.gather.steady.checksquare.pojo.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
/**
* 数据校验连续性区间。
*/
@Data
@ApiModel("数据校验连续性区间")
public class SteadyChecksquareSegmentVO implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty("开始时间")
private String startTime;
@ApiModelProperty("结束时间")
private String endTime;
@ApiModelProperty("状态NORMAL/MISSING")
private String status;
@ApiModelProperty("缺失点数")
private Integer missingPointCount;
@ApiModelProperty("持续时长,单位分钟")
private Integer durationMinutes;
}

View File

@@ -0,0 +1,28 @@
package com.njcn.gather.steady.checksquare.pojo.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/**
* 数据校验统计类型明细。
*/
@Data
@ApiModel("数据校验统计类型明细")
public class SteadyChecksquareStatDetailVO implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty("统计类型")
private String statType;
@ApiModelProperty("是否支持")
private Boolean supported;
@ApiModelProperty("连续性区间")
private List<SteadyChecksquareSegmentVO> segments = new ArrayList<SteadyChecksquareSegmentVO>();
}

View File

@@ -0,0 +1,45 @@
package com.njcn.gather.steady.checksquare.pojo.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
/**
* 数据校验统计类型摘要。
*/
@Data
@ApiModel("数据校验统计类型摘要")
public class SteadyChecksquareStatSummaryVO implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty("统计类型")
private String statType;
@ApiModelProperty("是否支持")
private Boolean supported;
@ApiModelProperty("是否存在数据")
private Boolean hasData;
@ApiModelProperty("期望点数")
private Integer expectedPointCount;
@ApiModelProperty("实际点数")
private Integer actualPointCount;
@ApiModelProperty("缺失点数")
private Integer missingPointCount;
@ApiModelProperty("缺失率")
private BigDecimal missingRate;
@ApiModelProperty("缺失率文本")
private String missingRateText;
@ApiModelProperty("最大连续缺失时长,单位分钟")
private Integer maxContinuousMissingMinutes;
}

View File

@@ -0,0 +1,12 @@
package com.njcn.gather.steady.checksquare.service;
import com.njcn.gather.steady.checksquare.pojo.param.SteadyChecksquareQueryParam;
import com.njcn.gather.steady.checksquare.pojo.vo.SteadyChecksquareQueryVO;
/**
* 数据校验服务。
*/
public interface SteadyChecksquareService {
SteadyChecksquareQueryVO query(SteadyChecksquareQueryParam param);
}

View File

@@ -0,0 +1,349 @@
package com.njcn.gather.steady.checksquare.service.impl;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.gather.steady.checksquare.component.SteadyChecksquareCalculator;
import com.njcn.gather.steady.checksquare.component.SteadyChecksquareInfluxQueryComponent;
import com.njcn.gather.steady.checksquare.pojo.param.SteadyChecksquareQueryParam;
import com.njcn.gather.steady.checksquare.pojo.vo.SteadyChecksquareItemVO;
import com.njcn.gather.steady.checksquare.pojo.vo.SteadyChecksquareQueryVO;
import com.njcn.gather.steady.checksquare.pojo.vo.SteadyChecksquareSegmentVO;
import com.njcn.gather.steady.checksquare.pojo.vo.SteadyChecksquareStatDetailVO;
import com.njcn.gather.steady.checksquare.pojo.vo.SteadyChecksquareStatSummaryVO;
import com.njcn.gather.steady.checksquare.service.SteadyChecksquareService;
import com.njcn.gather.steady.datavie.component.SteadyTrendIndicatorCatalog;
import com.njcn.gather.steady.datavie.pojo.bo.SteadyTrendIndicatorDefinitionBO;
import com.njcn.gather.steady.datavie.pojo.bo.SteadyTrendResolvedFieldBO;
import com.njcn.gather.steady.datavie.pojo.bo.SteadyTrendSeriesFieldBO;
import com.njcn.gather.tool.adddata.component.AddDataTimeSlotCalculator;
import com.njcn.gather.tool.addledger.pojo.constant.AddLedgerConst;
import com.njcn.gather.tool.addledger.pojo.vo.AddLedgerLinePathVO;
import com.njcn.gather.tool.addledger.service.AddLedgerService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 数据校验服务实现。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SteadyChecksquareServiceImpl implements SteadyChecksquareService {
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final String EMPTY_TEXT = "-";
private static final int FLICKER_SHORT_INTERVAL_MINUTES = 10;
private static final int FLICKER_LONG_INTERVAL_MINUTES = 120;
private final SteadyTrendIndicatorCatalog indicatorCatalog;
private final SteadyChecksquareInfluxQueryComponent influxQueryComponent;
private final SteadyChecksquareCalculator calculator;
private final AddDataTimeSlotCalculator timeSlotCalculator;
private final AddLedgerService addLedgerService;
@Override
public SteadyChecksquareQueryVO query(SteadyChecksquareQueryParam param) {
validateParam(param);
String lineId = trimToNull(param.getLineId());
LocalDateTime startTime = parseRequiredTime(param.getTimeStart(), "开始时间不能为空");
LocalDateTime endTime = parseRequiredTime(param.getTimeEnd(), "结束时间不能为空");
if (startTime.isAfter(endTime)) {
throw fail("开始时间不能大于结束时间");
}
AddLedgerLinePathVO linePath = requireLinePath(lineId);
int intervalMinutes = resolveIntervalMinutes(linePath);
SteadyChecksquareQueryVO result = new SteadyChecksquareQueryVO();
result.setLineId(lineId);
result.setLineName(trimToNull(linePath.getLineName()) == null ? EMPTY_TEXT : linePath.getLineName());
result.setTimeStart(param.getTimeStart());
result.setTimeEnd(param.getTimeEnd());
result.setIntervalMinutes(intervalMinutes);
long startMillis = System.currentTimeMillis();
List<String> indicatorCodes = normalizeTextList(param.getIndicatorCodes());
List<Integer> harmonicOrders = normalizeHarmonicOrders(param.getHarmonicOrders());
log.info("数据校验查询开始lineId={}indicatorCount={}timeStart={}timeEnd={}intervalMinutes={}",
lineId, indicatorCodes.size(), startTime, endTime, intervalMinutes);
for (String indicatorCode : indicatorCodes) {
SteadyTrendIndicatorDefinitionBO indicator = requireIndicator(indicatorCode);
int itemIntervalMinutes = resolveIndicatorIntervalMinutes(indicator, intervalMinutes);
List<LocalDateTime> itemSlots = timeSlotCalculator.buildTimeSlots(startTime, endTime, itemIntervalMinutes);
result.getItems().addAll(buildIndicatorItems(lineId, indicator, harmonicOrders, startTime, endTime, itemSlots, itemIntervalMinutes));
}
log.info("数据校验查询结束lineId={}itemCount={}costMs={}", lineId, result.getItems().size(), System.currentTimeMillis() - startMillis);
return result;
}
private List<SteadyChecksquareItemVO> buildIndicatorItems(String lineId, SteadyTrendIndicatorDefinitionBO indicator,
List<Integer> harmonicOrders,
LocalDateTime startTime, LocalDateTime endTime,
List<LocalDateTime> slots, int intervalMinutes) {
List<SteadyChecksquareItemVO> result = new ArrayList<SteadyChecksquareItemVO>();
if (Boolean.TRUE.equals(indicator.getHarmonic())) {
for (Integer order : requireValidHarmonicOrders(indicator, harmonicOrders)) {
result.add(buildItem(lineId, indicator, order, startTime, endTime, slots, intervalMinutes));
}
return result;
}
result.add(buildItem(lineId, indicator, null, startTime, endTime, slots, intervalMinutes));
return result;
}
private SteadyChecksquareItemVO buildItem(String lineId, SteadyTrendIndicatorDefinitionBO indicator, Integer harmonicOrder,
LocalDateTime startTime, LocalDateTime endTime,
List<LocalDateTime> slots, int intervalMinutes) {
SteadyChecksquareItemVO item = new SteadyChecksquareItemVO();
item.setItemKey(buildItemKey(lineId, indicator, harmonicOrder));
item.setIndicatorCode(indicator.getIndicatorCode());
item.setIndicatorName(indicator.getName());
item.setHarmonicOrder(harmonicOrder);
item.setIntervalMinutes(intervalMinutes);
int totalExpected = 0;
int totalActual = 0;
int maxContinuousMissingMinutes = 0;
boolean hasData = false;
for (String statType : indicator.getSupportStats()) {
Set<LocalDateTime> actualSlots = queryMergedActualSlots(lineId, indicator, harmonicOrder, statType, startTime, endTime, intervalMinutes);
Set<LocalDateTime> effectiveActualSlots = retainExpectedSlots(slots, actualSlots);
List<SteadyChecksquareSegmentVO> segments = calculator.buildSegments(slots, effectiveActualSlots, intervalMinutes);
SteadyChecksquareStatSummaryVO summary = buildSummary(statType, slots.size(), effectiveActualSlots.size(), segments);
SteadyChecksquareStatDetailVO detail = buildDetail(statType, segments);
item.getStatSummaries().add(summary);
item.getStatDetails().add(detail);
totalExpected += summary.getExpectedPointCount();
totalActual += summary.getActualPointCount();
maxContinuousMissingMinutes = Math.max(maxContinuousMissingMinutes, summary.getMaxContinuousMissingMinutes());
hasData = hasData || Boolean.TRUE.equals(summary.getHasData());
}
item.setHasData(hasData);
item.setExpectedPointCount(totalExpected);
item.setActualPointCount(totalActual);
item.setMissingPointCount(Math.max(0, totalExpected - totalActual));
item.setMissingRate(calculateRate(item.getMissingPointCount(), totalExpected));
item.setMissingRateText(formatRateText(item.getMissingRate()));
item.setMaxContinuousMissingMinutes(maxContinuousMissingMinutes);
return item;
}
private Set<LocalDateTime> queryMergedActualSlots(String lineId, SteadyTrendIndicatorDefinitionBO indicator, Integer harmonicOrder,
String statType, LocalDateTime startTime, LocalDateTime endTime,
int intervalMinutes) {
Set<LocalDateTime> result = new HashSet<LocalDateTime>();
for (String phase : indicator.getPhaseCodes()) {
SteadyTrendResolvedFieldBO field = buildResolvedField(lineId, indicator, harmonicOrder, phase, statType);
result.addAll(influxQueryComponent.queryExistingSlots(field, startTime, endTime, intervalMinutes));
}
return result;
}
private Set<LocalDateTime> retainExpectedSlots(List<LocalDateTime> slots, Set<LocalDateTime> actualSlots) {
Set<LocalDateTime> result = new HashSet<LocalDateTime>();
if (slots == null || actualSlots == null || actualSlots.isEmpty()) {
return result;
}
for (LocalDateTime slot : slots) {
if (actualSlots.contains(slot)) {
result.add(slot);
}
}
return result;
}
private SteadyTrendResolvedFieldBO buildResolvedField(String lineId, SteadyTrendIndicatorDefinitionBO indicator,
Integer harmonicOrder, String phase, String statType) {
SteadyTrendResolvedFieldBO field = new SteadyTrendResolvedFieldBO();
field.setMeasurement(indicator.getTableName());
field.setField(resolveField(indicator, harmonicOrder));
field.setLineId(lineId);
field.setIndicatorCode(indicator.getIndicatorCode());
field.setIndicatorName(indicator.getName());
field.setPhase(phase);
field.setStatType(statType);
field.setUnit(indicator.getUnit());
return field;
}
private String resolveField(SteadyTrendIndicatorDefinitionBO indicator, Integer harmonicOrder) {
if (Boolean.TRUE.equals(indicator.getHarmonic())) {
return indicator.getHarmonicFieldPrefix() + "_" + harmonicOrder;
}
List<SteadyTrendSeriesFieldBO> fields = indicator.getSeriesFields();
if (fields == null || fields.isEmpty()) {
throw fail("稳态指标不支持:" + indicator.getIndicatorCode());
}
return fields.get(0).getField();
}
private SteadyChecksquareStatSummaryVO buildSummary(String statType, int expectedCount, int actualCount,
List<SteadyChecksquareSegmentVO> segments) {
SteadyChecksquareStatSummaryVO summary = new SteadyChecksquareStatSummaryVO();
summary.setStatType(statType);
summary.setSupported(true);
summary.setHasData(actualCount > 0);
summary.setExpectedPointCount(expectedCount);
summary.setActualPointCount(actualCount);
summary.setMissingPointCount(Math.max(0, expectedCount - actualCount));
summary.setMissingRate(calculateRate(summary.getMissingPointCount(), expectedCount));
summary.setMissingRateText(formatRateText(summary.getMissingRate()));
summary.setMaxContinuousMissingMinutes(calculator.maxContinuousMissingMinutes(segments));
return summary;
}
private SteadyChecksquareStatDetailVO buildDetail(String statType, List<SteadyChecksquareSegmentVO> segments) {
SteadyChecksquareStatDetailVO detail = new SteadyChecksquareStatDetailVO();
detail.setStatType(statType);
detail.setSupported(true);
detail.setSegments(segments);
return detail;
}
private String buildItemKey(String lineId, SteadyTrendIndicatorDefinitionBO indicator, Integer harmonicOrder) {
if (harmonicOrder == null) {
return lineId + "|" + indicator.getIndicatorCode();
}
return lineId + "|" + indicator.getIndicatorCode() + "|" + harmonicOrder;
}
private void validateParam(SteadyChecksquareQueryParam param) {
if (param == null) {
throw fail("数据校验参数不能为空");
}
if (trimToNull(param.getLineId()) == null) {
throw fail("监测点 ID 不能为空");
}
if (normalizeTextList(param.getIndicatorCodes()).isEmpty()) {
throw fail("指标不能为空");
}
parseRequiredTime(param.getTimeStart(), "开始时间不能为空");
parseRequiredTime(param.getTimeEnd(), "结束时间不能为空");
}
private LocalDateTime parseRequiredTime(String time, String emptyMessage) {
String text = trimToNull(time);
if (text == null) {
throw fail(emptyMessage);
}
try {
return LocalDateTime.parse(text, TIME_FORMATTER);
} catch (DateTimeParseException ex) {
throw fail("时间格式不正确,仅支持 yyyy-MM-dd HH:mm:ss");
}
}
private AddLedgerLinePathVO requireLinePath(String lineId) {
Map<String, AddLedgerLinePathVO> linePathMap = addLedgerService.listLinePathByLineIds(Collections.singletonList(lineId));
AddLedgerLinePathVO linePath = linePathMap.get(lineId);
if (linePath == null) {
throw fail("监测点不存在或不可用");
}
return linePath;
}
private int resolveIntervalMinutes(AddLedgerLinePathVO linePath) {
Integer interval = linePath.getLineInterval();
if (interval == null || interval <= 0) {
return AddLedgerConst.LINE_INTERVAL_DEFAULT;
}
return interval;
}
private int resolveIndicatorIntervalMinutes(SteadyTrendIndicatorDefinitionBO indicator, int lineIntervalMinutes) {
String indicatorCode = indicator == null ? null : indicator.getIndicatorCode();
if ("FLUC".equals(indicatorCode) || "PST".equals(indicatorCode)) {
return FLICKER_SHORT_INTERVAL_MINUTES;
}
if ("PLT".equals(indicatorCode)) {
return FLICKER_LONG_INTERVAL_MINUTES;
}
return lineIntervalMinutes;
}
private SteadyTrendIndicatorDefinitionBO requireIndicator(String indicatorCode) {
SteadyTrendIndicatorDefinitionBO indicator = indicatorCatalog.getIndicator(indicatorCode);
if (indicator == null) {
throw fail("稳态指标不支持:" + indicatorCode);
}
return indicator;
}
private BigDecimal calculateRate(int missingCount, int expectedCount) {
if (expectedCount <= 0) {
return BigDecimal.ZERO.setScale(6, RoundingMode.HALF_UP);
}
return new BigDecimal(missingCount).divide(new BigDecimal(expectedCount), 6, RoundingMode.HALF_UP);
}
private String formatRateText(BigDecimal rate) {
if (rate == null) {
return null;
}
return rate.multiply(new BigDecimal("100")).setScale(2, RoundingMode.HALF_UP).toPlainString() + "%";
}
private List<String> normalizeTextList(List<String> values) {
if (values == null || values.isEmpty()) {
return new ArrayList<String>();
}
Set<String> result = new LinkedHashSet<String>();
for (String value : values) {
String text = trimToNull(value);
if (text != null) {
result.add(text);
}
}
return new ArrayList<String>(result);
}
private List<Integer> normalizeHarmonicOrders(List<Integer> values) {
if (values == null || values.isEmpty()) {
return new ArrayList<Integer>();
}
List<Integer> result = new ArrayList<Integer>();
for (Integer value : values) {
if (value != null && !result.contains(value)) {
result.add(value);
}
}
return result;
}
private List<Integer> requireValidHarmonicOrders(SteadyTrendIndicatorDefinitionBO indicator, List<Integer> harmonicOrders) {
if (harmonicOrders == null || harmonicOrders.isEmpty()) {
throw fail("谐波次数不能为空");
}
for (Integer order : harmonicOrders) {
if (order < indicator.getHarmonicOrderStart() || order > indicator.getHarmonicOrderEnd()) {
throw fail("谐波次数只能在 " + indicator.getHarmonicOrderStart() + "" + indicator.getHarmonicOrderEnd() + " 之间");
}
}
return harmonicOrders;
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private BusinessException fail(String message) {
return new BusinessException(CommonResponseEnum.FAIL, message);
}
}

View File

@@ -8,6 +8,7 @@ import com.njcn.gather.steady.datavie.config.SteadyInfluxDbProperties;
import com.njcn.gather.steady.datavie.pojo.bo.SteadyTrendResolvedFieldBO; import com.njcn.gather.steady.datavie.pojo.bo.SteadyTrendResolvedFieldBO;
import com.njcn.gather.steady.datavie.pojo.vo.SteadyTrendPointVO; import com.njcn.gather.steady.datavie.pojo.vo.SteadyTrendPointVO;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.io.BufferedReader; import java.io.BufferedReader;
@@ -29,6 +30,7 @@ import java.util.List;
/** /**
* 稳态趋势 InfluxDB 查询组件。 * 稳态趋势 InfluxDB 查询组件。
*/ */
@Slf4j
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class SteadyInfluxQueryComponent { public class SteadyInfluxQueryComponent {
@@ -40,34 +42,53 @@ public class SteadyInfluxQueryComponent {
private final SteadyInfluxDbProperties properties; private final SteadyInfluxDbProperties properties;
public List<SteadyTrendPointVO> queryTrendPoints(SteadyTrendResolvedFieldBO field, LocalDateTime startTime, public List<SteadyTrendPointVO> queryTrendPoints(SteadyTrendResolvedFieldBO field, LocalDateTime startTime,
LocalDateTime endTime, String bucket, Integer qualityFlag) { LocalDateTime endTime, Integer qualityFlag) {
validateConfig(); validateConfig();
String query = buildTrendQuery(field, startTime, endTime, bucket, qualityFlag); String query = buildTrendQuery(field, startTime, endTime, qualityFlag);
String body = executeQuery(query); String diagnostic = buildTrendQueryDiagnostic(field, startTime, endTime, qualityFlag);
return parseTrendPoints(body); long startMillis = System.currentTimeMillis();
log.info("稳态趋势 InfluxDB 查询开始,{}query={}", diagnostic, query);
try {
String body = executeQuery(query);
List<SteadyTrendPointVO> points = parseTrendPoints(body);
log.info("稳态趋势 InfluxDB 查询结束,{}pointCount={}costMs={}", diagnostic, points.size(), System.currentTimeMillis() - startMillis);
return points;
} catch (RuntimeException ex) {
log.warn("稳态趋势 InfluxDB 查询异常,{}costMs={}error={}", diagnostic, System.currentTimeMillis() - startMillis, ex.getMessage());
throw ex;
}
}
String buildTrendQueryDiagnostic(SteadyTrendResolvedFieldBO field, LocalDateTime startTime, LocalDateTime endTime,
Integer qualityFlag) {
StringBuilder diagnostic = new StringBuilder();
diagnostic.append("measurement=").append(field.getMeasurement());
diagnostic.append(", field=").append(field.getField());
diagnostic.append(", lineId=").append(field.getLineId());
diagnostic.append(", phase=").append(field.getPhase());
diagnostic.append(", statType=").append(resolveValueType(field.getStatType()));
diagnostic.append(", qualityFlag=").append(qualityFlag);
diagnostic.append(", timeStart=").append(startTime);
diagnostic.append(", timeEnd=").append(endTime);
return diagnostic.toString();
} }
public String buildTrendQuery(SteadyTrendResolvedFieldBO field, LocalDateTime startTime, LocalDateTime endTime, public String buildTrendQuery(SteadyTrendResolvedFieldBO field, LocalDateTime startTime, LocalDateTime endTime,
String bucket, Integer qualityFlag) { Integer qualityFlag) {
StringBuilder sql = new StringBuilder(); StringBuilder sql = new StringBuilder();
if (bucket == null || bucket.trim().isEmpty()) { sql.append("SELECT \"").append(field.getField()).append("\" AS \"value\"");
sql.append("SELECT \"").append(field.getField()).append("\" AS \"value\"");
} else {
sql.append("SELECT mean(\"").append(field.getField()).append("\") AS \"value\"");
}
sql.append(" FROM \"").append(field.getMeasurement()).append("\""); sql.append(" FROM \"").append(field.getMeasurement()).append("\"");
sql.append(" WHERE time >= '").append(INFLUX_TIME_FORMATTER.format(startTime)).append("'"); sql.append(" WHERE time >= '").append(INFLUX_TIME_FORMATTER.format(startTime)).append("'");
sql.append(" AND time <= '").append(INFLUX_TIME_FORMATTER.format(endTime)).append("'"); sql.append(" AND time <= '").append(INFLUX_TIME_FORMATTER.format(endTime)).append("'");
sql.append(" AND \"LINEID\" = '").append(escapeTagValue(field.getLineId())).append("'"); sql.append(" AND \"line_id\" = '").append(escapeTagValue(field.getLineId())).append("'");
sql.append(" AND \"PHASIC_TYPE\" = '").append(escapeTagValue(field.getPhase())).append("'"); sql.append(" AND \"phasic_type\" = '").append(escapeTagValue(field.getPhase())).append("'");
if (hasValueTypeTag(field.getMeasurement())) {
sql.append(" AND \"value_type\" = '").append(resolveValueType(field.getStatType())).append("'");
}
if (qualityFlag != null) { if (qualityFlag != null) {
sql.append(" AND \"QUALITYFLAG\" = '").append(qualityFlag).append("'"); sql.append(" AND \"quality_flag\" = '").append(qualityFlag).append("'");
}
if (bucket != null && !bucket.trim().isEmpty()) {
sql.append(" GROUP BY time(").append(bucket.trim()).append(") fill(none)");
} else {
sql.append(" ORDER BY time ASC");
} }
sql.append(" ORDER BY time ASC");
return sql.toString(); return sql.toString();
} }
@@ -165,6 +186,17 @@ public class SteadyInfluxQueryComponent {
return value == null ? "" : value.replace("\\", "\\\\").replace("'", "\\'"); return value == null ? "" : value.replace("\\", "\\\\").replace("'", "\\'");
} }
private String resolveValueType(String statType) {
if (statType == null || statType.trim().isEmpty()) {
return "AVG";
}
return statType.trim().toUpperCase();
}
private boolean hasValueTypeTag(String measurement) {
return !"data_flicker".equals(measurement) && !"data_fluc".equals(measurement) && !"data_plt".equals(measurement);
}
private String trimRightSlash(String value) { private String trimRightSlash(String value) {
String text = value.trim(); String text = value.trim();
while (text.endsWith("/")) { while (text.endsWith("/")) {

View File

@@ -6,8 +6,6 @@ import com.njcn.gather.steady.datavie.pojo.bo.SteadyTrendIndicatorDefinitionBO;
import com.njcn.gather.steady.datavie.pojo.bo.SteadyTrendResolvedFieldBO; import com.njcn.gather.steady.datavie.pojo.bo.SteadyTrendResolvedFieldBO;
import com.njcn.gather.steady.datavie.pojo.bo.SteadyTrendSeriesFieldBO; import com.njcn.gather.steady.datavie.pojo.bo.SteadyTrendSeriesFieldBO;
import com.njcn.gather.steady.datavie.pojo.param.SteadyTrendQueryParam; import com.njcn.gather.steady.datavie.pojo.param.SteadyTrendQueryParam;
import com.njcn.gather.tool.adddata.component.AddDataTableRegistry;
import com.njcn.gather.tool.adddata.pojo.bo.AddDataTableDefinition;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -28,17 +26,15 @@ public class SteadyTrendFieldResolver {
private static final int MAX_LINE_COUNT = 8; private static final int MAX_LINE_COUNT = 8;
private static final int MAX_INDICATOR_COUNT = 8; private static final int MAX_INDICATOR_COUNT = 8;
private static final int MAX_SERIES_COUNT = 24; private static final int MAX_SERIES_COUNT = 24;
private static final int MAX_HARMONIC_ORDER_COUNT = 6; private static final int MAX_HARMONIC_ORDER_COUNT = 3;
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final SteadyTrendIndicatorCatalog indicatorCatalog; private final SteadyTrendIndicatorCatalog indicatorCatalog;
private final AddDataTableRegistry addDataTableRegistry;
public List<SteadyTrendResolvedFieldBO> resolveFields(SteadyTrendQueryParam param) { public List<SteadyTrendResolvedFieldBO> resolveFields(SteadyTrendQueryParam param) {
validateBasicParam(param); validateBasicParam(param);
List<String> lineIds = normalizeTextList(param.getLineIds()); List<String> lineIds = normalizeTextList(param.getLineIds());
List<String> indicatorCodes = normalizeTextList(param.getIndicatorCodes()); List<String> indicatorCodes = normalizeTextList(param.getIndicatorCodes());
List<String> requestPhases = normalizeUpperList(param.getPhases());
List<String> statTypes = normalizeUpperList(param.getStatTypes()); List<String> statTypes = normalizeUpperList(param.getStatTypes());
if (statTypes.isEmpty()) { if (statTypes.isEmpty()) {
statTypes.add("AVG"); statTypes.add("AVG");
@@ -47,7 +43,7 @@ public class SteadyTrendFieldResolver {
for (String lineId : lineIds) { for (String lineId : lineIds) {
for (String indicatorCode : indicatorCodes) { for (String indicatorCode : indicatorCodes) {
SteadyTrendIndicatorDefinitionBO indicator = requireIndicator(indicatorCode); SteadyTrendIndicatorDefinitionBO indicator = requireIndicator(indicatorCode);
List<String> phases = resolvePhases(indicator, requestPhases); List<String> phases = resolvePhases(indicator);
for (String phase : phases) { for (String phase : phases) {
for (String statType : statTypes) { for (String statType : statTypes) {
validateStatType(indicator, statType); validateStatType(indicator, statType);
@@ -57,7 +53,7 @@ public class SteadyTrendFieldResolver {
} }
} }
if (result.size() > MAX_SERIES_COUNT) { if (result.size() > MAX_SERIES_COUNT) {
throw fail("趋势曲线数量不能超过 24 条,请缩小监测点、指标、相别或统计类型范围"); throw fail("趋势曲线数量不能超过 24 条,请缩小监测点、指标或统计类型范围");
} }
return result; return result;
} }
@@ -81,9 +77,7 @@ public class SteadyTrendFieldResolver {
} }
List<SteadyTrendResolvedFieldBO> result = new ArrayList<SteadyTrendResolvedFieldBO>(); List<SteadyTrendResolvedFieldBO> result = new ArrayList<SteadyTrendResolvedFieldBO>();
for (SteadyTrendSeriesFieldBO seriesField : indicator.getSeriesFields()) { for (SteadyTrendSeriesFieldBO seriesField : indicator.getSeriesFields()) {
String field = resolveStatField(seriesField.getField(), statType); result.add(buildResolvedField(lineId, indicator, phase, statType, seriesField.getField(), seriesField.getName()));
validateColumn(indicator.getTableName(), field);
result.add(buildResolvedField(lineId, indicator, phase, statType, field, seriesField.getName()));
} }
return result; return result;
} }
@@ -95,16 +89,14 @@ public class SteadyTrendFieldResolver {
throw fail("谐波次数不能为空"); throw fail("谐波次数不能为空");
} }
if (orders.size() > MAX_HARMONIC_ORDER_COUNT) { if (orders.size() > MAX_HARMONIC_ORDER_COUNT) {
throw fail("谐波次数最多选择 6 "); throw fail("谐波次数不允许一次展示超过3");
} }
List<SteadyTrendResolvedFieldBO> result = new ArrayList<SteadyTrendResolvedFieldBO>(); List<SteadyTrendResolvedFieldBO> result = new ArrayList<SteadyTrendResolvedFieldBO>();
for (Integer order : orders) { for (Integer order : orders) {
if (order < indicator.getHarmonicOrderStart() || order > indicator.getHarmonicOrderEnd()) { if (order < indicator.getHarmonicOrderStart() || order > indicator.getHarmonicOrderEnd()) {
throw fail("谐波次数只能在 " + indicator.getHarmonicOrderStart() + "" + indicator.getHarmonicOrderEnd() + " 之间"); throw fail("谐波次数只能在 " + indicator.getHarmonicOrderStart() + "" + indicator.getHarmonicOrderEnd() + " 之间");
} }
String baseField = indicator.getHarmonicFieldPrefix() + "_" + order; String field = indicator.getHarmonicFieldPrefix() + "_" + order;
String field = resolveStatField(baseField, statType);
validateColumn(indicator.getTableName(), field);
result.add(buildResolvedField(lineId, indicator, phase, statType, field, order + "" + indicator.getName())); result.add(buildResolvedField(lineId, indicator, phase, statType, field, order + "" + indicator.getName()));
} }
return result; return result;
@@ -165,23 +157,8 @@ public class SteadyTrendFieldResolver {
return indicator; return indicator;
} }
private List<String> resolvePhases(SteadyTrendIndicatorDefinitionBO indicator, List<String> requestPhases) { private List<String> resolvePhases(SteadyTrendIndicatorDefinitionBO indicator) {
if (requestPhases.isEmpty()) { return new ArrayList<String>(indicator.getPhaseCodes());
return new ArrayList<String>(indicator.getPhaseCodes());
}
List<String> result = new ArrayList<String>();
for (String phase : requestPhases) {
if (!"A".equals(phase) && !"B".equals(phase) && !"C".equals(phase) && !"T".equals(phase)) {
throw fail("相别只能是 A、B、C、T");
}
if (indicator.getPhaseCodes().contains(phase) && !result.contains(phase)) {
result.add(phase);
}
}
if (result.isEmpty()) {
throw fail("指标 " + indicator.getIndicatorCode() + " 不支持当前相别");
}
return result;
} }
private void validateStatType(SteadyTrendIndicatorDefinitionBO indicator, String statType) { private void validateStatType(SteadyTrendIndicatorDefinitionBO indicator, String statType) {
@@ -193,25 +170,6 @@ public class SteadyTrendFieldResolver {
} }
} }
private String resolveStatField(String baseField, String statType) {
if ("AVG".equals(statType)) {
return baseField;
}
return baseField + "_" + statType;
}
private void validateColumn(String tableName, String field) {
AddDataTableDefinition definition;
try {
definition = addDataTableRegistry.getDefinition(tableName);
} catch (IllegalArgumentException ex) {
throw fail("稳态数据表不支持:" + tableName);
}
if (!definition.getColumns().contains(field)) {
throw fail("稳态趋势字段不支持:" + tableName + "." + field);
}
}
private List<String> normalizeTextList(List<String> values) { private List<String> normalizeTextList(List<String> values) {
if (values == null || values.isEmpty()) { if (values == null || values.isEmpty()) {
return new ArrayList<String>(); return new ArrayList<String>();

View File

@@ -28,32 +28,31 @@ public class SteadyTrendIndicatorCatalog {
public SteadyTrendIndicatorCatalog() { public SteadyTrendIndicatorCatalog() {
List<SteadyTrendIndicatorDefinitionBO> result = new ArrayList<SteadyTrendIndicatorDefinitionBO>(); List<SteadyTrendIndicatorDefinitionBO> result = new ArrayList<SteadyTrendIndicatorDefinitionBO>();
result.add(indicator("V_RMS", "相电压有效值", "VOLTAGE", "电压趋势", "data_v", ABC_PHASES, result.add(indicator("V_RMS", "相电压有效值", "VOLTAGE", "电压趋势", "data_v", ABC_PHASES,
fields(field("RMS", "相电压有效值")), FULL_STATS, "V")); fields(field("rms", "相电压有效值")), FULL_STATS, "V"));
result.add(indicator("V_LINE_RMS", "线电压有效值", "VOLTAGE", "电压趋势", "data_v", T_PHASE, result.add(indicator("V_LINE_RMS", "线电压有效值", "VOLTAGE", "电压趋势", "data_v", ABC_PHASES,
fields(field("RMSAB", "AB线电压"), field("RMSBC", "BC线电压"), field("RMSCA", "CA线电压")), fields(field("rms_lvr", "线电压有效值")),
FULL_STATS, "V")); FULL_STATS, "V"));
result.add(indicator("FREQ", "频率", "FREQUENCY", "频率趋势", "data_v", T_PHASE, result.add(indicator("FREQ", "频率", "FREQUENCY", "频率趋势", "data_v", T_PHASE,
fields(field("FREQ", "频率")), FULL_STATS, "Hz")); fields(field("freq", "频率")), FULL_STATS, "Hz"));
result.add(indicator("V_THD", "电压总谐波畸变率", "HARMONIC", "谐波趋势", "data_v", ABC_PHASES, result.add(indicator("V_THD", "电压总谐波畸变率", "HARMONIC", "谐波趋势", "data_v", ABC_PHASES,
fields(field("V_THD", "电压总谐波畸变率")), FULL_STATS, "%")); fields(field("v_thd", "电压总谐波畸变率")), FULL_STATS, "%"));
result.add(indicator("I_RMS", "电流有效值", "CURRENT", "电流趋势", "data_i", ABC_PHASES, result.add(indicator("I_RMS", "电流有效值", "CURRENT", "电流趋势", "data_i", ABC_PHASES,
fields(field("RMS", "电流有效值")), FULL_STATS, "A")); fields(field("rms", "电流有效值")), FULL_STATS, "A"));
result.add(indicator("I_THD", "电流总谐波畸变率", "HARMONIC", "谐波趋势", "data_i", ABC_PHASES, result.add(indicator("I_THD", "电流总谐波畸变率", "HARMONIC", "谐波趋势", "data_i", ABC_PHASES,
fields(field("I_THD", "电流总谐波畸变率")), FULL_STATS, "%")); fields(field("i_thd", "电流总谐波畸变率")), FULL_STATS, "%"));
result.add(harmonic("V_HARMONIC", "电压谐波幅值", "data_harmphasic_v", "V", "V")); result.add(harmonic("V_HARMONIC", "电压谐波幅值", "data_harmphasic_v", "v", "V"));
result.add(harmonic("I_HARMONIC", "电流谐波幅值", "data_harmphasic_i", "I", "A")); result.add(harmonic("I_HARMONIC", "电流谐波幅值", "data_harmphasic_i", "i", "A"));
result.add(harmonic("V_HARMONIC_RATE", "电压谐波含有率", "data_harmrate_v", "V", "%")); result.add(harmonic("V_HARMONIC_RATE", "电压谐波含有率", "data_harmrate_v", "v", "%"));
result.add(harmonic("I_HARMONIC_RATE", "电流谐波含有率", "data_harmrate_i", "I", "%")); result.add(harmonic("I_INTER_HARMONIC", "间谐波电流", "data_inharm_i", "i", "A"));
result.add(harmonic("I_INTER_HARMONIC", "间谐波电流", "data_inharm_i", "I", "A")); result.add(harmonicPower("P_HARMONIC_POWER", "有功谐波功率", "data_harmpower_p", "p", "kW"));
result.add(harmonicPower("P_HARMONIC_POWER", "功谐波功率", "data_harmpower_p", "P", "kW")); result.add(harmonicPower("Q_HARMONIC_POWER", "功谐波功率", "data_harmpower_q", "q", "kvar"));
result.add(harmonicPower("Q_HARMONIC_POWER", "无功谐波功率", "data_harmpower_q", "Q", "kvar")); result.add(harmonicPower("S_HARMONIC_POWER", "视在谐波功率", "data_harmpower_s", "s", "kVA"));
result.add(harmonicPower("S_HARMONIC_POWER", "视在谐波功率", "data_harmpower_s", "S", "kVA"));
result.add(indicator("FLUC", "电压波动", "FLICKER", "闪变趋势", "data_fluc", T_PHASE, result.add(indicator("FLUC", "电压波动", "FLICKER", "闪变趋势", "data_fluc", T_PHASE,
fields(field("FLUC", "电压波动")), AVG_ONLY, "%")); fields(field("fluc", "电压波动")), AVG_ONLY, "%"));
result.add(indicator("PST", "短时闪变", "FLICKER", "闪变趋势", "data_flicker", T_PHASE, result.add(indicator("PST", "短时闪变", "FLICKER", "闪变趋势", "data_flicker", T_PHASE,
fields(field("PST", "短时闪变")), AVG_ONLY, "")); fields(field("pst", "短时闪变")), AVG_ONLY, ""));
result.add(indicator("PLT", "长时闪变", "FLICKER", "闪变趋势", "data_plt", T_PHASE, result.add(indicator("PLT", "长时闪变", "FLICKER", "闪变趋势", "data_plt", T_PHASE,
fields(field("PLT", "长时闪变")), AVG_ONLY, "")); fields(field("plt", "长时闪变")), AVG_ONLY, ""));
indicators = Collections.unmodifiableList(result); indicators = Collections.unmodifiableList(result);
Map<String, SteadyTrendIndicatorDefinitionBO> map = new LinkedHashMap<String, SteadyTrendIndicatorDefinitionBO>(); Map<String, SteadyTrendIndicatorDefinitionBO> map = new LinkedHashMap<String, SteadyTrendIndicatorDefinitionBO>();

View File

@@ -1,13 +1,11 @@
package com.njcn.gather.steady.datavie.controller; package com.njcn.gather.steady.datavie.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.njcn.common.pojo.annotation.OperateInfo; import com.njcn.common.pojo.annotation.OperateInfo;
import com.njcn.common.pojo.enums.common.LogEnum; import com.njcn.common.pojo.enums.common.LogEnum;
import com.njcn.common.pojo.enums.response.CommonResponseEnum; import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.response.HttpResult; import com.njcn.common.pojo.response.HttpResult;
import com.njcn.common.utils.LogUtil; import com.njcn.common.utils.LogUtil;
import com.njcn.gather.steady.datavie.pojo.param.SteadyDataViewDetailParam; import com.njcn.gather.steady.datavie.pojo.param.SteadyDataViewDetailParam;
import com.njcn.gather.steady.datavie.pojo.param.SteadyDataViewQueryParam;
import com.njcn.gather.steady.datavie.pojo.vo.SteadyDataViewTemplateVO; import com.njcn.gather.steady.datavie.pojo.vo.SteadyDataViewTemplateVO;
import com.njcn.gather.steady.datavie.pojo.vo.SteadyDataViewVO; import com.njcn.gather.steady.datavie.pojo.vo.SteadyDataViewVO;
import com.njcn.gather.steady.datavie.service.SteadyDataViewService; import com.njcn.gather.steady.datavie.service.SteadyDataViewService;
@@ -40,16 +38,6 @@ public class SteadyDataViewController extends BaseController {
/** 稳态数据查看服务。 */ /** 稳态数据查看服务。 */
private final SteadyDataViewService steadyDataViewService; private final SteadyDataViewService steadyDataViewService;
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("分页查询稳态数据")
@PostMapping("/page")
public HttpResult<Page<SteadyDataViewVO>> pageSteadyData(@RequestBody SteadyDataViewQueryParam param) {
String methodDescribe = getMethodDescribe("pageSteadyData");
LogUtil.njcnDebug(log, "{}开始分页查询稳态数据param={}", methodDescribe, param);
Page<SteadyDataViewVO> result = steadyDataViewService.pageSteadyData(param);
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS, result, methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON) @OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("查询稳态数据详情") @ApiOperation("查询稳态数据详情")
@PostMapping("/detail") @PostMapping("/detail")

View File

@@ -1,7 +1,5 @@
package com.njcn.gather.steady.datavie.mapper; package com.njcn.gather.steady.datavie.mapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.njcn.gather.steady.datavie.pojo.param.SteadyDataViewQueryParam;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import java.util.List; import java.util.List;
@@ -12,11 +10,6 @@ import java.util.Map;
*/ */
public interface SteadyDataViewMapper { public interface SteadyDataViewMapper {
Page<Map<String, Object>> selectSteadyPage(Page<Map<String, Object>> page,
@Param("tableName") String tableName,
@Param("columns") List<String> columns,
@Param("param") SteadyDataViewQueryParam param);
Map<String, Object> selectSteadyDetail(@Param("tableName") String tableName, Map<String, Object> selectSteadyDetail(@Param("tableName") String tableName,
@Param("columns") List<String> columns, @Param("columns") List<String> columns,
@Param("lineId") String lineId, @Param("lineId") String lineId,

View File

@@ -3,39 +3,6 @@
"http://mybatis.org/dtd/mybatis-3-mapper.dtd"> "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.njcn.gather.steady.datavie.mapper.SteadyDataViewMapper"> <mapper namespace="com.njcn.gather.steady.datavie.mapper.SteadyDataViewMapper">
<sql id="SteadyDataWhere">
<where>
<if test="param.timeStart != null and param.timeStart != ''">
AND TIMEID &gt;= #{param.timeStart}
</if>
<if test="param.timeEnd != null and param.timeEnd != ''">
AND TIMEID &lt;= #{param.timeEnd}
</if>
<if test="param.phasicType != null and param.phasicType != ''">
AND PHASIC_TYPE = #{param.phasicType}
</if>
<if test="param.qualityFlag != null">
AND QUALITYFLAG = #{param.qualityFlag}
</if>
<if test="param.lineIds != null and param.lineIds.size() &gt; 0">
AND LINEID IN
<foreach collection="param.lineIds" item="lineId" open="(" separator="," close=")">
#{lineId}
</foreach>
</if>
</where>
</sql>
<select id="selectSteadyPage" resultType="java.util.LinkedHashMap">
SELECT
<foreach collection="columns" item="column" separator=",">
`${column}`
</foreach>
FROM `${tableName}`
<include refid="SteadyDataWhere"/>
ORDER BY TIMEID DESC, LINEID ASC, PHASIC_TYPE ASC
</select>
<select id="selectSteadyDetail" resultType="java.util.LinkedHashMap"> <select id="selectSteadyDetail" resultType="java.util.LinkedHashMap">
SELECT SELECT
<foreach collection="columns" item="column" separator=","> <foreach collection="columns" item="column" separator=",">

View File

@@ -1,49 +0,0 @@
package com.njcn.gather.steady.datavie.pojo.param;
import com.njcn.web.pojo.param.BaseParam;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.ArrayList;
import java.util.List;
/**
* 稳态数据查看查询参数。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@ApiModel("稳态数据查看查询参数")
public class SteadyDataViewQueryParam extends BaseParam {
@ApiModelProperty("表名,对应 add-data 模板表名")
private String tableName;
@ApiModelProperty("时间开始,格式 yyyy-MM-dd HH:mm:ss")
private String timeStart;
@ApiModelProperty("时间结束,格式 yyyy-MM-dd HH:mm:ss")
private String timeEnd;
@ApiModelProperty("相别A/B/C/T")
private String phasicType;
@ApiModelProperty("质量标识")
private Integer qualityFlag;
@ApiModelProperty("监测点 ID 列表")
private List<String> lineIds = new ArrayList<String>();
@ApiModelProperty("工程名称关键字")
private String engineeringName;
@ApiModelProperty("项目名称关键字")
private String projectName;
@ApiModelProperty("设备名称关键字")
private String equipmentName;
@ApiModelProperty("监测点名称关键字")
private String lineName;
}

View File

@@ -23,21 +23,15 @@ public class SteadyTrendQueryParam {
@ApiModelProperty("统计类型AVG/MAX/MIN/CP95") @ApiModelProperty("统计类型AVG/MAX/MIN/CP95")
private List<String> statTypes = new ArrayList<String>(); private List<String> statTypes = new ArrayList<String>();
@ApiModelProperty("相别A/B/C/T")
private List<String> phases = new ArrayList<String>();
@ApiModelProperty("开始时间,格式 yyyy-MM-dd HH:mm:ss") @ApiModelProperty("开始时间,格式 yyyy-MM-dd HH:mm:ss")
private String timeStart; private String timeStart;
@ApiModelProperty("结束时间,格式 yyyy-MM-dd HH:mm:ss") @ApiModelProperty("结束时间,格式 yyyy-MM-dd HH:mm:ss")
private String timeEnd; private String timeEnd;
@ApiModelProperty("分桶粒度,如 1m、5m、10m、30m、1h")
private String bucket;
@ApiModelProperty("质量标识") @ApiModelProperty("质量标识")
private Integer qualityFlag; private Integer qualityFlag;
@ApiModelProperty("谐波次数,谐波指标必填,最多 6") @ApiModelProperty("谐波次数,谐波指标必填,默认最多展示 3")
private List<Integer> harmonicOrders = new ArrayList<Integer>(); private List<Integer> harmonicOrders = new ArrayList<Integer>();
} }

View File

@@ -18,8 +18,6 @@ public class SteadyTrendQueryVO implements Serializable {
private Boolean sampled; private Boolean sampled;
private String bucket;
private Integer sourcePointCount; private Integer sourcePointCount;
private Integer displayPointCount; private Integer displayPointCount;

View File

@@ -1,8 +1,6 @@
package com.njcn.gather.steady.datavie.service; package com.njcn.gather.steady.datavie.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.njcn.gather.steady.datavie.pojo.param.SteadyDataViewDetailParam; import com.njcn.gather.steady.datavie.pojo.param.SteadyDataViewDetailParam;
import com.njcn.gather.steady.datavie.pojo.param.SteadyDataViewQueryParam;
import com.njcn.gather.steady.datavie.pojo.vo.SteadyDataViewTemplateVO; import com.njcn.gather.steady.datavie.pojo.vo.SteadyDataViewTemplateVO;
import com.njcn.gather.steady.datavie.pojo.vo.SteadyDataViewVO; import com.njcn.gather.steady.datavie.pojo.vo.SteadyDataViewVO;
@@ -13,8 +11,6 @@ import java.util.List;
*/ */
public interface SteadyDataViewService { public interface SteadyDataViewService {
Page<SteadyDataViewVO> pageSteadyData(SteadyDataViewQueryParam param);
SteadyDataViewVO getSteadyDataDetail(SteadyDataViewDetailParam param); SteadyDataViewVO getSteadyDataDetail(SteadyDataViewDetailParam param);
List<SteadyDataViewTemplateVO> listTemplates(); List<SteadyDataViewTemplateVO> listTemplates();

View File

@@ -1,11 +1,9 @@
package com.njcn.gather.steady.datavie.service.impl; package com.njcn.gather.steady.datavie.service.impl;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.njcn.common.pojo.enums.response.CommonResponseEnum; import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException; import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.gather.steady.datavie.mapper.SteadyDataViewMapper; import com.njcn.gather.steady.datavie.mapper.SteadyDataViewMapper;
import com.njcn.gather.steady.datavie.pojo.param.SteadyDataViewDetailParam; import com.njcn.gather.steady.datavie.pojo.param.SteadyDataViewDetailParam;
import com.njcn.gather.steady.datavie.pojo.param.SteadyDataViewQueryParam;
import com.njcn.gather.steady.datavie.pojo.vo.SteadyDataViewTemplateVO; import com.njcn.gather.steady.datavie.pojo.vo.SteadyDataViewTemplateVO;
import com.njcn.gather.steady.datavie.pojo.vo.SteadyDataViewVO; import com.njcn.gather.steady.datavie.pojo.vo.SteadyDataViewVO;
import com.njcn.gather.steady.datavie.service.SteadyDataViewService; import com.njcn.gather.steady.datavie.service.SteadyDataViewService;
@@ -13,12 +11,9 @@ import com.njcn.gather.tool.adddata.component.AddDataTableRegistry;
import com.njcn.gather.tool.adddata.component.AddDataTemplateRegistry; import com.njcn.gather.tool.adddata.component.AddDataTemplateRegistry;
import com.njcn.gather.tool.adddata.pojo.bo.AddDataTableDefinition; import com.njcn.gather.tool.adddata.pojo.bo.AddDataTableDefinition;
import com.njcn.gather.tool.adddata.pojo.vo.AddDataTemplateVO; import com.njcn.gather.tool.adddata.pojo.vo.AddDataTemplateVO;
import com.njcn.gather.tool.addledger.pojo.param.AddLedgerLinePathQueryParam;
import com.njcn.gather.tool.addledger.pojo.vo.AddLedgerLinePathVO; import com.njcn.gather.tool.addledger.pojo.vo.AddLedgerLinePathVO;
import com.njcn.gather.tool.addledger.service.AddLedgerService; import com.njcn.gather.tool.addledger.service.AddLedgerService;
import com.njcn.web.factory.PageFactory;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.sql.Timestamp; import java.sql.Timestamp;
@@ -34,13 +29,10 @@ import java.util.Map;
/** /**
* 稳态数据查看服务实现。 * 稳态数据查看服务实现。
*/ */
@Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class SteadyDataViewServiceImpl implements SteadyDataViewService { public class SteadyDataViewServiceImpl implements SteadyDataViewService {
private static final int LEDGER_LINE_QUERY_LIMIT = 1000;
private static final int STEADY_LINE_ID_QUERY_LIMIT = 1000;
private static final String DEFAULT_TABLE_NAME = "data_v"; private static final String DEFAULT_TABLE_NAME = "data_v";
private static final String EMPTY_TEXT = "-"; private static final String EMPTY_TEXT = "-";
private static final DateTimeFormatter OUTPUT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private static final DateTimeFormatter OUTPUT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@@ -56,22 +48,6 @@ public class SteadyDataViewServiceImpl implements SteadyDataViewService {
private final AddDataTableRegistry addDataTableRegistry; private final AddDataTableRegistry addDataTableRegistry;
private final AddDataTemplateRegistry addDataTemplateRegistry; private final AddDataTemplateRegistry addDataTemplateRegistry;
@Override
public Page<SteadyDataViewVO> pageSteadyData(SteadyDataViewQueryParam param) {
SteadyDataViewQueryParam queryParam = normalizeQueryParam(param);
AddDataTableDefinition definition = resolveTableDefinition(queryParam.getTableName());
if (!resolveLineFilter(queryParam)) {
return emptyPage(queryParam);
}
Page<Map<String, Object>> steadyPage = steadyDataViewMapper.selectSteadyPage(
new Page<Map<String, Object>>(PageFactory.getPageNum(queryParam), PageFactory.getPageSize(queryParam)),
definition.getTableName(), definition.getColumns(), queryParam);
List<SteadyDataViewVO> records = buildSteadyDataList(definition.getTableName(), definition.getColumns(), steadyPage.getRecords());
Page<SteadyDataViewVO> resultPage = new Page<SteadyDataViewVO>(steadyPage.getCurrent(), steadyPage.getSize(), steadyPage.getTotal());
resultPage.setRecords(records);
return resultPage;
}
@Override @Override
public SteadyDataViewVO getSteadyDataDetail(SteadyDataViewDetailParam param) { public SteadyDataViewVO getSteadyDataDetail(SteadyDataViewDetailParam param) {
if (param == null) { if (param == null) {
@@ -156,69 +132,6 @@ public class SteadyDataViewServiceImpl implements SteadyDataViewService {
return vo; return vo;
} }
private SteadyDataViewQueryParam normalizeQueryParam(SteadyDataViewQueryParam param) {
SteadyDataViewQueryParam queryParam = param == null ? new SteadyDataViewQueryParam() : param;
queryParam.setTableName(normalizeTableName(queryParam.getTableName()));
LocalDateTime startTime = parseDateTime(queryParam.getTimeStart());
LocalDateTime endTime = parseDateTime(queryParam.getTimeEnd());
if (startTime == null) {
LocalDateTime now = LocalDateTime.now();
startTime = LocalDateTime.of(now.getYear(), now.getMonth(), 1, 0, 0, 0);
}
if (endTime == null) {
endTime = LocalDateTime.now();
}
if (startTime.isAfter(endTime)) {
throw new BusinessException(CommonResponseEnum.FAIL, "开始时间不能大于结束时间");
}
queryParam.setTimeStart(OUTPUT_FORMATTER.format(startTime));
queryParam.setTimeEnd(OUTPUT_FORMATTER.format(endTime));
queryParam.setPhasicType(normalizePhasicType(queryParam.getPhasicType()));
validateQualityFlag(queryParam.getQualityFlag());
queryParam.setEngineeringName(trimToNull(queryParam.getEngineeringName()));
queryParam.setProjectName(trimToNull(queryParam.getProjectName()));
queryParam.setEquipmentName(trimToNull(queryParam.getEquipmentName()));
queryParam.setLineName(trimToNull(queryParam.getLineName()));
List<String> lineIds = normalizeIds(queryParam.getLineIds());
if (lineIds.size() > STEADY_LINE_ID_QUERY_LIMIT) {
throw new BusinessException(CommonResponseEnum.FAIL, "监测点 ID 查询数量不能超过 1000 个");
}
queryParam.setLineIds(lineIds);
return queryParam;
}
private boolean resolveLineFilter(SteadyDataViewQueryParam queryParam) {
if (!hasLedgerKeyword(queryParam)) {
return true;
}
AddLedgerLinePathQueryParam linePathQueryParam = new AddLedgerLinePathQueryParam();
linePathQueryParam.setEngineeringName(queryParam.getEngineeringName());
linePathQueryParam.setProjectName(queryParam.getProjectName());
linePathQueryParam.setEquipmentName(queryParam.getEquipmentName());
linePathQueryParam.setLineName(queryParam.getLineName());
linePathQueryParam.setLimit(LEDGER_LINE_QUERY_LIMIT + 1);
List<String> ledgerLineIds = addLedgerService.listLineIdsByPathQuery(linePathQueryParam);
if (ledgerLineIds.size() > LEDGER_LINE_QUERY_LIMIT) {
throw new BusinessException(CommonResponseEnum.FAIL, "台账检索匹配监测点过多,请缩小查询条件");
}
if (ledgerLineIds.isEmpty()) {
return false;
}
List<String> explicitLineIds = normalizeIds(queryParam.getLineIds());
if (explicitLineIds.isEmpty()) {
queryParam.setLineIds(ledgerLineIds);
return true;
}
List<String> intersectLineIds = new ArrayList<String>();
for (String lineId : explicitLineIds) {
if (ledgerLineIds.contains(lineId)) {
intersectLineIds.add(lineId);
}
}
queryParam.setLineIds(intersectLineIds);
return !intersectLineIds.isEmpty();
}
private AddDataTableDefinition resolveTableDefinition(String tableName) { private AddDataTableDefinition resolveTableDefinition(String tableName) {
try { try {
return addDataTableRegistry.getDefinition(tableName); return addDataTableRegistry.getDefinition(tableName);
@@ -267,39 +180,6 @@ public class SteadyDataViewServiceImpl implements SteadyDataViewService {
return normalized; return normalized;
} }
private void validateQualityFlag(Integer qualityFlag) {
if (qualityFlag != null && qualityFlag != 0 && qualityFlag != 1) {
throw new BusinessException(CommonResponseEnum.FAIL, "质量标识只能是 0 或 1");
}
}
private List<String> normalizeIds(List<String> ids) {
if (ids == null || ids.isEmpty()) {
return Collections.emptyList();
}
List<String> normalizedIds = new ArrayList<String>();
for (String id : ids) {
String normalizedId = trimToNull(id);
if (normalizedId != null && !normalizedIds.contains(normalizedId)) {
normalizedIds.add(normalizedId);
}
}
return normalizedIds;
}
private boolean hasLedgerKeyword(SteadyDataViewQueryParam queryParam) {
return trimToNull(queryParam.getEngineeringName()) != null
|| trimToNull(queryParam.getProjectName()) != null
|| trimToNull(queryParam.getEquipmentName()) != null
|| trimToNull(queryParam.getLineName()) != null;
}
private Page<SteadyDataViewVO> emptyPage(SteadyDataViewQueryParam queryParam) {
Page<SteadyDataViewVO> page = new Page<SteadyDataViewVO>(PageFactory.getPageNum(queryParam), PageFactory.getPageSize(queryParam), 0);
page.setRecords(Collections.<SteadyDataViewVO>emptyList());
return page;
}
private List<String> resolveValueColumns(List<String> columns) { private List<String> resolveValueColumns(List<String> columns) {
List<String> result = new ArrayList<String>(); List<String> result = new ArrayList<String>();
for (String column : columns) { for (String column : columns) {

View File

@@ -1,7 +1,5 @@
package com.njcn.gather.steady.datavie.service.impl; package com.njcn.gather.steady.datavie.service.impl;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.gather.steady.datavie.component.SteadyInfluxQueryComponent; import com.njcn.gather.steady.datavie.component.SteadyInfluxQueryComponent;
import com.njcn.gather.steady.datavie.component.SteadyTrendFieldResolver; import com.njcn.gather.steady.datavie.component.SteadyTrendFieldResolver;
import com.njcn.gather.steady.datavie.pojo.bo.SteadyTrendResolvedFieldBO; import com.njcn.gather.steady.datavie.pojo.bo.SteadyTrendResolvedFieldBO;
@@ -15,13 +13,13 @@ import com.njcn.gather.steady.datavie.service.SteadyDataViewTrendService;
import com.njcn.gather.tool.addledger.pojo.vo.AddLedgerLinePathVO; import com.njcn.gather.tool.addledger.pojo.vo.AddLedgerLinePathVO;
import com.njcn.gather.tool.addledger.service.AddLedgerService; import com.njcn.gather.tool.addledger.service.AddLedgerService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator; import java.util.Comparator;
@@ -31,11 +29,13 @@ import java.util.Map;
/** /**
* 稳态趋势查询服务实现。 * 稳态趋势查询服务实现。
*/ */
@Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class SteadyDataViewTrendServiceImpl implements SteadyDataViewTrendService { public class SteadyDataViewTrendServiceImpl implements SteadyDataViewTrendService {
private static final String EMPTY_TEXT = "-"; private static final String EMPTY_TEXT = "-";
private static final String LINE_VOLTAGE_RMS_INDICATOR = "V_LINE_RMS";
private final SteadyTrendFieldResolver fieldResolver; private final SteadyTrendFieldResolver fieldResolver;
private final SteadyInfluxQueryComponent influxQueryComponent; private final SteadyInfluxQueryComponent influxQueryComponent;
@@ -43,19 +43,18 @@ public class SteadyDataViewTrendServiceImpl implements SteadyDataViewTrendServic
@Override @Override
public SteadyTrendQueryVO queryTrend(SteadyTrendQueryParam param) { public SteadyTrendQueryVO queryTrend(SteadyTrendQueryParam param) {
return queryTrendInternal(param, true); return queryTrendInternal(param);
} }
@Override @Override
public SteadyTrendQueryVO queryTrendDay(SteadyTrendQueryParam param) { public SteadyTrendQueryVO queryTrendDay(SteadyTrendQueryParam param) {
return queryTrendInternal(param, true); return queryTrendInternal(param);
} }
@Override @Override
public SteadyTrendSummaryVO summarizeTrend(SteadyTrendQueryParam param) { public SteadyTrendSummaryVO summarizeTrend(SteadyTrendQueryParam param) {
SteadyTrendQueryParam summaryParam = copyParam(param); SteadyTrendQueryParam summaryParam = copyParam(param);
summaryParam.setBucket(null); SteadyTrendQueryVO trend = queryTrendInternal(summaryParam);
SteadyTrendQueryVO trend = queryTrendInternal(summaryParam, false);
SteadyTrendSummaryVO result = new SteadyTrendSummaryVO(); SteadyTrendSummaryVO result = new SteadyTrendSummaryVO();
for (SteadyTrendSeriesVO series : trend.getSeries()) { for (SteadyTrendSeriesVO series : trend.getSeries()) {
result.getItems().add(buildSummaryItem(series)); result.getItems().add(buildSummaryItem(series));
@@ -63,23 +62,24 @@ public class SteadyDataViewTrendServiceImpl implements SteadyDataViewTrendServic
return result; return result;
} }
private SteadyTrendQueryVO queryTrendInternal(SteadyTrendQueryParam param, boolean autoBucket) { private SteadyTrendQueryVO queryTrendInternal(SteadyTrendQueryParam param) {
LocalDateTime startTime = fieldResolver.parseRequiredTime(param == null ? null : param.getTimeStart(), "开始时间不能为空"); LocalDateTime startTime = fieldResolver.parseRequiredTime(param == null ? null : param.getTimeStart(), "开始时间不能为空");
LocalDateTime endTime = fieldResolver.parseRequiredTime(param == null ? null : param.getTimeEnd(), "结束时间不能为空"); LocalDateTime endTime = fieldResolver.parseRequiredTime(param == null ? null : param.getTimeEnd(), "结束时间不能为空");
List<SteadyTrendResolvedFieldBO> fields = fieldResolver.resolveFields(param); List<SteadyTrendResolvedFieldBO> fields = fieldResolver.resolveFields(param);
enrichLineNames(fields); enrichLineNames(fields);
String bucket = autoBucket ? resolveBucket(param.getBucket(), startTime, endTime) : null;
SteadyTrendQueryVO result = new SteadyTrendQueryVO(); SteadyTrendQueryVO result = new SteadyTrendQueryVO();
result.setBucket(bucket); result.setSampled(false);
result.setSampled(bucket != null);
result.setLoadableDays(resolveLoadableDays(startTime, endTime)); result.setLoadableDays(resolveLoadableDays(startTime, endTime));
int displayPointCount = 0; int displayPointCount = 0;
long startMillis = System.currentTimeMillis();
log.info("稳态趋势查询开始seriesCount={}timeStart={}timeEnd={}qualityFlag={}", fields.size(), startTime, endTime, param.getQualityFlag());
for (SteadyTrendResolvedFieldBO field : fields) { for (SteadyTrendResolvedFieldBO field : fields) {
List<SteadyTrendPointVO> points = influxQueryComponent.queryTrendPoints(field, startTime, endTime, bucket, param.getQualityFlag()); List<SteadyTrendPointVO> points = influxQueryComponent.queryTrendPoints(field, startTime, endTime, param.getQualityFlag());
displayPointCount += points.size(); displayPointCount += points.size();
result.getSeries().add(buildSeries(field, points)); result.getSeries().add(buildSeries(field, points));
} }
log.info("稳态趋势查询结束seriesCount={}displayPointCount={}costMs={}", fields.size(), displayPointCount, System.currentTimeMillis() - startMillis);
/* /*
* 当前 Influx 查询按曲线独立执行,未额外发 count 查询sourcePointCount 保持与实际返回点数一致。 * 当前 Influx 查询按曲线独立执行,未额外发 count 查询sourcePointCount 保持与实际返回点数一致。
* 后续如需要精确原始点数,可单独增加 count(field) 查询。 * 后续如需要精确原始点数,可单独增加 count(field) 查询。
@@ -97,13 +97,29 @@ public class SteadyDataViewTrendServiceImpl implements SteadyDataViewTrendServic
series.setIndicatorCode(field.getIndicatorCode()); series.setIndicatorCode(field.getIndicatorCode());
series.setIndicatorName(field.getIndicatorName()); series.setIndicatorName(field.getIndicatorName());
series.setSeriesName(field.getSeriesName()); series.setSeriesName(field.getSeriesName());
series.setPhase(field.getPhase()); series.setPhase(resolveDisplayPhase(field));
series.setStatType(field.getStatType()); series.setStatType(field.getStatType());
series.setUnit(field.getUnit()); series.setUnit(field.getUnit());
series.setPoints(points); series.setPoints(points);
return series; return series;
} }
private String resolveDisplayPhase(SteadyTrendResolvedFieldBO field) {
if (!LINE_VOLTAGE_RMS_INDICATOR.equals(field.getIndicatorCode())) {
return field.getPhase();
}
if ("A".equals(field.getPhase())) {
return "AB";
}
if ("B".equals(field.getPhase())) {
return "BC";
}
if ("C".equals(field.getPhase())) {
return "CA";
}
return field.getPhase();
}
private void enrichLineNames(List<SteadyTrendResolvedFieldBO> fields) { private void enrichLineNames(List<SteadyTrendResolvedFieldBO> fields) {
List<String> lineIds = new ArrayList<String>(); List<String> lineIds = new ArrayList<String>();
for (SteadyTrendResolvedFieldBO field : fields) { for (SteadyTrendResolvedFieldBO field : fields) {
@@ -143,27 +159,6 @@ public class SteadyDataViewTrendServiceImpl implements SteadyDataViewTrendServic
return item; return item;
} }
private String resolveBucket(String requestBucket, LocalDateTime startTime, LocalDateTime endTime) {
String bucket = trimToNull(requestBucket);
if (bucket != null) {
if (!bucket.matches("^[1-9][0-9]*(m|h|d)$")) {
throw fail("分桶粒度仅支持 m、h、d例如 10m、1h");
}
return bucket;
}
long hours = ChronoUnit.HOURS.between(startTime, endTime);
if (hours < 6) {
return "1m";
}
if (hours <= 24) {
return "10m";
}
if (hours <= 24 * 7) {
return "30m";
}
return "1h";
}
private List<String> resolveLoadableDays(LocalDateTime startTime, LocalDateTime endTime) { private List<String> resolveLoadableDays(LocalDateTime startTime, LocalDateTime endTime) {
List<String> result = new ArrayList<String>(); List<String> result = new ArrayList<String>();
LocalDate date = startTime.toLocalDate(); LocalDate date = startTime.toLocalDate();
@@ -183,10 +178,8 @@ public class SteadyDataViewTrendServiceImpl implements SteadyDataViewTrendServic
target.setLineIds(copyList(source.getLineIds())); target.setLineIds(copyList(source.getLineIds()));
target.setIndicatorCodes(copyList(source.getIndicatorCodes())); target.setIndicatorCodes(copyList(source.getIndicatorCodes()));
target.setStatTypes(copyList(source.getStatTypes())); target.setStatTypes(copyList(source.getStatTypes()));
target.setPhases(copyList(source.getPhases()));
target.setTimeStart(source.getTimeStart()); target.setTimeStart(source.getTimeStart());
target.setTimeEnd(source.getTimeEnd()); target.setTimeEnd(source.getTimeEnd());
target.setBucket(source.getBucket());
target.setQualityFlag(source.getQualityFlag()); target.setQualityFlag(source.getQualityFlag());
target.setHarmonicOrders(source.getHarmonicOrders() == null ? Collections.<Integer>emptyList() : new ArrayList<Integer>(source.getHarmonicOrders())); target.setHarmonicOrders(source.getHarmonicOrders() == null ? Collections.<Integer>emptyList() : new ArrayList<Integer>(source.getHarmonicOrders()));
return target; return target;
@@ -204,7 +197,4 @@ public class SteadyDataViewTrendServiceImpl implements SteadyDataViewTrendServic
return trimmed.isEmpty() ? null : trimmed; return trimmed.isEmpty() ? null : trimmed;
} }
private BusinessException fail(String message) {
return new BusinessException(CommonResponseEnum.FAIL, message);
}
} }

View File

@@ -0,0 +1,38 @@
package com.njcn.gather.steady.checksquare.component;
import com.njcn.gather.steady.checksquare.pojo.vo.SteadyChecksquareSegmentVO;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
/**
* 数据校验缺失区间计算测试。
*/
class SteadyChecksquareCalculatorTest {
@Test
void shouldMergeContinuousMissingSlots() {
SteadyChecksquareCalculator calculator = new SteadyChecksquareCalculator();
List<LocalDateTime> slots = Arrays.asList(
LocalDateTime.of(2026, 5, 1, 0, 0),
LocalDateTime.of(2026, 5, 1, 0, 1),
LocalDateTime.of(2026, 5, 1, 0, 2),
LocalDateTime.of(2026, 5, 1, 0, 3),
LocalDateTime.of(2026, 5, 1, 0, 4)
);
List<SteadyChecksquareSegmentVO> segments = calculator.buildSegments(slots,
new HashSet<LocalDateTime>(Arrays.asList(slots.get(0), slots.get(3))), 1);
Assertions.assertEquals(4, segments.size());
Assertions.assertEquals("MISSING", segments.get(1).getStatus());
Assertions.assertEquals("2026-05-01 00:01:00", segments.get(1).getStartTime());
Assertions.assertEquals("2026-05-01 00:02:00", segments.get(1).getEndTime());
Assertions.assertEquals(Integer.valueOf(2), segments.get(1).getMissingPointCount());
Assertions.assertEquals(Integer.valueOf(2), segments.get(1).getDurationMinutes());
}
}

View File

@@ -0,0 +1,36 @@
package com.njcn.gather.steady.checksquare.component;
import com.njcn.gather.steady.datavie.config.SteadyInfluxDbProperties;
import com.njcn.gather.steady.datavie.pojo.bo.SteadyTrendResolvedFieldBO;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
/**
* 数据校验 InfluxQL 构造契约测试。
*/
class SteadyChecksquareInfluxQueryComponentTest {
@Test
void shouldBuildChecksquareQueryWithoutQualityFlag() {
SteadyChecksquareInfluxQueryComponent component = new SteadyChecksquareInfluxQueryComponent(new SteadyInfluxDbProperties());
SteadyTrendResolvedFieldBO field = new SteadyTrendResolvedFieldBO();
field.setMeasurement("data_v");
field.setField("rms");
field.setLineId("line-001");
field.setPhase("A");
field.setStatType("AVG");
String query = component.buildChecksquareQuery(field,
LocalDateTime.of(2026, 5, 1, 0, 0, 0),
LocalDateTime.of(2026, 5, 1, 23, 59, 59));
Assertions.assertTrue(query.contains("SELECT \"rms\" AS \"value\""));
Assertions.assertTrue(query.contains("\"line_id\" = 'line-001'"));
Assertions.assertTrue(query.contains("\"phasic_type\" = 'A'"));
Assertions.assertTrue(query.contains("\"value_type\" = 'AVG'"));
Assertions.assertFalse(query.contains("quality_flag"));
Assertions.assertFalse(query.contains("GROUP BY time"));
}
}

View File

@@ -0,0 +1,24 @@
package com.njcn.gather.steady.checksquare.controller;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.reflect.Method;
/**
* 数据校验接口契约测试。
*/
class SteadyChecksquareControllerTest {
@Test
void shouldExposeChecksquareQueryEndpointInSeparateController() throws Exception {
RequestMapping requestMapping = SteadyChecksquareController.class.getAnnotation(RequestMapping.class);
Assertions.assertArrayEquals(new String[]{"/steady/data-view/checksquare"}, requestMapping.value());
Method method = SteadyChecksquareController.class.getDeclaredMethod("query", com.njcn.gather.steady.checksquare.pojo.param.SteadyChecksquareQueryParam.class);
PostMapping postMapping = method.getAnnotation(PostMapping.class);
Assertions.assertArrayEquals(new String[]{"/query"}, postMapping.value());
}
}

View File

@@ -0,0 +1,32 @@
package com.njcn.gather.steady.checksquare.pojo.param;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
/**
* 数据校验查询参数契约测试。
*/
class SteadyChecksquareQueryParamTest {
@Test
void shouldOnlyExposeChecksquareQueryFields() {
Assertions.assertNotNull(field("lineId"));
Assertions.assertNotNull(field("indicatorCodes"));
Assertions.assertNotNull(field("timeStart"));
Assertions.assertNotNull(field("timeEnd"));
Assertions.assertNull(field("qualityFlag"));
Assertions.assertNull(field("statTypes"));
Assertions.assertNull(field("phases"));
Assertions.assertNotNull(field("harmonicOrders"));
}
private Field field(String name) {
try {
return SteadyChecksquareQueryParam.class.getDeclaredField(name);
} catch (NoSuchFieldException ex) {
return null;
}
}
}

View File

@@ -0,0 +1,130 @@
package com.njcn.gather.steady.checksquare.service.impl;
import com.njcn.gather.steady.checksquare.component.SteadyChecksquareCalculator;
import com.njcn.gather.steady.checksquare.component.SteadyChecksquareInfluxQueryComponent;
import com.njcn.gather.steady.checksquare.pojo.param.SteadyChecksquareQueryParam;
import com.njcn.gather.steady.checksquare.pojo.vo.SteadyChecksquareItemVO;
import com.njcn.gather.steady.checksquare.pojo.vo.SteadyChecksquareQueryVO;
import com.njcn.gather.steady.datavie.component.SteadyTrendIndicatorCatalog;
import com.njcn.gather.tool.adddata.component.AddDataTimeSlotCalculator;
import com.njcn.gather.tool.addledger.pojo.vo.AddLedgerLinePathVO;
import com.njcn.gather.tool.addledger.service.AddLedgerService;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 数据校验服务测试。
*/
class SteadyChecksquareServiceImplTest {
@Test
void shouldUseFixedFlickerIntervalsPerIndicator() {
SteadyChecksquareInfluxQueryComponent influxQueryComponent = mock(SteadyChecksquareInfluxQueryComponent.class);
AddLedgerService addLedgerService = mock(AddLedgerService.class);
SteadyChecksquareServiceImpl service = new SteadyChecksquareServiceImpl(new SteadyTrendIndicatorCatalog(),
influxQueryComponent, new SteadyChecksquareCalculator(), new AddDataTimeSlotCalculator(), addLedgerService);
AddLedgerLinePathVO linePath = new AddLedgerLinePathVO();
linePath.setLineId("line-001");
linePath.setLineName("进线一");
linePath.setLineInterval(1);
when(addLedgerService.listLinePathByLineIds(eq(Collections.singletonList("line-001"))))
.thenReturn(Collections.singletonMap("line-001", linePath));
when(influxQueryComponent.queryExistingSlots(any(), any(LocalDateTime.class), any(LocalDateTime.class), eq(10)))
.thenReturn(new HashSet<LocalDateTime>(Arrays.asList(
LocalDateTime.of(2026, 5, 1, 0, 0),
LocalDateTime.of(2026, 5, 1, 0, 10))));
when(influxQueryComponent.queryExistingSlots(any(), any(LocalDateTime.class), any(LocalDateTime.class), eq(120)))
.thenReturn(new HashSet<LocalDateTime>(Collections.singletonList(
LocalDateTime.of(2026, 5, 1, 0, 0))));
SteadyChecksquareQueryParam param = new SteadyChecksquareQueryParam();
param.setLineId("line-001");
param.setIndicatorCodes(Arrays.asList("FLUC", "PST", "PLT"));
param.setTimeStart("2026-05-01 00:00:00");
param.setTimeEnd("2026-05-01 02:00:00");
SteadyChecksquareQueryVO result = service.query(param);
Assertions.assertEquals(Integer.valueOf(1), result.getIntervalMinutes());
Assertions.assertEquals(3, result.getItems().size());
assertItemInterval(result.getItems().get(0), "FLUC", 10, 13);
assertItemInterval(result.getItems().get(1), "PST", 10, 13);
assertItemInterval(result.getItems().get(2), "PLT", 120, 2);
}
@Test
void shouldOnlyQueryRequestedHarmonicOrders() {
SteadyChecksquareInfluxQueryComponent influxQueryComponent = mock(SteadyChecksquareInfluxQueryComponent.class);
AddLedgerService addLedgerService = mock(AddLedgerService.class);
SteadyChecksquareServiceImpl service = new SteadyChecksquareServiceImpl(new SteadyTrendIndicatorCatalog(),
influxQueryComponent, new SteadyChecksquareCalculator(), new AddDataTimeSlotCalculator(), addLedgerService);
AddLedgerLinePathVO linePath = new AddLedgerLinePathVO();
linePath.setLineId("line-001");
linePath.setLineName("进线一");
linePath.setLineInterval(1);
when(addLedgerService.listLinePathByLineIds(eq(Collections.singletonList("line-001"))))
.thenReturn(Collections.singletonMap("line-001", linePath));
when(influxQueryComponent.queryExistingSlots(any(), any(LocalDateTime.class), any(LocalDateTime.class), eq(1)))
.thenReturn(new HashSet<LocalDateTime>(Collections.singletonList(
LocalDateTime.of(2026, 5, 1, 0, 0))));
SteadyChecksquareQueryParam param = new SteadyChecksquareQueryParam();
param.setLineId("line-001");
param.setIndicatorCodes(Collections.singletonList("V_HARMONIC"));
param.setHarmonicOrders(Collections.singletonList(5));
param.setTimeStart("2026-05-01 00:00:00");
param.setTimeEnd("2026-05-01 00:01:00");
SteadyChecksquareQueryVO result = service.query(param);
Assertions.assertEquals(1, result.getItems().size());
Assertions.assertEquals(Integer.valueOf(5), result.getItems().get(0).getHarmonicOrder());
}
@Test
void shouldKeepRequestedHarmonicOrdersDistinctAndOrdered() {
SteadyChecksquareInfluxQueryComponent influxQueryComponent = mock(SteadyChecksquareInfluxQueryComponent.class);
AddLedgerService addLedgerService = mock(AddLedgerService.class);
SteadyChecksquareServiceImpl service = new SteadyChecksquareServiceImpl(new SteadyTrendIndicatorCatalog(),
influxQueryComponent, new SteadyChecksquareCalculator(), new AddDataTimeSlotCalculator(), addLedgerService);
AddLedgerLinePathVO linePath = new AddLedgerLinePathVO();
linePath.setLineId("line-001");
linePath.setLineName("进线一");
linePath.setLineInterval(1);
when(addLedgerService.listLinePathByLineIds(eq(Collections.singletonList("line-001"))))
.thenReturn(Collections.singletonMap("line-001", linePath));
when(influxQueryComponent.queryExistingSlots(any(), any(LocalDateTime.class), any(LocalDateTime.class), eq(1)))
.thenReturn(new HashSet<LocalDateTime>());
SteadyChecksquareQueryParam param = new SteadyChecksquareQueryParam();
param.setLineId("line-001");
param.setIndicatorCodes(Collections.singletonList("V_HARMONIC"));
param.setHarmonicOrders(Arrays.asList(7, 5, 7));
param.setTimeStart("2026-05-01 00:00:00");
param.setTimeEnd("2026-05-01 00:01:00");
SteadyChecksquareQueryVO result = service.query(param);
List<SteadyChecksquareItemVO> items = result.getItems();
Assertions.assertEquals(2, items.size());
Assertions.assertEquals(Integer.valueOf(7), items.get(0).getHarmonicOrder());
Assertions.assertEquals(Integer.valueOf(5), items.get(1).getHarmonicOrder());
}
private void assertItemInterval(SteadyChecksquareItemVO item, String indicatorCode, int intervalMinutes, int expectedPointCount) {
Assertions.assertEquals(indicatorCode, item.getIndicatorCode());
Assertions.assertEquals(Integer.valueOf(intervalMinutes), item.getIntervalMinutes());
Assertions.assertEquals(Integer.valueOf(expectedPointCount), item.getExpectedPointCount());
}
}

View File

@@ -8,44 +8,112 @@ import org.junit.jupiter.api.Test;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** /**
* InfluxQL 查询语句生成测试。 * 稳态趋势 InfluxQL 构造契约测试。
*/ */
class SteadyInfluxQueryComponentTest { class SteadyInfluxQueryComponentTest {
@Test @Test
void shouldBuildBucketedTrendQueryWithRequiredTags() { void shouldBuildRawPointQueryWithoutTimeBucketAggregation() {
SteadyInfluxQueryComponent component = new SteadyInfluxQueryComponent(new SteadyInfluxDbProperties()); SteadyInfluxQueryComponent component = new SteadyInfluxQueryComponent(new SteadyInfluxDbProperties());
SteadyTrendResolvedFieldBO field = new SteadyTrendResolvedFieldBO(); SteadyTrendResolvedFieldBO field = new SteadyTrendResolvedFieldBO();
field.setMeasurement("data_v"); field.setMeasurement("data_v");
field.setField("RMS_CP95"); field.setField("RMS");
field.setLineId("line-001"); field.setLineId("line-001");
field.setPhase("A"); field.setPhase("A");
String query = component.buildTrendQuery(field, String query = component.buildTrendQuery(field,
LocalDateTime.of(2026, 5, 1, 0, 0, 0), LocalDateTime.of(2026, 5, 1, 0, 0, 0),
LocalDateTime.of(2026, 5, 1, 1, 0, 0), LocalDateTime.of(2026, 5, 1, 23, 59, 59),
"10m",
1); 1);
Assertions.assertEquals("SELECT mean(\"RMS_CP95\") AS \"value\" FROM \"data_v\" WHERE time >= '2026-05-01T00:00:00Z' AND time <= '2026-05-01T01:00:00Z' AND \"LINEID\" = 'line-001' AND \"PHASIC_TYPE\" = 'A' AND \"QUALITYFLAG\" = '1' GROUP BY time(10m) fill(none)", query); Assertions.assertTrue(query.startsWith("SELECT \"RMS\" AS \"value\""), "趋势查询应读取原始字段值");
Assertions.assertTrue(query.endsWith(" ORDER BY time ASC"), "趋势查询应按原始时间升序返回");
Assertions.assertFalse(query.contains("mean("), "趋势查询不应按颗粒度聚合");
Assertions.assertFalse(query.contains("GROUP BY time"), "趋势查询不应生成时间分桶");
} }
@Test @Test
void shouldEscapeTagValuesInTrendQuery() { void shouldBuildQueryWithActualInfluxTagsAndValueType() {
SteadyInfluxQueryComponent component = new SteadyInfluxQueryComponent(new SteadyInfluxDbProperties()); SteadyInfluxQueryComponent component = new SteadyInfluxQueryComponent(new SteadyInfluxDbProperties());
SteadyTrendResolvedFieldBO field = new SteadyTrendResolvedFieldBO(); SteadyTrendResolvedFieldBO field = new SteadyTrendResolvedFieldBO();
field.setMeasurement("data_v"); field.setMeasurement("data_v");
field.setField("RMS"); field.setField("rms");
field.setLineId("line'001"); field.setLineId("00B78D00A8791");
field.setPhase("A");
field.setStatType("AVG");
String query = component.buildTrendQuery(field,
LocalDateTime.of(2026, 5, 1, 0, 0, 0),
LocalDateTime.of(2026, 5, 1, 23, 59, 59),
0);
Assertions.assertTrue(query.contains("\"line_id\" = '00B78D00A8791'"));
Assertions.assertTrue(query.contains("\"phasic_type\" = 'A'"));
Assertions.assertTrue(query.contains("\"quality_flag\" = '0'"));
Assertions.assertTrue(query.contains("\"value_type\" = 'AVG'"));
Assertions.assertFalse(query.contains("\"LINEID\""));
Assertions.assertFalse(query.contains("\"PHASIC_TYPE\""));
Assertions.assertFalse(query.contains("\"QUALITYFLAG\""));
}
@Test
void shouldDefaultValueTypeToUpperAvg() {
SteadyInfluxQueryComponent component = new SteadyInfluxQueryComponent(new SteadyInfluxDbProperties());
SteadyTrendResolvedFieldBO field = new SteadyTrendResolvedFieldBO();
field.setMeasurement("data_v");
field.setField("rms");
field.setLineId("0798e5a9a72ebbc4daaa4a631701c813");
field.setPhase("A"); field.setPhase("A");
String query = component.buildTrendQuery(field, String query = component.buildTrendQuery(field,
LocalDateTime.of(2026, 5, 1, 0, 0, 0), LocalDateTime.of(2026, 5, 1, 0, 0, 0),
LocalDateTime.of(2026, 5, 1, 1, 0, 0), LocalDateTime.of(2026, 5, 1, 23, 59, 59),
null, 0);
null);
Assertions.assertTrue(query.contains("\"LINEID\" = 'line\\'001'")); Assertions.assertTrue(query.contains("\"value_type\" = 'AVG'"));
Assertions.assertFalse(query.contains("GROUP BY time")); }
@Test
void shouldBuildDiagnosticTextForTrendQuery() {
SteadyInfluxQueryComponent component = new SteadyInfluxQueryComponent(new SteadyInfluxDbProperties());
SteadyTrendResolvedFieldBO field = new SteadyTrendResolvedFieldBO();
field.setMeasurement("data_harmpower_p");
field.setField("p_3");
field.setLineId("f828bc42132841c2aeebc6859f5a9b7c");
field.setPhase("A");
field.setStatType("AVG");
String diagnostic = component.buildTrendQueryDiagnostic(field,
LocalDateTime.of(2026, 5, 1, 0, 0, 0),
LocalDateTime.of(2026, 5, 31, 23, 59, 59),
0);
Assertions.assertTrue(diagnostic.contains("measurement=data_harmpower_p"));
Assertions.assertTrue(diagnostic.contains("field=p_3"));
Assertions.assertTrue(diagnostic.contains("lineId=f828bc42132841c2aeebc6859f5a9b7c"));
Assertions.assertTrue(diagnostic.contains("phase=A"));
Assertions.assertTrue(diagnostic.contains("statType=AVG"));
Assertions.assertTrue(diagnostic.contains("qualityFlag=0"));
Assertions.assertTrue(diagnostic.contains("timeStart=2026-05-01T00:00"));
Assertions.assertTrue(diagnostic.contains("timeEnd=2026-05-31T23:59:59"));
}
@Test
void shouldSkipValueTypeWhenMeasurementHasNoValueTypeTag() {
SteadyInfluxQueryComponent component = new SteadyInfluxQueryComponent(new SteadyInfluxDbProperties());
SteadyTrendResolvedFieldBO field = new SteadyTrendResolvedFieldBO();
field.setMeasurement("data_fluc");
field.setField("fluc");
field.setLineId("0798e5a9a72ebbc4daaa4a631701c813");
field.setPhase("T");
field.setStatType("AVG");
String query = component.buildTrendQuery(field,
LocalDateTime.of(2026, 5, 1, 0, 0, 0),
LocalDateTime.of(2026, 5, 1, 23, 59, 59),
0);
Assertions.assertFalse(query.contains("\"value_type\""));
Assertions.assertTrue(query.contains("\"quality_flag\" = '0'"));
} }
} }

View File

@@ -3,13 +3,13 @@ package com.njcn.gather.steady.datavie.component;
import com.njcn.common.pojo.exception.BusinessException; import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.gather.steady.datavie.pojo.bo.SteadyTrendResolvedFieldBO; import com.njcn.gather.steady.datavie.pojo.bo.SteadyTrendResolvedFieldBO;
import com.njcn.gather.steady.datavie.pojo.param.SteadyTrendQueryParam; import com.njcn.gather.steady.datavie.pojo.param.SteadyTrendQueryParam;
import com.njcn.gather.tool.adddata.component.AddDataTableRegistry;
import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.stream.Collectors;
/** /**
* 稳态趋势字段解析测试。 * 稳态趋势字段解析测试。
@@ -19,49 +19,80 @@ class SteadyTrendFieldResolverTest {
private SteadyTrendFieldResolver resolver; private SteadyTrendFieldResolver resolver;
@BeforeEach @BeforeEach
void setUp() throws Exception { void setUp() {
AddDataTableRegistry tableRegistry = new AddDataTableRegistry(); resolver = new SteadyTrendFieldResolver(new SteadyTrendIndicatorCatalog());
tableRegistry.afterPropertiesSet();
resolver = new SteadyTrendFieldResolver(new SteadyTrendIndicatorCatalog(), tableRegistry);
} }
@Test @Test
void shouldResolveVoltageRmsAverageAndCp95Fields() { void shouldExpandAllCatalogPhasesWithoutRequestPhaseFilter() {
SteadyTrendQueryParam param = new SteadyTrendQueryParam(); SteadyTrendQueryParam param = new SteadyTrendQueryParam();
param.setLineIds(Arrays.asList("line-001")); param.setLineIds(Arrays.asList("line-001"));
param.setIndicatorCodes(Arrays.asList("V_RMS")); param.setIndicatorCodes(Arrays.asList("V_RMS"));
param.setPhases(Arrays.asList("A")); param.setStatTypes(Arrays.asList("AVG"));
param.setStatTypes(Arrays.asList("AVG", "CP95"));
param.setTimeStart("2026-05-01 00:00:00"); param.setTimeStart("2026-05-01 00:00:00");
param.setTimeEnd("2026-05-01 01:00:00"); param.setTimeEnd("2026-05-01 01:00:00");
List<SteadyTrendResolvedFieldBO> fields = resolver.resolveFields(param); List<SteadyTrendResolvedFieldBO> fields = resolver.resolveFields(param);
List<String> phases = fields.stream().map(SteadyTrendResolvedFieldBO::getPhase).collect(Collectors.toList());
Assertions.assertEquals(2, fields.size()); Assertions.assertEquals(Arrays.asList("A", "B", "C"), phases);
Assertions.assertEquals("data_v", fields.get(0).getMeasurement());
Assertions.assertEquals("RMS", fields.get(0).getField());
Assertions.assertEquals("AVG", fields.get(0).getStatType());
Assertions.assertEquals("RMS_CP95", fields.get(1).getField());
Assertions.assertEquals("V", fields.get(1).getUnit());
} }
@Test @Test
void shouldExpandLineVoltageTotalPhaseToThreeSeries() { void shouldResolveVoltageRmsToActualInfluxField() {
SteadyTrendQueryParam param = new SteadyTrendQueryParam(); SteadyTrendQueryParam param = new SteadyTrendQueryParam();
param.setLineIds(Arrays.asList("line-001")); param.setLineIds(Arrays.asList("00B78D00A8791"));
param.setIndicatorCodes(Arrays.asList("V_LINE_RMS")); param.setIndicatorCodes(Arrays.asList("V_RMS"));
param.setPhases(Arrays.asList("T"));
param.setStatTypes(Arrays.asList("AVG")); param.setStatTypes(Arrays.asList("AVG"));
param.setTimeStart("2026-05-01 00:00:00"); param.setTimeStart("2026-05-01 00:00:00");
param.setTimeEnd("2026-05-01 01:00:00"); param.setTimeEnd("2026-05-01 01:00:00");
List<SteadyTrendResolvedFieldBO> fields = resolver.resolveFields(param); List<SteadyTrendResolvedFieldBO> fields = resolver.resolveFields(param);
Assertions.assertEquals(3, fields.size()); Assertions.assertEquals("rms", fields.get(0).getField());
Assertions.assertEquals("RMSAB", fields.get(0).getField()); Assertions.assertEquals("00B78D00A8791|V_RMS|A|AVG|rms", fields.get(0).getSeriesKey());
Assertions.assertEquals("RMSBC", fields.get(1).getField()); }
Assertions.assertEquals("RMSCA", fields.get(2).getField());
Assertions.assertEquals("T", fields.get(0).getPhase()); @Test
void shouldExpandLineVoltageIndicatorWithQueryPhases() {
SteadyTrendQueryParam param = new SteadyTrendQueryParam();
param.setLineIds(Arrays.asList("line-001"));
param.setIndicatorCodes(Arrays.asList("V_LINE_RMS"));
param.setStatTypes(Arrays.asList("AVG"));
param.setTimeStart("2026-05-01 00:00:00");
param.setTimeEnd("2026-05-01 01:00:00");
List<SteadyTrendResolvedFieldBO> fields = resolver.resolveFields(param);
List<String> phases = fields.stream().map(SteadyTrendResolvedFieldBO::getPhase).collect(Collectors.toList());
Assertions.assertEquals(Arrays.asList("A", "B", "C"), phases);
Assertions.assertEquals("rms_lvr", fields.get(0).getField());
}
@Test
void shouldResolveCommonIndicatorsToActualInfluxFields() {
Assertions.assertEquals("freq", resolveSingleField("FREQ"));
Assertions.assertEquals("v_thd", resolveSingleField("V_THD"));
Assertions.assertEquals("rms", resolveSingleField("I_RMS"));
Assertions.assertEquals("i_thd", resolveSingleField("I_THD"));
Assertions.assertEquals("fluc", resolveSingleField("FLUC"));
Assertions.assertEquals("pst", resolveSingleField("PST"));
Assertions.assertEquals("plt", resolveSingleField("PLT"));
}
@Test
void shouldExposeActualInfluxFieldsInIndicatorCatalog() {
SteadyTrendIndicatorCatalog catalog = new SteadyTrendIndicatorCatalog();
Assertions.assertEquals("rms", catalog.getIndicator("V_RMS").getSeriesFields().get(0).getField());
Assertions.assertEquals("rms_lvr", catalog.getIndicator("V_LINE_RMS").getSeriesFields().get(0).getField());
Assertions.assertEquals("freq", catalog.getIndicator("FREQ").getSeriesFields().get(0).getField());
Assertions.assertEquals("v_thd", catalog.getIndicator("V_THD").getSeriesFields().get(0).getField());
Assertions.assertEquals("rms", catalog.getIndicator("I_RMS").getSeriesFields().get(0).getField());
Assertions.assertEquals("i_thd", catalog.getIndicator("I_THD").getSeriesFields().get(0).getField());
Assertions.assertEquals("fluc", catalog.getIndicator("FLUC").getSeriesFields().get(0).getField());
Assertions.assertEquals("pst", catalog.getIndicator("PST").getSeriesFields().get(0).getField());
Assertions.assertEquals("plt", catalog.getIndicator("PLT").getSeriesFields().get(0).getField());
} }
@Test @Test
@@ -69,7 +100,6 @@ class SteadyTrendFieldResolverTest {
SteadyTrendQueryParam param = new SteadyTrendQueryParam(); SteadyTrendQueryParam param = new SteadyTrendQueryParam();
param.setLineIds(Arrays.asList("line-001")); param.setLineIds(Arrays.asList("line-001"));
param.setIndicatorCodes(Arrays.asList("V_HARMONIC")); param.setIndicatorCodes(Arrays.asList("V_HARMONIC"));
param.setPhases(Arrays.asList("A"));
param.setStatTypes(Arrays.asList("AVG")); param.setStatTypes(Arrays.asList("AVG"));
param.setTimeStart("2026-05-01 00:00:00"); param.setTimeStart("2026-05-01 00:00:00");
param.setTimeEnd("2026-05-01 01:00:00"); param.setTimeEnd("2026-05-01 01:00:00");
@@ -80,11 +110,25 @@ class SteadyTrendFieldResolverTest {
} }
@Test @Test
void shouldResolveSelectedHarmonicOrdersOnly() { void shouldRejectHarmonicTrendWithMoreThanThreeOrders() {
SteadyTrendQueryParam param = new SteadyTrendQueryParam();
param.setLineIds(Arrays.asList("line-001"));
param.setIndicatorCodes(Arrays.asList("V_HARMONIC"));
param.setStatTypes(Arrays.asList("AVG"));
param.setHarmonicOrders(Arrays.asList(3, 5, 7, 11));
param.setTimeStart("2026-05-01 00:00:00");
param.setTimeEnd("2026-05-01 01:00:00");
BusinessException exception = Assertions.assertThrows(BusinessException.class, () -> resolver.resolveFields(param));
Assertions.assertTrue(exception.getMessage().contains("谐波次数不允许一次展示超过3个"));
}
@Test
void shouldResolveSelectedHarmonicOrdersForAllCatalogPhases() {
SteadyTrendQueryParam param = new SteadyTrendQueryParam(); SteadyTrendQueryParam param = new SteadyTrendQueryParam();
param.setLineIds(Arrays.asList("line-001")); param.setLineIds(Arrays.asList("line-001"));
param.setIndicatorCodes(Arrays.asList("V_HARMONIC")); param.setIndicatorCodes(Arrays.asList("V_HARMONIC"));
param.setPhases(Arrays.asList("A"));
param.setStatTypes(Arrays.asList("MAX")); param.setStatTypes(Arrays.asList("MAX"));
param.setHarmonicOrders(Arrays.asList(3, 5)); param.setHarmonicOrders(Arrays.asList(3, 5));
param.setTimeStart("2026-05-01 00:00:00"); param.setTimeStart("2026-05-01 00:00:00");
@@ -92,8 +136,36 @@ class SteadyTrendFieldResolverTest {
List<SteadyTrendResolvedFieldBO> fields = resolver.resolveFields(param); List<SteadyTrendResolvedFieldBO> fields = resolver.resolveFields(param);
Assertions.assertEquals(2, fields.size()); Assertions.assertEquals(6, fields.size());
Assertions.assertEquals("V_3_MAX", fields.get(0).getField()); Assertions.assertEquals("A", fields.get(0).getPhase());
Assertions.assertEquals("V_5_MAX", fields.get(1).getField()); Assertions.assertEquals("v_3", fields.get(0).getField());
Assertions.assertEquals("v_5", fields.get(1).getField());
Assertions.assertEquals("B", fields.get(2).getPhase());
Assertions.assertEquals("C", fields.get(4).getPhase());
}
@Test
void shouldResolveHarmonicPowerToActualInfluxField() {
SteadyTrendQueryParam param = new SteadyTrendQueryParam();
param.setLineIds(Arrays.asList("line-001"));
param.setIndicatorCodes(Arrays.asList("P_HARMONIC_POWER"));
param.setStatTypes(Arrays.asList("CP95"));
param.setHarmonicOrders(Arrays.asList(3));
param.setTimeStart("2026-05-01 00:00:00");
param.setTimeEnd("2026-05-01 01:00:00");
List<SteadyTrendResolvedFieldBO> fields = resolver.resolveFields(param);
Assertions.assertEquals("p_3", fields.get(0).getField());
}
private String resolveSingleField(String indicatorCode) {
SteadyTrendQueryParam param = new SteadyTrendQueryParam();
param.setLineIds(Arrays.asList("line-001"));
param.setIndicatorCodes(Arrays.asList(indicatorCode));
param.setStatTypes(Arrays.asList("AVG"));
param.setTimeStart("2026-05-01 00:00:00");
param.setTimeEnd("2026-05-01 01:00:00");
return resolver.resolveFields(param).get(0).getField();
} }
} }

View File

@@ -0,0 +1,19 @@
package com.njcn.gather.steady.datavie.controller;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
/**
* 稳态数据查看接口契约测试。
*/
class SteadyDataViewControllerTest {
@Test
void shouldNotExposePageQueryEndpointMethod() {
for (Method method : SteadyDataViewController.class.getDeclaredMethods()) {
Assertions.assertNotEquals("pageSteadyData", method.getName(), "本功能不提供分页检索接口");
}
}
}

View File

@@ -0,0 +1,20 @@
package com.njcn.gather.steady.datavie.pojo.param;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
/**
* 稳态趋势查询参数契约测试。
*/
class SteadyTrendQueryParamTest {
@Test
void shouldNotExposePhaseFilterInTrendQueryParam() {
for (Field field : SteadyTrendQueryParam.class.getDeclaredFields()) {
Assertions.assertNotEquals("phases", field.getName(), "趋势检索请求不再携带相别条件");
}
}
}

View File

@@ -1,27 +0,0 @@
package com.njcn.gather.steady.datavie.service.impl;
import com.njcn.gather.steady.datavie.component.SteadyTrendIndicatorCatalog;
import com.njcn.gather.steady.datavie.pojo.vo.SteadyDataViewIndicatorNodeVO;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.util.List;
/**
* 稳态趋势指标服务测试。
*/
class SteadyDataViewIndicatorServiceImplTest {
@Test
void shouldGroupIndicatorsByCategory() {
SteadyDataViewIndicatorServiceImpl service = new SteadyDataViewIndicatorServiceImpl(new SteadyTrendIndicatorCatalog());
List<SteadyDataViewIndicatorNodeVO> tree = service.listIndicatorTree();
Assertions.assertEquals(5, tree.size());
Assertions.assertEquals("VOLTAGE", tree.get(0).getGroupCode());
Assertions.assertTrue(tree.get(0).getChildren().size() >= 2);
Assertions.assertEquals("V_RMS", tree.get(0).getChildren().get(0).getIndicatorCode());
Assertions.assertTrue(tree.get(0).getChildren().get(0).getSelectable());
}
}

View File

@@ -2,122 +2,53 @@ package com.njcn.gather.steady.datavie.service.impl;
import com.njcn.gather.steady.datavie.component.SteadyInfluxQueryComponent; import com.njcn.gather.steady.datavie.component.SteadyInfluxQueryComponent;
import com.njcn.gather.steady.datavie.component.SteadyTrendFieldResolver; import com.njcn.gather.steady.datavie.component.SteadyTrendFieldResolver;
import com.njcn.gather.steady.datavie.component.SteadyTrendIndicatorCatalog;
import com.njcn.gather.steady.datavie.pojo.bo.SteadyTrendResolvedFieldBO; import com.njcn.gather.steady.datavie.pojo.bo.SteadyTrendResolvedFieldBO;
import com.njcn.gather.steady.datavie.pojo.param.SteadyTrendQueryParam; import com.njcn.gather.steady.datavie.pojo.param.SteadyTrendQueryParam;
import com.njcn.gather.steady.datavie.pojo.vo.SteadyTrendPointVO;
import com.njcn.gather.steady.datavie.pojo.vo.SteadyTrendQueryVO; import com.njcn.gather.steady.datavie.pojo.vo.SteadyTrendQueryVO;
import com.njcn.gather.steady.datavie.pojo.vo.SteadyTrendSummaryVO;
import com.njcn.gather.tool.addledger.pojo.vo.AddLedgerLinePathVO;
import com.njcn.gather.tool.addledger.service.AddLedgerService; import com.njcn.gather.tool.addledger.service.AddLedgerService;
import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/** /**
* 稳态趋势服务编排测试。 * 稳态趋势查询服务测试。
*/ */
class SteadyDataViewTrendServiceImplTest { class SteadyDataViewTrendServiceImplTest {
@Test @Test
void shouldBuildTrendQueryWithDefaultBucketAndLineName() { void shouldDisplayLineVoltagePhasesAsLinePairs() {
SteadyTrendFieldResolver fieldResolver = Mockito.mock(SteadyTrendFieldResolver.class); SteadyInfluxQueryComponent influxQueryComponent = mock(SteadyInfluxQueryComponent.class);
SteadyInfluxQueryComponent influxQueryComponent = Mockito.mock(SteadyInfluxQueryComponent.class); AddLedgerService addLedgerService = mock(AddLedgerService.class);
AddLedgerService addLedgerService = Mockito.mock(AddLedgerService.class); SteadyDataViewTrendServiceImpl service = new SteadyDataViewTrendServiceImpl(
new SteadyTrendFieldResolver(new SteadyTrendIndicatorCatalog()), influxQueryComponent, addLedgerService);
when(addLedgerService.listLinePathByLineIds(eq(Collections.singletonList("line-001"))))
.thenReturn(Collections.emptyMap());
when(influxQueryComponent.queryTrendPoints(any(SteadyTrendResolvedFieldBO.class),
any(LocalDateTime.class), any(LocalDateTime.class), eq(0))).thenReturn(Collections.emptyList());
SteadyTrendQueryParam param = new SteadyTrendQueryParam(); SteadyTrendQueryParam param = new SteadyTrendQueryParam();
param.setLineIds(Arrays.asList("line-001")); param.setLineIds(Collections.singletonList("line-001"));
param.setIndicatorCodes(Arrays.asList("V_RMS")); param.setIndicatorCodes(Collections.singletonList("V_LINE_RMS"));
param.setPhases(Arrays.asList("A")); param.setStatTypes(Collections.singletonList("AVG"));
param.setStatTypes(Arrays.asList("AVG"));
param.setTimeStart("2026-05-01 00:00:00"); param.setTimeStart("2026-05-01 00:00:00");
param.setTimeEnd("2026-05-01 05:59:59"); param.setTimeEnd("2026-05-01 01:00:00");
param.setQualityFlag(0);
SteadyTrendResolvedFieldBO field = new SteadyTrendResolvedFieldBO();
field.setMeasurement("data_v");
field.setField("RMS");
field.setLineId("line-001");
field.setIndicatorCode("V_RMS");
field.setIndicatorName("相电压有效值");
field.setSeriesName("相电压有效值");
field.setPhase("A");
field.setStatType("AVG");
field.setUnit("V");
field.setSeriesKey("line-001|V_RMS|A|AVG|RMS");
Mockito.when(fieldResolver.parseRequiredTime("2026-05-01 00:00:00", "开始时间不能为空"))
.thenReturn(LocalDateTime.of(2026, 5, 1, 0, 0, 0));
Mockito.when(fieldResolver.parseRequiredTime("2026-05-01 05:59:59", "结束时间不能为空"))
.thenReturn(LocalDateTime.of(2026, 5, 1, 5, 59, 59));
Mockito.when(fieldResolver.resolveFields(Mockito.any())).thenReturn(Collections.singletonList(field));
Mockito.when(addLedgerService.listLinePathByLineIds(Collections.singletonList("line-001")))
.thenReturn(Collections.singletonMap("line-001", buildLinePath("进线一")));
Mockito.when(influxQueryComponent.queryTrendPoints(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.eq("1m"), Mockito.isNull()))
.thenReturn(Collections.singletonList(new SteadyTrendPointVO("2026-05-01 00:00:00", new BigDecimal("1.2"))));
SteadyDataViewTrendServiceImpl service = new SteadyDataViewTrendServiceImpl(fieldResolver, influxQueryComponent, addLedgerService);
SteadyTrendQueryVO result = service.queryTrend(param); SteadyTrendQueryVO result = service.queryTrend(param);
List<String> phases = result.getSeries().stream()
.map(series -> series.getPhase())
.collect(Collectors.toList());
Assertions.assertEquals("1m", result.getBucket()); Assertions.assertEquals(Arrays.asList("AB", "BC", "CA"), phases);
Assertions.assertTrue(result.getSampled());
Assertions.assertEquals("进线一", result.getSeries().get(0).getLineName());
}
@Test
void shouldCalculateTrendSummaryFromSeriesPoints() {
SteadyTrendFieldResolver fieldResolver = Mockito.mock(SteadyTrendFieldResolver.class);
SteadyInfluxQueryComponent influxQueryComponent = Mockito.mock(SteadyInfluxQueryComponent.class);
AddLedgerService addLedgerService = Mockito.mock(AddLedgerService.class);
SteadyTrendQueryParam param = new SteadyTrendQueryParam();
param.setLineIds(Arrays.asList("line-001"));
param.setIndicatorCodes(Arrays.asList("V_RMS"));
param.setPhases(Arrays.asList("A"));
param.setStatTypes(Arrays.asList("AVG"));
param.setTimeStart("2026-05-01 00:00:00");
param.setTimeEnd("2026-05-01 05:59:59");
SteadyTrendResolvedFieldBO field = new SteadyTrendResolvedFieldBO();
field.setMeasurement("data_v");
field.setField("RMS");
field.setLineId("line-001");
field.setIndicatorCode("V_RMS");
field.setIndicatorName("相电压有效值");
field.setSeriesName("相电压有效值");
field.setPhase("A");
field.setStatType("AVG");
field.setUnit("V");
field.setSeriesKey("line-001|V_RMS|A|AVG|RMS");
Mockito.when(fieldResolver.parseRequiredTime(Mockito.anyString(), Mockito.anyString()))
.thenReturn(LocalDateTime.of(2026, 5, 1, 0, 0, 0))
.thenReturn(LocalDateTime.of(2026, 5, 1, 5, 59, 59));
Mockito.when(fieldResolver.resolveFields(param)).thenReturn(Collections.singletonList(field));
Mockito.when(addLedgerService.listLinePathByLineIds(Collections.singletonList("line-001")))
.thenReturn(Collections.<String, AddLedgerLinePathVO>emptyMap());
Mockito.when(influxQueryComponent.queryTrendPoints(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.isNull(), Mockito.isNull()))
.thenReturn(Arrays.asList(
new SteadyTrendPointVO("2026-05-01 00:00:00", new BigDecimal("1")),
new SteadyTrendPointVO("2026-05-01 01:00:00", new BigDecimal("3")),
new SteadyTrendPointVO("2026-05-01 02:00:00", new BigDecimal("2"))
));
SteadyDataViewTrendServiceImpl service = new SteadyDataViewTrendServiceImpl(fieldResolver, influxQueryComponent, addLedgerService);
SteadyTrendSummaryVO summary = service.summarizeTrend(param);
Assertions.assertEquals(new BigDecimal("3"), summary.getItems().get(0).getMax());
Assertions.assertEquals(new BigDecimal("1"), summary.getItems().get(0).getMin());
Assertions.assertEquals(new BigDecimal("2.000000"), summary.getItems().get(0).getAvg());
Assertions.assertEquals(new BigDecimal("3"), summary.getItems().get(0).getCp95());
}
private AddLedgerLinePathVO buildLinePath(String lineName) {
AddLedgerLinePathVO linePathVO = new AddLedgerLinePathVO();
linePathVO.setLineName(lineName);
return linePathVO;
} }
} }

34
system-ops/README.md Normal file
View File

@@ -0,0 +1,34 @@
# system-ops 模块说明
## 模块定位
`system-ops` 是根工程下的系统运维聚合模块,当前按菜单职责拆分为数据库监控和系统部署两个子模块。
当前包含:
- `dbms`:数据库监控基础入口
- `deploy`:系统部署基础入口
- 系统运维菜单初始化 SQL
## 当前结构
```text
system-ops/
├── pom.xml
├── README.md
├── dbms/
└── deploy/
```
## 当前接口
- `GET /database/overview`
- 查询数据库监控基础信息。
- `GET /deploy/overview`
- 查询系统部署基础信息。
## 当前限制
- 当前只完成后端基础入口,不包含真实数据库探测逻辑。
- 当前只完成后端基础入口,不包含真实系统部署执行逻辑。
- SQL 脚本不自动执行,需要按部署流程手动执行或纳入目标环境初始化流程。

89
system-ops/dbms/README.md Normal file
View File

@@ -0,0 +1,89 @@
# dbms 模块说明
## 模块定位
`dbms``system-ops` 下的数据库运维模块,当前面向 Oracle 数据库提供连接配置、连接测试、表列表查询、备份、恢复、任务状态查询和删除接口。
## 当前接口
- `GET /database/overview`
- 查询数据库运维概览信息。
- `POST /database/connections/list`
- 查询数据库连接配置。
- `POST /database/connections/add`
- 新增 Oracle 数据库连接配置。
- `POST /database/connections/update`
- 修改 Oracle 数据库连接配置。
- `POST /database/connections/delete`
- 删除数据库连接配置。
- `POST /database/connections/test`
- 测试 Oracle 数据库连接。
- `POST /database/connections/tables`
- 查询 Oracle 表列表。
- `POST /database/backups/create`
- 创建备份任务,默认使用 `DATA_PUMP`,可选 `JDBC_EXPORT`
- `POST /database/backups/tasks/list`
- 查询备份任务列表。
- `GET /database/backups/tasks/status`
- 查询任务状态。
- `POST /database/backups/files/list`
- 查询备份文件记录。
- `POST /database/restores/create`
- 创建恢复任务。
- `GET /database/restores/tasks/status`
- 查询恢复任务状态。
- `POST /database/delete/backup-file`
- 删除备份文件,要求 `confirmText=确认删除`
- `POST /database/delete/task`
- 删除任务记录,要求 `confirmText=确认删除`
## 数据脚本
- `src/main/resources/sql/system-ops/system-ops-init.sql`
- 系统运维菜单初始化脚本。
- `src/main/resources/sql/system-ops/dbms-database-ops-init.sql`
- 数据库运维连接、任务、备份文件和恢复记录表结构。
## 配置项
建议通过环境配置覆盖:
```yaml
dbms:
backup:
storage-path: D:/dbms-backup
default-max-file-size-mb: 512
tools:
expdp-path:
impdp-path:
```
说明:
- `backup.storage-path`
- `JDBC_EXPORT` 生成的 CSV 和元数据 JSON 的受管根目录。
- `tools.expdp-path``tools.impdp-path`
- Oracle Data Pump 工具路径;为空时尝试走系统 `PATH`
## 当前行为
- 一期仅支持 `ORACLE`
- 连接密码支持两种运行方式:
- 前端每次传 `temporaryPassword`
- 连接已保存密文,且公共 `Sm4Utils` 提供 `decryptData_ECB` 时由后端自动解密复用。
- 新增连接前的测试接口允许只传 `temporaryPassword`,不强制把密码写进 `connection.password`
- 备份任务异步执行,只有实际文件生成成功后才会写入 `dbms_backup_file` 记录。
- `JDBC_EXPORT` 当前会生成两类文件:
- 主数据文件:`*.csv`
- 元数据文件:`*_metadata_yyyyMMdd_<taskNo>.json`
- `JDBC_EXPORT` 恢复依赖元数据文件,不再允许缺少元数据直接发起恢复。
- 删除备份文件时,会校验目标路径必须位于受管备份目录下,避免误删非备份文件。
## 当前限制
- `DATA_PUMP` 仍依赖部署机器可执行 `expdp``impdp`,并且 Oracle 侧已准备好 `directory` 对象和权限。
- 当前代码要求 `DATA_PUMP` 连接配置里补齐可管理的 `directoryPath`,否则虽然 Oracle 端可能已导出成功,后端无法安全管理文件记录与删除。
- `JDBC_EXPORT` 恢复当前仅覆盖表数据,不承诺恢复索引、约束、触发器、序列、存储过程、权限等 Oracle 对象。
- `TIME_RANGE` 模式当前只在 `JDBC_EXPORT` 场景真正参与查询过滤;`DATA_PUMP` 尚未接入 Oracle `QUERY` 参数。
- `SIZE_SPLIT` 参数目前已做入参校验,但尚未实现真正的导出分片。
- 本轮仅完成代码路径和文档收口,未执行 `mvn` 编译、测试或真实库联调。

39
system-ops/dbms/pom.xml Normal file
View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.njcn.gather</groupId>
<artifactId>system-ops</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>dbms</artifactId>
<packaging>jar</packaging>
<name>dbms</name>
<dependencies>
<dependency>
<groupId>com.njcn</groupId>
<artifactId>njcn-common</artifactId>
<version>0.0.1</version>
</dependency>
<dependency>
<groupId>com.njcn</groupId>
<artifactId>spingboot2.3.12</artifactId>
<version>2.3.12</version>
</dependency>
<dependency>
<groupId>com.njcn</groupId>
<artifactId>mybatis-plus</artifactId>
<version>0.0.1</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,111 @@
package com.njcn.gather.systemops.database.component;
import cn.hutool.core.util.StrUtil;
import com.njcn.gather.systemops.database.config.DbmsProperties;
import com.njcn.gather.systemops.database.pojo.po.DatabaseConnection;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.List;
/**
* Oracle Data Pump 命令执行组件。
*/
@Component
@RequiredArgsConstructor
public class DataPumpCommandExecutor {
private final DbmsProperties dbmsProperties;
public CommandResult expdp(DatabaseConnection connection, String password, String directoryName,
String dumpFileName, String logFileName, List<String> tableNames) {
List<String> command = new ArrayList<>();
command.add(resolveTool(dbmsProperties.getTools().getExpdpPath(), "expdp"));
fillCommonArgs(command, connection, password, directoryName, dumpFileName, logFileName);
if (tableNames != null && !tableNames.isEmpty()) {
command.add("tables=" + connection.getSchemaName() + "." + String.join("," + connection.getSchemaName() + ".", tableNames));
}
return execute(command);
}
public CommandResult impdp(DatabaseConnection connection, String password, String directoryName,
String dumpFileName, String logFileName, String tableExistsAction) {
List<String> command = new ArrayList<>();
command.add(resolveTool(dbmsProperties.getTools().getImpdpPath(), "impdp"));
fillCommonArgs(command, connection, password, directoryName, dumpFileName, logFileName);
if (StrUtil.isNotBlank(tableExistsAction)) {
command.add("table_exists_action=" + tableExistsAction);
}
return execute(command);
}
private void fillCommonArgs(List<String> command, DatabaseConnection connection, String password,
String directoryName, String dumpFileName, String logFileName) {
command.add(connection.getUsername() + "/" + password + "@" + buildConnectIdentifier(connection));
command.add("directory=" + directoryName);
command.add("dumpfile=" + dumpFileName);
command.add("logfile=" + logFileName);
}
private String buildConnectIdentifier(DatabaseConnection connection) {
if (StrUtil.isNotBlank(connection.getServiceName())) {
return "//" + connection.getHost() + ":" + connection.getPort() + "/" + connection.getServiceName();
}
return connection.getHost() + ":" + connection.getPort() + ":" + connection.getSid();
}
private String resolveTool(String configuredPath, String defaultName) {
return StrUtil.blankToDefault(configuredPath, defaultName);
}
private CommandResult execute(List<String> command) {
CommandResult result = new CommandResult();
result.setCommand(maskPassword(command));
try {
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
StringBuilder output = new StringBuilder();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), Charset.defaultCharset()))) {
String line;
while ((line = reader.readLine()) != null) {
output.append(line).append(System.lineSeparator());
}
}
int exitCode = process.waitFor();
result.setExitCode(exitCode);
result.setOutput(output.toString());
result.setSuccess(exitCode == 0);
} catch (Exception exception) {
result.setExitCode(-1);
result.setOutput(exception.getMessage());
result.setSuccess(false);
}
return result;
}
private String maskPassword(List<String> command) {
if (command.size() < 2) {
return String.join(" ", command);
}
List<String> masked = new ArrayList<>(command);
String credential = masked.get(1);
int slashIndex = credential.indexOf('/');
int atIndex = credential.indexOf('@');
if (slashIndex > 0 && atIndex > slashIndex) {
masked.set(1, credential.substring(0, slashIndex + 1) + "******" + credential.substring(atIndex));
}
return String.join(" ", masked);
}
@Data
public static class CommandResult {
private Boolean success;
private Integer exitCode;
private String command;
private String output;
}
}

View File

@@ -0,0 +1,44 @@
package com.njcn.gather.systemops.database.component;
import cn.hutool.core.util.StrUtil;
import com.njcn.common.utils.sm.Sm4Utils;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
/**
* 数据库连接密码处理组件。
*/
@Component
public class DatabasePasswordComponent {
public String encrypt(String plainText) {
if (StrUtil.isBlank(plainText)) {
return null;
}
return new Sm4Utils(Sm4Utils.globalSecretKey).encryptData_ECB(plainText);
}
/**
* 优先使用本次请求传入的临时密码;如果公共 SM4 工具存在解密能力,则复用已保存密文。
*/
public String resolveRuntimePassword(String passwordCipher, String temporaryPassword) {
if (StrUtil.isNotBlank(temporaryPassword)) {
return temporaryPassword;
}
if (StrUtil.isBlank(passwordCipher)) {
return null;
}
try {
Sm4Utils sm4Utils = new Sm4Utils(Sm4Utils.globalSecretKey);
Method decryptMethod = Sm4Utils.class.getMethod("decryptData_ECB", String.class);
Object plainText = decryptMethod.invoke(sm4Utils, passwordCipher);
if (plainText instanceof String && StrUtil.isNotBlank((String) plainText)) {
return (String) plainText;
}
} catch (Exception ignored) {
// 兼容公共工具不同版本,未找到解密方法时继续走统一失败提示。
}
throw new IllegalArgumentException("当前环境未确认密码解密方法,请传入临时密码执行本次操作");
}
}

View File

@@ -0,0 +1,37 @@
package com.njcn.gather.systemops.database.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 数据库运维后台任务线程池。
*/
@Slf4j
@Configuration
public class DbmsExecutorConfig {
@Bean(name = "dbmsTaskExecutorService", destroyMethod = "shutdown")
public ExecutorService dbmsTaskExecutorService() {
AtomicInteger threadIndex = new AtomicInteger(1);
return new ThreadPoolExecutor(
1,
1,
30,
TimeUnit.SECONDS,
new LinkedBlockingQueue<Runnable>(8),
runnable -> {
Thread thread = new Thread(runnable);
thread.setName("dbms-task-" + threadIndex.getAndIncrement());
return thread;
},
(runnable, executor) -> log.warn("数据库运维任务线程池已满,拒绝新的任务")
);
}
}

View File

@@ -0,0 +1,29 @@
package com.njcn.gather.systemops.database.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 数据库运维配置。
*/
@Data
@Component
@ConfigurationProperties(prefix = "dbms")
public class DbmsProperties {
private Backup backup = new Backup();
private Tools tools = new Tools();
@Data
public static class Backup {
private String storagePath = "D:/dbms-backup";
private Integer defaultMaxFileSizeMb = 512;
}
@Data
public static class Tools {
private String expdpPath;
private String impdpPath;
}
}

View File

@@ -0,0 +1,20 @@
package com.njcn.gather.systemops.database.constant;
/**
* 数据库运维常量。
*/
public final class DatabaseOpsConst {
public static final String DB_TYPE_ORACLE = "ORACLE";
public static final String CONNECT_TYPE_SERVICE_NAME = "SERVICE_NAME";
public static final String CONNECT_TYPE_SID = "SID";
public static final String CONFIRM_DELETE = "确认删除";
public static final String CONFIRM_OVERWRITE = "确认覆盖";
public static final int STATE_DELETED = 0;
public static final int STATE_ENABLED = 1;
public static final int SAVE_PASSWORD_YES = 1;
public static final int SAVE_PASSWORD_NO = 0;
private DatabaseOpsConst() {
}
}

View File

@@ -0,0 +1,71 @@
package com.njcn.gather.systemops.database.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.njcn.common.pojo.annotation.OperateInfo;
import com.njcn.common.pojo.constant.OperateType;
import com.njcn.common.pojo.enums.common.LogEnum;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.response.HttpResult;
import com.njcn.gather.systemops.database.pojo.param.DatabaseBackupParam;
import com.njcn.gather.systemops.database.pojo.vo.DatabaseBackupFileVO;
import com.njcn.gather.systemops.database.pojo.vo.DatabaseTaskCreateVO;
import com.njcn.gather.systemops.database.pojo.vo.DatabaseTaskVO;
import com.njcn.gather.systemops.database.service.DatabaseBackupFileService;
import com.njcn.gather.systemops.database.service.DatabaseOperationTaskService;
import com.njcn.web.controller.BaseController;
import com.njcn.web.utils.HttpResultUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 数据库备份接口。
*/
@Api(tags = "数据库备份")
@RestController
@RequestMapping("/database/backups")
@RequiredArgsConstructor
public class DatabaseBackupController extends BaseController {
private final DatabaseOperationTaskService databaseOperationTaskService;
private final DatabaseBackupFileService databaseBackupFileService;
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.ADD)
@ApiOperation("创建备份任务")
@PostMapping("/create")
public HttpResult<DatabaseTaskCreateVO> create(@RequestBody @Validated DatabaseBackupParam.CreateParam param) {
String methodDescribe = getMethodDescribe("create");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS, databaseOperationTaskService.createBackupTask(param), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("查询备份任务")
@PostMapping("/tasks/list")
public HttpResult<Page<DatabaseTaskVO>> listTasks(@RequestBody @Validated DatabaseBackupParam.TaskQueryParam param) {
String methodDescribe = getMethodDescribe("listTasks");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS, databaseOperationTaskService.listBackupTasks(param), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("查询任务状态")
@GetMapping("/tasks/status")
public HttpResult<DatabaseTaskVO> status(@RequestParam("taskId") String taskId) {
String methodDescribe = getMethodDescribe("status");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS, databaseOperationTaskService.getStatus(taskId), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("查询备份文件")
@PostMapping("/files/list")
public HttpResult<Page<DatabaseBackupFileVO>> listFiles(@RequestBody @Validated DatabaseBackupParam.FileQueryParam param) {
String methodDescribe = getMethodDescribe("listFiles");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS, databaseBackupFileService.listFiles(param), methodDescribe);
}
}

View File

@@ -0,0 +1,88 @@
package com.njcn.gather.systemops.database.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.njcn.common.pojo.annotation.OperateInfo;
import com.njcn.common.pojo.constant.OperateType;
import com.njcn.common.pojo.enums.common.LogEnum;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.response.HttpResult;
import com.njcn.gather.systemops.database.pojo.param.DatabaseConnectionParam;
import com.njcn.gather.systemops.database.pojo.vo.DatabaseConnectionVO;
import com.njcn.gather.systemops.database.pojo.vo.DatabaseTableVO;
import com.njcn.gather.systemops.database.pojo.vo.DatabaseTestResultVO;
import com.njcn.gather.systemops.database.service.DatabaseConnectionService;
import com.njcn.web.controller.BaseController;
import com.njcn.web.utils.HttpResultUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 数据库连接配置接口。
*/
@Api(tags = "数据库连接配置")
@RestController
@RequestMapping("/database/connections")
@RequiredArgsConstructor
public class DatabaseConnectionController extends BaseController {
private final DatabaseConnectionService databaseConnectionService;
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("查询数据库连接配置")
@PostMapping("/list")
public HttpResult<Page<DatabaseConnectionVO>> list(@RequestBody @Validated DatabaseConnectionParam.QueryParam param) {
String methodDescribe = getMethodDescribe("list");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS, databaseConnectionService.listConnections(param), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.ADD)
@ApiOperation("新增数据库连接配置")
@PostMapping("/add")
public HttpResult<Boolean> add(@RequestBody @Validated DatabaseConnectionParam param) {
String methodDescribe = getMethodDescribe("add");
boolean result = databaseConnectionService.addConnection(param);
return HttpResultUtil.assembleCommonResponseResult(result ? CommonResponseEnum.SUCCESS : CommonResponseEnum.FAIL, result, methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.UPDATE)
@ApiOperation("修改数据库连接配置")
@PostMapping("/update")
public HttpResult<Boolean> update(@RequestBody @Validated DatabaseConnectionParam.UpdateParam param) {
String methodDescribe = getMethodDescribe("update");
boolean result = databaseConnectionService.updateConnection(param);
return HttpResultUtil.assembleCommonResponseResult(result ? CommonResponseEnum.SUCCESS : CommonResponseEnum.FAIL, result, methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.DELETE)
@ApiOperation("删除数据库连接配置")
@PostMapping("/delete")
public HttpResult<Boolean> delete(@RequestBody @Validated DatabaseConnectionParam.DeleteParam param) {
String methodDescribe = getMethodDescribe("delete");
boolean result = databaseConnectionService.deleteConnection(param);
return HttpResultUtil.assembleCommonResponseResult(result ? CommonResponseEnum.SUCCESS : CommonResponseEnum.FAIL, result, methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("测试数据库连接")
@PostMapping("/test")
public HttpResult<DatabaseTestResultVO> test(@RequestBody @Validated DatabaseConnectionParam.TestParam param) {
String methodDescribe = getMethodDescribe("test");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS, databaseConnectionService.testConnection(param), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("查询 Oracle 表列表")
@PostMapping("/tables")
public HttpResult<List<DatabaseTableVO>> tables(@RequestBody @Validated DatabaseConnectionParam.TablesParam param) {
String methodDescribe = getMethodDescribe("tables");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS, databaseConnectionService.listTables(param), methodDescribe);
}
}

View File

@@ -0,0 +1,41 @@
package com.njcn.gather.systemops.database.controller;
import com.njcn.common.pojo.annotation.OperateInfo;
import com.njcn.common.pojo.enums.common.LogEnum;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.response.HttpResult;
import com.njcn.common.utils.LogUtil;
import com.njcn.gather.systemops.database.pojo.vo.DatabaseOverviewVO;
import com.njcn.gather.systemops.database.service.DatabaseService;
import com.njcn.web.controller.BaseController;
import com.njcn.web.utils.HttpResultUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 数据库监控基础接口。
*/
@Slf4j
@Api(tags = "数据库监控")
@RestController
@RequestMapping("/database")
@RequiredArgsConstructor
public class DatabaseController extends BaseController {
private final DatabaseService databaseService;
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("查询数据库监控基础信息")
@GetMapping("/overview")
public HttpResult<DatabaseOverviewVO> overview() {
String methodDescribe = getMethodDescribe("overview");
LogUtil.njcnDebug(log, "{},开始查询数据库监控基础信息", methodDescribe);
DatabaseOverviewVO result = databaseService.overview();
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS, result, methodDescribe);
}
}

View File

@@ -0,0 +1,51 @@
package com.njcn.gather.systemops.database.controller;
import com.njcn.common.pojo.annotation.OperateInfo;
import com.njcn.common.pojo.constant.OperateType;
import com.njcn.common.pojo.enums.common.LogEnum;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.response.HttpResult;
import com.njcn.gather.systemops.database.pojo.param.DatabaseDeleteParam;
import com.njcn.gather.systemops.database.service.DatabaseBackupFileService;
import com.njcn.gather.systemops.database.service.DatabaseOperationTaskService;
import com.njcn.web.controller.BaseController;
import com.njcn.web.utils.HttpResultUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 数据库运维删除接口。
*/
@Api(tags = "数据库运维删除")
@RestController
@RequestMapping("/database/delete")
@RequiredArgsConstructor
public class DatabaseDeleteController extends BaseController {
private final DatabaseBackupFileService databaseBackupFileService;
private final DatabaseOperationTaskService databaseOperationTaskService;
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.DELETE)
@ApiOperation("删除备份文件")
@PostMapping("/backup-file")
public HttpResult<Boolean> deleteBackupFile(@RequestBody @Validated DatabaseDeleteParam.BackupFileParam param) {
String methodDescribe = getMethodDescribe("deleteBackupFile");
boolean result = databaseBackupFileService.deleteBackupFile(param.getBackupFileId(), param.getConfirmText());
return HttpResultUtil.assembleCommonResponseResult(result ? CommonResponseEnum.SUCCESS : CommonResponseEnum.FAIL, result, methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.DELETE)
@ApiOperation("删除任务记录")
@PostMapping("/task")
public HttpResult<Boolean> deleteTask(@RequestBody @Validated DatabaseDeleteParam.TaskParam param) {
String methodDescribe = getMethodDescribe("deleteTask");
boolean result = databaseOperationTaskService.deleteTask(param.getTaskId(), param.getConfirmText());
return HttpResultUtil.assembleCommonResponseResult(result ? CommonResponseEnum.SUCCESS : CommonResponseEnum.FAIL, result, methodDescribe);
}
}

View File

@@ -0,0 +1,53 @@
package com.njcn.gather.systemops.database.controller;
import com.njcn.common.pojo.annotation.OperateInfo;
import com.njcn.common.pojo.constant.OperateType;
import com.njcn.common.pojo.enums.common.LogEnum;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.response.HttpResult;
import com.njcn.gather.systemops.database.pojo.param.DatabaseRestoreParam;
import com.njcn.gather.systemops.database.pojo.vo.DatabaseTaskCreateVO;
import com.njcn.gather.systemops.database.pojo.vo.DatabaseTaskVO;
import com.njcn.gather.systemops.database.service.DatabaseOperationTaskService;
import com.njcn.gather.systemops.database.service.DatabaseRestoreService;
import com.njcn.web.controller.BaseController;
import com.njcn.web.utils.HttpResultUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 数据库恢复接口。
*/
@Api(tags = "数据库恢复")
@RestController
@RequestMapping("/database/restores")
@RequiredArgsConstructor
public class DatabaseRestoreController extends BaseController {
private final DatabaseRestoreService databaseRestoreService;
private final DatabaseOperationTaskService databaseOperationTaskService;
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.ADD)
@ApiOperation("创建恢复任务")
@PostMapping("/create")
public HttpResult<DatabaseTaskCreateVO> create(@RequestBody @Validated DatabaseRestoreParam.CreateParam param) {
String methodDescribe = getMethodDescribe("create");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS, databaseRestoreService.createRestoreTask(param), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("查询恢复任务状态")
@GetMapping("/tasks/status")
public HttpResult<DatabaseTaskVO> status(@RequestParam("taskId") String taskId) {
String methodDescribe = getMethodDescribe("status");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS, databaseOperationTaskService.getStatus(taskId), methodDescribe);
}
}

View File

@@ -0,0 +1,10 @@
package com.njcn.gather.systemops.database.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.njcn.gather.systemops.database.pojo.po.DatabaseBackupFile;
/**
* 数据库备份文件 Mapper。
*/
public interface DatabaseBackupFileMapper extends BaseMapper<DatabaseBackupFile> {
}

View File

@@ -0,0 +1,10 @@
package com.njcn.gather.systemops.database.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.njcn.gather.systemops.database.pojo.po.DatabaseConnection;
/**
* 数据库连接配置 Mapper。
*/
public interface DatabaseConnectionMapper extends BaseMapper<DatabaseConnection> {
}

View File

@@ -0,0 +1,10 @@
package com.njcn.gather.systemops.database.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.njcn.gather.systemops.database.pojo.po.DatabaseOperationTask;
/**
* 数据库运维任务 Mapper。
*/
public interface DatabaseOperationTaskMapper extends BaseMapper<DatabaseOperationTask> {
}

View File

@@ -0,0 +1,10 @@
package com.njcn.gather.systemops.database.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.njcn.gather.systemops.database.pojo.po.DatabaseRestoreRecord;
/**
* 数据库恢复记录 Mapper。
*/
public interface DatabaseRestoreRecordMapper extends BaseMapper<DatabaseRestoreRecord> {
}

View File

@@ -0,0 +1,10 @@
package com.njcn.gather.systemops.database.pojo.enums;
/**
* 备份模式。
*/
public enum BackupModeEnum {
FULL_TABLE,
TIME_RANGE,
SIZE_SPLIT
}

View File

@@ -0,0 +1,9 @@
package com.njcn.gather.systemops.database.pojo.enums;
/**
* 备份策略。
*/
public enum BackupStrategyEnum {
DATA_PUMP,
JDBC_EXPORT
}

View File

@@ -0,0 +1,10 @@
package com.njcn.gather.systemops.database.pojo.enums;
/**
* 备份文件格式。
*/
public enum FileFormatEnum {
DMP,
SQL,
CSV
}

View File

@@ -0,0 +1,12 @@
package com.njcn.gather.systemops.database.pojo.enums;
/**
* 运维任务状态。
*/
public enum TaskStatusEnum {
WAITING,
RUNNING,
SUCCESS,
FAIL,
CANCELLED
}

View File

@@ -0,0 +1,67 @@
package com.njcn.gather.systemops.database.pojo.param;
import com.njcn.web.pojo.param.BaseParam;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.validation.constraints.NotBlank;
import java.time.LocalDateTime;
import java.util.List;
/**
* 数据库备份参数。
*/
public class DatabaseBackupParam {
@Data
@ApiModel("创建备份任务参数")
public static class CreateParam {
@ApiModelProperty("连接 ID")
@NotBlank(message = "连接 ID 不能为空")
private String connectionId;
@ApiModelProperty("备份策略DATA_PUMP、JDBC_EXPORT默认 DATA_PUMP")
private String backupStrategy;
@ApiModelProperty("Schema")
private String schemaName;
@ApiModelProperty("表名列表")
private List<String> targetNames;
@ApiModelProperty("备份模式FULL_TABLE、TIME_RANGE、SIZE_SPLIT")
private String backupMode;
@ApiModelProperty("时间字段")
private String timeColumn;
@ApiModelProperty("开始时间")
private LocalDateTime startTime;
@ApiModelProperty("结束时间")
private LocalDateTime endTime;
@ApiModelProperty("最大文件大小 MB")
private Integer maxFileSizeMb;
@ApiModelProperty("Oracle Directory 名称")
private String directoryName;
@ApiModelProperty("临时密码,不保存密码时传入")
private String temporaryPassword;
}
@Data
@EqualsAndHashCode(callSuper = true)
@ApiModel("备份任务查询参数")
public static class TaskQueryParam extends BaseParam {
@ApiModelProperty("连接 ID")
private String connectionId;
@ApiModelProperty("任务状态")
private String taskStatus;
}
@Data
@EqualsAndHashCode(callSuper = true)
@ApiModel("备份文件查询参数")
public static class FileQueryParam extends BaseParam {
@ApiModelProperty("连接 ID")
private String connectionId;
@ApiModelProperty("任务 ID")
private String taskId;
@ApiModelProperty("备份策略")
private String backupStrategy;
}
}

View File

@@ -0,0 +1,119 @@
package com.njcn.gather.systemops.database.pojo.param;
import com.njcn.web.pojo.param.BaseParam;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
/**
* 数据库连接配置参数。
*/
@Data
@ApiModel("数据库连接配置参数")
public class DatabaseConnectionParam {
@ApiModelProperty("连接名称")
@NotBlank(message = "连接名称不能为空")
private String connectionName;
@ApiModelProperty("数据库类型,一期固定 ORACLE")
private String dbType;
@ApiModelProperty("数据库主机地址")
@NotBlank(message = "数据库主机地址不能为空")
private String host;
@ApiModelProperty("数据库端口")
@NotNull(message = "数据库端口不能为空")
private Integer port;
@ApiModelProperty("连接类型SERVICE_NAME、SID")
private String connectType;
@ApiModelProperty("服务名")
private String serviceName;
@ApiModelProperty("SID")
private String sid;
@ApiModelProperty("Schema")
private String schemaName;
@ApiModelProperty("用户名")
@NotBlank(message = "用户名不能为空")
private String username;
@ApiModelProperty("密码")
private String password;
@ApiModelProperty("是否保存密码0-否1-是")
private Integer savePassword;
@ApiModelProperty("Oracle Directory 名称")
private String directoryName;
@ApiModelProperty("Oracle Directory 物理路径")
private String directoryPath;
@ApiModelProperty("扩展配置 JSON")
private String extraConfigJson;
@ApiModelProperty("备注")
private String remark;
@Data
@EqualsAndHashCode(callSuper = true)
@ApiModel("数据库连接更新参数")
public static class UpdateParam extends DatabaseConnectionParam {
@ApiModelProperty("连接 ID")
@NotBlank(message = "连接 ID 不能为空")
private String id;
}
@Data
@EqualsAndHashCode(callSuper = true)
@ApiModel("数据库连接查询参数")
public static class QueryParam extends BaseParam {
@ApiModelProperty("连接名称")
private String connectionName;
@ApiModelProperty("数据库类型")
private String dbType;
@ApiModelProperty("Schema")
private String schemaName;
}
@Data
@ApiModel("数据库连接删除参数")
public static class DeleteParam {
@ApiModelProperty("连接 ID")
@NotBlank(message = "连接 ID 不能为空")
private String id;
}
@Data
@ApiModel("数据库连接测试参数")
public static class TestParam {
@ApiModelProperty("连接 ID已有连接测试时传入")
private String connectionId;
@ApiModelProperty("临时连接参数,新增前测试时传入")
private DatabaseConnectionParam connection;
@ApiModelProperty("临时密码,测试时允许只传该字段而不写入 connection.password")
private String temporaryPassword;
}
@Data
@ApiModel("数据库表查询参数")
public static class TablesParam {
@ApiModelProperty("连接 ID")
@NotBlank(message = "连接 ID 不能为空")
private String connectionId;
@ApiModelProperty("临时密码,不保存密码时传入")
private String temporaryPassword;
@ApiModelProperty("Schema不传则使用连接默认 Schema")
private String schemaName;
}
}

View File

@@ -0,0 +1,33 @@
package com.njcn.gather.systemops.database.pojo.param;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 数据库运维删除参数。
*/
public class DatabaseDeleteParam {
@Data
@ApiModel("删除备份文件参数")
public static class BackupFileParam {
@ApiModelProperty("备份文件 ID")
@NotBlank(message = "备份文件 ID 不能为空")
private String backupFileId;
@ApiModelProperty("确认文案")
private String confirmText;
}
@Data
@ApiModel("删除任务参数")
public static class TaskParam {
@ApiModelProperty("任务 ID")
@NotBlank(message = "任务 ID 不能为空")
private String taskId;
@ApiModelProperty("确认文案")
private String confirmText;
}
}

View File

@@ -0,0 +1,32 @@
package com.njcn.gather.systemops.database.pojo.param;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 数据库恢复参数。
*/
public class DatabaseRestoreParam {
@Data
@ApiModel("创建恢复任务参数")
public static class CreateParam {
@ApiModelProperty("目标连接 ID")
@NotBlank(message = "连接 ID 不能为空")
private String connectionId;
@ApiModelProperty("备份文件 ID")
@NotBlank(message = "备份文件 ID 不能为空")
private String backupFileId;
@ApiModelProperty("恢复模式SKIP、APPEND、TRUNCATE、REPLACE")
private String restoreMode;
@ApiModelProperty("目标 Schema")
private String targetSchemaName;
@ApiModelProperty("临时密码,不保存密码时传入")
private String temporaryPassword;
@ApiModelProperty("覆盖确认文案")
private String overwriteConfirmText;
}
}

View File

@@ -0,0 +1,71 @@
package com.njcn.gather.systemops.database.pojo.po;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 数据库备份文件记录。
*/
@Data
@TableName("dbms_backup_file")
public class DatabaseBackupFile implements Serializable {
private static final long serialVersionUID = 3119981982091873277L;
@TableId("id")
private String id;
@TableField("task_id")
private String taskId;
@TableField("connection_id")
private String connectionId;
@TableField("db_type")
private String dbType;
@TableField("backup_strategy")
private String backupStrategy;
@TableField("file_format")
private String fileFormat;
@TableField("schema_name")
private String schemaName;
@TableField("target_names_json")
private String targetNamesJson;
@TableField("backup_mode")
private String backupMode;
@TableField("backup_start_time")
private LocalDateTime backupStartTime;
@TableField("backup_end_time")
private LocalDateTime backupEndTime;
@TableField("time_column")
private String timeColumn;
@TableField("directory_name")
private String directoryName;
@TableField("dump_file_name")
private String dumpFileName;
@TableField("log_file_name")
private String logFileName;
@TableField("file_name")
private String fileName;
@TableField("file_path")
private String filePath;
@TableField("log_file_path")
private String logFilePath;
@TableField("metadata_file_path")
private String metadataFilePath;
@TableField("file_size")
private Long fileSize;
@TableField("checksum")
private String checksum;
@TableField("state")
private Integer state;
@TableField("create_by")
private String createBy;
@TableField("create_time")
private LocalDateTime createTime;
@TableField("update_by")
private String updateBy;
@TableField("update_time")
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,69 @@
package com.njcn.gather.systemops.database.pojo.po;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 数据库连接配置。
*/
@Data
@TableName("dbms_connection")
public class DatabaseConnection implements Serializable {
private static final long serialVersionUID = -5821519248914313778L;
@TableId("id")
private String id;
@TableField("connection_name")
private String connectionName;
@TableField("db_type")
private String dbType;
@TableField("host")
private String host;
@TableField("port")
private Integer port;
@TableField("connect_type")
private String connectType;
@TableField("service_name")
private String serviceName;
@TableField("sid")
private String sid;
@TableField("database_name")
private String databaseName;
@TableField("schema_name")
private String schemaName;
@TableField("username")
private String username;
@TableField("password_cipher")
private String passwordCipher;
@TableField("save_password")
private Integer savePassword;
@TableField("directory_name")
private String directoryName;
@TableField("directory_path")
private String directoryPath;
@TableField("extra_config_json")
private String extraConfigJson;
@TableField("remark")
private String remark;
@TableField("last_test_status")
private String lastTestStatus;
@TableField("last_test_message")
private String lastTestMessage;
@TableField("last_test_time")
private LocalDateTime lastTestTime;
@TableField("state")
private Integer state;
@TableField("create_by")
private String createBy;
@TableField("create_time")
private LocalDateTime createTime;
@TableField("update_by")
private String updateBy;
@TableField("update_time")
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,58 @@
package com.njcn.gather.systemops.database.pojo.po;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 数据库运维任务。
*/
@Data
@TableName("dbms_operation_task")
public class DatabaseOperationTask implements Serializable {
private static final long serialVersionUID = 1831235987236858769L;
@TableId("id")
private String id;
@TableField("task_no")
private String taskNo;
@TableField("connection_id")
private String connectionId;
@TableField("db_type")
private String dbType;
@TableField("operation_type")
private String operationType;
@TableField("backup_strategy")
private String backupStrategy;
@TableField("task_status")
private String taskStatus;
@TableField("schema_name")
private String schemaName;
@TableField("target_names_json")
private String targetNamesJson;
@TableField("request_param_json")
private String requestParamJson;
@TableField("result_message")
private String resultMessage;
@TableField("progress_percent")
private BigDecimal progressPercent;
@TableField("started_at")
private LocalDateTime startedAt;
@TableField("finished_at")
private LocalDateTime finishedAt;
@TableField("state")
private Integer state;
@TableField("create_by")
private String createBy;
@TableField("create_time")
private LocalDateTime createTime;
@TableField("update_by")
private String updateBy;
@TableField("update_time")
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,51 @@
package com.njcn.gather.systemops.database.pojo.po;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 数据库恢复记录。
*/
@Data
@TableName("dbms_restore_record")
public class DatabaseRestoreRecord implements Serializable {
private static final long serialVersionUID = -5638979151924581277L;
@TableId("id")
private String id;
@TableField("task_id")
private String taskId;
@TableField("backup_file_id")
private String backupFileId;
@TableField("connection_id")
private String connectionId;
@TableField("db_type")
private String dbType;
@TableField("restore_mode")
private String restoreMode;
@TableField("target_schema_name")
private String targetSchemaName;
@TableField("target_names_json")
private String targetNamesJson;
@TableField("table_exists_action")
private String tableExistsAction;
@TableField("overwrite_confirmed")
private Integer overwriteConfirmed;
@TableField("result_message")
private String resultMessage;
@TableField("state")
private Integer state;
@TableField("create_by")
private String createBy;
@TableField("create_time")
private LocalDateTime createTime;
@TableField("update_by")
private String updateBy;
@TableField("update_time")
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,29 @@
package com.njcn.gather.systemops.database.pojo.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 数据库备份文件响应。
*/
@Data
public class DatabaseBackupFileVO {
private String id;
private String taskId;
private String connectionId;
private String dbType;
private String backupStrategy;
private String fileFormat;
private String schemaName;
private String targetNamesJson;
private String backupMode;
private String fileName;
private String filePath;
private String logFileName;
private String logFilePath;
private Long fileSize;
private String checksum;
private Integer state;
private LocalDateTime createTime;
}

View File

@@ -0,0 +1,33 @@
package com.njcn.gather.systemops.database.pojo.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 数据库连接配置响应。
*/
@Data
public class DatabaseConnectionVO {
private String id;
private String connectionName;
private String dbType;
private String host;
private Integer port;
private String connectType;
private String serviceName;
private String sid;
private String schemaName;
private String username;
private Integer savePassword;
private String directoryName;
private String directoryPath;
private String extraConfigJson;
private String remark;
private String lastTestStatus;
private String lastTestMessage;
private LocalDateTime lastTestTime;
private Integer state;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,38 @@
package com.njcn.gather.systemops.database.pojo.vo;
import lombok.Data;
import java.io.Serializable;
/**
* 数据库监控基础信息。
*/
@Data
public class DatabaseOverviewVO implements Serializable {
private static final long serialVersionUID = -6645576505607222597L;
/**
* 菜单名称。
*/
private String menuName;
/**
* 菜单编码。
*/
private String menuCode;
/**
* 菜单路径。
*/
private String path;
/**
* 基础功能状态。
*/
private String status;
/**
* 功能说明。
*/
private String description;
}

View File

@@ -0,0 +1,13 @@
package com.njcn.gather.systemops.database.pojo.vo;
import lombok.Data;
/**
* 数据库表信息。
*/
@Data
public class DatabaseTableVO {
private String owner;
private String tableName;
private String comments;
}

View File

@@ -0,0 +1,13 @@
package com.njcn.gather.systemops.database.pojo.vo;
import lombok.Data;
/**
* 运维任务创建结果。
*/
@Data
public class DatabaseTaskCreateVO {
private String taskId;
private String taskNo;
private String taskStatus;
}

View File

@@ -0,0 +1,28 @@
package com.njcn.gather.systemops.database.pojo.vo;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 数据库运维任务响应。
*/
@Data
public class DatabaseTaskVO {
private String id;
private String taskNo;
private String connectionId;
private String dbType;
private String operationType;
private String backupStrategy;
private String taskStatus;
private String schemaName;
private String targetNamesJson;
private String resultMessage;
private BigDecimal progressPercent;
private LocalDateTime startedAt;
private LocalDateTime finishedAt;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,12 @@
package com.njcn.gather.systemops.database.pojo.vo;
import lombok.Data;
/**
* 数据库连接测试结果。
*/
@Data
public class DatabaseTestResultVO {
private Boolean success;
private String message;
}

View File

@@ -0,0 +1,23 @@
package com.njcn.gather.systemops.database.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.njcn.gather.systemops.database.pojo.param.DatabaseBackupParam;
import com.njcn.gather.systemops.database.pojo.po.DatabaseBackupFile;
import com.njcn.gather.systemops.database.pojo.vo.DatabaseBackupFileVO;
import java.nio.file.Path;
/**
* 数据库备份文件服务。
*/
public interface DatabaseBackupFileService extends IService<DatabaseBackupFile> {
Page<DatabaseBackupFileVO> listFiles(DatabaseBackupParam.FileQueryParam param);
boolean deleteBackupFile(String backupFileId, String confirmText);
void validateBackupFileReadable(DatabaseBackupFile backupFile);
Path resolveManagedPath(DatabaseBackupFile backupFile, String filePath);
}

View File

@@ -0,0 +1,33 @@
package com.njcn.gather.systemops.database.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.njcn.gather.systemops.database.pojo.param.DatabaseConnectionParam;
import com.njcn.gather.systemops.database.pojo.po.DatabaseConnection;
import com.njcn.gather.systemops.database.pojo.vo.DatabaseConnectionVO;
import com.njcn.gather.systemops.database.pojo.vo.DatabaseTableVO;
import com.njcn.gather.systemops.database.pojo.vo.DatabaseTestResultVO;
import java.util.List;
/**
* 数据库连接配置服务。
*/
public interface DatabaseConnectionService extends IService<DatabaseConnection> {
Page<DatabaseConnectionVO> listConnections(DatabaseConnectionParam.QueryParam queryParam);
boolean addConnection(DatabaseConnectionParam param);
boolean updateConnection(DatabaseConnectionParam.UpdateParam param);
boolean deleteConnection(DatabaseConnectionParam.DeleteParam param);
DatabaseTestResultVO testConnection(DatabaseConnectionParam.TestParam param);
List<DatabaseTableVO> listTables(DatabaseConnectionParam.TablesParam param);
DatabaseConnection requireEnabled(String connectionId);
String resolvePassword(DatabaseConnection connection, String temporaryPassword);
}

View File

@@ -0,0 +1,24 @@
package com.njcn.gather.systemops.database.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.njcn.gather.systemops.database.pojo.param.DatabaseBackupParam;
import com.njcn.gather.systemops.database.pojo.po.DatabaseOperationTask;
import com.njcn.gather.systemops.database.pojo.vo.DatabaseTaskCreateVO;
import com.njcn.gather.systemops.database.pojo.vo.DatabaseTaskVO;
/**
* 数据库运维任务服务。
*/
public interface DatabaseOperationTaskService extends IService<DatabaseOperationTask> {
DatabaseTaskCreateVO createBackupTask(DatabaseBackupParam.CreateParam param);
Page<DatabaseTaskVO> listBackupTasks(DatabaseBackupParam.TaskQueryParam param);
DatabaseTaskVO getStatus(String taskId);
boolean deleteTask(String taskId, String confirmText);
boolean existsRunningTask(String connectionId);
}

View File

@@ -0,0 +1,14 @@
package com.njcn.gather.systemops.database.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.njcn.gather.systemops.database.pojo.param.DatabaseRestoreParam;
import com.njcn.gather.systemops.database.pojo.po.DatabaseRestoreRecord;
import com.njcn.gather.systemops.database.pojo.vo.DatabaseTaskCreateVO;
/**
* 数据库恢复服务。
*/
public interface DatabaseRestoreService extends IService<DatabaseRestoreRecord> {
DatabaseTaskCreateVO createRestoreTask(DatabaseRestoreParam.CreateParam param);
}

View File

@@ -0,0 +1,16 @@
package com.njcn.gather.systemops.database.service;
import com.njcn.gather.systemops.database.pojo.vo.DatabaseOverviewVO;
/**
* 数据库监控基础服务。
*/
public interface DatabaseService {
/**
* 查询数据库监控基础信息。
*
* @return 数据库监控基础信息
*/
DatabaseOverviewVO overview();
}

View File

@@ -0,0 +1,144 @@
package com.njcn.gather.systemops.database.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.gather.systemops.database.config.DbmsProperties;
import com.njcn.gather.systemops.database.constant.DatabaseOpsConst;
import com.njcn.gather.systemops.database.mapper.DatabaseBackupFileMapper;
import com.njcn.gather.systemops.database.pojo.param.DatabaseBackupParam;
import com.njcn.gather.systemops.database.pojo.po.DatabaseBackupFile;
import com.njcn.gather.systemops.database.pojo.vo.DatabaseBackupFileVO;
import com.njcn.gather.systemops.database.service.DatabaseBackupFileService;
import com.njcn.gather.systemops.database.util.DatabaseChecksumUtil;
import com.njcn.gather.systemops.database.util.DatabasePathUtil;
import com.njcn.web.factory.PageFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.stream.Collectors;
/**
* 数据库备份文件服务实现。
*/
@Service
@RequiredArgsConstructor
public class DatabaseBackupFileServiceImpl extends ServiceImpl<DatabaseBackupFileMapper, DatabaseBackupFile> implements DatabaseBackupFileService {
private final DbmsProperties dbmsProperties;
@Override
public Page<DatabaseBackupFileVO> listFiles(DatabaseBackupParam.FileQueryParam param) {
DatabaseBackupParam.FileQueryParam query = param == null ? new DatabaseBackupParam.FileQueryParam() : param;
LambdaQueryWrapper<DatabaseBackupFile> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DatabaseBackupFile::getState, DatabaseOpsConst.STATE_ENABLED)
.eq(StrUtil.isNotBlank(query.getConnectionId()), DatabaseBackupFile::getConnectionId, query.getConnectionId())
.eq(StrUtil.isNotBlank(query.getTaskId()), DatabaseBackupFile::getTaskId, query.getTaskId())
.eq(StrUtil.isNotBlank(query.getBackupStrategy()), DatabaseBackupFile::getBackupStrategy, query.getBackupStrategy())
.orderByDesc(DatabaseBackupFile::getCreateTime);
Page<DatabaseBackupFile> page = this.page(new Page<>(PageFactory.getPageNum(query), PageFactory.getPageSize(query)), wrapper);
Page<DatabaseBackupFileVO> result = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
result.setRecords(page.getRecords().stream().map(this::toVO).collect(Collectors.toList()));
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean deleteBackupFile(String backupFileId, String confirmText) {
if (!DatabaseOpsConst.CONFIRM_DELETE.equals(confirmText)) {
throw new BusinessException(CommonResponseEnum.FAIL, "确认文案不正确");
}
DatabaseBackupFile file = this.lambdaQuery()
.eq(DatabaseBackupFile::getId, backupFileId)
.eq(DatabaseBackupFile::getState, DatabaseOpsConst.STATE_ENABLED)
.one();
if (file == null) {
throw new BusinessException(CommonResponseEnum.FAIL, "备份文件不存在或已删除");
}
deletePhysicalFile(file, file.getFilePath());
deletePhysicalFile(file, file.getLogFilePath());
deletePhysicalFile(file, file.getMetadataFilePath());
file.setState(DatabaseOpsConst.STATE_DELETED);
file.setUpdateTime(LocalDateTime.now());
return this.updateById(file);
}
@Override
public void validateBackupFileReadable(DatabaseBackupFile backupFile) {
validateReadablePath(backupFile, backupFile.getFilePath(), "备份文件", false);
validateReadablePath(backupFile, backupFile.getMetadataFilePath(), "备份元数据文件",
StrUtil.isBlank(backupFile.getMetadataFilePath()));
if (StrUtil.isBlank(backupFile.getChecksum())) {
throw new BusinessException(CommonResponseEnum.FAIL, "备份文件缺少校验值");
}
Path filePath = resolveManagedPath(backupFile, backupFile.getFilePath());
String actualChecksum = DatabaseChecksumUtil.sha256(filePath);
if (!backupFile.getChecksum().equalsIgnoreCase(actualChecksum)) {
throw new BusinessException(CommonResponseEnum.FAIL, "备份文件校验失败");
}
}
@Override
public Path resolveManagedPath(DatabaseBackupFile backupFile, String filePath) {
if (StrUtil.isBlank(filePath)) {
return null;
}
Path path = DatabasePathUtil.normalize(filePath);
if (path == null) {
return null;
}
Path storageRoot = DatabasePathUtil.normalize(dbmsProperties.getBackup().getStoragePath());
if (DatabasePathUtil.isUnder(path, storageRoot)) {
return path;
}
Path primaryFilePath = DatabasePathUtil.normalize(backupFile.getFilePath());
if (primaryFilePath != null && primaryFilePath.getParent() != null
&& DatabasePathUtil.isUnder(path, primaryFilePath.getParent())) {
return path;
}
throw new BusinessException(CommonResponseEnum.FAIL, "文件路径不在允许的备份目录内");
}
private void deletePhysicalFile(DatabaseBackupFile backupFile, String filePath) {
if (StrUtil.isBlank(filePath)) {
return;
}
try {
Path path = resolveManagedPath(backupFile, filePath);
if (path != null && Files.exists(path) && !Files.isDirectory(path)) {
Files.delete(path);
}
} catch (BusinessException exception) {
throw exception;
} catch (Exception exception) {
throw new BusinessException(CommonResponseEnum.FAIL, "删除物理文件失败:" + exception.getMessage());
}
}
private void validateReadablePath(DatabaseBackupFile backupFile, String filePath, String fileType, boolean allowBlank) {
if (StrUtil.isBlank(filePath)) {
if (allowBlank) {
return;
}
throw new BusinessException(CommonResponseEnum.FAIL, fileType + "路径不能为空");
}
Path path = resolveManagedPath(backupFile, filePath);
if (path == null || !Files.exists(path) || Files.isDirectory(path)) {
throw new BusinessException(CommonResponseEnum.FAIL, fileType + "不存在");
}
}
private DatabaseBackupFileVO toVO(DatabaseBackupFile file) {
DatabaseBackupFileVO vo = new DatabaseBackupFileVO();
BeanUtil.copyProperties(file, vo);
return vo;
}
}

View File

@@ -0,0 +1,212 @@
package com.njcn.gather.systemops.database.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.gather.systemops.database.component.DatabasePasswordComponent;
import com.njcn.gather.systemops.database.component.OracleJdbcComponent;
import com.njcn.gather.systemops.database.constant.DatabaseOpsConst;
import com.njcn.gather.systemops.database.mapper.DatabaseConnectionMapper;
import com.njcn.gather.systemops.database.pojo.param.DatabaseConnectionParam;
import com.njcn.gather.systemops.database.pojo.po.DatabaseConnection;
import com.njcn.gather.systemops.database.pojo.vo.DatabaseConnectionVO;
import com.njcn.gather.systemops.database.pojo.vo.DatabaseTableVO;
import com.njcn.gather.systemops.database.pojo.vo.DatabaseTestResultVO;
import com.njcn.gather.systemops.database.service.DatabaseConnectionService;
import com.njcn.gather.systemops.database.service.DatabaseOperationTaskService;
import com.njcn.gather.systemops.database.util.DatabaseOpsIdUtil;
import com.njcn.web.factory.PageFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;
/**
* 数据库连接配置服务实现。
*/
@Service
@RequiredArgsConstructor
public class DatabaseConnectionServiceImpl extends ServiceImpl<DatabaseConnectionMapper, DatabaseConnection> implements DatabaseConnectionService {
private final DatabasePasswordComponent databasePasswordComponent;
private final OracleJdbcComponent oracleJdbcComponent;
private final ObjectProvider<DatabaseOperationTaskService> databaseOperationTaskServiceProvider;
@Override
public Page<DatabaseConnectionVO> listConnections(DatabaseConnectionParam.QueryParam queryParam) {
DatabaseConnectionParam.QueryParam query = queryParam == null ? new DatabaseConnectionParam.QueryParam() : queryParam;
LambdaQueryWrapper<DatabaseConnection> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DatabaseConnection::getState, DatabaseOpsConst.STATE_ENABLED)
.like(StrUtil.isNotBlank(query.getConnectionName()), DatabaseConnection::getConnectionName, query.getConnectionName())
.eq(StrUtil.isNotBlank(query.getDbType()), DatabaseConnection::getDbType, query.getDbType())
.like(StrUtil.isNotBlank(query.getSchemaName()), DatabaseConnection::getSchemaName, query.getSchemaName())
.orderByDesc(DatabaseConnection::getUpdateTime);
Page<DatabaseConnection> page = this.page(new Page<>(PageFactory.getPageNum(query), PageFactory.getPageSize(query)), wrapper);
Page<DatabaseConnectionVO> result = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
result.setRecords(page.getRecords().stream().map(this::toVO).collect(Collectors.toList()));
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean addConnection(DatabaseConnectionParam param) {
DatabaseConnection connection = new DatabaseConnection();
fillConnection(connection, param, true);
connection.setId(DatabaseOpsIdUtil.uuid());
connection.setState(DatabaseOpsConst.STATE_ENABLED);
connection.setCreateTime(LocalDateTime.now());
connection.setUpdateTime(LocalDateTime.now());
return this.save(connection);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean updateConnection(DatabaseConnectionParam.UpdateParam param) {
DatabaseConnection connection = requireEnabled(param.getId());
fillConnection(connection, param, false);
connection.setUpdateTime(LocalDateTime.now());
return this.updateById(connection);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean deleteConnection(DatabaseConnectionParam.DeleteParam param) {
requireEnabled(param.getId());
if (databaseOperationTaskServiceProvider.getObject().existsRunningTask(param.getId())) {
throw new BusinessException(CommonResponseEnum.FAIL, "存在运行中的任务,不能删除连接");
}
return this.lambdaUpdate()
.set(DatabaseConnection::getState, DatabaseOpsConst.STATE_DELETED)
.set(DatabaseConnection::getUpdateTime, LocalDateTime.now())
.eq(DatabaseConnection::getId, param.getId())
.update();
}
@Override
@Transactional(rollbackFor = Exception.class)
public DatabaseTestResultVO testConnection(DatabaseConnectionParam.TestParam param) {
DatabaseConnection connection = resolveTestConnection(param);
DatabaseTestResultVO result = oracleJdbcComponent.test(connection, resolvePassword(connection, param.getTemporaryPassword()));
if (StrUtil.isNotBlank(connection.getId())) {
connection.setLastTestStatus(Boolean.TRUE.equals(result.getSuccess()) ? "SUCCESS" : "FAIL");
connection.setLastTestMessage(result.getMessage());
connection.setLastTestTime(LocalDateTime.now());
this.updateById(connection);
}
return result;
}
@Override
public List<DatabaseTableVO> listTables(DatabaseConnectionParam.TablesParam param) {
DatabaseConnection connection = requireEnabled(param.getConnectionId());
try {
return oracleJdbcComponent.listTables(connection, resolvePassword(connection, param.getTemporaryPassword()), param.getSchemaName());
} catch (Exception exception) {
throw new BusinessException(CommonResponseEnum.FAIL, exception.getMessage());
}
}
@Override
public DatabaseConnection requireEnabled(String connectionId) {
if (StrUtil.isBlank(connectionId)) {
throw new BusinessException(CommonResponseEnum.FAIL, "连接 ID 不能为空");
}
DatabaseConnection connection = this.lambdaQuery()
.eq(DatabaseConnection::getId, connectionId)
.eq(DatabaseConnection::getState, DatabaseOpsConst.STATE_ENABLED)
.one();
if (connection == null) {
throw new BusinessException(CommonResponseEnum.FAIL, "数据库连接不存在或已删除");
}
return connection;
}
@Override
public String resolvePassword(DatabaseConnection connection, String temporaryPassword) {
try {
return databasePasswordComponent.resolveRuntimePassword(connection.getPasswordCipher(), temporaryPassword);
} catch (IllegalArgumentException exception) {
throw new BusinessException(CommonResponseEnum.FAIL, exception.getMessage());
}
}
private DatabaseConnection resolveTestConnection(DatabaseConnectionParam.TestParam param) {
if (StrUtil.isNotBlank(param.getConnectionId())) {
return requireEnabled(param.getConnectionId());
}
if (param.getConnection() == null) {
throw new BusinessException(CommonResponseEnum.FAIL, "连接测试参数不能为空");
}
DatabaseConnection connection = new DatabaseConnection();
fillConnection(connection, param.getConnection(), true, StrUtil.isNotBlank(param.getTemporaryPassword()));
return connection;
}
private void fillConnection(DatabaseConnection connection, DatabaseConnectionParam param, boolean create) {
fillConnection(connection, param, create, false);
}
private void fillConnection(DatabaseConnection connection, DatabaseConnectionParam param, boolean create,
boolean allowTemporaryPasswordOnly) {
validateConnectionParam(param);
connection.setConnectionName(param.getConnectionName().trim());
connection.setDbType(DatabaseOpsConst.DB_TYPE_ORACLE);
connection.setHost(param.getHost().trim());
connection.setPort(param.getPort());
connection.setConnectType(resolveConnectType(param.getConnectType()));
connection.setServiceName(trimToNull(param.getServiceName()));
connection.setSid(trimToNull(param.getSid()));
connection.setSchemaName(trimToNull(param.getSchemaName()));
connection.setUsername(param.getUsername().trim());
connection.setSavePassword(param.getSavePassword() == null ? DatabaseOpsConst.SAVE_PASSWORD_YES : param.getSavePassword());
if (connection.getSavePassword() != DatabaseOpsConst.SAVE_PASSWORD_YES
&& connection.getSavePassword() != DatabaseOpsConst.SAVE_PASSWORD_NO) {
throw new BusinessException(CommonResponseEnum.FAIL, "savePassword 只能是 0 或 1");
}
if (DatabaseOpsConst.SAVE_PASSWORD_YES == connection.getSavePassword() && StrUtil.isNotBlank(param.getPassword())) {
connection.setPasswordCipher(databasePasswordComponent.encrypt(param.getPassword()));
}
if (DatabaseOpsConst.SAVE_PASSWORD_NO == connection.getSavePassword()) {
connection.setPasswordCipher(null);
} else if (create && StrUtil.isBlank(param.getPassword()) && !allowTemporaryPasswordOnly) {
throw new BusinessException(CommonResponseEnum.FAIL, "保存密码时密码不能为空");
}
connection.setDirectoryName(trimToNull(param.getDirectoryName()));
connection.setDirectoryPath(trimToNull(param.getDirectoryPath()));
connection.setExtraConfigJson(trimToNull(param.getExtraConfigJson()));
connection.setRemark(param.getRemark());
}
private void validateConnectionParam(DatabaseConnectionParam param) {
String connectType = resolveConnectType(param.getConnectType());
if (DatabaseOpsConst.CONNECT_TYPE_SERVICE_NAME.equals(connectType) && StrUtil.isBlank(param.getServiceName())) {
throw new BusinessException(CommonResponseEnum.FAIL, "SERVICE_NAME 连接方式下服务名不能为空");
}
if (DatabaseOpsConst.CONNECT_TYPE_SID.equals(connectType) && StrUtil.isBlank(param.getSid())) {
throw new BusinessException(CommonResponseEnum.FAIL, "SID 连接方式下 SID 不能为空");
}
}
private String resolveConnectType(String connectType) {
return StrUtil.blankToDefault(connectType, DatabaseOpsConst.CONNECT_TYPE_SERVICE_NAME).trim().toUpperCase(Locale.ROOT);
}
private String trimToNull(String value) {
return StrUtil.isBlank(value) ? null : value.trim();
}
private DatabaseConnectionVO toVO(DatabaseConnection connection) {
DatabaseConnectionVO vo = new DatabaseConnectionVO();
BeanUtil.copyProperties(connection, vo);
return vo;
}
}

View File

@@ -0,0 +1,353 @@
package com.njcn.gather.systemops.database.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.gather.systemops.database.component.DataPumpCommandExecutor;
import com.njcn.gather.systemops.database.component.JdbcExportComponent;
import com.njcn.gather.systemops.database.config.DbmsProperties;
import com.njcn.gather.systemops.database.constant.DatabaseOpsConst;
import com.njcn.gather.systemops.database.mapper.DatabaseOperationTaskMapper;
import com.njcn.gather.systemops.database.pojo.enums.BackupModeEnum;
import com.njcn.gather.systemops.database.pojo.enums.BackupStrategyEnum;
import com.njcn.gather.systemops.database.pojo.enums.FileFormatEnum;
import com.njcn.gather.systemops.database.pojo.enums.OperationTypeEnum;
import com.njcn.gather.systemops.database.pojo.enums.TaskStatusEnum;
import com.njcn.gather.systemops.database.pojo.param.DatabaseBackupParam;
import com.njcn.gather.systemops.database.pojo.po.DatabaseBackupFile;
import com.njcn.gather.systemops.database.pojo.po.DatabaseConnection;
import com.njcn.gather.systemops.database.pojo.po.DatabaseOperationTask;
import com.njcn.gather.systemops.database.pojo.vo.DatabaseTaskCreateVO;
import com.njcn.gather.systemops.database.pojo.vo.DatabaseTaskVO;
import com.njcn.gather.systemops.database.service.DatabaseBackupFileService;
import com.njcn.gather.systemops.database.service.DatabaseConnectionService;
import com.njcn.gather.systemops.database.service.DatabaseOperationTaskService;
import com.njcn.gather.systemops.database.util.DatabaseChecksumUtil;
import com.njcn.gather.systemops.database.util.DatabaseFileNameUtil;
import com.njcn.gather.systemops.database.util.DatabaseOpsIdUtil;
import com.njcn.gather.systemops.database.util.DatabasePathUtil;
import com.njcn.web.factory.PageFactory;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.stream.Collectors;
/**
* 数据库运维任务服务实现。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DatabaseOperationTaskServiceImpl extends ServiceImpl<DatabaseOperationTaskMapper, DatabaseOperationTask> implements DatabaseOperationTaskService {
private final DatabaseConnectionService databaseConnectionService;
private final DatabaseBackupFileService databaseBackupFileService;
private final DataPumpCommandExecutor dataPumpCommandExecutor;
private final JdbcExportComponent jdbcExportComponent;
private final DbmsProperties dbmsProperties;
private final ObjectMapper objectMapper;
@Resource(name = "dbmsTaskExecutorService")
private ExecutorService dbmsTaskExecutorService;
@Override
@Transactional(rollbackFor = Exception.class)
public DatabaseTaskCreateVO createBackupTask(DatabaseBackupParam.CreateParam param) {
DatabaseConnection connection = databaseConnectionService.requireEnabled(param.getConnectionId());
validateBackupParam(param, connection);
if (existsRunningTask(connection.getId())) {
throw new BusinessException(CommonResponseEnum.FAIL, "当前连接存在运行中的任务");
}
DatabaseOperationTask task = buildBackupTask(param, connection);
this.save(task);
dbmsTaskExecutorService.submit(() -> executeBackupTask(task.getId(), param));
return toCreateVO(task);
}
@Override
public Page<DatabaseTaskVO> listBackupTasks(DatabaseBackupParam.TaskQueryParam param) {
DatabaseBackupParam.TaskQueryParam query = param == null ? new DatabaseBackupParam.TaskQueryParam() : param;
LambdaQueryWrapper<DatabaseOperationTask> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DatabaseOperationTask::getState, DatabaseOpsConst.STATE_ENABLED)
.eq(DatabaseOperationTask::getOperationType, OperationTypeEnum.BACKUP.name())
.eq(StrUtil.isNotBlank(query.getConnectionId()), DatabaseOperationTask::getConnectionId, query.getConnectionId())
.eq(StrUtil.isNotBlank(query.getTaskStatus()), DatabaseOperationTask::getTaskStatus, query.getTaskStatus())
.orderByDesc(DatabaseOperationTask::getCreateTime);
Page<DatabaseOperationTask> page = this.page(new Page<>(PageFactory.getPageNum(query), PageFactory.getPageSize(query)), wrapper);
Page<DatabaseTaskVO> result = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
result.setRecords(page.getRecords().stream().map(this::toVO).collect(Collectors.toList()));
return result;
}
@Override
public DatabaseTaskVO getStatus(String taskId) {
DatabaseOperationTask task = this.getById(taskId);
if (task == null || !Integer.valueOf(DatabaseOpsConst.STATE_ENABLED).equals(task.getState())) {
throw new BusinessException(CommonResponseEnum.FAIL, "任务不存在或已删除");
}
return toVO(task);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean deleteTask(String taskId, String confirmText) {
if (!DatabaseOpsConst.CONFIRM_DELETE.equals(confirmText)) {
throw new BusinessException(CommonResponseEnum.FAIL, "确认文案不正确");
}
DatabaseOperationTask task = this.getById(taskId);
if (task == null || !Integer.valueOf(DatabaseOpsConst.STATE_ENABLED).equals(task.getState())) {
throw new BusinessException(CommonResponseEnum.FAIL, "任务不存在或已删除");
}
if (TaskStatusEnum.RUNNING.name().equals(task.getTaskStatus()) || TaskStatusEnum.WAITING.name().equals(task.getTaskStatus())) {
throw new BusinessException(CommonResponseEnum.FAIL, "运行中的任务不能删除");
}
task.setState(DatabaseOpsConst.STATE_DELETED);
task.setUpdateTime(LocalDateTime.now());
return this.updateById(task);
}
@Override
public boolean existsRunningTask(String connectionId) {
return this.lambdaQuery()
.eq(DatabaseOperationTask::getConnectionId, connectionId)
.eq(DatabaseOperationTask::getState, DatabaseOpsConst.STATE_ENABLED)
.in(DatabaseOperationTask::getTaskStatus, Arrays.asList(TaskStatusEnum.WAITING.name(), TaskStatusEnum.RUNNING.name()))
.count() > 0;
}
private void executeBackupTask(String taskId, DatabaseBackupParam.CreateParam param) {
DatabaseOperationTask task = this.getById(taskId);
try {
markRunning(task);
DatabaseConnection connection = databaseConnectionService.requireEnabled(task.getConnectionId());
connection.setSchemaName(task.getSchemaName());
String password = databaseConnectionService.resolvePassword(connection, param.getTemporaryPassword());
DatabaseBackupFile backupFile;
if (BackupStrategyEnum.DATA_PUMP.name().equals(task.getBackupStrategy())) {
backupFile = executeDataPumpBackup(task, connection, password, param);
} else {
backupFile = executeJdbcExportBackup(task, connection, password, param);
}
databaseBackupFileService.save(backupFile);
markSuccess(task, "备份任务执行成功");
} catch (Exception exception) {
log.error("数据库备份任务失败taskId={}", taskId, exception);
markFail(task, exception.getMessage());
}
}
private DatabaseBackupFile executeDataPumpBackup(DatabaseOperationTask task, DatabaseConnection connection, String password,
DatabaseBackupParam.CreateParam param) {
String directoryName = StrUtil.blankToDefault(param.getDirectoryName(), connection.getDirectoryName());
if (StrUtil.isBlank(directoryName)) {
throw new BusinessException(CommonResponseEnum.FAIL, "DATA_PUMP 备份需要 Oracle Directory 名称");
}
String baseName = buildBaseFileName(connection, task);
String dumpFileName = DatabaseFileNameUtil.appendTodayWithTask(baseName + ".dmp", task.getTaskNo());
String logFileName = DatabaseFileNameUtil.appendTodayWithTask(baseName + ".log", task.getTaskNo());
DataPumpCommandExecutor.CommandResult commandResult = dataPumpCommandExecutor.expdp(connection, password, directoryName, dumpFileName, logFileName, param.getTargetNames());
if (!Boolean.TRUE.equals(commandResult.getSuccess())) {
throw new BusinessException(CommonResponseEnum.FAIL, "Data Pump 执行失败:" + commandResult.getOutput());
}
if (StrUtil.isBlank(connection.getDirectoryPath())) {
throw new BusinessException(CommonResponseEnum.FAIL, "Data Pump 备份需要配置可管理的 directoryPath");
}
Path dumpPath = buildManagedPath(connection.getDirectoryPath(), dumpFileName);
Path logPath = buildManagedPath(connection.getDirectoryPath(), logFileName);
return buildBackupFile(task, connection, param, FileFormatEnum.DMP.name(), dumpFileName, dumpPath, logFileName, logPath, null);
}
private DatabaseBackupFile executeJdbcExportBackup(DatabaseOperationTask task, DatabaseConnection connection, String password,
DatabaseBackupParam.CreateParam param) throws Exception {
String baseName = buildBaseFileName(connection, task);
String fileName = DatabaseFileNameUtil.appendTodayWithTask(baseName + ".csv", task.getTaskNo());
String metadataFileName = DatabaseFileNameUtil.appendTodayWithTask(baseName + "_metadata.json", task.getTaskNo());
Path dataFilePath = buildManagedPath(dbmsProperties.getBackup().getStoragePath(), fileName);
Path metadataFilePath = buildManagedPath(dbmsProperties.getBackup().getStoragePath(), metadataFileName);
jdbcExportComponent.exportCsv(connection, password, param, dataFilePath, metadataFilePath);
return buildBackupFile(task, connection, param, FileFormatEnum.CSV.name(), fileName, dataFilePath, null, null, metadataFilePath);
}
private DatabaseBackupFile buildBackupFile(DatabaseOperationTask task, DatabaseConnection connection, DatabaseBackupParam.CreateParam param,
String fileFormat, String fileName, Path filePath, String logFileName, Path logFilePath,
Path metadataFilePath) {
if (filePath == null || !Files.exists(filePath)) {
throw new BusinessException(CommonResponseEnum.FAIL, "备份文件未生成");
}
DatabaseBackupFile file = new DatabaseBackupFile();
file.setId(DatabaseOpsIdUtil.uuid());
file.setTaskId(task.getId());
file.setConnectionId(connection.getId());
file.setDbType(connection.getDbType());
file.setBackupStrategy(task.getBackupStrategy());
file.setFileFormat(fileFormat);
file.setSchemaName(task.getSchemaName());
file.setTargetNamesJson(task.getTargetNamesJson());
file.setBackupMode(StrUtil.blankToDefault(param.getBackupMode(), BackupModeEnum.FULL_TABLE.name()).toUpperCase(Locale.ROOT));
file.setBackupStartTime(param.getStartTime());
file.setBackupEndTime(param.getEndTime());
file.setTimeColumn(param.getTimeColumn());
file.setDirectoryName(StrUtil.blankToDefault(param.getDirectoryName(), connection.getDirectoryName()));
file.setDumpFileName(FileFormatEnum.DMP.name().equals(fileFormat) ? fileName : null);
file.setLogFileName(logFileName);
file.setFileName(fileName);
file.setFilePath(filePath.toString());
file.setLogFilePath(logFilePath == null ? null : logFilePath.toString());
file.setMetadataFilePath(metadataFilePath == null ? null : metadataFilePath.toString());
file.setFileSize(readFileSize(filePath));
file.setChecksum(DatabaseChecksumUtil.sha256(filePath));
file.setState(DatabaseOpsConst.STATE_ENABLED);
file.setCreateTime(LocalDateTime.now());
file.setUpdateTime(LocalDateTime.now());
return file;
}
private Long readFileSize(Path filePath) {
try {
if (filePath != null && Files.exists(filePath) && !Files.isDirectory(filePath)) {
return Files.size(filePath);
}
} catch (Exception ignored) {
return null;
}
return null;
}
private Path buildManagedPath(String rootPath, String fileName) {
Path root = DatabasePathUtil.normalize(rootPath);
if (root == null) {
throw new BusinessException(CommonResponseEnum.FAIL, "备份目录未配置");
}
return root.resolve(fileName).normalize();
}
private String buildBaseFileName(DatabaseConnection connection, DatabaseOperationTask task) {
return connection.getSchemaName() + "_" + task.getBackupStrategy().toLowerCase(Locale.ROOT);
}
private DatabaseOperationTask buildBackupTask(DatabaseBackupParam.CreateParam param, DatabaseConnection connection) {
DatabaseOperationTask task = new DatabaseOperationTask();
task.setId(DatabaseOpsIdUtil.uuid());
task.setTaskNo(DatabaseOpsIdUtil.taskNo("DBMSB"));
task.setConnectionId(connection.getId());
task.setDbType(connection.getDbType());
task.setOperationType(OperationTypeEnum.BACKUP.name());
task.setBackupStrategy(resolveBackupStrategy(param.getBackupStrategy()));
task.setTaskStatus(TaskStatusEnum.WAITING.name());
task.setSchemaName(StrUtil.blankToDefault(param.getSchemaName(), connection.getSchemaName()));
task.setTargetNamesJson(writeJson(param.getTargetNames()));
task.setRequestParamJson(writeJsonWithoutPassword(param));
task.setProgressPercent(BigDecimal.ZERO);
task.setState(DatabaseOpsConst.STATE_ENABLED);
task.setCreateTime(LocalDateTime.now());
task.setUpdateTime(LocalDateTime.now());
return task;
}
private void validateBackupParam(DatabaseBackupParam.CreateParam param, DatabaseConnection connection) {
if (!DatabaseOpsConst.DB_TYPE_ORACLE.equals(connection.getDbType())) {
throw new BusinessException(CommonResponseEnum.FAIL, "一期仅支持 ORACLE");
}
if (param.getTargetNames() == null || param.getTargetNames().isEmpty()) {
throw new BusinessException(CommonResponseEnum.FAIL, "备份表不能为空");
}
if (StrUtil.isBlank(StrUtil.blankToDefault(param.getSchemaName(), connection.getSchemaName()))) {
throw new BusinessException(CommonResponseEnum.FAIL, "备份 Schema 不能为空");
}
String backupMode = StrUtil.blankToDefault(param.getBackupMode(), BackupModeEnum.FULL_TABLE.name()).toUpperCase(Locale.ROOT);
if (BackupModeEnum.TIME_RANGE.name().equals(backupMode)
&& (param.getStartTime() == null || param.getEndTime() == null)) {
throw new BusinessException(CommonResponseEnum.FAIL, "按时间备份必须传入开始时间和结束时间");
}
if (BackupModeEnum.TIME_RANGE.name().equals(backupMode)
&& param.getStartTime() != null && param.getEndTime() != null
&& param.getStartTime().isAfter(param.getEndTime())) {
throw new BusinessException(CommonResponseEnum.FAIL, "开始时间不能晚于结束时间");
}
if (BackupModeEnum.SIZE_SPLIT.name().equals(backupMode)
&& (param.getMaxFileSizeMb() == null || param.getMaxFileSizeMb() <= 0)) {
throw new BusinessException(CommonResponseEnum.FAIL, "按大小分片必须传入大于 0 的文件大小");
}
if (BackupStrategyEnum.JDBC_EXPORT.name().equals(resolveBackupStrategy(param.getBackupStrategy()))
&& BackupModeEnum.TIME_RANGE.name().equals(backupMode)
&& StrUtil.isBlank(param.getTimeColumn())) {
throw new BusinessException(CommonResponseEnum.FAIL, "JDBC 按时间备份必须传入时间字段");
}
}
private String resolveBackupStrategy(String backupStrategy) {
String value = StrUtil.blankToDefault(backupStrategy, BackupStrategyEnum.DATA_PUMP.name()).trim().toUpperCase(Locale.ROOT);
try {
return BackupStrategyEnum.valueOf(value).name();
} catch (Exception exception) {
throw new BusinessException(CommonResponseEnum.FAIL, "不支持的备份策略:" + backupStrategy);
}
}
private void markRunning(DatabaseOperationTask task) {
task.setTaskStatus(TaskStatusEnum.RUNNING.name());
task.setStartedAt(LocalDateTime.now());
task.setUpdateTime(LocalDateTime.now());
this.updateById(task);
}
private void markSuccess(DatabaseOperationTask task, String message) {
task.setTaskStatus(TaskStatusEnum.SUCCESS.name());
task.setResultMessage(message);
task.setProgressPercent(new BigDecimal("100.00"));
task.setFinishedAt(LocalDateTime.now());
task.setUpdateTime(LocalDateTime.now());
this.updateById(task);
}
private void markFail(DatabaseOperationTask task, String message) {
task.setTaskStatus(TaskStatusEnum.FAIL.name());
task.setResultMessage(message);
task.setFinishedAt(LocalDateTime.now());
task.setUpdateTime(LocalDateTime.now());
this.updateById(task);
}
private String writeJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (Exception exception) {
throw new BusinessException(CommonResponseEnum.JSON_CONVERT_EXCEPTION, exception.getMessage());
}
}
private String writeJsonWithoutPassword(DatabaseBackupParam.CreateParam param) {
DatabaseBackupParam.CreateParam copy = new DatabaseBackupParam.CreateParam();
BeanUtil.copyProperties(param, copy);
copy.setTemporaryPassword(null);
return writeJson(copy);
}
private DatabaseTaskCreateVO toCreateVO(DatabaseOperationTask task) {
DatabaseTaskCreateVO vo = new DatabaseTaskCreateVO();
vo.setTaskId(task.getId());
vo.setTaskNo(task.getTaskNo());
vo.setTaskStatus(task.getTaskStatus());
return vo;
}
private DatabaseTaskVO toVO(DatabaseOperationTask task) {
DatabaseTaskVO vo = new DatabaseTaskVO();
BeanUtil.copyProperties(task, vo);
return vo;
}
}

View File

@@ -0,0 +1,239 @@
package com.njcn.gather.systemops.database.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.gather.systemops.database.component.DataPumpCommandExecutor;
import com.njcn.gather.systemops.database.component.JdbcExportComponent;
import com.njcn.gather.systemops.database.component.OracleJdbcComponent;
import com.njcn.gather.systemops.database.constant.DatabaseOpsConst;
import com.njcn.gather.systemops.database.mapper.DatabaseRestoreRecordMapper;
import com.njcn.gather.systemops.database.pojo.enums.BackupStrategyEnum;
import com.njcn.gather.systemops.database.pojo.enums.FileFormatEnum;
import com.njcn.gather.systemops.database.pojo.enums.OperationTypeEnum;
import com.njcn.gather.systemops.database.pojo.enums.RestoreModeEnum;
import com.njcn.gather.systemops.database.pojo.enums.TaskStatusEnum;
import com.njcn.gather.systemops.database.pojo.param.DatabaseRestoreParam;
import com.njcn.gather.systemops.database.pojo.po.DatabaseBackupFile;
import com.njcn.gather.systemops.database.pojo.po.DatabaseConnection;
import com.njcn.gather.systemops.database.pojo.po.DatabaseOperationTask;
import com.njcn.gather.systemops.database.pojo.po.DatabaseRestoreRecord;
import com.njcn.gather.systemops.database.pojo.vo.DatabaseTaskCreateVO;
import com.njcn.gather.systemops.database.service.DatabaseBackupFileService;
import com.njcn.gather.systemops.database.service.DatabaseConnectionService;
import com.njcn.gather.systemops.database.service.DatabaseOperationTaskService;
import com.njcn.gather.systemops.database.service.DatabaseRestoreService;
import com.njcn.gather.systemops.database.util.DatabaseFileNameUtil;
import com.njcn.gather.systemops.database.util.DatabaseOpsIdUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.Locale;
import java.util.concurrent.ExecutorService;
/**
* 数据库恢复服务实现。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DatabaseRestoreServiceImpl extends ServiceImpl<DatabaseRestoreRecordMapper, DatabaseRestoreRecord> implements DatabaseRestoreService {
private final DatabaseConnectionService databaseConnectionService;
private final DatabaseOperationTaskService databaseOperationTaskService;
private final DatabaseBackupFileService databaseBackupFileService;
private final DataPumpCommandExecutor dataPumpCommandExecutor;
private final JdbcExportComponent jdbcExportComponent;
private final OracleJdbcComponent oracleJdbcComponent;
private final ObjectMapper objectMapper;
@Resource(name = "dbmsTaskExecutorService")
private ExecutorService dbmsTaskExecutorService;
@Override
@Transactional(rollbackFor = Exception.class)
public DatabaseTaskCreateVO createRestoreTask(DatabaseRestoreParam.CreateParam param) {
DatabaseConnection connection = databaseConnectionService.requireEnabled(param.getConnectionId());
DatabaseBackupFile backupFile = requireBackupFile(param.getBackupFileId());
validateRestoreParam(param, connection, backupFile);
if (databaseOperationTaskService.existsRunningTask(connection.getId())) {
throw new BusinessException(CommonResponseEnum.FAIL, "当前连接存在运行中的任务");
}
DatabaseOperationTask task = buildRestoreTask(param, connection, backupFile);
databaseOperationTaskService.save(task);
DatabaseRestoreRecord record = buildRestoreRecord(param, connection, backupFile, task);
this.save(record);
dbmsTaskExecutorService.submit(() -> executeRestoreTask(task.getId(), record.getId(), param));
DatabaseTaskCreateVO vo = new DatabaseTaskCreateVO();
vo.setTaskId(task.getId());
vo.setTaskNo(task.getTaskNo());
vo.setTaskStatus(task.getTaskStatus());
return vo;
}
private void executeRestoreTask(String taskId, String recordId, DatabaseRestoreParam.CreateParam param) {
DatabaseOperationTask task = databaseOperationTaskService.getById(taskId);
DatabaseRestoreRecord record = this.getById(recordId);
try {
markRunning(task);
DatabaseConnection connection = databaseConnectionService.requireEnabled(task.getConnectionId());
DatabaseBackupFile backupFile = requireBackupFile(record.getBackupFileId());
databaseBackupFileService.validateBackupFileReadable(backupFile);
String password = databaseConnectionService.resolvePassword(connection, param.getTemporaryPassword());
if (BackupStrategyEnum.DATA_PUMP.name().equals(backupFile.getBackupStrategy())) {
DataPumpCommandExecutor.CommandResult result = dataPumpCommandExecutor.impdp(connection, password,
backupFile.getDirectoryName(), backupFile.getDumpFileName(), buildRestoreLogName(task),
record.getTableExistsAction());
if (!Boolean.TRUE.equals(result.getSuccess())) {
throw new BusinessException(CommonResponseEnum.FAIL, "Data Pump 恢复失败:" + result.getOutput());
}
} else if (FileFormatEnum.CSV.name().equalsIgnoreCase(backupFile.getFileFormat())) {
Path dataFilePath = databaseBackupFileService.resolveManagedPath(backupFile, backupFile.getFilePath());
Path metadataFilePath = databaseBackupFileService.resolveManagedPath(backupFile, backupFile.getMetadataFilePath());
jdbcExportComponent.importCsv(connection, password, dataFilePath, metadataFilePath, record.getRestoreMode(), record.getTargetSchemaName());
} else {
throw new BusinessException(CommonResponseEnum.FAIL, "暂不支持的恢复文件格式:" + backupFile.getFileFormat());
}
record.setResultMessage("恢复任务执行成功");
record.setUpdateTime(LocalDateTime.now());
this.updateById(record);
markSuccess(task, "恢复任务执行成功");
} catch (Exception exception) {
log.error("数据库恢复任务失败taskId={}", taskId, exception);
record.setResultMessage(exception.getMessage());
record.setUpdateTime(LocalDateTime.now());
this.updateById(record);
markFail(task, exception.getMessage());
}
}
private void validateRestoreParam(DatabaseRestoreParam.CreateParam param, DatabaseConnection connection, DatabaseBackupFile backupFile) {
if (!connection.getDbType().equals(backupFile.getDbType())) {
throw new BusinessException(CommonResponseEnum.FAIL, "备份文件数据库类型和目标连接数据库类型不一致");
}
String restoreMode = resolveRestoreMode(param.getRestoreMode());
if ((RestoreModeEnum.TRUNCATE.name().equals(restoreMode) || RestoreModeEnum.REPLACE.name().equals(restoreMode))
&& !DatabaseOpsConst.CONFIRM_OVERWRITE.equals(param.getOverwriteConfirmText())) {
throw new BusinessException(CommonResponseEnum.FAIL, "覆盖类恢复必须输入确认覆盖");
}
databaseBackupFileService.validateBackupFileReadable(backupFile);
String password = databaseConnectionService.resolvePassword(connection, param.getTemporaryPassword());
if (!Boolean.TRUE.equals(oracleJdbcComponent.test(connection, password).getSuccess())) {
throw new BusinessException(CommonResponseEnum.FAIL, "目标连接测试失败,不能创建恢复任务");
}
if (BackupStrategyEnum.DATA_PUMP.name().equals(backupFile.getBackupStrategy())) {
if (StrUtil.isBlank(backupFile.getDirectoryName()) || StrUtil.isBlank(backupFile.getDumpFileName())) {
throw new BusinessException(CommonResponseEnum.FAIL, "Data Pump 备份记录缺少目录或文件名");
}
}
if (BackupStrategyEnum.JDBC_EXPORT.name().equals(backupFile.getBackupStrategy())
&& StrUtil.isBlank(backupFile.getMetadataFilePath())) {
throw new BusinessException(CommonResponseEnum.FAIL, "JDBC_EXPORT 备份缺少元数据文件,不能恢复");
}
}
private DatabaseOperationTask buildRestoreTask(DatabaseRestoreParam.CreateParam param, DatabaseConnection connection, DatabaseBackupFile backupFile) {
DatabaseOperationTask task = new DatabaseOperationTask();
task.setId(DatabaseOpsIdUtil.uuid());
task.setTaskNo(DatabaseOpsIdUtil.taskNo("DBMSR"));
task.setConnectionId(connection.getId());
task.setDbType(connection.getDbType());
task.setOperationType(OperationTypeEnum.RESTORE.name());
task.setBackupStrategy(backupFile.getBackupStrategy());
task.setTaskStatus(TaskStatusEnum.WAITING.name());
task.setSchemaName(StrUtil.blankToDefault(param.getTargetSchemaName(), connection.getSchemaName()));
task.setTargetNamesJson(backupFile.getTargetNamesJson());
task.setRequestParamJson(writeJsonWithoutPassword(param));
task.setProgressPercent(BigDecimal.ZERO);
task.setState(DatabaseOpsConst.STATE_ENABLED);
task.setCreateTime(LocalDateTime.now());
task.setUpdateTime(LocalDateTime.now());
return task;
}
private DatabaseRestoreRecord buildRestoreRecord(DatabaseRestoreParam.CreateParam param, DatabaseConnection connection,
DatabaseBackupFile backupFile, DatabaseOperationTask task) {
String restoreMode = resolveRestoreMode(param.getRestoreMode());
DatabaseRestoreRecord record = new DatabaseRestoreRecord();
record.setId(DatabaseOpsIdUtil.uuid());
record.setTaskId(task.getId());
record.setBackupFileId(backupFile.getId());
record.setConnectionId(connection.getId());
record.setDbType(connection.getDbType());
record.setRestoreMode(restoreMode);
record.setTargetSchemaName(StrUtil.blankToDefault(param.getTargetSchemaName(), connection.getSchemaName()));
record.setTargetNamesJson(backupFile.getTargetNamesJson());
record.setTableExistsAction(restoreMode);
record.setOverwriteConfirmed(DatabaseOpsConst.CONFIRM_OVERWRITE.equals(param.getOverwriteConfirmText()) ? 1 : 0);
record.setState(DatabaseOpsConst.STATE_ENABLED);
record.setCreateTime(LocalDateTime.now());
record.setUpdateTime(LocalDateTime.now());
return record;
}
private DatabaseBackupFile requireBackupFile(String backupFileId) {
DatabaseBackupFile backupFile = databaseBackupFileService.getById(backupFileId);
if (backupFile == null || !Integer.valueOf(DatabaseOpsConst.STATE_ENABLED).equals(backupFile.getState())) {
throw new BusinessException(CommonResponseEnum.FAIL, "备份文件不存在或已删除");
}
return backupFile;
}
private String resolveRestoreMode(String restoreMode) {
String value = StrUtil.blankToDefault(restoreMode, RestoreModeEnum.SKIP.name()).trim().toUpperCase(Locale.ROOT);
try {
return RestoreModeEnum.valueOf(value).name();
} catch (Exception exception) {
throw new BusinessException(CommonResponseEnum.FAIL, "不支持的恢复模式:" + restoreMode);
}
}
private String buildRestoreLogName(DatabaseOperationTask task) {
return DatabaseFileNameUtil.appendTodayWithTask(task.getTaskNo() + "_restore.log", task.getTaskNo());
}
private void markRunning(DatabaseOperationTask task) {
task.setTaskStatus(TaskStatusEnum.RUNNING.name());
task.setStartedAt(LocalDateTime.now());
task.setUpdateTime(LocalDateTime.now());
databaseOperationTaskService.updateById(task);
}
private void markSuccess(DatabaseOperationTask task, String message) {
task.setTaskStatus(TaskStatusEnum.SUCCESS.name());
task.setResultMessage(message);
task.setProgressPercent(new BigDecimal("100.00"));
task.setFinishedAt(LocalDateTime.now());
task.setUpdateTime(LocalDateTime.now());
databaseOperationTaskService.updateById(task);
}
private void markFail(DatabaseOperationTask task, String message) {
task.setTaskStatus(TaskStatusEnum.FAIL.name());
task.setResultMessage(message);
task.setFinishedAt(LocalDateTime.now());
task.setUpdateTime(LocalDateTime.now());
databaseOperationTaskService.updateById(task);
}
private String writeJsonWithoutPassword(DatabaseRestoreParam.CreateParam param) {
try {
DatabaseRestoreParam.CreateParam copy = new DatabaseRestoreParam.CreateParam();
copy.setConnectionId(param.getConnectionId());
copy.setBackupFileId(param.getBackupFileId());
copy.setRestoreMode(param.getRestoreMode());
copy.setTargetSchemaName(param.getTargetSchemaName());
copy.setOverwriteConfirmText(param.getOverwriteConfirmText());
return objectMapper.writeValueAsString(copy);
} catch (Exception exception) {
throw new BusinessException(CommonResponseEnum.JSON_CONVERT_EXCEPTION, exception.getMessage());
}
}
}

View File

@@ -0,0 +1,25 @@
package com.njcn.gather.systemops.database.service.impl;
import com.njcn.gather.systemops.database.pojo.vo.DatabaseOverviewVO;
import com.njcn.gather.systemops.database.service.DatabaseService;
import org.springframework.stereotype.Service;
/**
* 数据库监控基础服务实现。
*/
@Service
public class DatabaseServiceImpl implements DatabaseService {
private static final String STATUS_READY = "READY";
@Override
public DatabaseOverviewVO overview() {
DatabaseOverviewVO result = new DatabaseOverviewVO();
result.setMenuName("数据库监控");
result.setMenuCode("database");
result.setPath("/systemOps/database");
result.setStatus(STATUS_READY);
result.setDescription("数据库监控基础入口已接入,后续可在此扩展连接状态、容量和性能指标。");
return result;
}
}

Some files were not shown because too many files have changed in this diff Show More