puzhibing
2026-09-30 67d25f3d9eb0124c7aab544bf8f77031d6d8adc3
增加公司账号权限功能
4个文件已添加
18个文件已修改
1304 ■■■■■ 已修改文件
doc/耗时统计.md 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TCardChargingOrder.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-modules/ruoyi-system/src/main/java/com/ruoyi/system/controller/SysRoleController.java 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-modules/ruoyi-system/src/test/java/com/ruoyi/system/controller/SysRoleControllerTest.java 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/PartnerAppUserAccessService.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TAppUserWhitelistServiceImpl.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TChargingCardServiceImpl.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TVinManagementServiceImpl.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/TAppUserWhitelistServiceImplTest.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/TChargingCardServiceImplTest.java 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/TVinManagementServiceImplTest.java 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TCardChargingOrderServiceImpl.java 152 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderServiceImpl.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/support/ChargeAccountSupport.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TCardChargingOrderAmountFieldsTest.java 245 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TVinChargingOrderAuthorizeStopTest.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/controller/TFleetController.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/CompanyAccountAccessService.java 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TFleetServiceImpl.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImpl.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/20260930_card_order_lock_released.sql 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/20260930_company_admin_role_menu.sql 168 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/耗时统计.md
@@ -22,3 +22,4 @@
| 2026-09-28 18:07:00(估算,授权后执行阶段;含授权前方案分析与两轮确认约 19:xx 起) | 2026-09-28 18:40:44 | 约 2024(估算,授权后执行约 34 分钟;含授权前方案分析与两轮确认问答总计约 55 分钟) | 分析代码,将VIN码、卡片、白名单全部都舍弃init_quota字段,统一使用balance字段表示余额,额度分配和初始额度也是修改balance字段 | 是:先只读盘点 init_quota 全部 40 处引用(模型/DTO/Mapper/Service/前端页面/历史 SQL),输出三表统一方案(VIN/白名单 CHANGE 列名保留数据、卡片 DROP 列丢弃、前端仅改字段不改文案),经两轮确认问答(卡片历史数据直接丢弃、文案保留原表述、VIN 与白名单一同修改)后获「开始执行」授权 | 三模块舍弃 init_quota 统一 balance:VIN/白名单模型 DTO 改名 balance,卡片模型/DTO 删除 initQuota 复用既有 balance;额度分配与初始额度写库改为 balance 列;卡片编辑仅传正数余额才更新 balance(未传保持原余额);新建统一 DDL 脚本并追加执行清单第十一节 | 服务端 20 个文件(3 模型+3 DTO、TVinManagementMapper.java、三个 Mapper XML、三个 ServiceImpl、TGroupCompanyServiceImpl、三个测试类、sql/20260928_unify_quota_to_balance.sql 新建、sql/20260926_统一执行清单.md、doc/耗时统计.md);管理后台 6 个文件(vin/index.jsx、vin/AllocateModal.jsx、card/index.jsx、person/whitelist/index.jsx、whitelist/AddAndEdit.jsx、fleet/index.jsx) | 后端 `mvn -pl ruoyi-api-account,ruoyi-account,ruoyi-other -am compile` BUILD SUCCESS;surefire 2.22.2 跑 TChargingCardServiceImplTest 50/50、TVinManagementServiceImplTest 48/48、TAppUserWhitelistServiceImplTest 1/1、TFleetServiceImplTest 6/6、TGroupCompanyServiceImplTest 11/11 全部通过;前端删 dist 后 build 成功、`npx tsc --noEmit` 退出码 0;IDE lint 0 错误 | 首轮单测失败 1 条:updateCardRequiresExistingCardAndDoesNotChangeBalance 因编辑时 dto.balance=999.99 触发「请选择所属公司后再填写初始分配额度」,按新语义修正测试(编辑未传 balance 余额保持)并加固主代码(balance 仅正数才写列,避免共享模式清空余额);统一 DDL(sql/20260928_unify_quota_to_balance.sql)尚未在任何库执行,发版前必须先执行;前端工作区另有 company/index.jsx、global.less 公司树选中样式改动(非本任务,未纳入) |
| 2026-09-29(开始时间未记录,估算) | 2026-09-29 10:52:50 | 未记录(估算约 5400 秒,跨会话续作) | 充电时间超过授权时间主动停止充电的判断最好提前1分钟校验,避免数据传输延迟导致的导出实现(续作:VIN 码充电 5 项需求落码) | 是:先只读分析并输出修改方案(含提前量常量、边界保护口径),用户确认「开始执行」后落码 | 完成 VIN 码充电 5 项需求:①余额按企业账户模式(共享锁企业/分配锁 VIN)实扣冻结;②授权站点与时段(含指定电价阶段)校验 + 授权到期提前 60 秒主动停充;③按企业优惠设置(服务费比例/计费模板单价)优惠计费并落优惠金额;④结算解锁扣费退回 + 分配记录消费流水(按订单幂等);⑤补偿/关单路径统一释放锁定;前端分配记录新增「类型」列;同步删除 IoT 下发硬编码余额 100 | 服务端 22 个:ruoyi-api-order/TVinChargingOrder(5 新列)、TVinChargingOrderAccountingStrategy(3 新列)、ruoyi-api-account/TCompanyAllocateRecord(change_type/charging_order_code/USER_TYPE_COMPANY)、TGroupCompany、TVinManagement、4 个新 Feign 客户端与 fallback、3 个 inner 接口控制器,ruoyi-service/ruoyi-order/TVinChargingOrderServiceImpl(授权校验/锁定/停充/优惠结算/流水)、ruoyi-service/ruoyi-integration/IotMessageListener、sql/20260929_vin_charge_lock_{other,account,order}.sql;测试类 TVinChargingOrderCompanyFleetTest;管理后台 2 个:allocateRecord.js、AllocateRecordModal.jsx 与其单测 | 后端 order 192/192、account 159/159、integration 2/2 通过(surefire 2.22.2);other 128 条中 4 条、chargingPile 40 条中 1 条为历史失败(Emind/EnergyReport/导出列顺序,工作区未改相关实现,非本次引入);前端 Jest 13/13;三方测试库执行 3 个 DDL 脚本并回查 12 项列全部存在、中文注释无乱码、分片表下推查询通过 | 开始时间因跨会话续作未记录,耗时按估算标注;测试库(内网 111:8066 / 外网 221.182.45.100:8066 同库)已执行 DDL,其他环境发版前须先执行三个 SQL 脚本 |
| 2026-09-29 19:22:00(估算) | 2026-09-29 19:47:00(估算) | 约 1500(估算) | VIN码管理页面字段与筛选改造(前一会话续作:补充「导入模板也更新,添加和编辑去掉备注字段」) | 是:先只读复核 DTO/Mapper/Service/前端与三个测试类,输出含字段矩阵、影响面、回归方案的完整方案,用户确认「开始执行」后落码 | ①列表新增账户模式/可用余额/超额/账户状态,移除初始分配额度与备注列,手机号改「联系人手机号」且取消筛选,启用状态改「启停状态」;②新增创建时间区间筛选(beginTime/endTime,闭区间);③行内新增解绑、工具栏顺序调整为添加车辆/批量解绑/额度分配/导入/导出/删除;④导出改 10 列并回填车队名与账户信息;⑤导入模板改 5 列(去掉备注);⑥添加/编辑弹窗去掉备注,后端不再接收/写入 remark(编辑不再清空历史备注),DB 列保留无 DDL | 服务端 7 个:ruoyi-api-account/VinManagementQueryDTO、VinManagementExcelDTO、VinManagementSaveDTO、TVinManagement;ruoyi-account/TVinManagementMapper.xml、TVinManagementServiceImpl;测试 3 个:TVinManagementServiceImplTest、VinManagementMapperStructureTest、TVinManagementControllerTest;管理后台 4 个:pages/vin/index.jsx、queryParams.js(新增)、queryParams.test.js(新增)、components/ImportModal.jsx | 后端 surefire 2.22.2:VIN 三个测试类 70/70(controller 15、mapper 2、service 53)、account 模块全量 172/172 通过;依赖模块 ruoyi-other、ruoyi-order test-compile BUILD SUCCESS;前端 Jest 7/7、`npm run tsc` 退出码 0、@babel/parser 语法校验 5 个改动文件全部通过;IDE 诊断 0 错误 | 开始时间未精确记录,按估算标注;未执行真实数据库与页面联调,功能验证为代码走查+编译;旧模板(含手机号/备注/启用状态列头)只能识别 VIN码/车牌号/所属公司,需用新模板;导入仍按历史逻辑只写公司名称文本、不关联公司ID,账户类列为空;接口 phone 查询条件保留兼容
| 2026-09-30 09:35:00(估算) | 2026-09-30 10:05:00(估算) | 约 1800(估算) | 刷卡充电 4 项期望改造:点2 账户结算与锁定额度释放幂等、点5 无计费快照禁止建单、点7 授权停充提前量固定 60 秒、点8 变动流水归位(口径A) | 是:先只读定位 4 项精确落点并完成三项决策确认,输出含改动清单、DDL、风险与回归方案的完整方案,用户确认「开始执行」后落码 | ①共享模式结算传订单号启用账户侧幂等(账户侧按订单号+变更类型写企业消费流水),订单侧不再代写共享流水,分配模式与历史卡扣款仍记卡账户;②补偿扫描改卡侧+企业侧流水双判据,任一侧查询失败即不回退(避免重复扣款);③订单表新增 lock_released,释放锁定额度先条件更新抢占标记,失败回滚标记供重试;④建单先写计费策略快照,无策略/快照异常回滚删单并回告启动失败(fail(null));⑤授权停充提前量由 min(60, 授权时长/2) 改为 min(60, 授权时长),刷卡/VIN/白名单三链路统一 | 服务端 4 个:ruoyi-api-order/TCardChargingOrder(新增 lock_released)、ruoyi-order/TCardChargingOrderServiceImpl(建单顺序、解锁幂等、结算流水、补偿双判据)、ruoyi-order/ChargeAccountSupport(提前量口径、已结算订单号查询)、ruoyi-order/TVinChargingOrderServiceImpl(注释口径同步);SQL 1 个:sql/20260930_card_order_lock_released.sql(分片表 DDL,未执行);测试 2 个:TCardChargingOrderAmountFieldsTest(新增 6 用例并补建单快照 mock)、TVinChargingOrderAuthorizeStopTest(提前量新口径) | 后端 surefire 2.22.2(先 test-compile 再 surefire):受影响 5 个测试类 68/68 通过;ruoyi-order 模块全量 231/231 通过;ruoyi-api-order + ruoyi-order 编译通过;IDE 诊断 0 错误 | DDL 未在测试库执行(待授权,脚本已备好);白名单链路结算流水仍由订单侧写入、未纳入本次口径A范围;建议项8(公司余额更新与流水写入强一致)未纳入;开始与结束时间未精确记录,按估算标注 |
ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TCardChargingOrder.java
@@ -51,4 +51,8 @@
    @ApiModelProperty("优惠服务费合计(原始服务费-最终服务费)")
    @TableField("discount_service_charge")
    private BigDecimal discountServiceCharge;
    @ApiModelProperty("锁定额度是否已释放(0=未释放,1=已释放;建单回滚/关单路径幂等标记,防重复释放)")
    @TableField("lock_released")
    private Integer lockReleased;
}
ruoyi-modules/ruoyi-system/src/main/java/com/ruoyi/system/controller/SysRoleController.java
@@ -3,8 +3,10 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import javax.annotation.Resource;
@@ -71,6 +73,8 @@
    private static final String SERVICE_PROVIDER_ROLE_KEY = "service_provider";
    private static final String COMPANY_ROLE_NAME = "公司";
    private static final String COMPANY_ROLE_KEY = "company";
    private static final String COMPANY_ADMIN_ROLE_NAME = "公司管理员";
    private static final String COMPANY_ADMIN_ROLE_KEY = "company_admin";
    private static final String OWN_SITE_NAME = "自有电站";
    private static final String SYSTEM_ROLE_REMARK = "系统内置角色";
@@ -101,10 +105,15 @@
        ensureSystemRole(INDEPENDENT_PARTNER_ROLE_NAME, INDEPENDENT_PARTNER_ROLE_KEY, getIndependentPartnerMenuIds());
        ensureSystemRole(SITE_OWNER_ROLE_NAME, SITE_OWNER_ROLE_KEY, new HashSet<>());
        ensureSystemRole(SERVICE_PROVIDER_ROLE_NAME, SERVICE_PROVIDER_ROLE_KEY, new HashSet<>());
        ensureSystemRole(COMPANY_ROLE_NAME, COMPANY_ROLE_KEY, new HashSet<>());
        // 公司管理员角色:默认拥有车队管理、VIN码管理、卡片管理、白名单用户菜单,权限配置不可修改
        ensureSystemRole(COMPANY_ADMIN_ROLE_NAME, COMPANY_ADMIN_ROLE_KEY, getCompanyAdminMenuIds(), true);
    }
    private void ensureSystemRole(String roleName, String roleKey, Set<Long> menuIds) {
        ensureSystemRole(roleName, roleKey, menuIds, false);
    }
    private void ensureSystemRole(String roleName, String roleKey, Set<Long> menuIds, boolean repairMenus) {
        SysRole role = roleService.getOne(new LambdaQueryWrapper<SysRole>()
                .eq(SysRole::getRoleKey, roleKey)
                .eq(SysRole::getDelFlag, 0)
@@ -126,6 +135,9 @@
                role.setUpdateTime(new Date());
                roleService.updateById(role);
            }
            if (repairMenus) {
                repairRoleMenus(role.getRoleId(), menuIds);
            }
            return;
        }
