puzhibing
2026-09-28 ac5dac6c7c45e4c2ecf3cc7ccebac78d65ca9141
增加新功能和修改原有功能
1个文件已删除
1个文件已添加
8个文件已修改
321 ■■■■■ 已修改文件
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/ChargingCardAllocateDTO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/ChargingCardBalanceDTO.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TChargingCardController.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TChargingCardService.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TChargingCardServiceImpl.java 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/controller/TChargingCardControllerTest.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/TChargingCardServiceImplTest.java 119 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/20260926_统一执行清单.md 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/20260928_company_account_role.sql 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/20260928_delete_card_balance_menu.sql 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/ChargingCardAllocateDTO.java
@@ -21,7 +21,4 @@
    @ApiModelProperty(value = "备注(可选,写入卡片余额变更流水)")
    private String remark;
    @ApiModelProperty(value = "操作密码", required = true)
    private String operationPassword;
}
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/ChargingCardBalanceDTO.java
File was deleted
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TChargingCardController.java
@@ -3,7 +3,6 @@
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.ruoyi.account.api.dto.ChargingCardAllocateDTO;
import com.ruoyi.account.api.dto.ChargingCardAuthDTO;
import com.ruoyi.account.api.dto.ChargingCardBalanceDTO;
import com.ruoyi.account.api.dto.ChargingCardDeductDTO;
import com.ruoyi.account.api.dto.ChargingCardExcelDTO;
import com.ruoyi.account.api.dto.ChargingCardQueryDTO;
@@ -76,13 +75,6 @@
    @PutMapping("/status")
    public R<Boolean> status(@RequestBody ChargingCardStatusDTO dto) {
        return R.ok(chargingCardService.updateStatus(dto));
    }
    @ApiOperation(value = "批量更新卡片余额")
    @RequiresPermissions(value = {"/chargingCard/balance"}, logical = Logical.OR)
    @PutMapping("/balance")
    public R<Boolean> balance(@RequestBody ChargingCardBalanceDTO dto) {
        return R.ok(chargingCardService.updateBalance(dto));
    }
    @ApiOperation(value = "额度分配概览(公司总账户余额、可分配余额)")
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TChargingCardService.java
@@ -4,7 +4,6 @@
import com.baomidou.mybatisplus.extension.service.IService;
import com.ruoyi.account.api.dto.ChargingCardAllocateDTO;
import com.ruoyi.account.api.dto.ChargingCardAuthDTO;
import com.ruoyi.account.api.dto.ChargingCardBalanceDTO;
import com.ruoyi.account.api.dto.ChargingCardDeductDTO;
import com.ruoyi.account.api.dto.ChargingCardExcelDTO;
import com.ruoyi.account.api.dto.ChargingCardQueryDTO;
@@ -28,8 +27,6 @@
    boolean updateStatus(ChargingCardStatusDTO dto);
    boolean updateBalance(ChargingCardBalanceDTO dto);
    /**
     * 额度分配概览:公司可分配余额(available_balance)与总账户余额(累计充值到账)。
     * 仅分配模式(account_mode=2)的公司可执行额度分配。
@@ -44,7 +41,7 @@
     * 校验公司为启用且分配模式、卡片归属该公司且未在充电,禁止透支;
     * 每张卡片写入余额变更流水,失败时回滚并退还公司已扣金额。
     *
     * @param dto 分配参数(公司、卡片明细、备注、操作密码)
     * @param dto 分配参数(公司、卡片明细、备注)
     * @return 分配后的公司概览(可分配余额为最新值)
     */
    ChargingCardAllocateVO allocateQuota(ChargingCardAllocateDTO dto);
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TChargingCardServiceImpl.java
@@ -8,7 +8,6 @@
import com.ruoyi.account.api.dto.ChargingCardAllocateDTO;
import com.ruoyi.account.api.dto.ChargingCardAllocateItemDTO;
import com.ruoyi.account.api.dto.ChargingCardAuthDTO;
import com.ruoyi.account.api.dto.ChargingCardBalanceDTO;
import com.ruoyi.account.api.dto.ChargingCardDeductDTO;
import com.ruoyi.account.api.dto.ChargingCardExcelDTO;
import com.ruoyi.account.api.dto.ChargingCardQueryDTO;
@@ -22,14 +21,10 @@
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.security.service.TokenService;
import com.ruoyi.common.security.utils.SecurityUtils;
import com.ruoyi.other.api.domain.TFleet;
import com.ruoyi.other.api.domain.TGroupCompany;
import com.ruoyi.other.api.feignClient.GroupCompanyClient;
import com.ruoyi.system.api.domain.SysUser;
import com.ruoyi.system.api.feignClient.SysUserClient;
import com.ruoyi.system.api.model.LoginUser;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.slf4j.Logger;
@@ -64,7 +59,6 @@
    private static final int ENABLED = 1;
    private static final int DISABLED = 2;
    private static final int BALANCE_ADD = 1;
    private static final int BALANCE_SUBTRACT = 2;
    /** 余额变动类型:3=刷卡订单充电结算扣款(V0.2 定稿,订单扣款专用) */
    private static final int BALANCE_TYPE_ORDER_DEDUCT = 3;
    /** 充电锁定标记:1=充电中(业务锁定,管理操作互斥) */
