diff --git a/src/main/java/com/budwk/app/sys/services/SysUserMobileSyncService.java b/src/main/java/com/budwk/app/sys/services/SysUserMobileSyncService.java new file mode 100644 index 00000000..55248388 --- /dev/null +++ b/src/main/java/com/budwk/app/sys/services/SysUserMobileSyncService.java @@ -0,0 +1,310 @@ +package com.budwk.app.sys.services; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONUtil; +import com.budwk.app.base.constant.RedisConstant; +import com.budwk.app.base.constant.RoleConstant; +import com.budwk.app.web.commons.auth.utils.AuthUtil; +import com.budwk.app.base.exception.BaseException; +import com.budwk.app.sys.models.Sys_user; +import com.budwk.app.sys.param.SysDataUserUpdatePageForm; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.dao.Cnd; +import org.nutz.dao.Dao; +import org.nutz.dao.Sqls; +import org.nutz.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.util.NutMap; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.*; + +/** + * 手机号补齐任务:工号去重、会员优先、单并发限速,Redis保存队列和检查点。 + * 编排线程不持有数据库事务;每人工号通过注入的拉取service以独立短事务写入。 + */ +@IocBean +public class SysUserMobileSyncService { + // 同一hash tag使任务原子脚本兼容Redis Cluster;记录保留七天,不保存手机号。 + private static final String PREFIX = RedisConstant.PLATFORM_REDIS_PREFIX + "{userMobileSync}:"; + private static final String LOCK = PREFIX + "lock"; + private static final String LATEST = PREFIX + "latest"; + private static final String EMPTY = PREFIX + "empty"; + private static final int RETAIN_SECONDS = 7 * 24 * 3600; + private static final int LEASE_SECONDS = 120; + @Inject private Dao dao; + @Inject private RedisService redisService; + @Inject private SysDataUserPullService sysDataUserPullService; + @Inject private ThreadPoolTaskExecutor threadPoolTaskExecutor; + + /** + * 预览待请求工号范围,不调用外部接口。 + * @param scope ALL全部、MEMBER仅会员、FILTER当前筛选的全部结果 + * @param mode FILL仅补空号、REFRESH核对范围内所有人员 + * @param form 姓名、工号、单位、在职状态、编制类别、教职工类别;不使用分页参数 + * @return scopeCount范围工号数、existingCount已有号码数、cooldownCount冷却数、total待请求数 + */ + public NutMap preview(String scope, String mode, SysDataUserUpdatePageForm form) { + checkAccess(); + return select(scope, mode, form, false); + } + + /** + * 启动后台任务,返回脱敏进度;同一时间只允许一个本功能任务。 + * @param scope ALL/MEMBER/FILTER + * @param mode FILL/REFRESH + * @param form 当前列表筛选条件(仅FILTER使用) + * @param sourceTaskId 继续或重试的原任务编号;新任务传空 + * @param action NEW新任务、RESUME继续未处理部分、RETRY仅失败部分 + * @return taskId、status及total/processed/updated/empty/failed/skipped统计 + */ + @Aop(TransAop.READ_COMMITTED) + public NutMap start(String scope, String mode, SysDataUserUpdatePageForm form, String sourceTaskId, String action) { + checkAccess(); + List queue; + if ("NEW".equals(action)) { + NutMap selection = select(scope, mode, form, true); + queue = (List) selection.get("queue"); + } else if ("RESUME".equals(action) || "RETRY".equals(action)) { + NutMap source = read(sourceTaskId); + if (source.isEmpty()) throw new BaseException("任务记录已过期,请重新预览"); + if (sourceTaskId.equals(redisService.get(LOCK))) throw new BaseException("原任务仍在执行"); + mode = source.getString("mode"); + scope = source.getString("scope"); + if ("RETRY".equals(action)) { + queue = strings(source.get("failedJobs")); + } else { + List original = queue(sourceTaskId); + queue = new ArrayList<>(original.subList(Math.min(source.getInt("processed", 0), original.size()), original.size())); + // 继续时将原任务失败项排在未处理项之后,避免切换最近任务后遗失失败重试入口。 + queue.addAll(strings(source.get("failedJobs"))); + queue = new ArrayList<>(new LinkedHashSet<>(queue)); + } + } else { + throw new BaseException("不支持的任务操作"); + } + if (queue.isEmpty()) throw new BaseException("没有需要处理的人员"); + String id = UUID.randomUUID().toString(); + NutMap state = NutMap.NEW().addv("taskId", id).addv("status", "RUNNING") + .addv("scope", scope).addv("mode", mode).addv("total", queue.size()) + .addv("processed", 0).addv("updated", 0).addv("empty", 0).addv("failed", 0) + .addv("skipped", 0).addv("failedJobs", new ArrayList()) + .addv("message", "后台拉取中,每秒最多一个工号请求") + .addv("startedAt", System.currentTimeMillis()); + // 锁、队列、初始进度同时写入,防止重复点击及进程中途退出留下半个任务。 + String script = "if redis.call('exists',KEYS[1]) == 1 then return 0 end " + + "redis.call('setex',KEYS[1],ARGV[2],ARGV[1]); " + + "redis.call('setex',KEYS[2],ARGV[3],ARGV[1]); " + + "redis.call('setex',KEYS[3],ARGV[3],ARGV[4]); " + + "redis.call('setex',KEYS[4],ARGV[3],ARGV[5]); return 1"; + Object acquired = redisService.eval(script, Arrays.asList(LOCK, LATEST, stateKey(id), PREFIX + "queue:" + id), + Arrays.asList(id, String.valueOf(LEASE_SECONDS), String.valueOf(RETAIN_SECONDS), Json.toJson(state), Json.toJson(queue))); + if (!Long.valueOf(1).equals(acquired)) throw new BaseException("已有手机号任务执行中,请查看当前进度"); + NutMap initial = publicState(state); + final List jobs = queue; + try { + threadPoolTaskExecutor.execute(() -> run(state, jobs)); + } catch (RuntimeException e) { + state.put("status", "INTERRUPTED"); + state.put("message", "后台执行资源繁忙,请稍后继续任务"); + checkpoint(state, true); + throw new BaseException("后台执行资源繁忙,请稍后继续任务"); + } + return initial; + } + + /** + * 查询指定任务;taskId为空读取最近任务。锁过期的运行任务显示中断,可手动继续。 + * @param taskId 后端返回的UUID任务编号或空值 + * @return 进度对象,不含工号队列、手机号或失败人员明细 + */ + public NutMap status(String taskId) { + checkAccess(); + String id = StrUtil.isBlank(taskId) ? redisService.get(LATEST) : taskId; + NutMap state = read(id); + if (state.isEmpty()) return state; + if ("RUNNING".equals(state.getString("status")) && !id.equals(redisService.get(LOCK))) { + state.put("status", "INTERRUPTED"); + state.put("message", "任务心跳已中断,可继续未完成部分;已保存的号码不会丢失"); + } else if ("RUNNING".equals(state.getString("status")) && "1".equals(redisService.get(PREFIX + "stop:" + id))) { + state.put("message", "正在停止,等待当前工号处理结束"); + } + return publicState(state); + } + + /** + * 请求停止指定任务;当前工号处理结束后保存检查点,已完成结果不回滚。 + * @param taskId 当前任务UUID + * @return 最新脱敏进度对象 + */ + @Aop(TransAop.READ_COMMITTED) + public NutMap stop(String taskId) { + checkAccess(); + validateId(taskId); + if (taskId.equals(redisService.get(LOCK))) redisService.setex(PREFIX + "stop:" + taskId, RETAIN_SECONDS, "1"); + return status(taskId); + } + + /** 按工号去重并优先会员;读取冷却时间,不在预览中发起远程调用。 */ + private NutMap select(String scope, String mode, SysDataUserUpdatePageForm form, boolean includeQueue) { + if (!Arrays.asList("ALL", "MEMBER", "FILTER").contains(scope) + || !Arrays.asList("FILL", "REFRESH").contains(mode)) throw new BaseException("手机号拉取范围或模式无效"); + Cnd cnd = Cnd.where("u.loginname", "IS NOT", null).and("TRIM(u.loginname)", "<>", ""); + if ("MEMBER".equals(scope)) cnd.and("u.member", "=", true); + if ("FILTER".equals(scope)) { + if (form == null) throw new BaseException("缺少列表筛选条件"); + cnd.and(Cnd.likeEX("u.username", form.getUserName())); + cnd.and(Cnd.likeEX("u.loginname", form.getLoginName())); + cnd.andEX("u.unitId", "=", form.getUnitId()); + cnd.andEX("u.userState", "=", form.getUserState()); + cnd.andEX("u.preparedBy", "=", form.getPreparedBy()); + cnd.andEX("u.personType", "=", form.getPersonType()); + } + Sql sql = Sqls.create("SELECT u.loginname, MAX(COALESCE(u.member,0)) AS memberFirst, " + + "MIN(CASE WHEN u.mobile IS NULL OR TRIM(u.mobile) = '' THEN 0 ELSE 1 END) AS hasMobile " + + "FROM vw_user u $condition GROUP BY u.loginname ORDER BY memberFirst DESC,u.loginname"); + sql.setCondition(cnd); + sql.setCallback(Sqls.callback.maps()); + dao.execute(sql); + Map emptyTimes = redisService.hgetAll(EMPTY); + int existing = 0, cooldown = 0; + List jobs = new ArrayList<>(); + List rows = sql.getList(NutMap.class); + for (NutMap row : rows) { + String jobNo = row.getString("loginname"); + boolean hasMobile = row.getInt("hasMobile", 0) == 1; + if (hasMobile) existing++; + if ("FILL".equals(mode) && hasMobile) continue; + if (inCooldown(emptyTimes.get(jobNo))) { cooldown++; continue; } + jobs.add(jobNo); + } + NutMap result = NutMap.NEW().addv("scopeCount", rows.size()).addv("existingCount", existing) + .addv("cooldownCount", cooldown).addv("total", jobs.size()); + if (includeQueue) result.put("queue", jobs); + return result; + } + + /** 编排只负责远程读取与检查点;每人独立提交,连续五次失败暂停,支持手动继续和失败重试。 */ + private void run(NutMap state, List jobs) { + String id = state.getString("taskId"); + boolean fillOnly = "FILL".equals(state.getString("mode")); + int consecutiveFailures = 0; + try { + for (String jobNo : jobs) { + if (!checkpoint(state, false)) return; + if ("1".equals(redisService.get(PREFIX + "stop:" + id))) { + state.put("status", "STOPPED"); + state.put("message", "已停止,可继续未完成部分"); + break; + } + if (inCooldown(redisService.hget(EMPTY, jobNo))) { + increment(state, "skipped"); + } else { + List users = dao.query(Sys_user.class, Cnd.where(Sys_user::getLoginname, "=", jobNo)); + Map originals = new LinkedHashMap<>(); + for (Sys_user user : users) { + if (!fillOnly || StrUtil.isBlank(user.getMobile())) originals.put(user.getId(), user.getMobile()); + } + if (originals.isEmpty()) { + increment(state, "skipped"); + } else { + try { + String mobile = sysDataUserPullService.fetchTeacherMobile(jobNo); + // 外部请求返回后再次核对租约,过期的旧worker不得继续落库或提交检查点。 + if (!checkpoint(state, false)) return; + if (StrUtil.isBlank(mobile)) { + redisService.hset(EMPTY, jobNo, String.valueOf(System.currentTimeMillis())); + redisService.expire(EMPTY, RETAIN_SECONDS); + increment(state, "empty"); + } else { + int changed = sysDataUserPullService.saveTeacherMobile(jobNo, mobile, originals, fillOnly); + increment(state, changed > 0 ? "updated" : "skipped"); + } + consecutiveFailures = 0; + } catch (Exception e) { + // 不将第三方异常原文返回前端或写日志,避免泄露响应中的手机号和凭据。 + increment(state, "failed"); + ((List) state.get("failedJobs")).add(jobNo); + consecutiveFailures++; + } + // 每人工号请求结束后至少等待一秒;失败逐步退避,五连败后暂停。 + if (consecutiveFailures < 5) Thread.sleep(Math.max(1000, consecutiveFailures * 2000L)); + } + } + increment(state, "processed"); + if (consecutiveFailures >= 5) { + state.put("status", "PAUSED"); + state.put("message", "连续五次拉取失败,已暂停;请检查接口后继续或重试失败项"); + break; + } + if (!checkpoint(state, false)) return; + } + if ("RUNNING".equals(state.getString("status"))) { + state.put("status", "COMPLETED"); + state.put("message", state.getInt("failed", 0) > 0 ? "拉取结束,部分失败可单独重试" : "拉取完成"); + } + } catch (Exception e) { + if (e instanceof InterruptedException) Thread.currentThread().interrupt(); + state.put("status", "INTERRUPTED"); + state.put("message", "任务执行中断,可继续未完成部分"); + } finally { + checkpoint(state, true); + } + } + + /** 原子校验锁持有者后保存进度及续期;旧任务不能覆盖新任务,也不能释放别人的锁。 */ + private boolean checkpoint(NutMap state, boolean finish) { + String id = state.getString("taskId"); + state.put("updatedAt", System.currentTimeMillis()); + String script = "if redis.call('get',KEYS[1]) ~= ARGV[1] then return 0 end " + + "redis.call('setex',KEYS[2],ARGV[2],ARGV[3]); " + + "redis.call('expire',KEYS[3],ARGV[2]); redis.call('expire',KEYS[4],ARGV[2]); " + + (finish ? "redis.call('del',KEYS[1]); " : "redis.call('expire',KEYS[1],ARGV[4]); ") + "return 1"; + return Long.valueOf(1).equals(redisService.eval(script, + Arrays.asList(LOCK, stateKey(id), PREFIX + "queue:" + id, LATEST), + Arrays.asList(id, String.valueOf(RETAIN_SECONDS), Json.toJson(state), String.valueOf(LEASE_SECONDS)))); + } + + /** 全局人员拉取仅开放给系统管理员和校工会管理员,沿用现有会员手机号入口的角色限制。 */ + private void checkAccess() { + if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())) { + throw new BaseException("当前用户无权拉取系统人员手机号"); + } + } + + private boolean inCooldown(String time) { + if (time == null) return false; + try { return System.currentTimeMillis() - Long.parseLong(time) < RETAIN_SECONDS * 1000L; } + catch (NumberFormatException e) { return false; } + } + + private void increment(NutMap state, String field) { state.put(field, state.getInt(field, 0) + 1); } + private String stateKey(String id) { return PREFIX + "state:" + id; } + private void validateId(String id) { + if (id == null || !id.matches("[0-9a-fA-F-]{36}")) throw new BaseException("任务编号无效"); + } + private NutMap read(String id) { + if (StrUtil.isBlank(id)) return NutMap.NEW(); + validateId(id); + String json = redisService.get(stateKey(id)); + return json == null ? NutMap.NEW() : Json.fromJson(NutMap.class, json); + } + private List queue(String id) { + String json = redisService.get(PREFIX + "queue:" + id); + if (json == null) throw new BaseException("任务队列已过期,请重新预览"); + return JSONUtil.toList(JSONUtil.parseArray(json), String.class); + } + private List strings(Object value) { + return value == null ? new ArrayList<>() : JSONUtil.toList(JSONUtil.parseArray(value), String.class); + } + private NutMap publicState(NutMap state) { + NutMap result = NutMap.NEW(); + result.putAll(state); + result.remove("failedJobs"); + return result; + } +}