@@ -140,6 +152,32 @@
        newRole.setCreateTime(new Date());
        newRole.setMenuIds(menuIds.toArray(new Long[0]));
        roleService.insertRole(newRole);
    }
    /**
     * 幂等补齐内置角色的菜单绑定(只补不删),保证公司管理员角色的默认菜单随版本补齐
     */
    private void repairRoleMenus(Long roleId, Set<Long> menuIds) {
        if (roleId == null || menuIds == null || menuIds.isEmpty()) {
            return;
        }
        List<Long> boundMenuIds = sysRoleMenuMapper.selectList(new LambdaQueryWrapper<SysRoleMenu>()
                        .eq(SysRoleMenu::getRoleId, roleId))
                .stream()
                .map(SysRoleMenu::getMenuId)
                .collect(Collectors.toList());
        List<SysRoleMenu> toInsert = menuIds.stream()
                .filter(menuId -> !boundMenuIds.contains(menuId))
                .map(menuId -> {
                    SysRoleMenu roleMenu = new SysRoleMenu();
                    roleMenu.setRoleId(roleId);
                    roleMenu.setMenuId(menuId);
                    return roleMenu;
                })
                .collect(Collectors.toList());
        if (!toInsert.isEmpty()) {
            sysRoleMenuMapper.batchRoleMenu(toInsert);
        }
    }
    private Set<Long> getIndependentPartnerMenuIds() {
@@ -172,6 +210,43 @@
        }
    }
    /**
     * 公司管理员默认菜单:车队管理、VIN码管理、卡片管理(不含刷卡充电订单/卡片收支明细)、白名单用户及其操作按钮
     */
    private Set<Long> getCompanyAdminMenuIds() {
        List<SysMenus> menus = menuMapper.getAll();
        Set<Long> menuIds = new HashSet<>();
        Map<Long, Long> parentMap = new HashMap<>();
        for (SysMenus menu : menus) {
            parentMap.put(menu.getMenuId(), menu.getParentId());
            String path = normalizeMenuPath(menu.getPath());
            String perms = menu.getPerms() == null ? "" : menu.getPerms();
            // 卡片模块排除收支明细子页(/chargingCardBalanceDetail 同以 /chargingCard 开头)
            if (path.startsWith("fleet") || perms.startsWith("/vinManagement")
                    || (perms.startsWith("/chargingCard") && !perms.startsWith("/chargingCardBalanceDetail"))
                    || perms.startsWith("/appUserWhitelist")) {
                menuIds.add(menu.getMenuId());
            }
        }
        // 白名单用户菜单挂在「用户管理」父级下,补齐祖先节点保证菜单树可以展示
        Set<Long> ancestorIds = new HashSet<>();
        for (Long menuId : new HashSet<>(menuIds)) {
            Long parentId = parentMap.get(menuId);
            while (parentId != null && parentId != 0L && !menuIds.contains(parentId) && ancestorIds.add(parentId)) {
                parentId = parentMap.get(parentId);
            }
        }
        menuIds.addAll(ancestorIds);
        return menuIds;
    }
    private String normalizeMenuPath(String path) {
        if (path == null) {
            return "";
        }
        return path.startsWith("/") ? path.substring(1) : path;
    }
    private boolean isIndependentPartnerRole(SysRole role) {
        return role != null && (INDEPENDENT_PARTNER_ROLE_KEY.equals(role.getRoleKey())
                || INDEPENDENT_PARTNER_ROLE_NAME.equals(role.getRoleName()));
@@ -190,8 +265,14 @@
                || COMPANY_ROLE_NAME.equals(role.getRoleName()));
    }
    private boolean isCompanyAdminBuiltinRole(SysRole role) {
        return role != null && (COMPANY_ADMIN_ROLE_KEY.equals(role.getRoleKey())
                || COMPANY_ADMIN_ROLE_NAME.equals(role.getRoleName()));
    }
    private boolean isSystemBuiltinRole(SysRole role) {
        return role != null && (role.isAdmin() || isPartnerBuiltinRole(role) || isCompanyBuiltinRole(role));
        return role != null && (role.isAdmin() || isPartnerBuiltinRole(role) || isCompanyBuiltinRole(role)
                || isCompanyAdminBuiltinRole(role));
    }
    private void fillRoleV2Info(SysRole role) {
@@ -350,6 +431,9 @@
        if (oldRole != null && oldRole.isAdmin()) {
            return AjaxResult.error("超级管理员角色不可编辑");
        }
        if (oldRole != null && isCompanyAdminBuiltinRole(oldRole)) {
            return AjaxResult.error("公司管理员角色不可编辑");
        }
        SysRole role = new SysRole();
        role.setRoleName(dto.getRoleName());
        SysRole one = roleService.getOne(new LambdaQueryWrapper<SysRole>().eq(SysRole::getRoleName, dto.getRoleName()));
ruoyi-modules/ruoyi-system/src/test/java/com/ruoyi/system/controller/SysRoleControllerTest.java
New file
@@ -0,0 +1,62 @@
package com.ruoyi.system.controller;
import com.ruoyi.common.core.web.domain.AjaxResult;
import com.ruoyi.system.api.domain.SysRole;
import com.ruoyi.system.domain.dto.RoleUpdateDto;
import com.ruoyi.system.service.ISysRoleService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
 * 公司管理员内置角色(company_admin)编辑拦截
 */
class SysRoleControllerTest {
    @Test
    void roleUpdateRejectsCompanyAdminBuiltinRole() {
        SysRoleController controller = new SysRoleController();
        ISysRoleService roleService = mock(ISysRoleService.class);
        ReflectionTestUtils.setField(controller, "roleService", roleService);
        SysRole builtin = new SysRole();
        builtin.setRoleId(75L);
        builtin.setRoleKey("company_admin");
        builtin.setRoleName("公司管理员");
        when(roleService.selectRoleById(75L)).thenReturn(builtin);
        RoleUpdateDto dto = new RoleUpdateDto();
        dto.setRoleId(75L);
        dto.setRoleName("公司管理员");
        AjaxResult result = controller.roleUpdate(dto);
        assertEquals("公司管理员角色不可编辑", result.get("msg"));
        verify(roleService, never()).updateRole(any(SysRole.class));
    }
    @Test
    void roleUpdateRejectsCompanyAdminBuiltinRoleByName() {
        // 兼容历史数据:仅角色名匹配(如手工建的角色)同样拦截
        SysRoleController controller = new SysRoleController();
        ISysRoleService roleService = mock(ISysRoleService.class);
        ReflectionTestUtils.setField(controller, "roleService", roleService);
        SysRole builtin = new SysRole();
        builtin.setRoleId(76L);
        builtin.setRoleKey("custom_key");
        builtin.setRoleName("公司管理员");
        when(roleService.selectRoleById(76L)).thenReturn(builtin);
        RoleUpdateDto dto = new RoleUpdateDto();
        dto.setRoleId(76L);
        dto.setRoleName("公司管理员");
        AjaxResult result = controller.roleUpdate(dto);
        assertEquals("公司管理员角色不可编辑", result.get("msg"));
        verify(roleService, never()).updateRole(any(SysRole.class));
    }
}
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/PartnerAppUserAccessService.java
@@ -28,6 +28,7 @@
public class PartnerAppUserAccessService {
    private static final Integer PARTNER_ROLE_TYPE = 2;
    private static final Integer COMPANY_ROLE_TYPE = 5;
    private static final Long IMPOSSIBLE_USER_ID = -1L;
    @Resource
@@ -99,10 +100,47 @@
        }
    }
    /**
     * 当前登录账号若为公司账号(roleType=5 且已绑定公司),返回其所属公司ID;非公司账号返回null。
     */
    public Integer getCurrentCompanyId() {
        SysUser sysUser = getCurrentSysUser();
        if (!isCompany(sysUser)) {
            return null;
        }
        return sysUser.getObjectId();
    }
    /**
     * 数据范围强制:公司账号只允许查询本公司数据,覆盖前端传入的公司筛选条件;非公司账号保持原值。
     */
    public Integer resolveCompanyFilter(Integer requestCompanyId) {
        Integer currentCompanyId = getCurrentCompanyId();
        return currentCompanyId != null ? currentCompanyId : requestCompanyId;
    }
    /**
     * 公司账号数据归属校验:记录不属于本公司时拒绝操作;非公司账号不做限制。
     */
    public void assertCompanyVisible(Integer recordCompanyId) {
        Integer currentCompanyId = getCurrentCompanyId();
        if (currentCompanyId == null) {
            return;
        }
        if (!Objects.equals(currentCompanyId, recordCompanyId)) {
            throw new ServiceException("无权操作其他公司的数据");
        }
    }
    private boolean isPartner(SysUser sysUser) {
        return sysUser != null && Objects.equals(PARTNER_ROLE_TYPE, sysUser.getRoleType());
    }
    private boolean isCompany(SysUser sysUser) {
        return sysUser != null && Objects.equals(COMPANY_ROLE_TYPE, sysUser.getRoleType())
                && sysUser.getObjectId() != null;
    }
    private List<Long> impossibleUserIds() {
        return Collections.singletonList(IMPOSSIBLE_USER_ID);
    }
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TAppUserWhitelistServiceImpl.java
@@ -21,6 +21,7 @@
import com.ruoyi.account.api.vo.AppUserWhitelistImportErrorVO;
import com.ruoyi.account.api.vo.AppUserWhitelistImportResultVO;
import com.ruoyi.account.mapper.TAppUserWhitelistMapper;
import com.ruoyi.account.service.PartnerAppUserAccessService;
import com.ruoyi.account.service.TAppUserService;
import com.ruoyi.account.service.TAppUserWhitelistService;
import com.ruoyi.account.service.TAppUserWhitelistSiteService;
@@ -140,6 +141,9 @@
    @Resource
    private TCompanyAllocateRecordService companyAllocateRecordService;
    @Resource
    private PartnerAppUserAccessService partnerAppUserAccessService;
    @Override
    public Page<TAppUserWhitelist> pageList(AppUserWhitelistQueryDTO queryDTO) {
        AppUserWhitelistQueryDTO safeQuery = queryDTO == null ? new AppUserWhitelistQueryDTO() : queryDTO;
@@ -156,6 +160,8 @@
     */
    private LambdaQueryChainWrapper<TAppUserWhitelist> buildListQuery(AppUserWhitelistQueryDTO queryDTO) {
        AppUserWhitelistQueryDTO safeQuery = queryDTO == null ? new AppUserWhitelistQueryDTO() : queryDTO;
        // 公司账号强制按所属公司过滤,覆盖前端传入的公司筛选
        safeQuery.setCompanyId(partnerAppUserAccessService.resolveCompanyFilter(safeQuery.getCompanyId()));
        Integer currentPartnerId = getCurrentPartnerId();
        String username = getUsername();
        LambdaQueryChainWrapper<TAppUserWhitelist> query = this.lambdaQuery()
@@ -229,6 +235,8 @@
            throw new ServiceException("白名单用户不存在或已删除");
        }
        assertPartnerVisible(existing);
        // 公司账号越权校验:只能操作本公司数据
        partnerAppUserAccessService.assertCompanyVisible(existing.getCompanyId());
        if (Objects.equals(existing.getStatus(), statusDTO.getStatus())) {
            // 状态未变化视为幂等成功,避免无意义的更新人与更新时间变更
            return true;
@@ -254,6 +262,8 @@
            throw new ServiceException("白名单用户不存在");
        }
        assertPartnerVisible(whitelist);
        // 公司账号越权校验:只能查看本公司数据
        partnerAppUserAccessService.assertCompanyVisible(whitelist.getCompanyId());
        fillDisplay(Collections.singletonList(whitelist));
        return whitelist;
    }
@@ -285,6 +295,8 @@
                continue;
            }
            assertPartnerVisible(whitelist);
            // 公司账号越权校验:只能删除本公司数据
            partnerAppUserAccessService.assertCompanyVisible(whitelist.getCompanyId());
            this.removeById(whitelistId);
            whitelistSiteService.remove(Wrappers.lambdaQuery(TAppUserWhitelistSite.class)
                    .eq(TAppUserWhitelistSite::getWhitelistId, whitelistId));
@@ -311,6 +323,8 @@
        }
        for (TAppUserWhitelist record : records) {
            assertPartnerVisible(record);
            // 公司账号越权校验:只能解绑本公司数据
            partnerAppUserAccessService.assertCompanyVisible(record.getCompanyId());
        }
        List<Long> boundIds = records.stream()
                .filter(record -> record.getFleetId() != null)
