feat: 初始化 xj-oss-syn 项目
- OSS 文件同步核心功能:扫描业务表、上传文件、回写路径 - 业务文件映射配置表(BIZ_FILE_MAPPING),支持动态管理、启用/禁用、自定义 bizPath - 同步任务异步执行,前端实时轮询进度 - 全局异常过滤器,统一错误日志输出 - 文件日志/任务历史清理接口 - 管理页面:同步管理 + 映射配置 Tab Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+29
@@ -0,0 +1,29 @@
|
||||
target/
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
|
||||
### STS ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
*.log
|
||||
*.flattened-pom.xml
|
||||
|
||||
### NetBeans ###
|
||||
nbproject/private/
|
||||
build/
|
||||
nbbuild/
|
||||
dist/
|
||||
nbdist/
|
||||
.nb-gradle/
|
||||
|
||||
### Mac files ###
|
||||
*.DS_Store
|
||||
@@ -0,0 +1,141 @@
|
||||
<?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>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.noear</groupId>
|
||||
<artifactId>solon-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>${project.artifactId}</finalName>
|
||||
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.noear</groupId>
|
||||
<artifactId>solon-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>tencent</id>
|
||||
<url>https://mirrors.cloud.tencent.com/nexus/repository/maven-public/</url>
|
||||
<snapshots>
|
||||
<enabled>false</enabled>
|
||||
</snapshots>
|
||||
</repository>
|
||||
</repositories>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,56 @@
|
||||
-- ============================================================
|
||||
-- QH_OSS_FILE_LOG 文件转换日志表
|
||||
-- 数据源1(本项目数据库)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS `QH_OSS_FILE_LOG` (
|
||||
`ID` BIGINT NOT NULL COMMENT '主键(雪花ID)',
|
||||
`OSS_PATH` VARCHAR(512) DEFAULT NULL COMMENT 'OSS路径(上传成功后填入)',
|
||||
`BIZ_TAB_NAME` VARCHAR(128) NOT NULL COMMENT '业务表名',
|
||||
`BIZ_TAB_ID` VARCHAR(64) NOT NULL COMMENT '业务表主键值',
|
||||
`BIZ_COL_NAME` VARCHAR(128) NOT NULL COMMENT '业务表列名(文件路径字段)',
|
||||
`BIZ_TAB_PATH` VARCHAR(512) DEFAULT NULL COMMENT '业务表中原始文件路径',
|
||||
`BIZ_HANDLE_STATUS` TINYINT NOT NULL DEFAULT 0 COMMENT '处理状态:0=待处理,1=上传成功,2=上传失败,3=文件不存在,4=已回写',
|
||||
`CREATE_TIME` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`UPDATE_TIME` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`ID`),
|
||||
INDEX `idx_biz_tab` (`BIZ_TAB_NAME`, `BIZ_TAB_ID`),
|
||||
INDEX `idx_status` (`BIZ_HANDLE_STATUS`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件转换日志表';
|
||||
|
||||
-- ============================================================
|
||||
-- SYNC_TASK 同步任务记录表
|
||||
-- 记录每次触发的同步任务进度,供前端轮询展示
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS `SYNC_TASK` (
|
||||
`ID` BIGINT NOT NULL COMMENT '主键(雪花ID)',
|
||||
`TASK_TYPE` VARCHAR(32) NOT NULL COMMENT '任务类型:all/scan/upload/write-back',
|
||||
`STATUS` TINYINT NOT NULL DEFAULT 0 COMMENT '0=进行中,1=完成,2=失败',
|
||||
`TOTAL` INT NOT NULL DEFAULT 0 COMMENT '总处理条数',
|
||||
`SUCCESS` INT NOT NULL DEFAULT 0 COMMENT '成功条数',
|
||||
`FAIL` INT NOT NULL DEFAULT 0 COMMENT '失败条数',
|
||||
`MESSAGE` VARCHAR(512) DEFAULT NULL COMMENT '备注/错误信息',
|
||||
`CREATE_TIME` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`UPDATE_TIME` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`ID`),
|
||||
INDEX `idx_status` (`STATUS`),
|
||||
INDEX `idx_create_time` (`CREATE_TIME`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同步任务记录表';
|
||||
|
||||
-- ============================================================
|
||||
-- BIZ_FILE_MAPPING 业务文件映射配置表
|
||||
-- 替代 app.yml 中的 biz-file-mappings 配置,支持动态管理
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS `BIZ_FILE_MAPPING` (
|
||||
`ID` BIGINT NOT NULL COMMENT '主键(雪花ID)',
|
||||
`DB_NAME` VARCHAR(128) NOT NULL COMMENT '数据库名',
|
||||
`TABLE_NAME` VARCHAR(128) NOT NULL COMMENT '业务表名',
|
||||
`ID_COL` VARCHAR(64) NOT NULL DEFAULT 'id' COMMENT '主键列名',
|
||||
`FILE_COL` VARCHAR(128) NOT NULL COMMENT '文件路径列名',
|
||||
`BIZ_PATH` VARCHAR(128) DEFAULT NULL COMMENT 'OSS上传业务路径(不填则降级用表名)',
|
||||
`ENABLED` TINYINT NOT NULL DEFAULT 0 COMMENT '是否有效:0=无效,1=有效',
|
||||
`REMARK` VARCHAR(256) DEFAULT NULL COMMENT '备注',
|
||||
`CREATE_TIME` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`UPDATE_TIME` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`ID`),
|
||||
INDEX `idx_enabled` (`ENABLED`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务文件映射配置表';
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.yxtech.ossyn;
|
||||
|
||||
import org.noear.solon.Solon;
|
||||
import org.noear.solon.annotation.SolonMain;
|
||||
|
||||
/**
|
||||
* 应用程序入口
|
||||
*/
|
||||
@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,35 @@
|
||||
package com.yxtech.ossyn.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.noear.solon.annotation.Configuration;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 业务文件映射配置
|
||||
* 对应 app.yml 中的 biz-file-mappings 节点
|
||||
*/
|
||||
@Data
|
||||
@Configuration
|
||||
public class BizFileMappingConfig {
|
||||
|
||||
/** 业务表映射列表 */
|
||||
@Inject("${biz-file-mappings}")
|
||||
private List<BizFileMapping> mappings;
|
||||
|
||||
/**
|
||||
* 单条业务表映射配置
|
||||
*/
|
||||
@Data
|
||||
public static class BizFileMapping {
|
||||
/** 数据库名 */
|
||||
private String dbName;
|
||||
/** 业务表名 */
|
||||
private String tableName;
|
||||
/** 主键列名 */
|
||||
private String idCol;
|
||||
/** 文件路径列名 */
|
||||
private String fileCol;
|
||||
}
|
||||
}
|
||||
@@ -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,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,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,34 @@
|
||||
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 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) {
|
||||
log.error("[GlobalFilter] 请求异常 {} {} params={}",
|
||||
ctx.method(), ctx.path(), ctx.paramMap(), e);
|
||||
ctx.status(500);
|
||||
Map<String, Object> result = new HashMap<>(2);
|
||||
result.put("code", 500);
|
||||
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,367 @@
|
||||
package com.yxtech.ossyn.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.yxtech.ossyn.config.LocalFileConfig;
|
||||
import com.yxtech.ossyn.entity.BizFileMappingEntity;
|
||||
import com.yxtech.ossyn.mapper.BizFileMappingMapper;
|
||||
import com.yxtech.ossyn.entity.QhOssFileLog;
|
||||
import com.yxtech.ossyn.entity.SyncTask;
|
||||
import com.yxtech.ossyn.mapper.QhOssFileLogMapper;
|
||||
import com.yxtech.ossyn.mapper.SyncTaskMapper;
|
||||
import com.yxtech.ossyn.util.DynamicDataSourceUtil;
|
||||
import com.yxtech.ossyn.util.OssUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.noear.solon.annotation.Component;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* OSS 同步 Service
|
||||
* 负责:扫描业务表 → 写入日志 → 上传文件 → 回写业务表
|
||||
* 所有触发操作均异步执行,通过 SYNC_TASK 表实时记录进度
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class OssSyncService {
|
||||
|
||||
@Inject
|
||||
private BizFileMappingMapper bizFileMappingMapper;
|
||||
|
||||
@Inject
|
||||
private LocalFileConfig localFileConfig;
|
||||
|
||||
@Inject
|
||||
private DynamicDataSourceUtil dynamicDataSourceUtil;
|
||||
|
||||
@Inject
|
||||
private OssUtil ossUtil;
|
||||
|
||||
@Inject
|
||||
private QhOssFileLogMapper ossFileLogMapper;
|
||||
|
||||
@Inject
|
||||
private SyncTaskMapper syncTaskMapper;
|
||||
|
||||
/** 单线程异步执行器,保证同一时刻只有一个同步任务在跑 */
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread t = new Thread(r, "oss-sync-worker");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
// ===================== 异步触发入口 =====================
|
||||
|
||||
/**
|
||||
* 异步触发全量同步(扫描 → 上传 → 回写)
|
||||
*
|
||||
* @return 任务ID(String,避免前端精度丢失)
|
||||
*/
|
||||
public String asyncSyncAll() {
|
||||
SyncTask task = createTask("all");
|
||||
executor.submit(() -> doSyncAll(task));
|
||||
return String.valueOf(task.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步触发扫描阶段
|
||||
*/
|
||||
public String asyncScan() {
|
||||
SyncTask task = createTask("scan");
|
||||
executor.submit(() -> doScan(task));
|
||||
return String.valueOf(task.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步触发上传阶段
|
||||
*/
|
||||
public String asyncUpload() {
|
||||
SyncTask task = createTask("upload");
|
||||
executor.submit(() -> doUpload(task));
|
||||
return String.valueOf(task.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步触发回写阶段
|
||||
*/
|
||||
public String asyncWriteBack() {
|
||||
SyncTask task = createTask("write-back");
|
||||
executor.submit(() -> doWriteBack(task));
|
||||
return String.valueOf(task.getId());
|
||||
}
|
||||
|
||||
// ===================== 实际执行逻辑 =====================
|
||||
|
||||
private void doSyncAll(SyncTask task) {
|
||||
try {
|
||||
updateMessage(task.getId(), "扫描业务表中...");
|
||||
int scanned = scanAndRecord(task);
|
||||
updateMessage(task.getId(), "扫描完成,新增 " + scanned + " 条;上传文件中...");
|
||||
int uploaded = uploadPending(task);
|
||||
updateMessage(task.getId(), "上传完成,成功 " + uploaded + " 条;回写业务表中...");
|
||||
int written = writeBack(task);
|
||||
finishTask(task.getId(), SyncTask.STATUS_DONE,
|
||||
"全量同步完成:扫描新增 " + scanned + " 条,上传成功 " + uploaded + " 条,回写成功 " + written + " 条");
|
||||
} catch (Exception e) {
|
||||
log.error("[OssSyncService] 全量同步异常", e);
|
||||
finishTask(task.getId(), SyncTask.STATUS_FAIL, "执行异常:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void doScan(SyncTask task) {
|
||||
try {
|
||||
updateMessage(task.getId(), "扫描业务表中...");
|
||||
int count = scanAndRecord(task);
|
||||
finishTask(task.getId(), SyncTask.STATUS_DONE, "扫描完成,新增日志 " + count + " 条");
|
||||
} catch (Exception e) {
|
||||
log.error("[OssSyncService] 扫描异常", e);
|
||||
finishTask(task.getId(), SyncTask.STATUS_FAIL, "执行异常:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void doUpload(SyncTask task) {
|
||||
try {
|
||||
updateMessage(task.getId(), "上传文件中...");
|
||||
int count = uploadPending(task);
|
||||
finishTask(task.getId(), SyncTask.STATUS_DONE, "上传完成,成功 " + count + " 条");
|
||||
} catch (Exception e) {
|
||||
log.error("[OssSyncService] 上传异常", e);
|
||||
finishTask(task.getId(), SyncTask.STATUS_FAIL, "执行异常:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void doWriteBack(SyncTask task) {
|
||||
try {
|
||||
updateMessage(task.getId(), "回写业务表中...");
|
||||
int count = writeBack(task);
|
||||
finishTask(task.getId(), SyncTask.STATUS_DONE, "回写完成,成功 " + count + " 条");
|
||||
} catch (Exception e) {
|
||||
log.error("[OssSyncService] 回写异常", e);
|
||||
finishTask(task.getId(), SyncTask.STATUS_FAIL, "执行异常:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 核心业务逻辑 =====================
|
||||
|
||||
/**
|
||||
* 扫描所有配置的业务表,将文件路径记录写入日志(幂等)
|
||||
*/
|
||||
private int scanAndRecord(SyncTask task) {
|
||||
int total = 0;
|
||||
// 从数据库读取有效的映射配置
|
||||
List<BizFileMappingEntity> mappings = bizFileMappingMapper.selectList(
|
||||
new LambdaQueryWrapper<BizFileMappingEntity>()
|
||||
.eq(BizFileMappingEntity::getEnabled, BizFileMappingEntity.ENABLED_YES));
|
||||
if (mappings == null || mappings.isEmpty()) {
|
||||
log.warn("[OssSyncService] 无有效的业务表映射配置,跳过扫描");
|
||||
return 0;
|
||||
}
|
||||
for (BizFileMappingEntity mapping : mappings) {
|
||||
List<Map<String, String>> rows = dynamicDataSourceUtil.queryFilePaths(
|
||||
mapping.getDbName(), mapping.getTableName(),
|
||||
mapping.getIdCol(), mapping.getFileCol());
|
||||
for (Map<String, String> row : rows) {
|
||||
String bizId = row.get("id");
|
||||
String filePath = row.get("filePath");
|
||||
Long exists = ossFileLogMapper.selectCount(new LambdaQueryWrapper<QhOssFileLog>()
|
||||
.eq(QhOssFileLog::getBizTabName, mapping.getTableName())
|
||||
.eq(QhOssFileLog::getBizTabId, bizId)
|
||||
.eq(QhOssFileLog::getBizColName, mapping.getFileCol()));
|
||||
if (exists > 0) {
|
||||
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<QhOssFileLog> uploaded = ossFileLogMapper.selectList(
|
||||
new LambdaQueryWrapper<QhOssFileLog>()
|
||||
.eq(QhOssFileLog::getBizHandleStatus, QhOssFileLog.STATUS_UPLOADED));
|
||||
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()));
|
||||
// 从数据库读取所有映射配置(回写不限有效/无效,按表名匹配)
|
||||
List<BizFileMappingEntity> mappings = bizFileMappingMapper.selectList(null);
|
||||
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,253 @@
|
||||
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();
|
||||
// }
|
||||
String nowday = new SimpleDateFormat("yyyyMMdd").format(new Date());
|
||||
final String finalFileName = DIR + bizPath + "/" + nowday + "/" + fileName;
|
||||
return doUpload(stream, url, finalFileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行实际上传,401 时刷新 token 重试一次
|
||||
*/
|
||||
private String doUpload(InputStream stream, String url, String fileName) {
|
||||
try (CloseableHttpClient client = HttpClients.createDefault()) {
|
||||
URI uri = new URIBuilder(url)
|
||||
.addParameter("directory", ossConfig.getDirectory())
|
||||
.addParameter("fileName", fileName)
|
||||
.addParameter("docType", "0")
|
||||
.build();
|
||||
HttpPost post = new HttpPost(uri);
|
||||
String token = getToken();
|
||||
post.addHeader("Authorization", "Bearer " + token);
|
||||
post.addHeader("accept", "application/json;charset=utf-8");
|
||||
post.setEntity(MultipartEntityBuilder.create()
|
||||
.addBinaryBody("file", stream, ContentType.APPLICATION_OCTET_STREAM, fileName)
|
||||
.build());
|
||||
try (CloseableHttpResponse resp = client.execute(post)) {
|
||||
int status = resp.getCode();
|
||||
if (status == 401) {
|
||||
log.warn("[OssUtil] 上传时 token 过期,重新鉴权后重试");
|
||||
cachedToken = null;
|
||||
tokenExpireAt = 0;
|
||||
return doUpload(stream, url, fileName);
|
||||
}
|
||||
if (status != 200) {
|
||||
log.error("[OssUtil] 文件上传失败,HTTP状态码: {}", status);
|
||||
return null;
|
||||
}
|
||||
String fileUrl = EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8);
|
||||
// 判断业务错误
|
||||
try {
|
||||
JSONObject resultJson = JSON.parseObject(fileUrl);
|
||||
if (Boolean.TRUE.equals(resultJson.getBoolean("failed"))) {
|
||||
log.error("[OssUtil] 文件上传业务失败: {}", fileUrl);
|
||||
return null;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
String fileKey = extractFileKey(fileUrl);
|
||||
log.info("[OssUtil] 文件上传成功,fileKey: {}", fileKey);
|
||||
return fileKey;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[OssUtil] 文件上传异常", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 私有工具方法 =====================
|
||||
|
||||
/**
|
||||
* 文件类型白名单校验
|
||||
*/
|
||||
private void checkFileLegitimacy(String fileName) {
|
||||
String whiteList = ossConfig.getFileTypeWhiteList();
|
||||
if (whiteList != null && !whiteList.isBlank()) {
|
||||
int dotIdx = fileName.lastIndexOf('.');
|
||||
if (dotIdx < 0) {
|
||||
throw new IllegalArgumentException("文件上传失败:不能识别的文件类型");
|
||||
}
|
||||
String ext = fileName.substring(dotIdx + 1).toLowerCase();
|
||||
List<String> allowed = Arrays.asList(whiteList.split(","));
|
||||
if (!allowed.contains(ext)) {
|
||||
throw new IllegalArgumentException("文件上传失败:不支持的文件类型 " + ext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从上传接口返回的完整 URL 中截取 fileKey
|
||||
*/
|
||||
private String extractFileKey(String fileUrl) {
|
||||
int atIndex = fileUrl.indexOf("@");
|
||||
if (atIndex < 0) {
|
||||
return fileUrl;
|
||||
}
|
||||
int slashIndex = fileUrl.lastIndexOf("/", atIndex);
|
||||
if (slashIndex < 0) {
|
||||
return fileUrl;
|
||||
}
|
||||
return fileUrl.substring(slashIndex + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件预览地址(Location 重定向 URL)
|
||||
*
|
||||
* @param fileKey 短文件 key
|
||||
* @return 预览 URL,失败返回 null
|
||||
*/
|
||||
public String show(String fileKey) {
|
||||
String dir = ossConfig.getDirectory();
|
||||
if (!dir.endsWith("/")) {
|
||||
dir = dir + "/";
|
||||
}
|
||||
String fullFileKey = dir + "0/" + fileKey;
|
||||
String url = ossConfig.getGatewayUrl() + "/hfle/v2/" + ossConfig.getOrganizationId()
|
||||
+ "/files/" + ossConfig.getConfigCode() + "/download-by-key";
|
||||
try (CloseableHttpClient client = HttpClients.createDefault()) {
|
||||
URI uri = new URIBuilder(url).addParameter("fileKey", fullFileKey).build();
|
||||
HttpGet get = new HttpGet(uri);
|
||||
get.addHeader("Authorization", "Bearer " + getToken());
|
||||
try (CloseableHttpResponse resp = client.execute(get)) {
|
||||
int status = resp.getCode();
|
||||
if (status == 401) {
|
||||
cachedToken = null;
|
||||
tokenExpireAt = 0;
|
||||
return show(fileKey);
|
||||
}
|
||||
if (status != 302 && status != 303) {
|
||||
log.error("[OssUtil] download-by-key 返回非预期状态码: {}", status);
|
||||
return null;
|
||||
}
|
||||
Header location = resp.getFirstHeader("Location");
|
||||
if (location == null) {
|
||||
log.error("[OssUtil] download-by-key 未返回 Location 响应头");
|
||||
return null;
|
||||
}
|
||||
return location.getValue();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[OssUtil] 获取预览地址异常", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
server.port: 8080
|
||||
|
||||
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"
|
||||
|
||||
# =====================================
|
||||
# 业务表映射配置
|
||||
# 格式:数据库名 业务表名 业务表ID列名 业务文件路径列名
|
||||
# =====================================
|
||||
biz-file-mappings:
|
||||
# - dbName: dmz_health_system
|
||||
# tableName: sys_user
|
||||
# idCol: id
|
||||
# fileCol: avatar
|
||||
- dbName: dmz_health_consult
|
||||
tableName: con_resource
|
||||
idCol: id
|
||||
fileCol: img
|
||||
|
||||
# =====================================
|
||||
# 本地文件根路径(业务表中存储的是相对路径,拼接此前缀查找文件)
|
||||
# =====================================
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
div{font-size: 1.5em;}
|
||||
@@ -0,0 +1,622 @@
|
||||
<!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">OSS 文件同步管理</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>
|
||||
</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 -->
|
||||
|
||||
</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'][i] === name);
|
||||
});
|
||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||
document.getElementById('tab-' + name).classList.add('active');
|
||||
if (name === 'mapping') loadMappings();
|
||||
}
|
||||
|
||||
// ===================== 映射配置 =====================
|
||||
async function loadMappings() {
|
||||
try {
|
||||
const res = await fetch('/mapping/list');
|
||||
const json = await res.json();
|
||||
if (json.code !== 200) return;
|
||||
renderMappings(json.data);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function renderMappings(list) {
|
||||
const tbody = document.getElementById('mappingTableBody');
|
||||
if (!list || list.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:#aaa;padding:30px">暂无数据,点击「新增映射」添加</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = list.map(m => `
|
||||
<tr>
|
||||
<td style="font-size:11px;color:#aaa">${m.id}</td>
|
||||
<td>${m.dbName}</td>
|
||||
<td>${m.tableName}</td>
|
||||
<td>${m.idCol}</td>
|
||||
<td>${m.fileCol}</td>
|
||||
<td>${m.bizPath||'-'}</td>
|
||||
<td><span class="tag ${m.enabled===1?'tag-enabled':'tag-disabled'}">${m.enabled===1?'有效':'无效'}</span></td>
|
||||
<td style="color:#888;font-size:12px">${m.remark||'-'}</td>
|
||||
<td>
|
||||
<button class="btn btn-default" style="padding:3px 10px;font-size:12px" onclick="openMappingModal(${JSON.stringify(m).replace(/"/g,'"')})">编辑</button>
|
||||
<button class="btn ${m.enabled===1?'btn-warning':'btn-success'}" style="padding:3px 10px;font-size:12px;margin:0 4px" onclick="toggleMapping('${m.id}')">${m.enabled===1?'禁用':'启用'}</button>
|
||||
<button class="btn btn-default" style="padding:3px 10px;font-size:12px;color:#f5222d" onclick="deleteMapping('${m.id}')">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function openMappingModal(m) {
|
||||
document.getElementById('modalTitle').textContent = m ? '编辑映射配置' : '新增映射配置';
|
||||
document.getElementById('mappingId').value = m ? m.id : '';
|
||||
document.getElementById('f-dbName').value = m ? m.dbName : '';
|
||||
document.getElementById('f-tableName').value = m ? m.tableName : '';
|
||||
document.getElementById('f-idCol').value = m ? m.idCol : 'id';
|
||||
document.getElementById('f-fileCol').value = m ? m.fileCol : '';
|
||||
document.getElementById('f-bizPath').value = m ? (m.bizPath||'') : '';
|
||||
document.getElementById('f-remark').value = m ? (m.remark||'') : '';
|
||||
document.getElementById('mappingModal').classList.add('show');
|
||||
}
|
||||
|
||||
function closeMappingModal() {
|
||||
document.getElementById('mappingModal').classList.remove('show');
|
||||
}
|
||||
|
||||
async function saveMapping() {
|
||||
const id = document.getElementById('mappingId').value;
|
||||
const body = {
|
||||
dbName: document.getElementById('f-dbName').value.trim(),
|
||||
tableName: document.getElementById('f-tableName').value.trim(),
|
||||
idCol: document.getElementById('f-idCol').value.trim() || 'id',
|
||||
fileCol: document.getElementById('f-fileCol').value.trim(),
|
||||
bizPath: document.getElementById('f-bizPath').value.trim() || null,
|
||||
remark: document.getElementById('f-remark').value.trim(),
|
||||
};
|
||||
if (id) body.id = id;
|
||||
if (!body.dbName || !body.tableName || !body.fileCol) { alert('数据库名、业务表名、文件路径列为必填'); return; }
|
||||
try {
|
||||
const res = await fetch('/mapping/save', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
||||
const json = await res.json();
|
||||
if (json.code !== 200) { alert('保存失败:' + json.msg); return; }
|
||||
closeMappingModal();
|
||||
loadMappings();
|
||||
} catch(e) { alert('请求失败:' + e.message); }
|
||||
}
|
||||
|
||||
async function toggleMapping(id) {
|
||||
try {
|
||||
const res = await fetch('/mapping/toggle/' + id, { method:'POST' });
|
||||
const json = await res.json();
|
||||
if (json.code !== 200) { alert('操作失败:' + json.msg); return; }
|
||||
loadMappings();
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function deleteMapping(id) {
|
||||
if (!confirm('确认删除该映射配置?')) return;
|
||||
try {
|
||||
const res = await fetch('/mapping/delete/' + id, { method:'DELETE' });
|
||||
const json = await res.json();
|
||||
if (json.code !== 200) { alert('删除失败:' + json.msg); return; }
|
||||
loadMappings();
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// ===================== 初始化 =====================
|
||||
loadLogs(1);
|
||||
loadTaskHistory();
|
||||
</script></#noparse>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
package features;
|
||||
|
||||
import com.yxtech.watch.App;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.noear.solon.test.HttpTester;
|
||||
import org.noear.solon.test.SolonTest;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@SolonTest(App.class)
|
||||
public class HelloTest extends HttpTester {
|
||||
@Test
|
||||
public void hello() throws IOException {
|
||||
assert path("/hello?name=world").get().contains("world");
|
||||
assert path("/hello?name=solon").get().contains("solon");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user