From 3fddada267c3272794ecb34c53c2593d48b3c9d8 Mon Sep 17 00:00:00 2001 From: lianlonggang Date: Thu, 16 Apr 2026 18:55:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=9D=E5=A7=8B=E5=8C=96=20xj-oss-sy?= =?UTF-8?q?n=20=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OSS 文件同步核心功能:扫描业务表、上传文件、回写路径 - 业务文件映射配置表(BIZ_FILE_MAPPING),支持动态管理、启用/禁用、自定义 bizPath - 同步任务异步执行,前端实时轮询进度 - 全局异常过滤器,统一错误日志输出 - 文件日志/任务历史清理接口 - 管理页面:同步管理 + 映射配置 Tab Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 29 + pom.xml | 141 ++++ sql/init.sql | 56 ++ src/main/java/com/yxtech/ossyn/App.java | 14 + .../ossyn/config/BizDataSourceConfig.java | 39 ++ .../ossyn/config/BizFileMappingConfig.java | 35 + .../yxtech/ossyn/config/LocalFileConfig.java | 18 + .../ossyn/config/MybatisPlusConfig.java | 22 + .../com/yxtech/ossyn/config/OssConfig.java | 42 ++ .../controller/BizFileMappingController.java | 130 ++++ .../ossyn/controller/OssSyncController.java | 251 +++++++ .../ossyn/entity/BizFileMappingEntity.java | 69 ++ .../com/yxtech/ossyn/entity/QhOssFileLog.java | 70 ++ .../com/yxtech/ossyn/entity/SyncTask.java | 66 ++ .../ossyn/filter/GlobalExceptionFilter.java | 34 + .../ossyn/mapper/BizFileMappingMapper.java | 12 + .../ossyn/mapper/QhOssFileLogMapper.java | 12 + .../yxtech/ossyn/mapper/SyncTaskMapper.java | 12 + .../yxtech/ossyn/service/OssSyncService.java | 367 +++++++++++ .../ossyn/util/DynamicDataSourceUtil.java | 132 ++++ .../java/com/yxtech/ossyn/util/OssUtil.java | 253 +++++++ src/main/resources/app.yml | 82 +++ src/main/resources/static/base.css | 1 + src/main/resources/templates/index.ftl | 622 ++++++++++++++++++ src/test/java/features/HelloTest.java | 19 + 25 files changed, 2528 insertions(+) create mode 100644 .gitignore create mode 100644 pom.xml create mode 100644 sql/init.sql create mode 100644 src/main/java/com/yxtech/ossyn/App.java create mode 100644 src/main/java/com/yxtech/ossyn/config/BizDataSourceConfig.java create mode 100644 src/main/java/com/yxtech/ossyn/config/BizFileMappingConfig.java create mode 100644 src/main/java/com/yxtech/ossyn/config/LocalFileConfig.java create mode 100644 src/main/java/com/yxtech/ossyn/config/MybatisPlusConfig.java create mode 100644 src/main/java/com/yxtech/ossyn/config/OssConfig.java create mode 100644 src/main/java/com/yxtech/ossyn/controller/BizFileMappingController.java create mode 100644 src/main/java/com/yxtech/ossyn/controller/OssSyncController.java create mode 100644 src/main/java/com/yxtech/ossyn/entity/BizFileMappingEntity.java create mode 100644 src/main/java/com/yxtech/ossyn/entity/QhOssFileLog.java create mode 100644 src/main/java/com/yxtech/ossyn/entity/SyncTask.java create mode 100644 src/main/java/com/yxtech/ossyn/filter/GlobalExceptionFilter.java create mode 100644 src/main/java/com/yxtech/ossyn/mapper/BizFileMappingMapper.java create mode 100644 src/main/java/com/yxtech/ossyn/mapper/QhOssFileLogMapper.java create mode 100644 src/main/java/com/yxtech/ossyn/mapper/SyncTaskMapper.java create mode 100644 src/main/java/com/yxtech/ossyn/service/OssSyncService.java create mode 100644 src/main/java/com/yxtech/ossyn/util/DynamicDataSourceUtil.java create mode 100644 src/main/java/com/yxtech/ossyn/util/OssUtil.java create mode 100644 src/main/resources/app.yml create mode 100644 src/main/resources/static/base.css create mode 100644 src/main/resources/templates/index.ftl create mode 100644 src/test/java/features/HelloTest.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cf2a19c --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..b9aa60d --- /dev/null +++ b/pom.xml @@ -0,0 +1,141 @@ + + + 4.0.0 + + + org.noear + solon-parent + 3.10.0 + + + + com.yxtech + xj-oss-syn + 1.0 + + jar + + watch valid + + + 21 + 8.0.33 + 5.1.0 + + + + + org.noear + solon-web + + + + + com.baomidou + mybatis-plus-solon-plugin + 3.5.12 + + + com.baomidou + mybatis-plus-jsqlparser + 3.5.12 + + + + + mysql + mysql-connector-java + ${mysql.version} + runtime + + + + + com.zaxxer + HikariCP + ${hikaricp.version} + + + + + org.apache.httpcomponents.client5 + httpclient5 + 5.3.1 + + + + + com.alibaba.fastjson2 + fastjson2 + 2.0.51 + + + + + org.noear + solon-scheduling + + + org.noear + solon-scheduling-simple + + + + org.noear + solon-view-freemarker + + + + org.noear + solon-logging-logback-jakarta + + + + org.projectlombok + lombok + provided + + + + org.noear + solon-test + test + + + + + ${project.artifactId} + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + + org.noear + solon-maven-plugin + + + + + + + tencent + https://mirrors.cloud.tencent.com/nexus/repository/maven-public/ + + false + + + + + \ No newline at end of file diff --git a/sql/init.sql b/sql/init.sql new file mode 100644 index 0000000..e5ab50f --- /dev/null +++ b/sql/init.sql @@ -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='业务文件映射配置表'; diff --git a/src/main/java/com/yxtech/ossyn/App.java b/src/main/java/com/yxtech/ossyn/App.java new file mode 100644 index 0000000..059669c --- /dev/null +++ b/src/main/java/com/yxtech/ossyn/App.java @@ -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); + } +} diff --git a/src/main/java/com/yxtech/ossyn/config/BizDataSourceConfig.java b/src/main/java/com/yxtech/ossyn/config/BizDataSourceConfig.java new file mode 100644 index 0000000..7f8e125 --- /dev/null +++ b/src/main/java/com/yxtech/ossyn/config/BizDataSourceConfig.java @@ -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); + } +} diff --git a/src/main/java/com/yxtech/ossyn/config/BizFileMappingConfig.java b/src/main/java/com/yxtech/ossyn/config/BizFileMappingConfig.java new file mode 100644 index 0000000..ca3e099 --- /dev/null +++ b/src/main/java/com/yxtech/ossyn/config/BizFileMappingConfig.java @@ -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 mappings; + + /** + * 单条业务表映射配置 + */ + @Data + public static class BizFileMapping { + /** 数据库名 */ + private String dbName; + /** 业务表名 */ + private String tableName; + /** 主键列名 */ + private String idCol; + /** 文件路径列名 */ + private String fileCol; + } +} diff --git a/src/main/java/com/yxtech/ossyn/config/LocalFileConfig.java b/src/main/java/com/yxtech/ossyn/config/LocalFileConfig.java new file mode 100644 index 0000000..cab1822 --- /dev/null +++ b/src/main/java/com/yxtech/ossyn/config/LocalFileConfig.java @@ -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; +} diff --git a/src/main/java/com/yxtech/ossyn/config/MybatisPlusConfig.java b/src/main/java/com/yxtech/ossyn/config/MybatisPlusConfig.java new file mode 100644 index 0000000..a0d01ee --- /dev/null +++ b/src/main/java/com/yxtech/ossyn/config/MybatisPlusConfig.java @@ -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; + } +} diff --git a/src/main/java/com/yxtech/ossyn/config/OssConfig.java b/src/main/java/com/yxtech/ossyn/config/OssConfig.java new file mode 100644 index 0000000..688aaf7 --- /dev/null +++ b/src/main/java/com/yxtech/ossyn/config/OssConfig.java @@ -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; +} diff --git a/src/main/java/com/yxtech/ossyn/controller/BizFileMappingController.java b/src/main/java/com/yxtech/ossyn/controller/BizFileMappingController.java new file mode 100644 index 0000000..16884d0 --- /dev/null +++ b/src/main/java/com/yxtech/ossyn/controller/BizFileMappingController.java @@ -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 list() { + List list = mappingMapper.selectList( + new LambdaQueryWrapper() + .orderByDesc(BizFileMappingEntity::getCreateTime)); + List> data = list.stream().map(this::toMap).collect(Collectors.toList()); + return ok(data); + } + + /** + * 新增或更新映射配置 + * id 为空时新增,有 id 时更新 + */ + @Post + @Mapping("/save") + public Map 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 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() + .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 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 toMap(BizFileMappingEntity e) { + Map 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 ok(Object data) { + Map r = new HashMap<>(3); + r.put("code", 200); + r.put("msg", "ok"); + r.put("data", data); + return r; + } + + private Map fail(String msg) { + Map r = new HashMap<>(2); + r.put("code", 500); + r.put("msg", msg); + return r; + } +} diff --git a/src/main/java/com/yxtech/ossyn/controller/OssSyncController.java b/src/main/java/com/yxtech/ossyn/controller/OssSyncController.java new file mode 100644 index 0000000..30b9ca0 --- /dev/null +++ b/src/main/java/com/yxtech/ossyn/controller/OssSyncController.java @@ -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 syncAll() { + String taskId = ossSyncService.asyncSyncAll(); + return ok("全量同步任务已启动", taskId); + } + + /** 仅扫描 */ + @Post + @Mapping("/scan") + public Map scan() { + String taskId = ossSyncService.asyncScan(); + return ok("扫描任务已启动", taskId); + } + + /** 仅上传 */ + @Post + @Mapping("/upload") + public Map upload() { + String taskId = ossSyncService.asyncUpload(); + return ok("上传任务已启动", taskId); + } + + /** 仅回写 */ + @Post + @Mapping("/write-back") + public Map writeBack() { + String taskId = ossSyncService.asyncWriteBack(); + return ok("回写任务已启动", taskId); + } + + // ===================== 任务状态查询(前端轮询) ===================== + + /** + * 查询指定任务状态 + * + * @param id 任务ID(字符串,避免前端精度丢失) + */ + @Get + @Mapping("/task/{id}") + public Map taskStatus(@Path String id) { + SyncTask task = syncTaskMapper.selectById(Long.parseLong(id)); + if (task == null) { + return fail("任务不存在"); + } + Map 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 recentTasks() { + IPage page = syncTaskMapper.selectPage( + new Page<>(1, 20), + new LambdaQueryWrapper().orderByDesc(SyncTask::getCreateTime)); + // ID 转 String 防止前端精度丢失 + java.util.List> list = new java.util.ArrayList<>(); + for (SyncTask t : page.getRecords()) { + Map 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 logs( + @Param(defaultValue = "1") int pageNum, + @Param(defaultValue = "20") int pageSize, + @Param(required = false) Integer status, + @Param(required = false) String tabName) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .orderByDesc(QhOssFileLog::getCreateTime); + if (status != null) { + wrapper.eq(QhOssFileLog::getBizHandleStatus, status); + } + if (tabName != null && !tabName.isBlank()) { + wrapper.eq(QhOssFileLog::getBizTabName, tabName); + } + IPage page = ossFileLogMapper.selectPage(new Page<>(pageNum, pageSize), wrapper); + + // 统计各状态数量 + Map stats = new HashMap<>(); + stats.put("total", ossFileLogMapper.selectCount(null)); + stats.put("pending", ossFileLogMapper.selectCount(new LambdaQueryWrapper() + .eq(QhOssFileLog::getBizHandleStatus, QhOssFileLog.STATUS_PENDING))); + stats.put("uploaded", ossFileLogMapper.selectCount(new LambdaQueryWrapper() + .eq(QhOssFileLog::getBizHandleStatus, QhOssFileLog.STATUS_UPLOADED))); + stats.put("fail", ossFileLogMapper.selectCount(new LambdaQueryWrapper() + .eq(QhOssFileLog::getBizHandleStatus, QhOssFileLog.STATUS_UPLOAD_FAIL))); + stats.put("notFound", ossFileLogMapper.selectCount(new LambdaQueryWrapper() + .eq(QhOssFileLog::getBizHandleStatus, QhOssFileLog.STATUS_FILE_NOT_FOUND))); + stats.put("writtenBack", ossFileLogMapper.selectCount(new LambdaQueryWrapper() + .eq(QhOssFileLog::getBizHandleStatus, QhOssFileLog.STATUS_WRITTEN_BACK))); + + // 日志列表 ID 转 String + java.util.List> records = new java.util.ArrayList<>(); + for (QhOssFileLog r : page.getRecords()) { + Map 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 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 cleanLogs(@Param(required = false) Integer status) { + int rows; + if (status != null) { + rows = ossFileLogMapper.delete(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .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 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 ok(String msg, Object data) { + Map r = new HashMap<>(3); + r.put("code", 200); + r.put("msg", msg); + r.put("data", data); + return r; + } + + private Map ok(String msg) { + return ok(msg, null); + } + + private Map fail(String msg) { + Map r = new HashMap<>(2); + r.put("code", 500); + r.put("msg", msg); + return r; + } +} diff --git a/src/main/java/com/yxtech/ossyn/entity/BizFileMappingEntity.java b/src/main/java/com/yxtech/ossyn/entity/BizFileMappingEntity.java new file mode 100644 index 0000000..870c706 --- /dev/null +++ b/src/main/java/com/yxtech/ossyn/entity/BizFileMappingEntity.java @@ -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; +} diff --git a/src/main/java/com/yxtech/ossyn/entity/QhOssFileLog.java b/src/main/java/com/yxtech/ossyn/entity/QhOssFileLog.java new file mode 100644 index 0000000..28cda0d --- /dev/null +++ b/src/main/java/com/yxtech/ossyn/entity/QhOssFileLog.java @@ -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; +} diff --git a/src/main/java/com/yxtech/ossyn/entity/SyncTask.java b/src/main/java/com/yxtech/ossyn/entity/SyncTask.java new file mode 100644 index 0000000..6a7ab48 --- /dev/null +++ b/src/main/java/com/yxtech/ossyn/entity/SyncTask.java @@ -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; +} diff --git a/src/main/java/com/yxtech/ossyn/filter/GlobalExceptionFilter.java b/src/main/java/com/yxtech/ossyn/filter/GlobalExceptionFilter.java new file mode 100644 index 0000000..3dac955 --- /dev/null +++ b/src/main/java/com/yxtech/ossyn/filter/GlobalExceptionFilter.java @@ -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 result = new HashMap<>(2); + result.put("code", 500); + result.put("msg", e.getMessage()); + ctx.render(result); + } + } +} diff --git a/src/main/java/com/yxtech/ossyn/mapper/BizFileMappingMapper.java b/src/main/java/com/yxtech/ossyn/mapper/BizFileMappingMapper.java new file mode 100644 index 0000000..692954d --- /dev/null +++ b/src/main/java/com/yxtech/ossyn/mapper/BizFileMappingMapper.java @@ -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 { +} diff --git a/src/main/java/com/yxtech/ossyn/mapper/QhOssFileLogMapper.java b/src/main/java/com/yxtech/ossyn/mapper/QhOssFileLogMapper.java new file mode 100644 index 0000000..2f66555 --- /dev/null +++ b/src/main/java/com/yxtech/ossyn/mapper/QhOssFileLogMapper.java @@ -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 { +} diff --git a/src/main/java/com/yxtech/ossyn/mapper/SyncTaskMapper.java b/src/main/java/com/yxtech/ossyn/mapper/SyncTaskMapper.java new file mode 100644 index 0000000..40dee9e --- /dev/null +++ b/src/main/java/com/yxtech/ossyn/mapper/SyncTaskMapper.java @@ -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 { +} diff --git a/src/main/java/com/yxtech/ossyn/service/OssSyncService.java b/src/main/java/com/yxtech/ossyn/service/OssSyncService.java new file mode 100644 index 0000000..06f8fda --- /dev/null +++ b/src/main/java/com/yxtech/ossyn/service/OssSyncService.java @@ -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 mappings = bizFileMappingMapper.selectList( + new LambdaQueryWrapper() + .eq(BizFileMappingEntity::getEnabled, BizFileMappingEntity.ENABLED_YES)); + if (mappings == null || mappings.isEmpty()) { + log.warn("[OssSyncService] 无有效的业务表映射配置,跳过扫描"); + return 0; + } + for (BizFileMappingEntity mapping : mappings) { + List> rows = dynamicDataSourceUtil.queryFilePaths( + mapping.getDbName(), mapping.getTableName(), + mapping.getIdCol(), mapping.getFileCol()); + for (Map row : rows) { + String bizId = row.get("id"); + String filePath = row.get("filePath"); + Long exists = ossFileLogMapper.selectCount(new LambdaQueryWrapper() + .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 pending = ossFileLogMapper.selectList( + new LambdaQueryWrapper() + .eq(QhOssFileLog::getBizHandleStatus, QhOssFileLog.STATUS_PENDING)); + // 更新任务总数 + syncTaskMapper.update(null, new LambdaUpdateWrapper() + .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() + .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 uploaded = ossFileLogMapper.selectList( + new LambdaQueryWrapper() + .eq(QhOssFileLog::getBizHandleStatus, QhOssFileLog.STATUS_UPLOADED)); + syncTaskMapper.update(null, new LambdaUpdateWrapper() + .eq(SyncTask::getId, task.getId()) + .set(SyncTask::getTotal, uploaded.size()) + .set(SyncTask::getSuccess, 0) + .set(SyncTask::getFail, 0) + .set(SyncTask::getUpdateTime, LocalDateTime.now())); + // 从数据库读取所有映射配置(回写不限有效/无效,按表名匹配) + List 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() + .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() + .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() + .eq(SyncTask::getId, taskId) + .setSql("SUCCESS = SUCCESS + 1, UPDATE_TIME = NOW()")); + } + + private void incrFail(Long taskId) { + syncTaskMapper.update(null, new LambdaUpdateWrapper() + .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() + .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; + } + +} diff --git a/src/main/java/com/yxtech/ossyn/util/DynamicDataSourceUtil.java b/src/main/java/com/yxtech/ossyn/util/DynamicDataSourceUtil.java new file mode 100644 index 0000000..0c9be2e --- /dev/null +++ b/src/main/java/com/yxtech/ossyn/util/DynamicDataSourceUtil.java @@ -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 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> queryFilePaths(String dbName, String tableName, + String idCol, String fileCol) { + List> 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 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(); + } +} diff --git a/src/main/java/com/yxtech/ossyn/util/OssUtil.java b/src/main/java/com/yxtech/ossyn/util/OssUtil.java new file mode 100644 index 0000000..5aa66f1 --- /dev/null +++ b/src/main/java/com/yxtech/ossyn/util/OssUtil.java @@ -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 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; + } + } +} diff --git a/src/main/resources/app.yml b/src/main/resources/app.yml new file mode 100644 index 0000000..3d692f4 --- /dev/null +++ b/src/main/resources/app.yml @@ -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 diff --git a/src/main/resources/static/base.css b/src/main/resources/static/base.css new file mode 100644 index 0000000..7af79a4 --- /dev/null +++ b/src/main/resources/static/base.css @@ -0,0 +1 @@ +div{font-size: 1.5em;} \ No newline at end of file diff --git a/src/main/resources/templates/index.ftl b/src/main/resources/templates/index.ftl new file mode 100644 index 0000000..104b3fe --- /dev/null +++ b/src/main/resources/templates/index.ftl @@ -0,0 +1,622 @@ + + + + + + OSS 文件同步管理 + + + +
OSS 文件同步管理
+
+ + +
+ + +
+ + +
+ + +
+
操作
+
+ + + + +
+