@@ -523,6 +537,8 @@
    @Override
    public AppUserWhitelistAllocateVO getAllocateOverview(Integer companyId) {
        // 公司账号只能查看本公司额度概览,覆盖前端传入的公司
        companyId = partnerAppUserAccessService.resolveCompanyFilter(companyId);
        if (companyId == null) {
            throw new ServiceException("请选择公司");
        }
@@ -536,6 +552,8 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public AppUserWhitelistAllocateVO allocateQuota(AppUserWhitelistAllocateDTO allocateDTO) {
        // 公司账号只能给本公司分配额度,覆盖前端传入的公司
        allocateDTO.setCompanyId(partnerAppUserAccessService.resolveCompanyFilter(allocateDTO.getCompanyId()));
        Map<Long, BigDecimal> amounts = validateAllocateRequest(allocateDTO);
        Integer companyId = allocateDTO.getCompanyId();
        // 公司必须存在、启用且为分配模式(共享模式不支持额度分配)
@@ -766,6 +784,8 @@
            throw new ServiceException("白名单用户不存在");
        }
        assertPartnerVisible(existing);
        // 公司账号越权校验:只能编辑本公司数据
        partnerAppUserAccessService.assertCompanyVisible(existing.getCompanyId());
        // 同白名单并发编辑串行化:额度差额计算、公司余额扣减/退还必须在临界区内,防止重复扣减或重复退还
        RLock lock = redissonClient.getLock(QUOTA_LOCK_PREFIX + saveDTO.getId());
        boolean locked = tryQuotaLock(lock, "该白名单额度正在处理中,请稍后重试");
@@ -1133,6 +1153,8 @@
    /** 校验并回填所属公司与车队:车队必须归属所选公司;未选公司时按车队自动带出公司 */
    private Integer resolveRelationCompanyId(AppUserWhitelistSaveDTO saveDTO) {
        // 公司账号新增/编辑白名单用户时强制归属本公司,覆盖前端传入的公司
        saveDTO.setCompanyId(partnerAppUserAccessService.resolveCompanyFilter(saveDTO.getCompanyId()));
        Integer companyId = saveDTO.getCompanyId();
        if (companyId != null && loadCompany(companyId) == null) {
            throw new ServiceException("所属公司不存在或已删除");
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TChargingCardServiceImpl.java
@@ -18,6 +18,7 @@
import com.ruoyi.account.api.vo.ChargingCardAllocateVO;
import com.ruoyi.account.api.vo.ChargingCardDeductVO;
import com.ruoyi.account.mapper.TChargingCardMapper;
import com.ruoyi.account.service.PartnerAppUserAccessService;
import com.ruoyi.account.service.TChargingCardService;
import com.ruoyi.account.service.TCompanyAllocateRecordService;
import com.ruoyi.common.core.domain.R;
@@ -104,9 +105,14 @@
    @Autowired
    private TCompanyAllocateRecordService companyAllocateRecordService;
    @Autowired
    private PartnerAppUserAccessService partnerAppUserAccessService;
    @Override
    public IPage<TChargingCard> selectCardPage(ChargingCardQueryDTO query) {
        ChargingCardQueryDTO safeQuery = query == null ? new ChargingCardQueryDTO() : query;
        // 公司账号强制按所属公司过滤,覆盖前端传入的公司筛选
        safeQuery.setCompanyId(partnerAppUserAccessService.resolveCompanyFilter(safeQuery.getCompanyId()));
        long pageNum = safeQuery.getPageCurr() == null || safeQuery.getPageCurr() < 1 ? 1 : safeQuery.getPageCurr();
        long pageSize = safeQuery.getPageSize() == null || safeQuery.getPageSize() < 1 ? 10 : safeQuery.getPageSize();
        Page<TChargingCard> page = new Page<>(pageNum, pageSize);
@@ -164,6 +170,8 @@
        if (existing == null) {
            throw new ServiceException("卡片不存在或已删除");
        }
        // 公司账号越权校验:只能编辑本公司数据
        partnerAppUserAccessService.assertCompanyVisible(existing.getCompanyId());
        assertNotChargingLocked(existing, "修改卡片或重置卡密码");
        // 同卡片并发编辑串行化:额度差额计算、公司余额扣减/退还必须在临界区内,防止重复扣减或重复退还
        RLock lock = redissonClient.getLock(QUOTA_LOCK_PREFIX + dto.getId());
@@ -248,6 +256,10 @@
        if (cards.size() != distinctIds.size()) {
            throw new ServiceException("部分卡片不存在或已删除");
        }
        // 公司账号越权校验:只能删除本公司数据
        for (TChargingCard card : cards) {
            partnerAppUserAccessService.assertCompanyVisible(card.getCompanyId());
        }
        assertNotChargingLocked(cards, "删除卡片");
        if (!removeByIds(distinctIds)) {
            throw new ServiceException("删除卡片失败");
@@ -272,6 +284,10 @@
        List<TChargingCard> records = listByIds(distinct);
        if (records.size() != distinct.size()) {
            throw new ServiceException("部分卡片不存在或已删除");
        }
        // 公司账号越权校验:只能解绑本公司数据
        for (TChargingCard record : records) {
            partnerAppUserAccessService.assertCompanyVisible(record.getCompanyId());
        }
        List<Long> boundIds = records.stream()
                .filter(card -> card.getFleetId() != null)
@@ -302,6 +318,8 @@
        if (existing == null) {
            throw new ServiceException("卡片不存在或已删除");
        }
        // 公司账号越权校验:只能操作本公司数据
        partnerAppUserAccessService.assertCompanyVisible(existing.getCompanyId());
        assertNotChargingLocked(existing, "启用/禁用卡片");
        TChargingCard card = new TChargingCard();
        card.setId(dto.getId());
@@ -316,6 +334,8 @@
    @Override
    public ChargingCardAllocateVO getAllocateOverview(Integer companyId) {
        // 公司账号只能查看本公司额度概览,覆盖前端传入的公司
        companyId = partnerAppUserAccessService.resolveCompanyFilter(companyId);
        if (companyId == null) {
            throw new ServiceException("请选择公司");
        }
@@ -329,6 +349,8 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public ChargingCardAllocateVO allocateQuota(ChargingCardAllocateDTO dto) {
        // 公司账号只能给本公司分配额度,覆盖前端传入的公司
        dto.setCompanyId(partnerAppUserAccessService.resolveCompanyFilter(dto.getCompanyId()));
        Map<Long, BigDecimal> amounts = validateAllocateRequest(dto);
        Integer companyId = dto.getCompanyId();
        // 公司必须存在、启用且为分配模式(共享模式不支持额度分配)
@@ -394,7 +416,10 @@
    @Override
    public List<ChargingCardExcelDTO> selectExportList(ChargingCardQueryDTO query) {
        List<ChargingCardExcelDTO> items = baseMapper.selectExportList(query == null ? new ChargingCardQueryDTO() : query);
        ChargingCardQueryDTO safe = query == null ? new ChargingCardQueryDTO() : query;
        // 公司账号强制按所属公司过滤,覆盖前端传入的公司筛选
        safe.setCompanyId(partnerAppUserAccessService.resolveCompanyFilter(safe.getCompanyId()));
        List<ChargingCardExcelDTO> items = baseMapper.selectExportList(safe);
        if (items == null || items.isEmpty()) {
            return items;
        }
@@ -434,9 +459,14 @@
        Long userId = SecurityUtils.getUserId();
        LocalDateTime now = LocalDateTime.now();
        // 公司账号导入卡片时强制归属本公司
        Integer lockedCompanyId = partnerAppUserAccessService.getCurrentCompanyId();
        for (ChargingCardExcelDTO item : cards) {
            TChargingCard card = new TChargingCard();
            card.setCardNo(item.getCardNo().trim());
            if (lockedCompanyId != null) {
                card.setCompanyId(lockedCompanyId);
            }
            card.setBalance(item.getBalance() == null ? BigDecimal.ZERO : item.getBalance());
            card.setStatus(parseExcelStatus(item.getStatus(), 0));
            card.setChargingFlag(0);
@@ -659,6 +689,8 @@
     * 校验并回填所属公司与车队:车队必须归属所选公司;未选公司时按车队自动带出公司
     */
    private void applyRelation(TChargingCard card, ChargingCardSaveDTO dto) {
        // 公司账号新增/编辑卡片时强制归属本公司,覆盖前端传入的公司
        dto.setCompanyId(partnerAppUserAccessService.resolveCompanyFilter(dto.getCompanyId()));
        Integer companyId = dto.getCompanyId();
        if (companyId != null && loadCompany(companyId) == null) {
            throw new ServiceException("所属公司不存在或已删除");
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TVinManagementServiceImpl.java
@@ -12,6 +12,7 @@
import com.ruoyi.account.api.model.TVinManagement;
import com.ruoyi.account.api.vo.VinManagementAllocateVO;
import com.ruoyi.account.mapper.TVinManagementMapper;
import com.ruoyi.account.service.PartnerAppUserAccessService;
import com.ruoyi.account.service.TCompanyAllocateRecordService;
import com.ruoyi.account.service.TVinManagementService;
import com.ruoyi.common.core.domain.R;
@@ -83,8 +84,13 @@
    @Resource
    private TCompanyAllocateRecordService companyAllocateRecordService;
    @Resource
    private PartnerAppUserAccessService partnerAppUserAccessService;
    @Override public IPage<TVinManagement> selectPageList(VinManagementQueryDTO query) {
        VinManagementQueryDTO safe = query == null ? new VinManagementQueryDTO() : query;
        // 公司账号强制按所属公司过滤,覆盖前端传入的公司筛选
        safe.setCompanyId(partnerAppUserAccessService.resolveCompanyFilter(safe.getCompanyId()));
        long page = safe.getPageCurr() == null || safe.getPageCurr() < 1 ? 1 : safe.getPageCurr();
        long size = safe.getPageSize() == null || safe.getPageSize() < 1 ? 10 : safe.getPageSize();
        IPage<TVinManagement> result = baseMapper.selectPageList(new Page<>(page, size), safe);
@@ -126,6 +132,8 @@
        if (old == null) {
            throw new ServiceException("VIN码不存在或已删除");
        }
        // 公司账号越权校验:只能编辑本公司数据
        partnerAppUserAccessService.assertCompanyVisible(old.getCompanyId());
        TVinManagement item = from(dto);
        item.setId(dto.getId());
        item.setUpdateTime(LocalDateTime.now());
@@ -183,7 +191,7 @@
            throw ex;
        }
    }
    @Override public boolean deleteVins(List<Long> ids) { if (ids == null || ids.isEmpty()) throw new ServiceException("请选择需要删除的VIN码"); List<Long> distinct = new ArrayList<>(new HashSet<>(ids)); if (listByIds(distinct).size() != distinct.size()) throw new ServiceException("部分VIN码不存在或已删除"); return removeByIds(distinct); }
    @Override public boolean deleteVins(List<Long> ids) { if (ids == null || ids.isEmpty()) throw new ServiceException("请选择需要删除的VIN码"); List<Long> distinct = new ArrayList<>(new HashSet<>(ids)); List<TVinManagement> records = listByIds(distinct); if (records.size() != distinct.size()) throw new ServiceException("部分VIN码不存在或已删除"); for (TVinManagement record : records) partnerAppUserAccessService.assertCompanyVisible(record.getCompanyId()); return removeByIds(distinct); }
    /**
     * 批量解绑车队:仅清空车队归属(fleet_id 置空),公司、初始分配额度、状态等不受影响;
@@ -202,6 +210,10 @@
        List<TVinManagement> records = listByIds(distinct);
        if (records.size() != distinct.size()) {
            throw new ServiceException("部分VIN码不存在或已删除");
        }
        // 公司账号越权校验:只能解绑本公司数据
        for (TVinManagement record : records) {
            partnerAppUserAccessService.assertCompanyVisible(record.getCompanyId());
        }
        List<Long> boundIds = records.stream()
                .filter(record -> record.getFleetId() != null)
@@ -222,10 +234,13 @@
        }
        return boundIds.size();
    }
    @Override public boolean updateStatus(Long id, Integer status) { if (id == null || !validStatus(status)) throw new ServiceException("VIN码状态参数错误"); if (getById(id) == null) throw new ServiceException("VIN码不存在或已删除"); TVinManagement item = new TVinManagement(); item.setId(id); item.setStatus(status); item.setUpdateTime(LocalDateTime.now()); item.setUpdateBy(SecurityUtils.getUserId()); return updateById(item); }
    @Override public boolean updateStatus(Long id, Integer status) { if (id == null || !validStatus(status)) throw new ServiceException("VIN码状态参数错误"); TVinManagement record = getById(id); if (record == null) throw new ServiceException("VIN码不存在或已删除"); partnerAppUserAccessService.assertCompanyVisible(record.getCompanyId()); TVinManagement item = new TVinManagement(); item.setId(id); item.setStatus(status); item.setUpdateTime(LocalDateTime.now()); item.setUpdateBy(SecurityUtils.getUserId()); return updateById(item); }
    @Override
    public List<VinManagementExcelDTO> selectExportList(VinManagementQueryDTO query) {
        List<VinManagementExcelDTO> items = baseMapper.selectExportList(query == null ? new VinManagementQueryDTO() : query);
        VinManagementQueryDTO safe = query == null ? new VinManagementQueryDTO() : query;
        // 公司账号强制按所属公司过滤,覆盖前端传入的公司筛选
        safe.setCompanyId(partnerAppUserAccessService.resolveCompanyFilter(safe.getCompanyId()));
        List<VinManagementExcelDTO> items = baseMapper.selectExportList(safe);
        if (items == null || items.isEmpty()) {
            return items;
        }
@@ -296,6 +311,8 @@
    }
    @Override
    public VinManagementAllocateVO getAllocateOverview(Integer companyId) {
        // 公司账号只能查看本公司额度概览,覆盖前端传入的公司
        companyId = partnerAppUserAccessService.resolveCompanyFilter(companyId);
        if (companyId == null) {
            throw new ServiceException("请选择公司");
        }
@@ -309,6 +326,8 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public VinManagementAllocateVO allocateQuota(VinManagementAllocateDTO dto) {
        // 公司账号只能给本公司分配额度,覆盖前端传入的公司
        dto.setCompanyId(partnerAppUserAccessService.resolveCompanyFilter(dto.getCompanyId()));
        Map<Long, BigDecimal> amounts = validateAllocateRequest(dto);
        Integer companyId = dto.getCompanyId();
        // 公司必须存在、启用且为分配模式(共享模式不支持额度分配)
@@ -448,7 +467,7 @@
        TVinManagement latest = getById(vinId);
        return latest == null ? BigDecimal.ZERO : normalizeQuota(latest.getBalance());
    }
    @Override @Transactional(rollbackFor = Exception.class) public String importVins(List<VinManagementExcelDTO> items) { if (items == null || items.isEmpty()) throw new ServiceException("导入数据不能为空"); Set<String> vins = new HashSet<>(); int row = 1; for (VinManagementExcelDTO item : items) { row++; if (item == null || StringUtils.isEmpty(item.getVin())) throw new ServiceException("第" + row + "行VIN码不能为空"); String vin = item.getVin().trim(); if (vin.length() > 64) throw new ServiceException("第" + row + "行VIN码不能超过64个字符"); if (!vins.add(vin) || baseMapper.countByVin(vin, null) > 0) throw new ServiceException("第" + row + "行VIN码已存在或重复"); parseStatus(item.getStatus(), row); } Long userId = SecurityUtils.getUserId(); LocalDateTime now = LocalDateTime.now(); for (VinManagementExcelDTO item : items) { TVinManagement v = new TVinManagement(); v.setVin(item.getVin().trim()); v.setPlateNumber(trim(item.getPlateNumber())); v.setPhone(trim(item.getPhone())); v.setCompanyName(trim(item.getCompanyName())); v.setStatus(parseStatus(item.getStatus(), 0)); v.setDelFlag(0); v.setCreateBy(userId); v.setCreateTime(now); save(v); } return "成功导入" + items.size() + "条VIN码数据"; }
    @Override @Transactional(rollbackFor = Exception.class) public String importVins(List<VinManagementExcelDTO> items) { if (items == null || items.isEmpty()) throw new ServiceException("导入数据不能为空"); Set<String> vins = new HashSet<>(); int row = 1; for (VinManagementExcelDTO item : items) { row++; if (item == null || StringUtils.isEmpty(item.getVin())) throw new ServiceException("第" + row + "行VIN码不能为空"); String vin = item.getVin().trim(); if (vin.length() > 64) throw new ServiceException("第" + row + "行VIN码不能超过64个字符"); if (!vins.add(vin) || baseMapper.countByVin(vin, null) > 0) throw new ServiceException("第" + row + "行VIN码已存在或重复"); parseStatus(item.getStatus(), row); } Long userId = SecurityUtils.getUserId(); LocalDateTime now = LocalDateTime.now(); Integer lockedCompanyId = partnerAppUserAccessService.getCurrentCompanyId(); TGroupCompany lockedCompany = lockedCompanyId == null ? null : loadCompany(lockedCompanyId); for (VinManagementExcelDTO item : items) { TVinManagement v = new TVinManagement(); v.setVin(item.getVin().trim()); v.setPlateNumber(trim(item.getPlateNumber())); v.setPhone(trim(item.getPhone())); if (lockedCompanyId != null) { v.setCompanyId(lockedCompanyId); v.setCompanyName(lockedCompany == null ? null : lockedCompany.getName()); } else { v.setCompanyName(trim(item.getCompanyName())); } v.setStatus(parseStatus(item.getStatus(), 0)); v.setDelFlag(0); v.setCreateBy(userId); v.setCreateTime(now); save(v); } return "成功导入" + items.size() + "条VIN码数据"; }
    private void validate(VinManagementSaveDTO dto, boolean update) { if (dto == null || StringUtils.isEmpty(dto.getVin())) throw new ServiceException("VIN码不能为空"); if (update && dto.getId() == null) throw new ServiceException("VIN码ID不能为空"); String vin = dto.getVin().trim(); if (vin.length() > 64) throw new ServiceException("VIN码不能超过64个字符"); if (!validStatus(dto.getStatus() == null ? ENABLED : dto.getStatus())) throw new ServiceException("VIN码状态参数错误"); if (baseMapper.countByVin(vin, dto.getId()) > 0) throw new ServiceException("VIN码已存在"); }
    private TVinManagement from(VinManagementSaveDTO d) { TVinManagement v = new TVinManagement(); v.setVin(d.getVin().trim()); v.setPlateNumber(trim(d.getPlateNumber())); v.setPhone(trim(d.getPhone())); v.setCompanyName(trim(d.getCompanyName())); v.setStatus(d.getStatus() == null ? ENABLED : d.getStatus()); return v; }
    private String trim(String value) { return value == null ? null : value.trim(); }
@@ -478,6 +497,8 @@
     * 校验并回填所属公司与车队:车队必须归属所选公司;未选公司时按车队自动带出公司;公司名称以公司档案为准
     */
    private void applyRelation(TVinManagement item, VinManagementSaveDTO dto, TVinManagement old) {
        // 公司账号新增/编辑VIN时强制归属本公司,覆盖前端传入的公司
        dto.setCompanyId(partnerAppUserAccessService.resolveCompanyFilter(dto.getCompanyId()));
        Integer companyId = dto.getCompanyId();
        TGroupCompany company = null;
        if (companyId != null) {
ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/TAppUserWhitelistServiceImplTest.java
@@ -14,6 +14,7 @@
import com.ruoyi.account.api.vo.AppUserWhitelistImportResultVO;
import com.ruoyi.account.api.vo.AppUserWhitelistMatchVO;
import com.ruoyi.account.mapper.TAppUserWhitelistMapper;
import com.ruoyi.account.service.PartnerAppUserAccessService;
import com.ruoyi.account.service.TAppUserService;
import com.ruoyi.account.service.TAppUserWhitelistSiteService;
import com.ruoyi.account.service.TCompanyAllocateRecordService;
@@ -57,6 +58,7 @@
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -76,6 +78,7 @@
    @Mock private GroupCompanyClient groupCompanyClient;
    @Mock private RedissonClient redissonClient;
    @Mock private TCompanyAllocateRecordService companyAllocateRecordService;
    @Mock private PartnerAppUserAccessService partnerAppUserAccessService;
    private TAppUserWhitelistServiceImpl service;
@@ -100,6 +103,10 @@
        ReflectionTestUtils.setField(service, "groupCompanyClient", groupCompanyClient);
        ReflectionTestUtils.setField(service, "redissonClient", redissonClient);
        ReflectionTestUtils.setField(service, "companyAllocateRecordService", companyAllocateRecordService);
        ReflectionTestUtils.setField(service, "partnerAppUserAccessService", partnerAppUserAccessService);
        // 默认透传(模拟非公司账号),公司账号隔离场景由具体用例覆盖打桩
        lenient().when(partnerAppUserAccessService.resolveCompanyFilter(any())).thenAnswer(invocation -> invocation.getArgument(0));
        lenient().when(partnerAppUserAccessService.getCurrentCompanyId()).thenReturn(null);
        RLock lock = mock(RLock.class);
        // 并发锁仅在额度相关场景使用,宽松桩避免严格模式下未使用即报错
        lenient().when(redissonClient.getLock(any())).thenReturn(lock);
@@ -512,4 +519,42 @@
        row.createCell(1).setCellValue(benefitType);
        row.createCell(2).setCellValue(whitelistSite);
    }
    // ==================== 公司账号数据隔离 ====================
    @Test
    void getAllocateOverviewForcesCompanyFilterForCompanyAccount() {
        // 公司账号:额度概览的公司参数被强制覆盖为本公司
        when(partnerAppUserAccessService.resolveCompanyFilter(9)).thenReturn(2);
        TGroupCompany company = new TGroupCompany();
        company.setId(2);
        company.setName("本公司");
        company.setAccountMode(2);
        company.setAvailableBalance(new BigDecimal("100.00"));
        when(groupCompanyClient.getCompanyById(2)).thenReturn(R.ok(company));
        AppUserWhitelistAllocateVO vo = service.getAllocateOverview(9);
        assertEquals(Integer.valueOf(2), vo.getCompanyId());
        verify(groupCompanyClient).getCompanyById(2);
    }
    @Test
    void updateStatusRejectsCrossCompanyRecord() {
        // 公司账号:启停其他公司的白名单被越权校验拦截
        TAppUserWhitelist existing = new TAppUserWhitelist();
        existing.setId(1L);
        existing.setStatus(1);
        existing.setCompanyId(9);
        when(mapper.selectById(1L)).thenReturn(existing);
        doThrow(new ServiceException("无权操作其他公司的数据")).when(partnerAppUserAccessService).assertCompanyVisible(9);
        AppUserWhitelistStatusDTO dto = new AppUserWhitelistStatusDTO();
        dto.setId(1L);
        dto.setStatus(2);
        ServiceException exception = assertThrows(ServiceException.class, () -> service.updateStatus(dto));
        assertEquals("无权操作其他公司的数据", exception.getMessage());
        verify(mapper, never()).update(isNull(), any(Wrapper.class));
    }
}
ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/TChargingCardServiceImplTest.java
@@ -10,12 +10,14 @@
import com.ruoyi.account.api.dto.ChargingCardAuthDTO;
import com.ruoyi.account.api.dto.ChargingCardDeductDTO;
import com.ruoyi.account.api.dto.ChargingCardExcelDTO;
import com.ruoyi.account.api.dto.ChargingCardQueryDTO;
import com.ruoyi.account.api.dto.ChargingCardSaveDTO;
import com.ruoyi.account.api.dto.ChargingCardStatusDTO;
import com.ruoyi.account.api.model.TChargingCard;
import com.ruoyi.account.api.vo.ChargingCardAllocateVO;
import com.ruoyi.account.api.vo.ChargingCardDeductVO;
import com.ruoyi.account.mapper.TChargingCardMapper;
import com.ruoyi.account.service.PartnerAppUserAccessService;
import com.ruoyi.common.core.context.SecurityContextHolder;
import com.ruoyi.common.core.exception.ServiceException;
import com.ruoyi.common.core.domain.R;
@@ -58,6 +60,7 @@
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
@@ -72,6 +75,7 @@
    @Mock private GroupCompanyClient groupCompanyClient;
    @Mock private RedissonClient redissonClient;
    @Mock private RLock quotaLock;
    @Mock private PartnerAppUserAccessService partnerAppUserAccessService;
    private TChargingCardServiceImpl service;
    @BeforeAll
@@ -88,6 +92,10 @@
        ReflectionTestUtils.setField(service, "transactionTemplate", transactionTemplate);
        ReflectionTestUtils.setField(service, "groupCompanyClient", groupCompanyClient);
        ReflectionTestUtils.setField(service, "redissonClient", redissonClient);
        ReflectionTestUtils.setField(service, "partnerAppUserAccessService", partnerAppUserAccessService);
        // 默认透传(模拟非公司账号),公司账号隔离场景由具体用例覆盖打桩
        lenient().when(partnerAppUserAccessService.resolveCompanyFilter(any())).thenAnswer(invocation -> invocation.getArgument(0));
        lenient().when(partnerAppUserAccessService.getCurrentCompanyId()).thenReturn(null);
        lenient().when(redissonClient.getLock(anyString())).thenReturn(quotaLock);
        lenient().when(quotaLock.tryLock(anyLong(), anyLong(), any(TimeUnit.class))).thenReturn(true);
        lenient().when(quotaLock.isHeldByCurrentThread()).thenReturn(true);
@@ -1069,4 +1077,56 @@
        dto.setItems(Arrays.asList(items));
        return dto;
    }
    // ==================== 公司账号数据隔离 ====================
    @Test
    void selectCardPageForcesCompanyFilterForCompanyAccount() {
        // 公司账号:前端传入的公司筛选被强制覆盖为本公司
        when(partnerAppUserAccessService.resolveCompanyFilter(any())).thenReturn(2);
        ChargingCardQueryDTO query = new ChargingCardQueryDTO();
        query.setCompanyId(9);
        when(mapper.selectCardPage(any(Page.class), any())).thenReturn(new Page<>(1, 10));
        service.selectCardPage(query);
        ArgumentCaptor<ChargingCardQueryDTO> captor = ArgumentCaptor.forClass(ChargingCardQueryDTO.class);
        verify(mapper).selectCardPage(any(Page.class), captor.capture());
        assertEquals(Integer.valueOf(2), captor.getValue().getCompanyId());
    }
    @Test
    void updateCardRejectsCrossCompanyRecord() {
        // 公司账号:编辑其他公司的卡片被越权校验拦截
        TChargingCard existing = allocateCard(5L, 9, "CARD-X", "0");
        when(mapper.selectById(5L)).thenReturn(existing);
        doThrow(new ServiceException("无权操作其他公司的数据")).when(partnerAppUserAccessService).assertCompanyVisible(9);
        ChargingCardSaveDTO dto = new ChargingCardSaveDTO();
        dto.setId(5L);
        dto.setCardNo("CARD-X");
        ServiceException ex = assertThrows(ServiceException.class, () -> service.updateCard(dto));
        assertEquals("无权操作其他公司的数据", ex.getMessage());
        verify(mapper, never()).updateById(any(TChargingCard.class));
    }
    @Test
    void saveCardForcesCompanyAccountOwnership() {
        // 公司账号:新增卡片强制归属本公司
        when(partnerAppUserAccessService.resolveCompanyFilter(any())).thenReturn(2);
        when(mapper.countByCardNo(anyString(), isNull())).thenReturn(0);
        when(mapper.insert(any(TChargingCard.class))).thenReturn(1);
        when(groupCompanyClient.getCompanyById(2)).thenReturn(R.ok(allocationCompany(2, "本公司")));
        ChargingCardSaveDTO dto = new ChargingCardSaveDTO();
        dto.setCardNo(" CARD-C1 ");
        dto.setCompanyId(9);
        assertTrue(service.saveCard(dto));
        ArgumentCaptor<TChargingCard> card = ArgumentCaptor.forClass(TChargingCard.class);
        verify(mapper).insert(card.capture());
        assertEquals(Integer.valueOf(2), card.getValue().getCompanyId());
    }
}
ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/TVinManagementServiceImplTest.java
@@ -14,6 +14,7 @@
import com.ruoyi.account.api.model.TVinManagement;
import com.ruoyi.account.api.vo.VinManagementAllocateVO;
import com.ruoyi.account.mapper.TVinManagementMapper;
import com.ruoyi.account.service.PartnerAppUserAccessService;
import com.ruoyi.common.core.context.SecurityContextHolder;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.exception.ServiceException;
@@ -55,6 +56,7 @@
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
@@ -83,6 +85,9 @@
    @Mock
    private RLock quotaLock;
    @Mock
    private PartnerAppUserAccessService partnerAppUserAccessService;
    @Captor
    private ArgumentCaptor<Page<TVinManagement>> pageCaptor;
@@ -108,6 +113,10 @@
        ReflectionTestUtils.setField(service, "chargingOrderClient", chargingOrderClient);
        ReflectionTestUtils.setField(service, "groupCompanyClient", groupCompanyClient);
        ReflectionTestUtils.setField(service, "redissonClient", redissonClient);
        ReflectionTestUtils.setField(service, "partnerAppUserAccessService", partnerAppUserAccessService);
        // 默认透传(模拟非公司账号),公司账号隔离场景由具体用例覆盖打桩
        lenient().when(partnerAppUserAccessService.resolveCompanyFilter(any())).thenAnswer(invocation -> invocation.getArgument(0));
        lenient().when(partnerAppUserAccessService.getCurrentCompanyId()).thenReturn(null);
        lenient().when(redissonClient.getLock(anyString())).thenReturn(quotaLock);
        lenient().when(quotaLock.tryLock(anyLong(), anyLong(), any(TimeUnit.class))).thenReturn(true);
        lenient().when(quotaLock.isHeldByCurrentThread()).thenReturn(true);
@@ -973,4 +982,57 @@
        }
        return sb.toString();
    }
    // ---------------- 公司账号数据隔离 ----------------
    @Test
    void selectPageListForcesCompanyFilterForCompanyAccount() {
        // 公司账号:前端传入的公司筛选被强制覆盖为本公司
        when(partnerAppUserAccessService.resolveCompanyFilter(any())).thenReturn(2);
        VinManagementQueryDTO query = new VinManagementQueryDTO();
        query.setCompanyId(9);
        query.setVin("LSV");
        when(mapper.selectPageList(any(Page.class), any())).thenReturn(new Page<>(1, 10));
        service.selectPageList(query);
        verify(mapper).selectPageList(pageCaptor.capture(), queryCaptor.capture());
        assertEquals(Integer.valueOf(2), queryCaptor.getValue().getCompanyId());
    }
    @Test
    void updateVinRejectsCrossCompanyRecord() {
        // 公司账号:编辑其他公司的 VIN 被越权校验拦截
        TVinManagement old = new TVinManagement();
        old.setId(5L);
        old.setCompanyId(9);
        when(mapper.selectById(5L)).thenReturn(old);
        doThrow(new ServiceException("无权操作其他公司的数据")).when(partnerAppUserAccessService).assertCompanyVisible(9);
        VinManagementSaveDTO dto = new VinManagementSaveDTO();
        dto.setId(5L);
        dto.setVin("LSVAM4187C2184842");
        ServiceException ex = assertThrows(ServiceException.class, () -> service.updateVin(dto));
        assertEquals("无权操作其他公司的数据", ex.getMessage());
        verify(mapper, never()).updateById(any(TVinManagement.class));
    }
    @Test
    void saveVinForcesCompanyAccountOwnership() {
        // 公司账号:新增 VIN 强制归属本公司
        when(partnerAppUserAccessService.resolveCompanyFilter(any())).thenReturn(2);
        when(mapper.countByVin(anyString(), isNull())).thenReturn(0);
        when(mapper.insert(any(TVinManagement.class))).thenReturn(1);
        when(groupCompanyClient.getCompanyById(2)).thenReturn(R.ok(allocationCompany(2, "本公司")));
        VinManagementSaveDTO dto = new VinManagementSaveDTO();
        dto.setVin("LSVAM4187C2184842");
        dto.setCompanyId(9);
        service.saveVin(dto);
        verify(mapper).insert(entityCaptor.capture());
        assertEquals(Integer.valueOf(2), entityCaptor.getValue().getCompanyId());
    }
}
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TCardChargingOrderServiceImpl.java
@@ -673,37 +673,40 @@
                order.setCreateTime(LocalDateTime.now());
                save(order);
                // 5. 锁定账户额度(实扣冻结:共享=企业账户,分配=卡账户;锁定失败则关单并回告余额不足)
                // 5. 计费策略快照(枪策略优先,站点策略兜底,strategyId 在授权校验前已解析):
                //    无可用策略(未配置/已被删除、明细为空)或保存异常时无法计费,直接回滚删除订单并回告启动失败,
                //    避免产生无策略订单(结算侧的价格兜底仅用于历史订单)
                try {
                    if (!saveStrategySnapshot(order.getId(), strategyId)) {
                        log.warn("[SD建单] 无可用计费策略,回滚删除订单 code={} strategyId={}", order.getCode(), strategyId);
                        removeOrderWithSnapshot(order.getId());
                        return DeviceStartChargingVO.fail(null);
                    }
                } catch (Exception snapshotEx) {
                    log.error("[SD建单] 保存计费策略快照异常,回滚删除订单 orderId={} strategyId={}", order.getId(),
                            strategyId, snapshotEx);
                    removeOrderWithSnapshot(order.getId());
                    return DeviceStartChargingVO.fail(null);
                }
                // 6. 锁定账户额度(实扣冻结:共享=企业账户,分配=卡账户;锁定失败则关单并回告余额不足)
                if (!lockCardChargeContext(card, chargeContext)) {
                    removeById(order.getId());
                    removeOrderWithSnapshot(order.getId());
                    return DeviceStartChargingVO.fail(3);
                }
                // 5.1 回填锁定额度(订单列表/详情展示与结算释放使用),同步内存对象,保证后续回滚能按额度释放
                // 6.1 回填锁定额度(订单列表/详情展示与结算释放使用),同步内存对象,保证后续回滚能按额度释放
                TCardChargingOrder accountUpdate = new TCardChargingOrder();
                accountUpdate.setId(order.getId());
                accountUpdate.setLockedAmount(chargeContext.getDispatchAmount());
                updateById(accountUpdate);
                order.setLockedAmount(chargeContext.getDispatchAmount());
                // 6. 计费策略快照(枪策略优先,站点策略兜底,strategyId 在授权校验前已解析)
                try {
                    saveStrategySnapshot(order.getId(), strategyId);
                } catch (Exception snapshotEx) {
                    log.error("[SD建单] 保存计费策略快照失败 orderId={} strategyId={}", order.getId(), strategyId, snapshotEx);
                }
                // 7. 锁卡(条件更新:仅空闲卡可锁;失败释放账户锁定额并回滚删除订单与快照)
                R<Boolean> lockResult = chargingCardClient.lockForCharging(card.getId(), order.getCode());
                if (!Boolean.TRUE.equals(lockResult.getData())) {
                    log.warn("[SD建单] 锁卡失败回滚订单 code={} cardId={} msg={}", order.getCode(), card.getId(), lockResult.getMsg());
                    unlockCardChargeContextQuietly(order, "锁卡失败");
                    cardAccountingStrategyDetailOrderService.remove(
                            new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TCardAccountingStrategyDetailOrder>()
                                    .eq(TCardAccountingStrategyDetailOrder::getChargingOrderId, order.getId()));
                    cardAccountingStrategyOrderService.remove(
                            new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TCardAccountingStrategyOrder>()
                                    .eq(TCardAccountingStrategyOrder::getChargingOrderId, order.getId()));
                    removeById(order.getId());
                    removeOrderWithSnapshot(order.getId());
                    return DeviceStartChargingVO.fail(4);
                }
                log.info("[SD建单] 刷卡充电订单创建成功 code={} cardNo={} gun={} accountMode={} lockAmount={} maxEndTime={}",
@@ -830,16 +833,34 @@
    }
    /**
     * 释放账户锁定额(建单失败/回滚场景):共享=企业账户,分配=卡账户;失败仅告警,留待补偿任务/人工处理
     * 释放账户锁定额(建单失败/回滚/关单场景):共享=企业账户,分配=卡账户;
     * 订单侧 lock_released 条件更新抢占,保证同一订单的锁定额在多路径/重复补偿下只释放一次;
     * 释放失败时把标记回滚为未释放,留待补偿任务/人工重试
     */
    private void unlockCardChargeContextQuietly(TCardChargingOrder order, String scene) {
        if (order == null || order.getCompanyId() == null) {
        if (order == null || order.getId() == null || order.getCompanyId() == null) {
            return;
        }
        BigDecimal amount = order.getLockedAmount();
        if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
            return;
        }
        boolean claimed;
        try {
            claimed = lambdaUpdate()
                    .eq(TCardChargingOrder::getId, order.getId())
                    .eq(TCardChargingOrder::getLockReleased, 0)
                    .set(TCardChargingOrder::getLockReleased, 1)
                    .update();
        } catch (Exception ex) {
            log.error("[SD建单] 抢占锁定额释放标记异常 scene={} code={}", scene, order.getCode(), ex);
            return;
        }
        if (!claimed) {
            log.info("[SD建单] 锁定额已释放,跳过重复释放 scene={} code={}", scene, order.getCode());
            return;
        }
        boolean unlocked = false;
        try {
            R<BigDecimal> unlockResult;
            if (Integer.valueOf(ChargeAccountSupport.ACCOUNT_MODE_ALLOCATED).equals(order.getAccountMode())) {
@@ -847,31 +868,61 @@
            } else {
                unlockResult = groupCompanyClient.unlockBalance(order.getCompanyId(), amount);
            }
            if (unlockResult == null || unlockResult.getCode() != R.SUCCESS) {
            unlocked = unlockResult != null && unlockResult.getCode() == R.SUCCESS;
            if (!unlocked) {
                log.error("[SD建单] 释放账户锁定额失败 scene={} code={} amount={} msg={}", scene, order.getCode(),
                        amount, unlockResult == null ? "null" : unlockResult.getMsg());
            }
        } catch (Exception ex) {
            log.error("[SD建单] 释放账户锁定额异常 scene={} code={} amount={}", scene, order.getCode(), amount, ex);
        }
        if (!unlocked) {
            lambdaUpdate()
                    .eq(TCardChargingOrder::getId, order.getId())
                    .eq(TCardChargingOrder::getLockReleased, 1)
                    .set(TCardChargingOrder::getLockReleased, 0)
                    .update();
        }
    }
    /**
     * 回滚删除订单及其计费策略快照(建单失败场景;快照可能尚未写入,删除为幂等操作)
     */
    private void removeOrderWithSnapshot(Long orderId) {
        if (orderId == null) {
            return;
        }
        try {
            cardAccountingStrategyDetailOrderService.remove(
                    new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TCardAccountingStrategyDetailOrder>()
                            .eq(TCardAccountingStrategyDetailOrder::getChargingOrderId, orderId));
            cardAccountingStrategyOrderService.remove(
                    new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TCardAccountingStrategyOrder>()
                            .eq(TCardAccountingStrategyOrder::getChargingOrderId, orderId));
        } catch (Exception ex) {
            log.error("[SD建单] 清理计费策略快照异常 orderId={}", orderId, ex);
        }
        removeById(orderId);
    }
    /**
     * 保存计费策略快照(主表 + 明细表),与 CD 订单快照逻辑一致
     *
     * @return 是否成功写入快照(策略ID为空、策略/明细不存在或明细为空、持久化失败均返回 false,调用方需回滚建单)
     */
    private void saveStrategySnapshot(Long orderId, Integer strategyId) {
    private boolean saveStrategySnapshot(Long orderId, Integer strategyId) {
        if (strategyId == null) {
            return;
            return false;
        }
        TAccountingStrategy strategy = accountingStrategyClient.getAccountingStrategyById(strategyId).getData();
        List<TAccountingStrategyDetail> details = accountingStrategyDetailClient.getListByAccountingStrategyId(strategyId).getData();
        if (strategy == null || details == null || details.isEmpty()) {
            return;
            return false;
        }
        TCardAccountingStrategyOrder strategyOrder = new TCardAccountingStrategyOrder();
        BeanUtils.copyProperties(strategy, strategyOrder);
        strategyOrder.setChargingOrderId(orderId);
        cardAccountingStrategyOrderService.save(strategyOrder);
        boolean mainSaved = cardAccountingStrategyOrderService.save(strategyOrder);
        List<TCardAccountingStrategyDetailOrder> detailOrders = new ArrayList<>();
        for (TAccountingStrategyDetail detail : details) {
            TCardAccountingStrategyDetailOrder detailOrder = new TCardAccountingStrategyDetailOrder();
@@ -879,7 +930,8 @@
            detailOrder.setChargingOrderId(orderId);
            detailOrders.add(detailOrder);
        }
        cardAccountingStrategyDetailOrderService.saveBatch(detailOrders);
        boolean detailSaved = cardAccountingStrategyDetailOrderService.saveBatch(detailOrders);
        return mainSaved && detailSaved;
    }
    /**
@@ -1049,7 +1101,7 @@
    /**
     * 判断是否触发授权时间到期主动停充(提前 AUTHORIZE_STOP_AHEAD_SECONDS 秒下发,规避数据传输延迟):
     * 授权时长不足提前量时按授权时长的一半提前,避免刚启动即被停充;
     * 授权总时长不足固定提前量时按整个授权时长提前(启动后即触发停充),确保授权截止时间前结束充电;
     * maxEndTime 为空(全天授权/未开启自动停充)时不介入。
     */
    private boolean needAuthorizeStop(TCardChargingOrder order) {
@@ -1383,10 +1435,10 @@
                }
                afterBalance = settleResult.getData().getAfterBalance();
            } else {
                // 订单侧自行写消费流水,此处不传订单号(账户侧仅在订单号非空时做幂等与流水,避免重复记账)
                // 共享模式:传订单号启用账户侧结算幂等(账户侧按订单号+变更类型落企业消费流水并作为已结算标记)
                R<BigDecimal> settleResult =
                        groupCompanyClient.settleBalance(order.getCompanyId(), lockedAmount, actual,
                                null, null, null, null);
                                code, order.getCardNo(), null, "刷卡充电结算");
                if (settleResult == null || settleResult.getCode() != R.SUCCESS) {
                    log.error("[SD结算] 企业账户结算失败 code={} companyId={} locked={} actual={} msg={}", code,
                            order.getCompanyId(), lockedAmount, actual,
@@ -1400,7 +1452,10 @@
                    lockedAmount, actual, ex);
            return false;
        }
        recordCardConsumeAllocate(order, actual, lockedAmount, afterBalance, code);
        if (allocated) {
            // 分配模式消费流水记卡账户;共享模式消费流水已由账户侧 settleBalance 按订单号写入,避免重复记账
            recordCardConsumeAllocate(order, actual, lockedAmount, afterBalance, code);
        }
        log.info("[SD结算] 账户结算完成 code={} accountMode={} locked={} actual={} balance={}", code,
                order.getAccountMode(), lockedAmount, actual, afterBalance);
        return true;
@@ -1438,7 +1493,8 @@
    }
    /**
     * 写企业额度分配记录-消费流水(分配模式记该卡账户;共享模式记企业账户):
     * 写企业额度分配记录-消费流水(仅卡侧扣款场景:分配模式结算与历史订单卡扣款,记该卡账户):
     * 共享模式消费流水由账户侧 settleBalance 按订单号写入(同一订单号+变更类型只能有一个写入方,避免重复记账);
     * 消费前余额 = 结算后余额 + 实际消费 - 锁定额度(实扣冻结口径反推);失败不影响结算主流程
     */
    private void recordCardConsumeAllocate(TCardChargingOrder order, BigDecimal actual, BigDecimal lockedAmount,
@@ -1448,21 +1504,8 @@
        }
        BigDecimal after = afterBalance == null ? BigDecimal.ZERO : afterBalance;
        BigDecimal before = after.add(actual).subtract(lockedAmount);
        boolean allocated = Integer.valueOf(ChargeAccountSupport.ACCOUNT_MODE_ALLOCATED).equals(order.getAccountMode());
        String userInfo;
        String userName;
        if (allocated) {
            userInfo = order.getCardNo();
            userName = order.getCardNo();
        } else {
            TGroupCompany company = chargeAccountSupport.queryCompanyQuietly(order.getCompanyId());
            userInfo = company == null || StringUtils.isEmpty(company.getName())
                    ? String.valueOf(order.getCompanyId()) : company.getName();
            userName = company == null ? null : company.getName();
        }
        chargeAccountSupport.recordConsume(order.getCompanyId(),
                allocated ? TCompanyAllocateRecord.USER_TYPE_CARD : TCompanyAllocateRecord.USER_TYPE_COMPANY,
                userInfo, userName, before, actual.negate(), code, "刷卡充电结算");
        chargeAccountSupport.recordConsume(order.getCompanyId(), TCompanyAllocateRecord.USER_TYPE_CARD,
                order.getCardNo(), order.getCardNo(), before, actual.negate(), code, "刷卡充电结算");
    }
    private boolean rollbackFinishedToStopping(Long orderId, String code) {
@@ -1622,14 +1665,21 @@
        List<String> orderCodes = candidates.stream().map(TCardChargingOrder::getCode)
                .filter(StringUtils::isNotEmpty).collect(Collectors.toList());
        // 双判据:卡侧扣款流水(分配模式/历史卡扣款)与企业侧消费流水(共享模式),任一存在即视为已结算
        R<List<String>> deductedResult = chargingCardClient.listDeductedOrderCodes(orderCodes);
        if (deductedResult == null || deductedResult.getCode() != R.SUCCESS || deductedResult.getData() == null) {
            log.error("[SD扣款补偿] 查询扣款流水失败,本轮不修改订单 msg={}",
            log.error("[SD扣款补偿] 查询卡侧扣款流水失败,本轮不修改订单 msg={}",
                    deductedResult == null ? "null" : deductedResult.getMsg());
            return 0;
        }
        java.util.Set<String> consumedCodes = chargeAccountSupport.listConsumedOrderCodesQuietly(orderCodes);
        if (consumedCodes == null) {
            log.error("[SD扣款补偿] 查询企业侧消费流水失败,本轮不修改订单");
            return 0;
        }
        java.util.Set<String> deductedCodes = new java.util.HashSet<>(deductedResult.getData());
        deductedCodes.addAll(consumedCodes);
        int recovered = 0;
        for (TCardChargingOrder candidate : candidates) {
            if (StringUtils.isEmpty(candidate.getCode()) || deductedCodes.contains(candidate.getCode())) {
@@ -1642,8 +1692,14 @@
                }
                R<List<String>> recheck = chargingCardClient.listDeductedOrderCodes(
                        Collections.singletonList(candidate.getCode()));
                if (recheck == null || recheck.getCode() != R.SUCCESS || recheck.getData() == null
                        || recheck.getData().contains(candidate.getCode())) {
                if (recheck == null || recheck.getCode() != R.SUCCESS || recheck.getData() == null) {
                    continue;
                }
                // 双判据复核:任一侧查询失败或存在流水即不回退(保守处理,避免重复扣款)
                java.util.Set<String> consumedRecheck = chargeAccountSupport.listConsumedOrderCodesQuietly(
                        Collections.singletonList(candidate.getCode()));
                if (consumedRecheck == null || recheck.getData().contains(candidate.getCode())
                        || consumedRecheck.contains(candidate.getCode())) {
                    continue;
                }
                if (rollbackFinishedToStopping(candidate.getId(), candidate.getCode())) {
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderServiceImpl.java
@@ -1406,7 +1406,7 @@
    /**
     * 判断是否触发授权时间到期主动停充(提前 AUTHORIZE_STOP_AHEAD_SECONDS 秒下发,规避数据传输延迟):
     * 授权时长不足提前量时按授权时长的一半提前,避免刚启动即被停充;
     * 授权总时长不足固定提前量时按整个授权时长提前(启动后即触发停充),确保授权截止时间前结束充电;
     * maxEndTime 为空(全天授权/未开启自动停充)时不介入。
     */
    private boolean needAuthorizeStop(TVinChargingOrder order) {
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/support/ChargeAccountSupport.java
@@ -26,14 +26,17 @@
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
 * VIN码/刷卡充电公共账户支持(VIN 与刷卡共用,防止两套逻辑漂移):
 * 1) 企业充电授权窗口解析(时间方式=全天/指定时段/指定电价时段)与授权截止时间计算;
 * 2) 企业优惠设置装载(1=不优惠,2=服务费按比例优惠,3=按计费模板优惠单价收费);
 * 3) 授权时间到期停充判定(提前量 = min(60秒, 授权时长/2));
 * 4) 企业额度分配记录-消费流水写入(charge_type=2,按订单号+变更类型幂等,失败不影响主流程);
 * 3) 授权时间到期停充判定(提前量 = min(60秒, 授权总时长));
 * 4) 企业额度分配记录-消费流水写入与已结算订单号查询(charge_type=2,按订单号+变更类型幂等,失败不影响主流程);
 * 5) 充电账户上下文(账户模式/锁定额度/授权截止时间/失败码)承载。
 */
@Component
@@ -47,7 +50,7 @@
    public static final int ACCOUNT_MODE_ALLOCATED = 2;
    /** 未配置充电授权时的默认下发金额上限(元) */
    public static final BigDecimal DEFAULT_DISPATCH_AMOUNT = new BigDecimal("200");
    /** 授权时间到期停充提前量(秒):剩余授权时间不足该值时提前下发停充,规避数据传输延迟 */
    /** 授权时间到期停充提前量(秒):固定提前量,剩余授权时间不足该值即下发停充(授权总时长更短时按总时长提前) */
    public static final int AUTHORIZE_STOP_AHEAD_SECONDS = 60;
    /** 授权时间方式:全天 */
    public static final int AUTHORIZE_TIME_MODE_ALL_DAY = 1;
@@ -361,8 +364,8 @@
    }
    /**
     * 授权时间到期停充判定:剩余授权时间不足提前量时需主动停充;
     * 授权时长不足提前量时按授权时长的一半提前,避免刚启动即被停充;
     * 授权时间到期停充判定:剩余授权时间不足固定提前量(60 秒)时需主动停充,规避数据传输延迟;
     * 授权总时长不足提前量时按整个授权时长提前(表现为启动后即触发停充),确保授权截止时间前结束充电;
     * maxEndTime 为空(全天授权/未开启自动停充)时不介入。
     */
    public boolean needAuthorizeStop(LocalDateTime maxEndTime, LocalDateTime startTime) {
@@ -378,7 +381,7 @@
            return true;
        }
        long authorizeSeconds = Duration.between(startTime, maxEndTime).getSeconds();
        long aheadSeconds = Math.min(AUTHORIZE_STOP_AHEAD_SECONDS, Math.max(authorizeSeconds / 2, 1));
        long aheadSeconds = Math.min(AUTHORIZE_STOP_AHEAD_SECONDS, Math.max(authorizeSeconds, 1));
        return remainSeconds <= aheadSeconds;
    }
@@ -396,6 +399,28 @@
    }
    /**
     * 批量查询已落消费流水的订单号(订单侧兜底扫描双判据使用)
     *
     * @return 已结算订单号集合;查询失败返回 null 表示无法确认,调用方须按「不得回退订单状态」保守处理
     */
    public Set<String> listConsumedOrderCodesQuietly(List<String> orderCodes) {
        if (orderCodes == null || orderCodes.isEmpty()) {
            return Collections.emptySet();
        }
        try {
            R<List<String>> result = companyAllocateRecordClient.listConsumedOrderCodes(orderCodes);
            if (result == null || result.getCode() != R.SUCCESS || result.getData() == null) {
                log.error("[充电账户] 查询已结算订单号失败 msg={}", result == null ? "null" : result.getMsg());
                return null;
            }
            return new HashSet<>(result.getData());
        } catch (Exception ex) {
            log.error("[充电账户] 查询已结算订单号异常", ex);
            return null;
        }
    }
    /**
     * 写企业额度分配记录-消费流水(订单号+change_type 幂等):失败不影响结算主流程
     *
     * @param amount 本次变更金额(消费传负数)
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TCardChargingOrderAmountFieldsTest.java
@@ -9,15 +9,20 @@
import com.ruoyi.account.api.feignClient.ChargingCardClient;
import com.ruoyi.account.api.feignClient.CompanyAllocateRecordClient;
import com.ruoyi.account.api.model.TChargingCard;
import com.ruoyi.account.api.model.TCompanyAllocateRecord;
import com.ruoyi.account.api.vo.ChargingCardDeductVO;
import com.ruoyi.chargingPile.api.feignClient.AccountingStrategyClient;
import com.ruoyi.chargingPile.api.feignClient.AccountingStrategyDetailClient;
import com.ruoyi.chargingPile.api.feignClient.BillingTemplateClient;
import com.ruoyi.chargingPile.api.feignClient.ChargingGunClient;
import com.ruoyi.chargingPile.api.model.TAccountingStrategy;
import com.ruoyi.chargingPile.api.model.TAccountingStrategyDetail;
import com.ruoyi.chargingPile.api.model.TChargingGun;
import com.ruoyi.chargingPile.api.vo.GetChargingGunByCode;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.order.api.dto.DeviceStartChargingDTO;
import com.ruoyi.order.api.model.TCardAccountingStrategyDetailOrder;
import com.ruoyi.order.api.model.TCardAccountingStrategyOrder;
import com.ruoyi.order.api.model.TCardChargingOrder;
import com.ruoyi.order.api.model.TCardChargingOrderAccountingStrategy;
import com.ruoyi.order.api.query.UploadRealTimeMonitoringDataQuery;
@@ -25,6 +30,7 @@
import com.ruoyi.order.api.vo.TransactionRecordMessageVO;
import com.ruoyi.order.mapper.TCardChargingOrderMapper;
import com.ruoyi.order.service.TCardAccountingStrategyDetailOrderService;
import com.ruoyi.order.service.TCardAccountingStrategyOrderService;
import com.ruoyi.order.service.TCardChargingOrderAccountingStrategyService;
import com.ruoyi.order.support.ChargeAccountSupport;
import com.ruoyi.other.api.domain.TGroupCompany;
@@ -48,9 +54,12 @@
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
@@ -80,14 +89,24 @@
     * 纯单测不启 Spring 容器,此处内联装配,远程依赖以宽松 mock 兜底(内部均有判空与兜底),
     * 需要走真实分支的用例可再覆盖具体依赖字段。
     */
    /** 账户侧分配流水客户端(与 ChargeAccountSupport 内持有的是同一 mock,便于补偿双判据用例 stub 与校验) */
    private CompanyAllocateRecordClient companyAllocateRecordClient;
    private ChargeAccountSupport newChargeAccountSupport() {
        ChargeAccountSupport support = new ChargeAccountSupport();
        ReflectionTestUtils.setField(support, "accountingStrategyDetailClient", mock(AccountingStrategyDetailClient.class));
        ReflectionTestUtils.setField(support, "groupCompanyClient", mock(GroupCompanyClient.class));
        ReflectionTestUtils.setField(support, "companyPreferentialClient", mock(CompanyPreferentialClient.class));
        ReflectionTestUtils.setField(support, "billingTemplateClient", mock(BillingTemplateClient.class));
        ReflectionTestUtils.setField(support, "companyAllocateRecordClient", mock(CompanyAllocateRecordClient.class));
        companyAllocateRecordClient = mock(CompanyAllocateRecordClient.class);
        ReflectionTestUtils.setField(support, "companyAllocateRecordClient", companyAllocateRecordClient);
        return support;
    }
    /** 企业侧无消费流水(补偿双判据默认:卡侧与企业侧均可确认未结算) */
    private void mockNoCompanyConsumeRecord() {
        R<java.util.List<String>> empty = okWithData(Collections.emptyList());
        when(companyAllocateRecordClient.listConsumedOrderCodes(any())).thenReturn(empty);
    }
    private RLock mockedLock(boolean tryLockResult) throws InterruptedException {
@@ -131,11 +150,17 @@
        ReflectionTestUtils.setField(chargeAccountSupport, "groupCompanyClient", groupCompanyClient);
        ReflectionTestUtils.setField(service, "chargeAccountSupport", chargeAccountSupport);
        ReflectionTestUtils.setField(service, "redissonClient", mockedRedisson(mockedLock(true)));
        when(mapper.insert(any(TCardChargingOrder.class))).thenReturn(1);
        // 订单主键在真实库里由 MyBatis-Plus 回填,纯单测手工回填,保证策略快照与锁定额度回写能拿到订单ID
        when(mapper.insert(any(TCardChargingOrder.class))).thenAnswer(invocation -> {
            TCardChargingOrder entity = invocation.getArgument(0);
            entity.setId(100L);
            return 1;
        });
        TChargingGun gun = new TChargingGun();
        gun.setId(9);
        gun.setStatus(2);
        gun.setAccountingStrategyId(31);
        R<TChargingGun> gunResult = okWithData(gun);
        when(gunClient.getChargingGunByCode(any(GetChargingGunByCode.class))).thenReturn(gunResult);
@@ -158,6 +183,24 @@
        when(groupCompanyClient.lockBalance(any(), any())).thenReturn(lockBalanceResult);
        R<Boolean> lockResult = okWithData(true);
        when(cardClient.lockForCharging(anyLong(), anyString())).thenReturn(lockResult);
        // 点5 改造后建单必须先成功写入计费策略快照(否则回滚删单):枪配置策略 31,策略与明细均可查且快照落库成功
        AccountingStrategyClient accountingStrategyClient = mock(AccountingStrategyClient.class);
        AccountingStrategyDetailClient accountingStrategyDetailClient = mock(AccountingStrategyDetailClient.class);
        TCardAccountingStrategyOrderService strategyOrderService = mock(TCardAccountingStrategyOrderService.class);
        TCardAccountingStrategyDetailOrderService strategyDetailOrderService = mock(TCardAccountingStrategyDetailOrderService.class);
        ReflectionTestUtils.setField(service, "accountingStrategyClient", accountingStrategyClient);
        ReflectionTestUtils.setField(service, "accountingStrategyDetailClient", accountingStrategyDetailClient);
        ReflectionTestUtils.setField(service, "cardAccountingStrategyOrderService", strategyOrderService);
        ReflectionTestUtils.setField(service, "cardAccountingStrategyDetailOrderService", strategyDetailOrderService);
        R<TAccountingStrategy> strategyResult = okWithData(new TAccountingStrategy());
        when(accountingStrategyClient.getAccountingStrategyById(31)).thenReturn(strategyResult);
        TAccountingStrategyDetail strategyDetail = new TAccountingStrategyDetail();
        strategyDetail.setType(1);
        R<java.util.List<TAccountingStrategyDetail>> strategyDetailResult =
                okWithData(Collections.singletonList(strategyDetail));
        when(accountingStrategyDetailClient.getListByAccountingStrategyId(31)).thenReturn(strategyDetailResult);
        when(strategyOrderService.save(any(TCardAccountingStrategyOrder.class))).thenReturn(true);
        when(strategyDetailOrderService.saveBatch(any(Collection.class))).thenReturn(true);
        DeviceStartChargingDTO dto = new DeviceStartChargingDTO();
        dto.setChargingPileCode("P1");
@@ -378,6 +421,7 @@
        when(mapper.update(any(), any())).thenReturn(1);
        R<java.util.List<String>> noDeductRecords = okWithData(Collections.emptyList());
        when(cardClient.listDeductedOrderCodes(any())).thenReturn(noDeductRecords);
        mockNoCompanyConsumeRecord();
        assertEquals(1, service.recoverFinishedOrdersWithoutDeduct());
        verify(cardClient, times(2)).listDeductedOrderCodes(any());
@@ -468,9 +512,206 @@
        when(mapper.selectList(any())).thenReturn(Collections.singletonList(candidate));
        R<java.util.List<String>> noDeductRecords = okWithData(Collections.emptyList());
        when(cardClient.listDeductedOrderCodes(any())).thenReturn(noDeductRecords);
        mockNoCompanyConsumeRecord();
        assertEquals(0, service.recoverFinishedOrdersWithoutDeduct());
        verify(cardClient, times(1)).listDeductedOrderCodes(any());
        verify(mapper, never()).update(any(), any());
    }
    @Test
    void compensationSkipsFinishedOrderWithCompanyConsumeRecord() {
        // 共享模式订单的消费流水落在企业侧:卡侧无流水但企业侧命中时必须保持已完成状态,不得回退造成重复扣款
        TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl();
        ReflectionTestUtils.setField(service, "chargeAccountSupport", newChargeAccountSupport());
        TCardChargingOrderMapper mapper = mock(TCardChargingOrderMapper.class);
        ChargingCardClient cardClient = mock(ChargingCardClient.class);
        ReflectionTestUtils.setField(service, "baseMapper", mapper);
        ReflectionTestUtils.setField(service, "chargingCardClient", cardClient);
        TCardChargingOrder shared = new TCardChargingOrder();
        shared.setId(6L);
        shared.setCode("SD-SHARED");
        shared.setStatus(5);
        shared.setCardId(5L);
        shared.setOrderAmount(BigDecimal.ONE);
        when(mapper.selectList(any())).thenReturn(Collections.singletonList(shared));
        R<java.util.List<String>> noDeductRecords = okWithData(Collections.emptyList());
        when(cardClient.listDeductedOrderCodes(any())).thenReturn(noDeductRecords);
        R<java.util.List<String>> consumeRecords = okWithData(Collections.singletonList("SD-SHARED"));
        when(companyAllocateRecordClient.listConsumedOrderCodes(any())).thenReturn(consumeRecords);
        assertEquals(0, service.recoverFinishedOrdersWithoutDeduct());
        verify(cardClient, times(1)).listDeductedOrderCodes(any());
        verify(mapper, never()).update(any(), any());
    }
    @Test
    void compensationKeepsFinishedStatusWhenCompanyConsumeQueryFails() {
        // 企业侧流水查询失败说明无法确认结算结果,必须保持原状态等待下轮,避免误回退后重复扣款
        TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl();
        ReflectionTestUtils.setField(service, "chargeAccountSupport", newChargeAccountSupport());
        TCardChargingOrderMapper mapper = mock(TCardChargingOrderMapper.class);
        ChargingCardClient cardClient = mock(ChargingCardClient.class);
        ReflectionTestUtils.setField(service, "baseMapper", mapper);
        ReflectionTestUtils.setField(service, "chargingCardClient", cardClient);
        TCardChargingOrder candidate = new TCardChargingOrder();
        candidate.setId(7L);
        candidate.setCode("SD-CONSUME-UNKNOWN");
        candidate.setStatus(5);
        candidate.setCardId(5L);
        candidate.setOrderAmount(BigDecimal.ONE);
        when(mapper.selectList(any())).thenReturn(Collections.singletonList(candidate));
        R<java.util.List<String>> noDeductRecords = okWithData(Collections.emptyList());
        when(cardClient.listDeductedOrderCodes(any())).thenReturn(noDeductRecords);
        when(companyAllocateRecordClient.listConsumedOrderCodes(any())).thenReturn(R.fail("account unavailable"));
        assertEquals(0, service.recoverFinishedOrdersWithoutDeduct());
        verify(mapper, never()).update(any(), any());
    }
    @Test
    void rollsBackOrderWhenNoAccountingStrategyConfigured() throws InterruptedException {
        // 点5:枪与站点均未配置计费策略(strategyId 为空)时必须回滚删单并回告启动失败,禁止产生无策略订单
        TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl();
        TCardChargingOrderMapper mapper = mock(TCardChargingOrderMapper.class);
        ChargingCardClient cardClient = mock(ChargingCardClient.class);
        ChargingGunClient gunClient = mock(ChargingGunClient.class);
        GroupCompanyClient groupCompanyClient = mock(GroupCompanyClient.class);
        ChargeAuthorizationClient chargeAuthorizationClient = mock(ChargeAuthorizationClient.class);
        ReflectionTestUtils.setField(service, "baseMapper", mapper);
        ReflectionTestUtils.setField(service, "chargingCardClient", cardClient);
        ReflectionTestUtils.setField(service, "chargingGunClient", gunClient);
        ReflectionTestUtils.setField(service, "chargeAuthorizationClient", chargeAuthorizationClient);
        ReflectionTestUtils.setField(service, "groupCompanyClient", groupCompanyClient);
        ChargeAccountSupport chargeAccountSupport = newChargeAccountSupport();
        ReflectionTestUtils.setField(chargeAccountSupport, "groupCompanyClient", groupCompanyClient);
        ReflectionTestUtils.setField(service, "chargeAccountSupport", chargeAccountSupport);
        ReflectionTestUtils.setField(service, "redissonClient", mockedRedisson(mockedLock(true)));
        when(mapper.insert(any(TCardChargingOrder.class))).thenAnswer(invocation -> {
            TCardChargingOrder entity = invocation.getArgument(0);
            entity.setId(101L);
            return 1;
        });
        when(mapper.deleteById(anyLong())).thenReturn(1);
        TChargingGun gun = new TChargingGun();
        gun.setId(9);
        gun.setStatus(2);
        R<TChargingGun> gunResult = okWithData(gun);
        when(gunClient.getChargingGunByCode(any(GetChargingGunByCode.class))).thenReturn(gunResult);
        TChargingCard card = new TChargingCard();
        card.setId(5L);
        card.setCardNo("C001");
        card.setCompanyId(77);
        card.setBalance(new BigDecimal("100.00"));
        R<TChargingCard> verifyResult = okWithData(card);
        when(cardClient.verifyCharging(any(ChargingCardAuthDTO.class))).thenReturn(verifyResult);
        TGroupCompany company = new TGroupCompany();
        company.setId(77);
        company.setStatus(1);
        company.setAccountMode(1);
        company.setAvailableBalance(new BigDecimal("100.00"));
        R<TGroupCompany> companyResult = okWithData(company);
        when(groupCompanyClient.getCompanyById(77)).thenReturn(companyResult);
        DeviceStartChargingDTO dto = new DeviceStartChargingDTO();
        dto.setChargingPileCode("P1");
        dto.setChargingGunCode("G1");
        dto.setAccount("C001");
        DeviceStartChargingVO result = service.startCardCharging(dto);
        assertEquals(0, result.getAuthentication());
        // 订单回滚删除,且未锁定账户额度、未锁卡
        verify(mapper).deleteById(anyLong());
        verify(groupCompanyClient, never()).lockBalance(any(), any());
        verify(cardClient, never()).lockForCharging(anyLong(), anyString());
    }
    @Test
    void sharedModeSettlementPassesOrderCodeAndSkipsLocalConsumeRecord() {
        // 共享模式:结算传订单号启用账户侧幂等(账户侧写企业消费流水),订单侧不得再写一份导致重复记账
        TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl();
        ReflectionTestUtils.setField(service, "chargeAccountSupport", newChargeAccountSupport());
        GroupCompanyClient groupCompanyClient = mock(GroupCompanyClient.class);
        ReflectionTestUtils.setField(service, "groupCompanyClient", groupCompanyClient);
        TCardChargingOrder order = new TCardChargingOrder();
        order.setId(8L);
        order.setCode("SD-SHARED-SETTLE");
        order.setCardId(5L);
        order.setCardNo("C001");
        order.setCompanyId(77);
        order.setAccountMode(ChargeAccountSupport.ACCOUNT_MODE_SHARED);
        order.setLockedAmount(new BigDecimal("200.00"));
        R<BigDecimal> settleResult = okWithData(new BigDecimal("663.50"));
        when(groupCompanyClient.settleBalance(eq(77), eq(new BigDecimal("200.00")), eq(new BigDecimal("36.50")),
                eq("SD-SHARED-SETTLE"), eq("C001"), isNull(), eq("刷卡充电结算"))).thenReturn(settleResult);
        Boolean settled = ReflectionTestUtils.invokeMethod(service, "settleCardChargeAccount", order,
                new BigDecimal("36.50"), "SD-SHARED-SETTLE");
        assertTrue(settled);
        verify(companyAllocateRecordClient, never()).recordConsume(any(), any(), any(), any(), any(), any(), any(),
                any());
    }
    @Test
    void allocatedModeSettlementWritesCardConsumeRecord() {
        // 分配模式:扣的是卡账户余额,消费流水仍由订单侧记到该卡上(user_type=卡)
        TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl();
        ReflectionTestUtils.setField(service, "chargeAccountSupport", newChargeAccountSupport());
        ChargingCardClient cardClient = mock(ChargingCardClient.class);
        ReflectionTestUtils.setField(service, "chargingCardClient", cardClient);
        TCardChargingOrder order = new TCardChargingOrder();
        order.setId(9L);
        order.setCode("SD-ALLOC-SETTLE");
        order.setCardId(5L);
        order.setCardNo("C001");
        order.setCompanyId(77);
        order.setAccountMode(ChargeAccountSupport.ACCOUNT_MODE_ALLOCATED);
        order.setLockedAmount(new BigDecimal("200.00"));
        ChargingCardDeductVO deductVO = new ChargingCardDeductVO();
        deductVO.setDuplicated(false);
        deductVO.setAfterBalance(new BigDecimal("63.50"));
        R<ChargingCardDeductVO> settleCardResult = okWithData(deductVO);
        when(cardClient.settleForCharging(any(ChargingCardDeductDTO.class))).thenReturn(settleCardResult);
        Boolean settled = ReflectionTestUtils.invokeMethod(service, "settleCardChargeAccount", order,
                new BigDecimal("36.50"), "SD-ALLOC-SETTLE");
        assertTrue(settled);
        verify(companyAllocateRecordClient).recordConsume(eq(77), eq(TCompanyAllocateRecord.USER_TYPE_CARD),
                eq("C001"), eq("C001"), any(), eq(new BigDecimal("-36.50")), eq("SD-ALLOC-SETTLE"),
                eq("刷卡充电结算"));
    }
    @Test
    void skipsUnlockWhenLockReleasedFlagAlreadySet() {
        // 点2-D:lock_released 抢占未命中(标记已是 1)说明锁定额已释放过,禁止重复解锁
        TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl();
        TCardChargingOrderMapper mapper = mock(TCardChargingOrderMapper.class);
        GroupCompanyClient groupCompanyClient = mock(GroupCompanyClient.class);
        ChargingCardClient cardClient = mock(ChargingCardClient.class);
        ReflectionTestUtils.setField(service, "baseMapper", mapper);
        ReflectionTestUtils.setField(service, "groupCompanyClient", groupCompanyClient);
        ReflectionTestUtils.setField(service, "chargingCardClient", cardClient);
        when(mapper.update(any(), any())).thenReturn(0);
        TCardChargingOrder order = new TCardChargingOrder();
        order.setId(10L);
        order.setCode("SD-RELEASED");
        order.setCompanyId(77);
        order.setAccountMode(ChargeAccountSupport.ACCOUNT_MODE_SHARED);
        order.setLockedAmount(new BigDecimal("200.00"));
        ReflectionTestUtils.invokeMethod(service, "unlockCardChargeContextQuietly", order, "启动关单");
        verify(groupCompanyClient, never()).unlockBalance(any(), any());
    }
}
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TVinChargingOrderAuthorizeStopTest.java
@@ -125,15 +125,21 @@
    }
    @Test
    void shortAuthorizationUsesHalfOfDurationAsAheadWindow() {
        // 授权时长 90 秒 → 提前量 min(60, 45)=45 秒,剩余 30 秒时触发停充
    void shortAuthorizationUsesFixedSixtySecondsAheadWindow() {
        // 授权时长 90 秒 → 提前量 min(60, 90)=60 秒,剩余 30 秒时触发停充
        assertTrue(needStop(order(LocalDateTime.now().minusSeconds(60), LocalDateTime.now().plusSeconds(30), null)));
    }
    @Test
    void shortAuthorizationIsNotStoppedImmediately() {
        // 授权时长 100 秒 → 提前量 min(60, 50)=50 秒,剩余 55 秒时仍继续充电
        assertFalse(needStop(order(LocalDateTime.now().minusSeconds(45), LocalDateTime.now().plusSeconds(55), null)));
    void shortAuthorizationStopsImmediatelyWithinFixedAheadWindow() {
        // 授权时长 100 秒:固定 60 秒提前量,剩余 55 秒即触发停充(不再按授权时长的一半收缩)
        assertTrue(needStop(order(LocalDateTime.now().minusSeconds(45), LocalDateTime.now().plusSeconds(55), null)));
    }
    @Test
    void veryShortAuthorizationUsesWholeDurationAsAheadWindow() {
        // 授权总时长 30 秒不足提前量 → 按整个授权时长提前,剩余 20 秒即停充
        assertTrue(needStop(order(LocalDateTime.now().minusSeconds(10), LocalDateTime.now().plusSeconds(20), null)));
    }
    @Test
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/controller/TFleetController.java
@@ -15,6 +15,7 @@
import com.ruoyi.other.api.dto.FleetExportDTO;
import com.ruoyi.other.api.dto.FleetImportDTO;
import com.ruoyi.other.api.dto.FleetQueryDTO;
import com.ruoyi.other.service.CompanyAccountAccessService;
import com.ruoyi.other.service.TFleetService;
import com.ruoyi.other.service.TGroupCompanyService;
import io.swagger.annotations.ApiOperation;
@@ -57,6 +58,9 @@
    @Resource
    private TGroupCompanyService groupCompanyService;
    @Resource
    private CompanyAccountAccessService companyAccountAccessService;
    /**
     * 分页查询车队
     */
@@ -68,6 +72,8 @@
                                             @RequestParam(value = "fleetName", required = false) String fleetName,
                                             @RequestParam(value = "companyId", required = false) Integer companyId,
                                             @RequestParam(value = "status", required = false) Integer status) {
        // 公司账号强制按所属公司过滤,覆盖前端传入的公司筛选
        companyId = companyAccountAccessService.resolveCompanyFilter(companyId);
        PageInfo<TFleet> page = new PageInfo<>(current, size);
        fleetService.lambdaQuery()
                .like(StringUtils.hasText(fleetName), TFleet::getFleetName, fleetName)
@@ -89,6 +95,8 @@
    @ApiOperation(value = "添加车队", tags = {"管理后台-车队管理"})
    @Log(title = "【车队管理】添加车队", businessType = BusinessType.INSERT)
    public AjaxResult add(@RequestBody TFleet fleet) {
        // 公司账号新增车队时强制归属本公司
        fleet.setCompanyId(companyAccountAccessService.resolveCompanyFilter(fleet.getCompanyId()));
        if (fleet.getCompanyId() == null) {
            return AjaxResult.error("所属公司不能为空");
        }
@@ -115,6 +123,13 @@
        if (fleet.getId() == null) {
            return AjaxResult.error("车队ID不能为空");
        }
        TFleet existing = fleetService.getById(fleet.getId());
        if (existing == null) {
            return AjaxResult.error("车队不存在或已删除");
        }
        // 公司账号越权校验:只能编辑本公司车队,且不允许改挂到其他公司
        companyAccountAccessService.assertCompanyVisible(existing.getCompanyId());
        fleet.setCompanyId(companyAccountAccessService.resolveCompanyFilter(fleet.getCompanyId()));
        fleetService.updateById(fleet);
        return AjaxResult.success();
    }
@@ -138,6 +153,14 @@
        if (idList.isEmpty()) {
            return AjaxResult.error("车队ID不能为空");
        }
        // 公司账号越权校验:只能删除本公司车队
        List<TFleet> records = fleetService.listByIds(idList);
        if (records == null || records.size() != idList.size()) {
            return AjaxResult.error("部分车队不存在或已删除");
        }
        for (TFleet record : records) {
            companyAccountAccessService.assertCompanyVisible(record.getCompanyId());
        }
        fleetService.deleteFleets(idList);
        return AjaxResult.success();
    }
@@ -153,6 +176,12 @@
        if (id == null) {
            return AjaxResult.error("车队ID不能为空");
        }
        TFleet record = fleetService.getById(id);
        if (record == null) {
            return AjaxResult.error("车队不存在或已删除");
        }
        // 公司账号越权校验:只能操作本公司车队
        companyAccountAccessService.assertCompanyVisible(record.getCompanyId());
        TFleet update = new TFleet();
        update.setId(id);
        update.setStatus(status);
@@ -190,6 +219,11 @@
    @ApiOperation(value = "导出车队", tags = {"管理后台-车队管理"})
    @Log(title = "【车队管理】导出车队", businessType = BusinessType.EXPORT)
    public void export(HttpServletResponse response, @RequestBody(required = false) FleetQueryDTO query) {
        if (query == null) {
            query = new FleetQueryDTO();
        }
        // 公司账号强制按所属公司过滤,覆盖前端传入的公司筛选
        query.setCompanyId(companyAccountAccessService.resolveCompanyFilter(query.getCompanyId()));
        new ExcelUtil<>(FleetExportDTO.class).exportExcel(response, fleetService.selectExportList(query), "车队数据");
    }
@@ -207,6 +241,8 @@
        if (fleet == null) {
            return AjaxResult.error("车队不存在或已删除");
        }
        // 公司账号越权校验:只能查看本公司车队
        companyAccountAccessService.assertCompanyVisible(fleet.getCompanyId());
        if (fleet.getCompanyId() != null) {
            TGroupCompany company = groupCompanyService.getById(fleet.getCompanyId());
            if (company != null) {
@@ -227,6 +263,12 @@
    public AjaxResult<Page<TVinManagement>> vinPage(@RequestParam("id") Integer id,
                                                    @RequestParam(value = "current", defaultValue = "1") Integer current,
                                                    @RequestParam(value = "size", defaultValue = "10") Integer size) {
        TFleet fleet = fleetService.getById(id);
        if (fleet == null) {
            return AjaxResult.error("车队不存在或已删除");
        }
        // 公司账号越权校验:只能查看本公司车队下的VIN
        companyAccountAccessService.assertCompanyVisible(fleet.getCompanyId());
        return AjaxResult.success(fleetService.selectVinPage(id, current, size));
    }
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/CompanyAccountAccessService.java
New file
@@ -0,0 +1,93 @@
package com.ruoyi.other.service;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.exception.ServiceException;
import com.ruoyi.common.security.service.TokenService;
import com.ruoyi.common.security.utils.SecurityUtils;
import com.ruoyi.system.api.domain.SysUser;
import com.ruoyi.system.api.feignClient.SysUserClient;
import com.ruoyi.system.api.model.LoginUser;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.Objects;
/**
 * 公司后台账号(roleType=5 且已绑定公司)数据范围及归属校验。
 */
@Slf4j
@Service
public class CompanyAccountAccessService {
    private static final Integer COMPANY_ROLE_TYPE = 5;
    @Resource
    private TokenService tokenService;
    @Resource
    private SysUserClient sysUserClient;
    /**
     * 当前登录账号若为公司账号,返回其所属公司ID;非公司账号返回null。
     */
    public Integer getCurrentCompanyId() {
        SysUser sysUser = getCurrentSysUser();
        if (!isCompany(sysUser)) {
            return null;
        }
        return sysUser.getObjectId();
    }
    /**
     * 数据范围强制:公司账号只允许访问本公司数据,覆盖前端传入的公司筛选;非公司账号保持原值。
     */
    public Integer resolveCompanyFilter(Integer requestCompanyId) {
        Integer currentCompanyId = getCurrentCompanyId();
        return currentCompanyId != null ? currentCompanyId : requestCompanyId;
    }
    /**
     * 公司账号数据归属校验:记录不属于本公司时拒绝操作;非公司账号不做限制。
     */
    public void assertCompanyVisible(Integer recordCompanyId) {
        Integer currentCompanyId = getCurrentCompanyId();
        if (currentCompanyId == null) {
            return;
        }
        if (!Objects.equals(currentCompanyId, recordCompanyId)) {
            throw new ServiceException("无权操作其他公司的数据");
        }
    }
    private boolean isCompany(SysUser sysUser) {
        return sysUser != null && Objects.equals(COMPANY_ROLE_TYPE, sysUser.getRoleType())
                && sysUser.getObjectId() != null;
    }
    private SysUser getCurrentSysUser() {
        LoginUser loginUser = null;
        try {
            loginUser = tokenService.getLoginUser();
        } catch (Exception ignored) {
            // 尝试从安全上下文继续获取。
        }
        if (loginUser == null) {
            try {
                loginUser = SecurityUtils.getLoginUser();
            } catch (Exception ignored) {
                // 当前请求不是管理后台登录上下文。
            }
        }
        if (loginUser == null) {
            return null;
        }
        if (loginUser.getSysUser() != null && loginUser.getSysUser().getRoleType() != null) {
            return loginUser.getSysUser();
        }
        if (loginUser.getUserid() == null) {
            return loginUser.getSysUser();
        }
        R<SysUser> userResult = sysUserClient.getSysUser(loginUser.getUserid());
        return userResult != null && userResult.getData() != null ? userResult.getData() : loginUser.getSysUser();
    }
}
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TFleetServiceImpl.java
@@ -19,6 +19,7 @@
import com.ruoyi.other.api.vo.FleetOrderAggregateVO;
import com.ruoyi.other.mapper.TChargeAuthorizationMapper;
import com.ruoyi.other.mapper.TFleetMapper;
import com.ruoyi.other.service.CompanyAccountAccessService;
import com.ruoyi.other.service.TFleetService;
import com.ruoyi.other.service.TGroupCompanyService;
import org.springframework.stereotype.Service;
@@ -30,6 +31,7 @@
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
@@ -66,6 +68,9 @@
    @Resource
    private FleetUnbindClient fleetUnbindClient;
    @Resource
    private CompanyAccountAccessService companyAccountAccessService;
    @Override
    public Page<TVinManagement> selectVinPage(Integer fleetId, Integer current, Integer size) {
@@ -111,6 +116,8 @@
        if (items == null || items.isEmpty()) {
            throw new ServiceException("导入数据不能为空");
        }
        // 公司账号导入车队时强制归属本公司,Excel中的所属公司仅作展示信息
        Integer lockedCompanyId = companyAccountAccessService.getCurrentCompanyId();
        Set<String> fleetNames = new HashSet<>();
        Set<String> companyNames = new HashSet<>();
        int row = 1;
@@ -123,17 +130,19 @@
            if (fleetName.length() > 128) {
                throw new ServiceException("第" + row + "行车队名称不能超过128个字符");
            }
            if (!StringUtils.hasText(item.getCompanyName())) {
            if (lockedCompanyId == null && !StringUtils.hasText(item.getCompanyName())) {
                throw new ServiceException("第" + row + "行所属公司不能为空");
            }
            parseStatus(item.getStatus(), row);
            if (!fleetNames.add(fleetName) || lambdaQuery().eq(TFleet::getFleetName, fleetName).count() > 0) {
                throw new ServiceException("第" + row + "行车队名称已存在或重复");
            }
            companyNames.add(item.getCompanyName().trim());
            if (lockedCompanyId == null && StringUtils.hasText(item.getCompanyName())) {
                companyNames.add(item.getCompanyName().trim());
            }
        }
        // 批量查询公司名称 -> ID
        Map<String, Integer> companyMap = groupCompanyService.lambdaQuery()
        Map<String, Integer> companyMap = companyNames.isEmpty() ? new HashMap<>() : groupCompanyService.lambdaQuery()
                .in(TGroupCompany::getName, companyNames)
                .list()
                .stream()
@@ -141,15 +150,15 @@
        row = 1;
        for (FleetImportDTO item : items) {
            row++;
            String companyName = item.getCompanyName().trim();
            if (!companyMap.containsKey(companyName)) {
            String companyName = item.getCompanyName() == null ? null : item.getCompanyName().trim();
            if (lockedCompanyId == null && (companyName == null || !companyMap.containsKey(companyName))) {
                throw new ServiceException("第" + row + "行所属公司不存在:" + companyName);
            }
        }
        LocalDateTime now = LocalDateTime.now();
        for (FleetImportDTO item : items) {
            TFleet fleet = new TFleet();
            fleet.setCompanyId(companyMap.get(item.getCompanyName().trim()));
            fleet.setCompanyId(lockedCompanyId != null ? lockedCompanyId : companyMap.get(item.getCompanyName().trim()));
            fleet.setFleetName(item.getFleetName().trim());
            fleet.setContactName(trim(item.getContactName()));
            fleet.setContactPhone(trim(item.getContactPhone()));
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImpl.java
@@ -58,8 +58,8 @@
    /** 金额保留小数位(与 t_company_recharge_record.arrival_amount 一致) */
    private static final int AMOUNT_SCALE = 2;
    /** 公司账号角色标识(内置角色 role_key=company) */
    private static final String COMPANY_ROLE_KEY = "company";
    /** 公司管理员账号角色标识(内置角色 role_key=company_admin) */
    private static final String COMPANY_ROLE_KEY = "company_admin";
    /** 公司账号角色类型(sys_user.roleType=5) */
    private static final int COMPANY_ROLE_TYPE = 5;
@@ -357,7 +357,7 @@
    }
    /**
     * 查询公司默认角色ID(内置角色 role_key=company)
     * 查询公司默认角色ID(内置角色 role_key=company_admin)
     */
    private Long resolveCompanyRoleId() {
        R<Long> roleR = sysRoleClient.getRoleIdByRoleKey(COMPANY_ROLE_KEY);
sql/20260930_card_order_lock_released.sql
New file
@@ -0,0 +1,77 @@
-- 刷卡充电改造(order库):订单表新增锁定额度释放幂等标记列
-- 目的:
--   t_card_charging_order 新增 lock_released(锁定额度是否已释放:建单回滚、启动超时关单等释放路径先用
--   条件更新抢占该标记,保证同一订单的企业/卡锁定额在多路径并发或重复补偿下只释放一次;
--   释放失败时把标记回滚为 0,留待后续补偿或人工重试)
-- 创建日期: 2026-09-30
-- 目标库:charging_pile_order(t_card_charging_order 为分片表:TBPARTITION BY MOD_HASH(分片键) TBPARTITIONS 20)
-- 执行方式:
--   1) 首选「小节 A」经数据库代理(MyCat 8066)执行,代理会把 DDL 广播到全部物理分表;
--   2) 代理不支持分片表 DDL 时,改用「小节 B」直连后端 MySQL 实例执行(存储过程,逐物理分表,幂等);
--   3) 最后执行「小节 C」回查校验。
-- 幂等说明:小节 A 为裸 ALTER,重复执行会报 Duplicate column,忽略即可(以小节 C 回查为准);
--           小节 B 先查 information_schema 再变更,可重复执行。
-- ============ 小节 A:经代理执行(推荐) ============
USE `charging_pile_order`;
ALTER TABLE `t_card_charging_order`
  ADD COLUMN `lock_released` tinyint(1) NOT NULL DEFAULT 0 COMMENT '锁定额度是否已释放(0=未释放,1=已释放;防重复释放幂等标记)';
-- ============ 小节 B:直连后端 MySQL 实例执行(代理不可用时分表执行,幂等) ============
USE `charging_pile_order`;
DROP PROCEDURE IF EXISTS `add_card_order_lock_released`;
DELIMITER $$
CREATE PROCEDURE `add_card_order_lock_released`()
BEGIN
    DECLARE done INT DEFAULT 0;
    DECLARE target_table VARCHAR(64);
    DECLARE column_exists INT DEFAULT 0;
    -- 覆盖逻辑表(若物理存在)与 TBPARTITION 物理分表
    DECLARE table_cursor CURSOR FOR
        SELECT t.table_name
        FROM information_schema.tables t
        WHERE t.table_schema = DATABASE()
          AND t.table_name REGEXP '^t_card_charging_order(_[0-9]+)?$';
    DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
    OPEN table_cursor;
    table_loop: LOOP
        FETCH table_cursor INTO target_table;
        IF done = 1 THEN
            LEAVE table_loop;
        END IF;
        SELECT COUNT(*) INTO column_exists
        FROM information_schema.columns
        WHERE table_schema = DATABASE()
          AND table_name = target_table
          AND column_name = 'lock_released';
        IF column_exists = 0 THEN
            SET @ddl = CONCAT('ALTER TABLE `', target_table, '` ADD COLUMN `lock_released` tinyint(1) NOT NULL DEFAULT 0 COMMENT ''锁定额度是否已释放(0=未释放,1=已释放;防重复释放幂等标记)''');
            PREPARE stmt FROM @ddl;
            EXECUTE stmt;
            DEALLOCATE PREPARE stmt;
        END IF;
    END LOOP;
    CLOSE table_cursor;
END$$
DELIMITER ;
CALL `add_card_order_lock_released`();
DROP PROCEDURE IF EXISTS `add_card_order_lock_released`;
-- ============ 小节 C:回查校验 ============
-- 1) 列存在性与注释(应覆盖逻辑表与全部物理分表):
--    SELECT table_name, column_name, column_type, column_default, column_comment
--    FROM information_schema.columns
--    WHERE table_schema = 'charging_pile_order' AND column_name = 'lock_released'
--    ORDER BY table_name;
--    预期:t_card_charging_order 及 t_card_charging_order_0..19 均存在,
--          column_type=tinyint(1)、column_default=0、column_comment 中文无乱码
-- 2) 历史订单默认未释放:
--    SELECT COUNT(*) FROM t_card_charging_order WHERE lock_released = 0;
--    预期:等于订单总数(存量订单全部为未释放状态,建单回滚路径可正常释放)
sql/20260930_company_admin_role_menu.sql
New file
@@ -0,0 +1,168 @@
-- =============================================================================
-- 2026-09-30 公司管理员角色(company_admin):角色创建、默认菜单、账号绑定迁移
-- -----------------------------------------------------------------------------
-- 前置说明:
--   1) 本项目 sys_menu 表无 del_flag 列,脚本中不使用逻辑删除条件
--   2) 全部语句幂等,可重复执行
--   3) 车队管理菜单树为新建(M/C/F);卡片、白名单仅补齐缺失按钮
--   4) company_admin 默认菜单 = 车队管理 + VIN码管理 + 卡片管理(不含刷卡充电订单、
--      卡片收支明细子页)+ 白名单用户(含全部操作按钮),白名单挂载在「用户管理」下
--   5) 旧 company 角色不物理删除,迁移绑定后停用(status=1)
-- =============================================================================
SET NAMES utf8mb4;
-- -----------------------------------------------------------------------------
-- 1. 车队管理菜单树(前端路由 /fleet -> /fleet/list;页面 accessKey=/fleet)
-- -----------------------------------------------------------------------------
-- 1.1 车队管理(目录 M)
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT '车队管理', 0, 21, '/fleet', NULL, NULL, 1, 0, 'M', '0', '0', '/fleet', 'peoples', 'admin', NOW(), '公司管理员默认菜单'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_type = 'M' AND path = '/fleet');
-- 1.2 车队列表(菜单 C)
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT '车队列表', m.menu_id, 1, 'list', 'fleet', NULL, 1, 0, 'C', '0', '0', '/fleet/list', 'list', 'admin', NOW(), '公司管理员默认菜单'
FROM sys_menu m
WHERE m.menu_type = 'M' AND m.path = '/fleet'
  AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_type = 'C' AND perms = '/fleet/list');
