获取手机号

This commit is contained in:
2026-09-14 20:55:21 +08:00
parent 8301342598
commit 705fe4a086
@@ -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<String> queue;
if ("NEW".equals(action)) {
NutMap selection = select(scope, mode, form, true);
queue = (List<String>) 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<String> 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<String>())
.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<String> 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<String, String> emptyTimes = redisService.hgetAll(EMPTY);
int existing = 0, cooldown = 0;
List<String> jobs = new ArrayList<>();
List<NutMap> 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<String> 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<Sys_user> users = dao.query(Sys_user.class, Cnd.where(Sys_user::getLoginname, "=", jobNo));
Map<String, String> 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<String>) 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<String> 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<String> 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;
}
}