commit
build / build-push (push) Successful in 50s

This commit is contained in:
wanghu
2026-10-10 15:42:52 +08:00
parent c738fd9339
commit 25147ec74e
181 changed files with 13549 additions and 0 deletions
@@ -0,0 +1,20 @@
package com.yunhai.workleave;
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.transaction.annotation.EnableTransactionManagement;
@SpringBootApplication(scanBasePackages = "com.yunhai.workleave")
@EnableTransactionManagement
@EnableAsync
public class WorkLeaveApplication {
public static void main(String[] args) {
// 开启 fastjson 安全模式
ParserConfig.getGlobalInstance().setSafeMode(true);
SpringApplication.run(WorkLeaveApplication.class, args);
}
}
@@ -0,0 +1,154 @@
package com.yunhai.workleave.aspect;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.workleave.enums.UserType;
import com.yunhai.workleave.model.UserSysRole;
import com.yunhai.workleave.utils.IpUtils;
import com.yunhai.workleave.utils.UserContext;
import com.yunhai.platform.auth.BoaUserContext;
import com.yunhai.platform.utils.model.BoaUser;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.annotation.Order;
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 java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
/**
* 当前用户上下文填充切面(移植自 UserclassService ControllerUserContextAspect)。
* <p>@Order(1) 先于审计日志切面(Order 3)执行,保证日志能取到 UserContext。
* 取用户优先级:方法参数 BoaUser → BoaUserContext 静态持有者 → request attribute;
* 角色切换信息从 currentrole(Header/Query,URL 编码 JSON)解析。</p>
*/
@Aspect
@Component
@Order(1)
public class ControllerUserContextAspect {
private static final Logger log = LoggerFactory.getLogger(ControllerUserContextAspect.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
/** 外部认证框架把解析好的 BoaUser 放进 request attribute 的候选 key */
private static final List<String> BOA_USER_ATTR_KEYS = Arrays.asList(
"boaUser", "boauser", "currentUser", "user", "authUser", "loginUser"
);
@Around("execution(* com.yunhai.workleave.controller..*.*(..))")
public Object setUserContext(ProceedingJoinPoint joinPoint) throws Throwable {
Object[] args = joinPoint.getArgs();
BoaUser boaUser = null;
UserSysRole userSysRole = null;
for (Object arg : args) {
if (arg instanceof BoaUser) {
boaUser = (BoaUser) arg;
} else if (arg instanceof UserSysRole) {
userSysRole = (UserSysRole) arg;
}
}
if (boaUser == null) {
try {
boaUser = BoaUserContext.get();
} catch (Throwable ignore) {
boaUser = null;
}
}
HttpServletRequest request = getCurrentHttpRequest();
if (request != null) {
if (boaUser == null) {
boaUser = resolveBoaUserFromRequest(request);
}
if (userSysRole == null) {
userSysRole = resolveUserSysRoleFromRequest(request);
}
}
populateUserContext(boaUser, userSysRole);
try {
return joinPoint.proceed();
} finally {
UserContext.clear();
log.debug("UserContext cleared for {}", joinPoint.getSignature().toShortString());
}
}
/** 从 currentrole(URL 编码,Header 或 Query 参数)解析 UserSysRole */
private UserSysRole resolveUserSysRoleFromRequest(HttpServletRequest request) {
String raw = request.getHeader("currentrole");
boolean fromQuery = false;
if (raw == null) {
raw = request.getParameter("currentrole");
fromQuery = true;
}
if (raw == null) {
return null;
}
try {
String decoded = fromQuery ? raw : URLDecoder.decode(raw, StandardCharsets.UTF_8.name());
return MAPPER.readValue(decoded, UserSysRole.class);
} catch (Exception e) {
log.warn("解析 currentrole 失败: {}", e.getMessage());
return null;
}
}
/** 从 request attribute(外部框架注入的 BoaUser)回退解析 */
private BoaUser resolveBoaUserFromRequest(HttpServletRequest request) {
for (String key : BOA_USER_ATTR_KEYS) {
Object attr = request.getAttribute(key);
if (attr instanceof BoaUser) {
return (BoaUser) attr;
}
}
return null;
}
private HttpServletRequest getCurrentHttpRequest() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return attributes != null ? attributes.getRequest() : null;
}
private void populateUserContext(BoaUser boaUser, UserSysRole userSysRole) {
if (boaUser != null) {
UserContext.setUserId(boaUser.getId());
UserContext.setUserName(boaUser.getName() != null ? boaUser.getName() : "unknown");
Integer schId = boaUser.getSchool() == null ? null : boaUser.getSchool().getId();
if (schId == null && userSysRole != null) {
schId = userSysRole.getSchId();
}
UserContext.setSchId(schId);
} else {
UserContext.setUserName("anonymous");
if (userSysRole != null && userSysRole.getSchId() != null) {
UserContext.setSchId(userSysRole.getSchId());
}
log.warn("No BoaUser found, using anonymous");
}
if (userSysRole != null) {
UserContext.setRoleId(userSysRole.getRoleId());
UserContext.setChildId(userSysRole.getChildId());
}
UserContext.setType(boaUser != null ? boaUser.getType() : null);
// 超级管理员(TYPE=1)不绑定具体学校,schId 统一置 0
if (UserType.SUPER_ADMINISTRATOR.getId().equals(UserContext.getType())) {
UserContext.setSchId(0);
}
UserContext.setIp(IpUtils.getClientIp());
}
}
@@ -0,0 +1,223 @@
package com.yunhai.workleave.aspect;
import com.alibaba.fastjson.JSON;
import com.yunhai.workleave.entity.SysOperationLog;
import com.yunhai.workleave.service.ISysOperationLogService;
import com.yunhai.workleave.utils.GzipUtil;
import com.yunhai.workleave.utils.IpUtils;
import com.yunhai.workleave.utils.UserContext;
import com.yunhai.platform.apistd.ApiResponse;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.exception.IErrorCode;
import com.yunhai.platform.utils.http.ApiResult;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
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.servlet.http.HttpSession;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 系统审计日志切面(移植自 UserclassService SysOperationLogAspect)。
* <p>全量拦截 controller 包所有方法,不依赖注解;排除日志查询控制器自身(防递归);
* 用户信息来自 {@link ControllerUserContextAspect}(Order 1)填充的 UserContext;
* 响应体 GZIP 压缩落库,落库走 @Async 不阻塞请求。</p>
*/
@Aspect
@Component
@Order(3)
public class SysOperationLogAspect {
private static final Logger log = LoggerFactory.getLogger(SysOperationLogAspect.class);
/** 排除的控制器(日志查询自身,防递归刷屏) */
private static final List<String> EXCLUDE_CONTROLLERS = java.util.Arrays.asList(
"com.yunhai.workleave.controller.api.LogController"
);
private static final int DEFAULT_MAX_RESPONSE_SIZE = 10240;
private static final int MAX_ERROR_MSG = 8000;
private static final int MAX_PARAMS = 2000;
@Autowired
private ISysOperationLogService sysOperationLogService;
@Around("execution(* com.yunhai.workleave.controller..*.*(..))")
public Object around(ProceedingJoinPoint point) throws Throwable {
String declType = point.getSignature().getDeclaringTypeName();
if (EXCLUDE_CONTROLLERS.contains(declType)) {
return point.proceed();
}
HttpServletRequest request = getRequest();
String httpMethod = request != null ? request.getMethod() : "";
long start = System.currentTimeMillis();
SysOperationLog entity = buildEntity(point, request, httpMethod);
Object result;
try {
result = point.proceed();
entity.setStatusCode(resolveSuccessCode(result));
fillResponse(entity, result, DEFAULT_MAX_RESPONSE_SIZE);
return result;
} catch (Throwable e) {
entity.setStatusCode(resolveErrorCode(e));
entity.setErrorMsg(buildErrorMsg(e));
throw e;
} finally {
entity.setCostTime(System.currentTimeMillis() - start);
sysOperationLogService.saveAsync(entity);
}
}
private SysOperationLog buildEntity(ProceedingJoinPoint point, HttpServletRequest request, String httpMethod) {
SysOperationLog e = new SysOperationLog();
e.setCreateTime(LocalDateTime.now());
// 用户信息(ControllerUserContextAspect Order 1 已填充)
e.setUserId(UserContext.getUserId());
e.setUserName(StringUtils.hasText(UserContext.getUserName()) ? UserContext.getUserName() : "anonymous");
e.setSchId(UserContext.getSchId());
e.setRequestIp(StringUtils.hasText(UserContext.getIp()) ? UserContext.getIp() : IpUtils.getClientIp());
String uri = request != null ? request.getRequestURI() : "";
String methodName = point.getSignature().getName();
e.setRequestUri(uri);
e.setRequestMethod(httpMethod);
e.setClassMethod(point.getTarget().getClass().getName() + "." + methodName);
e.setLogLevel(resolveLevel(httpMethod));
e.setOperationType(resolveOperationType(httpMethod, methodName));
if (request != null) {
e.setUserAgent(request.getHeader("User-Agent"));
}
e.setRequestParams(truncate(buildRequestParams(point, request), MAX_PARAMS));
return e;
}
private String buildRequestParams(ProceedingJoinPoint point, HttpServletRequest request) {
try {
if (request != null && "GET".equalsIgnoreCase(request.getMethod())) {
String qs = request.getQueryString();
if (StringUtils.hasText(qs)) {
return qs;
}
}
Object[] args = point.getArgs();
if (args == null || args.length == 0) {
return "";
}
List<Object> filtered = new ArrayList<>();
for (Object arg : args) {
if (arg == null) {
continue;
}
if (arg instanceof HttpServletRequest || arg instanceof HttpServletResponse
|| arg instanceof HttpSession || arg instanceof MultipartFile
|| arg instanceof MultipartFile[]) {
continue;
}
// BoaUser 等平台对象跳过序列化(无 toString 意义且可能循环引用)
if (arg.getClass().getName().startsWith("com.yunhai.platform.")) {
continue;
}
filtered.add(arg);
}
return filtered.isEmpty() ? "" : JSON.toJSONString(filtered);
} catch (Exception ex) {
return "";
}
}
private void fillResponse(SysOperationLog e, Object result, int max) {
if (result == null) {
return;
}
try {
String resp = JSON.toJSONString(result);
if (resp.length() > max) {
resp = resp.substring(0, max) + "...[截断]";
}
e.setResponseData(GzipUtil.compress(resp));
} catch (Exception ex) {
log.warn("审计日志响应序列化失败, uri={}", e.getRequestUri());
}
}
/** 成功路径:Controller 返回 ApiResponse/ApiResult 时取其 code,其余视为 SUCCESS(0) */
private int resolveSuccessCode(Object result) {
if (result instanceof ApiResponse) {
return ((ApiResponse<?>) result).getCode();
}
if (result instanceof ApiResult) {
return ((ApiResult<?>) result).getCode();
}
return ErrorCode.SUCCESS.getCode();
}
/** 异常路径:从业务异常提取错误码,兜底 INTERNAL_SERVER_ERROR */
private int resolveErrorCode(Throwable e) {
if (e instanceof DException) {
IErrorCode ec = ((DException) e).getCode();
if (ec != null) {
return ec.getCode();
}
}
return ErrorCode.INTERNAL_SERVER_ERROR.getCode();
}
private HttpServletRequest getRequest() {
ServletRequestAttributes a = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return a != null ? a.getRequest() : null;
}
private String buildErrorMsg(Throwable e) {
String msg = e.getMessage();
String head = (msg == null || msg.trim().isEmpty()) ? e.getClass().getName() : msg;
return truncate(head + System.lineSeparator() + ExceptionUtils.getStackTrace(e), MAX_ERROR_MSG);
}
private String truncate(String s, int max) {
return s != null && s.length() > max ? s.substring(0, max) : s;
}
/** 1-重要(删除) 2-次要(新增/编辑) 3-一般(查询) */
private int resolveLevel(String httpMethod) {
if ("DELETE".equalsIgnoreCase(httpMethod)) {
return 1;
}
if ("POST".equalsIgnoreCase(httpMethod) || "PUT".equalsIgnoreCase(httpMethod)) {
return 2;
}
return 3;
}
private String resolveOperationType(String httpMethod, String methodName) {
String lower = methodName == null ? "" : methodName.toLowerCase();
if ("DELETE".equalsIgnoreCase(httpMethod) || lower.startsWith("delete") || lower.startsWith("del")) {
return "DELETE";
}
if (lower.startsWith("create") || lower.startsWith("add")) {
return "CREATE";
}
if ("POST".equalsIgnoreCase(httpMethod) || "PUT".equalsIgnoreCase(httpMethod)
|| lower.startsWith("edit") || lower.startsWith("update") || lower.startsWith("set")) {
return "UPDATE";
}
return "QUERY";
}
}
@@ -0,0 +1,36 @@
package com.yunhai.workleave.common.listener;
import com.yunhai.workleave.entity.BaseEntity;
import com.yunhai.workleave.utils.UserContext;
import org.springframework.stereotype.Component;
import javax.persistence.PrePersist;
import javax.persistence.PreUpdate;
import java.time.LocalDateTime;
/**
* JPA 审计监听器:在实体持久化/更新前自动填充创建人/更新人与时间戳。
* <p>操作人取 {@link UserContext#getUserId()}——只存平台用户ID,不保存账号;
* 无用户上下文的线程(如流程回调、定时任务)为 null,不影响落库。</p>
*/
@Component
public class BaseAuditListener {
@PrePersist
public void prePersist(BaseEntity entity) {
LocalDateTime now = LocalDateTime.now();
entity.setCreateTime(now);
entity.setUpdateTime(now);
Integer userId = UserContext.getUserId();
if (entity.getCreateBy() == null) {
entity.setCreateBy(userId);
}
entity.setUpdateBy(userId);
}
@PreUpdate
public void preUpdate(BaseEntity entity) {
entity.setUpdateTime(LocalDateTime.now());
entity.setUpdateBy(UserContext.getUserId());
}
}
@@ -0,0 +1,10 @@
package com.yunhai.workleave.common.validation;
/**
* JSR-303 分组校验标记接口(对齐 UserBase 规范)。
* Create:新建场景必填字段;Update:编辑场景必填字段(通常含 id)。
*/
public interface ValidationGroups {
interface Create {}
interface Update {}
}
@@ -0,0 +1,11 @@
package com.yunhai.workleave.config;
/**
* 全局路径常量。与 UserBase 一致:API 统一挂在 /{platform}/ 之下(platform = api / manage / mobiapi)。
*/
public enum Config {
Inst;
public static final String API_PATH = "/{platform}"; // manage / api / mobiapi,前面有/后面无/,拼接子路径时用"/"分隔
public static final String OPEN_API_PATH = "/oapi";
}
@@ -0,0 +1,83 @@
package com.yunhai.workleave.config;
import com.yunhai.platform.auth.BoaUserContext;
import com.yunhai.platform.utils.model.BoaGroup;
import com.yunhai.platform.utils.model.BoaUser;
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 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 java.io.IOException;
/**
* 本地冒烟/联调辅助:向 BoaUserContext 注入模拟登录用户。
* <p>仅当 smoke.auth.enabled=true 时注册(默认关闭,线上不受影响)。
* 需配合关闭 CAS 拦截启动:--yh.sso.url-pattern=/__smoke_off__/*</p>
*/
@Configuration
@ConditionalOnProperty(prefix = "smoke.auth", name = "enabled", havingValue = "true")
public class DevMockUserFilter {
@Bean
public FilterRegistrationBean<Filter> mockUserFilter(DevMockUserProperties props) {
FilterRegistrationBean<Filter> registration = new FilterRegistrationBean<>();
registration.setFilter((request, response, chain) -> doFilter(props, request, response, chain));
registration.addUrlPatterns("/api/*", "/manage/*", "/mobiapi/*");
registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 10);
registration.setName("devMockUserFilter");
return registration;
}
private void doFilter(DevMockUserProperties props, ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
BoaUser user = new BoaUser();
user.setId(props.getUserId());
user.setName(props.getUserName());
user.setType(props.getUserType());
BoaGroup school = new BoaGroup();
school.setId(props.getSchId());
school.setName("冒烟测试学校");
user.setSchool(school);
BoaUserContext.set(user);
try {
chain.doFilter(request, response);
} finally {
BoaUserContext.clear();
}
if (request instanceof HttpServletRequest) {
org.slf4j.LoggerFactory.getLogger(DevMockUserFilter.class)
.debug("mock user {} applied for {}", props.getUserId(), ((HttpServletRequest) request).getRequestURI());
}
}
@org.springframework.boot.context.properties.ConfigurationProperties(prefix = "smoke.auth")
@Bean
public DevMockUserProperties devMockUserProperties() {
return new DevMockUserProperties();
}
/** 模拟用户参数(默认值见 application.yml smoke 段) */
public static class DevMockUserProperties {
private Integer userId = 1001;
private String userName = "冒烟测试教师";
private Integer schId = 1;
private Integer userType = 3;
public Integer getUserId() { return userId; }
public void setUserId(Integer userId) { this.userId = userId; }
public String getUserName() { return userName; }
public void setUserName(String userName) { this.userName = userName; }
public Integer getSchId() { return schId; }
public void setSchId(Integer schId) { this.schId = schId; }
public Integer getUserType() { return userType; }
public void setUserType(Integer userType) { this.userType = userType; }
}
}
@@ -0,0 +1,35 @@
package com.yunhai.workleave.config;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import java.time.format.DateTimeFormatter;
/**
* Jackson 时间序列化全局约定(对齐旧 .NET 版 "yyyy-MM-dd HH:mm:ss"):
* 接口出入参中的 LocalDateTime/LocalDate 统一使用该格式,避免 ISO 的 "T" 分隔符
* (如 2026-09-01T16:38:40)直接暴露给前端表格。
*/
@Configuration
public class JacksonConfig {
private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd");
@Bean
public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
return builder -> builder
.serializers(
new LocalDateTimeSerializer(DATE_TIME),
new LocalDateSerializer(DATE))
.deserializers(
new LocalDateTimeDeserializer(DATE_TIME),
new LocalDateDeserializer(DATE));
}
}
@@ -0,0 +1,83 @@
package com.yunhai.workleave.config;
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 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.HttpServletRequestWrapper;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* 本地联调辅助:流程中心入站 oapi 调用(appKey=100019 签名)被 AppAuthFilter 401 拦截的绕行。
* <p>背景:应用间签名的密钥链在 160 infra Redis(容器网内),开发机不可达,本地无法校验
* 流程中心的 X-App-Key/X-Timestamp/X-Signature,覆盖两个入站端点:</p>
* <ul>
* <li>/oapi/flow/callback——回调接收端(starter),剥头后另有 yh-flow-callback 的
* X-Flw 验签(登记下发的 secret)兜底;</li>
* <li>/oapi/flow/dept-judge——条件路由判断端点(CONDITION 节点同步回调),纯读无副作用,
* 无第二层验签,仅本地开放(base 配置 enforce-patterns=[/oapi/*],线上签名强校验不受影响)。</li>
* </ul>
* <p>仅当 yh.app-auth.local-callback-bypass=true 时注册(默认关闭,线上不配即不生效)。</p>
*/
@Configuration
@ConditionalOnProperty(prefix = "yh.app-auth", name = "local-callback-bypass", havingValue = "true")
public class LocalCallbackBypassConfig {
private static final Set<String> STRIPPED = Collections.unmodifiableSet(
new HashSet<>(Arrays.asList("x-app-key", "x-timestamp", "x-signature")));
/** AppAuthFilter 的注册序是 HIGHEST_PRECEDENCE+10,本过滤器须更靠前才能先剥头 */
@Bean
public FilterRegistrationBean<Filter> localCallbackHeaderStripFilter() {
FilterRegistrationBean<Filter> registration = new FilterRegistrationBean<>();
registration.setFilter((request, response, chain) -> {
if (request instanceof HttpServletRequest) {
chain.doFilter(new HeaderStrippingRequest((HttpServletRequest) request), response);
} else {
chain.doFilter(request, response);
}
});
registration.addUrlPatterns("/oapi/flow/callback", "/oapi/flow/dept-judge");
registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 5);
registration.setName("localCallbackHeaderStripFilter");
return registration;
}
/** 剥掉应用间签名三头后,AppAuthFilter 视为无签名请求直接放行 */
private static class HeaderStrippingRequest extends HttpServletRequestWrapper {
HeaderStrippingRequest(HttpServletRequest request) {
super(request);
}
private boolean stripped(String name) {
return name != null && STRIPPED.contains(name.toLowerCase());
}
@Override
public String getHeader(String name) {
return stripped(name) ? null : super.getHeader(name);
}
@Override
public Enumeration<String> getHeaders(String name) {
return stripped(name) ? Collections.emptyEnumeration() : super.getHeaders(name);
}
@Override
public Enumeration<String> getHeaderNames() {
List<String> names = Collections.list(super.getHeaderNames());
names.removeIf(this::stripped);
return Collections.enumeration(names);
}
}
}
@@ -0,0 +1,22 @@
package com.yunhai.workleave.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 接口文档(springdoc-openapi,替代旧 Springfox)。
*/
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI noticeOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("请假申请接口文档")
.description("请假申请子应用:申请、审核(流程中心)、抄送、时长、统计(springdoc-openapi)")
.version("1.0"));
}
}
@@ -0,0 +1,177 @@
package com.yunhai.workleave.controller.api;
import com.yunhai.workleave.config.Config;
import com.yunhai.workleave.model.UserSysRole;
import com.yunhai.workleave.model.bsch.ClassTreeNodeVo;
import com.yunhai.workleave.model.base.SetRoleRequest;
import com.yunhai.workleave.model.base.SetRoleResult;
import com.yunhai.workleave.model.file.UpFileResultVo;
import com.yunhai.workleave.service.IClassService;
import com.yunhai.workleave.service.IFileService;
import com.yunhai.workleave.service.IUserCenterService;
import com.yunhai.workleave.utils.UserContext;
import com.yunhai.platform.auth.LoginUser;
import com.yunhai.platform.auth.UserContextHolder;
import com.yunhai.platform.utils.model.BoaUser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
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 org.springframework.web.multipart.MultipartFile;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 平台代理域接口(base/)。
* <p>承载:GetUserInfo / GetUserMod / GetSchoolAll / GetClassTree / SetRole / FileUpload / Logout。
* 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。</p>
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/base/")
public class BaseApiController {
private static final Logger log = LoggerFactory.getLogger(BaseApiController.class);
/** 家长角色ID(UserRole.PATRIARCH_USER),用于 GetUserInfo 的 role 判定 */
private static final Integer ROLE_PARENT = 4;
@Autowired
private IUserCenterService userCenterService;
@Autowired
private IFileService fileService;
@Autowired
private IClassService classService;
// ============================ FileUpload ============================
/**
* 附件上传(单文件)。
* 返回: { fileid, host, fileurl, name, type, size }
*/
@PostMapping("FileUpload")
public UpFileResultVo fileUpload(@RequestParam("file") MultipartFile file) {
return fileService.uploadToResult(file, "leave");
}
// ============================ GetUserInfo ============================
/**
* 获取当前登录用户信息。
* 返回 {id, name, role: teacher|parent, profilePicture, school: {id, name}, currentRole}
*/
@GetMapping("GetUserInfo")
public Map<String, Object> getUserInfo(BoaUser authUserWithDept) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("id", authUserWithDept.getId());
result.put("name", authUserWithDept.getName());
UserSysRole role = currentRole(authUserWithDept);
boolean parent = isParent(role);
result.put("role", parent ? "parent" : "teacher");
result.put("profilePicture", "");
Map<String, Object> school = new LinkedHashMap<>();
if (authUserWithDept.getSchool() != null) {
school.put("id", authUserWithDept.getSchool().getId());
school.put("name", authUserWithDept.getSchool().getName());
}
result.put("school", school);
result.put("currentRole", role);
return result;
}
// ============================ GetUserMod ============================
/**
* 获取当前用户已授权的模块列表(左侧菜单树)。
*/
@GetMapping("GetUserMod")
public List<Map<String, Object>> getUserMod(BoaUser authUserWithDept) {
Integer schId = schIdOf(authUserWithDept);
return userCenterService.getUserModuleTree(authUserWithDept.getId(), schId);
}
// ============================ GetSchoolAll ============================
/**
* 获取当前用户学校组及教师(树形结构,选人数据源)。
*/
@GetMapping("GetSchoolAll")
public List<Map<String, Object>> getSchoolAll(BoaUser authUserWithDept) {
return userCenterService.getSchoolAllTree(schIdOf(authUserWithDept));
}
// ============================ GetClassTree ============================
/**
* 年级→班级树(叶子=班级,附学生数)。数据来自班级服务学生树(5 分钟缓存)。
*/
@GetMapping("GetClassTree")
public List<ClassTreeNodeVo> getClassTree(BoaUser authUserWithDept) {
return classService.getClassTree(schIdOf(authUserWithDept));
}
// ============================ SetRole ============================
/**
* 角色切换(teacher / parent)。会话身份由平台 CAS 管理,此处回显确认。
*/
@PostMapping("SetRole")
public SetRoleResult setRole(@RequestBody SetRoleRequest request) {
String role = request != null && "parent".equalsIgnoreCase(request.getAs()) ? "parent" : "teacher";
return new SetRoleResult(role);
}
// ============================ Logout ============================
/**
* 退出登录。认证由平台网关处理,此处仅返回成功。
*/
@PostMapping("Logout")
public void logout() {
LoginUser user = UserContextHolder.get();
log.info("[logout] 用户退出登录: {}", user == null ? null : user.getLoginName());
}
// ============================ 内部方法 ============================
private Integer schIdOf(BoaUser user) {
if (user.getSchool() != null) {
return user.getSchool().getId();
}
return UserContext.getSchId();
}
private UserSysRole currentRole(BoaUser user) {
UserSysRole role = new UserSysRole();
if (user.getSchool() != null) {
role.setSchId(user.getSchool().getId());
}
role.setUserId(user.getId());
if (user.getRoleIds() != null && !user.getRoleIds().isEmpty()) {
role.setRoleId(user.getRoleIds().get(0));
}
// childId 由 ControllerUserContextAspect 填充进 UserContext
Integer childId = UserContext.getChildId();
role.setChildId(childId == null ? 0 : childId);
return role;
}
/** 家长判定:角色=家长(4) 或带有效 childId */
private boolean isParent(UserSysRole role) {
if (role == null) {
return false;
}
if (ROLE_PARENT.equals(role.getRoleId())) {
return true;
}
return role.getChildId() != null && role.getChildId() > 0;
}
}
@@ -0,0 +1,71 @@
package com.yunhai.workleave.controller.api;
import com.yunhai.workleave.config.Config;
import com.yunhai.workleave.service.IContactsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
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;
import java.util.Map;
/**
* 自定义联系组接口(contacts/)。
* <p>路径与方法名对齐前端 settingApi;POST form 参数只从 query 接收;
* 成员只存教师平台用户ID,不存账号。</p>
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/contacts/")
public class ContactsController {
@Autowired
private IContactsService contactsService;
/** 联系组分页:{list:[{serid, GroupName, count}], total} */
@GetMapping("GetGroup")
public Map<String, Object> getGroup(@RequestParam(value = "page", defaultValue = "1") int page,
@RequestParam(value = "count", defaultValue = "10") int count) {
return contactsService.getGroups(page, count);
}
/** 新增组,返回组ID */
@PostMapping("AddGroup")
public Integer addGroup(@RequestParam("name") String name) {
return contactsService.addGroup(name);
}
/** 编辑组名 */
@PostMapping("EditGroup")
public void editGroup(@RequestParam("id") Integer id, @RequestParam("name") String name) {
contactsService.editGroup(id, name);
}
/** 批量删除组(ids 逗号串) */
@PostMapping("DelGroup")
public void delGroup(@RequestParam("ids") String ids) {
contactsService.deleteGroups(ids);
}
/** 组成员分页(page=0 取全部):{list:[{id, name}], total} */
@GetMapping("GetContactsTea")
public Map<String, Object> getContactsTea(@RequestParam("gid") Integer gid,
@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "count", defaultValue = "10") int count) {
return contactsService.getContactsTea(gid, page, count);
}
/** 设置组成员(全量覆盖,ids 逗号串) */
@PostMapping("SetContactsTea")
public void setContactsTea(@RequestParam("gid") Integer gid, @RequestParam("ids") String ids) {
contactsService.setContactsTea(gid, ids);
}
/** 学校组织树(选人数据源) */
@GetMapping("GetGroupTree")
public List<Map<String, Object>> getGroupTree(@RequestParam(value = "type", required = false) Integer type) {
return contactsService.getGroupTree();
}
}
@@ -0,0 +1,89 @@
package com.yunhai.workleave.controller.api;
import com.yunhai.workleave.config.Config;
import com.yunhai.workleave.service.IDictService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
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;
import java.util.Map;
/**
* 系统字典接口(请假类型 leaveType)。
* <p>List 兼容 type=1/2 数字与 leaveType 字符串两种入参(历史前端沿用数字);
* POST form 参数只从 query 接收(与前端 postForm 契约一致)。</p>
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/dict/")
public class DictController {
@Autowired
private IDictService dictService;
/** 请假类型下拉(启用项),每项 {k, v} */
@GetMapping("List")
public List<Map<String, Object>> list(@RequestParam(value = "type", defaultValue = "leaveType") String type) {
return dictService.getSysDic(parseType(type));
}
/** 字典管理列表(全量含禁用),每项 {SerID, CodeID, Name, Des, Enable} */
@GetMapping("ManageList")
public List<Map<String, Object>> manageList(@RequestParam(value = "type", defaultValue = "leaveType") String type) {
return dictService.getDictMangeList(type);
}
/** 新增字典项(服务端按 (schId, type, name) 查重,防文字重复) */
@PostMapping("Add")
public void add(@RequestParam("name") String name,
@RequestParam(value = "type", defaultValue = "leaveType") String type) {
dictService.addDict(name, type);
}
/** 更新字典项(名称/启停用) */
@PostMapping("Update")
public void update(@RequestParam("SerID") Integer serID,
@RequestParam("Name") String name,
@RequestParam(value = "Enable", required = false, defaultValue = "1") Integer enable,
@RequestParam(value = "type", defaultValue = "leaveType") String type) {
dictService.updateDict(serID, name, enable, type);
}
/** 删除字典项(service 侧校验被请假单引用时拒绝) */
@PostMapping("Delete")
public void delete(@RequestParam("serid") Integer serID,
@RequestParam(value = "type", defaultValue = "leaveType") String type) {
dictService.deleteDict(serID, type);
}
/** 排序(ids = 前端顺序的 SerID 逗号串) */
@PostMapping("Sort")
public void sort(@RequestParam("ids") String ids,
@RequestParam(value = "type", defaultValue = "leaveType") String type) {
dictService.sortDict(ids, type);
}
/** 短信模块开关(前端 getSchModuleIsSms) */
@GetMapping("Sync")
public Boolean sync() {
return dictService.getSchModuleIsSms();
}
/** 数字入参一律归一为 leaveType(兼容历史 1/2) */
private Integer parseType(String type) {
if (type == null) {
return null;
}
switch (type) {
case "1":
case "2":
case "leaveType":
return 1;
default:
return -1;
}
}
}
@@ -0,0 +1,74 @@
package com.yunhai.workleave.controller.api;
import com.yunhai.workleave.config.Config;
import com.yunhai.workleave.model.file.UpFileResultVo;
import com.yunhai.workleave.model.file.UpFileVo;
import com.yunhai.workleave.service.IFileService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
/**
* 附件域接口(file/)。topicType 统一挂 topicType=leave。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/file/")
public class FileController {
private static final String TOPIC_TYPE = "leave";
@Autowired
private IFileService fileService;
/** 附件上传(单文件),返回 { fileid, host, fileurl, name, type, size } */
@PostMapping("Upload")
public UpFileResultVo upload(@RequestParam("file") MultipartFile file) {
return fileService.uploadToResult(file, TOPIC_TYPE);
}
/** 图片上传(多文件) */
@PostMapping("UploadImage")
public List<UpFileVo> uploadImage(@RequestParam("file") MultipartFile[] files) {
return fileService.uploadImages(files, TOPIC_TYPE);
}
/** 文件下载(浏览器直接 location.href 跳转) */
@GetMapping("Down")
public void down(@RequestParam("id") Integer id, HttpServletResponse response) {
fileService.download(id, response);
}
/** 文件在线预览(inline 输出) */
@GetMapping("Preview")
public void preview(@RequestParam("id") Integer id,
@RequestParam(value = "type", required = false) Integer type,
HttpServletResponse response) {
fileService.preview(id, response);
}
/** 删除文件 */
@PostMapping("Delete")
public void delete(@RequestParam("fileId") Integer fileId) {
fileService.delete(fileId);
}
/** 查询某请假单的附件列表 */
@GetMapping("List")
public List<UpFileVo> list(@RequestParam("topicId") Integer topicId) {
return fileService.listByTopic(TOPIC_TYPE, topicId);
}
/** 批量绑定附件到活动 */
@PostMapping("Bind")
public void bind(@RequestParam("fileIds") List<Integer> fileIds,
@RequestParam("topicId") Integer topicId) {
fileService.bindToTopic(fileIds, topicId);
}
}
@@ -0,0 +1,154 @@
package com.yunhai.workleave.controller.api;
import com.yunhai.workleave.config.Config;
import com.yunhai.workleave.model.BasePageResult;
import com.yunhai.workleave.model.leave.LeaveAuditRequest;
import com.yunhai.workleave.model.leave.LeaveCancelRequest;
import com.yunhai.workleave.model.leave.LeaveDetailVo;
import com.yunhai.workleave.model.leave.LeaveQuery;
import com.yunhai.workleave.model.leave.LeaveRequest;
import com.yunhai.workleave.model.leave.LeaveStatsVo;
import com.yunhai.workleave.model.leave.LeaveVo;
import com.yunhai.workleave.model.leave.TodoVo;
import com.yunhai.workleave.model.leave.UserTimeSumVo;
import com.yunhai.workleave.service.ILeaveApplyService;
import com.yunhai.workleave.utils.UserContext;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.springframework.beans.factory.annotation.Autowired;
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 javax.validation.Valid;
import java.util.List;
/**
* 请假申请域接口(leave/):申请 / 列表 / 详情 / 撤回 / 销假 / 待办 / 审核 / 统计。
* <p>审核流转全部由平台流程审核器承担;本控制器不直接改单据状态,
* 状态由 {@link com.yunhai.workleave.flow.LeaveFlowCallbackHandler} 按回调推进。</p>
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/leave/")
public class LeaveController {
@Autowired
private ILeaveApplyService leaveApplyService;
// ============================ 列表 / 详情 ============================
/** 请假列表:scope=user 我的 / all 全部 / cc 抄送我的 */
@GetMapping("List")
public BasePageResult<LeaveVo> list(LeaveQuery query) {
return leaveApplyService.list(schId(), userId(), query);
}
/** 请假详情(消息跳转落地页/详情弹窗) */
@GetMapping("Detail")
public LeaveDetailVo detail(@RequestParam("leaveId") Integer leaveId) {
return leaveApplyService.detail(schId(), userId(), leaveId);
}
// ============================ 申请 / 编辑 / 删除 ============================
/** 新建申请(JSON):返回单据ID */
@PostMapping("Create")
public Integer create(@RequestBody @Valid LeaveRequest request) {
return leaveApplyService.create(schId(), userId(), userName(), request);
}
/** 编辑/重新提交(JSON,仅审批中/已拒绝/已撤回) */
@PostMapping("Edit")
public void edit(@RequestBody @Valid LeaveRequest request) {
leaveApplyService.edit(schId(), userId(), userName(), request);
}
/** 删除(JSON 数组:仅本人,已通过不能删) */
@PostMapping("Delete")
public void delete(@RequestBody List<Integer> ids) {
leaveApplyService.delete(schId(), userId(), ids);
}
// ============================ 撤回 / 销假 ============================
/** 撤回(审批中) */
@PostMapping("Withdraw")
public void withdraw(@RequestParam("id") Integer id) {
leaveApplyService.withdraw(schId(), userId(), id);
}
/** 销假(已通过 → 已销假,返还时长) */
@PostMapping("Cancel")
public void cancel(@RequestBody @Valid LeaveCancelRequest request) {
leaveApplyService.cancel(schId(), userId(), request);
}
// ============================ 待办 / 审核 ============================
/** 待我审核列表 */
@GetMapping("TodoList")
public BasePageResult<TodoVo> todoList(@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "startTime", required = false) String startTime,
@RequestParam(value = "endTime", required = false) String endTime,
@RequestParam(value = "pageIndex", defaultValue = "1") int pageIndex,
@RequestParam(value = "pageSize", defaultValue = "10") int pageSize) {
return leaveApplyService.todoList(schId(), userId(), keyword, startTime, endTime, pageIndex, pageSize);
}
/** 我已审核列表 */
@GetMapping("AuditedList")
public BasePageResult<LeaveVo> auditedList(@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "startTime", required = false) String startTime,
@RequestParam(value = "endTime", required = false) String endTime,
@RequestParam(value = "pageIndex", defaultValue = "1") int pageIndex,
@RequestParam(value = "pageSize", defaultValue = "10") int pageSize) {
return leaveApplyService.auditedList(schId(), userId(), keyword, startTime, endTime, pageIndex, pageSize);
}
/** 审核(同意/拒绝,JSON);状态由回调推进 */
@PostMapping("Audit")
public void audit(@RequestBody @Valid LeaveAuditRequest request) {
leaveApplyService.audit(schId(), userId(), userName(), request);
}
// ============================ 余额 / 统计 ============================
/** 表单页余额提示(可用 - 在途) */
@GetMapping("MyTime")
public UserTimeSumVo myTime() {
return leaveApplyService.myTime(schId(), userId());
}
/** 统计分析(区间按申请时间,口径:已通过) */
@GetMapping("Statis")
public LeaveStatsVo statis(@RequestParam(value = "startTime", required = false) String startTime,
@RequestParam(value = "endTime", required = false) String endTime) {
return leaveApplyService.statistics(schId(), startTime, endTime);
}
// ============================ 内部方法 ============================
private Integer schId() {
Integer schId = UserContext.getSchId();
if (schId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败");
}
return schId;
}
private Integer userId() {
Integer userId = UserContext.getUserId();
if (userId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "获取当前用户信息失败");
}
return userId;
}
private String userName() {
String userName = UserContext.getUserName();
return userName == null ? "" : userName;
}
}
@@ -0,0 +1,76 @@
package com.yunhai.workleave.controller.api;
import com.yunhai.workleave.config.Config;
import com.yunhai.workleave.model.leave.FlowBindRequest;
import com.yunhai.workleave.model.leave.FlowEnableRequest;
import com.yunhai.workleave.model.leave.FlowManageVo;
import com.yunhai.workleave.service.ILeaveFlowBindService;
import com.yunhai.workleave.utils.UserContext;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.springframework.beans.factory.annotation.Autowired;
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 javax.validation.Valid;
/**
* 流程管理域接口(leave/flow/):流程列表 / 绑定 / 解绑。
* <p>对齐《子应用流程与消息对接手册 v1.0》:
* 流程在流程中心「流程编排」注册,本应用只记录选用哪条 flowGuid(bizType 恒为 leave)。</p>
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/leave/flow/")
public class LeaveFlowController {
@Autowired
private ILeaveFlowBindService flowBindService;
/** 流程管理页数据:流程中心列表 + 本校绑定关系 + 请假类型 */
@GetMapping("List")
public FlowManageVo list() {
return flowBindService.manage(schId());
}
/** 绑定流程(按 scope + scopeKey upsert,并触发回调登记) */
@PostMapping("Bind")
public void bind(@RequestBody @Valid FlowBindRequest request) {
flowBindService.bind(schId(), request);
}
/**
* 启用绑定:把流程标记为本应用可用(并回调登记到流程中心)。
* <p>启用后才允许设为默认 / 按请假类型绑定。与绑定范围无关,不校验 scope。</p>
*/
@PostMapping("Enable")
public void enable(@RequestBody @Valid FlowEnableRequest request) {
flowBindService.enable(schId(), request);
}
/**
* 关闭启用:移除启用标记;若该流程已被设为默认 / 按类型绑定,一并解除。
*/
@PostMapping("Disable")
public void disable(@RequestParam("flowGuid") String flowGuid) {
flowBindService.disable(schId(), flowGuid);
}
/** 解除绑定:scope=0 学校默认(scopeKey 可空)/ scope=1 按请假类型(scopeKey 必填) */
@PostMapping("Unbind")
public void unbind(@RequestParam("scope") Integer scope,
@RequestParam(value = "scopeKey", required = false) String scopeKey) {
flowBindService.unbind(schId(), scope, scopeKey);
}
private Integer schId() {
Integer schId = UserContext.getSchId();
if (schId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败");
}
return schId;
}
}
@@ -0,0 +1,92 @@
package com.yunhai.workleave.controller.api;
import com.yunhai.workleave.config.Config;
import com.yunhai.workleave.model.leave.LeaveSettingVo;
import com.yunhai.workleave.model.leave.SectionVo;
import com.yunhai.workleave.model.leave.TimeSetVo;
import com.yunhai.workleave.service.ILeaveSettingService;
import com.yunhai.workleave.utils.UserContext;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.springframework.beans.factory.annotation.Autowired;
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;
/**
* 请假设置域接口(setting/):作息时间 / 其他设置 / 节次。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/leave/setting/")
public class LeaveSettingController {
@Autowired
private ILeaveSettingService settingService;
// ============================ 作息时间 ============================
/** 学校作息时间 */
@GetMapping("GetTimeSet")
public TimeSetVo getTimeSet() {
return settingService.getTimeSet(schId());
}
/** 保存作息时间(JSON) */
@PostMapping("SetTimeSet")
public void setTimeSet(@RequestBody TimeSetVo vo) {
settingService.saveTimeSet(schId(), vo);
}
// ============================ 其他设置 ============================
/** 其他设置 */
@GetMapping("GetOption")
public LeaveSettingVo getOption() {
return settingService.getOption(schId());
}
/** 保存其他设置(JSON) */
@PostMapping("SetOption")
public void setOption(@RequestBody LeaveSettingVo vo) {
settingService.saveOption(schId(), vo);
}
// ============================ 节次 ============================
/** 节次列表 */
@GetMapping("SectionList")
public List<SectionVo> sectionList() {
return settingService.sectionList(schId());
}
/** 新增/编辑节次(JSON) */
@PostMapping("SectionSave")
public void sectionSave(@RequestBody SectionVo vo) {
settingService.sectionSave(schId(), vo);
}
/** 删除节次 */
@PostMapping("SectionDelete")
public void sectionDelete(@RequestParam("id") Integer id) {
settingService.sectionDelete(schId(), id);
}
/** 节次排序(ids 逗号串,按前端展示顺序) */
@PostMapping("SectionSort")
public void sectionSort(@RequestParam("ids") String ids) {
settingService.sectionSort(schId(), ids);
}
private Integer schId() {
Integer schId = UserContext.getSchId();
if (schId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败");
}
return schId;
}
}
@@ -0,0 +1,100 @@
package com.yunhai.workleave.controller.api;
import com.yunhai.workleave.config.Config;
import com.yunhai.workleave.model.BasePageResult;
import com.yunhai.workleave.model.leave.UserTimeAdjustRequest;
import com.yunhai.workleave.model.leave.UserTimeImportResult;
import com.yunhai.workleave.model.leave.UserTimeRecordVo;
import com.yunhai.workleave.model.leave.UserTimeSumVo;
import com.yunhai.workleave.model.leave.UserTimeVo;
import com.yunhai.workleave.service.ILeaveUserTimeService;
import com.yunhai.workleave.utils.UserContext;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.springframework.beans.factory.annotation.Autowired;
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 org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import java.net.URLEncoder;
import java.util.List;
/**
* 教师时长域接口(leave/time/):列表 / 调整 / 流水 / 合计。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/leave/time/")
public class LeaveTimeController {
@Autowired
private ILeaveUserTimeService userTimeService;
/** 教师时长列表 */
@GetMapping("UserTimeList")
public List<UserTimeVo> userTimeList(@RequestParam(value = "keyword", required = false) String keyword) {
return userTimeService.list(schId(), keyword);
}
/** 批量加减时长(JSON) */
@PostMapping("Adjust")
public void adjust(@RequestBody @Valid UserTimeAdjustRequest request) {
userTimeService.adjust(schId(), UserContext.getUserId(), request.getItems());
}
/** 下载「教师时长导入模板」(浏览器 location.href 直接跳转) */
@GetMapping("ImportTemplate")
public void importTemplate(HttpServletResponse response) {
byte[] data = userTimeService.importTemplate(schId());
try {
response.setContentType("application/octet-stream");
String name = URLEncoder.encode("教师时长导入模板.xlsx", "UTF-8").replaceAll("\\+", "%20");
response.setHeader("Content-Disposition", "attachment; filename=\"" + name + "\"");
response.getOutputStream().write(data);
} catch (Exception e) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "模板下载失败");
}
}
/** 教师时长 Excel 导入(任一行出错整体不入账,返回错误明细) */
@PostMapping("Import")
public UserTimeImportResult importExcel(@RequestParam("file") MultipartFile file) throws java.io.IOException {
if (file == null || file.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "请选择要上传的文件");
}
return userTimeService.importExcel(schId(), UserContext.getUserId(), file.getInputStream());
}
/** 时长流水(分页) */
@GetMapping("Records")
public BasePageResult<UserTimeRecordVo> records(
@RequestParam(value = "rqType", defaultValue = "3") Integer rqType,
@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "startTime", required = false) String startTime,
@RequestParam(value = "endTime", required = false) String endTime,
@RequestParam(value = "pageIndex", defaultValue = "1") int pageIndex,
@RequestParam(value = "pageSize", defaultValue = "10") int pageSize) {
return userTimeService.records(schId(), rqType, keyword, startTime, endTime, pageIndex, pageSize);
}
/** 合计:区间新加总时长 + 当前剩余总时长 */
@GetMapping("Sum")
public UserTimeSumVo sum(@RequestParam(value = "rqType", defaultValue = "3") Integer rqType,
@RequestParam(value = "startTime", required = false) String startTime,
@RequestParam(value = "endTime", required = false) String endTime) {
return userTimeService.sum(schId(), rqType, startTime, endTime);
}
private Integer schId() {
Integer schId = UserContext.getSchId();
if (schId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败");
}
return schId;
}
}
@@ -0,0 +1,32 @@
package com.yunhai.workleave.controller.api;
import com.yunhai.workleave.config.Config;
import com.yunhai.workleave.entity.SysOperationLog;
import com.yunhai.workleave.model.BasePageQuery;
import com.yunhai.workleave.model.BasePageResult;
import com.yunhai.workleave.service.ISysOperationLogService;
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;
/**
* 审计日志查询接口(log/)。
* <p>本控制器被 SysOperationLogAspect 排除,避免日志查询自身再产生日志(防递归)。</p>
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/log/")
public class LogController {
@Autowired
private ISysOperationLogService logService;
/** 分页查询审计日志(responseData 不返回) */
@GetMapping("Page")
public BasePageResult<SysOperationLog> page(BasePageQuery query,
@RequestParam(value = "operationType", required = false) String operationType,
@RequestParam(value = "statusCode", required = false) Integer statusCode) {
return logService.page(query, operationType, statusCode);
}
}
@@ -0,0 +1,120 @@
package com.yunhai.workleave.controller.oapi;
import com.yunhai.workleave.entity.LeaveApply;
import com.yunhai.workleave.repository.LeaveApplyRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.Data;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.StringUtils;
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.RestController;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* 条件路由判断端点:flow-center CONDITION 节点(routerKey=work-leave.dept-judge)激活时同步调用——
* 按请假时长分流,小于3天走「小于3天」线,大于等于3天走「大于等于3天」线。
* <p>流程中心路由注册表:path=/oapi/flow/dept-judge(serviceKey=work-leave);
* 已发布流程 d2237a5b…/c3e9c6ee… 的出边名即本端点的出口契约。</p>
* <ul>
* <li>验签:收在 /oapi/* 下由 AppAuthFilter 强制验 flow-center 的应用签名,零新概念;</li>
* <li>纯读幂等:凭 bizId({leaveId}-{attempt})查 leave_apply 比对时长(variables 里有 formData 的
* totalHours 则免查库,与发起时透传口径一致);</li>
* <li>时长口径:3天 = 24小时(一天按作息 8 小时折算,同 LeaveFlowService#buildFormData 的
* days=totalHours/8,恰好等于3天归「大于等于3天」);</li>
* <li>契约纪律:返回 outlet 必须命中 outlets——流程定义与端点的出口名是同一份契约,不命中抛业务错
* (flow-center 侧按判断失败挂起,绝不默认放行);</li>
* <li>轻实现:同步短超时,最多一次主键查询。</li>
* </ul>
*/
@RestController
@RequestMapping("/oapi/flow")
public class FlowJudgeController {
private static final Logger log = LoggerFactory.getLogger(FlowJudgeController.class);
/** 出口名(须与流程定义出边 name 逐字一致) */
public static final String OUTLET_LT_3_DAYS = "小于3天";
public static final String OUTLET_GE_3_DAYS = "大于等于3天";
/** 3天折算小时数(8小时/天 × 3) */
static final double THREE_DAYS_HOURS = 24D;
@Autowired
private LeaveApplyRepository leaveApplyRepository;
/** 判断请求体(flow-center CONDITION 节点下发;字段忽略未知向前兼容) */
@Data
public static class JudgeBody {
private Long schId;
private Long instanceId;
private Integer instanceVersion;
private String bizType;
private String bizId;
private String nodeId;
private String nodeName;
private String routerKey;
private List<String> outlets;
private Map<String, Object> variables;
}
@PostMapping("/dept-judge")
public Map<String, Object> deptJudge(@RequestBody JudgeBody body) {
Double hours = variablesTotalHours(body);
if (hours == null) {
LeaveApply leave = leaveApplyRepository.findById(parseLeaveId(body))
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "请假单不存在:bizId=" + body.getBizId()));
hours = leave.getTotalHours();
}
if (hours == null) {
// 无时长无法判断:挂起而不是默认放行
throw new BizException(ErrorCode.PARAM_ERROR, "请假单缺少时长,无法判断:bizId=" + body.getBizId());
}
String outlet = hours < THREE_DAYS_HOURS ? OUTLET_LT_3_DAYS : OUTLET_GE_3_DAYS;
List<String> outlets = body.getOutlets() == null ? Collections.emptyList() : body.getOutlets();
if (!outlets.contains(outlet)) {
// 出口契约不一致(流程定义改了出口名而端点没跟上):抛错让 flow-center 挂起,不默认放行
throw new BizException(ErrorCode.PARAM_ERROR,
"判断结果未命中出口集合:outlet=" + outlet + " outlets=" + outlets
+ "(totalHours=" + hours + ", bizId=" + body.getBizId() + ")");
}
log.info("[dept-judge] bizId={}, instanceId={}, totalHours={}h, outlet={}",
body.getBizId(), body.getInstanceId(), hours, outlet);
return Collections.singletonMap("outlet", outlet);
}
/** formData 里的请假时长(发起时 bizSnapshot 同源透传;缺字段/非法返回 null 回落查表) */
private static Double variablesTotalHours(JudgeBody body) {
Object v = body.getVariables() == null ? null : body.getVariables().get("totalHours");
if (v instanceof Number) {
return ((Number) v).doubleValue();
}
if (v instanceof String) {
try {
return Double.valueOf(((String) v).trim());
} catch (NumberFormatException e) {
// 回落查表
}
}
return null;
}
/** bizId 形态 {leaveId}-{attempt},取首段(同 LeaveFlowCallbackHandler) */
private static Integer parseLeaveId(JudgeBody body) {
if (!StringUtils.hasText(body.getBizId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "bizId 缺失,无法定位请假单");
}
try {
return Integer.valueOf(body.getBizId().trim().split("-")[0]);
} catch (NumberFormatException e) {
throw new BizException(ErrorCode.PARAM_ERROR, "bizId 非法:" + body.getBizId());
}
}
}
@@ -0,0 +1,42 @@
package com.yunhai.workleave.entity;
import com.yunhai.workleave.common.listener.BaseAuditListener;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.*;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 所有实体的公共基类:自增主键 + 创建人/时间 + 更新人/时间(由 {@link BaseAuditListener} 自动填充)。
* <p>创建人/更新人只存平台用户ID(不保存账号,见需求「实体不保存教师和学生账号」)。</p>
*/
@MappedSuperclass
@Getter
@Setter
@EntityListeners(BaseAuditListener.class)
public abstract class BaseEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
/** 创建人用户ID(平台用户中心ID) */
@Column(name = "create_by", updatable = false)
private Integer createBy;
/** 创建时间 */
@Column(name = "create_time", updatable = false)
private LocalDateTime createTime;
/** 更新人用户ID */
@Column(name = "update_by")
private Integer updateBy;
/** 更新时间 */
@Column(name = "update_time")
private LocalDateTime updateTime;
}
@@ -0,0 +1,28 @@
package com.yunhai.workleave.entity;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 自定义联系组(应用设置-自定义组)。组名按学校隔离。
*/
@NoArgsConstructor
@Getter
@Setter
@Entity
@Table(name = "contact_group")
public class ContactGroup extends BaseEntity {
/** 学校ID(多租户隔离) */
@Column(name = "sch_id")
private Integer schId;
/** 组名 */
@Column(name = "name", length = 100, nullable = false)
private String name;
}
@@ -0,0 +1,29 @@
package com.yunhai.workleave.entity;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
/**
* 联系组-成员关联。只存成员(教师)平台用户ID,不存账号。
*/
@NoArgsConstructor
@Getter
@Setter
@Entity
@Table(name = "contact_group_user",
uniqueConstraints = @UniqueConstraint(name = "uk_group_user", columnNames = {"group_id", "user_id"}))
public class ContactGroupUser extends BaseEntity {
@Column(name = "group_id", nullable = false)
private Integer groupId;
/** 成员(教师)平台用户ID */
@Column(name = "user_id", nullable = false)
private Integer userId;
}
@@ -0,0 +1,117 @@
package com.yunhai.workleave.entity;
import com.yunhai.workleave.enums.LeaveStatusEnum;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* 请假申请主表。
* <p>表名不用 leave:MySQL 保留字。人员只存平台用户ID(不存账号),
* 姓名/头像/类型名为冗余展示字段。审核流转由平台流程审核器承担,
* 本表只保留单据状态与流程实例映射(biz_id / instance_id / instance_version)。</p>
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "leave_apply")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class LeaveApply extends BaseEntity {
/** 学校ID(多租户隔离) */
@Column(name = "sch_id", nullable = false)
private Integer schId;
/** 申请人用户ID(平台用户中心数字ID,不存账号) */
@Column(name = "user_id", nullable = false)
private Integer userId;
/** 申请人姓名(冗余展示) */
@Column(name = "user_name", length = 64)
private String userName;
/** 申请人头像 URL(冗余展示,可空) */
@Column(name = "user_photo", length = 255)
private String userPhoto;
/** 请假类型(leaveType 字典 dictKey) */
@Column(name = "leave_type", length = 50, nullable = false)
private String leaveType;
/** 请假类型名称(冗余展示) */
@Column(name = "leave_type_name", length = 100)
private String leaveTypeName;
/** 开始时间 yyyy-MM-dd HH:mm:ss */
@Column(name = "start_time", nullable = false, length = 19)
private String startTime;
/** 结束时间 yyyy-MM-dd HH:mm:ss */
@Column(name = "end_time", nullable = false, length = 19)
private String endTime;
/** 请假时长(小时,服务端按作息复核后落库) */
@Column(name = "total_hours")
private Double totalHours;
/** 请假事由 */
@Column(name = "reason", length = 500)
private String reason;
/** 附件ID CSV(up_file.id) */
@Column(name = "file_ids", length = 500)
private String fileIds;
/** 抄送人用户ID CSV(发起时选择;流程 NOTIFY 节点另发消息) */
@Column(name = "cc_user_ids", length = 500)
private String ccUserIds;
/** 课务安排:0 无课务安排(默认)/ 1 自行安排 / 2 教务安排(对应历史课务安排三选一) */
@Column(name = "course_arrange")
private Integer courseArrange = 0;
/** 单据状态:见 {@link LeaveStatusEnum} */
@Column(name = "status", nullable = false)
private Integer status;
/** 流程业务ID({leaveId}-{attempt},流程中心按 bizType+bizId 幂等) */
@Column(name = "biz_id", length = 64)
private String bizId;
/** 流程实例ID(流程中心 instanceId) */
@Column(name = "instance_id")
private Long instanceId;
/** 已处理的最大流程版本号(乱序门) */
@Column(name = "instance_version")
private Integer instanceVersion;
/** 流程定义标识(flowGuid) */
@Column(name = "flow_guid", length = 64)
private String flowGuid;
/** 第几次提交(拒绝后重新提交自增,驱动 bizId 变化) */
@Column(name = "flow_attempt")
private Integer flowAttempt;
/** 终态快照 JSON(回调事件 data:审批意见/节点等) */
@Column(name = "snapshot", columnDefinition = "TEXT")
private String snapshot;
/** 申请时间(列表排序/统计口径) */
@Column(name = "apply_time", nullable = false)
private LocalDateTime applyTime;
/** 销假时间 */
@Column(name = "cancel_time")
private LocalDateTime cancelTime;
/** 销假事由/备注 */
@Column(name = "cancel_reason", length = 500)
private String cancelReason;
}
@@ -0,0 +1,52 @@
package com.yunhai.workleave.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* 审核痕迹表(本地留痕)。
* <p>流程中心的审核历史在中心侧;本表在审核动作提交成功后落一条,
* 支撑「我已审核」列表与详情时间线补全,避免每次翻页都查流程中心。</p>
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "leave_audit_trace")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class LeaveAuditTrace extends BaseEntity {
@Column(name = "sch_id", nullable = false)
private Integer schId;
@Column(name = "leave_id", nullable = false)
private Integer leaveId;
/** 流程任务ID */
@Column(name = "task_id")
private Long taskId;
/** 动作:approve / reject */
@Column(name = "action", length = 20)
private String action;
/** 审批意见 */
@Column(name = "opinion", length = 500)
private String opinion;
/** 操作人 */
@Column(name = "operator_id")
private Integer operatorId;
@Column(name = "operator_name", length = 64)
private String operatorName;
/** 审核时间 */
@Column(name = "audit_time", nullable = false)
private LocalDateTime auditTime;
}
@@ -0,0 +1,42 @@
package com.yunhai.workleave.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 抄送留痕表。
* <p>平台流程中心 NOTIFY 节点负责发抄送消息;本表在回调 flow.cc.sent 时落痕,
* 供「抄送列表」页渲染(历史 Common_SendUserinfo/SendUserID 的替代)。</p>
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "leave_cc")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class LeaveCc extends BaseEntity {
/** 学校ID */
@Column(name = "sch_id", nullable = false)
private Integer schId;
/** 请假单ID */
@Column(name = "leave_id", nullable = false)
private Integer leaveId;
/** 被抄送人用户ID */
@Column(name = "cc_user_id")
private Integer ccUserId;
/** 被抄送人姓名(冗余展示) */
@Column(name = "cc_user_name", length = 64)
private String ccUserName;
/** 流程实例ID(幂等键组成部分) */
@Column(name = "instance_id")
private Long instanceId;
}
@@ -0,0 +1,64 @@
package com.yunhai.workleave.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 审批流程绑定(一校多行)。
* <p>对应《子应用流程与消息对接手册 v1.0》§1.3/§3.2:本应用 bizType 恒为 {@code leave},
* 多套审批流用 flowGuid 区分(如 leave-annual-v1 / leave-cancel-v1),
* 这里记录「哪个场景走哪条 flowGuid」。</p>
*
* <p>两级作用域:
* <ul>
* <li>{@link #SCOPE_DEFAULT} 学校默认流程(一校仅一条,兜底所有场景)</li>
* <li>{@link #SCOPE_TYPE} 按请假类型指定流程(scopeKey = 请假类型 dictKey),优先级高于默认</li>
* </ul>
* 未绑定任何记录时,回落到配置 {@code leave.flow.guid}。</p>
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "leave_flow_bind")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class LeaveFlowBind extends BaseEntity {
/** 绑定范围:学校默认流程 */
public static final Integer SCOPE_DEFAULT = 0;
/** 绑定范围:按请假类型指定流程 */
public static final Integer SCOPE_TYPE = 1;
/** 学校ID */
@Column(name = "sch_id", nullable = false)
private Integer schId;
/** 绑定范围:0=学校默认 / 1=按请假类型 */
@Column(name = "bind_scope", nullable = false)
private Integer bindScope = SCOPE_DEFAULT;
/** 范围键:scope=1 时为请假类型 dictKey;scope=0 时为 "-"(便于唯一性判断) */
@Column(name = "scope_key", length = 100)
private String scopeKey = "-";
/** 流程定义标识(流程中心「流程编排」注册所得的 flowGuid) */
@Column(name = "flow_guid", nullable = false, length = 100)
private String flowGuid;
/** 流程名称(冗余展示;流程中心改名后重新绑定即刷新) */
@Column(name = "flow_name", length = 200)
private String flowName;
/** 流程版本(冗余展示) */
@Column(name = "flow_version")
private Integer flowVersion;
/** 节点摘要(冗余展示,如「发起 → 部门审核 → 校长审批」) */
@Column(name = "node_summary", length = 500)
private String nodeSummary;
}
@@ -0,0 +1,48 @@
package com.yunhai.workleave.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 流程启用绑定(一校多行):标记哪条流程「启用并绑定到本应用」。
*
* <p>背景:开放网关 oapi 的流程列表不按应用隔离(无 code 字段),
* 通知流程等非本应用的流程会混进候选列表。启用绑定开关用于:
* <ul>
* <li>开启(enable):登记该流程为本应用可用,并回调登记到流程中心;之后才允许设为默认 / 按类型绑定</li>
* <li>关闭(disable):该流程仅展示、不可被绑定;若已有默认/按类型绑定,一并解除</li>
* </ul>
* 唯一性由 (sch_id, flow_guid) 逻辑保证,不建物理唯一索引(与 leave_flow_bind 同理)。</p>
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "leave_flow_enable")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class LeaveFlowEnable extends BaseEntity {
/** 学校ID */
@Column(name = "sch_id", nullable = false)
private Integer schId;
/** 流程定义标识(流程中心「流程编排」注册所得的 flowGuid) */
@Column(name = "flow_guid", nullable = false, length = 100)
private String flowGuid;
/** 流程名称(冗余展示) */
@Column(name = "flow_name", length = 200)
private String flowName;
/** 流程版本(冗余展示) */
@Column(name = "flow_version")
private Integer flowVersion;
/** 节点摘要(冗余展示,如「发起 → 部门审核 → 校长审批」) */
@Column(name = "node_summary", length = 500)
private String nodeSummary;
}
@@ -0,0 +1,41 @@
package com.yunhai.workleave.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 节次表(设置页可维护;P7 指派代课/调课的节次数据源)。
* <p>对应历史 Setting_CourseSection,rank 决定排序。</p>
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "leave_section")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class LeaveSection extends BaseEntity {
/** 学校ID */
@Column(name = "sch_id", nullable = false)
private Integer schId;
/** 节次名称(如 "第一节") */
@Column(name = "name", length = 100)
private String name;
/** 排序号 */
@Column(name = "rank_no")
private Integer rankNo = 0;
/** 开始时间 "HH:mm" */
@Column(name = "begin_time", length = 10)
private String beginTime;
/** 结束时间 "HH:mm" */
@Column(name = "end_time", length = 10)
private String endTime;
}
@@ -0,0 +1,54 @@
package com.yunhai.workleave.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 学校请假设置(一校一行)。
* <p>对应历史 QJ_LeaveMustItem,按新方案裁剪:
* 「按小时/按天」拆成固化为小时制,「半天上下午」字段取消,「课务安排」延后到 P7。</p>
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "leave_setting")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class LeaveSetting extends BaseEntity {
/** 学校ID */
@Column(name = "sch_id", nullable = false)
private Integer schId;
/** 请假事由是否必填:0 选填 / 1 必填(默认 1) */
@Column(name = "reason_must")
private Integer reasonMust = 1;
/** 是否显示请假说明:0 否 / 1 是(默认 1) */
@Column(name = "show_explain")
private Integer showExplain = 1;
/** 是否启用课务安排(P7 预留,默认 0) */
@Column(name = "enable_course")
private Integer enableCourse = 0;
/** 是否允许换课(P7 预留,默认 0) */
@Column(name = "allow_exchange")
private Integer allowExchange = 0;
/** 时长管控的请假类型 CSV(dictKey;留空=全部类型不限时长) */
@Column(name = "time_managed_types", length = 500)
private String timeManagedTypes;
/** 是否启用时长管控:0 否 / 1 是(默认 0,与历史「类型绑定即管控」语义等价但集中开关) */
@Column(name = "time_limit_enabled")
private Integer timeLimitEnabled = 0;
/** 请假计算口径:1 按小时(默认,历史 IsCalculateWay=true)/ 0 按天(半天/半天以上输入模式) */
@Column(name = "calc_way")
private Integer calcWay = 1;
}
@@ -0,0 +1,50 @@
package com.yunhai.workleave.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 学校请假作息时间(一校一行)。
* <p>对应历史 QJ_SetSchTime。默认 上午 08:00-12:00 / 下午 13:00-17:00,
* 最短请假间隔 10 分钟(intervalMinutes 目前仅存设置,不参与时长计算)。</p>
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "leave_time_set")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class LeaveTimeSet extends BaseEntity {
/** 学校ID */
@Column(name = "sch_id", nullable = false)
private Integer schId;
/** 上午开始 "HH:mm" */
@Column(name = "forenoon1", length = 10)
private String forenoon1 = "08:00";
/** 上午结束 "HH:mm" */
@Column(name = "forenoon2", length = 10)
private String forenoon2 = "12:00";
/** 下午开始 "HH:mm" */
@Column(name = "afternoon1", length = 10)
private String afternoon1 = "13:00";
/** 下午结束 "HH:mm" */
@Column(name = "afternoon2", length = 10)
private String afternoon2 = "17:00";
/** 最短请假间隔(分钟,默认 10) */
@Column(name = "interval_minutes")
private Integer intervalMinutes = 10;
/** 最少请假时间(分钟,0 = 不限制;对应历史学校时间设置「最少请假时间」) */
@Column(name = "min_minutes")
private Integer minMinutes = 0;
}
@@ -0,0 +1,41 @@
package com.yunhai.workleave.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 教师可用时长池(一校一人一行)。
* <p>对应历史 QJ_SchUserTime。小时制;(schId,userId) 唯一。</p>
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "leave_user_time")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class LeaveUserTime extends BaseEntity {
/** 学校ID */
@Column(name = "sch_id", nullable = false)
private Integer schId;
/** 教师用户ID(平台用户中心ID,不存账号) */
@Column(name = "user_id", nullable = false)
private Integer userId;
/** 教师姓名(冗余展示) */
@Column(name = "user_name", length = 64)
private String userName;
/** 剩余可用小时数(默认 0,绝不出现 null 相加) */
@Column(name = "usable_hours")
private Double usableHours = 0D;
/** 备注 */
@Column(name = "remark", length = 500)
private String remark;
}
@@ -0,0 +1,68 @@
package com.yunhai.workleave.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* 教师时长变更流水。
* <p>对应历史 QJ_SchUserTimeRecord。editHours 正=新加、负=扣除;管理员手动调整
* 的流水 leaveType 记为空串,由 describe 区分("新加"/"销假返还"/"扣除"等原样留存)。</p>
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "leave_user_time_record")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class LeaveUserTimeRecord extends BaseEntity {
/** 学校ID */
@Column(name = "sch_id", nullable = false)
private Integer schId;
/** 教师用户ID */
@Column(name = "user_id", nullable = false)
private Integer userId;
/** 教师姓名(冗余展示) */
@Column(name = "user_name", length = 64)
private String userName;
/** 关联请假单ID(管理员手动调整时为空) */
@Column(name = "leave_id")
private Integer leaveId;
/** 请假类型 dictKey(手动调整为空串) */
@Column(name = "leave_type", length = 50)
private String leaveType;
/** 本次增减小时(正=加,负=减) */
@Column(name = "edit_hours")
private Double editHours;
/** 变更前小时 */
@Column(name = "old_hours")
private Double oldHours;
/** 变更后小时 */
@Column(name = "new_hours")
private Double newHours;
/** 备注("扣除"/"销假返还"/管理员备注/"新加") */
@Column(name = "describe_text", length = 500)
private String describeText;
/** 操作人用户ID(审核人/管理员) */
@Column(name = "operator_id")
private Integer operatorId;
/** 发生时间(统计过滤口径,独立于审计 createTime) */
@Column(name = "occur_time", nullable = false)
private LocalDateTime occurTime;
}
@@ -0,0 +1,45 @@
package com.yunhai.workleave.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 系统字典(主办部门 dept / 活动分类 category)。
* <p>schId 做学校隔离;schId=0 视为全局种子数据(学校无自有字典时兜底可用)。</p>
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "sys_dict")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class SysDict extends BaseEntity {
/** 学校ID;0=全局种子 */
@Column(name = "sch_id")
private Integer schId = 0;
/** 字典类型:dept=主办部门 / category=活动分类 */
@Column(name = "dict_type", length = 50)
private String dictType;
/** 字典 key(前端 CodeID,如 D001/C001) */
@Column(name = "dict_key", length = 100)
private String dictKey;
/** 字典 value(显示名称,前端 Name) */
@Column(name = "dict_value", length = 200)
private String dictValue;
/** 排序号 */
@Column(name = "sort_order")
private Integer sortOrder = 0;
/** 是否启用:0 禁用 / 1 启用 */
@Column(name = "enable_flag")
private Integer enableFlag = 1;
}
@@ -0,0 +1,84 @@
package com.yunhai.workleave.entity;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 系统审计日志实体(移植自 UserclassService SysOperationLog)。
* 全量记录 controller 请求:用户、URI、参数、响应(GZIP)、耗时、异常。
* 索引由 resources/sql/sys_operation_log.sql 创建,本实体不含 @Table(indexes),
* 避免 ddl-auto=update 启动时重复建索引告警。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "sys_operation_log")
public class SysOperationLog implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** 1-重要(删除) 2-次要(新增/编辑) 3-一般(查询) */
@Column(name = "log_level", nullable = false, columnDefinition = "TINYINT")
private Integer logLevel;
@Column(name = "operation_type", nullable = false, length = 16)
private String operationType;
@Column(name = "user_id")
private Integer userId;
@Column(name = "user_name", length = 64)
private String userName;
@Column(name = "sch_id")
private Integer schId;
@Column(name = "request_ip", nullable = false, length = 64)
private String requestIp;
@Column(name = "request_uri", nullable = false, length = 512)
private String requestUri;
@Column(name = "request_method", nullable = false, length = 16)
private String requestMethod;
@Column(name = "class_method", nullable = false, length = 512)
private String classMethod;
@Column(name = "request_params", columnDefinition = "TEXT")
private String requestParams;
/** GZIP 压缩后的响应 JSON(不参与 JSON 序列化) */
@Column(name = "response_data", columnDefinition = "MEDIUMBLOB")
private byte[] responseData;
@Column(name = "status_code", nullable = false)
private Integer statusCode;
/** 耗时 ms */
@Column(name = "cost_time", nullable = false)
private Long costTime;
@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,44 @@
package com.yunhai.workleave.entity;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 附件元信息实体(按前端 UpFileVo 契约重建)。
*/
@NoArgsConstructor
@Getter
@Setter
@Entity
@Table(name = "up_file")
public class UpFile extends BaseEntity {
/** 主题类型(如 "leave") */
@Column(name = "topic_type", length = 50)
private String topicType;
/** 主题ID(活动ID;未绑定时为空) */
@Column(name = "topic_id")
private Integer topicId;
/** 原始文件名 */
@Column(name = "file_true_name", length = 255)
private String fileTrueName;
/** 存储相对路径 */
@Column(name = "file_name", length = 255)
private String fileName;
/** 文件大小(字节) */
@Column(name = "file_size")
private Integer fileSize;
/** 扩展名(小写) */
@Column(name = "file_type", length = 50)
private String fileType;
}
@@ -0,0 +1,71 @@
package com.yunhai.workleave.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 请假单据状态。
* <p>历史状态机的 0~6 收敛为 5 个:撤销待审/通过(撤销被拒)/修改待审
* 三个依赖自建流程引擎的状态取消,审核流转交由平台流程审核器承担。</p>
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
public enum LeaveStatusEnum {
/** 审批中(已发起流程,待审核) */
APPROVING(0, "审批中"),
/** 已通过(flow.completed;管控类型扣减时长) */
PASSED(1, "已通过"),
/** 已拒绝(flow.rejected) */
REJECTED(2, "已拒绝"),
/** 已销假(已通过后教师发起销假;返还时长) */
CANCELED(3, "已销假"),
/** 已撤回(flow.withdrawn,提交人撤回) */
WITHDRAWN(4, "已撤回");
private Integer id;
private String name;
public static String nameById(Integer id) {
if (id == null) {
return "";
}
for (LeaveStatusEnum e : values()) {
if (e.id.equals(id)) {
return e.name;
}
}
return "";
}
/** 终态判定:通过/拒绝/销假为终态;撤回与审批中非终态 */
public static boolean isFinal(Integer id) {
return PASSED.id.equals(id) || REJECTED.id.equals(id) || CANCELED.id.equals(id);
}
/** 是否可编辑:审批中/已拒绝/已撤回 */
public static boolean editable(Integer id) {
return APPROVING.id.equals(id) || REJECTED.id.equals(id) || WITHDRAWN.id.equals(id);
}
/** 是否可删除:非已通过(已通过只能销假,不能删) */
public static boolean deletable(Integer id) {
return !PASSED.id.equals(id);
}
/** 该状态是否计入时间冲突(与历史 leave_status in (0,1,5) 对齐) */
public static boolean conflictStatus(Integer id) {
return APPROVING.id.equals(id) || PASSED.id.equals(id);
}
/** 该状态是否占用时长(在途占用:审批中) */
public static boolean occupyingStatus(Integer id) {
return APPROVING.id.equals(id);
}
}
@@ -0,0 +1,20 @@
package com.yunhai.workleave.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 用户类型(与基础平台 BoaUser.type 对齐)。
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
public enum UserType {
SUPER_ADMINISTRATOR(1, "超级管理员"),
SCHOOL_ADMIN(2, "学校管理员"),
REGULAR_USER(3, "普通用户");
private Integer id;
private String name;
}
@@ -0,0 +1,67 @@
package com.yunhai.workleave.flow;
import com.yunhai.workleave.entity.LeaveApply;
import com.yunhai.workleave.model.leave.FlowProgressVo;
import com.yunhai.platform.openapi.flow.model.FlowApplyResult;
import com.yunhai.platform.openapi.flow.model.FlowProgress;
import com.yunhai.platform.openapi.flow.model.FlowSummary;
import com.yunhai.platform.openapi.flow.model.FlowProgressStep;
import com.yunhai.platform.openapi.flow.model.FlowProgressTask;
import com.yunhai.platform.openapi.flow.model.FlowTodoTask;
import com.yunhai.platform.openapi.flow.model.FlowTodoTaskPage;
import java.util.List;
/**
* 流程审核器门面(本模块对 YschFlowOpenApi 的唯一出口)。
* <p>把静态 SDK 调用收敛到一处,便于单测替换与异常语义统一:
* 跨仓调用失败一律包成 BizException 抛出,由平台全局异常处理返回统一错误。</p>
*/
public interface ILeaveFlowService {
/** 回调登记(首次发起前确保已注册;结果落 starter 的登记镜像表) */
void ensureRegistered(Integer schId, String flowGuid);
/** 编排注册所得的本模块流程标识(配置 leave.flow.guid,未配时为默认值) */
String defaultFlowGuid();
/**
* 流程中心的流程列表(流程编排里已注册的流程)。
*
* @return 流程摘要列表;流程中心不可达时抛 BizException(由调用方决定是否降级)
*/
List<FlowSummary> listFlows(Integer schId);
/** 发起流程:返回实例ID;duplicated=true 表示同 bizId 幂等命中 */
FlowApplyResult apply(LeaveApply leave, String bizId, String bizTitle);
/** 撤回(仅提交人本人 + 尚无任务被办理) */
void withdraw(Integer schId, Long instanceId, Integer submitterId);
/** 我的待办(bizType 固定 leave) */
FlowTodoTaskPage todoTasks(Integer schId, Integer assigneeId, Integer page, Integer size, String bizType);
/** 同意任务 */
void approveTask(Integer schId, Long taskId, Integer operatorId, String operatorName, String opinion);
/** 拒绝任务(意见必填,服务层已校验) */
void rejectTask(Integer schId, Long taskId, Integer operatorId, String operatorName, String opinion);
/** 办件进度(时间线渲染);异常/不可达返回 null(详情页降级展示) */
FlowProgress progress(LeaveApply leave);
/** 流程图短时 URL(不可达返回 null) */
String diagramUrl(LeaveApply leave);
/** 流程中心进度 → 前端视图 */
FlowProgressVo toProgressVo(FlowProgress progress);
/** 待办条目 → 本地字段映射辅助(供 service 组装 TodoVo) */
List<FlowTodoTask> contentOf(FlowTodoTaskPage page);
/** 流程节点/任务遍历辅助(进度视图构建用) */
List<FlowProgressStep> stepsOf(FlowProgress progress);
/** 任务遍历辅助 */
List<FlowProgressTask> tasksOf(FlowProgressStep step);
}
@@ -0,0 +1,297 @@
package com.yunhai.workleave.flow;
import com.yunhai.workleave.entity.LeaveApply;
import com.yunhai.workleave.entity.LeaveCc;
import com.yunhai.workleave.enums.LeaveStatusEnum;
import com.yunhai.workleave.repository.LeaveApplyRepository;
import com.yunhai.workleave.repository.LeaveCcRepository;
import com.yunhai.workleave.service.ILeaveUserTimeService;
import com.alibaba.fastjson.JSON;
import com.yunhai.platform.flowcallback.FlowCallbackHandler;
import com.yunhai.platform.openapi.flow.model.FlowCallbackEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.Map;
/**
* 请假流程回调处理器(手册 §2.4 范本落地)。
* <p>事件已过全部门禁(验签、bizType 过滤、eventId 幂等、乱序门),本类只写状态推进:</p>
* <ul>
* <li>flow.submitted / task.created / task.handled → 审批中(终态不回退);</li>
* <li>flow.completed → 已通过 + 扣减时长 + 落终态快照;</li>
* <li>flow.rejected → 已拒绝 + 落终态快照(不扣不返还);</li>
* <li>flow.withdrawn → 已撤回(仅审批中可回退);</li>
* <li>flow.cc.sent → 抄送留痕(幂等)。</li>
* </ul>
* <p>事务语义:本方法与事件行同一事务(starter 内置),抛出异常整体回滚 + 流程中心重推;
* 确定性脏消息(bizId 解析失败等)记录 warn 后正常 return,绝不靠抛异常过滤。</p>
*/
@Component
public class LeaveFlowCallbackHandler implements FlowCallbackHandler {
private static final Logger log = LoggerFactory.getLogger(LeaveFlowCallbackHandler.class);
public static final String EVENT_SUBMITTED = "flow.submitted";
public static final String EVENT_TASK_CREATED = "flow.task.created";
public static final String EVENT_TASK_HANDLED = "flow.task.handled";
public static final String EVENT_COMPLETED = "flow.completed";
public static final String EVENT_REJECTED = "flow.rejected";
public static final String EVENT_WITHDRAWN = "flow.withdrawn";
public static final String EVENT_CC_SENT = "flow.cc.sent";
@Autowired
private LeaveApplyRepository leaveApplyRepository;
@Autowired
private LeaveCcRepository leaveCcRepository;
@Autowired
private ILeaveUserTimeService userTimeService;
@Override
@Transactional
public void onEvent(FlowCallbackEvent event) {
if (event == null || !StringUtils.hasText(event.getEvent())) {
log.warn("[flow-callback] 空事件,忽略");
return;
}
String eventCode = event.getEvent();
log.info("[flow-callback] 收到事件: event={}, bizId={}, instanceId={}, version={}, operator={}",
eventCode, event.getBizId(), event.getInstanceId(), event.getInstanceVersion(),
event.getOperatorName());
// 抄送事件不推进单据状态,按 leaveId 落留痕即可
if (EVENT_CC_SENT.equals(eventCode)) {
handleCcSent(event);
return;
}
LeaveApply leave = resolveLeave(event);
if (leave == null) {
// 确定性脏消息:本应用单据不存在,记录后正常返回(不触发重推)
log.warn("[flow-callback] 未找到对应单据,忽略: bizId={}", event.getBizId());
return;
}
if (!belongsToCurrentAttempt(leave, event)) {
log.info("[flow-callback] 非当前流程实例事件,忽略: leaveId={}, currentBizId={}, eventBizId={}, "
+ "currentInstanceId={}, eventInstanceId={}",
leave.getId(), leave.getBizId(), event.getBizId(), leave.getInstanceId(), event.getInstanceId());
return;
}
// 乱序门(starter 已按 instanceVersion 过滤大部分;此处对单据已记录版本再兜一层)
if (event.getInstanceVersion() != null && leave.getInstanceVersion() != null
&& event.getInstanceVersion() < leave.getInstanceVersion()) {
log.info("[flow-callback] 事件版本落后,忽略: leaveId={}, eventV={}, recordV={}",
leave.getId(), event.getInstanceVersion(), leave.getInstanceVersion());
return;
}
if (event.getInstanceVersion() != null) {
leave.setInstanceVersion(event.getInstanceVersion());
}
switch (eventCode) {
case EVENT_SUBMITTED:
case EVENT_TASK_CREATED:
case EVENT_TASK_HANDLED:
markApproving(leave);
break;
case EVENT_COMPLETED:
markPassed(leave, event);
break;
case EVENT_REJECTED:
markRejected(leave, event);
break;
case EVENT_WITHDRAWN:
markWithdrawn(leave);
break;
default:
log.warn("[flow-callback] 未知事件,忽略: {}", eventCode);
return;
}
leaveApplyRepository.save(leave);
}
// ============================ 状态推进 ============================
/** 审批中:终态不回退(并发推送可能乱序到达) */
private void markApproving(LeaveApply leave) {
if (!LeaveStatusEnum.APPROVING.getId().equals(leave.getStatus())) {
log.info("[flow-callback] 当前流程已离开审批中状态({}),拒绝非终态事件回退: leaveId={}",
LeaveStatusEnum.nameById(leave.getStatus()), leave.getId());
return;
}
leave.setStatus(LeaveStatusEnum.APPROVING.getId());
}
/** 已通过:扣减时长 + 终态快照 */
private void markPassed(LeaveApply leave, FlowCallbackEvent event) {
if (LeaveStatusEnum.PASSED.getId().equals(leave.getStatus())) {
log.info("[flow-callback] 通过事件重复到达,忽略副作用: leaveId={}", leave.getId());
return;
}
if (!LeaveStatusEnum.APPROVING.getId().equals(leave.getStatus())) {
log.warn("[flow-callback] 单据已处其他终态({}),忽略冲突通过事件: leaveId={}",
LeaveStatusEnum.nameById(leave.getStatus()), leave.getId());
return;
}
Integer before = leave.getStatus();
leave.setStatus(LeaveStatusEnum.PASSED.getId());
leave.setSnapshot(snapshotOf(event));
// 幂等:已扣减过则 service 内部跳过
userTimeService.deduct(leave.getSchId(), leave.getUserId(), leave.getUserName(),
leave.getId(), leave.getLeaveType(),
leave.getTotalHours() == null ? 0D : leave.getTotalHours(),
event.getOperatorUserId());
log.info("[flow-callback] 单据已通过: leaveId={}, before={}, operator={}, opinion={}",
leave.getId(), LeaveStatusEnum.nameById(before), event.getOperatorName(), opinionOf(event));
}
/** 已拒绝:不扣不返还(从未扣过),落终态快照 */
private void markRejected(LeaveApply leave, FlowCallbackEvent event) {
if (LeaveStatusEnum.REJECTED.getId().equals(leave.getStatus())) {
log.info("[flow-callback] 拒绝事件重复到达,忽略: leaveId={}", leave.getId());
return;
}
if (!LeaveStatusEnum.APPROVING.getId().equals(leave.getStatus())) {
log.warn("[flow-callback] 单据已处其他终态({}),忽略冲突拒绝事件: leaveId={}",
LeaveStatusEnum.nameById(leave.getStatus()), leave.getId());
return;
}
leave.setStatus(LeaveStatusEnum.REJECTED.getId());
leave.setSnapshot(snapshotOf(event));
log.info("[flow-callback] 单据已拒绝: leaveId={}, operator={}, opinion={}",
leave.getId(), event.getOperatorName(), opinionOf(event));
}
/** 已撤回:仅审批中可回退 */
private void markWithdrawn(LeaveApply leave) {
if (!LeaveStatusEnum.APPROVING.getId().equals(leave.getStatus())) {
log.info("[flow-callback] 单据非审批中({}),忽略撤回事件: leaveId={}",
LeaveStatusEnum.nameById(leave.getStatus()), leave.getId());
return;
}
leave.setStatus(LeaveStatusEnum.WITHDRAWN.getId());
}
/** 抄送留痕(幂等:同实例同人不重复落) */
private void handleCcSent(FlowCallbackEvent event) {
Long leaveId = parseLeaveId(event.getBizId());
if (leaveId == null) {
log.warn("[flow-callback] 抄送事件 bizId 非法,忽略: {}", event.getBizId());
return;
}
LeaveApply leave = leaveApplyRepository.findById(leaveId.intValue()).orElse(null);
if (leave == null) {
log.warn("[flow-callback] 抄送事件未找到单据,忽略: bizId={}", event.getBizId());
return;
}
if (!belongsToCurrentAttempt(leave, event)) {
log.info("[flow-callback] 非当前流程实例抄送事件,忽略: leaveId={}, eventBizId={}",
leave.getId(), event.getBizId());
return;
}
Integer ccUserId = event.getOperatorUserId();
if (ccUserId == null) {
// 抄送事件可能以 data.ccUserIds 列表下发;解析首个
Object dataValue = event.getData() == null ? null : event.getData().get("ccUserIds");
if (dataValue != null) {
String raw = String.valueOf(dataValue);
for (String part : raw.split(",")) {
recordCc(leave, parseInteger(part), null, event.getInstanceId());
}
}
return;
}
recordCc(leave, ccUserId, event.getOperatorName(), event.getInstanceId());
}
private void recordCc(LeaveApply leave, Integer ccUserId, String ccUserName, Long instanceId) {
if (ccUserId == null) {
return;
}
if (leaveCcRepository.existsByLeaveIdAndCcUserIdAndInstanceId(leave.getId(), ccUserId, instanceId)) {
return;
}
LeaveCc cc = new LeaveCc();
cc.setSchId(leave.getSchId());
cc.setLeaveId(leave.getId());
cc.setCcUserId(ccUserId);
cc.setCcUserName(ccUserName);
cc.setInstanceId(instanceId);
leaveCcRepository.save(cc);
}
// ============================ 工具 ============================
private LeaveApply resolveLeave(FlowCallbackEvent event) {
// 优先按 instanceId 定位(bizId 因重提交变化不影响)
if (event.getInstanceId() != null) {
LeaveApply byInstance = leaveApplyRepository.findByInstanceId(event.getInstanceId()).orElse(null);
if (byInstance != null) {
return byInstance;
}
}
Long leaveId = parseLeaveId(event.getBizId());
return leaveId == null ? null : leaveApplyRepository.findById(leaveId.intValue()).orElse(null);
}
/**
* 同一请假单可在拒绝/撤回后重新提交。按主键兜底定位时必须再核对当前 attempt,
* 否则旧实例迟到事件会污染新实例状态。
*/
private boolean belongsToCurrentAttempt(LeaveApply leave, FlowCallbackEvent event) {
if (StringUtils.hasText(leave.getBizId()) && StringUtils.hasText(event.getBizId())
&& !leave.getBizId().equals(event.getBizId())) {
return false;
}
return leave.getInstanceId() == null || event.getInstanceId() == null
|| leave.getInstanceId().equals(event.getInstanceId());
}
/** bizId 形态 {leaveId}-{attempt},取首段 */
private Long parseLeaveId(String bizId) {
if (!StringUtils.hasText(bizId)) {
return null;
}
String first = bizId.trim().split("-")[0];
try {
return Long.parseLong(first);
} catch (NumberFormatException e) {
return null;
}
}
private Integer parseInteger(String value) {
try {
return Integer.parseInt(value.trim());
} catch (Exception e) {
return null;
}
}
/** 终态快照:event.data 序列化(含 opinion/节点等),详情页免二次查询 */
private String snapshotOf(FlowCallbackEvent event) {
Map<String, Object> data = event.getData();
if (data == null || data.isEmpty()) {
return null;
}
try {
return JSON.toJSONString(data);
} catch (Exception e) {
log.warn("[flow-callback] 快照序列化失败: {}", e.getMessage());
return null;
}
}
private String opinionOf(FlowCallbackEvent event) {
Object value = event.getData() == null ? null : event.getData().get("opinion");
return value == null ? null : String.valueOf(value);
}
}
@@ -0,0 +1,24 @@
package com.yunhai.workleave.flow;
import com.yunhai.platform.openapi.core.JumpTarget;
/**
* 流程跳转页面名常量(手册 §3.1:页面名 + query 参数,多端最大公约数)。
* <p>PC 端路由 {@code /detail?leaveId=x} 与移动端页面名 {@code detail} 保持一致。</p>
*/
public final class LeaveFlowPages {
private LeaveFlowPages() {
}
/** 请假详情页(消息/待办跳转目标) */
public static final String DETAIL = "detail";
/** 详情页参数名 */
public static final String PARAM_LEAVE_ID = "leaveId";
/** 构造详情页跳转目标 */
public static JumpTarget detail(Integer leaveId) {
return JumpTarget.page(DETAIL).param(PARAM_LEAVE_ID, leaveId);
}
}
@@ -0,0 +1,305 @@
package com.yunhai.workleave.flow;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.flowcallback.FlowCallbackRegistrar;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.openapi.flow.YschFlowOpenApi;
import com.yunhai.platform.openapi.flow.model.FlowApplyBody;
import com.yunhai.platform.openapi.flow.model.FlowApplyResult;
import com.yunhai.platform.openapi.flow.model.FlowDiagramUrl;
import com.yunhai.platform.openapi.flow.model.FlowProgress;
import com.yunhai.platform.openapi.flow.model.FlowProgressStep;
import com.yunhai.platform.openapi.flow.model.FlowProgressTask;
import com.yunhai.platform.openapi.flow.model.FlowSummary;
import com.yunhai.platform.openapi.flow.model.FlowTodoTask;
import com.yunhai.platform.openapi.flow.model.FlowTodoTaskPage;
import com.yunhai.workleave.entity.LeaveApply;
import com.yunhai.workleave.model.leave.FlowProgressVo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 流程审核器门面实现(对齐《子应用流程与消息对接手册 v1.0》)。
* <p>地址类配置零项:SDK 按 {@code yh.open.base-host} + 服务名自动拼装
* (三级解析:call.targets → open.services → 枚举兜底)。</p>
*/
@Service
public class LeaveFlowService implements ILeaveFlowService {
private static final Logger log = LoggerFactory.getLogger(LeaveFlowService.class);
/** 本应用全局唯一业务类型(手册 §3.2 红线:= app_code,不可带点分档) */
public static final String BIZ_TYPE = "leave";
/** 流程流转中单据标题截断长度(快照/标题只放摘要) */
private static final int TITLE_MAX = 60;
@Value("${leave.flow.guid:leave-apply-v1}")
private String defaultFlowGuid;
/** 回调登记注册器(starter 提供,可能未装配) */
@Autowired
private ObjectProvider<FlowCallbackRegistrar> callbackRegistrarProvider;
// ============================ 登记 ============================
@Override
public String defaultFlowGuid() {
return defaultFlowGuid;
}
// ============================ 流程列表 ============================
@Override
public List<FlowSummary> listFlows(Integer schId) {
try {
List<FlowSummary> flows = YschFlowOpenApi.listFlows(schId == null ? 0L : schId, FlowSummary.class);
return flows == null ? Collections.emptyList() : flows;
} catch (Exception e) {
log.error("[flow] 流程列表查询失败: schId={}", schId, e);
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "获取流程列表失败:" + e.getMessage());
}
}
@Override
public void ensureRegistered(Integer schId, String flowGuid) {
FlowCallbackRegistrar registrar = callbackRegistrarProvider.getIfAvailable();
if (registrar == null) {
log.warn("[flow] 回调注册器未装配(yh.flow-callback.enabled=false?),跳过登记: schId={}, flowGuid={}",
schId, flowGuid);
return;
}
try {
registrar.ensureRegistered(schId, flowGuid);
} catch (Exception e) {
// 登记失败不阻断提交:流程中心可能已存在旧登记,或本地网络暂不可达
log.warn("[flow] 回调登记失败: schId={}, flowGuid={}, err={}", schId, flowGuid, e.getMessage());
}
}
// ============================ 发起 ============================
@Override
public FlowApplyResult apply(LeaveApply leave, String bizId, String bizTitle) {
FlowApplyBody body = buildApplyBody(leave, bizId, bizTitle);
try {
FlowApplyResult result = YschFlowOpenApi.apply(leave.getSchId(), body, FlowApplyResult.class);
if (result == null || result.getInstanceId() == null) {
throw new IllegalStateException("流程中心未返回流程实例ID");
}
log.info("[flow] 发起流程成功: leaveId={}, bizId={}, instanceId={}, duplicated={}",
leave.getId(), bizId, result.getInstanceId(), result.isDuplicated());
return result;
} catch (Exception e) {
log.error("[flow] 发起流程失败: leaveId={}, bizId={}", leave.getId(), bizId, e);
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "发起审批流程失败:" + e.getMessage());
}
}
/**
* 构造流程发起契约。保持包级可见,供契约单测直接断言,避免测试触发真实 OpenAPI。
*/
FlowApplyBody buildApplyBody(LeaveApply leave, String bizId, String bizTitle) {
FlowApplyBody body = new FlowApplyBody();
body.setFlowGuid(StringUtils.hasText(leave.getFlowGuid()) ? leave.getFlowGuid() : defaultFlowGuid);
body.setBizType(BIZ_TYPE);
body.setBizId(bizId);
body.setBizTitle(truncate(bizTitle, TITLE_MAX));
body.setBizJump(LeaveFlowPages.detail(leave.getId()));
body.setBizSnapshot(buildSnapshot(leave));
body.setSubmitterId(leave.getUserId());
body.setSubmitterName(leave.getUserName());
body.setFormData(buildFormData(leave));
return body;
}
// ============================ 撤回 ============================
@Override
public void withdraw(Integer schId, Long instanceId, Integer submitterId) {
if (instanceId == null) {
throw new BizException(ErrorCode.FORBIDDEN, "该单据尚未发起审批流程,无法撤回");
}
try {
YschFlowOpenApi.withdraw(schId, instanceId, submitterId == null ? 0 : submitterId);
log.info("[flow] 撤回成功: instanceId={}, submitterId={}", instanceId, submitterId);
} catch (Exception e) {
log.error("[flow] 撤回失败: instanceId={}", instanceId, e);
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "撤回失败:" + e.getMessage());
}
}
// ============================ 待办 / 审核 ============================
@Override
public FlowTodoTaskPage todoTasks(Integer schId, Integer assigneeId, Integer page, Integer size, String bizType) {
try {
return YschFlowOpenApi.todoTasks(schId == null ? 0L : schId, assigneeId == null ? 0 : assigneeId,
bizType, page == null ? 1 : page, size == null ? 10 : size);
} catch (Exception e) {
log.error("[flow] 待办查询失败: schId={}, assigneeId={}", schId, assigneeId, e);
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "获取待办列表失败:" + e.getMessage());
}
}
@Override
public void approveTask(Integer schId, Long taskId, Integer operatorId, String operatorName, String opinion) {
try {
YschFlowOpenApi.approveTask(schId == null ? 0L : schId, taskId,
operatorId == null ? 0 : operatorId, operatorName, opinion);
log.info("[flow] 审核同意: taskId={}, operatorId={}", taskId, operatorId);
} catch (Exception e) {
log.error("[flow] 审核同意失败: taskId={}", taskId, e);
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "审核提交失败:" + e.getMessage());
}
}
@Override
public void rejectTask(Integer schId, Long taskId, Integer operatorId, String operatorName, String opinion) {
try {
YschFlowOpenApi.rejectTask(schId == null ? 0L : schId, taskId,
operatorId == null ? 0 : operatorId, operatorName, opinion);
log.info("[flow] 审核拒绝: taskId={}, operatorId={}", taskId, operatorId);
} catch (Exception e) {
log.error("[flow] 审核拒绝失败: taskId={}", taskId, e);
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "审核提交失败:" + e.getMessage());
}
}
// ============================ 进度 / 流程图 ============================
@Override
public FlowProgress progress(LeaveApply leave) {
if (leave == null) {
return null;
}
try {
return YschFlowOpenApi.progress(leave.getInstanceId(), BIZ_TYPE, leave.getBizId(),
leave.getSchId() == null ? 0L : leave.getSchId(), FlowProgress.class);
} catch (Exception e) {
log.warn("[flow] 进度查询失败(降级空进度): leaveId={}, err={}", leave.getId(), e.getMessage());
return null;
}
}
@Override
public String diagramUrl(LeaveApply leave) {
if (leave == null || leave.getInstanceId() == null) {
return null;
}
try {
FlowDiagramUrl url = YschFlowOpenApi.diagramUrl(leave.getInstanceId(), leave.getFlowGuid(),
"track", leave.getSchId() == null ? 0L : leave.getSchId(), FlowDiagramUrl.class);
return url == null ? null : url.getImgUrl();
} catch (Exception e) {
log.warn("[flow] 流程图查询失败: leaveId={}, err={}", leave.getId(), e.getMessage());
return null;
}
}
@Override
public FlowProgressVo toProgressVo(FlowProgress progress) {
if (progress == null) {
return null;
}
FlowProgressVo vo = new FlowProgressVo();
vo.setInstanceId(progress.getInstanceId());
vo.setFlowName(progress.getBizTitle());
vo.setStatus(progress.getStatus() == null ? null : String.valueOf(progress.getStatus()));
vo.setCurrentNodeName(progress.getCurrentNodes());
vo.setSubmitterName(progress.getSubmitterName());
vo.setStartTime(progress.getSubmitTime());
vo.setEndTime(progress.getFinishTime());
List<FlowProgressVo.StepVo> steps = new ArrayList<>();
for (FlowProgressStep step : stepsOf(progress)) {
List<FlowProgressTask> tasks = tasksOf(step);
if (tasks == null || tasks.isEmpty()) {
// 无处理人的节点(尚未到达)也展示为待处理
FlowProgressVo.StepVo s = new FlowProgressVo.StepVo();
s.setNodeName(step.getNodeName());
s.setFinished(!step.isActive() && false);
s.setAction(step.getLabel());
steps.add(s);
continue;
}
for (FlowProgressTask task : tasks) {
FlowProgressVo.StepVo s = new FlowProgressVo.StepVo();
s.setNodeName(step.getNodeName());
s.setAssigneeName(task.getUserName());
s.setAction(StringUtils.hasText(task.getStatusLabel()) ? task.getStatusLabel() : step.getLabel());
s.setOpinion(task.getOpinion());
s.setTime(StringUtils.hasText(task.getHandleTime()) ? task.getHandleTime() : task.getArriveTime());
s.setFinished(StringUtils.hasText(task.getHandleTime()));
steps.add(s);
}
}
vo.setSteps(steps);
return vo;
}
@Override
public List<FlowTodoTask> contentOf(FlowTodoTaskPage page) {
if (page == null || page.getContent() == null) {
return Collections.emptyList();
}
return page.getContent();
}
@Override
public List<FlowProgressStep> stepsOf(FlowProgress progress) {
if (progress == null || progress.getSteps() == null) {
return Collections.emptyList();
}
return progress.getSteps();
}
@Override
public List<FlowProgressTask> tasksOf(FlowProgressStep step) {
if (step == null || step.getAssignees() == null) {
return Collections.emptyList();
}
return step.getAssignees();
}
// ============================ 内部方法 ============================
/** 只读摘要(手册坑 5:勿放大字段/富文本) */
private Map<String, Object> buildSnapshot(LeaveApply leave) {
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("leaveTypeName", leave.getLeaveTypeName());
snapshot.put("startTime", leave.getStartTime());
snapshot.put("endTime", leave.getEndTime());
snapshot.put("totalHours", leave.getTotalHours());
snapshot.put("reason", truncate(leave.getReason(), 100));
return snapshot;
}
/** 驱动条件路由/会签的表单数据 */
private Map<String, Object> buildFormData(LeaveApply leave) {
Map<String, Object> form = new LinkedHashMap<>();
form.put("leaveType", leave.getLeaveType());
form.put("leaveTypeName", leave.getLeaveTypeName());
form.put("totalHours", leave.getTotalHours());
form.put("days", leave.getTotalHours() == null ? 0 : Math.round(leave.getTotalHours() / 8D * 100D) / 100D);
return form;
}
private String truncate(String value, int max) {
if (value == null) {
return null;
}
return value.length() <= max ? value : value.substring(0, max);
}
}
@@ -0,0 +1,14 @@
package com.yunhai.workleave.model;
import lombok.Data;
/**
* 统一分页查询入参。pageIndex 从 1 起。
*/
@Data
public class BasePageQuery {
private int pageIndex = 1;
private int pageSize = 10;
/** 通用模糊搜索关键词 */
private String keyword;
}
@@ -0,0 +1,33 @@
package com.yunhai.workleave.model;
import lombok.Data;
import java.util.List;
/**
* 统一分页响应外壳。
*/
@Data
public class BasePageResult<T> {
/** 总记录数 */
private int total;
/** 当前页数据 */
private List<T> content;
/** 当前页码(从 1 起) */
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,106 @@
package com.yunhai.workleave.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/**
* 班级管理接口-教师(bschclass {@code /oapi/DbClass/GetSchteaList} 响应项)。
* 对应 C# BestSchool.Entity.Model.SchTeaList。
*/
public class SchTeaList {
/** 学校 GUID(SchSerID) */
@JsonProperty("SchSerID")
private String schSerID;
@JsonProperty("TeaTel")
private String teaTel;
/** 教师姓名 */
@JsonProperty("TeaName")
private String teaName;
/** 教师用户ID */
@JsonProperty("UserTeaSerID")
private Integer userTeaSerID;
@JsonProperty("TeaIDCard")
private String teaIDCard;
/** 教师工号 */
@JsonProperty("TeaNo")
private String teaNo;
/** 教师所属组ID列表(兼容旧字段 GroupIds) */
@JsonProperty("GroupIds")
private List<Integer> groupIds;
/** 教师所属组ID(接口现行字段 GroupID,单个) */
@JsonProperty("GroupID")
private Integer groupId;
public Integer getGroupId() {
return groupId;
}
public void setGroupId(Integer groupId) {
this.groupId = groupId;
}
public String getSchSerID() {
return schSerID;
}
public void setSchSerID(String schSerID) {
this.schSerID = schSerID;
}
public String getTeaTel() {
return teaTel;
}
public void setTeaTel(String teaTel) {
this.teaTel = teaTel;
}
public String getTeaName() {
return teaName;
}
public void setTeaName(String teaName) {
this.teaName = teaName;
}
public Integer getUserTeaSerID() {
return userTeaSerID;
}
public void setUserTeaSerID(Integer userTeaSerID) {
this.userTeaSerID = userTeaSerID;
}
public String getTeaIDCard() {
return teaIDCard;
}
public void setTeaIDCard(String teaIDCard) {
this.teaIDCard = teaIDCard;
}
public String getTeaNo() {
return teaNo;
}
public void setTeaNo(String teaNo) {
this.teaNo = teaNo;
}
public List<Integer> getGroupIds() {
return groupIds;
}
public void setGroupIds(List<Integer> groupIds) {
this.groupIds = groupIds;
}
}
@@ -0,0 +1,29 @@
package com.yunhai.workleave.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 用户系统角色上下文(来自请求头 currentrole,URL 编码 JSON:{childId, roleId, schId, userId})。
* 请假模块不使用 childId,仅透传平台统一的 currentrole 上下文。
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class UserSysRole {
/** 子账号/孩子ID,0=主账号 */
private Integer childId;
/** 角色ID */
private Integer roleId;
/** 学校ID(数据隔离关键字段) */
private Integer schId;
/** 用户ID(部分平台版本携带) */
private Integer userId;
}
@@ -0,0 +1,12 @@
package com.yunhai.workleave.model.base;
import lombok.Data;
/**
* 角色切换入参(对齐前端 baseApi.setRole)。
*/
@Data
public class SetRoleRequest {
/** "teacher" 或 "parent" */
private String as;
}
@@ -0,0 +1,15 @@
package com.yunhai.workleave.model.base;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 角色切换结果。
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SetRoleResult {
private String role;
}
@@ -0,0 +1,29 @@
package com.yunhai.workleave.model.bsch;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.List;
/**
* 年级→班级树节点(对齐前端 ClassTreeNode;叶子=班级,附学生数)。
*/
@Data
public class ClassTreeNodeVo {
private Integer id;
private String name;
/** 前端固定字段名 isParent */
@JsonProperty("isParent")
private boolean parent;
/** 叶子(班级)附学生数 */
private Integer studentCount;
private List<ClassTreeNodeVo> children;
public void setParent(boolean parent) {
this.parent = parent;
}
public boolean isParent() {
return parent;
}
}
@@ -0,0 +1,16 @@
package com.yunhai.workleave.model.file;
import lombok.Data;
/**
* 文件上传结果(对齐前端 UpFileResultVo,含旧字段 fileid/host/fileurl)。
*/
@Data
public class UpFileResultVo {
private Integer fileid;
private String name;
private String fileurl;
private String host;
private String type;
private Integer size;
}
@@ -0,0 +1,25 @@
package com.yunhai.workleave.model.file;
import com.yunhai.workleave.entity.UpFile;
import lombok.Data;
import org.springframework.beans.BeanUtils;
/**
* 附件视图对象(对齐前端 UpFileVo)。
*/
@Data
public class UpFileVo {
private Integer id;
private String fileTrueName;
private String fileName;
private Integer fileSize;
private String fileType;
private Integer topicId;
private String topicType;
public static UpFileVo from(UpFile entity) {
UpFileVo vo = new UpFileVo();
BeanUtils.copyProperties(entity, vo);
return vo;
}
}
@@ -0,0 +1,37 @@
package com.yunhai.workleave.model.leave;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.util.List;
/**
* 流程绑定入参。
*/
@Data
public class FlowBindRequest {
/** 绑定范围:0=学校默认流程 / 1=按请假类型指定 */
@NotNull(message = "绑定范围不能为空")
private Integer scope;
/** 范围键:scope=1 时与 scopeKeys 二选一(兼容旧单选入参) */
private String scopeKey;
/** 范围键列表:scope=1 时至少一项(请假类型 dictKey),一次把多个类型绑到同一流程 */
private List<String> scopeKeys;
/** 流程定义标识(flowGuid) */
@NotBlank(message = "流程标识不能为空")
private String flowGuid;
/** 流程名称(可空;为空时由流程中心列表回填) */
private String flowName;
/** 流程版本(可空;由流程中心列表回填) */
private Integer flowVersion;
/** 节点摘要(可空;由流程中心列表回填) */
private String nodeSummary;
}
@@ -0,0 +1,40 @@
package com.yunhai.workleave.model.leave;
import lombok.Data;
/**
* 已绑定流程一行(绑定关系表)。
*/
@Data
public class FlowBindVo {
/** 绑定记录ID */
private Integer id;
/** 绑定范围:0=学校默认 / 1=按请假类型 */
private Integer scope;
/** 范围键(scope=1 为请假类型 dictKey) */
private String scopeKey;
/** 范围显示名:「全校默认」或请假类型名称 */
private String scopeName;
/** 流程定义标识 */
private String flowGuid;
/** 流程名称 */
private String flowName;
/** 流程版本 */
private Integer flowVersion;
/** 节点摘要 */
private String nodeSummary;
/**
* 该 flowGuid 是否仍存在于流程中心。
* 流程中心不可达时为 null(未知),前端不做告警。
*/
private Boolean existsInCenter;
}
@@ -0,0 +1,28 @@
package com.yunhai.workleave.model.leave;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 流程启用入参(/flow/Enable)。
* <p>启用只关心流程本身(标记本应用可用 + 回调登记),与绑定范围无关;
* 历史 version 曾复用 FlowBindRequest 导致 scope 被误校验必填,前端只能传 0 绕过。
* 兼容旧调用方:请求体多传的 scope/scopeKey 会被 Jackson 忽略,不报错。
*/
@Data
public class FlowEnableRequest {
/** 流程定义标识(flowGuid) */
@NotBlank(message = "流程标识不能为空")
private String flowGuid;
/** 流程名称(可空;为空时由流程中心列表回填) */
private String flowName;
/** 流程版本(可空;由流程中心列表回填) */
private Integer flowVersion;
/** 节点摘要(可空;由流程中心列表回填) */
private String nodeSummary;
}
@@ -0,0 +1,38 @@
package com.yunhai.workleave.model.leave;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 流程中心流程项(流程列表一行)+ 本校绑定状态。
* <p>数据源:{@code YschFlowOpenApi.listFlows}(FlowSummary)。</p>
*/
@Data
public class FlowItemVo {
/** 流程定义标识(flowGuid) */
private String flowGuid;
/** 流程名称 */
private String name;
/** 流程版本 */
private Integer version;
/** 节点摘要(如「发起 → 部门审核 → 校长审批」) */
private String nodeSummary;
/** 是否为本校默认流程 */
private boolean defaultBind = false;
/** 是否已启用并绑定到本应用(未启用仅展示,不可被绑定) */
private boolean enabled = false;
/** 本流程被绑定到的请假类型 key 列表 */
private List<String> typeKeys = new ArrayList<>();
/** 本流程被绑定到的请假类型名称列表(与 typeKeys 同序) */
private List<String> typeNames = new ArrayList<>();
}
@@ -0,0 +1,37 @@
package com.yunhai.workleave.model.leave;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 流程管理页聚合视图:流程列表 + 已绑定关系 + 请假类型。
*/
@Data
public class FlowManageVo {
/** 本应用 bizType(手册 §3.2:恒等于 app_code,含 leave 一条) */
private String bizType = "leave";
/** 未绑定任何流程时的兜底 flowGuid(配置 leave.flow.guid) */
private String configFlowGuid;
/**
* 流程中心是否可达。false 时流程列表为空、只展示已绑定关系,
* 前端据此提示「流程中心暂不可达,仅可查看/解除已有绑定」。
*/
private boolean centerReachable = true;
/** 流程中心不可达/查询失败的原因摘要(可达时为 null) */
private String centerError;
/** 流程中心流程列表 */
private List<FlowItemVo> flows = new ArrayList<>();
/** 本校已绑定关系(默认流程排在最前) */
private List<FlowBindVo> binds = new ArrayList<>();
/** 请假类型(供「按类型绑定」选择):{key, name} */
private List<LeaveTypeOptionVo> leaveTypes = new ArrayList<>();
}
@@ -0,0 +1,55 @@
package com.yunhai.workleave.model.leave;
import lombok.Data;
import java.util.List;
/**
* 流程进度视图(包装流程中心 FlowProgress,端上只读渲染时间线)。
*/
@Data
public class FlowProgressVo {
/** 流程实例ID */
private Long instanceId;
/** 流程标识 */
private String flowGuid;
/** 流程名称 */
private String flowName;
/** 实例状态(流程中心口径) */
private String status;
/** 当前节点名 */
private String currentNodeName;
/** 发起人姓名 */
private String submitterName;
/** 发起时间 */
private String startTime;
/** 结束时间 */
private String endTime;
/** 节点/任务列表(按时间正序) */
private List<StepVo> steps;
@Data
public static class StepVo {
/** 节点名 */
private String nodeName;
/** 处理人姓名(可空:待办中) */
private String assigneeName;
/** 处理结果/动作(同意/拒绝/待处理等) */
private String action;
/** 审批意见 */
private String opinion;
/** 时间 */
private String time;
/** 是否已完成 */
private Boolean finished;
}
}
@@ -0,0 +1,27 @@
package com.yunhai.workleave.model.leave;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
/**
* 审核动作入参(同意/拒绝)。
* <p>只调用流程中心 approveTask/rejectTask,本地状态由回调推进(不在本接口直改)。</p>
*/
@Data
public class LeaveAuditRequest {
@NotNull(message = "任务ID不得为空")
private Long taskId;
/** 本地单据ID(审核列表已带出,用于落本地留痕) */
private Integer leaveId;
/** approve=同意 / reject=拒绝 */
@NotBlank(message = "审核动作不得为空")
private String action;
/** 审批意见(拒绝时必填,服务端校验) */
private String opinion;
}
@@ -0,0 +1,18 @@
package com.yunhai.workleave.model.leave;
import lombok.Data;
import javax.validation.constraints.NotNull;
/**
* 销假入参(已通过后发起)。
*/
@Data
public class LeaveCancelRequest {
@NotNull(message = "单据ID不得为空")
private Integer id;
/** 销假备注 */
private String reason;
}
@@ -0,0 +1,82 @@
package com.yunhai.workleave.model.leave;
import lombok.Data;
import java.util.List;
/**
* 请假详情视图(详情页/移动端消息跳转落地页)。
*/
@Data
public class LeaveDetailVo {
private Integer id;
private Integer userId;
private String userName;
private String userPhoto;
private String leaveType;
private String leaveTypeName;
private String startTime;
private String endTime;
private Double totalHours;
private String reason;
/** 课务安排:0 无 / 1 自行 / 2 教务 */
private Integer courseArrange;
private String courseArrangeName;
private Integer status;
private String statusName;
/** 申请时间(打印请假单展示用) */
private String applyTime;
/** 附件列表 */
private List<FileItem> files;
/** 抄送人(发起时选择 + 流程抄送留痕合并去重) */
private List<CcItem> ccUsers;
/** 流程进度(时间线;流程中心不可达时为 null) */
private FlowProgressVo progress;
/** 流程图短时 URL(流程中心不可达时为 null) */
private String diagramUrl;
/** 终态快照 JSON(审批意见等) */
private String snapshot;
/** 单据使用的审批流程 flowGuid(编辑重提时前端回显用) */
private String flowGuid;
/** 销假信息 */
private String cancelTime;
private String cancelReason;
/** 当前登录用户能否审核该单(审核列表用) */
private Boolean canAudit;
/** 当前登录用户是否可编辑/销假/撤回/删除 */
private Boolean canEdit;
private Boolean canCancel;
private Boolean canWithdraw;
private Boolean canDelete;
@Data
public static class FileItem {
private Integer fileId;
private String fileName;
private String fileUrl;
private Integer fileSize;
private String fileType;
}
@Data
public static class CcItem {
private Integer userId;
private String userName;
}
}
@@ -0,0 +1,29 @@
package com.yunhai.workleave.model.leave;
import com.yunhai.workleave.model.BasePageQuery;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 请假列表查询条件。
*/
@EqualsAndHashCode(callSuper = true)
@Data
public class LeaveQuery extends BasePageQuery {
/** 视角:user=我的 / all=全部(管理端)/ cc=抄送我的 */
private String scope;
/** 状态筛选(null/负数=全部) */
private Integer status;
/** 时间区间(yyyy-MM-dd,按请假开始/结束日期重叠过滤) */
private String startTime;
private String endTime;
/** 指定申请人(管理端按人筛选,user_id) */
private Integer userId;
/** 指定请假类型 dictKey */
private String leaveType;
}
@@ -0,0 +1,47 @@
package com.yunhai.workleave.model.leave;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 请假申请提交/编辑入参。
* <p>时间只提交字符串(yyyy-MM-dd HH:mm),服务端按学校作息复核时长。</p>
*/
@Data
public class LeaveRequest {
/** 编辑/重新提交时的单据ID(新建为空) */
private Integer id;
@NotBlank(message = "请假类型不得为空")
private String leaveType;
@NotBlank(message = "开始时间不得为空")
private String startTime;
@NotBlank(message = "结束时间不得为空")
private String endTime;
/** 请假事由(是否必填由学校设置决定,服务端二次校验) */
private String reason;
/** 附件ID CSV */
private String fileIds;
/** 抄送人用户ID CSV */
private String ccUserIds;
/** 课务安排:0 无课务安排 / 1 自行安排 / 2 教务安排(学校启用课务安排时必传) */
private Integer courseArrange;
/** 前端试算时长(小时);服务端复核后以复核值落库 */
private Double totalHours;
/**
* 发起时指定的审批流程 flowGuid(申请页下拉选择,可空)。
* <p>为空时服务端按绑定解析:按请假类型绑定 → 学校默认绑定 → 配置 leave.flow.guid。</p>
*/
private String flowGuid;
}
@@ -0,0 +1,31 @@
package com.yunhai.workleave.model.leave;
import lombok.Data;
/**
* 学校请假设置视图/入参。
*/
@Data
public class LeaveSettingVo {
/** 请假事由是否必填 0/1 */
private Integer reasonMust;
/** 是否显示请假说明 0/1 */
private Integer showExplain;
/** 是否启用课务安排 0/1(P7 预留) */
private Integer enableCourse;
/** 是否允许换课 0/1(P7 预留) */
private Integer allowExchange;
/** 时长管控的请假类型 CSV(dictKey) */
private String timeManagedTypes;
/** 是否启用时长管控 0/1 */
private Integer timeLimitEnabled;
/** 请假计算口径:1 按小时 / 0 按天(半天/半天以上输入模式) */
private Integer calcWay;
}
@@ -0,0 +1,48 @@
package com.yunhai.workleave.model.leave;
import lombok.Data;
import java.util.List;
/**
* 请假统计视图。
*/
@Data
public class LeaveStatsVo {
/** 区间内请假单数(口径:状态 ∈ 已通过) */
private Integer totalCount;
/** 区间内请假总时长(小时) */
private Double totalHours;
/** 折合天数(总时长/8,保留 1 位) */
private Double totalDays;
/** 出现最多的类型名 */
private String topTypeName;
/** 按类型分布 */
private List<TypeStat> typeStats;
/** 按月份趋势 */
private List<MonthStat> monthStats;
@Data
public static class TypeStat {
private String leaveType;
private String leaveTypeName;
/** 单数 */
private Integer count;
/** 时长(小时) */
private Double hours;
}
@Data
public static class MonthStat {
/** yyyy-MM */
private String month;
private Integer count;
private Double hours;
}
}
@@ -0,0 +1,20 @@
package com.yunhai.workleave.model.leave;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 请假类型选项(流程绑定下拉用)。
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LeaveTypeOptionVo {
/** 类型 key(sys_dict.dict_key,请假单 leave_type 存此值) */
private String key;
/** 类型名称 */
private String name;
}
@@ -0,0 +1,55 @@
package com.yunhai.workleave.model.leave;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 请假列表行视图(列表/抄送共用)。
*/
@Data
public class LeaveVo {
private Integer id;
private Integer userId;
private String userName;
private String userPhoto;
private String leaveType;
private String leaveTypeName;
/** yyyy-MM-dd HH:mm:ss */
private String startTime;
private String endTime;
/** 时长(小时) */
private Double totalHours;
private String reason;
/** 课务安排:0 无 / 1 自行 / 2 教务 */
private Integer courseArrange;
private String courseArrangeName;
/** 附件ID CSV */
private String fileIds;
/** 状态码 + 名称 */
private Integer status;
private String statusName;
private LocalDateTime applyTime;
/** 流程实例ID(有值时可拉进度/流程图) */
private Long instanceId;
/** 当前登录用户是否可编辑/销假/撤回/删除(前端据此置灰) */
private Boolean canEdit;
private Boolean canCancel;
private Boolean canWithdraw;
private Boolean canDelete;
/** 抄送列表场景:我的抄送时间 */
private LocalDateTime ccTime;
}
@@ -0,0 +1,24 @@
package com.yunhai.workleave.model.leave;
import lombok.Data;
/**
* 节次视图/入参。
*/
@Data
public class SectionVo {
private Integer id;
/** 节次名称 */
private String name;
/** 排序号 */
private Integer rankNo;
/** 开始时间 HH:mm */
private String beginTime;
/** 结束时间 HH:mm */
private String endTime;
}
@@ -0,0 +1,33 @@
package com.yunhai.workleave.model.leave;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 学校作息时间入参/视图(Get/Set 共用)。
*/
@Data
public class TimeSetVo {
/** 上午开始 HH:mm */
@NotBlank
private String forenoon1;
/** 上午结束 HH:mm */
@NotBlank
private String forenoon2;
/** 下午开始 HH:mm */
@NotBlank
private String afternoon1;
/** 下午结束 HH:mm */
@NotBlank
private String afternoon2;
/** 最短请假间隔(分钟) */
private Integer intervalMinutes;
/** 最少请假时间(分钟,0 = 不限制) */
private Integer minMinutes;
}
@@ -0,0 +1,47 @@
package com.yunhai.workleave.model.leave;
import lombok.Data;
/**
* 待办列表行(流程中心 todoTasks + 本地单据补全)。
*/
@Data
public class TodoVo {
/** 流程任务ID(审核提交用) */
private Long taskId;
/** 流程实例ID */
private Long instanceId;
/** 流程版本(并发提示用) */
private Integer instanceVersion;
/** 本地单据ID(bizId 解析所得) */
private Integer leaveId;
/** 申请人 */
private Integer userId;
private String userName;
private String leaveType;
private String leaveTypeName;
private String startTime;
private String endTime;
private Double totalHours;
private String reason;
/** 单据状态名(审批中/已通过…) */
private Integer status;
private String statusName;
/** 流程节点名("部门负责人审批"等) */
private String nodeName;
/** 任务到达时间 */
private String arriveTime;
/** 标题(流程中心 bizTitle,本地单据缺失时兜底展示) */
private String bizTitle;
}
@@ -0,0 +1,22 @@
package com.yunhai.workleave.model.leave;
import lombok.Data;
import javax.validation.constraints.NotNull;
/**
* 时长调整行(批量调整数组元素)。
*/
@Data
public class UserTimeAdjustItem {
@NotNull(message = "教师ID不得为空")
private Integer userId;
/** 增减小时(正=新加,负=扣减) */
@NotNull(message = "调整时长不得为空")
private Double editHours;
/** 备注 */
private String remark;
}
@@ -0,0 +1,19 @@
package com.yunhai.workleave.model.leave;
import lombok.Data;
import javax.validation.Valid;
import javax.validation.constraints.NotEmpty;
import java.util.List;
/**
* 时长批量调整入参。
*/
@Data
public class UserTimeAdjustRequest {
@NotEmpty(message = "调整列表不得为空")
@Valid
private List<UserTimeAdjustItem> items;
}
@@ -0,0 +1,70 @@
package com.yunhai.workleave.model.leave;
import java.util.ArrayList;
import java.util.List;
/**
* 教师时长 Excel 导入结果(对齐旧版 ImportQJSchUserTime 语义:任一行出错则整体不导入)。
*/
public class UserTimeImportResult {
private boolean success;
private String message;
/** 错误明细(行号从 1 计,对应数据行) */
private List<RowError> errors = new ArrayList<>();
public static class RowError {
private int row;
private String msg;
public RowError() {
}
public RowError(int row, String msg) {
this.row = row;
this.msg = msg;
}
public int getRow() {
return row;
}
public void setRow(int row) {
this.row = row;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
}
public boolean isSuccess() {
return success;
}
public void setSuccess(boolean success) {
this.success = success;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public List<RowError> getErrors() {
return errors;
}
public void setErrors(List<RowError> errors) {
this.errors = errors;
}
}
@@ -0,0 +1,42 @@
package com.yunhai.workleave.model.leave;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 时长流水行视图。
*/
@Data
public class UserTimeRecordVo {
private Integer id;
private Integer userId;
private String userName;
/** 关联请假单ID(可空) */
private Integer leaveId;
private String leaveType;
private String leaveTypeName;
/** 本次增减(小时,正负) */
private Double editHours;
private Double oldHours;
private Double newHours;
/** 备注(扣除/销假返还/新加…) */
private String describeText;
/** 操作人 */
private Integer operatorId;
private String operatorName;
private LocalDateTime occurTime;
/** 展示用:请假单起止(关联单据可查时填充) */
private String startTime;
private String endTime;
}
@@ -0,0 +1,16 @@
package com.yunhai.workleave.model.leave;
import lombok.Data;
/**
* 时长合计视图。
*/
@Data
public class UserTimeSumVo {
/** 区间内新加总时长(Σ 增减,小时) */
private Double addHours;
/** 当前剩余总时长(Σ usable,小时) */
private Double remainHours;
}
@@ -0,0 +1,24 @@
package com.yunhai.workleave.model.leave;
import lombok.Data;
/**
* 教师时长列表行。
*/
@Data
public class UserTimeVo {
/** 时长池记录ID(无记录时为 null,前端按 0 展示) */
private Integer id;
private Integer userId;
private String userName;
/** 部门/组名(列表展示,可空) */
private String deptName;
/** 剩余可用小时 */
private Double usableHours;
/** 备注 */
private String remark;
}
@@ -0,0 +1,16 @@
package com.yunhai.workleave.repository;
import com.yunhai.workleave.entity.ContactGroup;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
/**
* 联系组仓储。
*/
public interface ContactGroupRepository extends JpaRepository<ContactGroup, Integer> {
List<ContactGroup> findBySchIdOrderByIdDesc(Integer schId);
List<ContactGroup> findByIdIn(List<Integer> ids);
}
@@ -0,0 +1,22 @@
package com.yunhai.workleave.repository;
import com.yunhai.workleave.entity.ContactGroupUser;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 联系组-成员关联仓储。
*/
public interface ContactGroupUserRepository extends JpaRepository<ContactGroupUser, Integer> {
List<ContactGroupUser> findByGroupId(Integer groupId);
List<ContactGroupUser> findByGroupIdIn(List<Integer> groupIds);
@Modifying
@Transactional
void deleteByGroupId(Integer groupId);
}
@@ -0,0 +1,28 @@
package com.yunhai.workleave.repository;
import com.yunhai.workleave.entity.LeaveApply;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.util.List;
import java.util.Optional;
/**
* 请假申请仓储。
*/
public interface LeaveApplyRepository extends JpaRepository<LeaveApply, Integer>, JpaSpecificationExecutor<LeaveApply> {
/** 按主键与学校取单(防跨校越权) */
Optional<LeaveApply> findByIdAndSchId(Integer id, Integer schId);
/** 流程实例定位(回调按 bizType+bizId 或 instanceId 找回单据) */
Optional<LeaveApply> findByBizId(String bizId);
Optional<LeaveApply> findByInstanceId(Long instanceId);
/** 同人同状态集合(时间冲突校验/在途占用统计用) */
List<LeaveApply> findBySchIdAndUserIdAndStatusIn(Integer schId, Integer userId, List<Integer> statuses);
/** 某类型被引用过(字典删除前置校验) */
boolean existsBySchIdAndLeaveType(Integer schId, String leaveType);
}
@@ -0,0 +1,21 @@
package com.yunhai.workleave.repository;
import com.yunhai.workleave.entity.LeaveAuditTrace;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.util.List;
/**
* 审核痕迹仓储。
*/
public interface LeaveAuditTraceRepository extends JpaRepository<LeaveAuditTrace, Integer>, JpaSpecificationExecutor<LeaveAuditTrace> {
Page<LeaveAuditTrace> findBySchIdAndOperatorIdOrderByAuditTimeDesc(Integer schId, Integer operatorId, Pageable pageable);
List<LeaveAuditTrace> findBySchIdAndLeaveIdOrderByAuditTimeAsc(Integer schId, Integer leaveId);
boolean existsByLeaveIdAndTaskIdAndAction(Integer leaveId, Long taskId, String action);
}
@@ -0,0 +1,19 @@
package com.yunhai.workleave.repository;
import com.yunhai.workleave.entity.LeaveCc;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
/**
* 抄送留痕仓储。
*/
public interface LeaveCcRepository extends JpaRepository<LeaveCc, Integer> {
List<LeaveCc> findBySchIdAndLeaveId(Integer schId, Integer leaveId);
List<LeaveCc> findBySchIdAndCcUserIdOrderByIdDesc(Integer schId, Integer ccUserId);
/** 回调幂等:同实例同人不重复落痕 */
boolean existsByLeaveIdAndCcUserIdAndInstanceId(Integer leaveId, Integer ccUserId, Long instanceId);
}
@@ -0,0 +1,27 @@
package com.yunhai.workleave.repository;
import com.yunhai.workleave.entity.LeaveFlowBind;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
/**
* 审批流程绑定仓储。
* <p>唯一性由 (sch_id, bind_scope, scope_key) 逻辑保证,服务层按此三元组 upsert,
* 不建物理唯一索引——避免不同环境下 ddl-auto=update 追加约束失败。</p>
*/
public interface LeaveFlowBindRepository extends JpaRepository<LeaveFlowBind, Integer> {
/** 学校全部绑定(含默认与各类型) */
List<LeaveFlowBind> findBySchId(Integer schId);
/** 学校 + 范围 + 范围键(默认流程的 scopeKey 固定为 "-") */
Optional<LeaveFlowBind> findBySchIdAndBindScopeAndScopeKey(Integer schId, Integer bindScope, String scopeKey);
/** 学校下某范围的全部绑定(用于列出所有按类型绑定的记录) */
List<LeaveFlowBind> findBySchIdAndBindScope(Integer schId, Integer bindScope);
/** 学校下引用某条流程的全部绑定(关闭「启用绑定」时一并解除) */
List<LeaveFlowBind> findBySchIdAndFlowGuid(Integer schId, String flowGuid);
}
@@ -0,0 +1,21 @@
package com.yunhai.workleave.repository;
import com.yunhai.workleave.entity.LeaveFlowEnable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
/**
* 流程启用绑定仓储。
* <p>唯一性由 (sch_id, flow_guid) 逻辑保证,服务层按此二元组 upsert,
* 不建物理唯一索引——避免不同环境下 ddl-auto=update 追加约束失败。</p>
*/
public interface LeaveFlowEnableRepository extends JpaRepository<LeaveFlowEnable, Integer> {
/** 学校全部启用记录 */
List<LeaveFlowEnable> findBySchId(Integer schId);
/** 学校 + 流程标识(启用开关按此定位,upsert) */
Optional<LeaveFlowEnable> findBySchIdAndFlowGuid(Integer schId, String flowGuid);
}
@@ -0,0 +1,16 @@
package com.yunhai.workleave.repository;
import com.yunhai.workleave.entity.LeaveSection;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
/**
* 节次仓储。
*/
public interface LeaveSectionRepository extends JpaRepository<LeaveSection, Integer> {
List<LeaveSection> findBySchIdOrderByRankNoAsc(Integer schId);
long countBySchId(Integer schId);
}
@@ -0,0 +1,14 @@
package com.yunhai.workleave.repository;
import com.yunhai.workleave.entity.LeaveSetting;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
/**
* 学校请假设置仓储。
*/
public interface LeaveSettingRepository extends JpaRepository<LeaveSetting, Integer> {
Optional<LeaveSetting> findBySchId(Integer schId);
}
@@ -0,0 +1,14 @@
package com.yunhai.workleave.repository;
import com.yunhai.workleave.entity.LeaveTimeSet;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
/**
* 学校作息时间仓储。
*/
public interface LeaveTimeSetRepository extends JpaRepository<LeaveTimeSet, Integer> {
Optional<LeaveTimeSet> findBySchId(Integer schId);
}
@@ -0,0 +1,25 @@
package com.yunhai.workleave.repository;
import com.yunhai.workleave.entity.LeaveUserTimeRecord;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
/**
* 教师时长流水仓储。
*/
public interface LeaveUserTimeRecordRepository extends JpaRepository<LeaveUserTimeRecord, Integer>, JpaSpecificationExecutor<LeaveUserTimeRecord> {
/** 区间内加减合计(正负净额,统计页"新加总时长"口径) */
@Query("select coalesce(sum(r.editHours), 0) from LeaveUserTimeRecord r "
+ "where r.schId = :schId and r.occurTime >= :start and r.occurTime < :end")
Double sumEditHours(@Param("schId") Integer schId,
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
/** 按教师统计在途/已发生的请假是否已扣减(销假幂等辅助) */
boolean existsByLeaveIdAndDescribeText(Integer leaveId, String describeText);
}
@@ -0,0 +1,18 @@
package com.yunhai.workleave.repository;
import com.yunhai.workleave.entity.LeaveUserTime;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.util.List;
import java.util.Optional;
/**
* 教师时长池仓储。
*/
public interface LeaveUserTimeRepository extends JpaRepository<LeaveUserTime, Integer>, JpaSpecificationExecutor<LeaveUserTime> {
Optional<LeaveUserTime> findBySchIdAndUserId(Integer schId, Integer userId);
List<LeaveUserTime> findBySchIdAndUserIdIn(Integer schId, List<Integer> userIds);
}
@@ -0,0 +1,30 @@
package com.yunhai.workleave.repository;
import com.yunhai.workleave.entity.SysDict;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
/**
* 系统字典仓储。
*/
public interface SysDictRepository extends JpaRepository<SysDict, Integer> {
/** 某类型的全量字典(管理列表),sortOrder 升序 */
List<SysDict> findByDictTypeOrderBySortOrderAsc(String dictType);
/** 某学校自有的全量字典(管理列表),sortOrder 升序 */
List<SysDict> findByDictTypeAndSchIdOrderBySortOrderAsc(String dictType, Integer schId);
/** 学校自有字典启用项 */
List<SysDict> findByDictTypeAndSchIdAndEnableFlagOrderBySortOrderAsc(String dictType, Integer schId, Integer enableFlag);
/** 学校是否已有自有字典(首次进入初始化的判断依据) */
boolean existsByDictTypeAndSchId(String dictType, Integer schId);
Optional<SysDict> findByDictTypeAndDictKey(String dictType, String dictKey);
/** 限定 schId 的 key 精确查找(本校优先,再回退全局种子) */
Optional<SysDict> findFirstByDictTypeAndDictKeyAndSchId(String dictType, String dictKey, Integer schId);
}
@@ -0,0 +1,12 @@
package com.yunhai.workleave.repository;
import com.yunhai.workleave.entity.SysOperationLog;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
/**
* 系统审计日志仓储。
*/
public interface SysOperationLogRepository
extends JpaRepository<SysOperationLog, Long>, JpaSpecificationExecutor<SysOperationLog> {
}
@@ -0,0 +1,16 @@
package com.yunhai.workleave.repository;
import com.yunhai.workleave.entity.UpFile;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
/**
* 附件元信息仓储。
*/
public interface UpFileRepository extends JpaRepository<UpFile, Integer> {
List<UpFile> findByTopicTypeAndTopicId(String topicType, Integer topicId);
List<UpFile> findByIdIn(List<Integer> ids);
}
@@ -0,0 +1,58 @@
package com.yunhai.workleave.service;
import com.yunhai.workleave.model.SchTeaList;
import com.yunhai.workleave.model.bsch.ClassTreeNodeVo;
import java.util.List;
import java.util.Map;
/**
* 班级管理(bschclass)开放接口客户端。
* 基址取配置 {@code yh.open.bschclass}。
* <ul>
* <li>教师:GET {@code {bschclass}/oapi/DbClass/GetSchteaList?Group={schId}}</li>
* <li>学生树:GET {@code {bschclass}/oapi/SchGroup/GetStuTree?schId={schId}&toUser=true}
* (学校→学段→年级→班级→学生)</li>
* </ul>
*/
public interface IClassService {
/**
* 获取指定学校全部教师(含所属组 GroupIds)。
*/
List<SchTeaList> getSchTeaList(int schId);
/**
* 获取学校学生树原始节点(带 5 分钟内存缓存)。
* 节点字段:id/name/pid/type(1组 2学生)/isParent/children。
* 外部接口不可达时返回 null(调用方降级处理,不阻断业务)。
*/
List<Map<String, Object>> getStuTree(int schId);
/**
* 年级→班级树(叶子=班级,附 studentCount),由学生树派生。
* 外部接口不可达时返回空列表。
*/
List<ClassTreeNodeVo> getClassTree(int schId);
/** 班级学生数;班级不存在或数据不可得返回 0 */
int studentCountOf(int schId, Integer classId);
/** 班级名称;未知返回 null */
String classNameOf(int schId, Integer classId);
/** 年级名(按班级反查其父年级节点名);未知返回 null */
String gradeNameOf(int schId, Integer classId);
/**
* 学生定位:按学生ID在学生树中找到所在班级。
* 返回 {studentId, studentName, classId, className};找不到或不可得返回 null。
*/
Map<String, Object> resolveStudent(int schId, Integer studentId);
/**
* 班级→学生名单(roster):{studentId, studentName, classId, className} 列表。
* classIds 为空集合时返回空列表。
*/
List<Map<String, Object>> studentsOfClasses(int schId, List<Integer> classIds);
}
@@ -0,0 +1,32 @@
package com.yunhai.workleave.service;
import java.util.List;
import java.util.Map;
/**
* 自定义联系组业务接口(应用设置-自定义组)。
* 成员只存教师平台用户ID(不存账号),姓名经班级服务教师接口解析。
*/
public interface IContactsService {
/** 联系组分页:{list:[{serid, GroupName, count}], total} */
Map<String, Object> getGroups(int pageIndex, int pageSize);
/** 新增组,返回组ID */
Integer addGroup(String name);
/** 编辑组名 */
void editGroup(Integer id, String name);
/** 批量删除组 */
void deleteGroups(String ids);
/** 组成员分页(page=0 取全部):{list:[{id, name}], total} */
Map<String, Object> getContactsTea(Integer gid, int page, int count);
/** 设置组成员(全量覆盖,ids 逗号串) */
void setContactsTea(Integer gid, String ids);
/** 学校组织树(选人数据源,透传用户中心树) */
List<Map<String, Object>> getGroupTree();
}
@@ -0,0 +1,43 @@
package com.yunhai.workleave.service;
import java.util.List;
import java.util.Map;
/**
* 系统字典业务接口(请假类型 leaveType 域)。
*/
public interface IDictService {
/**
* 字典下拉(启用项)。前端 type=leaveType(兼容旧数字 1/2 入参)。
*
* @return 每项 {k: dictKey, v: 名称}
*/
List<Map<String, Object>> getSysDic(Integer type);
/**
* 字典管理列表(全量含禁用)。
*
* @param type 固定 "leaveType"
* @return 每项 {SerID, CodeID, Name, Des, Enable}
*/
List<Map<String, Object>> getDictMangeList(String type);
/** 新增字典项(按 (schId, type, name) 查重,重复报错) */
void addDict(String name, String type);
/** 更新字典项(名称/启停用,重名校验排除自身) */
void updateDict(Integer serId, String name, Integer enable, String type);
/** 删除字典项(删除前由调用方校验是否被请假单引用) */
void deleteDict(Integer serId, String type);
/** 排序(按前端顺序的 SerID 逗号串) */
void sortDict(String ids, String type);
/** 短信模块开关(Dict Sync 接口用) */
Boolean getSchModuleIsSms();
/** 按 dictKey 解析请假类型名称;未命中返回 null */
String resolveTypeName(String typeKey);
}
@@ -0,0 +1,94 @@
package com.yunhai.workleave.service;
import com.yunhai.workleave.entity.UpFile;
import com.yunhai.workleave.model.file.UpFileVo;
import com.yunhai.workleave.model.file.UpFileResultVo;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
/**
* 文件上传/下载/删除业务接口。
*/
public interface IFileService {
/**
* 上传单个文件。
*
* @param file 上传的文件
* @param appType 应用类型(固定 "leave")
* @param topicId 关联主题ID(可为空,通知保存后再绑定)
* @return 文件视图对象
*/
UpFileVo upload(MultipartFile file, String appType, Integer topicId);
/**
* 将已上传但未绑定主题的附件绑定到指定请假单。
*
* @param fileIds 文件记录ID列表
* @param topicId 通知ID
*/
void bindToTopic(List<Integer> fileIds, Integer topicId);
/**
* 查询某主题下的附件列表。
*
* @param appType 应用类型
* @param topicId 主题ID
* @return 附件列表
*/
List<UpFileVo> listByTopic(String appType, Integer topicId);
/**
* 下载文件。
*
* @param fileId 文件记录ID
* @param response HTTP 响应
*/
void download(Integer fileId, HttpServletResponse response);
/**
* 在线预览文件(Content-Disposition: inline,按扩展名输出真实 Content-Type,
* 供前端 open-file-viewer 取 Blob / video 播放)。
*
* @param fileId 文件记录ID
* @param response HTTP 响应
*/
void preview(Integer fileId, HttpServletResponse response);
/**
* 删除文件(同时删除物理文件和数据库记录)。
*
* @param fileId 文件记录ID
*/
void delete(Integer fileId);
/**
* 打包下载多个附件为 ZIP。
*
* @param files 附件记录列表(调用方按 fileIds 查好)
* @param zipName 压缩包显示名(自动追加 .zip)
* @param response HTTP 响应
*/
void downloadZip(List<UpFile> files, String zipName, HttpServletResponse response);
/**
* 按主题类型和主题ID打包下载附件为 ZIP(Controller 便捷方法,内部自行查附件列表)。
*
* @param topicType 主题类型(固定 "leave")
* @param topicId 主题ID
* @param zipName 压缩包显示名
* @param response HTTP 响应
*/
void downloadZipByTopic(String topicType, Integer topicId, String zipName, HttpServletResponse response);
/**
* 批量上传图片(循环上传,返回每个文件的视图对象列表)。
*/
List<UpFileVo> uploadImages(MultipartFile[] files, String appType);
/**
* 上传单个文件并返回前端兼容的结果 VO(含 fileid/name/fileurl/host/type/size)。
*/
UpFileResultVo uploadToResult(MultipartFile file, String appType);
}
@@ -0,0 +1,58 @@
package com.yunhai.workleave.service;
import com.yunhai.workleave.model.BasePageResult;
import com.yunhai.workleave.model.leave.LeaveAuditRequest;
import com.yunhai.workleave.model.leave.LeaveCancelRequest;
import com.yunhai.workleave.model.leave.LeaveDetailVo;
import com.yunhai.workleave.model.leave.LeaveQuery;
import com.yunhai.workleave.model.leave.LeaveRequest;
import com.yunhai.workleave.model.leave.LeaveStatsVo;
import com.yunhai.workleave.model.leave.LeaveVo;
import com.yunhai.workleave.model.leave.TodoVo;
import com.yunhai.workleave.model.leave.UserTimeSumVo;
import java.util.List;
/**
* 请假申请域服务(申请 / 列表 / 详情 / 销假 / 撤回 / 审核 / 统计)。
*/
public interface ILeaveApplyService {
/** 列表:scope=user 我的 / all 全部 / cc 抄送我的 */
BasePageResult<LeaveVo> list(Integer schId, Integer currentUserId, LeaveQuery query);
/** 详情 */
LeaveDetailVo detail(Integer schId, Integer currentUserId, Integer leaveId);
/** 新建(落库 + 发起流程);返回单据ID */
Integer create(Integer schId, Integer currentUserId, String userName, LeaveRequest request);
/** 编辑/重新提交(仅审批中/已拒绝/已撤回;重新发起流程 attempt+1) */
void edit(Integer schId, Integer currentUserId, String userName, LeaveRequest request);
/** 删除(仅本人;已通过只能销假不能删) */
void delete(Integer schId, Integer currentUserId, List<Integer> ids);
/** 撤回(调流程中心,状态由回调推进) */
void withdraw(Integer schId, Integer currentUserId, Integer leaveId);
/** 销假(已通过 → 已销假,返还时长) */
void cancel(Integer schId, Integer currentUserId, LeaveCancelRequest request);
/** 待我审核列表(流程中心待办 + 本地单据补全;startTime/endTime 按请假区间重叠过滤) */
BasePageResult<TodoVo> todoList(Integer schId, Integer currentUserId, String keyword,
String startTime, String endTime, int pageIndex, int pageSize);
/** 我已审核列表(本地口径:我操作过该实例的终态/流转单据;startTime/endTime 按请假区间重叠过滤) */
BasePageResult<LeaveVo> auditedList(Integer schId, Integer currentUserId, String keyword,
String startTime, String endTime, int pageIndex, int pageSize);
/** 审核动作(同意/拒绝,提交流程中心,本地状态待回调) */
void audit(Integer schId, Integer currentUserId, String userName, LeaveAuditRequest request);
/** 表单页余额提示 */
UserTimeSumVo myTime(Integer schId, Integer currentUserId);
/** 统计(区间按申请时间,口径:已通过) */
LeaveStatsVo statistics(Integer schId, String startTime, String endTime);
}
@@ -0,0 +1,47 @@
package com.yunhai.workleave.service;
import com.yunhai.workleave.model.leave.FlowBindRequest;
import com.yunhai.workleave.model.leave.FlowEnableRequest;
import com.yunhai.workleave.model.leave.FlowManageVo;
/**
* 审批流程绑定服务(流程管理页)。
* <p>对齐《子应用流程与消息对接手册 v1.0》:bizType 恒为 {@code leave},
* 多套审批流以 flowGuid 区分,本服务决定「本校 / 某请假类型走哪条 flowGuid」。</p>
*/
public interface ILeaveFlowBindService {
/** 流程管理页聚合数据:流程列表 + 已绑定关系 + 请假类型 */
FlowManageVo manage(Integer schId);
/**
* 绑定流程(按 scope + scopeKey upsert)。
* <p>绑定成功后立刻向流程中心登记回调(手册 §2.4:首次发起前完成登记)。
* 登记失败不回滚绑定(登记为幂等动作,可由下次发起重试)。</p>
*/
void bind(Integer schId, FlowBindRequest request);
/**
* 启用绑定:把流程标记为本应用可用(upsert),并回调登记到流程中心。
* <p>启用后该流程才允许被设为默认 / 按请假类型绑定。</p>
*/
void enable(Integer schId, FlowEnableRequest request);
/**
* 关闭启用:移除启用标记,并解除该流程在本校的全部绑定(默认 + 按类型)。
* <p>幂等:未启用过的流程重复关闭不报错。</p>
*/
void disable(Integer schId, String flowGuid);
/** 解除绑定(scope=1 时 scopeKey 必填) */
void unbind(Integer schId, Integer scope, String scopeKey);
/**
* 解析单据实际使用的 flowGuid:按请假类型绑定 → 学校默认绑定 → 配置 leave.flow.guid。
*
* @param schId 学校ID
* @param leaveType 请假类型 dictKey(可空)
* @return 流程定义标识,永不为 null
*/
String resolveFlowGuid(Integer schId, String leaveType);
}
@@ -0,0 +1,37 @@
package com.yunhai.workleave.service;
import com.yunhai.workleave.model.leave.LeaveSettingVo;
import com.yunhai.workleave.model.leave.SectionVo;
import com.yunhai.workleave.model.leave.TimeSetVo;
import java.util.List;
/**
* 请假设置域服务(作息时间 / 其他设置 / 节次)。
*/
public interface ILeaveSettingService {
/** 学校作息时间(不存在时创建默认:08:00-12:00 / 13:00-17:00 / 间隔 10 分钟) */
TimeSetVo getTimeSet(Integer schId);
/** 保存作息时间 */
void saveTimeSet(Integer schId, TimeSetVo vo);
/** 其他设置(不存在时创建默认) */
LeaveSettingVo getOption(Integer schId);
/** 保存其他设置 */
void saveOption(Integer schId, LeaveSettingVo vo);
/** 节次列表(不存在时按默认 8 节种子初始化) */
List<SectionVo> sectionList(Integer schId);
/** 新增/编辑节次 */
void sectionSave(Integer schId, SectionVo vo);
/** 删除节次 */
void sectionDelete(Integer schId, Integer id);
/** 节次排序(ids 按前端展示顺序) */
void sectionSort(Integer schId, String ids);
}
@@ -0,0 +1,75 @@
package com.yunhai.workleave.service;
import com.yunhai.workleave.model.BasePageResult;
import com.yunhai.workleave.model.leave.UserTimeAdjustItem;
import com.yunhai.workleave.model.leave.UserTimeImportResult;
import com.yunhai.workleave.model.leave.UserTimeRecordVo;
import com.yunhai.workleave.model.leave.UserTimeSumVo;
import com.yunhai.workleave.model.leave.UserTimeVo;
import java.util.List;
import java.util.Set;
/**
* 教师时长(调休余额)域服务。
* <p>承接历史 QJ_SchUserTime / QJ_SchUserTimeRecord 语义:
* 通过扣减、销假返还、管理员加减、流水留痕、合计统计。</p>
*/
public interface ILeaveUserTimeService {
/** 教师时长列表(教师名单来自班级服务;接口不可达时降级为时长池已有记录) */
List<UserTimeVo> list(Integer schId, String keyword);
/** 批量调整时长(正加负减,逐行写流水) */
void adjust(Integer schId, Integer operatorId, List<UserTimeAdjustItem> items);
/**
* 生成「教师时长导入模板」xlsx 字节流。
* 列:部门 / 教师名称 / 新加时长(小时) / 备注;每名教师一行(部门预填、时长与备注留空)。
*/
byte[] importTemplate(Integer schId);
/**
* 导入教师时长(对齐旧版 ImportQJSchUserTime):
* 表头校验 → 逐行按「部门+教师名称」(部门为空时全校匹配)定位教师 →
* 任一行错误则整体不导入并返回错误明细;全部通过后按 adjust 语义批量入账。
*/
UserTimeImportResult importExcel(Integer schId, Integer operatorId, java.io.InputStream in);
/**
* 时长流水(分页)。
*
* @param rqType 0 今日 / 1 本周 / 2 本月 / 3 累计
*/
BasePageResult<UserTimeRecordVo> records(Integer schId, Integer rqType, String keyword,
String startTime, String endTime, int pageIndex, int pageSize);
/** 合计:区间新加总时长 + 当前剩余总时长 */
UserTimeSumVo sum(Integer schId, Integer rqType, String startTime, String endTime);
/** 某教师当前可用时长(无记录返回 0) */
double usableOf(Integer schId, Integer userId);
/** 某教师已通过的时长总和(小时) */
double passedHoursOf(Integer schId, Integer userId);
/** 某教师审批中的在途占用时长(小时) */
double inTransitHoursOf(Integer schId, Integer userId);
/** 时长管控类型集合(dictKey);未启用管控返回空集 */
Set<String> managedTypes(Integer schId);
/** 是否管控该类型 */
boolean isManaged(Integer schId, String leaveType);
/** 余额校验:在途 + 本次 <= 可用,否则抛业务异常并附可申请时长 */
void checkEnough(Integer schId, Integer userId, String leaveType, double hours);
/** 扣减(审批通过):写流水"扣除";非管控类型静默跳过 */
void deduct(Integer schId, Integer userId, String userName, Integer leaveId, String leaveType,
double hours, Integer operatorId);
/** 返还(销假):写流水"销假返还";非管控类型静默跳过 */
void refund(Integer schId, Integer userId, String userName, Integer leaveId, String leaveType,
double hours, Integer operatorId, String describe);
}
@@ -0,0 +1,17 @@
package com.yunhai.workleave.service;
import com.yunhai.workleave.entity.SysOperationLog;
import com.yunhai.workleave.model.BasePageQuery;
import com.yunhai.workleave.model.BasePageResult;
/**
* 系统审计日志业务接口:异步落库 + 分页查询。
*/
public interface ISysOperationLogService {
/** 异步保存(失败仅记应用日志,不影响业务请求) */
void saveAsync(SysOperationLog entity);
/** 分页查询(responseData 不返回) */
BasePageResult<SysOperationLog> page(BasePageQuery query, String operationType, Integer statusCode);
}
@@ -0,0 +1,57 @@
package com.yunhai.workleave.service;
import java.util.List;
import java.util.Map;
/**
* 用户中心门面服务(对齐 UserBase {@code UserCenterService} 模式)。
* <p>封装 {@code YschBaseOpenApi} / {@code OpenApiUserClient} 静态门面调用,
* 把原本散落在 Controller 的组树组装、教师挂组、成员解析等业务逻辑下沉到 Service 层,
* Controller 只保留"取参 → 调 Service → 返回"的薄壳。</p>
*/
public interface IUserCenterService {
/**
* 获取当前用户学校组及用户(树形结构)。
* <p>组装规则(从原 BaseApiController.getSchoolAll 下沉):</p>
* <ol>
* <li>userbase 读取当前学校整棵组树(getGroupById(schId, withSub=true)),剔除学生组(type=8)及其子组;</li>
* <li>bschclass 获取该校全部教师(/oapi/DbClass/GetSchteaList?Group=schId),按 GroupIds 挂到对应组下,
* 无有效组(如所属组全被剔除)的教师兜底挂学校根节点;</li>
* <li>扁平节点按 pid 递归组装为树(空组剔除,同 C# FormatUserTree)。</li>
* </ol>
*
* @param schId 学校ID(用户当前角色所属学校)
* @return 用户树形结构(组节点 type=1 / 教师节点 type=2)
*/
List<Map<String, Object>> getSchoolAllTree(Integer schId);
/**
* 学校组树中所有组的 id → 名称映射(剔除学生组;同 C# GetGroupDepttree 的组名用途)。
* 供教师时长导入按「部门」列匹配教师所属组。
*/
Map<Integer, String> groupNames(Integer schId);
/**
* 获取指定组内用户(教师 + 管理员)。
* <p>对应前端 base/GetGroupUser。原实现为空桩返回空列表,现按 C# 语义
* (ITeainfoBLL.GetGroupTeainfo + IAdminfoBLL.GetGroupAdminfo)接入用户中心。
* 若用户中心不可用或未配置对应开放接口,则抛出带语义的异常(显式报错而非静默失能)。</p>
*
* @param gid 组ID
* @param role 角色(默认 2=教师;1=管理员等)
* @return 组内用户列表
*/
List<Map<String, Object>> getGroupUser(Integer gid, Integer role);
/**
* 获取当前用户可访问的模块树(左侧菜单)。
* <p>从基础平台拉取当前用户模块列表(含学校上下文),组装为前端 layout 期望的树结构
* ({@code modId/page/children}),对应前端 GetUserMod 接口。</p>
*
* @param userId 用户ID
* @param schId 学校ID(可为 null)
* @return 模块树(顶层 List,含 children 嵌套)
*/
List<Map<String, Object>> getUserModuleTree(Integer userId, Integer schId);
}

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