diff --git a/.gitignore b/.gitignore
index c327c544..27b758df 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,5 @@ logs
application-youlai.yml
/uploadFile/
+/boot-run*.log
+/boot-run*.err
diff --git a/README.en.md b/README.en.md
index 18138d06..5aa743a4 100644
--- a/README.en.md
+++ b/README.en.md
@@ -2,7 +2,7 @@
# youlai-boot
-**Enterprise-grade permission management backend based on Spring Boot**
+**Java 17 + Spring Boot 4.0.5 Enterprise-Grade Permission Management Backend**
[](https://spring.io/projects/spring-boot)
[](https://openjdk.org/)
@@ -17,7 +17,7 @@
-[](https://vue.youlai.tech)
+[](https://admin.youlai.tech)
[](https://app.youlai.tech)
[](https://www.youlai.tech/docs/server/spring-boot/)
[](./README.md)
@@ -30,7 +30,7 @@
## Core Features
-- 🔐 **Security** — Spring Security + JWT/Redis dual-session token model, token renewal, multi-device mutual exclusion
+- 🔐 **Security** — Spring Security + JWT/Redis Token formats, unified session management, token renewal, multi-device mutual exclusion
- 🔑 **Unified authentication (optional, disabled by default)** — [OIDC login](../docs/youlai-boot/oidc/implementation.md), delegates sign-in to an external identity provider and binds the authenticated account to a local user; nothing is loaded unless you enable it
@@ -100,14 +100,26 @@
> Every extension script is **optional**. Users, roles, menus, dictionaries, logs and the code generator all live in `sql/youlai-admin.sql`; importing the base schema alone is enough to run the system.
>
- > `oidc.sql` targets deployments that already run an identity provider: it only writes the "App Center" menu and one demo identity binding. Single sign-on is disabled by default (`security.oidc.enabled` plus the frontend `VITE_OIDC_ENABLED`); without an issuer, client secret and frontend switch no OIDC component is loaded, so the base features are unaffected. Skip it if you do not need single sign-on.
+ > `oidc.sql` targets deployments with an existing identity provider. It adds the "App Center" menu and one demo identity binding. The backend switch `security.oidc.enabled` defaults to false. Enabling it requires an issuer, client ID, client secret, and frontend callback URI. The frontend login entry is controlled separately by `VITE_OIDC_ENABLED`. Skip this extension if you do not need single sign-on.
-3. Adjust config (optional, a read-only online data source is configured by default):
+3. Check the development configuration and set the MySQL and Redis connections for your environment:
```bash
src/main/resources/application-dev.yml
```
+ Configuration is ordered by basic services, security and access, files and API documentation, then optional integrations. Start with the first half; email, SMS, mini programs, scheduled jobs, OIDC, AI, and workflow are grouped at the end. Sections separated by `---` belong to the same environment and load together.
+
+ `application.yml` holds shared rules. `application-dev.yml` and `application-prod.yml` expose common settings and integration credentials. CAPTCHA appearance, session lifetimes, and connection pools use defaults; add properties only when you need to override them. The IDE provides descriptions and default values. Configure file upload storage and credentials in the `storage` section.
+
+ Empty strings (`""`) in optional integrations mean not configured. Fill in your credentials when using the service, or leave the section alone. Email defaults to port 587 with SMTP authentication and STARTTLS; override these for other SMTP servers. The mini program provider defaults to `wechat-mini-main`. Set a new `wx.miniapp.provider` when replacing the AppID to avoid reusing existing identity bindings.
+
+ For local use, fill in YAML directly. Deployments can override it with external configuration files or native Spring Boot environment variables, such as `WX_MINIAPP_APPID` and `SECURITY_OIDC_ENABLED`, without adding placeholders to YAML. [Variable naming rules](https://docs.spring.io/spring-boot/reference/features/external-config.html#features.external-config.typesafe-configuration-properties.relaxed-binding.environment-variables): replace dots with underscores, remove hyphens, and uppercase the name. Do not commit real credentials.
+
+ Configure upload limits under `spring.servlet.multipart`; both the per-file and total request limits default to 50MB. When upgrading, migrate a custom `storage.upload.max-file-size` value to both `max-file-size` and `max-request-size`.
+
+ Production SQL logging uses SLF4J and Logback. Temporarily set the relevant Mapper package to `DEBUG` when investigating SQL; the default `INFO` level does not print SQL debug logs.
+
4. Start the service:
**Option 1: Run in IDEA (recommended)**
@@ -205,30 +217,46 @@ youlai-boot/
## Optional Extensions
-**Workflow (disabled by default)**
+These are off by default.
-Two steps to enable:
+**AI**: fill the key in the active profile and add `spring.ai.model.chat: openai`.
-1. Import the demo data (demo processes & demo accounts):
+```yaml
+spring:
+ ai:
+ model:
+ chat: openai
+ openai:
+ base-url: https://dashscope.aliyuncs.com/compatible-mode/v1
+ api-key: ""
+ chat:
+ model: qwen3.7-flash
+ temperature: 0.3
+ extra-body:
+ "[enable_thinking]": false
+```
- ```bash
- sql/extensions/workflow.sql
- ```
+**Workflow**: run the extension script to import menus, demo roles and forms (no `ACT_*` tables), then enable the switch in the active profile.
- The ACT_* engine tables do not need to be imported — Flowable creates them automatically on startup (existing tables are left untouched).
+```bash
+sql/extensions/workflow.sql
+```
-2. Enable it in `application-{profile}.yml`:
+```yaml
+flowable:
+ process:
+ enabled: true # single switch for the module and the engine
+ idm:
+ enabled: false
+ eventregistry:
+ enabled: false
+```
- ```yaml
- workflow:
- enabled: true
- ```
+Development creates engine tables on startup; production needs them created first. Demo processes come from the reset action on the process model page.
- The Flowable engine switches (process / idm / eventregistry) follow `workflow.enabled` via placeholders in the same `application-{profile}.yml`, no extra maintenance needed.
+**Single sign-on**: behind Nginx, uncomment `forward-headers-strategy: framework` in the profile's `server` block and make the proxy overwrite client `Forwarded` / `X-Forwarded-*` headers.
-**Multi-tenant**
-
-For tenant isolation and tenant configuration, use the standalone repo [youlai-boot-tenant](https://gitee.com/youlaiorg/youlai-boot-tenant).
+**Multi-tenant**: see [youlai-boot-tenant](https://gitee.com/youlaiorg/youlai-boot-tenant).
## License & Support
diff --git a/README.md b/README.md
index c45f66e2..08dddafe 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
-#
youlai-boot
+#
youlai-boot
-**Spring Boot 企业级权限管理系统后端**
+**Java 17 + Spring Boot 4.0.5 企业级权限管理系统后端**
[](https://spring.io/projects/spring-boot)
[](https://openjdk.org/)
@@ -17,7 +17,7 @@
-[](https://vue.youlai.tech)
+[](https://admin.youlai.tech)
[](https://app.youlai.tech)
[](https://www.youlai.tech/docs/server/spring-boot/)
[](./README.en.md)
@@ -30,7 +30,7 @@
## 核心特性
-- 🔐 **安全体系** — Spring Security + JWT/Redis Token 双会话模式、令牌续期、多端互斥
+- 🔐 **安全体系** — Spring Security + JWT/Redis Token 两种令牌格式、统一会话管理、令牌续期、多端互斥
- 🔑 **统一认证(可选,默认关闭)** — [OIDC 登录](../docs/youlai-boot/oidc/implementation.md),把登录交给外部认证中心,认证成功后关联本地用户并复用角色权限;不启用就不会加载相关组件
@@ -98,11 +98,7 @@
sql/extensions/oidc.sql # 统一认证:应用中心菜单与演示身份关联
```
- > 扩展脚本全部**可选**。用户、角色、菜单、字典、日志、代码生成等基础能力都在 `sql/youlai-admin.sql` 里,只导入基础库就能把系统跑起来。
- >
- > `oidc.sql` 面向"已经有认证中心"的场景,它只写入「应用中心」菜单和一条演示用的身份关联。单点登录本身默认关闭(`security.oidc.enabled` 与前端 `VITE_OIDC_ENABLED`),不填认证中心地址、客户端密钥和前端开关就不会加载 OIDC 组件,对基础功能零影响。暂时没有单点登录需求,跳过它即可。
-
-3. 修改配置(可选,默认已配置线上只读数据源):
+3. 检查开发配置,按实际环境填写 MySQL、Redis 连接信息:
```bash
src/main/resources/application-dev.yml
@@ -190,7 +186,7 @@ youlai-boot/
| [youlai-think](https://gitee.com/youlaiorg/youlai-think) | ThinkPHP + ThinkORM | PHP | ✅️ |
| [youlai-aspnet](https://gitee.com/youlaiorg/youlai-aspnet) | ASP.NET Core + EF Core | C# | ✅️ |
| [youlai-axum](https://gitee.com/youlaiorg/youlai-axum) | Axum + SeaORM | Rust | ✅️ |
-| [youlai-laravel](https://gitee.com/youlaiorg/youlai-laravel) | Laravel + Eloquent | PHP | ❌️ |
+| [youlai-laravel](https://gitee.com/youlaiorg/youlai-laravel) | Laravel + Eloquent | PHP | ✅️ |
> 九种后端共享同一套 **RESTful API 规范** 和 **数据库结构**,前端可无缝切换。
@@ -203,34 +199,50 @@ youlai-boot/
| [youlai-boot (db-pg)](https://gitee.com/youlaiorg/youlai-boot/tree/db-pg) | youlai-boot | 分支 | PostgreSQL 数据库分支 | ✅️ |
| [youlai-boot (multi-module)](https://gitee.com/youlaiorg/youlai-boot/tree/multi-module) | youlai-boot | 分支 | 多模块工程拆分 | ✅️ |
| [youlai-nest (multi-tenant)](https://gitee.com/youlaiorg/youlai-nest/tree/multi-tenant) | youlai-nest | 分支 | 多租户 SaaS,租户隔离与租户配置 | ✅️ |
-| [youlai-boot (spring-boot-3)](https://gitee.com/youlaiorg/youlai-boot/tree/spring-boot-3) | youlai-boot | 分支 | Spring Boot 3 兼容分支 | ❌️ |
+| [youlai-boot (spring-boot-3)](https://gitee.com/youlaiorg/youlai-boot/tree/spring-boot-3) | youlai-boot | 分支 | Spring Boot 3 兼容分支 | ✅️ |
## 可选扩展
-**工作流(默认关闭)**
+以下功能默认关闭,需要时再开启。
-两步开启:
+**AI 辅助**:在当前环境配置中填写密钥,并加上 `spring.ai.model.chat: openai`。
-1. 导入演示数据(演示流程与演示账号):
+```yaml
+spring:
+ ai:
+ model:
+ chat: openai
+ openai:
+ base-url: https://dashscope.aliyuncs.com/compatible-mode/v1
+ api-key: ""
+ chat:
+ model: qwen3.7-flash
+ temperature: 0.3
+ extra-body:
+ "[enable_thinking]": false
+```
- ```bash
- sql/extensions/workflow.sql
- ```
+**工作流**:先执行扩展脚本导入菜单、演示角色与发起表单(不含 `ACT_*` 引擎表),再在环境配置中打开开关。
- ACT_* 引擎表无需导入,启动时由 Flowable 自动创建(已存在则不重复处理)。
+```bash
+sql/extensions/workflow.sql
+```
-2. 在 `application-{profile}.yml` 中开启:
+```yaml
+flowable:
+ process:
+ enabled: true # 唯一开关,同时控制业务模块和引擎
+ idm:
+ enabled: false
+ eventregistry:
+ enabled: false
+```
- ```yaml
- workflow:
- enabled: true
- ```
+开发环境启动时自动建表,生产需先建表;演示流程在「流程模型」页点「重置工作流数据」后生成。
- Flowable 引擎开关(process / idm / eventregistry)在 `application.yml` 中以占位符跟随 `workflow.enabled`,无需单独维护;只改上面一处即可。
+**统一认证**:经 Nginx 接入时,取消环境配置 `server` 块中 `forward-headers-strategy: framework` 的注释,并让代理覆盖客户端的 `Forwarded` / `X-Forwarded-*`。
-**多租户**
-
-需要租户隔离与租户配置,使用独立仓库 [youlai-boot-tenant](https://gitee.com/youlaiorg/youlai-boot-tenant)。
+**多租户**:见 [youlai-boot-tenant](https://gitee.com/youlaiorg/youlai-boot-tenant)。
## 技术合作
diff --git a/boot-wf.err b/boot-wf.err
new file mode 100644
index 00000000..ec6e7216
--- /dev/null
+++ b/boot-wf.err
@@ -0,0 +1 @@
+16:12:01,542 |-WARN in ch.qos.logback.core.model.processor.AppenderModelHandler - Appender named [FILE] not referenced. Skipping further processing.
diff --git a/docs/images/logo/logo.png b/docs/images/logo/logo.png
index ebfa2af6..3b147dff 100644
Binary files a/docs/images/logo/logo.png and b/docs/images/logo/logo.png differ
diff --git a/docs/images/qrcode/wechat-mp.jpg b/docs/images/qrcode/wechat-mp.jpg
index 34852141..b97fd083 100644
Binary files a/docs/images/qrcode/wechat-mp.jpg and b/docs/images/qrcode/wechat-mp.jpg differ
diff --git a/docs/images/qrcode/wechat-official.jpg b/docs/images/qrcode/wechat-official.jpg
index 576f210a..507409ff 100644
Binary files a/docs/images/qrcode/wechat-official.jpg and b/docs/images/qrcode/wechat-official.jpg differ
diff --git a/pom.xml b/pom.xml
index 064270e0..36334d67 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
com.youlai
youlai-boot
-
4.14.0
+
4.14.1
基于 Java 17 + SpringBoot 4 + Spring Security 构建的权限管理系统。
@@ -71,7 +71,7 @@
1.2.5
- 2.0.0
+ 2.0.2
@@ -133,7 +133,7 @@
test
-
+
org.springframework.boot
spring-boot-starter-webmvc-test
@@ -147,7 +147,7 @@
test
-
+
com.h2database
h2
@@ -241,7 +241,7 @@
spring-boot-starter-cache
-
+
org.springframework.boot
spring-boot-starter-aspectj
@@ -252,7 +252,7 @@
spring-boot-starter-validation
-
+
org.springframework.ai
spring-ai-starter-model-openai
@@ -320,7 +320,7 @@
${xxl-job.version}
-
+
cn.idev.excel
fastexcel
@@ -440,6 +440,40 @@
${project.artifactId}
+
+ 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-configuration-processor
+ ${project.parent.version}
+
+
+
+ lombok.launch.AnnotationProcessorHider$AnnotationProcessor
+ org.mapstruct.ap.MappingProcessor
+ org.springframework.boot.configurationprocessor.ConfigurationMetadataAnnotationProcessor
+
+
+
org.apache.maven.plugins
maven-surefire-plugin
diff --git a/sql/extensions/workflow.sql b/sql/extensions/workflow.sql
index c3f12f83..7bc68d1e 100644
--- a/sql/extensions/workflow.sql
+++ b/sql/extensions/workflow.sql
@@ -65,7 +65,7 @@ INSERT IGNORE INTO `sys_role_menu` VALUES (10, 91), (10, 9104), (10, 9105);
-- ----------------------------
-- 演示流程的发起表单(依赖动态表单扩展)
--- form_key 与 BPMN 开始节点 flowable:formKey 一一对应
+-- form_key 与 BPMN 发起人节点 flowable:formKey 一一对应
-- ID 段 1004-1007 顺延 dynamic-form.sql 预置的 1001-1003
-- ----------------------------
INSERT IGNORE INTO `form_definition` (`id`, `form_key`, `form_name`, `description`, `form_json`, `options_json`, `status`, `is_public`, `category`, `menu_id`, `version`, `create_time`, `update_time`, `create_by`, `update_by`, `is_deleted`) VALUES
diff --git a/src/main/java/com/youlai/boot/auth/oidc/config/OidcProperties.java b/src/main/java/com/youlai/boot/auth/oidc/config/OidcProperties.java
index 6487fa1c..3533faf2 100644
--- a/src/main/java/com/youlai/boot/auth/oidc/config/OidcProperties.java
+++ b/src/main/java/com/youlai/boot/auth/oidc/config/OidcProperties.java
@@ -20,24 +20,27 @@ import org.springframework.validation.annotation.Validated;
@ConfigurationProperties(prefix = "security.oidc")
public class OidcProperties {
+ /** 是否启用 OIDC 统一认证;启用后必须配置认证中心、客户端凭据和回跳地址 */
private boolean enabled = false;
- // 身份表中的稳定标识;更换认证中心时使用新值或迁移关联
+ /** 身份关联使用的稳定标识;更换认证中心时使用新值或迁移已有关联 */
@NotBlank
@Pattern(regexp = "[a-z][a-z0-9-]{0,63}")
private String provider = "oidc-main";
- // 必须与认证中心公布的 issuer 完全一致
+ /** 认证中心地址,必须与其公布的 issuer 完全一致 */
@NotBlank
private String issuer;
+ /** 认证中心注册的客户端 ID,启用时必填 */
@NotBlank
private String clientId;
+ /** 客户端密钥,启用时必填;当前使用 client_secret_basic 认证 */
@NotBlank
private String clientSecret;
- // 认证成功后的前端回调地址
+ /** 认证成功后的前端回跳地址,启用时必填 */
@NotBlank
private String frontendCallbackUri;
}
diff --git a/src/main/java/com/youlai/boot/auth/password/config/CaptchaProperties.java b/src/main/java/com/youlai/boot/auth/password/config/CaptchaProperties.java
index 7e7bda1c..e3ad9910 100644
--- a/src/main/java/com/youlai/boot/auth/password/config/CaptchaProperties.java
+++ b/src/main/java/com/youlai/boot/auth/password/config/CaptchaProperties.java
@@ -5,7 +5,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
- * 验证码配置属性
+ * 验证码配置属性;默认使用圆圈干扰的算术验证码,可按需覆盖
*
* @author Ray.Hao
* @since 2023/11/24
@@ -15,38 +15,38 @@ import org.springframework.stereotype.Component;
@Data
public class CaptchaProperties {
- // 验证码类型 circle-圆圈干扰验证码|gif-Gif验证码|line-干扰线验证码|shear-扭曲干扰验证码
- private String type;
+ /** 验证码类型 circle-圆圈干扰验证码|gif-Gif验证码|line-干扰线验证码|shear-扭曲干扰验证码 */
+ private String type = "circle";
- // 验证码图片宽度
- private int width;
- // 验证码图片高度
- private int height;
+ /** 验证码图片宽度 */
+ private int width = 120;
+ /** 验证码图片高度 */
+ private int height = 40;
- // 干扰线数量
- private int interfereCount;
+ /** 干扰线数量 */
+ private int interfereCount = 2;
- // 文本透明度
- private Float textAlpha;
+ /** 文本透明度 */
+ private Float textAlpha = 0.8F;
- // 验证码过期时间,单位:秒
- private Long expireSeconds;
+ /** 验证码过期时间,单位:秒 */
+ private Long expireSeconds = 120L;
- // 验证码字符配置
- private CodeProperties code;
+ /** 验证码字符配置 */
+ private CodeProperties code = new CodeProperties();
- // 验证码字体
- private FontProperties font;
+ /** 验证码字体 */
+ private FontProperties font = new FontProperties();
/**
* 验证码字符配置
*/
@Data
public static class CodeProperties {
- // 验证码字符类型 math-算术|random-随机字符串
- private String type;
- // 验证码字符长度,type=算术时,表示运算位数(1:个位数 2:十位数);type=随机字符时,表示字符个数
- private int length;
+ /** 验证码字符类型 math-算术|random-随机字符串 */
+ private String type = "math";
+ /** 验证码字符长度,type=算术时,表示运算位数(1:个位数 2:十位数);type=随机字符时,表示字符个数 */
+ private int length = 1;
}
/**
@@ -54,12 +54,12 @@ public class CaptchaProperties {
*/
@Data
public static class FontProperties {
- // 字体名称
- private String name;
- // 字体样式 0-普通|1-粗体|2-斜体
- private int weight;
- // 字体大小
- private int size;
+ /** 字体名称 */
+ private String name = "SansSerif";
+ /** 字体样式 0-普通|1-粗体|2-斜体 */
+ private int weight = 1;
+ /** 字体大小 */
+ private int size = 24;
}
diff --git a/src/main/java/com/youlai/boot/auth/password/exception/CaptchaException.java b/src/main/java/com/youlai/boot/auth/password/exception/CaptchaException.java
index 5e484b00..8ab98ea0 100644
--- a/src/main/java/com/youlai/boot/auth/password/exception/CaptchaException.java
+++ b/src/main/java/com/youlai/boot/auth/password/exception/CaptchaException.java
@@ -4,7 +4,7 @@ import com.youlai.boot.common.result.ResultCode;
import lombok.Getter;
/**
- * 图形验证码校验失败时抛出的异常。
+ * 图形验证码校验失败时抛出的异常
*
* @author Ray.Hao
* @since 3.1.0
diff --git a/src/main/java/com/youlai/boot/auth/sms/token/SmsAuthenticationToken.java b/src/main/java/com/youlai/boot/auth/sms/token/SmsAuthenticationToken.java
index 84e9d787..9ce48c4f 100644
--- a/src/main/java/com/youlai/boot/auth/sms/token/SmsAuthenticationToken.java
+++ b/src/main/java/com/youlai/boot/auth/sms/token/SmsAuthenticationToken.java
@@ -8,7 +8,7 @@ import java.io.Serial;
import java.util.Collection;
/**
- * 短信验证码认证 Token。
+ * 短信验证码认证 Token
*
* @author Ray.Hao
* @since 2.20.0
diff --git a/src/main/java/com/youlai/boot/auth/wxma/config/WxMaProperties.java b/src/main/java/com/youlai/boot/auth/wxma/config/WxMaProperties.java
index 41f70591..9d9c2987 100644
--- a/src/main/java/com/youlai/boot/auth/wxma/config/WxMaProperties.java
+++ b/src/main/java/com/youlai/boot/auth/wxma/config/WxMaProperties.java
@@ -26,9 +26,9 @@ public class WxMaProperties {
@Pattern(regexp = "[a-z][a-z0-9-]{0,63}")
private String provider = "wechat-mini-main";
- // 小程序 appid
+ /** 小程序 appid */
private String appid;
- // 小程序 appsecret
+ /** 小程序 appsecret */
private String secret;
}
diff --git a/src/main/java/com/youlai/boot/auth/wxma/token/WxMaAuthenticationToken.java b/src/main/java/com/youlai/boot/auth/wxma/token/WxMaAuthenticationToken.java
index 24014e61..d6925118 100644
--- a/src/main/java/com/youlai/boot/auth/wxma/token/WxMaAuthenticationToken.java
+++ b/src/main/java/com/youlai/boot/auth/wxma/token/WxMaAuthenticationToken.java
@@ -8,7 +8,7 @@ import java.io.Serial;
import java.util.Collection;
/**
- * 微信小程序认证 Token。
+ * 微信小程序认证 Token
*
* @author Ray.Hao
* @since 4.0.0
diff --git a/src/main/java/com/youlai/boot/codegen/controller/CodegenController.java b/src/main/java/com/youlai/boot/codegen/controller/CodegenController.java
index 77d4433c..abaf25b3 100644
--- a/src/main/java/com/youlai/boot/codegen/controller/CodegenController.java
+++ b/src/main/java/com/youlai/boot/codegen/controller/CodegenController.java
@@ -85,7 +85,7 @@ public class CodegenController {
@Operation(summary = "获取预览生成代码")
@GetMapping("/{tableName}/preview")
public Result> getTablePreviewData(@PathVariable String tableName,
- @RequestParam(value = "pageType", required = false, defaultValue = "classic") String pageType,
+ @RequestParam(value = "pageType", required = false, defaultValue = "crud") String pageType,
@RequestParam(value = "type", required = false, defaultValue = "ts") String type) {
List list = codegenService.getCodegenPreviewData(tableName, pageType, type);
return Result.success(list);
@@ -95,7 +95,7 @@ public class CodegenController {
@GetMapping("/{tableName}/download")
@Log(module = LogModuleEnum.CODEGEN, value = ActionTypeEnum.DOWNLOAD)
public void downloadZip(HttpServletResponse response, @PathVariable String tableName,
- @RequestParam(value = "pageType", required = false, defaultValue = "classic") String pageType,
+ @RequestParam(value = "pageType", required = false, defaultValue = "crud") String pageType,
@RequestParam(value = "type", required = false, defaultValue = "ts") String type) {
String[] tableNames = tableName.split(",");
byte[] data = codegenService.downloadCode(tableNames, pageType, type);
diff --git a/src/main/java/com/youlai/boot/codegen/enums/JavaTypeEnum.java b/src/main/java/com/youlai/boot/codegen/enums/JavaTypeEnum.java
index 9c167fe2..de9660fb 100644
--- a/src/main/java/com/youlai/boot/codegen/enums/JavaTypeEnum.java
+++ b/src/main/java/com/youlai/boot/codegen/enums/JavaTypeEnum.java
@@ -23,7 +23,8 @@ public enum JavaTypeEnum {
TINYINT("tinyint", "Integer", "number"),
SMALLINT("smallint", "Integer", "number"),
MEDIUMINT("mediumint", "Integer", "number"),
- BIGINT("bigint", "Long", "number"),
+ // Long 与 BigInteger 由 Jackson 全局配置序列化为字符串,前端类型保持一致
+ BIGINT("bigint", "Long", "string"),
FLOAT("float", "Float", "number"),
DOUBLE("double", "Double", "number"),
DECIMAL("decimal", "BigDecimal", "number"),
diff --git a/src/main/java/com/youlai/boot/codegen/model/form/GenConfigForm.java b/src/main/java/com/youlai/boot/codegen/model/form/GenConfigForm.java
index db1ab797..c37f9447 100644
--- a/src/main/java/com/youlai/boot/codegen/model/form/GenConfigForm.java
+++ b/src/main/java/com/youlai/boot/codegen/model/form/GenConfigForm.java
@@ -60,7 +60,7 @@ public class GenConfigForm {
@Schema(description = "前端应用名")
private String frontendAppName;
- @Schema(description = "页面类型 classic|crud", example = "classic")
+ @Schema(description = "页面类型 classic|crud", example = "crud")
private String pageType;
@Schema(description = "要移除的表前缀,如: sys_", example = "sys_")
diff --git a/src/main/java/com/youlai/boot/codegen/property/CodegenProperties.java b/src/main/java/com/youlai/boot/codegen/property/CodegenProperties.java
index 67ae9736..35dfa217 100644
--- a/src/main/java/com/youlai/boot/codegen/property/CodegenProperties.java
+++ b/src/main/java/com/youlai/boot/codegen/property/CodegenProperties.java
@@ -2,6 +2,7 @@ package com.youlai.boot.codegen.property;
import cn.hutool.core.io.file.FileNameUtil;
import cn.hutool.core.map.MapUtil;
+import cn.hutool.core.util.StrUtil;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@@ -21,22 +22,37 @@ import java.util.Map;
public class CodegenProperties {
- // 默认配置
- private DefaultConfig defaultConfig ;
+ /** 默认配置 */
+ private DefaultConfig defaultConfig;
- // 模板配置
+ /** 模板配置 */
private Map templateConfigs = MapUtil.newHashMap(true);
- // 后端应用名
+ /** 模块名 → 权限前缀,未配置时用模块名 */
+ private Map permissionNamespaces = MapUtil.newHashMap(true);
+
+ /**
+ * 解析权限标识前缀
+ *
+ * @param moduleName 模块名
+ * @param entityName 实体名
+ * @return 权限标识前缀,如 sys:user
+ */
+ public String resolvePermissionPrefix(String moduleName, String entityName) {
+ String namespace = permissionNamespaces.getOrDefault(moduleName, moduleName);
+ return namespace + ":" + StrUtil.toSymbolCase(entityName, '-');
+ }
+
+ /** 后端应用名 */
private String backendAppName;
- // 前端应用名
+ /** 前端应用名 */
private String frontendAppName;
- // 下载文件名
+ /** 下载文件名 */
private String downloadFileName;
- // 排除数据表
+ /** 排除数据表 */
private List excludeTables;
/**
@@ -45,13 +61,13 @@ public class CodegenProperties {
@Data
public static class TemplateConfig {
- // 模板路径,相对模板根 classpath:codegen (e.g. backend/controller.java.vm)
+ /** 模板路径,相对模板根 classpath:codegen (e.g. backend/controller.java.vm) */
private String templatePath;
- // 子包名 (e.g. controller/service/mapper/model)
+ /** 子包名 (e.g. controller/service/mapper/model) */
private String subpackageName;
- // 文件扩展名,如 .java
+ /** 文件扩展名,如 .java */
private String extension = FileNameUtil.EXT_JAVA;
}
@@ -62,10 +78,10 @@ public class CodegenProperties {
@Data
public static class DefaultConfig {
- // 作者 (e.g. Ray)
+ /** 作者 (e.g. Ray) */
private String author;
- // 默认模块名(e.g. system)
+ /** 默认模块名(e.g. system) */
private String moduleName;
}
diff --git a/src/main/java/com/youlai/boot/codegen/service/impl/CodegenServiceImpl.java b/src/main/java/com/youlai/boot/codegen/service/impl/CodegenServiceImpl.java
index f4513a5d..feb09d75 100644
--- a/src/main/java/com/youlai/boot/codegen/service/impl/CodegenServiceImpl.java
+++ b/src/main/java/com/youlai/boot/codegen/service/impl/CodegenServiceImpl.java
@@ -16,6 +16,7 @@ import com.youlai.boot.codegen.property.CodegenProperties;
import com.youlai.boot.codegen.service.GenTableService;
import com.youlai.boot.codegen.service.GenTableColumnService;
import com.youlai.boot.codegen.service.CodegenService;
+import com.youlai.boot.codegen.util.NamingUtils;
import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.codegen.mapper.DatabaseMapper;
import com.youlai.boot.codegen.model.entity.GenTable;
@@ -46,6 +47,9 @@ import java.util.zip.ZipOutputStream;
@Slf4j
public class CodegenServiceImpl implements CodegenService {
+ private static final List FEATURES = List.of(
+ "LocalDate", "LocalDateTime", "BigDecimal", "NotBlank", "NotNull", "Size");
+
private final DatabaseMapper databaseMapper;
private final CodegenProperties codegenProperties;
private final GenTableService genTableService;
@@ -247,14 +251,14 @@ public class CodegenServiceImpl implements CodegenService {
+ File.separator + "src"
+ File.separator + "api"
+ File.separator + moduleName
- + File.separator + StrUtil.toSymbolCase(entityName, '-')
+ + File.separator + NamingUtils.kebab(entityName)
);
} else if ("API_TYPES".equals(templateName)) {
path = (codegenProperties.getFrontendAppName()
+ File.separator + "src"
+ File.separator + "api"
+ File.separator + moduleName
- + File.separator + StrUtil.toSymbolCase(entityName, '-')
+ + File.separator + NamingUtils.kebab(entityName)
);
} else if ("VIEW".equals(templateName)) {
// path = "src/views/system/user";
@@ -262,7 +266,7 @@ public class CodegenServiceImpl implements CodegenService {
+ File.separator + "src"
+ File.separator + subPackageName
+ File.separator + moduleName
- + File.separator + StrUtil.toSymbolCase(entityName, '-')
+ + File.separator + NamingUtils.kebab(entityName)
);
} else {
path = (codegenProperties.getBackendAppName()
@@ -297,8 +301,8 @@ public class CodegenServiceImpl implements CodegenService {
String pageType) {
Map bindMap = new HashMap<>();
-
String entityName = genTable.getEntityName();
+ String entityKebab = NamingUtils.kebab(entityName);
bindMap.put("packageName", genTable.getPackageName());
bindMap.put("moduleName", genTable.getModuleName());
@@ -307,65 +311,41 @@ public class CodegenServiceImpl implements CodegenService {
bindMap.put("entityName", entityName);
bindMap.put("tableName", genTable.getTableName());
bindMap.put("author", genTable.getAuthor());
- String entityLowerCamel = StrUtil.lowerFirst(entityName);
- String entityKebab = StrUtil.toSymbolCase(entityName, '-');
- String entityUpperSnake = StrUtil.toSymbolCase(entityName, '_').toUpperCase();
- bindMap.put("entityLowerCamel", entityLowerCamel);
+ bindMap.put("entityLowerCamel", StrUtil.lowerFirst(entityName));
bindMap.put("entityKebab", entityKebab);
- bindMap.put("entityUpperSnake", entityUpperSnake);
+ bindMap.put("entityKebabPlural", NamingUtils.pluralKebab(entityKebab));
+ bindMap.put("entityUpperSnake", NamingUtils.upperSnake(entityName));
+ bindMap.put("permissionPrefix",
+ codegenProperties.resolvePermissionPrefix(genTable.getModuleName(), entityName));
bindMap.put("businessName", genTable.getBusinessName());
- bindMap.put("entityComment", genTable.getBusinessName());
bindMap.put("fieldConfigs", fieldConfigs);
- boolean hasLocalDate = false;
- boolean hasLocalDateTime = false;
- boolean hasBigDecimal = false;
- boolean hasNotBlank = false;
- boolean hasNotNull = false;
- boolean hasSize = false;
-
+ Set features = new HashSet<>();
for (GenTableColumn fieldConfig : fieldConfigs) {
-
if (StrUtil.isBlank(fieldConfig.getFieldType())) {
fieldConfig.setFieldType(JavaTypeEnum.getJavaTypeByColumnType(fieldConfig.getColumnType()));
}
-
- if ("LocalDate".equals(fieldConfig.getFieldType())) {
- hasLocalDate = true;
- }
- if ("LocalDateTime".equals(fieldConfig.getFieldType())) {
- hasLocalDateTime = true;
- }
- if ("BigDecimal".equals(fieldConfig.getFieldType())) {
- hasBigDecimal = true;
- }
+ String fieldType = fieldConfig.getFieldType();
+ features.add(fieldType);
if (ObjectUtil.equals(fieldConfig.getIsShowInForm(), 1) && !"id".equals(fieldConfig.getFieldName())) {
- boolean isString = "String".equals(fieldConfig.getFieldType());
+ boolean isString = "String".equals(fieldType);
if (ObjectUtil.equals(fieldConfig.getIsRequired(), 1)) {
- if (isString) {
- hasNotBlank = true;
- } else {
- hasNotNull = true;
- }
+ features.add(isString ? "NotBlank" : "NotNull");
}
if (isString && fieldConfig.getMaxLength() != null && fieldConfig.getMaxLength() > 0) {
- hasSize = true;
+ features.add("Size");
}
}
- fieldConfig.setTsType(JavaTypeEnum.getTsTypeByJavaType(fieldConfig.getFieldType()));
+ fieldConfig.setTsType(JavaTypeEnum.getTsTypeByJavaType(fieldType));
+ }
+ for (String feature : FEATURES) {
+ bindMap.put("has" + feature, features.contains(feature));
}
- bindMap.put("hasLocalDate", hasLocalDate);
- bindMap.put("hasLocalDateTime", hasLocalDateTime);
- bindMap.put("hasBigDecimal", hasBigDecimal);
- bindMap.put("hasNotBlank", hasNotBlank);
- bindMap.put("hasNotNull", hasNotNull);
- bindMap.put("hasSize", hasSize);
-
TemplateEngine templateEngine = TemplateUtil.createEngine(new TemplateConfig("codegen", TemplateConfig.ResourceMode.CLASSPATH));
- // 根据 ui 选择不同的前端页面模板:默认 index.vue.vm;封装版使用 index.crud.vue.vm
+ // classic 为兼容模板,其余情况统一使用 CrudPage 页面
String path = templatePath;
- if ("crud".equalsIgnoreCase(pageType)) {
+ if (!"classic".equalsIgnoreCase(pageType)) {
if (path.endsWith("index.js.vue.vm")) {
path = path.replace("index.js.vue.vm", "index.crud.js.vue.vm");
} else if (path.endsWith("index.vue.vm")) {
diff --git a/src/main/java/com/youlai/boot/codegen/service/impl/GenTableServiceImpl.java b/src/main/java/com/youlai/boot/codegen/service/impl/GenTableServiceImpl.java
index 737149f8..4d9a7ddf 100644
--- a/src/main/java/com/youlai/boot/codegen/service/impl/GenTableServiceImpl.java
+++ b/src/main/java/com/youlai/boot/codegen/service/impl/GenTableServiceImpl.java
@@ -198,16 +198,21 @@ public class GenTableServiceImpl extends ServiceImpl i
@Override
public void saveGenConfig(GenConfigForm formData) {
GenTable genTable = codegenConverter.toGenTable(formData);
+ if (StrUtil.isBlank(genTable.getPageType())) {
+ genTable.setPageType("crud");
+ }
this.saveOrUpdate(genTable);
// 如果选择上级菜单且当前环境不是生产环境,则保存菜单
Long parentMenuId = formData.getParentMenuId();
if (parentMenuId != null && !EnvEnum.PROD.getValue().equals(springProfilesActive)) {
+ String permissionPrefix = codegenProperties.resolvePermissionPrefix(
+ genTable.getModuleName(), genTable.getEntityName());
MenuCreateDTO dto = new MenuCreateDTO(
genTable.getBusinessName(),
genTable.getEntityName(),
genTable.getModuleName(),
- genTable.getTableName()
+ permissionPrefix
);
menuService.addMenuForCodegen(parentMenuId, dto);
}
diff --git a/src/main/java/com/youlai/boot/codegen/util/NamingUtils.java b/src/main/java/com/youlai/boot/codegen/util/NamingUtils.java
new file mode 100644
index 00000000..09fd819c
--- /dev/null
+++ b/src/main/java/com/youlai/boot/codegen/util/NamingUtils.java
@@ -0,0 +1,42 @@
+package com.youlai.boot.codegen.util;
+
+import cn.hutool.core.util.StrUtil;
+
+/**
+ * 命名转换,代码生成的目录、路由与权限标识使用同一套规则
+ *
+ * @author Ray.Hao
+ * @since 4.14.1
+ */
+public final class NamingUtils {
+
+ private NamingUtils() {
+ }
+
+ /**
+ * 转短横线命名,如 MenuDemo → menu-demo
+ */
+ public static String kebab(String value) {
+ return StrUtil.toSymbolCase(value, '-');
+ }
+
+ /**
+ * 转大写下划线命名,如 MenuDemo → MENU_DEMO
+ */
+ public static String upperSnake(String value) {
+ return StrUtil.toSymbolCase(value, '_').toUpperCase();
+ }
+
+ /**
+ * 短横线命名转英文复数,如 dict-item → dict-items、category → categories
+ */
+ public static String pluralKebab(String kebab) {
+ if (kebab.matches(".*(s|x|z|ch|sh)$")) {
+ return kebab + "es";
+ }
+ if (kebab.matches(".*[^aeiou]y$")) {
+ return kebab.substring(0, kebab.length() - 1) + "ies";
+ }
+ return kebab + "s";
+ }
+}
diff --git a/src/main/java/com/youlai/boot/common/annotation/ValidField.java b/src/main/java/com/youlai/boot/common/annotation/ValidField.java
index 559b06e1..d89b37dd 100644
--- a/src/main/java/com/youlai/boot/common/annotation/ValidField.java
+++ b/src/main/java/com/youlai/boot/common/annotation/ValidField.java
@@ -19,7 +19,7 @@ import java.lang.annotation.*;
public @interface ValidField {
/**
- * 验证失败时的错误信息。
+ * 验证失败时的错误信息
*/
String message() default "非法字段";
@@ -28,7 +28,7 @@ public @interface ValidField {
Class extends Payload>[] payload() default {};
/**
- * 允许的合法值列表。
+ * 允许的合法值列表
*/
String[] allowedValues();
diff --git a/src/main/java/com/youlai/boot/common/exception/RateLimitException.java b/src/main/java/com/youlai/boot/common/exception/RateLimitException.java
index 012bac2d..63da843d 100644
--- a/src/main/java/com/youlai/boot/common/exception/RateLimitException.java
+++ b/src/main/java/com/youlai/boot/common/exception/RateLimitException.java
@@ -4,7 +4,7 @@ import com.youlai.boot.common.result.ResultCode;
import lombok.Getter;
/**
- * 接口限流异常:请求超出限流阈值时抛出,由全局异常处理器统一映射为 HTTP 429。
+ * 接口限流异常:请求超出限流阈值时抛出,由全局异常处理器统一映射为 HTTP 429
*
* @author Ray.Hao
* @since 4.4.0
diff --git a/src/main/java/com/youlai/boot/common/result/PageResult.java b/src/main/java/com/youlai/boot/common/result/PageResult.java
index b9df6bf0..214cf516 100644
--- a/src/main/java/com/youlai/boot/common/result/PageResult.java
+++ b/src/main/java/com/youlai/boot/common/result/PageResult.java
@@ -23,7 +23,7 @@ public class PageResult implements Serializable {
private PageData data;
/**
- * 构建分页结果(MyBatis-Plus {@link IPage})。
+ * 构建分页结果(MyBatis-Plus {@link IPage})
*/
public static PageResult success(IPage page) {
PageResult result = new PageResult<>();
@@ -43,7 +43,7 @@ public class PageResult implements Serializable {
}
/**
- * 构建列表结果(无分页)。
+ * 构建列表结果(无分页)
*/
public static PageResult success(List list) {
PageResult result = new PageResult<>();
diff --git a/src/main/java/com/youlai/boot/config/CacheConfig.java b/src/main/java/com/youlai/boot/config/CacheConfig.java
new file mode 100644
index 00000000..1740df95
--- /dev/null
+++ b/src/main/java/com/youlai/boot/config/CacheConfig.java
@@ -0,0 +1,13 @@
+package com.youlai.boot.config;
+
+import org.springframework.cache.annotation.EnableCaching;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * 启用缓存注解,由 Spring Boot 根据 spring.cache.type 选择唯一的缓存管理器
+ * none 使用空操作管理器,不缓存结果,也不影响登录会话等直接使用 Redis 的功能
+ */
+@EnableCaching
+@Configuration(proxyBeanMethods = false)
+public class CacheConfig {
+}
diff --git a/src/main/java/com/youlai/boot/config/CaffeineConfig.java b/src/main/java/com/youlai/boot/config/CaffeineConfig.java
deleted file mode 100644
index bba14491..00000000
--- a/src/main/java/com/youlai/boot/config/CaffeineConfig.java
+++ /dev/null
@@ -1,37 +0,0 @@
-package com.youlai.boot.config;
-
-import com.github.benmanes.caffeine.cache.Caffeine;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.cache.CacheManager;
-import org.springframework.cache.caffeine.CaffeineCacheManager;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-
-/**
- * caffeine缓存配置
- *
- * @author Theo
- * @since 2025-01-22 17:40:23
- */
-@Slf4j
-@Configuration
-public class CaffeineConfig {
-
- @Value("${spring.cache.caffeine.spec}")
- private String caffeineSpec;
-
- /**
- * 缓存管理器
- *
- * @return CacheManager 缓存管理器
- */
- @Bean
- public CacheManager cacheManager() {
- CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager();
- Caffeine caffeineBuilder = Caffeine.from(caffeineSpec);
- caffeineCacheManager.setCaffeine(caffeineBuilder);
- return caffeineCacheManager;
- }
-}
-
diff --git a/src/main/java/com/youlai/boot/config/RedisCacheConfig.java b/src/main/java/com/youlai/boot/config/RedisCacheConfig.java
index bd8883d9..b72dc828 100644
--- a/src/main/java/com/youlai/boot/config/RedisCacheConfig.java
+++ b/src/main/java/com/youlai/boot/config/RedisCacheConfig.java
@@ -3,42 +3,23 @@ package com.youlai.boot.config;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.cache.autoconfigure.CacheProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
-import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
-import org.springframework.data.redis.cache.RedisCacheManager;
-import org.springframework.data.redis.cache.RedisCacheWriter;
-import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.RedisSerializer;
/**
- * Redis 缓存配置
+ * Redis 缓存的序列化与过期策略,缓存管理器由 Spring Boot 自动装配
*
* @author Ray.Hao
* @since 2023/12/4
*/
-@EnableCaching
@EnableConfigurationProperties(CacheProperties.class)
-@Configuration
-@ConditionalOnProperty(name = "spring.cache.enabled") // 未配置视为关闭
+@Configuration(proxyBeanMethods = false)
+@ConditionalOnProperty(prefix = "spring.cache", name = "type", havingValue = "redis")
public class RedisCacheConfig {
- /**
- * 自定义 RedisCacheManager
- *
- * @param redisConnectionFactory {@link RedisConnectionFactory}
- * @param cacheProperties {@link CacheProperties}
- * @return {@link RedisCacheManager}
- */
- @Bean
- public RedisCacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory, CacheProperties cacheProperties){
- return RedisCacheManager.builder(RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory))
- .cacheDefaults(redisCacheConfiguration(cacheProperties))
- .build();
- }
-
/**
* 自定义 RedisCacheConfiguration
*
@@ -61,11 +42,12 @@ public class RedisCacheConfig {
if (!redisProperties.isCacheNullValues()) {
config = config.disableCachingNullValues();
}
+ // 保留现有 cacheName: 前缀格式,并支持 spring.cache.redis.key-prefix
+ String keyPrefix = redisProperties.getKeyPrefix();
+ config = config.computePrefixWith(name -> (keyPrefix != null ? keyPrefix : "") + name + ":");
if (!redisProperties.isUseKeyPrefix()) {
config = config.disableKeyPrefix();
}
- // 覆盖默认key双冒号 CacheKeyPrefix#prefixed
- config = config.computePrefixWith(name -> name + ":");
return config;
}
diff --git a/src/main/java/com/youlai/boot/config/SecurityConfig.java b/src/main/java/com/youlai/boot/config/SecurityConfig.java
index d3640845..2f4f3431 100644
--- a/src/main/java/com/youlai/boot/config/SecurityConfig.java
+++ b/src/main/java/com/youlai/boot/config/SecurityConfig.java
@@ -72,8 +72,6 @@ public class SecurityConfig {
.authorizeHttpRequests(requestMatcherRegistry -> {
// 异步响应的后续分发复用首次请求认证结果
requestMatcherRegistry.dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll();
- // 握手使用一次性票据;其余实时接口仍需 Bearer 认证
- requestMatcherRegistry.requestMatchers(HttpMethod.GET, "/api/v1/realtime/ws").permitAll();
String[] permitAllUrls = securityProperties.getPermitAllUrls();
if (ArrayUtil.isNotEmpty(permitAllUrls)) {
requestMatcherRegistry.requestMatchers(permitAllUrls).permitAll();
@@ -85,6 +83,8 @@ public class SecurityConfig {
requestMatcherRegistry.requestMatchers("/api/v1/open/**").permitAll();
// WebRTC 信令端点:双身份(设备签名 / client JWT)由握手拦截器自行鉴权,不走标准 HTTP 认证链
requestMatcherRegistry.requestMatchers("/ws/signal").permitAll();
+ // 握手使用一次性票据;其余实时接口仍需 Bearer 认证
+ requestMatcherRegistry.requestMatchers(HttpMethod.GET, "/api/v1/realtime/ws").permitAll();
// client 家属端接口:与后台管理使用同一套 TokenManager 鉴权,
// 但仅允许携带 client 端令牌(clientType=client)访问,实现端与端隔离
requestMatcherRegistry.requestMatchers("/api/v1/client/**")
diff --git a/src/main/java/com/youlai/boot/config/XxlJobConfig.java b/src/main/java/com/youlai/boot/config/XxlJobConfig.java
index 909b677d..9af03377 100644
--- a/src/main/java/com/youlai/boot/config/XxlJobConfig.java
+++ b/src/main/java/com/youlai/boot/config/XxlJobConfig.java
@@ -8,11 +8,12 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
+ * XXL-JOB 执行器配置;地址和 IP 默认自动获取,端口和日志策略可按需覆盖
*
* @author xuxueli 2017-04-28
*/
@Configuration
-@ConditionalOnProperty(name = "xxl.job.enabled") // xxl.job.enabled = true 才会自动装配
+@ConditionalOnProperty(name = "xxl.job.enabled", havingValue = "true")
@Slf4j
public class XxlJobConfig {
@@ -25,19 +26,19 @@ public class XxlJobConfig {
@Value("${xxl.job.executor.appname}")
private String appname;
- @Value("${xxl.job.executor.address}")
+ @Value("${xxl.job.executor.address:}")
private String address;
- @Value("${xxl.job.executor.ip}")
+ @Value("${xxl.job.executor.ip:}")
private String ip;
- @Value("${xxl.job.executor.port}")
+ @Value("${xxl.job.executor.port:9999}")
private int port;
- @Value("${xxl.job.executor.logpath}")
+ @Value("${xxl.job.executor.logpath:/data/applogs/xxl-job/jobhandler}")
private String logPath;
- @Value("${xxl.job.executor.logretentiondays}")
+ @Value("${xxl.job.executor.logretentiondays:30}")
private int logRetentionDays;
diff --git a/src/main/java/com/youlai/boot/framework/security/config/SecurityProperties.java b/src/main/java/com/youlai/boot/framework/security/config/SecurityProperties.java
index d0f1526b..76d22a26 100644
--- a/src/main/java/com/youlai/boot/framework/security/config/SecurityProperties.java
+++ b/src/main/java/com/youlai/boot/framework/security/config/SecurityProperties.java
@@ -25,11 +25,12 @@ import java.time.Duration;
@ConfigurationProperties(prefix = "security")
public class SecurityProperties {
+ /** 访问令牌格式与登录会话配置 */
@Valid
@NotNull
private TokenConfig token = new TokenConfig();
- // 匿名路径仍经过安全过滤器链
+ /** 免登录路径,仍经过安全过滤器链 */
@NotEmpty
private String[] permitAllUrls;
@@ -39,16 +40,20 @@ public class SecurityProperties {
@Data
public static class TokenConfig {
+ /** 访问令牌格式:jwt 或 redis-token,两种格式共用 Redis 登录会话 */
@NotNull
@Pattern(regexp = "jwt|redis-token", message = "令牌类型只能是 jwt 或 redis-token")
private String type = "jwt";
+ /** 访问令牌有效期,须为正整数秒且不超过会话最长有效期,如 2h */
@NotNull
private Duration accessTokenTimeToLive = Duration.ofHours(2);
+ /** 登录会话最长有效期,刷新令牌不延长该期限,如 7d */
@NotNull
private Duration refreshTokenTimeToLive = Duration.ofDays(7);
+ /** 是否允许多设备同时登录,false 时新登录会撤销已有会话 */
private boolean allowMultiLogin = true;
// 家属客户端访问令牌有效期,未配置时回退到 accessTokenTimeToLive
@@ -57,7 +62,7 @@ public class SecurityProperties {
// 家属客户端刷新令牌有效期,未配置时回退到 refreshTokenTimeToLive
private Duration clientRefreshTokenTimeToLive;
- // JWT 配置项(type 为 jwt 时必填)
+ /** JWT 签名配置,仅 jwt 模式需要 */
@Valid
private JwtConfig jwt;
@@ -87,6 +92,7 @@ public class SecurityProperties {
*/
@Data
public static class JwtConfig {
+ /** JWT 签名密钥,jwt 模式必填,至少 32 字节 UTF-8 */
private String secretKey;
}
}
diff --git a/src/main/java/com/youlai/boot/framework/security/port/PermissionPort.java b/src/main/java/com/youlai/boot/framework/security/port/PermissionPort.java
index 79fdfc80..137cda3b 100644
--- a/src/main/java/com/youlai/boot/framework/security/port/PermissionPort.java
+++ b/src/main/java/com/youlai/boot/framework/security/port/PermissionPort.java
@@ -3,14 +3,14 @@ package com.youlai.boot.framework.security.port;
import java.util.Set;
/**
- * 权限查询端口。
+ * 权限查询端口
*
* @see com.youlai.boot.system.adapter.security.PermissionAdapter
*/
public interface PermissionPort {
/**
- * 根据角色编码集合查询权限标识集合。
+ * 根据角色编码集合查询权限标识集合
*
* @param roleCodes 角色编码集合
* @return 权限标识集合,如 "sys:user:create"
diff --git a/src/main/java/com/youlai/boot/framework/security/service/PermissionService.java b/src/main/java/com/youlai/boot/framework/security/service/PermissionService.java
index 074fd1ea..fb81a395 100644
--- a/src/main/java/com/youlai/boot/framework/security/service/PermissionService.java
+++ b/src/main/java/com/youlai/boot/framework/security/service/PermissionService.java
@@ -12,7 +12,7 @@ import org.springframework.util.PatternMatchUtils;
import java.util.Set;
/**
- * Spring Security 权限校验组件。
+ * Spring Security 权限校验组件
*
* @author Ray.Hao
* @since 0.0.1
@@ -25,7 +25,7 @@ public class PermissionService {
private final PermissionPort permissionPort;
/**
- * 判断当前用户是否拥有操作权限,支持通配符匹配。
+ * 判断当前用户是否拥有操作权限,支持通配符匹配
*/
public boolean hasPerm(String requiredPerm) {
if (StrUtil.isBlank(requiredPerm)) {
diff --git a/src/main/java/com/youlai/boot/framework/security/service/SecurityUserDetailsService.java b/src/main/java/com/youlai/boot/framework/security/service/SecurityUserDetailsService.java
index 1dc4ced6..9e8113ff 100644
--- a/src/main/java/com/youlai/boot/framework/security/service/SecurityUserDetailsService.java
+++ b/src/main/java/com/youlai/boot/framework/security/service/SecurityUserDetailsService.java
@@ -11,7 +11,7 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
/**
- * 系统用户认证 DetailsService。
+ * 系统用户认证 DetailsService
*
* @author Ray.Hao
* @since 2021/10/19
diff --git a/src/main/java/com/youlai/boot/framework/web/filter/IpRateLimitFilter.java b/src/main/java/com/youlai/boot/framework/web/filter/IpRateLimitFilter.java
index 50d8e993..508089b1 100644
--- a/src/main/java/com/youlai/boot/framework/web/filter/IpRateLimitFilter.java
+++ b/src/main/java/com/youlai/boot/framework/web/filter/IpRateLimitFilter.java
@@ -70,8 +70,8 @@ public class IpRateLimitFilter extends OncePerRequestFilter {
}
/**
- * 设置渐进式限流响应头(X-RateLimit-Limit / Remaining / Reset)。
- * 超限时额外返回 Retry-After。
+ * 设置渐进式限流响应头(X-RateLimit-Limit / Remaining / Reset)
+ * 超限时额外返回 Retry-After
*/
private void setRateLimitHeaders(HttpServletResponse response,
int limit,
diff --git a/src/main/java/com/youlai/boot/config/OperationLogExecutorConfig.java b/src/main/java/com/youlai/boot/framework/web/log/OperationLogExecutorConfig.java
similarity index 95%
rename from src/main/java/com/youlai/boot/config/OperationLogExecutorConfig.java
rename to src/main/java/com/youlai/boot/framework/web/log/OperationLogExecutorConfig.java
index 50988f3c..6fca4d29 100644
--- a/src/main/java/com/youlai/boot/config/OperationLogExecutorConfig.java
+++ b/src/main/java/com/youlai/boot/framework/web/log/OperationLogExecutorConfig.java
@@ -1,4 +1,4 @@
-package com.youlai.boot.config;
+package com.youlai.boot.framework.web.log;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
diff --git a/src/main/java/com/youlai/boot/framework/web/ratelimit/RateLimit.java b/src/main/java/com/youlai/boot/framework/web/ratelimit/RateLimit.java
index 5ed95f94..92ed8eec 100644
--- a/src/main/java/com/youlai/boot/framework/web/ratelimit/RateLimit.java
+++ b/src/main/java/com/youlai/boot/framework/web/ratelimit/RateLimit.java
@@ -18,12 +18,12 @@ import java.lang.annotation.Target;
public @interface RateLimit {
/**
- * 窗口内允许的最大请求数。
+ * 窗口内允许的最大请求数
*/
int limit() default 0;
/**
- * 滑动窗口大小(秒)。
+ * 滑动窗口大小(秒)
*/
int window() default 0;
}
diff --git a/src/main/java/com/youlai/boot/framework/web/ratelimit/RateLimitProperties.java b/src/main/java/com/youlai/boot/framework/web/ratelimit/RateLimitProperties.java
index 06c2b35b..3b878235 100644
--- a/src/main/java/com/youlai/boot/framework/web/ratelimit/RateLimitProperties.java
+++ b/src/main/java/com/youlai/boot/framework/web/ratelimit/RateLimitProperties.java
@@ -17,25 +17,25 @@ import java.time.Duration;
@ConfigurationProperties(prefix = "rate-limit")
public class RateLimitProperties {
- // /
+ /** 接口限流注解未指定 limit 时,窗口内允许的最大请求数 */
private int defaultLimit = 5;
- // /
+ /** 接口限流注解未指定 window 时使用的滑动窗口时长 */
private Duration defaultWindow = Duration.ofSeconds(60);
- // IP 全局限流配置
+ /** IP 全局限流配置 */
private Ip ip = new Ip();
@Data
public static class Ip {
- // 是否启用 IP 全局限流。
+ /** 是否启用 IP 全局限流 */
private boolean enabled = true;
- // 窗口内最大请求数(默认 1000)
+ /** 窗口内最大请求数(默认 1000) */
private int limit = 1000;
- // 滑动窗口大小,支持 60s / 1m / 500ms 等 Duration 写法(默认 60s)
+ /** 滑动窗口大小,支持 60s / 1m / 500ms 等 Duration 写法(默认 60s) */
private Duration window = Duration.ofSeconds(60);
}
diff --git a/src/main/java/com/youlai/boot/module/form/service/FormDataService.java b/src/main/java/com/youlai/boot/module/form/service/FormDataService.java
index 283e9342..f57fc7c8 100644
--- a/src/main/java/com/youlai/boot/module/form/service/FormDataService.java
+++ b/src/main/java/com/youlai/boot/module/form/service/FormDataService.java
@@ -60,6 +60,14 @@ public interface FormDataService {
*/
FormDataVO getFormDataDetail(Long dataId);
+ /**
+ * 更新已提交的表单数据(发起人节点重新提交)
+ *
+ * @param dataId 数据ID
+ * @param data 表单数据(field -> value 映射)
+ */
+ void updateFormData(Long dataId, Map data);
+
/**
* 按表单标识删除全部提交数据(演示数据重置用)
*
diff --git a/src/main/java/com/youlai/boot/module/form/service/impl/FormDataServiceImpl.java b/src/main/java/com/youlai/boot/module/form/service/impl/FormDataServiceImpl.java
index 34894fd4..62092019 100644
--- a/src/main/java/com/youlai/boot/module/form/service/impl/FormDataServiceImpl.java
+++ b/src/main/java/com/youlai/boot/module/form/service/impl/FormDataServiceImpl.java
@@ -206,6 +206,22 @@ public class FormDataServiceImpl extends ServiceImpl i
this.removeByIds(ids);
}
+ /**
+ * 更新已提交的表单数据(发起人节点重新提交)
+ *
+ * @param dataId 数据ID
+ * @param data 表单数据(field -> value 映射)
+ */
+ @Override
+ public void updateFormData(Long dataId, Map data) {
+ FormData entity = this.getById(dataId);
+ if (entity == null) {
+ throw new BusinessException("表单数据不存在");
+ }
+ entity.setDataJson(JSONUtil.toJsonStr(data));
+ this.updateById(entity);
+ }
+
/**
* 按表单标识删除全部提交数据(演示数据重置用)
*
diff --git a/src/main/java/com/youlai/boot/module/workflow/config/ConditionalOnWorkflowEnabled.java b/src/main/java/com/youlai/boot/module/workflow/config/ConditionalOnWorkflowEnabled.java
index a2fd4e70..7c6e1290 100644
--- a/src/main/java/com/youlai/boot/module/workflow/config/ConditionalOnWorkflowEnabled.java
+++ b/src/main/java/com/youlai/boot/module/workflow/config/ConditionalOnWorkflowEnabled.java
@@ -17,6 +17,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProp
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
-@ConditionalOnBooleanProperty(prefix = "workflow", name = "enabled")
+@ConditionalOnBooleanProperty(prefix = "flowable.process", name = "enabled")
public @interface ConditionalOnWorkflowEnabled {
}
diff --git a/src/main/java/com/youlai/boot/module/workflow/model/form/CompleteTaskForm.java b/src/main/java/com/youlai/boot/module/workflow/model/form/CompleteTaskForm.java
index 3d6111a9..82b64299 100644
--- a/src/main/java/com/youlai/boot/module/workflow/model/form/CompleteTaskForm.java
+++ b/src/main/java/com/youlai/boot/module/workflow/model/form/CompleteTaskForm.java
@@ -23,4 +23,7 @@ public class CompleteTaskForm {
@Schema(description = "流程变量(驱动后续网关条件)")
private Map variables;
+ @Schema(description = "发起表单数据(发起人节点重新提交时填写)")
+ private Map formData;
+
}
diff --git a/src/main/java/com/youlai/boot/module/workflow/runner/WorkflowDemoInitializer.java b/src/main/java/com/youlai/boot/module/workflow/runner/WorkflowDemoInitializer.java
deleted file mode 100644
index b571591b..00000000
--- a/src/main/java/com/youlai/boot/module/workflow/runner/WorkflowDemoInitializer.java
+++ /dev/null
@@ -1,30 +0,0 @@
-package com.youlai.boot.module.workflow.runner;
-
-import com.youlai.boot.module.workflow.service.WorkflowDemoService;
-import com.youlai.boot.module.workflow.config.ConditionalOnWorkflowEnabled;
-import lombok.RequiredArgsConstructor;
-import org.springframework.boot.ApplicationArguments;
-import org.springframework.boot.ApplicationRunner;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.stereotype.Component;
-
-/**
- * 演示流程启动初始化器
- *
- * @author Ray.Hao
- * @since 4.9.0
- */
-@ConditionalOnWorkflowEnabled
-@Component
-@ConditionalOnProperty(name = "workflow.demo.enabled", havingValue = "true")
-@RequiredArgsConstructor
-public class WorkflowDemoInitializer implements ApplicationRunner {
-
- private final WorkflowDemoService workflowDemoService;
-
- @Override
- public void run(ApplicationArguments args) {
- workflowDemoService.initIfAbsent();
- }
-
-}
diff --git a/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowInstanceServiceImpl.java b/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowInstanceServiceImpl.java
index 13207be2..de1795d4 100644
--- a/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowInstanceServiceImpl.java
+++ b/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowInstanceServiceImpl.java
@@ -71,6 +71,10 @@ public class WorkflowInstanceServiceImpl implements WorkflowInstanceService {
Assert.isTrue(!definition.isSuspended(), "流程定义已挂起,无法发起");
Map variables = new HashMap<>();
+ // 发起人节点按发起人办理;首次发起用跳过表达式直达审批,驳回时才停在发起人
+ variables.put("initiator", SecurityUtils.getUsername());
+ variables.put("_FLOWABLE_SKIP_EXPRESSION_ENABLED", true);
+ variables.put("_SKIP_INITIATOR", true);
String businessKey = null;
String formKey = bpmnModelHelper.getStartFormKey(definition.getId());
if (StrUtil.isNotBlank(formKey)) {
@@ -97,6 +101,8 @@ public class WorkflowInstanceServiceImpl implements WorkflowInstanceService {
.name(instanceName)
.variables(variables)
.start();
+ // 首轮已跳过发起人,关闭跳过标记,后续驳回到发起人才会生成待办
+ runtimeService.setVariable(instance.getId(), "_SKIP_INITIATOR", false);
return instance.getId();
} finally {
Authentication.setAuthenticatedUserId(null);
diff --git a/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowModelServiceImpl.java b/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowModelServiceImpl.java
index c77e8b7c..4f97cfa8 100644
--- a/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowModelServiceImpl.java
+++ b/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowModelServiceImpl.java
@@ -71,14 +71,20 @@ public class WorkflowModelServiceImpl implements WorkflowModelService {
flow1
-
+
flow1
+ flowInitiator
+
+
+ flowInitiator
flow2
flow2
-
+
+
@@ -86,19 +92,26 @@ public class WorkflowModelServiceImpl implements WorkflowModelService {
-
+
+
+
+
-
+
-
+
-
+
+
+
+
+
diff --git a/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowTaskServiceImpl.java b/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowTaskServiceImpl.java
index 9ff67d35..7111f7f5 100644
--- a/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowTaskServiceImpl.java
+++ b/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowTaskServiceImpl.java
@@ -241,7 +241,19 @@ public class WorkflowTaskServiceImpl implements WorkflowTaskService {
Task task = getCurrentUserTask(taskId);
this.addComment(task, formData.getComment());
this.claimIfCandidate(task);
- taskService.complete(taskId, formData.getVariables() == null ? Map.of() : formData.getVariables());
+ Map variables = formData.getVariables() == null
+ ? new HashMap<>() : new HashMap<>(formData.getVariables());
+ if (formData.getFormData() != null && !formData.getFormData().isEmpty()) {
+ // 发起人节点重新提交:更新发起表单数据,字段同时作为流程变量驱动网关
+ ProcessInstance instance = runtimeService.createProcessInstanceQuery()
+ .processInstanceId(task.getProcessInstanceId())
+ .singleResult();
+ Assert.isTrue(instance != null && StrUtil.isNotBlank(instance.getBusinessKey()),
+ "当前任务无关联的发起表单数据");
+ formDataService.updateFormData(Long.valueOf(instance.getBusinessKey()), formData.getFormData());
+ variables.putAll(formData.getFormData());
+ }
+ taskService.complete(taskId, variables);
}
/**
diff --git a/src/main/java/com/youlai/boot/module/workflow/util/BpmnModelHelper.java b/src/main/java/com/youlai/boot/module/workflow/util/BpmnModelHelper.java
index 71de57ba..5b7bf976 100644
--- a/src/main/java/com/youlai/boot/module/workflow/util/BpmnModelHelper.java
+++ b/src/main/java/com/youlai/boot/module/workflow/util/BpmnModelHelper.java
@@ -74,9 +74,19 @@ public class BpmnModelHelper {
if (process == null) {
return null;
}
- return process.findFlowElementsOfType(StartEvent.class).stream()
+ String startFormKey = process.findFlowElementsOfType(StartEvent.class).stream()
.findFirst()
.map(StartEvent::getFormKey)
+ .filter(StrUtil::isNotBlank)
+ .orElse(null);
+ if (startFormKey != null) {
+ return startFormKey;
+ }
+ // 发起表单挂在发起人节点时,回退取首个用户任务的表单
+ return process.findFlowElementsOfType(UserTask.class).stream()
+ .map(UserTask::getFormKey)
+ .filter(StrUtil::isNotBlank)
+ .findFirst()
.orElse(null);
}
diff --git a/src/main/java/com/youlai/boot/support/file/property/StorageProperties.java b/src/main/java/com/youlai/boot/support/file/property/StorageProperties.java
index 0ecc9e77..6845259a 100644
--- a/src/main/java/com/youlai/boot/support/file/property/StorageProperties.java
+++ b/src/main/java/com/youlai/boot/support/file/property/StorageProperties.java
@@ -20,10 +20,10 @@ import java.util.stream.Collectors;
@ConfigurationProperties(prefix = "storage")
public class StorageProperties {
- // 存储类型,决定激活哪个 FileService 实现类
+ /** 文件存储类型:s3、aliyun 或 local */
private String type;
- // 上传限制
+ /** 上传限制 */
private Upload upload = new Upload();
/**
@@ -32,15 +32,12 @@ public class StorageProperties {
@Data
public static class Upload {
- // 单文件大小上限,如 50MB
- private String maxFileSize;
-
- // 允许上传的文件扩展名白名单
+ /** 允许上传的文件扩展名白名单,留空不限制 */
private List allowedExtensions;
}
/**
- * 获取规范化后的扩展名白名单
+ * 从 upload.allowed-extensions 计算规范化后的扩展名白名单,不是独立配置项
*
* @return 小写、不含点的扩展名集合;空集合表示不限制
*/
@@ -55,4 +52,4 @@ public class StorageProperties {
.map(String::toLowerCase)
.collect(Collectors.toUnmodifiableSet());
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/com/youlai/boot/support/file/service/impl/AliyunFileServiceImpl.java b/src/main/java/com/youlai/boot/support/file/service/impl/AliyunFileServiceImpl.java
index a10cca9f..daf15bb5 100644
--- a/src/main/java/com/youlai/boot/support/file/service/impl/AliyunFileServiceImpl.java
+++ b/src/main/java/com/youlai/boot/support/file/service/impl/AliyunFileServiceImpl.java
@@ -13,9 +13,11 @@ import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.support.file.model.FileInfo;
import com.youlai.boot.support.file.service.FileService;
import jakarta.annotation.PostConstruct;
+import lombok.AccessLevel;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
+import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.ConfigurationProperties;
@@ -27,7 +29,7 @@ import java.net.URI;
import java.time.LocalDateTime;
/**
- * 阿里云 OSS 文件存储服务。
+ * 阿里云 OSS 文件存储服务
*/
@Component
@Slf4j
@@ -37,17 +39,18 @@ import java.time.LocalDateTime;
@Data
public class AliyunFileServiceImpl implements FileService {
- // OSS 地域节点,如 {@code oss-cn-hangzhou.aliyuncs.com},兼容带 {@code http(s)://} 前缀写法
+ /** OSS 地域节点,如 oss-cn-hangzhou.aliyuncs.com,兼容带 http(s):// 前缀写法 */
private String endpoint;
- // 访问密钥 ID
+ /** 访问密钥 ID */
private String accessKeyId;
- // 访问密钥 Secret
+ /** 访问密钥 Secret */
private String accessKeySecret;
- // 存储空间名称
+ /** 存储空间名称 */
private String bucket;
// 自定义访问域名(可选,如 CDN / 反向代理):配置后文件 URL 走该域名,留空则用 bucket.endpoint
private String domain;
+ @Setter(AccessLevel.NONE)
private OSS aliyunOssClient;
@PostConstruct
@@ -56,7 +59,7 @@ public class AliyunFileServiceImpl implements FileService {
}
/**
- * 上传文件到阿里云 OSS,按日期分目录,返回 {@code 协议://bucket.endpoint/path} 格式的 URL。
+ * 上传文件到阿里云 OSS,按日期分目录,返回 {@code 协议://bucket.endpoint/path} 格式的 URL
*/
@Override
@SneakyThrows
@@ -151,7 +154,7 @@ public class AliyunFileServiceImpl implements FileService {
/**
* 补全 endpoint 协议前缀:已带 {@code http://} 或 {@code https://} 的仅去除尾部 {@code /},
- * 纯域名写法默认补 {@code https://},避免 OSS 客户端对无协议 endpoint 默认走 http 明文传输。
+ * 纯域名写法默认补 {@code https://},避免 OSS 客户端对无协议 endpoint 默认走 http 明文传输
*/
private String endpointWithProtocol() {
String trimmed = StrUtil.trim(endpoint);
@@ -162,7 +165,7 @@ public class AliyunFileServiceImpl implements FileService {
}
/**
- * 获取文件访问主机地址(含协议),如 {@code https://bucket.oss-cn-hangzhou.aliyuncs.com}。
+ * 获取文件访问主机地址(含协议),如 {@code https://bucket.oss-cn-hangzhou.aliyuncs.com}
*/
private String getFileHost() {
String endpointWithProtocol = endpointWithProtocol();
diff --git a/src/main/java/com/youlai/boot/support/file/service/impl/S3FileServiceImpl.java b/src/main/java/com/youlai/boot/support/file/service/impl/S3FileServiceImpl.java
index 981b1ec3..5248f5b8 100644
--- a/src/main/java/com/youlai/boot/support/file/service/impl/S3FileServiceImpl.java
+++ b/src/main/java/com/youlai/boot/support/file/service/impl/S3FileServiceImpl.java
@@ -10,9 +10,11 @@ import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.support.file.model.FileInfo;
import com.youlai.boot.support.file.service.FileService;
import jakarta.annotation.PostConstruct;
+import lombok.AccessLevel;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
+import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.ConfigurationProperties;
@@ -52,18 +54,20 @@ import java.time.LocalDateTime;
@Slf4j
public class S3FileServiceImpl implements FileService {
- // S3 对象存储服务端点,如 {@code http://localhost:9000}
+ /** S3 对象存储服务端点,如 http://localhost:9000 */
private String endpoint;
- // 访问密钥
+ /** 访问密钥 */
private String accessKey;
- // 秘密密钥
+ /** 秘密密钥 */
private String secretKey;
- // 存储桶名称
+ /** 存储桶名称 */
private String bucket;
- // 自定义域名(可选),配置后不再使用预签名 URL
+ /** 文件访问域名,留空时使用对象存储端点生成访问地址 */
private String domain;
+ @Setter(AccessLevel.NONE)
private S3Client s3Client;
+ @Setter(AccessLevel.NONE)
private S3Presigner presigner;
@PostConstruct
@@ -90,7 +94,7 @@ public class S3FileServiceImpl implements FileService {
}
/**
- * 上传文件到 S3(RustFS),按日期分目录,自动创建存储桶,返回文件访问 URL。
+ * 上传文件到 S3(RustFS),按日期分目录,自动创建存储桶,返回文件访问 URL
*/
@Override
@SneakyThrows
@@ -180,8 +184,8 @@ public class S3FileServiceImpl implements FileService {
}
/**
- * 生成预签名 GET URL 并去掉 query 参数,路径直达对象。
- * 依赖桶已开匿名只读(详见 createBucketIfAbsent 的策略),签名参数在 URL 中无实际作用。
+ * 生成预签名 GET URL 并去掉 query 参数,路径直达对象
+ * 依赖桶已开匿名只读(详见 createBucketIfAbsent 的策略),签名参数在 URL 中无实际作用
*/
private String presignedUrl(String key) {
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
@@ -197,7 +201,7 @@ public class S3FileServiceImpl implements FileService {
}
/**
- * 删除文件。
+ * 删除文件
*
* @param filePath 文件完整 URL
* @return 删除成功返回 true
@@ -233,7 +237,7 @@ public class S3FileServiceImpl implements FileService {
}
/**
- * 存储桶不存在时自动创建,并设置为公开只读策略。
+ * 存储桶不存在时自动创建,并设置为公开只读策略
*/
@SneakyThrows
private void createBucketIfAbsent(String bucket) {
@@ -250,4 +254,4 @@ public class S3FileServiceImpl implements FileService {
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/com/youlai/boot/support/mail/MailProperties.java b/src/main/java/com/youlai/boot/support/mail/MailProperties.java
index 41944bce..95471027 100644
--- a/src/main/java/com/youlai/boot/support/mail/MailProperties.java
+++ b/src/main/java/com/youlai/boot/support/mail/MailProperties.java
@@ -5,7 +5,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
- * 邮件配置属性
+ * 邮件配置属性;默认使用 587 端口、SMTP 认证和 STARTTLS
*
* @author Ray
* @since 2024/8/17
@@ -15,22 +15,22 @@ import org.springframework.stereotype.Component;
@Data
public class MailProperties {
- // SMTP 服务器地址
+ /** SMTP 服务器地址 */
private String host;
- // SMTP 端口
- private int port;
+ /** SMTP 端口 */
+ private int port = 587;
- // 发件人账号
+ /** 发件人账号 */
private String username;
- // 发件人密码
+ /** 发件人密码 */
private String password;
- // 发件人地址
+ /** 发件人地址 */
private String from;
- // SMTP 扩展属性
+ /** SMTP 扩展属性 */
private Properties properties = new Properties();
@Data
@@ -39,16 +39,16 @@ public class MailProperties {
@Data
public static class Smtp {
- // 启用 SMTP 认证
- private boolean auth;
+ /** 启用 SMTP 认证 */
+ private boolean auth = true;
private StartTls starttls = new StartTls();
@Data
public static class StartTls {
- // 启用 STARTTLS
- private boolean enable;
+ /** 启用 STARTTLS */
+ private boolean enable = true;
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/com/youlai/boot/support/mail/service/EmailService.java b/src/main/java/com/youlai/boot/support/mail/service/EmailService.java
index 401e7294..902eb96d 100644
--- a/src/main/java/com/youlai/boot/support/mail/service/EmailService.java
+++ b/src/main/java/com/youlai/boot/support/mail/service/EmailService.java
@@ -1,7 +1,7 @@
package com.youlai.boot.support.mail.service;
/**
- * 邮件发送服务接口。
+ * 邮件发送服务接口
*
* @author Ray.Hao
* @since 3.1.0
@@ -9,7 +9,7 @@ package com.youlai.boot.support.mail.service;
public interface EmailService {
/**
- * 发送纯文本邮件。
+ * 发送纯文本邮件
*
* @param to 收件人邮箱
* @param subject 邮件主题
@@ -18,7 +18,7 @@ public interface EmailService {
void send(String to, String subject, String text);
/**
- * 发送HTML格式的邮件。
+ * 发送HTML格式的邮件
*
* @param to 收件人邮箱
* @param subject 邮件主题
@@ -27,7 +27,7 @@ public interface EmailService {
void sendHtmlMail(String to, String subject, String html);
/**
- * 发送带附件的邮件。
+ * 发送带附件的邮件
*
* @param to 收件人邮箱
* @param subject 邮件主题
diff --git a/src/main/java/com/youlai/boot/support/mail/service/impl/SmtpEmailServiceImpl.java b/src/main/java/com/youlai/boot/support/mail/service/impl/SmtpEmailServiceImpl.java
index df87d87f..3df45cca 100644
--- a/src/main/java/com/youlai/boot/support/mail/service/impl/SmtpEmailServiceImpl.java
+++ b/src/main/java/com/youlai/boot/support/mail/service/impl/SmtpEmailServiceImpl.java
@@ -19,7 +19,7 @@ import java.io.File;
import java.nio.charset.StandardCharsets;
/**
- * SMTP 邮件服务。
+ * SMTP 邮件服务
*
* @author Ray.Hao
* @since 3.1.0
@@ -31,11 +31,11 @@ import java.nio.charset.StandardCharsets;
public class SmtpEmailServiceImpl implements EmailService {
private final JavaMailSender mailSender;
- // 发件人地址等邮件配置,来自 {@code mail.*}
+ // 发件人地址等邮件配置,绑定 spring.mail.*
private final MailProperties mailProperties;
/**
- * 发送纯文本邮件,发件人取自 {@code mail.from} 配置。
+ * 发送纯文本邮件,发件人取自 {@code spring.mail.from} 配置
*/
@Override
public void send(String to, String subject, String text) {
@@ -57,7 +57,6 @@ public class SmtpEmailServiceImpl implements EmailService {
* @param to 收件人地址
* @param subject 邮件主题
* @param html html格式的内容
- * @return 是否发送成功
*/
@Override
public void sendHtmlMail(String to, String subject, String html) {
@@ -82,7 +81,7 @@ public class SmtpEmailServiceImpl implements EmailService {
}
/**
- * 发送带附件的 HTML 邮件。
+ * 发送带附件的 HTML 邮件
*/
@Override
public void sendWithAttachment(String to, String subject, String text, String filePath) {
@@ -102,4 +101,4 @@ public class SmtpEmailServiceImpl implements EmailService {
log.error("发送带附件邮件失败: {}", e.getMessage());
}
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/com/youlai/boot/support/realtime/config/RealtimeProperties.java b/src/main/java/com/youlai/boot/support/realtime/config/RealtimeProperties.java
index f8619bb0..8467c60c 100644
--- a/src/main/java/com/youlai/boot/support/realtime/config/RealtimeProperties.java
+++ b/src/main/java/com/youlai/boot/support/realtime/config/RealtimeProperties.java
@@ -15,6 +15,8 @@ import java.util.List;
@Setter
@ConfigurationProperties("realtime")
public class RealtimeProperties {
+ /** 是否启用实时通信接口 */
private boolean enabled = true;
+ /** 额外允许的浏览器来源,如 https://admin.example.com */
private List allowedOrigins = List.of();
}
diff --git a/src/main/java/com/youlai/boot/support/sms/AliyunSmsProperties.java b/src/main/java/com/youlai/boot/support/sms/AliyunSmsProperties.java
index 36f82dd9..4ad195f9 100644
--- a/src/main/java/com/youlai/boot/support/sms/AliyunSmsProperties.java
+++ b/src/main/java/com/youlai/boot/support/sms/AliyunSmsProperties.java
@@ -17,22 +17,22 @@ import java.util.Map;
@ConfigurationProperties(prefix = "sms.aliyun")
public class AliyunSmsProperties {
- // 访问密钥 ID
+ /** 访问密钥 ID */
private String accessKeyId;
- // 访问密钥 Secret
+ /** 访问密钥 Secret */
private String accessKeySecret;
- // API 域名,如 dysmsapi.aliyuncs.com
- private String domain;
+ /** API 域名,默认使用阿里云短信公共端点 */
+ private String domain = "dysmsapi.aliyuncs.com";
- // 区域,如 cn-shanghai
- private String regionId;
+ /** 区域,默认 cn-shanghai */
+ private String regionId = "cn-shanghai";
- // 短信签名
+ /** 短信签名 */
private String signName;
- // 短信模板集合,key 为模板用途(login/register),value 为模板CODE
+ /** 短信模板集合,key 为模板用途(login/register),value 为模板CODE */
private Map templates;
-}
\ No newline at end of file
+}
diff --git a/src/main/java/com/youlai/boot/support/sms/service/SmsService.java b/src/main/java/com/youlai/boot/support/sms/service/SmsService.java
index c2bf7ba8..72c5bf4c 100644
--- a/src/main/java/com/youlai/boot/support/sms/service/SmsService.java
+++ b/src/main/java/com/youlai/boot/support/sms/service/SmsService.java
@@ -6,7 +6,7 @@ import com.youlai.boot.support.sms.SmsTypeEnum;
import java.util.Map;
/**
- * 短信发送服务接口。
+ * 短信发送服务接口
*
* @author Ray.Hao
* @since 2024/8/17
@@ -14,7 +14,7 @@ import java.util.Map;
public interface SmsService {
/**
- * 发送短信。
+ * 发送短信
*
* @param mobile 手机号
* @param smsType 短信类型,对应 {@code sms.templates.*} 配置中的模板
diff --git a/src/main/java/com/youlai/boot/system/adapter/security/PermissionAdapter.java b/src/main/java/com/youlai/boot/system/adapter/security/PermissionAdapter.java
index fbd1a177..e408329e 100644
--- a/src/main/java/com/youlai/boot/system/adapter/security/PermissionAdapter.java
+++ b/src/main/java/com/youlai/boot/system/adapter/security/PermissionAdapter.java
@@ -8,7 +8,7 @@ import org.springframework.stereotype.Component;
import java.util.Set;
/**
- * 权限查询适配器。
+ * 权限查询适配器
*
* @author Ray.Hao
* @since 3.1.0
diff --git a/src/main/java/com/youlai/boot/system/controller/FileController.java b/src/main/java/com/youlai/boot/system/controller/FileController.java
index fdaff799..ac720753 100644
--- a/src/main/java/com/youlai/boot/system/controller/FileController.java
+++ b/src/main/java/com/youlai/boot/system/controller/FileController.java
@@ -63,7 +63,7 @@ public class FileController {
}
/**
- * 文件扩展名白名单校验:{@code allowed-extensions} 为空时不限制。
+ * 文件扩展名白名单校验:{@code allowed-extensions} 为空时不限制
*/
private void validateFileExtension(MultipartFile file) {
Set allowedExtensions = storageProperties.getAllowedExtensions();
diff --git a/src/main/java/com/youlai/boot/system/controller/HealthController.java b/src/main/java/com/youlai/boot/system/controller/HealthController.java
index 09b0652d..68cb177a 100644
--- a/src/main/java/com/youlai/boot/system/controller/HealthController.java
+++ b/src/main/java/com/youlai/boot/system/controller/HealthController.java
@@ -9,8 +9,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
- * 系统健康检查接口,返回 ok。
- * 不标注 @RateLimit,仅受 IP 全局限流层约束,用作该层的验证端点。
+ * 系统健康检查接口,返回 ok
+ * 不标注 @RateLimit,仅受 IP 全局限流层约束,用作该层的验证端点
*/
@Tag(name = "00.系统健康检查")
@RestController
diff --git a/src/main/java/com/youlai/boot/system/controller/UserController.java b/src/main/java/com/youlai/boot/system/controller/UserController.java
index 1eb41a57..0808ab4c 100644
--- a/src/main/java/com/youlai/boot/system/controller/UserController.java
+++ b/src/main/java/com/youlai/boot/system/controller/UserController.java
@@ -1,7 +1,6 @@
package com.youlai.boot.system.controller;
import cn.idev.excel.EasyExcel;
-import cn.idev.excel.ExcelWriter;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.framework.web.repeatsubmit.RepeatSubmit;
import com.youlai.boot.common.enums.ActionTypeEnum;
@@ -51,11 +50,10 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
-import java.io.File;
import java.io.IOException;
-import java.io.InputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
+import java.util.Collections;
import java.util.List;
/**
@@ -166,18 +164,16 @@ public class UserController {
@Operation(summary = "用户导入模板下载")
@GetMapping("/template")
- public void downloadTemplate(HttpServletResponse response) {
+ public void downloadTemplate(HttpServletResponse response) {
String fileName = "用户导入模板.xlsx";
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition", "attachment; filename=" + URLEncoder.encode(fileName, StandardCharsets.UTF_8));
- // ClassLoader 只认 / 分隔,用 File.separator 打 jar 后(Linux)会取不到资源
- String fileClassPath = "excel/" + fileName;
- InputStream inputStream = this.getClass().getClassLoader().getResourceAsStream(fileClassPath);
-
- try (ServletOutputStream outputStream = response.getOutputStream();
- ExcelWriter excelWriter = EasyExcel.write(outputStream).withTemplate(inputStream).build()) {
- excelWriter.finish();
+ // 表头取自 UserImportForm 的 @ExcelProperty,写入空列表即只产出表头
+ try (ServletOutputStream outputStream = response.getOutputStream()) {
+ EasyExcel.write(outputStream, UserImportForm.class)
+ .sheet("用户导入")
+ .doWrite(Collections.emptyList());
} catch (IOException e) {
throw new BusinessException("用户导入模板下载失败", e);
}
diff --git a/src/main/java/com/youlai/boot/system/model/dto/MenuCreateDTO.java b/src/main/java/com/youlai/boot/system/model/dto/MenuCreateDTO.java
index 911de1e9..6588c372 100644
--- a/src/main/java/com/youlai/boot/system/model/dto/MenuCreateDTO.java
+++ b/src/main/java/com/youlai/boot/system/model/dto/MenuCreateDTO.java
@@ -17,7 +17,7 @@ public record MenuCreateDTO(
/** 前端组件所属模块名,如 system */
String moduleName,
- /** 按钮权限前缀使用的表名,如 sys_user */
- String tableName
+ /** 按钮权限标识前缀,如 sys:user */
+ String permissionPrefix
) {
}
\ No newline at end of file
diff --git a/src/main/java/com/youlai/boot/system/schedule/XxlJobSample.java b/src/main/java/com/youlai/boot/system/schedule/XxlJobSample.java
index 52eb68fe..baa2db31 100644
--- a/src/main/java/com/youlai/boot/system/schedule/XxlJobSample.java
+++ b/src/main/java/com/youlai/boot/system/schedule/XxlJobSample.java
@@ -5,7 +5,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
- * XXL-JOB 示例任务。
+ * XXL-JOB 示例任务
*
* @author Ray.Hao
* @since 3.1.0
diff --git a/src/main/java/com/youlai/boot/system/service/UserImportReader.java b/src/main/java/com/youlai/boot/system/service/UserImportReader.java
index c5ece4ec..f1e421d8 100644
--- a/src/main/java/com/youlai/boot/system/service/UserImportReader.java
+++ b/src/main/java/com/youlai/boot/system/service/UserImportReader.java
@@ -27,7 +27,7 @@ import java.util.List;
import java.util.stream.Collectors;
/**
- * 用户导入读取器。
+ * 用户导入读取器
*
* @author Ray
* @since 2022/4/10
diff --git a/src/main/java/com/youlai/boot/system/service/impl/MenuServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/MenuServiceImpl.java
index 136ad149..0db2aa9f 100644
--- a/src/main/java/com/youlai/boot/system/service/impl/MenuServiceImpl.java
+++ b/src/main/java/com/youlai/boot/system/service/impl/MenuServiceImpl.java
@@ -573,7 +573,7 @@ public class MenuServiceImpl extends ServiceImpl implements Me
this.updateById(menu);
// 生成 CRUD 按钮权限
- saveCrudButtons(menu.getId(), dto.moduleName() + ":" + dto.tableName().replace("_", "-"), treePath);
+ saveCrudButtons(menu.getId(), dto.permissionPrefix(), treePath);
}
}
diff --git a/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/src/main/resources/META-INF/additional-spring-configuration-metadata.json
index 5b09a787..0b4199d7 100644
--- a/src/main/resources/META-INF/additional-spring-configuration-metadata.json
+++ b/src/main/resources/META-INF/additional-spring-configuration-metadata.json
@@ -1,74 +1,10 @@
{
"properties": [
- {
- "name": "storage.type",
- "type": "java.lang.String",
- "description": "文件存储类型:s3 | aliyun | local"
- },
- {
- "name": "storage.upload.max-file-size",
- "type": "java.lang.String",
- "description": "单文件大小上限(如 50MB),同时作为 spring.servlet.multipart 的上限"
- },
- {
- "name": "storage.upload.allowed-extensions",
- "type": "java.util.List",
- "description": "允许的文件扩展名白名单(置空表示不限制)"
- },
- {
- "name": "storage.s3.endpoint",
- "type": "java.lang.String",
- "description": "S3 服务 Endpoint(S3 协议兼容存储,默认部署 RustFS)"
- },
- {
- "name": "storage.s3.access-key",
- "type": "java.lang.String",
- "description": "S3 访问凭据"
- },
- {
- "name": "storage.s3.secret-key",
- "type": "java.lang.String",
- "description": "S3 凭据密钥"
- },
- {
- "name": "storage.s3.bucket",
- "type": "java.lang.String",
- "description": "S3 存储桶名称"
- },
- {
- "name": "storage.s3.domain",
- "type": "java.lang.String",
- "description": "S3 自定义域名(文件 URL 域名)"
- },
- {
- "name": "storage.aliyun.endpoint",
- "type": "java.lang.String",
- "description": "阿里云 OSS 服务 Endpoint"
- },
- {
- "name": "storage.aliyun.access-key-id",
- "type": "java.lang.String",
- "description": "阿里云 OSS 访问凭据 ID"
- },
- {
- "name": "storage.aliyun.access-key-secret",
- "type": "java.lang.String",
- "description": "阿里云 OSS 凭据密钥"
- },
- {
- "name": "storage.aliyun.bucket",
- "type": "java.lang.String",
- "description": "阿里云 OSS 存储桶名称"
- },
- {
- "name": "storage.local.path",
- "type": "java.lang.String",
- "description": "本地存储根目录(相对路径基于工作目录)"
- },
{
"name": "xxl.job.enabled",
"type": "java.lang.Boolean",
- "description": "定时任务开关"
+ "description": "定时任务开关",
+ "defaultValue": false
},
{
"name": "xxl.job.admin.addresses",
@@ -88,47 +24,74 @@
{
"name": "xxl.job.executor.address",
"type": "java.lang.String",
- "description": "执行器地址"
+ "description": "执行器地址",
+ "defaultValue": ""
},
{
"name": "xxl.job.executor.ip",
"type": "java.lang.String",
- "description": "执行器IP"
+ "description": "执行器IP",
+ "defaultValue": ""
},
{
"name": "xxl.job.executor.port",
"type": "java.lang.Integer",
- "description": "执行器端口"
+ "description": "执行器端口",
+ "defaultValue": 9999
},
{
"name": "xxl.job.executor.logpath",
"type": "java.lang.String",
- "description": "日志路径"
+ "description": "日志路径",
+ "defaultValue": "/data/applogs/xxl-job/jobhandler"
},
{
"name": "xxl.job.executor.logretentiondays",
"type": "java.lang.Integer",
- "description": "日志保留天数"
+ "description": "日志保留天数",
+ "defaultValue": 30
},
{
- "name": "spring.cache.enabled",
- "type": "java.lang.Boolean",
- "description": "缓存开关"
+ "name": "security.token.access-token-time-to-live",
+ "defaultValue": "2h"
},
{
- "name": "security.redis-token.multi-login",
- "type": "java.lang.String",
- "description": "是否允许多点登录"
+ "name": "security.token.refresh-token-time-to-live",
+ "defaultValue": "7d"
+ }
+ ],
+ "ignored": {
+ "properties": [
+ {
+ "name": "storage.allowed-extensions"
+ }
+ ]
+ },
+ "hints": [
+ {
+ "name": "storage.type",
+ "values": [
+ {
+ "value": "s3"
+ },
+ {
+ "value": "aliyun"
+ },
+ {
+ "value": "local"
+ }
+ ]
},
{
- "name": "security.redis-token.access-token-time-to-live",
- "type": "java.lang.String",
- "description": "访问令牌有效期"
- },
- {
- "name": "security.redis-token.refresh-token-time-to-live",
- "type": "java.lang.String",
- "description": "刷新令牌有效期"
+ "name": "security.token.type",
+ "values": [
+ {
+ "value": "jwt"
+ },
+ {
+ "value": "redis-token"
+ }
+ ]
}
]
-}
\ No newline at end of file
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 61cf8909..9e127955 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -1,8 +1,68 @@
+# 公共配置;连接信息与功能开关在 application-{profile}.yml 中调整
spring:
application:
name: youlai-boot
profiles:
active: dev
config:
- # 引入代码生成模块的配置(数据源、表前缀等),由 codegen.yml 提供
- import: classpath:codegen.yml
+ # 代码生成的内部默认配置
+ import:
+ - classpath:codegen.yml
+ datasource:
+ type: com.alibaba.druid.pool.DruidDataSource
+ cache:
+ # 默认关闭应用缓存;环境配置可选择 redis 或 caffeine
+ type: none
+ redis:
+ time-to-live: 1h
+ caffeine:
+ spec: initialCapacity=50,maximumSize=1000,expireAfterWrite=600s
+ servlet:
+ multipart:
+ max-file-size: 50MB # 单个文件上限
+ max-request-size: 50MB # 整个 multipart 请求上限
+ ai:
+ model:
+ # 未启用的模型类型默认关闭,环境配置按需开启
+ chat: none
+ embedding: none
+ image: none
+ moderation: none
+ audio:
+ speech: none
+ transcription: none
+
+# 数据访问
+mybatis-plus:
+ mapper-locations: classpath*:/mapper/**/*.xml
+ global-config:
+ db-config:
+ id-type: none
+ # 逻辑删除字段写实体属性名(isDeleted),不是表字段名
+ logic-delete-field: isDeleted
+ logic-delete-value: 1
+ logic-not-delete-value: 0
+ configuration:
+ map-underscore-to-camel-case: true
+
+# 认证过滤器使用的公开接口
+security:
+ # 免登录路径,仍经过安全过滤器链
+ permit-all-urls:
+ - /api/v1/auth/login # 账号密码登录
+ - /api/v1/auth/captcha # 获取验证码
+ - /api/v1/auth/sms/** # 短信验证码与短信登录
+ - /api/v1/auth/refresh-token # 刷新令牌
+ - /api/v1/auth/wxma/** # 微信小程序登录 / 绑定手机号
+ - /api/v1/auth/qr-code/generate # 扫码登录:生成票据
+ - /api/v1/auth/qr-code/status # 扫码登录:查询状态
+ - /api/v1/auth/qr-code/login # 扫码登录:换取令牌
+ - /api/v1/forms/public/** # 公开表单接口(匿名渲染/提交,Service 校验 is_public 总闸 + IP 限流)
+ - /api/v1/health # 健康检查
+ - /api/v1/realtime/ws # 实时连接握手,票据在拦截器校验
+ - ${springdoc.swagger-ui.path:/swagger-ui.html} # Swagger UI 路径
+ - /swagger-ui/**
+ - /v3/api-docs/**
+ - /webjars/**
+ - /favicon.ico
+ - /error
diff --git a/src/main/resources/codegen.yml b/src/main/resources/codegen.yml
index ff07ff42..10f9addd 100644
--- a/src/main/resources/codegen.yml
+++ b/src/main/resources/codegen.yml
@@ -10,6 +10,9 @@ codegen:
defaultConfig:
author: youlaitech
moduleName: system
+ # 模块名 → 权限前缀,未配置时用模块名
+ permissionNamespaces:
+ system: sys
# 排除数据表
excludeTables:
- gen_table
diff --git a/src/main/resources/codegen/backend/controller.java.vm b/src/main/resources/codegen/backend/controller.java.vm
index 3335817a..c2c7c024 100644
--- a/src/main/resources/codegen/backend/controller.java.vm
+++ b/src/main/resources/codegen/backend/controller.java.vm
@@ -8,8 +8,12 @@ import ${packageName}.${moduleName}.model.form.${entityName}Form;
import ${packageName}.${moduleName}.model.query.${entityName}Query;
import ${packageName}.${moduleName}.model.vo.${entityName}VO;
import com.baomidou.mybatisplus.core.metadata.IPage;
+import ${packageName}.common.annotation.Log;
+import ${packageName}.common.enums.ActionTypeEnum;
+import ${packageName}.common.enums.LogModuleEnum;
import ${packageName}.common.result.PageResult;
import ${packageName}.common.result.Result;
+import ${packageName}.framework.web.repeatsubmit.RepeatSubmit;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Operation;
@@ -26,7 +30,7 @@ import jakarta.validation.Valid;
*/
@Tag(name = "${businessName}接口")
@RestController
-@RequestMapping("/api/v1/${entityKebab}")
+@RequestMapping("/api/v1/${entityKebabPlural}")
@RequiredArgsConstructor
public class ${entityName}Controller {
@@ -34,7 +38,8 @@ public class ${entityName}Controller {
@Operation(summary = "$!{businessName}分页列表")
@GetMapping
- @PreAuthorize("@ss.hasPerm('${moduleName}:${entityKebab}:list')")
+ @PreAuthorize("@ss.hasPerm('${permissionPrefix}:list')")
+ @Log(module = LogModuleEnum.OTHER, value = ActionTypeEnum.LIST)
public PageResult<${entityName}VO> get${entityName}Page(@Valid ${entityName}Query queryParams ) {
IPage<${entityName}VO> result = ${entityLowerCamel}Service.get${entityName}Page(queryParams);
return PageResult.success(result);
@@ -42,7 +47,9 @@ public class ${entityName}Controller {
@Operation(summary = "新增${businessName}")
@PostMapping
- @PreAuthorize("@ss.hasPerm('${moduleName}:${entityKebab}:create')")
+ @PreAuthorize("@ss.hasPerm('${permissionPrefix}:create')")
+ @RepeatSubmit
+ @Log(module = LogModuleEnum.OTHER, value = ActionTypeEnum.INSERT)
public Result save${entityName}(@RequestBody @Valid ${entityName}Form formData ) {
boolean result = ${entityLowerCamel}Service.save${entityName}(formData);
return Result.judge(result);
@@ -50,7 +57,7 @@ public class ${entityName}Controller {
@Operation(summary = "获取${businessName}表单数据")
@GetMapping("/{id}/form")
- @PreAuthorize("@ss.hasPerm('${moduleName}:${entityKebab}:update')")
+ @PreAuthorize("@ss.hasPerm('${permissionPrefix}:update')")
public Result<${entityName}Form> get${entityName}Form(
@Parameter(description = "$!{businessName}ID") @PathVariable Long id
) {
@@ -60,7 +67,8 @@ public class ${entityName}Controller {
@Operation(summary = "修改${businessName}")
@PutMapping(value = "/{id}")
- @PreAuthorize("@ss.hasPerm('${moduleName}:${entityKebab}:update')")
+ @PreAuthorize("@ss.hasPerm('${permissionPrefix}:update')")
+ @Log(module = LogModuleEnum.OTHER, value = ActionTypeEnum.UPDATE)
public Result update${entityName}(
@Parameter(description = "$!{businessName}ID") @PathVariable Long id,
@RequestBody @Validated ${entityName}Form formData
@@ -71,7 +79,8 @@ public class ${entityName}Controller {
@Operation(summary = "删除${businessName}")
@DeleteMapping("/{ids}")
- @PreAuthorize("@ss.hasPerm('${moduleName}:${entityKebab}:delete')")
+ @PreAuthorize("@ss.hasPerm('${permissionPrefix}:delete')")
+ @Log(module = LogModuleEnum.OTHER, value = ActionTypeEnum.DELETE)
public Result delete${entityName}s(
@Parameter(description = "$!{businessName}ID,多个以英文逗号(,)分割") @PathVariable String ids
) {
diff --git a/src/main/resources/codegen/backend/entity.java.vm b/src/main/resources/codegen/backend/entity.java.vm
index e28b564d..e6a962b2 100644
--- a/src/main/resources/codegen/backend/entity.java.vm
+++ b/src/main/resources/codegen/backend/entity.java.vm
@@ -1,7 +1,10 @@
package ${packageName}.${moduleName}.${subpackageName};
-import lombok.Getter;
-import lombok.Setter;
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
#if(${hasLocalDate})
import java.time.LocalDate;
#end
@@ -11,7 +14,6 @@ import java.time.LocalDateTime;
#if(${hasBigDecimal})
import java.math.BigDecimal;
#end
-import com.baomidou.mybatisplus.annotation.TableName;
import ${packageName}.common.base.BaseEntity;
/**
@@ -20,8 +22,8 @@ import ${packageName}.common.base.BaseEntity;
* @author ${author}
* @since ${date}
*/
-@Getter
-@Setter
+@Data
+@EqualsAndHashCode(callSuper = true)
@TableName("${tableName}")
public class ${entityName} extends BaseEntity {
@@ -34,6 +36,11 @@ public class ${entityName} extends BaseEntity {
* ${fieldConfig.fieldComment}
*/
#end
+ #if($fieldConfig.fieldName == "createBy")
+ @TableField(fill = FieldFill.INSERT)
+ #elseif($fieldConfig.fieldName == "updateBy")
+ @TableField(fill = FieldFill.INSERT_UPDATE)
+ #end
private ${fieldConfig.fieldType} ${fieldConfig.fieldName};
#end
#end
diff --git a/src/main/resources/codegen/backend/mapper.xml.vm b/src/main/resources/codegen/backend/mapper.xml.vm
index 69a87799..51bb564f 100644
--- a/src/main/resources/codegen/backend/mapper.xml.vm
+++ b/src/main/resources/codegen/backend/mapper.xml.vm
@@ -19,15 +19,28 @@
FROM
${tableName}
+ #if($fieldConfigs)
+ #set ($hasDeleted = false)
+ #foreach($fieldConfig in ${fieldConfigs})
+ #if($fieldConfig.fieldName == "isDeleted" || $fieldConfig.columnName == "is_deleted")
+ #set ($hasDeleted = true)
+ #end
+ #end
+ #if($hasDeleted)
+ is_deleted = 0
+ #end
+ #end
#if($fieldConfigs)
#foreach($fieldConfig in ${fieldConfigs})
#if($fieldConfig.isShowInQuery)
- #if($fieldConfig.fieldType == "String")
+ #set ($queryType = $fieldConfig.queryType.name())
+ #if($queryType == "IN" || $queryType == "BETWEEN")
+
+ #elseif($fieldConfig.fieldType == "String")
#else
#end
- #set ($queryType = $fieldConfig.queryType.name())
#if($queryType == "EQ")
AND ${fieldConfig.columnName} = #{queryParams.${fieldConfig.fieldName}}
#elseif($queryType == "LIKE")
@@ -38,7 +51,7 @@
#elseif($queryType == "BETWEEN")
#if($fieldConfig.formType.name() == "DATE" || $fieldConfig.formType.name() == "DATE_TIME")
-
+
AND ${fieldConfig.columnName} >= #{startDate}
@@ -46,12 +59,19 @@
AND ${fieldConfig.columnName} <= #{endDate}
- #else
-
+ #elseif($fieldConfig.fieldType == "String")
+
AND ${fieldConfig.columnName} >= #{queryParams.${fieldConfig.fieldName}[0]}
AND ${fieldConfig.columnName} <= #{queryParams.${fieldConfig.fieldName}[1]}
+
+ #else
+
+ AND ${fieldConfig.columnName} >= #{queryParams.${fieldConfig.fieldName}[0]}
+
+
+ AND ${fieldConfig.columnName} <= #{queryParams.${fieldConfig.fieldName}[1]}
#end
#elseif($queryType == "GT")
@@ -74,6 +94,7 @@
#end
#end
+ ORDER BY id DESC
diff --git a/src/main/resources/codegen/backend/query.java.vm b/src/main/resources/codegen/backend/query.java.vm
index e8e57c80..cb0e1d0a 100644
--- a/src/main/resources/codegen/backend/query.java.vm
+++ b/src/main/resources/codegen/backend/query.java.vm
@@ -5,24 +5,44 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
#if($fieldConfigs)
- #set ($hasDateBetween = false)
+ #set ($needList = false)
+ #set ($needLocalDate = false)
+ #set ($needLocalDateTime = false)
+ #set ($needBigDecimal = false)
#foreach($fieldConfig in ${fieldConfigs})
- #if(($fieldConfig.formType.name() == "DATE" || $fieldConfig.formType.name() == "DATE_TIME") && $fieldConfig.queryType.name() == "BETWEEN")
- #set ($hasDateBetween = true)
+ #if($fieldConfig.isShowInQuery == 1)
+ #set ($queryTypeName = $fieldConfig.queryType.name())
+ #if($queryTypeName == "IN" || $queryTypeName == "BETWEEN")
+ #set ($needList = true)
+ #end
+ #if($fieldConfig.fieldType == "LocalDate")
+ #if(($fieldConfig.formType.name() == "DATE" || $fieldConfig.formType.name() == "DATE_TIME") && ($queryTypeName == "IN" || $queryTypeName == "BETWEEN"))
+ #else
+ #set ($needLocalDate = true)
+ #end
+ #elseif($fieldConfig.fieldType == "LocalDateTime")
+ #if(($fieldConfig.formType.name() == "DATE" || $fieldConfig.formType.name() == "DATE_TIME") && ($queryTypeName == "IN" || $queryTypeName == "BETWEEN"))
+ #else
+ #set ($needLocalDateTime = true)
+ #end
+ #end
+ #if($fieldConfig.fieldType == "BigDecimal")
+ #set ($needBigDecimal = true)
+ #end
#end
#end
- #if($hasDateBetween)
+ #if($needList)
import java.util.List;
#end
-#end
-#if(${hasLocalDate})
+ #if($needLocalDate)
import java.time.LocalDate;
-#end
-#if(${hasLocalDateTime})
+ #end
+ #if($needLocalDateTime)
import java.time.LocalDateTime;
-#end
-#if(${hasBigDecimal})
+ #end
+ #if($needBigDecimal)
import java.math.BigDecimal;
+ #end
#end
/**
@@ -43,13 +63,17 @@ public class ${entityName}Query extends BaseQuery {
@Schema(description = "${fieldConfig.fieldComment}")
#end
#if($fieldConfig.formType.name() == "DATE" || $fieldConfig.formType.name() == "DATE_TIME")
- #if($fieldConfig.queryType.name() == "BETWEEN")
+ #if($fieldConfig.queryType.name() == "BETWEEN" || $fieldConfig.queryType.name() == "IN")
private List ${fieldConfig.fieldName};
#else
private ${fieldConfig.fieldType} ${fieldConfig.fieldName};
#end
#else
+ #if($fieldConfig.queryType.name() == "BETWEEN" || $fieldConfig.queryType.name() == "IN")
+ private List<${fieldConfig.fieldType}> ${fieldConfig.fieldName};
+ #else
private ${fieldConfig.fieldType} ${fieldConfig.fieldName};
+ #end
#end
#end
#end
diff --git a/src/main/resources/codegen/frontend/js/api.js.vm b/src/main/resources/codegen/frontend/js/api.js.vm
index 4b9cc527..c44c414b 100644
--- a/src/main/resources/codegen/frontend/js/api.js.vm
+++ b/src/main/resources/codegen/frontend/js/api.js.vm
@@ -1,9 +1,11 @@
import request from "@/utils/request";
-const ${entityUpperSnake}_BASE_URL = "/api/v1/${entityKebab}";
+const ${entityUpperSnake}_BASE_URL = "/api/v1/${entityKebabPlural}";
const ${entityName}API = {
- /** 获取${businessName}分页数据 */
+ /**
+ * 获取${businessName}分页数据
+ */
getPage(queryParams) {
return request({
url: `${${entityUpperSnake}_BASE_URL}`,
diff --git a/src/main/resources/codegen/frontend/js/index.crud.js.vue.vm b/src/main/resources/codegen/frontend/js/index.crud.js.vue.vm
index 6a5f6d94..7d37e5db 100644
--- a/src/main/resources/codegen/frontend/js/index.crud.js.vue.vm
+++ b/src/main/resources/codegen/frontend/js/index.crud.js.vue.vm
@@ -1,372 +1,220 @@
-
-
-
-#foreach($fieldConfig in $fieldConfigs)
-#if($fieldConfig.isShowInQuery == 1 && $fieldConfig.dictType && $fieldConfig.dictType.trim() != "")
-
-
-
+
+#foreach($field in $fieldConfigs)
+#if($field.isShowInQuery == 1)
+#if($field.queryType.name() == "IN")
+#if($field.dictType && $field.dictType.trim() != "")
+
+
+
+#end
+#elseif($field.queryType.name() == "BETWEEN" && $field.formType.name() != "DATE" && $field.formType.name() != "DATE_TIME")
+
+
+#if($field.tsType == "number")
+
+ ~
+
+#else
+
+ ~
+
+#end
+
+
+#elseif($field.dictType && $field.dictType.trim() != "")
+
+
+
#end
#end
-
-
-
-
-#foreach($fieldConfig in $fieldConfigs)
-#if($fieldConfig.isShowInList == 1 && $fieldConfig.dictType && $fieldConfig.dictType.trim() != "")
-
-
-
+#end
+#foreach($field in $fieldConfigs)
+#if($field.isShowInList == 1 && $field.dictType && $field.dictType.trim() != "")
+
+
+
#end
#end
-
-
-
-
-#foreach($fieldConfig in $fieldConfigs)
-#if($fieldConfig.isShowInForm == 1 && $fieldConfig.formType.name() != "HIDDEN" && $fieldConfig.dictType && $fieldConfig.dictType.trim() != "")
-
-
-
+#foreach($field in $fieldConfigs)
+#if($field.isShowInForm == 1 && $field.formType.name() != "HIDDEN" && $field.dictType && $field.dictType.trim() != "")
+
+
+
#end
#end
-
-
-
-
-#foreach($fieldConfig in $fieldConfigs)
-#if($fieldConfig.isShowInForm == 1 && $fieldConfig.formType.name() != "HIDDEN" && $fieldConfig.dictType && $fieldConfig.dictType.trim() != "")
-
-
-
-#end
-#end
-
-
+
diff --git a/src/main/resources/codegen/frontend/js/index.js.vue.vm b/src/main/resources/codegen/frontend/js/index.js.vue.vm
index 9c3f1db8..202fe413 100644
--- a/src/main/resources/codegen/frontend/js/index.js.vue.vm
+++ b/src/main/resources/codegen/frontend/js/index.js.vue.vm
@@ -6,7 +6,29 @@
#foreach($fieldConfig in $fieldConfigs)
#if($fieldConfig.isShowInQuery == 1)
- #if($fieldConfig.formType.name() == "INPUT")
+ #if($fieldConfig.queryType.name() == "IN")
+ #if($fieldConfig.dictType && $fieldConfig.dictType.trim() != "")
+
+ #else
+
+ #end
+ #elseif($fieldConfig.queryType.name() == "BETWEEN" && $fieldConfig.formType.name() != "DATE" && $fieldConfig.formType.name() != "DATE_TIME")
+
+ #if($fieldConfig.tsType == "number")
+
+ ~
+
+ #else
+
+ ~
+
+ #end
+
+ #elseif($fieldConfig.formType.name() == "INPUT")
新增
queryFormRef.value?.resetFields(),
diff --git a/src/main/resources/codegen/frontend/ts/api-types.ts.vm b/src/main/resources/codegen/frontend/ts/api-types.ts.vm
index a70e466b..45dfdc67 100644
--- a/src/main/resources/codegen/frontend/ts/api-types.ts.vm
+++ b/src/main/resources/codegen/frontend/ts/api-types.ts.vm
@@ -12,11 +12,19 @@ export interface ${entityName}Query extends PageQuery {
#if($fieldConfig.formType.name() == "DATE" || $fieldConfig.formType.name() == "DATE_TIME")
#if($fieldConfig.queryType.name() == "BETWEEN")
${fieldConfig.fieldName}?: [string, string];
+ #elseif($fieldConfig.queryType.name() == "IN")
+ ${fieldConfig.fieldName}?: string[];
#else
${fieldConfig.fieldName}?: ${fieldConfig.tsType};
#end
#else
+ #if($fieldConfig.queryType.name() == "IN")
+ ${fieldConfig.fieldName}?: string[];
+ #elseif($fieldConfig.queryType.name() == "BETWEEN")
+ ${fieldConfig.fieldName}: [${fieldConfig.tsType} | null, ${fieldConfig.tsType} | null];
+ #else
${fieldConfig.fieldName}?: ${fieldConfig.tsType};
+ #end
#end
#end
#end
diff --git a/src/main/resources/codegen/frontend/ts/api.ts.vm b/src/main/resources/codegen/frontend/ts/api.ts.vm
index 78f5b2df..d573f076 100644
--- a/src/main/resources/codegen/frontend/ts/api.ts.vm
+++ b/src/main/resources/codegen/frontend/ts/api.ts.vm
@@ -2,7 +2,7 @@ import request from "@/utils/request";
import type { ${entityName}Form, ${entityName}Query, ${entityName}Item } from "./types";
import type { PageResult } from "@/api/common";
-const ${entityUpperSnake}_BASE_URL = "/api/v1/${entityKebab}";
+const ${entityUpperSnake}_BASE_URL = "/api/v1/${entityKebabPlural}";
const ${entityName}API = {
/**
diff --git a/src/main/resources/codegen/frontend/ts/index.crud.vue.vm b/src/main/resources/codegen/frontend/ts/index.crud.vue.vm
index 5607f046..f264d40c 100644
--- a/src/main/resources/codegen/frontend/ts/index.crud.vue.vm
+++ b/src/main/resources/codegen/frontend/ts/index.crud.vue.vm
@@ -6,12 +6,34 @@
:load-form-data="loadFormData"
>
#foreach($field in $fieldConfigs)
-#if($field.isShowInQuery == 1 && $field.dictType && $field.dictType.trim() != "")
+#if($field.isShowInQuery == 1)
+#if($field.queryType.name() == "IN")
+#if($field.dictType && $field.dictType.trim() != "")
+
+
+
+#end
+#elseif($field.queryType.name() == "BETWEEN" && $field.formType.name() != "DATE" && $field.formType.name() != "DATE_TIME")
+
+
+#if($field.tsType == "number")
+
+ ~
+
+#else
+
+ ~
+
+#end
+
+
+#elseif($field.dictType && $field.dictType.trim() != "")
#end
#end
+#end
#foreach($field in $fieldConfigs)
#if($field.isShowInList == 1 && $field.dictType && $field.dictType.trim() != "")
@@ -52,23 +74,47 @@ const searchConfig: CrudSearchConfig = {
{
prop: "$field.fieldName",
label: "$field.fieldComment",
+#if($field.queryType.name() == "IN")
#if($field.dictType && $field.dictType.trim() != "")
type: "custom",
+ initialValue: [],
attrs: { clearable: true },
-#elseif($field.formType.name() == "DATE" || $field.formType.name() == "DATE_TIME")
+#else
+ type: "input-tag",
+ attrs: { clearable: true },
+#end
+#elseif($field.queryType.name() == "BETWEEN")
+#if($field.formType.name() == "DATE" || $field.formType.name() == "DATE_TIME")
type: "date-picker",
-#if($field.queryType.name() == "BETWEEN")
initialValue: [],
attrs: {
+#if($field.formType.name() == "DATE_TIME")
+ type: "datetimerange",
+ valueFormat: "YYYY-MM-DD HH:mm:ss",
+ startPlaceholder: "开始时间",
+ endPlaceholder: "结束时间",
+#else
type: "daterange",
valueFormat: "YYYY-MM-DD",
startPlaceholder: "开始日期",
endPlaceholder: "结束日期",
+#end
},
+#else
+ type: "custom",
+ initialValue: [null, null],
+#end
+#elseif($field.dictType && $field.dictType.trim() != "")
+ type: "custom",
+ attrs: { clearable: true },
+#elseif($field.formType.name() == "DATE" || $field.formType.name() == "DATE_TIME")
+ type: "date-picker",
+#if($field.formType.name() == "DATE_TIME")
+ attrs: { type: "datetime", valueFormat: "YYYY-MM-DD HH:mm:ss", clearable: true },
#else
attrs: { type: "date", valueFormat: "YYYY-MM-DD", clearable: true },
#end
-#elseif($field.fieldType == "Integer" || $field.fieldType == "Long" || $field.fieldType == "BigDecimal")
+#elseif($field.tsType == "number")
type: "input-number",
attrs: { clearable: true },
#else
@@ -82,7 +128,7 @@ const searchConfig: CrudSearchConfig = {
};
const tableConfig: CrudTableConfig<${entityName}Query, ${entityName}Item> = {
- permissionPrefix: "${moduleName}:${entityKebab}",
+ permissionPrefix: "${permissionPrefix}",
table: { border: true },
fetchData: ${entityName}API.getPage,
deleteByIds: ${entityName}API.deleteByIds,
diff --git a/src/main/resources/codegen/frontend/ts/index.vue.vm b/src/main/resources/codegen/frontend/ts/index.vue.vm
index 19078093..4e3c473d 100644
--- a/src/main/resources/codegen/frontend/ts/index.vue.vm
+++ b/src/main/resources/codegen/frontend/ts/index.vue.vm
@@ -6,7 +6,29 @@
#foreach($fieldConfig in $fieldConfigs)
#if($fieldConfig.isShowInQuery == 1)
- #if($fieldConfig.formType.name() == "INPUT")
+ #if($fieldConfig.queryType.name() == "IN")
+ #if($fieldConfig.dictType && $fieldConfig.dictType.trim() != "")
+
+ #else
+
+ #end
+ #elseif($fieldConfig.queryType.name() == "BETWEEN" && $fieldConfig.formType.name() != "DATE" && $fieldConfig.formType.name() != "DATE_TIME")
+
+ #if($fieldConfig.tsType == "number")
+
+ ~
+
+ #else
+
+ ~
+
+ #end
+
+ #elseif($fieldConfig.formType.name() == "INPUT")
新增
queryFormRef.value?.resetFields(),
diff --git a/src/main/resources/excel/用户导入模板.xlsx b/src/main/resources/excel/用户导入模板.xlsx
deleted file mode 100644
index 611492b0..00000000
Binary files a/src/main/resources/excel/用户导入模板.xlsx and /dev/null differ
diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml
index 1c9ba4f2..3c0cb740 100644
--- a/src/main/resources/logback-spring.xml
+++ b/src/main/resources/logback-spring.xml
@@ -1,5 +1,5 @@
-
+
@@ -11,7 +11,7 @@
-
+
DEBUG
@@ -41,7 +41,7 @@
1GB
-
+
INFO
diff --git a/src/main/resources/workflow/expense.bpmn20.xml b/src/main/resources/workflow/expense.bpmn20.xml
index ba1b7b53..a9038599 100644
--- a/src/main/resources/workflow/expense.bpmn20.xml
+++ b/src/main/resources/workflow/expense.bpmn20.xml
@@ -7,11 +7,16 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
id="Definitions_expense" targetNamespace="http://youlai.tech/workflow">
-
+
flow1
-
+
flow1
+ flowInitiator
+
+
+ flowInitiator
flow2
@@ -31,7 +36,8 @@
flow6
-
+
+
${amount > 5000}
@@ -42,50 +48,57 @@
+
+
+
-
+
-
+
-
+
-
+
-
+
+
+
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
-
+
+
+
-
-
-
+
+
+
diff --git a/src/main/resources/workflow/leave.bpmn20.xml b/src/main/resources/workflow/leave.bpmn20.xml
index 39f46a01..406200d5 100644
--- a/src/main/resources/workflow/leave.bpmn20.xml
+++ b/src/main/resources/workflow/leave.bpmn20.xml
@@ -7,11 +7,16 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
id="Definitions_leave" targetNamespace="http://youlai.tech/workflow">
-
+
flow1
-
+
flow1
+ flowInitiator
+
+
+ flowInitiator
flow2
@@ -27,7 +32,8 @@
flow4
flow5
-
+
+
${days > 3}
@@ -37,42 +43,49 @@
+
+
+
-
+
-
+
-
+
-
+
+
+
+
+
-
-
+
+
-
-
+
+
-
-
-
-
+
+
+
+
-
-
+
+
diff --git a/src/main/resources/workflow/purchase.bpmn20.xml b/src/main/resources/workflow/purchase.bpmn20.xml
index 5de37dcd..01a0e023 100644
--- a/src/main/resources/workflow/purchase.bpmn20.xml
+++ b/src/main/resources/workflow/purchase.bpmn20.xml
@@ -8,11 +8,16 @@
id="Definitions_purchase" targetNamespace="http://youlai.tech/workflow">
-
+
flow1
-
+
flow1
+ flowInitiator
+
+
+ flowInitiator
flow2
@@ -32,7 +37,8 @@
flow6
-
+
+
${amount > 10000}
@@ -43,50 +49,57 @@
+
+
+
-
+
-
+
-
+
-
+
-
+
+
+
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
-
+
+
+
-
-
-
+
+
+
diff --git a/src/main/resources/workflow/seal.bpmn20.xml b/src/main/resources/workflow/seal.bpmn20.xml
index 443c91ce..80da9a61 100644
--- a/src/main/resources/workflow/seal.bpmn20.xml
+++ b/src/main/resources/workflow/seal.bpmn20.xml
@@ -6,11 +6,16 @@
xmlns:flowable="http://flowable.org/bpmn"
id="Definitions_seal" targetNamespace="http://youlai.tech/workflow">
-
+
flow1
-
+
flow1
+ flowInitiator
+
+
+ flowInitiator
flow2
@@ -20,35 +25,43 @@
flow3
-
+
+
+
+
+
-
+
-
+
-
+
+
+
+
+
-
-
+
+
-
-
+
+
diff --git a/src/test/java/com/youlai/boot/config/CacheConfigurationTest.java b/src/test/java/com/youlai/boot/config/CacheConfigurationTest.java
new file mode 100644
index 00000000..843b8611
--- /dev/null
+++ b/src/test/java/com/youlai/boot/config/CacheConfigurationTest.java
@@ -0,0 +1,173 @@
+package com.youlai.boot.config;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.cache.autoconfigure.CacheAutoConfiguration;
+import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.cache.CacheManager;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.cache.caffeine.CaffeineCache;
+import org.springframework.cache.caffeine.CaffeineCacheManager;
+import org.springframework.cache.support.NoOpCacheManager;
+import org.springframework.data.redis.cache.RedisCache;
+import org.springframework.data.redis.cache.RedisCacheManager;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+/**
+ * 验证真实缓存注解与 Spring Boot 管理器选择,不连接数据库或 Redis。
+ */
+class CacheConfigurationTest {
+
+ private final ApplicationContextRunner runner = new ApplicationContextRunner()
+ .withConfiguration(AutoConfigurations.of(CacheAutoConfiguration.class))
+ .withUserConfiguration(CacheConfig.class, RedisCacheConfig.class)
+ .withBean(CountingService.class);
+
+ @ParameterizedTest
+ @ValueSource(strings = {"dev", "prod", "cache-config-test"})
+ void applicationDefaultsDisableCaching(String profile) {
+ runner.withInitializer(new ConfigDataApplicationContextInitializer())
+ .withPropertyValues("spring.profiles.active=" + profile)
+ .run(context -> {
+ assertThat(context).hasNotFailed().hasSingleBean(CacheManager.class);
+ assertThat(context.getBean(CacheManager.class)).isInstanceOf(NoOpCacheManager.class);
+ assertThat(context).doesNotHaveBean(RedisCacheConfig.class);
+ assertThat(context).doesNotHaveBean(RedisConnectionFactory.class);
+ assertThat(context.getEnvironment().getProperty("spring.cache.type")).isEqualTo("none");
+ });
+ }
+
+ @Test
+ void noneRunsEveryInvocationWithoutStoringResults() {
+ runner.withPropertyValues("spring.cache.type=none").run(context -> {
+ assertThat(context).hasNotFailed().hasSingleBean(CacheManager.class);
+ assertThat(context.getBean(CacheManager.class)).isInstanceOf(NoOpCacheManager.class);
+ var service = context.getBean(CountingService.class);
+ assertThat(service.load("item")).isEqualTo(1);
+ assertThat(service.load("item")).isEqualTo(2);
+ service.evict("item");
+ assertThat(service.load("item")).isEqualTo(3);
+ });
+ }
+
+ @Test
+ void caffeineCachesAndEvictsThroughAnnotations() {
+ runner.withPropertyValues("spring.cache.type=caffeine",
+ "spring.cache.caffeine.spec=maximumSize=7,expireAfterWrite=1m")
+ .run(context -> {
+ assertThat(context).hasNotFailed().hasSingleBean(CacheManager.class);
+ assertThat(context.getBean(CacheManager.class)).isInstanceOf(CaffeineCacheManager.class);
+ assertThat(context).doesNotHaveBean(RedisCacheConfig.class);
+ var service = context.getBean(CountingService.class);
+ assertThat(service.load("item")).isEqualTo(1);
+ assertThat(service.load("item")).isEqualTo(1);
+ service.evict("item");
+ assertThat(service.load("item")).isEqualTo(2);
+
+ var cache = (CaffeineCache) context.getBean(CacheManager.class).getCache("probe");
+ assertThat(cache).isNotNull();
+ assertThat(cache.getNativeCache().policy().eviction().orElseThrow().getMaximum()).isEqualTo(7);
+ assertThat(cache.getNativeCache().policy().expireAfterWrite().orElseThrow().getExpiresAfter())
+ .isEqualTo(Duration.ofMinutes(1));
+ });
+ }
+
+ @Test
+ void redisUsesOneManagerAndPreservesExistingStorageFormat() {
+ var connectionFactory = mock(RedisConnectionFactory.class);
+ runner.withBean(RedisConnectionFactory.class, () -> connectionFactory)
+ .withPropertyValues("spring.cache.type=redis", "spring.cache.redis.time-to-live=3600000")
+ .run(context -> {
+ assertThat(context).hasNotFailed().hasSingleBean(CacheManager.class);
+ assertThat(context.getBean(CacheManager.class)).isInstanceOf(RedisCacheManager.class);
+ var cache = (RedisCache) context.getBean(CacheManager.class).getCache("probe");
+ assertThat(cache).isNotNull();
+ var config = cache.getCacheConfiguration();
+ assertThat(config.getTtlFunction().getTimeToLive("key", "value")).isEqualTo(Duration.ofHours(1));
+ assertThat(config.getAllowCacheNullValues()).isTrue();
+ assertThat(config.usePrefix()).isTrue();
+ assertThat(config.getKeyPrefixFor("probe")).isEqualTo("probe:");
+ assertThat(StandardCharsets.UTF_8.decode(config.getKeySerializationPair().write("item")).toString())
+ .isEqualTo("item");
+ var encodedValue = config.getValueSerializationPair().write("cached-value");
+ assertThat(StandardCharsets.UTF_8.decode(encodedValue.duplicate()).toString())
+ .isEqualTo("\"cached-value\"");
+ assertThat(config.getValueSerializationPair().read(encodedValue)).isEqualTo("cached-value");
+ verify(connectionFactory, never()).getConnection();
+ verify(connectionFactory, never()).getClusterConnection();
+ });
+ }
+
+ @Test
+ void redisAppliesConfiguredPrefixTtlAndNullPolicy() {
+ runner.withBean(RedisConnectionFactory.class, () -> mock(RedisConnectionFactory.class))
+ .withPropertyValues("spring.cache.type=redis", "spring.cache.redis.time-to-live=90s",
+ "spring.cache.redis.key-prefix=tenant:", "spring.cache.redis.cache-null-values=false")
+ .run(context -> {
+ assertThat(context).hasNotFailed().hasSingleBean(CacheManager.class);
+ var cache = (RedisCache) context.getBean(CacheManager.class).getCache("probe");
+ assertThat(cache).isNotNull();
+ var config = cache.getCacheConfiguration();
+ assertThat(config.getTtlFunction().getTimeToLive("key", "value")).isEqualTo(Duration.ofSeconds(90));
+ assertThat(config.getAllowCacheNullValues()).isFalse();
+ assertThat(config.getKeyPrefixFor("probe")).isEqualTo("tenant:probe:");
+ });
+ }
+
+ @Test
+ void redisPrefixCanBeDisabled() {
+ runner.withBean(RedisConnectionFactory.class, () -> mock(RedisConnectionFactory.class))
+ .withPropertyValues("spring.cache.type=redis", "spring.cache.redis.key-prefix=tenant:",
+ "spring.cache.redis.use-key-prefix=false")
+ .run(context -> {
+ assertThat(context).hasNotFailed().hasSingleBean(CacheManager.class);
+ var cache = (RedisCache) context.getBean(CacheManager.class).getCache("probe");
+ assertThat(cache).isNotNull();
+ assertThat(cache.getCacheConfiguration().usePrefix()).isFalse();
+ });
+ }
+
+ @ParameterizedTest
+ @CsvSource({"none,true", "caffeine,false"})
+ void legacyEnabledFlagDoesNotOverrideExplicitType(String type, boolean legacyEnabled) {
+ runner.withPropertyValues("spring.cache.type=" + type, "spring.cache.enabled=" + legacyEnabled)
+ .run(context -> {
+ assertThat(context).hasNotFailed().hasSingleBean(CacheManager.class);
+ assertThat(context.getBean(CacheManager.class))
+ .isInstanceOf("none".equals(type) ? NoOpCacheManager.class : CaffeineCacheManager.class);
+ });
+ }
+
+ @Test
+ void redisWithoutConnectionFactoryFailsInsteadOfSelectingAnotherProvider() {
+ runner.withPropertyValues("spring.cache.type=redis").run(context -> assertThat(context).hasFailed());
+ }
+
+ static class CountingService {
+
+ private final AtomicInteger calls = new AtomicInteger();
+
+ @Cacheable(cacheNames = "probe", key = "#p0")
+ public int load(String key) {
+ return calls.incrementAndGet();
+ }
+
+ @CacheEvict(cacheNames = "probe", key = "#p0")
+ public void evict(String key) {
+ }
+ }
+}
diff --git a/src/test/java/com/youlai/boot/config/ConfigurationDefaultsTest.java b/src/test/java/com/youlai/boot/config/ConfigurationDefaultsTest.java
new file mode 100644
index 00000000..055c09d7
--- /dev/null
+++ b/src/test/java/com/youlai/boot/config/ConfigurationDefaultsTest.java
@@ -0,0 +1,146 @@
+package com.youlai.boot.config;
+
+import cn.hutool.captcha.generator.CodeGenerator;
+import cn.hutool.captcha.generator.MathGenerator;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.youlai.boot.auth.password.config.CaptchaConfig;
+import com.youlai.boot.auth.password.config.CaptchaProperties;
+import com.youlai.boot.auth.password.service.CaptchaService;
+import com.youlai.boot.framework.security.config.SecurityProperties;
+import com.youlai.boot.framework.web.ratelimit.RateLimitProperties;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.boot.autoconfigure.cache.CacheType;
+import org.springframework.boot.cache.autoconfigure.CacheProperties;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.boot.context.properties.bind.Binder;
+import org.springframework.boot.data.redis.autoconfigure.DataRedisProperties;
+import org.springframework.boot.servlet.autoconfigure.MultipartProperties;
+import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
+import org.springframework.util.unit.DataSize;
+
+import javax.imageio.ImageIO;
+import java.io.ByteArrayInputStream;
+import java.time.Duration;
+import java.util.Base64;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 验证精简环境配置后,公共设置和代码默认值仍能支撑现有行为;不连接外部服务。
+ */
+class ConfigurationDefaultsTest {
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void captchaWorksWithoutAnyExplicitConfiguration() {
+ RedisTemplate redis = mock(RedisTemplate.class);
+ ValueOperations values = mock(ValueOperations.class);
+ when(redis.opsForValue()).thenReturn(values);
+
+ new ApplicationContextRunner()
+ .withUserConfiguration(CaptchaDefaultsConfiguration.class, CaptchaConfig.class)
+ .withBean(RedisTemplate.class, () -> redis)
+ .withBean(CaptchaService.class)
+ .run(context -> {
+ assertThat(context).hasNotFailed();
+ var generator = context.getBean(CodeGenerator.class);
+ assertThat(generator).isInstanceOf(MathGenerator.class);
+ assertThat(generator.verify("1+2=", "3")).isTrue();
+
+ var captcha = context.getBean(CaptchaService.class).generate();
+ assertThat(captcha.getCaptchaId()).isNotBlank();
+ assertThat(captcha.getCaptchaBase64()).startsWith("data:image/png;base64,");
+ var imageBytes = Base64.getDecoder().decode(
+ captcha.getCaptchaBase64().substring(captcha.getCaptchaBase64().indexOf(',') + 1));
+ var image = ImageIO.read(new ByteArrayInputStream(imageBytes));
+ assertThat(image).isNotNull();
+ assertThat(image.getWidth()).isEqualTo(120);
+ assertThat(image.getHeight()).isEqualTo(40);
+ verify(values).set(anyString(), anyString(), eq(120L), eq(TimeUnit.SECONDS));
+ });
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"dev", "prod", "configuration-defaults-test"})
+ void profilesInheritOperationalDefaults(String profile) {
+ contextRunner(profile).run(context -> {
+ assertThat(context).hasNotFailed();
+ var binder = Binder.get(context.getEnvironment());
+
+ var rateLimit = binder.bindOrCreate("rate-limit", RateLimitProperties.class);
+ assertThat(rateLimit.getDefaultLimit()).isEqualTo(switch (profile) {
+ case "dev" -> 60;
+ case "prod" -> 10;
+ default -> 5;
+ });
+ assertThat(rateLimit.getDefaultWindow()).isEqualTo(Duration.ofSeconds(60));
+ assertThat(rateLimit.getIp().isEnabled()).isTrue();
+ assertThat(rateLimit.getIp().getLimit()).isEqualTo(1000);
+ assertThat(rateLimit.getIp().getWindow()).isEqualTo(Duration.ofSeconds(60));
+
+ var security = binder.bindOrCreate("security", SecurityProperties.class);
+ assertThat(security.getToken().getAccessTokenTimeToLive()).isEqualTo(Duration.ofHours(2));
+ assertThat(security.getToken().getRefreshTokenTimeToLive()).isEqualTo(Duration.ofDays(7));
+ assertThat(security.getToken().isAllowMultiLogin()).isTrue();
+ assertThat(security.getPermitAllUrls())
+ .contains("/api/v1/auth/login", "/api/v1/auth/captcha", "/swagger-ui.html")
+ .allSatisfy(url -> assertThat(url).doesNotContain("${"));
+
+ var pool = binder.bindOrCreate("spring.data.redis", DataRedisProperties.class).getLettuce().getPool();
+ assertThat(pool.getMaxActive()).isEqualTo(8);
+ assertThat(pool.getMaxIdle()).isEqualTo(8);
+ assertThat(pool.getMinIdle()).isZero();
+ assertThat(pool.getMaxWait()).isEqualTo(Duration.ofMillis(-1));
+
+ var multipart = binder.bindOrCreate("spring.servlet.multipart", MultipartProperties.class);
+ assertThat(multipart.getMaxFileSize()).isEqualTo(DataSize.ofMegabytes(50));
+ assertThat(multipart.getMaxRequestSize()).isEqualTo(DataSize.ofMegabytes(50));
+
+ var cache = binder.bindOrCreate("spring.cache", CacheProperties.class);
+ assertThat(cache.getType()).isEqualTo(CacheType.NONE);
+ assertThat(cache.getRedis().getTimeToLive()).isEqualTo(Duration.ofHours(1));
+ assertThat(cache.getRedis().isCacheNullValues()).isTrue();
+ var localCache = Caffeine.from(cache.getCaffeine().getSpec()).build();
+ assertThat(localCache.policy().eviction().orElseThrow().getMaximum()).isEqualTo(1000);
+ assertThat(localCache.policy().expireAfterWrite().orElseThrow().getExpiresAfter())
+ .isEqualTo(Duration.ofMinutes(10));
+ });
+ }
+
+ @Test
+ void multipartLimitsCanBeOverriddenIndependently() {
+ contextRunner("dev")
+ .withPropertyValues("spring.servlet.multipart.max-file-size=7MB",
+ "spring.servlet.multipart.max-request-size=8MB")
+ .run(context -> {
+ assertThat(context).hasNotFailed();
+ var multipart = Binder.get(context.getEnvironment())
+ .bindOrCreate("spring.servlet.multipart", MultipartProperties.class);
+ assertThat(multipart.getMaxFileSize()).isEqualTo(DataSize.ofMegabytes(7));
+ assertThat(multipart.getMaxRequestSize()).isEqualTo(DataSize.ofMegabytes(8));
+ });
+ }
+
+ private ApplicationContextRunner contextRunner(String profile) {
+ return new ApplicationContextRunner()
+ .withInitializer(new ConfigDataApplicationContextInitializer())
+ .withPropertyValues("spring.profiles.active=" + profile);
+ }
+
+ @Configuration(proxyBeanMethods = false)
+ @EnableConfigurationProperties(CaptchaProperties.class)
+ static class CaptchaDefaultsConfiguration {
+ }
+}
diff --git a/src/test/java/com/youlai/boot/config/OptionalIntegrationConfigurationTest.java b/src/test/java/com/youlai/boot/config/OptionalIntegrationConfigurationTest.java
new file mode 100644
index 00000000..49f8699f
--- /dev/null
+++ b/src/test/java/com/youlai/boot/config/OptionalIntegrationConfigurationTest.java
@@ -0,0 +1,127 @@
+package com.youlai.boot.config;
+
+import cn.binarywang.wx.miniapp.api.WxMaService;
+import com.xxl.job.core.executor.impl.XxlJobSpringExecutor;
+import com.youlai.boot.auth.oidc.config.OidcProperties;
+import com.youlai.boot.auth.wxma.config.WxMaConfig;
+import com.youlai.boot.auth.wxma.config.WxMaProperties;
+import com.youlai.boot.support.mail.MailConfig;
+import com.youlai.boot.support.mail.MailProperties;
+import com.youlai.boot.support.sms.AliyunSmsProperties;
+import com.youlai.boot.support.sms.service.impl.AliyunSmsServiceImpl;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.autoconfigure.context.ConfigurationPropertiesAutoConfiguration;
+import org.springframework.boot.context.properties.bind.validation.BindValidationException;
+import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.boot.validation.autoconfigure.ValidationAutoConfiguration;
+import org.springframework.core.env.StandardEnvironment;
+import org.springframework.core.env.SystemEnvironmentPropertySource;
+import org.springframework.mail.javamail.JavaMailSenderImpl;
+
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * 读取真实环境配置,验证空凭据和原生环境变量绑定;不发送请求或启动任务执行器。
+ */
+class OptionalIntegrationConfigurationTest {
+
+ @ParameterizedTest
+ @ValueSource(strings = {"dev", "prod"})
+ void optionalClientsStartWithoutCredentials(String profile) {
+ profileRunner(profile, Map.of())
+ .withUserConfiguration(WxMaProperties.class, WxMaConfig.class,
+ MailProperties.class, MailConfig.class, AliyunSmsProperties.class,
+ AliyunSmsServiceImpl.class, OidcProperties.class, XxlJobConfig.class)
+ .run(context -> {
+ assertThat(context).hasNotFailed()
+ .hasSingleBean(WxMaService.class)
+ .hasSingleBean(JavaMailSenderImpl.class)
+ .hasSingleBean(AliyunSmsServiceImpl.class)
+ .doesNotHaveBean(OidcProperties.class)
+ .doesNotHaveBean(XxlJobConfig.class)
+ .doesNotHaveBean(XxlJobSpringExecutor.class);
+
+ var wx = context.getBean(WxMaProperties.class);
+ assertThat(wx.getProvider()).isEqualTo("wechat-mini-main");
+ assertThat(wx.getAppid()).isEmpty();
+ assertThat(wx.getSecret()).isEmpty();
+ assertThat(context.getBean(WxMaService.class).getWxMaConfig().getAppid()).isEmpty();
+
+ var mail = context.getBean(MailProperties.class);
+ assertThat(mail.getHost()).isEmpty();
+ assertThat(mail.getUsername()).isEmpty();
+ assertThat(mail.getPassword()).isEmpty();
+ assertThat(mail.getFrom()).isEmpty();
+ var sender = context.getBean(JavaMailSenderImpl.class);
+ assertThat(sender.getPort()).isEqualTo(587);
+ assertThat(sender.getJavaMailProperties())
+ .containsEntry("mail.smtp.auth", true)
+ .containsEntry("mail.smtp.starttls.enable", true);
+
+ var sms = context.getBean(AliyunSmsProperties.class);
+ assertThat(sms.getAccessKeyId()).isEmpty();
+ assertThat(sms.getAccessKeySecret()).isEmpty();
+ assertThat(sms.getSignName()).isEmpty();
+ assertThat(sms.getDomain()).isEqualTo("dysmsapi.aliyuncs.com");
+ assertThat(sms.getRegionId()).isEqualTo("cn-shanghai");
+ assertThat(sms.getTemplates()).containsOnlyKeys("register", "login", "change-mobile");
+ assertThat(sms.getTemplates().values()).containsOnly("");
+ });
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"dev", "prod"})
+ void nativeEnvironmentVariablesOverridePlainYamlValues(String profile) {
+ profileRunner(profile, Map.of(
+ "SECURITY_OIDC_ENABLED", "true",
+ "SECURITY_OIDC_ISSUER", "https://identity.example.test/realms/demo",
+ "SECURITY_OIDC_CLIENTID", "test-client",
+ "SECURITY_OIDC_CLIENTSECRET", "test-secret",
+ "SECURITY_OIDC_FRONTENDCALLBACKURI", "http://localhost:3000/#/oidc/callback",
+ "WX_MINIAPP_PROVIDER", "wechat-mini-test"))
+ // 仅绑定属性,避免加载 OIDC 客户端并读取远端发现文档。
+ .withUserConfiguration(OidcProperties.class, WxMaProperties.class)
+ .run(context -> {
+ assertThat(context).hasNotFailed().hasSingleBean(OidcProperties.class);
+ var oidc = context.getBean(OidcProperties.class);
+ assertThat(oidc.isEnabled()).isTrue();
+ assertThat(oidc.getIssuer()).isEqualTo("https://identity.example.test/realms/demo");
+ assertThat(oidc.getClientId()).isEqualTo("test-client");
+ assertThat(oidc.getClientSecret()).isEqualTo("test-secret");
+ assertThat(oidc.getFrontendCallbackUri()).isEqualTo("http://localhost:3000/#/oidc/callback");
+ assertThat(context.getBean(WxMaProperties.class).getProvider()).isEqualTo("wechat-mini-test");
+ });
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"dev", "prod"})
+ void enablingOidcWithoutCredentialsFailsValidation(String profile) {
+ profileRunner(profile, Map.of())
+ .withUserConfiguration(OidcProperties.class)
+ .withPropertyValues("security.oidc.enabled=true")
+ .run(context -> {
+ assertThat(context).hasFailed();
+ assertThat(context.getStartupFailure()).hasRootCauseInstanceOf(BindValidationException.class);
+ });
+ }
+
+ private ApplicationContextRunner profileRunner(String profile, Map environment) {
+ return new ApplicationContextRunner()
+ .withConfiguration(AutoConfigurations.of(ConfigurationPropertiesAutoConfiguration.class,
+ ValidationAutoConfiguration.class))
+ .withPropertyValues("spring.profiles.active=" + profile)
+ .withInitializer(context -> {
+ // 使用真正的系统环境属性源,同时隔离运行测试的机器所配置的第三方凭据。
+ context.getEnvironment().getPropertySources().replace(
+ StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME,
+ new SystemEnvironmentPropertySource(
+ StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME, environment));
+ new ConfigDataApplicationContextInitializer().initialize(context);
+ });
+ }
+}
diff --git a/src/test/java/com/youlai/boot/module/workflow/config/WorkflowConfigurationTest.java b/src/test/java/com/youlai/boot/module/workflow/config/WorkflowConfigurationTest.java
new file mode 100644
index 00000000..ad38a9cf
--- /dev/null
+++ b/src/test/java/com/youlai/boot/module/workflow/config/WorkflowConfigurationTest.java
@@ -0,0 +1,60 @@
+package com.youlai.boot.module.workflow.config;
+
+import com.youlai.boot.module.workflow.listener.ProcessCompletedListener;
+import org.flowable.spring.boot.condition.ConditionalOnProcessEngine;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Configuration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+/**
+ * 加载真实公共与环境配置,验证业务和 Flowable 的启用条件一致,不启动引擎或连接数据库
+ */
+class WorkflowConfigurationTest {
+
+ private ApplicationContextRunner contextRunner(String profile) {
+ return new ApplicationContextRunner()
+ .withInitializer(new ConfigDataApplicationContextInitializer())
+ .withPropertyValues("spring.profiles.active=" + profile)
+ .withUserConfiguration(WorkflowEngineConfig.class, ProcessEngineConditionProbe.class)
+ .withBean(ProcessCompletedListener.class, () -> mock(ProcessCompletedListener.class));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"dev", "prod"})
+ void workflowAndEngineAreDisabledByDefault(String profile) {
+ contextRunner(profile).run(context -> {
+ assertThat(context).hasNotFailed();
+ assertThat(context).doesNotHaveBean(WorkflowEngineConfig.class);
+ assertThat(context).doesNotHaveBean(ProcessEngineConditionProbe.class);
+ assertThat(context.getEnvironment().getProperty("flowable.process.enabled", Boolean.class)).isFalse();
+ assertThat(context.getEnvironment().getProperty("flowable.idm.enabled", Boolean.class)).isFalse();
+ assertThat(context.getEnvironment().getProperty("flowable.eventregistry.enabled", Boolean.class)).isFalse();
+ });
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"dev", "prod"})
+ void oneWorkflowSwitchEnablesBusinessAndEngineConditions(String profile) {
+ contextRunner(profile).withPropertyValues("flowable.process.enabled=true").run(context -> {
+ assertThat(context).hasNotFailed();
+ assertThat(context).hasSingleBean(WorkflowEngineConfig.class);
+ assertThat(context).hasSingleBean(ProcessEngineConditionProbe.class);
+ assertThat(context.getEnvironment().getProperty("flowable.process.enabled", Boolean.class)).isTrue();
+ assertThat(context.getEnvironment().getProperty("flowable.idm.enabled", Boolean.class)).isFalse();
+ assertThat(context.getEnvironment().getProperty("flowable.eventregistry.enabled", Boolean.class)).isFalse();
+ assertThat(context.getEnvironment().getProperty("flowable.database-schema-update"))
+ .isEqualTo("dev".equals(profile) ? "true" : "false");
+ });
+ }
+
+ // 使用 Flowable 自己的条件注解,避免测试复制第三方开关逻辑,也不实例化真实流程引擎
+ @Configuration(proxyBeanMethods = false)
+ @ConditionalOnProcessEngine
+ static class ProcessEngineConditionProbe {
+ }
+}
diff --git a/src/test/java/com/youlai/boot/support/ai/AiConfigurationTest.java b/src/test/java/com/youlai/boot/support/ai/AiConfigurationTest.java
new file mode 100644
index 00000000..5b3fa150
--- /dev/null
+++ b/src/test/java/com/youlai/boot/support/ai/AiConfigurationTest.java
@@ -0,0 +1,120 @@
+package com.youlai.boot.support.ai;
+
+import com.sun.net.httpserver.HttpServer;
+import com.youlai.boot.common.exception.BusinessException;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.ai.chat.model.ChatModel;
+import org.springframework.ai.model.chat.client.autoconfigure.ChatClientAutoConfiguration;
+import org.springframework.ai.model.openai.autoconfigure.OpenAiAudioSpeechAutoConfiguration;
+import org.springframework.ai.model.openai.autoconfigure.OpenAiAudioTranscriptionAutoConfiguration;
+import org.springframework.ai.model.openai.autoconfigure.OpenAiChatAutoConfiguration;
+import org.springframework.ai.model.openai.autoconfigure.OpenAiCommonProperties;
+import org.springframework.ai.model.openai.autoconfigure.OpenAiEmbeddingAutoConfiguration;
+import org.springframework.ai.model.openai.autoconfigure.OpenAiImageAutoConfiguration;
+import org.springframework.ai.model.openai.autoconfigure.OpenAiModerationAutoConfiguration;
+import org.springframework.ai.model.tool.autoconfigure.ToolCallingAutoConfiguration;
+import org.springframework.ai.openai.OpenAiChatModel;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import tools.jackson.databind.json.JsonMapper;
+
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * 使用真实配置与本地 HTTP 桩验证 AI 装配及兼容协议,不连接数据库或外部模型服务
+ */
+class AiConfigurationTest {
+
+ private final ApplicationContextRunner runner = new ApplicationContextRunner()
+ .withInitializer(new ConfigDataApplicationContextInitializer())
+ .withConfiguration(AutoConfigurations.of(
+ ToolCallingAutoConfiguration.class,
+ ChatClientAutoConfiguration.class,
+ OpenAiChatAutoConfiguration.class,
+ OpenAiEmbeddingAutoConfiguration.class,
+ OpenAiImageAutoConfiguration.class,
+ OpenAiModerationAutoConfiguration.class,
+ OpenAiAudioSpeechAutoConfiguration.class,
+ OpenAiAudioTranscriptionAutoConfiguration.class))
+ .withBean(AiChatService.class);
+
+ @ParameterizedTest
+ @ValueSource(strings = {"dev", "prod", "ai-default-test"})
+ void disabledAiStartsWithoutCredentials(String profile) {
+ runner.withPropertyValues("spring.profiles.active=" + profile, "spring.ai.openai.api-key=")
+ .run(context -> {
+ assertThat(context).hasNotFailed().doesNotHaveBean(ChatModel.class);
+ assertThatThrownBy(() -> context.getBean(AiChatService.class).chat("system", "test"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("spring.ai.model.chat");
+ });
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"dev", "prod"})
+ void enabledAiBindsCurrentPropertiesAndPreservesVendorParameter(String profile) {
+ runner.withPropertyValues("spring.profiles.active=" + profile,
+ "spring.ai.model.chat=openai", "spring.ai.openai.api-key=test-key")
+ .run(context -> {
+ assertThat(context).hasNotFailed().hasSingleBean(ChatModel.class);
+ var options = context.getBean(OpenAiChatModel.class).getOptions();
+ assertThat(options.getModel()).isEqualTo(context.getEnvironment()
+ .getRequiredProperty("spring.ai.openai.chat.model"));
+ assertThat(options.getTemperature()).isEqualTo(0.3);
+ assertThat(options.getExtraBody()).containsEntry("enable_thinking", false)
+ .doesNotContainKey("enablethinking");
+ assertThat(context.getBean(OpenAiCommonProperties.class).getBaseUrl())
+ .isEqualTo("https://dashscope.aliyuncs.com/compatible-mode/v1");
+ });
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"dev", "prod"})
+ void requestUsesOneVersionPrefixAndBooleanThinkingFlag(String profile) throws Exception {
+ var requestPath = new AtomicReference();
+ var requestBody = new AtomicReference();
+ var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/", exchange -> {
+ requestPath.set(exchange.getRequestURI().getPath());
+ requestBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
+ byte[] response = """
+ {"id":"test-completion","object":"chat.completion","created":1,"model":"test-model",
+ "choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],
+ "usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}
+ """.getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().set("Content-Type", "application/json");
+ exchange.sendResponseHeaders(200, response.length);
+ try (var output = exchange.getResponseBody()) {
+ output.write(response);
+ }
+ });
+ server.start();
+ try {
+ runner.withPropertyValues("spring.profiles.active=" + profile,
+ "spring.ai.model.chat=openai", "spring.ai.openai.api-key=test-key",
+ "spring.ai.openai.base-url=http://127.0.0.1:" + server.getAddress().getPort()
+ + "/compatible-mode/v1")
+ .run(context -> {
+ assertThat(context).hasNotFailed();
+ assertThat(context.getBean(AiChatService.class).chat("system", "test")).isEqualTo("ok");
+ assertThat(requestPath.get()).isEqualTo("/compatible-mode/v1/chat/completions");
+ var body = JsonMapper.builder().build().readTree(requestBody.get());
+ assertThat(body.has("enable_thinking")).isTrue();
+ assertThat(body.get("enable_thinking").isBoolean()).isTrue();
+ assertThat(body.get("enable_thinking").booleanValue()).isFalse();
+ assertThat(body.has("enablethinking")).isFalse();
+ assertThat(body.get("model").asText()).isEqualTo(context.getEnvironment()
+ .getRequiredProperty("spring.ai.openai.chat.model"));
+ });
+ } finally {
+ server.stop(0);
+ }
+ }
+}