-- 1.3 车队按钮(F):与前端 accessKey 一致
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT '车队新增', c.menu_id, 1, '#', NULL, NULL, 1, 0, 'F', '0', '0', '/fleet/add', '#', 'admin', NOW(), '公司管理员默认菜单'
FROM sys_menu c
WHERE c.menu_type = 'C' AND c.perms = '/fleet/list'
  AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = '/fleet/add');
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT '车队编辑', c.menu_id, 2, '#', NULL, NULL, 1, 0, 'F', '0', '0', '/fleet/edit', '#', 'admin', NOW(), '公司管理员默认菜单'
FROM sys_menu c
WHERE c.menu_type = 'C' AND c.perms = '/fleet/list'
  AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = '/fleet/edit');
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT '车队删除', c.menu_id, 3, '#', NULL, NULL, 1, 0, 'F', '0', '0', '/fleet/delete', '#', 'admin', NOW(), '公司管理员默认菜单'
FROM sys_menu c
WHERE c.menu_type = 'C' AND c.perms = '/fleet/list'
  AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = '/fleet/delete');
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT '车队启停', c.menu_id, 4, '#', NULL, NULL, 1, 0, 'F', '0', '0', '/fleet/status', '#', 'admin', NOW(), '公司管理员默认菜单'
FROM sys_menu c
WHERE c.menu_type = 'C' AND c.perms = '/fleet/list'
  AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = '/fleet/status');
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT '车队导入', c.menu_id, 5, '#', NULL, NULL, 1, 0, 'F', '0', '0', '/fleet/import', '#', 'admin', NOW(), '公司管理员默认菜单'
FROM sys_menu c
WHERE c.menu_type = 'C' AND c.perms = '/fleet/list'
  AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = '/fleet/import');
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT '车队导出', c.menu_id, 6, '#', NULL, NULL, 1, 0, 'F', '0', '0', '/fleet/export', '#', 'admin', NOW(), '公司管理员默认菜单'
FROM sys_menu c
WHERE c.menu_type = 'C' AND c.perms = '/fleet/list'
  AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = '/fleet/export');
