| | |
| | | | 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(公司余额更新与流水写入强一致)未纳入;开始与结束时间未精确记录,按估算标注 | |
| | |
| | | @ApiModelProperty("优惠服务费合计(原始服务费-最终服务费)") |
| | | @TableField("discount_service_charge") |
| | | private BigDecimal discountServiceCharge; |
| | | |
| | | @ApiModelProperty("锁定额度是否已释放(0=未释放,1=已释放;建单回滚/关单路径幂等标记,防重复释放)") |
| | | @TableField("lock_released") |
| | | private Integer lockReleased; |
| | | } |
| | |
| | | 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; |
| | |
| | | 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 = "系统内置角色"; |
| | | |
| | |
| | | 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) |
| | |
| | | role.setUpdateTime(new Date()); |
| | | roleService.updateById(role); |
| | | } |
| | | if (repairMenus) { |
| | | repairRoleMenus(role.getRoleId(), menuIds); |
| | | } |
| | | return; |
| | | } |
| | | |
| | |
| | | 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() { |
| | |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 公司管理员默认菜单:车队管理、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())); |
| | |
| | | || 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) { |
| | |
| | | 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())); |
| New file |
| | |
| | | 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)); |
| | | } |
| | | } |
| | |
| | | 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 |
| | |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 当前登录账号若为公司账号(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); |
| | | } |
| | |
| | | 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; |
| | |
| | | @Resource |
| | | private TCompanyAllocateRecordService companyAllocateRecordService; |
| | | |
| | | @Resource |
| | | private PartnerAppUserAccessService partnerAppUserAccessService; |
| | | |
| | | @Override |
| | | public Page<TAppUserWhitelist> pageList(AppUserWhitelistQueryDTO queryDTO) { |
| | | AppUserWhitelistQueryDTO safeQuery = queryDTO == null ? new AppUserWhitelistQueryDTO() : queryDTO; |
| | |
| | | */ |
| | | 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() |
| | |
| | | throw new ServiceException("白名单用户不存在或已删除"); |
| | | } |
| | | assertPartnerVisible(existing); |
| | | // 公司账号越权校验:只能操作本公司数据 |
| | | partnerAppUserAccessService.assertCompanyVisible(existing.getCompanyId()); |
| | | if (Objects.equals(existing.getStatus(), statusDTO.getStatus())) { |
| | | // 状态未变化视为幂等成功,避免无意义的更新人与更新时间变更 |
| | | return true; |
| | |
| | | throw new ServiceException("白名单用户不存在"); |
| | | } |
| | | assertPartnerVisible(whitelist); |
| | | // 公司账号越权校验:只能查看本公司数据 |
| | | partnerAppUserAccessService.assertCompanyVisible(whitelist.getCompanyId()); |
| | | fillDisplay(Collections.singletonList(whitelist)); |
| | | return whitelist; |
| | | } |
| | |
| | | continue; |
| | | } |
| | | assertPartnerVisible(whitelist); |
| | | // 公司账号越权校验:只能删除本公司数据 |
| | | partnerAppUserAccessService.assertCompanyVisible(whitelist.getCompanyId()); |
| | | this.removeById(whitelistId); |
| | | whitelistSiteService.remove(Wrappers.lambdaQuery(TAppUserWhitelistSite.class) |
| | | .eq(TAppUserWhitelistSite::getWhitelistId, whitelistId)); |
| | |
| | | } |
| | | for (TAppUserWhitelist record : records) { |
| | | assertPartnerVisible(record); |
| | | // 公司账号越权校验:只能解绑本公司数据 |
| | | partnerAppUserAccessService.assertCompanyVisible(record.getCompanyId()); |
| | | } |
| | | List<Long> boundIds = records.stream() |
| | | .filter(record -> record.getFleetId() != null) |
| | |
| | | |
| | | @Override |
| | | public AppUserWhitelistAllocateVO getAllocateOverview(Integer companyId) { |
| | | // 公司账号只能查看本公司额度概览,覆盖前端传入的公司 |
| | | companyId = partnerAppUserAccessService.resolveCompanyFilter(companyId); |
| | | if (companyId == null) { |
| | | throw new ServiceException("请选择公司"); |
| | | } |
| | |
| | | @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(); |
| | | // 公司必须存在、启用且为分配模式(共享模式不支持额度分配) |
| | |
| | | throw new ServiceException("白名单用户不存在"); |
| | | } |
| | | assertPartnerVisible(existing); |
| | | // 公司账号越权校验:只能编辑本公司数据 |
| | | partnerAppUserAccessService.assertCompanyVisible(existing.getCompanyId()); |
| | | // 同白名单并发编辑串行化:额度差额计算、公司余额扣减/退还必须在临界区内,防止重复扣减或重复退还 |
| | | RLock lock = redissonClient.getLock(QUOTA_LOCK_PREFIX + saveDTO.getId()); |
| | | boolean locked = tryQuotaLock(lock, "该白名单额度正在处理中,请稍后重试"); |
| | |
| | | |
| | | /** 校验并回填所属公司与车队:车队必须归属所选公司;未选公司时按车队自动带出公司 */ |
| | | private Integer resolveRelationCompanyId(AppUserWhitelistSaveDTO saveDTO) { |
| | | // 公司账号新增/编辑白名单用户时强制归属本公司,覆盖前端传入的公司 |
| | | saveDTO.setCompanyId(partnerAppUserAccessService.resolveCompanyFilter(saveDTO.getCompanyId())); |
| | | Integer companyId = saveDTO.getCompanyId(); |
| | | if (companyId != null && loadCompany(companyId) == null) { |
| | | throw new ServiceException("所属公司不存在或已删除"); |
| | |
| | | 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; |
| | |
| | | @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); |
| | |
| | | if (existing == null) { |
| | | throw new ServiceException("卡片不存在或已删除"); |
| | | } |
| | | // 公司账号越权校验:只能编辑本公司数据 |
| | | partnerAppUserAccessService.assertCompanyVisible(existing.getCompanyId()); |
| | | assertNotChargingLocked(existing, "修改卡片或重置卡密码"); |
| | | // 同卡片并发编辑串行化:额度差额计算、公司余额扣减/退还必须在临界区内,防止重复扣减或重复退还 |
| | | RLock lock = redissonClient.getLock(QUOTA_LOCK_PREFIX + dto.getId()); |
| | |
| | | if (cards.size() != distinctIds.size()) { |
| | | throw new ServiceException("部分卡片不存在或已删除"); |
| | | } |
| | | // 公司账号越权校验:只能删除本公司数据 |
| | | for (TChargingCard card : cards) { |
| | | partnerAppUserAccessService.assertCompanyVisible(card.getCompanyId()); |
| | | } |
| | | assertNotChargingLocked(cards, "删除卡片"); |
| | | if (!removeByIds(distinctIds)) { |
| | | throw new ServiceException("删除卡片失败"); |
| | |
| | | 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) |
| | |
| | | if (existing == null) { |
| | | throw new ServiceException("卡片不存在或已删除"); |
| | | } |
| | | // 公司账号越权校验:只能操作本公司数据 |
| | | partnerAppUserAccessService.assertCompanyVisible(existing.getCompanyId()); |
| | | assertNotChargingLocked(existing, "启用/禁用卡片"); |
| | | TChargingCard card = new TChargingCard(); |
| | | card.setId(dto.getId()); |
| | |
| | | |
| | | @Override |
| | | public ChargingCardAllocateVO getAllocateOverview(Integer companyId) { |
| | | // 公司账号只能查看本公司额度概览,覆盖前端传入的公司 |
| | | companyId = partnerAppUserAccessService.resolveCompanyFilter(companyId); |
| | | if (companyId == null) { |
| | | throw new ServiceException("请选择公司"); |
| | | } |
| | |
| | | @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(); |
| | | // 公司必须存在、启用且为分配模式(共享模式不支持额度分配) |
| | |
| | | |
| | | @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; |
| | | } |
| | |
| | | |
| | | 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); |
| | |
| | | * 校验并回填所属公司与车队:车队必须归属所选公司;未选公司时按车队自动带出公司 |
| | | */ |
| | | 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("所属公司不存在或已删除"); |
| | |
| | | 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; |
| | |
| | | @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); |
| | |
| | | if (old == null) { |
| | | throw new ServiceException("VIN码不存在或已删除"); |
| | | } |
| | | // 公司账号越权校验:只能编辑本公司数据 |
| | | partnerAppUserAccessService.assertCompanyVisible(old.getCompanyId()); |
| | | TVinManagement item = from(dto); |
| | | item.setId(dto.getId()); |
| | | item.setUpdateTime(LocalDateTime.now()); |
| | |
| | | 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 置空),公司、初始分配额度、状态等不受影响; |
| | |
| | | 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) |
| | |
| | | } |
| | | 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; |
| | | } |
| | |
| | | } |
| | | @Override |
| | | public VinManagementAllocateVO getAllocateOverview(Integer companyId) { |
| | | // 公司账号只能查看本公司额度概览,覆盖前端传入的公司 |
| | | companyId = partnerAppUserAccessService.resolveCompanyFilter(companyId); |
| | | if (companyId == null) { |
| | | throw new ServiceException("请选择公司"); |
| | | } |
| | |
| | | @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(); |
| | | // 公司必须存在、启用且为分配模式(共享模式不支持额度分配) |
| | |
| | | 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(); } |
| | |
| | | * 校验并回填所属公司与车队:车队必须归属所选公司;未选公司时按车队自动带出公司;公司名称以公司档案为准 |
| | | */ |
| | | 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) { |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | @Mock private GroupCompanyClient groupCompanyClient; |
| | | @Mock private RedissonClient redissonClient; |
| | | @Mock private TCompanyAllocateRecordService companyAllocateRecordService; |
| | | @Mock private PartnerAppUserAccessService partnerAppUserAccessService; |
| | | |
| | | private TAppUserWhitelistServiceImpl service; |
| | | |
| | |
| | | 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); |
| | |
| | | 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)); |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | @Mock private GroupCompanyClient groupCompanyClient; |
| | | @Mock private RedissonClient redissonClient; |
| | | @Mock private RLock quotaLock; |
| | | @Mock private PartnerAppUserAccessService partnerAppUserAccessService; |
| | | private TChargingCardServiceImpl service; |
| | | |
| | | @BeforeAll |
| | |
| | | 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); |
| | |
| | | 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()); |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | @Mock |
| | | private RLock quotaLock; |
| | | |
| | | @Mock |
| | | private PartnerAppUserAccessService partnerAppUserAccessService; |
| | | |
| | | @Captor |
| | | private ArgumentCaptor<Page<TVinManagement>> pageCaptor; |
| | | |
| | |
| | | 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); |
| | |
| | | } |
| | | 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()); |
| | | } |
| | | } |
| | |
| | | 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={}", |
| | |
| | | } |
| | | |
| | | /** |
| | | * 释放账户锁定额(建单失败/回滚场景):共享=企业账户,分配=卡账户;失败仅告警,留待补偿任务/人工处理 |
| | | * 释放账户锁定额(建单失败/回滚/关单场景):共享=企业账户,分配=卡账户; |
| | | * 订单侧 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())) { |
| | |
| | | } 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(); |
| | |
| | | detailOrder.setChargingOrderId(orderId); |
| | | detailOrders.add(detailOrder); |
| | | } |
| | | cardAccountingStrategyDetailOrderService.saveBatch(detailOrders); |
| | | boolean detailSaved = cardAccountingStrategyDetailOrderService.saveBatch(detailOrders); |
| | | return mainSaved && detailSaved; |
| | | } |
| | | |
| | | /** |
| | |
| | | |
| | | /** |
| | | * 判断是否触发授权时间到期主动停充(提前 AUTHORIZE_STOP_AHEAD_SECONDS 秒下发,规避数据传输延迟): |
| | | * 授权时长不足提前量时按授权时长的一半提前,避免刚启动即被停充; |
| | | * 授权总时长不足固定提前量时按整个授权时长提前(启动后即触发停充),确保授权截止时间前结束充电; |
| | | * maxEndTime 为空(全天授权/未开启自动停充)时不介入。 |
| | | */ |
| | | private boolean needAuthorizeStop(TCardChargingOrder order) { |
| | |
| | | } |
| | | 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, |
| | |
| | | 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; |
| | |
| | | } |
| | | |
| | | /** |
| | | * 写企业额度分配记录-消费流水(分配模式记该卡账户;共享模式记企业账户): |
| | | * 写企业额度分配记录-消费流水(仅卡侧扣款场景:分配模式结算与历史订单卡扣款,记该卡账户): |
| | | * 共享模式消费流水由账户侧 settleBalance 按订单号写入(同一订单号+变更类型只能有一个写入方,避免重复记账); |
| | | * 消费前余额 = 结算后余额 + 实际消费 - 锁定额度(实扣冻结口径反推);失败不影响结算主流程 |
| | | */ |
| | | private void recordCardConsumeAllocate(TCardChargingOrder order, BigDecimal actual, BigDecimal lockedAmount, |
| | |
| | | } |
| | | 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) { |
| | |
| | | |
| | | 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())) { |
| | |
| | | } |
| | | 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())) { |
| | |
| | | |
| | | /** |
| | | * 判断是否触发授权时间到期主动停充(提前 AUTHORIZE_STOP_AHEAD_SECONDS 秒下发,规避数据传输延迟): |
| | | * 授权时长不足提前量时按授权时长的一半提前,避免刚启动即被停充; |
| | | * 授权总时长不足固定提前量时按整个授权时长提前(启动后即触发停充),确保授权截止时间前结束充电; |
| | | * maxEndTime 为空(全天授权/未开启自动停充)时不介入。 |
| | | */ |
| | | private boolean needAuthorizeStop(TVinChargingOrder order) { |
| | |
| | | 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 |
| | |
| | | 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; |
| | |
| | | } |
| | | |
| | | /** |
| | | * 授权时间到期停充判定:剩余授权时间不足提前量时需主动停充; |
| | | * 授权时长不足提前量时按授权时长的一半提前,避免刚启动即被停充; |
| | | * 授权时间到期停充判定:剩余授权时间不足固定提前量(60 秒)时需主动停充,规避数据传输延迟; |
| | | * 授权总时长不足提前量时按整个授权时长提前(表现为启动后即触发停充),确保授权截止时间前结束充电; |
| | | * maxEndTime 为空(全天授权/未开启自动停充)时不介入。 |
| | | */ |
| | | public boolean needAuthorizeStop(LocalDateTime maxEndTime, LocalDateTime startTime) { |
| | |
| | | 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; |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | /** |
| | | * 批量查询已落消费流水的订单号(订单侧兜底扫描双判据使用) |
| | | * |
| | | * @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 本次变更金额(消费传负数) |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | * 纯单测不启 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 { |
| | |
| | | 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); |
| | | |
| | |
| | | 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"); |
| | |
| | | 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()); |
| | |
| | | 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()); |
| | | } |
| | | } |
| | |
| | | } |
| | | |
| | | @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 |
| | |
| | | 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; |
| | |
| | | @Resource |
| | | private TGroupCompanyService groupCompanyService; |
| | | |
| | | @Resource |
| | | private CompanyAccountAccessService companyAccountAccessService; |
| | | |
| | | /** |
| | | * 分页查询车队 |
| | | */ |
| | |
| | | @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) |
| | |
| | | @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("所属公司不能为空"); |
| | | } |
| | |
| | | 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(); |
| | | } |
| | |
| | | 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(); |
| | | } |
| | |
| | | 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); |
| | |
| | | @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), "车队数据"); |
| | | } |
| | | |
| | |
| | | if (fleet == null) { |
| | | return AjaxResult.error("车队不存在或已删除"); |
| | | } |
| | | // 公司账号越权校验:只能查看本公司车队 |
| | | companyAccountAccessService.assertCompanyVisible(fleet.getCompanyId()); |
| | | if (fleet.getCompanyId() != null) { |
| | | TGroupCompany company = groupCompanyService.getById(fleet.getCompanyId()); |
| | | if (company != null) { |
| | |
| | | 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)); |
| | | } |
| | | |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | |
| | | @Resource |
| | | private FleetUnbindClient fleetUnbindClient; |
| | | |
| | | @Resource |
| | | private CompanyAccountAccessService companyAccountAccessService; |
| | | |
| | | @Override |
| | | public Page<TVinManagement> selectVinPage(Integer fleetId, Integer current, Integer size) { |
| | |
| | | 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; |
| | |
| | | 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() |
| | |
| | | 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())); |
| | |
| | | /** 金额保留小数位(与 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; |
| | |
| | | } |
| | | |
| | | /** |
| | | * 查询公司默认角色ID(内置角色 role_key=company) |
| | | * 查询公司默认角色ID(内置角色 role_key=company_admin) |
| | | */ |
| | | private Long resolveCompanyRoleId() { |
| | | R<Long> roleR = sysRoleClient.getRoleIdByRoleKey(COMPANY_ROLE_KEY); |
| New file |
| | |
| | | -- 刷卡充电改造(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; |
| | | -- 预期:等于订单总数(存量订单全部为未释放状态,建单回滚路径可正常释放) |
| New file |
| | |
| | | -- ============================================================================= |
| | | -- 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%'); |