fix(workflow): 修复驳回无法退回发起人

- 新增发起申请节点
- 发起表单改挂该节点
- 首次发起跳过发起人
- 驳回目标包含发起人
- 审批接口新增表单数据入参
- 新增表单数据更新逻辑
- 模型模板加入发起人节点
- AI 与流程引擎默认值移入公共配置
This commit is contained in:
Ray.Hao committed 2026-11-02 07:38:24 +08:00
1 parent e77d0b1f4f
commit 538153a4f0
74 files changed
+1582 -1183

No files matched your search

+2
View File
@@ -11,3 +11,5 @@ logs
.history
application-youlai.yml
/boot-run*.log
/boot-run*.err
+47 -19
View File
@@ -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
+47 -19
View File
@@ -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)。
## 技术合作
+1
View File
@@ -0,0 +1 @@
16:12:01,542 |-WARN in ch.qos.logback.core.model.processor.AppenderModelHandler - Appender named [FILE] not referenced. Skipping further processing.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

+41 -7
View File
@@ -6,7 +6,7 @@
<groupId>com.youlai</groupId>
<artifactId>youlai-boot</artifactId>
<version>4.14.0</version>
<version>4.14.1</version>
<description>基于 Java 17 + SpringBoot 4 + Spring Security 构建的权限管理系统。</description>
<parent>
@@ -68,7 +68,7 @@
<weixin-java-miniapp.version>4.8.1.B</weixin-java-miniapp.version>
<spring-ai.version>2.0.0</spring-ai.version>
<spring-ai.version>2.0.2</spring-ai.version>
</properties>
@@ -129,7 +129,7 @@
<scope>test</scope>
</dependency>
<!-- Spring Boot 4: @AutoConfigureMockMvc 移到了 webmvc-test 模块 -->
<!-- Web MVC 接口测试(MockMvc) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
@@ -143,7 +143,7 @@
<scope>test</scope>
</dependency>
<!-- 身份关联事务与唯一约束的隔离测试,不连接开发或演示数据库。 -->
<!-- 测试用内存数据库 -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
@@ -176,7 +176,7 @@
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!-- Spring Boot 4.x 已改名 -->
<!-- AOP 切面支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aspectj</artifactId>
@@ -187,7 +187,7 @@
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Spring AI OpenAI 兼容协议(百炼/DeepSeek 等,ChatModel 与 ChatClient 由 starter 按 spring.ai.openai 配置装配) -->
<!-- OpenAI 兼容模型接入(百炼、DeepSeek 等),通过 spring.ai.model.chat 开启 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
@@ -254,7 +254,7 @@
<version>${xxl-job.version}</version>
</dependency>
<!-- Excel 工具(EasyExcel-Plus) -->
<!-- Excel 导入导出(FastExcel) -->
<dependency>
<groupId>cn.idev.excel</groupId>
<artifactId>fastexcel</artifactId>
@@ -341,6 +341,40 @@
<build>
<finalName>${project.artifactId}</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<!-- 先生成 Lombok 访问器,再处理对象映射和配置提示。 -->
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>${lombok-mapstruct-binding.version}</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
<path>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<version>${project.parent.version}</version>
</path>
</annotationProcessorPaths>
<annotationProcessors>
<annotationProcessor>lombok.launch.AnnotationProcessorHider$AnnotationProcessor</annotationProcessor>
<annotationProcessor>org.mapstruct.ap.MappingProcessor</annotationProcessor>
<annotationProcessor>org.springframework.boot.configurationprocessor.ConfigurationMetadataAnnotationProcessor</annotationProcessor>
</annotationProcessors>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
+1 -1
View File
@@ -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
@@ -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;
}
@@ -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;
}
@@ -4,7 +4,7 @@ import com.youlai.boot.common.result.ResultCode;
import lombok.Getter;
/**
* 图形验证码校验失败时抛出的异常。
* 图形验证码校验失败时抛出的异常
*
* @author Ray.Hao
* @since 3.1.0
@@ -8,7 +8,7 @@ import java.io.Serial;
import java.util.Collection;
/**
* 短信验证码认证 Token。
* 短信验证码认证 Token
*
* @author Ray.Hao
* @since 2.20.0
@@ -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;
}
@@ -8,7 +8,7 @@ import java.io.Serial;
import java.util.Collection;
/**
* 微信小程序认证 Token。
* 微信小程序认证 Token
*
* @author Ray.Hao
* @since 4.0.0
@@ -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"),
@@ -21,22 +21,22 @@ import java.util.Map;
public class CodegenProperties {
// 默认配置
/** 默认配置 */
private DefaultConfig defaultConfig ;
// 模板配置
/** 模板配置 */
private Map<String, TemplateConfig> templateConfigs = MapUtil.newHashMap(true);
// 后端应用名
/** 后端应用名 */
private String backendAppName;
// 前端应用名
/** 前端应用名 */
private String frontendAppName;
// 下载文件名
/** 下载文件名 */
private String downloadFileName;
// 排除数据表
/** 排除数据表 */
private List<String> 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;
}
@@ -19,7 +19,7 @@ import java.lang.annotation.*;
public @interface ValidField {
/**
* 验证失败时的错误信息。
* 验证失败时的错误信息
*/
String message() default "非法字段";
@@ -28,7 +28,7 @@ public @interface ValidField {
Class<? extends Payload>[] payload() default {};
/**
* 允许的合法值列表。
* 允许的合法值列表
*/
String[] allowedValues();
@@ -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
@@ -23,7 +23,7 @@ public class PageResult<T> implements Serializable {
private PageData<T> data;
/**
* 构建分页结果(MyBatis-Plus {@link IPage})。
* 构建分页结果(MyBatis-Plus {@link IPage})
*/
public static <T> PageResult<T> success(IPage<T> page) {
PageResult<T> result = new PageResult<>();
@@ -43,7 +43,7 @@ public class PageResult<T> implements Serializable {
}
/**
* 构建列表结果(无分页)。
* 构建列表结果(无分页)
*/
public static <T> PageResult<T> success(List<T> list) {
PageResult<T> result = new PageResult<>();
@@ -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 {
}
@@ -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<Object, Object> caffeineBuilder = Caffeine.from(caffeineSpec);
caffeineCacheManager.setCaffeine(caffeineBuilder);
return caffeineCacheManager;
}
}
@@ -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;
}
@@ -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();
@@ -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;
@@ -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;
}
}
@@ -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"
@@ -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)) {
@@ -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
@@ -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,
@@ -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;
@@ -18,12 +18,12 @@ import java.lang.annotation.Target;
public @interface RateLimit {
/**
* 窗口内允许的最大请求数。
* 窗口内允许的最大请求数
*/
int limit() default 0;
/**
* 滑动窗口大小(秒)。
* 滑动窗口大小(秒)
*/
int window() default 0;
}
@@ -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);
}
@@ -60,6 +60,14 @@ public interface FormDataService {
*/
FormDataVO getFormDataDetail(Long dataId);
/**
* 更新已提交的表单数据(发起人节点重新提交)
*
* @param dataId 数据ID
* @param data 表单数据(field -> value 映射)
*/
void updateFormData(Long dataId, Map<String, Object> data);
/**
* 按表单标识删除全部提交数据(演示数据重置用)
*
@@ -206,6 +206,22 @@ public class FormDataServiceImpl extends ServiceImpl<FormDataMapper, FormData> i
this.removeByIds(ids);
}
/**
* 更新已提交的表单数据(发起人节点重新提交)
*
* @param dataId 数据ID
* @param data 表单数据(field -> value 映射)
*/
@Override
public void updateFormData(Long dataId, Map<String, Object> data) {
FormData entity = this.getById(dataId);
if (entity == null) {
throw new BusinessException("表单数据不存在");
}
entity.setDataJson(JSONUtil.toJsonStr(data));
this.updateById(entity);
}
/**
* 按表单标识删除全部提交数据(演示数据重置用)
*
@@ -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 {
}
@@ -23,4 +23,7 @@ public class CompleteTaskForm {
@Schema(description = "流程变量(驱动后续网关条件)")
private Map<String, Object> variables;
@Schema(description = "发起表单数据(发起人节点重新提交时填写)")
private Map<String, Object> formData;
}
@@ -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();
}
}
@@ -71,6 +71,10 @@ public class WorkflowInstanceServiceImpl implements WorkflowInstanceService {
Assert.isTrue(!definition.isSuspended(), "流程定义已挂起,无法发起");
Map<String, Object> 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);
@@ -71,14 +71,20 @@ public class WorkflowModelServiceImpl implements WorkflowModelService {
<bpmn:startEvent id="startEvent" name="开始">
<bpmn:outgoing>flow1</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:userTask id="activity1" name="审批">
<bpmn:userTask id="initiator" name="发起申请" flowable:assignee="${initiator}"
flowable:skipExpression="${_SKIP_INITIATOR == true}">
<bpmn:incoming>flow1</bpmn:incoming>
<bpmn:outgoing>flowInitiator</bpmn:outgoing>
</bpmn:userTask>
<bpmn:userTask id="activity1" name="审批">
<bpmn:incoming>flowInitiator</bpmn:incoming>
<bpmn:outgoing>flow2</bpmn:outgoing>
</bpmn:userTask>
<bpmn:endEvent id="endEvent" name="结束">
<bpmn:incoming>flow2</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="flow1" sourceRef="startEvent" targetRef="activity1"/>
<bpmn:sequenceFlow id="flow1" sourceRef="startEvent" targetRef="initiator"/>
<bpmn:sequenceFlow id="flowInitiator" sourceRef="initiator" targetRef="activity1"/>
<bpmn:sequenceFlow id="flow2" sourceRef="activity1" targetRef="endEvent"/>
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_%1$s">
@@ -86,19 +92,26 @@ public class WorkflowModelServiceImpl implements WorkflowModelService {
<bpmndi:BPMNShape id="startEvent_di" bpmnElement="startEvent">
<dc:Bounds x="152" y="102" width="36" height="36"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="activity1_di" bpmnElement="activity1">
<bpmndi:BPMNShape id="initiator_di" bpmnElement="initiator">
<dc:Bounds x="240" y="80" width="100" height="80"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="activity1_di" bpmnElement="activity1">
<dc:Bounds x="350" y="80" width="100" height="80"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="endEvent_di" bpmnElement="endEvent">
<dc:Bounds x="392" y="102" width="36" height="36"/>
<dc:Bounds x="502" y="102" width="36" height="36"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="flow1_di" bpmnElement="flow1">
<di:waypoint x="188" y="120"/>
<di:waypoint x="240" y="120"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow2_di" bpmnElement="flow2">
<bpmndi:BPMNEdge id="flowInitiator_di" bpmnElement="flowInitiator">
<di:waypoint x="340" y="120"/>
<di:waypoint x="392" y="120"/>
<di:waypoint x="350" y="120"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow2_di" bpmnElement="flow2">
<di:waypoint x="450" y="120"/>
<di:waypoint x="502" y="120"/>
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
@@ -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<String, Object> 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);
}
/**
@@ -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);
}
@@ -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<String> allowedExtensions;
}
/**
* 获取规范化后的扩展名白名单
* 从 upload.allowed-extensions 计算规范化后的扩展名白名单,不是独立配置项
*
* @return 小写、不含点的扩展名集合;空集合表示不限制
*/
@@ -55,4 +52,4 @@ public class StorageProperties {
.map(String::toLowerCase)
.collect(Collectors.toUnmodifiableSet());
}
}
}
@@ -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();
@@ -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
@@ -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 {
}
}
}
}
}
@@ -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;
}
}
}
}
}
@@ -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 邮件主题
@@ -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());
}
}
}
}
@@ -15,6 +15,8 @@ import java.util.List;
@Setter
@ConfigurationProperties("realtime")
public class RealtimeProperties {
/** 是否启用实时通信接口 */
private boolean enabled = true;
/** 额外允许的浏览器来源,如 https://admin.example.com */
private List<String> allowedOrigins = List.of();
}
@@ -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<String, String> templates;
}
}
@@ -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.*} 配置中的模板
@@ -8,7 +8,7 @@ import org.springframework.stereotype.Component;
import java.util.Set;
/**
* 权限查询适配器。
* 权限查询适配器
*
* @author Ray.Hao
* @since 3.1.0
@@ -63,7 +63,7 @@ public class FileController {
}
/**
* 文件扩展名白名单校验:{@code allowed-extensions} 为空时不限制。
* 文件扩展名白名单校验:{@code allowed-extensions} 为空时不限制
*/
private void validateFileExtension(MultipartFile file) {
Set<String> allowedExtensions = storageProperties.getAllowedExtensions();
@@ -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
@@ -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);
}
@@ -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
@@ -27,7 +27,7 @@ import java.util.List;
import java.util.stream.Collectors;
/**
* 用户导入读取器。
* 用户导入读取器
*
* @author Ray
* @since 2022/4/10
@@ -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<java.lang.String>",
"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"
}
]
}
]
}
}
+93 -188
View File
@@ -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
+95 -203
View File
@@ -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
+62 -2
View File
@@ -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
@@ -1,372 +1,174 @@
<template>
<div class="page-container h-full flex flex-1 flex-col">
<!-- 搜索 -->
<CrudSearch
ref="searchRef"
:search-config="searchConfig"
@query-click="handleQueryClick"
@reset-click="handleResetClick"
>
#foreach($fieldConfig in $fieldConfigs)
#if($fieldConfig.isShowInQuery == 1 && $fieldConfig.dictType && $fieldConfig.dictType.trim() != "")
<template #$fieldConfig.fieldName="scope">
<DictSelect v-model="scope.formData[scope.prop]" code="$fieldConfig.dictType" v-bind="scope.attrs" />
</template>
<CrudPage
:search-config="searchConfig"
:table-config="tableConfig"
:form-config="formConfig"
:load-form-data="loadFormData"
>
#foreach($field in $fieldConfigs)
#if($field.isShowInQuery == 1 && $field.dictType && $field.dictType.trim() != "")
<template #search-${field.fieldName}="{ formData, prop, attrs }">
<DictSelect v-model="formData[prop]" code="$field.dictType" v-bind="attrs" />
</template>
#end
#end
</CrudSearch>
<!-- 列表 -->
<CrudTable
ref="contentRef"
:content-config="contentConfig"
@add-click="handleAddClick"
@export-click="handleExportClick"
@search-click="handleSearchClick"
@toolbar-click="handleToolbarClick"
@operate-click="handleOperateClick"
@filter-change="handleFilterChange"
>
#foreach($fieldConfig in $fieldConfigs)
#if($fieldConfig.isShowInList == 1 && $fieldConfig.dictType && $fieldConfig.dictType.trim() != "")
<template #$fieldConfig.fieldName="scope">
<DictTag v-model="scope.row[scope.prop]" code="$fieldConfig.dictType" />
</template>
#foreach($field in $fieldConfigs)
#if($field.isShowInList == 1 && $field.dictType && $field.dictType.trim() != "")
<template #table-cell-${field.fieldName}="{ row }">
<DictTag :model-value="row.${field.fieldName}" code="$field.dictType" />
</template>
#end
#end
</CrudTable>
<!-- 新增 -->
<CrudModal ref="addModalRef" :modal-config="addModalConfig" @submit-click="handleSubmitClick">
#foreach($fieldConfig in $fieldConfigs)
#if($fieldConfig.isShowInForm == 1 && $fieldConfig.formType.name() != "HIDDEN" && $fieldConfig.dictType && $fieldConfig.dictType.trim() != "")
<template #$fieldConfig.fieldName="scope">
<DictSelect v-model="scope.formData[scope.prop]" code="$fieldConfig.dictType" v-bind="scope.attrs" />
</template>
#foreach($field in $fieldConfigs)
#if($field.isShowInForm == 1 && $field.formType.name() != "HIDDEN" && $field.dictType && $field.dictType.trim() != "")
<template #form-${field.fieldName}="{ formData, prop, attrs }">
<DictSelect v-model="formData[prop]" code="$field.dictType" v-bind="attrs" />
</template>
#end
#end
</CrudModal>
<!-- 编辑 -->
<CrudModal ref="editModalRef" :modal-config="editModalConfig" @submit-click="handleSubmitClick">
#foreach($fieldConfig in $fieldConfigs)
#if($fieldConfig.isShowInForm == 1 && $fieldConfig.formType.name() != "HIDDEN" && $fieldConfig.dictType && $fieldConfig.dictType.trim() != "")
<template #$fieldConfig.fieldName="scope">
<DictSelect v-model="scope.formData[scope.prop]" code="$fieldConfig.dictType" v-bind="scope.attrs" />
</template>
#end
#end
</CrudModal>
</div>
</CrudPage>
</template>
<script setup>
defineOptions({
name: "$entityName",
inheritAttrs: false,
});
import ${entityName}API from "@/api/${moduleName}/${entityKebab}";
import useCrudPage from "@/components/Crud/useCrudPage";
// 组合式 CRUD
const {
searchRef,
contentRef,
addModalRef,
editModalRef,
handleQueryClick,
handleResetClick,
handleAddClick,
handleEditClick,
handleSubmitClick,
handleExportClick,
handleSearchClick,
handleFilterChange,
} = useCrudPage();
defineOptions({ name: "$entityName", inheritAttrs: false });
// 搜索配置
const searchConfig = reactive({
permPrefix: "${moduleName}:${entityKebab}",
formItems: [
#foreach($fieldConfig in $fieldConfigs)
#if($fieldConfig.isShowInQuery == 1)
#if($fieldConfig.dictType && $fieldConfig.dictType.trim() != "")
const searchConfig = {
fields: [
#foreach($field in $fieldConfigs)
#if($field.isShowInQuery == 1)
{
prop: "$field.fieldName",
label: "$field.fieldComment",
#if($field.dictType && $field.dictType.trim() != "")
type: "custom",
slotName: "$fieldConfig.fieldName",
label: "$fieldConfig.fieldComment",
prop: "$fieldConfig.fieldName",
attrs: {
placeholder: "$fieldConfig.fieldComment",
clearable: true,
style: { width: "200px" },
},
},
#else
{
#if($fieldConfig.fieldType == "String")
type: "input",
#elseif($fieldConfig.fieldType == "Integer" || $fieldConfig.fieldType == "Long")
type: "input-number",
#elseif($fieldConfig.fieldType == "LocalDate" || $fieldConfig.fieldType == "LocalDateTime")
attrs: { clearable: true },
#elseif($field.formType.name() == "DATE" || $field.formType.name() == "DATE_TIME")
type: "date-picker",
#else
type: "input",
#end
label: "$fieldConfig.fieldComment",
prop: "$fieldConfig.fieldName",
#if($field.queryType.name() == "BETWEEN")
initialValue: [],
attrs: {
placeholder: "$fieldConfig.fieldComment",
#if($fieldConfig.fieldType == "LocalDate" || $fieldConfig.fieldType == "LocalDateTime")
type: "daterange",
"range-separator": "~",
"start-placeholder": "开始时间",
"end-placeholder": "截止时间",
"value-format": "YYYY-MM-DD",
#end
clearable: true,
style: { width: "200px" },
valueFormat: "YYYY-MM-DD",
startPlaceholder: "开始日期",
endPlaceholder: "结束日期",
},
},
#else
attrs: { type: "date", valueFormat: "YYYY-MM-DD", clearable: true },
#end
#elseif($field.fieldType == "Integer" || $field.fieldType == "Long" || $field.fieldType == "BigDecimal")
type: "input-number",
attrs: { clearable: true },
#else
type: "input",
attrs: { clearable: true },
#end
},
#end
#end
],
});
};
// 列表配置
const contentConfig = reactive({
// 权限前缀
permPrefix: "${moduleName}:${entityKebab}",
table: {
border: true,
highlightCurrentRow: true,
},
// 主键
pk: "id",
// 列表查询接口
indexAction: ${entityName}API.getPage,
// 删除接口
deleteAction: ${entityName}API.deleteByIds,
// 数据解析函数
parseData(res) {
return {
total: res?.total ?? 0,
list: res?.list ?? [],
};
},
// 分页配置
pagination: {
background: true,
layout: "total, sizes, prev, pager, next, jumper",
pageSize: 20,
pageSizes: [10, 20, 30, 50],
},
// 工具栏配置
toolbar: ["add", "delete"],
defaultToolbar: ["refresh", "filter"],
// 表格列配置
cols: [
{ type: "selection", width: 55, align: "center" },
#foreach($fieldConfig in $fieldConfigs)
#if($fieldConfig.isShowInList == 1)
#if($fieldConfig.dictType && $fieldConfig.dictType.trim() != "")
{
label: "$fieldConfig.fieldComment",
prop: "$fieldConfig.fieldName",
templet: "custom",
slotName: "$fieldConfig.fieldName"
},
#else
{ label: "$fieldConfig.fieldComment", prop: "$fieldConfig.fieldName" },
#end
const tableConfig = {
permissionPrefix: "${moduleName}:${entityKebab}",
table: { border: true },
fetchData: ${entityName}API.getPage,
deleteByIds: ${entityName}API.deleteByIds,
toolbarActions: ["create", "delete"],
tableTools: ["refresh", "columns", "search"],
columns: [
{ type: "selection", width: 50 },
#foreach($field in $fieldConfigs)
#if($field.isShowInList == 1)
{ prop: "$field.fieldName", label: "$field.fieldComment", minWidth: 120 },
#end
#end
{
prop: "actions",
label: "操作",
prop: "operation",
width: 180,
templet: "tool",
operat: ["edit", "delete"],
cellType: "actions",
actions: ["view", "edit", "delete"],
fixed: "right",
width: 200,
},
],
});
};
// 新增配置
const addModalConfig = reactive({
// 权限前缀
permPrefix: "${moduleName}:${entityKebab}",
// 主键
pk: "id",
// 弹窗配置
dialog: {
title: "新增$!{entityComment}",
width: 800,
draggable: true,
const formConfig = {
title: ({ mode }) => ({
create: "新增$!{entityComment}",
edit: "编辑$!{entityComment}",
view: "查看$!{entityComment}",
})[mode],
dialog: { width: 560 },
/**
* 保存记录,更新目标使用打开表单时的主键。
*/
submitForm(data, { mode, id }) {
if (mode === "create") return ${entityName}API.create(data);
if (id === undefined) throw new Error("缺少记录 ID");
return ${entityName}API.update(String(id), data);
},
form: {
labelWidth: 100,
},
// 表单项配置
formItems: [
#foreach($fieldConfig in $fieldConfigs)
#if($fieldConfig.isShowInForm == 1 && $fieldConfig.formType.name() != "HIDDEN")
#if($fieldConfig.dictType && $fieldConfig.dictType.trim() != "")
fields: [
#foreach($field in $fieldConfigs)
#if($field.isShowInForm == 1 && $field.formType.name() != "HIDDEN")
{
prop: "$field.fieldName",
label: "$field.fieldComment",
#if($field.isRequired == 1)
required: true,
#end
#if($field.dictType && $field.dictType.trim() != "")
type: "custom",
label: "$fieldConfig.fieldComment",
prop: "$fieldConfig.fieldName",
slotName: "$fieldConfig.fieldName",
attrs: {
placeholder: "$fieldConfig.fieldComment",
style: { width: "100%" }
},
#if($fieldConfig.isRequired == 1)
rules: [{ required: true, message: "$fieldConfig.fieldComment不能为空", trigger: "change" }],
#end
},
#else
{
#if($fieldConfig.fieldType == "String")
#if($fieldConfig.fieldName.contains("password") || $fieldConfig.fieldName.contains("pwd"))
type: "input",
attrs: {
type: "password",
placeholder: "$fieldConfig.fieldComment",
showPassword: true
},
#if($fieldConfig.isRequired == 1)
rules: [{ required: true, message: "$fieldConfig.fieldComment不能为空", trigger: "blur" }],
#end
#elseif($fieldConfig.fieldName.contains("email"))
type: "input",
attrs: {
placeholder: "$fieldConfig.fieldComment",
maxlength: 50
},
rules: [
#if($fieldConfig.isRequired == 1)
{ required: true, message: "$fieldConfig.fieldComment不能为空", trigger: "blur" },
#end
{ pattern: /\w[-\w.+]*@([A-Za-z0-9][-A-Za-z0-9]+\.)+[A-Za-z]{2,14}/, message: "请输入正确的邮箱地址", trigger: "blur" }
],
#elseif($fieldConfig.fieldName.contains("mobile") || $fieldConfig.fieldName.contains("phone"))
type: "input",
attrs: {
placeholder: "$fieldConfig.fieldComment",
maxlength: 11
},
rules: [
#if($fieldConfig.isRequired == 1)
{ required: true, message: "$fieldConfig.fieldComment不能为空", trigger: "blur" },
#end
{ pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/, message: "请输入正确的手机号码", trigger: "blur" }
],
#else
type: "input",
attrs: {
placeholder: "$fieldConfig.fieldComment"
},
#if($fieldConfig.isRequired == 1)
rules: [{ required: true, message: "$fieldConfig.fieldComment不能为空", trigger: "blur" }],
#end
#end
#elseif($fieldConfig.fieldType == "Integer" || $fieldConfig.fieldType == "Long")
type: "input-number",
attrs: {
placeholder: "$fieldConfig.fieldComment",
style: { width: "100%" }
},
#if($fieldConfig.isRequired == 1)
rules: [{ required: true, message: "$fieldConfig.fieldComment不能为空", trigger: "blur" }],
#end
#elseif($fieldConfig.fieldType == "BigDecimal" || $fieldConfig.fieldType == "Double")
type: "input-number",
attrs: {
placeholder: "$fieldConfig.fieldComment",
precision: 2,
style: { width: "100%" }
},
#if($fieldConfig.isRequired == 1)
rules: [{ required: true, message: "$fieldConfig.fieldComment不能为空", trigger: "blur" }],
#end
#elseif($fieldConfig.fieldType == "LocalDate" || $fieldConfig.fieldType == "LocalDateTime")
type: "date-picker",
attrs: {
type: "datetime",
placeholder: "$fieldConfig.fieldComment",
format: "YYYY-MM-DD HH:mm:ss",
"value-format": "YYYY-MM-DD HH:mm:ss",
style: { width: "100%" }
},
#if($fieldConfig.isRequired == 1)
rules: [{ required: true, message: "$fieldConfig.fieldComment不能为空", trigger: "change" }],
#end
#elseif($fieldConfig.fieldType == "Boolean" || $fieldConfig.fieldName.contains("status"))
#elseif($field.formType.name() == "SWITCH")
type: "switch",
attrs: {
activeText: "启用",
inactiveText: "禁用",
activeValue: 1,
inactiveValue: 0
},
#if($field.fieldType == "Boolean")
initialValue: true,
attrs: { activeValue: true, inactiveValue: false },
#else
initialValue: 1,
attrs: { activeValue: 1, inactiveValue: 0 },
#end
#elseif($field.formType.name() == "DATE" || $field.formType.name() == "DATE_TIME")
type: "date-picker",
#if($field.formType.name() == "DATE_TIME")
attrs: { type: "datetime", valueFormat: "YYYY-MM-DD HH:mm:ss" },
#else
attrs: { type: "date", valueFormat: "YYYY-MM-DD" },
#end
#elseif($field.formType.name() == "INPUT_NUMBER")
type: "input-number",
#if($field.fieldType == "BigDecimal" || $field.fieldType == "Double")
attrs: { precision: 2 },
#end
#elseif($field.formType.name() == "SELECT")
type: "select",
options: [],
#elseif($field.formType.name() == "RADIO")
type: "radio",
options: [],
#elseif($field.formType.name() == "CHECK_BOX")
type: "checkbox",
initialValue: [],
options: [],
#else
type: "input",
attrs: {
placeholder: "$fieldConfig.fieldComment"
},
#if($fieldConfig.isRequired == 1)
rules: [{ required: true, message: "$fieldConfig.fieldComment不能为空", trigger: "blur" }],
#if($field.formType.name() == "TEXT_AREA")
attrs: { type: "textarea", rows: 3 },
#end
#end
label: "$fieldConfig.fieldComment",
prop: "$fieldConfig.fieldName",
},
#end
#end
#end
],
// 提交函数
formAction(data) {
if (data.id) {
// 编辑
return ${entityName}API.update(data.id, data);
} else {
// 新增
return ${entityName}API.create(data);
}
},
});
// 编辑配置
const editModalConfig = reactive({
permPrefix: "${moduleName}:${entityKebab}",
component: "drawer",
drawer: {
title: "编辑$!{entityComment}",
size: 500,
},
pk: "id",
formAction(data) {
return ${entityName}API.update(data.id, data);
},
formItems: addModalConfig.formItems, // 复用新增的表单项
});
// 处理操作按钮点击
const handleOperateClick = (data) => {
if (data.name === "edit") {
handleEditClick(data.row, async () => {
return await ${entityName}API.getFormData(String(data.row.id));
});
}
};
// 处理工具栏按钮点击(删除等)
const handleToolbarClick = (name) => {
console.log(name);
};
/**
* 加载编辑和查看所需的详情。
*/
function loadFormData(row) {
const rowKey = tableConfig.rowKey ?? "id";
return ${entityName}API.getFormData(String(row[rowKey]));
}
</script>
Binary file not shown.
+3 -3
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- 日志级别从低到高分为TRACE < DEBUG < INFO < WARN < ERROR < FATAL,如果设置为WARN,则低于WARN的信息都不会输出 -->
<!-- 日志级别:TRACE < DEBUG < INFO < WARN < ERROR;设置 WARN 时输出 WARN 和 ERROR -->
<configuration>
<!-- Spring Boot 默认 logback 的配置 -->
@@ -11,7 +11,7 @@
<!-- 1. 输出到控制台-->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<!-- <withJansi>true</withJansi>-->
<!--此日志 appender 是为开发使用,只配置最低级别,控制台输出的日志级别是大于或等于此级别的日志信息-->
<!--各环境共用的控制台输出;同时受 logger 级别和此过滤器限制-->
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>DEBUG</level>
</filter>
@@ -41,7 +41,7 @@
<!-- 总日志文件大小不超过1GB -->
<totalSizeCap>1GB</totalSizeCap>
</rollingPolicy>
<!-- 临界值过滤器,输出大于INFO级别日志 -->
<!-- 输出 INFO 及以上级别的日志 -->
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>INFO</level>
</filter>
+34 -21
View File
@@ -7,11 +7,16 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
id="Definitions_expense" targetNamespace="http://youlai.tech/workflow">
<bpmn:process id="expense" name="报销审批" isExecutable="true">
<bpmn:startEvent id="startEvent" name="开始" flowable:formKey="expense_apply">
<bpmn:startEvent id="startEvent" name="开始">
<bpmn:outgoing>flow1</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:userTask id="leaderApprove" name="部门主管审批" flowable:candidateGroups="DEPT_MANAGER">
<bpmn:userTask id="initiator" name="发起申请" flowable:assignee="${initiator}"
flowable:formKey="expense_apply" flowable:skipExpression="${_SKIP_INITIATOR == true}">
<bpmn:incoming>flow1</bpmn:incoming>
<bpmn:outgoing>flowInitiator</bpmn:outgoing>
</bpmn:userTask>
<bpmn:userTask id="leaderApprove" name="部门主管审批" flowable:candidateGroups="DEPT_MANAGER">
<bpmn:incoming>flowInitiator</bpmn:incoming>
<bpmn:outgoing>flow2</bpmn:outgoing>
</bpmn:userTask>
<bpmn:exclusiveGateway id="amountGateway" name="报销金额?" default="flow4">
@@ -31,7 +36,8 @@
<bpmn:endEvent id="endEvent" name="结束">
<bpmn:incoming>flow6</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="flow1" sourceRef="startEvent" targetRef="leaderApprove"/>
<bpmn:sequenceFlow id="flow1" sourceRef="startEvent" targetRef="initiator"/>
<bpmn:sequenceFlow id="flowInitiator" sourceRef="initiator" targetRef="leaderApprove"/>
<bpmn:sequenceFlow id="flow2" sourceRef="leaderApprove" targetRef="amountGateway"/>
<bpmn:sequenceFlow id="flow3" name="超过5000元" sourceRef="amountGateway" targetRef="managerApprove">
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">${amount > 5000}</bpmn:conditionExpression>
@@ -42,50 +48,57 @@
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_expense">
<bpmndi:BPMNPlane id="BPMNPlane_expense" bpmnElement="expense">
<bpmndi:BPMNShape id="initiator_di" bpmnElement="initiator">
<dc:Bounds x="240" y="80" width="100" height="80"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="startEvent_di" bpmnElement="startEvent">
<dc:Bounds x="152" y="102" width="36" height="36"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="leaderApprove_di" bpmnElement="leaderApprove">
<dc:Bounds x="240" y="80" width="100" height="80"/>
<dc:Bounds x="350" y="80" width="100" height="80"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="amountGateway_di" bpmnElement="amountGateway">
<dc:Bounds x="395" y="95" width="50" height="50"/>
<dc:Bounds x="505" y="95" width="50" height="50"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="managerApprove_di" bpmnElement="managerApprove">
<dc:Bounds x="510" y="80" width="100" height="80"/>
<dc:Bounds x="620" y="80" width="100" height="80"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="financeApprove_di" bpmnElement="financeApprove">
<dc:Bounds x="650" y="240" width="100" height="80"/>
<dc:Bounds x="760" y="240" width="100" height="80"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="endEvent_di" bpmnElement="endEvent">
<dc:Bounds x="802" y="102" width="36" height="36"/>
<dc:Bounds x="912" y="102" width="36" height="36"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="flowInitiator_di" bpmnElement="flowInitiator">
<di:waypoint x="340" y="120"/>
<di:waypoint x="350" y="120"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow1_di" bpmnElement="flow1">
<di:waypoint x="188" y="120"/>
<di:waypoint x="240" y="120"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow2_di" bpmnElement="flow2">
<di:waypoint x="340" y="120"/>
<di:waypoint x="395" y="120"/>
<di:waypoint x="450" y="120"/>
<di:waypoint x="505" y="120"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow3_di" bpmnElement="flow3">
<di:waypoint x="445" y="120"/>
<di:waypoint x="510" y="120"/>
<di:waypoint x="555" y="120"/>
<di:waypoint x="620" y="120"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow4_di" bpmnElement="flow4">
<di:waypoint x="420" y="145"/>
<di:waypoint x="420" y="280"/>
<di:waypoint x="650" y="280"/>
<di:waypoint x="530" y="145"/>
<di:waypoint x="530" y="280"/>
<di:waypoint x="760" y="280"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow5_di" bpmnElement="flow5">
<di:waypoint x="610" y="120"/>
<di:waypoint x="700" y="120"/>
<di:waypoint x="700" y="240"/>
<di:waypoint x="720" y="120"/>
<di:waypoint x="810" y="120"/>
<di:waypoint x="810" y="240"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow6_di" bpmnElement="flow6">
<di:waypoint x="750" y="280"/>
<di:waypoint x="820" y="280"/>
<di:waypoint x="820" y="138"/>
<di:waypoint x="860" y="280"/>
<di:waypoint x="930" y="280"/>
<di:waypoint x="930" y="138"/>
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
+30 -17
View File
@@ -7,11 +7,16 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
id="Definitions_leave" targetNamespace="http://youlai.tech/workflow">
<bpmn:process id="leave" name="请假审批" isExecutable="true">
<bpmn:startEvent id="startEvent" name="开始" flowable:formKey="leave_apply">
<bpmn:startEvent id="startEvent" name="开始">
<bpmn:outgoing>flow1</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:userTask id="leaderApprove" name="部门主管审批" flowable:candidateGroups="DEPT_MANAGER">
<bpmn:userTask id="initiator" name="发起申请" flowable:assignee="${initiator}"
flowable:formKey="leave_apply" flowable:skipExpression="${_SKIP_INITIATOR == true}">
<bpmn:incoming>flow1</bpmn:incoming>
<bpmn:outgoing>flowInitiator</bpmn:outgoing>
</bpmn:userTask>
<bpmn:userTask id="leaderApprove" name="部门主管审批" flowable:candidateGroups="DEPT_MANAGER">
<bpmn:incoming>flowInitiator</bpmn:incoming>
<bpmn:outgoing>flow2</bpmn:outgoing>
</bpmn:userTask>
<bpmn:exclusiveGateway id="daysGateway" name="请假天数?" default="flow4">
@@ -27,7 +32,8 @@
<bpmn:incoming>flow4</bpmn:incoming>
<bpmn:incoming>flow5</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="flow1" sourceRef="startEvent" targetRef="leaderApprove"/>
<bpmn:sequenceFlow id="flow1" sourceRef="startEvent" targetRef="initiator"/>
<bpmn:sequenceFlow id="flowInitiator" sourceRef="initiator" targetRef="leaderApprove"/>
<bpmn:sequenceFlow id="flow2" sourceRef="leaderApprove" targetRef="daysGateway"/>
<bpmn:sequenceFlow id="flow3" name="超过3天" sourceRef="daysGateway" targetRef="managerApprove">
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">${days > 3}</bpmn:conditionExpression>
@@ -37,42 +43,49 @@
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_leave">
<bpmndi:BPMNPlane id="BPMNPlane_leave" bpmnElement="leave">
<bpmndi:BPMNShape id="initiator_di" bpmnElement="initiator">
<dc:Bounds x="240" y="80" width="100" height="80"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="startEvent_di" bpmnElement="startEvent">
<dc:Bounds x="152" y="102" width="36" height="36"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="leaderApprove_di" bpmnElement="leaderApprove">
<dc:Bounds x="240" y="80" width="100" height="80"/>
<dc:Bounds x="350" y="80" width="100" height="80"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="daysGateway_di" bpmnElement="daysGateway">
<dc:Bounds x="395" y="95" width="50" height="50"/>
<dc:Bounds x="505" y="95" width="50" height="50"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="managerApprove_di" bpmnElement="managerApprove">
<dc:Bounds x="510" y="80" width="100" height="80"/>
<dc:Bounds x="620" y="80" width="100" height="80"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="endEvent_di" bpmnElement="endEvent">
<dc:Bounds x="662" y="102" width="36" height="36"/>
<dc:Bounds x="772" y="102" width="36" height="36"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="flowInitiator_di" bpmnElement="flowInitiator">
<di:waypoint x="340" y="120"/>
<di:waypoint x="350" y="120"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow1_di" bpmnElement="flow1">
<di:waypoint x="188" y="120"/>
<di:waypoint x="240" y="120"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow2_di" bpmnElement="flow2">
<di:waypoint x="340" y="120"/>
<di:waypoint x="395" y="120"/>
<di:waypoint x="450" y="120"/>
<di:waypoint x="505" y="120"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow3_di" bpmnElement="flow3">
<di:waypoint x="445" y="120"/>
<di:waypoint x="510" y="120"/>
<di:waypoint x="555" y="120"/>
<di:waypoint x="620" y="120"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow4_di" bpmnElement="flow4">
<di:waypoint x="420" y="145"/>
<di:waypoint x="420" y="200"/>
<di:waypoint x="680" y="200"/>
<di:waypoint x="680" y="138"/>
<di:waypoint x="530" y="145"/>
<di:waypoint x="530" y="200"/>
<di:waypoint x="790" y="200"/>
<di:waypoint x="790" y="138"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow5_di" bpmnElement="flow5">
<di:waypoint x="610" y="120"/>
<di:waypoint x="662" y="120"/>
<di:waypoint x="720" y="120"/>
<di:waypoint x="772" y="120"/>
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
+34 -21
View File
@@ -8,11 +8,16 @@
id="Definitions_purchase" targetNamespace="http://youlai.tech/workflow">
<bpmn:process id="purchase" name="采购申请" isExecutable="true">
<!-- flowable:initiator:启动时将发起人写入流程变量 initiator,供"采购执行"回退发起人办理 -->
<bpmn:startEvent id="startEvent" name="开始" flowable:formKey="purchase_apply" flowable:initiator="initiator">
<bpmn:startEvent id="startEvent" name="开始" flowable:initiator="initiator">
<bpmn:outgoing>flow1</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:userTask id="leaderApprove" name="部门主管审批" flowable:candidateGroups="DEPT_MANAGER">
<bpmn:userTask id="initiator" name="发起申请" flowable:assignee="${initiator}"
flowable:formKey="purchase_apply" flowable:skipExpression="${_SKIP_INITIATOR == true}">
<bpmn:incoming>flow1</bpmn:incoming>
<bpmn:outgoing>flowInitiator</bpmn:outgoing>
</bpmn:userTask>
<bpmn:userTask id="leaderApprove" name="部门主管审批" flowable:candidateGroups="DEPT_MANAGER">
<bpmn:incoming>flowInitiator</bpmn:incoming>
<bpmn:outgoing>flow2</bpmn:outgoing>
</bpmn:userTask>
<bpmn:exclusiveGateway id="amountGateway" name="预估金额?" default="flow4">
@@ -32,7 +37,8 @@
<bpmn:endEvent id="endEvent" name="结束">
<bpmn:incoming>flow6</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="flow1" sourceRef="startEvent" targetRef="leaderApprove"/>
<bpmn:sequenceFlow id="flow1" sourceRef="startEvent" targetRef="initiator"/>
<bpmn:sequenceFlow id="flowInitiator" sourceRef="initiator" targetRef="leaderApprove"/>
<bpmn:sequenceFlow id="flow2" sourceRef="leaderApprove" targetRef="amountGateway"/>
<bpmn:sequenceFlow id="flow3" name="超过1万元" sourceRef="amountGateway" targetRef="managerApprove">
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">${amount > 10000}</bpmn:conditionExpression>
@@ -43,50 +49,57 @@
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_purchase">
<bpmndi:BPMNPlane id="BPMNPlane_purchase" bpmnElement="purchase">
<bpmndi:BPMNShape id="initiator_di" bpmnElement="initiator">
<dc:Bounds x="240" y="80" width="100" height="80"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="startEvent_di" bpmnElement="startEvent">
<dc:Bounds x="152" y="102" width="36" height="36"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="leaderApprove_di" bpmnElement="leaderApprove">
<dc:Bounds x="240" y="80" width="100" height="80"/>
<dc:Bounds x="350" y="80" width="100" height="80"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="amountGateway_di" bpmnElement="amountGateway">
<dc:Bounds x="395" y="95" width="50" height="50"/>
<dc:Bounds x="505" y="95" width="50" height="50"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="managerApprove_di" bpmnElement="managerApprove">
<dc:Bounds x="510" y="80" width="100" height="80"/>
<dc:Bounds x="620" y="80" width="100" height="80"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="purchaseExecute_di" bpmnElement="purchaseExecute">
<dc:Bounds x="650" y="240" width="100" height="80"/>
<dc:Bounds x="760" y="240" width="100" height="80"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="endEvent_di" bpmnElement="endEvent">
<dc:Bounds x="802" y="102" width="36" height="36"/>
<dc:Bounds x="912" y="102" width="36" height="36"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="flowInitiator_di" bpmnElement="flowInitiator">
<di:waypoint x="340" y="120"/>
<di:waypoint x="350" y="120"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow1_di" bpmnElement="flow1">
<di:waypoint x="188" y="120"/>
<di:waypoint x="240" y="120"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow2_di" bpmnElement="flow2">
<di:waypoint x="340" y="120"/>
<di:waypoint x="395" y="120"/>
<di:waypoint x="450" y="120"/>
<di:waypoint x="505" y="120"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow3_di" bpmnElement="flow3">
<di:waypoint x="445" y="120"/>
<di:waypoint x="510" y="120"/>
<di:waypoint x="555" y="120"/>
<di:waypoint x="620" y="120"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow4_di" bpmnElement="flow4">
<di:waypoint x="420" y="145"/>
<di:waypoint x="420" y="280"/>
<di:waypoint x="650" y="280"/>
<di:waypoint x="530" y="145"/>
<di:waypoint x="530" y="280"/>
<di:waypoint x="760" y="280"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow5_di" bpmnElement="flow5">
<di:waypoint x="610" y="120"/>
<di:waypoint x="700" y="120"/>
<di:waypoint x="700" y="240"/>
<di:waypoint x="720" y="120"/>
<di:waypoint x="810" y="120"/>
<di:waypoint x="810" y="240"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow6_di" bpmnElement="flow6">
<di:waypoint x="750" y="280"/>
<di:waypoint x="820" y="280"/>
<di:waypoint x="820" y="138"/>
<di:waypoint x="860" y="280"/>
<di:waypoint x="930" y="280"/>
<di:waypoint x="930" y="138"/>
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
+23 -10
View File
@@ -6,11 +6,16 @@
xmlns:flowable="http://flowable.org/bpmn"
id="Definitions_seal" targetNamespace="http://youlai.tech/workflow">
<bpmn:process id="seal" name="用章申请" isExecutable="true">
<bpmn:startEvent id="startEvent" name="开始" flowable:formKey="seal_apply">
<bpmn:startEvent id="startEvent" name="开始">
<bpmn:outgoing>flow1</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:userTask id="leaderApprove" name="部门主管审批" flowable:candidateGroups="DEPT_MANAGER">
<bpmn:userTask id="initiator" name="发起申请" flowable:assignee="${initiator}"
flowable:formKey="seal_apply" flowable:skipExpression="${_SKIP_INITIATOR == true}">
<bpmn:incoming>flow1</bpmn:incoming>
<bpmn:outgoing>flowInitiator</bpmn:outgoing>
</bpmn:userTask>
<bpmn:userTask id="leaderApprove" name="部门主管审批" flowable:candidateGroups="DEPT_MANAGER">
<bpmn:incoming>flowInitiator</bpmn:incoming>
<bpmn:outgoing>flow2</bpmn:outgoing>
</bpmn:userTask>
<bpmn:userTask id="adminApprove" name="行政审批" flowable:candidateGroups="ADMINISTRATION">
@@ -20,35 +25,43 @@
<bpmn:endEvent id="endEvent" name="结束">
<bpmn:incoming>flow3</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="flow1" sourceRef="startEvent" targetRef="leaderApprove"/>
<bpmn:sequenceFlow id="flow1" sourceRef="startEvent" targetRef="initiator"/>
<bpmn:sequenceFlow id="flowInitiator" sourceRef="initiator" targetRef="leaderApprove"/>
<bpmn:sequenceFlow id="flow2" sourceRef="leaderApprove" targetRef="adminApprove"/>
<bpmn:sequenceFlow id="flow3" sourceRef="adminApprove" targetRef="endEvent"/>
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_seal">
<bpmndi:BPMNPlane id="BPMNPlane_seal" bpmnElement="seal">
<bpmndi:BPMNShape id="initiator_di" bpmnElement="initiator">
<dc:Bounds x="240" y="80" width="100" height="80"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="startEvent_di" bpmnElement="startEvent">
<dc:Bounds x="152" y="102" width="36" height="36"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="leaderApprove_di" bpmnElement="leaderApprove">
<dc:Bounds x="240" y="80" width="100" height="80"/>
<dc:Bounds x="350" y="80" width="100" height="80"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="adminApprove_di" bpmnElement="adminApprove">
<dc:Bounds x="400" y="80" width="100" height="80"/>
<dc:Bounds x="510" y="80" width="100" height="80"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="endEvent_di" bpmnElement="endEvent">
<dc:Bounds x="560" y="102" width="36" height="36"/>
<dc:Bounds x="670" y="102" width="36" height="36"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="flowInitiator_di" bpmnElement="flowInitiator">
<di:waypoint x="340" y="120"/>
<di:waypoint x="350" y="120"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow1_di" bpmnElement="flow1">
<di:waypoint x="188" y="120"/>
<di:waypoint x="240" y="120"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow2_di" bpmnElement="flow2">
<di:waypoint x="340" y="120"/>
<di:waypoint x="400" y="120"/>
<di:waypoint x="450" y="120"/>
<di:waypoint x="510" y="120"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow3_di" bpmnElement="flow3">
<di:waypoint x="500" y="120"/>
<di:waypoint x="560" y="120"/>
<di:waypoint x="610" y="120"/>
<di:waypoint x="670" y="120"/>
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
@@ -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) {
}
}
}
@@ -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<String, Object> redis = mock(RedisTemplate.class);
ValueOperations<String, Object> 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 {
}
}
@@ -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<String, Object> 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);
});
}
}
@@ -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 {
}
}
@@ -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<String>();
var requestBody = new AtomicReference<String>();
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);
}
}
}