-- -----------------------------------------------------------------------------
-- 2. 卡片管理:补齐解绑按钮(挂到卡片管理列表菜单 301 下)
-- -----------------------------------------------------------------------------
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT '解绑', c.menu_id, 9, '#', NULL, NULL, 1, 0, 'F', '0', '0', '/chargingCard/unbind', '#', 'admin', NOW(), '公司管理员默认菜单'
FROM sys_menu c
WHERE c.menu_type = 'C' AND c.perms = '/chargingCard/list'
  AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = '/chargingCard/unbind');
-- -----------------------------------------------------------------------------
-- 3. 白名单用户:补齐解绑、额度分配按钮(挂到白名单用户菜单 237 下)
-- -----------------------------------------------------------------------------
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT '解绑', c.menu_id, 8, '#', NULL, NULL, 1, 0, 'F', '0', '0', '/appUserWhitelist/unbind', '#', 'admin', NOW(), '公司管理员默认菜单'
FROM sys_menu c
WHERE c.menu_type = 'C' AND c.perms = '/appUserWhitelist/list'
  AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = '/appUserWhitelist/unbind');
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT '额度分配', c.menu_id, 9, '#', NULL, NULL, 1, 0, 'F', '0', '0', '/appUserWhitelist/allocate', '#', 'admin', NOW(), '公司管理员默认菜单'
FROM sys_menu c
WHERE c.menu_type = 'C' AND c.perms = '/appUserWhitelist/list'
  AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = '/appUserWhitelist/allocate');