@@ -87,12 +81,6 @@
    private static final long LOCK_WAIT_SECONDS = 5L;
    /** 锁自动释放时间(秒),兜住异常未解锁的场景 */
    private static final long LOCK_LEASE_SECONDS = 30L;
    @Autowired
    private TokenService tokenService;
    @Autowired
    private SysUserClient sysUserClient;
    @Autowired
    private TransactionTemplate transactionTemplate;
@@ -262,31 +250,6 @@
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean updateBalance(ChargingCardBalanceDTO dto) {
        validateBalanceRequest(dto);
        verifyOperationPassword(dto.getOperationPassword());
        List<TChargingCard> cards = listByIds(dto.getIds());
        if (cards.size() != new HashSet<>(dto.getIds()).size()) {
            throw new ServiceException("部分卡片不存在或已删除");
        }
        assertNotChargingLocked(cards, "调整余额");
        int updated = baseMapper.adjustBalance(dto.getIds(), dto.getAmount(), dto.getType(),
                MAX_AMOUNT, SecurityUtils.getUserId());
        if (updated != cards.size()) {
            throw new ServiceException(dto.getType() == BALANCE_SUBTRACT
                    ? "部分卡片正在充电中或余额不足,无法完成扣减"
                    : "部分卡片正在充电中或余额超过上限,无法完成增加");
        }
        for (TChargingCard card : cards) {
            baseMapper.insertBalanceRecord(card.getId(), dto.getType(), dto.getAmount(),
                    StringUtils.isEmpty(dto.getRemark()) ? null : dto.getRemark().trim(), SecurityUtils.getUserId());
        }
        return true;
    }
    @Override
    public ChargingCardAllocateVO getAllocateOverview(Integer companyId) {
        if (companyId == null) {
            throw new ServiceException("请选择公司");
@@ -302,7 +265,6 @@
    @Transactional(rollbackFor = Exception.class)
    public ChargingCardAllocateVO allocateQuota(ChargingCardAllocateDTO dto) {
        Map<Long, BigDecimal> amounts = validateAllocateRequest(dto);
        verifyOperationPassword(dto.getOperationPassword());
        Integer companyId = dto.getCompanyId();
        // 公司必须存在、启用且为分配模式(共享模式不支持额度分配)
        TGroupCompany company = loadAllocationCompany(companyId);
@@ -629,9 +591,6 @@
        if (dto.getItems().size() > MAX_ALLOCATE_CARD_COUNT) {
            throw new ServiceException("单次最多分配" + MAX_ALLOCATE_CARD_COUNT + "张卡片");
        }
        if (StringUtils.isEmpty(dto.getOperationPassword())) {
            throw new ServiceException("请输入操作密码");
        }
        if (dto.getRemark() != null && dto.getRemark().trim().length() > 200) {
            throw new ServiceException("备注不能超过200个字符");
        }
@@ -921,22 +880,6 @@
        return baseMapper.countByCardNo(cardNo, excludeId) > 0;
    }
    private void validateBalanceRequest(ChargingCardBalanceDTO dto) {
        if (dto == null || dto.getIds() == null || dto.getIds().isEmpty()) {
            throw new ServiceException("请选择需要更新余额的卡片");
        }
        if (dto.getType() == null || (dto.getType() != BALANCE_ADD && dto.getType() != BALANCE_SUBTRACT)) {
            throw new ServiceException("余额调整类型错误");
        }
        validateAmount(dto.getAmount(), false, "调整金额");
        if (dto.getRemark() != null && dto.getRemark().trim().length() > 255) {
            throw new ServiceException("备注不能超过255个字符");
        }
        if (StringUtils.isEmpty(dto.getOperationPassword())) {
            throw new ServiceException("请输入操作密码");
        }
    }
    private void validateDeductRequest(ChargingCardDeductDTO dto) {
        if (dto == null) {
            throw new ServiceException("扣款参数不能为空");
@@ -978,21 +921,6 @@
        }
        String prefix = row > 0 ? "第" + row + "行" : "";
        throw new ServiceException(prefix + "启用状态错误");
    }
    private void verifyOperationPassword(String operationPassword) {
        LoginUser loginUser = tokenService.getLoginUser();
        if (loginUser == null || loginUser.getUserid() == null) {
            throw new ServiceException("登录状态已失效,请重新登录");
        }
        R<SysUser> response = sysUserClient.getSysUser(loginUser.getUserid());
        SysUser user = response == null ? null : response.getData();
        if (user == null || StringUtils.isEmpty(user.getOperationPassword())) {
            throw new ServiceException("当前账号未设置操作密码");
        }
        if (!SecurityUtils.matchesPassword(operationPassword, user.getOperationPassword())) {
            throw new ServiceException("操作密码错误");
        }
    }
    private boolean isValidStatus(Integer status) {
ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/controller/TChargingCardControllerTest.java
@@ -2,7 +2,6 @@
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.api.dto.ChargingCardAuthDTO;
import com.ruoyi.account.api.dto.ChargingCardBalanceDTO;
import com.ruoyi.account.api.dto.ChargingCardDeductDTO;
import com.ruoyi.account.api.dto.ChargingCardExcelDTO;
import com.ruoyi.account.api.dto.ChargingCardQueryDTO;
@@ -57,27 +56,23 @@
    }
    @Test
    void addEditDeleteStatusAndBalanceDelegateToService() {
    void addEditDeleteAndStatusDelegateToService() {
        ChargingCardSaveDTO save = new ChargingCardSaveDTO();
        ChargingCardStatusDTO status = new ChargingCardStatusDTO();
        ChargingCardBalanceDTO balance = new ChargingCardBalanceDTO();
        when(service.saveCard(save)).thenReturn(true);
        when(service.updateCard(save)).thenReturn(true);
        when(service.deleteCards(Collections.singletonList(1L))).thenReturn(true);
        when(service.updateStatus(status)).thenReturn(true);
        when(service.updateBalance(balance)).thenReturn(true);
        assertTrue(controller.add(save).getData());
        assertTrue(controller.edit(save).getData());
        assertTrue(controller.delete(Collections.singletonList(1L)).getData());
        assertTrue(controller.status(status).getData());
        assertTrue(controller.balance(balance).getData());
        verify(service).saveCard(save);
        verify(service).updateCard(save);
        verify(service).deleteCards(Collections.singletonList(1L));
        verify(service).updateStatus(status);
        verify(service).updateBalance(balance);
    }
    @Test
ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/TChargingCardServiceImplTest.java
@@ -6,7 +6,6 @@
import com.ruoyi.account.api.dto.ChargingCardAllocateDTO;
import com.ruoyi.account.api.dto.ChargingCardAllocateItemDTO;
import com.ruoyi.account.api.dto.ChargingCardAuthDTO;
import com.ruoyi.account.api.dto.ChargingCardBalanceDTO;
import com.ruoyi.account.api.dto.ChargingCardDeductDTO;
import com.ruoyi.account.api.dto.ChargingCardExcelDTO;
import com.ruoyi.account.api.dto.ChargingCardSaveDTO;
@@ -18,14 +17,10 @@
import com.ruoyi.common.core.context.SecurityContextHolder;
import com.ruoyi.common.core.exception.ServiceException;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.security.service.TokenService;
import com.ruoyi.common.security.utils.SecurityUtils;
import com.ruoyi.other.api.domain.TFleet;
import com.ruoyi.other.api.domain.TGroupCompany;
import com.ruoyi.other.api.feignClient.GroupCompanyClient;
import com.ruoyi.system.api.domain.SysUser;
import com.ruoyi.system.api.feignClient.SysUserClient;
import com.ruoyi.system.api.model.LoginUser;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -67,8 +62,6 @@
class TChargingCardServiceImplTest {
    @Mock private TChargingCardMapper mapper;
    @Mock private TokenService tokenService;
    @Mock private SysUserClient sysUserClient;
    @Mock private TransactionTemplate transactionTemplate;
    @Mock private GroupCompanyClient groupCompanyClient;
    @Mock private RedissonClient redissonClient;
@@ -79,8 +72,6 @@
    void setUp() throws InterruptedException {
        service = spy(new TChargingCardServiceImpl());
        ReflectionTestUtils.setField(service, "baseMapper", mapper);
        ReflectionTestUtils.setField(service, "tokenService", tokenService);
        ReflectionTestUtils.setField(service, "sysUserClient", sysUserClient);
        ReflectionTestUtils.setField(service, "transactionTemplate", transactionTemplate);
        ReflectionTestUtils.setField(service, "groupCompanyClient", groupCompanyClient);
        ReflectionTestUtils.setField(service, "redissonClient", redissonClient);
@@ -183,47 +174,6 @@
        ServiceException exception = assertThrows(ServiceException.class, () -> service.updateStatus(dto));
        assertEquals("卡片状态参数错误", exception.getMessage());
    }
    @Test
    void updateBalanceAdjustsAndRecordsForAllCards() {
        LoginUser loginUser = new LoginUser();
        loginUser.setUserid(123L);
        SysUser user = new SysUser();
        user.setOperationPassword(com.ruoyi.common.security.utils.SecurityUtils.encryptPassword("pwd"));
        when(tokenService.getLoginUser()).thenReturn(loginUser);
        when(sysUserClient.getSysUser(123L)).thenReturn(R.ok(user));
        TChargingCard first = new TChargingCard();
        first.setId(1L);
        TChargingCard second = new TChargingCard();
        second.setId(2L);
        when(mapper.selectBatchIds(anyList())).thenReturn(Arrays.asList(first, second));
        when(mapper.adjustBalance(anyList(), any(BigDecimal.class), any(Integer.class), any(BigDecimal.class), any(Long.class)))
                .thenReturn(2);
        ChargingCardBalanceDTO dto = new ChargingCardBalanceDTO();
        dto.setIds(Arrays.asList(1L, 2L));
        dto.setType(1);
        dto.setAmount(new BigDecimal("10.00"));
        dto.setOperationPassword("pwd");
        dto.setRemark(" test ");
        assertTrue(service.updateBalance(dto));
        verify(mapper).insertBalanceRecord(1L, 1, new BigDecimal("10.00"), "test", 123L);
        verify(mapper).insertBalanceRecord(2L, 1, new BigDecimal("10.00"), "test", 123L);
    }
    @Test
    void updateBalanceRejectsMissingPasswordBeforeDatabaseUpdate() {
        ChargingCardBalanceDTO dto = new ChargingCardBalanceDTO();
        dto.setIds(Collections.singletonList(1L));
        dto.setType(1);
        dto.setAmount(BigDecimal.ONE);
        ServiceException exception = assertThrows(ServiceException.class, () -> service.updateBalance(dto));
        assertEquals("请输入操作密码", exception.getMessage());
        verify(mapper, never()).adjustBalance(anyList(), any(), any(), any(), any());
    }
    @Test
@@ -372,31 +322,6 @@
        assertTrue(exception.getMessage().contains("正在充电中"));
        verify(mapper, never()).updateById(any());
    }
    @Test
    void updateBalanceRejectsCardInChargingLock() {
        LoginUser loginUser = new LoginUser();
        loginUser.setUserid(123L);
        SysUser user = new SysUser();
        user.setOperationPassword(SecurityUtils.encryptPassword("pwd"));
        when(tokenService.getLoginUser()).thenReturn(loginUser);
        when(sysUserClient.getSysUser(123L)).thenReturn(R.ok(user));
        TChargingCard locked = new TChargingCard();
        locked.setId(1L);
        locked.setCardNo("CARD-1");
        locked.setChargingFlag(1);
        when(mapper.selectBatchIds(anyList())).thenReturn(Collections.singletonList(locked));
        ChargingCardBalanceDTO dto = new ChargingCardBalanceDTO();
        dto.setIds(Collections.singletonList(1L));
        dto.setType(2);
        dto.setAmount(new BigDecimal("10.00"));
        dto.setOperationPassword("pwd");
        ServiceException exception = assertThrows(ServiceException.class, () -> service.updateBalance(dto));
        assertTrue(exception.getMessage().contains("正在充电中"));
        verify(mapper, never()).adjustBalance(anyList(), any(), any(), any(), any());
    }
    // ==================== 卡片所属公司/车队 + 初始分配额度 ====================
@@ -853,7 +778,6 @@
    @Test
    void allocateQuotaAddsCardBalanceAndDeductsCompanyBalance() {
        mockOperationPassword();
        when(groupCompanyClient.getCompanyById(9)).thenReturn(R.ok(allocationCompany(9, "测试公司")));
        TChargingCard first = allocateCard(1L, 9, "CARD-1", "10.00");
        TChargingCard second = allocateCard(2L, 9, "CARD-2", "20.00");
@@ -864,7 +788,7 @@
                .thenReturn(1);
        when(groupCompanyClient.deductBalance(9, new BigDecimal("30.00"))).thenReturn(R.ok(new BigDecimal("970.00")));
        ChargingCardAllocateVO vo = service.allocateQuota(allocateDto(9, "pwd",
        ChargingCardAllocateVO vo = service.allocateQuota(allocateDto(9,
                allocateItem(1L, "10.00"), allocateItem(2L, "20.00")));
        assertEquals(new BigDecimal("970.00"), vo.getAvailableBalance());
@@ -877,13 +801,12 @@
    @Test
    void allocateQuotaRejectsSharedModeCompany() {
        mockOperationPassword();
        TGroupCompany shared = allocationCompany(9, "共享公司");
        shared.setAccountMode(1);
        when(groupCompanyClient.getCompanyById(9)).thenReturn(R.ok(shared));
        ServiceException exception = assertThrows(ServiceException.class,
                () -> service.allocateQuota(allocateDto(9, "pwd", allocateItem(1L, "10.00"))));
                () -> service.allocateQuota(allocateDto(9, allocateItem(1L, "10.00"))));
        assertEquals("该公司为共享模式,不支持额度分配", exception.getMessage());
        verify(groupCompanyClient, never()).deductBalance(any(Integer.class), any(BigDecimal.class));
@@ -891,13 +814,12 @@
    @Test
    void allocateQuotaRejectsCardOfAnotherCompany() {
        mockOperationPassword();
        when(groupCompanyClient.getCompanyById(9)).thenReturn(R.ok(allocationCompany(9, "测试公司")));
        TChargingCard foreign = allocateCard(1L, 8, "CARD-1", "10.00");
        doReturn(Collections.singletonList(foreign)).when(service).listByIds(anyCollection());
        ServiceException exception = assertThrows(ServiceException.class,
                () -> service.allocateQuota(allocateDto(9, "pwd", allocateItem(1L, "10.00"))));
                () -> service.allocateQuota(allocateDto(9, allocateItem(1L, "10.00"))));
        assertTrue(exception.getMessage().contains("不属于所选公司"));
        verify(groupCompanyClient, never()).deductBalance(any(Integer.class), any(BigDecimal.class));
@@ -905,7 +827,6 @@
    @Test
    void allocateQuotaRefundsCompanyBalanceWhenCardUpdateFails() {
        mockOperationPassword();
        when(groupCompanyClient.getCompanyById(9)).thenReturn(R.ok(allocationCompany(9, "测试公司")));
        TChargingCard card = allocateCard(1L, 9, "CARD-1", "10.00");
        doReturn(Collections.singletonList(card)).when(service).listByIds(anyCollection());
@@ -915,7 +836,7 @@
                any(Long.class))).thenReturn(0);
        ServiceException exception = assertThrows(ServiceException.class,
                () -> service.allocateQuota(allocateDto(9, "pwd", allocateItem(1L, "10.00"))));
                () -> service.allocateQuota(allocateDto(9, allocateItem(1L, "10.00"))));
        assertTrue(exception.getMessage().contains("余额分配失败"));
        verify(groupCompanyClient).refundBalance(9, new BigDecimal("10.00"));
@@ -923,14 +844,13 @@
    @Test
    void allocateQuotaReportsInsufficientCompanyBalance() {
        mockOperationPassword();
        when(groupCompanyClient.getCompanyById(9)).thenReturn(R.ok(allocationCompany(9, "测试公司")));
        TChargingCard card = allocateCard(1L, 9, "CARD-1", "0.00");
        doReturn(Collections.singletonList(card)).when(service).listByIds(anyCollection());
        when(groupCompanyClient.deductBalance(9, new BigDecimal("500.00"))).thenReturn(R.fail("公司可用余额不足"));
        ServiceException exception = assertThrows(ServiceException.class,
                () -> service.allocateQuota(allocateDto(9, "pwd", allocateItem(1L, "500.00"))));
                () -> service.allocateQuota(allocateDto(9, allocateItem(1L, "500.00"))));
        assertEquals("公司可分配余额不足,无法完成本次分配", exception.getMessage());
        verify(mapper, never()).adjustBalance(anyList(), any(BigDecimal.class), any(Integer.class),
@@ -940,7 +860,7 @@
    @Test
    void allocateQuotaRejectsInvalidAmount() {
        ServiceException exception = assertThrows(ServiceException.class,
                () -> service.allocateQuota(allocateDto(9, "pwd", allocateItem(1L, "0"))));
                () -> service.allocateQuota(allocateDto(9, allocateItem(1L, "0"))));
        assertEquals("分配金额必须大于0", exception.getMessage());
        verify(groupCompanyClient, never()).getCompanyById(any(Integer.class));
@@ -949,43 +869,23 @@
    @Test
    void allocateQuotaRejectsDuplicateCards() {
        ServiceException exception = assertThrows(ServiceException.class,
                () -> service.allocateQuota(allocateDto(9, "pwd", allocateItem(1L, "10.00"), allocateItem(1L, "5.00"))));
                () -> service.allocateQuota(allocateDto(9, allocateItem(1L, "10.00"), allocateItem(1L, "5.00"))));
        assertEquals("分配明细中存在重复卡片", exception.getMessage());
    }
    @Test
    void allocateQuotaRejectsMissingPassword() {
        ServiceException exception = assertThrows(ServiceException.class,
                () -> service.allocateQuota(allocateDto(9, null, allocateItem(1L, "10.00"))));
        assertEquals("请输入操作密码", exception.getMessage());
        verify(mapper, never()).adjustBalance(anyList(), any(BigDecimal.class), any(Integer.class),
                any(BigDecimal.class), any(Long.class));
    }
    @Test
    void allocateQuotaRejectsChargingLockedCard() {
        mockOperationPassword();
        when(groupCompanyClient.getCompanyById(9)).thenReturn(R.ok(allocationCompany(9, "测试公司")));
        TChargingCard card = allocateCard(1L, 9, "CARD-1", "10.00");
        card.setChargingFlag(1);
        doReturn(Collections.singletonList(card)).when(service).listByIds(anyCollection());
        ServiceException exception = assertThrows(ServiceException.class,
                () -> service.allocateQuota(allocateDto(9, "pwd", allocateItem(1L, "10.00"))));
                () -> service.allocateQuota(allocateDto(9, allocateItem(1L, "10.00"))));
        assertTrue(exception.getMessage().contains("充电中"));
        verify(groupCompanyClient, never()).deductBalance(any(Integer.class), any(BigDecimal.class));
    }
    private void mockOperationPassword() {
        LoginUser loginUser = new LoginUser();
        loginUser.setUserid(123L);
        SysUser user = new SysUser();
        user.setOperationPassword(SecurityUtils.encryptPassword("pwd"));
        when(tokenService.getLoginUser()).thenReturn(loginUser);
        when(sysUserClient.getSysUser(123L)).thenReturn(R.ok(user));
    }
    private TChargingCard allocateCard(Long id, Integer companyId, String cardNo, String balance) {
@@ -1006,11 +906,10 @@
        return item;
    }
    private ChargingCardAllocateDTO allocateDto(Integer companyId, String password, ChargingCardAllocateItemDTO... items) {
    private ChargingCardAllocateDTO allocateDto(Integer companyId, ChargingCardAllocateItemDTO... items) {
        ChargingCardAllocateDTO dto = new ChargingCardAllocateDTO();
        dto.setCompanyId(companyId);
        dto.setItems(Arrays.asList(items));
        dto.setOperationPassword(password);
        return dto;
    }
}
sql/20260926_统一执行清单.md
@@ -97,3 +97,21 @@
- 幂等性:`ALTER TABLE ... MODIFY COLUMN` 语句本身可重复执行,本次连续执行两次均 EXIT=0、结构不变
- 代码侧配套(同批改动):后端 save 在共享模式忽略该字段并落库 NULL、分配模式仍必填;detail 在共享模式不回显;前端弹窗共享模式隐藏该输入项且提交 null
- 注意:`TCompanyBalanceWarning.subWarnAmount` 已加 `@TableField(updateStrategy = FieldStrategy.IGNORED)`,否则 MyBatis-Plus 默认策略会忽略 null,共享模式切换后旧子账户预警线不会被清空(该列可空是此策略生效的前提)
## 九、追加执行:公司登录账号/密码字段 + 内置公司角色(2026-09-28 11:06 ~ 11:16,精确)
> 背景:`TGroupCompany` 实体(ruoyi-api-other)新增 `account`/`password` 字段后,公司列表报 `Unknown column 'account' in 'field list'`(`TGroupCompanyMapper` 全字段 SELECT)。
>
> 脚本 `20260928_company_account_role.sql` 此前未生效,两层原因:① 原脚本用 `SET @变量 := (SELECT ...)` + `PREPARE/EXECUTE` 条件化 DDL,Mycat 2.0 不支持用户变量 → 静默失效(与 20260901 VIN 脚本同坑);② 脚本缺 `USE` 选库语句,`-e "source ..."` 直接报 `ERROR 1105 (HY000): please use schema`。
| 脚本 | 结果 |
|---|---|
| `20260928_company_account_role.sql`(本次改造为 Mycat 兼容) | **已执行生效**:`t_group_company` 补 `account`/`password` 列;`sys_role` 新增 `role_id=74`「公司/company」(data_scope=5);`sys_user.roleType` 注释更新为含 `5=公司` |
- 改造内容:第 1 部分删除 `SET @变量`/`PREPARE`,改为两条直接 `ALTER TABLE ... ADD COLUMN ... AFTER`(非幂等,重复执行报 `Duplicate column name` 属预期,脚本头已注明);文件开头补 `USE charging_pile_other;` 语句;第 2、3 部分(`sys_role` 幂等 `INSERT ... WHERE NOT EXISTS` / `UPDATE`、`sys_user` MODIFY 注释)写法保持,均无用户变量。
- 执行环境:`221.182.45.100:8066`(Mycat 2.0,`--default-character-set=utf8mb4`,客户端指定默认库 `charging_pile_other`,脚本复制为 `C:\...\Temp\company_account_role.sql` 后 `-e "source ..."`,EXIT=0)
- 回查证据:`information_schema.COLUMNS` 显示 `account varchar(255)` / `password varchar(255)` 位于 `contact_phone` 之后、中文注释无乱码;`sys_role` 新增行 `role_id=74, role_name=公司, role_key=company, data_scope=5, status=0, del_flag=0`;`sys_user.roleType` 注释=「角色类型 1=平台 2=合作商 3=门店 4=修理厂 5=公司」;**原报错 SQL 原样重放成功**(`SELECT ... account,password ... FROM t_group_company WHERE del_flag=0 ORDER BY id DESC LIMIT 5` 返回 1 行公司数据)
- 幂等性:`sys_role`/`sys_user` 段抽取为独立脚本连续执行两次,均 EXIT=0 且 `role_key='company'` 行数恒为 1(全表角色 21 行,无重复插入);`ALTER` 段以 `--force` 重跑报 `Duplicate column name 'account'`(Mycat 遇错即停后续语句,未产生副作用)
- 回归测试:后端 `mvn -B -pl ruoyi-service/ruoyi-other -am test-compile` SUCCESS(18 个测试源文件编译通过);surefire 2.22.2 定向 `TGroupCompanyServiceImplTest` **6/6 通过**(0 失败 0 错误)
- 代码侧配套(无需改动):`TGroupCompanyServiceImpl` 新增/编辑/删除公司与 `sys_user`(roleType=5、role_key=company)的同步逻辑、前端公司页「登录账号」必填项此前已实现,本次仅补齐数据库结构。
- 待办提醒:现有 1 行公司(喜望公司)`account`/`password` 为 NULL;前端编辑公司时「登录账号」为只读禁用态,补录账号需业务侧确认放开前端或后台补数据;`sys_role` 新角色的菜单权限需在「角色管理」中配置(脚本不涉及)。
sql/20260928_company_account_role.sql
@@ -3,47 +3,20 @@
-- 创建日期: 2026-09-28
-- 说明:
--   1. t_group_company 新增 account/password(存储前端 HMAC-MD5 加密后的登录密码)
--      注意:Mycat 2.0 不支持 SET @变量/PREPARE 条件化 DDL(会静默失效),故此处直接 ALTER;
--      执行前请先只读确认列不存在(information_schema.COLUMNS),重复执行报 Duplicate column name 属预期
--   2. sys_role 幂等补充内置角色 role_key=company(所有公司账号共享,菜单在角色管理配置)
--   3. sys_user.roleType 注释统一为 1=平台 2=合作商 3=门店 4=修理厂 5=公司
-- 库: charging_pile_other
-- ------------------------------------------
USE `charging_pile_other`;
-- 1. t_group_company 增加登录账号/密码字段(幂等)
SET @gc_account_exists := (
    SELECT COUNT(1)
    FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE()
      AND TABLE_NAME = 't_group_company'
      AND COLUMN_NAME = 'account'
);
-- 1. t_group_company 增加登录账号/密码字段(已只读确认当前库缺列;Mycat 2.0 不支持条件化 DDL)
ALTER TABLE `t_group_company`
    ADD COLUMN `account` varchar(255) NULL DEFAULT NULL COMMENT '登录账号' AFTER `contact_phone`;
SET @gc_account_ddl := IF(
    @gc_account_exists = 0,
    'ALTER TABLE `t_group_company` ADD COLUMN `account` varchar(255) NULL DEFAULT NULL COMMENT ''登录账号'' AFTER `contact_phone`',
    'SELECT 1'
);
PREPARE gc_account_stmt FROM @gc_account_ddl;
EXECUTE gc_account_stmt;
DEALLOCATE PREPARE gc_account_stmt;
SET @gc_password_exists := (
    SELECT COUNT(1)
    FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE()
      AND TABLE_NAME = 't_group_company'
      AND COLUMN_NAME = 'password'
);
SET @gc_password_ddl := IF(
    @gc_password_exists = 0,
    'ALTER TABLE `t_group_company` ADD COLUMN `password` varchar(255) NULL DEFAULT NULL COMMENT ''登录密码(前端HMAC-MD5加密值)'' AFTER `account`',
    'SELECT 1'
);
PREPARE gc_password_stmt FROM @gc_password_ddl;
EXECUTE gc_password_stmt;
DEALLOCATE PREPARE gc_password_stmt;
ALTER TABLE `t_group_company`
    ADD COLUMN `password` varchar(255) NULL DEFAULT NULL COMMENT '登录密码(前端HMAC-MD5加密值)' AFTER `account`;
-- 2. 内置公司角色(幂等)
INSERT INTO sys_role (role_name, role_key, data_scope, status, del_flag, create_by, create_time, remark)
sql/20260928_delete_card_balance_menu.sql
New file
@@ -0,0 +1,15 @@
-- =============================================================
-- 2026-09-28 删除卡片管理-批量修改余额按钮权限
-- 目标库:charging_pile_account
-- 说明:批量修改余额功能下线(前端按钮 + 后端 /balance 接口已删除),
--       幂等删除对应菜单按钮及角色关联。
-- =============================================================
-- 1. 先删除角色与菜单的关联(仅限该按钮)
DELETE srm
FROM sys_role_menu srm
JOIN sys_menu m ON srm.menu_id = m.menu_id
WHERE m.perms = '/chargingCard/balance';
-- 2. 再删除菜单按钮本身(幂等:不存在则影响 0 行)
DELETE FROM sys_menu WHERE perms = '/chargingCard/balance';