init: userbase front/service/deploy source (CI builds front via Gitea Actions)

This commit is contained in:
ab
2026-08-04 21:24:50 +08:00
commit e02dcc23da
407 changed files with 48134 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
target/*
!target/userbase.jar
src
.idea
*.iml
*.log
.workbuddy
+3
View File
@@ -0,0 +1,3 @@
EXCLUSION_MISSING. current:
EOF
+20
View File
@@ -0,0 +1,20 @@
# UserBase 后端镜像(基于已构建好的 target/userbase.jar)
# 构建上下文 = service 目录: docker build -f docker/Dockerfile -t <img> .
FROM eclipse-temurin:8-jre
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl tzdata \
&& ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY target/userbase.jar /app/userbase.jar
# 镜像内 application-prod.yml 仅作「nacos 不可达时的兜底默认」;
# 正常启动由 run-app.sh 从 nacos 拉取线上配置(dataId=userbase-prod.yml)后覆盖。
COPY docker/application-prod.yml /app/config/application-prod.yml
COPY docker/run-app.sh /app/run-app.sh
RUN chmod +x /app/run-app.sh && mkdir -p /data/upload
EXPOSE 8060
ENTRYPOINT ["/app/run-app.sh"]
+90
View File
@@ -0,0 +1,90 @@
# UserBase 生产配置(线上以 nacos 的 userbase-prod.yml 为准,本文件是 nacos 不可达时的兜底)
server:
port: 8060
servlet:
# 对外经 caddy 暴露为 http://www.yunhaixingtong.com/userbase
context-path: /userbase
spring:
mvc:
date-format: yyyy-MM-dd HH:mm:ss
servlet:
multipart:
max-file-size: 50MB
max-request-size: 200MB
jackson:
time-zone: GMT+8
date-format: yyyy-MM-dd HH:mm:ss
redis:
host: redis-server
port: 6379
database: 0
password: 'wv&WcDYAmLtqTIGM'
timeout: 2000ms
data:
redis:
repositories:
enabled: false
datasource:
url: jdbc:mysql://mysql8:3306/bsch_userbase?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: 'CVf^x$sW*b6CGHxh'
driver-class-name: com.mysql.cj.jdbc.Driver
min-idle: 10
max-active: 30
jpa:
database: MySQL
show-sql: false
hibernate:
ddl-auto: update
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
properties:
hibernate.order_by.default_null_ordering: last
hibernate:
dialect: org.hibernate.dialect.MySQL5InnoDBDialect
open-in-view: true
rabbitmq:
host: rabbitmq
port: 5672
username: admin
password: 'JdU29I#Q2E4yXoUl'
listener:
direct:
auto-startup: false
simple:
auto-startup: false
# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth)
yh:
sso:
server-url-prefix: http://sso.yunhaixingtong.com
client-host-url: http://www.yunhaixingtong.com/userbase
allow-headers: all
allow-origins: all
url-pattern: /api/*
mobi-api-pattern: /mobiapi/*
app-auth:
enabled: true
# 本应用自身身份(客户端签名用,若 UserBase 需调用其他带签名服务)
app-key: 100616
app-secret: 1febb9502d2744b6a235d3e4cfde4a2d
# 受信应用(服务端验签用):appKey -> appSecret
trusted-apps:
100616: 1febb9502d2744b6a235d3e4cfde4a2d
ADMIN_TAG_ID: 290
# 上传文件落容器卷,compose 里挂到宿主机 ./upload-data
FILE_PATH: /data/upload
FILE_WEB_PATH: http://www.yunhaixingtong.com/userbase/upload/
ZIYUAN_WEB_PATH_HTTP: http://base.bestsch.com/ziyuanku/file/
UPLOAD_WEB_PATH_HTTP: http://www.yunhaixingtong.com/userbase/upload/file/
ORC_SERVICE_URL: http://oa.bestsch.com/OrcService/api/Ocr/Upload
logging:
level:
root: INFO
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# UserBase 启动入口:配置统一由 Nacos 配置中心在 bootstrap 阶段下发(见 bootstrap.yml)
# 不再需要手动 curl 拉取;若 nacos 不可达,bootstrap 中 fail-fast=false 会自动回退到
# 镜像内烤好的 application-prod.yml 兜底默认。
set -e
echo "[run-app] starting UserBase (profile=prod, nacos=${NACOS_ADDR:-nacos.yunhaixingtong.com:8848}) ..."
exec java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/userbase.jar \
--spring.profiles.active=prod
+64
View File
@@ -0,0 +1,64 @@
import ctypes
from ctypes import wintypes
rstrtmgr = ctypes.windll.rstrtmgr
class RM_UNIQUE_PROCESS(ctypes.Structure):
_fields_ = [("dwProcessId", wintypes.DWORD),
("ProcessStartTime", wintypes.FILETIME)]
class RM_PROCESS_INFO(ctypes.Structure):
_fields_ = [("Process", RM_UNIQUE_PROCESS),
("strAppName", ctypes.c_wchar * 255),
("strServiceShortName", ctypes.c_wchar * 63),
("ApplicationType", ctypes.c_int),
("AppStatus", ctypes.c_uint),
("TSSessionId", wintypes.DWORD),
("bRestartable", wintypes.BOOL)]
RmStartSession = rstrtmgr.RmStartSession
RmStartSession.argtypes = [ctypes.POINTER(ctypes.c_uint32), ctypes.c_uint32, ctypes.c_wchar_p]
RmStartSession.restype = ctypes.c_uint32
RmRegisterResources = rstrtmgr.RmRegisterResources
RmRegisterResources.argtypes = [ctypes.c_uint32, ctypes.c_uint32, ctypes.c_wchar_p,
ctypes.c_uint32, ctypes.POINTER(ctypes.c_void_p),
ctypes.c_uint32, ctypes.c_wchar_p]
RmRegisterResources.restype = ctypes.c_uint32
RmGetList = rstrtmgr.RmGetList
RmGetList.argtypes = [ctypes.c_uint32, ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint32),
ctypes.POINTER(RM_PROCESS_INFO), ctypes.POINTER(ctypes.c_uint32)]
RmGetList.restype = ctypes.c_uint32
RmEndSession = rstrtmgr.RmEndSession
RmEndSession.argtypes = [ctypes.c_uint32]
RmEndSession.restype = ctypes.c_uint32
session = ctypes.c_uint32(0)
desc = ctypes.create_unicode_buffer("diag")
rc = RmStartSession(ctypes.byref(session), 0, desc)
if rc != 0:
print("RmStartSession failed", rc)
raise SystemExit
path = ctypes.create_unicode_buffer(r"C:\ub-build\userbase\classes\application.yml")
rc = RmRegisterResources(session, 1, path, 0, None, 0, None)
print("RmRegisterResources rc=", rc)
nNeeded = ctypes.c_uint32(0)
nActual = ctypes.c_uint32(0)
reason = ctypes.c_uint32(0)
rc = RmGetList(session, ctypes.byref(nNeeded), ctypes.byref(nActual), None, ctypes.byref(reason))
print("first RmGetList rc=", rc, "needed=", nNeeded.value)
if nNeeded.value > 0:
arr = (RM_PROCESS_INFO * nNeeded.value)()
nActual = ctypes.c_uint32(nNeeded.value)
rc = RmGetList(session, ctypes.byref(nNeeded), ctypes.byref(nActual), arr, ctypes.byref(reason))
print("second RmGetList rc=", rc, "actual=", nActual.value)
for i in range(nActual.value):
p = arr[i]
print("HOLDER PID=", p.Process.dwProcessId, "app=", p.strAppName)
else:
print("NO PROCESS HOLDER (likely ACL/permission, not a lock)")
RmEndSession(session)
+238
View File
@@ -0,0 +1,238 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.yunhai</groupId>
<artifactId>yunhaibase</artifactId>
<version>0.0.1</version>
<name>UserBase</name>
<description>用户中心</description>
<properties>
<java.version>1.8</java.version>
<cn.hutool.version>5.8.36</cn.hutool.version>
<easyexcel.version>3.1.2</easyexcel.version>
</properties>
<dependencyManagement>
<!-- 基础平台版本管理(含 yh-* 架包与 spring-boot-dependencies 对齐) -->
<dependencies>
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-platform-dependencies</artifactId>
<version>1.0.0-SNAPSHOT</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Spring Cloud Alibaba(Nacos 配置中心,对齐 Boot 2.7) -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2021.0.5.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- 仅引入 Spring Security 的加密模块(BCryptPasswordEncoder),不引入整套安全框架;密码加密与 CAS 校验共用 -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Nacos 配置中心(替代 Spring Cloud Config)。Spring Cloud Alibaba 2021.0.5 适配 Spring Boot 2.7 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!-- Boot 2.4+ 默认不再处理 bootstrap.yml,需显式引入以继续从 Nacos 拉取配置 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
<version>3.1.8</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Spring Cache 核心抽象(@Cacheable / @CachePut / @CacheEvict 注解支持) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!-- Spring Cache 抽象层(含 CaffeineCacheManager) -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context-support</artifactId>
</dependency>
<!-- 本地内存缓存兜底:Caffeine(版本由 Boot 2.7 父 POM 统一管理) -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<!-- Jackson JSON 序列化(Redis value 序列化,解决泛型/乱码) -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- Redisson 核心:分布式锁、限流器、Lua 复杂操作(与 Spring Cache 隔离,手动配置 RedissonClient 避免自动配置冲突) -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.80</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 核心基础工具 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-core</artifactId>
<version>${cn.hutool.version}</version>
</dependency>
<!-- Swagger / OpenAPI 文档(springdoc,兼容 Boot 2.7;替代旧 Springfox) -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-ui</artifactId>
<version>1.7.0</version>
</dependency>
<!-- easypoi 导出相关 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>cn.afterturn</groupId>
<artifactId>easypoi-base</artifactId>
<version>4.1.0</version>
</dependency>
<!-- 基础平台架包:认证(CAS/会话)+ 应用间签名鉴权 -->
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-auth-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-app-auth-spring-boot-starter</artifactId>
</dependency>
<!-- CAS 客户端(浏览器 SSO 重定向 / ticket 校验 / getRemoteUser 包装)。
yh-auth 只读取 getRemoteUser,不负责跳转,由本依赖补齐标准 CAS 过滤器三件套。
版本 3.5.1 已在本地 .m2 仓库。 -->
<dependency>
<groupId>org.jasig.cas.client</groupId>
<artifactId>cas-client-core</artifactId>
<version>3.5.1</version>
</dependency>
</dependencies>
<build>
<finalName>userbase</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<!-- 关闭增量编译,避免 Windows 下 maven-status 状态文件被锁导致
"Error while storing the mojo status / 拒绝访问" -->
<useIncrementalCompilation>false</useIncrementalCompilation>
</configuration>
</plugin>
<!-- 打可执行 fat jar(Docker 镜像直接 java -jar 启动) -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>com.yunhai.userbase.UserBaseApplication</mainClass>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<!--
ci 构建档案:将构建产物重定向到 IDE(IntelliJ/Eclipse)索引目录之外,
避免 IDE 后台进程持续锁定 target/maven-status/.../createdFiles.lst 导致
"Error while storing the mojo status / 拒绝访问"。
另:输出目录需从未被 IDE 打开过,否则同一棵输出树下的 application.yml 等资源
会被 IDE 的 JPS 预加载进程持有句柄而写不进(拒绝访问)。常规本地构建无需激活此 profile。
说明:C:/ci-build 已被 IDE 锁过,现改用全新目录 C:/ub-build。
-->
<profiles>
<profile>
<id>ci</id>
<build>
<directory>C:/ub-build2/userbase</directory>
</build>
</profile>
</profiles>
</project>
@@ -0,0 +1,37 @@
package com.bestsch.openapi;
/**
* 迁移兼容 shim:原 bestsch 开放接口的"当前角色"载体。
* 仅保留 {@code Operator.of(...)} 所需的访问器,避免批量改动业务代码。
* 后续可替换为 UserBase 自有角色模型。
*/
public class UserSysRole {
private Integer userId;
private Integer schId;
private Integer roleId;
public Integer getUserId() {
return userId;
}
public void setUserId(Integer userId) {
this.userId = userId;
}
public Integer getSchId() {
return schId;
}
public void setSchId(Integer schId) {
this.schId = schId;
}
public Integer getRoleId() {
return roleId;
}
public void setRoleId(Integer roleId) {
this.roleId = roleId;
}
}
@@ -0,0 +1,22 @@
package com.bestsch.utils;
/**
* 迁移兼容 shim:原 bestsch 框架的业务异常类。
* UserBase 大量业务代码依赖此异常,迁移到基础平台期间保留原包名与常用构造签名,避免批量改动。
* 后续可统一替换为 UserBase 自有异常类型。
*/
public class DException extends RuntimeException {
public DException(String message) {
super(message);
}
public DException(String message, Throwable cause) {
super(message, cause);
}
/** 兼容历史调用方携带错误码的场景 */
public DException(String message, int code) {
super(message);
}
}
@@ -0,0 +1,22 @@
package com.yunhai.userbase;
import com.alibaba.fastjson.parser.ParserConfig;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@SpringBootApplication
@EnableTransactionManagement
@EnableAsync
@EnableScheduling
public class UserBaseApplication {
public static void main(String[] args) {
// 开启安全模式
ParserConfig.getGlobalInstance().setSafeMode(true);
SpringApplication.run(UserBaseApplication.class, args);
}
}
@@ -0,0 +1,204 @@
package com.yunhai.userbase.aspect;
import com.alibaba.fastjson.JSON;
import com.yunhai.userbase.common.annotation.OperationLog;
import com.yunhai.userbase.common.context.UserContext;
import com.yunhai.userbase.entity.SysOperationLog;
import com.yunhai.userbase.service.ISysOperationLogService;
import com.yunhai.userbase.utils.GzipUtil;
import com.yunhai.userbase.utils.IpUtils;
import com.yunhai.userbase.utils.OperationLogResolver;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
/**
* 操作日志切面。<br>
* 拦截所有 Controller 方法,按如下规则自动采集并落库:<br>
* - GET 请求:自动记录为「一般(查询)」,无需任何注解;<br>
* - 非 GET 请求:仅当方法标注 @OperationLog 时才记录(沿用原注解式行为);<br>
* - 排除日志查看接口自身(SysOperationLogController),避免递归与刷屏。<br>
* 响应结果 GZIP 压缩 + 截断后,在 finally 中异步落库,失败不影响主业务。<br>
*
* @author chenx
* @date 2026/7/28
*/
@Slf4j
@Aspect
@Component
public class OperationLogAspect {
/** 不记录日志查询/下载接口自身,避免递归与刷屏。 */
private static final String LOG_CONTROLLER = "com.yunhai.userbase.controller.api.SysOperationLogController";
/** GET 自动记录时使用的默认响应保存上限(10KB)。 */
private static final int DEFAULT_MAX_RESPONSE_SIZE = 10240;
@Autowired
private ISysOperationLogService sysOperationLogService;
@Around("execution(* com.yunhai.userbase.controller..*.*(..))")
public Object around(ProceedingJoinPoint point) throws Throwable {
// 排除日志查看接口自身
if (LOG_CONTROLLER.equals(point.getSignature().getDeclaringTypeName())) {
return point.proceed();
}
HttpServletRequest request = getRequest();
String httpMethod = request != null ? request.getMethod() : "";
boolean isGet = "GET".equalsIgnoreCase(httpMethod);
// 解析方法上的 @OperationLog 注解(可能为 null)
OperationLog operationLog = findAnnotation(point);
// 决策:GET 自动记录;非 GET 仅在有注解时记录
if (!isGet && operationLog == null) {
return point.proceed();
}
long start = System.currentTimeMillis();
SysOperationLog entity = buildEntity(point, request, httpMethod);
// GET 未标注注解时使用默认参数;标注了则优先用注解配置
boolean saveResponse = operationLog != null ? operationLog.saveResponse() : true;
int maxResponseSize = operationLog != null ? operationLog.maxResponseSize() : DEFAULT_MAX_RESPONSE_SIZE;
Object result = null;
try {
result = point.proceed();
entity.setStatusCode(200);
fillResponse(entity, result, saveResponse, maxResponseSize);
} catch (Throwable e) {
entity.setStatusCode(500);
entity.setErrorMsg(truncate(e.getMessage(), 2000));
throw e;
} finally {
entity.setCostTime(System.currentTimeMillis() - start);
sysOperationLogService.saveAsync(entity);
}
return result;
}
/**
* 从连接点解析方法上的 @OperationLog 注解。
*/
private OperationLog findAnnotation(ProceedingJoinPoint point) {
try {
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
return method.getAnnotation(OperationLog.class);
} catch (Exception e) {
return null;
}
}
/**
* 组装日志实体(基础字段 + 级别/类型判定)。
*/
private SysOperationLog buildEntity(ProceedingJoinPoint point, HttpServletRequest request, String httpMethod) {
SysOperationLog entity = new SysOperationLog();
Integer userId = UserContext.getCurrentUser();
entity.setUserId(userId != null ? userId.longValue() : 0L);
String username = request != null ? request.getRemoteUser() : null;
entity.setUsername(StringUtils.isNotBlank(username) ? username : "anonymous");
String requestUri = "";
if (request != null) {
entity.setRequestIp(IpUtils.getIpAddr(request));
requestUri = request.getRequestURI();
entity.setUserAgent(request.getHeader("User-Agent"));
entity.setRequestParams(buildRequestParams(point, request));
} else {
entity.setRequestParams(buildRequestParams(point, null));
}
entity.setRequestUri(requestUri);
entity.setRequestMethod(httpMethod);
String methodName = point.getSignature().getName();
entity.setLogLevel(OperationLogResolver.resolveLevel(httpMethod, requestUri, methodName).getCode());
entity.setOperationType(OperationLogResolver.resolveOperationType(httpMethod, requestUri, methodName));
entity.setClassMethod(point.getTarget().getClass().getName() + "." + methodName);
return entity;
}
/**
* 构建请求参数。<br>
* GET 优先使用 queryString;其余序列化方法入参,过滤掉请求/响应等不可序列化对象。<br>
*/
private String buildRequestParams(ProceedingJoinPoint point, HttpServletRequest request) {
try {
if (request != null && "GET".equalsIgnoreCase(request.getMethod())) {
String qs = request.getQueryString();
if (StringUtils.isNotBlank(qs)) {
return qs;
}
}
Object[] args = point.getArgs();
if (args == null || args.length == 0) {
return "";
}
List<Object> filtered = new ArrayList<Object>();
for (Object arg : args) {
if (arg == null) {
continue;
}
if (arg instanceof HttpServletRequest || arg instanceof HttpServletResponse
|| arg instanceof HttpSession) {
continue;
}
filtered.add(arg);
}
if (filtered.isEmpty()) {
return "";
}
return JSON.toJSONString(filtered);
} catch (Exception e) {
return "";
}
}
/**
* 写入响应结果(GZIP 压缩 + 截断)。
*/
private void fillResponse(SysOperationLog entity, Object result, boolean saveResponse, int maxResponseSize) {
if (!saveResponse || result == null) {
return;
}
try {
String resp = JSON.toJSONString(result);
if (resp.length() > maxResponseSize) {
resp = resp.substring(0, maxResponseSize) + "...[截断]";
}
entity.setResponseData(GzipUtil.compress(resp));
} catch (Exception e) {
log.warn("操作日志响应结果序列化失败, uri={}", entity.getRequestUri(), e);
}
}
private HttpServletRequest getRequest() {
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return attrs != null ? attrs.getRequest() : null;
}
private String truncate(String text, int maxLen) {
if (text == null) {
return null;
}
return text.length() > maxLen ? text.substring(0, maxLen) : text;
}
}
@@ -0,0 +1,69 @@
package com.yunhai.userbase.common;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 全局异常处理器:仅处理「参数校验」类异常,把 Spring 默认抛出的冗长消息收敛成「一句话」返回,
* 避免前端收到整段 Validation failed ... 堆栈文本。<br>
* 返回 HTTP 200 + {@code {code:4, msg:..., data:null}} 结构(而非 HTTP 400),
* 与前端 alova errorHandle 的约定一致:status!=200 会被当作网络错误,code=4 才走「参数错误」分支。<br>
* 注意:本处理器【不】处理业务异常(如 {@code DException}),业务异常交由项目框架统一处理
* (返回 code=5、msg=异常消息),此处若拦截会覆盖框架行为。
*
* @author chenx
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
/** 参数校验失败的业务码,前端按「参数错误」处理 */
private static final int CODE_PARAM_ERROR = 4;
/** @RequestBody + @Validated/@Valid 失败:提取每个字段的 defaultMessage。 */
@ExceptionHandler(MethodArgumentNotValidException.class)
public Map<String, Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex) {
return paramError(fieldErrorsMessage(ex.getBindingResult().getFieldErrors()));
}
/** 普通 Bean 参数(如 SortChildren/Move 的 @Validated 请求体对象)校验失败。 */
@ExceptionHandler(BindException.class)
public Map<String, Object> handleBind(BindException ex) {
return paramError(fieldErrorsMessage(ex.getBindingResult().getFieldErrors()));
}
/** 方法级约束(@PathVariable/@RequestParam 上的 @NotNull 等)校验失败。 */
@ExceptionHandler(ConstraintViolationException.class)
public Map<String, Object> handleConstraintViolation(ConstraintViolationException ex) {
String msg = ex.getConstraintViolations().stream()
.map(ConstraintViolation::getMessage)
.collect(Collectors.joining(";"));
return paramError(msg);
}
private String fieldErrorsMessage(java.util.List<FieldError> fieldErrors) {
if (fieldErrors == null || fieldErrors.isEmpty()) {
return "参数校验失败";
}
return fieldErrors.stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining(";"));
}
/** HTTP 200 + code/msg/data 结构体,前端根据 code 提示 msg */
private Map<String, Object> paramError(String msg) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("code", CODE_PARAM_ERROR);
body.put("msg", msg);
body.put("data", null);
return body;
}
}
@@ -0,0 +1,35 @@
package com.yunhai.userbase.common.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 操作日志注解。<br>
* 标注在 Controller 方法上,AOP 拦截后自动记录操作日志并异步落库。<br>
*
* @author chenx
* @date 2026/7/28
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface OperationLog {
/**
* 操作描述,可选。
*/
String value() default "";
/**
* 是否保存响应结果(GZIP 压缩后入库)。
*/
boolean saveResponse() default true;
/**
* 响应结果序列化后的最大字符数,超过则截断。默认 10KB。
*/
int maxResponseSize() default 10240;
}
@@ -0,0 +1,18 @@
package com.yunhai.userbase.common.context;
public class UserContext {
private static final ThreadLocal<Integer> CURRENT_USER = new ThreadLocal<>();
public static void setCurrentUser(Integer userId) {
CURRENT_USER.set(userId);
}
public static Integer getCurrentUser() {
return CURRENT_USER.get();
}
public static void clear() {
CURRENT_USER.remove();
}
}
@@ -0,0 +1,18 @@
package com.yunhai.userbase.common.converter;
import com.yunhai.userbase.enums.ClientEnum;
import javax.persistence.AttributeConverter;
/**
* ClientEnum 专用 JPA 转换器(int &lt;-&gt; 枚举)。<br>
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。
*
* @author chenx
*/
public class ClientEnumConverter extends CommonIntEnumConverter<ClientEnum> {
public ClientEnumConverter() {
super(ClientEnum.class);
}
}
@@ -0,0 +1,47 @@
package com.yunhai.userbase.common.converter;
import com.yunhai.userbase.enums.IEnum;
import javax.persistence.AttributeConverter;
/**
* 通用「int &lt;-&gt; 实现 IEnum&lt;Integer&gt; 的枚举」JPA 转换器抽象基类。<br>
* <p>
* <b>绑定机制(通用做法):</b>为每个枚举写一个极薄的子类,在构造器里传入枚举 class 即可完成绑定,
* 然后实体字段用 {@code @Convert(converter = XxxEnumConverter.class)} 引用该子类。例如:
* <pre>
* public class GroupTypeEnumConverter extends CommonIntEnumConverter&lt;GroupTypeEnum&gt; {
* public GroupTypeEnumConverter() { super(GroupTypeEnum.class); }
* }
* </pre>
* 这样同一套逻辑可服务任意枚举;又因为每个枚举拥有<b>独立的转换器实例</b>,
* 彼此之间不会串类型(标准 JPA 的 @Convert 是按 converter 类创建共享单例的,
* 若让多个不同枚举共用同一个泛型实例,则无法区分各自的枚举 class,故采用「每枚举一子类」的绑定方式)。
* </p>
*
* @param <E> 必须实现 IEnum&lt;Integer&gt; 的枚举类型
* @author chenx
*/
public abstract class CommonIntEnumConverter<E extends Enum<E> & IEnum<Integer>>
implements AttributeConverter<E, Integer> {
private final Class<E> enumCls;
protected CommonIntEnumConverter(Class<E> enumCls) {
this.enumCls = enumCls;
}
@Override
public Integer convertToDatabaseColumn(E enumVal) {
return enumVal == null ? null : enumVal.getKey();
}
@Override
public E convertToEntityAttribute(Integer dbVal) {
if (dbVal == null) {
return null;
}
// 读库安全:无匹配的枚举值返回 null(不抛异常,避免脏数据导致整棵树加载失败)
return IEnum.getByKey(enumCls, dbVal);
}
}
@@ -0,0 +1,18 @@
package com.yunhai.userbase.common.converter;
import com.yunhai.userbase.enums.GenderEnum;
import javax.persistence.AttributeConverter;
/**
* GenderEnum 专用 JPA 转换器(int &lt;-&gt; 枚举)。<br>
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。
*
* @author chenx
*/
public class GenderEnumConverter extends CommonIntEnumConverter<GenderEnum> {
public GenderEnumConverter() {
super(GenderEnum.class);
}
}
@@ -0,0 +1,18 @@
package com.yunhai.userbase.common.converter;
import com.yunhai.userbase.enums.GroupTypeEnum;
import javax.persistence.AttributeConverter;
/**
* GroupTypeEnum 专用 JPA 转换器(int &lt;-&gt; 枚举)。<br>
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可,无需重复转换逻辑。
*
* @author chenx
*/
public class GroupTypeEnumConverter extends CommonIntEnumConverter<GroupTypeEnum> {
public GroupTypeEnumConverter() {
super(GroupTypeEnum.class);
}
}
@@ -0,0 +1,18 @@
package com.yunhai.userbase.common.converter;
import com.yunhai.userbase.enums.IntegEnum;
import javax.persistence.AttributeConverter;
/**
* IntegEnum 专用 JPA 转换器(int &lt;-&gt; 枚举)。<br>
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。
*
* @author chenx
*/
public class IntegEnumConverter extends CommonIntEnumConverter<IntegEnum> {
public IntegEnumConverter() {
super(IntegEnum.class);
}
}
@@ -0,0 +1,50 @@
package com.yunhai.userbase.common.converter;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import javax.persistence.AttributeConverter;
import javax.persistence.Converter;
import java.util.Collections;
import java.util.List;
/**
* @author chenx
* @date 2026/7/14
* @description 将 List<Integer> 与 MySQL JSON 字符串互相转换。
* 使用 Jackson 的 {@link TypeReference} 明确反序列化为 List<Integer>,
* 避免 Hibernate 内置 JSON 类型把数组元素类型读错的问题。
*/
@Converter
public class ListIntegerJsonConverter implements AttributeConverter<List<Integer>, String> {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final TypeReference<List<Integer>> LIST_INTEGER_TYPE = new TypeReference<List<Integer>>() {
};
@Override
public String convertToDatabaseColumn(List<Integer> attribute) {
if (attribute == null) {
return null;
}
try {
return MAPPER.writeValueAsString(attribute);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("角色列表序列化 JSON 失败: " + attribute, e);
}
}
@Override
public List<Integer> convertToEntityAttribute(String dbData) {
if (dbData == null || dbData.trim().isEmpty()) {
return null;
}
try {
List<Integer> list = MAPPER.readValue(dbData, LIST_INTEGER_TYPE);
return list == null ? Collections.emptyList() : list;
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("角色 JSON 解析失败: " + dbData, e);
}
}
}
@@ -0,0 +1,18 @@
package com.yunhai.userbase.common.converter;
import com.yunhai.userbase.enums.StatusEnum;
import javax.persistence.AttributeConverter;
/**
* StatusEnum 专用 JPA 转换器(int &lt;-&gt; 枚举)。<br>
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。
*
* @author chenx
*/
public class StatusEnumConverter extends CommonIntEnumConverter<StatusEnum> {
public StatusEnumConverter() {
super(StatusEnum.class);
}
}
@@ -0,0 +1,20 @@
package com.yunhai.userbase.common.converter;
import com.yunhai.userbase.enums.SysRoleEnum;
import javax.persistence.AttributeConverter;
/**
* SysRoleEnum 专用 JPA 转换器(int &lt;-&gt; 枚举)。<br>
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。<br>
* 注意:sys_user.roles 是「角色 key 的 JSON 列表」,使用 ListIntegerJsonConverter,
* 若某处需要「单角色」字段,再引用本转换器即可。
*
* @author chenx
*/
public class SysRoleEnumConverter extends CommonIntEnumConverter<SysRoleEnum> {
public SysRoleEnumConverter() {
super(SysRoleEnum.class);
}
}
@@ -0,0 +1,18 @@
package com.yunhai.userbase.common.converter;
import com.yunhai.userbase.enums.UserTypeEnum;
import javax.persistence.AttributeConverter;
/**
* UserTypeEnum 专用 JPA 转换器(int &lt;-&gt; 枚举)。<br>
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。
*
* @author chenx
*/
public class UserTypeEnumConverter extends CommonIntEnumConverter<UserTypeEnum> {
public UserTypeEnumConverter() {
super(UserTypeEnum.class);
}
}
@@ -0,0 +1,48 @@
package com.yunhai.userbase.common.interceptor;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.common.context.UserContext;
import com.yunhai.userbase.service.UserCenterService;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 把当前登录用户写入 {@link UserContext}(供 JPA 审计监听器 @PrePersist/@PreUpdate 与操作日志切面使用)。<br><br>
* 登录名取自 CAS 客户端写入的 {@code request.getRemoteUser()}(cas-client-core 过滤器已建立会话);
* 再经本地 {@link UserCenterService} 组装 {@link BoaUser} 取其 id 写入 ThreadLocal。
* 原 bsch 的 {@code BschBaseOpenApi.getUserInfoWithDept} 与 {@code LocalMemCache} 已移除,
* 统一改用基础平台用户模型与本地服务中心。
*/
@Component
public class BoaUserContextInterceptor implements HandlerInterceptor {
@Autowired
private UserCenterService userCenterService;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
String loginName = request.getRemoteUser();
if (StringUtils.isBlank(loginName)) {
return true;
}
BoaUser user = userCenterService.getUserInfoWithDeptByAccount(loginName);
if (user != null && user.getId() != null && user.getId() != 0) {
UserContext.setCurrentUser(user.getId());
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) {
UserContext.clear();
}
}
@@ -0,0 +1,33 @@
package com.yunhai.userbase.common.listener;
import com.bestsch.utils.DException;
import com.yunhai.userbase.common.context.UserContext;
import com.yunhai.userbase.entity.BaseEntity;
import javax.persistence.PrePersist;
import javax.persistence.PreUpdate;
public class UserAuditListener {
@PrePersist
public void prePersist(BaseEntity entity) {
System.out.println("=== prePersist 触发 ===");
Integer userId = UserContext.getCurrentUser();
System.out.println("ThreadLocal userId: " + userId);
if (userId != null) {
entity.setCreateUser(userId);
entity.setUpdateUser(userId);
}
}
@PreUpdate
public void preUpdate(BaseEntity entity) {
System.out.println("=== preUpdate 触发 ===");
Integer userId = UserContext.getCurrentUser();
System.out.println("ThreadLocal userId: " + userId);
if (userId != null) {
entity.setUpdateUser(userId);
}
}
}
@@ -0,0 +1,11 @@
package com.yunhai.userbase.common.validation;
/**
* @author chenx
* @date 2026/7/15 22:11
* @description
*/
public interface ValidationGroups {
interface Create {}
interface Update {}
}
@@ -0,0 +1,21 @@
package com.yunhai.userbase.config;
import com.yunhai.platform.appauth.AppAuthProperties;
import com.yunhai.platform.appauth.AppSecretProvider;
import com.yunhai.platform.appauth.PropertyAppSecretProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 用户中心验签密钥源:以配置式 {@link PropertyAppSecretProvider}({@code yh.app-auth.trustedApps})为准,
* 显式覆盖 yh-app-auth 默认的 {@code @ConditionalOnMissingBean} 实现,确保开放接口签名校验确定性生效,
* 不依赖 Redis 中 {@code sign:app:*} 的预填充时机。
*/
@Configuration
public class AppSecretConfig {
@Bean
public AppSecretProvider appSecretProvider(AppAuthProperties properties) {
return new PropertyAppSecretProvider(properties.getTrustedApps());
}
}
@@ -0,0 +1,183 @@
package com.yunhai.userbase.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.yunhai.userbase.utils.ConstantUtil;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
import java.util.Collection;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@EnableCaching
@Configuration
public class CacheConfig {
/**
* 一级缓存:Redis 分布式缓存管理器。
*/
@Bean
public RedisCacheManager redisCacheManager(RedisConnectionFactory factory) {
Jackson2JsonRedisSerializer<Object> jacksonSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper mapper = new ObjectMapper();
// 允许任意字段可见,保证反序列化不依赖 getter/setter
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
// 记录完整类型信息,保证泛型/多态对象反序列化正常(解决 JDK 序列化乱码、泛型丢失)
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
jacksonSerializer.setObjectMapper(mapper);
RedisCacheConfiguration cacheConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(ConstantUtil.CACHE_TTL) // 全局 5 分钟过期
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer())) // key 用 String
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(jacksonSerializer)) // value 用 Jackson
.disableCachingNullValues(); // 禁止缓存 null(防穿透)
return RedisCacheManager.builder(factory)
.cacheDefaults(cacheConfig)
.build();
}
/**
* 二级缓存:Caffeine 本地内存缓存管理器(Redis 降级兜底)。
*/
@Bean
public CaffeineCacheManager caffeineCacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager();
manager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(5, TimeUnit.MINUTES) // 与 Redis 过期保持一致
.maximumSize(10000) // 限制本地容量,防止内存溢出
.recordStats());
manager.setAllowNullValues(false); // 与 Redis 一致:禁止缓存 null
return manager;
}
/**
* 默认 RedisTemplate(Jackson 序列化),供项目已有工具类(如 RedisUtil)及手动缓存管理使用。<br>
* 覆盖 Boot 自动配置的 JDK 序列化默认实现,与 Spring Cache 注解缓存读写互不干扰。<br>
* 当 @Cacheable 无法满足需求(如 Hash/List/Set 结构、按字段读写)时,注入本模板或 RedisUtil 手动管理即可。
*/
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setValueSerializer(jacksonSerializer());
template.setHashValueSerializer(jacksonSerializer());
template.afterPropertiesSet();
return template;
}
/**
* 复合(故障转移)缓存管理器:作为全局默认 CacheManager 供 @Cacheable 使用。
* 优先 Redis;Redis 不可达 / 未配置时自动降级 Caffeine;后台定时探活,恢复后自动切回。
*/
@Primary
@Bean
public CacheManager cacheManager(RedisCacheManager redisCacheManager,
CaffeineCacheManager caffeineCacheManager,
RedisConnectionFactory redisConnectionFactory) {
return new FallbackCacheManager(redisCacheManager, caffeineCacheManager, redisConnectionFactory);
}
/**
* 轻量故障转移缓存管理器:Redis 健康时优先 Redis,故障时降级 Caffeine,无业务报错。
*/
static class FallbackCacheManager implements CacheManager {
private final RedisCacheManager redisManager;
private final CaffeineCacheManager caffeineManager;
private final RedisConnectionFactory connectionFactory;
private final ScheduledExecutorService healthScheduler =
Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "redis-cache-health");
t.setDaemon(true);
return t;
});
private volatile boolean redisUp = true;
FallbackCacheManager(RedisCacheManager redisManager,
CaffeineCacheManager caffeineManager,
RedisConnectionFactory connectionFactory) {
this.redisManager = redisManager;
this.caffeineManager = caffeineManager;
this.connectionFactory = connectionFactory;
if (connectionFactory != null) {
// 启动即探活(delay=0),之后每 10s 探活一次(Redis 恢复后自动切回)
healthScheduler.scheduleAtFixedRate(this::probe, 0, 10, TimeUnit.SECONDS);
} else {
redisUp = false;
}
}
private void probe() {
if (connectionFactory == null) {
redisUp = false;
return;
}
RedisConnection connection = null;
try {
connection = connectionFactory.getConnection();
redisUp = connection.ping() != null;
} catch (Exception e) {
redisUp = false;
} finally {
if (connection != null) {
try {
connection.close();
} catch (Exception ignored) {
// 关闭失败忽略,下次探活重试
}
}
}
}
@Override
public Cache getCache(String name) {
// AI-FIX: 不在请求线程做同步探活。redisUp 仅由后台定时探活(每 10s)维护。
// 否则 Redis 不可达时,每个 @Cacheable/@CacheEvict 都会同步阻塞一个连接超时(约 2s),
// resetAppSecret 等多次访问缓存的接口会累计到 4s+。Caffeine 降级必须零阻塞。
return redisUp ? redisManager.getCache(name) : caffeineManager.getCache(name);
}
@Override
public Collection<String> getCacheNames() {
return redisUp ? redisManager.getCacheNames() : caffeineManager.getCacheNames();
}
}
/** 统一构建带类型信息的 Jackson 序列化器(解决泛型/乱码),供 RedisTemplate 复用。 */
private Jackson2JsonRedisSerializer<Object> jacksonSerializer() {
Jackson2JsonRedisSerializer<Object> jackson = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
jackson.setObjectMapper(mapper);
return jackson;
}
}
@@ -0,0 +1,89 @@
package com.yunhai.userbase.config;
import com.yunhai.platform.auth.spring.YhSsoProperties;
import org.jasig.cas.client.authentication.AuthenticationFilter;
import org.jasig.cas.client.session.SingleSignOutFilter;
import org.jasig.cas.client.util.HttpServletRequestWrapperFilter;
import org.jasig.cas.client.validation.Cas20ProxyReceivingTicketValidationFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 浏览器端 CAS 单点登录过滤器(仅作用于 {@code /api/*})。<br><br>
* 基础平台 yh-auth 只负责「读 CAS 登录态 + 补全用户明细」,并不提供浏览器重定向;
* 本配置补齐标准 CAS 客户端三件套:<br>
* 1) {@link SingleSignOutFilter} 单点登出;<br>
* 2) {@link AuthenticationFilter} 未登录时重定向到 CAS 登录页;<br>
* 3) {@link Cas20ProxyReceivingTicketValidationFilter} 校验 service ticket 并建立会话;<br>
* 4) {@link HttpServletRequestWrapperFilter} 让 {@code request.getRemoteUser()} 可用(yh-auth 的
* {@code CasSessionAuthStrategy} 依赖它)。<br><br>
* 顺序须早于 yh-app-auth 的 {@code AppAuthFilter}(HIGHEST_PRECEDENCE+10),
* 且只对 {@code /api/*} 生效;{@code /oapi}、{@code /open/api} 走签名验签,不在此列。
*/
@Configuration
@ConditionalOnProperty(prefix = "yh.sso", name = "server-url-prefix")
public class CasSsoConfiguration {
@Autowired
private YhSsoProperties ssoProperties;
/** 从 client-host-url 抽取 scheme://host[:port](去掉上下文路径),供给 CAS 构造 service 参数。 */
private String serverName() {
String client = ssoProperties.getClientHostUrl();
if (client == null || client.trim().isEmpty()) {
return null;
}
Matcher m = Pattern.compile("^(https?://[^/]+)").matcher(client.trim());
return m.find() ? m.group(1) : client.trim();
}
@Bean
public FilterRegistrationBean<SingleSignOutFilter> casSingleSignOutFilter() {
FilterRegistrationBean<SingleSignOutFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new SingleSignOutFilter());
bean.addUrlPatterns("/api/*");
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 5);
return bean;
}
@Bean
public FilterRegistrationBean<AuthenticationFilter> casAuthenticationFilter() {
FilterRegistrationBean<AuthenticationFilter> bean = new FilterRegistrationBean<>();
AuthenticationFilter filter = new AuthenticationFilter();
filter.setCasServerLoginUrl(ssoProperties.getServerUrlPrefix() + "/login");
filter.setServerName(serverName());
bean.setFilter(filter);
bean.addUrlPatterns("/api/*");
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 6);
return bean;
}
@Bean
public FilterRegistrationBean<Cas20ProxyReceivingTicketValidationFilter> casValidationFilter() {
FilterRegistrationBean<Cas20ProxyReceivingTicketValidationFilter> bean = new FilterRegistrationBean<>();
Cas20ProxyReceivingTicketValidationFilter filter = new Cas20ProxyReceivingTicketValidationFilter();
filter.setServerName(serverName());
bean.setFilter(filter);
// cas-client 3.5.1 的校验过滤器无 setCasServerUrlPrefix 设值器,需经 init-parameter 注入
bean.addInitParameter("casServerUrlPrefix", ssoProperties.getServerUrlPrefix());
bean.addUrlPatterns("/api/*");
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 7);
return bean;
}
@Bean
public FilterRegistrationBean<HttpServletRequestWrapperFilter> casHttpServletRequestWrapperFilter() {
FilterRegistrationBean<HttpServletRequestWrapperFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new HttpServletRequestWrapperFilter());
bean.addUrlPatterns("/api/*");
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 8);
return bean;
}
}
@@ -0,0 +1,30 @@
package com.yunhai.userbase.config;
import java.util.ResourceBundle;
/**
* @author chenx
* @version 1.0
* @date 2025/4/27 14:31
*/
public enum Config
{
Inst;
public static final String API_PATH = "/{platform}/"; // manage / api / mobiapi
public static final String OPEN_API_PATH = "/oapi/";
public String getString(String key)
{
ResourceBundle resource = ResourceBundle.getBundle("config");
return resource.getString(key);
}
public int getInt(String key)
{
String value = getString(key);
return Integer.parseInt(value);
}
}
@@ -0,0 +1,32 @@
package com.yunhai.userbase.config;
import com.yunhai.userbase.common.interceptor.BoaUserContextInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class DWebMvcConfig implements WebMvcConfigurer
{
@Autowired
private BoaUserContextInterceptor boaUserContextInterceptor;
@Override
public void addCorsMappings(CorsRegistry registry)
{
registry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("POST", "GET", "PUT", "OPTIONS", "DELETE")
.allowedHeaders("*")
.maxAge(3600).allowCredentials(true);
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(boaUserContextInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/login", "/logout", "/static/**", "/error", "/upload/**");
}
}
@@ -0,0 +1,32 @@
package com.yunhai.userbase.config;
import com.yunhai.userbase.utils.FileUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
/**
* 文件存储配置注入。<br>
* 在 Spring 启动完成后,将上传根目录与 Web 访问前缀注入 FileUtil 静态字段,<br>
* 供上传工具类拼接完整可访问 URL 使用。<br>
*
* @author chenx
*/
@Component
public class FileStorageConfig {
@Value("${FILE_PATH}")
private String filePath;
@Value("${FILE_WEB_PATH}")
private String fileWebPath;
/**
* 启动时注入存储配置到 FileUtil。<br>
*/
@PostConstruct
public void init() {
FileUtil.init(filePath, fileWebPath);
}
}
@@ -0,0 +1,29 @@
package com.yunhai.userbase.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class MyWebAppConfigurer implements WebMvcConfigurer {
@Value("${FILE_PATH}")
public String FILE_PATH;
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
String IMG_FILE_WEB_PATH = "/upload/image/";
String File_FILE_WEB_PATH = "/upload/file/";
String ICON_FILE_WEB_PATH = "/upload/icon/";
String WORD_FILE_WEB_PATH = "/upload/word/";
String HTML_FILE_WEB_PATH = "/upload/html/";
registry.addResourceHandler(IMG_FILE_WEB_PATH + "**").addResourceLocations("file:" + FILE_PATH + "/image/");
registry.addResourceHandler(File_FILE_WEB_PATH + "**").addResourceLocations("file:" + FILE_PATH + "/file/");
registry.addResourceHandler(ICON_FILE_WEB_PATH+"**").addResourceLocations("file:"+FILE_PATH+"/icon/");
registry.addResourceHandler(WORD_FILE_WEB_PATH+"**").addResourceLocations("file:"+FILE_PATH+"/word/");
registry.addResourceHandler(HTML_FILE_WEB_PATH+"**").addResourceLocations("file:"+FILE_PATH+"/html/");
}
}
@@ -0,0 +1,51 @@
package com.yunhai.userbase.config;
import com.yunhai.platform.appauth.AppAuthFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 强制开放接口必须带应用签名。<br><br>
* yh-app-auth 的 {@link AppAuthFilter} 设计为「无签名头则放行」(以兼容浏览器 CAS 流),
* 但 UserBase 作为用户中心的开放接口({/open/api/**})与内部密钥兜底端点({/oapi/internal/**})
* 绝不允许匿名访问。本过滤器在 {@code AppAuthFilter} 之后运行,校验其写入的请求属性:
* 已验签(属性存在)则放行,否则返回 401。<br><br>
* 顺序须晚于 {@code AppAuthFilter}(HIGHEST_PRECEDENCE+10),故置为 +11。
*/
@Configuration
public class OpenApiAuthEnforceConfiguration {
@Bean
public FilterRegistrationBean<RequireAppSignFilter> requireAppSignFilter() {
FilterRegistrationBean<RequireAppSignFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new RequireAppSignFilter());
bean.addUrlPatterns("/open/api/*", "/oapi/internal/*");
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 11);
return bean;
}
public static class RequireAppSignFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
if (req.getAttribute(AppAuthFilter.APP_AUTH_ATTR) == null) {
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "missing app signature");
return;
}
chain.doFilter(request, response);
}
}
}
@@ -0,0 +1,29 @@
package com.yunhai.userbase.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
/**
* 密码编码器配置:统一使用 BCrypt。<br>
* 用户中心存库与后续 CAS 登录校验共用同一编码器,保证加密/校验规则一致。<br>
*
* @author chenx
* @date 2026/7/23
* @description BCrypt 密码编码器 Bean
*/
@Configuration
public class PasswordEncoderConfig {
/**
* BCrypt 密码编码器。<br>
* strength 使用默认 10,兼顾安全与性能;CAS 侧用同一 encoder 的 matches 校验即可。<br>
*
* @return PasswordEncoder
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
@@ -0,0 +1,37 @@
package com.yunhai.userbase.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springdoc.core.customizers.GlobalOperationCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.HandlerMethod;
/**
* 接口文档(springdoc-openapi,替代旧 Springfox)。<br><br>
* 提供统一文档元信息;并通过全局定制器隐藏由切面注入的 {@code BoaUser authUserWithDept} 参数,
* 避免其作为请求体出现在文档中(该参数不参与 HTTP 入参,由 {@code BoaUserArgumentResolver} 解析)。
*/
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI userBaseOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("用户中心接口文档")
.description("UserBase 用户中心 OpenAPI 文档(springdoc-openapi)")
.version("0.0.1"));
}
/** 隐藏切面注入的登录用户参数,保持文档整洁。 */
@Bean
public GlobalOperationCustomizer hideAuthUserParam() {
return (operation, handlerMethod) -> {
if (operation.getParameters() != null) {
operation.getParameters().removeIf(p -> "authUserWithDept".equals(p.getName()));
}
return operation;
};
}
}
@@ -0,0 +1,37 @@
package com.yunhai.userbase.config;
import com.yunhai.platform.auth.UserDetailsProvider;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.service.UserCenterService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 平台 {@link UserDetailsProvider} 的 UserBase 本地实现:CAS 会话流解析出登录名/手机号后,
* 由本类查 UserBase 自身库,组装成基础平台统一的 {@link BoaUser}。<br><br>
* 以 {@code @Component} 注册为 Bean,覆盖 yh-auth 默认的 NoOp 实现(@ConditionalOnMissingBean)。
* 注意:UserBase 自身不引入 yh-openapi-spring-boot-starter,故 openApiUserDetailsProvider 不会被创建,
* 不会形成「用户中心回调自身」的环。
*/
@Component
public class UserBaseUserDetailsProvider implements UserDetailsProvider {
@Autowired
private UserCenterService userCenterService;
@Override
public BoaUser loadByLoginName(String loginName) {
if (loginName == null) {
return null;
}
return userCenterService.getUserInfoWithDeptByAccount(loginName.trim());
}
@Override
public BoaUser loadByPhone(String phone) {
if (phone == null) {
return null;
}
return userCenterService.getUserInfoWithDeptByPhone(phone.trim());
}
}
@@ -0,0 +1,135 @@
package com.yunhai.userbase.controller.api;
import com.yunhai.userbase.common.annotation.OperationLog;
import com.yunhai.userbase.common.validation.ValidationGroups;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.model.BasePageQuery;
import com.yunhai.userbase.model.BasePageResult;
import com.yunhai.userbase.model.approle.AppRoleAuthzAppVo;
import com.yunhai.userbase.model.approle.AppRoleModuleAssignRequest;
import com.yunhai.userbase.model.approle.AppRoleUserBindRequest;
import com.yunhai.userbase.model.approle.AppRoleUserVo;
import com.yunhai.userbase.model.approle.SysAppRoleQuery;
import com.yunhai.userbase.model.approle.SysAppRoleRequest;
import com.yunhai.userbase.model.approle.SysAppRoleVo;
import com.yunhai.userbase.model.approle.UserAppRoleBindRequest;
import com.yunhai.userbase.service.IAppRoleService;
import com.yunhai.userbase.utils.PageUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.SortDefault;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* @author chenx
* @date 2026/7/27
* @description 应用业务角色管理(角色 CRUD + 角色模块授权 + 用户角色绑定)。<br>
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/approle/")
public class AppRoleController {
@Autowired
private IAppRoleService appRoleService;
/**
* 角色分页列表:name 模糊,appId/allAppFlag/disabled 精确;普通角色回填所属应用名称。
*/
@GetMapping("/RoleList")
public BasePageResult<SysAppRoleVo> roleList(SysAppRoleQuery query, BasePageQuery pageQuery,
@SortDefault(sort = "id", direction = Sort.Direction.DESC) Sort sort) {
return PageUtils.toResult(appRoleService.list(query, PageUtils.toPageable(pageQuery, sort)));
}
/**
* 新增角色:普通角色(allAppFlag=false,appId 必填,同应用下名称唯一);<br>
* 全应用角色(allAppFlag=true,appId 为空,全应用角色之间名称唯一)。
*/
@OperationLog("新增角色")
@PostMapping("RoleAdd")
public void roleAdd(@RequestBody @Validated(ValidationGroups.Create.class) SysAppRoleRequest request) {
appRoleService.add(request);
}
/**
* 编辑角色:仅可改 name/remark/disabled;allAppFlag 与 appId 不允许修改。
*/
@OperationLog("编辑角色")
@PostMapping("RoleEdit/{id}")
public void roleEdit(@PathVariable Integer id,
@RequestBody @Validated(ValidationGroups.Update.class) SysAppRoleRequest request) {
appRoleService.edit(id, request);
}
/**
* 删除角色:级联清理该角色的模块授权与用户绑定记录。
*/
@OperationLog("删除角色")
@PostMapping("RoleDelete/{id}")
public void roleDelete(@PathVariable Integer id) {
appRoleService.delete(id);
}
/**
* 角色模块授权回显:返回该角色可授权的应用及模块树(已授权 checked=true)。<br>
* 普通角色仅本应用;全应用角色返回全部应用(可跨应用授权)。
*/
@GetMapping("/RoleModuleTree")
public List<AppRoleAuthzAppVo> roleModuleTree(@RequestParam Integer roleId) {
return appRoleService.findRoleModuleTree(roleId);
}
/**
* 角色模块授权保存:全量覆盖式更新该角色的模块授权记录。
*/
@OperationLog("保存角色模块授权")
@PostMapping("SaveRoleModules")
public void saveRoleModules(@RequestBody @Validated AppRoleModuleAssignRequest request) {
appRoleService.saveRoleModules(request);
}
/**
* 查询角色已绑定的用户列表。
*/
@GetMapping("/RoleUsers")
public List<AppRoleUserVo> roleUsers(@RequestParam Integer roleId) {
return appRoleService.findRoleUsers(roleId);
}
/**
* 角色绑定用户保存(角色维度,全量覆盖)。
*/
@OperationLog("保存角色用户绑定")
@PostMapping("SaveRoleUsers")
public void saveRoleUsers(@RequestBody @Validated AppRoleUserBindRequest request) {
appRoleService.saveRoleUsers(request);
}
/**
* 用户绑定角色保存(用户维度,全量覆盖)。
*/
@OperationLog("保存用户角色绑定")
@PostMapping("SaveUserRoles")
public void saveUserRoles(@RequestBody @Validated UserAppRoleBindRequest request) {
appRoleService.saveUserRoles(request);
}
/**
* 移除单个用户与角色的绑定关系(精确删除单条记录,非全量重提)。<br>
* 前端「用户管理 - 删除」调用此接口。
*/
@OperationLog("移除角色用户绑定")
@PostMapping("RemoveRoleUser")
public void removeRoleUser(@RequestParam Integer roleId, @RequestParam Integer userId) {
appRoleService.removeRoleUser(roleId, userId);
}
}
@@ -0,0 +1,184 @@
package com.yunhai.userbase.controller.api;
import com.bestsch.utils.DException;
import com.yunhai.userbase.common.annotation.OperationLog;
import com.yunhai.userbase.common.context.UserContext;
import com.yunhai.userbase.common.validation.ValidationGroups;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.enums.BookType;
import com.yunhai.userbase.model.BaseDict;
import com.yunhai.userbase.model.BasePageQuery;
import com.yunhai.userbase.model.BasePageResult;
import com.yunhai.userbase.model.Operator;
import com.yunhai.userbase.model.appauthz.AppAuthzCategoryVo;
import com.yunhai.userbase.model.appcategory.AppCategoryQuery;
import com.yunhai.userbase.model.appcategory.AppCategoryRequest;
import com.yunhai.userbase.model.appcategory.AppCategoryVo;
import com.yunhai.userbase.model.application.ApplicationInfoQuery;
import com.yunhai.userbase.model.application.ApplicationInfoRequest;
import com.yunhai.userbase.model.application.ApplicationInfoSortVo;
import com.yunhai.userbase.model.application.ApplicationInfoVo;
import com.yunhai.userbase.model.appmodule.AppModuleQuery;
import com.yunhai.userbase.model.appmodule.AppModuleRequest;
import com.yunhai.userbase.model.appmodule.AppModuleVo;
import com.yunhai.userbase.service.IAppCategoryService;
import com.yunhai.userbase.service.IApplicationInfoService;
import com.yunhai.userbase.service.IAppModuleService;
import com.yunhai.userbase.service.SignKeyCacheService;
import com.yunhai.userbase.utils.DicUtil;
import com.yunhai.userbase.utils.PageUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.tomcat.util.http.fileupload.util.Streams;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.SortDefault;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import java.io.BufferedOutputStream;
import java.io.InputStream;
import java.net.URLEncoder;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping(value = Config.API_PATH + "/app/")
public class AppSettingsController {
@Autowired
private IAppCategoryService appCategoryService;
@Autowired
private IApplicationInfoService applicationInfoService;
@Autowired
private IAppModuleService appModuleService;
@Autowired
private SignKeyCacheService signKeyCacheService;
@OperationLog("新增应用类型")
@PostMapping("AddType")
public void addType(@RequestBody @Validated(ValidationGroups.Create.class) AppCategoryRequest request) {
appCategoryService.add(request);
}
@OperationLog("编辑应用类型")
@PostMapping("EditType/{id}")
public void editType(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) AppCategoryRequest request) {
appCategoryService.edit(id, request);
}
@OperationLog("删除应用类型")
@PostMapping("DeleteType/{id}")
public void deleteType(@PathVariable Integer id) {
appCategoryService.delete(id);
}
@GetMapping("/AppTypeList")
public List<AppCategoryVo> appTypeList(AppCategoryQuery query) {
return appCategoryService.list(query);
}
@OperationLog("应用类型排序")
@PostMapping("/AppTypeSort")
public void appTypeSort(@RequestBody List<Integer> ids) {
appCategoryService.sort(ids);
}
/**
* 应用授权用:获取所有应用类型及其类型下的应用(每个应用含 id/name/icon)。<br>
* 类型按 sortNo 升序,类型内应用按 sortNo、id 升序;无应用的类型 app 为空数组。
*/
@GetMapping("/AppAuthzList")
public List<AppAuthzCategoryVo> appAuthzList() {
return appCategoryService.listWithApps();
}
@OperationLog("新增应用")
@PostMapping("AddApp")
public void addApp(@RequestBody @Validated(ValidationGroups.Create.class) ApplicationInfoRequest request) {
applicationInfoService.add(request);
}
@OperationLog("编辑应用")
@PostMapping("EditApp/{id}")
public void editApp(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) ApplicationInfoRequest request) {
applicationInfoService.edit(id, request);
}
@OperationLog("删除应用")
@PostMapping("DeleteApp/{id}")
public void deleteApp(@PathVariable Integer id) {
applicationInfoService.delete(id);
}
@OperationLog("重置应用密钥")
@PostMapping("ResetAppSecret/{id}")
public String resetAppSecret(@PathVariable Integer id) {
applicationInfoService.resetAppSecret(id);
ApplicationInfo info= applicationInfoService.getById(id);
return info.getAppSecret();
}
@OperationLog("全量刷新应用签名密钥到Redis")
@PostMapping("RefreshSignKeys")
public Map<String, Object> refreshSignKeys() {
int count = signKeyCacheService.refreshAll();
Map<String, Object> result = new LinkedHashMap<>();
result.put("refreshed", count);
return result;
}
@GetMapping("/AppList")
public BasePageResult<ApplicationInfoVo> appList(ApplicationInfoQuery query, BasePageQuery pageQuery,
@SortDefault(sort = "sortNo", direction = Sort.Direction.ASC) Sort sort) {
return PageUtils.toResult(applicationInfoService.list(query, PageUtils.toPageable(pageQuery, sort)));
}
@OperationLog("应用排序")
@PostMapping("/AppSort")
public void appSort(@RequestBody List<Integer> ids) {
applicationInfoService.sort(ids);
}
@GetMapping("/AppSortList")
public List<ApplicationInfoSortVo> appSortList(@RequestParam Integer categoryId) {
return applicationInfoService.findListByCategoryId(categoryId);
}
@OperationLog("新增模块")
@PostMapping("AddModule")
public void addModule(@RequestBody @Validated(ValidationGroups.Create.class) AppModuleRequest request) {
appModuleService.add(request);
}
@OperationLog("编辑模块")
@PostMapping("EditModule/{id}")
public void editModule(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) AppModuleRequest request) {
appModuleService.edit(id, request);
}
@OperationLog("删除模块")
@PostMapping("DeleteModule/{id}")
public void deleteModule(@PathVariable Integer id) {
appModuleService.delete(id);
}
@GetMapping("/ModuleList")
public List<AppModuleVo> moduleList(AppModuleQuery query) {
return appModuleService.list(query);
}
}
@@ -0,0 +1,140 @@
package com.yunhai.userbase.controller.api;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.enums.*;
import com.yunhai.userbase.utils.*;
import com.bestsch.utils.DException;
import org.springframework.web.multipart.MultipartFile;
import lombok.var;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.view.RedirectView;
import java.util.*;
import java.util.function.Supplier;
@RestController
@RequestMapping(value = Config.API_PATH +"/base/")
public class BaseController {
@Autowired
private RedisUtil redisUtil;
/**
* 一般hashMap初始化大小
*/
public static final int initialCapacity = 6;
/**
* 通用上传允许的子目录(与 MyWebAppConfigurer.addResourceHandlers 的资源映射一一对应)。<br>
* 注:html 因 FileUtil 的危险后缀黑名单被禁止通过此接口上传,故未列入,避免存储可被浏览器执行的 HTML 引发 XSS。
*/
private static final Set<String> ALLOWED_UPLOAD_TYPES = new HashSet<>(Arrays.asList("image", "file", "icon", "word"));
@GetMapping("GetUserInfo")
public Object getUserInfo(BoaUser authUserWithDept){
return authUserWithDept;
}
//返回系统枚举
@GetMapping("GetSysDict")
public Map getSysDict(BoaUser authUserWithDept){
Map<String, List> resultMap = new HashMap<>(7);
resultMap.put("grouptype", IEnum.toSimpleMapList(GroupTypeEnum.class, "k", "v"));
resultMap.put("gender", IEnum.toSimpleMapList(GenderEnum.class, "k", "v"));
resultMap.put("client", IEnum.toSimpleMapList(ClientEnum.class, "k", "v"));
resultMap.put("integ", IEnum.toSimpleMapList(IntegEnum.class, "k", "v"));
resultMap.put("status", IEnum.toSimpleMapList(StatusEnum.class, "k", "v"));
resultMap.put("sysrole", IEnum.toSimpleMapList(SysRoleEnum.class, "k", "v"));
resultMap.put("mode", IEnum.toSimpleMapList(SysModeEnum.class, "k", "v"));
return resultMap;
}
/**
* 手动管理 Redis Hash 缓存的示例(@Cacheable 注解无法满足的场景)。<br>
* 适用场景:需要把一个对象的多个字段按 Hash 结构存储,<br>
* 可单独读写/更新某个字段,而不像 @Cacheable 那样把整个对象序列化成一个大 value。<br>
* 访问路径:GET {Config.API_PATH}/base/ManualHashCacheDemo。<br>
* 注:手动缓存依赖 Redis;Redis 不可用时 RedisUtil 已内部捕获异常,操作失败但不影响接口本身。
*
* @return 本次演示各步骤的结果快照
*/
@GetMapping("ManualHashCacheDemo")
public Map<String, Object> manualHashCacheDemo() {
Map<String, Object> result = new HashMap<>(8);
String hashKey = "demo:user:1001";
// 1) 写入整张 hash(带 TTL):适合一次性缓存一个对象的多个属性
Map<String, Object> profile = new HashMap<>(4);
profile.put("name", "yunhai");
profile.put("age", 18);
profile.put("role", "admin");
redisUtil.hmset(hashKey, profile, 300);
// 2) 只更新其中一个字段,无需整体覆盖
redisUtil.hset(hashKey, "role", "superadmin");
// 3) 读取单个字段
result.put("roleAfterUpdate", redisUtil.hget(hashKey, "role"));
// 4) 读取整张 hash
result.put("wholeHash", redisUtil.hmget(hashKey));
// 5) 判断字段是否存在
result.put("hasAgeField", redisUtil.hHasKey(hashKey, "age"));
// 6) Cache-Aside(复用下方私有方法):字段不存在时回源并写回 Hash
Object nickname = getHashFieldWithFallback(hashKey, "nickname", () -> "yunhai-nick");
result.put("nickname", nickname);
// 7) 删除某个字段
redisUtil.hdel(hashKey, "age");
result.put("afterDelAge", redisUtil.hmget(hashKey));
return result;
}
@GetMapping("RedirectTo/{id}")
public RedirectView RedirectTo(BoaUser authUserWithDept, @PathVariable(name = "id") String id) {
String url= OtherUtil.hexToString(id);
return new RedirectView(url,true);
}
/**
* 通用文件上传接口(一个接口覆盖所有类型,无需按类型拆分)。<br>
* type 决定存储子目录,与 MyWebAppConfigurer.addResourceHandlers 的 /upload/{type}/** 映射一一对应,<br>
* 上传成功后可直接用返回的 URL 访问,无需再改资源映射配置。<br>
* 访问路径:POST {Config.API_PATH}/base/UploadFile(multipart/form-data)。<br>
* 支持 type:image / file / icon / word(html 被 FileUtil 黑名单禁止上传)。<br>
*
* @param file 上传文件(必填,单文件上限 50MB,见 application.yml 的 multipart 配置)
* @param type 存储子目录类型,默认 image
* @return 文件对外可访问的 Web URL
*/
@PostMapping("UploadFile")
public String uploadFile(@RequestParam("file") MultipartFile file,
@RequestParam(value = "type", defaultValue = "image") String type) {
if (!ALLOWED_UPLOAD_TYPES.contains(type)) {
throw new DException("不支持的上传类型: " + type + ",可选: image/file/icon/word");
}
// FileUtil.upload 内部已处理空文件校验、危险后缀黑名单、目录创建与访问 URL 拼接
return FileUtil.upload(file, type);
}
/**
* 私有方法(可复用):演示 Hash 结构的 Cache-Aside(缓存旁路)模式。<br>
* 先从 Redis Hash 读字段;读不到则回源(此处用 fallback 模拟数据库查询),再把结果写回 Hash。<br>
*
* @param hashKey hash 键
* @param field 字段名
* @param fallback 回源加载器(真实场景替换为查数据库/远程调用)
* @return 字段值
*/
private Object getHashFieldWithFallback(String hashKey, String field, Supplier<Object> fallback) {
if (redisUtil.hHasKey(hashKey, field)) {
return redisUtil.hget(hashKey, field);
}
Object value = fallback.get();
if (value != null) {
redisUtil.hset(hashKey, field, value);
}
return value;
}
}
@@ -0,0 +1,179 @@
package com.yunhai.userbase.controller.api;
import com.yunhai.userbase.common.annotation.OperationLog;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.model.groupauthz.GroupAppAssignRequest;
import com.yunhai.userbase.model.groupauthz.GroupAppVo;
import com.yunhai.userbase.model.groupauthz.GroupAuthzAppVo;
import com.yunhai.userbase.model.groupauthz.GroupAuthzCategoryVo;
import com.yunhai.userbase.model.groupauthz.GroupModuleAuthzItem;
import com.yunhai.userbase.model.groupauthz.GroupModulePermRequest;
import com.yunhai.userbase.model.groupauthz.SchoolAppAuthzRequest;
import com.yunhai.userbase.model.groupauthz.UserGroupNodeVo;
import com.yunhai.userbase.model.groupauthz.UserPermissionCategoryVo;
import com.yunhai.userbase.model.groupauthz.UserPermissionUserVo;
import com.yunhai.userbase.model.groupauthz.UserRoleVo;
import com.yunhai.userbase.service.IGroupAuthzService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import javax.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* @author chenx
* @date 2026/7/23
* @description 组应用权限管理(应用开通 + 组授权 + 权限判定)。<br>
* 左侧组树复用 /sysgroup/GetTree;本控制器提供右侧数据加载与保存接口。<br>
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/groupauthz/")
public class GroupAuthzController {
@Autowired
private IGroupAuthzService groupAuthzService;
/**
* 功能1回显:点击左侧组后,返回系统全量应用列表,已开通的 checked=true。
*/
@GetMapping("/GroupAppList")
public List<GroupAppVo> groupAppList(@RequestParam Integer groupId) {
return groupAuthzService.listGroupApps(groupId);
}
/**
* 功能1保存:全量覆盖式更新该组的应用开通记录。
*/
@OperationLog("保存组应用开通")
@PostMapping("AssignApps")
public void assignApps(@RequestBody @Validated GroupAppAssignRequest request) {
groupAuthzService.assignAppsToGroup(request);
}
/**
* 功能2数据加载:返回该组已开通的应用及其模块权限树(含勾选与子组继承回显)。
*/
@GetMapping("/GroupModuleTree")
public List<GroupAuthzAppVo> groupModuleTree(@RequestParam Integer groupId) {
return groupAuthzService.findGroupAppsWithModules(groupId);
}
/**
* 功能2数据加载(按应用分类分组):供组授权页面按分类面板展示,<br>
* 返回全部分类及该组已开通的应用模块权限树。
*/
@GetMapping("/GroupModuleTreeByCategory")
public List<GroupAuthzCategoryVo> groupModuleTreeByCategory(@RequestParam Integer groupId) {
return groupAuthzService.findGroupAppsWithModulesByCategory(groupId);
}
/**
* 功能2保存:全量覆盖式更新该组的模块权限(含子组继承开关)。
*/
@OperationLog("保存组模块权限")
@PostMapping("SaveModulePerms")
public void saveModulePerms(@RequestBody @Validated GroupModulePermRequest request) {
groupAuthzService.saveModulePermissions(request);
}
/**
* 权限判定:判断用户对某模块是否有权限(供登录/拦截器/联调验证使用)。
*/
@GetMapping("/HasModulePerm")
public boolean hasModulePerm(@RequestParam Integer userId, @RequestParam Integer moduleId) {
return groupAuthzService.hasModulePermission(userId, moduleId);
}
/**
* 权限一览:根据组ID获取该组下的直接用户列表(不含子组,不分页)。
*/
@GetMapping("/GroupUsers")
public List<UserPermissionUserVo> groupUsers(@RequestParam Integer groupId) {
return groupAuthzService.findGroupUsers(groupId);
}
/**
* 权限一览:全局搜索用户(按账号/姓名/身份证模糊、手机号精确匹配),不分页。<br>
* 返回用户及其主归属部门/组织路径,供左侧用户搜索形态展示。
*/
@GetMapping("/SearchUsers")
public List<UserPermissionUserVo> searchUsers(@RequestParam String keyword) {
return groupAuthzService.searchUsers(keyword);
}
/**
* 权限一览:根据用户ID聚合该用户的全部已授权应用模块树。<br>
* 只返回有授权模块的应用,并按应用类型分组;无授权的应用类型和应用不显示。<br>
* 角色授权并集:结果 = 组授权模块 ∪ 启用角色授权模块(角色模块不要求组先开通应用)。
*/
@GetMapping("/UserPermissionTree")
public List<UserPermissionCategoryVo> userPermissionTree(@RequestParam Integer userId,
@RequestParam(required = false) Integer groupId) {
return groupAuthzService.findUserPermissionTree(groupId, userId);
}
/**
* 权限一览:查询用户所属的业务角色列表(中间下方「所属角色」区域展示用)。<br>
* 返回该用户绑定的全部角色(含禁用角色,disabled 字段标记),普通角色回填所属应用名称。
*/
@GetMapping("/UserRoles")
public List<UserRoleVo> userRoles(@RequestParam Integer userId) {
return groupAuthzService.findUserRoles(userId);
}
/**
* 权限一览:查询用户所属部门树(左侧上方「所属部门」区域展示用)。<br>
* 树仅由「用户直接归属的组 + 其全部祖先组」构成(如:云平台-学校中学-语文组)。
*/
@GetMapping("/UserGroupTree")
public List<UserGroupNodeVo> userGroupTree(@RequestParam Integer userId) {
return groupAuthzService.findUserGroupTree(userId);
}
// ===================== 新增:树节点式增量授权(左侧树 + 右侧 appAuthzList 勾选交互) =====================
/**
* 功能3回显:点击左侧树节点后,返回该组「已开通的应用ID列表」,供右侧 AppSettings.appAuthzList 回显勾选状态。<br>
* <b>交互</b>:左树节点 → 右 appAuthzList(全量应用)→ 本接口拿到已选 id → 前端把对应多选框置为勾选。<br>
* <b>实现</b>:应用开通不继承,直接查 sys_group_app_rel(group_id=nodeId) 返回 app_id 列表(详见 service.findAuthorizedAppIds)。<br>
* 与 GroupAppList(返回全量应用+checked)互为补充:本接口更轻量,只给 id 集合。
*/
@GetMapping("/GetSchoolAppId")
public List<Integer> getSchoolAppId(@RequestParam Integer nodeId) {
return groupAuthzService.findAuthorizedAppIds(nodeId);
}
/**
* 功能3保存(应用维):点击应用多选框触发,对该组做应用开通/取消。<br>
* <b>入参(请求体,JSON)</b>:{@link SchoolAppAuthzRequest} —— schid=组ID(nodeId);appid=单个应用ID(一次只传一个,逐次点击);mode=操作模式。<br>
* <b>mode 语义(SysModeEnum)</b>:0=追加(开通应用) / 2=删除(取消开通);1=覆盖 不适用,会被拒绝。<br>
* <b>实现</b>:应用开通不继承,仅操作本组;删除时同步清理该组在该应用下模块的权限残留(详见 service.setSchoolApps)。<br>
* <b>注意</b>:appid 为单个 Integer,后端不再做多值/逗号解析;每次点击提交一次请求。
*/
@OperationLog("设置组应用开通")
@PostMapping("SetSchoolApp")
public void setSchoolApp(@RequestBody @Valid SchoolAppAuthzRequest request) {
groupAuthzService.setSchoolApps(request);
}
/**
* 功能4保存(模块维):前端「组授权」面板逐条勾选/取消时提交,对 (组,应用,模块) 做增量授权/取消。<br>
* <b>入参</b>:请求体为数组,元素见 {@link GroupModuleAuthzItem}:{groupId, appId, moduleId, mode, isAll}。<br>
* <b>mode</b>:0=追加 / 2=删除(1=覆盖不适用)。<br>
* <b>isAll</b>:是否对子组继承,映射 sys_group_module_perm.inherit_flag(1=子孙组自动继承 / 0=仅本组)。<br>
* <b>moduleId=0</b>:表示「该应用下全部模块」,后端展开为应用下每个真实模块各写/删一条。<br>
* <b>实现</b>:逐条处理、顺序即执行顺序;追加前要求该组已开通该应用;越权写别的应用模块会被拒绝(详见 service.setGroupModulePerms)。<br>
* 与现有 SaveModulePerms(全量覆盖)共存:本接口是单条增量,二者操作同一张表、互不冲突。
*/
@OperationLog("设置组模块权限")
@PostMapping("SetGroupModule")
public void setGroupModule(@RequestBody @Valid List<GroupModuleAuthzItem> items) {
groupAuthzService.setGroupModulePerms(items);
}
}
@@ -0,0 +1,97 @@
package com.yunhai.userbase.controller.api;
import com.yunhai.userbase.common.annotation.OperationLog;
import com.yunhai.userbase.common.validation.ValidationGroups;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.model.sysgroup.SysGroupBatchRequest;
import com.yunhai.userbase.model.sysgroup.SysGroupMoveRequest;
import com.yunhai.userbase.model.sysgroup.SysGroupQuery;
import com.yunhai.userbase.model.sysgroup.SysGroupRequest;
import com.yunhai.userbase.model.sysgroup.SysGroupSortRequest;
import com.yunhai.userbase.model.sysgroup.SysGroupVo;
import com.yunhai.userbase.service.ISysGroupService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* @author chenx
* @date 2026/7/20 16:00
* @description 系统组管理(组织架构树)
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/sysgroup/")
public class SysGroupController {
@Autowired
private ISysGroupService sysGroupService;
@OperationLog("新增组")
@PostMapping("Add")
public void add(@RequestBody @Validated(ValidationGroups.Create.class) SysGroupRequest request) {
sysGroupService.add(request);
}
@OperationLog("编辑组")
@PostMapping("Edit/{id}")
public void edit(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) SysGroupRequest request) {
sysGroupService.edit(id, request);
}
@OperationLog("删除组")
@PostMapping("Delete/{id}")
public void delete(@PathVariable Integer id) {
sysGroupService.delete(id);
}
@GetMapping("/List")
public List<SysGroupVo> list(SysGroupQuery query) {
return sysGroupService.list(query);
}
/**
* 获取组织架构树;表为空时自动创建根节点「教育云中心」(pid=0)。
*/
@GetMapping("GetTree")
public List<SysGroupVo> getTree() {
return sysGroupService.getTree();
}
/**
* 获取「学校组」剪枝树:末端叶子一定是学校组;学校组下的组不显示;<br>
* 子树中无学校组的分支整体剪掉;根节点始终保留。
*/
@GetMapping("GetSchoolTree")
public List<SysGroupVo> getSchoolTree() {
return sysGroupService.getSchoolTree();
}
/**
* 批量新增子组:请求体 { pid, type, items:[{name},...] }(JSON)。
*/
@OperationLog("批量新增子组")
@PostMapping("BatchAdd")
public void batchAdd(@RequestBody @Validated SysGroupBatchRequest request) {
sysGroupService.batchAdd(request);
}
/**
* 子组排序:传入 pid 与有序 ids(必须是 pid 的直接子节点),按传入顺序落库。
*/
@OperationLog("子组排序")
@PostMapping("SortChildren")
public void sortChildren(@Validated SysGroupSortRequest request) {
sysGroupService.sortChildren(request);
}
/**
* 移动组:将 moveId 移动到 pid 下(不支持移动到自身或自身后代)。
*/
@OperationLog("移动组")
@PostMapping("Move")
public void move(@Validated SysGroupMoveRequest request) {
sysGroupService.move(request);
}
}
@@ -0,0 +1,74 @@
package com.yunhai.userbase.controller.api;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.model.BasePageQuery;
import com.yunhai.userbase.model.BasePageResult;
import com.yunhai.userbase.model.oplog.SysOperationLogListVo;
import com.yunhai.userbase.model.oplog.SysOperationLogQuery;
import com.yunhai.userbase.service.ISysOperationLogService;
import lombok.extern.slf4j.Slf4j;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 操作日志查询与下载接口。<br>
* 本控制器自身不标注 @OperationLog,避免记录日志查看行为。<br>
*
* @author chenx
* @date 2026/7/28
*/
@Slf4j
@RestController
@RequestMapping(value = Config.API_PATH + "/oplog/")
public class SysOperationLogController {
@Autowired
private ISysOperationLogService sysOperationLogService;
/**
* 分页查询操作日志列表(不含响应结果)。
*/
@GetMapping("/List")
public BasePageResult<SysOperationLogListVo> list(SysOperationLogQuery query, BasePageQuery pageQuery) {
return sysOperationLogService.pageQuery(query, pageQuery);
}
/**
* 下载某条日志的响应结果(GZIP 解压后原文,文本文件)。<br>
* 采用与项目 DownloadFileUtil 一致的 octet-stream + attachment 写法,确保浏览器稳定触发下载。<br>
* 若该日志未记录响应数据,返回一段说明文本文件而非 404,便于排查。
*/
@GetMapping("/Download/{id}")
public ResponseEntity<InputStreamResource> download(@PathVariable Long id) {
String data = sysOperationLogService.getResponseData(id);
if (data == null) {
data = "该条日志未记录响应数据(saveResponse=false、或接口响应为空、或响应序列化失败),无法下载原始内容。";
}
try {
ByteArrayInputStream bis = new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8));
HttpHeaders headers = new HttpHeaders();
headers.add("Cache-Control", "no-cache, no-store, must-revalidate");
headers.add(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=" + new String(("response-" + id + ".txt").getBytes("gbk"), "iso8859-1"));
headers.add("Pragma", "no-cache");
headers.add("Expires", "0");
return ResponseEntity.ok().headers(headers)
.contentType(MediaType.parseMediaType("application/octet-stream"))
.body(new InputStreamResource(bis));
} catch (Exception e) {
log.error("操作日志响应结果下载失败, id={}", id, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}
@@ -0,0 +1,68 @@
package com.yunhai.userbase.controller.api;
import com.yunhai.userbase.common.annotation.OperationLog;
import com.yunhai.userbase.common.validation.ValidationGroups;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.model.BasePageQuery;
import com.yunhai.userbase.model.BasePageResult;
import com.yunhai.userbase.model.sysuser.SysUserQuery;
import com.yunhai.userbase.model.sysuser.SysUserRequest;
import com.yunhai.userbase.model.sysuser.SysUserVo;
import com.yunhai.userbase.service.ISysUserService;
import com.yunhai.userbase.utils.PageUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.SortDefault;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
/**
* @author chenx
* @date 2026/7/20 16:14
* @description 组用户管理
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/sysuser/")
public class SysUserController {
@Autowired
private ISysUserService sysUserService;
@OperationLog("新增用户")
@PostMapping("Add")
public void add(@RequestBody @Validated(ValidationGroups.Create.class) SysUserRequest request) {
sysUserService.add(request);
}
@OperationLog("编辑用户")
@PostMapping("Edit/{id}")
public void edit(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) SysUserRequest request) {
sysUserService.edit(id, request);
}
@OperationLog("删除用户")
@PostMapping("Delete/{id}")
public void delete(@PathVariable Integer id) {
sysUserService.delete(id);
}
/** 禁用用户(disabled=true) */
@OperationLog("禁用用户")
@PostMapping("Disable/{id}")
public void disable(@PathVariable Integer id) {
sysUserService.changeStatus(id, true);
}
/** 启用用户(disabled=false) */
@OperationLog("启用用户")
@PostMapping("Enable/{id}")
public void enable(@PathVariable Integer id) {
sysUserService.changeStatus(id, false);
}
@GetMapping("/List")
public BasePageResult<SysUserVo> list(SysUserQuery query, BasePageQuery pageQuery,
@SortDefault(sort = "id", direction = Sort.Direction.DESC) Sort sort) {
return PageUtils.toResult(sysUserService.list(query, PageUtils.toPageable(pageQuery, sort)));
}
}
@@ -0,0 +1,45 @@
package com.yunhai.userbase.controller.oapi;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.service.UserCenterService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 用户中心开放接口:供其他业务服务经 yh-app-auth 签名调用({@code /open/api/user/...})。<br><br>
* 路径与基础平台 {@code DefaultUserCenterClient} 硬编码的契约严格一致:
* {@code GET /open/api/user/byId?id=}、{@code /byLoginName?loginName=}、{@code /byPhone?phone=}。<br>
* 接口本身不做验签——签名由 {@code OpenApiAuthEnforceConfiguration} 在过滤器层强制要求,
* 未带 {@code X-App-Key/X-Timestamp/X-Signature} 的请求直接 401。
*/
@RestController
@RequestMapping("/open/api/user/")
public class OpenBaseController {
@Autowired
private UserCenterService userCenterService;
@GetMapping("byId")
public BoaUser byId(@RequestParam("id") Integer id) {
return userCenterService.getUserInfoWithDeptById(id);
}
@GetMapping("byLoginName")
public BoaUser byLoginName(@RequestParam("loginName") String loginName) {
if (loginName == null || loginName.trim().isEmpty()) {
return null;
}
return userCenterService.getUserInfoWithDeptByAccount(loginName);
}
@GetMapping("byPhone")
public BoaUser byPhone(@RequestParam("phone") String phone) {
if (phone == null || phone.trim().isEmpty()) {
return null;
}
return userCenterService.getUserInfoWithDeptByPhone(phone);
}
}
@@ -0,0 +1,50 @@
package com.yunhai.userbase.controller.oapi;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.repository.ApplicationInfoRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 验签兜底端点:供其他业务服务在「共享 Redis 宕机且本地缓存也未命中」时直连获取某应用的密钥。<br><br>
* 直接从 MySQL 读(绕过可能同宕的共享 Redis),是密钥的权威源;写入侧仍由
* {@code SignKeyCacheService} 同步到 Redis。返回形状与 BasePlatform 读侧解析契约一致:<br>
* {@code {"appKey":"100616","secret":"...","enabled":true}};未知/缺失返回 404。<br><br>
* 注意:此端点返回的是应用密钥,必须仅内网可达(由部署网络隔离保障),切勿暴露到公网。
*/
@RestController
@RequestMapping(Config.OPEN_API_PATH + "internal")
public class SignKeyInternalController {
private final ApplicationInfoRepository applicationInfoRepository;
public SignKeyInternalController(ApplicationInfoRepository applicationInfoRepository) {
this.applicationInfoRepository = applicationInfoRepository;
}
@GetMapping("sign-key/{appId}")
public ResponseEntity<Map<String, Object>> signKey(@PathVariable String appId) {
Integer key;
try {
key = Integer.parseInt(appId.trim());
} catch (NumberFormatException e) {
return ResponseEntity.notFound().build();
}
ApplicationInfo info = applicationInfoRepository.findByAppKey(key);
if (info == null || info.getAppSecret() == null || info.getAppSecret().trim().isEmpty()) {
return ResponseEntity.notFound().build();
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("appKey", String.valueOf(info.getAppKey()));
body.put("secret", info.getAppSecret());
body.put("enabled", true);
return ResponseEntity.ok(body);
}
}
@@ -0,0 +1,28 @@
package com.yunhai.userbase.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import javax.validation.constraints.NotNull;
/**
* @author chenx
* @date 2026/7/15 15:53
* @description
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "app_category")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class AppCategory extends BaseEntity {
//类别名称
@Column(name = "name", nullable = false)
private String name;
@Column(name = "sort_no")
private Integer sortNo;
}
@@ -0,0 +1,81 @@
package com.yunhai.userbase.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
/**
* @author chenx
* @date 2026/7/22
* @description 应用模块(树形,逻辑外键,最深5层)<br>
*/
@NoArgsConstructor
@Data
@Entity
@Table(
name = "app_module",
indexes = {
@Index(name = "idx_app_module_app", columnList = "app_id"),
@Index(name = "idx_app_module_parent", columnList = "parent_id")
},
uniqueConstraints = {
@UniqueConstraint(name = "uk_app_module_custom", columnNames = {"app_id", "custom_id"})
}
)
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class AppModule extends BaseEntity {
/**
* 所属应用ID(逻辑外键 → ApplicationInfo.id),创建后不可改。<br>
*/
@NotNull
@Column(name = "app_id", nullable = false, updatable = false)
private Integer appId;
/**
* 父模块ID(逻辑外键 → AppModule.id);根模块为 null。<br>
*/
@Column(name = "parent_id")
private Integer parentId;
/**
* 模块名称。<br>
*/
@NotBlank
@Column(name = "name", nullable = false)
private String name;
/**
* 自定义ID(应用内唯一,业务编码,如 "user-mgmt")。<br>
*/
@NotBlank
@Column(name = "custom_id", nullable = false, length = 64)
private String customId;
/**
* 模块路径(前端路由/资源路径,如 "/system/user")。<br>
*/
@Column(name = "path", length = 255)
private String path;
/**
* 层级:根=1,子=父+1,上限5(由 Service 校验)。<br>
*/
@NotNull
@Column(name = "level", nullable = false)
private Integer level;
/**
* 排序号(沿用 AppCategory 约定)。<br>
*/
@Column(name = "sort_no")
private Integer sortNo;
}
@@ -0,0 +1,107 @@
package com.yunhai.userbase.entity;
import com.yunhai.userbase.common.converter.ListIntegerJsonConverter;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Convert;
import javax.persistence.Entity;
import javax.persistence.Table;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.util.List;
/**
* @author chenx
* @date 2026/7/15 15:53
* @description
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "application_info")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class ApplicationInfo extends BaseEntity {
/**
* 应用名称(页面*必填)
*/
private String name;
/**
* 应用类型ID
*/
private Integer categoryId;
/**
* 排序号,仅在同一个应用类型内生效;默认为 null(新增时不赋值)。<br>
*/
@Column(name = "sort_no")
private Integer sortNo;
/**
* 应用描述
*/
@Column(name = "app_desc", length = 500)
private String desc;
/**
* appKey 系统生成唯一标识,整型,从100000起自增。<br>
*/
@NotNull
@Column(name = "app_key", unique = true, nullable = false)
private Integer appKey;
/**
* appSecret 密钥,支持重设
*/
@NotBlank
@Column(name = "app_secret", nullable = false, length = 128)
private String appSecret;
//应用前端类型
@Convert(converter = ListIntegerJsonConverter.class)
// @Column(name = "clients", columnDefinition = "json")
@Column(name = "clients", columnDefinition = "TEXT")
private List<Integer> clients;
/**
* PC应用访问地址
*/
@Column(name = "pc_url")
private String pcUrl;
/**
* PC应用图标 图片URL
*/
@Column(name = "pc_icon")
private String pcIcon;
/**
* App访问地址(移动端主地址)
*/
@Column(name = "app_url")
private String appUrl;
/**
* App应用图标 图片URL
*/
@Column(name = "app_icon")
private String appIcon;
/**
* 公众号应用地址
*/
@Column(name = "official_account_url")
private String officialAccountUrl;
/**
* 钉钉应用地址
*/
@Column(name = "dingtalk_url")
private String dingtalkUrl;
}
@@ -0,0 +1,63 @@
package com.yunhai.userbase.entity;
import javax.persistence.*;
import javax.validation.constraints.NotNull;
import com.yunhai.userbase.common.listener.UserAuditListener;
import com.yunhai.userbase.common.validation.ValidationGroups;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* @author chenx
* @date 2026/7/14 10:46
* @description
*/
@MappedSuperclass
@Getter
@Setter
@EntityListeners(UserAuditListener.class)
public abstract class BaseEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@NotNull(groups = ValidationGroups.Update.class)
private Integer id;
/**
* 创建人ID
*/
@Column(name = "create_user", nullable = false, updatable = false)
private Integer createUser;
/**
* 创建时间
*/
@CreationTimestamp
@Column(name = "create_time", nullable = false, updatable = false)
private LocalDateTime createTime;
/**
* 更新人ID
*/
@Column(name = "update_user")
private Integer updateUser;
/**
* 更新时间
*/
@UpdateTimestamp
@Column(name = "update_time")
private LocalDateTime updateTime;
}
@@ -0,0 +1,68 @@
package com.yunhai.userbase.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
/**
* @author chenx
* @date 2026/7/27
* @description 应用业务角色。<br>
* 普通角色:allAppFlag=false 且 appId 必填;<br>
* 全应用角色:allAppFlag=true 且 appId 为空;<br>
* 注意:这里的“业务角色”不等同于 {@code sys_user.roles} 对应的系统身份枚举。<br>
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "sys_app_role", indexes = {
@Index(name = "idx_app_role_app", columnList = "app_id"),
@Index(name = "idx_app_role_all_app", columnList = "all_app_flag"),
@Index(name = "idx_app_role_name", columnList = "name")
})
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class SysAppRole extends BaseEntity {
/**
* 角色名称。<br>
*/
@NotBlank
@Column(name = "name", nullable = false, length = 100)
private String name;
/**
* 所属应用ID(逻辑外键 → ApplicationInfo.id)。<br>
* 普通角色必填;全应用角色为空。<br>
*/
@Column(name = "app_id")
private Integer appId;
/**
* 是否全应用角色。<br>
* true=可跨应用勾选模块;false=仅能授权所属应用下的模块。<br>
*/
@NotNull
@Column(name = "all_app_flag", nullable = false)
private Boolean allAppFlag = Boolean.FALSE;
/**
* 角色备注。<br>
*/
@Column(name = "remark", length = 500)
private String remark;
/**
* 是否禁用。<br>
* 禁用后角色关系可保留,但权限计算时应忽略该角色。<br>
*/
@NotNull
@Column(name = "disabled", nullable = false)
private Boolean disabled = Boolean.FALSE;
}
@@ -0,0 +1,49 @@
package com.yunhai.userbase.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
import javax.validation.constraints.NotNull;
/**
* @author chenx
* @date 2026/7/27
* @description 业务角色-模块权限关系表(逻辑外键,无物理 FK)。<br>
* 存在记录即代表该角色显式拥有该模块。<br>
*/
@NoArgsConstructor
@Data
@Entity
@Table(
name = "sys_app_role_module_rel",
indexes = {
@Index(name = "idx_app_role_module_role", columnList = "role_id"),
@Index(name = "idx_app_role_module_module", columnList = "module_id")
},
uniqueConstraints = {
@UniqueConstraint(name = "uk_app_role_module", columnNames = {"role_id", "module_id"})
}
)
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class SysAppRoleModuleRel extends BaseEntity {
/**
* 角色ID(逻辑外键 → SysAppRole.id)。<br>
*/
@NotNull
@Column(name = "role_id", nullable = false)
private Integer roleId;
/**
* 模块ID(逻辑外键 → AppModule.id)。<br>
*/
@NotNull
@Column(name = "module_id", nullable = false)
private Integer moduleId;
}
@@ -0,0 +1,42 @@
package com.yunhai.userbase.entity;
import com.yunhai.userbase.common.converter.GroupTypeEnumConverter;
import com.yunhai.userbase.enums.GroupTypeEnum;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import java.util.List;
/**
* @author chenx
* @date 2026/7/13 22:48
* @description 系统节点(组)表
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "sys_group")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class SysGroup extends BaseEntity {
private String name;
@Column(name = "parent_id", nullable = false)
private Integer parentId;
@Column(name = "type")
@Convert(converter = GroupTypeEnumConverter.class)
private GroupTypeEnum type;
private Integer sort;
// 一对多反向关联中间表
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JoinColumn(name = "groupId", insertable = false, updatable = false)
private List<SysUserGroupRel> userGroupRelList;
}
@@ -0,0 +1,49 @@
package com.yunhai.userbase.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
import javax.validation.constraints.NotNull;
/**
* @author chenx
* @date 2026/7/23
* @description 组应用开通表(逻辑外键,无物理 FK)。<br>
* 记录某个组显式开通了哪些应用;仅当本表存在记录时,「组授权」界面右侧才显示该应用。<br>
* 开通不继承:子组要使用应用必须自己开通。<br>
*/
@NoArgsConstructor
@Data
@Entity
@Table(
name = "sys_group_app_rel",
indexes = {
@Index(name = "idx_group_app_rel_app", columnList = "app_id")
},
uniqueConstraints = {
@UniqueConstraint(name = "uk_group_app", columnNames = {"group_id", "app_id"})
}
)
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class SysGroupAppRel extends BaseEntity {
/**
* 组ID(逻辑外键 → SysGroup.id)。<br>
*/
@NotNull
@Column(name = "group_id", nullable = false)
private Integer groupId;
/**
* 应用ID(逻辑外键 → ApplicationInfo.id)。<br>
*/
@NotNull
@Column(name = "app_id", nullable = false)
private Integer appId;
}
@@ -0,0 +1,56 @@
package com.yunhai.userbase.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
import javax.validation.constraints.NotNull;
/**
* @author chenx
* @date 2026/7/23
* @description 组模块权限表(逻辑外键,无物理 FK)。<br>
* 存在记录即代表该组勾选了该模块;inheritFlag=true 时当前组及其所有子孙组自动继承该模块权限。<br>
* 取消勾选时物理删除记录。<br>
*/
@NoArgsConstructor
@Data
@Entity
@Table(
name = "sys_group_module_perm",
indexes = {
@Index(name = "idx_group_module_perm_module", columnList = "module_id")
},
uniqueConstraints = {
@UniqueConstraint(name = "uk_group_module", columnNames = {"group_id", "module_id"})
}
)
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class SysGroupModulePerm extends BaseEntity {
/**
* 组ID(逻辑外键 → SysGroup.id)。<br>
*/
@NotNull
@Column(name = "group_id", nullable = false)
private Integer groupId;
/**
* 模块ID(逻辑外键 → AppModule.id)。<br>
*/
@NotNull
@Column(name = "module_id", nullable = false)
private Integer moduleId;
/**
* 是否子组继承(false=仅本组生效,true=子孙组自动继承),对应 UI「子组继承」勾选框。<br>
*/
@NotNull
@Column(name = "inherit_flag", nullable = false)
private Boolean inheritFlag = Boolean.FALSE;
}
@@ -0,0 +1,140 @@
package com.yunhai.userbase.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Index;
import javax.persistence.Table;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
/**
* 系统操作日志实体。<br>
* 记录用户的关键操作(增删改查),便于审计与问题排查。<br>
*
* @author chenx
* @date 2026/7/28
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "sys_operation_log", indexes = {
@Index(name = "idx_create_time", columnList = "create_time"),
@Index(name = "idx_user_time", columnList = "user_id, create_time"),
@Index(name = "idx_uri_time", columnList = "request_uri, create_time"),
@Index(name = "idx_level_time", columnList = "log_level, create_time"),
@Index(name = "idx_ip_time", columnList = "request_ip, create_time")
})
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class SysOperationLog implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键
*/
@EqualsAndHashCode.Include
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 日志级别:1-重要(删除) 2-次要(新增/编辑) 3-一般(查询)
*/
@Column(name = "log_level", nullable = false, columnDefinition = "TINYINT")
private Integer logLevel;
/**
* 操作用户ID
*/
@Column(name = "user_id", nullable = false)
private Long userId;
/**
* 操作用户名
*/
@Column(name = "username", nullable = false, length = 64)
private String username;
/**
* 请求IP
*/
@Column(name = "request_ip", nullable = false, length = 64)
private String requestIp;
/**
* 请求路径
*/
@Column(name = "request_uri", nullable = false, length = 512)
private String requestUri;
/**
* 请求方法 GET/POST/PUT/DELETE
*/
@Column(name = "request_method", nullable = false, length = 16)
private String requestMethod;
/**
* 操作类型 DELETE/CREATE/UPDATE/QUERY
*/
@Column(name = "operation_type", nullable = false, length = 32)
private String operationType;
/**
* 类名.方法名
*/
@Column(name = "class_method", nullable = false, length = 512)
private String classMethod;
/**
* 请求参数JSON
*/
@Column(name = "request_params", columnDefinition = "TEXT")
private String requestParams;
/**
* 响应结果(GZIP压缩)
*/
@Column(name = "response_data", columnDefinition = "MEDIUMBLOB")
private byte[] responseData;
/**
* HTTP状态码
*/
@Column(name = "status_code", nullable = false)
private Integer statusCode;
/**
* 耗时(ms)
*/
@Column(name = "cost_time", nullable = false)
private Long costTime;
/**
* 浏览器UA
*/
@Column(name = "user_agent", length = 512)
private String userAgent;
/**
* 错误信息
*/
@Column(name = "error_msg", columnDefinition = "TEXT")
private String errorMsg;
/**
* 操作时间
*/
@CreationTimestamp
@Column(name = "create_time", nullable = false, updatable = false, columnDefinition = "DATETIME(3)")
private LocalDateTime createTime;
}
@@ -0,0 +1,73 @@
package com.yunhai.userbase.entity;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.yunhai.userbase.common.converter.ListIntegerJsonConverter;
import com.yunhai.userbase.common.converter.StatusEnumConverter;
import com.yunhai.userbase.common.converter.UserTypeEnumConverter;
import com.yunhai.userbase.enums.StatusEnum;
import com.yunhai.userbase.enums.UserTypeEnum;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import java.time.LocalDate;
import java.util.List;
/**
* @author chenx
* @date 2026/7/13 23:30
* @description 系统用户表
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "sys_user")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class SysUser extends BaseEntity {
@Column(name = "username", nullable = false, unique = true)
private String account;
private String password;
//出生日期
private LocalDate birthday;
@JsonProperty("name")
private String realName;
private String phone;
private String idcard;
//头像
private String profilePicture;
/**
* 角色列表,存储为 MySQL JSON 类型,读取时自动映射为 List<Long>
* 取值对应 {@link com.yunhai.userbase.enums.SysRoleEnum} 的 key,例如 [1,2,3]
*/
@Convert(converter = ListIntegerJsonConverter.class)
// @Column(name = "roles", columnDefinition = "json")
@Column(name = "roles",columnDefinition = "TEXT")
private List<Integer> roles;
// 一对多:关联用户分组中间表,实现多对多业务
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JoinColumn(name = "userId", insertable = false, updatable = false)
private List<SysUserGroupRel> userGroupRelList;
// 是否禁用:存储为 StatusEnum(0=正常/未禁用,1=禁用),与 GroupTypeEnum 同机制;默认启用
@Convert(converter = StatusEnumConverter.class)
private StatusEnum disabled;
//是否过期
private Boolean expired=false;
/**
* 用户类型:0=普通用户,1=超级管理员,2=学校管理员(见 UserTypeEnum)。
* 由应用层根据用户账号/角色推导:内置超级管理员账号固定为 1,其余账号含 ADMIN(7) 角色则为 2,默认 0。
*/
@Convert(converter = UserTypeEnumConverter.class)
private UserTypeEnum type;
}
@@ -0,0 +1,49 @@
package com.yunhai.userbase.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
import javax.validation.constraints.NotNull;
/**
* @author chenx
* @date 2026/7/27
* @description 用户-业务角色关系表(逻辑外键,无物理 FK)。<br>
* 一个用户可绑定多个业务角色;一个角色也可绑定多个用户。<br>
*/
@NoArgsConstructor
@Data
@Entity
@Table(
name = "sys_user_app_role_rel",
indexes = {
@Index(name = "idx_user_app_role_user", columnList = "user_id"),
@Index(name = "idx_user_app_role_role", columnList = "role_id")
},
uniqueConstraints = {
@UniqueConstraint(name = "uk_user_app_role", columnNames = {"user_id", "role_id"})
}
)
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class SysUserAppRoleRel extends BaseEntity {
/**
* 用户ID(逻辑外键 → SysUser.id)。<br>
*/
@NotNull
@Column(name = "user_id", nullable = false)
private Integer userId;
/**
* 角色ID(逻辑外键 → SysAppRole.id)。<br>
*/
@NotNull
@Column(name = "role_id", nullable = false)
private Integer roleId;
}
@@ -0,0 +1,48 @@
package com.yunhai.userbase.entity;
import com.yunhai.userbase.common.validation.ValidationGroups;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import javax.validation.constraints.NotNull;
import java.time.LocalDateTime;
/**
* @author chenx
* @date 2026/7/15 15:05
* @description 用户当前角色
*/
@Data
@NoArgsConstructor
@Entity
@Table(
name = "sys_user_current_role",
uniqueConstraints = @UniqueConstraint(columnNames = {"userId"}),
indexes = {@Index(columnList = "userId")}
)
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class SysUserCurrentRole {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@NotNull(groups = ValidationGroups.Update.class)
private Integer id;
/**
* 用户ID,一对一唯一
*/
@Column(nullable = false)
private Integer userId;
/**
* 用户切换选中的学校ID(根级group)
*/
private Integer schoolId;
/**
* 用户切换后生效角色ID
*/
private Integer roleId;
}
@@ -0,0 +1,50 @@
package com.yunhai.userbase.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import javax.validation.constraints.NotNull;
import java.time.LocalDateTime;
/**
* @author chenx
* @date 2026/7/15 15:03
* @description 组用户多对多关联表
*/
@Data
@NoArgsConstructor
@Entity
@Table(name = "sys_user_group_rel")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class SysUserGroupRel {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@NotNull(groups = Update.class)
private Integer id;
/** 用户ID */
private Integer userId;
/** 分组ID */
private Integer groupId;
/** 是否主归属 1=本职主学校/部门 0=兼职兼任 */
private Integer isMain;
private LocalDateTime createTime;
// 关联用户,仅查询,不维护外键
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "userId", referencedColumnName = "id", insertable = false, updatable = false)
private SysUser user;
// 关联分组,仅查询,不维护外键
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "groupId", referencedColumnName = "id", insertable = false, updatable = false)
private SysGroup group;
public @interface Update {
}
}
@@ -0,0 +1,39 @@
package com.yunhai.userbase.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.Arrays;
import java.util.Optional;
/**
* @author chenx
* @date 2026/1/5 11:15
* @description 虚拟节点类型
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
public enum BizType {
ROOT (1),
TEXTBOOK (2),
GRADE (3),
KNOWLEDGE (4),
REALLY (5),
;
private Integer key;
public static BizType getByKey(Integer key){
BizType[] enums = BizType.values();
Optional<BizType> opt = Arrays.stream(enums).filter(item -> item.getKey().equals(key)).findAny();
if(opt.isPresent()){
return opt.get();
}else {
return null;
}
}
}
@@ -0,0 +1,50 @@
package com.yunhai.userbase.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.Arrays;
import java.util.Optional;
/**
* @author chenx
* @date 2026/1/16 9:32
* @description 资源类型
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
public enum BookType {
/** 教材 */
TextBook (1,"教材"),
/** 教辅 */
TeachingTutoring (2,"教辅"),
/** 知识点 */
KnowledgePoint (3,"知识点"),
;
private Integer key;
private String value;
public static BookType getByKey(Integer key){
BookType[] enums = BookType.values();
Optional<BookType> opt = Arrays.stream(enums).filter(item -> item.getKey().equals(key)).findAny();
if(opt.isPresent()){
return opt.get();
}else {
return null;
}
}
public static String getValue(Integer key) {
for (BookType bookType : BookType.values()) {
if (bookType.getKey().equals(key)) {
return bookType.getValue();
}
}
return null;
}
}
@@ -0,0 +1,35 @@
package com.yunhai.userbase.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author chenx
* @date 2026/07/13
* @description 客户端来源
*/
@Getter
@AllArgsConstructor
public enum ClientEnum implements IEnum<Integer> {
PC_BROWSER(1, "Pc浏览器"),
MOBILE_APP(2, "移动App"),
SYSTEM_MODULE(4, "系统模块"),
WECHAT_OFFICIAL(8, "微信公众号"),
WORK_WECHAT(16, "企业微信"),
DING_TALK(32, "钉钉"),
STUDENT_DEVICE(64, "学伴机");
private Integer key;
private String value;
@Override
public Integer getKey() {
return this.key;
}
@Override
public String getValue() {
return this.value;
}
}
@@ -0,0 +1,30 @@
package com.yunhai.userbase.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author chenx
* @date 2026/07/13
* @description 性别
*/
@Getter
@AllArgsConstructor
public enum GenderEnum implements IEnum<Integer> {
FEMALE(0, "女"),
MALE(1, "男");
private Integer key;
private String value;
@Override
public Integer getKey() {
return this.key;
}
@Override
public String getValue() {
return this.value;
}
}
@@ -0,0 +1,36 @@
package com.yunhai.userbase.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author chenx
* @date 2026/07/13
* @description 分组类型
*/
@Getter
@AllArgsConstructor
public enum GroupTypeEnum implements IEnum<Integer> {
NORMAL_GROUP(1, "普通组"),
SCHOOL_GROUP(2, "学校组"),
GRADE_GROUP(3, "年级组"),
CLASS_GROUP(4, "班级组"),
AREA_GROUP(5, "区域组"),
STAGE_GROUP(6, "学段组"),
TEACHER_GROUP(7, "教师组"),
STUDENT_GROUP(8, "学生组");
private Integer key;
private String value;
@Override
public Integer getKey() {
return this.key;
}
@Override
public String getValue() {
return this.value;
}
}
@@ -0,0 +1,65 @@
package com.yunhai.userbase.enums;
import com.yunhai.userbase.utils.EnumUtils;
import java.util.List;
import java.util.Map;
/**
* 统一枚举规范接口,仅定义契约getKey/getValue
* 内置静态工具方法,业务直接 IEnum.xxx(枚举类) 调用
* @param <T> key类型
*/
public interface IEnum<T> {
/** 获取编码key */
T getKey();
/** 获取中文展示value */
String getValue();
// ====================== 静态工具入口(统一对外调用) ======================
/** 获取枚举全部实例列表 */
static <E extends Enum<E> & IEnum<?>> List<E> toList(Class<E> clazz) {
return EnumUtils.toList(clazz);
}
/** 根据key获取枚举,无匹配返回null */
static <E extends Enum<E> & IEnum<?>> E getByKey(Class<E> clazz, Object key) {
return EnumUtils.getEnumByKey(clazz, key);
}
/** 根据key严格获取,不存在抛异常(对应fromKey) */
static <E extends Enum<E> & IEnum<?>> E fromKey(Class<E> clazz, Object key) {
return EnumUtils.fromKey(clazz, key);
}
/** 根据key获取中文value,无匹配返回空串 */
static <E extends Enum<E> & IEnum<?>> String getValueByKey(Class<E> clazz, Object key) {
return EnumUtils.getValue(clazz, key);
}
/** 根据中文value获取key字符串,无匹配返回空串 */
static <E extends Enum<E> & IEnum<?>> String getKeyByValue(Class<E> clazz, String value) {
return EnumUtils.getKey(clazz, value);
}
/** 判断key是否存在 */
static <E extends Enum<E> & IEnum<?>> boolean existsKey(Class<E> clazz, Object key) {
return EnumUtils.isExistKey(clazz, key);
}
/** 判断value是否存在 */
static <E extends Enum<E> & IEnum<?>> boolean existsValue(Class<E> clazz, String value) {
return EnumUtils.isExistValue(clazz, value);
}
/** 转完整Map列表(包含enum实例、name、key、value) */
static <E extends Enum<E> & IEnum<?>> List<Map<String, Object>> toFullMapList(Class<E> clazz) {
return EnumUtils.toMapList(clazz);
}
/** 转下拉简易Map列表,自定义key/value字段名 */
static <E extends Enum<E> & IEnum<?>> List<Map<String, Object>> toSimpleMapList(Class<E> clazz, String keyMapName, String valueMapName) {
return EnumUtils.toMapList(clazz, keyMapName, valueMapName);
}
}
@@ -0,0 +1,31 @@
package com.yunhai.userbase.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author chenx
* @date 2026/07/13
* @description 集成方式
*/
@Getter
@AllArgsConstructor
public enum IntegEnum implements IEnum<Integer> {
INNER(0, "内置"),
WEB(1, "Web"),
APP_JUMP(3, "App跳转");
private Integer key;
private String value;
@Override
public Integer getKey() {
return this.key;
}
@Override
public String getValue() {
return this.value;
}
}
@@ -0,0 +1,41 @@
package com.yunhai.userbase.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 操作日志级别。<br>
* 1-重要(删除) 2-次要(新增/编辑) 3-一般(查询)。<br>
*
* @author chenx
* @date 2026/7/28
*/
@Getter
@AllArgsConstructor
public enum LogLevel {
IMPORTANT(1, "重要(删除)"),
SECONDARY(2, "次要(新增/编辑)"),
GENERAL(3, "一般(查询)");
private final int code;
private final String desc;
/**
* 根据 code 反查级别。
*
* @param code 级别编码
* @return 对应级别,未知编码默认次要
*/
public static LogLevel fromCode(Integer code) {
if (code == null) {
return SECONDARY;
}
for (LogLevel level : values()) {
if (level.code == code) {
return level;
}
}
return SECONDARY;
}
}
@@ -0,0 +1,21 @@
package com.yunhai.userbase.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author chenx
* @date 2026/1/5 13:53
* @description 资源来源
*/
@AllArgsConstructor
@Getter
public enum ResourceSourceEnum {
ZIYUAN("ZIYUAN","资源库"),
LOCAL("LOCAL","平台资源库"),
;
private final String enKey;
private final String cnKey;
}
@@ -0,0 +1,49 @@
package com.yunhai.userbase.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.Arrays;
import java.util.Optional;
/**
* @author chenx
* @date 2025/12/15 13:29
* @description 根目录树类型
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
public enum RootNode {
ZIYUANKU (1,"ZIYUAN","资源"),
TUTORING (2,"TUTORING","辅导"),
QUESTION (3,"QUESTION","题库"),
SHIJUAN (4,"SHIJUAN","试卷"),
ZHUANXIANG (5,"ZHUANXIANG","专项"),
;
private Integer key;
private String enKey;
private String value;
public static RootNode getByKey(Integer key){
RootNode[] enums = RootNode.values();
Optional<RootNode> opt = Arrays.stream(enums).filter(item -> item.getKey().equals(key)).findAny();
if(opt.isPresent()){
return opt.get();
}else {
return null;
}
}
public static RootNode fromKey(Integer key) {
for (RootNode n : values()) if (n.key.equals(key)) return n;
throw new IllegalArgumentException("非法 RootNode key: " + key);
}
}
@@ -0,0 +1,66 @@
package com.yunhai.userbase.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.Arrays;
import java.util.Optional;
/**
* @author chenx
* @date 2025/12/15 15:55
* @description 学段
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
public enum SchoolStage {
PRIMARY (2,"PRIMARY","小学"),
MIDDLE (3,"MIDDLE","初中"),
HIGH (4,"HIGH","高中"),
;
private Integer key;
private String enKey;
private String value;
public static SchoolStage getByKey(Integer key){
SchoolStage[] enums = SchoolStage.values();
Optional<SchoolStage> opt = Arrays.stream(enums).filter(item -> item.getKey().equals(key)).findAny();
if(opt.isPresent()){
return opt.get();
}else {
return null;
}
}
public static String getName(Integer key) {
for (SchoolStage stage : SchoolStage.values()) {
if (stage.getKey().equals(key)) {
return stage.getValue();
}
}
return null;
}
public static SchoolStage getByValue(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
return Arrays.stream(values())
.filter(item -> item.getValue().equals(value))
.findAny()
.orElse(null);
}
public static SchoolStage fromKey(Integer key) {
for (SchoolStage n : values()) if (n.key.equals(key)) return n;
throw new IllegalArgumentException("非法 SchoolStage key: " + key);
}
}
@@ -0,0 +1,30 @@
package com.yunhai.userbase.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author chenx
* @date 2026/07/13
* @description 通用启用禁用状态
*/
@Getter
@AllArgsConstructor
public enum StatusEnum implements IEnum<Integer> {
NORMAL(0, "正常"),
DISABLED(1, "禁用");
private Integer key;
private String value;
@Override
public Integer getKey() {
return this.key;
}
@Override
public String getValue() {
return this.value;
}
}
@@ -0,0 +1,32 @@
package com.yunhai.userbase.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author chenx
* @date 2026/07/25
* @description 操作模式枚举(应用授权等场景:追加/覆盖/删除)
*/
@Getter
@AllArgsConstructor
public enum SysModeEnum implements IEnum<Integer> {
APPEND(0, "追加"),
OVERWRITE(1, "覆盖"),
DELETE(2, "删除");
private Integer key;
private String value;
@Override
public Integer getKey() {
return this.key;
}
@Override
public String getValue() {
return this.value;
}
}
@@ -0,0 +1,40 @@
package com.yunhai.userbase.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.Arrays;
import java.util.Optional;
/**
* @author chenx
* @date 2026/07/13
* @description 用户角色枚举
*/
@Getter
@AllArgsConstructor
public enum SysRoleEnum implements IEnum<Integer>{
TEACHER(1, "老师"),
SCHOOL_LEADER(2, "校领导"),
STUDENT(3, "学生"),
PARENT(4, "家长"),
BUREAU_LEADER(5, "局领导"),
WORK_ACCOUNT(6, "工作号"),
ADMIN(7, "管理员");
private Integer key;
private String value;
@Override
public Integer getKey() {
return this.key;
}
@Override
public String getValue() {
return this.value;
}
}
@@ -0,0 +1,38 @@
package com.yunhai.userbase.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* @ Description : java类作用描述
* @Author : cls
* @ CreateDate : 2020/2/21 18:39
* @ Company : bestsch
*/
@AllArgsConstructor
@NoArgsConstructor
@Getter
public enum SystemGrade {
one(1,"一年级",2),two(2,"二年级",2),
three(3,"三年级",2),four(4,"四年级",2),
five(5,"五年级",2),six(6,"六年级",2),
seven(7,"七年级",3),eight(8,"八年级",3),
nine(9,"九年级",3),ten(10,"高一",4),
eleven(11,"高二",4),twelve(12,"高三",4);
private Integer gradeId;
private String gradeName;
private Integer stage;
public static String getName(Integer gradeId) {
for (SystemGrade grade : SystemGrade.values()) {
if (grade.getGradeId().equals(gradeId)) {
return grade.gradeName;
}
}
return null;
}
}
@@ -0,0 +1,28 @@
package com.yunhai.userbase.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* @program: UserProfile
* @description: 用户类别
* @author: chenx
* @create: 2023-05-26 10:46
**/
@Getter
@NoArgsConstructor
@AllArgsConstructor
public enum UserType {
TEACHER (1,"教师","Teacher"),
STUDENT (2,"学生","Student"),
;
private Integer key;
private String value;
private String code;
}
@@ -0,0 +1,34 @@
package com.yunhai.userbase.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author chenx
* @date 2026/08/03
* @description 用户类型枚举
* - NORMAL(0) 普通用户:默认类型
* - SUPER_ADMIN(1) 超级管理员:系统内置种子账号(yunhaisch / yunhaisch2 / yunhaisch3),由初始化脚本写入
* - SCHOOL_ADMIN(2) 学校管理员:拥有管理身份(角色含 ADMIN(7))的普通账号(非超级管理员)
*/
@Getter
@AllArgsConstructor
public enum UserTypeEnum implements IEnum<Integer> {
NORMAL(0, "普通用户"),
SUPER_ADMIN(1, "超级管理员"),
SCHOOL_ADMIN(2, "学校管理员");
private Integer key;
private String value;
@Override
public Integer getKey() {
return this.key;
}
@Override
public String getValue() {
return this.value;
}
}
@@ -0,0 +1,34 @@
package com.yunhai.userbase.model;
/**
* @program: QualityEvaluate
* @description: 系统字典类
* @author: chenx
* @create: 2023-05-15 17:17
**/
public class BaseDict {
public Integer k;
public String v;
public int getK() {
return this.k;
}
public void setK(int k) {
this.k = k;
}
public String getV() {
return this.v;
}
public void setV(String v) {
this.v = v;
}
public BaseDict(Integer k, String v) {
this.k = k;
this.v = v;
}
}
@@ -0,0 +1,16 @@
package com.yunhai.userbase.model;
import lombok.Data;
/**
* @author chenx
* @date 2026/7/21 21:41
* @description
*/
@Data
public class BasePageQuery {
private int pageIndex = 1;
private int pageSize = 10;
/** 通用模糊搜索关键词 */
private String keyword;
}
@@ -0,0 +1,39 @@
package com.yunhai.userbase.model;
import lombok.Data;
import java.util.List;
/**
* 统一分页响应外壳。<br>
* 由 Controller 层负责装配(见 {@link com.yunhai.userbase.utils.PageUtils#toResult}),
* Service 层仍返回 Spring Data 的 {@link org.springframework.data.domain.Page},保持与传输协议解耦。<br>
*
* @author chenx
* @date 2026/7/21 21:40
* @description 统一分页响应外壳
*/
@Data
public class BasePageResult<T> {
/** 总记录数 */
private int total;
/** 当前页数据 */
private List<T> content;
/** 当前页码(从 1 起,与 {@link BasePageQuery#getPageIndex()} 约定一致) */
private int pageIndex;
/** 每页条数 */
private int pageSize;
/** 总页数 */
private int totalPages;
/** 是否还有下一页 */
private boolean hasNext;
public BasePageResult(List<T> content, int total, int pageIndex, int pageSize, int totalPages, boolean hasNext) {
this.content = content;
this.total = total;
this.pageIndex = pageIndex;
this.pageSize = pageSize;
this.totalPages = totalPages;
this.hasNext = hasNext;
}
}
@@ -0,0 +1,19 @@
package com.yunhai.userbase.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@AllArgsConstructor
@NoArgsConstructor
@Data
public class BaseTree {
public Integer id;
public String name;
public Integer pid ;
public Integer type;
public List<BaseTree> children;
}
@@ -0,0 +1,33 @@
package com.yunhai.userbase.model.Excel;
import cn.afterturn.easypoi.excel.annotation.Excel;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* @author chenx
* @date 2026/1/19 19:45
* @description 教材导入
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ExcelKnowledge {
@Excel(name = "第一层", width = 20)
private String first;
@Excel(name = "第二层", width = 20)
private String second;
@Excel(name = "第三层", width =20)
private String third;
@Excel(name = "第四层", width = 20)
private String fourth;
@Excel(name = "第五层", width = 20)
private String five;
}
@@ -0,0 +1,28 @@
package com.yunhai.userbase.model.Excel;
import cn.afterturn.easypoi.excel.annotation.Excel;
import lombok.Data;
/**
* @program: ResearchLearn
* @description: 导入研学活动填报实体类
* @author: chenx
* @create: 2023-07-21 14:12
**/
@Data
public class ExcelResearchFill {
@Excel(name = "年级")
private String gradeName;
@Excel(name = "班级")
private String className;
@Excel(name = "姓名")
private String stuName;
@Excel(name = "研学/活动名称")
private String researchName;
}
@@ -0,0 +1,29 @@
package com.yunhai.userbase.model;
import com.bestsch.openapi.UserSysRole;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
/**
*功能描述 :操作人信息
* @author chenx
* @date 2026/1/5 13:31
* @description
*/
@AllArgsConstructor
@NoArgsConstructor
@Data
public class Operator {
private Integer userId;
private Integer schoolSid;
private Integer roleId;
private Date createTime=new Date();
public static Operator of(UserSysRole curRole){
return new Operator(curRole.getUserId(),curRole.getSchId(),curRole.getRoleId(),new Date());
}
}
@@ -0,0 +1,72 @@
package com.yunhai.userbase.model;
import org.apache.commons.lang3.StringUtils;
public class Page<U> {
private int current;
private int pageSize;
private long count;
public Page() {
}
public int getCurrent() {
return this.current;
}
public void setCurrent(int current) {
this.current = current;
}
public int getPageSize() {
return this.pageSize == 0 ? 10 : this.pageSize;
}
public void setPageSize(int pageSize) {
this.pageSize = pageSize;
}
public void setPageSize(String pageSize) {
if (!StringUtils.isEmpty(pageSize) && StringUtils.isNumeric(pageSize)) {
this.pageSize = Integer.valueOf(pageSize);
}
}
public long getCount() {
return this.count;
}
public void setCount(long count) {
this.count = count;
}
public int getPageCount() {
return this.getPageSize() != 0 ? (int)Math.ceil((double)this.count / (double)this.getPageSize()) : 0;
}
public int getStartIndex() {
return (this.getCurrent() - 1) * this.getPageSize();
}
public void setCurrent(String current) {
if (!StringUtils.isEmpty(current) && StringUtils.isNumeric(current)) {
this.current = Integer.valueOf(current);
}
}
public void setCount(String count) {
if (!StringUtils.isEmpty(count) && StringUtils.isNumeric(count)) {
this.count = Long.valueOf(count);
}
}
public int getEndIndex() {
return this.getStartIndex() + this.getPageSize();
}
}
@@ -0,0 +1,16 @@
package com.yunhai.userbase.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class StartEndDate {
private Date startDate;
private Date endDate;
}
@@ -0,0 +1,18 @@
package com.yunhai.userbase.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@AllArgsConstructor
@NoArgsConstructor
@Data
public class Tree {
private String label;
private String value;
private Integer type = 0;
private List<Tree> children;
}
@@ -0,0 +1,37 @@
package com.yunhai.userbase.model.appauthz;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 应用授权查询的扁平投影行(含 categoryId 供分组)。<br>
* 仅取授权列表所需字段,避免加载 appSecret 等无关大字段。<br>
*
* @author chenx
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AppAuthzAppRow {
/**
* 应用类型ID(分组键)。<br>
*/
private Integer categoryId;
/**
* 应用ID。<br>
*/
private Integer id;
/**
* 应用名称。<br>
*/
private String name;
/**
* 应用图标(pcIcon)。<br>
*/
private String icon;
}
@@ -0,0 +1,48 @@
package com.yunhai.userbase.model.appauthz;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 应用授权视图:应用类型 + 该类型下的应用列表。<br>
*
* @author chenx
*/
@Data
public class AppAuthzCategoryVo {
/**
* 应用类型ID。<br>
*/
private Integer id;
/**
* 应用类型名称。<br>
*/
private String name;
/**
* 该类型下的应用列表。<br>
*/
private List<AppItem> app = new ArrayList<>();
/**
* 授权用的应用精简项(id/name/icon)。<br>
*/
@Data
public static class AppItem {
/**
* 应用ID。<br>
*/
private Integer id;
/**
* 应用名称。<br>
*/
private String name;
}
}
@@ -0,0 +1,13 @@
package com.yunhai.userbase.model.appcategory;
import lombok.Data;
/**
* @author chenx
* @date 2026/7/15 22:35
* @description
*/
@Data
public class AppCategoryQuery {
private String name;
}
@@ -0,0 +1,22 @@
package com.yunhai.userbase.model.appcategory;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import com.yunhai.userbase.common.validation.ValidationGroups;
import lombok.Data;
/**
* @author chenx
* @date 2026/7/15 22:35
* @description
*/
@Data
public class AppCategoryRequest {
@NotNull(groups = ValidationGroups.Update.class, message = "编辑时ID不能为空")
private Integer id;
@NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "类别名称不能为空")
private String name;
private Integer sortNo;
}
@@ -0,0 +1,15 @@
package com.yunhai.userbase.model.appcategory;
import lombok.Data;
/**
* @author chenx
* @date 2026/7/15 22:35
* @description
*/
@Data
public class AppCategoryVo {
private Integer id;
private String name;
private Integer sortNo;
}
@@ -0,0 +1,22 @@
package com.yunhai.userbase.model.application;
import lombok.Data;
/**
* 应用信息查询条件。<br>
* 用于列表查询时封装前端传入的筛选参数。<br>
*
* @author chenx
*/
@Data
public class ApplicationInfoQuery {
/**
* 应用名称,用于模糊匹配。<br>
*/
private String name;
/**
* 应用类别ID,用于精确筛选。<br>
*/
private Integer categoryId;
}
@@ -0,0 +1,76 @@
package com.yunhai.userbase.model.application;
import com.yunhai.userbase.common.validation.ValidationGroups;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.util.List;
/**
* 应用信息新增/修改入参。<br>
* 用于接收前端提交的应用数据,并按分组做参数校验。<br>
* 说明:appKey/appSecret 为系统生成字段,不在此入参中暴露。<br>
*
* @author chenx
*/
@Data
public class ApplicationInfoRequest {
/**
* 主键ID,编辑时必填。<br>
*/
@NotNull(groups = ValidationGroups.Update.class, message = "编辑时ID不能为空")
private Integer id;
/**
* 应用名称,新增与编辑均必填。<br>
*/
@NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "应用名称不能为空")
private String name;
/**
* 应用类别ID,新增与编辑均必填。<br>
*/
@NotNull(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "应用类别不能为空")
private Integer categoryId;
/**
* 应用描述。<br>
*/
private String desc;
/**
* 应用前端类型列表。<br>
*/
private List<Integer> clients;
/**
* PC应用访问地址。<br>
*/
private String pcUrl;
/**
* PC应用图标地址。<br>
*/
private String pcIcon;
/**
* App访问地址(移动端主地址)。<br>
*/
private String appUrl;
/**
* App应用图标地址。<br>
*/
private String appIcon;
/**
* 公众号应用地址。<br>
*/
private String officialAccountUrl;
/**
* 钉钉应用地址。<br>
*/
private String dingtalkUrl;
}
@@ -0,0 +1,34 @@
package com.yunhai.userbase.model.application;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 应用信息返回视图。<br>
* 用于接口返回给前端的应用数据。<br>
*
* @author chenx
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApplicationInfoSortVo {
/**
* 主键ID。<br>
*/
private Integer id;
/**
* 应用名称。<br>
*/
private String name;
/**
* 排序号,仅在同一个应用类型内有效;未排序时为 null。<br>
*/
private Integer sortNo;
}
@@ -0,0 +1,84 @@
package com.yunhai.userbase.model.application;
import lombok.Data;
import java.util.List;
/**
* 应用信息返回视图。
* 用于接口返回给前端的应用数据。
*
* @author chenx
*/
@Data
public class ApplicationInfoVo {
/**
* 主键ID。<br>
*/
private Integer id;
/**
* 应用名称。<br>
*/
private String name;
/**
* 应用类别ID。<br>
*/
private Integer categoryId;
/**
* 排序号,仅在同一个应用类型内有效;未排序时为 null。<br>
*/
private Integer sortNo;
/**
* 应用描述。<br>
*/
private String desc;
/**
* 系统生成的唯一标识(appKey),整型,从100000起自增。<br>
*/
private Integer appKey;
/**
* 密钥(appSecret),与数据库 app_secret 字段一致,字符串类型。<br>
*/
private String appSecret;
/**
* 应用前端类型列表。<br>
*/
private List<Integer> clients;
/**
* PC应用访问地址。<br>
*/
private String pcUrl;
/**
* PC应用图标地址。<br>
*/
private String pcIcon;
/**
* App访问地址。<br>
*/
private String appUrl;
/**
* App应用图标地址。<br>
*/
private String appIcon;
/**
* 公众号应用地址。<br>
*/
private String officialAccountUrl;
/**
* 钉钉应用地址。<br>
*/
private String dingtalkUrl;
}
@@ -0,0 +1,18 @@
package com.yunhai.userbase.model.appmodule;
import lombok.Data;
/**
* @author chenx
* @date 2026/7/22
* @description 应用模块查询条件。<br>
*/
@Data
public class AppModuleQuery {
/** 所属应用ID(按应用过滤模块树)。<br> */
private Integer appId;
/** 模块名称(模糊匹配)。<br> */
private String name;
/** 父模块ID(传入则以该模块为根,递归返回其完整子树;不传则顶层只显示 level-1 根模块)。<br> */
private Integer parentId;
}
@@ -0,0 +1,36 @@
package com.yunhai.userbase.model.appmodule;
import com.yunhai.userbase.common.validation.ValidationGroups;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
/**
* @author chenx
* @date 2026/7/22
* @description 应用模块请求体。<br>
*/
@Data
public class AppModuleRequest {
@NotNull(groups = ValidationGroups.Update.class, message = "编辑时ID不能为空")
private Integer id;
/** 所属应用ID(创建时必填,创建后不可改)。<br> */
@NotNull(groups = ValidationGroups.Create.class, message = "所属应用不能为空")
private Integer appId;
/** 父模块ID;根模块传 null。<br> */
private Integer parentId;
@NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "模块名称不能为空")
private String name;
@NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "自定义ID不能为空")
private String customId;
/** 模块路径(前端路由/资源路径)。<br> */
private String path;
private Integer sortNo;
}
@@ -0,0 +1,25 @@
package com.yunhai.userbase.model.appmodule;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* @author chenx
* @date 2026/7/22
* @description 应用模块视图(树形,children 递归存放子模块)。<br>
*/
@Data
public class AppModuleVo {
private Integer id;
private Integer appId;
private Integer parentId;
private String name;
private String customId;
private String path;
private Integer level;
private Integer sortNo;
/** 子模块(递归);默认空列表,避免 JSON 序列化出现 null。 */
private List<AppModuleVo> children = new ArrayList<>();
}
@@ -0,0 +1,34 @@
package com.yunhai.userbase.model.approle;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 角色授权页应用节点视图:应用信息 + 其下模块权限树。<br>
* 普通角色只返回其所属应用;全应用角色返回系统全部应用。<br>
*
* @author chenx
*/
@Data
public class AppRoleAuthzAppVo {
/** 应用ID。 */
private Integer id;
/** 应用名称。 */
private String name;
/** 应用唯一标识 appKey。 */
private Integer appKey;
/** PC 应用图标地址。 */
private String pcIcon;
/** 排序号。 */
private Integer sortNo;
/** 该应用下的模块权限树(含勾选回显)。 */
private List<AppRoleAuthzModuleVo> modules = new ArrayList<>();
}

Some files were not shown because too many files have changed in this diff Show More