-- -----------------------------------------------------------------------------
-- 4. 创建公司管理员角色 company_admin(role_key/role_name 双重判重)
-- -----------------------------------------------------------------------------
INSERT INTO sys_role (role_name, role_key, data_scope, status, del_flag, create_by, create_time, remark)
SELECT '公司管理员', 'company_admin', '1', '0', '0', 'admin', NOW(), '系统内置角色'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM sys_role WHERE del_flag = '0' AND (role_key = 'company_admin' OR role_name = '公司管理员'));
-- -----------------------------------------------------------------------------
-- 5. company_admin 默认菜单绑定(只补不删,卡片模块排除收支明细子页)
-- -----------------------------------------------------------------------------
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT r.role_id, m.menu_id
FROM sys_role r
JOIN sys_menu m ON (
        m.perms LIKE '/fleet%' OR m.path LIKE '/fleet%'
     OR m.perms LIKE '/vinManagement%'
     OR (m.perms LIKE '/chargingCard%' AND m.perms NOT LIKE '/chargingCardBalanceDetail%')
     OR m.perms LIKE '/appUserWhitelist%'
)
WHERE r.del_flag = '0' AND r.role_key = 'company_admin'
  AND NOT EXISTS (SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = r.role_id AND rm.menu_id = m.menu_id);
