From 2200009c8c07c43a72f94a5bded44e3450e7456c Mon Sep 17 00:00:00 2001 From: ikmkj Date: Sun, 15 Mar 2026 18:25:58 +0800 Subject: [PATCH] feat: initialize project --- .gitattributes | 2 + .gitignore | 44 ++ .mvn/wrapper/maven-wrapper.properties | 3 + docs/API_EXAMPLES.md | 397 ++++++++++++++++++ mvnw | 295 +++++++++++++ mvnw.cmd | 189 +++++++++ pom.xml | 240 +++++++++++ .../java/com/test/demo/DemoApplication.java | 13 + .../java/com/test/demo/aspect/LogAspect.java | 165 ++++++++ .../com/test/demo/aspect/RateLimitAspect.java | 77 ++++ .../java/com/test/demo/bo/EnumItemBo.java | 49 +++ .../java/com/test/demo/bo/EnumTypeBo.java | 41 ++ .../com/test/demo/bo/EnumTypeQueryBo.java | 27 ++ .../java/com/test/demo/bo/FileQueryBo.java | 21 + src/main/java/com/test/demo/bo/LoginBo.java | 29 ++ .../com/test/demo/bo/LoginLogQueryBo.java | 24 ++ src/main/java/com/test/demo/bo/MenuBo.java | 54 +++ .../java/com/test/demo/bo/OperLogQueryBo.java | 27 ++ .../java/com/test/demo/bo/RegisterBo.java | 43 ++ src/main/java/com/test/demo/bo/RoleBo.java | 36 ++ .../com/test/demo/bo/RoleMenuAssignBo.java | 22 + .../java/com/test/demo/bo/RoleQueryBo.java | 21 + .../java/com/test/demo/bo/TaskQueryBo.java | 24 ++ src/main/java/com/test/demo/bo/UserBo.java | 44 ++ .../java/com/test/demo/bo/UserQueryBo.java | 24 ++ .../com/test/demo/bo/UserRoleAssignBo.java | 22 + .../demo/common/annotation/Desensitize.java | 23 + .../common/annotation/DesensitizeType.java | 19 + .../demo/common/annotation/Idempotent.java | 18 + .../com/test/demo/common/annotation/Log.java | 30 ++ .../demo/common/annotation/RateLimit.java | 21 + .../com/test/demo/common/base/BaseBo.java | 29 ++ .../com/test/demo/common/base/BaseEntity.java | 36 ++ .../com/test/demo/common/base/PageResult.java | 49 +++ .../demo/common/constant/CommonConstant.java | 35 ++ .../demo/common/constant/EnumSourceType.java | 13 + .../common/constant/PermissionConstant.java | 61 +++ .../common/constant/RedisKeyConstant.java | 36 ++ .../com/test/demo/common/enums/EnumDesc.java | 15 + .../demo/common/enums/EnumDeserializer.java | 52 +++ .../test/demo/common/enums/EnumRegistry.java | 134 ++++++ .../demo/common/enums/EnumSerializer.java | 22 + .../com/test/demo/common/enums/IBaseEnum.java | 13 + .../demo/common/exception/BizException.java | 53 +++ .../exception/GlobalExceptionHandler.java | 127 ++++++ .../java/com/test/demo/common/result/R.java | 63 +++ .../test/demo/common/result/ResultCode.java | 30 ++ .../test/demo/common/validate/ValidGroup.java | 19 + .../test/demo/config/AuthDataInitializer.java | 204 +++++++++ .../test/demo/config/CaffeineCacheConfig.java | 29 ++ .../java/com/test/demo/config/FileConfig.java | 27 ++ .../com/test/demo/config/JacksonConfig.java | 46 ++ .../com/test/demo/config/Knife4jConfig.java | 24 ++ .../test/demo/config/MybatisPlusConfig.java | 27 ++ .../com/test/demo/config/RedisConfig.java | 31 ++ .../com/test/demo/config/SaTokenConfig.java | 13 + .../com/test/demo/config/ScheduleConfig.java | 25 ++ .../test/demo/config/ThreadPoolConfig.java | 32 ++ .../com/test/demo/config/WebMvcConfig.java | 65 +++ .../com/test/demo/config/XssFilterConfig.java | 30 ++ .../test/demo/controller/AuthController.java | 51 +++ .../demo/controller/CaptchaController.java | 30 ++ .../test/demo/controller/EnumController.java | 43 ++ .../demo/controller/EnumManageController.java | 149 +++++++ .../test/demo/controller/FileController.java | 122 ++++++ .../test/demo/controller/LogController.java | 79 ++++ .../test/demo/controller/MenuController.java | 81 ++++ .../test/demo/controller/RoleController.java | 117 ++++++ .../test/demo/controller/TaskController.java | 95 +++++ .../test/demo/controller/UserController.java | 125 ++++++ .../com/test/demo/convert/UserConvert.java | 33 ++ .../com/test/demo/entity/EnumItemEntity.java | 74 ++++ .../com/test/demo/entity/EnumTypeEntity.java | 71 ++++ .../com/test/demo/entity/LoginLogEntity.java | 49 +++ .../java/com/test/demo/entity/MenuEntity.java | 50 +++ .../com/test/demo/entity/OperLogEntity.java | 77 ++++ .../java/com/test/demo/entity/RoleEntity.java | 32 ++ .../com/test/demo/entity/RoleMenuEntity.java | 31 ++ .../test/demo/entity/ScheduledTaskEntity.java | 45 ++ .../java/com/test/demo/entity/UserEntity.java | 50 +++ .../com/test/demo/entity/UserRoleEntity.java | 31 ++ .../java/com/test/demo/enums/LogType.java | 37 ++ .../com/test/demo/enums/UserStatusEnum.java | 23 + .../demo/filter/RepeatableReadFilter.java | 25 ++ .../filter/RepeatableReadRequestWrapper.java | 64 +++ .../java/com/test/demo/filter/XssFilter.java | 43 ++ .../test/demo/filter/XssRequestWrapper.java | 40 ++ .../demo/handler/MyMetaObjectHandler.java | 25 ++ .../interceptor/IdempotentInterceptor.java | 58 +++ .../interceptor/SignatureInterceptor.java | 75 ++++ .../com/test/demo/mapper/EnumItemMapper.java | 12 + .../com/test/demo/mapper/EnumTypeMapper.java | 12 + .../com/test/demo/mapper/LoginLogMapper.java | 12 + .../java/com/test/demo/mapper/MenuMapper.java | 48 +++ .../com/test/demo/mapper/OperLogMapper.java | 12 + .../java/com/test/demo/mapper/RoleMapper.java | 23 + .../com/test/demo/mapper/RoleMenuMapper.java | 12 + .../test/demo/mapper/ScheduledTaskMapper.java | 12 + .../java/com/test/demo/mapper/UserMapper.java | 12 + .../com/test/demo/mapper/UserRoleMapper.java | 12 + .../com/test/demo/security/SecurityUtil.java | 57 +++ .../test/demo/security/StpInterfaceImpl.java | 34 ++ .../test/demo/security/crypto/SM3Util.java | 47 +++ .../test/demo/security/crypto/SM4Util.java | 61 +++ .../serializer/DesensitizeSerializer.java | 54 +++ .../com/test/demo/service/AuthService.java | 20 + .../com/test/demo/service/CaptchaService.java | 57 +++ .../test/demo/service/DynamicTaskService.java | 149 +++++++ .../test/demo/service/EnumItemService.java | 29 ++ .../test/demo/service/EnumTypeService.java | 30 ++ .../com/test/demo/service/FileService.java | 24 ++ .../test/demo/service/LoginLogService.java | 21 + .../com/test/demo/service/MailService.java | 16 + .../com/test/demo/service/MenuService.java | 25 ++ .../com/test/demo/service/OperLogService.java | 19 + .../com/test/demo/service/RoleService.java | 36 ++ .../com/test/demo/service/UserService.java | 38 ++ .../demo/service/impl/AuthServiceImpl.java | 153 +++++++ .../service/impl/EnumItemServiceImpl.java | 193 +++++++++ .../service/impl/EnumTypeServiceImpl.java | 159 +++++++ .../demo/service/impl/FileServiceImpl.java | 156 +++++++ .../service/impl/LoginLogServiceImpl.java | 112 +++++ .../demo/service/impl/MailServiceImpl.java | 77 ++++ .../demo/service/impl/MenuServiceImpl.java | 95 +++++ .../demo/service/impl/OperLogServiceImpl.java | 66 +++ .../demo/service/impl/RoleServiceImpl.java | 180 ++++++++ .../demo/service/impl/UserServiceImpl.java | 165 ++++++++ .../com/test/demo/util/DesensitizeUtil.java | 48 +++ .../java/com/test/demo/util/ExcelUtil.java | 45 ++ .../java/com/test/demo/util/FilePathUtil.java | 59 +++ .../java/com/test/demo/util/IdGenerator.java | 25 ++ .../java/com/test/demo/util/LockUtil.java | 63 +++ .../java/com/test/demo/util/MoneyUtil.java | 75 ++++ .../java/com/test/demo/vo/FileInfoVo.java | 38 ++ .../com/test/demo/vo/LoginLogExportVo.java | 51 +++ src/main/java/com/test/demo/vo/LoginVo.java | 29 ++ .../com/test/demo/vo/OperLogExportVo.java | 55 +++ .../java/com/test/demo/vo/RoleDetailVo.java | 21 + .../java/com/test/demo/vo/UserImportVo.java | 37 ++ src/main/java/com/test/demo/vo/UserVo.java | 45 ++ src/main/resources/application-dev.yml | 39 ++ src/main/resources/application-prod.yml | 36 ++ src/main/resources/application-test.yml | 37 ++ src/main/resources/application.yml | 86 ++++ src/main/resources/logback-spring.xml | 105 +++++ src/main/resources/mapper/OperLogMapper.xml | 5 + .../resources/mapper/ScheduledTaskMapper.xml | 5 + src/main/resources/mapper/UserMapper.xml | 5 + src/main/resources/sql/init-auth-schema.sql | 61 +++ src/main/resources/sql/init-demo-data.sql | 47 +++ src/main/resources/sql/schema.sql | 219 ++++++++++ 151 files changed, 8685 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .mvn/wrapper/maven-wrapper.properties create mode 100644 docs/API_EXAMPLES.md create mode 100644 mvnw create mode 100644 mvnw.cmd create mode 100644 pom.xml create mode 100644 src/main/java/com/test/demo/DemoApplication.java create mode 100644 src/main/java/com/test/demo/aspect/LogAspect.java create mode 100644 src/main/java/com/test/demo/aspect/RateLimitAspect.java create mode 100644 src/main/java/com/test/demo/bo/EnumItemBo.java create mode 100644 src/main/java/com/test/demo/bo/EnumTypeBo.java create mode 100644 src/main/java/com/test/demo/bo/EnumTypeQueryBo.java create mode 100644 src/main/java/com/test/demo/bo/FileQueryBo.java create mode 100644 src/main/java/com/test/demo/bo/LoginBo.java create mode 100644 src/main/java/com/test/demo/bo/LoginLogQueryBo.java create mode 100644 src/main/java/com/test/demo/bo/MenuBo.java create mode 100644 src/main/java/com/test/demo/bo/OperLogQueryBo.java create mode 100644 src/main/java/com/test/demo/bo/RegisterBo.java create mode 100644 src/main/java/com/test/demo/bo/RoleBo.java create mode 100644 src/main/java/com/test/demo/bo/RoleMenuAssignBo.java create mode 100644 src/main/java/com/test/demo/bo/RoleQueryBo.java create mode 100644 src/main/java/com/test/demo/bo/TaskQueryBo.java create mode 100644 src/main/java/com/test/demo/bo/UserBo.java create mode 100644 src/main/java/com/test/demo/bo/UserQueryBo.java create mode 100644 src/main/java/com/test/demo/bo/UserRoleAssignBo.java create mode 100644 src/main/java/com/test/demo/common/annotation/Desensitize.java create mode 100644 src/main/java/com/test/demo/common/annotation/DesensitizeType.java create mode 100644 src/main/java/com/test/demo/common/annotation/Idempotent.java create mode 100644 src/main/java/com/test/demo/common/annotation/Log.java create mode 100644 src/main/java/com/test/demo/common/annotation/RateLimit.java create mode 100644 src/main/java/com/test/demo/common/base/BaseBo.java create mode 100644 src/main/java/com/test/demo/common/base/BaseEntity.java create mode 100644 src/main/java/com/test/demo/common/base/PageResult.java create mode 100644 src/main/java/com/test/demo/common/constant/CommonConstant.java create mode 100644 src/main/java/com/test/demo/common/constant/EnumSourceType.java create mode 100644 src/main/java/com/test/demo/common/constant/PermissionConstant.java create mode 100644 src/main/java/com/test/demo/common/constant/RedisKeyConstant.java create mode 100644 src/main/java/com/test/demo/common/enums/EnumDesc.java create mode 100644 src/main/java/com/test/demo/common/enums/EnumDeserializer.java create mode 100644 src/main/java/com/test/demo/common/enums/EnumRegistry.java create mode 100644 src/main/java/com/test/demo/common/enums/EnumSerializer.java create mode 100644 src/main/java/com/test/demo/common/enums/IBaseEnum.java create mode 100644 src/main/java/com/test/demo/common/exception/BizException.java create mode 100644 src/main/java/com/test/demo/common/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/test/demo/common/result/R.java create mode 100644 src/main/java/com/test/demo/common/result/ResultCode.java create mode 100644 src/main/java/com/test/demo/common/validate/ValidGroup.java create mode 100644 src/main/java/com/test/demo/config/AuthDataInitializer.java create mode 100644 src/main/java/com/test/demo/config/CaffeineCacheConfig.java create mode 100644 src/main/java/com/test/demo/config/FileConfig.java create mode 100644 src/main/java/com/test/demo/config/JacksonConfig.java create mode 100644 src/main/java/com/test/demo/config/Knife4jConfig.java create mode 100644 src/main/java/com/test/demo/config/MybatisPlusConfig.java create mode 100644 src/main/java/com/test/demo/config/RedisConfig.java create mode 100644 src/main/java/com/test/demo/config/SaTokenConfig.java create mode 100644 src/main/java/com/test/demo/config/ScheduleConfig.java create mode 100644 src/main/java/com/test/demo/config/ThreadPoolConfig.java create mode 100644 src/main/java/com/test/demo/config/WebMvcConfig.java create mode 100644 src/main/java/com/test/demo/config/XssFilterConfig.java create mode 100644 src/main/java/com/test/demo/controller/AuthController.java create mode 100644 src/main/java/com/test/demo/controller/CaptchaController.java create mode 100644 src/main/java/com/test/demo/controller/EnumController.java create mode 100644 src/main/java/com/test/demo/controller/EnumManageController.java create mode 100644 src/main/java/com/test/demo/controller/FileController.java create mode 100644 src/main/java/com/test/demo/controller/LogController.java create mode 100644 src/main/java/com/test/demo/controller/MenuController.java create mode 100644 src/main/java/com/test/demo/controller/RoleController.java create mode 100644 src/main/java/com/test/demo/controller/TaskController.java create mode 100644 src/main/java/com/test/demo/controller/UserController.java create mode 100644 src/main/java/com/test/demo/convert/UserConvert.java create mode 100644 src/main/java/com/test/demo/entity/EnumItemEntity.java create mode 100644 src/main/java/com/test/demo/entity/EnumTypeEntity.java create mode 100644 src/main/java/com/test/demo/entity/LoginLogEntity.java create mode 100644 src/main/java/com/test/demo/entity/MenuEntity.java create mode 100644 src/main/java/com/test/demo/entity/OperLogEntity.java create mode 100644 src/main/java/com/test/demo/entity/RoleEntity.java create mode 100644 src/main/java/com/test/demo/entity/RoleMenuEntity.java create mode 100644 src/main/java/com/test/demo/entity/ScheduledTaskEntity.java create mode 100644 src/main/java/com/test/demo/entity/UserEntity.java create mode 100644 src/main/java/com/test/demo/entity/UserRoleEntity.java create mode 100644 src/main/java/com/test/demo/enums/LogType.java create mode 100644 src/main/java/com/test/demo/enums/UserStatusEnum.java create mode 100644 src/main/java/com/test/demo/filter/RepeatableReadFilter.java create mode 100644 src/main/java/com/test/demo/filter/RepeatableReadRequestWrapper.java create mode 100644 src/main/java/com/test/demo/filter/XssFilter.java create mode 100644 src/main/java/com/test/demo/filter/XssRequestWrapper.java create mode 100644 src/main/java/com/test/demo/handler/MyMetaObjectHandler.java create mode 100644 src/main/java/com/test/demo/interceptor/IdempotentInterceptor.java create mode 100644 src/main/java/com/test/demo/interceptor/SignatureInterceptor.java create mode 100644 src/main/java/com/test/demo/mapper/EnumItemMapper.java create mode 100644 src/main/java/com/test/demo/mapper/EnumTypeMapper.java create mode 100644 src/main/java/com/test/demo/mapper/LoginLogMapper.java create mode 100644 src/main/java/com/test/demo/mapper/MenuMapper.java create mode 100644 src/main/java/com/test/demo/mapper/OperLogMapper.java create mode 100644 src/main/java/com/test/demo/mapper/RoleMapper.java create mode 100644 src/main/java/com/test/demo/mapper/RoleMenuMapper.java create mode 100644 src/main/java/com/test/demo/mapper/ScheduledTaskMapper.java create mode 100644 src/main/java/com/test/demo/mapper/UserMapper.java create mode 100644 src/main/java/com/test/demo/mapper/UserRoleMapper.java create mode 100644 src/main/java/com/test/demo/security/SecurityUtil.java create mode 100644 src/main/java/com/test/demo/security/StpInterfaceImpl.java create mode 100644 src/main/java/com/test/demo/security/crypto/SM3Util.java create mode 100644 src/main/java/com/test/demo/security/crypto/SM4Util.java create mode 100644 src/main/java/com/test/demo/serializer/DesensitizeSerializer.java create mode 100644 src/main/java/com/test/demo/service/AuthService.java create mode 100644 src/main/java/com/test/demo/service/CaptchaService.java create mode 100644 src/main/java/com/test/demo/service/DynamicTaskService.java create mode 100644 src/main/java/com/test/demo/service/EnumItemService.java create mode 100644 src/main/java/com/test/demo/service/EnumTypeService.java create mode 100644 src/main/java/com/test/demo/service/FileService.java create mode 100644 src/main/java/com/test/demo/service/LoginLogService.java create mode 100644 src/main/java/com/test/demo/service/MailService.java create mode 100644 src/main/java/com/test/demo/service/MenuService.java create mode 100644 src/main/java/com/test/demo/service/OperLogService.java create mode 100644 src/main/java/com/test/demo/service/RoleService.java create mode 100644 src/main/java/com/test/demo/service/UserService.java create mode 100644 src/main/java/com/test/demo/service/impl/AuthServiceImpl.java create mode 100644 src/main/java/com/test/demo/service/impl/EnumItemServiceImpl.java create mode 100644 src/main/java/com/test/demo/service/impl/EnumTypeServiceImpl.java create mode 100644 src/main/java/com/test/demo/service/impl/FileServiceImpl.java create mode 100644 src/main/java/com/test/demo/service/impl/LoginLogServiceImpl.java create mode 100644 src/main/java/com/test/demo/service/impl/MailServiceImpl.java create mode 100644 src/main/java/com/test/demo/service/impl/MenuServiceImpl.java create mode 100644 src/main/java/com/test/demo/service/impl/OperLogServiceImpl.java create mode 100644 src/main/java/com/test/demo/service/impl/RoleServiceImpl.java create mode 100644 src/main/java/com/test/demo/service/impl/UserServiceImpl.java create mode 100644 src/main/java/com/test/demo/util/DesensitizeUtil.java create mode 100644 src/main/java/com/test/demo/util/ExcelUtil.java create mode 100644 src/main/java/com/test/demo/util/FilePathUtil.java create mode 100644 src/main/java/com/test/demo/util/IdGenerator.java create mode 100644 src/main/java/com/test/demo/util/LockUtil.java create mode 100644 src/main/java/com/test/demo/util/MoneyUtil.java create mode 100644 src/main/java/com/test/demo/vo/FileInfoVo.java create mode 100644 src/main/java/com/test/demo/vo/LoginLogExportVo.java create mode 100644 src/main/java/com/test/demo/vo/LoginVo.java create mode 100644 src/main/java/com/test/demo/vo/OperLogExportVo.java create mode 100644 src/main/java/com/test/demo/vo/RoleDetailVo.java create mode 100644 src/main/java/com/test/demo/vo/UserImportVo.java create mode 100644 src/main/java/com/test/demo/vo/UserVo.java create mode 100644 src/main/resources/application-dev.yml create mode 100644 src/main/resources/application-prod.yml create mode 100644 src/main/resources/application-test.yml create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/logback-spring.xml create mode 100644 src/main/resources/mapper/OperLogMapper.xml create mode 100644 src/main/resources/mapper/ScheduledTaskMapper.xml create mode 100644 src/main/resources/mapper/UserMapper.xml create mode 100644 src/main/resources/sql/init-auth-schema.sql create mode 100644 src/main/resources/sql/init-demo-data.sql create mode 100644 src/main/resources/sql/schema.sql diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3b41682 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d3f681 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +HELP.md +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ +logs/ +log/ +*.log +*.tmp +*.temp +*.swp +*.swo +*.bak +*.orig +.DS_Store +Thumbs.db + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..8dea6c2 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.12/apache-maven-3.9.12-bin.zip diff --git a/docs/API_EXAMPLES.md b/docs/API_EXAMPLES.md new file mode 100644 index 0000000..a174fbe --- /dev/null +++ b/docs/API_EXAMPLES.md @@ -0,0 +1,397 @@ +# 接口联调示例 + +## 启动顺序 + +1. 执行 `src/main/resources/sql/init-auth-schema.sql` +2. 执行 `src/main/resources/sql/init-demo-data.sql` +3. 启动应用 +4. 访问 `POST /api/captcha/generate` +5. 使用返回的验证码登录 + +## 1. 获取验证码 + +接口: + +```http +POST /api/captcha/generate +``` + +返回示例: + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "key": "8f3b8fd4d2cf4d1ca6a4b8cf7ab33f9d", + "image": "data:image/png;base64,..." + } +} +``` + +## 2. 登录 + +接口: + +```http +POST /api/auth/login +Content-Type: application/json +``` + +请求体: + +```json +{ + "username": "admin", + "password": "admin123", + "captchaKey": "8f3b8fd4d2cf4d1ca6a4b8cf7ab33f9d", + "captchaCode": "a8k3" +} +``` + +返回示例: + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "token": "xxxxxx", + "tokenName": "Authorization", + "userInfo": { + "id": 1000000000000000001, + "username": "admin", + "nickname": "系统管理员", + "status": 1, + "statusDesc": "正常" + }, + "roles": ["ADMIN"], + "permissions": [ + "system:user:page", + "system:role:page", + "system:menu:list" + ] + } +} +``` + +后续请求头: + +```http +Authorization: xxxxxx +``` + +## 3. 查询当前用户菜单 + +接口: + +```http +POST /api/menu/current +Authorization: xxxxxx +``` + +## 4. 查询当前用户权限标识 + +接口: + +```http +POST /api/menu/permissions/current +Authorization: xxxxxx +``` + +## 5. 查询前端下拉字典 + +查询字典类型: + +```http +POST /api/enums/list +``` + +返回示例: + +```json +{ + "code": 200, + "msg": "操作成功", + "data": [ + { + "enumName": "LogType", + "enumDesc": "日志类型枚举", + "displayName": "LogType", + "className": "com.test.demo.enums.LogType", + "packageName": "com.test.demo.enums", + "valueType": "java.lang.String", + "sourceType": "SYSTEM", + "builtin": 1, + "queryPath": "/api/enums/LogType" + } + ] +} +``` + +查询字典项: + +```http +POST /api/enums/items/LogType +``` + +返回示例: + +```json +{ + "code": 200, + "msg": "操作成功", + "data": [ + { + "value": "LOGIN", + "desc": "登录日志" + }, + { + "value": "OPER", + "desc": "操作日志" + } + ] +} +``` + +## 6. 新增自定义字典类型 + +接口: + +```http +POST /api/enum-manage/type/add +Authorization: xxxxxx +Content-Type: application/json +``` + +请求体: + +```json +{ + "enumCode": "PayChannel", + "enumName": "支付渠道", + "enumDesc": "支付渠道字典", + "enabled": 1, + "sort": 1, + "remark": "联调新增" +} +``` + +## 7. 新增自定义字典项 + +接口: + +```http +POST /api/enum-manage/item/add +Authorization: xxxxxx +Content-Type: application/json +``` + +请求体: + +```json +{ + "enumCode": "PayChannel", + "itemValue": "ALIPAY", + "itemLabel": "支付宝", + "itemName": "ALIPAY", + "enabled": 1, + "sort": 1 +} +``` + +## 8. 启停字典类型 + +接口: + +```http +POST /api/enum-manage/type/enable/2033103240660238338?enabled=0 +Authorization: xxxxxx +``` + +## 9. 启停字典项 + +接口: + +```http +POST /api/enum-manage/item/enable/2033103240752513025?enabled=1 +Authorization: xxxxxx +``` + +## 10. 查询登录日志 + +接口: + +```http +POST /api/log/login/page +Authorization: xxxxxx +Content-Type: application/json +``` + +请求体: + +```json +{ + "pageNum": 1, + "pageSize": 10, + "username": "admin" +} +``` + +## 11. 导出登录日志 + +接口: + +```http +POST /api/log/login/export +Authorization: xxxxxx +Content-Type: application/json +``` + +请求体: + +```json +{ + "username": "admin", + "logType": "LOGIN" +} +``` + +## 12. 查询操作日志 + +接口: + +```http +POST /api/log/oper/page +Authorization: xxxxxx +Content-Type: application/json +``` + +请求体: + +```json +{ + "pageNum": 1, + "pageSize": 10, + "userName": "admin" +} +``` + +## 13. 导出操作日志 + +接口: + +```http +POST /api/log/oper/export +Authorization: xxxxxx +Content-Type: application/json +``` + +请求体: + +```json +{ + "userName": "admin", + "logType": "OPER" +} +``` + +## 14. 查询文件列表 + +接口: + +```http +POST /api/file/list +Authorization: xxxxxx +Content-Type: application/json +``` + +请求体: + +```json +{ + "relativeDir": "", + "recursive": true, + "pageNum": 1, + "pageSize": 20 +} +``` + +## 15. 上传文件 + +接口: + +```http +POST /api/file/upload +Authorization: xxxxxx +Content-Type: multipart/form-data +``` + +参数: + +`file`: 文件 + +## 16. 下载文件 + +接口: + +```http +GET /api/file/download/2026/03/15/demo.xlsx +Authorization: xxxxxx +``` + +## 17. 预览文件 + +接口: + +```http +GET /api/file/preview/2026/03/15/demo.xlsx +``` + +## 18. 导入用户 + +接口: + +```http +POST /api/user/import +Authorization: xxxxxx +Content-Type: multipart/form-data +``` + +参数: + +`file`: 用户导入模板 + +## 19. 导出用户 + +接口: + +```http +POST /api/user/export +Authorization: xxxxxx +Content-Type: application/json +``` + +请求体: + +```json +{ + "username": "admin", + "pageNum": 1, + "pageSize": 10 +} +``` + +## 20. 可选签名请求头 + +如果客户端要启用签名,可附带以下请求头: + +```http +X-Timestamp: 1710000000 +X-Nonce: random-string +X-Sign: sha256(timestamp + nonce + body + signSecret) +``` + +签名密钥配置在: + +`security.sign.secret` diff --git a/mvnw b/mvnw new file mode 100644 index 0000000..bd8896b --- /dev/null +++ b/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..da0f67f --- /dev/null +++ b/pom.xml @@ -0,0 +1,240 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.3.7 + + + com.test + demo + 0.0.1-SNAPSHOT + demo + Spring Boot 4 后台管理脚手架 + + + 17 + 3.5.10.1 + 1.43.0 + 4.5.0 + 2.8.6 + 3.40.2 + 5.8.43 + 1.83 + 1.3.0 + 1.6.2 + 1.6.3 + 1.18.36 + 0.2.0 + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-tomcat + + + + + + + org.springframework.boot + spring-boot-starter-undertow + + + + + org.springframework.boot + spring-boot-starter-aop + + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + org.redisson + redisson-spring-boot-starter + ${redisson.version} + + + + + org.springframework.boot + spring-boot-starter-cache + + + com.github.ben-manes.caffeine + caffeine + + + + + com.mysql + mysql-connector-j + runtime + + + + + com.baomidou + mybatis-plus-spring-boot3-starter + ${mybatis-plus.version} + + + + com.baomidou + mybatis-plus-jsqlparser + ${mybatis-plus.version} + + + + + cn.dev33 + sa-token-spring-boot3-starter + ${sa-token.version} + + + + cn.dev33 + sa-token-redis-jackson + ${sa-token.version} + + + + + com.github.xiaoymin + knife4j-openapi3-jakarta-spring-boot-starter + ${knife4j.version} + + + + cn.hutool + hutool-all + ${hutool.version} + + + + + org.bouncycastle + bcprov-jdk18on + ${bouncycastle.version} + + + + + cn.idev.excel + fastexcel + ${fastexcel.version} + + + + + com.github.whvcse + easy-captcha + ${easy-captcha.version} + + + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + + + org.springframework.boot + spring-boot-starter-mail + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.projectlombok + lombok + ${lombok.version} + true + + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + + + org.apache.commons + commons-pool2 + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + ${lombok.version} + + + org.projectlombok + lombok-mapstruct-binding + ${lombok-mapstruct-binding.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/src/main/java/com/test/demo/DemoApplication.java b/src/main/java/com/test/demo/DemoApplication.java new file mode 100644 index 0000000..31a4057 --- /dev/null +++ b/src/main/java/com/test/demo/DemoApplication.java @@ -0,0 +1,13 @@ +package com.test.demo; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class DemoApplication { + + public static void main(String[] args) { + SpringApplication.run(DemoApplication.class, args); + } + +} diff --git a/src/main/java/com/test/demo/aspect/LogAspect.java b/src/main/java/com/test/demo/aspect/LogAspect.java new file mode 100644 index 0000000..787eee7 --- /dev/null +++ b/src/main/java/com/test/demo/aspect/LogAspect.java @@ -0,0 +1,165 @@ +package com.test.demo.aspect; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.test.demo.bo.LoginBo; +import com.test.demo.common.annotation.Log; +import com.test.demo.entity.OperLogEntity; +import com.test.demo.entity.UserEntity; +import com.test.demo.log.event.OperLogEvent; +import com.test.demo.security.SecurityUtil; +import com.test.demo.service.UserService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.time.LocalDateTime; + +/** + * 操作日志切面 + */ +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class LogAspect { + + private final ApplicationEventPublisher eventPublisher; + private final ObjectMapper objectMapper; + private final UserService userService; + + @Around("@annotation(logAnnotation)") + public Object around(ProceedingJoinPoint point, Log logAnnotation) throws Throwable { + long startTime = System.currentTimeMillis(); + OperLogEntity operLog = new OperLogEntity(); + operLog.setTitle(logAnnotation.value()); + operLog.setLogType(logAnnotation.type().name()); + + // 请求信息 + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes != null) { + HttpServletRequest request = attributes.getRequest(); + operLog.setRequestUrl(request.getRequestURI()); + operLog.setRequestMethod(request.getMethod()); + operLog.setIp(getClientIp(request)); + } + + // 方法信息 + MethodSignature signature = (MethodSignature) point.getSignature(); + operLog.setMethod(signature.getDeclaringTypeName() + "." + signature.getName()); + + // 请求参数(脱敏) + try { + String params = objectMapper.writeValueAsString(point.getArgs()); + operLog.setRequestParam(truncate(params, 2000)); + } catch (Exception e) { + operLog.setRequestParam("参数序列化失败"); + } + + // 用户信息 + try { + Long userId = SecurityUtil.getUserIdOrNull(); + operLog.setUserId(userId); + if (userId != null) { + UserEntity user = userService.getById(userId); + if (user != null) { + operLog.setUserName(user.getUsername()); + } + } + } catch (Exception ignored) {} + + Object result = null; + try { + result = point.proceed(); + operLog.setStatus(1); + // 返回结果(脱敏) + try { + String resultStr = objectMapper.writeValueAsString(result); + operLog.setResponseResult(truncate(resultStr, 2000)); + } catch (Exception ignored) {} + } catch (Throwable e) { + operLog.setStatus(0); + operLog.setErrorMsg(truncate(e.getMessage(), 2000)); + throw e; + } finally { + if (operLog.getUserId() == null) { + Long userId = SecurityUtil.getUserIdOrNull(); + if (userId != null) { + operLog.setUserId(userId); + UserEntity user = userService.getById(userId); + if (user != null) { + operLog.setUserName(user.getUsername()); + } + } + } + if (operLog.getUserName() == null) { + operLog.setUserName(extractUsername(point.getArgs())); + } + operLog.setCostTime(System.currentTimeMillis() - startTime); + operLog.setCreateTime(LocalDateTime.now()); + + // 日志输出 + switch (logAnnotation.level()) { + case DEBUG -> log.debug("[{}] {} 耗时:{}ms", logAnnotation.type(), logAnnotation.value(), operLog.getCostTime()); + case WARN -> log.warn("[{}] {} 耗时:{}ms", logAnnotation.type(), logAnnotation.value(), operLog.getCostTime()); + case ERROR -> log.error("[{}] {} 耗时:{}ms", logAnnotation.type(), logAnnotation.value(), operLog.getCostTime()); + default -> log.info("[{}] {} 耗时:{}ms", logAnnotation.type(), logAnnotation.value(), operLog.getCostTime()); + } + + // 异步写入数据库 + if (logAnnotation.saveToDB()) { + eventPublisher.publishEvent(new OperLogEvent(this, operLog)); + } + } + return result; + } + + private String getClientIp(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("X-Real-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + // 多级代理取第一个 + if (ip != null && ip.contains(",")) { + ip = ip.split(",")[0].trim(); + } + return ip; + } + + private String truncate(String str, int maxLen) { + if (str == null) return null; + return str.length() > maxLen ? str.substring(0, maxLen) : str; + } + + private String extractUsername(Object[] args) { + if (args == null) { + return null; + } + for (Object arg : args) { + if (arg instanceof LoginBo loginBo) { + return loginBo.getUsername(); + } + if (arg == null) { + continue; + } + try { + var method = arg.getClass().getMethod("getUsername"); + Object username = method.invoke(arg); + if (username != null) { + return String.valueOf(username); + } + } catch (Exception ignored) {} + } + return null; + } +} diff --git a/src/main/java/com/test/demo/aspect/RateLimitAspect.java b/src/main/java/com/test/demo/aspect/RateLimitAspect.java new file mode 100644 index 0000000..3d37400 --- /dev/null +++ b/src/main/java/com/test/demo/aspect/RateLimitAspect.java @@ -0,0 +1,77 @@ +package com.test.demo.aspect; + +import com.test.demo.common.annotation.RateLimit; +import com.test.demo.common.constant.RedisKeyConstant; +import com.test.demo.common.exception.BizException; +import com.test.demo.common.result.ResultCode; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.util.Collections; +import java.util.List; + +/** + * 限流切面(Redis 滑动窗口) + */ +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class RateLimitAspect { + + private final RedisTemplate redisTemplate; + + private static final String LUA_SCRIPT = """ + local key = KEYS[1] + local limit = tonumber(ARGV[1]) + local window = tonumber(ARGV[2]) + local now = tonumber(ARGV[3]) + redis.call('ZREMRANGEBYSCORE', key, 0, now - window * 1000) + local count = redis.call('ZCARD', key) + if count < limit then + redis.call('ZADD', key, now, now .. '-' .. math.random(1000000)) + redis.call('EXPIRE', key, window) + return 1 + end + return 0 + """; + + @Around("@annotation(rateLimit)") + public Object around(ProceedingJoinPoint point, RateLimit rateLimit) throws Throwable { + String key = buildKey(point, rateLimit); + DefaultRedisScript script = new DefaultRedisScript<>(LUA_SCRIPT, Long.class); + List keys = Collections.singletonList(key); + Long result = redisTemplate.execute(script, keys, + (long) rateLimit.count(), (long) rateLimit.time(), System.currentTimeMillis()); + + if (result == null || result == 0L) { + log.warn("限流触发: key={}", key); + throw new BizException(ResultCode.RATE_LIMIT); + } + return point.proceed(); + } + + private String buildKey(ProceedingJoinPoint point, RateLimit rateLimit) { + if (!rateLimit.key().isEmpty()) { + return RedisKeyConstant.RATE_LIMIT + rateLimit.key(); + } + MethodSignature signature = (MethodSignature) point.getSignature(); + String methodName = signature.getDeclaringTypeName() + "." + signature.getName(); + String ip = ""; + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes != null) { + ip = attributes.getRequest().getRemoteAddr(); + } + return RedisKeyConstant.RATE_LIMIT + ip + ":" + methodName; + } +} diff --git a/src/main/java/com/test/demo/bo/EnumItemBo.java b/src/main/java/com/test/demo/bo/EnumItemBo.java new file mode 100644 index 0000000..d07ced0 --- /dev/null +++ b/src/main/java/com/test/demo/bo/EnumItemBo.java @@ -0,0 +1,49 @@ +package com.test.demo.bo; + +import com.test.demo.common.validate.ValidGroup; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 枚举/字典项请求参数 + */ +@Data +@Schema(name = "EnumItemBo", description = "枚举/字典项请求参数") +public class EnumItemBo { + + @NotNull(groups = ValidGroup.Update.class, message = "字典项ID不能为空") + @Schema(description = "字典项ID", example = "2033103240752513025") + private Long id; + + @NotBlank(groups = {ValidGroup.Add.class, ValidGroup.Update.class}, message = "枚举编码不能为空") + @Schema(description = "枚举编码", example = "LogType") + private String enumCode; + + @NotBlank(groups = ValidGroup.Add.class, message = "字典值不能为空") + @Schema(description = "字典值", example = "LOGIN") + private String itemValue; + + @NotBlank(groups = {ValidGroup.Add.class, ValidGroup.Update.class}, message = "显示文本不能为空") + @Schema(description = "显示文本", example = "登录日志") + private String itemLabel; + + @Schema(description = "系统枚举常量名", example = "LOGIN") + private String itemName; + + @Schema(description = "是否启用", example = "1") + private Integer enabled; + + @Schema(description = "排序", example = "0") + private Integer sort; + + @Schema(description = "标签颜色", example = "#409EFF") + private String color; + + @Schema(description = "标签类型", example = "success") + private String tagType; + + @Schema(description = "备注", example = "系统默认日志类型") + private String remark; +} diff --git a/src/main/java/com/test/demo/bo/EnumTypeBo.java b/src/main/java/com/test/demo/bo/EnumTypeBo.java new file mode 100644 index 0000000..dc093ab --- /dev/null +++ b/src/main/java/com/test/demo/bo/EnumTypeBo.java @@ -0,0 +1,41 @@ +package com.test.demo.bo; + +import com.test.demo.common.validate.ValidGroup; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 枚举/字典类型请求参数 + */ +@Data +@Schema(name = "EnumTypeBo", description = "枚举/字典类型请求参数") +public class EnumTypeBo { + + @NotNull(groups = ValidGroup.Update.class, message = "类型ID不能为空") + @Schema(description = "类型ID", example = "2033103240660238338") + private Long id; + + @NotBlank(groups = ValidGroup.Add.class, message = "枚举编码不能为空") + @Schema(description = "枚举编码", example = "LogType") + private String enumCode; + + @Schema(description = "枚举名称", example = "日志类型") + private String enumName; + + @Schema(description = "枚举描述", example = "日志类型枚举") + private String enumDesc; + + @Schema(description = "是否启用", example = "1") + private Integer enabled; + + @Schema(description = "是否允许系统同步更新", example = "1") + private Integer syncEnabled; + + @Schema(description = "排序", example = "0") + private Integer sort; + + @Schema(description = "备注", example = "系统内置日志类型") + private String remark; +} diff --git a/src/main/java/com/test/demo/bo/EnumTypeQueryBo.java b/src/main/java/com/test/demo/bo/EnumTypeQueryBo.java new file mode 100644 index 0000000..140ec05 --- /dev/null +++ b/src/main/java/com/test/demo/bo/EnumTypeQueryBo.java @@ -0,0 +1,27 @@ +package com.test.demo.bo; + +import com.test.demo.common.base.BaseBo; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 枚举/字典类型查询参数 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(name = "EnumTypeQueryBo", description = "枚举/字典类型查询参数") +public class EnumTypeQueryBo extends BaseBo { + + @Schema(description = "枚举编码", example = "LogType") + private String enumCode; + + @Schema(description = "枚举名称/描述关键字", example = "日志") + private String keyword; + + @Schema(description = "来源类型", example = "SYSTEM", allowableValues = {"SYSTEM", "CUSTOM"}) + private String sourceType; + + @Schema(description = "是否启用", example = "1") + private Integer enabled; +} diff --git a/src/main/java/com/test/demo/bo/FileQueryBo.java b/src/main/java/com/test/demo/bo/FileQueryBo.java new file mode 100644 index 0000000..98642b5 --- /dev/null +++ b/src/main/java/com/test/demo/bo/FileQueryBo.java @@ -0,0 +1,21 @@ +package com.test.demo.bo; + +import com.test.demo.common.base.BaseBo; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 文件查询参数 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(name = "FileQueryBo", description = "文件查询参数") +public class FileQueryBo extends BaseBo { + + @Schema(description = "相对目录", example = "2026/03/15") + private String relativeDir; + + @Schema(description = "是否递归查询子目录", example = "false", defaultValue = "false") + private Boolean recursive = false; +} diff --git a/src/main/java/com/test/demo/bo/LoginBo.java b/src/main/java/com/test/demo/bo/LoginBo.java new file mode 100644 index 0000000..5b55726 --- /dev/null +++ b/src/main/java/com/test/demo/bo/LoginBo.java @@ -0,0 +1,29 @@ +package com.test.demo.bo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 登录请求参数 + */ +@Data +@Schema(name = "LoginBo", description = "登录请求参数") +public class LoginBo { + + @NotBlank(message = "用户名不能为空") + @Schema(description = "用户名", example = "admin") + private String username; + + @NotBlank(message = "密码不能为空") + @Schema(description = "密码", example = "admin123") + private String password; + + @NotBlank(message = "验证码Key不能为空") + @Schema(description = "验证码Key", example = "8f3b8fd4d2cf4d1ca6a4b8cf7ab33f9d") + private String captchaKey; + + @NotBlank(message = "验证码不能为空") + @Schema(description = "验证码", example = "a8k3") + private String captchaCode; +} diff --git a/src/main/java/com/test/demo/bo/LoginLogQueryBo.java b/src/main/java/com/test/demo/bo/LoginLogQueryBo.java new file mode 100644 index 0000000..ba7fcf3 --- /dev/null +++ b/src/main/java/com/test/demo/bo/LoginLogQueryBo.java @@ -0,0 +1,24 @@ +package com.test.demo.bo; + +import com.test.demo.common.base.BaseBo; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 登录日志查询参数 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(name = "LoginLogQueryBo", description = "登录日志查询参数") +public class LoginLogQueryBo extends BaseBo { + + @Schema(description = "用户名", example = "admin") + private String username; + + @Schema(description = "日志类型", example = "LOGIN", allowableValues = {"LOGIN", "LOGOUT"}) + private String logType; + + @Schema(description = "状态", example = "1", allowableValues = {"0", "1"}) + private Integer status; +} diff --git a/src/main/java/com/test/demo/bo/MenuBo.java b/src/main/java/com/test/demo/bo/MenuBo.java new file mode 100644 index 0000000..d363b15 --- /dev/null +++ b/src/main/java/com/test/demo/bo/MenuBo.java @@ -0,0 +1,54 @@ +package com.test.demo.bo; + +import com.test.demo.common.validate.ValidGroup; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 菜单请求参数 + */ +@Data +@Schema(name = "MenuBo", description = "菜单请求参数") +public class MenuBo { + + @NotNull(groups = ValidGroup.Update.class, message = "菜单ID不能为空") + @Schema(description = "菜单ID", example = "1") + private Long id; + + @Schema(description = "父级ID", example = "0") + private Long parentId; + + @NotBlank(groups = {ValidGroup.Add.class, ValidGroup.Update.class}, message = "菜单类型不能为空") + @Schema(description = "菜单类型", example = "MENU", allowableValues = {"MENU", "BUTTON"}) + private String menuType; + + @NotBlank(groups = {ValidGroup.Add.class, ValidGroup.Update.class}, message = "菜单名称不能为空") + @Schema(description = "菜单名称", example = "用户管理") + private String menuName; + + @Schema(description = "路由路径", example = "/user") + private String path; + + @Schema(description = "组件路径", example = "system/user/index") + private String component; + + @Schema(description = "权限标识", example = "system:user:page") + private String permission; + + @Schema(description = "图标", example = "User") + private String icon; + + @Schema(description = "是否显示", example = "1") + private Integer visible; + + @Schema(description = "是否启用", example = "1") + private Integer enabled; + + @Schema(description = "排序", example = "0") + private Integer sort; + + @Schema(description = "备注") + private String remark; +} diff --git a/src/main/java/com/test/demo/bo/OperLogQueryBo.java b/src/main/java/com/test/demo/bo/OperLogQueryBo.java new file mode 100644 index 0000000..a18a395 --- /dev/null +++ b/src/main/java/com/test/demo/bo/OperLogQueryBo.java @@ -0,0 +1,27 @@ +package com.test.demo.bo; + +import com.test.demo.common.base.BaseBo; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 操作日志查询参数 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(name = "OperLogQueryBo", description = "操作日志查询参数") +public class OperLogQueryBo extends BaseBo { + + @Schema(description = "操作说明", example = "新增用户") + private String title; + + @Schema(description = "日志类型", example = "OPER") + private String logType; + + @Schema(description = "操作用户名", example = "admin") + private String userName; + + @Schema(description = "状态", example = "1", allowableValues = {"0", "1"}) + private Integer status; +} diff --git a/src/main/java/com/test/demo/bo/RegisterBo.java b/src/main/java/com/test/demo/bo/RegisterBo.java new file mode 100644 index 0000000..faf7631 --- /dev/null +++ b/src/main/java/com/test/demo/bo/RegisterBo.java @@ -0,0 +1,43 @@ +package com.test.demo.bo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 注册请求参数 + */ +@Data +@Schema(name = "RegisterBo", description = "注册请求参数") +public class RegisterBo { + + @NotBlank(message = "用户名不能为空") + @Schema(description = "用户名", example = "zhangsan") + private String username; + + @NotBlank(message = "密码不能为空") + @Schema(description = "密码", example = "123456") + private String password; + + @Schema(description = "昵称", example = "张三") + private String nickname; + + @Email(message = "邮箱格式不正确") + @Schema(description = "邮箱", example = "zhangsan@example.com") + private String email; + + @Schema(description = "手机号", example = "13800138000") + private String phone; + + @Schema(description = "头像路径", example = "/avatar/default.png") + private String avatar; + + @NotBlank(message = "验证码Key不能为空") + @Schema(description = "验证码Key", example = "8f3b8fd4d2cf4d1ca6a4b8cf7ab33f9d") + private String captchaKey; + + @NotBlank(message = "验证码不能为空") + @Schema(description = "验证码", example = "a8k3") + private String captchaCode; +} diff --git a/src/main/java/com/test/demo/bo/RoleBo.java b/src/main/java/com/test/demo/bo/RoleBo.java new file mode 100644 index 0000000..94eede9 --- /dev/null +++ b/src/main/java/com/test/demo/bo/RoleBo.java @@ -0,0 +1,36 @@ +package com.test.demo.bo; + +import com.test.demo.common.validate.ValidGroup; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 角色请求参数 + */ +@Data +@Schema(name = "RoleBo", description = "角色请求参数") +public class RoleBo { + + @NotNull(groups = ValidGroup.Update.class, message = "角色ID不能为空") + @Schema(description = "角色ID", example = "1") + private Long id; + + @NotBlank(groups = ValidGroup.Add.class, message = "角色编码不能为空") + @Schema(description = "角色编码", example = "ADMIN") + private String roleCode; + + @NotBlank(groups = {ValidGroup.Add.class, ValidGroup.Update.class}, message = "角色名称不能为空") + @Schema(description = "角色名称", example = "系统管理员") + private String roleName; + + @Schema(description = "是否启用", example = "1") + private Integer enabled; + + @Schema(description = "排序", example = "0") + private Integer sort; + + @Schema(description = "备注") + private String remark; +} diff --git a/src/main/java/com/test/demo/bo/RoleMenuAssignBo.java b/src/main/java/com/test/demo/bo/RoleMenuAssignBo.java new file mode 100644 index 0000000..bda096c --- /dev/null +++ b/src/main/java/com/test/demo/bo/RoleMenuAssignBo.java @@ -0,0 +1,22 @@ +package com.test.demo.bo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +/** + * 角色菜单分配参数 + */ +@Data +@Schema(name = "RoleMenuAssignBo", description = "角色菜单分配参数") +public class RoleMenuAssignBo { + + @NotNull(message = "角色ID不能为空") + @Schema(description = "角色ID", example = "1") + private Long roleId; + + @Schema(description = "菜单ID列表") + private List menuIds; +} diff --git a/src/main/java/com/test/demo/bo/RoleQueryBo.java b/src/main/java/com/test/demo/bo/RoleQueryBo.java new file mode 100644 index 0000000..a797a6c --- /dev/null +++ b/src/main/java/com/test/demo/bo/RoleQueryBo.java @@ -0,0 +1,21 @@ +package com.test.demo.bo; + +import com.test.demo.common.base.BaseBo; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 角色查询参数 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(name = "RoleQueryBo", description = "角色查询参数") +public class RoleQueryBo extends BaseBo { + + @Schema(description = "角色编码/名称关键字", example = "管理员") + private String keyword; + + @Schema(description = "是否启用", example = "1") + private Integer enabled; +} diff --git a/src/main/java/com/test/demo/bo/TaskQueryBo.java b/src/main/java/com/test/demo/bo/TaskQueryBo.java new file mode 100644 index 0000000..93e9565 --- /dev/null +++ b/src/main/java/com/test/demo/bo/TaskQueryBo.java @@ -0,0 +1,24 @@ +package com.test.demo.bo; + +import com.test.demo.common.base.BaseBo; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 定时任务查询参数 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(name = "TaskQueryBo", description = "定时任务查询参数") +public class TaskQueryBo extends BaseBo { + + @Schema(description = "任务名称", example = "清理") + private String taskName; + + @Schema(description = "Bean名称", example = "fileService") + private String beanName; + + @Schema(description = "状态", example = "1", allowableValues = {"0", "1"}) + private Integer status; +} diff --git a/src/main/java/com/test/demo/bo/UserBo.java b/src/main/java/com/test/demo/bo/UserBo.java new file mode 100644 index 0000000..2c9ec47 --- /dev/null +++ b/src/main/java/com/test/demo/bo/UserBo.java @@ -0,0 +1,44 @@ +package com.test.demo.bo; + +import com.test.demo.common.validate.ValidGroup; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 用户新增/编辑 Bo + */ +@Data +@Schema(name = "UserBo", description = "用户请求参数") +public class UserBo { + + @NotNull(groups = ValidGroup.Update.class, message = "用户ID不能为空") + @Schema(description = "用户ID(编辑时必填)", example = "1") + private Long id; + + @NotBlank(groups = ValidGroup.Add.class, message = "用户名不能为空") + @Schema(description = "用户名", example = "admin") + private String username; + + @NotBlank(groups = ValidGroup.Add.class, message = "密码不能为空") + @Schema(description = "密码", example = "admin123") + private String password; + + @Schema(description = "昵称", example = "管理员") + private String nickname; + + @Email(message = "邮箱格式不正确") + @Schema(description = "邮箱", example = "admin@example.com") + private String email; + + @Schema(description = "手机号", example = "13800138000") + private String phone; + + @Schema(description = "头像路径", example = "/avatar/admin.png") + private String avatar; + + @Schema(description = "状态", example = "1", allowableValues = {"0", "1"}) + private Integer status; +} diff --git a/src/main/java/com/test/demo/bo/UserQueryBo.java b/src/main/java/com/test/demo/bo/UserQueryBo.java new file mode 100644 index 0000000..cedcccc --- /dev/null +++ b/src/main/java/com/test/demo/bo/UserQueryBo.java @@ -0,0 +1,24 @@ +package com.test.demo.bo; + +import com.test.demo.common.base.BaseBo; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 用户查询 Bo + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(name = "UserQueryBo", description = "用户查询参数") +public class UserQueryBo extends BaseBo { + + @Schema(description = "用户名(模糊查询)", example = "admin") + private String username; + + @Schema(description = "手机号", example = "13800138000") + private String phone; + + @Schema(description = "状态", example = "1", allowableValues = {"0", "1"}) + private Integer status; +} diff --git a/src/main/java/com/test/demo/bo/UserRoleAssignBo.java b/src/main/java/com/test/demo/bo/UserRoleAssignBo.java new file mode 100644 index 0000000..1460452 --- /dev/null +++ b/src/main/java/com/test/demo/bo/UserRoleAssignBo.java @@ -0,0 +1,22 @@ +package com.test.demo.bo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +/** + * 用户角色分配参数 + */ +@Data +@Schema(name = "UserRoleAssignBo", description = "用户角色分配参数") +public class UserRoleAssignBo { + + @NotNull(message = "用户ID不能为空") + @Schema(description = "用户ID", example = "1") + private Long userId; + + @Schema(description = "角色ID列表") + private List roleIds; +} diff --git a/src/main/java/com/test/demo/common/annotation/Desensitize.java b/src/main/java/com/test/demo/common/annotation/Desensitize.java new file mode 100644 index 0000000..feb9388 --- /dev/null +++ b/src/main/java/com/test/demo/common/annotation/Desensitize.java @@ -0,0 +1,23 @@ +package com.test.demo.common.annotation; + +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.test.demo.serializer.DesensitizeSerializer; + +import java.lang.annotation.*; + +/** + * 数据脱敏注解(标注在 VO 字段上) + */ +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@JacksonAnnotationsInside +@JsonSerialize(using = DesensitizeSerializer.class) +public @interface Desensitize { + + /** + * 脱敏类型 + */ + DesensitizeType type(); +} diff --git a/src/main/java/com/test/demo/common/annotation/DesensitizeType.java b/src/main/java/com/test/demo/common/annotation/DesensitizeType.java new file mode 100644 index 0000000..4b60b12 --- /dev/null +++ b/src/main/java/com/test/demo/common/annotation/DesensitizeType.java @@ -0,0 +1,19 @@ +package com.test.demo.common.annotation; + +/** + * 脱敏类型枚举 + */ +public enum DesensitizeType { + /** 手机号 */ + PHONE, + /** 身份证 */ + ID_CARD, + /** 邮箱 */ + EMAIL, + /** 银行卡 */ + BANK_CARD, + /** 姓名 */ + NAME, + /** 地址 */ + ADDRESS +} diff --git a/src/main/java/com/test/demo/common/annotation/Idempotent.java b/src/main/java/com/test/demo/common/annotation/Idempotent.java new file mode 100644 index 0000000..3db63c4 --- /dev/null +++ b/src/main/java/com/test/demo/common/annotation/Idempotent.java @@ -0,0 +1,18 @@ +package com.test.demo.common.annotation; + +import java.lang.annotation.*; + +/** + * 接口幂等性注解(用于插入类接口) + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Idempotent { + + /** 幂等有效期(秒) */ + int expireTime() default 5; + + /** 提示信息 */ + String message() default "请勿重复提交"; +} diff --git a/src/main/java/com/test/demo/common/annotation/Log.java b/src/main/java/com/test/demo/common/annotation/Log.java new file mode 100644 index 0000000..d5c30c5 --- /dev/null +++ b/src/main/java/com/test/demo/common/annotation/Log.java @@ -0,0 +1,30 @@ +package com.test.demo.common.annotation; + +import com.test.demo.enums.LogType; + +import java.lang.annotation.*; + +/** + * 操作日志注解 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Log { + + /** 操作说明(与 Knife4j @Operation summary 保持一致) */ + String value() default ""; + + /** 日志类型 */ + LogType type() default LogType.OPER; + + /** 是否写入数据库 */ + boolean saveToDB() default false; + + /** 日志等级 */ + LogLevel level() default LogLevel.INFO; + + enum LogLevel { + DEBUG, INFO, WARN, ERROR + } +} diff --git a/src/main/java/com/test/demo/common/annotation/RateLimit.java b/src/main/java/com/test/demo/common/annotation/RateLimit.java new file mode 100644 index 0000000..2efca44 --- /dev/null +++ b/src/main/java/com/test/demo/common/annotation/RateLimit.java @@ -0,0 +1,21 @@ +package com.test.demo.common.annotation; + +import java.lang.annotation.*; + +/** + * 限流注解 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RateLimit { + + /** 限流次数 */ + int count() default 10; + + /** 时间窗口(秒) */ + int time() default 60; + + /** 限流 key(默认 IP+方法名) */ + String key() default ""; +} diff --git a/src/main/java/com/test/demo/common/base/BaseBo.java b/src/main/java/com/test/demo/common/base/BaseBo.java new file mode 100644 index 0000000..d50316a --- /dev/null +++ b/src/main/java/com/test/demo/common/base/BaseBo.java @@ -0,0 +1,29 @@ +package com.test.demo.common.base; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * Bo 基类(分页参数) + */ +@Data +@Schema(description = "分页请求基类") +public class BaseBo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Min(value = 1, message = "页码最小为1") + @Schema(description = "页码", example = "1", defaultValue = "1") + private Integer pageNum = 1; + + @Min(value = 1, message = "每页条数最小为1") + @Max(value = 500, message = "每页条数最大为500") + @Schema(description = "每页条数", example = "10", defaultValue = "10") + private Integer pageSize = 10; +} diff --git a/src/main/java/com/test/demo/common/base/BaseEntity.java b/src/main/java/com/test/demo/common/base/BaseEntity.java new file mode 100644 index 0000000..e78b21e --- /dev/null +++ b/src/main/java/com/test/demo/common/base/BaseEntity.java @@ -0,0 +1,36 @@ +package com.test.demo.common.base; + +import com.baomidou.mybatisplus.annotation.*; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * Entity 基类 + */ +@Data +@Schema(description = "实体基类") +public abstract class BaseEntity implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.ASSIGN_ID) + @Schema(description = "主键ID", example = "2033103240660238338") + private Long id; + + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建时间") + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "更新时间") + private LocalDateTime updateTime; + + @TableLogic + @Schema(description = "逻辑删除标记", example = "0") + private Integer deleted; +} diff --git a/src/main/java/com/test/demo/common/base/PageResult.java b/src/main/java/com/test/demo/common/base/PageResult.java new file mode 100644 index 0000000..903a690 --- /dev/null +++ b/src/main/java/com/test/demo/common/base/PageResult.java @@ -0,0 +1,49 @@ +package com.test.demo.common.base; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Collections; +import java.util.List; + +/** + * 分页返回封装 + */ +@Data +@Schema(description = "分页响应结果") +public class PageResult implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "当前页数据列表") + private List list; + @Schema(description = "总记录数", example = "100") + private Long total; + @Schema(description = "当前页码", example = "1") + private Integer pageNum; + @Schema(description = "每页条数", example = "10") + private Integer pageSize; + @Schema(description = "总页数", example = "10") + private Integer totalPages; + + public PageResult() {} + + public PageResult(List list, Long total, Integer pageNum, Integer pageSize) { + this.list = list; + this.total = total; + this.pageNum = pageNum; + this.pageSize = pageSize; + this.totalPages = (int) Math.ceil((double) total / pageSize); + } + + public static PageResult of(List list, Long total, Integer pageNum, Integer pageSize) { + return new PageResult<>(list, total, pageNum, pageSize); + } + + public static PageResult empty(Integer pageNum, Integer pageSize) { + return new PageResult<>(Collections.emptyList(), 0L, pageNum, pageSize); + } +} diff --git a/src/main/java/com/test/demo/common/constant/CommonConstant.java b/src/main/java/com/test/demo/common/constant/CommonConstant.java new file mode 100644 index 0000000..bc50767 --- /dev/null +++ b/src/main/java/com/test/demo/common/constant/CommonConstant.java @@ -0,0 +1,35 @@ +package com.test.demo.common.constant; + +/** + * 通用常量 + */ +public final class CommonConstant { + + private CommonConstant() {} + + /** 成功标记 */ + public static final int SUCCESS = 200; + + /** 失败标记 */ + public static final int FAIL = 500; + + /** UTF-8 编码 */ + public static final String UTF8 = "UTF-8"; + + /** 默认日期格式 */ + public static final String DATE_FORMAT = "yyyy-MM-dd"; + + /** 默认日期时间格式 */ + public static final String DATETIME_FORMAT = "yyyy-MM-dd HH:mm:ss"; + + /** 未知用户 */ + public static final String UNKNOWN_USER = "unknown"; + + /** 签名请求头 */ + public static final String HEADER_TIMESTAMP = "X-Timestamp"; + public static final String HEADER_NONCE = "X-Nonce"; + public static final String HEADER_SIGN = "X-Sign"; + + /** 幂等性请求头 */ + public static final String HEADER_IDEMPOTENT_TOKEN = "X-Idempotent-Token"; +} diff --git a/src/main/java/com/test/demo/common/constant/EnumSourceType.java b/src/main/java/com/test/demo/common/constant/EnumSourceType.java new file mode 100644 index 0000000..083c8dd --- /dev/null +++ b/src/main/java/com/test/demo/common/constant/EnumSourceType.java @@ -0,0 +1,13 @@ +package com.test.demo.common.constant; + +/** + * 枚举/字典来源类型 + */ +public final class EnumSourceType { + + private EnumSourceType() {} + + public static final String SYSTEM = "SYSTEM"; + + public static final String CUSTOM = "CUSTOM"; +} diff --git a/src/main/java/com/test/demo/common/constant/PermissionConstant.java b/src/main/java/com/test/demo/common/constant/PermissionConstant.java new file mode 100644 index 0000000..ec821b5 --- /dev/null +++ b/src/main/java/com/test/demo/common/constant/PermissionConstant.java @@ -0,0 +1,61 @@ +package com.test.demo.common.constant; + +/** + * 权限标识常量 + */ +public final class PermissionConstant { + + private PermissionConstant() {} + + public static final String USER_PAGE = "system:user:page"; + public static final String USER_DETAIL = "system:user:detail"; + public static final String USER_ADD = "system:user:add"; + public static final String USER_EDIT = "system:user:edit"; + public static final String USER_REMOVE = "system:user:remove"; + public static final String USER_RESET_PASSWORD = "system:user:reset-password"; + public static final String USER_IMPORT = "system:user:import"; + public static final String USER_EXPORT = "system:user:export"; + + public static final String ROLE_PAGE = "system:role:page"; + public static final String ROLE_LIST = "system:role:list"; + public static final String ROLE_ADD = "system:role:add"; + public static final String ROLE_EDIT = "system:role:edit"; + public static final String ROLE_REMOVE = "system:role:remove"; + public static final String ROLE_ASSIGN_MENU = "system:role:assign-menu"; + public static final String ROLE_ASSIGN_USER = "system:role:assign-user"; + + public static final String MENU_LIST = "system:menu:list"; + public static final String MENU_ADD = "system:menu:add"; + public static final String MENU_EDIT = "system:menu:edit"; + public static final String MENU_REMOVE = "system:menu:remove"; + public static final String MENU_CURRENT = "system:menu:current"; + + public static final String ENUM_TYPE_PAGE = "system:enum:type:page"; + public static final String ENUM_TYPE_ADD = "system:enum:type:add"; + public static final String ENUM_TYPE_EDIT = "system:enum:type:edit"; + public static final String ENUM_TYPE_ENABLE = "system:enum:type:enable"; + public static final String ENUM_TYPE_REMOVE = "system:enum:type:remove"; + public static final String ENUM_ITEM_LIST = "system:enum:item:list"; + public static final String ENUM_ITEM_ADD = "system:enum:item:add"; + public static final String ENUM_ITEM_EDIT = "system:enum:item:edit"; + public static final String ENUM_ITEM_ENABLE = "system:enum:item:enable"; + public static final String ENUM_ITEM_REMOVE = "system:enum:item:remove"; + + public static final String TASK_PAGE = "system:task:page"; + public static final String TASK_DETAIL = "system:task:detail"; + public static final String TASK_ADD = "system:task:add"; + public static final String TASK_EDIT = "system:task:edit"; + public static final String TASK_PAUSE = "system:task:pause"; + public static final String TASK_RESUME = "system:task:resume"; + public static final String TASK_REMOVE = "system:task:remove"; + + public static final String FILE_LIST = "system:file:list"; + public static final String FILE_UPLOAD = "system:file:upload"; + public static final String FILE_DOWNLOAD = "system:file:download"; + public static final String FILE_DELETE = "system:file:delete"; + + public static final String LOGIN_LOG_PAGE = "system:login-log:page"; + public static final String LOGIN_LOG_EXPORT = "system:login-log:export"; + public static final String OPER_LOG_PAGE = "system:oper-log:page"; + public static final String OPER_LOG_EXPORT = "system:oper-log:export"; +} diff --git a/src/main/java/com/test/demo/common/constant/RedisKeyConstant.java b/src/main/java/com/test/demo/common/constant/RedisKeyConstant.java new file mode 100644 index 0000000..c61e4dd --- /dev/null +++ b/src/main/java/com/test/demo/common/constant/RedisKeyConstant.java @@ -0,0 +1,36 @@ +package com.test.demo.common.constant; + +/** + * Redis Key 常量 + */ +public final class RedisKeyConstant { + + private RedisKeyConstant() {} + + /** 前缀 */ + private static final String PREFIX = "demo:"; + + /** 验证码 */ + public static final String CAPTCHA = PREFIX + "captcha:"; + + /** 限流 */ + public static final String RATE_LIMIT = PREFIX + "rate_limit:"; + + /** 幂等性 */ + public static final String IDEMPOTENT = PREFIX + "idempotent:"; + + /** 防重放 nonce */ + public static final String NONCE = PREFIX + "nonce:"; + + /** 分布式锁 */ + public static final String LOCK = PREFIX + "lock:"; + + /** 用户权限缓存 */ + public static final String USER_PERMISSION = PREFIX + "user:permission:"; + + /** 用户角色缓存 */ + public static final String USER_ROLE = PREFIX + "user:role:"; + + /** 登录失败次数 */ + public static final String LOGIN_FAIL = PREFIX + "login:fail:"; +} diff --git a/src/main/java/com/test/demo/common/enums/EnumDesc.java b/src/main/java/com/test/demo/common/enums/EnumDesc.java new file mode 100644 index 0000000..4adac4d --- /dev/null +++ b/src/main/java/com/test/demo/common/enums/EnumDesc.java @@ -0,0 +1,15 @@ +package com.test.demo.common.enums; + +import java.lang.annotation.*; + +/** + * 枚举描述注解 - 标记在枚举类上,提供枚举说明信息 + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface EnumDesc { + + /** 枚举说明 */ + String value() default ""; +} diff --git a/src/main/java/com/test/demo/common/enums/EnumDeserializer.java b/src/main/java/com/test/demo/common/enums/EnumDeserializer.java new file mode 100644 index 0000000..0a24fb3 --- /dev/null +++ b/src/main/java/com/test/demo/common/enums/EnumDeserializer.java @@ -0,0 +1,52 @@ +package com.test.demo.common.enums; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.*; +import com.fasterxml.jackson.databind.deser.ContextualDeserializer; + +import java.io.IOException; + +/** + * 枚举 Jackson 反序列化器 - 按 value 匹配枚举 + */ +public class EnumDeserializer extends JsonDeserializer> implements ContextualDeserializer { + + private Class enumClass; + + public EnumDeserializer() {} + + public EnumDeserializer(Class enumClass) { + this.enumClass = enumClass; + } + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + public IBaseEnum deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + if (enumClass == null) { + return null; + } + String text = p.getText(); + for (Object constant : enumClass.getEnumConstants()) { + IBaseEnum baseEnum = (IBaseEnum) constant; + if (String.valueOf(baseEnum.getValue()).equals(text)) { + return baseEnum; + } + } + return null; + } + + @Override + public JsonDeserializer createContextual(DeserializationContext ctxt, BeanProperty property) { + JavaType type = ctxt.getContextualType(); + if (type == null && property != null) { + type = property.getType(); + } + if (type != null) { + Class rawClass = type.getRawClass(); + if (IBaseEnum.class.isAssignableFrom(rawClass)) { + return new EnumDeserializer(rawClass); + } + } + return this; + } +} diff --git a/src/main/java/com/test/demo/common/enums/EnumRegistry.java b/src/main/java/com/test/demo/common/enums/EnumRegistry.java new file mode 100644 index 0000000..8b9d182 --- /dev/null +++ b/src/main/java/com/test/demo/common/enums/EnumRegistry.java @@ -0,0 +1,134 @@ +package com.test.demo.common.enums; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.test.demo.common.constant.EnumSourceType; +import com.test.demo.entity.EnumItemEntity; +import com.test.demo.entity.EnumTypeEntity; +import com.test.demo.service.EnumItemService; +import com.test.demo.service.EnumTypeService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.core.type.classreading.CachingMetadataReaderFactory; +import org.springframework.core.type.classreading.MetadataReader; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 系统枚举注册中心 - 启动时扫描并同步到数据库 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class EnumRegistry implements ApplicationRunner { + + private final EnumTypeService enumTypeService; + private final EnumItemService enumItemService; + + @Override + public void run(ApplicationArguments args) throws Exception { + syncEnums("com.test.demo"); + } + + private void syncEnums(String basePackage) throws Exception { + String pattern = "classpath*:" + basePackage.replace('.', '/') + "/**/enums/**/*.class"; + PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); + CachingMetadataReaderFactory factory = new CachingMetadataReaderFactory(resolver); + Resource[] resources = resolver.getResources(pattern); + int count = 0; + + for (Resource resource : resources) { + MetadataReader reader = factory.getMetadataReader(resource); + String className = reader.getClassMetadata().getClassName(); + try { + Class clazz = Class.forName(className); + if (clazz.isEnum() && IBaseEnum.class.isAssignableFrom(clazz)) { + EnumDesc desc = clazz.getAnnotation(EnumDesc.class); + if (desc != null) { + syncEnum(clazz, desc); + count++; + } + } + } catch (ClassNotFoundException e) { + log.warn("枚举类加载失败: {}", className); + } + } + log.info("系统枚举同步完成, 共处理 {} 个带注解枚举", count); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + @Transactional(rollbackFor = Exception.class) + protected void syncEnum(Class clazz, EnumDesc desc) { + String enumCode = clazz.getSimpleName(); + EnumTypeEntity existingType = enumTypeService.getByEnumCode(enumCode); + LocalDateTime now = LocalDateTime.now(); + + if (existingType != null && EnumSourceType.CUSTOM.equals(existingType.getSourceType())) { + log.warn("检测到自定义字典与系统枚举编码冲突, 已跳过同步: {}", enumCode); + return; + } + + if (existingType == null) { + existingType = new EnumTypeEntity(); + existingType.setEnumCode(enumCode); + existingType.setSourceType(EnumSourceType.SYSTEM); + existingType.setBuiltin(1); + existingType.setEnabled(1); + existingType.setSyncEnabled(1); + existingType.setSort(0); + } else if (existingType.getSyncEnabled() != null && existingType.getSyncEnabled() == 0) { + log.info("系统枚举已关闭同步更新, 跳过: {}", enumCode); + return; + } + + existingType.setEnumName(existingType.getEnumName() == null ? enumCode : existingType.getEnumName()); + existingType.setEnumDesc(desc.value()); + existingType.setClassName(clazz.getName()); + existingType.setPackageName(clazz.getPackageName()); + Object[] constants = clazz.getEnumConstants(); + Object firstValue = constants.length == 0 ? null : ((IBaseEnum) constants[0]).getValue(); + existingType.setValueType(firstValue == null ? "java.lang.Object" : firstValue.getClass().getName()); + existingType.setLastSyncTime(now); + + if (existingType.getId() == null) { + enumTypeService.save(existingType); + } else { + enumTypeService.updateById(existingType); + } + + List systemItems = new ArrayList<>(); + int sort = 0; + for (Object constant : clazz.getEnumConstants()) { + IBaseEnum baseEnum = (IBaseEnum) constant; + EnumItemEntity item = new EnumItemEntity(); + item.setEnumCode(enumCode); + item.setItemValue(String.valueOf(baseEnum.getValue())); + item.setItemLabel(baseEnum.getDesc()); + item.setItemName(((Enum) constant).name()); + item.setSourceType(EnumSourceType.SYSTEM); + item.setBuiltin(1); + item.setEnabled(1); + item.setEditable(1); + item.setDeletable(0); + item.setSort(sort++); + item.setLastSyncTime(now); + systemItems.add(item); + } + enumItemService.syncSystemItems(enumCode, systemItems, now); + + long staleCount = enumItemService.count(new LambdaQueryWrapper() + .eq(EnumItemEntity::getEnumCode, enumCode) + .eq(EnumItemEntity::getSourceType, EnumSourceType.SYSTEM) + .eq(EnumItemEntity::getEnabled, 0)); + if (staleCount > 0) { + log.info("系统枚举 {} 存在 {} 个失效项,已自动置为停用", enumCode, staleCount); + } + } +} diff --git a/src/main/java/com/test/demo/common/enums/EnumSerializer.java b/src/main/java/com/test/demo/common/enums/EnumSerializer.java new file mode 100644 index 0000000..c4341c8 --- /dev/null +++ b/src/main/java/com/test/demo/common/enums/EnumSerializer.java @@ -0,0 +1,22 @@ +package com.test.demo.common.enums; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; + +import java.io.IOException; + +/** + * 枚举 Jackson 序列化器 - 输出 {value, desc} + */ +public class EnumSerializer extends JsonSerializer> { + + @Override + public void serialize(IBaseEnum value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + gen.writeStartObject(); + gen.writeFieldName("value"); + gen.writeObject(value.getValue()); + gen.writeStringField("desc", value.getDesc()); + gen.writeEndObject(); + } +} diff --git a/src/main/java/com/test/demo/common/enums/IBaseEnum.java b/src/main/java/com/test/demo/common/enums/IBaseEnum.java new file mode 100644 index 0000000..2369365 --- /dev/null +++ b/src/main/java/com/test/demo/common/enums/IBaseEnum.java @@ -0,0 +1,13 @@ +package com.test.demo.common.enums; + +/** + * 枚举基础接口 - 所有业务枚举需实现此接口 + */ +public interface IBaseEnum { + + /** 获取枚举值(存入数据库) */ + T getValue(); + + /** 获取枚举描述 */ + String getDesc(); +} diff --git a/src/main/java/com/test/demo/common/exception/BizException.java b/src/main/java/com/test/demo/common/exception/BizException.java new file mode 100644 index 0000000..dceaa39 --- /dev/null +++ b/src/main/java/com/test/demo/common/exception/BizException.java @@ -0,0 +1,53 @@ +package com.test.demo.common.exception; + +import com.test.demo.common.result.ResultCode; +import lombok.Getter; + +import java.io.Serial; + +/** + * 自定义业务异常 + */ +@Getter +public class BizException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 1L; + + private final int code; + + public BizException(String msg) { + super(msg); + this.code = ResultCode.INTERNAL_ERROR.getCode(); + } + + public BizException(int code, String msg) { + super(msg); + this.code = code; + } + + public BizException(ResultCode resultCode) { + super(resultCode.getMsg()); + this.code = resultCode.getCode(); + } + + public BizException(ResultCode resultCode, String msg) { + super(msg); + this.code = resultCode.getCode(); + } + + /** + * 快捷抛出异常 + */ + public static void throwEx(String msg) { + throw new BizException(msg); + } + + public static void throwEx(ResultCode resultCode) { + throw new BizException(resultCode); + } + + public static void throwEx(int code, String msg) { + throw new BizException(code, msg); + } +} diff --git a/src/main/java/com/test/demo/common/exception/GlobalExceptionHandler.java b/src/main/java/com/test/demo/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..dd293cc --- /dev/null +++ b/src/main/java/com/test/demo/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,127 @@ +package com.test.demo.common.exception; + +import com.test.demo.common.result.R; +import com.test.demo.common.result.ResultCode; +import cn.dev33.satoken.exception.NotLoginException; +import cn.dev33.satoken.exception.NotPermissionException; +import cn.dev33.satoken.exception.NotRoleException; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.http.HttpStatus; +import org.springframework.validation.FieldError; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MaxUploadSizeExceededException; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +import java.util.stream.Collectors; + +/** + * 全局异常处理器 + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BizException.class) + public ResponseEntity> handleBizException(BizException e) { + log.warn("业务异常: {}", e.getMessage()); + return ResponseEntity.status(resolveStatus(e.getCode())) + .body(R.fail(e.getCode(), e.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidException(MethodArgumentNotValidException e) { + String msg = e.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining("; ")); + log.warn("参数校验失败: {}", msg); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(R.fail(ResultCode.BAD_REQUEST, msg)); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity> handleConstraintViolation(ConstraintViolationException e) { + String msg = e.getConstraintViolations().stream() + .map(ConstraintViolation::getMessage) + .collect(Collectors.joining("; ")); + log.warn("约束校验失败: {}", msg); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(R.fail(ResultCode.BAD_REQUEST, msg)); + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity> handleMissingParam(MissingServletRequestParameterException e) { + log.warn("缺少请求参数: {}", e.getParameterName()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(R.fail(ResultCode.BAD_REQUEST, "缺少参数: " + e.getParameterName())); + } + + @ExceptionHandler(NotLoginException.class) + public ResponseEntity> handleNotLogin(NotLoginException e) { + log.warn("未登录访问: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(R.fail(ResultCode.UNAUTHORIZED)); + } + + @ExceptionHandler(NotPermissionException.class) + public ResponseEntity> handleNotPermission(NotPermissionException e) { + log.warn("无权限: {}", e.getPermission()); + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(R.fail(ResultCode.FORBIDDEN)); + } + + @ExceptionHandler(NotRoleException.class) + public ResponseEntity> handleNotRole(NotRoleException e) { + log.warn("无角色: {}", e.getRole()); + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(R.fail(ResultCode.FORBIDDEN)); + } + + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity> handleMaxUploadSize(MaxUploadSizeExceededException e) { + log.warn("上传文件过大: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(R.fail(ResultCode.BAD_REQUEST, "上传文件大小超出限制")); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity> handleMethodNotSupported(HttpRequestMethodNotSupportedException e) { + return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED) + .body(R.fail(ResultCode.METHOD_NOT_ALLOWED)); + } + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity> handleNoResource(NoResourceFoundException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(R.fail(ResultCode.NOT_FOUND)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException(Exception e) { + log.error("系统异常", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(R.fail(ResultCode.INTERNAL_ERROR)); + } + + private HttpStatus resolveStatus(int code) { + return switch (code) { + case 400 -> HttpStatus.BAD_REQUEST; + case 401 -> HttpStatus.UNAUTHORIZED; + case 403 -> HttpStatus.FORBIDDEN; + case 404 -> HttpStatus.NOT_FOUND; + case 405 -> HttpStatus.METHOD_NOT_ALLOWED; + case 409 -> HttpStatus.CONFLICT; + case 415 -> HttpStatus.UNSUPPORTED_MEDIA_TYPE; + case 423 -> HttpStatus.LOCKED; + case 429 -> HttpStatus.TOO_MANY_REQUESTS; + default -> HttpStatus.INTERNAL_SERVER_ERROR; + }; + } +} diff --git a/src/main/java/com/test/demo/common/result/R.java b/src/main/java/com/test/demo/common/result/R.java new file mode 100644 index 0000000..a8c5947 --- /dev/null +++ b/src/main/java/com/test/demo/common/result/R.java @@ -0,0 +1,63 @@ +package com.test.demo.common.result; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 统一返回结果 + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class R implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private int code; + private boolean success; + private String msg; + private T data; + private LocalDateTime timestamp; + + private R() {} + + private R(int code, String msg, T data) { + this.code = code; + this.success = code == ResultCode.SUCCESS.getCode(); + this.msg = msg; + this.data = data; + this.timestamp = LocalDateTime.now(); + } + + public static R ok() { + return new R<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMsg(), null); + } + + public static R ok(T data) { + return new R<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMsg(), data); + } + + public static R ok(T data, String msg) { + return new R<>(ResultCode.SUCCESS.getCode(), msg, data); + } + + public static R fail(String msg) { + return new R<>(ResultCode.INTERNAL_ERROR.getCode(), msg, null); + } + + public static R fail(ResultCode resultCode) { + return new R<>(resultCode.getCode(), resultCode.getMsg(), null); + } + + public static R fail(ResultCode resultCode, String msg) { + return new R<>(resultCode.getCode(), msg, null); + } + + public static R fail(int code, String msg) { + return new R<>(code, msg, null); + } +} diff --git a/src/main/java/com/test/demo/common/result/ResultCode.java b/src/main/java/com/test/demo/common/result/ResultCode.java new file mode 100644 index 0000000..3cbcdc0 --- /dev/null +++ b/src/main/java/com/test/demo/common/result/ResultCode.java @@ -0,0 +1,30 @@ +package com.test.demo.common.result; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 返回码枚举 + */ +@Getter +@AllArgsConstructor +public enum ResultCode { + + SUCCESS(200, "操作成功"), + BAD_REQUEST(400, "请求参数错误"), + UNAUTHORIZED(401, "未登录或登录已过期"), + FORBIDDEN(403, "无权限访问"), + NOT_FOUND(404, "资源不存在"), + CONFLICT(409, "资源冲突"), + METHOD_NOT_ALLOWED(405, "请求方法不允许"), + UNSUPPORTED_MEDIA_TYPE(415, "不支持的资源类型"), + ACCOUNT_LOCKED(423, "账号已锁定"), + RATE_LIMIT(429, "请求过于频繁"), + INTERNAL_ERROR(500, "系统内部错误"), + IDEMPOTENT_REJECT(601, "请勿重复提交"), + SIGNATURE_INVALID(602, "签名校验失败"), + REPLAY_ATTACK(603, "请求已过期或重复"); + + private final int code; + private final String msg; +} diff --git a/src/main/java/com/test/demo/common/validate/ValidGroup.java b/src/main/java/com/test/demo/common/validate/ValidGroup.java new file mode 100644 index 0000000..2e86123 --- /dev/null +++ b/src/main/java/com/test/demo/common/validate/ValidGroup.java @@ -0,0 +1,19 @@ +package com.test.demo.common.validate; + +/** + * 参数校验分组 + */ +public interface ValidGroup { + + /** 新增 */ + interface Add {} + + /** 修改 */ + interface Update {} + + /** 查询 */ + interface Query {} + + /** 删除 */ + interface Delete {} +} diff --git a/src/main/java/com/test/demo/config/AuthDataInitializer.java b/src/main/java/com/test/demo/config/AuthDataInitializer.java new file mode 100644 index 0000000..eec4037 --- /dev/null +++ b/src/main/java/com/test/demo/config/AuthDataInitializer.java @@ -0,0 +1,204 @@ +package com.test.demo.config; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.test.demo.common.constant.PermissionConstant; +import com.test.demo.entity.MenuEntity; +import com.test.demo.entity.RoleEntity; +import com.test.demo.entity.RoleMenuEntity; +import com.test.demo.entity.UserEntity; +import com.test.demo.entity.UserRoleEntity; +import com.test.demo.mapper.MenuMapper; +import com.test.demo.mapper.RoleMapper; +import com.test.demo.mapper.RoleMenuMapper; +import com.test.demo.mapper.UserRoleMapper; +import com.test.demo.mapper.UserMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +/** + * 权限基础数据初始化 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AuthDataInitializer implements ApplicationRunner { + + private final RoleMapper roleMapper; + private final MenuMapper menuMapper; + private final UserRoleMapper userRoleMapper; + private final RoleMenuMapper roleMenuMapper; + private final UserMapper userMapper; + + @Override + @Transactional(rollbackFor = Exception.class) + public void run(ApplicationArguments args) { + RoleEntity adminRole = ensureAdminRole(); + List menuIds = ensureMenus(); + bindRoleMenus(adminRole.getId(), menuIds); + bindAdminUser(adminRole.getId()); + } + + private RoleEntity ensureAdminRole() { + RoleEntity role = roleMapper.selectOne(new LambdaQueryWrapper() + .eq(RoleEntity::getRoleCode, "ADMIN") + .last("limit 1")); + if (role != null) { + return role; + } + role = new RoleEntity(); + role.setRoleCode("ADMIN"); + role.setRoleName("系统管理员"); + role.setEnabled(1); + role.setSort(0); + role.setRemark("系统初始化管理员角色"); + roleMapper.insert(role); + return role; + } + + private List ensureMenus() { + List ids = new ArrayList<>(); + + Long userRoot = ensureMenu(0L, "MENU", "用户管理", "/user", "system/user/index", null, "User", 1, 1, 10, "用户管理菜单"); + ids.add(userRoot); + ids.add(ensureMenu(userRoot, "BUTTON", "用户分页", null, null, PermissionConstant.USER_PAGE, null, 1, 1, 1, null)); + ids.add(ensureMenu(userRoot, "BUTTON", "用户详情", null, null, PermissionConstant.USER_DETAIL, null, 1, 1, 2, null)); + ids.add(ensureMenu(userRoot, "BUTTON", "新增用户", null, null, PermissionConstant.USER_ADD, null, 1, 1, 3, null)); + ids.add(ensureMenu(userRoot, "BUTTON", "编辑用户", null, null, PermissionConstant.USER_EDIT, null, 1, 1, 4, null)); + ids.add(ensureMenu(userRoot, "BUTTON", "删除用户", null, null, PermissionConstant.USER_REMOVE, null, 1, 1, 5, null)); + ids.add(ensureMenu(userRoot, "BUTTON", "重置密码", null, null, PermissionConstant.USER_RESET_PASSWORD, null, 1, 1, 6, null)); + ids.add(ensureMenu(userRoot, "BUTTON", "导入用户", null, null, PermissionConstant.USER_IMPORT, null, 1, 1, 7, null)); + ids.add(ensureMenu(userRoot, "BUTTON", "导出用户", null, null, PermissionConstant.USER_EXPORT, null, 1, 1, 8, null)); + + Long roleRoot = ensureMenu(0L, "MENU", "角色管理", "/role", "system/role/index", null, "Lock", 1, 1, 20, "角色管理菜单"); + ids.add(roleRoot); + ids.add(ensureMenu(roleRoot, "BUTTON", "角色分页", null, null, PermissionConstant.ROLE_PAGE, null, 1, 1, 1, null)); + ids.add(ensureMenu(roleRoot, "BUTTON", "角色列表", null, null, PermissionConstant.ROLE_LIST, null, 1, 1, 2, null)); + ids.add(ensureMenu(roleRoot, "BUTTON", "新增角色", null, null, PermissionConstant.ROLE_ADD, null, 1, 1, 3, null)); + ids.add(ensureMenu(roleRoot, "BUTTON", "编辑角色", null, null, PermissionConstant.ROLE_EDIT, null, 1, 1, 4, null)); + ids.add(ensureMenu(roleRoot, "BUTTON", "删除角色", null, null, PermissionConstant.ROLE_REMOVE, null, 1, 1, 5, null)); + ids.add(ensureMenu(roleRoot, "BUTTON", "分配菜单", null, null, PermissionConstant.ROLE_ASSIGN_MENU, null, 1, 1, 6, null)); + ids.add(ensureMenu(roleRoot, "BUTTON", "分配用户", null, null, PermissionConstant.ROLE_ASSIGN_USER, null, 1, 1, 7, null)); + + Long menuRoot = ensureMenu(0L, "MENU", "菜单管理", "/menu", "system/menu/index", null, "Menu", 1, 1, 30, "菜单权限管理"); + ids.add(menuRoot); + ids.add(ensureMenu(menuRoot, "BUTTON", "菜单列表", null, null, PermissionConstant.MENU_LIST, null, 1, 1, 1, null)); + ids.add(ensureMenu(menuRoot, "BUTTON", "新增菜单", null, null, PermissionConstant.MENU_ADD, null, 1, 1, 2, null)); + ids.add(ensureMenu(menuRoot, "BUTTON", "编辑菜单", null, null, PermissionConstant.MENU_EDIT, null, 1, 1, 3, null)); + ids.add(ensureMenu(menuRoot, "BUTTON", "删除菜单", null, null, PermissionConstant.MENU_REMOVE, null, 1, 1, 4, null)); + ids.add(ensureMenu(menuRoot, "BUTTON", "当前菜单", null, null, PermissionConstant.MENU_CURRENT, null, 1, 1, 5, null)); + + Long enumRoot = ensureMenu(0L, "MENU", "字典管理", "/enum-manage", "system/enum/index", null, "List", 1, 1, 40, "字典管理"); + ids.add(enumRoot); + ids.add(ensureMenu(enumRoot, "BUTTON", "类型分页", null, null, PermissionConstant.ENUM_TYPE_PAGE, null, 1, 1, 1, null)); + ids.add(ensureMenu(enumRoot, "BUTTON", "新增类型", null, null, PermissionConstant.ENUM_TYPE_ADD, null, 1, 1, 2, null)); + ids.add(ensureMenu(enumRoot, "BUTTON", "编辑类型", null, null, PermissionConstant.ENUM_TYPE_EDIT, null, 1, 1, 3, null)); + ids.add(ensureMenu(enumRoot, "BUTTON", "启停类型", null, null, PermissionConstant.ENUM_TYPE_ENABLE, null, 1, 1, 4, null)); + ids.add(ensureMenu(enumRoot, "BUTTON", "删除类型", null, null, PermissionConstant.ENUM_TYPE_REMOVE, null, 1, 1, 5, null)); + ids.add(ensureMenu(enumRoot, "BUTTON", "枚举项列表", null, null, PermissionConstant.ENUM_ITEM_LIST, null, 1, 1, 6, null)); + ids.add(ensureMenu(enumRoot, "BUTTON", "新增枚举项", null, null, PermissionConstant.ENUM_ITEM_ADD, null, 1, 1, 7, null)); + ids.add(ensureMenu(enumRoot, "BUTTON", "编辑枚举项", null, null, PermissionConstant.ENUM_ITEM_EDIT, null, 1, 1, 8, null)); + ids.add(ensureMenu(enumRoot, "BUTTON", "启停枚举项", null, null, PermissionConstant.ENUM_ITEM_ENABLE, null, 1, 1, 9, null)); + ids.add(ensureMenu(enumRoot, "BUTTON", "删除枚举项", null, null, PermissionConstant.ENUM_ITEM_REMOVE, null, 1, 1, 10, null)); + + Long taskRoot = ensureMenu(0L, "MENU", "定时任务", "/task", "system/task/index", null, "Clock", 1, 1, 50, "定时任务管理"); + ids.add(taskRoot); + ids.add(ensureMenu(taskRoot, "BUTTON", "任务分页", null, null, PermissionConstant.TASK_PAGE, null, 1, 1, 1, null)); + ids.add(ensureMenu(taskRoot, "BUTTON", "任务详情", null, null, PermissionConstant.TASK_DETAIL, null, 1, 1, 2, null)); + ids.add(ensureMenu(taskRoot, "BUTTON", "新增任务", null, null, PermissionConstant.TASK_ADD, null, 1, 1, 3, null)); + ids.add(ensureMenu(taskRoot, "BUTTON", "编辑任务", null, null, PermissionConstant.TASK_EDIT, null, 1, 1, 4, null)); + ids.add(ensureMenu(taskRoot, "BUTTON", "暂停任务", null, null, PermissionConstant.TASK_PAUSE, null, 1, 1, 5, null)); + ids.add(ensureMenu(taskRoot, "BUTTON", "恢复任务", null, null, PermissionConstant.TASK_RESUME, null, 1, 1, 6, null)); + ids.add(ensureMenu(taskRoot, "BUTTON", "删除任务", null, null, PermissionConstant.TASK_REMOVE, null, 1, 1, 7, null)); + + Long fileRoot = ensureMenu(0L, "MENU", "文件管理", "/file", "system/file/index", null, "Folder", 1, 1, 60, "文件管理"); + ids.add(fileRoot); + ids.add(ensureMenu(fileRoot, "BUTTON", "文件列表", null, null, PermissionConstant.FILE_LIST, null, 1, 1, 1, null)); + ids.add(ensureMenu(fileRoot, "BUTTON", "上传文件", null, null, PermissionConstant.FILE_UPLOAD, null, 1, 1, 2, null)); + ids.add(ensureMenu(fileRoot, "BUTTON", "下载文件", null, null, PermissionConstant.FILE_DOWNLOAD, null, 1, 1, 3, null)); + ids.add(ensureMenu(fileRoot, "BUTTON", "删除文件", null, null, PermissionConstant.FILE_DELETE, null, 1, 1, 4, null)); + + Long logRoot = ensureMenu(0L, "MENU", "日志管理", "/log", "system/log/index", null, "Document", 1, 1, 70, "日志管理"); + ids.add(logRoot); + ids.add(ensureMenu(logRoot, "BUTTON", "登录日志分页", null, null, PermissionConstant.LOGIN_LOG_PAGE, null, 1, 1, 1, null)); + ids.add(ensureMenu(logRoot, "BUTTON", "登录日志导出", null, null, PermissionConstant.LOGIN_LOG_EXPORT, null, 1, 1, 2, null)); + ids.add(ensureMenu(logRoot, "BUTTON", "操作日志分页", null, null, PermissionConstant.OPER_LOG_PAGE, null, 1, 1, 3, null)); + ids.add(ensureMenu(logRoot, "BUTTON", "操作日志导出", null, null, PermissionConstant.OPER_LOG_EXPORT, null, 1, 1, 4, null)); + + return ids; + } + + private Long ensureMenu(Long parentId, String menuType, String menuName, String path, String component, + String permission, String icon, Integer visible, Integer enabled, Integer sort, String remark) { + MenuEntity existing = menuMapper.selectOne(new LambdaQueryWrapper() + .eq(MenuEntity::getParentId, parentId) + .eq(MenuEntity::getMenuName, menuName) + .last("limit 1")); + if (existing != null) { + existing.setMenuType(menuType); + existing.setPath(path); + existing.setComponent(component); + existing.setPermission(permission); + existing.setIcon(icon); + existing.setVisible(visible); + existing.setEnabled(enabled); + existing.setSort(sort); + existing.setRemark(remark); + menuMapper.updateById(existing); + return existing.getId(); + } + MenuEntity entity = new MenuEntity(); + entity.setParentId(parentId); + entity.setMenuType(menuType); + entity.setMenuName(menuName); + entity.setPath(path); + entity.setComponent(component); + entity.setPermission(permission); + entity.setIcon(icon); + entity.setVisible(visible); + entity.setEnabled(enabled); + entity.setSort(sort); + entity.setRemark(remark); + menuMapper.insert(entity); + return entity.getId(); + } + + private void bindRoleMenus(Long roleId, List menuIds) { + for (Long menuId : menuIds) { + Long count = roleMenuMapper.selectCount(new LambdaQueryWrapper() + .eq(RoleMenuEntity::getRoleId, roleId) + .eq(RoleMenuEntity::getMenuId, menuId)); + if (count == 0) { + RoleMenuEntity entity = new RoleMenuEntity(); + entity.setRoleId(roleId); + entity.setMenuId(menuId); + roleMenuMapper.insert(entity); + } + } + } + + private void bindAdminUser(Long roleId) { + UserEntity admin = userMapper.selectOne(new LambdaQueryWrapper() + .eq(UserEntity::getUsername, "admin") + .last("limit 1")); + if (admin == null) { + log.warn("未找到 admin 用户,已跳过管理员角色绑定"); + return; + } + Long count = userRoleMapper.selectCount(new LambdaQueryWrapper() + .eq(UserRoleEntity::getUserId, admin.getId()) + .eq(UserRoleEntity::getRoleId, roleId)); + if (count == 0) { + UserRoleEntity entity = new UserRoleEntity(); + entity.setUserId(admin.getId()); + entity.setRoleId(roleId); + userRoleMapper.insert(entity); + } + } +} diff --git a/src/main/java/com/test/demo/config/CaffeineCacheConfig.java b/src/main/java/com/test/demo/config/CaffeineCacheConfig.java new file mode 100644 index 0000000..5092d2d --- /dev/null +++ b/src/main/java/com/test/demo/config/CaffeineCacheConfig.java @@ -0,0 +1,29 @@ +package com.test.demo.config; + +import com.github.benmanes.caffeine.cache.Caffeine; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.concurrent.TimeUnit; + +/** + * Caffeine 本地缓存配置 + */ +@EnableCaching +@Configuration +public class CaffeineCacheConfig { + + @Bean + public CacheManager caffeineCacheManager() { + CaffeineCacheManager cacheManager = new CaffeineCacheManager(); + cacheManager.setCaffeine(Caffeine.newBuilder() + .initialCapacity(128) + .maximumSize(1024) + .expireAfterWrite(30, TimeUnit.MINUTES) + .recordStats()); + return cacheManager; + } +} diff --git a/src/main/java/com/test/demo/config/FileConfig.java b/src/main/java/com/test/demo/config/FileConfig.java new file mode 100644 index 0000000..5a72161 --- /dev/null +++ b/src/main/java/com/test/demo/config/FileConfig.java @@ -0,0 +1,27 @@ +package com.test.demo.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 文件上传配置 + */ +@Data +@Component +@ConfigurationProperties(prefix = "app.file") +public class FileConfig { + + /** 上传根路径 */ + private String uploadPath = "/data/upload"; + + /** 允许的文件扩展名 */ + private List allowedExtensions = List.of( + "jpg", "jpeg", "png", "gif", "bmp", "webp", + "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", + "txt", "csv", "zip", "rar", "7z", + "mp4", "avi", "mov", "mp3", "wav" + ); +} diff --git a/src/main/java/com/test/demo/config/JacksonConfig.java b/src/main/java/com/test/demo/config/JacksonConfig.java new file mode 100644 index 0000000..e23993e --- /dev/null +++ b/src/main/java/com/test/demo/config/JacksonConfig.java @@ -0,0 +1,46 @@ +package com.test.demo.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * Jackson 全局配置 + */ +@Configuration +public class JacksonConfig { + + private static final String DATE_FORMAT = "yyyy-MM-dd"; + private static final String DATETIME_FORMAT = "yyyy-MM-dd HH:mm:ss"; + + @Bean + public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { + return builder -> { + // Long → String 防前端精度丢失 + builder.serializerByType(Long.class, ToStringSerializer.instance); + builder.serializerByType(Long.TYPE, ToStringSerializer.instance); + + // LocalDate 序列化/反序列化 + builder.serializerByType(LocalDate.class, + new LocalDateSerializer(DateTimeFormatter.ofPattern(DATE_FORMAT))); + builder.deserializerByType(LocalDate.class, + new LocalDateDeserializer(DateTimeFormatter.ofPattern(DATE_FORMAT))); + + // LocalDateTime 序列化/反序列化 + builder.serializerByType(LocalDateTime.class, + new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DATETIME_FORMAT))); + builder.deserializerByType(LocalDateTime.class, + new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DATETIME_FORMAT))); + }; + } +} diff --git a/src/main/java/com/test/demo/config/Knife4jConfig.java b/src/main/java/com/test/demo/config/Knife4jConfig.java new file mode 100644 index 0000000..53f3524 --- /dev/null +++ b/src/main/java/com/test/demo/config/Knife4jConfig.java @@ -0,0 +1,24 @@ +package com.test.demo.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Knife4j / OpenAPI 配置 + */ +@Configuration +public class Knife4jConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(new Info() + .title("Demo 后台管理系统 API") + .description("Spring Boot 4 脚手架接口文档") + .version("1.0.0") + .contact(new Contact().name("admin").email("admin@example.com"))); + } +} diff --git a/src/main/java/com/test/demo/config/MybatisPlusConfig.java b/src/main/java/com/test/demo/config/MybatisPlusConfig.java new file mode 100644 index 0000000..cff3fe0 --- /dev/null +++ b/src/main/java/com/test/demo/config/MybatisPlusConfig.java @@ -0,0 +1,27 @@ +package com.test.demo.config; + +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.BlockAttackInnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * MyBatis-Plus 配置 + */ +@Configuration +public class MybatisPlusConfig { + + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + // 分页插件 + PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL); + paginationInterceptor.setMaxLimit(500L); + interceptor.addInnerInterceptor(paginationInterceptor); + // 防全表更新与删除 + interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor()); + return interceptor; + } +} diff --git a/src/main/java/com/test/demo/config/RedisConfig.java b/src/main/java/com/test/demo/config/RedisConfig.java new file mode 100644 index 0000000..0c60e27 --- /dev/null +++ b/src/main/java/com/test/demo/config/RedisConfig.java @@ -0,0 +1,31 @@ +package com.test.demo.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +/** + * Redis 序列化配置 + */ +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory factory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(factory); + + StringRedisSerializer stringSerializer = new StringRedisSerializer(); + GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(); + + template.setKeySerializer(stringSerializer); + template.setHashKeySerializer(stringSerializer); + template.setValueSerializer(jsonSerializer); + template.setHashValueSerializer(jsonSerializer); + template.afterPropertiesSet(); + return template; + } +} diff --git a/src/main/java/com/test/demo/config/SaTokenConfig.java b/src/main/java/com/test/demo/config/SaTokenConfig.java new file mode 100644 index 0000000..ec1601f --- /dev/null +++ b/src/main/java/com/test/demo/config/SaTokenConfig.java @@ -0,0 +1,13 @@ +package com.test.demo.config; + +import org.springframework.context.annotation.Configuration; + +/** + * Sa-Token 配置 + */ +@Configuration +public class SaTokenConfig { + + // 如需 JWT 模式,需引入 sa-token-jwt 依赖后启用以下配置 + // 默认使用 Redis Session 模式 +} diff --git a/src/main/java/com/test/demo/config/ScheduleConfig.java b/src/main/java/com/test/demo/config/ScheduleConfig.java new file mode 100644 index 0000000..755c3fd --- /dev/null +++ b/src/main/java/com/test/demo/config/ScheduleConfig.java @@ -0,0 +1,25 @@ +package com.test.demo.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +/** + * 定时任务调度配置 + */ +@EnableScheduling +@Configuration +public class ScheduleConfig { + + @Bean + public TaskScheduler taskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(5); + scheduler.setThreadNamePrefix("schedule-"); + scheduler.setWaitForTasksToCompleteOnShutdown(true); + scheduler.setAwaitTerminationSeconds(30); + return scheduler; + } +} diff --git a/src/main/java/com/test/demo/config/ThreadPoolConfig.java b/src/main/java/com/test/demo/config/ThreadPoolConfig.java new file mode 100644 index 0000000..eaa55e3 --- /dev/null +++ b/src/main/java/com/test/demo/config/ThreadPoolConfig.java @@ -0,0 +1,32 @@ +package com.test.demo.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; + +/** + * 线程池配置 + */ +@EnableAsync +@Configuration +public class ThreadPoolConfig { + + @Bean("asyncExecutor") + public Executor asyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(Runtime.getRuntime().availableProcessors()); + executor.setMaxPoolSize(Runtime.getRuntime().availableProcessors() * 2); + executor.setQueueCapacity(500); + executor.setKeepAliveSeconds(60); + executor.setThreadNamePrefix("async-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(30); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/com/test/demo/config/WebMvcConfig.java b/src/main/java/com/test/demo/config/WebMvcConfig.java new file mode 100644 index 0000000..5104fbc --- /dev/null +++ b/src/main/java/com/test/demo/config/WebMvcConfig.java @@ -0,0 +1,65 @@ +package com.test.demo.config; + +import cn.dev33.satoken.interceptor.SaInterceptor; +import cn.dev33.satoken.stp.StpUtil; +import com.test.demo.interceptor.IdempotentInterceptor; +import com.test.demo.interceptor.SignatureInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Web MVC 配置(跨域 + 拦截器) + */ +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + + private final SignatureInterceptor signatureInterceptor; + + private final IdempotentInterceptor idempotentInterceptor; + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOriginPatterns("*") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(signatureInterceptor) + .addPathPatterns("/**") + .excludePathPatterns( + "/doc.html", + "/webjars/**", + "/v3/api-docs/**", + "/swagger-resources/**", + "/actuator/**" + ); + + registry.addInterceptor(idempotentInterceptor) + .addPathPatterns("/**"); + + // Sa-Token 路由拦截 + registry.addInterceptor(new SaInterceptor(handle -> StpUtil.checkLogin())) + .addPathPatterns("/**") + .excludePathPatterns( + "/auth/login", + "/auth/register", + "/captcha/**", + "/file/preview/**", + "/enums/**", + "/doc.html", + "/webjars/**", + "/v3/api-docs/**", + "/swagger-resources/**", + "/actuator/**" + ); + } +} diff --git a/src/main/java/com/test/demo/config/XssFilterConfig.java b/src/main/java/com/test/demo/config/XssFilterConfig.java new file mode 100644 index 0000000..2ed4ed5 --- /dev/null +++ b/src/main/java/com/test/demo/config/XssFilterConfig.java @@ -0,0 +1,30 @@ +package com.test.demo.config; + +import com.test.demo.filter.XssFilter; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.HashMap; +import java.util.Map; + +/** + * XSS 过滤器注册 + */ +@Configuration +public class XssFilterConfig { + + @Bean + public FilterRegistrationBean xssFilterRegistration() { + FilterRegistrationBean registration = new FilterRegistrationBean<>(); + registration.setFilter(new XssFilter()); + registration.addUrlPatterns("/*"); + registration.setName("xssFilter"); + registration.setOrder(1); + // 排除路径(如富文本接口) + Map initParams = new HashMap<>(); + initParams.put("excludes", "/file/upload"); + registration.setInitParameters(initParams); + return registration; + } +} diff --git a/src/main/java/com/test/demo/controller/AuthController.java b/src/main/java/com/test/demo/controller/AuthController.java new file mode 100644 index 0000000..b39a6b2 --- /dev/null +++ b/src/main/java/com/test/demo/controller/AuthController.java @@ -0,0 +1,51 @@ +package com.test.demo.controller; + +import com.test.demo.bo.LoginBo; +import com.test.demo.bo.RegisterBo; +import com.test.demo.common.result.R; +import com.test.demo.service.AuthService; +import com.test.demo.vo.LoginVo; +import com.test.demo.vo.UserVo; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +/** + * 认证接口 + */ +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +@Tag(name = "认证管理") +public class AuthController { + + private final AuthService authService; + + @PostMapping("/login") + @Operation(summary = "用户登录") + public R login(@RequestBody @Valid LoginBo bo) { + return R.ok(authService.login(bo)); + } + + @PostMapping("/register") + @Operation(summary = "用户注册") + public R register(@RequestBody @Valid RegisterBo bo) { + authService.register(bo); + return R.ok(); + } + + @PostMapping("/logout") + @Operation(summary = "退出登录") + public R logout() { + authService.logout(); + return R.ok(); + } + + @PostMapping("/me") + @Operation(summary = "获取当前登录用户") + public R me() { + return R.ok(authService.getCurrentUser()); + } +} diff --git a/src/main/java/com/test/demo/controller/CaptchaController.java b/src/main/java/com/test/demo/controller/CaptchaController.java new file mode 100644 index 0000000..13a201e --- /dev/null +++ b/src/main/java/com/test/demo/controller/CaptchaController.java @@ -0,0 +1,30 @@ +package com.test.demo.controller; + +import com.test.demo.common.result.R; +import com.test.demo.service.CaptchaService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * 验证码接口 + */ +@RestController +@RequestMapping("/captcha") +@RequiredArgsConstructor +@Tag(name = "验证码") +public class CaptchaController { + + private final CaptchaService captchaService; + + @PostMapping("/generate") + @Operation(summary = "获取图形验证码") + public R> getCaptcha() { + return R.ok(captchaService.generate()); + } +} diff --git a/src/main/java/com/test/demo/controller/EnumController.java b/src/main/java/com/test/demo/controller/EnumController.java new file mode 100644 index 0000000..316a683 --- /dev/null +++ b/src/main/java/com/test/demo/controller/EnumController.java @@ -0,0 +1,43 @@ +package com.test.demo.controller; + +import com.test.demo.common.result.R; +import com.test.demo.service.EnumItemService; +import com.test.demo.service.EnumTypeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +/** + * 枚举查询接口(供前端下拉框使用) + */ +@RestController +@RequestMapping("/enums") +@RequiredArgsConstructor +@Tag(name = "枚举查询") +public class EnumController { + + private final EnumTypeService enumTypeService; + private final EnumItemService enumItemService; + + @PostMapping("/list") + @Operation(summary = "获取所有带描述注解的枚举元信息") + public R>> listAll() { + return R.ok(enumTypeService.listEnabledForApi()); + } + + @PostMapping("/items/{enumName}") + @Operation(summary = "获取指定枚举的所有值") + public R>> getByName( + @Parameter(description = "枚举编码", example = "LogType") + @PathVariable String enumName) { + return R.ok(enumItemService.listEnabledForApi(enumName)); + } +} diff --git a/src/main/java/com/test/demo/controller/EnumManageController.java b/src/main/java/com/test/demo/controller/EnumManageController.java new file mode 100644 index 0000000..99c8289 --- /dev/null +++ b/src/main/java/com/test/demo/controller/EnumManageController.java @@ -0,0 +1,149 @@ +package com.test.demo.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import com.test.demo.bo.EnumItemBo; +import com.test.demo.bo.EnumTypeBo; +import com.test.demo.bo.EnumTypeQueryBo; +import com.test.demo.common.annotation.Log; +import com.test.demo.common.annotation.Idempotent; +import com.test.demo.common.base.PageResult; +import com.test.demo.common.constant.PermissionConstant; +import com.test.demo.common.result.R; +import com.test.demo.common.validate.ValidGroup; +import com.test.demo.entity.EnumItemEntity; +import com.test.demo.entity.EnumTypeEntity; +import com.test.demo.enums.LogType; +import com.test.demo.service.EnumItemService; +import com.test.demo.service.EnumTypeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 枚举/字典管理接口 + */ +@RestController +@RequestMapping("/enum-manage") +@RequiredArgsConstructor +@Tag(name = "枚举字典管理") +public class EnumManageController { + + private final EnumTypeService enumTypeService; + private final EnumItemService enumItemService; + + @PostMapping("/type/page") + @Operation(summary = "分页查询枚举类型") + @SaCheckPermission(PermissionConstant.ENUM_TYPE_PAGE) + public R> pageTypes(@RequestBody(required = false) EnumTypeQueryBo query) { + if (query == null) { + query = new EnumTypeQueryBo(); + } + return R.ok(enumTypeService.page(query)); + } + + @PostMapping("/type/add") + @Operation(summary = "新增自定义枚举类型") + @SaCheckPermission(PermissionConstant.ENUM_TYPE_ADD) + @Idempotent(message = "请勿重复新增枚举类型") + @Log(value = "新增自定义枚举类型", type = LogType.OPER, saveToDB = true) + public R addType(@RequestBody @Validated(ValidGroup.Add.class) EnumTypeBo bo) { + enumTypeService.add(bo); + return R.ok(); + } + + @PostMapping("/type/edit") + @Operation(summary = "编辑枚举类型") + @SaCheckPermission(PermissionConstant.ENUM_TYPE_EDIT) + @Idempotent(message = "请勿重复编辑枚举类型") + @Log(value = "编辑枚举类型", type = LogType.OPER, saveToDB = true) + public R editType(@RequestBody @Validated(ValidGroup.Update.class) EnumTypeBo bo) { + enumTypeService.edit(bo); + return R.ok(); + } + + @PostMapping("/type/enable/{id}") + @Operation(summary = "启用或停用枚举类型") + @SaCheckPermission(PermissionConstant.ENUM_TYPE_ENABLE) + @Idempotent(message = "请勿重复变更枚举类型状态") + @Log(value = "变更枚举类型状态", type = LogType.OPER, saveToDB = true) + public R changeTypeEnabled( + @Parameter(description = "类型ID", example = "2033103240660238338") + @PathVariable Long id, + @Parameter(description = "启用状态,1启用,0停用", example = "1") + @RequestParam Integer enabled) { + enumTypeService.changeEnabled(id, enabled); + return R.ok(); + } + + @PostMapping("/type/remove/{id}") + @Operation(summary = "删除自定义枚举类型") + @SaCheckPermission(PermissionConstant.ENUM_TYPE_REMOVE) + @Idempotent(message = "请勿重复删除枚举类型") + @Log(value = "删除自定义枚举类型", type = LogType.OPER, saveToDB = true) + public R removeType( + @Parameter(description = "类型ID", example = "2033103240660238338") + @PathVariable Long id) { + enumTypeService.remove(id); + return R.ok(); + } + + @PostMapping("/item/list/{enumCode}") + @Operation(summary = "查询枚举项列表") + @SaCheckPermission(PermissionConstant.ENUM_ITEM_LIST) + public R> listItems( + @Parameter(description = "枚举编码", example = "LogType") + @PathVariable String enumCode) { + return R.ok(enumItemService.listManageItems(enumCode)); + } + + @PostMapping("/item/add") + @Operation(summary = "新增枚举项") + @SaCheckPermission(PermissionConstant.ENUM_ITEM_ADD) + @Idempotent(message = "请勿重复新增枚举项") + @Log(value = "新增枚举项", type = LogType.OPER, saveToDB = true) + public R addItem(@RequestBody @Validated(ValidGroup.Add.class) EnumItemBo bo) { + enumItemService.add(bo); + return R.ok(); + } + + @PostMapping("/item/edit") + @Operation(summary = "编辑枚举项") + @SaCheckPermission(PermissionConstant.ENUM_ITEM_EDIT) + @Idempotent(message = "请勿重复编辑枚举项") + @Log(value = "编辑枚举项", type = LogType.OPER, saveToDB = true) + public R editItem(@RequestBody @Validated(ValidGroup.Update.class) EnumItemBo bo) { + enumItemService.edit(bo); + return R.ok(); + } + + @PostMapping("/item/enable/{id}") + @Operation(summary = "启用或停用枚举项") + @SaCheckPermission(PermissionConstant.ENUM_ITEM_ENABLE) + @Idempotent(message = "请勿重复变更枚举项状态") + @Log(value = "变更枚举项状态", type = LogType.OPER, saveToDB = true) + public R changeItemEnabled( + @Parameter(description = "枚举项ID", example = "2033103240752513025") + @PathVariable Long id, + @Parameter(description = "启用状态,1启用,0停用", example = "1") + @RequestParam Integer enabled) { + enumItemService.changeEnabled(id, enabled); + return R.ok(); + } + + @PostMapping("/item/remove/{id}") + @Operation(summary = "删除枚举项") + @SaCheckPermission(PermissionConstant.ENUM_ITEM_REMOVE) + @Idempotent(message = "请勿重复删除枚举项") + @Log(value = "删除枚举项", type = LogType.OPER, saveToDB = true) + public R removeItem( + @Parameter(description = "枚举项ID", example = "2033103240752513025") + @PathVariable Long id) { + enumItemService.remove(id); + return R.ok(); + } +} diff --git a/src/main/java/com/test/demo/controller/FileController.java b/src/main/java/com/test/demo/controller/FileController.java new file mode 100644 index 0000000..3f72a1a --- /dev/null +++ b/src/main/java/com/test/demo/controller/FileController.java @@ -0,0 +1,122 @@ +package com.test.demo.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import com.test.demo.bo.FileQueryBo; +import com.test.demo.common.annotation.Log; +import com.test.demo.common.base.PageResult; +import com.test.demo.common.constant.PermissionConstant; +import com.test.demo.common.result.R; +import com.test.demo.enums.LogType; +import com.test.demo.service.FileService; +import com.test.demo.vo.FileInfoVo; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.*; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * 文件管理接口 + */ +@Slf4j +@RestController +@RequestMapping("/file") +@RequiredArgsConstructor +@Tag(name = "文件管理") +public class FileController { + + private final FileService fileService; + + @PostMapping("/list") + @Operation(summary = "查询文件列表") + @SaCheckPermission(PermissionConstant.FILE_LIST) + public R> list(@RequestBody(required = false) FileQueryBo query) { + return R.ok(fileService.list(query)); + } + + @PostMapping("/upload") + @Operation(summary = "上传文件") + @SaCheckPermission(PermissionConstant.FILE_UPLOAD) + @Log(value = "上传文件", type = LogType.OPER) + public R upload( + @Parameter(description = "上传文件") + @RequestParam("file") MultipartFile file) { + return R.ok(fileService.upload(file)); + } + + @GetMapping("/download/{*filePath}") + @Operation(summary = "下载文件") + @SaCheckPermission(PermissionConstant.FILE_DOWNLOAD) + public void download( + @Parameter(description = "文件相对路径", example = "2026/03/15/demo.xlsx") + @PathVariable String filePath, + HttpServletResponse response) throws IOException { + String absPath = fileService.getFilePath(filePath); + Path path = Paths.get(absPath); + if (!Files.exists(path)) { + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return; + } + String fileName = path.getFileName().toString(); + response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE); + response.setHeader(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=" + URLEncoder.encode(fileName, StandardCharsets.UTF_8)); + response.setContentLengthLong(Files.size(path)); + try (InputStream is = Files.newInputStream(path); + OutputStream os = response.getOutputStream()) { + is.transferTo(os); + } + } + + @GetMapping("/preview/{*filePath}") + @Operation(summary = "预览文件") + public void preview( + @Parameter(description = "文件相对路径", example = "2026/03/15/demo.xlsx") + @PathVariable String filePath, + HttpServletResponse response) throws IOException { + String absPath = fileService.getFilePath(filePath); + Path path = Paths.get(absPath); + if (!Files.exists(path)) { + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return; + } + String contentType = Files.probeContentType(path); + if (contentType == null) { + contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE; + } + response.setContentType(contentType); + response.setContentLengthLong(Files.size(path)); + + // 视频支持 Range 请求 + if (contentType.startsWith("video/")) { + response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes"); + } + + try (InputStream is = Files.newInputStream(path); + OutputStream os = response.getOutputStream()) { + is.transferTo(os); + } + } + + @PostMapping("/delete/{*filePath}") + @Operation(summary = "删除文件") + @SaCheckPermission(PermissionConstant.FILE_DELETE) + @Log(value = "删除文件", type = LogType.OPER, saveToDB = true) + public R delete( + @Parameter(description = "文件相对路径", example = "2026/03/15/demo.xlsx") + @PathVariable String filePath) { + return R.ok(fileService.delete(filePath)); + } +} diff --git a/src/main/java/com/test/demo/controller/LogController.java b/src/main/java/com/test/demo/controller/LogController.java new file mode 100644 index 0000000..720ee1d --- /dev/null +++ b/src/main/java/com/test/demo/controller/LogController.java @@ -0,0 +1,79 @@ +package com.test.demo.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import com.test.demo.bo.LoginLogQueryBo; +import com.test.demo.bo.OperLogQueryBo; +import com.test.demo.common.base.PageResult; +import com.test.demo.common.constant.PermissionConstant; +import com.test.demo.common.result.R; +import com.test.demo.entity.LoginLogEntity; +import com.test.demo.entity.OperLogEntity; +import com.test.demo.service.LoginLogService; +import com.test.demo.service.OperLogService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.io.IOException; + +/** + * 日志管理接口 + */ +@RestController +@RequestMapping("/log") +@RequiredArgsConstructor +@Tag(name = "日志管理") +public class LogController { + + private final LoginLogService loginLogService; + private final OperLogService operLogService; + + @PostMapping("/login/page") + @Operation(summary = "分页查询登录日志") + @SaCheckPermission(PermissionConstant.LOGIN_LOG_PAGE) + public R> loginPage(@RequestBody(required = false) LoginLogQueryBo query) { + if (query == null) { + query = new LoginLogQueryBo(); + } + return R.ok(loginLogService.page(query)); + } + + @PostMapping("/oper/page") + @Operation(summary = "分页查询操作日志") + @SaCheckPermission(PermissionConstant.OPER_LOG_PAGE) + public R> operPage(@RequestBody(required = false) OperLogQueryBo query) { + if (query == null) { + query = new OperLogQueryBo(); + } + return R.ok(operLogService.page(query)); + } + + @PostMapping("/login/export") + @Operation(summary = "导出登录日志") + @SaCheckPermission(PermissionConstant.LOGIN_LOG_EXPORT) + public void exportLogin( + @Parameter(description = "登录日志筛选条件") + @RequestBody(required = false) LoginLogQueryBo query, + HttpServletResponse response) throws IOException { + if (query == null) { + query = new LoginLogQueryBo(); + } + loginLogService.export(query, response); + } + + @PostMapping("/oper/export") + @Operation(summary = "导出操作日志") + @SaCheckPermission(PermissionConstant.OPER_LOG_EXPORT) + public void exportOper( + @Parameter(description = "操作日志筛选条件") + @RequestBody(required = false) OperLogQueryBo query, + HttpServletResponse response) throws IOException { + if (query == null) { + query = new OperLogQueryBo(); + } + operLogService.export(query, response); + } +} diff --git a/src/main/java/com/test/demo/controller/MenuController.java b/src/main/java/com/test/demo/controller/MenuController.java new file mode 100644 index 0000000..2f9eaf6 --- /dev/null +++ b/src/main/java/com/test/demo/controller/MenuController.java @@ -0,0 +1,81 @@ +package com.test.demo.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import com.test.demo.bo.MenuBo; +import com.test.demo.common.annotation.Log; +import com.test.demo.common.annotation.Idempotent; +import com.test.demo.common.constant.PermissionConstant; +import com.test.demo.common.result.R; +import com.test.demo.common.validate.ValidGroup; +import com.test.demo.entity.MenuEntity; +import com.test.demo.enums.LogType; +import com.test.demo.security.SecurityUtil; +import com.test.demo.service.MenuService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 菜单权限管理接口 + */ +@RestController +@RequestMapping("/menu") +@RequiredArgsConstructor +@Tag(name = "菜单权限管理") +public class MenuController { + + private final MenuService menuService; + + @PostMapping("/list") + @Operation(summary = "查询菜单权限列表") + @SaCheckPermission(PermissionConstant.MENU_LIST) + public R> list() { + return R.ok(menuService.listAll()); + } + + @PostMapping("/current") + @Operation(summary = "查询当前用户菜单") + public R> current() { + return R.ok(menuService.listCurrentUserMenus(SecurityUtil.getUserId())); + } + + @PostMapping("/permissions/current") + @Operation(summary = "查询当前用户权限标识") + public R> currentPermissions() { + return R.ok(menuService.listCurrentUserPermissions(SecurityUtil.getUserId())); + } + + @PostMapping("/add") + @Operation(summary = "新增菜单权限") + @SaCheckPermission(PermissionConstant.MENU_ADD) + @Idempotent(message = "请勿重复新增菜单") + @Log(value = "新增菜单权限", type = LogType.OPER, saveToDB = true) + public R add(@RequestBody @Validated(ValidGroup.Add.class) MenuBo bo) { + menuService.add(bo); + return R.ok(); + } + + @PostMapping("/edit") + @Operation(summary = "编辑菜单权限") + @SaCheckPermission(PermissionConstant.MENU_EDIT) + @Idempotent(message = "请勿重复编辑菜单") + @Log(value = "编辑菜单权限", type = LogType.OPER, saveToDB = true) + public R edit(@RequestBody @Validated(ValidGroup.Update.class) MenuBo bo) { + menuService.edit(bo); + return R.ok(); + } + + @PostMapping("/remove/{id}") + @Operation(summary = "删除菜单权限") + @SaCheckPermission(PermissionConstant.MENU_REMOVE) + @Idempotent(message = "请勿重复删除菜单") + @Log(value = "删除菜单权限", type = LogType.OPER, saveToDB = true) + public R remove(@PathVariable Long id) { + menuService.remove(id); + return R.ok(); + } +} diff --git a/src/main/java/com/test/demo/controller/RoleController.java b/src/main/java/com/test/demo/controller/RoleController.java new file mode 100644 index 0000000..4aba227 --- /dev/null +++ b/src/main/java/com/test/demo/controller/RoleController.java @@ -0,0 +1,117 @@ +package com.test.demo.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import com.test.demo.bo.RoleBo; +import com.test.demo.bo.RoleMenuAssignBo; +import com.test.demo.bo.RoleQueryBo; +import com.test.demo.bo.UserRoleAssignBo; +import com.test.demo.common.annotation.Log; +import com.test.demo.common.annotation.Idempotent; +import com.test.demo.common.base.PageResult; +import com.test.demo.common.constant.PermissionConstant; +import com.test.demo.common.result.R; +import com.test.demo.common.validate.ValidGroup; +import com.test.demo.entity.RoleEntity; +import com.test.demo.enums.LogType; +import com.test.demo.service.RoleService; +import com.test.demo.vo.RoleDetailVo; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 角色管理接口 + */ +@RestController +@RequestMapping("/role") +@RequiredArgsConstructor +@Tag(name = "角色管理") +public class RoleController { + + private final RoleService roleService; + + @PostMapping("/page") + @Operation(summary = "分页查询角色") + @SaCheckPermission(PermissionConstant.ROLE_PAGE) + public R> page(@RequestBody(required = false) RoleQueryBo query) { + if (query == null) { + query = new RoleQueryBo(); + } + return R.ok(roleService.page(query)); + } + + @PostMapping("/list") + @Operation(summary = "查询启用角色列表") + @SaCheckPermission(PermissionConstant.ROLE_LIST) + public R> list() { + return R.ok(roleService.listEnabled()); + } + + @PostMapping("/detail/{id}") + @Operation(summary = "查询角色详情及已分配菜单") + @SaCheckPermission(PermissionConstant.ROLE_LIST) + public R detail(@PathVariable Long id) { + return R.ok(roleService.getDetail(id)); + } + + @PostMapping("/user/{userId}/role-ids") + @Operation(summary = "查询用户已分配角色ID列表") + @SaCheckPermission(PermissionConstant.ROLE_ASSIGN_USER) + public R> userRoleIds(@PathVariable Long userId) { + return R.ok(roleService.listUserRoleIds(userId)); + } + + @PostMapping("/add") + @Operation(summary = "新增角色") + @SaCheckPermission(PermissionConstant.ROLE_ADD) + @Idempotent(message = "请勿重复新增角色") + @Log(value = "新增角色", type = LogType.OPER, saveToDB = true) + public R add(@RequestBody @Validated(ValidGroup.Add.class) RoleBo bo) { + roleService.add(bo); + return R.ok(); + } + + @PostMapping("/edit") + @Operation(summary = "编辑角色") + @SaCheckPermission(PermissionConstant.ROLE_EDIT) + @Idempotent(message = "请勿重复编辑角色") + @Log(value = "编辑角色", type = LogType.OPER, saveToDB = true) + public R edit(@RequestBody @Validated(ValidGroup.Update.class) RoleBo bo) { + roleService.edit(bo); + return R.ok(); + } + + @PostMapping("/remove/{id}") + @Operation(summary = "删除角色") + @SaCheckPermission(PermissionConstant.ROLE_REMOVE) + @Idempotent(message = "请勿重复删除角色") + @Log(value = "删除角色", type = LogType.OPER, saveToDB = true) + public R remove(@PathVariable Long id) { + roleService.remove(id); + return R.ok(); + } + + @PostMapping("/assign-menu") + @Operation(summary = "分配角色菜单权限") + @SaCheckPermission(PermissionConstant.ROLE_ASSIGN_MENU) + @Idempotent(message = "请勿重复分配角色菜单") + @Log(value = "分配角色菜单权限", type = LogType.OPER, saveToDB = true) + public R assignMenu(@RequestBody @Validated RoleMenuAssignBo bo) { + roleService.assignMenus(bo.getRoleId(), bo.getMenuIds()); + return R.ok(); + } + + @PostMapping("/assign-user") + @Operation(summary = "分配用户角色") + @SaCheckPermission(PermissionConstant.ROLE_ASSIGN_USER) + @Idempotent(message = "请勿重复分配用户角色") + @Log(value = "分配用户角色", type = LogType.OPER, saveToDB = true) + public R assignUser(@RequestBody @Validated UserRoleAssignBo bo) { + roleService.assignUserRoles(bo.getUserId(), bo.getRoleIds()); + return R.ok(); + } +} diff --git a/src/main/java/com/test/demo/controller/TaskController.java b/src/main/java/com/test/demo/controller/TaskController.java new file mode 100644 index 0000000..2184590 --- /dev/null +++ b/src/main/java/com/test/demo/controller/TaskController.java @@ -0,0 +1,95 @@ +package com.test.demo.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import com.test.demo.bo.TaskQueryBo; +import com.test.demo.common.annotation.Log; +import com.test.demo.common.annotation.Idempotent; +import com.test.demo.common.base.PageResult; +import com.test.demo.common.constant.PermissionConstant; +import com.test.demo.common.result.R; +import com.test.demo.entity.ScheduledTaskEntity; +import com.test.demo.enums.LogType; +import com.test.demo.service.DynamicTaskService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +/** + * 定时任务管理接口 + */ +@RestController +@RequestMapping("/task") +@RequiredArgsConstructor +@Tag(name = "定时任务管理") +public class TaskController { + + private final DynamicTaskService dynamicTaskService; + + @PostMapping("/page") + @Operation(summary = "分页查询定时任务") + @SaCheckPermission(PermissionConstant.TASK_PAGE) + public R> page(@RequestBody(required = false) TaskQueryBo query) { + if (query == null) { + query = new TaskQueryBo(); + } + return R.ok(dynamicTaskService.page(query)); + } + + @PostMapping("/detail/{id}") + @Operation(summary = "获取定时任务详情") + @SaCheckPermission(PermissionConstant.TASK_DETAIL) + public R detail(@PathVariable Long id) { + return R.ok(dynamicTaskService.detail(id)); + } + + @PostMapping("/add") + @Operation(summary = "创建定时任务") + @SaCheckPermission(PermissionConstant.TASK_ADD) + @Idempotent(message = "请勿重复创建定时任务") + @Log(value = "创建定时任务", type = LogType.OPER, saveToDB = true) + public R create(@RequestBody ScheduledTaskEntity task) { + dynamicTaskService.create(task); + return R.ok(); + } + + @PostMapping("/edit") + @Operation(summary = "更新定时任务") + @SaCheckPermission(PermissionConstant.TASK_EDIT) + @Idempotent(message = "请勿重复更新定时任务") + @Log(value = "更新定时任务", type = LogType.OPER, saveToDB = true) + public R update(@RequestBody ScheduledTaskEntity task) { + dynamicTaskService.update(task); + return R.ok(); + } + + @PostMapping("/pause/{id}") + @Operation(summary = "暂停定时任务") + @SaCheckPermission(PermissionConstant.TASK_PAUSE) + @Idempotent(message = "请勿重复暂停定时任务") + @Log(value = "暂停定时任务", type = LogType.OPER, saveToDB = true) + public R pause(@PathVariable Long id) { + dynamicTaskService.pause(id); + return R.ok(); + } + + @PostMapping("/resume/{id}") + @Operation(summary = "恢复定时任务") + @SaCheckPermission(PermissionConstant.TASK_RESUME) + @Idempotent(message = "请勿重复恢复定时任务") + @Log(value = "恢复定时任务", type = LogType.OPER, saveToDB = true) + public R resume(@PathVariable Long id) { + dynamicTaskService.resume(id); + return R.ok(); + } + + @PostMapping("/remove/{id}") + @Operation(summary = "删除定时任务") + @SaCheckPermission(PermissionConstant.TASK_REMOVE) + @Idempotent(message = "请勿重复删除定时任务") + @Log(value = "删除定时任务", type = LogType.OPER, saveToDB = true) + public R delete(@PathVariable Long id) { + dynamicTaskService.delete(id); + return R.ok(); + } +} diff --git a/src/main/java/com/test/demo/controller/UserController.java b/src/main/java/com/test/demo/controller/UserController.java new file mode 100644 index 0000000..b5b799b --- /dev/null +++ b/src/main/java/com/test/demo/controller/UserController.java @@ -0,0 +1,125 @@ +package com.test.demo.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import com.test.demo.common.annotation.Log; +import com.test.demo.common.annotation.RateLimit; +import com.test.demo.common.annotation.Idempotent; +import com.test.demo.common.base.PageResult; +import com.test.demo.common.constant.PermissionConstant; +import com.test.demo.common.result.R; +import com.test.demo.common.validate.ValidGroup; +import com.test.demo.enums.LogType; +import com.test.demo.bo.UserBo; +import com.test.demo.bo.UserQueryBo; +import com.test.demo.service.UserService; +import jakarta.servlet.http.HttpServletResponse; +import com.test.demo.vo.UserVo; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +/** + * 用户管理接口 + */ +@RestController +@RequestMapping("/user") +@RequiredArgsConstructor +@Tag(name = "用户管理") +public class UserController { + + private final UserService userService; + + @PostMapping("/page") + @Operation(summary = "分页查询用户") + @SaCheckPermission(PermissionConstant.USER_PAGE) + @Log(value = "分页查询用户", type = LogType.OPER) + @RateLimit(count = 20, time = 60) + public R> page(@RequestBody(required = false) UserQueryBo query) { + if (query == null) { + query = new UserQueryBo(); + } + return R.ok(userService.page(query)); + } + + @PostMapping("/detail/{id}") + @Operation(summary = "获取用户详情") + @SaCheckPermission(PermissionConstant.USER_DETAIL) + public R detail( + @Parameter(description = "用户ID", example = "1") + @PathVariable Long id) { + return R.ok(userService.getDetail(id)); + } + + @PostMapping("/add") + @Operation(summary = "新增用户") + @SaCheckPermission(PermissionConstant.USER_ADD) + @Idempotent(message = "请勿重复新增用户") + @Log(value = "新增用户", type = LogType.OPER, saveToDB = true) + public R add(@RequestBody @Validated(ValidGroup.Add.class) UserBo bo) { + userService.add(bo); + return R.ok(); + } + + @PostMapping("/edit") + @Operation(summary = "编辑用户") + @SaCheckPermission(PermissionConstant.USER_EDIT) + @Idempotent(message = "请勿重复编辑用户") + @Log(value = "编辑用户", type = LogType.OPER, saveToDB = true) + public R edit(@RequestBody @Validated(ValidGroup.Update.class) UserBo bo) { + userService.edit(bo); + return R.ok(); + } + + @PostMapping("/remove/{id}") + @Operation(summary = "删除用户") + @SaCheckPermission(PermissionConstant.USER_REMOVE) + @Idempotent(message = "请勿重复删除用户") + @Log(value = "删除用户", type = LogType.OPER, saveToDB = true) + public R remove( + @Parameter(description = "用户ID", example = "1") + @PathVariable Long id) { + userService.remove(id); + return R.ok(); + } + + @PostMapping("/reset-password/{id}") + @Operation(summary = "重置密码") + @SaCheckPermission(PermissionConstant.USER_RESET_PASSWORD) + @Idempotent(message = "请勿重复重置密码") + @Log(value = "重置密码", type = LogType.OPER, saveToDB = true) + public R resetPassword( + @Parameter(description = "用户ID", example = "1") + @PathVariable Long id, + @Parameter(description = "新密码", example = "123456") + @RequestParam String newPassword) { + userService.resetPassword(id, newPassword); + return R.ok(); + } + + @PostMapping("/import") + @Operation(summary = "导入用户") + @SaCheckPermission(PermissionConstant.USER_IMPORT) + @Idempotent(message = "请勿重复导入用户") + @Log(value = "导入用户", type = LogType.IMPORT, saveToDB = true) + public R importUsers( + @Parameter(description = "用户导入Excel文件") + @RequestParam("file") MultipartFile file) { + userService.importUsers(file); + return R.ok(); + } + + @PostMapping("/export") + @Operation(summary = "导出用户") + @SaCheckPermission(PermissionConstant.USER_EXPORT) + @Log(value = "导出用户", type = LogType.EXPORT, saveToDB = true) + public void exportUsers(@RequestBody(required = false) UserQueryBo query, HttpServletResponse response) throws Exception { + if (query == null) { + query = new UserQueryBo(); + } + userService.exportUsers(query, response); + } +} diff --git a/src/main/java/com/test/demo/convert/UserConvert.java b/src/main/java/com/test/demo/convert/UserConvert.java new file mode 100644 index 0000000..531e33a --- /dev/null +++ b/src/main/java/com/test/demo/convert/UserConvert.java @@ -0,0 +1,33 @@ +package com.test.demo.convert; + +import com.test.demo.bo.UserBo; +import com.test.demo.entity.UserEntity; +import com.test.demo.vo.UserVo; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; +import org.mapstruct.factory.Mappers; + +import java.util.List; + +/** + * 用户对象转换器(MapStruct编译时生成实现) + */ +@Mapper(componentModel = "spring") +public interface UserConvert { + + UserConvert INSTANCE = Mappers.getMapper(UserConvert.class); + + @Mapping(target = "statusDesc", expression = "java(entity.getStatus() != null ? entity.getStatus().getDesc() : null)") + @Mapping(target = "status", expression = "java(entity.getStatus() != null ? entity.getStatus().getValue() : null)") + UserVo toVo(UserEntity entity); + + List toVoList(List list); + + @Mapping(target = "id", ignore = true) + @Mapping(target = "status", ignore = true) + UserEntity toEntity(UserBo bo); + + @Mapping(target = "status", ignore = true) + void updateEntity(UserBo bo, @MappingTarget UserEntity entity); +} diff --git a/src/main/java/com/test/demo/entity/EnumItemEntity.java b/src/main/java/com/test/demo/entity/EnumItemEntity.java new file mode 100644 index 0000000..6254e9f --- /dev/null +++ b/src/main/java/com/test/demo/entity/EnumItemEntity.java @@ -0,0 +1,74 @@ +package com.test.demo.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 枚举/字典项实体 + */ +@Data +@TableName("sys_enum_item") +@Schema(name = "EnumItemEntity", description = "枚举/字典项实体") +public class EnumItemEntity { + + @TableId(type = IdType.ASSIGN_ID) + @Schema(description = "主键ID", example = "2033103240752513025") + private Long id; + + @Schema(description = "枚举编码", example = "LogType") + private String enumCode; + + @Schema(description = "字典值", example = "LOGIN") + private String itemValue; + + @Schema(description = "显示文本", example = "登录日志") + private String itemLabel; + + @Schema(description = "系统枚举常量名", example = "LOGIN") + private String itemName; + + @Schema(description = "来源类型", example = "SYSTEM", allowableValues = {"SYSTEM", "CUSTOM"}) + private String sourceType; + + @Schema(description = "是否系统内置", example = "1") + private Integer builtin; + + @Schema(description = "是否启用", example = "1") + private Integer enabled; + + @Schema(description = "是否允许后台编辑", example = "1") + private Integer editable; + + @Schema(description = "是否允许后台删除", example = "0") + private Integer deletable; + + @Schema(description = "排序", example = "0") + private Integer sort; + + @Schema(description = "标签颜色", example = "#409EFF") + private String color; + + @Schema(description = "标签类型", example = "success") + private String tagType; + + @Schema(description = "备注", example = "系统默认项") + private String remark; + + @Schema(description = "最近同步时间") + private LocalDateTime lastSyncTime; + + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建时间") + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "更新时间") + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/test/demo/entity/EnumTypeEntity.java b/src/main/java/com/test/demo/entity/EnumTypeEntity.java new file mode 100644 index 0000000..77858da --- /dev/null +++ b/src/main/java/com/test/demo/entity/EnumTypeEntity.java @@ -0,0 +1,71 @@ +package com.test.demo.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 枚举/字典类型实体 + */ +@Data +@TableName("sys_enum_type") +@Schema(name = "EnumTypeEntity", description = "枚举/字典类型实体") +public class EnumTypeEntity { + + @TableId(type = IdType.ASSIGN_ID) + @Schema(description = "主键ID", example = "2033103240660238338") + private Long id; + + @Schema(description = "枚举编码", example = "LogType") + private String enumCode; + + @Schema(description = "枚举名称", example = "日志类型") + private String enumName; + + @Schema(description = "枚举描述", example = "日志类型枚举") + private String enumDesc; + + @Schema(description = "系统枚举完整类名", example = "com.test.demo.enums.LogType") + private String className; + + @Schema(description = "包路径", example = "com.test.demo.enums") + private String packageName; + + @Schema(description = "值类型", example = "java.lang.String") + private String valueType; + + @Schema(description = "来源类型", example = "SYSTEM", allowableValues = {"SYSTEM", "CUSTOM"}) + private String sourceType; + + @Schema(description = "是否系统内置", example = "1") + private Integer builtin; + + @Schema(description = "是否启用", example = "1") + private Integer enabled; + + @Schema(description = "是否允许系统同步更新", example = "1") + private Integer syncEnabled; + + @Schema(description = "排序", example = "0") + private Integer sort; + + @Schema(description = "备注", example = "系统内置枚举") + private String remark; + + @Schema(description = "最近同步时间") + private LocalDateTime lastSyncTime; + + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建时间") + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "更新时间") + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/test/demo/entity/LoginLogEntity.java b/src/main/java/com/test/demo/entity/LoginLogEntity.java new file mode 100644 index 0000000..67bf2e9 --- /dev/null +++ b/src/main/java/com/test/demo/entity/LoginLogEntity.java @@ -0,0 +1,49 @@ +package com.test.demo.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 登录日志实体 + */ +@Data +@TableName("sys_login_log") +@Schema(name = "LoginLogEntity", description = "登录日志实体") +public class LoginLogEntity { + + @TableId(type = IdType.ASSIGN_ID) + @Schema(description = "主键ID", example = "2033103240660238338") + private Long id; + + @Schema(description = "用户名", example = "admin") + private String username; + + @Schema(description = "用户ID", example = "1") + private Long userId; + + @Schema(description = "状态", example = "1", allowableValues = {"0", "1"}) + private Integer status; + + @Schema(description = "日志类型", example = "LOGIN", allowableValues = {"LOGIN", "LOGOUT"}) + private String logType; + + @Schema(description = "结果信息", example = "登录成功") + private String message; + + @Schema(description = "登录IP", example = "127.0.0.1") + private String ip; + + @Schema(description = "User-Agent") + private String userAgent; + + @Schema(description = "请求URL", example = "/api/auth/login") + private String requestUrl; + + @Schema(description = "创建时间") + private LocalDateTime createTime; +} diff --git a/src/main/java/com/test/demo/entity/MenuEntity.java b/src/main/java/com/test/demo/entity/MenuEntity.java new file mode 100644 index 0000000..d1d9ba2 --- /dev/null +++ b/src/main/java/com/test/demo/entity/MenuEntity.java @@ -0,0 +1,50 @@ +package com.test.demo.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.test.demo.common.base.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 菜单权限实体 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("sys_menu") +@Schema(name = "MenuEntity", description = "菜单权限实体") +public class MenuEntity extends BaseEntity { + + @Schema(description = "父级ID", example = "0") + private Long parentId; + + @Schema(description = "菜单类型", example = "MENU", allowableValues = {"MENU", "BUTTON"}) + private String menuType; + + @Schema(description = "菜单名称", example = "用户管理") + private String menuName; + + @Schema(description = "路由路径", example = "/user") + private String path; + + @Schema(description = "组件路径", example = "system/user/index") + private String component; + + @Schema(description = "权限标识", example = "system:user:page") + private String permission; + + @Schema(description = "图标", example = "User") + private String icon; + + @Schema(description = "是否显示", example = "1") + private Integer visible; + + @Schema(description = "是否启用", example = "1") + private Integer enabled; + + @Schema(description = "排序", example = "0") + private Integer sort; + + @Schema(description = "备注") + private String remark; +} diff --git a/src/main/java/com/test/demo/entity/OperLogEntity.java b/src/main/java/com/test/demo/entity/OperLogEntity.java new file mode 100644 index 0000000..5cba0f6 --- /dev/null +++ b/src/main/java/com/test/demo/entity/OperLogEntity.java @@ -0,0 +1,77 @@ +package com.test.demo.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.IdType; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 操作日志实体 + */ +@Data +@TableName("sys_oper_log") +@Schema(name = "OperLogEntity", description = "操作日志实体") +public class OperLogEntity { + + @TableId(type = IdType.ASSIGN_ID) + @Schema(description = "主键ID", example = "2033103240660238338") + private Long id; + + /** 操作说明 */ + @Schema(description = "操作说明", example = "新增用户") + private String title; + + /** 日志类型 */ + @Schema(description = "日志类型", example = "OPER") + private String logType; + + /** 请求方法(类名.方法名) */ + @Schema(description = "请求方法", example = "com.test.demo.controller.UserController.add") + private String method; + + /** HTTP方法 */ + @Schema(description = "HTTP方法", example = "POST") + private String requestMethod; + + /** 请求URL */ + @Schema(description = "请求URL", example = "/api/user") + private String requestUrl; + + /** 请求参数(脱敏后) */ + @Schema(description = "请求参数(脱敏后)") + private String requestParam; + + /** 返回结果(脱敏后) */ + @Schema(description = "返回结果(脱敏后)") + private String responseResult; + + /** 操作用户ID */ + @Schema(description = "操作用户ID", example = "1") + private Long userId; + + /** 操作用户名 */ + @Schema(description = "操作用户名", example = "admin") + private String userName; + + /** 操作IP */ + @Schema(description = "操作IP", example = "127.0.0.1") + private String ip; + + /** 操作状态(0失败 1成功) */ + @Schema(description = "操作状态", example = "1", allowableValues = {"0", "1"}) + private Integer status; + + /** 错误信息 */ + @Schema(description = "错误信息") + private String errorMsg; + + /** 耗时(ms) */ + @Schema(description = "耗时(ms)", example = "35") + private Long costTime; + + @Schema(description = "创建时间") + private LocalDateTime createTime; +} diff --git a/src/main/java/com/test/demo/entity/RoleEntity.java b/src/main/java/com/test/demo/entity/RoleEntity.java new file mode 100644 index 0000000..c357323 --- /dev/null +++ b/src/main/java/com/test/demo/entity/RoleEntity.java @@ -0,0 +1,32 @@ +package com.test.demo.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.test.demo.common.base.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 角色实体 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("sys_role") +@Schema(name = "RoleEntity", description = "角色实体") +public class RoleEntity extends BaseEntity { + + @Schema(description = "角色编码", example = "ADMIN") + private String roleCode; + + @Schema(description = "角色名称", example = "系统管理员") + private String roleName; + + @Schema(description = "是否启用", example = "1") + private Integer enabled; + + @Schema(description = "排序", example = "0") + private Integer sort; + + @Schema(description = "备注", example = "默认管理员角色") + private String remark; +} diff --git a/src/main/java/com/test/demo/entity/RoleMenuEntity.java b/src/main/java/com/test/demo/entity/RoleMenuEntity.java new file mode 100644 index 0000000..5879672 --- /dev/null +++ b/src/main/java/com/test/demo/entity/RoleMenuEntity.java @@ -0,0 +1,31 @@ +package com.test.demo.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 角色菜单关联实体 + */ +@Data +@TableName("sys_role_menu") +@Schema(name = "RoleMenuEntity", description = "角色菜单关联实体") +public class RoleMenuEntity { + + @TableId(type = IdType.ASSIGN_ID) + @Schema(description = "主键ID") + private Long id; + + @Schema(description = "角色ID", example = "1") + private Long roleId; + + @Schema(description = "菜单ID", example = "1") + private Long menuId; + + @Schema(description = "创建时间") + private LocalDateTime createTime; +} diff --git a/src/main/java/com/test/demo/entity/ScheduledTaskEntity.java b/src/main/java/com/test/demo/entity/ScheduledTaskEntity.java new file mode 100644 index 0000000..b6d8e64 --- /dev/null +++ b/src/main/java/com/test/demo/entity/ScheduledTaskEntity.java @@ -0,0 +1,45 @@ +package com.test.demo.entity; + +import com.baomidou.mybatisplus.annotation.*; +import com.test.demo.common.base.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 定时任务实体 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("sys_scheduled_task") +@Schema(name = "ScheduledTaskEntity", description = "定时任务实体") +public class ScheduledTaskEntity extends BaseEntity { + + /** 任务名称 */ + @Schema(description = "任务名称", example = "清理临时文件") + private String taskName; + + /** cron表达式 */ + @Schema(description = "Cron表达式", example = "0 0/5 * * * ?") + private String cronExpression; + + /** Bean名称 */ + @Schema(description = "Bean名称", example = "fileService") + private String beanName; + + /** 方法名 */ + @Schema(description = "方法名", example = "clearTempFiles") + private String methodName; + + /** 方法参数 */ + @Schema(description = "方法参数", example = "7") + private String methodParams; + + /** 状态(0暂停 1运行) */ + @Schema(description = "状态", example = "1", allowableValues = {"0", "1"}) + private Integer status; + + /** 备注 */ + @Schema(description = "备注", example = "每5分钟执行一次") + private String remark; +} diff --git a/src/main/java/com/test/demo/entity/UserEntity.java b/src/main/java/com/test/demo/entity/UserEntity.java new file mode 100644 index 0000000..1af19b1 --- /dev/null +++ b/src/main/java/com/test/demo/entity/UserEntity.java @@ -0,0 +1,50 @@ +package com.test.demo.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.test.demo.common.base.BaseEntity; +import com.test.demo.enums.UserStatusEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 用户实体 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("sys_user") +@Schema(name = "UserEntity", description = "用户实体") +public class UserEntity extends BaseEntity { + + /** 用户名 */ + @Schema(description = "用户名", example = "admin") + private String username; + + /** SM3加密密码 */ + @Schema(description = "SM3加密密码") + private String password; + + /** 密码盐 */ + @Schema(description = "密码盐") + private String salt; + + /** 昵称 */ + @Schema(description = "昵称", example = "管理员") + private String nickname; + + /** 邮箱 */ + @Schema(description = "邮箱", example = "admin@example.com") + private String email; + + /** 手机号 */ + @Schema(description = "手机号", example = "13800138000") + private String phone; + + /** 头像路径 */ + @Schema(description = "头像路径", example = "/avatar/admin.png") + private String avatar; + + /** 状态 */ + @Schema(description = "状态枚举") + private UserStatusEnum status; +} diff --git a/src/main/java/com/test/demo/entity/UserRoleEntity.java b/src/main/java/com/test/demo/entity/UserRoleEntity.java new file mode 100644 index 0000000..23dfcd5 --- /dev/null +++ b/src/main/java/com/test/demo/entity/UserRoleEntity.java @@ -0,0 +1,31 @@ +package com.test.demo.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 用户角色关联实体 + */ +@Data +@TableName("sys_user_role") +@Schema(name = "UserRoleEntity", description = "用户角色关联实体") +public class UserRoleEntity { + + @TableId(type = IdType.ASSIGN_ID) + @Schema(description = "主键ID") + private Long id; + + @Schema(description = "用户ID", example = "1") + private Long userId; + + @Schema(description = "角色ID", example = "1") + private Long roleId; + + @Schema(description = "创建时间") + private LocalDateTime createTime; +} diff --git a/src/main/java/com/test/demo/enums/LogType.java b/src/main/java/com/test/demo/enums/LogType.java new file mode 100644 index 0000000..792d01c --- /dev/null +++ b/src/main/java/com/test/demo/enums/LogType.java @@ -0,0 +1,37 @@ +package com.test.demo.enums; + +import com.test.demo.common.enums.EnumDesc; +import com.test.demo.common.enums.IBaseEnum; + +/** + * 日志类型枚举 + */ +@EnumDesc("日志类型枚举") +public enum LogType implements IBaseEnum { + + LOGIN("LOGIN", "登录日志"), + LOGOUT("LOGOUT", "登出日志"), + OPER("OPER", "操作日志"), + MAIL("MAIL", "邮件日志"), + EXPORT("EXPORT", "导出日志"), + IMPORT("IMPORT", "导入日志"), + OTHER("OTHER", "其他日志"); + + private final String value; + private final String desc; + + LogType(String value, String desc) { + this.value = value; + this.desc = desc; + } + + @Override + public String getValue() { + return this.value; + } + + @Override + public String getDesc() { + return this.desc; + } +} diff --git a/src/main/java/com/test/demo/enums/UserStatusEnum.java b/src/main/java/com/test/demo/enums/UserStatusEnum.java new file mode 100644 index 0000000..3ac0ff5 --- /dev/null +++ b/src/main/java/com/test/demo/enums/UserStatusEnum.java @@ -0,0 +1,23 @@ +package com.test.demo.enums; + +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.test.demo.common.enums.EnumDesc; +import com.test.demo.common.enums.IBaseEnum; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 用户状态枚举 + */ +@EnumDesc("用户状态枚举") +@Getter +@RequiredArgsConstructor +public enum UserStatusEnum implements IBaseEnum { + + DISABLED(0, "禁用"), + NORMAL(1, "正常"); + + @EnumValue + private final Integer value; + private final String desc; +} diff --git a/src/main/java/com/test/demo/filter/RepeatableReadFilter.java b/src/main/java/com/test/demo/filter/RepeatableReadFilter.java new file mode 100644 index 0000000..4d2ca81 --- /dev/null +++ b/src/main/java/com/test/demo/filter/RepeatableReadFilter.java @@ -0,0 +1,25 @@ +package com.test.demo.filter; + +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; + +import java.io.IOException; + +/** + * 可重复读取请求体过滤器 + */ +public class RepeatableReadFilter implements Filter { + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest req = (HttpServletRequest) request; + String contentType = req.getContentType(); + // 仅对 JSON 请求体做包装 + if (contentType != null && contentType.contains("application/json")) { + chain.doFilter(new RepeatableReadRequestWrapper(req), response); + } else { + chain.doFilter(request, response); + } + } +} diff --git a/src/main/java/com/test/demo/filter/RepeatableReadRequestWrapper.java b/src/main/java/com/test/demo/filter/RepeatableReadRequestWrapper.java new file mode 100644 index 0000000..67894eb --- /dev/null +++ b/src/main/java/com/test/demo/filter/RepeatableReadRequestWrapper.java @@ -0,0 +1,64 @@ +package com.test.demo.filter; + +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; + +import java.io.*; + +/** + * 可重复读取请求体的包装器(用于日志记录、签名校验等需要多次读取 body 的场景) + */ +public class RepeatableReadRequestWrapper extends HttpServletRequestWrapper { + + private final byte[] body; + + public RepeatableReadRequestWrapper(HttpServletRequest request) throws IOException { + super(request); + try (InputStream is = request.getInputStream(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + byte[] buffer = new byte[1024]; + int len; + while ((len = is.read(buffer)) != -1) { + baos.write(buffer, 0, len); + } + this.body = baos.toByteArray(); + } + } + + @Override + public ServletInputStream getInputStream() { + ByteArrayInputStream bais = new ByteArrayInputStream(body); + return new ServletInputStream() { + @Override + public boolean isFinished() { + return bais.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener listener) { + // no-op + } + + @Override + public int read() { + return bais.read(); + } + }; + } + + @Override + public BufferedReader getReader() { + return new BufferedReader(new InputStreamReader(getInputStream())); + } + + public byte[] getBody() { + return body; + } +} diff --git a/src/main/java/com/test/demo/filter/XssFilter.java b/src/main/java/com/test/demo/filter/XssFilter.java new file mode 100644 index 0000000..445c10a --- /dev/null +++ b/src/main/java/com/test/demo/filter/XssFilter.java @@ -0,0 +1,43 @@ +package com.test.demo.filter; + +import cn.hutool.core.util.StrUtil; +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +/** + * XSS 防护过滤器 + */ +public class XssFilter implements Filter { + + private List excludes; + + @Override + public void init(FilterConfig filterConfig) { + String excludeStr = filterConfig.getInitParameter("excludes"); + if (StrUtil.isNotBlank(excludeStr)) { + excludes = Arrays.asList(excludeStr.split(",")); + } else { + excludes = List.of(); + } + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest req = (HttpServletRequest) request; + if (isExclude(req)) { + chain.doFilter(request, response); + } else { + chain.doFilter(new XssRequestWrapper(req), response); + } + } + + private boolean isExclude(HttpServletRequest request) { + String uri = request.getServletPath(); + return excludes.stream().anyMatch(uri::startsWith); + } +} diff --git a/src/main/java/com/test/demo/filter/XssRequestWrapper.java b/src/main/java/com/test/demo/filter/XssRequestWrapper.java new file mode 100644 index 0000000..1bb78b4 --- /dev/null +++ b/src/main/java/com/test/demo/filter/XssRequestWrapper.java @@ -0,0 +1,40 @@ +package com.test.demo.filter; + +import cn.hutool.http.HtmlUtil; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; + +/** + * XSS 请求包装器 - 对参数和请求头做 HTML 转义 + */ +public class XssRequestWrapper extends HttpServletRequestWrapper { + + public XssRequestWrapper(HttpServletRequest request) { + super(request); + } + + @Override + public String getParameter(String name) { + String value = super.getParameter(name); + return value != null ? HtmlUtil.escape(value) : null; + } + + @Override + public String[] getParameterValues(String name) { + String[] values = super.getParameterValues(name); + if (values == null) { + return null; + } + String[] escaped = new String[values.length]; + for (int i = 0; i < values.length; i++) { + escaped[i] = HtmlUtil.escape(values[i]); + } + return escaped; + } + + @Override + public String getHeader(String name) { + String value = super.getHeader(name); + return value != null ? HtmlUtil.escape(value) : null; + } +} diff --git a/src/main/java/com/test/demo/handler/MyMetaObjectHandler.java b/src/main/java/com/test/demo/handler/MyMetaObjectHandler.java new file mode 100644 index 0000000..e60fff6 --- /dev/null +++ b/src/main/java/com/test/demo/handler/MyMetaObjectHandler.java @@ -0,0 +1,25 @@ +package com.test.demo.handler; + +import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; +import org.apache.ibatis.reflection.MetaObject; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +/** + * MyBatis-Plus 自动填充处理器 + */ +@Component +public class MyMetaObjectHandler implements MetaObjectHandler { + + @Override + public void insertFill(MetaObject metaObject) { + this.strictInsertFill(metaObject, "createTime", LocalDateTime::now, LocalDateTime.class); + this.strictInsertFill(metaObject, "updateTime", LocalDateTime::now, LocalDateTime.class); + } + + @Override + public void updateFill(MetaObject metaObject) { + this.strictUpdateFill(metaObject, "updateTime", LocalDateTime::now, LocalDateTime.class); + } +} diff --git a/src/main/java/com/test/demo/interceptor/IdempotentInterceptor.java b/src/main/java/com/test/demo/interceptor/IdempotentInterceptor.java new file mode 100644 index 0000000..1fb90ca --- /dev/null +++ b/src/main/java/com/test/demo/interceptor/IdempotentInterceptor.java @@ -0,0 +1,58 @@ +package com.test.demo.interceptor; + +import cn.hutool.crypto.digest.DigestUtil; +import com.test.demo.common.annotation.Idempotent; +import com.test.demo.common.constant.CommonConstant; +import com.test.demo.common.constant.RedisKeyConstant; +import com.test.demo.common.exception.BizException; +import com.test.demo.common.result.ResultCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.concurrent.TimeUnit; + +/** + * 幂等性拦截器 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class IdempotentInterceptor implements HandlerInterceptor { + + private final RedisTemplate redisTemplate; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + if (!(handler instanceof HandlerMethod handlerMethod)) { + return true; + } + + Idempotent idempotent = handlerMethod.getMethodAnnotation(Idempotent.class); + if (idempotent == null) { + return true; + } + + // 构建幂等 key: token + url + 参数hash + String token = request.getHeader(CommonConstant.HEADER_IDEMPOTENT_TOKEN); + if (token == null) { + token = request.getHeader("Authorization"); + } + String url = request.getRequestURI(); + String queryString = request.getQueryString() != null ? request.getQueryString() : ""; + String key = RedisKeyConstant.IDEMPOTENT + DigestUtil.md5Hex(token + url + queryString); + + Boolean absent = redisTemplate.opsForValue().setIfAbsent(key, "1", idempotent.expireTime(), TimeUnit.SECONDS); + if (Boolean.FALSE.equals(absent)) { + log.warn("幂等性拦截: key={}", key); + throw new BizException(ResultCode.IDEMPOTENT_REJECT.getCode(), idempotent.message()); + } + + return true; + } +} diff --git a/src/main/java/com/test/demo/interceptor/SignatureInterceptor.java b/src/main/java/com/test/demo/interceptor/SignatureInterceptor.java new file mode 100644 index 0000000..c2417e2 --- /dev/null +++ b/src/main/java/com/test/demo/interceptor/SignatureInterceptor.java @@ -0,0 +1,75 @@ +package com.test.demo.interceptor; + +import cn.hutool.crypto.digest.DigestUtil; +import com.test.demo.common.constant.CommonConstant; +import com.test.demo.common.constant.RedisKeyConstant; +import com.test.demo.common.exception.BizException; +import com.test.demo.common.result.ResultCode; +import com.test.demo.filter.RepeatableReadRequestWrapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; + +/** + * 接口签名/防重放拦截器 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SignatureInterceptor implements HandlerInterceptor { + + private final RedisTemplate redisTemplate; + + @Value("${security.sign.secret:change-me-sign-secret}") + private String signSecret; + + @Value("${security.sign.expire-seconds:300}") + private long expireSeconds; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String timestamp = request.getHeader(CommonConstant.HEADER_TIMESTAMP); + String nonce = request.getHeader(CommonConstant.HEADER_NONCE); + String sign = request.getHeader(CommonConstant.HEADER_SIGN); + + // 缺少签名头则跳过(可根据需求改为拒绝) + if (timestamp == null || nonce == null || sign == null) { + return true; + } + + // 时间戳校验 + long ts = Long.parseLong(timestamp); + long now = System.currentTimeMillis() / 1000; + if (Math.abs(now - ts) > expireSeconds) { + throw new BizException(ResultCode.REPLAY_ATTACK); + } + + // nonce 防重放 + String nonceKey = RedisKeyConstant.NONCE + nonce; + Boolean absent = redisTemplate.opsForValue().setIfAbsent(nonceKey, "1", expireSeconds, TimeUnit.SECONDS); + if (Boolean.FALSE.equals(absent)) { + throw new BizException(ResultCode.REPLAY_ATTACK); + } + + // 签名校验 + String body = ""; + if (request instanceof RepeatableReadRequestWrapper wrapper) { + body = new String(wrapper.getBody(), StandardCharsets.UTF_8); + } + String rawSign = timestamp + nonce + body + signSecret; + String expectedSign = DigestUtil.sha256Hex(rawSign); + if (!expectedSign.equalsIgnoreCase(sign)) { + throw new BizException(ResultCode.SIGNATURE_INVALID); + } + + return true; + } +} diff --git a/src/main/java/com/test/demo/mapper/EnumItemMapper.java b/src/main/java/com/test/demo/mapper/EnumItemMapper.java new file mode 100644 index 0000000..a32cc6d --- /dev/null +++ b/src/main/java/com/test/demo/mapper/EnumItemMapper.java @@ -0,0 +1,12 @@ +package com.test.demo.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.test.demo.entity.EnumItemEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 枚举/字典项 Mapper + */ +@Mapper +public interface EnumItemMapper extends BaseMapper { +} diff --git a/src/main/java/com/test/demo/mapper/EnumTypeMapper.java b/src/main/java/com/test/demo/mapper/EnumTypeMapper.java new file mode 100644 index 0000000..aa18ff8 --- /dev/null +++ b/src/main/java/com/test/demo/mapper/EnumTypeMapper.java @@ -0,0 +1,12 @@ +package com.test.demo.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.test.demo.entity.EnumTypeEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 枚举/字典类型 Mapper + */ +@Mapper +public interface EnumTypeMapper extends BaseMapper { +} diff --git a/src/main/java/com/test/demo/mapper/LoginLogMapper.java b/src/main/java/com/test/demo/mapper/LoginLogMapper.java new file mode 100644 index 0000000..e46fe86 --- /dev/null +++ b/src/main/java/com/test/demo/mapper/LoginLogMapper.java @@ -0,0 +1,12 @@ +package com.test.demo.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.test.demo.entity.LoginLogEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 登录日志 Mapper + */ +@Mapper +public interface LoginLogMapper extends BaseMapper { +} diff --git a/src/main/java/com/test/demo/mapper/MenuMapper.java b/src/main/java/com/test/demo/mapper/MenuMapper.java new file mode 100644 index 0000000..e210c91 --- /dev/null +++ b/src/main/java/com/test/demo/mapper/MenuMapper.java @@ -0,0 +1,48 @@ +package com.test.demo.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.test.demo.entity.MenuEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * 菜单 Mapper + */ +@Mapper +public interface MenuMapper extends BaseMapper { + + @Select(""" + SELECT DISTINCT m.id, m.parent_id, m.menu_type, m.menu_name, m.path, m.component, m.permission, + m.icon, m.visible, m.enabled, m.sort, m.remark, m.create_time, m.update_time, m.deleted + FROM sys_menu m + INNER JOIN sys_role_menu rm ON rm.menu_id = m.id + INNER JOIN sys_user_role ur ON ur.role_id = rm.role_id + INNER JOIN sys_role r ON r.id = rm.role_id + WHERE ur.user_id = #{userId} + AND m.enabled = 1 + AND m.visible = 1 + AND m.deleted = 0 + AND r.enabled = 1 + AND r.deleted = 0 + ORDER BY m.sort ASC, m.id ASC + """) + List selectMenusByUserId(Long userId); + + @Select(""" + SELECT DISTINCT m.permission + FROM sys_menu m + INNER JOIN sys_role_menu rm ON rm.menu_id = m.id + INNER JOIN sys_user_role ur ON ur.role_id = rm.role_id + INNER JOIN sys_role r ON r.id = rm.role_id + WHERE ur.user_id = #{userId} + AND m.enabled = 1 + AND m.deleted = 0 + AND r.enabled = 1 + AND r.deleted = 0 + AND m.permission IS NOT NULL + AND m.permission <> '' + """) + List selectPermissionsByUserId(Long userId); +} diff --git a/src/main/java/com/test/demo/mapper/OperLogMapper.java b/src/main/java/com/test/demo/mapper/OperLogMapper.java new file mode 100644 index 0000000..8b2c40e --- /dev/null +++ b/src/main/java/com/test/demo/mapper/OperLogMapper.java @@ -0,0 +1,12 @@ +package com.test.demo.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.test.demo.entity.OperLogEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 操作日志 Mapper + */ +@Mapper +public interface OperLogMapper extends BaseMapper { +} diff --git a/src/main/java/com/test/demo/mapper/RoleMapper.java b/src/main/java/com/test/demo/mapper/RoleMapper.java new file mode 100644 index 0000000..44351fe --- /dev/null +++ b/src/main/java/com/test/demo/mapper/RoleMapper.java @@ -0,0 +1,23 @@ +package com.test.demo.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.test.demo.entity.RoleEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * 角色 Mapper + */ +@Mapper +public interface RoleMapper extends BaseMapper { + + @Select(""" + SELECT DISTINCT r.role_code + FROM sys_role r + INNER JOIN sys_user_role ur ON ur.role_id = r.id + WHERE ur.user_id = #{userId} AND r.enabled = 1 AND r.deleted = 0 + """) + List selectRoleCodesByUserId(Long userId); +} diff --git a/src/main/java/com/test/demo/mapper/RoleMenuMapper.java b/src/main/java/com/test/demo/mapper/RoleMenuMapper.java new file mode 100644 index 0000000..5197e1a --- /dev/null +++ b/src/main/java/com/test/demo/mapper/RoleMenuMapper.java @@ -0,0 +1,12 @@ +package com.test.demo.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.test.demo.entity.RoleMenuEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 角色菜单 Mapper + */ +@Mapper +public interface RoleMenuMapper extends BaseMapper { +} diff --git a/src/main/java/com/test/demo/mapper/ScheduledTaskMapper.java b/src/main/java/com/test/demo/mapper/ScheduledTaskMapper.java new file mode 100644 index 0000000..5415370 --- /dev/null +++ b/src/main/java/com/test/demo/mapper/ScheduledTaskMapper.java @@ -0,0 +1,12 @@ +package com.test.demo.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.test.demo.entity.ScheduledTaskEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 定时任务 Mapper + */ +@Mapper +public interface ScheduledTaskMapper extends BaseMapper { +} diff --git a/src/main/java/com/test/demo/mapper/UserMapper.java b/src/main/java/com/test/demo/mapper/UserMapper.java new file mode 100644 index 0000000..b511ace --- /dev/null +++ b/src/main/java/com/test/demo/mapper/UserMapper.java @@ -0,0 +1,12 @@ +package com.test.demo.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.test.demo.entity.UserEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 用户 Mapper + */ +@Mapper +public interface UserMapper extends BaseMapper { +} diff --git a/src/main/java/com/test/demo/mapper/UserRoleMapper.java b/src/main/java/com/test/demo/mapper/UserRoleMapper.java new file mode 100644 index 0000000..a0e294d --- /dev/null +++ b/src/main/java/com/test/demo/mapper/UserRoleMapper.java @@ -0,0 +1,12 @@ +package com.test.demo.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.test.demo.entity.UserRoleEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 用户角色 Mapper + */ +@Mapper +public interface UserRoleMapper extends BaseMapper { +} diff --git a/src/main/java/com/test/demo/security/SecurityUtil.java b/src/main/java/com/test/demo/security/SecurityUtil.java new file mode 100644 index 0000000..182f992 --- /dev/null +++ b/src/main/java/com/test/demo/security/SecurityUtil.java @@ -0,0 +1,57 @@ +package com.test.demo.security; + +import cn.dev33.satoken.stp.StpUtil; + +/** + * 安全工具类 + */ +public final class SecurityUtil { + + private SecurityUtil() {} + + /** + * 获取当前登录用户ID + */ + public static Long getUserId() { + return StpUtil.getLoginIdAsLong(); + } + + /** + * 获取当前登录用户ID(未登录返回null) + */ + public static Long getUserIdOrNull() { + try { + return StpUtil.getLoginIdAsLong(); + } catch (Exception e) { + return null; + } + } + + /** + * 判断是否已登录 + */ + public static boolean isLogin() { + return StpUtil.isLogin(); + } + + /** + * 登录 + */ + public static void login(Long userId) { + StpUtil.login(userId); + } + + /** + * 登出 + */ + public static void logout() { + StpUtil.logout(); + } + + /** + * 获取当前 Token + */ + public static String getToken() { + return StpUtil.getTokenValue(); + } +} diff --git a/src/main/java/com/test/demo/security/StpInterfaceImpl.java b/src/main/java/com/test/demo/security/StpInterfaceImpl.java new file mode 100644 index 0000000..378c46e --- /dev/null +++ b/src/main/java/com/test/demo/security/StpInterfaceImpl.java @@ -0,0 +1,34 @@ +package com.test.demo.security; + +import cn.dev33.satoken.stp.StpInterface; +import com.test.demo.mapper.MenuMapper; +import com.test.demo.mapper.RoleMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.List; + +/** + * Sa-Token 权限/角色数据加载实现 + */ +@Component +@RequiredArgsConstructor +public class StpInterfaceImpl implements StpInterface { + + private final RoleMapper roleMapper; + private final MenuMapper menuMapper; + + @Override + public List getPermissionList(Object loginId, String loginType) { + Long userId = Long.parseLong(String.valueOf(loginId)); + return menuMapper.selectPermissionsByUserId(userId); + } + + @Override + public List getRoleList(Object loginId, String loginType) { + Long userId = Long.parseLong(String.valueOf(loginId)); + List roles = roleMapper.selectRoleCodesByUserId(userId); + return roles != null ? roles : Collections.emptyList(); + } +} diff --git a/src/main/java/com/test/demo/security/crypto/SM3Util.java b/src/main/java/com/test/demo/security/crypto/SM3Util.java new file mode 100644 index 0000000..4ad40ab --- /dev/null +++ b/src/main/java/com/test/demo/security/crypto/SM3Util.java @@ -0,0 +1,47 @@ +package com.test.demo.security.crypto; + +import cn.hutool.core.util.HexUtil; +import cn.hutool.core.util.RandomUtil; +import org.bouncycastle.crypto.digests.SM3Digest; +import org.bouncycastle.jce.provider.BouncyCastleProvider; + +import java.nio.charset.StandardCharsets; +import java.security.Security; + +/** + * SM3 国密哈希工具(密码加密) + */ +public final class SM3Util { + + static { + Security.addProvider(new BouncyCastleProvider()); + } + + private SM3Util() {} + + /** + * 生成随机盐 + */ + public static String generateSalt() { + return RandomUtil.randomString(16); + } + + /** + * SM3 哈希(加盐) + */ + public static String hash(String data, String salt) { + byte[] input = (salt + data).getBytes(StandardCharsets.UTF_8); + SM3Digest digest = new SM3Digest(); + digest.update(input, 0, input.length); + byte[] result = new byte[digest.getDigestSize()]; + digest.doFinal(result, 0); + return HexUtil.encodeHexStr(result); + } + + /** + * 验证密码 + */ + public static boolean verify(String data, String salt, String hash) { + return hash(data, salt).equals(hash); + } +} diff --git a/src/main/java/com/test/demo/security/crypto/SM4Util.java b/src/main/java/com/test/demo/security/crypto/SM4Util.java new file mode 100644 index 0000000..1bd1397 --- /dev/null +++ b/src/main/java/com/test/demo/security/crypto/SM4Util.java @@ -0,0 +1,61 @@ +package com.test.demo.security.crypto; + +import cn.hutool.core.util.HexUtil; +import org.bouncycastle.jce.provider.BouncyCastleProvider; + +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.Security; +import java.util.Base64; + +/** + * SM4 国密对称加解密工具 + */ +public final class SM4Util { + + private static final String ALGORITHM = "SM4"; + private static final String TRANSFORMATION = "SM4/ECB/PKCS5Padding"; + + static { + Security.addProvider(new BouncyCastleProvider()); + } + + private SM4Util() {} + + /** + * 加密(返回 Base64) + * @param plainText 明文 + * @param hexKey 16字节密钥的十六进制字符串(32个hex字符) + */ + public static String encrypt(String plainText, String hexKey) { + try { + byte[] keyBytes = HexUtil.decodeHex(hexKey); + SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM); + Cipher cipher = Cipher.getInstance(TRANSFORMATION, "BC"); + cipher.init(Cipher.ENCRYPT_MODE, keySpec); + byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(encrypted); + } catch (Exception e) { + throw new RuntimeException("SM4加密失败", e); + } + } + + /** + * 解密 + * @param cipherText Base64 密文 + * @param hexKey 16字节密钥的十六进制字符串 + */ + public static String decrypt(String cipherText, String hexKey) { + try { + byte[] keyBytes = HexUtil.decodeHex(hexKey); + SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM); + Cipher cipher = Cipher.getInstance(TRANSFORMATION, "BC"); + cipher.init(Cipher.DECRYPT_MODE, keySpec); + byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(cipherText)); + return new String(decrypted, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new RuntimeException("SM4解密失败", e); + } + } +} diff --git a/src/main/java/com/test/demo/serializer/DesensitizeSerializer.java b/src/main/java/com/test/demo/serializer/DesensitizeSerializer.java new file mode 100644 index 0000000..36f78b5 --- /dev/null +++ b/src/main/java/com/test/demo/serializer/DesensitizeSerializer.java @@ -0,0 +1,54 @@ +package com.test.demo.serializer; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.BeanProperty; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.ContextualSerializer; +import com.test.demo.common.annotation.Desensitize; +import com.test.demo.common.annotation.DesensitizeType; +import com.test.demo.util.DesensitizeUtil; +import lombok.NoArgsConstructor; + +import java.io.IOException; + +/** + * 数据脱敏 Jackson 序列化器 + */ +@NoArgsConstructor +public class DesensitizeSerializer extends JsonSerializer implements ContextualSerializer { + + private DesensitizeType type; + + public DesensitizeSerializer(DesensitizeType type) { + this.type = type; + } + + @Override + public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + if (value == null) { + gen.writeNull(); + return; + } + String result = switch (type) { + case PHONE -> DesensitizeUtil.phone(value); + case ID_CARD -> DesensitizeUtil.idCard(value); + case EMAIL -> DesensitizeUtil.email(value); + case BANK_CARD -> DesensitizeUtil.bankCard(value); + case NAME -> DesensitizeUtil.name(value); + case ADDRESS -> DesensitizeUtil.address(value); + }; + gen.writeString(result); + } + + @Override + public JsonSerializer createContextual(SerializerProvider prov, BeanProperty property) { + if (property != null) { + Desensitize annotation = property.getAnnotation(Desensitize.class); + if (annotation != null) { + return new DesensitizeSerializer(annotation.type()); + } + } + return this; + } +} diff --git a/src/main/java/com/test/demo/service/AuthService.java b/src/main/java/com/test/demo/service/AuthService.java new file mode 100644 index 0000000..0c95b79 --- /dev/null +++ b/src/main/java/com/test/demo/service/AuthService.java @@ -0,0 +1,20 @@ +package com.test.demo.service; + +import com.test.demo.bo.LoginBo; +import com.test.demo.bo.RegisterBo; +import com.test.demo.vo.LoginVo; +import com.test.demo.vo.UserVo; + +/** + * 认证 Service + */ +public interface AuthService { + + LoginVo login(LoginBo bo); + + void register(RegisterBo bo); + + void logout(); + + UserVo getCurrentUser(); +} diff --git a/src/main/java/com/test/demo/service/CaptchaService.java b/src/main/java/com/test/demo/service/CaptchaService.java new file mode 100644 index 0000000..1b5eb4f --- /dev/null +++ b/src/main/java/com/test/demo/service/CaptchaService.java @@ -0,0 +1,57 @@ +package com.test.demo.service; + +import cn.hutool.core.lang.UUID; +import com.test.demo.common.constant.RedisKeyConstant; +import com.wf.captcha.SpecCaptcha; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * 验证码服务 + */ +@Service +@RequiredArgsConstructor +public class CaptchaService { + + private final RedisTemplate redisTemplate; + + /** 验证码有效期(秒) */ + private static final long CAPTCHA_EXPIRE = 120; + + /** + * 生成验证码 + * @return {key: 缓存key, image: base64图片} + */ + public Map generate() { + SpecCaptcha captcha = new SpecCaptcha(130, 48, 4); + String code = captcha.text().toLowerCase(); + String key = UUID.randomUUID().toString(true); + + redisTemplate.opsForValue().set( + RedisKeyConstant.CAPTCHA + key, code, CAPTCHA_EXPIRE, TimeUnit.SECONDS); + + Map result = new HashMap<>(2); + result.put("key", key); + result.put("image", captcha.toBase64()); + return result; + } + + /** + * 校验验证码 + */ + public boolean verify(String key, String code) { + if (key == null || code == null) { + return false; + } + String redisKey = RedisKeyConstant.CAPTCHA + key; + Object cached = redisTemplate.opsForValue().get(redisKey); + // 验证后立即删除,防止重复使用 + redisTemplate.delete(redisKey); + return cached != null && cached.toString().equalsIgnoreCase(code); + } +} diff --git a/src/main/java/com/test/demo/service/DynamicTaskService.java b/src/main/java/com/test/demo/service/DynamicTaskService.java new file mode 100644 index 0000000..14bb398 --- /dev/null +++ b/src/main/java/com/test/demo/service/DynamicTaskService.java @@ -0,0 +1,149 @@ +package com.test.demo.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.test.demo.bo.TaskQueryBo; +import com.test.demo.common.base.PageResult; +import com.test.demo.common.exception.BizException; +import com.test.demo.common.result.ResultCode; +import com.test.demo.entity.ScheduledTaskEntity; +import com.test.demo.mapper.ScheduledTaskMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.ApplicationContext; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.support.CronTrigger; +import org.springframework.stereotype.Service; + +import java.lang.reflect.Method; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledFuture; + +/** + * 动态定时任务服务 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class DynamicTaskService implements ApplicationRunner { + + private final ScheduledTaskMapper taskMapper; + private final TaskScheduler taskScheduler; + private final ApplicationContext applicationContext; + + private final Map> runningTasks = new ConcurrentHashMap<>(); + + @Override + public void run(ApplicationArguments args) { + // 启动时加载所有运行中的任务 + var tasks = taskMapper.selectList( + new LambdaQueryWrapper().eq(ScheduledTaskEntity::getStatus, 1)); + for (ScheduledTaskEntity task : tasks) { + addTask(task); + } + log.info("动态定时任务加载完成, 共{}个", tasks.size()); + } + + /** 添加并启动任务 */ + public void addTask(ScheduledTaskEntity task) { + ScheduledFuture future = taskScheduler.schedule( + () -> executeTask(task), new CronTrigger(task.getCronExpression())); + runningTasks.put(task.getId(), future); + log.info("定时任务已启动: {} [{}]", task.getTaskName(), task.getCronExpression()); + } + + /** 停止任务 */ + public void removeTask(Long taskId) { + ScheduledFuture future = runningTasks.remove(taskId); + if (future != null) { + future.cancel(false); + log.info("定时任务已停止: taskId={}", taskId); + } + } + + /** 重启任务 */ + public void restartTask(ScheduledTaskEntity task) { + removeTask(task.getId()); + addTask(task); + } + + private void executeTask(ScheduledTaskEntity task) { + try { + Object bean = applicationContext.getBean(task.getBeanName()); + Method method; + if (task.getMethodParams() != null && !task.getMethodParams().isEmpty()) { + method = bean.getClass().getMethod(task.getMethodName(), String.class); + method.invoke(bean, task.getMethodParams()); + } else { + method = bean.getClass().getMethod(task.getMethodName()); + method.invoke(bean); + } + } catch (Exception e) { + log.error("定时任务执行失败: {}", task.getTaskName(), e); + } + } + + /** 创建任务 */ + public void create(ScheduledTaskEntity task) { + task.setStatus(1); + taskMapper.insert(task); + addTask(task); + } + + /** 更新任务 */ + public void update(ScheduledTaskEntity task) { + taskMapper.updateById(task); + if (task.getStatus() != null && task.getStatus() == 1) { + restartTask(task); + } else { + removeTask(task.getId()); + } + } + + /** 暂停任务 */ + public void pause(Long taskId) { + ScheduledTaskEntity entity = new ScheduledTaskEntity(); + entity.setId(taskId); + entity.setStatus(0); + taskMapper.updateById(entity); + removeTask(taskId); + } + + /** 恢复任务 */ + public void resume(Long taskId) { + ScheduledTaskEntity task = taskMapper.selectById(taskId); + if (task == null) { + throw new BizException(ResultCode.NOT_FOUND, "任务不存在"); + } + task.setStatus(1); + taskMapper.updateById(task); + addTask(task); + } + + /** 删除任务 */ + public void delete(Long taskId) { + removeTask(taskId); + taskMapper.deleteById(taskId); + } + + public PageResult page(TaskQueryBo query) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .like(query.getTaskName() != null && !query.getTaskName().isBlank(), ScheduledTaskEntity::getTaskName, query.getTaskName()) + .like(query.getBeanName() != null && !query.getBeanName().isBlank(), ScheduledTaskEntity::getBeanName, query.getBeanName()) + .eq(query.getStatus() != null, ScheduledTaskEntity::getStatus, query.getStatus()) + .orderByDesc(ScheduledTaskEntity::getCreateTime); + Page page = taskMapper.selectPage(new Page<>(query.getPageNum(), query.getPageSize()), wrapper); + return PageResult.of(page.getRecords(), page.getTotal(), query.getPageNum(), query.getPageSize()); + } + + public ScheduledTaskEntity detail(Long id) { + ScheduledTaskEntity entity = taskMapper.selectById(id); + if (entity == null) { + throw new BizException(ResultCode.NOT_FOUND, "任务不存在"); + } + return entity; + } +} diff --git a/src/main/java/com/test/demo/service/EnumItemService.java b/src/main/java/com/test/demo/service/EnumItemService.java new file mode 100644 index 0000000..9ad0fec --- /dev/null +++ b/src/main/java/com/test/demo/service/EnumItemService.java @@ -0,0 +1,29 @@ +package com.test.demo.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.test.demo.bo.EnumItemBo; +import com.test.demo.entity.EnumItemEntity; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * 枚举/字典项 Service + */ +public interface EnumItemService extends IService { + + List> listEnabledForApi(String enumCode); + + List listManageItems(String enumCode); + + void add(EnumItemBo bo); + + void edit(EnumItemBo bo); + + void changeEnabled(Long id, Integer enabled); + + void remove(Long id); + + void syncSystemItems(String enumCode, List items, LocalDateTime syncTime); +} diff --git a/src/main/java/com/test/demo/service/EnumTypeService.java b/src/main/java/com/test/demo/service/EnumTypeService.java new file mode 100644 index 0000000..d58c2b1 --- /dev/null +++ b/src/main/java/com/test/demo/service/EnumTypeService.java @@ -0,0 +1,30 @@ +package com.test.demo.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.test.demo.bo.EnumTypeBo; +import com.test.demo.bo.EnumTypeQueryBo; +import com.test.demo.common.base.PageResult; +import com.test.demo.entity.EnumTypeEntity; + +import java.util.List; +import java.util.Map; + +/** + * 枚举/字典类型 Service + */ +public interface EnumTypeService extends IService { + + EnumTypeEntity getByEnumCode(String enumCode); + + List> listEnabledForApi(); + + PageResult page(EnumTypeQueryBo query); + + void add(EnumTypeBo bo); + + void edit(EnumTypeBo bo); + + void changeEnabled(Long id, Integer enabled); + + void remove(Long id); +} diff --git a/src/main/java/com/test/demo/service/FileService.java b/src/main/java/com/test/demo/service/FileService.java new file mode 100644 index 0000000..550ce2b --- /dev/null +++ b/src/main/java/com/test/demo/service/FileService.java @@ -0,0 +1,24 @@ +package com.test.demo.service; + +import com.test.demo.bo.FileQueryBo; +import com.test.demo.common.base.PageResult; +import com.test.demo.vo.FileInfoVo; +import org.springframework.web.multipart.MultipartFile; + +/** + * 文件服务接口 + */ +public interface FileService { + + /** 上传文件,返回相对路径 */ + String upload(MultipartFile file); + + /** 获取文件绝对路径 */ + String getFilePath(String relativePath); + + /** 查询文件列表 */ + PageResult list(FileQueryBo query); + + /** 删除文件 */ + boolean delete(String relativePath); +} diff --git a/src/main/java/com/test/demo/service/LoginLogService.java b/src/main/java/com/test/demo/service/LoginLogService.java new file mode 100644 index 0000000..1643099 --- /dev/null +++ b/src/main/java/com/test/demo/service/LoginLogService.java @@ -0,0 +1,21 @@ +package com.test.demo.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.test.demo.bo.LoginLogQueryBo; +import com.test.demo.common.base.PageResult; +import com.test.demo.entity.LoginLogEntity; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; + +/** + * 登录日志 Service + */ +public interface LoginLogService extends IService { + + void record(String username, Long userId, String logType, Integer status, String message); + + PageResult page(LoginLogQueryBo query); + + void export(LoginLogQueryBo query, HttpServletResponse response) throws IOException; +} diff --git a/src/main/java/com/test/demo/service/MailService.java b/src/main/java/com/test/demo/service/MailService.java new file mode 100644 index 0000000..5ef5e35 --- /dev/null +++ b/src/main/java/com/test/demo/service/MailService.java @@ -0,0 +1,16 @@ +package com.test.demo.service; + +/** + * 邮件服务接口 + */ +public interface MailService { + + /** 发送简单文本邮件 */ + void sendSimpleMail(String to, String subject, String content); + + /** 发送HTML邮件 */ + void sendHtmlMail(String to, String subject, String htmlContent); + + /** 发送带附件的邮件 */ + void sendAttachmentMail(String to, String subject, String content, String filePath); +} diff --git a/src/main/java/com/test/demo/service/MenuService.java b/src/main/java/com/test/demo/service/MenuService.java new file mode 100644 index 0000000..f78d53e --- /dev/null +++ b/src/main/java/com/test/demo/service/MenuService.java @@ -0,0 +1,25 @@ +package com.test.demo.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.test.demo.bo.MenuBo; +import com.test.demo.entity.MenuEntity; + +import java.util.List; + +/** + * 菜单 Service + */ +public interface MenuService extends IService { + + List listAll(); + + List listCurrentUserMenus(Long userId); + + List listCurrentUserPermissions(Long userId); + + void add(MenuBo bo); + + void edit(MenuBo bo); + + void remove(Long id); +} diff --git a/src/main/java/com/test/demo/service/OperLogService.java b/src/main/java/com/test/demo/service/OperLogService.java new file mode 100644 index 0000000..445f84f --- /dev/null +++ b/src/main/java/com/test/demo/service/OperLogService.java @@ -0,0 +1,19 @@ +package com.test.demo.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.test.demo.bo.OperLogQueryBo; +import com.test.demo.common.base.PageResult; +import com.test.demo.entity.OperLogEntity; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; + +/** + * 操作日志 Service + */ +public interface OperLogService extends IService { + + PageResult page(OperLogQueryBo query); + + void export(OperLogQueryBo query, HttpServletResponse response) throws IOException; +} diff --git a/src/main/java/com/test/demo/service/RoleService.java b/src/main/java/com/test/demo/service/RoleService.java new file mode 100644 index 0000000..15c0edc --- /dev/null +++ b/src/main/java/com/test/demo/service/RoleService.java @@ -0,0 +1,36 @@ +package com.test.demo.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.test.demo.bo.RoleBo; +import com.test.demo.bo.RoleQueryBo; +import com.test.demo.common.base.PageResult; +import com.test.demo.entity.RoleEntity; +import com.test.demo.vo.RoleDetailVo; + +import java.util.List; + +/** + * 角色 Service + */ +public interface RoleService extends IService { + + PageResult page(RoleQueryBo query); + + List listEnabled(); + + List listRoleCodesByUserId(Long userId); + + RoleDetailVo getDetail(Long roleId); + + List listUserRoleIds(Long userId); + + void add(RoleBo bo); + + void edit(RoleBo bo); + + void remove(Long id); + + void assignMenus(Long roleId, List menuIds); + + void assignUserRoles(Long userId, List roleIds); +} diff --git a/src/main/java/com/test/demo/service/UserService.java b/src/main/java/com/test/demo/service/UserService.java new file mode 100644 index 0000000..f6acd29 --- /dev/null +++ b/src/main/java/com/test/demo/service/UserService.java @@ -0,0 +1,38 @@ +package com.test.demo.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.test.demo.common.base.PageResult; +import com.test.demo.bo.UserBo; +import com.test.demo.bo.UserQueryBo; +import com.test.demo.entity.UserEntity; +import com.test.demo.vo.UserImportVo; +import com.test.demo.vo.UserVo; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; + +/** + * 用户 Service + */ +public interface UserService extends IService { + + PageResult page(UserQueryBo query); + + UserVo getDetail(Long id); + + void add(UserBo bo); + + void edit(UserBo bo); + + void remove(Long id); + + void resetPassword(Long id, String newPassword); + + List listExportData(UserQueryBo query); + + void exportUsers(UserQueryBo query, HttpServletResponse response) throws IOException; + + void importUsers(MultipartFile file); +} diff --git a/src/main/java/com/test/demo/service/impl/AuthServiceImpl.java b/src/main/java/com/test/demo/service/impl/AuthServiceImpl.java new file mode 100644 index 0000000..81388b6 --- /dev/null +++ b/src/main/java/com/test/demo/service/impl/AuthServiceImpl.java @@ -0,0 +1,153 @@ +package com.test.demo.service.impl; + +import cn.dev33.satoken.stp.StpUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.test.demo.bo.LoginBo; +import com.test.demo.bo.RegisterBo; +import com.test.demo.common.constant.RedisKeyConstant; +import com.test.demo.common.exception.BizException; +import com.test.demo.common.result.ResultCode; +import com.test.demo.convert.UserConvert; +import com.test.demo.entity.UserEntity; +import com.test.demo.enums.UserStatusEnum; +import com.test.demo.security.SecurityUtil; +import com.test.demo.security.crypto.SM3Util; +import com.test.demo.service.AuthService; +import com.test.demo.service.CaptchaService; +import com.test.demo.service.LoginLogService; +import com.test.demo.service.MenuService; +import com.test.demo.service.RoleService; +import com.test.demo.service.UserService; +import com.test.demo.vo.LoginVo; +import com.test.demo.vo.UserVo; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +/** + * 认证 Service 实现 + */ +@Service +@RequiredArgsConstructor +public class AuthServiceImpl implements AuthService { + + private static final long LOGIN_FAIL_EXPIRE_MINUTES = 15; + + private static final long LOGIN_FAIL_MAX_TIMES = 5; + + private final UserService userService; + private final CaptchaService captchaService; + private final UserConvert userConvert; + private final RedisTemplate redisTemplate; + private final LoginLogService loginLogService; + private final RoleService roleService; + private final MenuService menuService; + + @Override + public LoginVo login(LoginBo bo) { + String loginFailKey = RedisKeyConstant.LOGIN_FAIL + bo.getUsername(); + long failTimes = getLoginFailTimes(loginFailKey); + if (failTimes >= LOGIN_FAIL_MAX_TIMES) { + loginLogService.record(bo.getUsername(), null, "LOGIN", 0, "登录失败次数过多"); + throw new BizException(ResultCode.ACCOUNT_LOCKED, "登录失败次数过多,请15分钟后再试"); + } + if (!captchaService.verify(bo.getCaptchaKey(), bo.getCaptchaCode())) { + loginLogService.record(bo.getUsername(), null, "LOGIN", 0, "验证码错误或已过期"); + throw new BizException(ResultCode.BAD_REQUEST, "验证码错误或已过期"); + } + + UserEntity user = userService.getOne(new LambdaQueryWrapper() + .eq(UserEntity::getUsername, bo.getUsername()) + .last("limit 1")); + if (user == null) { + increaseLoginFailTimes(loginFailKey); + loginLogService.record(bo.getUsername(), null, "LOGIN", 0, "用户名或密码错误"); + throw new BizException(ResultCode.BAD_REQUEST, "用户名或密码错误"); + } + if (!SM3Util.verify(bo.getPassword(), user.getSalt(), user.getPassword())) { + increaseLoginFailTimes(loginFailKey); + loginLogService.record(bo.getUsername(), user.getId(), "LOGIN", 0, "用户名或密码错误"); + throw new BizException(ResultCode.BAD_REQUEST, "用户名或密码错误"); + } + if (user.getStatus() == null || user.getStatus() != UserStatusEnum.NORMAL) { + loginLogService.record(user.getUsername(), user.getId(), "LOGIN", 0, "账号已被禁用"); + throw new BizException(ResultCode.FORBIDDEN, "账号已被禁用"); + } + + SecurityUtil.login(user.getId()); + redisTemplate.delete(loginFailKey); + loginLogService.record(user.getUsername(), user.getId(), "LOGIN", 1, "登录成功"); + + LoginVo vo = new LoginVo(); + vo.setToken(SecurityUtil.getToken()); + vo.setTokenName(StpUtil.getTokenName()); + vo.setUserInfo(userConvert.toVo(user)); + vo.setRoles(roleService.listRoleCodesByUserId(user.getId())); + vo.setPermissions(menuService.listCurrentUserPermissions(user.getId())); + return vo; + } + + @Override + public void register(RegisterBo bo) { + if (!captchaService.verify(bo.getCaptchaKey(), bo.getCaptchaCode())) { + throw new BizException(ResultCode.BAD_REQUEST, "验证码错误或已过期"); + } + long count = userService.count(new LambdaQueryWrapper() + .eq(UserEntity::getUsername, bo.getUsername())); + if (count > 0) { + throw new BizException(ResultCode.CONFLICT, "用户名已存在"); + } + UserEntity user = new UserEntity(); + user.setUsername(bo.getUsername()); + user.setNickname(bo.getNickname()); + user.setEmail(bo.getEmail()); + user.setPhone(bo.getPhone()); + user.setAvatar(bo.getAvatar()); + String salt = SM3Util.generateSalt(); + user.setSalt(salt); + user.setPassword(SM3Util.hash(bo.getPassword(), salt)); + user.setStatus(UserStatusEnum.NORMAL); + userService.save(user); + } + + @Override + public void logout() { + Long userId = SecurityUtil.getUserIdOrNull(); + String username = null; + if (userId != null) { + UserEntity user = userService.getById(userId); + if (user != null) { + username = user.getUsername(); + } + } + loginLogService.record(username, userId, "LOGOUT", 1, "退出登录"); + SecurityUtil.logout(); + } + + @Override + public UserVo getCurrentUser() { + Long userId = SecurityUtil.getUserId(); + UserEntity user = userService.getById(userId); + if (user == null) { + throw new BizException(ResultCode.NOT_FOUND, "当前用户不存在"); + } + return userConvert.toVo(user); + } + + private long getLoginFailTimes(String key) { + Object value = redisTemplate.opsForValue().get(key); + if (value == null) { + return 0L; + } + return Long.parseLong(value.toString()); + } + + private void increaseLoginFailTimes(String key) { + Long count = redisTemplate.opsForValue().increment(key); + if (count != null && count == 1L) { + redisTemplate.expire(key, LOGIN_FAIL_EXPIRE_MINUTES, TimeUnit.MINUTES); + } + } +} diff --git a/src/main/java/com/test/demo/service/impl/EnumItemServiceImpl.java b/src/main/java/com/test/demo/service/impl/EnumItemServiceImpl.java new file mode 100644 index 0000000..8914bdb --- /dev/null +++ b/src/main/java/com/test/demo/service/impl/EnumItemServiceImpl.java @@ -0,0 +1,193 @@ +package com.test.demo.service.impl; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.test.demo.bo.EnumItemBo; +import com.test.demo.common.constant.EnumSourceType; +import com.test.demo.common.exception.BizException; +import com.test.demo.common.result.ResultCode; +import com.test.demo.entity.EnumItemEntity; +import com.test.demo.entity.EnumTypeEntity; +import com.test.demo.mapper.EnumItemMapper; +import com.test.demo.mapper.EnumTypeMapper; +import com.test.demo.service.EnumItemService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 枚举/字典项 Service 实现 + */ +@Service +@RequiredArgsConstructor +public class EnumItemServiceImpl extends ServiceImpl implements EnumItemService { + + private final EnumTypeMapper enumTypeMapper; + + @Override + public List> listEnabledForApi(String enumCode) { + EnumTypeEntity type = enumTypeMapper.selectOne(new LambdaQueryWrapper() + .eq(EnumTypeEntity::getEnumCode, enumCode) + .last("limit 1")); + if (type == null || type.getEnabled() == null || type.getEnabled() != 1) { + return new ArrayList<>(); + } + List list = list(new LambdaQueryWrapper() + .eq(EnumItemEntity::getEnumCode, enumCode) + .eq(EnumItemEntity::getEnabled, 1) + .orderByAsc(EnumItemEntity::getSort) + .orderByAsc(EnumItemEntity::getId)); + List> result = new ArrayList<>(list.size()); + for (EnumItemEntity entity : list) { + Map item = new LinkedHashMap<>(); + item.put("value", entity.getItemValue()); + item.put("desc", entity.getItemLabel()); + result.add(item); + } + return result; + } + + @Override + public List listManageItems(String enumCode) { + return list(new LambdaQueryWrapper() + .eq(EnumItemEntity::getEnumCode, enumCode) + .orderByAsc(EnumItemEntity::getSort) + .orderByAsc(EnumItemEntity::getId)); + } + + @Override + public void add(EnumItemBo bo) { + EnumTypeEntity type = enumTypeMapper.selectOne(new LambdaQueryWrapper() + .eq(EnumTypeEntity::getEnumCode, bo.getEnumCode()) + .last("limit 1")); + if (type == null) { + throw new BizException(ResultCode.NOT_FOUND, "枚举类型不存在"); + } + long count = count(new LambdaQueryWrapper() + .eq(EnumItemEntity::getEnumCode, bo.getEnumCode()) + .eq(EnumItemEntity::getItemValue, bo.getItemValue())); + if (count > 0) { + throw new BizException(ResultCode.CONFLICT, "字典值已存在"); + } + EnumItemEntity entity = new EnumItemEntity(); + entity.setEnumCode(bo.getEnumCode()); + entity.setItemValue(bo.getItemValue()); + entity.setItemLabel(bo.getItemLabel()); + entity.setItemName(bo.getItemName()); + entity.setSourceType(EnumSourceType.CUSTOM); + entity.setBuiltin(0); + entity.setEnabled(bo.getEnabled() == null ? 1 : bo.getEnabled()); + entity.setEditable(1); + entity.setDeletable(1); + entity.setSort(bo.getSort() == null ? 0 : bo.getSort()); + entity.setColor(bo.getColor()); + entity.setTagType(bo.getTagType()); + entity.setRemark(bo.getRemark()); + save(entity); + } + + @Override + public void edit(EnumItemBo bo) { + EnumItemEntity entity = getById(bo.getId()); + if (entity == null) { + throw new BizException(ResultCode.NOT_FOUND, "枚举项不存在"); + } + if (!entity.getEnumCode().equals(bo.getEnumCode())) { + throw new BizException(ResultCode.BAD_REQUEST, "不允许变更枚举编码"); + } + if (!EnumSourceType.SYSTEM.equals(entity.getSourceType()) && StrUtil.isNotBlank(bo.getItemValue()) + && !entity.getItemValue().equals(bo.getItemValue())) { + long count = count(new LambdaQueryWrapper() + .eq(EnumItemEntity::getEnumCode, bo.getEnumCode()) + .eq(EnumItemEntity::getItemValue, bo.getItemValue()) + .ne(EnumItemEntity::getId, bo.getId())); + if (count > 0) { + throw new BizException(ResultCode.CONFLICT, "字典值已存在"); + } + entity.setItemValue(bo.getItemValue()); + } + entity.setItemLabel(bo.getItemLabel()); + if (!EnumSourceType.SYSTEM.equals(entity.getSourceType())) { + entity.setItemName(bo.getItemName()); + } + entity.setEnabled(bo.getEnabled() == null ? entity.getEnabled() : bo.getEnabled()); + entity.setSort(bo.getSort() == null ? entity.getSort() : bo.getSort()); + entity.setColor(bo.getColor()); + entity.setTagType(bo.getTagType()); + entity.setRemark(bo.getRemark()); + updateById(entity); + } + + @Override + public void changeEnabled(Long id, Integer enabled) { + EnumItemEntity entity = getById(id); + if (entity == null) { + throw new BizException(ResultCode.NOT_FOUND, "枚举项不存在"); + } + if (enabled == null || (enabled != 0 && enabled != 1)) { + throw new BizException(ResultCode.BAD_REQUEST, "enabled 只能是 0 或 1"); + } + entity.setEnabled(enabled); + updateById(entity); + } + + @Override + public void remove(Long id) { + EnumItemEntity entity = getById(id); + if (entity == null) { + throw new BizException(ResultCode.NOT_FOUND, "枚举项不存在"); + } + if (entity.getDeletable() != null && entity.getDeletable() == 0) { + throw new BizException(ResultCode.FORBIDDEN, "系统枚举项不允许删除"); + } + removeById(id); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void syncSystemItems(String enumCode, List items, LocalDateTime syncTime) { + Map existingMap = list(new LambdaQueryWrapper() + .eq(EnumItemEntity::getEnumCode, enumCode)) + .stream() + .collect(Collectors.toMap(EnumItemEntity::getItemValue, Function.identity(), (a, b) -> a)); + + List currentValues = new ArrayList<>(items.size()); + for (EnumItemEntity item : items) { + currentValues.add(item.getItemValue()); + EnumItemEntity existing = existingMap.get(item.getItemValue()); + if (existing == null) { + item.setLastSyncTime(syncTime); + save(item); + continue; + } + if (EnumSourceType.CUSTOM.equals(existing.getSourceType())) { + continue; + } + existing.setItemName(item.getItemName()); + if (StrUtil.isBlank(existing.getItemLabel())) { + existing.setItemLabel(item.getItemLabel()); + } + existing.setLastSyncTime(syncTime); + updateById(existing); + } + + List staleItems = list(new LambdaQueryWrapper() + .eq(EnumItemEntity::getEnumCode, enumCode) + .eq(EnumItemEntity::getSourceType, EnumSourceType.SYSTEM) + .notIn(!currentValues.isEmpty(), EnumItemEntity::getItemValue, currentValues)); + for (EnumItemEntity staleItem : staleItems) { + staleItem.setEnabled(0); + staleItem.setLastSyncTime(syncTime); + updateById(staleItem); + } + } +} diff --git a/src/main/java/com/test/demo/service/impl/EnumTypeServiceImpl.java b/src/main/java/com/test/demo/service/impl/EnumTypeServiceImpl.java new file mode 100644 index 0000000..21a2a3b --- /dev/null +++ b/src/main/java/com/test/demo/service/impl/EnumTypeServiceImpl.java @@ -0,0 +1,159 @@ +package com.test.demo.service.impl; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.test.demo.bo.EnumTypeBo; +import com.test.demo.bo.EnumTypeQueryBo; +import com.test.demo.common.base.PageResult; +import com.test.demo.common.constant.EnumSourceType; +import com.test.demo.common.exception.BizException; +import com.test.demo.common.result.ResultCode; +import com.test.demo.entity.EnumItemEntity; +import com.test.demo.entity.EnumTypeEntity; +import com.test.demo.mapper.EnumTypeMapper; +import com.test.demo.service.EnumItemService; +import com.test.demo.service.EnumTypeService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 枚举/字典类型 Service 实现 + */ +@Service +@RequiredArgsConstructor +public class EnumTypeServiceImpl extends ServiceImpl implements EnumTypeService { + + private final EnumItemService enumItemService; + + @Override + public EnumTypeEntity getByEnumCode(String enumCode) { + return getOne(new LambdaQueryWrapper() + .eq(EnumTypeEntity::getEnumCode, enumCode) + .last("limit 1")); + } + + @Override + public List> listEnabledForApi() { + List list = list(new LambdaQueryWrapper() + .eq(EnumTypeEntity::getEnabled, 1) + .orderByAsc(EnumTypeEntity::getSort) + .orderByAsc(EnumTypeEntity::getId)); + List> result = new ArrayList<>(list.size()); + for (EnumTypeEntity entity : list) { + Map item = new LinkedHashMap<>(); + item.put("enumName", entity.getEnumCode()); + item.put("enumDesc", entity.getEnumDesc()); + item.put("displayName", StrUtil.blankToDefault(entity.getEnumName(), entity.getEnumDesc())); + item.put("className", entity.getClassName()); + item.put("packageName", entity.getPackageName()); + item.put("valueType", entity.getValueType()); + item.put("sourceType", entity.getSourceType()); + item.put("builtin", entity.getBuiltin()); + item.put("queryPath", "/api/enums/" + entity.getEnumCode()); + result.add(item); + } + return result; + } + + @Override + public PageResult page(EnumTypeQueryBo query) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .like(StrUtil.isNotBlank(query.getEnumCode()), EnumTypeEntity::getEnumCode, query.getEnumCode()) + .and(StrUtil.isNotBlank(query.getKeyword()), q -> q + .like(EnumTypeEntity::getEnumName, query.getKeyword()) + .or() + .like(EnumTypeEntity::getEnumDesc, query.getKeyword())) + .eq(StrUtil.isNotBlank(query.getSourceType()), EnumTypeEntity::getSourceType, query.getSourceType()) + .eq(query.getEnabled() != null, EnumTypeEntity::getEnabled, query.getEnabled()) + .orderByAsc(EnumTypeEntity::getSort) + .orderByAsc(EnumTypeEntity::getId); + + Page page = page(new Page<>(query.getPageNum(), query.getPageSize()), wrapper); + return PageResult.of(page.getRecords(), page.getTotal(), query.getPageNum(), query.getPageSize()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void add(EnumTypeBo bo) { + if (getByEnumCode(bo.getEnumCode()) != null) { + throw new BizException(ResultCode.CONFLICT, "枚举编码已存在"); + } + EnumTypeEntity entity = new EnumTypeEntity(); + entity.setEnumCode(bo.getEnumCode()); + entity.setEnumName(bo.getEnumName()); + entity.setEnumDesc(bo.getEnumDesc()); + entity.setSourceType(EnumSourceType.CUSTOM); + entity.setBuiltin(0); + entity.setEnabled(bo.getEnabled() == null ? 1 : bo.getEnabled()); + entity.setSyncEnabled(0); + entity.setSort(bo.getSort() == null ? 0 : bo.getSort()); + entity.setRemark(bo.getRemark()); + save(entity); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void edit(EnumTypeBo bo) { + EnumTypeEntity entity = getById(bo.getId()); + if (entity == null) { + throw new BizException(ResultCode.NOT_FOUND, "枚举类型不存在"); + } + String oldEnumCode = entity.getEnumCode(); + entity.setEnumName(bo.getEnumName()); + if (!EnumSourceType.SYSTEM.equals(entity.getSourceType())) { + EnumTypeEntity exists = getByEnumCode(bo.getEnumCode()); + if (exists != null && !exists.getId().equals(bo.getId())) { + throw new BizException(ResultCode.CONFLICT, "枚举编码已存在"); + } + entity.setEnumCode(bo.getEnumCode()); + entity.setEnumDesc(bo.getEnumDesc()); + } + entity.setEnabled(bo.getEnabled() == null ? entity.getEnabled() : bo.getEnabled()); + entity.setSyncEnabled(bo.getSyncEnabled() == null ? entity.getSyncEnabled() : bo.getSyncEnabled()); + entity.setSort(bo.getSort() == null ? entity.getSort() : bo.getSort()); + entity.setRemark(bo.getRemark()); + updateById(entity); + if (!EnumSourceType.SYSTEM.equals(entity.getSourceType()) && !oldEnumCode.equals(entity.getEnumCode())) { + enumItemService.update(new LambdaUpdateWrapper() + .eq(EnumItemEntity::getEnumCode, oldEnumCode) + .set(EnumItemEntity::getEnumCode, entity.getEnumCode())); + } + } + + @Override + public void changeEnabled(Long id, Integer enabled) { + EnumTypeEntity entity = getById(id); + if (entity == null) { + throw new BizException(ResultCode.NOT_FOUND, "枚举类型不存在"); + } + if (enabled == null || (enabled != 0 && enabled != 1)) { + throw new BizException(ResultCode.BAD_REQUEST, "enabled 只能是 0 或 1"); + } + entity.setEnabled(enabled); + updateById(entity); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void remove(Long id) { + EnumTypeEntity entity = getById(id); + if (entity == null) { + throw new BizException(ResultCode.NOT_FOUND, "枚举类型不存在"); + } + if (EnumSourceType.SYSTEM.equals(entity.getSourceType())) { + throw new BizException(ResultCode.FORBIDDEN, "系统枚举不允许删除"); + } + removeById(id); + enumItemService.remove(new LambdaQueryWrapper() + .eq(com.test.demo.entity.EnumItemEntity::getEnumCode, entity.getEnumCode())); + } +} diff --git a/src/main/java/com/test/demo/service/impl/FileServiceImpl.java b/src/main/java/com/test/demo/service/impl/FileServiceImpl.java new file mode 100644 index 0000000..e26e525 --- /dev/null +++ b/src/main/java/com/test/demo/service/impl/FileServiceImpl.java @@ -0,0 +1,156 @@ +package com.test.demo.service.impl; + +import com.test.demo.bo.FileQueryBo; +import com.test.demo.common.base.PageResult; +import com.test.demo.common.exception.BizException; +import com.test.demo.common.result.ResultCode; +import com.test.demo.config.FileConfig; +import com.test.demo.service.FileService; +import com.test.demo.util.FilePathUtil; +import com.test.demo.vo.FileInfoVo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.BasicFileAttributes; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * 文件服务实现 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class FileServiceImpl implements FileService { + + private final FileConfig fileConfig; + + @Override + public String upload(MultipartFile file) { + if (file.isEmpty()) { + throw new BizException(ResultCode.BAD_REQUEST, "上传文件不能为空"); + } + + String originalName = file.getOriginalFilename(); + String ext = FilePathUtil.getExtension(originalName); + + if (!FilePathUtil.isAllowedExtension(ext, fileConfig.getAllowedExtensions().toArray(new String[0]))) { + throw new BizException(ResultCode.UNSUPPORTED_MEDIA_TYPE, "不支持的文件类型: " + ext); + } + + // 按日期分目录 + String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); + String fileName = UUID.randomUUID().toString().replace("-", "") + "." + ext; + String relativePath = datePath + "/" + fileName; + + Path targetPath = Paths.get(fileConfig.getUploadPath(), relativePath); + try { + Files.createDirectories(targetPath.getParent()); + file.transferTo(targetPath.toFile()); + log.info("文件上传成功: {}", relativePath); + return relativePath; + } catch (IOException e) { + log.error("文件上传失败", e); + throw new BizException(ResultCode.INTERNAL_ERROR, "文件上传失败"); + } + } + + @Override + public String getFilePath(String relativePath) { + if (!FilePathUtil.isSafePath(fileConfig.getUploadPath(), relativePath)) { + throw new BizException(ResultCode.BAD_REQUEST, "非法文件路径"); + } + return Paths.get(fileConfig.getUploadPath(), relativePath).toString(); + } + + @Override + public PageResult list(FileQueryBo query) { + FileQueryBo actualQuery = query == null ? new FileQueryBo() : query; + String safeDir = actualQuery.getRelativeDir() == null ? "" : actualQuery.getRelativeDir().trim(); + if (!safeDir.isEmpty() && !FilePathUtil.isSafePath(fileConfig.getUploadPath(), safeDir)) { + throw new BizException(ResultCode.BAD_REQUEST, "非法目录路径"); + } + Path dirPath = safeDir.isEmpty() ? Paths.get(fileConfig.getUploadPath()) : Paths.get(fileConfig.getUploadPath(), safeDir); + if (!Files.exists(dirPath)) { + throw new BizException(ResultCode.NOT_FOUND, "目录不存在"); + } + if (!Files.isDirectory(dirPath)) { + throw new BizException(ResultCode.BAD_REQUEST, "目标不是目录"); + } + boolean recursive = Boolean.TRUE.equals(actualQuery.getRecursive()); + try (Stream stream = recursive ? Files.walk(dirPath) : Files.list(dirPath)) { + List all = stream + .filter(path -> !path.equals(dirPath)) + .sorted(Comparator.comparing((Path path) -> !Files.isDirectory(path)) + .thenComparing(path -> toRelativePath(path))) + .map(this::toFileInfo) + .collect(Collectors.toList()); + if (all.isEmpty()) { + return PageResult.empty(actualQuery.getPageNum(), actualQuery.getPageSize()); + } + int pageNum = actualQuery.getPageNum(); + int pageSize = actualQuery.getPageSize(); + int fromIndex = Math.max((pageNum - 1) * pageSize, 0); + if (fromIndex >= all.size()) { + return PageResult.empty(pageNum, pageSize); + } + int toIndex = Math.min(fromIndex + pageSize, all.size()); + return PageResult.of(new ArrayList<>(all.subList(fromIndex, toIndex)), (long) all.size(), pageNum, pageSize); + } catch (IOException e) { + log.error("文件列表查询失败: {}", safeDir, e); + throw new BizException(ResultCode.INTERNAL_ERROR, "文件列表查询失败"); + } + } + + @Override + public boolean delete(String relativePath) { + if (!FilePathUtil.isSafePath(fileConfig.getUploadPath(), relativePath)) { + throw new BizException(ResultCode.BAD_REQUEST, "非法文件路径"); + } + try { + Path path = Paths.get(fileConfig.getUploadPath(), relativePath); + return Files.deleteIfExists(path); + } catch (IOException e) { + log.error("文件删除失败: {}", relativePath, e); + return false; + } + } + + private FileInfoVo toFileInfo(Path path) { + FileInfoVo vo = new FileInfoVo(); + String relativePath = toRelativePath(path); + vo.setFileName(path.getFileName().toString()); + vo.setRelativePath(relativePath); + vo.setDirectory(Files.isDirectory(path)); + vo.setExtension(Files.isDirectory(path) ? null : FilePathUtil.getExtension(path.getFileName().toString())); + vo.setPreviewUrl(Files.isDirectory(path) ? null : "/api/file/preview/" + relativePath); + vo.setDownloadUrl(Files.isDirectory(path) ? null : "/api/file/download/" + relativePath); + try { + BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class); + vo.setSize(attrs.isDirectory() ? 0L : attrs.size()); + vo.setLastModified(LocalDateTime.ofInstant(attrs.lastModifiedTime().toInstant(), ZoneId.systemDefault())); + } catch (IOException ignored) { + vo.setSize(0L); + } + return vo; + } + + private String toRelativePath(Path path) { + Path root = Paths.get(fileConfig.getUploadPath()); + return root.relativize(path).toString().replace('\\', '/'); + } +} diff --git a/src/main/java/com/test/demo/service/impl/LoginLogServiceImpl.java b/src/main/java/com/test/demo/service/impl/LoginLogServiceImpl.java new file mode 100644 index 0000000..39369fd --- /dev/null +++ b/src/main/java/com/test/demo/service/impl/LoginLogServiceImpl.java @@ -0,0 +1,112 @@ +package com.test.demo.service.impl; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.test.demo.bo.LoginLogQueryBo; +import com.test.demo.common.base.PageResult; +import com.test.demo.entity.LoginLogEntity; +import com.test.demo.mapper.LoginLogMapper; +import com.test.demo.service.LoginLogService; +import com.test.demo.util.ExcelUtil; +import com.test.demo.vo.LoginLogExportVo; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 登录日志 Service 实现 + */ +@Slf4j +@Service +public class LoginLogServiceImpl extends ServiceImpl implements LoginLogService { + + @Override + public PageResult page(LoginLogQueryBo query) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .like(StrUtil.isNotBlank(query.getUsername()), LoginLogEntity::getUsername, query.getUsername()) + .eq(StrUtil.isNotBlank(query.getLogType()), LoginLogEntity::getLogType, query.getLogType()) + .eq(query.getStatus() != null, LoginLogEntity::getStatus, query.getStatus()) + .orderByDesc(LoginLogEntity::getCreateTime); + Page page = page(new Page<>(query.getPageNum(), query.getPageSize()), wrapper); + return PageResult.of(page.getRecords(), page.getTotal(), query.getPageNum(), query.getPageSize()); + } + + @Override + public void export(LoginLogQueryBo query, HttpServletResponse response) throws IOException { + List entities = list(new LambdaQueryWrapper() + .like(StrUtil.isNotBlank(query.getUsername()), LoginLogEntity::getUsername, query.getUsername()) + .eq(StrUtil.isNotBlank(query.getLogType()), LoginLogEntity::getLogType, query.getLogType()) + .eq(query.getStatus() != null, LoginLogEntity::getStatus, query.getStatus()) + .orderByDesc(LoginLogEntity::getCreateTime)); + List data = new ArrayList<>(entities.size()); + for (LoginLogEntity entity : entities) { + LoginLogExportVo vo = new LoginLogExportVo(); + vo.setUsername(entity.getUsername()); + vo.setUserId(entity.getUserId()); + vo.setLogType(entity.getLogType()); + vo.setStatus(entity.getStatus()); + vo.setMessage(entity.getMessage()); + vo.setIp(entity.getIp()); + vo.setRequestUrl(entity.getRequestUrl()); + vo.setUserAgent(entity.getUserAgent()); + vo.setCreateTime(entity.getCreateTime()); + data.add(vo); + } + ExcelUtil.export(response, "login-log", "登录日志", LoginLogExportVo.class, data); + } + + @Override + public void record(String username, Long userId, String logType, Integer status, String message) { + try { + LoginLogEntity entity = new LoginLogEntity(); + entity.setUsername(username); + entity.setUserId(userId); + entity.setLogType(logType); + entity.setStatus(status); + entity.setMessage(message); + entity.setCreateTime(LocalDateTime.now()); + + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes != null) { + HttpServletRequest request = attributes.getRequest(); + entity.setIp(getClientIp(request)); + entity.setUserAgent(truncate(request.getHeader("User-Agent"), 500)); + entity.setRequestUrl(request.getRequestURI()); + } + save(entity); + } catch (Exception e) { + log.error("写入登录日志失败", e); + } + } + + private String getClientIp(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("X-Real-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + if (ip != null && ip.contains(",")) { + ip = ip.split(",")[0].trim(); + } + return ip; + } + + private String truncate(String str, int maxLen) { + if (str == null) { + return null; + } + return str.length() > maxLen ? str.substring(0, maxLen) : str; + } +} diff --git a/src/main/java/com/test/demo/service/impl/MailServiceImpl.java b/src/main/java/com/test/demo/service/impl/MailServiceImpl.java new file mode 100644 index 0000000..a8e0d6e --- /dev/null +++ b/src/main/java/com/test/demo/service/impl/MailServiceImpl.java @@ -0,0 +1,77 @@ +package com.test.demo.service.impl; + +import com.test.demo.service.MailService; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.io.File; + +/** + * 邮件服务实现 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class MailServiceImpl implements MailService { + + private final JavaMailSender mailSender; + + @Value("${spring.mail.username:noreply@example.com}") + private String from; + + @Async("asyncExecutor") + @Override + public void sendSimpleMail(String to, String subject, String content) { + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(from); + message.setTo(to); + message.setSubject(subject); + message.setText(content); + mailSender.send(message); + log.info("简单邮件发送成功: to={}", to); + } + + @Async("asyncExecutor") + @Override + public void sendHtmlMail(String to, String subject, String htmlContent) { + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true); + helper.setFrom(from); + helper.setTo(to); + helper.setSubject(subject); + helper.setText(htmlContent, true); + mailSender.send(message); + log.info("HTML邮件发送成功: to={}", to); + } catch (MessagingException e) { + log.error("HTML邮件发送失败: to={}", to, e); + } + } + + @Async("asyncExecutor") + @Override + public void sendAttachmentMail(String to, String subject, String content, String filePath) { + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true); + helper.setFrom(from); + helper.setTo(to); + helper.setSubject(subject); + helper.setText(content); + File file = new File(filePath); + helper.addAttachment(file.getName(), file); + mailSender.send(message); + log.info("附件邮件发送成功: to={}", to); + } catch (MessagingException e) { + log.error("附件邮件发送失败: to={}", to, e); + } + } +} diff --git a/src/main/java/com/test/demo/service/impl/MenuServiceImpl.java b/src/main/java/com/test/demo/service/impl/MenuServiceImpl.java new file mode 100644 index 0000000..aaac014 --- /dev/null +++ b/src/main/java/com/test/demo/service/impl/MenuServiceImpl.java @@ -0,0 +1,95 @@ +package com.test.demo.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.test.demo.bo.MenuBo; +import com.test.demo.common.exception.BizException; +import com.test.demo.common.result.ResultCode; +import com.test.demo.entity.MenuEntity; +import com.test.demo.entity.RoleMenuEntity; +import com.test.demo.mapper.MenuMapper; +import com.test.demo.mapper.RoleMenuMapper; +import com.test.demo.service.MenuService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 菜单 Service 实现 + */ +@Service +@RequiredArgsConstructor +public class MenuServiceImpl extends ServiceImpl implements MenuService { + + private final RoleMenuMapper roleMenuMapper; + + @Override + public List listAll() { + return list(new LambdaQueryWrapper() + .orderByAsc(MenuEntity::getSort) + .orderByAsc(MenuEntity::getId)); + } + + @Override + public List listCurrentUserMenus(Long userId) { + return baseMapper.selectMenusByUserId(userId); + } + + @Override + public List listCurrentUserPermissions(Long userId) { + return baseMapper.selectPermissionsByUserId(userId); + } + + @Override + public void add(MenuBo bo) { + MenuEntity entity = new MenuEntity(); + entity.setParentId(bo.getParentId() == null ? 0L : bo.getParentId()); + entity.setMenuType(bo.getMenuType()); + entity.setMenuName(bo.getMenuName()); + entity.setPath(bo.getPath()); + entity.setComponent(bo.getComponent()); + entity.setPermission(bo.getPermission()); + entity.setIcon(bo.getIcon()); + entity.setVisible(bo.getVisible() == null ? 1 : bo.getVisible()); + entity.setEnabled(bo.getEnabled() == null ? 1 : bo.getEnabled()); + entity.setSort(bo.getSort() == null ? 0 : bo.getSort()); + entity.setRemark(bo.getRemark()); + save(entity); + } + + @Override + public void edit(MenuBo bo) { + MenuEntity entity = getById(bo.getId()); + if (entity == null) { + throw new BizException(ResultCode.NOT_FOUND, "菜单不存在"); + } + entity.setParentId(bo.getParentId() == null ? 0L : bo.getParentId()); + entity.setMenuType(bo.getMenuType()); + entity.setMenuName(bo.getMenuName()); + entity.setPath(bo.getPath()); + entity.setComponent(bo.getComponent()); + entity.setPermission(bo.getPermission()); + entity.setIcon(bo.getIcon()); + entity.setVisible(bo.getVisible() == null ? entity.getVisible() : bo.getVisible()); + entity.setEnabled(bo.getEnabled() == null ? entity.getEnabled() : bo.getEnabled()); + entity.setSort(bo.getSort() == null ? entity.getSort() : bo.getSort()); + entity.setRemark(bo.getRemark()); + updateById(entity); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void remove(Long id) { + if (getById(id) == null) { + throw new BizException(ResultCode.NOT_FOUND, "菜单不存在"); + } + long childCount = count(new LambdaQueryWrapper().eq(MenuEntity::getParentId, id)); + if (childCount > 0) { + throw new BizException(ResultCode.FORBIDDEN, "请先删除子菜单"); + } + removeById(id); + roleMenuMapper.delete(new LambdaQueryWrapper().eq(RoleMenuEntity::getMenuId, id)); + } +} diff --git a/src/main/java/com/test/demo/service/impl/OperLogServiceImpl.java b/src/main/java/com/test/demo/service/impl/OperLogServiceImpl.java new file mode 100644 index 0000000..cc437f8 --- /dev/null +++ b/src/main/java/com/test/demo/service/impl/OperLogServiceImpl.java @@ -0,0 +1,66 @@ +package com.test.demo.service.impl; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.test.demo.bo.OperLogQueryBo; +import com.test.demo.common.base.PageResult; +import com.test.demo.entity.OperLogEntity; +import com.test.demo.mapper.OperLogMapper; +import com.test.demo.service.OperLogService; +import com.test.demo.util.ExcelUtil; +import com.test.demo.vo.OperLogExportVo; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * 操作日志 Service 实现 + */ +@Slf4j +@Service +public class OperLogServiceImpl extends ServiceImpl implements OperLogService { + + @Override + public PageResult page(OperLogQueryBo query) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .like(StrUtil.isNotBlank(query.getTitle()), OperLogEntity::getTitle, query.getTitle()) + .eq(StrUtil.isNotBlank(query.getLogType()), OperLogEntity::getLogType, query.getLogType()) + .like(StrUtil.isNotBlank(query.getUserName()), OperLogEntity::getUserName, query.getUserName()) + .eq(query.getStatus() != null, OperLogEntity::getStatus, query.getStatus()) + .orderByDesc(OperLogEntity::getCreateTime); + Page page = page(new Page<>(query.getPageNum(), query.getPageSize()), wrapper); + return PageResult.of(page.getRecords(), page.getTotal(), query.getPageNum(), query.getPageSize()); + } + + @Override + public void export(OperLogQueryBo query, HttpServletResponse response) throws IOException { + List entities = list(new LambdaQueryWrapper() + .like(StrUtil.isNotBlank(query.getTitle()), OperLogEntity::getTitle, query.getTitle()) + .eq(StrUtil.isNotBlank(query.getLogType()), OperLogEntity::getLogType, query.getLogType()) + .like(StrUtil.isNotBlank(query.getUserName()), OperLogEntity::getUserName, query.getUserName()) + .eq(query.getStatus() != null, OperLogEntity::getStatus, query.getStatus()) + .orderByDesc(OperLogEntity::getCreateTime)); + List data = new ArrayList<>(entities.size()); + for (OperLogEntity entity : entities) { + OperLogExportVo vo = new OperLogExportVo(); + vo.setTitle(entity.getTitle()); + vo.setLogType(entity.getLogType()); + vo.setUserName(entity.getUserName()); + vo.setIp(entity.getIp()); + vo.setRequestMethod(entity.getRequestMethod()); + vo.setRequestUrl(entity.getRequestUrl()); + vo.setStatus(entity.getStatus()); + vo.setErrorMsg(entity.getErrorMsg()); + vo.setCostTime(entity.getCostTime()); + vo.setCreateTime(entity.getCreateTime()); + data.add(vo); + } + ExcelUtil.export(response, "oper-log", "操作日志", OperLogExportVo.class, data); + } +} diff --git a/src/main/java/com/test/demo/service/impl/RoleServiceImpl.java b/src/main/java/com/test/demo/service/impl/RoleServiceImpl.java new file mode 100644 index 0000000..c6d1460 --- /dev/null +++ b/src/main/java/com/test/demo/service/impl/RoleServiceImpl.java @@ -0,0 +1,180 @@ +package com.test.demo.service.impl; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.test.demo.bo.RoleBo; +import com.test.demo.bo.RoleQueryBo; +import com.test.demo.common.base.PageResult; +import com.test.demo.common.exception.BizException; +import com.test.demo.common.result.ResultCode; +import com.test.demo.entity.RoleEntity; +import com.test.demo.entity.RoleMenuEntity; +import com.test.demo.entity.UserEntity; +import com.test.demo.entity.UserRoleEntity; +import com.test.demo.mapper.RoleMapper; +import com.test.demo.mapper.RoleMenuMapper; +import com.test.demo.mapper.UserRoleMapper; +import com.test.demo.service.RoleService; +import com.test.demo.service.UserService; +import com.test.demo.vo.RoleDetailVo; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 角色 Service 实现 + */ +@Service +@RequiredArgsConstructor +public class RoleServiceImpl extends ServiceImpl implements RoleService { + + private final RoleMenuMapper roleMenuMapper; + private final UserRoleMapper userRoleMapper; + private final UserService userService; + + @Override + public PageResult page(RoleQueryBo query) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .and(StrUtil.isNotBlank(query.getKeyword()), q -> q + .like(RoleEntity::getRoleCode, query.getKeyword()) + .or() + .like(RoleEntity::getRoleName, query.getKeyword())) + .eq(query.getEnabled() != null, RoleEntity::getEnabled, query.getEnabled()) + .orderByAsc(RoleEntity::getSort) + .orderByAsc(RoleEntity::getId); + Page page = page(new Page<>(query.getPageNum(), query.getPageSize()), wrapper); + return PageResult.of(page.getRecords(), page.getTotal(), query.getPageNum(), query.getPageSize()); + } + + @Override + public List listEnabled() { + return list(new LambdaQueryWrapper() + .eq(RoleEntity::getEnabled, 1) + .orderByAsc(RoleEntity::getSort) + .orderByAsc(RoleEntity::getId)); + } + + @Override + public List listRoleCodesByUserId(Long userId) { + return baseMapper.selectRoleCodesByUserId(userId); + } + + @Override + public RoleDetailVo getDetail(Long roleId) { + RoleEntity role = getById(roleId); + if (role == null) { + throw new BizException(ResultCode.NOT_FOUND, "角色不存在"); + } + List menuIds = roleMenuMapper.selectList(new LambdaQueryWrapper() + .eq(RoleMenuEntity::getRoleId, roleId)) + .stream() + .map(RoleMenuEntity::getMenuId) + .collect(Collectors.toList()); + RoleDetailVo vo = new RoleDetailVo(); + vo.setRole(role); + vo.setMenuIds(menuIds); + return vo; + } + + @Override + public List listUserRoleIds(Long userId) { + return userRoleMapper.selectList(new LambdaQueryWrapper() + .eq(UserRoleEntity::getUserId, userId)) + .stream() + .map(UserRoleEntity::getRoleId) + .collect(Collectors.toList()); + } + + @Override + public void add(RoleBo bo) { + long count = count(new LambdaQueryWrapper() + .eq(RoleEntity::getRoleCode, bo.getRoleCode())); + if (count > 0) { + throw new BizException(ResultCode.CONFLICT, "角色编码已存在"); + } + RoleEntity entity = new RoleEntity(); + entity.setRoleCode(bo.getRoleCode()); + entity.setRoleName(bo.getRoleName()); + entity.setEnabled(bo.getEnabled() == null ? 1 : bo.getEnabled()); + entity.setSort(bo.getSort() == null ? 0 : bo.getSort()); + entity.setRemark(bo.getRemark()); + save(entity); + } + + @Override + public void edit(RoleBo bo) { + RoleEntity entity = getById(bo.getId()); + if (entity == null) { + throw new BizException(ResultCode.NOT_FOUND, "角色不存在"); + } + long count = count(new LambdaQueryWrapper() + .eq(RoleEntity::getRoleCode, bo.getRoleCode()) + .ne(RoleEntity::getId, bo.getId())); + if (count > 0) { + throw new BizException(ResultCode.CONFLICT, "角色编码已存在"); + } + entity.setRoleCode(bo.getRoleCode()); + entity.setRoleName(bo.getRoleName()); + entity.setEnabled(bo.getEnabled() == null ? entity.getEnabled() : bo.getEnabled()); + entity.setSort(bo.getSort() == null ? entity.getSort() : bo.getSort()); + entity.setRemark(bo.getRemark()); + updateById(entity); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void remove(Long id) { + if (getById(id) == null) { + throw new BizException(ResultCode.NOT_FOUND, "角色不存在"); + } + removeById(id); + roleMenuMapper.delete(new LambdaQueryWrapper().eq(RoleMenuEntity::getRoleId, id)); + userRoleMapper.delete(new LambdaQueryWrapper().eq(UserRoleEntity::getRoleId, id)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void assignMenus(Long roleId, List menuIds) { + if (getById(roleId) == null) { + throw new BizException(ResultCode.NOT_FOUND, "角色不存在"); + } + roleMenuMapper.delete(new LambdaQueryWrapper().eq(RoleMenuEntity::getRoleId, roleId)); + if (CollUtil.isEmpty(menuIds)) { + return; + } + for (Long menuId : menuIds) { + RoleMenuEntity entity = new RoleMenuEntity(); + entity.setRoleId(roleId); + entity.setMenuId(menuId); + roleMenuMapper.insert(entity); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void assignUserRoles(Long userId, List roleIds) { + UserEntity user = userService.getById(userId); + if (user == null) { + throw new BizException(ResultCode.NOT_FOUND, "用户不存在"); + } + userRoleMapper.delete(new LambdaQueryWrapper().eq(UserRoleEntity::getUserId, userId)); + if (CollUtil.isEmpty(roleIds)) { + return; + } + for (Long roleId : roleIds) { + if (getById(roleId) == null) { + throw new BizException(ResultCode.NOT_FOUND, "角色不存在: " + roleId); + } + UserRoleEntity entity = new UserRoleEntity(); + entity.setUserId(userId); + entity.setRoleId(roleId); + userRoleMapper.insert(entity); + } + } +} diff --git a/src/main/java/com/test/demo/service/impl/UserServiceImpl.java b/src/main/java/com/test/demo/service/impl/UserServiceImpl.java new file mode 100644 index 0000000..92e87b1 --- /dev/null +++ b/src/main/java/com/test/demo/service/impl/UserServiceImpl.java @@ -0,0 +1,165 @@ +package com.test.demo.service.impl; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.test.demo.bo.UserBo; +import com.test.demo.bo.UserQueryBo; +import com.test.demo.common.base.PageResult; +import com.test.demo.common.exception.BizException; +import com.test.demo.common.result.ResultCode; +import com.test.demo.convert.UserConvert; +import com.test.demo.entity.UserEntity; +import com.test.demo.enums.UserStatusEnum; +import com.test.demo.mapper.UserMapper; +import com.test.demo.security.crypto.SM3Util; +import com.test.demo.util.ExcelUtil; +import com.test.demo.vo.UserImportVo; +import com.test.demo.vo.UserVo; +import com.test.demo.service.UserService; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * 用户 Service 实现 + */ +@Service +@RequiredArgsConstructor +public class UserServiceImpl extends ServiceImpl implements UserService { + + private final UserConvert userConvert; + + private LambdaQueryWrapper buildQuery(UserQueryBo query) { + return new LambdaQueryWrapper() + .like(StrUtil.isNotBlank(query.getUsername()), UserEntity::getUsername, query.getUsername()) + .eq(StrUtil.isNotBlank(query.getPhone()), UserEntity::getPhone, query.getPhone()) + .eq(query.getStatus() != null, UserEntity::getStatus, query.getStatus()) + .orderByDesc(UserEntity::getCreateTime); + } + + @Override + public PageResult page(UserQueryBo query) { + Page page = page(new Page<>(query.getPageNum(), query.getPageSize()), buildQuery(query)); + return PageResult.of(userConvert.toVoList(page.getRecords()), page.getTotal(), + query.getPageNum(), query.getPageSize()); + } + + @Override + public UserVo getDetail(Long id) { + UserEntity entity = getById(id); + if (entity == null) { + throw new BizException(ResultCode.NOT_FOUND, "用户不存在"); + } + return userConvert.toVo(entity); + } + + @Override + public void add(UserBo bo) { + // 检查用户名唯一 + long count = count(new LambdaQueryWrapper() + .eq(UserEntity::getUsername, bo.getUsername())); + if (count > 0) { + throw new BizException(ResultCode.CONFLICT, "用户名已存在"); + } + + UserEntity entity = userConvert.toEntity(bo); + // SM3加密密码 + String salt = SM3Util.generateSalt(); + entity.setSalt(salt); + entity.setPassword(SM3Util.hash(bo.getPassword(), salt)); + entity.setStatus(UserStatusEnum.NORMAL); + save(entity); + } + + @Override + public void edit(UserBo bo) { + UserEntity entity = getById(bo.getId()); + if (entity == null) { + throw new BizException(ResultCode.NOT_FOUND, "用户不存在"); + } + userConvert.updateEntity(bo, entity); + updateById(entity); + } + + @Override + public void remove(Long id) { + if (getById(id) == null) { + throw new BizException(ResultCode.NOT_FOUND, "用户不存在"); + } + removeById(id); + } + + @Override + public void resetPassword(Long id, String newPassword) { + UserEntity entity = getById(id); + if (entity == null) { + throw new BizException(ResultCode.NOT_FOUND, "用户不存在"); + } + String salt = SM3Util.generateSalt(); + entity.setSalt(salt); + entity.setPassword(SM3Util.hash(newPassword, salt)); + updateById(entity); + } + + @Override + public List listExportData(UserQueryBo query) { + List entities = list(buildQuery(query)); + List result = new ArrayList<>(entities.size()); + for (UserEntity entity : entities) { + UserImportVo vo = new UserImportVo(); + vo.setUsername(entity.getUsername()); + vo.setNickname(entity.getNickname()); + vo.setEmail(entity.getEmail()); + vo.setPhone(entity.getPhone()); + vo.setAvatar(entity.getAvatar()); + result.add(vo); + } + return result; + } + + @Override + public void exportUsers(UserQueryBo query, HttpServletResponse response) throws IOException { + ExcelUtil.export(response, "user-list", "用户列表", UserImportVo.class, listExportData(query)); + } + + @Override + public void importUsers(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new BizException(ResultCode.BAD_REQUEST, "导入文件不能为空"); + } + try { + List rows = ExcelUtil.importExcel(file.getInputStream(), UserImportVo.class); + for (UserImportVo row : rows) { + if (StrUtil.isBlank(row.getUsername())) { + continue; + } + long count = count(new LambdaQueryWrapper() + .eq(UserEntity::getUsername, row.getUsername())); + if (count > 0) { + continue; + } + UserEntity entity = new UserEntity(); + entity.setUsername(row.getUsername()); + entity.setNickname(row.getNickname()); + entity.setEmail(row.getEmail()); + entity.setPhone(row.getPhone()); + entity.setAvatar(row.getAvatar()); + String rawPassword = StrUtil.blankToDefault(row.getPassword(), "123456"); + String salt = SM3Util.generateSalt(); + entity.setSalt(salt); + entity.setPassword(SM3Util.hash(rawPassword, salt)); + entity.setStatus(UserStatusEnum.NORMAL); + save(entity); + } + } catch (IOException e) { + throw new BizException(ResultCode.INTERNAL_ERROR, "导入文件读取失败"); + } + } +} diff --git a/src/main/java/com/test/demo/util/DesensitizeUtil.java b/src/main/java/com/test/demo/util/DesensitizeUtil.java new file mode 100644 index 0000000..01d3b9c --- /dev/null +++ b/src/main/java/com/test/demo/util/DesensitizeUtil.java @@ -0,0 +1,48 @@ +package com.test.demo.util; + +/** + * 数据脱敏工具类 + */ +public final class DesensitizeUtil { + + private DesensitizeUtil() {} + + /** 手机号脱敏: 138****1234 */ + public static String phone(String phone) { + if (phone == null || phone.length() < 7) return phone; + return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4); + } + + /** 身份证脱敏: 110***********1234 */ + public static String idCard(String idCard) { + if (idCard == null || idCard.length() < 8) return idCard; + return idCard.substring(0, 3) + "***********" + idCard.substring(idCard.length() - 4); + } + + /** 邮箱脱敏: t***@example.com */ + public static String email(String email) { + if (email == null || !email.contains("@")) return email; + int atIndex = email.indexOf("@"); + if (atIndex <= 1) return email; + return email.charAt(0) + "***" + email.substring(atIndex); + } + + /** 银行卡脱敏: 6222 **** **** 1234 */ + public static String bankCard(String bankCard) { + if (bankCard == null || bankCard.length() < 8) return bankCard; + return bankCard.substring(0, 4) + " **** **** " + bankCard.substring(bankCard.length() - 4); + } + + /** 姓名脱敏: 张* / 张*明 */ + public static String name(String name) { + if (name == null || name.length() <= 1) return name; + if (name.length() == 2) return name.charAt(0) + "*"; + return name.charAt(0) + "*".repeat(name.length() - 2) + name.charAt(name.length() - 1); + } + + /** 地址脱敏: 保留前6位 */ + public static String address(String address) { + if (address == null || address.length() <= 6) return address; + return address.substring(0, 6) + "****"; + } +} diff --git a/src/main/java/com/test/demo/util/ExcelUtil.java b/src/main/java/com/test/demo/util/ExcelUtil.java new file mode 100644 index 0000000..ad8b027 --- /dev/null +++ b/src/main/java/com/test/demo/util/ExcelUtil.java @@ -0,0 +1,45 @@ +package com.test.demo.util; + +import cn.idev.excel.FastExcel; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; + +/** + * FastExcel 导入导出封装 + */ +public final class ExcelUtil { + + private ExcelUtil() {} + + /** + * 导出 Excel + * @param response HTTP响应 + * @param fileName 文件名(不含后缀) + * @param sheetName 工作表名 + * @param clazz 数据类型 + * @param data 数据列表 + */ + public static void export(HttpServletResponse response, String fileName, + String sheetName, Class clazz, List data) throws IOException { + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + String encodedName = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replaceAll("\\+", "%20"); + response.setHeader("Content-Disposition", "attachment;filename=" + encodedName + ".xlsx"); + FastExcel.write(response.getOutputStream(), clazz).sheet(sheetName).doWrite(data); + } + + /** + * 导入 Excel + * @param inputStream 输入流 + * @param clazz 数据类型 + * @return 数据列表 + */ + public static List importExcel(InputStream inputStream, Class clazz) { + return FastExcel.read(inputStream).head(clazz).sheet().doReadSync(); + } +} diff --git a/src/main/java/com/test/demo/util/FilePathUtil.java b/src/main/java/com/test/demo/util/FilePathUtil.java new file mode 100644 index 0000000..bf4d94a --- /dev/null +++ b/src/main/java/com/test/demo/util/FilePathUtil.java @@ -0,0 +1,59 @@ +package com.test.demo.util; + +import cn.hutool.core.util.StrUtil; + +/** + * 文件路径安全校验工具 + */ +public final class FilePathUtil { + + private FilePathUtil() {} + + /** + * 校验文件路径是否安全(防路径穿越) + * @param basePath 基础路径 + * @param filePath 待校验路径 + * @return 是否安全 + */ + public static boolean isSafePath(String basePath, String filePath) { + if (StrUtil.isBlank(filePath)) { + return false; + } + // 禁止路径穿越 + if (filePath.contains("..") || filePath.contains("./")) { + return false; + } + // 禁止绝对路径 + if (filePath.startsWith("/") || filePath.contains(":")) { + return false; + } + // 禁止特殊字符 + if (filePath.matches(".*[\\\\<>|\"?*].*")) { + return false; + } + return true; + } + + /** + * 获取文件扩展名(小写) + */ + public static String getExtension(String filename) { + if (StrUtil.isBlank(filename) || !filename.contains(".")) { + return ""; + } + return filename.substring(filename.lastIndexOf(".") + 1).toLowerCase(); + } + + /** + * 判断是否为允许的文件类型 + */ + public static boolean isAllowedExtension(String filename, String... allowedExtensions) { + String ext = getExtension(filename); + for (String allowed : allowedExtensions) { + if (allowed.equalsIgnoreCase(ext)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/test/demo/util/IdGenerator.java b/src/main/java/com/test/demo/util/IdGenerator.java new file mode 100644 index 0000000..f1829c5 --- /dev/null +++ b/src/main/java/com/test/demo/util/IdGenerator.java @@ -0,0 +1,25 @@ +package com.test.demo.util; + +import cn.hutool.core.util.IdUtil; + +/** + * 全局唯一ID生成器(雪花算法) + */ +public final class IdGenerator { + + private IdGenerator() {} + + /** + * 生成雪花ID + */ + public static long nextId() { + return IdUtil.getSnowflakeNextId(); + } + + /** + * 生成雪花ID字符串 + */ + public static String nextIdStr() { + return IdUtil.getSnowflakeNextIdStr(); + } +} diff --git a/src/main/java/com/test/demo/util/LockUtil.java b/src/main/java/com/test/demo/util/LockUtil.java new file mode 100644 index 0000000..ccf9f25 --- /dev/null +++ b/src/main/java/com/test/demo/util/LockUtil.java @@ -0,0 +1,63 @@ +package com.test.demo.util; + +import lombok.RequiredArgsConstructor; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +/** + * 分布式锁工具(Redisson 封装) + */ +@Component +@RequiredArgsConstructor +public class LockUtil { + + private final RedissonClient redissonClient; + + private static final String LOCK_PREFIX = "demo:lock:"; + + /** + * 加锁执行 + * @param key 锁名称 + * @param waitTime 等待时间(秒) + * @param leaseTime 持有时间(秒) + * @param supplier 业务逻辑 + */ + public T executeWithLock(String key, long waitTime, long leaseTime, Supplier supplier) { + RLock lock = redissonClient.getLock(LOCK_PREFIX + key); + try { + boolean acquired = lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS); + if (!acquired) { + throw new RuntimeException("获取分布式锁失败: " + key); + } + return supplier.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("获取分布式锁被中断: " + key, e); + } finally { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } + } + + /** + * 加锁执行(默认等待3秒,持有30秒) + */ + public T executeWithLock(String key, Supplier supplier) { + return executeWithLock(key, 3, 30, supplier); + } + + /** + * 加锁执行无返回值 + */ + public void executeWithLock(String key, Runnable runnable) { + executeWithLock(key, 3, 30, () -> { + runnable.run(); + return null; + }); + } +} diff --git a/src/main/java/com/test/demo/util/MoneyUtil.java b/src/main/java/com/test/demo/util/MoneyUtil.java new file mode 100644 index 0000000..e420c8d --- /dev/null +++ b/src/main/java/com/test/demo/util/MoneyUtil.java @@ -0,0 +1,75 @@ +package com.test.demo.util; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** + * 金额转换工具 + */ +public final class MoneyUtil { + + private static final BigDecimal HUNDRED = new BigDecimal("100"); + private static final String[] CN_UPPER = {"零", "壹", "贰", "叁", "肆", "伍", "陆", "柒", "捌", "玖"}; + private static final String[] CN_UNIT = {"", "拾", "佰", "仟", "万", "拾", "佰", "仟", "亿"}; + + private MoneyUtil() {} + + /** 分转元 */ + public static BigDecimal fen2Yuan(Long fen) { + if (fen == null) return BigDecimal.ZERO; + return new BigDecimal(fen).divide(HUNDRED, 2, RoundingMode.HALF_UP); + } + + /** 元转分 */ + public static Long yuan2Fen(BigDecimal yuan) { + if (yuan == null) return 0L; + return yuan.multiply(HUNDRED).setScale(0, RoundingMode.HALF_UP).longValue(); + } + + /** 格式化金额(保留2位小数,千分位) */ + public static String format(BigDecimal amount) { + if (amount == null) return "0.00"; + return String.format("%,.2f", amount); + } + + /** 金额转大写中文 */ + public static String toChinese(BigDecimal amount) { + if (amount == null || amount.compareTo(BigDecimal.ZERO) == 0) { + return "零元整"; + } + long fen = yuan2Fen(amount.abs()); + long yuan = fen / 100; + int jiao = (int) (fen % 100 / 10); + int fenPart = (int) (fen % 10); + + StringBuilder sb = new StringBuilder(); + if (amount.compareTo(BigDecimal.ZERO) < 0) { + sb.append("负"); + } + if (yuan > 0) { + String yuanStr = String.valueOf(yuan); + for (int i = 0; i < yuanStr.length(); i++) { + int digit = yuanStr.charAt(i) - '0'; + int unitIndex = yuanStr.length() - 1 - i; + if (digit != 0) { + sb.append(CN_UPPER[digit]).append(CN_UNIT[unitIndex]); + } else if (sb.length() > 0 && sb.charAt(sb.length() - 1) != '零') { + sb.append("零"); + } + } + if (sb.length() > 0 && sb.charAt(sb.length() - 1) == '零') { + sb.deleteCharAt(sb.length() - 1); + } + sb.append("元"); + } + if (jiao > 0) { + sb.append(CN_UPPER[jiao]).append("角"); + } + if (fenPart > 0) { + sb.append(CN_UPPER[fenPart]).append("分"); + } else { + sb.append("整"); + } + return sb.toString(); + } +} diff --git a/src/main/java/com/test/demo/vo/FileInfoVo.java b/src/main/java/com/test/demo/vo/FileInfoVo.java new file mode 100644 index 0000000..5b149f2 --- /dev/null +++ b/src/main/java/com/test/demo/vo/FileInfoVo.java @@ -0,0 +1,38 @@ +package com.test.demo.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 文件信息 + */ +@Data +@Schema(name = "FileInfoVo", description = "文件信息") +public class FileInfoVo { + + @Schema(description = "文件名", example = "demo.xlsx") + private String fileName; + + @Schema(description = "相对路径", example = "2026/03/15/demo.xlsx") + private String relativePath; + + @Schema(description = "是否目录", example = "false") + private Boolean directory; + + @Schema(description = "文件大小(字节)", example = "1024") + private Long size; + + @Schema(description = "扩展名", example = "xlsx") + private String extension; + + @Schema(description = "最后修改时间") + private LocalDateTime lastModified; + + @Schema(description = "预览地址", example = "/api/file/preview/2026/03/15/demo.xlsx") + private String previewUrl; + + @Schema(description = "下载地址", example = "/api/file/download/2026/03/15/demo.xlsx") + private String downloadUrl; +} diff --git a/src/main/java/com/test/demo/vo/LoginLogExportVo.java b/src/main/java/com/test/demo/vo/LoginLogExportVo.java new file mode 100644 index 0000000..a027c09 --- /dev/null +++ b/src/main/java/com/test/demo/vo/LoginLogExportVo.java @@ -0,0 +1,51 @@ +package com.test.demo.vo; + +import cn.idev.excel.annotation.ExcelProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 登录日志导出对象 + */ +@Data +@Schema(name = "LoginLogExportVo", description = "登录日志导出对象") +public class LoginLogExportVo { + + @ExcelProperty("用户名") + @Schema(description = "用户名", example = "admin") + private String username; + + @ExcelProperty("用户ID") + @Schema(description = "用户ID", example = "1") + private Long userId; + + @ExcelProperty("日志类型") + @Schema(description = "日志类型", example = "LOGIN") + private String logType; + + @ExcelProperty("状态") + @Schema(description = "状态", example = "1") + private Integer status; + + @ExcelProperty("结果信息") + @Schema(description = "结果信息", example = "登录成功") + private String message; + + @ExcelProperty("登录IP") + @Schema(description = "登录IP", example = "127.0.0.1") + private String ip; + + @ExcelProperty("请求地址") + @Schema(description = "请求地址", example = "/api/auth/login") + private String requestUrl; + + @ExcelProperty("请求终端") + @Schema(description = "请求终端") + private String userAgent; + + @ExcelProperty("创建时间") + @Schema(description = "创建时间") + private LocalDateTime createTime; +} diff --git a/src/main/java/com/test/demo/vo/LoginVo.java b/src/main/java/com/test/demo/vo/LoginVo.java new file mode 100644 index 0000000..8911a8c --- /dev/null +++ b/src/main/java/com/test/demo/vo/LoginVo.java @@ -0,0 +1,29 @@ +package com.test.demo.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +/** + * 登录响应对象 + */ +@Data +@Schema(name = "LoginVo", description = "登录响应对象") +public class LoginVo { + + @Schema(description = "访问令牌", example = "8d4d1f84-6ee7-4f08-b775-0f4bbdc6b3c3") + private String token; + + @Schema(description = "Token名称", example = "Authorization") + private String tokenName; + + @Schema(description = "用户信息") + private UserVo userInfo; + + @Schema(description = "角色编码列表") + private List roles; + + @Schema(description = "权限标识列表") + private List permissions; +} diff --git a/src/main/java/com/test/demo/vo/OperLogExportVo.java b/src/main/java/com/test/demo/vo/OperLogExportVo.java new file mode 100644 index 0000000..1814c25 --- /dev/null +++ b/src/main/java/com/test/demo/vo/OperLogExportVo.java @@ -0,0 +1,55 @@ +package com.test.demo.vo; + +import cn.idev.excel.annotation.ExcelProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 操作日志导出对象 + */ +@Data +@Schema(name = "OperLogExportVo", description = "操作日志导出对象") +public class OperLogExportVo { + + @ExcelProperty("操作说明") + @Schema(description = "操作说明", example = "新增用户") + private String title; + + @ExcelProperty("日志类型") + @Schema(description = "日志类型", example = "OPER") + private String logType; + + @ExcelProperty("操作用户") + @Schema(description = "操作用户", example = "admin") + private String userName; + + @ExcelProperty("操作IP") + @Schema(description = "操作IP", example = "127.0.0.1") + private String ip; + + @ExcelProperty("请求方法") + @Schema(description = "请求方法", example = "POST") + private String requestMethod; + + @ExcelProperty("请求地址") + @Schema(description = "请求地址", example = "/api/user/add") + private String requestUrl; + + @ExcelProperty("执行状态") + @Schema(description = "执行状态", example = "1") + private Integer status; + + @ExcelProperty("错误信息") + @Schema(description = "错误信息") + private String errorMsg; + + @ExcelProperty("耗时(ms)") + @Schema(description = "耗时(ms)", example = "35") + private Long costTime; + + @ExcelProperty("创建时间") + @Schema(description = "创建时间") + private LocalDateTime createTime; +} diff --git a/src/main/java/com/test/demo/vo/RoleDetailVo.java b/src/main/java/com/test/demo/vo/RoleDetailVo.java new file mode 100644 index 0000000..ccdffab --- /dev/null +++ b/src/main/java/com/test/demo/vo/RoleDetailVo.java @@ -0,0 +1,21 @@ +package com.test.demo.vo; + +import com.test.demo.entity.RoleEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +/** + * 角色详情 + */ +@Data +@Schema(name = "RoleDetailVo", description = "角色详情") +public class RoleDetailVo { + + @Schema(description = "角色信息") + private RoleEntity role; + + @Schema(description = "已分配菜单ID列表") + private List menuIds; +} diff --git a/src/main/java/com/test/demo/vo/UserImportVo.java b/src/main/java/com/test/demo/vo/UserImportVo.java new file mode 100644 index 0000000..7f9e053 --- /dev/null +++ b/src/main/java/com/test/demo/vo/UserImportVo.java @@ -0,0 +1,37 @@ +package com.test.demo.vo; + +import cn.idev.excel.annotation.ExcelProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 用户导入导出对象 + */ +@Data +@Schema(name = "UserImportVo", description = "用户导入导出对象") +public class UserImportVo { + + @ExcelProperty("用户名") + @Schema(description = "用户名", example = "admin") + private String username; + + @ExcelProperty("密码") + @Schema(description = "密码", example = "admin123") + private String password; + + @ExcelProperty("昵称") + @Schema(description = "昵称", example = "管理员") + private String nickname; + + @ExcelProperty("邮箱") + @Schema(description = "邮箱", example = "admin@example.com") + private String email; + + @ExcelProperty("手机号") + @Schema(description = "手机号", example = "13800138000") + private String phone; + + @ExcelProperty("头像路径") + @Schema(description = "头像路径", example = "/avatar/admin.png") + private String avatar; +} diff --git a/src/main/java/com/test/demo/vo/UserVo.java b/src/main/java/com/test/demo/vo/UserVo.java new file mode 100644 index 0000000..4fb37b1 --- /dev/null +++ b/src/main/java/com/test/demo/vo/UserVo.java @@ -0,0 +1,45 @@ +package com.test.demo.vo; + +import com.test.demo.common.annotation.Desensitize; +import com.test.demo.common.annotation.DesensitizeType; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 用户响应 Vo + */ +@Data +@Schema(name = "UserVo", description = "用户信息") +public class UserVo { + + @Schema(description = "用户ID", example = "1") + private Long id; + + @Schema(description = "用户名", example = "admin") + private String username; + + @Schema(description = "昵称", example = "管理员") + private String nickname; + + @Desensitize(type = DesensitizeType.EMAIL) + @Schema(description = "邮箱", example = "ad***@example.com") + private String email; + + @Desensitize(type = DesensitizeType.PHONE) + @Schema(description = "手机号", example = "138****8000") + private String phone; + + @Schema(description = "头像路径", example = "/avatar/admin.png") + private String avatar; + + @Schema(description = "状态", example = "1", allowableValues = {"0", "1"}) + private Integer status; + + @Schema(description = "状态描述", example = "正常") + private String statusDesc; + + @Schema(description = "创建时间") + private LocalDateTime createTime; +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..0afe814 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,39 @@ +# 开发环境配置 +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + username: root + password: 123456 + hikari: + minimum-idle: 5 + maximum-pool-size: 20 + idle-timeout: 30000 + max-lifetime: 1800000 + connection-timeout: 30000 + pool-name: DemoHikariCP + + data: + redis: + host: localhost + port: 6379 + password: 123456 + database: 0 + lettuce: + pool: + max-active: 20 + max-idle: 10 + min-idle: 5 + max-wait: 3000ms + +# Knife4j 开启 +knife4j: + enable: true + setting: + language: zh_cn + +# 日志级别 +logging: + level: + com.test.demo: debug + org.springframework: info diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..76a9fab --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,36 @@ +# 生产环境配置 +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://prod-db-host:3306/demo?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=Asia/Shanghai + username: demo_prod + password: prod_password + hikari: + minimum-idle: 10 + maximum-pool-size: 50 + idle-timeout: 30000 + max-lifetime: 1800000 + connection-timeout: 30000 + pool-name: DemoHikariCP + + data: + redis: + host: prod-redis-host + port: 6379 + password: prod_redis_password + database: 0 + lettuce: + pool: + max-active: 50 + max-idle: 20 + min-idle: 10 + max-wait: 3000ms + +# 生产环境关闭 Knife4j +knife4j: + enable: false + +logging: + level: + com.test.demo: warn + org.springframework: warn diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml new file mode 100644 index 0000000..debe0f7 --- /dev/null +++ b/src/main/resources/application-test.yml @@ -0,0 +1,37 @@ +# 测试环境配置 +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://test-db-host:3306/demo?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=Asia/Shanghai + username: demo_test + password: test_password + hikari: + minimum-idle: 5 + maximum-pool-size: 30 + idle-timeout: 30000 + max-lifetime: 1800000 + connection-timeout: 30000 + pool-name: DemoHikariCP + + data: + redis: + host: test-redis-host + port: 6379 + password: test_redis_password + database: 0 + lettuce: + pool: + max-active: 30 + max-idle: 10 + min-idle: 5 + max-wait: 3000ms + +knife4j: + enable: true + setting: + language: zh_cn + +logging: + level: + com.test.demo: info + org.springframework: warn diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..092d9da --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,86 @@ +# 公共配置 +server: + port: 8080 + servlet: + context-path: /api + +spring: + application: + name: demo + profiles: + active: dev + + # Jackson 全局配置 + jackson: + date-format: yyyy-MM-dd HH:mm:ss + time-zone: Asia/Shanghai + default-property-inclusion: non_null + serialization: + write-dates-as-timestamps: false + + # 文件上传 + servlet: + multipart: + enabled: true + max-file-size: 50MB + max-request-size: 100MB + + # 邮件配置 + mail: + host: smtp.example.com + port: 465 + username: your-email@example.com + password: your-password + properties: + mail: + smtp: + ssl: + enable: true + auth: true + +# MyBatis-Plus +mybatis-plus: + mapper-locations: classpath:mapper/**/*.xml + type-aliases-package: com.test.demo.modules.*.entity,com.test.demo.log.entity,com.test.demo.task.entity + global-config: + db-config: + id-type: assign_id + logic-delete-field: deleted + logic-delete-value: 1 + logic-not-delete-value: 0 + table-underline: true + configuration: + map-underscore-to-camel-case: true + +# Sa-Token +sa-token: + token-name: Authorization + timeout: 86400 + active-timeout: 1800 + is-concurrent: true + is-share: true + token-style: uuid + is-log: false + +# 文件上传路径 +file: + upload-path: /data/upload + preview-url: /api/file/preview + +# 接口签名配置 +security: + sign: + secret: change-me-sign-secret + expire-seconds: 300 + +# Actuator +management: + endpoints: + web: + exposure: + include: health,info,metrics + endpoint: + health: + show-details: when-authorized + mail: + enabled: false diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..a7bcc69 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - ${SENSITIVE_PATTERN}%n + UTF-8 + + + + + + ${LOG_PATH}/${APP_NAME}-info.log + + INFO + ACCEPT + DENY + + + ${LOG_PATH}/archive/${APP_NAME}-info.%d{yyyy-MM-dd}.%i.log.gz + 100MB + 30 + 3GB + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - ${SENSITIVE_PATTERN}%n + UTF-8 + + + + + + ${LOG_PATH}/${APP_NAME}-warn.log + + WARN + ACCEPT + DENY + + + ${LOG_PATH}/archive/${APP_NAME}-warn.%d{yyyy-MM-dd}.%i.log.gz + 100MB + 30 + 2GB + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - ${SENSITIVE_PATTERN}%n + UTF-8 + + + + + + ${LOG_PATH}/${APP_NAME}-error.log + + ERROR + + + ${LOG_PATH}/archive/${APP_NAME}-error.%d{yyyy-MM-dd}.%i.log.gz + 100MB + 60 + 2GB + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - ${SENSITIVE_PATTERN}%n + UTF-8 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/mapper/OperLogMapper.xml b/src/main/resources/mapper/OperLogMapper.xml new file mode 100644 index 0000000..1940f03 --- /dev/null +++ b/src/main/resources/mapper/OperLogMapper.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/mapper/ScheduledTaskMapper.xml b/src/main/resources/mapper/ScheduledTaskMapper.xml new file mode 100644 index 0000000..140d512 --- /dev/null +++ b/src/main/resources/mapper/ScheduledTaskMapper.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/mapper/UserMapper.xml b/src/main/resources/mapper/UserMapper.xml new file mode 100644 index 0000000..e799cf1 --- /dev/null +++ b/src/main/resources/mapper/UserMapper.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/sql/init-auth-schema.sql b/src/main/resources/sql/init-auth-schema.sql new file mode 100644 index 0000000..2c78928 --- /dev/null +++ b/src/main/resources/sql/init-auth-schema.sql @@ -0,0 +1,61 @@ +-- ============================================ +-- 权限体系增量建表 SQL +-- 适用于已有 demo 数据库,仅补建权限相关表 +-- ============================================ + +USE `demo`; + +CREATE TABLE IF NOT EXISTS `sys_role` ( + `id` BIGINT NOT NULL COMMENT '主键ID', + `role_code` VARCHAR(64) NOT NULL COMMENT '角色编码', + `role_name` VARCHAR(128) NOT NULL COMMENT '角色名称', + `enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用(0否 1是)', + `sort` INT NOT NULL DEFAULT 0 COMMENT '排序', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除(0正常 1删除)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_role_code` (`role_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表'; + +CREATE TABLE IF NOT EXISTS `sys_menu` ( + `id` BIGINT NOT NULL COMMENT '主键ID', + `parent_id` BIGINT NOT NULL DEFAULT 0 COMMENT '父级ID', + `menu_type` VARCHAR(16) NOT NULL COMMENT '类型(MENU/BUTTON)', + `menu_name` VARCHAR(128) NOT NULL COMMENT '菜单名称', + `path` VARCHAR(255) DEFAULT NULL COMMENT '路由路径', + `component` VARCHAR(255) DEFAULT NULL COMMENT '组件路径', + `permission` VARCHAR(128) DEFAULT NULL COMMENT '权限标识', + `icon` VARCHAR(64) DEFAULT NULL COMMENT '图标', + `visible` TINYINT NOT NULL DEFAULT 1 COMMENT '是否显示(0否 1是)', + `enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用(0否 1是)', + `sort` INT NOT NULL DEFAULT 0 COMMENT '排序', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除(0正常 1删除)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜单权限表'; + +CREATE TABLE IF NOT EXISTS `sys_user_role` ( + `id` BIGINT NOT NULL COMMENT '主键ID', + `user_id` BIGINT NOT NULL COMMENT '用户ID', + `role_id` BIGINT NOT NULL COMMENT '角色ID', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_role` (`user_id`, `role_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表'; + +CREATE TABLE IF NOT EXISTS `sys_role_menu` ( + `id` BIGINT NOT NULL COMMENT '主键ID', + `role_id` BIGINT NOT NULL COMMENT '角色ID', + `menu_id` BIGINT NOT NULL COMMENT '菜单ID', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_role_menu` (`role_id`, `menu_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色菜单关联表'; + +-- 说明: +-- 1. 执行完本文件后重启应用 +-- 2. 应用启动时会自动初始化 ADMIN 角色、基础菜单权限,并尝试将 admin 用户绑定为管理员 diff --git a/src/main/resources/sql/init-demo-data.sql b/src/main/resources/sql/init-demo-data.sql new file mode 100644 index 0000000..79da040 --- /dev/null +++ b/src/main/resources/sql/init-demo-data.sql @@ -0,0 +1,47 @@ +-- ============================================ +-- demo 示例数据 SQL +-- 适用于开发环境快速联调 +-- ============================================ + +USE `demo`; + +-- 1. 初始化管理员账号 +-- 用户名: admin +-- 密码: admin123 +-- salt: a1b2c3d4e5f6g7h8 +-- password(SM3(salt + password)): +-- a20d0010676b29cb9c4ae955640d95bc5dcfba017188607013185ae73e850e74 +INSERT INTO `sys_user` (`id`, `username`, `password`, `salt`, `nickname`, `email`, `phone`, `avatar`, `status`, `deleted`) +SELECT 1000000000000000001, 'admin', 'a20d0010676b29cb9c4ae955640d95bc5dcfba017188607013185ae73e850e74', + 'a1b2c3d4e5f6g7h8', '系统管理员', 'admin@example.com', '13800138000', '/avatar/admin.png', 1, 0 +WHERE NOT EXISTS ( + SELECT 1 FROM `sys_user` WHERE `username` = 'admin' AND `deleted` = 0 +); + +-- 2. 初始化一个示例自定义字典类型 +INSERT INTO `sys_enum_type` +(`id`, `enum_code`, `enum_name`, `enum_desc`, `source_type`, `builtin`, `enabled`, `sync_enabled`, `sort`, `remark`) +SELECT 1000000000000000101, 'OrderSource', '订单来源', '订单来源字典', 'CUSTOM', 0, 1, 0, 1, '联调用示例字典' +WHERE NOT EXISTS ( + SELECT 1 FROM `sys_enum_type` WHERE `enum_code` = 'OrderSource' +); + +-- 3. 初始化示例字典项 +INSERT INTO `sys_enum_item` +(`id`, `enum_code`, `item_value`, `item_label`, `item_name`, `source_type`, `builtin`, `enabled`, `editable`, `deletable`, `sort`, `remark`) +SELECT 1000000000000000201, 'OrderSource', 'APP', 'App下单', 'APP', 'CUSTOM', 0, 1, 1, 1, 1, '联调用示例' +WHERE NOT EXISTS ( + SELECT 1 FROM `sys_enum_item` WHERE `enum_code` = 'OrderSource' AND `item_value` = 'APP' +); + +INSERT INTO `sys_enum_item` +(`id`, `enum_code`, `item_value`, `item_label`, `item_name`, `source_type`, `builtin`, `enabled`, `editable`, `deletable`, `sort`, `remark`) +SELECT 1000000000000000202, 'OrderSource', 'WEB', '网页下单', 'WEB', 'CUSTOM', 0, 1, 1, 1, 2, '联调用示例' +WHERE NOT EXISTS ( + SELECT 1 FROM `sys_enum_item` WHERE `enum_code` = 'OrderSource' AND `item_value` = 'WEB' +); + +-- 使用说明: +-- 1. 先执行 init-auth-schema.sql +-- 2. 再执行本文件 +-- 3. 重启应用,系统会自动初始化 ADMIN 角色和菜单,并把 admin 用户绑定为管理员 diff --git a/src/main/resources/sql/schema.sql b/src/main/resources/sql/schema.sql new file mode 100644 index 0000000..e594ce4 --- /dev/null +++ b/src/main/resources/sql/schema.sql @@ -0,0 +1,219 @@ +-- ============================================ +-- Spring Boot 脚手架初始化 SQL +-- 数据库: demo +-- ============================================ + +CREATE DATABASE IF NOT EXISTS `demo` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; +USE `demo`; + +-- ---------------------------- +-- 用户表 +-- ---------------------------- +DROP TABLE IF EXISTS `sys_user`; +CREATE TABLE `sys_user` ( + `id` BIGINT NOT NULL COMMENT '主键ID', + `username` VARCHAR(64) NOT NULL COMMENT '用户名', + `password` VARCHAR(256) NOT NULL COMMENT 'SM3加密密码', + `salt` VARCHAR(64) NOT NULL COMMENT '密码盐', + `nickname` VARCHAR(64) DEFAULT NULL COMMENT '昵称', + `email` VARCHAR(128) DEFAULT NULL COMMENT '邮箱', + `phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号', + `avatar` VARCHAR(256) DEFAULT NULL COMMENT '头像路径', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态(0禁用 1正常)', + `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除(0正常 1删除)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_username` (`username`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; + +-- ---------------------------- +-- 操作日志表 +-- ---------------------------- +DROP TABLE IF EXISTS `sys_oper_log`; +CREATE TABLE `sys_oper_log` ( + `id` BIGINT NOT NULL COMMENT '主键ID', + `title` VARCHAR(128) DEFAULT NULL COMMENT '操作说明', + `log_type` VARCHAR(32) DEFAULT NULL COMMENT '日志类型', + `method` VARCHAR(256) DEFAULT NULL COMMENT '请求方法', + `request_method` VARCHAR(16) DEFAULT NULL COMMENT 'HTTP方法', + `request_url` VARCHAR(512) DEFAULT NULL COMMENT '请求URL', + `request_param` TEXT DEFAULT NULL COMMENT '请求参数', + `response_result` TEXT DEFAULT NULL COMMENT '返回结果', + `user_id` BIGINT DEFAULT NULL COMMENT '操作用户ID', + `user_name` VARCHAR(64) DEFAULT NULL COMMENT '操作用户名', + `ip` VARCHAR(64) DEFAULT NULL COMMENT '操作IP', + `status` TINYINT DEFAULT NULL COMMENT '状态(0失败 1成功)', + `error_msg` TEXT DEFAULT NULL COMMENT '错误信息', + `cost_time` BIGINT DEFAULT NULL COMMENT '耗时(ms)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_create_time` (`create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表'; + +-- ---------------------------- +-- 登录日志表 +-- ---------------------------- +DROP TABLE IF EXISTS `sys_login_log`; +CREATE TABLE `sys_login_log` ( + `id` BIGINT NOT NULL COMMENT '主键ID', + `username` VARCHAR(64) DEFAULT NULL COMMENT '用户名', + `user_id` BIGINT DEFAULT NULL COMMENT '用户ID', + `status` TINYINT NOT NULL COMMENT '登录状态(0失败 1成功)', + `log_type` VARCHAR(16) NOT NULL COMMENT '日志类型(LOGIN/LOGOUT)', + `message` VARCHAR(255) DEFAULT NULL COMMENT '结果信息', + `ip` VARCHAR(64) DEFAULT NULL COMMENT '登录IP', + `user_agent` VARCHAR(500) DEFAULT NULL COMMENT 'User-Agent', + `request_url` VARCHAR(512) DEFAULT NULL COMMENT '请求URL', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_username` (`username`), + KEY `idx_user_id` (`user_id`), + KEY `idx_create_time` (`create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='登录日志表'; + +-- ---------------------------- +-- 角色表 +-- ---------------------------- +DROP TABLE IF EXISTS `sys_role`; +CREATE TABLE `sys_role` ( + `id` BIGINT NOT NULL COMMENT '主键ID', + `role_code` VARCHAR(64) NOT NULL COMMENT '角色编码', + `role_name` VARCHAR(128) NOT NULL COMMENT '角色名称', + `enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用(0否 1是)', + `sort` INT NOT NULL DEFAULT 0 COMMENT '排序', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除(0正常 1删除)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_role_code` (`role_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表'; + +-- ---------------------------- +-- 菜单权限表 +-- ---------------------------- +DROP TABLE IF EXISTS `sys_menu`; +CREATE TABLE `sys_menu` ( + `id` BIGINT NOT NULL COMMENT '主键ID', + `parent_id` BIGINT NOT NULL DEFAULT 0 COMMENT '父级ID', + `menu_type` VARCHAR(16) NOT NULL COMMENT '类型(MENU/BUTTON)', + `menu_name` VARCHAR(128) NOT NULL COMMENT '菜单名称', + `path` VARCHAR(255) DEFAULT NULL COMMENT '路由路径', + `component` VARCHAR(255) DEFAULT NULL COMMENT '组件路径', + `permission` VARCHAR(128) DEFAULT NULL COMMENT '权限标识', + `icon` VARCHAR(64) DEFAULT NULL COMMENT '图标', + `visible` TINYINT NOT NULL DEFAULT 1 COMMENT '是否显示(0否 1是)', + `enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用(0否 1是)', + `sort` INT NOT NULL DEFAULT 0 COMMENT '排序', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除(0正常 1删除)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜单权限表'; + +-- ---------------------------- +-- 用户角色关联表 +-- ---------------------------- +DROP TABLE IF EXISTS `sys_user_role`; +CREATE TABLE `sys_user_role` ( + `id` BIGINT NOT NULL COMMENT '主键ID', + `user_id` BIGINT NOT NULL COMMENT '用户ID', + `role_id` BIGINT NOT NULL COMMENT '角色ID', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_role` (`user_id`, `role_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表'; + +-- ---------------------------- +-- 角色菜单关联表 +-- ---------------------------- +DROP TABLE IF EXISTS `sys_role_menu`; +CREATE TABLE `sys_role_menu` ( + `id` BIGINT NOT NULL COMMENT '主键ID', + `role_id` BIGINT NOT NULL COMMENT '角色ID', + `menu_id` BIGINT NOT NULL COMMENT '菜单ID', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_role_menu` (`role_id`, `menu_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色菜单关联表'; + +-- ---------------------------- +-- 枚举/字典类型表 +-- ---------------------------- +DROP TABLE IF EXISTS `sys_enum_type`; +CREATE TABLE `sys_enum_type` ( + `id` BIGINT NOT NULL COMMENT '主键ID', + `enum_code` VARCHAR(64) NOT NULL COMMENT '枚举编码/字典编码', + `enum_name` VARCHAR(128) DEFAULT NULL COMMENT '枚举名称', + `enum_desc` VARCHAR(255) DEFAULT NULL COMMENT '枚举描述', + `class_name` VARCHAR(255) DEFAULT NULL COMMENT '系统枚举完整类名', + `package_name` VARCHAR(255) DEFAULT NULL COMMENT '包路径', + `value_type` VARCHAR(128) DEFAULT NULL COMMENT '值类型', + `source_type` VARCHAR(16) NOT NULL DEFAULT 'CUSTOM' COMMENT '来源类型(SYSTEM/CUSTOM)', + `builtin` TINYINT NOT NULL DEFAULT 0 COMMENT '是否系统内置(0否 1是)', + `enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用(0否 1是)', + `sync_enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '是否允许系统同步更新(0否 1是)', + `sort` INT NOT NULL DEFAULT 0 COMMENT '排序', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `last_sync_time` DATETIME DEFAULT NULL COMMENT '最近同步时间', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_enum_code` (`enum_code`), + KEY `idx_source_type` (`source_type`), + KEY `idx_enabled_sort` (`enabled`, `sort`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='枚举/字典类型表'; + +-- ---------------------------- +-- 枚举/字典项表 +-- ---------------------------- +DROP TABLE IF EXISTS `sys_enum_item`; +CREATE TABLE `sys_enum_item` ( + `id` BIGINT NOT NULL COMMENT '主键ID', + `enum_code` VARCHAR(64) NOT NULL COMMENT '枚举编码/字典编码', + `item_value` VARCHAR(128) NOT NULL COMMENT '实际值', + `item_label` VARCHAR(128) NOT NULL COMMENT '显示文本', + `item_name` VARCHAR(128) DEFAULT NULL COMMENT '系统枚举常量名', + `source_type` VARCHAR(16) NOT NULL DEFAULT 'CUSTOM' COMMENT '来源类型(SYSTEM/CUSTOM)', + `builtin` TINYINT NOT NULL DEFAULT 0 COMMENT '是否系统内置(0否 1是)', + `enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用(0否 1是)', + `editable` TINYINT NOT NULL DEFAULT 1 COMMENT '是否允许后台编辑(0否 1是)', + `deletable` TINYINT NOT NULL DEFAULT 1 COMMENT '是否允许后台删除(0否 1是)', + `sort` INT NOT NULL DEFAULT 0 COMMENT '排序', + `color` VARCHAR(32) DEFAULT NULL COMMENT '扩展显示色值', + `tag_type` VARCHAR(32) DEFAULT NULL COMMENT '扩展标签类型', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `last_sync_time` DATETIME DEFAULT NULL COMMENT '最近同步时间', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_enum_code_value` (`enum_code`, `item_value`), + KEY `idx_enum_code` (`enum_code`), + KEY `idx_enabled_sort` (`enabled`, `sort`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='枚举/字典项表'; + +-- ---------------------------- +-- 定时任务表 +-- ---------------------------- +DROP TABLE IF EXISTS `sys_scheduled_task`; +CREATE TABLE `sys_scheduled_task` ( + `id` BIGINT NOT NULL COMMENT '主键ID', + `task_name` VARCHAR(128) NOT NULL COMMENT '任务名称', + `bean_name` VARCHAR(128) NOT NULL COMMENT 'Bean名称', + `method_name` VARCHAR(128) NOT NULL COMMENT '方法名称', + `method_params` VARCHAR(512) DEFAULT NULL COMMENT '方法参数', + `cron_expression` VARCHAR(64) NOT NULL COMMENT 'Cron表达式', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态(0暂停 1运行)', + `remark` VARCHAR(256) DEFAULT NULL COMMENT '备注', + `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除(0正常 1删除)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='定时任务表'; + +-- ---------------------------- +-- 初始管理员账号 (密码: admin123) +-- ---------------------------- +-- 注意: 实际密码需要通过SM3加密生成,此处为占位,启动后请通过接口重置密码