diff --git a/.gitignore b/.gitignore index 4b294208..fd24cbde 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ logs .history application-youlai.yml +/boot-run*.log +/boot-run*.err diff --git a/README.en.md b/README.en.md index 18138d06..7696df5b 100644 --- a/README.en.md +++ b/README.en.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..4dacb353 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ ## 核心特性 -- 🔐 **安全体系** — Spring Security + JWT/Redis Token 双会话模式、令牌续期、多端互斥 +- 🔐 **安全体系** — Spring Security + JWT/Redis Token 两种令牌格式、统一会话管理、令牌续期、多端互斥 - 🔑 **统一认证(可选,默认关闭)** — [OIDC 登录](../docs/youlai-boot/oidc/implementation.md),把登录交给外部认证中心,认证成功后关联本地用户并复用角色权限;不启用就不会加载相关组件 @@ -100,14 +100,26 @@ > 扩展脚本全部**可选**。用户、角色、菜单、字典、日志、代码生成等基础能力都在 `sql/youlai-admin.sql` 里,只导入基础库就能把系统跑起来。 > - > `oidc.sql` 面向"已经有认证中心"的场景,它只写入「应用中心」菜单和一条演示用的身份关联。单点登录本身默认关闭(`security.oidc.enabled` 与前端 `VITE_OIDC_ENABLED`),不填认证中心地址、客户端密钥和前端开关就不会加载 OIDC 组件,对基础功能零影响。暂时没有单点登录需求,跳过它即可。 + > `oidc.sql` 面向已有认证中心的场景,只写入「应用中心」菜单和一条演示身份关联。后端通过 `security.oidc.enabled` 控制 OIDC,默认关闭;开启时必须填写认证中心、客户端 ID、客户端密钥和前端回跳地址。前端登录入口通过 `VITE_OIDC_ENABLED` 单独控制。暂时没有单点登录需求,跳过即可。 -3. 修改配置(可选,默认已配置线上只读数据源): +3. 检查开发配置,按实际环境填写 MySQL、Redis 连接信息: ```bash src/main/resources/application-dev.yml ``` + 配置按「基础服务 → 安全与访问 → 文件与接口 → 可选扩展」排列。首次运行先看前半部分;邮件、短信、小程序、定时任务、OIDC、AI、工作流集中在后半部分,按需接入。`---` 分隔的各组都属于同一个环境,会一起加载。 + + `application.yml` 维护公共规则,`application-dev.yml` 和 `application-prod.yml` 只展开常用参数与接入信息。验证码外观、登录期限、连接池等沿用默认值,需要调整时再添加对应属性,IDE 可提示配置说明与默认值。文件上传的存储方式与凭据在 `storage` 段配置。 + + 可选服务中的 `""` 表示尚未接入,使用时填写自己的凭据;没有相关需求可以跳过。邮件默认使用端口 587、SMTP 认证和 STARTTLS,其他邮件服务按需覆盖。微信认证源默认 `wechat-mini-main`,首次接入无需填写 `provider`;更换 AppID 时需改用新的 `wx.miniapp.provider`,避免复用已有身份关联。 + + 本地接入可以直接填写 YAML,生产部署也可用外部配置文件或 Spring Boot 原生环境变量覆盖,例如 `WX_MINIAPP_APPID`、`SECURITY_OIDC_ENABLED`。无需在 YAML 中重复声明 `${变量名:默认值}`;[变量名转换规则](https://docs.spring.io/spring-boot/reference/features/external-config.html#features.external-config.typesafe-configuration-properties.relaxed-binding.environment-variables)为点改下划线、去掉连字符、转大写。实际密钥不要提交到版本库。 + + 上传大小在 `spring.servlet.multipart` 中配置,单文件和整个请求默认均为 50MB。升级时请把原 `storage.upload.max-file-size` 的自定义值分别迁移到 `max-file-size` 和 `max-request-size`。 + + 生产环境的 SQL 日志通过 SLF4J 接入 Logback。排查 SQL 时可临时将对应 Mapper 包的日志级别设为 `DEBUG`,默认 `INFO` 不输出 SQL 调试日志。 + 4. 启动服务: **方式一:IDEA 启动(推荐)** @@ -207,30 +219,46 @@ youlai-boot/ ## 可选扩展 -**工作流(默认关闭)** +以下功能默认关闭,需要时再开启。 -两步开启: +**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 54182db8..99378d06 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 构建的权限管理系统。 @@ -68,7 +68,7 @@ 4.8.1.B - 2.0.0 + 2.0.2 @@ -129,7 +129,7 @@ test - + org.springframework.boot spring-boot-starter-webmvc-test @@ -143,7 +143,7 @@ test - + com.h2database h2 @@ -176,7 +176,7 @@ spring-boot-starter-cache - + org.springframework.boot spring-boot-starter-aspectj @@ -187,7 +187,7 @@ spring-boot-starter-validation - + org.springframework.ai spring-ai-starter-model-openai @@ -254,7 +254,7 @@ ${xxl-job.version} - + cn.idev.excel fastexcel @@ -341,6 +341,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/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/property/CodegenProperties.java b/src/main/java/com/youlai/boot/codegen/property/CodegenProperties.java index 67ae9736..dea2979e 100644 --- a/src/main/java/com/youlai/boot/codegen/property/CodegenProperties.java +++ b/src/main/java/com/youlai/boot/codegen/property/CodegenProperties.java @@ -21,22 +21,22 @@ import java.util.Map; public class CodegenProperties { - // 默认配置 + /** 默认配置 */ private DefaultConfig defaultConfig ; - // 模板配置 + /** 模板配置 */ private Map templateConfigs = MapUtil.newHashMap(true); - // 后端应用名 + /** 后端应用名 */ private String backendAppName; - // 前端应用名 + /** 前端应用名 */ private String frontendAppName; - // 下载文件名 + /** 下载文件名 */ private String downloadFileName; - // 排除数据表 + /** 排除数据表 */ private List excludeTables; /** @@ -45,13 +45,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 +62,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/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[] 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 038e50dd..d7df96fb 100644 --- a/src/main/java/com/youlai/boot/config/SecurityConfig.java +++ b/src/main/java/com/youlai/boot/config/SecurityConfig.java @@ -36,7 +36,6 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import jakarta.servlet.DispatcherType; -import org.springframework.http.HttpMethod; /** * Spring Security 配置类 @@ -63,8 +62,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(); 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 d00aa708..fa827ecb 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,18 +40,23 @@ 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; + /** JWT 签名配置,仅 jwt 模式需要 */ private JwtConfig jwt; /** @@ -79,6 +85,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 ce084cd7..156ea3e0 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; @@ -26,7 +28,7 @@ import java.io.InputStream; import java.time.LocalDateTime; /** - * 阿里云 OSS 文件存储服务。 + * 阿里云 OSS 文件存储服务 */ @Component @Slf4j @@ -36,15 +38,16 @@ 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; + @Setter(AccessLevel.NONE) private OSS aliyunOssClient; @PostConstruct @@ -53,7 +56,7 @@ public class AliyunFileServiceImpl implements FileService { } /** - * 上传文件到阿里云 OSS,按日期分目录,返回 {@code 协议://bucket.endpoint/path} 格式的 URL。 + * 上传文件到阿里云 OSS,按日期分目录,返回 {@code 协议://bucket.endpoint/path} 格式的 URL */ @Override @SneakyThrows @@ -79,7 +82,7 @@ public class AliyunFileServiceImpl implements FileService { } /** - * 从完整 URL 中提取文件名后调用 OSS 删除。 + * 从完整 URL 中提取文件名后调用 OSS 删除 * * @param filePath 文件完整 URL * @return 删除成功返回 true @@ -94,7 +97,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); @@ -105,7 +108,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/LocalFileServiceImpl.java b/src/main/java/com/youlai/boot/support/file/service/impl/LocalFileServiceImpl.java index 1d00b2fc..afb6bf50 100644 --- a/src/main/java/com/youlai/boot/support/file/service/impl/LocalFileServiceImpl.java +++ b/src/main/java/com/youlai/boot/support/file/service/impl/LocalFileServiceImpl.java @@ -20,7 +20,7 @@ import java.io.InputStream; import java.time.LocalDateTime; /** - * 本地文件存储服务。 + * 本地文件存储服务 */ @Data @Slf4j @@ -30,11 +30,11 @@ import java.time.LocalDateTime; @RequiredArgsConstructor public class LocalFileServiceImpl implements FileService { - // 本地存储根目录,来自 {@code storage.local.path} + /** 本地存储根目录,来自 storage.local.path */ private String path; /** - * 上传文件到本地磁盘,按日期分目录,返回相对于存储根目录的路径。 + * 上传文件到本地磁盘,按日期分目录,返回相对于存储根目录的路径 */ @Override public FileInfo uploadFile(MultipartFile file) { @@ -57,7 +57,7 @@ public class LocalFileServiceImpl implements FileService { } /** - * 删除本地文件。路径是目录或不存在时返回 false。 + * 删除本地文件。路径是目录或不存在时返回 false * * @param filePath 相对于存储根目录的文件路径 * @return 删除成功返回 true 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 ad1cdd70..479c15e7 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 @@ -129,8 +133,8 @@ public class S3FileServiceImpl implements FileService { } /** - * 生成预签名 GET URL 并去掉 query 参数,路径直达对象。 - * 依赖桶已开匿名只读(详见 createBucketIfAbsent 的策略),签名参数在 URL 中无实际作用。 + * 生成预签名 GET URL 并去掉 query 参数,路径直达对象 + * 依赖桶已开匿名只读(详见 createBucketIfAbsent 的策略),签名参数在 URL 中无实际作用 */ private String presignedUrl(String dateFolder, String fileName) { GetObjectRequest getObjectRequest = GetObjectRequest.builder() @@ -146,7 +150,7 @@ public class S3FileServiceImpl implements FileService { } /** - * 删除文件。 + * 删除文件 * * @param filePath 文件完整 URL * @return 删除成功返回 true @@ -182,7 +186,7 @@ public class S3FileServiceImpl implements FileService { } /** - * 存储桶不存在时自动创建,并设置为公开只读策略。 + * 存储桶不存在时自动创建,并设置为公开只读策略 */ @SneakyThrows private void createBucketIfAbsent(String bucket) { @@ -199,4 +203,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 1922d48c..756ae139 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 @@ -5,7 +5,7 @@ import com.youlai.boot.support.sms.SmsTypeEnum; import java.util.Map; /** - * 短信发送服务接口。 + * 短信发送服务接口 * * @author Ray.Hao * @since 2024/8/17 @@ -13,7 +13,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/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/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-dev.yml b/src/main/resources/application-dev.yml index 4c707145..78f060cd 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -1,13 +1,18 @@ +# 开发环境配置 + +# 基础服务 server: port: 8000 - forward-headers-strategy: framework + # 经 Nginx 等代理访问且开启 OIDC 时取消注释 + # forward-headers-strategy: framework spring: + # MySQL 数据库 datasource: - type: com.alibaba.druid.pool.DruidDataSource url: jdbc:mysql://www.youlai.tech:3306/youlai_admin?zeroDateTimeBehavior=convertToNull&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&autoReconnect=true&allowMultiQueries=true username: youlai password: 123456 + # Redis(登录会话、验证码、限流等) data: redis: database: 0 @@ -15,127 +20,23 @@ spring: port: 6379 password: 123456 timeout: 10s - lettuce: - pool: - # 连接池最大连接数 默认8 ,负数表示没有限制 - max-active: 8 - # 连接池最大阻塞等待时间(使用负值表示没有限制) 默认-1 - max-wait: -1 - # 连接池中的最大空闲连接 默认8 - max-idle: 8 - # 连接池中的最小空闲连接 默认0 - min-idle: 0 - cache: - enabled: false - # 缓存类型 redis、none(不使用缓存) - type: redis - # 缓存时间(单位:ms) - redis: - time-to-live: 3600000 - # 缓存null值,防止缓存穿透 - cache-null-values: true - caffeine: - spec: initialCapacity=50,maximumSize=1000,expireAfterWrite=600s - # 邮件配置 - mail: - host: smtp.youlai.tech - port: 587 - username: your-email@example.com - password: 123456 - properties: - smtp: - auth: true - starttls: - enable: true - from: youlaitech@163.com - # AI 对话(本环境无默认密钥,默认关闭;演示环境配置见 application-youlai.yml) - ai: - # 装配开关:none 关闭,openai 启用 - model: - chat: none - embedding: none - image: none - moderation: none - audio: - speech: none - transcription: none - # 启用后在此填写模型凭据与名称 - openai: - # 厂商接口地址(OpenAI 兼容根地址) - base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 - # 厂商 API 密钥(本文件入版本库,用环境变量 AI_API_KEY 提供) - api-key: ${AI_API_KEY:} - chat: - options: - model: qwen3.7-flash - # 采样温度,取值 0~1,越小输出越稳定 - temperature: 0.3 - # 百炼私有参数:推理模型的思考内容耗时可占整次调用的大头,false 关闭 - extra-body: - enable_thinking: false - servlet: - multipart: - # 单文件大小上限,单一来源绑定 storage.upload.max-file-size - max-file-size: ${storage.upload.max-file-size:50MB} - max-request-size: ${storage.upload.max-file-size:50MB} + +# SQL 日志 mybatis-plus: - mapper-locations: classpath*:/mapper/**/*.xml - global-config: - db-config: - id-type: none - # 逻辑删除对应的全局属性名(注意:须是对象属性名,不能是表字段名,如 isDeleted 而非 is_deleted,否则逻辑删除失效) - logic-delete-field: isDeleted - logic-delete-value: 1 - logic-not-delete-value: 0 configuration: - map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl -# 接口限流 -rate-limit: - default-limit: 60 - default-window: 60s - ip: - enabled: true # 开发环境开启 IP 全局限流(供限流测试) - limit: 1000 - window: 60s - -# 安全配置 +# 安全与访问 security: token: - type: jwt # 令牌策略 [jwt|redis-token] - access-token-time-to-live: 2h # 访问令牌有效期 - refresh-token-time-to-live: 7d # 会话最长有效期,刷新不延长 + type: jwt # 令牌策略:jwt / redis-token jwt: - secret-key: SecretKey012345678901234567890123456789012345678901234567890123456789 # HS256,至少 32 字节 UTF-8 - allow-multi-login: true # 两种模式共用的多设备登录策略 - # 匿名放行路径,仅跳过认证与授权判断,仍走过滤器链(保留安全响应头) - 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 # 健康检查接口(供探针与限流测试) - - ${springdoc.swagger-ui.path} # Swagger UI 路径 - - /swagger-ui/** - - /v3/api-docs/** - - /webjars/** - - /favicon.ico - - /error + secret-key: SecretKey012345678901234567890123456789012345678901234567890123456789 # HS256 密钥,至少 32 字节 - # OIDC 登录 - oidc: - enabled: false - issuer: http://localhost:8080/realms/youlai - client-id: youlai-admin - client-secret: "" - # 认证成功后的前端回调地址 - frontend-callback-uri: "http://localhost:3000/#/oidc/callback" +# 请求限流 +rate-limit: + # @RateLimit 未指定参数时使用的接口限流规则 + default-limit: 60 # 文件存储 storage: @@ -143,34 +44,32 @@ storage: type: s3 # 上传限制 upload: - # 单文件大小上限(同时作为 spring.servlet.multipart 的上限,单一来源) - max-file-size: 50MB - # 允许的文件扩展名白名单(置空表示不限制) + # 扩展名白名单,留空不限制 allowed-extensions: - jpg - jpeg - png - gif - # S3 对象存储(S3 协议兼容服务,默认部署 RustFS) + # S3 存储(兼容 S3 协议,默认 RustFS) s3: endpoint: http://111.229.83.153:9000 access-key: bybaddp7zyARpgNbEGKf secret-key: p9rBdQZPBIJcMH23iyFkZkXmmawbmwPlk3JLlaaj bucket: public - # 自定义域名:配置后文件 URL 走域名,留空则用 endpoint(IP)格式 + # 文件访问域名;留空时使用对象存储端点生成访问地址 domain: # 阿里云 OSS aliyun: endpoint: oss-cn-hangzhou.aliyuncs.com - access-key-id: your-access-key-id - access-key-secret: your-access-key-secret - bucket: default + access-key-id: "" + access-key-secret: "" + bucket: "" # 本地存储 local: - # 存储根目录(相对路径基于工作目录;生产建议挂载独立卷) + # 存储目录,生产建议挂载独立磁盘 path: ./uploads/ -# springdoc 配置文档: https://springdoc.org/properties.html +# 接口文档:/swagger-ui.html springdoc: swagger-ui: path: /swagger-ui.html @@ -180,8 +79,9 @@ springdoc: path: /v3/api-docs group-configs: - group: "系统管理" - paths-to-match: "/**" - # 只生成这些包下的接口文档 + paths-to-match: + - "/**" + # 只扫描这些包 packages-to-scan: - com.youlai.boot.auth.password.controller - com.youlai.boot.auth.sms.controller @@ -195,85 +95,90 @@ springdoc: - com.youlai.boot.module.workflow.controller default-flat-param-object: true -# 验证码配置 -captcha: - # 验证码类型 circle-圆圈干扰验证码|gif-Gif验证码|line-干扰线验证码|shear-扭曲干扰验证码 - type: circle - width: 120 - height: 40 - interfere-count: 2 - # 文本透明度(0.0-1.0) - text-alpha: 0.8 - # 验证码字符配置 - code: - # 验证码字符类型 math-算术|random-随机字符 - type: math - # 验证码字符长度,type=算术时,表示运算位数(1:个位数运算 2:十位数运算);type=随机字符时,表示字符个数 - length: 1 - # 验证码字体 - font: - # 字体名称 Dialog|DialogInput|Monospaced|Serif|SansSerif - name: SansSerif - # 字体样式 0-普通|1-粗体|2-斜体 - weight: 1 - size: 24 - # 验证码有效期(秒) - expire-seconds: 120 +--- -# 短信配置 +# 邮件、短信、小程序与定时任务(可选) + +spring: + # 邮件(可选) + mail: + host: "" # SMTP 地址,如 smtp.example.com + username: "" # SMTP 登录账号,如 noreply@example.com + password: "" # SMTP 密码或邮箱授权码,由邮件服务商提供 + from: "" # 发件人地址,如 noreply@example.com,通常与 username 一致 + +# 阿里云短信(可选) sms: - # 阿里云短信 aliyun: - accessKeyId: LTAI5tSMgfxxxxxxdiBJLyR - accessKeySecret: SoOWRqpjtS7xxxxxxZ2PZiMTJOVC - domain: dysmsapi.aliyuncs.com - regionId: cn-shanghai - signName: 有来技术 + access-key-id: "" # 在阿里云 RAM 控制台创建的 AccessKey ID + access-key-secret: "" # 与 AccessKey ID 配套的密钥 + sign-name: "" # 短信控制台审核通过的签名名称,如 示例科技 + # 模板 CODE 从短信控制台获取,格式如 SMS_123456789 templates: - register: SMS_22xxx771 - login: SMS_22xxx772 - change-mobile: SMS_22xxx773 + register: "" # 注册验证码模板(预留) + login: "" # 登录验证码模板,模板变量使用 ${code} + change-mobile: "" # 修改手机号验证码模板,模板变量使用 ${code} -# xxl-job 定时任务配置 +# 微信小程序(可选) +wx: + miniapp: + appid: "" + secret: "" + +# 定时任务(可选) xxl: job: enabled: false admin: - # 调度中心地址,多个地址用逗号分割;3.4.1 起调度中心 context-path 为 /,地址不带 /xxl-job-admin 后缀 - addresses: http://127.0.0.1:8080 - accessToken: default_token + # 调度中心地址,多个用逗号分隔 + addresses: "" + accessToken: "" executor: appname: xxl-job-executor-${spring.application.name} - address: - ip: - port: 9999 - logpath: /data/applogs/xxl-job/jobhandler - logretentiondays: 30 -# 微信小程序配置 -wx: - miniapp: - # 不同 AppID 使用不同认证源标识。 - provider: wechat-mini-main - appid: your-app-id - secret: your-app-secret +--- -# 工作流(可选扩展,开启只需将 enabled 改为 true,引擎开关自动跟随) -workflow: - enabled: false - demo: - # 演示流程自动部署开关 - enabled: true +# OIDC 统一认证(可选) + +security: + oidc: + enabled: false + # 认证中心地址,如 https://sso.example.com/realms/youlai + issuer: "" + client-id: "" + client-secret: "" + # 认证成功后的前端回跳地址,如 http://localhost:3000/#/oidc/callback + frontend-callback-uri: "" + +--- + +# AI 对话(可选) + +spring: + ai: + openai: + # 百炼 OpenAI 兼容地址;切换服务商时修改 + base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 + api-key: "" # 填写服务商 API 密钥 + chat: + model: qwen3.7-flash + temperature: 0.3 + # 百炼扩展参数,切换服务商时按需调整 + extra-body: + "[enable_thinking]": false + +--- + +# 工作流(可选) -# 工作流引擎 flowable: - # 流程引擎跟随业务开关 workflow.enabled + # 总开关,同时控制业务组件和引擎 process: - enabled: ${workflow.enabled:false} - # 用户与角色走系统自有体系(sys_user/sys_role),不启用 Flowable IDM 与事件注册表 + enabled: false + # 使用系统账号体系,不启用 IDM 和事件注册表 idm: enabled: false eventregistry: enabled: false - # 引擎表初始化策略,true 启动时检查并自动建/更新 ACT_* 表;可选 true/false/create/create-drop + # 开发环境启动时自动建/更新 ACT_* 表 database-schema-update: true diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 4186e980..f7b4e3a9 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -1,13 +1,18 @@ +# 生产环境配置 + +# 基础服务 server: port: 8000 - forward-headers-strategy: framework + # 经 Nginx 等代理访问且开启 OIDC 时取消注释 + # forward-headers-strategy: framework spring: + # MySQL 数据库 datasource: - type: com.alibaba.druid.pool.DruidDataSource url: jdbc:mysql://www.youlai.tech:3306/youlai_admin?zeroDateTimeBehavior=convertToNull&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&autoReconnect=true&allowMultiQueries=true username: youlai password: 123456 + # Redis(登录会话、验证码、限流等) data: redis: database: 1 @@ -15,134 +20,24 @@ spring: port: 6379 password: 123456 timeout: 10s - lettuce: - pool: - # 连接池最大连接数 默认8 ,负数表示没有限制 - max-active: 8 - # 连接池最大阻塞等待时间(使用负值表示没有限制) 默认-1 - max-wait: -1 - # 连接池中的最大空闲连接 默认8 - max-idle: 8 - # 连接池中的最小空闲连接 默认0 - min-idle: 0 - cache: - enabled: false - # 缓存类型 redis、none(不使用缓存) - type: redis - # 缓存时间(单位:ms) - redis: - time-to-live: 3600000 - # 缓存null值,防止缓存穿透 - cache-null-values: true - caffeine: - spec: initialCapacity=50,maximumSize=1000,expireAfterWrite=600s - # 邮件配置 - mail: - host: smtp.youlai.tech - port: 587 - username: your-email@example.com - password: 123456 - properties: - smtp: - auth: true - starttls: - enable: true - # 邮件发送者 - from: youlaitech@163.com - # AI 对话(本环境无默认密钥,默认关闭;演示环境配置见 application-youlai.yml) - ai: - # 装配开关:none 关闭,openai 启用 - model: - chat: none - embedding: none - image: none - moderation: none - audio: - speech: none - transcription: none - # 启用后在此填写模型凭据与名称 - openai: - # 厂商接口地址(OpenAI 兼容根地址) - base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 - # 厂商 API 密钥(本文件入版本库,用环境变量 AI_API_KEY 提供) - api-key: ${AI_API_KEY:} - chat: - options: - # 对话模型标识 - model: qwen3.7-flash - # 采样温度,取值 0~1,越小输出越稳定 - temperature: 0.3 - # 百炼私有参数:推理模型的思考内容耗时可占整次调用的大头,false 关闭 - extra-body: - enable_thinking: false - servlet: - multipart: - # 单文件大小上限,单一来源绑定 storage.upload.max-file-size - max-file-size: ${storage.upload.max-file-size:50MB} - max-request-size: ${storage.upload.max-file-size:50MB} + +# SQL 日志 mybatis-plus: - mapper-locations: classpath*:/mapper/**/*.xml - global-config: - db-config: - # 主键ID类型 - id-type: none - # 逻辑删除对应的全局属性名(注意:须是对象属性名,不能是表字段名,如 isDeleted 而非 is_deleted,否则逻辑删除失效) - logic-delete-field: isDeleted - # 逻辑删除-删除值 - logic-delete-value: 1 - # 逻辑删除-未删除值 - logic-not-delete-value: 0 configuration: - # 驼峰下划线转换 - map-underscore-to-camel-case: true - # 这个配置会将执行的sql打印出来,在开发或测试的时候可以用 - log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + # 排查 SQL 时,将对应 Mapper 的日志级别调为 DEBUG + log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl -# 接口限流 -rate-limit: - default-limit: 10 - default-window: 60s - ip: - enabled: true - limit: 1000 # IP 全局:1000 req / 60s - window: 60s - -# 安全配置 +# 安全与访问 security: token: - type: jwt # 令牌策略 [jwt|redis-token] - access-token-time-to-live: 2h # 访问令牌有效期 - refresh-token-time-to-live: 7d # 会话最长有效期,刷新不延长 + type: jwt # 令牌策略:jwt / redis-token jwt: secret-key: SecretKey012345678901234567890123456789012345678901234567890123456789 # HS256,至少 32 字节 UTF-8 - allow-multi-login: true # 两种模式共用的多设备登录策略 - # 匿名放行路径,仅跳过认证与授权判断,仍走过滤器链(保留安全响应头) - 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 # 健康检查接口(供探针与限流测试) - - ${springdoc.swagger-ui.path} # Swagger UI 路径 - - /swagger-ui/** - - /v3/api-docs/** - - /webjars/** - - /favicon.ico - - /error - # OIDC 登录 - oidc: - enabled: ${OIDC_ENABLED:false} - issuer: ${OIDC_ISSUER:} - client-id: ${OIDC_CLIENT_ID:} - client-secret: ${OIDC_CLIENT_SECRET:} - # 认证成功后的前端回调地址 - frontend-callback-uri: "${OIDC_FRONTEND_CALLBACK_URI:}" +# 请求限流 +rate-limit: + # @RateLimit 未指定参数时使用的接口限流规则 + default-limit: 10 # 文件存储 storage: @@ -150,9 +45,7 @@ storage: type: s3 # 上传限制 upload: - # 单文件大小上限(同时作为 spring.servlet.multipart 的上限,单一来源) - max-file-size: 50MB - # 允许的文件扩展名白名单(置空表示不限制) + # 允许的文件扩展名,留空不限制 allowed-extensions: - jpg - jpeg @@ -164,20 +57,20 @@ storage: access-key: rustfs-admin secret-key: rustfs-admin bucket: public - # 自定义域名:配置后文件 URL 走域名,留空则用 endpoint(IP)格式 + # 文件访问域名;留空时使用对象存储端点生成访问地址 domain: # 阿里云 OSS aliyun: endpoint: oss-cn-hangzhou.aliyuncs.com - access-key-id: your-access-key-id - access-key-secret: your-access-key-secret - bucket: default + access-key-id: "" + access-key-secret: "" + bucket: "" # 本地存储 local: # 存储根目录(相对路径基于工作目录;生产建议挂载独立卷) path: ./uploads/ -# springdoc 配置文档: https://springdoc.org/properties.html +# 接口文档:/swagger-ui.html springdoc: swagger-ui: path: /swagger-ui.html # Swagger UI 访问路径 @@ -187,7 +80,8 @@ springdoc: path: /v3/api-docs # OpenAPI JSON 地址 group-configs: # 分组配置 - group: "系统管理" # 分组名称 - paths-to-match: "/**" # 匹配的请求路径 + paths-to-match: # 匹配的请求路径 + - "/**" packages-to-scan: # 扫描的 Controller 包,限制只生成指定包的接口文档 - com.youlai.boot.auth.password.controller - com.youlai.boot.auth.sms.controller @@ -200,92 +94,90 @@ springdoc: - com.youlai.boot.module.workflow.controller default-flat-param-object: true # 将对象参数扁平化显示在文档中 -# 验证码配置 -captcha: - # 验证码类型 circle-圆圈干扰验证码|gif-Gif验证码|line-干扰线验证码|shear-扭曲干扰验证码 - type: circle - # 验证码宽度 - width: 120 - # 验证码高度 - height: 40 - # 验证码干扰元素个数 - interfere-count: 2 - # 文本透明度(0.0-1.0) - text-alpha: 0.8 - # 验证码字符配置 - code: - # 验证码字符类型 math-算术|random-随机字符 - type: math - # 验证码字符长度,type=算术时,表示运算位数(1:个位数运算 2:十位数运算);type=随机字符时,表示字符个数 - length: 1 - # 验证码字体 - font: - # 字体名称 Dialog|DialogInput|Monospaced|Serif|SansSerif - name: SansSerif - # 字体样式 0-普通|1-粗体|2-斜体 - weight: 1 - # 字体大小 - size: 24 - # 验证码有效期(秒) - expire-seconds: 120 +--- -# 短信配置 +# 邮件、短信、小程序与定时任务(可选) + +spring: + # 邮件(可选) + mail: + host: "" # SMTP 地址,如 smtp.example.com + username: "" # SMTP 登录账号,如 noreply@example.com + password: "" # SMTP 密码或邮箱授权码,由邮件服务商提供 + from: "" # 发件人地址,如 noreply@example.com,通常与 username 一致 + +# 阿里云短信(可选) sms: - # 阿里云短信 aliyun: - accessKeyId: LTAI5tSMgfxxxxxxdiBJLyR - accessKeySecret: SoOWRqpjtS7xxxxxxZ2PZiMTJOVC - domain: dysmsapi.aliyuncs.com - regionId: cn-shanghai - signName: 有来技术 + access-key-id: "" # 在阿里云 RAM 控制台创建的 AccessKey ID + access-key-secret: "" # 与 AccessKey ID 配套的密钥 + sign-name: "" # 短信控制台审核通过的签名名称,如 示例科技 + # 模板 CODE 从短信控制台获取,格式如 SMS_123456789 templates: - # 注册短信验证码模板 - register: SMS_22xxx771 - # 登录短信验证码模板 - login: SMS_22xxx772 - # 修改手机号短信验证码模板 - change-mobile: SMS_22xxx773 + register: "" # 注册验证码模板(预留) + login: "" # 登录验证码模板,模板变量使用 ${code} + change-mobile: "" # 修改手机号验证码模板,模板变量使用 ${code} -# xxl-job 定时任务配置 -xxl: - job: - # 定时任务开关 - enabled: false - admin: - # 调度中心地址,多个地址用逗号分割;3.4.1 起调度中心 context-path 为 /,地址不带 /xxl-job-admin 后缀 - addresses: http://127.0.0.1:8080 - accessToken: default_token - executor: - appname: xxl-job-executor-${spring.application.name} - address: - ip: - port: 9999 - logpath: /data/applogs/xxl-job/jobhandler - logretentiondays: 30 - -# 微信小程序配置 +# 微信小程序(可选) wx: miniapp: - provider: ${WX_MINIAPP_PROVIDER:wechat-mini-main} - appid: your-app-id - secret: your-app-secret + appid: "" + secret: "" -# 工作流(可选扩展,开启只需将 enabled 改为 true,引擎开关自动跟随) -workflow: - enabled: false - demo: - # 演示流程自动部署开关 +# 定时任务(可选) +xxl: + job: enabled: false + admin: + # 调度中心地址,多个用逗号分隔 + addresses: "" + accessToken: "" + executor: + appname: xxl-job-executor-${spring.application.name} + +--- + +# OIDC 统一认证(可选) + +security: + oidc: + enabled: false + # 认证中心地址,如 https://sso.example.com/realms/youlai + issuer: "" + client-id: "" + client-secret: "" + # 认证成功后的前端回跳地址,如 http://localhost:3000/#/oidc/callback + frontend-callback-uri: "" + +--- + +# AI 对话(可选) + +spring: + ai: + openai: + # 百炼 OpenAI 兼容地址;切换服务商时修改 + base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 + api-key: "" # 填写服务商 API 密钥 + chat: + model: qwen3.7-flash + temperature: 0.3 + # 百炼扩展参数,切换服务商时按需调整 + extra-body: + "[enable_thinking]": false + +--- + +# 工作流(可选) -# 工作流引擎 flowable: - # 流程引擎跟随业务开关 workflow.enabled + # 总开关,同时控制业务组件和引擎 process: - enabled: ${workflow.enabled:false} - # 用户与角色走系统自有体系(sys_user/sys_role),不启用 Flowable IDM 与事件注册表 + enabled: false + # 使用系统账号体系,不启用 IDM 和事件注册表 idm: enabled: false eventregistry: enabled: false - # 引擎表初始化策略,true 启动时检查并自动建/更新 ACT_* 表;可选 true/false/create/create-drop + # 生产环境由部署流程提前建表/升级,启动时仅校验 database-schema-update: false 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/frontend/js/index.crud.js.vue.vm b/src/main/resources/codegen/frontend/js/index.crud.js.vue.vm index 6a5f6d94..359a7e57 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,174 @@ 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); + } + } +}