27 Commits
4.0.x ... tmp

Author SHA1 Message Date
979403df06 临时 2025-10-01 21:37:43 +08:00
818e614e37 docs: 更新 README 及 Bug Issue 模板 2025-09-23 22:11:43 +08:00
48e85292a8 merge build-error into dev
回退pom.xml 且 用户导入多部门分隔符变更 : -> /

Created-by: kiki1373639299
Commit-by: kiki1373639299
Merged-by: Charles_7c
Description: <!--
  非常感谢您的 PR!在提交之前,请务必确保您 PR 的代码经过了完整测试,并且通过了代码规范检查。
-->

<!-- 在 [] 中输入 x 来勾选) -->

## PR 类型

<!-- 您的 PR 引入了哪种类型的变更? -->
<!-- 只支持选择一种类型,如果有多种类型,可以在更新日志中增加 “类型” 列。 -->

- [ ] 新 feature
- [ ] Bug 修复
- [ ] 功能增强
- [ ] 文档变更
- [ ] 代码样式变更
- [ ] 重构
- [ ] 性能改进
- [ ] 单元测试
- [ ] CI/CD
- [ ] 其他

## PR 目的

<!-- 描述一下您的 PR 解决了什么问题。如果可以,请链接到相关 issues。 -->

## 解决方案

<!-- 详细描述您是如何解决的问题 -->

## PR 测试

<!-- 如果可以,请为您的 PR 添加或更新单元测试。 -->
<!-- 请描述一下您是如何测试 PR 的。例如:创建/更新单元测试或添加相关的截图。 -->

## Changelog

| 模块  | Changelog | Related issues |
|-----|-----------| -------------- |
|     |           |                |

<!-- 如果有多种类型的变更,可以在变更日志表中增加 “类型” 列,该列的值与上方 “PR 类型” 相同。 -->
<!-- Related issues 格式为 Closes #<issue号>,或者 Fixes #<issue号>,或者 Resolves #<issue号>。 -->

## 其他信息

<!-- 请描述一下还有哪些注意事项。例如:如果引入了一个不向下兼容的变更,请描述其影响。 -->

## 提交前确认

- [X] PR 代码经过了完整测试,并且通过了代码规范检查
- [ ] 已经完整填写 Changelog,并链接到了相关 issues
- [X] PR 代码将要提交到 dev 分支

See merge request: continew/continew-admin!14
2025-09-23 15:49:11 +08:00
kiki1373639299
ac2e16c472 chore(user): 用户导入多部门分隔符变更 : -> / 2025-09-23 15:26:29 +08:00
kiki1373639299
2b795b9db6 Revert "fix: 修复新建租户的管理员用户角色回显错误"
但在实际使用中发现 [高版本maven编译时会报错],
因此先回退。
2025-09-23 15:17:42 +08:00
kiki1373639299
a39f6446d7 fix(system/dept): 修复系统用户导入提示【存在无效部门】且新增支持多级部门导入
Co-authored-by: kiki1373639299<zkai0106@163.com>



# message auto-generated for no-merge-commit merge:
merge import-uesr into dev

fix: 修复系统用户导入提示【存在无效部门】且新增支持多级部门导入

Created-by: kiki1373639299
Commit-by: kiki1373639299
Merged-by: Charles_7c
Description: <!--
  非常感谢您的 PR!在提交之前,请务必确保您 PR 的代码经过了完整测试,并且通过了代码规范检查。
-->

<!-- 在 [] 中输入 x 来勾选) -->

## PR 类型

<!-- 您的 PR 引入了哪种类型的变更? -->
<!-- 只支持选择一种类型,如果有多种类型,可以在更新日志中增加 “类型” 列。 -->

- [ ] 新 feature
- [X] Bug 修复
- [ ] 功能增强
- [ ] 文档变更
- [ ] 代码样式变更
- [ ] 重构
- [ ] 性能改进
- [ ] 单元测试
- [ ] CI/CD
- [ ] 其他

## PR 目的

<!-- 描述一下您的 PR 解决了什么问题。如果可以,请链接到相关 issues。 -->

## 解决方案

<!-- 详细描述您是如何解决的问题 -->

## PR 测试

<!-- 如果可以,请为您的 PR 添加或更新单元测试。 -->
<!-- 请描述一下您是如何测试 PR 的。例如:创建/更新单元测试或添加相关的截图。 -->

## Changelog

| 模块  | Changelog | Related issues |
|-----|-----------| -------------- |
|  系统管理 - 用户导入	   |       修复:解决用户导入时部门名称重复导致的"存在无效部门"错误,支持不同公司下同名部门区分	    |       Fixes #ICUHCT         |
|  系统管理 - 用户导入	   |       新增:支持多级部门导入功能,使用冒号(:)分隔层级,如公司A:研发部:前端组,优化错误提示和代码结构		    |                |

<!-- 如果有多种类型的变更,可以在变更日志表中增加 “类型” 列,该列的值与上方 “PR 类型” 相同。 -->
<!-- Related issues 格式为 Closes #<issue号>,或者 Fixes #<issue号>,或者 Resolves #<issue号>。 -->

## 其他信息

<!-- 请描述一下还有哪些注意事项。例如:如果引入了一个不向下兼容的变更,请描述其影响。 -->

## 提交前确认

- [X] PR 代码经过了完整测试,并且通过了代码规范检查
- [X] 已经完整填写 Changelog,并链接到了相关 issues
- [X] PR 代码将要提交到 dev 分支

See merge request: continew/continew-admin!13
2025-09-21 19:00:26 +08:00
3f01a5c84a refactor: UserContextHolder ThreadLocal => TransmittableThreadLocal 2025-09-13 21:36:16 +08:00
8bcf27c48e fix(system/auth):修复查询密码过期时间配置,未使用线程池的问题 2025-09-10 20:44:36 +08:00
5e6290f5c5 fix(system/user):修复新建租户的管理员用户角色回显错误 2025-09-10 20:37:13 +08:00
tao
fa77fc50ee fix:修复新建租户的管理员用户角色回显错误 2025-09-10 12:23:47 +00:00
kiki1373639299
1b065b1755 fix(system/file): 修复创建上级文件夹的并发问题
Co-authored-by: kiki1373639299<zkai0106@163.com>



# message auto-generated for no-merge-commit merge:
merge 2025-09-10 into dev

fix(system): 修复创建上级文件夹的并发问题

Created-by: kiki1373639299
Commit-by: kiki1373639299
Merged-by: Charles_7c
Description: <!--
  非常感谢您的 PR!在提交之前,请务必确保您 PR 的代码经过了完整测试,并且通过了代码规范检查。
-->

<!-- 在 [] 中输入 x 来勾选) -->

## PR 类型

<!-- 您的 PR 引入了哪种类型的变更? -->
<!-- 只支持选择一种类型,如果有多种类型,可以在更新日志中增加 “类型” 列。 -->

- [ ] 新 feature
- [X] Bug 修复
- [ ] 功能增强
- [ ] 文档变更
- [ ] 代码样式变更
- [ ] 重构
- [ ] 性能改进
- [ ] 单元测试
- [ ] CI/CD
- [ ] 其他

## PR 目的

<!-- 描述一下您的 PR 解决了什么问题。如果可以,请链接到相关 issues。 -->
修复创建上级文件夹的并发问题
## 解决方案

<!-- 详细描述您是如何解决的问题 -->
使用redis做锁
## PR 测试

<!-- 如果可以,请为您的 PR 添加或更新单元测试。 -->
<!-- 请描述一下您是如何测试 PR 的。例如:创建/更新单元测试或添加相关的截图。 -->

## Changelog

| 模块  | Changelog | Related issues |
|-----|-----------| -------------- |
|  continew-system   |     为文件服务添加Redis分布式锁防止并发创建目录冲突,使用Lock:storageCode:parentPath作为锁键,使用try-with-resources确保锁的正确释放	      |          [#33](https://gitcode.com/continew/continew-admin/issues/33)       |

<!-- 如果有多种类型的变更,可以在变更日志表中增加 “类型” 列,该列的值与上方 “PR 类型” 相同。 -->
<!-- Related issues 格式为 Closes #<issue号>,或者 Fixes #<issue号>,或者 Resolves #<issue号>。 -->

## 其他信息

<!-- 请描述一下还有哪些注意事项。例如:如果引入了一个不向下兼容的变更,请描述其影响。 -->

## 提交前确认

- [X] PR 代码经过了完整测试,并且通过了代码规范检查
- [X] 已经完整填写 Changelog,并链接到了相关 issues
- [X] PR 代码将要提交到 dev 分支

See merge request: continew/continew-admin!12
2025-09-10 17:50:03 +08:00
c733eab8ea chore: 优化 Issue 模板,更新部分链接 2025-09-09 23:01:06 +08:00
dd386231be ci: 更新 CI 部署脚本 2025-08-29 23:08:50 +08:00
2d86b0f249 refactor(system/storage): 修改存储配置时,保持Secret Key为空将不更改 2025-08-28 21:54:31 +08:00
b5acdb1c1c chore: 优化加密参数的 API 接口 example 示例说明 2025-08-28 21:37:51 +08:00
cb3184e9f1 refactor: 优化 Docker 部署配置 2025-08-28 20:35:19 +08:00
f2258d821b fix(system/storage): 对象存储配置增加 Endpoint 参数格式校验 2025-08-20 22:00:50 +08:00
b305dd7e53 refactor(generator): 简化前端模板命名,例如:UserAddDrawer => AddDrawer 2025-08-15 22:34:26 +08:00
fac8922933 fix: 修复个人中心角色信息展示错误 2025-08-14 22:40:16 +08:00
5bc5666be9 refactor: 统一命名风格 (名词 + 动词 + 类型) 2025-08-14 22:32:01 +08:00
61a6cac714 refactor: 简化命名 isSuperAdminUser() => isSuperAdmin(), isTenantAdminUser() => isTenantAdmin() 2025-08-14 22:31:28 +08:00
84e7f60dd4 chore: 调整代码格式 (mvn compile) 2025-08-14 22:31:15 +08:00
b1a3e20494 build: 更新项目版本号至4.1.0-SNAPSHOT 2025-08-14 22:28:31 +08:00
kiki1373639299
af0f58a096 feat(system/file): 新增多文件分片上传功能,支持本地存储和S3存储
Co-authored-by: kiki1373639299<zkai0106@163.com>



# message auto-generated for no-merge-commit merge:
merge upload into dev

feat(system/file):  新增多文件分片上传功能,支持本地存储和S3存储

Created-by: kiki1373639299
Commit-by: kiki1373639299
Merged-by: Charles_7c
Description: <!--
  非常感谢您的 PR!在提交之前,请务必确保您 PR 的代码经过了完整测试,并且通过了代码规范检查。
-->

<!-- 在 [] 中输入 x 来勾选) -->

## PR 类型

<!-- 您的 PR 引入了哪种类型的变更? -->
<!-- 只支持选择一种类型,如果有多种类型,可以在更新日志中增加 “类型” 列。 -->

- [X] 新 feature
- [ ] Bug 修复
- [ ] 功能增强
- [ ] 文档变更
- [ ] 代码样式变更
- [ ] 重构
- [ ] 性能改进
- [ ] 单元测试
- [ ] CI/CD
- [ ] 其他

## PR 目的

<!-- 描述一下您的 PR 解决了什么问题。如果可以,请链接到相关 issues。 -->

## 解决方案

<!-- 详细描述您是如何解决的问题 -->

## PR 测试

<!-- 如果可以,请为您的 PR 添加或更新单元测试。 -->
<!-- 请描述一下您是如何测试 PR 的。例如:创建/更新单元测试或添加相关的截图。 -->

## Changelog

| 模块  | Changelog | Related issues |
|-----|-----------| -------------- |
|     |           |                |

<!-- 如果有多种类型的变更,可以在变更日志表中增加 “类型” 列,该列的值与上方 “PR 类型” 相同。 -->
<!-- Related issues 格式为 Closes #<issue号>,或者 Fixes #<issue号>,或者 Resolves #<issue号>。 -->

## 其他信息

<!-- 请描述一下还有哪些注意事项。例如:如果引入了一个不向下兼容的变更,请描述其影响。 -->

## 提交前确认

- [X] PR 代码经过了完整测试,并且通过了代码规范检查
- [] 已经完整填写 Changelog,并链接到了相关 issues
- [X] PR 代码将要提交到 dev 分支

