| | |
| | | @ApiModelProperty(value = "合作商ID") |
| | | private Integer partnerId; |
| | | |
| | | @ApiModelProperty(value = "所属公司ID(关联t_group_company.id)") |
| | | @ApiModelProperty(value = "所属公司ID(关联t_group_company.id),必填") |
| | | private Integer companyId; |
| | | |
| | | @ApiModelProperty(value = "所属车队ID(关联t_fleet.id),必须归属所选公司") |
| | | @ApiModelProperty(value = "所属车队ID(关联t_fleet.id),必填,必须归属所选公司") |
| | | private Integer fleetId; |
| | | |
| | | @ApiModelProperty(value = "余额(元),分配模式公司设置后从公司可用余额扣减") |
| | |
| | | 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 |
| | | |
| | | } |
| | |
| | | package com.ruoyi.account.api.factory; |
| | | |
| | | import com.ruoyi.account.api.feignClient.CompanyAllocateRecordClient; |
| | | import com.ruoyi.account.api.vo.CompanyAllocateSummaryVO; |
| | | import com.ruoyi.common.core.domain.R; |
| | | import org.slf4j.Logger; |
| | | import org.slf4j.LoggerFactory; |
| | |
| | | public R<List<String>> listConsumedOrderCodes(List<String> orderCodes) { |
| | | return R.fail("查询消费流水订单号失败:" + throwable.getMessage()); |
| | | } |
| | | |
| | | @Override |
| | | public R<CompanyAllocateSummaryVO> sumAllocatedBalance(Integer companyId) { |
| | | return R.fail("统计公司分配余额失败:" + throwable.getMessage()); |
| | | } |
| | | |
| | | @Override |
| | | public R<BigDecimal> recycleAllocatedBalance(Integer companyId, String operatorName, String companyName, |
| | | BigDecimal companyBalanceBefore) { |
| | | return R.fail("回收公司分配余额失败:" + throwable.getMessage()); |
| | | } |
| | | }; |
| | | } |
| | | } |
| | |
| | | package com.ruoyi.account.api.feignClient; |
| | | |
| | | import com.ruoyi.account.api.factory.CompanyAllocateRecordFallbackFactory; |
| | | import com.ruoyi.account.api.vo.CompanyAllocateSummaryVO; |
| | | import com.ruoyi.common.core.constant.ServiceNameConstants; |
| | | import com.ruoyi.common.core.domain.R; |
| | | import org.springframework.cloud.openfeign.FeignClient; |
| | |
| | | */ |
| | | @PostMapping("/company-allocate-record/inner/list-consumed-order-codes") |
| | | R<List<String>> listConsumedOrderCodes(@RequestBody List<String> orderCodes); |
| | | } |
| | | |
| | | /** |
| | | * 统计公司名下三类主体(VIN码/刷卡/白名单手机号)可用余额与充电锁定金额合计(只读, |
| | | * 分配模式切换为共享模式前的校验使用)。 |
| | | * |
| | | * @param companyId 所属公司ID |
| | | */ |
| | | @PostMapping("/company-allocate-record/inner/sum-allocated-balance") |
| | | R<CompanyAllocateSummaryVO> sumAllocatedBalance(@RequestParam("companyId") Integer companyId); |
| | | |
| | | /** |
| | | * 回收公司名下三类主体全部分配余额至公司账户(分配模式切换为共享模式时调用)。 |
| | | * 幂等:余额已清零的主体不重复回收、不重复写流水;返回本次实际回收总额。 |
| | | * |
| | | * @param companyId 所属公司ID |
| | | * @param operatorName 操作人账号 |
| | | * @param companyName 公司名称(企业账户流水 userInfo 使用,可为空) |
| | | * @param companyBalanceBefore 回退前公司可用余额(企业账户正数流水 beforeBalance 使用) |
| | | * @return 本次实际回收总额(元,无余额时返回 0) |
| | | */ |
| | | @PostMapping("/company-allocate-record/inner/recycle-allocated-balance") |
| | | R<BigDecimal> recycleAllocatedBalance(@RequestParam("companyId") Integer companyId, |
| | | @RequestParam("operatorName") String operatorName, |
| | | @RequestParam(value = "companyName", required = false) String companyName, |
| | | @RequestParam("companyBalanceBefore") BigDecimal companyBalanceBefore); |
| | | } |
| | |
| | | public static final int SOURCE_EDIT = 2; |
| | | /** 变更来源:额度分配(额度分配弹窗) */ |
| | | public static final int SOURCE_ALLOCATE = 3; |
| | | /** 变更来源:账户模式切换(分配模式切换为共享模式,回收用户余额至公司账户) */ |
| | | public static final int SOURCE_MODE_SWITCH = 4; |
| | | |
| | | /** 变更类型:分配(历史数据默认值,金额由公司划拨到用户账户) */ |
| | | public static final int CHANGE_TYPE_ALLOCATE = 1; |
| New file |
| | |
| | | package com.ruoyi.account.api.vo; |
| | | |
| | | import io.swagger.annotations.ApiModel; |
| | | import io.swagger.annotations.ApiModelProperty; |
| | | import lombok.Data; |
| | | |
| | | import java.io.Serializable; |
| | | import java.math.BigDecimal; |
| | | |
| | | /** |
| | | * 公司名下分配余额汇总(VIN码/刷卡卡/白名单手机号三类主体余额与锁定金额合计), |
| | | * 供「分配模式切换为共享模式」时校验可回退金额与在途锁定。 |
| | | */ |
| | | @Data |
| | | @ApiModel(value = "公司分配余额汇总") |
| | | public class CompanyAllocateSummaryVO implements Serializable { |
| | | |
| | | private static final long serialVersionUID = 1L; |
| | | |
| | | @ApiModelProperty(value = "所属公司ID") |
| | | private Integer companyId; |
| | | |
| | | @ApiModelProperty(value = "三类主体可用余额合计(元)") |
| | | private BigDecimal totalBalance; |
| | | |
| | | @ApiModelProperty(value = "三类主体充电锁定金额合计(元)") |
| | | private BigDecimal totalLocked; |
| | | |
| | | @ApiModelProperty(value = "余额大于0的主体数量") |
| | | private Integer balanceUserCount; |
| | | } |
| | |
| | | public R<DeviceStartChargingVO> deviceStartCharging(DeviceStartChargingDTO dto) { |
| | | return R.fail("设备主动发起充电鉴权建单失败:" + throwable.getMessage()); |
| | | } |
| | | |
| | | @Override |
| | | public R<Integer> countActiveByCompany(Integer companyId) { |
| | | return R.fail("统计公司进行中充电订单失败:" + throwable.getMessage()); |
| | | } |
| | | }; |
| | | } |
| | | } |
| | |
| | | */ |
| | | @PostMapping("/t-charging-order/inner/device-start-charging") |
| | | R<DeviceStartChargingVO> deviceStartCharging(@RequestBody DeviceStartChargingDTO dto); |
| | | |
| | | /** |
| | | * 统计公司名下进行中的充电订单数(三表合计,状态 2=启动中/3=充电中/4=停止中, |
| | | * 账户模式切换:分配→共享 前校验使用) |
| | | * |
| | | * @param companyId 所属公司ID |
| | | * @return 进行中订单数 |
| | | */ |
| | | @PostMapping("/t-charging-order/inner/count-active-by-company") |
| | | R<Integer> countActiveByCompany(@RequestParam("companyId") Integer companyId); |
| | | } |
| New file |
| | |
| | | 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; |
| | | |
| | | } |
| | |
| | | |
| | | /** 桩故障(自动/手动) 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 */ |
| | |
| | | /** 桩故障(自动/手动) AP_ID */ |
| | | public static final String FAULT_AP_ID = "GZ123"; |
| | | |
| | | /** 余额预警(公司总账户/子账号) AP_ID(与充电结束共用) */ |
| | | public static final String BALANCE_WARN_AP_ID = "CD123"; |
| | | |
| | | } |
| | |
| | | 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 |
| | | */ |
| | |
| | | */ |
| | | private Mono<Void> authSign(ServerWebExchange exchange, GatewayFilterChain chain, String sign, String nonce_str){ |
| | | return DataBufferUtils.join(exchange.getRequest().getBody()) |
| | | .defaultIfEmpty(exchange.getResponse().bufferFactory().wrap(new byte[0])) |
| | | .flatMap(dataBuffer -> { |
| | | byte[] bytes = new byte[dataBuffer.readableByteCount()]; |
| | | dataBuffer.read(bytes); |
| | | String bodyString = new String(bytes, StandardCharsets.UTF_8); |
| | | log.info("请求参数:{}", bodyString); |
| | | if(!authSign(JSON.parseObject(bodyString), sign, nonce_str)){ |
| | | JSONObject bodyJson = StringUtils.isEmpty(bodyString) ? new JSONObject() : JSON.parseObject(bodyString); |
| | | if(!authSign(bodyJson, sign, nonce_str)){ |
| | | return unauthorizedResponse(exchange, "签名验证失败!"); |
| | | } |
| | | DataBufferUtils.release(dataBuffer); |
| | |
| | | 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); |
| | |
| | | import com.baomidou.mybatisplus.core.metadata.IPage; |
| | | import com.ruoyi.account.api.dto.CompanyAllocateRecordQueryDTO; |
| | | import com.ruoyi.account.api.model.TCompanyAllocateRecord; |
| | | import com.ruoyi.account.api.vo.CompanyAllocateSummaryVO; |
| | | import com.ruoyi.account.service.TCompanyAllocateRecordService; |
| | | import com.ruoyi.common.core.domain.R; |
| | | import com.ruoyi.common.core.web.controller.BaseController; |
| | |
| | | return R.fail("查询消费流水订单号失败:" + ex.getMessage()); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 内部接口:统计公司名下三类主体可用余额与充电锁定金额合计(账户模式切换前校验使用,只读) |
| | | */ |
| | | @ApiOperation(value = "内部-统计公司分配余额", tags = {"内部调用"}) |
| | | @PostMapping("/inner/sum-allocated-balance") |
| | | public R<CompanyAllocateSummaryVO> sumAllocatedBalance(@RequestParam("companyId") Integer companyId) { |
| | | try { |
| | | return R.ok(companyAllocateRecordService.sumAllocatedBalance(companyId)); |
| | | } catch (RuntimeException ex) { |
| | | logger.error("统计公司分配余额失败:companyId={}", companyId, ex); |
| | | return R.fail("统计公司分配余额失败:" + ex.getMessage()); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 内部接口:回收公司名下三类主体全部分配余额(账户模式切换:分配→共享,幂等) |
| | | */ |
| | | @ApiOperation(value = "内部-回收公司分配余额", tags = {"内部调用"}) |
| | | @PostMapping("/inner/recycle-allocated-balance") |
| | | public R<BigDecimal> recycleAllocatedBalance(@RequestParam("companyId") Integer companyId, |
| | | @RequestParam("operatorName") String operatorName, |
| | | @RequestParam(value = "companyName", required = false) String companyName, |
| | | @RequestParam("companyBalanceBefore") BigDecimal companyBalanceBefore) { |
| | | try { |
| | | return R.ok(companyAllocateRecordService.recycleAllocatedBalance(companyId, operatorName, companyName, |
| | | companyBalanceBefore)); |
| | | } catch (RuntimeException ex) { |
| | | logger.error("回收公司分配余额失败:companyId={}", companyId, ex); |
| | | return R.fail("回收公司分配余额失败:" + ex.getMessage()); |
| | | } |
| | | } |
| | | } |
| | |
| | | * @return 更新行数 |
| | | */ |
| | | int addBalance(@Param("id") Long id, @Param("amount") BigDecimal amount, @Param("updateBy") String updateBy); |
| | | |
| | | /** |
| | | * 原子清零余额(账户模式切换回收专用:仅当余额>0时清零,返回受影响行数,天然幂等) |
| | | */ |
| | | int clearBalance(@Param("id") Long id, @Param("updateBy") String updateBy); |
| | | } |
| | |
| | | * 批量查询已存在的充电扣款流水订单号。 |
| | | */ |
| | | List<String> selectDeductedOrderCodes(@Param("orderCodes") List<String> orderCodes); |
| | | |
| | | /** |
| | | * 原子清零余额(账户模式切换回收专用:仅当余额>0时清零,返回受影响行数,天然幂等) |
| | | */ |
| | | int clearBalance(@Param("id") Long id, @Param("updateBy") Long updateBy); |
| | | } |
| | |
| | | int countByVin(@Param("vin") String vin, @Param("excludeId") Long excludeId); |
| | | /** 余额原子累加(额度分配专用,null 视为 0) */ |
| | | int addBalance(@Param("id") Long id, @Param("amount") BigDecimal amount, @Param("updateBy") Long updateBy); |
| | | /** 原子清零余额(账户模式切换回收专用:仅当余额>0时清零,返回受影响行数,天然幂等) */ |
| | | int clearBalance(@Param("id") Long id, @Param("updateBy") Long updateBy); |
| | | List<TVinManagement> selectAppletVehicleList(@Param("appUserId") Long appUserId); |
| | | TVinManagement selectEnabledVehicle(@Param("keyword") String keyword); |
| | | int bindAppletVehicle(@Param("id") Long id, @Param("appUserId") Long appUserId); |
| | |
| | | import com.baomidou.mybatisplus.extension.service.IService; |
| | | import com.ruoyi.account.api.dto.CompanyAllocateRecordQueryDTO; |
| | | import com.ruoyi.account.api.model.TCompanyAllocateRecord; |
| | | import com.ruoyi.account.api.vo.CompanyAllocateSummaryVO; |
| | | |
| | | import java.math.BigDecimal; |
| | | |
| | |
| | | * @return 已存在消费流水的订单号集合,无匹配返回空集合 |
| | | */ |
| | | java.util.List<String> listConsumedOrderCodes(java.util.List<String> orderCodes); |
| | | |
| | | /** |
| | | * 统计公司名下三类主体(VIN码/刷卡/白名单手机号)可用余额与充电锁定金额合计(只读, |
| | | * 账户模式从分配切换为共享前的校验与预加回使用)。 |
| | | * |
| | | * @param companyId 所属公司ID |
| | | */ |
| | | CompanyAllocateSummaryVO sumAllocatedBalance(Integer companyId); |
| | | |
| | | /** |
| | | * 回收公司名下三类主体全部分配余额至公司账户(账户模式:分配→共享)。 |
| | | * <p>内部按固定顺序抢占 VIN/卡/白名单三把公司级并发锁,与额度分配操作串行; |
| | | * 余额清零与流水写入同事务,幂等(重复调用时无余额可回收,返回 0,不重复写流水)。</p> |
| | | * |
| | | * @param companyId 所属公司ID |
| | | * @param operatorName 操作人账号 |
| | | * @param companyName 公司名称(企业账户流水 userInfo,可为空) |
| | | * @param companyBalanceBefore 回退前公司可用余额(企业账户正数流水 beforeBalance) |
| | | * @return 本次实际回收总额(元,无余额时返回 0) |
| | | */ |
| | | BigDecimal recycleAllocatedBalance(Integer companyId, String operatorName, String companyName, |
| | | BigDecimal companyBalanceBefore); |
| | | } |
| | |
| | | if (Objects.equals(scopeType, SCOPE_PARTNER) && saveDTO.getPartnerId() == null) { |
| | | throw new ServiceException("请选择加盟商"); |
| | | } |
| | | // 所属公司与所属车队必填:白名单用户归属企业,充电完全按企业优惠结算 |
| | | if (saveDTO.getCompanyId() == null) { |
| | | throw new ServiceException("请选择所属公司"); |
| | | } |
| | | if (saveDTO.getFleetId() == null) { |
| | | throw new ServiceException("请选择所属车队"); |
| | | } |
| | | validatePartnerDataScope(saveDTO); |
| | | TAppUserWhitelist exists = getByPhone(saveDTO.getPhone()); |
| | | if (saveDTO.getId() == null && exists != null) { |
| | |
| | | import com.baomidou.mybatisplus.extension.plugins.pagination.Page; |
| | | import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; |
| | | import com.ruoyi.account.api.dto.CompanyAllocateRecordQueryDTO; |
| | | import com.ruoyi.account.api.model.TAppUserWhitelist; |
| | | import com.ruoyi.account.api.model.TChargingCard; |
| | | import com.ruoyi.account.api.model.TCompanyAllocateRecord; |
| | | import com.ruoyi.account.api.model.TVinManagement; |
| | | import com.ruoyi.account.api.vo.CompanyAllocateSummaryVO; |
| | | import com.ruoyi.account.mapper.TAppUserWhitelistMapper; |
| | | import com.ruoyi.account.mapper.TChargingCardMapper; |
| | | import com.ruoyi.account.mapper.TCompanyAllocateRecordMapper; |
| | | import com.ruoyi.account.mapper.TVinManagementMapper; |
| | | import com.ruoyi.account.service.TCompanyAllocateRecordService; |
| | | import com.ruoyi.common.core.exception.ServiceException; |
| | | import com.ruoyi.common.core.utils.StringUtils; |
| | | import com.ruoyi.common.security.utils.SecurityUtils; |
| | | import org.redisson.api.RLock; |
| | | import org.redisson.api.RedissonClient; |
| | | import org.slf4j.Logger; |
| | | import org.slf4j.LoggerFactory; |
| | | import org.springframework.stereotype.Service; |
| | | import org.springframework.transaction.annotation.Transactional; |
| | | |
| | | import javax.annotation.Resource; |
| | | import java.math.BigDecimal; |
| | | import java.math.RoundingMode; |
| | | import java.time.LocalDateTime; |
| | | import java.util.List; |
| | | import java.util.concurrent.TimeUnit; |
| | | |
| | | /** |
| | | * 公司额度分配记录服务实现:对外提供分页查询与统一流水写入。 |
| | |
| | | private static final int MAX_REMARK_LENGTH = 255; |
| | | /** charging_order_code 列长度(varchar(64)) */ |
| | | private static final int MAX_ORDER_CODE_LENGTH = 64; |
| | | |
| | | /** 模式切换回收锁:与额度分配同前缀、按公司串行,防止切换与分配并发导致资金不一致 */ |
| | | private static final String RECYCLE_LOCK_PREFIX_VIN = "VIN_QUOTA:COMPANY:"; |
| | | private static final String RECYCLE_LOCK_PREFIX_CARD = "CHARGING_CARD_QUOTA:COMPANY:"; |
| | | private static final String RECYCLE_LOCK_PREFIX_WHITELIST = "APP_USER_WHITELIST_QUOTA:COMPANY:"; |
| | | /** 获取锁最长等待时间(秒) */ |
| | | private static final long LOCK_WAIT_SECONDS = 5L; |
| | | /** 锁自动释放时间(秒),兜住异常未解锁的场景 */ |
| | | private static final long LOCK_LEASE_SECONDS = 30L; |
| | | |
| | | @Resource |
| | | private RedissonClient redissonClient; |
| | | |
| | | @Resource |
| | | private TVinManagementMapper vinManagementMapper; |
| | | |
| | | @Resource |
| | | private TChargingCardMapper chargingCardMapper; |
| | | |
| | | @Resource |
| | | private TAppUserWhitelistMapper appUserWhitelistMapper; |
| | | |
| | | @Override |
| | | public IPage<TCompanyAllocateRecord> selectRecordPage(CompanyAllocateRecordQueryDTO query) { |
| | |
| | | return consumed; |
| | | } |
| | | |
| | | @Override |
| | | public CompanyAllocateSummaryVO sumAllocatedBalance(Integer companyId) { |
| | | CompanyAllocateSummaryVO summary = new CompanyAllocateSummaryVO(); |
| | | summary.setCompanyId(companyId); |
| | | BigDecimal totalBalance = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); |
| | | BigDecimal totalLocked = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); |
| | | int balanceUserCount = 0; |
| | | if (companyId == null) { |
| | | return summary; |
| | | } |
| | | // VIN码(delFlag 逻辑删除由 @TableLogic 自动过滤) |
| | | List<TVinManagement> vins = vinManagementMapper.selectList( |
| | | new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TVinManagement>() |
| | | .eq(TVinManagement::getCompanyId, companyId) |
| | | .gt(TVinManagement::getBalance, BigDecimal.ZERO)); |
| | | if (vins != null) { |
| | | for (TVinManagement vin : vins) { |
| | | totalBalance = totalBalance.add(normalizeMoney(vin.getBalance())); |
| | | totalLocked = totalLocked.add(normalizeMoney(vin.getLockedAmount())); |
| | | balanceUserCount++; |
| | | } |
| | | } |
| | | // 刷卡卡 |
| | | List<TChargingCard> cards = chargingCardMapper.selectList( |
| | | new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TChargingCard>() |
| | | .eq(TChargingCard::getCompanyId, companyId) |
| | | .gt(TChargingCard::getBalance, BigDecimal.ZERO)); |
| | | if (cards != null) { |
| | | for (TChargingCard card : cards) { |
| | | totalBalance = totalBalance.add(normalizeMoney(card.getBalance())); |
| | | totalLocked = totalLocked.add(normalizeMoney(card.getLockedAmount())); |
| | | balanceUserCount++; |
| | | } |
| | | } |
| | | // 白名单手机号 |
| | | List<TAppUserWhitelist> whitelists = appUserWhitelistMapper.selectList( |
| | | new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TAppUserWhitelist>() |
| | | .eq(TAppUserWhitelist::getCompanyId, companyId) |
| | | .gt(TAppUserWhitelist::getBalance, BigDecimal.ZERO)); |
| | | if (whitelists != null) { |
| | | for (TAppUserWhitelist whitelist : whitelists) { |
| | | totalBalance = totalBalance.add(normalizeMoney(whitelist.getBalance())); |
| | | totalLocked = totalLocked.add(normalizeMoney(whitelist.getLockedAmount())); |
| | | balanceUserCount++; |
| | | } |
| | | } |
| | | summary.setTotalBalance(totalBalance); |
| | | summary.setTotalLocked(totalLocked); |
| | | summary.setBalanceUserCount(balanceUserCount); |
| | | return summary; |
| | | } |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public BigDecimal recycleAllocatedBalance(Integer companyId, String operatorName, String companyName, |
| | | BigDecimal companyBalanceBefore) { |
| | | if (companyId == null) { |
| | | throw new ServiceException("公司ID不能为空"); |
| | | } |
| | | String operator = truncate(operatorName, MAX_USER_TEXT_LENGTH); |
| | | // 按固定顺序抢占三把公司级锁(与三类额度分配操作互斥,顺序固定避免死锁) |
| | | RLock vinLock = redissonClient.getLock(RECYCLE_LOCK_PREFIX_VIN + companyId); |
| | | RLock cardLock = redissonClient.getLock(RECYCLE_LOCK_PREFIX_CARD + companyId); |
| | | RLock whitelistLock = redissonClient.getLock(RECYCLE_LOCK_PREFIX_WHITELIST + companyId); |
| | | boolean vinLocked = tryRecycleLock(vinLock); |
| | | boolean cardLocked = false; |
| | | boolean whitelistLocked = false; |
| | | try { |
| | | cardLocked = tryRecycleLock(cardLock); |
| | | try { |
| | | whitelistLocked = tryRecycleLock(whitelistLock); |
| | | return doRecycleAllocatedBalance(companyId, operator, companyName, companyBalanceBefore); |
| | | } finally { |
| | | unlockRecycle(whitelistLock, whitelistLocked); |
| | | } |
| | | } finally { |
| | | unlockRecycle(cardLock, cardLocked); |
| | | unlockRecycle(vinLock, vinLocked); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 锁内回收实现:清零三类主体余额并写流水(与余额清零同事务,异常整体回滚),返回实际回收总额。 |
| | | */ |
| | | private BigDecimal doRecycleAllocatedBalance(Integer companyId, String operator, String companyName, |
| | | BigDecimal companyBalanceBefore) { |
| | | Long operatorId = currentUserId(); |
| | | BigDecimal total = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); |
| | | String remark = "账户模式切换为共享模式,回收分配余额至公司账户"; |
| | | // VIN码 |
| | | List<TVinManagement> vins = vinManagementMapper.selectList( |
| | | new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TVinManagement>() |
| | | .eq(TVinManagement::getCompanyId, companyId) |
| | | .gt(TVinManagement::getBalance, BigDecimal.ZERO)); |
| | | if (vins != null) { |
| | | for (TVinManagement vin : vins) { |
| | | BigDecimal before = normalizeMoney(vin.getBalance()); |
| | | if (vinManagementMapper.clearBalance(vin.getId(), operatorId) == 1) { |
| | | total = total.add(before); |
| | | recordModeSwitchRecord(companyId, TCompanyAllocateRecord.USER_TYPE_VIN, vin.getVin(), |
| | | vin.getPlateNumber(), operator, operatorId, before, before.negate(), remark); |
| | | } |
| | | } |
| | | } |
| | | // 刷卡卡 |
| | | List<TChargingCard> cards = chargingCardMapper.selectList( |
| | | new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TChargingCard>() |
| | | .eq(TChargingCard::getCompanyId, companyId) |
| | | .gt(TChargingCard::getBalance, BigDecimal.ZERO)); |
| | | if (cards != null) { |
| | | for (TChargingCard card : cards) { |
| | | BigDecimal before = normalizeMoney(card.getBalance()); |
| | | if (chargingCardMapper.clearBalance(card.getId(), operatorId) == 1) { |
| | | total = total.add(before); |
| | | recordModeSwitchRecord(companyId, TCompanyAllocateRecord.USER_TYPE_CARD, card.getCardNo(), |
| | | null, operator, operatorId, before, before.negate(), remark); |
| | | } |
| | | } |
| | | } |
| | | // 白名单手机号 |
| | | List<TAppUserWhitelist> whitelists = appUserWhitelistMapper.selectList( |
| | | new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TAppUserWhitelist>() |
| | | .eq(TAppUserWhitelist::getCompanyId, companyId) |
| | | .gt(TAppUserWhitelist::getBalance, BigDecimal.ZERO)); |
| | | if (whitelists != null) { |
| | | for (TAppUserWhitelist whitelist : whitelists) { |
| | | BigDecimal before = normalizeMoney(whitelist.getBalance()); |
| | | if (appUserWhitelistMapper.clearBalance(whitelist.getId(), operator) == 1) { |
| | | total = total.add(before); |
| | | recordModeSwitchRecord(companyId, TCompanyAllocateRecord.USER_TYPE_WHITELIST, |
| | | whitelist.getPhone(), whitelist.getName(), operator, operatorId, before, |
| | | before.negate(), remark); |
| | | } |
| | | } |
| | | } |
| | | // 企业账户正数流水:公司余额加回留痕(与用户负流水同事务) |
| | | if (total.signum() > 0) { |
| | | String companyInfo = StringUtils.isNotBlank(companyName) ? companyName : "企业账户"; |
| | | recordModeSwitchRecord(companyId, TCompanyAllocateRecord.USER_TYPE_COMPANY, companyInfo, null, |
| | | operator, operatorId, normalizeMoney(companyBalanceBefore), total, remark); |
| | | } |
| | | return total; |
| | | } |
| | | |
| | | /** 写入一条模式切换回退流水(change_source=4),写入失败抛出异常由事务回滚保证与余额清零一致 */ |
| | | private void recordModeSwitchRecord(Integer companyId, int userType, String userInfo, String userName, |
| | | String operator, Long operatorId, BigDecimal beforeBalance, |
| | | BigDecimal amount, String remark) { |
| | | BigDecimal before = normalizeMoney(beforeBalance); |
| | | BigDecimal change = normalizeMoney(amount); |
| | | TCompanyAllocateRecord record = new TCompanyAllocateRecord(); |
| | | record.setCompanyId(companyId); |
| | | record.setUserType(userType); |
| | | record.setUserInfo(truncate(userInfo, MAX_USER_TEXT_LENGTH)); |
| | | record.setUserName(truncate(userName, MAX_USER_TEXT_LENGTH)); |
| | | record.setChangeSource(TCompanyAllocateRecord.SOURCE_MODE_SWITCH); |
| | | record.setChangeType(TCompanyAllocateRecord.CHANGE_TYPE_ALLOCATE); |
| | | record.setBeforeBalance(before); |
| | | record.setAmount(change); |
| | | record.setAfterBalance(before.add(change)); |
| | | record.setOperatorId(operatorId); |
| | | record.setOperatorName(operator); |
| | | record.setOperateTime(LocalDateTime.now()); |
| | | record.setRemark(truncate(remark, MAX_REMARK_LENGTH)); |
| | | record.setCreateTime(record.getOperateTime()); |
| | | if (!save(record)) { |
| | | throw new ServiceException("模式切换回退流水写入失败"); |
| | | } |
| | | } |
| | | |
| | | /** 抢占回收并发锁,抢占失败抛出业务异常 */ |
| | | private boolean tryRecycleLock(RLock lock) { |
| | | try { |
| | | if (!lock.tryLock(LOCK_WAIT_SECONDS, LOCK_LEASE_SECONDS, TimeUnit.SECONDS)) { |
| | | throw new ServiceException("公司额度变更操作正在处理中,请稍后重试"); |
| | | } |
| | | return true; |
| | | } catch (InterruptedException ex) { |
| | | Thread.currentThread().interrupt(); |
| | | throw new ServiceException("公司额度变更操作正在处理中,请稍后重试"); |
| | | } |
| | | } |
| | | |
| | | /** 释放回收并发锁(仅释放当前线程持有的锁,异常仅记录日志) */ |
| | | private void unlockRecycle(RLock lock, boolean locked) { |
| | | if (!locked) { |
| | | return; |
| | | } |
| | | try { |
| | | if (lock.isHeldByCurrentThread()) { |
| | | lock.unlock(); |
| | | } |
| | | } catch (Exception ex) { |
| | | log.warn("模式切换回收锁释放失败,lockName={}", lock.getName(), ex); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 消费流水写入公共实现 |
| | | * |
| | |
| | | set balance = IFNULL(balance, 0) + #{amount}, update_time = now(), update_by = #{updateBy} |
| | | where id = #{id} and del_flag = 0 |
| | | </update> |
| | | <update id="clearBalance"> |
| | | update t_app_user_whitelist |
| | | set balance = 0, update_time = now(), update_by = #{updateBy} |
| | | where id = #{id} and del_flag = 0 and balance > 0 |
| | | </update> |
| | | </mapper> |
| | |
| | | </foreach> |
| | | </select> |
| | | |
| | | <!-- 原子清零余额:账户模式切换回收专用,仅当余额>0时清零,天然幂等 --> |
| | | <update id="clearBalance"> |
| | | UPDATE t_charging_card |
| | | SET balance = 0, |
| | | update_time = NOW(), |
| | | update_by = #{updateBy} |
| | | WHERE id = #{id} |
| | | AND del_flag = 0 |
| | | AND balance > 0 |
| | | </update> |
| | | |
| | | </mapper> |
| | |
| | | SET balance = IFNULL(balance, 0) + #{amount}, update_time = NOW(), update_by = #{updateBy} |
| | | WHERE id = #{id} AND del_flag = 0 |
| | | </update> |
| | | <update id="clearBalance"> |
| | | UPDATE t_vin_management |
| | | SET balance = 0, update_time = NOW(), update_by = #{updateBy} |
| | | WHERE id = #{id} AND del_flag = 0 AND balance > 0 |
| | | </update> |
| | | </mapper> |
| | |
| | | import com.baomidou.mybatisplus.core.metadata.IPage; |
| | | import com.baomidou.mybatisplus.extension.plugins.pagination.Page; |
| | | import com.ruoyi.account.api.dto.CompanyAllocateRecordQueryDTO; |
| | | import com.ruoyi.account.api.model.TAppUserWhitelist; |
| | | import com.ruoyi.account.api.model.TChargingCard; |
| | | import com.ruoyi.account.api.model.TCompanyAllocateRecord; |
| | | import com.ruoyi.account.api.model.TVinManagement; |
| | | import com.ruoyi.account.api.vo.CompanyAllocateSummaryVO; |
| | | import com.ruoyi.account.mapper.TAppUserWhitelistMapper; |
| | | import com.ruoyi.account.mapper.TChargingCardMapper; |
| | | import com.ruoyi.account.mapper.TCompanyAllocateRecordMapper; |
| | | import com.ruoyi.account.mapper.TVinManagementMapper; |
| | | import com.ruoyi.common.core.context.SecurityContextHolder; |
| | | import com.ruoyi.common.core.exception.ServiceException; |
| | | import org.junit.jupiter.api.AfterEach; |
| | | import org.junit.jupiter.api.BeforeEach; |
| | | import org.junit.jupiter.api.Test; |
| | |
| | | import org.mockito.ArgumentCaptor; |
| | | import org.mockito.Mock; |
| | | import org.mockito.junit.jupiter.MockitoExtension; |
| | | import org.redisson.api.RLock; |
| | | import org.redisson.api.RedissonClient; |
| | | import org.springframework.test.util.ReflectionTestUtils; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.util.Arrays; |
| | | import java.util.Collections; |
| | | import java.util.List; |
| | | import java.util.concurrent.TimeUnit; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | import static org.junit.jupiter.api.Assertions.assertFalse; |
| | | import static org.junit.jupiter.api.Assertions.assertNotNull; |
| | | import static org.junit.jupiter.api.Assertions.assertNull; |
| | | import static org.junit.jupiter.api.Assertions.assertSame; |
| | | import static org.junit.jupiter.api.Assertions.assertThrows; |
| | | import static org.junit.jupiter.api.Assertions.assertTrue; |
| | | import static org.mockito.ArgumentMatchers.any; |
| | | import static org.mockito.ArgumentMatchers.anyString; |
| | | import static org.mockito.ArgumentMatchers.eq; |
| | | import static org.mockito.Mockito.lenient; |
| | | import static org.mockito.Mockito.never; |
| | | import static org.mockito.Mockito.spy; |
| | |
| | | class TCompanyAllocateRecordServiceImplTest { |
| | | |
| | | @Mock private TCompanyAllocateRecordMapper mapper; |
| | | @Mock private TVinManagementMapper vinMapper; |
| | | @Mock private TChargingCardMapper cardMapper; |
| | | @Mock private TAppUserWhitelistMapper whitelistMapper; |
| | | @Mock private RedissonClient redissonClient; |
| | | @Mock private RLock lock; |
| | | private TCompanyAllocateRecordServiceImpl service; |
| | | |
| | | @BeforeEach |
| | | void setUp() { |
| | | service = spy(new TCompanyAllocateRecordServiceImpl()); |
| | | ReflectionTestUtils.setField(service, "baseMapper", mapper); |
| | | ReflectionTestUtils.setField(service, "vinManagementMapper", vinMapper); |
| | | ReflectionTestUtils.setField(service, "chargingCardMapper", cardMapper); |
| | | ReflectionTestUtils.setField(service, "appUserWhitelistMapper", whitelistMapper); |
| | | ReflectionTestUtils.setField(service, "redissonClient", redissonClient); |
| | | lenient().when(mapper.insert(any(TCompanyAllocateRecord.class))).thenReturn(1); |
| | | lenient().when(redissonClient.getLock(anyString())).thenReturn(lock); |
| | | SecurityContextHolder.setUserId("123"); |
| | | SecurityContextHolder.setUserName("admin"); |
| | | } |
| | |
| | | return captor.getValue(); |
| | | } |
| | | |
| | | // ============ 账户模式切换回收相关 ============ |
| | | |
| | | @Test |
| | | void sumAllocatedBalanceAggregatesThreeTypes() { |
| | | TVinManagement vin = new TVinManagement(); |
| | | vin.setId(1L); |
| | | vin.setBalance(new BigDecimal("10.50")); |
| | | vin.setLockedAmount(new BigDecimal("2.00")); |
| | | TVinManagement vin2 = new TVinManagement(); |
| | | vin2.setId(2L); |
| | | vin2.setBalance(new BigDecimal("5.25")); |
| | | vin2.setLockedAmount(BigDecimal.ZERO); |
| | | when(vinMapper.selectList(any())).thenReturn(Arrays.asList(vin, vin2)); |
| | | |
| | | TChargingCard card = new TChargingCard(); |
| | | card.setId(3L); |
| | | card.setBalance(new BigDecimal("8.00")); |
| | | card.setLockedAmount(new BigDecimal("1.50")); |
| | | when(cardMapper.selectList(any())).thenReturn(Collections.singletonList(card)); |
| | | |
| | | TAppUserWhitelist whitelist = new TAppUserWhitelist(); |
| | | whitelist.setId(4L); |
| | | whitelist.setBalance(new BigDecimal("6.00")); |
| | | whitelist.setLockedAmount(BigDecimal.ZERO); |
| | | when(whitelistMapper.selectList(any())).thenReturn(Collections.singletonList(whitelist)); |
| | | |
| | | CompanyAllocateSummaryVO summary = service.sumAllocatedBalance(9); |
| | | |
| | | assertEquals(9, summary.getCompanyId()); |
| | | assertEquals(new BigDecimal("29.75"), summary.getTotalBalance()); |
| | | assertEquals(new BigDecimal("3.50"), summary.getTotalLocked()); |
| | | assertEquals(4, summary.getBalanceUserCount()); |
| | | } |
| | | |
| | | @Test |
| | | void recycleAllocatedBalanceClearsAndWritesBothSides() throws InterruptedException { |
| | | when(lock.tryLock(5L, 30L, TimeUnit.SECONDS)).thenReturn(true); |
| | | when(lock.isHeldByCurrentThread()).thenReturn(true); |
| | | |
| | | TVinManagement vin = new TVinManagement(); |
| | | vin.setId(1L); |
| | | vin.setBalance(new BigDecimal("10.00")); |
| | | vin.setVin("LVIN0001"); |
| | | vin.setPlateNumber("粤A12345"); |
| | | when(vinMapper.selectList(any())).thenReturn(Collections.singletonList(vin)); |
| | | when(vinMapper.clearBalance(eq(1L), eq(123L))).thenReturn(1); |
| | | |
| | | TChargingCard card = new TChargingCard(); |
| | | card.setId(2L); |
| | | card.setBalance(new BigDecimal("20.00")); |
| | | card.setCardNo("CARD0001"); |
| | | when(cardMapper.selectList(any())).thenReturn(Collections.singletonList(card)); |
| | | when(cardMapper.clearBalance(eq(2L), eq(123L))).thenReturn(1); |
| | | |
| | | TAppUserWhitelist whitelist = new TAppUserWhitelist(); |
| | | whitelist.setId(3L); |
| | | whitelist.setBalance(new BigDecimal("30.00")); |
| | | whitelist.setPhone("13800000000"); |
| | | whitelist.setName("张三"); |
| | | when(whitelistMapper.selectList(any())).thenReturn(Collections.singletonList(whitelist)); |
| | | when(whitelistMapper.clearBalance(eq(3L), eq("admin"))).thenReturn(1); |
| | | |
| | | BigDecimal recycled = service.recycleAllocatedBalance(9, "admin", "测试公司", new BigDecimal("100.00")); |
| | | |
| | | assertEquals(new BigDecimal("60.00"), recycled); |
| | | verify(vinMapper).clearBalance(1L, 123L); |
| | | verify(cardMapper).clearBalance(2L, 123L); |
| | | verify(whitelistMapper).clearBalance(3L, "admin"); |
| | | // 3 条用户负流水 + 1 条企业账户正流水 |
| | | verify(mapper, times(4)).insert(any(TCompanyAllocateRecord.class)); |
| | | ArgumentCaptor<TCompanyAllocateRecord> captor = ArgumentCaptor.forClass(TCompanyAllocateRecord.class); |
| | | verify(mapper, times(4)).insert(captor.capture()); |
| | | List<TCompanyAllocateRecord> records = captor.getAllValues(); |
| | | TCompanyAllocateRecord companyRecord = records.get(records.size() - 1); |
| | | assertEquals(TCompanyAllocateRecord.USER_TYPE_COMPANY, companyRecord.getUserType()); |
| | | assertEquals("测试公司", companyRecord.getUserInfo()); |
| | | assertEquals(TCompanyAllocateRecord.SOURCE_MODE_SWITCH, companyRecord.getChangeSource()); |
| | | assertEquals(new BigDecimal("100.00"), companyRecord.getBeforeBalance()); |
| | | assertEquals(new BigDecimal("60.00"), companyRecord.getAmount()); |
| | | assertEquals(new BigDecimal("160.00"), companyRecord.getAfterBalance()); |
| | | for (int i = 0; i < 3; i++) { |
| | | assertEquals(TCompanyAllocateRecord.SOURCE_MODE_SWITCH, records.get(i).getChangeSource()); |
| | | assertTrue(records.get(i).getAmount().signum() < 0); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void recycleAllocatedBalanceIsIdempotentWhenNoBalanceLeft() throws InterruptedException { |
| | | when(lock.tryLock(5L, 30L, TimeUnit.SECONDS)).thenReturn(true); |
| | | when(lock.isHeldByCurrentThread()).thenReturn(true); |
| | | when(vinMapper.selectList(any())).thenReturn(Collections.emptyList()); |
| | | when(cardMapper.selectList(any())).thenReturn(Collections.emptyList()); |
| | | when(whitelistMapper.selectList(any())).thenReturn(Collections.emptyList()); |
| | | |
| | | BigDecimal recycled = service.recycleAllocatedBalance(9, "admin", "测试公司", new BigDecimal("100.00")); |
| | | |
| | | assertEquals(new BigDecimal("0.00"), recycled); |
| | | verify(mapper, never()).insert(any(TCompanyAllocateRecord.class)); |
| | | } |
| | | |
| | | @Test |
| | | void recycleAllocatedBalanceThrowsWhenLockBusy() throws InterruptedException { |
| | | when(lock.tryLock(5L, 30L, TimeUnit.SECONDS)).thenReturn(false); |
| | | |
| | | assertThrows(ServiceException.class, |
| | | () -> service.recycleAllocatedBalance(9, "admin", "测试公司", new BigDecimal("100.00"))); |
| | | verify(vinMapper, never()).selectList(any()); |
| | | } |
| | | |
| | | @Test |
| | | void recycleAllocatedBalanceRejectsMissingCompany() { |
| | | assertThrows(ServiceException.class, () -> service.recycleAllocatedBalance(null, "admin", null, null)); |
| | | verify(mapper, never()).insert(any(TCompanyAllocateRecord.class)); |
| | | } |
| | | |
| | | private String repeat(char value, int count) { |
| | | StringBuilder builder = new StringBuilder(count); |
| | | for (int i = 0; i < count; i++) { |
| | |
| | | } |
| | | return R.ok(result); |
| | | } |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | /** |
| | | * 内部接口:统计公司名下进行中充电订单数(三表合计,状态 2=启动中/3=充电中/4=停止中, |
| | | * 账户模式切换:分配→共享 前校验使用) |
| | | */ |
| | | @PostMapping("/inner/count-active-by-company") |
| | | public R<Integer> countActiveByCompany(@RequestParam("companyId") Integer companyId) { |
| | | if (companyId == null) { |
| | | return R.ok(0); |
| | | } |
| | | try { |
| | | List<Integer> activeStatuses = Arrays.asList(2, 3, 4); |
| | | long count = 0L; |
| | | count += chargingOrderService.lambdaQuery() |
| | | .eq(TChargingOrder::getCompanyId, companyId) |
| | | .in(TChargingOrder::getStatus, activeStatuses) |
| | | .count(); |
| | | count += cardChargingOrderService.lambdaQuery() |
| | | .eq(TCardChargingOrder::getCompanyId, companyId) |
| | | .in(TCardChargingOrder::getStatus, activeStatuses) |
| | | .count(); |
| | | count += vinChargingOrderService.lambdaQuery() |
| | | .eq(TVinChargingOrder::getCompanyId, companyId) |
| | | .in(TVinChargingOrder::getStatus, activeStatuses) |
| | | .count(); |
| | | return R.ok((int) count); |
| | | } catch (RuntimeException ex) { |
| | | log.error("统计公司进行中充电订单失败:companyId={}", companyId, ex); |
| | | return R.fail("统计公司进行中充电订单失败:" + ex.getMessage()); |
| | | } |
| | | } |
| | | |
| | | @RequiresPermissions(value = {"/chargingOperationAnalysis"}, logical = Logical.OR) |
| | | @ResponseBody |
| | | @PostMapping(value = "/charging/statistics") |
| | |
| | | |
| | | long count(@Param("query") VinChargingOrderQuery query); |
| | | |
| | | long countAppletOrders(@Param("vinIds") List<Long> vinIds); |
| | | long countAppletOrders(@Param("appUserId") Long appUserId, @Param("vinIds") List<Long> vinIds); |
| | | |
| | | List<MyVinChargingOrderList> pageAppletOrders(@Param("vinIds") List<Long> vinIds, |
| | | List<MyVinChargingOrderList> pageAppletOrders(@Param("appUserId") Long appUserId, |
| | | @Param("vinIds") List<Long> vinIds, |
| | | @Param("offset") long offset, |
| | | @Param("pageSize") long pageSize); |
| | | |
| | |
| | | page.setRecords(Collections.emptyList()); |
| | | return page; |
| | | } |
| | | long total = baseMapper.countAppletOrders(vinIds); |
| | | long total = baseMapper.countAppletOrders(appUserId, vinIds); |
| | | page.setTotal(total); |
| | | List<MyVinChargingOrderList> records = total == 0 ? Collections.emptyList() |
| | | : baseMapper.pageAppletOrders(vinIds, (long) (current - 1) * size, size); |
| | | : baseMapper.pageAppletOrders(appUserId, vinIds, (long) (current - 1) * size, size); |
| | | fillAppletExtra(records); |
| | | page.setRecords(records); |
| | | return page; |
| | |
| | | tokenService.getLoginUserApplet().getUserId()); |
| | | List<Long> vinIds = response == null ? Collections.emptyList() |
| | | : Optional.ofNullable(response.getData()).orElse(Collections.emptyList()); |
| | | return vinIds.contains(order.getVinId()) ? order : null; |
| | | // VIN 可换绑:除校验 VIN 当前归属外,还必须校验订单下单用户与当前登录用户一致,防止新绑定用户查看他人历史订单 |
| | | Long appUserId = tokenService.getLoginUserApplet().getUserId(); |
| | | return vinIds.contains(order.getVinId()) && appUserId.equals(order.getAppUserId()) ? order : null; |
| | | } |
| | | |
| | | private Long toEpochMilli(LocalDateTime time) { |
| | |
| | | SELECT COUNT(1) |
| | | FROM t_vin_charging_order o |
| | | WHERE o.del_flag = 0 |
| | | AND o.app_user_id = #{appUserId} |
| | | AND o.vin_id IN |
| | | <foreach collection="vinIds" item="vinId" open="(" separator="," close=")"> |
| | | #{vinId} |
| | |
| | | ) THEN 2 ELSE 1 END AS evaluate |
| | | FROM t_vin_charging_order o |
| | | WHERE o.del_flag = 0 |
| | | AND o.app_user_id = #{appUserId} |
| | | AND o.vin_id IN |
| | | <foreach collection="vinIds" item="vinId" open="(" separator="," close=")"> |
| | | #{vinId} |
| | |
| | | assertTrue(mapper.contains("group by charging_order_id")); |
| | | assertTrue(mapper.contains("pageappletorders")); |
| | | assertTrue(mapper.contains("o.vin_id in")); |
| | | assertTrue(mapper.contains("o.app_user_id = #{appuserid}")); |
| | | assertTrue(mapper.contains("order by o.create_time desc, o.id desc")); |
| | | assertTrue(mapper.contains("limit #{offset}, #{pagesize}")); |
| | | assertTrue(mapper.contains("getchargingvinstatus")); |
| | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | import static org.junit.jupiter.api.Assertions.assertTrue; |
| | | import static org.mockito.ArgumentMatchers.anyList; |
| | | import static org.mockito.ArgumentMatchers.anyLong; |
| | | import static org.mockito.ArgumentMatchers.eq; |
| | | import static org.mockito.Mockito.never; |
| | | import static org.mockito.Mockito.verify; |
| | |
| | | loginUser.setUserId(88L); |
| | | when(tokenService.getLoginUserApplet()).thenReturn(loginUser); |
| | | when(vinManagementClient.getVinIdsByAppUserId(88L)).thenReturn(R.ok(Arrays.asList(11L, 22L))); |
| | | when(mapper.countAppletOrders(Arrays.asList(11L, 22L))).thenReturn(1L); |
| | | when(mapper.countAppletOrders(88L, Arrays.asList(11L, 22L))).thenReturn(1L); |
| | | MyVinChargingOrderList order = new MyVinChargingOrderList(); |
| | | order.setId(1001L); |
| | | when(mapper.pageAppletOrders(Arrays.asList(11L, 22L), 10L, 10L)) |
| | | when(mapper.pageAppletOrders(88L, Arrays.asList(11L, 22L), 10L, 10L)) |
| | | .thenReturn(Collections.singletonList(order)); |
| | | |
| | | PageInfo<MyVinChargingOrderList> result = service.pageAppletOrders(2, 10); |
| | |
| | | assertEquals(1L, result.getTotal()); |
| | | assertEquals(1001L, result.getRecords().get(0).getId()); |
| | | verify(vinManagementClient).getVinIdsByAppUserId(88L); |
| | | verify(mapper).pageAppletOrders(Arrays.asList(11L, 22L), 10L, 10L); |
| | | verify(mapper).countAppletOrders(88L, Arrays.asList(11L, 22L)); |
| | | verify(mapper).pageAppletOrders(88L, Arrays.asList(11L, 22L), 10L, 10L); |
| | | } |
| | | |
| | | @Test |
| | |
| | | |
| | | assertEquals(0L, result.getTotal()); |
| | | assertEquals(0, result.getRecords().size()); |
| | | verify(mapper, never()).countAppletOrders(anyList()); |
| | | verify(mapper, never()).pageAppletOrders(anyList(), eq(0L), eq(10L)); |
| | | verify(mapper, never()).countAppletOrders(anyLong(), anyList()); |
| | | verify(mapper, never()).pageAppletOrders(anyLong(), anyList(), eq(0L), eq(10L)); |
| | | } |
| | | |
| | | @Test |
| | |
| | | TVinChargingOrder order = new TVinChargingOrder(); |
| | | order.setId(1001L); |
| | | order.setVinId(11L); |
| | | order.setAppUserId(88L); |
| | | order.setCode("VD_1001"); |
| | | order.setOrderAmount(new BigDecimal("18.50")); |
| | | order.setElectrovalence(new BigDecimal("12.30")); |
| | |
| | | assertTrue(service.appletDetail(1001L).isEmpty()); |
| | | verify(mapper, never()).accountingStrategyList(1001L); |
| | | } |
| | | |
| | | @Test |
| | | void returnsEmptyDetailWhenVinReboundAndOrderBelongsToPreviousUser() { |
| | | LoginUserApplet loginUser = new LoginUserApplet(); |
| | | loginUser.setUserId(88L); |
| | | when(tokenService.getLoginUserApplet()).thenReturn(loginUser); |
| | | // VIN 11 当前绑定用户 88,但该订单是换绑前用户 99 下单的,不能查看 |
| | | when(vinManagementClient.getVinIdsByAppUserId(88L)).thenReturn(R.ok(Collections.singletonList(11L))); |
| | | TVinChargingOrder order = new TVinChargingOrder(); |
| | | order.setId(1001L); |
| | | order.setVinId(11L); |
| | | order.setAppUserId(99L); |
| | | when(mapper.selectById(1001L)).thenReturn(order); |
| | | |
| | | assertTrue(service.appletDetail(1001L).isEmpty()); |
| | | verify(mapper, never()).accountingStrategyList(1001L); |
| | | } |
| | | } |
| New file |
| | |
| | | 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> { |
| | | |
| | | } |
| New file |
| | |
| | | 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); |
| | | |
| | | } |
| New file |
| | |
| | | package com.ruoyi.other.service; |
| | | |
| | | /** |
| | | * <p> |
| | | * 公司余额预警扫描服务(触发扫描 + 短信通知) |
| | | * </p> |
| | | * |
| | | * @author mxcd |
| | | * @since 2026-10-10 |
| | | */ |
| | | public interface TBalanceWarningScanService { |
| | | |
| | | /** |
| | | * 扫描公司余额预警配置,对触发预警的公司/子账户发送提醒短信(按天去重) |
| | | */ |
| | | void scan(); |
| | | |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | |
| | | } |
| New file |
| | |
| | | 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(); |
| | | } |
| | | |
| | | } |
| | |
| | | import com.ruoyi.account.api.model.TChargingCard; |
| | | import com.ruoyi.account.api.model.TCompanyAllocateRecord; |
| | | import com.ruoyi.account.api.model.TVinManagement; |
| | | import com.ruoyi.account.api.vo.CompanyAllocateSummaryVO; |
| | | import com.ruoyi.common.core.domain.R; |
| | | import com.ruoyi.common.core.exception.ServiceException; |
| | | import com.ruoyi.common.core.utils.StringUtils; |
| | | import com.ruoyi.common.core.web.domain.AjaxResult; |
| | | import com.ruoyi.common.security.utils.SecurityUtils; |
| | | import com.ruoyi.order.api.feignClient.ChargingOrderClient; |
| | | import com.ruoyi.other.api.domain.TFleet; |
| | | import com.ruoyi.other.api.domain.TGroupCompany; |
| | | import com.ruoyi.other.api.model.TCompanyRechargeRecord; |
| | |
| | | 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; |
| | |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.Objects; |
| | | import java.util.concurrent.ThreadLocalRandom; |
| | | import java.util.stream.Collectors; |
| | | |
| | | /** |
| | |
| | | /** 账户状态:欠款 */ |
| | | 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。 |
| | | * 此处注入 Mapper 而非 TFleetService:TFleetServiceImpl 已依赖 TGroupCompanyService, |
| | |
| | | /** 企业额度分配记录远程客户端(结算时写消费流水、幂等判定使用) */ |
| | | @Resource |
| | | private CompanyAllocateRecordClient companyAllocateRecordClient; |
| | | |
| | | /** 充电订单远程客户端(账户模式切换前进行中订单校验使用) */ |
| | | @Resource |
| | | private ChargingOrderClient chargingOrderClient; |
| | | |
| | | @Override |
| | | public TGroupCompany getDetail(Integer id) { |
| | |
| | | @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); |
| | |
| | | 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 |
| | |
| | | if (company == null) { |
| | | return AjaxResult.error("公司不存在"); |
| | | } |
| | | // 目标模式与当前一致:幂等直接返回 |
| | | if (Objects.equals(company.getAccountMode(), accountMode)) { |
| | | return AjaxResult.success(); |
| | | } |
| | | // 统一校验:无论切换方向,存在进行中充电订单均拒绝切换 |
| | | AjaxResult chargingCheck = ensureNoActiveChargingOrder(id); |
| | | if (chargingCheck != null) { |
| | | return chargingCheck; |
| | | } |
| | | // 共享 → 分配:仅切换模式,不涉及资金回退 |
| | | if (accountMode == ACCOUNT_MODE_ALLOCATION) { |
| | | TGroupCompany update = new TGroupCompany(); |
| | | update.setId(id); |
| | | update.setAccountMode(accountMode); |
| | | updateById(update); |
| | | return AjaxResult.success(); |
| | | } |
| | | // 分配 → 共享:校验在途业务后回收全部分配余额 |
| | | return switchAllocationToShared(id, company); |
| | | } |
| | | |
| | | /** |
| | | * 校验公司无进行中充电订单(三表合计,状态 2=启动中/3=充电中/4=停止中)。 |
| | | * 通过返回 null;不通过返回可直接回给前端的错误结果。 |
| | | * 账户模式双向切换均须先通过本校验。 |
| | | */ |
| | | private AjaxResult ensureNoActiveChargingOrder(Integer id) { |
| | | R<Integer> activeCountResult; |
| | | try { |
| | | activeCountResult = chargingOrderClient.countActiveByCompany(id); |
| | | } catch (RuntimeException ex) { |
| | | log.error("[模式切换] 统计进行中充电订单异常 companyId={}", id, ex); |
| | | return AjaxResult.error("校验充电状态失败,请稍后重试"); |
| | | } |
| | | if (activeCountResult == null || R.SUCCESS != activeCountResult.getCode()) { |
| | | return AjaxResult.error("校验充电状态失败,请稍后重试"); |
| | | } |
| | | Integer activeCount = activeCountResult.getData(); |
| | | if (activeCount != null && activeCount > 0) { |
| | | return AjaxResult.error("该公司存在正在充电的订单,暂不允许切换账户模式"); |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * 分配模式切换为共享模式: |
| | | * <ol> |
| | | * <li>校验公司与三类主体均无未释放充电锁定额</li> |
| | | * <li>公司余额预加回回收总额 + 切换为共享模式(本地事务)</li> |
| | | * <li>远程回收三类主体全部余额并写双向流水(account 侧锁内事务,幂等)</li> |
| | | * <li>按实际回收额与预加回额差额校准公司余额(并发分配竞态兜底)</li> |
| | | * </ol> |
| | | * 进行中充电订单校验已在 updateAccountMode 公共入口完成。 |
| | | */ |
| | | private AjaxResult switchAllocationToShared(Integer id, TGroupCompany company) { |
| | | // 1. 公司锁定额校验(在途充电锁定未释放) |
| | | BigDecimal companyLocked = company.getLockedAmount(); |
| | | if (companyLocked != null && companyLocked.signum() > 0) { |
| | | return AjaxResult.error("公司存在未释放的充电锁定金额,暂不允许切换为共享模式"); |
| | | } |
| | | // 2. 三类主体余额与锁定额统计 |
| | | R<CompanyAllocateSummaryVO> summaryResult; |
| | | try { |
| | | summaryResult = companyAllocateRecordClient.sumAllocatedBalance(id); |
| | | } catch (RuntimeException ex) { |
| | | log.error("[模式切换] 统计分配余额异常 companyId={}", id, ex); |
| | | return AjaxResult.error("查询分配余额失败,请稍后重试"); |
| | | } |
| | | if (summaryResult == null || R.SUCCESS != summaryResult.getCode() || summaryResult.getData() == null) { |
| | | return AjaxResult.error("查询分配余额失败,请稍后重试"); |
| | | } |
| | | CompanyAllocateSummaryVO summary = summaryResult.getData(); |
| | | if (summary.getTotalLocked() != null && summary.getTotalLocked().signum() > 0) { |
| | | return AjaxResult.error("存在充电锁定金额未释放,暂不允许切换为共享模式"); |
| | | } |
| | | BigDecimal total = summary.getTotalBalance() == null ? BigDecimal.ZERO : summary.getTotalBalance(); |
| | | BigDecimal companyBalanceBefore = company.getAvailableBalance() == null |
| | | ? BigDecimal.ZERO : company.getAvailableBalance(); |
| | | // 3. 公司余额加回 + 模式切换(本地事务) |
| | | if (!updateCompanyToShared(id, total)) { |
| | | throw new ServiceException("公司余额更新失败"); |
| | | } |
| | | // 4. 远程回收三类主体余额(account 侧三把公司级锁串行、事务、幂等),失败回滚公司余额与模式 |
| | | BigDecimal actual; |
| | | try { |
| | | actual = recycleAllocatedBalanceRemote(id, company, companyBalanceBefore); |
| | | } catch (RuntimeException ex) { |
| | | log.error("[模式切换] 远程回收分配余额异常 companyId={}", id, ex); |
| | | rollbackModeSwitch(id, total); |
| | | throw new ServiceException("回收分配余额失败,请稍后重试"); |
| | | } |
| | | // 5. 差额调整:回收与统计间存在并发分配时,按实际回收额校准公司余额 |
| | | BigDecimal diff = actual.subtract(total); |
| | | if (diff.signum() != 0) { |
| | | boolean adjusted = lambdaUpdate() |
| | | .eq(TGroupCompany::getId, id) |
| | | .setSql("available_balance = IFNULL(available_balance, 0) + (" + diff.toPlainString() + ")") |
| | | .update(); |
| | | if (!adjusted) { |
| | | log.error("[模式切换] 公司余额差额调整失败 companyId={} diff={}", id, diff.toPlainString()); |
| | | } |
| | | } |
| | | log.info("[模式切换] 分配→共享成功 companyId={} 回收总额={}", id, actual.toPlainString()); |
| | | return AjaxResult.success("账户模式已切换为共享模式,回收分配余额 " + actual.toPlainString() + " 元"); |
| | | } |
| | | |
| | | /** 更新公司余额(加回回收额)并切换为共享模式,返回是否更新成功 */ |
| | | private boolean updateCompanyToShared(Integer id, BigDecimal total) { |
| | | if (total.signum() > 0) { |
| | | return lambdaUpdate() |
| | | .eq(TGroupCompany::getId, id) |
| | | .setSql("available_balance = IFNULL(available_balance, 0) + " + total.toPlainString()) |
| | | .set(TGroupCompany::getAccountMode, ACCOUNT_MODE_SHARED) |
| | | .update(); |
| | | } |
| | | TGroupCompany update = new TGroupCompany(); |
| | | update.setId(id); |
| | | update.setAccountMode(accountMode); |
| | | updateById(update); |
| | | return AjaxResult.success(); |
| | | update.setAccountMode(ACCOUNT_MODE_SHARED); |
| | | return updateById(update); |
| | | } |
| | | |
| | | /** 远程回收三类主体分配余额,失败(远程失败码/返回体异常)抛出 ServiceException 由调用方补偿 */ |
| | | private BigDecimal recycleAllocatedBalanceRemote(Integer id, TGroupCompany company, |
| | | BigDecimal companyBalanceBefore) { |
| | | String operatorName = null; |
| | | try { |
| | | operatorName = SecurityUtils.getLoginUser().getUsername(); |
| | | } catch (Exception ignored) { |
| | | // 无安全上下文时(内部调用)操作人留空 |
| | | } |
| | | R<BigDecimal> recycleResult = companyAllocateRecordClient.recycleAllocatedBalance(id, operatorName, |
| | | company.getName(), companyBalanceBefore); |
| | | if (recycleResult == null || R.SUCCESS != recycleResult.getCode() || recycleResult.getData() == null) { |
| | | String msg = recycleResult == null ? "远程调用失败" : recycleResult.getMsg(); |
| | | log.error("[模式切换] 回收分配余额失败 companyId={} msg={}", id, msg); |
| | | throw new ServiceException("回收分配余额失败:" + msg); |
| | | } |
| | | return recycleResult.getData(); |
| | | } |
| | | |
| | | /** |
| | | * 回收失败补偿:公司余额减回预加回额、模式恢复为分配模式。 |
| | | * 仅当模式仍为共享时执行恢复,避免覆盖后续并发操作;恢复失败仅记录日志(下轮对账/重试兜底)。 |
| | | */ |
| | | private void rollbackModeSwitch(Integer id, BigDecimal total) { |
| | | try { |
| | | boolean rollbacked = lambdaUpdate() |
| | | .eq(TGroupCompany::getId, id) |
| | | .eq(TGroupCompany::getAccountMode, ACCOUNT_MODE_SHARED) |
| | | .setSql("available_balance = IFNULL(available_balance, 0) - " + total.toPlainString()) |
| | | .set(TGroupCompany::getAccountMode, ACCOUNT_MODE_ALLOCATION) |
| | | .update(); |
| | | log.warn("[模式切换] 回收失败已回滚 companyId={} rollbacked={}", id, rollbacked); |
| | | } catch (RuntimeException ex) { |
| | | log.error("[模式切换] 回收失败回滚异常 companyId={}", id, ex); |
| | | } |
| | | } |
| | | |
| | | @Override |
| | |
| | | JSONObject json = JSONObject.parseObject(item.toString()); |
| | | String collectId = firstNotBlank(json.getString("devDn"), json.getString("esnCode")); |
| | | String stationId = json.getString("stationCode"); |
| | | Integer devTypeId = json.getInteger("devTypeId"); |
| | | if (StringUtils.isBlank(collectId) || StringUtils.isBlank(stationId) |
| | | || existingKeys.contains(collectId) || !pendingKeys.add(collectId)) { |
| | | || existingKeys.contains(collectId) || !pendingKeys.add(collectId) || devTypeId != 1) { |
| | | continue; |
| | | } |
| | | TStationCollect collect = new TStationCollect(); |
| | |
| | | statusVO.setOnStatusCount(onStatusList.size()); |
| | | statusVO.setOffStatusCount(stationInfoList.size() - onStatusList.size()); |
| | | } |
| | | // 电站状态数量加上非科士达的数据 |
| | | List<TStationInfo> noKeShiDaList = stationInfoList.stream().filter(s -> s.getPlatformId() != 0).collect(Collectors.toList()); |
| | | statusVO.setTotal(statusVO.getTotal() + noKeShiDaList.size()); |
| | | statusVO.setOnline(statusVO.getOnline() + noKeShiDaList.size()); |
| | | return statusVO; |
| | | } |
| | | |
| New file |
| | |
| | | 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(); |
| | | } |
| | | |
| | | } |
| | |
| | | import com.alibaba.fastjson.JSON; |
| | | import com.alibaba.fastjson.JSONArray; |
| | | import com.alibaba.fastjson.JSONObject; |
| | | import com.baomidou.mybatisplus.core.toolkit.Wrappers; |
| | | import com.ruoyi.common.core.utils.StringUtils; |
| | | import com.ruoyi.other.domain.TPlatformConfig; |
| | | import com.ruoyi.other.service.TPlatformConfigService; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.http.HttpEntity; |
| | | import org.springframework.http.HttpHeaders; |
| | |
| | | @Resource |
| | | private RestTemplate restTemplate; |
| | | |
| | | @Resource |
| | | private TPlatformConfigService platformConfigService; |
| | | |
| | | /** 登录华为 FusionSolar 并取得 XSRF-TOKEN。 */ |
| | | public String getXsrfToken() { |
| | | return getXsrfToken(DEFAULT_USER_NAME, DEFAULT_SYSTEM_CODE); |
| | | TPlatformConfig config = platformConfigService.getOne(Wrappers.lambdaQuery(TPlatformConfig.class).eq(TPlatformConfig::getPlatformName, "华为")); |
| | | if (config == null || StringUtils.isBlank(config.getAccount()) || StringUtils.isBlank(config.getPassword())) { |
| | | return getXsrfToken(DEFAULT_USER_NAME, DEFAULT_SYSTEM_CODE); |
| | | } |
| | | return getXsrfToken(config.getAccount(), config.getPassword()); |
| | | } |
| | | |
| | | /** 使用指定账号登录并取得 XSRF-TOKEN。 */ |
| | |
| | | import cn.hutool.http.HttpUtil; |
| | | import com.alibaba.fastjson.JSONArray; |
| | | import com.alibaba.fastjson2.JSONObject; |
| | | import com.baomidou.mybatisplus.core.toolkit.Wrappers; |
| | | import com.ruoyi.common.core.utils.SpringUtils; |
| | | import com.ruoyi.common.core.utils.StringUtils; |
| | | import com.ruoyi.other.domain.TPlatformConfig; |
| | | import com.ruoyi.other.service.TPlatformConfigService; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | |
| | | |
| | | |
| | | public static String token() { |
| | | TPlatformConfig config = SpringUtils.getBean(TPlatformConfigService.class).getOne(Wrappers.lambdaQuery(TPlatformConfig.class).eq(TPlatformConfig::getPlatformName, "科士达")); |
| | | if (config == null) { |
| | | config = new TPlatformConfig(); |
| | | } |
| | | if (StringUtils.isBlank(config.getAccount()) || StringUtils.isBlank(config.getPassword())) { |
| | | config.setAccount("环岛光伏"); |
| | | config.setPassword("cR8GGwIXMI462A8Dfl9eXg=="); |
| | | } |
| | | HttpRequest post = HttpUtil.createPost("http://111.230.136.62:8092/authentication/form"); |
| | | post.basicAuth("kstar","kstarSecret"); |
| | | post.form("username", "环岛光伏"); |
| | | post.form("password", "cR8GGwIXMI462A8Dfl9eXg=="); |
| | | System.out.println(post); |
| | | post.form("username", config.getAccount()); |
| | | post.form("password", config.getPassword()); |
| | | HttpResponse execute = post.execute(); |
| | | String body = execute.body(); |
| | | JSONObject jsonObject = JSONObject.parseObject(body); |
| | |
| | | } |
| | | |
| | | |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | |
| | | } |
| | |
| | | |
| | | import com.baomidou.mybatisplus.core.MybatisConfiguration; |
| | | import com.baomidou.mybatisplus.core.conditions.Wrapper; |
| | | import com.baomidou.mybatisplus.extension.conditions.update.LambdaUpdateChainWrapper; |
| | | import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; |
| | | import com.ruoyi.account.api.feignClient.AppUserWhitelistClient; |
| | | import com.ruoyi.account.api.feignClient.ChargingCardClient; |
| | | import com.ruoyi.account.api.feignClient.CompanyAllocateRecordClient; |
| | | import com.ruoyi.account.api.feignClient.VinManagementClient; |
| | | import com.ruoyi.account.api.model.TVinManagement; |
| | | import com.ruoyi.account.api.vo.CompanyAllocateSummaryVO; |
| | | import com.ruoyi.common.core.domain.R; |
| | | import com.ruoyi.common.core.exception.ServiceException; |
| | | import com.ruoyi.common.core.web.domain.AjaxResult; |
| | | import com.ruoyi.other.api.domain.TGroupCompany; |
| | | import com.ruoyi.other.api.model.TCompanyRechargeRecord; |
| | | import com.ruoyi.other.mapper.TCompanyRechargeRecordMapper; |
| | | import com.ruoyi.other.mapper.TFleetMapper; |
| | | import com.ruoyi.order.api.feignClient.ChargingOrderClient; |
| | | import com.ruoyi.system.api.domain.SysUser; |
| | | import com.ruoyi.system.api.feignClient.SysRoleClient; |
| | | import com.ruoyi.system.api.feignClient.SysUserClient; |
| | |
| | | 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; |
| | |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | import static org.junit.jupiter.api.Assertions.assertNull; |
| | | import static org.junit.jupiter.api.Assertions.assertThrows; |
| | | import static org.junit.jupiter.api.Assertions.assertTrue; |
| | | |
| | | /** |
| | |
| | | |
| | | /** 编辑公司用例共享的远程账号客户端 mock,供 verify 使用 */ |
| | | private SysUserClient editUserClient; |
| | | |
| | | /** 新增公司用例共享的远程账号客户端 mock,供 verify 使用 */ |
| | | private SysUserClient addUserClient; |
| | | |
| | | @BeforeAll |
| | | static void initTableInfo() { |
| | |
| | | } |
| | | |
| | | /** |
| | | * 添加/编辑企业弹窗不再配置账户模式:未传账户模式时按共享模式(1)建档 |
| | | * 添加/编辑企业弹窗不再配置账户模式:未传账户模式时按共享模式(1)建档; |
| | | * 登录账号由系统自动生成(忽略前端传入值),并随返回体回显 |
| | | */ |
| | | @Test |
| | | void addCompanyDefaultsAccountModeToSharedWhenAbsent() { |
| | |
| | | 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"))); |
| | | } |
| | | |
| | | /** |
| | |
| | | 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 |
| | |
| | | void updateAccountModeOnlyUpdatesAccountModeColumn() { |
| | | TGroupCompany company = new TGroupCompany(); |
| | | company.setId(2); |
| | | ChargingOrderClient orderClient = Mockito.mock(ChargingOrderClient.class); |
| | | Mockito.when(orderClient.countActiveByCompany(2)).thenReturn(R.ok(0)); |
| | | TGroupCompanyServiceImpl service = buildService(company, Mockito.mock(TFleetMapper.class), |
| | | Mockito.mock(TCompanyRechargeRecordMapper.class)); |
| | | ReflectionTestUtils.setField(service, "chargingOrderClient", orderClient); |
| | | Mockito.doReturn(true).when(service).updateById(Mockito.any()); |
| | | |
| | | assertEquals(200, code(service.updateAccountMode(2, 2))); |
| | |
| | | assertNull(update.getName()); |
| | | assertNull(update.getAccount()); |
| | | assertNull(update.getStatus()); |
| | | } |
| | | |
| | | /** |
| | | * 分配→共享切换成功:加回公司余额、切换模式、远程回收、差额一致无需调整 |
| | | */ |
| | | @Test |
| | | void switchAllocationToSharedRecyclesBalanceAndWritesRecords() { |
| | | TGroupCompany company = new TGroupCompany(); |
| | | company.setId(2); |
| | | company.setName("测试公司"); |
| | | company.setAccountMode(2); |
| | | company.setAvailableBalance(new BigDecimal("100.00")); |
| | | company.setLockedAmount(new BigDecimal("0.00")); |
| | | |
| | | CompanyAllocateRecordClient allocateClient = Mockito.mock(CompanyAllocateRecordClient.class); |
| | | CompanyAllocateSummaryVO summary = new CompanyAllocateSummaryVO(); |
| | | summary.setCompanyId(2); |
| | | summary.setTotalBalance(new BigDecimal("30.50")); |
| | | summary.setTotalLocked(new BigDecimal("0.00")); |
| | | summary.setBalanceUserCount(2); |
| | | Mockito.when(allocateClient.sumAllocatedBalance(2)).thenReturn(R.ok(summary)); |
| | | Mockito.when(allocateClient.recycleAllocatedBalance(Mockito.eq(2), Mockito.any(), Mockito.eq("测试公司"), |
| | | Mockito.eq(new BigDecimal("100.00")))).thenReturn(R.ok(new BigDecimal("30.50"))); |
| | | |
| | | ChargingOrderClient orderClient = Mockito.mock(ChargingOrderClient.class); |
| | | Mockito.when(orderClient.countActiveByCompany(2)).thenReturn(R.ok(0)); |
| | | |
| | | TGroupCompanyServiceImpl service = buildService(company, Mockito.mock(TFleetMapper.class), |
| | | Mockito.mock(TCompanyRechargeRecordMapper.class)); |
| | | ReflectionTestUtils.setField(service, "companyAllocateRecordClient", allocateClient); |
| | | ReflectionTestUtils.setField(service, "chargingOrderClient", orderClient); |
| | | stubLambdaUpdate(service, true); |
| | | |
| | | assertEquals(200, code(service.updateAccountMode(2, 1))); |
| | | |
| | | // 远程回收携带回退前公司余额,写入企业账户正数流水与用户负数流水由 account 侧完成 |
| | | Mockito.verify(allocateClient).recycleAllocatedBalance(2, null, "测试公司", new BigDecimal("100.00")); |
| | | Mockito.verify(orderClient).countActiveByCompany(2); |
| | | } |
| | | |
| | | /** |
| | | * 存在进行中充电订单:拒绝切换且不触发回收 |
| | | */ |
| | | @Test |
| | | void switchAllocationToSharedRejectsWhenChargingInProgress() { |
| | | TGroupCompany company = new TGroupCompany(); |
| | | company.setId(3); |
| | | company.setName("充电中公司"); |
| | | company.setAccountMode(2); |
| | | company.setAvailableBalance(new BigDecimal("50.00")); |
| | | company.setLockedAmount(new BigDecimal("0.00")); |
| | | |
| | | ChargingOrderClient orderClient = Mockito.mock(ChargingOrderClient.class); |
| | | Mockito.when(orderClient.countActiveByCompany(3)).thenReturn(R.ok(2)); |
| | | CompanyAllocateRecordClient allocateClient = Mockito.mock(CompanyAllocateRecordClient.class); |
| | | |
| | | TGroupCompanyServiceImpl service = buildService(company, Mockito.mock(TFleetMapper.class), |
| | | Mockito.mock(TCompanyRechargeRecordMapper.class)); |
| | | ReflectionTestUtils.setField(service, "companyAllocateRecordClient", allocateClient); |
| | | ReflectionTestUtils.setField(service, "chargingOrderClient", orderClient); |
| | | |
| | | AjaxResult result = service.updateAccountMode(3, 1); |
| | | |
| | | assertEquals(500, code(result)); |
| | | assertTrue(String.valueOf(result.get("msg")).contains("正在充电")); |
| | | Mockito.verify(allocateClient, Mockito.never()).sumAllocatedBalance(Mockito.anyInt()); |
| | | Mockito.verify(allocateClient, Mockito.never()).recycleAllocatedBalance(Mockito.anyInt(), Mockito.any(), |
| | | Mockito.any(), Mockito.any()); |
| | | Mockito.verify(service, Mockito.never()).updateById(Mockito.any()); |
| | | } |
| | | |
| | | /** |
| | | * 存在未释放充电锁定额:拒绝切换且不触发回收 |
| | | */ |
| | | @Test |
| | | void switchAllocationToSharedRejectsWhenLockedAmountNotReleased() { |
| | | TGroupCompany company = new TGroupCompany(); |
| | | company.setId(4); |
| | | company.setName("锁定残留公司"); |
| | | company.setAccountMode(2); |
| | | company.setAvailableBalance(new BigDecimal("50.00")); |
| | | company.setLockedAmount(new BigDecimal("0.00")); |
| | | |
| | | ChargingOrderClient orderClient = Mockito.mock(ChargingOrderClient.class); |
| | | Mockito.when(orderClient.countActiveByCompany(4)).thenReturn(R.ok(0)); |
| | | CompanyAllocateRecordClient allocateClient = Mockito.mock(CompanyAllocateRecordClient.class); |
| | | CompanyAllocateSummaryVO summary = new CompanyAllocateSummaryVO(); |
| | | summary.setCompanyId(4); |
| | | summary.setTotalBalance(new BigDecimal("10.00")); |
| | | summary.setTotalLocked(new BigDecimal("3.00")); |
| | | Mockito.when(allocateClient.sumAllocatedBalance(4)).thenReturn(R.ok(summary)); |
| | | |
| | | TGroupCompanyServiceImpl service = buildService(company, Mockito.mock(TFleetMapper.class), |
| | | Mockito.mock(TCompanyRechargeRecordMapper.class)); |
| | | ReflectionTestUtils.setField(service, "companyAllocateRecordClient", allocateClient); |
| | | ReflectionTestUtils.setField(service, "chargingOrderClient", orderClient); |
| | | |
| | | AjaxResult result = service.updateAccountMode(4, 1); |
| | | |
| | | assertEquals(500, code(result)); |
| | | assertTrue(String.valueOf(result.get("msg")).contains("锁定金额")); |
| | | Mockito.verify(allocateClient, Mockito.never()).recycleAllocatedBalance(Mockito.anyInt(), Mockito.any(), |
| | | Mockito.any(), Mockito.any()); |
| | | Mockito.verify(service, Mockito.never()).updateById(Mockito.any()); |
| | | } |
| | | |
| | | /** |
| | | * 回收失败:抛业务异常并回滚公司余额与模式(避免资金总账丢失) |
| | | */ |
| | | @Test |
| | | void switchAllocationToSharedRollsBackWhenRecycleFails() { |
| | | TGroupCompany company = new TGroupCompany(); |
| | | company.setId(5); |
| | | company.setName("回滚公司"); |
| | | company.setAccountMode(2); |
| | | company.setAvailableBalance(new BigDecimal("80.00")); |
| | | company.setLockedAmount(new BigDecimal("0.00")); |
| | | |
| | | ChargingOrderClient orderClient = Mockito.mock(ChargingOrderClient.class); |
| | | Mockito.when(orderClient.countActiveByCompany(5)).thenReturn(R.ok(0)); |
| | | CompanyAllocateRecordClient allocateClient = Mockito.mock(CompanyAllocateRecordClient.class); |
| | | CompanyAllocateSummaryVO summary = new CompanyAllocateSummaryVO(); |
| | | summary.setCompanyId(5); |
| | | summary.setTotalBalance(new BigDecimal("20.00")); |
| | | summary.setTotalLocked(new BigDecimal("0.00")); |
| | | Mockito.when(allocateClient.sumAllocatedBalance(5)).thenReturn(R.ok(summary)); |
| | | Mockito.when(allocateClient.recycleAllocatedBalance(Mockito.eq(5), Mockito.any(), Mockito.any(), |
| | | Mockito.any())).thenReturn(R.fail("回收失败")); |
| | | |
| | | TGroupCompanyServiceImpl service = buildService(company, Mockito.mock(TFleetMapper.class), |
| | | Mockito.mock(TCompanyRechargeRecordMapper.class)); |
| | | ReflectionTestUtils.setField(service, "companyAllocateRecordClient", allocateClient); |
| | | ReflectionTestUtils.setField(service, "chargingOrderClient", orderClient); |
| | | stubLambdaUpdate(service, true); |
| | | |
| | | assertThrows(ServiceException.class, () -> service.updateAccountMode(5, 1)); |
| | | // 加回与回滚各一次:第二次(回滚)将模式恢复为分配模式 |
| | | Mockito.verify(service, Mockito.times(2)).lambdaUpdate(); |
| | | } |
| | | |
| | | /** |
| | | * 共享→分配无进行中充电订单:通过充电校验后仅切换模式,不做任何资金操作 |
| | | */ |
| | | @Test |
| | | void switchSharedToAllocationChecksChargingThenOnlyUpdatesMode() { |
| | | TGroupCompany company = new TGroupCompany(); |
| | | company.setId(6); |
| | | company.setName("切分配公司"); |
| | | company.setAccountMode(1); |
| | | company.setAvailableBalance(new BigDecimal("60.00")); |
| | | |
| | | ChargingOrderClient orderClient = Mockito.mock(ChargingOrderClient.class); |
| | | Mockito.when(orderClient.countActiveByCompany(6)).thenReturn(R.ok(0)); |
| | | CompanyAllocateRecordClient allocateClient = Mockito.mock(CompanyAllocateRecordClient.class); |
| | | |
| | | TGroupCompanyServiceImpl service = buildService(company, Mockito.mock(TFleetMapper.class), |
| | | Mockito.mock(TCompanyRechargeRecordMapper.class)); |
| | | ReflectionTestUtils.setField(service, "companyAllocateRecordClient", allocateClient); |
| | | ReflectionTestUtils.setField(service, "chargingOrderClient", orderClient); |
| | | Mockito.doReturn(true).when(service).updateById(Mockito.any()); |
| | | |
| | | assertEquals(200, code(service.updateAccountMode(6, 2))); |
| | | |
| | | Mockito.verify(orderClient).countActiveByCompany(6); |
| | | Mockito.verify(allocateClient, Mockito.never()).sumAllocatedBalance(Mockito.anyInt()); |
| | | Mockito.verify(allocateClient, Mockito.never()).recycleAllocatedBalance(Mockito.anyInt(), Mockito.any(), |
| | | Mockito.any(), Mockito.any()); |
| | | Mockito.verify(service).updateById(Mockito.any(TGroupCompany.class)); |
| | | } |
| | | |
| | | /** |
| | | * 共享→分配存在进行中充电订单:拒绝切换且不改模式 |
| | | */ |
| | | @Test |
| | | void switchSharedToAllocationRejectsWhenChargingInProgress() { |
| | | TGroupCompany company = new TGroupCompany(); |
| | | company.setId(7); |
| | | company.setName("充电中切分配公司"); |
| | | company.setAccountMode(1); |
| | | company.setAvailableBalance(new BigDecimal("60.00")); |
| | | |
| | | ChargingOrderClient orderClient = Mockito.mock(ChargingOrderClient.class); |
| | | Mockito.when(orderClient.countActiveByCompany(7)).thenReturn(R.ok(3)); |
| | | CompanyAllocateRecordClient allocateClient = Mockito.mock(CompanyAllocateRecordClient.class); |
| | | |
| | | TGroupCompanyServiceImpl service = buildService(company, Mockito.mock(TFleetMapper.class), |
| | | Mockito.mock(TCompanyRechargeRecordMapper.class)); |
| | | ReflectionTestUtils.setField(service, "companyAllocateRecordClient", allocateClient); |
| | | ReflectionTestUtils.setField(service, "chargingOrderClient", orderClient); |
| | | |
| | | AjaxResult result = service.updateAccountMode(7, 2); |
| | | |
| | | assertEquals(500, code(result)); |
| | | assertTrue(String.valueOf(result.get("msg")).contains("正在充电")); |
| | | Mockito.verify(allocateClient, Mockito.never()).sumAllocatedBalance(Mockito.anyInt()); |
| | | Mockito.verify(service, Mockito.never()).updateById(Mockito.any()); |
| | | } |
| | | |
| | | /** |
| | | * 打桩 lambdaUpdate 链:eq/setSql/set 返回自身,update 返回指定结果 |
| | | */ |
| | | @SuppressWarnings("unchecked") |
| | | private void stubLambdaUpdate(TGroupCompanyServiceImpl service, boolean updateResult) { |
| | | LambdaUpdateChainWrapper<TGroupCompany> chain = Mockito.mock(LambdaUpdateChainWrapper.class); |
| | | Mockito.when(chain.eq(Mockito.any(), Mockito.any())).thenReturn(chain); |
| | | Mockito.when(chain.setSql(Mockito.anyString())).thenReturn(chain); |
| | | Mockito.when(chain.set(Mockito.any(), Mockito.any())).thenReturn(chain); |
| | | Mockito.when(chain.update()).thenReturn(updateResult); |
| | | Mockito.doReturn(chain).when(service).lambdaUpdate(); |
| | | } |
| | | |
| | | /** |
| | |
| | | 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 更新为新账号, |
| | | * 不误走补建分支(避免双账号残留) |
| | | */ |
| New file |
| | |
| | | -- 2026-10-10 修复:添加白名单用户报 "Field 'benefit_type' doesn't have a default value" |
| | | -- 背景:白名单权益已下线(白名单用户充电完全按企业优惠结算),代码 validateSaveDTO 显式置空 |
| | | -- benefitType 且 createWhitelist 不再写入该字段,MyBatis-Plus NOT_NULL 策略下 insert 不带 |
| | | -- benefit_type 列;但表列仍为历史定义 tinyint NOT NULL 无默认值,MySQL 严格模式拒绝插入。 |
| | | -- 已在 dev 环境执行(192.168.110.74:3306 物理库;192.168.110.111:8066 / 221.182.45.100:8066 Mycat 代理已同步执行)。 |
| | | -- 正式/测试环境部署前需 DBA 在对应物理库执行以下语句。 |
| | | -- 幂等:列已可空时重复执行无害;历史数据保留原值不受影响。 |
| | | |
| | | ALTER TABLE `charging_pile_account`.`t_app_user_whitelist` |
| | | MODIFY COLUMN `benefit_type` tinyint DEFAULT NULL COMMENT '享受权益:1=全部免费,2=免电费,3=免服务费(已下线,白名单用户充电完全按企业优惠结算)'; |
| New file |
| | |
| | | -- ------------------------------------------ |
| | | -- 公司余额预警短信提醒记录表(按天去重) |
| | | -- 创建日期: 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`; |
| New file |
| | |
| | | -- 2026-10-10 修复:t_vin_management 主键丢失 AUTO_INCREMENT |
| | | -- 背景:20260903_complete_database_comments.sql 补注释时 MODIFY COLUMN id 漏带 AUTO_INCREMENT, |
| | | -- 实体 TVinManagement 的 id 为 IdType.AUTO(insert 不带 id 列),添加 VIN 车辆报 |
| | | -- "Field 'id' doesn't have a default value"。 |
| | | -- 已在 dev 环境执行(192.168.110.74:3306 物理库;192.168.110.111:8066 / 221.182.45.100:8066 Mycat 代理已同步执行)。 |
| | | -- 正式/测试环境部署前需 DBA 在对应物理库执行以下语句。 |
| | | -- 幂等:已自增时重复执行无害。 |
| | | |
| | | ALTER TABLE `charging_pile_account`.`t_vin_management` |
| | | MODIFY COLUMN `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID'; |