ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/SendMessageDTO.java
@@ -15,6 +15,14 @@ private String chargeGun; private String chargingCapacity; private String chargingFee; private Integer type;// 1 codeMsg 2 applyCodeMsg 3 faultMsg 4 vipChargeFinishMsg /** 公司名称(余额预警短信:公司总账户/子账号模板共用) */ private String companyName; /** 用户信息(余额预警短信:子账号模板使用,VIN=车牌号/卡=卡号/白名单=姓名) */ private String userInfo; /** 当前余额(余额预警短信:格式化后的金额字符串) */ private String balance; /** 预警线(余额预警短信:格式化后的金额字符串) */ private String warnAmount; private Integer type;// 1 codeMsg 2 applyCodeMsg 3 faultMsg 4 vipChargeFinishMsg 5 companyBalanceWarnMsg 6 subBalanceWarnMsg } ruoyi-api/ruoyi-api-other/src/main/java/com/ruoyi/other/api/domain/TBalanceWarningRecord.java
New file @@ -0,0 +1,55 @@ package com.ruoyi.other.api.domain; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import com.ruoyi.common.core.web.domain.BasePojo; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; import lombok.Data; import lombok.EqualsAndHashCode; import java.time.LocalDate; /** * <p> * 公司余额预警短信提醒记录实体(t_balance_warning_record,按天去重) * </p> * * @author mxcd * @since 2026-10-10 */ @Data @EqualsAndHashCode(callSuper = false) @TableName("t_balance_warning_record") @ApiModel(value = "TBalanceWarningRecord对象", description = "公司余额预警短信提醒记录") public class TBalanceWarningRecord extends BasePojo { private static final long serialVersionUID = 1L; @ApiModelProperty(value = "主键") @TableId(value = "id", type = IdType.AUTO) private Integer id; @ApiModelProperty(value = "公司ID(关联t_group_company.id)") @TableField("company_id") private Integer companyId; @ApiModelProperty(value = "预警范围(1=公司账户,2=VIN码,3=刷卡卡,4=白名单用户)") @TableField("warn_scope") private Integer warnScope; @ApiModelProperty(value = "子账户标识(公司级为空串,子账户级为VIN码/卡号/白名单手机号)") @TableField("sub_identifier") private String subIdentifier; @ApiModelProperty(value = "通知手机号") @TableField("notify_phone") private String notifyPhone; @ApiModelProperty(value = "提醒日期(按天去重依据)") @TableField("warn_date") private LocalDate warnDate; } ruoyi-common/ruoyi-common-core/src/main/java/com/ruoyi/common/core/constant/MsgConstants.java
@@ -25,6 +25,10 @@ /** 桩故障(自动/手动) TEMPLATE_ID */ public static final String FAULT_TEMPLATE_ID = "37956469a14a47e484a747a1e7e462de"; /** 公司总账户余额不足提醒 TEMPLATE_ID(参数:公司名称/当前余额/预警线) */ public static final String COMPANY_BALANCE_WARN_TEMPLATE_ID = "09d153405b8340829f546210e083e5b2"; /** 公司子账号余额不足提醒 TEMPLATE_ID(参数:公司名称/用户信息/当前余额/预警线) */ public static final String SUB_BALANCE_WARN_TEMPLATE_ID = "719a86bfb46e40f8aa828191c45f11b2"; /** 更换手机号短信验证码 AP_ID */ public static final String CODE_AP_ID = "GH123"; /** 建桩短信验证码 AP_ID */ @@ -42,4 +46,7 @@ /** 桩故障(自动/手动) AP_ID */ public static final String FAULT_AP_ID = "GZ123"; /** 余额预警(公司总账户/子账号) AP_ID(与充电结束共用) */ public static final String BALANCE_WARN_AP_ID = "CD123"; } ruoyi-common/ruoyi-common-core/src/main/java/com/ruoyi/common/core/utils/MsgUtil.java
@@ -220,6 +220,93 @@ return encode; } /** * 公司总账户余额不足提醒:尊敬的客户您好!您公司{[公司名称]}总账户余额不足,当前余额{[当前余额]}元,已低于预警线{[预警线]}元。为避免影响车辆正常充电,请及时充值。感谢配合! * @param phone 手机号 * @param companyName 公司名称(纯中文,总长度10) * @param balance 当前余额(纯数字,总长度10) * @param warnAmount 预警线(纯数字,总长度10) * @return */ public static String companyBalanceWarnMsg(String phone, String companyName, String balance, String warnAmount){ return buildBalanceWarnMsg(phone, MsgConstants.COMPANY_BALANCE_WARN_TEMPLATE_ID, new String[]{limitChinese(companyName), limitDecimal(balance), limitDecimal(warnAmount)}); } /** * 公司子账号余额不足提醒:尊敬的客户您好!您公司{[公司名称]}用户{[用户信息]}子账户余额不足,当前子账户余额{[当前余额]}元,已低于预警线{[预警线]}元。为避免影响车辆正常充电,请及时分配额度。感谢配合! * @param phone 手机号 * @param companyName 公司名称(纯中文,总长度10) * @param userInfo 用户信息(中文+字母+数字,总长度10) * @param balance 当前余额(纯数字,总长度10) * @param warnAmount 预警线(纯数字,总长度10) * @return */ public static String subBalanceWarnMsg(String phone, String companyName, String userInfo, String balance, String warnAmount){ return buildBalanceWarnMsg(phone, MsgConstants.SUB_BALANCE_WARN_TEMPLATE_ID, new String[]{limitChinese(companyName), limitUserInfo(userInfo), limitDecimal(balance), limitDecimal(warnAmount)}); } /** * 余额预警模板短信组包(公共逻辑) */ private static String buildBalanceWarnMsg(String phone, String templateId, String[] params){ SubmitTempletReq submitReq = new SubmitTempletReq(); submitReq.setApId(MsgConstants.BALANCE_WARN_AP_ID); submitReq.setEcName(ecName); submitReq.setSecretKey(secretKey); submitReq.setParams(JSON.toJSONString(params)); submitReq.setMobiles(phone); submitReq.setAddSerial(""); submitReq.setSign(sign); submitReq.setTemplateId(templateId); StringBuffer stringBuffer = new StringBuffer(); stringBuffer.append(submitReq.getEcName()); stringBuffer.append(submitReq.getApId()); stringBuffer.append(submitReq.getSecretKey()); stringBuffer.append(submitReq.getTemplateId()); stringBuffer.append(submitReq.getMobiles()); stringBuffer.append(submitReq.getParams()); stringBuffer.append(submitReq.getSign()); stringBuffer.append(submitReq.getAddSerial()); submitReq.setMac(MD5Util.getMD5(stringBuffer.toString())); String reqText = JSON.toJSONString(submitReq); return Base64.encodeBase64String(reqText.getBytes()); } /** * 公司名称参数约束:纯中文,总长度10;剔除非法字符,空值兜底 */ private static String limitChinese(String value) { String v = value == null ? "" : value.replaceAll("[^\\u4e00-\\u9fa5]", ""); if (v.length() == 0) { v = "公司"; } return v.length() <= 10 ? v : v.substring(0, 10); } /** * 用户信息参数约束:中文+字母+数字,总长度10;剔除非法字符,空值兜底 */ private static String limitUserInfo(String value) { String v = value == null ? "" : value.replaceAll("[^\\u4e00-\\u9fa5A-Za-z0-9]", ""); if (v.length() == 0) { v = "用户"; } return v.length() <= 10 ? v : v.substring(0, 10); } /** * 金额参数约束:纯数字(允许小数点),总长度10;剔除非法字符,空值兜底 */ private static String limitDecimal(String value) { String v = value == null ? "" : value.replaceAll("[^\\d.]", ""); if (v.length() == 0) { v = "0"; } return v.length() <= 10 ? v : v.substring(0, 10); } /** * 生成验证码 * @return */ ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TAppUserController.java
@@ -1722,6 +1722,16 @@ sendMessageDTO.getChargingCapacity(), sendMessageDTO.getChargingFee()); String result4 = HttpUtils.post(MsgConstants.SEND_URL, reqStr4); return R.ok(result4); case 5: String reqStr5 = MsgUtil.companyBalanceWarnMsg(sendMessageDTO.getPhone(), sendMessageDTO.getCompanyName(), sendMessageDTO.getBalance(), sendMessageDTO.getWarnAmount()); String result5 = HttpUtils.post(MsgConstants.SEND_URL, reqStr5); return R.ok(result5); case 6: String reqStr6 = MsgUtil.subBalanceWarnMsg(sendMessageDTO.getPhone(), sendMessageDTO.getCompanyName(), sendMessageDTO.getUserInfo(), sendMessageDTO.getBalance(), sendMessageDTO.getWarnAmount()); String result6 = HttpUtils.post(MsgConstants.SEND_URL, reqStr6); return R.ok(result6); default: String reqStr3 = MsgUtil.faultMsg(sendMessageDTO.getPhone(), sendMessageDTO.getSite(), sendMessageDTO.getChargeGun()); String result3 = HttpUtils.post(MsgConstants.SEND_URL, reqStr3); ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/mapper/TBalanceWarningRecordMapper.java
New file @@ -0,0 +1,16 @@ package com.ruoyi.other.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.ruoyi.other.api.domain.TBalanceWarningRecord; /** * <p> * 公司余额预警短信提醒记录 Mapper 接口 * </p> * * @author mxcd * @since 2026-10-10 */ public interface TBalanceWarningRecordMapper extends BaseMapper<TBalanceWarningRecord> { } ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/TBalanceWarningRecordService.java
New file @@ -0,0 +1,26 @@ package com.ruoyi.other.service; import com.baomidou.mybatisplus.extension.service.IService; import com.ruoyi.other.api.domain.TBalanceWarningRecord; /** * <p> * 公司余额预警短信提醒记录 服务类 * </p> * * @author mxcd * @since 2026-10-10 */ public interface TBalanceWarningRecordService extends IService<TBalanceWarningRecord> { /** * 今日是否已提醒过(按天去重:公司 + 预警范围 + 子账户标识 + 当天日期) * * @param companyId 公司ID * @param warnScope 预警范围(1=公司账户,2=VIN码,3=刷卡卡,4=白名单用户) * @param subIdentifier 子账户标识(公司级传空串) * @return 今日已提醒返回 true */ boolean isNotifiedToday(Integer companyId, Integer warnScope, String subIdentifier); } ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/TBalanceWarningScanService.java
New file @@ -0,0 +1,18 @@ package com.ruoyi.other.service; /** * <p> * 公司余额预警扫描服务(触发扫描 + 短信通知) * </p> * * @author mxcd * @since 2026-10-10 */ public interface TBalanceWarningScanService { /** * 扫描公司余额预警配置,对触发预警的公司/子账户发送提醒短信(按天去重) */ void scan(); } ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TBalanceWarningRecordServiceImpl.java
New file @@ -0,0 +1,38 @@ package com.ruoyi.other.service.impl; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.ruoyi.other.api.domain.TBalanceWarningRecord; import com.ruoyi.other.mapper.TBalanceWarningRecordMapper; import com.ruoyi.other.service.TBalanceWarningRecordService; import org.springframework.stereotype.Service; import java.time.LocalDate; /** * <p> * 公司余额预警短信提醒记录 服务实现类 * </p> * * @author mxcd * @since 2026-10-10 */ @Service public class TBalanceWarningRecordServiceImpl extends ServiceImpl<TBalanceWarningRecordMapper, TBalanceWarningRecord> implements TBalanceWarningRecordService { @Override public boolean isNotifiedToday(Integer companyId, Integer warnScope, String subIdentifier) { if (companyId == null || warnScope == null) { return false; } String identifier = subIdentifier == null ? "" : subIdentifier; return count(Wrappers.<TBalanceWarningRecord>lambdaQuery() .eq(TBalanceWarningRecord::getCompanyId, companyId) .eq(TBalanceWarningRecord::getWarnScope, warnScope) .eq(TBalanceWarningRecord::getSubIdentifier, identifier) .eq(TBalanceWarningRecord::getWarnDate, LocalDate.now())) > 0; } } ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TBalanceWarningScanServiceImpl.java
New file @@ -0,0 +1,338 @@ package com.ruoyi.other.service.impl; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.ruoyi.account.api.dto.SendMessageDTO; import com.ruoyi.account.api.feignClient.AppUserClient; import com.ruoyi.account.api.feignClient.AppUserWhitelistClient; import com.ruoyi.account.api.feignClient.ChargingCardClient; import com.ruoyi.account.api.feignClient.VinManagementClient; import com.ruoyi.account.api.model.TAppUserWhitelist; import com.ruoyi.account.api.model.TChargingCard; import com.ruoyi.account.api.model.TVinManagement; import com.ruoyi.common.core.domain.R; import com.ruoyi.other.api.domain.TBalanceWarningRecord; import com.ruoyi.other.api.domain.TCompanyBalanceWarning; import com.ruoyi.other.api.domain.TGroup; import com.ruoyi.other.api.domain.TGroupCompany; import com.ruoyi.other.mapper.TCompanyBalanceWarningMapper; import com.ruoyi.other.mapper.TGroupCompanyMapper; import com.ruoyi.other.mapper.TGroupMapper; import com.ruoyi.other.service.TBalanceWarningRecordService; import com.ruoyi.other.service.TBalanceWarningScanService; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; /** * <p> * 公司余额预警扫描服务实现: * 遍历预警配置,公司总账户余额/子账户余额低于预警线时发送提醒短信(按天去重)。 * </p> * * @author mxcd * @since 2026-10-10 */ @Slf4j @Service public class TBalanceWarningScanServiceImpl implements TBalanceWarningScanService { /** 预警范围:公司账户 */ private static final int WARN_SCOPE_COMPANY = 1; /** 预警范围:VIN码 */ private static final int WARN_SCOPE_VIN = 2; /** 预警范围:刷卡卡 */ private static final int WARN_SCOPE_CARD = 3; /** 预警范围:白名单用户 */ private static final int WARN_SCOPE_WHITELIST = 4; /** 通知对象:集团联系人 */ private static final int NOTIFY_OBJECT_GROUP_CONTACT = 1; /** 通知对象:公司联系人 */ private static final int NOTIFY_OBJECT_COMPANY_CONTACT = 2; /** 通知方式:短信 */ private static final int NOTIFY_TYPE_SMS = 1; /** 账户模式:分配模式 */ private static final int ACCOUNT_MODE_ALLOCATION = 2; /** 公司状态:启用 */ private static final int COMPANY_STATUS_ENABLED = 1; /** 短信类型:公司总账户余额不足 */ private static final int SMS_TYPE_COMPANY_BALANCE = 5; /** 短信类型:子账号余额不足 */ private static final int SMS_TYPE_SUB_BALANCE = 6; @Resource private TCompanyBalanceWarningMapper companyBalanceWarningMapper; @Resource private TGroupCompanyMapper groupCompanyMapper; @Resource private TGroupMapper groupMapper; @Resource private TBalanceWarningRecordService balanceWarningRecordService; @Resource private VinManagementClient vinManagementClient; @Resource private ChargingCardClient chargingCardClient; @Resource private AppUserWhitelistClient appUserWhitelistClient; @Resource private AppUserClient appUserClient; @Override public void scan() { List<TCompanyBalanceWarning> warnings = companyBalanceWarningMapper.selectList( Wrappers.<TCompanyBalanceWarning>lambdaQuery()); for (TCompanyBalanceWarning warning : warnings) { try { handleWarning(warning); } catch (Exception e) { log.error("余额预警扫描处理失败, companyId={}", warning.getCompanyId(), e); } } } /** * 处理单条预警配置 */ private void handleWarning(TCompanyBalanceWarning warning) { if (warning.getCompanyId() == null) { return; } TGroupCompany company = groupCompanyMapper.selectById(warning.getCompanyId()); // 公司不存在或已停用,跳过 if (company == null || !Integer.valueOf(COMPANY_STATUS_ENABLED).equals(company.getStatus())) { return; } // 仅支持短信通知 if (warning.getNotifyType() != null && warning.getNotifyType() != NOTIFY_TYPE_SMS) { return; } // 联系人手机号为空时跳过(记日志) String phone = resolveNotifyPhone(warning.getNotifyObject(), company); if (isBlank(phone)) { log.info("余额预警联系人手机号为空,跳过发送, companyId={}, notifyObject={}", warning.getCompanyId(), warning.getNotifyObject()); return; } // 公司总账户余额预警 if (warning.getCompanyWarnAmount() != null && company.getAvailableBalance() != null && company.getAvailableBalance().compareTo(warning.getCompanyWarnAmount()) <= 0) { sendCompanyWarn(warning, company, phone); } // 子账户余额预警(仅分配模式) if (Integer.valueOf(ACCOUNT_MODE_ALLOCATION).equals(company.getAccountMode()) && warning.getSubWarnAmount() != null) { warnSubAccounts(warning, company, phone); } } /** * 解析通知对象手机号(1=集团联系人,2=公司联系人;默认集团联系人) */ private String resolveNotifyPhone(Integer notifyObject, TGroupCompany company) { int target = notifyObject == null ? NOTIFY_OBJECT_GROUP_CONTACT : notifyObject; if (target == NOTIFY_OBJECT_COMPANY_CONTACT) { return company.getContactPhone(); } if (company.getGroupId() == null) { return null; } TGroup group = groupMapper.selectById(company.getGroupId()); return group == null ? null : group.getContactPhone(); } /** * 发送公司总账户余额不足提醒(模板1:公司名称/当前余额/预警线) */ private void sendCompanyWarn(TCompanyBalanceWarning warning, TGroupCompany company, String phone) { if (balanceWarningRecordService.isNotifiedToday(warning.getCompanyId(), WARN_SCOPE_COMPANY, "")) { return; } SendMessageDTO dto = new SendMessageDTO(); dto.setType(SMS_TYPE_COMPANY_BALANCE); dto.setPhone(phone); dto.setCompanyName(company.getName()); dto.setBalance(formatAmount(company.getAvailableBalance())); dto.setWarnAmount(formatAmount(warning.getCompanyWarnAmount())); sendSms(dto, warning.getCompanyId(), WARN_SCOPE_COMPANY, "", phone); } /** * 扫描三类子账户(VIN/卡/白名单)余额,低于子账户预警线时发送提醒(模板2) */ private void warnSubAccounts(TCompanyBalanceWarning warning, TGroupCompany company, String phone) { Integer companyId = warning.getCompanyId(); BigDecimal subWarn = warning.getSubWarnAmount(); String companyName = company.getName(); for (TVinManagement vin : fetchVins(companyId)) { if (vin.getBalance() == null || vin.getBalance().compareTo(subWarn) > 0) { continue; } String identifier = vin.getVin(); if (isBlank(identifier)) { continue; } if (balanceWarningRecordService.isNotifiedToday(companyId, WARN_SCOPE_VIN, identifier)) { continue; } String userInfo = isBlank(vin.getPlateNumber()) ? identifier : vin.getPlateNumber(); sendSms(buildSubWarnDto(phone, companyName, userInfo, vin.getBalance(), subWarn), companyId, WARN_SCOPE_VIN, identifier, phone); } for (TChargingCard card : fetchCards(companyId)) { if (card.getBalance() == null || card.getBalance().compareTo(subWarn) > 0) { continue; } String identifier = card.getCardNo(); if (isBlank(identifier)) { continue; } if (balanceWarningRecordService.isNotifiedToday(companyId, WARN_SCOPE_CARD, identifier)) { continue; } sendSms(buildSubWarnDto(phone, companyName, identifier, card.getBalance(), subWarn), companyId, WARN_SCOPE_CARD, identifier, phone); } for (TAppUserWhitelist whitelist : fetchWhitelists(companyId)) { if (whitelist.getBalance() == null || whitelist.getBalance().compareTo(subWarn) > 0) { continue; } String identifier = whitelist.getPhone(); if (isBlank(identifier)) { continue; } if (balanceWarningRecordService.isNotifiedToday(companyId, WARN_SCOPE_WHITELIST, identifier)) { continue; } String userInfo = isBlank(whitelist.getName()) ? identifier : whitelist.getName(); sendSms(buildSubWarnDto(phone, companyName, userInfo, whitelist.getBalance(), subWarn), companyId, WARN_SCOPE_WHITELIST, identifier, phone); } } /** * 构建子账号余额不足短信 DTO */ private SendMessageDTO buildSubWarnDto(String phone, String companyName, String userInfo, BigDecimal balance, BigDecimal subWarn) { SendMessageDTO dto = new SendMessageDTO(); dto.setType(SMS_TYPE_SUB_BALANCE); dto.setPhone(phone); dto.setCompanyName(companyName); dto.setUserInfo(userInfo); dto.setBalance(formatAmount(balance)); dto.setWarnAmount(formatAmount(subWarn)); return dto; } /** * 发送短信;成功后写提醒记录(按天去重),失败不写以便下次扫描重试 */ private void sendSms(SendMessageDTO dto, Integer companyId, int warnScope, String subIdentifier, String phone) { try { R<String> result = appUserClient.sendMessage(dto); if (R.isSuccess(result)) { saveNotifyRecord(companyId, warnScope, subIdentifier, phone); log.info("余额预警短信发送成功, companyId={}, scope={}, identifier={}, phone={}", companyId, warnScope, subIdentifier, phone); } else { log.warn("余额预警短信发送失败, companyId={}, scope={}, identifier={}, result={}", companyId, warnScope, subIdentifier, result); } } catch (Exception e) { log.error("余额预警短信发送异常, companyId={}, scope={}, identifier={}", companyId, warnScope, subIdentifier, e); } } /** * 保存提醒记录(唯一键兜底,重复插入忽略) */ private void saveNotifyRecord(Integer companyId, int warnScope, String subIdentifier, String phone) { try { TBalanceWarningRecord record = new TBalanceWarningRecord(); record.setCompanyId(companyId); record.setWarnScope(warnScope); record.setSubIdentifier(subIdentifier == null ? "" : subIdentifier); record.setNotifyPhone(phone); record.setWarnDate(LocalDate.now()); balanceWarningRecordService.save(record); } catch (Exception e) { log.warn("保存余额预警提醒记录失败(可能唯一键冲突), companyId={}, scope={}, identifier={}", companyId, warnScope, subIdentifier, e); } } /** * 金额格式化:保留2位小数;超过模板变量长度(10)时退回整数部分,仍超则截断 */ private String formatAmount(BigDecimal amount) { if (amount == null) { return "0"; } String value = amount.setScale(2, RoundingMode.HALF_UP).toPlainString(); if (value.length() > 10) { value = amount.setScale(0, RoundingMode.DOWN).toPlainString(); } return value.length() > 10 ? value.substring(0, 10) : value; } /** * 远程查询公司VIN码列表(降级返回空列表,保证扫描主流程不受影响) */ private List<TVinManagement> fetchVins(Integer companyId) { try { R<List<TVinManagement>> response = vinManagementClient.listByCompany(companyId); return (response != null && R.isSuccess(response) && response.getData() != null) ? response.getData() : new ArrayList<>(); } catch (Exception e) { return new ArrayList<>(); } } /** * 远程查询公司卡片列表(降级返回空列表) */ private List<TChargingCard> fetchCards(Integer companyId) { try { R<List<TChargingCard>> response = chargingCardClient.listByCompany(companyId); return (response != null && R.isSuccess(response) && response.getData() != null) ? response.getData() : new ArrayList<>(); } catch (Exception e) { return new ArrayList<>(); } } /** * 远程查询公司白名单列表(降级返回空列表) */ private List<TAppUserWhitelist> fetchWhitelists(Integer companyId) { try { R<List<TAppUserWhitelist>> response = appUserWhitelistClient.listByCompany(companyId); return (response != null && R.isSuccess(response) && response.getData() != null) ? response.getData() : new ArrayList<>(); } catch (Exception e) { return new ArrayList<>(); } } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } } ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImpl.java
@@ -33,8 +33,11 @@ import org.springframework.transaction.annotation.Transactional; import javax.annotation.Resource; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.math.BigDecimal; import java.math.RoundingMode; import java.nio.charset.StandardCharsets; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; @@ -44,6 +47,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.ThreadLocalRandom; import java.util.stream.Collectors; /** @@ -87,6 +91,21 @@ /** 账户状态:欠款 */ private static final int ACCOUNT_STATUS_OVERDUE = 2; /** 公司登录账号前缀:admin */ private static final String ACCOUNT_PREFIX = "admin"; /** 公司登录账号随机数字位数 */ private static final int ACCOUNT_RANDOM_DIGITS = 4; /** 账号生成查重重试上限 */ private static final int ACCOUNT_GENERATE_MAX_ATTEMPTS = 20; /** 新增公司初始密码(明文,落库前按登录口径 HMAC-MD5 处理) */ private static final String DEFAULT_INITIAL_PASSWORD = "123456"; /** 密码传输 HMAC 密钥(与前端 CryptoJS.HmacMD5(password, 'password') 口径一致) */ private static final String PASSWORD_HMAC_KEY = "password"; /** * 车队 Mapper。 @@ -387,30 +406,21 @@ @Override @Transactional(rollbackFor = Exception.class) public AjaxResult addCompany(TGroupCompany company) { if (!StringUtils.hasText(company.getAccount())) { return AjaxResult.error("登录账号不能为空"); } // 登录账号由系统自动生成:admin + 4位随机数字,前端不可指定、不可修改 String account = generateCompanyAccount(); company.setAccount(account); // 添加企业弹窗不再配置账户模式:未指定时按共享模式建档,后续可在“账户模式”弹窗切换 if (company.getAccountMode() == null) { company.setAccountMode(ACCOUNT_MODE_SHARED); } if (!StringUtils.hasText(company.getPassword())) { return AjaxResult.error("登录密码不能为空"); } if (accountExists(company.getAccount(), null)) { return AjaxResult.error("登录账号不能重复"); } R<SysUser> existR = sysUserClient.queryUserByUserName(company.getAccount()); SysUser existUser = existR == null ? null : existR.getData(); if (existUser != null) { return AjaxResult.error("登录账号不能重复"); } // 初始密码固定 123456,按登录口径 HMAC-MD5 后由 system 侧 BCrypt 落库 String password = hmacMd5(DEFAULT_INITIAL_PASSWORD, PASSWORD_HMAC_KEY); Long roleId = resolveCompanyRoleId(); SysUser user = new SysUser(); user.setUserName(company.getAccount()); user.setUserName(account); user.setNickName(company.getName()); user.setPhonenumber(company.getContactPhone()); user.setPassword(company.getPassword()); user.setPassword(password); user.setStatus("0"); user.setDelFlag("0"); user.setRoleId(roleId); @@ -428,7 +438,47 @@ created.setObjectId(company.getId()); sysUserClient.updateUser(created); } return AjaxResult.success(); // 返回生成的登录账号,供添加页回显(初始密码为固定 123456) return AjaxResult.success("操作成功", account); } /** * 生成公司登录账号:admin + 4位随机数字,公司表与 sys_user 双查重,冲突重试 */ private String generateCompanyAccount() { int start = (int) Math.pow(10, ACCOUNT_RANDOM_DIGITS - 1); int bound = (int) Math.pow(10, ACCOUNT_RANDOM_DIGITS) - start; for (int i = 0; i < ACCOUNT_GENERATE_MAX_ATTEMPTS; i++) { String candidate = ACCOUNT_PREFIX + (start + ThreadLocalRandom.current().nextInt(bound)); if (accountExists(candidate, null)) { continue; } R<SysUser> existR = sysUserClient.queryUserByUserName(candidate); SysUser existUser = existR == null ? null : existR.getData(); if (existUser == null) { return candidate; } } throw new ServiceException("登录账号生成失败,请稍后重试"); } /** * HMAC-MD5(小写 Hex),与前端 CryptoJS.HmacMD5(text, key) 口径一致 */ private String hmacMd5(String text, String key) { try { Mac mac = Mac.getInstance("HmacMD5"); mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacMD5")); byte[] bytes = mac.doFinal(text.getBytes(StandardCharsets.UTF_8)); StringBuilder hex = new StringBuilder(bytes.length * 2); for (byte b : bytes) { hex.append(Character.forDigit((b >> 4) & 0xF, 16)); hex.append(Character.forDigit(b & 0xF, 16)); } return hex.toString(); } catch (Exception e) { throw new ServiceException("密码处理失败: " + e.getMessage()); } } @Override ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/task/BalanceWarningScanTask.java
New file @@ -0,0 +1,26 @@ package com.ruoyi.other.task; import com.ruoyi.other.service.TBalanceWarningScanService; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import javax.annotation.Resource; /** * 公司余额预警扫描定时任务(每5分钟,可通过 balance-warning.scan-cron 配置覆盖) * * @author mxcd * @since 2026-10-10 */ @Component public class BalanceWarningScanTask { @Resource private TBalanceWarningScanService balanceWarningScanService; @Scheduled(cron = "${balance-warning.scan-cron:0 */5 * * * ?}") public void scan() { balanceWarningScanService.scan(); } } ruoyi-service/ruoyi-other/src/test/java/com/ruoyi/other/service/impl/TBalanceWarningScanServiceImplTest.java
New file @@ -0,0 +1,312 @@ package com.ruoyi.other.service.impl; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.ruoyi.account.api.dto.SendMessageDTO; import com.ruoyi.account.api.feignClient.AppUserClient; import com.ruoyi.account.api.feignClient.AppUserWhitelistClient; import com.ruoyi.account.api.feignClient.ChargingCardClient; import com.ruoyi.account.api.feignClient.VinManagementClient; import com.ruoyi.account.api.model.TAppUserWhitelist; import com.ruoyi.account.api.model.TChargingCard; import com.ruoyi.account.api.model.TVinManagement; import com.ruoyi.common.core.domain.R; import com.ruoyi.other.api.domain.TBalanceWarningRecord; import com.ruoyi.other.api.domain.TCompanyBalanceWarning; import com.ruoyi.other.api.domain.TGroup; import com.ruoyi.other.api.domain.TGroupCompany; import com.ruoyi.other.mapper.TCompanyBalanceWarningMapper; import com.ruoyi.other.mapper.TGroupCompanyMapper; import com.ruoyi.other.mapper.TGroupMapper; import com.ruoyi.other.service.TBalanceWarningRecordService; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.Mockito; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.util.Arrays; import java.util.Collections; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; /** * 公司余额预警扫描服务单测,不依赖 Spring 容器与数据库。 */ class TBalanceWarningScanServiceImplTest { @BeforeAll static void initTableInfo() { // 初始化 MyBatis-Plus 表元数据,使 scan() 中的 LambdaQueryWrapper 可在无 Spring/DB 环境下解析字段 TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), TCompanyBalanceWarning.class); } @Test void sendsCompanyWarnSmsWhenBalanceBelowThreshold() { TCompanyBalanceWarningMapper warningMapper = Mockito.mock(TCompanyBalanceWarningMapper.class); TCompanyBalanceWarning warning = warning(); warning.setSubWarnAmount(null); Mockito.when(warningMapper.selectList(Mockito.any())).thenReturn(Collections.singletonList(warning)); TGroupCompany company = company(); company.setAccountMode(1); company.setAvailableBalance(new BigDecimal("500")); company.setContactPhone("13800138000"); TGroupCompanyMapper companyMapper = Mockito.mock(TGroupCompanyMapper.class); Mockito.when(companyMapper.selectById(11)).thenReturn(company); TBalanceWarningRecordService recordService = Mockito.mock(TBalanceWarningRecordService.class); AppUserClient appUserClient = Mockito.mock(AppUserClient.class); Mockito.when(appUserClient.sendMessage(Mockito.any(SendMessageDTO.class))).thenReturn(R.ok("ok")); buildService(warningMapper, companyMapper, null, recordService, Mockito.mock(VinManagementClient.class), Mockito.mock(ChargingCardClient.class), Mockito.mock(AppUserWhitelistClient.class), appUserClient).scan(); ArgumentCaptor<SendMessageDTO> captor = ArgumentCaptor.forClass(SendMessageDTO.class); Mockito.verify(appUserClient).sendMessage(captor.capture()); SendMessageDTO dto = captor.getValue(); assertEquals(5, dto.getType()); assertEquals("13800138000", dto.getPhone()); assertEquals("利普芯科技有限公司", dto.getCompanyName()); assertEquals("500.00", dto.getBalance()); assertEquals("1000.00", dto.getWarnAmount()); ArgumentCaptor<TBalanceWarningRecord> recordCaptor = ArgumentCaptor.forClass(TBalanceWarningRecord.class); Mockito.verify(recordService).save(recordCaptor.capture()); assertEquals(11, recordCaptor.getValue().getCompanyId()); assertEquals(1, recordCaptor.getValue().getWarnScope()); } @Test void skipsWhenAlreadyNotifiedToday() { TCompanyBalanceWarningMapper warningMapper = Mockito.mock(TCompanyBalanceWarningMapper.class); TCompanyBalanceWarning warning = warning(); warning.setSubWarnAmount(null); Mockito.when(warningMapper.selectList(Mockito.any())).thenReturn(Collections.singletonList(warning)); TGroupCompany company = company(); company.setAccountMode(1); company.setAvailableBalance(new BigDecimal("500")); company.setContactPhone("13800138000"); TGroupCompanyMapper companyMapper = Mockito.mock(TGroupCompanyMapper.class); Mockito.when(companyMapper.selectById(11)).thenReturn(company); TBalanceWarningRecordService recordService = Mockito.mock(TBalanceWarningRecordService.class); Mockito.when(recordService.isNotifiedToday(11, 1, "")).thenReturn(true); AppUserClient appUserClient = Mockito.mock(AppUserClient.class); buildService(warningMapper, companyMapper, null, recordService, Mockito.mock(VinManagementClient.class), Mockito.mock(ChargingCardClient.class), Mockito.mock(AppUserWhitelistClient.class), appUserClient).scan(); Mockito.verify(appUserClient, Mockito.never()).sendMessage(Mockito.any(SendMessageDTO.class)); Mockito.verify(recordService, Mockito.never()).save(Mockito.any(TBalanceWarningRecord.class)); } @Test void skipsWhenNotifyPhoneBlank() { TCompanyBalanceWarningMapper warningMapper = Mockito.mock(TCompanyBalanceWarningMapper.class); TCompanyBalanceWarning warning = warning(); warning.setSubWarnAmount(null); Mockito.when(warningMapper.selectList(Mockito.any())).thenReturn(Collections.singletonList(warning)); TGroupCompany company = company(); company.setAccountMode(1); company.setAvailableBalance(new BigDecimal("500")); company.setContactPhone(null); TGroupCompanyMapper companyMapper = Mockito.mock(TGroupCompanyMapper.class); Mockito.when(companyMapper.selectById(11)).thenReturn(company); TBalanceWarningRecordService recordService = Mockito.mock(TBalanceWarningRecordService.class); AppUserClient appUserClient = Mockito.mock(AppUserClient.class); buildService(warningMapper, companyMapper, null, recordService, Mockito.mock(VinManagementClient.class), Mockito.mock(ChargingCardClient.class), Mockito.mock(AppUserWhitelistClient.class), appUserClient).scan(); Mockito.verifyNoInteractions(appUserClient, recordService); } @Test void sendsSubAccountWarnSmsForAllocationMode() { TCompanyBalanceWarningMapper warningMapper = Mockito.mock(TCompanyBalanceWarningMapper.class); TCompanyBalanceWarning warning = warning(); warning.setCompanyWarnAmount(new BigDecimal("100000")); Mockito.when(warningMapper.selectList(Mockito.any())).thenReturn(Collections.singletonList(warning)); TGroupCompany company = company(); company.setAccountMode(2); company.setAvailableBalance(new BigDecimal("500000")); company.setContactPhone("13800138000"); TGroupCompanyMapper companyMapper = Mockito.mock(TGroupCompanyMapper.class); Mockito.when(companyMapper.selectById(11)).thenReturn(company); // VIN1 余额低于子账户预警线且车牌号为空(回退VIN码),VIN2 余额充足不提醒 TVinManagement vin1 = vin("VIN123456", null, new BigDecimal("100")); TVinManagement vin2 = vin("VIN999999", "川B88888", new BigDecimal("300")); VinManagementClient vinClient = Mockito.mock(VinManagementClient.class); Mockito.when(vinClient.listByCompany(11)).thenReturn(R.ok(Arrays.asList(vin1, vin2))); // 卡余额低于预警线 TChargingCard card = new TChargingCard(); card.setCardNo("CARD001"); card.setBalance(new BigDecimal("150")); ChargingCardClient cardClient = Mockito.mock(ChargingCardClient.class); Mockito.when(cardClient.listByCompany(11)).thenReturn(R.ok(Collections.singletonList(card))); // 白名单1余额低于预警线(姓名回退手机号),白名单2余额充足 TAppUserWhitelist wl1 = whitelist("13900000001", null, new BigDecimal("180")); TAppUserWhitelist wl2 = whitelist("13900000002", "李四", new BigDecimal("250")); AppUserWhitelistClient whitelistClient = Mockito.mock(AppUserWhitelistClient.class); Mockito.when(whitelistClient.listByCompany(11)).thenReturn(R.ok(Arrays.asList(wl1, wl2))); TBalanceWarningRecordService recordService = Mockito.mock(TBalanceWarningRecordService.class); AppUserClient appUserClient = Mockito.mock(AppUserClient.class); Mockito.when(appUserClient.sendMessage(Mockito.any(SendMessageDTO.class))).thenReturn(R.ok("ok")); buildService(warningMapper, companyMapper, null, recordService, vinClient, cardClient, whitelistClient, appUserClient).scan(); ArgumentCaptor<SendMessageDTO> captor = ArgumentCaptor.forClass(SendMessageDTO.class); Mockito.verify(appUserClient, Mockito.times(3)).sendMessage(captor.capture()); List<SendMessageDTO> sent = captor.getAllValues(); for (SendMessageDTO dto : sent) { assertEquals(6, dto.getType()); assertEquals("13800138000", dto.getPhone()); assertEquals("200.00", dto.getWarnAmount()); } // VIN:车牌号为空回退VIN码 assertTrue(sent.stream().anyMatch(dto -> "VIN123456".equals(dto.getUserInfo()) && "100.00".equals(dto.getBalance()))); // 卡:用户信息取卡号 assertTrue(sent.stream().anyMatch(dto -> "CARD001".equals(dto.getUserInfo()) && "150.00".equals(dto.getBalance()))); // 白名单:姓名为空回退手机号 assertTrue(sent.stream().anyMatch(dto -> "13900000001".equals(dto.getUserInfo()) && "180.00".equals(dto.getBalance()))); } @Test void skipsSubAccountScanInSharedMode() { TCompanyBalanceWarningMapper warningMapper = Mockito.mock(TCompanyBalanceWarningMapper.class); TCompanyBalanceWarning warning = warning(); warning.setCompanyWarnAmount(new BigDecimal("100000")); Mockito.when(warningMapper.selectList(Mockito.any())).thenReturn(Collections.singletonList(warning)); TGroupCompany company = company(); company.setAccountMode(1); company.setAvailableBalance(new BigDecimal("500000")); company.setContactPhone("13800138000"); TGroupCompanyMapper companyMapper = Mockito.mock(TGroupCompanyMapper.class); Mockito.when(companyMapper.selectById(11)).thenReturn(company); VinManagementClient vinClient = Mockito.mock(VinManagementClient.class); ChargingCardClient cardClient = Mockito.mock(ChargingCardClient.class); AppUserWhitelistClient whitelistClient = Mockito.mock(AppUserWhitelistClient.class); AppUserClient appUserClient = Mockito.mock(AppUserClient.class); buildService(warningMapper, companyMapper, null, Mockito.mock(TBalanceWarningRecordService.class), vinClient, cardClient, whitelistClient, appUserClient).scan(); Mockito.verifyNoInteractions(vinClient, cardClient, whitelistClient, appUserClient); } @Test void routesToGroupContactWhenNotifyObjectIsGroup() { TCompanyBalanceWarningMapper warningMapper = Mockito.mock(TCompanyBalanceWarningMapper.class); TCompanyBalanceWarning warning = warning(); warning.setSubWarnAmount(null); warning.setNotifyObject(1); Mockito.when(warningMapper.selectList(Mockito.any())).thenReturn(Collections.singletonList(warning)); TGroupCompany company = company(); company.setAccountMode(1); company.setAvailableBalance(new BigDecimal("500")); company.setContactPhone("13800138000"); company.setGroupId(5); TGroupCompanyMapper companyMapper = Mockito.mock(TGroupCompanyMapper.class); Mockito.when(companyMapper.selectById(11)).thenReturn(company); TGroup group = new TGroup(); group.setId(5); group.setContactPhone("13711112222"); TGroupMapper groupMapper = Mockito.mock(TGroupMapper.class); Mockito.when(groupMapper.selectById(5)).thenReturn(group); AppUserClient appUserClient = Mockito.mock(AppUserClient.class); Mockito.when(appUserClient.sendMessage(Mockito.any(SendMessageDTO.class))).thenReturn(R.ok("ok")); buildService(warningMapper, companyMapper, groupMapper, Mockito.mock(TBalanceWarningRecordService.class), Mockito.mock(VinManagementClient.class), Mockito.mock(ChargingCardClient.class), Mockito.mock(AppUserWhitelistClient.class), appUserClient).scan(); ArgumentCaptor<SendMessageDTO> captor = ArgumentCaptor.forClass(SendMessageDTO.class); Mockito.verify(appUserClient).sendMessage(captor.capture()); assertEquals("13711112222", captor.getValue().getPhone()); } private TBalanceWarningScanServiceImpl buildService(TCompanyBalanceWarningMapper warningMapper, TGroupCompanyMapper companyMapper, TGroupMapper groupMapper, TBalanceWarningRecordService recordService, VinManagementClient vinClient, ChargingCardClient cardClient, AppUserWhitelistClient whitelistClient, AppUserClient appUserClient) { TBalanceWarningScanServiceImpl service = new TBalanceWarningScanServiceImpl(); ReflectionTestUtils.setField(service, "companyBalanceWarningMapper", warningMapper); ReflectionTestUtils.setField(service, "groupCompanyMapper", companyMapper); ReflectionTestUtils.setField(service, "groupMapper", groupMapper); ReflectionTestUtils.setField(service, "balanceWarningRecordService", recordService); ReflectionTestUtils.setField(service, "vinManagementClient", vinClient); ReflectionTestUtils.setField(service, "chargingCardClient", cardClient); ReflectionTestUtils.setField(service, "appUserWhitelistClient", whitelistClient); ReflectionTestUtils.setField(service, "appUserClient", appUserClient); return service; } private TCompanyBalanceWarning warning() { TCompanyBalanceWarning warning = new TCompanyBalanceWarning(); warning.setCompanyId(11); warning.setCompanyWarnAmount(new BigDecimal("1000")); warning.setSubWarnAmount(new BigDecimal("200")); warning.setNotifyObject(2); warning.setNotifyType(1); return warning; } private TGroupCompany company() { TGroupCompany company = new TGroupCompany(); company.setId(11); company.setName("利普芯科技有限公司"); company.setStatus(1); company.setAccountMode(2); return company; } private TVinManagement vin(String vin, String plateNumber, BigDecimal balance) { TVinManagement entity = new TVinManagement(); entity.setVin(vin); entity.setPlateNumber(plateNumber); entity.setBalance(balance); return entity; } private TAppUserWhitelist whitelist(String phone, String name, BigDecimal balance) { TAppUserWhitelist entity = new TAppUserWhitelist(); entity.setPhone(phone); entity.setName(name); entity.setBalance(balance); return entity; } } ruoyi-service/ruoyi-other/src/test/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImplTest.java
@@ -29,6 +29,7 @@ import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; @@ -48,6 +49,9 @@ /** 编辑公司用例共享的远程账号客户端 mock,供 verify 使用 */ private SysUserClient editUserClient; /** 新增公司用例共享的远程账号客户端 mock,供 verify 使用 */ private SysUserClient addUserClient; @BeforeAll static void initTableInfo() { @@ -247,7 +251,8 @@ } /** * 添加/编辑企业弹窗不再配置账户模式:未传账户模式时按共享模式(1)建档 * 添加/编辑企业弹窗不再配置账户模式:未传账户模式时按共享模式(1)建档; * 登录账号由系统自动生成(忽略前端传入值),并随返回体回显 */ @Test void addCompanyDefaultsAccountModeToSharedWhenAbsent() { @@ -257,11 +262,16 @@ company.setName("测试公司"); TGroupCompanyServiceImpl service = buildAddCompanyService(); assertEquals(200, code(service.addCompany(company))); AjaxResult result = service.addCompany(company); assertEquals(200, code(result)); ArgumentCaptor<TGroupCompany> captor = ArgumentCaptor.forClass(TGroupCompany.class); Mockito.verify(service).save(captor.capture()); assertEquals(1, captor.getValue().getAccountMode()); // 账号不可由前端指定:传入值被系统生成值覆盖 assertTrue(captor.getValue().getAccount().matches("admin\\d{4}")); assertTrue(!captor.getValue().getAccount().equals("company01")); assertEquals(captor.getValue().getAccount(), String.valueOf(result.get("data"))); } /** @@ -280,6 +290,70 @@ ArgumentCaptor<TGroupCompany> captor = ArgumentCaptor.forClass(TGroupCompany.class); Mockito.verify(service).save(captor.capture()); assertEquals(2, captor.getValue().getAccountMode()); // 账号仍为系统生成值,而非前端传入值 assertTrue(captor.getValue().getAccount().matches("admin\\d{4}")); assertTrue(!captor.getValue().getAccount().equals("company02")); } /** * 新增公司:登录账号自动生成 admin+4位随机数字,初始密码按登录口径 HMAC-MD5 传输, * 生成账号随返回体回显 */ @Test void addCompanyGeneratesAccountAndDefaultPassword() { TGroupCompany company = new TGroupCompany(); company.setName("测试公司"); TGroupCompanyServiceImpl service = buildAddCompanyService(); AjaxResult result = service.addCompany(company); assertEquals(200, code(result)); assertTrue(company.getAccount().matches("admin\\d{4}")); assertEquals(company.getAccount(), String.valueOf(result.get("data"))); ArgumentCaptor<SysUser> userCaptor = ArgumentCaptor.forClass(SysUser.class); Mockito.verify(addUserClient).addSysUser(userCaptor.capture()); SysUser created = userCaptor.getValue(); assertEquals(company.getAccount(), created.getUserName()); // 初始密码 123456:HMAC-MD5(明文, password),与登录/重置链路口径一致 assertEquals(hmacMd5Expected("123456", "password"), created.getPassword()); } /** * 生成候选账号被 sys_user 占用时换号重试,最终以未占用账号建户 */ @Test void addCompanyRetriesWhenCandidateAccountOccupied() { TGroupCompany company = new TGroupCompany(); company.setName("测试公司"); SysUserClient sysUserClient = Mockito.mock(SysUserClient.class); SysUser occupied = new SysUser(); occupied.setUserId(9L); // 首个候选账号被占用,第二个候选通过 Mockito.when(sysUserClient.queryUserByUserName(Mockito.any())) .thenReturn(R.ok(occupied), R.ok()); Mockito.when(sysUserClient.addSysUser(Mockito.any())).thenReturn(R.ok(100L)); Mockito.when(sysUserClient.getSysUser(Mockito.anyLong())).thenReturn(R.ok()); SysRoleClient sysRoleClient = Mockito.mock(SysRoleClient.class); Mockito.when(sysRoleClient.getRoleIdByRoleKey(Mockito.any())).thenReturn(R.ok(74L)); TGroupCompanyServiceImpl service = Mockito.spy(new TGroupCompanyServiceImpl()); Mockito.doReturn(0L).when(service).count(Mockito.any()); Mockito.doReturn(true).when(service).save(Mockito.any()); ReflectionTestUtils.setField(service, "sysUserClient", sysUserClient); ReflectionTestUtils.setField(service, "sysRoleClient", sysRoleClient); AjaxResult result = service.addCompany(company); assertEquals(200, code(result)); assertEquals(company.getAccount(), String.valueOf(result.get("data"))); // 最终以通过查重的候选账号建户 ArgumentCaptor<SysUser> userCaptor = ArgumentCaptor.forClass(SysUser.class); Mockito.verify(sysUserClient).addSysUser(userCaptor.capture()); assertEquals(company.getAccount(), userCaptor.getValue().getUserName()); assertTrue(userCaptor.getValue().getUserName().matches("admin\\d{4}")); } @Test @@ -554,10 +628,30 @@ Mockito.doReturn(true).when(service).save(Mockito.any()); ReflectionTestUtils.setField(service, "sysUserClient", sysUserClient); ReflectionTestUtils.setField(service, "sysRoleClient", sysRoleClient); this.addUserClient = sysUserClient; return service; } /** * 按登录口径计算 HMAC-MD5(明文, key) 的期望值(小写 Hex),用于断言初始密码传输值 */ private String hmacMd5Expected(String text, String key) { try { javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacMD5"); mac.init(new javax.crypto.spec.SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacMD5")); byte[] bytes = mac.doFinal(text.getBytes(StandardCharsets.UTF_8)); StringBuilder hex = new StringBuilder(bytes.length * 2); for (byte b : bytes) { hex.append(Character.forDigit((b >> 4) & 0xF, 16)); hex.append(Character.forDigit(b & 0xF, 16)); } return hex.toString(); } catch (Exception e) { throw new RuntimeException(e); } } /** * 编辑公司修改登录账号:按旧账号定位用户后把 userName 更新为新账号, * 不误走补建分支(避免双账号残留) */ sql/20261010_balance_warning_record.sql
New file @@ -0,0 +1,31 @@ -- ------------------------------------------ -- 公司余额预警短信提醒记录表(按天去重) -- 创建日期: 2026-10-10 -- 库: charging_pile_other -- 说明: -- 1. 余额预警扫描任务(BalanceWarningScanTask,每5分钟)发送短信成功后写入本表; -- 同一公司同一预警范围同一子账户同一天仅提醒一次(uk_company_scope_sub_date 唯一键) -- 2. warn_scope: 1=公司账户, 2=VIN码, 3=刷卡卡, 4=白名单用户 -- sub_identifier: 公司级固定空串;子账户级为 VIN码/卡号/白名单手机号 -- 3. Mycat 2.0 不支持 SET @变量/条件化 DDL,故直接建表; -- 重复执行时 CREATE TABLE IF NOT EXISTS 幂等,不报错 -- 库: charging_pile_other(单表,经 8066 代理执行即可) -- ------------------------------------------ USE `charging_pile_other`; CREATE TABLE IF NOT EXISTS `t_balance_warning_record` ( `id` int NOT NULL AUTO_INCREMENT COMMENT '主键', `company_id` int NOT NULL COMMENT '公司ID(关联t_group_company.id)', `warn_scope` tinyint NOT NULL COMMENT '预警范围(1=公司账户,2=VIN码,3=刷卡卡,4=白名单用户)', `sub_identifier` varchar(64) NOT NULL DEFAULT '' COMMENT '子账户标识(公司级为空串,子账户级为VIN码/卡号/白名单手机号)', `notify_phone` varchar(32) NOT NULL COMMENT '通知手机号', `warn_date` date NOT NULL COMMENT '提醒日期(按天去重依据)', `create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间', `del_flag` int NULL DEFAULT 0 COMMENT '删除(0=否,1=是)', PRIMARY KEY (`id`), UNIQUE KEY `uk_company_scope_sub_date` (`company_id`, `warn_scope`, `sub_identifier`, `warn_date`) USING BTREE, KEY `idx_warn_date` (`warn_date`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='公司余额预警短信提醒记录(按天去重)'; -- 回查(应返回表结构 8 列 + 唯一键 uk_company_scope_sub_date) -- SHOW CREATE TABLE `t_balance_warning_record`;