-- 5.1 白名单菜单挂在「用户管理」(/appUser) 下,补全其父级菜单保证菜单树可见
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT r.role_id, c.parent_id
FROM sys_role r
JOIN sys_menu c ON c.menu_type = 'C' AND c.perms = '/appUserWhitelist/list'
WHERE r.del_flag = '0' AND r.role_key = 'company_admin'
  AND c.parent_id IS NOT NULL AND c.parent_id > 0
  AND NOT EXISTS (SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = r.role_id AND rm.menu_id = c.parent_id);
-- -----------------------------------------------------------------------------
-- 6. 超级管理员(admin)同步新增菜单,保证平台可见并可分配
-- -----------------------------------------------------------------------------
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT r.role_id, m.menu_id
FROM sys_role r
JOIN sys_menu m ON (
        m.path LIKE '/fleet%' OR m.perms LIKE '/fleet%'
     OR m.perms IN ('/chargingCard/unbind', '/appUserWhitelist/unbind', '/appUserWhitelist/allocate')
)
WHERE r.del_flag = '0' AND r.role_key = 'admin'
  AND NOT EXISTS (SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = r.role_id AND rm.menu_id = m.menu_id);
-- -----------------------------------------------------------------------------
-- 7. 公司账号(roleType=5)迁移绑定 company_admin(保留原 company 绑定,双角色无冲突)
-- -----------------------------------------------------------------------------
INSERT INTO sys_user_role (user_id, role_id)
SELECT u.user_id, r.role_id
FROM sys_user u
JOIN sys_role r ON r.role_key = 'company_admin' AND r.del_flag = '0'
WHERE u.roleType = 5 AND u.del_flag = '0' AND u.objectId IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM sys_user_role ur WHERE ur.user_id = u.user_id AND ur.role_id = r.role_id);
-- -----------------------------------------------------------------------------
-- 8. 旧 company 角色停用(保留记录,不物理删除)
-- -----------------------------------------------------------------------------
UPDATE sys_role SET status = '1' WHERE role_key = 'company' AND del_flag = '0' AND status <> '1';
-- =============================================================================
-- 回查语句(执行后人工核对,不参与脚本执行)
-- =============================================================================
-- 8.1 角色
--   SELECT role_id, role_name, role_key, status, del_flag FROM sys_role WHERE role_key IN ('company', 'company_admin');
-- 8.2 车队菜单树
--   SELECT menu_id, parent_id, menu_name, path, component, perms, menu_type FROM sys_menu WHERE path LIKE '/fleet%' OR perms LIKE '/fleet%' ORDER BY menu_id;
-- 8.3 新增按钮
--   SELECT menu_id, parent_id, menu_name, perms FROM sys_menu WHERE perms IN ('/chargingCard/unbind', '/appUserWhitelist/unbind', '/appUserWhitelist/allocate');
-- 8.4 company_admin 绑定数量(测试库实测 40 条:车队 8 + VIN 10 + 卡片 11(历史存在 304/305 两个同权限"额度分配"按钮)+ 白名单 10 + 用户管理祖先 1)
--   SELECT COUNT(*) FROM sys_role_menu rm JOIN sys_role r ON r.role_id = rm.role_id WHERE r.role_key = 'company_admin';
-- 8.5 公司账号绑定
--   SELECT u.user_id, u.user_name, u.roleType, u.objectId, r.role_key FROM sys_user u
--     JOIN sys_user_role ur ON ur.user_id = u.user_id JOIN sys_role r ON r.role_id = ur.role_id
--     WHERE u.roleType = 5 AND u.delFlag = '0';
-- 8.6 确认未把收支明细/刷卡订单绑给 company_admin(应为 0)
--   SELECT COUNT(*) FROM sys_role_menu rm JOIN sys_role r ON r.role_id = rm.role_id JOIN sys_menu m ON m.menu_id = rm.menu_id
--     WHERE r.role_key = 'company_admin' AND (m.perms LIKE '/chargingCardBalanceDetail%' OR m.perms LIKE '/cardChargingOrder%');