fix(workflow): 修复驳回无法退回发起人
- 新增发起申请节点 - 发起表单改挂该节点 - 首次发起跳过发起人 - 驳回目标包含发起人 - 审批接口新增表单数据入参 - 新增表单数据更新逻辑 - 模型模板加入发起人节点 - AI 与流程引擎默认值移入公共配置
This commit is contained in:
1 parent
e77d0b1f4f
commit
538153a4f0
74 files changed
+1582
-1183
No files matched your search
@@ -11,3 +11,5 @@ logs
|
||||
.history
|
||||
|
||||
application-youlai.yml
|
||||
/boot-run*.log
|
||||
/boot-run*.err
|
||||
+47
-19
@@ -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
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
## 技术合作
|
||||
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按表单标识删除全部提交数据(演示数据重置用)
|
||||
*
|
||||
|
||||
+1
-1
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
+6
@@ -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);
|
||||
|
||||
+19
-6
@@ -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>
|
||||
|
||||
+13
-1
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user