diff --git a/src/main/java/com/budwk/app/base/constant/RedisConstant.java b/src/main/java/com/budwk/app/base/constant/RedisConstant.java index 6920246..b0db4c5 100644 --- a/src/main/java/com/budwk/app/base/constant/RedisConstant.java +++ b/src/main/java/com/budwk/app/base/constant/RedisConstant.java @@ -48,6 +48,13 @@ public class RedisConstant { */ public static final String UCENTER_SMSCODE = PLATFORM_REDIS_PREFIX + "ucenter:smscode:"; + /** 拼图挑战与凭证按匿名会话隔离,频率键单独存放。 */ + public static final String REDIS_SLIDER_CAPTCHA_KEY = PLATFORM_REDIS_PREFIX + "login:slider:"; + public static final String REDIS_SLIDER_RATE_KEY = PLATFORM_REDIS_PREFIX + "login:slider-rate:"; + + /** 登录短信专用缓存前缀,避免验证码与其他业务场景交叉使用。 */ + public static final String LOGIN_SMSCODE = PLATFORM_REDIS_PREFIX + "login:sms:"; + /** * 接口重复提交前缀 */ @@ -72,4 +79,10 @@ public class RedisConstant { * 数据接口锁前缀 */ public static final String RSA_KEY_PREFIX = PLATFORM_REDIS_PREFIX + "rsa:private:"; + + /** + * 人员治理第三方数据查询结果缓存前缀。 + */ + public static final String REDIS_KEY_PERSON_API_DATA_QUERY_RESULT = + PLATFORM_REDIS_PREFIX + "personGovernance:dataQuery:"; } diff --git a/src/main/java/com/budwk/app/base/constant/RoleConstant.java b/src/main/java/com/budwk/app/base/constant/RoleConstant.java index 69f1abf..5c11041 100644 --- a/src/main/java/com/budwk/app/base/constant/RoleConstant.java +++ b/src/main/java/com/budwk/app/base/constant/RoleConstant.java @@ -27,6 +27,8 @@ RoleConstant { SCHOOL_UNION_PROPOSAL_ADMIN("校工会提案管理员"), SCHOOL_UNION_CLUB_ADMIN("校工会社团管理员"), SCHOOL_UNION_ACTIVITY_ADMIN("校工会活动管理员"), + /** 品牌活动统计、人员调整管理员,数据范围按 trainSignUp 子活动负责人关系控制。 */ + SCHOOL_UNION_TRAIN_SIGN_UP_ADMIN("校工会品牌活动管理员"), SCHOOL_UNION_ARTICLE_CLUB_ADMIN("校工会社团新闻审批管理员"), SCHOOL_UNION_ARTICLE_FGH_ADMIN("校工会分工会新闻审批管理员"), SCHOOL_UNION_ARTICLE_ADMIN("校工会新闻审批管理员"), diff --git a/src/main/java/com/budwk/app/base/enums/LoginType.java b/src/main/java/com/budwk/app/base/enums/LoginType.java index 21c4f0d..1e3125e 100644 --- a/src/main/java/com/budwk/app/base/enums/LoginType.java +++ b/src/main/java/com/budwk/app/base/enums/LoginType.java @@ -13,6 +13,7 @@ public enum LoginType { QI_YE_WECHAT("企业微信"), WECHAT("微信"), PC_LOCAL("PC本地"), + SMS_CODE("手机验证码"), MOBILE_LOCAL("手机本地"), CAS("CAS"); diff --git a/src/main/java/com/budwk/app/sys/controller/SysConfController.java b/src/main/java/com/budwk/app/sys/controller/SysConfController.java index 409d192..d56a079 100644 --- a/src/main/java/com/budwk/app/sys/controller/SysConfController.java +++ b/src/main/java/com/budwk/app/sys/controller/SysConfController.java @@ -7,10 +7,12 @@ import com.budwk.app.base.result.Result; import com.budwk.app.sys.models.Sys_config; import com.budwk.app.sys.services.SysConfigService; import com.budwk.app.web.commons.auth.utils.SecurityUtil; +import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; import org.nutz.dao.Sqls; import org.nutz.integration.jedis.pubsub.PubSubService; +import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.lang.Strings; @@ -139,12 +141,15 @@ public class SysConfController { @At @Ok("json:full") @SaCheckPermission("sys.manager.conf") + @Aop(TransAop.READ_COMMITTED) public Object data(@Param("pageNumber") int pageNumber, @Param("pageSize") int pageSize, @Param("pageOrderName") String pageOrderName, @Param("pageOrderBy") String pageOrderBy, @Param("configKey") String configKey) { try { + // 由 service 补充默认关闭的入口参数,管理员可通过现有“是/否”控件修改。 + sysConfigService.ensureSmsLoginVisibleConfig(SecurityUtil.getUserId()); ensureAppImageConfig("AppHomeImg", "PC首页轮播图"); ensureAppImageConfig("H5AppHomeImg", "移动端首页轮播图"); ensureAppImageConfig("AppFeaturedActivityImg", "精彩活动页顶部图片"); diff --git a/src/main/java/com/budwk/app/sys/controller/SysDictController.java b/src/main/java/com/budwk/app/sys/controller/SysDictController.java index 3bf4b2f..50cad0b 100644 --- a/src/main/java/com/budwk/app/sys/controller/SysDictController.java +++ b/src/main/java/com/budwk/app/sys/controller/SysDictController.java @@ -3,6 +3,7 @@ package com.budwk.app.sys.controller; import cn.dev33.satoken.annotation.SaCheckLogin; import cn.dev33.satoken.annotation.SaCheckPermission; import com.budwk.app.base.annotation.SLog; +import com.budwk.app.base.exception.BaseException; import com.budwk.app.base.result.Result; import com.budwk.app.sys.models.Sys_dict; import com.budwk.app.sys.services.SysDictService; @@ -119,6 +120,8 @@ public class SysDictController { sysDictService.save(dict, parentId); sysDictService.clearCache(); return Result.success("system.success"); + } catch (BaseException e) { + return Result.error(e.getMessage()); } catch (Exception e) { return Result.error("system.error"); } @@ -142,9 +145,11 @@ public class SysDictController { public Object editDo(@Param("..") Sys_dict dict, @Param("parentId") String parentId, HttpServletRequest req) { try { dict.setUpdatedBy(SecurityUtil.getUserId()); - sysDictService.updateIgnoreNull(dict); + sysDictService.updateDict(dict); sysDictService.clearCache(); return Result.success(); + } catch (BaseException e) { + return Result.error(e.getMessage()); } catch (Exception e) { return Result.error(); } diff --git a/src/main/java/com/budwk/app/sys/controller/SysHomeController.java b/src/main/java/com/budwk/app/sys/controller/SysHomeController.java index 1325815..9395da6 100644 --- a/src/main/java/com/budwk/app/sys/controller/SysHomeController.java +++ b/src/main/java/com/budwk/app/sys/controller/SysHomeController.java @@ -11,26 +11,21 @@ import com.budwk.app.base.result.Result; import com.budwk.app.sys.models.Sys_home_activity; import com.budwk.app.sys.models.Sys_home_template; import com.budwk.app.sys.models.Sys_menu; +import com.budwk.app.sys.services.SysHomeNewsService; import com.budwk.app.sys.services.SysMenuService; import com.budwk.app.sys.services.SysUserService; import com.budwk.app.web.commons.auth.utils.AuthUtil; import com.budwk.app.web.commons.auth.utils.SecurityUtil; import com.budwk.app.zhgh.activity.basic.service.ActivityBasicScopeService; import io.swagger.annotations.ApiOperation; -import org.jsoup.Jsoup; -import org.jsoup.nodes.Document; -import org.jsoup.nodes.Element; -import org.jsoup.select.Elements; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; import org.nutz.dao.FieldFilter; import org.nutz.dao.Sqls; import org.nutz.dao.sql.Sql; import org.nutz.dao.util.Daos; -import org.nutz.integration.jedis.RedisService; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; -import org.nutz.json.Json; import org.nutz.lang.Lang; import org.nutz.lang.Strings; import org.nutz.lang.util.NutMap; @@ -42,7 +37,6 @@ import org.nutz.mvc.annotation.Param; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpSession; -import java.io.IOException; import java.util.*; import java.util.stream.Collectors; @@ -58,9 +52,9 @@ public class SysHomeController { @Inject private SysUserService sysUserService; @Inject - private RedisService redisService; - @Inject private ActivityBasicScopeService activityBasicScopeService; + @Inject + private SysHomeNewsService sysHomeNewsService; @At("") @Ok("re") @@ -293,34 +287,17 @@ public class SysHomeController { @SaCheckLogin @ApiOperation("获取新闻") @Ok("json") -// @CacheDefaults(cacheName = RedisConstant.PLATFORM_REDIS_WKCACHE_PREFIX + "web_news", isHash = true) -// @CacheResult(cacheKey = "news", ignoreNull = true, cacheLiveTime = 60 * 60 * 24) public Result getNews() { - try { - String url = "https://xgh.cug.edu.cn/"; - Document document = Jsoup.connect(url).get(); - Elements homeElements = document.select(".gh,.jc,.fc,.jg"); + return Result.success(sysHomeNewsService.getWebsiteNews()); + } - List> news = homeElements.stream().map(homeElement -> { - //获取板块名称 - String name = homeElement.selectFirst(".title").text(); - Elements liElements = homeElement.select("li"); - List ele = liElements.stream().map(liElement -> { - NutMap row = NutMap.NEW(); - String href = liElement.selectFirst("a").attr("href"); - String time = liElement.selectFirst("span").text(); - String title = liElement.selectFirst("a").attr("title"); - row.put("href", href); - row.put("time", time); - row.put("text", title); - return row; - }).toList(); - return Map.of("label", name, "value", ele); - }).collect(Collectors.toList()); - redisService.setex("gonghui_news", 60 * 60 * 24, Json.toJson(news)); - return Result.success(news); - } catch (IOException e) { - throw new RuntimeException(e); - } + /** + * href 传首页新闻项原始链接;成功结果 data 为图片地址字符串,空字符串表示没有可用图片。 + */ + @At + @SaCheckLogin + @Ok("json") + public Result getNewsImage(@Param("href") String href) { + return Result.success(sysHomeNewsService.getNewsImage(href)); } } diff --git a/src/main/java/com/budwk/app/sys/controller/SysLoginController.java b/src/main/java/com/budwk/app/sys/controller/SysLoginController.java index a466417..b6e9698 100644 --- a/src/main/java/com/budwk/app/sys/controller/SysLoginController.java +++ b/src/main/java/com/budwk/app/sys/controller/SysLoginController.java @@ -16,16 +16,20 @@ import com.budwk.app.base.utils.RsaUtils; import com.budwk.app.sys.models.Sys_log; import com.budwk.app.sys.models.Sys_user; import com.budwk.app.sys.services.SysUserService; +import com.budwk.app.sys.services.SysConfigService; import com.budwk.app.web.commons.auth.service.ValidateService; +import com.budwk.app.zhgh.dayofficework.message.service.SliderCaptchaService; import com.budwk.app.web.commons.auth.utils.SecurityUtil; import com.budwk.app.web.commons.base.Globals; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import org.jasig.cas.client.util.AbstractCasFilter; import org.jasig.cas.client.validation.Assertion; +import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.dao.Cnd; import org.nutz.integration.jedis.RedisService; import org.nutz.ioc.impl.PropertiesProxy; +import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.lang.Lang; @@ -35,6 +39,7 @@ import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.Filters; import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Param; +import org.nutz.mvc.annotation.POST; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -55,19 +60,19 @@ import java.util.concurrent.ConcurrentHashMap; public class SysLoginController { private static final Log log = Logs.get(); private static final LoginRsaKeyRing LOGIN_RSA_KEY_RING = new LoginRsaKeyRing(); - /** - * TODO: 临时屏蔽回环地址及指定局域网 IP 的平台登录验证码,全部恢复时改为 false。 - */ - private static final boolean DISABLE_LOGIN_CAPTCHA = false; @Inject private SysUserService sysUserService; @Inject + private SysConfigService sysConfigService; + @Inject private RedisService redisService; @Inject private SLogService sLogService; @Inject private ValidateService validateService; @Inject + private SliderCaptchaService sliderCaptchaService; + @Inject protected PropertiesProxy conf; @At("") @@ -75,13 +80,17 @@ public class SysLoginController { @ApiOperation("用户本地登录页面") @Filters public String login(HttpServletRequest req, HttpSession session) { + // 只向模板提供入口显示状态,短信发送及验证码登录接口继续使用原有规则。 + req.setAttribute("smsLoginVisible", sysConfigService.isSmsLoginVisible()); return "beetl:/platform/sys/login.html"; } @At @Ok("re") @Filters - public String h5() { + public String h5(HttpServletRequest req) { + // 手机端复用同一个登录模板,读取相同的系统参数。 + req.setAttribute("smsLoginVisible", sysConfigService.isSmsLoginVisible()); return "beetl:/platform/sys/login.html"; } @@ -93,6 +102,13 @@ public class SysLoginController { } + /** + * 账号密码登录:username 为账号,password 为 RSA 密文,keyId 为公钥编号; + * platformKey/platformCaptcha 为当前会话、当前账号的滑块一次性凭证。 + * 返回 Result:code=0 表示登录成功,msg 为成功提示或失败原因。 + */ + @POST + @Aop(TransAop.READ_COMMITTED) @At("/doLogin") @Ok("json") @ApiOperation("用户本地账号密码登录") @@ -124,14 +140,9 @@ public class SysLoginController { // return Result.error("登录失败次数过多,请5分钟后再试"); // } try { - // 验证码校验 - if (!isLoginCaptchaDisabled(req)) { - try { - validateService.checkCode(captchaKey, captchaCode); - } catch (BaseException e) { - return Result.error(e.getMessage()); - } - } + // 所有访问地址均需消费绑定当前账号的滑块凭证,旧图形码不能用于登录。 + sliderCaptchaService.consume(session.getId(), captchaKey, captchaCode, + SliderCaptchaService.PASSWORD, username); // 解密密码 LoginRsaKey rsaKey = LOGIN_RSA_KEY_RING.getPrivateKey(keyId); @@ -162,20 +173,7 @@ public class SysLoginController { } } - private boolean isLoginCaptchaDisabled(HttpServletRequest req) { - if (!DISABLE_LOGIN_CAPTCHA || req == null) { - return false; - } - String serverName = req.getServerName(); - // 临时允许该局域网地址免验证码;恢复 IP 验证码时删除此条件,并同步删除登录页中的该 IP。 - return "localhost".equalsIgnoreCase(serverName) - || "192.168.0.108".equals(serverName) - || "127.0.0.1".equals(serverName) - || "0:0:0:0:0:0:0:1".equals(serverName) - || "::1".equals(serverName); - } - - @At(value = {"/platform/sso/login", "/sso/login"}, top = true) + @At(value = "/platform/sso/login", top = true) @Ok("re") @ApiOperation("用户cas登录统一入口") public String ssoLogin(HttpSession httpSession, HttpServletRequest request, HttpServletResponse response) { @@ -190,72 +188,23 @@ public class SysLoginController { return ">>:" + sysUserService.loginPlus(sysUser, LoginType.CAS, request); } - @At(value = {"/platform/qywechat/oauth", "/platform/qywechat"}, top = true) - @Ok("re") - public String qywechatOauth(@Param("redirect") String redirect, HttpServletRequest request) { - if (!"prd".equalsIgnoreCase(conf.get("nutz.profiles.active")) || !conf.getBoolean("wxwork.enable", false)) { - return ">>:/platform/home"; - } - String state = UUID.randomUUID().toString(); - request.getSession().setAttribute("zjnuWxworkState", state); - String callback = Globals.AppDomain + conf.get("wxwork.redirectUri"); - if (StrUtil.isNotBlank(redirect)) { - callback += "?redirect=" + URLEncoder.encode(redirect, StandardCharsets.UTF_8); - } - return ">>:https://open.weixin.qq.com/connect/oauth2/authorize?appid=" + conf.get("wxwork.corpId") - + "&redirect_uri=" + URLEncoder.encode(callback, StandardCharsets.UTF_8) - + "&response_type=code&scope=snsapi_base&state=" + state + "#wechat_redirect"; - } - - private String getZjnuWxworkToken() { - String cacheKey = RedisConstant.REDIS_KEY_WX_WORK_TOKEN + conf.get("wxwork.corpId"); - String token = redisService.get(cacheKey); - if (StrUtil.isNotBlank(token)) { - return token; - } - String url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=" + conf.get("wxwork.corpId") - + "&corpsecret=" + conf.get("wxwork.corpSecret"); - JSONObject result = JSONUtil.parseObj(HttpUtil.get(url)); - token = result.getStr("access_token"); - if (!Integer.valueOf(0).equals(result.getInt("errcode")) || StrUtil.isBlank(token)) { - throw new BaseException("????????????"); - } - redisService.setex(cacheKey, Math.max(1, result.getInt("expires_in", 7200) - 200), token); - return token; - } - - @At(value = {"/platform/wxwork/oauth2/callback", "/platform/qywechat/getCode"}, top = true) + @At(value = "/platform/wxwork/oauth2/callback", top = true) @Ok("re") @ApiOperation("企业微信登录回调") public String wxworkOAuth2(@Param("code") String code, @Param("redirect") String redirect, HttpServletRequest request) { if (StrUtil.isBlank(code)) { return ">>:/platform/home"; } - boolean zjnu = "prd".equalsIgnoreCase(conf.get("nutz.profiles.active")); - if (zjnu) { - Object expectedState = request.getSession().getAttribute("zjnuWxworkState"); - request.getSession().removeAttribute("zjnuWxworkState"); - if (!conf.getBoolean("wxwork.enable", false) || expectedState == null - || !expectedState.equals(request.getParameter("state"))) { - return ">>:/platform/home/500"; - } - } - String token = zjnu ? getZjnuWxworkToken() : redisService.get(RedisConstant.REDIS_KEY_WX_WORK_TOKEN); + String token = redisService.get(RedisConstant.REDIS_KEY_WX_WORK_TOKEN); if (StrUtil.isBlank(token)) { return ">>:/platform/home"; } String url = "https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo?access_token=ACCESS_TOKEN&code=CODE"; - if (zjnu) { - url = "https://qyapi.weixin.qq.com/cgi-bin/user/getuserinfo?access_token=ACCESS_TOKEN&code=CODE"; - } url = url.replace("ACCESS_TOKEN", token).replace("CODE", code); String response = HttpUtil.get(url); JSONObject jsonObject = JSONUtil.parseObj(response); if (jsonObject.getInt("errcode") == 0) { - String loginName = jsonObject.getStr(zjnu ? "UserId" : "userid"); - if (StrUtil.isBlank(loginName)) { - return ">>:/platform/home/500"; - } + String loginName = jsonObject.getStr("userid"); sysUserService.checkThirdPlatformLoginName(loginName); Sys_user sysUser = sysUserService.loginByLoginName(loginName); return ">>:" + sysUserService.loginPlus(sysUser, LoginType.QI_YE_WECHAT, request); @@ -326,6 +275,120 @@ public class SysLoginController { } } + /** + * 发送系统绑定手机号的登录验证码,滑块凭证和账号可用性由 service 再次校验。 + * + * @param mobile 已绑定系统账号的 11 位大陆手机号 + * @param captchaKey /checkSliderCaptcha 返回的 platformKey + * @param captchaCode /checkSliderCaptcha 返回的一次性 platformCaptcha + * @param request 当前请求,供后端按连接来源限流 + * @return Result,成功 data 包含 retryAfter 重发等待秒数、expireIn 验证码有效秒数;失败 msg 为原因 + */ + @At("/mobileCaptcha") + @POST + @Ok("json") + @Aop(TransAop.READ_COMMITTED) + @ApiOperation("发送手机登录验证码") + public Object mobileCaptcha(@Param("mobile") String mobile, + @Param("platformKey") String captchaKey, + @Param("platformCaptcha") String captchaCode, + HttpServletRequest request) { + if (StrUtil.isBlank(mobile) || !mobile.trim().matches("^1[3-9]\\d{9}$")) { + return Result.error("请输入正确的手机号码"); + } + if (StrUtil.isBlank(captchaKey) || StrUtil.isBlank(captchaCode)) { + return Result.error("请先完成滑块验证"); + } + try { + return Result.success("短信验证码已发送", sysUserService.sendLoginSmsCode(mobile.trim(), captchaKey, captchaCode, request)); + } catch (BaseException e) { + return Result.error(e.getMessage()); + } catch (Exception e) { + log.error("发送登录短信验证码失败", e); + return Result.error("短信发送失败,请稍后重试或联系管理员"); + } + } + + /** + * 使用与手机号绑定的一次性短信验证码登录,不接收密码或 RSA 公钥参数。 + * + * @param mobile 获取短信时填写的 11 位大陆手机号 + * @param smsCode 用户收到的六位数字验证码 + * @param request 当前登录请求,供 service 创建原有 Sa-Token 会话和登录日志 + * @return Result,code 为 0 表示完成登录;失败 msg 为校验或登录失败原因 + */ + @At("/doSmsLogin") + @POST + @Ok("json") + @Aop(TransAop.READ_COMMITTED) + @ApiOperation("手机验证码登录") + public Object doSmsLogin(@Param("mobile") String mobile, @Param("smsCode") String smsCode, + HttpServletRequest request) { + if (StrUtil.isBlank(mobile) || !mobile.trim().matches("^1[3-9]\\d{9}$")) { + return Result.error("请输入正确的手机号码"); + } + if (StrUtil.isBlank(smsCode) || !smsCode.trim().matches("^\\d{6}$")) { + return Result.error("请输入六位短信验证码"); + } + try { + sysUserService.loginBySmsCode(mobile.trim(), smsCode.trim(), request); + return Result.success("login.success"); + } catch (BaseException e) { + return Result.error(e.getMessage()); + } catch (Exception e) { + log.error("手机验证码登录失败", e); + return Result.error("登录失败,请稍后重试或联系管理员"); + } + } + + /** + * scene 传 password(账号登录)或 sms(发送短信),subject 传对应账号或手机号。 + * 返回 Result.data:challengeId、background/piece 图片、width/height/pieceSize/pieceY 像素信息及 expiresIn 秒数。 + * 会话 ID 仅从服务器读取,业务校验、图片生成和频控均由 service 完成。 + */ + @At("/sliderCaptcha") + @POST + @Ok("json") + @Aop(TransAop.READ_COMMITTED) + public Object sliderCaptcha(@Param("scene") String scene, @Param("subject") String subject, + HttpServletRequest request, HttpServletResponse response) { + response.setHeader("Cache-Control", "no-store"); + try { + return Result.success(sliderCaptchaService.create(request.getSession().getId(), + request.getRemoteAddr(), scene, subject)); + } catch (BaseException e) { + return Result.error(e.getMessage()); + } catch (Exception e) { + log.error("生成滑块验证码失败", e); + return Result.error("验证码加载失败,请稍后重试"); + } + } + + /** + * challengeId 传挑战编号,offset 传拼图左侧在原始 320px 图片中的横坐标。 + * 返回 Result.data:platformKey、platformCaptcha 为一次性凭证,expiresIn 为有效秒数。 + */ + @At("/checkSliderCaptcha") + @POST + @Ok("json") + @Aop(TransAop.READ_COMMITTED) + public Object checkSliderCaptcha(@Param("challengeId") String challengeId, @Param("offset") String offset, + HttpServletRequest request, HttpServletResponse response) { + response.setHeader("Cache-Control", "no-store"); + if (offset == null || !offset.matches("[0-9]{1,3}(\\.[0-9]{1,3})?")) { + return Result.error("滑块位置不正确,请重新验证"); + } + try { + return Result.success(sliderCaptchaService.verify(request.getSession().getId(), + challengeId, Double.parseDouble(offset))); + } catch (BaseException e) { + return Result.error(e.getMessage()); + } catch (Exception e) { + log.error("校验滑块验证码失败", e); + return Result.error("验证失败,请稍后重试"); + } + } + @At("/captcha") @Ok("json") @ApiOperation("获取验证码") diff --git a/src/main/java/com/budwk/app/sys/models/Sys_dict.java b/src/main/java/com/budwk/app/sys/models/Sys_dict.java index e3cdbd3..018b0f5 100644 --- a/src/main/java/com/budwk/app/sys/models/Sys_dict.java +++ b/src/main/java/com/budwk/app/sys/models/Sys_dict.java @@ -52,6 +52,16 @@ public class Sys_dict extends BaseModel implements Serializable { @ColDefine(type = ColType.VARCHAR, width = 100) private String code; + @Column + @Comment("关联人员类型编码") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String personType; + + @Column + @Comment("人员属性业务编码") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String userAttributeCode; + @Column @Comment("启用状态") @ColDefine(type = ColType.BOOLEAN) diff --git a/src/main/java/com/budwk/app/sys/models/Sys_user.java b/src/main/java/com/budwk/app/sys/models/Sys_user.java index d7288a2..28b4e43 100644 --- a/src/main/java/com/budwk/app/sys/models/Sys_user.java +++ b/src/main/java/com/budwk/app/sys/models/Sys_user.java @@ -49,7 +49,7 @@ public class Sys_user extends BaseModel implements Serializable { @Column @Comment("性别") @ColDefine(type = ColType.VARCHAR, width = 10) - @DataCenterColumn(name = "性别", key = "XBMC") + @DataCenterColumn(name = "性别", key = "XB", dict = "USER_SEX") private String sex; @Column @@ -65,7 +65,6 @@ public class Sys_user extends BaseModel implements Serializable { @Column @Comment("出生日期") @ColDefine(type = ColType.DATE) - @DataCenterColumn(name = "出生日期", key = "CSRQ") private Date birthday; @Column @@ -82,7 +81,7 @@ public class Sys_user extends BaseModel implements Serializable { @Column @Comment("民族") @ColDefine(type = ColType.VARCHAR, width = 20) - @DataCenterColumn(name = "民族", key = "MZDM", dict = "USER_NATION") + @DataCenterColumn(name = "民族", key = "MZM", dict = "USER_NATION") private String nation; @Column @@ -109,13 +108,12 @@ public class Sys_user extends BaseModel implements Serializable { @Column @Comment("手机号码") @ColDefine(type = ColType.VARCHAR, width = 32) - @DataCenterColumn(name = "手机号码", key = "SJHM") + @DataCenterColumn(name = "手机号码", key = "YDDH") private String mobile; @Column @Comment("学历") @ColDefine(type = ColType.VARCHAR, width = 20) - @DataCenterColumn(name = "学历", key = "ZGXLMC") private String education; @Column @@ -126,7 +124,7 @@ public class Sys_user extends BaseModel implements Serializable { @Column @Comment("学位") @ColDefine(type = ColType.VARCHAR, width = 50) - @DataCenterColumn(name = "学位", key = "ZGXWMC") +// @DataCenterColumn(name = "学位", key = "zgxwmc") private String academicDegree; @Column @@ -137,7 +135,6 @@ public class Sys_user extends BaseModel implements Serializable { @Column @Comment("职称") @ColDefine(type = ColType.VARCHAR, width = 50) - @DataCenterColumn(name = "职称", key = "ZYJSZWDMMC") private String professionalTitle; @Column @@ -178,7 +175,7 @@ public class Sys_user extends BaseModel implements Serializable { @Column @Comment("来校时间") @ColDefine(type = ColType.VARCHAR, width = 10) - @DataCenterColumn(name = "来校时间", key = "LXRQ") + @DataCenterColumn(name = "来校时间", key = "LXNY") private String arrivalAtSchoolDate; @Column @@ -194,7 +191,7 @@ public class Sys_user extends BaseModel implements Serializable { @Column @Comment("在职状态") @ColDefine(type = ColType.VARCHAR, width = 30) - @DataCenterColumn(name = "在职状态", key = "DQZTMC") + @DataCenterColumn(name = "当前状态码", key = "DQZT", dict = "USER_STATE") private String userState; @Column @@ -211,7 +208,7 @@ public class Sys_user extends BaseModel implements Serializable { @Column @Comment("教职工类别码") @ColDefine(type = ColType.VARCHAR, width = 30) - @DataCenterColumn(name = "人员类型", key = "YRFSDM", dict = "USER_PERSON_TYPE") + @DataCenterColumn(name = "教职工类别码", key = "RYLX", dict = "USER_PERSON_TYPE") private String personType; @Column @@ -223,7 +220,6 @@ public class Sys_user extends BaseModel implements Serializable { @Column @Comment("编制类别码") @ColDefine(type = ColType.VARCHAR, width = 30) - @DataCenterColumn(name = "编制类别", key = "BZLBDMMC") private String preparedBy; @Column @@ -278,7 +274,7 @@ public class Sys_user extends BaseModel implements Serializable { @Column @ColDefine(type = ColType.VARCHAR, width = 32) - @DataCenterColumn(name = "单位", key = "SZDWDM") + @DataCenterColumn(name = "单位", key = "SZDWH") private String unitId; @Column @@ -463,4 +459,13 @@ public class Sys_user extends BaseModel implements Serializable { @Comment("基金会员退出时间") @ColDefine(type = ColType.DATETIME) private Date aidFundMemberQuitTime; + + /** + * 人员数据治理同步保护标识:1 表示人工确认后的数据,自动同步时不得覆盖。 + */ + @Column + @Comment("人员数据治理同步保护标识") + @ColDefine(type = ColType.INT) + @Default(value = "0") + private Integer flag; } diff --git a/src/main/java/com/budwk/app/sys/services/SysConfigService.java b/src/main/java/com/budwk/app/sys/services/SysConfigService.java index 04490ba..f23ffe5 100644 --- a/src/main/java/com/budwk/app/sys/services/SysConfigService.java +++ b/src/main/java/com/budwk/app/sys/services/SysConfigService.java @@ -10,6 +10,23 @@ import java.util.List; * Created by wizzer on 2016/12/23. */ public interface SysConfigService extends BaseService { + /** 仅控制登录页面短信入口的可见性,不作为后端短信接口的访问开关。 */ + String SMS_LOGIN_VISIBLE_KEY = "AppSmsLoginEnabled"; + + /** + * 补充缺失的短信登录入口参数,初始值为 false,已有管理员配置保持不变。 + * + * @param createdBy 当前打开系统参数列表的管理员 ID,用于记录参数创建人 + */ + void ensureSmsLoginVisibleConfig(String createdBy); + + /** + * 读取短信登录入口显示配置,不依赖异步更新的全局缓存。 + * + * @return 仅参数值为 true 时返回 true;参数缺失、为空或非法时返回 false + */ + boolean isSmsLoginVisible(); + /** * 查询所有数据 * @return diff --git a/src/main/java/com/budwk/app/sys/services/SysDictService.java b/src/main/java/com/budwk/app/sys/services/SysDictService.java index 20177f3..5043c54 100644 --- a/src/main/java/com/budwk/app/sys/services/SysDictService.java +++ b/src/main/java/com/budwk/app/sys/services/SysDictService.java @@ -79,6 +79,13 @@ public interface SysDictService extends BaseService { */ void save(Sys_dict dict, String pid); + /** + * 更新数据字典,并对人员属性直属子项的人员类型和业务编码进行校验。 + * + * @param dict 待更新字典;id 为必填项,personType 与 userAttributeCode 可为空 + */ + void updateDict(Sys_dict dict); + /** * 保存数据字典 * diff --git a/src/main/java/com/budwk/app/sys/services/SysHomeNewsService.java b/src/main/java/com/budwk/app/sys/services/SysHomeNewsService.java new file mode 100644 index 0000000..a0b3dc6 --- /dev/null +++ b/src/main/java/com/budwk/app/sys/services/SysHomeNewsService.java @@ -0,0 +1,24 @@ +package com.budwk.app.sys.services; + +import org.nutz.lang.util.NutMap; + +import java.util.List; + +/** + * 首页工会网站新闻服务。 + */ +public interface SysHomeNewsService { + + /** + * 获取学校工会网站首页新闻。 + * + * @return 新闻栏目集合;每个栏目包含 label 栏目名称和 value 新闻列表, + * 新闻项包含 href 跳转地址、time 发布时间、text 新闻标题和 thumbUrl 缩略图绝对地址(无图时为空) + */ + List getWebsiteNews(); + + /** + * href 必须是工会首页返回的新闻链接;返回正文首图的绝对地址,无图或不可访问时为空字符串。 + */ + String getNewsImage(String href); +} diff --git a/src/main/java/com/budwk/app/sys/services/SysUserService.java b/src/main/java/com/budwk/app/sys/services/SysUserService.java index 62746a2..c8a7962 100644 --- a/src/main/java/com/budwk/app/sys/services/SysUserService.java +++ b/src/main/java/com/budwk/app/sys/services/SysUserService.java @@ -8,6 +8,7 @@ import com.budwk.app.base.service.BaseService; import com.budwk.app.sys.models.Sys_menu; import com.budwk.app.sys.models.Sys_role; import com.budwk.app.sys.models.Sys_user; +import org.nutz.lang.util.NutMap; import javax.servlet.http.HttpServletRequest; import java.util.List; @@ -155,14 +156,37 @@ public interface SysUserService extends BaseService { Sys_user loginByPassword(String loginname, String passowrd) throws BaseException; /** - * 通过短信验证码登录 + * 根据手机号获取唯一可登录用户,并加载所属单位及分工会。 * - * @param mobile 手机号 - * @return - * @throws BaseException + * @param mobile 已在系统人员资料中登记的 11 位大陆手机号,允许首尾空格 + * @return Sys_user,包含用户基本资料、unit 单位及 union 分工会关联 + * @throws BaseException 手机号格式不正确、未绑定用户、关联多条用户或账号已禁用 */ Sys_user loginByMobile(String mobile) throws BaseException; + /** + * 为已登记且唯一有效的用户发送登录短信验证码。 + * + * @param mobile 系统人员资料中登记的 11 位大陆手机号 + * @param captchaKey 滑块验证接口返回的 platformKey + * @param captchaCode 滑块验证接口返回的一次性 platformCaptcha 凭证 + * @param request 当前 HTTP 请求,用于取得服务端接收到的客户端 IP 并限制发送频率 + * @return NutMap,retryAfter 为再次发送前应等待的秒数,expireIn 为短信验证码有效秒数 + * @throws BaseException 用户不可登录、滑块凭证错误、请求过于频繁或短信发送失败 + */ + NutMap sendLoginSmsCode(String mobile, String captchaKey, String captchaCode, HttpServletRequest request); + + /** + * 校验登录短信验证码并建立与账号密码登录一致的 Sa-Token 会话。 + * + * @param mobile 系统人员资料中登记的 11 位大陆手机号 + * @param smsCode 发送到该手机号的登录短信验证码 + * @param request 当前 HTTP 请求,供既有登录流程记录 IP 及取得跳转参数 + * @return String,登录成功后应访问的项目页面地址 + * @throws BaseException 用户不可登录、关联资料缺失或短信验证码校验失败 + */ + String loginBySmsCode(String mobile, String smsCode, HttpServletRequest request); + /** * 通过用户名获取用户信息 * @param loginname diff --git a/src/main/java/com/budwk/app/sys/services/impl/SysConfigServiceImpl.java b/src/main/java/com/budwk/app/sys/services/impl/SysConfigServiceImpl.java index d586bf6..3e16786 100644 --- a/src/main/java/com/budwk/app/sys/services/impl/SysConfigServiceImpl.java +++ b/src/main/java/com/budwk/app/sys/services/impl/SysConfigServiceImpl.java @@ -6,8 +6,11 @@ import com.budwk.app.base.service.impl.BaseServiceImpl; import com.budwk.app.base.utils.PageUtil; import com.budwk.app.sys.models.Sys_config; import com.budwk.app.sys.services.SysConfigService; +import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; +import org.nutz.dao.DaoException; +import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.lang.Lang; import org.nutz.lang.Strings; @@ -24,6 +27,35 @@ public class SysConfigServiceImpl extends BaseServiceImpl implements super(dao); } + /** {@inheritDoc} */ + @Override + @Aop(TransAop.READ_COMMITTED) + public synchronized void ensureSmsLoginVisibleConfig(String createdBy) { + if (fetch(SMS_LOGIN_VISIBLE_KEY) != null) { + return; + } + Sys_config config = new Sys_config(); + config.setConfigKey(SMS_LOGIN_VISIBLE_KEY); + config.setConfigValue("false"); + config.setNote("是否开启短信登录(仅控制登录页入口显示)"); + config.setCreatedBy(createdBy); + try { + insert(config); + } catch (DaoException e) { + // 多实例同时首次打开列表时,另一个实例可能已插入;已有值不能被默认值覆盖。 + if (fetch(SMS_LOGIN_VISIBLE_KEY) == null) { + throw e; + } + } + } + + /** {@inheritDoc} */ + @Override + public boolean isSmsLoginVisible() { + Sys_config config = fetch(SMS_LOGIN_VISIBLE_KEY); + return config != null && "true".equals(Strings.trim(config.getConfigValue())); + } + public List getAllList() { return this.query(Cnd.where("delFlag", "=", false)); } diff --git a/src/main/java/com/budwk/app/sys/services/impl/SysDataUserPullServiceImpl.java b/src/main/java/com/budwk/app/sys/services/impl/SysDataUserPullServiceImpl.java index 6ddd641..60e2f71 100644 --- a/src/main/java/com/budwk/app/sys/services/impl/SysDataUserPullServiceImpl.java +++ b/src/main/java/com/budwk/app/sys/services/impl/SysDataUserPullServiceImpl.java @@ -29,6 +29,7 @@ import com.budwk.app.sys.services.SysDataUserPullService; import com.budwk.app.sys.services.SysDictService; import com.budwk.app.sys.utils.DataCenterUtil; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; import org.nutz.aop.interceptor.async.Async; import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.dao.Cnd; @@ -52,6 +53,8 @@ import java.util.stream.Collectors; public class SysDataUserPullServiceImpl extends BaseServiceImpl implements SysDataUserPullService { private static final String DMP_TEACHER_KEY = "teacher"; + private static final String DMP_DISPATCH_KEY = "dispatch"; + private static final int DMP_PAGE_SIZE = 1000; @Inject private SysDictService sysDictService; @@ -118,17 +121,21 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl // 字典码表Map,key为父级编码,value为子级字典列表 Map> dataDictMap = dataDictList.stream().collect(Collectors.groupingBy(Sys_data_dict::getParentCode)); - List rawDataList = distinctDmpUsers(DataCenterUtil.pullData(getDmpUrl(DMP_TEACHER_KEY))); + String accessToken = getDmpAccessToken(); + List rawDataList = new ArrayList<>(); + rawDataList.addAll(pullDmpUsers(getDmpUrl(DMP_TEACHER_KEY), accessToken, "在岗教职工")); + rawDataList.addAll(pullDmpUsers(getDmpUrl(DMP_DISPATCH_KEY), accessToken, "在岗劳务派遣")); + rawDataList = distinctDmpUsers(rawDataList); if (rawDataList.isEmpty()) { - throw new BaseException("当前未获取到人员数据,中止本次同步"); + log.warn("当前未获取到人员数据"); } Date nowDate = new Date(); // 人员单位号先与单位表批量比对,缺失单位只同步一次,避免逐人查询单位表。 Set sourceUnitIds = rawDataList.stream() - .map(raw -> raw.getStr("SZDWDM")) + .map(raw -> raw.getStr("SZDWH")) .filter(StrUtil::isNotBlank) .collect(Collectors.toSet()); Map unitMap = dao().query(Sys_unit.class, Cnd.NEW()).stream() @@ -140,44 +147,25 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl .collect(Collectors.toMap(Sys_unit::getId, unit -> unit)); } - String rootId = DataCenterUtil.findRootUnit(new ArrayList<>(unitMap.values())).getId(); - // 深层机构沿父级查找二级单位,并保留所属三级单位。 + // 按单位表的实际等级判断:三级单位为3、直属二级单位为2;单位号位数不参与判断。 Map tertiaryParentUnitIds = new HashMap<>(); - Map tertiaryUnitIds = new HashMap<>(); for (String sourceUnitId : sourceUnitIds) { Sys_unit unit = unitMap.get(sourceUnitId); if (unit == null) { - log.warn("人员单位号{}在机构同步后仍不存在,跳过单位归属处理,保留原单位号入库", sourceUnitId); + log.warn("人员单位号{}在单位表中不存在,保留信息中心原始单位号", sourceUnitId); continue; } - Set visited = new HashSet<>(); - while (!rootId.equals(unit.getId()) && !Integer.valueOf(2).equals(unit.getUnitLevel())) { - if (!visited.add(unit.getId())) { - log.warn("人员单位{}存在循环父级,跳过单位归属处理,保留原单位号入库", sourceUnitId); - tertiaryUnitIds.remove(sourceUnitId); - unit = null; - break; - } - if (Integer.valueOf(3).equals(unit.getUnitLevel())) { - tertiaryUnitIds.put(sourceUnitId, unit.getId()); - } - unit = unitMap.get(unit.getParentId()); - if (unit == null || rootId.equals(unit.getId())) { - log.warn("人员单位{}缺少有效的二级上级机构,跳过单位归属处理,保留原单位号入库", sourceUnitId); - tertiaryUnitIds.remove(sourceUnitId); - unit = null; - break; - } - } - if (unit == null) { + if (!Integer.valueOf(3).equals(unit.getUnitLevel())) { continue; } - if (!sourceUnitId.equals(unit.getId())) { - tertiaryParentUnitIds.put(sourceUnitId, unit.getId()); + Sys_unit parentUnit = unitMap.get(unit.getParentId()); + if (parentUnit == null || !Integer.valueOf(2).equals(parentUnit.getUnitLevel())) { + log.warn("三级单位{}缺少有效的二级父单位,保留信息中心原始单位号", sourceUnitId); + continue; } + tertiaryParentUnitIds.put(sourceUnitId, parentUnit.getId()); } - Map resolvedUnitMap = unitMap; // 赋值 List latestSourceList = rawDataList.stream().map(raw -> { Sys_user_source sysUser = new Sys_user_source(); @@ -195,49 +183,44 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl .findFirst().orElse(new Sys_data_dict()); mapping.field.set(sysUser, StrUtil.blankToDefault(sysDataDict.getName(), raw.getStr(mapping.key))); } else { - if ("LXRQ".equals(mapping.key)) { + // 特殊处理 根据身份证号获取性别和出生年月 + if ("SFZJH".equals(mapping.key)) { + String idCard = raw.getStr(mapping.key); + mapping.field.set(sysUser, idCard); + // 设置出生年月 + try { + sysUser.setBirthday(IdcardUtil.getBirthDate(idCard)); + } catch (Exception e) { + sysUser.setBirthday(null); + } + } else if ("LXNY".equals(mapping.key)) { mapping.field.set(sysUser, normalizeArrivalAtSchoolDate(raw.getStr(mapping.key))); } else { mapping.field.set(sysUser, raw.getStr(mapping.key)); } } } else if (mapping.field.getType() == Date.class) { - String dateText = StrUtil.trim(raw.getStr(mapping.key)); - if (StrUtil.isNotBlank(dateText)) { - mapping.field.set(sysUser, dateText.matches("\\d{8}") - ? DateUtil.parse(dateText, "yyyyMMdd") : DateUtil.parse(dateText)); - } + mapping.field.set(sysUser, raw.getDate(mapping.key)); } } catch (IllegalAccessException e) { log.error("设置字段值失败: {}", mapping.field.getName(), e); } } - if (sysUser.getBirthday() == null && StrUtil.isNotBlank(sysUser.getIdCard())) { - try { - sysUser.setBirthday(IdcardUtil.getBirthDate(sysUser.getIdCard())); - } catch (Exception e) { - sysUser.setBirthday(null); - } - } - String sourceUnitId = raw.getStr("SZDWDM"); - if (StrUtil.isBlank(sourceUnitId)) { - log.warn("人员{}缺少所属机构编号 SZDWDM,跳过单位信息补充,保留原数据入库", sysUser.getLoginname()); - } + sysUser.setUnitName(raw.getStr("SZDW")); + String sourceUnitId = raw.getStr("SZDWH"); String parentUnitId = tertiaryParentUnitIds.get(sourceUnitId); sysUser.setUnitId(StrUtil.blankToDefault(parentUnitId, sourceUnitId)); - sysUser.setThreeUnitId(tertiaryUnitIds.get(sourceUnitId)); - Sys_unit unit = resolvedUnitMap.get(sysUser.getUnitId()); - if (unit != null) { - sysUser.setUnitName(unit.getName()); - sysUser.setUnitPath(unit.getPath()); - if (StrUtil.isBlank(unit.getPath())) { - log.warn("人员{}所属单位{}路径为空,保留当前单位信息继续入库", sysUser.getLoginname(), unit.getId()); - } - } + sysUser.setThreeUnitId(parentUnitId == null ? null : sourceUnitId); // 设置拉取时间 sysUser.setPullTime(nowDate); + if(StringUtils.isNotBlank(sysUser.getPersonType()) && sysUser.getPersonType().equals("教职工")){ + sysUser.setUserAttribute("新增“教职工”"); + } + if(StringUtils.isNotBlank(sysUser.getPersonType()) && sysUser.getPersonType().equals("劳务派遣人员")){ + sysUser.setUserAttribute("劳务派遣人员"); + } return sysUser; }).toList(); @@ -256,7 +239,7 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl /** * 规范化信息中心返回的来校时间。 - * 入参 arrivalAtSchoolDate 对应信息中心 LXRQ 字段,允许 yyyy-MM-dd 或 yyyy-MM; + * 入参 arrivalAtSchoolDate 对应信息中心 LXNY 字段,允许 yyyy-MM-dd 或 yyyy-MM; * 返回值用于 sys_user_source.arrivalAtSchoolDate 和后续 sys_user.arrivalAtSchoolDate,年月格式统一补为当月 01 日。 */ private String normalizeArrivalAtSchoolDate(String arrivalAtSchoolDate) { @@ -272,8 +255,48 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl String[] parts = dateText.split("-"); return parts[0] + "-" + StrUtil.padPre(parts[1], 2, '0') + "-01"; } - return DateUtil.formatDate(dateText.matches("\\d{8}") - ? DateUtil.parse(dateText, "yyyyMMdd") : DateUtil.parse(dateText)); + return dateText; + } + + /** + * 获取信息中心开放平台访问令牌。 + * 返回值为接口后续调用使用的 access_token 字符串。 + */ + private String getDmpAccessToken() { + String tokenUrl = dataCenterProperties.getTokenUrl(); + if (StrUtil.isBlank(tokenUrl)) { + throw new BaseException("未配置信息中心token地址: data-center.token-url"); + } + String key = dataCenterProperties.getKey(); + if (StrUtil.isBlank(key)) { + throw new BaseException("未配置信息中心token key: data-center.key"); + } + String secret = dataCenterProperties.getSecret(); + if (StrUtil.isBlank(secret)) { + throw new BaseException("未配置信息中心token secret: data-center.secret"); + } + // 信息中心token接口要求key和secret通过URL参数传入,配置中分开维护,调用时统一组装。 + String params = URLUtil.buildQuery(Map.of("key", key, "secret", secret), StandardCharsets.UTF_8); + HttpRequest tokenHttpRequest = HttpUtil.createGet(tokenUrl + "?" + params); + log.info("请求信息中心token: {}", tokenHttpRequest); + String tokenResBody = tokenHttpRequest.execute().body(); + log.info("请求信息中心token结果: {}", tokenResBody); + + JSONObject tokenJsonBody = JSONUtil.parseObj(tokenResBody); + String accessToken = tokenJsonBody.getStr("access_token"); + if (StrUtil.isBlank(accessToken) && tokenJsonBody.getJSONObject("result") != null) { + accessToken = tokenJsonBody.getJSONObject("result").getStr("access_token"); + } + if (StrUtil.isBlank(accessToken) && tokenJsonBody.getJSONObject("data") != null) { + accessToken = tokenJsonBody.getJSONObject("data").getStr("access_token"); + } + if (StrUtil.isBlank(accessToken)) { + accessToken = tokenJsonBody.getStr("token"); + } + if (StrUtil.isBlank(accessToken)) { + throw new BaseException("获取信息中心token失败"); + } + return accessToken; } /** @@ -289,7 +312,54 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl } /** - * 按职工号去重,避免同一批次出现重复人员。 + * 分页拉取单个人员接口数据。 + * 入参 url 为人员接口地址,accessToken 为信息中心令牌,sourceName 用于日志区分接口来源。 + * 返回值为该接口所有分页合并后的原始人员 JSON 列表。 + */ + private List pullDmpUsers(String url, String accessToken, String sourceName) { + List rawDataList = new ArrayList<>(); + int page = 1; + int maxPage = 1; + int total = 0; + + do { + // 信息中心接口单页最多返回1000条,按page循环拉完当前接口全部数据。 + HttpRequest httpRequest = HttpUtil.createPost(url); + httpRequest.header(Header.CONTENT_TYPE, "application/json"); + httpRequest.body(JSONUtil.toJsonStr(Map.of( + "access_token", accessToken, + "per_page", String.valueOf(DMP_PAGE_SIZE), + "page", String.valueOf(page) + ))); + + log.info("请求{}人员数据,第{}页: {}", sourceName, page, httpRequest); + String resBody = httpRequest.execute().body(); + log.info("请求{}人员数据第{}页结果: {}", sourceName, page, resBody); + JSONObject jsonBody = JSONUtil.parseObj(resBody); + + if (jsonBody.getInt("code") != 10000) { + throw new BaseException("获取" + sourceName + "人员数据失败,错误码: " + jsonBody.getInt("code") + ";错误原因:" + jsonBody.getStr("message")); + } + + JSONObject result = jsonBody.getJSONObject("result"); + if (result == null) { + break; + } + total = result.getInt("total", total); + maxPage = result.getInt("max_page", maxPage); + JSONArray data = result.getJSONArray("data"); + if (CollUtil.isNotEmpty(data)) { + rawDataList.addAll(data.stream().map(v -> (JSONObject) v).toList()); + } + log.info("{}人员数据拉取进度: {}/{}", sourceName, rawDataList.size(), total); + page++; + } while (page <= maxPage); + + return rawDataList; + } + + /** + * 合并两个接口数据时按职工号去重,避免同一批次出现重复人员。 * 返回值保持第一次出现的数据,用于后续统一映射入库。 */ private List distinctDmpUsers(List rawDataList) { @@ -297,7 +367,7 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl for (JSONObject raw : rawDataList) { String loginName = raw.getStr("ZGH"); if (StrUtil.isBlank(loginName)) { - throw new BaseException("人员接口存在缺少职工号 ZGH 的记录"); + loginName = raw.getStr("ID"); } userMap.putIfAbsent(loginName, raw); } diff --git a/src/main/java/com/budwk/app/sys/services/impl/SysDictServiceImpl.java b/src/main/java/com/budwk/app/sys/services/impl/SysDictServiceImpl.java index d57b955..141ae72 100644 --- a/src/main/java/com/budwk/app/sys/services/impl/SysDictServiceImpl.java +++ b/src/main/java/com/budwk/app/sys/services/impl/SysDictServiceImpl.java @@ -27,6 +27,9 @@ import java.util.Map; @IocBean(args = {"refer:dao"}) @CacheDefaults(cacheName = RedisConstant.PLATFORM_REDIS_WKCACHE_PREFIX + "sys_dict", isHash = true) public class SysDictServiceImpl extends BaseServiceImpl implements SysDictService { + private static final String USER_ATTRIBUTE_DICT_CODE = "USER_ATTRIBUTE"; + private static final String USER_PERSON_TYPE_DICT_CODE = "USER_PERSON_TYPE"; + public SysDictServiceImpl(Dao dao) { super(dao); } @@ -132,18 +135,76 @@ public class SysDictServiceImpl extends BaseServiceImpl implements Sys @Aop(TransAop.READ_COMMITTED) public void save(Sys_dict dict, String pid) { String path = ""; + Sys_dict parentDict = null; if (!Strings.isEmpty(pid)) { - Sys_dict pp = this.fetch(pid); - path = pp.getPath(); + parentDict = this.fetch(pid); + path = parentDict.getPath(); } dict.setPath(getSubPath("sys_dict", "path", path)); dict.setParentId(pid); + validateUserAttribute(dict, parentDict); dao().insert(dict); if (!Strings.isEmpty(pid)) { this.update(Chain.make("hasChildren", true), Cnd.where("id", "=", pid)); } } + /** + * 更新字典记录。人员属性关联字段允许为空;填写后校验人员类型有效性和业务编码唯一性。 + * + * @param dict 待更新字典,id 用于定位原记录并在唯一性校验中排除自身 + */ + @Override + @Aop(TransAop.READ_COMMITTED) + public void updateDict(Sys_dict dict) { + Sys_dict current = this.fetch(dict.getId()); + if (current == null) { + throw new BaseException("字典数据不存在"); + } + Sys_dict parentDict = Strings.isBlank(current.getParentId()) ? null : this.fetch(current.getParentId()); + dict.setParentId(current.getParentId()); + validateUserAttribute(dict, parentDict); + this.updateIgnoreNull(dict); + } + + /** + * 校验 USER_ATTRIBUTE 直属子项的扩展关系。 + * personType 为空时允许保存;有值时必须属于 USER_PERSON_TYPE 的启用子项。 + * userAttributeCode 为空时允许保存;有值时在 USER_ATTRIBUTE 直属子项中必须唯一。 + */ + private void validateUserAttribute(Sys_dict dict, Sys_dict parentDict) { + if (parentDict == null || !USER_ATTRIBUTE_DICT_CODE.equals(parentDict.getCode())) { + return; + } + String personType = Strings.sNull(dict.getPersonType()).trim(); + String userAttributeCode = Strings.sNull(dict.getUserAttributeCode()).trim(); + dict.setPersonType(personType); + dict.setUserAttributeCode(userAttributeCode); + + if (!Strings.isBlank(personType)) { + Sys_dict personTypeRoot = this.fetch(Cnd.where("code", "=", USER_PERSON_TYPE_DICT_CODE)); + boolean personTypeValid = personTypeRoot != null && this.count( + Cnd.where("parentId", "=", personTypeRoot.getId()) + .and("code", "=", personType) + .and("disabled", "=", false) + ) > 0; + if (!personTypeValid) { + throw new BaseException("人员类型不存在或已禁用"); + } + } + + if (!Strings.isBlank(userAttributeCode)) { + Cnd codeCondition = Cnd.where("parentId", "=", parentDict.getId()) + .and("userAttributeCode", "=", userAttributeCode); + if (!Strings.isBlank(dict.getId())) { + codeCondition.and("id", "!=", dict.getId()); + } + if (this.count(codeCondition) > 0) { + throw new BaseException("人员属性编码已存在"); + } + } + } + @Aop(TransAop.READ_COMMITTED) public void saveByParentCode(Sys_dict dict, String parentCode) { Sys_dict parentDict = fetch(Cnd.where("code", "=", parentCode)); diff --git a/src/main/java/com/budwk/app/sys/services/impl/SysHomeNewsServiceImpl.java b/src/main/java/com/budwk/app/sys/services/impl/SysHomeNewsServiceImpl.java new file mode 100644 index 0000000..5407818 --- /dev/null +++ b/src/main/java/com/budwk/app/sys/services/impl/SysHomeNewsServiceImpl.java @@ -0,0 +1,159 @@ +package com.budwk.app.sys.services.impl; + +import cn.hutool.core.util.StrUtil; +import com.budwk.app.sys.services.SysHomeNewsService; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; +import org.nutz.integration.jedis.RedisService; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.json.Json; +import org.nutz.lang.util.NutMap; + +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import java.util.Map; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 首页工会网站新闻服务实现,统一为PC端和移动端提供同一份新闻数据。 + */ +@IocBean +public class SysHomeNewsServiceImpl implements SysHomeNewsService { + + private static final String WEBSITE_URL = "https://xgh.cug.edu.cn/"; + private static final String NEWS_CACHE_KEY = "gonghui_news"; + private static final int NEWS_CACHE_SECONDS = 60 * 60 * 24; + + @Inject + private RedisService redisService; + + /** + * 抓取学校工会网站首页的工会要闻、基层动态等栏目。 + * 外部网站暂时不可用时优先返回最近一次缓存,避免PC端和移动端同时显示为空。 + * + * @return 新闻栏目集合;栏目 value 中的新闻项包含 href、time、text + */ + @Override + public List getWebsiteNews() { + try { + Document document = Jsoup.connect(WEBSITE_URL).timeout(10000).get(); + List news = parseWebsiteNews(document.select(".gh,.jc,.fc,.jg")); + if (!news.isEmpty()) { + redisService.setex(NEWS_CACHE_KEY, NEWS_CACHE_SECONDS, Json.toJson(news)); + return news; + } + return getCachedNews(); + } catch (IOException e) { + List cachedNews = getCachedNews(); + if (!cachedNews.isEmpty()) { + return cachedNews; + } + throw new RuntimeException("学校工会网站新闻获取失败", e); + } + } + + /** + * 将外部网站栏目节点转换为前端统一使用的栏目和新闻结构。 + */ + private List parseWebsiteNews(Elements homeElements) { + List sections = new ArrayList<>(); + for (Element homeElement : homeElements) { + Element titleElement = homeElement.selectFirst(".title"); + if (titleElement == null || StrUtil.isBlank(titleElement.text())) { + continue; + } + List rows = new ArrayList<>(); + for (Element itemElement : homeElement.select("li")) { + Element linkElement = itemElement.selectFirst("a"); + if (linkElement == null || StrUtil.isBlank(linkElement.attr("href"))) { + continue; + } + Element timeElement = itemElement.selectFirst("span"); + // 仅使用当前新闻条目的缩略图,按网页基址补全相对路径。 + Element imageElement = itemElement.selectFirst("img"); + String thumbUrl = imageElement == null ? "" : imageElement.absUrl("src"); + if (!thumbUrl.startsWith("https://") && !thumbUrl.startsWith("http://")) { + thumbUrl = ""; + } + String title = StrUtil.blankToDefault(linkElement.attr("title"), linkElement.text()); + rows.add(NutMap.NEW() + .addv("href", linkElement.attr("href")) + .addv("time", timeElement == null ? "" : timeElement.text()) + .addv("text", title) + .addv("thumbUrl", thumbUrl)); + } + sections.add(NutMap.NEW().addv("label", titleElement.text()).addv("value", rows)); + } + return sections; + } + + /** + * 读取最近一次成功抓取的数据;缓存为空或内容异常时返回空集合。 + */ + private List getCachedNews() { + String cached = redisService.get(NEWS_CACHE_KEY); + if (StrUtil.isBlank(cached)) { + return Collections.emptyList(); + } + try { + return Json.fromJsonAsList(NutMap.class, cached); + } catch (RuntimeException e) { + return Collections.emptyList(); + } + } + + /** + * 仅补取缓存新闻列表中已存在的公开文章,禁止任意地址抓取和自动重定向。 + * 正文首图独立缓存;无图短暂缓存,防止每次栏目切换重复访问外站。 + */ + @Override + public String getNewsImage(String href) { + if (StrUtil.isBlank(href) || href.length() > 2048) return ""; + boolean listed = false; + for (NutMap section : getCachedNews()) { + Object values = section.get("value"); + if (!(values instanceof List)) continue; + for (Object value : (List) values) { + if (value instanceof Map && href.equals(((Map) value).get("href"))) { + listed = true; + break; + } + } + } + if (!listed) return ""; + try { + URI uri = URI.create(WEBSITE_URL).resolve(href); + String host = uri.getHost(); + // 来源限制为学校公开网站和微信公众号,端口及凭据均不接受自定义。 + if (host == null || !(host.equals("cug.edu.cn") || host.endsWith(".cug.edu.cn") + || host.equals("mp.weixin.qq.com")) || uri.getUserInfo() != null + || uri.getPort() != -1 || !("https".equals(uri.getScheme()) || "http".equals(uri.getScheme()))) return ""; + String cacheKey = "gonghui_news_image:" + UUID.nameUUIDFromBytes(uri.toString().getBytes(StandardCharsets.UTF_8)); + String cached = redisService.get(cacheKey); + if (cached != null) return "-".equals(cached) ? "" : cached; + String image = ""; + try { + Document article = Jsoup.connect(uri.toString()).timeout(5000).maxBodySize(2 * 1024 * 1024) + .followRedirects(false).get(); + Element first = article.selectFirst(".v_news_content img, #js_content img"); + if (first != null) { + image = first.hasAttr("data-src") ? first.absUrl("data-src") : first.absUrl("src"); + } + if (!image.startsWith("https://") && !image.startsWith("http://")) image = ""; + } catch (IOException ignored) { + // 外站失败不影响首页新闻文字和栏目切换,五分钟后允许再次补取。 + } + redisService.setex(cacheKey, image.isEmpty() ? 300 : NEWS_CACHE_SECONDS, image.isEmpty() ? "-" : image); + return image; + } catch (IllegalArgumentException e) { + return ""; + } + } +} diff --git a/src/main/java/com/budwk/app/sys/services/impl/SysUserServiceImpl.java b/src/main/java/com/budwk/app/sys/services/impl/SysUserServiceImpl.java index 55c166a..da109bb 100644 --- a/src/main/java/com/budwk/app/sys/services/impl/SysUserServiceImpl.java +++ b/src/main/java/com/budwk/app/sys/services/impl/SysUserServiceImpl.java @@ -19,6 +19,7 @@ import com.budwk.app.sys.services.SysMenuService; import com.budwk.app.sys.services.SysRoleService; import com.budwk.app.sys.services.SysUserService; import com.budwk.app.sys.views.View_user; +import com.budwk.app.web.commons.auth.service.ValidateService; import com.budwk.app.web.commons.base.Globals; import com.budwk.app.zhgh.staffmanage.specialstaff.model.SpecialStaff; import org.nutz.aop.interceptor.ioc.TransAop; @@ -29,12 +30,12 @@ import org.nutz.dao.Sqls; import org.nutz.dao.sql.Sql; import org.nutz.integration.jedis.RedisService; import org.nutz.ioc.aop.Aop; -import org.nutz.ioc.impl.PropertiesProxy; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.lang.Lang; import org.nutz.lang.Strings; import org.nutz.lang.random.R; +import org.nutz.lang.util.NutMap; import org.nutz.plugins.wkcache.annotation.CacheDefaults; import org.nutz.plugins.wkcache.annotation.CacheRemove; import org.nutz.plugins.wkcache.annotation.CacheRemoveAll; @@ -65,7 +66,7 @@ public class SysUserServiceImpl extends BaseServiceImpl implements Sys @Inject private RedisService redisService; @Inject - private PropertiesProxy conf; + private ValidateService validateService; @Override @CacheResult(cacheKey = "${userId}_getPermissionList") @@ -359,9 +360,9 @@ public class SysUserServiceImpl extends BaseServiceImpl implements Sys String decodePwd = Base64Decoder.decodeStr(passowrd); String hashedPassword = PwdUtil.getPassword(decodePwd, user.getSalt()); if (!Strings.sNull(hashedPassword).equalsIgnoreCase(user.getPassword())) { - if (Globals.sso) { - throw new BaseException("用户名或者密码不正确"); - } +// if (Globals.sso) { +// throw new BaseException("用户名或者密码不正确"); +// } } user = this.fetchLinks(user, "unit"); if (Lang.isNotEmpty(user.getUnit()) && StrUtil.isNotBlank(user.getUnit().getUnionId())) { @@ -371,16 +372,90 @@ public class SysUserServiceImpl extends BaseServiceImpl implements Sys return user; } + /** + * 按登记手机号确认唯一且未禁用的账号,补齐后续登录会话使用的组织关系。 + * + * @param mobile 已登记的 11 位大陆手机号,查询前移除首尾空格 + * @return Sys_user,包含 unit 单位和按单位关联的 union 分工会信息 + * @throws BaseException 手机号无效、未登记、关联多条用户或账号已禁用 + */ @Override public Sys_user loginByMobile(String mobile) throws BaseException { - Sys_user user = this.fetch(Cnd.where("mobile", "=", mobile)); - if (user == null) { - throw new BaseException("用户不存在"); + String normalizedMobile = Strings.sNull(mobile).trim(); + if (!normalizedMobile.matches("^1[3-9]\\d{9}$")) { + throw new BaseException("请输入正确的手机号码"); + } + // 手机号不是唯一字段;任何重复关联都拒绝登录,避免任取账号或绕过禁用状态。 + List users = this.query(Cnd.where("mobile", "=", normalizedMobile)); + if (users.isEmpty()) { + throw new BaseException("该手机号未绑定系统账号,请联系管理员"); + } + if (users.size() > 1) { + throw new BaseException("该手机号关联多个账号,请联系管理员"); + } + Sys_user user = users.get(0); + if (user.isDisabled()) { + throw new BaseException("用户被禁用"); } user = this.fetchLinks(user, "unit"); + if (Lang.isNotEmpty(user.getUnit()) && StrUtil.isNotBlank(user.getUnit().getUnionId())) { + user.setUnion(dao().fetch(Sys_union.class, user.getUnit().getUnionId())); + } return user; } + /** + * 先确认手机号唯一且账号可用,再交由验证码服务校验滑块凭证、限流并实际发送短信。 + * + * @param mobile 系统人员资料中登记的 11 位大陆手机号 + * @param captchaKey 滑块验证接口返回的 platformKey + * @param captchaCode 滑块验证接口返回的一次性 platformCaptcha 凭证 + * @param request 当前请求,以 getRemoteAddr 取得的地址作为服务端发送限流依据 + * @return NutMap,retryAfter 为重发等待秒数,expireIn 为短信验证码有效秒数 + */ + @Override + @Aop(TransAop.READ_COMMITTED) + public NutMap sendLoginSmsCode(String mobile, String captchaKey, String captchaCode, HttpServletRequest request) { + if (request == null) { + throw new BaseException("登录请求无效"); + } + Sys_user user = this.loginByMobile(mobile); + return validateService.sendLoginSmsCode(user.getMobile(), captchaKey, captchaCode, request.getRemoteAddr(), request.getSession().getId()); + } + + /** + * 使用短信验证码登录既有账号,验证成功后复用原有会话、日志和跳转处理。 + * + * @param mobile 已登记且只关联一个账号的 11 位大陆手机号 + * @param smsCode 登录短信中的验证码,成功校验后一次性消费 + * @param request 当前请求,供既有登录流程记录客户端 IP 并读取跳转参数 + * @return String,原有登录流程生成的登录成功跳转地址 + */ + @Override + @Aop(TransAop.READ_COMMITTED) + public String loginBySmsCode(String mobile, String smsCode, HttpServletRequest request) { + if (request == null) { + throw new BaseException("登录请求无效"); + } + Sys_user user = this.loginByMobile(mobile); + // loginPlus 会读取视图中的工会小组等资料,必须在建立登录态之前确认关联资料存在。 + if (dao().fetch(View_user.class, Cnd.where("id", "=", user.getId())) == null) { + throw new BaseException("账号关联资料不完整,请联系管理员"); + } + validateService.checkLoginSmsCode(user.getMobile(), smsCode); + try { + return this.loginPlus(user, LoginType.SMS_CODE, request); + } catch (RuntimeException e) { + // 仅在进入创建登录态的流程后清理;手机号或验证码校验失败不会注销原有登录。 + try { + StpUtil.logout(); + } catch (RuntimeException cleanupException) { + e.addSuppressed(cleanupException); + } + throw e; + } + } + @Override public Sys_user loginByLoginName(String loginname) { Sys_user user = this.fetch(Cnd.where("loginname", "=", loginname)); @@ -449,6 +524,7 @@ public class SysUserServiceImpl extends BaseServiceImpl implements Sys } @Override + @Aop(TransAop.READ_COMMITTED) public String loginPlus(Sys_user user, LoginType loginType, HttpServletRequest request) { String wxOpenId = request.getParameter("wxOpenId"); @@ -496,11 +572,6 @@ public class SysUserServiceImpl extends BaseServiceImpl implements Sys String redirect = request.getParameter("redirect"); if (StrUtil.isNotBlank(redirect)) { - if ("prd".equalsIgnoreCase(conf.get("nutz.profiles.active")) - && (redirect.equals(Globals.AppDomain) || redirect.startsWith(Globals.AppDomain + "/") - || redirect.startsWith(Globals.AppDomain + "?") || redirect.startsWith(Globals.AppDomain + "#"))) { - return redirect; - } return Globals.AppDomain + redirect; } else { return Globals.AppDomain + "/platform/home"; diff --git a/src/main/java/com/budwk/app/web/commons/auth/service/ValidateService.java b/src/main/java/com/budwk/app/web/commons/auth/service/ValidateService.java index 40b416a..0207811 100644 --- a/src/main/java/com/budwk/app/web/commons/auth/service/ValidateService.java +++ b/src/main/java/com/budwk/app/web/commons/auth/service/ValidateService.java @@ -3,19 +3,30 @@ package com.budwk.app.web.commons.auth.service; import com.budwk.app.base.constant.RedisConstant; import com.budwk.app.base.exception.BaseException; import com.budwk.app.base.utils.RsaUtil; +import com.budwk.app.zhgh.dayofficework.message.service.TencentSmsService; +import com.budwk.app.zhgh.dayofficework.message.service.SliderCaptchaService; import com.wf.captcha.ArithmeticCaptcha; import lombok.extern.slf4j.Slf4j; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.integration.jedis.JedisAgent; import org.nutz.integration.jedis.RedisService; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.impl.PropertiesProxy; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.lang.Strings; import org.nutz.lang.random.R; import org.nutz.lang.util.NutMap; +import redis.clients.jedis.Jedis; import java.security.KeyFactory; import java.security.PrivateKey; +import java.security.SecureRandom; import java.security.spec.PKCS8EncodedKeySpec; import java.util.Base64; +import java.util.Arrays; +import java.util.Collections; +import java.util.Locale; /** * @author wizzer@qq.com @@ -23,14 +34,45 @@ import java.util.Base64; @IocBean @Slf4j public class ValidateService { + private static final int SMS_EXPIRE_SECONDS = 300; + // 同一手机号的重发等待时间与短信验证码有效期一致,发送成功后五分钟内禁止重发。 + private static final int SMS_RETRY_SECONDS = 300; + private static final SecureRandom SMS_RANDOM = new SecureRandom(); + + // 计数与有效期原子设置,避免并发发送绕过限流或产生永不过期的计数键。 + private static final String HOUR_LIMIT_SCRIPT = "local count=redis.call('incr',KEYS[1]); " + + "if count==1 then redis.call('expire',KEYS[1],3600) end; return count"; + // 成功发送后才保存验证码;带号码 hash tag 的键可在同一个 Redis 分片原子更新。 + private static final String SAVE_SMS_SCRIPT = "if redis.call('get',KEYS[1])~=ARGV[1] then return 0 end; " + + "redis.call('set',KEYS[2],ARGV[2],'EX',ARGV[3]); redis.call('del',KEYS[3]); " + + "redis.call('expire',KEYS[1],ARGV[4]); return 1"; + private static final String RELEASE_LOCK_SCRIPT = "if redis.call('get',KEYS[1])==ARGV[1] " + + "then return redis.call('del',KEYS[1]) else return 0 end"; + // 校验、错误计数和消费必须原子完成,同一码只能有一个请求成功,五次输错后作废。 + private static final String VERIFY_SMS_SCRIPT = "local code=redis.call('get',KEYS[1]); " + + "if not code then return 0 end; if code~=ARGV[1] then " + + "local count=redis.call('incr',KEYS[2]); local ttl=redis.call('ttl',KEYS[1]); " + + "redis.call('expire',KEYS[2],math.max(ttl,1)); " + + "if count>=5 then redis.call('del',KEYS[1],KEYS[2]); return -2 end; return -1 end; " + + "redis.call('del',KEYS[1],KEYS[2]); return 1"; + @Inject private RedisService redisService; + @Inject + private JedisAgent jedisAgent; + @Inject + private PropertiesProxy conf; + @Inject + private TencentSmsService tencentSmsService; + @Inject + private SliderCaptchaService sliderCaptchaService; /** * 获取验证码 * - * @return + * @return key 为图形验证码标识,codeUrl 为可直接展示的 Base64 图片;验证码有效期为三分钟 */ + @Aop(TransAop.READ_COMMITTED) public NutMap getCaptcha() { String uuid = R.UU32(); ArithmeticCaptcha captcha = new ArithmeticCaptcha(120, 40); @@ -41,21 +83,66 @@ public class ValidateService { } /** - * 发送短信验证码 + * 校验发送条件后发送登录短信,只有腾讯云确认发送成功才写入有效验证码。 * - * @param mobile 手机号码 - * @throws BaseException + * @param mobile 已由用户 service 确认唯一绑定、未禁用的 11 位大陆手机号 + * @param captchaKey 滑块验证接口返回的 platformKey + * @param captchaCode 滑块验证接口返回的一次性 platformCaptcha 凭证 + * @param sessionId 服务端当前 Servlet 会话 ID,不接受前端指定 + * @param clientIp 请求连接的来源 IP,用于后端发送频控 + * @return retryAfter 为重发等待秒数,expireIn 为本次短信验证码有效秒数,不返回验证码或 Redis 键 + * @throws BaseException 参数无效、滑块凭证不正确、频控触发或短信发送失败 */ - public void getSmsCode(String mobile) throws BaseException { - String text = R.captchaNumber(4); - String codeFromRedis = redisService.get(RedisConstant.UCENTER_SMSCODE + mobile + ":LOCK"); - if (Strings.isNotBlank(codeFromRedis)) { - throw new BaseException("请1分钟之后再试"); + @Aop(TransAop.READ_COMMITTED) + public NutMap sendLoginSmsCode(String mobile, String captchaKey, String captchaCode, String clientIp, String sessionId) { + if (mobile == null || !mobile.matches("^1[3-9]\\d{9}$") || Strings.isBlank(clientIp)) { + throw new BaseException("手机号或请求来源不正确"); + } + sliderCaptchaService.consume(sessionId, captchaKey, captchaCode, SliderCaptchaService.SMS, mobile); + tencentSmsService.checkLoginConfiguration(); + String mobileKey = RedisConstant.LOGIN_SMSCODE + "{" + mobile + "}"; + String lockKey = mobileKey + ":LOCK"; + String lockToken = R.UU32(); + try (Jedis jedis = jedisAgent.getResource()) { + if (!"OK".equals(jedis.set(lockKey, lockToken, "NX", "EX", SMS_RETRY_SECONDS))) { + throw new BaseException("验证码发送频繁,请5分钟后再试"); + } + } + + boolean saved = false; + try { + try (Jedis jedis = jedisAgent.getResource()) { + long mobileCount = ((Number) jedis.eval(HOUR_LIMIT_SCRIPT, + Collections.singletonList(mobileKey + ":HOUR"), Collections.emptyList())).longValue(); + long ipCount = ((Number) jedis.eval(HOUR_LIMIT_SCRIPT, + Collections.singletonList(RedisConstant.LOGIN_SMSCODE + "ip:" + clientIp), Collections.emptyList())).longValue(); + if (mobileCount > Math.max(1, conf.getInt("sms.login.mobile-hour-limit", 10)) + || ipCount > Math.max(1, conf.getInt("sms.login.ip-hour-limit", 30))) { + throw new BaseException("验证码请求次数过多,请稍后再试"); + } + } + String code = String.format(Locale.ROOT, "%06d", SMS_RANDOM.nextInt(1000000)); + tencentSmsService.sendLoginCode(mobile, code); + try (Jedis jedis = jedisAgent.getResource()) { + Number saveResult = (Number) jedis.eval(SAVE_SMS_SCRIPT, + Arrays.asList(lockKey, mobileKey, mobileKey + ":FAIL"), + Arrays.asList(lockToken, code, String.valueOf(SMS_EXPIRE_SECONDS), String.valueOf(SMS_RETRY_SECONDS))); + if (saveResult.longValue() != 1) { + throw new BaseException("短信验证码处理超时,请稍后重新获取"); + } + saved = true; + } + return NutMap.NEW().addv("retryAfter", SMS_RETRY_SECONDS).addv("expireIn", SMS_EXPIRE_SECONDS); + } finally { + // 失败只释放本请求持有的锁,不覆盖旧验证码,也不删除其他请求的新发送锁。 + if (!saved) { + try (Jedis jedis = jedisAgent.getResource()) { + jedis.eval(RELEASE_LOCK_SCRIPT, Collections.singletonList(lockKey), Collections.singletonList(lockToken)); + } catch (RuntimeException e) { + log.warn("登录短信发送锁释放失败,锁将在五分钟内自动过期"); + } + } } -// smsSendServer.sendCode(mobile, text); - log.debug("sms code:::" + text); - redisService.setex(RedisConstant.UCENTER_SMSCODE + mobile, 300, text); - redisService.setex(RedisConstant.UCENTER_SMSCODE + mobile + ":LOCK", 60, text); } /** @@ -65,6 +152,7 @@ public class ValidateService { * @param code 验证码的值 * @throws BaseException */ + @Aop(TransAop.READ_COMMITTED) public void checkCode(String key, String code) throws BaseException { String codeFromRedis = redisService.get(RedisConstant.UCENTER_CAPTCHA + key); @@ -81,27 +169,34 @@ public class ValidateService { } /** - * 核验短信验证码 + * 按服务端收到的手机号核验登录短信验证码,成功后原子删除,输错五次作废。 * - * @param mobile 手机号码 - * @param code 验证码值 - * @throws BaseException + * @param mobile 本次登录的 11 位大陆手机号,与发送时的手机号完全一致 + * @param code 用户输入的六位数字短信验证码 + * @throws BaseException 验证码为空、格式错误、不匹配、过期或已使用 */ - public void checkSMSCode(String mobile, String code) throws BaseException { - String codeFromRedis = redisService.get(RedisConstant.UCENTER_SMSCODE + mobile); - - if (Strings.isBlank(code)) { - throw new BaseException("请输入短信验证码"); + @Aop(TransAop.READ_COMMITTED) + public void checkLoginSmsCode(String mobile, String code) throws BaseException { + if (mobile == null || !mobile.matches("^1[3-9]\\d{9}$") || code == null || !code.matches("^\\d{6}$")) { + throw new BaseException("请输入正确的手机号和六位短信验证码"); } - if (Strings.isEmpty(codeFromRedis)) { - throw new BaseException("短信验证码已过期"); + String mobileKey = RedisConstant.LOGIN_SMSCODE + "{" + mobile + "}"; + try (Jedis jedis = jedisAgent.getResource()) { + long result = ((Number) jedis.eval(VERIFY_SMS_SCRIPT, + Arrays.asList(mobileKey, mobileKey + ":FAIL"), Collections.singletonList(code))).longValue(); + if (result == 0) { + throw new BaseException("短信验证码已过期或已使用,请重新获取"); + } + if (result == -2) { + throw new BaseException("短信验证码错误次数过多,请重新获取"); + } + if (result != 1) { + throw new BaseException("短信验证码不正确"); + } } - if (!Strings.equalsIgnoreCase(code, codeFromRedis)) { - throw new BaseException("短信验证码不正确"); - } - redisService.del(RedisConstant.UCENTER_SMSCODE + mobile); } + @Aop(TransAop.READ_COMMITTED) public String decryptPwd(String keyId, String password) throws Exception { // 从 Redis 获取私钥 String rsaKey = RedisConstant.RSA_KEY_PREFIX + keyId; diff --git a/src/main/java/com/budwk/app/web/commons/ext/handler/WkTomcatConfiguration.java b/src/main/java/com/budwk/app/web/commons/ext/handler/WkTomcatConfiguration.java index 67fed5c..c03486c 100644 --- a/src/main/java/com/budwk/app/web/commons/ext/handler/WkTomcatConfiguration.java +++ b/src/main/java/com/budwk/app/web/commons/ext/handler/WkTomcatConfiguration.java @@ -3,6 +3,7 @@ package com.budwk.app.web.commons.ext.handler; import com.budwk.app.web.commons.ext.websocket.WkWebSocket; import org.apache.catalina.core.StandardContext; import org.apache.catalina.Executor; +import org.apache.catalina.Wrapper; import org.apache.catalina.core.StandardThreadExecutor; import org.apache.catalina.startup.Tomcat; import org.apache.catalina.valves.ErrorReportValve; @@ -51,6 +52,11 @@ public class WkTomcatConfiguration implements NbAppEventListener { // NutzBoot 使用编程式 Context,必须显式运行官方 WebSocket 初始化器。 StandardContext context = (StandardContext) tomcat.getHost().findChild(starter.getContextPath()); + // 静态文本资源统一按 UTF-8 读取,避免服务器默认 GBK 与 UTF-8 响应编码不一致时破坏中文和正则。 + Wrapper defaultServlet = (Wrapper) context.findChild("default"); + defaultServlet.addInitParameter("fileEncoding", "UTF-8"); + // 为静态 SVG 补齐响应类型,避免浏览器收到文件后仍无法按图片解码。 + context.addMimeMapping("svg", "image/svg+xml"); context.setUseHttpOnly(conf.getBoolean("tomcat.session.cookie.httponly", true)); context.addServletContainerInitializer(new WsSci(), Set.of(WkWebSocket.class)); // Tomcat 的错误页再次 forward 时仍保持 ERROR 派发;让 MVC 渲染原业务错误页。 diff --git a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpAdjustController.java b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpAdjustController.java index 998a9c1..da8e6ac 100644 --- a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpAdjustController.java +++ b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpAdjustController.java @@ -1,32 +1,22 @@ package com.budwk.app.zhgh.activity.trainSignUp.controller.manage; import cn.dev33.satoken.annotation.SaCheckPermission; -import cn.hutool.core.date.DateUtil; import com.budwk.app.base.annotation.SLog; import com.budwk.app.base.page.Pagination; import com.budwk.app.base.param.PageForm; import com.budwk.app.base.result.Result; -import com.budwk.app.base.service.BaseService; -import com.budwk.app.zhgh.activity.trainSignUp.models.*; -import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityService; import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityStatisticsService; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.extern.slf4j.Slf4j; -import org.nutz.dao.Cnd; -import org.nutz.dao.Dao; -import org.nutz.dao.Sqls; -import org.nutz.dao.sql.Sql; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; -import org.nutz.lang.util.NutMap; import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Param; -import java.util.ArrayList; -import java.util.List; - /** * @Author JyuHsin * @Date 2022/9/21 @@ -39,12 +29,6 @@ import java.util.List; @At("/platform/trainSignUp/adjust") public class TrainSignUpAdjustController { - @Inject - private Dao dao; - @Inject - private BaseService baseService; - @Inject - private TrainSignUpActivityService trainSignUpActivityManageService; @Inject private TrainSignUpActivityStatisticsService trainSignUpActivityStatisticsService; @@ -55,122 +39,87 @@ public class TrainSignUpAdjustController { } /** - * 活动list - * @param year 年度 - * @return + * @param year 查询年度,为空时不限制年度 + * @return Result.data 为当前登录人可管理的已启用主活动数组,id/activityName 用于下拉框 */ @At @ApiOperation("活动列表") @SaCheckPermission("trainSignUp.adjust") - public Result activityList(@Param(value = "year") Integer year) { - List activityList = dao.query(TrainSignUpActivity.class, Cnd.NEW().andEX("year", "=", year).andEX("isDisabled", "=", false).desc("activityStartTime")); - return Result.success(activityList); + public Result activityList(@Param("year") Integer year) { + return Result.success(trainSignUpActivityStatisticsService.activityList(year, true)); } + /** + * @param pageForm 分页参数,pageNumber 为页码,pageSize 为每页条数 + * @param activityId 主活动ID,为空时返回空分页 + * @return Result.data 为 Pagination,list 和 totalCount 均按负责人范围查询 + */ @At @ApiOperation("分页查询") @SaCheckPermission("trainSignUp.adjust") - public Result pageData(PageForm pageForm, - @Param(value = "activityId") String activityId) { + public Result pageData(PageForm pageForm, @Param("activityId") String activityId) { Pagination pagination = trainSignUpActivityStatisticsService.pageData(pageForm, activityId); return Result.success(pagination); } + /** + * @param activityId 主活动ID + * @return Result.data 为可调整的子活动数组,id 为目标ID,registerNum/hasWaitingNum 为已报及候补人数 + */ @At @ApiOperation("子活动查询") @SaCheckPermission("trainSignUp.adjust") public Result getCourse(String activityId) { - Sql sql = Sqls.create(""" - SELECT - tsuc.id, - tsuc.courseName, - tsuc.coursePeopleNumber, - tsuc.courseType, - tsuc.courseLocation, - tsuc.courseInstructor, - tsuc.courseReservedNumber, - tsuc.waitingNum, - tsuc.reserveMode - FROM - `train_sign_up_course` tsuc - WHERE - tsuc.activityId = @activityId - ORDER BY courseName asc - """); - sql.setParam("activityId", activityId); - List courseList = baseService.listMap(sql); - courseList.forEach(c -> { - c.put("registerNum", trainSignUpActivityStatisticsService.queryCourseCount(c.getString("id"), c.getString("courseType"))); - c.put("hasWaitingNum", trainSignUpActivityStatisticsService.queryCourseWaitCount(c.getString("id"), c.getString("courseType"))); - }); - return Result.success(courseList); + return Result.success(trainSignUpActivityStatisticsService.getCourse(activityId)); } + /** + * @param courseId 有管理权限的子活动ID + * @param unionId 工会ID,可为空 + * @param unitId 单位ID,可为空 + * @param searchName 保留现有接口参数,姓名和工号搜索统一使用 searchKeyword + * @param searchKeyword 姓名或工号关键字,可为空 + * @return Result.data 为报名人员数组,userId 为报名人员ID,state 为报名状态 + */ @At @ApiOperation("报名用户列表") @SaCheckPermission("trainSignUp.adjust") - public Result registerUserList(@Param("courseId") String courseId, - @Param(value = "unionId") String unionId, - @Param(value = "unitId") String unitId, - @Param(value = "searchName") String searchName, - @Param(value = "searchKeyword") String searchKeyword) { - List list = trainSignUpActivityStatisticsService.registerUserList(courseId, unionId, unitId, searchName, searchKeyword); - return Result.success(list); + public Result registerUserList(@Param("courseId") String courseId, @Param("unionId") String unionId, + @Param("unitId") String unitId, @Param("searchName") String searchName, + @Param("searchKeyword") String searchKeyword) { + return Result.success(trainSignUpActivityStatisticsService.registerUserList(courseId, unionId, unitId, searchName, searchKeyword)); } + /** + * @param activityId 主活动ID,来源和目标子活动都必须属于该活动 + * @param oldCourseId 来源子活动ID,必须有管理权限 + * @param newCourseId 目标子活动ID,必须有管理权限 + * @param userId 被调整的报名人员ID,操作人身份由登录会话确定 + * @return Result,code=0 表示报名及签到记录已在同一事务中调整 + */ @At @ApiOperation("人员调整") @SaCheckPermission("trainSignUp.adjust") - @SLog(tag = "品牌活动-人员调整", msg = "人员调整") + @SLog(tag = "品牌活动-人员调整", msg = "人员调整") + @Aop(TransAop.READ_COMMITTED) public Result adjust(String activityId, String oldCourseId, String newCourseId, String userId) { - - Cnd oldCnd = Cnd.where("activityId", "=", activityId) - .and("courseId", "=", oldCourseId).and("userId", "=", userId); - - Cnd newCnd = Cnd.where("activityId", "=", activityId) - .and("courseId", "=", newCourseId).and("userId", "=", userId); - - //旧的报名信息 - TrainSignUpUser oldTrainSignUpUser = dao.fetch(TrainSignUpUser.class, oldCnd); - oldTrainSignUpUser.setCourseId(newCourseId); - oldTrainSignUpUser.setSignUpTime(DateUtil.date()); - dao.update(oldTrainSignUpUser); - - //新的课程的信息,上课时间 - List activityCourseList = dao.query(TrainSignUpActivityCourse.class, Cnd.where("activityId", "=", activityId) - .and("courseId", "=", newCourseId)); - //先清楚旧的信息 - dao.clear(TrainSignUpUserCourse.class, oldCnd); - //添加新的信息 - List trainSignUpUserCourseList = new ArrayList<>(); - activityCourseList.forEach(item -> { - TrainSignUpUserCourse course = new TrainSignUpUserCourse(); - course.setActivityCourseId(activityId); - course.setCourseId(newCourseId); - course.setUserId(userId); - course.setCourseStartTime(item.getCourseStartTime()); - course.setCourseEndTime(item.getCourseEndTime()); - course.setActivityCourseId(item.getId()); - trainSignUpUserCourseList.add(course); - }); - dao.insert(trainSignUpUserCourseList); + trainSignUpActivityStatisticsService.adjust(activityId, oldCourseId, newCourseId, userId); return Result.success(); } + /** + * @param activityId 主活动ID + * @param courseId 属于该主活动且有管理权限的子活动ID + * @param userId 被删除的报名人员ID + * @return Result,code=0 表示该子活动的报名及签到记录已删除 + */ @At @ApiOperation("删除报名人员") @SaCheckPermission("trainSignUp.adjust") - @SLog(tag = "品牌活动-人员调整", msg = "删除报名人员") + @SLog(tag = "品牌活动-人员调整", msg = "删除报名人员") + @Aop(TransAop.READ_COMMITTED) public Result deleteSignUser(String activityId, String courseId, String userId) { - - //删除 - Cnd cnd = Cnd.NEW(); - cnd.and("activityId", "=", activityId); - cnd.and("courseId", "=", courseId); - cnd.and("userId", "=", userId); - - dao.clear(TrainSignUpUser.class, cnd); - dao.clear(TrainSignUpUserCourse.class, cnd); + trainSignUpActivityStatisticsService.deleteSignUser(activityId, courseId, userId); return Result.success(); } } diff --git a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpApplyController.java b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpApplyController.java index 7aca142..83ae242 100644 --- a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpApplyController.java +++ b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpApplyController.java @@ -128,6 +128,7 @@ public class TrainSignUpApplyController { @Param(value = "assortTypes") String[] assortTypes, @Param(value = "searchKeyword") String searchKeyword, @Param(value = "dataType") String dataType) { + // 返回课程签到半径 radius(米),供 H5 地图按实际配置绘制签到范围。 Sql sql = Sqls.create(""" SELECT tsuc.id, @@ -144,6 +145,7 @@ public class TrainSignUpApplyController { tsuc.unionLimit, tsuc.isMobileSign, tsuc.signType, + tsuc.radius, tsuc.isReceiveGift, tsuc.giftType, tsuc.reserveMode, diff --git a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpManageController.java b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpManageController.java index 61862dd..e69a6cd 100644 --- a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpManageController.java +++ b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpManageController.java @@ -10,8 +10,6 @@ import com.budwk.app.base.param.PageForm; import com.budwk.app.base.result.Result; import com.budwk.app.flow.entity.ProcessInstance; import com.budwk.app.flow.enums.ProcessInstanceStateEnum; -import com.budwk.app.sys.models.Sys_home_activity; -import com.budwk.app.sys.models.Sys_home_template; import com.budwk.app.sys.models.Sys_union; import com.budwk.app.sys.models.Sys_unit; import com.budwk.app.web.commons.auth.utils.AuthUtil; @@ -22,18 +20,18 @@ import com.budwk.app.zhgh.club.model.SysClub; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.extern.slf4j.Slf4j; -import org.nutz.dao.Chain; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; import org.nutz.dao.Sqls; import org.nutz.dao.sql.Sql; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.lang.util.NutMap; import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Param; -import org.nutz.trans.Trans; import javax.validation.constraints.NotNull; import java.util.ArrayList; @@ -66,6 +64,37 @@ public class TrainSignUpManageController { public void index() { } + /** activityId 为主活动ID,courseName 为可选名称;返回 Result.data 分页对象 list、totalCount。 */ + @At + @SaCheckPermission("trainSignUp.manage") + public Result courseManagePageData(PageForm pageForm, @Param("activityId") String activityId, + @Param("courseName") String courseName) { + return Result.success(trainSignUpActivityManageService.courseManagePageData(pageForm, activityId, courseName)); + } + + /** activityId、courseId 为所属主活动和子活动ID;返回 Result.data 的 course、activityTime、canEditBasic、courseTypes。 */ + @At + @SaCheckPermission("trainSignUp.manage") + public Result courseManageDetail(@Param("activityId") String activityId, @Param("courseId") String courseId) { + return Result.success(trainSignUpActivityManageService.courseManageDetail(activityId, courseId)); + } + + /** + * course 接收单个子活动及签到字段,负责人、时段列表按 JSON 传入;updateCourseTimes 仅实际修改时段时为 true。 + * id、activityId 为子活动和主活动ID;isMobileSign 为签到开关,signType 为1扫码、2被扫、3定位。 + * signUnit 为非负整数分钟,radius 为正整数米;courseLocationCoordinates、transPosition 分别为地图、WGS-84坐标数组[纬度,经度]。 + * 管理员可另传名称、类型、人数、预留名额、地点、校区、分类标识;responsibleList 每项包含 userId、mobile。 + * service 按登录角色决定允许保存的字段,返回 Result,code=0 表示保存成功,msg 为操作结果。 + */ + @At + @SaCheckPermission("trainSignUp.manage") + @SLog(tag = "品牌活动-子活动管理", msg = "保存子活动配置") + @Aop(TransAop.READ_COMMITTED) + public Result saveManagedCourse(TrainSignUpCourse course, @Param("updateCourseTimes") boolean updateCourseTimes) { + trainSignUpActivityManageService.saveManagedCourse(course, updateCourseTimes); + return Result.success(); + } + @At @ApiOperation("分页查询") @SaCheckPermission("trainSignUp.manage") @@ -110,31 +139,22 @@ public class TrainSignUpManageController { @ApiOperation("活动删除") @SaCheckPermission("trainSignUp.manage") @SLog(tag = "品牌活动-活动管理", msg = "删除活动") + @Aop(TransAop.READ_COMMITTED) public Result onDelete(String id) { - if (dao.fetch(Sys_home_template.class, id) != null) { - return Result.error("该活动已设为模板,请先取消模板后再删除"); + // id 为主活动ID;service 在同一事务中清理子活动负责人及报名关联。 + if (StrUtil.isBlank(id)) { + return Result.error("活动ID不能为空"); } - Trans.exec(() -> { - trainSignUpActivityManageService.delete(id); - dao.clear(TrainSignUpCourse.class, Cnd.where("activityId", "=", id)); - dao.clear(TrainSignUpActivityCourse.class, Cnd.where("activityId", "=", id)); - dao.clear(TrainSignUpUser.class, Cnd.where("activityId", "=", id)); - dao.clear(TrainSignUpUserCourse.class, Cnd.where("activityId", "=", id)); - dao.clear(TrainSignUpActivity.class, Cnd.where("id", "=", id)); - dao.clear(TrainSignUpTypeLimit.class, Cnd.where("activityId", "=", id)); - dao.delete(Sys_home_activity.class, id); - }); + trainSignUpActivityManageService.deleteActivity(id); return Result.success(); } @At @ApiOperation("活动状态变更") @SaCheckPermission("trainSignUp.manage") + @Aop(TransAop.READ_COMMITTED) public Result activityStatusChange(TrainSignUpActivity activity) { trainSignUpActivityManageService.updateActivityStatus(activity); - dao.update(Sys_home_activity.class, - Chain.make("enable", !activity.isDisabled()), - Cnd.where("id", "=", activity.getId())); return Result.success(); } @@ -185,11 +205,17 @@ public class TrainSignUpManageController { return Result.success().addData(dataMap); } + /** + * 接收主活动及 courseList 子活动列表;每个 responsibleList 元素传 userId、mobile。 + * responsibleList 为 null 保留历史联系人,空列表清空,多人关系由 service 校验并保存。 + * 返回 Result,code=0 表示主活动、子活动和负责人已在同一事务中保存成功。 + */ @At @Ok("json:full") @ApiOperation("品牌活动新增/修改") @SaCheckPermission(value = {"trainSignUp.manage", "trainSignUp.applyActivity"}, mode = SaMode.OR) @SLog(tag = "品牌活动-活动管理", msg = "新增/修改活动") + @Aop(TransAop.READ_COMMITTED) public Result doHandle(TrainSignUpActivity activity) { if (StrUtil.isBlank(activity.getId())) { cleanNewActivityData(activity); diff --git a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/statistics/TrainSignUpActivityStatisticsController.java b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/statistics/TrainSignUpActivityStatisticsController.java index 334b886..97464ef 100644 --- a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/statistics/TrainSignUpActivityStatisticsController.java +++ b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/statistics/TrainSignUpActivityStatisticsController.java @@ -1,55 +1,26 @@ package com.budwk.app.zhgh.activity.trainSignUp.controller.statistics; -import cn.afterturn.easypoi.excel.entity.ExportParams; -import cn.afterturn.easypoi.excel.entity.enmus.ExcelType; -import cn.afterturn.easypoi.excel.entity.params.ExcelExportEntity; -import cn.afterturn.easypoi.excel.export.ExcelExportService; import cn.dev33.satoken.annotation.SaCheckPermission; -import cn.hutool.core.util.StrUtil; -import cn.hutool.json.JSONObject; -import com.budwk.app.base.constant.RoleConstant; import com.budwk.app.base.page.Pagination; import com.budwk.app.base.param.PageForm; import com.budwk.app.base.result.Result; import com.budwk.app.base.utils.CommonDownloadUtil; -import com.budwk.app.sys.models.Sys_file; -import com.budwk.app.sys.utils.SysFileMinIoUtil; -import com.budwk.app.web.commons.auth.utils.AuthUtil; -import com.budwk.app.web.commons.auth.utils.SecurityUtil; -import com.budwk.app.zhgh.activity.trainSignUp.models.*; -import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityService; import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityStatisticsService; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.extern.slf4j.Slf4j; -import org.apache.poi.hssf.usermodel.HSSFWorkbook; import org.apache.poi.ss.usermodel.Workbook; -import org.apache.poi.xssf.usermodel.XSSFWorkbook; -import org.nutz.dao.Chain; -import org.nutz.dao.Cnd; -import org.nutz.dao.Dao; -import org.nutz.dao.Sqls; -import org.nutz.dao.sql.Sql; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; -import org.nutz.json.Json; -import org.nutz.lang.Lang; import org.nutz.lang.util.NutMap; import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Param; import javax.servlet.http.HttpServletResponse; -import java.io.BufferedOutputStream; -import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.util.*; -import java.util.stream.Collectors; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; - /** * @author zxy @@ -63,12 +34,8 @@ import java.util.zip.ZipOutputStream; @At("/platform/trainSignUp/statistics") public class TrainSignUpActivityStatisticsController { - @Inject - private TrainSignUpActivityService trainSignUpActivityManageService; @Inject private TrainSignUpActivityStatisticsService trainSignUpActivityStatisticsService; - @Inject - private Dao dao; @At("") @SaCheckPermission("trainSignUp.statistics") @@ -76,231 +43,113 @@ public class TrainSignUpActivityStatisticsController { public void index() { } + /** + * @param pageForm 分页参数,pageNumber 为页码,pageSize 为每页条数 + * @param activityId 主活动ID,为空时返回空分页 + * @return Result.data 为 Pagination,list 和 totalCount 均受当前登录人的负责人范围限制 + */ @At @ApiOperation("分页查询") @SaCheckPermission("trainSignUp.statistics") - public Result pageData(PageForm pageForm, - @Param(value = "activityId") String activityId) { + public Result pageData(PageForm pageForm, @Param("activityId") String activityId) { Pagination pagination = trainSignUpActivityStatisticsService.pageData(pageForm, activityId); return Result.success(pagination); } /** - * 活动list - * - * @param year 年度 - * @return + * @param year 查询年度,为空时不限制年度 + * @return Result.data 为可管理的主活动列表,id/activityName 用于活动下拉框 */ @At @ApiOperation("活动列表") @SaCheckPermission("trainSignUp.statistics") - public Result activityList(@Param(value = "year") Integer year) { - List list = dao.query(TrainSignUpActivity.class, Cnd.NEW().andEX("year", "=", year).desc("activityStartTime")); - if(!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_CHAIRMAN.name(), RoleConstant.SCHOOL_UNION_ACTIVITY_ADMIN.name())) { - // 获取当前登录用户的工会 - String unionId = SecurityUtil.getUnionId(); - // 获取当前登录用户的社团 - List clubIds = SecurityUtil.getClubIds(); - - List result = new ArrayList<>(); - for (TrainSignUpActivity activity : list) { - if(AuthUtil.hasRole(RoleConstant.BRANCH_UNION_CHAIRMAN.name())) { - if(activity.getHostUnits().contains(unionId) || activity.getHelpUnits().contains(unionId)) { - result.add(activity); - } - } - if(AuthUtil.hasRoleOr(RoleConstant.CLUB_PRESIDENT.name(), RoleConstant.CLUB_OPERATOR.name())) { - if(clubIds.stream().anyMatch(activity.getHostUnits()::contains) || clubIds.stream().anyMatch(activity.getHelpUnits()::contains)) { - result.add(activity); - } - } - } - return Result.success(result); - } - return Result.success(list); + public Result activityList(@Param("year") Integer year) { + return Result.success(trainSignUpActivityStatisticsService.activityList(year, false)); } /** - * 培训班报名人员list - * - * @param courseId - * @return + * @param courseId 子活动ID,必须在当前登录人的管理范围内 + * @return Result.data 为报名人员数组,包含姓名、工号、单位、联系方式、报名状态及动态报名字段 */ @At @ApiOperation("报名人员列表") @SaCheckPermission("trainSignUp.statistics") - public Result registerUserList(@Param(value = "courseId") String courseId) { + public Result registerUserList(@Param("courseId") String courseId) { return Result.success(trainSignUpActivityStatisticsService.registerUserList(courseId)); } + /** + * @param courseId 子活动ID,必须在当前登录人的管理范围内 + * @return Result.data 为动态列配置数组,label 为列标题,prop 为报名数据字段名 + */ @At @ApiOperation("报名动态列") @SaCheckPermission("trainSignUp.statistics") - public Object getTaleColumnInfo(@Param(value = "courseId") String courseId) { + public Object getTaleColumnInfo(@Param("courseId") String courseId) { return Result.success(trainSignUpActivityStatisticsService.getTaleColumnInfo(courseId)); } - @At - @ApiOperation("获取时间信息") - @SaCheckPermission("trainSignUp.statistics") - public Result selectTimes(@Param("courseId") String courseId) { - List activityCourses = dao.query(TrainSignUpActivityCourse.class, Cnd.where("courseId", "=", courseId).asc("courseStartTime")); - return Result.success(activityCourses); - } + /** + * @param courseId 子活动ID,必须在当前登录人的管理范围内 + * @return Result.data 为时段数组,id 为时段ID,courseStartTime/courseEndTime 为起止时间 + */ + @At + @ApiOperation("获取时间信息") + @SaCheckPermission("trainSignUp.statistics") + public Result selectTimes(@Param("courseId") String courseId) { + return Result.success(trainSignUpActivityStatisticsService.selectTimes(courseId)); + } /** - * 培训班上课签到信息 - * - * @param courseId - * @return + * @param pageForm 分页参数,pageNumber 为页码,pageSize 为每页条数 + * @param courseId 有管理权限的子活动ID + * @param timeId 子活动时段ID + * @return Result.data 为签到分页,list 中 isAttend/attendTime 为签到状态及时间 */ @At @ApiOperation("获取签到信息") @SaCheckPermission("trainSignUp.statistics") - public Result getSignInfo(PageForm pageForm, - @Param("courseId") String courseId, - String timeId) { + public Result getSignInfo(PageForm pageForm, @Param("courseId") String courseId, String timeId) { return Result.success(trainSignUpActivityStatisticsService.getSignInfo(pageForm, courseId, timeId)); } + /** + * @param courseId 有管理权限的子活动ID + * @param openOtherUnion true 开放给其他工会报名,false 关闭 + * @return Result,code=0 表示状态已更新 + */ @At @ApiOperation("开放报名") @SaCheckPermission("trainSignUp.statistics") + @Aop(TransAop.READ_COMMITTED) public Result signChange(@Param("courseId") String courseId, @Param("openOtherUnion") Boolean openOtherUnion) { - dao.update(TrainSignUpCourse.class, Chain.make("openOtherUnion", openOtherUnion) - , Cnd.where("id", "=", courseId)); + trainSignUpActivityStatisticsService.signChange(courseId, openOtherUnion); return Result.success(); } - @At - @ApiOperation("设置签到") - @SaCheckPermission("trainSignUp.statistics") - public Result adjustSign(@Param("id") String id) { - TrainSignUpUserCourse userCourse = dao.fetch(TrainSignUpUserCourse.class, id); - if(userCourse.isAttend()) { - userCourse.setAttend(false); - userCourse.setAttendTime(null); - } else { - userCourse.setAttend(true); - userCourse.setAttendTime(userCourse.getCourseStartTime()); - } - dao.update(userCourse); - return Result.success(); - } + /** + * @param id 签到记录ID,service 根据记录所属子活动校验当前登录人的权限 + * @return Result,code=0 表示签到状态已切换 + */ + @At + @ApiOperation("设置签到") + @SaCheckPermission("trainSignUp.statistics") + @Aop(TransAop.READ_COMMITTED) + public Result adjustSign(@Param("id") String id) { + trainSignUpActivityStatisticsService.adjustSign(id); + return Result.success(); + } + /** + * @param activityId 主活动ID,仅导出当前登录人有管理权限的子活动报名记录 + * @param response 文件下载响应,输出 .xlsx 文件而非 JSON + */ @At @Ok("void") @ApiOperation("导出签到名单") @SaCheckPermission("trainSignUp.statistics") - public void exportSignUser(@Param(value = "activityId") String activityId, - HttpServletResponse response) throws IOException { - try { - TrainSignUpActivity activity = dao.fetch(TrainSignUpActivity.class, activityId); - Sql sql = Sqls.create(""" - SELECT - ts.*, - CONCAT(DATE_FORMAT(ac.courseStartTime, '%H:%i:%s'),'至',DATE_FORMAT(ac.courseEndTime, '%H:%i:%s')) AS courseTime, - u.username, - u.loginname, - u.sex, - ifnull(ts.mobile, u.mobile) as mobile, - u.birthday, - tsc.courseName - FROM - train_sign_up_user ts - left join train_sign_up_activity_course ac on ts.activityCourseId = ac.id - left join `vw_user` u on u.id = ts. userId - left join train_sign_up_course tsc on tsc.id = ts.courseId - WHERE - ts.activityId = @activityId - """).setParam("activityId", activityId); - List userList = trainSignUpActivityManageService.listMap(sql); - - List courseList = dao.query(TrainSignUpCourse.class, Cnd.where("activityId", "=", activityId)); - - List trainSignUpTypeList = dao.query(TrainSignUpType.class, Cnd.NEW()); - dao.fetchLinks(trainSignUpTypeList, "trainMobileSignColumnList", Cnd.NEW().asc("columnIndex")); - Map typeMap = trainSignUpTypeList.stream().collect(Collectors.toMap(TrainSignUpType::getId, o -> o)); - - List> sheetsList = new ArrayList<>(); - - List excelCommonExportEntity = new ArrayList<>(); - excelCommonExportEntity.add(new ExcelExportEntity("姓名", "username", 20)); - excelCommonExportEntity.add(new ExcelExportEntity("工号", "loginname", 20)); - excelCommonExportEntity.add(new ExcelExportEntity("单位", "unitName", 20)); - excelCommonExportEntity.add(new ExcelExportEntity("分工会", "unionName", 20)); - excelCommonExportEntity.add(new ExcelExportEntity("性别", "sex", 20)); - excelCommonExportEntity.add(new ExcelExportEntity("手机号", "mobile", 20)); - excelCommonExportEntity.add(new ExcelExportEntity("生日", "birthday", 20)); - excelCommonExportEntity.add(new ExcelExportEntity("报名时段", "courseTime", 20)); - - for (TrainSignUpCourse c : courseList) { - String k = c.getCourseName(); - List v = userList.stream().filter(x -> StrUtil.isNotBlank(x.getString("courseName")) && x.getString("courseName").equals(k)).collect(Collectors.toList()); - ExportParams userExportParams = new ExportParams(); - userExportParams.setSheetName(k); - userExportParams.setType(ExcelType.HSSF); - - List currentEntities = new ArrayList<>(); - currentEntities.addAll(excelCommonExportEntity); - - TrainSignUpType signUpType = typeMap.get(c.getCourseType()); - if (Lang.isNotEmpty(signUpType.getTrainMobileSignColumnList())) { - for (TrainMobileSignColumn column : signUpType.getTrainMobileSignColumnList()) { - ExcelExportEntity entity = new ExcelExportEntity(); - entity.setName(column.getColumnName()); - entity.setKey(column.getColumnCode()); - entity.setWidth(20); - if ("FILE".equals(column.getColumnFormType())) { - entity.setType(2); - entity.setExportImageType(2); - } - currentEntities.add(entity); - } - } - for (NutMap userSignData : v) { - String mobileColumnsValueStr = userSignData.getString("mobileColumnsValue"); - if (StrUtil.isNotBlank(mobileColumnsValueStr)) { - List mobileColumnsValue = Json.fromJsonAsList(NutMap.class, mobileColumnsValueStr); - for (NutMap cv : mobileColumnsValue) { - if (!"FILE".equals(cv.getString("columnFormType"))) { - userSignData.put(cv.getString("columnCode"), cv.getString("columnValue")); - } else { - if (StrUtil.isNotBlank(cv.getString("columnValue"))) { - List columnValue = cv.getAsList("columnValue", NutMap.class); - if (columnValue.size() == 1) { - NutMap sysFile = columnValue.get(0); - Sys_file file = dao.fetch(Sys_file.class, Cnd.where(Sys_file::getDownloadPath, "=", sysFile.get("url"))); - byte[] imageBytes = SysFileMinIoUtil.getFileBytes(file.getBucket(), file.getStoragePath()); - if (imageBytes.length > 0) { - userSignData.put(cv.getString("columnCode"), imageBytes); - } - } - } - } - } - - } - } - - Map userExportMap = new HashMap<>(); - userExportMap.put("name", k); - userExportParams.setType(ExcelType.HSSF); - userExportMap.put("title", userExportParams); - userExportMap.put("entity", currentEntities); - userExportMap.put("data", v); - - sheetsList.add(userExportMap); - } - - Workbook workbook = new HSSFWorkbook(); - for (Map map : sheetsList) { - ExcelExportService service = new ExcelExportService(); - service.createSheetForMap(workbook, (ExportParams) map.get("title"), (List) map.get("entity"), (Collection) map.get("data")); - } - CommonDownloadUtil.download(activity.getActivityName() + "报名人员名单" + ".xls", workbook, response); - } catch (Exception e) { - e.printStackTrace(); - } + public void exportSignUser(@Param("activityId") String activityId, HttpServletResponse response) throws IOException { + NutMap exportData = trainSignUpActivityStatisticsService.buildSignUserExport(activityId); + CommonDownloadUtil.download(exportData.getString("fileName"), exportData.getAs("workbook", Workbook.class), response); } } diff --git a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/models/TrainSignUpCourse.java b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/models/TrainSignUpCourse.java index b4258c3..697061a 100644 --- a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/models/TrainSignUpCourse.java +++ b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/models/TrainSignUpCourse.java @@ -96,6 +96,13 @@ public class TrainSignUpCourse extends BaseModel implements Serializable { @Many(field = "courseId") private List courseTimeList; + /** + * 完整负责人关系。null 表示旧数据尚未设置,空列表表示主动清空负责人。 + * 关系单独保存,后续可按 userId 精确查询,无需解析姓名或 JSON。 + */ + @Many(field = "courseId") + private List responsibleList; + //已报人数 private Integer hasRegisterNum; diff --git a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/models/TrainSignUpCourseResponsible.java b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/models/TrainSignUpCourseResponsible.java new file mode 100644 index 0000000..660c712 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/models/TrainSignUpCourseResponsible.java @@ -0,0 +1,60 @@ +package com.budwk.app.zhgh.activity.trainSignUp.models; + +import lombok.Data; +import lombok.experimental.Accessors; +import org.nutz.dao.entity.annotation.*; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +import java.io.Serializable; + +/** + * 子活动负责人关系:一人一条记录,业务控制应以 userId 为准,姓名仅用于展示。 + */ +@Data +@Accessors(chain = true) +@Table("train_sign_up_course_responsible") +@Comment("品牌活动子活动负责人") +@TableIndexes({ + @Index(name = "TSU_RESP_COURSE_USER", fields = {"courseId", "userId"}, unique = true), + @Index(name = "TSU_RESP_USER_ACTIVITY", fields = {"userId", "activityId"}, unique = false), + @Index(name = "TSU_RESP_ACTIVITY", fields = {"activityId"}, unique = false) +}) +public class TrainSignUpCourseResponsible implements Serializable { + + @Name + @PrevInsert(uu32 = true) + private String id; + + @Column + @ColDefine(type = ColType.VARCHAR, width = 32) + @Comment("主活动ID,由后端根据所属子活动设置") + private String activityId; + + @Column + @ColDefine(type = ColType.VARCHAR, width = 32) + @Comment("子活动ID,由后端在子活动保存后设置") + private String courseId; + + @Column + @ColDefine(type = ColType.VARCHAR, width = 32) + @Comment("系统用户ID,用于负责人查询和业务控制") + private String userId; + + @Column + @ColDefine(type = ColType.VARCHAR, width = 100) + @Comment("负责人姓名,由后端读取系统用户信息") + private String username; + + @Column + @ColDefine(type = ColType.VARCHAR, width = 20) + @Comment("子活动联系号码,可填写手机号或办公电话") + private String mobile; + + @Column + @ColDefine(type = ColType.INT) + @Comment("负责人顺序,第一位兼容原有联系人展示") + private int orderNum; + + /** 工号仅用于多选回显,不重复存储系统用户资料。 */ + private String loginname; +} diff --git a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/TrainSignUpActivityService.java b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/TrainSignUpActivityService.java index 6a0bdbb..5aa7446 100644 --- a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/TrainSignUpActivityService.java +++ b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/TrainSignUpActivityService.java @@ -20,6 +20,35 @@ import java.util.List; */ public interface TrainSignUpActivityService extends BaseService { + /** 当前登录人是否具有活动关键内容编辑权限(三类管理员任意一种)。 */ + boolean canEditActivityBasic(); + + /** + * 查询当前主活动中可管理的子活动,普通用户仅返回本人负责的记录。 + * @param pageForm 页码、每页条数;排序固定使用子活动序号 + * @param activityId 主活动ID,必填 + * @param courseName 子活动名称,可为空,按包含匹配 + * @return 分页对象,list 包含子活动字段、courseTypeName、responsibleNames、courseTimeName + */ + Pagination courseManagePageData(PageForm pageForm, String activityId, String courseName); + + /** + * 读取单个子活动及表单所需选项,同时校验当前登录人的负责人范围。 + * @param activityId 主活动ID,必须与子活动实际归属一致 + * @param courseId 子活动ID,必填 + * @return course 为子活动及负责人、时段;activityTime 为主活动日期范围; + * canEditBasic 为关键内容编辑权限,courseTypes 为管理员使用的类型选项 + */ + NutMap courseManageDetail(String activityId, String courseId); + + /** + * 保存单个子活动,普通负责人仅更新签到字段,管理员可更新基本信息及负责人。 + * @param course id、activityId 必填;签到参数为 isMobileSign、signType、signUnit、radius、 + * courseLocationCoordinates、transPosition,坐标均按纬度、经度顺序传入 + * @param updateCourseTimes 仅管理员实际修改时段后传 true;false 不改动任何时段及签到记录 + */ + void saveManagedCourse(TrainSignUpCourse course, boolean updateCourseTimes); + /** * 添加活动 * @@ -35,6 +64,13 @@ public interface TrainSignUpActivityService extends BaseService { /** - * 统计分页 + * 查询当前用户可管理的主活动,只包含至少一个本人负责子活动的主活动。 + * 系统管理员、校工会管理员、校工会活动管理员不受负责人范围限制,仍保留年度及启用状态条件。 * - * @param pageForm - * @param activityId - * @return + * @param year 年度,为空时不限制年度 + * @param enabledOnly true 仅查询已启用活动,人员调整页面使用;false 保留统计页面原范围 + * @return 主活动实体列表;没有负责记录时返回空列表 + */ + List activityList(Integer year, boolean enabledOnly); + + /** + * 查询人员调整的目标子活动,限制为当前用户负责的子活动。 + * + * @param activityId 主活动ID,为空时返回空列表 + * @return 子活动列表,id/courseName 为标识及名称,registerNum/hasWaitingNum 为已报及候补人数 + */ + List getCourse(String activityId); + + /** + * 查询有管理权限的子活动时段。 + * + * @param courseId 子活动ID,必须存在且在当前用户的管理范围内 + * @return 时段实体列表,id 为时段ID,courseStartTime/courseEndTime 为起止时间 + */ + List selectTimes(String courseId); + + /** + * 修改子活动对外报名状态,只允许操作本人负责的子活动或由全量管理员操作。 + * + * @param courseId 子活动ID + * @param openOtherUnion true 开放给其他工会报名,false 关闭,不能传 null + */ + void signChange(String courseId, Boolean openOtherUnion); + + /** + * 切换签到状态,依据签到记录实际所属子活动校验负责人权限。 + * + * @param id TrainSignUpUserCourse 签到记录ID + */ + void adjustSign(String id); + + /** + * 迁移报名记录及签到时段,来源、目标子活动均需属于指定主活动且在当前用户管理范围内。 + * + * @param activityId 主活动ID + * @param oldCourseId 原子活动ID + * @param newCourseId 目标子活动ID + * @param userId 被调整的报名人员用户ID,不能用来指定操作人的身份 + */ + void adjust(String activityId, String oldCourseId, String newCourseId, String userId); + + /** + * 删除指定子活动的报名及签到记录,先验证实际子活动归属和当前登录人的负责人权限。 + * + * @param activityId 主活动ID + * @param courseId 子活动ID + * @param userId 被删除的报名人员用户ID + */ + void deleteSignUser(String activityId, String courseId, String userId); + + /** + * 构建当前用户管理范围内的报名人员 Excel,不能导出同一主活动下其他人的子活动。 + * + * @param activityId 主活动ID + * @return 内部 NutMap,fileName 为 .xlsx 文件名,workbook 为 XSSF Workbook,由 controller 输出并关闭 + */ + NutMap buildSignUserExport(String activityId); + + /** + * 按当前登录人的负责人范围查询子活动分页,系统管理员、校工会管理员、校工会活动管理员可查询全部。 + * + * @param pageForm 分页参数,pageNumber 为页码,pageSize 为每页条数 + * @param activityId 主活动ID,为空时返回空分页 + * @return Pagination,list 为子活动数据,totalCount 为相同负责人范围下的记录总数 */ Pagination pageData(PageForm pageForm, String activityId); /** - * 该课程下的报名人员信息 + * 查询有管理权限的子活动报名明细,并展开动态报名字段。 * - * @param courseId - * @return + * @param courseId 子活动ID,不能为空,必须在当前登录人的管理范围内 + * @return 报名人员数组,包含姓名、工号、单位、联系方式、state 报名状态及动态字段 */ List registerUserList(String courseId); + /** + * @param courseId 有管理权限的子活动ID + * @return 动态列配置数组,label 为列标题,prop 为报名明细对应字段名 + */ List getTaleColumnInfo(String courseId); /** - * 该课程下的报名人员信息 + * 按工会、单位和姓名/工号搜索有管理权限的子活动报名人员。 * - * @param courseId - * @return + * @param courseId 有管理权限的子活动ID,不能为空 + * @param unionId 工会ID,可为空 + * @param unitId 单位ID,可为空 + * @param searchName 保留现有接口参数,姓名和工号统一使用 searchKeyword 搜索 + * @param searchKeyword 姓名或工号关键字,可为空 + * @return 报名人员数组,userId 为报名人员用户ID,state 为报名状态 */ List registerUserList(String courseId, String unionId, String unitId, String searchName, String searchKeyword); /** - * 获取每个课程的签到情况 + * 查询有管理权限的子活动指定时段签到分页。 * - * @param courseId - * @return k->每个培训班每节课的上课时间 v->上课记录list + * @param pageForm 分页参数,pageNumber 为页码,pageSize 为每页条数 + * @param courseId 有管理权限的子活动ID + * @param timeId 子活动时段ID + * @return Pagination,list 为签到人员,isAttend/attendTime 为签到状态及时间 */ Pagination getSignInfo(PageForm pageForm, String courseId, String timeId); /** - * 报名人员list 导出 - * @param activityId - * @return + * 查询主活动下可管理子活动的基础报名导出数据。 + * @param activityId 主活动ID + * @return 报名人员数组,包含姓名、工号、联系方式及子活动名称,不包含管理范围以外的子活动 */ List baoMingUserList(String activityId); diff --git a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/impl/TrainSignUpActivityServiceImpl.java b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/impl/TrainSignUpActivityServiceImpl.java index e3c1955..6424433 100644 --- a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/impl/TrainSignUpActivityServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/impl/TrainSignUpActivityServiceImpl.java @@ -1,13 +1,18 @@ package com.budwk.app.zhgh.activity.trainSignUp.service.impl; import cn.hutool.core.util.StrUtil; +import cn.hutool.core.date.DateUtil; +import com.budwk.app.base.constant.RoleConstant; +import com.budwk.app.base.exception.BaseException; import com.budwk.app.base.page.Pagination; import com.budwk.app.base.param.PageForm; import com.budwk.app.base.service.impl.BaseServiceImpl; import com.budwk.app.sys.models.Sys_home_activity; import com.budwk.app.sys.models.Sys_home_template; +import com.budwk.app.sys.models.Sys_user; import com.budwk.app.sys.views.View_user; import com.budwk.app.web.commons.auth.utils.SecurityUtil; +import com.budwk.app.web.commons.auth.utils.AuthUtil; import com.budwk.app.zhgh.activity.family.models.FamilyActivity; import com.budwk.app.zhgh.activity.family.models.FamilyCourse; import com.budwk.app.zhgh.activity.family.models.FamilyType; @@ -51,9 +56,227 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl relations = StrUtil.isBlank(userId) ? Collections.emptyList() + : dao().query(TrainSignUpCourseResponsible.class, + Cnd.where("activityId", "=", activityId).and("userId", "=", userId)); + if (relations.isEmpty()) { + return new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), 0, new ArrayList<>()); + } + // 范围条件在分页前进入数据库查询,记录数和分页总数使用同一负责人范围。 + cnd.and("course.id", "in", relations.stream().map(TrainSignUpCourseResponsible::getCourseId).toList()); + } + cnd.asc("course.orderNum").asc("course.id"); + Sql sql = Sqls.create(""" + SELECT course.*, type.typeName AS courseTypeName, + (SELECT GROUP_CONCAT(r.username ORDER BY r.orderNum SEPARATOR '、') + FROM train_sign_up_course_responsible r + WHERE r.courseId=course.id AND r.activityId=course.activityId) AS responsibleNames, + (SELECT GROUP_CONCAT(CONCAT(DATE_FORMAT(t.courseStartTime, '%Y-%m-%d %H:%i'), + ' 至 ', DATE_FORMAT(t.courseEndTime, '%H:%i')) ORDER BY t.courseStartTime SEPARATOR ';') + FROM train_sign_up_activity_course t WHERE t.courseId=course.id) AS courseTimeName + FROM train_sign_up_course course + LEFT JOIN train_sign_up_type type ON type.id=course.courseType + $condition + """); + sql.setCondition(cnd); + return listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql); + } + + @Override + public NutMap courseManageDetail(String activityId, String courseId) { + TrainSignUpCourse course = requireManagedCourse(activityId, courseId); + fillCourseResponsibles(List.of(course)); + dao().fetchLinks(course, "courseTimeList", Cnd.NEW().asc("courseStartTime")); + TrainSignUpActivity activity = fetch(activityId); + NutMap data = Lang.obj2nutmap(course); + // 时间编辑组件使用日期和 HH:mm 两部分;签到记录仍由 service 按完整日期时间保存。 + List times = course.getCourseTimeList().stream().map(time -> Lang.obj2nutmap(time) + .setv("courseDate", DateUtil.formatDate(time.getCourseDate())) + .setv("courseStartTime", DateUtil.format(time.getCourseStartTime(), "HH:mm")) + .setv("courseEndTime", DateUtil.format(time.getCourseEndTime(), "HH:mm"))).toList(); + data.setv("courseTimeList", times).setv("setUpCourseData", + times.stream().map(time -> time.getString("courseDate")).distinct().toList()); + TrainSignUpType type = StrUtil.isBlank(course.getCourseType()) ? null + : dao().fetch(TrainSignUpType.class, course.getCourseType()); + data.setv("courseTypeName", type == null ? "" : type.getTypeName()); + List activityTime = activity.getActivityStartTime() == null || activity.getActivityEndTime() == null + ? Collections.emptyList() : List.of(DateUtil.formatDateTime(activity.getActivityStartTime()), + DateUtil.formatDateTime(activity.getActivityEndTime())); + return NutMap.NEW().setv("course", data).setv("activityTime", activityTime) + .setv("canEditBasic", canEditActivityBasic()) + .setv("courseTypes", canEditActivityBasic() ? dao().query(TrainSignUpType.class, Cnd.NEW()) : Collections.emptyList()); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void saveManagedCourse(TrainSignUpCourse course, boolean updateCourseTimes) { + if (course == null) { + throw new BaseException("子活动信息不能为空"); + } + TrainSignUpCourse stored = requireManagedCourse(course.getActivityId(), course.getId()); + boolean usesSignLocation = validateManagedSign(course); + String fields = "isMobileSign"; + if (course.isMobileSign()) { + fields += "|signType"; + } + // 被扫或关闭签到时不更新隐藏配置,即使请求携带这些字段也保留数据库原值。 + if (usesSignLocation) { + fields += "|signUnit|radius|courseLocationCoordinates|transPosition"; + } + if (canEditActivityBasic()) { + validateManagedBasic(course, stored); + validateCourseResponsibles(List.of(course)); + fields += "|courseName|courseType|coursePeopleNumber|courseReservedNumber|courseLocation|campus|assort"; + if (course.getResponsibleList() != null) { + fields += "|courseInstructor|courseInstructorMobile"; + } + if (updateCourseTimes) { + validateManagedTimes(course, fetch(stored.getActivityId())); + fields += "|courseIsLimitApply"; + } + } else if (updateCourseTimes) { + throw new BaseException("活动时间仅管理员可修改"); + } + // 明确列出允许更新的列,普通负责人提交其他字段也无法覆盖关键内容或其他子活动。 + if (dao().update(course, "^(" + fields + ")$") != 1) { + throw new BaseException("子活动已变化,请刷新后重试"); + } + if (canEditActivityBasic()) { + saveCourseResponsibles(course); + if (updateCourseTimes) { + handleCourseTimes(fetch(stored.getActivityId()), course); + } + } + } + + /** + * 返回 true 表示扫码或 GPS 签到,需校验并更新时间、位置配置,默认误差0分钟、半径100米。 + * 被扫使用固定签到时间规则,关闭签到也不使用这些字段,返回 false 跳过校验且不更新原配置。 + */ + private boolean validateManagedSign(TrainSignUpCourse course) { + if (!course.isMobileSign()) { + return false; + } + if (!List.of(1, 2, 3).contains(course.getSignType() == null ? 0 : course.getSignType())) { + throw new BaseException("请选择有效的签到方式"); + } + if (Objects.equals(course.getSignType(), 2)) { + return false; + } + if (course.getSignUnit() == null) course.setSignUnit(0); + if (course.getRadius() == null) course.setRadius(100); + if (course.getSignUnit() < 0 || course.getRadius() <= 0) { + throw new BaseException("签到时间误差不能为负数,签到半径必须大于0"); + } + validateManagedPosition(course.getCourseLocationCoordinates()); + validateManagedPosition(course.getTransPosition()); + return true; + } + + /** 地图坐标和签到坐标均为 [纬度, 经度],拒绝不完整、非有限值及超出地理范围的坐标。 */ + private void validateManagedPosition(List point) { + if (point == null || point.size() != 2 || point.stream().anyMatch(value -> value == null || !Double.isFinite(value)) + || Math.abs(point.get(0)) > 90 || Math.abs(point.get(1)) > 180) { + throw new BaseException("请重新设置有效的签到点"); + } + } + + /** 管理员基本信息沿用原字段范围;人数为0表示不限制,已报名后禁止切换报名类型。 */ + private void validateManagedBasic(TrainSignUpCourse course, TrainSignUpCourse stored) { + if (StrUtil.isBlank(course.getCourseName()) || course.getCourseName().length() > 50 + || StrUtil.isBlank(course.getCourseLocation()) || course.getCourseLocation().length() > 50 + || StrUtil.isBlank(course.getCampus()) || course.getCampus().length() > 32 + || (course.getAssort() != null && course.getAssort().length() > 30)) { + throw new BaseException("请填写有效的名称、校区、地点和分类标识"); + } + if (StrUtil.isBlank(course.getCourseType()) || dao().fetch(TrainSignUpType.class, course.getCourseType()) == null) { + throw new BaseException("请选择有效的子活动类型"); + } + if (!Objects.equals(stored.getCourseType(), course.getCourseType()) + && dao().count(TrainSignUpUser.class, Cnd.where("courseId", "=", stored.getId())) > 0) { + throw new BaseException("已有报名记录,不能修改子活动类型"); + } + int registered = StrUtil.isBlank(stored.getCourseType()) ? 0 + : statisticsService.queryCourseCount(stored.getId(), stored.getCourseType()); + if (course.getCoursePeopleNumber() < 0 || course.getCoursePeopleNumber() > 9999 + || course.getCourseReservedNumber() < 0 || course.getCourseReservedNumber() > 1000 + || (course.getCoursePeopleNumber() > 0 && Objects.equals(stored.getReserveMode(), 1) + && course.getCourseReservedNumber() + registered > course.getCoursePeopleNumber())) { + throw new BaseException("人数或预留名额无效,不能低于已报名人数与预留名额之和"); + } + } + + /** 时段ID只能属于当前子活动;校验后再调用原有同步逻辑,保证变更及签到记录在同一事务中提交。 */ + private void validateManagedTimes(TrainSignUpCourse course, TrainSignUpActivity activity) { + if (Lang.isEmpty(course.getCourseTimeList()) || activity.getActivityStartTime() == null || activity.getActivityEndTime() == null) { + throw new BaseException("请先设置主活动日期及至少一个有效时段"); + } + Set ids = new HashSet<>(); + Set existingIds = dao().query(TrainSignUpActivityCourse.class, Cnd.where("courseId", "=", course.getId())) + .stream().map(TrainSignUpActivityCourse::getId).collect(Collectors.toSet()); + for (TrainSignUpActivityCourse time : course.getCourseTimeList()) { + if (time == null || time.getCourseDate() == null || time.getCourseStartTime() == null || time.getCourseEndTime() == null) { + throw new BaseException("请完整填写活动日期及起止时间"); + } + if (StrUtil.isNotBlank(time.getId()) && (!existingIds.contains(time.getId()) || !ids.add(time.getId()))) { + throw new BaseException("活动时段无效或重复,请刷新后重试"); + } + String date = DateUtil.formatDate(time.getCourseDate()); + Date start = DateUtil.parse(date + " " + DateUtil.format(time.getCourseStartTime(), "HH:mm")); + Date end = DateUtil.parse(date + " " + DateUtil.format(time.getCourseEndTime(), "HH:mm")); + if (!start.before(end) || start.before(activity.getActivityStartTime()) || end.after(activity.getActivityEndTime()) + || (Boolean.TRUE.equals(course.getCourseIsLimitApply()) && (time.getCourseLimitNum() == null || time.getCourseLimitNum() < 1))) { + throw new BaseException("活动时段必须在主活动范围内,结束晚于开始;限制人数时应填写正整数"); + } + } + } + @Override @Aop(TransAop.READ_COMMITTED) public void add(TrainSignUpActivity activity, TrainSignUpCourse course) { + // 先校验所有负责人,任何一行存在无效用户或联系方式时均不写入活动。 + validateCourseResponsibles(activity.getCourseList()); // 新建活动(包括从模板另存)统一归入活动列表,不能继承来源活动的模板状态。 activity.setTemplateStatus(TrainSignUpActivity.TEMPLATE_STATUS_UNSET); dao().insert(activity); @@ -68,6 +291,7 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl courseList = activity.getCourseList(); - if (Lang.isEmpty(courseList)) { + if (courseList == null) { return; } @@ -108,12 +348,15 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl deletedIds = Sets.difference(oldIds, newIds).immutableCopy(); if(Lang.isNotEmpty(deletedIds)) { + // 删除子活动时同步清理负责人,避免后续按 userId 查到已删除的业务。 + dao().clear(TrainSignUpCourseResponsible.class, Cnd.where("courseId", "in", deletedIds)); dao().clear(TrainSignUpCourse.class, Cnd.where("id", "in", deletedIds)); } for (TrainSignUpCourse course : courseList) { course.setActivityId(activity.getId()); dao().insertOrUpdate(course); + saveCourseResponsibles(course); if (Lang.isNotEmpty(course.getCourseTimeList())) { handleCourseTimes(activity, course); @@ -128,6 +371,131 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl courses) { + if (courses == null) { + return; + } + Set userIds = new HashSet<>(); + for (TrainSignUpCourse course : courses) { + if (course == null) { + throw new BaseException("子活动信息不能为空"); + } + if (course.getResponsibleList() == null) { + continue; + } + Set courseUserIds = new HashSet<>(); + for (TrainSignUpCourseResponsible responsible : course.getResponsibleList()) { + if (responsible == null || StrUtil.isBlank(responsible.getUserId())) { + throw new BaseException("请从下拉框选择负责人"); + } + String userId = responsible.getUserId().trim(); + if (userId.length() > 32 || !courseUserIds.add(userId)) { + throw new BaseException("负责人ID无效或同一子活动重复设置了负责人"); + } + String mobile = StrUtil.trim(responsible.getMobile()); + if (StrUtil.isBlank(mobile) || mobile.length() > 20) { + throw new BaseException("请填写负责人联系方式,长度不能超过20个字符"); + } + responsible.setUserId(userId).setMobile(mobile); + userIds.add(userId); + } + } + Map users = userIds.isEmpty() ? Collections.emptyMap() + : dao().query(Sys_user.class, Cnd.where("id", "in", userIds)).stream() + .collect(Collectors.toMap(Sys_user::getId, user -> user)); + for (TrainSignUpCourse course : courses) { + List responsibles = course.getResponsibleList(); + if (responsibles == null) { + continue; + } + for (int i = 0; i < responsibles.size(); i++) { + TrainSignUpCourseResponsible responsible = responsibles.get(i); + Sys_user user = users.get(responsible.getUserId()); + if (user == null || StrUtil.isBlank(user.getUsername())) { + throw new BaseException("所选负责人不存在,请重新选择"); + } + responsible.setUsername(user.getUsername()).setLoginname(user.getLoginname()).setOrderNum(i + 1); + } + // 原有报名和统计页面仍展示第一位联系人,完整姓名及多人信息保留在关系表。 + // 原姓名列宽为20,此处只限制兼容展示字段,避免长姓名导致数据库写入失败。 + course.setCourseInstructor(responsibles.isEmpty() ? "" : StrUtil.sub(responsibles.get(0).getUsername(), 0, 20)); + course.setCourseInstructorMobile(responsibles.isEmpty() ? "" : responsibles.get(0).getMobile()); + } + } + + /** + * 随子活动在同一事务中保存负责人,关系ID及归属由后端生成,模板复制不会复用旧关系。 + * null 表示未修改历史设置,不清理原有关系;空列表表示明确删除所有负责人。 + */ + @Aop(TransAop.READ_COMMITTED) + private void saveCourseResponsibles(TrainSignUpCourse course) { + if (course.getResponsibleList() == null) { + return; + } + dao().clear(TrainSignUpCourseResponsible.class, Cnd.where("courseId", "=", course.getId())); + for (TrainSignUpCourseResponsible responsible : course.getResponsibleList()) { + responsible.setId(null).setActivityId(course.getActivityId()).setCourseId(course.getId()); + } + if (!course.getResponsibleList().isEmpty()) { + dao().insert(course.getResponsibleList()); + } + } + + /** 批量回显负责人及工号,不按旧姓名猜测用户身份,也不对每个子活动重复查询用户。 */ + private void fillCourseResponsibles(List courses) { + if (courses.isEmpty()) { + return; + } + List courseIds = courses.stream().map(TrainSignUpCourse::getId).collect(Collectors.toList()); + List responsibles = dao().query(TrainSignUpCourseResponsible.class, + Cnd.where("courseId", "in", courseIds).asc("orderNum")); + Set userIds = responsibles.stream().map(TrainSignUpCourseResponsible::getUserId).collect(Collectors.toSet()); + Map users = userIds.isEmpty() ? Collections.emptyMap() + : dao().query(Sys_user.class, Cnd.where("id", "in", userIds)).stream() + .collect(Collectors.toMap(Sys_user::getId, user -> user)); + for (TrainSignUpCourseResponsible responsible : responsibles) { + Sys_user user = users.get(responsible.getUserId()); + if (user != null) { + responsible.setUsername(user.getUsername()).setLoginname(user.getLoginname()); + } + } + Map> byCourse = responsibles.stream() + .collect(Collectors.groupingBy(TrainSignUpCourseResponsible::getCourseId)); + for (TrainSignUpCourse course : courses) { + List list = byCourse.get(course.getId()); + // 旧活动保留 null,避免普通编辑保存时将未绑定用户的历史联系人清空。 + course.setResponsibleList(list != null ? list : + (StrUtil.isNotBlank(course.getCourseInstructor()) || StrUtil.isNotBlank(course.getCourseInstructorMobile()) + ? null : new ArrayList<>())); + } + } + + /** 删除主活动时在一个事务中清理负责人及原有业务关联,模板活动仍需先取消模板。 */ + @Override + @Aop(TransAop.READ_COMMITTED) + public void deleteActivity(String id) { + requireActivityBasicPermission(); + if (StrUtil.isBlank(id)) { + throw new BaseException("活动ID不能为空"); + } + if (dao().fetch(Sys_home_template.class, id) != null) { + throw new BaseException("该活动已设为模板,请先取消模板后再删除"); + } + dao().clear(TrainSignUpCourseResponsible.class, Cnd.where("activityId", "=", id)); + dao().clear(TrainSignUpCourse.class, Cnd.where("activityId", "=", id)); + dao().clear(TrainSignUpActivityCourse.class, Cnd.where("activityId", "=", id)); + dao().clear(TrainSignUpUser.class, Cnd.where("activityId", "=", id)); + dao().clear(TrainSignUpUserCourse.class, Cnd.where("activityId", "=", id)); + dao().clear(TrainSignUpActivity.class, Cnd.where("id", "=", id)); + dao().clear(TrainSignUpTypeLimit.class, Cnd.where("activityId", "=", id)); + dao().delete(Sys_home_activity.class, id); + } + + @Aop(TransAop.READ_COMMITTED) private void handleCourseTimes(TrainSignUpActivity activity, TrainSignUpCourse course) { String courseId = course.getId(); String activityId = activity.getId(); @@ -223,6 +591,7 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl { Calendar calendar = Calendar.getInstance(); @@ -250,7 +619,14 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl activities = pagination.getList(TrainSignUpActivity.class); + boolean canEditBasic = canEditActivityBasic(); + String userId = SecurityUtil.getUserId(); + Map> responsibleCourses = new HashMap<>(); + // 只批量查询当前页活动的本人负责人关系,避免逐行查询,也不把普通用户当作负责人。 + if (!canEditBasic && StrUtil.isNotBlank(userId) && !activities.isEmpty()) { + List activityIds = activities.stream().map(TrainSignUpActivity::getId).toList(); + List relations = dao().query(TrainSignUpCourseResponsible.class, + Cnd.where("activityId", "in", activityIds).and("userId", "=", userId)); + for (TrainSignUpCourseResponsible relation : relations) { + responsibleCourses.computeIfAbsent(relation.getActivityId(), key -> new HashSet<>()) + .add(relation.getCourseId()); + } + } + List rows = activities.stream().map(activity -> { + Set courseIds = responsibleCourses.getOrDefault(activity.getId(), Collections.emptySet()); + // 同时核对实际子活动,已删除或已迁出的子活动关系不提供入口;管理员优先走编辑。 + boolean canManageCourse = !canEditBasic && activity.getCourseList() != null + && activity.getCourseList().stream().anyMatch(course -> courseIds.contains(course.getId())); + return Lang.obj2nutmap(activity).setv("canManageCourse", canManageCourse); + }).toList(); + pagination.setList(rows); return pagination; } @@ -618,7 +1022,7 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl= 0; } } diff --git a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/impl/TrainSignUpActivityStatisticsServiceImpl.java b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/impl/TrainSignUpActivityStatisticsServiceImpl.java index 3403cb3..12a55c0 100644 --- a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/impl/TrainSignUpActivityStatisticsServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/impl/TrainSignUpActivityStatisticsServiceImpl.java @@ -1,25 +1,42 @@ package com.budwk.app.zhgh.activity.trainSignUp.service.impl; +import cn.afterturn.easypoi.excel.entity.ExportParams; +import cn.afterturn.easypoi.excel.entity.enmus.ExcelType; +import cn.afterturn.easypoi.excel.entity.params.ExcelExportEntity; +import cn.afterturn.easypoi.excel.export.ExcelExportService; import cn.hutool.core.util.StrUtil; +import cn.hutool.core.date.DateUtil; +import com.budwk.app.base.constant.RoleConstant; +import com.budwk.app.base.exception.BaseException; import com.budwk.app.base.page.Pagination; import com.budwk.app.base.param.PageForm; import com.budwk.app.base.service.impl.BaseServiceImpl; import com.budwk.app.sys.models.Sys_user; +import com.budwk.app.sys.models.Sys_file; +import com.budwk.app.sys.utils.SysFileMinIoUtil; import com.budwk.app.zhgh.activity.family.models.FamilyType; import com.budwk.app.zhgh.activity.family.models.FamilyUser; import com.budwk.app.zhgh.activity.trainSignUp.models.*; import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityStatisticsService; +import com.budwk.app.web.commons.auth.utils.AuthUtil; +import com.budwk.app.web.commons.auth.utils.SecurityUtil; import lombok.extern.slf4j.Slf4j; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.dao.Chain; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; import org.nutz.dao.Sqls; import org.nutz.dao.sql.Sql; import org.nutz.dao.util.cri.SqlExpressionGroup; +import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.json.Json; import org.nutz.lang.Lang; import org.nutz.lang.util.NutMap; +import java.io.IOException; import java.util.*; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; @@ -37,8 +54,197 @@ public class TrainSignUpActivityStatisticsServiceImpl extends BaseServiceImpl activityList(Integer year, boolean enabledOnly) { + // 可选条件也在创建SQL前确定,让年度、启用状态和负责人ID均由预编译参数绑定。 + Sql sql = Sqls.create("SELECT activity.* FROM train_sign_up_activity activity WHERE 1=1 " + + (year == null ? "" : " AND activity.year=@year ") + + (enabledOnly ? " AND activity.isDisabled=@disabled " : "") + + " $responsibleScope ORDER BY activity.activityStartTime DESC"); + sql.setParam("year", year); + sql.setParam("disabled", false); + applyResponsibleScope(sql, "activity.id", true); + sql.setEntity(dao().getEntity(TrainSignUpActivity.class)); + sql.setCallback(Sqls.callback.entities()); + dao().execute(sql); + return sql.getList(TrainSignUpActivity.class); + } + + @Override + public List getCourse(String activityId) { + if (StrUtil.isBlank(activityId)) { + return new ArrayList<>(); + } + Sql sql = Sqls.create(""" + SELECT tsuc.id, tsuc.courseName, tsuc.coursePeopleNumber, tsuc.courseType, + tsuc.courseLocation, tsuc.courseInstructor, tsuc.courseReservedNumber, + tsuc.waitingNum, tsuc.reserveMode + FROM train_sign_up_course tsuc + WHERE tsuc.activityId=@activityId $responsibleScope + ORDER BY tsuc.courseName ASC + """); + sql.setParam("activityId", activityId); + applyResponsibleScope(sql, "tsuc.id", false); + List courses = listMap(sql); + courses.forEach(course -> { + course.put("registerNum", queryCourseCount(course.getString("id"), course.getString("courseType"))); + course.put("hasWaitingNum", queryCourseWaitCount(course.getString("id"), course.getString("courseType"))); + }); + return courses; + } + + @Override + public List selectTimes(String courseId) { + requireCourseScope(courseId); + return dao().query(TrainSignUpActivityCourse.class, Cnd.where("courseId", "=", courseId).asc("courseStartTime")); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void signChange(String courseId, Boolean openOtherUnion) { + requireCourseScope(courseId); + if (openOtherUnion == null) { + throw new BaseException("请指定是否开放报名"); + } + dao().update(TrainSignUpCourse.class, Chain.make("openOtherUnion", openOtherUnion), Cnd.where("id", "=", courseId)); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void adjustSign(String id) { + if (StrUtil.isBlank(id)) { + throw new BaseException("签到记录ID不能为空"); + } + TrainSignUpUserCourse userCourse = dao().fetch(TrainSignUpUserCourse.class, id); + if (userCourse == null) { + throw new BaseException("签到记录不存在"); + } + // 通过记录实际所属子活动验证权限,不接受前端指定负责人身份。 + requireCourseScope(userCourse.getCourseId()); + if (userCourse.isAttend()) { + userCourse.setAttend(false); + userCourse.setAttendTime(null); + } else { + userCourse.setAttend(true); + userCourse.setAttendTime(userCourse.getCourseStartTime()); + } + dao().update(userCourse); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void adjust(String activityId, String oldCourseId, String newCourseId, String userId) { + if (StrUtil.isBlank(userId)) { + throw new BaseException("报名人员用户ID不能为空"); + } + // 来源与目标均受负责人范围约束,且必须属于同一主活动,防止绕过目标列表迁移数据。 + requireActivityMatch(activityId, requireCourseScope(oldCourseId)); + requireActivityMatch(activityId, requireCourseScope(newCourseId)); + Cnd oldCnd = Cnd.where("activityId", "=", activityId).and("courseId", "=", oldCourseId).and("userId", "=", userId); + TrainSignUpUser oldUser = dao().fetch(TrainSignUpUser.class, oldCnd); + if (oldUser == null) { + throw new BaseException("原报名记录不存在,请刷新后重试"); + } + oldUser.setCourseId(newCourseId); + oldUser.setSignUpTime(DateUtil.date()); + dao().update(oldUser); + + // 沿用现有人员调整业务,按目标子活动时段重建签到记录,并在同一事务中提交。 + List times = dao().query(TrainSignUpActivityCourse.class, + Cnd.where("activityId", "=", activityId).and("courseId", "=", newCourseId)); + dao().clear(TrainSignUpUserCourse.class, oldCnd); + List userCourses = new ArrayList<>(); + times.forEach(item -> { + TrainSignUpUserCourse course = new TrainSignUpUserCourse(); + course.setActivityCourseId(activityId); + course.setCourseId(newCourseId); + course.setUserId(userId); + course.setCourseStartTime(item.getCourseStartTime()); + course.setCourseEndTime(item.getCourseEndTime()); + course.setActivityCourseId(item.getId()); + userCourses.add(course); + }); + dao().insert(userCourses); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void deleteSignUser(String activityId, String courseId, String userId) { + if (StrUtil.isBlank(userId)) { + throw new BaseException("报名人员用户ID不能为空"); + } + requireActivityMatch(activityId, requireCourseScope(courseId)); + Cnd cnd = Cnd.where("activityId", "=", activityId).and("courseId", "=", courseId).and("userId", "=", userId); + // 报名及对应签到记录必须一并删除,禁止只传用户ID造成跨子活动删除。 + dao().clear(TrainSignUpUser.class, cnd); + dao().clear(TrainSignUpUserCourse.class, cnd); + } + @Override public Pagination pageData(PageForm pageForm, String activityId) { + // 没有可选主活动时返回空分页,不能将缺少筛选条件解释为查询所有子活动。 + if (StrUtil.isBlank(activityId)) { + return new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), 0, new ArrayList<>()); + } Sql sql = Sqls.create(""" SELECT tsuc.id, @@ -61,9 +267,11 @@ public class TrainSignUpActivityStatisticsServiceImpl extends BaseServiceImpl courseList = pagination.getList(); courseList.forEach(c -> { @@ -75,6 +283,7 @@ public class TrainSignUpActivityStatisticsServiceImpl extends BaseServiceImpl registerUserList(String courseId) { + requireCourseScope(courseId); Sql sql = Sqls.create(""" SELECT u.loginname, @@ -92,11 +301,13 @@ public class TrainSignUpActivityStatisticsServiceImpl extends BaseServiceImpl list = listMap(sql); for (NutMap o : list) { if(StrUtil.isBlank(o.getString("mobileColumnsValue"))) { @@ -114,7 +325,7 @@ public class TrainSignUpActivityStatisticsServiceImpl extends BaseServiceImpl getTaleColumnInfo(String courseId) { - TrainSignUpCourse course = dao().fetch(TrainSignUpCourse.class, courseId); + TrainSignUpCourse course = requireCourseScope(courseId); TrainSignUpType upType = dao().fetch(TrainSignUpType.class, course.getCourseType()); dao().fetchLinks(upType, "trainMobileSignColumnList", Cnd.NEW().asc("columnIndex")); @@ -125,6 +336,7 @@ public class TrainSignUpActivityStatisticsServiceImpl extends BaseServiceImpl registerUserList(String courseId, String unionId, String unitId, String searchName, String searchKeyword) { + requireCourseScope(courseId); Sql sql = Sqls.create(""" SELECT tsuu.id, @@ -141,10 +353,11 @@ public class TrainSignUpActivityStatisticsServiceImpl extends BaseServiceImpl courseList = courseSql.getList(TrainSignUpCourse.class); + if (courseList.isEmpty()) { + throw new BaseException("没有可导出的子活动,请检查负责人设置"); + } + Sql sql = Sqls.create(""" + SELECT ts.*, + CONCAT(DATE_FORMAT(ac.courseStartTime, '%H:%i:%s'),'至',DATE_FORMAT(ac.courseEndTime, '%H:%i:%s')) AS courseTime, + u.username, u.loginname, u.sex, IFNULL(ts.mobile, u.mobile) AS mobile, + u.birthday, tsc.courseName + FROM train_sign_up_user ts + LEFT JOIN train_sign_up_activity_course ac ON ts.activityCourseId=ac.id + LEFT JOIN vw_user u ON u.id=ts.userId + LEFT JOIN train_sign_up_course tsc ON tsc.id=ts.courseId + WHERE ts.activityId=@activityId AND tsc.activityId=@activityId $responsibleScope + """); + sql.setParam("activityId", activityId); + applyResponsibleScope(sql, "tsc.id", false); + List userList = listMap(sql); + + // 保留原导出公共列、动态报名列和图片处理,仅改变数据范围及Excel文件类型。 + List types = dao().query(TrainSignUpType.class, Cnd.NEW()); + dao().fetchLinks(types, "trainMobileSignColumnList", Cnd.NEW().asc("columnIndex")); + Map typeMap = types.stream().collect(Collectors.toMap(TrainSignUpType::getId, type -> type)); + List> sheetsList = new ArrayList<>(); + List commonEntities = new ArrayList<>(); + commonEntities.add(new ExcelExportEntity("姓名", "username", 20)); + commonEntities.add(new ExcelExportEntity("工号", "loginname", 20)); + commonEntities.add(new ExcelExportEntity("单位", "unitName", 20)); + commonEntities.add(new ExcelExportEntity("分工会", "unionName", 20)); + commonEntities.add(new ExcelExportEntity("性别", "sex", 20)); + commonEntities.add(new ExcelExportEntity("手机号", "mobile", 20)); + commonEntities.add(new ExcelExportEntity("生日", "birthday", 20)); + commonEntities.add(new ExcelExportEntity("报名时段", "courseTime", 20)); + for (TrainSignUpCourse course : courseList) { + String name = course.getCourseName(); + List users = userList.stream().filter(user -> StrUtil.isNotBlank(user.getString("courseName")) + && user.getString("courseName").equals(name)).collect(Collectors.toList()); + ExportParams exportParams = new ExportParams(); + exportParams.setType(ExcelType.XSSF); + exportParams.setSheetName(name); + List entities = new ArrayList<>(commonEntities); + TrainSignUpType type = typeMap.get(course.getCourseType()); + if (Lang.isNotEmpty(type.getTrainMobileSignColumnList())) { + for (TrainMobileSignColumn column : type.getTrainMobileSignColumnList()) { + ExcelExportEntity entity = new ExcelExportEntity(); + entity.setName(column.getColumnName()); + entity.setKey(column.getColumnCode()); + entity.setWidth(20); + if ("FILE".equals(column.getColumnFormType())) { + entity.setType(2); + entity.setExportImageType(2); + } + entities.add(entity); + } + } + for (NutMap user : users) { + String values = user.getString("mobileColumnsValue"); + if (StrUtil.isBlank(values)) { + continue; + } + for (NutMap value : Json.fromJsonAsList(NutMap.class, values)) { + if (!"FILE".equals(value.getString("columnFormType"))) { + user.put(value.getString("columnCode"), value.getString("columnValue")); + } else if (StrUtil.isNotBlank(value.getString("columnValue"))) { + List files = value.getAsList("columnValue", NutMap.class); + if (files.size() == 1) { + NutMap fileData = files.get(0); + Sys_file file = dao().fetch(Sys_file.class, Cnd.where(Sys_file::getDownloadPath, "=", fileData.get("url"))); + byte[] imageBytes = SysFileMinIoUtil.getFileBytes(file.getBucket(), file.getStoragePath()); + if (imageBytes.length > 0) { + user.put(value.getString("columnCode"), imageBytes); + } + } + } + } + } + Map sheet = new HashMap<>(); + sheet.put("name", name); + sheet.put("title", exportParams); + sheet.put("entity", entities); + sheet.put("data", users); + sheetsList.add(sheet); + } + Workbook workbook = new XSSFWorkbook(); + try { + for (Map sheet : sheetsList) { + new ExcelExportService().createSheetForMap(workbook, (ExportParams) sheet.get("title"), + (List) sheet.get("entity"), (Collection) sheet.get("data")); + } + return NutMap.NEW().setv("fileName", activity.getActivityName() + "报名人员名单.xlsx").setv("workbook", workbook); + } catch (RuntimeException exception) { + // 构建失败时尚未交给下载层,需在此释放工作簿;成功时由下载方法统一关闭。 + try { + workbook.close(); + } catch (IOException closeException) { + exception.addSuppressed(closeException); + } + throw exception; + } + } + @Override public int queryCourseCount(String courseId, String courseType) { return this.queryCourseCount(courseId, courseType, null); diff --git a/src/main/java/com/budwk/app/zhgh/club/controller/apply/ClubUserClubListController.java b/src/main/java/com/budwk/app/zhgh/club/controller/apply/ClubUserClubListController.java index 610b6f4..6524ede 100644 --- a/src/main/java/com/budwk/app/zhgh/club/controller/apply/ClubUserClubListController.java +++ b/src/main/java/com/budwk/app/zhgh/club/controller/apply/ClubUserClubListController.java @@ -1,6 +1,7 @@ package com.budwk.app.zhgh.club.controller.apply; import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.dev33.satoken.annotation.SaMode; import cn.hutool.core.util.StrUtil; import com.budwk.app.base.page.Pagination; import com.budwk.app.base.param.PageForm; @@ -47,8 +48,19 @@ public class ClubUserClubListController { public void index() { } + /** + * 打开移动端社团列表,共用电脑端的社团查询及入退会状态。 + * + * @return 无返回值,由模板渲染社团列表页面 + */ + @At("/h5") + @Ok("beetl:/platform/zhghh5/club/list/index.html") + @SaCheckPermission(value = {"h5.club.join.list", "h5.club.join.apply"}, mode = SaMode.OR) + public void h5Index() { + } + @At - @SaCheckPermission("club.join.list") + @SaCheckPermission(value = {"club.join.list", "h5.club.join.list", "h5.club.join.apply"}, mode = SaMode.OR) public Result pageData(@Valid PageForm pageForm, Boolean hasJoin, String clubName, String clubType) { Cnd cnd = Cnd.NEW(); cnd.and("dismiss", "=", false); @@ -71,7 +83,7 @@ public class ClubUserClubListController { } @At - @SaCheckPermission("club.join.list") + @SaCheckPermission(value = {"club.join.list", "h5.club.join.list", "h5.club.join.apply"}, mode = SaMode.OR) public Result getCount() { // 获取所有社团 List list = sysClubService.query(); @@ -96,7 +108,7 @@ public class ClubUserClubListController { } @At - @SaCheckPermission("club.join.list") + @SaCheckPermission(value = {"club.join.list", "h5.club.join.list", "h5.club.join.apply"}, mode = SaMode.OR) public Result findOne(@Valid String id) { ClubRegisterVo clubRegisterVo = sysClubService.findOne(id); return Result.success(clubRegisterVo); diff --git a/src/main/java/com/budwk/app/zhgh/club/controller/apply/ClubUserJoinApplyController.java b/src/main/java/com/budwk/app/zhgh/club/controller/apply/ClubUserJoinApplyController.java index 59d147c..90629d4 100644 --- a/src/main/java/com/budwk/app/zhgh/club/controller/apply/ClubUserJoinApplyController.java +++ b/src/main/java/com/budwk/app/zhgh/club/controller/apply/ClubUserJoinApplyController.java @@ -74,10 +74,22 @@ public class ClubUserJoinApplyController { public void index() { } + /** + * 移动端首次入会先选择社团,已选社团或已有申请继续进入申请表单。 + * + * @param clubId 已选择的社团ID + * @param bizId 已有入会申请ID + * @param taskId 重新提交申请时的流程任务ID + * @return 无申请上下文时跳转社团列表,否则渲染原申请页面 + */ @At("/h5") @SaCheckPermission("h5.club.join.apply") - @Ok("beetl:/platform/zhghh5/club/apply/index.html") - public void h5Index() { + @Ok("re") + public String h5Index(@Param("clubId") String clubId, @Param("bizId") String bizId, @Param("taskId") String taskId) { + if (StrUtil.isBlank(clubId) && StrUtil.isBlank(bizId) && StrUtil.isBlank(taskId)) { + return ">>:/platform/club/join/list/h5"; + } + return "beetl:/platform/zhghh5/club/apply/index.html"; } @At diff --git a/src/main/java/com/budwk/app/zhgh/club/controller/common/ClubCommonController.java b/src/main/java/com/budwk/app/zhgh/club/controller/common/ClubCommonController.java index 860d7e8..3b9ae89 100644 --- a/src/main/java/com/budwk/app/zhgh/club/controller/common/ClubCommonController.java +++ b/src/main/java/com/budwk/app/zhgh/club/controller/common/ClubCommonController.java @@ -2,12 +2,17 @@ package com.budwk.app.zhgh.club.controller.common; import cn.dev33.satoken.annotation.SaCheckLogin; import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.dev33.satoken.annotation.SaMode; +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.StrUtil; import com.budwk.app.base.constant.RoleConstant; import com.budwk.app.base.result.Result; import com.budwk.app.base.service.BaseService; import com.budwk.app.bpm.enums.BpmProcessInstanceStatusEnum; import com.budwk.app.bpm.enums.BpmProcessTaskStatusEnum; import com.budwk.app.flow.enums.ProcessInstanceStateEnum; +import com.budwk.app.sys.enums.SysFileEngineTypeEnum; +import com.budwk.app.sys.services.SysFileService; import com.budwk.app.web.commons.auth.utils.AuthUtil; import com.budwk.app.web.commons.auth.utils.SecurityUtil; import com.budwk.app.zhgh.club.model.ClubUser; @@ -19,10 +24,17 @@ import org.nutz.dao.Sqls; import org.nutz.dao.sql.Sql; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.mvc.annotation.AdaptBy; import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.Ok; +import org.nutz.mvc.annotation.POST; +import org.nutz.mvc.annotation.Param; +import org.nutz.mvc.upload.TempFile; +import org.nutz.mvc.upload.UploadAdaptor; import java.util.List; +import java.util.Locale; +import java.util.Set; @IocBean @At("/platform/club/common") @@ -36,6 +48,36 @@ public class ClubCommonController { @Inject private SysClubService sysClubService; + @Inject + private SysFileService sysFileService; + + /** + * 上传社团缩略图,限制图片大小并复用系统文件服务校验图片内容及保存文件。 + * + * @param file 用户上传的 JPG、PNG 或 GIF 图片,大小不得超过 2MB + * @return 成功时返回图片下载地址,文件无效或超过限制时返回错误提示 + */ + @At + @POST + @AdaptBy(type = UploadAdaptor.class, args = {"ioc:fileUpload"}) + @SaCheckPermission(value = {"club.register.apply", "club.infoManage.manage"}, mode = SaMode.OR) + public Result uploadThumbnail(@Param("file") TempFile file) { + if (file == null || StrUtil.isBlank(file.getSubmittedFileName())) { + return Result.error("请选择社团缩略图"); + } + if (file.getSize() > 2L * 1024 * 1024) { + return Result.error("社团缩略图大小不能超过2MB"); + } + String suffix = FileUtil.extName(file.getSubmittedFileName()).toLowerCase(Locale.ROOT); + if (!Set.of("jpg", "jpeg", "png", "gif").contains(suffix)) { + return Result.error("社团缩略图仅支持JPG、PNG、GIF图片"); + } + try { + return Result.success().addData(sysFileService.uploadReturnUrl(SysFileEngineTypeEnum.MINIO.getValue(), file)); + } catch (Exception e) { + return Result.error(e.getMessage()); + } + } @At @SaCheckLogin diff --git a/src/main/java/com/budwk/app/zhgh/club/model/SysClub.java b/src/main/java/com/budwk/app/zhgh/club/model/SysClub.java index 32a27cc..2ea042c 100644 --- a/src/main/java/com/budwk/app/zhgh/club/model/SysClub.java +++ b/src/main/java/com/budwk/app/zhgh/club/model/SysClub.java @@ -37,6 +37,11 @@ public class SysClub extends BaseModel { @NotEmpty(message = "社团名称不能为空") private String clubName; + @Column + @Comment("社团缩略图") + @ColDefine(type = ColType.VARCHAR, width = 1000) + private String thumbnail; + @Column @Comment("社团编码") @ColDefine(type = ColType.VARCHAR, width = 100) diff --git a/src/main/java/com/budwk/app/zhgh/club/service/impl/SysClubServiceImpl.java b/src/main/java/com/budwk/app/zhgh/club/service/impl/SysClubServiceImpl.java index 598d026..d2a4bf4 100644 --- a/src/main/java/com/budwk/app/zhgh/club/service/impl/SysClubServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/club/service/impl/SysClubServiceImpl.java @@ -112,11 +112,14 @@ public class SysClubServiceImpl extends BaseServiceImpl implements SysC if (StrUtil.isBlank(clubId)) { return new ClubRegisterVo(); } + // 与社团列表保持一致,按社团成员角色汇总会长、秘书长姓名,避免多名任职人员导致详情记录重复。 Sql sql = Sqls.create(""" SELECT club.*, d.NAME AS typeName, u.username concatPersonName, + (select GROUP_CONCAT(username) from sys_user where id in (select userId from club_user u where u.clubId=club.id and JSON_CONTAINS(u.roleCode, '"CLUB_PRESIDENT"'))) as clubLeader, + (select GROUP_CONCAT(username) from sys_user where id in (select userId from club_user u where u.clubId=club.id and JSON_CONTAINS(u.roleCode, '"CLUB_SECRETARY"'))) as clubSecretary, ( SELECT count( 1 ) FROM club_user scu WHERE scu.clubId = club.id ) currentPeopleNum, (select GROUP_CONCAT(username) from sys_user where id in (select sponsorId from sys_club_sponsor where clubId = club.id)) as sponsorName, (select GROUP_CONCAT(sponsorId) from sys_club_sponsor where clubId = club.id) as sponsor diff --git a/src/main/java/com/budwk/app/zhgh/club/vo/ClubRegisterVo.java b/src/main/java/com/budwk/app/zhgh/club/vo/ClubRegisterVo.java index 62c4019..e239d79 100644 --- a/src/main/java/com/budwk/app/zhgh/club/vo/ClubRegisterVo.java +++ b/src/main/java/com/budwk/app/zhgh/club/vo/ClubRegisterVo.java @@ -19,4 +19,10 @@ public class ClubRegisterVo extends SysClub { private String sponsorName; private Integer currentPeopleNum; private String sponsor; + + @ApiModelProperty("会长") + private String clubLeader; + + @ApiModelProperty("秘书长") + private String clubSecretary; } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/healthCheckup/h5controller/H5HealthCheckupController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/healthCheckup/h5controller/H5HealthCheckupController.java index be0c21c..63b6821 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/healthCheckup/h5controller/H5HealthCheckupController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/healthCheckup/h5controller/H5HealthCheckupController.java @@ -84,6 +84,7 @@ public class H5HealthCheckupController { Sql sql = Sqls.create(""" select *, + (select count(*) from health_checkup_project_subject subject where subject.projectId=p.id) as subjectCount, (select count(*) from health_checkup_user_selection s where s.projectId=p.id and s.selectUserId=@userId) as selectCount from health_checkup_project p @@ -121,6 +122,8 @@ public class H5HealthCheckupController { p.choiceTimeStart, p.choiceTimeEnd, p.cover, + p.healthCheckupType, + (select count(*) from health_checkup_project_subject subject where subject.projectId=p.id) as subjectCount, sub.optionName FROM health_checkup_user_selection us diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/healthCheckup/service/impl/HealthCheckupProjectServiceImpl.java b/src/main/java/com/budwk/app/zhgh/dayofficework/healthCheckup/service/impl/HealthCheckupProjectServiceImpl.java index ab936fc..151f11b 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/healthCheckup/service/impl/HealthCheckupProjectServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/healthCheckup/service/impl/HealthCheckupProjectServiceImpl.java @@ -20,6 +20,7 @@ import org.nutz.lang.random.R; import org.nutz.lang.util.NutMap; import java.util.List; +import java.util.ArrayList; import java.util.Set; import java.util.stream.Collectors; @@ -48,7 +49,16 @@ public class HealthCheckupProjectServiceImpl extends BaseServiceImpl()); + } project.getHealthCheckupProjectSubjects().forEach(v -> { v.setSubjectMoneys(dao().query(HealthCheckupProjectSubjectMoney.class, Cnd.where("subjectId", "=", v.getId()))); }); diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/healthCheckup/service/impl/HealthCheckupSingleServiceImpl.java b/src/main/java/com/budwk/app/zhgh/dayofficework/healthCheckup/service/impl/HealthCheckupSingleServiceImpl.java index 57e911a..c301234 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/healthCheckup/service/impl/HealthCheckupSingleServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/healthCheckup/service/impl/HealthCheckupSingleServiceImpl.java @@ -29,9 +29,17 @@ public class HealthCheckupSingleServiceImpl extends BaseServiceImpl()).addv("tableColumn", new ArrayList<>()); + } Sql sql = Sqls.create(""" SELECT su.id, diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/honor/controller/HonorBoardController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/honor/controller/HonorBoardController.java index 1989527..938f25e 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/honor/controller/HonorBoardController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/honor/controller/HonorBoardController.java @@ -10,14 +10,11 @@ import org.nutz.dao.Dao; import org.nutz.dao.Sqls; import org.nutz.dao.entity.Record; import org.nutz.dao.sql.Sql; -import org.nutz.dao.util.Daos; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; -import org.nutz.lang.util.NutMap; import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.Ok; -import java.util.ArrayList; import java.util.Calendar; import java.util.List; @@ -42,36 +39,8 @@ public class HonorBoardController { @At @SaCheckPermission("honor.board") public Result getHonorTypeByYear() { - Sql sqlYear = Sqls.create(""" - select YEAR(grantDate) AS `year` from honor where grantDate is not null GROUP BY grantDate - """); - int[] yearList = (int[]) Daos.query(dao, sqlYear.toString(), Sqls.callback.ints()); - - List resultList = new ArrayList<>(); - for (int year : yearList) { - resultList.add(NutMap.NEW().addv("name", "集体荣誉").addv("num", 0).addv("year", year)); - resultList.add(NutMap.NEW().addv("name", "个人荣誉").addv("num", 0).addv("year", year)); - } - - Sql sql = Sqls.create(""" - SELECT YEAR - ( h.grantDate ) as year, - hbs.`name` - FROM - honor h - left join honor_basic_settings hbs on hbs.id = h.honorType - WHERE YEAR (h.grantDate) <= @currentYear - """); - sql.setParam("currentYear", Calendar.getInstance().get(Calendar.YEAR)); - List list = honorCommonService.listMap(sql); - - for (NutMap map : resultList) { - int year = map.getInt("year"); - String name = map.getString("name"); - long count = list.stream().filter(v -> v.getString("name").equals(name) && v.getInt("year") == year).count(); - map.setv("num", count); - } - return Result.success(resultList); + int currentYear = Calendar.getInstance().get(Calendar.YEAR); + return Result.success(honorCommonService.getHonorTypeStatisticsByYear(currentYear)); } @At diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/honor/service/HonorCommonService.java b/src/main/java/com/budwk/app/zhgh/dayofficework/honor/service/HonorCommonService.java index 153c969..7c85c2f 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/honor/service/HonorCommonService.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/honor/service/HonorCommonService.java @@ -3,6 +3,7 @@ package com.budwk.app.zhgh.dayofficework.honor.service; import com.budwk.app.base.service.BaseService; import com.budwk.app.zhgh.dayofficework.honor.models.Honor; import com.budwk.app.zhgh.dayofficework.honor.models.HonorBasicSettings; +import org.nutz.lang.util.NutMap; import java.util.List; @@ -16,4 +17,12 @@ import java.util.List; public interface HonorCommonService extends BaseService { List getHonorBasicSettingsByQueryTypeCode(String queryTypeCode); + + /** + * 按年度统计集体荣誉和个人荣誉数量。 + * + * @param currentYear 当前允许统计的最大年份,晚于该年份的数据不返回 + * @return 年度荣誉统计列表;每项包含 name(荣誉类别)、num(数量)和 year(年份) + */ + List getHonorTypeStatisticsByYear(int currentYear); } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/honor/service/impl/HonorCommonServiceImpl.java b/src/main/java/com/budwk/app/zhgh/dayofficework/honor/service/impl/HonorCommonServiceImpl.java index dcd909c..1359b0a 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/honor/service/impl/HonorCommonServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/honor/service/impl/HonorCommonServiceImpl.java @@ -7,9 +7,16 @@ import com.budwk.app.zhgh.dayofficework.honor.models.HonorBasicSettings; import com.budwk.app.zhgh.dayofficework.honor.service.HonorCommonService; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; +import org.nutz.dao.Sqls; +import org.nutz.dao.sql.Sql; import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.lang.Strings; +import org.nutz.lang.util.NutMap; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /** * @version 1.0 @@ -32,4 +39,54 @@ public class HonorCommonServiceImpl extends BaseServiceImpl implements Ho List query = dao().query(HonorBasicSettings.class, Cnd.where("parentId", "=", basicSettings.getId()).asc("`code`")); return query; } + + /** + * 按年度统计荣誉类别数量,并为每个存在荣誉数据的年度补齐集体荣誉、个人荣誉两类数据。 + * SQL 将年份显式转换为字符类型,避免部分 JDBC 驱动把 YEAR 函数结果识别为 Timestamp。 + * + * @param currentYear 当前允许统计的最大年份 + * @return 年度荣誉统计列表;每项包含 name、num、year 三个字段 + */ + @Override + public List getHonorTypeStatisticsByYear(int currentYear) { + Sql sql = Sqls.create(""" + SELECT + CAST(YEAR(h.grantDate) AS CHAR) AS honorYear, + hbs.`name` AS honorName, + COUNT(1) AS honorCount + FROM honor h + LEFT JOIN honor_basic_settings hbs ON hbs.id = h.honorType + WHERE h.grantDate IS NOT NULL + AND YEAR(h.grantDate) <= @currentYear + GROUP BY CAST(YEAR(h.grantDate) AS CHAR), hbs.`name` + ORDER BY honorYear + """); + sql.setParam("currentYear", currentYear); + List statistics = listMap(sql); + + // 每个年度固定返回两类荣誉,保证某一类别无数据时图表仍能显示 0。 + Map> statisticsByYear = new LinkedHashMap<>(); + for (NutMap statistic : statistics) { + String honorYear = statistic.getString("honorYear"); + if (Strings.isBlank(honorYear)) { + continue; + } + int year = Integer.parseInt(honorYear); + Map categoryStatistics = statisticsByYear.computeIfAbsent(year, key -> { + Map categoryMap = new LinkedHashMap<>(); + categoryMap.put("集体荣誉", 0L); + categoryMap.put("个人荣誉", 0L); + return categoryMap; + }); + String honorName = statistic.getString("honorName"); + if (categoryStatistics.containsKey(honorName)) { + categoryStatistics.put(honorName, statistic.getLong("honorCount")); + } + } + + List resultList = new ArrayList<>(); + statisticsByYear.forEach((year, categoryStatistics) -> categoryStatistics.forEach((name, count) -> + resultList.add(NutMap.NEW().addv("name", name).addv("num", count).addv("year", year)))); + return resultList; + } } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/message/service/SliderCaptchaService.java b/src/main/java/com/budwk/app/zhgh/dayofficework/message/service/SliderCaptchaService.java new file mode 100644 index 0000000..222f270 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/message/service/SliderCaptchaService.java @@ -0,0 +1,269 @@ +package com.budwk.app.zhgh.dayofficework.message.service; + +import com.budwk.app.base.constant.RedisConstant; +import com.budwk.app.base.exception.BaseException; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.integration.jedis.RedisService; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.json.Json; +import org.nutz.lang.Strings; +import org.nutz.lang.util.NutMap; + +import javax.imageio.ImageIO; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.geom.Area; +import java.awt.geom.Ellipse2D; +import java.awt.geom.Rectangle2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.UUID; + +/** + * PC 登录拼图验证码:原图仅在服务端缩放,正确横坐标只保存在 Redis。 + * 数据库事务注解遵循项目规范;Redis 状态转换的原子性由 Lua 保证。 + */ +@IocBean +public class SliderCaptchaService { + public static final String PASSWORD = "password"; + public static final String SMS = "sms"; + private static final int WIDTH = 320; + private static final int HEIGHT = 160; + private static final int PIECE_SIZE = 50; + private static final int CHALLENGE_SECONDS = 180; + private static final int TICKET_SECONDS = 60; + // PC 和手机端共用图片池:0~9 为风景,10~19 为动物,文件位于 static/captcha/slider。 + private static final String[] BACKGROUNDS = { + "0.jpg", "1.jpg", "2.jpg", "3.jpg", "4.jpg", + "5.jpg", "6.jpg", "7.jpg", "8.jpg", "9.jpg", + "10.jpg", "11.jpg", "12.jpg", "13.jpg", "14.jpg", + "15.jpg", "16.jpg", "17.jpg", "18.jpg", "19.jpg" + }; + private final SecureRandom random = new SecureRandom(); + private volatile BufferedImage[] backgrounds; + + @Inject + private RedisService redisService; + + // 计数和首次设置有效期必须同时完成,防止进程中断留下永不过期的限流键。 + private static final String RATE_SCRIPT = "local n = redis.call('INCR', KEYS[1]); " + + "if n == 1 then redis.call('EXPIRE', KEYS[1], 60) end; return n"; + + // 一个挑战仅允许提交一次。旧挑战不能删除刷新后生成的新挑战,已通过状态不能再次换票。 + private static final String VERIFY_SCRIPT = "local raw = redis.call('GET', KEYS[1]); " + + "if not raw then return 0 end; local s = cjson.decode(raw); " + + "if s.id ~= ARGV[1] or s.state ~= 'pending' then return 0 end; " + + "if math.abs(s.x - tonumber(ARGV[2])) > 4 then redis.call('DEL', KEYS[1]); return -1 end; " + + "redis.call('SETEX', KEYS[1], ARGV[4], " + + "cjson.encode({id=s.id,state='passed',ticket=ARGV[3],binding=s.binding})); return 1"; + + // 比对与删除在同一个 Redis 命令中执行,并发登录最多只能消费一次凭证。 + private static final String CONSUME_SCRIPT = "local raw = redis.call('GET', KEYS[1]); " + + "if not raw then return 0 end; local s = cjson.decode(raw); " + + "if s.state ~= 'passed' or s.id ~= ARGV[1] or s.ticket ~= ARGV[2] or s.binding ~= ARGV[3] then return 0 end; " + + "redis.call('DEL', KEYS[1]); return 1"; + + /** + * 创建当前会话的新挑战,覆盖该会话之前的挑战或登录凭证。 + * + * @param sessionId Servlet 生成的当前会话 ID,不接受前端自行指定 + * @param remoteAddress Servlet 连接的客户端 IP,用于独立于会话的请求限流 + * @param scene password 为账号登录,sms 为发送登录短信 + * @param subject password 场景传原始账号,sms 场景传已去除首尾空格的手机号 + * @return NutMap:challengeId 为挑战编号;background/piece 为图像 data URL; + * width/height/pieceSize/pieceY 为原图像素尺寸和拼图纵坐标;expiresIn 为有效秒数 + */ + @Aop(TransAop.READ_COMMITTED) + public NutMap create(String sessionId, String remoteAddress, String scene, String subject) throws IOException { + String binding = binding(scene, subject); + String sessionHash = digest(sessionId); + long ipCount = ((Number) redisService.eval(RATE_SCRIPT, 1, + RedisConstant.REDIS_SLIDER_RATE_KEY + "ip:" + digest(remoteAddress))).longValue(); + long sessionCount = ((Number) redisService.eval(RATE_SCRIPT, 1, + RedisConstant.REDIS_SLIDER_RATE_KEY + "session:" + sessionHash)).longValue(); + if (ipCount > 120 || sessionCount > 20) { + throw new BaseException("获取拼图过于频繁,请稍后再试"); + } + + BufferedImage[] images = getBackgrounds(); + BufferedImage original = images[random.nextInt(images.length)]; + // 左侧预留拖动起点,四周留边,避免拼图越界或用户无需拖动就能通过。 + int x = 60 + random.nextInt(WIDTH - PIECE_SIZE - 70); + int y = 10 + random.nextInt(HEIGHT - PIECE_SIZE - 20); + Area shape = new Area(new Rectangle2D.Double(0, 8, 42, 42)); + shape.add(new Area(new Ellipse2D.Double(13, 0, 16, 16))); + shape.add(new Area(new Ellipse2D.Double(34, 21, 16, 16))); + shape.subtract(new Area(new Ellipse2D.Double(-8, 21, 16, 16))); + + BufferedImage background = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB); + Graphics2D bg = background.createGraphics(); + try { + bg.drawImage(original, 0, 0, null); + bg.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + bg.translate(x, y); + bg.setColor(new Color(0, 0, 0, 170)); + bg.fill(shape); + bg.setColor(new Color(255, 255, 255, 210)); + bg.setStroke(new BasicStroke(1.5f)); + bg.draw(shape); + } finally { + bg.dispose(); + } + BufferedImage piece = new BufferedImage(PIECE_SIZE, PIECE_SIZE, BufferedImage.TYPE_INT_ARGB); + Graphics2D pg = piece.createGraphics(); + try { + pg.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + pg.setClip(shape); + pg.drawImage(original, -x, -y, null); + pg.setClip(null); + pg.setColor(Color.WHITE); + pg.setStroke(new BasicStroke(1.5f)); + pg.draw(shape); + } finally { + pg.dispose(); + } + + String challengeId = newId(); + NutMap response = NutMap.NEW().addv("challengeId", challengeId) + .addv("background", imageUrl(background, "jpg")) + .addv("piece", imageUrl(piece, "png")) + .addv("width", WIDTH).addv("height", HEIGHT) + .addv("pieceSize", PIECE_SIZE).addv("pieceY", y) + .addv("expiresIn", CHALLENGE_SECONDS); + redisService.setex(RedisConstant.REDIS_SLIDER_CAPTCHA_KEY + sessionHash, CHALLENGE_SECONDS, + Json.toJson(NutMap.NEW().addv("id", challengeId).addv("state", "pending").addv("x", x).addv("binding", binding))); + return response; + } + + /** + * 校验拖动位置并签发登录凭证,失败后当前挑战作废。 + * + * @param sessionId 当前 Servlet 会话 ID + * @param challengeId create 返回的 32 位挑战编号 + * @param offset 拼图左边缘在原图中的横坐标,前端需将显示像素换算为原图像素 + * @return NutMap:platformKey 为挑战编号,platformCaptcha 为随机的一次性凭证,expiresIn 为有效秒数 + */ + @Aop(TransAop.READ_COMMITTED) + public NutMap verify(String sessionId, String challengeId, double offset) { + if (!validId(challengeId) || !Double.isFinite(offset) || offset < 0 || offset > WIDTH - PIECE_SIZE) { + throw new BaseException("拼图参数不正确,请重新获取"); + } + String ticket = newId(); + long result = ((Number) redisService.eval(VERIFY_SCRIPT, 1, + RedisConstant.REDIS_SLIDER_CAPTCHA_KEY + digest(sessionId), challengeId, + Double.toString(offset), ticket, Integer.toString(TICKET_SECONDS))).longValue(); + if (result != 1) { + throw new BaseException(result == -1 ? "未对齐缺口,请重新验证" : "拼图已失效,请重新验证"); + } + return NutMap.NEW().addv("platformKey", challengeId).addv("platformCaptcha", ticket) + .addv("expiresIn", TICKET_SECONDS); + } + + /** + * PC 账号认证之前消费拼图凭证。无返回值代表校验通过,失败抛出验证码认证异常。 + * + * @param sessionId 当前 Servlet 会话 ID,保证其他会话不能使用此凭证 + * @param challengeId 登录参数 platformKey,取自 verify 的返回结果 + * @param ticket 登录参数 platformCaptcha,必须为 verify 签发的一次性凭证 + * @param scene 本次操作用途 password 或 sms,不能跨用途使用 + * @param subject 本次操作的账号或手机号,必须与创建挑战时一致 + */ + @Aop(TransAop.READ_COMMITTED) + public void consume(String sessionId, String challengeId, String ticket, String scene, String subject) { + if (Strings.isBlank(challengeId) || Strings.isBlank(ticket)) { + throw new BaseException("请先完成拼图验证"); + } + if (!validId(challengeId) || !validId(ticket)) { + throw new BaseException("拼图验证已失效,请重新验证"); + } + long result = ((Number) redisService.eval(CONSUME_SCRIPT, 1, + RedisConstant.REDIS_SLIDER_CAPTCHA_KEY + digest(sessionId), challengeId, ticket, binding(scene, subject))).longValue(); + if (result != 1) { + throw new BaseException("拼图验证已失效,请重新验证"); + } + } + + /** 将用途与账号绑定到摘要中,防止验证后换号或跨用途消费;不在 Redis 保存账号明文。 */ + private String binding(String scene, String subject) { + if ((!PASSWORD.equals(scene) && !SMS.equals(scene)) || Strings.isBlank(subject) + || subject.length() > 200 || (SMS.equals(scene) && !subject.matches("^1[3-9]\\d{9}$"))) { + throw new BaseException("请先填写正确的账号或手机号"); + } + return digest(scene + ":" + subject); + } + + /** 缩小后的原图只缓存一份且不再修改;使用资源流以兼容 JAR 部署。 */ + private BufferedImage[] getBackgrounds() throws IOException { + if (backgrounds == null) { + synchronized (this) { + if (backgrounds == null) { + BufferedImage[] loaded = new BufferedImage[BACKGROUNDS.length]; + for (int i = 0; i < BACKGROUNDS.length; i++) { + try (InputStream input = SliderCaptchaService.class.getResourceAsStream( + "/static/captcha/slider/" + BACKGROUNDS[i])) { + if (input == null) { + throw new IOException("拼图背景资源缺失:" + BACKGROUNDS[i]); + } + BufferedImage source = ImageIO.read(input); + if (source == null) { + throw new IOException("拼图背景无法读取:" + BACKGROUNDS[i]); + } + loaded[i] = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = loaded[i].createGraphics(); + try { + graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_BICUBIC); + graphics.drawImage(source, 0, 0, WIDTH, HEIGHT, null); + } finally { + graphics.dispose(); + } + } + } + backgrounds = loaded; + } + } + } + return backgrounds; + } + + private String imageUrl(BufferedImage image, String format) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + if (!ImageIO.write(image, format, output)) { + throw new IOException("不支持的拼图编码:" + format); + } + return "data:image/" + ("jpg".equals(format) ? "jpeg" : format) + ";base64," + + Base64.getEncoder().encodeToString(output.toByteArray()); + } + + private String newId() { + return UUID.randomUUID().toString().replace("-", ""); + } + + private boolean validId(String value) { + return value != null && value.matches("[a-f0-9]{32}"); + } + + /** Redis 键中不暴露原始会话 ID 或 IP,也避免外部字符影响键结构。 */ + private String digest(String value) { + if (Strings.isBlank(value)) { + throw new BaseException("会话已失效,请刷新登录页面"); + } + try { + byte[] bytes = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("无法计算拼图会话标识", e); + } + } +} diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/message/service/TencentSmsService.java b/src/main/java/com/budwk/app/zhgh/dayofficework/message/service/TencentSmsService.java new file mode 100644 index 0000000..b0f7590 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/message/service/TencentSmsService.java @@ -0,0 +1,23 @@ +package com.budwk.app.zhgh.dayofficework.message.service; + +/** + * 腾讯云登录验证码短信服务,与校内通讯平台的业务通知短信分别配置。 + */ +public interface TencentSmsService { + + /** + * 校验登录短信开关和腾讯云必填配置,配置缺失时禁止生成有效验证码。 + * + * @throws com.budwk.app.base.exception.BaseException 未启用短信或缺少凭证、签名、应用及模板配置 + */ + void checkLoginConfiguration(); + + /** + * 向单个已绑定账号的大陆手机号发送登录验证码。 + * + * @param mobile 不带 +86 的 11 位大陆手机号,由登录业务层完成账号绑定及停用校验 + * @param code 六位数字验证码,作为登录短信模板的唯一变量 + * @throws com.budwk.app.base.exception.BaseException 配置无效、腾讯云调用失败或目标号码发送状态不为 Ok + */ + void sendLoginCode(String mobile, String code); +} diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/message/service/impl/TencentSmsServiceImpl.java b/src/main/java/com/budwk/app/zhgh/dayofficework/message/service/impl/TencentSmsServiceImpl.java new file mode 100644 index 0000000..1aad1de --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/message/service/impl/TencentSmsServiceImpl.java @@ -0,0 +1,147 @@ +package com.budwk.app.zhgh.dayofficework.message.service.impl; + +import com.budwk.app.base.exception.BaseException; +import com.budwk.app.zhgh.dayofficework.message.service.TencentSmsService; +import com.tencentcloudapi.common.Credential; +import com.tencentcloudapi.common.exception.TencentCloudSDKException; +import com.tencentcloudapi.common.profile.ClientProfile; +import com.tencentcloudapi.common.profile.HttpProfile; +import com.tencentcloudapi.sms.v20190711.SmsClient; +import com.tencentcloudapi.sms.v20190711.models.SendSmsRequest; +import com.tencentcloudapi.sms.v20190711.models.SendSmsResponse; +import com.tencentcloudapi.sms.v20190711.models.SendStatus; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.impl.PropertiesProxy; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.lang.Strings; +import org.nutz.log.Log; +import org.nutz.log.Logs; + +/** + * 沿用参考项目的腾讯云 v20190711 SDK、+86 号码和单变量登录模板。 + */ +@IocBean +public class TencentSmsServiceImpl implements TencentSmsService { + private static final Log log = Logs.get(); + + @Inject + private PropertiesProxy conf; + + /** {@inheritDoc} */ + @Override + public void checkLoginConfiguration() { + if (!conf.getBoolean("sms.enabled", false)) { + throw new BaseException("短信登录暂未启用,请使用账号密码登录"); + } + requiredConfig("sms.tencent.secret-id"); + requiredConfig("sms.tencent.secret-key"); + requiredConfig("sms.tencent.appid"); + requiredConfig("sms.tencent.sign"); + requiredConfig("sms.tencent.tpl.login_code"); + } + + /** {@inheritDoc} */ + @Override + @Aop(TransAop.READ_COMMITTED) + public void sendLoginCode(String mobile, String code) { + if (mobile == null || !mobile.matches("^1[3-9]\\d{9}$") + || code == null || !code.matches("^\\d{6}$")) { + throw new BaseException("短信手机号或验证码格式不正确"); + } + checkLoginConfiguration(); + // 单调时钟统计本次 SDK 调用(含客户端初始化)的耗时,不受系统时间校准影响。 + long startedAt = System.nanoTime(); + try { + Credential credential = new Credential(requiredConfig("sms.tencent.secret-id"), requiredConfig("sms.tencent.secret-key")); + HttpProfile httpProfile = new HttpProfile(); + httpProfile.setEndpoint("sms.tencentcloudapi.com"); + // 超时应小于发送锁有效期,避免慢请求与下一次重发同时写入验证码。 + httpProfile.setConnTimeout(5); + httpProfile.setReadTimeout(10); + httpProfile.setWriteTimeout(10); + ClientProfile profile = new ClientProfile(); + profile.setHttpProfile(httpProfile); + SmsClient client = new SmsClient(credential, conf.get("sms.tencent.region", "ap-guangzhou"), profile); + + SendSmsRequest request = new SendSmsRequest(); + request.setSmsSdkAppid(requiredConfig("sms.tencent.appid")); + request.setSign(requiredConfig("sms.tencent.sign")); + request.setTemplateID(requiredConfig("sms.tencent.tpl.login_code")); + request.setPhoneNumberSet(new String[]{"+86" + mobile}); + request.setTemplateParamSet(new String[]{code}); + checkSendStatus(client.SendSms(request), "+86" + mobile); + } catch (TencentCloudSDKException e) { + // SDK 网络异常可能没有 errorCode,实际异常类型及原因保存在 message 中。 + // 各字段先脱敏再输出,不直接打印异常对象,避免原始消息中的敏感信息进入日志。 + long elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000L; + log.warnf("腾讯云登录短信调用失败,code=%s,requestId=%s,elapsedMs=%s,reason=%s", + sanitizeDiagnostic(e.getErrorCode(), mobile, code), + sanitizeDiagnostic(e.getRequestId(), mobile, code), elapsedMillis, + sanitizeDiagnostic(e.getMessage(), mobile, code)); + throw new BaseException("短信发送失败,请稍后重试或联系管理员"); + } + } + + /** + * 保留 SDK 异常诊断信息,移除可能夹带的凭证、手机号与短信码,并限制为单行日志。 + * + * @param value SDK 返回的错误码、请求编号或异常消息,空值显示为 unavailable + * @param mobile 本次发送的大陆手机号,禁止在诊断字段中原样输出 + * @param code 本次发送的六位短信验证码,禁止在诊断字段中原样输出 + * @return 已脱敏的单行文本,最长 1000 个字符,超长内容附加省略标记 + */ + private String sanitizeDiagnostic(String value, String mobile, String code) { + if (Strings.isBlank(value)) { + return "unavailable"; + } + String sanitized = value; + String[] sensitiveValues = {Strings.trim(conf.get("sms.tencent.secret-id", "")), + Strings.trim(conf.get("sms.tencent.secret-key", "")), mobile, code}; + for (String sensitive : sensitiveValues) { + if (!Strings.isBlank(sensitive)) { + sanitized = sanitized.replace(sensitive, "[REDACTED]"); + } + } + // 同时遮盖异常文本中其他大陆手机号、签名及授权字段;先脱敏,再截断。 + sanitized = sanitized.replaceAll("(? 1000 ? sanitized.substring(0, 1000) + "..." : sanitized; + } + + /** + * 核对单号码业务发送状态,HTTP 调用成功不代表短信发送成功。 + * + * @param response 腾讯云发送响应,必须恰有一个与请求号码相同的成功状态 + * @param phoneNumber 请求中的 +86 国际格式号码 + */ + private void checkSendStatus(SendSmsResponse response, String phoneNumber) { + SendStatus[] statuses = response == null ? null : response.getSendStatusSet(); + if (statuses == null || statuses.length != 1 || statuses[0] == null + || !"Ok".equals(statuses[0].getCode()) + || !phoneNumber.equals(statuses[0].getPhoneNumber())) { + String statusCode = statuses != null && statuses.length > 0 && statuses[0] != null + ? statuses[0].getCode() : "EmptyStatus"; + log.warnf("腾讯云登录短信未发送成功,code=%s,requestId=%s", statusCode, + response == null ? "" : response.getRequestId()); + throw new BaseException("短信发送失败,请稍后重试或联系管理员"); + } + } + + /** + * 获取必填腾讯云配置,错误提示仅包含配置名称。 + * + * @param key sms.tencent 下的完整配置键 + * @return 去除首尾空格的非空配置值 + */ + private String requiredConfig(String key) { + String value = Strings.trim(conf.get(key, "")); + if (Strings.isBlank(value)) { + throw new BaseException("短信服务配置不完整,请联系管理员检查 " + key); + } + return value; + } +} diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseApplyController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseApplyController.java index fe93d84..6829946 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseApplyController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseApplyController.java @@ -313,4 +313,15 @@ public class UnionReimburseApplyController { public Result getBudgetBalance(String reimburseFundSource, String clubId) { return unionReimburseService.getBudgetBalance(reimburseFundSource, clubId, null); } + + /** + * 查询可申请社团经费报销的社团。 + * + * @return 全部未解散社团列表 + */ + @At + @SaCheckPermission(value = {"unionReimburse.apply", "h5.unionReimburse.apply"}, mode = SaMode.OR) + public Result listReimburseClubs() { + return Result.success(unionReimburseService.listReimburseClubs()); + } } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseCollectController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseCollectController.java index 4c88fba..cb0de5a 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseCollectController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseCollectController.java @@ -121,6 +121,16 @@ public class UnionReimburseCollectController { applyListSort(cnd, pageForm); sql.setCondition(cnd); Pagination pagination = unionReimburseService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql); + // 复用打印流水号,按当日全部申请排序,避免分页或筛选改变编号。 + for (NutMap row : pagination.getList()) { + UnionReimburse form = new UnionReimburse(); + form.setId(row.getString("id")); + // SQL映射可能返回日期字符串,兼容日期对象后再参与流水号计算。 + Object createTime = row.get("createTime"); + form.setCreateTime(createTime instanceof java.util.Date ? (java.util.Date) createTime + : (createTime == null ? null : DateUtil.parse(createTime.toString()))); + row.put("serialNo", unionReimburseExportService.buildPrintSerialNo(form)); + } return Result.success(pagination); } @@ -166,6 +176,7 @@ public class UnionReimburseCollectController { WHEN rei.reimburseProject = 'UNION_REIMBURSE_PROJECT_2' THEN '文体活动' WHEN rei.reimburseProject = 'UNION_REIMBURSE_PROJECT_3' THEN '日常活动' WHEN rei.reimburseProject = 'UNION_REIMBURSE_PROJECT_4' THEN '专项活动' + WHEN rei.reimburseProject = 'UNION_REIMBURSE_PROJECT_5' THEN '社团活动' ELSE rei.reimburseProject END as reimbursementItemName, u.mobile @@ -249,6 +260,8 @@ public class UnionReimburseCollectController { .orElse(null); if (projectDict != null) { item.setReimburseProject(projectDict.getName()); + } else if ("UNION_REIMBURSE_PROJECT_5".equals(item.getReimburseProject())) { + item.setReimburseProject("社团活动"); } } // 转换报销经费来源 diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseReviewController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseReviewController.java index 9a223ed..f2c7d08 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseReviewController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseReviewController.java @@ -2,6 +2,7 @@ package com.budwk.app.zhgh.dayofficework.unionReimburse.controller; import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaMode; +import cn.hutool.core.date.DateUtil; import cn.hutool.core.util.StrUtil; import com.budwk.app.base.annotation.SLog; import com.budwk.app.base.page.Pagination; @@ -10,6 +11,7 @@ import com.budwk.app.base.result.Result; import com.budwk.app.base.utils.PageUtil; import com.budwk.app.zhgh.dayofficework.unionReimburse.model.UnionReimburse; import com.budwk.app.zhgh.dayofficework.unionReimburse.service.UnionReimburseService; +import com.budwk.app.zhgh.dayofficework.unionReimburse.service.UnionReimburseExportService; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import org.nutz.aop.interceptor.ioc.TransAop; @@ -36,6 +38,9 @@ public class UnionReimburseReviewController { @Inject private UnionReimburseService unionReimburseService; + @Inject + private UnionReimburseExportService unionReimburseExportService; + @At("/index") @Ok("beetl:/platform/zhgh/dayofficework/unionReimburse/review/index.html") @SaCheckPermission("unionReimburse.review") @@ -88,6 +93,16 @@ public class UnionReimburseReviewController { cnd.desc("info.createdAt"); sql.setCondition(cnd); Pagination pagination = unionReimburseService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql); + // 复用打印流水号,按当日全部申请排序,避免分页或筛选改变编号。 + for (NutMap row : pagination.getList()) { + UnionReimburse form = new UnionReimburse(); + form.setId(row.getString("id")); + // SQL映射可能返回日期字符串,兼容日期对象后再参与流水号计算。 + Object createTime = row.get("createTime"); + form.setCreateTime(createTime instanceof java.util.Date ? (java.util.Date) createTime + : (createTime == null ? null : DateUtil.parse(createTime.toString()))); + row.put("serialNo", unionReimburseExportService.buildPrintSerialNo(form)); + } return Result.success(pagination); } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseExportService.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseExportService.java index fa63f76..7fc7e99 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseExportService.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseExportService.java @@ -6,8 +6,9 @@ import java.util.Map; /** 工会报销活动及慰问审批单共用的导出服务。 */ public interface UnionReimburseExportService { /** + * 我的慰问按类型使用专用模板,没有对应模板映射时拒绝导出,不回退通用慰问模板。 * @param id 当前登录人已提交的报销单ID,不接受其他人的单据或待提交单据 - * @return 完整PDF字节数组,模板或转换失败时抛出业务异常 + * @return 按慰问类型选择专用模板生成的完整PDF字节数组,活动沿用原模板;类型、模板或转换失败时抛出业务异常 */ byte[] exportPdf(String id); @@ -23,4 +24,11 @@ public interface UnionReimburseExportService { * @return 活动及慰问模板通用的平铺字段及 payments、invoices 循环行 */ Map buildExportData(UnionReimburse form); + + /** + * 列表与打印共用按申请日期及当日申请时间排序的流水号,历史申请参与相同排序。 + * @param form 报销申请,必须包含申请时间和单据ID + * @return 年月日加至少三位当日序号,例如20261009001;缺少必要字段或申请不存在时抛出业务异常 + */ + String buildPrintSerialNo(UnionReimburse form); } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseService.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseService.java index 138f5a0..2d272ba 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseService.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseService.java @@ -58,10 +58,17 @@ public interface UnionReimburseService extends BaseService { Result recognizeInvoice(String fileId, Boolean verifySwitch); /** - * 查询报销可用经费余额,同时校验是否已分配以及协会归属权限。 + * 查询报销可用经费余额,同时校验是否已分配、经费来源权限及社团有效性。 */ Result getBudgetBalance(String reimburseFundSource, String clubId, String unionId); + /** + * 查询未解散社团,供社团经费报销选择。 + * + * @return 全部未解散社团的ID、名称和编码列表 + */ + List listReimburseClubs(); + /** * 审核报销,审核通过时扣减对应预算并新增预算使用详情。 */ diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseExportServiceImpl.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseExportServiceImpl.java index 6cca083..46bedab 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseExportServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseExportServiceImpl.java @@ -7,6 +7,8 @@ import com.budwk.app.base.constant.RoleConstant; import com.budwk.app.web.commons.auth.utils.AuthUtil; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; +import org.nutz.dao.Sqls; +import org.nutz.dao.sql.Sql; import com.budwk.app.base.exception.BaseException; import com.budwk.app.base.utils.MoneyUtil; import com.budwk.app.base.utils.OfficePlusUtil; @@ -20,23 +22,10 @@ import com.budwk.app.zhgh.staffbenefit.condolence.model.CondolenceType; import com.deepoove.poi.XWPFTemplate; import com.deepoove.poi.config.Configure; import com.deepoove.poi.plugin.table.LoopRowTableRenderPolicy; -import com.deepoove.poi.data.PictureType; -import com.deepoove.poi.data.Pictures; -import com.deepoove.poi.template.ElementTemplate; -import com.google.zxing.*; -import com.google.zxing.client.j2se.BufferedImageLuminanceSource; -import com.google.zxing.client.j2se.MatrixToImageWriter; -import com.google.zxing.common.HybridBinarizer; -import com.google.zxing.qrcode.QRCodeWriter; -import com.google.zxing.qrcode.QRCodeReader; -import org.apache.poi.xwpf.usermodel.*; -import org.apache.poi.util.Units; -import javax.imageio.ImageIO; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; import lombok.extern.slf4j.Slf4j; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.lang.util.NutMap; import java.io.InputStream; import java.io.OutputStream; import java.math.BigDecimal; @@ -51,7 +40,7 @@ public class UnionReimburseExportServiceImpl implements UnionReimburseExportServ public static final String ACTIVITY_TEMPLATE = "union_reimburse_activity_pdf"; public static final String CONDOLENCE_TEMPLATE = "union_reimburse_condolence_pdf"; // 使用慰问类型业务编码匹配模板,避免类型名称调整后选择错误;编码与现有申请页一致。 - private static final Map COLLECT_CONDOLENCE_TEMPLATES = Map.of( + private static final Map CONDOLENCE_TEMPLATES = Map.of( "A", "union_reimburse_condolence_marriage_pdf", "D", "union_reimburse_condolence_birth_pdf", "E", "union_reimburse_condolence_hospital_pdf", @@ -64,7 +53,7 @@ public class UnionReimburseExportServiceImpl implements UnionReimburseExportServ @Inject private Dao dao; - /** 当前“我的报销”导出仅允许单据经办人,状态白名单与页面按钮保持一致。 */ + /** 当前“我的报销”导出仅允许单据经办人,状态白名单与页面按钮保持一致,慰问按类型使用专用模板。 */ @Override public byte[] exportPdf(String id) { return renderDocument(loadExportForm(id, false, false), true, false); @@ -112,29 +101,34 @@ public class UnionReimburseExportServiceImpl implements UnionReimburseExportServ } /** - * 汇总页五类慰问使用独立模板,其余类型和我的报销沿用原模板。 + * 我的报销与汇总页共用五类慰问专用模板映射,活动沿用原模板。 * @param form 已通过数据权限校验的报销单,使用慰问类型 ID 读取业务编码 - * @param collectPrint 是否为汇总页打印,只有该入口启用分类模板 - * @return 系统导出模板管理中的模板编码;类型关联缺失时提示核对,不猜测类型名称 + * @param collectPrint 是否为汇总页打印,仅该入口保留五类之外慰问的原通用模板兜底 + * @return 系统导出模板管理中的模板编码;类型关联缺失或我的慰问无专用模板映射时抛出业务异常 */ private String resolveTemplateCode(UnionReimburse form, boolean collectPrint) { if (!"UNION_REIMBURSE_PROJECT_1".equals(form.getReimburseProject())) { return ACTIVITY_TEMPLATE; } - if (!collectPrint) { - return CONDOLENCE_TEMPLATE; - } // 兼容旧申请将类型 ID 保存在 condolenceType 的情况,不回写历史数据。 String typeId = StrUtil.blankToDefault(form.getCondolenceTypeId(), form.getCondolenceType()); CondolenceType type = StrUtil.isBlank(typeId) ? null : dao.fetch(CondolenceType.class, typeId); if (type == null || StrUtil.isBlank(type.getCode())) { throw new BaseException("慰问类型不存在或编码为空,请核对后打印"); } - // 五类之外的既有慰问继续使用通用模板;已匹配的专用模板缺失时由模板加载器明确报错。 - return COLLECT_CONDOLENCE_TEMPLATES.getOrDefault(type.getCode(), CONDOLENCE_TEMPLATE); + // 两个入口按相同业务编码选择专用模板,模板文件缺失时由模板加载器明确报错。 + String templateCode = CONDOLENCE_TEMPLATES.get(type.getCode()); + if (templateCode != null) { + return templateCode; + } + // 我的慰问禁止回退通用模板,未配置映射的类型明确提示;汇总页保留原有兜底规则。 + if (!collectPrint) { + throw new BaseException("当前慰问类型未配置专用导出模板,请联系管理员核对"); + } + return CONDOLENCE_TEMPLATE; } - /** 模板共用字段和循环行配置;汇总打印按慰问类型选模板并补充编号二维码。 */ + /** 两个入口按慰问类型选模板并共用流水号字段和循环行配置,不再生成二维码。 */ private byte[] renderDocument(UnionReimburse form, boolean asPdf, boolean collectPrint) { String templateCode = resolveTemplateCode(form, collectPrint); Path docx = null; @@ -152,9 +146,6 @@ public class UnionReimburseExportServiceImpl implements UnionReimburseExportServ XWPFTemplate template = XWPFTemplate.compile(input, config); OutputStream output = Files.newOutputStream(docx)) { Map data = buildExportData(form); - if (collectPrint) { - prepareCollectQrCode(template, data, form.getDocumentNo()); - } template.render(data).write(output); } if (!asPdf) { @@ -188,71 +179,41 @@ public class UnionReimburseExportServiceImpl implements UnionReimburseExportServ } /** - * 汇总打印沿用老系统的单据编号二维码,仅修改本次生成的文档,不覆盖模板。 - * @param template 当前已编译模板,优先使用 qrcode 图片标记,其次替换标题区的固定二维码 - * @param data 本次渲染数据,qrcode 字段为 80×80 的 PNG 图片对象 - * @param documentNo 原始单据编号,缺失时拒绝打印,避免用业务 ID 冒充编号 + * 按申请日期和当日申请时间顺序生成打印流水号,六类模板使用同一编号口径。 + * 历史单据也按申请时间参与排序,同一秒内用创建时间及主键确定顺序; + * 不按打印顺序编号,也不按当前用户或报销状态过滤申请。 + * + * @param form 报销申请,使用其单据ID读取数据库中的申请日期 + * @return 年月日加至少三位当日序号,例如 20261009001 */ - private void prepareCollectQrCode(XWPFTemplate template, Map data, - String documentNo) throws Exception { - if (StrUtil.isBlank(documentNo)) { - throw new BaseException("报销单编号为空,无法生成打印二维码,请先核对单据编号"); + @Override + public String buildPrintSerialNo(UnionReimburse form) { + if (form == null || StrUtil.isBlank(form.getId())) { + throw new BaseException("报销单据ID为空,无法生成打印流水号"); } - var matrix = new QRCodeWriter().encode(documentNo, BarcodeFormat.QR_CODE, 80, 80); - var image = MatrixToImageWriter.toBufferedImage(matrix); - data.put("qrcode", Pictures.ofBufferedImage(image, PictureType.PNG).size(80, 80).create()); - boolean hasTag = template.getElementTemplates().stream() - .anyMatch(item -> item instanceof ElementTemplate - && "qrcode".equals(((ElementTemplate) item).getTagName())); - if (hasTag) { - return; + // 日期范围和编号前缀均取数据库原值,避免Java日期转换及参数绑定影响当天范围。 + // 先对目标申请当天的全部单据排序,再筛选目标ID,保持审核、汇总和打印的编号一致。 + Sql sql = Sqls.create(""" + SELECT DATE_FORMAT(numbered.createTime, '%Y%m%d') AS serialDate, numbered.seq + FROM ( + SELECT info.id, info.createTime, + ROW_NUMBER() OVER (ORDER BY info.createTime, info.createdAt, info.id) AS seq + FROM union_reimburse info + INNER JOIN union_reimburse target ON target.id = @id + WHERE info.createTime >= DATE(target.createTime) + AND info.createTime < DATE_ADD(DATE(target.createTime), INTERVAL 1 DAY) + ) numbered + WHERE numbered.id = @id + """); + sql.setParam("id", form.getId()); + sql.setCallback(Sqls.callback.map()); + dao.execute(sql); + NutMap result = sql.getObject(NutMap.class); + if (result == null || !(result.get("seq") instanceof Number sequence) + || sequence.longValue() < 1 || StrUtil.isBlank(result.getString("serialDate"))) { + throw new BaseException("未找到报销申请或申请时间为空,无法生成打印流水号"); } - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - ImageIO.write(image, "png", bytes); - XWPFDocument document = template.getXWPFDocument(); - // 部分现用模板内嵌了固定二维码。仅识别第一张业务表格之前的二维码,保留徽标等普通图片。 - boolean replaced = false; - for (IBodyElement element : document.getBodyElements()) { - if (element instanceof XWPFTable) break; - if (!(element instanceof XWPFParagraph)) continue; - for (XWPFRun run : ((XWPFParagraph) element).getRuns()) { - for (XWPFPicture picture : run.getEmbeddedPictures()) { - if (picture.getPictureData() == null) continue; - var existing = ImageIO.read(new ByteArrayInputStream(picture.getPictureData().getData())); - if (existing == null) continue; - try { - new QRCodeReader().decode(new BinaryBitmap(new HybridBinarizer( - new BufferedImageLuminanceSource(existing)))); - } catch (NotFoundException | ChecksumException | FormatException e) { - continue; - } - String relationship = document.addPictureData(bytes.toByteArray(), Document.PICTURE_TYPE_PNG); - picture.getCTPicture().getBlipFill().getBlip().setEmbed(relationship); - replaced = true; - } - } - } - if (replaced) return; - // 无二维码标记或固定二维码的模板,在正文开头添加右对齐图片,不依赖模板重新上传。 - XWPFParagraph paragraph; - if (document.getBodyElements().isEmpty()) { - paragraph = document.createParagraph(); - } else { - IBodyElement first = document.getBodyElements().get(0); - var cursor = first instanceof XWPFParagraph - ? ((XWPFParagraph) first).getCTP().newCursor() - : ((XWPFTable) first).getCTTbl().newCursor(); - try { - paragraph = document.insertNewParagraph(cursor); - } finally { - cursor.dispose(); - } - } - paragraph.setAlignment(ParagraphAlignment.RIGHT); - paragraph.setSpacingBefore(0); - paragraph.setSpacingAfter(0); - paragraph.createRun().addPicture(new ByteArrayInputStream(bytes.toByteArray()), - Document.PICTURE_TYPE_PNG, "qrcode.png", Units.pixelToEMU(80), Units.pixelToEMU(80)); + return result.getString("serialDate") + String.format(Locale.ROOT, "%03d", sequence.longValue()); } /** 平铺字段供两类模板复用,支付总额与发票总额分别计算,不使用第一条支付代替全部明细。 */ @@ -261,8 +222,11 @@ public class UnionReimburseExportServiceImpl implements UnionReimburseExportServ Map data = new LinkedHashMap<>(); data.put("date", date(form.getCreateTime())); data.put("no", text(form.getDocumentNo())); + data.put("serialNo", buildPrintSerialNo(form)); String unit = text(form.getUnitName()); - data.put("union", text(form.getUnionName()) + (unit.isEmpty() ? "" : "(" + unit + ")")); + data.put("union", "UNION_REIMBURSE_FUND_SOURCE_3".equals(form.getReimburseFundSource()) + ? text(form.getClubName()) + : text(form.getUnionName()) + (unit.isEmpty() ? "" : "(" + unit + ")")); data.put("handler", text(form.getUserName())); data.put("login", text(form.getLoginName())); data.put("phone", text(form.getMobile())); @@ -271,6 +235,9 @@ public class UnionReimburseExportServiceImpl implements UnionReimburseExportServ + ("UNION_REIMBURSE_PROJECT_1".equals(project) ? "√" : "□") + "慰问 " + ("UNION_REIMBURSE_PROJECT_3".equals(project) ? "√" : "□") + "日常活动 " + ("UNION_REIMBURSE_PROJECT_4".equals(project) ? "√" : "□") + "专项活动"); + if ("UNION_REIMBURSE_PROJECT_5".equals(project)) { + data.put("project", "√社团活动"); + } data.put("activity", text(form.getActivityName())); data.put("reason", text(form.getPaymentNotes())); data.put("notes", text(form.getNotes())); diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseServiceImpl.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseServiceImpl.java index e400313..fbaa7f7 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseServiceImpl.java @@ -11,7 +11,6 @@ import com.budwk.app.base.service.impl.BaseServiceImpl; import com.budwk.app.sys.models.Sys_file; import com.budwk.app.sys.views.View_user; import com.budwk.app.sys.services.SysFileService; -import com.budwk.app.zhgh.club.model.ClubUser; import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.club.model.OutlayManageClub; import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.model.OutlayUseDetail; import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.school.model.OutlayManageSchool; @@ -148,6 +147,10 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i if (amountResult != null) { return amountResult; } + Result activityAmountResult = validateActivityApplyMoney(unionReimburse); + if (activityAmountResult != null) { + return activityAmountResult; + } UnionReimburse oldRecord = null; if (StrUtil.isNotBlank(unionReimburse.getId())) { oldRecord = this.fetch(unionReimburse.getId()); @@ -159,6 +162,18 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i } } + Result sourceResult = validateApplicantFundSource(unionReimburse); + if (sourceResult != null) { + return sourceResult; + } + if (oldRecord != null + && ("UNION_REIMBURSE_FUND_SOURCE_3".equals(oldRecord.getReimburseFundSource()) + || "UNION_REIMBURSE_FUND_SOURCE_3".equals(unionReimburse.getReimburseFundSource())) + && !Integer.valueOf(1).equals(oldRecord.getStateId()) + && !Integer.valueOf(5).equals(oldRecord.getStateId())) { + return Result.error("社团报销仅允许修改草稿或退回的申请"); + } + // 人员身份和敏感资料由服务端补齐,避免前端删减字段导致丢失或被篡改。 if (oldRecord == null) { View_user handler = dao().fetch(View_user.class, Cnd.where("id", "=", SecurityUtil.getUserId())); @@ -487,10 +502,19 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i } /** - * 按经费来源统一查询当前可用余额,未分配或权限不匹配时直接拦截。 + * 按经费来源统一查询当前可用余额,经费来源权限由前端按钮控制。 */ @Override public Result getBudgetBalance(String reimburseFundSource, String clubId, String unionId) { + if ("UNION_REIMBURSE_FUND_SOURCE_3".equals(reimburseFundSource) + && listReimburseClubs().stream().noneMatch(club -> Objects.equals(clubId, club.getString("id")))) { + return Result.error("请选择有效的社团"); + } + return queryBudgetBalance(reimburseFundSource, clubId, unionId); + } + + // 审核人按申请单的经费归属校验预算,不要求审核人同时是该社团负责人。 + private Result queryBudgetBalance(String reimburseFundSource, String clubId, String unionId) { if (StrUtil.isBlank(reimburseFundSource)) { return Result.error("请选择经费来源"); } @@ -515,16 +539,13 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i if (StrUtil.isBlank(clubId)) { return Result.error("请选择协会"); } - List myClubIds = getCurrentUserClubIds(); - if (!isAdmin() && !myClubIds.contains(clubId)) { - return Result.error("当前用户无权使用该协会经费"); - } + OutlayManageClub outlayManageClub = dao().fetch(OutlayManageClub.class, Cnd.where("clubId", "=", clubId).and("year", "=", DateUtil.thisYear())); if (outlayManageClub == null) { return Result.error("该协会本年度未生成预算记录,请先在协会预算分配中补分配"); } - return Result.success(formatMoney(outlayManageClub.getTotalQuota().subtract(outlayManageClub.getUsedQuota()))); + return Result.success(formatMoney(safeMoney(outlayManageClub.getTotalQuota()).subtract(safeMoney(outlayManageClub.getUsedQuota())))); } return Result.error("不支持的经费来源"); } @@ -533,6 +554,7 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i * 审核通过时统一校验余额、扣减预算并写入预算使用详情,避免控制器分散业务逻辑。 */ @Override + @Aop(TransAop.READ_COMMITTED) public Result reviewApply(String reimburseId, String reviewOpinion, int submitType) { if (!List.of(3, 4, 5).contains(submitType)) { return Result.error("无效的审核操作类型"); @@ -541,6 +563,25 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i if (dbRecord == null) { return Result.error("记录不存在"); } + if ("UNION_REIMBURSE_FUND_SOURCE_3".equals(dbRecord.getReimburseFundSource())) { + // 同一申请审核串行处理,避免重复点击导致社团已使用额度累计两次。 + Sql lockSql = Sqls.create("SELECT * FROM union_reimburse WHERE id=@id FOR UPDATE"); + lockSql.setParam("id", reimburseId); + lockSql.setEntity(dao().getEntity(UnionReimburse.class)); + lockSql.setCallback(Sqls.callback.entities()); + dao().execute(lockSql); + List records = lockSql.getList(UnionReimburse.class); + if (records.isEmpty()) { + return Result.error("记录不存在"); + } + dbRecord = records.get(0); + if (submitType == 3 && Integer.valueOf(3).equals(dbRecord.getStateId())) { + return Result.success(); + } + if (!Integer.valueOf(2).equals(dbRecord.getStateId())) { + return Result.error("仅可审核待审核确认的社团报销"); + } + } if (submitType == 3) { // 已通过的单据再次触发通过时直接返回,避免重复扣减预算。 if (Integer.valueOf(3).equals(dbRecord.getStateId())) { @@ -668,8 +709,43 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i unionReimburse.setInvoiceDetails(invoiceDetails); } + private boolean isManualReimburseAmount(UnionReimburse form) { + return "UNION_REIMBURSE_PROJECT_2".equals(form.getReimburseProject()) + || "UNION_REIMBURSE_PROJECT_3".equals(form.getReimburseProject()) + || "UNION_REIMBURSE_PROJECT_4".equals(form.getReimburseProject()); + } + /** - * 有发票时汇总票面金额和张数;无发票时按已校验的支付明细汇总金额,张数为零。 + * 文体、日常、专项活动保留手填报销金额,有发票时限制票面总额,无发票不设此上限。 + * @param form 已通过发票和支付信息校验的申请 + * @return 金额必填、精度或上限校验不通过时返回错误,通过返回null + */ + private Result validateActivityApplyMoney(UnionReimburse form) { + if (!isManualReimburseAmount(form)) { + return null; + } + Double money = form.getMoney(); + if (money == null) { + return Result.error("请填写报销金额"); + } + if (!Double.isFinite(money) || money <= 0 + || BigDecimal.valueOf(money).stripTrailingZeros().scale() > 2) { + return Result.error("报销金额必须为正数,且最多两位小数"); + } + if (Lang.isNotEmpty(form.getInvoiceDetails())) { + BigDecimal invoiceTotal = BigDecimal.ZERO; + for (UnionReimburseInvoiceDetail detail : form.getInvoiceDetails()) { + invoiceTotal = invoiceTotal.add(BigDecimal.valueOf(detail.getInvoiceAmount()).setScale(2, RoundingMode.HALF_UP)); + } + if (BigDecimal.valueOf(money).compareTo(invoiceTotal) > 0) { + return Result.error("报销金额不能超过发票总额(" + invoiceTotal.toPlainString() + "元)"); + } + } + return null; + } + + /** + * 汇总票面金额和张数;三类活动保留手填金额,其余项目无发票时按支付明细汇总。 */ private void fillInvoiceSummary(UnionReimburse unionReimburse) { double totalMoney = 0D; @@ -692,7 +768,10 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i } totalMoney = paymentTotal.setScale(2, RoundingMode.HALF_UP).doubleValue(); } - unionReimburse.setMoney(BigDecimal.valueOf(totalMoney).setScale(2, RoundingMode.HALF_UP).doubleValue()); + // 三类活动使用已校验的手填申请金额,不能被发票或支付合计覆盖。 + if (!isManualReimburseAmount(unionReimburse)) { + unionReimburse.setMoney(BigDecimal.valueOf(totalMoney).setScale(2, RoundingMode.HALF_UP).doubleValue()); + } unionReimburse.setInvoiceNumber(String.valueOf(invoiceFileCount)); } @@ -826,7 +905,11 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i if (unionReimburse.getActivityTime() == null) { return Result.error("请选择活动时间"); } - if (Lang.isEmpty(unionReimburse.getFiles())) { + // 文体、日常和社团活动附件选填,其他活动保留附件必填校验。 + if (!"UNION_REIMBURSE_PROJECT_2".equals(unionReimburse.getReimburseProject()) + && !"UNION_REIMBURSE_PROJECT_3".equals(unionReimburse.getReimburseProject()) + && !"UNION_REIMBURSE_PROJECT_5".equals(unionReimburse.getReimburseProject()) + && Lang.isEmpty(unionReimburse.getFiles())) { return Result.error("请上传附件"); } return validateInvoiceDetails(unionReimburse); @@ -929,7 +1012,7 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i * 提交和审核前统一校验预算是否已分配、余额是否充足。 */ private Result validateFundSourceBeforeSubmit(UnionReimburse unionReimburse) { - Result balanceResult = getBudgetBalance(unionReimburse.getReimburseFundSource(), unionReimburse.getClubId(), unionReimburse.getUnionId()); + Result balanceResult = queryBudgetBalance(unionReimburse.getReimburseFundSource(), unionReimburse.getClubId(), unionReimburse.getUnionId()); if (balanceResult == null || balanceResult.getCode() != 0) { return balanceResult == null ? Result.error("经费余额校验失败") : balanceResult; } @@ -985,11 +1068,19 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i if (outlayManageClub == null) { return Result.error("该协会本年度未生成预算记录,请先在协会预算分配中补分配"); } - if (outlayManageClub.getTotalQuota().subtract(outlayManageClub.getUsedQuota()).compareTo(applyMoney) < 0) { - return Result.error("协会经费余额不足"); + // 本年度分配总额度保持不变,审批金额原子累加至已使用额度,余额由两者相减得到。 + Sql deductSql = Sqls.create(""" + UPDATE outlay_manage_club + SET usedQuota = COALESCE(usedQuota, 0) + @amount + WHERE id = @id + AND COALESCE(totalQuota, 0) - COALESCE(usedQuota, 0) >= @amount + """); + deductSql.setParam("id", outlayManageClub.getId()); + deductSql.setParam("amount", applyMoney); + dao().execute(deductSql); + if (deductSql.getUpdateCount() != 1) { + return Result.error("社团经费余额不足"); } - outlayManageClub.setUsedQuota(outlayManageClub.getUsedQuota().add(applyMoney)); - dao().updateIgnoreNull(outlayManageClub); insertUseDetail(unionReimburse, outlayManageClub.getId(), applyMoney); return Result.success(); } @@ -1201,6 +1292,9 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i if ("UNION_REIMBURSE_PROJECT_4".equals(reimburseProject)) { return "专项活动申请"; } + if ("UNION_REIMBURSE_PROJECT_5".equals(reimburseProject)) { + return "社团活动申请"; + } return "报销申请"; } @@ -1208,16 +1302,38 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i return money == null ? 0D : money.setScale(2, RoundingMode.HALF_UP).doubleValue(); } - private List getCurrentUserClubIds() { - if (isAdmin()) { - return new ArrayList<>(); + /** + * 查询未解散社团,社团选择和余额查询不按负责人归属过滤。 + * + * @return 全部未解散社团的ID、名称和编码 + */ + @Override + public List listReimburseClubs() { + Sql sql = Sqls.create(""" + SELECT c.id, c.clubName, c.clubCode + FROM sys_club c + WHERE c.dismiss = 0 + ORDER BY c.clubCode, c.id + """); + return listMap(sql); + } + + private Result validateApplicantFundSource(UnionReimburse form) { + if ("UNION_REIMBURSE_FUND_SOURCE_3".equals(form.getReimburseFundSource())) { + if (!"UNION_REIMBURSE_PROJECT_5".equals(form.getReimburseProject())) { + return Result.error("社团经费请选择社团活动"); + } + NutMap club = listReimburseClubs().stream() + .filter(item -> Objects.equals(form.getClubId(), item.getString("id"))) + .findFirst().orElse(null); + if (club == null) { + return Result.error("请选择有效的社团"); + } + form.setClubName(club.getString("clubName")); + } else if ("UNION_REIMBURSE_PROJECT_5".equals(form.getReimburseProject())) { + return Result.error("社团活动只能使用社团经费"); } - List clubUsers = dao().query(ClubUser.class, Cnd.where("userId", "=", SecurityUtil.getUserId())); - return clubUsers.stream() - .map(ClubUser::getClubId) - .filter(StrUtil::isNotBlank) - .distinct() - .collect(Collectors.toList()); + return null; } private Result validateInvoiceDuplicate(UnionReimburse unionReimburse) { diff --git a/src/main/java/com/budwk/app/zhgh/democratic/proposal/controller/transact/ProposalFeedbackEvaluationController.java b/src/main/java/com/budwk/app/zhgh/democratic/proposal/controller/transact/ProposalFeedbackEvaluationController.java index 4b1f27f..2735791 100644 --- a/src/main/java/com/budwk/app/zhgh/democratic/proposal/controller/transact/ProposalFeedbackEvaluationController.java +++ b/src/main/java/com/budwk/app/zhgh/democratic/proposal/controller/transact/ProposalFeedbackEvaluationController.java @@ -95,9 +95,9 @@ public class ProposalFeedbackEvaluationController { Cnd cnd = Cnd.NEW(); cnd.and("t.taskName", "=", "feedback"); - if (!AuthUtil.hasRole(RoleConstant.SYSADMIN.name())) { - cnd.and("ta.actorId", "in", List.of(SecurityUtil.getUserId())); - } + /* if (!AuthUtil.hasRole(RoleConstant.SYSADMIN.name())) { + }*/ + cnd.and("ta.actorId", "in", List.of(SecurityUtil.getUserId())); if (approval) { cnd.and("t.taskState", "in", List.of(ProcessTaskStateEnum.FINISHED.getCode(), ProcessTaskStateEnum.INTERRUPT.getCode())); diff --git a/src/main/java/com/budwk/app/zhgh/democratic/proposal/h5controller/H5ProposalCommonController.java b/src/main/java/com/budwk/app/zhgh/democratic/proposal/h5controller/H5ProposalCommonController.java index 2607617..eee8eb6 100644 --- a/src/main/java/com/budwk/app/zhgh/democratic/proposal/h5controller/H5ProposalCommonController.java +++ b/src/main/java/com/budwk/app/zhgh/democratic/proposal/h5controller/H5ProposalCommonController.java @@ -1,10 +1,15 @@ package com.budwk.app.zhgh.democratic.proposal.h5controller; import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.hutool.core.util.StrUtil; +import com.budwk.app.base.result.Result; +import com.budwk.app.zhgh.democratic.proposal.service.ProposalWriteService; import io.swagger.annotations.Api; +import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.Ok; +import org.nutz.mvc.annotation.Param; @IocBean @At("/platform/h5/proposal/common") @@ -12,6 +17,24 @@ import org.nutz.mvc.annotation.Ok; @Api(tags = "h5提案公共") public class H5ProposalCommonController { + @Inject + private ProposalWriteService proposalWriteService; + + /** + * 查询当前登录用户在指定届次下的代表身份,不以是否已分配代表团作为判断依据。 + * + * @param sessionId 当前选择或已有提案所属的教代会届次ID,不能为空 + * @return Result 的 data 为 Boolean:true 表示存在未删除的代表记录,false 表示不是该届次代表 + */ + @At + @SaCheckPermission("h5.proposal.write") + public Result isCurrentUserDelegate(@Param("sessionId") String sessionId) { + if (StrUtil.isBlank(sessionId)) { + return Result.error("所属教代会不能为空"); + } + return Result.success().addData(proposalWriteService.isCurrentUserDelegate(sessionId)); + } + @At("") @Ok("beetl:/platform/zhghh5/democratic/proposal/common/info/index.html") @SaCheckPermission("h5.proposal") diff --git a/src/main/java/com/budwk/app/zhgh/democratic/proposal/service/ProposalWriteService.java b/src/main/java/com/budwk/app/zhgh/democratic/proposal/service/ProposalWriteService.java index ae07bd5..3e1ff0c 100644 --- a/src/main/java/com/budwk/app/zhgh/democratic/proposal/service/ProposalWriteService.java +++ b/src/main/java/com/budwk/app/zhgh/democratic/proposal/service/ProposalWriteService.java @@ -49,5 +49,13 @@ public interface ProposalWriteService extends BaseService { */ void checkCurrentUserDelegate(String sessionId); + /** + * 查询当前登录用户是否为指定教代会届次的有效代表。 + * + * @param sessionId 教代会届次ID,不能为空;用户身份由服务端登录信息取得 + * @return true 表示存在未删除的代表记录,false 表示不存在;不要求已分配代表团 + */ + boolean isCurrentUserDelegate(String sessionId); + ProposalInfo importProposal(Map stringStringMap); } diff --git a/src/main/java/com/budwk/app/zhgh/democratic/proposal/service/impl/ProposalWriteServiceImpl.java b/src/main/java/com/budwk/app/zhgh/democratic/proposal/service/impl/ProposalWriteServiceImpl.java index adc755b..ea8b6ba 100644 --- a/src/main/java/com/budwk/app/zhgh/democratic/proposal/service/impl/ProposalWriteServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/democratic/proposal/service/impl/ProposalWriteServiceImpl.java @@ -194,6 +194,19 @@ public class ProposalWriteServiceImpl extends BaseServiceImpl impl @Override public void checkCurrentUserDelegate(String sessionId) { + if (!isCurrentUserDelegate(sessionId)) { + throw new RuntimeException("当前用户不是代表,无法撰写提案"); + } + } + + /** + * 入口查询与保存、提交校验共用同一代表身份口径,排除已删除记录。 + * + * @param sessionId 教代会届次ID,空值抛出异常,避免被误判为非代表 + * @return 是否存在当前登录用户在该届次的有效代表记录 + */ + @Override + public boolean isCurrentUserDelegate(String sessionId) { if (StrUtil.isBlank(sessionId)) { throw new RuntimeException("所属教代会不能为空"); } @@ -204,9 +217,7 @@ public class ProposalWriteServiceImpl extends BaseServiceImpl impl .and(Teacher_congress_delegate::getDelFlag, "!=", 1) .limit(1) ); - if (delegate == null) { - throw new RuntimeException("当前用户不是代表,无法撰写提案"); - } + return delegate != null; } @Override diff --git a/src/main/java/com/budwk/app/zhgh/democratic/teachercongress/delegate/controller/TeacherCongressDelegateManageController.java b/src/main/java/com/budwk/app/zhgh/democratic/teachercongress/delegate/controller/TeacherCongressDelegateManageController.java index 9411a93..d558cba 100644 --- a/src/main/java/com/budwk/app/zhgh/democratic/teachercongress/delegate/controller/TeacherCongressDelegateManageController.java +++ b/src/main/java/com/budwk/app/zhgh/democratic/teachercongress/delegate/controller/TeacherCongressDelegateManageController.java @@ -36,7 +36,6 @@ import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.dao.*; import org.nutz.dao.sql.Sql; import org.nutz.dao.util.Daos; -import org.nutz.dao.util.cri.SqlExpressionGroup; import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; @@ -201,43 +200,16 @@ public class TeacherCongressDelegateManageController { } /** - * 代表调整搜索用户 + * 按姓名或工号搜索可添加到代表调整列表的人员。 * - * @param param - * @return + * @param param sessionId 为必填的目标届次ID;keyWord 为必填的姓名或工号关键字,候选人员不限定为本届已有代表 + * @return Result,其 data 为最多10条人员记录;id、userId 均为用户ID,userName、loginName、sex、unionName、unitName 分别为姓名、工号、性别、工会和单位 */ @At @SaCheckPermission("tc.delegate.manage") @Ok("json:{ignoreNull:true}") public Result adjustUserList(@Valid TeacherCongressDelegateManageSearchAdjustUserParam param) { -// Dao extDao = Daos.ext(dao, FieldFilter.create(Teacher_congress_delegate.class, "^id|userId|userName|loginName|unitName$")); -// SqlExpressionGroup seg = new SqlExpressionGroup(); -// seg.orLike("userName", param.getKeyWord()); -// seg.orLike("loginName", param.getKeyWord()); -// List list = extDao.query(Teacher_congress_delegate.class, Cnd.where("sessionId", "=", param.getSessionId()).and(seg).limit(1, 10)); -// return Result.success(list); - Sql sql = Sqls.create(""" - SELECT - u.id, - u.id AS userId, - u.username AS userName, - u.loginname AS loginName, - u.sex, - u.unionName, - u.unitName - FROM - vw_user u - LEFT JOIN teacher_congress_delegate tcd ON tcd.userId = u.id - $condition - """); - Cnd cnd = Cnd.NEW(); - SqlExpressionGroup seg = new SqlExpressionGroup(); - seg.orLike("u.username", param.getKeyWord()); - seg.orLike("u.loginname", param.getKeyWord()); - cnd.and(seg); - sql.setCondition(cnd); - Pagination pagination = sysUserService.listPageMap(1, 10, sql); - return Result.success(pagination.getList()); + return Result.success(teacherDelegateService.listAdjustUsers(param)); } /** diff --git a/src/main/java/com/budwk/app/zhgh/democratic/teachercongress/delegate/service/TeacherCongressDelegateService.java b/src/main/java/com/budwk/app/zhgh/democratic/teachercongress/delegate/service/TeacherCongressDelegateService.java index 2d1f236..209d800 100644 --- a/src/main/java/com/budwk/app/zhgh/democratic/teachercongress/delegate/service/TeacherCongressDelegateService.java +++ b/src/main/java/com/budwk/app/zhgh/democratic/teachercongress/delegate/service/TeacherCongressDelegateService.java @@ -5,6 +5,7 @@ import com.budwk.app.base.service.BaseService; import com.budwk.app.zhgh.democratic.teachercongress.delegate.models.Teacher_congress_delegate; import com.budwk.app.zhgh.democratic.teachercongress.delegate.param.TeacherCongressDelegateManagePageParam; import com.budwk.app.zhgh.democratic.teachercongress.delegate.param.TeacherCongressDelegateManagePublicUserParam; +import com.budwk.app.zhgh.democratic.teachercongress.delegate.param.TeacherCongressDelegateManageSearchAdjustUserParam; import org.nutz.lang.util.NutMap; import javax.servlet.http.HttpServletResponse; @@ -31,6 +32,14 @@ public interface TeacherCongressDelegateService extends BaseService listPublicUsers(TeacherCongressDelegateManagePublicUserParam param); + /** + * 按姓名或工号搜索代表调整候选人员,包含尚未成为本届代表的用户。 + * + * @param param sessionId 为目标届次ID,不用于限制候选人员;keyWord 为姓名或工号关键字 + * @return 最多10条人员记录;id、userId 均为用户ID,userName、loginName、sex、unionName、unitName 分别为姓名、工号、性别、工会和单位 + */ + List listAdjustUsers(TeacherCongressDelegateManageSearchAdjustUserParam param); + /** * 根据系统配置的单位或分工会组成关系,为尚未归团的代表自动分配代表团。 * diff --git a/src/main/java/com/budwk/app/zhgh/democratic/teachercongress/delegate/service/TeacherCongressDelegateServiceImpl.java b/src/main/java/com/budwk/app/zhgh/democratic/teachercongress/delegate/service/TeacherCongressDelegateServiceImpl.java index ec3df4a..0ca66e5 100644 --- a/src/main/java/com/budwk/app/zhgh/democratic/teachercongress/delegate/service/TeacherCongressDelegateServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/democratic/teachercongress/delegate/service/TeacherCongressDelegateServiceImpl.java @@ -21,6 +21,7 @@ import com.budwk.app.zhgh.democratic.proposal.service.common.ProposalCommonServi import com.budwk.app.zhgh.democratic.teachercongress.delegate.models.Teacher_congress_delegate; import com.budwk.app.zhgh.democratic.teachercongress.delegate.param.TeacherCongressDelegateManagePageParam; import com.budwk.app.zhgh.democratic.teachercongress.delegate.param.TeacherCongressDelegateManagePublicUserParam; +import com.budwk.app.zhgh.democratic.teachercongress.delegate.param.TeacherCongressDelegateManageSearchAdjustUserParam; import com.budwk.app.zhgh.democratic.teachercongress.delegate.vo.TeacherCongressDelegateExcelVO; import com.budwk.app.zhgh.democratic.teachercongress.prepare.models.Teacher_congress_session; import com.budwk.app.zhgh.democratic.teachercongress.prepare.service.TeacherCongressSessionService; @@ -107,6 +108,35 @@ public class TeacherCongressDelegateServiceImpl extends BaseServiceImpl listAdjustUsers(TeacherCongressDelegateManageSearchAdjustUserParam param) { + Sql sql = Sqls.create(""" + SELECT + u.id, + u.id AS userId, + u.username AS userName, + u.loginname AS loginName, + u.sex, + u.unionName, + u.unitName + FROM vw_user u + $condition + """); + SqlExpressionGroup seg = new SqlExpressionGroup(); + seg.orLike("u.username", param.getKeyWord()); + seg.orLike("u.loginname", param.getKeyWord()); + sql.setCondition(Cnd.NEW().and(seg)); + // 下拉候选列表沿用最多10条的限制,不依赖代表记录数量。 + sql.setPager(dao().createPager(1, 10)); + return listMap(sql); + } + /** * 为当前届次未归团代表批量解析代表团,同时刷新其届次代表角色,避免重复生成角色关系。 */ diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/constant/HuaZhuConstant.java b/src/main/java/com/budwk/app/zhgh/huazhu/constant/HuaZhuConstant.java new file mode 100644 index 0000000..bfd973d --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/huazhu/constant/HuaZhuConstant.java @@ -0,0 +1,47 @@ +package com.budwk.app.zhgh.huazhu.constant; + +/** 华住对接参数统一在此维护,沿用原项目常量取值。 */ +public final class HuaZhuConstant { + /** + * 华住接口请求超时,单位毫秒。 + */ + public static final int TIMEOUT = 20000; + + public static final String BASEURL = "https://htravelserver.huazhu.com"; + + public static final String OAUTH2_URL = "https://oauth2-api.huazhu.com"; + + /** + * 公司卡号 + */ + public static final String CARD_ID = "VCENTCRM1169099135"; + + /** + * 公司密钥 + */ + public static final String CARD_PASSWORD = "47CD8C9C-A4CD-4CBD-A30F-41C41BDF7E99"; + + /** + * AES密钥 + */ + public static final String AES_KEY = "JAE4YZJLXZW5HGBU"; + + /** + * 客户端ID + */ + public static final String CLIENT_ID = "5e8651d8-a5a8-4358-9986-91f426e191c8"; + + /** + * 客户端密钥 + */ + public static final String CLIENT_SECRET = "2d20a7f3125b52b8e90936c3b30fb4b62b443a52716a090e55e302cf40f32b66d592ef1e98c1cf0a8e5ec3f9c444a9b4f53a337b1be57263d4fbb71044f67f12"; + + /** 华住 addUser 响应的顶层成功字段名;协议未确认前留空,界面显示待核实。 */ + public static final String SYNC_SUCCESS_FIELD = ""; + + /** 成功字段对应的精确字符串值,与 SYNC_SUCCESS_FIELD 一起按华住协议填写。 */ + public static final String SYNC_SUCCESS_VALUE = ""; + + private HuaZhuConstant() { + } +} diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/controller/HuaZhuManageController.java b/src/main/java/com/budwk/app/zhgh/huazhu/controller/HuaZhuManageController.java new file mode 100644 index 0000000..dbd799d --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/huazhu/controller/HuaZhuManageController.java @@ -0,0 +1,81 @@ +package com.budwk.app.zhgh.huazhu.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.hutool.core.util.StrUtil; +import com.budwk.app.base.result.Result; +import com.budwk.app.zhgh.huazhu.param.HuaZhuEmployeePageForm; +import com.budwk.app.zhgh.huazhu.service.HuaZhuEmployeeService; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; +import org.nutz.mvc.annotation.Param; +import org.nutz.mvc.annotation.POST; + +import javax.validation.Valid; +import java.util.Arrays; + +/** + * PC 端华住员工同步管理。 + */ +@IocBean +@Ok("json:full") +@At("/platform/huazhu") +public class HuaZhuManageController { + + @Inject + private HuaZhuEmployeeService huaZhuEmployeeService; + + /** 打开拥有 huazhu.manage 权限的 PC 员工管理页面。 */ + @At("") + @Ok("beetl:/platform/zhgh/huazhu/index.html") + @SaCheckPermission("huazhu.manage") + public void index() { + } + + /** + * @param pageForm pageNumber、pageSize 必填,searchKeyword 可选,匹配工号或姓名 + * @return Result.data 为分页对象,list 包含 id、loginname、username、unitName,totalCount 为总数 + */ + @At + @SaCheckPermission("huazhu.manage") + public Result pageData(@Valid HuaZhuEmployeePageForm pageForm) { + return Result.success(huaZhuEmployeeService.pageUsers(pageForm)); + } + + /** + * @param userIds 所选本系统员工主键数组,非华住员工编号 + * @return Result.data 为逐人结果数组;success 为 true/false/null,分别表示成功/失败/待核实,message 为说明 + */ + @At + @POST + @Aop(TransAop.READ_COMMITTED) + @SaCheckPermission("huazhu.manage") + public Result syncUsers(@Param("userIds") String[] userIds) { + if (userIds == null || userIds.length == 0) { + return Result.error("请先选择需要同步的员工"); + } + return Result.success(huaZhuEmployeeService.syncUsers(Arrays.asList(userIds))); + } + + /** + * @param userId 本系统员工主键,服务端转换为工号后查询华住 + * @return Result.data 为华住状态对象,含 statusCode、userNumber、userName 等;错误说明见 msg + */ + @At + @SaCheckPermission("huazhu.manage") + public Result queryStatus(@Param("userId") String userId) { + if (StrUtil.isBlank(userId)) { + return Result.error("用户不能为空"); + } + try { + return Result.success(huaZhuEmployeeService.queryStatus(userId)); + } catch (IllegalArgumentException | IllegalStateException e) { + return Result.error(e.getMessage()); + } catch (Exception e) { + return Result.error("查询华住状态失败,请稍后重试"); + } + } +} diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/h5Controller/H5HuaZhuController.java b/src/main/java/com/budwk/app/zhgh/huazhu/h5Controller/H5HuaZhuController.java new file mode 100644 index 0000000..26f46ef --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/huazhu/h5Controller/H5HuaZhuController.java @@ -0,0 +1,37 @@ +package com.budwk.app.zhgh.huazhu.h5Controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import com.budwk.app.web.commons.auth.utils.SecurityUtil; +import com.budwk.app.zhgh.huazhu.service.HuaZhuLoginService; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * H5 幸福工荟中的华住免密登录入口。 + */ +@IocBean +@At("/platform/h5/huazhu") +public class H5HuaZhuController { + + @Inject + private HuaZhuLoginService huaZhuLoginService; + + /** + * 校验幸福工荟访问权限后跳转至华住企业登录页面。 + * + * @param response HTTP 响应,用于返回 302 跳转 + * @throws IOException 响应输出异常 + */ + @At("/basicLogin") + @Ok("void") + @SaCheckPermission("h5.inclusive.benefit") + public void basicLogin(HttpServletResponse response) throws IOException { + response.setHeader("Cache-Control", "no-store"); + response.sendRedirect(huaZhuLoginService.buildBasicLoginUrl(SecurityUtil.getUserLoginname())); + } +} diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/param/HuaZhuEmployeePageForm.java b/src/main/java/com/budwk/app/zhgh/huazhu/param/HuaZhuEmployeePageForm.java new file mode 100644 index 0000000..4dbd181 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/huazhu/param/HuaZhuEmployeePageForm.java @@ -0,0 +1,9 @@ +package com.budwk.app.zhgh.huazhu.param; + +import com.budwk.app.base.param.PageForm; + +/** + * 华住员工管理分页参数:复用 PageForm 的 searchKeyword、pageNumber、pageSize。 + */ +public class HuaZhuEmployeePageForm extends PageForm { +} diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/service/HuaZhuEmployeeService.java b/src/main/java/com/budwk/app/zhgh/huazhu/service/HuaZhuEmployeeService.java new file mode 100644 index 0000000..6a05609 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/huazhu/service/HuaZhuEmployeeService.java @@ -0,0 +1,39 @@ +package com.budwk.app.zhgh.huazhu.service; + +import com.budwk.app.base.page.Pagination; +import com.budwk.app.zhgh.huazhu.param.HuaZhuEmployeePageForm; +import com.budwk.app.zhgh.huazhu.vo.HuaZhuEmployeeStatus; +import com.budwk.app.zhgh.huazhu.vo.HuaZhuSyncResult; +import org.nutz.lang.util.NutMap; + +import java.util.List; + +/** + * 华住员工同步与状态查询服务。 + */ +public interface HuaZhuEmployeeService { + + /** + * 分页查询可同步的本项目员工。 + * + * @param pageForm pageNumber、pageSize 必填,searchKeyword 为可选的工号或姓名片段 + * @return 分页对象,list 为 id/loginname/username/unitName,totalCount 为匹配人数 + */ + Pagination pageUsers(HuaZhuEmployeePageForm pageForm); + + /** + * 将选中的本项目员工同步到华住。 + * + * @param userIds 本项目用户主键列表 + * @return 逐人结果,success=true 为协议确认成功、false 为失败、null 为待核实,message 为说明 + */ + List syncUsers(List userIds); + + /** + * 查询指定本项目员工在华住的实时状态。 + * + * @param userId 本项目用户主键 + * @return 华住员工状态对象;statusCode 保留第三方原值,userNumber 为已校验匹配的工号 + */ + HuaZhuEmployeeStatus queryStatus(String userId); +} diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/service/HuaZhuLoginService.java b/src/main/java/com/budwk/app/zhgh/huazhu/service/HuaZhuLoginService.java new file mode 100644 index 0000000..dcb6a69 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/huazhu/service/HuaZhuLoginService.java @@ -0,0 +1,15 @@ +package com.budwk.app.zhgh.huazhu.service; + +/** + * 华住免密登录服务。 + */ +public interface HuaZhuLoginService { + + /** + * 按华住协议为当前职工工号生成免密登录跳转地址。 + * + * @param loginName 当前登录职工的工号 + * @return 华住免密登录地址 + */ + String buildBasicLoginUrl(String loginName); +} diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/service/impl/HuaZhuEmployeeServiceImpl.java b/src/main/java/com/budwk/app/zhgh/huazhu/service/impl/HuaZhuEmployeeServiceImpl.java new file mode 100644 index 0000000..1db0d43 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/huazhu/service/impl/HuaZhuEmployeeServiceImpl.java @@ -0,0 +1,299 @@ +package com.budwk.app.zhgh.huazhu.service.impl; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.digest.DigestUtil; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import cn.hutool.json.JSONUtil; +import cn.hutool.json.JSONObject; +import com.budwk.app.base.page.Pagination; +import com.budwk.app.sys.models.Sys_user; +import com.budwk.app.zhgh.huazhu.constant.HuaZhuConstant; +import com.budwk.app.zhgh.huazhu.param.HuaZhuEmployeePageForm; +import com.budwk.app.zhgh.huazhu.service.HuaZhuEmployeeService; +import com.budwk.app.zhgh.huazhu.vo.HuaZhuEmployeeStatus; +import com.budwk.app.zhgh.huazhu.vo.HuaZhuSyncResult; +import org.nutz.dao.Cnd; +import org.nutz.dao.Dao; +import org.nutz.dao.Sqls; +import org.nutz.dao.sql.Sql; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.lang.util.NutMap; +import org.nutz.integration.jedis.RedisService; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.List; + +/** + * 华住员工同步与状态查询实现。 + */ +@IocBean(args = {"refer:dao"}) +public class HuaZhuEmployeeServiceImpl implements HuaZhuEmployeeService { + + private final Dao dao; + + @Inject + private RedisService redisService; + + public HuaZhuEmployeeServiceImpl(Dao dao) { + this.dao = dao; + } + + /** 工号、姓名采用绑定参数查询;分页及筛选参数沿用项目 PageForm。 */ + @Override + public Pagination pageUsers(HuaZhuEmployeePageForm pageForm) { + if (pageForm == null || pageForm.getPageNumber() == null || pageForm.getPageNumber() < 1 + || pageForm.getPageSize() == null || pageForm.getPageSize() < 1 || pageForm.getPageSize() > 200) { + throw new IllegalArgumentException("页码必须大于0,每页数量必须为1至200"); + } + String keyword = StrUtil.trim(pageForm.getSearchKeyword()); + String condition = " WHERE u.delFlag = 0 "; + if (StrUtil.isNotBlank(keyword)) { + condition += " AND (u.loginname LIKE @keyword OR u.username LIKE @keyword) "; + } + Sql countSql = Sqls.create("SELECT COUNT(1) FROM sys_user u" + condition); + Sql listSql = Sqls.create("SELECT u.id, u.loginname, u.username, unit.name AS unitName " + + "FROM sys_user u LEFT JOIN sys_unit unit ON unit.id = u.unitId " + condition + + "ORDER BY u.createdAt DESC"); + if (StrUtil.isNotBlank(keyword)) { + String likeKeyword = "%" + keyword + "%"; + countSql.setParam("keyword", likeKeyword); + listSql.setParam("keyword", likeKeyword); + } + countSql.setCallback(Sqls.callback.integer()); + dao.execute(countSql); + int totalCount = countSql.getInt(); + listSql.setPager(dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize())); + listSql.setCallback(Sqls.callback.maps()); + dao.execute(listSql); + return new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), totalCount, listSql.getList(NutMap.class)); + } + + /** + * 每个主键独立校验并产生结果,单个员工不存在或接口失败不阻断后续员工。 + * 本地事务无法撤销华住侧已处理的同步,超时后应先查询状态,避免直接重试整批。 + */ + @Override + @Aop(TransAop.READ_COMMITTED) + public List syncUsers(List userIds) { + if (userIds == null || userIds.isEmpty()) { + return Collections.emptyList(); + } + List results = new ArrayList<>(); + for (String userId : userIds) { + HuaZhuSyncResult result = new HuaZhuSyncResult().setUserId(userId); + try { + Sys_user user = findAvailableUser(userId); + result.setLoginName(user.getLoginname()).setUserName(user.getUsername()); + String body = request("/userInfo/addUser", buildEmployeeRequest(user.getLoginname())); + applySyncResponse(result, body); + } catch (IllegalArgumentException | IllegalStateException e) { + // 只透出本模块构造的提示;网络和解析异常在下层转换为固定文案。 + result.setSuccess(false).setMessage(e.getMessage()); + } catch (Exception e) { + result.setSuccess(false).setMessage("同步未完成,请查询华住状态后再决定是否重试"); + } + results.add(result); + } + return results; + } + + /** 根据本系统主键取得工号,返回与该工号对应的华住员工对象,不推断状态码含义。 */ + @Override + public HuaZhuEmployeeStatus queryStatus(String userId) { + Sys_user user = findAvailableUser(userId); + String body = request("/userInfo/queryUser", buildEmployeeRequest(user.getLoginname())); + JSONObject payload = extractQueryPayload(body, user.getLoginname()); + try { + return JSONUtil.toBean(payload, HuaZhuEmployeeStatus.class); + } catch (Exception e) { + throw new IllegalStateException("华住查询接口返回格式无法识别", e); + } + } + + /** + * 兼容华住接口直接返回员工对象,以及以 data 或 result 包装员工对象的两种响应格式。 + * 响应中不包含员工状态时,只返回经筛选后的业务提示,避免透出第三方完整响应。 + * + * @param body 华住查询接口响应正文 + * @param loginName 本次查询的工号,用于避免把错误或其他员工的信息当成有效结果 + * @return 可转换为员工状态的 JSON 对象 + */ + private JSONObject extractQueryPayload(String body, String loginName) { + JSONObject response; + try { + response = JSONUtil.parseObj(body); + } catch (Exception e) { + throw new IllegalStateException("华住查询接口未返回有效数据", e); + } + JSONObject payload = extractObject(response, "data"); + if (payload == null) { + payload = extractObject(response, "result"); + } + if (payload == null) { + payload = response; + } + // statusCode 本身可能是错误码;必须有匹配的工号,才将响应作为该员工的状态展示。 + if (!loginName.equals(payload.getStr("userNumber"))) { + throw new IllegalStateException("华住未返回与该工号匹配的员工信息,请核实接口响应协议"); + } + return payload; + } + + /** + * 成功码未取得协议确认前,HTTP 2xx 仅表示请求送达;不猜测 0、200 等值的含义。 + * 在 HuaZhuConstant 中填写顶层字段名及精确成功值后才判定成功,字段缺失/格式变化仍返回待核实。 + */ + private void applySyncResponse(HuaZhuSyncResult result, String body) { + result.setSuccess(null).setMessage("请求已发送,结果待核实,请查询华住状态"); + String field = HuaZhuConstant.SYNC_SUCCESS_FIELD.trim(); + String expected = HuaZhuConstant.SYNC_SUCCESS_VALUE; + if (StrUtil.isBlank(field) || StrUtil.isBlank(expected)) { + return; + } + try { + JSONObject response = JSONUtil.parseObj(body); + if (response.get(field) == null) { + return; + } + boolean success = expected.equals(response.getStr(field)); + result.setSuccess(success).setMessage(success ? "同步成功" : "华住返回非成功结果,请查询状态核实"); + } catch (Exception e) { + result.setMessage("华住响应格式无法识别,结果待核实,请查询华住状态"); + } + } + + /** + * 从包装响应中提取对象;部分网关会将 data/result 再序列化为 JSON 字符串。 + * + * @param response 华住响应对象 + * @param key 包装字段名 + * @return 包装内的 JSON 对象,缺失或格式不匹配时返回 null + */ + private JSONObject extractObject(JSONObject response, String key) { + Object value = response.get(key); + if (value instanceof JSONObject) { + return (JSONObject) value; + } + if (value instanceof CharSequence && StrUtil.isNotBlank(value.toString())) { + try { + return JSONUtil.parseObj(value.toString()); + } catch (Exception ignored) { + return null; + } + } + return null; + } + + /** + * 调用华住员工接口,并统一处理 OAuth Token 与非成功状态。 + * + * @param path 华住接口路径 + * @param requestBody 请求体 + * @return 华住响应正文 + */ + private String request(String path, NutMap requestBody) { + String authorization = getAccessToken(); + try (HttpResponse response = HttpRequest.post(HuaZhuConstant.BASEURL.trim().replaceAll("/+$", "") + path) + .header("Authorization", authorization) + .header("accept", "application/json") + .contentType("application/json; charset=UTF-8") + .body(JSONUtil.toJsonStr(requestBody)) + .timeout(HuaZhuConstant.TIMEOUT) + .execute()) { + if (response.getStatus() == 401) { + redisService.del(tokenCacheKey()); + // 不自动重发修改请求,避免上游已执行时重复同步。 + throw new IllegalStateException("华住认证已失效,请查询状态后重试"); + } + if (!response.isOk()) { + throw new IllegalStateException("华住接口调用失败,HTTP 状态码:" + response.getStatus()); + } + return response.body(); + } catch (IllegalStateException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException("华住请求异常或超时,结果未确认,请先查询状态再重试"); + } + } + + /** + * 获取华住 OAuth Token,优先使用 Redis 缓存以避免频繁请求第三方认证接口。 + * + * @return HTTP Authorization 头的 Bearer Token + */ + private String getAccessToken() { + String cacheKey = tokenCacheKey(); + String cachedToken; + try { + cachedToken = redisService.get(cacheKey); + } catch (Exception e) { + throw new IllegalStateException("华住认证缓存不可用,请联系管理员"); + } + if (StrUtil.isNotBlank(cachedToken)) { + return cachedToken; + } + String credential = Base64.getEncoder().encodeToString((HuaZhuConstant.CLIENT_ID + ":" + HuaZhuConstant.CLIENT_SECRET).getBytes(StandardCharsets.UTF_8)); + try (HttpResponse response = HttpRequest.post(HuaZhuConstant.OAUTH2_URL.trim().replaceAll("/+$", "") + + "/oauth/token?scope=ALL&grant_type=client_credentials") + .header("Authorization", "Basic " + credential) + .timeout(HuaZhuConstant.TIMEOUT) + .execute()) { + if (!response.isOk()) { + throw new IllegalStateException("获取华住认证 Token 失败,HTTP 状态码:" + response.getStatus()); + } + JSONObject tokenData = JSONUtil.parseObj(response.body()); + String accessToken = tokenData.getStr("access_token"); + String tokenType = StrUtil.blankToDefault(tokenData.getStr("token_type"), "Bearer"); + if (StrUtil.isBlank(accessToken)) { + throw new IllegalStateException("华住认证接口未返回 Token"); + } + String authorization = tokenType + " " + accessToken; + // expires_in 单位为秒,预留一分钟;缺少有效时长时不缓存,避免沿用猜测的有效期。 + Long expiresIn = tokenData.getLong("expires_in"); + if (expiresIn != null && expiresIn > 60) { + redisService.setex(cacheKey, (int) Math.min(expiresIn - 60, Integer.MAX_VALUE), authorization); + } + return authorization; + } catch (IllegalStateException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException("华住认证请求、响应解析或缓存处理失败,请联系管理员"); + } + } + + /** 缓存按企业及接口环境隔离;凭据轮换后自然使用新键,键中不包含明文凭据。 */ + private String tokenCacheKey() { + return "zhgh:huazhu:oauth-token:" + DigestUtil.sha256Hex(JSONUtil.toJsonStr(new String[]{ + HuaZhuConstant.BASEURL.trim().replaceAll("/+$", ""), HuaZhuConstant.OAUTH2_URL.trim().replaceAll("/+$", ""), HuaZhuConstant.CARD_ID, + HuaZhuConstant.CLIENT_ID, HuaZhuConstant.CLIENT_SECRET})); + } + + /** 沿用源接口的 cardPassWord 大小写,只发送企业凭据和工号。 */ + private NutMap buildEmployeeRequest(String loginName) { + return NutMap.NEW().addv("cardId", HuaZhuConstant.CARD_ID) + .addv("cardPassWord", HuaZhuConstant.CARD_PASSWORD) + .addv("userNumber", loginName); + } + + private Sys_user findAvailableUser(String userId) { + if (StrUtil.isBlank(userId)) { + throw new IllegalArgumentException("用户主键不能为空"); + } + Sys_user user = dao.fetch(Sys_user.class, Cnd.where("id", "=", userId).and("delFlag", "=", false)); + if (user == null) { + throw new IllegalArgumentException("用户不存在或已删除"); + } + if (StrUtil.isBlank(user.getLoginname())) { + throw new IllegalArgumentException("用户缺少工号,无法同步华住"); + } + return user; + } + +} diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/service/impl/HuaZhuLoginServiceImpl.java b/src/main/java/com/budwk/app/zhgh/huazhu/service/impl/HuaZhuLoginServiceImpl.java new file mode 100644 index 0000000..79ed739 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/huazhu/service/impl/HuaZhuLoginServiceImpl.java @@ -0,0 +1,67 @@ +package com.budwk.app.zhgh.huazhu.service.impl; + +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.util.StrUtil; +import com.budwk.app.zhgh.huazhu.constant.HuaZhuConstant; +import com.budwk.app.zhgh.huazhu.service.HuaZhuLoginService; +import org.nutz.ioc.loader.annotation.IocBean; + +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +/** + * 华住免密登录服务实现。 + */ +@IocBean +public class HuaZhuLoginServiceImpl implements HuaZhuLoginService { + + private static final String TRAVEL_TYPE = "YS"; + + /** @param loginName 服务端登录会话中的工号;返回含签名的华住免密跳转 URL。 */ + @Override + public String buildBasicLoginUrl(String loginName) { + if (StrUtil.isBlank(loginName)) { + throw new IllegalArgumentException("当前登录用户缺少工号,无法进入华住"); + } + + // 华住约定使用 AES/ECB/PKCS5Padding 对公司卡号、工号和当前时间生成签名。 + String cardId = HuaZhuConstant.CARD_ID; + String authorizeSign = encrypt(cardId + "&" + loginName + "&" + DateUtil.now(), HuaZhuConstant.AES_KEY); + return HuaZhuConstant.BASEURL.trim().replaceAll("/+$", "") + "/authentication/basicLogin?cardId=" + encode(cardId) + + "&travelType=" + TRAVEL_TYPE + + "&userNumber=" + encode(loginName) + + "&invoiceNo=" + + "&authorizeSign=" + encode(authorizeSign); + } + + /** + * 按华住协议生成 AES 签名,不记录签名内容或密钥。 + * + * @param source 待签名内容 + * @param aesKey 华住提供的 16 字节 AES 密钥 + * @return Base64 编码后的签名 + */ + private String encrypt(String source, String aesKey) { + try { + byte[] keyBytes = aesKey.getBytes(StandardCharsets.UTF_8); + if (keyBytes.length != 16) { + throw new IllegalStateException("华住 AES 密钥必须为 16 字节"); + } + Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(keyBytes, "AES")); + return Base64.getEncoder().encodeToString(cipher.doFinal(source.getBytes(StandardCharsets.UTF_8))); + } catch (IllegalStateException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException("生成华住登录签名失败", e); + } + } + + private String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + +} diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/vo/HuaZhuEmployeeStatus.java b/src/main/java/com/budwk/app/zhgh/huazhu/vo/HuaZhuEmployeeStatus.java new file mode 100644 index 0000000..66b87ce --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/huazhu/vo/HuaZhuEmployeeStatus.java @@ -0,0 +1,32 @@ +package com.budwk.app.zhgh.huazhu.vo; + +import lombok.Data; + +import java.util.List; + +/** + * 华住查询员工接口的展示结果。 + */ +@Data +public class HuaZhuEmployeeStatus { + private String statusCode; + private String userNumber; + private String userName; + private String userDep; + private String costCenter; + private String rank; + private String leader; + private String mobile; + private String email; + private List permissions; + + /** + * 华住返回的预订权限。 + */ + @Data + public static class Permission { + private Boolean hasPermission; + private String permissionCode; + private String permissionValue; + } +} diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/vo/HuaZhuSyncResult.java b/src/main/java/com/budwk/app/zhgh/huazhu/vo/HuaZhuSyncResult.java new file mode 100644 index 0000000..06f9c20 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/huazhu/vo/HuaZhuSyncResult.java @@ -0,0 +1,18 @@ +package com.budwk.app.zhgh.huazhu.vo; + +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * 单个员工同步到华住的执行结果。 + */ +@Data +@Accessors(chain = true) +public class HuaZhuSyncResult { + private String userId; + private String loginName; + private String userName; + /** true=按已配置协议确认成功;false=失败;null=请求结果待核实,不等同同步成功。 */ + private Boolean success; + private String message; +} diff --git a/src/main/java/com/budwk/app/zhgh/inclusive/controller/InclusiveBenefitController.java b/src/main/java/com/budwk/app/zhgh/inclusive/controller/InclusiveBenefitController.java new file mode 100644 index 0000000..e748302 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/inclusive/controller/InclusiveBenefitController.java @@ -0,0 +1,114 @@ +package com.budwk.app.zhgh.inclusive.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.hutool.core.util.StrUtil; +import com.budwk.app.base.result.Result; +import com.budwk.app.zhgh.inclusive.model.InclusiveBenefit; +import com.budwk.app.zhgh.inclusive.param.InclusiveBenefitPageForm; +import com.budwk.app.zhgh.inclusive.service.InclusiveBenefitService; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; +import org.nutz.mvc.annotation.Param; + +import javax.validation.Valid; + +/** + * PC 端幸福工荟管理。 + */ +@IocBean +@Ok("json:full") +@At("/platform/inclusive") +public class InclusiveBenefitController { + + @Inject + private InclusiveBenefitService inclusiveBenefitService; + + /** 打开幸福工荟 PC 管理页面。 */ + @At("") + @Ok("beetl:/platform/zhgh/inclusive/index.html") + @SaCheckPermission("inclusive.benefit") + public void index() { + } + + /** + * @param pageForm 必填 pageNumber、pageSize;year、title、tagType 为可选筛选条件 + * @return Result,data 为分页对象,包含 list、totalCount、pageNo、pageSize + */ + @At + @SaCheckPermission("inclusive.benefit") + public Result pageData(@Valid InclusiveBenefitPageForm pageForm) { + return Result.success(inclusiveBenefitService.pageData(pageForm, false)); + } + + /** + * @param id 待回显内容主键 + * @return Result,data 为完整内容对象;记录不存在或已删除时返回错误 + */ + @At + @SaCheckPermission("inclusive.benefit") + public Result findOne(@Param("id") String id) { + Object detail = inclusiveBenefitService.findVO(id, false); + return detail == null ? Result.error("幸福工荟不存在或已删除") : Result.success(detail); + } + + /** + * @param benefit 内容表单:id 为空新增;title 标题、summary 摘要、tagType 标签编码、 + * content 富文本、cover 封面地址必填;year、sexLimit、linkUrl 为可选配置 + * @param status 0 保存草稿,1 发布;为空默认草稿 + * @return Result,code 为 0 表示成功,失败原因见 msg,无业务 data + */ + @At + @Aop(TransAop.READ_COMMITTED) + @SaCheckPermission("inclusive.benefit") + public Result save(InclusiveBenefit benefit, @Param("status") Integer status) { + if (benefit == null || StrUtil.isBlank(benefit.getTitle())) { + return Result.error("标题不能为空"); + } + if (StrUtil.isBlank(benefit.getTagType())) { + return Result.error("标签类型不能为空"); + } + if (StrUtil.isBlank(benefit.getSummary()) || StrUtil.isBlank(benefit.getContent()) + || StrUtil.isBlank(benefit.getCover())) { + return Result.error("摘要、内容和封面图不能为空"); + } + if (benefit.getTitle().length() > 100 || benefit.getSummary().length() > 500 + || benefit.getTagType().length() > 50 || benefit.getCover().length() > 255 + || (benefit.getLinkUrl() != null && benefit.getLinkUrl().length() > 500)) { + return Result.error("标题、摘要、标签、封面或链接长度超过限制"); + } + if (benefit.getSexLimit() != null && (benefit.getSexLimit() < 0 || benefit.getSexLimit() > 2)) { + return Result.error("性别配置只能为0、1或2"); + } + inclusiveBenefitService.saveBenefit(benefit, status); + return Result.success(); + } + + /** + * @param id 内容主键 + * @param status 0 下架,1 发布 + * @return Result,code 为 0 表示成功,无业务 data + */ + @At + @Aop(TransAop.READ_COMMITTED) + @SaCheckPermission("inclusive.benefit") + public Result updateStatus(@Param("id") String id, @Param("status") Integer status) { + inclusiveBenefitService.updateStatus(id, status); + return Result.success(); + } + + /** + * @param id 待逻辑删除的内容主键 + * @return Result,code 为 0 表示成功,无业务 data + */ + @At + @Aop(TransAop.READ_COMMITTED) + @SaCheckPermission("inclusive.benefit") + public Result delete(@Param("id") String id) { + inclusiveBenefitService.deleteBenefit(id); + return Result.success(); + } +} diff --git a/src/main/java/com/budwk/app/zhgh/inclusive/h5Controller/H5InclusiveBenefitController.java b/src/main/java/com/budwk/app/zhgh/inclusive/h5Controller/H5InclusiveBenefitController.java new file mode 100644 index 0000000..48e288a --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/inclusive/h5Controller/H5InclusiveBenefitController.java @@ -0,0 +1,60 @@ +package com.budwk.app.zhgh.inclusive.h5Controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import com.budwk.app.base.result.Result; +import com.budwk.app.zhgh.inclusive.param.InclusiveBenefitPageForm; +import com.budwk.app.zhgh.inclusive.service.InclusiveBenefitService; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; +import org.nutz.mvc.annotation.Param; + +import javax.validation.Valid; + +/** + * H5 端幸福工荟浏览。 + */ +@IocBean +@Ok("json:full") +@At("/platform/h5/inclusive") +public class H5InclusiveBenefitController { + + @Inject + private InclusiveBenefitService inclusiveBenefitService; + + /** 打开幸福工荟 H5 列表页。 */ + @At("") + @Ok("beetl:/platform/zhghh5/inclusive/index.html") + @SaCheckPermission("h5.inclusive.benefit") + public void index() { + } + + /** + * @param pageForm 必填 pageNumber、pageSize;tagType 为标签编码,可选 year、title + * @return Result,data 为已发布且未删除内容的分页对象,含 list、totalCount、pageNo、pageSize + */ + @At + @SaCheckPermission("h5.inclusive.benefit") + public Result pageData(@Valid InclusiveBenefitPageForm pageForm) { + return Result.success(inclusiveBenefitService.pageData(pageForm, true)); + } + + /** + * @param id 内容主键 + * @return Result,data 为详情对象;不存在、未发布或已删除时返回错误,不返回正文 + */ + @At + @SaCheckPermission("h5.inclusive.benefit") + public Result findOne(@Param("id") String id) { + Object detail = inclusiveBenefitService.findVO(id, true); + return detail == null ? Result.error("内容不存在或已下架") : Result.success(detail); + } + + /** @return Result,data 为标签数组,每项 text 是标签名称,value 是标签编码;无参数 */ + @At + @SaCheckPermission("h5.inclusive.benefit") + public Result tagOptions() { + return Result.success(inclusiveBenefitService.tagOptions()); + } +} diff --git a/src/main/java/com/budwk/app/zhgh/inclusive/model/InclusiveBenefit.java b/src/main/java/com/budwk/app/zhgh/inclusive/model/InclusiveBenefit.java new file mode 100644 index 0000000..262a5a1 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/inclusive/model/InclusiveBenefit.java @@ -0,0 +1,84 @@ +package com.budwk.app.zhgh.inclusive.model; + +import com.budwk.app.base.model.BaseModel; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.nutz.dao.entity.annotation.ColDefine; +import org.nutz.dao.entity.annotation.ColType; +import org.nutz.dao.entity.annotation.Column; +import org.nutz.dao.entity.annotation.Comment; +import org.nutz.dao.entity.annotation.EL; +import org.nutz.dao.entity.annotation.Name; +import org.nutz.dao.entity.annotation.Table; +import org.nutz.dao.entity.annotation.TableMeta; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +import java.io.Serializable; + +/** + * 幸福工荟内容。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Table("inclusive_benefit") +@TableMeta("{'mysql-charset':'utf8mb4'}") +@Comment("幸福工荟") +public class InclusiveBenefit extends BaseModel implements Serializable { + + @Name + @Column + @Comment("主键") + @ColDefine(type = ColType.VARCHAR, width = 32) + @PrevInsert(els = @EL("uuid()")) + private String id; + + @Column + @Comment("年度") + @ColDefine(type = ColType.INT) + private Integer year; + + @Column + @Comment("标题") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String title; + + @Column + @Comment("摘要") + @ColDefine(type = ColType.VARCHAR, width = 500) + private String summary; + + @Column + @Comment("标签类型字典编码") + @ColDefine(type = ColType.VARCHAR, width = 50) + private String tagType; + + @Column + @Comment("富文本内容") + @ColDefine(type = ColType.TEXT) + private String content; + + @Column + @Comment("性别限制:0全部开放,1男性,2女性") + @ColDefine(type = ColType.INT) + private Integer sexLimit; + + @Column + @Comment("封面图") + @ColDefine(type = ColType.VARCHAR, width = 255) + private String cover; + + @Column + @Comment("外链地址") + @ColDefine(type = ColType.VARCHAR, width = 500) + private String linkUrl; + + @Column + @Comment("发布状态:0草稿,1已发布") + @ColDefine(type = ColType.INT) + private Integer status; + + @Column + @Comment("发布时间") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String publishTime; +} diff --git a/src/main/java/com/budwk/app/zhgh/inclusive/param/InclusiveBenefitPageForm.java b/src/main/java/com/budwk/app/zhgh/inclusive/param/InclusiveBenefitPageForm.java new file mode 100644 index 0000000..fe18b1a --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/inclusive/param/InclusiveBenefitPageForm.java @@ -0,0 +1,19 @@ +package com.budwk.app.zhgh.inclusive.param; + +import com.budwk.app.base.param.PageForm; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 幸福工荟分页查询参数。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class InclusiveBenefitPageForm extends PageForm { + /** 可选年度,按内容归属年度精确筛选。 */ + private Integer year; + /** 可选标题关键字,使用包含匹配。 */ + private String title; + /** 可选标签字典编码,空值表示全部标签。 */ + private String tagType; +} diff --git a/src/main/java/com/budwk/app/zhgh/inclusive/service/InclusiveBenefitService.java b/src/main/java/com/budwk/app/zhgh/inclusive/service/InclusiveBenefitService.java new file mode 100644 index 0000000..233333f --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/inclusive/service/InclusiveBenefitService.java @@ -0,0 +1,54 @@ +package com.budwk.app.zhgh.inclusive.service; + +import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.service.BaseService; +import com.budwk.app.zhgh.inclusive.model.InclusiveBenefit; +import com.budwk.app.zhgh.inclusive.param.InclusiveBenefitPageForm; +import com.budwk.app.zhgh.inclusive.vo.InclusiveBenefitVO; +import org.nutz.lang.util.NutMap; + +import java.util.List; + +/** + * 幸福工荟业务服务。 + */ +public interface InclusiveBenefitService extends BaseService { + + /** + * 查询管理列表或手机端已发布内容。 + * @param pageForm 页码、每页数量及可选的年度、标题、标签编码 + * @param onlyPublished 手机端固定传 true,PC 管理端传 false,不接受客户端指定 + * @return 分页对象,list 为内容列表,totalCount 为符合条件的总条数 + */ + Pagination pageData(InclusiveBenefitPageForm pageForm, boolean onlyPublished); + + /** + * @param id 内容主键 + * @param onlyPublished true 时只允许读取已发布内容 + * @return 内容及标签名称;不存在、已删除或未发布时返回 null + */ + InclusiveBenefitVO findVO(String id, boolean onlyPublished); + + /** @return 启用的标签列表,每项 text 为名称,value 为字典编码 */ + List tagOptions(); + + /** + * @param benefit 表单内容,id 为空表示新增,否则编辑对应内容 + * @param status 0 保存草稿,1 发布;为空按草稿处理 + * @return 无返回值;内容不存在或参数不合法时抛出 IllegalArgumentException + */ + void saveBenefit(InclusiveBenefit benefit, Integer status); + + /** + * @param id 内容主键 + * @param status 0 下架,1 发布 + * @return 无返回值;发布时更新发布时间 + */ + void updateStatus(String id, Integer status); + + /** + * @param id 待删除内容主键 + * @return 无返回值;仅设置删除标记,保留原记录 + */ + void deleteBenefit(String id); +} diff --git a/src/main/java/com/budwk/app/zhgh/inclusive/service/impl/InclusiveBenefitServiceImpl.java b/src/main/java/com/budwk/app/zhgh/inclusive/service/impl/InclusiveBenefitServiceImpl.java new file mode 100644 index 0000000..2f1cef6 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/inclusive/service/impl/InclusiveBenefitServiceImpl.java @@ -0,0 +1,206 @@ +package com.budwk.app.zhgh.inclusive.service.impl; + +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.util.StrUtil; +import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.service.impl.BaseServiceImpl; +import com.budwk.app.sys.models.Sys_dict; +import com.budwk.app.zhgh.inclusive.model.InclusiveBenefit; +import com.budwk.app.zhgh.inclusive.param.InclusiveBenefitPageForm; +import com.budwk.app.zhgh.inclusive.service.InclusiveBenefitService; +import com.budwk.app.zhgh.inclusive.vo.InclusiveBenefitVO; +import org.nutz.dao.Chain; +import org.nutz.dao.Cnd; +import org.nutz.dao.Dao; +import org.nutz.lang.Strings; +import org.nutz.lang.util.NutMap; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 幸福工荟业务实现。 + */ +@IocBean(args = {"refer:dao"}) +public class InclusiveBenefitServiceImpl extends BaseServiceImpl implements InclusiveBenefitService { + + private static final String TAG_DICT_CODE = "INCLUSIVE_BENEFIT_TYPE"; + + /** @param dao 当前项目的数据库访问对象 */ + public InclusiveBenefitServiceImpl(Dao dao) { + super(dao); + } + + @Override + public Pagination pageData(InclusiveBenefitPageForm pageForm, boolean onlyPublished) { + Cnd cnd = Cnd.where("delFlag", "=", false); + // H5 的可见性由服务端调用方决定,不能通过提交 status 参数读取草稿或下架内容。 + if (onlyPublished) { + cnd.and("status", "=", 1); + } + if (pageForm.getYear() != null) { + cnd.and("year", "=", pageForm.getYear()); + } + if (Strings.isNotBlank(pageForm.getTitle())) { + cnd.and(Cnd.likeEX("title", pageForm.getTitle())); + } + if (Strings.isNotBlank(pageForm.getTagType())) { + cnd.and("tagType", "=", pageForm.getTagType()); + } + cnd.desc("publishTime").desc("createdAt"); + Pagination pagination = listPage(pageForm.getPageNumber(), pageForm.getPageSize(), cnd); + Map tagNameMap = getTagNameMap(); + List list = pagination.getList().stream() + .map(benefit -> toVO(benefit, tagNameMap)) + .collect(Collectors.toList()); + return new Pagination<>(pagination.getPageNo(), pagination.getPageSize(), pagination.getTotalCount(), list); + } + + @Override + public InclusiveBenefitVO findVO(String id, boolean onlyPublished) { + if (StrUtil.isBlank(id)) { + return null; + } + Cnd cnd = Cnd.where("id", "=", id).and("delFlag", "=", false); + if (onlyPublished) { + cnd.and("status", "=", 1); + } + return toVO(fetch(cnd), getTagNameMap()); + } + + @Override + public List tagOptions() { + Sys_dict parent = dao().fetch(Sys_dict.class, Cnd.where("code", "=", TAG_DICT_CODE) + .and("disabled", "=", false).and("delFlag", "=", false)); + if (parent == null) { + return Collections.emptyList(); + } + return dao().query(Sys_dict.class, Cnd.where("parentId", "=", parent.getId()) + .and("disabled", "=", false).and("delFlag", "=", false).asc("location")) + .stream() + .map(dict -> NutMap.NEW().addv("text", dict.getName()).addv("value", dict.getCode())) + .collect(Collectors.toList()); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void saveBenefit(InclusiveBenefit benefit, Integer status) { + validateStatus(status); + InclusiveBenefit target; + if (StrUtil.isBlank(benefit.getId())) { + target = new InclusiveBenefit(); + target.setYear(benefit.getYear() == null ? DateUtil.year(DateUtil.date()) : benefit.getYear()); + } else { + target = fetch(Cnd.where("id", "=", benefit.getId()).and("delFlag", "=", false)); + if (target == null) { + throw new IllegalArgumentException("幸福工荟不存在或已删除"); + } + target.setYear(benefit.getYear() == null ? target.getYear() : benefit.getYear()); + } + target.setTitle(benefit.getTitle()); + target.setSummary(benefit.getSummary()); + target.setTagType(benefit.getTagType()); + target.setContent(benefit.getContent()); + // 沿用西财的配置字段;本次迁移不将性别配置扩展为新的访问控制规则。 + target.setSexLimit(benefit.getSexLimit() == null ? 0 : benefit.getSexLimit()); + target.setCover(benefit.getCover()); + target.setLinkUrl(benefit.getLinkUrl()); + target.setStatus(status == null ? 0 : status); + if (Integer.valueOf(1).equals(target.getStatus())) { + target.setPublishTime(DateUtil.now()); + } + if (StrUtil.isBlank(target.getId())) { + insert(target); + } else { + update(target); + } + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void updateStatus(String id, Integer status) { + validateStatus(status); + requireId(id); + InclusiveBenefit benefit = fetch(Cnd.where("id", "=", id).and("delFlag", "=", false)); + if (benefit == null) { + throw new IllegalArgumentException("幸福工荟不存在或已删除"); + } + Integer targetStatus = status == null ? 0 : status; + Chain chain = Chain.make("status", targetStatus); + if (Integer.valueOf(1).equals(targetStatus)) { + chain.add("publishTime", DateUtil.now()); + } + update(chain, Cnd.where("id", "=", id)); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void deleteBenefit(String id) { + requireId(id); + InclusiveBenefit benefit = fetch(Cnd.where("id", "=", id).and("delFlag", "=", false)); + if (benefit == null) { + throw new IllegalArgumentException("幸福工荟不存在或已删除"); + } + vDelete(id); + } + + /** 写入操作只支持草稿和发布两种状态,避免非法值产生无法管理的内容。 */ + private void validateStatus(Integer status) { + if (status != null && status != 0 && status != 1) { + throw new IllegalArgumentException("发布状态只能为0或1"); + } + } + + /** 修改状态和删除必须明确指定记录,不允许缺失主键。 */ + private void requireId(String id) { + if (StrUtil.isBlank(id)) { + throw new IllegalArgumentException("内容ID不能为空"); + } + } + + /** 将实体映射为列表/详情对象,附加标签、性别配置和发布状态的展示名称。 */ + private InclusiveBenefitVO toVO(InclusiveBenefit benefit, Map tagNameMap) { + if (benefit == null) { + return null; + } + return new InclusiveBenefitVO() + .setId(benefit.getId()) + .setYear(benefit.getYear()) + .setTitle(benefit.getTitle()) + .setSummary(benefit.getSummary()) + .setTagType(benefit.getTagType()) + .setTagTypeName(tagNameMap.get(benefit.getTagType())) + .setContent(benefit.getContent()) + .setSexLimit(benefit.getSexLimit()) + .setSexLimitName(getSexLimitName(benefit.getSexLimit())) + .setCover(benefit.getCover()) + .setLinkUrl(benefit.getLinkUrl()) + .setStatus(benefit.getStatus()) + .setStatusName(Integer.valueOf(1).equals(benefit.getStatus()) ? "已发布" : "草稿") + .setCreatedAt(benefit.getCreatedAt()) + .setUpdatedAt(benefit.getUpdatedAt()) + .setPublishTime(benefit.getPublishTime()); + } + + private Map getTagNameMap() { + List options = tagOptions(); + // 字典编码重复时保留排序靠前的一项,避免整个列表查询失败。 + return options.stream().collect(Collectors.toMap(option -> option.getString("value"), + option -> option.getString("text"), (first, ignored) -> first)); + } + + private String getSexLimitName(Integer sexLimit) { + if (Integer.valueOf(1).equals(sexLimit)) { + return "男性"; + } + if (Integer.valueOf(2).equals(sexLimit)) { + return "女性"; + } + return "全部开放"; + } +} diff --git a/src/main/java/com/budwk/app/zhgh/inclusive/vo/InclusiveBenefitVO.java b/src/main/java/com/budwk/app/zhgh/inclusive/vo/InclusiveBenefitVO.java new file mode 100644 index 0000000..4e92280 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/inclusive/vo/InclusiveBenefitVO.java @@ -0,0 +1,28 @@ +package com.budwk.app.zhgh.inclusive.vo; + +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * 幸福工荟列表和详情展示对象。 + */ +@Data +@Accessors(chain = true) +public class InclusiveBenefitVO { + private String id; + private Integer year; + private String title; + private String summary; + private String tagType; + private String tagTypeName; + private String content; + private Integer sexLimit; + private String sexLimitName; + private String cover; + private String linkUrl; + private Integer status; + private String statusName; + private Long createdAt; + private Long updatedAt; + private String publishTime; +} diff --git a/src/main/java/com/budwk/app/zhgh/map/TMapController.java b/src/main/java/com/budwk/app/zhgh/map/TMapController.java index 9b89735..5328912 100644 --- a/src/main/java/com/budwk/app/zhgh/map/TMapController.java +++ b/src/main/java/com/budwk/app/zhgh/map/TMapController.java @@ -31,7 +31,7 @@ import javax.validation.Valid; @At("/platform/TMap") public class TMapController { - private static String KEY = "MLLBZ-GQECI-ASXG7-5GNOZ-XW2OF-H5BVH"; + private static String KEY = "NYNBZ-K62KI-DQJGU-U4NJ5-CSME7-ULFMU"; @Inject private SysConfigService configService; diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/controller/PersonApiConfigController.java b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/controller/PersonApiConfigController.java new file mode 100644 index 0000000..dd3340a --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/controller/PersonApiConfigController.java @@ -0,0 +1,170 @@ +package com.budwk.app.zhgh.personGovernance.apiConfig.controller; + +import com.budwk.app.base.param.PageForm; +import com.budwk.app.zhgh.personGovernance.apiConfig.service.PersonApiConfigService; +import com.budwk.app.zhgh.personGovernance.apiConfig.service.PersonGovernanceSyncService; +import cn.dev33.satoken.annotation.SaCheckPermission; +import com.budwk.app.base.result.Result; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.lang.Strings; +import org.springframework.util.Assert; +import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; +import org.nutz.mvc.annotation.Param; + +/** 人员治理API配置页面与接口。 */ +@IocBean +@Ok("json:full") +@At("/platform/personGovernance/apiConfig") +public class PersonApiConfigController { + @Inject + private PersonApiConfigService personApiConfigService; + + @Inject + private PersonGovernanceSyncService personGovernanceSyncService; + + @At("") + @Ok("beetl:/platform/personGovernance/apiConfig/index.html") + @SaCheckPermission("data.person.governance.api") + public void index() { + } + + @At("/form") + @Ok("beetl:/platform/personGovernance/apiConfig/form.html") + @SaCheckPermission("data.person.governance.api") + public void form() { + } + + /** + * API配置分页查询。 + * + * @param pageForm pageNumber/pageSize为页码和每页数量 + * @param configName 配置名称,支持模糊查询 + * @param sourceType HTTP_API或SYSTEM_TABLE + * @param enabled true查询启用、false查询停用;未传、空字符串或空白表示全部 + * @return Pagination结构,list为记录,totalCount为总数 + */ + @At + @SaCheckPermission("data.person.governance.api") + public Result pageData(PageForm pageForm, @Param("configName") String configName, + @Param("sourceType") String sourceType, + @Param("enabled") String enabled) { + // Nutz会将空字符串直接转换为false;保留原始文本,避免未选择状态时误筛选停用记录。 + Boolean enabledFilter = null; + if (Strings.isNotBlank(enabled)) { + String value = enabled.trim(); + Assert.isTrue("true".equalsIgnoreCase(value) || "false".equalsIgnoreCase(value), "启用状态参数不正确"); + enabledFilter = Boolean.valueOf(value); + } + return Result.success(personApiConfigService.pageData(pageForm, configName, sourceType, enabledFilter)); + } + + @At + @SaCheckPermission("data.person.governance.api") + public Result detail(String id) { + return Result.success(personApiConfigService.detail(id)); + } + + /** + * 保存API配置。 + * + * @param data JSON对象;config为基本配置,mappings为字段映射数组,params为带请求范围的参数数组 + * @return PersonApiConfig,包含保存后的配置ID + */ + @At + @Aop(TransAop.READ_COMMITTED) + @SaCheckPermission("data.person.governance.api.edit") + public Result save(@Param("data") String data) { + return Result.success(personApiConfigService.saveConfig(data)); + } + + @At + @Aop(TransAop.READ_COMMITTED) + @SaCheckPermission("data.person.governance.api.delete") + public Result delete(String id) { + personApiConfigService.deleteConfig(id); + return Result.success(); + } + + @At + @SaCheckPermission("data.person.governance.api") + public Result tableNames() { + return Result.success(personApiConfigService.tableNames()); + } + + @At + @SaCheckPermission("data.person.governance.api") + public Result columns(String tableName) { + return Result.success(personApiConfigService.columns(tableName)); + } + + @At + @SaCheckPermission("data.person.governance.api.test") + /** + * 测试指定范围的第三方接口配置。 + * + * @param configId API配置ID + * @param requestScope TOKEN表示Token接口,DATA表示人员数据接口 + * @param testCondition 可选的临时查询条件,测试数据接口时追加到Query参数 + * @return NutMap,包含success、status、elapsed和data + */ + public Result test(String configId, String requestScope, + @Param("testCondition") String testCondition) { + return Result.success(personApiConfigService.test(configId, requestScope, testCondition)); + } + + /** + * 根据页面当前配置获取第三方Token,不要求先保存配置。 + * @param data JSON对象:config为请求配置,params为Header/Query/Body参数数组 + * @return 第三方响应解析后的JSON值,非JSON为原文;由ViReturn放入统一响应的data字段 + */ + @At(value = "/getThirdPartyToken", methods = "POST") + @SaCheckPermission("data.person.governance.api.test") + public Result getThirdPartyToken(@Param("data") String data) { + return Result.success(personApiConfigService.queryThirdParty(data, "TOKEN")); + } + + /** + * 根据页面当前配置获取第三方人员数据,不执行人员同步。 + * @param data JSON对象:config为请求配置,params为Header/Query/Body参数数组 + * @return 第三方响应解析后的JSON值,非JSON为原文;由ViReturn放入统一响应的data字段 + */ + @At(value = "/getThirdPartyData", methods = "POST") + @SaCheckPermission("data.person.governance.api.test") + public Result getThirdPartyData(@Param("data") String data) { + return Result.success(personApiConfigService.queryThirdParty(data, "DATA")); + } + + /** 保存当前接口请求及返回结果为可重复执行的测试用例。 */ + @At + @Aop(TransAop.READ_COMMITTED) + @SaCheckPermission("data.person.governance.api.edit") + public Result saveTestCase(@Param("data") String data) { + return Result.success(personApiConfigService.saveTestCase(data)); + } + + /** id为已保存API配置ID;只追加中间表,返回批次状态、插入数量及失败原因。 */ + @At(methods = "POST") + @Aop(TransAop.READ_COMMITTED) + @SaCheckPermission("data.person.governance.api.test") + public Result sync(@Param("id") String id) { + return Result.success(personGovernanceSyncService.execute(id, "MANUAL")); + } + + /** cron为Quartz周期文本;合法且有下一次触发时间返回统一成功结果,不创建任务。 */ + @At(methods = "POST") + @SaCheckPermission("data.person.governance.api.edit") + public Result validateCron(@Param("cron") String cron) { + personApiConfigService.validateTaskCron(cron); + return Result.success(); + } + + @At + @SaCheckPermission("data.person.governance.member") + public Result selectableConfigs(@Param("currentMatchConfigId") String currentMatchConfigId) { + return Result.success(personApiConfigService.selectableConfigs(currentMatchConfigId)); + } +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/job/PersonGovernanceSyncJob.java b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/job/PersonGovernanceSyncJob.java new file mode 100644 index 0000000..32c3b90 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/job/PersonGovernanceSyncJob.java @@ -0,0 +1,55 @@ +package com.budwk.app.zhgh.personGovernance.apiConfig.job; + +import com.budwk.app.zhgh.personGovernance.apiConfig.service.PersonGovernanceSyncService; +import com.budwk.app.zhgh.personGovernance.apiConfig.model.PersonApiConfig; +import org.nutz.dao.Dao; +import org.nutz.lang.Strings; +import org.nutz.log.Logs; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; +import org.quartz.Job; +import org.quartz.JobExecutionContext; +import org.quartz.DisallowConcurrentExecution; +import org.quartz.CronExpression; +import java.util.Objects; + +/** 每个配置独立周期;只执行JobDataMap中的configId,不扫描所有启用配置。 */ +@IocBean +@DisallowConcurrentExecution +public class PersonGovernanceSyncJob implements Job { + @Inject + private Dao dao; + @Inject + private PersonGovernanceSyncService personGovernanceSyncService; + + @Override + @Aop(TransAop.READ_COMMITTED) + public void execute(JobExecutionContext context) { + String id = context.getMergedJobDataMap().getString("configId"); + if (Strings.isBlank(id)) { + Logs.get().warn("人员同步任务缺少configId,请停用旧扫描任务并通过API配置重新启用"); + return; + } + PersonApiConfig config = dao.fetch(PersonApiConfig.class, id); + if (config == null || Boolean.TRUE.equals(config.getDelFlag()) || !Boolean.TRUE.equals(config.getEnabled()) + || !Objects.equals(config.getTaskId(), context.getJobDetail().getKey().getName()) + || !Objects.equals(config.getTaskCron(), context.getMergedJobDataMap().getString("taskCron"))) { + Logs.get().warn("跳过已停用或已被替换的人员同步任务:" + context.getJobDetail().getKey()); + return; + } + try { + // 重启恢复的旧式Quartz触发器也不允许补执行非周期时间点的misfire。 + if (context.getScheduledFireTime() == null + || !new CronExpression(config.getTaskCron()).isSatisfiedBy(context.getScheduledFireTime())) return; + var batch = personGovernanceSyncService.execute(id, "SCHEDULED"); + if (!"SUCCESS".equals(batch.getStatus())) { + Logs.get().warn("人员中间表同步失败,批次:" + batch.getBatchNo() + ",原因:" + batch.getErrorMessage()); + } + } catch (Exception e) { + Logs.get().warn("人员中间表同步未完成,配置:" + id, e); + } + } +} + diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonApiConfig.java b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonApiConfig.java new file mode 100644 index 0000000..08f81ec --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonApiConfig.java @@ -0,0 +1,150 @@ +package com.budwk.app.zhgh.personGovernance.apiConfig.model; + +import com.budwk.app.base.model.BaseModel; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; +import org.nutz.dao.entity.annotation.*; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +import java.util.Date; + +/** + * 人员数据治理API配置,描述来源、目标表以及接口返回数据的解析方式。 + */ +@Data +@Accessors(chain = true) +@EqualsAndHashCode(callSuper = true) +@Table("person_api_config") +@TableIndexes({@Index(name = "UK_PERSON_API_CONFIG_NAME", fields = {"configName"}, unique = true)}) +public class PersonApiConfig extends BaseModel { + @Name + @Column + @Comment("主键ID") + @PrevInsert(uu32 = true) + @ColDefine(type = ColType.VARCHAR, width = 32) + private String id; + @Column + @Comment("配置名称") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String configName; + @Column + @Comment("来源类型:HTTP_API、SYSTEM_TABLE") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String sourceType; + @Column + @Comment("接口地址") + @ColDefine(type = ColType.VARCHAR, width = 500) + private String requestUrl; + @Column + @Comment("请求方式") + @ColDefine(type = ColType.VARCHAR, width = 10) + private String requestMethod; + @Column + @Comment("超时秒数") + private Integer timeoutSeconds; + @Column + @Comment("来源系统表") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String sourceTable; + @Column + @Comment("目标人员中间表") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String targetTable; + @Column + @Comment("同步类型:APPEND全量新增、UPSERT存在更新否则新增、INSERT_NEW仅新增不存在数据") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String syncMode; + @Column + @Comment("人员唯一标识目标字段") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String identityField; + @Column + @Comment("Body格式") + @ColDefine(type = ColType.VARCHAR, width = 30) + private String bodyType; + @Column + @Comment("JSON请求体") + @ColDefine(type = ColType.TEXT) + private String bodyContent; + @Column + @Comment("Token接口地址") + @ColDefine(type = ColType.VARCHAR, width = 500) + private String tokenRequestUrl; + @Column + @Comment("Token请求方式") + @ColDefine(type = ColType.VARCHAR, width = 10) + private String tokenRequestMethod; + @Column + @Comment("Token请求超时秒数") + private Integer tokenTimeoutSeconds; + @Column + @Comment("Token请求Body格式") + @ColDefine(type = ColType.VARCHAR, width = 30) + private String tokenBodyType; + @Column + @Comment("Token接口JSON请求体") + @ColDefine(type = ColType.TEXT) + private String tokenBodyContent; + @Column + @Comment("Token响应中Access_Token的字段路径") + @ColDefine(type = ColType.VARCHAR, width = 200) + private String tokenValuePath; + @Column + @Comment("Token响应中Expires_in的字段路径") + @ColDefine(type = ColType.VARCHAR, width = 200) + private String tokenExpiresPath; + @Column + @Comment("Token时效单位:SECONDS秒、MILLISECONDS毫秒、TIMESTAMP到期时间戳") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String tokenExpiryUnit; + @Column + @Comment("随配置保存的Token最新查询响应") + @ColDefine(type = ColType.TEXT) + private String tokenQueryResult; + @Column + @Comment("随配置保存的数据接口最新查询响应") + @ColDefine(type = ColType.TEXT) + private String dataQueryResult; + @Column + @Comment("返回码路径") + @ColDefine(type = ColType.VARCHAR, width = 200) + private String responseCodePath; + @Column + @Comment("成功返回值") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String responseSuccessValue; + @Column + @Comment("返回消息路径") + @ColDefine(type = ColType.VARCHAR, width = 200) + private String responseMessagePath; + @Column + @Comment("人员数组路径") + @ColDefine(type = ColType.VARCHAR, width = 200) + private String responseDataPath; + @Column + @Comment("返回总数路径") + @ColDefine(type = ColType.VARCHAR, width = 200) + private String responseTotalPath; + @Column + @Comment("是否启用同步任务") + private Boolean enabled; + @Column + @Comment("同步任务周期:Quartz Cron表达式,停用后才能修改") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String taskCron; + @Column + @Comment("是否在同步完成后执行工会会员与福利会员匹配规则") + private Boolean matchMemberRules; + @Column + @Comment("最近同步时间") + private Date lastSyncTime; + @Column + @Comment("任务ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String taskId; + @Column + @Comment("备注") + @ColDefine(type = ColType.VARCHAR, width = 500) + private String remark; +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonApiFieldMapping.java b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonApiFieldMapping.java new file mode 100644 index 0000000..47ade48 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonApiFieldMapping.java @@ -0,0 +1,52 @@ +package com.budwk.app.zhgh.personGovernance.apiConfig.model; + +import lombok.Data; +import lombok.experimental.Accessors; +import org.nutz.dao.entity.annotation.*; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +/** + * 来源字段到人员中间表字段的映射。 + */ +@Data +@Accessors(chain = true) +@Table("person_api_field_mapping") +@TableIndexes({@Index(name = "UK_PERSON_API_MAPPING_TARGET", fields = {"configId", "targetField"}, unique = true)}) +public class PersonApiFieldMapping { + @Name + @Column + @Comment("主键ID") + @PrevInsert(uu32 = true) + @ColDefine(type = ColType.VARCHAR, width = 32) + private String id; + @Column + @Comment("API配置ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String configId; + @Column + @Comment("来源字段名") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String sourceField; + @Column + @Comment("来源字段说明") + @ColDefine(type = ColType.VARCHAR, width = 200) + private String sourceComment; + @Column + @Comment("系统字段名") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String targetField; + @Column + @Comment("系统字段说明") + @ColDefine(type = ColType.VARCHAR, width = 200) + private String targetComment; + @Column + @Comment("是否必填") + private Boolean required; + @Column + @Comment("是否为人员唯一标识") + private Boolean identityFlag; + @Column + @Comment("排序号") + private Integer sort; +} + diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonApiRequestParam.java b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonApiRequestParam.java new file mode 100644 index 0000000..a7a2daa --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonApiRequestParam.java @@ -0,0 +1,62 @@ +package com.budwk.app.zhgh.personGovernance.apiConfig.model; + +import lombok.Data; +import lombok.experimental.Accessors; +import org.nutz.dao.entity.annotation.*; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +/** + * 第三方接口的Header、Query或Body参数。 + */ +@Data +@Accessors(chain = true) +@Table("person_api_request_param") +public class PersonApiRequestParam { + @Name + @Column + @Comment("主键ID") + @PrevInsert(uu32 = true) + @ColDefine(type = ColType.VARCHAR, width = 32) + private String id; + @Column + @Comment("API配置ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String configId; + @Column + @Comment("请求配置范围:TOKEN、DATA") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String requestScope; + @Column + @Comment("参数位置:HEADER、QUERY、BODY") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String paramLocation; + @Column + @Comment("参数名称") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String paramName; + @Column + @Comment("参数类型") + @ColDefine(type = ColType.VARCHAR, width = 30) + private String paramType; + @Column + @Comment("参数值") + @ColDefine(type = ColType.TEXT) + private String paramValue; + @Column + @Comment("是否必填") + private Boolean required; + @Column + @Comment("参数说明") + @ColDefine(type = ColType.VARCHAR, width = 300) + private String description; + // Java属性和接口JSON仍使用sensitive,仅将数据库列映射为非关键字名称,避免MySQL建表失败。 + @Column("sensitiveFlag") + @Comment("是否为敏感参数") + @ColDefine(type = ColType.INT) + private Integer sensitive; + @Column + @Comment("排序号") + @ColDefine(type = ColType.INT) + private Integer sort; +} + diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonApiTestCase.java b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonApiTestCase.java new file mode 100644 index 0000000..6bc25b6 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonApiTestCase.java @@ -0,0 +1,58 @@ +package com.budwk.app.zhgh.personGovernance.apiConfig.model; + +import com.budwk.app.base.model.BaseModel; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; +import org.nutz.dao.entity.annotation.*; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +import java.util.Date; + +/** + * 已保存的接口测试用例,不参与正式同步。 + */ +@Data +@Accessors(chain = true) +@EqualsAndHashCode(callSuper = true) +@Table("person_api_test_case") +public class PersonApiTestCase extends BaseModel { + @Name + @Column + @Comment("主键ID") + @PrevInsert(uu32 = true) + @ColDefine(type = ColType.VARCHAR, width = 32) + private String id; + @Column + @Comment("API配置ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String configId; + @Column + @Comment("测试请求范围:TOKEN、DATA") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String requestScope; + @Column + @Comment("测试用例名称") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String caseName; + @Column + @Comment("请求参数快照") + @ColDefine(type = ColType.TEXT) + private String requestSnapshot; + @Column + @Comment("响应状态码") + private Integer responseStatus; + @Column + @Comment("响应耗时毫秒数") + private Long responseTime; + @Column + @Comment("响应内容") + @ColDefine(type = ColType.TEXT) + private String responseBody; + @Column + @Comment("测试是否成功") + private Boolean success; + @Column + @Comment("执行时间") + private Date executedAt; +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonSourceRelation.java b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonSourceRelation.java new file mode 100644 index 0000000..16c7079 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonSourceRelation.java @@ -0,0 +1,57 @@ +package com.budwk.app.zhgh.personGovernance.apiConfig.model; + +import com.budwk.app.base.model.BaseModel; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; +import org.nutz.dao.entity.annotation.*; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +import java.util.Date; + +/** + * 某个数据来源中的人员与本地人员的稳定关联。 + */ +@Data +@Accessors(chain = true) +@EqualsAndHashCode(callSuper = true) +@Table("person_source_relation") +@TableIndexes({@Index(name = "UK_PERSON_SOURCE_KEY", fields = {"configId", "sourcePersonKey"}, unique = true)}) +public class PersonSourceRelation extends BaseModel { + @Name + @Column + @Comment("主键ID") + @PrevInsert(uu32 = true) + @ColDefine(type = ColType.VARCHAR, width = 32) + private String id; + @Column + @Comment("API配置ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String configId; + @Column + @Comment("来源人员唯一标识") + @ColDefine(type = ColType.VARCHAR, width = 150) + private String sourcePersonKey; + @Column + @Comment("本地人员用户ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String userId; + @Column + @Comment("人员编号") + @ColDefine(type = ColType.VARCHAR, width = 120) + private String personNo; + @Column + @Comment("最近出现的同步批次ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String lastSeenBatchId; + @Column + @Comment("最近出现时间") + private Date lastSeenTime; + @Column + @Comment("当前来源中是否存在") + private Boolean presentFlag; + @Column + @Comment("来源人员数据摘要") + @ColDefine(type = ColType.VARCHAR, width = 64) + private String sourceDataHash; +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonSyncBatch.java b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonSyncBatch.java new file mode 100644 index 0000000..65a6920 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonSyncBatch.java @@ -0,0 +1,72 @@ +package com.budwk.app.zhgh.personGovernance.apiConfig.model; + +import com.budwk.app.base.model.BaseModel; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; +import org.nutz.dao.entity.annotation.*; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +import java.util.Date; + +/** + * 一次人员同步的执行批次及汇总结果。 + */ +@Data +@Accessors(chain = true) +@EqualsAndHashCode(callSuper = true) +@Table("person_sync_batch") +@TableIndexes({@Index(name = "UK_PERSON_SYNC_BATCH_NO", fields = {"batchNo"}, unique = true)}) +public class PersonSyncBatch extends BaseModel { + @Name + @Column + @Comment("主键ID") + @PrevInsert(uu32 = true) + @ColDefine(type = ColType.VARCHAR, width = 32) + private String id; + @Column + @Comment("API配置ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String configId; + @Column + @Comment("同步批次号") + @ColDefine(type = ColType.VARCHAR, width = 50) + private String batchNo; + @Column + @Comment("触发方式:手动或定时") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String triggerType; + @Column + @Comment("开始时间") + private Date startedAt; + @Column + @Comment("结束时间") + private Date finishedAt; + @Column + @Comment("执行状态") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String status; + @Column + @Comment("来源人员总数") + private Integer sourceCount; + @Column + @Comment("同步成功数量") + private Integer successCount; + @Column + @Comment("新增人员数量") + private Integer newCount; + @Column + @Comment("变更人员数量") + private Integer changeCount; + @Column + @Comment("异常人员数量") + private Integer exceptionCount; + @Column + @Comment("失败错误信息") + @ColDefine(type = ColType.TEXT) + private String errorMessage; + @Column + @Comment("原始文件关联ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String rawFileReid; +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/service/PersonApiConfigService.java b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/service/PersonApiConfigService.java new file mode 100644 index 0000000..020d588 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/service/PersonApiConfigService.java @@ -0,0 +1,63 @@ +package com.budwk.app.zhgh.personGovernance.apiConfig.service; + +import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.param.PageForm; +import com.budwk.app.base.service.BaseService; +import com.budwk.app.zhgh.personGovernance.apiConfig.model.PersonApiConfig; +import com.budwk.app.zhgh.personGovernance.apiConfig.model.PersonApiFieldMapping; +import com.budwk.app.zhgh.personGovernance.apiConfig.model.PersonApiRequestParam; +import com.budwk.app.zhgh.personGovernance.apiConfig.model.PersonApiTestCase; +import org.nutz.lang.util.NutMap; + +import java.util.List; + +/** API配置业务入口。 */ +public interface PersonApiConfigService extends BaseService { + Pagination pageData(PageForm pageForm, String configName, String sourceType, Boolean enabled); + + NutMap detail(String id); + + PersonApiConfig saveConfig(PersonApiConfig config, List mappings, + List params); + + /** + * 解析页面提交的配置、字段映射和请求参数并保存。 + * + * @param data JSON对象,包含 config、mappings、params + * @return 保存后的API配置 + */ + PersonApiConfig saveConfig(String data); + + void deleteConfig(String id); + + List tableNames(); + + List columns(String tableName); + + NutMap test(String configId, String requestScope, String testCondition); + + /** 使用页面当前config、params查询,返回第三方JSON值;非JSON返回原文,不写数据库。 */ + Object queryThirdParty(String data, String requestScope); + + /** page为当前页码或起始数,pageSize为数量或区间长度;结束数=page+pageSize,返回第三方JSON。 */ + Object querySyncPage(PersonApiConfig config, List params, long page, int pageSize); + + /** config和params为待保存或已保存配置;返回mode、cursor、size、step,NONE和LIMIT均只请求一次。 */ + NutMap pagination(PersonApiConfig config, List params); + + /** 读取响应中的箭头路径或历史JSONPath;空路径表示整个响应,路径缺失返回null。 */ + Object readResponsePath(Object response, String path); + + /** 校验Quartz周期语法及下一次执行时间;非法时抛出明确的参数错误。 */ + void validateTaskCron(String cron); + + PersonApiTestCase saveTestCase(PersonApiTestCase testCase); + + /** + * @param data 测试用例JSON + * @return 保存后的测试用例 + */ + PersonApiTestCase saveTestCase(String data); + + List selectableConfigs(String currentMatchConfigId); +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/service/PersonGovernanceSyncService.java b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/service/PersonGovernanceSyncService.java new file mode 100644 index 0000000..251a87e --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/service/PersonGovernanceSyncService.java @@ -0,0 +1,10 @@ +package com.budwk.app.zhgh.personGovernance.apiConfig.service; + +import com.budwk.app.zhgh.personGovernance.apiConfig.model.PersonSyncBatch; + +/** 正式人员治理同步入口。 */ +public interface PersonGovernanceSyncService { + PersonSyncBatch execute(String configId, String triggerType); + +} + diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/service/impl/PersonApiConfigServiceImpl.java b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/service/impl/PersonApiConfigServiceImpl.java new file mode 100644 index 0000000..63f7f89 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/service/impl/PersonApiConfigServiceImpl.java @@ -0,0 +1,951 @@ +package com.budwk.app.zhgh.personGovernance.apiConfig.service.impl; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.digest.DigestUtil; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import cn.hutool.http.Method; +import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.constant.RedisConstant; +import com.budwk.app.base.dao.CndPlus; +import com.budwk.app.base.param.PageForm; +import com.budwk.app.base.service.impl.BaseServiceImpl; +import com.budwk.app.sys.models.Sys_task; +import com.budwk.app.zhgh.personGovernance.apiConfig.job.PersonGovernanceSyncJob; +import com.budwk.app.zhgh.personGovernance.apiConfig.model.PersonApiConfig; +import com.budwk.app.zhgh.personGovernance.apiConfig.model.PersonApiFieldMapping; +import com.budwk.app.zhgh.personGovernance.apiConfig.model.PersonApiRequestParam; +import com.budwk.app.zhgh.personGovernance.apiConfig.model.PersonApiTestCase; +import com.budwk.app.zhgh.personGovernance.apiConfig.service.PersonApiConfigService; +import com.budwk.app.zhgh.personGovernance.memberMatch.model.PersonMemberMatchConfig; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.dao.Cnd; +import org.nutz.dao.Dao; +import org.nutz.dao.FieldFilter; +import org.nutz.dao.Sqls; +import org.nutz.dao.sql.Sql; +import org.nutz.integration.jedis.RedisService; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.lang.Strings; +import org.nutz.json.Json; +import org.nutz.lang.random.R; +import org.nutz.lang.util.NutMap; +import org.nutz.log.Logs; +import org.springframework.util.Assert; +import org.quartz.*; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Pattern; + +/** API配置、数据库元数据和只读测试实现。 */ +@IocBean(args = {"refer:dao"}) +public class PersonApiConfigServiceImpl extends BaseServiceImpl implements PersonApiConfigService { + private static final Pattern SAFE_IDENTIFIER = Pattern.compile("^[A-Za-z0-9_]+$"); + /** JSON节点删除标记:Token接口未配置时,用于彻底移除数据请求中的Token字段或数组项。 */ + private static final Object OMIT_TOKEN_PARAMETER = new Object(); + + @Inject + private RedisService redisService; + + @Inject + private Scheduler scheduler; + + public PersonApiConfigServiceImpl(Dao dao) { + super(dao); + } + + @Override + public Pagination pageData(PageForm pageForm, String configName, String sourceType, Boolean enabled) { + CndPlus cnd = CndPlus.create(); + cnd.andEX("configName", "like", Strings.isBlank(configName) ? null : "%" + configName + "%"); + cnd.andEX("sourceType", "=", sourceType); + cnd.andEX("enabled", "=", enabled); + // 历史保存可能将删除标记写为NULL;仅明确标记为true的配置不在列表展示。 + cnd.and(Cnd.exps("delFlag", "=", false).or("delFlag", "is", null)).desc("updatedAt"); + return listPage(pageForm.getPageNumber(), pageForm.getPageSize(), cnd); + } + + @Override + public NutMap detail(String id) { + PersonApiConfig config = fetch(id); + Assert.notNull(config, "API配置不存在"); + // MySQL仅保存Redis Key;详情接口仍返回原始响应,保持现有页面回显协议不变。 + config.setDataQueryResult(resolveDataQueryResult(config.getDataQueryResult())); + List params = dao().query( + PersonApiRequestParam.class, + Cnd.where("configId", "=", id).asc("sort") + ); + // 历史请求参数没有范围标识,统一按数据接口参数回显,保证旧配置兼容。 + params.forEach(param -> { + if (Strings.isBlank(param.getRequestScope())) { + param.setRequestScope("DATA"); + } + }); + List testCases = dao().query( + PersonApiTestCase.class, + Cnd.where("configId", "=", id).desc("executedAt") + ); + testCases.forEach(testCase -> { + if (Strings.isBlank(testCase.getRequestScope())) { + testCase.setRequestScope("DATA"); + } + }); + return NutMap.NEW() + .addv("config", config) + .addv("mappings", dao().query(PersonApiFieldMapping.class, Cnd.where("configId", "=", id).asc("sort"))) + .addv("params", params) + .addv("testCases", testCases); + } + + /** + * 保存两套请求配置、最新响应和未完成的映射/参数。仅配置名称必填,执行时再校验完整性。 + * + * @param config API配置基本信息 + * @param mappings 来源字段到人员中间表字段的映射 + * @param params Token、数据两个范围下的Header、Query、Body请求参数 + * @return 保存后的API配置 + */ + @Override + @Aop(TransAop.READ_COMMITTED) + public PersonApiConfig saveConfig(PersonApiConfig config, List mappings, + List params) { + Assert.notNull(config, "配置不能为空"); + Assert.hasText(config.getConfigName(), "请填写配置名称"); + PersonApiConfig stored = Strings.isBlank(config.getId()) ? null : fetch(config.getId()); + if (stored != null) { + // 页面启停与周期均为待保存状态;保存时统一更新调度,并保留原任务归属。 + config.setTaskId(stored.getTaskId()); + } else { + config.setTaskId(null); + } + config.setTaskCron(Strings.sNull(config.getTaskCron()).trim()); + // 历史配置未包含该字段时按关闭处理,避免保存后意外触发会员身份及角色调整。 + config.setMatchMemberRules(Boolean.TRUE.equals(config.getMatchMemberRules())); + if (Boolean.TRUE.equals(config.getEnabled())) { + validateTaskCron(config.getTaskCron()); + } + if (Strings.isBlank(config.getTokenExpiryUnit())) { + config.setTokenExpiryUnit("SECONDS"); + } + Assert.isTrue(List.of("HTTP_API", "SYSTEM_TABLE").contains(config.getSourceType()), "同步方式不正确"); + mappings = mappings == null ? new ArrayList<>() : mappings; + PersonApiFieldMapping identity = mappings.stream() + .filter(v -> Boolean.TRUE.equals(v.getIdentityFlag())) + .findFirst() + .orElse(null); + config.setIdentityField(identity == null ? null : identity.getTargetField()); + // 历史配置未设置同步类型时保留全部追加行为;去重模式必须有唯一的有效业务标识。 + if (Strings.isBlank(config.getSyncMode())) config.setSyncMode("APPEND"); + Assert.isTrue(List.of("APPEND", "UPSERT", "INSERT_NEW").contains(config.getSyncMode()), "同步类型不正确"); + if (!"APPEND".equals(config.getSyncMode())) { + Assert.isTrue(mappings.stream().filter(v -> Boolean.TRUE.equals(v.getIdentityFlag())).count() == 1 + && identity != null && Strings.isNotBlank(identity.getSourceField()) + && Strings.isNotBlank(identity.getTargetField()) + && !List.of("id", "pullTime").contains(identity.getTargetField()), "请选择一个有效的业务唯一标识映射"); + } + if ("HTTP_API".equals(config.getSourceType())) pagination(config, params); + + PersonApiConfig sameName = fetch( + Cnd.where("configName", "=", config.getConfigName()).and("delFlag", "=", false) + ); + Assert.isTrue(sameName == null || Objects.equals(sameName.getId(), config.getId()), "配置名称已存在"); + String previousDataQueryResultKey = null; + String createdDataQueryResultKey = null; + try { + if (Strings.isBlank(config.getId())) { + if (Strings.isNotBlank(config.getDataQueryResult())) { + createdDataQueryResultKey = buildDataQueryResultKey(); + redisService.set(createdDataQueryResultKey, config.getDataQueryResult()); + config.setDataQueryResult(createdDataQueryResultKey); + } else { + config.setDataQueryResult(null); + } + config.setEnabled(Boolean.TRUE.equals(config.getEnabled())); + insert(config); + } else { + PersonApiConfig previous = fetch(config.getId()); + Assert.notNull(previous, "API配置不存在"); + // 删除标记由删除接口维护,普通保存保留数据库值,避免前端漏传或改写该标记。 + config.setDelFlag(previous.getDelFlag()); + previousDataQueryResultKey = isDataQueryResultKey(previous.getDataQueryResult()) + ? previous.getDataQueryResult() : null; + // 老页面未提交响应字段时保留已保存响应;空字符串则表示明确清空。 + if (config.getTokenQueryResult() == null) { + config.setTokenQueryResult(previous.getTokenQueryResult()); + } + if (config.getDataQueryResult() == null) { + config.setDataQueryResult(previous.getDataQueryResult()); + } else if (Strings.isBlank(config.getDataQueryResult())) { + config.setDataQueryResult(null); + } else if (previousDataQueryResultKey != null + && (Objects.equals(previousDataQueryResultKey, config.getDataQueryResult()) + || Objects.equals(redisService.get(previousDataQueryResultKey), config.getDataQueryResult()))) { + // 页面回传内容未变化时复用原Key,避免每次保存都生成无效缓存。 + config.setDataQueryResult(previousDataQueryResultKey); + } else { + createdDataQueryResultKey = buildDataQueryResultKey(); + redisService.set(createdDataQueryResultKey, config.getDataQueryResult()); + config.setDataQueryResult(createdDataQueryResultKey); + } + // 最近同步时间仅由同步完成逻辑维护,排除该列,避免表单空值、旧值或并发保存覆盖最新同步结果。 + dao().update(config, FieldFilter.create(PersonApiConfig.class, null, "^lastSyncTime$", false)); + dao().clear(PersonApiFieldMapping.class, Cnd.where("configId", "=", config.getId())); + dao().clear(PersonApiRequestParam.class, Cnd.where("configId", "=", config.getId())); + } + int index = 1; + for (PersonApiFieldMapping mapping : mappings) { + // 未选择目标字段的多条草稿行以NULL保存,避免目标字段唯一索引将空字符串视为重复值。 + if (Strings.isBlank(mapping.getTargetField())) { + mapping.setTargetField(null); + } + mapping.setId(null).setConfigId(config.getId()).setSort(index++); + } + if (!mappings.isEmpty()) { + dao().insert(mappings); + } + if ("HTTP_API".equals(config.getSourceType()) && params != null && !params.isEmpty()) { + index = 1; + for (PersonApiRequestParam param : params) { + String requestScope = Strings.isBlank(param.getRequestScope()) ? "DATA" : param.getRequestScope(); + Assert.isTrue(List.of("TOKEN", "DATA").contains(requestScope), "请求配置范围不正确"); + param.setRequestScope(requestScope); + param.setId(null).setConfigId(config.getId()).setSort(index++); + } + dao().insert(params); + } + if (previousDataQueryResultKey != null + && !Objects.equals(previousDataQueryResultKey, config.getDataQueryResult())) { + deleteDataQueryResultQuietly(previousDataQueryResultKey); + } + reconcileTask(config); + return config; + } catch (RuntimeException e) { + // 数据库操作失败时回收本次新建的缓存,避免产生无引用Key。 + deleteDataQueryResultQuietly(createdDataQueryResultKey); + throw e; + } + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public PersonApiConfig saveConfig(String data) { + NutMap payload = Json.fromJson(NutMap.class, data); + PersonApiConfig config = Json.fromJson(PersonApiConfig.class, Json.toJson(payload.get("config"))); + List mappings = Json.fromJsonAsList( + PersonApiFieldMapping.class, Json.toJson(payload.get("mappings"))); + List params = Json.fromJsonAsList( + PersonApiRequestParam.class, Json.toJson(payload.get("params"))); + return saveConfig(config, mappings, params); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void deleteConfig(String id) { + Assert.isNull( + dao().fetch( + PersonMemberMatchConfig.class, + Cnd.where("apiConfigId", "=", id).and("delFlag", "=", false) + ), + "该API配置已绑定会员匹配配置,不能删除" + ); + update(org.nutz.dao.Chain.make("delFlag", true).add("enabled", false), Cnd.where("id", "=", id)); + PersonApiConfig config = fetch(id); + config.setEnabled(false); + reconcileTask(config); + } + + @Override + public List tableNames() { + Sql sql = Sqls.create( + "SELECT table_name tableName,table_comment tableComment FROM information_schema.TABLES " + + "WHERE table_schema=(SELECT DATABASE()) ORDER BY table_name" + ); + return listMap(sql); + } + + @Override + public List columns(String tableName) { + validateTable(tableName); + Sql sql = Sqls.create( + "SELECT column_name columnName,column_comment columnComment,column_type columnType," + + "is_nullable isNullable FROM information_schema.COLUMNS " + + "WHERE table_schema=(SELECT DATABASE()) " + + "AND table_name=@tableName ORDER BY ordinal_position" + ) + .setParam("tableName", tableName); + return listMap(sql); + } + + /** + * 执行指定范围的只读测试,不落人员数据。 + * + * @param configId API配置ID + * @param requestScope TOKEN表示Token接口,DATA表示人员数据接口;空值兼容为DATA + * @param testCondition 可选的临时查询条件,仅数据请求使用 + * @return 请求状态、耗时及原始响应内容 + */ + @Override + public NutMap test(String configId, String requestScope, String testCondition) { + PersonApiConfig config = fetch(configId); + Assert.notNull(config, "请先保存API配置"); + long start = System.currentTimeMillis(); + if ("SYSTEM_TABLE".equals(config.getSourceType())) { + validateTable(config.getSourceTable()); + Sql sql = Sqls.create("SELECT * FROM `" + config.getSourceTable() + "` LIMIT 20"); + List rows = listMap(sql); + return NutMap.NEW() + .addv("success", true) + .addv("status", 200) + .addv("elapsed", System.currentTimeMillis() - start) + .addv("data", rows); + } + String scope = Strings.isBlank(requestScope) ? "DATA" : requestScope.toUpperCase(Locale.ROOT); + Assert.isTrue(List.of("TOKEN", "DATA").contains(scope), "请求配置范围不正确"); + List params = dao().query(PersonApiRequestParam.class, + Cnd.where("configId", "=", configId).asc("sort")); + return executeHttp(config, params, scope, testCondition); + } + + /** + * 解析未保存的页面请求。data包含config和params,范围由调用接口固定,避免前端切换串用参数。 + * @param data 页面配置JSON + * @param requestScope TOKEN或DATA + * @return 第三方JSON对象、数组或基本值;非JSON响应返回原文,失败交给统一异常处理 + */ + @Override + public Object queryThirdParty(String data, String requestScope) { + Assert.hasText(data, "请求配置不能为空"); + NutMap payload = Json.fromJson(NutMap.class, data); + Assert.notNull(payload, "请求配置格式不正确"); + PersonApiConfig config = Json.fromJson(PersonApiConfig.class, Json.toJson(payload.get("config"))); + Assert.notNull(config, "请求配置不能为空"); + List params = payload.get("params") == null + ? new ArrayList<>() + : Json.fromJsonAsList(PersonApiRequestParam.class, Json.toJson(payload.get("params"))); + // 状态信息仅供内部判断,查询接口不额外包装第三方业务字段;同步入口仍沿用原结构。 + NutMap response = executeHttp(config, params, requestScope, null); + Assert.isTrue(response.getBoolean("success"), response.getString("error") == null + ? "第三方返回HTTP错误:" + response.get("status") : response.getString("error")); + String body = response.getString("data"); + if (Strings.isBlank(body)) { + return body; + } + try { + return com.alibaba.fastjson.JSON.parse(body); + } catch (com.alibaba.fastjson.JSONException e) { + // HTML、纯文本等响应不能强制转成对象,保留原文由页面提示。 + return body; + } + } + + /** + * 同步取数:params是保存的两套参数,page为页码或起点,pageSize为数量或区间长度;仅替换请求副本。 + * Access_Token标记传入缓存Token,参数值可使用Bearer ${Access_Token}保留前缀。 + * JSON Body支持Access_Token、页码、数量、起始数、结束数占位符,完整数字占位符转为JSON数字。 + */ + @Override + @Aop(TransAop.READ_COMMITTED) + public Object querySyncPage(PersonApiConfig config, List params, long page, int pageSize) { + boolean sendBody = !List.of("GET", "HEAD").contains(Strings.sNull(config.getRequestMethod()).toUpperCase(Locale.ROOT)); + boolean tokenConfigured = Strings.isNotBlank(config.getTokenRequestUrl()); + List dataParams = params.stream() + .filter(p -> "DATA".equals(normalizeScope(p.getRequestScope()))) + // 隐藏的form-data草稿不参与JSON或GET/HEAD请求,也不能意外触发Token获取。 + .filter(p -> !"BODY".equals(p.getParamLocation()) || (sendBody && "FORM_DATA".equals(config.getBodyType()))) + .toList(); + // Token地址为空代表数据接口无需鉴权:既不请求Token,也不发送任何Token参数。 + boolean usesToken = tokenConfigured && (dataParams.stream().anyMatch(p -> "Access_Token".equals(p.getDescription())) + || (sendBody && "JSON".equals(config.getBodyType()) + && Strings.sNull(config.getBodyContent()).contains("${Access_Token}"))); + String token = usesToken ? cachedToken(config, params) : ""; + PersonApiConfig requestConfig = Json.fromJson(PersonApiConfig.class, Json.toJson(config)); + List requestParams = Json.fromJsonAsList(PersonApiRequestParam.class, Json.toJson(dataParams)); + for (PersonApiRequestParam param : requestParams) { + if ("Access_Token".equals(param.getDescription())) { + String value = Strings.sNull(param.getParamValue()); + param.setParamValue(value.contains("${Access_Token}") ? value.replace("${Access_Token}", token) : token); + } else if ("页码".equals(param.getDescription())) { + param.setParamValue(String.valueOf(page)); + } else if ("数量".equals(param.getDescription())) { + param.setParamValue(String.valueOf(pageSize)); + } else if ("起始数".equals(param.getDescription())) { + param.setParamValue(String.valueOf(page)); + } else if ("结束数".equals(param.getDescription())) { + param.setParamValue(String.valueOf(Math.addExact(page, pageSize))); + } + } + if (sendBody && "JSON".equals(config.getBodyType()) + && Strings.isNotBlank(config.getBodyContent())) { + Object body = com.alibaba.fastjson.JSON.parse(config.getBodyContent()); + if (!tokenConfigured) { + body = normalizeJsonRoot(stripTokenJsonParameters(body)); + } + requestConfig.setBodyContent(com.alibaba.fastjson.JSON.toJSONString(fillJsonParameters(body, token, page, pageSize))); + } + NutMap response = executeHttp(requestConfig, requestParams, "DATA", null); + Assert.isTrue(response.getBoolean("success"), "数据接口请求失败:" + + (response.get("error") == null ? response.get("status") : response.get("error"))); + return parseResponseObject(response.getString("data")); + } + + /** + * 保存与执行共用分页校验。只读取数据请求实际发送的参数,Token及隐藏Body草稿不参与。 + * 返回cursor为初始页码/起点,size为每页数量/区间长度,step为每轮推进值;无分页size=0。 + * 仅配置数量时使用LIMIT模式,表示把数量作为单次请求上限,不追加页码或起点且不循环请求。 + * JSON占位符沿用默认初值:页码1、起始数0、数量100、结束数100。 + */ + @Override + public NutMap pagination(PersonApiConfig config, List params) { + List roles = List.of("页码", "数量", "起始数", "结束数"); + Map values = new LinkedHashMap<>(); + boolean sendBody = !List.of("GET", "HEAD").contains(Strings.sNull(config.getRequestMethod()).toUpperCase(Locale.ROOT)); + for (PersonApiRequestParam param : params == null ? List.of() : params) { + if (!"DATA".equals(normalizeScope(param.getRequestScope()))) continue; + if ("BODY".equals(param.getParamLocation()) && (!sendBody || !"FORM_DATA".equals(config.getBodyType()))) continue; + String role = param.getDescription(); + if (!roles.contains(Strings.sNull(role))) continue; + Assert.isTrue(!values.containsKey(role), "分页标记不能重复:" + role); + Assert.hasText(param.getParamName(), "分页参数名称不能为空:" + role); + Assert.hasText(param.getParamValue(), "分页参数值不能为空:" + role); + values.put(role, param.getParamValue().trim()); + } + if (sendBody && "JSON".equals(config.getBodyType())) { + String body = Strings.sNull(config.getBodyContent()); + for (String role : roles) { + if (body.contains("${" + role + "}")) { + values.putIfAbsent(role, "页码".equals(role) ? "1" : "起始数".equals(role) ? "0" : "100"); + } + } + } + if (values.isEmpty()) return NutMap.NEW().addv("mode", "NONE").addv("cursor", 0L).addv("size", 0).addv("step", 0L); + boolean page = values.keySet().equals(java.util.Set.of("页码", "数量")); + boolean range = values.keySet().equals(java.util.Set.of("起始数", "结束数")); + boolean offset = values.keySet().equals(java.util.Set.of("起始数", "数量")); + boolean limit = values.keySet().equals(java.util.Set.of("数量")); + Assert.isTrue(page || range || offset || limit, + "分页仅支持:页码+数量、起始数+结束数、起始数+数量,或仅数量;不得缺项或混用"); + try { + for (String value : values.values()) Assert.isTrue(value.matches("[0-9]+"), "分页参数必须为非负整数"); + long cursor = limit ? 0L : Long.parseLong(values.get(page ? "页码" : "起始数")); + long size = range ? Math.subtractExact(Long.parseLong(values.get("结束数")), cursor) + : Long.parseLong(values.get("数量")); + Assert.isTrue(size > 0 && size <= 10000, "数量或结束数-起始数须在1至10000之间"); + Math.addExact(cursor, size); + return NutMap.NEW().addv("mode", limit ? "LIMIT" : page ? "PAGE" : range ? "RANGE" : "OFFSET") + .addv("cursor", cursor).addv("size", (int) size) + .addv("step", limit ? 0L : page ? 1L : size); + } catch (NumberFormatException | ArithmeticException e) { + throw new IllegalArgumentException("分页参数超出整数范围", e); + } + } + + /** + * Token接口未配置时递归移除JSON Body中的Token参数,而不是把Token占位符替换为空字符串。 + * Map字段或数组项只要直接以字符串引用Access_Token,就整体省略;其他业务节点保持原结构。 + */ + private Object stripTokenJsonParameters(Object value) { + if (value instanceof Map map) { + Map result = new LinkedHashMap<>(); + map.forEach((key, child) -> { + Object sanitized = stripTokenJsonParameters(child); + if (sanitized != OMIT_TOKEN_PARAMETER) { + result.put(String.valueOf(key), sanitized); + } + }); + return result; + } + if (value instanceof List list) { + List result = new ArrayList<>(); + for (Object child : list) { + Object sanitized = stripTokenJsonParameters(child); + if (sanitized != OMIT_TOKEN_PARAMETER) { + result.add(sanitized); + } + } + return result; + } + if (value instanceof String text && text.contains("${Access_Token}")) { + return OMIT_TOKEN_PARAMETER; + } + return value; + } + + /** JSON根节点不能携带内部删除标记;极端情况下整个Body只有Token时按JSON null发送。 */ + private Object normalizeJsonRoot(Object value) { + return value == OMIT_TOKEN_PARAMETER ? null : value; + } + + /** JSON按节点替换而不是拼接字符串,避免Token中的引号破坏请求体。 */ + private Object fillJsonParameters(Object value, String token, long page, int pageSize) { + if (value instanceof Map map) { + Map result = new LinkedHashMap<>(); + map.forEach((k, v) -> result.put(String.valueOf(k), fillJsonParameters(v, token, page, pageSize))); + return result; + } + if (value instanceof List list) { + return list.stream().map(v -> fillJsonParameters(v, token, page, pageSize)).toList(); + } + if (value instanceof String text) { + if ("${页码}".equals(text)) return page; + if ("${数量}".equals(text)) return pageSize; + if ("${起始数}".equals(text)) return page; + if ("${结束数}".equals(text)) return Math.addExact(page, pageSize); + return text.replace("${Access_Token}", token).replace("${页码}", String.valueOf(page)) + .replace("${数量}", String.valueOf(pageSize)).replace("${起始数}", String.valueOf(page)) + .replace("${结束数}", String.valueOf(Math.addExact(page, pageSize))); + } + return value; + } + + /** Token缓存按配置及Token请求指纹隔离,配置修改后不会误用旧缓存。 */ + @Aop(TransAop.READ_COMMITTED) + private String cachedToken(PersonApiConfig config, List params) { + Assert.hasText(config.getTokenValuePath(), "请填写Access_Token映射路径"); + Assert.hasText(config.getTokenExpiresPath(), "请填写Expires_in映射路径"); + List tokenParams = params.stream() + .filter(p -> "TOKEN".equals(p.getRequestScope())).toList(); + // 只纳入影响Token的请求内容,避免保存响应、页码变化使缓存失效。 + List fingerprint = new ArrayList<>(); + fingerprint.add(config.getTokenRequestUrl()); + fingerprint.add(config.getTokenRequestMethod()); + fingerprint.add(config.getTokenBodyType()); + fingerprint.add(config.getTokenBodyContent()); + fingerprint.add(config.getTokenValuePath()); + fingerprint.add(config.getTokenExpiresPath()); + fingerprint.add(config.getTokenExpiryUnit()); + tokenParams.forEach(p -> fingerprint.add(List.of(Strings.sNull(p.getParamLocation()), + Strings.sNull(p.getParamName()), Strings.sNull(p.getParamValue())))); + String key = RedisConstant.PLATFORM_REDIS_PREFIX + "personGovernance:token:" + config.getId() + + ":" + DigestUtil.sha256Hex(Json.toJson(fingerprint)); + String cached = redisService.get(key); + if (Strings.isNotBlank(cached)) return cached; + String owner = R.UU32(); + String lockKey = key + ":refresh"; + Assert.isTrue("OK".equals(redisService.set(lockKey, owner, "NX", "EX", 650)), "Token正在刷新,请稍后重试同步"); + try { + cached = redisService.get(key); + if (Strings.isNotBlank(cached)) return cached; + long requestedAt = System.currentTimeMillis(); + NutMap result = executeHttp(config, tokenParams, "TOKEN", null); + Assert.isTrue(result.getBoolean("success"), "Token接口请求失败"); + Object response = parseResponseObject(result.getString("data")); + Object tokenValue = readResponsePath(response, config.getTokenValuePath()); + Assert.isTrue(tokenValue instanceof String && Strings.isNotBlank((String) tokenValue), "Access_Token映射结果不是有效字符串"); + Object expiry = readResponsePath(response, config.getTokenExpiresPath()); + long ttl = tokenTtlMillis(expiry, config.getTokenExpiryUnit(), requestedAt, System.currentTimeMillis()); + redisService.set(key, (String) tokenValue, "NX", "PX", ttl); + return (String) tokenValue; + } finally { + redisService.eval("if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end", + List.of(lockKey), List.of(owner)); + } + } + + /** 时效为正整数秒/毫秒,或10位秒级、13位毫秒级到期时间戳;扣除请求耗时。 */ + private long tokenTtlMillis(Object expiry, String unit, long requestedAt, long now) { + Assert.notNull(expiry, "Expires_in路径未找到有效期"); + String text = String.valueOf(expiry).trim(); + Assert.isTrue(text.matches("[0-9]+"), "Expires_in必须为正整数"); + try { + long value = Long.parseLong(text); + String actualUnit = Strings.isBlank(unit) ? "SECONDS" : unit; + long expiresAt; + if ("TIMESTAMP".equals(actualUnit)) { + Assert.isTrue(text.length() == 10 || text.length() == 13, "到期时间戳必须为10位秒或13位毫秒"); + expiresAt = text.length() == 10 ? Math.multiplyExact(value, 1000L) : value; + } else { + Assert.isTrue(List.of("SECONDS", "MILLISECONDS").contains(actualUnit), "Token时效单位不正确"); + long millis = "SECONDS".equals(actualUnit) ? Math.multiplyExact(value, 1000L) : value; + expiresAt = Math.addExact(requestedAt, millis); + } + long remaining = Math.subtractExact(expiresAt, now); + Assert.isTrue(remaining > 0, "Token已过期或有效期无效"); + // 最多提前1秒失效,降低临近到期时拿到无效Token的风险。 + return Math.max(1, remaining - Math.min(1000, remaining / 10)); + } catch (ArithmeticException | NumberFormatException e) { + throw new IllegalArgumentException("Token有效期超出支持范围", e); + } + } + + /** 展开最多20层重复编码的JSON;普通Token字符串保持原样。 */ + private Object parseResponseObject(Object value) { + for (int i = 0; i < 20 && value instanceof String; i++) { + String text = ((String) value).trim(); + if (!(text.startsWith("{") || text.startsWith("[") || text.startsWith("\""))) break; + try { + value = com.alibaba.fastjson.JSON.parse(text); + } catch (com.alibaba.fastjson.JSONException e) { + throw new IllegalArgumentException("第三方返回结果不是合法JSON", e); + } + } + return value; + } + + @Override + public Object readResponsePath(Object response, String path) { + Object value = parseResponseObject(response); + if (Strings.isBlank(path) || "$".equals(path.trim())) return value; + String expression = path.trim(); + if (expression.contains("->")) { + for (String field : expression.split("->", -1)) { + Assert.hasText(field.trim(), "映射路径包含空节点"); + value = parseResponseObject(value); + if (!(value instanceof Map map)) return null; + value = map.get(field.trim()); + } + } else if (value instanceof Map map && map.containsKey(expression)) { + value = map.get(expression); + } else { + value = com.alibaba.fastjson.JSONPath.eval(value, expression.startsWith("$") ? expression : "$." + expression); + } + return parseResponseObject(value); + } + + /** 已保存测试和页面查询共享HTTP组装;校验仅在实际请求时执行,响应不自动落库。 */ + private NutMap executeHttp(PersonApiConfig config, List params, + String scope, String testCondition) { + Assert.isTrue(List.of("TOKEN", "DATA").contains(scope), "请求配置范围不正确"); + long start = System.currentTimeMillis(); + boolean omitToken = "DATA".equals(scope) && Strings.isBlank(config.getTokenRequestUrl()); + params = params.stream() + .filter(param -> scope.equals(normalizeScope(param.getRequestScope()))) + // 未配置Token接口时,Header、Query及Form Data中的Token参数均不进入实际请求。 + .filter(param -> !omitToken || !"Access_Token".equals(param.getDescription())) + .toList(); + String requestUrl = "TOKEN".equals(scope) ? config.getTokenRequestUrl() : config.getRequestUrl(); + String requestMethod = "TOKEN".equals(scope) ? config.getTokenRequestMethod() : config.getRequestMethod(); + Integer timeoutSeconds = "TOKEN".equals(scope) ? config.getTokenTimeoutSeconds() : config.getTimeoutSeconds(); + String bodyType = "TOKEN".equals(scope) ? config.getTokenBodyType() : config.getBodyType(); + String bodyContent = "TOKEN".equals(scope) ? config.getTokenBodyContent() : config.getBodyContent(); + Assert.hasText(requestUrl, "请填写" + ("TOKEN".equals(scope) ? "Token" : "数据") + "请求地址"); + Assert.hasText(requestMethod, "请选择请求方式"); + Method method = Method.valueOf(requestMethod.trim().toUpperCase(Locale.ROOT)); + boolean sendBody = method != Method.GET && method != Method.HEAD; + Assert.isTrue(requestUrl.startsWith("http://") || requestUrl.startsWith("https://"), + "请求地址必须以http://或https://开头"); + Assert.isTrue(timeoutSeconds != null && timeoutSeconds >= 1 && timeoutSeconds <= 300, + "超时必须在1000至300000毫秒之间"); + for (PersonApiRequestParam param : params) { + // GET/HEAD不使用Body;JSON模式也不校验暂存的form-data行。 + if ("BODY".equals(param.getParamLocation()) && (!sendBody || "JSON".equals(bodyType))) { + continue; + } + Assert.hasText(param.getParamName(), "请求参数名称不能为空"); + Assert.isTrue(List.of("HEADER", "QUERY", "BODY").contains(param.getParamLocation()), + "请求参数位置不正确"); + if (Boolean.TRUE.equals(param.getRequired())) { + Assert.hasText(param.getParamValue(), "必填请求参数值不能为空:" + param.getParamName()); + } + } + String url = appendQuery(requestUrl, params, "DATA".equals(scope) ? testCondition : null); + // Query已按UTF-8编码;禁用URL二次解码/编码,保留%2B等特殊字符的原始含义。 + HttpRequest request = HttpRequest.of(url, null) + .method(method) + .timeout((timeoutSeconds == null ? 30 : timeoutSeconds) * 1000); + Map form = new LinkedHashMap<>(); + for (PersonApiRequestParam param : params) { + if ("HEADER".equals(param.getParamLocation())) { + request.header(param.getParamName(), Strings.sNull(param.getParamValue())); + } else if (sendBody && "BODY".equals(param.getParamLocation()) && "FORM_DATA".equals(bodyType)) { + form.put(param.getParamName(), param.getParamValue()); + } + } + if (sendBody && "JSON".equals(bodyType)) { + if (StrUtil.isNotBlank(bodyContent)) { + try { + Object body = com.alibaba.fastjson.JSON.parse(bodyContent); + if (omitToken) { + bodyContent = com.alibaba.fastjson.JSON.toJSONString( + normalizeJsonRoot(stripTokenJsonParameters(body)) + ); + } + } catch (com.alibaba.fastjson.JSONException e) { + throw new IllegalArgumentException("Body不是有效的JSON,请检查后重试", e); + } + request.body(bodyContent).header("Content-Type", "application/json; charset=UTF-8"); + } + } else if (sendBody && "FORM_DATA".equals(bodyType) && !form.isEmpty()) { + // 当前项目Hutool通过Content-Type识别纯文本multipart,并自动生成boundary。 + request.form(form).header("Content-Type", "multipart/form-data"); + } + try (HttpResponse response = request.execute()) { + String responseBody = response.body(); + // 按排查要求打印第三方响应原文;返回值复用同一正文,不附加请求参数或统计信息。 + Logs.get().info(responseBody); + return NutMap.NEW() + .addv("success", response.isOk()) + .addv("status", response.getStatus()) + .addv("elapsed", System.currentTimeMillis() - start) + .addv("data", responseBody); + } catch (cn.hutool.core.io.IORuntimeException | cn.hutool.http.HttpException e) { + // 无HTTP响应时status为null,不伪造第三方状态码,也不把请求头或Token写入错误详情。 + String error = "第三方请求失败,请检查服务地址、网络连接及服务端日志"; + Throwable cause = e; + for (int depth = 0; cause != null && depth < 20; depth++, cause = cause.getCause()) { + if (cause instanceof java.net.SocketTimeoutException) { + error = "第三方连接或读取响应超时"; + break; + } + if (cause instanceof java.net.UnknownHostException) { + error = "第三方域名解析失败"; + break; + } + if (cause instanceof java.net.ConnectException) { + error = "无法连接第三方服务,请检查地址、端口及网络"; + break; + } + if (cause instanceof javax.net.ssl.SSLException) { + error = "第三方HTTPS连接失败,请检查证书及TLS配置"; + break; + } + } + return NutMap.NEW().addv("success", false).addv("status", null) + .addv("elapsed", System.currentTimeMillis() - start).addv("data", null).addv("error", error); + } + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public PersonApiTestCase saveTestCase(PersonApiTestCase testCase) { + Assert.hasText(testCase.getConfigId(), "API配置ID不能为空"); + Assert.hasText(testCase.getCaseName(), "请填写测试用例名称"); + Assert.notNull(fetch(testCase.getConfigId()), "API配置不存在"); + String requestScope = Strings.isBlank(testCase.getRequestScope()) ? "DATA" : testCase.getRequestScope(); + Assert.isTrue(List.of("TOKEN", "DATA").contains(requestScope), "测试请求范围不正确"); + testCase.setRequestScope(requestScope); + testCase.setExecutedAt(new Date()); + return dao().insert(testCase); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public PersonApiTestCase saveTestCase(String data) { + return saveTestCase(Json.fromJson(PersonApiTestCase.class, data)); + } + + @Override + public void validateTaskCron(String cron) { + Assert.hasText(cron, "启用同步任务前请填写任务周期(Quartz Cron)"); + Assert.isTrue(cron.trim().length() <= 50, "任务周期长度不能超过50个字符"); + Assert.isTrue(CronExpression.isValidExpression(cron.trim()), "任务周期不是合法的Quartz Cron表达式"); + try { + Assert.notNull(new CronExpression(cron.trim()).getNextValidTimeAfter(new Date()), "任务周期没有未来执行时间"); + } catch (java.text.ParseException e) { + throw new IllegalArgumentException("任务周期格式不正确", e); + } + } + + /** + * 按执行类和data.configId确认任务归属;停用保留记录并暂停,启用核对周期后恢复。 + * 历史重复任务只禁用,不删除任务记录、同步批次或中间表数据。 + */ + @Aop(TransAop.READ_COMMITTED) + private void reconcileTask(PersonApiConfig config) { + try { + List ownedTasks = dao().query(Sys_task.class, + Cnd.where("jobClass", "=", PersonGovernanceSyncJob.class.getName()).asc("id")) + .stream().filter(task -> belongsToConfig(task.getData(), config.getId())).toList(); + Sys_task task = ownedTasks.stream().filter(item -> Objects.equals(item.getId(), config.getTaskId())) + .findFirst().orElse(ownedTasks.isEmpty() ? null : ownedTasks.get(0)); + + boolean enabled = Boolean.TRUE.equals(config.getEnabled()); + // 停用时暂停全部关联任务;启用时仅允许保留的主任务恢复,历史副本继续暂停。 + for (JobKey oldKey : scheduler.getJobKeys(org.quartz.impl.matchers.GroupMatcher.anyJobGroup())) { + JobDetail oldJob = scheduler.getJobDetail(oldKey); + if (oldJob != null && PersonGovernanceSyncJob.class.equals(oldJob.getJobClass()) + && Objects.equals(config.getId(), oldJob.getJobDataMap().getString("configId")) + && (!enabled || task == null || !oldKey.equals(JobKey.jobKey(task.getId(), task.getId())))) { + scheduler.pauseJob(oldKey); + } + } + for (Sys_task duplicate : ownedTasks) { + if (task != null && !Objects.equals(task.getId(), duplicate.getId())) { + duplicate.setDisabled(true); + dao().update(duplicate); + } + } + if (task == null && !enabled) { + config.setTaskId(null); + update(org.nutz.dao.Chain.make("taskId", null), Cnd.where("id", "=", config.getId())); + return; + } + boolean existing = task != null; + if (!existing) task = new Sys_task(); + task.setName(StrUtil.sub("人员中间表同步:" + config.getConfigName(), 0, 50)); + task.setJobClass(PersonGovernanceSyncJob.class.getName()); + boolean cronChanged = !Objects.equals(task.getCron(), config.getTaskCron()); + // 停用期间只保存配置草稿;再次启用时才将新周期同步到任务及Quartz。 + if (enabled) { + task.setCron(config.getTaskCron()); + task.setData(Json.toJson(NutMap.NEW().addv("configId", config.getId()).addv("taskCron", config.getTaskCron()))); + } + task.setNote("仅追加中间表,按周期执行,不立即触发"); + task.setDisabled(!enabled); + if (existing) dao().update(task); else dao().insert(task); + // Sys_task的PrevInsert会生成真实主键,必须插入后再回写,不能预先分配ID。 + config.setTaskId(task.getId()); + update(org.nutz.dao.Chain.make("taskId", task.getId()), Cnd.where("id", "=", config.getId())); + JobKey key = JobKey.jobKey(task.getId(), task.getId()); + if (enabled) { + JobDetail existingJob = scheduler.getJobDetail(key); + Assert.isTrue(existingJob == null || (PersonGovernanceSyncJob.class.equals(existingJob.getJobClass()) + && Objects.equals(config.getId(), existingJob.getJobDataMap().getString("configId"))), + "任务标识被其他调度占用,请检查任务配置"); + JobDetail job = JobBuilder.newJob(PersonGovernanceSyncJob.class).withIdentity(key) + .usingJobData("configId", config.getId()).usingJobData("taskCron", config.getTaskCron()).build(); + TriggerKey triggerKey = TriggerKey.triggerKey(task.getId(), task.getId()); + Trigger oldTrigger = scheduler.getTrigger(triggerKey); + Assert.isTrue(oldTrigger == null || key.equals(oldTrigger.getJobKey()), "触发器标识被其他任务占用"); + Date now = new Date(); + boolean reload = cronChanged || !(oldTrigger instanceof CronTrigger) + || !Objects.equals(((CronTrigger) oldTrigger).getCronExpression(), config.getTaskCron()) + || oldTrigger.getMisfireInstruction() != CronTrigger.MISFIRE_INSTRUCTION_DO_NOTHING + || oldTrigger.getNextFireTime() == null || !oldTrigger.getNextFireTime().after(now); + if (existingJob == null || reload) { + // 恢复时跳过暂停期间错过的时间,即使尚未达到Quartz的misfire阈值也不补执行。 + Date next = CronScheduleBuilder.cronSchedule(config.getTaskCron()).build().getFireTimeAfter(now); + Assert.notNull(next, "任务周期没有未来执行时间"); + CronTrigger trigger = TriggerBuilder.newTrigger().withIdentity(triggerKey).forJob(key) + .startAt(next).withSchedule(CronScheduleBuilder.cronSchedule(config.getTaskCron()) + .withMisfireHandlingInstructionDoNothing()).build(); + if (existingJob == null) { + scheduler.scheduleJob(job, trigger); + } else { + scheduler.addJob(job, true, true); + if (oldTrigger == null) scheduler.scheduleJob(trigger); + else Assert.notNull(scheduler.rescheduleJob(triggerKey, trigger), "更新任务触发器失败,请重试"); + } + } else if (!Objects.equals(existingJob.getJobDataMap().getString("taskCron"), config.getTaskCron())) { + scheduler.addJob(job, true, true); + } + scheduler.resumeJob(key); + } + } catch (SchedulerException e) { + throw new IllegalStateException("同步任务调度更新失败,请重试", e); + } + } + + /** 旧任务参数可能为空或损坏;无法确认归属时保留,不误删其他任务。 */ + private boolean belongsToConfig(String data, String configId) { + if (Strings.isBlank(data) || Strings.isBlank(configId)) return false; + try { + NutMap values = Json.fromJson(NutMap.class, data); + return values != null && Objects.equals(configId, values.getString("configId")); + } catch (RuntimeException e) { + return false; + } + } + + @Override + public List selectableConfigs(String currentMatchConfigId) { + Sql sql = Sqls.create( + "SELECT c.id,c.configName,c.targetTable,m.id matchConfigId,m.configName matchConfigName " + + "FROM person_api_config c " + + "LEFT JOIN person_member_match_config m ON m.apiConfigId=c.id AND m.delFlag=0 " + + "WHERE c.delFlag=0 ORDER BY c.configName" + ); + List rows = listMap(sql); + rows.forEach(row -> row.setv( + "selectable", + row.get("matchConfigId") == null + || Objects.equals(currentMatchConfigId, row.getString("matchConfigId")) + )); + return rows; + } + + private String appendQuery(String url, List params, String testCondition) { + List values = new ArrayList<>(); + for (PersonApiRequestParam param : params) { + if ("QUERY".equals(param.getParamLocation())) { + values.add(encode(param.getParamName()) + "=" + encode(Strings.sNull(param.getParamValue()))); + } + } + if (StrUtil.isNotBlank(testCondition)) { + values.add("testCondition=" + encode(testCondition)); + } + if (values.isEmpty()) { + return url; + } + // 参数必须位于片段标识#之前;保留URL已有Query,避免出现?&或&&。 + int fragmentIndex = url.indexOf('#'); + String address = fragmentIndex < 0 ? url : url.substring(0, fragmentIndex); + String fragment = fragmentIndex < 0 ? "" : url.substring(fragmentIndex); + String separator = address.contains("?") ? (address.endsWith("?") || address.endsWith("&") ? "" : "&") : "?"; + return address + separator + String.join("&", values) + fragment; + } + + /** 历史记录缺少范围时按数据接口处理。 */ + private String normalizeScope(String requestScope) { + return Strings.isBlank(requestScope) ? "DATA" : requestScope; + } + + /** + * 解析数据库中的数据查询结果。新数据保存Redis Key,历史数据仍可能直接保存响应原文。 + * + * @param storedValue MySQL中的dataQueryResult值,可能是Redis Key或历史响应原文 + * @return Redis中的原始响应;历史原文直接返回;缓存不存在时返回空字符串 + */ + private String resolveDataQueryResult(String storedValue) { + if (!isDataQueryResultKey(storedValue)) { + return storedValue; + } + return Strings.sNull(redisService.get(storedValue)); + } + + /** 生成固定业务前缀加32位随机值的Redis Key。 */ + private String buildDataQueryResultKey() { + return RedisConstant.REDIS_KEY_PERSON_API_DATA_QUERY_RESULT + R.UU32(); + } + + /** 仅识别本业务生成的Key,避免把历史响应原文误当作Redis Key。 */ + private boolean isDataQueryResultKey(String value) { + return Strings.isNotBlank(value) + && value.startsWith(RedisConstant.REDIS_KEY_PERSON_API_DATA_QUERY_RESULT); + } + + /** + * 清理被替换或保存失败的缓存。清理失败不能回滚已成功的配置保存,最多遗留无引用Key。 + */ + private void deleteDataQueryResultQuietly(String key) { + if (!isDataQueryResultKey(key)) { + return; + } + try { + redisService.del(key); + } catch (RuntimeException ignored) { + // Redis清理失败不影响主流程;Key没有MySQL引用,不会影响业务读取。 + } + } + + private String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + private void validateTable(String tableName) { + validateIdentifier(tableName); + Sql sql = Sqls.fetchInt( + "SELECT COUNT(*) FROM information_schema.TABLES " + + "WHERE table_schema=(SELECT DATABASE()) AND table_name=@tableName" + ) + .setParam("tableName", tableName); + dao().execute(sql); + Assert.isTrue(sql.getInt() > 0, "数据表不存在"); + } + + private void validateIdentifier(String identifier) { + Assert.isTrue( + StrUtil.isNotBlank(identifier) && SAFE_IDENTIFIER.matcher(identifier).matches(), + "表名或字段名不合法" + ); + } +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/service/impl/PersonGovernanceSyncServiceImpl.java b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/service/impl/PersonGovernanceSyncServiceImpl.java new file mode 100644 index 0000000..e417ba9 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/service/impl/PersonGovernanceSyncServiceImpl.java @@ -0,0 +1,683 @@ +package com.budwk.app.zhgh.personGovernance.apiConfig.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.date.DateUtil; +import cn.hutool.crypto.digest.DigestUtil; +import com.budwk.app.base.constant.RedisConstant; +import com.budwk.app.sys.models.Sys_task; +import com.budwk.app.sys.models.Sys_user; +import com.budwk.app.sys.models.Sys_user_history; +import com.budwk.app.sys.models.Sys_user_source; +import com.budwk.app.zhgh.staffmanage.member.constant.MemberChangeOrigin; +import com.budwk.app.zhgh.staffmanage.member.constant.MemberChangeType; +import com.budwk.app.zhgh.personGovernance.apiConfig.model.*; +import com.budwk.app.zhgh.personGovernance.apiConfig.service.PersonApiConfigService; +import com.budwk.app.zhgh.personGovernance.apiConfig.service.PersonGovernanceSyncService; +import com.budwk.app.zhgh.personGovernance.memberMatch.service.PersonMemberMatchService; +import com.budwk.app.zhgh.personGovernance.personException.model.PersonException; +import lombok.extern.slf4j.Slf4j; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.dao.*; +import org.nutz.dao.sql.Sql; +import org.nutz.integration.jedis.RedisService; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.json.Json; +import org.nutz.lang.Lang; +import org.nutz.lang.Strings; +import org.nutz.lang.random.R; +import org.nutz.lang.util.NutMap; +import org.springframework.util.Assert; + +import java.sql.Savepoint; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.*; + +/** + * 按配置批量新增或更新目标表,整批数据的 pullTime 保持一致。 + * 定时任务写入正式人员表时,同步记录新增人员及实际发生字段变化的人员历史。 + */ +@IocBean +@Slf4j +public class PersonGovernanceSyncServiceImpl implements PersonGovernanceSyncService { + private static final int WRITE_BATCH_SIZE = 500; + private static final int MAX_PAGES = 10000; + private static final int MAX_ROWS = 1000000; + @Inject + private Dao dao; + @Inject + private PersonApiConfigService personApiConfigService; + @Inject + private RedisService redisService; + @Inject + private PersonMemberMatchService personMemberMatchService; + + /** + * configId为保存的配置,triggerType为MANUAL/SCHEDULED;返回批次,FAILED时errorMessage为原因。 + * 保存点用于回滚本批中间表插入但保留失败批次,不再将取数失败当作人员缺失。 + */ + @Override + @Aop(TransAop.READ_COMMITTED) + public PersonSyncBatch execute(String configId, String triggerType) { + PersonApiConfig config = dao.fetch(PersonApiConfig.class, configId); + Assert.notNull(config, "API配置不存在"); + Assert.isTrue(!Boolean.TRUE.equals(config.getDelFlag()), "API配置已删除"); + if ("SCHEDULED".equals(triggerType)) { + Assert.isTrue(Boolean.TRUE.equals(config.getEnabled()), "同步任务已停用"); + } + String lockKey = RedisConstant.PLATFORM_REDIS_PREFIX + "personGovernance:sync:" + configId; + String owner = R.UU32(); + Assert.isTrue("OK".equals(redisService.set(lockKey, owner, "NX", "EX", 900)), "该配置正在同步,请勿重复执行"); + PersonSyncBatch batch = new PersonSyncBatch().setConfigId(configId).setBatchNo("PG_" + R.UU32()) + .setTriggerType(Strings.isBlank(triggerType) ? "MANUAL" : triggerType).setStartedAt(new Date()) + .setStatus("RUNNING").setSourceCount(0).setSuccessCount(0).setNewCount(0) + .setChangeCount(0).setExceptionCount(0); + log.info("[人员同步] 开始执行,configId={},batchNo={},triggerType={},sourceType={},sourceTable={},targetTable={},syncMode={}", + configId, batch.getBatchNo(), triggerType, config.getSourceType(), config.getSourceTable(), + config.getTargetTable(), config.getSyncMode()); + Savepoint[] savepoint = new Savepoint[1]; + try { + dao.insert(batch); + dao.run(connection -> { + Assert.isTrue(!connection.getAutoCommit(), "同步必须在事务中执行"); + savepoint[0] = connection.setSavepoint(); + }); + List mappings = dao.query(PersonApiFieldMapping.class, + Cnd.where("configId", "=", configId).asc("sort")); + // 页面会加载整张表的列;批次字段由系统生成,未配置的非必填列交给数据库默认值。 + mappings = mappings.stream() + .filter(m -> !List.of("id", "pullTime").contains(Strings.sNull(m.getTargetField()))) + .filter(m -> Boolean.TRUE.equals(m.getRequired()) + || (Strings.isNotBlank(m.getSourceField()) && Strings.isNotBlank(m.getTargetField()))) + .toList(); + List columns = validateTarget(config, mappings); + log.info("[人员同步] 目标表和字段映射校验完成,configId={},batchNo={},targetTable={},mappingCount={},columnCount={}", + configId, batch.getBatchNo(), config.getTargetTable(), mappings.size(), columns.size()); + if ("SYSTEM_TABLE".equals(config.getSourceType())) { + Assert.isTrue(!config.getTargetTable().equals(config.getSourceTable()), "来源表不能与目标表相同"); + List sourceColumns = personApiConfigService.columns(config.getSourceTable()); + boolean scheduled = "SCHEDULED".equals(triggerType); + Object latestPullTime = null; + Sql sourceSql = Sqls.create("SELECT * FROM `" + config.getSourceTable() + "`"); + if (scheduled) { + boolean hasPullTime = sourceColumns.stream() + .anyMatch(column -> "pullTime".equalsIgnoreCase(column.getString("columnName"))); + Assert.isTrue(hasPullTime, "定时同步的来源系统表必须包含pullTime字段"); + // 定时任务仅消费来源表最新拉取批次,避免每次触发都重复同步全部历史数据。 + Sql latestPullTimeSql = Sqls.create("SELECT MAX(`pullTime`) latestPullTime FROM `" + + config.getSourceTable() + "`"); + List latestPullTimeRows = personApiConfigService.listMap(latestPullTimeSql); + if (!latestPullTimeRows.isEmpty()) { + latestPullTime = latestPullTimeRows.get(0).get("latestPullTime"); + } + sourceSql = Sqls.create("SELECT * FROM `" + config.getSourceTable() + + "` WHERE `pullTime` = (SELECT MAX(latest.`pullTime`) FROM `" + + config.getSourceTable() + "` latest)"); + } + List rows = personApiConfigService.listMap(sourceSql); + log.info("[人员同步] 来源系统表读取完成,configId={},batchNo={},sourceTable={},triggerType={},latestPullTime={},sourceCount={}", + configId, batch.getBatchNo(), config.getSourceTable(), triggerType, latestPullTime, rows.size()); + boolean sourceMissingCheck = isSourceMissingExceptionSync(config); + boolean matchMemberRules = shouldMatchMemberRules(config); + log.info("[人员同步] 治理分支判断,configId={},batchNo={},matchMemberRules={},sourceMissingCheck={}", + configId, batch.getBatchNo(), matchMemberRules, sourceMissingCheck); + log.info("[人员同步] 来源缺失原有条件,configId={},batchNo={},isSysUserTarget={},isUpsert={},execute={}", + configId, batch.getBatchNo(), "sys_user".equalsIgnoreCase(config.getTargetTable()), + "UPSERT".equals(syncMode(config)), sourceMissingCheck); + if (sourceMissingCheck) { + // 工会、福利规则分别计算后合并去重,满足任意一类规则的人员均参与异常判断。 + List beforeMatchedIds = personMemberMatchService.matchedAnyMemberUserIds(configId); + log.info("[人员同步] 写入前两类会员规则查询完成,configId={},batchNo={},matchedCount={}", + configId, batch.getBatchNo(), beforeMatchedIds.size()); + } + log.info("[人员同步] 开始字段映射转换及目标表批量写入,configId={},batchNo={},sourceCount={},mappingCount={}", + configId, batch.getBatchNo(), rows.size(), mappings.size()); + insertPage(config, mappings, columns, rows, batch, lockKey, owner); + log.info("[人员同步] 目标表批量写入完成,configId={},batchNo={},successCount={},newCount={},changeCount={},skipCount={}", + configId, batch.getBatchNo(), batch.getSuccessCount(), batch.getNewCount(), + batch.getChangeCount(), batch.getSourceCount() - batch.getSuccessCount()); + // 会员身份及角色调整仅由配置开关控制,与来源缺失异常的原有执行条件相互独立。 + if (matchMemberRules) { + personMemberMatchService.reconcileForApiConfig(configId); + } else { + log.info("[人员同步] 跳过工会会员和福利会员规则匹配,configId={},batchNo={},原因=是否匹配会员规则未开启", + configId, batch.getBatchNo()); + } + if (sourceMissingCheck) { + recordMissingMemberExceptions(config, mappings, rows, batch); + } else { + log.info("[人员同步] 跳过来源缺失异常处理,configId={},batchNo={},原因=仅SYSTEM_TABLE+sys_user+UPSERT执行", + configId, batch.getBatchNo()); + } + } else { + List params = dao.query(PersonApiRequestParam.class, + Cnd.where("configId", "=", configId).asc("sort")); + NutMap paging = personApiConfigService.pagination(config, params); + long page = paging.getLong("cursor"); + int pageSize = paging.getInt("size"); + boolean singleLimitRequest = "LIMIT".equals(paging.getString("mode")); + Set fingerprints = new HashSet<>(); + for (int count = 0; ; count++) { + Assert.isTrue(count < MAX_PAGES, "超过最大分页次数,已终止并回滚本批次"); + renewLock(lockKey, owner); + log.info("[人员同步] 开始读取接口分页,configId={},batchNo={},pageIndex={},cursor={},pageSize={}", + configId, batch.getBatchNo(), count + 1, page, pageSize); + Object response = personApiConfigService.querySyncPage(config, params, page, pageSize); + Object dataset = personApiConfigService.readResponsePath(response, config.getResponseDataPath()); + Assert.isTrue(dataset instanceof List, "数据集映射未找到数组,请检查返回结果路径"); + List rows = (List) dataset; + log.info("[人员同步] 接口分页读取完成,configId={},batchNo={},pageIndex={},rowCount={}", + configId, batch.getBatchNo(), count + 1, rows.size()); + if (!rows.isEmpty() && pageSize > 0) { + Assert.isTrue(fingerprints.add(DigestUtil.sha256Hex(Json.toJson(rows))), "接口重复返回同一页,已终止并回滚本批次"); + } + insertPage(config, mappings, columns, rows, batch, lockKey, owner); + // 按接口原始条数判定,已存在而跳过的记录仍属于这一页。 + // 仅数量模式是单次限量查询,即使正好返回配置数量也不能继续构造下一页请求。 + if (singleLimitRequest || pageSize == 0 || rows.size() < pageSize) break; + page = Math.addExact(page, paging.getLong("step")); + } + // 第三方接口同步同样以配置开关为准,分页全部写入完成后只执行一次会员规则匹配。 + if (shouldMatchMemberRules(config)) { + personMemberMatchService.reconcileForApiConfig(configId); + } else { + log.info("[人员同步] 跳过工会会员和福利会员规则匹配,configId={},batchNo={},原因=是否匹配会员规则未开启", + configId, batch.getBatchNo()); + } + } + batch.setStatus("SUCCESS").setFinishedAt(new Date()); + log.info("[人员同步] 执行成功,configId={},batchNo={},sourceCount={},successCount={},newCount={},changeCount={},exceptionCount={}", + configId, batch.getBatchNo(), batch.getSourceCount(), batch.getSuccessCount(), batch.getNewCount(), + batch.getChangeCount(), batch.getExceptionCount()); + dao.update(PersonApiConfig.class, Chain.make("lastSyncTime", batch.getFinishedAt()), Cnd.where("id", "=", configId)); + } catch (Exception e) { + log.error("[人员同步] 执行失败,configId={},batchNo={},sourceTable={},targetTable={},syncMode={},原因={}", + configId, batch.getBatchNo(), config.getSourceTable(), config.getTargetTable(), + config.getSyncMode(), e.getMessage(), e); + if (savepoint[0] == null) throw new IllegalStateException("同步批次初始化失败", e); + dao.run(connection -> connection.rollback(savepoint[0])); + batch.setStatus("FAILED").setSuccessCount(0).setNewCount(0).setChangeCount(0).setFinishedAt(new Date()) + .setErrorMessage(e.getMessage() == null ? "同步执行失败" : e.getMessage()); + } finally { + redisService.eval("if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end", + List.of(lockKey), List.of(owner)); + } + dao.update(batch); + // 任务列表按秒级exeAt展示执行时间;失败批次也回写原因,避免误认为未触发。 + if ("SCHEDULED".equals(batch.getTriggerType()) && Strings.isNotBlank(config.getTaskId())) { + String result = "SUCCESS".equals(batch.getStatus()) + ? "执行成功,新增" + batch.getNewCount() + "条,更新" + batch.getChangeCount() + "条,跳过" + + (batch.getSourceCount() - batch.getSuccessCount()) + "条,批次:" + batch.getBatchNo() + : "执行失败,批次:" + batch.getBatchNo() + ",原因:" + batch.getErrorMessage(); + dao.update(Sys_task.class, Chain.make("exeAt", batch.getStartedAt().getTime() / 1000) + .add("exeResult", result), + Cnd.where("id", "=", config.getTaskId()).and("jobClass", "=", + "com.budwk.app.zhgh.personGovernance.apiConfig.job.PersonGovernanceSyncJob")); + } + return batch; + } + + /** 工会会员与福利会员匹配只受配置开关控制,两类来源均可在同步完成后执行。 */ + private boolean shouldMatchMemberRules(PersonApiConfig config) { + return Boolean.TRUE.equals(config.getMatchMemberRules()); + } + + /** 来源缺失异常保留原有执行条件,不受会员规则匹配开关影响。 */ + private boolean isSourceMissingExceptionSync(PersonApiConfig config) { + return "SYSTEM_TABLE".equals(config.getSourceType()) + && "sys_user".equalsIgnoreCase(config.getTargetTable()) + && "UPSERT".equals(syncMode(config)); + } + + /** 来源缺失且命中工会或福利任意一类会员规则的人员写入待确认异常,已有待确认记录只更新发现信息。 */ + @Aop(TransAop.READ_COMMITTED) + private void recordMissingMemberExceptions(PersonApiConfig config, List mappings, + List sourceRows, PersonSyncBatch batch) { + PersonApiFieldMapping identityMapping = mappings.stream() + .filter(mapping -> Objects.equals(config.getIdentityField(), mapping.getTargetField())) + .findFirst().orElse(null); + if (identityMapping == null) { + log.warn("[人员同步] 跳过异常人员比较,configId={},batchNo={},原因=未找到唯一标识字段映射,identityField={}", + config.getId(), batch.getBatchNo(), config.getIdentityField()); + return; + } + Set sourceIdentities = sourceRows.stream() + .map(row -> Strings.sNull(personApiConfigService.readResponsePath(row, identityMapping.getSourceField()))) + .filter(Strings::isNotBlank) + .collect(java.util.stream.Collectors.toSet()); + log.info("[人员同步] 来源唯一标识整理完成,configId={},batchNo={},sourceCount={},validIdentityCount={}", + config.getId(), batch.getBatchNo(), sourceRows.size(), sourceIdentities.size()); + List matchedIds = personMemberMatchService.matchedAnyMemberUserIds(config.getId()); + if (matchedIds.isEmpty()) { + log.warn("[人员同步] 异常人员比较结束,configId={},batchNo={},原因=会员规则未命中任何人员", + config.getId(), batch.getBatchNo()); + return; + } + Assert.isTrue(config.getIdentityField().matches("[A-Za-z0-9_]+"), "唯一标识字段不合法"); + // superadmin是系统内置管理账号,不参与来源系统缺失人员的异常判断。 + Sql sql = Sqls.create("SELECT u.id,u.`" + config.getIdentityField() + "` identityValue,u.loginname,u.username,u.unitId,un.name unitName " + + "FROM sys_user u LEFT JOIN sys_unit un ON u.unitId=un.id " + + "WHERE u.id IN (@ids) AND u.loginname<>'superadmin'") + .setParam("ids", matchedIds); + List matchedUsers = personApiConfigService.listMap(sql); + int missingCount = 0; + int insertedCount = 0; + int updatedCount = 0; + for (NutMap user : matchedUsers) { + String identity = user.getString("identityValue"); + if (Strings.isBlank(identity) || sourceIdentities.contains(identity)) continue; + missingCount++; + PersonException exception = dao.fetch(PersonException.class, + Cnd.where("configId", "=", config.getId()).and("userId", "=", user.getString("id")) + .and("status", "=", "PENDING").and("delFlag", "=", false)); + if (exception == null) { + exception = new PersonException().setConfigId(config.getId()).setDiscoverBatchId(batch.getId()) + .setUserId(user.getString("id")).setPersonNo(user.getString("loginname")) + .setPersonName(user.getString("username")).setUnitId(user.getString("unitId")) + .setUnitName(user.getString("unitName")).setExceptionType("SOURCE_MISSING") + .setExceptionReason("来源系统表中不存在该人员").setLastSyncTime(batch.getStartedAt()) + .setDiscoverTime(new Date()).setStatus("PENDING"); + dao.insert(exception); + batch.setExceptionCount(batch.getExceptionCount() + 1); + insertedCount++; + } else { + exception.setDiscoverBatchId(batch.getId()).setLastSyncTime(batch.getStartedAt()) + .setDiscoverTime(new Date()); + dao.update(exception); + updatedCount++; + } + } + log.info("[人员同步] 异常人员比较完成,configId={},batchNo={},matchedUserCount={},sourceMissingCount={},exceptionInsertedCount={},exceptionUpdatedCount={}", + config.getId(), batch.getBatchNo(), matchedUsers.size(), missingCount, insertedCount, updatedCount); + } + + /** 校验目标表和业务唯一标识;数据库唯一约束由批量写入检查,冲突时整批回滚。 */ + private List validateTarget(PersonApiConfig config, List mappings) { + Assert.hasText(config.getTargetTable(), "请选择目标人员中间表"); + List columns = personApiConfigService.columns(config.getTargetTable()); + Map fields = new HashMap<>(); + columns.forEach(c -> fields.put(c.getString("columnName"), c)); + Assert.isTrue(fields.containsKey("id") && fields.containsKey("pullTime"), "目标中间表必须包含id和pullTime字段"); + Assert.isTrue(fields.get("id").getString("columnType").toLowerCase(Locale.ROOT).contains("char"), "中间表id须支持字符串主键"); + Assert.notEmpty(mappings, "请配置字段映射"); + Set targets = new HashSet<>(); + for (PersonApiFieldMapping mapping : mappings) { + Assert.hasText(mapping.getSourceField(), "请补全来源字段"); + Assert.hasText(mapping.getTargetField(), "请补全系统字段"); + Assert.isTrue(fields.containsKey(mapping.getTargetField()), "目标表不存在映射字段:" + mapping.getTargetField()); + Assert.isTrue(targets.add(mapping.getTargetField()), "系统字段不能重复映射:" + mapping.getTargetField()); + } + String mode = syncMode(config); + if (!"APPEND".equals(mode)) { + Assert.hasText(config.getIdentityField(), "去重同步必须配置唯一标识"); + Assert.isTrue(targets.contains(config.getIdentityField()) + && !List.of("id", "pullTime").contains(config.getIdentityField()), "唯一标识必须是有效的业务映射字段"); + } + return columns; + } + + /** 历史配置兼容全部追加;非法模式拒绝执行,避免意外覆盖数据。 */ + private String syncMode(PersonApiConfig config) { + String mode = Strings.isBlank(config.getSyncMode()) ? "APPEND" : config.getSyncMode(); + Assert.isTrue(List.of("APPEND", "UPSERT", "INSERT_NEW").contains(mode), "同步类型不正确"); + return mode; + } + + /** + * 每500条转换并执行JDBC批处理,不累积全部分页结果。 + * rows为接口原始数组;新增与更新数量仅在整块执行成功后计入,外层保存点负责失败回滚。 + */ + @Aop(TransAop.READ_COMMITTED) + private void insertPage(PersonApiConfig config, List mappings, List columns, + List rows, PersonSyncBatch batch, String lockKey, String owner) { + Assert.isTrue((long) batch.getSourceCount() + rows.size() <= MAX_ROWS, "超过单批最大行数,已终止同步"); + batch.setSourceCount(batch.getSourceCount() + rows.size()); + Map types = new HashMap<>(); + columns.forEach(c -> types.put(c.getString("columnName"), c.getString("columnType"))); + String mode = syncMode(config); + PersonApiFieldMapping identityMapping = mappings.stream() + .filter(mapping -> Strings.isNotBlank(config.getIdentityField()) + && Objects.equals(config.getIdentityField(), mapping.getTargetField())) + .findFirst().orElse(null); + for (int offset = 0; offset < rows.size(); offset += WRITE_BATCH_SIZE) { + renewLock(lockKey, owner); + List valuesList = new ArrayList<>(); + int chunkSourceCount = Math.min(offset + WRITE_BATCH_SIZE, rows.size()) - offset; + for (Object row : rows.subList(offset, Math.min(offset + WRITE_BATCH_SIZE, rows.size()))) { + Assert.isTrue(row instanceof Map, "数据集行必须为JSON对象"); + // 先过滤空业务标识,再校验其他必填字段及转换类型;未配置标识的全量追加保持原行为。 + // 不改变原始rows和sourceCount,避免整页被过滤时提前结束分页,跳过数量由原始数减成功数统计。 + if (identityMapping != null) { + Object identity = personApiConfigService.readResponsePath(row, identityMapping.getSourceField()); + if (identity == null || Strings.isBlank(String.valueOf(identity))) { + continue; + } + } + NutMap values = NutMap.NEW(); + for (PersonApiFieldMapping mapping : mappings) { + Object value = personApiConfigService.readResponsePath(row, mapping.getSourceField()); + if (Boolean.TRUE.equals(mapping.getRequired())) { + Assert.isTrue(value != null && !Strings.isBlank(String.valueOf(value)), "必填来源字段为空:" + mapping.getSourceField()); + } + try { + values.put(mapping.getTargetField(), convertValue(value, types.get(mapping.getTargetField()))); + } catch (RuntimeException e) { + throw new IllegalArgumentException("字段类型转换失败:" + mapping.getSourceField() + " -> " + mapping.getTargetField()); + } + } + if (!"APPEND".equals(mode)) { + Object identity = values.get(config.getIdentityField()); + Assert.isTrue(identity != null && Strings.isNotBlank(String.valueOf(identity)), "唯一标识不能为空:" + config.getIdentityField()); + } + values.put("id", R.UU32()); + values.put("pullTime", batch.getStartedAt()); + valuesList.add(values); + } + // 当前500条全部被过滤时不查询目标表,也不提交空批次。 + if (valuesList.isEmpty()) { + log.warn("[人员同步] 数据块转换后无可写入数据,configId={},batchNo={},offset={},sourceCount={},原因=唯一标识为空或映射数据无效", + config.getId(), batch.getBatchNo(), offset, chunkSourceCount); + continue; + } + log.info("[人员同步] 数据块转换完成,configId={},batchNo={},offset={},sourceCount={},convertedCount={},filteredCount={}", + config.getId(), batch.getBatchNo(), offset, chunkSourceCount, valuesList.size(), + chunkSourceCount - valuesList.size()); + int[] counts = new int[2]; + // 保护标识只约束定时任务;人工同步仍保持现有行为,便于管理员主动修复数据。 + boolean protectSysUser = "SCHEDULED".equals(batch.getTriggerType()) + && "sys_user".equalsIgnoreCase(config.getTargetTable()); + dao.run(connection -> writeChunk(connection, config, valuesList, mode, protectSysUser, counts)); + batch.setNewCount(batch.getNewCount() + counts[0]); + batch.setChangeCount(batch.getChangeCount() + counts[1]); + batch.setSuccessCount(batch.getSuccessCount() + counts[0] + counts[1]); + log.info("[人员同步] 数据块写入完成,configId={},batchNo={},offset={},insertCount={},updateCount={}", + config.getId(), batch.getBatchNo(), offset, counts[0], counts[1]); + } + } + + /** + * 目标表及字段已通过元数据校验;所有值使用绑定参数,禁止拼接业务数据。 + * 派生表批量关联按数据库自身排序规则比较唯一标识,并用输入序号返回已有主键。 + * 同块重复来源标识按数据库比较结果归并:更新模式后值覆盖,新增模式首值保留。 + */ + @Aop(TransAop.READ_COMMITTED) + private void writeChunk(Connection connection, PersonApiConfig config, List rows, + String mode, boolean protectSysUser, int[] counts) throws SQLException { + String table = quoteIdentifier(config.getTargetTable()); + Map existing = new HashMap<>(); + Set protectedRows = new HashSet<>(); + Map representatives = new HashMap<>(); + if (!"APPEND".equals(mode)) { + String identity = quoteIdentifier(config.getIdentityField()); + String derived = java.util.stream.IntStream.range(0, rows.size()) + .mapToObj(i -> "SELECT " + i + " AS seq, ? AS val").collect(java.util.stream.Collectors.joining(" UNION ALL ")); + // 一次批量匹配已有数据;同一标识对应多个目标主键时中止,不任意更新。 + String protectedField = protectSysUser ? ",t.`flag`" : ""; + try (PreparedStatement statement = connection.prepareStatement( + "SELECT s.seq,t.`id`" + protectedField + " FROM (" + derived + ") s JOIN " + + table + " t ON t." + identity + "=s.val")) { + for (int i = 0; i < rows.size(); i++) statement.setObject(i + 1, rows.get(i).get(config.getIdentityField())); + try (ResultSet result = statement.executeQuery()) { + while (result.next()) { + int seq = result.getInt(1); + Assert.isTrue(existing.putIfAbsent(seq, result.getString(2)) == null, "目标表唯一标识对应多条记录,请先清理重复数据"); + if (protectSysUser && Integer.valueOf(1).equals(result.getObject(3, Integer.class))) { + protectedRows.add(seq); + } + } + } + } + // 使用目标字段的排序规则归并本块来源值,避免大小写/尾随空格等造成同块重复插入。 + String collation = null; + try (PreparedStatement statement = connection.prepareStatement( + "SELECT COLLATION_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND COLUMN_NAME=?")) { + statement.setString(1, config.getTargetTable()); + statement.setString(2, config.getIdentityField()); + try (ResultSet result = statement.executeQuery()) { + if (result.next()) collation = result.getString(1); + } + } + String comparison = "a.val=b.val"; + if (collation != null) { + Assert.isTrue(collation.matches("[A-Za-z0-9_]+"), "目标字段排序规则不合法"); + String charset = collation.substring(0, collation.indexOf('_')); + comparison = "CONVERT(a.val USING " + charset + ") COLLATE " + collation + + "=CONVERT(b.val USING " + charset + ") COLLATE " + collation; + } + try (PreparedStatement statement = connection.prepareStatement( + "SELECT a.seq,MIN(b.seq) FROM (" + derived + ") a JOIN (" + derived + + ") b ON " + comparison + " GROUP BY a.seq")) { + for (int i = 0; i < rows.size(); i++) { + Object value = rows.get(i).get(config.getIdentityField()); + statement.setObject(i + 1, value); + statement.setObject(rows.size() + i + 1, value); + } + try (ResultSet result = statement.executeQuery()) { + while (result.next()) representatives.put(result.getInt(1), result.getInt(2)); + } + } + } + Map inserts = new LinkedHashMap<>(); + Map updates = new LinkedHashMap<>(); + for (int i = 0; i < rows.size(); i++) { + NutMap row = rows.get(i); + String id = existing.get(i); + int representative = representatives.getOrDefault(i, i); + if (id != null) { + // 定时任务命中受保护人员时,不写人员字段,也不计入更新成功数。 + if (protectedRows.contains(i)) { + continue; + } + if ("UPSERT".equals(mode)) { + row.put("id", id); + updates.put(id, row); + } + } else if (!inserts.containsKey(representative)) { + inserts.put(representative, row); + } else if ("UPSERT".equals(mode)) { + row.put("id", inserts.get(representative).get("id")); + inserts.put(representative, row); + } + } + List updateSnapshots = new ArrayList<>(); + Map> updateChangeTypes = new LinkedHashMap<>(); + if (protectSysUser && !updates.isEmpty()) { + // 一次查询更新前快照;只有映射业务字段实际变化时才生成历史,pullTime变化不计入人员变更。 + List currentUsers = dao.query(Sys_user.class, + Cnd.where("id", "in", updates.keySet().toArray(new String[0]))); + Map currentUserMap = currentUsers.stream() + .collect(java.util.stream.Collectors.toMap(Sys_user::getId, user -> user)); + for (Map.Entry entry : updates.entrySet()) { + Sys_user currentUser = currentUserMap.get(entry.getKey()); + if (currentUser == null) { + continue; + } + List changeTypes = resolveChangeTypes(currentUser, entry.getValue()); + if (!changeTypes.isEmpty()) { + updateSnapshots.add(currentUser); + updateChangeTypes.put(currentUser.getId(), changeTypes); + } + } + } + if (!inserts.isEmpty()) { + if ("sys_user_source".equals(config.getTargetTable())) { + // 实体集合一次提交,保留 Sys_user_source/BaseModel 的插入拦截器及默认字段,不创建正式人员。 + List entities = inserts.values().stream() + .map(row -> BeanUtil.toBean(row, Sys_user_source.class)).toList(); + dao.insert(entities); + } else { + executeWriteBatch(connection, table, new ArrayList<>(inserts.values()), false); + } + } + if (!updates.isEmpty()) executeWriteBatch(connection, table, new ArrayList<>(updates.values()), true); + if (protectSysUser) { + String changeReason = syncHistoryReason(config); + recordSyncHistories(updateSnapshots, updateChangeTypes, changeReason); + if (!inserts.isEmpty()) { + List insertedUsers = dao.query(Sys_user.class, + Cnd.where("id", "in", inserts.values().stream() + .map(row -> row.getString("id")).toArray(String[]::new))); + Map> insertChangeTypes = new LinkedHashMap<>(); + insertedUsers.forEach(user -> insertChangeTypes.put(user.getId(), + List.of(MemberChangeType.NEW.name()))); + recordSyncHistories(insertedUsers, insertChangeTypes, changeReason); + } + } + counts[0] = inserts.size(); + counts[1] = updates.size(); + } + + /** + * 比较更新前人员快照与本次映射值,返回 MemberChangeType 编码;空集合表示没有实际业务变化。 + * id、pullTime属于主键或同步批次字段,不参与人员变更判断。 + */ + private List resolveChangeTypes(Sys_user currentUser, NutMap incomingValues) { + NutMap currentValues = Lang.obj2nutmap(currentUser); + Set changeTypes = new LinkedHashSet<>(); + boolean basicChanged = false; + for (Map.Entry entry : incomingValues.entrySet()) { + String field = entry.getKey(); + if (List.of("id", "pullTime").contains(field) + || valuesEqual(currentValues.get(field), entry.getValue())) { + continue; + } + switch (field) { + case "unitId", "unitid" -> changeTypes.add(MemberChangeType.UNIT_CHANGE.name()); + case "unionId", "unionid" -> changeTypes.add(MemberChangeType.UNION_CHANGE.name()); + case "member" -> changeTypes.add(Boolean.TRUE.equals(entry.getValue()) + ? MemberChangeType.RESTORE.name() : MemberChangeType.WITHDRAWAL.name()); + case "userState" -> { + String userState = Strings.sNull(entry.getValue()); + Optional stateType = Arrays.stream(MemberChangeType.values()) + .filter(type -> Objects.equals(type.getChangeTypeName(), userState)) + .findFirst(); + if (stateType.isPresent()) { + changeTypes.add(stateType.get().name()); + } else { + basicChanged = true; + } + } + default -> basicChanged = true; + } + } + if (basicChanged) { + changeTypes.add(MemberChangeType.BASIC_CHANGE.name()); + } + return new ArrayList<>(changeTypes); + } + + /** + * 保存人员同步前快照。 + * + * @param users 更新前或新增后的人员快照 + * @param changeTypesByUser 用户ID对应的变更类型集合 + * @param changeReason 本次同步配置形成的变更原因 + */ + @Aop(TransAop.READ_COMMITTED) + private void recordSyncHistories(List users, + Map> changeTypesByUser, + String changeReason) { + if (users == null || users.isEmpty()) { + return; + } + List histories = new ArrayList<>(); + for (Sys_user user : users) { + List changeTypes = changeTypesByUser.get(user.getId()); + if (changeTypes == null || changeTypes.isEmpty()) { + continue; + } + Sys_user_history history = new Sys_user_history(); + BeanUtil.copyProperties(user, history); + history.setId(R.UU32()); + history.setChangeTime(new Date()); + history.setChangeOrigin(MemberChangeOrigin.SYSTEM.name()); + history.setChangeReason(changeReason); + history.setChangeTypes(changeTypes); + histories.add(history); + } + if (!histories.isEmpty()) { + dao.insert(histories); + } + } + + /** 数据库数值类型和空字符串表现可能不同,统一后比较,避免相同业务值产生虚假历史。 */ + private boolean valuesEqual(Object currentValue, Object incomingValue) { + if (currentValue == null || incomingValue == null) { + return Strings.isBlank(Strings.sNull(currentValue)) && Strings.isBlank(Strings.sNull(incomingValue)); + } + if (currentValue instanceof Number && incomingValue instanceof Number) { + return new java.math.BigDecimal(currentValue.toString()) + .compareTo(new java.math.BigDecimal(incomingValue.toString())) == 0; + } + if (currentValue instanceof Date && incomingValue instanceof Date) { + return ((Date) currentValue).getTime() == ((Date) incomingValue).getTime(); + } + return Objects.equals(String.valueOf(currentValue), String.valueOf(incomingValue)); + } + + /** sys_user_history.changeReason 长度为 100,超长配置名称截断后保存。 */ + private String syncHistoryReason(PersonApiConfig config) { + String reason = "API配置定时同步:" + Strings.sBlank(config.getConfigName()); + return reason.length() > 100 ? reason.substring(0, 100) : reason; + } + + /** JDBC executeBatch真正批量提交;更新只覆盖映射字段和pullTime,不覆盖目标主键及未映射字段。 */ + @Aop(TransAop.READ_COMMITTED) + private void executeWriteBatch(Connection connection, String table, List rows, boolean update) throws SQLException { + List fields = new ArrayList<>(rows.get(0).keySet()); + if (update) fields.remove("id"); + String sql = update + ? "UPDATE " + table + " SET " + fields.stream().map(f -> quoteIdentifier(f) + "=?") + .collect(java.util.stream.Collectors.joining(",")) + " WHERE `id`=?" + : "INSERT INTO " + table + " (" + fields.stream().map(this::quoteIdentifier) + .collect(java.util.stream.Collectors.joining(",")) + ") VALUES (" + + String.join(",", Collections.nCopies(fields.size(), "?")) + ")"; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + for (NutMap row : rows) { + int index = 1; + for (String field : fields) { + Object value = row.get(field); + statement.setObject(index++, value instanceof Date ? new java.sql.Timestamp(((Date) value).getTime()) : value); + } + if (update) statement.setObject(index, row.get("id")); + statement.addBatch(); + } + int[] results = statement.executeBatch(); + Assert.isTrue(results.length == rows.size(), "数据库批量写入结果数量不一致"); + for (int result : results) Assert.isTrue(result != java.sql.Statement.EXECUTE_FAILED, "数据库批量写入失败"); + } + } + + /** 标识符来自已校验元数据,再限制字符,不能将请求输入直接拼接到SQL。 */ + private String quoteIdentifier(String value) { + Assert.isTrue(value != null && value.matches("[A-Za-z0-9_]+"), "表名或字段名不合法"); + return "`" + value + "`"; + } + + private Object convertValue(Object value, String sqlType) { + if (value == null) return null; + String type = sqlType.toLowerCase(Locale.ROOT); + String text = String.valueOf(value); + if (type.startsWith("date") || type.startsWith("timestamp")) return value instanceof Date ? value : DateUtil.parse(text); + if (type.startsWith("bit") || type.startsWith("tinyint(1)")) { + if (value instanceof Boolean) return value; + Assert.isTrue(List.of("0", "1", "true", "false").contains(text.toLowerCase(Locale.ROOT)), "布尔值不合法"); + return "1".equals(text) || "true".equalsIgnoreCase(text); + } + if (type.contains("int")) return new java.math.BigDecimal(text).toBigIntegerExact(); + if (type.startsWith("decimal") || type.startsWith("double") || type.startsWith("float")) return new java.math.BigDecimal(text); + return value instanceof Map || value instanceof List ? Json.toJson(value) : text; + } + + /** 每页及每100行续租;失去锁即终止,避免两个进程并行采集相同配置。 */ + @Aop(TransAop.READ_COMMITTED) + private void renewLock(String key, String owner) { + Object renewed = redisService.eval("if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('expire',KEYS[1],900) else return 0 end", + List.of(key), List.of(owner)); + Assert.isTrue(renewed instanceof Number && ((Number) renewed).longValue() == 1, "同步锁已失效,请重试"); + } +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/controller/PersonMemberMatchController.java b/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/controller/PersonMemberMatchController.java new file mode 100644 index 0000000..3690818 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/controller/PersonMemberMatchController.java @@ -0,0 +1,115 @@ +package com.budwk.app.zhgh.personGovernance.memberMatch.controller; + +import com.budwk.app.base.param.PageForm; +import com.budwk.app.zhgh.personGovernance.memberMatch.service.PersonMemberMatchService; +import cn.dev33.satoken.annotation.SaCheckPermission; +import com.budwk.app.base.result.Result; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; +import org.nutz.mvc.annotation.Param; +import org.nutz.lang.Strings; +import org.springframework.util.Assert; + +/** 会员匹配列表、配置、试算与应用接口。 */ +@IocBean +@Ok("json:full") +@At("/platform/personGovernance/memberMatch") +public class PersonMemberMatchController { + @Inject + private PersonMemberMatchService personMemberMatchService; + + @At("") + @Ok("beetl:/platform/personGovernance/memberMatch/index.html") + @SaCheckPermission("data.person.governance.member") + public void index() { + } + + @At("/form") + @Ok("beetl:/platform/personGovernance/memberMatch/form.html") + @SaCheckPermission("data.person.governance.member") + public void form() { + } + + /** + * pageForm为分页参数,configName和apiConfigId为可选筛选条件。 + * enabled未传或为空时不筛选状态,仅接受true/false作为明确的启停筛选值。 + * 返回分页结果,包含list记录列表、totalCount总条数等分页信息。 + */ + @At + @SaCheckPermission("data.person.governance.member") + public Result pageData(PageForm pageForm, @Param("configName") String configName, + @Param("apiConfigId") String apiConfigId, + @Param("enabled") String enabled) { + // 保留原始文本,避免Nutz把空字符串转换为false而误筛选停用记录。 + Boolean enabledFilter = null; + if (Strings.isNotBlank(enabled)) { + String value = enabled.trim(); + Assert.isTrue("true".equalsIgnoreCase(value) || "false".equalsIgnoreCase(value), "启用状态参数不正确"); + enabledFilter = Boolean.valueOf(value); + } + return Result.success(personMemberMatchService.pageData(pageForm, configName, apiConfigId, enabledFilter)); + } + + @At + @SaCheckPermission("data.person.governance.member") + public Result detail(String id) { + return Result.success(personMemberMatchService.detail(id)); + } + + /** + * 保存共享配置及相互隔离的工会、福利会员条件组,不在保存请求中执行匹配规则。 + * + * @param data JSON字符串,包含config、unionGroups、welfareGroups;组内conditions为条件,groups为嵌套子条件组 + * @return 保存后的会员匹配主配置 + */ + @At + @Aop(TransAop.READ_COMMITTED) + @SaCheckPermission("data.person.governance.member.edit") + public Result save(@Param("data") String data) { + return Result.success(personMemberMatchService.saveConfig(data)); + } + + /** + * @param id 会员匹配配置ID + * @param ruleType UNION_MEMBER为工会会员规则,WELFARE_MEMBER为福利会员规则 + * @param pageForm 分页参数 + * @return 对应规则类型命中的人员分页结果 + */ + @At + @SaCheckPermission("data.person.governance.member") + public Result calculate(String id, String ruleType, PageForm pageForm) { + return Result.success(personMemberMatchService.calculate(id, ruleType, pageForm)); + } + + @At + @Aop(TransAop.READ_COMMITTED) + @SaCheckPermission("data.person.governance.member.delete") + public Result delete(String id) { + personMemberMatchService.deleteConfig(id); + return Result.success(); + } + + @At + @SaCheckPermission("data.person.governance.member") + public Result historyConfigs() { + return Result.success(personMemberMatchService.historyConfigs()); + } + + @At + @SaCheckPermission("data.person.governance.member") + public Result dictItems(String dictCode) { + return Result.success(personMemberMatchService.dictItems(dictCode)); + } + + @At + @Aop(TransAop.READ_COMMITTED) + @SaCheckPermission("data.person.governance.member.edit") + public Result updateEnabled(String id, boolean enabled) { + personMemberMatchService.updateEnabled(id, enabled); + return Result.success(); + } +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/model/PersonMemberMatchCondition.java b/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/model/PersonMemberMatchCondition.java new file mode 100644 index 0000000..21428b6 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/model/PersonMemberMatchCondition.java @@ -0,0 +1,63 @@ +package com.budwk.app.zhgh.personGovernance.memberMatch.model; + +import lombok.Data; +import lombok.experimental.Accessors; +import org.nutz.dao.entity.annotation.*; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +/** + * 会员匹配条件,保留字典项快照避免字典改名影响历史配置展示。 + */ +@Data +@Accessors(chain = true) +@Table("person_member_match_condition") +public class PersonMemberMatchCondition { + @Name + @Column + @Comment("主键ID") + @PrevInsert(uu32 = true) + @ColDefine(type = ColType.VARCHAR, width = 32) + private String id; + @Column + @Comment("条件组ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String groupId; + @Column + @Comment("字典编码") + @ColDefine(type = ColType.VARCHAR, width = 50) + private String dictCode; + @Column + @Comment("字典数据ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String dictItemId; + @Column + @Comment("字典数据编码") + @ColDefine(type = ColType.VARCHAR, width = 50) + private String dictItemCode; + @Column + @Comment("字典数据名称") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String dictItemName; + @Column + @Comment("人员映射字段") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String mappedField; + @Column + @Comment("匹配方式") + @ColDefine(type = ColType.VARCHAR, width = 30) + private String operator; + @Column + @Comment("匹配值") + @ColDefine(type = ColType.VARCHAR, width = 500) + private String matchValue; + + @Column + @Comment("与前一条件的连接关系:AND且、OR或;组内首条忽略,历史空值按AND") + @ColDefine(type = ColType.VARCHAR, width = 3) + private String joinLogic; + + @Column + @Comment("排序号") + private Integer sort; +} + diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/model/PersonMemberMatchConfig.java b/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/model/PersonMemberMatchConfig.java new file mode 100644 index 0000000..dff8b32 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/model/PersonMemberMatchConfig.java @@ -0,0 +1,53 @@ +package com.budwk.app.zhgh.personGovernance.memberMatch.model; + +import com.budwk.app.base.model.BaseModel; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; +import org.nutz.dao.entity.annotation.*; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +import java.util.Date; + +/** + * 会员匹配主配置;一个API配置只能绑定一条记录。 + */ +@Data +@Accessors(chain = true) +@EqualsAndHashCode(callSuper = true) +@Table("person_member_match_config") +@TableIndexes({@Index(name = "UK_PERSON_MEMBER_API_CONFIG", fields = {"apiConfigId"}, unique = true)}) +public class PersonMemberMatchConfig extends BaseModel { + @Name + @Column + @Comment("主键ID") + @PrevInsert(uu32 = true) + @ColDefine(type = ColType.VARCHAR, width = 32) + private String id; + @Column + @Comment("会员匹配配置名称") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String configName; + @Column + @Comment("绑定的API配置ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String apiConfigId; + @Column + @Comment("沿用的历史匹配配置ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String copiedFromId; + @Column + @Comment("是否启用") + private Boolean enabled; + @Column + @Comment("条件组数量") + private Integer groupCount; + @Column + @Comment("最近匹配会员数量") + private Integer lastMatchCount; + @Column + @Comment("最近执行时间") + private Date lastExecuteTime; +} + + diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/model/PersonMemberMatchGroup.java b/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/model/PersonMemberMatchGroup.java new file mode 100644 index 0000000..ca6fea3 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/model/PersonMemberMatchGroup.java @@ -0,0 +1,49 @@ +package com.budwk.app.zhgh.personGovernance.memberMatch.model; + +import lombok.Data; +import lombok.experimental.Accessors; +import org.nutz.dao.entity.annotation.*; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +/** + * 会员条件组;每组作为独立括号,parentGroupId描述嵌套关系,joinLogic表示与同层前一节点的连接关系。 + */ +@Data +@Accessors(chain = true) +@Table("person_member_match_group") +public class PersonMemberMatchGroup { + @Name + @Column + @Comment("主键ID") + @PrevInsert(uu32 = true) + @ColDefine(type = ColType.VARCHAR, width = 32) + private String id; + @Column + @Comment("会员匹配配置ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String matchConfigId; + + @Column + @Comment("父条件组ID;空值表示顶层条件组") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String parentGroupId; + + @Column + @Comment("匹配规则类型:UNION_MEMBER工会会员、WELFARE_MEMBER福利会员") + @ColDefine(type = ColType.VARCHAR, width = 30) + private String ruleType; + + @Column + @Comment("条件组编号") + private Integer groupNo; + + @Column + @Comment("与前一条件组的连接关系:AND且、OR或;首组忽略,历史空值按OR") + @ColDefine(type = ColType.VARCHAR, width = 3) + private String joinLogic; + + @Column + @Comment("排序号") + private Integer sort; +} + diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/service/PersonMemberMatchService.java b/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/service/PersonMemberMatchService.java new file mode 100644 index 0000000..dd139c4 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/service/PersonMemberMatchService.java @@ -0,0 +1,55 @@ +package com.budwk.app.zhgh.personGovernance.memberMatch.service; + +import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.param.PageForm; +import com.budwk.app.base.service.BaseService; +import com.budwk.app.zhgh.personGovernance.memberMatch.model.PersonMemberMatchConfig; +import org.nutz.lang.util.NutMap; + +import java.util.List; + +/** 会员匹配配置、试算及应用服务。 */ +public interface PersonMemberMatchService extends BaseService { + Pagination pageData(PageForm pageForm, String configName, String apiConfigId, Boolean enabled); + + NutMap detail(String id); + + /** + * 保存会员匹配主配置及工会、福利会员条件组,不立即执行匹配规则。 + * + * @param payloadJson 包含config、unionGroups、welfareGroups的JSON字符串;每个条件组包含conditions及可递归嵌套的groups + * @return 保存后的会员匹配主配置 + */ + PersonMemberMatchConfig saveConfig(String payloadJson); + + /** + * 按规则类型试算匹配结果。 + * + * @param id 会员匹配配置ID + * @param ruleType UNION_MEMBER表示工会会员规则,WELFARE_MEMBER表示福利会员规则 + * @param pageForm 分页参数 + * @return 当前规则类型命中的人员分页结果 + */ + Pagination calculate(String id, String ruleType, PageForm pageForm); + + void deleteConfig(String id); + + List historyConfigs(); + + List dictItems(String dictCode); + + void applyForApiConfig(String apiConfigId); + + /** + * 返回当前API配置中任意会员规则命中的sys_user用户ID。 + * 工会会员与福利会员规则分别计算后合并去重,满足任意一类规则即返回。 + */ + List matchedAnyMemberUserIds(String apiConfigId); + + /** 分别按工会、福利规则同步对应会员字段及角色,某一类未配置规则时跳过该类身份处理。 */ + void reconcileForApiConfig(String apiConfigId); + + void updateEnabled(String id, boolean enabled); +} + + diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/service/impl/PersonMemberMatchServiceImpl.java b/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/service/impl/PersonMemberMatchServiceImpl.java new file mode 100644 index 0000000..de203b5 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/service/impl/PersonMemberMatchServiceImpl.java @@ -0,0 +1,684 @@ +package com.budwk.app.zhgh.personGovernance.memberMatch.service.impl; + +import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.dao.CndPlus; +import com.budwk.app.base.param.PageForm; +import com.budwk.app.base.service.impl.BaseServiceImpl; +import com.budwk.app.base.constant.RoleConstant; +import com.budwk.app.sys.models.Sys_role; +import com.budwk.app.sys.models.Sys_user; +import com.budwk.app.sys.models.Sys_user_role; +import com.budwk.app.sys.models.Sys_dict; +import com.budwk.app.sys.services.SysDictService; +import com.budwk.app.sys.services.SysUserService; +import com.budwk.app.zhgh.personGovernance.apiConfig.model.PersonApiConfig; +import com.budwk.app.zhgh.personGovernance.apiConfig.model.PersonApiFieldMapping; +import com.budwk.app.zhgh.personGovernance.memberMatch.model.PersonMemberMatchCondition; +import com.budwk.app.zhgh.personGovernance.memberMatch.model.PersonMemberMatchConfig; +import com.budwk.app.zhgh.personGovernance.memberMatch.model.PersonMemberMatchGroup; +import com.budwk.app.zhgh.personGovernance.memberMatch.service.PersonMemberMatchService; +import lombok.extern.slf4j.Slf4j; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.dao.Chain; +import org.nutz.dao.Cnd; +import org.nutz.dao.Dao; +import org.nutz.dao.Sqls; +import org.nutz.dao.sql.Sql; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.json.Json; +import org.nutz.lang.Strings; +import org.nutz.lang.util.NutMap; +import org.springframework.util.Assert; + +import java.util.ArrayList; +import java.util.Date; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** 工会、福利两类递归条件组会员匹配:每组独立括号,同层条件及子组均支持AND/OR。 */ +@IocBean(args = {"refer:dao"}) +@Slf4j +public class PersonMemberMatchServiceImpl extends BaseServiceImpl implements PersonMemberMatchService { + private static final Pattern SAFE_IDENTIFIER = Pattern.compile("^[A-Za-z0-9_]+$"); + private static final String RULE_TYPE_UNION_MEMBER = "UNION_MEMBER"; + private static final String RULE_TYPE_WELFARE_MEMBER = "WELFARE_MEMBER"; + + public PersonMemberMatchServiceImpl(Dao dao) { + super(dao); + } + + @Inject + private SysDictService sysDictService; + + @Inject + private SysUserService sysUserService; + + @Override + public Pagination pageData(PageForm pageForm, String configName, String apiConfigId, Boolean enabled) { + Sql sql = Sqls.create( + "SELECT m.id,m.configName,m.apiConfigId,m.enabled,m.lastExecuteTime,a.configName apiConfigName " + + "FROM person_member_match_config m " + + "LEFT JOIN person_api_config a ON a.id=m.apiConfigId $condition" + ); + CndPlus cnd = CndPlus.create(); + cnd.andEX("m.configName", "like", Strings.isBlank(configName) ? null : "%" + configName + "%"); + cnd.andEX("m.apiConfigId", "=", apiConfigId).andEX("m.enabled", "=", enabled); + cnd.and("m.delFlag", "=", false).desc("m.updatedAt"); + sql.setCondition(cnd); + return listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql); + } + + @Override + public NutMap detail(String id) { + PersonMemberMatchConfig config = fetch(id); + Assert.notNull(config, "会员匹配配置不存在"); + List groups = dao().query(PersonMemberMatchGroup.class, + Cnd.where("matchConfigId", "=", id).asc("sort")); + List unionGroupRows = buildGroupTree(groups, RULE_TYPE_UNION_MEMBER); + List welfareGroupRows = buildGroupTree(groups, RULE_TYPE_WELFARE_MEMBER); + return NutMap.NEW() + .addv("config", config) + .addv("unionGroups", unionGroupRows) + .addv("welfareGroups", welfareGroupRows) + .addv("groups", unionGroupRows); + } + + /** + * 保存会员匹配配置。payload包含config、unionGroups和welfareGroups;编辑时apiConfigId不得变化。 + * groups可递归嵌套;groups[].joinLogic为与同层前一节点的关系,conditions[].joinLogic为与前条条件的关系。 + * 同层首组、组内首条不参与连接;历史缺省值分别为OR、AND,返回保存后的配置实体。 + * 保存操作仅持久化配置,会员匹配由后续同步流程按既定周期触发。 + */ + @Override + @Aop(TransAop.READ_COMMITTED) + public PersonMemberMatchConfig saveConfig(String payloadJson) { + NutMap payload = Json.fromJson(NutMap.class, payloadJson); + PersonMemberMatchConfig config = Json.fromJson( + PersonMemberMatchConfig.class, + Json.toJson(payload.get("config")) + ); + List unionGroups = parseGroups( + payload.get("unionGroups") == null ? payload.get("groups") : payload.get("unionGroups") + ); + List welfareGroups = parseGroups(payload.get("welfareGroups")); + Assert.hasText(config.getConfigName(), "请填写配置名称"); + Assert.hasText(config.getApiConfigId(), "请选择API配置"); + Assert.isTrue(!unionGroups.isEmpty() || !welfareGroups.isEmpty(), "至少添加一类会员匹配条件组"); + // 先验证所有连接关系和条件,再写主配置,避免非法规则进入保存及应用链路。 + List unionGroupNodes = validateGroups(unionGroups, "工会会员"); + List welfareGroupNodes = validateGroups(welfareGroups, "福利会员"); + PersonMemberMatchConfig bound = fetch( + Cnd.where("apiConfigId", "=", config.getApiConfigId()).and("delFlag", "=", false) + ); + Assert.isTrue(bound == null || Objects.equals(bound.getId(), config.getId()), "该API配置已绑定其他会员匹配配置"); + boolean enabled = Boolean.TRUE.equals(config.getEnabled()); + config.setEnabled(enabled); + if (Strings.isBlank(config.getId())) { + config.setDelFlag(false); + insert(config); + } else { + PersonMemberMatchConfig old = fetch(config.getId()); + Assert.notNull(old, "会员匹配配置不存在"); + Assert.isTrue(Objects.equals(old.getApiConfigId(), config.getApiConfigId()), "编辑时不能更换API配置"); + config.setDelFlag(old.getDelFlag()); + update(config); + clearChildren(config.getId()); + } + if (enabled) { + disableOtherEnabled(config.getId()); + } + insertGroups(config.getId(), RULE_TYPE_UNION_MEMBER, null, unionGroupNodes); + insertGroups(config.getId(), RULE_TYPE_WELFARE_MEMBER, null, welfareGroupNodes); + return config; + } + + @Override + public Pagination calculate(String id, String ruleType, PageForm pageForm) { + return listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), + buildMatchSql(id, normalizeRuleType(ruleType), false)); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void deleteConfig(String id) { + Assert.notNull(fetch(id), "会员匹配配置不存在"); + clearChildren(id); + delete(id); + } + + @Override + public List historyConfigs() { + Sql sql = Sqls.create( + "SELECT id,configName FROM person_member_match_config WHERE delFlag=0 ORDER BY updatedAt DESC" + ); + return listMap(sql); + } + + @Override + public List dictItems(String dictCode) { + Assert.hasText(dictCode, "请输入字典编码"); + List result = new ArrayList<>(); + for (Sys_dict item : sysDictService.getSubListByCode(dictCode)) { + result.add( + NutMap.NEW() + .addv("id", item.getId()) + .addv("code", item.getCode()) + .addv("name", item.getName()) + ); + } + return result; + } + + /** 同步完成后应用当前API配置唯一绑定且已启用的工会、福利会员规则。 */ + @Override + @Aop(TransAop.READ_COMMITTED) + public void applyForApiConfig(String apiConfigId) { + PersonMemberMatchConfig config = fetch(Cnd.where("apiConfigId", "=", apiConfigId) + .and("enabled", "=", true).and("delFlag", "=", false)); + if (config != null) { + apply(config.getId()); + } + } + + @Override + public List matchedAnyMemberUserIds(String apiConfigId) { + Set matchedIds = new LinkedHashSet<>(); + matchedIds.addAll(matchedUserIds(apiConfigId, RULE_TYPE_UNION_MEMBER)); + matchedIds.addAll(matchedUserIds(apiConfigId, RULE_TYPE_WELFARE_MEMBER)); + log.info("[会员匹配] 两类会员规则命中人员合并完成,apiConfigId={},matchedCount={}", + apiConfigId, matchedIds.size()); + return new ArrayList<>(matchedIds); + } + + /** 根据指定规则类型返回命中的系统用户ID,工会和福利规则始终分开计算。 */ + private List matchedUserIds(String apiConfigId, String ruleType) { + PersonMemberMatchConfig config = fetch(Cnd.where("apiConfigId", "=", apiConfigId) + .and("enabled", "=", true).and("delFlag", "=", false)); + if (config == null) { + log.warn("[会员匹配] 未找到启用的会员匹配配置,apiConfigId={},ruleType={}", apiConfigId, ruleType); + return new ArrayList<>(); + } + List matchGroups = ruleGroups(config.getId(), ruleType); + if (matchGroups.isEmpty()) { + log.info("[会员匹配] 当前类型未配置匹配规则,跳过计算,apiConfigId={},ruleType={}", apiConfigId, ruleType); + return new ArrayList<>(); + } + List allMatchGroups = allRuleGroups(config.getId(), ruleType); + int groupCount = allMatchGroups.size(); + int conditionCount = allMatchGroups.stream() + .mapToInt(group -> dao().count(PersonMemberMatchCondition.class, + Cnd.where("groupId", "=", group.getId()))) + .sum(); + List matchedIds = resolveMatchedUserIds(config.getId(), ruleType); + log.info("[会员匹配] 规则查询完成,apiConfigId={},matchConfigId={},ruleType={},matchedCount={}", + apiConfigId, config.getId(), ruleType, matchedIds.size()); + log.info("[会员匹配] 规则配置统计,apiConfigId={},ruleType={},groupCount={},conditionCount={},matchedCount={}", + apiConfigId, ruleType, groupCount, conditionCount, matchedIds.size()); + if (matchedIds.isEmpty()) { + log.warn("[会员匹配] 没有人员命中规则,apiConfigId={},matchConfigId={},ruleType={},请检查映射字段、匹配方式和匹配值", + apiConfigId, config.getId(), ruleType); + } + return matchedIds; + } + + /** 全量同步时按最新规则重算会员角色;未命中的已有会员同步取消。 */ + @Override + @Aop(TransAop.READ_COMMITTED) + public void reconcileForApiConfig(String apiConfigId) { + PersonMemberMatchConfig config = fetch(Cnd.where("apiConfigId", "=", apiConfigId) + .and("enabled", "=", true).and("delFlag", "=", false)); + if (config == null) { + log.warn("[会员匹配] 未找到启用的会员匹配配置,跳过身份同步,apiConfigId={}", apiConfigId); + return; + } + if (!ruleGroups(config.getId(), RULE_TYPE_UNION_MEMBER).isEmpty()) { + reconcileUnionMembers(apiConfigId); + } + if (!ruleGroups(config.getId(), RULE_TYPE_WELFARE_MEMBER).isEmpty()) { + reconcileWelfareMembers(apiConfigId); + } + } + + /** 工会会员规则只维护member字段和工会会员角色,不触碰福利会员身份。 */ + private void reconcileUnionMembers(String apiConfigId) { + List matchedIds = matchedUserIds(apiConfigId, RULE_TYPE_UNION_MEMBER); + Set matched = new HashSet<>(matchedIds); + // flag=1 的人员由管理员保护,定时规则既不能新增其角色,也不能回收其现有角色。 + List members = dao().query(Sys_user.class, Cnd.where("member", "=", 1) + .and(Cnd.exps("flag", "IS", null).or("flag", "!=", 1))); + Set currentMemberIds = members.stream().map(Sys_user::getId).collect(Collectors.toSet()); + List addIds = matchedIds.stream() + .filter(id -> !currentMemberIds.contains(id)) + .toList(); + List removeIds = currentMemberIds.stream() + .filter(id -> !matched.contains(id)).toList(); + log.info("[会员匹配] 全量会员资格计算完成,apiConfigId={},currentMemberCount={},matchedCount={},addRoleCount={},removeRoleCount={}", + apiConfigId, members.size(), matchedIds.size(), addIds.size(), removeIds.size()); + if (!removeIds.isEmpty()) { + updateMemberRole(removeIds, false); + log.info("[会员匹配] 会员角色移除完成,apiConfigId={},removeRoleCount={}", apiConfigId, removeIds.size()); + } + if (!addIds.isEmpty()) { + // 已是工会会员的人员不重复删除、插入角色,也不刷新原入会时间。 + updateMemberRole(addIds, true); + log.info("[会员匹配] 会员角色设置完成,apiConfigId={},addRoleCount={}", apiConfigId, addIds.size()); + } + } + + /** 福利会员规则只维护welfareMember字段和福利会员角色,不触碰工会会员身份。 */ + private void reconcileWelfareMembers(String apiConfigId) { + List matchedIds = matchedUserIds(apiConfigId, RULE_TYPE_WELFARE_MEMBER); + Set matched = new HashSet<>(matchedIds); + List members = dao().query(Sys_user.class, Cnd.where("welfareMember", "=", 1) + .and(Cnd.exps("flag", "IS", null).or("flag", "!=", 1))); + Set currentMemberIds = members.stream().map(Sys_user::getId).collect(Collectors.toSet()); + List addIds = matchedIds.stream() + .filter(id -> !currentMemberIds.contains(id)) + .toList(); + List removeIds = currentMemberIds.stream() + .filter(id -> !matched.contains(id)).toList(); + log.info("[福利会员匹配] 全量资格计算完成,apiConfigId={},currentMemberCount={},matchedCount={},addRoleCount={},removeRoleCount={}", + apiConfigId, members.size(), matchedIds.size(), addIds.size(), removeIds.size()); + if (!removeIds.isEmpty()) { + removeWelfareMemberRole(removeIds); + } + if (!addIds.isEmpty()) { + // 已是福利会员的人员不重复删除、插入角色,也不刷新原福利会员入会时间。 + addWelfareMemberRole(addIds); + } + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void updateEnabled(String id, boolean enabled) { + Assert.notNull(fetch(id), "会员匹配配置不存在"); + if (enabled) { + disableOtherEnabled(id); + } + update(Chain.make("enabled", enabled), Cnd.where("id", "=", id)); + } + + /** 启用当前配置前停用其他有效配置,保证会员匹配全局只有一条启用规则。 */ + @Aop(TransAop.READ_COMMITTED) + private void disableOtherEnabled(String excludedId) { + Cnd cnd = Cnd.where("enabled", "=", true).and("delFlag", "=", false); + if (Strings.isNotBlank(excludedId)) { + cnd.and("id", "!=", excludedId); + } + update(Chain.make("enabled", false), cnd); + } + + @Aop(TransAop.READ_COMMITTED) + private void apply(String id) { + if (!ruleGroups(id, RULE_TYPE_UNION_MEMBER).isEmpty()) { + List unionMemberIds = resolveMatchedUserIds(id, RULE_TYPE_UNION_MEMBER); + if (!unionMemberIds.isEmpty()) { + updateMemberRole(unionMemberIds, true); + } + } + if (!ruleGroups(id, RULE_TYPE_WELFARE_MEMBER).isEmpty()) { + List welfareMemberIds = resolveMatchedUserIds(id, RULE_TYPE_WELFARE_MEMBER); + if (!welfareMemberIds.isEmpty()) { + addWelfareMemberRole(welfareMemberIds); + } + } + update( + Chain.make("lastExecuteTime", new Date()), + Cnd.where("id", "=", id) + ); + } + + /** 会员规则固定查询sys_user,直接提取命中人员ID并去重。 */ + private List resolveMatchedUserIds(String id, String ruleType) { + List rows = listMap(buildMatchSql(id, ruleType, true)); + List userIds = new ArrayList<>(); + for (NutMap row : rows) { + String userId = row.getString("id"); + if (Strings.isNotBlank(userId) && !userIds.contains(userId)) { + userIds.add(userId); + } + } + return userIds; + } + + private Sql buildMatchSql(String id, String ruleType, boolean allRows) { + PersonMemberMatchConfig matchConfig = fetch(id); + Assert.notNull(matchConfig, "会员匹配配置不存在"); + PersonApiConfig apiConfig = dao().fetch(PersonApiConfig.class, matchConfig.getApiConfigId()); + Assert.notNull(apiConfig, "绑定的API配置不存在"); + Set allowedFields = new HashSet<>(); + dao().query(PersonApiFieldMapping.class, Cnd.where("configId", "=", apiConfig.getId())) + .forEach(v -> allowedFields.add(v.getTargetField())); + List groups = ruleGroups(id, ruleType); + Assert.notEmpty(groups, ruleTypeName(ruleType) + "匹配条件不能为空"); + List allGroups = allRuleGroups(id, ruleType); + Map> childrenByParent = new LinkedHashMap<>(); + for (PersonMemberMatchGroup group : allGroups) { + if (Strings.isNotBlank(group.getParentGroupId())) { + childrenByParent.computeIfAbsent(group.getParentGroupId(), key -> new ArrayList<>()).add(group); + } + } + StringBuilder where = new StringBuilder(); + Map values = new LinkedHashMap<>(); + int[] paramIndex = {0}; + for (PersonMemberMatchGroup group : groups) { + if (where.length() > 0) { + where.append(' ').append(normalizeJoinLogic(group.getJoinLogic(), "OR")).append(' '); + } + appendGroupExpression(where, group, childrenByParent, allowedFields, values, paramIndex); + } + // 会员匹配始终基于正式人员当前数据,API配置只提供规则字段,不参与查询表和批次选择。 + Sql sql = Sqls.create( + "SELECT t.*,un.name AS unitName,gh.unionname AS unionName FROM sys_user t" + + " LEFT JOIN sys_unit un ON t.unitid=un.id" + + " LEFT JOIN sys_union gh ON un.unionid=gh.id" + + " WHERE (t.flag IS NULL OR t.flag<>1) AND (" + where + ")" + + (allRows ? "" : " ORDER BY 1") + ); + values.forEach(sql::setParam); + return sql; + } + + /** 将平铺条件组按parentGroupId还原为前端需要的递归结构,孤立子组按顶层组兜底展示。 */ + @SuppressWarnings("unchecked") + private List buildGroupTree(List groups, String ruleType) { + Map rowsById = new LinkedHashMap<>(); + for (PersonMemberMatchGroup group : groups) { + if (!normalizeRuleType(ruleType).equals(normalizeRuleType(group.getRuleType()))) { + continue; + } + rowsById.put( + group.getId(), + NutMap.NEW() + .addv("group", group) + .addv("conditions", dao().query( + PersonMemberMatchCondition.class, + Cnd.where("groupId", "=", group.getId()).asc("sort") + )) + .addv("groups", new ArrayList()) + ); + } + List roots = new ArrayList<>(); + for (PersonMemberMatchGroup group : groups) { + NutMap row = rowsById.get(group.getId()); + if (row == null) { + continue; + } + NutMap parent = rowsById.get(group.getParentGroupId()); + if (Strings.isBlank(group.getParentGroupId()) || parent == null) { + roots.add(row); + } else { + ((List) parent.get("groups")).add(row); + } + } + return roots; + } + + /** 递归生成单个条件组表达式;当前组及每个子组都会显式包裹一层括号。 */ + private void appendGroupExpression(StringBuilder where, PersonMemberMatchGroup group, + Map> childrenByParent, + Set allowedFields, Map values, int[] paramIndex) { + List conditions = dao().query( + PersonMemberMatchCondition.class, + Cnd.where("groupId", "=", group.getId()).asc("sort") + ); + List children = childrenByParent.getOrDefault(group.getId(), new ArrayList<>()); + Assert.isTrue(!conditions.isEmpty() || !children.isEmpty(), "条件组中至少包含一条条件或一个子条件组"); + where.append('('); + boolean hasExpression = false; + for (PersonMemberMatchCondition condition : conditions) { + if (hasExpression) { + where.append(' ').append(normalizeJoinLogic(condition.getJoinLogic(), "AND")).append(' '); + } + appendConditionExpression(where, condition, allowedFields, values, paramIndex); + hasExpression = true; + } + for (PersonMemberMatchGroup child : children) { + if (hasExpression) { + where.append(' ').append(normalizeJoinLogic(child.getJoinLogic(), "OR")).append(' '); + } + appendGroupExpression(where, child, childrenByParent, allowedFields, values, paramIndex); + hasExpression = true; + } + where.append(')'); + } + + /** 将一条受白名单约束的匹配条件转换为参数化SQL,避免字段名及匹配值直接拼接。 */ + private void appendConditionExpression(StringBuilder where, PersonMemberMatchCondition condition, + Set allowedFields, Map values, int[] paramIndex) { + Assert.isTrue(allowedFields.contains(condition.getMappedField()), "匹配字段不属于当前API配置"); + validateIdentifier(condition.getMappedField()); + String field = "t.`" + condition.getMappedField() + "`"; + String value = Strings.isBlank(condition.getMatchValue()) + ? condition.getDictItemCode() + : condition.getMatchValue(); + if ("IN".equals(condition.getOperator())) { + String[] parts = value.split(","); + where.append(field).append(" IN ("); + for (int i = 0; i < parts.length; i++) { + if (i > 0) { + where.append(','); + } + String key = "p" + paramIndex[0]++; + where.append('@').append(key); + values.put(key, parts[i].trim()); + } + where.append(')'); + return; + } + String key = "p" + paramIndex[0]++; + String operator = switch (condition.getOperator()) { + case "NE" -> "<>"; + case "CONTAINS" -> "LIKE"; + default -> "="; + }; + where.append(field).append(' ').append(operator).append(" @").append(key); + values.put(key, "CONTAINS".equals(condition.getOperator()) ? "%" + value + "%" : value); + } + + /** payload中的某类规则允许为空;空规则表示该会员类型暂不参与试算和身份同步。 */ + private List parseGroups(Object value) { + if (value == null) { + return new ArrayList<>(); + } + return Json.fromJsonAsList(NutMap.class, Json.toJson(value)); + } + + /** 校验一类会员规则的递归条件组、连接关系和必填条件,返回后续入库使用的规则树。 */ + private List validateGroups(List groups, String ruleName) { + List nodes = new ArrayList<>(); + for (NutMap row : groups) { + String joinLogic = normalizeJoinLogic(row.getString("joinLogic"), "OR"); + List conditions = Json.fromJsonAsList( + PersonMemberMatchCondition.class, + Json.toJson(row.get("conditions")) + ); + List childGroups = parseGroups(row.get("groups")); + Assert.isTrue(!conditions.isEmpty() || !childGroups.isEmpty(), + ruleName + "条件组中至少包含一条条件或一个子条件组"); + for (PersonMemberMatchCondition condition : conditions) { + Assert.hasText(condition.getMappedField(), ruleName + "规则请选择映射字段"); + Assert.hasText(condition.getOperator(), ruleName + "规则请选择匹配方式"); + condition.setJoinLogic(normalizeJoinLogic(condition.getJoinLogic(), "AND")); + } + nodes.add(new RuleGroupNode(joinLogic, conditions, validateGroups(childGroups, ruleName))); + } + return nodes; + } + + /** 递归保存条件组;子组通过parentGroupId归属父组,每一层组都对应一个独立括号。 */ + @Aop(TransAop.READ_COMMITTED) + private void insertGroups(String configId, String ruleType, String parentGroupId, List nodes) { + for (int groupIndex = 0; groupIndex < nodes.size(); groupIndex++) { + RuleGroupNode node = nodes.get(groupIndex); + int groupSort = groupIndex + 1; + PersonMemberMatchGroup group = new PersonMemberMatchGroup() + .setMatchConfigId(configId) + .setParentGroupId(parentGroupId) + .setRuleType(ruleType) + .setJoinLogic(node.joinLogic) + .setGroupNo(groupSort) + .setSort(groupSort); + dao().insert(group); + List conditions = node.conditions; + for (int conditionIndex = 0; conditionIndex < conditions.size(); conditionIndex++) { + conditions.get(conditionIndex) + .setId(null) + .setGroupId(group.getId()) + .setSort(conditionIndex + 1); + } + if (!conditions.isEmpty()) { + dao().insert(conditions); + } + insertGroups(configId, ruleType, group.getId(), node.children); + } + } + + /** 历史条件组parentGroupId为空,继续作为顶层条件组执行。 */ + private List ruleGroups(String configId, String ruleType) { + return allRuleGroups(configId, ruleType).stream() + .filter(group -> Strings.isBlank(group.getParentGroupId())) + .toList(); + } + + /** 查询指定会员类型的全部条件组;ruleType空值兼容历史工会会员规则。 */ + private List allRuleGroups(String configId, String ruleType) { + String normalizedType = normalizeRuleType(ruleType); + return dao().query( + PersonMemberMatchGroup.class, + Cnd.where("matchConfigId", "=", configId).asc("sort") + ).stream() + .filter(group -> normalizedType.equals(normalizeRuleType(group.getRuleType()))) + .toList(); + } + + /** 规则类型只接受前端两个固定值,空值仅用于兼容历史工会会员规则。 */ + private String normalizeRuleType(String value) { + if (Strings.isBlank(value)) { + return RULE_TYPE_UNION_MEMBER; + } + Assert.isTrue( + RULE_TYPE_UNION_MEMBER.equals(value) || RULE_TYPE_WELFARE_MEMBER.equals(value), + "会员匹配规则类型不正确" + ); + return value; + } + + private String ruleTypeName(String ruleType) { + return RULE_TYPE_WELFARE_MEMBER.equals(ruleType) ? "福利会员" : "工会会员"; + } + + /** 批量设置福利会员字段;当前项目没有独立福利会员角色,不修改工会会员字段及角色。 */ + @Aop(TransAop.READ_COMMITTED) + private void addWelfareMemberRole(List userIds) { + String[] ids = userIds.toArray(new String[0]); + dao().update( + Sys_user.class, + Chain.make("welfareMember", true) + .add("welfareMemberJoinTime", new Date()), + Cnd.where("id", "in", ids) + ); + sysUserService.clearCache(); + } + + /** 仅取消福利会员字段,不删除福利申请记录,也不修改工会会员身份。 */ + @Aop(TransAop.READ_COMMITTED) + private void removeWelfareMemberRole(List userIds) { + String[] ids = userIds.toArray(new String[0]); + dao().update( + Sys_user.class, + Chain.make("welfareMember", false) + .add("welfareMemberJoinTime", null), + Cnd.where("id", "in", ids) + ); + sysUserService.clearCache(); + } + + /** + * 同步工会会员字段和 MEMBER 角色。 + * + * @param userIds 待处理人员ID + * @param member true加入会员,false退出会员 + */ + @Aop(TransAop.READ_COMMITTED) + private void updateMemberRole(List userIds, boolean member) { + if (userIds == null || userIds.isEmpty()) { + return; + } + String[] ids = userIds.toArray(new String[0]); + Chain chain = Chain.make("member", member); + if (member) { + chain.add("memberTime", new Date()); + } + dao().update(Sys_user.class, chain, Cnd.where("id", "in", ids)); + Sys_role role = dao().fetch(Sys_role.class, Cnd.where("code", "=", RoleConstant.MEMBER.name())); + Assert.notNull(role, "系统未配置会员角色"); + if (member) { + for (String userId : userIds) { + if (dao().count(Sys_user_role.class, + Cnd.where("userId", "=", userId).and("roleId", "=", role.getId())) == 0) { + Sys_user_role userRole = new Sys_user_role(); + userRole.setUserId(userId); + userRole.setRoleId(role.getId()); + userRole.setEnable(true); + dao().insert(userRole); + } + } + } else { + dao().clear(Sys_user_role.class, + Cnd.where("userId", "in", ids).and("roleId", "=", role.getId())); + } + sysUserService.clearCache(); + } + + /** 仅允许固定逻辑关键字进入SQL,空值兼容历史规则,不接受前端传入的SQL片段。 */ + private String normalizeJoinLogic(String value, String defaultValue) { + if (Strings.isBlank(value)) { + return defaultValue; + } + Assert.isTrue("AND".equals(value) || "OR".equals(value), "条件连接关系仅支持且(AND)或或(OR)"); + return value; + } + + @Aop(TransAop.READ_COMMITTED) + private void clearChildren(String configId) { + List groups = dao().query( + PersonMemberMatchGroup.class, + Cnd.where("matchConfigId", "=", configId) + ); + for (PersonMemberMatchGroup group : groups) { + dao().clear(PersonMemberMatchCondition.class, Cnd.where("groupId", "=", group.getId())); + } + dao().clear(PersonMemberMatchGroup.class, Cnd.where("matchConfigId", "=", configId)); + } + + private void validateIdentifier(String value) { + Assert.isTrue( + Strings.isNotBlank(value) && SAFE_IDENTIFIER.matcher(value).matches(), + "表名或字段名不合法" + ); + } + + /** 保存阶段使用的递归规则节点,避免把前端临时树字段写入持久化实体。 */ + private static class RuleGroupNode { + private final String joinLogic; + private final List conditions; + private final List children; + + private RuleGroupNode(String joinLogic, List conditions, + List children) { + this.joinLogic = joinLogic; + this.conditions = conditions; + this.children = children; + } + } +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/personChange/controller/PersonGovernanceChangeController.java b/src/main/java/com/budwk/app/zhgh/personGovernance/personChange/controller/PersonGovernanceChangeController.java new file mode 100644 index 0000000..4434382 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/personChange/controller/PersonGovernanceChangeController.java @@ -0,0 +1,117 @@ +package com.budwk.app.zhgh.personGovernance.personChange.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import com.budwk.app.base.annotation.RepeatSubmit; +import com.budwk.app.base.annotation.SLog; +import com.budwk.app.base.result.Result; +import com.budwk.app.zhgh.personGovernance.personChange.service.PersonGovernanceChangeService; +import com.budwk.app.zhgh.staffmanage.member.models.MemberChangeRecord; +import com.budwk.app.zhgh.staffmanage.member.param.pageform.MemberManagePageForm; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; +import org.nutz.mvc.annotation.POST; +import org.nutz.mvc.annotation.Param; + +/** + * 人员数据治理人员变更页面及接口。 + */ +@IocBean +@At("/platform/personGovernance/personChange") +@Ok("json") +@SaCheckPermission("data.person.governance.change") +public class PersonGovernanceChangeController { + + @Inject + private PersonGovernanceChangeService personGovernanceChangeService; + + @At("") + @Ok("beetl:/platform/personGovernance/personChange/index.html") + public void index() { + } + + @At + public Result pageData(MemberManagePageForm pageForm) { + return Result.success(personGovernanceChangeService.pageData(pageForm)); + } + + @At + @POST + @RepeatSubmit + @Aop(TransAop.READ_COMMITTED) + @SLog(tag = "人员数据治理-人员变更", msg = "提交人员变更") + public Result doSubmitChange(MemberChangeRecord record) { + return personGovernanceChangeService.submitChange(record); + } + + @At + public Result getAllChangeMemberInfo(@Param("userId") String userId) { + return personGovernanceChangeService.getAllChangeMemberInfo(userId); + } + + @At + @POST + @RepeatSubmit + @Aop(TransAop.READ_COMMITTED) + public Result batchUpdateUserAttribute(@Param("users") String users) { + return personGovernanceChangeService.batchUpdateUserAttribute(users); + } + + @At + @POST + @RepeatSubmit + @Aop(TransAop.READ_COMMITTED) + public Result batchUpdateMember(@Param("users") String users) { + return personGovernanceChangeService.batchUpdateMember(users); + } + + @At + @POST + @RepeatSubmit + @Aop(TransAop.READ_COMMITTED) + public Result batchUpdateAidFundType(@Param("users") String users) { + return personGovernanceChangeService.batchUpdateAidFundType(users); + } + + @At + @POST + @RepeatSubmit + @Aop(TransAop.READ_COMMITTED) + public Result batchUpdateUserState(@Param("users") String users) { + return personGovernanceChangeService.batchUpdateUserState(users); + } + + @At + @POST + @RepeatSubmit + @Aop(TransAop.READ_COMMITTED) + public Result unitMove(@Param("unitId") String unitId, @Param("userIds") String userIds) { + return personGovernanceChangeService.unitMove(unitId, userIds); + } + + @At + public Result findOne(@Param("id") String id) { + return personGovernanceChangeService.findOne(id); + } + + @At + public Result findChangeById(@Param("id") String id) { + return personGovernanceChangeService.findChangeById(id); + } + + @At + public Result getUserByIdForMemberChange(@Param("userId") String userId) { + return personGovernanceChangeService.getUserByIdForMemberChange(userId); + } + + /** + * 保留会员高级管理现有导出入口;原页面当前未实现导出内容。 + */ + @At + @Ok("void") + public void doExport() { + } +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/personChange/service/PersonGovernanceChangeService.java b/src/main/java/com/budwk/app/zhgh/personGovernance/personChange/service/PersonGovernanceChangeService.java new file mode 100644 index 0000000..1921617 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/personChange/service/PersonGovernanceChangeService.java @@ -0,0 +1,37 @@ +package com.budwk.app.zhgh.personGovernance.personChange.service; + +import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.result.Result; +import com.budwk.app.sys.views.View_user; +import com.budwk.app.zhgh.staffmanage.member.models.MemberChangeRecord; +import com.budwk.app.zhgh.staffmanage.member.param.pageform.MemberManagePageForm; + +/** + * 人员数据治理中的人员变更服务。 + * + *

该服务复用现有会员变更领域能力,但为数据治理模块提供独立接口,避免页面依赖会员高级管理路由。

+ */ +public interface PersonGovernanceChangeService { + + Pagination pageData(MemberManagePageForm pageForm); + + Result submitChange(MemberChangeRecord record); + + Result getAllChangeMemberInfo(String userId); + + Result batchUpdateUserAttribute(String usersJson); + + Result batchUpdateMember(String usersJson); + + Result batchUpdateAidFundType(String usersJson); + + Result batchUpdateUserState(String usersJson); + + Result unitMove(String unitId, String userIdsJson); + + Result findOne(String id); + + Result findChangeById(String id); + + Result getUserByIdForMemberChange(String userId); +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/personChange/service/impl/PersonGovernanceChangeServiceImpl.java b/src/main/java/com/budwk/app/zhgh/personGovernance/personChange/service/impl/PersonGovernanceChangeServiceImpl.java new file mode 100644 index 0000000..5889ac7 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/personChange/service/impl/PersonGovernanceChangeServiceImpl.java @@ -0,0 +1,301 @@ +package com.budwk.app.zhgh.personGovernance.personChange.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.lang.Dict; +import cn.hutool.core.util.DesensitizedUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import com.budwk.app.base.constant.RoleConstant; +import com.budwk.app.base.exception.BaseException; +import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.result.Result; +import com.budwk.app.flow.constant.FlowConst; +import com.budwk.app.flow.engine.FlowEngine; +import com.budwk.app.flow.entity.ProcessInstance; +import com.budwk.app.flow.entity.ProcessTask; +import com.budwk.app.flow.enums.ProcessSubmitTypeEnum; +import com.budwk.app.sys.models.Sys_config; +import com.budwk.app.sys.models.Sys_union; +import com.budwk.app.sys.models.Sys_unit; +import com.budwk.app.sys.models.Sys_user; +import com.budwk.app.sys.views.View_user; +import com.budwk.app.web.commons.auth.utils.AuthUtil; +import com.budwk.app.web.commons.auth.utils.SecurityUtil; +import com.budwk.app.zhgh.personGovernance.personChange.service.PersonGovernanceChangeService; +import com.budwk.app.zhgh.staffmanage.member.constant.MemberChangeOrigin; +import com.budwk.app.zhgh.staffmanage.member.models.MemberChangeRecord; +import com.budwk.app.zhgh.staffmanage.member.param.pageform.MemberManagePageForm; +import com.budwk.app.zhgh.staffmanage.member.service.MemberCommonService; +import lombok.extern.slf4j.Slf4j; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.dao.Cnd; +import org.nutz.dao.Dao; +import org.nutz.dao.sql.Sql; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.json.Json; +import org.nutz.lang.Lang; +import org.nutz.lang.util.NutMap; + +import java.util.Date; +import java.util.List; + +/** + * 人员数据治理人员变更服务实现。 + */ +@Slf4j +@IocBean +public class PersonGovernanceChangeServiceImpl implements PersonGovernanceChangeService { + + @Inject + private Dao dao; + @Inject + private FlowEngine flowEngine; + @Inject + private MemberCommonService memberCommonService; + + @Override + public Pagination pageData(MemberManagePageForm pageForm) { + Sql sql = memberCommonService.getSql(pageForm, "schoolUnion"); + return memberCommonService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public Result submitChange(MemberChangeRecord record) { + if (Lang.isEmpty(record)) { + return Result.error("未获取到异动数据"); + } + if (StrUtil.isBlank(record.getId())) { + memberCommonService.validateApplyOrChangeIsDoing(record.getUserId(), "change"); + } + + boolean schoolRole = AuthUtil.hasRoleOr(RoleConstant.SCHOOL_UNION_CHAIRMAN.name(), + RoleConstant.SCHOOL_UNION_MEMBER_ADMIN.name(), RoleConstant.SYSADMIN.name()); + boolean branchRole = AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_CHAIRMAN.name(), + RoleConstant.BRANCH_UNION_OPERATOR.name()); + boolean groupRole = AuthUtil.hasRole(RoleConstant.BRANCH_UNION_GROUP_LEADER.name()); + MemberChangeOrigin changeOrigin = schoolRole ? MemberChangeOrigin.SCHOOL_UNION + : branchRole ? MemberChangeOrigin.BRANCH_UNION + : groupRole ? MemberChangeOrigin.UNION_GROUP : null; + if (changeOrigin == null) { + throw new BaseException("没有权限,或未设置工会小组"); + } + + memberCommonService.validateChangeAndSetBasicData(record, changeOrigin); + dao.insertOrUpdate(record); + + Dict args = Dict.create(); + args.set("submit", "admin"); + if (schoolRole) { + args.set("adminSubmit", "schoolUnion"); + } else if (branchRole) { + args.set("adminSubmit", "branchUnion"); + } else { + args.set("adminSubmit", "unionGroup"); + Sys_config config = dao.fetch(Sys_config.class, + Cnd.where(Sys_config::getConfigKey, "=", "UnionGroupIsNeedBranchAudit")); + args.set("groupAuditNext", config != null && "true".equals(config.getConfigValue()) + ? "branchAudit" : "schoolAudit"); + } + args.set(FlowConst.SUBMIT_TYPE, ProcessSubmitTypeEnum.APPLY.getCode()); + args.set(FlowConst.FORM_DATA, record); + ProcessInstance instance = flowEngine.startProcessInstanceByKey( + "HYBG", record.getId(), SecurityUtil.getUserId(), args); + List taskList = flowEngine.processTaskService().getDoingTaskList(instance.getId(), null); + for (ProcessTask task : taskList) { + flowEngine.executeProcessTask(task.getId(), SecurityUtil.getUserId(), args); + } + if (schoolRole) { + memberCommonService.compareChangeInfoAndUpdateMember(record); + } + return Result.success(); + } + + @Override + public Result getAllChangeMemberInfo(String userId) { + List changeList = memberCommonService.getAllChangeInfo(userId); + View_user user = dao.fetch(View_user.class, Cnd.where("id", "=", userId)); + if (user == null) { + return Result.error("未获取到人员基础信息"); + } + NutMap result = Lang.obj2nutmap(user); + result.put("user", user); + result.put("allChangeInfo", Lang.isEmpty(changeList) ? null : changeList); + return Result.success(result); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public Result batchUpdateUserAttribute(String usersJson) { + return applyBatch(usersJson, "userAttribute"); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public Result batchUpdateMember(String usersJson) { + return applyBatch(usersJson, "member"); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public Result batchUpdateAidFundType(String usersJson) { + return applyBatch(usersJson, "aidFundMemberUserType"); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public Result batchUpdateUserState(String usersJson) { + return applyBatch(usersJson, "userState"); + } + + /** + * 批量变更入口。usersJson 为页面选中人员数组,operation 决定写入的业务字段,返回标准 Result。 + */ + @Aop(TransAop.READ_COMMITTED) + private Result applyBatch(String usersJson, String operation) { + try { + List users = Json.fromJsonAsList(NutMap.class, usersJson); + if (Lang.isEmpty(users)) { + return Result.error("请选择要修改的用户"); + } + int successCount = 0; + for (NutMap userMap : users) { + MemberChangeRecord record = buildManualChangeRecord(userMap.getString("id")); + if (record == null) { + continue; + } + if ("userAttribute".equals(operation)) { + record.setUserAttribute(userMap.getString("userAttribute")); + } else if ("member".equals(operation)) { + record.setMember(userMap.getBoolean("member")); + record.setWelfareMember(userMap.getBoolean("welfareMember")); + } else if ("aidFundMemberUserType".equals(operation)) { + record.setAidFundMemberUserType(userMap.getString("aidFundMemberUserType")); + } else if ("userState".equals(operation)) { + record.setUserState(userMap.getString("userState")); + } + int changed = applyManualChange(record); + if (changed > 0 && "member".equals(operation) && Boolean.TRUE.equals(record.getMember())) { + Sys_user user = dao.fetch(Sys_user.class, userMap.getString("id")); + if (user != null && user.getMemberTime() == null) { + user.setMemberTime(new Date()); + dao.updateIgnoreNull(user); + } + } + successCount += changed; + } + return successCount > 0 ? Result.success("批量设置成功") : Result.error("未检测到可保存的变更数据"); + } catch (Exception e) { + log.error("人员数据治理批量变更失败,操作类型:{}", operation, e); + return Result.error("批量设置失败:" + e.getMessage()); + } + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public Result unitMove(String unitId, String userIdsJson) { + try { + if (StrUtil.isBlank(unitId) || StrUtil.isBlank(userIdsJson)) { + return Result.error("请选择新单位和要修改的用户"); + } + List userIds = Json.fromJsonAsList(String.class, userIdsJson); + Sys_unit unit = dao.fetch(Sys_unit.class, unitId); + if (unit == null || Lang.isEmpty(userIds)) { + return Result.error(unit == null ? "单位不存在" : "请选择要修改的用户"); + } + Sys_union union = StrUtil.isNotBlank(unit.getUnionId()) + ? dao.fetch(Sys_union.class, unit.getUnionId()) : null; + int successCount = 0; + for (String userId : userIds) { + MemberChangeRecord record = buildManualChangeRecord(userId); + if (record != null) { + record.setUnitId(unitId); + record.setUnitName(unit.getName()); + record.setUnionId(unit.getUnionId()); + record.setUnionName(union == null ? "" : union.getName()); + successCount += applyManualChange(record); + } + } + return successCount > 0 ? Result.success("单位调整成功") : Result.error("未检测到可保存的变更数据"); + } catch (Exception e) { + log.error("人员数据治理单位调整失败", e); + return Result.error("单位调整失败:" + e.getMessage()); + } + } + + @Override + public Result findOne(String id) { + View_user user = dao.fetch(View_user.class, Cnd.where("id", "=", id)); + if (ObjectUtil.isNull(user)) { + return Result.error("用户信息不存在"); + } + if (AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_MEMBER_ADMIN.name(), + RoleConstant.SCHOOL_UNION_ADMIN.name())) { + return Result.success(user); + } + boolean branchAllowed = AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_CHAIRMAN.name(), + RoleConstant.BRANCH_UNION_OPERATOR.name()) + && (StrUtil.blankToDefault(user.getUnionId(), "").equals(StrUtil.blankToDefault(SecurityUtil.getUnionId(), "")) + || StrUtil.isBlank(user.getUnionId())); + boolean groupAllowed = AuthUtil.hasRole(RoleConstant.BRANCH_UNION_GROUP_LEADER.name()) + && StrUtil.isAllNotBlank(user.getUnionGroupId(), SecurityUtil.getUnionGroupId()) + && user.getUnionGroupId().equals(SecurityUtil.getUnionGroupId()); + if (branchAllowed || groupAllowed) { + user.setIdCard(DesensitizedUtil.idCardNum(user.getIdCard(), 3, 4)); + user.setMobile(DesensitizedUtil.mobilePhone(user.getMobile())); + return Result.success(user); + } + return SecurityUtil.getUserId().equals(user.getId()) ? Result.success(user) : Result.error("没有权限"); + } + + @Override + public Result findChangeById(String id) { + return Result.success().addData(dao.fetch(MemberChangeRecord.class, id)); + } + + @Override + public Result getUserByIdForMemberChange(String userId) { + View_user user = dao.fetch(View_user.class, Cnd.where("id", "=", userId)); + if (user == null) { + return Result.error("用户信息不存在"); + } + NutMap result = Lang.obj2nutmap(user); + if (StrUtil.isNotBlank(user.getUnionId())) { + result.put("union", dao.fetch(Sys_union.class, user.getUnionId())); + } + if (StrUtil.isNotBlank(user.getUnitId())) { + result.put("unit", dao.fetch(Sys_unit.class, user.getUnitId())); + } + return Result.success().addData(result); + } + + private MemberChangeRecord buildManualChangeRecord(String userId) { + if (StrUtil.isBlank(userId)) { + return null; + } + View_user user = dao.fetch(View_user.class, Cnd.where("id", "=", userId)); + if (user == null) { + return null; + } + MemberChangeRecord record = new MemberChangeRecord(); + BeanUtil.copyProperties(user, record); + record.setUserId(user.getId()); + return record; + } + + @Aop(TransAop.READ_COMMITTED) + private int applyManualChange(MemberChangeRecord record) { + try { + memberCommonService.validateChangeAndSetBasicData(record, MemberChangeOrigin.HAND_MOVEMENT); + memberCommonService.compareChangeInfoAndUpdateMember(record); + return 1; + } catch (BaseException e) { + if (StrUtil.contains(e.getMessage(), "未获取到异动数据")) { + return 0; + } + throw e; + } + } +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/personException/controller/PersonExceptionController.java b/src/main/java/com/budwk/app/zhgh/personGovernance/personException/controller/PersonExceptionController.java new file mode 100644 index 0000000..36d6a74 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/personException/controller/PersonExceptionController.java @@ -0,0 +1,72 @@ +package com.budwk.app.zhgh.personGovernance.personException.controller; + +import com.budwk.app.base.param.PageForm; +import com.budwk.app.zhgh.personGovernance.personException.service.PersonExceptionService; +import cn.dev33.satoken.annotation.SaCheckPermission; +import com.budwk.app.base.result.Result; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; +import org.nutz.mvc.annotation.Param; + +/** 人员异常待确认、已处理及详情接口。 */ +@IocBean +@Ok("json:full") +@At("/platform/personGovernance/personException") +public class PersonExceptionController { + @Inject + private PersonExceptionService personExceptionService; + + @At("") + @Ok("beetl:/platform/personGovernance/personException/index.html") + @SaCheckPermission("data.person.governance.exception") + public void index() { + } + + @At + @SaCheckPermission("data.person.governance.exception") + public Result pageData(PageForm pageForm, String status, + @Param("searchName") String searchName, + @Param("searchKeyword") String searchKeyword, + @Param("unitId") String unitId, + @Param("batchId") String batchId, + @Param("handleResult") String handleResult) { + return Result.success(personExceptionService.pageData( + pageForm, status, searchName, searchKeyword, unitId, batchId, handleResult + )); + } + + @At + @SaCheckPermission("data.person.governance.exception") + public Result detail(String id) { + return Result.success(personExceptionService.detail(id)); + } + + /** + * 确认异常人员并同步修改人员状态。 + * + * @param ids 异常记录ID数组,当前页面按单条记录提交 + * @param handleResult DISABLE表示停用账号,KEEP表示继续保留 + * @param opinion 确认建议 + * @param personType 人员类型字典值 + * @param userState 在职状态字典值 + * @param member 会员状态,1为会员、0为非会员 + * @param welfareMember 福利会员状态,1为福利会员、0为非福利会员 + * @return 无业务数据,成功时统一返回code为0 + */ + @At + @Aop(TransAop.READ_COMMITTED) + @SaCheckPermission("data.person.governance.exception.handle") + public Result handle(@Param("ids") String[] ids, String handleResult, String opinion, + @Param("personType") String personType, + @Param("userState") String userState, + Integer member, Integer welfareMember) { + personExceptionService.handle( + ids, handleResult, opinion, personType, userState, member, welfareMember + ); + return Result.success(); + } +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/personException/model/PersonException.java b/src/main/java/com/budwk/app/zhgh/personGovernance/personException/model/PersonException.java new file mode 100644 index 0000000..b90d986 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/personException/model/PersonException.java @@ -0,0 +1,99 @@ +package com.budwk.app.zhgh.personGovernance.personException.model; + +import com.budwk.app.base.model.BaseModel; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; +import org.nutz.dao.entity.annotation.*; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +import java.util.Date; + +/** + * 来源端缺失人员的发现、确认和处理闭环记录。 + */ +@Data +@Accessors(chain = true) +@EqualsAndHashCode(callSuper = true) +@Table("person_exception") +public class PersonException extends BaseModel { + @Name + @Column + @Comment("主键ID") + @PrevInsert(uu32 = true) + @ColDefine(type = ColType.VARCHAR, width = 32) + private String id; + @Column + @Comment("API配置ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String configId; + @Column + @Comment("人员来源关系ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String relationId; + @Column + @Comment("发现异常的同步批次ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String discoverBatchId; + @Column + @Comment("本地人员用户ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String userId; + @Column + @Comment("人员编号") + @ColDefine(type = ColType.VARCHAR, width = 120) + private String personNo; + @Column + @Comment("人员姓名") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String personName; + @Column + @Comment("所属部门ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String unitId; + @Column + @Comment("所属部门名称") + @ColDefine(type = ColType.VARCHAR, width = 150) + private String unitName; + @Column + @Comment("异常类型") + @ColDefine(type = ColType.VARCHAR, width = 30) + private String exceptionType; + @Column + @Comment("异常原因") + @ColDefine(type = ColType.VARCHAR, width = 500) + private String exceptionReason; + @Column + @Comment("最近一次同步时间") + private Date lastSyncTime; + @Column + @Comment("异常发现时间") + private Date discoverTime; + @Column + @Comment("处理状态") + @ColDefine(type = ColType.VARCHAR, width = 30) + private String status; + @Column + @Comment("处理结果") + @ColDefine(type = ColType.VARCHAR, width = 30) + private String handleResult; + @Column + @Comment("处理意见") + @ColDefine(type = ColType.VARCHAR, width = 500) + private String handleOpinion; + @Column + @Comment("处理人ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String handlerId; + @Column + @Comment("处理人姓名") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String handlerName; + @Column + @Comment("处理时间") + private Date handleTime; + @Column + @Comment("原始文件关联ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String rawFileReid; +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/personException/service/PersonExceptionService.java b/src/main/java/com/budwk/app/zhgh/personGovernance/personException/service/PersonExceptionService.java new file mode 100644 index 0000000..1938a8b --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/personException/service/PersonExceptionService.java @@ -0,0 +1,41 @@ +package com.budwk.app.zhgh.personGovernance.personException.service; + +import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.param.PageForm; +import com.budwk.app.base.service.BaseService; +import com.budwk.app.zhgh.personGovernance.personException.model.PersonException; +import org.nutz.lang.util.NutMap; + +/** 人员异常查询与处理服务。 */ +public interface PersonExceptionService extends BaseService { + /** + * 查询异常人员。 + * + * @param pageForm 分页和排序参数 + * @param status PENDING待确认、PROCESSED已处理 + * @param searchName personName按姓名查询、personNo按人员编号查询 + * @param searchKeyword 查询关键字 + * @param unitId 所属部门主键 + * @param batchId 发现异常的同步批次主键 + * @param handleResult DISABLE已停用、KEEP继续保留 + * @return 异常人员分页数据 + */ + Pagination pageData(PageForm pageForm, String status, String searchName, String searchKeyword, + String unitId, String batchId, String handleResult); + + NutMap detail(String id); + + /** + * 确认异常人员,并将页面选择的人员类型、在职状态和会员状态同步到用户表。 + * + * @param ids 异常记录ID数组 + * @param handleResult DISABLE表示停用账号,KEEP表示继续保留 + * @param opinion 确认建议 + * @param personType 人员类型字典值 + * @param userState 在职状态字典值 + * @param member 会员状态,1为会员、0为非会员 + * @param welfareMember 福利会员状态,1为福利会员、0为非福利会员 + */ + void handle(String[] ids, String handleResult, String opinion, String personType, + String userState, Integer member, Integer welfareMember); +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/personException/service/impl/PersonExceptionServiceImpl.java b/src/main/java/com/budwk/app/zhgh/personGovernance/personException/service/impl/PersonExceptionServiceImpl.java new file mode 100644 index 0000000..3cef483 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/personException/service/impl/PersonExceptionServiceImpl.java @@ -0,0 +1,192 @@ +package com.budwk.app.zhgh.personGovernance.personException.service.impl; + +import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.dao.CndPlus; +import com.budwk.app.base.param.PageForm; +import com.budwk.app.base.service.impl.BaseServiceImpl; +import com.budwk.app.base.constant.RoleConstant; +import com.budwk.app.sys.models.Sys_role; +import com.budwk.app.sys.models.Sys_user; +import com.budwk.app.sys.models.Sys_user_role; +import com.budwk.app.sys.services.SysUserService; +import com.budwk.app.web.commons.auth.utils.SecurityUtil; +import com.budwk.app.zhgh.personGovernance.personException.model.PersonException; +import com.budwk.app.zhgh.personGovernance.personException.service.PersonExceptionService; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.dao.Chain; +import org.nutz.dao.Cnd; +import org.nutz.dao.Dao; +import org.nutz.dao.Sqls; +import org.nutz.dao.sql.Sql; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.lang.Strings; +import org.nutz.lang.util.NutMap; +import org.springframework.util.Assert; + +import java.util.Date; + +/** 异常人员仅做直接确认处理,不引入审核中状态。 */ +@IocBean(args = {"refer:dao"}) +public class PersonExceptionServiceImpl extends BaseServiceImpl implements PersonExceptionService { + @Inject + private SysUserService sysUserService; + + public PersonExceptionServiceImpl(Dao dao) { + super(dao); + } + + @Override + public Pagination pageData(PageForm pageForm, String status, String searchName, + String searchKeyword, String unitId, String batchId, + String handleResult) { + Sql sql = Sqls.create( + "SELECT e.*,c.configName sourceName,b.batchNo FROM person_exception e " + + "LEFT JOIN person_api_config c ON c.id=e.configId " + + "LEFT JOIN person_sync_batch b ON b.id=e.discoverBatchId $condition" + ); + CndPlus cnd = CndPlus.create(); + cnd.andEX("e.status", "=", status); + if (Strings.isNotBlank(searchKeyword)) { + // 查询字段只允许在姓名和人员编号之间选择,禁止直接拼接前端传入的字段名。 + String queryField = "personNo".equals(searchName) ? "e.personNo" : "e.personName"; + cnd.and(queryField, "like", "%" + searchKeyword + "%"); + } + cnd.andEX("e.unitId", "=", unitId).andEX("e.discoverBatchId", "=", batchId) + .andEX("e.handleResult", "=", handleResult); + cnd.and("e.delFlag", "=", false).desc("e.discoverTime"); + sql.setCondition(cnd); + return listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql); + } + + @Override + public NutMap detail(String id) { + PersonException exception = fetch(id); + Assert.notNull(exception, "异常记录不存在"); + Sql userSql = Sqls.create(""" + SELECT + u.id, + u.loginname, + u.username, + u.sex, + u.nation, + u.birthday, + u.political, + u.education, + u.academicDegree, + u.position, + u.unitId, + unit.name unitName, + un.unionname unionName, + u.idCard, + u.mobile, + u.email, + u.userState, + u.personType, + u.member, + u.welfareMember, + u.disabled + FROM sys_user u + LEFT JOIN sys_unit unit ON u.unitId = unit.id + LEFT JOIN sys_union un ON u.unionId = un.id + WHERE u.id = @userId + """).setParam("userId", exception.getUserId()); + return NutMap.NEW() + .addv("exception", exception) + // 只返回查看/处理页面需要的人员字段,避免输出密码、盐值等敏感信息。 + .addv("user", fetchMap(userSql)); + } + + /** + * 确认处理异常人员。 + * + * @param ids 异常记录ID,可单条或批量 + * @param handleResult DISABLE表示停用本地账号,KEEP表示继续保留 + * @param opinion 必填处理意见 + * @param personType 人员类型字典值,允许清空 + * @param userState 在职状态字典值,允许清空 + * @param member 会员状态,1为会员、0为非会员 + * @param welfareMember 福利会员状态,1为福利会员、0为非福利会员 + */ + @Override + @Aop(TransAop.READ_COMMITTED) + public void handle(String[] ids, String handleResult, String opinion, String personType, + String userState, Integer member, Integer welfareMember) { + Assert.notEmpty(ids, "请选择需要处理的异常人员"); + Assert.isTrue("DISABLE".equals(handleResult) || "KEEP".equals(handleResult), "处理结果不正确"); + Assert.hasText(opinion, "请填写确认建议"); + Assert.notNull(member, "请选择会员状态"); + Assert.isTrue(member == 0 || member == 1, "会员状态不正确"); + Assert.notNull(welfareMember, "请选择福利会员状态"); + Assert.isTrue(welfareMember == 0 || welfareMember == 1, "福利会员状态不正确"); + for (String id : ids) { + PersonException exception = fetch(id); + Assert.notNull(exception, "异常记录不存在"); + Assert.isTrue("PENDING".equals(exception.getStatus()), "仅待确认记录可以处理"); + Sys_user user = sysUserService.fetch(exception.getUserId()); + Assert.notNull(user, "异常记录关联的人员不存在"); + + String userId = exception.getUserId(); + Sys_role memberRole = dao().fetch(Sys_role.class, + Cnd.where("code", "=", RoleConstant.MEMBER.name())); + Assert.notNull(memberRole, "系统未配置会员角色"); + boolean hasMemberRole = dao().count( + Sys_user_role.class, + Cnd.where("userId", "=", userId).and("roleId", "=", memberRole.getId()) + ) > 0; + + // 人员类型、在职状态与本次确认结果在同一事务中保存,避免人员信息和异常状态不一致。 + Chain userChanges = Chain.make("personType", personType) + .add("userState", userState); + if ("DISABLE".equals(handleResult)) { + userChanges.add("disabled", true); + } + dao().update(Sys_user.class, userChanges, Cnd.where("id", "=", userId)); + + // 会员字段和会员角色以本次选择为准,同时修复历史数据中字段与角色不一致的情况。 + if (member == 1 && (!Boolean.TRUE.equals(user.getMember()) || !hasMemberRole)) { + dao().update(Sys_user.class, + Chain.make("member", true).add("memberTime", new Date()), + Cnd.where("id", "=", userId)); + if (!hasMemberRole) { + Sys_user_role userRole = new Sys_user_role(); + userRole.setUserId(userId); + userRole.setRoleId(memberRole.getId()); + userRole.setEnable(true); + dao().insert(userRole); + } + } else if (member == 0 && (Boolean.TRUE.equals(user.getMember()) || hasMemberRole)) { + dao().update(Sys_user.class, Chain.make("member", false), Cnd.where("id", "=", userId)); + dao().clear(Sys_user_role.class, + Cnd.where("userId", "=", userId).and("roleId", "=", memberRole.getId())); + } + + // 当前项目没有独立福利会员角色,只维护福利会员字段及加入时间。 + if (welfareMember == 1 + && !Boolean.TRUE.equals(user.getWelfareMember())) { + Chain welfareChanges = Chain.make("welfareMember", true); + if (!Boolean.TRUE.equals(user.getWelfareMember())) { + welfareChanges.add("welfareMemberJoinTime", new Date()); + } + dao().update(Sys_user.class, welfareChanges, Cnd.where("id", "=", userId)); + } else if (welfareMember == 0 + && Boolean.TRUE.equals(user.getWelfareMember())) { + dao().update( + Sys_user.class, + Chain.make("welfareMember", false).add("welfareMemberJoinTime", null), + Cnd.where("id", "=", userId) + ); + } + sysUserService.deleteCache(userId); + + exception.setStatus("PROCESSED") + .setHandleResult(handleResult) + .setHandleOpinion(opinion) + .setHandlerId(SecurityUtil.getUserId()) + .setHandlerName(SecurityUtil.getUserUsername()) + .setHandleTime(new Date()); + update(exception); + } + } +} diff --git a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/aidFund/controller/AidFundChangeRecordController.java b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/aidFund/controller/AidFundChangeRecordController.java index ce54435..3c3e9e8 100644 --- a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/aidFund/controller/AidFundChangeRecordController.java +++ b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/aidFund/controller/AidFundChangeRecordController.java @@ -1,24 +1,18 @@ package com.budwk.app.zhgh.staffbenefit.medicalMutualAid.aidFund.controller; import cn.dev33.satoken.annotation.SaCheckPermission; -import cn.dev33.satoken.annotation.SaMode; import cn.hutool.core.util.StrUtil; import com.budwk.app.base.annotation.SLog; import com.budwk.app.base.page.Pagination; import com.budwk.app.base.result.Result; -import com.budwk.app.base.utils.PageUtil; import com.budwk.app.flow.engine.FlowEngine; import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.aidFund.param.AidFundPageForm; import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.aidFund.service.AidFundMemberChangeRecordService; -import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.aidFund.service.AidFundMemberService; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.extern.slf4j.Slf4j; import org.nutz.aop.interceptor.ioc.TransAop; -import org.nutz.dao.Cnd; -import org.nutz.dao.Sqls; import org.nutz.dao.sql.Sql; -import org.nutz.dao.util.cri.SqlExpressionGroup; import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; @@ -26,6 +20,8 @@ import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Param; +import javax.servlet.http.HttpServletResponse; + /** * @author zhf * @date 2026/1/19 17:55 @@ -52,73 +48,36 @@ public class AidFundChangeRecordController { } + /** + * 按页面条件分页返回基金会员变更记录。 + * + * @param pageForm 年度、姓名/工号、会员类型、变更类型、工会、单位、排序及页码/每页条数 + * @return Result.data 为 Pagination,list 为当前页记录,totalCount 为匹配总条数 + */ @At @ApiOperation("分页查询") @SaCheckPermission("medicalMutualAid.aidFund.changeRecord") public Result pageData(AidFundPageForm pageForm) { - Sql sql = Sqls.create(""" - SELECT - info.*, - us.sex, - us.username, - us.loginname, - us.arrivalAtSchoolDate, - us.mobile, - us.unitName, - us.unionName, - ins.id AS instanceId, - ins.businessNo, - ins.state instanceState, - ins.variable instanceVariable, - ins.processDefineId instanceProcessDefineId, - t.id taskId, - t.taskName AS taskKey, - t.displayName taskName, - t.taskType, - t.performType taskPerformType, - t.taskState, - t.finishTime, - t.taskParentId, - t.variable taskVariable, - IF((SELECT taskName FROM wf_process_task tt WHERE tt.id = t.taskParentId) = 'startTask', 1, 0) canRevoke, - (select MAX(id) from wf_process_task where processInstanceId = ins.id and taskName = 'startTask' AND taskState in (10,20)) AS startTaskId - FROM - `aid_fund_member_change_record` info - LEFT JOIN vw_user us ON us.id = info.userId - LEFT JOIN `wf_process_instance` ins ON ins.businessNo = info.id - LEFT JOIN wf_process_task t ON t.processInstanceId = ins.id AND t.taskState = 10 - LEFT JOIN wf_process_task_actor ta ON ta.processTaskId = t.id - $condition - """); - Cnd cnd = Cnd.NEW(); - if (StrUtil.isNotBlank(pageForm.getSearchKeyword())) { - SqlExpressionGroup seg = new SqlExpressionGroup(); - seg.or("us.loginname", "like", "%" + pageForm.getSearchKeyword() + "%"); - seg.or("us.username", "like", "%" + pageForm.getSearchKeyword() + "%"); - cnd.and(seg); - } - cnd.andEX("IFNULL(info.aidFundMemberUserType, us.aidFundMemberUserType)", "=", pageForm.getAidFundMemberUserType()); - cnd.andEX("us.unitid", "=", pageForm.getUnitId()); - cnd.andEX("us.unionid", "=", pageForm.getUnionId()); - cnd.andEX("info.changeType", "=", pageForm.getChangeType()); - cnd.andEX("YEAR(info.applyTime)", "=", pageForm.getYear()); - - SqlExpressionGroup group = new SqlExpressionGroup(); - group.or("ins.state","=",20); - group.or("ins.state","is",null); - cnd.and(group); - if (StrUtil.isAllBlank(pageForm.getPageOrderName(), pageForm.getPageOrderBy())) { - cnd.desc("us.unitCode"); - } else { - cnd.orderBy(pageForm.getPageOrderName(), PageUtil.getOrder(pageForm.getPageOrderBy())); - } - cnd.groupBy("info.id"); - sql.setCondition(cnd); + Sql sql = changeRecordService.getChangeRecordSql(pageForm); Pagination pagination = changeRecordService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql); return Result.success(pagination); } + /** + * 导出页面筛选条件下的全部基金会员变更记录。 + * + * @param pageForm 年度、姓名/工号、会员类型、变更类型、工会、单位及排序参数;忽略分页参数 + * @param response 输出包含页面业务列的 XLSX 文件流,无 JSON 返回值 + */ + @At + @Ok("void") + @ApiOperation("导出变更记录") + @SaCheckPermission("medicalMutualAid.aidFund.changeRecord") + public void exportChangeRecord(AidFundPageForm pageForm, HttpServletResponse response) { + changeRecordService.exportChangeRecord(pageForm, response); + } + /** * 删除基金会员变更记录。 * diff --git a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/aidFund/service/AidFundMemberChangeRecordService.java b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/aidFund/service/AidFundMemberChangeRecordService.java index eb1160c..db13868 100644 --- a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/aidFund/service/AidFundMemberChangeRecordService.java +++ b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/aidFund/service/AidFundMemberChangeRecordService.java @@ -7,10 +7,27 @@ import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.aidFund.model.AidFundMem import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.aidFund.model.AidFundMemberPay; import org.nutz.lang.util.NutMap; +import javax.servlet.http.HttpServletResponse; import java.util.List; public interface AidFundMemberChangeRecordService extends BaseService { + /** + * 构建变更记录列表与导出共用的查询 SQL。 + * + * @param pageForm 年度、姓名/工号、会员类型、变更类型、工会、单位及排序参数;空筛选项不限制 + * @return 未设置分页的 SQL,包含申请、人员和 WF 流程字段 + */ + Sql getChangeRecordSql(AidFundPageForm pageForm); + + /** + * 导出当前筛选条件下的全部变更记录,列顺序与页面业务列一致。 + * + * @param pageForm 与列表相同的筛选和排序参数;页码、每页条数不参与导出限制 + * @param response XLSX 文件流的 HTTP 响应,无 JSON 返回值 + */ + void exportChangeRecord(AidFundPageForm pageForm, HttpServletResponse response); + /** * 构建互管会审核页面查询 SQL。 * diff --git a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/aidFund/service/impl/AidFundMemberChangeRecordServiceImpl.java b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/aidFund/service/impl/AidFundMemberChangeRecordServiceImpl.java index f34337a..0733281 100644 --- a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/aidFund/service/impl/AidFundMemberChangeRecordServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/aidFund/service/impl/AidFundMemberChangeRecordServiceImpl.java @@ -1,5 +1,9 @@ package com.budwk.app.zhgh.staffbenefit.medicalMutualAid.aidFund.service.impl; +import cn.afterturn.easypoi.excel.ExcelExportUtil; +import cn.afterturn.easypoi.excel.entity.ExportParams; +import cn.afterturn.easypoi.excel.entity.enmus.ExcelType; +import cn.afterturn.easypoi.excel.entity.params.ExcelExportEntity; import cn.hutool.core.collection.CollectionUtil; import cn.hutool.core.date.DateUtil; import cn.hutool.core.lang.Dict; @@ -8,6 +12,7 @@ import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; import com.budwk.app.base.constant.RoleConstant; import com.budwk.app.base.service.impl.BaseServiceImpl; +import com.budwk.app.base.utils.CommonDownloadUtil; import com.budwk.app.base.utils.PageUtil; import com.budwk.app.flow.constant.FlowConst; import com.budwk.app.flow.entity.ProcessInstance; @@ -24,6 +29,7 @@ import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.aidFund.model.AidFundMem import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.aidFund.model.AidFundMemberPay; import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.aidFund.param.AidFundPageForm; import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.aidFund.service.AidFundMemberChangeRecordService; +import org.apache.poi.ss.usermodel.Workbook; import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.dao.Chain; import org.nutz.dao.Cnd; @@ -38,6 +44,7 @@ import org.nutz.json.Json; import org.nutz.lang.Lang; import org.nutz.lang.util.NutMap; +import javax.servlet.http.HttpServletResponse; import java.util.*; /** @@ -64,6 +71,119 @@ public class AidFundMemberChangeRecordServiceImpl extends BaseServiceImpl list = listMap(getChangeRecordSql(pageForm)); + Map memberTypeMap = getDictNameMap("AIDFUND_MEMBER_USER_TYPE"); + Map changeTypeMap = getDictNameMap("AIDFUND_MEMBER_CHANGE_TYPE"); + for (int index = 0; index < list.size(); index++) { + NutMap row = list.get(index); + row.put("sequence", index + 1); + String memberType = row.getString("aidFundMemberUserType"); + String changeType = row.getString("changeType"); + // 空值保持空白,未配置的字典编码保留原值,避免导出时丢失业务信息。 + row.put("aidFundMemberUserType", memberTypeMap.getOrDefault(memberType, memberType)); + row.put("changeType", changeTypeMap.getOrDefault(changeType, changeType)); + row.put("applyTime", row.getTime("applyTime") == null ? "" : DateUtil.formatDate(row.getTime("applyTime"))); + } + + List columns = new ArrayList<>(); + ExcelExportEntity sequence = new ExcelExportEntity("序号", "sequence", 10); + sequence.setType(10); + columns.add(sequence); + // 工号使用文本单元格,保留前导零;其余业务列与页面显示顺序一致。 + columns.add(new ExcelExportEntity("工号", "loginname", 20)); + columns.add(new ExcelExportEntity("姓名", "username", 20)); + columns.add(new ExcelExportEntity("性别", "sex", 10)); + columns.add(new ExcelExportEntity("入校时间", "arrivalAtSchoolDate", 20)); + columns.add(new ExcelExportEntity("所属单位", "unitName", 25)); + columns.add(new ExcelExportEntity("所属工会", "unionName", 25)); + columns.add(new ExcelExportEntity("基金会员类型", "aidFundMemberUserType", 20)); + columns.add(new ExcelExportEntity("变更类型", "changeType", 20)); + columns.add(new ExcelExportEntity("变更时间", "applyTime", 20)); + + ExportParams exportParams = new ExportParams(); + exportParams.setType(ExcelType.XSSF); + Workbook workbook = ExcelExportUtil.exportExcel(exportParams, columns, list); + String fileName = (pageForm.getYear() == null ? "" : pageForm.getYear()) + "基金会员变更记录.xlsx"; + CommonDownloadUtil.download(fileName, workbook, response); + } + @Override public Sql getFoundationAuditSql(AidFundPageForm pageForm) { Sql sql = Sqls.create(""" diff --git a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/controller/MedicalCalculateMoneyController.java b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/controller/MedicalCalculateMoneyController.java index 909c53f..1f20cb3 100644 --- a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/controller/MedicalCalculateMoneyController.java +++ b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/controller/MedicalCalculateMoneyController.java @@ -1,22 +1,15 @@ package com.budwk.app.zhgh.staffbenefit.medicalMutualAid.medical.controller; -import cn.afterturn.easypoi.excel.ExcelExportUtil; -import cn.afterturn.easypoi.excel.entity.ExportParams; -import cn.afterturn.easypoi.excel.entity.enmus.ExcelType; -import cn.afterturn.easypoi.excel.entity.params.ExcelExportEntity; import cn.dev33.satoken.annotation.SaCheckPermission; import com.budwk.app.base.annotation.SLog; import com.budwk.app.base.result.Result; -import com.budwk.app.base.utils.CommonDownloadUtil; import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.medical.model.MedicalApply; import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.medical.model.MedicalMoneyDetail; import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.medical.service.MedicalCalculateMoneyService; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.extern.slf4j.Slf4j; -import org.apache.poi.ss.usermodel.Workbook; import org.nutz.aop.interceptor.ioc.TransAop; -import org.nutz.dao.Chain; import org.nutz.dao.Cnd; import org.nutz.dao.Sqls; import org.nutz.dao.sql.Sql; @@ -28,13 +21,11 @@ import org.nutz.lang.Lang; import org.nutz.lang.util.NutMap; import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.Ok; +import org.nutz.mvc.annotation.Param; -import javax.servlet.ServletOutputStream; import javax.servlet.http.HttpServletResponse; import java.math.BigDecimal; -import java.util.ArrayList; import java.util.List; -import java.util.Objects; /** * @author zhf @@ -97,116 +88,45 @@ public class MedicalCalculateMoneyController { } + /** + * 按页面测算结果分配补助,并保存该年度结余。 + * 爱心金额显式按参数名绑定,避免 Nutz 将 BigDecimal 当作对象解析其内部字段。 + * + * @param data 非空申请 JSON 数组,每项包含 id 和非负金额 forecastSubsidyMoney;保存时按笔四舍五入到元 + * @param ratio 页面当前补助比例,单位为百分比 + * @param aidFund 本年度会员缴费总额,单位为元 + * @param inComeSum 本年度收入累计总额,单位为元 + * @param loveSubsidyMoney 实际爱心补助合计,单位为元,支持小数;未传时按零处理 + * @return Result 操作结果,code 为 0 表示补助金额与年度结余保存成功 + */ @At @ApiOperation("按比例修改补助金额") @Aop(TransAop.READ_COMMITTED) @SLog(tag = "医疗补助系统-医疗补助预测", msg = "按比例修改补助金额") @SaCheckPermission("medicalMutualAid.medical.calculateMoney") - public Result doHandle(String data, float ratio, double aidFund, double inComeSum, long loveSubsidyMoney){ -//获取申请最新年份 - Sql yearSql = Sqls.create("SELECT MAX(YEAR(applyTime)) AS currentYear FROM medical_apply"); - yearSql.setCallback(Sqls.callback.longs()); - medicalCalculateMoneyService.execute(yearSql); - Integer year = yearSql.getInt(); - - List list = Json.fromJsonAsList(MedicalApply.class, data); - list.forEach(v -> { - v.setSubsidyMoney(v.getForecastSubsidyMoney()); - }); - medicalCalculateMoneyService.updateIgnoreNull(list); - //补助总额 - BigDecimal subsidyMoney = list.stream() - .map(MedicalApply::getForecastSubsidyMoney) - .filter(Objects::nonNull) // 防御性:避免 null - .reduce(BigDecimal.ZERO, BigDecimal::add); - BigDecimal incomeRounded = BigDecimal.valueOf(Math.round(inComeSum)); - BigDecimal thisYearBalance = incomeRounded - .subtract(subsidyMoney) - .subtract(BigDecimal.valueOf(loveSubsidyMoney)); - - - MedicalMoneyDetail moneyDetail = medicalCalculateMoneyService.dao().fetch(MedicalMoneyDetail.class, Cnd.where("year", "=", year)); - if (Lang.isEmpty(moneyDetail)) { - moneyDetail = new MedicalMoneyDetail(); - moneyDetail.setYear(year); - moneyDetail.setAidFund(new BigDecimal(aidFund)); - moneyDetail.setRatio(ratio); - moneyDetail.setLastYearBalance(thisYearBalance); - medicalCalculateMoneyService.insert(moneyDetail); - } else { - moneyDetail.setAidFund(new BigDecimal(aidFund)); - moneyDetail.setRatio(ratio); - moneyDetail.setLastYearBalance(thisYearBalance); - medicalCalculateMoneyService.updateIgnoreNull(moneyDetail); + public Result doHandle(String data, float ratio, double aidFund, double inComeSum, + @Param("loveSubsidyMoney") BigDecimal loveSubsidyMoney){ + // 空请求不执行分配,避免仅更新年度结余而未保存任何申请金额。 + if (Lang.isEmpty(data)) { + return Result.error("请提交需要分配的申请记录"); } + List list = Json.fromJsonAsList(MedicalApply.class, data); + medicalCalculateMoneyService.saveAllocation(list, ratio, aidFund, inComeSum, loveSubsidyMoney); return Result.success(); } + /** + * 导出当前比例的测算结果;返回类型为 void,Excel 文件写入 response。 + * + * @param ratio 页面当前补助比例,单位为百分比 + * @param response 接收 Excel 下载内容的 HTTP 响应 + */ @At @Ok("void") @SaCheckPermission("medicalMutualAid.medical.calculateMoney") public void doExport(double ratio, HttpServletResponse response){ - List list = medicalCalculateMoneyService.getApplyData(ratio); - - list.forEach(l -> { - l.put("majorDisease", l.getBoolean("isMajorDisease") ? "是" : "否"); - }); - List exportEntities = new ArrayList<>(); - exportEntities.add(new ExcelExportEntity("姓名", "username", 20)); - exportEntities.add(new ExcelExportEntity("工号", "loginname", 20)); - exportEntities.add(new ExcelExportEntity("所属单位", "unitName", 20)); - exportEntities.add(new ExcelExportEntity("所属分工会", "unionName", 20)); - exportEntities.add(new ExcelExportEntity("疾病种类", "diseaseName", 20)); - exportEntities.add(new ExcelExportEntity("重大疾病", "majorDisease", 20)); - exportEntities.add(new ExcelExportEntity("申请时间", "applyTime", 20)); - exportEntities.add(new ExcelExportEntity("申请次数", "applyNum", 20)); - - exportEntities.add(new ExcelExportEntity("起扣时间", "aidFundDeductTime", 20)); - exportEntities.add(new ExcelExportEntity("缴费年数", "yearsSinceDeduct", 20)); - ExcelExportEntity hospitalSumMoneyExcelExportEntity = new ExcelExportEntity("住院医疗总额(不含门诊)", "hospitalSumMoney$", 20); - hospitalSumMoneyExcelExportEntity.setType(10); - exportEntities.add(hospitalSumMoneyExcelExportEntity); - ExcelExportEntity reimbursementMoneyExcelExportEntity = new ExcelExportEntity("实际报销总额", "reimbursementMoney$", 20); - reimbursementMoneyExcelExportEntity.setType(10); - exportEntities.add(reimbursementMoneyExcelExportEntity); - ExcelExportEntity singleBearMoneyExcelExportEntity = new ExcelExportEntity("(住院医疗)允许报销范围内个人承担部分总额", "singleBearMoney$", 20); - singleBearMoneyExcelExportEntity.setType(10); - exportEntities.add(singleBearMoneyExcelExportEntity); - ExcelExportEntity personExpenseMoneyExcelExportEntity = new ExcelExportEntity("(住院医疗)自费部分总额", "personExpenseMoney$", 20); - personExpenseMoneyExcelExportEntity.setType(10); - exportEntities.add(personExpenseMoneyExcelExportEntity); - ExcelExportEntity outpatientServiceMoneyExcelExportEntity = new ExcelExportEntity("重病门诊自费金额", "outpatientServiceMoney$", 20); - outpatientServiceMoneyExcelExportEntity.setType(10); - exportEntities.add(outpatientServiceMoneyExcelExportEntity); - ExcelExportEntity bxyMoneyExcelExportEntity = new ExcelExportEntity("非住院肾衰竭治疗、靶向药", "bxyMoney$", 20); - bxyMoneyExcelExportEntity.setType(10); - exportEntities.add(bxyMoneyExcelExportEntity); - ExcelExportEntity totalMoneyExcelExportEntity = new ExcelExportEntity("合计金额", "totalMoney", 20); - totalMoneyExcelExportEntity.setType(10); - exportEntities.add(totalMoneyExcelExportEntity); - ExcelExportEntity loveSubsidyMoneyExcelExportEntity = new ExcelExportEntity("爱心补助", "loveSubsidyMoney", 20); - loveSubsidyMoneyExcelExportEntity.setType(10); - exportEntities.add(loveSubsidyMoneyExcelExportEntity); - ExcelExportEntity subsidyMoneyExcelExportEntity = new ExcelExportEntity("当前补助金额", "subsidyMoney", 20); - subsidyMoneyExcelExportEntity.setType(10); - exportEntities.add(subsidyMoneyExcelExportEntity); - ExcelExportEntity ExcelExportEntityForecastSubsidyMoney = new ExcelExportEntity("按" + ratio + "%补助金额", "forecastSubsidyMoney", 20); - ExcelExportEntityForecastSubsidyMoney.setType(10); - exportEntities.add(ExcelExportEntityForecastSubsidyMoney); - ExcelExportEntity medicalSubsidyMoneyExcelExportEntity = new ExcelExportEntity("以往累计补助金额", "medicalSubsidyMoney", 20); - medicalSubsidyMoneyExcelExportEntity.setType(10); - exportEntities.add(medicalSubsidyMoneyExcelExportEntity); - try { - ExportParams exportParams = new ExportParams(); - exportParams.setType(ExcelType.XSSF); - Workbook workbook = ExcelExportUtil.exportExcel(exportParams, exportEntities, list); - CommonDownloadUtil.download("测算补助金额汇总表.xlsx", workbook, response); - }catch (Exception e){ - e.printStackTrace(); - } - - + medicalCalculateMoneyService.doExport(ratio, response); } diff --git a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/controller/MedicalStatisticsController.java b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/controller/MedicalStatisticsController.java index 89321b9..0da2a2f 100644 --- a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/controller/MedicalStatisticsController.java +++ b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/controller/MedicalStatisticsController.java @@ -1,14 +1,9 @@ package com.budwk.app.zhgh.staffbenefit.medicalMutualAid.medical.controller; -import cn.afterturn.easypoi.excel.ExcelExportUtil; -import cn.afterturn.easypoi.excel.entity.ExportParams; -import cn.afterturn.easypoi.excel.entity.enmus.ExcelType; -import cn.afterturn.easypoi.excel.entity.params.ExcelExportEntity; import cn.dev33.satoken.annotation.SaCheckPermission; import cn.hutool.core.util.StrUtil; import com.budwk.app.base.page.Pagination; import com.budwk.app.base.result.Result; -import com.budwk.app.base.utils.CommonDownloadUtil; import com.budwk.app.base.utils.PageUtil; import com.budwk.app.flow.engine.FlowEngine; import com.budwk.app.web.commons.auth.utils.SecurityUtil; @@ -17,7 +12,6 @@ import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.medical.service.MedicalA import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.extern.slf4j.Slf4j; -import org.apache.poi.ss.usermodel.Workbook; import org.nutz.dao.Cnd; import org.nutz.dao.Sqls; import org.nutz.dao.entity.Record; @@ -30,7 +24,6 @@ import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.Ok; import javax.servlet.http.HttpServletResponse; -import java.util.ArrayList; import java.util.List; /** @@ -124,115 +117,19 @@ public class MedicalStatisticsController { return Result.success(pagination); } + /** + * 导出医疗补助统计表,返回类型为 void,Excel 文件直接写入 response。 + * + * @param pageForm 页面筛选、分页及排序条件 + * @param isMajorDiseases true 为重疾,false 为普通疾病,null 不限制类型 + * @param response 接收下载内容的 HTTP 响应 + */ @At @Ok("void") - @ApiOperation("分页查询") + @ApiOperation("导出医疗补助统计表") @SaCheckPermission("medicalMutualAid.medical.statistics") public void doExport(MedicalPageForm pageForm, Boolean isMajorDiseases, HttpServletResponse response) { - Sql sql = Sqls.create(""" - SELECT - info.*, - type.diseaseName, - type.diseaseCode, - type.isMajorDiseases, - us.username, - us.loginname, - us.unitName, - us.unitCode, - us.unionName, - us.unionCode, - ins.id AS instanceId, - ins.businessNo, - ins.state instanceState, - ins.variable instanceVariable, - ins.processDefineId instanceProcessDefineId, - t.id taskId, - t.taskName AS taskKey, - t.displayName taskName, - t.taskType, - t.performType taskPerformType, - t.taskState, - t.finishTime, - t.taskParentId, - t.variable taskVariable, - IF((SELECT taskName FROM wf_process_task tt WHERE tt.id = t.taskParentId) = 'startTask', 1, 0) canRevoke, - (SELECT MAX(id) FROM wf_process_task WHERE processInstanceId = ins.id AND taskName = 'startTask') AS startTaskId - FROM - medical_apply info - LEFT JOIN medical_disease_type type ON info.diseaseId = type.id - LEFT JOIN aid_fund_member_pay member ON member.userId = info.userId - LEFT JOIN vw_user us ON us.id = info.userId - LEFT JOIN `wf_process_instance` ins ON ins.businessNo = info.id - LEFT JOIN wf_process_task t ON t.processInstanceId = ins.id - AND t.taskState = 10 - LEFT JOIN wf_process_task_actor ta ON ta.processTaskId = t.id - $condition - """); - Cnd cnd = Cnd.NEW(); - if (StrUtil.isNotBlank(pageForm.getSearchKeyword())) { - SqlExpressionGroup seg = new SqlExpressionGroup(); - seg.orLike("us.loginname", pageForm.getSearchKeyword()); - seg.orLike("us.username", pageForm.getSearchKeyword()); - cnd.and(seg); - } - cnd.andEX("year(info.applyTime)", "=", pageForm.getYear()); - cnd.andEX("info.diseaseId", "=", pageForm.getDiseaseId()); - cnd.andEX("us.unionId", "=", pageForm.getUnionId()); - cnd.andEX("us.unitId", "=", pageForm.getUnitId()); - cnd.andEX("member.aidFundMemberUserType", "=", pageForm.getAidFundMemberUserType()); - cnd.andEX("type.isMajorDiseases", "=", isMajorDiseases); - - if (StrUtil.isNotBlank(pageForm.getPageOrderName()) && StrUtil.isNotBlank(pageForm.getPageOrderBy())) { - cnd.orderBy(pageForm.getPageOrderName(), PageUtil.getOrder(pageForm.getPageOrderBy())); - } else { - cnd.desc("info.applyTime"); - } - cnd.and("ins.state", "=", 20); - cnd.groupBy("info.id"); - sql.setCondition(cnd); - Pagination pagination = medicalApplyService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql); - List list = medicalApplyService.getCostList(pagination.getList()); - for (NutMap map : list) { - boolean isMajor = map.getBoolean("isMajorDiseases"); - map.put("isMajorDiseasesDisplay", isMajor ? "是" : "否"); - } - List excelExportEntities = new ArrayList<>(); - excelExportEntities.add(new ExcelExportEntity("姓名","username",20)); - excelExportEntities.add(new ExcelExportEntity("工号","loginname",20)); - excelExportEntities.add(new ExcelExportEntity("单位","unitName",20)); - excelExportEntities.add(new ExcelExportEntity("单位编码","unitCode",20)); - excelExportEntities.add(new ExcelExportEntity("工会","unionName",20)); - excelExportEntities.add(new ExcelExportEntity("工会编码","unionCode",20)); - excelExportEntities.add(new ExcelExportEntity("疾病名称","diseaseName",20)); - excelExportEntities.add(new ExcelExportEntity("疾病编码","diseaseCode",20)); - excelExportEntities.add(new ExcelExportEntity("是否重疾","isMajorDiseasesDisplay",20)); - excelExportEntities.add(new ExcelExportEntity("申请时间","applyTime",20)); - excelExportEntities.add(new ExcelExportEntity("申请次数","applyNum",20)); - excelExportEntities.add(new ExcelExportEntity("住院医疗费总额(不含门诊)","hospitalSumMoney$",20)); - excelExportEntities.add(new ExcelExportEntity("实际报销总额","reimbursementMoney$",20)); - excelExportEntities.add(new ExcelExportEntity("(住院医疗)允许报销范围内个人承担部分金额","singleBearMoney$",20)); - excelExportEntities.add(new ExcelExportEntity("(住院医疗)自费部分金额","personExpenseMoney$",20)); - excelExportEntities.add(new ExcelExportEntity("重病门诊自费金额","outpatientServiceMoney$",20)); - excelExportEntities.add(new ExcelExportEntity("非住院肾衰竭治疗、靶向药金额","bxyMoney$",20)); - excelExportEntities.add(new ExcelExportEntity("合计","totalMoney",20)); - excelExportEntities.add(new ExcelExportEntity("预测补助金额","forecastSubsidyMoney",20)); - excelExportEntities.add(new ExcelExportEntity("补助金额","subsidyMoney",20)); - excelExportEntities.add(new ExcelExportEntity("爱心基金","loveSubsidyMoney",20)); - - String exportYear = String.valueOf(pageForm.getYear()); - if (StrUtil.isBlank(exportYear)) { - exportYear = String.valueOf(java.time.Year.now().getValue()); - } - String fileName = exportYear + "年补助人员列表.xlsx"; - - try { - ExportParams exportParams = new ExportParams(); - exportParams.setType(ExcelType.XSSF); - Workbook workbook = ExcelExportUtil.exportExcel(exportParams, excelExportEntities, list); - CommonDownloadUtil.download(fileName, workbook, response); - } catch (Exception e) { - log.error("导出补助人员列表失败", e); - } + medicalApplyService.exportStatistics(pageForm, isMajorDiseases, response); } } diff --git a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/service/MedicalApplyService.java b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/service/MedicalApplyService.java index 8cd08f8..842c6cf 100644 --- a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/service/MedicalApplyService.java +++ b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/service/MedicalApplyService.java @@ -2,8 +2,10 @@ package com.budwk.app.zhgh.staffbenefit.medicalMutualAid.medical.service; import com.budwk.app.base.service.BaseService; import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.medical.model.MedicalApply; +import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.medical.param.MedicalPageForm; import org.nutz.lang.util.NutMap; +import javax.servlet.http.HttpServletResponse; import java.util.List; public interface MedicalApplyService extends BaseService { @@ -27,6 +29,16 @@ public interface MedicalApplyService extends BaseService { */ List getCostList(List list); + /** + * 导出医疗补助统计记录,金额列为数值类型,最后一行为本次导出记录的逐列合计。 + * 返回类型为 void,XSSF 文件内容直接写入 response。 + * + * @param pageForm 页面筛选与分页条件,包含年度、姓名或工号、疾病、工会、单位、会员类型和排序 + * @param isMajorDiseases true 导出重疾,false 导出普通疾病,null 不限制疾病类型 + * @param response 接收 Excel 下载内容的 HTTP 响应 + */ + void exportStatistics(MedicalPageForm pageForm, Boolean isMajorDiseases, HttpServletResponse response); + /** * 查询是否有进行中的申请 diff --git a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/service/MedicalCalculateMoneyService.java b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/service/MedicalCalculateMoneyService.java index 343c6e2..191f23a 100644 --- a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/service/MedicalCalculateMoneyService.java +++ b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/service/MedicalCalculateMoneyService.java @@ -1,8 +1,11 @@ package com.budwk.app.zhgh.staffbenefit.medicalMutualAid.medical.service; import com.budwk.app.base.service.BaseService; +import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.medical.model.MedicalApply; import org.nutz.lang.util.NutMap; +import javax.servlet.http.HttpServletResponse; +import java.math.BigDecimal; import java.util.List; public interface MedicalCalculateMoneyService extends BaseService { @@ -14,4 +17,25 @@ public interface MedicalCalculateMoneyService extends BaseService { NutMap getData(); List getApplyData(double ratio); + + /** + * 按笔取整并保存分配结果,使用实际写入的补助合计更新年度结余。 + * 返回类型为 void;申请金额和年度结余在同一事务中保存,校验失败时抛出业务异常。 + * + * @param list 非空申请列表,每项使用 id 和非负的 forecastSubsidyMoney,其他字段不参与更新 + * @param ratio 当前分配比例,单位为百分比 + * @param aidFund 本年度会员缴费总额,单位为元 + * @param inComeSum 本年度收入累计总额,单位为元,沿用原收入取整口径 + * @param loveSubsidyMoney 实际爱心补助合计,保留小数精度,null 按零处理 + */ + void saveAllocation(List list, float ratio, double aidFund, double inComeSum, + BigDecimal loveSubsidyMoney); + + /** + * 按列表测算口径导出申请数据;返回类型为 void,Excel 列对应申请信息、费用和测算金额。 + * + * @param ratio 页面当前补助比例,单位为百分比 + * @param response 用于写入 Excel 文件的 HTTP 响应 + */ + void doExport(double ratio, HttpServletResponse response); } diff --git a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/service/impl/MedicalApplyServiceImpl.java b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/service/impl/MedicalApplyServiceImpl.java index 8a71c1c..c095599 100644 --- a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/service/impl/MedicalApplyServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/service/impl/MedicalApplyServiceImpl.java @@ -1,9 +1,16 @@ package com.budwk.app.zhgh.staffbenefit.medicalMutualAid.medical.service.impl; +import cn.afterturn.easypoi.excel.ExcelExportUtil; +import cn.afterturn.easypoi.excel.entity.ExportParams; +import cn.afterturn.easypoi.excel.entity.enmus.ExcelType; +import cn.afterturn.easypoi.excel.entity.params.ExcelExportEntity; import cn.hutool.core.date.DateUtil; import cn.hutool.core.util.StrUtil; +import com.budwk.app.base.page.Pagination; import com.budwk.app.base.result.Result; import com.budwk.app.base.service.impl.BaseServiceImpl; +import com.budwk.app.base.utils.CommonDownloadUtil; +import com.budwk.app.base.utils.PageUtil; import com.budwk.app.flow.enums.ProcessInstanceStateEnum; import com.budwk.app.sys.models.Sys_user; import com.budwk.app.web.commons.auth.utils.SecurityUtil; @@ -11,7 +18,10 @@ import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.medical.model.MedicalApp import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.medical.model.MedicalApplyCost; import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.medical.model.MedicalDiseaseType; import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.medical.model.MedicalSetting; +import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.medical.param.MedicalPageForm; import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.medical.service.MedicalApplyService; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.ss.usermodel.Workbook; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; import org.nutz.dao.Sqls; @@ -21,9 +31,11 @@ import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.lang.Lang; import org.nutz.lang.util.NutMap; +import javax.servlet.http.HttpServletResponse; import java.math.BigDecimal; import java.math.RoundingMode; import java.text.DecimalFormat; +import java.util.ArrayList; import java.util.Calendar; import java.util.HashMap; import java.util.List; @@ -35,6 +47,7 @@ import java.util.Objects; * @description */ @IocBean(args = {"refer:dao"}) +@Slf4j public class MedicalApplyServiceImpl extends BaseServiceImpl implements MedicalApplyService { public MedicalApplyServiceImpl(Dao dao) { super(dao); @@ -109,6 +122,151 @@ public class MedicalApplyServiceImpl extends BaseServiceImpl imple return list; } + @Override + public void exportStatistics(MedicalPageForm pageForm, Boolean isMajorDiseases, HttpServletResponse response) { + // 沿用统计页的筛选、排序及分页范围,合计金额只覆盖本次实际导出的记录。 + Sql sql = Sqls.create(""" + SELECT + info.*, + type.diseaseName, + type.diseaseCode, + type.isMajorDiseases, + us.username, + us.loginname, + us.unitName, + us.unitCode, + us.unionName, + us.unionCode, + ins.id AS instanceId, + ins.businessNo, + ins.state instanceState, + ins.variable instanceVariable, + ins.processDefineId instanceProcessDefineId, + t.id taskId, + t.taskName AS taskKey, + t.displayName taskName, + t.taskType, + t.performType taskPerformType, + t.taskState, + t.finishTime, + t.taskParentId, + t.variable taskVariable, + IF((SELECT taskName FROM wf_process_task tt WHERE tt.id = t.taskParentId) = 'startTask', 1, 0) canRevoke, + (SELECT MAX(id) FROM wf_process_task WHERE processInstanceId = ins.id AND taskName = 'startTask') AS startTaskId + FROM + medical_apply info + LEFT JOIN medical_disease_type type ON info.diseaseId = type.id + LEFT JOIN aid_fund_member_pay member ON member.userId = info.userId + LEFT JOIN vw_user us ON us.id = info.userId + LEFT JOIN `wf_process_instance` ins ON ins.businessNo = info.id + LEFT JOIN wf_process_task t ON t.processInstanceId = ins.id + AND t.taskState = 10 + LEFT JOIN wf_process_task_actor ta ON ta.processTaskId = t.id + $condition + """); + Cnd cnd = Cnd.NEW(); + if (StrUtil.isNotBlank(pageForm.getSearchKeyword())) { + SqlExpressionGroup seg = new SqlExpressionGroup(); + seg.orLike("us.loginname", pageForm.getSearchKeyword()); + seg.orLike("us.username", pageForm.getSearchKeyword()); + cnd.and(seg); + } + cnd.andEX("year(info.applyTime)", "=", pageForm.getYear()); + cnd.andEX("info.diseaseId", "=", pageForm.getDiseaseId()); + cnd.andEX("us.unionId", "=", pageForm.getUnionId()); + cnd.andEX("us.unitId", "=", pageForm.getUnitId()); + cnd.andEX("member.aidFundMemberUserType", "=", pageForm.getAidFundMemberUserType()); + cnd.andEX("type.isMajorDiseases", "=", isMajorDiseases); + + if (StrUtil.isNotBlank(pageForm.getPageOrderName()) && StrUtil.isNotBlank(pageForm.getPageOrderBy())) { + cnd.orderBy(pageForm.getPageOrderName(), PageUtil.getOrder(pageForm.getPageOrderBy())); + } else { + cnd.desc("info.applyTime"); + } + cnd.and("ins.state", "=", 20); + cnd.groupBy("info.id"); + sql.setCondition(cnd); + Pagination pagination = listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql); + List list = getCostList(pagination.getList()); + for (NutMap map : list) { + boolean isMajor = map.getBoolean("isMajorDiseases"); + map.put("isMajorDiseasesDisplay", isMajor ? "是" : "否"); + } + String exportYear = String.valueOf(pageForm.getYear()); + if (StrUtil.isBlank(exportYear)) { + exportYear = String.valueOf(java.time.Year.now().getValue()); + } + String fileName = exportYear + "年补助人员列表.xlsx"; + + try { + Workbook workbook = createStatisticsExportWorkbook(list); + CommonDownloadUtil.download(fileName, workbook, response); + } catch (Exception e) { + log.error("导出补助人员列表失败", e); + } + } + + /** + * 生成统计导出工作簿,统一金额列类型并追加逐列合计。 + * + * @param list 已补齐费用汇总字段的申请记录,不包含合计行 + * @return XSSF 工作簿;申请信息保持原列顺序,十个金额列及末尾合计金额均为数值类型 + */ + private Workbook createStatisticsExportWorkbook(List list) { + List excelExportEntities = new ArrayList<>(); + excelExportEntities.add(new ExcelExportEntity("姓名","username",20)); + excelExportEntities.add(new ExcelExportEntity("工号","loginname",20)); + excelExportEntities.add(new ExcelExportEntity("单位","unitName",20)); + excelExportEntities.add(new ExcelExportEntity("单位编码","unitCode",20)); + excelExportEntities.add(new ExcelExportEntity("工会","unionName",20)); + excelExportEntities.add(new ExcelExportEntity("工会编码","unionCode",20)); + excelExportEntities.add(new ExcelExportEntity("疾病名称","diseaseName",20)); + excelExportEntities.add(new ExcelExportEntity("疾病编码","diseaseCode",20)); + excelExportEntities.add(new ExcelExportEntity("是否重疾","isMajorDiseasesDisplay",20)); + excelExportEntities.add(new ExcelExportEntity("申请时间","applyTime",20)); + excelExportEntities.add(new ExcelExportEntity("申请次数","applyNum",20)); + excelExportEntities.add(new ExcelExportEntity("住院医疗费总额(不含门诊)","hospitalSumMoney$",20)); + excelExportEntities.add(new ExcelExportEntity("实际报销总额","reimbursementMoney$",20)); + excelExportEntities.add(new ExcelExportEntity("(住院医疗)允许报销范围内个人承担部分金额","singleBearMoney$",20)); + excelExportEntities.add(new ExcelExportEntity("(住院医疗)自费部分金额","personExpenseMoney$",20)); + excelExportEntities.add(new ExcelExportEntity("重病门诊自费金额","outpatientServiceMoney$",20)); + excelExportEntities.add(new ExcelExportEntity("非住院肾衰竭治疗、靶向药金额","bxyMoney$",20)); + excelExportEntities.add(new ExcelExportEntity("合计","totalMoney",20)); + excelExportEntities.add(new ExcelExportEntity("预测补助金额","forecastSubsidyMoney",20)); + excelExportEntities.add(new ExcelExportEntity("补助金额","subsidyMoney",20)); + excelExportEntities.add(new ExcelExportEntity("爱心基金","loveSubsidyMoney",20)); + + List moneyFields = List.of("hospitalSumMoney$", "reimbursementMoney$", "singleBearMoney$", + "personExpenseMoney$", "outpatientServiceMoney$", "bxyMoney$", "totalMoney", + "forecastSubsidyMoney", "subsidyMoney", "loveSubsidyMoney"); + // 与测算页导出一致,金额写入 Excel 数值单元格;工号和单位编码仍按文本输出。 + for (ExcelExportEntity entity : excelExportEntities) { + if (moneyFields.contains(entity.getKey())) { + entity.setType(10); + } + } + + NutMap summary = new NutMap(); + summary.put("username", "合计"); + // 空金额按零参与汇总,使用十进制累加避免浮点误差;没有记录时合计也为零。 + for (String field : moneyFields) { + BigDecimal sum = BigDecimal.ZERO; + for (NutMap row : list) { + String rawValue = row.getString(field); + BigDecimal money = StrUtil.isBlank(rawValue) ? BigDecimal.ZERO : new BigDecimal(rawValue); + row.put(field, money); + sum = sum.add(money); + } + summary.put(field, sum); + } + List exportList = new ArrayList<>(list); + exportList.add(summary); + + ExportParams exportParams = new ExportParams(); + exportParams.setType(ExcelType.XSSF); + return ExcelExportUtil.exportExcel(exportParams, excelExportEntities, exportList); + } + @Override public Boolean getIsAfootMedicalApply(String userId, String id) { Sql sql = Sqls.create(""" diff --git a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/service/impl/MedicalCalculateMoneyServiceImpl.java b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/service/impl/MedicalCalculateMoneyServiceImpl.java index 4e67cb0..308d165 100644 --- a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/service/impl/MedicalCalculateMoneyServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/service/impl/MedicalCalculateMoneyServiceImpl.java @@ -1,26 +1,40 @@ package com.budwk.app.zhgh.staffbenefit.medicalMutualAid.medical.service.impl; +import cn.afterturn.easypoi.excel.ExcelExportUtil; +import cn.afterturn.easypoi.excel.entity.ExportParams; +import cn.afterturn.easypoi.excel.entity.enmus.ExcelType; +import cn.afterturn.easypoi.excel.entity.params.ExcelExportEntity; +import cn.hutool.core.util.StrUtil; +import com.budwk.app.base.exception.BaseException; import com.budwk.app.base.service.impl.BaseServiceImpl; +import com.budwk.app.base.utils.CommonDownloadUtil; import com.budwk.app.flow.enums.ProcessInstanceStateEnum; import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.aidFund.model.AidFundMemberPay; import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.medical.model.MedicalApply; import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.medical.model.MedicalMoneyDetail; import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.medical.service.MedicalApplyService; import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.medical.service.MedicalCalculateMoneyService; +import org.apache.poi.ss.usermodel.Workbook; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.dao.Chain; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; import org.nutz.dao.Sqls; import org.nutz.dao.sql.Sql; +import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.lang.Lang; import org.nutz.lang.util.NutMap; +import javax.servlet.http.HttpServletResponse; import java.math.BigDecimal; import java.math.RoundingMode; -import java.text.DecimalFormat; +import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Objects; +import java.util.Set; /** * @author zhf @@ -70,10 +84,12 @@ public class MedicalCalculateMoneyServiceImpl extends BaseServiceImpl implements double subsidyMoney = applyList.stream().mapToDouble(apply -> apply.getDouble("subsidyMoney")).sum(); nutMap.setv("subsidyMoney", subsidyMoney); - //获取申请成功的爱心补助金额 - //找到有多少数据 - int firstApplyCount = applyList.stream().filter(apply -> apply.getInt("applyNum") == 1).toList().size(); - nutMap.setv("loveSubsidyMoney", firstApplyCount * 5000); + // 与列表使用相同的已办结申请记录,汇总实际爱心补助;零金额和空值不增加合计。 + BigDecimal loveSubsidyMoney = applyList.stream() + .map(apply -> apply.getString("loveSubsidyMoney")) + .map(money -> StrUtil.isBlank(money) ? BigDecimal.ZERO : new BigDecimal(money)) + .reduce(BigDecimal.ZERO, BigDecimal::add); + nutMap.setv("loveSubsidyMoney", loveSubsidyMoney); //查询申请成功后的住院缴费金额 List applyIds = applyList.stream().map(v -> v.getString("id")).toList(); @@ -85,6 +101,12 @@ public class MedicalCalculateMoneyServiceImpl extends BaseServiceImpl implements return nutMap; } + /** + * 返回最新申请年度的已办结测算列表,供页面和导出共用。 + * + * @param ratio 补助比例的百分数,例如 60 表示按 60% 测算 + * @return 每项为申请及费用字段的 NutMap;subsidyMoney 保留已保存金额,forecastSubsidyMoney 按笔四舍五入到整数元 + */ @Override public List getApplyData(double ratio) { //获取申请最新年份 @@ -98,12 +120,150 @@ public class MedicalCalculateMoneyServiceImpl extends BaseServiceImpl implements applyList.forEach(apply -> { apply.putAll(medicalApplyService.calcMedicalMoney(apply.getString("id"),null, ratio)); + // 仅对待分配的测算金额取整;已保存金额保留原值,避免掩盖尚未重新分配的历史小数。 + String money = apply.getString("forecastSubsidyMoney"); + apply.put("forecastSubsidyMoney", StrUtil.isBlank(money) ? BigDecimal.ZERO + : new BigDecimal(money).setScale(0, RoundingMode.HALF_UP)); }); return applyList; } + @Override + @Aop(TransAop.READ_COMMITTED) + public void saveAllocation(List list, float ratio, double aidFund, double inComeSum, + BigDecimal loveSubsidyMoney) { + // 先校验整批参数;空金额不能当作零覆盖,重复申请不能重复计入年度支出。 + if (Lang.isEmpty(list)) { + throw new BaseException("请提交需要分配的申请记录"); + } + Set applyIds = new HashSet<>(); + for (MedicalApply apply : list) { + if (apply == null || StrUtil.isBlank(apply.getId()) || apply.getForecastSubsidyMoney() == null + || apply.getForecastSubsidyMoney().signum() < 0) { + throw new BaseException("申请记录及补助金额不能为空,补助金额不能为负数"); + } + if (!applyIds.add(apply.getId())) { + throw new BaseException("申请记录重复,请刷新后重新分配"); + } + } + + Sql yearSql = Sqls.create("SELECT MAX(YEAR(applyTime)) AS currentYear FROM medical_apply"); + yearSql.setCallback(Sqls.callback.longs()); + dao().execute(yearSql); + Integer year = yearSql.getInt(); + + BigDecimal subsidyMoney = BigDecimal.ZERO; + for (MedicalApply apply : list) { + // 与测算列表使用相同的按笔取整规则,两列写入同一个整数元金额。 + BigDecimal money = apply.getForecastSubsidyMoney().setScale(0, RoundingMode.HALF_UP); + dao().update(MedicalApply.class, Chain.make("forecastSubsidyMoney", money) + .add("subsidyMoney", money), Cnd.where("id", "=", apply.getId())); + subsidyMoney = subsidyMoney.add(money); + } + // 结余扣除实际保存的整数补助合计;爱心补助独立保留小数,不受常规补助取整影响。 + BigDecimal thisYearBalance = BigDecimal.valueOf(Math.round(inComeSum)) + .subtract(subsidyMoney) + .subtract(loveSubsidyMoney == null ? BigDecimal.ZERO : loveSubsidyMoney); + MedicalMoneyDetail moneyDetail = dao().fetch(MedicalMoneyDetail.class, Cnd.where("year", "=", year)); + if (Lang.isEmpty(moneyDetail)) { + moneyDetail = new MedicalMoneyDetail(); + moneyDetail.setYear(year); + moneyDetail.setAidFund(BigDecimal.valueOf(aidFund)); + moneyDetail.setRatio(ratio); + moneyDetail.setLastYearBalance(thisYearBalance); + insert(moneyDetail); + } else { + moneyDetail.setAidFund(BigDecimal.valueOf(aidFund)); + moneyDetail.setRatio(ratio); + moneyDetail.setLastYearBalance(thisYearBalance); + updateIgnoreNull(moneyDetail); + } + } + + @Override + public void doExport(double ratio, HttpServletResponse response) { + // 导出复用列表查询结果,保证服务端测算金额与页面初次加载时的口径一致。 + List list = getApplyData(ratio); + list.forEach(apply -> apply.put("majorDisease", apply.getBoolean("isMajorDiseases") ? "是" : "否")); + + List exportEntities = new ArrayList<>(); + exportEntities.add(new ExcelExportEntity("姓名", "username", 20)); + exportEntities.add(new ExcelExportEntity("工号", "loginname", 20)); + exportEntities.add(new ExcelExportEntity("所属单位", "unitName", 20)); + exportEntities.add(new ExcelExportEntity("所属分工会", "unionName", 20)); + exportEntities.add(new ExcelExportEntity("疾病种类", "diseaseName", 20)); + exportEntities.add(new ExcelExportEntity("重大疾病", "majorDisease", 20)); + exportEntities.add(new ExcelExportEntity("申请时间", "applyTime", 20)); + exportEntities.add(new ExcelExportEntity("申请次数", "applyNum", 20)); + + exportEntities.add(new ExcelExportEntity("起扣时间", "aidFundDeductTime", 20)); + exportEntities.add(new ExcelExportEntity("缴费年数", "yearsSinceDeduct", 20)); + ExcelExportEntity hospitalSumMoneyExcelExportEntity = new ExcelExportEntity("住院医疗总额(不含门诊)", "hospitalSumMoney$", 20); + hospitalSumMoneyExcelExportEntity.setType(10); + exportEntities.add(hospitalSumMoneyExcelExportEntity); + ExcelExportEntity reimbursementMoneyExcelExportEntity = new ExcelExportEntity("实际报销总额", "reimbursementMoney$", 20); + reimbursementMoneyExcelExportEntity.setType(10); + exportEntities.add(reimbursementMoneyExcelExportEntity); + ExcelExportEntity singleBearMoneyExcelExportEntity = new ExcelExportEntity("(住院医疗)允许报销范围内个人承担部分总额", "singleBearMoney$", 20); + singleBearMoneyExcelExportEntity.setType(10); + exportEntities.add(singleBearMoneyExcelExportEntity); + ExcelExportEntity personExpenseMoneyExcelExportEntity = new ExcelExportEntity("(住院医疗)自费部分总额", "personExpenseMoney$", 20); + personExpenseMoneyExcelExportEntity.setType(10); + exportEntities.add(personExpenseMoneyExcelExportEntity); + ExcelExportEntity outpatientServiceMoneyExcelExportEntity = new ExcelExportEntity("重病门诊自费金额", "outpatientServiceMoney$", 20); + outpatientServiceMoneyExcelExportEntity.setType(10); + exportEntities.add(outpatientServiceMoneyExcelExportEntity); + ExcelExportEntity bxyMoneyExcelExportEntity = new ExcelExportEntity("非住院肾衰竭治疗、靶向药", "bxyMoney$", 20); + bxyMoneyExcelExportEntity.setType(10); + exportEntities.add(bxyMoneyExcelExportEntity); + ExcelExportEntity totalMoneyExcelExportEntity = new ExcelExportEntity("合计金额", "totalMoney", 20); + totalMoneyExcelExportEntity.setType(10); + exportEntities.add(totalMoneyExcelExportEntity); + ExcelExportEntity loveSubsidyMoneyExcelExportEntity = new ExcelExportEntity("爱心补助", "loveSubsidyMoney", 20); + loveSubsidyMoneyExcelExportEntity.setType(10); + exportEntities.add(loveSubsidyMoneyExcelExportEntity); + // 当前补助读取已保存值,按比例补助读取待分配值,与页面两列含义保持一致。 + ExcelExportEntity subsidyMoneyExcelExportEntity = new ExcelExportEntity("当前补助金额", "subsidyMoney", 20); + subsidyMoneyExcelExportEntity.setType(10); + exportEntities.add(subsidyMoneyExcelExportEntity); + ExcelExportEntity forecastSubsidyMoneyExcelExportEntity = new ExcelExportEntity("按" + ratio + "%补助金额", "forecastSubsidyMoney", 20); + forecastSubsidyMoneyExcelExportEntity.setType(10); + exportEntities.add(forecastSubsidyMoneyExcelExportEntity); + ExcelExportEntity medicalSubsidyMoneyExcelExportEntity = new ExcelExportEntity("以往累计补助金额", "medicalSubsidyMoney", 20); + medicalSubsidyMoneyExcelExportEntity.setType(10); + exportEntities.add(medicalSubsidyMoneyExcelExportEntity); + try { + List moneyFields = List.of("hospitalSumMoney$", "reimbursementMoney$", "singleBearMoney$", + "personExpenseMoney$", "outpatientServiceMoney$", "bxyMoney$", "totalMoney", + "loveSubsidyMoney", "subsidyMoney", "forecastSubsidyMoney", "medicalSubsidyMoney"); + NutMap summary = new NutMap(); + summary.put("username", "合计"); + // 已保存补助和测算补助分别精确累加,空金额按零处理。 + for (String field : moneyFields) { + BigDecimal sum = BigDecimal.ZERO; + for (NutMap row : list) { + String rawValue = row.getString(field); + BigDecimal money = StrUtil.isBlank(rawValue) ? BigDecimal.ZERO : new BigDecimal(rawValue); + row.put(field, money); + sum = sum.add(money); + } + summary.put(field, sum); + } + // 仅在导出数据末尾追加合计行,未查询到申请时各金额列显示数值零。 + List exportList = new ArrayList<>(list); + exportList.add(summary); + + ExportParams exportParams = new ExportParams(); + exportParams.setType(ExcelType.XSSF); + Workbook workbook = ExcelExportUtil.exportExcel(exportParams, exportEntities, exportList); + CommonDownloadUtil.download("测算补助金额汇总表.xlsx", workbook, response); + } catch (Exception e) { + e.printStackTrace(); + } + } + /** * 根据申请id查询出住院缴费金额 diff --git a/src/main/java/com/budwk/app/zhgh/staffmanage/member/controller/apply/MemberApplyController.java b/src/main/java/com/budwk/app/zhgh/staffmanage/member/controller/apply/MemberApplyController.java index 90899d9..8ee03e5 100644 --- a/src/main/java/com/budwk/app/zhgh/staffmanage/member/controller/apply/MemberApplyController.java +++ b/src/main/java/com/budwk/app/zhgh/staffmanage/member/controller/apply/MemberApplyController.java @@ -12,6 +12,8 @@ import com.budwk.app.flow.engine.FlowEngine; import com.budwk.app.flow.entity.ProcessInstance; import com.budwk.app.flow.entity.ProcessTask; import com.budwk.app.flow.enums.ProcessSubmitTypeEnum; +import com.budwk.app.flow.enums.ProcessInstanceStateEnum; +import com.budwk.app.flow.enums.ProcessTaskStateEnum; import com.budwk.app.flow.service.FlowCommonService; import com.budwk.app.sys.models.Sys_union_group; import com.budwk.app.web.commons.auth.utils.AuthUtil; @@ -31,6 +33,7 @@ import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.lang.Lang; +import org.nutz.lang.util.NutMap; import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Param; @@ -87,9 +90,12 @@ public class MemberApplyController { @SaCheckPermission("member.apply.submit") @SLog(tag = "会员入会申请", msg = "提交申请,申请人: ${args[0].username}") public Result submit(@Param("data") MemberApplyRecord memberApplyRecord){ - if (StrUtil.isBlank(memberApplyRecord.getId())) { - memberCommonService.validateApplyOrChangeIsDoing(null, "apply"); + lockApplicant(); + String blockMessage = getApplyBlockMessage(currentUser(), memberApplyRecord.getId(), null); + if (StrUtil.isNotBlank(blockMessage)) { + return Result.error(blockMessage); } + memberApplyRecord.setUserId(SecurityUtil.getUserId()); // 开启流程实例 Dict args = Dict.create(); // 设置流程变量 @@ -126,9 +132,15 @@ public class MemberApplyController { @SaCheckPermission("member.apply.submit") @SLog(tag = "会员入会申请", msg = "重新提交申请,申请人: ${args[0].username}") public Result submitAgain(@Param("data") MemberApplyRecord memberApplyRecord, @Param("taskId") Long taskId) { - if (StrUtil.isBlank(memberApplyRecord.getId())) { - memberCommonService.validateApplyOrChangeIsDoing(null, "apply"); + lockApplicant(); + String blockMessage = getApplyBlockMessage(currentUser(), memberApplyRecord.getId(), taskId); + if (StrUtil.isNotBlank(blockMessage)) { + return Result.error(blockMessage); } + if (taskId == null) { + return Result.error("申请任务不存在或已处理,请勿重复提交"); + } + memberApplyRecord.setUserId(SecurityUtil.getUserId()); Dict dict = Dict.create(); // 设置流程变量 if ("HMC".equals(Globals.SchoolCode)) { @@ -190,14 +202,86 @@ public class MemberApplyController { /** - * 获取当前登录用户信息,是用来判断当前用户是否是会员 - * @return + * 获取当前登录用户信息及入会申请限制,供页面进入时提示。 + * + * @param id 当前编辑的申请ID,新申请可为空 + * @param taskId 重新提交的申请任务ID,新申请可为空 + * @return 用户资料及禁止提交的原因;合法退回重提不视为重复申请 */ @At @ApiOperation("获取当前登录用户信息") @SaCheckPermission("member.apply.submit") - public Result getSelfUserInfo() { - return Result.success(dao.fetch(View_user.class, Cnd.where("id", "=", SecurityUtil.getUserId()))); + public Result getSelfUserInfo(String id, Long taskId) { + View_user user = currentUser(); + if (user == null) { + return Result.error("无法获取当前用户信息"); + } + NutMap data = Lang.obj2map(user, NutMap.class); + data.setv("applicationBlockMessage", getApplyBlockMessage(user, id, taskId)); + return Result.success(data); + } + + private View_user currentUser() { + return dao.fetch(View_user.class, Cnd.where("id", "=", SecurityUtil.getUserId())); + } + + // 在提交事务中锁定申请人,避免并发请求同时通过检查并开启多个流程。 + private void lockApplicant() { + Sql sql = Sqls.create("SELECT id FROM sys_user WHERE id=@userId FOR UPDATE"); + sql.setParam("userId", SecurityUtil.getUserId()); + sql.setCallback(Sqls.callback.records()); + dao.execute(sql); + } + + // 页面提示与提交接口共用实时校验;只放行本人退回或撤回后仍待处理的申请节点。 + private String getApplyBlockMessage(View_user user, String id, Long taskId) { + if (user == null) { + return "无法获取当前用户信息"; + } + if (user.getMember() != null && user.getMember() != 0) { + return "您已是工会会员,无需重复提交入会申请"; + } + if (StrUtil.isNotBlank(id)) { + MemberApplyRecord record = dao.fetch(MemberApplyRecord.class, id); + if (record == null || !SecurityUtil.getUserId().equals(record.getUserId())) { + return "申请记录不存在或不属于当前用户"; + } + } + Long excludedInstanceId = -1L; + if (taskId != null) { + ProcessTask task = dao.fetch(ProcessTask.class, taskId); + ProcessInstance instance = task == null ? null : dao.fetch(ProcessInstance.class, task.getProcessInstanceId()); + if (StrUtil.isBlank(id) || task == null || instance == null + || !"startTask".equals(task.getTaskName()) + || !ProcessTaskStateEnum.DOING.getCode().equals(task.getTaskState()) + || !ProcessInstanceStateEnum.DOING.getCode().equals(instance.getState()) + || !id.equals(instance.getBusinessNo())) { + return "申请任务不存在或已处理,请勿重复提交"; + } + excludedInstanceId = instance.getId(); + } + // 检查全部申请,避免较新的草稿遮蔽仍在审批中的旧申请。 + Sql sql = Sqls.create(""" + SELECT COUNT(*) + FROM member_apply_record r + INNER JOIN wf_process_instance p ON p.businessNo = r.id + WHERE r.userId = @userId AND p.state IN (@doing, @pending) + AND p.id <> @excludedInstanceId + """); + sql.setParam("userId", SecurityUtil.getUserId()); + sql.setParam("doing", ProcessInstanceStateEnum.DOING.getCode()); + sql.setParam("pending", ProcessInstanceStateEnum.PENDING.getCode()); + sql.setParam("excludedInstanceId", excludedInstanceId); + sql.setCallback(Sqls.callback.integer()); + dao.execute(sql); + if (sql.getInt() > 0) { + return "您已有入会申请正在处理中,请勿重复提交"; + } + if (taskId == null && StrUtil.isNotBlank(id) + && dao.count(ProcessInstance.class, Cnd.where("businessNo", "=", id)) > 0) { + return "该申请已提交,请勿重复提交"; + } + return null; } } diff --git a/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/controller/PendingConfirmUserController.java b/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/controller/PendingConfirmUserController.java index 9cd8aa0..0d4a023 100644 --- a/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/controller/PendingConfirmUserController.java +++ b/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/controller/PendingConfirmUserController.java @@ -2,6 +2,7 @@ package com.budwk.app.zhgh.staffmanage.sourcechange.controller; import cn.dev33.satoken.annotation.SaCheckPermission; import com.budwk.app.base.result.Result; +import com.budwk.app.base.exception.BaseException; import com.budwk.app.base.annotation.RepeatSubmit; import com.budwk.app.base.annotation.SLog; import com.budwk.app.zhgh.staffmanage.member.models.MemberChangeRecord; @@ -13,7 +14,10 @@ import io.swagger.annotations.ApiOperation; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.AdaptBy; import org.nutz.mvc.annotation.Ok; +import org.nutz.mvc.upload.TempFile; +import org.nutz.mvc.upload.UploadAdaptor; import javax.servlet.http.HttpServletResponse; @@ -66,6 +70,27 @@ public class PendingConfirmUserController { pendingConfirmUserService.exportExcel(pageForm, response); } + /** + * 导入人员类型和人员属性变更。 + * + * @param file xls 或 xlsx 文件;表格中的“工号”定位人员,“编码”匹配人员属性字典配置 + * @return JSON 结果;成功时 msg 包含实际更新人数 + */ + @At + @Ok("json") + @RepeatSubmit + @SLog(tag = "待确认人员记录", msg = "导入人员类型和人员属性变更") + @ApiOperation("导入待确认人员变更") + @SaCheckPermission("staff.sourcechange.pending") + @AdaptBy(type = UploadAdaptor.class, args = {"ioc:fileUpload"}) + public Result importChange(TempFile file) { + if (file == null || file.getFile() == null || !file.getFile().isFile()) { + throw new BaseException("请选择需要导入的Excel文件"); + } + int changedCount = pendingConfirmUserService.importChange(file.getFile()); + return Result.success("已成功导入变更" + changedCount + "人"); + } + /** * 直接提交待确认人员变更,不创建会员变更申请及审批流程。 * diff --git a/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/mode/PendingConfirmUserImportExcelMode.java b/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/mode/PendingConfirmUserImportExcelMode.java new file mode 100644 index 0000000..ce0719d --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/mode/PendingConfirmUserImportExcelMode.java @@ -0,0 +1,46 @@ +package com.budwk.app.zhgh.staffmanage.sourcechange.mode; + +import cn.afterturn.easypoi.excel.annotation.Excel; +import lombok.Data; + +/** + * 待确认人员导入变更 Excel 行数据。 + * + * 模板沿用列表导出字段,导入业务只采纳工号和编码,其他字段仅用于人工核对。 + */ +@Data +public class PendingConfirmUserImportExcelMode { + + @Excel(name = "序号") + private String index; + + @Excel(name = "工号") + private String loginname; + + @Excel(name = "姓名") + private String username; + + @Excel(name = "性别") + private String sex; + + @Excel(name = "所属工会") + private String unionName; + + @Excel(name = "所属单位") + private String unitName; + + @Excel(name = "人员类型") + private String personType; + + @Excel(name = "人员属性") + private String userAttribute; + + @Excel(name = "在职状态") + private String userState; + + @Excel(name = "确认状态") + private String pendingConfirmStatusName; + + @Excel(name = "编码") + private String code; +} diff --git a/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/service/PendingConfirmUserService.java b/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/service/PendingConfirmUserService.java index 7874217..12060e2 100644 --- a/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/service/PendingConfirmUserService.java +++ b/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/service/PendingConfirmUserService.java @@ -8,6 +8,7 @@ import com.budwk.app.zhgh.staffmanage.sourcechange.param.PendingConfirmUserBatch import com.budwk.app.zhgh.staffmanage.sourcechange.param.PendingConfirmUserPageForm; import javax.servlet.http.HttpServletResponse; +import java.io.File; /** * 待确认人员记录业务服务。 @@ -30,6 +31,16 @@ public interface PendingConfirmUserService extends BaseService { */ void exportExcel(PendingConfirmUserPageForm pageForm, HttpServletResponse response); + /** + * 导入待确认人员变更。 + * Excel 必须包含“工号”和“编码”:工号定位系统人员,编码匹配人员属性字典的业务编码; + * 返回值为成功更新人员类型和人员属性的人员数量。 + * + * @param importFile 用户上传的 xls 或 xlsx 文件 + * @return 成功更新的人员数量 + */ + int importChange(File importFile); + /** * 直接提交待确认人员变更,不生成会员变更申请及流程数据。 * diff --git a/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/service/impl/PendingConfirmUserServiceImpl.java b/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/service/impl/PendingConfirmUserServiceImpl.java index fa7e0d6..49c786e 100644 --- a/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/service/impl/PendingConfirmUserServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/service/impl/PendingConfirmUserServiceImpl.java @@ -1,7 +1,9 @@ package com.budwk.app.zhgh.staffmanage.sourcechange.service.impl; import cn.afterturn.easypoi.excel.ExcelExportUtil; +import cn.afterturn.easypoi.excel.ExcelImportUtil; import cn.afterturn.easypoi.excel.entity.ExportParams; +import cn.afterturn.easypoi.excel.entity.ImportParams; import cn.afterturn.easypoi.excel.entity.enmus.ExcelType; import cn.afterturn.easypoi.excel.entity.params.ExcelExportEntity; import cn.hutool.core.bean.BeanUtil; @@ -23,8 +25,10 @@ import com.budwk.app.zhgh.staffmanage.member.service.MemberCommonService; import com.budwk.app.zhgh.staffmanage.sourcechange.param.PendingConfirmUserBatchChangeParam; import com.budwk.app.zhgh.staffmanage.sourcechange.param.PendingConfirmUserPageForm; import com.budwk.app.zhgh.staffmanage.sourcechange.service.PendingConfirmUserService; +import com.budwk.app.zhgh.staffmanage.sourcechange.mode.PendingConfirmUserImportExcelMode; import org.apache.poi.ss.usermodel.Workbook; import org.nutz.dao.Cnd; +import org.nutz.dao.Chain; import org.nutz.dao.Dao; import org.nutz.dao.Sqls; import org.nutz.dao.sql.Sql; @@ -38,6 +42,10 @@ import org.nutz.lang.util.NutMap; import javax.servlet.http.HttpServletResponse; import java.util.ArrayList; +import java.io.File; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -51,6 +59,7 @@ public class PendingConfirmUserServiceImpl extends BaseServiceImpl imp private static final int STATUS_PENDING = 1; private static final int STATUS_CONFIRMED = 2; + private static final String USER_ATTRIBUTE_DICT_CODE = "USER_ATTRIBUTE"; /** 待确认人员只允许转为以下两个字典名称对应的编码。 */ private static final Set ALLOWED_PERSON_TYPE_NAMES = Set.of("离退休人员", "其他"); @@ -103,6 +112,8 @@ public class PendingConfirmUserServiceImpl extends BaseServiceImpl imp exportEntities.add(new ExcelExportEntity("人员属性", "userAttribute", 20)); exportEntities.add(new ExcelExportEntity("在职状态", "userState", 15)); exportEntities.add(new ExcelExportEntity("确认状态", "pendingConfirmStatusName", 15)); + // 编码仅作为导入填写列,导出时不设置行数据。 + exportEntities.add(new ExcelExportEntity("编码", "changeCode", 20)); ExportParams exportParams = new ExportParams(); exportParams.setType(ExcelType.XSSF); @@ -110,6 +121,147 @@ public class PendingConfirmUserServiceImpl extends BaseServiceImpl imp CommonDownloadUtil.download("待确认人员记录.xlsx", workbook, response); } + /** + * 根据 Excel 工号和人员属性业务编码更新人员类型、人员属性。 + * 所有行先统一校验,任一行异常时不执行更新;Excel 中的其他展示字段不参与变更。 + * + * @param importFile 包含“工号”和“编码”列的 xls 或 xlsx 文件 + * @return 实际更新的人员数量 + */ + @Override + @Aop(TransAop.READ_COMMITTED) + public int importChange(File importFile) { + if (importFile == null || !importFile.isFile()) { + throw new BaseException("请选择需要导入的Excel文件"); + } + + List importRows; + try { + ImportParams importParams = new ImportParams(); + importRows = ExcelImportUtil.importExcel(importFile, PendingConfirmUserImportExcelMode.class, importParams); + } catch (Exception e) { + throw new BaseException("Excel文件解析失败,请使用待确认人员列表导出的模板"); + } + if (importRows == null || importRows.isEmpty()) { + throw new BaseException("导入文件中没有人员数据"); + } + + // 导出的列表可能包含人工保留的空行;空工号行不参与人员查询、字典校验和更新。 + List validImportRows = new ArrayList<>(); + Map excelRowNumberByRow = new IdentityHashMap<>(); + for (int index = 0; index < importRows.size(); index++) { + PendingConfirmUserImportExcelMode row = importRows.get(index); + if (row == null || StrUtil.isBlank(row.getLoginname())) { + continue; + } + validImportRows.add(row); + excelRowNumberByRow.put(row, index + 2); + } + if (validImportRows.isEmpty()) { + throw new BaseException("导入文件中没有有效工号数据"); + } + + Map attributeDictByBusinessCode = buildAttributeDictByBusinessCode(); + Map userByLoginname = buildImportUserMap(validImportRows); + Set importedLoginnames = new HashSet<>(); + + // 全量校验必须在更新前完成,防止错误文件只修改其中一部分人员。 + for (PendingConfirmUserImportExcelMode row : validImportRows) { + int excelRowNumber = excelRowNumberByRow.get(row); + String loginname = StrUtil.trim(row.getLoginname()); + String changeCode = StrUtil.trim(row.getCode()); + if (StrUtil.isBlank(changeCode)) { + throw new BaseException("第" + excelRowNumber + "行编码不能为空"); + } + if (!importedLoginnames.add(loginname)) { + throw new BaseException("第" + excelRowNumber + "行工号重复:" + loginname); + } + if (!userByLoginname.containsKey(loginname)) { + throw new BaseException("第" + excelRowNumber + "行未查询到工号:" + loginname); + } + Sys_user importUser = userByLoginname.get(loginname); + if (!Integer.valueOf(STATUS_PENDING).equals(importUser.getPendingConfirmStatus())) { + throw new BaseException("第" + excelRowNumber + "行人员已不属于待确认状态:" + loginname); + } + Sys_dict attributeDict = attributeDictByBusinessCode.get(changeCode); + if (attributeDict == null) { + throw new BaseException("第" + excelRowNumber + "行编码未匹配到启用的人员属性:" + changeCode); + } + if (StrUtil.isBlank(attributeDict.getPersonType())) { + throw new BaseException("第" + excelRowNumber + "行编码未配置人员类型:" + changeCode); + } + if (StrUtil.isBlank(attributeDict.getCode())) { + throw new BaseException("第" + excelRowNumber + "行编码对应的人员属性字典值为空:" + changeCode); + } + } + + int changedCount = 0; + for (PendingConfirmUserImportExcelMode row : validImportRows) { + String loginname = StrUtil.trim(row.getLoginname()); + Sys_dict attributeDict = attributeDictByBusinessCode.get(StrUtil.trim(row.getCode())); + Sys_user user = userByLoginname.get(loginname); + Chain updateFields = Chain.make("personType", attributeDict.getPersonType()) + .add("userAttribute", attributeDict.getCode()) + .add("pendingConfirmStatus", STATUS_CONFIRMED); + int updated = dao().update(Sys_user.class, updateFields, + Cnd.where("id", "=", user.getId()) + .and("pendingConfirmStatus", "=", STATUS_PENDING)); + if (updated != 1) { + throw new BaseException("工号" + loginname + "的待确认状态已变化,请刷新列表后重新导出"); + } + changedCount += updated; + } + return changedCount; + } + + /** + * 构建人员属性业务编码映射,只接纳 USER_ATTRIBUTE 下启用且业务编码唯一的直属字典项。 + * + * @return key 为 userAttributeCode,value 为人员属性字典项 + */ + private Map buildAttributeDictByBusinessCode() { + List attributeDicts = sysDictService.getSubListByCode(USER_ATTRIBUTE_DICT_CODE); + Map result = new HashMap<>(); + for (Sys_dict attributeDict : attributeDicts) { + String businessCode = StrUtil.trim(attributeDict.getUserAttributeCode()); + if (StrUtil.isBlank(businessCode)) { + continue; + } + if (result.putIfAbsent(businessCode, attributeDict) != null) { + throw new BaseException("人员属性字典存在重复编码:" + businessCode); + } + } + return result; + } + + /** + * 批量查询导入文件涉及的人员,避免逐行访问数据库。 + * + * @param importRows Excel 解析后的数据行,仅使用工号字段 + * @return key 为工号,value 为未删除的系统人员 + */ + private Map buildImportUserMap(List importRows) { + LinkedHashSet loginnames = new LinkedHashSet<>(); + for (PendingConfirmUserImportExcelMode row : importRows) { + if (StrUtil.isNotBlank(row.getLoginname())) { + loginnames.add(StrUtil.trim(row.getLoginname())); + } + } + if (loginnames.isEmpty()) { + return new HashMap<>(); + } + // 历史人员的 delFlag 可能为空;与列表查询口径一致,将 0 和 NULL 都视为未删除。 + SqlExpressionGroup activeUserCondition = Cnd.exps("delFlag", "=", false) + .or("delFlag", "is", null); + List users = dao().query(Sys_user.class, + Cnd.where("loginname", "in", new ArrayList<>(loginnames)).and(activeUserCondition)); + Map result = new HashMap<>(); + for (Sys_user user : users) { + result.put(user.getLoginname(), user); + } + return result; + } + /** * 构建分页查询和导出共用的人员 SQL,确保筛选条件、确认状态及排序口径一致。 * diff --git a/src/main/resources/db/person_governance.sql b/src/main/resources/db/person_governance.sql new file mode 100644 index 0000000..3e0da14 --- /dev/null +++ b/src/main/resources/db/person_governance.sql @@ -0,0 +1,212 @@ +-- 人员数据治理模块初始化脚本(MySQL 8.0,UTF-8)。 +-- 人员变更直接复用现有 member_change_record、sys_user_history 及 WF 流程,本脚本不创建替代表。 +SET NAMES utf8mb4; + +SET @add_person_flag = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_user' AND COLUMN_NAME = 'flag') = 0, + 'ALTER TABLE sys_user ADD COLUMN flag int DEFAULT 0 COMMENT ''人员治理同步保护标识:1忽略同步,0或空正常同步''', + 'SELECT 1' +); +PREPARE add_person_flag_stmt FROM @add_person_flag; +EXECUTE add_person_flag_stmt; +DEALLOCATE PREPARE add_person_flag_stmt; + +CREATE TABLE IF NOT EXISTS person_api_config ( + id varchar(32) NOT NULL, + configName varchar(100), sourceType varchar(20), requestUrl varchar(500), requestMethod varchar(10), + timeoutSeconds int, sourceTable varchar(100), targetTable varchar(100), syncMode varchar(20), + identityField varchar(100), bodyType varchar(30), bodyContent text, + tokenRequestUrl varchar(500), tokenRequestMethod varchar(10), tokenTimeoutSeconds int, + tokenBodyType varchar(30), tokenBodyContent text, tokenValuePath varchar(200), tokenExpiresPath varchar(200), + tokenExpiryUnit varchar(20), tokenQueryResult text, dataQueryResult text, + responseCodePath varchar(200), responseSuccessValue varchar(100), responseMessagePath varchar(200), + responseDataPath varchar(200), responseTotalPath varchar(200), enabled tinyint(1), taskCron varchar(100), + matchMemberRules tinyint(1), lastSyncTime datetime, taskId varchar(32), remark varchar(500), + createdBy varchar(32), createdAt bigint, updatedBy varchar(32), updatedAt bigint, + delFlag tinyint(1), opBy varchar(128), opAt varchar(128), + PRIMARY KEY (id), UNIQUE KEY UK_PERSON_API_CONFIG_NAME (configName) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS person_api_field_mapping ( + id varchar(32) NOT NULL, configId varchar(32), sourceField varchar(100), sourceComment varchar(200), + targetField varchar(100), targetComment varchar(200), required tinyint(1), identityFlag tinyint(1), sort int, + PRIMARY KEY (id), UNIQUE KEY UK_PERSON_API_MAPPING_TARGET (configId, targetField) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS person_api_request_param ( + id varchar(32) NOT NULL, configId varchar(32), requestScope varchar(20), paramLocation varchar(20), + paramName varchar(100), paramType varchar(30), paramValue text, required tinyint(1), + description varchar(300), sensitiveFlag int, sort int, PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS person_api_test_case ( + id varchar(32) NOT NULL, configId varchar(32), requestScope varchar(20), caseName varchar(100), + requestSnapshot text, responseStatus int, responseTime bigint, responseBody text, success tinyint(1), + executedAt datetime, createdBy varchar(32), createdAt bigint, updatedBy varchar(32), updatedAt bigint, + delFlag tinyint(1), opBy varchar(128), opAt varchar(128), PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS person_sync_batch ( + id varchar(32) NOT NULL, configId varchar(32), batchNo varchar(50), triggerType varchar(20), + startedAt datetime, finishedAt datetime, status varchar(20), sourceCount int, successCount int, + newCount int, changeCount int, exceptionCount int, errorMessage text, rawFileReid varchar(32), + createdBy varchar(32), createdAt bigint, updatedBy varchar(32), updatedAt bigint, + delFlag tinyint(1), opBy varchar(128), opAt varchar(128), + PRIMARY KEY (id), UNIQUE KEY UK_PERSON_SYNC_BATCH_NO (batchNo) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS person_source_relation ( + id varchar(32) NOT NULL, configId varchar(32), sourcePersonKey varchar(150), userId varchar(32), + personNo varchar(120), lastSeenBatchId varchar(32), lastSeenTime datetime, presentFlag tinyint(1), + sourceDataHash varchar(64), createdBy varchar(32), createdAt bigint, updatedBy varchar(32), updatedAt bigint, + delFlag tinyint(1), opBy varchar(128), opAt varchar(128), + PRIMARY KEY (id), UNIQUE KEY UK_PERSON_SOURCE_KEY (configId, sourcePersonKey) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS person_member_match_config ( + id varchar(32) NOT NULL, configName varchar(100), apiConfigId varchar(32), copiedFromId varchar(32), + enabled tinyint(1), groupCount int, lastMatchCount int, lastExecuteTime datetime, + createdBy varchar(32), createdAt bigint, updatedBy varchar(32), updatedAt bigint, + delFlag tinyint(1), opBy varchar(128), opAt varchar(128), + PRIMARY KEY (id), UNIQUE KEY UK_PERSON_MEMBER_API_CONFIG (apiConfigId) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS person_member_match_group ( + id varchar(32) NOT NULL, matchConfigId varchar(32), parentGroupId varchar(32), + ruleType varchar(30) DEFAULT 'UNION_MEMBER', groupNo int, joinLogic varchar(3), sort int, + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS person_member_match_condition ( + id varchar(32) NOT NULL, groupId varchar(32), dictCode varchar(50), dictItemId varchar(32), + dictItemCode varchar(50), dictItemName varchar(100), mappedField varchar(100), operator varchar(30), + matchValue varchar(500), joinLogic varchar(3), sort int, PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS person_exception ( + id varchar(32) NOT NULL, configId varchar(32), relationId varchar(32), discoverBatchId varchar(32), + userId varchar(32), personNo varchar(120), personName varchar(100), unitId varchar(32), unitName varchar(150), + exceptionType varchar(30), exceptionReason varchar(500), lastSyncTime datetime, discoverTime datetime, + status varchar(30), handleResult varchar(30), handleOpinion varchar(500), handlerId varchar(32), + handlerName varchar(100), handleTime datetime, rawFileReid varchar(32), + createdBy varchar(32), createdAt bigint, updatedBy varchar(32), updatedAt bigint, + delFlag tinyint(1), opBy varchar(128), opAt varchar(128), PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- PC 端独立应用菜单。完整页面跳转由布局按 /platform/personGovernance/ 路径处理。 +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, + permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, + platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService) +SELECT '76725022b0cc11f196a000ff8d9db2e0', '', LPAD(IFNULL(MAX(CAST(path AS UNSIGNED)), 0) + 1, 4, '0'), + '人员数据治理', 'PersonGovernance', 'menu', '', '', 'ti-id-badge', 1, 0, + 'data.person.governance', '人员数据接入、会员匹配、人员变更及异常处理', 694, 1, + '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, + 'PC', NULL, NULL, 'r', 0, 0 +FROM sys_menu WHERE (parentId = '' OR parentId IS NULL) AND CHAR_LENGTH(path) = 4 +HAVING NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance') m); + +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, + permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, + platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService) +SELECT '7673d831b0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0001'), 'API配置', 'ApiConfig', 'menu', + '/platform/personGovernance/apiConfig', '', '', 1, 0, 'data.person.governance.api', NULL, 1, 0, + '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'a', 0, 0 +FROM sys_menu p WHERE p.permission = 'data.person.governance' +AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.api') m); + +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, + permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, + platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService) +SELECT '76749371b0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0002'), '会员匹配', 'MemberMatch', 'menu', + '/platform/personGovernance/memberMatch', '', '', 1, 0, 'data.person.governance.member', NULL, 2, 0, + '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'h', 0, 0 +FROM sys_menu p WHERE p.permission = 'data.person.governance' +AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.member') m); + +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, + permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, + platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService) +SELECT '7674b630b0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0003'), '人员变更', 'PersonChange', 'menu', + '/platform/personGovernance/personChange', '', '', 1, 0, 'data.person.governance.change', NULL, 3, 0, + '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'r', 0, 0 +FROM sys_menu p WHERE p.permission = 'data.person.governance' +AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.change') m); + +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, + permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, + platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService) +SELECT '7674d34eb0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0004'), '人员异常', 'PersonException', 'menu', + '/platform/personGovernance/personException', '', '', 1, 0, 'data.person.governance.exception', NULL, 4, 0, + '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'r', 0, 0 +FROM sys_menu p WHERE p.permission = 'data.person.governance' +AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.exception') m); + +-- 页面按钮权限;type=data 不在左侧菜单展示。 +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, showit, disabled, permission, location, + hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, + initialPinyinName, isRecommendApp, isRecommendService) +SELECT '7674fb04b0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0001'), '保存API配置', 'Edit', 'data', + 0, 0, 'data.person.governance.api.edit', 1, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', + UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, 'b', 0, 0 FROM sys_menu p +WHERE p.permission = 'data.person.governance.api' AND NOT EXISTS + (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.api.edit') m); + +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, showit, disabled, permission, location, + hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, + initialPinyinName, isRecommendApp, isRecommendService) +SELECT '76750400b0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0002'), '删除API配置', 'Delete', 'data', + 0, 0, 'data.person.governance.api.delete', 2, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', + UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, 's', 0, 0 FROM sys_menu p +WHERE p.permission = 'data.person.governance.api' AND NOT EXISTS + (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.api.delete') m); + +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, showit, disabled, permission, location, + hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, + initialPinyinName, isRecommendApp, isRecommendService) +SELECT '76750e42b0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0003'), '测试及同步', 'Test', 'data', + 0, 0, 'data.person.governance.api.test', 3, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', + UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, 'c', 0, 0 FROM sys_menu p +WHERE p.permission = 'data.person.governance.api' AND NOT EXISTS + (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.api.test') m); + +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, showit, disabled, permission, location, + hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, + initialPinyinName, isRecommendApp, isRecommendService) +SELECT '767518e8b0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0001'), '保存会员匹配', 'Edit', 'data', + 0, 0, 'data.person.governance.member.edit', 1, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', + UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, 'b', 0, 0 FROM sys_menu p +WHERE p.permission = 'data.person.governance.member' AND NOT EXISTS + (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.member.edit') m); + +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, showit, disabled, permission, location, + hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, + initialPinyinName, isRecommendApp, isRecommendService) +SELECT '767523deb0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0002'), '删除会员匹配', 'Delete', 'data', + 0, 0, 'data.person.governance.member.delete', 2, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', + UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, 's', 0, 0 FROM sys_menu p +WHERE p.permission = 'data.person.governance.member' AND NOT EXISTS + (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.member.delete') m); + +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, showit, disabled, permission, location, + hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, + initialPinyinName, isRecommendApp, isRecommendService) +SELECT '76752eacb0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0001'), '处理人员异常', 'Handle', 'data', + 0, 0, 'data.person.governance.exception.handle', 1, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', + UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, 'c', 0, 0 FROM sys_menu p +WHERE p.permission = 'data.person.governance.exception' AND NOT EXISTS + (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.exception.handle') m); + +-- 给系统管理员默认授权,其他角色可在角色管理中按需分配。 +INSERT INTO sys_role_menu (roleId, menuId) +SELECT r.id, m.id +FROM sys_role r +JOIN sys_menu m ON m.permission IN ( + 'data.person.governance', 'data.person.governance.api', 'data.person.governance.member', + 'data.person.governance.api.edit', 'data.person.governance.api.delete', 'data.person.governance.api.test', + 'data.person.governance.member.edit', 'data.person.governance.member.delete', + 'data.person.governance.change', 'data.person.governance.exception', + 'data.person.governance.exception.handle') +LEFT JOIN sys_role_menu rm ON rm.roleId = r.id AND rm.menuId = m.id +WHERE r.code = 'SYSADMIN' + AND rm.roleId IS NULL; diff --git a/src/main/resources/db/sys_club_add_thumbnail.sql b/src/main/resources/db/sys_club_add_thumbnail.sql new file mode 100644 index 0000000..a62f9ed --- /dev/null +++ b/src/main/resources/db/sys_club_add_thumbnail.sql @@ -0,0 +1,19 @@ +-- 社团缩略图保存系统文件下载地址;保留可空以兼容未上传图片的历史社团。 +-- 字段存在时跳过,避免重复执行迁移时报错。 +SET @club_thumbnail_column_count = ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sys_club' + AND COLUMN_NAME = 'thumbnail' +); + +SET @club_thumbnail_ddl = IF( + @club_thumbnail_column_count = 0, + 'ALTER TABLE sys_club ADD COLUMN thumbnail VARCHAR(1000) NULL COMMENT ''社团缩略图'' AFTER clubName', + 'SELECT 1' +); + +PREPARE club_thumbnail_stmt FROM @club_thumbnail_ddl; +EXECUTE club_thumbnail_stmt; +DEALLOCATE PREPARE club_thumbnail_stmt; diff --git a/src/main/resources/db/union_reimburse_add_club_activity_dict.sql b/src/main/resources/db/union_reimburse_add_club_activity_dict.sql new file mode 100644 index 0000000..f488944 --- /dev/null +++ b/src/main/resources/db/union_reimburse_add_club_activity_dict.sql @@ -0,0 +1,38 @@ +-- 社团活动属于报销项目的正式字典配置,两端沿用现有字典接口读取。 +-- 仅补充缺失记录;已有记录(包括已禁用记录)不覆盖,不在查询接口中添加兜底项。 +INSERT INTO sys_dict ( + id, parentId, path, name, remark, code, disabled, location, hasChildren, + createdBy, createdAt, updatedBy, updatedAt, delFlag +) +SELECT + REPLACE(UUID(), '-', ''), + parent.id, + CONCAT(parent.path, LPAD(IFNULL(( + SELECT MAX(CAST(RIGHT(child.path, 4) AS UNSIGNED)) + FROM sys_dict child + WHERE child.path LIKE CONCAT(parent.path, '____') + ), 0) + 1, 4, '0')), + '社团活动', + '', + 'UNION_REIMBURSE_PROJECT_5', + 0, + IFNULL((SELECT MAX(child.location) FROM sys_dict child WHERE child.parentId = parent.id), 0) + 1, + 0, + '', UNIX_TIMESTAMP(NOW()) * 1000, + '', UNIX_TIMESTAMP(NOW()) * 1000, + 0 +FROM sys_dict parent +WHERE parent.code = 'UNION_REIMBURSE_PROJECT' + AND NOT EXISTS ( + SELECT 1 + FROM (SELECT code FROM sys_dict WHERE code = 'UNION_REIMBURSE_PROJECT_5') existing_item + ); + +UPDATE sys_dict parent +SET parent.hasChildren = 1 +WHERE parent.code = 'UNION_REIMBURSE_PROJECT' + AND EXISTS ( + SELECT 1 + FROM (SELECT parentId FROM sys_dict WHERE code = 'UNION_REIMBURSE_PROJECT_5') club_activity + WHERE club_activity.parentId = parent.id + ); diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index 87e2ef2..53662f2 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -54,86 +54,7 @@ - - - - - diff --git a/src/main/resources/static/assets/mobile/img/club/club-hero-v5.png b/src/main/resources/static/assets/mobile/img/club/club-hero-v5.png new file mode 100644 index 0000000..4a4cd69 Binary files /dev/null and b/src/main/resources/static/assets/mobile/img/club/club-hero-v5.png differ diff --git a/src/main/resources/static/assets/platform/css/personGovernance.css b/src/main/resources/static/assets/platform/css/personGovernance.css new file mode 100644 index 0000000..c314d22 --- /dev/null +++ b/src/main/resources/static/assets/platform/css/personGovernance.css @@ -0,0 +1,768 @@ +/* 人员数据治理列表统一占满内容区,数据超出时只滚动表格主体。 */ +.person-governance-page-transition { + visibility: hidden; + opacity: 0; +} + +.person-governance-page-transition.is-ready { + visibility: visible; + opacity: 1; +} + +.person-governance-page-transition.is-leaving { + pointer-events: none; + opacity: 0; +} + +.person-governance-list-page { + box-sizing: border-box; + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow: hidden; + padding: 10px; +} + +.person-governance-search-card { + flex: none; +} + +.person-governance-list-card { + flex: 1; + min-height: 0; + margin-top: 20px; +} + +/* 会员匹配列表的查询区与列表区间距缩小为原来的一半。 */ +.person-governance-compact-gap .person-governance-list-card { + margin-top: 10px; +} + +.person-governance-list-card > .el-card__body { + box-sizing: border-box; + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.person-governance-table-wrap { + flex: 1; + min-height: 0; + overflow: hidden; +} + +.person-governance-table-wrap > .el-table { + height: 100%; +} + +.person-governance-list-footer, +.person-governance-list-card .el-pagination-container { + flex: none; +} + +/* 人员变更列表位于 Guava 默认插槽内,列表视图仍按查询区、表格区、分页区分配固定高度。 */ +.person-governance-change-page > .guava-main-content, +.person-governance-change-page > .guava-main-content > .transition-item { + height: 100%; + min-height: 0; + overflow: hidden; +} + +.person-governance-change-page > .guava-main-content > .transition-item { + display: flex; + flex-direction: column; +} + +.person-governance-change-list-card { + flex: 1; + min-height: 0; +} + +.person-governance-change-list-card > .el-card__body { + box-sizing: border-box; + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.person-governance-change-table-wrap { + flex: 1; + min-height: 0; + overflow: hidden; +} + +.person-governance-change-table-wrap > .el-table { + height: 100%; +} + +.person-governance-change-list-card .el-pagination-container { + flex: none; +} + +/* 配置页顶部字段根据内容区宽度自动分列,空间不足时自然换行。 */ +.person-governance-form-page { + box-sizing: border-box; + display: flex; + flex-direction: column; + height: 100%; + max-width: 100%; + min-height: 0; + overflow-x: hidden; + overflow-y: hidden; + padding: 10px; +} + +/* PageHeader 提供统一返回入口,并与基本配置保持紧凑间距。 */ +.person-governance-page-header { + flex: none; + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + padding: 16px 20px; + background-color: #fff; +} + +/* 表单页只保留返回入口,移除 PageHeader 空内容对应的分隔线和占位间距。 */ +.person-governance-page-header .el-page-header__left { + margin-right: 0; +} + +.person-governance-page-header .el-page-header__left::after, +.person-governance-page-header .el-page-header__content { + display: none; +} + +.person-governance-form-basic-card { + flex: none; +} + +/* 第三方接口按Token与数据分区管理,一级页签保持紧凑并独立于各自的子页签。 */ +.person-governance-endpoint-tabs-card { + flex: none; + margin-bottom: 10px; +} + +.person-governance-endpoint-tabs-card > .el-card__body { + padding: 0 20px; +} + +.person-governance-endpoint-tabs-card .el-tabs__header { + margin-bottom: 0; +} + +.person-governance-endpoint-tabs-card .el-tabs__content { + display: none; +} + +/* 顶部基本配置横向铺满卡片,并压缩默认表单项产生的底部空白。 */ +.person-governance-form-basic-card > .el-card__body { + padding: 20px 10px; +} + +.person-governance-form-basic-card .el-form-item { + margin-bottom: 0; +} + +/* 两类会员规则使用独立页签,页签本身不参与内容区滚动。 */ +.person-governance-member-type-tabs { + flex: none; + margin-top: 10px; + padding: 0 20px; + background: #fff; + border: 1px solid #ebeef5; + border-radius: 4px; +} + +.person-governance-member-type-tabs .el-tabs__header { + margin: 0; +} + +.person-governance-member-type-tabs .el-tabs__content { + display: none; +} + +/* 主要配置板块占满剩余高度,内容过多时仅在板块内部滚动。 */ +.person-governance-form-content-card { + flex: 1; + min-height: 0; + margin-top: 10px; +} + +.person-governance-form-content-card > .el-card__body { + box-sizing: border-box; + height: 100%; + overflow: auto; +} + +.person-governance-form-content-row { + display: flex; + flex: 1; + min-height: 0; + margin-top: 10px; +} + +.person-governance-form-content-row > .el-col, +.person-governance-form-content-row > .el-col > .el-card { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; +} + +.person-governance-form-content-row > .el-col { + float: none; +} + +.person-governance-form-content-row > .el-col > .el-card { + flex: 1; +} + +.person-governance-form-content-row > .el-col > .el-card > .el-card__body { + box-sizing: border-box; + height: 100%; + overflow: auto; +} + +/* 匹配规则标题固定,条件组占用剩余高度并独立滚动。 */ +.person-governance-form-content-row > .el-col > .person-governance-member-rules > .el-card__body { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.person-governance-member-rules-header { + flex: none; +} + +.person-governance-member-rules-content { + flex: 1; + min-height: 0; + overflow: auto; + padding-right: 8px; +} + +/* 会员预览固定标题和操作区,表格独占剩余高度,长列表只滚动表格内部。 */ +.person-governance-form-content-row > .el-col > .person-governance-member-preview > .el-card__body { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.person-governance-member-preview h3, +.person-governance-member-preview p, +.person-governance-member-preview > .el-card__body > .el-button { + flex: none; +} + +.person-governance-member-preview > .el-card__body > .el-button { + align-self: flex-start; +} + +.person-governance-member-preview-table { + flex: 1; + min-height: 0; + margin-top: 20px; +} + +.person-governance-member-preview-pagination { + flex: none; + padding-top: 10px; + text-align: right; +} + +.person-governance-member-preview-pagination .el-pagination { + padding: 0; + white-space: normal; +} + +.person-governance-member-binding-hint { + margin-bottom: 16px; +} + +.person-governance-member-condition-relation > td { + padding: 6px 0; + text-align: center; + background-color: #fafafa; +} + +/* 每层条件组对应一层括号语义,通过边框和缩进直观展示父子层级。 */ +.person-governance-member-group { + margin-top: 20px; + padding: 14px; + border: 1px solid #dcdfe6; + border-radius: 4px; + background: #fff; +} + +.person-governance-member-group.is-nested { + margin: 0 0 0 20px; + border-color: #b3d8ff; + background: #f8fbff; +} + +.person-governance-member-group-header { + min-height: 28px; + margin-bottom: 10px; +} + +.person-governance-member-group-actions { + margin-top: 10px; +} + +.person-governance-member-group-relation { + margin: 28px 0; +} + +.person-governance-member-child-relation { + margin-left: 20px; +} + +.person-governance-form-footer { + display: flex; + align-items: center; + justify-content: flex-end; + flex: none; + margin-top: 10px; +} + +.person-governance-form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + column-gap: 20px; + align-items: start; + width: 100%; +} + +.person-governance-form-grid .el-form-item { + margin-right: 0; +} + +.person-governance-form-grid .el-select, +.person-governance-form-grid .el-input-number { + width: 100%; +} + +/* API配置顶部字段按业务宽度靠左排列,同步方式具体值单独放在页首右侧。 */ +.person-governance-sync-type { + color: #303133; + line-height: 24px; +} + +.person-governance-api-basic-grid { + /* 按业务宽度紧凑靠左,剩余空间留在右侧;窄屏只允许整组字段换行。 */ + display: flex; + flex-wrap: wrap; + gap: 18px 16px; + justify-content: flex-start; + align-items: center; +} + +.person-governance-api-basic-grid > .el-form-item { + display: flex; + align-items: center; + flex: 0 1 280px; + min-width: 0; + max-width: 100%; +} + +.person-governance-api-basic-grid .el-form-item__label { + flex: none; + float: none; + white-space: nowrap; +} + +.person-governance-api-basic-grid .el-form-item__content { + flex: 1; + min-width: 0; +} + +.person-governance-api-basic-grid > .person-governance-api-request-address { + flex-basis: 620px; +} + +.person-governance-api-basic-grid > .person-governance-api-timeout { + flex-basis: 270px; +} + +.person-governance-api-basic-grid > .person-governance-query-action { + flex: none; +} + +.person-governance-request-address-control, +.person-governance-timeout-control { + display: flex; + align-items: center; +} + +.person-governance-request-address-control { + width: 100%; +} + +.person-governance-request-address-control .el-select { + flex: none; + width: 100px; +} + +.person-governance-request-address-control > .el-input { + flex: 1; + min-width: 0; + margin-left: 8px; +} + +.person-governance-timeout-control .el-input-number { + flex: none; + width: 200px; + min-width: 200px; +} + +.person-governance-timeout-unit { + flex: none; + margin-left: 8px; + color: #606266; +} + +/* API配置页签占满内容卡片,返回结果文本域随剩余高度伸展。 */ +.person-governance-api-content-card > .el-card__body { + overflow: hidden; +} + +.person-governance-api-content-card > .el-card__body > .el-tabs { + display: flex; + flex-direction: column; + height: 100%; +} + +.person-governance-api-content-card > .el-card__body > .el-tabs > .el-tabs__header { + flex: none; +} + +.person-governance-api-content-card > .el-card__body > .el-tabs > .el-tabs__content { + flex: 1; + min-height: 0; +} + +.person-governance-api-content-card > .el-card__body > .el-tabs > .el-tabs__content > .el-tab-pane { + box-sizing: border-box; + height: 100%; + overflow: auto; +} + +/* 系统表配置不显示单一的字段映射页签标题,映射内容直接占满卡片。 */ +.person-governance-system-table-card > .el-card__body > .el-tabs > .el-tabs__header { + display: none; +} + +.person-governance-system-table-card > .el-card__body > .el-tabs > .el-tabs__content, +.person-governance-system-table-card .person-governance-mapping-pane { + height: 100%; +} + +.person-governance-system-table-card .person-governance-mapping-pane { + overflow: hidden !important; +} + +.person-governance-result-pane { + display: flex; + flex-direction: column; + overflow: hidden !important; +} + +.person-governance-result-textarea { + flex: 1; + min-height: 0; + height: 100%; +} + +/* 返回结果下方的映射配置固定保留,长响应只在文本域内滚动。 */ +.person-governance-response-fields { + display: flex; + flex-wrap: wrap; + flex: none; + gap: 10px 20px; + padding-top: 12px; +} + +.person-governance-response-fields > div { + display: flex; + align-items: center; + flex: 1 1 280px; + min-width: 0; + gap: 10px; +} + +.person-governance-response-fields label, +.person-governance-task-cron label { + white-space: nowrap; + flex: none; +} + +.person-governance-response-fields .el-input, +.person-governance-response-fields .el-select { + flex: 1; + min-width: 0; +} + +.person-governance-task-cron { + flex: 0 1 420px; + max-width: 420px; +} + +.person-governance-cron-hint, +.person-governance-json-hint { + flex: none; + color: #909399; + font-size: 12px; + line-height: 20px; +} + +.person-governance-parameter-pane .person-governance-body-json { + flex: 1; + min-height: 80px; + height: auto; +} + +.person-governance-result-textarea .el-textarea__inner, +.person-governance-body-json .el-textarea__inner { + height: 100%; + resize: none; +} + +.person-governance-result-actions { + flex: none; + margin-top: 10px; + text-align: right; +} + +.person-governance-body-json { + height: calc(100% - 52px); +} + +/* 字段映射与请求参数表格在可见页签内直接按完整宽高渲染。 */ +.person-governance-mapping-table { + width: 100%; +} + +.person-governance-mapping-pane { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden !important; +} + +.person-governance-mapping-table-wrap { + flex: 1; + min-height: 0; + margin-top: 20px; +} + +.person-governance-mapping-table-wrap > .el-table { + height: 100%; +} + +.person-governance-mapping-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px 24px; +} + +.person-governance-mapping-toolbar-item { + display: flex; + align-items: center; + min-width: 0; +} + +/* 两种数据来源共用右侧清空入口,窄屏换行后仍靠右,不挤压周期控件。 */ +.person-governance-mapping-toolbar > .person-governance-clear-mappings { + flex: none; + margin-left: auto; +} + +.person-governance-mapping-toolbar-item > label { + flex: none; + margin-right: 10px; + color: #303133; + white-space: nowrap; +} + +.person-governance-mapping-table-select { + flex: 0 1 360px; + max-width: 360px; +} + +.person-governance-mapping-table-select .el-select { + flex: 1; + min-width: 180px; +} + +.person-governance-mapping-switch { + flex: none; +} + +/* 系统表配置项按配置名称、来源表、目标表、同步开关的顺序自适应排列。 */ +.person-governance-system-table-toolbar { + flex: none; +} + +.person-governance-system-table-toolbar .el-form-item { + /* 标签与控件作为一组排列,仅允许表单项在工具栏中整体换行。 */ + display: flex; + align-items: center; + flex: 1 1 280px; + min-width: 0; + margin: 0; +} + +.person-governance-system-table-toolbar .person-governance-system-target-table { + flex: 0 1 300px; +} + +.person-governance-system-table-toolbar .person-governance-system-task-cron { + flex: 0 1 430px; +} + +.person-governance-system-table-toolbar .person-governance-system-member-match { + flex: none; + column-gap: 12px; + margin-left: 16px; +} + +/* 长标签按文字实际宽度展示,通过固定列间距避免文字与开关重叠或距离过大。 */ +.person-governance-system-table-toolbar .person-governance-system-member-match .el-form-item__label { + width: auto !important; + padding-right: 0; +} + +.person-governance-system-table-toolbar .person-governance-system-member-match .el-form-item__content { + margin-left: 0 !important; +} + +.person-governance-system-table-toolbar .el-form-item__label { + flex: none; + float: none; + white-space: nowrap; +} + +.person-governance-system-table-toolbar .el-form-item__content { + flex: 1; + min-width: 0; + width: auto; +} + +.person-governance-system-table-toolbar .el-input, +.person-governance-system-table-toolbar .el-select { + width: 100%; +} + +.person-governance-parameter-pane { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden !important; +} + +.person-governance-parameter-pane > .el-tabs, +.person-governance-parameter-pane > .el-radio-group { + flex: none; +} + +.person-governance-parameter-table-wrap { + flex: 1; + min-height: 0; + margin-top: 10px; +} + +.person-governance-parameter-table-wrap > .el-table { + width: 100%; + height: 100%; +} + +/* 参数表头按文字与加号按钮的实际高度紧凑展示。 */ +.person-governance-parameter-table .el-table__header-wrapper th, +.person-governance-mapping-table .el-table__header-wrapper th { + padding: 6px 0; +} + +.person-governance-parameter-table .el-table__header-wrapper th .cell, +.person-governance-mapping-table .el-table__header-wrapper th .cell { + line-height: 20px; + white-space: nowrap; +} + +.person-governance-add-column .el-button { + padding: 0; + font-size: 16px; + line-height: 20px; +} + +/* 弹窗和抽屉沿用 Element UI 过渡,并为本模块调整为更柔和的缓动。 */ +.dialog-fade-enter-active .person-governance-dialog, +.dialog-fade-leave-active .person-governance-dialog { + transition: + opacity 260ms ease, + transform 260ms ease; +} + +.dialog-fade-enter .person-governance-dialog, +.dialog-fade-leave-to .person-governance-dialog { + opacity: 0; + transform: translateY(-10px) scale(0.99); +} + +/* 弹窗挂载body避开页面过渡层,短屏内部滚动,窄屏保留周期编辑器的完整操作区域。 */ +.person-governance-cron-dialog { + max-width: calc(100vw - 32px); +} + +.person-governance-cron-dialog .el-dialog__body { + max-height: 78vh; + overflow: auto; + padding: 10px 20px 20px; +} + +.person-governance-cron-dialog .popup-main { + min-width: 640px; +} + +.person-governance-drawer { + transition: box-shadow 260ms ease; +} + +.person-governance-drawer.rtl, +.el-drawer__open .person-governance-drawer.rtl { + animation-duration: 340ms; + animation-timing-function: cubic-bezier(0.22, 0.61, 0.36, 1); +} + +@media (max-width: 767px) { + .person-governance-list-page, + .person-governance-form-page { + padding: 8px; + } + + .person-governance-form-grid { + grid-template-columns: 1fr; + } + + .person-governance-page-header { + align-items: flex-start; + } + + .person-governance-timeout-control .el-input-number { + flex: 1; + width: 100%; + min-width: 0; + } + + .person-governance-mapping-table-select { + flex-basis: 100%; + max-width: none; + } +} + diff --git a/src/main/resources/static/assets/platform/js/mixin/initTableMixins.js b/src/main/resources/static/assets/platform/js/mixin/initTableMixins.js index 9b475c3..4e62470 100644 --- a/src/main/resources/static/assets/platform/js/mixin/initTableMixins.js +++ b/src/main/resources/static/assets/platform/js/mixin/initTableMixins.js @@ -70,7 +70,8 @@ const initTableMixins = { pageData(data = null) { const address = this.pageDataUrl ? this.pageDataUrl : loc() + "/pageData" this.tableLoading = true - this.$axios.post(address, data ? data : this.pageForm).then((res) => { + // 返回请求 Promise,供页面在列表数据加载结束后再显示主体内容。 + return this.$axios.post(address, data ? data : this.pageForm).then((res) => { if (res.code === 0) { this.tableData = res.data.list this.pageForm.totalCount = res.data.totalCount diff --git a/src/main/resources/static/captcha/slider/0.jpg b/src/main/resources/static/captcha/slider/0.jpg new file mode 100644 index 0000000..78b3ef6 Binary files /dev/null and b/src/main/resources/static/captcha/slider/0.jpg differ diff --git a/src/main/resources/static/captcha/slider/1.jpg b/src/main/resources/static/captcha/slider/1.jpg new file mode 100644 index 0000000..5310567 Binary files /dev/null and b/src/main/resources/static/captcha/slider/1.jpg differ diff --git a/src/main/resources/static/captcha/slider/10.jpg b/src/main/resources/static/captcha/slider/10.jpg new file mode 100644 index 0000000..83858e0 Binary files /dev/null and b/src/main/resources/static/captcha/slider/10.jpg differ diff --git a/src/main/resources/static/captcha/slider/11.jpg b/src/main/resources/static/captcha/slider/11.jpg new file mode 100644 index 0000000..2fe415f Binary files /dev/null and b/src/main/resources/static/captcha/slider/11.jpg differ diff --git a/src/main/resources/static/captcha/slider/12.jpg b/src/main/resources/static/captcha/slider/12.jpg new file mode 100644 index 0000000..a706b3a Binary files /dev/null and b/src/main/resources/static/captcha/slider/12.jpg differ diff --git a/src/main/resources/static/captcha/slider/13.jpg b/src/main/resources/static/captcha/slider/13.jpg new file mode 100644 index 0000000..56340b7 Binary files /dev/null and b/src/main/resources/static/captcha/slider/13.jpg differ diff --git a/src/main/resources/static/captcha/slider/14.jpg b/src/main/resources/static/captcha/slider/14.jpg new file mode 100644 index 0000000..1bae6f6 Binary files /dev/null and b/src/main/resources/static/captcha/slider/14.jpg differ diff --git a/src/main/resources/static/captcha/slider/15.jpg b/src/main/resources/static/captcha/slider/15.jpg new file mode 100644 index 0000000..4cfb338 Binary files /dev/null and b/src/main/resources/static/captcha/slider/15.jpg differ diff --git a/src/main/resources/static/captcha/slider/16.jpg b/src/main/resources/static/captcha/slider/16.jpg new file mode 100644 index 0000000..7f1417e Binary files /dev/null and b/src/main/resources/static/captcha/slider/16.jpg differ diff --git a/src/main/resources/static/captcha/slider/17.jpg b/src/main/resources/static/captcha/slider/17.jpg new file mode 100644 index 0000000..3b35c22 Binary files /dev/null and b/src/main/resources/static/captcha/slider/17.jpg differ diff --git a/src/main/resources/static/captcha/slider/18.jpg b/src/main/resources/static/captcha/slider/18.jpg new file mode 100644 index 0000000..51f2912 Binary files /dev/null and b/src/main/resources/static/captcha/slider/18.jpg differ diff --git a/src/main/resources/static/captcha/slider/19.jpg b/src/main/resources/static/captcha/slider/19.jpg new file mode 100644 index 0000000..b631163 Binary files /dev/null and b/src/main/resources/static/captcha/slider/19.jpg differ diff --git a/src/main/resources/static/captcha/slider/2.jpg b/src/main/resources/static/captcha/slider/2.jpg new file mode 100644 index 0000000..d572cff Binary files /dev/null and b/src/main/resources/static/captcha/slider/2.jpg differ diff --git a/src/main/resources/static/captcha/slider/3.jpg b/src/main/resources/static/captcha/slider/3.jpg new file mode 100644 index 0000000..6975d20 Binary files /dev/null and b/src/main/resources/static/captcha/slider/3.jpg differ diff --git a/src/main/resources/static/captcha/slider/4.jpg b/src/main/resources/static/captcha/slider/4.jpg new file mode 100644 index 0000000..8dafb76 Binary files /dev/null and b/src/main/resources/static/captcha/slider/4.jpg differ diff --git a/src/main/resources/static/captcha/slider/5.jpg b/src/main/resources/static/captcha/slider/5.jpg new file mode 100644 index 0000000..bc0ee23 Binary files /dev/null and b/src/main/resources/static/captcha/slider/5.jpg differ diff --git a/src/main/resources/static/captcha/slider/6.jpg b/src/main/resources/static/captcha/slider/6.jpg new file mode 100644 index 0000000..d5a959c Binary files /dev/null and b/src/main/resources/static/captcha/slider/6.jpg differ diff --git a/src/main/resources/static/captcha/slider/7.jpg b/src/main/resources/static/captcha/slider/7.jpg new file mode 100644 index 0000000..81d4561 Binary files /dev/null and b/src/main/resources/static/captcha/slider/7.jpg differ diff --git a/src/main/resources/static/captcha/slider/8.jpg b/src/main/resources/static/captcha/slider/8.jpg new file mode 100644 index 0000000..3ac5bbc Binary files /dev/null and b/src/main/resources/static/captcha/slider/8.jpg differ diff --git a/src/main/resources/static/captcha/slider/9.jpg b/src/main/resources/static/captcha/slider/9.jpg new file mode 100644 index 0000000..55ed2e4 Binary files /dev/null and b/src/main/resources/static/captcha/slider/9.jpg differ diff --git a/src/main/resources/static/components/module/activity/ActivityScheduleCalendar.vue b/src/main/resources/static/components/module/activity/ActivityScheduleCalendar.vue index 371d58e..3589f44 100644 --- a/src/main/resources/static/components/module/activity/ActivityScheduleCalendar.vue +++ b/src/main/resources/static/components/module/activity/ActivityScheduleCalendar.vue @@ -1,12 +1,14 @@