Compare commits

5 Commits
Author SHA1 Message Date
lianlonggangandClaude Opus 4.7 1bfda7bbe9 fix: 修复skopeo Windows路径 + 回写阶段无效表过滤 + 清理调试代码
- HarborService: skopeo push 改用工作目录+相对路径,规避 Windows 盘符路径问题
- OssSyncService: 扫描阶段跳过已回写路径避免重复处理
- OssSyncService: 回写阶段先过滤有效映射表,避免加载无效数据
- app.yml: 修复 server.request.maxBodySize 配置格式,补充 maxFileSize/maxRequestSize
- App.java: 删除注释掉的 Choerodon 登录调试代码

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 10:54:30 +08:00
lianlonggangandClaude Sonnet 4.6 036d7488ad fix: json文件上传重命名为txt + 扫描阶段增量路径变更修复
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 15:32:17 +08:00
lianlonggangandClaude Sonnet 4.6 e0231fca8f feat: 新增镜像管理 + 控制台实例管理功能
- 新增 Harbor 镜像仓库查询、最新 tag 查询、skopeo 推送接口
- 新增 Choerodon DevOps 实例列表、Pod 状态、YAML 配置查询与更新接口
- 修复全局异常过滤器 404 日志刷屏问题
- 前端新增「镜像管理」和「控制台实例」Tab 页

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 18:52:16 +08:00
lianlonggang 2f068c6a5f feat: 新增 sa-token 登录鉴权 + 映射配置管理 + 清理接口
- 新增 sa-token 登录鉴权(admin/mD8C-gR8UgRE8fNvJWgmjg)
- 新增 BizFileMappingEntity/Mapper/Controller,映射配置改为数据库管理
- 新增 bizPath 字段,支持每张表单独配置 OSS 业务路径
- 新增文件日志/任务历史清理接口
- 新增全局异常过滤器
- 删除 BizFileMappingConfig(yml 配置方式废弃)
- 前端新增映射配置 Tab、登录页、退出登录
2026-04-16 19:17:59 +08:00
lianlonggangandClaude Sonnet 4.6 3fddada267 feat: 初始化 xj-oss-syn 项目
- OSS 文件同步核心功能:扫描业务表、上传文件、回写路径
- 业务文件映射配置表(BIZ_FILE_MAPPING),支持动态管理、启用/禁用、自定义 bizPath
- 同步任务异步执行,前端实时轮询进度
- 全局异常过滤器,统一错误日志输出
- 文件日志/任务历史清理接口
- 管理页面:同步管理 + 映射配置 Tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 18:55:44 +08:00
35 changed files with 3909 additions and 24 deletions
+25 -22
View File
@@ -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
+149
View File
@@ -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
- ORMMyBatis-Plussolon 插件)
- 数据库:MySQL 8.xHikariCP 连接池)
- HTTP 客户端:Java 内置 `java.net.http.HttpClient`trust-all SSLContext,支持 NEVER/NORMAL 重定向策略)
- JSONFastjson2
- 认证: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 控制台 APItoken 缓存 + 401 重试)
│ ├── HarborService.java # Harbor API + skopeo pushtrust-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 | 仅回写业务表 |
### DevOpsDevopsController
| 接口 | 说明 |
|------|------|
| GET /devops/harbor/repositories | 查询 Harbor 镜像仓库列表 |
| GET /devops/harbor/latest-tag?repo= | 查询指定仓库最新 tag |
| POST /devops/harbor/push | 上传 tar 包并推送到 Harbormultipart,自动计算下一个 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`)。
-2
View File
@@ -1,2 +0,0 @@
# xj-oss-syn
+148
View File
@@ -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>
+56
View File
@@ -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='业务文件映射配置表';
+24
View File
@@ -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
* 参数:filemultipart)、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();
// 使用 authClientNEVER 重定向),确保能拿到 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();
// 使用 authClientNEVER 重定向),确保能拿到 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 任务IDString,避免前端精度丢失)
*/
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;
}
}
}
+118
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
div{font-size: 1.5em;}
+912
View File
@@ -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,'&quot;')})">编辑</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>
+89
View File
@@ -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>
+19
View File
@@ -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");
}
}