feat: 同步地大功能至2026年10月20日

This commit is contained in:
2026-10-21 09:26:25 +08:00
parent 6b5d27c68d
commit 88d624172c
198 changed files with 19375 additions and 2128 deletions
@@ -48,6 +48,13 @@ public class RedisConstant {
*/ */
public static final String UCENTER_SMSCODE = PLATFORM_REDIS_PREFIX + "ucenter:smscode:"; 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 RSA_KEY_PREFIX = PLATFORM_REDIS_PREFIX + "rsa:private:";
/**
* 人员治理第三方数据查询结果缓存前缀。
*/
public static final String REDIS_KEY_PERSON_API_DATA_QUERY_RESULT =
PLATFORM_REDIS_PREFIX + "personGovernance:dataQuery:";
} }
@@ -27,6 +27,8 @@ RoleConstant {
SCHOOL_UNION_PROPOSAL_ADMIN("校工会提案管理员"), SCHOOL_UNION_PROPOSAL_ADMIN("校工会提案管理员"),
SCHOOL_UNION_CLUB_ADMIN("校工会社团管理员"), SCHOOL_UNION_CLUB_ADMIN("校工会社团管理员"),
SCHOOL_UNION_ACTIVITY_ADMIN("校工会活动管理员"), SCHOOL_UNION_ACTIVITY_ADMIN("校工会活动管理员"),
/** 品牌活动统计、人员调整管理员,数据范围按 trainSignUp 子活动负责人关系控制。 */
SCHOOL_UNION_TRAIN_SIGN_UP_ADMIN("校工会品牌活动管理员"),
SCHOOL_UNION_ARTICLE_CLUB_ADMIN("校工会社团新闻审批管理员"), SCHOOL_UNION_ARTICLE_CLUB_ADMIN("校工会社团新闻审批管理员"),
SCHOOL_UNION_ARTICLE_FGH_ADMIN("校工会分工会新闻审批管理员"), SCHOOL_UNION_ARTICLE_FGH_ADMIN("校工会分工会新闻审批管理员"),
SCHOOL_UNION_ARTICLE_ADMIN("校工会新闻审批管理员"), SCHOOL_UNION_ARTICLE_ADMIN("校工会新闻审批管理员"),
@@ -13,6 +13,7 @@ public enum LoginType {
QI_YE_WECHAT("企业微信"), QI_YE_WECHAT("企业微信"),
WECHAT("微信"), WECHAT("微信"),
PC_LOCAL("PC本地"), PC_LOCAL("PC本地"),
SMS_CODE("手机验证码"),
MOBILE_LOCAL("手机本地"), MOBILE_LOCAL("手机本地"),
CAS("CAS"); CAS("CAS");
@@ -7,10 +7,12 @@ import com.budwk.app.base.result.Result;
import com.budwk.app.sys.models.Sys_config; import com.budwk.app.sys.models.Sys_config;
import com.budwk.app.sys.services.SysConfigService; import com.budwk.app.sys.services.SysConfigService;
import com.budwk.app.web.commons.auth.utils.SecurityUtil; 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.Cnd;
import org.nutz.dao.Dao; import org.nutz.dao.Dao;
import org.nutz.dao.Sqls; import org.nutz.dao.Sqls;
import org.nutz.integration.jedis.pubsub.PubSubService; 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.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Strings; import org.nutz.lang.Strings;
@@ -139,12 +141,15 @@ public class SysConfController {
@At @At
@Ok("json:full") @Ok("json:full")
@SaCheckPermission("sys.manager.conf") @SaCheckPermission("sys.manager.conf")
@Aop(TransAop.READ_COMMITTED)
public Object data(@Param("pageNumber") int pageNumber, public Object data(@Param("pageNumber") int pageNumber,
@Param("pageSize") int pageSize, @Param("pageSize") int pageSize,
@Param("pageOrderName") String pageOrderName, @Param("pageOrderName") String pageOrderName,
@Param("pageOrderBy") String pageOrderBy, @Param("pageOrderBy") String pageOrderBy,
@Param("configKey") String configKey) { @Param("configKey") String configKey) {
try { try {
// 由 service 补充默认关闭的入口参数,管理员可通过现有“是/否”控件修改。
sysConfigService.ensureSmsLoginVisibleConfig(SecurityUtil.getUserId());
ensureAppImageConfig("AppHomeImg", "PC首页轮播图"); ensureAppImageConfig("AppHomeImg", "PC首页轮播图");
ensureAppImageConfig("H5AppHomeImg", "移动端首页轮播图"); ensureAppImageConfig("H5AppHomeImg", "移动端首页轮播图");
ensureAppImageConfig("AppFeaturedActivityImg", "精彩活动页顶部图片"); ensureAppImageConfig("AppFeaturedActivityImg", "精彩活动页顶部图片");
@@ -3,6 +3,7 @@ package com.budwk.app.sys.controller;
import cn.dev33.satoken.annotation.SaCheckLogin; import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import com.budwk.app.base.annotation.SLog; 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.base.result.Result;
import com.budwk.app.sys.models.Sys_dict; import com.budwk.app.sys.models.Sys_dict;
import com.budwk.app.sys.services.SysDictService; import com.budwk.app.sys.services.SysDictService;
@@ -119,6 +120,8 @@ public class SysDictController {
sysDictService.save(dict, parentId); sysDictService.save(dict, parentId);
sysDictService.clearCache(); sysDictService.clearCache();
return Result.success("system.success"); return Result.success("system.success");
} catch (BaseException e) {
return Result.error(e.getMessage());
} catch (Exception e) { } catch (Exception e) {
return Result.error("system.error"); 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) { public Object editDo(@Param("..") Sys_dict dict, @Param("parentId") String parentId, HttpServletRequest req) {
try { try {
dict.setUpdatedBy(SecurityUtil.getUserId()); dict.setUpdatedBy(SecurityUtil.getUserId());
sysDictService.updateIgnoreNull(dict); sysDictService.updateDict(dict);
sysDictService.clearCache(); sysDictService.clearCache();
return Result.success(); return Result.success();
} catch (BaseException e) {
return Result.error(e.getMessage());
} catch (Exception e) { } catch (Exception e) {
return Result.error(); return Result.error();
} }
@@ -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_activity;
import com.budwk.app.sys.models.Sys_home_template; import com.budwk.app.sys.models.Sys_home_template;
import com.budwk.app.sys.models.Sys_menu; 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.SysMenuService;
import com.budwk.app.sys.services.SysUserService; import com.budwk.app.sys.services.SysUserService;
import com.budwk.app.web.commons.auth.utils.AuthUtil; import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil; import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.activity.basic.service.ActivityBasicScopeService; import com.budwk.app.zhgh.activity.basic.service.ActivityBasicScopeService;
import io.swagger.annotations.ApiOperation; 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.Cnd;
import org.nutz.dao.Dao; import org.nutz.dao.Dao;
import org.nutz.dao.FieldFilter; import org.nutz.dao.FieldFilter;
import org.nutz.dao.Sqls; import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql; import org.nutz.dao.sql.Sql;
import org.nutz.dao.util.Daos; 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.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.json.Json;
import org.nutz.lang.Lang; import org.nutz.lang.Lang;
import org.nutz.lang.Strings; import org.nutz.lang.Strings;
import org.nutz.lang.util.NutMap; 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.HttpServletRequest;
import javax.servlet.http.HttpSession; import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -58,9 +52,9 @@ public class SysHomeController {
@Inject @Inject
private SysUserService sysUserService; private SysUserService sysUserService;
@Inject @Inject
private RedisService redisService;
@Inject
private ActivityBasicScopeService activityBasicScopeService; private ActivityBasicScopeService activityBasicScopeService;
@Inject
private SysHomeNewsService sysHomeNewsService;
@At("") @At("")
@Ok("re") @Ok("re")
@@ -293,34 +287,17 @@ public class SysHomeController {
@SaCheckLogin @SaCheckLogin
@ApiOperation("获取新闻") @ApiOperation("获取新闻")
@Ok("json") @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() { public Result getNews() {
try { return Result.success(sysHomeNewsService.getWebsiteNews());
String url = "https://xgh.cug.edu.cn/"; }
Document document = Jsoup.connect(url).get();
Elements homeElements = document.select(".gh,.jc,.fc,.jg");
List<Map<String, Object>> news = homeElements.stream().map(homeElement -> { /**
//获取板块名称 * href 传首页新闻项原始链接;成功结果 data 为图片地址字符串,空字符串表示没有可用图片。
String name = homeElement.selectFirst(".title").text(); */
Elements liElements = homeElement.select("li"); @At
List<NutMap> ele = liElements.stream().map(liElement -> { @SaCheckLogin
NutMap row = NutMap.NEW(); @Ok("json")
String href = liElement.selectFirst("a").attr("href"); public Result getNewsImage(@Param("href") String href) {
String time = liElement.selectFirst("span").text(); return Result.success(sysHomeNewsService.getNewsImage(href));
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);
}
} }
} }
@@ -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_log;
import com.budwk.app.sys.models.Sys_user; import com.budwk.app.sys.models.Sys_user;
import com.budwk.app.sys.services.SysUserService; 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.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.auth.utils.SecurityUtil;
import com.budwk.app.web.commons.base.Globals; import com.budwk.app.web.commons.base.Globals;
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
import org.jasig.cas.client.util.AbstractCasFilter; import org.jasig.cas.client.util.AbstractCasFilter;
import org.jasig.cas.client.validation.Assertion; import org.jasig.cas.client.validation.Assertion;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd; import org.nutz.dao.Cnd;
import org.nutz.integration.jedis.RedisService; import org.nutz.integration.jedis.RedisService;
import org.nutz.ioc.impl.PropertiesProxy; 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.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Lang; 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.Filters;
import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param; import org.nutz.mvc.annotation.Param;
import org.nutz.mvc.annotation.POST;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
@@ -55,19 +60,19 @@ import java.util.concurrent.ConcurrentHashMap;
public class SysLoginController { public class SysLoginController {
private static final Log log = Logs.get(); private static final Log log = Logs.get();
private static final LoginRsaKeyRing LOGIN_RSA_KEY_RING = new LoginRsaKeyRing(); private static final LoginRsaKeyRing LOGIN_RSA_KEY_RING = new LoginRsaKeyRing();
/**
* TODO: 临时屏蔽回环地址及指定局域网 IP 的平台登录验证码,全部恢复时改为 false。
*/
private static final boolean DISABLE_LOGIN_CAPTCHA = false;
@Inject @Inject
private SysUserService sysUserService; private SysUserService sysUserService;
@Inject @Inject
private SysConfigService sysConfigService;
@Inject
private RedisService redisService; private RedisService redisService;
@Inject @Inject
private SLogService sLogService; private SLogService sLogService;
@Inject @Inject
private ValidateService validateService; private ValidateService validateService;
@Inject @Inject
private SliderCaptchaService sliderCaptchaService;
@Inject
protected PropertiesProxy conf; protected PropertiesProxy conf;
@At("") @At("")
@@ -75,13 +80,17 @@ public class SysLoginController {
@ApiOperation("用户本地登录页面") @ApiOperation("用户本地登录页面")
@Filters @Filters
public String login(HttpServletRequest req, HttpSession session) { public String login(HttpServletRequest req, HttpSession session) {
// 只向模板提供入口显示状态,短信发送及验证码登录接口继续使用原有规则。
req.setAttribute("smsLoginVisible", sysConfigService.isSmsLoginVisible());
return "beetl:/platform/sys/login.html"; return "beetl:/platform/sys/login.html";
} }
@At @At
@Ok("re") @Ok("re")
@Filters @Filters
public String h5() { public String h5(HttpServletRequest req) {
// 手机端复用同一个登录模板,读取相同的系统参数。
req.setAttribute("smsLoginVisible", sysConfigService.isSmsLoginVisible());
return "beetl:/platform/sys/login.html"; 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") @At("/doLogin")
@Ok("json") @Ok("json")
@ApiOperation("用户本地账号密码登录") @ApiOperation("用户本地账号密码登录")
@@ -124,14 +140,9 @@ public class SysLoginController {
// return Result.error("登录失败次数过多,请5分钟后再试"); // return Result.error("登录失败次数过多,请5分钟后再试");
// } // }
try { try {
// 验证码校验 // 所有访问地址均需消费绑定当前账号的滑块凭证,旧图形码不能用于登录。
if (!isLoginCaptchaDisabled(req)) { sliderCaptchaService.consume(session.getId(), captchaKey, captchaCode,
try { SliderCaptchaService.PASSWORD, username);
validateService.checkCode(captchaKey, captchaCode);
} catch (BaseException e) {
return Result.error(e.getMessage());
}
}
// 解密密码 // 解密密码
LoginRsaKey rsaKey = LOGIN_RSA_KEY_RING.getPrivateKey(keyId); LoginRsaKey rsaKey = LOGIN_RSA_KEY_RING.getPrivateKey(keyId);
@@ -162,20 +173,7 @@ public class SysLoginController {
} }
} }
private boolean isLoginCaptchaDisabled(HttpServletRequest req) { @At(value = "/platform/sso/login", top = true)
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)
@Ok("re") @Ok("re")
@ApiOperation("用户cas登录统一入口") @ApiOperation("用户cas登录统一入口")
public String ssoLogin(HttpSession httpSession, HttpServletRequest request, HttpServletResponse response) { public String ssoLogin(HttpSession httpSession, HttpServletRequest request, HttpServletResponse response) {
@@ -190,72 +188,23 @@ public class SysLoginController {
return ">>:" + sysUserService.loginPlus(sysUser, LoginType.CAS, request); return ">>:" + sysUserService.loginPlus(sysUser, LoginType.CAS, request);
} }
@At(value = {"/platform/qywechat/oauth", "/platform/qywechat"}, top = true) @At(value = "/platform/wxwork/oauth2/callback", 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)
@Ok("re") @Ok("re")
@ApiOperation("企业微信登录回调") @ApiOperation("企业微信登录回调")
public String wxworkOAuth2(@Param("code") String code, @Param("redirect") String redirect, HttpServletRequest request) { public String wxworkOAuth2(@Param("code") String code, @Param("redirect") String redirect, HttpServletRequest request) {
if (StrUtil.isBlank(code)) { if (StrUtil.isBlank(code)) {
return ">>:/platform/home"; return ">>:/platform/home";
} }
boolean zjnu = "prd".equalsIgnoreCase(conf.get("nutz.profiles.active")); String token = redisService.get(RedisConstant.REDIS_KEY_WX_WORK_TOKEN);
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);
if (StrUtil.isBlank(token)) { if (StrUtil.isBlank(token)) {
return ">>:/platform/home"; return ">>:/platform/home";
} }
String url = "https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo?access_token=ACCESS_TOKEN&code=CODE"; 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); url = url.replace("ACCESS_TOKEN", token).replace("CODE", code);
String response = HttpUtil.get(url); String response = HttpUtil.get(url);
JSONObject jsonObject = JSONUtil.parseObj(response); JSONObject jsonObject = JSONUtil.parseObj(response);
if (jsonObject.getInt("errcode") == 0) { if (jsonObject.getInt("errcode") == 0) {
String loginName = jsonObject.getStr(zjnu ? "UserId" : "userid"); String loginName = jsonObject.getStr("userid");
if (StrUtil.isBlank(loginName)) {
return ">>:/platform/home/500";
}
sysUserService.checkThirdPlatformLoginName(loginName); sysUserService.checkThirdPlatformLoginName(loginName);
Sys_user sysUser = sysUserService.loginByLoginName(loginName); Sys_user sysUser = sysUserService.loginByLoginName(loginName);
return ">>:" + sysUserService.loginPlus(sysUser, LoginType.QI_YE_WECHAT, request); 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") @At("/captcha")
@Ok("json") @Ok("json")
@ApiOperation("获取验证码") @ApiOperation("获取验证码")
@@ -52,6 +52,16 @@ public class Sys_dict extends BaseModel implements Serializable {
@ColDefine(type = ColType.VARCHAR, width = 100) @ColDefine(type = ColType.VARCHAR, width = 100)
private String code; 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 @Column
@Comment("启用状态") @Comment("启用状态")
@ColDefine(type = ColType.BOOLEAN) @ColDefine(type = ColType.BOOLEAN)
@@ -49,7 +49,7 @@ public class Sys_user extends BaseModel implements Serializable {
@Column @Column
@Comment("性别") @Comment("性别")
@ColDefine(type = ColType.VARCHAR, width = 10) @ColDefine(type = ColType.VARCHAR, width = 10)
@DataCenterColumn(name = "性别", key = "XBMC") @DataCenterColumn(name = "性别", key = "XB", dict = "USER_SEX")
private String sex; private String sex;
@Column @Column
@@ -65,7 +65,6 @@ public class Sys_user extends BaseModel implements Serializable {
@Column @Column
@Comment("出生日期") @Comment("出生日期")
@ColDefine(type = ColType.DATE) @ColDefine(type = ColType.DATE)
@DataCenterColumn(name = "出生日期", key = "CSRQ")
private Date birthday; private Date birthday;
@Column @Column
@@ -82,7 +81,7 @@ public class Sys_user extends BaseModel implements Serializable {
@Column @Column
@Comment("民族") @Comment("民族")
@ColDefine(type = ColType.VARCHAR, width = 20) @ColDefine(type = ColType.VARCHAR, width = 20)
@DataCenterColumn(name = "民族", key = "MZDM", dict = "USER_NATION") @DataCenterColumn(name = "民族", key = "MZM", dict = "USER_NATION")
private String nation; private String nation;
@Column @Column
@@ -109,13 +108,12 @@ public class Sys_user extends BaseModel implements Serializable {
@Column @Column
@Comment("手机号码") @Comment("手机号码")
@ColDefine(type = ColType.VARCHAR, width = 32) @ColDefine(type = ColType.VARCHAR, width = 32)
@DataCenterColumn(name = "手机号码", key = "SJHM") @DataCenterColumn(name = "手机号码", key = "YDDH")
private String mobile; private String mobile;
@Column @Column
@Comment("学历") @Comment("学历")
@ColDefine(type = ColType.VARCHAR, width = 20) @ColDefine(type = ColType.VARCHAR, width = 20)
@DataCenterColumn(name = "学历", key = "ZGXLMC")
private String education; private String education;
@Column @Column
@@ -126,7 +124,7 @@ public class Sys_user extends BaseModel implements Serializable {
@Column @Column
@Comment("学位") @Comment("学位")
@ColDefine(type = ColType.VARCHAR, width = 50) @ColDefine(type = ColType.VARCHAR, width = 50)
@DataCenterColumn(name = "学位", key = "ZGXWMC") // @DataCenterColumn(name = "学位", key = "zgxwmc")
private String academicDegree; private String academicDegree;
@Column @Column
@@ -137,7 +135,6 @@ public class Sys_user extends BaseModel implements Serializable {
@Column @Column
@Comment("职称") @Comment("职称")
@ColDefine(type = ColType.VARCHAR, width = 50) @ColDefine(type = ColType.VARCHAR, width = 50)
@DataCenterColumn(name = "职称", key = "ZYJSZWDMMC")
private String professionalTitle; private String professionalTitle;
@Column @Column
@@ -178,7 +175,7 @@ public class Sys_user extends BaseModel implements Serializable {
@Column @Column
@Comment("来校时间") @Comment("来校时间")
@ColDefine(type = ColType.VARCHAR, width = 10) @ColDefine(type = ColType.VARCHAR, width = 10)
@DataCenterColumn(name = "来校时间", key = "LXRQ") @DataCenterColumn(name = "来校时间", key = "LXNY")
private String arrivalAtSchoolDate; private String arrivalAtSchoolDate;
@Column @Column
@@ -194,7 +191,7 @@ public class Sys_user extends BaseModel implements Serializable {
@Column @Column
@Comment("在职状态") @Comment("在职状态")
@ColDefine(type = ColType.VARCHAR, width = 30) @ColDefine(type = ColType.VARCHAR, width = 30)
@DataCenterColumn(name = "在职状态", key = "DQZTMC") @DataCenterColumn(name = "当前状态码", key = "DQZT", dict = "USER_STATE")
private String userState; private String userState;
@Column @Column
@@ -211,7 +208,7 @@ public class Sys_user extends BaseModel implements Serializable {
@Column @Column
@Comment("教职工类别码") @Comment("教职工类别码")
@ColDefine(type = ColType.VARCHAR, width = 30) @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; private String personType;
@Column @Column
@@ -223,7 +220,6 @@ public class Sys_user extends BaseModel implements Serializable {
@Column @Column
@Comment("编制类别码") @Comment("编制类别码")
@ColDefine(type = ColType.VARCHAR, width = 30) @ColDefine(type = ColType.VARCHAR, width = 30)
@DataCenterColumn(name = "编制类别", key = "BZLBDMMC")
private String preparedBy; private String preparedBy;
@Column @Column
@@ -278,7 +274,7 @@ public class Sys_user extends BaseModel implements Serializable {
@Column @Column
@ColDefine(type = ColType.VARCHAR, width = 32) @ColDefine(type = ColType.VARCHAR, width = 32)
@DataCenterColumn(name = "单位", key = "SZDWDM") @DataCenterColumn(name = "单位", key = "SZDWH")
private String unitId; private String unitId;
@Column @Column
@@ -463,4 +459,13 @@ public class Sys_user extends BaseModel implements Serializable {
@Comment("基金会员退出时间") @Comment("基金会员退出时间")
@ColDefine(type = ColType.DATETIME) @ColDefine(type = ColType.DATETIME)
private Date aidFundMemberQuitTime; private Date aidFundMemberQuitTime;
/**
* 人员数据治理同步保护标识:1 表示人工确认后的数据,自动同步时不得覆盖。
*/
@Column
@Comment("人员数据治理同步保护标识")
@ColDefine(type = ColType.INT)
@Default(value = "0")
private Integer flag;
} }
@@ -10,6 +10,23 @@ import java.util.List;
* Created by wizzer on 2016/12/23. * Created by wizzer on 2016/12/23.
*/ */
public interface SysConfigService extends BaseService<Sys_config> { public interface SysConfigService extends BaseService<Sys_config> {
/** 仅控制登录页面短信入口的可见性,不作为后端短信接口的访问开关。 */
String SMS_LOGIN_VISIBLE_KEY = "AppSmsLoginEnabled";
/**
* 补充缺失的短信登录入口参数,初始值为 false,已有管理员配置保持不变。
*
* @param createdBy 当前打开系统参数列表的管理员 ID,用于记录参数创建人
*/
void ensureSmsLoginVisibleConfig(String createdBy);
/**
* 读取短信登录入口显示配置,不依赖异步更新的全局缓存。
*
* @return 仅参数值为 true 时返回 true;参数缺失、为空或非法时返回 false
*/
boolean isSmsLoginVisible();
/** /**
* 查询所有数据 * 查询所有数据
* @return * @return
@@ -79,6 +79,13 @@ public interface SysDictService extends BaseService<Sys_dict> {
*/ */
void save(Sys_dict dict, String pid); void save(Sys_dict dict, String pid);
/**
* 更新数据字典,并对人员属性直属子项的人员类型和业务编码进行校验。
*
* @param dict 待更新字典;id 为必填项,personType 与 userAttributeCode 可为空
*/
void updateDict(Sys_dict dict);
/** /**
* 保存数据字典 * 保存数据字典
* *
@@ -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<NutMap> getWebsiteNews();
/**
* href 必须是工会首页返回的新闻链接;返回正文首图的绝对地址,无图或不可访问时为空字符串。
*/
String getNewsImage(String href);
}
@@ -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_menu;
import com.budwk.app.sys.models.Sys_role; import com.budwk.app.sys.models.Sys_role;
import com.budwk.app.sys.models.Sys_user; import com.budwk.app.sys.models.Sys_user;
import org.nutz.lang.util.NutMap;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import java.util.List; import java.util.List;
@@ -155,14 +156,37 @@ public interface SysUserService extends BaseService<Sys_user> {
Sys_user loginByPassword(String loginname, String passowrd) throws BaseException; Sys_user loginByPassword(String loginname, String passowrd) throws BaseException;
/** /**
* 通过短信验证码登录 * 根据手机号获取唯一可登录用户,并加载所属单位及分工会。
* *
* @param mobile 手机号 * @param mobile 已在系统人员资料中登记的 11 位大陆手机号,允许首尾空格
* @return * @return Sys_user,包含用户基本资料、unit 单位及 union 分工会关联
* @throws BaseException * @throws BaseException 手机号格式不正确、未绑定用户、关联多条用户或账号已禁用
*/ */
Sys_user loginByMobile(String mobile) 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 * @param loginname
@@ -6,8 +6,11 @@ import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.base.utils.PageUtil; import com.budwk.app.base.utils.PageUtil;
import com.budwk.app.sys.models.Sys_config; import com.budwk.app.sys.models.Sys_config;
import com.budwk.app.sys.services.SysConfigService; import com.budwk.app.sys.services.SysConfigService;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd; import org.nutz.dao.Cnd;
import org.nutz.dao.Dao; 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.ioc.loader.annotation.IocBean;
import org.nutz.lang.Lang; import org.nutz.lang.Lang;
import org.nutz.lang.Strings; import org.nutz.lang.Strings;
@@ -24,6 +27,35 @@ public class SysConfigServiceImpl extends BaseServiceImpl<Sys_config> implements
super(dao); 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<Sys_config> getAllList() { public List<Sys_config> getAllList() {
return this.query(Cnd.where("delFlag", "=", false)); return this.query(Cnd.where("delFlag", "=", false));
} }
@@ -29,6 +29,7 @@ import com.budwk.app.sys.services.SysDataUserPullService;
import com.budwk.app.sys.services.SysDictService; import com.budwk.app.sys.services.SysDictService;
import com.budwk.app.sys.utils.DataCenterUtil; import com.budwk.app.sys.utils.DataCenterUtil;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.nutz.aop.interceptor.async.Async; import org.nutz.aop.interceptor.async.Async;
import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd; import org.nutz.dao.Cnd;
@@ -52,6 +53,8 @@ import java.util.stream.Collectors;
public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source> implements SysDataUserPullService { public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source> implements SysDataUserPullService {
private static final String DMP_TEACHER_KEY = "teacher"; 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 @Inject
private SysDictService sysDictService; private SysDictService sysDictService;
@@ -118,17 +121,21 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source>
// 字典码表Map,key为父级编码,value为子级字典列表 // 字典码表Map,key为父级编码,value为子级字典列表
Map<String, List<Sys_data_dict>> dataDictMap = dataDictList.stream().collect(Collectors.groupingBy(Sys_data_dict::getParentCode)); Map<String, List<Sys_data_dict>> dataDictMap = dataDictList.stream().collect(Collectors.groupingBy(Sys_data_dict::getParentCode));
List<JSONObject> rawDataList = distinctDmpUsers(DataCenterUtil.pullData(getDmpUrl(DMP_TEACHER_KEY))); String accessToken = getDmpAccessToken();
List<JSONObject> 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()) { if (rawDataList.isEmpty()) {
throw new BaseException("当前未获取到人员数据,中止本次同步"); log.warn("当前未获取到人员数据");
} }
Date nowDate = new Date(); Date nowDate = new Date();
// 人员单位号先与单位表批量比对,缺失单位只同步一次,避免逐人查询单位表。 // 人员单位号先与单位表批量比对,缺失单位只同步一次,避免逐人查询单位表。
Set<String> sourceUnitIds = rawDataList.stream() Set<String> sourceUnitIds = rawDataList.stream()
.map(raw -> raw.getStr("SZDWDM")) .map(raw -> raw.getStr("SZDWH"))
.filter(StrUtil::isNotBlank) .filter(StrUtil::isNotBlank)
.collect(Collectors.toSet()); .collect(Collectors.toSet());
Map<String, Sys_unit> unitMap = dao().query(Sys_unit.class, Cnd.NEW()).stream() Map<String, Sys_unit> unitMap = dao().query(Sys_unit.class, Cnd.NEW()).stream()
@@ -140,44 +147,25 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source>
.collect(Collectors.toMap(Sys_unit::getId, unit -> unit)); .collect(Collectors.toMap(Sys_unit::getId, unit -> unit));
} }
String rootId = DataCenterUtil.findRootUnit(new ArrayList<>(unitMap.values())).getId(); // 按单位表的实际等级判断:三级单位为3、直属二级单位为2;单位号位数不参与判断。
// 深层机构沿父级查找二级单位,并保留所属三级单位。
Map<String, String> tertiaryParentUnitIds = new HashMap<>(); Map<String, String> tertiaryParentUnitIds = new HashMap<>();
Map<String, String> tertiaryUnitIds = new HashMap<>();
for (String sourceUnitId : sourceUnitIds) { for (String sourceUnitId : sourceUnitIds) {
Sys_unit unit = unitMap.get(sourceUnitId); Sys_unit unit = unitMap.get(sourceUnitId);
if (unit == null) { if (unit == null) {
log.warn("人员单位号{}在机构同步后仍不存在,跳过单位归属处理,保留原单位号入库", sourceUnitId); log.warn("人员单位号{}在单位表中不存在,保留信息中心原始单位号", sourceUnitId);
continue; continue;
} }
Set<String> visited = new HashSet<>(); if (!Integer.valueOf(3).equals(unit.getUnitLevel())) {
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) {
continue; continue;
} }
if (!sourceUnitId.equals(unit.getId())) { Sys_unit parentUnit = unitMap.get(unit.getParentId());
tertiaryParentUnitIds.put(sourceUnitId, unit.getId()); if (parentUnit == null || !Integer.valueOf(2).equals(parentUnit.getUnitLevel())) {
log.warn("三级单位{}缺少有效的二级父单位,保留信息中心原始单位号", sourceUnitId);
continue;
} }
tertiaryParentUnitIds.put(sourceUnitId, parentUnit.getId());
} }
Map<String, Sys_unit> resolvedUnitMap = unitMap;
// 赋值 // 赋值
List<Sys_user_source> latestSourceList = rawDataList.stream().map(raw -> { List<Sys_user_source> latestSourceList = rawDataList.stream().map(raw -> {
Sys_user_source sysUser = new Sys_user_source(); Sys_user_source sysUser = new Sys_user_source();
@@ -195,49 +183,44 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source>
.findFirst().orElse(new Sys_data_dict()); .findFirst().orElse(new Sys_data_dict());
mapping.field.set(sysUser, StrUtil.blankToDefault(sysDataDict.getName(), raw.getStr(mapping.key))); mapping.field.set(sysUser, StrUtil.blankToDefault(sysDataDict.getName(), raw.getStr(mapping.key)));
} else { } 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))); mapping.field.set(sysUser, normalizeArrivalAtSchoolDate(raw.getStr(mapping.key)));
} else { } else {
mapping.field.set(sysUser, raw.getStr(mapping.key)); mapping.field.set(sysUser, raw.getStr(mapping.key));
} }
} }
} else if (mapping.field.getType() == Date.class) { } else if (mapping.field.getType() == Date.class) {
String dateText = StrUtil.trim(raw.getStr(mapping.key)); mapping.field.set(sysUser, raw.getDate(mapping.key));
if (StrUtil.isNotBlank(dateText)) {
mapping.field.set(sysUser, dateText.matches("\\d{8}")
? DateUtil.parse(dateText, "yyyyMMdd") : DateUtil.parse(dateText));
}
} }
} catch (IllegalAccessException e) { } catch (IllegalAccessException e) {
log.error("设置字段值失败: {}", mapping.field.getName(), e); log.error("设置字段值失败: {}", mapping.field.getName(), e);
} }
} }
if (sysUser.getBirthday() == null && StrUtil.isNotBlank(sysUser.getIdCard())) { sysUser.setUnitName(raw.getStr("SZDW"));
try { String sourceUnitId = raw.getStr("SZDWH");
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());
}
String parentUnitId = tertiaryParentUnitIds.get(sourceUnitId); String parentUnitId = tertiaryParentUnitIds.get(sourceUnitId);
sysUser.setUnitId(StrUtil.blankToDefault(parentUnitId, sourceUnitId)); sysUser.setUnitId(StrUtil.blankToDefault(parentUnitId, sourceUnitId));
sysUser.setThreeUnitId(tertiaryUnitIds.get(sourceUnitId)); sysUser.setThreeUnitId(parentUnitId == null ? null : 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.setPullTime(nowDate); 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; return sysUser;
}).toList(); }).toList();
@@ -256,7 +239,7 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source>
/** /**
* 规范化信息中心返回的来校时间。 * 规范化信息中心返回的来校时间。
* 入参 arrivalAtSchoolDate 对应信息中心 LXRQ 字段,允许 yyyy-MM-dd 或 yyyy-MM; * 入参 arrivalAtSchoolDate 对应信息中心 LXNY 字段,允许 yyyy-MM-dd 或 yyyy-MM;
* 返回值用于 sys_user_source.arrivalAtSchoolDate 和后续 sys_user.arrivalAtSchoolDate,年月格式统一补为当月 01 日。 * 返回值用于 sys_user_source.arrivalAtSchoolDate 和后续 sys_user.arrivalAtSchoolDate,年月格式统一补为当月 01 日。
*/ */
private String normalizeArrivalAtSchoolDate(String arrivalAtSchoolDate) { private String normalizeArrivalAtSchoolDate(String arrivalAtSchoolDate) {
@@ -272,8 +255,48 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source>
String[] parts = dateText.split("-"); String[] parts = dateText.split("-");
return parts[0] + "-" + StrUtil.padPre(parts[1], 2, '0') + "-01"; return parts[0] + "-" + StrUtil.padPre(parts[1], 2, '0') + "-01";
} }
return DateUtil.formatDate(dateText.matches("\\d{8}") return dateText;
? DateUtil.parse(dateText, "yyyyMMdd") : DateUtil.parse(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<Sys_user_source>
} }
/** /**
* 按职工号去重,避免同一批次出现重复人员。 * 分页拉取单个人员接口数据。
* 入参 url 为人员接口地址,accessToken 为信息中心令牌,sourceName 用于日志区分接口来源。
* 返回值为该接口所有分页合并后的原始人员 JSON 列表。
*/
private List<JSONObject> pullDmpUsers(String url, String accessToken, String sourceName) {
List<JSONObject> 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<JSONObject> distinctDmpUsers(List<JSONObject> rawDataList) { private List<JSONObject> distinctDmpUsers(List<JSONObject> rawDataList) {
@@ -297,7 +367,7 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source>
for (JSONObject raw : rawDataList) { for (JSONObject raw : rawDataList) {
String loginName = raw.getStr("ZGH"); String loginName = raw.getStr("ZGH");
if (StrUtil.isBlank(loginName)) { if (StrUtil.isBlank(loginName)) {
throw new BaseException("人员接口存在缺少职工号 ZGH 的记录"); loginName = raw.getStr("ID");
} }
userMap.putIfAbsent(loginName, raw); userMap.putIfAbsent(loginName, raw);
} }
@@ -27,6 +27,9 @@ import java.util.Map;
@IocBean(args = {"refer:dao"}) @IocBean(args = {"refer:dao"})
@CacheDefaults(cacheName = RedisConstant.PLATFORM_REDIS_WKCACHE_PREFIX + "sys_dict", isHash = true) @CacheDefaults(cacheName = RedisConstant.PLATFORM_REDIS_WKCACHE_PREFIX + "sys_dict", isHash = true)
public class SysDictServiceImpl extends BaseServiceImpl<Sys_dict> implements SysDictService { public class SysDictServiceImpl extends BaseServiceImpl<Sys_dict> 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) { public SysDictServiceImpl(Dao dao) {
super(dao); super(dao);
} }
@@ -132,18 +135,76 @@ public class SysDictServiceImpl extends BaseServiceImpl<Sys_dict> implements Sys
@Aop(TransAop.READ_COMMITTED) @Aop(TransAop.READ_COMMITTED)
public void save(Sys_dict dict, String pid) { public void save(Sys_dict dict, String pid) {
String path = ""; String path = "";
Sys_dict parentDict = null;
if (!Strings.isEmpty(pid)) { if (!Strings.isEmpty(pid)) {
Sys_dict pp = this.fetch(pid); parentDict = this.fetch(pid);
path = pp.getPath(); path = parentDict.getPath();
} }
dict.setPath(getSubPath("sys_dict", "path", path)); dict.setPath(getSubPath("sys_dict", "path", path));
dict.setParentId(pid); dict.setParentId(pid);
validateUserAttribute(dict, parentDict);
dao().insert(dict); dao().insert(dict);
if (!Strings.isEmpty(pid)) { if (!Strings.isEmpty(pid)) {
this.update(Chain.make("hasChildren", true), Cnd.where("id", "=", 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) @Aop(TransAop.READ_COMMITTED)
public void saveByParentCode(Sys_dict dict, String parentCode) { public void saveByParentCode(Sys_dict dict, String parentCode) {
Sys_dict parentDict = fetch(Cnd.where("code", "=", parentCode)); Sys_dict parentDict = fetch(Cnd.where("code", "=", parentCode));
@@ -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<NutMap> getWebsiteNews() {
try {
Document document = Jsoup.connect(WEBSITE_URL).timeout(10000).get();
List<NutMap> 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<NutMap> cachedNews = getCachedNews();
if (!cachedNews.isEmpty()) {
return cachedNews;
}
throw new RuntimeException("学校工会网站新闻获取失败", e);
}
}
/**
* 将外部网站栏目节点转换为前端统一使用的栏目和新闻结构。
*/
private List<NutMap> parseWebsiteNews(Elements homeElements) {
List<NutMap> sections = new ArrayList<>();
for (Element homeElement : homeElements) {
Element titleElement = homeElement.selectFirst(".title");
if (titleElement == null || StrUtil.isBlank(titleElement.text())) {
continue;
}
List<NutMap> 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<NutMap> 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 "";
}
}
}
@@ -19,6 +19,7 @@ import com.budwk.app.sys.services.SysMenuService;
import com.budwk.app.sys.services.SysRoleService; import com.budwk.app.sys.services.SysRoleService;
import com.budwk.app.sys.services.SysUserService; import com.budwk.app.sys.services.SysUserService;
import com.budwk.app.sys.views.View_user; 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.web.commons.base.Globals;
import com.budwk.app.zhgh.staffmanage.specialstaff.model.SpecialStaff; import com.budwk.app.zhgh.staffmanage.specialstaff.model.SpecialStaff;
import org.nutz.aop.interceptor.ioc.TransAop; 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.dao.sql.Sql;
import org.nutz.integration.jedis.RedisService; import org.nutz.integration.jedis.RedisService;
import org.nutz.ioc.aop.Aop; 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.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Lang; import org.nutz.lang.Lang;
import org.nutz.lang.Strings; import org.nutz.lang.Strings;
import org.nutz.lang.random.R; 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.CacheDefaults;
import org.nutz.plugins.wkcache.annotation.CacheRemove; import org.nutz.plugins.wkcache.annotation.CacheRemove;
import org.nutz.plugins.wkcache.annotation.CacheRemoveAll; import org.nutz.plugins.wkcache.annotation.CacheRemoveAll;
@@ -65,7 +66,7 @@ public class SysUserServiceImpl extends BaseServiceImpl<Sys_user> implements Sys
@Inject @Inject
private RedisService redisService; private RedisService redisService;
@Inject @Inject
private PropertiesProxy conf; private ValidateService validateService;
@Override @Override
@CacheResult(cacheKey = "${userId}_getPermissionList") @CacheResult(cacheKey = "${userId}_getPermissionList")
@@ -359,9 +360,9 @@ public class SysUserServiceImpl extends BaseServiceImpl<Sys_user> implements Sys
String decodePwd = Base64Decoder.decodeStr(passowrd); String decodePwd = Base64Decoder.decodeStr(passowrd);
String hashedPassword = PwdUtil.getPassword(decodePwd, user.getSalt()); String hashedPassword = PwdUtil.getPassword(decodePwd, user.getSalt());
if (!Strings.sNull(hashedPassword).equalsIgnoreCase(user.getPassword())) { if (!Strings.sNull(hashedPassword).equalsIgnoreCase(user.getPassword())) {
if (Globals.sso) { // if (Globals.sso) {
throw new BaseException("用户名或者密码不正确"); // throw new BaseException("用户名或者密码不正确");
} // }
} }
user = this.fetchLinks(user, "unit"); user = this.fetchLinks(user, "unit");
if (Lang.isNotEmpty(user.getUnit()) && StrUtil.isNotBlank(user.getUnit().getUnionId())) { if (Lang.isNotEmpty(user.getUnit()) && StrUtil.isNotBlank(user.getUnit().getUnionId())) {
@@ -371,16 +372,90 @@ public class SysUserServiceImpl extends BaseServiceImpl<Sys_user> implements Sys
return user; return user;
} }
/**
* 按登记手机号确认唯一且未禁用的账号,补齐后续登录会话使用的组织关系。
*
* @param mobile 已登记的 11 位大陆手机号,查询前移除首尾空格
* @return Sys_user,包含 unit 单位和按单位关联的 union 分工会信息
* @throws BaseException 手机号无效、未登记、关联多条用户或账号已禁用
*/
@Override @Override
public Sys_user loginByMobile(String mobile) throws BaseException { public Sys_user loginByMobile(String mobile) throws BaseException {
Sys_user user = this.fetch(Cnd.where("mobile", "=", mobile)); String normalizedMobile = Strings.sNull(mobile).trim();
if (user == null) { if (!normalizedMobile.matches("^1[3-9]\\d{9}$")) {
throw new BaseException("用户不存在"); throw new BaseException("请输入正确的手机号码");
}
// 手机号不是唯一字段;任何重复关联都拒绝登录,避免任取账号或绕过禁用状态。
List<Sys_user> 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"); 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; 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 @Override
public Sys_user loginByLoginName(String loginname) { public Sys_user loginByLoginName(String loginname) {
Sys_user user = this.fetch(Cnd.where("loginname", "=", loginname)); Sys_user user = this.fetch(Cnd.where("loginname", "=", loginname));
@@ -449,6 +524,7 @@ public class SysUserServiceImpl extends BaseServiceImpl<Sys_user> implements Sys
} }
@Override @Override
@Aop(TransAop.READ_COMMITTED)
public String loginPlus(Sys_user user, LoginType loginType, HttpServletRequest request) { public String loginPlus(Sys_user user, LoginType loginType, HttpServletRequest request) {
String wxOpenId = request.getParameter("wxOpenId"); String wxOpenId = request.getParameter("wxOpenId");
@@ -496,11 +572,6 @@ public class SysUserServiceImpl extends BaseServiceImpl<Sys_user> implements Sys
String redirect = request.getParameter("redirect"); String redirect = request.getParameter("redirect");
if (StrUtil.isNotBlank(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; return Globals.AppDomain + redirect;
} else { } else {
return Globals.AppDomain + "/platform/home"; return Globals.AppDomain + "/platform/home";
@@ -3,19 +3,30 @@ package com.budwk.app.web.commons.auth.service;
import com.budwk.app.base.constant.RedisConstant; import com.budwk.app.base.constant.RedisConstant;
import com.budwk.app.base.exception.BaseException; import com.budwk.app.base.exception.BaseException;
import com.budwk.app.base.utils.RsaUtil; 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 com.wf.captcha.ArithmeticCaptcha;
import lombok.extern.slf4j.Slf4j; 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.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.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Strings; import org.nutz.lang.Strings;
import org.nutz.lang.random.R; import org.nutz.lang.random.R;
import org.nutz.lang.util.NutMap; import org.nutz.lang.util.NutMap;
import redis.clients.jedis.Jedis;
import java.security.KeyFactory; import java.security.KeyFactory;
import java.security.PrivateKey; import java.security.PrivateKey;
import java.security.SecureRandom;
import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64; import java.util.Base64;
import java.util.Arrays;
import java.util.Collections;
import java.util.Locale;
/** /**
* @author wizzer@qq.com * @author wizzer@qq.com
@@ -23,14 +34,45 @@ import java.util.Base64;
@IocBean @IocBean
@Slf4j @Slf4j
public class ValidateService { 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 @Inject
private RedisService redisService; 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() { public NutMap getCaptcha() {
String uuid = R.UU32(); String uuid = R.UU32();
ArithmeticCaptcha captcha = new ArithmeticCaptcha(120, 40); ArithmeticCaptcha captcha = new ArithmeticCaptcha(120, 40);
@@ -41,21 +83,66 @@ public class ValidateService {
} }
/** /**
* 发送短信验证码 * 校验发送条件后发送登录短信,只有腾讯云确认发送成功才写入有效验证码。
* *
* @param mobile 手机号码 * @param mobile 已由用户 service 确认唯一绑定、未禁用的 11 位大陆手机号
* @throws BaseException * @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 { @Aop(TransAop.READ_COMMITTED)
String text = R.captchaNumber(4); public NutMap sendLoginSmsCode(String mobile, String captchaKey, String captchaCode, String clientIp, String sessionId) {
String codeFromRedis = redisService.get(RedisConstant.UCENTER_SMSCODE + mobile + ":LOCK"); if (mobile == null || !mobile.matches("^1[3-9]\\d{9}$") || Strings.isBlank(clientIp)) {
if (Strings.isNotBlank(codeFromRedis)) { throw new BaseException("手机号或请求来源不正确");
throw new BaseException("请1分钟之后再试"); }
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 验证码的值 * @param code 验证码的值
* @throws BaseException * @throws BaseException
*/ */
@Aop(TransAop.READ_COMMITTED)
public void checkCode(String key, String code) throws BaseException { public void checkCode(String key, String code) throws BaseException {
String codeFromRedis = redisService.get(RedisConstant.UCENTER_CAPTCHA + key); String codeFromRedis = redisService.get(RedisConstant.UCENTER_CAPTCHA + key);
@@ -81,27 +169,34 @@ public class ValidateService {
} }
/** /**
* 核验短信验证码 * 按服务端收到的手机号核验登录短信验证码,成功后原子删除,输错五次作废。
* *
* @param mobile 手机号码 * @param mobile 本次登录的 11 位大陆手机号,与发送时的手机号完全一致
* @param code 验证码值 * @param code 用户输入的六位数字短信验证码
* @throws BaseException * @throws BaseException 验证码为空、格式错误、不匹配、过期或已使用
*/ */
public void checkSMSCode(String mobile, String code) throws BaseException { @Aop(TransAop.READ_COMMITTED)
String codeFromRedis = redisService.get(RedisConstant.UCENTER_SMSCODE + mobile); 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}$")) {
if (Strings.isBlank(code)) { throw new BaseException("请输入正确的手机号和六位短信验证码");
throw new BaseException("请输入短信验证码");
} }
if (Strings.isEmpty(codeFromRedis)) { String mobileKey = RedisConstant.LOGIN_SMSCODE + "{" + mobile + "}";
throw new BaseException("短信验证码已过期"); 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 (!Strings.equalsIgnoreCase(code, codeFromRedis)) { if (result == -2) {
throw new BaseException("短信验证码错误次数过多,请重新获取");
}
if (result != 1) {
throw new BaseException("短信验证码不正确"); throw new BaseException("短信验证码不正确");
} }
redisService.del(RedisConstant.UCENTER_SMSCODE + mobile); }
} }
@Aop(TransAop.READ_COMMITTED)
public String decryptPwd(String keyId, String password) throws Exception { public String decryptPwd(String keyId, String password) throws Exception {
// 从 Redis 获取私钥 // 从 Redis 获取私钥
String rsaKey = RedisConstant.RSA_KEY_PREFIX + keyId; String rsaKey = RedisConstant.RSA_KEY_PREFIX + keyId;
@@ -3,6 +3,7 @@ package com.budwk.app.web.commons.ext.handler;
import com.budwk.app.web.commons.ext.websocket.WkWebSocket; import com.budwk.app.web.commons.ext.websocket.WkWebSocket;
import org.apache.catalina.core.StandardContext; import org.apache.catalina.core.StandardContext;
import org.apache.catalina.Executor; import org.apache.catalina.Executor;
import org.apache.catalina.Wrapper;
import org.apache.catalina.core.StandardThreadExecutor; import org.apache.catalina.core.StandardThreadExecutor;
import org.apache.catalina.startup.Tomcat; import org.apache.catalina.startup.Tomcat;
import org.apache.catalina.valves.ErrorReportValve; import org.apache.catalina.valves.ErrorReportValve;
@@ -51,6 +52,11 @@ public class WkTomcatConfiguration implements NbAppEventListener {
// NutzBoot 使用编程式 Context,必须显式运行官方 WebSocket 初始化器。 // NutzBoot 使用编程式 Context,必须显式运行官方 WebSocket 初始化器。
StandardContext context = (StandardContext) tomcat.getHost().findChild(starter.getContextPath()); 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.setUseHttpOnly(conf.getBoolean("tomcat.session.cookie.httponly", true));
context.addServletContainerInitializer(new WsSci(), Set.of(WkWebSocket.class)); context.addServletContainerInitializer(new WsSci(), Set.of(WkWebSocket.class));
// Tomcat 的错误页再次 forward 时仍保持 ERROR 派发;让 MVC 渲染原业务错误页。 // Tomcat 的错误页再次 forward 时仍保持 ERROR 派发;让 MVC 渲染原业务错误页。
@@ -1,32 +1,22 @@
package com.budwk.app.zhgh.activity.trainSignUp.controller.manage; package com.budwk.app.zhgh.activity.trainSignUp.controller.manage;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.date.DateUtil;
import com.budwk.app.base.annotation.SLog; import com.budwk.app.base.annotation.SLog;
import com.budwk.app.base.page.Pagination; import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm; import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result; 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 com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityStatisticsService;
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.nutz.dao.Cnd; import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Dao; import org.nutz.ioc.aop.Aop;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param; import org.nutz.mvc.annotation.Param;
import java.util.ArrayList;
import java.util.List;
/** /**
* @Author JyuHsin * @Author JyuHsin
* @Date 2022/9/21 * @Date 2022/9/21
@@ -39,12 +29,6 @@ import java.util.List;
@At("/platform/trainSignUp/adjust") @At("/platform/trainSignUp/adjust")
public class TrainSignUpAdjustController { public class TrainSignUpAdjustController {
@Inject
private Dao dao;
@Inject
private BaseService baseService;
@Inject
private TrainSignUpActivityService trainSignUpActivityManageService;
@Inject @Inject
private TrainSignUpActivityStatisticsService trainSignUpActivityStatisticsService; private TrainSignUpActivityStatisticsService trainSignUpActivityStatisticsService;
@@ -55,122 +39,87 @@ public class TrainSignUpAdjustController {
} }
/** /**
* 活动list * @param year 查询年度,为空时不限制年度
* @param year 年度 * @return Result.data 为当前登录人可管理的已启用主活动数组,id/activityName 用于下拉框
* @return
*/ */
@At @At
@ApiOperation("活动列表") @ApiOperation("活动列表")
@SaCheckPermission("trainSignUp.adjust") @SaCheckPermission("trainSignUp.adjust")
public Result activityList(@Param(value = "year") Integer year) { public Result activityList(@Param("year") Integer year) {
List<TrainSignUpActivity> activityList = dao.query(TrainSignUpActivity.class, Cnd.NEW().andEX("year", "=", year).andEX("isDisabled", "=", false).desc("activityStartTime")); return Result.success(trainSignUpActivityStatisticsService.activityList(year, true));
return Result.success(activityList);
} }
/**
* @param pageForm 分页参数,pageNumber 为页码,pageSize 为每页条数
* @param activityId 主活动ID,为空时返回空分页
* @return Result.data 为 Pagination,list 和 totalCount 均按负责人范围查询
*/
@At @At
@ApiOperation("分页查询") @ApiOperation("分页查询")
@SaCheckPermission("trainSignUp.adjust") @SaCheckPermission("trainSignUp.adjust")
public Result pageData(PageForm pageForm, public Result pageData(PageForm pageForm, @Param("activityId") String activityId) {
@Param(value = "activityId") String activityId) {
Pagination pagination = trainSignUpActivityStatisticsService.pageData(pageForm, activityId); Pagination pagination = trainSignUpActivityStatisticsService.pageData(pageForm, activityId);
return Result.success(pagination); return Result.success(pagination);
} }
/**
* @param activityId 主活动ID
* @return Result.data 为可调整的子活动数组,id 为目标ID,registerNum/hasWaitingNum 为已报及候补人数
*/
@At @At
@ApiOperation("子活动查询") @ApiOperation("子活动查询")
@SaCheckPermission("trainSignUp.adjust") @SaCheckPermission("trainSignUp.adjust")
public Result getCourse(String activityId) { public Result getCourse(String activityId) {
Sql sql = Sqls.create(""" return Result.success(trainSignUpActivityStatisticsService.getCourse(activityId));
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<NutMap> 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);
} }
/**
* @param courseId 有管理权限的子活动ID
* @param unionId 工会ID,可为空
* @param unitId 单位ID,可为空
* @param searchName 保留现有接口参数,姓名和工号搜索统一使用 searchKeyword
* @param searchKeyword 姓名或工号关键字,可为空
* @return Result.data 为报名人员数组,userId 为报名人员ID,state 为报名状态
*/
@At @At
@ApiOperation("报名用户列表") @ApiOperation("报名用户列表")
@SaCheckPermission("trainSignUp.adjust") @SaCheckPermission("trainSignUp.adjust")
public Result registerUserList(@Param("courseId") String courseId, public Result registerUserList(@Param("courseId") String courseId, @Param("unionId") String unionId,
@Param(value = "unionId") String unionId, @Param("unitId") String unitId, @Param("searchName") String searchName,
@Param(value = "unitId") String unitId, @Param("searchKeyword") String searchKeyword) {
@Param(value = "searchName") String searchName, return Result.success(trainSignUpActivityStatisticsService.registerUserList(courseId, unionId, unitId, searchName, searchKeyword));
@Param(value = "searchKeyword") String searchKeyword) {
List<NutMap> list = trainSignUpActivityStatisticsService.registerUserList(courseId, unionId, unitId, searchName, searchKeyword);
return Result.success(list);
} }
/**
* @param activityId 主活动ID,来源和目标子活动都必须属于该活动
* @param oldCourseId 来源子活动ID,必须有管理权限
* @param newCourseId 目标子活动ID,必须有管理权限
* @param userId 被调整的报名人员ID,操作人身份由登录会话确定
* @return Result,code=0 表示报名及签到记录已在同一事务中调整
*/
@At @At
@ApiOperation("人员调整") @ApiOperation("人员调整")
@SaCheckPermission("trainSignUp.adjust") @SaCheckPermission("trainSignUp.adjust")
@SLog(tag = "品牌活动-人员调整", msg = "人员调整") @SLog(tag = "品牌活动-人员调整", msg = "人员调整")
@Aop(TransAop.READ_COMMITTED)
public Result adjust(String activityId, String oldCourseId, String newCourseId, String userId) { public Result adjust(String activityId, String oldCourseId, String newCourseId, String userId) {
trainSignUpActivityStatisticsService.adjust(activityId, oldCourseId, newCourseId, 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<TrainSignUpActivityCourse> activityCourseList = dao.query(TrainSignUpActivityCourse.class, Cnd.where("activityId", "=", activityId)
.and("courseId", "=", newCourseId));
//先清楚旧的信息
dao.clear(TrainSignUpUserCourse.class, oldCnd);
//添加新的信息
List<TrainSignUpUserCourse> 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);
return Result.success(); return Result.success();
} }
/**
* @param activityId 主活动ID
* @param courseId 属于该主活动且有管理权限的子活动ID
* @param userId 被删除的报名人员ID
* @return Result,code=0 表示该子活动的报名及签到记录已删除
*/
@At @At
@ApiOperation("删除报名人员") @ApiOperation("删除报名人员")
@SaCheckPermission("trainSignUp.adjust") @SaCheckPermission("trainSignUp.adjust")
@SLog(tag = "品牌活动-人员调整", msg = "删除报名人员") @SLog(tag = "品牌活动-人员调整", msg = "删除报名人员")
@Aop(TransAop.READ_COMMITTED)
public Result deleteSignUser(String activityId, String courseId, String userId) { public Result deleteSignUser(String activityId, String courseId, String userId) {
trainSignUpActivityStatisticsService.deleteSignUser(activityId, courseId, 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);
return Result.success(); return Result.success();
} }
} }
@@ -128,6 +128,7 @@ public class TrainSignUpApplyController {
@Param(value = "assortTypes") String[] assortTypes, @Param(value = "assortTypes") String[] assortTypes,
@Param(value = "searchKeyword") String searchKeyword, @Param(value = "searchKeyword") String searchKeyword,
@Param(value = "dataType") String dataType) { @Param(value = "dataType") String dataType) {
// 返回课程签到半径 radius(米),供 H5 地图按实际配置绘制签到范围。
Sql sql = Sqls.create(""" Sql sql = Sqls.create("""
SELECT SELECT
tsuc.id, tsuc.id,
@@ -144,6 +145,7 @@ public class TrainSignUpApplyController {
tsuc.unionLimit, tsuc.unionLimit,
tsuc.isMobileSign, tsuc.isMobileSign,
tsuc.signType, tsuc.signType,
tsuc.radius,
tsuc.isReceiveGift, tsuc.isReceiveGift,
tsuc.giftType, tsuc.giftType,
tsuc.reserveMode, tsuc.reserveMode,
@@ -10,8 +10,6 @@ import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result; import com.budwk.app.base.result.Result;
import com.budwk.app.flow.entity.ProcessInstance; import com.budwk.app.flow.entity.ProcessInstance;
import com.budwk.app.flow.enums.ProcessInstanceStateEnum; 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_union;
import com.budwk.app.sys.models.Sys_unit; import com.budwk.app.sys.models.Sys_unit;
import com.budwk.app.web.commons.auth.utils.AuthUtil; 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.Api;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.nutz.dao.Chain;
import org.nutz.dao.Cnd; import org.nutz.dao.Cnd;
import org.nutz.dao.Dao; import org.nutz.dao.Dao;
import org.nutz.dao.Sqls; import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql; 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.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap; import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param; import org.nutz.mvc.annotation.Param;
import org.nutz.trans.Trans;
import javax.validation.constraints.NotNull; import javax.validation.constraints.NotNull;
import java.util.ArrayList; import java.util.ArrayList;
@@ -66,6 +64,37 @@ public class TrainSignUpManageController {
public void index() { 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 @At
@ApiOperation("分页查询") @ApiOperation("分页查询")
@SaCheckPermission("trainSignUp.manage") @SaCheckPermission("trainSignUp.manage")
@@ -110,31 +139,22 @@ public class TrainSignUpManageController {
@ApiOperation("活动删除") @ApiOperation("活动删除")
@SaCheckPermission("trainSignUp.manage") @SaCheckPermission("trainSignUp.manage")
@SLog(tag = "品牌活动-活动管理", msg = "删除活动") @SLog(tag = "品牌活动-活动管理", msg = "删除活动")
@Aop(TransAop.READ_COMMITTED)
public Result onDelete(String id) { public Result onDelete(String id) {
if (dao.fetch(Sys_home_template.class, id) != null) { // id 为主活动ID;service 在同一事务中清理子活动负责人及报名关联。
return Result.error("该活动已设为模板,请先取消模板后再删除"); if (StrUtil.isBlank(id)) {
return Result.error("活动ID不能为空");
} }
Trans.exec(() -> { trainSignUpActivityManageService.deleteActivity(id);
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);
});
return Result.success(); return Result.success();
} }
@At @At
@ApiOperation("活动状态变更") @ApiOperation("活动状态变更")
@SaCheckPermission("trainSignUp.manage") @SaCheckPermission("trainSignUp.manage")
@Aop(TransAop.READ_COMMITTED)
public Result activityStatusChange(TrainSignUpActivity activity) { public Result activityStatusChange(TrainSignUpActivity activity) {
trainSignUpActivityManageService.updateActivityStatus(activity); trainSignUpActivityManageService.updateActivityStatus(activity);
dao.update(Sys_home_activity.class,
Chain.make("enable", !activity.isDisabled()),
Cnd.where("id", "=", activity.getId()));
return Result.success(); return Result.success();
} }
@@ -185,11 +205,17 @@ public class TrainSignUpManageController {
return Result.success().addData(dataMap); return Result.success().addData(dataMap);
} }
/**
* 接收主活动及 courseList 子活动列表;每个 responsibleList 元素传 userId、mobile。
* responsibleList 为 null 保留历史联系人,空列表清空,多人关系由 service 校验并保存。
* 返回 Result,code=0 表示主活动、子活动和负责人已在同一事务中保存成功。
*/
@At @At
@Ok("json:full") @Ok("json:full")
@ApiOperation("品牌活动新增/修改") @ApiOperation("品牌活动新增/修改")
@SaCheckPermission(value = {"trainSignUp.manage", "trainSignUp.applyActivity"}, mode = SaMode.OR) @SaCheckPermission(value = {"trainSignUp.manage", "trainSignUp.applyActivity"}, mode = SaMode.OR)
@SLog(tag = "品牌活动-活动管理", msg = "新增/修改活动") @SLog(tag = "品牌活动-活动管理", msg = "新增/修改活动")
@Aop(TransAop.READ_COMMITTED)
public Result doHandle(TrainSignUpActivity activity) { public Result doHandle(TrainSignUpActivity activity) {
if (StrUtil.isBlank(activity.getId())) { if (StrUtil.isBlank(activity.getId())) {
cleanNewActivityData(activity); cleanNewActivityData(activity);
@@ -1,55 +1,26 @@
package com.budwk.app.zhgh.activity.trainSignUp.controller.statistics; 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.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.page.Pagination;
import com.budwk.app.base.param.PageForm; import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result; import com.budwk.app.base.result.Result;
import com.budwk.app.base.utils.CommonDownloadUtil; 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 com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityStatisticsService;
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.poi.hssf.usermodel.HSSFWorkbook;
import org.apache.poi.ss.usermodel.Workbook; 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.ioc.aop.Aop;
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.Inject; import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean; 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.lang.util.NutMap;
import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param; import org.nutz.mvc.annotation.Param;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException; 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 * @author zxy
@@ -63,12 +34,8 @@ import java.util.zip.ZipOutputStream;
@At("/platform/trainSignUp/statistics") @At("/platform/trainSignUp/statistics")
public class TrainSignUpActivityStatisticsController { public class TrainSignUpActivityStatisticsController {
@Inject
private TrainSignUpActivityService trainSignUpActivityManageService;
@Inject @Inject
private TrainSignUpActivityStatisticsService trainSignUpActivityStatisticsService; private TrainSignUpActivityStatisticsService trainSignUpActivityStatisticsService;
@Inject
private Dao dao;
@At("") @At("")
@SaCheckPermission("trainSignUp.statistics") @SaCheckPermission("trainSignUp.statistics")
@@ -76,231 +43,113 @@ public class TrainSignUpActivityStatisticsController {
public void index() { public void index() {
} }
/**
* @param pageForm 分页参数,pageNumber 为页码,pageSize 为每页条数
* @param activityId 主活动ID,为空时返回空分页
* @return Result.data 为 Pagination,list 和 totalCount 均受当前登录人的负责人范围限制
*/
@At @At
@ApiOperation("分页查询") @ApiOperation("分页查询")
@SaCheckPermission("trainSignUp.statistics") @SaCheckPermission("trainSignUp.statistics")
public Result pageData(PageForm pageForm, public Result pageData(PageForm pageForm, @Param("activityId") String activityId) {
@Param(value = "activityId") String activityId) {
Pagination pagination = trainSignUpActivityStatisticsService.pageData(pageForm, activityId); Pagination pagination = trainSignUpActivityStatisticsService.pageData(pageForm, activityId);
return Result.success(pagination); return Result.success(pagination);
} }
/** /**
* 活动list * @param year 查询年度,为空时不限制年度
* * @return Result.data 为可管理的主活动列表,id/activityName 用于活动下拉框
* @param year 年度
* @return
*/ */
@At @At
@ApiOperation("活动列表") @ApiOperation("活动列表")
@SaCheckPermission("trainSignUp.statistics") @SaCheckPermission("trainSignUp.statistics")
public Result activityList(@Param(value = "year") Integer year) { public Result activityList(@Param("year") Integer year) {
List<TrainSignUpActivity> list = dao.query(TrainSignUpActivity.class, Cnd.NEW().andEX("year", "=", year).desc("activityStartTime")); return Result.success(trainSignUpActivityStatisticsService.activityList(year, false));
if(!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_CHAIRMAN.name(), RoleConstant.SCHOOL_UNION_ACTIVITY_ADMIN.name())) {
// 获取当前登录用户的工会
String unionId = SecurityUtil.getUnionId();
// 获取当前登录用户的社团
List<String> clubIds = SecurityUtil.getClubIds();
List<TrainSignUpActivity> 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);
} }
/** /**
* 培训班报名人员list * @param courseId 子活动ID,必须在当前登录人的管理范围内
* * @return Result.data 为报名人员数组,包含姓名、工号、单位、联系方式、报名状态及动态报名字段
* @param courseId
* @return
*/ */
@At @At
@ApiOperation("报名人员列表") @ApiOperation("报名人员列表")
@SaCheckPermission("trainSignUp.statistics") @SaCheckPermission("trainSignUp.statistics")
public Result registerUserList(@Param(value = "courseId") String courseId) { public Result registerUserList(@Param("courseId") String courseId) {
return Result.success(trainSignUpActivityStatisticsService.registerUserList(courseId)); return Result.success(trainSignUpActivityStatisticsService.registerUserList(courseId));
} }
/**
* @param courseId 子活动ID,必须在当前登录人的管理范围内
* @return Result.data 为动态列配置数组,label 为列标题,prop 为报名数据字段名
*/
@At @At
@ApiOperation("报名动态列") @ApiOperation("报名动态列")
@SaCheckPermission("trainSignUp.statistics") @SaCheckPermission("trainSignUp.statistics")
public Object getTaleColumnInfo(@Param(value = "courseId") String courseId) { public Object getTaleColumnInfo(@Param("courseId") String courseId) {
return Result.success(trainSignUpActivityStatisticsService.getTaleColumnInfo(courseId)); return Result.success(trainSignUpActivityStatisticsService.getTaleColumnInfo(courseId));
} }
/**
* @param courseId 子活动ID,必须在当前登录人的管理范围内
* @return Result.data 为时段数组,id 为时段ID,courseStartTime/courseEndTime 为起止时间
*/
@At @At
@ApiOperation("获取时间信息") @ApiOperation("获取时间信息")
@SaCheckPermission("trainSignUp.statistics") @SaCheckPermission("trainSignUp.statistics")
public Result selectTimes(@Param("courseId") String courseId) { public Result selectTimes(@Param("courseId") String courseId) {
List<TrainSignUpActivityCourse> activityCourses = dao.query(TrainSignUpActivityCourse.class, Cnd.where("courseId", "=", courseId).asc("courseStartTime")); return Result.success(trainSignUpActivityStatisticsService.selectTimes(courseId));
return Result.success(activityCourses);
} }
/** /**
* 培训班上课签到信息 * @param pageForm 分页参数,pageNumber 为页码,pageSize 为每页条数
* * @param courseId 有管理权限的子活动ID
* @param courseId * @param timeId 子活动时段ID
* @return * @return Result.data 为签到分页,list 中 isAttend/attendTime 为签到状态及时间
*/ */
@At @At
@ApiOperation("获取签到信息") @ApiOperation("获取签到信息")
@SaCheckPermission("trainSignUp.statistics") @SaCheckPermission("trainSignUp.statistics")
public Result getSignInfo(PageForm pageForm, public Result getSignInfo(PageForm pageForm, @Param("courseId") String courseId, String timeId) {
@Param("courseId") String courseId,
String timeId) {
return Result.success(trainSignUpActivityStatisticsService.getSignInfo(pageForm, courseId, timeId)); return Result.success(trainSignUpActivityStatisticsService.getSignInfo(pageForm, courseId, timeId));
} }
/**
* @param courseId 有管理权限的子活动ID
* @param openOtherUnion true 开放给其他工会报名,false 关闭
* @return Result,code=0 表示状态已更新
*/
@At @At
@ApiOperation("开放报名") @ApiOperation("开放报名")
@SaCheckPermission("trainSignUp.statistics") @SaCheckPermission("trainSignUp.statistics")
@Aop(TransAop.READ_COMMITTED)
public Result signChange(@Param("courseId") String courseId, @Param("openOtherUnion") Boolean openOtherUnion) { public Result signChange(@Param("courseId") String courseId, @Param("openOtherUnion") Boolean openOtherUnion) {
dao.update(TrainSignUpCourse.class, Chain.make("openOtherUnion", openOtherUnion) trainSignUpActivityStatisticsService.signChange(courseId, openOtherUnion);
, Cnd.where("id", "=", courseId));
return Result.success(); return Result.success();
} }
/**
* @param id 签到记录ID,service 根据记录所属子活动校验当前登录人的权限
* @return Result,code=0 表示签到状态已切换
*/
@At @At
@ApiOperation("设置签到") @ApiOperation("设置签到")
@SaCheckPermission("trainSignUp.statistics") @SaCheckPermission("trainSignUp.statistics")
@Aop(TransAop.READ_COMMITTED)
public Result adjustSign(@Param("id") String id) { public Result adjustSign(@Param("id") String id) {
TrainSignUpUserCourse userCourse = dao.fetch(TrainSignUpUserCourse.class, id); trainSignUpActivityStatisticsService.adjustSign(id);
if(userCourse.isAttend()) {
userCourse.setAttend(false);
userCourse.setAttendTime(null);
} else {
userCourse.setAttend(true);
userCourse.setAttendTime(userCourse.getCourseStartTime());
}
dao.update(userCourse);
return Result.success(); return Result.success();
} }
/**
* @param activityId 主活动ID,仅导出当前登录人有管理权限的子活动报名记录
* @param response 文件下载响应,输出 .xlsx 文件而非 JSON
*/
@At @At
@Ok("void") @Ok("void")
@ApiOperation("导出签到名单") @ApiOperation("导出签到名单")
@SaCheckPermission("trainSignUp.statistics") @SaCheckPermission("trainSignUp.statistics")
public void exportSignUser(@Param(value = "activityId") String activityId, public void exportSignUser(@Param("activityId") String activityId, HttpServletResponse response) throws IOException {
HttpServletResponse response) throws IOException { NutMap exportData = trainSignUpActivityStatisticsService.buildSignUserExport(activityId);
try { CommonDownloadUtil.download(exportData.getString("fileName"), exportData.getAs("workbook", Workbook.class), response);
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<NutMap> userList = trainSignUpActivityManageService.listMap(sql);
List<TrainSignUpCourse> courseList = dao.query(TrainSignUpCourse.class, Cnd.where("activityId", "=", activityId));
List<TrainSignUpType> trainSignUpTypeList = dao.query(TrainSignUpType.class, Cnd.NEW());
dao.fetchLinks(trainSignUpTypeList, "trainMobileSignColumnList", Cnd.NEW().asc("columnIndex"));
Map<String, TrainSignUpType> typeMap = trainSignUpTypeList.stream().collect(Collectors.toMap(TrainSignUpType::getId, o -> o));
List<Map<String, Object>> sheetsList = new ArrayList<>();
List<ExcelExportEntity> 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<NutMap> 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<ExcelExportEntity> 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<NutMap> 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<NutMap> 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<String, Object> 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<String, Object> map : sheetsList) {
ExcelExportService service = new ExcelExportService();
service.createSheetForMap(workbook, (ExportParams) map.get("title"), (List<ExcelExportEntity>) map.get("entity"), (Collection<?>) map.get("data"));
}
CommonDownloadUtil.download(activity.getActivityName() + "报名人员名单" + ".xls", workbook, response);
} catch (Exception e) {
e.printStackTrace();
}
} }
} }
@@ -96,6 +96,13 @@ public class TrainSignUpCourse extends BaseModel implements Serializable {
@Many(field = "courseId") @Many(field = "courseId")
private List<TrainSignUpActivityCourse> courseTimeList; private List<TrainSignUpActivityCourse> courseTimeList;
/**
* 完整负责人关系。null 表示旧数据尚未设置,空列表表示主动清空负责人。
* 关系单独保存,后续可按 userId 精确查询,无需解析姓名或 JSON。
*/
@Many(field = "courseId")
private List<TrainSignUpCourseResponsible> responsibleList;
//已报人数 //已报人数
private Integer hasRegisterNum; private Integer hasRegisterNum;
@@ -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;
}
@@ -20,6 +20,35 @@ import java.util.List;
*/ */
public interface TrainSignUpActivityService extends BaseService<TrainSignUpActivity> { public interface TrainSignUpActivityService extends BaseService<TrainSignUpActivity> {
/** 当前登录人是否具有活动关键内容编辑权限(三类管理员任意一种)。 */
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<TrainSignUpActiv
*/ */
void edit(TrainSignUpActivity activity); void edit(TrainSignUpActivity activity);
/**
* 删除活动及其子活动、负责人关系、报名和首页记录。
*
* @param id 主活动ID;已经设为模板的活动需先取消模板
*/
void deleteActivity(String id);
/** /**
* 更新活动状态 * 更新活动状态
* *
@@ -3,6 +3,8 @@ package com.budwk.app.zhgh.activity.trainSignUp.service;
import com.budwk.app.base.page.Pagination; import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm; import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.service.BaseService; import com.budwk.app.base.service.BaseService;
import com.budwk.app.zhgh.activity.trainSignUp.models.TrainSignUpActivity;
import com.budwk.app.zhgh.activity.trainSignUp.models.TrainSignUpActivityCourse;
import com.budwk.app.zhgh.activity.trainSignUp.models.TrainSignUpUser; import com.budwk.app.zhgh.activity.trainSignUp.models.TrainSignUpUser;
import org.nutz.lang.util.NutMap; import org.nutz.lang.util.NutMap;
@@ -17,44 +19,122 @@ import java.util.Map;
public interface TrainSignUpActivityStatisticsService extends BaseService<TrainSignUpUser> { public interface TrainSignUpActivityStatisticsService extends BaseService<TrainSignUpUser> {
/** /**
* 统计分页 * 查询当前用户可管理的主活动,只包含至少一个本人负责子活动的主活动。
* 系统管理员、校工会管理员、校工会活动管理员不受负责人范围限制,仍保留年度及启用状态条件。
* *
* @param pageForm * @param year 年度,为空时不限制年度
* @param activityId * @param enabledOnly true 仅查询已启用活动,人员调整页面使用;false 保留统计页面原范围
* @return * @return 主活动实体列表;没有负责记录时返回空列表
*/
List<TrainSignUpActivity> activityList(Integer year, boolean enabledOnly);
/**
* 查询人员调整的目标子活动,限制为当前用户负责的子活动。
*
* @param activityId 主活动ID,为空时返回空列表
* @return 子活动列表,id/courseName 为标识及名称,registerNum/hasWaitingNum 为已报及候补人数
*/
List<NutMap> getCourse(String activityId);
/**
* 查询有管理权限的子活动时段。
*
* @param courseId 子活动ID,必须存在且在当前用户的管理范围内
* @return 时段实体列表,id 为时段ID,courseStartTime/courseEndTime 为起止时间
*/
List<TrainSignUpActivityCourse> 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); Pagination pageData(PageForm pageForm, String activityId);
/** /**
* 该课程下的报名人员信息 * 查询有管理权限的子活动报名明细,并展开动态报名字段。
* *
* @param courseId * @param courseId 子活动ID,不能为空,必须在当前登录人的管理范围内
* @return * @return 报名人员数组,包含姓名、工号、单位、联系方式、state 报名状态及动态字段
*/ */
List<NutMap> registerUserList(String courseId); List<NutMap> registerUserList(String courseId);
/**
* @param courseId 有管理权限的子活动ID
* @return 动态列配置数组,label 为列标题,prop 为报名明细对应字段名
*/
List<NutMap> getTaleColumnInfo(String courseId); List<NutMap> getTaleColumnInfo(String courseId);
/** /**
* 该课程下的报名人员信息 * 按工会、单位和姓名/工号搜索有管理权限的子活动报名人员。
* *
* @param courseId * @param courseId 有管理权限的子活动ID,不能为空
* @return * @param unionId 工会ID,可为空
* @param unitId 单位ID,可为空
* @param searchName 保留现有接口参数,姓名和工号统一使用 searchKeyword 搜索
* @param searchKeyword 姓名或工号关键字,可为空
* @return 报名人员数组,userId 为报名人员用户ID,state 为报名状态
*/ */
List<NutMap> registerUserList(String courseId, String unionId, String unitId, String searchName, String searchKeyword); List<NutMap> registerUserList(String courseId, String unionId, String unitId, String searchName, String searchKeyword);
/** /**
* 获取每个课程的签到情况 * 查询有管理权限的子活动指定时段签到分页。
* *
* @param courseId * @param pageForm 分页参数,pageNumber 为页码,pageSize 为每页条数
* @return k->每个培训班每节课的上课时间 v->上课记录list * @param courseId 有管理权限的子活动ID
* @param timeId 子活动时段ID
* @return Pagination,list 为签到人员,isAttend/attendTime 为签到状态及时间
*/ */
Pagination getSignInfo(PageForm pageForm, String courseId, String timeId); Pagination getSignInfo(PageForm pageForm, String courseId, String timeId);
/** /**
* 报名人员list 导出 * 查询主活动下可管理子活动的基础报名导出数据。
* @param activityId * @param activityId 主活动ID
* @return * @return 报名人员数组,包含姓名、工号、联系方式及子活动名称,不包含管理范围以外的子活动
*/ */
List<NutMap> baoMingUserList(String activityId); List<NutMap> baoMingUserList(String activityId);
@@ -1,13 +1,18 @@
package com.budwk.app.zhgh.activity.trainSignUp.service.impl; package com.budwk.app.zhgh.activity.trainSignUp.service.impl;
import cn.hutool.core.util.StrUtil; 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.page.Pagination;
import com.budwk.app.base.param.PageForm; import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.service.impl.BaseServiceImpl; 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_activity;
import com.budwk.app.sys.models.Sys_home_template; 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.sys.views.View_user;
import com.budwk.app.web.commons.auth.utils.SecurityUtil; 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.FamilyActivity;
import com.budwk.app.zhgh.activity.family.models.FamilyCourse; import com.budwk.app.zhgh.activity.family.models.FamilyCourse;
import com.budwk.app.zhgh.activity.family.models.FamilyType; import com.budwk.app.zhgh.activity.family.models.FamilyType;
@@ -51,9 +56,227 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl<TrainSignUpA
super(dao); super(dao);
} }
@Override
public boolean canEditActivityBasic() {
return AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name(),
RoleConstant.SCHOOL_UNION_ACTIVITY_ADMIN.name());
}
/** 整场编辑、删除和启停均会修改关键内容,不能仅凭页面菜单权限放行。 */
private void requireActivityBasicPermission() {
if (!canEditActivityBasic()) {
throw new BaseException("活动关键信息仅管理员可修改,请通过子活动管理维护签到设置");
}
}
/** 每次读写都重新检查实际归属和负责人关系,禁止信任前端传入的角色或负责人名单。 */
private TrainSignUpCourse requireManagedCourse(String activityId, String courseId) {
if (StrUtil.isBlank(activityId) || StrUtil.isBlank(courseId)) {
throw new BaseException("主活动和子活动ID不能为空");
}
TrainSignUpCourse course = dao().fetch(TrainSignUpCourse.class, courseId);
if (course == null || !activityId.equals(course.getActivityId()) || fetch(activityId) == null) {
throw new BaseException("子活动不存在或不属于当前活动");
}
if (!canEditActivityBasic()) {
String userId = SecurityUtil.getUserId();
if (StrUtil.isBlank(userId) || dao().count(TrainSignUpCourseResponsible.class,
Cnd.where("activityId", "=", activityId).and("courseId", "=", courseId)
.and("userId", "=", userId)) == 0) {
throw new BaseException("无权管理该子活动,请检查负责人设置");
}
}
return course;
}
@Override
public Pagination courseManagePageData(PageForm pageForm, String activityId, String courseName) {
if (StrUtil.isBlank(activityId) || fetch(activityId) == null) {
throw new BaseException("主活动不存在");
}
Cnd cnd = Cnd.where("course.activityId", "=", activityId).and(Cnd.likeEX("course.courseName", courseName));
if (!canEditActivityBasic()) {
String userId = SecurityUtil.getUserId();
List<TrainSignUpCourseResponsible> 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<NutMap> 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<String> 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<Double> 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<String> ids = new HashSet<>();
Set<String> 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 @Override
@Aop(TransAop.READ_COMMITTED) @Aop(TransAop.READ_COMMITTED)
public void add(TrainSignUpActivity activity, TrainSignUpCourse course) { public void add(TrainSignUpActivity activity, TrainSignUpCourse course) {
// 先校验所有负责人,任何一行存在无效用户或联系方式时均不写入活动。
validateCourseResponsibles(activity.getCourseList());
// 新建活动(包括从模板另存)统一归入活动列表,不能继承来源活动的模板状态。 // 新建活动(包括从模板另存)统一归入活动列表,不能继承来源活动的模板状态。
activity.setTemplateStatus(TrainSignUpActivity.TEMPLATE_STATUS_UNSET); activity.setTemplateStatus(TrainSignUpActivity.TEMPLATE_STATUS_UNSET);
dao().insert(activity); dao().insert(activity);
@@ -68,6 +291,7 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl<TrainSignUpA
v.setActivityId(activity.getId()); v.setActivityId(activity.getId());
v.setOpenOtherUnion(false); v.setOpenOtherUnion(false);
dao().insert(v); dao().insert(v);
saveCourseResponsibles(v);
this.setCourseTimeAndInsert(v); this.setCourseTimeAndInsert(v);
} }
@@ -80,6 +304,22 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl<TrainSignUpA
@Override @Override
@Aop(TransAop.READ_COMMITTED) @Aop(TransAop.READ_COMMITTED)
public void edit(TrainSignUpActivity activity) { public void edit(TrainSignUpActivity activity) {
requireActivityBasicPermission();
if (fetch(activity.getId()) == null) {
throw new BaseException("活动不存在");
}
validateCourseResponsibles(activity.getCourseList());
// 子活动只能属于当前活动,防止提交其他活动的ID覆盖其负责人关系。
if (activity.getCourseList() != null) {
for (TrainSignUpCourse course : activity.getCourseList()) {
if (StrUtil.isNotBlank(course.getId())) {
TrainSignUpCourse oldCourse = dao().fetch(TrainSignUpCourse.class, course.getId());
if (oldCourse == null || !activity.getId().equals(oldCourse.getActivityId())) {
throw new BaseException("子活动不存在或不属于当前活动,请刷新后重试");
}
}
}
}
// 1. 更新主活动 // 1. 更新主活动
update(activity); update(activity);
// 2. 处理类型限制 // 2. 处理类型限制
@@ -90,7 +330,7 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl<TrainSignUpA
} }
// 3. 处理课程列表 // 3. 处理课程列表
List<TrainSignUpCourse> courseList = activity.getCourseList(); List<TrainSignUpCourse> courseList = activity.getCourseList();
if (Lang.isEmpty(courseList)) { if (courseList == null) {
return; return;
} }
@@ -108,12 +348,15 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl<TrainSignUpA
Set<String> deletedIds = Sets.difference(oldIds, newIds).immutableCopy(); Set<String> deletedIds = Sets.difference(oldIds, newIds).immutableCopy();
if(Lang.isNotEmpty(deletedIds)) { if(Lang.isNotEmpty(deletedIds)) {
// 删除子活动时同步清理负责人,避免后续按 userId 查到已删除的业务。
dao().clear(TrainSignUpCourseResponsible.class, Cnd.where("courseId", "in", deletedIds));
dao().clear(TrainSignUpCourse.class, Cnd.where("id", "in", deletedIds)); dao().clear(TrainSignUpCourse.class, Cnd.where("id", "in", deletedIds));
} }
for (TrainSignUpCourse course : courseList) { for (TrainSignUpCourse course : courseList) {
course.setActivityId(activity.getId()); course.setActivityId(activity.getId());
dao().insertOrUpdate(course); dao().insertOrUpdate(course);
saveCourseResponsibles(course);
if (Lang.isNotEmpty(course.getCourseTimeList())) { if (Lang.isNotEmpty(course.getCourseTimeList())) {
handleCourseTimes(activity, course); handleCourseTimes(activity, course);
@@ -128,6 +371,131 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl<TrainSignUpA
} }
} }
/**
* 校验多选负责人并补齐可信姓名;前端仅需传 userId 和本次活动使用的 mobile。
* null 保留历史联系人,空列表主动清空,同一子活动不允许重复选择同一 userId。
*/
private void validateCourseResponsibles(List<TrainSignUpCourse> courses) {
if (courses == null) {
return;
}
Set<String> userIds = new HashSet<>();
for (TrainSignUpCourse course : courses) {
if (course == null) {
throw new BaseException("子活动信息不能为空");
}
if (course.getResponsibleList() == null) {
continue;
}
Set<String> 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<String, Sys_user> 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<TrainSignUpCourseResponsible> 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<TrainSignUpCourse> courses) {
if (courses.isEmpty()) {
return;
}
List<String> courseIds = courses.stream().map(TrainSignUpCourse::getId).collect(Collectors.toList());
List<TrainSignUpCourseResponsible> responsibles = dao().query(TrainSignUpCourseResponsible.class,
Cnd.where("courseId", "in", courseIds).asc("orderNum"));
Set<String> userIds = responsibles.stream().map(TrainSignUpCourseResponsible::getUserId).collect(Collectors.toSet());
Map<String, Sys_user> 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<String, List<TrainSignUpCourseResponsible>> byCourse = responsibles.stream()
.collect(Collectors.groupingBy(TrainSignUpCourseResponsible::getCourseId));
for (TrainSignUpCourse course : courses) {
List<TrainSignUpCourseResponsible> 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) { private void handleCourseTimes(TrainSignUpActivity activity, TrainSignUpCourse course) {
String courseId = course.getId(); String courseId = course.getId();
String activityId = activity.getId(); String activityId = activity.getId();
@@ -223,6 +591,7 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl<TrainSignUpA
return u; return u;
} }
@Aop(TransAop.READ_COMMITTED)
private void setCourseTimeAndInsert(TrainSignUpCourse course) { private void setCourseTimeAndInsert(TrainSignUpCourse course) {
course.getCourseTimeList().forEach(courseTime -> { course.getCourseTimeList().forEach(courseTime -> {
Calendar calendar = Calendar.getInstance(); Calendar calendar = Calendar.getInstance();
@@ -250,7 +619,14 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl<TrainSignUpA
@Override @Override
@Aop(TransAop.READ_COMMITTED) @Aop(TransAop.READ_COMMITTED)
public void updateActivityStatus(TrainSignUpActivity activity) { public void updateActivityStatus(TrainSignUpActivity activity) {
requireActivityBasicPermission();
if (StrUtil.isBlank(activity.getId()) || fetch(activity.getId()) == null) {
throw new BaseException("活动不存在");
}
updateIgnoreNull(activity); updateIgnoreNull(activity);
// 活动启停及首页可见状态在同一个事务内更新,controller 仅负责接收参数和返回结果。
dao().update(Sys_home_activity.class, Chain.make("enable", !activity.isDisabled()),
Cnd.where("id", "=", activity.getId()));
} }
@Override @Override
@@ -270,6 +646,7 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl<TrainSignUpA
activity.setCourseList(courseArray); activity.setCourseList(courseArray);
List<TrainSignUpCourse> courseList = activity.getCourseList(); List<TrainSignUpCourse> courseList = activity.getCourseList();
fillCourseResponsibles(courseList);
courseList.forEach(c -> { courseList.forEach(c -> {
if (StrUtil.isNotBlank(c.getCourseType())) { if (StrUtil.isNotBlank(c.getCourseType())) {
@@ -285,9 +662,36 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl<TrainSignUpA
return Lang.obj2nutmap(activity); return Lang.obj2nutmap(activity);
} }
/**
* pageForm 为页码和每页条数,cnd 为已有活动筛选条件;返回原分页结构及活动列表。
* list 中新增 canManageCourse 布尔值,仅非校工会管理角色且负责当前活动的用户为 true。
* 该字段控制子活动入口显示,实际读写仍由 requireManagedCourse 校验负责人关系。
*/
@Override @Override
public Pagination pageData(PageForm pageForm, Cnd cnd) { public Pagination pageData(PageForm pageForm, Cnd cnd) {
Pagination pagination = listPageLinks(pageForm.getPageNumber(), pageForm.getPageSize(), cnd, "^(courseList)$"); Pagination pagination = listPageLinks(pageForm.getPageNumber(), pageForm.getPageSize(), cnd, "^(courseList)$");
List<TrainSignUpActivity> activities = pagination.getList(TrainSignUpActivity.class);
boolean canEditBasic = canEditActivityBasic();
String userId = SecurityUtil.getUserId();
Map<String, Set<String>> responsibleCourses = new HashMap<>();
// 只批量查询当前页活动的本人负责人关系,避免逐行查询,也不把普通用户当作负责人。
if (!canEditBasic && StrUtil.isNotBlank(userId) && !activities.isEmpty()) {
List<String> activityIds = activities.stream().map(TrainSignUpActivity::getId).toList();
List<TrainSignUpCourseResponsible> 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<NutMap> rows = activities.stream().map(activity -> {
Set<String> 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; return pagination;
} }
@@ -618,7 +1022,7 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl<TrainSignUpA
if (SecurityUtil.getUnionId().equals(o.getHostUnionId())) { if (SecurityUtil.getUnionId().equals(o.getHostUnionId())) {
return true; return true;
} else { } else {
int compare = cn.hutool.core.date.DateUtil.compare(cn.hutool.core.date.DateUtil.date(), cn.hutool.core.date.DateUtil.parse(o.getInterTime()), "yyyy-MM-dd HH:mm"); int compare = DateUtil.compare(DateUtil.date(), DateUtil.parse(o.getInterTime()), "yyyy-MM-dd HH:mm");
return compare >= 0; return compare >= 0;
} }
} }
@@ -1,25 +1,42 @@
package com.budwk.app.zhgh.activity.trainSignUp.service.impl; 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.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.page.Pagination;
import com.budwk.app.base.param.PageForm; import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.service.impl.BaseServiceImpl; import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.sys.models.Sys_user; 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.FamilyType;
import com.budwk.app.zhgh.activity.family.models.FamilyUser; import com.budwk.app.zhgh.activity.family.models.FamilyUser;
import com.budwk.app.zhgh.activity.trainSignUp.models.*; import com.budwk.app.zhgh.activity.trainSignUp.models.*;
import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityStatisticsService; 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 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.Cnd;
import org.nutz.dao.Dao; import org.nutz.dao.Dao;
import org.nutz.dao.Sqls; import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql; import org.nutz.dao.sql.Sql;
import org.nutz.dao.util.cri.SqlExpressionGroup; import org.nutz.dao.util.cri.SqlExpressionGroup;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.json.Json; import org.nutz.json.Json;
import org.nutz.lang.Lang; import org.nutz.lang.Lang;
import org.nutz.lang.util.NutMap; import org.nutz.lang.util.NutMap;
import java.io.IOException;
import java.util.*; import java.util.*;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -37,8 +54,197 @@ public class TrainSignUpActivityStatisticsServiceImpl extends BaseServiceImpl<Tr
super(dao); super(dao);
} }
/** 系统管理员、校工会管理员、校工会活动管理员任意一种角色可管理全部,其他角色按负责人控制。 */
private boolean hasAllTrainSignUpScope() {
return AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name(),
RoleConstant.SCHOOL_UNION_ACTIVITY_ADMIN.name());
}
/**
* 将负责人范围放入数据库查询,使分页数据与总数、活动下拉框和导出保持一致。
* idField 仅由本 service 传入固定的表别名字段;操作人始终取登录 userId 并使用 SQL 参数绑定。
* activityLevel=true 按主活动筛选,false 按子活动筛选;没有登录身份时返回空范围。
*/
private void applyResponsibleScope(Sql sql, String idField, boolean activityLevel) {
if (hasAllTrainSignUpScope()) {
sql.setSourceSql(sql.getSourceSql().replace("$responsibleScope", ""));
return;
}
String userId = SecurityUtil.getUserId();
if (StrUtil.isBlank(userId)) {
sql.setSourceSql(sql.getSourceSql().replace("$responsibleScope", " AND 1=0 "));
return;
}
// 同时关联实际子活动,已删除子活动或活动归属不一致的关系不能提供管理权限。
String scope = " AND EXISTS (SELECT 1 FROM train_sign_up_course_responsible responsible "
+ "INNER JOIN train_sign_up_course responsibleCourse ON responsibleCourse.id=responsible.courseId "
+ "AND responsibleCourse.activityId=responsible.activityId WHERE "
+ (activityLevel ? "responsibleCourse.activityId=" : "responsibleCourse.id=")
+ idField + " AND responsible.userId=@responsibleUserId) ";
// Nutz 不会解析 setVar 字符串中的 @参数,必须先替换原始SQL并重新解析,再绑定用户ID。
sql.setSourceSql(sql.getSourceSql().replace("$responsibleScope", scope));
sql.setParam("responsibleUserId", userId);
}
/** 按实际子活动校验详情及操作权限;缺少ID不能省略查询条件后查询全部报名人员。 */
private TrainSignUpCourse requireCourseScope(String courseId) {
if (StrUtil.isBlank(courseId)) {
throw new BaseException("子活动ID不能为空");
}
TrainSignUpCourse course = dao().fetch(TrainSignUpCourse.class, courseId);
if (course == null) {
throw new BaseException("子活动不存在");
}
if (!hasAllTrainSignUpScope()) {
String userId = SecurityUtil.getUserId();
if (StrUtil.isBlank(userId) || dao().count(TrainSignUpCourseResponsible.class,
Cnd.where("courseId", "=", courseId).and("activityId", "=", course.getActivityId())
.and("userId", "=", userId)) == 0) {
throw new BaseException("无权查看或操作该子活动,请检查负责人设置");
}
}
return course;
}
/** 主活动ID用于校验归属,不能将其他活动的子活动作为调整来源或目标。 */
private void requireActivityMatch(String activityId, TrainSignUpCourse course) {
if (StrUtil.isBlank(activityId) || !activityId.equals(course.getActivityId())) {
throw new BaseException("子活动不属于当前主活动");
}
}
@Override
public List<TrainSignUpActivity> 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<NutMap> 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<NutMap> 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<TrainSignUpActivityCourse> 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<TrainSignUpActivityCourse> times = dao().query(TrainSignUpActivityCourse.class,
Cnd.where("activityId", "=", activityId).and("courseId", "=", newCourseId));
dao().clear(TrainSignUpUserCourse.class, oldCnd);
List<TrainSignUpUserCourse> 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 @Override
public Pagination pageData(PageForm pageForm, String activityId) { 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(""" Sql sql = Sqls.create("""
SELECT SELECT
tsuc.id, tsuc.id,
@@ -61,9 +267,11 @@ public class TrainSignUpActivityStatisticsServiceImpl extends BaseServiceImpl<Tr
train_sign_up_type type on tsuc.courseType = type.id train_sign_up_type type on tsuc.courseType = type.id
WHERE WHERE
tsuc.activityId = @activityId tsuc.activityId = @activityId
$responsibleScope
ORDER BY tsuc.orderNum ORDER BY tsuc.orderNum
"""); """);
sql.setParam("activityId", activityId); sql.setParam("activityId", activityId);
applyResponsibleScope(sql, "tsuc.id", false);
Pagination pagination = listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql); Pagination pagination = listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql);
List<NutMap> courseList = pagination.getList(); List<NutMap> courseList = pagination.getList();
courseList.forEach(c -> { courseList.forEach(c -> {
@@ -75,6 +283,7 @@ public class TrainSignUpActivityStatisticsServiceImpl extends BaseServiceImpl<Tr
@Override @Override
public List<NutMap> registerUserList(String courseId) { public List<NutMap> registerUserList(String courseId) {
requireCourseScope(courseId);
Sql sql = Sqls.create(""" Sql sql = Sqls.create("""
SELECT SELECT
u.loginname, u.loginname,
@@ -92,11 +301,13 @@ public class TrainSignUpActivityStatisticsServiceImpl extends BaseServiceImpl<Tr
train_sign_up_user tsuu train_sign_up_user tsuu
LEFT JOIN `vw_user` u ON u.id = tsuu.userId LEFT JOIN `vw_user` u ON u.id = tsuu.userId
$condition $condition
$responsibleScope
ORDER BY FIELD( tsuu.state, 1, 3, 2, 4 ),tsuu.signUpTime desc,u.unionid desc, u.unitid desc ORDER BY FIELD( tsuu.state, 1, 3, 2, 4 ),tsuu.signUpTime desc,u.unionid desc, u.unitid desc
"""); """);
Cnd cnd = Cnd.NEW(); Cnd cnd = Cnd.NEW();
cnd.andEX("tsuu.courseId", "=", courseId); cnd.and("tsuu.courseId", "=", courseId);
sql.setCondition(cnd); sql.setCondition(cnd);
applyResponsibleScope(sql, "tsuu.courseId", false);
List<NutMap> list = listMap(sql); List<NutMap> list = listMap(sql);
for (NutMap o : list) { for (NutMap o : list) {
if(StrUtil.isBlank(o.getString("mobileColumnsValue"))) { if(StrUtil.isBlank(o.getString("mobileColumnsValue"))) {
@@ -114,7 +325,7 @@ public class TrainSignUpActivityStatisticsServiceImpl extends BaseServiceImpl<Tr
@Override @Override
public List<NutMap> getTaleColumnInfo(String courseId) { public List<NutMap> getTaleColumnInfo(String courseId) {
TrainSignUpCourse course = dao().fetch(TrainSignUpCourse.class, courseId); TrainSignUpCourse course = requireCourseScope(courseId);
TrainSignUpType upType = dao().fetch(TrainSignUpType.class, course.getCourseType()); TrainSignUpType upType = dao().fetch(TrainSignUpType.class, course.getCourseType());
dao().fetchLinks(upType, "trainMobileSignColumnList", Cnd.NEW().asc("columnIndex")); dao().fetchLinks(upType, "trainMobileSignColumnList", Cnd.NEW().asc("columnIndex"));
@@ -125,6 +336,7 @@ public class TrainSignUpActivityStatisticsServiceImpl extends BaseServiceImpl<Tr
@Override @Override
public List<NutMap> registerUserList(String courseId, String unionId, String unitId, String searchName, String searchKeyword) { public List<NutMap> registerUserList(String courseId, String unionId, String unitId, String searchName, String searchKeyword) {
requireCourseScope(courseId);
Sql sql = Sqls.create(""" Sql sql = Sqls.create("""
SELECT SELECT
tsuu.id, tsuu.id,
@@ -141,10 +353,11 @@ public class TrainSignUpActivityStatisticsServiceImpl extends BaseServiceImpl<Tr
train_sign_up_user tsuu train_sign_up_user tsuu
LEFT JOIN `vw_user` u ON u.id = tsuu.userId LEFT JOIN `vw_user` u ON u.id = tsuu.userId
$condition $condition
$responsibleScope
ORDER BY FIELD( tsuu.state, 1, 3, 2, 4 ),tsuu.signUpTime desc,u.unionid desc, u.unitid desc ORDER BY FIELD( tsuu.state, 1, 3, 2, 4 ),tsuu.signUpTime desc,u.unionid desc, u.unitid desc
"""); """);
Cnd cnd = Cnd.NEW(); Cnd cnd = Cnd.NEW();
cnd.andEX("tsuu.courseId", "=", courseId); cnd.and("tsuu.courseId", "=", courseId);
cnd.andEX("u.unionid", "=", unionId); cnd.andEX("u.unionid", "=", unionId);
cnd.andEX("u.unitid", "=", unitId); cnd.andEX("u.unitid", "=", unitId);
if (StrUtil.isNotBlank(searchKeyword)) { if (StrUtil.isNotBlank(searchKeyword)) {
@@ -153,11 +366,13 @@ public class TrainSignUpActivityStatisticsServiceImpl extends BaseServiceImpl<Tr
cnd.and(group); cnd.and(group);
} }
sql.setCondition(cnd); sql.setCondition(cnd);
applyResponsibleScope(sql, "tsuu.courseId", false);
return listMap(sql); return listMap(sql);
} }
@Override @Override
public Pagination getSignInfo(PageForm pageForm, String courseId, String timeId) { public Pagination getSignInfo(PageForm pageForm, String courseId, String timeId) {
requireCourseScope(courseId);
Sql sql = Sqls.create(""" Sql sql = Sqls.create("""
SELECT SELECT
tsuuc.id, tsuuc.id,
@@ -173,6 +388,7 @@ public class TrainSignUpActivityStatisticsServiceImpl extends BaseServiceImpl<Tr
`train_sign_up_user_course` tsuuc `train_sign_up_user_course` tsuuc
LEFT JOIN `vw_user` u ON u.id = tsuuc.userId LEFT JOIN `vw_user` u ON u.id = tsuuc.userId
$condition $condition
$responsibleScope
"""); """);
Cnd cnd = Cnd.NEW(); Cnd cnd = Cnd.NEW();
cnd.and("tsuuc.courseId", "=", courseId); cnd.and("tsuuc.courseId", "=", courseId);
@@ -180,6 +396,7 @@ public class TrainSignUpActivityStatisticsServiceImpl extends BaseServiceImpl<Tr
cnd.desc("isAttend"); cnd.desc("isAttend");
cnd.desc("attendTime"); cnd.desc("attendTime");
sql.setCondition(cnd); sql.setCondition(cnd);
applyResponsibleScope(sql, "tsuuc.courseId", false);
return listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql); return listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql);
} }
@@ -201,12 +418,134 @@ public class TrainSignUpActivityStatisticsServiceImpl extends BaseServiceImpl<Tr
LEFT JOIN `vw_user` u ON u.id = uu.userId LEFT JOIN `vw_user` u ON u.id = uu.userId
WHERE WHERE
uc.activityId = @activityId uc.activityId = @activityId
$responsibleScope
ORDER BY u.unitCode,u.unioncode,u.sex ORDER BY u.unitCode,u.unioncode,u.sex
"""); """);
sql.setParam("activityId", activityId); sql.setParam("activityId", activityId);
applyResponsibleScope(sql, "uc.id", false);
return listMap(sql); return listMap(sql);
} }
@Override
public NutMap buildSignUserExport(String activityId) {
if (StrUtil.isBlank(activityId)) {
throw new BaseException("请选择要导出的活动");
}
TrainSignUpActivity activity = dao().fetch(TrainSignUpActivity.class, activityId);
if (activity == null) {
throw new BaseException("活动不存在");
}
// 工作表和报名数据分别在SQL中约束负责人范围,不能仅根据前端可见活动判断导出权限。
Sql courseSql = Sqls.create("""
SELECT course.* FROM train_sign_up_course course
WHERE course.activityId=@activityId $responsibleScope
""");
courseSql.setParam("activityId", activityId);
applyResponsibleScope(courseSql, "course.id", false);
courseSql.setEntity(dao().getEntity(TrainSignUpCourse.class));
courseSql.setCallback(Sqls.callback.entities());
dao().execute(courseSql);
List<TrainSignUpCourse> 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<NutMap> userList = listMap(sql);
// 保留原导出公共列、动态报名列和图片处理,仅改变数据范围及Excel文件类型。
List<TrainSignUpType> types = dao().query(TrainSignUpType.class, Cnd.NEW());
dao().fetchLinks(types, "trainMobileSignColumnList", Cnd.NEW().asc("columnIndex"));
Map<String, TrainSignUpType> typeMap = types.stream().collect(Collectors.toMap(TrainSignUpType::getId, type -> type));
List<Map<String, Object>> sheetsList = new ArrayList<>();
List<ExcelExportEntity> 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<NutMap> 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<ExcelExportEntity> 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<NutMap> 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<String, Object> 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<String, Object> sheet : sheetsList) {
new ExcelExportService().createSheetForMap(workbook, (ExportParams) sheet.get("title"),
(List<ExcelExportEntity>) 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 @Override
public int queryCourseCount(String courseId, String courseType) { public int queryCourseCount(String courseId, String courseType) {
return this.queryCourseCount(courseId, courseType, null); return this.queryCourseCount(courseId, courseType, null);
@@ -1,6 +1,7 @@
package com.budwk.app.zhgh.club.controller.apply; package com.budwk.app.zhgh.club.controller.apply;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaMode;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.page.Pagination; import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm; import com.budwk.app.base.param.PageForm;
@@ -47,8 +48,19 @@ public class ClubUserClubListController {
public void index() { 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 @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) { public Result pageData(@Valid PageForm pageForm, Boolean hasJoin, String clubName, String clubType) {
Cnd cnd = Cnd.NEW(); Cnd cnd = Cnd.NEW();
cnd.and("dismiss", "=", false); cnd.and("dismiss", "=", false);
@@ -71,7 +83,7 @@ public class ClubUserClubListController {
} }
@At @At
@SaCheckPermission("club.join.list") @SaCheckPermission(value = {"club.join.list", "h5.club.join.list", "h5.club.join.apply"}, mode = SaMode.OR)
public Result getCount() { public Result getCount() {
// 获取所有社团 // 获取所有社团
List<SysClub> list = sysClubService.query(); List<SysClub> list = sysClubService.query();
@@ -96,7 +108,7 @@ public class ClubUserClubListController {
} }
@At @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) { public Result findOne(@Valid String id) {
ClubRegisterVo clubRegisterVo = sysClubService.findOne(id); ClubRegisterVo clubRegisterVo = sysClubService.findOne(id);
return Result.success(clubRegisterVo); return Result.success(clubRegisterVo);
@@ -74,10 +74,22 @@ public class ClubUserJoinApplyController {
public void index() { public void index() {
} }
/**
* 移动端首次入会先选择社团,已选社团或已有申请继续进入申请表单。
*
* @param clubId 已选择的社团ID
* @param bizId 已有入会申请ID
* @param taskId 重新提交申请时的流程任务ID
* @return 无申请上下文时跳转社团列表,否则渲染原申请页面
*/
@At("/h5") @At("/h5")
@SaCheckPermission("h5.club.join.apply") @SaCheckPermission("h5.club.join.apply")
@Ok("beetl:/platform/zhghh5/club/apply/index.html") @Ok("re")
public void h5Index() { 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 @At
@@ -2,12 +2,17 @@ package com.budwk.app.zhgh.club.controller.common;
import cn.dev33.satoken.annotation.SaCheckLogin; import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.annotation.SaCheckPermission; 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.constant.RoleConstant;
import com.budwk.app.base.result.Result; import com.budwk.app.base.result.Result;
import com.budwk.app.base.service.BaseService; import com.budwk.app.base.service.BaseService;
import com.budwk.app.bpm.enums.BpmProcessInstanceStatusEnum; import com.budwk.app.bpm.enums.BpmProcessInstanceStatusEnum;
import com.budwk.app.bpm.enums.BpmProcessTaskStatusEnum; import com.budwk.app.bpm.enums.BpmProcessTaskStatusEnum;
import com.budwk.app.flow.enums.ProcessInstanceStateEnum; 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.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil; import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.club.model.ClubUser; 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.dao.sql.Sql;
import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.AdaptBy;
import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok; 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.List;
import java.util.Locale;
import java.util.Set;
@IocBean @IocBean
@At("/platform/club/common") @At("/platform/club/common")
@@ -36,6 +48,36 @@ public class ClubCommonController {
@Inject @Inject
private SysClubService sysClubService; 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 @At
@SaCheckLogin @SaCheckLogin
@@ -37,6 +37,11 @@ public class SysClub extends BaseModel {
@NotEmpty(message = "社团名称不能为空") @NotEmpty(message = "社团名称不能为空")
private String clubName; private String clubName;
@Column
@Comment("社团缩略图")
@ColDefine(type = ColType.VARCHAR, width = 1000)
private String thumbnail;
@Column @Column
@Comment("社团编码") @Comment("社团编码")
@ColDefine(type = ColType.VARCHAR, width = 100) @ColDefine(type = ColType.VARCHAR, width = 100)
@@ -112,11 +112,14 @@ public class SysClubServiceImpl extends BaseServiceImpl<SysClub> implements SysC
if (StrUtil.isBlank(clubId)) { if (StrUtil.isBlank(clubId)) {
return new ClubRegisterVo(); return new ClubRegisterVo();
} }
// 与社团列表保持一致,按社团成员角色汇总会长、秘书长姓名,避免多名任职人员导致详情记录重复。
Sql sql = Sqls.create(""" Sql sql = Sqls.create("""
SELECT SELECT
club.*, club.*,
d.NAME AS typeName, d.NAME AS typeName,
u.username concatPersonName, 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 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(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 (select GROUP_CONCAT(sponsorId) from sys_club_sponsor where clubId = club.id) as sponsor
@@ -19,4 +19,10 @@ public class ClubRegisterVo extends SysClub {
private String sponsorName; private String sponsorName;
private Integer currentPeopleNum; private Integer currentPeopleNum;
private String sponsor; private String sponsor;
@ApiModelProperty("会长")
private String clubLeader;
@ApiModelProperty("秘书长")
private String clubSecretary;
} }
@@ -84,6 +84,7 @@ public class H5HealthCheckupController {
Sql sql = Sqls.create(""" Sql sql = Sqls.create("""
select 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 (select count(*) from health_checkup_user_selection s where s.projectId=p.id and s.selectUserId=@userId) as selectCount
from from
health_checkup_project p health_checkup_project p
@@ -121,6 +122,8 @@ public class H5HealthCheckupController {
p.choiceTimeStart, p.choiceTimeStart,
p.choiceTimeEnd, p.choiceTimeEnd,
p.cover, p.cover,
p.healthCheckupType,
(select count(*) from health_checkup_project_subject subject where subject.projectId=p.id) as subjectCount,
sub.optionName sub.optionName
FROM FROM
health_checkup_user_selection us health_checkup_user_selection us
@@ -20,6 +20,7 @@ import org.nutz.lang.random.R;
import org.nutz.lang.util.NutMap; import org.nutz.lang.util.NutMap;
import java.util.List; import java.util.List;
import java.util.ArrayList;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -48,7 +49,16 @@ public class HealthCheckupProjectServiceImpl extends BaseServiceImpl<HealthCheck
if (Strings.isBlank(id)){ if (Strings.isBlank(id)){
return null; return null;
} }
HealthCheckupProject project = fetchLinks(fetch(id), "healthCheckupProjectSubjects", Cnd.NEW().asc("optionSort")); HealthCheckupProject project = fetch(id);
// 项目可能已被删除,详情查询保持与空 ID 相同的返回约定。
if (project == null) {
return null;
}
project = fetchLinks(project, "healthCheckupProjectSubjects", Cnd.NEW().asc("optionSort"));
// 尚未配置套餐的项目仍可查看详情和统计基础人数。
if (project.getHealthCheckupProjectSubjects() == null) {
project.setHealthCheckupProjectSubjects(new ArrayList<>());
}
project.getHealthCheckupProjectSubjects().forEach(v -> { project.getHealthCheckupProjectSubjects().forEach(v -> {
v.setSubjectMoneys(dao().query(HealthCheckupProjectSubjectMoney.class, Cnd.where("subjectId", "=", v.getId()))); v.setSubjectMoneys(dao().query(HealthCheckupProjectSubjectMoney.class, Cnd.where("subjectId", "=", v.getId())));
}); });
@@ -29,9 +29,17 @@ public class HealthCheckupSingleServiceImpl extends BaseServiceImpl<HealthChecku
@Inject @Inject
private HealthCheckupProjectService healthCheckupProjectService; private HealthCheckupProjectService healthCheckupProjectService;
/**
* 按体检项目和可选分工会统计人数;projectId 为项目 ID,unionId 为空时统计所有工会。
* 返回 tableList 统计行、tableColumn 动态列;未指定项目或项目已删除时两者为空列表。
*/
@Override @Override
public NutMap pageData(String projectId, String unionId) { public NutMap pageData(String projectId, String unionId) {
HealthCheckupProject project = healthCheckupProjectService.findOne(projectId); HealthCheckupProject project = healthCheckupProjectService.findOne(projectId);
// 无有效项目时不执行统计 SQL,避免空指针及无项目条件的查询。
if (project == null) {
return NutMap.NEW().addv("tableList", new ArrayList<>()).addv("tableColumn", new ArrayList<>());
}
Sql sql = Sqls.create(""" Sql sql = Sqls.create("""
SELECT SELECT
su.id, su.id,
@@ -10,14 +10,11 @@ import org.nutz.dao.Dao;
import org.nutz.dao.Sqls; import org.nutz.dao.Sqls;
import org.nutz.dao.entity.Record; import org.nutz.dao.entity.Record;
import org.nutz.dao.sql.Sql; 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.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Ok;
import java.util.ArrayList;
import java.util.Calendar; import java.util.Calendar;
import java.util.List; import java.util.List;
@@ -42,36 +39,8 @@ public class HonorBoardController {
@At @At
@SaCheckPermission("honor.board") @SaCheckPermission("honor.board")
public Result getHonorTypeByYear() { public Result getHonorTypeByYear() {
Sql sqlYear = Sqls.create(""" int currentYear = Calendar.getInstance().get(Calendar.YEAR);
select YEAR(grantDate) AS `year` from honor where grantDate is not null GROUP BY grantDate return Result.success(honorCommonService.getHonorTypeStatisticsByYear(currentYear));
""");
int[] yearList = (int[]) Daos.query(dao, sqlYear.toString(), Sqls.callback.ints());
List<NutMap> 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<NutMap> 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);
} }
@At @At
@@ -3,6 +3,7 @@ package com.budwk.app.zhgh.dayofficework.honor.service;
import com.budwk.app.base.service.BaseService; import com.budwk.app.base.service.BaseService;
import com.budwk.app.zhgh.dayofficework.honor.models.Honor; import com.budwk.app.zhgh.dayofficework.honor.models.Honor;
import com.budwk.app.zhgh.dayofficework.honor.models.HonorBasicSettings; import com.budwk.app.zhgh.dayofficework.honor.models.HonorBasicSettings;
import org.nutz.lang.util.NutMap;
import java.util.List; import java.util.List;
@@ -16,4 +17,12 @@ import java.util.List;
public interface HonorCommonService extends BaseService<Honor> { public interface HonorCommonService extends BaseService<Honor> {
List<HonorBasicSettings> getHonorBasicSettingsByQueryTypeCode(String queryTypeCode); List<HonorBasicSettings> getHonorBasicSettingsByQueryTypeCode(String queryTypeCode);
/**
* 按年度统计集体荣誉和个人荣誉数量。
*
* @param currentYear 当前允许统计的最大年份,晚于该年份的数据不返回
* @return 年度荣誉统计列表;每项包含 name(荣誉类别)、num(数量)和 year(年份)
*/
List<NutMap> getHonorTypeStatisticsByYear(int currentYear);
} }
@@ -7,9 +7,16 @@ import com.budwk.app.zhgh.dayofficework.honor.models.HonorBasicSettings;
import com.budwk.app.zhgh.dayofficework.honor.service.HonorCommonService; import com.budwk.app.zhgh.dayofficework.honor.service.HonorCommonService;
import org.nutz.dao.Cnd; import org.nutz.dao.Cnd;
import org.nutz.dao.Dao; 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.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.List;
import java.util.Map;
/** /**
* @version 1.0 * @version 1.0
@@ -32,4 +39,54 @@ public class HonorCommonServiceImpl extends BaseServiceImpl<Honor> implements Ho
List<HonorBasicSettings> query = dao().query(HonorBasicSettings.class, Cnd.where("parentId", "=", basicSettings.getId()).asc("`code`")); List<HonorBasicSettings> query = dao().query(HonorBasicSettings.class, Cnd.where("parentId", "=", basicSettings.getId()).asc("`code`"));
return query; return query;
} }
/**
* 按年度统计荣誉类别数量,并为每个存在荣誉数据的年度补齐集体荣誉、个人荣誉两类数据。
* SQL 将年份显式转换为字符类型,避免部分 JDBC 驱动把 YEAR 函数结果识别为 Timestamp。
*
* @param currentYear 当前允许统计的最大年份
* @return 年度荣誉统计列表;每项包含 name、num、year 三个字段
*/
@Override
public List<NutMap> 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<NutMap> statistics = listMap(sql);
// 每个年度固定返回两类荣誉,保证某一类别无数据时图表仍能显示 0。
Map<Integer, Map<String, Long>> statisticsByYear = new LinkedHashMap<>();
for (NutMap statistic : statistics) {
String honorYear = statistic.getString("honorYear");
if (Strings.isBlank(honorYear)) {
continue;
}
int year = Integer.parseInt(honorYear);
Map<String, Long> categoryStatistics = statisticsByYear.computeIfAbsent(year, key -> {
Map<String, Long> 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<NutMap> 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;
}
} }
@@ -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);
}
}
}
@@ -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);
}
@@ -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("(?<!\\d)(?:\\+86)?1[3-9]\\d{9}(?!\\d)", "[PHONE]")
.replaceAll("(?i)(authorization|signature|token)[\"']?\\s*[:=]\\s*(\"[^\"]*\"|'[^']*'|[^,;}\\r\\n]+)",
"$1=[REDACTED]")
.replaceAll("[\\p{Cntrl}\\u2028\\u2029]", " ").trim();
return sanitized.length() > 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;
}
}
@@ -313,4 +313,15 @@ public class UnionReimburseApplyController {
public Result getBudgetBalance(String reimburseFundSource, String clubId) { public Result getBudgetBalance(String reimburseFundSource, String clubId) {
return unionReimburseService.getBudgetBalance(reimburseFundSource, clubId, null); 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());
}
} }
@@ -121,6 +121,16 @@ public class UnionReimburseCollectController {
applyListSort(cnd, pageForm); applyListSort(cnd, pageForm);
sql.setCondition(cnd); sql.setCondition(cnd);
Pagination<NutMap> pagination = unionReimburseService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql); Pagination<NutMap> 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); 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_2' THEN '文体活动'
WHEN rei.reimburseProject = 'UNION_REIMBURSE_PROJECT_3' THEN '日常活动' WHEN rei.reimburseProject = 'UNION_REIMBURSE_PROJECT_3' THEN '日常活动'
WHEN rei.reimburseProject = 'UNION_REIMBURSE_PROJECT_4' THEN '专项活动' WHEN rei.reimburseProject = 'UNION_REIMBURSE_PROJECT_4' THEN '专项活动'
WHEN rei.reimburseProject = 'UNION_REIMBURSE_PROJECT_5' THEN '社团活动'
ELSE rei.reimburseProject ELSE rei.reimburseProject
END as reimbursementItemName, END as reimbursementItemName,
u.mobile u.mobile
@@ -249,6 +260,8 @@ public class UnionReimburseCollectController {
.orElse(null); .orElse(null);
if (projectDict != null) { if (projectDict != null) {
item.setReimburseProject(projectDict.getName()); item.setReimburseProject(projectDict.getName());
} else if ("UNION_REIMBURSE_PROJECT_5".equals(item.getReimburseProject())) {
item.setReimburseProject("社团活动");
} }
} }
// 转换报销经费来源 // 转换报销经费来源
@@ -2,6 +2,7 @@ package com.budwk.app.zhgh.dayofficework.unionReimburse.controller;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaMode; import cn.dev33.satoken.annotation.SaMode;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.annotation.SLog; import com.budwk.app.base.annotation.SLog;
import com.budwk.app.base.page.Pagination; 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.base.utils.PageUtil;
import com.budwk.app.zhgh.dayofficework.unionReimburse.model.UnionReimburse; 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.UnionReimburseService;
import com.budwk.app.zhgh.dayofficework.unionReimburse.service.UnionReimburseExportService;
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.aop.interceptor.ioc.TransAop;
@@ -36,6 +38,9 @@ public class UnionReimburseReviewController {
@Inject @Inject
private UnionReimburseService unionReimburseService; private UnionReimburseService unionReimburseService;
@Inject
private UnionReimburseExportService unionReimburseExportService;
@At("/index") @At("/index")
@Ok("beetl:/platform/zhgh/dayofficework/unionReimburse/review/index.html") @Ok("beetl:/platform/zhgh/dayofficework/unionReimburse/review/index.html")
@SaCheckPermission("unionReimburse.review") @SaCheckPermission("unionReimburse.review")
@@ -88,6 +93,16 @@ public class UnionReimburseReviewController {
cnd.desc("info.createdAt"); cnd.desc("info.createdAt");
sql.setCondition(cnd); sql.setCondition(cnd);
Pagination<NutMap> pagination = unionReimburseService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql); Pagination<NutMap> 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); return Result.success(pagination);
} }
@@ -6,8 +6,9 @@ import java.util.Map;
/** 工会报销活动及慰问审批单共用的导出服务。 */ /** 工会报销活动及慰问审批单共用的导出服务。 */
public interface UnionReimburseExportService { public interface UnionReimburseExportService {
/** /**
* 我的慰问按类型使用专用模板,没有对应模板映射时拒绝导出,不回退通用慰问模板。
* @param id 当前登录人已提交的报销单ID,不接受其他人的单据或待提交单据 * @param id 当前登录人已提交的报销单ID,不接受其他人的单据或待提交单据
* @return 完整PDF字节数组,模板或转换失败时抛出业务异常 * @return 按慰问类型选择专用模板生成的完整PDF字节数组,活动沿用原模板;类型、模板或转换失败时抛出业务异常
*/ */
byte[] exportPdf(String id); byte[] exportPdf(String id);
@@ -23,4 +24,11 @@ public interface UnionReimburseExportService {
* @return 活动及慰问模板通用的平铺字段及 payments、invoices 循环行 * @return 活动及慰问模板通用的平铺字段及 payments、invoices 循环行
*/ */
Map<String, Object> buildExportData(UnionReimburse form); Map<String, Object> buildExportData(UnionReimburse form);
/**
* 列表与打印共用按申请日期及当日申请时间排序的流水号,历史申请参与相同排序。
* @param form 报销申请,必须包含申请时间和单据ID
* @return 年月日加至少三位当日序号,例如20261009001;缺少必要字段或申请不存在时抛出业务异常
*/
String buildPrintSerialNo(UnionReimburse form);
} }
@@ -58,10 +58,17 @@ public interface UnionReimburseService extends BaseService<UnionReimburse> {
Result recognizeInvoice(String fileId, Boolean verifySwitch); Result recognizeInvoice(String fileId, Boolean verifySwitch);
/** /**
* 查询报销可用经费余额,同时校验是否已分配以及协会归属权限。 * 查询报销可用经费余额,同时校验是否已分配、经费来源权限及社团有效性。
*/ */
Result getBudgetBalance(String reimburseFundSource, String clubId, String unionId); Result getBudgetBalance(String reimburseFundSource, String clubId, String unionId);
/**
* 查询未解散社团,供社团经费报销选择。
*
* @return 全部未解散社团的ID、名称和编码列表
*/
List<NutMap> listReimburseClubs();
/** /**
* 审核报销,审核通过时扣减对应预算并新增预算使用详情。 * 审核报销,审核通过时扣减对应预算并新增预算使用详情。
*/ */
@@ -7,6 +7,8 @@ import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.web.commons.auth.utils.AuthUtil; import com.budwk.app.web.commons.auth.utils.AuthUtil;
import org.nutz.dao.Cnd; import org.nutz.dao.Cnd;
import org.nutz.dao.Dao; 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.exception.BaseException;
import com.budwk.app.base.utils.MoneyUtil; import com.budwk.app.base.utils.MoneyUtil;
import com.budwk.app.base.utils.OfficePlusUtil; 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.XWPFTemplate;
import com.deepoove.poi.config.Configure; import com.deepoove.poi.config.Configure;
import com.deepoove.poi.plugin.table.LoopRowTableRenderPolicy; 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 lombok.extern.slf4j.Slf4j;
import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.math.BigDecimal; 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 ACTIVITY_TEMPLATE = "union_reimburse_activity_pdf";
public static final String CONDOLENCE_TEMPLATE = "union_reimburse_condolence_pdf"; public static final String CONDOLENCE_TEMPLATE = "union_reimburse_condolence_pdf";
// 使用慰问类型业务编码匹配模板,避免类型名称调整后选择错误;编码与现有申请页一致。 // 使用慰问类型业务编码匹配模板,避免类型名称调整后选择错误;编码与现有申请页一致。
private static final Map<String, String> COLLECT_CONDOLENCE_TEMPLATES = Map.of( private static final Map<String, String> CONDOLENCE_TEMPLATES = Map.of(
"A", "union_reimburse_condolence_marriage_pdf", "A", "union_reimburse_condolence_marriage_pdf",
"D", "union_reimburse_condolence_birth_pdf", "D", "union_reimburse_condolence_birth_pdf",
"E", "union_reimburse_condolence_hospital_pdf", "E", "union_reimburse_condolence_hospital_pdf",
@@ -64,7 +53,7 @@ public class UnionReimburseExportServiceImpl implements UnionReimburseExportServ
@Inject @Inject
private Dao dao; private Dao dao;
/** 当前“我的报销”导出仅允许单据经办人,状态白名单与页面按钮保持一致。 */ /** 当前“我的报销”导出仅允许单据经办人,状态白名单与页面按钮保持一致,慰问按类型使用专用模板。 */
@Override @Override
public byte[] exportPdf(String id) { public byte[] exportPdf(String id) {
return renderDocument(loadExportForm(id, false, false), true, false); return renderDocument(loadExportForm(id, false, false), true, false);
@@ -112,29 +101,34 @@ public class UnionReimburseExportServiceImpl implements UnionReimburseExportServ
} }
/** /**
* 汇总页五类慰问使用独立模板,其余类型和我的报销沿用原模板。 * 我的报销与汇总页共用五类慰问专用模板映射,活动沿用原模板。
* @param form 已通过数据权限校验的报销单,使用慰问类型 ID 读取业务编码 * @param form 已通过数据权限校验的报销单,使用慰问类型 ID 读取业务编码
* @param collectPrint 是否为汇总页打印,只有该入口启用分类模板 * @param collectPrint 是否为汇总页打印,仅该入口保留五类之外慰问的原通用模板兜底
* @return 系统导出模板管理中的模板编码;类型关联缺失时提示核对,不猜测类型名称 * @return 系统导出模板管理中的模板编码;类型关联缺失或我的慰问无专用模板映射时抛出业务异常
*/ */
private String resolveTemplateCode(UnionReimburse form, boolean collectPrint) { private String resolveTemplateCode(UnionReimburse form, boolean collectPrint) {
if (!"UNION_REIMBURSE_PROJECT_1".equals(form.getReimburseProject())) { if (!"UNION_REIMBURSE_PROJECT_1".equals(form.getReimburseProject())) {
return ACTIVITY_TEMPLATE; return ACTIVITY_TEMPLATE;
} }
if (!collectPrint) {
return CONDOLENCE_TEMPLATE;
}
// 兼容旧申请将类型 ID 保存在 condolenceType 的情况,不回写历史数据。 // 兼容旧申请将类型 ID 保存在 condolenceType 的情况,不回写历史数据。
String typeId = StrUtil.blankToDefault(form.getCondolenceTypeId(), form.getCondolenceType()); String typeId = StrUtil.blankToDefault(form.getCondolenceTypeId(), form.getCondolenceType());
CondolenceType type = StrUtil.isBlank(typeId) ? null : dao.fetch(CondolenceType.class, typeId); CondolenceType type = StrUtil.isBlank(typeId) ? null : dao.fetch(CondolenceType.class, typeId);
if (type == null || StrUtil.isBlank(type.getCode())) { if (type == null || StrUtil.isBlank(type.getCode())) {
throw new BaseException("慰问类型不存在或编码为空,请核对后打印"); 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) { private byte[] renderDocument(UnionReimburse form, boolean asPdf, boolean collectPrint) {
String templateCode = resolveTemplateCode(form, collectPrint); String templateCode = resolveTemplateCode(form, collectPrint);
Path docx = null; Path docx = null;
@@ -152,9 +146,6 @@ public class UnionReimburseExportServiceImpl implements UnionReimburseExportServ
XWPFTemplate template = XWPFTemplate.compile(input, config); XWPFTemplate template = XWPFTemplate.compile(input, config);
OutputStream output = Files.newOutputStream(docx)) { OutputStream output = Files.newOutputStream(docx)) {
Map<String, Object> data = buildExportData(form); Map<String, Object> data = buildExportData(form);
if (collectPrint) {
prepareCollectQrCode(template, data, form.getDocumentNo());
}
template.render(data).write(output); template.render(data).write(output);
} }
if (!asPdf) { 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<String, Object> data, @Override
String documentNo) throws Exception { public String buildPrintSerialNo(UnionReimburse form) {
if (StrUtil.isBlank(documentNo)) { if (form == null || StrUtil.isBlank(form.getId())) {
throw new BaseException("报销单编号为空,无法生成打印二维码,请先核对单据编号"); throw new BaseException("报销单据ID为空,无法生成打印流水号");
} }
var matrix = new QRCodeWriter().encode(documentNo, BarcodeFormat.QR_CODE, 80, 80); // 日期范围和编号前缀均取数据库原值,避免Java日期转换及参数绑定影响当天范围。
var image = MatrixToImageWriter.toBufferedImage(matrix); // 先对目标申请当天的全部单据排序,再筛选目标ID,保持审核、汇总和打印的编号一致。
data.put("qrcode", Pictures.ofBufferedImage(image, PictureType.PNG).size(80, 80).create()); Sql sql = Sqls.create("""
boolean hasTag = template.getElementTemplates().stream() SELECT DATE_FORMAT(numbered.createTime, '%Y%m%d') AS serialDate, numbered.seq
.anyMatch(item -> item instanceof ElementTemplate FROM (
&& "qrcode".equals(((ElementTemplate) item).getTagName())); SELECT info.id, info.createTime,
if (hasTag) { ROW_NUMBER() OVER (ORDER BY info.createTime, info.createdAt, info.id) AS seq
return; 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(); return result.getString("serialDate") + String.format(Locale.ROOT, "%03d", sequence.longValue());
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));
} }
/** 平铺字段供两类模板复用,支付总额与发票总额分别计算,不使用第一条支付代替全部明细。 */ /** 平铺字段供两类模板复用,支付总额与发票总额分别计算,不使用第一条支付代替全部明细。 */
@@ -261,8 +222,11 @@ public class UnionReimburseExportServiceImpl implements UnionReimburseExportServ
Map<String, Object> data = new LinkedHashMap<>(); Map<String, Object> data = new LinkedHashMap<>();
data.put("date", date(form.getCreateTime())); data.put("date", date(form.getCreateTime()));
data.put("no", text(form.getDocumentNo())); data.put("no", text(form.getDocumentNo()));
data.put("serialNo", buildPrintSerialNo(form));
String unit = text(form.getUnitName()); 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("handler", text(form.getUserName()));
data.put("login", text(form.getLoginName())); data.put("login", text(form.getLoginName()));
data.put("phone", text(form.getMobile())); data.put("phone", text(form.getMobile()));
@@ -271,6 +235,9 @@ public class UnionReimburseExportServiceImpl implements UnionReimburseExportServ
+ ("UNION_REIMBURSE_PROJECT_1".equals(project) ? "√" : "□") + "慰问 " + ("UNION_REIMBURSE_PROJECT_1".equals(project) ? "√" : "□") + "慰问 "
+ ("UNION_REIMBURSE_PROJECT_3".equals(project) ? "√" : "□") + "日常活动 " + ("UNION_REIMBURSE_PROJECT_3".equals(project) ? "√" : "□") + "日常活动 "
+ ("UNION_REIMBURSE_PROJECT_4".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("activity", text(form.getActivityName()));
data.put("reason", text(form.getPaymentNotes())); data.put("reason", text(form.getPaymentNotes()));
data.put("notes", text(form.getNotes())); data.put("notes", text(form.getNotes()));
@@ -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.models.Sys_file;
import com.budwk.app.sys.views.View_user; import com.budwk.app.sys.views.View_user;
import com.budwk.app.sys.services.SysFileService; 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.club.model.OutlayManageClub;
import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.model.OutlayUseDetail; import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.model.OutlayUseDetail;
import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.school.model.OutlayManageSchool; import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.school.model.OutlayManageSchool;
@@ -148,6 +147,10 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
if (amountResult != null) { if (amountResult != null) {
return amountResult; return amountResult;
} }
Result activityAmountResult = validateActivityApplyMoney(unionReimburse);
if (activityAmountResult != null) {
return activityAmountResult;
}
UnionReimburse oldRecord = null; UnionReimburse oldRecord = null;
if (StrUtil.isNotBlank(unionReimburse.getId())) { if (StrUtil.isNotBlank(unionReimburse.getId())) {
oldRecord = this.fetch(unionReimburse.getId()); oldRecord = this.fetch(unionReimburse.getId());
@@ -159,6 +162,18 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> 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) { if (oldRecord == null) {
View_user handler = dao().fetch(View_user.class, Cnd.where("id", "=", SecurityUtil.getUserId())); View_user handler = dao().fetch(View_user.class, Cnd.where("id", "=", SecurityUtil.getUserId()));
@@ -487,10 +502,19 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
} }
/** /**
* 按经费来源统一查询当前可用余额,未分配或权限不匹配时直接拦截。 * 按经费来源统一查询当前可用余额,经费来源权限由前端按钮控制。
*/ */
@Override @Override
public Result getBudgetBalance(String reimburseFundSource, String clubId, String unionId) { 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)) { if (StrUtil.isBlank(reimburseFundSource)) {
return Result.error("请选择经费来源"); return Result.error("请选择经费来源");
} }
@@ -515,16 +539,13 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
if (StrUtil.isBlank(clubId)) { if (StrUtil.isBlank(clubId)) {
return Result.error("请选择协会"); return Result.error("请选择协会");
} }
List<String> myClubIds = getCurrentUserClubIds();
if (!isAdmin() && !myClubIds.contains(clubId)) {
return Result.error("当前用户无权使用该协会经费");
}
OutlayManageClub outlayManageClub = dao().fetch(OutlayManageClub.class, OutlayManageClub outlayManageClub = dao().fetch(OutlayManageClub.class,
Cnd.where("clubId", "=", clubId).and("year", "=", DateUtil.thisYear())); Cnd.where("clubId", "=", clubId).and("year", "=", DateUtil.thisYear()));
if (outlayManageClub == null) { if (outlayManageClub == null) {
return Result.error("该协会本年度未生成预算记录,请先在协会预算分配中补分配"); 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("不支持的经费来源"); return Result.error("不支持的经费来源");
} }
@@ -533,6 +554,7 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
* 审核通过时统一校验余额、扣减预算并写入预算使用详情,避免控制器分散业务逻辑。 * 审核通过时统一校验余额、扣减预算并写入预算使用详情,避免控制器分散业务逻辑。
*/ */
@Override @Override
@Aop(TransAop.READ_COMMITTED)
public Result reviewApply(String reimburseId, String reviewOpinion, int submitType) { public Result reviewApply(String reimburseId, String reviewOpinion, int submitType) {
if (!List.of(3, 4, 5).contains(submitType)) { if (!List.of(3, 4, 5).contains(submitType)) {
return Result.error("无效的审核操作类型"); return Result.error("无效的审核操作类型");
@@ -541,6 +563,25 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
if (dbRecord == null) { if (dbRecord == null) {
return Result.error("记录不存在"); 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<UnionReimburse> 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 (submitType == 3) {
// 已通过的单据再次触发通过时直接返回,避免重复扣减预算。 // 已通过的单据再次触发通过时直接返回,避免重复扣减预算。
if (Integer.valueOf(3).equals(dbRecord.getStateId())) { if (Integer.valueOf(3).equals(dbRecord.getStateId())) {
@@ -668,8 +709,43 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
unionReimburse.setInvoiceDetails(invoiceDetails); 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) { private void fillInvoiceSummary(UnionReimburse unionReimburse) {
double totalMoney = 0D; double totalMoney = 0D;
@@ -692,7 +768,10 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
} }
totalMoney = paymentTotal.setScale(2, RoundingMode.HALF_UP).doubleValue(); totalMoney = paymentTotal.setScale(2, RoundingMode.HALF_UP).doubleValue();
} }
// 三类活动使用已校验的手填申请金额,不能被发票或支付合计覆盖。
if (!isManualReimburseAmount(unionReimburse)) {
unionReimburse.setMoney(BigDecimal.valueOf(totalMoney).setScale(2, RoundingMode.HALF_UP).doubleValue()); unionReimburse.setMoney(BigDecimal.valueOf(totalMoney).setScale(2, RoundingMode.HALF_UP).doubleValue());
}
unionReimburse.setInvoiceNumber(String.valueOf(invoiceFileCount)); unionReimburse.setInvoiceNumber(String.valueOf(invoiceFileCount));
} }
@@ -826,7 +905,11 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
if (unionReimburse.getActivityTime() == null) { if (unionReimburse.getActivityTime() == null) {
return Result.error("请选择活动时间"); 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 Result.error("请上传附件");
} }
return validateInvoiceDetails(unionReimburse); return validateInvoiceDetails(unionReimburse);
@@ -929,7 +1012,7 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
* 提交和审核前统一校验预算是否已分配、余额是否充足。 * 提交和审核前统一校验预算是否已分配、余额是否充足。
*/ */
private Result validateFundSourceBeforeSubmit(UnionReimburse unionReimburse) { 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) { if (balanceResult == null || balanceResult.getCode() != 0) {
return balanceResult == null ? Result.error("经费余额校验失败") : balanceResult; return balanceResult == null ? Result.error("经费余额校验失败") : balanceResult;
} }
@@ -985,11 +1068,19 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
if (outlayManageClub == null) { if (outlayManageClub == null) {
return Result.error("该协会本年度未生成预算记录,请先在协会预算分配中补分配"); 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); insertUseDetail(unionReimburse, outlayManageClub.getId(), applyMoney);
return Result.success(); return Result.success();
} }
@@ -1201,6 +1292,9 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
if ("UNION_REIMBURSE_PROJECT_4".equals(reimburseProject)) { if ("UNION_REIMBURSE_PROJECT_4".equals(reimburseProject)) {
return "专项活动申请"; return "专项活动申请";
} }
if ("UNION_REIMBURSE_PROJECT_5".equals(reimburseProject)) {
return "社团活动申请";
}
return "报销申请"; return "报销申请";
} }
@@ -1208,16 +1302,38 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
return money == null ? 0D : money.setScale(2, RoundingMode.HALF_UP).doubleValue(); return money == null ? 0D : money.setScale(2, RoundingMode.HALF_UP).doubleValue();
} }
private List<String> getCurrentUserClubIds() { /**
if (isAdmin()) { * 查询未解散社团,社团选择和余额查询不按负责人归属过滤。
return new ArrayList<>(); *
* @return 全部未解散社团的ID、名称和编码
*/
@Override
public List<NutMap> 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);
} }
List<ClubUser> clubUsers = dao().query(ClubUser.class, Cnd.where("userId", "=", SecurityUtil.getUserId()));
return clubUsers.stream() private Result validateApplicantFundSource(UnionReimburse form) {
.map(ClubUser::getClubId) if ("UNION_REIMBURSE_FUND_SOURCE_3".equals(form.getReimburseFundSource())) {
.filter(StrUtil::isNotBlank) if (!"UNION_REIMBURSE_PROJECT_5".equals(form.getReimburseProject())) {
.distinct() return Result.error("社团经费请选择社团活动");
.collect(Collectors.toList()); }
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("社团活动只能使用社团经费");
}
return null;
} }
private Result validateInvoiceDuplicate(UnionReimburse unionReimburse) { private Result validateInvoiceDuplicate(UnionReimburse unionReimburse) {
@@ -95,9 +95,9 @@ public class ProposalFeedbackEvaluationController {
Cnd cnd = Cnd.NEW(); Cnd cnd = Cnd.NEW();
cnd.and("t.taskName", "=", "feedback"); cnd.and("t.taskName", "=", "feedback");
if (!AuthUtil.hasRole(RoleConstant.SYSADMIN.name())) { /* if (!AuthUtil.hasRole(RoleConstant.SYSADMIN.name())) {
}*/
cnd.and("ta.actorId", "in", List.of(SecurityUtil.getUserId())); cnd.and("ta.actorId", "in", List.of(SecurityUtil.getUserId()));
}
if (approval) { if (approval) {
cnd.and("t.taskState", "in", List.of(ProcessTaskStateEnum.FINISHED.getCode(), ProcessTaskStateEnum.INTERRUPT.getCode())); cnd.and("t.taskState", "in", List.of(ProcessTaskStateEnum.FINISHED.getCode(), ProcessTaskStateEnum.INTERRUPT.getCode()));
@@ -1,10 +1,15 @@
package com.budwk.app.zhgh.democratic.proposal.h5controller; package com.budwk.app.zhgh.democratic.proposal.h5controller;
import cn.dev33.satoken.annotation.SaCheckPermission; 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 io.swagger.annotations.Api;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
@IocBean @IocBean
@At("/platform/h5/proposal/common") @At("/platform/h5/proposal/common")
@@ -12,6 +17,24 @@ import org.nutz.mvc.annotation.Ok;
@Api(tags = "h5提案公共") @Api(tags = "h5提案公共")
public class H5ProposalCommonController { 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("") @At("")
@Ok("beetl:/platform/zhghh5/democratic/proposal/common/info/index.html") @Ok("beetl:/platform/zhghh5/democratic/proposal/common/info/index.html")
@SaCheckPermission("h5.proposal") @SaCheckPermission("h5.proposal")
@@ -49,5 +49,13 @@ public interface ProposalWriteService extends BaseService<ProposalInfo> {
*/ */
void checkCurrentUserDelegate(String sessionId); void checkCurrentUserDelegate(String sessionId);
/**
* 查询当前登录用户是否为指定教代会届次的有效代表。
*
* @param sessionId 教代会届次ID,不能为空;用户身份由服务端登录信息取得
* @return true 表示存在未删除的代表记录,false 表示不存在;不要求已分配代表团
*/
boolean isCurrentUserDelegate(String sessionId);
ProposalInfo importProposal(Map<String, String> stringStringMap); ProposalInfo importProposal(Map<String, String> stringStringMap);
} }
@@ -194,6 +194,19 @@ public class ProposalWriteServiceImpl extends BaseServiceImpl<ProposalInfo> impl
@Override @Override
public void checkCurrentUserDelegate(String sessionId) { 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)) { if (StrUtil.isBlank(sessionId)) {
throw new RuntimeException("所属教代会不能为空"); throw new RuntimeException("所属教代会不能为空");
} }
@@ -204,9 +217,7 @@ public class ProposalWriteServiceImpl extends BaseServiceImpl<ProposalInfo> impl
.and(Teacher_congress_delegate::getDelFlag, "!=", 1) .and(Teacher_congress_delegate::getDelFlag, "!=", 1)
.limit(1) .limit(1)
); );
if (delegate == null) { return delegate != null;
throw new RuntimeException("当前用户不是代表,无法撰写提案");
}
} }
@Override @Override
@@ -36,7 +36,6 @@ import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.*; import org.nutz.dao.*;
import org.nutz.dao.sql.Sql; import org.nutz.dao.sql.Sql;
import org.nutz.dao.util.Daos; import org.nutz.dao.util.Daos;
import org.nutz.dao.util.cri.SqlExpressionGroup;
import org.nutz.ioc.aop.Aop; import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
@@ -201,43 +200,16 @@ public class TeacherCongressDelegateManageController {
} }
/** /**
* 代表调整搜索用户 * 按姓名或工号搜索可添加到代表调整列表的人员。
* *
* @param param * @param param sessionId 为必填的目标届次ID;keyWord 为必填的姓名或工号关键字,候选人员不限定为本届已有代表
* @return * @return Result,其 data 为最多10条人员记录;id、userId 均为用户ID,userName、loginName、sex、unionName、unitName 分别为姓名、工号、性别、工会和单位
*/ */
@At @At
@SaCheckPermission("tc.delegate.manage") @SaCheckPermission("tc.delegate.manage")
@Ok("json:{ignoreNull:true}") @Ok("json:{ignoreNull:true}")
public Result adjustUserList(@Valid TeacherCongressDelegateManageSearchAdjustUserParam param) { public Result adjustUserList(@Valid TeacherCongressDelegateManageSearchAdjustUserParam param) {
// Dao extDao = Daos.ext(dao, FieldFilter.create(Teacher_congress_delegate.class, "^id|userId|userName|loginName|unitName$")); return Result.success(teacherDelegateService.listAdjustUsers(param));
// SqlExpressionGroup seg = new SqlExpressionGroup();
// seg.orLike("userName", param.getKeyWord());
// seg.orLike("loginName", param.getKeyWord());
// List<Teacher_congress_delegate> 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());
} }
/** /**
@@ -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.models.Teacher_congress_delegate;
import com.budwk.app.zhgh.democratic.teachercongress.delegate.param.TeacherCongressDelegateManagePageParam; 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.TeacherCongressDelegateManagePublicUserParam;
import com.budwk.app.zhgh.democratic.teachercongress.delegate.param.TeacherCongressDelegateManageSearchAdjustUserParam;
import org.nutz.lang.util.NutMap; import org.nutz.lang.util.NutMap;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
@@ -31,6 +32,14 @@ public interface TeacherCongressDelegateService extends BaseService<Teacher_cong
*/ */
List<NutMap> listPublicUsers(TeacherCongressDelegateManagePublicUserParam param); List<NutMap> listPublicUsers(TeacherCongressDelegateManagePublicUserParam param);
/**
* 按姓名或工号搜索代表调整候选人员,包含尚未成为本届代表的用户。
*
* @param param sessionId 为目标届次ID,不用于限制候选人员;keyWord 为姓名或工号关键字
* @return 最多10条人员记录;id、userId 均为用户ID,userName、loginName、sex、unionName、unitName 分别为姓名、工号、性别、工会和单位
*/
List<NutMap> listAdjustUsers(TeacherCongressDelegateManageSearchAdjustUserParam param);
/** /**
* 根据系统配置的单位或分工会组成关系,为尚未归团的代表自动分配代表团。 * 根据系统配置的单位或分工会组成关系,为尚未归团的代表自动分配代表团。
* *
@@ -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.models.Teacher_congress_delegate;
import com.budwk.app.zhgh.democratic.teachercongress.delegate.param.TeacherCongressDelegateManagePageParam; 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.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.delegate.vo.TeacherCongressDelegateExcelVO;
import com.budwk.app.zhgh.democratic.teachercongress.prepare.models.Teacher_congress_session; import com.budwk.app.zhgh.democratic.teachercongress.prepare.models.Teacher_congress_session;
import com.budwk.app.zhgh.democratic.teachercongress.prepare.service.TeacherCongressSessionService; import com.budwk.app.zhgh.democratic.teachercongress.prepare.service.TeacherCongressSessionService;
@@ -107,6 +108,35 @@ public class TeacherCongressDelegateServiceImpl extends BaseServiceImpl<Teacher_
return listMap(sql); return listMap(sql);
} }
/**
* 直接查询用户视图,避免同一用户的多届次代表记录将候选人员重复展开。
*
* @param param sessionId 为目标届次ID,保留跨届次及非代表用户的搜索范围;keyWord 为姓名或工号关键字
* @return 最多10条 NutMap 人员记录,包含用户ID(id、userId)、姓名、工号、性别、工会和单位
*/
@Override
public List<NutMap> 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);
}
/** /**
* 为当前届次未归团代表批量解析代表团,同时刷新其届次代表角色,避免重复生成角色关系。 * 为当前届次未归团代表批量解析代表团,同时刷新其届次代表角色,避免重复生成角色关系。
*/ */
@@ -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() {
}
}
@@ -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("查询华住状态失败,请稍后重试");
}
}
}
@@ -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()));
}
}
@@ -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 {
}
@@ -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<NutMap> pageUsers(HuaZhuEmployeePageForm pageForm);
/**
* 将选中的本项目员工同步到华住。
*
* @param userIds 本项目用户主键列表
* @return 逐人结果,success=true 为协议确认成功、false 为失败、null 为待核实,message 为说明
*/
List<HuaZhuSyncResult> syncUsers(List<String> userIds);
/**
* 查询指定本项目员工在华住的实时状态。
*
* @param userId 本项目用户主键
* @return 华住员工状态对象;statusCode 保留第三方原值,userNumber 为已校验匹配的工号
*/
HuaZhuEmployeeStatus queryStatus(String userId);
}
@@ -0,0 +1,15 @@
package com.budwk.app.zhgh.huazhu.service;
/**
* 华住免密登录服务。
*/
public interface HuaZhuLoginService {
/**
* 按华住协议为当前职工工号生成免密登录跳转地址。
*
* @param loginName 当前登录职工的工号
* @return 华住免密登录地址
*/
String buildBasicLoginUrl(String loginName);
}
@@ -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<NutMap> 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<HuaZhuSyncResult> syncUsers(List<String> userIds) {
if (userIds == null || userIds.isEmpty()) {
return Collections.emptyList();
}
List<HuaZhuSyncResult> 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;
}
}
@@ -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);
}
}
@@ -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<Permission> permissions;
/**
* 华住返回的预订权限。
*/
@Data
public static class Permission {
private Boolean hasPermission;
private String permissionCode;
private String permissionValue;
}
}
@@ -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;
}
@@ -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();
}
}
@@ -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());
}
}
@@ -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;
}
@@ -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;
}
@@ -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<InclusiveBenefit> {
/**
* 查询管理列表或手机端已发布内容。
* @param pageForm 页码、每页数量及可选的年度、标题、标签编码
* @param onlyPublished 手机端固定传 true,PC 管理端传 false,不接受客户端指定
* @return 分页对象,list 为内容列表,totalCount 为符合条件的总条数
*/
Pagination<InclusiveBenefitVO> pageData(InclusiveBenefitPageForm pageForm, boolean onlyPublished);
/**
* @param id 内容主键
* @param onlyPublished true 时只允许读取已发布内容
* @return 内容及标签名称;不存在、已删除或未发布时返回 null
*/
InclusiveBenefitVO findVO(String id, boolean onlyPublished);
/** @return 启用的标签列表,每项 text 为名称,value 为字典编码 */
List<NutMap> 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);
}
@@ -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<InclusiveBenefit> implements InclusiveBenefitService {
private static final String TAG_DICT_CODE = "INCLUSIVE_BENEFIT_TYPE";
/** @param dao 当前项目的数据库访问对象 */
public InclusiveBenefitServiceImpl(Dao dao) {
super(dao);
}
@Override
public Pagination<InclusiveBenefitVO> 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<InclusiveBenefit> pagination = listPage(pageForm.getPageNumber(), pageForm.getPageSize(), cnd);
Map<String, String> tagNameMap = getTagNameMap();
List<InclusiveBenefitVO> 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<NutMap> 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<String, String> 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<String, String> getTagNameMap() {
List<NutMap> 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 "全部开放";
}
}
@@ -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;
}
@@ -31,7 +31,7 @@ import javax.validation.Valid;
@At("/platform/TMap") @At("/platform/TMap")
public class TMapController { public class TMapController {
private static String KEY = "MLLBZ-GQECI-ASXG7-5GNOZ-XW2OF-H5BVH"; private static String KEY = "NYNBZ-K62KI-DQJGU-U4NJ5-CSME7-ULFMU";
@Inject @Inject
private SysConfigService configService; private SysConfigService configService;
@@ -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));
}
}
@@ -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);
}
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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<PersonApiConfig> {
Pagination pageData(PageForm pageForm, String configName, String sourceType, Boolean enabled);
NutMap detail(String id);
PersonApiConfig saveConfig(PersonApiConfig config, List<PersonApiFieldMapping> mappings,
List<PersonApiRequestParam> params);
/**
* 解析页面提交的配置、字段映射和请求参数并保存。
*
* @param data JSON对象,包含 config、mappings、params
* @return 保存后的API配置
*/
PersonApiConfig saveConfig(String data);
void deleteConfig(String id);
List<NutMap> tableNames();
List<NutMap> 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<PersonApiRequestParam> params, long page, int pageSize);
/** config和params为待保存或已保存配置;返回mode、cursor、size、step,NONE和LIMIT均只请求一次。 */
NutMap pagination(PersonApiConfig config, List<PersonApiRequestParam> 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<NutMap> selectableConfigs(String currentMatchConfigId);
}
@@ -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);
}
@@ -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<PersonApiConfig> 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<PersonApiRequestParam> params = dao().query(
PersonApiRequestParam.class,
Cnd.where("configId", "=", id).asc("sort")
);
// 历史请求参数没有范围标识,统一按数据接口参数回显,保证旧配置兼容。
params.forEach(param -> {
if (Strings.isBlank(param.getRequestScope())) {
param.setRequestScope("DATA");
}
});
List<PersonApiTestCase> 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<PersonApiFieldMapping> mappings,
List<PersonApiRequestParam> 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<PersonApiFieldMapping> mappings = Json.fromJsonAsList(
PersonApiFieldMapping.class, Json.toJson(payload.get("mappings")));
List<PersonApiRequestParam> 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<NutMap> 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<NutMap> 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<NutMap> 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<PersonApiRequestParam> 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<PersonApiRequestParam> 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<PersonApiRequestParam> 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<PersonApiRequestParam> 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<PersonApiRequestParam> 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<PersonApiRequestParam> params) {
List<String> roles = List.of("页码", "数量", "起始数", "结束数");
Map<String, String> values = new LinkedHashMap<>();
boolean sendBody = !List.of("GET", "HEAD").contains(Strings.sNull(config.getRequestMethod()).toUpperCase(Locale.ROOT));
for (PersonApiRequestParam param : params == null ? List.<PersonApiRequestParam>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<String, Object> 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<Object> 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<String, Object> 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<PersonApiRequestParam> params) {
Assert.hasText(config.getTokenValuePath(), "请填写Access_Token映射路径");
Assert.hasText(config.getTokenExpiresPath(), "请填写Expires_in映射路径");
List<PersonApiRequestParam> tokenParams = params.stream()
.filter(p -> "TOKEN".equals(p.getRequestScope())).toList();
// 只纳入影响Token的请求内容,避免保存响应、页码变化使缓存失效。
List<Object> 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<PersonApiRequestParam> 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<String, Object> 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<Sys_task> 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<NutMap> 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<NutMap> 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<PersonApiRequestParam> params, String testCondition) {
List<String> 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(),
"表名或字段名不合法"
);
}
}
@@ -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<PersonApiFieldMapping> 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<NutMap> 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<NutMap> 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<NutMap> 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<NutMap> 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<String> 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<PersonApiRequestParam> 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<String> 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<PersonApiFieldMapping> mappings,
List<NutMap> 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<String> 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<String> 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<NutMap> 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<NutMap> validateTarget(PersonApiConfig config, List<PersonApiFieldMapping> mappings) {
Assert.hasText(config.getTargetTable(), "请选择目标人员中间表");
List<NutMap> columns = personApiConfigService.columns(config.getTargetTable());
Map<String, NutMap> 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<String> 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<PersonApiFieldMapping> mappings, List<NutMap> 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<String, String> 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<NutMap> 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<NutMap> rows,
String mode, boolean protectSysUser, int[] counts) throws SQLException {
String table = quoteIdentifier(config.getTargetTable());
Map<Integer, String> existing = new HashMap<>();
Set<Integer> protectedRows = new HashSet<>();
Map<Integer, Integer> 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<Integer, NutMap> inserts = new LinkedHashMap<>();
Map<String, NutMap> 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<Sys_user> updateSnapshots = new ArrayList<>();
Map<String, List<String>> updateChangeTypes = new LinkedHashMap<>();
if (protectSysUser && !updates.isEmpty()) {
// 一次查询更新前快照;只有映射业务字段实际变化时才生成历史,pullTime变化不计入人员变更。
List<Sys_user> currentUsers = dao.query(Sys_user.class,
Cnd.where("id", "in", updates.keySet().toArray(new String[0])));
Map<String, Sys_user> currentUserMap = currentUsers.stream()
.collect(java.util.stream.Collectors.toMap(Sys_user::getId, user -> user));
for (Map.Entry<String, NutMap> entry : updates.entrySet()) {
Sys_user currentUser = currentUserMap.get(entry.getKey());
if (currentUser == null) {
continue;
}
List<String> 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<Sys_user_source> 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<Sys_user> insertedUsers = dao.query(Sys_user.class,
Cnd.where("id", "in", inserts.values().stream()
.map(row -> row.getString("id")).toArray(String[]::new)));
Map<String, List<String>> 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<String> resolveChangeTypes(Sys_user currentUser, NutMap incomingValues) {
NutMap currentValues = Lang.obj2nutmap(currentUser);
Set<String> changeTypes = new LinkedHashSet<>();
boolean basicChanged = false;
for (Map.Entry<String, Object> 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<MemberChangeType> 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<Sys_user> users,
Map<String, List<String>> changeTypesByUser,
String changeReason) {
if (users == null || users.isEmpty()) {
return;
}
List<Sys_user_history> histories = new ArrayList<>();
for (Sys_user user : users) {
List<String> 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<NutMap> rows, boolean update) throws SQLException {
List<String> 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, "同步锁已失效,请重试");
}
}
@@ -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();
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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<PersonMemberMatchConfig> {
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<NutMap> historyConfigs();
List<NutMap> dictItems(String dictCode);
void applyForApiConfig(String apiConfigId);
/**
* 返回当前API配置中任意会员规则命中的sys_user用户ID。
* 工会会员与福利会员规则分别计算后合并去重,满足任意一类规则即返回。
*/
List<String> matchedAnyMemberUserIds(String apiConfigId);
/** 分别按工会、福利规则同步对应会员字段及角色,某一类未配置规则时跳过该类身份处理。 */
void reconcileForApiConfig(String apiConfigId);
void updateEnabled(String id, boolean enabled);
}
@@ -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<PersonMemberMatchConfig> 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<PersonMemberMatchGroup> groups = dao().query(PersonMemberMatchGroup.class,
Cnd.where("matchConfigId", "=", id).asc("sort"));
List<NutMap> unionGroupRows = buildGroupTree(groups, RULE_TYPE_UNION_MEMBER);
List<NutMap> 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<NutMap> unionGroups = parseGroups(
payload.get("unionGroups") == null ? payload.get("groups") : payload.get("unionGroups")
);
List<NutMap> welfareGroups = parseGroups(payload.get("welfareGroups"));
Assert.hasText(config.getConfigName(), "请填写配置名称");
Assert.hasText(config.getApiConfigId(), "请选择API配置");
Assert.isTrue(!unionGroups.isEmpty() || !welfareGroups.isEmpty(), "至少添加一类会员匹配条件组");
// 先验证所有连接关系和条件,再写主配置,避免非法规则进入保存及应用链路。
List<RuleGroupNode> unionGroupNodes = validateGroups(unionGroups, "工会会员");
List<RuleGroupNode> 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<NutMap> 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<NutMap> dictItems(String dictCode) {
Assert.hasText(dictCode, "请输入字典编码");
List<NutMap> 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<String> matchedAnyMemberUserIds(String apiConfigId) {
Set<String> 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<String> 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<PersonMemberMatchGroup> matchGroups = ruleGroups(config.getId(), ruleType);
if (matchGroups.isEmpty()) {
log.info("[会员匹配] 当前类型未配置匹配规则,跳过计算,apiConfigId={},ruleType={}", apiConfigId, ruleType);
return new ArrayList<>();
}
List<PersonMemberMatchGroup> 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<String> 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<String> matchedIds = matchedUserIds(apiConfigId, RULE_TYPE_UNION_MEMBER);
Set<String> matched = new HashSet<>(matchedIds);
// flag=1 的人员由管理员保护,定时规则既不能新增其角色,也不能回收其现有角色。
List<Sys_user> members = dao().query(Sys_user.class, Cnd.where("member", "=", 1)
.and(Cnd.exps("flag", "IS", null).or("flag", "!=", 1)));
Set<String> currentMemberIds = members.stream().map(Sys_user::getId).collect(Collectors.toSet());
List<String> addIds = matchedIds.stream()
.filter(id -> !currentMemberIds.contains(id))
.toList();
List<String> 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<String> matchedIds = matchedUserIds(apiConfigId, RULE_TYPE_WELFARE_MEMBER);
Set<String> matched = new HashSet<>(matchedIds);
List<Sys_user> members = dao().query(Sys_user.class, Cnd.where("welfareMember", "=", 1)
.and(Cnd.exps("flag", "IS", null).or("flag", "!=", 1)));
Set<String> currentMemberIds = members.stream().map(Sys_user::getId).collect(Collectors.toSet());
List<String> addIds = matchedIds.stream()
.filter(id -> !currentMemberIds.contains(id))
.toList();
List<String> 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<String> unionMemberIds = resolveMatchedUserIds(id, RULE_TYPE_UNION_MEMBER);
if (!unionMemberIds.isEmpty()) {
updateMemberRole(unionMemberIds, true);
}
}
if (!ruleGroups(id, RULE_TYPE_WELFARE_MEMBER).isEmpty()) {
List<String> 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<String> resolveMatchedUserIds(String id, String ruleType) {
List<NutMap> rows = listMap(buildMatchSql(id, ruleType, true));
List<String> 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<String> allowedFields = new HashSet<>();
dao().query(PersonApiFieldMapping.class, Cnd.where("configId", "=", apiConfig.getId()))
.forEach(v -> allowedFields.add(v.getTargetField()));
List<PersonMemberMatchGroup> groups = ruleGroups(id, ruleType);
Assert.notEmpty(groups, ruleTypeName(ruleType) + "匹配条件不能为空");
List<PersonMemberMatchGroup> allGroups = allRuleGroups(id, ruleType);
Map<String, List<PersonMemberMatchGroup>> 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<String, Object> 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<NutMap> buildGroupTree(List<PersonMemberMatchGroup> groups, String ruleType) {
Map<String, NutMap> 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<NutMap>())
);
}
List<NutMap> 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<NutMap>) parent.get("groups")).add(row);
}
}
return roots;
}
/** 递归生成单个条件组表达式;当前组及每个子组都会显式包裹一层括号。 */
private void appendGroupExpression(StringBuilder where, PersonMemberMatchGroup group,
Map<String, List<PersonMemberMatchGroup>> childrenByParent,
Set<String> allowedFields, Map<String, Object> values, int[] paramIndex) {
List<PersonMemberMatchCondition> conditions = dao().query(
PersonMemberMatchCondition.class,
Cnd.where("groupId", "=", group.getId()).asc("sort")
);
List<PersonMemberMatchGroup> 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<String> allowedFields, Map<String, Object> 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<NutMap> parseGroups(Object value) {
if (value == null) {
return new ArrayList<>();
}
return Json.fromJsonAsList(NutMap.class, Json.toJson(value));
}
/** 校验一类会员规则的递归条件组、连接关系和必填条件,返回后续入库使用的规则树。 */
private List<RuleGroupNode> validateGroups(List<NutMap> groups, String ruleName) {
List<RuleGroupNode> nodes = new ArrayList<>();
for (NutMap row : groups) {
String joinLogic = normalizeJoinLogic(row.getString("joinLogic"), "OR");
List<PersonMemberMatchCondition> conditions = Json.fromJsonAsList(
PersonMemberMatchCondition.class,
Json.toJson(row.get("conditions"))
);
List<NutMap> 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<RuleGroupNode> 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<PersonMemberMatchCondition> 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<PersonMemberMatchGroup> ruleGroups(String configId, String ruleType) {
return allRuleGroups(configId, ruleType).stream()
.filter(group -> Strings.isBlank(group.getParentGroupId()))
.toList();
}
/** 查询指定会员类型的全部条件组;ruleType空值兼容历史工会会员规则。 */
private List<PersonMemberMatchGroup> 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<String> 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<String> 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<String> 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<PersonMemberMatchGroup> 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<PersonMemberMatchCondition> conditions;
private final List<RuleGroupNode> children;
private RuleGroupNode(String joinLogic, List<PersonMemberMatchCondition> conditions,
List<RuleGroupNode> children) {
this.joinLogic = joinLogic;
this.conditions = conditions;
this.children = children;
}
}
}
@@ -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() {
}
}
@@ -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;
/**
* 人员数据治理中的人员变更服务。
*
* <p>该服务复用现有会员变更领域能力,但为数据治理模块提供独立接口,避免页面依赖会员高级管理路由。</p>
*/
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);
}
@@ -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<ProcessTask> 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<NutMap> 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<NutMap> 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<String> 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;
}
}
}
@@ -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();
}
}
@@ -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;
}

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