Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bfda7bbe9 | ||
|
|
036d7488ad | ||
|
|
e0231fca8f | ||
|
|
2f068c6a5f | ||
|
|
3fddada267 |
+25
-22
@@ -1,26 +1,29 @@
|
||||
# ---> Java
|
||||
# Compiled class file
|
||||
*.class
|
||||
target/
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
|
||||
# Log file
|
||||
### STS ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
*.log
|
||||
*.flattened-pom.xml
|
||||
|
||||
# BlueJ files
|
||||
*.ctxt
|
||||
|
||||
# Mobile Tools for Java (J2ME)
|
||||
.mtj.tmp/
|
||||
|
||||
# Package Files #
|
||||
*.jar
|
||||
*.war
|
||||
*.nar
|
||||
*.ear
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
|
||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
||||
hs_err_pid*
|
||||
replay_pid*
|
||||
### NetBeans ###
|
||||
nbproject/private/
|
||||
build/
|
||||
nbbuild/
|
||||
dist/
|
||||
nbdist/
|
||||
.nb-gradle/
|
||||
|
||||
### Mac files ###
|
||||
*.DS_Store
|
||||
@@ -0,0 +1,149 @@
|
||||
# xj-oss-syn 项目说明
|
||||
|
||||
## 项目目标
|
||||
1. **OSS 同步**:将业务系统中存储在本地的文件批量上传到新疆油田 IOSP OSS 平台,并将业务表中的文件路径更新为 OSS 路径。
|
||||
2. **镜像管理**:通过 Harbor API + skopeo CLI 查询镜像仓库列表、最新 tag,并支持将 tar 包推送到 Harbor。
|
||||
3. **控制台实例管理**:通过 Choerodon DevOps API 查询实例列表、Pod 状态、YAML 配置,并支持更新配置。
|
||||
|
||||
## 技术栈
|
||||
- 框架:Solon 3.10.0
|
||||
- ORM:MyBatis-Plus(solon 插件)
|
||||
- 数据库:MySQL 8.x(HikariCP 连接池)
|
||||
- HTTP 客户端:Java 内置 `java.net.http.HttpClient`(trust-all SSLContext,支持 NEVER/NORMAL 重定向策略)
|
||||
- JSON:Fastjson2
|
||||
- 认证:Sa-Token(单账号 HTTP Basic,凭证来自 `sa-token.http-basic` 配置)
|
||||
- 模板引擎:FreeMarker(`src/main/resources/templates/`)
|
||||
- 构建:Maven + solon-maven-plugin
|
||||
|
||||
## 包结构
|
||||
```
|
||||
com.yxtech.ossyn
|
||||
├── App.java # 启动入口
|
||||
├── config/
|
||||
│ ├── BizDataSourceConfig.java # 业务库连接公共参数
|
||||
│ ├── BizFileMappingConfig.java # 业务表映射配置(来自 yml)
|
||||
│ ├── ConsoleConfig.java # Choerodon 控制台参数
|
||||
│ ├── HarborConfig.java # Harbor 镜像仓库参数
|
||||
│ ├── LocalFileConfig.java # 本地文件根路径
|
||||
│ ├── MybatisPlusConfig.java # MyBatis-Plus 分页插件等
|
||||
│ ├── OssConfig.java # OSS 连接参数
|
||||
│ ├── SaTokenConfig.java # Sa-Token 路由白名单
|
||||
│ └── SkopeoConfig.java # skopeo 可执行文件及镜像目录
|
||||
├── controller/
|
||||
│ ├── AuthController.java # 登录/登出页面(/auth/*)
|
||||
│ ├── BizFileMappingController.java # 映射配置 CRUD 接口
|
||||
│ ├── DevopsController.java # 镜像管理 + 控制台实例接口(/devops/*)
|
||||
│ └── OssSyncController.java # OSS 同步触发接口(/oss-sync/*)
|
||||
├── entity/
|
||||
│ ├── BizFileMappingEntity.java # 业务表映射配置实体
|
||||
│ ├── QhOssFileLog.java # 文件转换日志实体
|
||||
│ └── SyncTask.java # 同步任务实体
|
||||
├── filter/
|
||||
│ └── GlobalExceptionFilter.java # 全局异常处理(404 DEBUG / 其他 ERROR)
|
||||
├── mapper/
|
||||
│ ├── BizFileMappingMapper.java
|
||||
│ ├── QhOssFileLogMapper.java
|
||||
│ └── SyncTaskMapper.java
|
||||
├── service/
|
||||
│ ├── ConsoleService.java # Choerodon 控制台 API(token 缓存 + 401 重试)
|
||||
│ ├── HarborService.java # Harbor API + skopeo push(trust-all SSL)
|
||||
│ └── OssSyncService.java # OSS 同步核心逻辑
|
||||
└── util/
|
||||
├── DynamicDataSourceUtil.java # 动态数据源(按 dbName 创建连接池)
|
||||
└── OssUtil.java # OSS 上传工具(内存缓存 token)
|
||||
```
|
||||
|
||||
## 数据库
|
||||
- **数据源1(本项目)**:存储 `QH_OSS_FILE_LOG` 日志表,建表脚本见 `sql/init.sql`
|
||||
- **数据源2(业务库)**:动态创建,根据 `biz-file-mappings` 配置的 dbName 连接
|
||||
|
||||
## 配置说明(app.yml)
|
||||
| 配置项 | 说明 |
|
||||
|--------|------|
|
||||
| `solon.dataSources.db1!` | 本项目数据库连接 |
|
||||
| `server.request-maxBodySize` | 请求体大小上限(当前 1GB,支持大镜像上传) |
|
||||
| `biz-datasource` | 业务库公共连接参数(host/port/user/pass) |
|
||||
| `biz-file-mappings` | 业务表映射列表(dbName/tableName/idCol/fileCol) |
|
||||
| `local-file.root-path` | 本地文件根路径 |
|
||||
| `xj-oss.*` | OSS 网关、鉴权、目录等参数 |
|
||||
| `harbor.*` | Harbor 地址、项目名、账号密码 |
|
||||
| `skopeo.exe-path` | skopeo.exe 可执行文件路径 |
|
||||
| `skopeo.images-path` | 镜像 tar 包存放目录 |
|
||||
| `console.api-base` | Choerodon DevOps API 网关地址 |
|
||||
| `console.project-id` | 控制台项目 ID |
|
||||
| `console.env-id` | 控制台环境 ID |
|
||||
| `console.login-username/password` | 控制台登录账号 |
|
||||
| `console.public-key` | 控制台 RSA 公钥(用于密码加密) |
|
||||
| `sa-token.http-basic` | 本系统登录凭证(格式:用户名:密码) |
|
||||
|
||||
> 敏感配置(密码、密钥等)可通过 `app-env.yml` 覆盖,该文件不纳入版本管理。
|
||||
|
||||
## HTTP 接口
|
||||
### 认证(AuthController)
|
||||
| 接口 | 说明 |
|
||||
|------|------|
|
||||
| GET /auth/login | 显示登录页 |
|
||||
| POST /auth/doLogin | 处理登录(凭证来自 sa-token.http-basic) |
|
||||
| GET /auth/logout | 退出登录 |
|
||||
|
||||
### OSS 同步(OssSyncController)
|
||||
| 接口 | 说明 |
|
||||
|------|------|
|
||||
| POST /oss-sync/all | 一键全量同步(扫描+上传+回写) |
|
||||
| POST /oss-sync/scan | 仅扫描业务表,写入日志 |
|
||||
| POST /oss-sync/upload | 仅上传待处理文件 |
|
||||
| POST /oss-sync/write-back | 仅回写业务表 |
|
||||
|
||||
### DevOps(DevopsController)
|
||||
| 接口 | 说明 |
|
||||
|------|------|
|
||||
| GET /devops/harbor/repositories | 查询 Harbor 镜像仓库列表 |
|
||||
| GET /devops/harbor/latest-tag?repo= | 查询指定仓库最新 tag |
|
||||
| POST /devops/harbor/push | 上传 tar 包并推送到 Harbor(multipart,自动计算下一个 tag) |
|
||||
| POST /devops/console/instances | 查询所有实例(全量 size=500) |
|
||||
| POST /devops/console/pods | 查询实例 Pod 列表 |
|
||||
| GET /devops/console/yaml | 查询实例 YAML 配置 |
|
||||
| PUT /devops/console/yaml | 更新实例 YAML 配置 |
|
||||
|
||||
## OSS 处理状态说明
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| 0 | 待处理 |
|
||||
| 1 | 上传成功 |
|
||||
| 2 | 上传失败 |
|
||||
| 3 | 文件不存在 |
|
||||
| 4 | 已回写业务表 |
|
||||
|
||||
## 关键设计说明
|
||||
|
||||
### Choerodon 控制台登录流程
|
||||
登录需要两步(均需要 `NEVER` 重定向策略才能拿到 302 响应头):
|
||||
1. `POST /oauth/choerodon/login` → 收集响应中所有 `Set-Cookie`(`allValues`),拼成完整 Cookie 字符串
|
||||
2. `GET /oauth/oauth/authorize` 携带 Cookie → 从 302 响应的 `Location` URL fragment(`#` 后)提取 `access_token`
|
||||
|
||||
- `authClient`:`NEVER` 重定向(登录/授权专用)
|
||||
- `apiClient`:`NORMAL` 重定向(业务请求专用)
|
||||
- token 内存缓存,有效期 ~24h;收到 401 自动清除缓存并重试一次
|
||||
|
||||
### SSL 证书
|
||||
内部域名使用自签证书,`HarborService.buildTrustAllClient()` 构造 trust-all `SSLContext`,
|
||||
所有 `HttpClient` 实例均通过该方法创建,跳过证书验证。
|
||||
|
||||
### 镜像上传(skopeo)
|
||||
```
|
||||
skopeo copy --dest-creds user:pass --dest-tls-verify=false \
|
||||
docker-archive:{tarAbsPath} \
|
||||
docker://{harborHost}/{project}/{repo}:{tag}
|
||||
```
|
||||
- tar 包按日期子目录存放(`imagesPath/yyyyMMdd/filename.tar`)
|
||||
- tag 自动在最新 tag 基础上末位 +1(如 `1.0.10` → `1.0.11`)
|
||||
|
||||
### 注意事项
|
||||
- 扫描阶段幂等,同一业务表+ID+列已存在日志则跳过
|
||||
- OSS token 使用内存缓存,无需 Redis
|
||||
- 动态数据源按 dbName 缓存连接池,避免重复创建
|
||||
- 业务库列名/表名来自内部配置,不存在 SQL 注入风险
|
||||
- 文件上传暂不支持 JSON 类型(TXX-19,待排查)
|
||||
|
||||
## Linear 任务追踪
|
||||
所有功能任务和 Bug 统一记录到 Linear `tar-update` 项目(团队 `txxz`)。
|
||||
@@ -0,0 +1,148 @@
|
||||
<?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>org.noear</groupId>
|
||||
<artifactId>solon-parent</artifactId>
|
||||
<version>3.10.0</version>
|
||||
<relativePath />
|
||||
</parent>
|
||||
|
||||
<groupId>com.yxtech</groupId>
|
||||
<artifactId>xj-oss-syn</artifactId>
|
||||
<version>1.0</version>
|
||||
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<description>watch valid</description>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
<mysql.version>8.0.33</mysql.version>
|
||||
<hikaricp.version>5.1.0</hikaricp.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.noear</groupId>
|
||||
<artifactId>solon-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- MyBatis-Plus(含 Solon 插件) -->
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-solon-plugin</artifactId>
|
||||
<version>3.5.12</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-jsqlparser</artifactId>
|
||||
<version>3.5.12</version>
|
||||
</dependency>
|
||||
|
||||
<!-- MySQL 驱动 -->
|
||||
<dependency>
|
||||
<groupId>mysql</groupId>
|
||||
<artifactId>mysql-connector-java</artifactId>
|
||||
<version>${mysql.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- 数据库连接池 -->
|
||||
<dependency>
|
||||
<groupId>com.zaxxer</groupId>
|
||||
<artifactId>HikariCP</artifactId>
|
||||
<version>${hikaricp.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- HTTP 客户端(用于 OSS 上传鉴权) -->
|
||||
<dependency>
|
||||
<groupId>org.apache.httpcomponents.client5</groupId>
|
||||
<artifactId>httpclient5</artifactId>
|
||||
<version>5.3.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- JSON 解析 -->
|
||||
<dependency>
|
||||
<groupId>com.alibaba.fastjson2</groupId>
|
||||
<artifactId>fastjson2</artifactId>
|
||||
<version>2.0.51</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 定时任务 -->
|
||||
<dependency>
|
||||
<groupId>org.noear</groupId>
|
||||
<artifactId>solon-scheduling</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.noear</groupId>
|
||||
<artifactId>solon-scheduling-simple</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.noear</groupId>
|
||||
<artifactId>solon-view-freemarker</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.noear</groupId>
|
||||
<artifactId>solon-logging-logback-jakarta</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Sa-Token 鉴权 -->
|
||||
<dependency>
|
||||
<groupId>cn.dev33</groupId>
|
||||
<artifactId>sa-token-solon-plugin</artifactId>
|
||||
<version>1.44.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.noear</groupId>
|
||||
<artifactId>solon-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>${project.artifactId}</finalName>
|
||||
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.noear</groupId>
|
||||
<artifactId>solon-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>tencent</id>
|
||||
<url>https://mirrors.cloud.tencent.com/nexus/repository/maven-public/</url>
|
||||
<snapshots>
|
||||
<enabled>false</enabled>
|
||||
</snapshots>
|
||||
</repository>
|
||||
</repositories>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,56 @@
|
||||
-- ============================================================
|
||||
-- QH_OSS_FILE_LOG 文件转换日志表
|
||||
-- 数据源1(本项目数据库)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS `QH_OSS_FILE_LOG` (
|
||||
`ID` BIGINT NOT NULL COMMENT '主键(雪花ID)',
|
||||
`OSS_PATH` VARCHAR(512) DEFAULT NULL COMMENT 'OSS路径(上传成功后填入)',
|
||||
`BIZ_TAB_NAME` VARCHAR(128) NOT NULL COMMENT '业务表名',
|
||||
`BIZ_TAB_ID` VARCHAR(64) NOT NULL COMMENT '业务表主键值',
|
||||
`BIZ_COL_NAME` VARCHAR(128) NOT NULL COMMENT '业务表列名(文件路径字段)',
|
||||
`BIZ_TAB_PATH` VARCHAR(512) DEFAULT NULL COMMENT '业务表中原始文件路径',
|
||||
`BIZ_HANDLE_STATUS` TINYINT NOT NULL DEFAULT 0 COMMENT '处理状态:0=待处理,1=上传成功,2=上传失败,3=文件不存在,4=已回写',
|
||||
`CREATE_TIME` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`UPDATE_TIME` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`ID`),
|
||||
INDEX `idx_biz_tab` (`BIZ_TAB_NAME`, `BIZ_TAB_ID`),
|
||||
INDEX `idx_status` (`BIZ_HANDLE_STATUS`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件转换日志表';
|
||||
|
||||
-- ============================================================
|
||||
-- SYNC_TASK 同步任务记录表
|
||||
-- 记录每次触发的同步任务进度,供前端轮询展示
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS `SYNC_TASK` (
|
||||
`ID` BIGINT NOT NULL COMMENT '主键(雪花ID)',
|
||||
`TASK_TYPE` VARCHAR(32) NOT NULL COMMENT '任务类型:all/scan/upload/write-back',
|
||||
`STATUS` TINYINT NOT NULL DEFAULT 0 COMMENT '0=进行中,1=完成,2=失败',
|
||||
`TOTAL` INT NOT NULL DEFAULT 0 COMMENT '总处理条数',
|
||||
`SUCCESS` INT NOT NULL DEFAULT 0 COMMENT '成功条数',
|
||||
`FAIL` INT NOT NULL DEFAULT 0 COMMENT '失败条数',
|
||||
`MESSAGE` VARCHAR(512) DEFAULT NULL COMMENT '备注/错误信息',
|
||||
`CREATE_TIME` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`UPDATE_TIME` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`ID`),
|
||||
INDEX `idx_status` (`STATUS`),
|
||||
INDEX `idx_create_time` (`CREATE_TIME`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同步任务记录表';
|
||||
|
||||
-- ============================================================
|
||||
-- BIZ_FILE_MAPPING 业务文件映射配置表
|
||||
-- 替代 app.yml 中的 biz-file-mappings 配置,支持动态管理
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS `BIZ_FILE_MAPPING` (
|
||||
`ID` BIGINT NOT NULL COMMENT '主键(雪花ID)',
|
||||
`DB_NAME` VARCHAR(128) NOT NULL COMMENT '数据库名',
|
||||
`TABLE_NAME` VARCHAR(128) NOT NULL COMMENT '业务表名',
|
||||
`ID_COL` VARCHAR(64) NOT NULL DEFAULT 'id' COMMENT '主键列名',
|
||||
`FILE_COL` VARCHAR(128) NOT NULL COMMENT '文件路径列名',
|
||||
`BIZ_PATH` VARCHAR(128) DEFAULT NULL COMMENT 'OSS上传业务路径(不填则降级用表名)',
|
||||
`ENABLED` TINYINT NOT NULL DEFAULT 0 COMMENT '是否有效:0=无效,1=有效',
|
||||
`REMARK` VARCHAR(256) DEFAULT NULL COMMENT '备注',
|
||||
`CREATE_TIME` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`UPDATE_TIME` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`ID`),
|
||||
INDEX `idx_enabled` (`ENABLED`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务文件映射配置表';
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.yxtech.ossyn;
|
||||
|
||||
import org.noear.solon.Solon;
|
||||
import org.noear.solon.annotation.SolonMain;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PublicKey;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* 应用程序入口
|
||||
*/
|
||||
@SolonMain
|
||||
public class App {
|
||||
public static void main(String[] args) {
|
||||
Solon.start(App.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.yxtech.ossyn.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.noear.solon.annotation.Configuration;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
|
||||
/**
|
||||
* 业务库连接公共参数配置
|
||||
* 对应 app.yml 中的 biz-datasource 节点
|
||||
*/
|
||||
@Data
|
||||
@Configuration
|
||||
public class BizDataSourceConfig {
|
||||
|
||||
@Inject("${biz-datasource.host}")
|
||||
private String host;
|
||||
|
||||
@Inject("${biz-datasource.port}")
|
||||
private int port;
|
||||
|
||||
@Inject("${biz-datasource.username}")
|
||||
private String username;
|
||||
|
||||
@Inject("${biz-datasource.password}")
|
||||
private String password;
|
||||
|
||||
@Inject("${biz-datasource.params}")
|
||||
private String params;
|
||||
|
||||
/**
|
||||
* 根据数据库名拼接 JDBC URL
|
||||
*
|
||||
* @param dbName 数据库名
|
||||
* @return JDBC URL
|
||||
*/
|
||||
public String buildJdbcUrl(String dbName) {
|
||||
return String.format("jdbc:mysql://%s:%d/%s?%s", host, port, dbName, params);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.yxtech.ossyn.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.noear.solon.annotation.Configuration;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
|
||||
/**
|
||||
* 控制台(Choerodon DevOps)配置
|
||||
*/
|
||||
@Data
|
||||
@Configuration
|
||||
public class ConsoleConfig {
|
||||
|
||||
@Inject("${console.api-base}")
|
||||
private String apiBase;
|
||||
|
||||
@Inject("${console.project-id}")
|
||||
private String projectId;
|
||||
|
||||
@Inject("${console.env-id}")
|
||||
private String envId;
|
||||
|
||||
@Inject("${console.login-username}")
|
||||
private String loginUsername;
|
||||
|
||||
@Inject("${console.login-password}")
|
||||
private String loginPassword;
|
||||
|
||||
/** RSA 公钥(用于密码加密) */
|
||||
@Inject("${console.public-key}")
|
||||
private String publicKey;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.yxtech.ossyn.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.noear.solon.annotation.Configuration;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
|
||||
/**
|
||||
* Harbor 镜像仓库配置
|
||||
*/
|
||||
@Data
|
||||
@Configuration
|
||||
public class HarborConfig {
|
||||
|
||||
@Inject("${harbor.url}")
|
||||
private String url;
|
||||
|
||||
@Inject("${harbor.project}")
|
||||
private String project;
|
||||
|
||||
@Inject("${harbor.username}")
|
||||
private String username;
|
||||
|
||||
@Inject("${harbor.password}")
|
||||
private String password;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.yxtech.ossyn.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.noear.solon.annotation.Configuration;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
|
||||
/**
|
||||
* 本地文件根路径配置
|
||||
* 对应 app.yml 中的 local-file 节点
|
||||
*/
|
||||
@Data
|
||||
@Configuration
|
||||
public class LocalFileConfig {
|
||||
|
||||
/** 本地文件根路径,业务表中存储的相对路径拼接此前缀后查找文件 */
|
||||
@Inject("${local-file.root-path}")
|
||||
private String rootPath;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.yxtech.ossyn.config;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||
import org.noear.solon.annotation.Bean;
|
||||
import org.noear.solon.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* MyBatis-Plus 配置
|
||||
* 注册分页插件,支持 selectPage 分页查询
|
||||
*/
|
||||
@Configuration
|
||||
public class MybatisPlusConfig {
|
||||
|
||||
@Bean
|
||||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||
// 添加分页插件(MySQL 方言)
|
||||
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
|
||||
return interceptor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.yxtech.ossyn.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.noear.solon.annotation.Configuration;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
|
||||
/**
|
||||
* OSS 连接配置
|
||||
* 对应 app.yml 中的 xj-oss 节点
|
||||
*/
|
||||
@Data
|
||||
@Configuration
|
||||
public class OssConfig {
|
||||
|
||||
/** 网关地址 */
|
||||
@Inject("${xj-oss.gateway-url}")
|
||||
private String gatewayUrl;
|
||||
|
||||
/** OAuth2 客户端ID */
|
||||
@Inject("${xj-oss.client-id}")
|
||||
private String clientId;
|
||||
|
||||
/** OAuth2 客户端密钥 */
|
||||
@Inject("${xj-oss.client-secret}")
|
||||
private String clientSecret;
|
||||
|
||||
/** 组织ID */
|
||||
@Inject("${xj-oss.organization-id}")
|
||||
private String organizationId;
|
||||
|
||||
/** 配置Code */
|
||||
@Inject("${xj-oss.config-code}")
|
||||
private String configCode;
|
||||
|
||||
/** 授权上传目录 */
|
||||
@Inject("${xj-oss.directory}")
|
||||
private String directory;
|
||||
|
||||
/** 允许上传的文件类型白名单,逗号分隔 */
|
||||
@Inject("${xj-oss.file-type-white-list}")
|
||||
private String fileTypeWhiteList;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.yxtech.ossyn.config;
|
||||
|
||||
import cn.dev33.satoken.context.SaHolder;
|
||||
import cn.dev33.satoken.router.SaHttpMethod;
|
||||
import cn.dev33.satoken.router.SaRouter;
|
||||
import cn.dev33.satoken.solon.integration.SaTokenInterceptor;
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.noear.solon.annotation.Bean;
|
||||
import org.noear.solon.annotation.Configuration;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
import org.noear.solon.core.handle.Filter;
|
||||
|
||||
/**
|
||||
* Sa-Token 鉴权配置
|
||||
* 拦截所有请求,放行登录页、登录接口、静态资源
|
||||
* 未登录时重定向到 /auth/login
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class SaTokenConfig {
|
||||
|
||||
@Inject("${server.contextPath:}")
|
||||
private String basePath;
|
||||
|
||||
@Bean(index = -100)
|
||||
public SaTokenInterceptor saTokenInterceptor() {
|
||||
return new SaTokenInterceptor()
|
||||
.addInclude("/**")
|
||||
.addExclude("/favicon.ico")
|
||||
.setAuth(obj -> {
|
||||
// 放行登录相关路由,其余均需登录
|
||||
SaRouter.match("/**")
|
||||
.notMatch(basePath + "/auth/login")
|
||||
.notMatch(basePath + "/auth/doLogin")
|
||||
.check(r -> StpUtil.checkLogin());
|
||||
})
|
||||
.setBeforeAuth(obj -> {
|
||||
SaHolder.getResponse()
|
||||
.setHeader("Access-Control-Allow-Origin", "*")
|
||||
.setHeader("Access-Control-Allow-Methods", "*")
|
||||
.setHeader("Access-Control-Allow-Headers", "*")
|
||||
.setHeader("Access-Control-Max-Age", "3600");
|
||||
SaRouter.match(SaHttpMethod.OPTIONS).back();
|
||||
})
|
||||
.setError(e -> {
|
||||
// 拦截器抛出异常由下方 Filter 处理重定向,此处仅记录日志
|
||||
log.warn("鉴权失败 [{}]: {}", SaHolder.getRequest().getUrl(), e.getMessage());
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 未登录时重定向到登录页(order=-90,在 SaTokenInterceptor 之后执行)
|
||||
*/
|
||||
@Bean(index = -90)
|
||||
public Filter authRedirectFilter() {
|
||||
return (ctx, chain) -> {
|
||||
String path = ctx.path();
|
||||
// 放行登录相关路由和静态资源
|
||||
if (path.startsWith(basePath + "/auth/") || path.startsWith("/static/")) {
|
||||
chain.doFilter(ctx);
|
||||
return;
|
||||
}
|
||||
if (!StpUtil.isLogin()) {
|
||||
ctx.redirect(basePath + "/auth/login");
|
||||
return;
|
||||
}
|
||||
chain.doFilter(ctx);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.yxtech.ossyn.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.noear.solon.annotation.Configuration;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
|
||||
/**
|
||||
* Skopeo 工具配置
|
||||
*/
|
||||
@Data
|
||||
@Configuration
|
||||
public class SkopeoConfig {
|
||||
|
||||
/** skopeo.exe 可执行文件路径 */
|
||||
@Inject("${skopeo.exe-path}")
|
||||
private String exePath;
|
||||
|
||||
/** 镜像 tar 包存放目录 */
|
||||
@Inject("${skopeo.images-path}")
|
||||
private String imagesPath;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.yxtech.ossyn.controller;
|
||||
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.noear.solon.annotation.*;
|
||||
import org.noear.solon.core.handle.Context;
|
||||
import org.noear.solon.core.handle.ModelAndView;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 登录/登出控制器
|
||||
* 凭证来源:app.yml sa-token.http-basic(格式:用户名:密码)
|
||||
*/
|
||||
@Slf4j
|
||||
@Controller
|
||||
@Mapping("/auth")
|
||||
public class AuthController {
|
||||
|
||||
/** HTTP Basic 凭证,格式:用户名:密码 */
|
||||
@Inject("${sa-token.http-basic}")
|
||||
private String httpBasic;
|
||||
|
||||
@Inject("${server.contextPath:}")
|
||||
private String basePath;
|
||||
|
||||
/**
|
||||
* 显示登录页
|
||||
*/
|
||||
@Get
|
||||
@Mapping("/login")
|
||||
public Object loginPage(@Param(defaultValue = "") String error, Context ctx) throws IOException {
|
||||
if (StpUtil.isLogin()) {
|
||||
ctx.redirect(basePath + "/oss-sync/");
|
||||
return null;
|
||||
}
|
||||
ModelAndView mv = new ModelAndView("login.ftl");
|
||||
mv.put("basePath", basePath);
|
||||
mv.put("errorMsg", "1".equals(error) ? "用户名或密码错误,请重试" : "");
|
||||
return mv;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理登录表单提交
|
||||
*/
|
||||
@Post
|
||||
@Mapping("/doLogin")
|
||||
public void doLogin(
|
||||
@Param(defaultValue = "") String username,
|
||||
@Param(defaultValue = "") String password,
|
||||
Context ctx) throws IOException {
|
||||
String[] parts = httpBasic.split(":", 2);
|
||||
String cfgUser = parts.length > 0 ? parts[0] : "";
|
||||
String cfgPass = parts.length > 1 ? parts[1] : "";
|
||||
|
||||
if (cfgUser.equals(username.trim()) && cfgPass.equals(password)) {
|
||||
StpUtil.login(1);
|
||||
log.info("[Auth] 登录成功,用户: {}", username);
|
||||
ctx.redirect(basePath + "/oss-sync/");
|
||||
} else {
|
||||
log.warn("[Auth] 登录失败,用户名: {}", username);
|
||||
ctx.redirect(basePath + "/auth/login?error=1");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录
|
||||
*/
|
||||
@Get
|
||||
@Mapping("/logout")
|
||||
public void logout(Context ctx) throws IOException {
|
||||
StpUtil.logout();
|
||||
ctx.redirect(basePath + "/auth/login");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.yxtech.ossyn.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.yxtech.ossyn.entity.BizFileMappingEntity;
|
||||
import com.yxtech.ossyn.mapper.BizFileMappingMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.noear.solon.annotation.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 业务文件映射配置管理接口
|
||||
*/
|
||||
@Slf4j
|
||||
@Controller
|
||||
@Mapping("/mapping")
|
||||
public class BizFileMappingController {
|
||||
|
||||
@Inject
|
||||
private BizFileMappingMapper mappingMapper;
|
||||
|
||||
/**
|
||||
* 查询所有映射配置列表
|
||||
*/
|
||||
@Get
|
||||
@Mapping("/list")
|
||||
public Map<String, Object> list() {
|
||||
List<BizFileMappingEntity> list = mappingMapper.selectList(
|
||||
new LambdaQueryWrapper<BizFileMappingEntity>()
|
||||
.orderByDesc(BizFileMappingEntity::getCreateTime));
|
||||
List<Map<String, Object>> data = list.stream().map(this::toMap).collect(Collectors.toList());
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增或更新映射配置
|
||||
* id 为空时新增,有 id 时更新
|
||||
*/
|
||||
@Post
|
||||
@Mapping("/save")
|
||||
public Map<String, Object> save(@Body BizFileMappingEntity body) {
|
||||
if (body.getDbName() == null || body.getDbName().isBlank()) return fail("dbName 不能为空");
|
||||
if (body.getTableName() == null || body.getTableName().isBlank()) return fail("tableName 不能为空");
|
||||
if (body.getIdCol() == null || body.getIdCol().isBlank()) return fail("idCol 不能为空");
|
||||
if (body.getFileCol() == null || body.getFileCol().isBlank()) return fail("fileCol 不能为空");
|
||||
|
||||
if (body.getId() == null) {
|
||||
// 新增:默认无效
|
||||
body.setEnabled(BizFileMappingEntity.ENABLED_NO);
|
||||
body.setCreateTime(LocalDateTime.now());
|
||||
body.setUpdateTime(LocalDateTime.now());
|
||||
mappingMapper.insert(body);
|
||||
log.info("[MappingCtrl] 新增映射配置: {}.{}", body.getDbName(), body.getTableName());
|
||||
} else {
|
||||
// 更新
|
||||
body.setUpdateTime(LocalDateTime.now());
|
||||
mappingMapper.updateById(body);
|
||||
log.info("[MappingCtrl] 更新映射配置 id={}: {}.{}", body.getId(), body.getDbName(), body.getTableName());
|
||||
}
|
||||
return ok(toMap(body));
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换启用/禁用状态
|
||||
*/
|
||||
@Post
|
||||
@Mapping("/toggle/{id}")
|
||||
public Map<String, Object> toggle(@Path String id) {
|
||||
BizFileMappingEntity entity = mappingMapper.selectById(Long.parseLong(id));
|
||||
if (entity == null) return fail("记录不存在");
|
||||
int newEnabled = entity.getEnabled() == BizFileMappingEntity.ENABLED_YES
|
||||
? BizFileMappingEntity.ENABLED_NO : BizFileMappingEntity.ENABLED_YES;
|
||||
mappingMapper.update(null, new LambdaUpdateWrapper<BizFileMappingEntity>()
|
||||
.eq(BizFileMappingEntity::getId, entity.getId())
|
||||
.set(BizFileMappingEntity::getEnabled, newEnabled)
|
||||
.set(BizFileMappingEntity::getUpdateTime, LocalDateTime.now()));
|
||||
log.info("[MappingCtrl] 切换映射配置 id={} enabled={}", id, newEnabled);
|
||||
entity.setEnabled(newEnabled);
|
||||
return ok(toMap(entity));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除映射配置
|
||||
*/
|
||||
@Delete
|
||||
@Mapping("/delete/{id}")
|
||||
public Map<String, Object> delete(@Path String id) {
|
||||
int rows = mappingMapper.deleteById(Long.parseLong(id));
|
||||
if (rows == 0) return fail("记录不存在");
|
||||
log.info("[MappingCtrl] 删除映射配置 id={}", id);
|
||||
return ok(null);
|
||||
}
|
||||
|
||||
// ===================== 工具方法 =====================
|
||||
|
||||
private Map<String, Object> toMap(BizFileMappingEntity e) {
|
||||
Map<String, Object> m = new HashMap<>(10);
|
||||
m.put("id", String.valueOf(e.getId()));
|
||||
m.put("dbName", e.getDbName());
|
||||
m.put("tableName", e.getTableName());
|
||||
m.put("idCol", e.getIdCol());
|
||||
m.put("fileCol", e.getFileCol());
|
||||
m.put("bizPath", e.getBizPath());
|
||||
m.put("enabled", e.getEnabled());
|
||||
m.put("remark", e.getRemark());
|
||||
m.put("createTime", e.getCreateTime() != null ? e.getCreateTime().toString() : null);
|
||||
m.put("updateTime", e.getUpdateTime() != null ? e.getUpdateTime().toString() : null);
|
||||
return m;
|
||||
}
|
||||
|
||||
private Map<String, Object> ok(Object data) {
|
||||
Map<String, Object> r = new HashMap<>(3);
|
||||
r.put("code", 200);
|
||||
r.put("msg", "ok");
|
||||
r.put("data", data);
|
||||
return r;
|
||||
}
|
||||
|
||||
private Map<String, Object> fail(String msg) {
|
||||
Map<String, Object> r = new HashMap<>(2);
|
||||
r.put("code", 500);
|
||||
r.put("msg", msg);
|
||||
return r;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package com.yxtech.ossyn.controller;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.yxtech.ossyn.service.ConsoleService;
|
||||
import com.yxtech.ossyn.service.HarborService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.noear.solon.annotation.*;
|
||||
import org.noear.solon.core.handle.UploadedFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 镜像管理 + 控制台实例管理接口
|
||||
*/
|
||||
@Slf4j
|
||||
@Controller
|
||||
@Mapping("/devops")
|
||||
public class DevopsController {
|
||||
|
||||
@Inject
|
||||
private HarborService harborService;
|
||||
|
||||
@Inject
|
||||
private ConsoleService consoleService;
|
||||
|
||||
@Inject("${skopeo.images-path}")
|
||||
private String imagesPath;
|
||||
|
||||
// ===================== Harbor 镜像 =====================
|
||||
|
||||
/** 查询镜像仓库列表 */
|
||||
@Get
|
||||
@Mapping("/harbor/repositories")
|
||||
public Map<String, Object> listRepositories() {
|
||||
try {
|
||||
return ok(harborService.listRepositories());
|
||||
} catch (Exception e) {
|
||||
log.error("[Devops] listRepositories error", e);
|
||||
return fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询指定镜像最新 tag */
|
||||
@Get
|
||||
@Mapping("/harbor/latest-tag")
|
||||
public Map<String, Object> latestTag(@Param String repo) {
|
||||
try {
|
||||
return ok(harborService.latestTag(repo));
|
||||
} catch (Exception e) {
|
||||
log.error("[Devops] latestTag error", e);
|
||||
return fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传 tar 包并推送到 Harbor
|
||||
* 参数:file(multipart)、repo(目标仓库名)、tag(可选,不传则自动+1)
|
||||
*/
|
||||
@Post
|
||||
@Mapping("/harbor/push")
|
||||
public Map<String, Object> pushImage(
|
||||
@Param String repo,
|
||||
@Param(required = false) String tag,
|
||||
UploadedFile file) {
|
||||
try {
|
||||
// 保存 tar 到 images 目录
|
||||
String subDir = new java.text.SimpleDateFormat("yyyyMMdd")
|
||||
.format(new java.util.Date());
|
||||
File dir = Paths.get(imagesPath, subDir).toFile();
|
||||
if (!dir.exists()) dir.mkdirs();
|
||||
File tarFile = new File(dir, file.getName());
|
||||
try (FileOutputStream fos = new FileOutputStream(tarFile)) {
|
||||
file.getContent().transferTo(fos);
|
||||
}
|
||||
|
||||
// 自动计算 tag
|
||||
if (tag == null || tag.isBlank()) {
|
||||
JSONObject latest = harborService.latestTag(repo);
|
||||
tag = nextTag(latest);
|
||||
}
|
||||
|
||||
String result = harborService.pushImage(subDir + "/" + tarFile.getName(), repo, tag);
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("tag", tag);
|
||||
data.put("output", result);
|
||||
return ok(data);
|
||||
} catch (Exception e) {
|
||||
log.error("[Devops] pushImage error", e);
|
||||
return fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 在最新 tag 基础上 +1(支持 1.0.10 → 1.0.11 格式) */
|
||||
private String nextTag(JSONObject latestArtifact) {
|
||||
if (latestArtifact == null) return "1.0.1";
|
||||
var tags = latestArtifact.getJSONArray("tags");
|
||||
if (tags == null || tags.isEmpty()) return "1.0.1";
|
||||
String name = tags.getJSONObject(0).getString("name");
|
||||
if (name == null) return "1.0.1";
|
||||
String[] parts = name.split("\\.");
|
||||
try {
|
||||
int last = Integer.parseInt(parts[parts.length - 1]) + 1;
|
||||
parts[parts.length - 1] = String.valueOf(last);
|
||||
return String.join(".", parts);
|
||||
} catch (NumberFormatException e) {
|
||||
return name + "-1";
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 控制台实例 =====================
|
||||
|
||||
/** 查询所有实例 */
|
||||
@Post
|
||||
@Mapping("/console/instances")
|
||||
public Map<String, Object> listInstances() {
|
||||
try {
|
||||
return ok(consoleService.listInstances());
|
||||
} catch (Exception e) {
|
||||
log.error("[Devops] listInstances error", e);
|
||||
return fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询实例的 Pod 列表 */
|
||||
@Post
|
||||
@Mapping("/console/pods")
|
||||
public Map<String, Object> listPods(@Param String instanceId) {
|
||||
try {
|
||||
return ok(consoleService.listPods(instanceId));
|
||||
} catch (Exception e) {
|
||||
log.error("[Devops] listPods error", e);
|
||||
return fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询实例 YAML 配置 */
|
||||
@Get
|
||||
@Mapping("/console/yaml")
|
||||
public Map<String, Object> getYaml(@Param String instanceId, @Param String versionId) {
|
||||
try {
|
||||
return ok(consoleService.getInstanceYaml(instanceId, versionId));
|
||||
} catch (Exception e) {
|
||||
log.error("[Devops] getYaml error", e);
|
||||
return fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新实例 YAML 配置 */
|
||||
@Put
|
||||
@Mapping("/console/yaml")
|
||||
public Map<String, Object> updateYaml(@Body JSONObject payload) {
|
||||
try {
|
||||
return ok(consoleService.updateInstanceYaml(payload));
|
||||
} catch (Exception e) {
|
||||
log.error("[Devops] updateYaml error", e);
|
||||
return fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 工具方法 =====================
|
||||
|
||||
private Map<String, Object> ok(Object data) {
|
||||
Map<String, Object> r = new HashMap<>(3);
|
||||
r.put("code", 200);
|
||||
r.put("msg", "ok");
|
||||
r.put("data", data);
|
||||
return r;
|
||||
}
|
||||
|
||||
private Map<String, Object> fail(String msg) {
|
||||
Map<String, Object> r = new HashMap<>(2);
|
||||
r.put("code", 500);
|
||||
r.put("msg", msg);
|
||||
return r;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package com.yxtech.ossyn.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.yxtech.ossyn.entity.QhOssFileLog;
|
||||
import com.yxtech.ossyn.entity.SyncTask;
|
||||
import com.yxtech.ossyn.mapper.QhOssFileLogMapper;
|
||||
import com.yxtech.ossyn.mapper.SyncTaskMapper;
|
||||
import com.yxtech.ossyn.service.OssSyncService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.noear.solon.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* OSS 同步触发接口 + 任务状态查询 + 日志查询
|
||||
*/
|
||||
@Slf4j
|
||||
@Controller
|
||||
@Mapping("/oss-sync")
|
||||
public class OssSyncController {
|
||||
|
||||
@Inject
|
||||
private OssSyncService ossSyncService;
|
||||
|
||||
@Inject
|
||||
private SyncTaskMapper syncTaskMapper;
|
||||
|
||||
@Inject
|
||||
private QhOssFileLogMapper ossFileLogMapper;
|
||||
|
||||
// ===================== 触发接口(异步,立即返回任务ID) =====================
|
||||
|
||||
/** 一键全量同步 */
|
||||
@Post
|
||||
@Mapping("/all")
|
||||
public Map<String, Object> syncAll() {
|
||||
String taskId = ossSyncService.asyncSyncAll();
|
||||
return ok("全量同步任务已启动", taskId);
|
||||
}
|
||||
|
||||
/** 仅扫描 */
|
||||
@Post
|
||||
@Mapping("/scan")
|
||||
public Map<String, Object> scan() {
|
||||
String taskId = ossSyncService.asyncScan();
|
||||
return ok("扫描任务已启动", taskId);
|
||||
}
|
||||
|
||||
/** 仅上传 */
|
||||
@Post
|
||||
@Mapping("/upload")
|
||||
public Map<String, Object> upload() {
|
||||
String taskId = ossSyncService.asyncUpload();
|
||||
return ok("上传任务已启动", taskId);
|
||||
}
|
||||
|
||||
/** 仅回写 */
|
||||
@Post
|
||||
@Mapping("/write-back")
|
||||
public Map<String, Object> writeBack() {
|
||||
String taskId = ossSyncService.asyncWriteBack();
|
||||
return ok("回写任务已启动", taskId);
|
||||
}
|
||||
|
||||
// ===================== 任务状态查询(前端轮询) =====================
|
||||
|
||||
/**
|
||||
* 查询指定任务状态
|
||||
*
|
||||
* @param id 任务ID(字符串,避免前端精度丢失)
|
||||
*/
|
||||
@Get
|
||||
@Mapping("/task/{id}")
|
||||
public Map<String, Object> taskStatus(@Path String id) {
|
||||
SyncTask task = syncTaskMapper.selectById(Long.parseLong(id));
|
||||
if (task == null) {
|
||||
return fail("任务不存在");
|
||||
}
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("id", String.valueOf(task.getId()));
|
||||
data.put("taskType", task.getTaskType());
|
||||
data.put("status", task.getStatus());
|
||||
data.put("total", task.getTotal());
|
||||
data.put("success", task.getSuccess());
|
||||
data.put("fail", task.getFail());
|
||||
data.put("message", task.getMessage());
|
||||
data.put("createTime", task.getCreateTime() != null ? task.getCreateTime().toString() : null);
|
||||
data.put("updateTime", task.getUpdateTime() != null ? task.getUpdateTime().toString() : null);
|
||||
return ok("ok", data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询最近任务列表(最多20条)
|
||||
*/
|
||||
@Get
|
||||
@Mapping("/tasks")
|
||||
public Map<String, Object> recentTasks() {
|
||||
IPage<SyncTask> page = syncTaskMapper.selectPage(
|
||||
new Page<>(1, 20),
|
||||
new LambdaQueryWrapper<SyncTask>().orderByDesc(SyncTask::getCreateTime));
|
||||
// ID 转 String 防止前端精度丢失
|
||||
java.util.List<Map<String, Object>> list = new java.util.ArrayList<>();
|
||||
for (SyncTask t : page.getRecords()) {
|
||||
Map<String, Object> item = new HashMap<>();
|
||||
item.put("id", String.valueOf(t.getId()));
|
||||
item.put("taskType", t.getTaskType());
|
||||
item.put("status", t.getStatus());
|
||||
item.put("total", t.getTotal());
|
||||
item.put("success", t.getSuccess());
|
||||
item.put("fail", t.getFail());
|
||||
item.put("message", t.getMessage());
|
||||
item.put("createTime", t.getCreateTime() != null ? t.getCreateTime().toString() : null);
|
||||
item.put("updateTime", t.getUpdateTime() != null ? t.getUpdateTime().toString() : null);
|
||||
list.add(item);
|
||||
}
|
||||
return ok("ok", list);
|
||||
}
|
||||
|
||||
// ===================== 日志查询接口 =====================
|
||||
|
||||
/**
|
||||
* 分页查询文件日志
|
||||
*
|
||||
* @param pageNum 页码,默认1
|
||||
* @param pageSize 每页条数,默认20
|
||||
* @param status 状态过滤(可选)
|
||||
* @param tabName 业务表名过滤(可选)
|
||||
*/
|
||||
@Get
|
||||
@Mapping("/logs")
|
||||
public Map<String, Object> logs(
|
||||
@Param(defaultValue = "1") int pageNum,
|
||||
@Param(defaultValue = "20") int pageSize,
|
||||
@Param(required = false) Integer status,
|
||||
@Param(required = false) String tabName) {
|
||||
LambdaQueryWrapper<QhOssFileLog> wrapper = new LambdaQueryWrapper<QhOssFileLog>()
|
||||
.orderByDesc(QhOssFileLog::getCreateTime);
|
||||
if (status != null) {
|
||||
wrapper.eq(QhOssFileLog::getBizHandleStatus, status);
|
||||
}
|
||||
if (tabName != null && !tabName.isBlank()) {
|
||||
wrapper.eq(QhOssFileLog::getBizTabName, tabName);
|
||||
}
|
||||
IPage<QhOssFileLog> page = ossFileLogMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);
|
||||
|
||||
// 统计各状态数量
|
||||
Map<String, Long> stats = new HashMap<>();
|
||||
stats.put("total", ossFileLogMapper.selectCount(null));
|
||||
stats.put("pending", ossFileLogMapper.selectCount(new LambdaQueryWrapper<QhOssFileLog>()
|
||||
.eq(QhOssFileLog::getBizHandleStatus, QhOssFileLog.STATUS_PENDING)));
|
||||
stats.put("uploaded", ossFileLogMapper.selectCount(new LambdaQueryWrapper<QhOssFileLog>()
|
||||
.eq(QhOssFileLog::getBizHandleStatus, QhOssFileLog.STATUS_UPLOADED)));
|
||||
stats.put("fail", ossFileLogMapper.selectCount(new LambdaQueryWrapper<QhOssFileLog>()
|
||||
.eq(QhOssFileLog::getBizHandleStatus, QhOssFileLog.STATUS_UPLOAD_FAIL)));
|
||||
stats.put("notFound", ossFileLogMapper.selectCount(new LambdaQueryWrapper<QhOssFileLog>()
|
||||
.eq(QhOssFileLog::getBizHandleStatus, QhOssFileLog.STATUS_FILE_NOT_FOUND)));
|
||||
stats.put("writtenBack", ossFileLogMapper.selectCount(new LambdaQueryWrapper<QhOssFileLog>()
|
||||
.eq(QhOssFileLog::getBizHandleStatus, QhOssFileLog.STATUS_WRITTEN_BACK)));
|
||||
|
||||
// 日志列表 ID 转 String
|
||||
java.util.List<Map<String, Object>> records = new java.util.ArrayList<>();
|
||||
for (QhOssFileLog r : page.getRecords()) {
|
||||
Map<String, Object> item = new HashMap<>();
|
||||
item.put("id", String.valueOf(r.getId()));
|
||||
item.put("bizTabName", r.getBizTabName());
|
||||
item.put("bizTabId", r.getBizTabId());
|
||||
item.put("bizColName", r.getBizColName());
|
||||
item.put("bizTabPath", r.getBizTabPath());
|
||||
item.put("ossPath", r.getOssPath());
|
||||
item.put("bizHandleStatus", r.getBizHandleStatus());
|
||||
item.put("createTime", r.getCreateTime() != null ? r.getCreateTime().toString() : null);
|
||||
item.put("updateTime", r.getUpdateTime() != null ? r.getUpdateTime().toString() : null);
|
||||
records.add(item);
|
||||
}
|
||||
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("records", records);
|
||||
data.put("total", page.getTotal());
|
||||
data.put("pages", page.getPages());
|
||||
data.put("current", page.getCurrent());
|
||||
data.put("stats", stats);
|
||||
return ok("ok", data);
|
||||
}
|
||||
|
||||
// ===================== 清理接口 =====================
|
||||
|
||||
/**
|
||||
* 清理文件日志
|
||||
*
|
||||
* @param status 状态值(可选):不传则清理全部;传具体值则只清对应状态
|
||||
* 0=待处理 1=已上传 2=上传失败 3=文件不存在 4=已回写
|
||||
*/
|
||||
@Post
|
||||
@Mapping("/clean-logs")
|
||||
public Map<String, Object> cleanLogs(@Param(required = false) Integer status) {
|
||||
int rows;
|
||||
if (status != null) {
|
||||
rows = ossFileLogMapper.delete(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<QhOssFileLog>()
|
||||
.eq(QhOssFileLog::getBizHandleStatus, status));
|
||||
log.info("[OssSyncCtrl] 清理文件日志 status={} 共 {} 条", status, rows);
|
||||
} else {
|
||||
rows = ossFileLogMapper.delete(null);
|
||||
log.info("[OssSyncCtrl] 清理全部文件日志 共 {} 条", rows);
|
||||
}
|
||||
return ok("已清理 " + rows + " 条", rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理任务历史(SYNC_TASK 全部记录)
|
||||
*/
|
||||
@Post
|
||||
@Mapping("/clean-tasks")
|
||||
public Map<String, Object> cleanTasks() {
|
||||
int rows = syncTaskMapper.delete(null);
|
||||
log.info("[OssSyncCtrl] 清理任务历史 共 {} 条", rows);
|
||||
return ok("已清理 " + rows + " 条", rows);
|
||||
}
|
||||
|
||||
// ===================== 页面入口 =====================
|
||||
|
||||
/** 返回管理页面 */
|
||||
@Get
|
||||
@Mapping("/")
|
||||
public org.noear.solon.core.handle.ModelAndView index() {
|
||||
return new org.noear.solon.core.handle.ModelAndView("index.ftl");
|
||||
}
|
||||
|
||||
// ===================== 工具方法 =====================
|
||||
|
||||
private Map<String, Object> ok(String msg, Object data) {
|
||||
Map<String, Object> r = new HashMap<>(3);
|
||||
r.put("code", 200);
|
||||
r.put("msg", msg);
|
||||
r.put("data", data);
|
||||
return r;
|
||||
}
|
||||
|
||||
private Map<String, Object> ok(String msg) {
|
||||
return ok(msg, null);
|
||||
}
|
||||
|
||||
private Map<String, Object> fail(String msg) {
|
||||
Map<String, Object> r = new HashMap<>(2);
|
||||
r.put("code", 500);
|
||||
r.put("msg", msg);
|
||||
return r;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.yxtech.ossyn.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 业务文件映射配置实体
|
||||
* 对应数据库表 BIZ_FILE_MAPPING
|
||||
*/
|
||||
@Data
|
||||
@TableName("BIZ_FILE_MAPPING")
|
||||
public class BizFileMappingEntity {
|
||||
|
||||
/** 主键(雪花ID) */
|
||||
@TableId(value = "ID", type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
|
||||
/** 数据库名 */
|
||||
@TableField("DB_NAME")
|
||||
private String dbName;
|
||||
|
||||
/** 业务表名 */
|
||||
@TableField("TABLE_NAME")
|
||||
private String tableName;
|
||||
|
||||
/** 主键列名 */
|
||||
@TableField("ID_COL")
|
||||
private String idCol;
|
||||
|
||||
/** 文件路径列名 */
|
||||
@TableField("FILE_COL")
|
||||
private String fileCol;
|
||||
|
||||
/**
|
||||
* OSS 上传业务路径(如 import、avatar)
|
||||
* 不填则降级使用表名
|
||||
*/
|
||||
@TableField("BIZ_PATH")
|
||||
private String bizPath;
|
||||
|
||||
/**
|
||||
* 是否有效:0=无效,1=有效
|
||||
* 扫描时只处理有效的映射
|
||||
*/
|
||||
@TableField("ENABLED")
|
||||
private Integer enabled;
|
||||
|
||||
/** 备注 */
|
||||
@TableField("REMARK")
|
||||
private String remark;
|
||||
|
||||
/** 创建时间 */
|
||||
@TableField("CREATE_TIME")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
/** 更新时间 */
|
||||
@TableField("UPDATE_TIME")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
/** 有效 */
|
||||
public static final int ENABLED_YES = 1;
|
||||
/** 无效 */
|
||||
public static final int ENABLED_NO = 0;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.yxtech.ossyn.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 文件转换日志实体
|
||||
* 对应数据库表 QH_OSS_FILE_LOG
|
||||
*/
|
||||
@Data
|
||||
@TableName("QH_OSS_FILE_LOG")
|
||||
public class QhOssFileLog {
|
||||
|
||||
/** 主键(雪花ID) */
|
||||
@TableId(value = "ID", type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
|
||||
/** OSS路径(上传成功后填入) */
|
||||
@TableField("OSS_PATH")
|
||||
private String ossPath;
|
||||
|
||||
/** 业务表名 */
|
||||
@TableField("BIZ_TAB_NAME")
|
||||
private String bizTabName;
|
||||
|
||||
/** 业务表主键值 */
|
||||
@TableField("BIZ_TAB_ID")
|
||||
private String bizTabId;
|
||||
|
||||
/** 业务表列名(文件路径字段) */
|
||||
@TableField("BIZ_COL_NAME")
|
||||
private String bizColName;
|
||||
|
||||
/** 业务表中原始文件路径 */
|
||||
@TableField("BIZ_TAB_PATH")
|
||||
private String bizTabPath;
|
||||
|
||||
/**
|
||||
* 处理状态
|
||||
* 0=待处理,1=上传成功,2=上传失败,3=文件不存在,4=已回写
|
||||
*/
|
||||
@TableField("BIZ_HANDLE_STATUS")
|
||||
private Integer bizHandleStatus;
|
||||
|
||||
/** 创建时间 */
|
||||
@TableField("CREATE_TIME")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
/** 更新时间 */
|
||||
@TableField("UPDATE_TIME")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
// ===================== 状态常量 =====================
|
||||
|
||||
/** 待处理 */
|
||||
public static final int STATUS_PENDING = 0;
|
||||
/** 上传成功 */
|
||||
public static final int STATUS_UPLOADED = 1;
|
||||
/** 上传失败 */
|
||||
public static final int STATUS_UPLOAD_FAIL = 2;
|
||||
/** 文件不存在 */
|
||||
public static final int STATUS_FILE_NOT_FOUND = 3;
|
||||
/** 已回写业务表 */
|
||||
public static final int STATUS_WRITTEN_BACK = 4;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.yxtech.ossyn.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 同步任务记录实体
|
||||
* 对应数据库表 SYNC_TASK
|
||||
*/
|
||||
@Data
|
||||
@TableName("SYNC_TASK")
|
||||
public class SyncTask {
|
||||
|
||||
/** 主键(雪花ID) */
|
||||
@TableId(value = "ID", type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
|
||||
/** 任务类型:all / scan / upload / write-back */
|
||||
@TableField("TASK_TYPE")
|
||||
private String taskType;
|
||||
|
||||
/**
|
||||
* 任务状态
|
||||
* 0=进行中,1=完成,2=失败
|
||||
*/
|
||||
@TableField("STATUS")
|
||||
private Integer status;
|
||||
|
||||
/** 总处理条数 */
|
||||
@TableField("TOTAL")
|
||||
private Integer total;
|
||||
|
||||
/** 成功条数 */
|
||||
@TableField("SUCCESS")
|
||||
private Integer success;
|
||||
|
||||
/** 失败条数 */
|
||||
@TableField("FAIL")
|
||||
private Integer fail;
|
||||
|
||||
/** 备注/错误信息 */
|
||||
@TableField("MESSAGE")
|
||||
private String message;
|
||||
|
||||
/** 创建时间 */
|
||||
@TableField("CREATE_TIME")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
/** 更新时间 */
|
||||
@TableField("UPDATE_TIME")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
// ===================== 状态常量 =====================
|
||||
|
||||
/** 进行中 */
|
||||
public static final int STATUS_RUNNING = 0;
|
||||
/** 完成 */
|
||||
public static final int STATUS_DONE = 1;
|
||||
/** 失败 */
|
||||
public static final int STATUS_FAIL = 2;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.yxtech.ossyn.filter;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.noear.solon.annotation.Component;
|
||||
import org.noear.solon.core.handle.Context;
|
||||
import org.noear.solon.core.handle.Filter;
|
||||
import org.noear.solon.core.handle.FilterChain;
|
||||
import org.noear.solon.core.exception.StatusException;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 全局异常过滤器
|
||||
* 捕获所有未处理异常,打印请求上下文和完整堆栈,返回统一 JSON 格式
|
||||
*/
|
||||
@Slf4j
|
||||
@Component(index = -1)
|
||||
public class GlobalExceptionFilter implements Filter {
|
||||
|
||||
@Override
|
||||
public void doFilter(Context ctx, FilterChain chain) throws Throwable {
|
||||
try {
|
||||
chain.doFilter(ctx);
|
||||
} catch (Throwable e) {
|
||||
int status = 500;
|
||||
// StatusException 直接用其状态码,避免 404 被当成 500 处理
|
||||
if (e instanceof StatusException se) {
|
||||
status = se.getCode();
|
||||
}
|
||||
// 静态资源 404 不打 ERROR,避免刷屏
|
||||
if (status == 404) {
|
||||
log.debug("[GlobalFilter] 404 {} {}", ctx.method(), ctx.path());
|
||||
} else {
|
||||
log.error("[GlobalFilter] 请求异常 {} {} params={}",
|
||||
ctx.method(), ctx.path(), ctx.paramMap(), e);
|
||||
}
|
||||
ctx.status(status);
|
||||
Map<String, Object> result = new HashMap<>(2);
|
||||
result.put("code", status);
|
||||
result.put("msg", e.getMessage());
|
||||
ctx.render(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yxtech.ossyn.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.yxtech.ossyn.entity.BizFileMappingEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 业务文件映射配置 Mapper
|
||||
*/
|
||||
@Mapper
|
||||
public interface BizFileMappingMapper extends BaseMapper<BizFileMappingEntity> {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yxtech.ossyn.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.yxtech.ossyn.entity.QhOssFileLog;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 文件转换日志 Mapper
|
||||
*/
|
||||
@Mapper
|
||||
public interface QhOssFileLogMapper extends BaseMapper<QhOssFileLog> {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yxtech.ossyn.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.yxtech.ossyn.entity.SyncTask;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 同步任务记录 Mapper
|
||||
*/
|
||||
@Mapper
|
||||
public interface SyncTaskMapper extends BaseMapper<SyncTask> {
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package com.yxtech.ossyn.service;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.yxtech.ossyn.config.ConsoleConfig;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.noear.solon.annotation.Component;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PublicKey;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 控制台(Choerodon DevOps)服务
|
||||
* token 内存缓存,401 时自动重新登录
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class ConsoleService {
|
||||
|
||||
@Inject
|
||||
private ConsoleConfig cfg;
|
||||
|
||||
/** 缓存的 access_token */
|
||||
private volatile String cachedToken;
|
||||
/** token 过期时间戳(毫秒) */
|
||||
private final AtomicLong tokenExpireAt = new AtomicLong(0);
|
||||
|
||||
/** 登录/授权专用:不跟随重定向,才能拿到 302 的 Set-Cookie 和 Location */
|
||||
private final HttpClient authClient = HarborService.buildTrustAllClientNoRedirect();
|
||||
|
||||
/** 业务请求专用:跟随重定向 */
|
||||
private final HttpClient apiClient = HarborService.buildTrustAllClient();
|
||||
|
||||
// ===================== token 获取 =====================
|
||||
|
||||
/** 强制清除缓存 token,下次调用 getToken() 时重新登录 */
|
||||
public void invalidateToken() {
|
||||
cachedToken = null;
|
||||
tokenExpireAt.set(0);
|
||||
log.info("[Console] token 缓存已清除,下次请求将重新登录");
|
||||
}
|
||||
|
||||
/** 获取有效 token(自动刷新) */
|
||||
public String getToken() throws Exception {
|
||||
if (cachedToken != null && System.currentTimeMillis() < tokenExpireAt.get() - 60_000) {
|
||||
return cachedToken;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (cachedToken != null && System.currentTimeMillis() < tokenExpireAt.get() - 60_000) {
|
||||
return cachedToken;
|
||||
}
|
||||
String cookie = login();
|
||||
String location = authorize(cookie);
|
||||
String token = extractToken(location);
|
||||
if (token == null) throw new RuntimeException("授权成功但未能从 Location 中提取 access_token: " + location);
|
||||
cachedToken = token;
|
||||
tokenExpireAt.set(System.currentTimeMillis() + 86399_000L);
|
||||
log.info("[Console] token 已刷新");
|
||||
}
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
private String login() throws Exception {
|
||||
String encPwd = encryptPassword(cfg.getLoginPassword(), cfg.getPublicKey());
|
||||
String formBody = "username=" + java.net.URLEncoder.encode(cfg.getLoginUsername(), "UTF-8")
|
||||
+ "&password=" + java.net.URLEncoder.encode(encPwd, "UTF-8");
|
||||
String loginUrl = cfg.getApiBase() + "/oauth/choerodon/login";
|
||||
|
||||
log.info("[Console][login] curl \"{}\" ^\n -H \"Origin: {}\" ^\n -H \"Content-Type: application/x-www-form-urlencoded\" ^\n --data-raw \"{}\"",
|
||||
loginUrl, cfg.getApiBase(), formBody);
|
||||
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(loginUrl))
|
||||
.header("Origin", cfg.getApiBase())
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.header("Referer", loginUrl)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(formBody))
|
||||
.build();
|
||||
|
||||
// 使用 authClient(NEVER 重定向),确保能拿到 302 响应中的 Set-Cookie
|
||||
HttpResponse<String> resp = authClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
log.info("[Console][login] status={}", resp.statusCode());
|
||||
log.info("[Console][login] headers={}", resp.headers().map());
|
||||
log.info("[Console][login] body={}", resp.body());
|
||||
// 收集所有 Set-Cookie,拼成 Cookie 字符串传给 authorize
|
||||
java.util.List<String> cookies = resp.headers().allValues("Set-Cookie");
|
||||
if (cookies.isEmpty()) {
|
||||
throw new RuntimeException("登录失败,未获取到 Set-Cookie,状态码: " + resp.statusCode());
|
||||
}
|
||||
String cookie = cookies.stream()
|
||||
.map(c -> c.split(";")[0]) // 每条只取 name=value 部分
|
||||
.collect(java.util.stream.Collectors.joining("; "));
|
||||
log.info("[Console][login] 拼装 Cookie: {}", cookie);
|
||||
return cookie;
|
||||
}
|
||||
|
||||
private String authorize(String cookie) throws Exception {
|
||||
String redirectUri = java.net.URLEncoder.encode(
|
||||
"http://dev.xjyt.petrochina/#/devops/resource?activeKey=instance&category=AGILE"
|
||||
+ "&id=" + cfg.getProjectId()
|
||||
+ "&organizationId=1&type=project&redirectFlag", "UTF-8");
|
||||
String url = cfg.getApiBase() + "/oauth/oauth/authorize"
|
||||
+ "?response_type=token&client_id=choerodon&state=&redirect_uri=" + redirectUri;
|
||||
|
||||
log.info("[Console][authorize] curl -v \"{}\" ^\n -H \"Cookie: {}\"", url, cookie);
|
||||
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.header("Referer", "http://dev.xjyt.petrochina/")
|
||||
.header("Cookie", cookie)
|
||||
.GET().build();
|
||||
|
||||
// 使用 authClient(NEVER 重定向),确保能拿到 302 的 Location 头
|
||||
HttpResponse<String> resp = authClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
log.info("[Console][authorize] status={}", resp.statusCode());
|
||||
log.info("[Console][authorize] headers={}", resp.headers().map());
|
||||
log.info("[Console][authorize] body={}", resp.body());
|
||||
|
||||
String location = resp.headers().firstValue("Location").orElse(null);
|
||||
log.info("[Console][authorize] Location={}", location);
|
||||
|
||||
if (location == null) {
|
||||
throw new RuntimeException("授权失败,未获取到 Location,状态码: " + resp.statusCode());
|
||||
}
|
||||
return location;
|
||||
}
|
||||
|
||||
/** 从 Location URL 的 fragment(# 后)中提取 access_token */
|
||||
private String extractToken(String location) {
|
||||
int hashIdx = location.indexOf('#');
|
||||
if (hashIdx < 0) return null;
|
||||
String fragment = location.substring(hashIdx + 1);
|
||||
int queryIdx = fragment.indexOf('?');
|
||||
String query = queryIdx >= 0 ? fragment.substring(queryIdx + 1) : fragment;
|
||||
for (String param : query.split("&")) {
|
||||
if (param.startsWith("access_token=")) return param.substring("access_token=".length());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String encryptPassword(String password, String publicKeyBase64) throws Exception {
|
||||
byte[] keyBytes = Base64.getDecoder().decode(publicKeyBase64);
|
||||
PublicKey publicKey = KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(keyBytes));
|
||||
Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
|
||||
return Base64.getEncoder().encodeToString(cipher.doFinal(password.getBytes("UTF-8")));
|
||||
}
|
||||
|
||||
// ===================== 通用请求工具 =====================
|
||||
|
||||
private HttpRequest.Builder baseRequest(String url) throws Exception {
|
||||
return HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Authorization", "bearer " + getToken())
|
||||
.header("Accept", "application/json")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("H-Tenant-Id", "1")
|
||||
.header("H-Menu-Id", "1968")
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.header("Origin", "http://dev.xjyt.petrochina")
|
||||
.header("Referer", "http://dev.xjyt.petrochina/");
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送请求,401 时清除 token 缓存并重试一次
|
||||
*/
|
||||
private HttpResponse<String> sendWithRetry(HttpRequest.Builder reqBuilder, String body, String method) throws Exception {
|
||||
HttpRequest req = buildReq(reqBuilder, body, method);
|
||||
HttpResponse<String> resp = apiClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (resp.statusCode() == 401) {
|
||||
log.warn("[Console] 收到 401,清除 token 缓存后重试");
|
||||
invalidateToken();
|
||||
// 重新构建请求(带新 token)
|
||||
String url = req.uri().toString();
|
||||
HttpRequest.Builder newBuilder = baseRequest(url);
|
||||
req = buildReq(newBuilder, body, method);
|
||||
resp = apiClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
private HttpRequest buildReq(HttpRequest.Builder builder, String body, String method) {
|
||||
return switch (method) {
|
||||
case "POST" -> builder.POST(body != null
|
||||
? HttpRequest.BodyPublishers.ofString(body)
|
||||
: HttpRequest.BodyPublishers.noBody()).build();
|
||||
case "PUT" -> builder.PUT(body != null
|
||||
? HttpRequest.BodyPublishers.ofString(body)
|
||||
: HttpRequest.BodyPublishers.noBody()).build();
|
||||
default -> builder.GET().build();
|
||||
};
|
||||
}
|
||||
|
||||
// ===================== 实例列表 =====================
|
||||
|
||||
/** 查询所有实例(全量,size=500) */
|
||||
public JSONObject listInstances() throws Exception {
|
||||
String url = cfg.getApiBase() + "/devops/v1/projects/" + cfg.getProjectId()
|
||||
+ "/app_service_instances/info/page_by_options?page=0&size=500&env_id=" + cfg.getEnvId();
|
||||
String body = "{\"params\":[],\"searchParam\":{}}";
|
||||
HttpResponse<String> resp = sendWithRetry(baseRequest(url), body, "POST");
|
||||
log.info("[Console][listInstances] status={}", resp.statusCode());
|
||||
return JSON.parseObject(resp.body());
|
||||
}
|
||||
|
||||
// ===================== Pod 列表 =====================
|
||||
|
||||
/** 查询指定实例的 Pod 列表 */
|
||||
public JSONObject listPods(String instanceId) throws Exception {
|
||||
String url = cfg.getApiBase() + "/devops/v1/projects/" + cfg.getProjectId()
|
||||
+ "/pods/page_by_options?page=0&size=50&env_id=" + cfg.getEnvId()
|
||||
+ "&instance_id=" + instanceId;
|
||||
String body = "{\"params\":[],\"searchParam\":{}}";
|
||||
HttpResponse<String> resp = sendWithRetry(baseRequest(url), body, "POST");
|
||||
return JSON.parseObject(resp.body());
|
||||
}
|
||||
|
||||
// ===================== YAML 配置 =====================
|
||||
|
||||
/** 查询实例的 YAML 配置 */
|
||||
public JSONObject getInstanceYaml(String instanceId, String versionId) throws Exception {
|
||||
String url = cfg.getApiBase() + "/devops/v1/projects/" + cfg.getProjectId()
|
||||
+ "/app_service_instances/" + instanceId
|
||||
+ "/appServiceVersion/" + versionId + "/upgrade_value";
|
||||
HttpResponse<String> resp = sendWithRetry(baseRequest(url), null, "GET");
|
||||
return JSON.parseObject(resp.body());
|
||||
}
|
||||
|
||||
/** 更新实例的 YAML 配置 */
|
||||
public JSONObject updateInstanceYaml(JSONObject payload) throws Exception {
|
||||
String url = cfg.getApiBase() + "/devops/v1/projects/" + cfg.getProjectId()
|
||||
+ "/app_service_instances";
|
||||
HttpResponse<String> resp = sendWithRetry(baseRequest(url), payload.toJSONString(), "PUT");
|
||||
return JSON.parseObject(resp.body());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.yxtech.ossyn.service;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.yxtech.ossyn.config.HarborConfig;
|
||||
import com.yxtech.ossyn.config.SkopeoConfig;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.noear.solon.annotation.Component;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.io.File;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Base64;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
|
||||
/**
|
||||
* Harbor 镜像仓库服务
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class HarborService {
|
||||
|
||||
@Inject
|
||||
private HarborConfig harborConfig;
|
||||
|
||||
@Inject
|
||||
private SkopeoConfig skopeoConfig;
|
||||
|
||||
private final HttpClient httpClient = buildTrustAllClient();
|
||||
|
||||
/** 构建忽略 SSL 证书验证的 HttpClient(内部域名自签证书) */
|
||||
public static HttpClient buildTrustAllClient() {
|
||||
return buildTrustAllClient(HttpClient.Redirect.NORMAL);
|
||||
}
|
||||
|
||||
/** 构建忽略 SSL 证书验证、不跟随重定向的 HttpClient(用于登录/授权,需拿 302 头信息) */
|
||||
public static HttpClient buildTrustAllClientNoRedirect() {
|
||||
return buildTrustAllClient(HttpClient.Redirect.NEVER);
|
||||
}
|
||||
|
||||
private static HttpClient buildTrustAllClient(HttpClient.Redirect redirect) {
|
||||
try {
|
||||
SSLContext sslContext = SSLContext.getInstance("TLS");
|
||||
sslContext.init(null, new TrustManager[]{new X509TrustManager() {
|
||||
public void checkClientTrusted(X509Certificate[] c, String a) {}
|
||||
public void checkServerTrusted(X509Certificate[] c, String a) {}
|
||||
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
|
||||
}}, null);
|
||||
return HttpClient.newBuilder()
|
||||
.followRedirects(redirect)
|
||||
.sslContext(sslContext)
|
||||
.build();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("初始化 TrustAll SSLContext 失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 构建 Basic Auth 头 */
|
||||
private String basicAuth() {
|
||||
String cred = harborConfig.getUsername() + ":" + harborConfig.getPassword();
|
||||
return "Basic " + Base64.getEncoder().encodeToString(cred.getBytes());
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询项目下所有镜像仓库列表
|
||||
*/
|
||||
public JSONArray listRepositories() throws Exception {
|
||||
String url = harborConfig.getUrl() + "/api/v2.0/projects/"
|
||||
+ harborConfig.getProject() + "/repositories?page=1&page_size=100";
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Authorization", basicAuth())
|
||||
.header("Accept", "application/json")
|
||||
.GET().build();
|
||||
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
log.info("[Harbor] listRepositories status={}", resp.statusCode());
|
||||
return JSON.parseArray(resp.body());
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定镜像仓库的最新 tag
|
||||
*
|
||||
* @param repoName 仓库名(不含项目前缀,如 health-bank-admin)
|
||||
*/
|
||||
public JSONObject latestTag(String repoName) throws Exception {
|
||||
String url = harborConfig.getUrl() + "/api/v2.0/projects/"
|
||||
+ harborConfig.getProject() + "/repositories/"
|
||||
+ repoName + "/artifacts?page_size=1&sort=-push_time&with_tag=true";
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Authorization", basicAuth())
|
||||
.header("Accept", "application/json")
|
||||
.GET().build();
|
||||
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
JSONArray arr = JSON.parseArray(resp.body());
|
||||
if (arr == null || arr.isEmpty()) return null;
|
||||
return arr.getJSONObject(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 skopeo 将 tar 包推送到 Harbor
|
||||
*
|
||||
* @param tarRelPath tar 包相对于 images-path 的路径(如 20260417/xxx.tar)
|
||||
* @param repoName 目标仓库名(不含项目前缀)
|
||||
* @param tag 目标 tag
|
||||
*/
|
||||
public String pushImage(String tarRelPath, String repoName, String tag) throws Exception {
|
||||
// tarAbsPath 使用系统原生路径分隔符,供 File 对象使用
|
||||
String tarAbsPath = skopeoConfig.getImagesPath() + File.separator + tarRelPath.replace("/", File.separator);
|
||||
File tarFile = new File(tarAbsPath);
|
||||
// skopeo 在 Windows 下无法处理带盘符的绝对路径,改用工作目录+文件名相对路径规避
|
||||
String tarFileName = tarFile.getName();
|
||||
File workDir = tarFile.getParentFile();
|
||||
|
||||
String harborHost = harborConfig.getUrl().replaceFirst("https?://", "");
|
||||
String destImage = "docker://" + harborHost + "/" + harborConfig.getProject() + "/" + repoName + ":" + tag;
|
||||
|
||||
String[] cmd = {
|
||||
skopeoConfig.getExePath(),
|
||||
"copy",
|
||||
"--dest-creds", harborConfig.getUsername() + ":" + harborConfig.getPassword(),
|
||||
"--dest-tls-verify=false",
|
||||
"docker-archive:" + tarFileName,
|
||||
destImage
|
||||
};
|
||||
log.info("[Harbor] skopeo push: workDir={} cmd={}", workDir.getAbsolutePath(), String.join(" ", cmd));
|
||||
Process process = new ProcessBuilder(cmd)
|
||||
.directory(workDir) // 工作目录设为 tar 所在目录
|
||||
.redirectErrorStream(true)
|
||||
.start();
|
||||
String output = new String(process.getInputStream().readAllBytes());
|
||||
int exitCode = process.waitFor();
|
||||
log.info("[Harbor] skopeo exit={} output={}", exitCode, output);
|
||||
if (exitCode != 0) {
|
||||
throw new RuntimeException("skopeo 推送失败: " + output);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
package com.yxtech.ossyn.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.yxtech.ossyn.config.LocalFileConfig;
|
||||
import com.yxtech.ossyn.entity.BizFileMappingEntity;
|
||||
import com.yxtech.ossyn.mapper.BizFileMappingMapper;
|
||||
import com.yxtech.ossyn.entity.QhOssFileLog;
|
||||
import com.yxtech.ossyn.entity.SyncTask;
|
||||
import com.yxtech.ossyn.mapper.QhOssFileLogMapper;
|
||||
import com.yxtech.ossyn.mapper.SyncTaskMapper;
|
||||
import com.yxtech.ossyn.util.DynamicDataSourceUtil;
|
||||
import com.yxtech.ossyn.util.OssUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.noear.solon.annotation.Component;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* OSS 同步 Service
|
||||
* 负责:扫描业务表 → 写入日志 → 上传文件 → 回写业务表
|
||||
* 所有触发操作均异步执行,通过 SYNC_TASK 表实时记录进度
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class OssSyncService {
|
||||
|
||||
@Inject
|
||||
private BizFileMappingMapper bizFileMappingMapper;
|
||||
|
||||
@Inject
|
||||
private LocalFileConfig localFileConfig;
|
||||
|
||||
@Inject
|
||||
private DynamicDataSourceUtil dynamicDataSourceUtil;
|
||||
|
||||
@Inject
|
||||
private OssUtil ossUtil;
|
||||
|
||||
@Inject
|
||||
private QhOssFileLogMapper ossFileLogMapper;
|
||||
|
||||
@Inject
|
||||
private SyncTaskMapper syncTaskMapper;
|
||||
|
||||
/** 单线程异步执行器,保证同一时刻只有一个同步任务在跑 */
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread t = new Thread(r, "oss-sync-worker");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
// ===================== 异步触发入口 =====================
|
||||
|
||||
/**
|
||||
* 异步触发全量同步(扫描 → 上传 → 回写)
|
||||
*
|
||||
* @return 任务ID(String,避免前端精度丢失)
|
||||
*/
|
||||
public String asyncSyncAll() {
|
||||
SyncTask task = createTask("all");
|
||||
executor.submit(() -> doSyncAll(task));
|
||||
return String.valueOf(task.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步触发扫描阶段
|
||||
*/
|
||||
public String asyncScan() {
|
||||
SyncTask task = createTask("scan");
|
||||
executor.submit(() -> doScan(task));
|
||||
return String.valueOf(task.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步触发上传阶段
|
||||
*/
|
||||
public String asyncUpload() {
|
||||
SyncTask task = createTask("upload");
|
||||
executor.submit(() -> doUpload(task));
|
||||
return String.valueOf(task.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步触发回写阶段
|
||||
*/
|
||||
public String asyncWriteBack() {
|
||||
SyncTask task = createTask("write-back");
|
||||
executor.submit(() -> doWriteBack(task));
|
||||
return String.valueOf(task.getId());
|
||||
}
|
||||
|
||||
// ===================== 实际执行逻辑 =====================
|
||||
|
||||
private void doSyncAll(SyncTask task) {
|
||||
try {
|
||||
updateMessage(task.getId(), "扫描业务表中...");
|
||||
int scanned = scanAndRecord(task);
|
||||
updateMessage(task.getId(), "扫描完成,新增 " + scanned + " 条;上传文件中...");
|
||||
int uploaded = uploadPending(task);
|
||||
updateMessage(task.getId(), "上传完成,成功 " + uploaded + " 条;回写业务表中...");
|
||||
int written = writeBack(task);
|
||||
finishTask(task.getId(), SyncTask.STATUS_DONE,
|
||||
"全量同步完成:扫描新增 " + scanned + " 条,上传成功 " + uploaded + " 条,回写成功 " + written + " 条");
|
||||
} catch (Exception e) {
|
||||
log.error("[OssSyncService] 全量同步异常", e);
|
||||
finishTask(task.getId(), SyncTask.STATUS_FAIL, "执行异常:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void doScan(SyncTask task) {
|
||||
try {
|
||||
updateMessage(task.getId(), "扫描业务表中...");
|
||||
int count = scanAndRecord(task);
|
||||
finishTask(task.getId(), SyncTask.STATUS_DONE, "扫描完成,新增日志 " + count + " 条");
|
||||
} catch (Exception e) {
|
||||
log.error("[OssSyncService] 扫描异常", e);
|
||||
finishTask(task.getId(), SyncTask.STATUS_FAIL, "执行异常:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void doUpload(SyncTask task) {
|
||||
try {
|
||||
updateMessage(task.getId(), "上传文件中...");
|
||||
int count = uploadPending(task);
|
||||
finishTask(task.getId(), SyncTask.STATUS_DONE, "上传完成,成功 " + count + " 条");
|
||||
} catch (Exception e) {
|
||||
log.error("[OssSyncService] 上传异常", e);
|
||||
finishTask(task.getId(), SyncTask.STATUS_FAIL, "执行异常:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void doWriteBack(SyncTask task) {
|
||||
try {
|
||||
updateMessage(task.getId(), "回写业务表中...");
|
||||
int count = writeBack(task);
|
||||
finishTask(task.getId(), SyncTask.STATUS_DONE, "回写完成,成功 " + count + " 条");
|
||||
} catch (Exception e) {
|
||||
log.error("[OssSyncService] 回写异常", e);
|
||||
finishTask(task.getId(), SyncTask.STATUS_FAIL, "执行异常:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 核心业务逻辑 =====================
|
||||
|
||||
/**
|
||||
* 扫描所有配置的业务表,将文件路径记录写入日志(幂等)
|
||||
*/
|
||||
private int scanAndRecord(SyncTask task) {
|
||||
int total = 0;
|
||||
// 从数据库读取有效的映射配置
|
||||
List<BizFileMappingEntity> mappings = bizFileMappingMapper.selectList(
|
||||
new LambdaQueryWrapper<BizFileMappingEntity>()
|
||||
.eq(BizFileMappingEntity::getEnabled, BizFileMappingEntity.ENABLED_YES));
|
||||
if (mappings == null || mappings.isEmpty()) {
|
||||
log.warn("[OssSyncService] 无有效的业务表映射配置,跳过扫描");
|
||||
return 0;
|
||||
}
|
||||
for (BizFileMappingEntity mapping : mappings) {
|
||||
List<Map<String, String>> rows = dynamicDataSourceUtil.queryFilePaths(
|
||||
mapping.getDbName(), mapping.getTableName(),
|
||||
mapping.getIdCol(), mapping.getFileCol());
|
||||
for (Map<String, String> row : rows) {
|
||||
String bizId = row.get("id");
|
||||
String filePath = row.get("filePath");
|
||||
// 已回写的 OSS 路径包含 @/jkgl 关键字,跳过避免重复处理
|
||||
if (filePath != null && filePath.contains("@/jkgl")) {
|
||||
continue;
|
||||
}
|
||||
QhOssFileLog existing = ossFileLogMapper.selectOne(new LambdaQueryWrapper<QhOssFileLog>()
|
||||
.eq(QhOssFileLog::getBizTabName, mapping.getTableName())
|
||||
.eq(QhOssFileLog::getBizTabId, bizId)
|
||||
.eq(QhOssFileLog::getBizColName, mapping.getFileCol())
|
||||
.last("LIMIT 1"));
|
||||
if (existing != null) {
|
||||
// 路径未变,跳过;路径已变(业务重新上传),重置为待处理
|
||||
if (filePath != null && filePath.equals(existing.getBizTabPath())) {
|
||||
continue;
|
||||
}
|
||||
log.info("[OssSyncService] 检测到文件路径变更,重置日志 id={} 旧路径={} 新路径={}",
|
||||
existing.getId(), existing.getBizTabPath(), filePath);
|
||||
ossFileLogMapper.update(null, new LambdaUpdateWrapper<QhOssFileLog>()
|
||||
.eq(QhOssFileLog::getId, existing.getId())
|
||||
.set(QhOssFileLog::getBizTabPath, filePath)
|
||||
.set(QhOssFileLog::getBizHandleStatus, QhOssFileLog.STATUS_PENDING)
|
||||
.set(QhOssFileLog::getOssPath, null)
|
||||
.set(QhOssFileLog::getUpdateTime, LocalDateTime.now()));
|
||||
continue;
|
||||
}
|
||||
QhOssFileLog record = new QhOssFileLog();
|
||||
record.setBizTabName(mapping.getTableName());
|
||||
record.setBizTabId(bizId);
|
||||
record.setBizColName(mapping.getFileCol());
|
||||
record.setBizTabPath(filePath);
|
||||
record.setBizHandleStatus(QhOssFileLog.STATUS_PENDING);
|
||||
record.setCreateTime(LocalDateTime.now());
|
||||
record.setUpdateTime(LocalDateTime.now());
|
||||
ossFileLogMapper.insert(record);
|
||||
total++;
|
||||
incrSuccess(task.getId());
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理所有待上传记录(状态=0)
|
||||
*/
|
||||
private int uploadPending(SyncTask task) {
|
||||
List<QhOssFileLog> pending = ossFileLogMapper.selectList(
|
||||
new LambdaQueryWrapper<QhOssFileLog>()
|
||||
.eq(QhOssFileLog::getBizHandleStatus, QhOssFileLog.STATUS_PENDING));
|
||||
// 更新任务总数
|
||||
syncTaskMapper.update(null, new LambdaUpdateWrapper<SyncTask>()
|
||||
.eq(SyncTask::getId, task.getId())
|
||||
.set(SyncTask::getTotal, pending.size())
|
||||
.set(SyncTask::getSuccess, 0)
|
||||
.set(SyncTask::getFail, 0)
|
||||
.set(SyncTask::getUpdateTime, LocalDateTime.now()));
|
||||
int success = 0;
|
||||
for (QhOssFileLog record : pending) {
|
||||
boolean ok = uploadOne(record);
|
||||
if (ok) {
|
||||
success++;
|
||||
incrSuccess(task.getId());
|
||||
} else {
|
||||
incrFail(task.getId());
|
||||
}
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传单条记录对应的本地文件
|
||||
*/
|
||||
private boolean uploadOne(QhOssFileLog record) {
|
||||
String localPath = buildLocalPath(record.getBizTabPath());
|
||||
File file = new File(localPath);
|
||||
if (!file.exists() || !file.isFile()) {
|
||||
log.warn("[OssSyncService] 文件不存在: {}", localPath);
|
||||
updateLogStatus(record.getId(), QhOssFileLog.STATUS_FILE_NOT_FOUND, null);
|
||||
return false;
|
||||
}
|
||||
// 从映射配置取 bizPath,未配置则降级用表名
|
||||
String bizPath = bizFileMappingMapper.selectList(
|
||||
new LambdaQueryWrapper<BizFileMappingEntity>()
|
||||
.eq(BizFileMappingEntity::getTableName, record.getBizTabName())
|
||||
.last("LIMIT 1"))
|
||||
.stream().findFirst()
|
||||
.map(m -> (m.getBizPath() != null && !m.getBizPath().isBlank()) ? m.getBizPath() : m.getTableName())
|
||||
.orElse(record.getBizTabName());
|
||||
try (InputStream is = new FileInputStream(file)) {
|
||||
String fileKey = ossUtil.upload(is, bizPath, file.getName());
|
||||
if (fileKey == null) {
|
||||
updateLogStatus(record.getId(), QhOssFileLog.STATUS_UPLOAD_FAIL, null);
|
||||
return false;
|
||||
}
|
||||
updateLogStatus(record.getId(), QhOssFileLog.STATUS_UPLOADED, fileKey);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.error("[OssSyncService] 上传异常 id={}", record.getId(), e);
|
||||
updateLogStatus(record.getId(), QhOssFileLog.STATUS_UPLOAD_FAIL, null);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回写已上传成功(状态=1)的记录到业务表
|
||||
*/
|
||||
private int writeBack(SyncTask task) {
|
||||
// 先取有效映射,再按表名范围查日志,避免加载无效表的数据
|
||||
List<BizFileMappingEntity> mappings = bizFileMappingMapper.selectList(
|
||||
new LambdaQueryWrapper<BizFileMappingEntity>()
|
||||
.eq(BizFileMappingEntity::getEnabled, BizFileMappingEntity.ENABLED_YES));
|
||||
if (mappings == null || mappings.isEmpty()) {
|
||||
log.warn("[OssSyncService] 无有效的业务表映射配置,跳过回写");
|
||||
return 0;
|
||||
}
|
||||
List<String> validTableNames = mappings.stream()
|
||||
.map(BizFileMappingEntity::getTableName).toList();
|
||||
List<QhOssFileLog> uploaded = ossFileLogMapper.selectList(
|
||||
new LambdaQueryWrapper<QhOssFileLog>()
|
||||
.eq(QhOssFileLog::getBizHandleStatus, QhOssFileLog.STATUS_UPLOADED)
|
||||
.in(QhOssFileLog::getBizTabName, validTableNames));
|
||||
syncTaskMapper.update(null, new LambdaUpdateWrapper<SyncTask>()
|
||||
.eq(SyncTask::getId, task.getId())
|
||||
.set(SyncTask::getTotal, uploaded.size())
|
||||
.set(SyncTask::getSuccess, 0)
|
||||
.set(SyncTask::getFail, 0)
|
||||
.set(SyncTask::getUpdateTime, LocalDateTime.now()));
|
||||
int success = 0;
|
||||
for (QhOssFileLog record : uploaded) {
|
||||
String dbName = mappings.stream()
|
||||
.filter(m -> m.getTableName().equals(record.getBizTabName()))
|
||||
.map(BizFileMappingEntity::getDbName)
|
||||
.findFirst().orElse(null);
|
||||
if (dbName == null) {
|
||||
log.warn("[OssSyncService] 找不到表 {} 对应的数据库配置,跳过", record.getBizTabName());
|
||||
incrFail(task.getId());
|
||||
continue;
|
||||
}
|
||||
String idCol = mappings.stream()
|
||||
.filter(m -> m.getTableName().equals(record.getBizTabName()))
|
||||
.map(BizFileMappingEntity::getIdCol)
|
||||
.findFirst().orElse("id");
|
||||
boolean ok = dynamicDataSourceUtil.updateFilePath(
|
||||
dbName, record.getBizTabName(),
|
||||
idCol, record.getBizTabId(),
|
||||
record.getBizColName(), record.getOssPath());
|
||||
if (ok) {
|
||||
updateLogStatus(record.getId(), QhOssFileLog.STATUS_WRITTEN_BACK, record.getOssPath());
|
||||
success++;
|
||||
incrSuccess(task.getId());
|
||||
} else {
|
||||
incrFail(task.getId());
|
||||
}
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
// ===================== 任务状态辅助方法 =====================
|
||||
|
||||
/**
|
||||
* 创建任务记录,初始状态为进行中
|
||||
*/
|
||||
private SyncTask createTask(String taskType) {
|
||||
SyncTask task = new SyncTask();
|
||||
task.setTaskType(taskType);
|
||||
task.setStatus(SyncTask.STATUS_RUNNING);
|
||||
task.setTotal(0);
|
||||
task.setSuccess(0);
|
||||
task.setFail(0);
|
||||
task.setMessage("任务已启动");
|
||||
task.setCreateTime(LocalDateTime.now());
|
||||
task.setUpdateTime(LocalDateTime.now());
|
||||
syncTaskMapper.insert(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
private void updateMessage(Long taskId, String message) {
|
||||
syncTaskMapper.update(null, new LambdaUpdateWrapper<SyncTask>()
|
||||
.eq(SyncTask::getId, taskId)
|
||||
.set(SyncTask::getMessage, message)
|
||||
.set(SyncTask::getUpdateTime, LocalDateTime.now()));
|
||||
}
|
||||
|
||||
private void finishTask(Long taskId, int status, String message) {
|
||||
syncTaskMapper.update(null, new LambdaUpdateWrapper<SyncTask>()
|
||||
.eq(SyncTask::getId, taskId)
|
||||
.set(SyncTask::getStatus, status)
|
||||
.set(SyncTask::getMessage, message)
|
||||
.set(SyncTask::getUpdateTime, LocalDateTime.now()));
|
||||
}
|
||||
|
||||
private void incrSuccess(Long taskId) {
|
||||
syncTaskMapper.update(null, new LambdaUpdateWrapper<SyncTask>()
|
||||
.eq(SyncTask::getId, taskId)
|
||||
.setSql("SUCCESS = SUCCESS + 1, UPDATE_TIME = NOW()"));
|
||||
}
|
||||
|
||||
private void incrFail(Long taskId) {
|
||||
syncTaskMapper.update(null, new LambdaUpdateWrapper<SyncTask>()
|
||||
.eq(SyncTask::getId, taskId)
|
||||
.setSql("FAIL = FAIL + 1, UPDATE_TIME = NOW()"));
|
||||
}
|
||||
|
||||
// ===================== 私有工具方法 =====================
|
||||
|
||||
private void updateLogStatus(Long id, int status, String ossPath) {
|
||||
ossFileLogMapper.update(null, new LambdaUpdateWrapper<QhOssFileLog>()
|
||||
.eq(QhOssFileLog::getId, id)
|
||||
.set(QhOssFileLog::getBizHandleStatus, status)
|
||||
.set(ossPath != null, QhOssFileLog::getOssPath, ossPath)
|
||||
.set(QhOssFileLog::getUpdateTime, LocalDateTime.now()));
|
||||
}
|
||||
|
||||
private String buildLocalPath(String bizTabPath) {
|
||||
String root = localFileConfig.getRootPath();
|
||||
if (root.endsWith("/") || root.endsWith("\\")) {
|
||||
return root + bizTabPath;
|
||||
}
|
||||
return root + File.separator + bizTabPath;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.yxtech.ossyn.util;
|
||||
|
||||
import com.yxtech.ossyn.config.BizDataSourceConfig;
|
||||
import com.zaxxer.hikari.HikariConfig;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.noear.solon.annotation.Component;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 动态数据源工具
|
||||
* 根据数据库名动态创建并缓存 HikariCP 连接池,用于查询业务库数据
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class DynamicDataSourceUtil {
|
||||
|
||||
@Inject
|
||||
private BizDataSourceConfig bizDataSourceConfig;
|
||||
|
||||
/** 按数据库名缓存连接池,避免重复创建 */
|
||||
private final ConcurrentHashMap<String, HikariDataSource> dsCache = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 获取指定数据库的连接池(不存在则创建)
|
||||
*
|
||||
* @param dbName 数据库名
|
||||
* @return HikariDataSource
|
||||
*/
|
||||
private HikariDataSource getDataSource(String dbName) {
|
||||
return dsCache.computeIfAbsent(dbName, name -> {
|
||||
HikariConfig cfg = new HikariConfig();
|
||||
cfg.setJdbcUrl(bizDataSourceConfig.buildJdbcUrl(name));
|
||||
cfg.setUsername(bizDataSourceConfig.getUsername());
|
||||
cfg.setPassword(bizDataSourceConfig.getPassword());
|
||||
cfg.setDriverClassName("com.mysql.cj.jdbc.Driver");
|
||||
cfg.setMaximumPoolSize(5);
|
||||
cfg.setMinimumIdle(1);
|
||||
cfg.setConnectionTimeout(30_000);
|
||||
cfg.setPoolName("biz-pool-" + name);
|
||||
log.info("[DynamicDS] 创建业务库连接池: {}", name);
|
||||
return new HikariDataSource(cfg);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询业务表中文件路径列不为空的记录
|
||||
* 返回列表,每条为 Map,包含 idCol 和 fileCol 两个字段
|
||||
*
|
||||
* @param dbName 数据库名
|
||||
* @param tableName 业务表名
|
||||
* @param idCol 主键列名
|
||||
* @param fileCol 文件路径列名
|
||||
* @return 记录列表,每条含 id 和 filePath
|
||||
*/
|
||||
public List<Map<String, String>> queryFilePaths(String dbName, String tableName,
|
||||
String idCol, String fileCol) {
|
||||
List<Map<String, String>> result = new ArrayList<>();
|
||||
// 列名和表名来自内部配置,不存在 SQL 注入风险,直接拼接
|
||||
String sql = "SELECT `" + idCol + "`, `" + fileCol + "` FROM `" + tableName
|
||||
+ "` WHERE `" + fileCol + "` IS NOT NULL AND `" + fileCol + "` != ''";
|
||||
HikariDataSource ds = getDataSource(dbName);
|
||||
try (Connection conn = ds.getConnection();
|
||||
PreparedStatement ps = conn.prepareStatement(sql);
|
||||
ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
Map<String, String> row = new HashMap<>(2);
|
||||
row.put("id", rs.getString(idCol));
|
||||
row.put("filePath", rs.getString(fileCol));
|
||||
result.add(row);
|
||||
}
|
||||
log.info("[DynamicDS] 查询 {}.{} 共 {} 条文件记录", dbName, tableName, result.size());
|
||||
} catch (SQLException e) {
|
||||
log.error("[DynamicDS] 查询 {}.{} 失败", dbName, tableName, e);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新业务表指定行的文件路径列为 OSS 路径
|
||||
*
|
||||
* @param dbName 数据库名
|
||||
* @param tableName 业务表名
|
||||
* @param idCol 主键列名
|
||||
* @param idVal 主键值
|
||||
* @param fileCol 文件路径列名
|
||||
* @param ossPath 新的 OSS 路径
|
||||
* @return 是否更新成功
|
||||
*/
|
||||
public boolean updateFilePath(String dbName, String tableName,
|
||||
String idCol, String idVal,
|
||||
String fileCol, String ossPath) {
|
||||
String sql = "UPDATE `" + tableName + "` SET `" + fileCol + "` = ? WHERE `" + idCol + "` = ?";
|
||||
HikariDataSource ds = getDataSource(dbName);
|
||||
try (Connection conn = ds.getConnection();
|
||||
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
ps.setString(1, ossPath);
|
||||
ps.setString(2, idVal);
|
||||
int rows = ps.executeUpdate();
|
||||
log.info("[DynamicDS] 回写 {}.{} id={} {}={} 影响行数={}", dbName, tableName, idVal, fileCol, ossPath, rows);
|
||||
return rows > 0;
|
||||
} catch (SQLException e) {
|
||||
log.error("[DynamicDS] 回写 {}.{} id={} 失败", dbName, tableName, idVal, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭所有缓存的连接池(应用关闭时调用)
|
||||
*/
|
||||
public void closeAll() {
|
||||
dsCache.forEach((name, ds) -> {
|
||||
try {
|
||||
ds.close();
|
||||
log.info("[DynamicDS] 关闭连接池: {}", name);
|
||||
} catch (Exception e) {
|
||||
log.warn("[DynamicDS] 关闭连接池 {} 异常", name, e);
|
||||
}
|
||||
});
|
||||
dsCache.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package com.yxtech.ossyn.util;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.yxtech.ossyn.config.OssConfig;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.hc.client5.http.classic.methods.HttpGet;
|
||||
import org.apache.hc.client5.http.classic.methods.HttpPost;
|
||||
import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder;
|
||||
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
||||
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
|
||||
import org.apache.hc.client5.http.impl.classic.HttpClients;
|
||||
import org.apache.hc.core5.http.ContentType;
|
||||
import org.apache.hc.core5.http.Header;
|
||||
import org.apache.hc.core5.http.io.entity.EntityUtils;
|
||||
import org.apache.hc.core5.net.URIBuilder;
|
||||
import org.noear.solon.annotation.Component;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* OSS 文件上传工具(适配 Solon,无 Redis,内存缓存 token)
|
||||
* 参考 XjOssUtil,使用 HttpClient5
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class OssUtil {
|
||||
|
||||
@Inject
|
||||
private OssConfig ossConfig;
|
||||
|
||||
/** 内存缓存的 access_token */
|
||||
private volatile String cachedToken;
|
||||
/** token 过期时间戳(毫秒) */
|
||||
private volatile long tokenExpireAt = 0;
|
||||
/** token 过期缓冲(秒) */
|
||||
private static final int TOKEN_EXPIRE_BUFFER = 60;
|
||||
/** HTTP 连接超时(毫秒) */
|
||||
private static final int CONNECT_TIMEOUT_MS = 30_000;
|
||||
private static final String DIR = "/jkgl/";
|
||||
|
||||
// ===================== 鉴权 =====================
|
||||
|
||||
/**
|
||||
* 向 IOSP 网关发起 OAuth2 鉴权,获取 access_token 并缓存到内存
|
||||
*
|
||||
* @return access_token,失败返回 null
|
||||
*/
|
||||
public synchronized String authenticate() {
|
||||
String url = ossConfig.getGatewayUrl() + "/oauth/oauth/token";
|
||||
try (CloseableHttpClient client = HttpClients.createDefault()) {
|
||||
HttpPost post = new HttpPost(url);
|
||||
post.setEntity(MultipartEntityBuilder.create()
|
||||
.addTextBody("grant_type", "client_credentials", ContentType.TEXT_PLAIN)
|
||||
.addTextBody("client_id", ossConfig.getClientId(), ContentType.TEXT_PLAIN)
|
||||
.addTextBody("client_secret", ossConfig.getClientSecret(), ContentType.TEXT_PLAIN)
|
||||
.build());
|
||||
try (CloseableHttpResponse resp = client.execute(post)) {
|
||||
int status = resp.getCode();
|
||||
if (status != 200) {
|
||||
log.error("[OssUtil] 鉴权失败,HTTP状态码: {}", status);
|
||||
return null;
|
||||
}
|
||||
String body = EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8);
|
||||
JSONObject json = JSON.parseObject(body);
|
||||
String token = json.getString("access_token");
|
||||
int expiresIn = json.getIntValue("expires_in");
|
||||
int ttl = Math.max(expiresIn - TOKEN_EXPIRE_BUFFER, 30);
|
||||
cachedToken = token;
|
||||
tokenExpireAt = System.currentTimeMillis() + ttl * 1000L;
|
||||
log.info("[OssUtil] 鉴权成功,token 有效期 {}s(含缓冲)", ttl);
|
||||
return token;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[OssUtil] 鉴权异常", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取有效的 access_token,优先使用内存缓存
|
||||
*
|
||||
* @return access_token,失败返回 null
|
||||
*/
|
||||
public String getToken() {
|
||||
if (cachedToken != null && System.currentTimeMillis() < tokenExpireAt) {
|
||||
return cachedToken;
|
||||
}
|
||||
log.info("[OssUtil] token 不存在或已过期,重新鉴权");
|
||||
return authenticate();
|
||||
}
|
||||
|
||||
// ===================== 文件上传 =====================
|
||||
|
||||
/**
|
||||
* 上传文件(InputStream 方式)
|
||||
*
|
||||
* @param stream 文件输入流
|
||||
* @param bizPath 业务子路径,如 import、temp
|
||||
* @param fileName 文件名,如 test.png
|
||||
* @return 上传成功后的 fileKey,失败返回 null
|
||||
*/
|
||||
public String upload(InputStream stream, String bizPath, String fileName) {
|
||||
checkFileLegitimacy(fileName);
|
||||
String url = ossConfig.getGatewayUrl() + "/hfle/v2/" + ossConfig.getOrganizationId()
|
||||
+ "/files/" + ossConfig.getConfigCode() + "/multipart";
|
||||
// 追加时间戳避免重复覆盖
|
||||
// if (fileName.contains(".")) {
|
||||
// String left = fileName.substring(0, fileName.lastIndexOf("."));
|
||||
// String ext = fileName.substring(fileName.lastIndexOf("."));
|
||||
// fileName = left + "_" + System.currentTimeMillis() + ext;
|
||||
// } else {
|
||||
// fileName += "_" + System.currentTimeMillis();
|
||||
// }
|
||||
// json 文件改为 txt 后缀上传(OSS 不支持 json 类型)
|
||||
if (fileName.toLowerCase().endsWith(".json")) {
|
||||
fileName = fileName.substring(0, fileName.length() - 5) + ".txt";
|
||||
}
|
||||
String nowday = new SimpleDateFormat("yyyyMMdd").format(new Date());
|
||||
final String finalFileName = DIR + bizPath + "/" + nowday + "/" + fileName;
|
||||
return doUpload(stream, url, finalFileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行实际上传,401 时刷新 token 重试一次
|
||||
*/
|
||||
private String doUpload(InputStream stream, String url, String fileName) {
|
||||
try (CloseableHttpClient client = HttpClients.createDefault()) {
|
||||
URI uri = new URIBuilder(url)
|
||||
.addParameter("directory", ossConfig.getDirectory())
|
||||
.addParameter("fileName", fileName)
|
||||
.addParameter("docType", "0")
|
||||
.build();
|
||||
HttpPost post = new HttpPost(uri);
|
||||
String token = getToken();
|
||||
post.addHeader("Authorization", "Bearer " + token);
|
||||
post.addHeader("accept", "application/json;charset=utf-8");
|
||||
post.setEntity(MultipartEntityBuilder.create()
|
||||
.addBinaryBody("file", stream, ContentType.APPLICATION_OCTET_STREAM, fileName)
|
||||
.build());
|
||||
try (CloseableHttpResponse resp = client.execute(post)) {
|
||||
int status = resp.getCode();
|
||||
if (status == 401) {
|
||||
log.warn("[OssUtil] 上传时 token 过期,重新鉴权后重试");
|
||||
cachedToken = null;
|
||||
tokenExpireAt = 0;
|
||||
return doUpload(stream, url, fileName);
|
||||
}
|
||||
if (status != 200) {
|
||||
log.error("[OssUtil] 文件上传失败,HTTP状态码: {}", status);
|
||||
return null;
|
||||
}
|
||||
String fileUrl = EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8);
|
||||
// 判断业务错误
|
||||
try {
|
||||
JSONObject resultJson = JSON.parseObject(fileUrl);
|
||||
if (Boolean.TRUE.equals(resultJson.getBoolean("failed"))) {
|
||||
log.error("[OssUtil] 文件上传业务失败: {}", fileUrl);
|
||||
return null;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
String fileKey = extractFileKey(fileUrl);
|
||||
log.info("[OssUtil] 文件上传成功,fileKey: {}", fileKey);
|
||||
return fileKey;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[OssUtil] 文件上传异常", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 私有工具方法 =====================
|
||||
|
||||
/**
|
||||
* 文件类型白名单校验
|
||||
*/
|
||||
private void checkFileLegitimacy(String fileName) {
|
||||
String whiteList = ossConfig.getFileTypeWhiteList();
|
||||
if (whiteList != null && !whiteList.isBlank()) {
|
||||
int dotIdx = fileName.lastIndexOf('.');
|
||||
if (dotIdx < 0) {
|
||||
throw new IllegalArgumentException("文件上传失败:不能识别的文件类型");
|
||||
}
|
||||
String ext = fileName.substring(dotIdx + 1).toLowerCase();
|
||||
List<String> allowed = Arrays.asList(whiteList.split(","));
|
||||
if (!allowed.contains(ext)) {
|
||||
throw new IllegalArgumentException("文件上传失败:不支持的文件类型 " + ext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从上传接口返回的完整 URL 中截取 fileKey
|
||||
*/
|
||||
private String extractFileKey(String fileUrl) {
|
||||
int atIndex = fileUrl.indexOf("@");
|
||||
if (atIndex < 0) {
|
||||
return fileUrl;
|
||||
}
|
||||
int slashIndex = fileUrl.lastIndexOf("/", atIndex);
|
||||
if (slashIndex < 0) {
|
||||
return fileUrl;
|
||||
}
|
||||
return fileUrl.substring(slashIndex + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件预览地址(Location 重定向 URL)
|
||||
*
|
||||
* @param fileKey 短文件 key
|
||||
* @return 预览 URL,失败返回 null
|
||||
*/
|
||||
public String show(String fileKey) {
|
||||
String dir = ossConfig.getDirectory();
|
||||
if (!dir.endsWith("/")) {
|
||||
dir = dir + "/";
|
||||
}
|
||||
String fullFileKey = dir + "0/" + fileKey;
|
||||
String url = ossConfig.getGatewayUrl() + "/hfle/v2/" + ossConfig.getOrganizationId()
|
||||
+ "/files/" + ossConfig.getConfigCode() + "/download-by-key";
|
||||
try (CloseableHttpClient client = HttpClients.createDefault()) {
|
||||
URI uri = new URIBuilder(url).addParameter("fileKey", fullFileKey).build();
|
||||
HttpGet get = new HttpGet(uri);
|
||||
get.addHeader("Authorization", "Bearer " + getToken());
|
||||
try (CloseableHttpResponse resp = client.execute(get)) {
|
||||
int status = resp.getCode();
|
||||
if (status == 401) {
|
||||
cachedToken = null;
|
||||
tokenExpireAt = 0;
|
||||
return show(fileKey);
|
||||
}
|
||||
if (status != 302 && status != 303) {
|
||||
log.error("[OssUtil] download-by-key 返回非预期状态码: {}", status);
|
||||
return null;
|
||||
}
|
||||
Header location = resp.getFirstHeader("Location");
|
||||
if (location == null) {
|
||||
log.error("[OssUtil] download-by-key 未返回 Location 响应头");
|
||||
return null;
|
||||
}
|
||||
return location.getValue();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[OssUtil] 获取预览地址异常", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
server.port: 8080
|
||||
server.request.maxBodySize: 1073741824
|
||||
server.request.maxFileSize: 1073741824
|
||||
server.request.maxRequestSize: 1073741824
|
||||
solon.config.load:
|
||||
- "app-env.yml" #可以是环境相关的
|
||||
|
||||
solon.app:
|
||||
name: 'xj-oss-syn'
|
||||
group: 'yxtech'
|
||||
|
||||
solon.logging:
|
||||
appender:
|
||||
console:
|
||||
level: INFO
|
||||
charset: UTF-8
|
||||
file:
|
||||
level: INFO
|
||||
charset: UTF-8
|
||||
logger:
|
||||
com.yxtech.ossyn:
|
||||
level: INFO
|
||||
com.baomidou.mybatisplus:
|
||||
level: INFO
|
||||
|
||||
# =====================================
|
||||
# 数据源1:本项目数据库(存储 QH_OSS_FILE_LOG)
|
||||
# =====================================
|
||||
solon.dataSources:
|
||||
db1!:
|
||||
class: "com.zaxxer.hikari.HikariDataSource"
|
||||
jdbcUrl: jdbc:mysql://localhost:3308/oss_handle?characterEncoding=UTF-8&useUnicode=true&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
|
||||
driverClassName: com.mysql.cj.jdbc.Driver
|
||||
username: root
|
||||
password: 123456
|
||||
|
||||
mybatis.db1:
|
||||
typeAliases:
|
||||
- "com.yxtech.ossyn.entity"
|
||||
mappers:
|
||||
- "com.yxtech.ossyn.mapper"
|
||||
configuration:
|
||||
logImpl: org.apache.ibatis.logging.nologging.NoLoggingImpl
|
||||
globalConfig:
|
||||
banner: false
|
||||
|
||||
# =====================================
|
||||
# 数据源2(业务库)公共连接参数,数据库名动态替换
|
||||
# =====================================
|
||||
biz-datasource:
|
||||
host: 192.168.1.250
|
||||
port: 3309
|
||||
username: root
|
||||
password: Aa135790123
|
||||
params: "characterEncoding=UTF-8&useUnicode=true&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai"
|
||||
|
||||
# =====================================
|
||||
# 本地文件根路径(业务表中存储的是相对路径,拼接此前缀查找文件)
|
||||
# =====================================
|
||||
local-file:
|
||||
root-path: "D:/upload"
|
||||
|
||||
# =====================================
|
||||
# OSS 配置(新疆油田 IOSP 平台)
|
||||
# =====================================
|
||||
xj-oss:
|
||||
gateway-url: http://api.iosp.ydpt.tech
|
||||
client-id: jkglpt
|
||||
client-secret: jkglpt@12345!
|
||||
organization-id: 0
|
||||
config-code: FILE-OSS-0a85c4dd40ca745a598b872c7974bcaca
|
||||
directory: scyx/scfz/aygc/jkgl/
|
||||
file-type-white-list: dat,svg,dwg,pr,pptm,ai,bak,pptx,lic,db,caj,wps,xmind,eps,exe,doc,docx,xls,xlsx,ppt,pps,pdf,xml,webp,MOV,MP4,HEVC,wmv,rmvb,rm,odt,3gp,swf,gz,tgz,log,bz,bz2,tbz,zip,rar,tar,7z,mp3,mid,ogg,mpga,mp4a,wav,wma,avi,dv,mp4,bmp,xlsm,dps,bak,cdr,et,kdh,nh,,nh,mpeg,mpg,mov,wm,flv,mkv,bmp,jpg,jpeg,gif,png,tif,tiff,tga,psd,txt,php,html,htm,js,css,rtf,rtfd,py,java,rb,sh,pl,sql,xml,gdb,gdbx
|
||||
|
||||
# =====================================
|
||||
# Harbor 镜像仓库配置
|
||||
# =====================================
|
||||
harbor:
|
||||
url: https://harbor.dev.xjyt.petrochina
|
||||
project: operation-jkglpt
|
||||
username: jkglpt
|
||||
password: Jkglpt@123
|
||||
|
||||
# =====================================
|
||||
# Skopeo 配置
|
||||
# =====================================
|
||||
skopeo:
|
||||
exe-path: "E:/skopeo/skopeo.exe"
|
||||
images-path: "E:/skopeo/images"
|
||||
|
||||
# =====================================
|
||||
# 控制台(Choerodon DevOps)配置
|
||||
# =====================================
|
||||
console:
|
||||
api-base: http://api.dev.xjyt.petrochina
|
||||
project-id: "748209321615708160"
|
||||
env-id: "766633041995898880"
|
||||
login-username: jy-lianlonggang
|
||||
login-password: "UserPwdLlgA@123!"
|
||||
public-key: "MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAJL0JkqsUoK6kt3JyogsgqNp9VDGDp+t3ZAGMbVoMPdHNT2nfiIVh9ZMNHF7g2XiAa8O8AQWyh2PjMR0NiUSVQMCAwEAAQ=="
|
||||
# =====================================
|
||||
sa-token:
|
||||
# token 名称(同时也是 cookie 名称)
|
||||
token-name: oss-token
|
||||
# token 有效期(秒),-1 代表永不过期
|
||||
timeout: 86400
|
||||
# 无操作超时时间(秒),-1 代表不限制
|
||||
activity-timeout: -1
|
||||
# 允许同一账号并发登录
|
||||
allow-concurrent-login: true
|
||||
# 多人登录共用同一 token
|
||||
is-share: true
|
||||
# token 风格
|
||||
token-style: uuid
|
||||
# 关闭操作日志
|
||||
is-log: false
|
||||
# 登录凭证(格式:用户名:密码)
|
||||
http-basic: "admin:mD8C-gR8UgRE8fNvJWgmjg"
|
||||
@@ -0,0 +1 @@
|
||||
div{font-size: 1.5em;}
|
||||
@@ -0,0 +1,912 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OSS 文件同步管理</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f0f2f5; color: #333; }
|
||||
.header { background: #1677ff; color: #fff; padding: 16px 24px; font-size: 18px; font-weight: 600; }
|
||||
.container { max-width: 1200px; margin: 24px auto; padding: 0 16px; }
|
||||
|
||||
/* 卡片 */
|
||||
.card { background: #fff; border-radius: 8px; padding: 20px 24px; margin-bottom: 20px; box-shadow: 0 1px 4px rgba(0,0,0,.08); }
|
||||
.card-title { font-size: 15px; font-weight: 600; margin-bottom: 16px; color: #1677ff; border-left: 3px solid #1677ff; padding-left: 8px; }
|
||||
|
||||
/* 操作按钮 */
|
||||
.btn-group { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.btn { padding: 8px 20px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 500; transition: opacity .2s; }
|
||||
.btn:hover { opacity: .85; }
|
||||
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.btn-primary { background: #1677ff; color: #fff; }
|
||||
.btn-success { background: #52c41a; color: #fff; }
|
||||
.btn-warning { background: #fa8c16; color: #fff; }
|
||||
.btn-info { background: #13c2c2; color: #fff; }
|
||||
.btn-default { background: #f0f0f0; color: #555; }
|
||||
|
||||
/* 统计数字 */
|
||||
.stats-row { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 4px; }
|
||||
.stat-item { flex: 1; min-width: 100px; background: #fafafa; border: 1px solid #f0f0f0; border-radius: 6px; padding: 12px 16px; text-align: center; }
|
||||
.stat-num { font-size: 26px; font-weight: 700; }
|
||||
.stat-label { font-size: 12px; color: #888; margin-top: 4px; }
|
||||
.c-blue { color: #1677ff; }
|
||||
.c-orange { color: #fa8c16; }
|
||||
.c-green { color: #52c41a; }
|
||||
.c-red { color: #f5222d; }
|
||||
.c-gray { color: #aaa; }
|
||||
.c-purple { color: #722ed1; }
|
||||
|
||||
/* 进度条 */
|
||||
.progress-wrap { margin-top: 12px; }
|
||||
.progress-bar-bg { background: #f0f0f0; border-radius: 4px; height: 10px; overflow: hidden; }
|
||||
.progress-bar { height: 10px; border-radius: 4px; background: #1677ff; transition: width .4s; }
|
||||
.progress-text { font-size: 13px; color: #555; margin-top: 6px; }
|
||||
|
||||
/* 任务状态 */
|
||||
.task-status { display: inline-block; padding: 2px 10px; border-radius: 10px; font-size: 12px; font-weight: 500; }
|
||||
.ts-running { background: #e6f4ff; color: #1677ff; }
|
||||
.ts-done { background: #f6ffed; color: #52c41a; }
|
||||
.ts-fail { background: #fff2f0; color: #f5222d; }
|
||||
|
||||
/* 表格 */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th { background: #fafafa; padding: 10px 12px; text-align: left; border-bottom: 1px solid #f0f0f0; font-weight: 600; white-space: nowrap; }
|
||||
td { padding: 9px 12px; border-bottom: 1px solid #f7f7f7; vertical-align: middle; }
|
||||
tr:hover td { background: #fafffe; }
|
||||
.tag { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 12px; }
|
||||
.tag-0 { background: #fff7e6; color: #fa8c16; }
|
||||
.tag-1 { background: #e6f4ff; color: #1677ff; }
|
||||
.tag-2 { background: #fff2f0; color: #f5222d; }
|
||||
.tag-3 { background: #f9f0ff; color: #722ed1; }
|
||||
.tag-4 { background: #f6ffed; color: #52c41a; }
|
||||
.ellipsis { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
|
||||
|
||||
/* 筛选栏 */
|
||||
.filter-bar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 14px; }
|
||||
.filter-bar select, .filter-bar input { padding: 6px 10px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 13px; outline: none; }
|
||||
.filter-bar select:focus, .filter-bar input:focus { border-color: #1677ff; }
|
||||
|
||||
/* 分页 */
|
||||
.pagination { display: flex; gap: 6px; align-items: center; justify-content: flex-end; margin-top: 14px; font-size: 13px; }
|
||||
.page-btn { padding: 4px 10px; border: 1px solid #d9d9d9; border-radius: 4px; cursor: pointer; background: #fff; }
|
||||
.page-btn:hover { border-color: #1677ff; color: #1677ff; }
|
||||
.page-btn.active { background: #1677ff; color: #fff; border-color: #1677ff; }
|
||||
.page-btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||
|
||||
/* 任务历史 */
|
||||
.task-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid #f5f5f5; font-size: 13px; }
|
||||
.task-row:last-child { border-bottom: none; }
|
||||
.task-type-tag { padding: 2px 8px; border-radius: 4px; font-size: 12px; background: #e6f4ff; color: #1677ff; white-space: nowrap; }
|
||||
.task-msg { flex: 1; color: #555; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.task-time { color: #aaa; white-space: nowrap; }
|
||||
|
||||
/* Tab 导航 */
|
||||
.tabs { display: flex; gap: 0; margin-bottom: 20px; border-bottom: 2px solid #e8e8e8; }
|
||||
.tab-btn { padding: 10px 24px; border: none; background: none; cursor: pointer; font-size: 14px; color: #666; border-bottom: 2px solid transparent; margin-bottom: -2px; transition: all .2s; }
|
||||
.tab-btn:hover { color: #1677ff; }
|
||||
.tab-btn.active { color: #1677ff; border-bottom-color: #1677ff; font-weight: 600; }
|
||||
.tab-panel { display: none; }
|
||||
.tab-panel.active { display: block; }
|
||||
|
||||
/* 弹窗 */
|
||||
.modal-mask { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.45); z-index: 1000; align-items: center; justify-content: center; }
|
||||
.modal-mask.show { display: flex; }
|
||||
.modal { background: #fff; border-radius: 8px; padding: 24px; width: 480px; max-width: 95vw; box-shadow: 0 8px 32px rgba(0,0,0,.18); }
|
||||
.modal-title { font-size: 16px; font-weight: 600; margin-bottom: 20px; }
|
||||
.form-item { margin-bottom: 14px; }
|
||||
.form-item label { display: block; font-size: 13px; color: #555; margin-bottom: 4px; }
|
||||
.form-item input, .form-item textarea { width: 100%; padding: 7px 10px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 13px; outline: none; }
|
||||
.form-item input:focus, .form-item textarea:focus { border-color: #1677ff; }
|
||||
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
|
||||
.tag-enabled { background: #f6ffed; color: #52c41a; }
|
||||
.tag-disabled { background: #f5f5f5; color: #aaa; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header" style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<span>OSS 文件同步管理</span>
|
||||
<a href="/auth/logout" style="color:rgba(255,255,255,.8);font-size:13px;text-decoration:none;" onmouseover="this.style.color='#fff'" onmouseout="this.style.color='rgba(255,255,255,.8)'">退出登录</a>
|
||||
</div>
|
||||
<div class="container">
|
||||
|
||||
<!-- Tab 导航 -->
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" onclick="switchTab('sync')">同步管理</button>
|
||||
<button class="tab-btn" onclick="switchTab('mapping')">映射配置</button>
|
||||
<button class="tab-btn" onclick="switchTab('harbor')">镜像管理</button>
|
||||
<button class="tab-btn" onclick="switchTab('console')">控制台实例</button>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 同步管理 Tab ==================== -->
|
||||
<div id="tab-sync" class="tab-panel active">
|
||||
|
||||
<!-- 操作区 -->
|
||||
<div class="card">
|
||||
<div class="card-title">操作</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" onclick="triggerSync('all')">一键全量同步</button>
|
||||
<button class="btn btn-success" onclick="triggerSync('scan')">仅扫描业务表</button>
|
||||
<button class="btn btn-warning" onclick="triggerSync('upload')">仅上传文件</button>
|
||||
<button class="btn btn-info" onclick="triggerSync('write-back')">仅回写业务表</button>
|
||||
</div>
|
||||
<p class="tip">全量同步 = 扫描 → 上传 → 回写,三步连续执行。各步骤也可单独触发。</p>
|
||||
</div>
|
||||
|
||||
<!-- 当前任务进度 -->
|
||||
<div class="card" id="currentTaskBox">
|
||||
<div class="card-title">当前任务进度</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:10px;">
|
||||
<span id="curTaskType" class="task-type-tag">-</span>
|
||||
<span id="curTaskStatus" class="task-status ts-running">进行中</span>
|
||||
<span id="curTaskMsg" style="font-size:13px;color:#555;"></span>
|
||||
</div>
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-bar-bg"><div class="progress-bar" id="progressBar" style="width:0%"></div></div>
|
||||
<div class="progress-text" id="progressText">0 / 0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志统计 -->
|
||||
<div class="card">
|
||||
<div class="card-title">日志统计</div>
|
||||
<div class="stats-row">
|
||||
<div class="stat-item"><div class="stat-num c-blue" id="s-total">-</div><div class="stat-label">总计</div></div>
|
||||
<div class="stat-item"><div class="stat-num c-orange" id="s-pending">-</div><div class="stat-label">待处理</div></div>
|
||||
<div class="stat-item"><div class="stat-num c-blue" id="s-uploaded">-</div><div class="stat-label">已上传</div></div>
|
||||
<div class="stat-item"><div class="stat-num c-red" id="s-fail">-</div><div class="stat-label">上传失败</div></div>
|
||||
<div class="stat-item"><div class="stat-num c-purple" id="s-notFound">-</div><div class="stat-label">文件不存在</div></div>
|
||||
<div class="stat-item"><div class="stat-num c-green" id="s-writtenBack">-</div><div class="stat-label">已回写</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志列表 -->
|
||||
<div class="card">
|
||||
<div class="card-title">文件日志</div>
|
||||
<div class="filter-bar">
|
||||
<select id="filterStatus" onchange="loadLogs(1)">
|
||||
<option value="">全部状态</option>
|
||||
<option value="0">待处理</option>
|
||||
<option value="1">已上传</option>
|
||||
<option value="2">上传失败</option>
|
||||
<option value="3">文件不存在</option>
|
||||
<option value="4">已回写</option>
|
||||
</select>
|
||||
<input id="filterTab" placeholder="业务表名" onkeydown="if(event.key==='Enter')loadLogs(1)" style="width:160px">
|
||||
<button class="btn btn-default" onclick="loadLogs(1)">查询</button>
|
||||
<button class="btn btn-default" onclick="resetFilter()">重置</button>
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn btn-default" style="color:#fa8c16;border-color:#fa8c16" onclick="openCleanLogsModal()">清理日志</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>业务表</th>
|
||||
<th>业务ID</th>
|
||||
<th>字段</th>
|
||||
<th>原始路径</th>
|
||||
<th>OSS路径</th>
|
||||
<th>状态</th>
|
||||
<th>更新时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="logTableBody">
|
||||
<tr><td colspan="8" style="text-align:center;color:#aaa;padding:30px">加载中...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="pagination"></div>
|
||||
</div>
|
||||
|
||||
<!-- 任务历史 -->
|
||||
<div class="card">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
|
||||
<div class="card-title" style="margin-bottom:0">任务历史(最近20条)</div>
|
||||
<button class="btn btn-default" style="color:#f5222d;border-color:#f5222d;font-size:12px;padding:4px 12px" onclick="cleanTasks()">清理全部</button>
|
||||
</div>
|
||||
<div id="taskHistory"><div style="color:#aaa;font-size:13px">加载中...</div></div>
|
||||
</div>
|
||||
|
||||
</div><!-- end tab-sync -->
|
||||
|
||||
<!-- ==================== 映射配置 Tab ==================== -->
|
||||
<div id="tab-mapping" class="tab-panel">
|
||||
<div class="card">
|
||||
<div class="card-title">业务文件映射配置</div>
|
||||
<div style="margin-bottom:14px">
|
||||
<button class="btn btn-primary" onclick="openMappingModal(null)">+ 新增映射</button>
|
||||
<span class="tip" style="margin-left:12px">新增后默认无效,需手动启用;扫描时只处理有效的映射。</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>数据库名</th>
|
||||
<th>业务表名</th>
|
||||
<th>主键列</th>
|
||||
<th>文件路径列</th>
|
||||
<th>OSS业务路径</th>
|
||||
<th>状态</th>
|
||||
<th>备注</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="mappingTableBody">
|
||||
<tr><td colspan="8" style="text-align:center;color:#aaa;padding:30px">加载中...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- end tab-mapping -->
|
||||
|
||||
<!-- ==================== 镜像管理 Tab ==================== -->
|
||||
<div id="tab-harbor" class="tab-panel">
|
||||
<div class="card">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
|
||||
<div class="card-title" style="margin-bottom:0">Harbor 镜像仓库</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-default" onclick="loadHarbor()">刷新</button>
|
||||
<button class="btn btn-primary" onclick="openPushModal()">上传镜像</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>仓库名</th><th>镜像数</th><th>最新 Tag</th><th>最后更新</th></tr>
|
||||
</thead>
|
||||
<tbody id="harborTableBody">
|
||||
<tr><td colspan="4" style="text-align:center;color:#aaa;padding:30px">加载中...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- end tab-harbor -->
|
||||
|
||||
<!-- ==================== 控制台实例 Tab ==================== -->
|
||||
<div id="tab-console" class="tab-panel">
|
||||
<div class="card">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
|
||||
<div class="card-title" style="margin-bottom:0">控制台实例</div>
|
||||
<button class="btn btn-default" onclick="loadInstances()">刷新</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>服务名</th><th>Code</th><th>状态</th><th>版本</th><th>最后更新</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody id="instanceTableBody">
|
||||
<tr><td colspan="6" style="text-align:center;color:#aaa;padding:30px">加载中...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- end tab-console -->
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 上传镜像弹窗 -->
|
||||
<div class="modal-mask" id="pushModal">
|
||||
<div class="modal" style="width:440px">
|
||||
<div class="modal-title">上传镜像到 Harbor</div>
|
||||
<div class="form-item">
|
||||
<label>目标仓库 <span style="color:red">*</span></label>
|
||||
<input id="push-repo" placeholder="如 health-bank-admin">
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>Tag(留空自动 +1)</label>
|
||||
<input id="push-tag" placeholder="如 1.0.11,不填则自动计算">
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>镜像 tar 包 <span style="color:red">*</span></label>
|
||||
<input type="file" id="push-file" accept=".tar">
|
||||
</div>
|
||||
<div id="push-result" style="font-size:12px;color:#555;margin-top:8px;white-space:pre-wrap;max-height:120px;overflow:auto"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-default" onclick="document.getElementById('pushModal').classList.remove('show')">取消</button>
|
||||
<button class="btn btn-primary" id="pushBtn" onclick="doPushImage()">开始上传</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- YAML 配置弹窗 -->
|
||||
<div class="modal-mask" id="yamlModal">
|
||||
<div class="modal" style="width:700px;max-width:96vw">
|
||||
<div class="modal-title">实例 YAML 配置</div>
|
||||
<input type="hidden" id="yaml-instanceId">
|
||||
<input type="hidden" id="yaml-versionId">
|
||||
<input type="hidden" id="yaml-envId">
|
||||
<input type="hidden" id="yaml-appServiceId">
|
||||
<input type="hidden" id="yaml-appServiceVersionId">
|
||||
<input type="hidden" id="yaml-objectVersionNumber">
|
||||
<textarea id="yaml-content" style="width:100%;height:360px;font-family:monospace;font-size:12px;padding:10px;border:1px solid #d9d9d9;border-radius:6px;resize:vertical;outline:none"></textarea>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-default" onclick="document.getElementById('yamlModal').classList.remove('show')">取消</button>
|
||||
<button class="btn btn-primary" onclick="doUpdateYaml()">保存更新</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pod 列表弹窗 -->
|
||||
<div class="modal-mask" id="podModal">
|
||||
<div class="modal" style="width:700px;max-width:96vw">
|
||||
<div class="modal-title" id="podModalTitle">Pod 列表</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Pod 名称</th><th>IP</th><th>状态</th><th>Ready</th><th>创建时间</th></tr></thead>
|
||||
<tbody id="podTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-default" onclick="document.getElementById('podModal').classList.remove('show')">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 清理日志弹窗 -->
|
||||
<div class="modal-mask" id="cleanLogsModal">
|
||||
<div class="modal" style="width:360px">
|
||||
<div class="modal-title">清理文件日志</div>
|
||||
<div class="form-item">
|
||||
<label>清理范围</label>
|
||||
<select id="cleanStatus" style="width:100%;padding:7px 10px;border:1px solid #d9d9d9;border-radius:6px;font-size:13px;outline:none">
|
||||
<option value="">全部日志</option>
|
||||
<option value="4">已回写(推荐)</option>
|
||||
<option value="2">上传失败</option>
|
||||
<option value="3">文件不存在</option>
|
||||
<option value="0">待处理</option>
|
||||
<option value="1">已上传(未回写)</option>
|
||||
</select>
|
||||
</div>
|
||||
<p style="font-size:12px;color:#f5222d;margin-top:-6px">此操作不可恢复,请谨慎操作。</p>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-default" onclick="document.getElementById('cleanLogsModal').classList.remove('show')">取消</button>
|
||||
<button class="btn btn-default" style="color:#f5222d;border-color:#f5222d" onclick="doCleanLogs()">确认清理</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-mask" id="mappingModal">
|
||||
<div class="modal">
|
||||
<div class="modal-title" id="modalTitle">新增映射配置</div>
|
||||
<input type="hidden" id="mappingId">
|
||||
<div class="form-item">
|
||||
<label>数据库名 <span style="color:red">*</span></label>
|
||||
<input id="f-dbName" placeholder="如 dmz_health_consult">
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>业务表名 <span style="color:red">*</span></label>
|
||||
<input id="f-tableName" placeholder="如 con_resource">
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>主键列名 <span style="color:red">*</span></label>
|
||||
<input id="f-idCol" placeholder="默认 id">
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>文件路径列名 <span style="color:red">*</span></label>
|
||||
<input id="f-fileCol" placeholder="如 img">
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>OSS 业务路径</label>
|
||||
<input id="f-bizPath" placeholder="如 health/resource,不填则使用表名">
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>备注</label>
|
||||
<input id="f-remark" placeholder="可选">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-default" onclick="closeMappingModal()">取消</button>
|
||||
<button class="btn btn-primary" onclick="saveMapping()">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<#noparse><script>
|
||||
const STATUS_LABELS = ['待处理','已上传','上传失败','文件不存在','已回写'];
|
||||
const TASK_TYPE_LABELS = { all:'全量同步', scan:'扫描', upload:'上传', 'write-back':'回写' };
|
||||
const TASK_STATUS_LABELS = ['进行中','完成','失败'];
|
||||
|
||||
let currentTaskId = null;
|
||||
let pollTimer = null;
|
||||
let currentPage = 1;
|
||||
|
||||
// ===================== 触发同步 =====================
|
||||
async function triggerSync(type) {
|
||||
const urlMap = { all:'/oss-sync/all', scan:'/oss-sync/scan', upload:'/oss-sync/upload', 'write-back':'/oss-sync/write-back' };
|
||||
try {
|
||||
const res = await fetch(urlMap[type], { method: 'POST' });
|
||||
const json = await res.json();
|
||||
if (json.code === 200) {
|
||||
currentTaskId = json.data;
|
||||
showTaskBox();
|
||||
startPolling();
|
||||
} else {
|
||||
alert('启动失败:' + json.msg);
|
||||
}
|
||||
} catch(e) {
|
||||
alert('请求失败:' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 轮询任务进度 =====================
|
||||
function startPolling() {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = setInterval(pollTask, 2000);
|
||||
pollTask();
|
||||
}
|
||||
|
||||
async function pollTask() {
|
||||
if (!currentTaskId) return;
|
||||
try {
|
||||
const res = await fetch('/oss-sync/task/' + currentTaskId);
|
||||
const json = await res.json();
|
||||
if (json.code !== 200) return;
|
||||
const t = json.data;
|
||||
updateTaskBox(t);
|
||||
if (t.status !== 0) {
|
||||
clearInterval(pollTimer);
|
||||
loadLogs(currentPage);
|
||||
loadStats();
|
||||
loadTaskHistory();
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function showTaskBox() {
|
||||
document.getElementById('currentTaskBox').style.display = 'block';
|
||||
}
|
||||
|
||||
function updateTaskBox(t) {
|
||||
const typeEl = document.getElementById('curTaskType');
|
||||
const statusEl = document.getElementById('curTaskStatus');
|
||||
const msgEl = document.getElementById('curTaskMsg');
|
||||
const bar = document.getElementById('progressBar');
|
||||
const text = document.getElementById('progressText');
|
||||
|
||||
typeEl.textContent = TASK_TYPE_LABELS[t.taskType] || t.taskType;
|
||||
msgEl.textContent = t.message || '';
|
||||
|
||||
const pct = t.total > 0 ? Math.round((t.success + t.fail) / t.total * 100) : (t.status === 1 ? 100 : 0);
|
||||
bar.style.width = pct + '%';
|
||||
text.textContent = (t.success + t.fail) + ' / ' + t.total + ' (成功 ' + t.success + ',失败 ' + t.fail + ')';
|
||||
|
||||
statusEl.className = 'task-status ' + ['ts-running','ts-done','ts-fail'][t.status];
|
||||
statusEl.textContent = TASK_STATUS_LABELS[t.status];
|
||||
if (t.status === 1) bar.style.background = '#52c41a';
|
||||
else if (t.status === 2) bar.style.background = '#f5222d';
|
||||
else bar.style.background = '#1677ff';
|
||||
}
|
||||
|
||||
// ===================== 日志列表 =====================
|
||||
async function loadLogs(page) {
|
||||
currentPage = page;
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
const tabName = document.getElementById('filterTab').value.trim();
|
||||
let url = '/oss-sync/logs?pageNum=' + page + '&pageSize=20';
|
||||
if (status !== '') url += '&status=' + status;
|
||||
if (tabName) url += '&tabName=' + encodeURIComponent(tabName);
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
if (json.code !== 200) return;
|
||||
const d = json.data;
|
||||
renderLogs(d.records);
|
||||
renderPagination(d.current, d.pages);
|
||||
renderStats(d.stats);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function renderLogs(records) {
|
||||
const tbody = document.getElementById('logTableBody');
|
||||
if (!records || records.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:#aaa;padding:30px">暂无数据</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = records.map(r => `
|
||||
<tr>
|
||||
<td style="font-size:11px;color:#aaa">${r.id}</td>
|
||||
<td>${r.bizTabName || '-'}</td>
|
||||
<td>${r.bizTabId || '-'}</td>
|
||||
<td>${r.bizColName || '-'}</td>
|
||||
<td><span class="ellipsis" title="${r.bizTabPath || ''}">${r.bizTabPath || '-'}</span></td>
|
||||
<td><span class="ellipsis" title="${r.ossPath || ''}">${r.ossPath || '-'}</span></td>
|
||||
<td><span class="tag tag-${r.bizHandleStatus}">${STATUS_LABELS[r.bizHandleStatus] || r.bizHandleStatus}</span></td>
|
||||
<td style="color:#aaa;font-size:12px">${(r.updateTime||'').replace('T',' ')}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderPagination(current, pages) {
|
||||
const el = document.getElementById('pagination');
|
||||
if (pages <= 1) { el.innerHTML = ''; return; }
|
||||
let html = `<button class="page-btn" onclick="loadLogs(${current-1})" ${current<=1?'disabled':''}>上一页</button>`;
|
||||
const start = Math.max(1, current - 2), end = Math.min(pages, current + 2);
|
||||
if (start > 1) html += `<button class="page-btn" onclick="loadLogs(1)">1</button>${start>2?'<span>...</span>':''}`;
|
||||
for (let i = start; i <= end; i++) {
|
||||
html += `<button class="page-btn ${i===current?'active':''}" onclick="loadLogs(${i})">${i}</button>`;
|
||||
}
|
||||
if (end < pages) html += `${end<pages-1?'<span>...</span>':''}<button class="page-btn" onclick="loadLogs(${pages})">${pages}</button>`;
|
||||
html += `<button class="page-btn" onclick="loadLogs(${current+1})" ${current>=pages?'disabled':''}>下一页</button>`;
|
||||
html += `<span style="color:#aaa">共 ${pages} 页</span>`;
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
// ===================== 统计 =====================
|
||||
function renderStats(stats) {
|
||||
if (!stats) return;
|
||||
document.getElementById('s-total').textContent = stats.total ?? '-';
|
||||
document.getElementById('s-pending').textContent = stats.pending ?? '-';
|
||||
document.getElementById('s-uploaded').textContent = stats.uploaded ?? '-';
|
||||
document.getElementById('s-fail').textContent = stats.fail ?? '-';
|
||||
document.getElementById('s-notFound').textContent = stats.notFound ?? '-';
|
||||
document.getElementById('s-writtenBack').textContent = stats.writtenBack ?? '-';
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res = await fetch('/oss-sync/logs?pageNum=1&pageSize=1');
|
||||
const json = await res.json();
|
||||
if (json.code === 200) renderStats(json.data.stats);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// ===================== 任务历史 =====================
|
||||
async function loadTaskHistory() {
|
||||
try {
|
||||
const res = await fetch('/oss-sync/tasks');
|
||||
const json = await res.json();
|
||||
if (json.code !== 200) return;
|
||||
const el = document.getElementById('taskHistory');
|
||||
if (!json.data || json.data.length === 0) {
|
||||
el.innerHTML = '<div style="color:#aaa;font-size:13px">暂无任务记录</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = json.data.map(t => `
|
||||
<div class="task-row">
|
||||
<span class="task-type-tag">${TASK_TYPE_LABELS[t.taskType]||t.taskType}</span>
|
||||
<span class="task-status ${['ts-running','ts-done','ts-fail'][t.status]}">${TASK_STATUS_LABELS[t.status]}</span>
|
||||
<span class="task-msg">${t.message||''}</span>
|
||||
<span style="font-size:12px;color:#888">成功 ${t.success} / 失败 ${t.fail} / 总 ${t.total}</span>
|
||||
<span class="task-time">${(t.createTime||'').replace('T',' ')}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function resetFilter() {
|
||||
document.getElementById('filterStatus').value = '';
|
||||
document.getElementById('filterTab').value = '';
|
||||
loadLogs(1);
|
||||
}
|
||||
|
||||
// ===================== 清理 =====================
|
||||
function openCleanLogsModal() {
|
||||
document.getElementById('cleanStatus').value = '4';
|
||||
document.getElementById('cleanLogsModal').classList.add('show');
|
||||
}
|
||||
|
||||
async function doCleanLogs() {
|
||||
const status = document.getElementById('cleanStatus').value;
|
||||
const label = document.getElementById('cleanStatus').options[document.getElementById('cleanStatus').selectedIndex].text;
|
||||
if (!confirm('确认清理「' + label + '」的日志记录?此操作不可恢复。')) return;
|
||||
try {
|
||||
const url = '/oss-sync/clean-logs' + (status !== '' ? '?status=' + status : '');
|
||||
const res = await fetch(url, { method: 'POST' });
|
||||
const json = await res.json();
|
||||
document.getElementById('cleanLogsModal').classList.remove('show');
|
||||
if (json.code === 200) {
|
||||
alert(json.msg);
|
||||
loadLogs(1);
|
||||
} else {
|
||||
alert('清理失败:' + json.msg);
|
||||
}
|
||||
} catch(e) { alert('请求失败:' + e.message); }
|
||||
}
|
||||
|
||||
async function cleanTasks() {
|
||||
if (!confirm('确认清理全部任务历史?此操作不可恢复。')) return;
|
||||
try {
|
||||
const res = await fetch('/oss-sync/clean-tasks', { method: 'POST' });
|
||||
const json = await res.json();
|
||||
if (json.code === 200) {
|
||||
alert(json.msg);
|
||||
loadTaskHistory();
|
||||
} else {
|
||||
alert('清理失败:' + json.msg);
|
||||
}
|
||||
} catch(e) { alert('请求失败:' + e.message); }
|
||||
}
|
||||
|
||||
// ===================== Tab 切换 =====================
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.tab-btn').forEach((b, i) => {
|
||||
b.classList.toggle('active', ['sync','mapping','harbor','console'][i] === name);
|
||||
});
|
||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||
document.getElementById('tab-' + name).classList.add('active');
|
||||
if (name === 'mapping') loadMappings();
|
||||
if (name === 'harbor') loadHarbor();
|
||||
if (name === 'console') loadInstances();
|
||||
}
|
||||
|
||||
// ===================== 映射配置 =====================
|
||||
async function loadMappings() {
|
||||
try {
|
||||
const res = await fetch('/mapping/list');
|
||||
const json = await res.json();
|
||||
if (json.code !== 200) return;
|
||||
renderMappings(json.data);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function renderMappings(list) {
|
||||
const tbody = document.getElementById('mappingTableBody');
|
||||
if (!list || list.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:#aaa;padding:30px">暂无数据,点击「新增映射」添加</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = list.map(m => `
|
||||
<tr>
|
||||
<td style="font-size:11px;color:#aaa">${m.id}</td>
|
||||
<td>${m.dbName}</td>
|
||||
<td>${m.tableName}</td>
|
||||
<td>${m.idCol}</td>
|
||||
<td>${m.fileCol}</td>
|
||||
<td>${m.bizPath||'-'}</td>
|
||||
<td><span class="tag ${m.enabled===1?'tag-enabled':'tag-disabled'}">${m.enabled===1?'有效':'无效'}</span></td>
|
||||
<td style="color:#888;font-size:12px">${m.remark||'-'}</td>
|
||||
<td>
|
||||
<button class="btn btn-default" style="padding:3px 10px;font-size:12px" onclick="openMappingModal(${JSON.stringify(m).replace(/"/g,'"')})">编辑</button>
|
||||
<button class="btn ${m.enabled===1?'btn-warning':'btn-success'}" style="padding:3px 10px;font-size:12px;margin:0 4px" onclick="toggleMapping('${m.id}')">${m.enabled===1?'禁用':'启用'}</button>
|
||||
<button class="btn btn-default" style="padding:3px 10px;font-size:12px;color:#f5222d" onclick="deleteMapping('${m.id}')">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function openMappingModal(m) {
|
||||
document.getElementById('modalTitle').textContent = m ? '编辑映射配置' : '新增映射配置';
|
||||
document.getElementById('mappingId').value = m ? m.id : '';
|
||||
document.getElementById('f-dbName').value = m ? m.dbName : '';
|
||||
document.getElementById('f-tableName').value = m ? m.tableName : '';
|
||||
document.getElementById('f-idCol').value = m ? m.idCol : 'id';
|
||||
document.getElementById('f-fileCol').value = m ? m.fileCol : '';
|
||||
document.getElementById('f-bizPath').value = m ? (m.bizPath||'') : '';
|
||||
document.getElementById('f-remark').value = m ? (m.remark||'') : '';
|
||||
document.getElementById('mappingModal').classList.add('show');
|
||||
}
|
||||
|
||||
function closeMappingModal() {
|
||||
document.getElementById('mappingModal').classList.remove('show');
|
||||
}
|
||||
|
||||
async function saveMapping() {
|
||||
const id = document.getElementById('mappingId').value;
|
||||
const body = {
|
||||
dbName: document.getElementById('f-dbName').value.trim(),
|
||||
tableName: document.getElementById('f-tableName').value.trim(),
|
||||
idCol: document.getElementById('f-idCol').value.trim() || 'id',
|
||||
fileCol: document.getElementById('f-fileCol').value.trim(),
|
||||
bizPath: document.getElementById('f-bizPath').value.trim() || null,
|
||||
remark: document.getElementById('f-remark').value.trim(),
|
||||
};
|
||||
if (id) body.id = id;
|
||||
if (!body.dbName || !body.tableName || !body.fileCol) { alert('数据库名、业务表名、文件路径列为必填'); return; }
|
||||
try {
|
||||
const res = await fetch('/mapping/save', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
||||
const json = await res.json();
|
||||
if (json.code !== 200) { alert('保存失败:' + json.msg); return; }
|
||||
closeMappingModal();
|
||||
loadMappings();
|
||||
} catch(e) { alert('请求失败:' + e.message); }
|
||||
}
|
||||
|
||||
async function toggleMapping(id) {
|
||||
try {
|
||||
const res = await fetch('/mapping/toggle/' + id, { method:'POST' });
|
||||
const json = await res.json();
|
||||
if (json.code !== 200) { alert('操作失败:' + json.msg); return; }
|
||||
loadMappings();
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function deleteMapping(id) {
|
||||
if (!confirm('确认删除该映射配置?')) return;
|
||||
try {
|
||||
const res = await fetch('/mapping/delete/' + id, { method:'DELETE' });
|
||||
const json = await res.json();
|
||||
if (json.code !== 200) { alert('删除失败:' + json.msg); return; }
|
||||
loadMappings();
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// ===================== Harbor 镜像 =====================
|
||||
async function loadHarbor() {
|
||||
try {
|
||||
const res = await fetch('/devops/harbor/repositories');
|
||||
const json = await res.json();
|
||||
if (json.code !== 200) return;
|
||||
const tbody = document.getElementById('harborTableBody');
|
||||
const list = json.data || [];
|
||||
if (!list.length) { tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:#aaa;padding:30px">暂无数据</td></tr>'; return; }
|
||||
tbody.innerHTML = list.map(r => {
|
||||
const shortName = (r.name || '').replace(/^[^/]+\//, '');
|
||||
return `<tr>
|
||||
<td>${shortName}</td>
|
||||
<td>${r.artifact_count ?? '-'}</td>
|
||||
<td><span id="tag-${shortName}" style="color:#aaa">加载中...</span></td>
|
||||
<td style="color:#aaa;font-size:12px">${(r.update_time||'').replace('T',' ').substring(0,19)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
// 渲染完列表后异步逐条加载最新 tag
|
||||
list.forEach(r => {
|
||||
const shortName = (r.name || '').replace(/^[^/]+\//, '');
|
||||
loadLatestTag(shortName);
|
||||
});
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function loadLatestTag(repo) {
|
||||
try {
|
||||
const res = await fetch('/devops/harbor/latest-tag?repo=' + encodeURIComponent(repo));
|
||||
const json = await res.json();
|
||||
const el = document.getElementById('tag-' + repo);
|
||||
if (!el) return;
|
||||
if (json.code === 200 && json.data) {
|
||||
const tags = json.data.tags;
|
||||
el.textContent = tags && tags.length ? tags[0].name : '-';
|
||||
el.style.color = '#1677ff';
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function openPushModal() {
|
||||
document.getElementById('push-repo').value = '';
|
||||
document.getElementById('push-tag').value = '';
|
||||
document.getElementById('push-file').value = '';
|
||||
document.getElementById('push-result').textContent = '';
|
||||
document.getElementById('pushModal').classList.add('show');
|
||||
}
|
||||
|
||||
async function doPushImage() {
|
||||
const repo = document.getElementById('push-repo').value.trim();
|
||||
const tag = document.getElementById('push-tag').value.trim();
|
||||
const fileInput = document.getElementById('push-file');
|
||||
if (!repo) { alert('请填写目标仓库名'); return; }
|
||||
if (!fileInput.files.length) { alert('请选择 tar 包文件'); return; }
|
||||
const btn = document.getElementById('pushBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '上传中...';
|
||||
document.getElementById('push-result').textContent = '正在上传,请稍候...';
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('repo', repo);
|
||||
if (tag) fd.append('tag', tag);
|
||||
fd.append('file', fileInput.files[0]);
|
||||
const res = await fetch('/devops/harbor/push', { method: 'POST', body: fd });
|
||||
const json = await res.json();
|
||||
if (json.code === 200) {
|
||||
document.getElementById('push-result').textContent = '✅ 推送成功!Tag: ' + json.data.tag + '\n' + (json.data.output || '');
|
||||
loadHarbor();
|
||||
} else {
|
||||
document.getElementById('push-result').textContent = '❌ 失败:' + json.msg;
|
||||
}
|
||||
} catch(e) {
|
||||
document.getElementById('push-result').textContent = '❌ 请求失败:' + e.message;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '开始上传';
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 控制台实例 =====================
|
||||
async function loadInstances() {
|
||||
try {
|
||||
const res = await fetch('/devops/console/instances', { method: 'POST' });
|
||||
const json = await res.json();
|
||||
if (json.code !== 200) return;
|
||||
const list = (json.data && json.data.content) || [];
|
||||
const tbody = document.getElementById('instanceTableBody');
|
||||
if (!list.length) { tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:#aaa;padding:30px">暂无数据</td></tr>'; return; }
|
||||
tbody.innerHTML = list.map(inst => {
|
||||
const statusColor = inst.status === 'running' ? '#52c41a' : inst.status === 'failed' ? '#f5222d' : '#fa8c16';
|
||||
return `<tr>
|
||||
<td>${inst.appServiceName || '-'}</td>
|
||||
<td style="font-size:12px;color:#888">${inst.code || '-'}</td>
|
||||
<td><span style="color:${statusColor};font-weight:500">${inst.status || '-'}</span></td>
|
||||
<td style="font-size:12px">${inst.commandVersion || inst.versionName || '-'}</td>
|
||||
<td style="color:#aaa;font-size:12px">${(inst.lastUpdateDate||'').substring(0,16)}</td>
|
||||
<td>
|
||||
<button class="btn btn-default" style="padding:3px 10px;font-size:12px" onclick="openYamlModal('${inst.id}','${inst.appServiceVersionId||inst.commandVersionId||''}','${inst.appServiceId||''}')">查看配置</button>
|
||||
<button class="btn btn-default" style="padding:3px 10px;font-size:12px;margin-left:4px" onclick="openPodModal('${inst.id}','${inst.appServiceName||inst.code}')">Pod</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function openYamlModal(instanceId, versionId, appServiceId) {
|
||||
document.getElementById('yaml-instanceId').value = instanceId;
|
||||
document.getElementById('yaml-versionId').value = versionId;
|
||||
document.getElementById('yaml-appServiceId').value = appServiceId;
|
||||
document.getElementById('yaml-content').value = '加载中...';
|
||||
document.getElementById('yamlModal').classList.add('show');
|
||||
try {
|
||||
const res = await fetch('/devops/console/yaml?instanceId=' + instanceId + '&versionId=' + versionId);
|
||||
const json = await res.json();
|
||||
if (json.code === 200 && json.data) {
|
||||
document.getElementById('yaml-content').value = json.data.yaml || '';
|
||||
document.getElementById('yaml-envId').value = json.data.envId || '';
|
||||
document.getElementById('yaml-appServiceVersionId').value = json.data.id || versionId;
|
||||
document.getElementById('yaml-objectVersionNumber').value = json.data.objectVersionNumber || 1;
|
||||
} else {
|
||||
document.getElementById('yaml-content').value = '加载失败:' + (json.msg || '');
|
||||
}
|
||||
} catch(e) { document.getElementById('yaml-content').value = '加载失败:' + e.message; }
|
||||
}
|
||||
|
||||
async function doUpdateYaml() {
|
||||
const payload = {
|
||||
values: document.getElementById('yaml-content').value,
|
||||
instanceId: document.getElementById('yaml-instanceId').value,
|
||||
type: 'update',
|
||||
environmentId: document.getElementById('yaml-envId').value,
|
||||
appServiceId: document.getElementById('yaml-appServiceId').value,
|
||||
appServiceVersionId: document.getElementById('yaml-appServiceVersionId').value,
|
||||
};
|
||||
try {
|
||||
const res = await fetch('/devops/console/yaml', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.code === 200) {
|
||||
alert('更新成功!');
|
||||
document.getElementById('yamlModal').classList.remove('show');
|
||||
loadInstances();
|
||||
} else {
|
||||
alert('更新失败:' + json.msg);
|
||||
}
|
||||
} catch(e) { alert('请求失败:' + e.message); }
|
||||
}
|
||||
|
||||
async function openPodModal(instanceId, name) {
|
||||
document.getElementById('podModalTitle').textContent = 'Pod 列表 - ' + name;
|
||||
document.getElementById('podTableBody').innerHTML = '<tr><td colspan="5" style="text-align:center;color:#aaa;padding:20px">加载中...</td></tr>';
|
||||
document.getElementById('podModal').classList.add('show');
|
||||
try {
|
||||
const res = await fetch('/devops/console/pods', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'instanceId=' + encodeURIComponent(instanceId)
|
||||
});
|
||||
const json = await res.json();
|
||||
const list = (json.data && json.data.content) || [];
|
||||
if (!list.length) {
|
||||
document.getElementById('podTableBody').innerHTML = '<tr><td colspan="5" style="text-align:center;color:#aaa;padding:20px">暂无 Pod</td></tr>';
|
||||
return;
|
||||
}
|
||||
document.getElementById('podTableBody').innerHTML = list.map(p => {
|
||||
const sc = p.status === 'Running' ? '#52c41a' : p.status === 'Pending' ? '#fa8c16' : '#f5222d';
|
||||
return `<tr>
|
||||
<td style="font-size:12px">${p.name||'-'}</td>
|
||||
<td>${p.ip||'-'}</td>
|
||||
<td><span style="color:${sc};font-weight:500">${p.status||'-'}</span></td>
|
||||
<td>${p.ready ? '✅' : '❌'}</td>
|
||||
<td style="color:#aaa;font-size:12px">${(p.creationDate||'').substring(0,16)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
} catch(e) {
|
||||
document.getElementById('podTableBody').innerHTML = '<tr><td colspan="5" style="color:#f5222d;padding:20px">加载失败:' + e.message + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 初始化 =====================
|
||||
loadLogs(1);
|
||||
loadTaskHistory();
|
||||
</script></#noparse>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,89 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>登录 - OSS 文件同步管理</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Microsoft YaHei", sans-serif;
|
||||
background: #f0f2f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.login-wrapper { width: 380px; }
|
||||
.login-title {
|
||||
text-align: center;
|
||||
color: #1677ff;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.login-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||||
padding: 36px 40px;
|
||||
}
|
||||
.form-group { margin-bottom: 18px; }
|
||||
.form-group label { display: block; font-size: 13px; color: #666; margin-bottom: 6px; }
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 10px 13px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.form-group input:focus { border-color: #1677ff; }
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
margin-top: 6px;
|
||||
transition: background 0.2s;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.btn-login:hover { background: #4096ff; }
|
||||
.error-msg {
|
||||
background: #fff0f0;
|
||||
border: 1px solid #ffcccc;
|
||||
color: #d32f2f;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-wrapper">
|
||||
<div class="login-title">OSS 文件同步管理</div>
|
||||
<div class="login-card">
|
||||
<#if errorMsg?has_content>
|
||||
<div class="error-msg">${errorMsg}</div>
|
||||
</#if>
|
||||
<form method="post" action="${basePath}/auth/doLogin">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" name="username" placeholder="请输入用户名" autofocus required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input type="password" name="password" placeholder="请输入密码" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-login">登 录</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
package features;
|
||||
|
||||
import com.yxtech.ossyn.App;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.noear.solon.test.HttpTester;
|
||||
import org.noear.solon.test.SolonTest;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@SolonTest(App.class)
|
||||
public class HelloTest extends HttpTester {
|
||||
@Test
|
||||
public void hello() throws IOException {
|
||||
assert path("/hello?name=world").get().contains("world");
|
||||
assert path("/hello?name=solon").get().contains("solon");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user