See merge request: continew/continew-admin!11
2025-08-12 17:56:30 +08:00
21b753e5eb docs: 更新 README 反馈交流部分内容 2025-08-07 20:23:09 +08:00
ac825032e2 chore: 优化部分配置注释 2025-08-06 20:49:18 +08:00
luoqiz
2bb2f96857 refactor: 增加租户查询条件 (#181) 2025-08-06 09:49:57 +08:00
82 changed files with 2444 additions and 227 deletions

View File

@@ -15,13 +15,15 @@ body:
options:
- label: 重启项目和 IDE 后,仍然能够复现此问题
required: true
- label: 查阅过 [使用指南](https://continew.top/admin/backend/structure.html) 和 [常见问题](https://continew.top/admin/faq.html) ,仍无解决方法
- label: 尝试了最新 dev 分支代码(演示环境),仍有相同问题
required: true
- label: 查看过控制台是否有报错,如果有报错,下拉控制台到最下查找过 Caused 提示(如果有 Caused 关键字,一般其后都有直观提示,请自行翻译英文),并百度或 Google 后,仍无法解决
required: true
- label: 尝试了最新 dev 分支代码(演示环境),仍有相同问题
- label: 查阅过 [使用指南](https://continew.top/docs/admin/guide/introduction.html) 和 [常见问题](https://continew.top/docs/admin/faq.html) ,仍无解决方法
required: true
- label: 搜索了项目 Issues,没有其他人提交过类似的 Bug如果对应 Bug 尚未解决,您可以先订阅关注该 Issue为了方便后来者查找问题解决方法请避免创建重复的 Issue
- label: 搜索了 [吐槽广场](https://continew.top/docs/admin/issue-hub.html),没有其他人提交过类似的 Bug如果对应 Bug 尚未解决,您可以先订阅关注该 Issue为了方便后来者查找问题解决方法请避免创建重复的 Issue
required: true
- label: 问过 [DeepWiki](https://deepwiki.com/continew-org/continew-admin) 及知名 AI 大模型,仍无解决方法
required: true
- label: 【后端】确认不是依赖组件相关的问题例如sa-token、mybatis-plus、snail-job、crane4j、cosid等如有此类组件相关的问题请提交至对应组件仓库
required: true

View File

@@ -15,11 +15,9 @@ body:
options:
- label: 尝试了最新 dev 分支代码(演示环境),仍没有该功能
required: true
- label: 查阅过 [使用指南](https://continew.top/admin/backend/structure.html) 和 [常见问题](https://continew.top/admin/faq.html) ,仍然认为很有必要
- label: 查阅过 [使用指南](https://continew.top/docs/admin/guide/introduction.html) 和 [常见问题](https://continew.top/docs/admin/faq.html) ,仍然认为很有必要
required: true
- label: 查阅过 [需求墙](https://continew.top/admin/feature.html)没有该功能计划
required: true
- label: 搜索了项目 Issues没有其他人提交过类似的 Feature如果对应 Feature 尚未实现,您可以先订阅关注该 Issue为了方便后来者查找问题解决方法请避免创建重复的 Issue
- label: 搜索了 [吐槽广场](https://continew.top/docs/admin/issue-hub.html),没有其他人提交过类似的 Feature如果对应 Feature 尚未实现,您可以先订阅关注该 Issue为了方便后来者查找问题解决方法请避免创建重复的 Issue
required: true
- label: 【后端】确认不是依赖组件相关的需求例如sa-token、mybatis-plus、snail-job、crane4j、cosid等如有此类组件相关的需求请提交至对应组件仓库
required: true

View File

@@ -36,7 +36,7 @@ jobs:
username: ${{ secrets.SERVER_USERNAME }}
password: ${{ secrets.SERVER_PASSWORD }}
source: ./continew-server/target/app/*
target: /docker/continew-admin
target: ${{ secrets.SERVER_PATH }}/continew-admin
strip_components: 3
# 5、启动
- name: Start
@@ -47,6 +47,6 @@ jobs:
username: ${{ secrets.SERVER_USERNAME }}
password: ${{ secrets.SERVER_PASSWORD }}
script: |
cd /docker
cd ${{ secrets.SERVER_PATH }}
docker-compose up --force-recreate --build -d continew-server
docker images | grep none | awk '{print $3}' | xargs docker rmi
docker images | grep none | awk '{print $3}' | xargs -r docker rmi

View File

@@ -1,7 +1,7 @@
# ContiNew Admin 多租户中后台管理框架
<a href="https://github.com/continew-org/continew-admin" title="Release" target="_blank">
<img src="https://img.shields.io/badge/RELEASE-v4.0.0-%23ff3f59.svg" alt="Release" />
<img src="https://img.shields.io/badge/SNAPSHOT-v4.1.0-%23ff3f59.svg" alt="Release" />
</a>
<a href="https://github.com/continew-org/continew-starter" title="ContiNew Starter" target="_blank">
<img src="https://img.shields.io/badge/ContiNew Starter-2.13.4-%236CB52D.svg" alt="ContiNew Starter" />
@@ -38,7 +38,7 @@
<img src="https://gitcode.com/continew/continew-admin/star/badge.svg" alt="GitCode Stars" />
</a>
📚 [在线文档](https://continew.top) | 🚀 [演示地址](https://continew.top/admin/guide/demo.html)
📚 [在线文档](https://continew.top) | 🚀 [演示地址](https://continew.top/docs/admin/guide/demo.html) | 💬 [吐槽广场(你就是 Talk King!](https://continew.top/docs/admin/issue-hub.html) | [![问 DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/continew-org/continew-admin)
## 简介
@@ -80,7 +80,7 @@ ContiNew AdminContinue New Admin页面现代美观且专注设计与
## 为什么选我们?
> [!TIP]
> 更为完整的图文描述请查阅[《在线文档》](https://continew.top/admin/guide/why-choose-us.html)。
> 更为完整的图文描述请查阅[《在线文档》](https://continew.top/docs/admin/guide/why-choose-us.html)。
**AI 编程纪元已经开启,基于 ContiNew 项目开发,让 AI 助手“学习”更优雅的代码规范,“写出”更优质的代码。**
@@ -95,7 +95,7 @@ ContiNew AdminContinue New Admin页面现代美观且专注设计与
```java
@Tag(name = "部门管理 API")
@RestController
@CrudRequestMapping(value = "/system/dept", api = {Api.TREE, Api.GET, Api.CREATE, Api.UPDATE, Api.DELETE, Api.EXPORT, Api.DICT_TREE})
@CrudRequestMapping(value = "/system/dept", api = {Api.TREE, Api.GET, Api.CREATE, Api.UPDATE, Api.DELETE, Api.EXPORT, Api.TREE_DICT})
public class DeptController extends BaseController<DeptService, DeptResp, DeptDetailResp, DeptQuery, DeptReq> {}
```
@@ -134,8 +134,8 @@ public class DeptController extends BaseController<DeptService, DeptResp, DeptDe
## 系统功能
> [!TIP]
> 更多功能和优化正在赶来💦,最新项目计划、进展请进群或关注 [需求墙](https://continew.top/admin/feature.html) 和 [更新日志](https://continew.top/admin/changelog/)。
> 功能不会用?请查看 [功能手册](https://continew.top/admin/function/tenant/management.html)。
> 更多功能和优化正在赶来💦,最新项目计划、进展请进群或查看 [吐槽广场](https://continew.top/docs/admin/issue-hub.html) 和 [更新日志](https://continew.top/docs/admin/changelog/)。
> 功能不会用?请查看 [功能手册](https://continew.top/docs/admin/function/tenant/management.html)。
- 仪表盘:提供工作台、分析页,工作台提供功能快捷导航入口、最新公告、动态;分析页提供全面数据可视化能力
- 个人中心:支持基础信息修改、密码修改、邮箱绑定、手机号绑定(并提供行为验证码、短信限流等安全处理)、第三方账号绑定/解绑、头像裁剪上传
@@ -273,7 +273,7 @@ public class DeptController extends BaseController<DeptService, DeptResp, DeptDe
## 快速开始
> [!TIP]
> 更详细的流程,请查看在线文档[《快速开始》](https://continew.top/admin/guide/quick-start.html)。
> 更详细的流程,请查看在线文档[《快速开始》](https://continew.top/docs/admin/guide/quick-start.html)。
```bash
# 1.克隆本项目
@@ -497,7 +497,7 @@ ContiNewContinue New系列项目致力于通过持续迭代为开发者
### 分支说明
ContiNew 系列项目采用清晰的分支策略,确保开发与维护有序进行。提交 PR 前,请确认目标分支是否处于活跃维护状态,版本支持情况请查看 [更新日志#版本支持](https://continew.top/admin/changelog/)。
ContiNew 系列项目采用清晰的分支策略,确保开发与维护有序进行。提交 PR 前,请确认目标分支是否处于活跃维护状态,版本支持情况请查看 [更新日志#版本支持](https://continew.top/docs/admin/changelog/)。
| 分支 | 说明 |
| ----- | ------------------------------------------------------------ |
@@ -526,7 +526,12 @@ ContiNew 系列项目采用清晰的分支策略,确保开发与维护有序
## 反馈交流
欢迎各位小伙伴儿扫描下方二维码加入项目交流群,与项目维护团队及其他大佬用户实时交流讨论。
感谢您对 ContiNew 开源项目的关注与支持!我们非常重视每一位用户的反馈和建议,这是推动项目不断进步的动力。 欢迎扫描下方二维码加入我们的官方交流群,与项目维护团队及其他大佬用户实时交流探讨。
- 与项目核心团队直接沟通,获取第一手项目动态
- 解决使用过程中遇到的问题,分享经验心得
- 参与功能讨论和需求收集,影响项目未来发展
- 结识志同道合的技术爱好者,扩展人脉圈
<div align="left">
<img src=".image/qrcode.jpg" alt="二维码" height="230px" />
@@ -551,7 +556,7 @@ ContiNew 系列项目采用清晰的分支策略,确保开发与维护有序
## License
- 遵循 <a href="https://github.com/continew-org/continew-admin/blob/dev/LICENSE" target="_blank">Apache-2.0</a> 开源许可协议
- Copyright © 2022-present <a href="https://blog.charles7c.top" target="_blank">Charles7c</a>
- Copyright © 2022-present <a href="https://charles7c.top" target="_blank">Charles7c</a>
## GitHub Star 趋势

View File

@@ -31,12 +31,18 @@
<groupId>org.dromara.x-file-storage</groupId>
<artifactId>x-file-storage-spring</artifactId>
</dependency>
<!-- Amazon S3Amazon Simple Storage Service亚马逊简单存储服务通用存储协议 S3兼容主流云厂商对象存储 -->
<!-- Amazon S3Amazon Simple Storage Service亚马逊简单存储服务通用存储协议 S3兼容主流云厂商对象存储后续会移除替换1.x的版本 -->
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-java-sdk-s3</artifactId>
</dependency>
<!-- Amazon S3 2.x version Amazon Simple Storage Service亚马逊简单存储服务通用存储协议 S3兼容主流云厂商对象存储 -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
</dependency>
<!-- FreeMarker模板引擎 -->
<dependency>
<groupId>org.freemarker</groupId>
@@ -102,10 +108,15 @@
<artifactId>continew-starter-auth-justauth</artifactId>
</dependency>
<!-- ContiNew Starter 安全模块 - 加密 -->
<!-- ContiNew Starter 加密模块 - 字段加密 -->
<dependency>
<groupId>top.continew.starter</groupId>
<artifactId>continew-starter-security-crypto</artifactId>
<artifactId>continew-starter-encrypt-field</artifactId>
</dependency>
<!-- ContiNew Starter 加密模块 - 密码编码器 -->
<dependency>
<groupId>top.continew.starter</groupId>
<artifactId>continew-starter-encrypt-password-encoder</artifactId>
</dependency>
<!-- ContiNew Starter 安全模块 - 脱敏 -->
@@ -174,4 +185,4 @@
<artifactId>continew-starter-extension-tenant-mp</artifactId>
</dependency>
</dependencies>
</project>
</project>

View File

@@ -32,7 +32,8 @@ public interface MenuApi {
* 查询树结构列表
*
* @param excludeMenuIds 排除的菜单 ID 列表
* @param isSimple 是否是简单树结构
* @return 树结构列表
*/
List<Tree<Long>> listTree(List<Long> excludeMenuIds);
List<Tree<Long>> listTree(List<Long> excludeMenuIds, boolean isSimple);
}

View File

@@ -74,7 +74,7 @@ public class BaseController<S extends BaseService<L, D, Q, C>, L, D, Q, C> exten
}
}
// 不需要校验 DICT、DICT_TREE 接口权限
if (Api.DICT.equals(crudApi.value()) || Api.DICT_TREE.equals(crudApi.value())) {
if (Api.DICT.equals(crudApi.value()) || Api.TREE_DICT.equals(crudApi.value())) {
return;
}
// 校验权限例如创建用户接口POST /system/user => 校验 system:user:create 权限

View File

@@ -34,8 +34,8 @@ public class RsaProperties {
public static final String PUBLIC_KEY;
static {
PRIVATE_KEY = SpringUtil.getProperty("continew-starter.security.crypto.private-key");
PUBLIC_KEY = SpringUtil.getProperty("continew-starter.security.crypto.public-key");
PRIVATE_KEY = SpringUtil.getProperty("continew-starter.encrypt.field.private-key");
PUBLIC_KEY = SpringUtil.getProperty("continew-starter.encrypt.field.public-key");
}
private RsaProperties() {

View File

@@ -171,7 +171,7 @@ public class OperationDescriptionCustomizer {
if (crudRequestMapping == null || crudApi == null) {
return StringConstants.EMPTY;
}
if (Api.DICT.equals(crudApi.value()) || Api.DICT_TREE.equals(crudApi.value())) {
if (Api.DICT.equals(crudApi.value()) || Api.TREE_DICT.equals(crudApi.value())) {
return StringConstants.EMPTY;
}
String permissionPrefix = CrudApiPermissionPrefixCache.get(targetClass);

View File

@@ -34,7 +34,7 @@ public class DefaultDataPermissionUserDataProvider implements DataPermissionUser
@Override
public boolean isFilter() {
return !UserContextHolder.isSuperAdminUser() && !UserContextHolder.isTenantAdminUser();
return !UserContextHolder.isSuperAdmin() && !UserContextHolder.isTenantAdmin();
}
@Override

View File

@@ -60,9 +60,14 @@ public class RegexConstants {
public static final String PACKAGE_NAME = "^(?:[a-zA-Z_][a-zA-Z0-9_]*\\.)*[a-zA-Z_][a-zA-Z0-9_]*$";
/**
* HTTP 域名 URL 正则(非 IP 地址)
* HTTP URL 正则
*/
public static final String HTTP_DOMAIN_URL = "^(https?:\\/\\/)([a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,}(\\/[^\\s]*)?$";
public static final String URL_HTTP = "^(https?)://[\\w-+&@#/%?=~_|!:,.;]*[\\w-+&@#/%=~_|]$";
/**
* HTTP URL 正则(非 IP 地址)
*/
public static final String URL_HTTP_NOT_IP = "^(https?:\\/\\/)([a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,}(\\/[^\\s]*)?$";
/**
* HTTP HOST 正则

View File

@@ -132,11 +132,11 @@ public class UserContext implements Serializable {
}
/**
* 是否为超级管理员用户
* 是否为超级管理员
*
* @return truefalse
*/
public boolean isSuperAdminUser() {
public boolean isSuperAdmin() {
if (CollUtil.isEmpty(roleCodes)) {
return false;
}
@@ -144,11 +144,11 @@ public class UserContext implements Serializable {
}
/**
* 是否为租户管理员用户
* 是否为租户管理员
*
* @return truefalse
*/
public boolean isTenantAdminUser() {
public boolean isTenantAdmin() {
if (CollUtil.isEmpty(roleCodes)) {
return false;
}

View File

@@ -20,6 +20,7 @@ import cn.dev33.satoken.session.SaSession;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.convert.Convert;
import cn.hutool.extra.spring.SpringUtil;
import com.alibaba.ttl.TransmittableThreadLocal;
import top.continew.admin.common.api.system.UserApi;
import top.continew.starter.core.util.ExceptionUtils;
@@ -31,8 +32,8 @@ import top.continew.starter.core.util.ExceptionUtils;
*/
public class UserContextHolder {
private static final ThreadLocal<UserContext> CONTEXT_HOLDER = new ThreadLocal<>();
private static final ThreadLocal<UserExtraContext> EXTRA_CONTEXT_HOLDER = new ThreadLocal<>();
private static final TransmittableThreadLocal<UserContext> CONTEXT_HOLDER = new TransmittableThreadLocal<>();
private static final TransmittableThreadLocal<UserExtraContext> EXTRA_CONTEXT_HOLDER = new TransmittableThreadLocal<>();
private UserContextHolder() {
}
@@ -181,22 +182,22 @@ public class UserContextHolder {
}
/**
* 是否为超级管理员用户
* 是否为超级管理员
*
* @return truefalse
*/
public static boolean isSuperAdminUser() {
public static boolean isSuperAdmin() {
StpUtil.checkLogin();
return getContext().isSuperAdminUser();
return getContext().isSuperAdmin();
}
/**
* 是否为租户管理员用户
* 是否为租户管理员
*
* @return truefalse
*/
public static boolean isTenantAdminUser() {
public static boolean isTenantAdmin() {
StpUtil.checkLogin();
return getContext().isTenantAdminUser();
return getContext().isTenantAdmin();
}
}

View File

@@ -334,7 +334,7 @@ public class GeneratorServiceImpl implements GeneratorService {
if (!isBackend) {
fileName = ".vue".equals(extension) && "index".equals(classNameSuffix)
? "index.vue"
: this.getFrontendFileName(classNamePrefix, className, extension);
: this.getFrontendFileName(classNamePrefix, classNameSuffix, extension);
}
generatePreview.setFileName(fileName);
generatePreview.setContent(engine.getTemplate(templateConfig.getTemplatePath())

View File

@@ -100,15 +100,15 @@
</template>
</GiTable>
<${classNamePrefix}AddModal ref="${classNamePrefix}AddModalRef" @save-success="search" />
<${classNamePrefix}DetailDrawer ref="${classNamePrefix}DetailDrawerRef" />
<AddModal ref="AddModalRef" @save-success="search" />
<DetailDrawer ref="DetailDrawerRef" />
</GiPageLayout>
</template>
<script setup lang="ts">
import type { TableInstance } from '@arco-design/web-vue'
import ${classNamePrefix}AddModal from './${classNamePrefix}AddModal.vue'
import ${classNamePrefix}DetailDrawer from './${classNamePrefix}DetailDrawer.vue'
import AddModal from './AddModal.vue'
import DetailDrawer from './DetailDrawer.vue'
import { type ${classNamePrefix}Resp, type ${classNamePrefix}Query, delete${classNamePrefix}, export${classNamePrefix}, list${classNamePrefix} } from '@/apis/${apiModuleName}/${apiName}'
import { useDownload, useTable } from '@/hooks'
import { useDict } from '@/hooks/app'
@@ -185,21 +185,21 @@ const onExport = () => {
useDownload(() => export${classNamePrefix}(queryForm))
}
const ${classNamePrefix}AddModalRef = ref<InstanceType<typeof ${classNamePrefix}AddModal>>()
const AddModalRef = ref<InstanceType<typeof AddModal>>()
// 新增
const onAdd = () => {
${classNamePrefix}AddModalRef.value?.onAdd()
AddModalRef.value?.onAdd()
}
// 修改
const onUpdate = (record: ${classNamePrefix}Resp) => {
${classNamePrefix}AddModalRef.value?.onUpdate(record.id)
AddModalRef.value?.onUpdate(record.id)
}
const ${classNamePrefix}DetailDrawerRef = ref<InstanceType<typeof ${classNamePrefix}DetailDrawer>>()
const DetailDrawerRef = ref<InstanceType<typeof DetailDrawer>>()
// 详情
const onDetail = (record: ${classNamePrefix}Resp) => {
${classNamePrefix}DetailDrawerRef.value?.onOpen(record.id)
DetailDrawerRef.value?.onOpen(record.id)
}
</script>

View File

@@ -21,7 +21,7 @@ import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import top.continew.admin.open.model.entity.AppDO;
import top.continew.starter.data.mapper.BaseMapper;
import top.continew.starter.security.crypto.annotation.FieldEncrypt;
import top.continew.starter.encrypt.field.annotation.FieldEncrypt;
/**
* 应用 Mapper

View File

@@ -18,9 +18,9 @@ package top.continew.admin.open.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import top.continew.admin.common.enums.DisEnableStatusEnum;
import top.continew.admin.common.base.model.entity.BaseDO;
import top.continew.starter.security.crypto.annotation.FieldEncrypt;
import top.continew.admin.common.enums.DisEnableStatusEnum;
import top.continew.starter.encrypt.field.annotation.FieldEncrypt;
import java.io.Serial;
import java.time.LocalDateTime;

View File

@@ -22,6 +22,7 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import top.continew.admin.common.api.system.MenuApi;
import top.continew.admin.common.base.controller.BaseController;
@@ -56,7 +57,7 @@ public class PackageController extends BaseController<PackageService, PackageRes
@Operation(summary = "查询租户套餐菜单", description = "查询租户套餐菜单树列表")
@SaCheckPermission("tenant:package:list")
@GetMapping("/menu/tree")
public List<Tree<Long>> listMenuTree() {
return menuApi.listTree(tenantExtensionProperties.getIgnoreMenus());
public List<Tree<Long>> listMenuTree(@RequestParam(required = false, defaultValue = "true") Boolean isSimple) {
return menuApi.listTree(tenantExtensionProperties.getIgnoreMenus(), isSimple);
}
}

View File

@@ -45,6 +45,20 @@ public class TenantQuery implements Serializable {
@Query(columns = {"name", "description"}, type = QueryType.LIKE)
private String description;
/**
* 编码
*/
@Schema(description = "编码", example = "T0stxiJK6RMH")
@Query(type = QueryType.EQ)
private String code;
/**
* 域名
*/
@Schema(description = "域名", example = "admin.continew.top")
@Query(type = QueryType.LIKE)
private String domain;
/**
* 套餐 ID
*/

View File

@@ -33,9 +33,9 @@ import java.io.Serializable;
public class TenantAdminUserPwdUpdateReq implements Serializable {
/**
* 新密码(加密)
* 新密码
*/
@Schema(description = "新密码(加密)", example = "E7c72TH+LDxKTwavjM99W1MdI9Lljh79aPKiv3XB9MXcplhm7qJ1BJCj28yaflbdVbfc366klMtjLIWQGqb0qw==")
@Schema(description = "新密码", example = "RSA 公钥加密的新密码")
@NotBlank(message = "新密码不能为空")
private String password;
}

View File

@@ -97,9 +97,9 @@ public class TenantReq implements Serializable {
private String adminUsername;
/**
* 管理员密码(加密)
* 管理员密码
*/
@Schema(description = "管理员密码(加密)", example = "E7c72TH+LDxKTwavjM99W1MdI9Lljh79aPKiv3XB9MXcplhm7qJ1BJCj28yaflbdVbfc366klMtjLIWQGqb0qw==")
@Schema(description = "管理员密码", example = "RSA 公钥加密的管理员密码")
@NotBlank(message = "管理员密码不能为空", groups = CrudValidationGroup.Create.class)
private String adminPassword;

View File

@@ -33,9 +33,14 @@ import org.springframework.boot.SpringBootVersion;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.web.ServerProperties;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import top.continew.admin.common.context.UserContext;
import top.continew.admin.common.context.UserContextHolder;
import top.continew.starter.core.autoconfigure.application.ApplicationProperties;
import top.continew.starter.core.util.SpringUtils;
import top.continew.starter.extension.crud.annotation.EnableCrudApi;
import top.continew.starter.web.annotation.EnableGlobalResponse;
import top.continew.starter.web.model.R;
@@ -59,6 +64,7 @@ public class ContiNewAdminApplication implements ApplicationRunner {
private final ApplicationProperties applicationProperties;
private final ServerProperties serverProperties;
private final ThreadPoolTaskExecutor threadPoolTaskExecutor;
public static void main(String[] args) {
SpringApplication.run(ContiNewAdminApplication.class, args);
@@ -88,9 +94,20 @@ public class ContiNewAdminApplication implements ApplicationRunner {
if (!knife4jProperties.isProduction()) {
log.info("接口文档: {}/doc.html", baseUrl);
}
log.info("常见问题: https://continew.top/admin/faq.html");
log.info("更新日志: https://continew.top/admin/changelog/");
log.info("吐槽广场: https://continew.top/docs/admin/issue-hub.html");
log.info("常见问题: https://continew.top/docs/admin/faq.html");
log.info("更新日志: https://continew.top/docs/admin/changelog/");
log.info("ContiNew Admin: 持续迭代优化的,高质量多租户中后台管理系统框架");
log.info("--------------------------------------------------------");
UserContext userContext = new UserContext();
userContext.setId(222L);
UserContextHolder.setContext(userContext);
log.info("userId: {}", UserContextHolder.getUserId());
SpringUtils.getProxy(this).async();
}
@Async
public void async() {
log.info("async: {}", UserContextHolder.getUserId());
}
}

View File

@@ -131,9 +131,10 @@ public class SaTokenConfiguration {
}).filter(Objects::nonNull).toList();
if (!additionalExcludes.isEmpty()) {
// 合并现有的 excludes 和新扫描到的
String[] existingExcludes = Optional.ofNullable(properties.getSecurity().getExcludes()).orElse(new String[0]);
String[] existingExcludes = Optional.ofNullable(properties.getSecurity().getExcludes())
.orElse(new String[0]);
String[] combinedExcludes = Stream.concat(Arrays.stream(existingExcludes), additionalExcludes.stream())
.toArray(String[]::new);
.toArray(String[]::new);
properties.getSecurity().setExcludes(combinedExcludes);
}
log.debug("缓存 CRUD API 权限前缀完成:{}", CrudApiPermissionPrefixCache.getAll().values());

View File

@@ -119,8 +119,14 @@ logging:
file:
path: ./logs
--- ### 安全配置:加/解密配置
continew-starter.security.crypto:
--- ### 加密配置:密码编码器配置
continew-starter.encrypt.password-encoder:
enabled: true
# 默认启用的编码器算法默认BCrypt 加密算法)
algorithm: BCRYPT
--- ### 加密配置:字段加/解密配置
continew-starter.encrypt.field:
enabled: true
# 默认算法,即 @FieldEncrypt 默认采用的算法默认AES 对称加密算法)
algorithm: AES
@@ -129,11 +135,6 @@ continew-starter.security.crypto:
# 非对称加密算法密钥(在线生成 RSA 密钥对http://web.chacuo.net/netrsakeypair
public-key: MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAM51dgYtMyF+tTQt80sfFOpSV27a7t9uaUVeFrdGiVxscuizE7H8SMntYqfn9lp8a5GH5P1/GGehVjUD2gF/4kcCAwEAAQ==
private-key: MIIBVQIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEAznV2Bi0zIX61NC3zSx8U6lJXbtru325pRV4Wt0aJXGxy6LMTsfxIye1ip+f2WnxrkYfk/X8YZ6FWNQPaAX/iRwIDAQABAkEAk/VcAusrpIqA5Ac2P5Tj0VX3cOuXmyouaVcXonr7f+6y2YTjLQuAnkcfKKocQI/juIRQBFQIqqW/m1nmz1wGeQIhAO8XaA/KxzOIgU0l/4lm0A2Wne6RokJ9HLs1YpOzIUmVAiEA3Q9DQrpAlIuiT1yWAGSxA9RxcjUM/1kdVLTkv0avXWsCIE0X8woEjK7lOSwzMG6RpEx9YHdopjViOj1zPVH61KTxAiBmv/dlhqkJ4rV46fIXELZur0pj6WC3N7a4brR8a+CLLQIhAMQyerWl2cPNVtE/8tkziHKbwW3ZUiBXU24wFxedT9iV
## 密码编码器配置
password-encoder:
enabled: true
# 默认启用的编码器算法默认BCrypt 加密算法)
algorithm: BCRYPT
--- ### 验证码配置
continew-starter.captcha:

View File

@@ -128,8 +128,14 @@ logging:
file:
path: ../logs
--- ### 安全配置:加/解密配置
continew-starter.security.crypto:
--- ### 加密配置:密码编码器配置
continew-starter.encrypt.password-encoder:
enabled: true
# 默认启用的编码器算法默认BCrypt 加密算法)
algorithm: BCRYPT
--- ### 加密配置:字段加/解密配置
continew-starter.encrypt.field:
enabled: true
# 默认算法,即 @FieldEncrypt 默认采用的算法默认AES 对称加密算法)
algorithm: AES
@@ -138,11 +144,6 @@ continew-starter.security.crypto:
# 非对称加密算法密钥(在线生成 RSA 密钥对http://web.chacuo.net/netrsakeypair
public-key: MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAM51dgYtMyF+tTQt80sfFOpSV27a7t9uaUVeFrdGiVxscuizE7H8SMntYqfn9lp8a5GH5P1/GGehVjUD2gF/4kcCAwEAAQ==
private-key: MIIBVQIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEAznV2Bi0zIX61NC3zSx8U6lJXbtru325pRV4Wt0aJXGxy6LMTsfxIye1ip+f2WnxrkYfk/X8YZ6FWNQPaAX/iRwIDAQABAkEAk/VcAusrpIqA5Ac2P5Tj0VX3cOuXmyouaVcXonr7f+6y2YTjLQuAnkcfKKocQI/juIRQBFQIqqW/m1nmz1wGeQIhAO8XaA/KxzOIgU0l/4lm0A2Wne6RokJ9HLs1YpOzIUmVAiEA3Q9DQrpAlIuiT1yWAGSxA9RxcjUM/1kdVLTkv0avXWsCIE0X8woEjK7lOSwzMG6RpEx9YHdopjViOj1zPVH61KTxAiBmv/dlhqkJ4rV46fIXELZur0pj6WC3N7a4brR8a+CLLQIhAMQyerWl2cPNVtE/8tkziHKbwW3ZUiBXU24wFxedT9iV
## 密码编码器配置
password-encoder:
enabled: true
# 默认启用的编码器算法默认BCrypt 加密算法)
algorithm: BCRYPT
--- ### 验证码配置
continew-starter.captcha:

View File

@@ -6,7 +6,7 @@ application:
# 描述
description: 持续迭代优化的前后端分离中后台管理系统框架,开箱即用,持续提供舒适的开发体验。
# 版本
version: 4.0.0
version: 4.1.0-SNAPSHOT
starter: 2.13.4
# 基本包
base-package: top.continew.admin
@@ -182,8 +182,8 @@ continew-starter.trace:
--- ### CRUD 配置
continew-starter.crud:
## 全局树结构配置(简单树,对应前端 UI
tree:
## 树型字典结构映射配置(简单树,对应前端 UI
tree-dict-model:
id-key: key
name-key: title
weight-key: sort
@@ -236,9 +236,10 @@ continew-starter.rate-limiter:
sa-token:
# Token 名称(同时也是 cookie 名称)
token-name: Authorization
## 提示:通过 [系统管理/系统配置/客户端配置]功能 动态维护,如果不需要可移除相关配置代码,放开下方注释
# 是否启用动态 activeTimeout 功能
dynamic-active-timeout: true
## 提示:请通过页面功能 [系统管理/系统配置/客户端配置] 动态调整 timeout、active-timeout 配置项
## 如果不需要动态配置,请将 dynamic-active-timeout 设为 false并取消下方 timeout 和 active-timeout 配置的注释,最好再移除相关登录处理代码
# # Token 有效期(单位:秒,默认 30 天,-1 代表永不过期)
# timeout: 86400
# # Token 最低活跃频率(单位:秒,默认 -1代表不限制永不冻结。如果 token 超过此时间没有访问系统就会被冻结)

View File

@@ -110,7 +110,7 @@ public abstract class AbstractLoginHandler<T extends LoginReq> implements LoginH
return roles;
}, threadPoolTaskExecutor);
CompletableFuture<Integer> passwordExpirationDaysFuture = CompletableFuture.supplyAsync(() -> optionService
.getValueByCode2Int(PASSWORD_EXPIRATION_DAYS.name()));
.getValueByCode2Int(PASSWORD_EXPIRATION_DAYS.name()), threadPoolTaskExecutor);
CompletableFuture.allOf(permissionFuture, roleFuture, passwordExpirationDaysFuture);
UserContext userContext = new UserContext(permissionFuture.join(), roleFuture
.join(), passwordExpirationDaysFuture.join());

View File

@@ -43,9 +43,9 @@ public class AccountLoginReq extends LoginReq {
private String username;
/**
* 密码(加密)
* 密码
*/
@Schema(description = "密码(加密)", example = "HHwZoiBwCfh0xLdWOAd0bHOkEZlIMMOQKJyeFUw9T3ArrhL57od2i42s1o0sSXKkeHPJXvQsninhPFH2lArDDQ==")
@Schema(description = "密码", example = "RSA 公钥加密的密码")
@NotBlank(message = "密码不能为空")
private String password;

View File

@@ -27,6 +27,7 @@ import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
/**
@@ -140,6 +141,12 @@ public class UserInfoResp implements Serializable {
@Schema(description = "角色编码集合", example = "[\"test\"]")
private Set<String> roles;
/**
* 角色名称列表
*/
@Schema(description = "角色名称列表", example = "测试人员")
private List<String> roleNames;
public LocalDate getRegistrationDate() {
return createTime.toLocalDate();
}

View File

@@ -104,7 +104,7 @@ public class AuthServiceImpl implements AuthService {
}
// 构建路由树
TreeField treeField = MenuResp.class.getDeclaredAnnotation(TreeField.class);
TreeNodeConfig treeNodeConfig = crudProperties.getTree().genTreeNodeConfig(treeField);
TreeNodeConfig treeNodeConfig = crudProperties.getTreeDictModel().genTreeNodeConfig(treeField);
List<Tree<Long>> treeList = TreeUtil.build(menuList, treeField.rootId(), treeNodeConfig, (m, tree) -> {
tree.setId(m.getId());
tree.setParentId(m.getParentId());

View File

@@ -39,11 +39,11 @@ public class MenuApiImpl implements MenuApi {
private final MenuService baseService;
@Override
public List<Tree<Long>> listTree(List<Long> excludeMenuIds) {
public List<Tree<Long>> listTree(List<Long> excludeMenuIds, boolean isSimple) {
MenuQuery query = new MenuQuery();
query.setStatus(DisEnableStatusEnum.ENABLE);
// 过滤掉租户不能使用的菜单
query.setExcludeMenuIdList(excludeMenuIds);
return baseService.tree(query, null, true);
return baseService.tree(query, null, isSimple);
}
}

View File

@@ -0,0 +1,98 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.admin.system.constant;
/**
* 分片上传常量
*
* @author KAI
* @since 2025/7/30 17:40
*/
public class MultipartUploadConstants {
//todo 后续改为从配置文件读取
/**
* MD5到uploadId的映射前缀
* <p>
* 用于存储文件MD5到uploadId的映射关系实现基于MD5的双列Map结构。
* 键格式multipart:md5_to_upload:{md5}
* 值格式Hash结构包含uploadId和fileInfo
* </p>
*/
public static final String MD5_TO_UPLOAD_ID_PREFIX = "multipart:md5_to_upload:";
/**
* 分片上传信息前缀
* <p>
* 用于存储分片上传的初始化信息包含uploadId、bucket、path等基本信息。
* 键格式multipart:upload:{uploadId}
* 值格式JSON字符串包含MultipartInitResp的序列化数据
* </p>
*/
public static final String MULTIPART_UPLOAD_PREFIX = "multipart:upload:";
/**
* 分片信息前缀
* <p>
* 用于存储所有分片的上传信息使用Hash结构存储。
* 键格式multipart:parts:{uploadId}
* 值格式Hash结构field为分片编号value为FilePartInfo的JSON序列化数据
* </p>
*/
public static final String MULTIPART_PARTS_PREFIX = "multipart:parts:";
/**
* 元数据前缀
* <p>
* 用于存储分片上传的元数据信息,如文件名、大小、类型等。
* 键格式multipart:metadata:{uploadId}
* 值格式Hash结构field为元数据键value为元数据值
* </p>
*/
public static final String MULTIPART_METADATA_PREFIX = "multipart:metadata:";
/**
* 过期时间前缀
* <p>
* 用于存储分片上传的过期时间,用于定期清理过期数据。
* 键格式multipart:expire:{uploadId}
* 值格式ISO格式的时间字符串
* </p>
*/
public static final String MULTIPART_EXPIRE_PREFIX = "multipart:expire:";
/**
* 默认过期时间(小时)
* <p>
* 分片上传缓存数据的默认过期时间,超过此时间的数据会被自动清理。
* 设置为24小时平衡存储空间和用户体验。
* </p>
*/
public static final long DEFAULT_EXPIRE_HOURS = 24;
/**
* 临时文件夹
* <p>
* 分片上传的临时文件夹名称
* </p>
*/
public static final String TEMP_DIR_NAME = "temp";
/**
* 分片大小
*/
public static final long MULTIPART_UPLOAD_PART_SIZE = 5 * 1024 * 1024;
}

View File

@@ -35,6 +35,6 @@ import top.continew.starter.extension.crud.enums.Api;
@Tag(name = "部门管理 API")
@RestController
@CrudRequestMapping(value = "/system/dept", api = {Api.TREE, Api.GET, Api.CREATE, Api.UPDATE, Api.BATCH_DELETE,
Api.EXPORT, Api.DICT_TREE})
Api.EXPORT, Api.TREE_DICT})
public class DeptController extends BaseController<DeptService, DeptResp, DeptResp, DeptQuery, DeptReq> {
}

View File

@@ -50,7 +50,7 @@ import java.lang.reflect.Method;
@RestController
@RequiredArgsConstructor
@CrudRequestMapping(value = "/system/menu", api = {Api.TREE, Api.GET, Api.CREATE, Api.UPDATE, Api.BATCH_DELETE,
Api.DICT_TREE})
Api.TREE_DICT})
public class MenuController extends BaseController<MenuService, MenuResp, MenuResp, MenuQuery, MenuReq> {
@Operation(summary = "清除缓存", description = "清除缓存")

View File

@@ -0,0 +1,95 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.admin.system.controller;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import top.continew.admin.system.model.entity.FileDO;
import top.continew.admin.system.model.req.MultipartUploadInitReq;
import top.continew.admin.system.model.resp.file.MultipartUploadInitResp;
import top.continew.admin.system.model.resp.file.MultipartUploadResp;
import top.continew.admin.system.service.MultipartUploadService;
/**
* 分片上传控制器
*
* @author KAI
* @since 2025/7/30 16:38
*/
@RestController
@RequestMapping("/system/multipart-upload")
@RequiredArgsConstructor
public class MultipartUploadController {
private final MultipartUploadService multipartUploadService;
/**
* 初始化分片上传
*
* @param multiPartUploadInitReq 分片上传信息
* @return 初始化响应
*/
@Operation(summary = "初始化分片上传", description = "初始化分片上传返回uploadId等信息")
@PostMapping("/init")
public MultipartUploadInitResp initMultipartUpload(@RequestBody @Valid MultipartUploadInitReq multiPartUploadInitReq) {
return multipartUploadService.initMultipartUpload(multiPartUploadInitReq);
}
/**
* 上传分片
*
* @param file 分片文件
* @param uploadId 上传ID
* @param partNumber 分片编号
* @param path 文件路径
* @return 上传结果
*/
@Operation(summary = "上传分片", description = "上传单个分片")
@PostMapping("/part")
public MultipartUploadResp uploadPart(@RequestPart("file") MultipartFile file,
@RequestParam("uploadId") String uploadId,
@RequestParam("partNumber") Integer partNumber,
@RequestParam("path") String path) {
return multipartUploadService.uploadPart(file, uploadId, partNumber, path);
}
/**
* 合并分片
*
* @param uploadId 上传ID
*/
@Operation(summary = "完成分片上传", description = "合并所有分片,完成上传")
@GetMapping("/complete/{uploadId}")
public FileDO completeMultipartUpload(@PathVariable String uploadId) {
return multipartUploadService.completeMultipartUpload(uploadId);
}
/**
* 取消分片上传
*
* @param uploadId 上传ID
*/
@Operation(summary = "取消分片上传", description = "删除缓存信息,分片数据")
@GetMapping("/cancel/{uploadId}")
public void cancelMultipartUpload(@PathVariable String uploadId) {
multipartUploadService.cancelMultipartUpload(uploadId);
}
}

View File

@@ -25,7 +25,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import top.continew.admin.system.model.query.OptionQuery;
import top.continew.admin.system.model.req.OptionReq;
import top.continew.admin.system.model.req.OptionResetValueReq;
import top.continew.admin.system.model.req.OptionValueResetReq;
import top.continew.admin.system.model.resp.OptionResp;
import top.continew.admin.system.service.OptionService;
@@ -65,7 +65,7 @@ public class OptionController {
@SaCheckPermission(value = {"system:siteConfig:update", "system:securityConfig:update", "system:loginConfig:update",
"system:mailConfig:update"}, mode = SaMode.OR)
@PatchMapping("/value")
public void resetValue(@RequestBody @Valid OptionResetValueReq req) {
public void resetValue(@RequestBody @Valid OptionValueResetReq req) {
baseService.resetValue(req);
}
}

View File

@@ -32,7 +32,7 @@ import top.continew.admin.common.base.controller.BaseController;
import top.continew.admin.system.model.query.RoleQuery;
import top.continew.admin.system.model.query.RoleUserQuery;
import top.continew.admin.system.model.req.RoleReq;
import top.continew.admin.system.model.req.RoleUpdatePermissionReq;
import top.continew.admin.system.model.req.RolePermissionUpdateReq;
import top.continew.admin.system.model.resp.role.RoleDetailResp;
import top.continew.admin.system.model.resp.role.RolePermissionResp;
import top.continew.admin.system.model.resp.role.RoleResp;
@@ -75,7 +75,7 @@ public class RoleController extends BaseController<RoleService, RoleResp, RoleDe
@Operation(summary = "修改权限", description = "修改角色的功能权限")
@SaCheckPermission("system:role:updatePermission")
@PutMapping("/{id}/permission")
public void updatePermission(@PathVariable("id") Long id, @RequestBody @Valid RoleUpdatePermissionReq req) {
public void updatePermission(@PathVariable("id") Long id, @RequestBody @Valid RolePermissionUpdateReq req) {
baseService.updatePermission(id, req);
}

View File

@@ -0,0 +1,124 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.admin.system.dao;
import top.continew.admin.system.model.resp.file.FilePartInfo;
import top.continew.admin.system.model.resp.file.MultipartUploadInitResp;
import java.util.List;
import java.util.Map;
/**
* 分片上传持久化接口
* <p>
* 纯粹的缓存操作,不包含业务逻辑:
* 1. MD5到uploadId的映射管理
* 2. 分片信息缓存
* 3. 上传状态缓存
* </p>
*
* @author KAI
* @since 2.14.0
*/
public interface MultipartUploadDao {
/**
* 根据MD5获取uploadId
*
* @param md5 文件MD5值
* @return uploadId如果不存在则返回null
*/
String getUploadIdByMd5(String md5);
/**
* 缓存MD5到uploadId的映射
*
* @param md5 文件MD5值
* @param uploadId 上传ID
*/
void setMd5Mapping(String md5, String uploadId);
/**
* 删除MD5映射
*
* @param md5 文件MD5值
*/
void deleteMd5Mapping(String md5);
/**
* 设置缓存分片上传信息
*
* @param uploadId 上传ID
* @param initResp 初始化响应
* @param metadata 元数据
*/
void setMultipartUpload(String uploadId, MultipartUploadInitResp initResp, Map<String, String> metadata);
/**
* 获取分片上传信息
*
* @param uploadId 上传ID
* @return 分片上传信息如果不存在则返回null
*/
MultipartUploadInitResp getMultipartUpload(String uploadId);
/**
* 删除分片上传信息
*
* @param uploadId 上传ID
*/
void deleteMultipartUpload(String uploadId);
void deleteMultipartUploadAll(String uploadId);
/**
* 设置缓存分片信息
*
* @param uploadId 上传ID
* @param filePartInfo 分片信息
*/
void setFilePart(String uploadId, FilePartInfo filePartInfo);
/**
* 获取所有分片信息
*
* @param uploadId 上传ID
* @return 分片信息列表
*/
List<FilePartInfo> getFileParts(String uploadId);
/**
* 删除所有分片信息
*
* @param uploadId 上传ID
*/
void deleteFileParts(String uploadId);
/**
* 检查分片是否存在
*
* @param uploadId 上传ID
* @param partNumber 分片编号
* @return 是否存在
*/
boolean existsFilePart(String uploadId, int partNumber);
/**
* 清理过期的缓存数据
*/
void cleanupExpiredData();
}

View File

@@ -0,0 +1,260 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.admin.system.dao.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import top.continew.admin.system.constant.MultipartUploadConstants;
import top.continew.admin.system.dao.MultipartUploadDao;
import top.continew.admin.system.model.resp.file.FilePartInfo;
import top.continew.admin.system.model.resp.file.MultipartUploadInitResp;
import top.continew.starter.cache.redisson.util.RedisUtils;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
/**
* Redis分片上传缓存实现
* <p>
* 核心功能:
* 1. MD5到uploadId的映射管理
* 2. 分片信息缓存
* 3. 上传状态缓存
* </p>
*
* @author KAI
* @since 2025/7/30 17:40
*/
@Slf4j
@Repository
public class RedisMultipartUploadDaoDaoImpl implements MultipartUploadDao {
@Override
public String getUploadIdByMd5(String md5) {
String md5Key = MultipartUploadConstants.MD5_TO_UPLOAD_ID_PREFIX + md5;
try {
return RedisUtils.hGet(md5Key, "uploadId");
} catch (Exception e) {
log.error("根据MD5获取uploadId失败: md5={}", md5, e);
return null;
}
}
@Override
public void setMd5Mapping(String md5, String uploadId) {
String md5Key = MultipartUploadConstants.MD5_TO_UPLOAD_ID_PREFIX + md5;
try {
RedisUtils.hSet(md5Key, "uploadId", uploadId);
RedisUtils.expire(md5Key, Duration.ofHours(MultipartUploadConstants.DEFAULT_EXPIRE_HOURS));
log.debug("缓存MD5映射: md5={}, uploadId={}", md5, uploadId);
} catch (Exception e) {
log.error("缓存MD5映射失败: md5={}, uploadId={}", md5, uploadId, e);
throw new RuntimeException("缓存MD5映射失败", e);
}
}
@Override
public void deleteMd5Mapping(String md5) {
String md5Key = MultipartUploadConstants.MD5_TO_UPLOAD_ID_PREFIX + md5;
try {
RedisUtils.delete(md5Key);
log.debug("删除MD5映射: md5={}", md5);
} catch (Exception e) {
log.error("删除MD5映射失败: md5={}", md5, e);
}
}
private String getMd5Mapping(String uploadId) {
List<Object> list = RedisUtils.getList(MultipartUploadConstants.MD5_TO_UPLOAD_ID_PREFIX);
return null;
}
@Override
public void setMultipartUpload(String uploadId, MultipartUploadInitResp initResp, Map<String, String> metadata) {
String key = MultipartUploadConstants.MULTIPART_UPLOAD_PREFIX + uploadId;
String metadataKey = MultipartUploadConstants.MULTIPART_METADATA_PREFIX + uploadId;
try {
// 缓存初始化信息
RedisUtils.set(key, JSONUtil.toJsonStr(initResp), Duration
.ofHours(MultipartUploadConstants.DEFAULT_EXPIRE_HOURS));
// 缓存元数据
if (metadata != null && !metadata.isEmpty()) {
for (Map.Entry<String, String> entry : metadata.entrySet()) {
RedisUtils.hSet(metadataKey, entry.getKey(), entry.getValue());
}
RedisUtils.expire(metadataKey, Duration.ofHours(MultipartUploadConstants.DEFAULT_EXPIRE_HOURS));
}
log.debug("缓存分片上传信息: uploadId={}", uploadId);
} catch (Exception e) {
log.error("缓存分片上传信息失败: uploadId={}", uploadId, e);
throw new RuntimeException("缓存分片上传信息失败", e);
}
}
@Override
public MultipartUploadInitResp getMultipartUpload(String uploadId) {
String key = MultipartUploadConstants.MULTIPART_UPLOAD_PREFIX + uploadId;
try {
Object value = RedisUtils.get(key);
if (value != null) {
return JSONUtil.toBean(value.toString(), MultipartUploadInitResp.class);
}
return null;
} catch (Exception e) {
log.error("获取分片上传信息失败: uploadId={}", uploadId, e);
return null;
}
}
@Override
public void deleteMultipartUpload(String uploadId) {
try {
String key = MultipartUploadConstants.MULTIPART_UPLOAD_PREFIX + uploadId;
String metadataKey = MultipartUploadConstants.MULTIPART_METADATA_PREFIX + uploadId;
String expireKey = MultipartUploadConstants.MULTIPART_EXPIRE_PREFIX + uploadId;
// 先获取MD5信息再删除数据
MultipartUploadInitResp initResp = getMultipartUpload(uploadId);
String fileMd5 = initResp.getFileMd5();
if (StrUtil.isNotBlank(fileMd5)) {
deleteMd5Mapping(fileMd5);
}
// 删除分片上传相关数据
RedisUtils.delete(key);
RedisUtils.delete(metadataKey);
RedisUtils.delete(expireKey);
log.debug("删除分片上传信息: uploadId={}", uploadId);
} catch (Exception e) {
log.error("删除分片上传信息失败: uploadId={}", uploadId, e);
}
}
@Override
public void deleteMultipartUploadAll(String uploadId) {
this.deleteMultipartUpload(uploadId);
this.deleteFileParts(uploadId);
// this.deleteMd5Mapping();
}
@Override
public void setFilePart(String uploadId, FilePartInfo partInfo) {
String key = MultipartUploadConstants.MULTIPART_PARTS_PREFIX + uploadId;
String partKey = partInfo.getPartNumber().toString();
try {
RedisUtils.hSet(key, partKey, JSONUtil.toJsonStr(partInfo));
RedisUtils.expire(key, Duration.ofHours(MultipartUploadConstants.DEFAULT_EXPIRE_HOURS));
log.debug("缓存分片信息: uploadId={}, partNumber={}", uploadId, partKey);
} catch (Exception e) {
log.error("缓存分片信息失败: uploadId={}, partNumber={}", uploadId, partKey, e);
throw new RuntimeException("缓存分片信息失败", e);
}
}
@Override
public List<FilePartInfo> getFileParts(String uploadId) {
String key = MultipartUploadConstants.MULTIPART_PARTS_PREFIX + uploadId;
try {
Map<String, Object> entries = RedisUtils.hGetAll(key);
if (entries.isEmpty()) {
return new ArrayList<>();
}
return entries.values()
.stream()
.map(value -> JSONUtil.toBean(value.toString(), FilePartInfo.class))
.sorted(Comparator.comparing(FilePartInfo::getPartNumber))
.collect(Collectors.toList());
} catch (Exception e) {
log.error("获取分片列表失败: uploadId={}", uploadId, e);
return new ArrayList<>();
}
}
@Override
public void deleteFileParts(String uploadId) {
String key = MultipartUploadConstants.MULTIPART_PARTS_PREFIX + uploadId;
try {
RedisUtils.delete(key);
log.debug("删除所有分片信息: uploadId={}", uploadId);
} catch (Exception e) {
log.error("删除所有分片信息失败: uploadId={}", uploadId, e);
}
}
@Override
public boolean existsFilePart(String uploadId, int partNumber) {
String key = MultipartUploadConstants.MULTIPART_PARTS_PREFIX + uploadId;
String partKey = String.valueOf(partNumber);
return RedisUtils.hExists(key, partKey);
}
@Override
public void cleanupExpiredData() {
try {
// 获取所有分片上传的过期时间
Collection<String> keys = RedisUtils.keys(MultipartUploadConstants.MULTIPART_EXPIRE_PREFIX + "*");
if (keys.isEmpty()) {
return;
}
LocalDateTime now = LocalDateTime.now();
List<String> expiredUploadIds = new ArrayList<>();
for (String key : keys) {
String uploadId = key.substring(MultipartUploadConstants.MULTIPART_EXPIRE_PREFIX.length());
Object value = RedisUtils.get(key);
if (value != null) {
try {
LocalDateTime expireTime = LocalDateTime.parse(value
.toString(), DateTimeFormatter.ISO_LOCAL_DATE_TIME);
if (now.isAfter(expireTime)) {
expiredUploadIds.add(uploadId);
}
} catch (Exception e) {
log.warn("解析过期时间失败: uploadId={}, value={}", uploadId, value);
expiredUploadIds.add(uploadId);
}
}
}
// 删除过期的数据
for (String uploadId : expiredUploadIds) {
deleteMultipartUpload(uploadId);
deleteFileParts(uploadId);
log.info("清理过期数据: uploadId={}", uploadId);
}
log.info("清理过期数据完成: count={}", expiredUploadIds.size());
} catch (Exception e) {
log.error("清理过期数据失败", e);
}
}
}

View File

@@ -65,7 +65,7 @@ public enum StorageTypeEnum implements BaseEnum<Integer> {
public void validate(StorageReq req) {
ValidationUtils.validate(req, ValidationGroup.Storage.OSS.class);
ValidationUtils.throwIf(StrUtil.isNotBlank(req.getDomain()) && !ReUtil
.isMatch(RegexConstants.HTTP_DOMAIN_URL, req.getDomain()), "域名格式不正确");
.isMatch(RegexConstants.URL_HTTP_NOT_IP, req.getDomain()), "域名格式不正确");
}
};

View File

@@ -0,0 +1,61 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.admin.system.factory;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.S3Configuration;
import software.amazon.awssdk.utils.SdkAutoCloseable;
import top.continew.admin.system.model.entity.StorageDO;
import java.net.URI;
import java.util.concurrent.ConcurrentHashMap;
/**
* 异步 S3 客户端工厂
* <p>支持多 endpoint / 多 accessKey 的动态客户端池</p>
*/
@Slf4j
@Component
public class S3ClientFactory {
private final ConcurrentHashMap<String, S3Client> CLIENT_CACHE = new ConcurrentHashMap<>();
public S3Client getClient(StorageDO storage) {
String key = storage.getEndpoint() + "|" + storage.getAccessKey();
return CLIENT_CACHE.computeIfAbsent(key, k -> {
StaticCredentialsProvider auth = StaticCredentialsProvider.create(AwsBasicCredentials.create(storage
.getAccessKey(), storage.getSecretKey()));
return S3Client.builder()
.credentialsProvider(auth)
.endpointOverride(URI.create(storage.getEndpoint()))
.region(Region.US_EAST_1)
.serviceConfiguration(S3Configuration.builder().chunkedEncodingEnabled(false).build())
.build();
});
}
@PreDestroy
public void closeAll() {
CLIENT_CACHE.values().forEach(SdkAutoCloseable::close);
}
}

View File

@@ -0,0 +1,73 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.admin.system.factory;/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import cn.hutool.core.util.StrUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import top.continew.admin.system.enums.StorageTypeEnum;
import top.continew.admin.system.handler.StorageHandler;
import top.continew.starter.core.exception.BaseException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* 存储处理器工厂
* <p>按类型分发 StorageHandler</p>
*
* @author KAI
* @since 2025/07/24 13:35
*/
@Component
public class StorageHandlerFactory {
private final Map<StorageTypeEnum, StorageHandler> HANDLER_MAP = new ConcurrentHashMap<>();
@Autowired
public StorageHandlerFactory(List<StorageHandler> handlers) {
for (StorageHandler handler : handlers) {
HANDLER_MAP.put(handler.getType(), handler);
}
}
/**
* 获取指定类型的存储处理器
*
* @param type 存储类型
* @return StorageHandler
*/
public StorageHandler createHandler(StorageTypeEnum type) {
return Optional.ofNullable(HANDLER_MAP.get(type))
.orElseThrow(() -> new BaseException(StrUtil.format("不存在此类型存储处理器:{}: ", type)));
}
}

View File

@@ -0,0 +1,81 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.admin.system.handler;
import org.springframework.web.multipart.MultipartFile;
import top.continew.admin.system.enums.StorageTypeEnum;
import top.continew.admin.system.model.entity.StorageDO;
import top.continew.admin.system.model.req.MultipartUploadInitReq;
import top.continew.admin.system.model.resp.file.MultipartUploadInitResp;
import top.continew.admin.system.model.resp.file.MultipartUploadResp;
import java.util.List;
/**
* 存储类型处理器
* <p>
* 专注于文件操作,不包含业务逻辑
*
* @author KAI
* @since 2025/7/30 17:15
*/
public interface StorageHandler {
MultipartUploadInitResp initMultipartUpload(StorageDO storageDO, MultipartUploadInitReq req);
/**
* 分片上传
*
* @param storageDO 存储实体
* @param path 存储路径
* @param uploadId 文件名
* @param file 文件对象
* @return {@link MultipartUploadResp} 分片上传结果
*/
MultipartUploadResp uploadPart(StorageDO storageDO,
String path,
String uploadId,
Integer partNumber,
MultipartFile file);
/**
* 合并分片
*
* @param storageDO 存储实体
* @param uploadId 上传Id
*/
void completeMultipartUpload(StorageDO storageDO,
List<MultipartUploadResp> parts,
String path,
String uploadId,
boolean needVerify);
/**
* 清楚分片
*
* @param storageDO 存储实体
* @param uploadId 上传Id
*/
void cleanPart(StorageDO storageDO, String uploadId);
/**
* 获取存储类型
*
* @return 存储类型
*/
StorageTypeEnum getType();
}

View File

@@ -0,0 +1,246 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.admin.system.handler.impl;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import top.continew.admin.system.constant.MultipartUploadConstants;
import top.continew.admin.system.enums.StorageTypeEnum;
import top.continew.admin.system.handler.StorageHandler;
import top.continew.admin.system.model.entity.StorageDO;
import top.continew.admin.system.model.req.MultipartUploadInitReq;
import top.continew.admin.system.model.resp.file.MultipartUploadInitResp;
import top.continew.admin.system.model.resp.file.MultipartUploadResp;
import top.continew.admin.system.service.FileService;
import top.continew.starter.core.exception.BaseException;
import java.io.File;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;
/**
* 本地存储处理器
* <p>实现分片上传、合并、取消等操作。</p>
*
* @author KAI
* @since 2023/7/30 22:58
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class LocalStorageHandler implements StorageHandler {
private final FileService fileService;
@Override
public MultipartUploadInitResp initMultipartUpload(StorageDO storageDO, MultipartUploadInitReq req) {
String uploadId = UUID.randomUUID().toString();
String bucket = storageDO.getBucketName(); // 本地存储中bucket是存储根路径
String parentPath = req.getParentPath();
String fileName = req.getFileName();
StrUtil.blankToDefault(parentPath, StrUtil.SLASH);
String relativePath = StrUtil.endWith(parentPath, StrUtil.SLASH)
? parentPath + fileName
: parentPath + StrUtil.SLASH + fileName;
try {
// 创建临时目录用于存储分片
String tempDirPath = buildTempDirPath(bucket, uploadId);
FileUtil.mkdir(tempDirPath);
fileService.createParentDir(parentPath, storageDO);
// 构建返回结果
MultipartUploadInitResp result = new MultipartUploadInitResp();
result.setBucket(bucket);
result.setFileId(UUID.randomUUID().toString());
result.setUploadId(uploadId);
result.setPlatform(storageDO.getCode());
result.setFileName(fileName);
result.setFileMd5(req.getFileMd5());
result.setFileSize(req.getFileSize());
result.setExtension(FileUtil.extName(fileName));
result.setContentType(req.getContentType());
result.setPath(relativePath);
result.setParentPath(parentPath);
result.setPartSize(MultipartUploadConstants.MULTIPART_UPLOAD_PART_SIZE);
log.info("本地存储初始化分片上传成功: uploadId={}, path={}", uploadId, parentPath);
return result;
} catch (Exception e) {
log.error("本地存储初始化分片上传失败: {}", e.getMessage(), e);
throw new BaseException("本地存储初始化分片上传失败: " + e.getMessage(), e);
}
}
@Override
public MultipartUploadResp uploadPart(StorageDO storageDO,
String path,
String uploadId,
Integer partNumber,
MultipartFile file) {
try {
long size = file.getSize();
String bucket = storageDO.getBucketName();
// 获取临时目录路径
String tempDirPath = buildTempDirPath(bucket, uploadId);
// 确保临时目录存在
File tempDir = new File(tempDirPath);
if (!tempDir.exists()) {
FileUtil.mkdir(tempDirPath);
}
// 保存分片文件
String partFilePath = tempDirPath + File.separator + String.format("part_%s", partNumber);
File partFile = new File(partFilePath);
file.transferTo(partFile);
// 计算ETag (使用MD5)
String etag = DigestUtil.md5Hex(partFile);
// 构建返回结果
MultipartUploadResp result = new MultipartUploadResp();
result.setPartNumber(partNumber);
result.setPartETag(etag);
result.setPartSize(size);
result.setSuccess(true);
log.info("本地存储分片上传成功: uploadId={}, partNumber={}, etag={}", uploadId, partNumber, etag);
return result;
} catch (Exception e) {
log.error("本地存储分片上传失败: uploadId={}, partNumber={}, error={}", uploadId, partNumber, e.getMessage(), e);
MultipartUploadResp result = new MultipartUploadResp();
result.setPartNumber(partNumber);
result.setSuccess(false);
result.setErrorMessage(e.getMessage());
return result;
}
}
@Override
public void completeMultipartUpload(StorageDO storageDO,
List<MultipartUploadResp> parts,
String path,
String uploadId,
boolean needVerify) {
String bucket = storageDO.getBucketName(); // 本地存储中bucket是存储根路径
String tempDirPath = buildTempDirPath(bucket, uploadId);
try {
// 本地存储不需要验证,直接使用传入的分片信息
Path targetPath = Paths.get(bucket, path);
Files.createDirectories(targetPath.getParent());
// 合并分片
try (OutputStream out = Files
.newOutputStream(targetPath, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) {
// 按分片编号排序
List<MultipartUploadResp> sortedParts = parts.stream()
.filter(MultipartUploadResp::isSuccess)
.sorted(Comparator.comparingInt(MultipartUploadResp::getPartNumber))
.toList();
// 逐个读取并写入
for (MultipartUploadResp part : sortedParts) {
Path partPath = Paths.get(tempDirPath, String.format("part_%s", part.getPartNumber()));
if (!Files.exists(partPath)) {
throw new BaseException("分片文件不存在: partNumber=" + part.getPartNumber());
}
Files.copy(partPath, out);
}
}
// 清理临时文件
cleanupTempFiles(tempDirPath);
log.info("本地存储分片合并成功: uploadId={}, targetPath={}", uploadId, targetPath);
} catch (Exception e) {
log.error("本地存储分片合并失败: uploadId={}, path={}, error={}", uploadId, path, e.getMessage(), e);
throw new BaseException("完成分片上传失败: " + e.getMessage(), e);
}
}
@Override
public void cleanPart(StorageDO storageDO, String uploadId) {
try {
String bucket = storageDO.getBucketName();
// 获取临时目录路径
String tempDirPath = buildTempDirPath(bucket, uploadId);
// 清理临时文件
cleanupTempFiles(tempDirPath);
log.info("本地存储分片清理成功: uploadId={}", uploadId);
} catch (Exception e) {
log.error("本地存储分片清理失败: uploadId={}, error={}", uploadId, e.getMessage(), e);
throw new BaseException("本地存储分片清理失败: " + e.getMessage(), e);
}
}
@Override
public StorageTypeEnum getType() {
return StorageTypeEnum.LOCAL;
}
/**
* 构建临时目录路径
*
* @param bucket 存储桶(本地存储根路径)
* @param uploadId 上传ID
* @return 临时目录路径
*/
private String buildTempDirPath(String bucket, String uploadId) {
return StrUtil
.appendIfMissing(bucket, File.separator) + MultipartUploadConstants.TEMP_DIR_NAME + File.separator + uploadId;
}
/**
* 构建目标文件路径
*
* @param bucket 存储桶(本地存储根路径)
* @param path 文件路径
* @return 目标文件路径
*/
private String buildTargetDirPath(String bucket, String path) {
return StrUtil.appendIfMissing(bucket, File.separator) + path;
}
/**
* 清理临时文件
*
* @param tempDirPath 临时目录路径
*/
private void cleanupTempFiles(String tempDirPath) {
try {
FileUtil.del(tempDirPath);
} catch (Exception e) {
log.warn("清理临时文件失败: {}, {}", tempDirPath, e.getMessage());
}
}
}

View File

@@ -0,0 +1,326 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.admin.system.handler.impl;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.*;
import top.continew.admin.system.constant.MultipartUploadConstants;
import top.continew.admin.system.enums.StorageTypeEnum;
import top.continew.admin.system.factory.S3ClientFactory;
import top.continew.admin.system.handler.StorageHandler;
import top.continew.admin.system.model.entity.StorageDO;
import top.continew.admin.system.model.req.MultipartUploadInitReq;
import top.continew.admin.system.model.resp.file.MultipartUploadInitResp;
import top.continew.admin.system.model.resp.file.MultipartUploadResp;
import top.continew.admin.system.service.FileService;
import top.continew.starter.core.exception.BaseException;
import java.util.*;
import java.util.stream.Collectors;
/**
* S3存储处理器
* <p>使用AWS SDK 2.x版本API。实现分片上传、合并、取消等操作。</p>
*
* @author KAI
* @since 2025/07/30 20:10
*/
@Component
@Slf4j
@RequiredArgsConstructor
public class S3StorageHandler implements StorageHandler {
private final S3ClientFactory s3ClientFactory;
private final FileService fileService;
@Override
public MultipartUploadInitResp initMultipartUpload(StorageDO storageDO, MultipartUploadInitReq req) {
String bucket = storageDO.getBucketName();
String parentPath = req.getParentPath();
String fileName = req.getFileName();
String contentType = req.getContentType();
StrUtil.blankToDefault(parentPath, StrUtil.SLASH);
String relativePath = StrUtil.endWith(parentPath, StrUtil.SLASH)
? parentPath + fileName
: parentPath + StrUtil.SLASH + fileName;
fileService.createParentDir(parentPath, storageDO);
try {
// 构建请求
CreateMultipartUploadRequest.Builder requestBuilder = CreateMultipartUploadRequest.builder()
.bucket(bucket)
.key(buildS3Key(relativePath))
.contentType(contentType);
// 添加元数据 暂时注释掉 mataData传递中文会导致签名校验不通过
// if (metaData != null && !metaData.isEmpty()) {
// requestBuilder.metadata(metaData);
// }
S3Client s3Client = s3ClientFactory.getClient(storageDO);
log.info("S3初始化分片上传: bucket={}, key={}, contentType={}", bucket, buildS3Key(relativePath), contentType);
// 执行请求
CreateMultipartUploadResponse response = s3Client.createMultipartUpload(requestBuilder.build());
String uploadId = response.uploadId();
// 构建返回结果
MultipartUploadInitResp result = new MultipartUploadInitResp();
result.setBucket(bucket);
result.setFileId(UUID.randomUUID().toString());
result.setUploadId(uploadId);
result.setPlatform(storageDO.getCode());
result.setFileName(fileName);
result.setFileMd5(req.getFileMd5());
result.setFileSize(req.getFileSize());
result.setExtension(FileUtil.extName(fileName));
result.setContentType(req.getContentType());
result.setPath(relativePath);
result.setParentPath(parentPath);
result.setPartSize(MultipartUploadConstants.MULTIPART_UPLOAD_PART_SIZE);
log.info("S3初始化分片上传成功: uploadId={}, path={}", uploadId, relativePath);
return result;
} catch (Exception e) {
throw new BaseException("S3初始化分片上传失败: " + e.getMessage(), e);
}
}
@Override
public MultipartUploadResp uploadPart(StorageDO storageDO,
String path,
String uploadId,
Integer partNumber,
MultipartFile file) {
try {
String bucket = storageDO.getBucketName();
// 读取数据到内存(注意:实际使用时可能需要优化大文件处理)
byte[] bytes = file.getBytes();
// 构建请求
UploadPartRequest request = UploadPartRequest.builder()
.bucket(bucket)
.key(buildS3Key(path))
.uploadId(uploadId)
.partNumber(partNumber)
.contentLength((long)bytes.length)
.build();
// 执行上传
S3Client s3Client = s3ClientFactory.getClient(storageDO);
UploadPartResponse response = s3Client.uploadPart(request, RequestBody.fromBytes(bytes));
// 构建返回结果
MultipartUploadResp result = new MultipartUploadResp();
result.setPartNumber(partNumber);
result.setPartETag(response.eTag());
result.setSuccess(true);
log.info("S3上传分片成功: partNumber={} for key={} with uploadId={}", partNumber, path, uploadId);
log.info("上传分片ETag: {}", response.eTag());
return result;
} catch (Exception e) {
MultipartUploadResp result = new MultipartUploadResp();
result.setPartNumber(partNumber);
result.setSuccess(false);
result.setErrorMessage(e.getMessage());
log.error("S3上传分片失败: partNumber={} for key={} with uploadId={} errorMessage={}", partNumber, path, uploadId, e
.getMessage());
return result;
}
}
@Override
public void completeMultipartUpload(StorageDO storageDO,
List<MultipartUploadResp> parts,
String path,
String uploadId,
boolean needVerify) {
if (path == null) {
throw new BaseException("无效的uploadId: " + uploadId);
}
String bucket = storageDO.getBucketName();
S3Client s3Client = s3ClientFactory.getClient(storageDO);
// 如果需要验证比较本地记录和S3的分片信息
if (needVerify) {
List<MultipartUploadResp> s3Parts = listParts(bucket, path, uploadId, s3Client);
validateParts(parts, s3Parts);
}
// 构建已完成的分片列表
List<CompletedPart> completedParts = parts.stream()
.filter(MultipartUploadResp::isSuccess)
.map(part -> CompletedPart.builder().partNumber(part.getPartNumber()).eTag(part.getPartETag()).build())
.sorted(Comparator.comparingInt(CompletedPart::partNumber))
.collect(Collectors.toList());
// 构建请求
CompleteMultipartUploadRequest request = CompleteMultipartUploadRequest.builder()
.bucket(bucket)
.key(buildS3Key(path))
.uploadId(uploadId)
.multipartUpload(CompletedMultipartUpload.builder().parts(completedParts).build())
.build();
// 完成上传
s3Client.completeMultipartUpload(request);
log.info("S3完成分片上传: key={}, uploadId={}, parts={}", buildS3Key(path), uploadId, completedParts.size());
}
@Override
public void cleanPart(StorageDO storageDO, String uploadId) {
try {
String bucket = storageDO.getBucketName();
S3Client s3Client = s3ClientFactory.getClient(storageDO);
// 列出所有未完成的分片上传
ListMultipartUploadsRequest listRequest = ListMultipartUploadsRequest.builder().bucket(bucket).build();
ListMultipartUploadsResponse listResponse = s3Client.listMultipartUploads(listRequest);
// 查找匹配的上传任务
Optional<MultipartUpload> targetUpload = listResponse.uploads()
.stream()
.filter(upload -> upload.uploadId().equals(uploadId))
.findFirst();
if (targetUpload.isPresent()) {
MultipartUpload upload = targetUpload.get();
// 取消分片上传
AbortMultipartUploadRequest abortRequest = AbortMultipartUploadRequest.builder()
.bucket(bucket)
.key(upload.key())
.uploadId(uploadId)
.build();
s3Client.abortMultipartUpload(abortRequest);
log.info("S3清理分片上传成功: bucket={}, key={}, uploadId={}", bucket, upload.key(), uploadId);
} else {
log.warn("S3未找到对应的分片上传任务: uploadId={}", uploadId);
}
} catch (Exception e) {
log.error("S3清理分片上传失败: uploadId={}, error={}", uploadId, e.getMessage(), e);
throw new BaseException("S3清理分片上传失败: " + e.getMessage(), e);
}
}
@Override
public StorageTypeEnum getType() {
return StorageTypeEnum.OSS;
}
/**
* 列出已上传的分片
*/
public List<MultipartUploadResp> listParts(String bucket, String path, String uploadId, S3Client s3Client) {
try {
// 构建请求
ListPartsRequest request = ListPartsRequest.builder()
.bucket(bucket)
.key(buildS3Key(path))
.uploadId(uploadId)
.build();
// 获取分片列表
ListPartsResponse response = s3Client.listParts(request);
// 转换结果
return response.parts().stream().map(part -> {
MultipartUploadResp result = new MultipartUploadResp();
result.setPartNumber(part.partNumber());
result.setPartETag(part.eTag());
result.setSuccess(true);
return result;
}).collect(Collectors.toList());
} catch (Exception e) {
throw new BaseException("S3列出分片失败: " + e.getMessage(), e);
}
}
/**
* 验证分片一致性
*
* @param recordParts 记录部件
* @param s3Parts s3零件
*/
private void validateParts(List<MultipartUploadResp> recordParts, List<MultipartUploadResp> s3Parts) {
Map<Integer, String> recordMap = recordParts.stream()
.collect(Collectors.toMap(MultipartUploadResp::getPartNumber, MultipartUploadResp::getPartETag));
Map<Integer, String> s3Map = s3Parts.stream()
.collect(Collectors.toMap(MultipartUploadResp::getPartNumber, MultipartUploadResp::getPartETag));
// 检查分片数量
if (recordMap.size() != s3Map.size()) {
throw new BaseException(String.format("分片数量不一致: 本地记录=%d, S3=%d", recordMap.size(), s3Map.size()));
}
// 检查每个分片
List<Integer> missingParts = new ArrayList<>();
List<Integer> mismatchParts = new ArrayList<>();
for (Map.Entry<Integer, String> entry : recordMap.entrySet()) {
Integer partNumber = entry.getKey();
String recordETag = entry.getValue();
String s3ETag = s3Map.get(partNumber);
if (s3ETag == null) {
missingParts.add(partNumber);
} else if (!recordETag.equals(s3ETag)) {
mismatchParts.add(partNumber);
}
}
if (!missingParts.isEmpty()) {
throw new BaseException("S3缺失分片: " + missingParts);
}
if (!mismatchParts.isEmpty()) {
throw new BaseException("分片ETag不匹配: " + mismatchParts);
}
}
/**
* 规范化 S3 对象 key去掉前导斜杠合并多余斜杠。
*
* @param rawKey 你传入的完整路径,比如 "/folder//子目录//文件名.png"
* @return 规范化后的 key比如 "folder/子目录/文件名.png"
*/
public static String buildS3Key(String rawKey) {
if (rawKey == null || rawKey.isEmpty()) {
throw new IllegalArgumentException("key 不能为空");
}
// 去掉前导斜杠
while (rawKey.startsWith("/")) {
rawKey = rawKey.substring(1);
}
// 替换连续多个斜杠为一个斜杠
rawKey = rawKey.replaceAll("/+", "/");
return rawKey;
}
}

View File

@@ -26,7 +26,7 @@ import top.continew.admin.common.base.mapper.DataPermissionMapper;
import top.continew.admin.system.model.entity.user.UserDO;
import top.continew.admin.system.model.resp.user.UserDetailResp;
import top.continew.starter.extension.datapermission.annotation.DataPermission;
import top.continew.starter.security.crypto.annotation.FieldEncrypt;
import top.continew.starter.encrypt.field.annotation.FieldEncrypt;
import java.util.List;

View File

@@ -20,7 +20,7 @@ import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import top.continew.admin.common.enums.DisEnableStatusEnum;
import top.continew.admin.common.base.model.entity.BaseDO;
import top.continew.starter.security.crypto.annotation.FieldEncrypt;
import top.continew.starter.encrypt.field.annotation.FieldEncrypt;
import java.io.Serial;

View File

@@ -26,7 +26,7 @@ import top.continew.admin.common.enums.DisEnableStatusEnum;
import top.continew.admin.common.base.model.entity.BaseDO;
import top.continew.admin.system.enums.StorageTypeEnum;
import top.continew.starter.core.constant.StringConstants;
import top.continew.starter.security.crypto.annotation.FieldEncrypt;
import top.continew.starter.encrypt.field.annotation.FieldEncrypt;
import java.io.Serial;
import java.net.URL;

View File

@@ -23,9 +23,9 @@ import lombok.Data;
import top.continew.admin.common.base.model.entity.BaseDO;
import top.continew.admin.common.enums.DisEnableStatusEnum;
import top.continew.admin.common.enums.GenderEnum;
import top.continew.starter.encrypt.field.annotation.FieldEncrypt;
import top.continew.starter.encrypt.password.encoder.encryptor.PasswordEncoderEncryptor;
import top.continew.starter.extension.crud.annotation.DictModel;
import top.continew.starter.security.crypto.annotation.FieldEncrypt;
import top.continew.starter.security.crypto.enums.Algorithm;
import java.io.Serial;
import java.time.LocalDateTime;
@@ -57,7 +57,7 @@ public class UserDO extends BaseDO {
/**
* 密码
*/
@FieldEncrypt(Algorithm.PASSWORD_ENCODER)
@FieldEncrypt(encryptor = PasswordEncoderEncryptor.class)
private String password;
/**

View File

@@ -0,0 +1,81 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.admin.system.model.req;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Min;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Map;
/**
* 分片初始化请求参数
*
* @author KAI
* @since 2025/7/30 16:38
*/
@Data
@Schema(description = "分片初始化请求参数")
public class MultipartUploadInitReq implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 文件名
*/
@Schema(description = "文件名", example = "example.zip")
@NotBlank(message = "文件名不能为空")
private String fileName;
/**
* 文件大小(字节)
*/
@Schema(description = "文件大小", example = "1048576")
@NotNull(message = "文件大小不能为空")
@Min(value = 1, message = "文件大小必须大于0")
private Long fileSize;
/**
* 文件MD5值
*/
@Schema(description = "文件MD5值", example = "5d41402abc4b2a76b9719d911017c592")
@NotBlank(message = "文件MD5值不能为空")
private String fileMd5;
/**
* 文件MIME类型
*/
@Schema(description = "文件MIME类型", example = "application/zip")
private String contentType;
/**
* 存储路径
*/
@Schema(description = "存储父路径", example = "/upload/files/")
private String parentPath;
/**
* 文件元信息
*/
@Schema(description = "文件元信息")
private Map<String, String> metaData;
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.admin.system.model.req;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 分片上传请求参数
*
* @author KAI
* @since 2025/7/30 16:40
*/
@Data
@Schema(description = "分片上传请求参数")
public class MultipartUploadReq {
/**
* 上传ID
*/
@Schema(description = "上传ID")
private String uploadId;
/**
* 分片序号
*/
@Schema(description = "分片序号")
private Integer partNumber;
/**
* 分片ETag
*/
@Schema(description = "分片ETag")
private String eTag;
/**
* 存储编码
*/
@Schema(description = "存储编码")
private String storageCode;
}

View File

@@ -31,7 +31,7 @@ import java.util.List;
*/
@Data
@Schema(description = "参数重置请求参数")
public class OptionResetValueReq implements Serializable {
public class OptionValueResetReq implements Serializable {
@Serial
private static final long serialVersionUID = 1L;

View File

@@ -32,7 +32,7 @@ import java.util.List;
*/
@Data
@Schema(description = "角色功能权限修改请求参数")
public class RoleUpdatePermissionReq implements Serializable {
public class RolePermissionUpdateReq implements Serializable {
@Serial
private static final long serialVersionUID = 1L;

View File

@@ -47,7 +47,7 @@ public class StorageReq implements Serializable {
/**
* 名称
*/
@Schema(description = "名称", example = "存储1")
@Schema(description = "名称", example = "S3对象存储")
@NotBlank(message = "名称不能为空")
@Length(max = 100, message = "名称长度不能超过 {max} 个字符")
private String name;
@@ -55,7 +55,7 @@ public class StorageReq implements Serializable {
/**
* 编码
*/
@Schema(description = "编码", example = "local")
@Schema(description = "编码", example = "s3_aliyun")
@NotBlank(message = "编码不能为空")
@Pattern(regexp = RegexConstants.GENERAL_CODE, message = "编码长度为 2-30 个字符,支持大小写字母、数字、下划线,以字母开头")
private String code;
@@ -70,7 +70,7 @@ public class StorageReq implements Serializable {
/**
* Access Key
*/
@Schema(description = "Access Key", example = "")
@Schema(description = "Access Key", example = "LBAI4Fp4dXYcZamU5EXTBdTa")
@Length(max = 255, message = "Access Key长度不能超过 {max} 个字符")
@NotBlank(message = "Access Key不能为空", groups = ValidationGroup.Storage.OSS.class)
private String accessKey;
@@ -78,22 +78,22 @@ public class StorageReq implements Serializable {
/**
* Secret Key
*/
@Schema(description = "Secret Key", example = "")
@NotBlank(message = "Secret Key不能为空", groups = ValidationGroup.Storage.OSS.class)
@Schema(description = "Secret Key", example = "RSA 公钥加密的 Secret Key")
private String secretKey;
/**
* Endpoint
*/
@Schema(description = "Endpoint", example = "")
@Schema(description = "Endpoint", example = "http://oss-cn-shanghai.aliyuncs.com")
@Length(max = 255, message = "Endpoint长度不能超过 {max} 个字符")
@NotBlank(message = "Endpoint不能为空", groups = ValidationGroup.Storage.OSS.class)
@Pattern(regexp = RegexConstants.URL_HTTP, message = "Endpoint格式不正确", groups = ValidationGroup.Storage.OSS.class)
private String endpoint;
/**
* Bucket/存储路径
*/
@Schema(description = "Bucket/存储路径", example = "C:/continew-admin/data/file/")
@Schema(description = "Bucket/存储路径", example = "continew-admin")
@Length(max = 255, message = "Bucket长度不能超过 {max} 个字符", groups = ValidationGroup.Storage.OSS.class)
@Length(max = 255, message = "存储路径长度不能超过 {max} 个字符", groups = ValidationGroup.Storage.Local.class)
@NotBlank(message = "Bucket不能为空", groups = ValidationGroup.Storage.OSS.class)
@@ -103,7 +103,7 @@ public class StorageReq implements Serializable {
/**
* 域名/访问路径
*/
@Schema(description = "域名/访问路径", example = "http://localhost:8000/file")
@Schema(description = "域名/访问路径", example = "https://continew-admin.file.continew.top/")
@Length(max = 255, message = "域名长度不能超过 {max} 个字符", groups = ValidationGroup.Storage.OSS.class)
@Length(max = 255, message = "访问路径长度不能超过 {max} 个字符", groups = ValidationGroup.Storage.Local.class)
@NotBlank(message = "访问路径不能为空", groups = ValidationGroup.Storage.Local.class)

View File

@@ -55,9 +55,9 @@ public class UserEmailUpdateReq implements Serializable {
private String captcha;
/**
* 当前密码(加密)
* 当前密码
*/
@Schema(description = "当前密码(加密)", example = "SYRLSszQGcMv4kP2Yolou9zf28B9GDakR9u91khxmR7V++i5A384kwnNZxqgvT6bjT4zqpIDuMFLWSt92hQJJA==")
@Schema(description = "当前密码", example = "RSA 公钥加密的当前密码")
@NotBlank(message = "当前密码不能为空")
private String oldPassword;
}

View File

@@ -37,9 +37,9 @@ public class UserPasswordResetReq implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 新密码(加密)
* 新密码
*/
@Schema(description = "新密码(加密)", example = "Gzc78825P5baH190lRuZFb9KJxRt/psN2jiyOMPoc5WRcCvneCwqDm3Q33BZY56EzyyVy7vQu7jQwYTK4j1+5w==")
@Schema(description = "新密码", example = "RSA 公钥加密的新密码")
@NotBlank(message = "新密码不能为空")
private String newPassword;
}

View File

@@ -37,15 +37,15 @@ public class UserPasswordUpdateReq implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 当前密码(加密)
* 当前密码
*/
@Schema(description = "当前密码(加密)", example = "E7c72TH+LDxKTwavjM99W1MdI9Lljh79aPKiv3XB9MXcplhm7qJ1BJCj28yaflbdVbfc366klMtjLIWQGqb0qw==")
@Schema(description = "当前密码", example = "RSA 公钥加密的当前密码")
private String oldPassword;
/**
* 新密码(加密)
* 新密码
*/
@Schema(description = "新密码(加密)", example = "Gzc78825P5baH190lRuZFb9KJxRt/psN2jiyOMPoc5WRcCvneCwqDm3Q33BZY56EzyyVy7vQu7jQwYTK4j1+5w==")
@Schema(description = "新密码", example = "RSA 公钥加密的新密码")
@NotBlank(message = "新密码不能为空")
private String newPassword;
}

View File

@@ -55,9 +55,9 @@ public class UserPhoneUpdateReq implements Serializable {
private String captcha;
/**
* 当前密码(加密)
* 当前密码
*/
@Schema(description = "当前密码(加密)", example = "SYRLSszQGcMv4kP2Yolou9zf28B9GDakR9u91khxmR7V++i5A384kwnNZxqgvT6bjT4zqpIDuMFLWSt92hQJJA==")
@Schema(description = "当前密码", example = "RSA 公钥加密的当前密码")
@NotBlank(message = "当前密码不能为空")
private String oldPassword;
}

View File

@@ -60,9 +60,9 @@ public class UserReq implements Serializable {
private String nickname;
/**
* 密码(加密)
* 密码
*/
@Schema(description = "密码(加密)", example = "E7c72TH+LDxKTwavjM99W1MdI9Lljh79aPKiv3XB9MXcplhm7qJ1BJCj28yaflbdVbfc366klMtjLIWQGqb0qw==")
@Schema(description = "密码", example = "RSA 公钥加密的密码")
@NotBlank(message = "密码不能为空", groups = CrudValidationGroup.Create.class)
private String password;

View File

@@ -21,7 +21,6 @@ import lombok.Data;
import top.continew.admin.common.base.model.resp.BaseDetailResp;
import top.continew.admin.common.enums.DisEnableStatusEnum;
import top.continew.admin.system.enums.StorageTypeEnum;
import top.continew.starter.security.mask.annotation.JsonMask;
import java.io.Serial;
@@ -68,13 +67,6 @@ public class StorageResp extends BaseDetailResp {
@Schema(description = "Access Key", example = "")
private String accessKey;
/**
* Secret Key
*/
@Schema(description = "Secret Key", example = "")
@JsonMask(left = 4, right = 3)
private String secretKey;
/**
* Endpoint
*/

View File

@@ -0,0 +1,94 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.admin.system.model.resp.file;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 文件分片信息
*
* @author echo
* @since 2.14.0
*/
@Data
@Schema(description = "文件分片信息")
public class FilePartInfo implements Serializable {
/**
* 文件ID
*/
@Schema(description = "文件ID")
private String fileId;
/**
* 分片编号从1开始
*/
@Schema(description = "分片编号从1开始")
private Integer partNumber;
/**
* 分片大小
*/
@Schema(description = "分片大小")
private Long partSize;
/**
* 分片MD5
*/
@Schema(description = "分片MD5")
private String partMd5;
/**
* 分片ETagS3返回的标识
*/
@Schema(description = "分片ETag")
private String partETag;
/**
* 上传IDS3分片上传标识
*/
@Schema(description = "上传ID")
private String uploadId;
/**
* 上传时间
*/
@Schema(description = "上传时间")
private LocalDateTime uploadTime;
/**
* 状态UPLOADING, SUCCESS, FAILED
*/
@Schema(description = "状态")
private String status;
/**
* 存储桶
*/
@Schema(description = "存储桶")
private String bucket;
/**
* 文件路径
*/
@Schema(description = "文件路径")
private String path;
}

View File

@@ -0,0 +1,119 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.admin.system.model.resp.file;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.util.Set;
/**
* 分片上传初始化结果
*
* @author echo
* @since 2.14.0
*/
@Data
@Schema(description = "分片初始化响应参数")
public class MultipartUploadInitResp implements Serializable {
/**
* 文件ID
*/
@Schema(description = "文件ID")
private String fileId;
/**
* 上传IDS3返回的uploadId
*/
@Schema(description = "上传ID")
private String uploadId;
/**
* 存储桶
*/
@Schema(description = "存储桶")
private String bucket;
/**
* 存储平台
*/
@Schema(description = "存储平台")
private String platform;
/**
* 文件名称
*/
@Schema(description = "文件名称")
private String fileName;
/**
* 文件MD5
*/
@Schema(description = "文件MD5")
private String fileMd5;
/**
* 文件大小
*/
@Schema(description = "文件大小")
private long fileSize;
/**
* 扩展名
*/
@Schema(description = "扩展名")
private String extension;
/**
* 内容类型
*/
@Schema(description = "内容类型")
private String contentType;
/**
* 文件类型
*/
@Schema(description = "文件类型")
private String type;
/**
* 文件父路径
*/
@Schema(description = "文件父路径")
private String parentPath;
/**
* 文件路径
*/
@Schema(description = "文件路径")
private String path;
/**
* 分片大小
*/
@Schema(description = "分片大小")
private Long partSize;
/**
* 已上传分片编号集合
*/
@Schema(description = "已上传分片编号集合")
private Set<Integer> uploadedPartNumbers;
}

View File

@@ -0,0 +1,63 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.admin.system.model.resp.file;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
/**
* 分片上传结果
*
* @author echo
* @since 2.14.0
*/
@Data
@Schema(description = "分片上传响应参数")
public class MultipartUploadResp implements Serializable {
/**
* 分片编号
*/
@Schema(description = "分片编号")
private Integer partNumber;
/**
* 分片ETag
*/
@Schema(description = "分片ETag")
private String partETag;
/**
* 分片大小
*/
@Schema(description = "分片大小")
private Long partSize;
/**
* 是否成功
*/
@Schema(description = "是否成功")
private boolean success;
/**
* 错误信息
*/
@Schema(description = "错误信息")
private String errorMessage;
}

View File

@@ -36,7 +36,7 @@ import top.continew.admin.system.model.resp.DeptResp;
import top.continew.admin.system.service.DeptService;
import top.continew.starter.excel.converter.ExcelBaseEnumConverter;
import top.continew.starter.excel.converter.ExcelListConverter;
import top.continew.starter.security.crypto.annotation.FieldEncrypt;
import top.continew.starter.encrypt.field.annotation.FieldEncrypt;
import java.io.Serial;
import java.time.LocalDateTime;

View File

@@ -24,6 +24,7 @@ import top.continew.admin.system.model.resp.DeptResp;
import top.continew.starter.data.service.IService;
import java.util.List;
import java.util.Set;
/**
* 部门业务接口
@@ -55,5 +56,5 @@ public interface DeptService extends BaseService<DeptResp, DeptResp, DeptQuery,
* @param deptNames 名称列表
* @return 部门数量
*/
int countByNames(List<String> deptNames);
int countByNames(Set<String> deptNames);
}

View File

@@ -21,6 +21,7 @@ import org.dromara.x.file.storage.core.FileInfo;
import org.springframework.web.multipart.MultipartFile;
import top.continew.admin.common.base.service.BaseService;
import top.continew.admin.system.model.entity.FileDO;
import top.continew.admin.system.model.entity.StorageDO;
import top.continew.admin.system.model.query.FileQuery;
import top.continew.admin.system.model.req.FileReq;
import top.continew.admin.system.model.resp.file.FileResp;
@@ -148,6 +149,18 @@ public interface FileService extends BaseService<FileResp, FileResp, FileQuery,
*/
Long countByStorageIds(List<Long> storageIds);
/**
* 创建上级文件夹(支持多级)
*
* <p>
* user/avatar/ => userpath/user、avatarpath/user/avatar
* </p>
*
* @param parentPath 上级目录
* @param storage 存储配置
*/
void createParentDir(String parentPath, StorageDO storage);
/**
* 获取默认上级目录
*

View File

@@ -0,0 +1,40 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.admin.system.service;
import org.springframework.web.multipart.MultipartFile;
import top.continew.admin.system.model.entity.FileDO;
import top.continew.admin.system.model.req.MultipartUploadInitReq;
import top.continew.admin.system.model.resp.file.MultipartUploadInitResp;
import top.continew.admin.system.model.resp.file.MultipartUploadResp;
/**
* 分片上传业务接口
*
* @author KAI
* @since 2025/7/3 8:42
*/
public interface MultipartUploadService {
MultipartUploadInitResp initMultipartUpload(MultipartUploadInitReq multiPartUploadInitReq);
MultipartUploadResp uploadPart(MultipartFile file, String uploadId, Integer partNumber, String path);
FileDO completeMultipartUpload(String uploadId);
void cancelMultipartUpload(String uploadId);
}

View File

@@ -19,7 +19,7 @@ package top.continew.admin.system.service;
import top.continew.admin.system.enums.OptionCategoryEnum;
import top.continew.admin.system.model.query.OptionQuery;
import top.continew.admin.system.model.req.OptionReq;
import top.continew.admin.system.model.req.OptionResetValueReq;
import top.continew.admin.system.model.req.OptionValueResetReq;
import top.continew.admin.system.model.resp.OptionResp;
import java.util.List;
@@ -62,7 +62,7 @@ public interface OptionService {
*
* @param req 重置信息
*/
void resetValue(OptionResetValueReq req);
void resetValue(OptionValueResetReq req);
/**
* 根据编码查询参数值

View File

@@ -21,7 +21,7 @@ import top.continew.admin.common.context.RoleContext;
import top.continew.admin.system.model.entity.RoleDO;
import top.continew.admin.system.model.query.RoleQuery;
import top.continew.admin.system.model.req.RoleReq;
import top.continew.admin.system.model.req.RoleUpdatePermissionReq;
import top.continew.admin.system.model.req.RolePermissionUpdateReq;
import top.continew.admin.system.model.resp.role.RoleDetailResp;
import top.continew.admin.system.model.resp.role.RoleResp;
import top.continew.starter.data.service.IService;
@@ -43,7 +43,7 @@ public interface RoleService extends BaseService<RoleResp, RoleDetailResp, RoleQ
* @param id 角色 ID
* @param req 请求参数
*/
void updatePermission(Long id, RoleUpdatePermissionReq req);
void updatePermission(Long id, RolePermissionUpdateReq req);
/**
* 分配角色给用户

View File

@@ -38,10 +38,7 @@ import top.continew.starter.data.enums.DatabaseType;
import top.continew.starter.data.util.MetaUtils;
import javax.sql.DataSource;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.*;
/**
* 部门业务实现
@@ -128,7 +125,7 @@ public class DeptServiceImpl extends BaseServiceImpl<DeptMapper, DeptDO, DeptRes
}
@Override
public int countByNames(List<String> deptNames) {
public int countByNames(Set<String> deptNames) {
if (CollUtil.isEmpty(deptNames)) {
return 0;
}

View File

@@ -43,6 +43,7 @@ import top.continew.admin.system.model.resp.file.FileResp;
import top.continew.admin.system.model.resp.file.FileStatisticsResp;
import top.continew.admin.system.service.FileService;
import top.continew.admin.system.service.StorageService;
import top.continew.starter.cache.redisson.util.RedisLockUtils;
import top.continew.starter.core.constant.StringConstants;
import top.continew.starter.core.util.StrUtils;
import top.continew.starter.core.util.validation.CheckUtils;
@@ -278,39 +279,47 @@ public class FileServiceImpl extends BaseServiceImpl<FileMapper, FileDO, FileRes
* @param parentPath 上级目录
* @param storage 存储配置
*/
private void createParentDir(String parentPath, StorageDO storage) {
if (StrUtil.isBlank(parentPath) || StringConstants.SLASH.equals(parentPath)) {
return;
}
// user/avatar/ => user、avatar
String[] parentPathParts = StrUtil.split(parentPath, StringConstants.SLASH, false, true).toArray(String[]::new);
String lastPath = StringConstants.SLASH;
StringBuilder currentPathBuilder = new StringBuilder();
for (int i = 0; i < parentPathParts.length; i++) {
String parentPathPart = parentPathParts[i];
if (i > 0) {
lastPath = currentPathBuilder.toString();
@Override
public void createParentDir(String parentPath, StorageDO storage) {
String lockKey = StrUtil.format("Lock:{}:{}", storage.getCode(), parentPath);
try (RedisLockUtils lock = RedisLockUtils.tryLock(lockKey)) {
if (!lock.isLocked()) {
return; // 获取锁失败,直接返回
}
// /user、/user/avatar
currentPathBuilder.append(StringConstants.SLASH).append(parentPathPart);
String currentPath = currentPathBuilder.toString();
// 文件夹和文件存储引擎需要一致
FileDO dirFile = baseMapper.lambdaQuery()
.eq(FileDO::getPath, currentPath)
.eq(FileDO::getType, FileTypeEnum.DIR)
.one();
if (dirFile != null) {
CheckUtils.throwIfNotEqual(dirFile.getStorageId(), storage.getId(), "文件夹和上传文件存储引擎不一致");
continue;
if (StrUtil.isBlank(parentPath) || StringConstants.SLASH.equals(parentPath)) {
return;
}
// user/avatar/ => user、avatar
String[] parentPathParts = StrUtil.split(parentPath, StringConstants.SLASH, false, true)
.toArray(String[]::new);
String lastPath = StringConstants.SLASH;
StringBuilder currentPathBuilder = new StringBuilder();
for (int i = 0; i < parentPathParts.length; i++) {
String parentPathPart = parentPathParts[i];
if (i > 0) {
lastPath = currentPathBuilder.toString();
}
// /user、/user/avatar
currentPathBuilder.append(StringConstants.SLASH).append(parentPathPart);
String currentPath = currentPathBuilder.toString();
// 文件夹和文件存储引擎需要一致
FileDO dirFile = baseMapper.lambdaQuery()
.eq(FileDO::getPath, currentPath)
.eq(FileDO::getType, FileTypeEnum.DIR)
.one();
if (dirFile != null) {
CheckUtils.throwIfNotEqual(dirFile.getStorageId(), storage.getId(), "文件夹和上传文件存储引擎不一致");
continue;
}
FileDO file = new FileDO();
file.setName(parentPathPart);
file.setOriginalName(parentPathPart);
file.setPath(currentPath);
file.setParentPath(lastPath);
file.setType(FileTypeEnum.DIR);
file.setStorageId(storage.getId());
baseMapper.insert(file);
}
FileDO file = new FileDO();
file.setName(parentPathPart);
file.setOriginalName(parentPathPart);
file.setPath(currentPath);
file.setParentPath(lastPath);
file.setType(FileTypeEnum.DIR);
file.setStorageId(storage.getId());
baseMapper.insert(file);
}
}
}

View File

@@ -0,0 +1,203 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.admin.system.service.impl;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import top.continew.admin.system.constant.MultipartUploadConstants;
import top.continew.admin.system.dao.MultipartUploadDao;
import top.continew.admin.system.enums.FileTypeEnum;
import top.continew.admin.system.factory.StorageHandlerFactory;
import top.continew.admin.system.handler.StorageHandler;
import top.continew.admin.system.handler.impl.LocalStorageHandler;
import top.continew.admin.system.model.entity.FileDO;
import top.continew.admin.system.model.entity.StorageDO;
import top.continew.admin.system.model.req.MultipartUploadInitReq;
import top.continew.admin.system.model.resp.file.FilePartInfo;
import top.continew.admin.system.model.resp.file.MultipartUploadInitResp;
import top.continew.admin.system.model.resp.file.MultipartUploadResp;
import top.continew.admin.system.service.FileService;
import top.continew.admin.system.service.MultipartUploadService;
import top.continew.admin.system.service.StorageService;
import top.continew.starter.core.exception.BaseException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 分片上传业务实现
*
* @author KAI
* @since 2025/7/31 9:30
*/
@Service
@RequiredArgsConstructor
public class MultipartUploadServiceImpl implements MultipartUploadService {
private final StorageService storageService;
private final StorageHandlerFactory storageHandlerFactory;
private final MultipartUploadDao multipartUploadDao;
private final FileService fileService;
@Override
public MultipartUploadInitResp initMultipartUpload(MultipartUploadInitReq multiPartUploadInitReq) {
// 后续可以增加storageCode参数 指定某个存储平台 当前设计是默认存储平台
StorageDO storageDO = storageService.getByCode(null);
// 根据文件Md5查询当前存储平台是否初始化过分片
String uploadId = multipartUploadDao.getUploadIdByMd5(multiPartUploadInitReq.getFileMd5());
if (StrUtil.isNotBlank(uploadId)) {
MultipartUploadInitResp multipartUpload = multipartUploadDao.getMultipartUpload(uploadId);
//对比存储平台和分片大小是否一致 一致则返回结果
if (multipartUpload != null && multipartUpload.getPartSize()
.equals(MultipartUploadConstants.MULTIPART_UPLOAD_PART_SIZE) && multipartUpload.getPlatform()
.equals(storageDO.getCode())) {
// 获取已上传分片信息
List<FilePartInfo> fileParts = multipartUploadDao.getFileParts(uploadId);
Set<Integer> partNumbers = fileParts.stream()
.map(FilePartInfo::getPartNumber)
.collect(Collectors.toSet());
multipartUpload.setUploadedPartNumbers(partNumbers);
return multipartUpload;
}
//todo else 待定 更换存储平台 或分片大小有变更 是否需要删除原先分片
}
StorageHandler storageHandler = storageHandlerFactory.createHandler(storageDO.getType());
//文件元信息
Map<String, String> metaData = multiPartUploadInitReq.getMetaData();
MultipartUploadInitResp multipartUploadInitResp = storageHandler
.initMultipartUpload(storageDO, multiPartUploadInitReq);
// 缓存文件信息,md5和uploadId映射
multipartUploadDao.setMultipartUpload(multipartUploadInitResp.getUploadId(), multipartUploadInitResp, metaData);
multipartUploadDao.setMd5Mapping(multiPartUploadInitReq.getFileMd5(), multipartUploadInitResp.getUploadId());
return multipartUploadInitResp;
}
@Override
public MultipartUploadResp uploadPart(MultipartFile file, String uploadId, Integer partNumber, String path) {
StorageDO storageDO = storageService.getByCode(null);
StorageHandler storageHandler = storageHandlerFactory.createHandler(storageDO.getType());
MultipartUploadResp resp = storageHandler.uploadPart(storageDO, path, uploadId, partNumber, file);
FilePartInfo partInfo = new FilePartInfo();
partInfo.setUploadId(uploadId);
partInfo.setBucket(storageDO.getBucketName());
partInfo.setPath(path);
partInfo.setPartNumber(partNumber);
partInfo.setPartETag(resp.getPartETag());
partInfo.setPartSize(resp.getPartSize());
partInfo.setStatus("SUCCESS");
partInfo.setUploadTime(LocalDateTime.now());
multipartUploadDao.setFilePart(uploadId, partInfo);
return resp;
}
@Override
public FileDO completeMultipartUpload(String uploadId) {
StorageDO storageDO = storageService.getByCode(null);
// 从 FileRecorder 获取所有分片信息
List<FilePartInfo> recordedParts = multipartUploadDao.getFileParts(uploadId);
MultipartUploadInitResp initResp = multipartUploadDao.getMultipartUpload(uploadId);
// 转换为 MultipartUploadResp
List<MultipartUploadResp> parts = recordedParts.stream().map(partInfo -> {
MultipartUploadResp resp = new MultipartUploadResp();
resp.setPartNumber(partInfo.getPartNumber());
resp.setPartETag(partInfo.getPartETag());
resp.setPartSize(partInfo.getPartSize());
resp.setSuccess("SUCCESS".equals(partInfo.getStatus()));
return resp;
}).collect(Collectors.toList());
// 如果没有记录,使用客户端传入的分片信息
if (parts.isEmpty()) {
throw new BaseException("没有找到任何分片信息");
}
// 验证分片完整性
validatePartsCompleteness(parts);
// 获取策略,判断是否需要验证
boolean needVerify = true;
StorageHandler storageHandler = storageHandlerFactory.createHandler(storageDO.getType());
if (storageHandler instanceof LocalStorageHandler) {
needVerify = false;
}
// 完成上传
storageHandler.completeMultipartUpload(storageDO, parts, initResp.getPath(), uploadId, needVerify);
FileDO file = new FileDO();
file.setName(initResp.getFileName().replaceFirst("^[/\\\\]+", ""));
file.setOriginalName(initResp.getFileName().replaceFirst("^[/\\\\]+", ""));
file.setPath(initResp.getPath());
file.setParentPath(initResp.getParentPath());
file.setSize(initResp.getFileSize());
file.setSha256(initResp.getFileMd5());
file.setExtension(initResp.getExtension());
file.setContentType(initResp.getContentType());
file.setType(FileTypeEnum.getByExtension(FileUtil.extName(initResp.getFileName())));
file.setStorageId(storageDO.getId());
fileService.save(file);
multipartUploadDao.deleteMultipartUpload(uploadId);
return file;
}
@Override
public void cancelMultipartUpload(String uploadId) {
StorageDO storageDO = storageService.getByCode(null);
multipartUploadDao.deleteMultipartUploadAll(uploadId);
StorageHandler storageHandler = storageHandlerFactory.createHandler(storageDO.getType());
storageHandler.cleanPart(storageDO, uploadId);
}
/**
* 验证分片完整性
*
* @param parts 分片信息
*/
private void validatePartsCompleteness(List<MultipartUploadResp> parts) {
if (parts.isEmpty()) {
throw new BaseException("没有找到任何分片信息");
}
// 检查分片编号连续性
List<Integer> partNumbers = parts.stream().map(MultipartUploadResp::getPartNumber).sorted().toList();
for (int i = 0; i < partNumbers.size(); i++) {
if (partNumbers.get(i) != i + 1) {
throw new BaseException("分片编号不连续,缺失分片: " + (i + 1));
}
}
// 检查是否所有分片都成功
List<Integer> failedParts = parts.stream()
.filter(part -> !part.isSuccess())
.map(MultipartUploadResp::getPartNumber)
.toList();
if (!failedParts.isEmpty()) {
throw new BaseException("存在失败的分片: " + failedParts);
}
}
}

View File

@@ -32,7 +32,7 @@ import top.continew.admin.system.mapper.OptionMapper;
import top.continew.admin.system.model.entity.OptionDO;
import top.continew.admin.system.model.query.OptionQuery;
import top.continew.admin.system.model.req.OptionReq;
import top.continew.admin.system.model.req.OptionResetValueReq;
import top.continew.admin.system.model.req.OptionValueResetReq;
import top.continew.admin.system.model.resp.OptionResp;
import top.continew.admin.system.service.OptionService;
import top.continew.starter.cache.redisson.util.RedisUtils;
@@ -104,7 +104,7 @@ public class OptionServiceImpl implements OptionService {
}
@Override
public void resetValue(OptionResetValueReq req) {
public void resetValue(OptionValueResetReq req) {
RedisUtils.deleteByPattern(CacheConstants.OPTION_KEY_PREFIX + StringConstants.ASTERISK);
String category = req.getCategory();
List<String> codeList = req.getCode();

View File

@@ -35,8 +35,8 @@ import top.continew.admin.system.constant.SystemConstants;
import top.continew.admin.system.mapper.RoleMapper;
import top.continew.admin.system.model.entity.RoleDO;
import top.continew.admin.system.model.query.RoleQuery;
import top.continew.admin.system.model.req.RolePermissionUpdateReq;
import top.continew.admin.system.model.req.RoleReq;
import top.continew.admin.system.model.req.RoleUpdatePermissionReq;
import top.continew.admin.system.model.resp.MenuResp;
import top.continew.admin.system.model.resp.role.RoleDetailResp;
import top.continew.admin.system.model.resp.role.RoleResp;
@@ -135,14 +135,17 @@ public class RoleServiceImpl extends BaseServiceImpl<RoleMapper, RoleDO, RoleRes
@Override
public List<LabelValueResp> dict(RoleQuery query, SortQuery sortQuery) {
query.setExcludeRoleCodes(RoleCodeEnum.getSuperRoleCodes());
boolean isTenantAdmin = UserContextHolder.isTenantAdmin();
query.setExcludeRoleCodes(isTenantAdmin
? List.of(RoleCodeEnum.SUPER_ADMIN.getCode())
: RoleCodeEnum.getSuperRoleCodes());
return super.dict(query, sortQuery);
}
@Override
@Transactional(rollbackFor = Exception.class)
@CacheInvalidate(key = "#id", name = CacheConstants.ROLE_MENU_KEY_PREFIX)
public void updatePermission(Long id, RoleUpdatePermissionReq req) {
public void updatePermission(Long id, RolePermissionUpdateReq req) {
RoleDO role = super.getById(id);
CheckUtils.throwIf(Boolean.TRUE.equals(role.getIsSystem()), "[{}] 是系统内置角色,不允许修改角色功能权限", role.getName());
// 保存角色和菜单关联

View File

@@ -28,9 +28,9 @@ import org.dromara.x.file.storage.core.FileStorageServiceBuilder;
import org.dromara.x.file.storage.core.platform.FileStorage;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import top.continew.admin.common.model.req.CommonStatusUpdateReq;
import top.continew.admin.common.base.service.BaseServiceImpl;
import top.continew.admin.common.enums.DisEnableStatusEnum;
import top.continew.admin.common.model.req.CommonStatusUpdateReq;
import top.continew.admin.common.util.SecureUtils;
import top.continew.admin.system.enums.StorageTypeEnum;
import top.continew.admin.system.mapper.StorageMapper;
@@ -40,7 +40,6 @@ import top.continew.admin.system.model.req.StorageReq;
import top.continew.admin.system.model.resp.StorageResp;
import top.continew.admin.system.service.FileService;
import top.continew.admin.system.service.StorageService;
import top.continew.starter.core.constant.StringConstants;
import top.continew.starter.core.util.ExceptionUtils;
import top.continew.starter.core.util.SpringWebUtils;
import top.continew.starter.core.util.validation.CheckUtils;
@@ -68,6 +67,7 @@ public class StorageServiceImpl extends BaseServiceImpl<StorageMapper, StorageDO
public void beforeCreate(StorageReq req) {
// 解密密钥
if (StorageTypeEnum.OSS.equals(req.getType())) {
ValidationUtils.throwIfBlank(req.getSecretKey(), "Secret Key不能为空");
req.setSecretKey(this.decryptSecretKey(req.getSecretKey(), null));
}
// 指定配置参数校验及预处理
@@ -228,13 +228,9 @@ public class StorageServiceImpl extends BaseServiceImpl<StorageMapper, StorageDO
* @return 解密后的 SecretKey
*/
private String decryptSecretKey(String encryptSecretKey, StorageDO oldStorage) {
// 修改时SecretKey 为空或带 *将不更改
if (oldStorage != null) {
boolean isSecretKeyNotUpdate = StrUtil.isBlank(encryptSecretKey) || encryptSecretKey
.contains(StringConstants.ASTERISK);
if (isSecretKeyNotUpdate) {
return oldStorage.getSecretKey();
}
// 修改时SecretKey 为空将不更改
if (oldStorage != null && StrUtil.isBlank(encryptSecretKey)) {
return oldStorage.getSecretKey();
}
// 解密
String secretKey = ExceptionUtils.exToNull(() -> SecureUtils.decryptByRsaPrivateKey(encryptSecretKey));

View File

@@ -80,10 +80,10 @@ import top.continew.starter.core.exception.BusinessException;
import top.continew.starter.core.util.CollUtils;
import top.continew.starter.core.util.FileUploadUtils;
import top.continew.starter.core.util.validation.CheckUtils;
import top.continew.starter.encrypt.field.util.EncryptHelper;
import top.continew.starter.extension.crud.model.query.PageQuery;
import top.continew.starter.extension.crud.model.query.SortQuery;
import top.continew.starter.extension.crud.model.resp.PageResp;
import top.continew.starter.security.crypto.util.EncryptHelper;
import java.io.IOException;
import java.time.Duration;
@@ -264,10 +264,10 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, UserDO, UserRes
List<String> roleNames = validRowList.stream().map(UserImportRowReq::getRoleName).distinct().toList();
int existRoleCount = roleService.countByNames(roleNames);
CheckUtils.throwIf(existRoleCount < roleNames.size(), "存在无效角色,请检查数据");
// 校验是否存在无效部门
List<String> deptNames = CollUtils.mapToList(validRowList, UserImportRowReq::getDeptName);
int existDeptCount = deptService.countByNames(deptNames);
CheckUtils.throwIf(existDeptCount < deptNames.size(), "存在无效部门,请检查数据");
// 校验是否存在无效部门(支持多级部门解析)
Set<String> deptNames = CollUtils.mapToSet(validRowList, UserImportRowReq::getDeptName);
int existDeptCount = countValidMultiLevelDepts(deptNames);
CheckUtils.throwIf(existDeptCount < deptNames.size(), "存在无效部门,请检查部门名称或部门层级是否正确");
// 查询重复用户
userImportResp
@@ -319,11 +319,11 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, UserDO, UserRes
.distinct()
.toList());
Map<String, Long> roleMap = roleList.stream().collect(Collectors.toMap(RoleDO::getName, RoleDO::getId));
List<DeptDO> deptList = deptService.listByNames(importUserList.stream()
// 获取多级部门映射
Map<String, Long> deptMap = buildMultiLevelDeptMapping(importUserList.stream()
.map(UserImportRowReq::getDeptName)
.distinct()
.toList());
Map<String, Long> deptMap = deptList.stream().collect(Collectors.toMap(DeptDO::getName, DeptDO::getId));
// 批量操作数据库集合
List<UserDO> insertList = new ArrayList<>();
@@ -731,4 +731,130 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, UserDO, UserRes
CheckUtils.throwIfNull(user, "用户不存在");
return user;
}
/**
* 统计有效的多级部门数量
* <p>
* 支持多级部门路径解析,使用冒号(:)作为层级分隔符
* 例如公司A:研发部:前端组 或 研发部
* </p>
*
* @param deptNames 部门名称集合
* @return 有效部门数量
*/
private int countValidMultiLevelDepts(Set<String> deptNames) {
CheckUtils.throwIfEmpty(deptNames, "部门名称集合不能为空");
int validCount = 0;
List<String> invalidDepts = new ArrayList<>();
for (String deptName : deptNames) {
try {
findDeptByHierarchicalPath(deptName);
validCount++;
} catch (Exception e) {
invalidDepts.add(deptName);
}
}
CheckUtils.throwIf(CollUtil.isNotEmpty(invalidDepts), "以下部门无效或存在歧义:{}", String.join(", ", invalidDepts));
return validCount;
}
/**
* 构建多级部门映射关系
* <p>
* 将部门名称列表转换为部门名称到ID的映射支持多级部门路径解析
* </p>
*
* @param deptNames 部门名称列表
* @return 部门名称到ID的映射
*/
private Map<String, Long> buildMultiLevelDeptMapping(List<String> deptNames) {
CheckUtils.throwIfEmpty(deptNames, "部门名称列表不能为空");
Map<String, Long> deptMap = new HashMap<>();
for (String deptName : deptNames) {
DeptDO dept = findDeptByHierarchicalPath(deptName);
CheckUtils.throwIfNull(dept, "部门 [{}] 不存在或存在歧义", deptName);
deptMap.put(deptName, dept.getId());
}
return deptMap;
}
/**
* 根据层级路径查找部门
* <p>
* 支持两种格式:
* <ul>
* <li>多级部门公司A/研发部/前端组</li>
* <li>单级部门:研发部</li>
* </ul>
* 使用左斜杠/作为层级分隔符,会逐级查找对应的部门
* </p>
*
* @param deptPath 部门路径
* @return 部门信息未找到时返回null
*/
private DeptDO findDeptByHierarchicalPath(String deptPath) {
CheckUtils.throwIfBlank(deptPath, "部门路径不能为空");
return deptPath.contains(StringConstants.SLASH)
? findMultiLevelDept(deptPath)
: findSingleLevelDept(deptPath.trim());
}
/**
* 查找多级部门
* <p>
* 从根部门开始逐级查找,确保部门层级关系正确
* </p>
*
* @param deptPath 多级部门路径
* @return 部门信息未找到时返回null
*/
private DeptDO findMultiLevelDept(String deptPath) {
String[] pathParts = deptPath.split(StringConstants.SLASH);
CheckUtils.throwIf(pathParts.length == 0, "部门路径格式错误:{}", deptPath);
// 从根部门开始逐级查找
DeptDO currentDept = null;
Long parentId = 0L; // 根部门的parentId为null
for (String part : pathParts) {
String trimmedPart = part.trim();
CheckUtils.throwIfBlank(trimmedPart, "部门路径包含空名称:{}", deptPath);
// 查找当前层级下指定名称的部门
currentDept = deptService.lambdaQuery()
.eq(DeptDO::getName, trimmedPart)
.eq(DeptDO::getParentId, parentId)
.one();
CheckUtils.throwIfNull(currentDept, "找不到部门 [{}] 在路径 [{}] 中", trimmedPart, deptPath);
parentId = currentDept.getId(); // 更新父级ID为当前部门ID
}
return currentDept;
}
/**
* 查找单级部门
* <p>
* 当只提供部门名称时,检查是否存在多个同名部门
* 如果存在多个同名部门,则要求用户提供完整的层级路径
* </p>
*
* @param deptName 部门名称
* @return 部门信息未找到或存在歧义时返回null
*/
private DeptDO findSingleLevelDept(String deptName) {
// 查找所有同名部门
List<DeptDO> deptList = deptService.lambdaQuery().eq(DeptDO::getName, deptName).list();
CheckUtils.throwIfEmpty(deptList, "部门 [{}] 不存在", deptName);
CheckUtils.throwIf(deptList.size() > 1, "存在多个同名部门 [{}],请使用完整层级路径,如:公司名:{}", deptName, deptName);
return deptList.get(0);
}
}

View File

@@ -3,8 +3,6 @@
# 若对镜像大小有严格要求,可将当前镜像替换为 alpine 版本
FROM bellsoft/liberica-openjdk-debian:17.0.14
MAINTAINER Charles7c charles7c@126.com
ARG JAR_FILE=./bin/*.jar
COPY ${JAR_FILE} /app/bin/app.jar
WORKDIR /app/bin

View File

@@ -2,10 +2,13 @@ version: '3'
services:
mysql:
image: mysql:8.0.42
restart: always
container_name: mysql
restart: always
ports:
- '3306:3306'
volumes:
- /docker/mysql/conf/:/etc/mysql/conf.d/
- /docker/mysql/data/:/var/lib/mysql/
environment:
TZ: Asia/Shanghai
MYSQL_ROOT_PASSWORD: 你的root用户密码
@@ -13,9 +16,6 @@ services:
MYSQL_DATABASE: continew_admin
#MYSQL_USER: 你的数据库用户名
#MYSQL_PASSWORD: 你的数据库密码
volumes:
- /docker/mysql/conf/:/etc/mysql/conf.d/
- /docker/mysql/data/:/var/lib/mysql/
command:
--default-authentication-plugin=mysql_native_password
--character-set-server=utf8mb4
@@ -24,37 +24,42 @@ services:
--lower_case_table_names=1
# postgresql:
# image: postgres:14.2
# restart: always
# container_name: postgresql
# restart: always
# ports:
# - '5432:5432'
# volumes:
# - /docker/postgresql/data/:/var/lib/postgresql/data/
# environment:
# TZ: Asia/Shanghai
# POSTGRES_USER: 你的用户名
# POSTGRES_PASSWORD: 你的用户密码
# POSTGRES_DB: continew_admin
# volumes:
# - /docker/postgresql/data/:/var/lib/postgresql/data/
redis:
image: redis:7.2.8
restart: always
container_name: redis
restart: always
ports:
- '6379:6379'
environment:
TZ: Asia/Shanghai
volumes:
- /docker/redis/conf/redis.conf:/usr/local/redis/config/redis.conf
- /docker/redis/data/:/data/
- /docker/redis/logs/:/logs/
command: 'redis-server /usr/local/redis/config/redis.conf --appendonly yes --requirepass 你的 Redis 密码'
environment:
TZ: Asia/Shanghai
command: 'redis-server /usr/local/redis/config/redis.conf --appendonly yes'
continew-server:
build: ./continew-admin
restart: always
container_name: continew-server
restart: always
ports:
- '18000:18000'
- '1789:1789'
volumes:
- /docker/continew-admin/config/:/app/config/
- /docker/continew-admin/data/file/:/app/data/file/
- /docker/continew-admin/logs/:/app/logs/
- /docker/continew-admin/lib/:/app/lib/
environment:
TZ: Asia/Shanghai
DB_HOST: 172.17.0.1
@@ -69,36 +74,33 @@ services:
SCHEDULE_HOST: 172.17.0.1
SCHEDULE_PORT: 1788
SCHEDULE_TOKEN: 任务调度服务端 Token
volumes:
- /docker/continew-admin/config/:/app/config/
- /docker/continew-admin/data/file/:/app/data/file/
- /docker/continew-admin/logs/:/app/logs/
- /docker/continew-admin/lib/:/app/lib/
depends_on:
- redis
- mysql
continew-web:
image: nginx:1.27.0
restart: always
container_name: continew-web
restart: always
ports:
- '80:80'
- '443:443'
environment:
TZ: Asia/Shanghai
volumes:
- /docker/nginx/conf/nginx.conf:/etc/nginx/nginx.conf
- /docker/nginx/cert/:/etc/nginx/cert/
- /docker/nginx/logs/:/var/log/nginx/
# 前端页面目录
- /docker/continew-admin/web/:/usr/share/nginx/html/
environment:
TZ: Asia/Shanghai
schedule-server:
build: ./schedule-server
restart: always
container_name: schedule-server
restart: always
ports:
- '18001:18001'
- '1788:1788'
volumes:
- /docker/schedule-server/logs/:/app/logs/
environment:
TZ: Asia/Shanghai
DB_HOST: 172.17.0.1
@@ -106,7 +108,5 @@ services:
DB_USER: 你的数据库用户名
DB_PWD: 你的数据库密码
DB_NAME: continew_admin_job
volumes:
- /docker/schedule-server/logs/:/app/logs/
depends_on:
- mysql

View File

@@ -1 +0,0 @@
Redis 数据存储目录,请确保赋予了读写权限,否则将无法写入数据

View File

@@ -3,8 +3,6 @@
# 若对镜像大小有严格要求,可将当前镜像替换为 alpine 版本
FROM bellsoft/liberica-openjdk-debian:17.0.14
MAINTAINER Charles7c charles7c@126.com
ARG JAR_FILE=*.jar
COPY ${JAR_FILE} /app/bin/app.jar
WORKDIR /app/bin

View File

@@ -13,7 +13,7 @@
<parent>
<groupId>top.continew.starter</groupId>
<artifactId>continew-starter</artifactId>
<version>2.13.4</version>
<version>2.14.0-SNAPSHOT</version>
</parent>
<groupId>top.continew.admin</groupId>
@@ -35,7 +35,7 @@
<properties>
<!-- 项目版本号 -->
<revision>4.0.0</revision>
<revision>4.1.0-SNAPSHOT</revision>
</properties>
<!-- 全局依赖版本管理 -->