puzhibing
4 天以前 a0e03b646f2c444b0f09687d24e9995112b99017
修改bug
9个文件已添加
6个文件已修改
1156 ■■■■■ 已修改文件
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/SendMessageDTO.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-other/src/main/java/com/ruoyi/other/api/domain/TBalanceWarningRecord.java 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-common/ruoyi-common-core/src/main/java/com/ruoyi/common/core/constant/MsgConstants.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-common/ruoyi-common-core/src/main/java/com/ruoyi/common/core/utils/MsgUtil.java 87 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TAppUserController.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/mapper/TBalanceWarningRecordMapper.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/TBalanceWarningRecordService.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/TBalanceWarningScanService.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TBalanceWarningRecordServiceImpl.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TBalanceWarningScanServiceImpl.java 338 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImpl.java 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/task/BalanceWarningScanTask.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-other/src/test/java/com/ruoyi/other/service/impl/TBalanceWarningScanServiceImplTest.java 312 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-other/src/test/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImplTest.java 98 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/20261010_balance_warning_record.sql 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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`;