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:
2026-04-16 18:55:44 +08:00
co-authored by Claude Sonnet 4.6
commit 3fddada267
25 changed files with 2528 additions and 0 deletions
+29
View File
@@ -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
+141
View File
@@ -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>
+56
View File
@@ -0,0 +1,56 @@
-- ============================================================
-- QH_OSS_FILE_LOG 文件转换日志表
-- 数据源1(本项目数据库)
-- ============================================================
CREATE TABLE IF NOT EXISTS `QH_OSS_FILE_LOG` (
`ID` BIGINT NOT NULL COMMENT '主键(雪花ID',
`OSS_PATH` VARCHAR(512) DEFAULT NULL COMMENT 'OSS路径(上传成功后填入)',
`BIZ_TAB_NAME` VARCHAR(128) NOT NULL COMMENT '业务表名',
`BIZ_TAB_ID` VARCHAR(64) NOT NULL COMMENT '业务表主键值',
`BIZ_COL_NAME` VARCHAR(128) NOT NULL COMMENT '业务表列名(文件路径字段)',
`BIZ_TAB_PATH` VARCHAR(512) DEFAULT NULL COMMENT '业务表中原始文件路径',
`BIZ_HANDLE_STATUS` TINYINT NOT NULL DEFAULT 0 COMMENT '处理状态:0=待处理,1=上传成功,2=上传失败,3=文件不存在,4=已回写',
`CREATE_TIME` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`UPDATE_TIME` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`ID`),
INDEX `idx_biz_tab` (`BIZ_TAB_NAME`, `BIZ_TAB_ID`),
INDEX `idx_status` (`BIZ_HANDLE_STATUS`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件转换日志表';
-- ============================================================
-- SYNC_TASK 同步任务记录表
-- 记录每次触发的同步任务进度,供前端轮询展示
-- ============================================================
CREATE TABLE IF NOT EXISTS `SYNC_TASK` (
`ID` BIGINT NOT NULL COMMENT '主键(雪花ID',
`TASK_TYPE` VARCHAR(32) NOT NULL COMMENT '任务类型:all/scan/upload/write-back',
`STATUS` TINYINT NOT NULL DEFAULT 0 COMMENT '0=进行中,1=完成,2=失败',
`TOTAL` INT NOT NULL DEFAULT 0 COMMENT '总处理条数',
`SUCCESS` INT NOT NULL DEFAULT 0 COMMENT '成功条数',
`FAIL` INT NOT NULL DEFAULT 0 COMMENT '失败条数',
`MESSAGE` VARCHAR(512) DEFAULT NULL COMMENT '备注/错误信息',
`CREATE_TIME` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`UPDATE_TIME` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`ID`),
INDEX `idx_status` (`STATUS`),
INDEX `idx_create_time` (`CREATE_TIME`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同步任务记录表';
-- ============================================================
-- BIZ_FILE_MAPPING 业务文件映射配置表
-- 替代 app.yml 中的 biz-file-mappings 配置,支持动态管理
-- ============================================================
CREATE TABLE IF NOT EXISTS `BIZ_FILE_MAPPING` (
`ID` BIGINT NOT NULL COMMENT '主键(雪花ID',
`DB_NAME` VARCHAR(128) NOT NULL COMMENT '数据库名',
`TABLE_NAME` VARCHAR(128) NOT NULL COMMENT '业务表名',
`ID_COL` VARCHAR(64) NOT NULL DEFAULT 'id' COMMENT '主键列名',
`FILE_COL` VARCHAR(128) NOT NULL COMMENT '文件路径列名',
`BIZ_PATH` VARCHAR(128) DEFAULT NULL COMMENT 'OSS上传业务路径(不填则降级用表名)',
`ENABLED` TINYINT NOT NULL DEFAULT 0 COMMENT '是否有效:0=无效,1=有效',
`REMARK` VARCHAR(256) DEFAULT NULL COMMENT '备注',
`CREATE_TIME` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`UPDATE_TIME` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`ID`),
INDEX `idx_enabled` (`ENABLED`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务文件映射配置表';
+14
View File
@@ -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 任务IDString,避免前端精度丢失)
*/
public String asyncSyncAll() {
SyncTask task = createTask("all");
executor.submit(() -> doSyncAll(task));
return String.valueOf(task.getId());
}
/**
* 异步触发扫描阶段
*/
public String asyncScan() {
SyncTask task = createTask("scan");
executor.submit(() -> doScan(task));
return String.valueOf(task.getId());
}
/**
* 异步触发上传阶段
*/
public String asyncUpload() {
SyncTask task = createTask("upload");
executor.submit(() -> doUpload(task));
return String.valueOf(task.getId());
}
/**
* 异步触发回写阶段
*/
public String asyncWriteBack() {
SyncTask task = createTask("write-back");
executor.submit(() -> doWriteBack(task));
return String.valueOf(task.getId());
}
// ===================== 实际执行逻辑 =====================
private void doSyncAll(SyncTask task) {
try {
updateMessage(task.getId(), "扫描业务表中...");
int scanned = scanAndRecord(task);
updateMessage(task.getId(), "扫描完成,新增 " + scanned + " 条;上传文件中...");
int uploaded = uploadPending(task);
updateMessage(task.getId(), "上传完成,成功 " + uploaded + " 条;回写业务表中...");
int written = writeBack(task);
finishTask(task.getId(), SyncTask.STATUS_DONE,
"全量同步完成:扫描新增 " + scanned + " 条,上传成功 " + uploaded + " 条,回写成功 " + written + "");
} catch (Exception e) {
log.error("[OssSyncService] 全量同步异常", e);
finishTask(task.getId(), SyncTask.STATUS_FAIL, "执行异常:" + e.getMessage());
}
}
private void doScan(SyncTask task) {
try {
updateMessage(task.getId(), "扫描业务表中...");
int count = scanAndRecord(task);
finishTask(task.getId(), SyncTask.STATUS_DONE, "扫描完成,新增日志 " + count + "");
} catch (Exception e) {
log.error("[OssSyncService] 扫描异常", e);
finishTask(task.getId(), SyncTask.STATUS_FAIL, "执行异常:" + e.getMessage());
}
}
private void doUpload(SyncTask task) {
try {
updateMessage(task.getId(), "上传文件中...");
int count = uploadPending(task);
finishTask(task.getId(), SyncTask.STATUS_DONE, "上传完成,成功 " + count + "");
} catch (Exception e) {
log.error("[OssSyncService] 上传异常", e);
finishTask(task.getId(), SyncTask.STATUS_FAIL, "执行异常:" + e.getMessage());
}
}
private void doWriteBack(SyncTask task) {
try {
updateMessage(task.getId(), "回写业务表中...");
int count = writeBack(task);
finishTask(task.getId(), SyncTask.STATUS_DONE, "回写完成,成功 " + count + "");
} catch (Exception e) {
log.error("[OssSyncService] 回写异常", e);
finishTask(task.getId(), SyncTask.STATUS_FAIL, "执行异常:" + e.getMessage());
}
}
// ===================== 核心业务逻辑 =====================
/**
* 扫描所有配置的业务表,将文件路径记录写入日志(幂等)
*/
private int scanAndRecord(SyncTask task) {
int total = 0;
// 从数据库读取有效的映射配置
List<BizFileMappingEntity> mappings = bizFileMappingMapper.selectList(
new LambdaQueryWrapper<BizFileMappingEntity>()
.eq(BizFileMappingEntity::getEnabled, BizFileMappingEntity.ENABLED_YES));
if (mappings == null || mappings.isEmpty()) {
log.warn("[OssSyncService] 无有效的业务表映射配置,跳过扫描");
return 0;
}
for (BizFileMappingEntity mapping : mappings) {
List<Map<String, String>> rows = dynamicDataSourceUtil.queryFilePaths(
mapping.getDbName(), mapping.getTableName(),
mapping.getIdCol(), mapping.getFileCol());
for (Map<String, String> row : rows) {
String bizId = row.get("id");
String filePath = row.get("filePath");
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;
}
}
}
+82
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
div{font-size: 1.5em;}
+622
View File
@@ -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,'&quot;')})">编辑</button>
<button class="btn ${m.enabled===1?'btn-warning':'btn-success'}" style="padding:3px 10px;font-size:12px;margin:0 4px" onclick="toggleMapping('${m.id}')">${m.enabled===1?'禁用':'启用'}</button>
<button class="btn btn-default" style="padding:3px 10px;font-size:12px;color:#f5222d" onclick="deleteMapping('${m.id}')">删除</button>
</td>
</tr>
`).join('');
}
function openMappingModal(m) {
document.getElementById('modalTitle').textContent = m ? '编辑映射配置' : '新增映射配置';
document.getElementById('mappingId').value = m ? m.id : '';
document.getElementById('f-dbName').value = m ? m.dbName : '';
document.getElementById('f-tableName').value = m ? m.tableName : '';
document.getElementById('f-idCol').value = m ? m.idCol : 'id';
document.getElementById('f-fileCol').value = m ? m.fileCol : '';
document.getElementById('f-bizPath').value = m ? (m.bizPath||'') : '';
document.getElementById('f-remark').value = m ? (m.remark||'') : '';
document.getElementById('mappingModal').classList.add('show');
}
function closeMappingModal() {
document.getElementById('mappingModal').classList.remove('show');
}
async function saveMapping() {
const id = document.getElementById('mappingId').value;
const body = {
dbName: document.getElementById('f-dbName').value.trim(),
tableName: document.getElementById('f-tableName').value.trim(),
idCol: document.getElementById('f-idCol').value.trim() || 'id',
fileCol: document.getElementById('f-fileCol').value.trim(),
bizPath: document.getElementById('f-bizPath').value.trim() || null,
remark: document.getElementById('f-remark').value.trim(),
};
if (id) body.id = id;
if (!body.dbName || !body.tableName || !body.fileCol) { alert('数据库名、业务表名、文件路径列为必填'); return; }
try {
const res = await fetch('/mapping/save', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
const json = await res.json();
if (json.code !== 200) { alert('保存失败:' + json.msg); return; }
closeMappingModal();
loadMappings();
} catch(e) { alert('请求失败:' + e.message); }
}
async function toggleMapping(id) {
try {
const res = await fetch('/mapping/toggle/' + id, { method:'POST' });
const json = await res.json();
if (json.code !== 200) { alert('操作失败:' + json.msg); return; }
loadMappings();
} catch(e) {}
}
async function deleteMapping(id) {
if (!confirm('确认删除该映射配置?')) return;
try {
const res = await fetch('/mapping/delete/' + id, { method:'DELETE' });
const json = await res.json();
if (json.code !== 200) { alert('删除失败:' + json.msg); return; }
loadMappings();
} catch(e) {}
}
// ===================== 初始化 =====================
loadLogs(1);
loadTaskHistory();
</script></#noparse>
</body>
</html>
+19
View File
@@ -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");
}
}