diff --git a/src/main/java/com/budwk/app/base/constant/RedisConstant.java b/src/main/java/com/budwk/app/base/constant/RedisConstant.java index 6920246e..a539718e 100644 --- a/src/main/java/com/budwk/app/base/constant/RedisConstant.java +++ b/src/main/java/com/budwk/app/base/constant/RedisConstant.java @@ -72,4 +72,10 @@ public class RedisConstant { * 数据接口锁前缀 */ public static final String RSA_KEY_PREFIX = PLATFORM_REDIS_PREFIX + "rsa:private:"; + + /** + * 人员治理第三方数据查询结果缓存前缀。 + */ + public static final String REDIS_KEY_PERSON_API_DATA_QUERY_RESULT = + PLATFORM_REDIS_PREFIX + "personGovernance:dataQuery:"; } diff --git a/src/main/java/com/budwk/app/sys/models/Sys_user.java b/src/main/java/com/budwk/app/sys/models/Sys_user.java index edc832a7..28b4e43e 100644 --- a/src/main/java/com/budwk/app/sys/models/Sys_user.java +++ b/src/main/java/com/budwk/app/sys/models/Sys_user.java @@ -459,4 +459,13 @@ public class Sys_user extends BaseModel implements Serializable { @Comment("基金会员退出时间") @ColDefine(type = ColType.DATETIME) private Date aidFundMemberQuitTime; + + /** + * 人员数据治理同步保护标识:1 表示人工确认后的数据,自动同步时不得覆盖。 + */ + @Column + @Comment("人员数据治理同步保护标识") + @ColDefine(type = ColType.INT) + @Default(value = "0") + private Integer flag; } diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/controller/PersonApiConfigController.java b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/controller/PersonApiConfigController.java new file mode 100644 index 00000000..dd3340a7 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/controller/PersonApiConfigController.java @@ -0,0 +1,170 @@ +package com.budwk.app.zhgh.personGovernance.apiConfig.controller; + +import com.budwk.app.base.param.PageForm; +import com.budwk.app.zhgh.personGovernance.apiConfig.service.PersonApiConfigService; +import com.budwk.app.zhgh.personGovernance.apiConfig.service.PersonGovernanceSyncService; +import cn.dev33.satoken.annotation.SaCheckPermission; +import com.budwk.app.base.result.Result; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.lang.Strings; +import org.springframework.util.Assert; +import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; +import org.nutz.mvc.annotation.Param; + +/** 人员治理API配置页面与接口。 */ +@IocBean +@Ok("json:full") +@At("/platform/personGovernance/apiConfig") +public class PersonApiConfigController { + @Inject + private PersonApiConfigService personApiConfigService; + + @Inject + private PersonGovernanceSyncService personGovernanceSyncService; + + @At("") + @Ok("beetl:/platform/personGovernance/apiConfig/index.html") + @SaCheckPermission("data.person.governance.api") + public void index() { + } + + @At("/form") + @Ok("beetl:/platform/personGovernance/apiConfig/form.html") + @SaCheckPermission("data.person.governance.api") + public void form() { + } + + /** + * API配置分页查询。 + * + * @param pageForm pageNumber/pageSize为页码和每页数量 + * @param configName 配置名称,支持模糊查询 + * @param sourceType HTTP_API或SYSTEM_TABLE + * @param enabled true查询启用、false查询停用;未传、空字符串或空白表示全部 + * @return Pagination结构,list为记录,totalCount为总数 + */ + @At + @SaCheckPermission("data.person.governance.api") + public Result pageData(PageForm pageForm, @Param("configName") String configName, + @Param("sourceType") String sourceType, + @Param("enabled") String enabled) { + // Nutz会将空字符串直接转换为false;保留原始文本,避免未选择状态时误筛选停用记录。 + Boolean enabledFilter = null; + if (Strings.isNotBlank(enabled)) { + String value = enabled.trim(); + Assert.isTrue("true".equalsIgnoreCase(value) || "false".equalsIgnoreCase(value), "启用状态参数不正确"); + enabledFilter = Boolean.valueOf(value); + } + return Result.success(personApiConfigService.pageData(pageForm, configName, sourceType, enabledFilter)); + } + + @At + @SaCheckPermission("data.person.governance.api") + public Result detail(String id) { + return Result.success(personApiConfigService.detail(id)); + } + + /** + * 保存API配置。 + * + * @param data JSON对象;config为基本配置,mappings为字段映射数组,params为带请求范围的参数数组 + * @return PersonApiConfig,包含保存后的配置ID + */ + @At + @Aop(TransAop.READ_COMMITTED) + @SaCheckPermission("data.person.governance.api.edit") + public Result save(@Param("data") String data) { + return Result.success(personApiConfigService.saveConfig(data)); + } + + @At + @Aop(TransAop.READ_COMMITTED) + @SaCheckPermission("data.person.governance.api.delete") + public Result delete(String id) { + personApiConfigService.deleteConfig(id); + return Result.success(); + } + + @At + @SaCheckPermission("data.person.governance.api") + public Result tableNames() { + return Result.success(personApiConfigService.tableNames()); + } + + @At + @SaCheckPermission("data.person.governance.api") + public Result columns(String tableName) { + return Result.success(personApiConfigService.columns(tableName)); + } + + @At + @SaCheckPermission("data.person.governance.api.test") + /** + * 测试指定范围的第三方接口配置。 + * + * @param configId API配置ID + * @param requestScope TOKEN表示Token接口,DATA表示人员数据接口 + * @param testCondition 可选的临时查询条件,测试数据接口时追加到Query参数 + * @return NutMap,包含success、status、elapsed和data + */ + public Result test(String configId, String requestScope, + @Param("testCondition") String testCondition) { + return Result.success(personApiConfigService.test(configId, requestScope, testCondition)); + } + + /** + * 根据页面当前配置获取第三方Token,不要求先保存配置。 + * @param data JSON对象:config为请求配置,params为Header/Query/Body参数数组 + * @return 第三方响应解析后的JSON值,非JSON为原文;由ViReturn放入统一响应的data字段 + */ + @At(value = "/getThirdPartyToken", methods = "POST") + @SaCheckPermission("data.person.governance.api.test") + public Result getThirdPartyToken(@Param("data") String data) { + return Result.success(personApiConfigService.queryThirdParty(data, "TOKEN")); + } + + /** + * 根据页面当前配置获取第三方人员数据,不执行人员同步。 + * @param data JSON对象:config为请求配置,params为Header/Query/Body参数数组 + * @return 第三方响应解析后的JSON值,非JSON为原文;由ViReturn放入统一响应的data字段 + */ + @At(value = "/getThirdPartyData", methods = "POST") + @SaCheckPermission("data.person.governance.api.test") + public Result getThirdPartyData(@Param("data") String data) { + return Result.success(personApiConfigService.queryThirdParty(data, "DATA")); + } + + /** 保存当前接口请求及返回结果为可重复执行的测试用例。 */ + @At + @Aop(TransAop.READ_COMMITTED) + @SaCheckPermission("data.person.governance.api.edit") + public Result saveTestCase(@Param("data") String data) { + return Result.success(personApiConfigService.saveTestCase(data)); + } + + /** id为已保存API配置ID;只追加中间表,返回批次状态、插入数量及失败原因。 */ + @At(methods = "POST") + @Aop(TransAop.READ_COMMITTED) + @SaCheckPermission("data.person.governance.api.test") + public Result sync(@Param("id") String id) { + return Result.success(personGovernanceSyncService.execute(id, "MANUAL")); + } + + /** cron为Quartz周期文本;合法且有下一次触发时间返回统一成功结果,不创建任务。 */ + @At(methods = "POST") + @SaCheckPermission("data.person.governance.api.edit") + public Result validateCron(@Param("cron") String cron) { + personApiConfigService.validateTaskCron(cron); + return Result.success(); + } + + @At + @SaCheckPermission("data.person.governance.member") + public Result selectableConfigs(@Param("currentMatchConfigId") String currentMatchConfigId) { + return Result.success(personApiConfigService.selectableConfigs(currentMatchConfigId)); + } +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/job/PersonGovernanceSyncJob.java b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/job/PersonGovernanceSyncJob.java new file mode 100644 index 00000000..32c3b904 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/job/PersonGovernanceSyncJob.java @@ -0,0 +1,55 @@ +package com.budwk.app.zhgh.personGovernance.apiConfig.job; + +import com.budwk.app.zhgh.personGovernance.apiConfig.service.PersonGovernanceSyncService; +import com.budwk.app.zhgh.personGovernance.apiConfig.model.PersonApiConfig; +import org.nutz.dao.Dao; +import org.nutz.lang.Strings; +import org.nutz.log.Logs; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; +import org.quartz.Job; +import org.quartz.JobExecutionContext; +import org.quartz.DisallowConcurrentExecution; +import org.quartz.CronExpression; +import java.util.Objects; + +/** 每个配置独立周期;只执行JobDataMap中的configId,不扫描所有启用配置。 */ +@IocBean +@DisallowConcurrentExecution +public class PersonGovernanceSyncJob implements Job { + @Inject + private Dao dao; + @Inject + private PersonGovernanceSyncService personGovernanceSyncService; + + @Override + @Aop(TransAop.READ_COMMITTED) + public void execute(JobExecutionContext context) { + String id = context.getMergedJobDataMap().getString("configId"); + if (Strings.isBlank(id)) { + Logs.get().warn("人员同步任务缺少configId,请停用旧扫描任务并通过API配置重新启用"); + return; + } + PersonApiConfig config = dao.fetch(PersonApiConfig.class, id); + if (config == null || Boolean.TRUE.equals(config.getDelFlag()) || !Boolean.TRUE.equals(config.getEnabled()) + || !Objects.equals(config.getTaskId(), context.getJobDetail().getKey().getName()) + || !Objects.equals(config.getTaskCron(), context.getMergedJobDataMap().getString("taskCron"))) { + Logs.get().warn("跳过已停用或已被替换的人员同步任务:" + context.getJobDetail().getKey()); + return; + } + try { + // 重启恢复的旧式Quartz触发器也不允许补执行非周期时间点的misfire。 + if (context.getScheduledFireTime() == null + || !new CronExpression(config.getTaskCron()).isSatisfiedBy(context.getScheduledFireTime())) return; + var batch = personGovernanceSyncService.execute(id, "SCHEDULED"); + if (!"SUCCESS".equals(batch.getStatus())) { + Logs.get().warn("人员中间表同步失败,批次:" + batch.getBatchNo() + ",原因:" + batch.getErrorMessage()); + } + } catch (Exception e) { + Logs.get().warn("人员中间表同步未完成,配置:" + id, e); + } + } +} + diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonApiConfig.java b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonApiConfig.java new file mode 100644 index 00000000..08f81ec1 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonApiConfig.java @@ -0,0 +1,150 @@ +package com.budwk.app.zhgh.personGovernance.apiConfig.model; + +import com.budwk.app.base.model.BaseModel; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; +import org.nutz.dao.entity.annotation.*; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +import java.util.Date; + +/** + * 人员数据治理API配置,描述来源、目标表以及接口返回数据的解析方式。 + */ +@Data +@Accessors(chain = true) +@EqualsAndHashCode(callSuper = true) +@Table("person_api_config") +@TableIndexes({@Index(name = "UK_PERSON_API_CONFIG_NAME", fields = {"configName"}, unique = true)}) +public class PersonApiConfig extends BaseModel { + @Name + @Column + @Comment("主键ID") + @PrevInsert(uu32 = true) + @ColDefine(type = ColType.VARCHAR, width = 32) + private String id; + @Column + @Comment("配置名称") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String configName; + @Column + @Comment("来源类型:HTTP_API、SYSTEM_TABLE") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String sourceType; + @Column + @Comment("接口地址") + @ColDefine(type = ColType.VARCHAR, width = 500) + private String requestUrl; + @Column + @Comment("请求方式") + @ColDefine(type = ColType.VARCHAR, width = 10) + private String requestMethod; + @Column + @Comment("超时秒数") + private Integer timeoutSeconds; + @Column + @Comment("来源系统表") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String sourceTable; + @Column + @Comment("目标人员中间表") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String targetTable; + @Column + @Comment("同步类型:APPEND全量新增、UPSERT存在更新否则新增、INSERT_NEW仅新增不存在数据") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String syncMode; + @Column + @Comment("人员唯一标识目标字段") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String identityField; + @Column + @Comment("Body格式") + @ColDefine(type = ColType.VARCHAR, width = 30) + private String bodyType; + @Column + @Comment("JSON请求体") + @ColDefine(type = ColType.TEXT) + private String bodyContent; + @Column + @Comment("Token接口地址") + @ColDefine(type = ColType.VARCHAR, width = 500) + private String tokenRequestUrl; + @Column + @Comment("Token请求方式") + @ColDefine(type = ColType.VARCHAR, width = 10) + private String tokenRequestMethod; + @Column + @Comment("Token请求超时秒数") + private Integer tokenTimeoutSeconds; + @Column + @Comment("Token请求Body格式") + @ColDefine(type = ColType.VARCHAR, width = 30) + private String tokenBodyType; + @Column + @Comment("Token接口JSON请求体") + @ColDefine(type = ColType.TEXT) + private String tokenBodyContent; + @Column + @Comment("Token响应中Access_Token的字段路径") + @ColDefine(type = ColType.VARCHAR, width = 200) + private String tokenValuePath; + @Column + @Comment("Token响应中Expires_in的字段路径") + @ColDefine(type = ColType.VARCHAR, width = 200) + private String tokenExpiresPath; + @Column + @Comment("Token时效单位:SECONDS秒、MILLISECONDS毫秒、TIMESTAMP到期时间戳") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String tokenExpiryUnit; + @Column + @Comment("随配置保存的Token最新查询响应") + @ColDefine(type = ColType.TEXT) + private String tokenQueryResult; + @Column + @Comment("随配置保存的数据接口最新查询响应") + @ColDefine(type = ColType.TEXT) + private String dataQueryResult; + @Column + @Comment("返回码路径") + @ColDefine(type = ColType.VARCHAR, width = 200) + private String responseCodePath; + @Column + @Comment("成功返回值") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String responseSuccessValue; + @Column + @Comment("返回消息路径") + @ColDefine(type = ColType.VARCHAR, width = 200) + private String responseMessagePath; + @Column + @Comment("人员数组路径") + @ColDefine(type = ColType.VARCHAR, width = 200) + private String responseDataPath; + @Column + @Comment("返回总数路径") + @ColDefine(type = ColType.VARCHAR, width = 200) + private String responseTotalPath; + @Column + @Comment("是否启用同步任务") + private Boolean enabled; + @Column + @Comment("同步任务周期:Quartz Cron表达式,停用后才能修改") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String taskCron; + @Column + @Comment("是否在同步完成后执行工会会员与福利会员匹配规则") + private Boolean matchMemberRules; + @Column + @Comment("最近同步时间") + private Date lastSyncTime; + @Column + @Comment("任务ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String taskId; + @Column + @Comment("备注") + @ColDefine(type = ColType.VARCHAR, width = 500) + private String remark; +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonApiFieldMapping.java b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonApiFieldMapping.java new file mode 100644 index 00000000..47ade480 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonApiFieldMapping.java @@ -0,0 +1,52 @@ +package com.budwk.app.zhgh.personGovernance.apiConfig.model; + +import lombok.Data; +import lombok.experimental.Accessors; +import org.nutz.dao.entity.annotation.*; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +/** + * 来源字段到人员中间表字段的映射。 + */ +@Data +@Accessors(chain = true) +@Table("person_api_field_mapping") +@TableIndexes({@Index(name = "UK_PERSON_API_MAPPING_TARGET", fields = {"configId", "targetField"}, unique = true)}) +public class PersonApiFieldMapping { + @Name + @Column + @Comment("主键ID") + @PrevInsert(uu32 = true) + @ColDefine(type = ColType.VARCHAR, width = 32) + private String id; + @Column + @Comment("API配置ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String configId; + @Column + @Comment("来源字段名") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String sourceField; + @Column + @Comment("来源字段说明") + @ColDefine(type = ColType.VARCHAR, width = 200) + private String sourceComment; + @Column + @Comment("系统字段名") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String targetField; + @Column + @Comment("系统字段说明") + @ColDefine(type = ColType.VARCHAR, width = 200) + private String targetComment; + @Column + @Comment("是否必填") + private Boolean required; + @Column + @Comment("是否为人员唯一标识") + private Boolean identityFlag; + @Column + @Comment("排序号") + private Integer sort; +} + diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonApiRequestParam.java b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonApiRequestParam.java new file mode 100644 index 00000000..a7a2daab --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonApiRequestParam.java @@ -0,0 +1,62 @@ +package com.budwk.app.zhgh.personGovernance.apiConfig.model; + +import lombok.Data; +import lombok.experimental.Accessors; +import org.nutz.dao.entity.annotation.*; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +/** + * 第三方接口的Header、Query或Body参数。 + */ +@Data +@Accessors(chain = true) +@Table("person_api_request_param") +public class PersonApiRequestParam { + @Name + @Column + @Comment("主键ID") + @PrevInsert(uu32 = true) + @ColDefine(type = ColType.VARCHAR, width = 32) + private String id; + @Column + @Comment("API配置ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String configId; + @Column + @Comment("请求配置范围:TOKEN、DATA") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String requestScope; + @Column + @Comment("参数位置:HEADER、QUERY、BODY") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String paramLocation; + @Column + @Comment("参数名称") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String paramName; + @Column + @Comment("参数类型") + @ColDefine(type = ColType.VARCHAR, width = 30) + private String paramType; + @Column + @Comment("参数值") + @ColDefine(type = ColType.TEXT) + private String paramValue; + @Column + @Comment("是否必填") + private Boolean required; + @Column + @Comment("参数说明") + @ColDefine(type = ColType.VARCHAR, width = 300) + private String description; + // Java属性和接口JSON仍使用sensitive,仅将数据库列映射为非关键字名称,避免MySQL建表失败。 + @Column("sensitiveFlag") + @Comment("是否为敏感参数") + @ColDefine(type = ColType.INT) + private Integer sensitive; + @Column + @Comment("排序号") + @ColDefine(type = ColType.INT) + private Integer sort; +} + diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonApiTestCase.java b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonApiTestCase.java new file mode 100644 index 00000000..6bc25b6c --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonApiTestCase.java @@ -0,0 +1,58 @@ +package com.budwk.app.zhgh.personGovernance.apiConfig.model; + +import com.budwk.app.base.model.BaseModel; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; +import org.nutz.dao.entity.annotation.*; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +import java.util.Date; + +/** + * 已保存的接口测试用例,不参与正式同步。 + */ +@Data +@Accessors(chain = true) +@EqualsAndHashCode(callSuper = true) +@Table("person_api_test_case") +public class PersonApiTestCase extends BaseModel { + @Name + @Column + @Comment("主键ID") + @PrevInsert(uu32 = true) + @ColDefine(type = ColType.VARCHAR, width = 32) + private String id; + @Column + @Comment("API配置ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String configId; + @Column + @Comment("测试请求范围:TOKEN、DATA") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String requestScope; + @Column + @Comment("测试用例名称") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String caseName; + @Column + @Comment("请求参数快照") + @ColDefine(type = ColType.TEXT) + private String requestSnapshot; + @Column + @Comment("响应状态码") + private Integer responseStatus; + @Column + @Comment("响应耗时毫秒数") + private Long responseTime; + @Column + @Comment("响应内容") + @ColDefine(type = ColType.TEXT) + private String responseBody; + @Column + @Comment("测试是否成功") + private Boolean success; + @Column + @Comment("执行时间") + private Date executedAt; +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonSourceRelation.java b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonSourceRelation.java new file mode 100644 index 00000000..16c70790 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonSourceRelation.java @@ -0,0 +1,57 @@ +package com.budwk.app.zhgh.personGovernance.apiConfig.model; + +import com.budwk.app.base.model.BaseModel; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; +import org.nutz.dao.entity.annotation.*; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +import java.util.Date; + +/** + * 某个数据来源中的人员与本地人员的稳定关联。 + */ +@Data +@Accessors(chain = true) +@EqualsAndHashCode(callSuper = true) +@Table("person_source_relation") +@TableIndexes({@Index(name = "UK_PERSON_SOURCE_KEY", fields = {"configId", "sourcePersonKey"}, unique = true)}) +public class PersonSourceRelation extends BaseModel { + @Name + @Column + @Comment("主键ID") + @PrevInsert(uu32 = true) + @ColDefine(type = ColType.VARCHAR, width = 32) + private String id; + @Column + @Comment("API配置ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String configId; + @Column + @Comment("来源人员唯一标识") + @ColDefine(type = ColType.VARCHAR, width = 150) + private String sourcePersonKey; + @Column + @Comment("本地人员用户ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String userId; + @Column + @Comment("人员编号") + @ColDefine(type = ColType.VARCHAR, width = 120) + private String personNo; + @Column + @Comment("最近出现的同步批次ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String lastSeenBatchId; + @Column + @Comment("最近出现时间") + private Date lastSeenTime; + @Column + @Comment("当前来源中是否存在") + private Boolean presentFlag; + @Column + @Comment("来源人员数据摘要") + @ColDefine(type = ColType.VARCHAR, width = 64) + private String sourceDataHash; +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonSyncBatch.java b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonSyncBatch.java new file mode 100644 index 00000000..65a69200 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/model/PersonSyncBatch.java @@ -0,0 +1,72 @@ +package com.budwk.app.zhgh.personGovernance.apiConfig.model; + +import com.budwk.app.base.model.BaseModel; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; +import org.nutz.dao.entity.annotation.*; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +import java.util.Date; + +/** + * 一次人员同步的执行批次及汇总结果。 + */ +@Data +@Accessors(chain = true) +@EqualsAndHashCode(callSuper = true) +@Table("person_sync_batch") +@TableIndexes({@Index(name = "UK_PERSON_SYNC_BATCH_NO", fields = {"batchNo"}, unique = true)}) +public class PersonSyncBatch extends BaseModel { + @Name + @Column + @Comment("主键ID") + @PrevInsert(uu32 = true) + @ColDefine(type = ColType.VARCHAR, width = 32) + private String id; + @Column + @Comment("API配置ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String configId; + @Column + @Comment("同步批次号") + @ColDefine(type = ColType.VARCHAR, width = 50) + private String batchNo; + @Column + @Comment("触发方式:手动或定时") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String triggerType; + @Column + @Comment("开始时间") + private Date startedAt; + @Column + @Comment("结束时间") + private Date finishedAt; + @Column + @Comment("执行状态") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String status; + @Column + @Comment("来源人员总数") + private Integer sourceCount; + @Column + @Comment("同步成功数量") + private Integer successCount; + @Column + @Comment("新增人员数量") + private Integer newCount; + @Column + @Comment("变更人员数量") + private Integer changeCount; + @Column + @Comment("异常人员数量") + private Integer exceptionCount; + @Column + @Comment("失败错误信息") + @ColDefine(type = ColType.TEXT) + private String errorMessage; + @Column + @Comment("原始文件关联ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String rawFileReid; +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/service/PersonApiConfigService.java b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/service/PersonApiConfigService.java new file mode 100644 index 00000000..020d5888 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/service/PersonApiConfigService.java @@ -0,0 +1,63 @@ +package com.budwk.app.zhgh.personGovernance.apiConfig.service; + +import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.param.PageForm; +import com.budwk.app.base.service.BaseService; +import com.budwk.app.zhgh.personGovernance.apiConfig.model.PersonApiConfig; +import com.budwk.app.zhgh.personGovernance.apiConfig.model.PersonApiFieldMapping; +import com.budwk.app.zhgh.personGovernance.apiConfig.model.PersonApiRequestParam; +import com.budwk.app.zhgh.personGovernance.apiConfig.model.PersonApiTestCase; +import org.nutz.lang.util.NutMap; + +import java.util.List; + +/** API配置业务入口。 */ +public interface PersonApiConfigService extends BaseService { + Pagination pageData(PageForm pageForm, String configName, String sourceType, Boolean enabled); + + NutMap detail(String id); + + PersonApiConfig saveConfig(PersonApiConfig config, List mappings, + List params); + + /** + * 解析页面提交的配置、字段映射和请求参数并保存。 + * + * @param data JSON对象,包含 config、mappings、params + * @return 保存后的API配置 + */ + PersonApiConfig saveConfig(String data); + + void deleteConfig(String id); + + List tableNames(); + + List columns(String tableName); + + NutMap test(String configId, String requestScope, String testCondition); + + /** 使用页面当前config、params查询,返回第三方JSON值;非JSON返回原文,不写数据库。 */ + Object queryThirdParty(String data, String requestScope); + + /** page为当前页码或起始数,pageSize为数量或区间长度;结束数=page+pageSize,返回第三方JSON。 */ + Object querySyncPage(PersonApiConfig config, List params, long page, int pageSize); + + /** config和params为待保存或已保存配置;返回mode、cursor、size、step,NONE和LIMIT均只请求一次。 */ + NutMap pagination(PersonApiConfig config, List params); + + /** 读取响应中的箭头路径或历史JSONPath;空路径表示整个响应,路径缺失返回null。 */ + Object readResponsePath(Object response, String path); + + /** 校验Quartz周期语法及下一次执行时间;非法时抛出明确的参数错误。 */ + void validateTaskCron(String cron); + + PersonApiTestCase saveTestCase(PersonApiTestCase testCase); + + /** + * @param data 测试用例JSON + * @return 保存后的测试用例 + */ + PersonApiTestCase saveTestCase(String data); + + List selectableConfigs(String currentMatchConfigId); +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/service/PersonGovernanceSyncService.java b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/service/PersonGovernanceSyncService.java new file mode 100644 index 00000000..251a87ed --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/service/PersonGovernanceSyncService.java @@ -0,0 +1,10 @@ +package com.budwk.app.zhgh.personGovernance.apiConfig.service; + +import com.budwk.app.zhgh.personGovernance.apiConfig.model.PersonSyncBatch; + +/** 正式人员治理同步入口。 */ +public interface PersonGovernanceSyncService { + PersonSyncBatch execute(String configId, String triggerType); + +} + diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/service/impl/PersonApiConfigServiceImpl.java b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/service/impl/PersonApiConfigServiceImpl.java new file mode 100644 index 00000000..63f7f895 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/service/impl/PersonApiConfigServiceImpl.java @@ -0,0 +1,951 @@ +package com.budwk.app.zhgh.personGovernance.apiConfig.service.impl; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.digest.DigestUtil; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import cn.hutool.http.Method; +import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.constant.RedisConstant; +import com.budwk.app.base.dao.CndPlus; +import com.budwk.app.base.param.PageForm; +import com.budwk.app.base.service.impl.BaseServiceImpl; +import com.budwk.app.sys.models.Sys_task; +import com.budwk.app.zhgh.personGovernance.apiConfig.job.PersonGovernanceSyncJob; +import com.budwk.app.zhgh.personGovernance.apiConfig.model.PersonApiConfig; +import com.budwk.app.zhgh.personGovernance.apiConfig.model.PersonApiFieldMapping; +import com.budwk.app.zhgh.personGovernance.apiConfig.model.PersonApiRequestParam; +import com.budwk.app.zhgh.personGovernance.apiConfig.model.PersonApiTestCase; +import com.budwk.app.zhgh.personGovernance.apiConfig.service.PersonApiConfigService; +import com.budwk.app.zhgh.personGovernance.memberMatch.model.PersonMemberMatchConfig; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.dao.Cnd; +import org.nutz.dao.Dao; +import org.nutz.dao.FieldFilter; +import org.nutz.dao.Sqls; +import org.nutz.dao.sql.Sql; +import org.nutz.integration.jedis.RedisService; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.lang.Strings; +import org.nutz.json.Json; +import org.nutz.lang.random.R; +import org.nutz.lang.util.NutMap; +import org.nutz.log.Logs; +import org.springframework.util.Assert; +import org.quartz.*; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Pattern; + +/** API配置、数据库元数据和只读测试实现。 */ +@IocBean(args = {"refer:dao"}) +public class PersonApiConfigServiceImpl extends BaseServiceImpl implements PersonApiConfigService { + private static final Pattern SAFE_IDENTIFIER = Pattern.compile("^[A-Za-z0-9_]+$"); + /** JSON节点删除标记:Token接口未配置时,用于彻底移除数据请求中的Token字段或数组项。 */ + private static final Object OMIT_TOKEN_PARAMETER = new Object(); + + @Inject + private RedisService redisService; + + @Inject + private Scheduler scheduler; + + public PersonApiConfigServiceImpl(Dao dao) { + super(dao); + } + + @Override + public Pagination pageData(PageForm pageForm, String configName, String sourceType, Boolean enabled) { + CndPlus cnd = CndPlus.create(); + cnd.andEX("configName", "like", Strings.isBlank(configName) ? null : "%" + configName + "%"); + cnd.andEX("sourceType", "=", sourceType); + cnd.andEX("enabled", "=", enabled); + // 历史保存可能将删除标记写为NULL;仅明确标记为true的配置不在列表展示。 + cnd.and(Cnd.exps("delFlag", "=", false).or("delFlag", "is", null)).desc("updatedAt"); + return listPage(pageForm.getPageNumber(), pageForm.getPageSize(), cnd); + } + + @Override + public NutMap detail(String id) { + PersonApiConfig config = fetch(id); + Assert.notNull(config, "API配置不存在"); + // MySQL仅保存Redis Key;详情接口仍返回原始响应,保持现有页面回显协议不变。 + config.setDataQueryResult(resolveDataQueryResult(config.getDataQueryResult())); + List params = dao().query( + PersonApiRequestParam.class, + Cnd.where("configId", "=", id).asc("sort") + ); + // 历史请求参数没有范围标识,统一按数据接口参数回显,保证旧配置兼容。 + params.forEach(param -> { + if (Strings.isBlank(param.getRequestScope())) { + param.setRequestScope("DATA"); + } + }); + List testCases = dao().query( + PersonApiTestCase.class, + Cnd.where("configId", "=", id).desc("executedAt") + ); + testCases.forEach(testCase -> { + if (Strings.isBlank(testCase.getRequestScope())) { + testCase.setRequestScope("DATA"); + } + }); + return NutMap.NEW() + .addv("config", config) + .addv("mappings", dao().query(PersonApiFieldMapping.class, Cnd.where("configId", "=", id).asc("sort"))) + .addv("params", params) + .addv("testCases", testCases); + } + + /** + * 保存两套请求配置、最新响应和未完成的映射/参数。仅配置名称必填,执行时再校验完整性。 + * + * @param config API配置基本信息 + * @param mappings 来源字段到人员中间表字段的映射 + * @param params Token、数据两个范围下的Header、Query、Body请求参数 + * @return 保存后的API配置 + */ + @Override + @Aop(TransAop.READ_COMMITTED) + public PersonApiConfig saveConfig(PersonApiConfig config, List mappings, + List params) { + Assert.notNull(config, "配置不能为空"); + Assert.hasText(config.getConfigName(), "请填写配置名称"); + PersonApiConfig stored = Strings.isBlank(config.getId()) ? null : fetch(config.getId()); + if (stored != null) { + // 页面启停与周期均为待保存状态;保存时统一更新调度,并保留原任务归属。 + config.setTaskId(stored.getTaskId()); + } else { + config.setTaskId(null); + } + config.setTaskCron(Strings.sNull(config.getTaskCron()).trim()); + // 历史配置未包含该字段时按关闭处理,避免保存后意外触发会员身份及角色调整。 + config.setMatchMemberRules(Boolean.TRUE.equals(config.getMatchMemberRules())); + if (Boolean.TRUE.equals(config.getEnabled())) { + validateTaskCron(config.getTaskCron()); + } + if (Strings.isBlank(config.getTokenExpiryUnit())) { + config.setTokenExpiryUnit("SECONDS"); + } + Assert.isTrue(List.of("HTTP_API", "SYSTEM_TABLE").contains(config.getSourceType()), "同步方式不正确"); + mappings = mappings == null ? new ArrayList<>() : mappings; + PersonApiFieldMapping identity = mappings.stream() + .filter(v -> Boolean.TRUE.equals(v.getIdentityFlag())) + .findFirst() + .orElse(null); + config.setIdentityField(identity == null ? null : identity.getTargetField()); + // 历史配置未设置同步类型时保留全部追加行为;去重模式必须有唯一的有效业务标识。 + if (Strings.isBlank(config.getSyncMode())) config.setSyncMode("APPEND"); + Assert.isTrue(List.of("APPEND", "UPSERT", "INSERT_NEW").contains(config.getSyncMode()), "同步类型不正确"); + if (!"APPEND".equals(config.getSyncMode())) { + Assert.isTrue(mappings.stream().filter(v -> Boolean.TRUE.equals(v.getIdentityFlag())).count() == 1 + && identity != null && Strings.isNotBlank(identity.getSourceField()) + && Strings.isNotBlank(identity.getTargetField()) + && !List.of("id", "pullTime").contains(identity.getTargetField()), "请选择一个有效的业务唯一标识映射"); + } + if ("HTTP_API".equals(config.getSourceType())) pagination(config, params); + + PersonApiConfig sameName = fetch( + Cnd.where("configName", "=", config.getConfigName()).and("delFlag", "=", false) + ); + Assert.isTrue(sameName == null || Objects.equals(sameName.getId(), config.getId()), "配置名称已存在"); + String previousDataQueryResultKey = null; + String createdDataQueryResultKey = null; + try { + if (Strings.isBlank(config.getId())) { + if (Strings.isNotBlank(config.getDataQueryResult())) { + createdDataQueryResultKey = buildDataQueryResultKey(); + redisService.set(createdDataQueryResultKey, config.getDataQueryResult()); + config.setDataQueryResult(createdDataQueryResultKey); + } else { + config.setDataQueryResult(null); + } + config.setEnabled(Boolean.TRUE.equals(config.getEnabled())); + insert(config); + } else { + PersonApiConfig previous = fetch(config.getId()); + Assert.notNull(previous, "API配置不存在"); + // 删除标记由删除接口维护,普通保存保留数据库值,避免前端漏传或改写该标记。 + config.setDelFlag(previous.getDelFlag()); + previousDataQueryResultKey = isDataQueryResultKey(previous.getDataQueryResult()) + ? previous.getDataQueryResult() : null; + // 老页面未提交响应字段时保留已保存响应;空字符串则表示明确清空。 + if (config.getTokenQueryResult() == null) { + config.setTokenQueryResult(previous.getTokenQueryResult()); + } + if (config.getDataQueryResult() == null) { + config.setDataQueryResult(previous.getDataQueryResult()); + } else if (Strings.isBlank(config.getDataQueryResult())) { + config.setDataQueryResult(null); + } else if (previousDataQueryResultKey != null + && (Objects.equals(previousDataQueryResultKey, config.getDataQueryResult()) + || Objects.equals(redisService.get(previousDataQueryResultKey), config.getDataQueryResult()))) { + // 页面回传内容未变化时复用原Key,避免每次保存都生成无效缓存。 + config.setDataQueryResult(previousDataQueryResultKey); + } else { + createdDataQueryResultKey = buildDataQueryResultKey(); + redisService.set(createdDataQueryResultKey, config.getDataQueryResult()); + config.setDataQueryResult(createdDataQueryResultKey); + } + // 最近同步时间仅由同步完成逻辑维护,排除该列,避免表单空值、旧值或并发保存覆盖最新同步结果。 + dao().update(config, FieldFilter.create(PersonApiConfig.class, null, "^lastSyncTime$", false)); + dao().clear(PersonApiFieldMapping.class, Cnd.where("configId", "=", config.getId())); + dao().clear(PersonApiRequestParam.class, Cnd.where("configId", "=", config.getId())); + } + int index = 1; + for (PersonApiFieldMapping mapping : mappings) { + // 未选择目标字段的多条草稿行以NULL保存,避免目标字段唯一索引将空字符串视为重复值。 + if (Strings.isBlank(mapping.getTargetField())) { + mapping.setTargetField(null); + } + mapping.setId(null).setConfigId(config.getId()).setSort(index++); + } + if (!mappings.isEmpty()) { + dao().insert(mappings); + } + if ("HTTP_API".equals(config.getSourceType()) && params != null && !params.isEmpty()) { + index = 1; + for (PersonApiRequestParam param : params) { + String requestScope = Strings.isBlank(param.getRequestScope()) ? "DATA" : param.getRequestScope(); + Assert.isTrue(List.of("TOKEN", "DATA").contains(requestScope), "请求配置范围不正确"); + param.setRequestScope(requestScope); + param.setId(null).setConfigId(config.getId()).setSort(index++); + } + dao().insert(params); + } + if (previousDataQueryResultKey != null + && !Objects.equals(previousDataQueryResultKey, config.getDataQueryResult())) { + deleteDataQueryResultQuietly(previousDataQueryResultKey); + } + reconcileTask(config); + return config; + } catch (RuntimeException e) { + // 数据库操作失败时回收本次新建的缓存,避免产生无引用Key。 + deleteDataQueryResultQuietly(createdDataQueryResultKey); + throw e; + } + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public PersonApiConfig saveConfig(String data) { + NutMap payload = Json.fromJson(NutMap.class, data); + PersonApiConfig config = Json.fromJson(PersonApiConfig.class, Json.toJson(payload.get("config"))); + List mappings = Json.fromJsonAsList( + PersonApiFieldMapping.class, Json.toJson(payload.get("mappings"))); + List params = Json.fromJsonAsList( + PersonApiRequestParam.class, Json.toJson(payload.get("params"))); + return saveConfig(config, mappings, params); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void deleteConfig(String id) { + Assert.isNull( + dao().fetch( + PersonMemberMatchConfig.class, + Cnd.where("apiConfigId", "=", id).and("delFlag", "=", false) + ), + "该API配置已绑定会员匹配配置,不能删除" + ); + update(org.nutz.dao.Chain.make("delFlag", true).add("enabled", false), Cnd.where("id", "=", id)); + PersonApiConfig config = fetch(id); + config.setEnabled(false); + reconcileTask(config); + } + + @Override + public List tableNames() { + Sql sql = Sqls.create( + "SELECT table_name tableName,table_comment tableComment FROM information_schema.TABLES " + + "WHERE table_schema=(SELECT DATABASE()) ORDER BY table_name" + ); + return listMap(sql); + } + + @Override + public List columns(String tableName) { + validateTable(tableName); + Sql sql = Sqls.create( + "SELECT column_name columnName,column_comment columnComment,column_type columnType," + + "is_nullable isNullable FROM information_schema.COLUMNS " + + "WHERE table_schema=(SELECT DATABASE()) " + + "AND table_name=@tableName ORDER BY ordinal_position" + ) + .setParam("tableName", tableName); + return listMap(sql); + } + + /** + * 执行指定范围的只读测试,不落人员数据。 + * + * @param configId API配置ID + * @param requestScope TOKEN表示Token接口,DATA表示人员数据接口;空值兼容为DATA + * @param testCondition 可选的临时查询条件,仅数据请求使用 + * @return 请求状态、耗时及原始响应内容 + */ + @Override + public NutMap test(String configId, String requestScope, String testCondition) { + PersonApiConfig config = fetch(configId); + Assert.notNull(config, "请先保存API配置"); + long start = System.currentTimeMillis(); + if ("SYSTEM_TABLE".equals(config.getSourceType())) { + validateTable(config.getSourceTable()); + Sql sql = Sqls.create("SELECT * FROM `" + config.getSourceTable() + "` LIMIT 20"); + List rows = listMap(sql); + return NutMap.NEW() + .addv("success", true) + .addv("status", 200) + .addv("elapsed", System.currentTimeMillis() - start) + .addv("data", rows); + } + String scope = Strings.isBlank(requestScope) ? "DATA" : requestScope.toUpperCase(Locale.ROOT); + Assert.isTrue(List.of("TOKEN", "DATA").contains(scope), "请求配置范围不正确"); + List params = dao().query(PersonApiRequestParam.class, + Cnd.where("configId", "=", configId).asc("sort")); + return executeHttp(config, params, scope, testCondition); + } + + /** + * 解析未保存的页面请求。data包含config和params,范围由调用接口固定,避免前端切换串用参数。 + * @param data 页面配置JSON + * @param requestScope TOKEN或DATA + * @return 第三方JSON对象、数组或基本值;非JSON响应返回原文,失败交给统一异常处理 + */ + @Override + public Object queryThirdParty(String data, String requestScope) { + Assert.hasText(data, "请求配置不能为空"); + NutMap payload = Json.fromJson(NutMap.class, data); + Assert.notNull(payload, "请求配置格式不正确"); + PersonApiConfig config = Json.fromJson(PersonApiConfig.class, Json.toJson(payload.get("config"))); + Assert.notNull(config, "请求配置不能为空"); + List params = payload.get("params") == null + ? new ArrayList<>() + : Json.fromJsonAsList(PersonApiRequestParam.class, Json.toJson(payload.get("params"))); + // 状态信息仅供内部判断,查询接口不额外包装第三方业务字段;同步入口仍沿用原结构。 + NutMap response = executeHttp(config, params, requestScope, null); + Assert.isTrue(response.getBoolean("success"), response.getString("error") == null + ? "第三方返回HTTP错误:" + response.get("status") : response.getString("error")); + String body = response.getString("data"); + if (Strings.isBlank(body)) { + return body; + } + try { + return com.alibaba.fastjson.JSON.parse(body); + } catch (com.alibaba.fastjson.JSONException e) { + // HTML、纯文本等响应不能强制转成对象,保留原文由页面提示。 + return body; + } + } + + /** + * 同步取数:params是保存的两套参数,page为页码或起点,pageSize为数量或区间长度;仅替换请求副本。 + * Access_Token标记传入缓存Token,参数值可使用Bearer ${Access_Token}保留前缀。 + * JSON Body支持Access_Token、页码、数量、起始数、结束数占位符,完整数字占位符转为JSON数字。 + */ + @Override + @Aop(TransAop.READ_COMMITTED) + public Object querySyncPage(PersonApiConfig config, List params, long page, int pageSize) { + boolean sendBody = !List.of("GET", "HEAD").contains(Strings.sNull(config.getRequestMethod()).toUpperCase(Locale.ROOT)); + boolean tokenConfigured = Strings.isNotBlank(config.getTokenRequestUrl()); + List dataParams = params.stream() + .filter(p -> "DATA".equals(normalizeScope(p.getRequestScope()))) + // 隐藏的form-data草稿不参与JSON或GET/HEAD请求,也不能意外触发Token获取。 + .filter(p -> !"BODY".equals(p.getParamLocation()) || (sendBody && "FORM_DATA".equals(config.getBodyType()))) + .toList(); + // Token地址为空代表数据接口无需鉴权:既不请求Token,也不发送任何Token参数。 + boolean usesToken = tokenConfigured && (dataParams.stream().anyMatch(p -> "Access_Token".equals(p.getDescription())) + || (sendBody && "JSON".equals(config.getBodyType()) + && Strings.sNull(config.getBodyContent()).contains("${Access_Token}"))); + String token = usesToken ? cachedToken(config, params) : ""; + PersonApiConfig requestConfig = Json.fromJson(PersonApiConfig.class, Json.toJson(config)); + List requestParams = Json.fromJsonAsList(PersonApiRequestParam.class, Json.toJson(dataParams)); + for (PersonApiRequestParam param : requestParams) { + if ("Access_Token".equals(param.getDescription())) { + String value = Strings.sNull(param.getParamValue()); + param.setParamValue(value.contains("${Access_Token}") ? value.replace("${Access_Token}", token) : token); + } else if ("页码".equals(param.getDescription())) { + param.setParamValue(String.valueOf(page)); + } else if ("数量".equals(param.getDescription())) { + param.setParamValue(String.valueOf(pageSize)); + } else if ("起始数".equals(param.getDescription())) { + param.setParamValue(String.valueOf(page)); + } else if ("结束数".equals(param.getDescription())) { + param.setParamValue(String.valueOf(Math.addExact(page, pageSize))); + } + } + if (sendBody && "JSON".equals(config.getBodyType()) + && Strings.isNotBlank(config.getBodyContent())) { + Object body = com.alibaba.fastjson.JSON.parse(config.getBodyContent()); + if (!tokenConfigured) { + body = normalizeJsonRoot(stripTokenJsonParameters(body)); + } + requestConfig.setBodyContent(com.alibaba.fastjson.JSON.toJSONString(fillJsonParameters(body, token, page, pageSize))); + } + NutMap response = executeHttp(requestConfig, requestParams, "DATA", null); + Assert.isTrue(response.getBoolean("success"), "数据接口请求失败:" + + (response.get("error") == null ? response.get("status") : response.get("error"))); + return parseResponseObject(response.getString("data")); + } + + /** + * 保存与执行共用分页校验。只读取数据请求实际发送的参数,Token及隐藏Body草稿不参与。 + * 返回cursor为初始页码/起点,size为每页数量/区间长度,step为每轮推进值;无分页size=0。 + * 仅配置数量时使用LIMIT模式,表示把数量作为单次请求上限,不追加页码或起点且不循环请求。 + * JSON占位符沿用默认初值:页码1、起始数0、数量100、结束数100。 + */ + @Override + public NutMap pagination(PersonApiConfig config, List params) { + List roles = List.of("页码", "数量", "起始数", "结束数"); + Map values = new LinkedHashMap<>(); + boolean sendBody = !List.of("GET", "HEAD").contains(Strings.sNull(config.getRequestMethod()).toUpperCase(Locale.ROOT)); + for (PersonApiRequestParam param : params == null ? List.of() : params) { + if (!"DATA".equals(normalizeScope(param.getRequestScope()))) continue; + if ("BODY".equals(param.getParamLocation()) && (!sendBody || !"FORM_DATA".equals(config.getBodyType()))) continue; + String role = param.getDescription(); + if (!roles.contains(Strings.sNull(role))) continue; + Assert.isTrue(!values.containsKey(role), "分页标记不能重复:" + role); + Assert.hasText(param.getParamName(), "分页参数名称不能为空:" + role); + Assert.hasText(param.getParamValue(), "分页参数值不能为空:" + role); + values.put(role, param.getParamValue().trim()); + } + if (sendBody && "JSON".equals(config.getBodyType())) { + String body = Strings.sNull(config.getBodyContent()); + for (String role : roles) { + if (body.contains("${" + role + "}")) { + values.putIfAbsent(role, "页码".equals(role) ? "1" : "起始数".equals(role) ? "0" : "100"); + } + } + } + if (values.isEmpty()) return NutMap.NEW().addv("mode", "NONE").addv("cursor", 0L).addv("size", 0).addv("step", 0L); + boolean page = values.keySet().equals(java.util.Set.of("页码", "数量")); + boolean range = values.keySet().equals(java.util.Set.of("起始数", "结束数")); + boolean offset = values.keySet().equals(java.util.Set.of("起始数", "数量")); + boolean limit = values.keySet().equals(java.util.Set.of("数量")); + Assert.isTrue(page || range || offset || limit, + "分页仅支持:页码+数量、起始数+结束数、起始数+数量,或仅数量;不得缺项或混用"); + try { + for (String value : values.values()) Assert.isTrue(value.matches("[0-9]+"), "分页参数必须为非负整数"); + long cursor = limit ? 0L : Long.parseLong(values.get(page ? "页码" : "起始数")); + long size = range ? Math.subtractExact(Long.parseLong(values.get("结束数")), cursor) + : Long.parseLong(values.get("数量")); + Assert.isTrue(size > 0 && size <= 10000, "数量或结束数-起始数须在1至10000之间"); + Math.addExact(cursor, size); + return NutMap.NEW().addv("mode", limit ? "LIMIT" : page ? "PAGE" : range ? "RANGE" : "OFFSET") + .addv("cursor", cursor).addv("size", (int) size) + .addv("step", limit ? 0L : page ? 1L : size); + } catch (NumberFormatException | ArithmeticException e) { + throw new IllegalArgumentException("分页参数超出整数范围", e); + } + } + + /** + * Token接口未配置时递归移除JSON Body中的Token参数,而不是把Token占位符替换为空字符串。 + * Map字段或数组项只要直接以字符串引用Access_Token,就整体省略;其他业务节点保持原结构。 + */ + private Object stripTokenJsonParameters(Object value) { + if (value instanceof Map map) { + Map result = new LinkedHashMap<>(); + map.forEach((key, child) -> { + Object sanitized = stripTokenJsonParameters(child); + if (sanitized != OMIT_TOKEN_PARAMETER) { + result.put(String.valueOf(key), sanitized); + } + }); + return result; + } + if (value instanceof List list) { + List result = new ArrayList<>(); + for (Object child : list) { + Object sanitized = stripTokenJsonParameters(child); + if (sanitized != OMIT_TOKEN_PARAMETER) { + result.add(sanitized); + } + } + return result; + } + if (value instanceof String text && text.contains("${Access_Token}")) { + return OMIT_TOKEN_PARAMETER; + } + return value; + } + + /** JSON根节点不能携带内部删除标记;极端情况下整个Body只有Token时按JSON null发送。 */ + private Object normalizeJsonRoot(Object value) { + return value == OMIT_TOKEN_PARAMETER ? null : value; + } + + /** JSON按节点替换而不是拼接字符串,避免Token中的引号破坏请求体。 */ + private Object fillJsonParameters(Object value, String token, long page, int pageSize) { + if (value instanceof Map map) { + Map result = new LinkedHashMap<>(); + map.forEach((k, v) -> result.put(String.valueOf(k), fillJsonParameters(v, token, page, pageSize))); + return result; + } + if (value instanceof List list) { + return list.stream().map(v -> fillJsonParameters(v, token, page, pageSize)).toList(); + } + if (value instanceof String text) { + if ("${页码}".equals(text)) return page; + if ("${数量}".equals(text)) return pageSize; + if ("${起始数}".equals(text)) return page; + if ("${结束数}".equals(text)) return Math.addExact(page, pageSize); + return text.replace("${Access_Token}", token).replace("${页码}", String.valueOf(page)) + .replace("${数量}", String.valueOf(pageSize)).replace("${起始数}", String.valueOf(page)) + .replace("${结束数}", String.valueOf(Math.addExact(page, pageSize))); + } + return value; + } + + /** Token缓存按配置及Token请求指纹隔离,配置修改后不会误用旧缓存。 */ + @Aop(TransAop.READ_COMMITTED) + private String cachedToken(PersonApiConfig config, List params) { + Assert.hasText(config.getTokenValuePath(), "请填写Access_Token映射路径"); + Assert.hasText(config.getTokenExpiresPath(), "请填写Expires_in映射路径"); + List tokenParams = params.stream() + .filter(p -> "TOKEN".equals(p.getRequestScope())).toList(); + // 只纳入影响Token的请求内容,避免保存响应、页码变化使缓存失效。 + List fingerprint = new ArrayList<>(); + fingerprint.add(config.getTokenRequestUrl()); + fingerprint.add(config.getTokenRequestMethod()); + fingerprint.add(config.getTokenBodyType()); + fingerprint.add(config.getTokenBodyContent()); + fingerprint.add(config.getTokenValuePath()); + fingerprint.add(config.getTokenExpiresPath()); + fingerprint.add(config.getTokenExpiryUnit()); + tokenParams.forEach(p -> fingerprint.add(List.of(Strings.sNull(p.getParamLocation()), + Strings.sNull(p.getParamName()), Strings.sNull(p.getParamValue())))); + String key = RedisConstant.PLATFORM_REDIS_PREFIX + "personGovernance:token:" + config.getId() + + ":" + DigestUtil.sha256Hex(Json.toJson(fingerprint)); + String cached = redisService.get(key); + if (Strings.isNotBlank(cached)) return cached; + String owner = R.UU32(); + String lockKey = key + ":refresh"; + Assert.isTrue("OK".equals(redisService.set(lockKey, owner, "NX", "EX", 650)), "Token正在刷新,请稍后重试同步"); + try { + cached = redisService.get(key); + if (Strings.isNotBlank(cached)) return cached; + long requestedAt = System.currentTimeMillis(); + NutMap result = executeHttp(config, tokenParams, "TOKEN", null); + Assert.isTrue(result.getBoolean("success"), "Token接口请求失败"); + Object response = parseResponseObject(result.getString("data")); + Object tokenValue = readResponsePath(response, config.getTokenValuePath()); + Assert.isTrue(tokenValue instanceof String && Strings.isNotBlank((String) tokenValue), "Access_Token映射结果不是有效字符串"); + Object expiry = readResponsePath(response, config.getTokenExpiresPath()); + long ttl = tokenTtlMillis(expiry, config.getTokenExpiryUnit(), requestedAt, System.currentTimeMillis()); + redisService.set(key, (String) tokenValue, "NX", "PX", ttl); + return (String) tokenValue; + } finally { + redisService.eval("if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end", + List.of(lockKey), List.of(owner)); + } + } + + /** 时效为正整数秒/毫秒,或10位秒级、13位毫秒级到期时间戳;扣除请求耗时。 */ + private long tokenTtlMillis(Object expiry, String unit, long requestedAt, long now) { + Assert.notNull(expiry, "Expires_in路径未找到有效期"); + String text = String.valueOf(expiry).trim(); + Assert.isTrue(text.matches("[0-9]+"), "Expires_in必须为正整数"); + try { + long value = Long.parseLong(text); + String actualUnit = Strings.isBlank(unit) ? "SECONDS" : unit; + long expiresAt; + if ("TIMESTAMP".equals(actualUnit)) { + Assert.isTrue(text.length() == 10 || text.length() == 13, "到期时间戳必须为10位秒或13位毫秒"); + expiresAt = text.length() == 10 ? Math.multiplyExact(value, 1000L) : value; + } else { + Assert.isTrue(List.of("SECONDS", "MILLISECONDS").contains(actualUnit), "Token时效单位不正确"); + long millis = "SECONDS".equals(actualUnit) ? Math.multiplyExact(value, 1000L) : value; + expiresAt = Math.addExact(requestedAt, millis); + } + long remaining = Math.subtractExact(expiresAt, now); + Assert.isTrue(remaining > 0, "Token已过期或有效期无效"); + // 最多提前1秒失效,降低临近到期时拿到无效Token的风险。 + return Math.max(1, remaining - Math.min(1000, remaining / 10)); + } catch (ArithmeticException | NumberFormatException e) { + throw new IllegalArgumentException("Token有效期超出支持范围", e); + } + } + + /** 展开最多20层重复编码的JSON;普通Token字符串保持原样。 */ + private Object parseResponseObject(Object value) { + for (int i = 0; i < 20 && value instanceof String; i++) { + String text = ((String) value).trim(); + if (!(text.startsWith("{") || text.startsWith("[") || text.startsWith("\""))) break; + try { + value = com.alibaba.fastjson.JSON.parse(text); + } catch (com.alibaba.fastjson.JSONException e) { + throw new IllegalArgumentException("第三方返回结果不是合法JSON", e); + } + } + return value; + } + + @Override + public Object readResponsePath(Object response, String path) { + Object value = parseResponseObject(response); + if (Strings.isBlank(path) || "$".equals(path.trim())) return value; + String expression = path.trim(); + if (expression.contains("->")) { + for (String field : expression.split("->", -1)) { + Assert.hasText(field.trim(), "映射路径包含空节点"); + value = parseResponseObject(value); + if (!(value instanceof Map map)) return null; + value = map.get(field.trim()); + } + } else if (value instanceof Map map && map.containsKey(expression)) { + value = map.get(expression); + } else { + value = com.alibaba.fastjson.JSONPath.eval(value, expression.startsWith("$") ? expression : "$." + expression); + } + return parseResponseObject(value); + } + + /** 已保存测试和页面查询共享HTTP组装;校验仅在实际请求时执行,响应不自动落库。 */ + private NutMap executeHttp(PersonApiConfig config, List params, + String scope, String testCondition) { + Assert.isTrue(List.of("TOKEN", "DATA").contains(scope), "请求配置范围不正确"); + long start = System.currentTimeMillis(); + boolean omitToken = "DATA".equals(scope) && Strings.isBlank(config.getTokenRequestUrl()); + params = params.stream() + .filter(param -> scope.equals(normalizeScope(param.getRequestScope()))) + // 未配置Token接口时,Header、Query及Form Data中的Token参数均不进入实际请求。 + .filter(param -> !omitToken || !"Access_Token".equals(param.getDescription())) + .toList(); + String requestUrl = "TOKEN".equals(scope) ? config.getTokenRequestUrl() : config.getRequestUrl(); + String requestMethod = "TOKEN".equals(scope) ? config.getTokenRequestMethod() : config.getRequestMethod(); + Integer timeoutSeconds = "TOKEN".equals(scope) ? config.getTokenTimeoutSeconds() : config.getTimeoutSeconds(); + String bodyType = "TOKEN".equals(scope) ? config.getTokenBodyType() : config.getBodyType(); + String bodyContent = "TOKEN".equals(scope) ? config.getTokenBodyContent() : config.getBodyContent(); + Assert.hasText(requestUrl, "请填写" + ("TOKEN".equals(scope) ? "Token" : "数据") + "请求地址"); + Assert.hasText(requestMethod, "请选择请求方式"); + Method method = Method.valueOf(requestMethod.trim().toUpperCase(Locale.ROOT)); + boolean sendBody = method != Method.GET && method != Method.HEAD; + Assert.isTrue(requestUrl.startsWith("http://") || requestUrl.startsWith("https://"), + "请求地址必须以http://或https://开头"); + Assert.isTrue(timeoutSeconds != null && timeoutSeconds >= 1 && timeoutSeconds <= 300, + "超时必须在1000至300000毫秒之间"); + for (PersonApiRequestParam param : params) { + // GET/HEAD不使用Body;JSON模式也不校验暂存的form-data行。 + if ("BODY".equals(param.getParamLocation()) && (!sendBody || "JSON".equals(bodyType))) { + continue; + } + Assert.hasText(param.getParamName(), "请求参数名称不能为空"); + Assert.isTrue(List.of("HEADER", "QUERY", "BODY").contains(param.getParamLocation()), + "请求参数位置不正确"); + if (Boolean.TRUE.equals(param.getRequired())) { + Assert.hasText(param.getParamValue(), "必填请求参数值不能为空:" + param.getParamName()); + } + } + String url = appendQuery(requestUrl, params, "DATA".equals(scope) ? testCondition : null); + // Query已按UTF-8编码;禁用URL二次解码/编码,保留%2B等特殊字符的原始含义。 + HttpRequest request = HttpRequest.of(url, null) + .method(method) + .timeout((timeoutSeconds == null ? 30 : timeoutSeconds) * 1000); + Map form = new LinkedHashMap<>(); + for (PersonApiRequestParam param : params) { + if ("HEADER".equals(param.getParamLocation())) { + request.header(param.getParamName(), Strings.sNull(param.getParamValue())); + } else if (sendBody && "BODY".equals(param.getParamLocation()) && "FORM_DATA".equals(bodyType)) { + form.put(param.getParamName(), param.getParamValue()); + } + } + if (sendBody && "JSON".equals(bodyType)) { + if (StrUtil.isNotBlank(bodyContent)) { + try { + Object body = com.alibaba.fastjson.JSON.parse(bodyContent); + if (omitToken) { + bodyContent = com.alibaba.fastjson.JSON.toJSONString( + normalizeJsonRoot(stripTokenJsonParameters(body)) + ); + } + } catch (com.alibaba.fastjson.JSONException e) { + throw new IllegalArgumentException("Body不是有效的JSON,请检查后重试", e); + } + request.body(bodyContent).header("Content-Type", "application/json; charset=UTF-8"); + } + } else if (sendBody && "FORM_DATA".equals(bodyType) && !form.isEmpty()) { + // 当前项目Hutool通过Content-Type识别纯文本multipart,并自动生成boundary。 + request.form(form).header("Content-Type", "multipart/form-data"); + } + try (HttpResponse response = request.execute()) { + String responseBody = response.body(); + // 按排查要求打印第三方响应原文;返回值复用同一正文,不附加请求参数或统计信息。 + Logs.get().info(responseBody); + return NutMap.NEW() + .addv("success", response.isOk()) + .addv("status", response.getStatus()) + .addv("elapsed", System.currentTimeMillis() - start) + .addv("data", responseBody); + } catch (cn.hutool.core.io.IORuntimeException | cn.hutool.http.HttpException e) { + // 无HTTP响应时status为null,不伪造第三方状态码,也不把请求头或Token写入错误详情。 + String error = "第三方请求失败,请检查服务地址、网络连接及服务端日志"; + Throwable cause = e; + for (int depth = 0; cause != null && depth < 20; depth++, cause = cause.getCause()) { + if (cause instanceof java.net.SocketTimeoutException) { + error = "第三方连接或读取响应超时"; + break; + } + if (cause instanceof java.net.UnknownHostException) { + error = "第三方域名解析失败"; + break; + } + if (cause instanceof java.net.ConnectException) { + error = "无法连接第三方服务,请检查地址、端口及网络"; + break; + } + if (cause instanceof javax.net.ssl.SSLException) { + error = "第三方HTTPS连接失败,请检查证书及TLS配置"; + break; + } + } + return NutMap.NEW().addv("success", false).addv("status", null) + .addv("elapsed", System.currentTimeMillis() - start).addv("data", null).addv("error", error); + } + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public PersonApiTestCase saveTestCase(PersonApiTestCase testCase) { + Assert.hasText(testCase.getConfigId(), "API配置ID不能为空"); + Assert.hasText(testCase.getCaseName(), "请填写测试用例名称"); + Assert.notNull(fetch(testCase.getConfigId()), "API配置不存在"); + String requestScope = Strings.isBlank(testCase.getRequestScope()) ? "DATA" : testCase.getRequestScope(); + Assert.isTrue(List.of("TOKEN", "DATA").contains(requestScope), "测试请求范围不正确"); + testCase.setRequestScope(requestScope); + testCase.setExecutedAt(new Date()); + return dao().insert(testCase); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public PersonApiTestCase saveTestCase(String data) { + return saveTestCase(Json.fromJson(PersonApiTestCase.class, data)); + } + + @Override + public void validateTaskCron(String cron) { + Assert.hasText(cron, "启用同步任务前请填写任务周期(Quartz Cron)"); + Assert.isTrue(cron.trim().length() <= 50, "任务周期长度不能超过50个字符"); + Assert.isTrue(CronExpression.isValidExpression(cron.trim()), "任务周期不是合法的Quartz Cron表达式"); + try { + Assert.notNull(new CronExpression(cron.trim()).getNextValidTimeAfter(new Date()), "任务周期没有未来执行时间"); + } catch (java.text.ParseException e) { + throw new IllegalArgumentException("任务周期格式不正确", e); + } + } + + /** + * 按执行类和data.configId确认任务归属;停用保留记录并暂停,启用核对周期后恢复。 + * 历史重复任务只禁用,不删除任务记录、同步批次或中间表数据。 + */ + @Aop(TransAop.READ_COMMITTED) + private void reconcileTask(PersonApiConfig config) { + try { + List ownedTasks = dao().query(Sys_task.class, + Cnd.where("jobClass", "=", PersonGovernanceSyncJob.class.getName()).asc("id")) + .stream().filter(task -> belongsToConfig(task.getData(), config.getId())).toList(); + Sys_task task = ownedTasks.stream().filter(item -> Objects.equals(item.getId(), config.getTaskId())) + .findFirst().orElse(ownedTasks.isEmpty() ? null : ownedTasks.get(0)); + + boolean enabled = Boolean.TRUE.equals(config.getEnabled()); + // 停用时暂停全部关联任务;启用时仅允许保留的主任务恢复,历史副本继续暂停。 + for (JobKey oldKey : scheduler.getJobKeys(org.quartz.impl.matchers.GroupMatcher.anyJobGroup())) { + JobDetail oldJob = scheduler.getJobDetail(oldKey); + if (oldJob != null && PersonGovernanceSyncJob.class.equals(oldJob.getJobClass()) + && Objects.equals(config.getId(), oldJob.getJobDataMap().getString("configId")) + && (!enabled || task == null || !oldKey.equals(JobKey.jobKey(task.getId(), task.getId())))) { + scheduler.pauseJob(oldKey); + } + } + for (Sys_task duplicate : ownedTasks) { + if (task != null && !Objects.equals(task.getId(), duplicate.getId())) { + duplicate.setDisabled(true); + dao().update(duplicate); + } + } + if (task == null && !enabled) { + config.setTaskId(null); + update(org.nutz.dao.Chain.make("taskId", null), Cnd.where("id", "=", config.getId())); + return; + } + boolean existing = task != null; + if (!existing) task = new Sys_task(); + task.setName(StrUtil.sub("人员中间表同步:" + config.getConfigName(), 0, 50)); + task.setJobClass(PersonGovernanceSyncJob.class.getName()); + boolean cronChanged = !Objects.equals(task.getCron(), config.getTaskCron()); + // 停用期间只保存配置草稿;再次启用时才将新周期同步到任务及Quartz。 + if (enabled) { + task.setCron(config.getTaskCron()); + task.setData(Json.toJson(NutMap.NEW().addv("configId", config.getId()).addv("taskCron", config.getTaskCron()))); + } + task.setNote("仅追加中间表,按周期执行,不立即触发"); + task.setDisabled(!enabled); + if (existing) dao().update(task); else dao().insert(task); + // Sys_task的PrevInsert会生成真实主键,必须插入后再回写,不能预先分配ID。 + config.setTaskId(task.getId()); + update(org.nutz.dao.Chain.make("taskId", task.getId()), Cnd.where("id", "=", config.getId())); + JobKey key = JobKey.jobKey(task.getId(), task.getId()); + if (enabled) { + JobDetail existingJob = scheduler.getJobDetail(key); + Assert.isTrue(existingJob == null || (PersonGovernanceSyncJob.class.equals(existingJob.getJobClass()) + && Objects.equals(config.getId(), existingJob.getJobDataMap().getString("configId"))), + "任务标识被其他调度占用,请检查任务配置"); + JobDetail job = JobBuilder.newJob(PersonGovernanceSyncJob.class).withIdentity(key) + .usingJobData("configId", config.getId()).usingJobData("taskCron", config.getTaskCron()).build(); + TriggerKey triggerKey = TriggerKey.triggerKey(task.getId(), task.getId()); + Trigger oldTrigger = scheduler.getTrigger(triggerKey); + Assert.isTrue(oldTrigger == null || key.equals(oldTrigger.getJobKey()), "触发器标识被其他任务占用"); + Date now = new Date(); + boolean reload = cronChanged || !(oldTrigger instanceof CronTrigger) + || !Objects.equals(((CronTrigger) oldTrigger).getCronExpression(), config.getTaskCron()) + || oldTrigger.getMisfireInstruction() != CronTrigger.MISFIRE_INSTRUCTION_DO_NOTHING + || oldTrigger.getNextFireTime() == null || !oldTrigger.getNextFireTime().after(now); + if (existingJob == null || reload) { + // 恢复时跳过暂停期间错过的时间,即使尚未达到Quartz的misfire阈值也不补执行。 + Date next = CronScheduleBuilder.cronSchedule(config.getTaskCron()).build().getFireTimeAfter(now); + Assert.notNull(next, "任务周期没有未来执行时间"); + CronTrigger trigger = TriggerBuilder.newTrigger().withIdentity(triggerKey).forJob(key) + .startAt(next).withSchedule(CronScheduleBuilder.cronSchedule(config.getTaskCron()) + .withMisfireHandlingInstructionDoNothing()).build(); + if (existingJob == null) { + scheduler.scheduleJob(job, trigger); + } else { + scheduler.addJob(job, true, true); + if (oldTrigger == null) scheduler.scheduleJob(trigger); + else Assert.notNull(scheduler.rescheduleJob(triggerKey, trigger), "更新任务触发器失败,请重试"); + } + } else if (!Objects.equals(existingJob.getJobDataMap().getString("taskCron"), config.getTaskCron())) { + scheduler.addJob(job, true, true); + } + scheduler.resumeJob(key); + } + } catch (SchedulerException e) { + throw new IllegalStateException("同步任务调度更新失败,请重试", e); + } + } + + /** 旧任务参数可能为空或损坏;无法确认归属时保留,不误删其他任务。 */ + private boolean belongsToConfig(String data, String configId) { + if (Strings.isBlank(data) || Strings.isBlank(configId)) return false; + try { + NutMap values = Json.fromJson(NutMap.class, data); + return values != null && Objects.equals(configId, values.getString("configId")); + } catch (RuntimeException e) { + return false; + } + } + + @Override + public List selectableConfigs(String currentMatchConfigId) { + Sql sql = Sqls.create( + "SELECT c.id,c.configName,c.targetTable,m.id matchConfigId,m.configName matchConfigName " + + "FROM person_api_config c " + + "LEFT JOIN person_member_match_config m ON m.apiConfigId=c.id AND m.delFlag=0 " + + "WHERE c.delFlag=0 ORDER BY c.configName" + ); + List rows = listMap(sql); + rows.forEach(row -> row.setv( + "selectable", + row.get("matchConfigId") == null + || Objects.equals(currentMatchConfigId, row.getString("matchConfigId")) + )); + return rows; + } + + private String appendQuery(String url, List params, String testCondition) { + List values = new ArrayList<>(); + for (PersonApiRequestParam param : params) { + if ("QUERY".equals(param.getParamLocation())) { + values.add(encode(param.getParamName()) + "=" + encode(Strings.sNull(param.getParamValue()))); + } + } + if (StrUtil.isNotBlank(testCondition)) { + values.add("testCondition=" + encode(testCondition)); + } + if (values.isEmpty()) { + return url; + } + // 参数必须位于片段标识#之前;保留URL已有Query,避免出现?&或&&。 + int fragmentIndex = url.indexOf('#'); + String address = fragmentIndex < 0 ? url : url.substring(0, fragmentIndex); + String fragment = fragmentIndex < 0 ? "" : url.substring(fragmentIndex); + String separator = address.contains("?") ? (address.endsWith("?") || address.endsWith("&") ? "" : "&") : "?"; + return address + separator + String.join("&", values) + fragment; + } + + /** 历史记录缺少范围时按数据接口处理。 */ + private String normalizeScope(String requestScope) { + return Strings.isBlank(requestScope) ? "DATA" : requestScope; + } + + /** + * 解析数据库中的数据查询结果。新数据保存Redis Key,历史数据仍可能直接保存响应原文。 + * + * @param storedValue MySQL中的dataQueryResult值,可能是Redis Key或历史响应原文 + * @return Redis中的原始响应;历史原文直接返回;缓存不存在时返回空字符串 + */ + private String resolveDataQueryResult(String storedValue) { + if (!isDataQueryResultKey(storedValue)) { + return storedValue; + } + return Strings.sNull(redisService.get(storedValue)); + } + + /** 生成固定业务前缀加32位随机值的Redis Key。 */ + private String buildDataQueryResultKey() { + return RedisConstant.REDIS_KEY_PERSON_API_DATA_QUERY_RESULT + R.UU32(); + } + + /** 仅识别本业务生成的Key,避免把历史响应原文误当作Redis Key。 */ + private boolean isDataQueryResultKey(String value) { + return Strings.isNotBlank(value) + && value.startsWith(RedisConstant.REDIS_KEY_PERSON_API_DATA_QUERY_RESULT); + } + + /** + * 清理被替换或保存失败的缓存。清理失败不能回滚已成功的配置保存,最多遗留无引用Key。 + */ + private void deleteDataQueryResultQuietly(String key) { + if (!isDataQueryResultKey(key)) { + return; + } + try { + redisService.del(key); + } catch (RuntimeException ignored) { + // Redis清理失败不影响主流程;Key没有MySQL引用,不会影响业务读取。 + } + } + + private String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + private void validateTable(String tableName) { + validateIdentifier(tableName); + Sql sql = Sqls.fetchInt( + "SELECT COUNT(*) FROM information_schema.TABLES " + + "WHERE table_schema=(SELECT DATABASE()) AND table_name=@tableName" + ) + .setParam("tableName", tableName); + dao().execute(sql); + Assert.isTrue(sql.getInt() > 0, "数据表不存在"); + } + + private void validateIdentifier(String identifier) { + Assert.isTrue( + StrUtil.isNotBlank(identifier) && SAFE_IDENTIFIER.matcher(identifier).matches(), + "表名或字段名不合法" + ); + } +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/service/impl/PersonGovernanceSyncServiceImpl.java b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/service/impl/PersonGovernanceSyncServiceImpl.java new file mode 100644 index 00000000..e417ba9f --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/apiConfig/service/impl/PersonGovernanceSyncServiceImpl.java @@ -0,0 +1,683 @@ +package com.budwk.app.zhgh.personGovernance.apiConfig.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.date.DateUtil; +import cn.hutool.crypto.digest.DigestUtil; +import com.budwk.app.base.constant.RedisConstant; +import com.budwk.app.sys.models.Sys_task; +import com.budwk.app.sys.models.Sys_user; +import com.budwk.app.sys.models.Sys_user_history; +import com.budwk.app.sys.models.Sys_user_source; +import com.budwk.app.zhgh.staffmanage.member.constant.MemberChangeOrigin; +import com.budwk.app.zhgh.staffmanage.member.constant.MemberChangeType; +import com.budwk.app.zhgh.personGovernance.apiConfig.model.*; +import com.budwk.app.zhgh.personGovernance.apiConfig.service.PersonApiConfigService; +import com.budwk.app.zhgh.personGovernance.apiConfig.service.PersonGovernanceSyncService; +import com.budwk.app.zhgh.personGovernance.memberMatch.service.PersonMemberMatchService; +import com.budwk.app.zhgh.personGovernance.personException.model.PersonException; +import lombok.extern.slf4j.Slf4j; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.dao.*; +import org.nutz.dao.sql.Sql; +import org.nutz.integration.jedis.RedisService; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.json.Json; +import org.nutz.lang.Lang; +import org.nutz.lang.Strings; +import org.nutz.lang.random.R; +import org.nutz.lang.util.NutMap; +import org.springframework.util.Assert; + +import java.sql.Savepoint; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.*; + +/** + * 按配置批量新增或更新目标表,整批数据的 pullTime 保持一致。 + * 定时任务写入正式人员表时,同步记录新增人员及实际发生字段变化的人员历史。 + */ +@IocBean +@Slf4j +public class PersonGovernanceSyncServiceImpl implements PersonGovernanceSyncService { + private static final int WRITE_BATCH_SIZE = 500; + private static final int MAX_PAGES = 10000; + private static final int MAX_ROWS = 1000000; + @Inject + private Dao dao; + @Inject + private PersonApiConfigService personApiConfigService; + @Inject + private RedisService redisService; + @Inject + private PersonMemberMatchService personMemberMatchService; + + /** + * configId为保存的配置,triggerType为MANUAL/SCHEDULED;返回批次,FAILED时errorMessage为原因。 + * 保存点用于回滚本批中间表插入但保留失败批次,不再将取数失败当作人员缺失。 + */ + @Override + @Aop(TransAop.READ_COMMITTED) + public PersonSyncBatch execute(String configId, String triggerType) { + PersonApiConfig config = dao.fetch(PersonApiConfig.class, configId); + Assert.notNull(config, "API配置不存在"); + Assert.isTrue(!Boolean.TRUE.equals(config.getDelFlag()), "API配置已删除"); + if ("SCHEDULED".equals(triggerType)) { + Assert.isTrue(Boolean.TRUE.equals(config.getEnabled()), "同步任务已停用"); + } + String lockKey = RedisConstant.PLATFORM_REDIS_PREFIX + "personGovernance:sync:" + configId; + String owner = R.UU32(); + Assert.isTrue("OK".equals(redisService.set(lockKey, owner, "NX", "EX", 900)), "该配置正在同步,请勿重复执行"); + PersonSyncBatch batch = new PersonSyncBatch().setConfigId(configId).setBatchNo("PG_" + R.UU32()) + .setTriggerType(Strings.isBlank(triggerType) ? "MANUAL" : triggerType).setStartedAt(new Date()) + .setStatus("RUNNING").setSourceCount(0).setSuccessCount(0).setNewCount(0) + .setChangeCount(0).setExceptionCount(0); + log.info("[人员同步] 开始执行,configId={},batchNo={},triggerType={},sourceType={},sourceTable={},targetTable={},syncMode={}", + configId, batch.getBatchNo(), triggerType, config.getSourceType(), config.getSourceTable(), + config.getTargetTable(), config.getSyncMode()); + Savepoint[] savepoint = new Savepoint[1]; + try { + dao.insert(batch); + dao.run(connection -> { + Assert.isTrue(!connection.getAutoCommit(), "同步必须在事务中执行"); + savepoint[0] = connection.setSavepoint(); + }); + List mappings = dao.query(PersonApiFieldMapping.class, + Cnd.where("configId", "=", configId).asc("sort")); + // 页面会加载整张表的列;批次字段由系统生成,未配置的非必填列交给数据库默认值。 + mappings = mappings.stream() + .filter(m -> !List.of("id", "pullTime").contains(Strings.sNull(m.getTargetField()))) + .filter(m -> Boolean.TRUE.equals(m.getRequired()) + || (Strings.isNotBlank(m.getSourceField()) && Strings.isNotBlank(m.getTargetField()))) + .toList(); + List columns = validateTarget(config, mappings); + log.info("[人员同步] 目标表和字段映射校验完成,configId={},batchNo={},targetTable={},mappingCount={},columnCount={}", + configId, batch.getBatchNo(), config.getTargetTable(), mappings.size(), columns.size()); + if ("SYSTEM_TABLE".equals(config.getSourceType())) { + Assert.isTrue(!config.getTargetTable().equals(config.getSourceTable()), "来源表不能与目标表相同"); + List sourceColumns = personApiConfigService.columns(config.getSourceTable()); + boolean scheduled = "SCHEDULED".equals(triggerType); + Object latestPullTime = null; + Sql sourceSql = Sqls.create("SELECT * FROM `" + config.getSourceTable() + "`"); + if (scheduled) { + boolean hasPullTime = sourceColumns.stream() + .anyMatch(column -> "pullTime".equalsIgnoreCase(column.getString("columnName"))); + Assert.isTrue(hasPullTime, "定时同步的来源系统表必须包含pullTime字段"); + // 定时任务仅消费来源表最新拉取批次,避免每次触发都重复同步全部历史数据。 + Sql latestPullTimeSql = Sqls.create("SELECT MAX(`pullTime`) latestPullTime FROM `" + + config.getSourceTable() + "`"); + List latestPullTimeRows = personApiConfigService.listMap(latestPullTimeSql); + if (!latestPullTimeRows.isEmpty()) { + latestPullTime = latestPullTimeRows.get(0).get("latestPullTime"); + } + sourceSql = Sqls.create("SELECT * FROM `" + config.getSourceTable() + + "` WHERE `pullTime` = (SELECT MAX(latest.`pullTime`) FROM `" + + config.getSourceTable() + "` latest)"); + } + List rows = personApiConfigService.listMap(sourceSql); + log.info("[人员同步] 来源系统表读取完成,configId={},batchNo={},sourceTable={},triggerType={},latestPullTime={},sourceCount={}", + configId, batch.getBatchNo(), config.getSourceTable(), triggerType, latestPullTime, rows.size()); + boolean sourceMissingCheck = isSourceMissingExceptionSync(config); + boolean matchMemberRules = shouldMatchMemberRules(config); + log.info("[人员同步] 治理分支判断,configId={},batchNo={},matchMemberRules={},sourceMissingCheck={}", + configId, batch.getBatchNo(), matchMemberRules, sourceMissingCheck); + log.info("[人员同步] 来源缺失原有条件,configId={},batchNo={},isSysUserTarget={},isUpsert={},execute={}", + configId, batch.getBatchNo(), "sys_user".equalsIgnoreCase(config.getTargetTable()), + "UPSERT".equals(syncMode(config)), sourceMissingCheck); + if (sourceMissingCheck) { + // 工会、福利规则分别计算后合并去重,满足任意一类规则的人员均参与异常判断。 + List beforeMatchedIds = personMemberMatchService.matchedAnyMemberUserIds(configId); + log.info("[人员同步] 写入前两类会员规则查询完成,configId={},batchNo={},matchedCount={}", + configId, batch.getBatchNo(), beforeMatchedIds.size()); + } + log.info("[人员同步] 开始字段映射转换及目标表批量写入,configId={},batchNo={},sourceCount={},mappingCount={}", + configId, batch.getBatchNo(), rows.size(), mappings.size()); + insertPage(config, mappings, columns, rows, batch, lockKey, owner); + log.info("[人员同步] 目标表批量写入完成,configId={},batchNo={},successCount={},newCount={},changeCount={},skipCount={}", + configId, batch.getBatchNo(), batch.getSuccessCount(), batch.getNewCount(), + batch.getChangeCount(), batch.getSourceCount() - batch.getSuccessCount()); + // 会员身份及角色调整仅由配置开关控制,与来源缺失异常的原有执行条件相互独立。 + if (matchMemberRules) { + personMemberMatchService.reconcileForApiConfig(configId); + } else { + log.info("[人员同步] 跳过工会会员和福利会员规则匹配,configId={},batchNo={},原因=是否匹配会员规则未开启", + configId, batch.getBatchNo()); + } + if (sourceMissingCheck) { + recordMissingMemberExceptions(config, mappings, rows, batch); + } else { + log.info("[人员同步] 跳过来源缺失异常处理,configId={},batchNo={},原因=仅SYSTEM_TABLE+sys_user+UPSERT执行", + configId, batch.getBatchNo()); + } + } else { + List params = dao.query(PersonApiRequestParam.class, + Cnd.where("configId", "=", configId).asc("sort")); + NutMap paging = personApiConfigService.pagination(config, params); + long page = paging.getLong("cursor"); + int pageSize = paging.getInt("size"); + boolean singleLimitRequest = "LIMIT".equals(paging.getString("mode")); + Set fingerprints = new HashSet<>(); + for (int count = 0; ; count++) { + Assert.isTrue(count < MAX_PAGES, "超过最大分页次数,已终止并回滚本批次"); + renewLock(lockKey, owner); + log.info("[人员同步] 开始读取接口分页,configId={},batchNo={},pageIndex={},cursor={},pageSize={}", + configId, batch.getBatchNo(), count + 1, page, pageSize); + Object response = personApiConfigService.querySyncPage(config, params, page, pageSize); + Object dataset = personApiConfigService.readResponsePath(response, config.getResponseDataPath()); + Assert.isTrue(dataset instanceof List, "数据集映射未找到数组,请检查返回结果路径"); + List rows = (List) dataset; + log.info("[人员同步] 接口分页读取完成,configId={},batchNo={},pageIndex={},rowCount={}", + configId, batch.getBatchNo(), count + 1, rows.size()); + if (!rows.isEmpty() && pageSize > 0) { + Assert.isTrue(fingerprints.add(DigestUtil.sha256Hex(Json.toJson(rows))), "接口重复返回同一页,已终止并回滚本批次"); + } + insertPage(config, mappings, columns, rows, batch, lockKey, owner); + // 按接口原始条数判定,已存在而跳过的记录仍属于这一页。 + // 仅数量模式是单次限量查询,即使正好返回配置数量也不能继续构造下一页请求。 + if (singleLimitRequest || pageSize == 0 || rows.size() < pageSize) break; + page = Math.addExact(page, paging.getLong("step")); + } + // 第三方接口同步同样以配置开关为准,分页全部写入完成后只执行一次会员规则匹配。 + if (shouldMatchMemberRules(config)) { + personMemberMatchService.reconcileForApiConfig(configId); + } else { + log.info("[人员同步] 跳过工会会员和福利会员规则匹配,configId={},batchNo={},原因=是否匹配会员规则未开启", + configId, batch.getBatchNo()); + } + } + batch.setStatus("SUCCESS").setFinishedAt(new Date()); + log.info("[人员同步] 执行成功,configId={},batchNo={},sourceCount={},successCount={},newCount={},changeCount={},exceptionCount={}", + configId, batch.getBatchNo(), batch.getSourceCount(), batch.getSuccessCount(), batch.getNewCount(), + batch.getChangeCount(), batch.getExceptionCount()); + dao.update(PersonApiConfig.class, Chain.make("lastSyncTime", batch.getFinishedAt()), Cnd.where("id", "=", configId)); + } catch (Exception e) { + log.error("[人员同步] 执行失败,configId={},batchNo={},sourceTable={},targetTable={},syncMode={},原因={}", + configId, batch.getBatchNo(), config.getSourceTable(), config.getTargetTable(), + config.getSyncMode(), e.getMessage(), e); + if (savepoint[0] == null) throw new IllegalStateException("同步批次初始化失败", e); + dao.run(connection -> connection.rollback(savepoint[0])); + batch.setStatus("FAILED").setSuccessCount(0).setNewCount(0).setChangeCount(0).setFinishedAt(new Date()) + .setErrorMessage(e.getMessage() == null ? "同步执行失败" : e.getMessage()); + } finally { + redisService.eval("if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end", + List.of(lockKey), List.of(owner)); + } + dao.update(batch); + // 任务列表按秒级exeAt展示执行时间;失败批次也回写原因,避免误认为未触发。 + if ("SCHEDULED".equals(batch.getTriggerType()) && Strings.isNotBlank(config.getTaskId())) { + String result = "SUCCESS".equals(batch.getStatus()) + ? "执行成功,新增" + batch.getNewCount() + "条,更新" + batch.getChangeCount() + "条,跳过" + + (batch.getSourceCount() - batch.getSuccessCount()) + "条,批次:" + batch.getBatchNo() + : "执行失败,批次:" + batch.getBatchNo() + ",原因:" + batch.getErrorMessage(); + dao.update(Sys_task.class, Chain.make("exeAt", batch.getStartedAt().getTime() / 1000) + .add("exeResult", result), + Cnd.where("id", "=", config.getTaskId()).and("jobClass", "=", + "com.budwk.app.zhgh.personGovernance.apiConfig.job.PersonGovernanceSyncJob")); + } + return batch; + } + + /** 工会会员与福利会员匹配只受配置开关控制,两类来源均可在同步完成后执行。 */ + private boolean shouldMatchMemberRules(PersonApiConfig config) { + return Boolean.TRUE.equals(config.getMatchMemberRules()); + } + + /** 来源缺失异常保留原有执行条件,不受会员规则匹配开关影响。 */ + private boolean isSourceMissingExceptionSync(PersonApiConfig config) { + return "SYSTEM_TABLE".equals(config.getSourceType()) + && "sys_user".equalsIgnoreCase(config.getTargetTable()) + && "UPSERT".equals(syncMode(config)); + } + + /** 来源缺失且命中工会或福利任意一类会员规则的人员写入待确认异常,已有待确认记录只更新发现信息。 */ + @Aop(TransAop.READ_COMMITTED) + private void recordMissingMemberExceptions(PersonApiConfig config, List mappings, + List sourceRows, PersonSyncBatch batch) { + PersonApiFieldMapping identityMapping = mappings.stream() + .filter(mapping -> Objects.equals(config.getIdentityField(), mapping.getTargetField())) + .findFirst().orElse(null); + if (identityMapping == null) { + log.warn("[人员同步] 跳过异常人员比较,configId={},batchNo={},原因=未找到唯一标识字段映射,identityField={}", + config.getId(), batch.getBatchNo(), config.getIdentityField()); + return; + } + Set sourceIdentities = sourceRows.stream() + .map(row -> Strings.sNull(personApiConfigService.readResponsePath(row, identityMapping.getSourceField()))) + .filter(Strings::isNotBlank) + .collect(java.util.stream.Collectors.toSet()); + log.info("[人员同步] 来源唯一标识整理完成,configId={},batchNo={},sourceCount={},validIdentityCount={}", + config.getId(), batch.getBatchNo(), sourceRows.size(), sourceIdentities.size()); + List matchedIds = personMemberMatchService.matchedAnyMemberUserIds(config.getId()); + if (matchedIds.isEmpty()) { + log.warn("[人员同步] 异常人员比较结束,configId={},batchNo={},原因=会员规则未命中任何人员", + config.getId(), batch.getBatchNo()); + return; + } + Assert.isTrue(config.getIdentityField().matches("[A-Za-z0-9_]+"), "唯一标识字段不合法"); + // superadmin是系统内置管理账号,不参与来源系统缺失人员的异常判断。 + Sql sql = Sqls.create("SELECT u.id,u.`" + config.getIdentityField() + "` identityValue,u.loginname,u.username,u.unitId,un.name unitName " + + "FROM sys_user u LEFT JOIN sys_unit un ON u.unitId=un.id " + + "WHERE u.id IN (@ids) AND u.loginname<>'superadmin'") + .setParam("ids", matchedIds); + List matchedUsers = personApiConfigService.listMap(sql); + int missingCount = 0; + int insertedCount = 0; + int updatedCount = 0; + for (NutMap user : matchedUsers) { + String identity = user.getString("identityValue"); + if (Strings.isBlank(identity) || sourceIdentities.contains(identity)) continue; + missingCount++; + PersonException exception = dao.fetch(PersonException.class, + Cnd.where("configId", "=", config.getId()).and("userId", "=", user.getString("id")) + .and("status", "=", "PENDING").and("delFlag", "=", false)); + if (exception == null) { + exception = new PersonException().setConfigId(config.getId()).setDiscoverBatchId(batch.getId()) + .setUserId(user.getString("id")).setPersonNo(user.getString("loginname")) + .setPersonName(user.getString("username")).setUnitId(user.getString("unitId")) + .setUnitName(user.getString("unitName")).setExceptionType("SOURCE_MISSING") + .setExceptionReason("来源系统表中不存在该人员").setLastSyncTime(batch.getStartedAt()) + .setDiscoverTime(new Date()).setStatus("PENDING"); + dao.insert(exception); + batch.setExceptionCount(batch.getExceptionCount() + 1); + insertedCount++; + } else { + exception.setDiscoverBatchId(batch.getId()).setLastSyncTime(batch.getStartedAt()) + .setDiscoverTime(new Date()); + dao.update(exception); + updatedCount++; + } + } + log.info("[人员同步] 异常人员比较完成,configId={},batchNo={},matchedUserCount={},sourceMissingCount={},exceptionInsertedCount={},exceptionUpdatedCount={}", + config.getId(), batch.getBatchNo(), matchedUsers.size(), missingCount, insertedCount, updatedCount); + } + + /** 校验目标表和业务唯一标识;数据库唯一约束由批量写入检查,冲突时整批回滚。 */ + private List validateTarget(PersonApiConfig config, List mappings) { + Assert.hasText(config.getTargetTable(), "请选择目标人员中间表"); + List columns = personApiConfigService.columns(config.getTargetTable()); + Map fields = new HashMap<>(); + columns.forEach(c -> fields.put(c.getString("columnName"), c)); + Assert.isTrue(fields.containsKey("id") && fields.containsKey("pullTime"), "目标中间表必须包含id和pullTime字段"); + Assert.isTrue(fields.get("id").getString("columnType").toLowerCase(Locale.ROOT).contains("char"), "中间表id须支持字符串主键"); + Assert.notEmpty(mappings, "请配置字段映射"); + Set targets = new HashSet<>(); + for (PersonApiFieldMapping mapping : mappings) { + Assert.hasText(mapping.getSourceField(), "请补全来源字段"); + Assert.hasText(mapping.getTargetField(), "请补全系统字段"); + Assert.isTrue(fields.containsKey(mapping.getTargetField()), "目标表不存在映射字段:" + mapping.getTargetField()); + Assert.isTrue(targets.add(mapping.getTargetField()), "系统字段不能重复映射:" + mapping.getTargetField()); + } + String mode = syncMode(config); + if (!"APPEND".equals(mode)) { + Assert.hasText(config.getIdentityField(), "去重同步必须配置唯一标识"); + Assert.isTrue(targets.contains(config.getIdentityField()) + && !List.of("id", "pullTime").contains(config.getIdentityField()), "唯一标识必须是有效的业务映射字段"); + } + return columns; + } + + /** 历史配置兼容全部追加;非法模式拒绝执行,避免意外覆盖数据。 */ + private String syncMode(PersonApiConfig config) { + String mode = Strings.isBlank(config.getSyncMode()) ? "APPEND" : config.getSyncMode(); + Assert.isTrue(List.of("APPEND", "UPSERT", "INSERT_NEW").contains(mode), "同步类型不正确"); + return mode; + } + + /** + * 每500条转换并执行JDBC批处理,不累积全部分页结果。 + * rows为接口原始数组;新增与更新数量仅在整块执行成功后计入,外层保存点负责失败回滚。 + */ + @Aop(TransAop.READ_COMMITTED) + private void insertPage(PersonApiConfig config, List mappings, List columns, + List rows, PersonSyncBatch batch, String lockKey, String owner) { + Assert.isTrue((long) batch.getSourceCount() + rows.size() <= MAX_ROWS, "超过单批最大行数,已终止同步"); + batch.setSourceCount(batch.getSourceCount() + rows.size()); + Map types = new HashMap<>(); + columns.forEach(c -> types.put(c.getString("columnName"), c.getString("columnType"))); + String mode = syncMode(config); + PersonApiFieldMapping identityMapping = mappings.stream() + .filter(mapping -> Strings.isNotBlank(config.getIdentityField()) + && Objects.equals(config.getIdentityField(), mapping.getTargetField())) + .findFirst().orElse(null); + for (int offset = 0; offset < rows.size(); offset += WRITE_BATCH_SIZE) { + renewLock(lockKey, owner); + List valuesList = new ArrayList<>(); + int chunkSourceCount = Math.min(offset + WRITE_BATCH_SIZE, rows.size()) - offset; + for (Object row : rows.subList(offset, Math.min(offset + WRITE_BATCH_SIZE, rows.size()))) { + Assert.isTrue(row instanceof Map, "数据集行必须为JSON对象"); + // 先过滤空业务标识,再校验其他必填字段及转换类型;未配置标识的全量追加保持原行为。 + // 不改变原始rows和sourceCount,避免整页被过滤时提前结束分页,跳过数量由原始数减成功数统计。 + if (identityMapping != null) { + Object identity = personApiConfigService.readResponsePath(row, identityMapping.getSourceField()); + if (identity == null || Strings.isBlank(String.valueOf(identity))) { + continue; + } + } + NutMap values = NutMap.NEW(); + for (PersonApiFieldMapping mapping : mappings) { + Object value = personApiConfigService.readResponsePath(row, mapping.getSourceField()); + if (Boolean.TRUE.equals(mapping.getRequired())) { + Assert.isTrue(value != null && !Strings.isBlank(String.valueOf(value)), "必填来源字段为空:" + mapping.getSourceField()); + } + try { + values.put(mapping.getTargetField(), convertValue(value, types.get(mapping.getTargetField()))); + } catch (RuntimeException e) { + throw new IllegalArgumentException("字段类型转换失败:" + mapping.getSourceField() + " -> " + mapping.getTargetField()); + } + } + if (!"APPEND".equals(mode)) { + Object identity = values.get(config.getIdentityField()); + Assert.isTrue(identity != null && Strings.isNotBlank(String.valueOf(identity)), "唯一标识不能为空:" + config.getIdentityField()); + } + values.put("id", R.UU32()); + values.put("pullTime", batch.getStartedAt()); + valuesList.add(values); + } + // 当前500条全部被过滤时不查询目标表,也不提交空批次。 + if (valuesList.isEmpty()) { + log.warn("[人员同步] 数据块转换后无可写入数据,configId={},batchNo={},offset={},sourceCount={},原因=唯一标识为空或映射数据无效", + config.getId(), batch.getBatchNo(), offset, chunkSourceCount); + continue; + } + log.info("[人员同步] 数据块转换完成,configId={},batchNo={},offset={},sourceCount={},convertedCount={},filteredCount={}", + config.getId(), batch.getBatchNo(), offset, chunkSourceCount, valuesList.size(), + chunkSourceCount - valuesList.size()); + int[] counts = new int[2]; + // 保护标识只约束定时任务;人工同步仍保持现有行为,便于管理员主动修复数据。 + boolean protectSysUser = "SCHEDULED".equals(batch.getTriggerType()) + && "sys_user".equalsIgnoreCase(config.getTargetTable()); + dao.run(connection -> writeChunk(connection, config, valuesList, mode, protectSysUser, counts)); + batch.setNewCount(batch.getNewCount() + counts[0]); + batch.setChangeCount(batch.getChangeCount() + counts[1]); + batch.setSuccessCount(batch.getSuccessCount() + counts[0] + counts[1]); + log.info("[人员同步] 数据块写入完成,configId={},batchNo={},offset={},insertCount={},updateCount={}", + config.getId(), batch.getBatchNo(), offset, counts[0], counts[1]); + } + } + + /** + * 目标表及字段已通过元数据校验;所有值使用绑定参数,禁止拼接业务数据。 + * 派生表批量关联按数据库自身排序规则比较唯一标识,并用输入序号返回已有主键。 + * 同块重复来源标识按数据库比较结果归并:更新模式后值覆盖,新增模式首值保留。 + */ + @Aop(TransAop.READ_COMMITTED) + private void writeChunk(Connection connection, PersonApiConfig config, List rows, + String mode, boolean protectSysUser, int[] counts) throws SQLException { + String table = quoteIdentifier(config.getTargetTable()); + Map existing = new HashMap<>(); + Set protectedRows = new HashSet<>(); + Map representatives = new HashMap<>(); + if (!"APPEND".equals(mode)) { + String identity = quoteIdentifier(config.getIdentityField()); + String derived = java.util.stream.IntStream.range(0, rows.size()) + .mapToObj(i -> "SELECT " + i + " AS seq, ? AS val").collect(java.util.stream.Collectors.joining(" UNION ALL ")); + // 一次批量匹配已有数据;同一标识对应多个目标主键时中止,不任意更新。 + String protectedField = protectSysUser ? ",t.`flag`" : ""; + try (PreparedStatement statement = connection.prepareStatement( + "SELECT s.seq,t.`id`" + protectedField + " FROM (" + derived + ") s JOIN " + + table + " t ON t." + identity + "=s.val")) { + for (int i = 0; i < rows.size(); i++) statement.setObject(i + 1, rows.get(i).get(config.getIdentityField())); + try (ResultSet result = statement.executeQuery()) { + while (result.next()) { + int seq = result.getInt(1); + Assert.isTrue(existing.putIfAbsent(seq, result.getString(2)) == null, "目标表唯一标识对应多条记录,请先清理重复数据"); + if (protectSysUser && Integer.valueOf(1).equals(result.getObject(3, Integer.class))) { + protectedRows.add(seq); + } + } + } + } + // 使用目标字段的排序规则归并本块来源值,避免大小写/尾随空格等造成同块重复插入。 + String collation = null; + try (PreparedStatement statement = connection.prepareStatement( + "SELECT COLLATION_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND COLUMN_NAME=?")) { + statement.setString(1, config.getTargetTable()); + statement.setString(2, config.getIdentityField()); + try (ResultSet result = statement.executeQuery()) { + if (result.next()) collation = result.getString(1); + } + } + String comparison = "a.val=b.val"; + if (collation != null) { + Assert.isTrue(collation.matches("[A-Za-z0-9_]+"), "目标字段排序规则不合法"); + String charset = collation.substring(0, collation.indexOf('_')); + comparison = "CONVERT(a.val USING " + charset + ") COLLATE " + collation + + "=CONVERT(b.val USING " + charset + ") COLLATE " + collation; + } + try (PreparedStatement statement = connection.prepareStatement( + "SELECT a.seq,MIN(b.seq) FROM (" + derived + ") a JOIN (" + derived + + ") b ON " + comparison + " GROUP BY a.seq")) { + for (int i = 0; i < rows.size(); i++) { + Object value = rows.get(i).get(config.getIdentityField()); + statement.setObject(i + 1, value); + statement.setObject(rows.size() + i + 1, value); + } + try (ResultSet result = statement.executeQuery()) { + while (result.next()) representatives.put(result.getInt(1), result.getInt(2)); + } + } + } + Map inserts = new LinkedHashMap<>(); + Map updates = new LinkedHashMap<>(); + for (int i = 0; i < rows.size(); i++) { + NutMap row = rows.get(i); + String id = existing.get(i); + int representative = representatives.getOrDefault(i, i); + if (id != null) { + // 定时任务命中受保护人员时,不写人员字段,也不计入更新成功数。 + if (protectedRows.contains(i)) { + continue; + } + if ("UPSERT".equals(mode)) { + row.put("id", id); + updates.put(id, row); + } + } else if (!inserts.containsKey(representative)) { + inserts.put(representative, row); + } else if ("UPSERT".equals(mode)) { + row.put("id", inserts.get(representative).get("id")); + inserts.put(representative, row); + } + } + List updateSnapshots = new ArrayList<>(); + Map> updateChangeTypes = new LinkedHashMap<>(); + if (protectSysUser && !updates.isEmpty()) { + // 一次查询更新前快照;只有映射业务字段实际变化时才生成历史,pullTime变化不计入人员变更。 + List currentUsers = dao.query(Sys_user.class, + Cnd.where("id", "in", updates.keySet().toArray(new String[0]))); + Map currentUserMap = currentUsers.stream() + .collect(java.util.stream.Collectors.toMap(Sys_user::getId, user -> user)); + for (Map.Entry entry : updates.entrySet()) { + Sys_user currentUser = currentUserMap.get(entry.getKey()); + if (currentUser == null) { + continue; + } + List changeTypes = resolveChangeTypes(currentUser, entry.getValue()); + if (!changeTypes.isEmpty()) { + updateSnapshots.add(currentUser); + updateChangeTypes.put(currentUser.getId(), changeTypes); + } + } + } + if (!inserts.isEmpty()) { + if ("sys_user_source".equals(config.getTargetTable())) { + // 实体集合一次提交,保留 Sys_user_source/BaseModel 的插入拦截器及默认字段,不创建正式人员。 + List entities = inserts.values().stream() + .map(row -> BeanUtil.toBean(row, Sys_user_source.class)).toList(); + dao.insert(entities); + } else { + executeWriteBatch(connection, table, new ArrayList<>(inserts.values()), false); + } + } + if (!updates.isEmpty()) executeWriteBatch(connection, table, new ArrayList<>(updates.values()), true); + if (protectSysUser) { + String changeReason = syncHistoryReason(config); + recordSyncHistories(updateSnapshots, updateChangeTypes, changeReason); + if (!inserts.isEmpty()) { + List insertedUsers = dao.query(Sys_user.class, + Cnd.where("id", "in", inserts.values().stream() + .map(row -> row.getString("id")).toArray(String[]::new))); + Map> insertChangeTypes = new LinkedHashMap<>(); + insertedUsers.forEach(user -> insertChangeTypes.put(user.getId(), + List.of(MemberChangeType.NEW.name()))); + recordSyncHistories(insertedUsers, insertChangeTypes, changeReason); + } + } + counts[0] = inserts.size(); + counts[1] = updates.size(); + } + + /** + * 比较更新前人员快照与本次映射值,返回 MemberChangeType 编码;空集合表示没有实际业务变化。 + * id、pullTime属于主键或同步批次字段,不参与人员变更判断。 + */ + private List resolveChangeTypes(Sys_user currentUser, NutMap incomingValues) { + NutMap currentValues = Lang.obj2nutmap(currentUser); + Set changeTypes = new LinkedHashSet<>(); + boolean basicChanged = false; + for (Map.Entry entry : incomingValues.entrySet()) { + String field = entry.getKey(); + if (List.of("id", "pullTime").contains(field) + || valuesEqual(currentValues.get(field), entry.getValue())) { + continue; + } + switch (field) { + case "unitId", "unitid" -> changeTypes.add(MemberChangeType.UNIT_CHANGE.name()); + case "unionId", "unionid" -> changeTypes.add(MemberChangeType.UNION_CHANGE.name()); + case "member" -> changeTypes.add(Boolean.TRUE.equals(entry.getValue()) + ? MemberChangeType.RESTORE.name() : MemberChangeType.WITHDRAWAL.name()); + case "userState" -> { + String userState = Strings.sNull(entry.getValue()); + Optional stateType = Arrays.stream(MemberChangeType.values()) + .filter(type -> Objects.equals(type.getChangeTypeName(), userState)) + .findFirst(); + if (stateType.isPresent()) { + changeTypes.add(stateType.get().name()); + } else { + basicChanged = true; + } + } + default -> basicChanged = true; + } + } + if (basicChanged) { + changeTypes.add(MemberChangeType.BASIC_CHANGE.name()); + } + return new ArrayList<>(changeTypes); + } + + /** + * 保存人员同步前快照。 + * + * @param users 更新前或新增后的人员快照 + * @param changeTypesByUser 用户ID对应的变更类型集合 + * @param changeReason 本次同步配置形成的变更原因 + */ + @Aop(TransAop.READ_COMMITTED) + private void recordSyncHistories(List users, + Map> changeTypesByUser, + String changeReason) { + if (users == null || users.isEmpty()) { + return; + } + List histories = new ArrayList<>(); + for (Sys_user user : users) { + List changeTypes = changeTypesByUser.get(user.getId()); + if (changeTypes == null || changeTypes.isEmpty()) { + continue; + } + Sys_user_history history = new Sys_user_history(); + BeanUtil.copyProperties(user, history); + history.setId(R.UU32()); + history.setChangeTime(new Date()); + history.setChangeOrigin(MemberChangeOrigin.SYSTEM.name()); + history.setChangeReason(changeReason); + history.setChangeTypes(changeTypes); + histories.add(history); + } + if (!histories.isEmpty()) { + dao.insert(histories); + } + } + + /** 数据库数值类型和空字符串表现可能不同,统一后比较,避免相同业务值产生虚假历史。 */ + private boolean valuesEqual(Object currentValue, Object incomingValue) { + if (currentValue == null || incomingValue == null) { + return Strings.isBlank(Strings.sNull(currentValue)) && Strings.isBlank(Strings.sNull(incomingValue)); + } + if (currentValue instanceof Number && incomingValue instanceof Number) { + return new java.math.BigDecimal(currentValue.toString()) + .compareTo(new java.math.BigDecimal(incomingValue.toString())) == 0; + } + if (currentValue instanceof Date && incomingValue instanceof Date) { + return ((Date) currentValue).getTime() == ((Date) incomingValue).getTime(); + } + return Objects.equals(String.valueOf(currentValue), String.valueOf(incomingValue)); + } + + /** sys_user_history.changeReason 长度为 100,超长配置名称截断后保存。 */ + private String syncHistoryReason(PersonApiConfig config) { + String reason = "API配置定时同步:" + Strings.sBlank(config.getConfigName()); + return reason.length() > 100 ? reason.substring(0, 100) : reason; + } + + /** JDBC executeBatch真正批量提交;更新只覆盖映射字段和pullTime,不覆盖目标主键及未映射字段。 */ + @Aop(TransAop.READ_COMMITTED) + private void executeWriteBatch(Connection connection, String table, List rows, boolean update) throws SQLException { + List fields = new ArrayList<>(rows.get(0).keySet()); + if (update) fields.remove("id"); + String sql = update + ? "UPDATE " + table + " SET " + fields.stream().map(f -> quoteIdentifier(f) + "=?") + .collect(java.util.stream.Collectors.joining(",")) + " WHERE `id`=?" + : "INSERT INTO " + table + " (" + fields.stream().map(this::quoteIdentifier) + .collect(java.util.stream.Collectors.joining(",")) + ") VALUES (" + + String.join(",", Collections.nCopies(fields.size(), "?")) + ")"; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + for (NutMap row : rows) { + int index = 1; + for (String field : fields) { + Object value = row.get(field); + statement.setObject(index++, value instanceof Date ? new java.sql.Timestamp(((Date) value).getTime()) : value); + } + if (update) statement.setObject(index, row.get("id")); + statement.addBatch(); + } + int[] results = statement.executeBatch(); + Assert.isTrue(results.length == rows.size(), "数据库批量写入结果数量不一致"); + for (int result : results) Assert.isTrue(result != java.sql.Statement.EXECUTE_FAILED, "数据库批量写入失败"); + } + } + + /** 标识符来自已校验元数据,再限制字符,不能将请求输入直接拼接到SQL。 */ + private String quoteIdentifier(String value) { + Assert.isTrue(value != null && value.matches("[A-Za-z0-9_]+"), "表名或字段名不合法"); + return "`" + value + "`"; + } + + private Object convertValue(Object value, String sqlType) { + if (value == null) return null; + String type = sqlType.toLowerCase(Locale.ROOT); + String text = String.valueOf(value); + if (type.startsWith("date") || type.startsWith("timestamp")) return value instanceof Date ? value : DateUtil.parse(text); + if (type.startsWith("bit") || type.startsWith("tinyint(1)")) { + if (value instanceof Boolean) return value; + Assert.isTrue(List.of("0", "1", "true", "false").contains(text.toLowerCase(Locale.ROOT)), "布尔值不合法"); + return "1".equals(text) || "true".equalsIgnoreCase(text); + } + if (type.contains("int")) return new java.math.BigDecimal(text).toBigIntegerExact(); + if (type.startsWith("decimal") || type.startsWith("double") || type.startsWith("float")) return new java.math.BigDecimal(text); + return value instanceof Map || value instanceof List ? Json.toJson(value) : text; + } + + /** 每页及每100行续租;失去锁即终止,避免两个进程并行采集相同配置。 */ + @Aop(TransAop.READ_COMMITTED) + private void renewLock(String key, String owner) { + Object renewed = redisService.eval("if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('expire',KEYS[1],900) else return 0 end", + List.of(key), List.of(owner)); + Assert.isTrue(renewed instanceof Number && ((Number) renewed).longValue() == 1, "同步锁已失效,请重试"); + } +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/controller/PersonMemberMatchController.java b/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/controller/PersonMemberMatchController.java new file mode 100644 index 00000000..36908187 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/controller/PersonMemberMatchController.java @@ -0,0 +1,115 @@ +package com.budwk.app.zhgh.personGovernance.memberMatch.controller; + +import com.budwk.app.base.param.PageForm; +import com.budwk.app.zhgh.personGovernance.memberMatch.service.PersonMemberMatchService; +import cn.dev33.satoken.annotation.SaCheckPermission; +import com.budwk.app.base.result.Result; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; +import org.nutz.mvc.annotation.Param; +import org.nutz.lang.Strings; +import org.springframework.util.Assert; + +/** 会员匹配列表、配置、试算与应用接口。 */ +@IocBean +@Ok("json:full") +@At("/platform/personGovernance/memberMatch") +public class PersonMemberMatchController { + @Inject + private PersonMemberMatchService personMemberMatchService; + + @At("") + @Ok("beetl:/platform/personGovernance/memberMatch/index.html") + @SaCheckPermission("data.person.governance.member") + public void index() { + } + + @At("/form") + @Ok("beetl:/platform/personGovernance/memberMatch/form.html") + @SaCheckPermission("data.person.governance.member") + public void form() { + } + + /** + * pageForm为分页参数,configName和apiConfigId为可选筛选条件。 + * enabled未传或为空时不筛选状态,仅接受true/false作为明确的启停筛选值。 + * 返回分页结果,包含list记录列表、totalCount总条数等分页信息。 + */ + @At + @SaCheckPermission("data.person.governance.member") + public Result pageData(PageForm pageForm, @Param("configName") String configName, + @Param("apiConfigId") String apiConfigId, + @Param("enabled") String enabled) { + // 保留原始文本,避免Nutz把空字符串转换为false而误筛选停用记录。 + Boolean enabledFilter = null; + if (Strings.isNotBlank(enabled)) { + String value = enabled.trim(); + Assert.isTrue("true".equalsIgnoreCase(value) || "false".equalsIgnoreCase(value), "启用状态参数不正确"); + enabledFilter = Boolean.valueOf(value); + } + return Result.success(personMemberMatchService.pageData(pageForm, configName, apiConfigId, enabledFilter)); + } + + @At + @SaCheckPermission("data.person.governance.member") + public Result detail(String id) { + return Result.success(personMemberMatchService.detail(id)); + } + + /** + * 保存共享配置及相互隔离的工会、福利会员条件组,不在保存请求中执行匹配规则。 + * + * @param data JSON字符串,包含config、unionGroups、welfareGroups;组内conditions为条件,groups为嵌套子条件组 + * @return 保存后的会员匹配主配置 + */ + @At + @Aop(TransAop.READ_COMMITTED) + @SaCheckPermission("data.person.governance.member.edit") + public Result save(@Param("data") String data) { + return Result.success(personMemberMatchService.saveConfig(data)); + } + + /** + * @param id 会员匹配配置ID + * @param ruleType UNION_MEMBER为工会会员规则,WELFARE_MEMBER为福利会员规则 + * @param pageForm 分页参数 + * @return 对应规则类型命中的人员分页结果 + */ + @At + @SaCheckPermission("data.person.governance.member") + public Result calculate(String id, String ruleType, PageForm pageForm) { + return Result.success(personMemberMatchService.calculate(id, ruleType, pageForm)); + } + + @At + @Aop(TransAop.READ_COMMITTED) + @SaCheckPermission("data.person.governance.member.delete") + public Result delete(String id) { + personMemberMatchService.deleteConfig(id); + return Result.success(); + } + + @At + @SaCheckPermission("data.person.governance.member") + public Result historyConfigs() { + return Result.success(personMemberMatchService.historyConfigs()); + } + + @At + @SaCheckPermission("data.person.governance.member") + public Result dictItems(String dictCode) { + return Result.success(personMemberMatchService.dictItems(dictCode)); + } + + @At + @Aop(TransAop.READ_COMMITTED) + @SaCheckPermission("data.person.governance.member.edit") + public Result updateEnabled(String id, boolean enabled) { + personMemberMatchService.updateEnabled(id, enabled); + return Result.success(); + } +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/model/PersonMemberMatchCondition.java b/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/model/PersonMemberMatchCondition.java new file mode 100644 index 00000000..21428b6d --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/model/PersonMemberMatchCondition.java @@ -0,0 +1,63 @@ +package com.budwk.app.zhgh.personGovernance.memberMatch.model; + +import lombok.Data; +import lombok.experimental.Accessors; +import org.nutz.dao.entity.annotation.*; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +/** + * 会员匹配条件,保留字典项快照避免字典改名影响历史配置展示。 + */ +@Data +@Accessors(chain = true) +@Table("person_member_match_condition") +public class PersonMemberMatchCondition { + @Name + @Column + @Comment("主键ID") + @PrevInsert(uu32 = true) + @ColDefine(type = ColType.VARCHAR, width = 32) + private String id; + @Column + @Comment("条件组ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String groupId; + @Column + @Comment("字典编码") + @ColDefine(type = ColType.VARCHAR, width = 50) + private String dictCode; + @Column + @Comment("字典数据ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String dictItemId; + @Column + @Comment("字典数据编码") + @ColDefine(type = ColType.VARCHAR, width = 50) + private String dictItemCode; + @Column + @Comment("字典数据名称") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String dictItemName; + @Column + @Comment("人员映射字段") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String mappedField; + @Column + @Comment("匹配方式") + @ColDefine(type = ColType.VARCHAR, width = 30) + private String operator; + @Column + @Comment("匹配值") + @ColDefine(type = ColType.VARCHAR, width = 500) + private String matchValue; + + @Column + @Comment("与前一条件的连接关系:AND且、OR或;组内首条忽略,历史空值按AND") + @ColDefine(type = ColType.VARCHAR, width = 3) + private String joinLogic; + + @Column + @Comment("排序号") + private Integer sort; +} + diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/model/PersonMemberMatchConfig.java b/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/model/PersonMemberMatchConfig.java new file mode 100644 index 00000000..dff8b32f --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/model/PersonMemberMatchConfig.java @@ -0,0 +1,53 @@ +package com.budwk.app.zhgh.personGovernance.memberMatch.model; + +import com.budwk.app.base.model.BaseModel; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; +import org.nutz.dao.entity.annotation.*; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +import java.util.Date; + +/** + * 会员匹配主配置;一个API配置只能绑定一条记录。 + */ +@Data +@Accessors(chain = true) +@EqualsAndHashCode(callSuper = true) +@Table("person_member_match_config") +@TableIndexes({@Index(name = "UK_PERSON_MEMBER_API_CONFIG", fields = {"apiConfigId"}, unique = true)}) +public class PersonMemberMatchConfig extends BaseModel { + @Name + @Column + @Comment("主键ID") + @PrevInsert(uu32 = true) + @ColDefine(type = ColType.VARCHAR, width = 32) + private String id; + @Column + @Comment("会员匹配配置名称") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String configName; + @Column + @Comment("绑定的API配置ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String apiConfigId; + @Column + @Comment("沿用的历史匹配配置ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String copiedFromId; + @Column + @Comment("是否启用") + private Boolean enabled; + @Column + @Comment("条件组数量") + private Integer groupCount; + @Column + @Comment("最近匹配会员数量") + private Integer lastMatchCount; + @Column + @Comment("最近执行时间") + private Date lastExecuteTime; +} + + diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/model/PersonMemberMatchGroup.java b/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/model/PersonMemberMatchGroup.java new file mode 100644 index 00000000..ca6fea3f --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/model/PersonMemberMatchGroup.java @@ -0,0 +1,49 @@ +package com.budwk.app.zhgh.personGovernance.memberMatch.model; + +import lombok.Data; +import lombok.experimental.Accessors; +import org.nutz.dao.entity.annotation.*; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +/** + * 会员条件组;每组作为独立括号,parentGroupId描述嵌套关系,joinLogic表示与同层前一节点的连接关系。 + */ +@Data +@Accessors(chain = true) +@Table("person_member_match_group") +public class PersonMemberMatchGroup { + @Name + @Column + @Comment("主键ID") + @PrevInsert(uu32 = true) + @ColDefine(type = ColType.VARCHAR, width = 32) + private String id; + @Column + @Comment("会员匹配配置ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String matchConfigId; + + @Column + @Comment("父条件组ID;空值表示顶层条件组") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String parentGroupId; + + @Column + @Comment("匹配规则类型:UNION_MEMBER工会会员、WELFARE_MEMBER福利会员") + @ColDefine(type = ColType.VARCHAR, width = 30) + private String ruleType; + + @Column + @Comment("条件组编号") + private Integer groupNo; + + @Column + @Comment("与前一条件组的连接关系:AND且、OR或;首组忽略,历史空值按OR") + @ColDefine(type = ColType.VARCHAR, width = 3) + private String joinLogic; + + @Column + @Comment("排序号") + private Integer sort; +} + diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/service/PersonMemberMatchService.java b/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/service/PersonMemberMatchService.java new file mode 100644 index 00000000..dd139c40 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/service/PersonMemberMatchService.java @@ -0,0 +1,55 @@ +package com.budwk.app.zhgh.personGovernance.memberMatch.service; + +import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.param.PageForm; +import com.budwk.app.base.service.BaseService; +import com.budwk.app.zhgh.personGovernance.memberMatch.model.PersonMemberMatchConfig; +import org.nutz.lang.util.NutMap; + +import java.util.List; + +/** 会员匹配配置、试算及应用服务。 */ +public interface PersonMemberMatchService extends BaseService { + Pagination pageData(PageForm pageForm, String configName, String apiConfigId, Boolean enabled); + + NutMap detail(String id); + + /** + * 保存会员匹配主配置及工会、福利会员条件组,不立即执行匹配规则。 + * + * @param payloadJson 包含config、unionGroups、welfareGroups的JSON字符串;每个条件组包含conditions及可递归嵌套的groups + * @return 保存后的会员匹配主配置 + */ + PersonMemberMatchConfig saveConfig(String payloadJson); + + /** + * 按规则类型试算匹配结果。 + * + * @param id 会员匹配配置ID + * @param ruleType UNION_MEMBER表示工会会员规则,WELFARE_MEMBER表示福利会员规则 + * @param pageForm 分页参数 + * @return 当前规则类型命中的人员分页结果 + */ + Pagination calculate(String id, String ruleType, PageForm pageForm); + + void deleteConfig(String id); + + List historyConfigs(); + + List dictItems(String dictCode); + + void applyForApiConfig(String apiConfigId); + + /** + * 返回当前API配置中任意会员规则命中的sys_user用户ID。 + * 工会会员与福利会员规则分别计算后合并去重,满足任意一类规则即返回。 + */ + List matchedAnyMemberUserIds(String apiConfigId); + + /** 分别按工会、福利规则同步对应会员字段及角色,某一类未配置规则时跳过该类身份处理。 */ + void reconcileForApiConfig(String apiConfigId); + + void updateEnabled(String id, boolean enabled); +} + + diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/service/impl/PersonMemberMatchServiceImpl.java b/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/service/impl/PersonMemberMatchServiceImpl.java new file mode 100644 index 00000000..c8739959 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/memberMatch/service/impl/PersonMemberMatchServiceImpl.java @@ -0,0 +1,684 @@ +package com.budwk.app.zhgh.personGovernance.memberMatch.service.impl; + +import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.dao.CndPlus; +import com.budwk.app.base.param.PageForm; +import com.budwk.app.base.service.impl.BaseServiceImpl; +import com.budwk.app.base.constant.RoleConstant; +import com.budwk.app.sys.models.Sys_role; +import com.budwk.app.sys.models.Sys_user; +import com.budwk.app.sys.models.Sys_user_role; +import com.budwk.app.sys.models.Sys_dict; +import com.budwk.app.sys.services.SysDictService; +import com.budwk.app.sys.services.SysUserService; +import com.budwk.app.zhgh.personGovernance.apiConfig.model.PersonApiConfig; +import com.budwk.app.zhgh.personGovernance.apiConfig.model.PersonApiFieldMapping; +import com.budwk.app.zhgh.personGovernance.memberMatch.model.PersonMemberMatchCondition; +import com.budwk.app.zhgh.personGovernance.memberMatch.model.PersonMemberMatchConfig; +import com.budwk.app.zhgh.personGovernance.memberMatch.model.PersonMemberMatchGroup; +import com.budwk.app.zhgh.personGovernance.memberMatch.service.PersonMemberMatchService; +import lombok.extern.slf4j.Slf4j; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.dao.Chain; +import org.nutz.dao.Cnd; +import org.nutz.dao.Dao; +import org.nutz.dao.Sqls; +import org.nutz.dao.sql.Sql; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.json.Json; +import org.nutz.lang.Strings; +import org.nutz.lang.util.NutMap; +import org.springframework.util.Assert; + +import java.util.ArrayList; +import java.util.Date; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** 工会、福利两类递归条件组会员匹配:每组独立括号,同层条件及子组均支持AND/OR。 */ +@IocBean(args = {"refer:dao"}) +@Slf4j +public class PersonMemberMatchServiceImpl extends BaseServiceImpl implements PersonMemberMatchService { + private static final Pattern SAFE_IDENTIFIER = Pattern.compile("^[A-Za-z0-9_]+$"); + private static final String RULE_TYPE_UNION_MEMBER = "UNION_MEMBER"; + private static final String RULE_TYPE_WELFARE_MEMBER = "WELFARE_MEMBER"; + + public PersonMemberMatchServiceImpl(Dao dao) { + super(dao); + } + + @Inject + private SysDictService sysDictService; + + @Inject + private SysUserService sysUserService; + + @Override + public Pagination pageData(PageForm pageForm, String configName, String apiConfigId, Boolean enabled) { + Sql sql = Sqls.create( + "SELECT m.id,m.configName,m.apiConfigId,m.enabled,m.lastExecuteTime,a.configName apiConfigName " + + "FROM person_member_match_config m " + + "LEFT JOIN person_api_config a ON a.id=m.apiConfigId $condition" + ); + CndPlus cnd = CndPlus.create(); + cnd.andEX("m.configName", "like", Strings.isBlank(configName) ? null : "%" + configName + "%"); + cnd.andEX("m.apiConfigId", "=", apiConfigId).andEX("m.enabled", "=", enabled); + cnd.and("m.delFlag", "=", false).desc("m.updatedAt"); + sql.setCondition(cnd); + return listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql); + } + + @Override + public NutMap detail(String id) { + PersonMemberMatchConfig config = fetch(id); + Assert.notNull(config, "会员匹配配置不存在"); + List groups = dao().query(PersonMemberMatchGroup.class, + Cnd.where("matchConfigId", "=", id).asc("sort")); + List unionGroupRows = buildGroupTree(groups, RULE_TYPE_UNION_MEMBER); + List welfareGroupRows = buildGroupTree(groups, RULE_TYPE_WELFARE_MEMBER); + return NutMap.NEW() + .addv("config", config) + .addv("unionGroups", unionGroupRows) + .addv("welfareGroups", welfareGroupRows) + .addv("groups", unionGroupRows); + } + + /** + * 保存会员匹配配置。payload包含config、unionGroups和welfareGroups;编辑时apiConfigId不得变化。 + * groups可递归嵌套;groups[].joinLogic为与同层前一节点的关系,conditions[].joinLogic为与前条条件的关系。 + * 同层首组、组内首条不参与连接;历史缺省值分别为OR、AND,返回保存后的配置实体。 + * 保存操作仅持久化配置,会员匹配由后续同步流程按既定周期触发。 + */ + @Override + @Aop(TransAop.READ_COMMITTED) + public PersonMemberMatchConfig saveConfig(String payloadJson) { + NutMap payload = Json.fromJson(NutMap.class, payloadJson); + PersonMemberMatchConfig config = Json.fromJson( + PersonMemberMatchConfig.class, + Json.toJson(payload.get("config")) + ); + List unionGroups = parseGroups( + payload.get("unionGroups") == null ? payload.get("groups") : payload.get("unionGroups") + ); + List welfareGroups = parseGroups(payload.get("welfareGroups")); + Assert.hasText(config.getConfigName(), "请填写配置名称"); + Assert.hasText(config.getApiConfigId(), "请选择API配置"); + Assert.isTrue(!unionGroups.isEmpty() || !welfareGroups.isEmpty(), "至少添加一类会员匹配条件组"); + // 先验证所有连接关系和条件,再写主配置,避免非法规则进入保存及应用链路。 + List unionGroupNodes = validateGroups(unionGroups, "工会会员"); + List welfareGroupNodes = validateGroups(welfareGroups, "福利会员"); + PersonMemberMatchConfig bound = fetch( + Cnd.where("apiConfigId", "=", config.getApiConfigId()).and("delFlag", "=", false) + ); + Assert.isTrue(bound == null || Objects.equals(bound.getId(), config.getId()), "该API配置已绑定其他会员匹配配置"); + boolean enabled = Boolean.TRUE.equals(config.getEnabled()); + config.setEnabled(enabled); + if (Strings.isBlank(config.getId())) { + config.setDelFlag(false); + insert(config); + } else { + PersonMemberMatchConfig old = fetch(config.getId()); + Assert.notNull(old, "会员匹配配置不存在"); + Assert.isTrue(Objects.equals(old.getApiConfigId(), config.getApiConfigId()), "编辑时不能更换API配置"); + config.setDelFlag(old.getDelFlag()); + update(config); + clearChildren(config.getId()); + } + if (enabled) { + disableOtherEnabled(config.getId()); + } + insertGroups(config.getId(), RULE_TYPE_UNION_MEMBER, null, unionGroupNodes); + insertGroups(config.getId(), RULE_TYPE_WELFARE_MEMBER, null, welfareGroupNodes); + return config; + } + + @Override + public Pagination calculate(String id, String ruleType, PageForm pageForm) { + return listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), + buildMatchSql(id, normalizeRuleType(ruleType), false)); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void deleteConfig(String id) { + Assert.notNull(fetch(id), "会员匹配配置不存在"); + clearChildren(id); + delete(id); + } + + @Override + public List historyConfigs() { + Sql sql = Sqls.create( + "SELECT id,configName FROM person_member_match_config WHERE delFlag=0 ORDER BY updatedAt DESC" + ); + return listMap(sql); + } + + @Override + public List dictItems(String dictCode) { + Assert.hasText(dictCode, "请输入字典编码"); + List result = new ArrayList<>(); + for (Sys_dict item : sysDictService.getSubListByCode(dictCode)) { + result.add( + NutMap.NEW() + .addv("id", item.getId()) + .addv("code", item.getCode()) + .addv("name", item.getName()) + ); + } + return result; + } + + /** 同步完成后应用当前API配置唯一绑定且已启用的工会、福利会员规则。 */ + @Override + @Aop(TransAop.READ_COMMITTED) + public void applyForApiConfig(String apiConfigId) { + PersonMemberMatchConfig config = fetch(Cnd.where("apiConfigId", "=", apiConfigId) + .and("enabled", "=", true).and("delFlag", "=", false)); + if (config != null) { + apply(config.getId()); + } + } + + @Override + public List matchedAnyMemberUserIds(String apiConfigId) { + Set matchedIds = new LinkedHashSet<>(); + matchedIds.addAll(matchedUserIds(apiConfigId, RULE_TYPE_UNION_MEMBER)); + matchedIds.addAll(matchedUserIds(apiConfigId, RULE_TYPE_WELFARE_MEMBER)); + log.info("[会员匹配] 两类会员规则命中人员合并完成,apiConfigId={},matchedCount={}", + apiConfigId, matchedIds.size()); + return new ArrayList<>(matchedIds); + } + + /** 根据指定规则类型返回命中的系统用户ID,工会和福利规则始终分开计算。 */ + private List matchedUserIds(String apiConfigId, String ruleType) { + PersonMemberMatchConfig config = fetch(Cnd.where("apiConfigId", "=", apiConfigId) + .and("enabled", "=", true).and("delFlag", "=", false)); + if (config == null) { + log.warn("[会员匹配] 未找到启用的会员匹配配置,apiConfigId={},ruleType={}", apiConfigId, ruleType); + return new ArrayList<>(); + } + List matchGroups = ruleGroups(config.getId(), ruleType); + if (matchGroups.isEmpty()) { + log.info("[会员匹配] 当前类型未配置匹配规则,跳过计算,apiConfigId={},ruleType={}", apiConfigId, ruleType); + return new ArrayList<>(); + } + List allMatchGroups = allRuleGroups(config.getId(), ruleType); + int groupCount = allMatchGroups.size(); + int conditionCount = allMatchGroups.stream() + .mapToInt(group -> dao().count(PersonMemberMatchCondition.class, + Cnd.where("groupId", "=", group.getId()))) + .sum(); + List matchedIds = resolveMatchedUserIds(config.getId(), ruleType); + log.info("[会员匹配] 规则查询完成,apiConfigId={},matchConfigId={},ruleType={},matchedCount={}", + apiConfigId, config.getId(), ruleType, matchedIds.size()); + log.info("[会员匹配] 规则配置统计,apiConfigId={},ruleType={},groupCount={},conditionCount={},matchedCount={}", + apiConfigId, ruleType, groupCount, conditionCount, matchedIds.size()); + if (matchedIds.isEmpty()) { + log.warn("[会员匹配] 没有人员命中规则,apiConfigId={},matchConfigId={},ruleType={},请检查映射字段、匹配方式和匹配值", + apiConfigId, config.getId(), ruleType); + } + return matchedIds; + } + + /** 全量同步时按最新规则重算会员角色;未命中的已有会员同步取消。 */ + @Override + @Aop(TransAop.READ_COMMITTED) + public void reconcileForApiConfig(String apiConfigId) { + PersonMemberMatchConfig config = fetch(Cnd.where("apiConfigId", "=", apiConfigId) + .and("enabled", "=", true).and("delFlag", "=", false)); + if (config == null) { + log.warn("[会员匹配] 未找到启用的会员匹配配置,跳过身份同步,apiConfigId={}", apiConfigId); + return; + } + if (!ruleGroups(config.getId(), RULE_TYPE_UNION_MEMBER).isEmpty()) { + reconcileUnionMembers(apiConfigId); + } + if (!ruleGroups(config.getId(), RULE_TYPE_WELFARE_MEMBER).isEmpty()) { + reconcileWelfareMembers(apiConfigId); + } + } + + /** 工会会员规则只维护member字段和工会会员角色,不触碰福利会员身份。 */ + private void reconcileUnionMembers(String apiConfigId) { + List matchedIds = matchedUserIds(apiConfigId, RULE_TYPE_UNION_MEMBER); + Set matched = new HashSet<>(matchedIds); + // flag=1 的人员由管理员保护,定时规则既不能新增其角色,也不能回收其现有角色。 + List members = dao().query(Sys_user.class, Cnd.where("member", "=", 1) + .and(Cnd.exps("flag", "IS", null).or("flag", "!=", 1))); + Set currentMemberIds = members.stream().map(Sys_user::getId).collect(Collectors.toSet()); + List addIds = matchedIds.stream() + .filter(id -> !currentMemberIds.contains(id)) + .toList(); + List removeIds = currentMemberIds.stream() + .filter(id -> !matched.contains(id)).toList(); + log.info("[会员匹配] 全量会员资格计算完成,apiConfigId={},currentMemberCount={},matchedCount={},addRoleCount={},removeRoleCount={}", + apiConfigId, members.size(), matchedIds.size(), addIds.size(), removeIds.size()); + if (!removeIds.isEmpty()) { + updateMemberRole(removeIds, false); + log.info("[会员匹配] 会员角色移除完成,apiConfigId={},removeRoleCount={}", apiConfigId, removeIds.size()); + } + if (!addIds.isEmpty()) { + // 已是工会会员的人员不重复删除、插入角色,也不刷新原入会时间。 + updateMemberRole(addIds, true); + log.info("[会员匹配] 会员角色设置完成,apiConfigId={},addRoleCount={}", apiConfigId, addIds.size()); + } + } + + /** 福利会员规则只维护welfareMember字段和福利会员角色,不触碰工会会员身份。 */ + private void reconcileWelfareMembers(String apiConfigId) { + List matchedIds = matchedUserIds(apiConfigId, RULE_TYPE_WELFARE_MEMBER); + Set matched = new HashSet<>(matchedIds); + List members = dao().query(Sys_user.class, Cnd.where("welfareMember", "=", 1) + .and(Cnd.exps("flag", "IS", null).or("flag", "!=", 1))); + Set currentMemberIds = members.stream().map(Sys_user::getId).collect(Collectors.toSet()); + List addIds = matchedIds.stream() + .filter(id -> !currentMemberIds.contains(id)) + .toList(); + List removeIds = currentMemberIds.stream() + .filter(id -> !matched.contains(id)).toList(); + log.info("[福利会员匹配] 全量资格计算完成,apiConfigId={},currentMemberCount={},matchedCount={},addRoleCount={},removeRoleCount={}", + apiConfigId, members.size(), matchedIds.size(), addIds.size(), removeIds.size()); + if (!removeIds.isEmpty()) { + removeWelfareMemberRole(removeIds); + } + if (!addIds.isEmpty()) { + // 已是福利会员的人员不重复删除、插入角色,也不刷新原福利会员入会时间。 + addWelfareMemberRole(addIds); + } + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void updateEnabled(String id, boolean enabled) { + Assert.notNull(fetch(id), "会员匹配配置不存在"); + if (enabled) { + disableOtherEnabled(id); + } + update(org.nutz.dao.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(org.nutz.dao.Chain.make("enabled", false), cnd); + } + + @Aop(TransAop.READ_COMMITTED) + private void apply(String id) { + if (!ruleGroups(id, RULE_TYPE_UNION_MEMBER).isEmpty()) { + List unionMemberIds = resolveMatchedUserIds(id, RULE_TYPE_UNION_MEMBER); + if (!unionMemberIds.isEmpty()) { + updateMemberRole(unionMemberIds, true); + } + } + if (!ruleGroups(id, RULE_TYPE_WELFARE_MEMBER).isEmpty()) { + List welfareMemberIds = resolveMatchedUserIds(id, RULE_TYPE_WELFARE_MEMBER); + if (!welfareMemberIds.isEmpty()) { + addWelfareMemberRole(welfareMemberIds); + } + } + update( + Chain.make("lastExecuteTime", new Date()), + Cnd.where("id", "=", id) + ); + } + + /** 会员规则固定查询sys_user,直接提取命中人员ID并去重。 */ + private List resolveMatchedUserIds(String id, String ruleType) { + List rows = listMap(buildMatchSql(id, ruleType, true)); + List userIds = new ArrayList<>(); + for (NutMap row : rows) { + String userId = row.getString("id"); + if (Strings.isNotBlank(userId) && !userIds.contains(userId)) { + userIds.add(userId); + } + } + return userIds; + } + + private Sql buildMatchSql(String id, String ruleType, boolean allRows) { + PersonMemberMatchConfig matchConfig = fetch(id); + Assert.notNull(matchConfig, "会员匹配配置不存在"); + PersonApiConfig apiConfig = dao().fetch(PersonApiConfig.class, matchConfig.getApiConfigId()); + Assert.notNull(apiConfig, "绑定的API配置不存在"); + Set allowedFields = new HashSet<>(); + dao().query(PersonApiFieldMapping.class, Cnd.where("configId", "=", apiConfig.getId())) + .forEach(v -> allowedFields.add(v.getTargetField())); + List groups = ruleGroups(id, ruleType); + Assert.notEmpty(groups, ruleTypeName(ruleType) + "匹配条件不能为空"); + List allGroups = allRuleGroups(id, ruleType); + Map> childrenByParent = new LinkedHashMap<>(); + for (PersonMemberMatchGroup group : allGroups) { + if (Strings.isNotBlank(group.getParentGroupId())) { + childrenByParent.computeIfAbsent(group.getParentGroupId(), key -> new ArrayList<>()).add(group); + } + } + StringBuilder where = new StringBuilder(); + Map values = new LinkedHashMap<>(); + int[] paramIndex = {0}; + for (PersonMemberMatchGroup group : groups) { + if (where.length() > 0) { + where.append(' ').append(normalizeJoinLogic(group.getJoinLogic(), "OR")).append(' '); + } + appendGroupExpression(where, group, childrenByParent, allowedFields, values, paramIndex); + } + // 会员匹配始终基于正式人员当前数据,API配置只提供规则字段,不参与查询表和批次选择。 + Sql sql = Sqls.create( + "SELECT t.*,un.name AS unitName,gh.unionname AS unionName FROM sys_user t" + + " LEFT JOIN sys_unit un ON t.unitid=un.id" + + " LEFT JOIN sys_union gh ON un.unionid=gh.id" + + " WHERE (t.flag IS NULL OR t.flag<>1) AND (" + where + ")" + + (allRows ? "" : " ORDER BY 1") + ); + values.forEach(sql::setParam); + return sql; + } + + /** 将平铺条件组按parentGroupId还原为前端需要的递归结构,孤立子组按顶层组兜底展示。 */ + @SuppressWarnings("unchecked") + private List buildGroupTree(List groups, String ruleType) { + Map rowsById = new LinkedHashMap<>(); + for (PersonMemberMatchGroup group : groups) { + if (!normalizeRuleType(ruleType).equals(normalizeRuleType(group.getRuleType()))) { + continue; + } + rowsById.put( + group.getId(), + NutMap.NEW() + .addv("group", group) + .addv("conditions", dao().query( + PersonMemberMatchCondition.class, + Cnd.where("groupId", "=", group.getId()).asc("sort") + )) + .addv("groups", new ArrayList()) + ); + } + List roots = new ArrayList<>(); + for (PersonMemberMatchGroup group : groups) { + NutMap row = rowsById.get(group.getId()); + if (row == null) { + continue; + } + NutMap parent = rowsById.get(group.getParentGroupId()); + if (Strings.isBlank(group.getParentGroupId()) || parent == null) { + roots.add(row); + } else { + ((List) parent.get("groups")).add(row); + } + } + return roots; + } + + /** 递归生成单个条件组表达式;当前组及每个子组都会显式包裹一层括号。 */ + private void appendGroupExpression(StringBuilder where, PersonMemberMatchGroup group, + Map> childrenByParent, + Set allowedFields, Map values, int[] paramIndex) { + List conditions = dao().query( + PersonMemberMatchCondition.class, + Cnd.where("groupId", "=", group.getId()).asc("sort") + ); + List children = childrenByParent.getOrDefault(group.getId(), new ArrayList<>()); + Assert.isTrue(!conditions.isEmpty() || !children.isEmpty(), "条件组中至少包含一条条件或一个子条件组"); + where.append('('); + boolean hasExpression = false; + for (PersonMemberMatchCondition condition : conditions) { + if (hasExpression) { + where.append(' ').append(normalizeJoinLogic(condition.getJoinLogic(), "AND")).append(' '); + } + appendConditionExpression(where, condition, allowedFields, values, paramIndex); + hasExpression = true; + } + for (PersonMemberMatchGroup child : children) { + if (hasExpression) { + where.append(' ').append(normalizeJoinLogic(child.getJoinLogic(), "OR")).append(' '); + } + appendGroupExpression(where, child, childrenByParent, allowedFields, values, paramIndex); + hasExpression = true; + } + where.append(')'); + } + + /** 将一条受白名单约束的匹配条件转换为参数化SQL,避免字段名及匹配值直接拼接。 */ + private void appendConditionExpression(StringBuilder where, PersonMemberMatchCondition condition, + Set allowedFields, Map values, int[] paramIndex) { + Assert.isTrue(allowedFields.contains(condition.getMappedField()), "匹配字段不属于当前API配置"); + validateIdentifier(condition.getMappedField()); + String field = "t.`" + condition.getMappedField() + "`"; + String value = Strings.isBlank(condition.getMatchValue()) + ? condition.getDictItemCode() + : condition.getMatchValue(); + if ("IN".equals(condition.getOperator())) { + String[] parts = value.split(","); + where.append(field).append(" IN ("); + for (int i = 0; i < parts.length; i++) { + if (i > 0) { + where.append(','); + } + String key = "p" + paramIndex[0]++; + where.append('@').append(key); + values.put(key, parts[i].trim()); + } + where.append(')'); + return; + } + String key = "p" + paramIndex[0]++; + String operator = switch (condition.getOperator()) { + case "NE" -> "<>"; + case "CONTAINS" -> "LIKE"; + default -> "="; + }; + where.append(field).append(' ').append(operator).append(" @").append(key); + values.put(key, "CONTAINS".equals(condition.getOperator()) ? "%" + value + "%" : value); + } + + /** payload中的某类规则允许为空;空规则表示该会员类型暂不参与试算和身份同步。 */ + private List parseGroups(Object value) { + if (value == null) { + return new ArrayList<>(); + } + return Json.fromJsonAsList(NutMap.class, Json.toJson(value)); + } + + /** 校验一类会员规则的递归条件组、连接关系和必填条件,返回后续入库使用的规则树。 */ + private List validateGroups(List groups, String ruleName) { + List nodes = new ArrayList<>(); + for (NutMap row : groups) { + String joinLogic = normalizeJoinLogic(row.getString("joinLogic"), "OR"); + List conditions = Json.fromJsonAsList( + PersonMemberMatchCondition.class, + Json.toJson(row.get("conditions")) + ); + List childGroups = parseGroups(row.get("groups")); + Assert.isTrue(!conditions.isEmpty() || !childGroups.isEmpty(), + ruleName + "条件组中至少包含一条条件或一个子条件组"); + for (PersonMemberMatchCondition condition : conditions) { + Assert.hasText(condition.getMappedField(), ruleName + "规则请选择映射字段"); + Assert.hasText(condition.getOperator(), ruleName + "规则请选择匹配方式"); + condition.setJoinLogic(normalizeJoinLogic(condition.getJoinLogic(), "AND")); + } + nodes.add(new RuleGroupNode(joinLogic, conditions, validateGroups(childGroups, ruleName))); + } + return nodes; + } + + /** 递归保存条件组;子组通过parentGroupId归属父组,每一层组都对应一个独立括号。 */ + @Aop(TransAop.READ_COMMITTED) + private void insertGroups(String configId, String ruleType, String parentGroupId, List nodes) { + for (int groupIndex = 0; groupIndex < nodes.size(); groupIndex++) { + RuleGroupNode node = nodes.get(groupIndex); + int groupSort = groupIndex + 1; + PersonMemberMatchGroup group = new PersonMemberMatchGroup() + .setMatchConfigId(configId) + .setParentGroupId(parentGroupId) + .setRuleType(ruleType) + .setJoinLogic(node.joinLogic) + .setGroupNo(groupSort) + .setSort(groupSort); + dao().insert(group); + List conditions = node.conditions; + for (int conditionIndex = 0; conditionIndex < conditions.size(); conditionIndex++) { + conditions.get(conditionIndex) + .setId(null) + .setGroupId(group.getId()) + .setSort(conditionIndex + 1); + } + if (!conditions.isEmpty()) { + dao().insert(conditions); + } + insertGroups(configId, ruleType, group.getId(), node.children); + } + } + + /** 历史条件组parentGroupId为空,继续作为顶层条件组执行。 */ + private List ruleGroups(String configId, String ruleType) { + return allRuleGroups(configId, ruleType).stream() + .filter(group -> Strings.isBlank(group.getParentGroupId())) + .toList(); + } + + /** 查询指定会员类型的全部条件组;ruleType空值兼容历史工会会员规则。 */ + private List allRuleGroups(String configId, String ruleType) { + String normalizedType = normalizeRuleType(ruleType); + return dao().query( + PersonMemberMatchGroup.class, + Cnd.where("matchConfigId", "=", configId).asc("sort") + ).stream() + .filter(group -> normalizedType.equals(normalizeRuleType(group.getRuleType()))) + .toList(); + } + + /** 规则类型只接受前端两个固定值,空值仅用于兼容历史工会会员规则。 */ + private String normalizeRuleType(String value) { + if (Strings.isBlank(value)) { + return RULE_TYPE_UNION_MEMBER; + } + Assert.isTrue( + RULE_TYPE_UNION_MEMBER.equals(value) || RULE_TYPE_WELFARE_MEMBER.equals(value), + "会员匹配规则类型不正确" + ); + return value; + } + + private String ruleTypeName(String ruleType) { + return RULE_TYPE_WELFARE_MEMBER.equals(ruleType) ? "福利会员" : "工会会员"; + } + + /** 批量设置福利会员字段;当前项目没有独立福利会员角色,不修改工会会员字段及角色。 */ + @Aop(TransAop.READ_COMMITTED) + private void addWelfareMemberRole(List userIds) { + String[] ids = userIds.toArray(new String[0]); + dao().update( + Sys_user.class, + Chain.make("welfareMember", true) + .add("welfareMemberJoinTime", new Date()), + Cnd.where("id", "in", ids) + ); + sysUserService.clearCache(); + } + + /** 仅取消福利会员字段,不删除福利申请记录,也不修改工会会员身份。 */ + @Aop(TransAop.READ_COMMITTED) + private void removeWelfareMemberRole(List userIds) { + String[] ids = userIds.toArray(new String[0]); + dao().update( + Sys_user.class, + Chain.make("welfareMember", false) + .add("welfareMemberJoinTime", null), + Cnd.where("id", "in", ids) + ); + sysUserService.clearCache(); + } + + /** + * 同步工会会员字段和 MEMBER 角色。 + * + * @param userIds 待处理人员ID + * @param member true加入会员,false退出会员 + */ + @Aop(TransAop.READ_COMMITTED) + private void updateMemberRole(List userIds, boolean member) { + if (userIds == null || userIds.isEmpty()) { + return; + } + String[] ids = userIds.toArray(new String[0]); + Chain chain = Chain.make("member", member); + if (member) { + chain.add("memberTime", new Date()); + } + dao().update(Sys_user.class, chain, Cnd.where("id", "in", ids)); + Sys_role role = dao().fetch(Sys_role.class, Cnd.where("code", "=", RoleConstant.MEMBER.name())); + Assert.notNull(role, "系统未配置会员角色"); + if (member) { + for (String userId : userIds) { + if (dao().count(Sys_user_role.class, + Cnd.where("userId", "=", userId).and("roleId", "=", role.getId())) == 0) { + Sys_user_role userRole = new Sys_user_role(); + userRole.setUserId(userId); + userRole.setRoleId(role.getId()); + userRole.setEnable(true); + dao().insert(userRole); + } + } + } else { + dao().clear(Sys_user_role.class, + Cnd.where("userId", "in", ids).and("roleId", "=", role.getId())); + } + sysUserService.clearCache(); + } + + /** 仅允许固定逻辑关键字进入SQL,空值兼容历史规则,不接受前端传入的SQL片段。 */ + private String normalizeJoinLogic(String value, String defaultValue) { + if (Strings.isBlank(value)) { + return defaultValue; + } + Assert.isTrue("AND".equals(value) || "OR".equals(value), "条件连接关系仅支持且(AND)或或(OR)"); + return value; + } + + @Aop(TransAop.READ_COMMITTED) + private void clearChildren(String configId) { + List groups = dao().query( + PersonMemberMatchGroup.class, + Cnd.where("matchConfigId", "=", configId) + ); + for (PersonMemberMatchGroup group : groups) { + dao().clear(PersonMemberMatchCondition.class, Cnd.where("groupId", "=", group.getId())); + } + dao().clear(PersonMemberMatchGroup.class, Cnd.where("matchConfigId", "=", configId)); + } + + private void validateIdentifier(String value) { + Assert.isTrue( + Strings.isNotBlank(value) && SAFE_IDENTIFIER.matcher(value).matches(), + "表名或字段名不合法" + ); + } + + /** 保存阶段使用的递归规则节点,避免把前端临时树字段写入持久化实体。 */ + private static class RuleGroupNode { + private final String joinLogic; + private final List conditions; + private final List children; + + private RuleGroupNode(String joinLogic, List conditions, + List children) { + this.joinLogic = joinLogic; + this.conditions = conditions; + this.children = children; + } + } +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/personChange/controller/PersonGovernanceChangeController.java b/src/main/java/com/budwk/app/zhgh/personGovernance/personChange/controller/PersonGovernanceChangeController.java new file mode 100644 index 00000000..44343829 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/personChange/controller/PersonGovernanceChangeController.java @@ -0,0 +1,117 @@ +package com.budwk.app.zhgh.personGovernance.personChange.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import com.budwk.app.base.annotation.RepeatSubmit; +import com.budwk.app.base.annotation.SLog; +import com.budwk.app.base.result.Result; +import com.budwk.app.zhgh.personGovernance.personChange.service.PersonGovernanceChangeService; +import com.budwk.app.zhgh.staffmanage.member.models.MemberChangeRecord; +import com.budwk.app.zhgh.staffmanage.member.param.pageform.MemberManagePageForm; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; +import org.nutz.mvc.annotation.POST; +import org.nutz.mvc.annotation.Param; + +/** + * 人员数据治理人员变更页面及接口。 + */ +@IocBean +@At("/platform/personGovernance/personChange") +@Ok("json") +@SaCheckPermission("data.person.governance.change") +public class PersonGovernanceChangeController { + + @Inject + private PersonGovernanceChangeService personGovernanceChangeService; + + @At("") + @Ok("beetl:/platform/personGovernance/personChange/index.html") + public void index() { + } + + @At + public Result pageData(MemberManagePageForm pageForm) { + return Result.success(personGovernanceChangeService.pageData(pageForm)); + } + + @At + @POST + @RepeatSubmit + @Aop(TransAop.READ_COMMITTED) + @SLog(tag = "人员数据治理-人员变更", msg = "提交人员变更") + public Result doSubmitChange(MemberChangeRecord record) { + return personGovernanceChangeService.submitChange(record); + } + + @At + public Result getAllChangeMemberInfo(@Param("userId") String userId) { + return personGovernanceChangeService.getAllChangeMemberInfo(userId); + } + + @At + @POST + @RepeatSubmit + @Aop(TransAop.READ_COMMITTED) + public Result batchUpdateUserAttribute(@Param("users") String users) { + return personGovernanceChangeService.batchUpdateUserAttribute(users); + } + + @At + @POST + @RepeatSubmit + @Aop(TransAop.READ_COMMITTED) + public Result batchUpdateMember(@Param("users") String users) { + return personGovernanceChangeService.batchUpdateMember(users); + } + + @At + @POST + @RepeatSubmit + @Aop(TransAop.READ_COMMITTED) + public Result batchUpdateAidFundType(@Param("users") String users) { + return personGovernanceChangeService.batchUpdateAidFundType(users); + } + + @At + @POST + @RepeatSubmit + @Aop(TransAop.READ_COMMITTED) + public Result batchUpdateUserState(@Param("users") String users) { + return personGovernanceChangeService.batchUpdateUserState(users); + } + + @At + @POST + @RepeatSubmit + @Aop(TransAop.READ_COMMITTED) + public Result unitMove(@Param("unitId") String unitId, @Param("userIds") String userIds) { + return personGovernanceChangeService.unitMove(unitId, userIds); + } + + @At + public Result findOne(@Param("id") String id) { + return personGovernanceChangeService.findOne(id); + } + + @At + public Result findChangeById(@Param("id") String id) { + return personGovernanceChangeService.findChangeById(id); + } + + @At + public Result getUserByIdForMemberChange(@Param("userId") String userId) { + return personGovernanceChangeService.getUserByIdForMemberChange(userId); + } + + /** + * 保留会员高级管理现有导出入口;原页面当前未实现导出内容。 + */ + @At + @Ok("void") + public void doExport() { + } +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/personChange/service/PersonGovernanceChangeService.java b/src/main/java/com/budwk/app/zhgh/personGovernance/personChange/service/PersonGovernanceChangeService.java new file mode 100644 index 00000000..19216173 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/personChange/service/PersonGovernanceChangeService.java @@ -0,0 +1,37 @@ +package com.budwk.app.zhgh.personGovernance.personChange.service; + +import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.result.Result; +import com.budwk.app.sys.views.View_user; +import com.budwk.app.zhgh.staffmanage.member.models.MemberChangeRecord; +import com.budwk.app.zhgh.staffmanage.member.param.pageform.MemberManagePageForm; + +/** + * 人员数据治理中的人员变更服务。 + * + *

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

+ */ +public interface PersonGovernanceChangeService { + + Pagination pageData(MemberManagePageForm pageForm); + + Result submitChange(MemberChangeRecord record); + + Result getAllChangeMemberInfo(String userId); + + Result batchUpdateUserAttribute(String usersJson); + + Result batchUpdateMember(String usersJson); + + Result batchUpdateAidFundType(String usersJson); + + Result batchUpdateUserState(String usersJson); + + Result unitMove(String unitId, String userIdsJson); + + Result findOne(String id); + + Result findChangeById(String id); + + Result getUserByIdForMemberChange(String userId); +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/personChange/service/impl/PersonGovernanceChangeServiceImpl.java b/src/main/java/com/budwk/app/zhgh/personGovernance/personChange/service/impl/PersonGovernanceChangeServiceImpl.java new file mode 100644 index 00000000..5889ac79 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/personChange/service/impl/PersonGovernanceChangeServiceImpl.java @@ -0,0 +1,301 @@ +package com.budwk.app.zhgh.personGovernance.personChange.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.lang.Dict; +import cn.hutool.core.util.DesensitizedUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import com.budwk.app.base.constant.RoleConstant; +import com.budwk.app.base.exception.BaseException; +import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.result.Result; +import com.budwk.app.flow.constant.FlowConst; +import com.budwk.app.flow.engine.FlowEngine; +import com.budwk.app.flow.entity.ProcessInstance; +import com.budwk.app.flow.entity.ProcessTask; +import com.budwk.app.flow.enums.ProcessSubmitTypeEnum; +import com.budwk.app.sys.models.Sys_config; +import com.budwk.app.sys.models.Sys_union; +import com.budwk.app.sys.models.Sys_unit; +import com.budwk.app.sys.models.Sys_user; +import com.budwk.app.sys.views.View_user; +import com.budwk.app.web.commons.auth.utils.AuthUtil; +import com.budwk.app.web.commons.auth.utils.SecurityUtil; +import com.budwk.app.zhgh.personGovernance.personChange.service.PersonGovernanceChangeService; +import com.budwk.app.zhgh.staffmanage.member.constant.MemberChangeOrigin; +import com.budwk.app.zhgh.staffmanage.member.models.MemberChangeRecord; +import com.budwk.app.zhgh.staffmanage.member.param.pageform.MemberManagePageForm; +import com.budwk.app.zhgh.staffmanage.member.service.MemberCommonService; +import lombok.extern.slf4j.Slf4j; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.dao.Cnd; +import org.nutz.dao.Dao; +import org.nutz.dao.sql.Sql; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.json.Json; +import org.nutz.lang.Lang; +import org.nutz.lang.util.NutMap; + +import java.util.Date; +import java.util.List; + +/** + * 人员数据治理人员变更服务实现。 + */ +@Slf4j +@IocBean +public class PersonGovernanceChangeServiceImpl implements PersonGovernanceChangeService { + + @Inject + private Dao dao; + @Inject + private FlowEngine flowEngine; + @Inject + private MemberCommonService memberCommonService; + + @Override + public Pagination pageData(MemberManagePageForm pageForm) { + Sql sql = memberCommonService.getSql(pageForm, "schoolUnion"); + return memberCommonService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public Result submitChange(MemberChangeRecord record) { + if (Lang.isEmpty(record)) { + return Result.error("未获取到异动数据"); + } + if (StrUtil.isBlank(record.getId())) { + memberCommonService.validateApplyOrChangeIsDoing(record.getUserId(), "change"); + } + + boolean schoolRole = AuthUtil.hasRoleOr(RoleConstant.SCHOOL_UNION_CHAIRMAN.name(), + RoleConstant.SCHOOL_UNION_MEMBER_ADMIN.name(), RoleConstant.SYSADMIN.name()); + boolean branchRole = AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_CHAIRMAN.name(), + RoleConstant.BRANCH_UNION_OPERATOR.name()); + boolean groupRole = AuthUtil.hasRole(RoleConstant.BRANCH_UNION_GROUP_LEADER.name()); + MemberChangeOrigin changeOrigin = schoolRole ? MemberChangeOrigin.SCHOOL_UNION + : branchRole ? MemberChangeOrigin.BRANCH_UNION + : groupRole ? MemberChangeOrigin.UNION_GROUP : null; + if (changeOrigin == null) { + throw new BaseException("没有权限,或未设置工会小组"); + } + + memberCommonService.validateChangeAndSetBasicData(record, changeOrigin); + dao.insertOrUpdate(record); + + Dict args = Dict.create(); + args.set("submit", "admin"); + if (schoolRole) { + args.set("adminSubmit", "schoolUnion"); + } else if (branchRole) { + args.set("adminSubmit", "branchUnion"); + } else { + args.set("adminSubmit", "unionGroup"); + Sys_config config = dao.fetch(Sys_config.class, + Cnd.where(Sys_config::getConfigKey, "=", "UnionGroupIsNeedBranchAudit")); + args.set("groupAuditNext", config != null && "true".equals(config.getConfigValue()) + ? "branchAudit" : "schoolAudit"); + } + args.set(FlowConst.SUBMIT_TYPE, ProcessSubmitTypeEnum.APPLY.getCode()); + args.set(FlowConst.FORM_DATA, record); + ProcessInstance instance = flowEngine.startProcessInstanceByKey( + "HYBG", record.getId(), SecurityUtil.getUserId(), args); + List taskList = flowEngine.processTaskService().getDoingTaskList(instance.getId(), null); + for (ProcessTask task : taskList) { + flowEngine.executeProcessTask(task.getId(), SecurityUtil.getUserId(), args); + } + if (schoolRole) { + memberCommonService.compareChangeInfoAndUpdateMember(record); + } + return Result.success(); + } + + @Override + public Result getAllChangeMemberInfo(String userId) { + List changeList = memberCommonService.getAllChangeInfo(userId); + View_user user = dao.fetch(View_user.class, Cnd.where("id", "=", userId)); + if (user == null) { + return Result.error("未获取到人员基础信息"); + } + NutMap result = Lang.obj2nutmap(user); + result.put("user", user); + result.put("allChangeInfo", Lang.isEmpty(changeList) ? null : changeList); + return Result.success(result); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public Result batchUpdateUserAttribute(String usersJson) { + return applyBatch(usersJson, "userAttribute"); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public Result batchUpdateMember(String usersJson) { + return applyBatch(usersJson, "member"); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public Result batchUpdateAidFundType(String usersJson) { + return applyBatch(usersJson, "aidFundMemberUserType"); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public Result batchUpdateUserState(String usersJson) { + return applyBatch(usersJson, "userState"); + } + + /** + * 批量变更入口。usersJson 为页面选中人员数组,operation 决定写入的业务字段,返回标准 Result。 + */ + @Aop(TransAop.READ_COMMITTED) + private Result applyBatch(String usersJson, String operation) { + try { + List users = Json.fromJsonAsList(NutMap.class, usersJson); + if (Lang.isEmpty(users)) { + return Result.error("请选择要修改的用户"); + } + int successCount = 0; + for (NutMap userMap : users) { + MemberChangeRecord record = buildManualChangeRecord(userMap.getString("id")); + if (record == null) { + continue; + } + if ("userAttribute".equals(operation)) { + record.setUserAttribute(userMap.getString("userAttribute")); + } else if ("member".equals(operation)) { + record.setMember(userMap.getBoolean("member")); + record.setWelfareMember(userMap.getBoolean("welfareMember")); + } else if ("aidFundMemberUserType".equals(operation)) { + record.setAidFundMemberUserType(userMap.getString("aidFundMemberUserType")); + } else if ("userState".equals(operation)) { + record.setUserState(userMap.getString("userState")); + } + int changed = applyManualChange(record); + if (changed > 0 && "member".equals(operation) && Boolean.TRUE.equals(record.getMember())) { + Sys_user user = dao.fetch(Sys_user.class, userMap.getString("id")); + if (user != null && user.getMemberTime() == null) { + user.setMemberTime(new Date()); + dao.updateIgnoreNull(user); + } + } + successCount += changed; + } + return successCount > 0 ? Result.success("批量设置成功") : Result.error("未检测到可保存的变更数据"); + } catch (Exception e) { + log.error("人员数据治理批量变更失败,操作类型:{}", operation, e); + return Result.error("批量设置失败:" + e.getMessage()); + } + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public Result unitMove(String unitId, String userIdsJson) { + try { + if (StrUtil.isBlank(unitId) || StrUtil.isBlank(userIdsJson)) { + return Result.error("请选择新单位和要修改的用户"); + } + List userIds = Json.fromJsonAsList(String.class, userIdsJson); + Sys_unit unit = dao.fetch(Sys_unit.class, unitId); + if (unit == null || Lang.isEmpty(userIds)) { + return Result.error(unit == null ? "单位不存在" : "请选择要修改的用户"); + } + Sys_union union = StrUtil.isNotBlank(unit.getUnionId()) + ? dao.fetch(Sys_union.class, unit.getUnionId()) : null; + int successCount = 0; + for (String userId : userIds) { + MemberChangeRecord record = buildManualChangeRecord(userId); + if (record != null) { + record.setUnitId(unitId); + record.setUnitName(unit.getName()); + record.setUnionId(unit.getUnionId()); + record.setUnionName(union == null ? "" : union.getName()); + successCount += applyManualChange(record); + } + } + return successCount > 0 ? Result.success("单位调整成功") : Result.error("未检测到可保存的变更数据"); + } catch (Exception e) { + log.error("人员数据治理单位调整失败", e); + return Result.error("单位调整失败:" + e.getMessage()); + } + } + + @Override + public Result findOne(String id) { + View_user user = dao.fetch(View_user.class, Cnd.where("id", "=", id)); + if (ObjectUtil.isNull(user)) { + return Result.error("用户信息不存在"); + } + if (AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_MEMBER_ADMIN.name(), + RoleConstant.SCHOOL_UNION_ADMIN.name())) { + return Result.success(user); + } + boolean branchAllowed = AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_CHAIRMAN.name(), + RoleConstant.BRANCH_UNION_OPERATOR.name()) + && (StrUtil.blankToDefault(user.getUnionId(), "").equals(StrUtil.blankToDefault(SecurityUtil.getUnionId(), "")) + || StrUtil.isBlank(user.getUnionId())); + boolean groupAllowed = AuthUtil.hasRole(RoleConstant.BRANCH_UNION_GROUP_LEADER.name()) + && StrUtil.isAllNotBlank(user.getUnionGroupId(), SecurityUtil.getUnionGroupId()) + && user.getUnionGroupId().equals(SecurityUtil.getUnionGroupId()); + if (branchAllowed || groupAllowed) { + user.setIdCard(DesensitizedUtil.idCardNum(user.getIdCard(), 3, 4)); + user.setMobile(DesensitizedUtil.mobilePhone(user.getMobile())); + return Result.success(user); + } + return SecurityUtil.getUserId().equals(user.getId()) ? Result.success(user) : Result.error("没有权限"); + } + + @Override + public Result findChangeById(String id) { + return Result.success().addData(dao.fetch(MemberChangeRecord.class, id)); + } + + @Override + public Result getUserByIdForMemberChange(String userId) { + View_user user = dao.fetch(View_user.class, Cnd.where("id", "=", userId)); + if (user == null) { + return Result.error("用户信息不存在"); + } + NutMap result = Lang.obj2nutmap(user); + if (StrUtil.isNotBlank(user.getUnionId())) { + result.put("union", dao.fetch(Sys_union.class, user.getUnionId())); + } + if (StrUtil.isNotBlank(user.getUnitId())) { + result.put("unit", dao.fetch(Sys_unit.class, user.getUnitId())); + } + return Result.success().addData(result); + } + + private MemberChangeRecord buildManualChangeRecord(String userId) { + if (StrUtil.isBlank(userId)) { + return null; + } + View_user user = dao.fetch(View_user.class, Cnd.where("id", "=", userId)); + if (user == null) { + return null; + } + MemberChangeRecord record = new MemberChangeRecord(); + BeanUtil.copyProperties(user, record); + record.setUserId(user.getId()); + return record; + } + + @Aop(TransAop.READ_COMMITTED) + private int applyManualChange(MemberChangeRecord record) { + try { + memberCommonService.validateChangeAndSetBasicData(record, MemberChangeOrigin.HAND_MOVEMENT); + memberCommonService.compareChangeInfoAndUpdateMember(record); + return 1; + } catch (BaseException e) { + if (StrUtil.contains(e.getMessage(), "未获取到异动数据")) { + return 0; + } + throw e; + } + } +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/personException/controller/PersonExceptionController.java b/src/main/java/com/budwk/app/zhgh/personGovernance/personException/controller/PersonExceptionController.java new file mode 100644 index 00000000..36d6a74e --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/personException/controller/PersonExceptionController.java @@ -0,0 +1,72 @@ +package com.budwk.app.zhgh.personGovernance.personException.controller; + +import com.budwk.app.base.param.PageForm; +import com.budwk.app.zhgh.personGovernance.personException.service.PersonExceptionService; +import cn.dev33.satoken.annotation.SaCheckPermission; +import com.budwk.app.base.result.Result; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; +import org.nutz.mvc.annotation.Param; + +/** 人员异常待确认、已处理及详情接口。 */ +@IocBean +@Ok("json:full") +@At("/platform/personGovernance/personException") +public class PersonExceptionController { + @Inject + private PersonExceptionService personExceptionService; + + @At("") + @Ok("beetl:/platform/personGovernance/personException/index.html") + @SaCheckPermission("data.person.governance.exception") + public void index() { + } + + @At + @SaCheckPermission("data.person.governance.exception") + public Result pageData(PageForm pageForm, String status, + @Param("searchName") String searchName, + @Param("searchKeyword") String searchKeyword, + @Param("unitId") String unitId, + @Param("batchId") String batchId, + @Param("handleResult") String handleResult) { + return Result.success(personExceptionService.pageData( + pageForm, status, searchName, searchKeyword, unitId, batchId, handleResult + )); + } + + @At + @SaCheckPermission("data.person.governance.exception") + public Result detail(String id) { + return Result.success(personExceptionService.detail(id)); + } + + /** + * 确认异常人员并同步修改人员状态。 + * + * @param ids 异常记录ID数组,当前页面按单条记录提交 + * @param handleResult DISABLE表示停用账号,KEEP表示继续保留 + * @param opinion 确认建议 + * @param personType 人员类型字典值 + * @param userState 在职状态字典值 + * @param member 会员状态,1为会员、0为非会员 + * @param welfareMember 福利会员状态,1为福利会员、0为非福利会员 + * @return 无业务数据,成功时统一返回code为0 + */ + @At + @Aop(TransAop.READ_COMMITTED) + @SaCheckPermission("data.person.governance.exception.handle") + public Result handle(@Param("ids") String[] ids, String handleResult, String opinion, + @Param("personType") String personType, + @Param("userState") String userState, + Integer member, Integer welfareMember) { + personExceptionService.handle( + ids, handleResult, opinion, personType, userState, member, welfareMember + ); + return Result.success(); + } +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/personException/model/PersonException.java b/src/main/java/com/budwk/app/zhgh/personGovernance/personException/model/PersonException.java new file mode 100644 index 00000000..b90d986e --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/personException/model/PersonException.java @@ -0,0 +1,99 @@ +package com.budwk.app.zhgh.personGovernance.personException.model; + +import com.budwk.app.base.model.BaseModel; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; +import org.nutz.dao.entity.annotation.*; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +import java.util.Date; + +/** + * 来源端缺失人员的发现、确认和处理闭环记录。 + */ +@Data +@Accessors(chain = true) +@EqualsAndHashCode(callSuper = true) +@Table("person_exception") +public class PersonException extends BaseModel { + @Name + @Column + @Comment("主键ID") + @PrevInsert(uu32 = true) + @ColDefine(type = ColType.VARCHAR, width = 32) + private String id; + @Column + @Comment("API配置ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String configId; + @Column + @Comment("人员来源关系ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String relationId; + @Column + @Comment("发现异常的同步批次ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String discoverBatchId; + @Column + @Comment("本地人员用户ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String userId; + @Column + @Comment("人员编号") + @ColDefine(type = ColType.VARCHAR, width = 120) + private String personNo; + @Column + @Comment("人员姓名") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String personName; + @Column + @Comment("所属部门ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String unitId; + @Column + @Comment("所属部门名称") + @ColDefine(type = ColType.VARCHAR, width = 150) + private String unitName; + @Column + @Comment("异常类型") + @ColDefine(type = ColType.VARCHAR, width = 30) + private String exceptionType; + @Column + @Comment("异常原因") + @ColDefine(type = ColType.VARCHAR, width = 500) + private String exceptionReason; + @Column + @Comment("最近一次同步时间") + private Date lastSyncTime; + @Column + @Comment("异常发现时间") + private Date discoverTime; + @Column + @Comment("处理状态") + @ColDefine(type = ColType.VARCHAR, width = 30) + private String status; + @Column + @Comment("处理结果") + @ColDefine(type = ColType.VARCHAR, width = 30) + private String handleResult; + @Column + @Comment("处理意见") + @ColDefine(type = ColType.VARCHAR, width = 500) + private String handleOpinion; + @Column + @Comment("处理人ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String handlerId; + @Column + @Comment("处理人姓名") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String handlerName; + @Column + @Comment("处理时间") + private Date handleTime; + @Column + @Comment("原始文件关联ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String rawFileReid; +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/personException/service/PersonExceptionService.java b/src/main/java/com/budwk/app/zhgh/personGovernance/personException/service/PersonExceptionService.java new file mode 100644 index 00000000..1938a8b3 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/personException/service/PersonExceptionService.java @@ -0,0 +1,41 @@ +package com.budwk.app.zhgh.personGovernance.personException.service; + +import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.param.PageForm; +import com.budwk.app.base.service.BaseService; +import com.budwk.app.zhgh.personGovernance.personException.model.PersonException; +import org.nutz.lang.util.NutMap; + +/** 人员异常查询与处理服务。 */ +public interface PersonExceptionService extends BaseService { + /** + * 查询异常人员。 + * + * @param pageForm 分页和排序参数 + * @param status PENDING待确认、PROCESSED已处理 + * @param searchName personName按姓名查询、personNo按人员编号查询 + * @param searchKeyword 查询关键字 + * @param unitId 所属部门主键 + * @param batchId 发现异常的同步批次主键 + * @param handleResult DISABLE已停用、KEEP继续保留 + * @return 异常人员分页数据 + */ + Pagination pageData(PageForm pageForm, String status, String searchName, String searchKeyword, + String unitId, String batchId, String handleResult); + + NutMap detail(String id); + + /** + * 确认异常人员,并将页面选择的人员类型、在职状态和会员状态同步到用户表。 + * + * @param ids 异常记录ID数组 + * @param handleResult DISABLE表示停用账号,KEEP表示继续保留 + * @param opinion 确认建议 + * @param personType 人员类型字典值 + * @param userState 在职状态字典值 + * @param member 会员状态,1为会员、0为非会员 + * @param welfareMember 福利会员状态,1为福利会员、0为非福利会员 + */ + void handle(String[] ids, String handleResult, String opinion, String personType, + String userState, Integer member, Integer welfareMember); +} diff --git a/src/main/java/com/budwk/app/zhgh/personGovernance/personException/service/impl/PersonExceptionServiceImpl.java b/src/main/java/com/budwk/app/zhgh/personGovernance/personException/service/impl/PersonExceptionServiceImpl.java new file mode 100644 index 00000000..3cef4837 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/personGovernance/personException/service/impl/PersonExceptionServiceImpl.java @@ -0,0 +1,192 @@ +package com.budwk.app.zhgh.personGovernance.personException.service.impl; + +import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.dao.CndPlus; +import com.budwk.app.base.param.PageForm; +import com.budwk.app.base.service.impl.BaseServiceImpl; +import com.budwk.app.base.constant.RoleConstant; +import com.budwk.app.sys.models.Sys_role; +import com.budwk.app.sys.models.Sys_user; +import com.budwk.app.sys.models.Sys_user_role; +import com.budwk.app.sys.services.SysUserService; +import com.budwk.app.web.commons.auth.utils.SecurityUtil; +import com.budwk.app.zhgh.personGovernance.personException.model.PersonException; +import com.budwk.app.zhgh.personGovernance.personException.service.PersonExceptionService; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.dao.Chain; +import org.nutz.dao.Cnd; +import org.nutz.dao.Dao; +import org.nutz.dao.Sqls; +import org.nutz.dao.sql.Sql; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.lang.Strings; +import org.nutz.lang.util.NutMap; +import org.springframework.util.Assert; + +import java.util.Date; + +/** 异常人员仅做直接确认处理,不引入审核中状态。 */ +@IocBean(args = {"refer:dao"}) +public class PersonExceptionServiceImpl extends BaseServiceImpl implements PersonExceptionService { + @Inject + private SysUserService sysUserService; + + public PersonExceptionServiceImpl(Dao dao) { + super(dao); + } + + @Override + public Pagination pageData(PageForm pageForm, String status, String searchName, + String searchKeyword, String unitId, String batchId, + String handleResult) { + Sql sql = Sqls.create( + "SELECT e.*,c.configName sourceName,b.batchNo FROM person_exception e " + + "LEFT JOIN person_api_config c ON c.id=e.configId " + + "LEFT JOIN person_sync_batch b ON b.id=e.discoverBatchId $condition" + ); + CndPlus cnd = CndPlus.create(); + cnd.andEX("e.status", "=", status); + if (Strings.isNotBlank(searchKeyword)) { + // 查询字段只允许在姓名和人员编号之间选择,禁止直接拼接前端传入的字段名。 + String queryField = "personNo".equals(searchName) ? "e.personNo" : "e.personName"; + cnd.and(queryField, "like", "%" + searchKeyword + "%"); + } + cnd.andEX("e.unitId", "=", unitId).andEX("e.discoverBatchId", "=", batchId) + .andEX("e.handleResult", "=", handleResult); + cnd.and("e.delFlag", "=", false).desc("e.discoverTime"); + sql.setCondition(cnd); + return listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql); + } + + @Override + public NutMap detail(String id) { + PersonException exception = fetch(id); + Assert.notNull(exception, "异常记录不存在"); + Sql userSql = Sqls.create(""" + SELECT + u.id, + u.loginname, + u.username, + u.sex, + u.nation, + u.birthday, + u.political, + u.education, + u.academicDegree, + u.position, + u.unitId, + unit.name unitName, + un.unionname unionName, + u.idCard, + u.mobile, + u.email, + u.userState, + u.personType, + u.member, + u.welfareMember, + u.disabled + FROM sys_user u + LEFT JOIN sys_unit unit ON u.unitId = unit.id + LEFT JOIN sys_union un ON u.unionId = un.id + WHERE u.id = @userId + """).setParam("userId", exception.getUserId()); + return NutMap.NEW() + .addv("exception", exception) + // 只返回查看/处理页面需要的人员字段,避免输出密码、盐值等敏感信息。 + .addv("user", fetchMap(userSql)); + } + + /** + * 确认处理异常人员。 + * + * @param ids 异常记录ID,可单条或批量 + * @param handleResult DISABLE表示停用本地账号,KEEP表示继续保留 + * @param opinion 必填处理意见 + * @param personType 人员类型字典值,允许清空 + * @param userState 在职状态字典值,允许清空 + * @param member 会员状态,1为会员、0为非会员 + * @param welfareMember 福利会员状态,1为福利会员、0为非福利会员 + */ + @Override + @Aop(TransAop.READ_COMMITTED) + public void handle(String[] ids, String handleResult, String opinion, String personType, + String userState, Integer member, Integer welfareMember) { + Assert.notEmpty(ids, "请选择需要处理的异常人员"); + Assert.isTrue("DISABLE".equals(handleResult) || "KEEP".equals(handleResult), "处理结果不正确"); + Assert.hasText(opinion, "请填写确认建议"); + Assert.notNull(member, "请选择会员状态"); + Assert.isTrue(member == 0 || member == 1, "会员状态不正确"); + Assert.notNull(welfareMember, "请选择福利会员状态"); + Assert.isTrue(welfareMember == 0 || welfareMember == 1, "福利会员状态不正确"); + for (String id : ids) { + PersonException exception = fetch(id); + Assert.notNull(exception, "异常记录不存在"); + Assert.isTrue("PENDING".equals(exception.getStatus()), "仅待确认记录可以处理"); + Sys_user user = sysUserService.fetch(exception.getUserId()); + Assert.notNull(user, "异常记录关联的人员不存在"); + + String userId = exception.getUserId(); + Sys_role memberRole = dao().fetch(Sys_role.class, + Cnd.where("code", "=", RoleConstant.MEMBER.name())); + Assert.notNull(memberRole, "系统未配置会员角色"); + boolean hasMemberRole = dao().count( + Sys_user_role.class, + Cnd.where("userId", "=", userId).and("roleId", "=", memberRole.getId()) + ) > 0; + + // 人员类型、在职状态与本次确认结果在同一事务中保存,避免人员信息和异常状态不一致。 + Chain userChanges = Chain.make("personType", personType) + .add("userState", userState); + if ("DISABLE".equals(handleResult)) { + userChanges.add("disabled", true); + } + dao().update(Sys_user.class, userChanges, Cnd.where("id", "=", userId)); + + // 会员字段和会员角色以本次选择为准,同时修复历史数据中字段与角色不一致的情况。 + if (member == 1 && (!Boolean.TRUE.equals(user.getMember()) || !hasMemberRole)) { + dao().update(Sys_user.class, + Chain.make("member", true).add("memberTime", new Date()), + Cnd.where("id", "=", userId)); + if (!hasMemberRole) { + Sys_user_role userRole = new Sys_user_role(); + userRole.setUserId(userId); + userRole.setRoleId(memberRole.getId()); + userRole.setEnable(true); + dao().insert(userRole); + } + } else if (member == 0 && (Boolean.TRUE.equals(user.getMember()) || hasMemberRole)) { + dao().update(Sys_user.class, Chain.make("member", false), Cnd.where("id", "=", userId)); + dao().clear(Sys_user_role.class, + Cnd.where("userId", "=", userId).and("roleId", "=", memberRole.getId())); + } + + // 当前项目没有独立福利会员角色,只维护福利会员字段及加入时间。 + if (welfareMember == 1 + && !Boolean.TRUE.equals(user.getWelfareMember())) { + Chain welfareChanges = Chain.make("welfareMember", true); + if (!Boolean.TRUE.equals(user.getWelfareMember())) { + welfareChanges.add("welfareMemberJoinTime", new Date()); + } + dao().update(Sys_user.class, welfareChanges, Cnd.where("id", "=", userId)); + } else if (welfareMember == 0 + && Boolean.TRUE.equals(user.getWelfareMember())) { + dao().update( + Sys_user.class, + Chain.make("welfareMember", false).add("welfareMemberJoinTime", null), + Cnd.where("id", "=", userId) + ); + } + sysUserService.deleteCache(userId); + + exception.setStatus("PROCESSED") + .setHandleResult(handleResult) + .setHandleOpinion(opinion) + .setHandlerId(SecurityUtil.getUserId()) + .setHandlerName(SecurityUtil.getUserUsername()) + .setHandleTime(new Date()); + update(exception); + } + } +} diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 57bcf154..f903b4a9 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -5,7 +5,7 @@ nutz: ignore: ^(.+[.])(jsp|png|gif|jpg|js|css|jspx|jpeg|html|mp3|mp4|ico|svg|vue)$ exclusions: /favicon/*,/assets/*,/druid/*,/upload/*,/components/* server: - port: 8080 + port: 80 host: 0.0.0.0 spdb: @@ -33,7 +33,7 @@ tomcat: #结合ftp使用,或用nginx代理ftp路径 #staticPath: /Users/wizzer/temp/files #开发模式静态资源 - staticPathLocal: E:/DevOpsCode/zhgh_cug_v4/src/main/resources/static + staticPathLocal: G:/pufa/new/zhgh_cug_v4/src/main/resources/static security: tokenName: saToken @@ -83,9 +83,10 @@ 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 jdbc: - url: jdbc:mysql://127.0.0.1:3306/zhgh_cug_v4?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true + url: jdbc:mysql://192.168.21.204:3306/zhgh_cug_v4?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true username: root - password: Root@123456 + password: wqnmd2331 +# password: Root@123456 validationQuery: select 1 maxActive: 1000 testWhileIdle: true @@ -99,7 +100,7 @@ xsssql: beetl: RESOURCE: #本地路径,开发时设置本地路径,便于调试 - rootLocal: E:/DevOpsCode/zhgh_cug_v4/src/main/resources/views/ + rootLocal: G:/pufa/new/zhgh_cug_v4/src/main/resources/views/ root: views/ DELIMITER_STATEMENT_START: "" @@ -172,10 +173,10 @@ quartz: class: com.budwk.app.task.conn.NutConnectionProvider minio: - accessKey: o1MwuzixNqAXh5zqEfMH - secretKey: RVnZum91JmGO79qtzWy2gKMBsF6OxAaibGrr7ets - endPoint: http://localhost:9000 - bucket: njupt + accessKey: minioadmin + secretKey: minioadmin + endPoint: http://192.168.21.214:9000 + bucket: cug data-center: grant_type: password @@ -186,31 +187,31 @@ data-center: 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 - # 地大通讯平台基础地址,默认对接正式地址 - base-url: https://msg.cug.edu.cn - # 授权系统名称,对应文档中的 tp_name - tp-name : nwszl.cug.edu.cn - # 便捷发送方法默认发送人工号,controller 只传接收人和内容时会读取这里 - default-send-user-id: 790036 - # 便捷发送方法默认发送人姓名,controller 只传接收人和内容时会读取这里 - default-send-user-name: 周斐霏 - # 便捷发送方法默认发送机构ID,可不填 - 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 - # HTTP读取超时时间,单位毫秒 - read-timeout: 10000 +#msg: +# platform: +# enabled: false +# # 地大通讯平台基础地址,默认对接正式地址 +# base-url: https://msg.cug.edu.cn +# # 授权系统名称,对应文档中的 tp_name +# tp-name : nwszl.cug.edu.cn +# # 便捷发送方法默认发送人工号,controller 只传接收人和内容时会读取这里 +# default-send-user-id: 790036 +# # 便捷发送方法默认发送人姓名,controller 只传接收人和内容时会读取这里 +# default-send-user-name: 周斐霏 +# # 便捷发送方法默认发送机构ID,可不填 +# 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 +# # HTTP读取超时时间,单位毫秒 +# read-timeout: 10000 diff --git a/src/main/resources/db/person_governance.sql b/src/main/resources/db/person_governance.sql new file mode 100644 index 00000000..3e0da141 --- /dev/null +++ b/src/main/resources/db/person_governance.sql @@ -0,0 +1,212 @@ +-- 人员数据治理模块初始化脚本(MySQL 8.0,UTF-8)。 +-- 人员变更直接复用现有 member_change_record、sys_user_history 及 WF 流程,本脚本不创建替代表。 +SET NAMES utf8mb4; + +SET @add_person_flag = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_user' AND COLUMN_NAME = 'flag') = 0, + 'ALTER TABLE sys_user ADD COLUMN flag int DEFAULT 0 COMMENT ''人员治理同步保护标识:1忽略同步,0或空正常同步''', + 'SELECT 1' +); +PREPARE add_person_flag_stmt FROM @add_person_flag; +EXECUTE add_person_flag_stmt; +DEALLOCATE PREPARE add_person_flag_stmt; + +CREATE TABLE IF NOT EXISTS person_api_config ( + id varchar(32) NOT NULL, + configName varchar(100), sourceType varchar(20), requestUrl varchar(500), requestMethod varchar(10), + timeoutSeconds int, sourceTable varchar(100), targetTable varchar(100), syncMode varchar(20), + identityField varchar(100), bodyType varchar(30), bodyContent text, + tokenRequestUrl varchar(500), tokenRequestMethod varchar(10), tokenTimeoutSeconds int, + tokenBodyType varchar(30), tokenBodyContent text, tokenValuePath varchar(200), tokenExpiresPath varchar(200), + tokenExpiryUnit varchar(20), tokenQueryResult text, dataQueryResult text, + responseCodePath varchar(200), responseSuccessValue varchar(100), responseMessagePath varchar(200), + responseDataPath varchar(200), responseTotalPath varchar(200), enabled tinyint(1), taskCron varchar(100), + matchMemberRules tinyint(1), lastSyncTime datetime, taskId varchar(32), remark varchar(500), + createdBy varchar(32), createdAt bigint, updatedBy varchar(32), updatedAt bigint, + delFlag tinyint(1), opBy varchar(128), opAt varchar(128), + PRIMARY KEY (id), UNIQUE KEY UK_PERSON_API_CONFIG_NAME (configName) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS person_api_field_mapping ( + id varchar(32) NOT NULL, configId varchar(32), sourceField varchar(100), sourceComment varchar(200), + targetField varchar(100), targetComment varchar(200), required tinyint(1), identityFlag tinyint(1), sort int, + PRIMARY KEY (id), UNIQUE KEY UK_PERSON_API_MAPPING_TARGET (configId, targetField) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS person_api_request_param ( + id varchar(32) NOT NULL, configId varchar(32), requestScope varchar(20), paramLocation varchar(20), + paramName varchar(100), paramType varchar(30), paramValue text, required tinyint(1), + description varchar(300), sensitiveFlag int, sort int, PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS person_api_test_case ( + id varchar(32) NOT NULL, configId varchar(32), requestScope varchar(20), caseName varchar(100), + requestSnapshot text, responseStatus int, responseTime bigint, responseBody text, success tinyint(1), + executedAt datetime, createdBy varchar(32), createdAt bigint, updatedBy varchar(32), updatedAt bigint, + delFlag tinyint(1), opBy varchar(128), opAt varchar(128), PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS person_sync_batch ( + id varchar(32) NOT NULL, configId varchar(32), batchNo varchar(50), triggerType varchar(20), + startedAt datetime, finishedAt datetime, status varchar(20), sourceCount int, successCount int, + newCount int, changeCount int, exceptionCount int, errorMessage text, rawFileReid varchar(32), + createdBy varchar(32), createdAt bigint, updatedBy varchar(32), updatedAt bigint, + delFlag tinyint(1), opBy varchar(128), opAt varchar(128), + PRIMARY KEY (id), UNIQUE KEY UK_PERSON_SYNC_BATCH_NO (batchNo) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS person_source_relation ( + id varchar(32) NOT NULL, configId varchar(32), sourcePersonKey varchar(150), userId varchar(32), + personNo varchar(120), lastSeenBatchId varchar(32), lastSeenTime datetime, presentFlag tinyint(1), + sourceDataHash varchar(64), createdBy varchar(32), createdAt bigint, updatedBy varchar(32), updatedAt bigint, + delFlag tinyint(1), opBy varchar(128), opAt varchar(128), + PRIMARY KEY (id), UNIQUE KEY UK_PERSON_SOURCE_KEY (configId, sourcePersonKey) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS person_member_match_config ( + id varchar(32) NOT NULL, configName varchar(100), apiConfigId varchar(32), copiedFromId varchar(32), + enabled tinyint(1), groupCount int, lastMatchCount int, lastExecuteTime datetime, + createdBy varchar(32), createdAt bigint, updatedBy varchar(32), updatedAt bigint, + delFlag tinyint(1), opBy varchar(128), opAt varchar(128), + PRIMARY KEY (id), UNIQUE KEY UK_PERSON_MEMBER_API_CONFIG (apiConfigId) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS person_member_match_group ( + id varchar(32) NOT NULL, matchConfigId varchar(32), parentGroupId varchar(32), + ruleType varchar(30) DEFAULT 'UNION_MEMBER', groupNo int, joinLogic varchar(3), sort int, + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS person_member_match_condition ( + id varchar(32) NOT NULL, groupId varchar(32), dictCode varchar(50), dictItemId varchar(32), + dictItemCode varchar(50), dictItemName varchar(100), mappedField varchar(100), operator varchar(30), + matchValue varchar(500), joinLogic varchar(3), sort int, PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS person_exception ( + id varchar(32) NOT NULL, configId varchar(32), relationId varchar(32), discoverBatchId varchar(32), + userId varchar(32), personNo varchar(120), personName varchar(100), unitId varchar(32), unitName varchar(150), + exceptionType varchar(30), exceptionReason varchar(500), lastSyncTime datetime, discoverTime datetime, + status varchar(30), handleResult varchar(30), handleOpinion varchar(500), handlerId varchar(32), + handlerName varchar(100), handleTime datetime, rawFileReid varchar(32), + createdBy varchar(32), createdAt bigint, updatedBy varchar(32), updatedAt bigint, + delFlag tinyint(1), opBy varchar(128), opAt varchar(128), PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- PC 端独立应用菜单。完整页面跳转由布局按 /platform/personGovernance/ 路径处理。 +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, + permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, + platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService) +SELECT '76725022b0cc11f196a000ff8d9db2e0', '', LPAD(IFNULL(MAX(CAST(path AS UNSIGNED)), 0) + 1, 4, '0'), + '人员数据治理', 'PersonGovernance', 'menu', '', '', 'ti-id-badge', 1, 0, + 'data.person.governance', '人员数据接入、会员匹配、人员变更及异常处理', 694, 1, + '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, + 'PC', NULL, NULL, 'r', 0, 0 +FROM sys_menu WHERE (parentId = '' OR parentId IS NULL) AND CHAR_LENGTH(path) = 4 +HAVING NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance') m); + +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, + permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, + platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService) +SELECT '7673d831b0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0001'), 'API配置', 'ApiConfig', 'menu', + '/platform/personGovernance/apiConfig', '', '', 1, 0, 'data.person.governance.api', NULL, 1, 0, + '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'a', 0, 0 +FROM sys_menu p WHERE p.permission = 'data.person.governance' +AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.api') m); + +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, + permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, + platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService) +SELECT '76749371b0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0002'), '会员匹配', 'MemberMatch', 'menu', + '/platform/personGovernance/memberMatch', '', '', 1, 0, 'data.person.governance.member', NULL, 2, 0, + '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'h', 0, 0 +FROM sys_menu p WHERE p.permission = 'data.person.governance' +AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.member') m); + +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, + permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, + platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService) +SELECT '7674b630b0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0003'), '人员变更', 'PersonChange', 'menu', + '/platform/personGovernance/personChange', '', '', 1, 0, 'data.person.governance.change', NULL, 3, 0, + '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'r', 0, 0 +FROM sys_menu p WHERE p.permission = 'data.person.governance' +AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.change') m); + +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, + permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, + platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService) +SELECT '7674d34eb0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0004'), '人员异常', 'PersonException', 'menu', + '/platform/personGovernance/personException', '', '', 1, 0, 'data.person.governance.exception', NULL, 4, 0, + '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'r', 0, 0 +FROM sys_menu p WHERE p.permission = 'data.person.governance' +AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.exception') m); + +-- 页面按钮权限;type=data 不在左侧菜单展示。 +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, showit, disabled, permission, location, + hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, + initialPinyinName, isRecommendApp, isRecommendService) +SELECT '7674fb04b0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0001'), '保存API配置', 'Edit', 'data', + 0, 0, 'data.person.governance.api.edit', 1, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', + UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, 'b', 0, 0 FROM sys_menu p +WHERE p.permission = 'data.person.governance.api' AND NOT EXISTS + (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.api.edit') m); + +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, showit, disabled, permission, location, + hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, + initialPinyinName, isRecommendApp, isRecommendService) +SELECT '76750400b0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0002'), '删除API配置', 'Delete', 'data', + 0, 0, 'data.person.governance.api.delete', 2, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', + UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, 's', 0, 0 FROM sys_menu p +WHERE p.permission = 'data.person.governance.api' AND NOT EXISTS + (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.api.delete') m); + +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, showit, disabled, permission, location, + hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, + initialPinyinName, isRecommendApp, isRecommendService) +SELECT '76750e42b0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0003'), '测试及同步', 'Test', 'data', + 0, 0, 'data.person.governance.api.test', 3, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', + UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, 'c', 0, 0 FROM sys_menu p +WHERE p.permission = 'data.person.governance.api' AND NOT EXISTS + (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.api.test') m); + +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, showit, disabled, permission, location, + hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, + initialPinyinName, isRecommendApp, isRecommendService) +SELECT '767518e8b0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0001'), '保存会员匹配', 'Edit', 'data', + 0, 0, 'data.person.governance.member.edit', 1, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', + UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, 'b', 0, 0 FROM sys_menu p +WHERE p.permission = 'data.person.governance.member' AND NOT EXISTS + (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.member.edit') m); + +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, showit, disabled, permission, location, + hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, + initialPinyinName, isRecommendApp, isRecommendService) +SELECT '767523deb0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0002'), '删除会员匹配', 'Delete', 'data', + 0, 0, 'data.person.governance.member.delete', 2, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', + UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, 's', 0, 0 FROM sys_menu p +WHERE p.permission = 'data.person.governance.member' AND NOT EXISTS + (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.member.delete') m); + +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, showit, disabled, permission, location, + hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, + initialPinyinName, isRecommendApp, isRecommendService) +SELECT '76752eacb0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0001'), '处理人员异常', 'Handle', 'data', + 0, 0, 'data.person.governance.exception.handle', 1, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', + UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, 'c', 0, 0 FROM sys_menu p +WHERE p.permission = 'data.person.governance.exception' AND NOT EXISTS + (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.exception.handle') m); + +-- 给系统管理员默认授权,其他角色可在角色管理中按需分配。 +INSERT INTO sys_role_menu (roleId, menuId) +SELECT r.id, m.id +FROM sys_role r +JOIN sys_menu m ON m.permission IN ( + 'data.person.governance', 'data.person.governance.api', 'data.person.governance.member', + 'data.person.governance.api.edit', 'data.person.governance.api.delete', 'data.person.governance.api.test', + 'data.person.governance.member.edit', 'data.person.governance.member.delete', + 'data.person.governance.change', 'data.person.governance.exception', + 'data.person.governance.exception.handle') +LEFT JOIN sys_role_menu rm ON rm.roleId = r.id AND rm.menuId = m.id +WHERE r.code = 'SYSADMIN' + AND rm.roleId IS NULL; diff --git a/src/main/resources/static/assets/platform/css/personGovernance.css b/src/main/resources/static/assets/platform/css/personGovernance.css new file mode 100644 index 00000000..769dd886 --- /dev/null +++ b/src/main/resources/static/assets/platform/css/personGovernance.css @@ -0,0 +1,732 @@ +/* 人员数据治理列表统一占满内容区,数据超出时只滚动表格主体。 */ +.person-governance-page-transition { + visibility: hidden; + opacity: 0; + transform: translateY(6px); + transition: + opacity 220ms ease, + transform 220ms ease, + visibility 0s linear 220ms; +} + +.person-governance-page-transition.is-ready { + visibility: visible; + opacity: 1; + transform: translateY(0); + transition-delay: 0s; +} + +.person-governance-page-transition.is-leaving { + pointer-events: none; + opacity: 0; + transform: translateY(3px); +} + +.person-governance-list-page { + box-sizing: border-box; + display: flex; + flex-direction: column; + height: calc(100vh - 50px); + 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; +} + +.person-governance-table-wrap > .el-table { + height: 100%; +} + +.person-governance-list-footer, +.person-governance-list-card .el-pagination-container { + flex: none; +} + +/* 配置页顶部字段根据内容区宽度自动分列,空间不足时自然换行。 */ +.person-governance-form-page { + box-sizing: border-box; + display: flex; + flex-direction: column; + height: calc(100vh - 50px); + 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 { + flex: none; + margin-top: 10px; +} + +.person-governance-form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + column-gap: 20px; + align-items: start; + width: 100%; +} + +.person-governance-form-grid .el-form-item { + margin-right: 0; +} + +.person-governance-form-grid .el-select, +.person-governance-form-grid .el-input-number { + width: 100%; +} + +/* API配置顶部字段按业务宽度靠左排列,同步方式具体值单独放在页首右侧。 */ +.person-governance-sync-type { + color: #303133; + line-height: 24px; +} + +.person-governance-api-basic-grid { + /* 按业务宽度紧凑靠左,剩余空间留在右侧;窄屏只允许整组字段换行。 */ + display: flex; + flex-wrap: wrap; + gap: 18px 16px; + justify-content: flex-start; + align-items: center; +} + +.person-governance-api-basic-grid > .el-form-item { + display: flex; + align-items: center; + flex: 0 1 280px; + min-width: 0; + max-width: 100%; +} + +.person-governance-api-basic-grid .el-form-item__label { + flex: none; + float: none; + white-space: nowrap; +} + +.person-governance-api-basic-grid .el-form-item__content { + flex: 1; + min-width: 0; +} + +.person-governance-api-basic-grid > .person-governance-api-request-address { + flex-basis: 620px; +} + +.person-governance-api-basic-grid > .person-governance-api-timeout { + flex-basis: 270px; +} + +.person-governance-api-basic-grid > .person-governance-query-action { + flex: none; +} + +.person-governance-request-address-control, +.person-governance-timeout-control { + display: flex; + align-items: center; +} + +.person-governance-request-address-control { + width: 100%; +} + +.person-governance-request-address-control .el-select { + flex: none; + width: 100px; +} + +.person-governance-request-address-control > .el-input { + flex: 1; + min-width: 0; + margin-left: 8px; +} + +.person-governance-timeout-control .el-input-number { + flex: none; + width: 200px; + min-width: 200px; +} + +.person-governance-timeout-unit { + flex: none; + margin-left: 8px; + color: #606266; +} + +/* API配置页签占满内容卡片,返回结果文本域随剩余高度伸展。 */ +.person-governance-api-content-card > .el-card__body { + overflow: hidden; +} + +.person-governance-api-content-card > .el-card__body > .el-tabs { + display: flex; + flex-direction: column; + height: 100%; +} + +.person-governance-api-content-card > .el-card__body > .el-tabs > .el-tabs__header { + flex: none; +} + +.person-governance-api-content-card > .el-card__body > .el-tabs > .el-tabs__content { + flex: 1; + min-height: 0; +} + +.person-governance-api-content-card > .el-card__body > .el-tabs > .el-tabs__content > .el-tab-pane { + box-sizing: border-box; + height: 100%; + overflow: auto; +} + +/* 系统表配置不显示单一的字段映射页签标题,映射内容直接占满卡片。 */ +.person-governance-system-table-card > .el-card__body > .el-tabs > .el-tabs__header { + display: none; +} + +.person-governance-system-table-card > .el-card__body > .el-tabs > .el-tabs__content, +.person-governance-system-table-card .person-governance-mapping-pane { + height: 100%; +} + +.person-governance-system-table-card .person-governance-mapping-pane { + overflow: hidden !important; +} + +.person-governance-result-pane { + display: flex; + flex-direction: column; + overflow: hidden !important; +} + +.person-governance-result-textarea { + flex: 1; + min-height: 0; + height: 100%; +} + +/* 返回结果下方的映射配置固定保留,长响应只在文本域内滚动。 */ +.person-governance-response-fields { + display: flex; + flex-wrap: wrap; + flex: none; + gap: 10px 20px; + padding-top: 12px; +} + +.person-governance-response-fields > div { + display: flex; + align-items: center; + flex: 1 1 280px; + min-width: 0; + gap: 10px; +} + +.person-governance-response-fields label, +.person-governance-task-cron label { + white-space: nowrap; + flex: none; +} + +.person-governance-response-fields .el-input, +.person-governance-response-fields .el-select { + flex: 1; + min-width: 0; +} + +.person-governance-task-cron { + flex: 0 1 420px; + max-width: 420px; +} + +.person-governance-cron-hint, +.person-governance-json-hint { + flex: none; + color: #909399; + font-size: 12px; + line-height: 20px; +} + +.person-governance-parameter-pane .person-governance-body-json { + flex: 1; + min-height: 80px; + height: auto; +} + +.person-governance-result-textarea .el-textarea__inner, +.person-governance-body-json .el-textarea__inner { + height: 100%; + resize: none; +} + +.person-governance-result-actions { + flex: none; + margin-top: 10px; + text-align: right; +} + +.person-governance-body-json { + height: calc(100% - 52px); +} + +/* 字段映射与请求参数表格在可见页签内直接按完整宽高渲染。 */ +.person-governance-mapping-table { + width: 100%; +} + +.person-governance-mapping-pane { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden !important; +} + +.person-governance-mapping-table-wrap { + flex: 1; + min-height: 0; + margin-top: 20px; +} + +.person-governance-mapping-table-wrap > .el-table { + height: 100%; +} + +.person-governance-mapping-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px 24px; +} + +.person-governance-mapping-toolbar-item { + display: flex; + align-items: center; + min-width: 0; +} + +/* 两种数据来源共用右侧清空入口,窄屏换行后仍靠右,不挤压周期控件。 */ +.person-governance-mapping-toolbar > .person-governance-clear-mappings { + flex: none; + margin-left: auto; +} + +.person-governance-mapping-toolbar-item > label { + flex: none; + margin-right: 10px; + color: #303133; + white-space: nowrap; +} + +.person-governance-mapping-table-select { + flex: 0 1 360px; + max-width: 360px; +} + +.person-governance-mapping-table-select .el-select { + flex: 1; + min-width: 180px; +} + +.person-governance-mapping-switch { + flex: none; +} + +/* 系统表配置项按配置名称、来源表、目标表、同步开关的顺序自适应排列。 */ +.person-governance-system-table-toolbar { + flex: none; +} + +.person-governance-system-table-toolbar .el-form-item { + /* 标签与控件作为一组排列,仅允许表单项在工具栏中整体换行。 */ + display: flex; + align-items: center; + flex: 1 1 280px; + min-width: 0; + margin: 0; +} + +.person-governance-system-table-toolbar .person-governance-system-target-table { + flex: 0 1 300px; +} + +.person-governance-system-table-toolbar .person-governance-system-task-cron { + flex: 0 1 430px; +} + +.person-governance-system-table-toolbar .person-governance-system-member-match { + flex: none; + column-gap: 12px; + margin-left: 16px; +} + +/* 长标签按文字实际宽度展示,通过固定列间距避免文字与开关重叠或距离过大。 */ +.person-governance-system-table-toolbar .person-governance-system-member-match .el-form-item__label { + width: auto !important; + padding-right: 0; +} + +.person-governance-system-table-toolbar .person-governance-system-member-match .el-form-item__content { + margin-left: 0 !important; +} + +.person-governance-system-table-toolbar .el-form-item__label { + flex: none; + float: none; + white-space: nowrap; +} + +.person-governance-system-table-toolbar .el-form-item__content { + flex: 1; + min-width: 0; + width: auto; +} + +.person-governance-system-table-toolbar .el-input, +.person-governance-system-table-toolbar .el-select { + width: 100%; +} + +.person-governance-parameter-pane { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden !important; +} + +.person-governance-parameter-pane > .el-tabs, +.person-governance-parameter-pane > .el-radio-group { + flex: none; +} + +.person-governance-parameter-table-wrap { + flex: 1; + min-height: 0; + margin-top: 10px; +} + +.person-governance-parameter-table-wrap > .el-table { + width: 100%; + height: 100%; +} + +/* 参数表头按文字与加号按钮的实际高度紧凑展示。 */ +.person-governance-parameter-table .el-table__header-wrapper th, +.person-governance-mapping-table .el-table__header-wrapper th { + padding: 6px 0; +} + +.person-governance-parameter-table .el-table__header-wrapper th .cell, +.person-governance-mapping-table .el-table__header-wrapper th .cell { + line-height: 20px; + white-space: nowrap; +} + +.person-governance-add-column .el-button { + padding: 0; + font-size: 16px; + line-height: 20px; +} + +/* 弹窗和抽屉沿用 Element UI 过渡,并为本模块调整为更柔和的缓动。 */ +.dialog-fade-enter-active .person-governance-dialog, +.dialog-fade-leave-active .person-governance-dialog { + transition: + opacity 260ms ease, + transform 260ms ease; +} + +.dialog-fade-enter .person-governance-dialog, +.dialog-fade-leave-to .person-governance-dialog { + opacity: 0; + transform: translateY(-10px) scale(0.99); +} + +/* 弹窗挂载body避开页面过渡层,短屏内部滚动,窄屏保留周期编辑器的完整操作区域。 */ +.person-governance-cron-dialog { + max-width: calc(100vw - 32px); +} + +.person-governance-cron-dialog .el-dialog__body { + max-height: 78vh; + overflow: auto; + padding: 10px 20px 20px; +} + +.person-governance-cron-dialog .popup-main { + min-width: 640px; +} + +.person-governance-drawer { + transition: box-shadow 260ms ease; +} + +.person-governance-drawer.rtl, +.el-drawer__open .person-governance-drawer.rtl { + animation-duration: 340ms; + animation-timing-function: cubic-bezier(0.22, 0.61, 0.36, 1); +} + +@media (max-width: 767px) { + .person-governance-list-page, + .person-governance-form-page { + padding: 8px; + } + + .person-governance-form-grid { + grid-template-columns: 1fr; + } + + .person-governance-page-header { + align-items: flex-start; + } + + .person-governance-timeout-control .el-input-number { + flex: 1; + width: 100%; + min-width: 0; + } + + .person-governance-mapping-table-select { + flex-basis: 100%; + max-width: none; + } +} + diff --git a/src/main/resources/views/layouts/v4/baseLayout.html b/src/main/resources/views/layouts/v4/baseLayout.html index a7e6cc72..7ad7f1cc 100644 --- a/src/main/resources/views/layouts/v4/baseLayout.html +++ b/src/main/resources/views/layouts/v4/baseLayout.html @@ -25,6 +25,10 @@ + + + + diff --git a/src/main/resources/views/platform/personGovernance/apiConfig/form.html b/src/main/resources/views/platform/personGovernance/apiConfig/form.html new file mode 100644 index 00000000..261c5d8d --- /dev/null +++ b/src/main/resources/views/platform/personGovernance/apiConfig/form.html @@ -0,0 +1,1091 @@ + + +
+
+ +
+ {{form.sourceType==='HTTP_API'?'请求第三方接口':'读取系统表数据'}} +
+
+ + + + + + + + +
+ + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 选择周期 + + + + + + 清空 + +
+
+ + + + +
+
+ + + + + +
+
+ + +
+
+ + + 选择周期 + +
+
+ + +
+ 清空 +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + form-data + json + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ JSON动态值支持Access_Token、页码、数量、起始数、结束数占位符(如${起始数},放在字符串中)。分页支持页码+数量、起始数+结束数、起始数+数量;也可仅配置数量进行单次限量查询(如10000条)。默认页码1、起始数0、数量/结束数100,未配置分页也只查询一次。 +
+
+ + + +
+ +
+ + +
+
+
+
+
+ + +
+ + +
+
+
+ + + diff --git a/src/main/resources/views/platform/personGovernance/apiConfig/index.html b/src/main/resources/views/platform/personGovernance/apiConfig/index.html new file mode 100644 index 00000000..341185d5 --- /dev/null +++ b/src/main/resources/views/platform/personGovernance/apiConfig/index.html @@ -0,0 +1,198 @@ + + +
+ + + + + + + + + + + + + + + + + 查询 + 重置 + + 新增 + + + +
+ + + + + + + + + + + + + + + +
+ +
+ + + + + +

请求第三方接口

+

通过GET、POST等方式获取第三方人员数据

+ 选择 +
+
+ + +

读取系统表数据

+

读取第三方已推送到系统表中的人员数据

+ 选择 +
+
+
+ 取消 +
+
+ + + diff --git a/src/main/resources/views/platform/personGovernance/memberMatch/form.html b/src/main/resources/views/platform/personGovernance/memberMatch/form.html new file mode 100644 index 00000000..edd218a3 --- /dev/null +++ b/src/main/resources/views/platform/personGovernance/memberMatch/form.html @@ -0,0 +1,639 @@ + + +
+
+ +
+ + + +
+ + + + + + 该API配置已与当前会员匹配配置绑定,编辑时不可修改 + + + + + + + +
+
+
+ + + + + + + +
+

{{activeRuleTitle}}

+ +
+
+
+ + + 且 + 或 + + + +
+ + 新增条件组 + +
+
+
+ + +

{{activePreviewTitle}}

+

+ 预计匹配 + {{previewTotal}} + 人 +

+ + 试算匹配 + +
+ + + + + + +
+
+ +
+
+
+
+ +
+ + + + diff --git a/src/main/resources/views/platform/personGovernance/memberMatch/index.html b/src/main/resources/views/platform/personGovernance/memberMatch/index.html new file mode 100644 index 00000000..a76db783 --- /dev/null +++ b/src/main/resources/views/platform/personGovernance/memberMatch/index.html @@ -0,0 +1,193 @@ + + +
+ + + + + + + + + + + + + + + + 查询 + 重置 + + 新增 + + + +
+ + + + + + + + + + + + +
+ +
+ +
+

+ 预计匹配 + {{previewTotal}} + 人 +

+ + + +
+
+
+ + + diff --git a/src/main/resources/views/platform/personGovernance/personChange/common/memberAllChangeInfo.js b/src/main/resources/views/platform/personGovernance/personChange/common/memberAllChangeInfo.js new file mode 100644 index 00000000..e3c7df6a --- /dev/null +++ b/src/main/resources/views/platform/personGovernance/personChange/common/memberAllChangeInfo.js @@ -0,0 +1,114 @@ +const PERSON_GOVERNANCE_MEMBER_ALL_CHANGE_INFO = { + template: /*language=HTML*/ ` +
+ + + + {{ viewData.loginname }} + {{ viewData.username }} + {{ viewData.sex }} + + {{ viewData.nation }} + {{ viewData.birthday ? $moment(viewData.birthday).format("YYYY年MM月DD日") : "" }} + {{ viewData.political }} + + {{ viewData.education }} + {{ viewData.academicDegree }} + {{ viewData.position }} + + {{ viewData.unitName }} + {{ viewData.unionName }} + {{ viewData.campusName }} + + {{ viewData.idCard }} + {{ viewData.mobile }} + {{ viewData.email }} + + {{ viewData.userState }} + {{ viewData.personType }} + + + + {{ viewData.member ? '会员' : '非会员' }} + {{ viewData.welfareMember ? '福利会员' : '非福利会员' }} + + + + + + + + + +

{{ changeData.changeOriginName }}

+ + + + + + + + + + +
+
+
+
+
+ +
+ `, + 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(() => {}) + } + } +} diff --git a/src/main/resources/views/platform/personGovernance/personChange/common/memberChange.js b/src/main/resources/views/platform/personGovernance/personChange/common/memberChange.js new file mode 100644 index 00000000..d75014d2 --- /dev/null +++ b/src/main/resources/views/platform/personGovernance/personChange/common/memberChange.js @@ -0,0 +1,599 @@ +const PERSON_GOVERNANCE_MEMBER_CHANGE = { + template: /*language=HTML*/ ` + + + + + + + + + + + + + + + + + + 男 + 女 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 会员 + 非会员 + + + + + + + + 福利会员 + 非福利会员 + + + + + + + + + + + + + + + 返回 + 提交变更 + + + + + + + + + + `, + 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; + } + ` +} diff --git a/src/main/resources/views/platform/personGovernance/personChange/common/memberInfo.js b/src/main/resources/views/platform/personGovernance/personChange/common/memberInfo.js new file mode 100644 index 00000000..22570d7f --- /dev/null +++ b/src/main/resources/views/platform/personGovernance/personChange/common/memberInfo.js @@ -0,0 +1,108 @@ +const PERSON_GOVERNANCE_MEMBER_INFO = { + template: /*language=HTML*/ ` +
+ +
基本信息
+ + + {{ viewData.username + '(' + viewData.loginname + ')' }} + {{ viewData.sex }} + {{ viewData.birthday ? $moment(viewData.birthday).format('YYYY-MM-DD') : null }} + + + {{ viewData.nationality }} + {{ viewData.nation }} + {{ viewData.political }} + + + {{ viewData.idCardType }} + {{ viewData.idCard }} + {{ viewData.mobile }} + + + {{ viewData.education }} + {{ viewData.academicDegree }} + {{ viewData.marriage }} + + + {{ viewData.member ? '会员' : '非会员' }} + {{ viewData.welfareMember ? '福利会员' : '非福利会员' }} + + + + + + + + + + +
工作信息
+ + + {{ viewData.unitName }} + {{ viewData.unionName }} + {{ viewData.campus }} + + + {{ viewData.arrivalAtSchoolDate ? $moment(viewData.arrivalAtSchoolDate).format('YYYY-MM-DD') : null }} + {{ viewData.teachingTime ? $moment(viewData.teachingTime).format('YYYY-MM-DD') : null }} + {{ viewData.expectedLeaveSchoolDate ? $moment(viewData.expectedLeaveSchoolDate).format('YYYY-MM-DD') : null }} + + + {{ viewData.userState }} + {{ viewData.personType }} + + + {{ viewData.userAttribute }} + {{ viewData.professionalTitle }} + {{ viewData.professionalLevel }} + + + {{ viewData.positionSeries }} + {{ viewData.administrativeLevel }} + {{ viewData.positionLevel }} + + {{ viewData.administrativePositionLevel }} + {{ viewData.aidFundMemberUserType }} + + +
+ `, + 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(() => {}) + } + } +}; diff --git a/src/main/resources/views/platform/personGovernance/personChange/index.html b/src/main/resources/views/platform/personGovernance/personChange/index.html new file mode 100644 index 00000000..9c6a1ca1 --- /dev/null +++ b/src/main/resources/views/platform/personGovernance/personChange/index.html @@ -0,0 +1,923 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 设置会员 + + + + 设置人员分类 + + + + 设置人员属性 + + + + 设置在职状态 + + + + 单位调整 + + + + 全部教工 + 会员 + 非会员 + + + 人员异动: + + 全部 + 异动 + 未异动 + + + + + + + + +
+ 全选 + 反选 +
+ +
+ + + {{ f.label }} + + +
+ + + 列设置 + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 取消 + 确定 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 取消 + 确定 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 取消 + 确定 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 取消 + 确定 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 取消 + 确定 + + +
+
+ + + diff --git a/src/main/resources/views/platform/personGovernance/personException/index.html b/src/main/resources/views/platform/personGovernance/personException/index.html new file mode 100644 index 00000000..ef4138eb --- /dev/null +++ b/src/main/resources/views/platform/personGovernance/personException/index.html @@ -0,0 +1,413 @@ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + 查询 + 重置 + + + + +
+ + + + + + + + + + + + + + +
+ +
+ +
+ + + + + + + + + + + + + + + + + +
+
+
+ + + +