全量同步 = 扫描 → 上传 → 回写,三步连续执行。各步骤也可单独触发。

+
+ + +
+
当前任务进度
+
+ - + 进行中 + +
+
+
+
0 / 0
+
+
+ + +
+
日志统计
+
+
-
总计
+
-
待处理
+
-
已上传
+
-
上传失败
+
-
文件不存在
+
-
已回写
+
+
+ + +
+
文件日志
+
+ + + + +
+ +
+
+ + + + + + + + + + + + + + + + +
ID业务表业务ID字段原始路径OSS路径状态更新时间
加载中...
+
+ +
+ + +
+
+
任务历史(最近20条)
+ +
+
加载中...
+
+ +
+ + +
+
+
业务文件映射配置
+
+ + 新增后默认无效,需手动启用;扫描时只处理有效的映射。 +
+
+ + + + + + + + + + + + + + + + + +
ID数据库名业务表名主键列文件路径列OSS业务路径状态备注操作
加载中...
+
+
+
+ +
+ + + + + +<#noparse> + + diff --git a/src/test/java/features/HelloTest.java b/src/test/java/features/HelloTest.java new file mode 100644 index 0000000..050dc6e --- /dev/null +++ b/src/test/java/features/HelloTest.java @@ -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"); + } +} \ No newline at end of file