Compare commits

...
21 Commits
Author SHA1 Message Date
c-chenxs1 81b0a4c50e fix: 人员数据治理迁移5 2026-11-10 15:11:46 +08:00
c-chenxs1 0da369366a fix: 人员数据治理迁移4 2026-11-09 17:39:59 +08:00
c-chenxs1 bd7857a5c8 fix: 人员数据治理迁移3 2026-11-09 16:08:24 +08:00
c-chenxs1 73c8750b6a fix: 人员数据治理迁移2 2026-11-09 15:21:10 +08:00
c-chenxs1 edc53353cb fix: 人员数据治理迁移1 2026-11-09 14:49:57 +08:00
c-zhouhf1 33943a1d62 新增分工会活动达标统计菜单 2026-10-08 18:00:18 +08:00
c-zhangr1 1ae8ae167a Merge remote-tracking branch 'origin/main' 2026-10-08 11:13:02 +08:00
c-zhouhf1 3f1a209f99 修改健步走抽奖规则 2026-10-07 12:00:27 +08:00
c-zhangr1 35f54eafc7 1、人员单位变更、校工会审核
2、pc端健步走模块分工会查询框显示有误
2026-09-21 11:16:34 +08:00
c-zhouhf1 8524150f79 commit 2026-09-10 12:19:47 +08:00
c-chenxs1 156b9d57a7 fix: 会员高级管理 查询人员不同页出现相同人员-加一个id排序 2026-09-10 11:18:23 +08:00
c-zhangr1 1c3c3024dd changes 2026-07-02 14:20:47 +08:00
c-zhangr1 cfe8b7daf4 1 2026-06-30 17:06:25 +08:00
c-zhangr1 5c8fb99ff2 移动端单点登录 2026-06-30 10:10:30 +08:00
= b694b69549 commit 2026-06-22 10:59:52 +08:00
c-zhouhf1 67bbe336e7 Merge branch 'main' of https://dd3skj.picp.vip/hongqiwei/zhgh_jsahvc_new 2026-06-18 09:05:16 +08:00
c-zhouhf1 641c942d8d commit 2026-06-18 09:05:09 +08:00
c-zhouhf1 b49a532442 commit 2026-06-18 09:04:39 +08:00
= ed58618f0e Merge branch 'main' of https://dd3skj.picp.vip/hongqiwei/zhgh_jsahvc_new 2026-06-18 08:43:25 +08:00
= 5fc4faca79 commit 2026-06-18 08:43:07 +08:00
c-zhouhf1 13fc53e849 commit 2026-06-17 17:18:32 +08:00
118 changed files with 12099 additions and 1694 deletions
+33
View File
@@ -0,0 +1,33 @@
# Compiled class file
*.class
*.iml
*.idea
target/
.idea/compiler.xml
.idea/
# Log file
*.log
# BlueJ files
*.ctxt
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files #
*.jar
*.war
*.ear
*.zip
*.tar.gz
*.rar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
src/main/resources/application.yaml
src/main/resources/application-dev.yaml
.vscode
bin
node-data-center-mock/
.workbuddy
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="cn.fjdmy.uniapp.UniappProjectDataService">
<option name="generalBasePath" value="$PROJECT_DIR$" />
<option name="manifestPath" value="$PROJECT_DIR$/manifest.json" />
<option name="pagesPath" value="$PROJECT_DIR$/pages.json" />
<option name="scanNum" value="1" />
<option name="type" value="store" />
</component>
</project>
+1
View File
@@ -2,6 +2,7 @@
<project version="4"> <project version="4">
<component name="CompilerConfiguration"> <component name="CompilerConfiguration">
<annotationProcessing> <annotationProcessing>
<profile default="true" name="Default" enabled="true" />
<profile name="Maven default annotation processors profile" enabled="true"> <profile name="Maven default annotation processors profile" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" /> <sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" /> <sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
-7
View File
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
</component>
</project>
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GitToolBoxProjectSettings">
<option name="commitMessageIssueKeyValidationOverride">
<BoolValueOverride>
<option name="enabled" value="true" />
</BoolValueOverride>
</option>
<option name="commitMessageValidationEnabledOverride">
<BoolValueOverride>
<option name="enabled" value="true" />
</BoolValueOverride>
</option>
</component>
</project>
+14
View File
@@ -0,0 +1,14 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="AliAccessStaticViaInstance" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AliArrayNamingShouldHaveBracket" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AliControlFlowStatementWithoutBraces" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AliDeprecation" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AliEqualsAvoidNull" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AliLongLiteralsEndingWithLowercaseL" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AliMissingOverrideAnnotation" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AliWrapperTypeEquality" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="MapOrSetKeyShouldOverrideHashCodeEquals" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>
+15
View File
@@ -1,6 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="RemoteRepositoriesConfiguration"> <component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Central Repository" />
<option name="url" value="https://repo.maven.apache.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="nutz" />
<option name="name" value="nutz" />
<option name="url" value="https://jfrog.nutz.cn/artifactory/libs-release" />
</remote-repository>
<remote-repository>
<option name="id" value="nutz-snapshots" />
<option name="name" value="nutz-snapshots" />
<option name="url" value="https://jfrog.nutz.cn/artifactory/snapshots" />
</remote-repository>
<remote-repository> <remote-repository>
<option name="id" value="central" /> <option name="id" value="central" />
<option name="name" value="Maven Central repository" /> <option name="name" value="Maven Central repository" />
+1 -1
View File
@@ -2,7 +2,7 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd"> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<groupId>com.budwk</groupId> <groupId>com.budwk</groupId>
<artifactId>zhgh_jsahvc</artifactId> <artifactId>zhgh_jsahvc_new</artifactId>
<version>5.6.0-plus</version> <version>5.6.0-plus</version>
<build> <build>
<resources> <resources>
+1 -1
View File
@@ -2,7 +2,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<packaging>jar</packaging> <packaging>jar</packaging>
<groupId>com.budwk</groupId> <groupId>com.budwk</groupId>
<artifactId>zhgh_cug_v4</artifactId> <artifactId>zhgh_jsahvc_new</artifactId>
<version>5.6.0-plus</version> <version>5.6.0-plus</version>
<properties> <properties>
<nutzboot.version>2.6.0-SNAPSHOT</nutzboot.version> <nutzboot.version>2.6.0-SNAPSHOT</nutzboot.version>
@@ -72,4 +72,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:";
} }
@@ -2,17 +2,14 @@ package com.budwk.app.base.sms;
import com.budwk.app.base.sms.model.MsgPlatformResponse; import com.budwk.app.base.sms.model.MsgPlatformResponse;
import com.budwk.app.base.sms.model.MsgPlatformSmsRequest; import com.budwk.app.base.sms.model.MsgPlatformSmsRequest;
import com.budwk.app.base.sms.model.MsgPlatformWechatRequest;
import java.util.List;
public interface SmsService { public interface SmsService {
/** /**
* 发送短信消息。 * 发送阿里云短信消息。
* *
* @param request 短信请求参数,需传接收人、发送人、模板ID或消息内容等字段 * @param request 短信请求参数,需传接收人、模板 Code、模板变量或消息内容等字段
* @return 通讯平台返回结果,返回类型为 {@link MsgPlatformResponse} * @return 阿里云短信发送结果,返回类型为 {@link MsgPlatformResponse}
*/ */
MsgPlatformResponse sendSms(MsgPlatformSmsRequest request); MsgPlatformResponse sendSms(MsgPlatformSmsRequest request);
@@ -22,7 +19,7 @@ public interface SmsService {
* *
* @param account 接收人工号 * @param account 接收人工号
* @param content 短信内容 * @param content 短信内容
* @return 通讯平台返回结果,返回类型为 {@link MsgPlatformResponse} * @return 阿里云短信发送结果,返回类型为 {@link MsgPlatformResponse}
*/ */
MsgPlatformResponse sendSmsByAccount(String account, String content); MsgPlatformResponse sendSmsByAccount(String account, String content);
@@ -32,25 +29,7 @@ public interface SmsService {
* *
* @param mobile 接收人手机号 * @param mobile 接收人手机号
* @param content 短信内容 * @param content 短信内容
* @return 通讯平台返回结果,返回类型为 {@link MsgPlatformResponse} * @return 阿里云短信发送结果,返回类型为 {@link MsgPlatformResponse}
*/ */
MsgPlatformResponse sendSmsByMobile(String mobile, String content); MsgPlatformResponse sendSmsByMobile(String mobile, String content);
/**
* 发送微信消息。
*
* @param request 微信请求参数,需传接收人、发送人、消息类型、模板ID或消息内容等字段
* @return 通讯平台返回结果,返回类型为 {@link MsgPlatformResponse}
*/
MsgPlatformResponse sendWechat(MsgPlatformWechatRequest request);
/**
* 发送微信文本消息的便捷方法。
* controller 只需要传接收人工号或手机号,以及微信文本内容即可。
*
* @param accountOrMobile 接收人工号或手机号
* @param content 微信文本内容
* @return 通讯平台返回结果,返回类型为 {@link MsgPlatformResponse}
*/
MsgPlatformResponse sendWechatTextByAccountOrMobile(String accountOrMobile, String content);
} }
@@ -1,422 +1,286 @@
package com.budwk.app.base.sms.impl; package com.budwk.app.base.sms.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.JSONObject; import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil; import cn.hutool.json.JSONUtil;
import com.budwk.app.base.exception.BaseException; import com.budwk.app.base.exception.BaseException;
import com.budwk.app.base.sms.SmsService; import com.budwk.app.base.sms.SmsService;
import com.budwk.app.base.sms.model.*; import com.budwk.app.base.sms.model.MsgPlatformRecipient;
import com.budwk.app.base.sms.model.MsgPlatformResponse;
import com.budwk.app.base.sms.model.MsgPlatformSmsRequest;
import com.budwk.app.sys.models.Sys_user;
import com.budwk.app.web.commons.base.Globals; import com.budwk.app.web.commons.base.Globals;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.nutz.integration.jedis.RedisService; import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.ioc.impl.PropertiesProxy; 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.json.Json;
import org.nutz.lang.Lang;
import org.nutz.lang.Strings; import org.nutz.lang.Strings;
import org.nutz.lang.util.NutMap;
import org.nutz.log.Log;
import org.nutz.log.Logs;
import java.io.*; import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.URL; import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Base64;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
* @ClassName SmsJshvcServiceImpl * 阿里云短信发送服务。
* @Author JyuHsin * 统一封装阿里云 SendSms 接口,业务代码只需要传接收人工号/手机号和短信内容;
* @Date 2025/11/26 19:32 * 签名、模板、模板变量名等固定参数从 sms.aliyun.* 配置中读取。
* @Version 1.0
* @Description TODO
*/ */
@Slf4j @Slf4j
@IocBean @IocBean
public class SmsServiceImpl implements SmsService { public class SmsServiceImpl implements SmsService {
private static final Log log = Logs.get();
private static final String SMS_API_PATH = "/tp_mp/api/SmsService/saveSmsInfo"; private static final String SMS_ACTION = "SendSms";
private static final String WECHAT_API_PATH = "/tp_mp/api/WechatService/saveWechatInfo"; private static final String SMS_VERSION = "2017-05-25";
private static final int MAX_RECEIVER_COUNT = 500; private static final String SIGN_ALGORITHM = "ACS3-HMAC-SHA256";
private static final String EMPTY_BODY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
@Inject @Inject
private PropertiesProxy conf; private PropertiesProxy conf;
@Inject
private Dao dao;
/** /**
* 发送短信消息。 * 发送短信消息。
* 参数说明:
* 1. recipients:接收人列表,mobile 为手机号;如果 mobile 为空但 account 不为空,会按工号查询 sys_user.mobile。
* 2. templateId:可选,阿里云短信模板 Code;为空时读取 sms.aliyun.template-code。
* 3. sendUserSign:可选,阿里云短信签名;为空时读取 sms.aliyun.sign-name。
* 4. customVar:可选,阿里云模板变量 Map;为空时使用 sms.aliyun.template-param-name 作为变量名,info 作为变量值。
* 返回值为 MsgPlatformResponse,code=0/result=true 表示阿里云返回 Code=OK,msgId 保存阿里云 BizId。
* *
* @param request 短信请求参数,需传接收人、发送人、模板ID或消息内容等字段 * @param request 短信发送请求参数
* @return 通讯平台返回结果,返回类型为 {@link MsgPlatformResponse} * @return 阿里云短信发送结果
*/ */
@Override @Override
public MsgPlatformResponse sendSms(MsgPlatformSmsRequest request) { public MsgPlatformResponse sendSms(MsgPlatformSmsRequest request) {
validateCommonRequest(request); MsgPlatformResponse skipResponse = checkSendEnabled();
JSONObject payload = buildCommonPayload(request, true); if (skipResponse != null) {
return doPost(SMS_API_PATH, payload); return skipResponse;
}
validateSmsRequest(request);
String phoneNumbers = resolvePhoneNumbers(request);
String signName = Strings.isBlank(request.getSendUserSign()) ? getRequiredConfig("sms.aliyun.sign-name", "请先配置 sms.aliyun.sign-name") : request.getSendUserSign();
String templateCode = Strings.isBlank(request.getTemplateId()) ? getRequiredConfig("sms.aliyun.template-code", "请先配置 sms.aliyun.template-code") : request.getTemplateId();
String templateParam = buildTemplateParam(request);
return sendToAliyun(phoneNumbers, signName, templateCode, templateParam);
} }
/** /**
* 通过工号发送短信文本消息的便捷方法。 * 通过工号发送阿里云短信。
* 该方法适合 controller 里只传“工号 + 内容”的简单场景,发送人信息从配置中读取。 * 参数说明:
* account 传系统用户工号,对应 sys_user.loginname;方法内部查询 sys_user.mobile。
* content 传模板变量内容,默认写入 sms.aliyun.template-param-name 对应变量。
* 返回值为 MsgPlatformResponse,code=0/result=true 表示发送请求提交成功。
* *
* @param account 接收人工号 * @param account 接收人工号
* @param content 短信内容 * @param content 短信模板变量内容
* @return 通讯平台返回结果,返回类型为 {@link MsgPlatformResponse} * @return 阿里云短信发送结果
*/ */
@Override @Override
public MsgPlatformResponse sendSmsByAccount(String account, String content) { public MsgPlatformResponse sendSmsByAccount(String account, String content) {
MsgPlatformSmsRequest request = new MsgPlatformSmsRequest();
request.setInfo(content);
request.setRecipients(buildAccountRecipients(account));
fillDefaultSenderInfo(request);
return sendSms(request);
}
/**
* 通过手机号发送短信文本消息的便捷方法。
* 该方法适合 controller 里只传“手机号 + 内容”的简单场景,发送人信息从配置中读取。
*
* @param mobile 接收人手机号
* @param content 短信内容
* @return 通讯平台返回结果,返回类型为 {@link MsgPlatformResponse}
*/
@Override
public MsgPlatformResponse sendSmsByMobile(String mobile, String content) {
MsgPlatformSmsRequest request = new MsgPlatformSmsRequest();
request.setInfo(content);
request.setRecipients(buildMobileRecipients(mobile));
request.setCustomVar(Map.of("1","","2","","3",content));
fillDefaultSenderInfo(request);
return sendSms(request);
}
/**
* 发送微信消息。
*
* @param request 微信请求参数,需传接收人、发送人、消息类型、模板ID或消息内容等字段
* @return 通讯平台返回结果,返回类型为 {@link MsgPlatformResponse}
*/
@Override
public MsgPlatformResponse sendWechat(MsgPlatformWechatRequest request) {
validateCommonRequest(request);
if (Strings.isBlank(request.getWechatType())) {
request.setWechatType("text");
}
if ("news".equalsIgnoreCase(request.getWechatType()) && Strings.isBlank(request.getInfo())) {
if (request.getNewsItems() == null || request.getNewsItems().isEmpty()) {
throw new BaseException("微信图文消息必须传 info 或 newsItems");
}
request.setInfo(JSONUtil.toJsonStr(request.getNewsItems()));
}
JSONObject payload = buildCommonPayload(request, false);
payload.set("wechat_type", request.getWechatType());
return doPost(WECHAT_API_PATH, payload);
}
/**
* 发送微信文本消息的便捷方法。
* 该方法适合 controller 里只传“工号/手机号 + 内容”的简单场景,发送人信息从配置中读取。
*
* @param accountOrMobile 接收人工号或手机号
* @param content 微信文本内容
* @return 通讯平台返回结果,返回类型为 {@link MsgPlatformResponse}
*/
@Override
public MsgPlatformResponse sendWechatTextByAccountOrMobile(String accountOrMobile, String content) {
MsgPlatformWechatRequest request = new MsgPlatformWechatRequest();
request.setInfo(content);
request.setWechatType("text");
request.setRecipients(buildWechatRecipients(accountOrMobile));
fillDefaultSenderInfo(request);
return sendWechat(request);
}
/**
* 组装平台公共请求体。
*
* @param request 平台请求参数
* @param includeMobile 是否在 person_info 第 5 段写入手机号
* @return 返回发送给第三方平台的 JSON 对象
*/
private JSONObject buildCommonPayload(MsgPlatformBaseRequest request, boolean includeMobile) {
JSONObject payload = new JSONObject();
payload.set("tp_name", getRequiredConfig("msg.platform.tp-name", "请先配置 msg.platform.tp-name"));
payload.set("secret_key", getSecretKey());
payload.set("person_info", resolvePersonInfo(request, includeMobile));
payload.set("template_id", Strings.sNull(request.getTemplateId()));
payload.set("info", Strings.sNull(request.getInfo()));
payload.set("send_priority", Strings.isBlank(request.getSendPriority()) ? "3" : request.getSendPriority());
payload.set("send_user_id", Strings.sNull(request.getSendUserId()));
payload.set("send_user_name", Strings.sNull(request.getSendUserName()));
payload.set("send_time", Strings.sNull(request.getSendTime()));
payload.set("send_unit_id", Strings.sNull(request.getSendUnitId()));
payload.set("send_unit_name", Strings.sNull(request.getSendUnitName()));
payload.set("send_user_sign", Strings.sNull(request.getSendUserSign()));
payload.set("receipt_id", Strings.isBlank(request.getReceiptId()) ? "0" : request.getReceiptId());
if (request.getCustomVar() != null && !request.getCustomVar().isEmpty()) {
payload.set("custom_var", JSONUtil.toJsonStr(request.getCustomVar()));
}
return payload;
}
/**
* 校验公共请求参数,避免 controller 传错参数后才到第三方接口报错。
*
* @param request 平台请求参数
*/
private void validateCommonRequest(MsgPlatformBaseRequest request) {
if (request == null) {
throw new BaseException("请求参数不能为空");
}
if (Strings.isBlank(request.getPersonInfo()) && (request.getRecipients() == null || request.getRecipients().isEmpty())) {
throw new BaseException("personInfo 和 recipients 不能同时为空");
}
if (Strings.isBlank(request.getTemplateId()) && Strings.isBlank(request.getInfo())) {
throw new BaseException("templateId 和 info 至少需要传一个");
}
if (Strings.isBlank(request.getSendUserId())) {
throw new BaseException("sendUserId 不能为空");
}
if (Strings.isBlank(request.getSendUserName())) {
throw new BaseException("sendUserName 不能为空");
}
if (Strings.isBlank(request.getSendPriority())) {
request.setSendPriority("3");
}
if ("4".equals(request.getSendPriority()) && Strings.isBlank(request.getSendTime())) {
throw new BaseException("sendPriority=4 时必须传 sendTime");
}
validateReceiverCount(request.getPersonInfo(), request.getRecipients());
}
/**
* 解析 person_info。
* 已传原始 personInfo 时直接使用;否则根据 recipients 自动拼接平台要求的格式。
*
* @param request 平台请求参数
* @param includeMobile 是否在第 5 段写入手机号
* @return 平台要求的 person_info 字符串
*/
private String resolvePersonInfo(MsgPlatformBaseRequest request, boolean includeMobile) {
if (Strings.isNotBlank(request.getPersonInfo())) {
return request.getPersonInfo();
}
List<MsgPlatformRecipient> recipients = request.getRecipients();
if (recipients == null || recipients.isEmpty()) {
throw new BaseException("接收人列表不能为空");
}
return recipients.stream().map(recipient -> buildReceiverLine(recipient, includeMobile)).collect(Collectors.joining("^@^"));
}
/**
* 将单个接收人转成平台要求的分隔符格式。
*
* @param recipient 接收人对象
* @param includeMobile 是否在最后一段补手机号
* @return 单个接收人的 person_info 片段
*/
private String buildReceiverLine(MsgPlatformRecipient recipient, boolean includeMobile) {
if (recipient == null) {
throw new BaseException("接收人信息中存在空对象");
}
List<String> segments = new ArrayList<>(5);
segments.add(Strings.sNull(recipient.getName()));
segments.add(Strings.sNull(recipient.getAccount()));
segments.add(Strings.sNull(recipient.getUnitId()));
segments.add(Strings.sNull(recipient.getUnitName()));
if (includeMobile) {
segments.add(resolveSmsMobile(recipient));
} else {
segments.add("");
}
return String.join("|", segments);
}
/**
* 解析短信接收人手机号。
* 如果 controller 传的是手机号,则校验手机号格式;
* 如果传的是工号,则允许手机号为空,按“姓名|工号|部门ID|部门名称|”格式发送。
*
* @param recipient 接收人信息
* @return 短信 person_info 第 5 段内容
*/
private String resolveSmsMobile(MsgPlatformRecipient recipient) {
String mobile = recipient.getMobile();
if (Strings.isBlank(mobile)) {
if (Strings.isBlank(recipient.getAccount())) {
throw new BaseException("短信接收人手机号和工号不能同时为空");
}
return "";
}
if (!mobile.matches("^\\d{11}$")) {
throw new BaseException("短信接收人手机号必须为11位数字");
}
return mobile;
}
/**
* 校验接收人数量,避免超过第三方平台单次上限。
*
* @param personInfo 原始 person_info
* @param recipients 接收人列表
*/
private void validateReceiverCount(String personInfo, List<MsgPlatformRecipient> recipients) {
int receiverCount;
if (Strings.isNotBlank(personInfo)) {
receiverCount = personInfo.split("\\^@\\^", -1).length;
} else {
receiverCount = recipients == null ? 0 : recipients.size();
}
if (receiverCount > MAX_RECEIVER_COUNT) {
throw new BaseException("单次发送人数不能超过" + MAX_RECEIVER_COUNT + "人");
}
}
/**
* 构建工号发送专用接收人列表。
*
* @param account controller 传入的工号
* @return 仅包含一个接收人的列表
*/
private List<MsgPlatformRecipient> buildAccountRecipients(String account) {
if (Strings.isBlank(account)) { if (Strings.isBlank(account)) {
throw new BaseException("接收人工号不能为空"); throw new BaseException("接收人工号不能为空");
} }
MsgPlatformRecipient recipient = new MsgPlatformRecipient(); Sys_user user = dao.fetch(Sys_user.class, Cnd.where(Sys_user::getLoginname, "=", account));
recipient.setAccount(account); if (user == null) {
List<MsgPlatformRecipient> recipients = new ArrayList<>(1); throw new BaseException("未找到工号对应用户:" + account);
recipients.add(recipient); }
return recipients; return sendSmsByMobile(user.getMobile(), content);
} }
/** /**
* 构建手机号发送专用接收人列表。 * 通过手机号发送阿里云短信。
* 参数说明:
* mobile 传 11 位手机号。
* content 传模板变量内容,默认写入 sms.aliyun.template-param-name 对应变量。
* 返回值为 MsgPlatformResponse,code=0/result=true 表示发送请求提交成功。
* *
* @param mobile controller 传入的手机号 * @param mobile 接收人手机号
* @return 仅包含一个接收人的列表 * @param content 短信模板变量内容
* @return 阿里云短信发送结果
*/ */
private List<MsgPlatformRecipient> buildMobileRecipients(String mobile) { @Override
if (Strings.isBlank(mobile)) { public MsgPlatformResponse sendSmsByMobile(String mobile, String content) {
throw new BaseException("接收人手机号不能为空");
}
if (!mobile.matches("^\\d{11}$")) {
throw new BaseException("接收人手机号必须为11位数字");
}
MsgPlatformRecipient recipient = new MsgPlatformRecipient(); MsgPlatformRecipient recipient = new MsgPlatformRecipient();
recipient.setMobile(mobile); recipient.setMobile(mobile);
List<MsgPlatformRecipient> recipients = new ArrayList<>(1);
recipients.add(recipient); MsgPlatformSmsRequest request = new MsgPlatformSmsRequest();
return recipients; request.setInfo(content);
request.setRecipients(List.of(recipient));
return sendSms(request);
} }
/** /**
* 构建微信便捷发送专用接收人列表。 * 检查当前环境是否允许真实发送短信。
* 线下项目以 Globals.sso=false 识别,直接跳过阿里云调用,避免本地或离线环境误发短信;
* 阿里云短信开关未启用时同样返回跳过结果,不影响待办主流程继续执行。
* *
* @param accountOrMobile controller 传入的企业微信成员ID、工号或手机号 * @return 不允许发送时返回跳过响应;允许发送时返回 null
* @return 仅包含一个接收人的列表
*/ */
private List<MsgPlatformRecipient> buildWechatRecipients(String accountOrMobile) { private MsgPlatformResponse checkSendEnabled() {
if (Strings.isBlank(accountOrMobile)) { if (!Globals.sso) {
throw new BaseException("微信接收人工号或手机号不能为空"); log.warn("线下项目不允许发送短信,已跳过阿里云短信调用");
} return buildSkippedResponse("线下项目不允许发送短信,已跳过实际发送");
MsgPlatformRecipient recipient = new MsgPlatformRecipient();
recipient.setAccount(accountOrMobile);
List<MsgPlatformRecipient> recipients = new ArrayList<>(1);
recipients.add(recipient);
return recipients;
}
/**
* 为便捷发送方法补默认发送人信息。
* 这些字段从配置中读取,避免 controller 每次重复传固定参数。
*
* @param request 平台请求参数
*/
private void fillDefaultSenderInfo(MsgPlatformBaseRequest request) {
request.setSendUserId(getRequiredConfig("msg.platform.default-send-user-id", "请先配置 msg.platform.default-send-user-id"));
request.setSendUserName(getRequiredConfig("msg.platform.default-send-user-name", "请先配置 msg.platform.default-send-user-name"));
request.setSendUnitId(conf.get("msg.platform.default-send-unit-id", ""));
request.setSendUnitName(conf.get("msg.platform.default-send-unit-name", ""));
request.setSendUserSign(conf.get("msg.platform.default-send-user-sign", ""));
request.setTemplateId(conf.get("msg.platform.default-template-id", ""));
}
/**
* 调用地大通讯平台 REST 接口。
*
* @param apiPath 具体接口路径
* @param payload 发送报文
* @return 平台响应结果
*/
private MsgPlatformResponse doPost(String apiPath, JSONObject payload) {
if (!Globals.sso){
log.error("【log】开发模式不允许发送消息通知");
MsgPlatformResponse response = new MsgPlatformResponse();
response.setHttpStatus(200);
response.setCode(0);
response.setResult(true);
response.setMsg("开发模式不允许发送消息通知");
response.setRawBody(JSONUtil.toJsonStr(payload));
return response;
} }
if (!Globals.MyConfig.getBoolean("AppSms", false)) { if (!Globals.MyConfig.getBoolean("AppSms", false)) {
log.error("【log】短信配置未开启"); log.warn("系统参数 AppSms 未开启,已跳过阿里云短信调用");
return buildSkippedResponse("系统参数 AppSms 未开启,已跳过实际发送");
}
if (!conf.getBoolean("sms.aliyun.enabled", false)) {
log.warn("阿里云短信未启用,已跳过阿里云短信调用");
return buildSkippedResponse("阿里云短信未启用,已跳过实际发送");
}
return null;
}
/**
* 构建跳过发送的统一响应。
* 该响应表示业务流程可继续,但没有发生真实外部短信调用。
*
* @param message 跳过原因
* @return 统一短信响应对象
*/
private MsgPlatformResponse buildSkippedResponse(String message) {
MsgPlatformResponse response = new MsgPlatformResponse(); MsgPlatformResponse response = new MsgPlatformResponse();
response.setHttpStatus(200); response.setHttpStatus(200);
response.setCode(0); response.setCode(0);
response.setResult(true); response.setResult(true);
response.setMsg("短信配置未开启"); response.setMsg(message);
response.setRawBody(JSONUtil.toJsonStr(payload));
return response; return response;
} }
if (!conf.getBoolean("msg.platform.enabled", false)) {
MsgPlatformResponse response = new MsgPlatformResponse(); /**
response.setHttpStatus(200); * 校验阿里云短信请求参数。
response.setCode(0); * 必须至少提供接收人和模板变量内容;模板变量可通过 customVar 显式传入。
response.setResult(true); *
response.setMsg("通讯平台未启用,已跳过实际发送"); * @param request 短信请求参数
response.setRawBody(JSONUtil.toJsonStr(payload)); */
return response; private void validateSmsRequest(MsgPlatformSmsRequest request) {
if (request == null) {
throw new BaseException("短信请求参数不能为空");
} }
if (request.getRecipients() == null || request.getRecipients().isEmpty()) {
throw new BaseException("短信接收人不能为空");
}
if ((request.getCustomVar() == null || request.getCustomVar().isEmpty()) && Strings.isBlank(request.getInfo())) {
throw new BaseException("短信模板变量内容不能为空");
}
}
/**
* 解析接收人手机号。
* recipients.mobile 优先;mobile 为空时,使用 recipients.account 按工号查询 sys_user.mobile。
*
* @param request 短信请求参数
* @return 逗号分隔的手机号列表
*/
private String resolvePhoneNumbers(MsgPlatformSmsRequest request) {
List<String> mobiles = new ArrayList<>();
for (MsgPlatformRecipient recipient : request.getRecipients()) {
if (recipient == null) {
continue;
}
String mobile = recipient.getMobile();
if (Strings.isBlank(mobile) && Strings.isNotBlank(recipient.getAccount())) {
Sys_user user = dao.fetch(Sys_user.class, Cnd.where(Sys_user::getLoginname, "=", recipient.getAccount()));
mobile = user == null ? "" : user.getMobile();
}
if (Strings.isBlank(mobile)) {
log.warn("短信接收人缺少手机号,account={}", recipient.getAccount());
continue;
}
if (!mobile.matches("^1\\d{10}$")) {
log.warn("短信接收人手机号格式不正确,mobile={}", mobile);
continue;
}
mobiles.add(mobile);
}
if (mobiles.isEmpty()) {
throw new BaseException("没有可发送短信的有效手机号");
}
return mobiles.stream().distinct().collect(Collectors.joining(","));
}
/**
* 构建阿里云模板变量。
* 如果业务传了 customVar,则直接作为模板变量;否则用 sms.aliyun.template-param-name 配置项包裹 info。
*
* @param request 短信请求参数
* @return JSON 字符串格式的模板变量
*/
private String buildTemplateParam(MsgPlatformSmsRequest request) {
Map<String, String> customVar = request.getCustomVar();
if (customVar != null && !customVar.isEmpty()) {
return JSONUtil.toJsonStr(customVar);
}
String paramName = conf.get("sms.aliyun.template-param-name", "content");
JSONObject params = new JSONObject();
params.set(paramName, request.getInfo());
return JSONUtil.toJsonStr(params);
}
/**
* 调用阿里云 SendSms 接口。
* 使用阿里云 V3 签名机制,HTTP 请求参数放在 query 中,body 为空。
*
* @param phoneNumbers 手机号,多个手机号用英文逗号分隔
* @param signName 阿里云短信签名
* @param templateCode 阿里云短信模板 Code
* @param templateParam 模板变量 JSON 字符串
* @return 阿里云短信发送结果
*/
private MsgPlatformResponse sendToAliyun(String phoneNumbers, String signName, String templateCode, String templateParam) {
String endpoint = conf.get("sms.aliyun.endpoint", "dysmsapi.aliyuncs.com");
String accessKeyId = getRequiredConfig("sms.aliyun.access-key-id", "请先配置 sms.aliyun.access-key-id");
String accessKeySecret = getRequiredConfig("sms.aliyun.access-key-secret", "请先配置 sms.aliyun.access-key-secret");
Map<String, String> queryParams = Map.of(
"PhoneNumbers", phoneNumbers,
"SignName", signName,
"TemplateCode", templateCode,
"TemplateParam", templateParam
);
Map<String, String> headers = buildSignedHeaders(endpoint, accessKeyId, accessKeySecret, queryParams);
String requestUrl = "https://" + endpoint + "/?" + canonicalQueryString(queryParams);
HttpURLConnection connection = null; HttpURLConnection connection = null;
try { try {
String requestBody = JSONUtil.toJsonStr(payload);
log.error("发送内容:=================================================="+requestBody);
String requestUrl = buildRequestUrl(apiPath);
log.warnf("发送地址:=================================================="+requestUrl);
connection = (HttpURLConnection) new URL(requestUrl).openConnection(); connection = (HttpURLConnection) new URL(requestUrl).openConnection();
connection.setRequestMethod("POST"); connection.setRequestMethod("POST");
connection.setDoOutput(true); connection.setDoOutput(false);
connection.setDoInput(true); connection.setDoInput(true);
connection.setUseCaches(false); connection.setUseCaches(false);
connection.setRequestProperty("Content-Type", "application/json;charset=UTF-8"); connection.setConnectTimeout(conf.getInt("sms.aliyun.connect-timeout", 5000));
connection.setConnectTimeout(conf.getInt("msg.platform.connect-timeout", 5000)); connection.setReadTimeout(conf.getInt("sms.aliyun.read-timeout", 10000));
connection.setReadTimeout(conf.getInt("msg.platform.read-timeout", 10000)); for (Map.Entry<String, String> header : headers.entrySet()) {
try (OutputStream outputStream = connection.getOutputStream()) { connection.setRequestProperty(header.getKey(), header.getValue());
outputStream.write(requestBody.getBytes(StandardCharsets.UTF_8));
outputStream.flush();
} }
int httpStatus = connection.getResponseCode(); int httpStatus = connection.getResponseCode();
String responseBody = readResponseBody(connection, httpStatus); String responseBody = readResponseBody(connection, httpStatus);
MsgPlatformResponse response = parseResponse(httpStatus, responseBody); return parseAliyunResponse(httpStatus, responseBody);
log.warnf("接口调用返回结果=============================="+responseBody);
if (response.getCode() != null && response.getCode() != 0) {
log.warnf("通讯平台调用失败, httpStatus=%s, body=%s", httpStatus, responseBody);
}
return response;
} catch (IOException e) { } catch (IOException e) {
log.error("调用通讯平台接口异常", e); log.error("调用阿里云短信接口异常", e);
throw new BaseException("调用通讯平台接口失败: " + e.getMessage()); throw new BaseException("调用阿里云短信接口失败:" + e.getMessage());
} finally { } finally {
if (connection != null) { if (connection != null) {
connection.disconnect(); connection.disconnect();
@@ -425,26 +289,110 @@ public class SmsServiceImpl implements SmsService {
} }
/** /**
* 构建完整请求地址,支持配置带或不带结尾斜杠的 base-url。 * 构建阿里云 V3 签名请求头。
* 参与签名的头包括 host、x-acs-action、x-acs-content-sha256、x-acs-date、x-acs-signature-nonce、x-acs-version。
* *
* @param apiPath 接口路径 * @param endpoint 阿里云短信服务域名
* @return 完整请求地址 * @param accessKeyId AccessKey ID
* @param accessKeySecret AccessKey Secret
* @param queryParams SendSms 请求参数
* @return 已包含 Authorization 的请求头
*/ */
private String buildRequestUrl(String apiPath) { private Map<String, String> buildSignedHeaders(String endpoint, String accessKeyId, String accessKeySecret, Map<String, String> queryParams) {
String baseUrl = getRequiredConfig("msg.platform.base-url", "请先配置 msg.platform.base-url"); String date = Instant.now().truncatedTo(ChronoUnit.SECONDS).toString();
if (baseUrl.endsWith("/")) { String nonce = UUID.randomUUID().toString();
return baseUrl.substring(0, baseUrl.length() - 1) + apiPath; String signedHeaders = "host;x-acs-action;x-acs-content-sha256;x-acs-date;x-acs-signature-nonce;x-acs-version";
} String canonicalHeaders = "host:" + endpoint + "\n"
return baseUrl + apiPath; + "x-acs-action:" + SMS_ACTION + "\n"
+ "x-acs-content-sha256:" + EMPTY_BODY_SHA256 + "\n"
+ "x-acs-date:" + date + "\n"
+ "x-acs-signature-nonce:" + nonce + "\n"
+ "x-acs-version:" + SMS_VERSION + "\n";
String canonicalRequest = "POST\n"
+ "/\n"
+ canonicalQueryString(queryParams) + "\n"
+ canonicalHeaders + "\n"
+ signedHeaders + "\n"
+ EMPTY_BODY_SHA256;
String stringToSign = SIGN_ALGORITHM + "\n" + sha256Hex(canonicalRequest);
String signature = hmacSha256Hex(stringToSign, accessKeySecret);
String authorization = SIGN_ALGORITHM
+ " Credential=" + accessKeyId
+ ",SignedHeaders=" + signedHeaders
+ ",Signature=" + signature;
return Map.of(
"host", endpoint,
"x-acs-action", SMS_ACTION,
"x-acs-version", SMS_VERSION,
"x-acs-date", date,
"x-acs-signature-nonce", nonce,
"x-acs-content-sha256", EMPTY_BODY_SHA256,
"Authorization", authorization
);
} }
/** /**
* 读取第三方接口响应报文。 * 生成规范化 QueryString,按参数名排序后进行 RFC3986 编码。
*
* @param params 请求参数
* @return 规范化 QueryString
*/
private String canonicalQueryString(Map<String, String> params) {
return params.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.map(entry -> percentEncode(entry.getKey()) + "=" + percentEncode(entry.getValue()))
.collect(Collectors.joining("&"));
}
/**
* RFC3986 URL 编码,匹配阿里云 OpenAPI 签名要求。
*
* @param value 原始字符串
* @return 编码后的字符串
*/
private String percentEncode(String value) {
return URLEncoder.encode(Strings.sNull(value), StandardCharsets.UTF_8)
.replace("+", "%20")
.replace("*", "%2A")
.replace("%7E", "~");
}
/**
* 解析阿里云短信响应。
* *
* @param connection HttpURLConnection
* @param httpStatus HTTP 状态码 * @param httpStatus HTTP 状态码
* @return 原始响应字符串 * @param responseBody 原始响应体
* @throws IOException 读取流失败时抛出 * @return 统一响应对象
*/
private MsgPlatformResponse parseAliyunResponse(int httpStatus, String responseBody) {
MsgPlatformResponse response = new MsgPlatformResponse();
response.setHttpStatus(httpStatus);
response.setRawBody(responseBody);
if (Strings.isBlank(responseBody) || !JSONUtil.isTypeJSON(responseBody)) {
response.setCode(httpStatus);
response.setResult(false);
response.setMsg("阿里云短信返回内容为空或不是合法JSON");
return response;
}
JSONObject jsonObject = JSONUtil.parseObj(responseBody);
String code = jsonObject.getStr("Code");
response.setCode("OK".equalsIgnoreCase(code) ? 0 : httpStatus);
response.setResult("OK".equalsIgnoreCase(code));
response.setMsg(jsonObject.getStr("Message"));
response.setMsgId(jsonObject.getStr("BizId"));
response.setDescription(code);
return response;
}
/**
* 读取 HTTP 响应内容。
*
* @param connection HTTP 连接
* @param httpStatus HTTP 状态码
* @return 响应体字符串
* @throws IOException 读取失败时抛出
*/ */
private String readResponseBody(HttpURLConnection connection, int httpStatus) throws IOException { private String readResponseBody(HttpURLConnection connection, int httpStatus) throws IOException {
InputStream inputStream = httpStatus >= 200 && httpStatus < 400 ? connection.getInputStream() : connection.getErrorStream(); InputStream inputStream = httpStatus >= 200 && httpStatus < 400 ? connection.getInputStream() : connection.getErrorStream();
@@ -462,58 +410,10 @@ public class SmsServiceImpl implements SmsService {
} }
/** /**
* 解析平台响应体。 * 读取必填配置。
*
* @param httpStatus HTTP 状态码
* @param responseBody 原始响应字符串
* @return 解析后的平台响应对象
*/
private MsgPlatformResponse parseResponse(int httpStatus, String responseBody) {
MsgPlatformResponse response = new MsgPlatformResponse();
response.setHttpStatus(httpStatus);
response.setRawBody(responseBody);
if (Strings.isBlank(responseBody)) {
response.setCode(httpStatus);
response.setResult(false);
response.setMsg("通讯平台未返回响应内容");
return response;
}
if (!JSONUtil.isTypeJSON(responseBody)) {
response.setCode(httpStatus);
response.setResult(false);
response.setMsg("通讯平台返回内容不是合法JSON");
return response;
}
JSONObject jsonObject = JSONUtil.parseObj(responseBody);
response.setCode(jsonObject.getInt("code"));
response.setMsg(jsonObject.getStr("msg"));
response.setResult(jsonObject.getBool("result"));
response.setMsgId(jsonObject.getStr("msg_id"));
response.setRemainingQuota(jsonObject.getInt("remaining_quota"));
response.setDescription(jsonObject.getStr("description"));
return response;
}
/**
* 获取平台密钥。
* 优先读取已加密密钥;未配置时再根据原始密钥按“Base64 后 SHA”规则自动计算。
*
* @return 平台要求的 secret_key
*/
private String getSecretKey() {
String encryptedSecretKey = conf.get("msg.platform.encrypted-secret-key", "");
if (Strings.isNotBlank(encryptedSecretKey)) {
return encryptedSecretKey;
}
String rawSecret = getRequiredConfig("msg.platform.raw-secret-key", "请先配置 msg.platform.raw-secret-key 或 msg.platform.encrypted-secret-key");
return shaHex(rawSecret);
}
/**
* 读取必填配置项。
* *
* @param key 配置 key * @param key 配置 key
* @param errorMessage 配置缺失时抛出的异常信息 * @param errorMessage 缺失时提示
* @return 配置值 * @return 配置值
*/ */
private String getRequiredConfig(String key, String errorMessage) { private String getRequiredConfig(String key, String errorMessage) {
@@ -525,20 +425,53 @@ public class SmsServiceImpl implements SmsService {
} }
/** /**
* 生成 SHA 十六进制摘要。 * 生成 SHA256 十六进制摘要。
* *
* @param content 待加密内容 * @param content 原始内容
* @return SHA 十六进制字符串 * @return 十六进制摘要
*/ */
private String shaHex(String content) { private String sha256Hex(String content) {
try { try {
MessageDigest messageDigest = MessageDigest.getInstance("SHA"); MessageDigest digest = MessageDigest.getInstance("SHA-256");
messageDigest.update(content.getBytes()); return bytesToHex(digest.digest(content.getBytes(StandardCharsets.UTF_8)));
byte[] hash = messageDigest.digest();
return Base64.getEncoder().encodeToString(hash);
} catch (Exception e) { } catch (Exception e) {
throw new BaseException("生成通讯平台 secret_key 失败: " + e.getMessage()); throw new BaseException("生成阿里云签名摘要失败:" + e.getMessage());
} }
} }
/**
* 生成 HMAC-SHA256 十六进制签名。
*
* @param content 签名原文
* @param secret AccessKey Secret
* @return 十六进制签名
*/
private String hmacSha256Hex(String content, String secret) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return bytesToHex(mac.doFinal(content.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new BaseException("生成阿里云短信签名失败:" + e.getMessage());
}
}
/**
* 字节数组转十六进制字符串。
*
* @param bytes 字节数组
* @return 十六进制字符串
*/
private String bytesToHex(byte[] bytes) {
StringBuilder builder = new StringBuilder(bytes.length * 2);
for (byte item : bytes) {
String hex = Integer.toHexString(item & 0xff);
if (hex.length() == 1) {
builder.append('0');
}
builder.append(hex);
}
return builder.toString();
}
} }
@@ -1,87 +0,0 @@
package com.budwk.app.base.sms.model;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* 通讯平台公共请求参数。
*/
@Data
public class MsgPlatformBaseRequest {
/**
* 接收人信息原始字符串。
* 如果 controller 已经按平台要求拼好了 person_info,可以直接传该字段;
* 否则可只传 recipients,由 service 自动组装。
*/
private String personInfo;
/**
* 接收人列表。
* 当 personInfo 为空时,service 会根据该列表自动拼接平台要求的 person_info 字符串。
*/
private List<MsgPlatformRecipient> recipients;
/**
* 模板ID。
* templateId 和 info 至少需要传一个,使用模板发送时优先传该字段。
*/
private String templateId;
/**
* 消息内容。
* templateId 和 info 至少需要传一个;不使用模板时,需要传完整消息内容。
*/
private String info;
/**
* 发送优先级。
* 3 表示立即发送,4 表示定时发送。
*/
private String sendPriority = "3";
/**
* 发送人账号。
*/
private String sendUserId;
/**
* 发送人姓名。
*/
private String sendUserName;
/**
* 模板变量。
* 使用模板发送时可传该字段,service 会自动转成平台要求的 JSON 字符串。
*/
private Map<String, String> customVar;
/**
* 定时发送时间,格式必须为 yyyy-MM-dd HH:mm:ss。
* 当 sendPriority=4 时该字段必填。
*/
private String sendTime;
/**
* 发送机构ID。
*/
private String sendUnitId;
/**
* 发送机构名称。
*/
private String sendUnitName;
/**
* 发送签名。
* 旧通用模板或特定签名场景可传该字段。
*/
private String sendUserSign;
/**
* 回执ID。
* 不需要回执时可保持默认值 0。
*/
private String receiptId = "0";
}
@@ -1,29 +0,0 @@
package com.budwk.app.base.sms.model;
import lombok.Data;
/**
* 微信图文消息单条数据。
*/
@Data
public class MsgPlatformNewsItem {
/**
* 图文标题。
*/
private String title;
/**
* 图文描述。
*/
private String description;
/**
* 点击跳转链接。
*/
private String url;
/**
* 图文封面图片链接。
*/
private String picurl;
}
@@ -3,34 +3,34 @@ package com.budwk.app.base.sms.model;
import lombok.Data; import lombok.Data;
/** /**
* 通讯平台接收人信息。 * 短信接收人信息。
*/ */
@Data @Data
public class MsgPlatformRecipient { public class MsgPlatformRecipient {
/** /**
* 接收人姓名,对应 person_info 中的第 1 段。 * 接收人姓名,便于日志排查。
*/ */
private String name; private String name;
/** /**
* 接收人账号,对应 person_info 中的第 2 段。 * 接收人工号。
* 短信场景一般传学工号;微信场景可传企业微信成员ID、学工号或手机号。 * mobile 为空时,可用该字段按 sys_user.loginname 查询手机号。
*/ */
private String account; private String account;
/** /**
* 接收人部门ID,对应 person_info 中的第 3 段。 * 接收人部门ID,当前阿里云短信发送不使用,预留给调用方传递上下文。
*/ */
private String unitId; private String unitId;
/** /**
* 接收人部门名称,对应 person_info 中的第 4 段。 * 接收人部门名称,当前阿里云短信发送不使用,预留给调用方传递上下文。
*/ */
private String unitName; private String unitName;
/** /**
* 接收人手机号,对应短信 person_info 中的第 5 段。 * 接收人手机号。
* 微信接口不会使用该字段,微信接口会自动补空串以保留分隔符格式。 * 阿里云短信发送时优先使用该字段,必须为 11 位大陆手机号。
*/ */
private String mobile; private String mobile;
} }
@@ -3,7 +3,7 @@ package com.budwk.app.base.sms.model;
import lombok.Data; import lombok.Data;
/** /**
* 通讯平台响应结果。 * 短信发送响应结果。
*/ */
@Data @Data
public class MsgPlatformResponse { public class MsgPlatformResponse {
@@ -13,37 +13,37 @@ public class MsgPlatformResponse {
private Integer httpStatus; private Integer httpStatus;
/** /**
* 平台返回业务状态码,0 表示成功。 * 业务状态码,0 表示阿里云返回 OK。
*/ */
private Integer code; private Integer code;
/** /**
* 平台返回消息提示。 * 阿里云返回消息提示。
*/ */
private String msg; private String msg;
/** /**
* 平台返回业务是否成功。 * 是否发送成功。
*/ */
private Boolean result; private Boolean result;
/** /**
* 发送成功后的消息ID。 * 发送成功后的阿里云 BizId。
*/ */
private String msgId; private String msgId;
/** /**
* 短信剩余额度,仅短信接口返回。 * 短信剩余额度,当前阿里云接口不返回,预留兼容字段。
*/ */
private Integer remainingQuota; private Integer remainingQuota;
/** /**
* 平台失败描述。 * 阿里云返回 Code,失败时可用于定位错误类型。
*/ */
private String description; private String description;
/** /**
* 平台原始响应报文,排查问题时可直接查看。 * 阿里云原始响应报文,排查问题时可直接查看。
*/ */
private String rawBody; private String rawBody;
} }
@@ -1,7 +1,42 @@
package com.budwk.app.base.sms.model; package com.budwk.app.base.sms.model;
import lombok.Data;
import java.util.List;
import java.util.Map;
/** /**
* 短信发送请求参数。 * 阿里云短信发送请求参数。
*/ */
public class MsgPlatformSmsRequest extends MsgPlatformBaseRequest { @Data
public class MsgPlatformSmsRequest {
/**
* 接收人列表。
* mobile 为接收手机号;mobile 为空但 account 不为空时,会按工号查询 sys_user.mobile。
*/
private List<MsgPlatformRecipient> recipients;
/**
* 阿里云短信模板 Code。
* 为空时使用 sms.aliyun.template-code 配置。
*/
private String templateId;
/**
* 短信模板变量内容。
* customVar 为空时,会使用 sms.aliyun.template-param-name 作为变量名包装该内容。
*/
private String info;
/**
* 阿里云模板变量。
* key 为模板变量名,value 为变量内容,例如模板中有 ${content} 时传 content -> 具体通知内容。
*/
private Map<String, String> customVar;
/**
* 阿里云短信签名。
* 为空时使用 sms.aliyun.sign-name 配置。
*/
private String sendUserSign;
} }
@@ -1,25 +0,0 @@
package com.budwk.app.base.sms.model;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
/**
* 微信发送请求参数。
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class MsgPlatformWechatRequest extends MsgPlatformBaseRequest {
/**
* 微信消息类型。
* 可传 text、news、image、file、video、voice,默认 text。
*/
private String wechatType = "text";
/**
* 图文消息列表。
* 当 wechatType=news 且未直接传 info 时,service 会将该列表转成平台要求的 JSON 数组字符串。
*/
private List<MsgPlatformNewsItem> newsItems;
}
@@ -58,8 +58,8 @@ public class ProcessTaskStartEventListener implements ProcessEventListener {
List<ProcessTaskActor> taskActors = dao.query(ProcessTaskActor.class, Cnd.where(ProcessTaskActor::getProcessTaskId, "=", task.getId())); List<ProcessTaskActor> taskActors = dao.query(ProcessTaskActor.class, Cnd.where(ProcessTaskActor::getProcessTaskId, "=", task.getId()));
for (ProcessTaskActor taskActor : taskActors) { for (ProcessTaskActor taskActor : taskActors) {
// 模拟发送消息 // 待办通知内容会同步进入本地消息和短信模板变量,需明确包含接收人、实例和处理环节。
String message = StrUtil.format("您有一条待办任务,实例:{},处理环节:{}", taskActor.getActorName(), instanceName, taskDisplayName); String message = StrUtil.format("{},您有一条待办任务,实例:{},处理环节:{}", taskActor.getActorName(), instanceName, taskDisplayName);
log.info(message); log.info(message);
globalMessageSendService.sendMessage(taskDisplayName, message, 2, List.of(taskActor.getActorId()), null); globalMessageSendService.sendMessage(taskDisplayName, message, 2, List.of(taskActor.getActorId()), null);
} }
@@ -148,6 +148,7 @@ public class SysHomeController {
List<Sys_menu> menus = sysUserService.getMenus(SecurityUtil.getUserId()); List<Sys_menu> menus = sysUserService.getMenus(SecurityUtil.getUserId());
List<String> allMenuIds = menus.stream().map(Sys_menu::getId).toList(); List<String> allMenuIds = menus.stream().map(Sys_menu::getId).toList();
List<Sys_menu> sysMenus = list.stream() List<Sys_menu> sysMenus = list.stream()
.filter(m -> allMenuIds.contains(m.getId()))
.sorted(Comparator.comparing(Sys_menu::getLocation, Comparator.nullsLast(Integer::compareTo)) .sorted(Comparator.comparing(Sys_menu::getLocation, Comparator.nullsLast(Integer::compareTo))
.thenComparing(Sys_menu::getId)) .thenComparing(Sys_menu::getId))
.toList(); .toList();
@@ -166,8 +167,11 @@ public class SysHomeController {
); );
List<Sys_menu> menus = sysUserService.getMenus(SecurityUtil.getUserId()); List<Sys_menu> menus = sysUserService.getMenus(SecurityUtil.getUserId());
List<String> allMenuIds = menus.stream().map(Sys_menu::getId).toList(); List<String> allMenuIds = menus.stream().map(Sys_menu::getId).toList();
//List<Sys_menu> sysMenus = list.stream().filter(m -> allMenuIds.contains(m.getId())).sorted(Comparator.comparingInt(Sys_menu::getLocation)).toList(); List<Sys_menu> sysMenus = list.stream()
List<Sys_menu> sysMenus = list.stream().sorted(Comparator.comparingInt(Sys_menu::getLocation)).toList(); .filter(m -> allMenuIds.contains(m.getId()))
.sorted(Comparator.comparing(Sys_menu::getLocation, Comparator.nullsLast(Integer::compareTo))
.thenComparing(Sys_menu::getId))
.toList();
return Result.success(sysMenus); return Result.success(sysMenus);
} }
@@ -0,0 +1,82 @@
package com.budwk.app.sys.controller;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.exception.BaseException;
import com.budwk.app.base.result.Result;
import com.budwk.app.sys.services.SysMobileSsoService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.log.Log;
import org.nutz.log.Logs;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import javax.servlet.http.HttpServletRequest;
import java.util.Map;
/**
* 移动端单点登录控制器,提供授权跳转、授权地址获取和移动门户回调入口。
*/
@IocBean
@At("/platform/h5/sso")
@Api(tags = "移动端单点登录")
public class SysMobileSsoController {
private static final Log log = Logs.get();
@Inject
private SysMobileSsoService sysMobileSsoService;
/**
* 直接跳转到移动门户授权页面,适合浏览器访问发起单点登录。
*
* @param state 透传参数,可传本系统登录成功后的目标路径
* @return 移动门户授权页重定向地址
*/
@At("/authorize")
@Ok("re")
@ApiOperation("移动端单点登录授权跳转")
public String authorize(@Param("state") String state) {
return ">>:" + sysMobileSsoService.buildAuthorizeUrl(state);
}
/**
* 返回移动门户授权地址,适合前端自行控制跳转时调用。
*
* @param state 透传参数,可传本系统登录成功后的目标路径
* @return 包含授权地址的 JSON 结果
*/
@At("/authUrl")
@Ok("json")
@ApiOperation("获取移动端单点登录授权地址")
public Object authUrl(@Param("state") String state) {
return Result.success(Map.of("url", sysMobileSsoService.buildAuthorizeUrl(state)));
}
/**
* 移动门户授权回调入口,接收 code 后换取用户学工号并登录本系统。
*
* @param code 移动门户返回的 authorization code
* @param state 移动门户原样带回的透传参数
* @param request 当前 HTTP 请求
* @return 登录成功或失败后的重定向地址
*/
@At("/callback")
@Ok("re")
@ApiOperation("移动端单点登录回调")
public String callback(@Param("code") String code, @Param("state") String state, HttpServletRequest request) {
try {
if (StrUtil.isBlank(code)) {
return ">>:/platform/home/500";
}
return ">>:" + sysMobileSsoService.loginByAuthorizationCode(code, state, request);
} catch (BaseException e) {
log.error("移动端单点登录失败:" + e.getMessage(), e);
return ">>:/platform/home/500";
} catch (Exception e) {
log.error("移动端单点登录异常", e);
return ">>:/platform/home/500";
}
}
}
@@ -440,4 +440,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;
} }
@@ -0,0 +1,37 @@
package com.budwk.app.sys.services;
import cn.hutool.json.JSONObject;
import javax.servlet.http.HttpServletRequest;
/**
* 移动端单点登录服务,负责对接移动门户 Authorization Code 授权码方案。
*/
public interface SysMobileSsoService {
/**
* 生成移动门户授权地址,供前端或浏览器跳转获取授权码。
*
* @param state 授权回调透传参数,可用于保存登录成功后的本系统跳转地址
* @return 移动门户 OAuth 授权地址
*/
String buildAuthorizeUrl(String state);
/**
* 使用移动门户回调的授权码完成本系统登录。
*
* @param code 移动门户回调返回的 authorization code
* @param state 移动门户原样透传的 state 参数
* @param request 当前登录请求,用于写入 Sa-Token 登录态和记录登录日志
* @return 登录成功后的本系统跳转地址
*/
String loginByAuthorizationCode(String code, String state, HttpServletRequest request);
/**
* 根据授权码换取 access_token,并从返回结果中读取当前登录用户学工号。
*
* @param code 移动门户回调返回的 authorization code
* @return 移动门户 token 接口返回的 JSON 数据
*/
JSONObject exchangeAccessToken(String code);
}
@@ -1,9 +1,9 @@
package com.budwk.app.sys.services.impl; package com.budwk.app.sys.services.impl;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import cn.hutool.core.util.URLUtil;
import cn.hutool.http.HttpRequest; import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpUtil; import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject; import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil; import cn.hutool.json.JSONUtil;
import com.budwk.app.base.config.DataCenterProperties; import com.budwk.app.base.config.DataCenterProperties;
@@ -13,16 +13,19 @@ import com.budwk.app.sys.enums.Api2UnitFiledMap;
import com.budwk.app.sys.models.Sys_unit; import com.budwk.app.sys.models.Sys_unit;
import com.budwk.app.sys.services.SysDataUnitPullService; import com.budwk.app.sys.services.SysDataUnitPullService;
import com.budwk.app.sys.utils.DataCenterUtil; import com.budwk.app.sys.utils.DataCenterUtil;
import com.budwk.app.web.commons.base.Globals;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.aop.interceptor.ioc.TransAop;
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.entity.Record;
import org.nutz.dao.sql.Sql; import org.nutz.dao.sql.Sql;
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;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
@@ -40,6 +43,10 @@ import java.util.stream.Collectors;
@IocBean(args = {"refer:dao"}) @IocBean(args = {"refer:dao"})
public class SysDataUnitPullServiceImpl extends BaseServiceImpl<Sys_unit> implements SysDataUnitPullService { public class SysDataUnitPullServiceImpl extends BaseServiceImpl<Sys_unit> implements SysDataUnitPullService {
private static final String URL = "http://api.jsahvc.edu.cn/getREST_XXZZJG_c515102514c74b4e913f01e05ecb0286";
private static final String APPLY_ID = "2001030048_1778744549088";
private static final String SECRET_KEY = "9ec51871-ee77-47ce-b981-d1c29d3477fa";
@Inject @Inject
private DataCenterProperties dcPro; private DataCenterProperties dcPro;
@@ -64,54 +71,52 @@ public class SysDataUnitPullServiceImpl extends BaseServiceImpl<Sys_unit> implem
@Override @Override
@Aop(TransAop.READ_COMMITTED) @Aop(TransAop.READ_COMMITTED)
public void updateUnits(List<String> unitIds) { public void updateUnits(List<String> unitIds) {
// token url参数
Map<String, String> tokenParams = Map.of(
"grant_type", "password",
"scope", "read",
"username", "3848A3C738EFBB2C2E76E0D59B419DACA16A80F3AB0354CD",
"password", "3848A3C738EFBB2C2E76E0D59B419DACA16A80F3AB0354CD"
);
String params = URLUtil.buildQuery(tokenParams, StandardCharsets.UTF_8);
String tokenUrl = "https://bd.whcp.edu.cn/sjjkfwpt/api/bd-api/oauth/token" + (StrUtil.isBlank(params) ? "" : "?" + params);
HttpRequest tokenHttpRequest = HttpUtil.createPost(tokenUrl);
tokenHttpRequest.header("Content-Type", "application/json");
tokenHttpRequest.header("Authorization", "Basic MTE0OjU5M2UwNGI4YTE4NWRlMzkyNjY4ZmY0ZDJhZGMyZTEw");
log.info("请求token: {}", tokenHttpRequest);
String tokenResBody = tokenHttpRequest.execute().body();
log.info("请求token结果: {}", tokenResBody);
JSONObject tokenJsonBody = JSONUtil.parseObj(tokenResBody);
// 请求数据 // 请求数据
HttpRequest httpRequest = HttpUtil.createGet("https://bd.whcp.edu.cn/sjjkfwpt/api/bd-api/get/YXSDWJBSJZLB"); HttpRequest httpRequest = HttpUtil.createPost(URL);
httpRequest.form(Map.of("access_token", tokenJsonBody.getStr("access_token"), "pageSize", 100, "page", 1)); httpRequest.header("Content-Type", "application/json");
log.info("请求单位数据: {}", httpRequest); httpRequest.header("applyId", APPLY_ID);
String resBody = httpRequest.execute().body(); httpRequest.header("secretKey", SECRET_KEY);
log.info("请求单位数据结果: {}", resBody); httpRequest.body(JSONUtil.toJsonStr(Map.of("page", 1, "pagesize", 1000)));
JSONObject jsonBody = JSONUtil.parseObj(resBody);
if (jsonBody.getInt("code") != 200) { String resBody = httpRequest.execute().body();
throw new BaseException("获取单位数据失败,错误码: " + jsonBody.getInt("code") + ";错误原因:" + jsonBody.getStr("msg")); log.info("请求数据: {}", resBody);
JSONObject result = JSONUtil.parseObj(resBody);
if (!"successful".equals(result.getStr("msg"))) {
throw new BaseException("获取人员数据失败,错误码: " + result.getInt("status") + ";错误原因:" + result.getStr("msg"));
} }
// 需要新增的单位 // 需要新增的单位
List<Sys_unit> insertList = new ArrayList<>(); List<Sys_unit> insertList = new ArrayList<>();
// 需要更新的单位 // 需要更新的单位
List<Sys_unit> updateList = new ArrayList<>(); List<Sys_unit> updateList = new ArrayList<>();
JSONObject resultData = result.getJSONObject("data");
// 拉取过来的数据,全部的单位,一万四千多条,这里只保留 “部门类” JSONArray dataArray = resultData.getJSONArray("Rows");
List<JSONObject> data = jsonBody.getJSONArray("data").stream() List<JSONObject> data = dataArray.stream().map(v -> JSONUtil.parseObj(JSONUtil.toJsonStr(v))).toList();
.map(o -> (JSONObject) o)
.toList();
Map<String, String> api2db = Arrays.stream(Api2UnitFiledMap.values()) Map<String, String> api2db = Arrays.stream(Api2UnitFiledMap.values())
.collect(Collectors.toMap(e -> e.apiField, e -> e.dbColumn)); .collect(Collectors.toMap(e -> e.apiField, e -> e.dbColumn));
for (JSONObject row : data) { for (JSONObject row : data) {
Sys_unit unit = DataCenterUtil.mapJsonToBean(row, Sys_unit.class, api2db); JSONObject jsonRow = JSONUtil.parseObj(JSONUtil.toJsonStr(row));
unit.setUnitcode(unit.getId()); Sys_unit unit = DataCenterUtil.mapJsonToBean(jsonRow, Sys_unit.class, api2db);
if("100".equals(unit.getParentId())) {
unit.setUnitcode("0");
unit.setParentId("0");
unit.setUnitLevel(1);
unit.setUnitType("根部门");
unit.setUnitTypeCode(0);
} else if(StringUtils.isBlank(unit.getParentId())) {
unit.setParentId("0");
unit.setUnitLevel(2);
unit.setUnitType("子部门");
unit.setUnitTypeCode(0);
} else {
unit.setUnitLevel(3);
unit.setUnitType("子部门");
unit.setUnitTypeCode(1); unit.setUnitTypeCode(1);
}
unit.setUnitcode(unit.getId());
if (unitIds.contains(unit.getId())) { if (unitIds.contains(unit.getId())) {
// 存在则更新 // 存在则更新
@@ -6,7 +6,6 @@ import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.CharsetUtil; import cn.hutool.core.util.CharsetUtil;
import cn.hutool.core.util.IdcardUtil; import cn.hutool.core.util.IdcardUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import cn.hutool.core.util.URLUtil;
import cn.hutool.crypto.digest.DigestUtil; import cn.hutool.crypto.digest.DigestUtil;
import cn.hutool.http.Header; import cn.hutool.http.Header;
import cn.hutool.http.HttpRequest; import cn.hutool.http.HttpRequest;
@@ -33,6 +32,7 @@ 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.dao.entity.Record;
import org.nutz.dao.sql.Sql; import org.nutz.dao.sql.Sql;
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;
@@ -42,7 +42,6 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.lang.reflect.Field; import java.lang.reflect.Field;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -50,6 +49,10 @@ import java.util.stream.Collectors;
@Slf4j @Slf4j
public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source> implements SysDataUserPullService { public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source> implements SysDataUserPullService {
private static final String URL = "http://api.jsahvc.edu.cn/getREST_JZGJBXXOABG_529a0bf78f574e10a4de5c6acbb48410";
private static final String APPLY_ID = "2001030048_1778744549088";
private static final String SECRET_KEY = "9ec51871-ee77-47ce-b981-d1c29d3477fa";
@Inject @Inject
private SysDictService sysDictService; private SysDictService sysDictService;
@Inject @Inject
@@ -110,59 +113,47 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source>
@Aop(TransAop.READ_COMMITTED) @Aop(TransAop.READ_COMMITTED)
public Date pull() { public Date pull() {
try { try {
// 获取数据中心的字典 List<JSONObject> rawDataList = new ArrayList<>();
List<Sys_data_dict> dataDictList = dao().query(Sys_data_dict.class, Cnd.NEW()); int page = 1;
// 字典码表Map,key为父级编码,value为子级字典列表 int total;
Map<String, List<Sys_data_dict>> dataDictMap = dataDictList.stream().collect(Collectors.groupingBy(Sys_data_dict::getParentCode)); final Integer pageSize = 1000;
do {
// token url参数
Map<String, String> tokenParams = Map.of(
"grant_type", "password",
"scope", "read",
"username", "3848A3C738EFBB2C2E76E0D59B419DACA16A80F3AB0354CD",
"password", "3848A3C738EFBB2C2E76E0D59B419DACA16A80F3AB0354CD"
);
String params = URLUtil.buildQuery(tokenParams, StandardCharsets.UTF_8);
String tokenUrl = "https://bd.whcp.edu.cn/sjjkfwpt/api/bd-api/oauth/token" + (StrUtil.isBlank(params) ? "" : "?" + params);
HttpRequest tokenHttpRequest = HttpUtil.createPost(tokenUrl);
tokenHttpRequest.header("Content-Type", "application/json");
tokenHttpRequest.header("Authorization", "Basic MTE0OjU5M2UwNGI4YTE4NWRlMzkyNjY4ZmY0ZDJhZGMyZTEw");
log.info("请求token: {}", tokenHttpRequest);
String tokenResBody = tokenHttpRequest.execute().body();
log.info("请求token结果: {}", tokenResBody);
JSONObject tokenJsonBody = JSONUtil.parseObj(tokenResBody);
// 请求数据 // 请求数据
HttpRequest httpRequest = HttpUtil.createGet("https://bd.whcp.edu.cn/sjjkfwpt/api/bd-api/get/JZGJCSJZLB"); HttpRequest httpRequest = HttpUtil.createPost(URL);
httpRequest.form(Map.of("access_token", tokenJsonBody.getStr("access_token"), "pageSize", 5000, "page", 1)); httpRequest.header("Content-Type", "application/json");
httpRequest.header("applyId", APPLY_ID);
httpRequest.header("secretKey", SECRET_KEY);
String jsonStr = JSONUtil.toJsonStr(Map.of("page", page, "pagesize", pageSize));
httpRequest.body(jsonStr);
log.info("请求人员数据: {}", httpRequest);
String resBody = httpRequest.execute().body(); String resBody = httpRequest.execute().body();
log.info("请求人员数据结果: {}", resBody); log.info("请求数据: {}", resBody);
JSONObject jsonBody = JSONUtil.parseObj(resBody); JSONObject result = JSONUtil.parseObj(resBody);
if (jsonBody.getInt("code") != 200) { if (!"successful".equals(result.getStr("msg"))) {
throw new BaseException("获取人员数据失败,错误码: " + jsonBody.getInt("code") + ";错误原因:" + jsonBody.getStr("msg")); throw new BaseException("获取人员数据失败,错误码: " + result.getInt("status") + ";错误原因:" + result.getStr("msg"));
} }
List<JSONObject> rawDataList = new ArrayList<>(); JSONObject resultData = result.getJSONObject("data");
JSONArray data = jsonBody.getJSONArray("data"); total = result.getInt("total");
JSONArray data = resultData.getJSONArray("Rows");
if (!data.isEmpty()) { if (!data.isEmpty()) {
// 先收集所有原始数据 // 先收集所有原始数据
rawDataList = data.stream().map(v -> (JSONObject) v).toList(); rawDataList.addAll(data.stream().map(v -> JSONUtil.parseObj(JSONUtil.toJsonStr(v))).toList());
log.info("数据拉取进度: {}/{}", rawDataList.size(), jsonBody.getInt("total")); log.info("数据拉取进度: 总数据量:{},已经拉取{},本次拉取:{}", total, rawDataList.size(), data.size());
} else { } else {
log.warn("当前未获取到数据"); log.warn("当前未获取到数据");
} }
page ++;
} while (page <= Math.ceil(total / (double) pageSize));
Date nowDate = new Date(); Date nowDate = new Date();
// 赋值 // 赋值
List<Sys_user_source> latestSourceList = rawDataList.stream().map(raw -> { List<Sys_user_source> latestSourceList = rawDataList.stream().filter(e -> e.getStr("BH").length() == 10).map(raw -> {
Sys_user_source sysUser = new Sys_user_source(); Sys_user_source sysUser = new Sys_user_source();
// 使用缓存的字段映射 // 使用缓存的字段映射
@@ -170,25 +161,9 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source>
try { try {
// 根据字段类型设置值 // 根据字段类型设置值
if (mapping.field.getType() == String.class) { if (mapping.field.getType() == String.class) {
if (StrUtil.isNotBlank(mapping.dict)) {
Sys_data_dict sysDataDict = dataDictMap.get(mapping.dict).stream()
.filter(v -> raw.getStr(mapping.key)
.equals(v.getCode())).findFirst().orElse(new Sys_data_dict());
mapping.field.set(sysUser, sysDataDict.getName());
} else {
// 特殊处理 根据身份证号获取性别和出生年月
if ("SFZJH".equals(mapping.field.getName())) {
String idCard = raw.getStr(mapping.key);
// 设置出生年月
try {
sysUser.setBirthday(IdcardUtil.getBirthDate(idCard));
} catch (Exception e) {
sysUser.setBirthday(null);
}
} else {
mapping.field.set(sysUser, raw.getStr(mapping.key)); mapping.field.set(sysUser, raw.getStr(mapping.key));
} if("XB".equals(mapping.key) && StrUtil.isNotBlank(raw.getStr(mapping.key))) {
mapping.field.set(sysUser, raw.getStr(mapping.key).replace("性", ""));
} }
} else if (mapping.field.getType() == Date.class) { } else if (mapping.field.getType() == Date.class) {
mapping.field.set(sysUser, raw.getDate(mapping.key)); mapping.field.set(sysUser, raw.getDate(mapping.key));
@@ -198,15 +173,15 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source>
} }
} }
// 设置拉取时间
sysUser.setPullTime(nowDate); sysUser.setPullTime(nowDate);
return sysUser; return sysUser;
}).toList(); }).toList();
log.info("用户数据初始化完成,等待插入,当前数据条数{}", latestSourceList.size()); log.info("用户数据初始化完成,等待插入,当前数据条数{}", latestSourceList.size());
// 插入到数据库 // 插入到数据库
dao().insert(latestSourceList); dao().insert(latestSourceList);
// 更新字典数据
this.updateDict(latestSourceList);
// 人员的单位数据和数据库的单位数据比较,如果人员里面有单位不存在,去更新单位数据 // 人员的单位数据和数据库的单位数据比较,如果人员里面有单位不存在,去更新单位数据
List<String> sourceUnitIds = latestSourceList.stream().map(Sys_user_source::getUnitId).distinct().toList(); List<String> sourceUnitIds = latestSourceList.stream().map(Sys_user_source::getUnitId).distinct().toList();
@@ -221,10 +196,16 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source>
} }
return nowDate; return nowDate;
} catch (Exception e) { } catch (Exception e) {
throw new BaseException("数据拉取失败,{}", e.getMessage()); log.error("数据拉取失败,{}", e.getMessage());
throw new BaseException("数据拉取失败");
} }
} }
@Override
public Map<String, Date> pullPostDoctoral() {
return null;
}
@Async @Async
private void updateDict(List<Sys_user_source> userSources) { private void updateDict(List<Sys_user_source> userSources) {
//判断是否要更新在职状态字典 //判断是否要更新在职状态字典
@@ -234,9 +215,7 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source>
//判断是否要更新学位字典 //判断是否要更新学位字典
List<String> sourceAcademicDegrees = userSources.stream().map(Sys_user::getAcademicDegree).filter(StrUtil::isNotBlank).distinct().toList(); List<String> sourceAcademicDegrees = userSources.stream().map(Sys_user::getAcademicDegree).filter(StrUtil::isNotBlank).distinct().toList();
//判断是否要更新教职工类别字典 //判断是否要更新教职工类别字典
List<String> personTypes = userSources.stream().map(Sys_user::getPersonType).filter(StrUtil::isNotBlank).distinct().toList(); List<String> sourcePersonTypes = userSources.stream().map(Sys_user::getPersonType).filter(StrUtil::isNotBlank).distinct().toList();
//判断是否要更新身份类型字典
List<String> sourceIdentityTypes = userSources.stream().map(Sys_user::getIdentityType).filter(StrUtil::isNotBlank).distinct().toList();
//系统在职状态字典 //系统在职状态字典
List<Sys_dict> sysUserStates = sysDictService.getSubListByCode("USER_STATE"); List<Sys_dict> sysUserStates = sysDictService.getSubListByCode("USER_STATE");
@@ -244,10 +223,7 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source>
List<Sys_dict> sysEducations = sysDictService.getSubListByCode("USER_EDUCATION"); List<Sys_dict> sysEducations = sysDictService.getSubListByCode("USER_EDUCATION");
//系统学位字典 //系统学位字典
List<Sys_dict> sysAcademicDegrees = sysDictService.getSubListByCode("USER_ACADEMIC_DEGREE"); List<Sys_dict> sysAcademicDegrees = sysDictService.getSubListByCode("USER_ACADEMIC_DEGREE");
//系统教职工类别字典 List<Sys_dict> sysPersonTypes = sysDictService.getSubListByCode("USER_PERSON_TYPE");
List<Sys_dict> sysStaffTypes = sysDictService.getSubListByCode("USER_STAFF_TYPE");
//系统身份类型字典
List<Sys_dict> sysIdentityTypes = sysDictService.getSubListByCode("USER_IDENTITY_TYPE");
//系统中不存在的就插入 //系统中不存在的就插入
List<Sys_dict> insertUserStates = sourceUserStates.stream().filter(state -> sysUserStates.stream().noneMatch(dict -> dict.getCode().equals(state))).map(state -> { List<Sys_dict> insertUserStates = sourceUserStates.stream().filter(state -> sysUserStates.stream().noneMatch(dict -> dict.getCode().equals(state))).map(state -> {
@@ -271,6 +247,13 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source>
return dict; return dict;
}).toList(); }).toList();
List<Sys_dict> insertPersonTypes = sourcePersonTypes.stream().filter(personType -> sysPersonTypes.stream().noneMatch(dict -> dict.getCode().equals(personType))).map(personType -> {
Sys_dict dict = new Sys_dict();
dict.setCode(personType);
dict.setName(personType);
return dict;
}).toList();
for (Sys_dict dict : insertUserStates) { for (Sys_dict dict : insertUserStates) {
sysDictService.saveByParentCode(dict, "USER_STATE"); sysDictService.saveByParentCode(dict, "USER_STATE");
} }
@@ -280,63 +263,12 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source>
for (Sys_dict dict : insertAcademicDegrees) { for (Sys_dict dict : insertAcademicDegrees) {
sysDictService.saveByParentCode(dict, "USER_ACADEMIC_DEGREE"); sysDictService.saveByParentCode(dict, "USER_ACADEMIC_DEGREE");
} }
for (Sys_dict dict : insertPersonTypes) {
sysDictService.saveByParentCode(dict, "USER_PERSON_TYPE");
}
sysDictService.clearCache(); sysDictService.clearCache();
} }
@Override
public Map<String, Date> pullPostDoctoral() {
String appId = "1926887238437818370";
String secret = "32df31fcf4fc43f9a647ee1f47134f36";
List<JSONObject> allUsers = new ArrayList<>();
int skip = 0;
int totalCount = 0;
boolean firstRequest = true;
do {
long ts = System.currentTimeMillis();
String sign = Base64.encode(DigestUtil.md5Hex(appId + secret + ts, CharsetUtil.CHARSET_UTF_8));
HttpRequest httpRequest = HttpUtil.createPost("https://sjzcpt.nnu.edu.cn/cdsp/data-api/v2/DS0040");
httpRequest.header("Content-Type", "application/json");
httpRequest.header("appId", appId);
httpRequest.header("timestamp", String.valueOf(ts));
httpRequest.header("sign", sign);
HashMap<String, Object> reqBody = new HashMap<>();
reqBody.put("$count", true);
reqBody.put("$skip", skip);
reqBody.put("$top", 1000);
httpRequest.body(JSONUtil.toJsonStr(reqBody));
String resBody = httpRequest.execute().body();
JSONObject entries = JSONUtil.parseObj(resBody);
if (firstRequest) {
totalCount = entries.getInt("@odata.count", 0);
firstRequest = false;
}
JSONArray value = entries.getJSONArray("value");
if (!value.isEmpty()) {
List<JSONObject> list = value.stream().map(item -> (JSONObject) item).toList();
allUsers.addAll(list);
skip += list.size();
} else {
break;
}
} while (skip < totalCount);
Map<String, Date> data = allUsers.stream().filter(v -> v.getStr("进站日期") != null).collect(Collectors.toMap(v -> v.getStr("教职工号"), v -> v.getDate("进站日期")));
return data;
}
@Override @Override
public NutMap searchOptions() { public NutMap searchOptions() {
Sql sql = Sqls.create(""" Sql sql = Sqls.create("""
@@ -385,73 +317,8 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source>
return listMap(sql); return listMap(sql);
} }
@Override @Override
public Map<String, NutMap> pullFinance() { public Map<String, NutMap> pullFinance() {
// 获取配置文件 接口地址 和 token return null;
DataCenterProperties.Credential credential = dataCenterProperties.credential("finance");
String url = credential.getUrl();
String token = credential.getToken();
List<JSONObject> rawDataList = new ArrayList<>();
String year = String.valueOf(DateUtil.thisYear());
String month = String.valueOf(DateUtil.thisMonth() + 1);
int pageNum = 1;
int pageSize = 1000;
int totalPages = 1;
int totalCount = 0;
do {
HttpRequest httpRequest = HttpUtil.createPost(url);
httpRequest.header(Header.CONTENT_TYPE, "application/json");
httpRequest.header("Accept-Encoding", "gzip, deflate, br");
httpRequest.header("X-H3C-TOKEN", token);
Map<String, Object> reqBody = Map.of(
"nf", year,
"yf", month,
"pageNum", pageNum,
"pageSize", pageSize
);
httpRequest.body(JSONUtil.toJsonStr(reqBody));
JSONObject resp = JSONUtil.parseObj(httpRequest.execute().body());
if (resp.getInt("code") != 0) {
throw new BaseException("获取财务数据失败,错误码: " + resp.getInt("code") + ";错误原因:" + resp.getStr("msg"));
}
JSONObject data = resp.getJSONObject("data");
if (pageNum == 1) {
totalCount = data.getInt("total", 0);
// totalPages = data.getInt("pages", 0);
totalPages = (int) Math.ceil((double) totalCount / pageSize);
}
JSONArray records = data.getJSONArray("records");
if (CollUtil.isNotEmpty(records)) {
rawDataList.addAll(records.stream().map(v -> (JSONObject) v).toList());
log.info("数据拉取进度: {}/{}", rawDataList.size(), totalCount);
}
pageNum++; // 只加页码
} while (pageNum <= totalPages);
// 接口数据输出
log.info("数据拉取结果: {}", rawDataList);
// 最后结果数据
Map<String, NutMap> result = new HashMap<>();
Map<String, String> api2db = Arrays.stream(Api2FinanceFiledMap.values())
.collect(Collectors.toMap(e -> e.apiField, e -> e.dbColumn));
for (JSONObject row : rawDataList) {
NutMap nutMap = DataCenterUtil.mapJsonToBean(row, NutMap.class, api2db);
result.put(nutMap.getString("loginname"), nutMap);
}
// 输出结果
log.info("财务数据拉取转换结果: {}", result);
log.info("数据拉取完成,共拉取 {} 条数据", result.size());
return result;
} }
} }
@@ -0,0 +1,207 @@
package com.budwk.app.sys.services.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.budwk.app.base.enums.LoginType;
import com.budwk.app.base.exception.BaseException;
import com.budwk.app.sys.models.Sys_user;
import com.budwk.app.sys.services.SysMobileSsoService;
import com.budwk.app.sys.services.SysUserService;
import com.budwk.app.web.commons.base.Globals;
import org.nutz.ioc.impl.PropertiesProxy;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.log.Log;
import org.nutz.log.Logs;
import javax.servlet.http.HttpServletRequest;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 移动端单点登录服务实现,按移动门户文档方案一完成授权码换取用户身份并登录本系统。
*/
@IocBean
public class SysMobileSsoServiceImpl implements SysMobileSsoService {
private static final Log log = Logs.get();
private static final String DEFAULT_AUTHORIZE_URL = "http://app.jsahvc.edu.cn/interface/oauth/authorize.htm";
private static final String DEFAULT_TOKEN_URL = "http://app.jsahvc.edu.cn/interface/oauth/token.htm";
private static final String GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code";
private static final String RESPONSE_TYPE_CODE = "code";
@Inject
private SysUserService sysUserService;
@Inject
private PropertiesProxy conf;
/**
* 生成移动门户 OAuth 授权地址,授权成功后移动门户会携带 code 回调 redirect_uri。
*
* @param state 透传字段,通常用于保存本系统登录后的目标页面
* @return 拼接完成的移动门户授权地址
*/
@Override
public String buildAuthorizeUrl(String state) {
String clientId = getRequiredConfig("mobile.sso.client-id", "移动端单点登录 client_id 未配置");
String redirectUri = getRedirectUri();
String authorizeUrl = conf.get("mobile.sso.authorize-url", DEFAULT_AUTHORIZE_URL);
Map<String, String> params = new LinkedHashMap<>();
params.put("response_type", RESPONSE_TYPE_CODE);
params.put("client_id", clientId);
params.put("redirect_uri", redirectUri);
if (StrUtil.isNotBlank(state)) {
params.put("state", state);
}
return authorizeUrl + "?" + buildQuery(params);
}
/**
* 根据授权码换取移动门户 access_token,并使用返回的 username 登录本系统。
*
* @param code 移动门户回调返回的 authorization code
* @param state 移动门户原样透传的 state 参数
* @param request 当前 HTTP 请求
* @return 登录完成后的跳转地址
*/
@Override
public String loginByAuthorizationCode(String code, String state, HttpServletRequest request) {
if (StrUtil.isBlank(code)) {
throw new BaseException("移动端单点登录授权码不能为空");
}
JSONObject tokenJson = exchangeAccessToken(code);
log.debug("移动端单点登录: "+code+","+state);
log.debug("移动端单点登录tokenJson返回内容:" + tokenJson);
String loginName = tokenJson.getStr("username");
if (StrUtil.isBlank(loginName)) {
log.error("移动端单点登录未返回 username,返回内容:" + tokenJson);
throw new BaseException("移动端单点登录未返回用户学工号");
}
// 校验第三方返回的学工号必须存在于本系统,避免创建未知登录态。
sysUserService.checkThirdPlatformLoginName(loginName);
Sys_user sysUser = sysUserService.loginByLoginName(loginName);
// 复用系统统一登录增强逻辑,写入 Sa-Token 会话、用户扩展信息和登录日志。
sysUserService.loginPlus(sysUser, LoginType.CAS, request);
return resolveRedirectUrl(state);
}
/**
* 请求移动门户 token 接口,将 authorization code 换成 access_token 和 username。
*
* @param code 移动门户授权码
* @return 移动门户 token 接口 JSON 响应
*/
@Override
public JSONObject exchangeAccessToken(String code) {
String clientId = getRequiredConfig("mobile.sso.client-id", "移动端单点登录 client_id 未配置");
String clientSecret = getRequiredConfig("mobile.sso.client-secret", "移动端单点登录 client_secret 未配置");
String tokenUrl = conf.get("mobile.sso.token-url", DEFAULT_TOKEN_URL);
Map<String, Object> form = new LinkedHashMap<>();
form.put("grant_type", GRANT_TYPE_AUTHORIZATION_CODE);
form.put("client_id", clientId);
form.put("client_secret", clientSecret);
form.put("code", code);
form.put("redirect_uri", getRedirectUri());
try (HttpResponse response = HttpRequest.post(tokenUrl)
.basicAuth(clientId, clientSecret)
.form(form)
.timeout(conf.getInt("mobile.sso.timeout", 10000))
.execute()) {
String body = response.body();
log.debug("移动端单点登录返回内容:" + body);
if (!response.isOk()) {
log.error("移动端单点登录 token 接口请求失败,状态码:" + response.getStatus() + ",返回内容:" + body);
throw new BaseException("移动端单点登录 token 接口请求失败");
}
if (!JSONUtil.isTypeJSON(body)) {
log.error("移动端单点登录 token 接口返回非 JSON 内容:" + body);
throw new BaseException("移动端单点登录 token 接口返回格式错误");
}
JSONObject tokenJson = JSONUtil.parseObj(body);
if (StrUtil.isBlank(tokenJson.getStr("access_token"))) {
log.error("移动端单点登录 token 接口未返回 access_token,返回内容:" + tokenJson);
throw new BaseException("移动端单点登录 token 获取失败");
}
return tokenJson;
} catch (BaseException e) {
throw e;
} catch (Exception e) {
log.error("移动端单点登录 token 接口调用异常", e);
throw new BaseException("移动端单点登录 token 接口调用异常");
}
}
/**
* 获取移动门户回调地址,默认使用当前系统域名拼接本控制器 callback 地址。
*
* @return 与授权请求、token 请求保持一致的 redirect_uri
*/
private String getRedirectUri() {
String redirectUri = conf.get("mobile.sso.redirect-uri", "");
if (StrUtil.isNotBlank(redirectUri)) {
return redirectUri;
}
return Globals.AppDomain + "/platform/h5/sso/callback";
}
/**
* 读取必填配置,缺失时给出明确业务异常,避免使用空密钥请求移动门户。
*
* @param key 配置键
* @param errorMessage 配置缺失提示
* @return 配置值
*/
private String getRequiredConfig(String key, String errorMessage) {
String value = conf.get(key, "");
if (StrUtil.isBlank(value)) {
throw new BaseException(errorMessage);
}
return value;
}
/**
* 拼接 URL 查询参数,统一做 UTF-8 编码防止 redirect_uri、state 中特殊字符破坏授权地址。
*
* @param params 查询参数
* @return 编码后的查询字符串
*/
private String buildQuery(Map<String, String> params) {
return params.entrySet().stream()
.map(entry -> encode(entry.getKey()) + "=" + encode(entry.getValue()))
.reduce((left, right) -> left + "&" + right)
.orElse("");
}
/**
* 对 URL 参数进行 UTF-8 编码。
*
* @param value 参数原始值
* @return 编码后的参数值
*/
private String encode(String value) {
return URLEncoder.encode(StrUtil.nullToEmpty(value), StandardCharsets.UTF_8);
}
/**
* 根据 state 解析登录后的跳转地址,只允许本系统内路径,避免开放重定向风险。
*
* @param state 移动门户透传参数
* @return 登录成功后的跳转地址
*/
private String resolveRedirectUrl(String state) {
if (StrUtil.isNotBlank(state) && state.startsWith("/") && !state.startsWith("//")) {
return Globals.AppDomain + state;
}
return Globals.AppDomain + "/platform/h5";
}
}
@@ -6,7 +6,6 @@ import cn.hutool.http.HtmlUtil;
import com.budwk.app.base.constant.RedisConstant; import com.budwk.app.base.constant.RedisConstant;
import com.budwk.app.base.service.impl.BaseServiceImpl; import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.base.sms.SmsService; import com.budwk.app.base.sms.SmsService;
import com.budwk.app.base.sms.model.MsgPlatformWechatRequest;
import com.budwk.app.base.utils.ManyAddOrRenewUtil; import com.budwk.app.base.utils.ManyAddOrRenewUtil;
import com.budwk.app.sys.models.Sys_msg; import com.budwk.app.sys.models.Sys_msg;
import com.budwk.app.sys.models.Sys_msg_user; import com.budwk.app.sys.models.Sys_msg_user;
@@ -106,7 +105,6 @@ public class SysMsgServiceImpl extends BaseServiceImpl<Sys_msg> implements SysMs
ThreadUtil.execute(() -> { ThreadUtil.execute(() -> {
for (String loginName : loginNames) { for (String loginName : loginNames) {
smsService.sendSmsByAccount(loginName,sysMsg.getNote()); smsService.sendSmsByAccount(loginName,sysMsg.getNote());
smsService.sendWechatTextByAccountOrMobile(loginName,sysMsg.getNote());
} }
}); });
@@ -125,7 +125,7 @@ public class CommonController {
@At @At
@SaCheckLogin @SaCheckLogin
@ApiOperation("单位列表") @ApiOperation("单位列表")
public Result unitOptions(String unionId) { public Result unitOptions(String unionId, Integer unitLevel) {
Sql sql = Sqls.create(""" Sql sql = Sqls.create("""
SELECT SELECT
unit.*, unit.*,
@@ -138,6 +138,8 @@ public class CommonController {
Cnd cnd = Cnd.NEW(); Cnd cnd = Cnd.NEW();
cnd.andEX("unit.unionId", "=", unionId); cnd.andEX("unit.unionId", "=", unionId);
cnd.and("unit.unitTypeCode", "=", "1"); cnd.and("unit.unitTypeCode", "=", "1");
// unitLevel 为可选筛选条件,不传时保持原有单位列表查询逻辑。
cnd.andEX("unit.unitLevel", "=", unitLevel);
cnd.asc("unit.unitcode"); cnd.asc("unit.unitcode");
sql.setCondition(cnd); sql.setCondition(cnd);
List<NutMap> unitList = sysUserService.listMap(sql); List<NutMap> unitList = sysUserService.listMap(sql);
@@ -22,10 +22,8 @@ import cn.dev33.satoken.annotation.SaCheckPermission;
import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Chain; import org.nutz.dao.Chain;
import org.nutz.dao.Cnd; import org.nutz.dao.Cnd;
import com.budwk.app.base.dao.CndPlus;
import org.nutz.dao.Dao; import org.nutz.dao.Dao;
import org.nutz.dao.sql.Sql; import org.nutz.dao.sql.Sql;
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;
@@ -89,17 +87,17 @@ public class FitnessWalkStepRaffleController {
/** /**
* 手动抽奖规则 * 保存手动抽奖规则并返回实际抽取结果。
* @param fitnessWalkRaffleRules * @param fitnessWalkRaffleRules 活动 ID 及奖项规则,包含奖项名称、抽奖人数、类型、位次和兼得设置
* @return * @return Result,code 为 0 表示抽奖完成,msg 包含人数不足奖项的实际抽取说明
*/ */
@At @At
@Aop(TransAop.READ_COMMITTED) @Aop(TransAop.READ_COMMITTED)
@SaCheckPermission("fitnessWalk.stepRaffle") @SaCheckPermission("fitnessWalk.stepRaffle")
public Result saveRaffle(FitnessWalkRaffleRules fitnessWalkRaffleRules){ public Result saveRaffle(FitnessWalkRaffleRules fitnessWalkRaffleRules){
baseService.dao().insertOrUpdate(fitnessWalkRaffleRules); baseService.dao().insertOrUpdate(fitnessWalkRaffleRules);
fitnessWalkStepRaffleService.manualLottery(fitnessWalkRaffleRules.getActivityId()); String message = fitnessWalkStepRaffleService.manualLottery(fitnessWalkRaffleRules.getActivityId());
return Result.success(); return Result.success(message);
} }
@@ -194,26 +192,37 @@ public class FitnessWalkStepRaffleController {
} }
/**
* 后台查看当前活动的全部中奖人员。
*
* @param activityId 当前活动 ID,必填
* @param lotteryTime 奖项名称(沿用页面参数名),空值查询全部奖项
* @param searchKeyword 姓名或工号关键字,空值查询全部中奖人员
* @return Result,data 为中奖人员 Map 列表,isRaffleText 表示是否已在手机端抽奖
*/
@At
@SaCheckPermission("fitnessWalk.stepRaffle")
public Result awardUsers(String activityId, String lotteryTime, String searchKeyword) {
if (StrUtil.isBlank(activityId)) {
return Result.error("请选择活动");
}
return Result.success(fitnessWalkStepRaffleService.getAwardUsers(activityId, lotteryTime, searchKeyword));
}
/**
* 导出当前筛选条件下的全部中奖人员,包含手机端是否已抽奖。
*
* @param searchKeyword 姓名或工号关键字,空值不限制人员
* @param activityId 当前活动 ID,空值导出空名单
* @param lotteryTime 奖项名称(沿用页面参数名),空值不限制奖项
* @param response HTTP 下载响应,写入包含人员、奖项、是否抽奖及中奖时间的 XSSF 工作簿
*/
@At @At
@Ok("void") @Ok("void")
@SaCheckPermission("fitnessWalk.stepRaffle") @SaCheckPermission("fitnessWalk.stepRaffle")
public void exportAwardExcel(String searchKeyword, String activityId, public void exportAwardExcel(String searchKeyword, String activityId,
String lotteryTime, HttpServletResponse response) { String lotteryTime, HttpServletResponse response) {
CndPlus cnd = CndPlus.create(); List<NutMap> list = fitnessWalkStepRaffleService.getAwardUsers(activityId, lotteryTime, searchKeyword);
cnd.andEX("activityId", "=", activityId);
cnd.andEX("awardName", "=", lotteryTime);
if (StrUtil.isNotBlank(searchKeyword)) {
SqlExpressionGroup seg = new SqlExpressionGroup();
seg.orLike("username", searchKeyword);
seg.orLike("loginName", searchKeyword);
cnd.and(seg);
}
cnd.and("isRead", "=", true);
List<FitnessWalkAwardUser> list = fitnessWalkCommonService.dao().query(FitnessWalkAwardUser.class, cnd);
for (FitnessWalkAwardUser awardUser : list) {
awardUser.setApplyDate(awardUser.getApplyDate());
}
List<ExcelExportEntity> entityList = new ArrayList<>(); List<ExcelExportEntity> entityList = new ArrayList<>();
ExcelExportEntity no = new ExcelExportEntity("序号", "no", 10); ExcelExportEntity no = new ExcelExportEntity("序号", "no", 10);
@@ -224,6 +233,7 @@ public class FitnessWalkStepRaffleController {
entityList.add(new ExcelExportEntity("分工会", "unionName", 20)); entityList.add(new ExcelExportEntity("分工会", "unionName", 20));
entityList.add(new ExcelExportEntity("单位", "unitName", 40)); entityList.add(new ExcelExportEntity("单位", "unitName", 40));
entityList.add(new ExcelExportEntity("奖项名称", "awardName", 20)); entityList.add(new ExcelExportEntity("奖项名称", "awardName", 20));
entityList.add(new ExcelExportEntity("是否抽奖", "isRaffleText", 15));
entityList.add(new ExcelExportEntity("中奖时间", "applyDate2", 20)); entityList.add(new ExcelExportEntity("中奖时间", "applyDate2", 20));
try { try {
@@ -302,6 +302,8 @@ public class FitnessWalkStepRankingController {
entityList.add(new ExcelExportEntity("分工会", "unionname", 20)); entityList.add(new ExcelExportEntity("分工会", "unionname", 20));
entityList.add(new ExcelExportEntity("单位", "unitname", 20)); entityList.add(new ExcelExportEntity("单位", "unitname", 20));
entityList.add(new ExcelExportEntity("today".equals(mode) ? "今日步数" : "活动期间总步数", "total_steps", 20)); entityList.add(new ExcelExportEntity("today".equals(mode) ? "今日步数" : "活动期间总步数", "total_steps", 20));
// 与页面共用排行榜查询结果,今日榜导出也展示活动期间累计的达标天数。
entityList.add(new ExcelExportEntity("达标天数", "standardsDays", 20));
try { try {
String fileName = ("today".equals(mode) ? DateUtil.format(new Date(), "yyyy年MM月dd日") : "活动期间总") + "步数榜.xlsx"; String fileName = ("today".equals(mode) ? DateUtil.format(new Date(), "yyyy年MM月dd日") : "活动期间总") + "步数榜.xlsx";
ExportParams exportParams = new ExportParams(); ExportParams exportParams = new ExportParams();
@@ -88,9 +88,9 @@ public class FitnessWalkStepStatisticsController {
@Param("year") Integer year, @Param("year") Integer year,
@Param("activityId") String activityId, @Param("activityId") String activityId,
@Param("activityDate") String[] activityDate, @Param("activityDate") String[] activityDate,
@Param("unionId") String unionId, String unionId,
@Param("unitId") String unitId) { String unitId) {
CndPlus cnd = CndPlus.create(); Cnd cnd = Cnd.NEW();
Sql sql = Sqls.create(getSql()); Sql sql = Sqls.create(getSql());
if (Lang.isNotEmpty(activityDate)) { if (Lang.isNotEmpty(activityDate)) {
cnd.and("date_format(al.applyDate,'%Y-%m-%d')", ">=", activityDate[0]); cnd.and("date_format(al.applyDate,'%Y-%m-%d')", ">=", activityDate[0]);
@@ -0,0 +1,70 @@
package com.budwk.app.zhgh.activity.fitnesswalk.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.date.DateUtil;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.activity.fitnesswalk.service.FitnessWalkStepUnionStatisticsService;
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;
/**
* 分工会活动达标统计,活动资格判断及人数汇总统一交由 service 处理。
*/
@IocBean
@Ok("json")
@At("/platform/fitnessWalk/stepUnionStatistics")
public class FitnessWalkStepUnionStatisticsController {
@Inject
private FitnessWalkStepUnionStatisticsService fitnessWalkStepUnionStatisticsService;
/**
* 打开分工会活动达标统计页面,菜单链接与当前路由保持一致。
*/
@At("")
@Ok("beetl:/platform/zhgh/activity/fitnesswalk/stepUnionStatistics.html")
@SaCheckPermission("fitnessWalk.stepUnionStatistics")
public void index() {
}
/**
* 获取年份对应的活动选项,供页面多选;默认查询本年度。
*
* @param year 活动开始年份,不传时使用当前年份
* @return Result;data 为活动选项数组,选项的 value 是活动 ID,label 和 ruleLabel 用于展示名称及达标标准
*/
@At
@SaCheckPermission("fitnessWalk.stepUnionStatistics")
public Result activityOptions(@Param("year") Integer year) {
try {
return Result.success(fitnessWalkStepUnionStatisticsService.getActivityOptions(year == null ? DateUtil.thisYear() : year));
} catch (IllegalArgumentException e) {
return Result.error(e.getMessage());
}
}
/**
* 接收活动多选及分工会筛选,返回各活动的达标结果和所选范围的平均达标率。
*
* @param year 活动开始年份,不传时使用当前年份
* @param activityIds 活动 ID 数组,页面通过 JSON 数组提交;空数组表示该年份全部活动
* @param unionId 分工会 ID,空值表示查询当前账号可查看的全部分工会
* @return Result;data.activities 为实际统计活动,data.list 为分工会行,
* activityStatistics 按活动 ID 保存人数及比例,averageQualifiedRate 为所选活动平均达标率
*/
@At
@Ok("json:full")
@SaCheckPermission("fitnessWalk.stepUnionStatistics")
public Result pageData(@Param("year") Integer year,
@Param("activityIds") String[] activityIds,
@Param("unionId") String unionId) {
try {
return Result.success(fitnessWalkStepUnionStatisticsService.getUnionStatistics(year == null ? DateUtil.thisYear() : year, activityIds, unionId));
} catch (IllegalArgumentException e) {
return Result.error(e.getMessage());
}
}
}
@@ -19,6 +19,17 @@ public interface FitnessWalkStepRaffleService extends BaseService<FitnessWalkRaf
Sql getRaffleSql(PageForm pageForm, String activityId, String unionId, String unitId); Sql getRaffleSql(PageForm pageForm, String activityId, String unionId, String unitId);
/**
* 查询后台完整中奖名单,列表和导出共用,不受手机端是否已抽奖限制。
*
* @param activityId 活动 ID,空值返回空名单,避免跨活动查询
* @param awardName 奖项名称,空值表示该活动的全部奖项
* @param searchKeyword 姓名或工号关键字,空值表示不限制人员
* @return 中奖记录 Map 列表;包含人员及奖项信息,isRaffleText 为“是/否”,
* applyDate2 为手机端抽奖时间文本,未抽奖时为空字符串
*/
List<NutMap> getAwardUsers(String activityId, String awardName, String searchKeyword);
/** /**
* 获取活动的奖项 * 获取活动的奖项
* @param activityId * @param activityId
@@ -27,10 +38,11 @@ public interface FitnessWalkStepRaffleService extends BaseService<FitnessWalkRaf
List<NutMap> getActivityAwardList(String activityId); List<NutMap> getActivityAwardList(String activityId);
/** /**
* 手动抽奖 * 按规则重新抽奖,人数不足时按实际可抽人数抽取。
* @param activityId 活动id * @param activityId 活动 ID,用于查询资格及奖项规则、替换本活动中奖名单
* @return 完成提示,包含人数不足奖项的配置人数及实际抽取人数
*/ */
void manualLottery(String activityId); String manualLottery(String activityId);
} }
@@ -0,0 +1,33 @@
package com.budwk.app.zhgh.activity.fitnesswalk.service;
import com.budwk.app.base.service.BaseService;
import com.budwk.app.zhgh.activity.fitnesswalk.model.FitnessWalkStep;
import org.nutz.lang.util.NutMap;
import java.util.List;
/**
* 按计步活动统计分工会会员的达标人数、达标率及所选活动的平均达标率。
*/
public interface FitnessWalkStepUnionStatisticsService extends BaseService<FitnessWalkStep> {
/**
* 查询指定年份开始、且当前账号可查看的计步活动。
*
* @param year 活动开始年份,例如 2026
* @return 活动选项列表;value 为活动 ID,label 为活动名称及日期,ruleLabel 为该活动的达标规则
*/
List<NutMap> getActivityOptions(int year);
/**
* 使用各活动自己的达标配置统计分工会,活动内按人员去重,活动之间分别计算。
*
* @param year 活动开始年份,例如 2026
* @param activityIds 所选活动 ID 数组;未选择时统计该年份全部可查看活动,已选择时严格限定所选活动
* @param unionId 分工会 ID;管理员传空值时统计全部分工会,其他账号仅统计自己所属分工会
* @return 统计结果 Map;activities 为实际统计的活动选项,list 为分工会汇总行。
* 每行包含 unionId、unionName、memberNum、activityStatistics 和 averageQualifiedRate;
* activityStatistics 按活动 ID 保存 qualifiedNum(达标人数)及 qualifiedRate(百分比文本)
*/
NutMap getUnionStatistics(int year, String[] activityIds, String unionId);
}
@@ -138,9 +138,9 @@ public class FitnessWalkCommonServiceImpl extends BaseServiceImpl<FitnessWalkAwa
if (activity == null) { if (activity == null) {
return null; return null;
} }
if (AuthUtil.hasRoleOr("BRANCH_UNION_CHAIRMAN", "BRANCH_UNION_OPERATOR", "BRANCH_UNION_ADMIN") && !AuthUtil.hasRoleOr("SYSADMIN")) { /* if (AuthUtil.hasRoleOr("BRANCH_UNION_CHAIRMAN", "BRANCH_UNION_OPERATOR", "BRANCH_UNION_ADMIN") && !AuthUtil.hasRoleOr("SYSADMIN")) {
return SecurityUtil.getUnionId().equals(activity.getUnionId()) ? activity : null; return SecurityUtil.getUnionId().equals(activity.getUnionId()) ? activity : null;
} }*/
return activity; return activity;
} }
@@ -547,6 +547,17 @@ public class FitnessWalkCommonServiceImpl extends BaseServiceImpl<FitnessWalkAwa
} }
/**
* 构建排行榜查询,步数按排行榜模式统计,达标天数始终按所选活动期间统计。
*
* @param searchName 姓名或工号查询字段,沿用页面传入的 u.username 或 u.loginname
* @param searchKeyword 姓名或工号关键字,空值表示不按关键字筛选
* @param activityId 所选活动 ID,用于读取活动起止日期及抽奖资格配置
* @param unionId 分工会 ID,空值表示不限制分工会
* @param unitId 单位 ID,空值表示不限制单位
* @param mode today 统计今日步数,all 统计活动期间总步数
* @return 排行榜查询 SQL;total_steps 为当前模式步数,standardsDays 为活动期间累计达标天数,未达标返回 0
*/
private Sql rankingListSql(String searchName, String searchKeyword, String activityId, String unionId, String unitId, String mode) { private Sql rankingListSql(String searchName, String searchKeyword, String activityId, String unionId, String unitId, String mode) {
FitnessWalkActivity activity = getActivity(activityId); FitnessWalkActivity activity = getActivity(activityId);
CndPlus cnd = CndPlus.create(); CndPlus cnd = CndPlus.create();
@@ -559,7 +570,7 @@ public class FitnessWalkCommonServiceImpl extends BaseServiceImpl<FitnessWalkAwa
u.loginname, u.loginname,
u.unionName AS unionname, u.unionName AS unionname,
u.unitName AS unitname, u.unitName AS unitname,
over3k.standardsDays COALESCE(over3k.standardsDays, 0) AS standardsDays
FROM FROM
vw_user u vw_user u
LEFT JOIN ( LEFT JOIN (
@@ -584,15 +595,24 @@ public class FitnessWalkCommonServiceImpl extends BaseServiceImpl<FitnessWalkAwa
FROM FROM
`fitness_walk_step` `fitness_walk_step`
WHERE WHERE
step >= @step activityId=@activityId
AND activityId=@activityId AND DATE(applyDate) >= @activityStartDate
AND $dateSql AND DATE(applyDate) <= @activityEndDate
AND (@requireDailyQualification = 0 OR step >= @qualificationStep)
GROUP BY GROUP BY
userId userId
) AS over3k ON u.id = over3k.userId ) AS over3k ON u.id = over3k.userId
$condition $condition
""").setParam("activityId",activityId).setParam("step",0); """).setParam("activityId",activityId).setParam("step",0);
// 达标天数独立使用活动日期,切换到今日榜时也保留整个活动期间的累计结果。
sql.setParam("activityStartDate", DateUtil.format(DateUtil.date(activity.getStartTime()), "yyyy-MM-dd"));
sql.setParam("activityEndDate", DateUtil.format(DateUtil.date(activity.getEndTime()), "yyyy-MM-dd"));
// 与计步抽奖页一致:配置资格天数时按每日资格步数计数,否则统计有记录的日期。
// 历史活动的资格步数为空时按 0 处理;同一天重复记录通过 COUNT(DISTINCT DATE) 只计一次。
sql.setParam("requireDailyQualification", activity.getLotteryQualificationDays() == null ? 0 : 1);
sql.setParam("qualificationStep", activity.getLotteryQualificationSteps() == null ? 0 : activity.getLotteryQualificationSteps());
if ("today".equals(mode)) { if ("today".equals(mode)) {
sql.setVar("dateSql",new Static("DATE(applyDate) = '"+DateUtil.today()+"'")); sql.setVar("dateSql",new Static("DATE(applyDate) = '"+DateUtil.today()+"'"));
// cnd.and("DATE( s.applyDate )", "=", DateUtil.today()); // cnd.and("DATE( s.applyDate )", "=", DateUtil.today());
@@ -43,6 +43,16 @@ public class FitnessWalkStepRaffleServiceImpl extends BaseServiceImpl<FitnessWal
@Inject @Inject
private FitnessWalkCommonService fitnessWalkCommonService; private FitnessWalkCommonService fitnessWalkCommonService;
/**
* 按活动的抽奖资格查询候选人员,供达标列表、导出和手动抽奖共用。
*
* @param pageForm 查询参数,仅使用姓名或工号关键字;抽奖时可传 null
* @param activityId 活动 ID,用于读取活动日期及达标天数、步数门槛
* @param unionId 分工会 ID,空值表示不限制分工会
* @param unitId 单位 ID,空值表示不限制单位
* @return 候选人员查询 SQL;id 为用户 ID,total_steps 为规则口径下的步数,
* standardsDays 为统计天数,无匹配记录时返回 0
*/
@Override @Override
public Sql getRaffleSql(PageForm pageForm, String activityId, String unionId, String unitId) { public Sql getRaffleSql(PageForm pageForm, String activityId, String unionId, String unitId) {
FitnessWalkActivity activity = fitnessWalkCommonService.getActivity(activityId); FitnessWalkActivity activity = fitnessWalkCommonService.getActivity(activityId);
@@ -65,7 +75,7 @@ public class FitnessWalkStepRaffleServiceImpl extends BaseServiceImpl<FitnessWal
u.unionName AS unionname, u.unionName AS unionname,
u.unitId AS unitid, u.unitId AS unitid,
u.unitName AS unitname, u.unitName AS unitname,
over3k.standardsDays COALESCE(over3k.standardsDays, 0) AS standardsDays
FROM FROM
vw_user u vw_user u
LEFT JOIN ( LEFT JOIN (
@@ -100,6 +110,9 @@ public class FitnessWalkStepRaffleServiceImpl extends BaseServiceImpl<FitnessWal
) AS over3k ON u.id = over3k.userId ) AS over3k ON u.id = over3k.userId
$condition $condition
"""); """);
// Nutz 的 groupBy 会重建分组对象,必须先分组再设置 having,否则抽奖资格筛选会丢失。
// 达标天数参与分组,确保查询列做空值处理后,having 仍能引用原始天数。
cnd.groupBy("u.id", "u.username", "u.loginname", "u.unionId", "u.unionName", "u.unitId", "u.unitName", "over3k.standardsDays");
if (lotteryQualificationDays != null) { if (lotteryQualificationDays != null) {
sql.setVar("lotteryQualificationDaySql", new Static(" AND step >= '%s' ".formatted(effectiveLotteryQualificationSteps))); sql.setVar("lotteryQualificationDaySql", new Static(" AND step >= '%s' ".formatted(effectiveLotteryQualificationSteps)));
cnd.having(Cnd.NEW().and(new Static(" over3k.standardsDays >= '%s' ".formatted(lotteryQualificationDays)))); cnd.having(Cnd.NEW().and(new Static(" over3k.standardsDays >= '%s' ".formatted(lotteryQualificationDays))));
@@ -130,13 +143,52 @@ public class FitnessWalkStepRaffleServiceImpl extends BaseServiceImpl<FitnessWal
cnd.and(seg); cnd.and(seg);
} }
// cnd.and("u.id","in",Sqls.create("select userId from activity_user_scope where groupId = @groupId").setParam("groupId",activity.getGroupId())); // cnd.and("u.id","in",Sqls.create("select userId from activity_user_scope where groupId = @groupId").setParam("groupId",activity.getGroupId()));
cnd.groupBy("u.id", "u.username", "u.loginname", "u.unionId", "u.unionName", "u.unitId", "u.unitName");
cnd.desc("total_steps"); cnd.desc("total_steps");
sql.setCondition(cnd); sql.setCondition(cnd);
return sql; return sql;
} }
/**
* 查询后台完整中奖名单,列表和导出共用,不受手机端是否已抽奖限制。
*
* @param activityId 活动 ID,空值返回空名单,避免跨活动查询
* @param awardName 奖项名称,空值表示该活动的全部奖项
* @param searchKeyword 姓名或工号关键字,空值表示不限制人员
* @return 中奖记录 Map 列表;包含人员及奖项信息,isRaffleText 为“是/否”,
* applyDate2 为手机端抽奖时间文本,未抽奖时为空字符串
*/
@Override
public List<NutMap> getAwardUsers(String activityId, String awardName, String searchKeyword) {
// 未选活动时不查询,防止空筛选条件返回其他活动的中奖人员。
if (Strings.isBlank(activityId)) {
return Collections.emptyList();
}
Sql sql = Sqls.create("""
SELECT
id, activityId, userId, loginName, username,
unionName, unitName, awardName,
CASE WHEN isRead = 1 THEN '是' ELSE '否' END AS isRaffleText,
CASE WHEN isRead = 1 THEN COALESCE(DATE_FORMAT(applyDate, '%Y-%m-%d %H:%i:%s'), '')
ELSE '' END AS applyDate2
FROM fitness_walk_award_user
$condition
""");
Cnd cnd = Cnd.where("activityId", "=", activityId);
if (Strings.isNotBlank(awardName)) {
cnd.and("awardName", "=", awardName);
}
if (Strings.isNotBlank(searchKeyword)) {
SqlExpressionGroup seg = new SqlExpressionGroup();
seg.orLike("username", searchKeyword);
seg.orLike("loginName", searchKeyword);
cnd.and(seg);
}
// 手机端抽奖成功会将 isRead 置为 true;false 和历史空值均表示尚未抽奖,仅展示状态,不据此过滤人员。
sql.setCondition(cnd);
return listMap(sql);
}
/** /**
* 获取活动的奖项 * 获取活动的奖项
* @param activityId * @param activityId
@@ -216,17 +268,14 @@ public class FitnessWalkStepRaffleServiceImpl extends BaseServiceImpl<FitnessWal
} }
/** /**
* 手动抽奖 * 按规则重新抽奖;人数不足时抽取全部可抽人员,并汇总不足奖项的实际结果。
* *
* @param activityId 活动id * @param activityId 活动 ID,用于读取资格及奖项规则并替换该活动的中奖名单
* @return 抽奖完成提示;人数不足时包含奖项名称、配置人数、可抽人数及实际抽取人数
*/ */
@Override @Override
@Aop(TransAop.READ_COMMITTED) @Aop(TransAop.READ_COMMITTED)
public void manualLottery(String activityId) { public String manualLottery(String activityId) {
// 删除此活动下的获奖记录
dao().clear(FitnessWalkAwardUser.class, Cnd.where("activityId", "=", activityId));
dao().clear(FitnessWalkRaffleUser.class, Cnd.where("activityId", "=", activityId));
FitnessWalkActivity activity = fitnessWalkCommonService.getActivity(activityId); FitnessWalkActivity activity = fitnessWalkCommonService.getActivity(activityId);
Date startDate = DateUtil.parse(DateUtil.format(DateUtil.date(activity.getStartTime()), "yyyy-MM-dd"), "yyyy-MM-dd"); Date startDate = DateUtil.parse(DateUtil.format(DateUtil.date(activity.getStartTime()), "yyyy-MM-dd"), "yyyy-MM-dd");
Date endDate = DateUtil.parse(DateUtil.format(DateUtil.date(activity.getEndTime()), "yyyy-MM-dd"), "yyyy-MM-dd"); Date endDate = DateUtil.parse(DateUtil.format(DateUtil.date(activity.getEndTime()), "yyyy-MM-dd"), "yyyy-MM-dd");
@@ -235,6 +284,7 @@ public class FitnessWalkStepRaffleServiceImpl extends BaseServiceImpl<FitnessWal
Sql sql = getRaffleSql(null, activityId, null, null); Sql sql = getRaffleSql(null, activityId, null, null);
List<NutMap> list = listMap(sql); List<NutMap> list = listMap(sql);
// 无达标日期时按 0 天保存,候选人员及中奖记录均避免使用 NutMap 默认的 -1。
List<FitnessWalkRaffleUser> raffleUserList = list.stream().map(v -> { List<FitnessWalkRaffleUser> raffleUserList = list.stream().map(v -> {
FitnessWalkRaffleUser raffleUser = new FitnessWalkRaffleUser(); FitnessWalkRaffleUser raffleUser = new FitnessWalkRaffleUser();
raffleUser.setId(R.UU32()); raffleUser.setId(R.UU32());
@@ -243,16 +293,17 @@ public class FitnessWalkStepRaffleServiceImpl extends BaseServiceImpl<FitnessWal
raffleUser.setLoginName(v.getString("loginname")); raffleUser.setLoginName(v.getString("loginname"));
raffleUser.setUsername(v.getString("username")); raffleUser.setUsername(v.getString("username"));
raffleUser.setAwardSteps(v.getString("total_steps")); raffleUser.setAwardSteps(v.getString("total_steps"));
raffleUser.setStandardsDays(v.getInt("standardsDays")); raffleUser.setStandardsDays(v.getInt("standardsDays", 0));
raffleUser.setIsRaffle(false); raffleUser.setIsRaffle(false);
return raffleUser; return raffleUser;
}).collect(Collectors.toList()); }).collect(Collectors.toList());
dao().fastInsert(raffleUserList);
Set<String> winningRankLoginNames = new HashSet<>(); Set<String> winningRankLoginNames = new HashSet<>();
// 获奖人员 // 获奖人员
List<FitnessWalkAwardUser> winningUsers = new ArrayList<>(); List<FitnessWalkAwardUser> winningUsers = new ArrayList<>();
// 收集各奖项人数不足的说明,抽奖完成后一次性返回给页面。
List<String> shortageMessages = new ArrayList<>();
// 获取抽奖规则 // 获取抽奖规则
FitnessWalkRaffleRules rules = fetch(Cnd.where("activityId", "=", activityId)); FitnessWalkRaffleRules rules = fetch(Cnd.where("activityId", "=", activityId));
@@ -272,8 +323,12 @@ public class FitnessWalkStepRaffleServiceImpl extends BaseServiceImpl<FitnessWal
.sorted(Comparator.comparingInt((NutMap v) -> v.getInt("total_steps")).reversed()) .sorted(Comparator.comparingInt((NutMap v) -> v.getInt("total_steps")).reversed())
.toList(); .toList();
// 截取当前奖项的中奖人员 // 排除前序中奖人员后,最多抽取当前剩余人数;没有剩余人员时取空名单并继续后续奖项。
List<NutMap> rankNutMaps = canLotteryUsers.subList(0, lotteryUserNum); int actualNum = Math.min(lotteryUserNum, canLotteryUsers.size());
if (actualNum < lotteryUserNum) {
shortageMessages.add(awardName + ":设置 " + lotteryUserNum + " 人,实际可抽 " + canLotteryUsers.size() + " 人,已抽取 " + actualNum + " 人");
}
List<NutMap> rankNutMaps = canLotteryUsers.subList(0, actualNum);
// 收集成中奖人员存储 // 收集成中奖人员存储
List<FitnessWalkAwardUser> collect = rankNutMaps.stream().map(v -> { List<FitnessWalkAwardUser> collect = rankNutMaps.stream().map(v -> {
FitnessWalkAwardUser awardUser = new FitnessWalkAwardUser(); FitnessWalkAwardUser awardUser = new FitnessWalkAwardUser();
@@ -289,7 +344,7 @@ public class FitnessWalkStepRaffleServiceImpl extends BaseServiceImpl<FitnessWal
awardUser.setUnionName(v.getString("unionname")); awardUser.setUnionName(v.getString("unionname"));
awardUser.setUnitId(v.getString("unitid")); awardUser.setUnitId(v.getString("unitid"));
awardUser.setUnitName(v.getString("unitname")); awardUser.setUnitName(v.getString("unitname"));
awardUser.setStandardsDays(v.getInt("standardsDays")); awardUser.setStandardsDays(v.getInt("standardsDays", 0));
awardUser.setAwardSteps(v.getString("total_steps")); awardUser.setAwardSteps(v.getString("total_steps"));
awardUser.setPoints(rank.getPoints()); awardUser.setPoints(rank.getPoints());
return awardUser; return awardUser;
@@ -301,8 +356,9 @@ public class FitnessWalkStepRaffleServiceImpl extends BaseServiceImpl<FitnessWal
} }
// 达标奖项不要求填写排名位次;空位次保持原有规则顺序,支持多个达标奖项连续抽取。
List<FitnessWalkRaffleRules.ManualLotteryRules> reachList = manualLotteryRules.stream().filter(v -> "达标".equals(v.getRaffleType())) List<FitnessWalkRaffleRules.ManualLotteryRules> reachList = manualLotteryRules.stream().filter(v -> "达标".equals(v.getRaffleType()))
.sorted(Comparator.comparing(FitnessWalkRaffleRules.ManualLotteryRules::getRankingPosition)).toList(); .sorted(Comparator.comparing(FitnessWalkRaffleRules.ManualLotteryRules::getRankingPosition, Comparator.nullsLast(Comparator.naturalOrder()))).toList();
for (FitnessWalkRaffleRules.ManualLotteryRules reach : reachList) { for (FitnessWalkRaffleRules.ManualLotteryRules reach : reachList) {
List<NutMap> nutMaps; List<NutMap> nutMaps;
@@ -317,9 +373,15 @@ public class FitnessWalkStepRaffleServiceImpl extends BaseServiceImpl<FitnessWal
Integer lotteryUserNum = reach.getLotteryUserNum(); Integer lotteryUserNum = reach.getLotteryUserNum();
String awardName = reach.getAwardName(); String awardName = reach.getAwardName();
// 兼得规则已决定当前候选池,人数不足时全部抽取,人数为零时继续处理其他奖项。
int actualNum = Math.min(lotteryUserNum, nutMaps.size());
if (actualNum < lotteryUserNum) {
shortageMessages.add(awardName + ":设置 " + lotteryUserNum + " 人,实际可抽 " + nutMaps.size() + " 人,已抽取 " + actualNum + " 人");
}
List<NutMap> reachNutMaps = nutMaps.stream().collect(Collectors.collectingAndThen(Collectors.toList(), collected -> { List<NutMap> reachNutMaps = nutMaps.stream().collect(Collectors.collectingAndThen(Collectors.toList(), collected -> {
Collections.shuffle(collected); Collections.shuffle(collected);
return collected.subList(0, lotteryUserNum - 1); return collected.subList(0, actualNum);
})); }));
List<FitnessWalkAwardUser> collect = reachNutMaps.stream().map(v -> { List<FitnessWalkAwardUser> collect = reachNutMaps.stream().map(v -> {
@@ -336,7 +398,7 @@ public class FitnessWalkStepRaffleServiceImpl extends BaseServiceImpl<FitnessWal
awardUser.setUnionName(v.getString("unionname")); awardUser.setUnionName(v.getString("unionname"));
awardUser.setUnitId(v.getString("unitid")); awardUser.setUnitId(v.getString("unitid"));
awardUser.setUnitName(v.getString("unitname")); awardUser.setUnitName(v.getString("unitname"));
awardUser.setStandardsDays(v.getInt("standardsDays")); awardUser.setStandardsDays(v.getInt("standardsDays", 0));
awardUser.setAwardSteps(v.getString("total_steps")); awardUser.setAwardSteps(v.getString("total_steps"));
awardUser.setPoints(reach.getPoints()); awardUser.setPoints(reach.getPoints());
return awardUser; return awardUser;
@@ -347,8 +409,17 @@ public class FitnessWalkStepRaffleServiceImpl extends BaseServiceImpl<FitnessWal
winningUsers.addAll(collect); winningUsers.addAll(collect);
} }
// 全部奖项计算完成后,在同一事务中替换旧名单;空候选池或空中奖名单无需批量插入。
dao().clear(FitnessWalkAwardUser.class, Cnd.where("activityId", "=", activityId));
dao().clear(FitnessWalkRaffleUser.class, Cnd.where("activityId", "=", activityId));
if (!raffleUserList.isEmpty()) {
dao().fastInsert(raffleUserList);
}
if (!winningUsers.isEmpty()) {
dao().fastInsert(winningUsers); dao().fastInsert(winningUsers);
} }
return shortageMessages.isEmpty() ? "抽奖完成" : "抽奖完成;" + String.join(";", shortageMessages);
}
} }
@@ -0,0 +1,286 @@
package com.budwk.app.zhgh.activity.fitnesswalk.service.impl;
import cn.hutool.core.date.DateUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.activity.fitnesswalk.contants.FitnessWalkMode;
import com.budwk.app.zhgh.activity.fitnesswalk.model.FitnessWalkActivity;
import com.budwk.app.zhgh.activity.fitnesswalk.model.FitnessWalkStep;
import com.budwk.app.zhgh.activity.fitnesswalk.service.FitnessWalkStepUnionStatisticsService;
import com.budwk.app.zhgh.activity.fitnesswalk.utils.WeAppCloudUtil;
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.IocBean;
import org.nutz.lang.Strings;
import org.nutz.lang.util.NutMap;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 分工会计步活动达标统计;使用当前会员人数作为分母,每个活动单独判断人员资格。
*/
@IocBean(args = {"refer:dao"})
public class FitnessWalkStepUnionStatisticsServiceImpl extends BaseServiceImpl<FitnessWalkStep> implements FitnessWalkStepUnionStatisticsService {
private static final int ACTIVITY_QUERY_BATCH_SIZE = 100;
@Inject
private WeAppCloudUtil weAppCloudUtil;
/**
* @param dao 项目注入的 Nutz 数据访问对象,用于查询当前会员及活动步数记录
*/
public FitnessWalkStepUnionStatisticsServiceImpl(Dao dao) {
super(dao);
}
/**
* @param year 活动开始年份,允许查询 1970 年至当前年份
* @return 当前账号可查看的活动选项,包含 ID、名称、日期和达标规则
*/
@Override
public List<NutMap> getActivityOptions(int year) {
return queryYearActivities(year).stream().map(this::toActivityOption).collect(Collectors.toList());
}
/**
* @param year 活动开始年份
* @param activityIds 所选活动 ID;空数组查询全部活动,非空数组只查询所选活动
* @param unionId 分工会 ID;非管理员的查询范围由服务层限制为本人分工会
* @return activities 为实际统计活动,list 为全部可查看分工会的活动达标矩阵;比例以两位小数百分比返回
*/
@Override
public NutMap getUnionStatistics(int year, String[] activityIds, String unionId) {
List<FitnessWalkActivity> activities = resolveSelectedActivities(queryYearActivities(year), activityIds);
List<NutMap> activityOptions = activities.stream().map(this::toActivityOption).collect(Collectors.toList());
if (activities.isEmpty()) {
return NutMap.NEW().addv("activities", activityOptions).addv("list", Collections.emptyList());
}
String effectiveUnionId = resolveUnionId(unionId);
// 分母和达标人数均限定为当前会员,避免未入会人员计入分子后出现超过 100% 的比例。
Sql memberSql = Sqls.create("""
SELECT
un.id AS unionId,
un.name AS unionName,
COALESCE(m.memberNum, 0) AS memberNum
FROM sys_union un
LEFT JOIN (
SELECT unionId, COUNT(DISTINCT id) AS memberNum
FROM vw_user
WHERE member = 1
GROUP BY unionId
) m ON m.unionId = un.id
WHERE (@hasUnionFilter = 0 OR un.id = @unionId)
ORDER BY un.unionCode
""");
memberSql.setParam("hasUnionFilter", Strings.isBlank(effectiveUnionId) ? 0 : 1);
memberSql.setParam("unionId", Strings.sNull(effectiveUnionId));
List<NutMap> rows = listMap(memberSql);
Map<String, Map<String, Long>> activityCounts = new LinkedHashMap<>();
for (FitnessWalkActivity activity : activities) {
Map<String, Long> unionCounts = new LinkedHashMap<>();
for (NutMap countRow : listMap(buildQualifiedCountSql(activity, effectiveUnionId))) {
unionCounts.put(countRow.getString("unionId"), countRow.getLong("qualifiedNum", 0));
}
activityCounts.put(activity.get_id(), unionCounts);
}
for (NutMap row : rows) {
String rowUnionId = row.getString("unionId");
long memberNum = row.getLong("memberNum", 0);
long qualifiedTotal = 0;
NutMap activityStatistics = NutMap.NEW();
for (FitnessWalkActivity activity : activities) {
long qualifiedNum = activityCounts.get(activity.get_id()).getOrDefault(rowUnionId, 0L);
qualifiedTotal += qualifiedNum;
activityStatistics.put(activity.get_id(), NutMap.NEW()
.addv("qualifiedNum", qualifiedNum)
.addv("qualifiedRate", formatRate(qualifiedNum, BigDecimal.valueOf(memberNum))));
}
row.put("activityStatistics", activityStatistics);
// 每个活动使用同一分工会会员分母,因此先汇总人数再除以会员人数×活动数,等价于活动达标率的算术平均。
// 直接使用原始人数计算,避免先对单活动比例四舍五入后再平均造成误差;零达标活动仍计入活动数。
row.put("averageQualifiedRate", formatRate(qualifiedTotal, BigDecimal.valueOf(memberNum).multiply(BigDecimal.valueOf(activities.size()))));
}
return NutMap.NEW().addv("activities", activityOptions).addv("list", rows);
}
/**
* 分批获取指定年份的活动及资格配置,避免云查询默认条数和公共活动缓存影响新统计页面。
*/
private List<FitnessWalkActivity> queryYearActivities(int year) {
if (year < 1970 || year > DateUtil.thisYear()) {
throw new IllegalArgumentException("请选择有效的活动年份");
}
Calendar calendar = Calendar.getInstance();
calendar.set(Calendar.YEAR, year);
long startTs = DateUtil.beginOfYear(calendar.getTime()).getTime();
long endTs = DateUtil.endOfYear(calendar.getTime()).getTime();
String effectiveUnionId = resolveUnionId(null);
StringBuilder query = new StringBuilder("db.collection('activity').where({activityModel:");
query.append(JSON.toJSONString(FitnessWalkMode.STEP_COUNT.getValue()))
.append(",startTime:_.gte(").append(startTs).append(").and(_.lte(").append(endTs).append("))");
if (Strings.isNotBlank(effectiveUnionId)) {
query.append(",unionId:").append(JSON.toJSONString(effectiveUnionId));
}
query.append("}).field({_id:true,masterActivityName:true,branchActivityName:true,activityModel:true,")
.append("startTime:true,endTime:true,lotteryQualificationDays:true,lotteryQualificationSteps:true,unionId:true})")
.append(".orderBy('startTime','desc').orderBy('_id','asc')");
Map<String, FitnessWalkActivity> activityMap = new LinkedHashMap<>();
int skip = 0;
while (true) {
String sql = query + ".skip(" + skip + ").limit(" + ACTIVITY_QUERY_BATCH_SIZE + ").get()";
JSONObject response = weAppCloudUtil.request(WeAppCloudUtil.CRUD.QUERY, sql);
JSONArray data = response.getJSONArray("data");
if (data == null) {
throw new IllegalArgumentException("活动列表获取失败,请稍后重试");
}
for (Object item : data) {
FitnessWalkActivity activity = JSON.parseObject((String) item, FitnessWalkActivity.class);
// 历史活动缺失日期时不进入统计,防止空日期变成当前日期而误算达标人数。
if (Strings.isNotBlank(activity.get_id()) && activity.getStartTime() != null && activity.getEndTime() != null
&& activity.getStartTime() <= activity.getEndTime()) {
activityMap.put(activity.get_id(), activity);
}
}
if (data.size() < ACTIVITY_QUERY_BATCH_SIZE) {
break;
}
skip += ACTIVITY_QUERY_BATCH_SIZE;
}
return new ArrayList<>(activityMap.values());
}
/**
* 已选择活动时逐个校验年份和可见范围,失效选项报错,避免错误地扩大为全部活动。
*/
private List<FitnessWalkActivity> resolveSelectedActivities(List<FitnessWalkActivity> availableActivities, String[] activityIds) {
if (activityIds == null || activityIds.length == 0) {
return availableActivities;
}
Set<String> selectedIds = new LinkedHashSet<>();
for (String activityId : activityIds) {
if (Strings.isBlank(activityId)) {
throw new IllegalArgumentException("所选活动无效,请重新选择");
}
selectedIds.add(activityId);
}
Set<String> availableIds = availableActivities.stream().map(FitnessWalkActivity::get_id).collect(Collectors.toSet());
if (!availableIds.containsAll(selectedIds)) {
throw new IllegalArgumentException("所选活动不属于当前年份或不可查看,请刷新活动列表后重新选择");
}
return availableActivities.stream().filter(activity -> selectedIds.contains(activity.get_id())).collect(Collectors.toList());
}
/**
* 管理员可筛选全部分工会;其他账号忽略外部传入范围,只统计本人所属分工会。
*/
private String resolveUnionId(String unionId) {
if (AuthUtil.hasRoleOr("SYSADMIN", "SCHOOL_UNION_ADMIN")) {
return unionId;
}
String currentUnionId = SecurityUtil.getUnionId();
if (Strings.isBlank(currentUnionId)) {
throw new IllegalArgumentException("当前账号未关联分工会,无法查询统计");
}
return currentUnionId;
}
/**
* 按计步抽奖页的三种资格口径统计人数:达标天数、累计步数或有步数记录。
* 有资格天数时只计达到每日步数门槛的日期;同日重复上传保留最大步数,避免累计步数重复计算。
*/
private Sql buildQualifiedCountSql(FitnessWalkActivity activity, String unionId) {
Sql sql = Sqls.create("""
SELECT qualified.unionId, COUNT(DISTINCT qualified.userId) AS qualifiedNum
FROM (
SELECT u.id AS userId, u.unionId
FROM vw_user u
LEFT JOIN (
SELECT userId, DATE(applyDate) AS applyDay, MAX(step) AS daySteps
FROM fitness_walk_step
WHERE activityId = @activityId
AND DATE(applyDate) >= @startDate
AND DATE(applyDate) <= @endDate
AND (@hasDayRequirement = 0 OR step >= @qualificationSteps)
GROUP BY userId, DATE(applyDate)
) daily ON daily.userId = u.id
WHERE u.member = 1 AND (@hasUnionFilter = 0 OR u.unionId = @unionId)
GROUP BY u.id, u.unionId
HAVING (
(@hasDayRequirement = 1 AND COUNT(DISTINCT daily.applyDay) > 0
AND COUNT(DISTINCT daily.applyDay) >= @qualificationDays)
OR (@hasDayRequirement = 0 AND @hasStepRequirement = 1
AND COALESCE(SUM(daily.daySteps), 0) >= @qualificationSteps)
OR (@hasDayRequirement = 0 AND @hasStepRequirement = 0
AND COALESCE(SUM(daily.daySteps), 0) > 0)
)
) qualified
GROUP BY qualified.unionId
""");
sql.setParam("activityId", activity.get_id());
sql.setParam("startDate", DateUtil.format(DateUtil.date(activity.getStartTime()), "yyyy-MM-dd"));
sql.setParam("endDate", DateUtil.format(DateUtil.date(activity.getEndTime()), "yyyy-MM-dd"));
sql.setParam("hasDayRequirement", activity.getLotteryQualificationDays() == null ? 0 : 1);
sql.setParam("hasStepRequirement", activity.getLotteryQualificationSteps() == null ? 0 : 1);
sql.setParam("qualificationDays", activity.getLotteryQualificationDays() == null ? 0 : activity.getLotteryQualificationDays());
sql.setParam("qualificationSteps", activity.getLotteryQualificationSteps() == null ? 0 : activity.getLotteryQualificationSteps());
sql.setParam("hasUnionFilter", Strings.isBlank(unionId) ? 0 : 1);
sql.setParam("unionId", Strings.sNull(unionId));
return sql;
}
/**
* 名称同时包含主活动、分活动及日期,避免同名月度活动在多选和表头中无法区分。
*/
private NutMap toActivityOption(FitnessWalkActivity activity) {
String name = Strings.sNull(activity.getMasterActivityName());
if (Strings.isNotBlank(activity.getBranchActivityName()) && !activity.getBranchActivityName().equals(name)) {
name += " / " + activity.getBranchActivityName();
}
String startDate = DateUtil.format(DateUtil.date(activity.getStartTime()), "yyyy-MM-dd");
String endDate = DateUtil.format(DateUtil.date(activity.getEndTime()), "yyyy-MM-dd");
String ruleLabel;
if (activity.getLotteryQualificationDays() != null) {
int steps = activity.getLotteryQualificationSteps() == null ? 0 : activity.getLotteryQualificationSteps();
ruleLabel = "每日至少" + steps + "步,活动期间至少达标" + activity.getLotteryQualificationDays() + "天";
} else if (activity.getLotteryQualificationSteps() != null) {
ruleLabel = "活动期间累计至少" + activity.getLotteryQualificationSteps() + "步";
} else {
ruleLabel = "活动期间总步数大于0";
}
return NutMap.NEW().addv("value", activity.get_id()).addv("name", name)
.addv("label", name + "(" + startDate + " 至 " + endDate + ")")
.addv("ruleLabel", ruleLabel);
}
/**
* 统一输出两位小数百分比;没有会员或没有统计活动时返回 0.00%,避免除零。
*/
private String formatRate(long qualifiedNum, BigDecimal denominator) {
if (denominator.signum() <= 0) {
return "0.00%";
}
return BigDecimal.valueOf(qualifiedNum).multiply(BigDecimal.valueOf(100))
.divide(denominator, 2, RoundingMode.HALF_UP).toPlainString() + "%";
}
}
@@ -329,7 +329,7 @@ public class TrainSignUpManageController {
public Result selectUnitAndClub() { public Result selectUnitAndClub() {
List<NutMap> result = new ArrayList<>(); List<NutMap> result = new ArrayList<>();
java.util.Set<String> optionKeys = new java.util.HashSet<>(); java.util.Set<String> optionKeys = new java.util.HashSet<>();
List<Sys_unit> unitList = dao.query(Sys_unit.class, Cnd.where(Sys_unit::getUnitLevel, "=", 2).asc(Sys_unit::getUnitcode)); List<Sys_unit> unitList = dao.query(Sys_unit.class, Cnd.where(Sys_unit::getUnitLevel, "=", 3).asc(Sys_unit::getUnitcode));
for (Sys_unit unit : unitList) { for (Sys_unit unit : unitList) {
addSelectOption(result, optionKeys, unit.getId(), unit.getName(), "unit"); addSelectOption(result, optionKeys, unit.getId(), unit.getName(), "unit");
} }
@@ -241,10 +241,15 @@ public class ActivityWorksCollectionManageController {
@SaCheckPermission(value = {"activity.workscollection.manage", "activity.workscollection.new"}, mode = SaMode.OR) @SaCheckPermission(value = {"activity.workscollection.manage", "activity.workscollection.new"}, mode = SaMode.OR)
public Result findOne(@Valid String id) { public Result findOne(@Valid String id) {
Activity_works_collection worksCollection = dao.fetch(Activity_works_collection.class, id); Activity_works_collection worksCollection = dao.fetch(Activity_works_collection.class, id);
if (worksCollection == null) {
return Result.error("活动不存在");
}
dao.fetchLinks(worksCollection, "subjectTypes"); dao.fetchLinks(worksCollection, "subjectTypes");
if (worksCollection.getSubjectTypes() != null) {
worksCollection.getSubjectTypes().forEach(item -> { worksCollection.getSubjectTypes().forEach(item -> {
dao.fetchLinks(item, "worksTypes"); dao.fetchLinks(item, "worksTypes");
}); });
}
return Result.success(worksCollection); return Result.success(worksCollection);
} }
@@ -74,23 +74,23 @@ public class SmsMessageSendStrategy implements GlobalMessageSendStrategy {
/** /**
* 发送短信消息。 * 发送短信消息。
* 接收人由全局消息服务传入的用户ID或登录名查询得到,发送时使用用户工号对接通讯平台; * 接收人由全局消息服务传入的用户ID或登录名查询得到,发送时使用用户手机号对接阿里云短信;
* 返回结果由 SmsService 内部解析并记录,当前策略只负责逐个用户触发发送并记录异常。 * 单个用户手机号缺失或发送失败只记录日志,不影响其他待审核人接收短信。
*/ */
private void sendSmsMessage(String title, String content, Integer type, List<Sys_user> users, JSONObject config) { private void sendSmsMessage(String title, String content, Integer type, List<Sys_user> users, JSONObject config) {
for (Sys_user user : users) { for (Sys_user user : users) {
try { try {
String loginName = user.getLoginname(); String mobile = user.getMobile();
if (loginName == null || loginName.trim().isEmpty()) { if (mobile == null || mobile.trim().isEmpty()) {
log.warn("用户{}没有工号,跳过短信发送", user.getUsername()); log.warn("用户{}没有手机号,跳过短信发送", user.getUsername());
continue; continue;
} }
// 构建短信内容 // 构建短信内容
String smsContent = buildSmsContent(title, content, type); String smsContent = buildSmsContent(title, content, type);
smsService.sendSmsByAccount(loginName, smsContent); smsService.sendSmsByMobile(mobile, smsContent);
log.info("短信发送完成:用户{},工号:{}", user.getUsername(), loginName); log.info("短信发送完成:用户{},手机号:{}", user.getUsername(), mobile);
} catch (Exception e) { } catch (Exception e) {
log.error("发送短信给用户{}时出错:{}", user.getUsername(), e.getMessage(), e); log.error("发送短信给用户{}时出错:{}", user.getUsername(), e.getMessage(), e);
} }
@@ -103,10 +103,8 @@ public class SmsMessageSendStrategy implements GlobalMessageSendStrategy {
private String buildSmsContent(String title, String content, Integer type) { private String buildSmsContent(String title, String content, Integer type) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
// 根据消息类型添加前缀 // 待办短信由阿里云模板统一展示“待办提醒”,这里不再重复拼接【待办通知】前缀。
if (type != null && type == 2) { if (type == null || type != 2) {
sb.append("【待办通知】");
} else {
sb.append("【系统通知】"); sb.append("【系统通知】");
} }
@@ -1,113 +0,0 @@
package com.budwk.app.zhgh.dayofficework.message.strategy.impl;
import cn.hutool.json.JSONObject;
import com.budwk.app.base.sms.SmsService;
import com.budwk.app.sys.models.Sys_user;
import com.budwk.app.zhgh.dayofficework.message.enums.GlobalMessageChannel;
import com.budwk.app.zhgh.dayofficework.message.strategy.GlobalMessageSendStrategy;
import lombok.extern.slf4j.Slf4j;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import java.util.List;
/**
* 微信发送策略
*/
@IocBean
@Slf4j
public class WechatMessageSendStrategy implements GlobalMessageSendStrategy {
@Inject
private Dao dao;
@Inject
private SmsService smsService;
@Override
public GlobalMessageChannel getChannel() {
return GlobalMessageChannel.WECHAT;
}
@Override
public boolean supportAsync() {
return true;
}
@Override
public void send(String title, String content, Integer type, List<String> receiverIds, JSONObject config) {
try {
log.info("开始发送微信消息,标题:{},接收人数量:{}", title, receiverIds.size());
// receiverIds 为 sys_user.id,按用户ID查询后使用工号对接通讯平台微信文本消息接口。
List<Sys_user> users = dao.query(Sys_user.class, Cnd.where(Sys_user::getId, "in", receiverIds));
sendWechatMessage(title, content, type, users, config);
log.info("微信消息发送完成,标题:{},接收人数量:{}", title, users.size());
} catch (Exception e) {
log.error("微信消息发送失败,标题:{},错误:{}", title, e.getMessage(), e);
throw new RuntimeException("微信消息发送失败:" + e.getMessage(), e);
}
}
@Override
public void sendByLoginName(String title, String content, Integer type, List<String> receiverLoginNames, JSONObject config) {
try {
log.info("开始发送微信消息(按登录名),标题:{},接收人数量:{}", title, receiverLoginNames.size());
// receiverLoginNames 为 sys_user.loginname,查询用户后仍使用工号作为通讯平台接收人标识。
List<Sys_user> users = dao.query(Sys_user.class, Cnd.where(Sys_user::getLoginname, "in", receiverLoginNames));
sendWechatMessage(title, content, type, users, config);
log.info("微信消息发送完成(按登录名),标题:{},接收人数量:{}", title, users.size());
} catch (Exception e) {
log.error("微信消息发送失败(按登录名),标题:{},错误:{}", title, e.getMessage(), e);
throw new RuntimeException("微信消息发送失败:" + e.getMessage(), e);
}
}
/**
* 发送微信文本消息。
* 接收人来自全局消息服务传入的用户ID或登录名,方法内部转换为 Sys_user 后取工号发送;
* SmsService 返回 MsgPlatformResponse,当前策略按单人发送记录日志,单个用户失败不影响其他接收人。
*/
private void sendWechatMessage(String title, String content, Integer type, List<Sys_user> users, JSONObject config) {
for (Sys_user user : users) {
try {
String loginName = user.getLoginname();
if (loginName == null || loginName.trim().isEmpty()) {
log.warn("用户{}没有工号,跳过微信发送", user.getUsername());
continue;
}
String wechatContent = buildWechatContent(title, content, type);
smsService.sendWechatTextByAccountOrMobile(loginName, wechatContent);
log.info("微信发送完成:用户{},工号:{}", user.getUsername(), loginName);
} catch (Exception e) {
log.error("发送微信给用户{}时出错:{}", user.getUsername(), e.getMessage(), e);
}
}
}
/**
* 构建微信文本内容。
* type=2 表示待办消息,其余类型按系统通知处理,内容格式保持与短信策略一致。
*/
private String buildWechatContent(String title, String content, Integer type) {
StringBuilder sb = new StringBuilder();
if (type != null && type == 2) {
sb.append("【待办通知】");
} else {
sb.append("【系统通知】");
}
sb.append(title);
if (content != null && !content.trim().isEmpty()) {
sb.append(":").append(content);
}
return sb.toString();
}
}
@@ -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,957 @@
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", "=", Strings.isBlank(sourceType) ? null : sourceType.trim());
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;
if (Boolean.TRUE.equals(config.getEnabled())) {
Assert.hasText(config.getTargetTable(), "启用同步任务前请选择目标表");
Assert.isTrue(mappings.stream().anyMatch(mapping -> Strings.isNotBlank(mapping.getSourceField())
&& Strings.isNotBlank(mapping.getTargetField())), "启用同步任务前请至少完成一条来源字段与目标字段的映射");
}
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,683 @@
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());
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;
}
@@ -0,0 +1,41 @@
package com.budwk.app.zhgh.personGovernance.personException.service;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.service.BaseService;
import com.budwk.app.zhgh.personGovernance.personException.model.PersonException;
import org.nutz.lang.util.NutMap;
/** 人员异常查询与处理服务。 */
public interface PersonExceptionService extends BaseService<PersonException> {
/**
* 查询异常人员。
*
* @param pageForm 分页和排序参数
* @param status PENDING待确认、PROCESSED已处理
* @param searchName personName按姓名查询、personNo按人员编号查询
* @param searchKeyword 查询关键字
* @param unitId 所属部门主键
* @param batchId 发现异常的同步批次主键
* @param handleResult DISABLE已停用、KEEP继续保留
* @return 异常人员分页数据
*/
Pagination pageData(PageForm pageForm, String status, String searchName, String searchKeyword,
String unitId, String batchId, String handleResult);
NutMap detail(String id);
/**
* 确认异常人员,并将页面选择的人员类型、在职状态和会员状态同步到用户表。
*
* @param ids 异常记录ID数组
* @param handleResult DISABLE表示停用账号,KEEP表示继续保留
* @param opinion 确认建议
* @param personType 人员类型字典值
* @param userState 在职状态字典值
* @param member 会员状态,1为会员、0为非会员
* @param welfareMember 福利会员状态,1为福利会员、0为非福利会员
*/
void handle(String[] ids, String handleResult, String opinion, String personType,
String userState, Integer member, Integer welfareMember);
}
@@ -0,0 +1,191 @@
package com.budwk.app.zhgh.personGovernance.personException.service.impl;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.dao.CndPlus;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.sys.models.Sys_role;
import com.budwk.app.sys.models.Sys_user;
import com.budwk.app.sys.models.Sys_user_role;
import com.budwk.app.sys.services.SysUserService;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.personGovernance.personException.model.PersonException;
import com.budwk.app.zhgh.personGovernance.personException.service.PersonExceptionService;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Chain;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Strings;
import org.nutz.lang.util.NutMap;
import org.springframework.util.Assert;
import java.util.Date;
/** 异常人员仅做直接确认处理,不引入审核中状态。 */
@IocBean(args = {"refer:dao"})
public class PersonExceptionServiceImpl extends BaseServiceImpl<PersonException> implements PersonExceptionService {
@Inject
private SysUserService sysUserService;
public PersonExceptionServiceImpl(Dao dao) {
super(dao);
}
@Override
public Pagination pageData(PageForm pageForm, String status, String searchName,
String searchKeyword, String unitId, String batchId,
String handleResult) {
Sql sql = Sqls.create(
"SELECT e.*,c.configName sourceName,b.batchNo FROM person_exception e "
+ "LEFT JOIN person_api_config c ON c.id=e.configId "
+ "LEFT JOIN person_sync_batch b ON b.id=e.discoverBatchId $condition"
);
CndPlus cnd = CndPlus.create();
cnd.andEX("e.status", "=", status);
if (Strings.isNotBlank(searchKeyword)) {
// 查询字段只允许在姓名和人员编号之间选择,禁止直接拼接前端传入的字段名。
String queryField = "personNo".equals(searchName) ? "e.personNo" : "e.personName";
cnd.and(queryField, "like", "%" + searchKeyword + "%");
}
cnd.andEX("e.unitId", "=", unitId).andEX("e.discoverBatchId", "=", batchId)
.andEX("e.handleResult", "=", handleResult);
cnd.and("e.delFlag", "=", false).desc("e.discoverTime");
sql.setCondition(cnd);
return listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql);
}
@Override
public NutMap detail(String id) {
PersonException exception = fetch(id);
Assert.notNull(exception, "异常记录不存在");
Sql userSql = Sqls.create("""
SELECT
u.id,
u.loginname,
u.username,
u.sex,
u.nation,
u.birthday,
u.political,
u.education,
u.academicDegree,
u.position,
u.unitId,
unit.name unitName,
un.unionname unionName,
u.idCard,
u.mobile,
u.email,
u.userState,
u.personType,
u.member,
u.welfareMember,
u.disabled
FROM sys_user u
LEFT JOIN sys_unit unit ON u.unitId = unit.id
LEFT JOIN sys_union un ON u.unionId = un.id
WHERE u.id = @userId
""").setParam("userId", exception.getUserId());
return NutMap.NEW()
.addv("exception", exception)
// 只返回查看/处理页面需要的人员字段,避免输出密码、盐值等敏感信息。
.addv("user", fetchMap(userSql));
}
/**
* 确认处理异常人员。
*
* @param ids 异常记录ID,可单条或批量
* @param handleResult DISABLE表示停用本地账号,KEEP表示继续保留
* @param opinion 必填处理意见
* @param personType 人员类型字典值,允许清空
* @param userState 在职状态字典值,允许清空
* @param member 会员状态,1为会员、0为非会员
* @param welfareMember 福利会员状态,1为福利会员、0为非福利会员
*/
@Override
@Aop(TransAop.READ_COMMITTED)
public void handle(String[] ids, String handleResult, String opinion, String personType,
String userState, Integer member, Integer welfareMember) {
Assert.notEmpty(ids, "请选择需要处理的异常人员");
Assert.isTrue("DISABLE".equals(handleResult) || "KEEP".equals(handleResult), "处理结果不正确");
Assert.hasText(opinion, "请填写确认建议");
Assert.notNull(member, "请选择会员状态");
Assert.isTrue(member == 0 || member == 1, "会员状态不正确");
Assert.notNull(welfareMember, "请选择福利会员状态");
Assert.isTrue(welfareMember == 0 || welfareMember == 1, "福利会员状态不正确");
for (String id : ids) {
PersonException exception = fetch(id);
Assert.notNull(exception, "异常记录不存在");
Assert.isTrue("PENDING".equals(exception.getStatus()), "仅待确认记录可以处理");
Sys_user user = sysUserService.fetch(exception.getUserId());
Assert.notNull(user, "异常记录关联的人员不存在");
String userId = exception.getUserId();
Sys_role memberRole = dao().fetch(Sys_role.class,
Cnd.where("code", "=", RoleConstant.MEMBER.name()));
Assert.notNull(memberRole, "系统未配置会员角色");
boolean hasMemberRole = dao().count(
Sys_user_role.class,
Cnd.where("userId", "=", userId).and("roleId", "=", memberRole.getId())
) > 0;
// 人员类型、在职状态与本次确认结果在同一事务中保存,避免人员信息和异常状态不一致。
Chain userChanges = Chain.make("personType", personType)
.add("userState", userState);
if ("DISABLE".equals(handleResult)) {
userChanges.add("disabled", true);
}
dao().update(Sys_user.class, userChanges, Cnd.where("id", "=", userId));
// 会员字段和会员角色以本次选择为准,同时修复历史数据中字段与角色不一致的情况。
if (member == 1 && (!Boolean.TRUE.equals(user.getMember()) || !hasMemberRole)) {
dao().update(Sys_user.class,
Chain.make("member", true).add("memberTime", new Date()),
Cnd.where("id", "=", userId));
if (!hasMemberRole) {
Sys_user_role userRole = new Sys_user_role();
userRole.setUserId(userId);
userRole.setRoleId(memberRole.getId());
dao().insert(userRole);
}
} else if (member == 0 && (Boolean.TRUE.equals(user.getMember()) || hasMemberRole)) {
dao().update(Sys_user.class, Chain.make("member", false), Cnd.where("id", "=", userId));
dao().clear(Sys_user_role.class,
Cnd.where("userId", "=", userId).and("roleId", "=", memberRole.getId()));
}
// 当前项目没有独立福利会员角色,只维护福利会员字段及加入时间。
if (welfareMember == 1
&& !Boolean.TRUE.equals(user.getWelfareMember())) {
Chain welfareChanges = Chain.make("welfareMember", true);
if (!Boolean.TRUE.equals(user.getWelfareMember())) {
welfareChanges.add("welfareMemberJoinTime", new Date());
}
dao().update(Sys_user.class, welfareChanges, Cnd.where("id", "=", userId));
} else if (welfareMember == 0
&& Boolean.TRUE.equals(user.getWelfareMember())) {
dao().update(
Sys_user.class,
Chain.make("welfareMember", false).add("welfareMemberJoinTime", null),
Cnd.where("id", "=", userId)
);
}
sysUserService.deleteCache(userId);
exception.setStatus("PROCESSED")
.setHandleResult(handleResult)
.setHandleOpinion(opinion)
.setHandlerId(SecurityUtil.getUserId())
.setHandlerName(SecurityUtil.getUserUsername())
.setHandleTime(new Date());
update(exception);
}
}
}
@@ -39,8 +39,9 @@ public class MemberApplyCommonController {
return Result.success(user); return Result.success(user);
} }
// 分会主席只能查看本分会用户或未分配分会的用户 分工会操作员同主席权限一致 // 分工会主席、操作员和委员只能查看本分工会用户或未分配分工会的用户。
if (AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_CHAIRMAN.name(), RoleConstant.BRANCH_UNION_OPERATOR.name()) && (user.getUnionId().equals(SecurityUtil.getUnionId()) || StrUtil.isBlank(user.getUnionId()))) { if (AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_CHAIRMAN.name(), RoleConstant.BRANCH_UNION_OPERATOR.name(),
RoleConstant.BRANCH_UNION_WY.name()) && (user.getUnionId().equals(SecurityUtil.getUnionId()) || StrUtil.isBlank(user.getUnionId()))) {
user.setIdCard(DesensitizedUtil.idCardNum(user.getIdCard(), 3, 4)); user.setIdCard(DesensitizedUtil.idCardNum(user.getIdCard(), 3, 4));
user.setMobile(DesensitizedUtil.mobilePhone(user.getMobile())); user.setMobile(DesensitizedUtil.mobilePhone(user.getMobile()));
return Result.success(user); return Result.success(user);
@@ -156,7 +156,8 @@ public class MemberChangeApplyController {
* @return * @return
*/ */
@At @At
@SaCheckPermission(value = {"member.change.branchUnion.audit","member.change.schoolUnion.audit", "member.change.unionGroup.audit"}, mode = SaMode.OR) @SaCheckPermission(value = {"member.change.branchUnion.audit", "member.change.schoolUnion.audit",
"member.change.unionGroup.audit", "member.change.mange", "staff.member.change.mange"}, mode = SaMode.OR)
public Result getUserByIdForMemberChange(String userId){ public Result getUserByIdForMemberChange(String userId){
View_user user = dao.fetch(View_user.class, Cnd.where("id", "=", userId)); View_user user = dao.fetch(View_user.class, Cnd.where("id", "=", userId));
NutMap nutMap = Lang.obj2nutmap(user); NutMap nutMap = Lang.obj2nutmap(user);
@@ -113,7 +113,9 @@ public class MemberChangeManageController {
} }
boolean schoolRoleBool = AuthUtil.hasRoleOr(RoleConstant.SCHOOL_UNION_CHAIRMAN.name(), RoleConstant.SCHOOL_UNION_MEMBER_ADMIN.name(), RoleConstant.SYSADMIN.name()); boolean schoolRoleBool = AuthUtil.hasRoleOr(RoleConstant.SCHOOL_UNION_CHAIRMAN.name(), RoleConstant.SCHOOL_UNION_MEMBER_ADMIN.name(), RoleConstant.SYSADMIN.name());
boolean branchRoleBool = AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_CHAIRMAN.name(), RoleConstant.BRANCH_UNION_OPERATOR.name()); // 分工会委员与分工会管理员使用同一提交路线,提交后直接进入校工会审核节点。
boolean branchRoleBool = AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_CHAIRMAN.name(), RoleConstant.BRANCH_UNION_OPERATOR.name(),
RoleConstant.BRANCH_UNION_WY.name());
boolean groupRoleBool = AuthUtil.hasRole(RoleConstant.BRANCH_UNION_GROUP_LEADER.name()); boolean groupRoleBool = AuthUtil.hasRole(RoleConstant.BRANCH_UNION_GROUP_LEADER.name());
// 变更类型 // 变更类型
@@ -42,6 +42,8 @@ public class MemberManagePageForm extends PageForm {
private String userAttribute; private String userAttribute;
private List<String> userAttributes;
private String changeType; private String changeType;
//变更状态数组 //变更状态数组
@@ -68,7 +70,9 @@ public class MemberManagePageForm extends PageForm {
cnd = Cnd.NEW(); cnd = Cnd.NEW();
} }
if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())) { if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())) {
if (AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_CHAIRMAN.name(), RoleConstant.BRANCH_UNION_OPERATOR.name())) { // 分工会主席、操作员和委员均只能查询当前登录人所属分工会的人员。
if (AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_CHAIRMAN.name(), RoleConstant.BRANCH_UNION_OPERATOR.name(),
RoleConstant.BRANCH_UNION_WY.name())) {
cnd.and("u.unionId", "=", SecurityUtil.getUnionId()); cnd.and("u.unionId", "=", SecurityUtil.getUnionId());
} else if (AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_GROUP_LEADER.name())) { } else if (AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_GROUP_LEADER.name())) {
cnd.and("u.unionGroupId", "=", SecurityUtil.getUnionGroupId()); cnd.and("u.unionGroupId", "=", SecurityUtil.getUnionGroupId());
@@ -93,7 +97,11 @@ public class MemberManagePageForm extends PageForm {
cnd.andEX("u.sex", "=", pageForm.getSex()); cnd.andEX("u.sex", "=", pageForm.getSex());
cnd.andEX("u.personType", "=", pageForm.getPersonType()); cnd.andEX("u.personType", "=", pageForm.getPersonType());
cnd.andEX("u.aidFundMemberUserType", "=", pageForm.getAidFundMemberUserType()); cnd.andEX("u.aidFundMemberUserType", "=", pageForm.getAidFundMemberUserType());
if (Lang.isNotEmpty(pageForm.getUserAttributes())) {
cnd.and("u.userAttribute", "in", pageForm.getUserAttributes());
} else {
cnd.andEX("u.userAttribute", "=", pageForm.getUserAttribute()); cnd.andEX("u.userAttribute", "=", pageForm.getUserAttribute());
}
if (StrUtil.isNotBlank(pageForm.getChangeDateBefore()) && StrUtil.isNotBlank(pageForm.getChangeDateEnd())) { if (StrUtil.isNotBlank(pageForm.getChangeDateBefore()) && StrUtil.isNotBlank(pageForm.getChangeDateEnd())) {
cnd.and(new Static(String.format("Date(his.changeTime) >= '%s' and Date(his.changeTime) <= '%s'", pageForm.getChangeDateBefore(), pageForm.getChangeDateEnd()))); cnd.and(new Static(String.format("Date(his.changeTime) >= '%s' and Date(his.changeTime) <= '%s'", pageForm.getChangeDateBefore(), pageForm.getChangeDateEnd())));
@@ -148,7 +156,11 @@ public class MemberManagePageForm extends PageForm {
cnd.andEX("record.preparedBy", "=", pageForm.getPreparedBy()); cnd.andEX("record.preparedBy", "=", pageForm.getPreparedBy());
cnd.andEX("record.personType", "=", pageForm.getPersonType()); cnd.andEX("record.personType", "=", pageForm.getPersonType());
cnd.andEX("record.aidFundMemberUserType", "=", pageForm.getAidFundMemberUserType()); cnd.andEX("record.aidFundMemberUserType", "=", pageForm.getAidFundMemberUserType());
if (Lang.isNotEmpty(pageForm.getUserAttributes())) {
cnd.and("record.userAttribute", "in", pageForm.getUserAttributes());
} else {
cnd.andEX("record.userAttribute", "=", pageForm.getUserAttribute()); cnd.andEX("record.userAttribute", "=", pageForm.getUserAttribute());
}
cnd.groupBy("record.id,task.id"); cnd.groupBy("record.id,task.id");
} }
@@ -98,8 +98,8 @@ public class MemberManageServiceImpl extends BaseServiceImpl<Sys_user> implement
u.userAttribute, u.userAttribute,
u.aidFundMemberUserType, u.aidFundMemberUserType,
u.arrivalAtSchoolDate, u.arrivalAtSchoolDate,
his.id hisId, COALESCE(his.id, '') hisId,
his.changeTypes, COALESCE(his.changeTypes, '[]') changeTypes,
his.changeTime his.changeTime
FROM FROM
vw_user u vw_user u
@@ -109,6 +109,7 @@ public class MemberManageServiceImpl extends BaseServiceImpl<Sys_user> implement
Cnd cnd = Cnd.NEW(); Cnd cnd = Cnd.NEW();
MemberManagePageForm.buildSearch(cnd, pageForm); MemberManagePageForm.buildSearch(cnd, pageForm);
cnd.desc("his.changeTime"); cnd.desc("his.changeTime");
cnd.desc("u.id");
cnd.groupBy("u.id"); cnd.groupBy("u.id");
if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())) { if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())) {
@@ -175,6 +176,11 @@ public class MemberManageServiceImpl extends BaseServiceImpl<Sys_user> implement
cnd.andEX("his.unitId", "=", pageForm.getUnitId()); cnd.andEX("his.unitId", "=", pageForm.getUnitId());
cnd.andEX("his.personType", "=", pageForm.getPersonType()); cnd.andEX("his.personType", "=", pageForm.getPersonType());
cnd.andEX("his.userState", "=", pageForm.getUserState()); cnd.andEX("his.userState", "=", pageForm.getUserState());
if (Lang.isNotEmpty(pageForm.getUserAttributes())) {
cnd.and("his.userAttribute", "in", pageForm.getUserAttributes());
} else {
cnd.andEX("his.userAttribute", "=", pageForm.getUserAttribute());
}
if (StrUtil.isNotBlank(pageForm.getChangeType())){ if (StrUtil.isNotBlank(pageForm.getChangeType())){
cnd.and(new Static("JSON_CONTAINS(his.changeTypes, JSON_ARRAY('%s'), '$')".formatted(pageForm.getChangeType()))); cnd.and(new Static("JSON_CONTAINS(his.changeTypes, JSON_ARRAY('%s'), '$')".formatted(pageForm.getChangeType())));
} }
@@ -251,6 +257,11 @@ public class MemberManageServiceImpl extends BaseServiceImpl<Sys_user> implement
Set<String> allowChangeFieldNames = map.getAs("allowChangeFieldNames", Set.class); Set<String> allowChangeFieldNames = map.getAs("allowChangeFieldNames", Set.class);
Map<String, String> dictMap = map.getAs("dictMap", Map.class); Map<String, String> dictMap = map.getAs("dictMap", Map.class);
if (record.getRetireDate() != null) {
allowChangeFieldNames.add("retireDate");
dictMap.put("retireDate", "退休日期");
}
// 新数据 // 新数据
NutMap newMap = Lang.obj2nutmap(record); NutMap newMap = Lang.obj2nutmap(record);
// 原数据 // 原数据
@@ -300,6 +311,7 @@ public class MemberManageServiceImpl extends BaseServiceImpl<Sys_user> implement
@Override @Override
public void compareChangeInfoAndUpdateMember(MemberChangeRecord record) { public void compareChangeInfoAndUpdateMember(MemberChangeRecord record) {
applyRetiredPersonType(record);
normalizeArrivalAtSchoolDate(record); normalizeArrivalAtSchoolDate(record);
List<NutMap> changeList = getChangeInfos(record); List<NutMap> changeList = getChangeInfos(record);
@@ -506,6 +518,7 @@ public class MemberManageServiceImpl extends BaseServiceImpl<Sys_user> implement
@Override @Override
public void validateChangeAndSetBasicData(MemberChangeRecord record, MemberChangeOrigin origin) { public void validateChangeAndSetBasicData(MemberChangeRecord record, MemberChangeOrigin origin) {
applyRetiredPersonType(record);
normalizeArrivalAtSchoolDate(record); normalizeArrivalAtSchoolDate(record);
// 校验有没有发生变更 // 校验有没有发生变更
List<NutMap> changeInfos = getChangeInfos(record); List<NutMap> changeInfos = getChangeInfos(record);
@@ -525,6 +538,20 @@ public class MemberManageServiceImpl extends BaseServiceImpl<Sys_user> implement
} }
private void applyRetiredPersonType(MemberChangeRecord record) {
if (record == null || !StrUtil.equals("退休", record.getUserState())) {
return;
}
List<Sys_dict> personTypeOptions = sysDictService.getSubListByCode("USER_PERSON_TYPE");
if (Lang.isEmpty(personTypeOptions)) {
return;
}
personTypeOptions.stream()
.filter(option -> StrUtil.equals("离退休人员", option.getName()))
.findFirst()
.ifPresent(option -> record.setPersonType(option.getCode()));
}
/** /**
* 统一规范变更记录中的到校日期,避免 Date 对象复制后以英文字符串形式参与比较或直接落库。 * 统一规范变更记录中的到校日期,避免 Date 对象复制后以英文字符串形式参与比较或直接落库。
*/ */
+17 -38
View File
@@ -1,6 +1,6 @@
nutz: nutz:
application: application:
name: zhgh_cug_v4 name: zhgh_jsahvc
mvc: mvc:
ignore: ^(.+[.])(jsp|png|gif|jpg|js|css|jspx|jpeg|html|mp3|mp4|ico|svg|vue)$ ignore: ^(.+[.])(jsp|png|gif|jpg|js|css|jspx|jpeg|html|mp3|mp4|ico|svg|vue)$
exclusions: /favicon/*,/assets/*,/druid/*,/upload/*,/components/* exclusions: /favicon/*,/assets/*,/druid/*,/upload/*,/components/*
@@ -21,7 +21,7 @@ jetty:
#结合ftp使用,或用nginx代理ftp路径 #结合ftp使用,或用nginx代理ftp路径
#staticPath: /Users/wizzer/temp/files #staticPath: /Users/wizzer/temp/files
#开发模式静态资源 #开发模式静态资源
staticPathLocal: E:/projects/zhgh_cug_v4/src/main/resources/static staticPathLocal: E:/projects/zhgh_jsahvc_new/src/main/resources/static
security: security:
tokenName: saToken tokenName: saToken
@@ -49,7 +49,6 @@ cas:
enable: false enable: false
server-url-prefix: https://i.njupt.edu.cn/cas server-url-prefix: https://i.njupt.edu.cn/cas
server-login-url: https://i.njupt.edu.cn/cas/login server-login-url: https://i.njupt.edu.cn/cas/login
qi-wx-server-login-url: httos:/cas.whcp.eou.cn/lyuapServer/wechatauz/wxAuthRedirect
client-host-url: https://zhgh.njupt.edu.cn client-host-url: https://zhgh.njupt.edu.cn
client-call-back-url: /platform/sso/login client-call-back-url: /platform/sso/login
validation-type: cas validation-type: cas
@@ -71,8 +70,9 @@ redis:
#nodes=192.168.6.31:6377,192.168.6.31:6378,192.168.6.28:6377,192.168.6.28:6378,192.168.6.34:6377,192.168.6.34:6378 #nodes=192.168.6.31:6377,192.168.6.31:6378,192.168.6.28:6377,192.168.6.28:6378,192.168.6.34:6377,192.168.6.34:6378
jdbc: jdbc:
url: jdbc:mysql://192.168.21.215:3306/zhgh_cug_v4?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true url: jdbc:mysql://192.168.21.215:3306/zhgh_jsahvc_new?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true
username: root username: root
# password: wqnmd2331
password: root password: root
validationQuery: select 1 validationQuery: select 1
maxActive: 1000 maxActive: 1000
@@ -87,7 +87,7 @@ xsssql:
beetl: beetl:
RESOURCE: RESOURCE:
#本地路径,开发时设置本地路径,便于调试 #本地路径,开发时设置本地路径,便于调试
rootLocal: E:/projects/zhgh_cug_v4/src/main/resources/views/ rootLocal: E:/projects/zhgh_jsahvc_new/src/main/resources/views/
root: views/ root: views/
DELIMITER_STATEMENT_START: "<!--#" DELIMITER_STATEMENT_START: "<!--#"
DELIMITER_STATEMENT_END: "#-->" DELIMITER_STATEMENT_END: "#-->"
@@ -165,40 +165,19 @@ minio:
endPoint: http://192.168.21.214:9000 endPoint: http://192.168.21.214:9000
bucket: njupt bucket: njupt
data-center: sms:
grant_type: password aliyun:
scope: read # 是否启用阿里云短信。未启用时不会调用阿里云,避免开发环境误发短信。
username: 3848A3C738EFBB2C2E76E0D59B419DACA16A80F3AB0354CD
password: 3848A3C738EFBB2C2E76E0D59B419DACA16A80F3AB0354CD
token-url: https://bd.whcp.edu.cn/sjjkfwpt/api/bd-api/oauth/token
urls:
teacher: https://bd.whcp.edu.cn/sjjkfwpt/api/bd-api/get/JZGJCSJZLB
unit: https://bd.whcp.edu.cn/sjjkfwpt/api/bd-api/get/YXSDWJBSJZLB
msg:
platform:
enabled: false enabled: false
# 地大通讯平台基础地址,默认对接正式地址 endpoint: dysmsapi.aliyuncs.com
base-url: https://msg.cug.edu.cn access-key-id: LTAI5t6CeHgF4fnsXca8Xwee
# 授权系统名称,对应文档中的 tp_name access-key-secret: 09hqxEplUSAgwn02Pzv4UuAoUxZoD2
tp-name : nwszl.cug.edu.cn # 阿里云短信签名名称,必须使用审核通过的签名。
# 便捷发送方法默认发送人工号,controller 只传接收人和内容时会读取这里 sign-name: 江苏农牧科技职业学院
default-send-user-id: 790036 # 阿里云短信模板 Code,必须使用审核通过的模板。
# 便捷发送方法默认发送人姓名,controller 只传接收人和内容时会读取这里 template-code: SMS_508220213
default-send-user-name: 周斐霏 # 模板变量名。例如模板内容为“您有一条待办:${title}”,这里填 title。
# 便捷发送方法默认发送机构ID,可不填 template-param-name: title
default-send-unit-id: 421
# 便捷发送方法默认发送机构名称,可不填
default-send-unit-name: 工会
# 便捷发送方法默认签名,可不填
default-send-user-sign:
#短信中如果不包含链接和电话,可以使用公共模板ID:
default-template-id: 1978744168869937153
# 原始密钥,未提前加密时可直接填这里,程序会按 Base64 后 SHA 自动计算 secret_key
raw-secret-key: Z21kbDlwYWJ4c2Vtbnk4NjlxeQ==
# 如果对方已经提供了可直接使用的 secret_key,也可以直接配置该值,优先级高于 raw-secret-key
encrypted-secret-key:
# HTTP连接超时时间,单位毫秒
connect-timeout: 5000 connect-timeout: 5000
# HTTP读取超时时间,单位毫秒
read-timeout: 10000 read-timeout: 10000
+212
View File
@@ -0,0 +1,212 @@
-- 人员数据治理模块初始化脚本(MySQL 8.0,UTF-8)。
-- 人员变更直接复用现有 member_change_record、sys_user_history 及 WF 流程,本脚本不创建替代表。
SET NAMES utf8mb4;
SET @add_person_flag = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_user' AND COLUMN_NAME = 'flag') = 0,
'ALTER TABLE sys_user ADD COLUMN flag int DEFAULT 0 COMMENT ''人员治理同步保护标识:1忽略同步,0或空正常同步''',
'SELECT 1'
);
PREPARE add_person_flag_stmt FROM @add_person_flag;
EXECUTE add_person_flag_stmt;
DEALLOCATE PREPARE add_person_flag_stmt;
CREATE TABLE IF NOT EXISTS person_api_config (
id varchar(32) NOT NULL,
configName varchar(100), sourceType varchar(20), requestUrl varchar(500), requestMethod varchar(10),
timeoutSeconds int, sourceTable varchar(100), targetTable varchar(100), syncMode varchar(20),
identityField varchar(100), bodyType varchar(30), bodyContent text,
tokenRequestUrl varchar(500), tokenRequestMethod varchar(10), tokenTimeoutSeconds int,
tokenBodyType varchar(30), tokenBodyContent text, tokenValuePath varchar(200), tokenExpiresPath varchar(200),
tokenExpiryUnit varchar(20), tokenQueryResult text, dataQueryResult text,
responseCodePath varchar(200), responseSuccessValue varchar(100), responseMessagePath varchar(200),
responseDataPath varchar(200), responseTotalPath varchar(200), enabled tinyint(1), taskCron varchar(100),
matchMemberRules tinyint(1), lastSyncTime datetime, taskId varchar(32), remark varchar(500),
createdBy varchar(32), createdAt bigint, updatedBy varchar(32), updatedAt bigint,
delFlag tinyint(1), opBy varchar(128), opAt varchar(128),
PRIMARY KEY (id), UNIQUE KEY UK_PERSON_API_CONFIG_NAME (configName)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS person_api_field_mapping (
id varchar(32) NOT NULL, configId varchar(32), sourceField varchar(100), sourceComment varchar(200),
targetField varchar(100), targetComment varchar(200), required tinyint(1), identityFlag tinyint(1), sort int,
PRIMARY KEY (id), UNIQUE KEY UK_PERSON_API_MAPPING_TARGET (configId, targetField)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS person_api_request_param (
id varchar(32) NOT NULL, configId varchar(32), requestScope varchar(20), paramLocation varchar(20),
paramName varchar(100), paramType varchar(30), paramValue text, required tinyint(1),
description varchar(300), sensitiveFlag int, sort int, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS person_api_test_case (
id varchar(32) NOT NULL, configId varchar(32), requestScope varchar(20), caseName varchar(100),
requestSnapshot text, responseStatus int, responseTime bigint, responseBody text, success tinyint(1),
executedAt datetime, createdBy varchar(32), createdAt bigint, updatedBy varchar(32), updatedAt bigint,
delFlag tinyint(1), opBy varchar(128), opAt varchar(128), PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS person_sync_batch (
id varchar(32) NOT NULL, configId varchar(32), batchNo varchar(50), triggerType varchar(20),
startedAt datetime, finishedAt datetime, status varchar(20), sourceCount int, successCount int,
newCount int, changeCount int, exceptionCount int, errorMessage text, rawFileReid varchar(32),
createdBy varchar(32), createdAt bigint, updatedBy varchar(32), updatedAt bigint,
delFlag tinyint(1), opBy varchar(128), opAt varchar(128),
PRIMARY KEY (id), UNIQUE KEY UK_PERSON_SYNC_BATCH_NO (batchNo)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS person_source_relation (
id varchar(32) NOT NULL, configId varchar(32), sourcePersonKey varchar(150), userId varchar(32),
personNo varchar(120), lastSeenBatchId varchar(32), lastSeenTime datetime, presentFlag tinyint(1),
sourceDataHash varchar(64), createdBy varchar(32), createdAt bigint, updatedBy varchar(32), updatedAt bigint,
delFlag tinyint(1), opBy varchar(128), opAt varchar(128),
PRIMARY KEY (id), UNIQUE KEY UK_PERSON_SOURCE_KEY (configId, sourcePersonKey)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS person_member_match_config (
id varchar(32) NOT NULL, configName varchar(100), apiConfigId varchar(32), copiedFromId varchar(32),
enabled tinyint(1), groupCount int, lastMatchCount int, lastExecuteTime datetime,
createdBy varchar(32), createdAt bigint, updatedBy varchar(32), updatedAt bigint,
delFlag tinyint(1), opBy varchar(128), opAt varchar(128),
PRIMARY KEY (id), UNIQUE KEY UK_PERSON_MEMBER_API_CONFIG (apiConfigId)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS person_member_match_group (
id varchar(32) NOT NULL, matchConfigId varchar(32), parentGroupId varchar(32),
ruleType varchar(30) DEFAULT 'UNION_MEMBER', groupNo int, joinLogic varchar(3), sort int,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS person_member_match_condition (
id varchar(32) NOT NULL, groupId varchar(32), dictCode varchar(50), dictItemId varchar(32),
dictItemCode varchar(50), dictItemName varchar(100), mappedField varchar(100), operator varchar(30),
matchValue varchar(500), joinLogic varchar(3), sort int, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS person_exception (
id varchar(32) NOT NULL, configId varchar(32), relationId varchar(32), discoverBatchId varchar(32),
userId varchar(32), personNo varchar(120), personName varchar(100), unitId varchar(32), unitName varchar(150),
exceptionType varchar(30), exceptionReason varchar(500), lastSyncTime datetime, discoverTime datetime,
status varchar(30), handleResult varchar(30), handleOpinion varchar(500), handlerId varchar(32),
handlerName varchar(100), handleTime datetime, rawFileReid varchar(32),
createdBy varchar(32), createdAt bigint, updatedBy varchar(32), updatedAt bigint,
delFlag tinyint(1), opBy varchar(128), opAt varchar(128), PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- PC 端独立应用菜单。完整页面跳转由布局按 /platform/personGovernance/ 路径处理。
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled,
permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag,
platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT '76725022b0cc11f196a000ff8d9db2e0', '', LPAD(IFNULL(MAX(CAST(path AS UNSIGNED)), 0) + 1, 4, '0'),
'人员数据治理', 'PersonGovernance', 'menu', '', '', 'ti-id-badge', 1, 0,
'data.person.governance', '人员数据接入、会员匹配、人员变更及异常处理', 694, 1,
'', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0,
'PC', NULL, NULL, 'r', 0, 0
FROM sys_menu WHERE (parentId = '' OR parentId IS NULL) AND CHAR_LENGTH(path) = 4
HAVING NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance') m);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled,
permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag,
platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT '7673d831b0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0001'), 'API配置', 'ApiConfig', 'menu',
'/platform/personGovernance/apiConfig', '', '', 1, 0, 'data.person.governance.api', NULL, 1, 0,
'', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'a', 0, 0
FROM sys_menu p WHERE p.permission = 'data.person.governance'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.api') m);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled,
permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag,
platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT '76749371b0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0002'), '会员匹配', 'MemberMatch', 'menu',
'/platform/personGovernance/memberMatch', '', '', 1, 0, 'data.person.governance.member', NULL, 2, 0,
'', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'h', 0, 0
FROM sys_menu p WHERE p.permission = 'data.person.governance'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.member') m);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled,
permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag,
platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT '7674b630b0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0003'), '人员变更', 'PersonChange', 'menu',
'/platform/personGovernance/personChange', '', '', 1, 0, 'data.person.governance.change', NULL, 3, 0,
'', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'r', 0, 0
FROM sys_menu p WHERE p.permission = 'data.person.governance'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.change') m);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled,
permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag,
platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT '7674d34eb0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0004'), '人员异常', 'PersonException', 'menu',
'/platform/personGovernance/personException', '', '', 1, 0, 'data.person.governance.exception', NULL, 4, 0,
'', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'r', 0, 0
FROM sys_menu p WHERE p.permission = 'data.person.governance'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.exception') m);
-- 页面按钮权限;type=data 不在左侧菜单展示。
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, showit, disabled, permission, location,
hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId,
initialPinyinName, isRecommendApp, isRecommendService)
SELECT '7674fb04b0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0001'), '保存API配置', 'Edit', 'data',
0, 0, 'data.person.governance.api.edit', 1, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '',
UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, 'b', 0, 0 FROM sys_menu p
WHERE p.permission = 'data.person.governance.api' AND NOT EXISTS
(SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.api.edit') m);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, showit, disabled, permission, location,
hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId,
initialPinyinName, isRecommendApp, isRecommendService)
SELECT '76750400b0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0002'), '删除API配置', 'Delete', 'data',
0, 0, 'data.person.governance.api.delete', 2, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '',
UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, 's', 0, 0 FROM sys_menu p
WHERE p.permission = 'data.person.governance.api' AND NOT EXISTS
(SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.api.delete') m);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, showit, disabled, permission, location,
hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId,
initialPinyinName, isRecommendApp, isRecommendService)
SELECT '76750e42b0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0003'), '测试及同步', 'Test', 'data',
0, 0, 'data.person.governance.api.test', 3, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '',
UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, 'c', 0, 0 FROM sys_menu p
WHERE p.permission = 'data.person.governance.api' AND NOT EXISTS
(SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.api.test') m);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, showit, disabled, permission, location,
hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId,
initialPinyinName, isRecommendApp, isRecommendService)
SELECT '767518e8b0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0001'), '保存会员匹配', 'Edit', 'data',
0, 0, 'data.person.governance.member.edit', 1, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '',
UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, 'b', 0, 0 FROM sys_menu p
WHERE p.permission = 'data.person.governance.member' AND NOT EXISTS
(SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.member.edit') m);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, showit, disabled, permission, location,
hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId,
initialPinyinName, isRecommendApp, isRecommendService)
SELECT '767523deb0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0002'), '删除会员匹配', 'Delete', 'data',
0, 0, 'data.person.governance.member.delete', 2, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '',
UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, 's', 0, 0 FROM sys_menu p
WHERE p.permission = 'data.person.governance.member' AND NOT EXISTS
(SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.member.delete') m);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, showit, disabled, permission, location,
hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId,
initialPinyinName, isRecommendApp, isRecommendService)
SELECT '76752eacb0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0001'), '处理人员异常', 'Handle', 'data',
0, 0, 'data.person.governance.exception.handle', 1, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '',
UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, 'c', 0, 0 FROM sys_menu p
WHERE p.permission = 'data.person.governance.exception' AND NOT EXISTS
(SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.exception.handle') m);
-- 给系统管理员默认授权,其他角色可在角色管理中按需分配。
INSERT INTO sys_role_menu (roleId, menuId)
SELECT r.id, m.id
FROM sys_role r
JOIN sys_menu m ON m.permission IN (
'data.person.governance', 'data.person.governance.api', 'data.person.governance.member',
'data.person.governance.api.edit', 'data.person.governance.api.delete', 'data.person.governance.api.test',
'data.person.governance.member.edit', 'data.person.governance.member.delete',
'data.person.governance.change', 'data.person.governance.exception',
'data.person.governance.exception.handle')
LEFT JOIN sys_role_menu rm ON rm.roleId = r.id AND rm.menuId = m.id
WHERE r.code = 'SYSADMIN'
AND rm.roleId IS NULL;
+89 -9
View File
@@ -1,17 +1,85 @@
<?xml version="1.0" encoding="UTF-8" ?> <?xml version="1.0" encoding="UTF-8" ?>
<configuration scan="false" scanPeriod="60000" debug="false"> <configuration scan="false" scanPeriod="60000" debug="false">
<!-- 定义日志文件的存储路径 --> <!-- 日志存储路径 -->
<property name="LOG_HOME" value="./logs"/> <property name="LOG_HOME" value="./logs"/>
<!-- 控制台输出配置 --> <!-- 控制台输出 -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder> <encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder> </encoder>
</appender> </appender>
<!-- INFO级别日志文件配置 -->
<!--
#################################################################################
# #
# 全文输出日志(一个文件存储)start #
# #
#################################################################################
-->
<!-- 所有级别日志写入同一个文件 -->
<appender name="ALL_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/app.log</file>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/app-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>300</maxHistory>
</rollingPolicy>
</appender>
<!-- 框架日志级别(按需调整) -->
<logger name="org.eclipse.jetty" level="INFO"/>
<logger name="org.quartz" level="INFO"/>
<logger name="org.nutz" level="DEBUG"/>
<!-- root logger:DEBUG 级别,输出到控制台和全量文件 -->
<root level="DEBUG">
<appender-ref ref="STDOUT"/>
<appender-ref ref="ALL_FILE"/>
</root>
<!--
#################################################################################
# #
# 全文输出日志(一个文件存储)end #
# #
#################################################################################
-->
<!--
#################################################################################
# #
# debug、info、error分文件输出 start #
# #
#################################################################################
-->
<!-- DEBUG级别日志文件配置 -->
<!--<appender name="DEBUG_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/debug.log</file>
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>DEBUG</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/debug-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>300</maxHistory>
</rollingPolicy>
</appender>
&lt;!&ndash; INFO级别日志文件配置 &ndash;&gt;
<appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/info.log</file> <file>${LOG_HOME}/info.log</file>
<encoder> <encoder>
@@ -24,11 +92,11 @@
</filter> </filter>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/info-%d{yyyy-MM-dd}.log</fileNamePattern> <fileNamePattern>${LOG_HOME}/info-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory> <maxHistory>300</maxHistory>
</rollingPolicy> </rollingPolicy>
</appender> </appender>
<!-- ERROR级别日志文件配置 --> &lt;!&ndash; ERROR级别日志文件配置 &ndash;&gt;
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/error.log</file> <file>${LOG_HOME}/error.log</file>
<encoder> <encoder>
@@ -41,19 +109,31 @@
</filter> </filter>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/error-%d{yyyy-MM-dd}.log</fileNamePattern> <fileNamePattern>${LOG_HOME}/error-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory> <maxHistory>300</maxHistory>
</rollingPolicy> </rollingPolicy>
</appender> </appender>
<logger name="java" additivity="false" /> &lt;!&ndash; 框架日志级别 &ndash;&gt;
&lt;!&ndash; <logger name="java" additivity="false" />&ndash;&gt;
<logger name="org.eclipse.jetty" level="INFO"/> <logger name="org.eclipse.jetty" level="INFO"/>
<logger name="org.quartz" level="INFO"/> <logger name="org.quartz" level="INFO"/>
<logger name="org.nutz" level="DEBUG"/> <logger name="org.nutz" level="DEBUG"/>
<!-- 日志级别和appender的关联 --> &lt;!&ndash; 日志级别和appender的关联 &ndash;&gt;
<root level="DEBUG"> <root level="DEBUG">
<appender-ref ref="STDOUT"/> <appender-ref ref="STDOUT"/>
<appender-ref ref="DEBUG_FILE"/>
<appender-ref ref="INFO_FILE"/> <appender-ref ref="INFO_FILE"/>
<appender-ref ref="ERROR_FILE"/> <appender-ref ref="ERROR_FILE"/>
</root> </root>-->
<!--
#################################################################################
# #
# debug、info、error分文件输出 end #
# #
#################################################################################
-->
</configuration> </configuration>
@@ -0,0 +1,772 @@
/* 人员数据治理列表统一占满内容区,数据超出时只滚动表格主体。 */
.person-governance-page-transition {
visibility: hidden;
opacity: 0;
}
.person-governance-page-transition.is-ready {
visibility: visible;
opacity: 1;
}
.person-governance-page-transition.is-leaving {
pointer-events: none;
opacity: 0;
}
.person-governance-list-page {
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
padding: 10px;
}
.person-governance-search-card {
flex: none;
}
.person-governance-list-card {
flex: 1;
min-height: 0;
margin-top: 20px;
}
/* 会员匹配列表的查询区与列表区间距缩小为原来的一半。 */
.person-governance-compact-gap .person-governance-list-card {
margin-top: 10px;
}
.person-governance-list-card > .el-card__body {
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.person-governance-table-wrap {
flex: 1;
min-height: 0;
overflow: hidden;
}
.person-governance-table-wrap > .el-table {
height: 100%;
}
.person-governance-list-footer,
.person-governance-list-card .el-pagination-container {
flex: none;
}
/* 人员变更列表位于 Guava 默认插槽内,列表视图仍按查询区、表格区、分页区分配固定高度。 */
.person-governance-change-page > .guava-main-content,
.person-governance-change-page > .guava-main-content > .transition-item {
height: 100%;
min-height: 0;
overflow: hidden;
}
.person-governance-change-page > .guava-main-content > .transition-item {
display: flex;
flex-direction: column;
}
.person-governance-change-list-card {
flex: 1;
min-height: 0;
}
.person-governance-change-list-card > .el-card__body {
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.person-governance-change-table-wrap {
flex: 1;
min-height: 0;
overflow: hidden;
}
.person-governance-change-table-wrap > .el-table {
height: 100%;
}
.person-governance-change-list-card .el-pagination-container {
flex: none;
}
/* 配置页顶部字段根据内容区宽度自动分列,空间不足时自然换行。 */
.person-governance-form-page {
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%;
max-width: 100%;
min-height: 0;
overflow-x: hidden;
overflow-y: hidden;
padding: 10px;
}
/* PageHeader 提供统一返回入口,并与基本配置保持紧凑间距。 */
.person-governance-page-header {
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
padding: 16px 20px;
background-color: #fff;
}
/* 表单页只保留返回入口,移除 PageHeader 空内容对应的分隔线和占位间距。 */
.person-governance-page-header .el-page-header__left {
margin-right: 0;
}
.person-governance-page-header .el-page-header__left::after,
.person-governance-page-header .el-page-header__content {
display: none;
}
.person-governance-form-basic-card {
flex: none;
}
/* 第三方接口按Token与数据分区管理,一级页签保持紧凑并独立于各自的子页签。 */
.person-governance-endpoint-tabs-card {
flex: none;
margin-bottom: 10px;
}
.person-governance-endpoint-tabs-card > .el-card__body {
padding: 0 20px;
}
.person-governance-endpoint-tabs-card .el-tabs__header {
margin-bottom: 0;
}
.person-governance-endpoint-tabs-card .el-tabs__content {
display: none;
}
/* 顶部基本配置横向铺满卡片,并压缩默认表单项产生的底部空白。 */
.person-governance-form-basic-card > .el-card__body {
padding: 20px 10px;
}
.person-governance-form-basic-card .el-form-item {
margin-bottom: 0;
}
/* 两类会员规则使用独立页签,页签本身不参与内容区滚动。 */
.person-governance-member-type-tabs {
flex: none;
margin-top: 10px;
padding: 0 20px;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 4px;
}
.person-governance-member-type-tabs .el-tabs__header {
margin: 0;
}
.person-governance-member-type-tabs .el-tabs__content {
display: none;
}
/* 主要配置板块占满剩余高度,内容过多时仅在板块内部滚动。 */
.person-governance-form-content-card {
flex: 1;
min-height: 0;
margin-top: 10px;
}
.person-governance-form-content-card > .el-card__body {
box-sizing: border-box;
height: 100%;
overflow: auto;
}
.person-governance-form-content-row {
display: flex;
flex: 1;
min-height: 0;
margin-top: 10px;
}
.person-governance-form-content-row > .el-col,
.person-governance-form-content-row > .el-col > .el-card {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
}
.person-governance-form-content-row > .el-col {
float: none;
}
.person-governance-form-content-row > .el-col > .el-card {
flex: 1;
}
.person-governance-form-content-row > .el-col > .el-card > .el-card__body {
box-sizing: border-box;
height: 100%;
overflow: auto;
}
/* 匹配规则标题固定,条件组占用剩余高度并独立滚动。 */
.person-governance-form-content-row > .el-col > .person-governance-member-rules > .el-card__body {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.person-governance-member-rules-header {
flex: none;
}
.person-governance-member-rules-content {
flex: 1;
min-height: 0;
overflow: auto;
padding-right: 8px;
}
/* 会员预览固定标题和操作区,表格独占剩余高度,长列表只滚动表格内部。 */
.person-governance-form-content-row > .el-col > .person-governance-member-preview > .el-card__body {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.person-governance-member-preview h3,
.person-governance-member-preview p,
.person-governance-member-preview > .el-card__body > .el-button {
flex: none;
}
.person-governance-member-preview > .el-card__body > .el-button {
align-self: flex-start;
}
.person-governance-member-preview-table {
flex: 1;
min-height: 0;
margin-top: 20px;
}
.person-governance-member-preview-pagination {
flex: none;
padding-top: 10px;
text-align: right;
}
.person-governance-member-preview-pagination .el-pagination {
padding: 0;
white-space: normal;
}
.person-governance-member-binding-hint {
margin-bottom: 16px;
}
.person-governance-member-condition-relation > td {
padding: 6px 0;
text-align: center;
background-color: #fafafa;
}
/* 每层条件组对应一层括号语义,通过边框和缩进直观展示父子层级。 */
.person-governance-member-group {
margin-top: 20px;
padding: 14px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #fff;
}
.person-governance-member-group.is-nested {
margin: 0 0 0 20px;
border-color: #b3d8ff;
background: #f8fbff;
}
.person-governance-member-group-header {
min-height: 28px;
margin-bottom: 10px;
}
.person-governance-member-group-actions {
margin-top: 10px;
}
.person-governance-member-group-relation {
margin: 28px 0;
}
.person-governance-member-child-relation {
margin-left: 20px;
}
.person-governance-form-footer {
display: flex;
align-items: center;
justify-content: flex-end;
flex: none;
margin-top: 10px;
}
.person-governance-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
column-gap: 20px;
align-items: start;
width: 100%;
}
.person-governance-form-grid .el-form-item {
margin-right: 0;
}
.person-governance-form-grid .el-select,
.person-governance-form-grid .el-input-number {
width: 100%;
}
/* API配置顶部字段按业务宽度靠左排列,同步方式具体值单独放在页首右侧。 */
.person-governance-sync-type {
color: #303133;
line-height: 24px;
}
.person-governance-api-basic-grid {
/* 按业务宽度紧凑靠左,剩余空间留在右侧;窄屏只允许整组字段换行。 */
display: flex;
flex-wrap: wrap;
gap: 18px 16px;
justify-content: flex-start;
align-items: center;
}
.person-governance-api-basic-grid > .el-form-item {
display: flex;
align-items: center;
flex: 0 1 280px;
min-width: 0;
max-width: 100%;
}
.person-governance-api-basic-grid .el-form-item__label {
flex: none;
float: none;
white-space: nowrap;
}
.person-governance-api-basic-grid .el-form-item__content {
flex: 1;
min-width: 0;
}
.person-governance-api-basic-grid > .person-governance-api-request-address {
flex-basis: 620px;
}
.person-governance-api-basic-grid > .person-governance-api-timeout {
flex-basis: 270px;
}
.person-governance-api-basic-grid > .person-governance-query-action {
flex: none;
}
.person-governance-request-address-control,
.person-governance-timeout-control {
display: flex;
align-items: center;
}
.person-governance-request-address-control {
width: 100%;
}
.person-governance-request-address-control .el-select {
flex: none;
width: 100px;
}
.person-governance-request-address-control > .el-input {
flex: 1;
min-width: 0;
margin-left: 8px;
}
.person-governance-timeout-control .el-input-number {
flex: none;
width: 200px;
min-width: 200px;
}
.person-governance-timeout-unit {
flex: none;
margin-left: 8px;
color: #606266;
}
/* API配置页签占满内容卡片,返回结果文本域随剩余高度伸展。 */
.person-governance-api-content-card > .el-card__body {
overflow: hidden;
}
.person-governance-api-content-card > .el-card__body > .el-tabs {
display: flex;
flex-direction: column;
height: 100%;
}
.person-governance-api-content-card > .el-card__body > .el-tabs > .el-tabs__header {
flex: none;
}
.person-governance-api-content-card > .el-card__body > .el-tabs > .el-tabs__content {
flex: 1;
min-height: 0;
}
.person-governance-api-content-card > .el-card__body > .el-tabs > .el-tabs__content > .el-tab-pane {
box-sizing: border-box;
height: 100%;
overflow: auto;
}
/* 系统表配置不显示单一的字段映射页签标题,映射内容直接占满卡片。 */
.person-governance-system-table-card > .el-card__body > .el-tabs > .el-tabs__header {
display: none;
}
.person-governance-system-table-card > .el-card__body > .el-tabs > .el-tabs__content,
.person-governance-system-table-card .person-governance-mapping-pane {
height: 100%;
}
.person-governance-system-table-card .person-governance-mapping-pane {
overflow: hidden !important;
}
.person-governance-result-pane {
display: flex;
flex-direction: column;
overflow: hidden !important;
}
.person-governance-result-textarea {
flex: 1;
min-height: 0;
height: 100%;
}
/* 返回结果下方的映射配置固定保留,长响应只在文本域内滚动。 */
.person-governance-response-fields {
display: flex;
flex-wrap: wrap;
flex: none;
gap: 10px 20px;
padding-top: 12px;
}
.person-governance-response-fields > div {
display: flex;
align-items: center;
flex: 1 1 280px;
min-width: 0;
gap: 10px;
}
.person-governance-response-fields label,
.person-governance-task-cron label {
white-space: nowrap;
flex: none;
}
.person-governance-response-fields .el-input,
.person-governance-response-fields .el-select {
flex: 1;
min-width: 0;
}
.person-governance-task-cron {
flex: 0 1 340px;
max-width: 340px;
}
.person-governance-cron-hint,
.person-governance-json-hint {
flex: none;
color: #909399;
font-size: 12px;
line-height: 20px;
}
.person-governance-parameter-pane .person-governance-body-json {
flex: 1;
min-height: 80px;
height: auto;
}
.person-governance-result-textarea .el-textarea__inner,
.person-governance-body-json .el-textarea__inner {
height: 100%;
resize: none;
}
.person-governance-result-actions {
flex: none;
margin-top: 10px;
text-align: right;
}
.person-governance-body-json {
height: calc(100% - 52px);
}
/* 字段映射与请求参数表格在可见页签内直接按完整宽高渲染。 */
.person-governance-mapping-table {
width: 100%;
}
.person-governance-mapping-pane {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden !important;
}
.person-governance-mapping-table-wrap {
flex: 1;
min-height: 0;
margin-top: 20px;
}
.person-governance-mapping-table-wrap > .el-table {
height: 100%;
}
.person-governance-mapping-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px 24px;
}
.person-governance-mapping-toolbar-item {
display: flex;
align-items: center;
min-width: 0;
}
/* 两种数据来源共用右侧清空入口,窄屏换行后仍靠右,不挤压周期控件。 */
.person-governance-mapping-toolbar > .person-governance-mapping-actions {
display: flex;
align-items: center;
flex-wrap: nowrap;
white-space: nowrap;
flex: none;
margin-left: auto;
}
.person-governance-mapping-toolbar-item > label {
flex: none;
margin-right: 10px;
color: #303133;
white-space: nowrap;
}
.person-governance-mapping-table-select {
flex: 0 1 360px;
max-width: 360px;
}
.person-governance-mapping-table-select .el-select {
flex: 1;
min-width: 180px;
}
.person-governance-mapping-switch {
flex: none;
}
/* 系统表配置项按配置名称、来源表、目标表、同步开关的顺序自适应排列。 */
.person-governance-system-table-toolbar {
flex: none;
}
.person-governance-system-table-toolbar .el-form-item {
/* 标签与控件作为一组排列,仅允许表单项在工具栏中整体换行。 */
display: flex;
align-items: center;
flex: 1 1 280px;
min-width: 0;
margin: 0;
}
.person-governance-system-table-toolbar .person-governance-system-target-table {
flex: 0 1 300px;
}
.person-governance-system-table-toolbar .person-governance-system-task-cron {
flex: 0 1 430px;
}
.person-governance-system-table-toolbar .person-governance-system-member-match {
flex: none;
column-gap: 12px;
margin-left: 16px;
}
/* 长标签按文字实际宽度展示,通过固定列间距避免文字与开关重叠或距离过大。 */
.person-governance-system-table-toolbar .person-governance-system-member-match .el-form-item__label {
width: auto !important;
padding-right: 0;
}
.person-governance-system-table-toolbar .person-governance-system-member-match .el-form-item__content {
margin-left: 0 !important;
}
.person-governance-system-table-toolbar .el-form-item__label {
flex: none;
float: none;
white-space: nowrap;
}
.person-governance-system-table-toolbar .el-form-item__content {
flex: 1;
min-width: 0;
width: auto;
}
.person-governance-system-table-toolbar .el-input,
.person-governance-system-table-toolbar .el-select {
width: 100%;
}
.person-governance-parameter-pane {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden !important;
}
.person-governance-parameter-pane > .el-tabs,
.person-governance-parameter-pane > .el-radio-group {
flex: none;
}
.person-governance-parameter-table-wrap {
flex: 1;
min-height: 0;
margin-top: 10px;
}
.person-governance-parameter-table-wrap > .el-table {
width: 100%;
height: 100%;
}
/* 参数表头按文字与加号按钮的实际高度紧凑展示。 */
.person-governance-parameter-table .el-table__header-wrapper th,
.person-governance-mapping-table .el-table__header-wrapper th {
padding: 6px 0;
}
.person-governance-parameter-table .el-table__header-wrapper th .cell,
.person-governance-mapping-table .el-table__header-wrapper th .cell {
line-height: 20px;
white-space: nowrap;
}
.person-governance-add-column .el-button {
padding: 0;
font-size: 16px;
line-height: 20px;
}
/* 弹窗和抽屉沿用 Element UI 过渡,并为本模块调整为更柔和的缓动。 */
.dialog-fade-enter-active .person-governance-dialog,
.dialog-fade-leave-active .person-governance-dialog {
transition:
opacity 260ms ease,
transform 260ms ease;
}
.dialog-fade-enter .person-governance-dialog,
.dialog-fade-leave-to .person-governance-dialog {
opacity: 0;
transform: translateY(-10px) scale(0.99);
}
/* 弹窗挂载body避开页面过渡层,短屏内部滚动,窄屏保留周期编辑器的完整操作区域。 */
.person-governance-cron-dialog {
max-width: calc(100vw - 32px);
}
.person-governance-cron-dialog .el-dialog__body {
max-height: 78vh;
overflow: auto;
padding: 10px 20px 20px;
}
.person-governance-cron-dialog .popup-main {
min-width: 640px;
}
.person-governance-drawer {
transition: box-shadow 260ms ease;
}
.person-governance-drawer.rtl,
.el-drawer__open .person-governance-drawer.rtl {
animation-duration: 340ms;
animation-timing-function: cubic-bezier(0.22, 0.61, 0.36, 1);
}
@media (max-width: 767px) {
.person-governance-list-page,
.person-governance-form-page {
padding: 8px;
}
.person-governance-form-grid {
grid-template-columns: 1fr;
}
.person-governance-page-header {
align-items: flex-start;
}
.person-governance-timeout-control .el-input-number {
flex: 1;
width: 100%;
min-width: 0;
}
.person-governance-mapping-table-select {
flex-basis: 100%;
max-width: none;
}
}
@@ -12,7 +12,10 @@ $.ajaxSetup({
contentType: "application/x-www-form-urlencoded;charset=utf-8", contentType: "application/x-www-form-urlencoded;charset=utf-8",
success: function (response, textStatus, jqXHR) { success: function (response, textStatus, jqXHR) {
if (response.code !== 0) { if (response.code !== 0) {
this.$message.error(response.msg) // AJAX全局回调没有Vue实例上下文,使用Element全局消息组件展示业务错误。
if (window.ELEMENT && ELEMENT.Message) {
ELEMENT.Message.error(response.msg)
}
} else { } else {
return response return response
} }
@@ -70,12 +70,15 @@ const initTableMixins = {
pageData(data = null) { pageData(data = null) {
const address = this.pageDataUrl ? this.pageDataUrl : loc() + "/pageData" const address = this.pageDataUrl ? this.pageDataUrl : loc() + "/pageData"
this.tableLoading = true this.tableLoading = true
this.$axios.post(address, data ? data : this.pageForm).then((res) => { // 返回请求 Promise,供页面在列表数据加载结束后再显示主体内容。
this.tableLoading = false return this.$axios.post(address, data ? data : this.pageForm).then((res) => {
if (res.code === 0) { if (res.code === 0) {
this.tableData = res.data.list this.tableData = res.data.list
this.pageForm.totalCount = res.data.totalCount this.pageForm.totalCount = res.data.totalCount
} }
}).finally(() => {
// 接口异常时同样结束加载状态,避免表格持续显示加载动画。
this.tableLoading = false
}) })
}, },
notifySuccess(msg) { notifySuccess(msg) {
@@ -24,10 +24,10 @@ const businessTool = {
} }
}) })
}, },
listUnit(unionId = null) { listUnit(unionId = null, unitLevel = null) {
return commonUtil return commonUtil
.axiosService() .axiosService()
.post("/open/common/unitOptions", {unionId}) .post("/open/common/unitOptions", {unionId, unitLevel})
.then((resp) => { .then((resp) => {
if (resp.code === 0) { if (resp.code === 0) {
return resp.data return resp.data
@@ -399,6 +399,14 @@ layout("/layouts/v4/baseLayout.html"){
// 默认选中 // 默认选中
getMenuOwnerPath() { getMenuOwnerPath() {
const pathname = window.location.pathname const pathname = window.location.pathname
// API配置表单不是独立菜单,直接刷新表单地址时仍归属API配置菜单。
if (pathname === "/platform/personGovernance/apiConfig/form") {
return "/platform/personGovernance/apiConfig"
}
// 会员匹配表单不是独立菜单,直接刷新表单地址时仍归属会员匹配菜单。
if (pathname === "/platform/personGovernance/memberMatch/form") {
return "/platform/personGovernance/memberMatch"
}
if (pathname === "/platform/qsv/survey/reportPage") { if (pathname === "/platform/qsv/survey/reportPage") {
return "/platform/qsv/survey" return "/platform/qsv/survey"
} }
@@ -409,6 +417,12 @@ layout("/layouts/v4/baseLayout.html"){
}, },
getMenuOwnerFullPath() { getMenuOwnerFullPath() {
if (window.location.pathname === "/platform/personGovernance/apiConfig/form") {
return "/platform/personGovernance/apiConfig"
}
if (window.location.pathname === "/platform/personGovernance/memberMatch/form") {
return "/platform/personGovernance/memberMatch"
}
if (window.location.pathname === "/platform/qsv/survey/reportPage") { if (window.location.pathname === "/platform/qsv/survey/reportPage") {
return "/platform/qsv/survey" return "/platform/qsv/survey"
} }
@@ -460,6 +474,25 @@ layout("/layouts/v4/baseLayout.html"){
tempLink.click() tempLink.click()
document.body.removeChild(tempLink)*/ document.body.removeChild(tempLink)*/
}) })
return
}
// 没有已授权的可用子菜单时,才使用当前应用自身配置的地址作为兜底入口。
try {
const rootApp = JSON.parse(window.sessionStorage.getItem("zhgh_sub_app"))
const currentAppId = GetQueryString("appId")
const hasRootHref = rootApp && typeof rootApp.href === "string" && rootApp.href.trim() !== ""
// 校验缓存应用与当前URL一致,避免复用旧标签页缓存时跳转到其他应用。
if (hasRootHref && String(rootApp.id) === currentAppId) {
this.activeMenuIndex = rootApp.id
this.openedMenus = []
this.$nextTick(() => {
this.menuSelect(this.activeMenuIndex, this.openedMenus)
commonUtil.pjaxPush(getBaseSubAppPath(rootApp.href))
})
}
} catch (e) {
// 根菜单缓存缺失或格式异常时保持当前页面,不执行无权限地址跳转。
} }
}, },
@@ -25,6 +25,10 @@
<link rel="stylesheet" href="${base!}/assets/platform/plugins/element-ui/lib/theme-chalk/index_custom.css" /> <link rel="stylesheet" href="${base!}/assets/platform/plugins/element-ui/lib/theme-chalk/index_custom.css" />
<link rel="stylesheet" href="${base!}/assets/platform/plugins/element-ui/lib/theme-chalk/override.css" /> <link rel="stylesheet" href="${base!}/assets/platform/plugins/element-ui/lib/theme-chalk/override.css" />
<!-- 人员数据治理的接口配置表单使用 Cron 可视化选择器。 -->
<link rel="stylesheet" href="${base!}/assets/platform/plugins/vue-cron/vcrontab.css" />
<script src="${base!}/assets/platform/plugins/vue-cron/vcrontab.umd.min.js"></script>
<link rel="stylesheet" href="${base!}/assets/platform/plugins/fullcalendar/fullcalendar.css"> <link rel="stylesheet" href="${base!}/assets/platform/plugins/fullcalendar/fullcalendar.css">
<!-- import common css --> <!-- import common css -->
@@ -68,9 +72,9 @@
<link rel="stylesheet" href="${base!}/assets/platform/plugins/vxe/vxe-pc-ui.css" /> <link rel="stylesheet" href="${base!}/assets/platform/plugins/vxe/vxe-pc-ui.css" />
<link rel="stylesheet" href="${base!}/assets/platform/plugins/vxe/vxe-table.css" /> <link rel="stylesheet" href="${base!}/assets/platform/plugins/vxe/vxe-table.css" />
<script src="https://vxeui.com/umd/xe-utils@3.5.30/dist/xe-utils.umd.min.js"></script> <script src="${base!}/assets/platform/plugins/vxe/xe-utils.js"></script>
<script src="https://vxeui.com/umd/vxe-pc-ui@3.1.25/lib/index.umd.min.js"></script> <script src="${base!}/assets/platform/plugins/vxe/vxe-pc-ui.js"></script>
<script src="https://vxeui.com/umd/vxe-table@3.9.0/lib/index.umd.min.js"></script> <script src="${base!}/assets/platform/plugins/vxe/vue-table.js"></script>
<!-- 引入 form-create 和 designer --> <!-- 引入 form-create 和 designer -->
<script src="${base!}/assets/platform/plugins/form-create/form-create.min.js"></script> <script src="${base!}/assets/platform/plugins/form-create/form-create.min.js"></script>
@@ -99,7 +103,7 @@
src="https://webapi.amap.com/maps?v=2.0&key=b4fa2e2bc10a725ab007b98a3aa447cc&plugin=AMap.PolyEditor,AMap.Geolocation" src="https://webapi.amap.com/maps?v=2.0&key=b4fa2e2bc10a725ab007b98a3aa447cc&plugin=AMap.PolyEditor,AMap.Geolocation"
></script>--> ></script>-->
<script src="https://map.qq.com/api/gljs?v=2.exp&key=NYNBZ-K62KI-DQJGU-U4NJ5-CSME7-ULFMU&libraries=tools"></script> <script src="https://map.qq.com/api/gljs?v=2.exp&key=MLLBZ-GQECI-ASXG7-5GNOZ-XW2OF-H5BVH"></script>
<script nonce="${cspNonce!}"> <script nonce="${cspNonce!}">
// 在加载 lodash 后、使用前插入 // 在加载 lodash 后、使用前插入
@@ -329,7 +333,8 @@
}, },
actions: {} actions: {}
}) })
$.get("/platform/sys/user/getLogonUser").then((res) => { // 暴露当前登录用户的加载状态,业务页面可在用户、角色和所属工会写入 store 后再执行初始化。
window.logonUserReady = $.get("/platform/sys/user/getLogonUser").then((res) => {
if (res.code === 0) { if (res.code === 0) {
window.sessionStorage.setItem("user", JSON.stringify(res.data)) window.sessionStorage.setItem("user", JSON.stringify(res.data))
store.commit("setUser", res.data) store.commit("setUser", res.data)
@@ -435,6 +440,8 @@
.v4-logo { .v4-logo {
height: 100%; height: 100%;
width: auto; width: auto;
/* 保留 Logo 透明轮廓,将所有可见颜色统一转换为白色。 */
filter: brightness(0) invert(1);
} }
.v4-nav { .v4-nav {
@@ -753,7 +760,8 @@
var currentPath = getBaseSubAppPath() var currentPath = getBaseSubAppPath()
var footer = $("#v4-footer") var footer = $("#v4-footer")
if (currentPath === "/platform/v4/home") { // 服务门户首页不显示版权栏,PJAX 切换时同步判断当前页面。
if (currentPath === "/platform/v4/home" && !$("#v4-home-app").hasClass("home-portal")) {
footer.show() footer.show()
} else { } else {
footer.hide() footer.hide()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,210 @@
<!--# layout("/layouts/platform.html"){ #-->
<link rel="stylesheet" href="${base!}/assets/platform/css/personGovernance.css?v=1.0.22" />
<div
id="app"
v-cloak
class="person-governance-list-page person-governance-page-transition"
:class="{'is-ready':pageReady,'is-leaving':pageLeaving}"
>
<el-card shadow="never" class="person-governance-search-card">
<el-form :inline="true" :model="pageForm">
<el-form-item label="配置名称"><el-input v-model="pageForm.configName" clearable></el-input></el-form-item>
<el-form-item label="同步方式">
<el-select v-model="pageForm.sourceType" clearable filterable>
<el-option label="请求第三方接口" value="HTTP_API"></el-option>
<el-option label="读取系统表数据" value="SYSTEM_TABLE"></el-option>
</el-select>
</el-form-item>
<el-form-item label="启用状态">
<el-select v-model="pageForm.enabled" clearable filterable>
<el-option label="启用" :value="true"></el-option>
<el-option label="停用" :value="false"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="doSearch">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
<el-button class="pull-right" type="primary" icon="el-icon-plus" @click="openType">新增</el-button>
</el-form>
</el-card>
<el-card shadow="never" class="person-governance-list-card">
<div class="person-governance-table-wrap">
<el-table ref="dataTable" :data="tableData" v-loading="tableLoading" height="100%">
<el-table-column
type="index"
:index="indexMethod"
label="序号"
width="70"
align="center"
></el-table-column>
<el-table-column prop="configName" label="配置名称" min-width="160"></el-table-column>
<el-table-column label="同步方式" width="150">
<template v-slot="{row}">
{{row.sourceType==='HTTP_API'?'请求第三方接口':'读取系统表数据'}}
</template>
</el-table-column>
<el-table-column prop="targetTable" label="目标系统表" min-width="150"></el-table-column>
<el-table-column prop="lastSyncTime" label="最近同步时间" width="180"></el-table-column>
<el-table-column label="是否启用" width="150" align="center">
<template v-slot="{row}">
{{row.enabled ? '是' : '否'}}
</template>
</el-table-column>
<el-table-column label="操作" width="130">
<template v-slot="{row}">
<el-button type="text" @click="edit(row)">编辑</el-button>
<el-button type="text" class="text-danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
<el-dialog
title="选择API配置类型"
:visible.sync="typeVisible"
:append-to-body="true"
:close-on-click-modal="false"
width="520px"
custom-class="person-governance-dialog"
@closed="handleTypeClosed"
>
<el-alert title="请选择配置的数据来源,选择后直接进入对应配置页面" type="info" :closable="false"></el-alert>
<el-row :gutter="16" class="mt20">
<el-col :span="12">
<el-card shadow="hover" @click.native="create('HTTP_API')">
<h4>请求第三方接口</h4>
<p>通过GET、POST等方式获取第三方人员数据</p>
<el-link type="primary">选择</el-link>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="hover" @click.native="create('SYSTEM_TABLE')">
<h4>读取系统表数据</h4>
<p>读取第三方已推送到系统表中的人员数据</p>
<el-link type="primary">选择</el-link>
</el-card>
</el-col>
</el-row>
<span slot="footer"><el-button @click="closeType">取消</el-button></span>
</el-dialog>
</div>
<script>
// 使用独立作用域承载页面实例,避免列表页与表单页通过 PJAX 反复切换时重复声明顶层变量。
;(() => {
new Vue({
el: '#app',
mixins: [initTableMixins],
data() {
return {
pageReady: false,
pageLeaving: false,
typeVisible: false,
pendingCreateType: '',
pageForm: { configName: '', sourceType: '', enabled: '', pageNumber: 1, pageSize: 10, totalCount: 0 },
}
},
methods: {
pageData() {
this.$set(this, 'tableLoading', true)
const params = {
pageNumber: this.pageForm.pageNumber,
pageSize: this.pageForm.pageSize,
}
// 未选择的查询条件不提交,避免空来源类型被后端拼成 sourceType=''。
if (this.pageForm.configName) params.configName = this.pageForm.configName
if (this.pageForm.sourceType) params.sourceType = this.pageForm.sourceType
if (this.pageForm.enabled !== '') params.enabled = this.pageForm.enabled
return $.post(loc() + '/pageData', params)
.then((res) => {
if (res.code === 0) {
this.$set(this, 'tableData', res.data.list)
this.$set(this.pageForm, 'totalCount', res.data.totalCount)
} else {
this.notifyWarning(res.msg)
}
})
.always(() => {
this.$set(this, 'tableLoading', false)
})
},
openType() {
if (this.pendingCreateType || this.pageLeaving) {
return
}
this.$set(this, 'typeVisible', true)
},
closeType() {
this.$set(this, 'pendingCreateType', '')
this.$set(this, 'typeVisible', false)
},
// 等待弹窗退出动画完成后再离开列表,避免body上的弹窗和遮罩在跳转期间残留。
handleTypeClosed() {
const type = this.pendingCreateType
this.$set(this, 'pendingCreateType', '')
if (!type || this.pageLeaving) {
return
}
this.leavePage('/platform/personGovernance/apiConfig/form?sourceType=' + type)
},
resetSearch() {
this.$set(this.pageForm, 'configName', '')
this.$set(this.pageForm, 'sourceType', '')
this.$set(this.pageForm, 'enabled', '')
this.doSearch()
},
showPage() {
this.$nextTick(() => {
if (this.$refs.dataTable && this.$refs.dataTable.doLayout) {
this.$refs.dataTable.doLayout()
}
window.requestAnimationFrame(() => {
this.$set(this, 'pageReady', true)
})
})
},
leavePage(url) {
this.$set(this, 'pageLeaving', true)
window.setTimeout(() => {
// 仅替换右侧内容区域,保留左侧导航实例及已经加载的菜单数据。
commonUtil.pjaxPush(url)
}, 180)
},
// 弹窗关闭后通过 PJAX 打开表单页,避免整个后台布局重新加载。
create(type) {
// 关闭期间忽略重复选择;仅记录目标类型,由closed事件统一触发跳转。
if (!this.typeVisible || this.pendingCreateType || this.pageLeaving) {
return
}
this.$set(this, 'pendingCreateType', type)
this.$set(this, 'typeVisible', false)
},
edit(row) {
this.leavePage('/platform/personGovernance/apiConfig/form?id=' + row.id)
},
remove(row) {
this.$confirm('删除后不可继续同步,确认删除吗?', '提示').then(() => {
$.post(loc() + '/delete', { id: row.id })
.then((res) => {
if (res.code === 0) {
this.notifySuccess('删除成功')
this.pageData()
} else {
this.notifyWarning(res.msg)
}
})
.always(() => {})
})
},
},
created() {
this.pageData().always(() => {
this.showPage()
})
},
})
})()
</script>
<!--# } #-->
@@ -0,0 +1,669 @@
<!--# layout("/layouts/platform.html"){ #-->
<link rel="stylesheet" href="${base!}/assets/platform/css/personGovernance.css?v=1.0.24" />
<div
id="app"
v-cloak
class="person-governance-form-page person-governance-page-transition"
:class="{'is-ready':pageReady,'is-leaving':pageLeaving}"
>
<div class="person-governance-page-header">
<el-page-header @back="back"></el-page-header>
</div>
<el-card shadow="never" class="person-governance-form-basic-card">
<el-alert
class="person-governance-member-binding-hint"
title="每个API配置仅可绑定一个会员匹配配置。"
type="info"
:closable="false"
></el-alert>
<el-form :model="form" label-width="110px">
<div class="person-governance-form-grid">
<el-form-item label="配置名称"><el-input v-model="form.configName"></el-input></el-form-item>
<el-form-item label="选择API配置">
<el-select
v-model="form.apiConfigId"
:disabled="!!form.id"
clearable
filterable
style="width: 100%"
@change="apiChanged"
>
<el-option
v-for="a in apiConfigs"
:key="a.id"
:disabled="!a.selectable"
:label="a.selectable?a.configName:a.configName+'(已绑定:'+a.matchConfigName+')'"
:value="a.id"
></el-option>
</el-select>
<small v-if="form.id">该API配置已与当前会员匹配配置绑定,编辑时不可修改</small>
</el-form-item>
<el-form-item v-if="!form.id" label="沿用历史匹配">
<el-select v-model="copyId" clearable filterable @change="copyHistory">
<el-option v-for="h in histories" :key="h.id" :label="h.configName" :value="h.id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="启用"><el-switch v-model="form.enabled"></el-switch></el-form-item>
</div>
</el-form>
</el-card>
<el-tabs
v-model="activeRuleType"
class="person-governance-member-type-tabs"
@tab-click="ruleTabChanged"
>
<el-tab-pane label="工会会员匹配规则配置" name="UNION_MEMBER"></el-tab-pane>
<el-tab-pane label="福利会员匹配规则配置" name="WELFARE_MEMBER"></el-tab-pane>
</el-tabs>
<el-row :gutter="20" class="person-governance-form-content-row">
<el-col :span="16">
<el-card shadow="never" class="person-governance-member-rules">
<div class="person-governance-member-rules-header">
<h3>{{activeRuleTitle}}</h3>
<el-alert
title="条件及子条件组之间可切换且/或;每个条件组(含子条件组)都作为独立括号。"
type="info"
:closable="false"
></el-alert>
</div>
<div class="person-governance-member-rules-content">
<div v-for="(g,gi) in groups" :key="gi">
<el-divider v-if="gi>0" class="person-governance-member-group-relation">
<el-radio-group
:value="g.joinLogic"
size="mini"
aria-label="与上一条件组的关系"
@input="setGroupLogic(g, $event)"
>
<el-radio-button label="AND">且</el-radio-button>
<el-radio-button label="OR">或</el-radio-button>
</el-radio-group>
</el-divider>
<member-condition-group
:group="g"
:group-path="String(gi+1)"
:depth="0"
:fields="fields"
:create-condition="emptyCondition"
@load-dict="loadDict"
@dict-changed="dictChanged"
@copy-self="copyGroup(gi)"
@delete-self="removeGroup(gi)"
></member-condition-group>
</div>
<el-button class="mt20" type="primary" plain style="width: 100%" @click="addGroup">
新增条件组
</el-button>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never" class="person-governance-member-preview">
<h3>{{activePreviewTitle}}</h3>
<p>
预计匹配
<strong>{{previewTotal}}</strong>
人
</p>
<el-button
type="primary"
:disabled="!form.id || !groups.length"
:loading="previewLoading"
@click="calculate"
>
试算匹配
</el-button>
<div class="person-governance-member-preview-table">
<el-table ref="previewTable" :data="previewRows" height="100%" v-loading="previewLoading">
<el-table-column prop="loginname" label="工号" show-overflow-tooltip></el-table-column>
<el-table-column prop="username" label="姓名" show-overflow-tooltip></el-table-column>
<el-table-column prop="unitName" label="单位" show-overflow-tooltip></el-table-column>
<el-table-column prop="unionName" label="工会" show-overflow-tooltip></el-table-column>
</el-table>
</div>
<div class="person-governance-member-preview-pagination">
<el-pagination
@current-change="previewPageNumberChange"
:current-page="previewPageForm.pageNumber"
:page-size="previewPageForm.pageSize"
layout="total, prev, pager, next"
:total="previewPageForm.totalCount"
></el-pagination>
</div>
</el-card>
</el-col>
</el-row>
<div class="text-right person-governance-form-footer">
<el-button @click="back">取消</el-button>
<el-button type="primary" :loading="formLoading" @click="save">保存</el-button>
</div>
</div>
<script type="text/x-template" id="member-condition-group-template">
<div class="person-governance-member-group" :class="{'is-nested':depth>0}">
<div class="person-governance-member-group-header">
<strong>条件组 {{groupPath}}</strong>
<span class="pull-right">
<el-button type="text" @click="$emit('copy-self')">复制条件组</el-button>
<el-button type="text" class="text-danger" @click="$emit('delete-self')">删除条件组</el-button>
</span>
</div>
<el-table
v-if="group.conditions.length"
:data="conditionRows"
:span-method="conditionSpan"
:row-class-name="conditionRowClass"
border
>
<el-table-column label="字典编码">
<template v-slot="{row}">
<el-radio-group
v-if="row.isRelation"
:value="row.condition.joinLogic"
size="mini"
aria-label="与上一条件的关系"
@input="setConditionLogic(row.condition, $event)"
>
<el-radio-button label="AND">且</el-radio-button>
<el-radio-button label="OR">或</el-radio-button>
</el-radio-group>
<el-input v-else v-model="row.dictCode" @change="$emit('load-dict', row)">
<el-button slot="append" @click="$emit('load-dict', row)">加载</el-button>
</el-input>
</template>
</el-table-column>
<el-table-column label="字典数据">
<template v-slot="{row}">
<el-select
v-model="row.dictItemId"
clearable
filterable
@change="$emit('dict-changed', row)"
>
<el-option
v-for="d in row.dictItems||[]"
:key="d.id"
:label="d.name+'('+d.code+')'"
:value="d.id"
></el-option>
</el-select>
</template>
</el-table-column>
<el-table-column label="映射字段">
<template v-slot="{row}">
<el-select v-model="row.mappedField" clearable filterable>
<el-option
v-for="f in fields"
:key="f.targetField"
:label="f.targetComment+'('+f.targetField+')'"
:value="f.targetField"
></el-option>
</el-select>
</template>
</el-table-column>
<el-table-column label="匹配方式">
<template v-slot="{row}">
<el-select v-model="row.operator" clearable filterable>
<el-option label="等于" value="EQ"></el-option>
<el-option label="不等于" value="NE"></el-option>
<el-option label="包含" value="CONTAINS"></el-option>
<el-option label="在集合中" value="IN"></el-option>
</el-select>
</template>
</el-table-column>
<el-table-column label="匹配值">
<template v-slot="{row}"><el-input v-model="row.matchValue"></el-input></template>
</el-table-column>
<el-table-column label="操作" width="70">
<template v-slot="{$index}">
<el-button type="text" class="text-danger" @click="removeCondition($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="person-governance-member-group-actions">
<el-button type="primary" plain @click="addCondition">新增条件</el-button>
<el-button type="primary" plain @click="addChildGroup">新增子条件组</el-button>
</div>
<div v-for="(child,childIndex) in group.groups" :key="childIndex">
<el-divider
v-if="group.conditions.length||childIndex>0"
class="person-governance-member-group-relation person-governance-member-child-relation"
>
<el-radio-group
:value="child.joinLogic"
size="mini"
aria-label="与上一条件或条件组的关系"
@input="setGroupLogic(child, $event)"
>
<el-radio-button label="AND">且</el-radio-button>
<el-radio-button label="OR">或</el-radio-button>
</el-radio-group>
</el-divider>
<member-condition-group
:group="child"
:group-path="groupPath+'.'+(childIndex+1)"
:depth="depth+1"
:fields="fields"
:create-condition="createCondition"
@load-dict="$emit('load-dict', $event)"
@dict-changed="$emit('dict-changed', $event)"
@copy-self="copyChildGroup(childIndex)"
@delete-self="deleteChildGroup(childIndex)"
></member-condition-group>
</div>
</div>
</script>
<script>
// 使用独立作用域承载页面常量、组件和实例,避免 PJAX 多次进入时重复声明。
;(() => {
const apiBase = '/platform/personGovernance/apiConfig'
const memberBase = '/platform/personGovernance/memberMatch'
Vue.component('member-condition-group', {
template: '#member-condition-group-template',
props: {
group: { type: Object, required: true },
groupPath: { type: String, required: true },
depth: { type: Number, required: true },
fields: { type: Array, required: true },
createCondition: { type: Function, required: true },
},
computed: {
conditionRows() {
const rows = []
this.group.conditions.forEach((condition, index) => {
if (index > 0) rows.push({ isRelation: true, condition: condition })
rows.push(condition)
})
return rows
},
},
methods: {
conditionSpan({ row, columnIndex }) {
if (row.isRelation) return columnIndex === 0 ? [1, 6] : [0, 0]
return [1, 1]
},
conditionRowClass({ row }) {
return row.isRelation ? 'person-governance-member-condition-relation' : ''
},
setConditionLogic(condition, value) {
this.$set(condition, 'joinLogic', value)
},
setGroupLogic(group, value) {
this.$set(group, 'joinLogic', value)
},
addCondition() {
this.group.conditions.push(this.createCondition())
},
// 表格包含连接行,实际条件索引为展示行索引的一半。
removeCondition(rowIndex) {
this.group.conditions.splice(rowIndex / 2, 1)
},
addChildGroup() {
this.group.groups.push({
joinLogic: 'OR',
conditions: [this.createCondition()],
groups: [],
})
},
copyChildGroup(index) {
this.group.groups.splice(index + 1, 0, JSON.parse(JSON.stringify(this.group.groups[index])))
},
deleteChildGroup(index) {
this.group.groups.splice(index, 1)
},
},
})
new Vue({
el: '#app',
mixins: [initTableMixins],
data() {
return {
pageReady: false,
pageLeaving: false,
form: { id: '', configName: '', apiConfigId: '', enabled: true },
copyId: '',
apiConfigs: [],
histories: [],
fields: [],
activeRuleType: 'UNION_MEMBER',
ruleGroups: {
UNION_MEMBER: [],
WELFARE_MEMBER: [],
},
previewByType: {
UNION_MEMBER: {
rows: [],
total: 0,
loading: false,
pageForm: { pageNumber: 1, pageSize: 10, totalCount: 0 },
},
WELFARE_MEMBER: {
rows: [],
total: 0,
loading: false,
pageForm: { pageNumber: 1, pageSize: 10, totalCount: 0 },
},
},
}
},
computed: {
groups() {
return this.ruleGroups[this.activeRuleType]
},
currentPreview() {
return this.previewByType[this.activeRuleType]
},
previewRows() {
return this.currentPreview.rows
},
previewTotal() {
return this.currentPreview.total
},
previewLoading() {
return this.currentPreview.loading
},
previewPageForm() {
return this.currentPreview.pageForm
},
activeRuleTitle() {
return this.activeRuleType === 'WELFARE_MEMBER'
? '福利会员匹配规则配置'
: '工会会员匹配规则配置'
},
activePreviewTitle() {
return this.activeRuleType === 'WELFARE_MEMBER'
? '福利会员匹配结果预览'
: '工会会员匹配结果预览'
},
},
methods: {
emptyCondition() {
return {
dictCode: '',
dictItemId: '',
dictItemCode: '',
dictItemName: '',
dictItems: [],
mappedField: '',
operator: 'EQ',
matchValue: '',
joinLogic: 'AND',
}
},
addGroup() {
this.groups.push({ joinLogic: 'OR', conditions: [this.emptyCondition()], groups: [] })
},
copyGroup(i) {
this.groups.splice(i + 1, 0, JSON.parse(JSON.stringify(this.groups[i])))
},
removeGroup(i) {
this.groups.splice(i, 1)
},
setGroupLogic(group, value) {
this.$set(group, 'joinLogic', value)
},
// 历史记录没有父子关系时保持顶层结构;新数据递归恢复所有子条件组。
restoreGroups(rows) {
return (rows || []).map((row) => {
const sourceGroup = row.group || row
return {
joinLogic: sourceGroup.joinLogic || 'OR',
conditions: (row.conditions || []).map((condition) => ({
...condition,
joinLogic: condition.joinLogic || 'AND',
})),
groups: this.restoreGroups(row.groups),
}
})
},
// 对工会、福利两棵规则树执行相同处理,避免只遍历顶层条件组。
walkGroups(groups, callback) {
(groups || []).forEach((group) => {
callback(group)
this.walkGroups(group.groups, callback)
})
},
loadApis(current) {
return $.post(apiBase + '/selectableConfigs', { currentMatchConfigId: current || '' })
.then((res) => {
if (res.code === 0) {
this.$set(this, 'apiConfigs', res.data)
}
})
.always(() => {})
},
loadHistories() {
return $.post(memberBase + '/historyConfigs')
.then((res) => {
if (res.code === 0) {
this.$set(this, 'histories', res.data)
}
})
.always(() => {})
},
apiChanged(id) {
if (!id) {
this.$set(this, 'fields', [])
Object.keys(this.ruleGroups).forEach((ruleType) => {
this.walkGroups(this.ruleGroups[ruleType], (group) => {
group.conditions.forEach((condition) => {
this.$set(condition, 'mappedField', '')
})
})
})
return $.when()
}
return $.post(apiBase + '/detail', { id: id })
.then((res) => {
if (res.code === 0) {
this.$set(this, 'fields', res.data.mappings || [])
}
})
.always(() => {})
},
copyHistory(id) {
if (!id) {
return
}
$.post(memberBase + '/detail', { id: id })
.then((res) => {
if (res.code === 0) {
this.$set(
this.ruleGroups,
'UNION_MEMBER',
this.restoreGroups(res.data.unionGroups || res.data.groups),
)
this.$set(
this.ruleGroups,
'WELFARE_MEMBER',
this.restoreGroups(res.data.welfareGroups),
)
this.loadGroupDicts()
}
})
.always(() => {})
},
loadDict(row) {
if (!row.dictCode) {
return
}
$.post(memberBase + '/dictItems', { dictCode: row.dictCode })
.then((res) => {
if (res.code === 0) {
this.$set(row, 'dictItems', res.data)
} else this.notifyWarning(res.msg)
})
.always(() => {})
},
dictChanged(row) {
const d = (row.dictItems || []).find((v) => v.id === row.dictItemId)
if (d) {
this.$set(row, 'dictItemCode', d.code)
this.$set(row, 'dictItemName', d.name)
if (!row.matchValue) {
this.$set(row, 'matchValue', d.code)
}
return
}
const previousCode = row.dictItemCode
this.$set(row, 'dictItemCode', '')
this.$set(row, 'dictItemName', '')
if (row.matchValue === previousCode) {
this.$set(row, 'matchValue', '')
}
},
// 保存前递归检查两类规则树,避免无效规则进入后端后才返回校验异常。
validateRuleGroups(groups, ruleName) {
let message = ''
this.walkGroups(groups, (group) => {
if (message) return
;(group.conditions || []).some((condition) => {
if (!condition.mappedField) {
message = ruleName + '规则请选择映射字段'
return true
}
if (!condition.operator) {
message = ruleName + '规则请选择匹配方式'
return true
}
return false
})
})
return message
},
save() {
if (!this.form.configName || !this.form.apiConfigId) {
this.notifyWarning('请填写配置名称并选择API配置')
return
}
const ruleMessage =
this.validateRuleGroups(this.ruleGroups.UNION_MEMBER, '工会会员') ||
this.validateRuleGroups(this.ruleGroups.WELFARE_MEMBER, '福利会员')
if (ruleMessage) {
this.notifyWarning(ruleMessage)
return
}
this.$set(this, 'formLoading', true)
$.post(memberBase + '/save', {
data: JSON.stringify({
config: this.form,
unionGroups: this.ruleGroups.UNION_MEMBER,
welfareGroups: this.ruleGroups.WELFARE_MEMBER,
}),
})
.then((res) => {
if (res.code === 0) {
this.notifySuccess('保存成功')
this.$set(this.form, 'id', res.data.id)
} else this.notifyWarning(res.msg)
})
.always(() => {
this.$set(this, 'formLoading', false)
})
},
calculate(resetPage) {
if (!this.form.id) {
return
}
if (resetPage) {
this.$set(this.currentPreview.pageForm, 'pageNumber', 1)
}
const ruleType = this.activeRuleType
const preview = this.previewByType[ruleType]
this.$set(preview, 'loading', true)
$.post(memberBase + '/calculate', {
id: this.form.id,
ruleType: ruleType,
pageNumber: preview.pageForm.pageNumber,
pageSize: preview.pageForm.pageSize,
})
.then((res) => {
if (res.code === 0) {
this.$set(preview, 'rows', res.data.list)
this.$set(preview, 'total', res.data.totalCount)
this.$set(preview.pageForm, 'totalCount', res.data.totalCount)
} else this.notifyWarning(res.msg)
})
.always(() => {
this.$set(preview, 'loading', false)
this.$nextTick(() => {
if (this.$refs.previewTable) this.$refs.previewTable.doLayout()
})
})
},
// 匹配预览分页仅切换当前页,查询条件和每页条数保持不变。
previewPageNumberChange(number) {
this.$set(this.currentPreview.pageForm, 'pageNumber', number)
this.calculate()
},
load(id) {
return $.post(memberBase + '/detail', { id: id })
.then((res) => {
if (res.code === 0) {
this.$set(this, 'form', res.data.config)
this.$set(
this.ruleGroups,
'UNION_MEMBER',
this.restoreGroups(res.data.unionGroups || res.data.groups),
)
this.$set(
this.ruleGroups,
'WELFARE_MEMBER',
this.restoreGroups(res.data.welfareGroups),
)
this.loadGroupDicts()
return this.apiChanged(this.form.apiConfigId)
}
return $.when()
})
.always(() => {})
},
showPage() {
this.$nextTick(() => {
if (this.$refs.previewTable) this.$refs.previewTable.doLayout()
window.requestAnimationFrame(() => {
this.$set(this, 'pageReady', true)
})
})
},
// 两类规则分别恢复字典候选项,切换页签时不重新覆盖另一类规则的数据。
loadGroupDicts() {
Object.keys(this.ruleGroups).forEach((ruleType) => {
this.walkGroups(this.ruleGroups[ruleType], (group) => {
group.conditions.forEach((condition) => this.loadDict(condition))
})
})
},
ruleTabChanged() {
this.$nextTick(() => {
if (this.$refs.previewTable) this.$refs.previewTable.doLayout()
})
},
leavePage(url) {
this.$set(this, 'pageLeaving', true)
window.setTimeout(() => {
// 仅替换右侧内容区域,返回列表时不销毁或重新请求左侧菜单。
commonUtil.pjaxPush(url)
}, 180)
},
// 使用 PJAX 返回列表,并保留跳转前的页面淡出效果。
back() {
this.leavePage(memberBase)
},
},
created() {
const id = new URLSearchParams(window.location.search).get('id')
const requests = [this.loadApis(id), this.loadHistories()]
if (id) {
requests.push(this.load(id))
} else {
this.addGroup()
}
$.when.apply($, requests).always(() => {
this.showPage()
})
},
})
})()
</script>
<!--# } #-->
@@ -0,0 +1,197 @@
<!--# layout("/layouts/platform.html"){ #-->
<link rel="stylesheet" href="${base!}/assets/platform/css/personGovernance.css?v=1.0.23" />
<div
id="app"
v-cloak
class="person-governance-list-page person-governance-compact-gap person-governance-page-transition"
:class="{'is-ready':pageReady,'is-leaving':pageLeaving}"
>
<el-card shadow="never" class="person-governance-search-card">
<el-form :inline="true" :model="pageForm">
<el-form-item label="配置名称"><el-input v-model="pageForm.configName" clearable></el-input></el-form-item>
<el-form-item label="API配置">
<el-select v-model="pageForm.apiConfigId" clearable filterable>
<el-option v-for="a in apiConfigs" :key="a.id" :label="a.configName" :value="a.id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="启用状态">
<el-select v-model="pageForm.enabled" clearable filterable>
<el-option label="启用" :value="true"></el-option>
<el-option label="停用" :value="false"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="doSearch">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
<el-button class="pull-right" type="primary" icon="el-icon-plus" @click="create">新增</el-button>
</el-form>
</el-card>
<el-card shadow="never" class="person-governance-list-card">
<div class="person-governance-table-wrap">
<el-table ref="dataTable" :data="tableData" v-loading="tableLoading" height="100%">
<el-table-column
type="index"
:index="indexMethod"
label="序号"
width="70"
align="center"
></el-table-column>
<el-table-column prop="configName" label="配置名称"></el-table-column>
<el-table-column prop="apiConfigName" label="API配置"></el-table-column>
<el-table-column prop="lastExecuteTime" label="最近执行时间" width="180"></el-table-column>
<el-table-column label="是否启用" width="100">
<template v-slot="{row}">
<el-switch v-model="row.enabled" @change="toggle(row)"></el-switch>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template v-slot="{row}">
<el-button type="text" @click="edit(row)">编辑</el-button>
<!-- <el-button type="text" @click="preview(row)">试算匹配</el-button>-->
<el-button type="text" class="text-danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
<el-drawer title="匹配结果预览" :visible.sync="previewVisible" size="55%" custom-class="person-governance-drawer">
<div style="padding: 0 20px">
<p>
预计匹配
<strong>{{previewTotal}}</strong>
人
</p>
<el-table :data="previewRows">
<el-table-column
v-for="c in previewColumns"
:key="c"
:prop="c"
:label="c"
show-overflow-tooltip
></el-table-column>
</el-table>
</div>
</el-drawer>
</div>
<script>
// 使用独立作用域承载页面实例,支持列表与表单通过 PJAX 反复切换。
;(() => {
new Vue({
el: '#app',
mixins: [initTableMixins],
data() {
return {
pageReady: false,
pageLeaving: false,
apiConfigs: [],
previewVisible: false,
previewRows: [],
previewColumns: [],
previewTotal: 0,
pageForm: { configName: '', apiConfigId: '', enabled: '', pageNumber: 1, pageSize: 10, totalCount: 0 },
}
},
methods: {
pageData() {
this.$set(this, 'tableLoading', true)
return $.post(loc() + '/pageData', this.pageForm)
.then((res) => {
if (res.code === 0) {
this.$set(this, 'tableData', res.data.list)
this.$set(this.pageForm, 'totalCount', res.data.totalCount)
} else this.notifyWarning(res.msg)
})
.always(() => {
this.$set(this, 'tableLoading', false)
})
},
loadApis() {
return $.post('/platform/personGovernance/apiConfig/selectableConfigs')
.then((res) => {
if (res.code === 0) {
this.$set(this, 'apiConfigs', res.data)
}
})
.always(() => {})
},
resetSearch() {
this.$set(this.pageForm, 'configName', '')
this.$set(this.pageForm, 'apiConfigId', '')
this.$set(this.pageForm, 'enabled', '')
this.doSearch()
},
showPage() {
this.$nextTick(() => {
if (this.$refs.dataTable && this.$refs.dataTable.doLayout) {
this.$refs.dataTable.doLayout()
}
window.requestAnimationFrame(() => {
this.$set(this, 'pageReady', true)
})
})
},
leavePage(url) {
this.$set(this, 'pageLeaving', true)
window.setTimeout(() => {
// 仅替换右侧内容区域,避免重新初始化平台布局和左侧菜单。
commonUtil.pjaxPush(url)
}, 180)
},
// 进入新增或编辑页时保留人员数据治理模块的左侧菜单。
create() {
this.leavePage('/platform/personGovernance/memberMatch/form')
},
edit(row) {
this.leavePage('/platform/personGovernance/memberMatch/form?id=' + row.id)
},
toggle(row) {
$.post(loc() + '/updateEnabled', { id: row.id, enabled: row.enabled })
.then((res) => {
if (res.code !== 0) {
this.$set(row, 'enabled', !row.enabled)
this.notifyWarning(res.msg)
}
})
.always(() => {})
},
preview(row) {
$.post(loc() + '/calculate', { id: row.id, pageNumber: 1, pageSize: 10 })
.then((res) => {
if (res.code === 0) {
this.$set(this, 'previewRows', res.data.list)
this.$set(this, 'previewTotal', res.data.totalCount)
this.$set(
this,
'previewColumns',
res.data.list.length ? Object.keys(res.data.list[0]).slice(0, 6) : [],
)
this.$set(this, 'previewVisible', true)
} else this.notifyWarning(res.msg)
})
.always(() => {})
},
remove(row) {
this.$confirm('确认删除该会员匹配配置吗?', '提示').then(() => {
$.post(loc() + '/delete', { id: row.id })
.then((res) => {
if (res.code === 0) {
this.notifySuccess('删除成功')
this.pageData()
} else this.notifyWarning(res.msg)
})
.always(() => {})
})
},
},
created() {
$.when(this.loadApis(), this.pageData()).always(() => {
this.showPage()
})
},
})
})()
</script>
<!--# } #-->
@@ -0,0 +1,114 @@
const PERSON_GOVERNANCE_MEMBER_ALL_CHANGE_INFO = {
template: /*language=HTML*/ `
<div>
<el-tabs v-model="activeName">
<el-tab-pane label="基础信息" name="basicInfo">
<el-descriptions :column="3" border class="descriptions-form">
<el-descriptions-item label="工号">{{ viewData.loginname }}</el-descriptions-item>
<el-descriptions-item label="姓名">{{ viewData.username }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ viewData.sex }}</el-descriptions-item>
<el-descriptions-item label="民族">{{ viewData.nation }}</el-descriptions-item>
<el-descriptions-item label="出生日期">{{ viewData.birthday ? $moment(viewData.birthday).format("YYYY年MM月DD日") : "" }}</el-descriptions-item>
<el-descriptions-item label="政治面貌">{{ viewData.political }}</el-descriptions-item>
<el-descriptions-item label="学历">{{ viewData.education }}</el-descriptions-item>
<el-descriptions-item label="学位">{{ viewData.academicDegree }}</el-descriptions-item>
<el-descriptions-item label="党政职务">{{ viewData.position }}</el-descriptions-item>
<el-descriptions-item label="工作单位">{{ viewData.unitName }}</el-descriptions-item>
<el-descriptions-item label="所属工会">{{ viewData.unionName }}</el-descriptions-item>
<el-descriptions-item label="所属校区">{{ viewData.campusName }}</el-descriptions-item>
<el-descriptions-item label="身份证号码">{{ viewData.idCard }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ viewData.mobile }}</el-descriptions-item>
<el-descriptions-item label="电子邮箱">{{ viewData.email }}</el-descriptions-item>
<el-descriptions-item label="在职状态">{{ viewData.userState }}</el-descriptions-item>
<el-descriptions-item label="人员类型">{{ viewData.personType }}</el-descriptions-item>
<template v-if="viewData.userState == '退休'">
<el-descriptions-item label="退休日期">{{ viewData.retireDate ? $moment(viewData.retireDate).format("YYYY-MM-DD") : '暂无' }}</el-descriptions-item>
<el-descriptions-item label="福利享受截止日期">{{ $moment(viewData.welfareStopDate).format("YYYY-MM-DD") }}</el-descriptions-item>
<el-descriptions-item></el-descriptions-item>
</template>
<el-descriptions-item label="会员状态">{{ viewData.member ? '会员' : '非会员' }}</el-descriptions-item>
<el-descriptions-item label="福利会员状态">{{ viewData.welfareMember ? '福利会员' : '非福利会员' }}</el-descriptions-item>
<el-descriptions-item></el-descriptions-item>
<template v-if="viewData.loginname == $store.state.user.loginname">
<el-descriptions-item label="家庭主要成员" :span="3">
<el-table v-if="viewData.families&&viewData.families.length"
:data="viewData.families" size="mini" border
:header-cell-style="{background:'#eff3f6'}"
style="width: 100%">
<el-table-column prop="relation" label="与本人关系" align="center" header-align="center">
</el-table-column>
<el-table-column prop="name" label="姓名" align="center" header-align="center">
</el-table-column>
<el-table-column prop="unit" label="单位" align="center" header-align="center">
</el-table-column>
<el-table-column prop="remark" label="备注" align="center" header-align="center">
</el-table-column>
</el-table>
<el-empty v-else :image-size="50" description="暂无家庭成员信息"></el-empty>
</el-descriptions-item>
<el-descriptions-item label="个人简况" :span="3">
<template slot="label">个人简况</template>
<div v-if="viewData.vita" class="text-left" v-html="viewData.vita"></div>
<span v-else>无数据</span>
</el-descriptions-item>
</template>
</el-descriptions>
</el-tab-pane>
<el-tab-pane label="历史变更记录" name="changeInfo" v-if="viewData.allChangeInfo">
<el-timeline>
<el-timeline-item placement="top" v-for="(changeData, index) in viewData.allChangeInfo"
:key="index" :timestamp="changeData.timestamp">
<el-card>
<h4>{{ changeData.changeOriginName }}</h4>
<el-table :data="changeData.changeInfos" size="mini" ref="table" row-key="id" style="width: 100%">
<el-table-column align="center" header-align="center"
label="序号" type="index" width="80px"></el-table-column>
<el-table-column prop="fieldName" label="变更字段" header-align="center"></el-table-column>
<el-table-column prop="sourceValue" label="原数据" header-align="center">
<template slot-scope="{row}">
<div v-html="row.sourceValue"></div>
</template>
</el-table-column>
<el-table-column prop="newValue" label="新数据" header-align="center">
<template slot-scope="{row}">
<div v-html="row.newValue"></div>
</template>
</el-table-column>
</el-table>
</el-card>
</el-timeline-item>
</el-timeline>
</el-tab-pane>
</el-tabs>
<slot></slot>
</div>
`,
store,
data(){
return{
viewData: {},
activeName: "basicInfo"
}
},
methods:{
onOpen(userId){
this.$axios.post('/platform/personGovernance/personChange/getAllChangeMemberInfo', {userId})
.then((resp) => {
if (resp.code === 0) {
this.$set(this, "viewData", resp.data)
} else {
this.$message.warning(resp.msg)
}
})
.finally(() => {})
}
}
}
@@ -0,0 +1,599 @@
const PERSON_GOVERNANCE_MEMBER_CHANGE = {
template: /*language=HTML*/ `
<el-card shadow="never">
<snaker-start slot="header" label="会员变更" define_key="HYBG"></snaker-start>
<el-form :model="formData" ref="formRef" :rules="formRules" label-width="0" label-suffix=":"
class="flow-task-form">
<el-descriptions :column="3" border>
<el-descriptions-item label="工号">
<el-form-item prop="loginname">
<el-input v-model="formData.loginname" readonly size="small"></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="姓名">
<el-form-item prop="username">
<el-input v-model="formData.username" readonly size="small"></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="性别">
<el-form-item prop="sex">
<el-radio-group :disabled="allowFields('sex')" v-model="formData.sex" size="small">
<el-radio border label="男">男</el-radio>
<el-radio border label="女">女</el-radio>
</el-radio-group>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="民族">
<el-form-item prop="nation">
<dict-select style="width: 100%" placeholder="请选择民族" v-model="formData.nation"
:disabled="allowFields('nation')" code="USER_NATION"></dict-select>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="出生日期">
<el-form-item prop="birthday">
<el-date-picker v-model="formData.birthday"
type="date"
:disabled="allowFields('birthday')"
placeholder="请选择出生日期"
format="yyyy-MM-dd"
value-format="yyyy-MM-dd"
style="width: 100%"></el-date-picker>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="政治面貌">
<el-form-item prop="political">
<dict-select style="width: 100%" placeholder="请选择政治面貌" v-model="formData.political"
:disabled="allowFields('political')" code="USER_POLITICAL"></dict-select>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="学历">
<el-form-item prop="education">
<dict-select style="width: 100%" placeholder="请选择学历" v-model="formData.education"
:disabled="allowFields('education')" code="USER_EDUCATION"></dict-select>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="学位">
<el-form-item prop="academicDegree">
<dict-select style="width: 100%" placeholder="请选择学位" v-model="formData.academicDegree"
:disabled="allowFields('academicDegree')"
code="USER_ACADEMIC_DEGREE"></dict-select>
</el-form-item>
</el-descriptions-item>
<!-- <el-descriptions-item label="党政职务">-->
<!-- <el-form-item prop="position">-->
<!-- <el-input v-model="formData.position" placeholder="请输入党政职务"-->
<!-- :disabled="allowFields('position')" maxlength="50"></el-input>-->
<!-- </el-form-item>-->
<!-- </el-descriptions-item>-->
<!--<template v-if="$auth.hasRoleOr('SYSADMIN, SCHOOL_UNION_ADMIN, SCHOOL_UNION_MEMBER_ADMIN')">
<el-descriptions-item label="工作单位">
<el-form-item prop="unitId">
<el-select clearable filterable placeholder="请选择工作单位" style="width: 100%"
v-model="formData.unitId">
<el-option :key="item.id" :label="item.name" :value="item.id"
v-for="item in units"></el-option>
</el-select>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="所属工会">
<el-form-item prop="unionId">
<el-select clearable filterable placeholder="请选择所属工会" style="width: 100%"
v-model="formData.unionId">
<el-option :key="item.id" :label="item.name" :value="item.id"
v-for="item in unions"></el-option>
</el-select>
</el-form-item>
</el-descriptions-item>
</template>-->
<template>
<el-descriptions-item label="工作单位">
<el-form-item prop="unitId">
<el-input v-model="formData.unitName" disabled placeholder="暂无工作单位"></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="所属工会">
<el-form-item prop="unionName">
<el-input v-model="formData.unionName" disabled placeholder="请输入所属工会"></el-input>
</el-form-item>
</el-descriptions-item>
</template>
<el-descriptions-item v-if="showThreeUnit" label="三级单位">
<el-form-item prop="threeUnitId">
<el-select v-model="formData.threeUnitId" clearable filterable
placeholder="请选择三级单位" style="width: 100%">
<el-option v-for="item in threeUnits" :key="item.id" :label="item.name"
:value="item.id"></el-option>
</el-select>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item v-else label="所属校区">
<el-form-item prop="campus">
<dict-select style="width: 100%" placeholder="请选择所属校区" v-model="formData.campus"
:disabled="allowFields('campus')" code="USER_CAMPUS"></dict-select>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="身份证号码">
<el-form-item prop="idCard">
<el-input v-model="formData.idCard" :disabled="allowFields('idCard')"
placeholder="请输入身份证号码" maxlength="18"></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="联系电话">
<el-form-item prop="mobile">
<el-input v-model="formData.mobile" :disabled="allowFields('mobile')"
placeholder="请输入联系电话" maxlength="15"></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="电子邮箱">
<el-form-item prop="email">
<el-input v-model="formData.email" :disabled="allowFields('email')"
placeholder="请输入电子邮箱" maxlength="50"></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="在职状态">
<el-form-item prop="userState">
<dict-select v-model="formData.userState" code="USER_STATE"
@change="handleRetiredStateChange"
:disabled="allowFields('userState')" style="width: 100%"></dict-select>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="人员类型">
<el-form-item prop="personType">
<dict-select v-model="formData.personType" code="USER_PERSON_TYPE"
:disabled="allowFields('personType')" style="width: 100%"></dict-select>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="人员属性">
<el-form-item prop="userAttribute">
<dict-select v-model="formData.userAttribute" code="USER_ATTRIBUTE"
style="width: 100%"></dict-select>
</el-form-item>
</el-descriptions-item>
<!-- <el-descriptions-item label="人员分类">-->
<!-- <el-form-item prop="aidFundMemberUserType">-->
<!-- <dict-select v-model="formData.aidFundMemberUserType" code="AIDFUND_MEMBER_USER_TYPE"-->
<!-- style="width: 100%"></dict-select>-->
<!-- </el-form-item>-->
<!-- </el-descriptions-item>-->
<template v-if="formData.userState == '退休'">
<el-descriptions-item label="退休日期">
<el-form-item prop="retireDate">
<el-date-picker v-model="formData.retireDate"
type="date"
placeholder="请选择退休日期"
format="yyyy-MM-dd"
value-format="yyyy-MM-dd"
style="width: 100%"></el-date-picker>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="福利享受截止日期">
<el-form-item prop="welfareStopDate">
<el-date-picker v-model="formData.welfareStopDate"
type="date"
placeholder="请选择福利享受截止日期"
format="yyyy-MM-dd"
value-format="yyyy-MM-dd"
style="width: 100%"></el-date-picker>
</el-form-item>
</el-descriptions-item>
<!-- <el-descriptions-item></el-descriptions-item>-->
</template>
<el-descriptions-item label="会员状态" >
<el-form-item prop="member">
<el-radio-group v-model="formData.member" size="small">
<el-radio border :label="true">会员</el-radio>
<el-radio border :label="false">非会员</el-radio>
</el-radio-group>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="福利会员状态">
<el-form-item prop="welfareMember">
<el-radio-group v-model="formData.welfareMember" size="small" class="welfare-member-radio-group"
style="display: flex; align-items: center; flex-wrap: nowrap; white-space: nowrap;">
<el-radio border :label="true" style="flex-shrink: 0;">福利会员</el-radio>
<el-radio border :label="false" style="flex-shrink: 0;">非福利会员</el-radio>
</el-radio-group>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="入校时间">
<el-form-item>
<el-date-picker v-model="formData.arrivalAtSchoolDate"
type="date"
placeholder="请选择入校时间"
format="yyyy-MM-dd"
value-format="yyyy-MM-dd"></el-date-picker>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item></el-descriptions-item>
<el-descriptions-item></el-descriptions-item>
<template v-if="formData.loginname == $store.state.user.loginname">
<el-descriptions-item label="家庭主要成员" :span="3">
<el-form-item prop="families">
<el-table :data="formData.families" border size="small">
<el-table-column label="关系" prop="relation">
<template slot-scope="{row}">
<el-input v-model="row.relation" maxlength="50"
placeholder="请输入与本人关系"></el-input>
</template>
</el-table-column>
<el-table-column label="姓名" prop="name">
<template slot-scope="{row}">
<el-input v-model="row.name" maxlength="50"
placeholder="请输入姓名"></el-input>
</template>
</el-table-column>
<el-table-column label="工作单位" prop="unit">
<template slot-scope="{row}">
<el-input v-model="row.unit" maxlength="100"
placeholder="请输入工作单位"></el-input>
</template>
</el-table-column>
<el-table-column label="备注" prop="remark">
<template slot-scope="{row}">
<el-input v-model="row.remark" maxlength="100"
placeholder="请输入备注"></el-input>
</template>
</el-table-column>
<el-table-column width="100px">
<template slot="header" slot-scope="scope">
<el-button type="primary" size="mini" :disabled="allowFields('families')"
@click="formData.families.push({})">添加
</el-button>
</template>
<template slot-scope="scope">
<el-button
type="danger"
icon="el-icon-delete"
size="mini"
:disabled="formData.families.length===0 || allowFields('families')"
@click="formData.families.splice(scope.$index,1)"
></el-button>
</template>
</el-table-column>
</el-table>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="个人简历" :span="3">
<el-form-item prop="personalData">
<text-editor v-model="formData.personalData"></text-editor>
</el-form-item>
</el-descriptions-item>
</template>
</el-descriptions>
</el-form>
<el-row type="flex" justify="end" class="mt20">
<el-button type="primary" plain @click="$emit('do-back')">返回</el-button>
<el-button type="primary" @click="onSubmit">提交变更</el-button>
</el-row>
<!-- <el-row justify="end" type="flex" class="mt10">-->
<!-- <el-button @click="$emit('do-back')" v-if="formData.id || !isShow" type="primary">返 回</el-button>-->
<!-- <template v-if="isShow">-->
<!-- <el-button @click="doSave" type="primary">保存变更</el-button>-->
<!-- </template>-->
<!-- <el-button @click="doSubmit" type="primary">提交变更</el-button>-->
<!-- </el-row>-->
</el-card>
`,
props: {
id: { type: String, default: '' },
showThreeUnit: { type: Boolean, default: false },
},
mixins: [initTableMixins],
store,
data(){
return{
isShow: true,
user: {},
units: [],
unions: [],
threeUnits: [],
// 允许变更的字段
allowChangeFields: [],
formData: {
families: [],
},
formRules: {
username: [{required: false, message: "必填", trigger: ["change", "blur"]}],
sex: [{required: false, message: "必填", trigger: ["change", "blur"]}],
/*email: [
{
validator: (rule, value, callback) => {
if (!value) {
callback();
} else if (/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(value)) {
callback();
} else {
callback(new Error("邮箱格式错误"));
}
},
trigger: ["change", "blur"]
}
],
idCard: [
{
validator: (rule, value, callback) => {
if (!value) {
// 如果值为空,直接通过校验
callback();
} else if (/^[1-9]\d{5}(18|19|20)?\d{2}((0[1-9])|(10|11|12))(([0|1|2][0-9])|10|20|30|31)\d{3}(\d|[Xx])$/.test(value)) {
// 18位身份证号码格式正确
callback();
} else if (/^[1-9]\d{7}((0[1-9])|(10|11|12))(([0|1|2][0-9])|10|20|30|31)\d{3}$/.test(value)) {
// 15位身份证号码格式正确
callback();
} else if (/^[1-9]\d{5}(18|19|20)?\d{2}((0[1-9])|(10|11|12))(([0|1|2][0-9])|10|20|30|31)\d{3}$/.test(value)) {
// 外国人身份证号码格式正确(18位)
callback();
} else if (/^[1-9]\d{7}((0[1-9])|(10|11|12))(([0|1|2][0-9])|10|20|30|31)\d{3}$/.test(value)) {
// 外国人身份证号码格式正确(15位)
callback();
} else {
// 格式错误,返回错误信息
callback(new Error("身份证号码格式错误"));
}
},
trigger: ["change", "blur"]
}
],
mobile: [
{
validator: (rule, value, callback) => {
if (!value) {
// 如果值为空,直接通过校验
callback();
} else if (/^[1][345789][0-9]{9}$/.test(value)) {
// 手机号格式正确
callback();
} else if (/^\d+-\d+$/.test(value) && value.length <= 16) {
// 座机号格式正确
callback();
} else {
// 格式错误,返回错误信息
callback(new Error(value.includes('-') ? '座机号码格式错误' : '手机号码格式错误'));
}
},
trigger: ["change", "blur"]
}
]*/
},
welfareProjectList: []
}
},
methods: {
getUnionName(val){
const data = this.units.find(v => String(v.id) === String(val))
this.$set(this.formData, 'unitName', data ? data.name : '')
this.$set(this.formData, 'unionName', data ? (data.unionName || data.unionname || '') : '')
},
normalizeUnitField() {
if (!this.formData || !this.formData.unitId) {
return
}
const unitId = String(this.formData.unitId)
this.$set(this.formData, "unitId", unitId)
let unit = this.units.find(v => String(v.id) === unitId)
if (unit) {
this.$set(this.formData, "unitName", unit.name)
this.$set(this.formData, "unionName", unit.unionName || unit.unionname || this.formData.unionName || "")
}
},
allowFields(prop) {
return !this.allowChangeFields.map(v => v.code).includes(prop)
},
/** 在职状态选择退休时,存在“离退休人员”字典项才自动设置对应人员类型编码。 */
handleRetiredStateChange(userState) {
if (userState !== "退休") {
return
}
this.$businessTool.getDictOptions("USER_PERSON_TYPE").then((options) => {
const retiredOption = (options || []).find((item) => item.name === "离退休人员")
if (retiredOption) {
this.$set(this.formData, "personType", retiredOption.code)
}
})
},
// 管理页仅查询当前被编辑人员所属二级单位的直属三级单位,避免越级选择其他单位数据。
initThreeUnits() {
this.$set(this, "threeUnits", [])
if (!this.showThreeUnit) {
return Promise.resolve()
}
if (!this.formData.unitId) {
this.$message.warning("当前人员未配置所属单位,无法查询三级单位")
return Promise.resolve()
}
return this.$axios.post("/platform/sys/unit/child", {pid: this.formData.unitId})
.then((resp) => {
if (resp.code === 0) {
this.$set(this, "threeUnits", resp.data || [])
if (this.threeUnits.length === 0) {
this.$message.warning("当前所属单位未配置三级单位")
}
} else {
this.$message.error(resp.msg || "三级单位查询失败")
}
})
.catch(() => {
this.$message.error("三级单位查询失败,请稍后重试")
})
.finally(() => {})
},
doSave(){
this.$confirm("保存后可在我的申请里面再次编辑,您确定要保存吗?", "提示", { type: "warning" })
.then(() => {
const formData = this.formData
if (formData.families) {
formData.families = JSON.stringify(this.formData.families)
}
this.$emit('do-save', formData)
})
.catch()
},
onSubmit() {
this.$refs.formRef.validate((valid) => {
if (valid) {
this.$confirm("提交后将进入审核流程,无法再进行编辑,您确定要提交申请吗?", "提示", { type: "warning" })
.then(() => {
const formData = this.formData
if (formData.families) {
formData.families = JSON.stringify(this.formData.families)
}
this.$emit('do-submit', formData)
})
.catch()
}
})
},
init(userId){
let userPromise = Promise.resolve()
if (userId) {
this.$set(this, "isShow", false)
userPromise = this.getUserById(userId)
} else {
this.$set(this, "isShow", true)
this.$set(this, "user", this.$store.state.user)
}
return userPromise
.then(() => this.getFormDataValue(this.id))
.then(() => {
const requests = [
this.$businessTool.getDictOptions("ALLOW_CHANGE_FIELDS"),
this.$businessTool.listUnit()
]
if (this.$auth.hasRoleOr('SYSADMIN, SCHOOL_UNION_MEMBER_ADMIN, SCHOOL_UNION_ADMIN')) {
requests.push(this.$businessTool.listUnion())
}
return Promise.all(requests)
})
.then((result) => {
this.$set(this, "allowChangeFields", result[0])
this.$set(this, "units", result[1])
if (result.length > 2) {
this.$set(this, "unions", result[2])
}
this.normalizeUnitField()
this.initThreeUnits()
})
},
getUserById(userId){
return $.post("/platform/personGovernance/personChange/getUserByIdForMemberChange", { userId })
.then((resp) => {
if (resp.code === 0) {
this.$set(this, "user", resp.data)
}
})
.always(() => {})
},
getFormDataValue(id){
if (id) {
return $.post("/platform/personGovernance/personChange/findChangeById", { id }).then((res) => {
if (res.code === 0) {
this.$set(this, "formData", res.data)
this.$set(this.formData, "unionid", this.user.unionid)
if (this.formData.families) {
this.$set(this.formData, "families", JSON.parse(this.formData.families))
} else {
this.$set(this.formData, "families", [])
}
}
}).always(() => {})
} else {
const {
id,
username,
loginname,
sex,
member,
welfareMember,
nation,
birthday,
political,
education,
academicDegree,
position,
unit,
union,
campus,
threeUnitId,
userState,
retireDate,
welfareStopDate,
personType,
preparedBy,
userAttribute,
aidFundMemberUserType,
idCard,
mobile,
email,
families,
personalData,
arrivalAtSchoolDate
} = this.user
this.$set(this.formData, "userId", id)
this.$set(this.formData, "username", username)
this.$set(this.formData, "loginname", loginname)
this.$set(this.formData, "birthday", birthday)
this.$set(this.formData, "sex", sex)
this.$set(this.formData, "member", member === 1)
this.$set(this.formData, "welfareMember", welfareMember === 1)
this.$set(this.formData, "idCard", idCard)
this.$set(this.formData, "nation", nation)
this.$set(this.formData, "political", political)
this.$set(this.formData, "position", position)
this.$set(this.formData, "education", education)
this.$set(this.formData, "academicDegree", academicDegree)
this.$set(this.formData, "campus", campus)
this.$set(this.formData, "threeUnitId", threeUnitId)
this.$set(this.formData, "userState", userState)
// 日期选择器使用 yyyy-MM-dd 字符串,避免接口日期值无法在组件中正确回显。
this.$set(this.formData, "retireDate", retireDate
? this.$moment(retireDate).format("YYYY-MM-DD") : "")
this.$set(this.formData, "welfareStopDate", welfareStopDate
? this.$moment(welfareStopDate).format("YYYY-MM-DD") : "")
this.$set(this.formData, "personType", personType)
this.$set(this.formData, "preparedBy", preparedBy)
this.$set(this.formData, "userAttribute", userAttribute)
this.$set(this.formData, "aidFundMemberUserType", aidFundMemberUserType)
this.$set(this.formData, "unitName", unit ? unit.name : null)
this.$set(this.formData, "unitId", unit ? String(unit.id) : null)
this.$set(this.formData, "unionName", union ? union.name : null)
this.$set(this.formData, "unionId", union ? union.id : null)
this.$set(this.formData, "mobile", mobile)
this.$set(this.formData, "email", email)
this.$set(this.formData, "families", families ? families : [])
this.$set(this.formData, "personalData", personalData)
this.$set(this.formData, "arrivalAtSchoolDate", arrivalAtSchoolDate)
}
}
},
created(){
this.init()
},
style: /*language=CSS*/ `
.el-descriptions-item__label {
width: 15% !important;
}
.welfare-member-radio-group {
display: flex;
align-items: center;
flex-wrap: nowrap;
}
.welfare-member-radio-group .el-radio {
margin-right: 12px;
}
`
}
@@ -0,0 +1,108 @@
const PERSON_GOVERNANCE_MEMBER_INFO = {
template: /*language=HTML*/ `
<div>
<!-- 基本信息 -->
<div class="process-title">基本信息</div>
<el-descriptions :column="3" border class="descriptions-form">
<!-- 第 1 行 -->
<el-descriptions-item label="姓名工号">{{ viewData.username + '(' + viewData.loginname + ')' }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ viewData.sex }}</el-descriptions-item>
<el-descriptions-item label="出生日期">{{ viewData.birthday ? $moment(viewData.birthday).format('YYYY-MM-DD') : null }}</el-descriptions-item>
<!-- 第 2 行 -->
<el-descriptions-item label="国籍">{{ viewData.nationality }}</el-descriptions-item>
<el-descriptions-item label="民族">{{ viewData.nation }}</el-descriptions-item>
<el-descriptions-item label="政治面貌">{{ viewData.political }}</el-descriptions-item>
<!-- 第 3 行 -->
<el-descriptions-item label="证件类型">{{ viewData.idCardType }}</el-descriptions-item>
<el-descriptions-item label="身份证号码">{{ viewData.idCard }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ viewData.mobile }}</el-descriptions-item>
<!-- 第 4 行 -->
<el-descriptions-item label="学历">{{ viewData.education }}</el-descriptions-item>
<el-descriptions-item label="学位">{{ viewData.academicDegree }}</el-descriptions-item>
<el-descriptions-item label="婚姻状况">{{ viewData.marriage }}</el-descriptions-item>
<!-- 第 5 行 -->
<el-descriptions-item label="会员状态">{{ viewData.member ? '会员' : '非会员' }}</el-descriptions-item>
<el-descriptions-item label="福利会员状态">{{ viewData.welfareMember ? '福利会员' : '非福利会员' }}</el-descriptions-item>
<el-descriptions-item></el-descriptions-item>
<!-- 退休补充 -->
<template v-if="viewData.userState == '退休'">
<el-descriptions-item label="退休日期">{{ viewData.retireDate ? $moment(viewData.retireDate).format("YYYY-MM-DD") : '暂无' }}</el-descriptions-item>
<el-descriptions-item label="福利享受截止日期">{{ viewData.welfareStopDate ? $moment(viewData.welfareStopDate).format("YYYY-MM-DD") : '暂无' }}</el-descriptions-item>
<el-descriptions-item></el-descriptions-item>
</template>
<!-- 仅本人可见 -->
<template v-if="viewData.loginname === $store.state.user.loginname">
<el-descriptions-item label="家庭主要成员" :span="3">
<el-table v-if="viewData.families && viewData.families.length" :data="viewData.families" size="mini" border :header-cell-style="{background:'#eff3f6'}" style="width: 100%">
<el-table-column prop="relation" label="与本人关系" align="center"/>
<el-table-column prop="name" label="姓名" align="center"/>
<el-table-column prop="unit" label="单位" align="center"/>
<el-table-column prop="remark" label="备注" align="center"/>
</el-table>
<el-empty v-else :image-size="50" description="暂无家庭成员信息"/>
</el-descriptions-item>
<el-descriptions-item label="个人简况" :span="3">
<div v-if="viewData.personalData" class="text-left" v-html="viewData.personalData"/>
<span v-else>无数据</span>
</el-descriptions-item>
</template>
</el-descriptions>
<!-- 工作信息 -->
<div class="process-title">工作信息</div>
<el-descriptions :column="3" border>
<!-- 第 1 行 -->
<el-descriptions-item label="工作单位">{{ viewData.unitName }}</el-descriptions-item>
<el-descriptions-item label="所属工会">{{ viewData.unionName }}</el-descriptions-item>
<el-descriptions-item label="所属校区">{{ viewData.campus }}</el-descriptions-item>
<!-- 第 2 行 -->
<el-descriptions-item label="入校时间">{{ viewData.arrivalAtSchoolDate ? $moment(viewData.arrivalAtSchoolDate).format('YYYY-MM-DD') : null }}</el-descriptions-item>
<el-descriptions-item label="从教年月">{{ viewData.teachingTime ? $moment(viewData.teachingTime).format('YYYY-MM-DD') : null }}</el-descriptions-item>
<el-descriptions-item label="预/已离校时间">{{ viewData.expectedLeaveSchoolDate ? $moment(viewData.expectedLeaveSchoolDate).format('YYYY-MM-DD') : null }}</el-descriptions-item>
<!-- 第 3 行 -->
<el-descriptions-item label="在职状态">{{ viewData.userState }}</el-descriptions-item>
<el-descriptions-item label="人员类型">{{ viewData.personType }}</el-descriptions-item>
<!-- 第 4 行 -->
<el-descriptions-item label="人员属性">{{ viewData.userAttribute }}</el-descriptions-item>
<el-descriptions-item label="职称">{{ viewData.professionalTitle }}</el-descriptions-item>
<el-descriptions-item label="职级">{{ viewData.professionalLevel }}</el-descriptions-item>
<!-- 第 5 行 -->
<el-descriptions-item label="岗位系列">{{ viewData.positionSeries }}</el-descriptions-item>
<el-descriptions-item label="行政级别(管理岗位)">{{ viewData.administrativeLevel }}</el-descriptions-item>
<el-descriptions-item label="岗级">{{ viewData.positionLevel }}</el-descriptions-item>
<el-descriptions-item label="行政岗级">{{ viewData.administrativePositionLevel }}</el-descriptions-item>
<el-descriptions-item label="人员分类" v-if="false">{{ viewData.aidFundMemberUserType }}</el-descriptions-item>
</el-descriptions>
<slot></slot>
</div>
`,
store,
data() {
return {
viewData: {}
}
},
methods: {
onOpen(id) {
this.$axios.post("/platform/personGovernance/personChange/findOne", { id })
.then((res) => {
if (res.code === 0) {
this.$set(this, "viewData", res.data)
}
})
.finally(() => {})
}
}
};
@@ -0,0 +1,944 @@
<!--#
layout("/layouts/platform.html"){
#-->
<link rel="stylesheet" href="${base!}/assets/platform/css/personGovernance.css?v=1.0.22" />
<div
id="app"
v-cloak
class="person-governance-list-page person-governance-change-page person-governance-page-transition"
:class="{'is-ready':pageReady}"
>
<guava ref="guava">
<el-card shadow="never" class="person-governance-search-card">
<search @search="doSearch">
<search-item label="姓名/工号">
<el-input placeholder="请输入姓名或工号" clearable v-model="pageForm.searchKeyword"></el-input>
</search-item>
<search-item label="所属工会">
<el-select @change="flushUnits" @clear="flushUnits" clearable filterable
placeholder="请选择所属工会" style="width: 100%;" v-model="pageForm.unionId">
<el-option :key="item.id" :label="item.name" :value="item.id"
v-for="item in unions"></el-option>
</el-select>
</search-item>
<search-item label="所属单位">
<el-select @change="flushThreeUnits" @clear="flushThreeUnits" clearable filterable
placeholder="请选择所属单位" style="width: 100%" v-model="pageForm.unitId">
<el-option :key="item.id" :label="item.name" :value="item.id"
v-for="item in units"></el-option>
</el-select>
</search-item>
<search-item label="三级单位">
<el-select clearable filterable placeholder="请选择三级单位" style="width: 100%"
v-model="pageForm.threeUnitId">
<el-option :key="item.id" :label="item.name" :value="item.id"
v-for="item in threeUnits"></el-option>
</el-select>
</search-item>
<search-item label="性别">
<dict-select v-model="pageForm.sex" placeholder="性别" @change="doSearch"
code="USER_SEX"></dict-select>
</search-item>
<search-item label="在职状态">
<dict-select v-model="pageForm.userState" placeholder="请选择在职状态" @change="doSearch"
code="USER_STATE"></dict-select>
</search-item>
<search-item label="人员类型">
<dict-select v-model="pageForm.personType" placeholder="请选择人员类型" @change="doSearch"
code="USER_PERSON_TYPE"></dict-select>
</search-item>
<!-- <search-item label="编制类别">-->
<!-- <dict-select v-model="pageForm.preparedBy" placeholder="请选择编制类别" @change="doSearch"-->
<!-- code="USER_PREPARED_BY_TYPE"></dict-select>-->
<!-- </search-item>-->
<!-- 临时屏蔽“人员分类”查询字段,不删除配置,后续需要可恢复。-->
<!-- <search-item label="人员分类">-->
<!-- <dict-select v-model="pageForm.aidFundMemberUserType" placeholder="请选择人员分类" @change="doSearch"-->
<!-- code="AIDFUND_MEMBER_USER_TYPE"></dict-select>-->
<!-- </search-item>-->
<search-item label="人员属性">
<dict-select v-model="pageForm.userAttributes" placeholder="请选择人员属性" @change="doSearch"
code="USER_ATTRIBUTE" multiple collapse-tags clearable></dict-select>
</search-item>
<search-item label="更新日期">
<el-date-picker :picker-options="pickerOptions" @change="changeDateRangeChange"
align="right" end-placeholder="结束日期" format="yyyy-MM-dd"
range-separator="-" start-placeholder="开始日期" style="width: 100%"
type="datetimerange" v-model="pageForm.changeDateRange"
value-format="yyyy-MM-dd"></el-date-picker>
</search-item>
</search>
</el-card>
<el-card shadow="never" class="mt10 person-governance-change-list-card">
<table-tool label="人员列表(工会小组或分工会提交的变更还需校工会审核)">
<el-button :disabled="!checkUsers.length" @click="openBatchEditMember" class="ml5" icon="el-icon-edit"
size="small" type="primary">
设置会员
</el-button>
<el-button :disabled="!checkUsers.length" @click="openBatchEditAidFundType" class="ml5" icon="el-icon-edit"
size="small" type="primary">
设置人员分类
</el-button>
<el-button :disabled="!checkUsers.length" @click="openBatchEditUserAttribute" class="ml5" icon="el-icon-edit"
size="small" type="primary">
设置人员属性
</el-button>
<el-button :disabled="!checkUsers.length" @click="openBatchEditUserState" class="ml5" icon="el-icon-edit"
size="small" type="primary">
设置在职状态
</el-button>
<el-button :disabled="!checkUsers.length" @click="openUnitMove" class="ml5" icon="el-icon-edit"
size="small" type="primary">
单位调整
</el-button>
<el-radio-group @change="doSearch" class="mr5"
size="small"
v-model="pageForm.isMember">
<el-radio-button :label="null">全部教工</el-radio-button>
<el-radio-button :label="1">会员</el-radio-button>
<el-radio-button :label="0">非会员</el-radio-button>
</el-radio-group>
<span class="text-danger">人员异动:</span>
<el-radio-group @change="doSearch" class="mr5" size="small"
v-model="pageForm.changed">
<el-radio-button :label="2">全部</el-radio-button>
<el-radio-button :label="1">异动</el-radio-button>
<el-radio-button :label="0">未异动</el-radio-button>
</el-radio-group>
<!--<div v-if="pageForm.changed == '1'" class="div-enum">
<dict-select v-model="pageForm.USER_CHANGE_TYPE" placeholder="异动类型" @change="doSearch"
code="UserType"></dict-select>
</div>-->
<!--<el-button type="primary" size="small" icon="el-icon-printer" @click="exportExcel">导出会员名单</el-button>-->
<el-popover
placement="bottom"
trigger="click"
width="200">
<div style="padding:4px 0;border-bottom:1px solid #eee;">
<el-button size="mini" @click="checkAll">全选</el-button>
<el-button size="mini" @click="invertCheck">反选</el-button>
</div>
<div style="max-height:40vh;overflow-y:auto;padding:6px 0;">
<el-checkbox-group v-model="checkedFields">
<el-checkbox
v-for="f in tableColumns"
:key="f.prop"
:label="f.prop"
style="display:block;margin:6px 0;">
{{ f.label }}
</el-checkbox>
</el-checkbox-group>
</div>
<el-button
slot="reference"
type="text"
icon="el-icon-s-operation"
size="small"
style="margin-left:12px;">
列设置
</el-button>
</el-popover>
</table-tool>
<div class="person-governance-change-table-wrap">
<el-table :data="tableData" :size="tableSize" @selection-change="tableHandleSelectionChange" @sort-change="pageOrder"
class="vi-table" ref="table" row-key="id" style="width: 100%" height="100%">
<el-table-column :reserve-selection="true" type="selection"></el-table-column>
<el-table-column :index="indexMethod" align="center" header-align="center"
label="序号" type="index" width="80px" fixed="left"></el-table-column>
<el-table-column :label="column.label" :prop="column.prop"
:sortable="column.sortable" :width="column.width"
align="center" header-align="center" min-width="100px"
v-if="checkedFields.includes(column.prop)" :fixed="column.fixed"
show-overflow-tooltip v-for="column in tableColumns">
<template scope="{row}" v-if="column.prop=='loginname'">
<el-link @click="openView(row.id)" type="primary">{{row.loginname}}</el-link>
</template>
<!-- 出生年月仅格式化表格显示值,保留原始数据供其他业务使用。 -->
<template scope="{row}" v-if="column.prop=='birthday'">
{{row.birthday ? $moment(row.birthday).format('YYYY-MM-DD') : ''}}
</template>
</el-table-column>
<el-table-column header-align="center" label="异动信息">
<el-table-column align="center" header-align="center" label="更新时间"
min-width="145px" prop="changeTime"
show-overflow-tooltip sortable>
</el-table-column>
<el-table-column align="center" header-align="center" label="异动类型"
prop="changeType" show-overflow-tooltip sortable>
<template scope="{row}">
<span style="color: #0e78c5;cursor: pointer" @click="openUserChangeInfo(row)">
{{getChangeTypes(row.changeTypes)}}
</span>
</template>
</el-table-column>
</el-table-column>
<el-table-column align="center" header-align="center" label="会员" prop="member"
show-overflow-tooltip sortable>
<template scope="{row}">
<span class="text-success " v-if="row.member==1">
<i class="fa fa-circle ml5"></i> 是
</span>
<span class="text-danger" v-else>
<i class="fa fa-circle ml5"></i> 否
</span>
</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="福利会员" prop="member"
show-overflow-tooltip sortable>
<template scope="{row}">
<span class="text-success " v-if="row.welfareMember==1">
<i class="fa fa-circle ml5"></i> 是
</span>
<span class="text-danger" v-else>
<i class="fa fa-circle ml5"></i> 否
</span>
</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="操作"
prop="userOnline" width="100px" fixed="right">
<template scope="{row}">
<el-dropdown @command="dropdownCommand">
<el-button plain size="mini">
<i class="ti-settings"></i>
<span class="ti-angle-down"></span>
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item :command="{type:'view',data:row}">
查看
</el-dropdown-item>
<el-dropdown-item
:command="{type:'change',data:row}"
v-if="$auth.hasRoleOr(['SYSADMIN','SCHOOL_UNION_ADMIN','BRANCH_UNION_CHAIRMAN','BRANCH_UNION_CHAIRMAN','BRANCH_UNION_GROUP_LEADER'])">
变更
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</template>
</el-table-column>
</el-table>
</div>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
<template #view>
<member-info ref="memberInfoRef"></member-info>
</template>
<template #public>
<member-change ref="memberChangeRef" :show-three-unit="true" @do-back="$refs.guava.index()" @do-submit="doSubmit"></member-change>
</template>
<template #edit>
<member-all-change ref="memberAllChangeRef"></member-all-change>
</template>
<el-drawer
:before-close="handleClose"
:visible.sync="batchEditUserAttributeVisible"
size="1000px" title="批量设置人员属性">
<el-timeline style="margin-right: 40px">
<el-timeline-item placement="top" timestamp="">
<el-form :model="batchFormDataUserAttribute" label-width="100px" ref="batchFormUserAttribute">
<el-row :gutter="60">
<el-col :span="12">
<el-form-item label="人员属性">
<dict-select
@change="checkUsers.map(v => $set(v, 'userAttribute', batchFormDataUserAttribute.userAttribute))"
clearable code="USER_ATTRIBUTE"
placeholder="请选择人员属性"
style="width: 300px"
v-model="batchFormDataUserAttribute.userAttribute"></dict-select>
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-timeline-item>
<el-timeline-item placement="top" timestamp="">
<el-table :data="checkUsers" class="vi-table" height="70vh" row-key="id" style="width: 100%">
<el-table-column align="center" header-align="center" label="工号" prop="loginname" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="姓名" prop="username" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="所属单位" prop="unitName" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="所属工会" prop="unionName" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="人员属性" prop="userAttribute" show-overflow-tooltip width="300px">
<template scope="{row}">
<dict-select clearable code="USER_ATTRIBUTE" placeholder="请选择人员属性" style="width: 200px"
v-model="row.userAttribute"></dict-select>
</template>
</el-table-column>
</el-table>
</el-timeline-item>
</el-timeline>
<span style="position: absolute;bottom: 20px;right: 20px">
<el-button @click="batchEditUserAttributeVisible = false;checkUsers=[];$refs.table.clearSelection()">取消</el-button>
<el-button :loading="batchEditLoading" @click="doBatchEditUserAttribute" type="primary">确定</el-button>
</span>
</el-drawer>
<el-drawer
:before-close="handleClose"
:visible.sync="batchEditMemberVisible"
size="1000px" title="批量设置会员">
<el-timeline style="margin-right: 40px">
<el-timeline-item placement="top" timestamp="">
<el-form :model="batchFormDataMember" label-width="100px" ref="batchFormMember">
<el-row :gutter="60">
<el-col :span="12">
<el-form-item label="是否会员">
<el-switch
@change="checkUsers.map(v => $set(v, 'member', batchFormDataMember.member))"
v-model="batchFormDataMember.member"
active-text="是"
inactive-text="否">
</el-switch>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="是否福利会员">
<el-switch
@change="checkUsers.map(v => $set(v, 'welfareMember', batchFormDataMember.welfareMember))"
v-model="batchFormDataMember.welfareMember"
active-text="是"
inactive-text="否">
</el-switch>
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-timeline-item>
<el-timeline-item placement="top" timestamp="">
<el-table :data="checkUsers" class="vi-table" height="70vh" row-key="id" style="width: 100%">
<el-table-column align="center" header-align="center" label="工号" prop="loginname" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="姓名" prop="username" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="所属单位" prop="unitName" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="所属工会" prop="unionName" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="是否会员" prop="member" show-overflow-tooltip width="150px">
<template scope="{row}">
<el-switch v-model="row.member" active-text="是" inactive-text="否"></el-switch>
</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="是否福利会员" prop="welfareMember" show-overflow-tooltip width="150px">
<template scope="{row}">
<el-switch v-model="row.welfareMember" active-text="是" inactive-text="否"></el-switch>
</template>
</el-table-column>
</el-table>
</el-timeline-item>
</el-timeline>
<span style="position: absolute;bottom: 20px;right: 20px">
<el-button @click="batchEditMemberVisible = false;checkUsers=[];$refs.table.clearSelection()">取消</el-button>
<el-button :loading="batchEditLoading" @click="doBatchEditMember" type="primary">确定</el-button>
</span>
</el-drawer>
<el-drawer
:before-close="handleClose"
:visible.sync="batchEditAidFundTypeVisible"
size="1000px" title="批量设置人员分类">
<el-timeline style="margin-right: 40px">
<el-timeline-item placement="top" timestamp="">
<el-form :model="batchFormDataAidFundType" label-width="100px" ref="batchFormAidFundType">
<el-row :gutter="60">
<el-col :span="12">
<el-form-item label="人员分类">
<dict-select
@change="checkUsers.map(v => $set(v, 'aidFundMemberUserType', batchFormDataAidFundType.aidFundMemberUserType))"
clearable code="AIDFUND_MEMBER_USER_TYPE"
placeholder="请选择人员分类"
style="width: 300px"
v-model="batchFormDataAidFundType.aidFundMemberUserType"></dict-select>
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-timeline-item>
<el-timeline-item placement="top" timestamp="">
<el-table :data="checkUsers" class="vi-table" height="70vh" row-key="id" style="width: 100%">
<el-table-column align="center" header-align="center" label="工号" prop="loginname" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="姓名" prop="username" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="所属单位" prop="unitName" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="所属工会" prop="unionName" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="人员分类" prop="aidFundMemberUserType" show-overflow-tooltip width="300px">
<template scope="{row}">
<dict-select clearable code="AIDFUND_MEMBER_USER_TYPE" placeholder="请选择人员分类" style="width: 200px"
v-model="row.aidFundMemberUserType"></dict-select>
</template>
</el-table-column>
</el-table>
</el-timeline-item>
</el-timeline>
<span style="position: absolute;bottom: 20px;right: 20px">
<el-button @click="batchEditAidFundTypeVisible = false;checkUsers=[];$refs.table.clearSelection()">取消</el-button>
<el-button :loading="batchEditLoading" @click="doBatchEditAidFundType" type="primary">确定</el-button>
</span>
</el-drawer>
<el-drawer
:before-close="handleClose"
:visible.sync="batchEditUserStateVisible"
size="1000px" title="批量设置在职状态">
<el-timeline style="margin-right: 40px">
<el-timeline-item placement="top" timestamp="">
<el-form :model="batchFormDataUserState" label-width="100px" ref="batchFormUserState">
<el-row :gutter="60">
<el-col :span="12">
<el-form-item label="在职状态">
<dict-select
@change="checkUsers.map(v => $set(v, 'userState', batchFormDataUserState.userState))"
clearable code="USER_STATE"
placeholder="请选择在职状态"
style="width: 300px"
v-model="batchFormDataUserState.userState"></dict-select>
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-timeline-item>
<el-timeline-item placement="top" timestamp="">
<el-table :data="checkUsers" class="vi-table" height="70vh" row-key="id" style="width: 100%">
<el-table-column align="center" header-align="center" label="工号" prop="loginname" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="姓名" prop="username" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="所属单位" prop="unitName" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="所属工会" prop="unionName" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="在职状态" prop="userState" show-overflow-tooltip width="300px">
<template scope="{row}">
<dict-select clearable code="USER_STATE" placeholder="请选择在职状态" style="width: 200px"
v-model="row.userState"></dict-select>
</template>
</el-table-column>
</el-table>
</el-timeline-item>
</el-timeline>
<span style="position: absolute;bottom: 20px;right: 20px">
<el-button @click="batchEditUserStateVisible = false;checkUsers=[];$refs.table.clearSelection()">取消</el-button>
<el-button :loading="batchEditLoading" @click="doBatchEditUserState" type="primary">确定</el-button>
</span>
</el-drawer>
<el-drawer
:before-close="handleClose"
:visible.sync="unitMoveVisible"
size="1000px" title="批量单位调整">
<el-timeline style="margin-right: 40px">
<el-timeline-item placement="top" timestamp="">
<el-form :model="unitMoveFormData" label-width="100px" ref="unitMoveForm">
<el-row :gutter="60">
<el-col :span="12">
<el-form-item label="所属工会">
<el-select clearable filterable placeholder="请选择所属工会" style="width: 300px"
v-model="unitMoveFormData.unionId" @change="moveUnionsChange">
<el-option v-for="item in unitMoveUnions" :key="item.id" :label="item.name" :value="item.id"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="所属单位">
<el-select clearable filterable placeholder="请选择所属单位" style="width: 300px"
v-model="unitMoveFormData.unitId" @change="moveUnitChange">
<el-option v-for="item in unitMoveUnits" :key="item.id" :label="item.name" :value="item.id"></el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-timeline-item>
<el-timeline-item placement="top" timestamp="">
<el-table :data="checkUsers" class="vi-table" height="70vh" row-key="id" style="width: 100%">
<el-table-column align="center" header-align="center" label="工号" prop="loginname" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="姓名" prop="username" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="当前单位" prop="unitName" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="当前工会" prop="unionName" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="调整后工会" show-overflow-tooltip>
<template>{{ unitMoveFormData.unionName }}</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="调整后单位" show-overflow-tooltip>
<template>{{ unitMoveFormData.unitName }}</template>
</el-table-column>
</el-table>
</el-timeline-item>
</el-timeline>
<span style="position: absolute;bottom: 20px;right: 20px">
<el-button @click="unitMoveVisible = false;checkUsers=[];$refs.table.clearSelection()">取消</el-button>
<el-button :loading="batchEditLoading" @click="doUnitMove" type="primary">确定</el-button>
</span>
</el-drawer>
</guava>
</div>
<script nonce="${cspNonce!}">
<!--#include("common/memberInfo.js"){}#-->
<!--#include("common/memberChange.js"){}#-->
<!--#include("common/memberAllChangeInfo.js"){}#-->
new Vue({
el: "#app",
store,
mixins: [initTableMixins],
data: {
pageReady: false,
pickerOptions: {
shortcuts: [{
text: '最近一周',
onClick(picker) {
const end = new Date();
const start = new Date();
start.setTime(start.getTime() - 3600 * 1000 * 24 * 7);
picker.$emit('pick', [start, end]);
}
}, {
text: '最近一个月',
onClick(picker) {
const end = new Date();
const start = new Date();
start.setTime(start.getTime() - 3600 * 1000 * 24 * 30);
picker.$emit('pick', [start, end]);
}
}, {
text: '最近三个月',
onClick(picker) {
const end = new Date();
const start = new Date();
start.setTime(start.getTime() - 3600 * 1000 * 24 * 90);
picker.$emit('pick', [start, end]);
}
}]
},
pageForm: {
unionId: "",
unitId: "",
threeUnitId: "",
sex: "",
searchKeyword: "",
changed: 2,
isMember: 1,
changeTypes: [],
aidFundMemberUserType: "",
userAttributes: [],
},
unions: [],
units: [],
threeUnits: [],
id: "",
checkUsers: [],
batchEditLoading: false,
batchEditUserAttributeVisible: false,
batchEditMemberVisible: false,
batchEditAidFundTypeVisible: false,
batchEditUserStateVisible: false,
unitMoveVisible: false,
batchFormDataUserAttribute: {},
batchFormDataMember: {member: false, welfareMember: false},
batchFormDataAidFundType: {},
batchFormDataUserState: {},
unitMoveFormData: {
unitId: "",
unionId: "",
unitName: "",
unionName: ""
},
unitMoveUnions: [],
unitMoveUnits: [],
tableColumns: [
{ prop: "loginname", label: "工号", width: 120, fixed: "left"},
{ prop: "username", label: "姓名", width: 120, fixed: "left" },
{ prop: "sex", label: "性别", width: 120},
{ prop: "birthday", label: "出生年月", width: 120, sortable: true },
{ prop: "nationality", label: "国籍", width: 120, sortable: true, checked: 0 },
// 临时屏蔽“民族”列表字段,不删除配置,后续需要可恢复。
// { prop: "nation", label: "民族", width: 120, sortable: true},
{ prop: "mobile", label: "联系电话", width: 120 },
// 临时屏蔽“政治面貌”列表字段,不删除配置,后续需要可恢复。
// { prop: "political", label: "政治面貌", width: 120, sortable: true},
{ prop: "userState", label: "在职状态", width: 120, sortable: true },
// 临时隐藏原人员类型位置,人员类型显示到原“人员分类”位置。
// { prop: "personType", label: "人员类型", width: 120, sortable: true, checked: 0 },
// { prop: "preparedBy", label: "编制类别", width: 120, sortable: true },
{ prop: "postDoctoralJoinDate", label: "进站时间", sortable: true, checked: 0 },
{ prop: "unionName", label: "所属工会", width: 120, sortable: true },
{ prop: "unitName", label: "所属单位", width: 120, sortable: true },
{ prop: "personType", label: "人员类型", width: 120, sortable: true },
// 临时屏蔽“人员分类”列表字段,不删除配置,后续需要可恢复。
// { prop: "aidFundMemberUserType", label: "人员分类", width: 120, sortable: true },
{ prop: "userAttribute", label: "人员属性", width: 120, sortable: true },
{ prop: "idCardType", label: "证件类型", width: 120, sortable: true, checked: 0 },
{ prop: "idCard", label: "证件号码", width: 120, sortable: true, checked: 0 },
{ prop: "marriage", label: "婚姻状况", width: 120, sortable: true, checked: 0 },
{ prop: "jobCategory", label: "岗位类别", width: 120, sortable: true, checked: 0 },
{ prop: "professionalTitle", label: "职称", width: 120, sortable: true, checked: 0 },
{ prop: "professionalLevel", label: "职级", width: 120, sortable: true, checked: 0 },
{ prop: "employeeSource", label: "职工来源", width: 120, sortable: true, checked: 0 },
{ prop: "administrativeLevel", label: "行政级别(管理岗位)", width: 120, sortable: true, checked: 0 },
{ prop: "positionSeries", label: "岗位系列", width: 120, sortable: true, checked: 0 },
{ prop: "positionLevel", label: "岗级", width: 120, sortable: true, checked: 0 },
{ prop: "administrativePositionLevel", label: "行政岗级", width: 120, sortable: true, checked: 0 },
{ prop: "arrivalAtSchoolDate", label: "来校时间", width: 120, sortable: true, checked: 0 },
{ prop: "teachingTime", label: "从教年月", width: 120, sortable: true, checked: 0 },
{ prop: "expectedLeaveSchoolDate", label: "预计/最后离校时间", width: 120, sortable: true, checked: 0 },
],
changeTypeData: [],
checkedFields: [],
allowChangeFields: []
},
components: {
"member-info": PERSON_GOVERNANCE_MEMBER_INFO,
"member-change": PERSON_GOVERNANCE_MEMBER_CHANGE,
"member-all-change": PERSON_GOVERNANCE_MEMBER_ALL_CHANGE_INFO,
},
methods: {
// 分页和导出共用该参数构建方法,保证人员属性多选值始终按 JSON 数组提交。
buildQueryPageForm(source = this.pageForm) {
const pageForm = clone(source)
pageForm.userAttributes = JSON.stringify(source.userAttributes || [])
return pageForm
},
// 覆盖公共分页方法,使后端按人员属性集合执行查询。
pageData(data = null) {
return initTableMixins.methods.pageData.call(this, this.buildQueryPageForm(data || this.pageForm))
},
showPage() {
this.$nextTick(() => {
if (this.$refs.table && this.$refs.table.doLayout) {
this.$refs.table.doLayout()
}
window.requestAnimationFrame(() => {
this.$set(this, "pageReady", true)
})
})
},
dropdownCommand(command) {
const {type, data} = command
if (type === 'view') {
this.openView(data.id)
} else if (type === 'change') {
this.openChange(data.id)
}
},
openChange(id) {
this.$refs.guava.public(() => {
this.$refs.memberChangeRef.init(id)
})
},
openView(id) {
this.$refs.guava.view(() => {
this.$refs.memberInfoRef.onOpen(id)
})
},
openUserChangeInfo(row) {
this.$refs.guava.edit(() => {
this.$refs.memberAllChangeRef.onOpen(row.id)
})
},
doSubmit(data) {
const loading = createLoading('正在提交中')
this.$axios.post("/platform/personGovernance/personChange/doSubmitChange", data)
.then((resp) => {
if (resp.code === 0) {
this.$message.success(resp.msg)
this.$refs.guava.index()
this.pageData()
} else {
this.$message.error(resp.msg)
}
})
.finally(() => {
loading.close()
})
},
getChangeTypes(changeTypes){
if (!changeTypes) return null
const changeTypesList = JSON.parse(changeTypes)
if (changeTypesList) {
if (!changeTypesList || changeTypesList.length === 0 || !this.changeTypeData || this.changeTypeData.length === 0) return null
return changeTypesList
.map((v) => {
return this.changeTypeData.find((item) => item.code === v).changeTypeName
})
.join(",")
}
return null
},
changeDateRangeChange(val) {
if (val && val.length > 0) {
this.$set(this.pageForm, "changeDateBefore", val[0])
this.$set(this.pageForm, "changeDateEnd", val[1])
}
},
/**
* 导出会员名单
*/
exportExcel() {
const pageForm = this.buildQueryPageForm()
const data = this.tableColumns.filter(item => this.checkedFields.includes(item.prop)).map(item => {
return {prop: item.prop, label: item.label}
})
pageForm.columns = JSON.stringify(data)
this.$downLoad("/platform/personGovernance/personChange/doExport", pageForm)
},
initData() {
const isAdmin = this.$auth.hasRoleOr(["SYSADMIN", "SCHOOL_UNION_ADMIN"])
const unionId = isAdmin ? this.pageForm.unionId : this.$store.state.user.union.id
return Promise.all([
this.$businessTool.listUnion(unionId),
this.$businessTool.getEnumOptions("MemberChangeType"),
this.$businessTool.getDictOptions("ALLOW_CHANGE_FIELDS")
]).then((result) => {
this.$set(this, "unions", result[0])
this.$set(this, "changeTypeData", result[1])
this.$set(this, "allowChangeFields", result[2])
})
},
flushUnits() {
this.$set(this.pageForm, "unitId", null)
this.$set(this.pageForm, "threeUnitId", null)
this.$set(this, "units", [])
this.$set(this, "threeUnits", [])
if (this.pageForm.unionId) {
this.$businessTool.listUnit(this.pageForm.unionId).then(data => {
this.$set(this, "units", data)
})
}
},
// 所属单位变化时只加载其直属三级单位,避免跨单位筛选。
flushThreeUnits(unitId) {
this.$set(this.pageForm, "threeUnitId", null)
this.$set(this, "threeUnits", [])
if (!unitId) {
return
}
this.$axios.post("/platform/sys/unit/child", {pid: unitId})
.then((res) => {
if (res.code === 0) {
this.$set(this, "threeUnits", res.data || [])
} else {
this.$message.error(res.msg || "三级单位查询失败")
}
})
.catch(() => {
this.$message.error("三级单位查询失败,请稍后重试")
})
.finally(() => {})
},
checkAll() {
this.$set(this, "checkedFields", this.tableColumns.map(c => c.prop));
},
invertCheck() {
const all = this.tableColumns.map(c => c.prop);
this.$set(this, "checkedFields", all.filter(p => !this.checkedFields.includes(p)));
},
tableHandleSelectionChange(val) {
this.$set(this, "checkUsers", JSON.parse(JSON.stringify([...val])))
},
handleClose(done) {
this.$confirm("确认关闭?").then(() => {
done()
this.$set(this, "checkUsers", [])
this.$refs.table.clearSelection()
}).catch(() => {})
},
openBatchEditUserAttribute() {
this.$set(this, "batchFormDataUserAttribute", {})
this.$set(this, "batchEditUserAttributeVisible", true)
},
openBatchEditMember() {
this.$set(this, "batchFormDataMember", {member: false, welfareMember: false})
this.$set(this, "batchEditMemberVisible", true)
},
openBatchEditAidFundType() {
this.$set(this, "batchFormDataAidFundType", {})
this.$set(this, "batchEditAidFundTypeVisible", true)
},
openBatchEditUserState() {
this.$set(this, "batchFormDataUserState", {})
this.$set(this, "batchEditUserStateVisible", true)
},
openUnitMove() {
this.$set(this, "unitMoveFormData", {
unitId: "",
unionId: "",
unitName: "",
unionName: ""
})
this.$set(this, "unitMoveUnits", [])
this.$set(this, "unitMoveVisible", true)
const isAdmin = this.$auth.hasRoleOr(["SYSADMIN", "SCHOOL_UNION_ADMIN"])
const unionId = isAdmin ? this.pageForm.unionId : this.$store.state.user.union.id
this.$businessTool.listUnion(unionId).then((data) => {
this.$set(this, "unitMoveUnions", data)
})
},
moveUnionsChange(val) {
if (val) {
this.$businessTool.listUnit(val).then((data) => {
this.$set(this, "unitMoveUnits", data)
const union = this.unitMoveUnions.find(v => v.id === val)
this.$set(this.unitMoveFormData, "unionName", union ? union.name : "")
this.$set(this.unitMoveFormData, "unitId", "")
this.$set(this.unitMoveFormData, "unitName", "")
})
} else {
this.$set(this.unitMoveFormData, "unitId", "")
this.$set(this.unitMoveFormData, "unitName", "")
this.$set(this.unitMoveFormData, "unionName", "")
this.$set(this, "unitMoveUnits", [])
}
},
moveUnitChange(val) {
if (val) {
const unit = this.unitMoveUnits.find(v => v.id === val)
this.$set(this.unitMoveFormData, "unitName", unit ? unit.name : "")
} else {
this.$set(this.unitMoveFormData, "unitName", "")
}
},
doBatchEditUserAttribute() {
const batch = this.checkUsers.map(v => {
return {id: v.id, userAttribute: v.userAttribute}
})
this.$set(this, "batchEditLoading", true)
this.$axios.post("/platform/personGovernance/personChange/batchUpdateUserAttribute", {users: JSON.stringify(batch)})
.then((resp) => {
if (resp.code === 0) {
this.$message.success(resp.msg)
this.pageData()
this.$refs.table.clearSelection()
this.$set(this, "batchEditUserAttributeVisible", false)
this.$set(this, "checkUsers", [])
} else {
this.$message.error(resp.msg)
}
}).finally(() => {
this.$set(this, "batchEditLoading", false)
})
},
doBatchEditMember() {
const batch = this.checkUsers.map(v => {
return {id: v.id, member: v.member, welfareMember: v.welfareMember}
})
this.$set(this, "batchEditLoading", true)
this.$axios.post("/platform/personGovernance/personChange/batchUpdateMember", {users: JSON.stringify(batch)})
.then((resp) => {
if (resp.code === 0) {
this.$message.success(resp.msg)
this.pageData()
this.$refs.table.clearSelection()
this.$set(this, "batchEditMemberVisible", false)
this.$set(this, "checkUsers", [])
} else {
this.$message.error(resp.msg)
}
}).finally(() => {
this.$set(this, "batchEditLoading", false)
})
},
doBatchEditAidFundType() {
const batch = this.checkUsers.map(v => {
return {id: v.id, aidFundMemberUserType: v.aidFundMemberUserType}
})
this.$set(this, "batchEditLoading", true)
this.$axios.post("/platform/personGovernance/personChange/batchUpdateAidFundType", {users: JSON.stringify(batch)})
.then((resp) => {
if (resp.code === 0) {
this.$message.success(resp.msg)
this.pageData()
this.$refs.table.clearSelection()
this.$set(this, "batchEditAidFundTypeVisible", false)
this.$set(this, "checkUsers", [])
} else {
this.$message.error(resp.msg)
}
}).finally(() => {
this.$set(this, "batchEditLoading", false)
})
},
doBatchEditUserState() {
const batch = this.checkUsers.map(v => {
return {id: v.id, userState: v.userState}
})
this.$set(this, "batchEditLoading", true)
this.$axios.post("/platform/personGovernance/personChange/batchUpdateUserState", {users: JSON.stringify(batch)})
.then((resp) => {
if (resp.code === 0) {
this.$message.success(resp.msg)
this.pageData()
this.$refs.table.clearSelection()
this.$set(this, "batchEditUserStateVisible", false)
this.$set(this, "checkUsers", [])
} else {
this.$message.error(resp.msg)
}
}).finally(() => {
this.$set(this, "batchEditLoading", false)
})
},
doUnitMove() {
if (!this.unitMoveFormData.unitId) {
this.$message.warning("请选择新单位!")
return
}
const params = {
unitId: this.unitMoveFormData.unitId,
userIds: JSON.stringify(this.checkUsers.map(v => v.id))
}
this.$set(this, "batchEditLoading", true)
this.$axios.post("/platform/personGovernance/personChange/unitMove", params)
.then((resp) => {
if (resp.code === 0) {
this.$message.success(resp.msg)
this.pageData()
this.$refs.table.clearSelection()
this.$set(this, "unitMoveVisible", false)
this.$set(this, "checkUsers", [])
} else {
this.$message.error(resp.msg)
}
}).finally(() => {
this.$set(this, "batchEditLoading", false)
})
},
},
computed: {
showColumns() {
return this.tableColumns.filter(c => this.checkedFields.includes(c.prop));
}
},
mounted() {
this.$set(this, "checkedFields", this.tableColumns.filter(c => c.checked !== 0).map(c => c.prop));
},
created() {
// 单位、字典和列表请求全部结束后再显示页面,避免初始化过程中的布局跳动。
$.when(this.initData(), this.pageData()).always(() => {
this.showPage()
})
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,414 @@
<!--# layout("/layouts/platform.html"){ #-->
<link rel="stylesheet" href="${base!}/assets/platform/css/personGovernance.css?v=1.0.22" />
<div
id="app"
v-cloak
class="person-governance-list-page person-governance-page-transition"
:class="{'is-ready':pageReady}"
>
<el-card shadow="never" class="person-governance-search-card">
<el-tabs v-model="activeTab" @tab-click="switchTab">
<el-tab-pane label="待确认" name="PENDING"></el-tab-pane>
<el-tab-pane label="已确认" name="PROCESSED"></el-tab-pane>
</el-tabs>
<el-alert
v-if="activeTab==='PENDING'"
title="以下人员在第三方来源中已不存在,但仍保留在本系统,请确认处理。"
type="warning"
:closable="false"
></el-alert>
<el-form class="mt20" :inline="true" :model="pageForm">
<el-form-item>
<el-input
v-model="pageForm.searchKeyword"
clearable
placeholder="请输入内容"
@keyup.enter.native="doSearch"
>
<el-select v-model="pageForm.searchName" slot="prepend" style="width: 100px">
<el-option label="姓名" value="personName"></el-option>
<el-option label="人员编号" value="personNo"></el-option>
</el-select>
</el-input>
</el-form-item>
<el-form-item label="所属部门">
<el-select v-model="pageForm.unitId" clearable filterable>
<el-option v-for="u in units" :key="u.id" :label="u.name" :value="u.id"></el-option>
</el-select>
</el-form-item>
<el-form-item v-if="activeTab==='PROCESSED'" label="处理结果">
<el-select v-model="pageForm.handleResult" clearable filterable>
<el-option label="已停用" value="DISABLE"></el-option>
<el-option label="继续保留" value="KEEP"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="doSearch">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" class="person-governance-list-card">
<div class="person-governance-table-wrap">
<el-table
:key="'person-exception-table-' + activeTab"
ref="dataTable"
:data="tableData"
v-loading="tableLoading"
height="100%"
>
<el-table-column v-if="activeTab==='PENDING'" type="selection" width="45"></el-table-column>
<el-table-column prop="personNo" label="工号"></el-table-column>
<el-table-column prop="personName" label="名称"></el-table-column>
<el-table-column prop="unitName" label="单位"></el-table-column>
<el-table-column label="异常类型"><template>第三方不存在</template></el-table-column>
<el-table-column prop="discoverTime" label="发现时间" width="180"></el-table-column>
<el-table-column label="当前状态">
<template v-slot="{row}">{{row.status==='PENDING'?'待确认':'已确认'}}</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template v-slot="{row}">
<el-button
v-if="activeTab==='PENDING'"
type="primary"
plain
size="small"
@click="view(row, $event)"
>确认</el-button>
<el-button v-else type="text" @click="view(row)">查看</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="person-governance-list-footer">
<!--#include("/layouts/pagination.html"){}#-->
</div>
</el-card>
<el-drawer
:title="activeTab==='PENDING'?'异常人员确认':'异常处理详情'"
:visible.sync="drawerVisible"
:append-to-body="true"
size="85%"
custom-class="person-governance-drawer"
>
<div class="person-exception-handle-page" v-loading="detailLoading">
<el-form
v-if="detail.exception"
:model="handleForm"
label-width="0"
label-suffix=":"
class="person-exception-handle-form"
>
<template v-if="detail.user">
<vi-title title="人员信息"></vi-title>
<el-descriptions :column="3" border>
<el-descriptions-item label="工号"><el-input :value="detail.user.loginname" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="姓名"><el-input :value="detail.user.username" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="性别"><el-input :value="detail.user.sex" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="民族"><el-input :value="detail.user.nation" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="出生日期"><el-input :value="detail.user.birthday" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="政治面貌"><el-input :value="detail.user.political" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="学历"><el-input :value="detail.user.education" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="学位"><el-input :value="detail.user.academicDegree" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="党政职务"><el-input :value="detail.user.position" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="工作单位"><el-input :value="detail.user.unitName" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="所属工会"><el-input :value="detail.user.unionName" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="本地人员状态"><el-input :value="detail.user.disabled ? '已停用' : '在用'" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="身份证号码"><el-input :value="detail.user.idcard" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="联系电话"><el-input :value="detail.user.mobile" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="电子邮箱"><el-input :value="detail.user.email" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="在职状态">
<el-select
v-if="activeTab==='PENDING'"
v-model="handleForm.userState"
clearable
filterable
size="small"
style="width: 100%"
>
<el-option
v-for="item in userStateOptions"
:key="item.code"
:label="item.name"
:value="item.code"
></el-option>
</el-select>
<el-input v-else :value="detail.user.userState" readonly size="small"></el-input>
</el-descriptions-item>
<el-descriptions-item label="人员类型">
<el-select
v-if="activeTab==='PENDING'"
v-model="handleForm.personType"
clearable
filterable
size="small"
style="width: 100%"
>
<el-option
v-for="item in personTypeOptions"
:key="item.code"
:label="item.name"
:value="item.code"
></el-option>
</el-select>
<el-input v-else :value="detail.user.personType" readonly size="small"></el-input>
</el-descriptions-item>
<el-descriptions-item label="会员状态">
<el-select
v-if="activeTab==='PENDING'"
v-model="handleForm.member"
clearable
filterable
size="small"
style="width: 100%"
>
<el-option label="会员" :value="1"></el-option>
<el-option label="非会员" :value="0"></el-option>
</el-select>
<el-input v-else :value="detail.user.member == 1 ? '会员' : '非会员'" readonly size="small"></el-input>
</el-descriptions-item>
<el-descriptions-item label="福利会员状态">
<el-select
v-if="activeTab==='PENDING'"
v-model="handleForm.welfareMember"
clearable
filterable
size="small"
style="width: 100%"
>
<el-option label="福利会员" :value="1"></el-option>
<el-option label="非福利会员" :value="0"></el-option>
</el-select>
<el-input v-else :value="detail.user.welfareMember == 1 ? '福利会员' : '非福利会员'" readonly size="small"></el-input>
</el-descriptions-item>
</el-descriptions>
</template>
<vi-title :class="{'mt20': detail.user}" title="异常信息"></vi-title>
<el-descriptions :column="3" border>
<el-descriptions-item label="人员姓名"><el-input :value="detail.exception.personName" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="人员编号"><el-input :value="detail.exception.personNo" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="所属部门"><el-input :value="detail.exception.unitName" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="异常类型"><el-input value="第三方不存在" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="最后同步时间"><el-input :value="detail.exception.lastSyncTime" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="异常发现时间"><el-input :value="detail.exception.discoverTime" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="异常原因" :span="3"><el-input :value="detail.exception.exceptionReason" readonly size="small"></el-input></el-descriptions-item>
</el-descriptions>
<template v-if="activeTab==='PENDING'">
<vi-title class="mt20" title="处理信息"></vi-title>
<el-descriptions :column="3" border>
<el-descriptions-item label="处理建议" :span="1">
<el-radio-group v-model="handleForm.result" size="small">
<el-radio border label="DISABLE">建议停用</el-radio>
<el-radio border label="KEEP">继续保留</el-radio>
</el-radio-group>
</el-descriptions-item>
<el-descriptions-item label="确认建议" :span="2">
<el-input v-model="handleForm.opinion" type="textarea" :rows="4" maxlength="500" show-word-limit></el-input>
</el-descriptions-item>
</el-descriptions>
<div class="person-exception-handle-actions">
<el-button @click="closeDrawer">暂存</el-button>
<el-button type="primary" :loading="formLoading" @click="submitHandle">确认处理</el-button>
</div>
</template>
<template v-else>
<vi-title class="mt20" title="处理结果"></vi-title>
<el-descriptions :column="3" border>
<el-descriptions-item label="处理建议"><el-input :value="detail.exception.handleResult==='DISABLE'?'建议停用':'继续保留'" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="处理人"><el-input :value="detail.exception.handlerName" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="处理时间"><el-input :value="detail.exception.handleTime" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="确认建议" :span="3"><el-input :value="detail.exception.handleOpinion" type="textarea" :rows="3" readonly></el-input></el-descriptions-item>
</el-descriptions>
</template>
</el-form>
</div>
</el-drawer>
</div>
<style>
.person-exception-handle-page {
box-sizing: border-box;
min-height: 240px;
padding: 0 24px 24px;
}
.person-exception-handle-form .el-form-item {
margin-bottom: 0;
}
.person-exception-handle-form .el-input__inner,
.person-exception-handle-form .el-textarea__inner {
background-color: #fff;
color: #606266;
}
.person-exception-handle-actions {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
</style>
<script>
const vue = new Vue({
el: '#app',
mixins: [initTableMixins],
data() {
return {
pageReady: false,
activeTab: 'PENDING',
units: [],
personTypeOptions: [],
userStateOptions: [],
drawerVisible: false,
detailLoading: false,
detail: {},
currentIds: [],
handleForm: {
result: 'DISABLE',
opinion: '',
personType: '',
userState: '',
member: null,
welfareMember: null,
},
pageForm: {
status: 'PENDING',
searchName: 'personName',
searchKeyword: '',
unitId: '',
handleResult: '',
pageNumber: 1,
pageSize: 10,
totalCount: 0,
},
}
},
methods: {
pageData() {
this.$set(this.pageForm, 'status', this.activeTab)
this.$set(this, 'tableLoading', true)
return $.post(loc() + '/pageData', this.pageForm)
.then((res) => {
if (res.code === 0) {
this.$set(this, 'tableData', res.data.list)
this.$set(this.pageForm, 'totalCount', res.data.totalCount)
} else this.notifyWarning(res.msg)
})
.always(() => {
this.$set(this, 'tableLoading', false)
})
},
switchTab() {
this.$set(this.pageForm, 'pageNumber', 1)
this.pageData()
},
resetSearch() {
this.$set(this.pageForm, 'searchName', 'personName')
this.$set(this.pageForm, 'searchKeyword', '')
this.$set(this.pageForm, 'unitId', '')
this.$set(this.pageForm, 'handleResult', '')
this.doSearch()
},
view(row, event) {
// 点击确认后立即释放按钮焦点,避免 plain 按钮持续显示实心蓝色背景。
if (event && event.currentTarget && event.currentTarget.blur) {
event.currentTarget.blur()
}
this.$set(this, 'detail', {})
this.$set(this, 'drawerVisible', true)
this.$set(this, 'detailLoading', true)
$.post(loc() + '/detail', { id: row.id })
.then((res) => {
if (res.code === 0) {
const user = res.data.user || {}
this.$set(this, 'detail', res.data)
this.$set(this, 'currentIds', [row.id])
this.$set(this, 'handleForm', {
result: 'DISABLE',
opinion: '',
personType: user.personType || '',
userState: user.userState || '',
member: user.member === null || typeof user.member === 'undefined'
? null
: Number(user.member),
welfareMember: user.welfareMember === null || typeof user.welfareMember === 'undefined'
? null
: Number(user.welfareMember),
})
} else {
this.notifyWarning(res.msg)
this.$set(this, 'drawerVisible', false)
}
})
.always(() => {
this.$set(this, 'detailLoading', false)
})
},
closeDrawer() {
this.$set(this, 'drawerVisible', false)
},
submitHandle() {
if (!this.handleForm.opinion) {
this.notifyWarning('请填写确认建议')
return
}
if (this.handleForm.member === null || this.handleForm.member === '') {
this.notifyWarning('请选择会员状态')
return
}
if (this.handleForm.welfareMember === null || this.handleForm.welfareMember === '') {
this.notifyWarning('请选择福利会员状态')
return
}
this.$set(this, 'formLoading', true)
$.post(loc() + '/handle', {
ids: JSON.stringify(this.currentIds),
handleResult: this.handleForm.result,
opinion: this.handleForm.opinion,
personType: this.handleForm.personType,
userState: this.handleForm.userState,
member: this.handleForm.member,
welfareMember: this.handleForm.welfareMember,
})
.then((res) => {
if (res.code === 0) {
this.notifySuccess('处理完成')
this.$set(this, 'drawerVisible', false)
this.pageData()
} else this.notifyWarning(res.msg)
})
.always(() => {
this.$set(this, 'formLoading', false)
})
},
showPage() {
this.$nextTick(() => {
if (this.$refs.dataTable && this.$refs.dataTable.doLayout) {
this.$refs.dataTable.doLayout()
}
window.requestAnimationFrame(() => {
this.$set(this, 'pageReady', true)
})
})
},
},
created() {
// 当前项目通过 businessTool 获取单位和人员字典,避免依赖原项目 v.js 中不存在的全局方法。
const unitRequest = this.$businessTool.listUnit().then((rows) => {
this.$set(this, 'units', rows)
})
const personTypeRequest = this.$businessTool.getDictOptions('USER_PERSON_TYPE').then((rows) => {
this.$set(this, 'personTypeOptions', rows)
})
const userStateRequest = this.$businessTool.getDictOptions('USER_STATE').then((rows) => {
this.$set(this, 'userStateOptions', rows)
})
$.when(this.pageData(), unitRequest, personTypeRequest, userStateRequest).always(() => {
this.showPage()
})
},
})
</script>
<!--# } #-->
@@ -1034,7 +1034,7 @@ const ACTIVITY_CULTURE_APPLY_ACTIVITY = {
this.clubOptions.map((v) => { this.clubOptions.map((v) => {
v.name = v.clubName v.name = v.clubName
}) })
const units = await this.$businessTool.listUnit() const units = await this.$businessTool.listUnit(null, 3)
this.unitOptions = this.clubOptions.concat(units) this.unitOptions = this.clubOptions.concat(units)
} finally { } finally {
this.formLoading = false this.formLoading = false
@@ -96,7 +96,7 @@ layout("/layouts/platform.html"){
placeholder="请选择所属工会" style="width: 100%" v-model="pageForm.unionId"> placeholder="请选择所属工会" style="width: 100%" v-model="pageForm.unionId">
<el-option <el-option
:key="item.id" :key="item.id"
:label="item.unionname" :label="item.name"
:value="item.id" :value="item.id"
v-for="item in unionOptions"> v-for="item in unionOptions">
</el-option> </el-option>
@@ -24,7 +24,7 @@ layout("/layouts/platform.html"){
<el-option <el-option
v-for="item in unionList" v-for="item in unionList"
:key="item.id" :key="item.id"
:label="item.unionname" :label="item.name"
:value="item.id"> :value="item.id">
</el-option> </el-option>
</el-select> </el-select>
@@ -51,7 +51,7 @@ layout("/layouts/platform.html"){
placeholder="请选择所属工会" v-model="pageForm.unionId"> placeholder="请选择所属工会" v-model="pageForm.unionId">
<el-option <el-option
:key="item.id" :key="item.id"
:label="item.unionname" :label="item.name"
:value="item.id" :value="item.id"
v-for="item in unionOptions"> v-for="item in unionOptions">
</el-option> </el-option>
@@ -173,7 +173,7 @@ layout("/layouts/platform.html"){
</el-form> </el-form>
<div style="text-align: right"> <div style="text-align: right">
<el-button @click="$refs.guava.index()" class="ml10">返回</el-button> <el-button @click="$refs.guava.index()" class="ml10">返回</el-button>
<el-button @click="saveRaffle" class="ml10" type="primary">提交</el-button> <el-button @click="saveRaffle" :loading="formLoading" class="ml10" type="primary">提交</el-button>
</div> </div>
</el-card> </el-card>
</template> </template>
@@ -211,7 +211,7 @@ layout("/layouts/platform.html"){
<table-tool :app="this" label="中奖人员列表"> <table-tool :app="this" label="中奖人员列表">
<el-button @click="exportAwardExcel" icon="el-icon-download" type="primary" size="small">导出中奖人员</el-button> <el-button @click="exportAwardExcel" icon="el-icon-download" type="primary" size="small">导出中奖人员</el-button>
</table-tool> </table-tool>
<el-table :data="awardTableData"> <el-table :data="awardTableData" v-loading="tableLoading">
<el-table-column :index="indexMethod" align="center" header-align="center" label="排名" type="index" <el-table-column :index="indexMethod" align="center" header-align="center" label="排名" type="index"
width="80px"></el-table-column> width="80px"></el-table-column>
<el-table-column align="center" header-align="center" label="工号" prop="loginName"></el-table-column> <el-table-column align="center" header-align="center" label="工号" prop="loginName"></el-table-column>
@@ -219,6 +219,7 @@ layout("/layouts/platform.html"){
<el-table-column align="center" header-align="center" label="分工会" prop="unionName"></el-table-column> <el-table-column align="center" header-align="center" label="分工会" prop="unionName"></el-table-column>
<el-table-column align="center" header-align="center" label="单位" prop="unitName"></el-table-column> <el-table-column align="center" header-align="center" label="单位" prop="unitName"></el-table-column>
<el-table-column align="center" header-align="center" label="奖项名称" prop="awardName"></el-table-column> <el-table-column align="center" header-align="center" label="奖项名称" prop="awardName"></el-table-column>
<el-table-column align="center" header-align="center" label="是否抽奖" prop="isRaffleText"></el-table-column>
</el-table> </el-table>
</el-card> </el-card>
</template> </template>
@@ -300,32 +301,52 @@ layout("/layouts/platform.html"){
this.awardPageData() this.awardPageData()
}) })
}, },
/**
* 使用当前活动、奖项和姓名工号筛选后台完整中奖名单,手机端抽奖状态仅作为展示字段。
*/
awardPageData() { awardPageData() {
this.awardPageForm.activityId = this.pageForm.activityId this.$set(this.awardPageForm, 'activityId', this.pageForm.activityId)
this.$axios.post('/platform/fitnessWalk/stepWining/prizeUsers', this.awardPageForm).then((resp) => { this.$set(this, 'awardTableData', [])
this.$set(this, 'tableLoading', true)
this.$axios.post('/platform/fitnessWalk/stepRaffle/awardUsers', this.awardPageForm)
.then((resp) => {
if (resp.code === 0) { if (resp.code === 0) {
this.awardTableData = resp.data this.$set(this, 'awardTableData', resp.data)
} }
}) })
.finally(() => {
this.$set(this, 'tableLoading', false)
})
}, },
exportAwardExcel(){ exportAwardExcel(){
const {searchKeyword,activityId,lotteryTime} = this.awardPageForm const {searchKeyword,activityId,lotteryTime} = this.awardPageForm
window.open( loc() + '/exportAwardExcel?searchKeyword=' + searchKeyword + window.open( loc() + '/exportAwardExcel?searchKeyword=' + searchKeyword +
'&activityId=' + activityId +'&lotteryTime=' + lotteryTime) '&activityId=' + activityId +'&lotteryTime=' + lotteryTime)
}, },
/**
* 提交当前活动的奖项规则,展示后端返回的实际抽取结果;提交期间避免重复触发重抽。
*/
saveRaffle() { saveRaffle() {
console.log(this.formData) if (this.formLoading) {
return
}
const formData = Object.assign({}, this.formData) const formData = Object.assign({}, this.formData)
formData.activityId = this.pageForm.activityId this.$set(formData, 'activityId', this.pageForm.activityId)
formData.manualLotteryRules = JSON.stringify(this.formData.manualLotteryRules) this.$set(formData, 'manualLotteryRules', JSON.stringify(this.formData.manualLotteryRules))
this.$axios.post('/platform/fitnessWalk/stepRaffle/saveRaffle', formData).then((resp) => { this.$set(this, 'formLoading', true)
this.$axios.post('/platform/fitnessWalk/stepRaffle/saveRaffle', formData)
.then((resp) => {
if (resp.code === 0) { if (resp.code === 0) {
this.$message.success('保存成功') // 多个奖项不足时展示完整汇总,并延长提示时间方便核对实际抽取人数。
this.$message.success({message: resp.msg || '抽奖完成', duration: 10000, showClose: true})
this.$refs.guava.index() this.$refs.guava.index()
} else { } else {
this.$message.warning(resp.msg) this.$message.warning(resp.msg)
} }
}) })
.finally(() => {
this.$set(this, 'formLoading', false)
})
}, },
exportExcel(){ exportExcel(){
const {searchKeyword,activityId,unionId,unitId} = this.pageForm const {searchKeyword,activityId,unionId,unitId} = this.pageForm
@@ -56,7 +56,7 @@ layout("/layouts/platform.html"){
placeholder="请选择所属工会" v-model="pageForm.unionId"> placeholder="请选择所属工会" v-model="pageForm.unionId">
<el-option <el-option
:key="item.id" :key="item.id"
:label="item.unionname" :label="item.name"
:value="item.id" :value="item.id"
v-for="item in unionOptions"> v-for="item in unionOptions">
</el-option> </el-option>
@@ -139,6 +139,8 @@ layout("/layouts/platform.html"){
{label: '分工会', prop: 'unionname', sortable: true}, {label: '分工会', prop: 'unionname', sortable: true},
{label: '单位', prop: 'unitname'}, {label: '单位', prop: 'unitname'},
{label: '步数', prop: 'total_steps', sortable: true}, {label: '步数', prop: 'total_steps', sortable: true},
// 今日榜和总榜均展示所选活动期间累计的达标天数。
{label: '达标天数', prop: 'standardsDays', sortable: true},
], ],
} }
}, },
@@ -61,7 +61,7 @@ layout("/layouts/platform.html"){
placeholder="请选择所属工会" v-model="pageForm.unionId"> placeholder="请选择所属工会" v-model="pageForm.unionId">
<el-option <el-option
:key="item.id" :key="item.id"
:label="item.unionname" :label="item.name"
:value="item.id" :value="item.id"
v-for="item in unionOptions"> v-for="item in unionOptions">
</el-option> </el-option>
@@ -0,0 +1,286 @@
<!--#
layout("/layouts/platform.html"){
#-->
<style>
/* 当前平台顶部为 64px,内容区上下留白共 20px;限制页面高度,避免数据行撑高外层。 */
.step-union-statistics {
height: calc(100vh - 84px);
min-width: 0;
overflow: hidden;
}
.step-union-statistics > .guava-main-content,
.step-union-statistics > .guava-main-content > .transition-item {
height: 100%;
min-height: 0;
overflow: hidden;
}
.step-union-statistics > .guava-main-content > .transition-item {
display: flex;
flex-direction: column;
}
.step-union-statistics .statistics-search-card,
.step-union-statistics .ele-table-tool {
flex-shrink: 0;
}
/* 表格卡片和表体允许缩小到剩余空间,筛选区与标题保持固定,由 el-table 内部滚动。 */
.step-union-statistics .statistics-table-card {
flex: 1;
min-height: 0;
min-width: 0;
}
.step-union-statistics .statistics-table-card > .el-card__body {
height: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
}
.step-union-statistics .statistics-table-container {
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.step-union-statistics .el-table__body-wrapper {
overscroll-behavior: contain;
}
</style>
<div id="app" class="step-union-statistics" v-cloak>
<guava>
<template>
<el-card class="statistics-search-card" shadow="never">
<div class="search">
<div class="search-item">
<div class="search-item-label">年份:</div>
<div class="search-item-option">
<el-date-picker
v-model="pageForm.year"
type="year"
value-format="yyyy"
placeholder="选择年"
style="width: 100%"
:clearable="false"
:picker-options="pickerOptions"
:disabled="formLoading"
@change="getActivityOptions">
</el-date-picker>
</div>
</div>
<div class="search-item">
<div class="search-item-label">活动:</div>
<div class="search-item-option">
<el-select
v-model="pageForm.activityIds"
multiple
collapse-tags
filterable
clearable
:disabled="formLoading"
placeholder="不选择则统计所选年份全部活动"
style="width: 100%"
@change="queryActivityStatistics">
<el-option
v-for="item in activityOptions"
:key="item.value"
:label="item.label"
:value="item.value">
</el-option>
</el-select>
</div>
</div>
<div class="search-item">
<div class="search-item-label">分工会:</div>
<div class="search-item-option">
<el-select
v-model="pageForm.unionId"
clearable
filterable
:disabled="formLoading"
placeholder="请选择分工会"
style="width: 100%"
@change="queryActivityStatistics">
<el-option
v-for="item in unionOptions"
:key="item.id"
:label="item.name"
:value="item.id">
</el-option>
</el-select>
</div>
</div>
<div class="search-query">
<el-button
@click="queryActivityStatistics"
:disabled="formLoading"
:loading="tableLoading"
icon="el-icon-search"
type="primary">
</el-button>
</div>
</div>
<el-alert
class="mt10"
title="各活动按自己的达标规则统计;分工会人数为当前会员人数。平均达标率为本次统计活动达标率的平均值。"
type="info"
:closable="false"
show-icon>
</el-alert>
</el-card>
<el-card class="mt10 statistics-table-card" shadow="never">
<table-tool label="分工会活动达标统计">
<span>统计活动:{{ statisticsActivities.length }} 个</span>
</table-tool>
<div class="statistics-table-container">
<el-table :data="tableData" :size="tableSize" v-loading="tableLoading" height="100%" border>
<el-table-column :index="indexMethod" label="序号" type="index" width="70"
align="center" header-align="center" fixed="left"></el-table-column>
<el-table-column label="分工会" prop="unionName" min-width="180"
align="center" header-align="center" fixed="left"></el-table-column>
<el-table-column label="分工会人数" prop="memberNum" width="120"
align="center" header-align="center"></el-table-column>
<el-table-column
v-for="activity in statisticsActivities"
:key="activity.value"
:label="activity.label"
align="center"
header-align="center">
<template slot="header">
<el-tooltip :content="activity.ruleLabel" placement="top">
<span>{{ activity.label }}</span>
</el-tooltip>
</template>
<el-table-column label="达标人数" width="110" align="center" header-align="center">
<template slot-scope="{row}">
{{ row.activityStatistics[activity.value].qualifiedNum }}
</template>
</el-table-column>
<el-table-column label="达标率" width="110" align="center" header-align="center">
<template slot-scope="{row}">
{{ row.activityStatistics[activity.value].qualifiedRate }}
</template>
</el-table-column>
</el-table-column>
<el-table-column label="平均达标率" prop="averageQualifiedRate" width="130"
align="center" header-align="center" fixed="right"></el-table-column>
</el-table>
</div>
</el-card>
</template>
</guava>
</div>
<script nonce="${cspNonce!}">
const vue = new Vue({
el: '#app',
mixins: [initTableMixins],
data() {
return {
pageForm: {
year: new Date().getFullYear() + '',
activityIds: [],
unionId: ''
},
pickerOptions: {
disabledDate(time) {
return time.getFullYear() < 1970 || time.getFullYear() > new Date().getFullYear()
}
},
unionOptions: [],
activityOptions: [],
statisticsActivities: [],
statisticsRequestVersion: 0
}
},
methods: {
/**
* 切换年份后清除上一年的所选活动,加载当前年份活动并自动查询全部活动。
*/
getActivityOptions() {
this.$set(this.pageForm, 'activityIds', [])
this.$set(this, 'activityOptions', [])
this.$set(this, 'statisticsActivities', [])
this.$set(this, 'tableData', [])
// 使上一轮统计失效,避免年份切换期间旧请求覆盖新表格。
this.$set(this, 'statisticsRequestVersion', this.statisticsRequestVersion + 1)
this.$set(this, 'tableLoading', false)
this.$set(this, 'formLoading', true)
this.$axios.post(loc() + '/activityOptions', {year: this.pageForm.year})
.then((res) => {
if (res.code === 0) {
this.$set(this, 'activityOptions', res.data || [])
this.queryActivityStatistics()
} else {
this.$message.error(res.msg)
}
})
.catch(() => {
// 请求错误由全局 axios 提示,清除选项及统计结果,避免继续选择失效活动。
this.$set(this, 'activityOptions', [])
this.$set(this, 'statisticsActivities', [])
this.$set(this, 'tableData', [])
})
.finally(() => {
this.$set(this, 'formLoading', false)
})
},
/**
* 查询完整分工会活动矩阵,表头以后台实际统计活动为准,避免活动列和结果范围不一致。
* 活动 ID 以 JSON 数组提交;未选择时为空数组,选择后只提交所选 ID。
* 此页面返回活动表头及全部分工会汇总,复用 mixin 的表格能力,不重复编写分页方法。
*/
queryActivityStatistics() {
const requestVersion = this.statisticsRequestVersion + 1
this.$set(this, 'statisticsRequestVersion', requestVersion)
this.$set(this, 'tableLoading', true)
// 新筛选条件生效时清除旧结果,接口失败时也不会保留上一组活动的统计。
this.$set(this, 'tableData', [])
this.$set(this, 'statisticsActivities', [])
const params = {
year: this.pageForm.year,
activityIds: JSON.stringify(this.pageForm.activityIds),
unionId: this.pageForm.unionId
}
this.$axios.post(loc() + '/pageData', params)
.then((res) => {
// 快速切换活动或工会时,只展示最后一次请求对应的数据。
if (requestVersion !== this.statisticsRequestVersion) {
return
}
if (res.code === 0) {
this.$set(this, 'statisticsActivities', res.data.activities || [])
this.$set(this, 'tableData', res.data.list || [])
} else {
this.$message.error(res.msg)
}
})
.catch(() => {
// 只清除本轮失败查询,较早请求失败时保留后来成功查询的结果。
if (requestVersion === this.statisticsRequestVersion) {
this.$set(this, 'statisticsActivities', [])
this.$set(this, 'tableData', [])
}
})
.finally(() => {
if (requestVersion === this.statisticsRequestVersion) {
this.$set(this, 'tableLoading', false)
}
})
}
},
created() {
this.$businessTool.listUnion().then((res) => {
this.$set(this, 'unionOptions', res)
this.getActivityOptions()
})
}
})
</script>
<!--#
}
#-->
@@ -69,7 +69,7 @@ layout("/layouts/platform.html"){
placeholder="请选择所属工会" v-model="pageForm.unionId"> placeholder="请选择所属工会" v-model="pageForm.unionId">
<el-option <el-option
:key="item.id" :key="item.id"
:label="item.unionname" :label="item.name"
:value="item.id" :value="item.id"
v-for="item in unionOptions"> v-for="item in unionOptions">
</el-option> </el-option>
@@ -64,7 +64,7 @@ layout("/layouts/platform.html"){
v-model="pageForm.unionId"> v-model="pageForm.unionId">
<el-option <el-option
:key="item.id" :key="item.id"
:label="item.unionname" :label="item.name"
:value="item.id" :value="item.id"
v-for="item in unionOptions"></el-option> v-for="item in unionOptions"></el-option>
</el-select> </el-select>
@@ -1285,7 +1285,7 @@
this.clubOptions.map((v) => { this.clubOptions.map((v) => {
v.name = v.clubName v.name = v.clubName
}) })
const units = await this.$businessTool.listUnit() const units = await this.$businessTool.listUnit(null, 3)
this.unitOptions = this.clubOptions.concat(units) this.unitOptions = this.clubOptions.concat(units)
} }
} }
@@ -5,7 +5,7 @@ layout("/layouts/platform.html"){
<style></style> <style></style>
<div id="app" v-cloak> <div id="app" v-cloak>
<guava ref="guava"> <guava ref="guava" v-model="guavaIndex">
<template> <template>
<el-card shadow="never"> <el-card shadow="never">
<search @search="doSearch"> <search @search="doSearch">
@@ -386,6 +386,7 @@ layout("/layouts/platform.html"){
formOptionsPromise: null, formOptionsPromise: null,
unitOptions: [], unitOptions: [],
clubOptions: [], clubOptions: [],
guavaIndex: window.location.pathname.toLowerCase() === "/platform/activity/workscollection/new" ? "edit" : "index",
} }
}, },
components: { components: {
@@ -409,6 +410,7 @@ layout("/layouts/platform.html"){
}, },
afterSaveSuccess() { afterSaveSuccess() {
this.formVisible = false this.formVisible = false
this.guavaIndex = "index"
if (this.isNewPage()) { if (this.isNewPage()) {
this.backToManage() this.backToManage()
} else { } else {
@@ -416,14 +418,14 @@ layout("/layouts/platform.html"){
this.$refs.guava.index() this.$refs.guava.index()
} }
}, },
initNewPage() { async initNewPage() {
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
const mode = params.get("mode") const mode = params.get("mode")
const id = params.get("id") const id = params.get("id")
if (mode === "edit" && id) { if (mode === "edit" && id) {
this.openEdit(id, true) await this.openEdit(id, true)
} else { } else {
this.openAdd() await this.openAdd()
} }
}, },
async getActivityGroup() { async getActivityGroup() {
@@ -454,7 +456,7 @@ layout("/layouts/platform.html"){
this.formOptionsPromise = Promise.all([ this.formOptionsPromise = Promise.all([
this.getActivityGroup(), this.getActivityGroup(),
this.getClubsByRole(), this.getClubsByRole(),
this.$businessTool.listUnit() this.$businessTool.listUnit(null, 3)
]).then(([activityGroupList, clubOptions, units]) => { ]).then(([activityGroupList, clubOptions, units]) => {
this.activityGroupList = activityGroupList this.activityGroupList = activityGroupList
this.clubOptions = clubOptions this.clubOptions = clubOptions
@@ -501,7 +503,7 @@ layout("/layouts/platform.html"){
this.copyTemplateName = "" this.copyTemplateName = ""
await this.loadFormOptions() await this.loadFormOptions()
this.formVisible = true this.formVisible = true
this.$refs.guava.edit() this.guavaIndex = "edit"
this.$nextTick(() => { this.$nextTick(() => {
this.formData = { this.formData = {
nbCount: 0, nbCount: 0,
@@ -574,10 +576,14 @@ layout("/layouts/platform.html"){
}, },
async openEdit(id, copyTemplateMode = false) { async openEdit(id, copyTemplateMode = false) {
this.copyTemplateMode = !!copyTemplateMode this.copyTemplateMode = !!copyTemplateMode
const [resp] = await Promise.all([ this.loadFormOptions().catch(() => {
this.$axios.post("/platform/activity/worksCollection/manage/findOne", {id}), this.$message.warning("下拉数据加载失败,请刷新后重试")
this.loadFormOptions() })
]) const resp = await this.$axios.post("/platform/activity/worksCollection/manage/findOne", {id})
if (!resp || resp.code !== 0 || !resp.data) {
this.$message.warning(resp && resp.msg ? resp.msg : "活动数据不存在")
return
}
this.formVisible = true this.formVisible = true
this.formData = resp.data this.formData = resp.data
if (this.copyTemplateMode) { if (this.copyTemplateMode) {
@@ -592,12 +598,11 @@ layout("/layouts/platform.html"){
// this.formData.nbTime = [this.formData.nbStartDateTime, this.formData.nbEndDateTime] // this.formData.nbTime = [this.formData.nbStartDateTime, this.formData.nbEndDateTime]
this.$set(this.formData, "nbTime", [this.formData.nbStartDateTime, this.formData.nbEndDateTime]) this.$set(this.formData, "nbTime", [this.formData.nbStartDateTime, this.formData.nbEndDateTime])
} }
this.$nextTick(() => { this.guavaIndex = "edit"
this.$refs.guava.edit()
})
}, },
close() { close() {
this.formVisible = false this.formVisible = false
this.guavaIndex = "index"
if (this.isNewPage()) { if (this.isNewPage()) {
this.backToManage() this.backToManage()
} else { } else {
@@ -734,7 +739,6 @@ layout("/layouts/platform.html"){
}, },
async created() { async created() {
if (this.isNewPage()) { if (this.isNewPage()) {
await this.loadFormOptions()
this.$nextTick(() => { this.$nextTick(() => {
this.initNewPage() this.initNewPage()
}) })

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