From 26c4ce07ebd10e53be41ef02f5a02c7fb7ea9c29 Mon Sep 17 00:00:00 2001 From: lzh Date: Sat, 7 Mar 2026 21:06:10 +0800 Subject: [PATCH 01/35] =?UTF-8?q?feat(iot,ops):=20=E5=8C=BA=E5=9F=9F?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E5=85=B3=E8=81=94=E6=8E=A5=E5=8F=A3=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E6=9B=B4=E5=A4=9A=E8=AE=BE=E5=A4=87=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=20N+1=20=E5=92=8C=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E8=B4=A8=E9=87=8F=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IotDeviceSimpleRespDTO 新增 nickname、serialNumber、state、deviceType 字段 - IotDeviceQueryApi 新增 batchGetDevices 批量查询接口 - IotDeviceQueryApiImpl 提取 toSimpleDTO 统一转换、通过产品缓存解析 productName、 移除 blanket try-catch 让异常正确传播、删除无用 import - AreaDeviceRelationRespVO 新增 nickname、serialNumber、deviceState、deviceType 字段 - AreaDeviceRelationServiceImpl.listByAreaId 改为批量查询避免 N+1 RPC、 增加 null 防护;bindDevice 改为 fail-fast 不再存脏数据 - ErrorCodeConstants 新增 IOT_SERVICE_UNAVAILABLE 错误码 Co-Authored-By: Claude Opus 4.6 --- .../iot/api/device/IotDeviceQueryApi.java | 6 + .../device/dto/IotDeviceSimpleRespDTO.java | 12 + .../iot/api/device/IotDeviceQueryApiImpl.java | 94 +++++--- .../module/ops/enums/ErrorCodeConstants.java | 1 + .../vo/area/AreaDeviceRelationRespVO.java | 12 + .../area/AreaDeviceRelationServiceImpl.java | 211 ++++++++++-------- 6 files changed, 203 insertions(+), 133 deletions(-) diff --git a/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/device/IotDeviceQueryApi.java b/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/device/IotDeviceQueryApi.java index 1a5025d..24ee8ab 100644 --- a/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/device/IotDeviceQueryApi.java +++ b/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/device/IotDeviceQueryApi.java @@ -10,6 +10,7 @@ import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; +import java.util.Collection; import java.util.List; /** @@ -38,4 +39,9 @@ public interface IotDeviceQueryApi { @Parameter(name = "id", description = "设备ID", required = true) CommonResult getDevice(@RequestParam("id") Long id); + @GetMapping(PREFIX + "/batch-get") + @Operation(summary = "批量获取设备详情") + @Parameter(name = "ids", description = "设备ID列表", required = true, example = "[1,2,3]") + CommonResult> batchGetDevices(@RequestParam("ids") Collection ids); + } diff --git a/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/device/dto/IotDeviceSimpleRespDTO.java b/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/device/dto/IotDeviceSimpleRespDTO.java index a130329..1ca23d7 100644 --- a/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/device/dto/IotDeviceSimpleRespDTO.java +++ b/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/device/dto/IotDeviceSimpleRespDTO.java @@ -29,4 +29,16 @@ public class IotDeviceSimpleRespDTO { @Schema(description = "产品名称", example = "客流计数器") private String productName; + @Schema(description = "设备备注名称", example = "A栋3层客流计数器") + private String nickname; + + @Schema(description = "设备序列号", example = "SN20250101001") + private String serialNumber; + + @Schema(description = "设备状态(0-未激活 1-在线 2-离线)", example = "1") + private Integer state; + + @Schema(description = "设备类型", example = "10") + private Integer deviceType; + } diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/api/device/IotDeviceQueryApiImpl.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/api/device/IotDeviceQueryApiImpl.java index fed268f..1f08750 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/api/device/IotDeviceQueryApiImpl.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/api/device/IotDeviceQueryApiImpl.java @@ -2,9 +2,10 @@ package com.viewsh.module.iot.api.device; import com.viewsh.framework.common.pojo.CommonResult; import com.viewsh.module.iot.api.device.dto.IotDeviceSimpleRespDTO; -import com.viewsh.module.iot.controller.admin.device.vo.device.IotDeviceRespVO; import com.viewsh.module.iot.dal.dataobject.device.IotDeviceDO; +import com.viewsh.module.iot.dal.dataobject.product.IotProductDO; import com.viewsh.module.iot.service.device.IotDeviceService; +import com.viewsh.module.iot.service.product.IotProductService; import io.swagger.v3.oas.annotations.Operation; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; @@ -14,8 +15,8 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.Collection; import java.util.List; -import java.util.stream.Collectors; import static com.viewsh.framework.common.pojo.CommonResult.success; import static com.viewsh.module.iot.api.device.IotDeviceQueryApi.PREFIX; @@ -36,55 +37,74 @@ public class IotDeviceQueryApiImpl implements IotDeviceQueryApi { @Resource private IotDeviceService deviceService; + @Resource + private IotProductService productService; + @Override @GetMapping(PREFIX + "/simple-list") @Operation(summary = "获取设备精简列表(按类型/产品筛选)") public CommonResult> getDeviceSimpleList( @RequestParam(value = "deviceType", required = false) Integer deviceType, @RequestParam(value = "productId", required = false) Long productId) { - try { - List list = deviceService.getDeviceListByCondition(deviceType, productId); - List result = list.stream() - .map(device -> { - IotDeviceSimpleRespDTO dto = new IotDeviceSimpleRespDTO(); - dto.setId(device.getId()); - dto.setDeviceName(device.getDeviceName()); - dto.setProductId(device.getProductId()); - dto.setProductKey(device.getProductKey()); - // TODO: 从产品服务获取产品名称 - dto.setProductName("产品_" + device.getProductKey()); - return dto; - }) - .collect(Collectors.toList()); - return success(result); - } catch (Exception e) { - log.error("[getDeviceSimpleList] 查询设备列表失败: deviceType={}, productId={}", deviceType, productId, e); - return success(List.of()); - } + List list = deviceService.getDeviceListByCondition(deviceType, productId); + List result = list.stream() + .map(this::toSimpleDTO) + .toList(); + return success(result); } @Override @GetMapping(PREFIX + "/get") @Operation(summary = "获取设备详情") public CommonResult getDevice(@RequestParam("id") Long id) { - try { - IotDeviceDO device = deviceService.getDevice(id); - if (device == null) { - return success(null); - } - - IotDeviceSimpleRespDTO dto = new IotDeviceSimpleRespDTO(); - dto.setId(device.getId()); - dto.setDeviceName(device.getDeviceName()); - dto.setProductId(device.getProductId()); - dto.setProductKey(device.getProductKey()); - dto.setProductName("产品_" + device.getProductKey()); - - return success(dto); - } catch (Exception e) { - log.error("[getDevice] 查询设备详情失败: id={}", id, e); + IotDeviceDO device = deviceService.getDevice(id); + if (device == null) { return success(null); } + return success(toSimpleDTO(device)); + } + + @Override + @GetMapping(PREFIX + "/batch-get") + @Operation(summary = "批量获取设备详情") + public CommonResult> batchGetDevices( + @RequestParam("ids") Collection ids) { + if (ids == null || ids.isEmpty()) { + return success(List.of()); + } + List devices = deviceService.getDeviceList(ids); + List result = devices.stream() + .map(this::toSimpleDTO) + .toList(); + return success(result); + } + + private IotDeviceSimpleRespDTO toSimpleDTO(IotDeviceDO device) { + IotDeviceSimpleRespDTO dto = new IotDeviceSimpleRespDTO(); + dto.setId(device.getId()); + dto.setDeviceName(device.getDeviceName()); + dto.setProductId(device.getProductId()); + dto.setProductKey(device.getProductKey()); + dto.setNickname(device.getNickname()); + dto.setSerialNumber(device.getSerialNumber()); + dto.setState(device.getState()); + dto.setDeviceType(device.getDeviceType()); + // 从产品缓存获取产品名称 + dto.setProductName(resolveProductName(device.getProductId())); + return dto; + } + + private String resolveProductName(Long productId) { + if (productId == null) { + return null; + } + try { + IotProductDO product = productService.getProductFromCache(productId); + return product != null ? product.getName() : null; + } catch (Exception e) { + log.warn("[resolveProductName] 获取产品名称失败: productId={}, error={}", productId, e.getMessage()); + return null; + } } } diff --git a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/ErrorCodeConstants.java b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/ErrorCodeConstants.java index 6d60cce..ca60e2c 100644 --- a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/ErrorCodeConstants.java +++ b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/ErrorCodeConstants.java @@ -21,5 +21,6 @@ public interface ErrorCodeConstants { ErrorCode DEVICE_ALREADY_BOUND = new ErrorCode(1_020_002_001, "该工牌已绑定至此区域"); ErrorCode DEVICE_TYPE_ALREADY_BOUND = new ErrorCode(1_020_002_002, "该区域已绑定{},一个区域只能绑定一个"); ErrorCode DEVICE_RELATION_NOT_FOUND = new ErrorCode(1_020_002_003, "设备关联关系不存在"); + ErrorCode IOT_SERVICE_UNAVAILABLE = new ErrorCode(1_020_002_004, "IoT 设备服务不可用,请稍后重试"); } diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/AreaDeviceRelationRespVO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/AreaDeviceRelationRespVO.java index 1f58fe3..caf86f1 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/AreaDeviceRelationRespVO.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/AreaDeviceRelationRespVO.java @@ -33,6 +33,18 @@ public class AreaDeviceRelationRespVO { @Schema(description = "设备名称", example = "客流计数器001") private String deviceName; + @Schema(description = "设备备注名称", example = "A栋3层客流计数器") + private String nickname; + + @Schema(description = "设备序列号", example = "SN20250101001") + private String serialNumber; + + @Schema(description = "设备状态(0-未激活 1-在线 2-离线)", example = "1") + private Integer deviceState; + + @Schema(description = "设备类型", example = "10") + private Integer deviceType; + @Schema(description = "产品ID", example = "10") private Long productId; diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceRelationServiceImpl.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceRelationServiceImpl.java index 04a1238..ba1a63d 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceRelationServiceImpl.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceRelationServiceImpl.java @@ -18,9 +18,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; import static com.viewsh.module.ops.enums.AreaTypeEnum.*; @@ -34,17 +34,17 @@ import static com.viewsh.module.ops.enums.AreaTypeEnum.*; @Slf4j public class AreaDeviceRelationServiceImpl implements AreaDeviceRelationService { - @Resource - private OpsAreaDeviceRelationMapper opsAreaDeviceRelationMapper; - - @Resource - private OpsBusAreaMapper opsBusAreaMapper; - - @Resource - private IotDeviceQueryApi iotDeviceQueryApi; - - @Resource - private AreaDeviceService areaDeviceService; + @Resource + private OpsAreaDeviceRelationMapper opsAreaDeviceRelationMapper; + + @Resource + private OpsBusAreaMapper opsBusAreaMapper; + + @Resource + private IotDeviceQueryApi iotDeviceQueryApi; + + @Resource + private AreaDeviceService areaDeviceService; private static final String TYPE_TRAFFIC_COUNTER = "TRAFFIC_COUNTER"; private static final String TYPE_BEACON = "BEACON"; @@ -53,9 +53,19 @@ public class AreaDeviceRelationServiceImpl implements AreaDeviceRelationService @Override public List listByAreaId(Long areaId) { List list = opsAreaDeviceRelationMapper.selectListByAreaId(areaId); + if (list == null || list.isEmpty()) { + return Collections.emptyList(); + } + + // 批量查询设备信息,避免 N+1 RPC 调用 + Set deviceIds = list.stream() + .map(OpsAreaDeviceRelationDO::getDeviceId) + .collect(Collectors.toSet()); + Map deviceMap = batchGetDeviceMap(deviceIds); + return list.stream() - .map(this::convertToRespVO) - .collect(java.util.stream.Collectors.toList()); + .map(relation -> convertToRespVO(relation, deviceMap.get(relation.getDeviceId()))) + .toList(); } @Override @@ -87,43 +97,27 @@ public class AreaDeviceRelationServiceImpl implements AreaDeviceRelationService } } - // 调用 IoT 接口获取设备信息 - String deviceKey = "DEVICE_" + bindReq.getDeviceId(); - Long productId = 0L; - String productKey = "PRODUCT_DEFAULT"; - - try { - CommonResult result = iotDeviceQueryApi.getDevice(bindReq.getDeviceId()); - if (result != null && result.getData() != null) { - IotDeviceSimpleRespDTO device = result.getData(); - deviceKey = device.getDeviceName() != null ? device.getDeviceName() : deviceKey; - productId = device.getProductId() != null ? device.getProductId() : 0L; - productKey = device.getProductKey() != null ? device.getProductKey() : productKey; - } - } catch (Exception e) { - log.warn("[bindDevice] 调用 IoT 接口获取设备信息失败: deviceId={}, error={}", - bindReq.getDeviceId(), e.getMessage()); - // 降级:使用默认值 - } + // 调用 IoT 接口获取设备信息(失败则阻断绑定) + IotDeviceSimpleRespDTO device = getDeviceOrThrow(bindReq.getDeviceId()); OpsAreaDeviceRelationDO relation = OpsAreaDeviceRelationDO.builder() .areaId(bindReq.getAreaId()) .deviceId(bindReq.getDeviceId()) - .deviceKey(deviceKey) - .productId(productId) - .productKey(productKey) + .deviceKey(device.getDeviceName()) + .productId(device.getProductId()) + .productKey(device.getProductKey()) .relationType(bindReq.getRelationType()) .configData(bindReq.getConfigData() != null ? bindReq.getConfigData() : new HashMap<>()) .enabled(true) .build(); - opsAreaDeviceRelationMapper.insert(relation); - - // 清除可能存在的 NULL_CACHE 标记 - areaDeviceService.evictConfigCache(relation.getAreaId(), relation.getRelationType()); - - return relation.getId(); - } + opsAreaDeviceRelationMapper.insert(relation); + + // 清除可能存在的 NULL_CACHE 标记 + areaDeviceService.evictConfigCache(relation.getAreaId(), relation.getRelationType()); + + return relation.getId(); + } @Override @Transactional(rollbackFor = Exception.class) @@ -141,60 +135,85 @@ public class AreaDeviceRelationServiceImpl implements AreaDeviceRelationService relation.setConfigData(updateReq.getConfigData()); } - // enabled 更新 - if (updateReq.getEnabled() != null) { - relation.setEnabled(updateReq.getEnabled()); - } - - opsAreaDeviceRelationMapper.updateById(relation); - - // 删缓存以同步 Redis - areaDeviceService.evictConfigCache(existing.getAreaId(), existing.getRelationType()); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public Boolean unbindDevice(Long id) { - OpsAreaDeviceRelationDO existing = opsAreaDeviceRelationMapper.selectById(id); - if (existing == null) { - return false; - } - - boolean deleted = opsAreaDeviceRelationMapper.deleteById(id) > 0; - if (deleted) { - // 同步 Redis 缓存 - areaDeviceService.evictConfigCache(existing.getAreaId(), existing.getRelationType()); - } - return deleted; - } - - /** - * 转换为响应 VO - * 从 IoT 模块获取设备名称和产品名称(带降级) - */ - private AreaDeviceRelationRespVO convertToRespVO(OpsAreaDeviceRelationDO relation) { - AreaDeviceRelationRespVO respVO = BeanUtil.copyProperties(relation, AreaDeviceRelationRespVO.class); - - // 从 IoT 模块获取设备信息 - try { - CommonResult result = iotDeviceQueryApi.getDevice(relation.getDeviceId()); - if (result != null && result.getData() != null) { - IotDeviceSimpleRespDTO device = result.getData(); - respVO.setDeviceName(device.getDeviceName()); - respVO.setProductName(device.getProductName()); - } else { - // 降级:使用默认值 - respVO.setDeviceName("设备_" + relation.getDeviceId()); - respVO.setProductName("产品_" + relation.getProductKey()); - } - } catch (Exception e) { - log.warn("[convertToRespVO] 调用 IoT 接口获取设备信息失败: deviceId={}, error={}", - relation.getDeviceId(), e.getMessage()); - // 降级:使用默认值 - respVO.setDeviceName("设备_" + relation.getDeviceId()); - respVO.setProductName("产品_" + relation.getProductKey()); + // enabled 更新 + if (updateReq.getEnabled() != null) { + relation.setEnabled(updateReq.getEnabled()); } + opsAreaDeviceRelationMapper.updateById(relation); + + // 删缓存以同步 Redis + areaDeviceService.evictConfigCache(existing.getAreaId(), existing.getRelationType()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean unbindDevice(Long id) { + OpsAreaDeviceRelationDO existing = opsAreaDeviceRelationMapper.selectById(id); + if (existing == null) { + return false; + } + + boolean deleted = opsAreaDeviceRelationMapper.deleteById(id) > 0; + if (deleted) { + // 同步 Redis 缓存 + areaDeviceService.evictConfigCache(existing.getAreaId(), existing.getRelationType()); + } + return deleted; + } + + /** + * 批量查询设备信息,返回 deviceId → DTO 映射 + */ + private Map batchGetDeviceMap(Set deviceIds) { + if (deviceIds.isEmpty()) { + return Collections.emptyMap(); + } + try { + CommonResult> result = iotDeviceQueryApi.batchGetDevices(deviceIds); + if (result != null && result.getData() != null) { + return result.getData().stream() + .collect(Collectors.toMap(IotDeviceSimpleRespDTO::getId, Function.identity())); + } + } catch (Exception e) { + log.warn("[batchGetDeviceMap] 批量查询设备信息失败: deviceIds={}, error={}", deviceIds, e.getMessage()); + } + return Collections.emptyMap(); + } + + /** + * 获取单个设备信息,失败则抛出异常阻断操作 + */ + private IotDeviceSimpleRespDTO getDeviceOrThrow(Long deviceId) { + try { + CommonResult result = iotDeviceQueryApi.getDevice(deviceId); + if (result != null && result.getData() != null) { + return result.getData(); + } + throw ServiceExceptionUtil.exception(ErrorCodeConstants.DEVICE_NOT_FOUND); + } catch (com.viewsh.framework.common.exception.ServiceException e) { + throw e; + } catch (Exception e) { + log.error("[getDeviceOrThrow] 调用 IoT 接口获取设备信息失败: deviceId={}, error={}", + deviceId, e.getMessage()); + throw ServiceExceptionUtil.exception(ErrorCodeConstants.IOT_SERVICE_UNAVAILABLE); + } + } + + /** + * 转换为响应 VO(使用预查询的设备信息,避免 N+1) + */ + private AreaDeviceRelationRespVO convertToRespVO(OpsAreaDeviceRelationDO relation, + IotDeviceSimpleRespDTO device) { + AreaDeviceRelationRespVO respVO = BeanUtil.copyProperties(relation, AreaDeviceRelationRespVO.class); + if (device != null) { + respVO.setDeviceName(device.getDeviceName()); + respVO.setProductName(device.getProductName()); + respVO.setNickname(device.getNickname()); + respVO.setSerialNumber(device.getSerialNumber()); + respVO.setDeviceState(device.getState()); + respVO.setDeviceType(device.getDeviceType()); + } return respVO; } From a9fd9313cc998c788a1a8b2be068e259d08c9306 Mon Sep 17 00:00:00 2001 From: lzh Date: Sat, 7 Mar 2026 21:12:48 +0800 Subject: [PATCH 02/35] =?UTF-8?q?feat(ops):=20=E9=87=8D=E6=9E=84=E6=B4=BE?= =?UTF-8?q?=E5=8D=95=E9=98=9F=E5=88=97=E8=AF=84=E5=88=86=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E6=A5=BC=E5=B1=82=E5=B7=AE=E4=B8=8E?= =?UTF-8?q?=E7=AD=89=E5=BE=85=E8=80=81=E5=8C=96=E7=BB=BC=E5=90=88=E6=8E=92?= =?UTF-8?q?=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 QueueScoreCalculator/QueueScoreContext/QueueScoreResult,统一按优先级分 + 楼层差分 - 等待老化分计算队列总分,并将 PRIORITY_WEIGHT 调整为 1500 - OrderQueueService 新增 rebuildWaitingTasksByUserId 接口,OrderQueueServiceEnhanced 支持按执行人重算 WAITING 队列、以当前执行工单楼层为基准动态重排,并在事务提交后同步刷新 Redis - RedisOrderQueueServiceImpl 支持持久化 baseFloorNo、targetFloorNo、floorDiff、waitMinutes、scoreUpdateTime 等评分明细,清队列时同时清理关联 Hash,避免脏数据残留 - DispatchEngineImpl、CleanerPriorityScheduleStrategy、BadgeDeviceScheduleStrategy 调整为非抢占式派单:P0 忙碌时仅入队等待,空闲时直接派发,自动派单前按总分重排并派发下一单 - CleanOrderServiceImpl 取消 P0 自动打断链路,升级到 P0 后仅重算等待队列并发送通知;补充 QueueScoreCalculatorTest、OrderQueueServiceEnhancedTest、CleanerPriorityScheduleStrategyTest、CleanOrderEndToEndTest 覆盖新行为 --- .../cleanorder/CleanOrderServiceImpl.java | 12 +- .../dispatch/BadgeDeviceScheduleStrategy.java | 45 ++- .../CleanerPriorityScheduleStrategy.java | 19 +- .../cleanorder/CleanOrderEndToEndTest.java | 23 +- .../CleanerPriorityScheduleStrategyTest.java | 5 +- .../module/ops/api/queue/OrderQueueDTO.java | 34 +- .../ops/api/queue/OrderQueueService.java | 9 + .../ops/core/dispatch/DispatchEngineImpl.java | 115 ++----- .../dal/dataobject/queue/OpsOrderQueueDO.java | 9 +- .../queue/OrderQueueServiceEnhanced.java | 315 ++++++++++++------ .../service/queue/QueueScoreCalculator.java | 49 +++ .../ops/service/queue/QueueScoreContext.java | 21 ++ .../ops/service/queue/QueueScoreResult.java | 19 ++ .../queue/RedisOrderQueueServiceImpl.java | 84 ++++- .../queue/OrderQueueServiceEnhancedTest.java | 103 ++++++ .../queue/QueueScoreCalculatorTest.java | 73 ++++ 16 files changed, 674 insertions(+), 261 deletions(-) create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/QueueScoreCalculator.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/QueueScoreContext.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/QueueScoreResult.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/queue/OrderQueueServiceEnhancedTest.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/queue/QueueScoreCalculatorTest.java diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanOrderServiceImpl.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanOrderServiceImpl.java index 1dd0195..e0a8308 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanOrderServiceImpl.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanOrderServiceImpl.java @@ -245,13 +245,13 @@ public class CleanOrderServiceImpl implements CleanOrderService { if (queueDTO != null) { orderQueueService.adjustPriority(queueDTO.getId(), PriorityEnum.P0, reason); - // 5. 使用新的调度引擎处理P0紧急插队 - DispatchResult result = dispatchEngine.urgentInterrupt(orderId, queueDTO.getUserId()); + // 5. 重算等待队列,P0 不再打断当前任务 + orderQueueService.rebuildWaitingTasksByUserId(queueDTO.getUserId(), order.getAreaId()); // 6. 发送优先级升级通知 cleanOrderEventListener.sendPriorityUpgradeNotification(queueDTO.getUserId(), order.getOrderCode(), orderId); - return result.isSuccess(); + return true; } return true; @@ -289,11 +289,11 @@ public class CleanOrderServiceImpl implements CleanOrderService { if (queueDTO != null) { orderQueueService.adjustPriority(queueDTO.getId(), newPriority, reason); - // 6. 如果升级到 P0,触发紧急打断逻辑 + // 6. 如果升级到 P0,仅重算等待队列,不再触发打断 if (newPriority == PriorityEnum.P0) { - DispatchResult result = dispatchEngine.urgentInterrupt(orderId, queueDTO.getUserId()); + orderQueueService.rebuildWaitingTasksByUserId(queueDTO.getUserId(), order.getAreaId()); cleanOrderEventListener.sendPriorityUpgradeNotification(queueDTO.getUserId(), order.getOrderCode(), orderId); - log.warn("客流升级到P0,触发紧急打断: orderId={}, success={}", orderId, result.isSuccess()); + log.warn("客流升级到P0,已重算等待队列: orderId={}", orderId); } } diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/dispatch/BadgeDeviceScheduleStrategy.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/dispatch/BadgeDeviceScheduleStrategy.java index 0dec654..4e7a3ee 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/dispatch/BadgeDeviceScheduleStrategy.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/dispatch/BadgeDeviceScheduleStrategy.java @@ -23,13 +23,13 @@ import java.util.List; *

* 职责:怎么派单 *

- * 策略规则: - *

    - *
  • 空闲无任务 → DIRECT_DISPATCH(直接派单)
  • - *
  • 空闲有等待 → PUSH_AND_ENQUEUE(推送等待+新任务入队)
  • - *
  • 忙碌且非P0 → ENQUEUE_ONLY(仅入队)
  • - *
  • 忙碌且P0 → INTERRUPT_AND_DISPATCH(打断并派单)
  • - *
+ * 策略规则: + *
    + *
  • 空闲无任务 → DIRECT_DISPATCH(直接派单)
  • + *
  • 空闲有等待 → PUSH_AND_ENQUEUE(推送等待+新任务入队)
  • + *
  • 忙碌且非P0 → ENQUEUE_ONLY(仅入队)
  • + *
  • 忙碌且P0 → ENQUEUE_ONLY(不抢断,进入等待队列)
  • + *
* * @author lzh */ @@ -106,12 +106,12 @@ public class BadgeDeviceScheduleStrategy implements ScheduleStrategy { // P0紧急任务,需要打断 if (assigneeStatus.getCurrentTaskCount() > 0) { Long currentOrderId = deviceStatus.getCurrentOpsOrderId(); - log.warn("决策: INTERRUPT_AND_DISPATCH - P0紧急任务打断当前任务: currentOrderId={}", currentOrderId); - return DispatchDecision.interruptAndDispatch(currentOrderId); - } else { - log.info("决策: DIRECT_DISPATCH - P0紧急任务直接派单"); - return DispatchDecision.directDispatch(); - } + log.info("决策: ENQUEUE_ONLY - P0工单不再打断当前任务,进入等待队列: currentOrderId={}", currentOrderId); + return DispatchDecision.enqueueOnly(); + } else { + log.info("决策: DIRECT_DISPATCH - P0工单在空闲状态下直接派发"); + return DispatchDecision.directDispatch(); + } } else { // 非紧急任务,设备忙碌,入队等待 log.info("决策: ENQUEUE_ONLY - 设备忙碌,任务入队等待"); @@ -133,15 +133,14 @@ public class BadgeDeviceScheduleStrategy implements ScheduleStrategy { // P0任务可以打断任何任务 if (urgentContext.isUrgent()) { - log.warn("允许打断: P0紧急任务可以打断当前任务"); - return InterruptDecision.allowByDefault(); - } - - // P1/P2任务不能打断 - log.info("拒绝打断: 非P0任务不能打断当前任务"); - return InterruptDecision.deny( - "紧急任务优先级不足", - "建议等待当前任务完成" - ); + log.info("拒绝打断: 当前调度已改为非抢占式队列派发"); + return InterruptDecision.deny("当前调度不再支持抢断", "工单将按队列总分在下一轮派发"); + } + + log.info("拒绝打断: 当前调度已改为非抢占式队列派发"); + return InterruptDecision.deny( + "当前调度不再支持抢断", + "工单将按队列总分在下一轮派发" + ); } } diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/dispatch/CleanerPriorityScheduleStrategy.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/dispatch/CleanerPriorityScheduleStrategy.java index 09a9077..d54f9d0 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/dispatch/CleanerPriorityScheduleStrategy.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/dispatch/CleanerPriorityScheduleStrategy.java @@ -28,7 +28,7 @@ import java.util.List; *
  • 空闲无任务 → DIRECT_DISPATCH(直接派单)
  • *
  • 空闲有等待 → PUSH_AND_ENQUEUE(推送等待+新任务入队)
  • *
  • 忙碌且非P0 → ENQUEUE_ONLY(仅入队)
  • - *
  • 忙碌且P0 → INTERRUPT_AND_DISPATCH(打断并派单)
  • + *
  • 忙碌且P0 → ENQUEUE_ONLY(不抢断,进入等待队列)
  • * * * @author lzh @@ -106,10 +106,10 @@ public class CleanerPriorityScheduleStrategy implements ScheduleStrategy { // P0紧急任务,需要打断 if (assigneeStatus.getCurrentTaskCount() > 0) { Long currentOrderId = cleanerStatus.getCurrentOpsOrderId(); - log.warn("决策: INTERRUPT_AND_DISPATCH - P0紧急任务打断当前任务: currentOrderId={}", currentOrderId); - return DispatchDecision.interruptAndDispatch(currentOrderId); + log.info("决策: ENQUEUE_ONLY - P0工单不再打断当前任务,进入等待队列: currentOrderId={}", currentOrderId); + return DispatchDecision.enqueueOnly(); } else { - log.info("决策: DIRECT_DISPATCH - P0紧急任务直接派单"); + log.info("决策: DIRECT_DISPATCH - P0工单在空闲状态下直接派发"); return DispatchDecision.directDispatch(); } } else { @@ -133,15 +133,14 @@ public class CleanerPriorityScheduleStrategy implements ScheduleStrategy { // P0任务可以打断任何任务 if (urgentContext.isUrgent()) { - log.warn("允许打断: P0紧急任务可以打断当前任务"); - return InterruptDecision.allowByDefault(); + log.info("拒绝打断: 当前调度已改为非抢占式队列派发"); + return InterruptDecision.deny("当前调度不再支持抢断", "工单将按队列总分在下一轮派发"); } - // P1/P2任务不能打断 - log.info("拒绝打断: 非P0任务不能打断当前任务"); + log.info("拒绝打断: 当前调度已改为非抢占式队列派发"); return InterruptDecision.deny( - "紧急任务优先级不足", - "建议等待当前任务完成" + "当前调度不再支持抢断", + "工单将按队列总分在下一轮派发" ); } } diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/service/cleanorder/CleanOrderEndToEndTest.java b/viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/service/cleanorder/CleanOrderEndToEndTest.java index 2722bea..de9bda5 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/service/cleanorder/CleanOrderEndToEndTest.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/service/cleanorder/CleanOrderEndToEndTest.java @@ -9,10 +9,13 @@ import com.viewsh.module.ops.core.event.OrderEventPublisher; import com.viewsh.module.ops.core.lifecycle.OrderLifecycleManager; import com.viewsh.module.ops.core.lifecycle.model.OrderTransitionRequest; import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO; +import com.viewsh.module.ops.dal.dataobject.area.OpsBusAreaDO; +import com.viewsh.module.ops.dal.mysql.area.OpsBusAreaMapper; import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper; import com.viewsh.module.ops.enums.PriorityEnum; import com.viewsh.module.ops.enums.WorkOrderStatusEnum; import com.viewsh.module.ops.environment.dal.dataobject.workorder.OpsOrderCleanExtDO; +import com.viewsh.module.ops.environment.dal.redis.TrafficActiveOrderRedisDAO; import com.viewsh.module.ops.environment.dal.mysql.workorder.OpsOrderCleanExtMapper; import com.viewsh.module.ops.environment.integration.consumer.*; import com.viewsh.framework.common.pojo.CommonResult; @@ -73,6 +76,8 @@ public class CleanOrderEndToEndTest { @Mock private OpsOrderMapper opsOrderMapper; @Mock + private OpsBusAreaMapper opsBusAreaMapper; + @Mock private OpsOrderCleanExtMapper cleanExtMapper; @Mock private OrderIdGenerator orderIdGenerator; @@ -94,6 +99,8 @@ public class CleanOrderEndToEndTest { private ValueOperations valueOperations; @Mock private VoiceBroadcastService voiceBroadcastService; + @Mock + private TrafficActiveOrderRedisDAO trafficActiveOrderRedisDAO; @Mock private BadgeDeviceStatusService badgeDeviceStatusService; @@ -149,6 +156,7 @@ public class CleanOrderEndToEndTest { // 注入 CleanOrderEventListener injectField(cleanOrderService, "cleanOrderEventListener", cleanOrderEventListener); + injectField(cleanOrderService, "opsBusAreaMapper", opsBusAreaMapper); // 注入 CleanOrderAuditEventHandler 依赖 injectField(auditEventHandler, "eventLogRecorder", eventLogRecorder); @@ -156,10 +164,18 @@ public class CleanOrderEndToEndTest { injectField(auditEventHandler, "opsOrderMapper", opsOrderMapper); injectField(auditEventHandler, "stringRedisTemplate", stringRedisTemplate); injectField(auditEventHandler, "objectMapper", objectMapper); + injectField(createEventHandler, "trafficActiveOrderRedisDAO", trafficActiveOrderRedisDAO); // Stub IotDeviceControlApi for resetTrafficCounter lenient().when(iotDeviceControlApi.resetTrafficCounter(any())) .thenReturn(CommonResult.success(true)); + lenient().when(opsBusAreaMapper.selectById(anyLong())) + .thenAnswer(i -> OpsBusAreaDO.builder() + .id(i.getArgument(0)) + .areaName("测试区域") + .parentPath(null) + .floorNo(1) + .build()); } // ========================================== @@ -338,7 +354,7 @@ public class CleanOrderEndToEndTest { verify(eventLogRecorder).record(any()); // 2. TTS sent (orderId can be null for TTS_REQUEST events) - verify(voiceBroadcastService).broadcastInOrder(eq(5001L), contains("请回到作业区域"), eq((Long) null)); + verify(voiceBroadcastService).broadcastDirect(eq(5001L), contains("请回到作业区域"), eq(9), eq((Long) null)); } // ========================================== @@ -378,9 +394,6 @@ public class CleanOrderEndToEndTest { when(opsOrderMapper.selectById(orderId)).thenReturn(order); when(orderQueueService.getByOpsOrderId(orderId)).thenReturn(queueDTO); - when(dispatchEngine.urgentInterrupt(eq(orderId), eq(2001L))) - .thenReturn(DispatchResult.success("Success", 2001L)); - // Execute boolean result = cleanOrderService.upgradePriorityToP0(orderId, "Manual Upgrade"); @@ -392,7 +405,7 @@ public class CleanOrderEndToEndTest { assertEquals(PriorityEnum.P0.getPriority(), orderCaptor.getValue().getPriority()); verify(orderQueueService).adjustPriority(eq(500L), eq(PriorityEnum.P0), anyString()); - verify(dispatchEngine).urgentInterrupt(orderId, 2001L); + verify(orderQueueService).rebuildWaitingTasksByUserId(2001L, order.getAreaId()); verify(cleanOrderEventListener).sendPriorityUpgradeNotification(eq(2001L), eq("WO-P2"), eq(orderId)); } diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/service/dispatch/CleanerPriorityScheduleStrategyTest.java b/viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/service/dispatch/CleanerPriorityScheduleStrategyTest.java index 9ebb1d2..763b100 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/service/dispatch/CleanerPriorityScheduleStrategyTest.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/service/dispatch/CleanerPriorityScheduleStrategyTest.java @@ -36,7 +36,7 @@ class CleanerPriorityScheduleStrategyTest { private com.viewsh.module.ops.core.dispatch.DispatchEngine dispatchEngine; @Test - void testDecide_P0_Interrupt() { + void testDecide_P0_EnqueueOnlyWhenBusy() { // Setup OpsCleanerStatusDO c1 = new OpsCleanerStatusDO(); c1.setUserId(1L); @@ -54,8 +54,7 @@ class CleanerPriorityScheduleStrategyTest { DispatchDecision decision = strategy.decide(context); // Verify - assertEquals(DispatchPath.INTERRUPT_AND_DISPATCH, decision.getPath()); - assertEquals(500L, decision.getInterruptedOrderId()); + assertEquals(DispatchPath.ENQUEUE_ONLY, decision.getPath()); } @Test diff --git a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/queue/OrderQueueDTO.java b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/queue/OrderQueueDTO.java index d1f8f39..7d0f98f 100644 --- a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/queue/OrderQueueDTO.java +++ b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/queue/OrderQueueDTO.java @@ -40,13 +40,8 @@ public class OrderQueueDTO { /** * 队列分数(用于排序) - * 计算公式:优先级分数 + 时间戳 - * - P0: 0 + timestamp - * - P1: 1000000 + timestamp - * - P2: 2000000 + timestamp - * - P3: 3000000 + timestamp - * - * 用于数据库层面的排序,优先级高的排在前面,同优先级按时间排序 + * 计算公式:优先级分 + 楼层差分 - 等待老化分 + * 分数越小越靠前,用于等待队列的动态重排 */ private Double queueScore; @@ -95,6 +90,31 @@ public class OrderQueueDTO { */ private LocalDateTime updateTime; + /** + * 评分基准楼层 + */ + private Integer baseFloorNo; + + /** + * 目标工单楼层 + */ + private Integer targetFloorNo; + + /** + * 楼层差 + */ + private Integer floorDiff; + + /** + * 等待分钟数 + */ + private Long waitMinutes; + + /** + * 分数更新时间 + */ + private LocalDateTime scoreUpdateTime; + // ========== 兼容旧字段名的getter方法 ========== /** diff --git a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/queue/OrderQueueService.java b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/queue/OrderQueueService.java index 81c0171..2024ac2 100644 --- a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/queue/OrderQueueService.java +++ b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/queue/OrderQueueService.java @@ -164,6 +164,15 @@ public interface OrderQueueService { */ List getWaitingTasksByUserIdFromDb(Long userId); + /** + * 按当前上下文重算指定执行人等待队列的总分并返回最新排序结果 + * + * @param userId 执行人ID + * @param fallbackAreaId 当没有执行中工单时可使用的楼层基准区域ID + * @return 已按最新总分排序的 WAITING 工单列表 + */ + List rebuildWaitingTasksByUserId(Long userId, Long fallbackAreaId); + /** * 获取用户的暂停任务列表(PAUSED状态,按暂停时间排序) * diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/dispatch/DispatchEngineImpl.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/dispatch/DispatchEngineImpl.java index 7367798..2365319 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/dispatch/DispatchEngineImpl.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/dispatch/DispatchEngineImpl.java @@ -134,7 +134,7 @@ public class DispatchEngineImpl implements DispatchEngine { @Override @Transactional(rollbackFor = Exception.class) public DispatchResult urgentInterrupt(Long urgentOrderId, Long assigneeId) { - log.warn("开始P0紧急插队: urgentOrderId={}, assigneeId={}", urgentOrderId, assigneeId); + log.warn("处理P0工单派发请求: urgentOrderId={}, assigneeId={}", urgentOrderId, assigneeId); // 查询紧急工单 OpsOrderDO urgentOrder = orderMapper.selectById(urgentOrderId); @@ -156,23 +156,15 @@ public class DispatchEngineImpl implements DispatchEngine { // 查询执行人当前任务 List currentTasks = orderQueueService.getTasksByUserId(assigneeId); - // 如果有正在执行的任务,需要打断 + // 如果有正在执行的任务,不再打断,重排等待队列后等待下一轮派发 OrderQueueDTO currentTask = currentTasks.stream() .filter(t -> OrderQueueStatusEnum.PROCESSING.getStatus().equals(t.getQueueStatus())) .findFirst() .orElse(null); if (currentTask != null && !currentTask.getOpsOrderId().equals(urgentOrderId)) { - // 需要打断当前任务 - log.info("打断当前任务: currentOrderId={}, urgentOrderId={}", - currentTask.getOpsOrderId(), urgentOrderId); - - try { - orderLifecycleManager.interruptOrder( - currentTask.getOpsOrderId(), urgentOrderId, assigneeId); - } catch (Exception e) { - log.warn("打断任务失败: currentOrderId={}", currentTask.getOpsOrderId(), e); - } + orderQueueService.rebuildWaitingTasksByUserId(assigneeId, null); + return DispatchResult.success("P0工单已入队等待,不再打断当前任务", assigneeId); } // 派发紧急任务 @@ -182,63 +174,42 @@ public class DispatchEngineImpl implements DispatchEngine { .assigneeId(assigneeId) .operatorType(OperatorTypeEnum.SYSTEM) .operatorId(assigneeId) - .reason("P0紧急任务派单") + .reason("P0工单直接派发") .build(); OrderTransitionResult result = orderLifecycleManager.dispatch(request); if (result.isSuccess()) { - return DispatchResult.success("P0紧急任务已派单", assigneeId); + return DispatchResult.success("P0工单已直接派发", assigneeId); } else { - return DispatchResult.fail("P0紧急任务派单失败: " + result.getMessage()); + return DispatchResult.fail("P0工单派发失败: " + result.getMessage()); } } @Override @Transactional(rollbackFor = Exception.class) public DispatchResult autoDispatchNext(Long completedOrderId, Long assigneeId) { - log.info("任务完成后自动调度下一个: completedOrderId={}, assigneeId={}", completedOrderId, assigneeId); + log.info("任务完成后自动派发下一单: completedOrderId={}, assigneeId={}", completedOrderId, assigneeId); - // 1. 优先检查是否有被中断的任务 - List interruptedTasks = orderQueueService.getInterruptedTasksByUserId(assigneeId); - - if (!interruptedTasks.isEmpty()) { - // 恢复第一个中断的任务 - OrderQueueDTO interruptedTask = interruptedTasks.get(0); - log.info("恢复中断任务: orderId={}", interruptedTask.getOpsOrderId()); - - OrderTransitionRequest request = OrderTransitionRequest.builder() - .orderId(interruptedTask.getOpsOrderId()) - .targetStatus(WorkOrderStatusEnum.DISPATCHED) - .queueId(interruptedTask.getId()) - .assigneeId(assigneeId) - .operatorType(OperatorTypeEnum.SYSTEM) - .operatorId(assigneeId) - .reason("恢复中断任务") - .build(); - - OrderTransitionResult result = orderLifecycleManager.transition(request); - - if (result.isSuccess()) { - return DispatchResult.success("已恢复中断任务", assigneeId); - } else { - return DispatchResult.fail("恢复中断任务失败: " + result.getMessage()); - } + Long fallbackAreaId = null; + OpsOrderDO completedOrder = orderMapper.selectById(completedOrderId); + if (completedOrder != null) { + fallbackAreaId = completedOrder.getAreaId(); } - // 2. 如果没有中断任务,推送队列中的下一个任务 - // 注意:这里必须从 MySQL 读取最新数据,确保刚完成的任务不在等待列表中 - List waitingTasks = orderQueueService.getWaitingTasksByUserIdFromDb(assigneeId); + List waitingTasks = orderQueueService.rebuildWaitingTasksByUserId(assigneeId, fallbackAreaId); if (waitingTasks.isEmpty()) { log.info("无等待任务,执行人变为空闲: assigneeId={}", assigneeId); // 发布事件,由业务层更新执行人状态 - return DispatchResult.success("无等待任务,任务完成", assigneeId); + return DispatchResult.success("无等待工单,执行人保持空闲", assigneeId); } - // ��送第一个等待任务 + // 动态总分重排后,派发得分最低的等待工单 OrderQueueDTO nextTask = waitingTasks.get(0); - log.info("推送下一个等待任务: taskId={}, orderId={}", nextTask.getId(), nextTask.getOpsOrderId()); + log.info("派发下一单: queueId={}, orderId={}, score={}, floorDiff={}, waitMinutes={}", + nextTask.getId(), nextTask.getOpsOrderId(), nextTask.getQueueScore(), + nextTask.getFloorDiff(), nextTask.getWaitMinutes()); OrderTransitionRequest request = OrderTransitionRequest.builder() .orderId(nextTask.getOpsOrderId()) @@ -247,15 +218,15 @@ public class DispatchEngineImpl implements DispatchEngine { .assigneeId(assigneeId) .operatorType(OperatorTypeEnum.SYSTEM) .operatorId(assigneeId) - .reason("自动推送下一个任务") + .reason("等待队列动态重排后自动派发") .build(); OrderTransitionResult result = orderLifecycleManager.transition(request); if (result.isSuccess()) { - return DispatchResult.success("已推送下一个任务", assigneeId); + return DispatchResult.success("已按队列总分派发下一单", assigneeId); } else { - return DispatchResult.fail("推送下一个任务失败: " + result.getMessage()); + return DispatchResult.fail("按队列总分派发下一单失败: " + result.getMessage()); } } @@ -330,7 +301,7 @@ public class DispatchEngineImpl implements DispatchEngine { OrderDispatchContext urgentContext) { ScheduleStrategy strategy = scheduleStrategyRegistry.get(urgentContext.getBusinessType()); if (strategy == null) { - log.warn("未找到调度策略,使用默认打断规则: businessType={}", urgentContext.getBusinessType()); + log.warn("未找到调度策略,使用默认非抢断规则: businessType={}", urgentContext.getBusinessType()); return defaultInterruptDecision(urgentContext); } @@ -343,14 +314,13 @@ public class DispatchEngineImpl implements DispatchEngine { } /** - * 默认打断决策 - * P0任务可以打断任何非P0任务 + * 默认非抢断决策 */ private InterruptDecision defaultInterruptDecision(OrderDispatchContext urgentContext) { if (urgentContext.isUrgent()) { - return InterruptDecision.allowByDefault(); + return InterruptDecision.deny("当前调度不再支持抢断", "工单将按队列总分在下一轮派发"); } - return InterruptDecision.deny("紧急任务优先级不足", "建议等待当前任务完成"); + return InterruptDecision.deny("当前调度不再支持抢断", "建议等待当前任务完成后按队列总分派发"); } // ==================== 私有方法 ==================== @@ -502,41 +472,12 @@ public class DispatchEngineImpl implements DispatchEngine { } /** - * 打断并派单 + * 历史抢断入口已废弃,统一降级为仅入队等待下一轮动态派发 */ private DispatchResult executeInterruptAndDispatch(OrderDispatchContext context, Long assigneeId, Long interruptedOrderId) { - log.warn("执行打断并派单: orderId={}, assigneeId={}, interruptedOrderId={}", + log.warn("检测到过期抢断路径,降级为仅入队: orderId={}, assigneeId={}, interruptedOrderId={}", context.getOrderId(), assigneeId, interruptedOrderId); - - // 先打断当前任务 - if (interruptedOrderId != null) { - orderLifecycleManager.interruptOrder(interruptedOrderId, context.getOrderId(), assigneeId); - } - - // 派发紧急任务 - OrderTransitionRequest request = OrderTransitionRequest.builder() - .orderId(context.getOrderId()) - .targetStatus(WorkOrderStatusEnum.DISPATCHED) - .assigneeId(assigneeId) - .assigneeName(context.getRecommendedAssigneeName()) - .operatorType(OperatorTypeEnum.SYSTEM) - .operatorId(assigneeId) - .reason("P0紧急任务派单") - .build(); - - OrderTransitionResult result = orderLifecycleManager.dispatch(request); - - if (result.isSuccess()) { - return DispatchResult.success( - "P0紧急任务已派单", - assigneeId, - null, - DispatchPath.INTERRUPT_AND_DISPATCH, - result.getQueueId() - ); - } else { - return DispatchResult.fail("P0紧急任务派单失败: " + result.getMessage()); - } + return executeEnqueueOnly(context, assigneeId); } } diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/queue/OpsOrderQueueDO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/queue/OpsOrderQueueDO.java index ab5c54e..7ccc413 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/queue/OpsOrderQueueDO.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/queue/OpsOrderQueueDO.java @@ -62,13 +62,8 @@ public class OpsOrderQueueDO extends BaseDO { /** * 队列分数(用于排序) - * 计算公式:优先级分数 + 时间戳 - * - P0: 0 + timestamp - * - P1: 1000000 + timestamp - * - P2: 2000000 + timestamp - * - P3: 3000000 + timestamp - * - * 用于数据库层面的排序,优先级高的排在前面,同优先级按时间排序 + * 计算公式:优先级分 + 楼层差分 - 等待老化分 + * 分数越小越靠前,用于数据库与 Redis 的一致排序 */ @TableField("queue_score") private Double queueScore; diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/OrderQueueServiceEnhanced.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/OrderQueueServiceEnhanced.java index bf2dca6..aeb3fc2 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/OrderQueueServiceEnhanced.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/OrderQueueServiceEnhanced.java @@ -1,21 +1,26 @@ package com.viewsh.module.ops.service.queue; -import com.viewsh.module.ops.api.queue.OrderQueueDTO; -import com.viewsh.module.ops.api.queue.OrderQueueService; -import com.viewsh.module.ops.dal.dataobject.queue.OpsOrderQueueDO; -import com.viewsh.module.ops.dal.mysql.queue.OpsOrderQueueMapper; +import com.viewsh.module.ops.api.queue.OrderQueueDTO; +import com.viewsh.module.ops.api.queue.OrderQueueService; +import com.viewsh.module.ops.dal.dataobject.area.OpsBusAreaDO; +import com.viewsh.module.ops.dal.dataobject.queue.OpsOrderQueueDO; +import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO; +import com.viewsh.module.ops.dal.mysql.area.OpsBusAreaMapper; +import com.viewsh.module.ops.dal.mysql.queue.OpsOrderQueueMapper; +import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper; import com.viewsh.module.ops.enums.OrderQueueStatusEnum; import com.viewsh.module.ops.enums.PriorityEnum; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.BeanUtils; -import org.springframework.context.annotation.Primary; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; +import org.springframework.beans.BeanUtils; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.*; +import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; @@ -35,27 +40,27 @@ import java.util.stream.Collectors; @Primary public class OrderQueueServiceEnhanced implements OrderQueueService { - /** - * Score 计算公式:优先级分数 + 时间戳 - * 优先级分数:P0=0, P1=1000000, P2=2000000, P3=3000000 - * 时间戳:秒级时间戳 - * 结果:优先级高的排在前面,同优先级按时间排序 - */ - private static final Map PRIORITY_SCORES = Map.of( - 0, 0L, // P0: 0 - 1, 1000000L, // P1: 1,000,000 - 2, 2000000L, // P2: 2,000,000 - 3, 3000000L // P3: 3,000,000 - ); - - @Resource - private OpsOrderQueueMapper orderQueueMapper; - - @Resource - private RedisOrderQueueService redisQueueService; - - @Resource - private QueueSyncService queueSyncService; + /** + * 队列总分由 QueueScoreCalculator 统一计算: + * 优先级分 + 楼层差分 - 等待老化分。 + */ + @Resource + private OpsOrderQueueMapper orderQueueMapper; + + @Resource + private OpsOrderMapper orderMapper; + + @Resource + private OpsBusAreaMapper areaMapper; + + @Resource + private RedisOrderQueueService redisQueueService; + + @Resource + private QueueSyncService queueSyncService; + + @Resource + private QueueScoreCalculator queueScoreCalculator; @Override @Transactional(rollbackFor = Exception.class) @@ -69,7 +74,11 @@ public class OrderQueueServiceEnhanced implements OrderQueueService { // 2. 计算队列分数 LocalDateTime now = LocalDateTime.now(); - double queueScore = calculateQueueScore(priority.getPriority(), now); + double queueScore = queueScoreCalculator.calculate(QueueScoreContext.builder() + .priority(priority.getPriority()) + .enqueueTime(now) + .now(now) + .build()).getTotalScore(); // 3. 创建队列记录(MySQL) OpsOrderQueueDO queueDO = OpsOrderQueueDO.builder() @@ -361,22 +370,20 @@ public class OrderQueueServiceEnhanced implements OrderQueueService { queueDO.setQueueIndex(calculateNextQueueIndex(newPriority)); // 重新计算队列分数(使用原入队时间保持时间戳不变) LocalDateTime enqueueTime = queueDO.getEnqueueTime() != null ? queueDO.getEnqueueTime() : LocalDateTime.now(); - double newQueueScore = calculateQueueScore(newPriority.getPriority(), enqueueTime); + double newQueueScore = queueScoreCalculator.calculate(QueueScoreContext.builder() + .priority(newPriority.getPriority()) + .enqueueTime(enqueueTime) + .now(LocalDateTime.now()) + .build()).getTotalScore(); queueDO.setQueueScore(newQueueScore); queueDO.setEventMessage("优先级调整: " + oldPriority + " -> " + newPriority + ", 原因: " + reason); int updated = orderQueueMapper.updateById(queueDO); - // 异步更新 Redis - if (updated > 0) { - CompletableFuture.runAsync(() -> { - try { - redisQueueService.updatePriority(queueId, newPriority.getPriority()); - } catch (Exception e) { - log.error("Redis 更新优先级失败: queueId={}", queueId, e); - } - }); - } + // 事务提交后重算并同步 Redis,避免在事务未提交前读到旧数据 + if (updated > 0) { + triggerQueueRebuildAfterCommit(queueDO.getUserId(), null); + } log.info("优先级已调整: queueId={}, opsOrderId={}, oldPriority={}, newPriority={}, reason={}, newScore={}", queueId, queueDO.getOpsOrderId(), oldPriority, newPriority, reason, newQueueScore); @@ -487,38 +494,58 @@ public class OrderQueueServiceEnhanced implements OrderQueueService { } @Override - public List getWaitingTasksByUserIdFromDb(Long userId) { - // 直接从 MySQL 获取所有任务 - List mysqlList = orderQueueMapper.selectListByUserId(userId); - List allTasks = convertToDTO(mysqlList); - - // 过滤出 WAITING 状态的任务,并按队列分数排序 - return filterAndSortWaitingTasks(allTasks); - } - - private List filterAndSortWaitingTasks(List allTasks) { - return allTasks.stream() - .filter(task -> OrderQueueStatusEnum.WAITING.getStatus().equals(task.getQueueStatus())) - .sorted((a, b) -> { - // 优先使用队列分数 score 排序(已在入队时计算好) - if (a.getQueueScore() != null && b.getQueueScore() != null) { - return Double.compare(a.getQueueScore(), b.getQueueScore()); - } - - // 兜底:如果 score 为空,则按优先级+时间排序 - // 按优先级排序(P0 > P1 > P2 > P3) - int priorityCompare = Integer.compare( - b.getPriority() != null ? b.getPriority() : 999, - a.getPriority() != null ? a.getPriority() : 999 - ); - if (priorityCompare != 0) { - return priorityCompare; - } - // 优先级相同,按入队时间排序 - return a.getEnqueueTime().compareTo(b.getEnqueueTime()); - }) - .collect(Collectors.toList()); - } + public List getWaitingTasksByUserIdFromDb(Long userId) { + return rebuildWaitingTasksByUserId(userId, null); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public List rebuildWaitingTasksByUserId(Long userId, Long fallbackAreaId) { + List waitingQueues = orderQueueMapper.selectListByUserIdAndStatus( + userId, OrderQueueStatusEnum.WAITING.getStatus()); + if (waitingQueues == null || waitingQueues.isEmpty()) { + return Collections.emptyList(); + } + + Long baselineAreaId = resolveBaselineAreaId(userId, fallbackAreaId); + Integer baseFloorNo = resolveFloorNo(baselineAreaId); + LocalDateTime now = LocalDateTime.now(); + + List rebuiltTasks = new ArrayList<>(waitingQueues.size()); + for (OpsOrderQueueDO queueDO : waitingQueues) { + OrderQueueDTO dto = convertToDTO(queueDO); + Integer targetFloorNo = resolveFloorNo(resolveOrderAreaId(queueDO.getOpsOrderId())); + QueueScoreResult result = queueScoreCalculator.calculate(QueueScoreContext.builder() + .priority(queueDO.getPriority()) + .baseFloorNo(baseFloorNo) + .targetFloorNo(targetFloorNo) + .enqueueTime(queueDO.getEnqueueTime()) + .now(now) + .build()); + + queueDO.setQueueScore(result.getTotalScore()); + orderQueueMapper.updateById(queueDO); + + dto.setQueueScore(result.getTotalScore()); + dto.setBaseFloorNo(result.getBaseFloorNo()); + dto.setTargetFloorNo(result.getTargetFloorNo()); + dto.setFloorDiff(result.getFloorDiff()); + dto.setWaitMinutes(result.getWaitMinutes()); + dto.setScoreUpdateTime(now); + rebuiltTasks.add(dto); + } + + rebuiltTasks.sort(this::compareByDynamicScore); + syncUserQueueToRedis(userId, rebuiltTasks); + return rebuiltTasks; + } + + private List filterAndSortWaitingTasks(List allTasks) { + return allTasks.stream() + .filter(task -> OrderQueueStatusEnum.WAITING.getStatus().equals(task.getQueueStatus())) + .sorted(this::compareByDynamicScore) + .collect(Collectors.toList()); + } @Override public List getInterruptedTasksByUserId(Long userId) { @@ -620,32 +647,9 @@ public class OrderQueueServiceEnhanced implements OrderQueueService { // ========== 私有方法 ========== - /** - * 计算队列分数(用于排序) - * 公式:优先级分数 + 时间戳 - * - * @param priority 优先级(0=P0, 1=P1, 2=P2, 3=P3) - * @param enqueueTime 入队时间 - * @return 队列分数 - */ - private double calculateQueueScore(Integer priority, LocalDateTime enqueueTime) { - // 获取优先级分数 - long priorityScore = PRIORITY_SCORES.getOrDefault(priority, 3000000L); - - // 计算时间戳(秒级) - long timestamp; - if (enqueueTime != null) { - timestamp = enqueueTime.atZone(ZoneId.systemDefault()).toEpochSecond(); - } else { - timestamp = System.currentTimeMillis() / 1000; - } - - return priorityScore + timestamp; - } - - /** - * 计算下一个队列顺序号 - */ + /** + * 计算下一个队列顺序号 + */ private Integer calculateNextQueueIndex(PriorityEnum priority) { // TODO: 查询当前优先级的最大 queueIndex,然后 +1 // 这里简化处理,返回默认值 @@ -709,9 +713,106 @@ public class OrderQueueServiceEnhanced implements OrderQueueService { /** * 批量转换为 DTO */ - private List convertToDTO(List list) { - return list.stream() - .map(this::convertToDTO) - .collect(Collectors.toList()); - } -} + private List convertToDTO(List list) { + return list.stream() + .map(this::convertToDTO) + .collect(Collectors.toList()); + } + + private int compareByDynamicScore(OrderQueueDTO a, OrderQueueDTO b) { + int scoreCompare = Double.compare( + a.getQueueScore() != null ? a.getQueueScore() : Double.MAX_VALUE, + b.getQueueScore() != null ? b.getQueueScore() : Double.MAX_VALUE + ); + if (scoreCompare != 0) { + return scoreCompare; + } + if (a.getEnqueueTime() != null && b.getEnqueueTime() != null) { + int enqueueCompare = a.getEnqueueTime().compareTo(b.getEnqueueTime()); + if (enqueueCompare != 0) { + return enqueueCompare; + } + } + return Long.compare( + a.getId() != null ? a.getId() : Long.MAX_VALUE, + b.getId() != null ? b.getId() : Long.MAX_VALUE + ); + } + + private Long resolveBaselineAreaId(Long userId, Long fallbackAreaId) { + OpsOrderQueueDO processingQueue = orderQueueMapper.selectCurrentExecutingByUserId(userId); + if (processingQueue != null) { + Long processingAreaId = resolveOrderAreaId(processingQueue.getOpsOrderId()); + if (processingAreaId != null) { + return processingAreaId; + } + } + return fallbackAreaId; + } + + private Long resolveOrderAreaId(Long orderId) { + OpsOrderDO order = orderMapper.selectById(orderId); + return order != null ? order.getAreaId() : null; + } + + private Integer resolveFloorNo(Long areaId) { + if (areaId == null) { + return null; + } + OpsBusAreaDO area = areaMapper.selectById(areaId); + return area != null ? area.getFloorNo() : null; + } + + private void syncUserQueueToRedis(Long userId, List rebuiltWaitingTasks) { + List queues = orderQueueMapper.selectListByUserId(userId); + if (queues == null || queues.isEmpty()) { + redisQueueService.clearQueue(userId); + return; + } + + Map rebuiltTaskMap = rebuiltWaitingTasks == null + ? Collections.emptyMap() + : rebuiltWaitingTasks.stream() + .filter(dto -> dto.getId() != null) + .collect(Collectors.toMap(OrderQueueDTO::getId, dto -> dto)); + + List queueDTOs = queues.stream() + .map(this::convertToDTO) + .map(dto -> rebuiltTaskMap.getOrDefault(dto.getId(), dto)) + .sorted((a, b) -> { + if (Objects.equals(a.getUserId(), b.getUserId())) { + return compareByDynamicScore(a, b); + } + return Long.compare( + a.getUserId() != null ? a.getUserId() : Long.MAX_VALUE, + b.getUserId() != null ? b.getUserId() : Long.MAX_VALUE + ); + }) + .collect(Collectors.toList()); + + redisQueueService.clearQueue(userId); + redisQueueService.batchEnqueue(queueDTOs); + } + + private void triggerQueueRebuildAfterCommit(Long userId, Long fallbackAreaId) { + Runnable rebuildAction = () -> { + try { + rebuildWaitingTasksByUserId(userId, fallbackAreaId); + } catch (Exception e) { + log.error("等待队列重算失败: userId={}", userId, e); + } + }; + + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + rebuildAction.run(); + return; + } + + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + rebuildAction.run(); + } + }); + } +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/QueueScoreCalculator.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/QueueScoreCalculator.java new file mode 100644 index 0000000..dd37022 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/QueueScoreCalculator.java @@ -0,0 +1,49 @@ +package com.viewsh.module.ops.service.queue; + +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.LocalDateTime; + +@Component +public class QueueScoreCalculator { + + static final int PRIORITY_WEIGHT = 1500; + static final int FLOOR_WEIGHT = 20; + static final int AGING_WEIGHT = 5; + static final int MAX_FLOOR_DIFF = 10; + static final int MAX_AGING_MINUTES = 240; + + public QueueScoreResult calculate(QueueScoreContext context) { + LocalDateTime now = context.getNow() != null ? context.getNow() : LocalDateTime.now(); + int priorityRank = context.getPriority() != null ? context.getPriority() : 3; + + Integer baseFloorNo = context.getBaseFloorNo(); + Integer targetFloorNo = context.getTargetFloorNo(); + Integer floorDiff = null; + int floorDiffScore = 0; + if (baseFloorNo != null && targetFloorNo != null) { + floorDiff = Math.abs(targetFloorNo - baseFloorNo); + floorDiffScore = Math.min(floorDiff, MAX_FLOOR_DIFF) * FLOOR_WEIGHT; + } else if (baseFloorNo != null) { + floorDiffScore = MAX_FLOOR_DIFF * FLOOR_WEIGHT; + } + + long waitMinutes = 0; + if (context.getEnqueueTime() != null) { + waitMinutes = Math.max(0, Duration.between(context.getEnqueueTime(), now).toMinutes()); + } + long agingScore = (long) Math.min(waitMinutes, MAX_AGING_MINUTES) * AGING_WEIGHT; + + long priorityScore = (long) priorityRank * PRIORITY_WEIGHT; + double totalScore = priorityScore + floorDiffScore - agingScore; + + return QueueScoreResult.builder() + .totalScore(totalScore) + .baseFloorNo(baseFloorNo) + .targetFloorNo(targetFloorNo) + .floorDiff(floorDiff) + .waitMinutes(waitMinutes) + .build(); + } +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/QueueScoreContext.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/QueueScoreContext.java new file mode 100644 index 0000000..d55d86a --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/QueueScoreContext.java @@ -0,0 +1,21 @@ +package com.viewsh.module.ops.service.queue; + +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Builder +public class QueueScoreContext { + + private Integer priority; + + private Integer baseFloorNo; + + private Integer targetFloorNo; + + private LocalDateTime enqueueTime; + + private LocalDateTime now; +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/QueueScoreResult.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/QueueScoreResult.java new file mode 100644 index 0000000..8263ec3 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/QueueScoreResult.java @@ -0,0 +1,19 @@ +package com.viewsh.module.ops.service.queue; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class QueueScoreResult { + + private double totalScore; + + private Integer baseFloorNo; + + private Integer targetFloorNo; + + private Integer floorDiff; + + private long waitMinutes; +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/RedisOrderQueueServiceImpl.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/RedisOrderQueueServiceImpl.java index b9a2537..c368e3c 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/RedisOrderQueueServiceImpl.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/RedisOrderQueueServiceImpl.java @@ -47,7 +47,9 @@ public class RedisOrderQueueServiceImpl implements RedisOrderQueueService { String infoKey = INFO_KEY_PREFIX + dto.getId(); // 1. 计算分数(优先级 + 时间戳) - double score = calculateScore(dto.getPriority(), dto.getEnqueueTime()); + double score = dto.getQueueScore() != null + ? dto.getQueueScore() + : calculateScore(dto.getPriority(), dto.getEnqueueTime()); dto.setQueueScore(score); // 2. 添加到 Sorted Set(使用 queueId 作为 member,而非 JSON) @@ -84,7 +86,9 @@ public class RedisOrderQueueServiceImpl implements RedisOrderQueueService { byte[] infoKey = (INFO_KEY_PREFIX + dto.getId()).getBytes(); // 计算分数并设置到 DTO - double score = calculateScore(dto.getPriority(), dto.getEnqueueTime()); + double score = dto.getQueueScore() != null + ? dto.getQueueScore() + : calculateScore(dto.getPriority(), dto.getEnqueueTime()); dto.setQueueScore(score); // 添加到 Sorted Set(使用 queueId 作为 member) @@ -214,9 +218,17 @@ public class RedisOrderQueueServiceImpl implements RedisOrderQueueService { public long clearQueue(Long cleanerId) { try { String queueKey = QUEUE_KEY_PREFIX + cleanerId; + Set queueIds = stringRedisTemplate.opsForZSet().range(queueKey, 0, -1); + if (queueIds != null && !queueIds.isEmpty()) { + List infoKeys = queueIds.stream() + .map(queueId -> INFO_KEY_PREFIX + queueId) + .collect(Collectors.toList()); + stringRedisTemplate.delete(infoKeys); + } stringRedisTemplate.delete(queueKey); - log.info("Redis 清空队列成功: cleanerId={}", cleanerId); - return 0; // Redis 不返回删除数量 + long removedCount = queueIds != null ? queueIds.size() : 0; + log.info("Redis 清空队列成功: cleanerId={}, removedCount={}", cleanerId, removedCount); + return removedCount; } catch (Exception e) { log.error("Redis 清空队列失败: cleanerId={}", cleanerId, e); return 0; @@ -326,7 +338,9 @@ public class RedisOrderQueueServiceImpl implements RedisOrderQueueService { String queueKey = QUEUE_KEY_PREFIX + dto.getUserId(); dto.setPriority(newPriority); - double newScore = calculateScore(newPriority, dto.getEnqueueTime()); + double newScore = dto.getQueueScore() != null + ? dto.getQueueScore() + : calculateScore(newPriority, dto.getEnqueueTime()); // 使用 Lua 脚本原子性更新 Hash 和 Sorted Set String script = @@ -451,7 +465,15 @@ public class RedisOrderQueueServiceImpl implements RedisOrderQueueService { if (opsOrderIdObj != null) { Long opsOrderId = Long.parseLong(opsOrderIdObj.toString()); if (opsOrderId.equals(orderId)) { - return mapToDto(infoMap); + OrderQueueDTO dto = mapToDto(infoMap); + if (dto == null || dto.getUserId() == null || dto.getId() == null) { + continue; + } + String queueKey = QUEUE_KEY_PREFIX + dto.getUserId(); + Double score = stringRedisTemplate.opsForZSet().score(queueKey, dto.getId().toString()); + if (score != null) { + return dto; + } } } } @@ -566,6 +588,21 @@ public class RedisOrderQueueServiceImpl implements RedisOrderQueueService { if (dto.getEnqueueTime() != null) { map.put("enqueueTime", dto.getEnqueueTime().toString()); } + if (dto.getBaseFloorNo() != null) { + map.put("baseFloorNo", String.valueOf(dto.getBaseFloorNo())); + } + if (dto.getTargetFloorNo() != null) { + map.put("targetFloorNo", String.valueOf(dto.getTargetFloorNo())); + } + if (dto.getFloorDiff() != null) { + map.put("floorDiff", String.valueOf(dto.getFloorDiff())); + } + if (dto.getWaitMinutes() != null) { + map.put("waitMinutes", String.valueOf(dto.getWaitMinutes())); + } + if (dto.getScoreUpdateTime() != null) { + map.put("scoreUpdateTime", dto.getScoreUpdateTime().toString()); + } return map; } @@ -587,6 +624,21 @@ public class RedisOrderQueueServiceImpl implements RedisOrderQueueService { if (dto.getEnqueueTime() != null) { map.put("enqueueTime".getBytes(), dto.getEnqueueTime().toString().getBytes()); } + if (dto.getBaseFloorNo() != null) { + map.put("baseFloorNo".getBytes(), String.valueOf(dto.getBaseFloorNo()).getBytes()); + } + if (dto.getTargetFloorNo() != null) { + map.put("targetFloorNo".getBytes(), String.valueOf(dto.getTargetFloorNo()).getBytes()); + } + if (dto.getFloorDiff() != null) { + map.put("floorDiff".getBytes(), String.valueOf(dto.getFloorDiff()).getBytes()); + } + if (dto.getWaitMinutes() != null) { + map.put("waitMinutes".getBytes(), String.valueOf(dto.getWaitMinutes()).getBytes()); + } + if (dto.getScoreUpdateTime() != null) { + map.put("scoreUpdateTime".getBytes(), dto.getScoreUpdateTime().toString().getBytes()); + } return map; } @@ -633,6 +685,26 @@ public class RedisOrderQueueServiceImpl implements RedisOrderQueueService { if (enqueueTimeObj != null) { dto.setEnqueueTime(LocalDateTime.parse(enqueueTimeObj.toString())); } + Object baseFloorNoObj = map.get("baseFloorNo"); + if (baseFloorNoObj != null) { + dto.setBaseFloorNo(Integer.parseInt(baseFloorNoObj.toString())); + } + Object targetFloorNoObj = map.get("targetFloorNo"); + if (targetFloorNoObj != null) { + dto.setTargetFloorNo(Integer.parseInt(targetFloorNoObj.toString())); + } + Object floorDiffObj = map.get("floorDiff"); + if (floorDiffObj != null) { + dto.setFloorDiff(Integer.parseInt(floorDiffObj.toString())); + } + Object waitMinutesObj = map.get("waitMinutes"); + if (waitMinutesObj != null) { + dto.setWaitMinutes(Long.parseLong(waitMinutesObj.toString())); + } + Object scoreUpdateTimeObj = map.get("scoreUpdateTime"); + if (scoreUpdateTimeObj != null) { + dto.setScoreUpdateTime(LocalDateTime.parse(scoreUpdateTimeObj.toString())); + } return dto; } catch (Exception e) { diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/queue/OrderQueueServiceEnhancedTest.java b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/queue/OrderQueueServiceEnhancedTest.java new file mode 100644 index 0000000..19f5885 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/queue/OrderQueueServiceEnhancedTest.java @@ -0,0 +1,103 @@ +package com.viewsh.module.ops.service.queue; + +import com.viewsh.module.ops.api.queue.OrderQueueDTO; +import com.viewsh.module.ops.dal.dataobject.area.OpsBusAreaDO; +import com.viewsh.module.ops.dal.dataobject.queue.OpsOrderQueueDO; +import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO; +import com.viewsh.module.ops.dal.mysql.area.OpsBusAreaMapper; +import com.viewsh.module.ops.dal.mysql.queue.OpsOrderQueueMapper; +import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper; +import com.viewsh.module.ops.enums.OrderQueueStatusEnum; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; + +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.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OrderQueueServiceEnhancedTest { + + @Mock + private OpsOrderQueueMapper orderQueueMapper; + @Mock + private OpsOrderMapper orderMapper; + @Mock + private OpsBusAreaMapper areaMapper; + @Mock + private RedisOrderQueueService redisQueueService; + @Mock + private QueueSyncService queueSyncService; + @Spy + private QueueScoreCalculator queueScoreCalculator = new QueueScoreCalculator(); + + @InjectMocks + private OrderQueueServiceEnhanced orderQueueService; + + @Test + void shouldRebuildWaitingTasksAndAvoidStarvation() { + LocalDateTime now = LocalDateTime.now(); + Long userId = 2001L; + + OpsOrderQueueDO olderFarTask = OpsOrderQueueDO.builder() + .id(11L) + .userId(userId) + .opsOrderId(101L) + .priority(1) + .queueScore(0D) + .queueStatus(OrderQueueStatusEnum.WAITING.getStatus()) + .enqueueTime(now.minusMinutes(80)) + .build(); + OpsOrderQueueDO newerNearTask = OpsOrderQueueDO.builder() + .id(12L) + .userId(userId) + .opsOrderId(102L) + .priority(1) + .queueScore(0D) + .queueStatus(OrderQueueStatusEnum.WAITING.getStatus()) + .enqueueTime(now.minusMinutes(5)) + .build(); + OpsOrderQueueDO currentTask = OpsOrderQueueDO.builder() + .id(13L) + .userId(userId) + .opsOrderId(900L) + .queueStatus(OrderQueueStatusEnum.PROCESSING.getStatus()) + .build(); + + when(orderQueueMapper.selectListByUserIdAndStatus(userId, OrderQueueStatusEnum.WAITING.getStatus())) + .thenReturn(List.of(olderFarTask, newerNearTask)); + when(orderQueueMapper.selectCurrentExecutingByUserId(userId)).thenReturn(currentTask); + when(orderQueueMapper.selectListByUserId(userId)).thenReturn(List.of(olderFarTask, newerNearTask, currentTask)); + when(orderMapper.selectById(900L)).thenReturn(OpsOrderDO.builder().id(900L).areaId(501L).build()); + when(orderMapper.selectById(101L)).thenReturn(OpsOrderDO.builder().id(101L).areaId(503L).build()); + when(orderMapper.selectById(102L)).thenReturn(OpsOrderDO.builder().id(102L).areaId(502L).build()); + when(areaMapper.selectById(501L)).thenReturn(OpsBusAreaDO.builder().id(501L).floorNo(5).build()); + when(areaMapper.selectById(502L)).thenReturn(OpsBusAreaDO.builder().id(502L).floorNo(6).build()); + when(areaMapper.selectById(503L)).thenReturn(OpsBusAreaDO.builder().id(503L).floorNo(8).build()); + when(orderQueueMapper.updateById(any(OpsOrderQueueDO.class))).thenReturn(1); + + List rebuiltTasks = orderQueueService.rebuildWaitingTasksByUserId(userId, null); + + assertEquals(2, rebuiltTasks.size()); + assertEquals(101L, rebuiltTasks.get(0).getOpsOrderId()); + assertEquals(3, rebuiltTasks.get(0).getFloorDiff()); + assertTrue(rebuiltTasks.get(0).getWaitMinutes() >= 79); + assertEquals(102L, rebuiltTasks.get(1).getOpsOrderId()); + assertEquals(1, rebuiltTasks.get(1).getFloorDiff()); + assertTrue(rebuiltTasks.get(0).getQueueScore() < rebuiltTasks.get(1).getQueueScore()); + + verify(orderQueueMapper, times(2)).updateById(any(OpsOrderQueueDO.class)); + verify(redisQueueService).clearQueue(userId); + verify(redisQueueService).batchEnqueue(any()); + } +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/queue/QueueScoreCalculatorTest.java b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/queue/QueueScoreCalculatorTest.java new file mode 100644 index 0000000..2f44a4d --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/queue/QueueScoreCalculatorTest.java @@ -0,0 +1,73 @@ +package com.viewsh.module.ops.service.queue; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class QueueScoreCalculatorTest { + + private final QueueScoreCalculator calculator = new QueueScoreCalculator(); + + @Test + void shouldPreferSmallerFloorDiffWhenPrioritySame() { + LocalDateTime now = LocalDateTime.now(); + + QueueScoreResult near = calculator.calculate(QueueScoreContext.builder() + .priority(1) + .baseFloorNo(5) + .targetFloorNo(6) + .enqueueTime(now.minusMinutes(5)) + .now(now) + .build()); + + QueueScoreResult far = calculator.calculate(QueueScoreContext.builder() + .priority(1) + .baseFloorNo(5) + .targetFloorNo(9) + .enqueueTime(now.minusMinutes(5)) + .now(now) + .build()); + + assertTrue(near.getTotalScore() < far.getTotalScore()); + } + + @Test + void shouldAllowOlderOrderToGainAgingAdvantage() { + LocalDateTime now = LocalDateTime.now(); + + QueueScoreResult older = calculator.calculate(QueueScoreContext.builder() + .priority(1) + .baseFloorNo(5) + .targetFloorNo(8) + .enqueueTime(now.minusMinutes(80)) + .now(now) + .build()); + + QueueScoreResult newer = calculator.calculate(QueueScoreContext.builder() + .priority(1) + .baseFloorNo(5) + .targetFloorNo(6) + .enqueueTime(now.minusMinutes(5)) + .now(now) + .build()); + + assertTrue(older.getTotalScore() < newer.getTotalScore()); + } + + @Test + void shouldDegradeGracefullyWhenBaseFloorMissing() { + LocalDateTime now = LocalDateTime.now(); + + QueueScoreResult result = calculator.calculate(QueueScoreContext.builder() + .priority(2) + .baseFloorNo(null) + .targetFloorNo(6) + .enqueueTime(now.minusMinutes(10)) + .now(now) + .build()); + + assertTrue(result.getTotalScore() < QueueScoreCalculator.PRIORITY_WEIGHT * 2); + } +} From 713ae744acfbdf8df9a317f57c208f9ea92d36a7 Mon Sep 17 00:00:00 2001 From: lzh Date: Sat, 7 Mar 2026 22:28:11 +0800 Subject: [PATCH 03/35] =?UTF-8?q?feat(ops):=20=E5=AE=A2=E6=B5=81=E9=98=88?= =?UTF-8?q?=E5=80=BC=E8=A7=A6=E5=8F=91=E9=9D=99=E9=BB=98=E5=A4=84=E7=90=86?= =?UTF-8?q?=EF=BC=8C=E5=B7=A5=E5=8D=95=E5=AE=8C=E6=88=90=E6=97=B6=E9=87=8D?= =?UTF-8?q?=E7=BD=AE=E8=AE=A1=E6=95=B0=E5=99=A8=E9=98=B2=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 已派发/已到达(DISPATCHED/CONFIRMED/ARRIVED)状态静默忽略客流触发, 仅排队中(PENDING/QUEUED)状态才升级优先级 2. 工单完成时先重置IoT客流计数器再清除活跃标记,防止残留计数 和MQ消息延迟导致的竞态误创建工单 3. 工单取消时仅清除活跃标记不重置计数器,保留客流数据以便尽快 重新触发 Co-Authored-By: Claude Opus 4.6 --- .../CleanOrderCreateEventHandler.java | 46 +++++++++--- .../listener/CleanOrderEventListener.java | 71 +++++++++++++++++-- 2 files changed, 104 insertions(+), 13 deletions(-) diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/CleanOrderCreateEventHandler.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/CleanOrderCreateEventHandler.java index ca1b621..33ecab7 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/CleanOrderCreateEventHandler.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/CleanOrderCreateEventHandler.java @@ -36,8 +36,8 @@ import java.util.concurrent.TimeUnit; *

    * 客流触发逻辑(周期化): * 1. 无活跃工单 → 创建新工单 → 标记活跃 → 重置阈值 - * 2. 有未派发工单(PENDING/QUEUED) → 升级优先级一级 → 重置阈值 - * 3. 有已派发工单(DISPATCHED/CONFIRMED/ARRIVED) → 忽略 → 重置阈值 + * 2. 有排队中工单(PENDING/QUEUED) → 升级优先级一级 → 重置阈值 + * 3. 有已派发/已到达工单(DISPATCHED/CONFIRMED/ARRIVED) → 静默处理,不升级不创建 → 重置阈值 * 4. 已是 P0 → 不升级,记录审计日志 → 重置阈值 *

    * RocketMQ 配置: @@ -69,9 +69,9 @@ public class CleanOrderCreateEventHandler implements RocketMQListener { private static final int DEDUP_TTL_SECONDS = 300; /** - * 未派发状态集合(可升级优先级) + * 可升级优先级的状态集合(仅排队中,尚未派发) */ - private static final Set UNDISPATCHED_STATUSES = Set.of( + private static final Set UPGRADABLE_STATUSES = Set.of( WorkOrderStatusEnum.PENDING.getStatus(), WorkOrderStatusEnum.QUEUED.getStatus() ); @@ -162,8 +162,8 @@ public class CleanOrderCreateEventHandler implements RocketMQListener { if (activeOrder != null) { String status = activeOrder.getStatus(); - if (UNDISPATCHED_STATUSES.contains(status)) { - // 未派发 → 升级优先级一级 + if (UPGRADABLE_STATUSES.contains(status)) { + // 排队中 → 升级优先级一级 PriorityEnum result = cleanOrderService.upgradeOneLevelPriority( activeOrder.getOrderId(), "客流持续达标自动升级"); @@ -179,9 +179,10 @@ public class CleanOrderCreateEventHandler implements RocketMQListener { areaId, activeOrder.getOrderId()); } } else { - // 已派发(DISPATCHED/CONFIRMED/ARRIVED)→ 忽略 - log.info("[CleanOrderCreateEventHandler] 区域{}已有已派发工单{}(状态:{}),忽略本次客流触发", - areaId, activeOrder.getOrderId(), status); + // 已派发/已确认/已到达 → 保洁员已在处理中,静默忽略 + log.info("[CleanOrderCreateEventHandler] 区域{}保洁员已在处理中(状态:{}),客流触发静默忽略: orderId={}", + areaId, status, activeOrder.getOrderId()); + recordArrivedSilentLog(event, activeOrder.getOrderId()); } // ★ 所有分支都重置阈值 @@ -389,6 +390,33 @@ public class CleanOrderCreateEventHandler implements RocketMQListener { } } + /** + * 记录已派发/已到达静默处理审计日志 + */ + private void recordArrivedSilentLog(CleanOrderCreateEventDTO event, Long orderId) { + try { + Map extra = new HashMap<>(); + extra.put("eventId", event.getEventId()); + extra.put("areaId", event.getAreaId()); + extra.put("reason", "保洁员已在处理中,客流触发静默忽略"); + + eventLogRecorder.record(EventLogRecord.builder() + .module("clean") + .domain(EventDomain.TRAFFIC) + .eventType("ARRIVED_SILENT_IGNORE") + .message(String.format("保洁员已在处理中,客流触发静默忽略 [区域:%d]", event.getAreaId())) + .targetId(orderId) + .targetType("order") + .deviceId(event.getTriggerDeviceId()) + .level(EventLevel.INFO) + .payload(extra) + .build()); + + } catch (Exception e) { + log.warn("[CleanOrderCreateEventHandler] 记录静默处理日志失败: orderId={}", orderId, e); + } + } + /** * 确定事件域 */ diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/listener/CleanOrderEventListener.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/listener/CleanOrderEventListener.java index 1d4f9c4..785cd29 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/listener/CleanOrderEventListener.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/listener/CleanOrderEventListener.java @@ -1,5 +1,7 @@ package com.viewsh.module.ops.environment.integration.listener; +import com.viewsh.module.iot.api.device.IotDeviceControlApi; +import com.viewsh.module.iot.api.device.dto.ResetTrafficCounterReqDTO; import com.viewsh.module.ops.api.queue.OrderQueueService; import com.viewsh.module.ops.core.dispatch.DispatchEngine; import com.viewsh.module.ops.core.dispatch.model.DispatchResult; @@ -87,6 +89,9 @@ public class CleanOrderEventListener { @Resource private TrafficActiveOrderRedisDAO trafficActiveOrderRedisDAO; + @Resource + private IotDeviceControlApi iotDeviceControlApi; + // ==================== 工单创建事件 ==================== /** @@ -184,7 +189,7 @@ public class CleanOrderEventListener { break; case COMPLETED: handleCompleted(event); - clearTrafficActiveOrder(event); + clearTrafficActiveOrderOnComplete(event); break; case CANCELLED: @@ -733,22 +738,80 @@ public class CleanOrderEventListener { } /** - * 工单终态时,清除 Redis 中的活跃工单标记 + * 工单完成时,先重置 IoT 客流计数器,再清除活跃标记 *

    - * 仅处理客流触发的工单。清除后下次客流达标将创建新工单(新周期)。 + * 顺序至关重要:先重置计数器确保设备归零,再清除活跃标记开放新周期。 + * 如果先清标记后重置,存在竞态窗口——已发出的 MQ 消息在标记清除后到达, + * 此时计数器尚未归零,会误创建基于残留计数的工单。 + *

    + * 如果计数器重置失败,仍然清除活跃标记(降级处理),避免区域永远被锁死。 + */ + private void clearTrafficActiveOrderOnComplete(OrderStateChangedEvent event) { + try { + OpsOrderDO order = opsOrderMapper.selectById(event.getOrderId()); + if (order != null && "IOT_TRAFFIC".equals(order.getTriggerSource()) && order.getAreaId() != null) { + // 1. 先重置 IoT 客流计数器(阻塞等待结果) + boolean resetOk = resetTrafficCounterOnComplete(order.getTriggerDeviceId(), order.getAreaId()); + + // 2. 再清除活跃标记 + trafficActiveOrderRedisDAO.removeActive(order.getAreaId()); + + if (resetOk) { + log.info("[CleanOrderEventListener] 客流工单完成,计数器已重置,活跃标记已清除: areaId={}", order.getAreaId()); + } else { + log.warn("[CleanOrderEventListener] 客流工单完成,计数器重置失败但活跃标记已清除(降级): areaId={}", order.getAreaId()); + } + } + } catch (Exception e) { + log.warn("[CleanOrderEventListener] 清除客流活跃工单标记失败: orderId={}", event.getOrderId(), e); + } + } + + /** + * 工单取消时,仅清除活跃标记,不重置计数器 + *

    + * 取消意味着区域未被清洁,客流计数应保留,以便尽快重新触发工单。 */ private void clearTrafficActiveOrder(OrderStateChangedEvent event) { try { OpsOrderDO order = opsOrderMapper.selectById(event.getOrderId()); if (order != null && "IOT_TRAFFIC".equals(order.getTriggerSource()) && order.getAreaId() != null) { trafficActiveOrderRedisDAO.removeActive(order.getAreaId()); - log.info("[CleanOrderEventListener] 客流工单周期结束,已清除区域{}活跃标记", order.getAreaId()); + log.info("[CleanOrderEventListener] 客流工单取消,已清除区域{}活跃标记(计数器保留)", order.getAreaId()); } } catch (Exception e) { log.warn("[CleanOrderEventListener] 清除客流活跃工单标记失败: orderId={}", event.getOrderId(), e); } } + /** + * 工单完成时重置客流计数器 + * + * @return true 重置成功,false 重置失败或无设备ID + */ + private boolean resetTrafficCounterOnComplete(Long deviceId, Long areaId) { + if (deviceId == null) { + return false; + } + try { + ResetTrafficCounterReqDTO reqDTO = ResetTrafficCounterReqDTO.builder() + .deviceId(deviceId) + .remark("工单完成后重置计数器,清除作业期间残留计数") + .build(); + var result = iotDeviceControlApi.resetTrafficCounter(reqDTO); + if (result.getData() != null && result.getData()) { + log.info("[CleanOrderEventListener] 工单完成,计数器重置成功: deviceId={}, areaId={}", deviceId, areaId); + return true; + } else { + log.warn("[CleanOrderEventListener] 工单完成,计数器重置失败: deviceId={}, areaId={}", deviceId, areaId); + return false; + } + } catch (Exception e) { + log.warn("[CleanOrderEventListener] 工单完成,计数器重置异常: deviceId={}", deviceId, e); + return false; + } + } + /** * 记录暂停开始时间 */ From af1e0c0989ddbf2e65e84c60c07f9459f6599caa Mon Sep 17 00:00:00 2001 From: lzh Date: Sat, 7 Mar 2026 22:32:16 +0800 Subject: [PATCH 04/35] =?UTF-8?q?fix(iot):=20=E6=9A=82=E6=97=B6=E5=8F=96?= =?UTF-8?q?=E6=B6=88=E4=BD=9C=E4=B8=9A=E6=97=B6=E9=95=BF=E4=B8=8D=E8=B6=B3?= =?UTF-8?q?=E6=8A=91=E5=88=B6=E8=87=AA=E5=8A=A8=E5=AE=8C=E6=88=90=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 信号丢失超时后不再校验最小有效作业时长,所有情况均直接触发自动完成。 Co-Authored-By: Claude Opus 4.6 --- .../processor/SignalLossRuleProcessor.java | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/SignalLossRuleProcessor.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/SignalLossRuleProcessor.java index 5b2dd29..492427f 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/SignalLossRuleProcessor.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/SignalLossRuleProcessor.java @@ -191,15 +191,16 @@ public class SignalLossRuleProcessor { long minValidWorkMillis = exitConfig.getMinValidWorkMinutes() * 60000L; // 6. 分支处理:有效 vs 无效作业 - if (durationMs < minValidWorkMillis) { - // 作业时长不足,抑制完成 - handleInvalidWork(deviceId, badgeDeviceKey, areaId, - durationMs, minValidWorkMillis, exitConfig); - } else { - // 作业时长有效,触发完成 - handleTimeoutComplete(deviceId, badgeDeviceKey, areaId, - durationMs, lastLossTime); - } + // TODO 暂时取消作业时长不足抑制自动完成的逻辑,所有情况均触发完成 + // if (durationMs < minValidWorkMillis) { + // // 作业时长不足,抑制完成 + // handleInvalidWork(deviceId, badgeDeviceKey, areaId, + // durationMs, minValidWorkMillis, exitConfig); + // } else { + // 作业时长有效,触发完成 + handleTimeoutComplete(deviceId, badgeDeviceKey, areaId, + durationMs, lastLossTime); + // } } /** From 57f32e56a97cf1ae2ff437affa6fe69cede68689 Mon Sep 17 00:00:00 2001 From: lzh Date: Sat, 7 Mar 2026 22:44:09 +0800 Subject: [PATCH 05/35] =?UTF-8?q?fix(ops):=20=E6=94=B6=E5=8F=A3=E9=98=9F?= =?UTF-8?q?=E5=88=97=20Redis=20=E5=88=86=E6=95=B0=E6=9D=A5=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 Redis 队列层基于优先级和时间戳的本地兜底算分逻辑 - 强制 enqueue、batchEnqueue、updatePriority 使用服务层预先计算的 queueScore - 兼容历史缺少 queueScore 的 Redis 记录,按最低优先级处理避免旧模型重新参与排序 - 补齐 QueueSyncService 的 queueScore 映射,确保 MySQL 同步到 Redis 时保留总分 - 新增 QueueSyncServiceTest 覆盖同步链路携带 queueScore 的行为 --- .../ops/service/queue/QueueSyncService.java | 1 + .../queue/RedisOrderQueueServiceImpl.java | 65 +++++-------------- .../service/queue/QueueSyncServiceTest.java | 61 +++++++++++++++++ 3 files changed, 77 insertions(+), 50 deletions(-) create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/queue/QueueSyncServiceTest.java diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/QueueSyncService.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/QueueSyncService.java index 57168a0..2ec000a 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/QueueSyncService.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/QueueSyncService.java @@ -232,6 +232,7 @@ public class QueueSyncService { dto.setUserId(queueDO.getUserId()); dto.setQueueIndex(queueDO.getQueueIndex()); dto.setPriority(queueDO.getPriority()); + dto.setQueueScore(queueDO.getQueueScore()); dto.setQueueStatus(queueDO.getQueueStatus()); dto.setEnqueueTime(queueDO.getEnqueueTime()); dto.setDequeueTime(queueDO.getDequeueTime()); diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/RedisOrderQueueServiceImpl.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/RedisOrderQueueServiceImpl.java index c368e3c..24a7ad7 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/RedisOrderQueueServiceImpl.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/RedisOrderQueueServiceImpl.java @@ -9,7 +9,6 @@ import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Service; import java.time.LocalDateTime; -import java.time.ZoneId; import java.util.*; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @@ -27,29 +26,14 @@ public class RedisOrderQueueServiceImpl implements RedisOrderQueueService { @Resource private StringRedisTemplate stringRedisTemplate; - /** - * Score 计算公式:优先级分数 + 时间戳 - * 优先级分数:P0=0, P1=1000000, P2=2000000, P3=3000000 - * 时间戳:毫秒级时间戳 - * 结果:优先级高的排在前面,同优先级按时间排序 - */ - private static final Map PRIORITY_SCORES = Map.of( - 0, 0L, // P0: 0 - 1, 1000000L, // P1: 1,000,000 - 2, 2000000L, // P2: 2,000,000 - 3, 3000000L // P3: 3,000,000 - ); - @Override public boolean enqueue(OrderQueueDTO dto) { try { String queueKey = QUEUE_KEY_PREFIX + dto.getUserId(); String infoKey = INFO_KEY_PREFIX + dto.getId(); - // 1. 计算分数(优先级 + 时间戳) - double score = dto.getQueueScore() != null - ? dto.getQueueScore() - : calculateScore(dto.getPriority(), dto.getEnqueueTime()); + // Redis 仅持久化服务层已计算好的最终总分,不再本地兜底重算 + double score = requireQueueScore(dto, "enqueue"); dto.setQueueScore(score); // 2. 添加到 Sorted Set(使用 queueId 作为 member,而非 JSON) @@ -85,10 +69,8 @@ public class RedisOrderQueueServiceImpl implements RedisOrderQueueService { byte[] queueKey = (QUEUE_KEY_PREFIX + dto.getUserId()).getBytes(); byte[] infoKey = (INFO_KEY_PREFIX + dto.getId()).getBytes(); - // 计算分数并设置到 DTO - double score = dto.getQueueScore() != null - ? dto.getQueueScore() - : calculateScore(dto.getPriority(), dto.getEnqueueTime()); + // Redis 仅持久化服务层已计算好的最终总分,不再本地兜底重算 + double score = requireQueueScore(dto, "batchEnqueue"); dto.setQueueScore(score); // 添加到 Sorted Set(使用 queueId 作为 member) @@ -338,9 +320,7 @@ public class RedisOrderQueueServiceImpl implements RedisOrderQueueService { String queueKey = QUEUE_KEY_PREFIX + dto.getUserId(); dto.setPriority(newPriority); - double newScore = dto.getQueueScore() != null - ? dto.getQueueScore() - : calculateScore(newPriority, dto.getEnqueueTime()); + double newScore = requireQueueScore(dto, "updatePriority"); // 使用 Lua 脚本原子性更新 Hash 和 Sorted Set String script = @@ -554,23 +534,6 @@ public class RedisOrderQueueServiceImpl implements RedisOrderQueueService { // ========== 私有方法 ========== - /** - * 计算分数(优先级 + 时间戳) - */ - private double calculateScore(Integer priority, LocalDateTime enqueueTime) { - long priorityScore = PRIORITY_SCORES.getOrDefault(priority, 3000000L); - long timestamp; - if (enqueueTime != null) { - timestamp = enqueueTime - .atZone(ZoneId.systemDefault()) - .toEpochSecond(); - } else { - timestamp = System.currentTimeMillis() / 1000; - } - - return priorityScore + timestamp; - } - /** * 将 DTO 转换为 Map(用于 Hash 存储,所有值显式转 String,确保跨路径序列化一致) */ @@ -670,14 +633,9 @@ public class RedisOrderQueueServiceImpl implements RedisOrderQueueService { if (queueScoreObj != null) { dto.setQueueScore(Double.parseDouble(queueScoreObj.toString())); } else { - // 如果没有存储,则根据 priority 和 enqueueTime 计算 - Integer priority = dto.getPriority(); - LocalDateTime enqueueTime = null; - Object enqueueTimeObj = map.get("enqueueTime"); - if (enqueueTimeObj != null) { - enqueueTime = LocalDateTime.parse(enqueueTimeObj.toString()); - } - dto.setQueueScore(calculateScore(priority, enqueueTime)); + // 历史数据兼容:没有总分时将其视为最低优先级,避免旧模型再次参与排序 + log.warn("Redis 队列记录缺少 queueScore,按最低优先级处理: queueId={}", dto.getId()); + dto.setQueueScore(Double.MAX_VALUE); } // 字符串转换为 LocalDateTime @@ -712,4 +670,11 @@ public class RedisOrderQueueServiceImpl implements RedisOrderQueueService { return null; } } + + private double requireQueueScore(OrderQueueDTO dto, String operation) { + if (dto == null || dto.getQueueScore() == null) { + throw new IllegalArgumentException("queueScore is required for Redis " + operation); + } + return dto.getQueueScore(); + } } diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/queue/QueueSyncServiceTest.java b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/queue/QueueSyncServiceTest.java new file mode 100644 index 0000000..84e4878 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/queue/QueueSyncServiceTest.java @@ -0,0 +1,61 @@ +package com.viewsh.module.ops.service.queue; + +import com.viewsh.module.ops.api.queue.OrderQueueDTO; +import com.viewsh.module.ops.dal.dataobject.queue.OpsOrderQueueDO; +import com.viewsh.module.ops.dal.mysql.queue.OpsOrderQueueMapper; +import com.viewsh.module.ops.enums.OrderQueueStatusEnum; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class QueueSyncServiceTest { + + @Mock + private OpsOrderQueueMapper orderQueueMapper; + @Mock + private RedisOrderQueueService redisQueueService; + + @InjectMocks + private QueueSyncService queueSyncService; + + @Test + void shouldCarryPersistedQueueScoreWhenSyncUserQueueToRedis() { + Long userId = 1001L; + OpsOrderQueueDO queueDO = OpsOrderQueueDO.builder() + .id(11L) + .opsOrderId(22L) + .userId(userId) + .queueIndex(1) + .priority(0) + .queueScore(-120D) + .queueStatus(OrderQueueStatusEnum.WAITING.getStatus()) + .enqueueTime(LocalDateTime.now().minusMinutes(10)) + .build(); + + when(orderQueueMapper.selectList(any())).thenReturn(List.of(queueDO)); + when(redisQueueService.clearQueue(userId)).thenReturn(1L); + when(redisQueueService.batchEnqueue(any())).thenReturn(1L); + + queueSyncService.syncUserQueueToRedis(userId); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(redisQueueService).batchEnqueue(captor.capture()); + List syncedTasks = captor.getValue(); + assertNotNull(syncedTasks); + assertEquals(1, syncedTasks.size()); + assertEquals(-120D, syncedTasks.get(0).getQueueScore()); + } +} From b8d0a77156fd2deddd895e58d3c03518cd743c50 Mon Sep 17 00:00:00 2001 From: lzh Date: Mon, 9 Mar 2026 15:59:11 +0800 Subject: [PATCH 06/35] =?UTF-8?q?feat(ops):=20=E8=B0=83=E6=95=B4=E9=98=9F?= =?UTF-8?q?=E5=88=97=E8=AF=84=E5=88=86=E6=9D=83=E9=87=8D=EF=BC=8C=E6=A5=BC?= =?UTF-8?q?=E5=B1=82=E5=B7=AE=C3=973=20=E8=80=81=E5=8C=96=C3=B71.67?= =?UTF-8?q?=EF=BC=8C=E4=B8=B4=E7=95=8C=E5=80=BC=201=20=E5=B1=82=3D20=20?= =?UTF-8?q?=E5=88=86=E9=92=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FLOOR_WEIGHT 20→60、AGING_WEIGHT 5→3,强化就近派单效果。 Co-Authored-By: Claude Opus 4.6 --- .../viewsh/module/ops/service/queue/QueueScoreCalculator.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/QueueScoreCalculator.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/QueueScoreCalculator.java index dd37022..5e22b1f 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/QueueScoreCalculator.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/QueueScoreCalculator.java @@ -9,8 +9,8 @@ import java.time.LocalDateTime; public class QueueScoreCalculator { static final int PRIORITY_WEIGHT = 1500; - static final int FLOOR_WEIGHT = 20; - static final int AGING_WEIGHT = 5; + static final int FLOOR_WEIGHT = 60; + static final int AGING_WEIGHT = 3; static final int MAX_FLOOR_DIFF = 10; static final int MAX_AGING_MINUTES = 240; From d53d1c4584c2d3d6c902a4fb206a636f2a6f3f65 Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 11 Mar 2026 17:32:56 +0800 Subject: [PATCH 07/35] =?UTF-8?q?feat(ops):=20=E6=96=B0=E5=A2=9E=E5=AE=89?= =?UTF-8?q?=E4=BF=9D=E6=A8=A1=E5=9D=97=E6=95=B0=E6=8D=AE=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E4=B8=8E=20Mapper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增安保区域人员绑定表 ops_area_security_user 和安保工单扩展表 ops_order_security_ext,以及对应的 DO 和 Mapper 接口。 Co-Authored-By: Claude Opus 4.6 --- sql/mysql/ops_area_security_user.sql | 16 +++ sql/mysql/ops_order_security_ext.sql | 41 +++++++ .../area/OpsAreaSecurityUserDO.java | 58 ++++++++++ .../workorder/OpsOrderSecurityExtDO.java | 100 ++++++++++++++++++ .../mysql/area/OpsAreaSecurityUserMapper.java | 37 +++++++ .../workorder/OpsOrderSecurityExtMapper.java | 37 +++++++ 6 files changed, 289 insertions(+) create mode 100644 sql/mysql/ops_area_security_user.sql create mode 100644 sql/mysql/ops_order_security_ext.sql create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/dataobject/area/OpsAreaSecurityUserDO.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/dataobject/workorder/OpsOrderSecurityExtDO.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/mysql/area/OpsAreaSecurityUserMapper.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/mysql/workorder/OpsOrderSecurityExtMapper.java diff --git a/sql/mysql/ops_area_security_user.sql b/sql/mysql/ops_area_security_user.sql new file mode 100644 index 0000000..eaee0ef --- /dev/null +++ b/sql/mysql/ops_area_security_user.sql @@ -0,0 +1,16 @@ +CREATE TABLE ops_area_security_user ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + area_id BIGINT NOT NULL COMMENT '区域ID,关联 ops_bus_area.id', + user_id BIGINT NOT NULL COMMENT '安保人员用户ID,关联 system_users.id', + user_name VARCHAR(64) DEFAULT '' COMMENT '安保人员姓名(冗余)', + team_id BIGINT DEFAULT NULL COMMENT '所属班组ID', + enabled BIT DEFAULT 1 COMMENT '是否启用', + sort INT DEFAULT 0 COMMENT '排序值', + creator VARCHAR(64) DEFAULT '', + create_time DATETIME DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) DEFAULT '', + update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted BIT DEFAULT 0, + tenant_id BIGINT DEFAULT 0, + UNIQUE KEY uk_area_user (area_id, user_id, deleted) +) COMMENT '区域-安保人员绑定表'; diff --git a/sql/mysql/ops_order_security_ext.sql b/sql/mysql/ops_order_security_ext.sql new file mode 100644 index 0000000..983fad0 --- /dev/null +++ b/sql/mysql/ops_order_security_ext.sql @@ -0,0 +1,41 @@ +-- ---------------------------- +-- Table structure for ops_order_security_ext +-- ---------------------------- +DROP TABLE IF EXISTS `ops_order_security_ext`; +CREATE TABLE `ops_order_security_ext` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `ops_order_id` bigint NOT NULL COMMENT '工单ID,关联 ops_order.id', + + -- 告警来源(告警工单必填,手动工单可空) + `alarm_id` varchar(64) DEFAULT NULL COMMENT '关联告警ID', + `alarm_type` varchar(50) DEFAULT NULL COMMENT '告警类型: intrusion/leave_post/fire/fence', + `camera_id` varchar(64) DEFAULT NULL COMMENT '摄像头ID', + `roi_id` varchar(64) DEFAULT NULL COMMENT 'ROI区域ID', + `image_url` varchar(512) DEFAULT NULL COMMENT '告警截图URL', + + -- 处理人(冗余快照,创建时写入) + `assigned_user_id` bigint DEFAULT NULL COMMENT '处理人user_id', + `assigned_user_name` varchar(100) DEFAULT NULL COMMENT '处理人姓名', + `assigned_team_id` bigint DEFAULT NULL COMMENT '班组ID', + + -- 处理结果(完成时提交) + `result` text DEFAULT NULL COMMENT '处理结果描述', + `result_img_urls` varchar(2048) DEFAULT NULL COMMENT '处理结果图片URL,JSON数组', + + -- 关键时间点 + `dispatched_time` datetime DEFAULT NULL COMMENT '派单时间', + `confirmed_time` datetime DEFAULT NULL COMMENT '确认时间', + `completed_time` datetime DEFAULT NULL COMMENT '完成时间', + + -- 审计字段 + `creator` varchar(64) DEFAULT '' COMMENT '创建者', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` varchar(64) DEFAULT '' COMMENT '更新者', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号', + + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_ops_order_id` (`ops_order_id`, `deleted`) USING BTREE, + INDEX `idx_alarm_id` (`alarm_id`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '安保工单扩展表' ROW_FORMAT = Dynamic; diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/dataobject/area/OpsAreaSecurityUserDO.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/dataobject/area/OpsAreaSecurityUserDO.java new file mode 100644 index 0000000..ea9fa78 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/dataobject/area/OpsAreaSecurityUserDO.java @@ -0,0 +1,58 @@ +package com.viewsh.module.ops.security.dal.dataobject.area; + +import com.viewsh.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * 区域-安保人员绑定 DO + * + * @author lzh + */ +@TableName("ops_area_security_user") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class OpsAreaSecurityUserDO extends BaseDO { + + /** + * 主键 + */ + @TableId + private Long id; + + /** + * 区域ID,关联 ops_bus_area.id + */ + private Long areaId; + + /** + * 安保人员用户ID,关联 system_users.id + */ + private Long userId; + + /** + * 安保人员姓名(冗余) + */ + private String userName; + + /** + * 所属班组ID + */ + private Long teamId; + + /** + * 是否启用 + */ + private Boolean enabled; + + /** + * 排序值 + */ + private Integer sort; + +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/dataobject/workorder/OpsOrderSecurityExtDO.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/dataobject/workorder/OpsOrderSecurityExtDO.java new file mode 100644 index 0000000..5073164 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/dataobject/workorder/OpsOrderSecurityExtDO.java @@ -0,0 +1,100 @@ +package com.viewsh.module.ops.security.dal.dataobject.workorder; + +import com.viewsh.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * 安保工单扩展 DO + * + * @author lzh + */ +@TableName("ops_order_security_ext") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class OpsOrderSecurityExtDO extends BaseDO { + + /** + * 主键 + */ + @TableId + private Long id; + /** + * 工单ID + * + * 关联 {@link com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO#getId()} + */ + private Long opsOrderId; + + // ==================== 告警来源 ==================== + + /** + * 关联告警ID(告警工单必填,手动工单可空) + */ + private String alarmId; + /** + * 告警类型(intrusion=入侵/leave_post=离岗/fire=火灾/fence=越界) + */ + private String alarmType; + /** + * 摄像头ID + */ + private String cameraId; + /** + * ROI区域ID + */ + private String roiId; + /** + * 告警截图URL + */ + private String imageUrl; + + // ==================== 处理人信息(冗余快照) ==================== + + /** + * 处理人user_id + */ + private Long assignedUserId; + /** + * 处理人姓名 + */ + private String assignedUserName; + /** + * 班组ID + */ + private Long assignedTeamId; + + // ==================== 处理结果(完成时提交) ==================== + + /** + * 处理结果描述 + */ + private String result; + /** + * 处理结果图片URL,JSON数组 + */ + private String resultImgUrls; + + // ==================== 关键时间点 ==================== + + /** + * 派单时间 + */ + private LocalDateTime dispatchedTime; + /** + * 确认时间 + */ + private LocalDateTime confirmedTime; + /** + * 完成时间 + */ + private LocalDateTime completedTime; + +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/mysql/area/OpsAreaSecurityUserMapper.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/mysql/area/OpsAreaSecurityUserMapper.java new file mode 100644 index 0000000..fc6ac9b --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/mysql/area/OpsAreaSecurityUserMapper.java @@ -0,0 +1,37 @@ +package com.viewsh.module.ops.security.dal.mysql.area; + +import com.viewsh.framework.mybatis.core.mapper.BaseMapperX; +import com.viewsh.module.ops.security.dal.dataobject.area.OpsAreaSecurityUserDO; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * 区域-安保人员绑定 Mapper + * + * @author lzh + */ +@Mapper +public interface OpsAreaSecurityUserMapper extends BaseMapperX { + + /** + * 查询区域内所有启用的安保人员 + */ + default List selectListByAreaId(Long areaId) { + return selectList(new LambdaQueryWrapper() + .eq(OpsAreaSecurityUserDO::getAreaId, areaId) + .eq(OpsAreaSecurityUserDO::getEnabled, true) + .orderByAsc(OpsAreaSecurityUserDO::getSort)); + } + + /** + * 根据区域ID和用户ID查询(唯一性校验用) + */ + default OpsAreaSecurityUserDO selectByAreaIdAndUserId(Long areaId, Long userId) { + return selectOne(new LambdaQueryWrapper() + .eq(OpsAreaSecurityUserDO::getAreaId, areaId) + .eq(OpsAreaSecurityUserDO::getUserId, userId)); + } + +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/mysql/workorder/OpsOrderSecurityExtMapper.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/mysql/workorder/OpsOrderSecurityExtMapper.java new file mode 100644 index 0000000..8c934a3 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/mysql/workorder/OpsOrderSecurityExtMapper.java @@ -0,0 +1,37 @@ +package com.viewsh.module.ops.security.dal.mysql.workorder; + +import com.viewsh.framework.mybatis.core.mapper.BaseMapperX; +import com.viewsh.module.ops.security.dal.dataobject.workorder.OpsOrderSecurityExtDO; +import org.apache.ibatis.annotations.Mapper; + +/** + * 安保工单扩展 Mapper + * + * @author lzh + */ +@Mapper +public interface OpsOrderSecurityExtMapper extends BaseMapperX { + + /** + * 根据工单ID查询扩展信息 + */ + default OpsOrderSecurityExtDO selectByOpsOrderId(Long opsOrderId) { + return selectOne(OpsOrderSecurityExtDO::getOpsOrderId, opsOrderId); + } + + /** + * 插入或选择性更新扩展信息 + *

    + * 已存在时按 ID 更新,不存在时插入 + */ + default int insertOrUpdateSelective(OpsOrderSecurityExtDO entity) { + OpsOrderSecurityExtDO existing = selectByOpsOrderId(entity.getOpsOrderId()); + if (existing == null) { + return insert(entity); + } else { + entity.setId(existing.getId()); + return updateById(entity); + } + } + +} From 784c2ed387d4465182db23568baff6abf5a7b598 Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 11 Mar 2026 17:33:09 +0800 Subject: [PATCH 08/35] =?UTF-8?q?feat(ops):=20=E6=96=B0=E5=A2=9E=E5=AE=89?= =?UTF-8?q?=E4=BF=9D=E5=B7=A5=E5=8D=95=E6=A0=B8=E5=BF=83=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E4=B8=8E=E6=B4=BE=E5=8D=95=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 包含安保工单 CRUD(创建/确认/完单)、区域人员绑定服务、 区域分配策略 SecurityAreaAssignStrategy、调度策略 SecurityScheduleStrategy,以及安保扩展查询处理器。 Co-Authored-By: Claude Opus 4.6 --- .../area/OpsAreaSecurityUserService.java | 51 +++++ .../area/OpsAreaSecurityUserServiceImpl.java | 94 ++++++++ .../dispatch/SecurityAreaAssignStrategy.java | 76 +++++++ .../dispatch/SecurityScheduleStrategy.java | 59 +++++ .../SecurityOrderCompleteReqDTO.java | 37 ++++ .../SecurityOrderCreateReqDTO.java | 47 ++++ .../SecurityOrderExtQueryHandler.java | 102 +++++++++ .../securityorder/SecurityOrderService.java | 55 +++++ .../SecurityOrderServiceImpl.java | 209 ++++++++++++++++++ 9 files changed, 730 insertions(+) create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/area/OpsAreaSecurityUserService.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/area/OpsAreaSecurityUserServiceImpl.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/dispatch/SecurityAreaAssignStrategy.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/dispatch/SecurityScheduleStrategy.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCompleteReqDTO.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCreateReqDTO.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderExtQueryHandler.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderService.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderServiceImpl.java diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/area/OpsAreaSecurityUserService.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/area/OpsAreaSecurityUserService.java new file mode 100644 index 0000000..39eb00e --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/area/OpsAreaSecurityUserService.java @@ -0,0 +1,51 @@ +package com.viewsh.module.ops.security.service.area; + +import com.viewsh.module.ops.security.dal.dataobject.area.OpsAreaSecurityUserDO; + +import java.util.List; + +/** + * 区域-安保人员绑定服务 + * + * @author lzh + */ +public interface OpsAreaSecurityUserService { + + /** + * 查询区域绑定的启用安保人员 + * + * @param areaId 区域ID + * @return 安保人员列表 + */ + List listByAreaId(Long areaId); + + /** + * 绑定安保人员到区域 + * + * @param areaId 区域ID + * @param userId 用户ID + * @param userName 用户姓名 + * @param teamId 班组ID + * @param sort 排序值 + * @return 绑定记录ID + */ + Long bindUser(Long areaId, Long userId, String userName, Long teamId, Integer sort); + + /** + * 更新绑定信息 + * + * @param id 绑定记录ID + * @param enabled 是否启用 + * @param sort 排序值 + * @param teamId 班组ID + */ + void updateBinding(Long id, Boolean enabled, Integer sort, Long teamId); + + /** + * 解除绑定 + * + * @param id 绑定记录ID + */ + void unbindUser(Long id); + +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/area/OpsAreaSecurityUserServiceImpl.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/area/OpsAreaSecurityUserServiceImpl.java new file mode 100644 index 0000000..137bb44 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/area/OpsAreaSecurityUserServiceImpl.java @@ -0,0 +1,94 @@ +package com.viewsh.module.ops.security.service.area; + +import com.viewsh.module.ops.security.dal.dataobject.area.OpsAreaSecurityUserDO; +import com.viewsh.module.ops.security.dal.mysql.area.OpsAreaSecurityUserMapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception; +import static com.viewsh.module.ops.enums.ErrorCodeConstants.*; + +/** + * 区域-安保人员绑定服务实现 + * + * @author lzh + */ +@Slf4j +@Service +public class OpsAreaSecurityUserServiceImpl implements OpsAreaSecurityUserService { + + @Resource + private OpsAreaSecurityUserMapper areaSecurityUserMapper; + + @Override + public List listByAreaId(Long areaId) { + return areaSecurityUserMapper.selectListByAreaId(areaId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long bindUser(Long areaId, Long userId, String userName, Long teamId, Integer sort) { + // 唯一性校验 + OpsAreaSecurityUserDO existing = areaSecurityUserMapper.selectByAreaIdAndUserId(areaId, userId); + if (existing != null) { + throw exception(SECURITY_AREA_USER_DUPLICATE); + } + + OpsAreaSecurityUserDO record = OpsAreaSecurityUserDO.builder() + .areaId(areaId) + .userId(userId) + .userName(userName) + .teamId(teamId) + .enabled(true) + .sort(sort != null ? sort : 0) + .build(); + try { + areaSecurityUserMapper.insert(record); + } catch (DuplicateKeyException e) { + throw exception(SECURITY_AREA_USER_DUPLICATE); + } + + log.info("绑定安保人员到区域: areaId={}, userId={}, userName={}", areaId, userId, userName); + return record.getId(); + } + + @Override + public void updateBinding(Long id, Boolean enabled, Integer sort, Long teamId) { + OpsAreaSecurityUserDO existing = areaSecurityUserMapper.selectById(id); + if (existing == null) { + throw exception(SECURITY_AREA_USER_NOT_FOUND); + } + + OpsAreaSecurityUserDO update = new OpsAreaSecurityUserDO(); + update.setId(id); + if (enabled != null) { + update.setEnabled(enabled); + } + if (sort != null) { + update.setSort(sort); + } + if (teamId != null) { + update.setTeamId(teamId); + } + areaSecurityUserMapper.updateById(update); + + log.info("更新安保人员绑定: id={}, enabled={}, sort={}", id, enabled, sort); + } + + @Override + public void unbindUser(Long id) { + OpsAreaSecurityUserDO existing = areaSecurityUserMapper.selectById(id); + if (existing == null) { + throw exception(SECURITY_AREA_USER_NOT_FOUND); + } + + areaSecurityUserMapper.deleteById(id); + log.info("解除安保人员绑定: id={}, areaId={}, userId={}", id, existing.getAreaId(), existing.getUserId()); + } + +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/dispatch/SecurityAreaAssignStrategy.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/dispatch/SecurityAreaAssignStrategy.java new file mode 100644 index 0000000..f007585 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/dispatch/SecurityAreaAssignStrategy.java @@ -0,0 +1,76 @@ +package com.viewsh.module.ops.security.service.dispatch; + +import cn.hutool.core.collection.CollUtil; +import com.viewsh.module.ops.core.dispatch.DispatchEngine; +import com.viewsh.module.ops.core.dispatch.model.AssigneeRecommendation; +import com.viewsh.module.ops.core.dispatch.model.OrderDispatchContext; +import com.viewsh.module.ops.core.dispatch.strategy.AssignStrategy; +import com.viewsh.module.ops.enums.WorkOrderTypeEnum; +import com.viewsh.module.ops.security.dal.dataobject.area.OpsAreaSecurityUserDO; +import com.viewsh.module.ops.security.dal.mysql.area.OpsAreaSecurityUserMapper; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 安保工单区域分配策略 + *

    + * 根据区域绑定的安保人员随机分配。 + * + * @author lzh + */ +@Slf4j +@Component +public class SecurityAreaAssignStrategy implements AssignStrategy { + + private static final String STRATEGY_NAME = "security_area_user"; + private static final String BUSINESS_TYPE = WorkOrderTypeEnum.SECURITY.getType(); + + @Resource + private DispatchEngine dispatchEngine; + + @Resource + private OpsAreaSecurityUserMapper areaSecurityUserMapper; + + @PostConstruct + public void init() { + dispatchEngine.registerAssignStrategy(BUSINESS_TYPE, this); + log.info("注册安保分配策略: {}", STRATEGY_NAME); + } + + @Override + public String getName() { + return STRATEGY_NAME; + } + + @Override + public String getSupportedBusinessType() { + return BUSINESS_TYPE; + } + + @Override + public AssigneeRecommendation recommend(OrderDispatchContext context) { + Long areaId = context.getAreaId(); + if (areaId == null) { + log.warn("安保派单缺少区域ID: orderId={}", context.getOrderId()); + return AssigneeRecommendation.none(); + } + + List users = areaSecurityUserMapper.selectListByAreaId(areaId); + if (CollUtil.isEmpty(users)) { + log.info("区域 {} 无绑定安保人员,工单 {} 等待手动分配", areaId, context.getOrderId()); + return AssigneeRecommendation.none(); + } + + // 选择 sort 值最小的人员(sort 越小优先级越高,由 Mapper 已按 sort ASC 排序) + OpsAreaSecurityUserDO chosen = users.get(0); + AssigneeRecommendation recommendation = AssigneeRecommendation.of( + chosen.getUserId(), chosen.getUserName(), 50, "区域排序优先分配"); + recommendation.setAreaId(areaId); + return recommendation; + } + +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/dispatch/SecurityScheduleStrategy.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/dispatch/SecurityScheduleStrategy.java new file mode 100644 index 0000000..99a0f56 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/dispatch/SecurityScheduleStrategy.java @@ -0,0 +1,59 @@ +package com.viewsh.module.ops.security.service.dispatch; + +import com.viewsh.module.ops.core.dispatch.DispatchEngine; +import com.viewsh.module.ops.core.dispatch.model.DispatchDecision; +import com.viewsh.module.ops.core.dispatch.model.DispatchPath; +import com.viewsh.module.ops.core.dispatch.model.OrderDispatchContext; +import com.viewsh.module.ops.core.dispatch.strategy.ScheduleStrategy; +import com.viewsh.module.ops.enums.WorkOrderTypeEnum; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 安保工单调度策略 + *

    + * 安保工单调度相对简单: + * - 有可用人员 → 直接派单 + * - 人员忙碌 → 入队等待 + * + * @author lzh + */ +@Slf4j +@Component +public class SecurityScheduleStrategy implements ScheduleStrategy { + + private static final String STRATEGY_NAME = "security_schedule"; + private static final String BUSINESS_TYPE = WorkOrderTypeEnum.SECURITY.getType(); + + @Resource + private DispatchEngine dispatchEngine; + + @PostConstruct + public void init() { + dispatchEngine.registerScheduleStrategy(BUSINESS_TYPE, this); + log.info("注册安保调度策略: {}", STRATEGY_NAME); + } + + @Override + public String getName() { + return STRATEGY_NAME; + } + + @Override + public String getSupportedBusinessType() { + return BUSINESS_TYPE; + } + + @Override + public DispatchDecision decide(OrderDispatchContext context) { + // 安保工单默认直接派单 + // DispatchEngine 会根据执行人状态自动选择路径 + return DispatchDecision.builder() + .path(DispatchPath.DIRECT_DISPATCH) + .reason("安保工单直接派单") + .build(); + } + +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCompleteReqDTO.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCompleteReqDTO.java new file mode 100644 index 0000000..ba1a094 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCompleteReqDTO.java @@ -0,0 +1,37 @@ +package com.viewsh.module.ops.security.service.securityorder; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 安保工单人工完单请求 DTO(Service 层内部使用) + * + * @author lzh + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SecurityOrderCompleteReqDTO { + + @NotNull(message = "工单ID不能为空") + private Long orderId; + + @NotBlank(message = "处理结果不能为空") + private String result; + + private List resultImgUrls; + + /** + * 操作人ID(由 Controller 层填充) + */ + @NotNull(message = "操作人ID不能为空") + private Long operatorId; + +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCreateReqDTO.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCreateReqDTO.java new file mode 100644 index 0000000..56d90ed --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCreateReqDTO.java @@ -0,0 +1,47 @@ +package com.viewsh.module.ops.security.service.securityorder; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 安保工单创建请求 DTO(Service 层内部使用) + * + * @author lzh + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SecurityOrderCreateReqDTO { + + @NotBlank(message = "工单标题不能为空") + private String title; + + private String description; + + private Integer priority; + + @NotNull(message = "区域ID不能为空") + private Long areaId; + + private String location; + + // ==================== 告警来源 ==================== + + private String alarmId; + + private String alarmType; + + private String cameraId; + + private String roiId; + + private String imageUrl; + + private String sourceType; + +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderExtQueryHandler.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderExtQueryHandler.java new file mode 100644 index 0000000..84137af --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderExtQueryHandler.java @@ -0,0 +1,102 @@ +package com.viewsh.module.ops.security.service.securityorder; + +import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO; +import com.viewsh.module.ops.security.dal.dataobject.workorder.OpsOrderSecurityExtDO; +import com.viewsh.module.ops.security.dal.mysql.workorder.OpsOrderSecurityExtMapper; +import com.viewsh.module.ops.service.OrderDetailVO; +import com.viewsh.module.ops.service.OrderExtQueryHandler; +import com.viewsh.module.ops.service.OrderSummaryVO; +import com.viewsh.module.ops.enums.WorkOrderTypeEnum; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 安保工单扩展查询处理器 + *

    + * 实现 OrderExtQueryHandler 接口,为工单中心查询提供安保扩展信息加载能力 + * + * @author lzh + */ +@Slf4j +@Component +public class SecurityOrderExtQueryHandler implements OrderExtQueryHandler { + + @Resource + private OpsOrderSecurityExtMapper securityExtMapper; + + private static final String ORDER_TYPE_SECURITY = WorkOrderTypeEnum.SECURITY.getType(); + + @Override + public boolean supports(String orderType) { + return ORDER_TYPE_SECURITY.equals(orderType); + } + + @Override + public void enrichWithExtInfo(OrderSummaryVO vo, Long orderId) { + OpsOrderSecurityExtDO securityExt = securityExtMapper.selectByOpsOrderId(orderId); + if (securityExt != null) { + vo.setExtInfo(buildExtInfoMap(securityExt)); + } + } + + @Override + public OrderDetailVO buildDetailVO(OpsOrderDO order) { + OpsOrderSecurityExtDO securityExt = securityExtMapper.selectByOpsOrderId(order.getId()); + + OrderDetailVO vo = OrderDetailVO.builder() + .id(order.getId()) + .orderCode(order.getOrderCode()) + .orderType(order.getOrderType()) + .sourceType(order.getSourceType()) + .title(order.getTitle()) + .description(order.getDescription()) + .priority(order.getPriority()) + .status(order.getStatus()) + .areaId(order.getAreaId()) + .location(order.getLocation()) + .urgentReason(order.getUrgentReason()) + .assigneeId(order.getAssigneeId()) + .assigneeName(order.getAssigneeName()) + .inspectorId(order.getInspectorId()) + .startTime(order.getStartTime()) + .endTime(order.getEndTime()) + .qualityScore(order.getQualityScore()) + .qualityComment(order.getQualityComment()) + .responseSeconds(order.getResponseSeconds()) + .completionSeconds(order.getCompletionSeconds()) + .createTime(order.getCreateTime()) + .updateTime(order.getUpdateTime()) + .build(); + + if (securityExt != null) { + vo.setExtInfo(buildExtInfoMap(securityExt)); + } + + return vo; + } + + /** + * 构建安保工单扩展信息 Map(列表/详情统一使用) + */ + private Map buildExtInfoMap(OpsOrderSecurityExtDO ext) { + Map extInfo = new LinkedHashMap<>(16); + extInfo.put("alarmId", ext.getAlarmId()); + extInfo.put("alarmType", ext.getAlarmType()); + extInfo.put("cameraId", ext.getCameraId()); + extInfo.put("roiId", ext.getRoiId()); + extInfo.put("imageUrl", ext.getImageUrl()); + extInfo.put("assignedUserId", ext.getAssignedUserId()); + extInfo.put("assignedUserName", ext.getAssignedUserName()); + extInfo.put("assignedTeamId", ext.getAssignedTeamId()); + extInfo.put("result", ext.getResult()); + extInfo.put("resultImgUrls", ext.getResultImgUrls()); + extInfo.put("dispatchedTime", ext.getDispatchedTime()); + extInfo.put("confirmedTime", ext.getConfirmedTime()); + extInfo.put("completedTime", ext.getCompletedTime()); + return extInfo; + } +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderService.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderService.java new file mode 100644 index 0000000..245d602 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderService.java @@ -0,0 +1,55 @@ +package com.viewsh.module.ops.security.service.securityorder; + +import com.viewsh.module.ops.security.dal.dataobject.workorder.OpsOrderSecurityExtDO; + +/** + * 安保工单服务接口 + *

    + * 提供安保工单的创建、确认、自动完单、人工完单等能力 + * + * @author lzh + */ +public interface SecurityOrderService { + + /** + * 创建安保工单(对外接口,由外部告警系统调用) + *

    + * 流程:创建主表 + 扩展表 → 自动派单 + * + * @param createReq 创建请求 + * @return 工单ID + */ + Long createSecurityOrder(SecurityOrderCreateReqDTO createReq); + + /** + * 确认工单(安保人员确认接单) + * + * @param orderId 工单ID + * @param userId 安保人员user_id + */ + void confirmOrder(Long orderId, Long userId); + + /** + * 自动完单(对方系统调用,无需提交结果) + * + * @param orderId 工单ID + * @param remark 备注 + */ + void autoCompleteOrder(Long orderId, String remark); + + /** + * 人工完单(安保人员提交处理结果) + * + * @param req 完单请求(包含 result + resultImgUrls) + */ + void manualCompleteOrder(SecurityOrderCompleteReqDTO req); + + /** + * 根据工单ID查询安保扩展信息 + * + * @param opsOrderId 工单ID + * @return 扩展信息 + */ + OpsOrderSecurityExtDO getSecurityExt(Long opsOrderId); + +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderServiceImpl.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderServiceImpl.java new file mode 100644 index 0000000..206a44c --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderServiceImpl.java @@ -0,0 +1,209 @@ +package com.viewsh.module.ops.security.service.securityorder; + +import cn.hutool.core.util.StrUtil; +import com.viewsh.module.ops.core.event.OrderEventPublisher; +import com.viewsh.module.ops.core.event.OrderCreatedEvent; +import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO; +import com.viewsh.module.ops.dal.mysql.area.OpsBusAreaMapper; +import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper; +import com.viewsh.module.ops.enums.OperatorTypeEnum; +import com.viewsh.module.ops.enums.PriorityEnum; +import com.viewsh.module.ops.enums.SourceTypeEnum; +import com.viewsh.module.ops.enums.WorkOrderStatusEnum; +import com.viewsh.module.ops.enums.WorkOrderTypeEnum; +import com.viewsh.module.ops.infrastructure.code.OrderCodeGenerator; +import com.viewsh.module.ops.infrastructure.log.enumeration.EventDomain; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogModule; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogType; +import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecord; +import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecorder; +// 注意:confirm/complete 的业务日志由 SecurityOrderEventListener 统一记录 +// 本类仅记录 CREATE 日志(创建不经过状态变更事件) +import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception; +import static com.viewsh.module.ops.enums.ErrorCodeConstants.*; +import com.viewsh.module.ops.infrastructure.id.OrderIdGenerator; +import com.viewsh.module.ops.security.dal.dataobject.workorder.OpsOrderSecurityExtDO; +import com.viewsh.module.ops.security.dal.mysql.workorder.OpsOrderSecurityExtMapper; +import com.viewsh.module.ops.service.fsm.OrderStateMachine; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 安保工单服务实现 + * + * @author lzh + */ +@Slf4j +@Service +public class SecurityOrderServiceImpl implements SecurityOrderService { + + @Resource + private OpsOrderMapper opsOrderMapper; + + @Resource + private OpsOrderSecurityExtMapper securityExtMapper; + + @Resource + private OrderIdGenerator orderIdGenerator; + + @Resource + private OrderCodeGenerator orderCodeGenerator; + + @Resource + private OrderEventPublisher orderEventPublisher; + + @Resource + private OpsBusAreaMapper opsBusAreaMapper; + + @Resource + private OrderStateMachine orderStateMachine; + + @Resource + private EventLogRecorder eventLogRecorder; + + private static final String BUSINESS_TYPE = WorkOrderTypeEnum.SECURITY.getType(); + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createSecurityOrder(SecurityOrderCreateReqDTO createReq) { + // 0. 校验区域是否存在 + if (opsBusAreaMapper.selectById(createReq.getAreaId()) == null) { + throw exception(AREA_NOT_FOUND); + } + + // 1. 生成ID和编号 + Long orderId = orderIdGenerator.generate(); + String orderCode = orderCodeGenerator.generate(BUSINESS_TYPE); + + // 2. 确定来源类型 + String sourceType = StrUtil.isNotBlank(createReq.getSourceType()) + ? createReq.getSourceType() + : (StrUtil.isNotBlank(createReq.getAlarmId()) ? SourceTypeEnum.ALARM.getType() : SourceTypeEnum.MANUAL.getType()); + + // 3. 构建主表记录 + OpsOrderDO order = OpsOrderDO.builder() + .id(orderId) + .orderCode(orderCode) + .orderType(BUSINESS_TYPE) + .sourceType(sourceType) + .title(createReq.getTitle()) + .description(createReq.getDescription()) + .priority(createReq.getPriority() != null ? createReq.getPriority() : PriorityEnum.P2.getPriority()) + .status(WorkOrderStatusEnum.PENDING.getStatus()) + .areaId(createReq.getAreaId()) + .location(createReq.getLocation()) + .build(); + opsOrderMapper.insert(order); + + // 4. 构建扩展表记录 + OpsOrderSecurityExtDO securityExt = OpsOrderSecurityExtDO.builder() + .opsOrderId(orderId) + .alarmId(createReq.getAlarmId()) + .alarmType(createReq.getAlarmType()) + .cameraId(createReq.getCameraId()) + .roiId(createReq.getRoiId()) + .imageUrl(createReq.getImageUrl()) + .build(); + securityExtMapper.insert(securityExt); + + // 5. 发布工单创建事件(触发自动派单) + OrderCreatedEvent event = OrderCreatedEvent.builder() + .orderId(orderId) + .orderType(BUSINESS_TYPE) + .orderCode(orderCode) + .title(createReq.getTitle()) + .areaId(createReq.getAreaId()) + .priority(order.getPriority()) + .createTime(order.getCreateTime()) + .build(); + orderEventPublisher.publishOrderCreated(event); + + // 6. 记录业务日志 + eventLogRecorder.record(EventLogRecord.builder() + .module(LogModule.SECURITY) + .domain(EventDomain.DISPATCH) + .eventType(LogType.ORDER_CREATED.getCode()) + .message("安保工单创建") + .targetId(orderId) + .targetType("order") + .build()); + + log.info("创建安保工单成功: orderId={}, orderCode={}, alarmId={}, areaId={}", + orderId, orderCode, createReq.getAlarmId(), createReq.getAreaId()); + + return orderId; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void confirmOrder(Long orderId, Long userId) { + OpsOrderDO order = getOrderOrThrow(orderId); + validateOrderType(order); + + // 状态转换:DISPATCHED → CONFIRMED(扩展表时间 + 业务日志由 EventListener 统一记录) + orderStateMachine.transition(order, WorkOrderStatusEnum.CONFIRMED, + OperatorTypeEnum.SECURITY_GUARD, userId, "安保人员确认接单"); + + log.info("安保工单确认: orderId={}, userId={}", orderId, userId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void autoCompleteOrder(Long orderId, String remark) { + OpsOrderDO order = getOrderOrThrow(orderId); + validateOrderType(order); + + // 状态转换 → COMPLETED(扩展表时间 + 业务日志由 EventListener 统一记录,主表 endTime 由状态机统一设置) + orderStateMachine.transition(order, WorkOrderStatusEnum.COMPLETED, + OperatorTypeEnum.SYSTEM, null, + StrUtil.isNotBlank(remark) ? remark : "系统自动完单"); + + log.info("安保工单自动完单: orderId={}", orderId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void manualCompleteOrder(SecurityOrderCompleteReqDTO req) { + OpsOrderDO order = getOrderOrThrow(req.getOrderId()); + validateOrderType(order); + + // 状态转换 → COMPLETED(扩展表 completedTime + 业务日志由 EventListener 统一记录,主表 endTime 由状态机统一设置) + orderStateMachine.transition(order, WorkOrderStatusEnum.COMPLETED, + OperatorTypeEnum.SECURITY_GUARD, req.getOperatorId(), "安保人员提交处理结果"); + + // 更新扩展表:结果 + 图片 + OpsOrderSecurityExtDO extUpdate = new OpsOrderSecurityExtDO(); + extUpdate.setOpsOrderId(req.getOrderId()); + extUpdate.setResult(req.getResult()); + if (req.getResultImgUrls() != null && !req.getResultImgUrls().isEmpty()) { + extUpdate.setResultImgUrls(cn.hutool.json.JSONUtil.toJsonStr(req.getResultImgUrls())); + } + securityExtMapper.insertOrUpdateSelective(extUpdate); + + log.info("安保工单人工完单: orderId={}", req.getOrderId()); + } + + @Override + public OpsOrderSecurityExtDO getSecurityExt(Long opsOrderId) { + return securityExtMapper.selectByOpsOrderId(opsOrderId); + } + + // ==================== 内部方法 ==================== + + private OpsOrderDO getOrderOrThrow(Long orderId) { + OpsOrderDO order = opsOrderMapper.selectById(orderId); + if (order == null) { + throw exception(SECURITY_ORDER_NOT_FOUND); + } + return order; + } + + private void validateOrderType(OpsOrderDO order) { + if (!BUSINESS_TYPE.equals(order.getOrderType())) { + throw exception(SECURITY_ORDER_TYPE_MISMATCH); + } + } + +} From 4d36bf5b1cd13173bbda5b8cf53f22668e20448d Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 11 Mar 2026 17:33:18 +0800 Subject: [PATCH 09/35] =?UTF-8?q?feat(ops):=20=E6=96=B0=E5=A2=9E=E5=AE=89?= =?UTF-8?q?=E4=BF=9D=E5=B7=A5=E5=8D=95=E4=BA=8B=E4=BB=B6=E7=9B=91=E5=90=AC?= =?UTF-8?q?=E5=99=A8=EF=BC=8C=E8=A6=86=E7=9B=96=E5=85=A8=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 监听工单创建后自动派单、状态变更记录扩展表时间点(派发/确认/ 完成),统一记录业务日志,区分系统自动完单与人工完单。 Co-Authored-By: Claude Opus 4.6 --- .../listener/SecurityOrderEventListener.java | 256 ++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListener.java diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListener.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListener.java new file mode 100644 index 0000000..602adc5 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListener.java @@ -0,0 +1,256 @@ +package com.viewsh.module.ops.security.integration.listener; + +import com.viewsh.module.ops.core.event.OrderCreatedEvent; +import com.viewsh.module.ops.core.event.OrderStateChangedEvent; +import com.viewsh.module.ops.core.event.OrderCompletedEvent; +import com.viewsh.module.ops.core.dispatch.DispatchEngine; +import com.viewsh.module.ops.core.dispatch.model.DispatchResult; +import com.viewsh.module.ops.core.dispatch.model.OrderDispatchContext; +import com.viewsh.module.ops.enums.OperatorTypeEnum; +import com.viewsh.module.ops.enums.PriorityEnum; +import com.viewsh.module.ops.enums.WorkOrderStatusEnum; +import com.viewsh.module.ops.enums.WorkOrderTypeEnum; +import com.viewsh.module.ops.infrastructure.log.enumeration.EventDomain; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogModule; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogType; +import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecord; +import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecorder; +import com.viewsh.module.ops.security.dal.dataobject.workorder.OpsOrderSecurityExtDO; +import com.viewsh.module.ops.security.dal.mysql.workorder.OpsOrderSecurityExtMapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.time.LocalDateTime; + +/** + * 安保工单事件监听器 + *

    + * 监听工单生命周期事件,处理安保业务特有逻辑: + * - 工单创建后触发自动派单 + * - 状态变更时记录扩展表时间点 + * - 统一记录业务日志(所有状态变更) + * + * @author lzh + */ +@Slf4j +@Component +public class SecurityOrderEventListener { + + private static final String ORDER_TYPE_SECURITY = WorkOrderTypeEnum.SECURITY.getType(); + + @Resource + private OpsOrderSecurityExtMapper securityExtMapper; + + @Resource + private DispatchEngine dispatchEngine; + + @Resource + private EventLogRecorder eventLogRecorder; + + // ==================== 工单创建事件 ==================== + + /** + * 工单创建事件 - 异步触发自动派单 + *

    + * {@code @Async} + {@code @TransactionalEventListener(AFTER_COMMIT)} 组合: + * Spring 先等事务提交,再在异步线程池中执行本方法。 + */ + @Async("ops-task-executor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onOrderCreated(OrderCreatedEvent event) { + if (!ORDER_TYPE_SECURITY.equals(event.getOrderType())) { + return; + } + log.info("安保工单创建事件: orderId={}, orderCode={}", event.getOrderId(), event.getOrderCode()); + + try { + OrderDispatchContext context = OrderDispatchContext.builder() + .orderId(event.getOrderId()) + .orderCode(event.getOrderCode()) + .orderTitle(event.getTitle()) + .businessType(ORDER_TYPE_SECURITY) + .areaId(event.getAreaId()) + .priority(PriorityEnum.fromPriority(event.getPriority())) + .build(); + + DispatchResult result = dispatchEngine.dispatch(context); + + if (result.isSuccess()) { + log.info("安保工单自动派单完成: orderId={}, assigneeId={}", event.getOrderId(), result.getAssigneeId()); + // 记录派单成功日志 + recordLog(EventDomain.DISPATCH, LogType.ORDER_DISPATCHED, + "自动派单成功,分配给: " + result.getAssigneeName(), + event.getOrderId(), result.getAssigneeId()); + } else { + log.warn("安保工单自动派单失败: orderId={}, reason={}", event.getOrderId(), result.getMessage()); + // 记录派单失败日志 + recordLog(EventDomain.DISPATCH, LogType.ORDER_DISPATCHED, + "自动派单失败: " + result.getMessage(), + event.getOrderId(), null); + } + } catch (Exception e) { + log.error("安保工单自动派单失败: orderId={}", event.getOrderId(), e); + recordLog(EventDomain.DISPATCH, LogType.ORDER_DISPATCHED, + "自动派单异常: " + e.getMessage(), + event.getOrderId(), null); + } + } + + // ==================== 状态变更事件 ==================== + + /** + * 状态变更事件 - 记录扩展表时间点 + 业务日志 + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onOrderStateChanged(OrderStateChangedEvent event) { + if (!ORDER_TYPE_SECURITY.equals(event.getOrderType())) { + return; + } + + WorkOrderStatusEnum newStatus = event.getNewStatus(); + Long orderId = event.getOrderId(); + + log.info("安保工单状态变更: orderId={}, {} -> {}", orderId, event.getOldStatus(), newStatus); + + switch (newStatus) { + case DISPATCHED -> handleDispatched(orderId, event); + case CONFIRMED -> handleConfirmed(orderId, event); + case COMPLETED -> handleCompleted(orderId, event); + case CANCELLED -> handleCancelled(orderId, event); + case PAUSED -> handlePaused(orderId, event); + default -> log.debug("安保工单状态变更无需额外处理: orderId={}, status={}", orderId, newStatus); + } + } + + /** + * 工单完成事件 - 自动派送下一个任务 + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onOrderCompleted(OrderCompletedEvent event) { + if (!ORDER_TYPE_SECURITY.equals(event.getOrderType())) { + return; + } + if (event.getAssigneeId() != null) { + try { + dispatchEngine.autoDispatchNext(event.getOrderId(), event.getAssigneeId()); + } catch (Exception e) { + log.error("安保工单完成后自动派送下一个失败: orderId={}", event.getOrderId(), e); + } + } + } + + // ==================== 状态处理方法 ==================== + + private void handleDispatched(Long orderId, OrderStateChangedEvent event) { + // 1. 记录下发时间 + OpsOrderSecurityExtDO extUpdate = new OpsOrderSecurityExtDO(); + extUpdate.setOpsOrderId(orderId); + extUpdate.setDispatchedTime(LocalDateTime.now()); + securityExtMapper.insertOrUpdateSelective(extUpdate); + + // 2. 业务日志 + Long assigneeId = event.getPayloadLong("assigneeId"); + String assigneeName = (String) event.getPayload().get("assigneeName"); + String message = assigneeName != null + ? String.format("工单已派发给 %s", assigneeName) + : "工单已派发"; + + // 如果是从 PAUSED 恢复,补充说明 + if (event.getOldStatus() == WorkOrderStatusEnum.PAUSED) { + message = "工单从暂停恢复,重新派发"; + } + + recordLog(EventDomain.DISPATCH, LogType.ORDER_DISPATCHED, message, orderId, assigneeId); + } + + private void handleConfirmed(Long orderId, OrderStateChangedEvent event) { + // 1. 记录确认时间 + OpsOrderSecurityExtDO extUpdate = new OpsOrderSecurityExtDO(); + extUpdate.setOpsOrderId(orderId); + extUpdate.setConfirmedTime(LocalDateTime.now()); + securityExtMapper.insertOrUpdateSelective(extUpdate); + + // 2. 业务日志 + Long operatorId = event.getOperatorId(); + recordLog(EventDomain.DISPATCH, LogType.ORDER_CONFIRM, "安保人员确认接单", orderId, operatorId); + } + + private void handleCompleted(Long orderId, OrderStateChangedEvent event) { + // 1. 记录完成时间 + OpsOrderSecurityExtDO extUpdate = new OpsOrderSecurityExtDO(); + extUpdate.setOpsOrderId(orderId); + extUpdate.setCompletedTime(LocalDateTime.now()); + securityExtMapper.insertOrUpdateSelective(extUpdate); + + // 2. 业务日志(区分自动完单 vs 人工完单) + Long operatorId = event.getOperatorId(); + OperatorTypeEnum operatorType = event.getOperatorType(); + String remark = event.getRemark(); + + String message; + if (operatorType == OperatorTypeEnum.SYSTEM || operatorId == null) { + message = "系统自动完单"; + if (remark != null && !remark.isEmpty()) { + message += "(" + remark + ")"; + } + } else { + message = "安保人员提交处理结果"; + } + + recordLog(EventDomain.DISPATCH, LogType.ORDER_COMPLETED, message, orderId, operatorId); + } + + private void handleCancelled(Long orderId, OrderStateChangedEvent event) { + Long operatorId = event.getOperatorId(); + String remark = event.getRemark(); + String message = "安保工单已取消"; + if (remark != null && !remark.isEmpty()) { + message += "(" + remark + ")"; + } + + recordLog(EventDomain.DISPATCH, LogType.ORDER_CANCELLED, message, orderId, operatorId); + } + + private void handlePaused(Long orderId, OrderStateChangedEvent event) { + Long operatorId = event.getOperatorId(); + String remark = event.getRemark(); + String message = "安保工单已暂停"; + if (remark != null && !remark.isEmpty()) { + message += "(" + remark + ")"; + } + + recordLog(EventDomain.DISPATCH, LogType.ORDER_PAUSED, message, orderId, operatorId); + } + + // ==================== 日志辅助方法 ==================== + + /** + * 统一记录安保业务日志 + */ + private void recordLog(EventDomain domain, LogType logType, String message, + Long orderId, Long personId) { + try { + EventLogRecord.EventLogRecordBuilder builder = EventLogRecord.builder() + .module(LogModule.SECURITY) + .domain(domain) + .eventType(logType.getCode()) + .message(message) + .targetId(orderId) + .targetType("order"); + + if (personId != null) { + builder.personId(personId); + } + + eventLogRecorder.record(builder.build()); + } catch (Exception e) { + log.warn("[SecurityOrderEventListener] 记录业务日志失败: orderId={}, eventType={}", + orderId, logType.getCode(), e); + } + } + +} From 0f2fb3c50e105534c9107d96c705c9c375cbd276 Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 11 Mar 2026 17:33:28 +0800 Subject: [PATCH 10/35] =?UTF-8?q?test(ops):=20=E6=96=B0=E5=A2=9E=E5=AE=89?= =?UTF-8?q?=E4=BF=9D=E6=A8=A1=E5=9D=97=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖 SecurityOrderService、SecurityOrderEventListener、 SecurityAreaAssignStrategy、SecurityOrderExtQueryHandler、 OpsAreaSecurityUserService 的核心逻辑测试。 Co-Authored-By: Claude Opus 4.6 --- .../SecurityOrderEventListenerTest.java | 277 +++++++++++ .../area/OpsAreaSecurityUserServiceTest.java | 122 +++++ .../SecurityAreaAssignStrategyTest.java | 108 +++++ .../SecurityOrderExtQueryHandlerTest.java | 190 ++++++++ .../SecurityOrderServiceTest.java | 437 ++++++++++++++++++ 5 files changed, 1134 insertions(+) create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListenerTest.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/area/OpsAreaSecurityUserServiceTest.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/dispatch/SecurityAreaAssignStrategyTest.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderExtQueryHandlerTest.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderServiceTest.java diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListenerTest.java b/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListenerTest.java new file mode 100644 index 0000000..7b16739 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListenerTest.java @@ -0,0 +1,277 @@ +package com.viewsh.module.ops.security.integration.listener; + +import com.viewsh.module.ops.core.dispatch.DispatchEngine; +import com.viewsh.module.ops.core.dispatch.model.OrderDispatchContext; +import com.viewsh.module.ops.core.event.OrderCompletedEvent; +import com.viewsh.module.ops.core.event.OrderCreatedEvent; +import com.viewsh.module.ops.core.event.OrderStateChangedEvent; +import com.viewsh.module.ops.enums.PriorityEnum; +import com.viewsh.module.ops.enums.WorkOrderStatusEnum; +import com.viewsh.module.ops.security.dal.dataobject.workorder.OpsOrderSecurityExtDO; +import com.viewsh.module.ops.security.dal.mysql.workorder.OpsOrderSecurityExtMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 安保工单事件监听器测试 + * + * @author lzh + */ +@ExtendWith(MockitoExtension.class) +public class SecurityOrderEventListenerTest { + + @InjectMocks + private SecurityOrderEventListener listener; + + @Mock + private OpsOrderSecurityExtMapper securityExtMapper; + + @Mock + private DispatchEngine dispatchEngine; + + private static final Long TEST_ORDER_ID = 10001L; + private static final String TEST_ORDER_CODE = "SECURITY-20260310-0001"; + + // ==================== onOrderCreated 测试 ==================== + + @Test + void testOnOrderCreated_SecurityType_TriggersDispatch() { + OrderCreatedEvent event = OrderCreatedEvent.builder() + .orderId(TEST_ORDER_ID) + .orderType("SECURITY") + .orderCode(TEST_ORDER_CODE) + .title("入侵告警") + .areaId(100L) + .priority(PriorityEnum.P1.getPriority()) + .build(); + + // 执行 + listener.onOrderCreated(event); + + // 验证触发了派单(asyncDispatchAfterCreated 是自调用,实际同步执行) + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(OrderDispatchContext.class); + verify(dispatchEngine).dispatch(contextCaptor.capture()); + OrderDispatchContext ctx = contextCaptor.getValue(); + assertEquals(TEST_ORDER_ID, ctx.getOrderId()); + assertEquals(TEST_ORDER_CODE, ctx.getOrderCode()); + assertEquals("入侵告警", ctx.getOrderTitle()); + assertEquals("SECURITY", ctx.getBusinessType()); + assertEquals(100L, ctx.getAreaId()); + assertEquals(PriorityEnum.P1, ctx.getPriority()); + } + + @Test + void testOnOrderCreated_CleanType_Ignored() { + OrderCreatedEvent event = OrderCreatedEvent.builder() + .orderId(TEST_ORDER_ID) + .orderType("CLEAN") + .build(); + + // 执行 + listener.onOrderCreated(event); + + // 验证不触发派单 + verify(dispatchEngine, never()).dispatch(any()); + } + + @Test + void testOnOrderCreated_DispatchException_Caught() { + OrderCreatedEvent event = OrderCreatedEvent.builder() + .orderId(TEST_ORDER_ID) + .orderType("SECURITY") + .orderCode(TEST_ORDER_CODE) + .areaId(100L) + .priority(PriorityEnum.P1.getPriority()) + .build(); + + when(dispatchEngine.dispatch(any())).thenThrow(new RuntimeException("调度引擎异常")); + + // 执行:不应抛出异常 + assertDoesNotThrow(() -> listener.onOrderCreated(event)); + } + + // ==================== onOrderStateChanged 测试 ==================== + + @Test + void testOnOrderStateChanged_Dispatched_RecordsTime() { + OrderStateChangedEvent event = buildStateChangedEvent( + WorkOrderStatusEnum.PENDING, WorkOrderStatusEnum.DISPATCHED); + + lenient().when(securityExtMapper.insertOrUpdateSelective(any())).thenReturn(1); + + // 执行 + listener.onOrderStateChanged(event); + + // 验证扩展表写入 dispatchedTime + ArgumentCaptor captor = ArgumentCaptor.forClass(OpsOrderSecurityExtDO.class); + verify(securityExtMapper).insertOrUpdateSelective(captor.capture()); + OpsOrderSecurityExtDO ext = captor.getValue(); + assertEquals(TEST_ORDER_ID, ext.getOpsOrderId()); + assertNotNull(ext.getDispatchedTime()); + } + + @Test + void testOnOrderStateChanged_Confirmed_RecordsTime() { + OrderStateChangedEvent event = buildStateChangedEvent( + WorkOrderStatusEnum.DISPATCHED, WorkOrderStatusEnum.CONFIRMED); + + lenient().when(securityExtMapper.insertOrUpdateSelective(any())).thenReturn(1); + + // 执行 + listener.onOrderStateChanged(event); + + // 验证扩展表写入 confirmedTime + ArgumentCaptor captor = ArgumentCaptor.forClass(OpsOrderSecurityExtDO.class); + verify(securityExtMapper).insertOrUpdateSelective(captor.capture()); + OpsOrderSecurityExtDO ext = captor.getValue(); + assertEquals(TEST_ORDER_ID, ext.getOpsOrderId()); + assertNotNull(ext.getConfirmedTime()); + } + + @Test + void testOnOrderStateChanged_Completed_RecordsTime() { + OrderStateChangedEvent event = buildStateChangedEvent( + WorkOrderStatusEnum.ARRIVED, WorkOrderStatusEnum.COMPLETED); + + OpsOrderSecurityExtDO existingExt = OpsOrderSecurityExtDO.builder() + .id(1L).opsOrderId(TEST_ORDER_ID).build(); + when(securityExtMapper.selectByOpsOrderId(TEST_ORDER_ID)).thenReturn(existingExt); + lenient().when(securityExtMapper.insertOrUpdateSelective(any())).thenReturn(1); + + // 执行 + listener.onOrderStateChanged(event); + + // 验证扩展表写入 completedTime + ArgumentCaptor captor = ArgumentCaptor.forClass(OpsOrderSecurityExtDO.class); + verify(securityExtMapper).insertOrUpdateSelective(captor.capture()); + OpsOrderSecurityExtDO ext = captor.getValue(); + assertEquals(TEST_ORDER_ID, ext.getOpsOrderId()); + assertNotNull(ext.getCompletedTime()); + } + + @Test + void testOnOrderStateChanged_Completed_NoExt_Skipped() { + OrderStateChangedEvent event = buildStateChangedEvent( + WorkOrderStatusEnum.ARRIVED, WorkOrderStatusEnum.COMPLETED); + + when(securityExtMapper.selectByOpsOrderId(TEST_ORDER_ID)).thenReturn(null); + + // 执行 + listener.onOrderStateChanged(event); + + // 验证:扩展记录不存在时不写入 + verify(securityExtMapper, never()).insertOrUpdateSelective(any()); + } + + @Test + void testOnOrderStateChanged_CleanType_Ignored() { + OrderStateChangedEvent event = OrderStateChangedEvent.builder() + .orderId(TEST_ORDER_ID) + .orderType("CLEAN") + .oldStatus(WorkOrderStatusEnum.PENDING) + .newStatus(WorkOrderStatusEnum.DISPATCHED) + .build(); + + // 执行 + listener.onOrderStateChanged(event); + + // 验证不触发任何扩展表操作 + verify(securityExtMapper, never()).insertOrUpdateSelective(any()); + verify(securityExtMapper, never()).selectByOpsOrderId(anyLong()); + } + + @Test + void testOnOrderStateChanged_OtherStatus_NoAction() { + // CANCELLED 等状态无需额外处理 + OrderStateChangedEvent event = buildStateChangedEvent( + WorkOrderStatusEnum.PENDING, WorkOrderStatusEnum.CANCELLED); + + // 执行 + listener.onOrderStateChanged(event); + + // 验证无扩展表操作 + verify(securityExtMapper, never()).insertOrUpdateSelective(any()); + } + + // ==================== onOrderCompleted 测试 ==================== + + @Test + void testOnOrderCompleted_HasAssignee_DispatchNext() { + OrderCompletedEvent event = OrderCompletedEvent.builder() + .orderId(TEST_ORDER_ID) + .orderType("SECURITY") + .assigneeId(2001L) + .build(); + + // 执行 + listener.onOrderCompleted(event); + + // 验证调用自动派送下一个 + verify(dispatchEngine).autoDispatchNext(TEST_ORDER_ID, 2001L); + } + + @Test + void testOnOrderCompleted_NoAssignee_SkipDispatch() { + OrderCompletedEvent event = OrderCompletedEvent.builder() + .orderId(TEST_ORDER_ID) + .orderType("SECURITY") + .assigneeId(null) // 无分配人 + .build(); + + // 执行 + listener.onOrderCompleted(event); + + // 验证不调用自动派送 + verify(dispatchEngine, never()).autoDispatchNext(anyLong(), anyLong()); + } + + @Test + void testOnOrderCompleted_CleanType_Ignored() { + OrderCompletedEvent event = OrderCompletedEvent.builder() + .orderId(TEST_ORDER_ID) + .orderType("CLEAN") + .assigneeId(2001L) + .build(); + + // 执行 + listener.onOrderCompleted(event); + + // 验证不触发 + verify(dispatchEngine, never()).autoDispatchNext(anyLong(), anyLong()); + } + + @Test + void testOnOrderCompleted_DispatchException_Caught() { + OrderCompletedEvent event = OrderCompletedEvent.builder() + .orderId(TEST_ORDER_ID) + .orderType("SECURITY") + .assigneeId(2001L) + .build(); + + doThrow(new RuntimeException("派送异常")).when(dispatchEngine) + .autoDispatchNext(anyLong(), anyLong()); + + // 执行:不应抛出异常 + assertDoesNotThrow(() -> listener.onOrderCompleted(event)); + } + + // ==================== 辅助方法 ==================== + + private OrderStateChangedEvent buildStateChangedEvent( + WorkOrderStatusEnum oldStatus, WorkOrderStatusEnum newStatus) { + return OrderStateChangedEvent.builder() + .orderId(TEST_ORDER_ID) + .orderType("SECURITY") + .oldStatus(oldStatus) + .newStatus(newStatus) + .build(); + } +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/area/OpsAreaSecurityUserServiceTest.java b/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/area/OpsAreaSecurityUserServiceTest.java new file mode 100644 index 0000000..dd43103 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/area/OpsAreaSecurityUserServiceTest.java @@ -0,0 +1,122 @@ +package com.viewsh.module.ops.security.service.area; + +import com.viewsh.framework.common.exception.ServiceException; +import com.viewsh.module.ops.security.dal.dataobject.area.OpsAreaSecurityUserDO; +import com.viewsh.module.ops.security.dal.mysql.area.OpsAreaSecurityUserMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 区域安保人员绑定服务单元测试 + * + * @author lzh + */ +@ExtendWith(MockitoExtension.class) +public class OpsAreaSecurityUserServiceTest { + + @InjectMocks + private OpsAreaSecurityUserServiceImpl service; + + @Mock + private OpsAreaSecurityUserMapper areaSecurityUserMapper; + + @Test + void bindUser_success() { + when(areaSecurityUserMapper.selectByAreaIdAndUserId(100L, 2001L)).thenReturn(null); + when(areaSecurityUserMapper.insert(any(OpsAreaSecurityUserDO.class))).thenReturn(1); + + Long id = service.bindUser(100L, 2001L, "张三", 10L, 0); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OpsAreaSecurityUserDO.class); + verify(areaSecurityUserMapper).insert(captor.capture()); + OpsAreaSecurityUserDO saved = captor.getValue(); + assertEquals(100L, saved.getAreaId()); + assertEquals(2001L, saved.getUserId()); + assertEquals("张三", saved.getUserName()); + assertEquals(10L, saved.getTeamId()); + assertTrue(saved.getEnabled()); + assertEquals(0, saved.getSort()); + } + + @Test + void bindUser_duplicate_throws() { + OpsAreaSecurityUserDO existing = OpsAreaSecurityUserDO.builder() + .id(1L).areaId(100L).userId(2001L).build(); + when(areaSecurityUserMapper.selectByAreaIdAndUserId(100L, 2001L)).thenReturn(existing); + + assertThrows(ServiceException.class, () -> service.bindUser(100L, 2001L, "张三", null, null)); + verify(areaSecurityUserMapper, never()).insert(any(OpsAreaSecurityUserDO.class)); + } + + @Test + void unbindUser_success() { + OpsAreaSecurityUserDO existing = OpsAreaSecurityUserDO.builder() + .id(1L).areaId(100L).userId(2001L).build(); + when(areaSecurityUserMapper.selectById(1L)).thenReturn(existing); + when(areaSecurityUserMapper.deleteById(1L)).thenReturn(1); + + service.unbindUser(1L); + + verify(areaSecurityUserMapper).deleteById(1L); + } + + @Test + void unbindUser_notFound_throws() { + when(areaSecurityUserMapper.selectById(999L)).thenReturn(null); + + assertThrows(ServiceException.class, () -> service.unbindUser(999L)); + verify(areaSecurityUserMapper, never()).deleteById(any()); + } + + @Test + void listByAreaId_returnsEnabledOnly() { + OpsAreaSecurityUserDO user1 = OpsAreaSecurityUserDO.builder() + .id(1L).areaId(100L).userId(2001L).userName("张三").enabled(true).build(); + OpsAreaSecurityUserDO user2 = OpsAreaSecurityUserDO.builder() + .id(2L).areaId(100L).userId(2002L).userName("李四").enabled(true).build(); + + when(areaSecurityUserMapper.selectListByAreaId(100L)).thenReturn(Arrays.asList(user1, user2)); + + List result = service.listByAreaId(100L); + + assertEquals(2, result.size()); + verify(areaSecurityUserMapper).selectListByAreaId(100L); + } + + @Test + void updateBinding_success() { + OpsAreaSecurityUserDO existing = OpsAreaSecurityUserDO.builder() + .id(1L).areaId(100L).userId(2001L).enabled(true).sort(0).build(); + when(areaSecurityUserMapper.selectById(1L)).thenReturn(existing); + when(areaSecurityUserMapper.updateById(any(OpsAreaSecurityUserDO.class))).thenReturn(1); + + service.updateBinding(1L, false, 5, 20L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OpsAreaSecurityUserDO.class); + verify(areaSecurityUserMapper).updateById(captor.capture()); + OpsAreaSecurityUserDO updated = captor.getValue(); + assertEquals(1L, updated.getId()); + assertFalse(updated.getEnabled()); + assertEquals(5, updated.getSort()); + assertEquals(20L, updated.getTeamId()); + } + + @Test + void updateBinding_notFound_throws() { + when(areaSecurityUserMapper.selectById(999L)).thenReturn(null); + + assertThrows(ServiceException.class, () -> service.updateBinding(999L, true, null, null)); + } + +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/dispatch/SecurityAreaAssignStrategyTest.java b/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/dispatch/SecurityAreaAssignStrategyTest.java new file mode 100644 index 0000000..1387fd1 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/dispatch/SecurityAreaAssignStrategyTest.java @@ -0,0 +1,108 @@ +package com.viewsh.module.ops.security.service.dispatch; + +import com.viewsh.module.ops.core.dispatch.model.AssigneeRecommendation; +import com.viewsh.module.ops.core.dispatch.model.OrderDispatchContext; +import com.viewsh.module.ops.security.dal.dataobject.area.OpsAreaSecurityUserDO; +import com.viewsh.module.ops.security.dal.mysql.area.OpsAreaSecurityUserMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * 安保区域分配策略单元测试 + * + * @author lzh + */ +@ExtendWith(MockitoExtension.class) +public class SecurityAreaAssignStrategyTest { + + @InjectMocks + private SecurityAreaAssignStrategy strategy; + + @Mock + private OpsAreaSecurityUserMapper areaSecurityUserMapper; + + @Test + void recommend_noAreaId_returnsNone() { + OrderDispatchContext context = OrderDispatchContext.builder() + .orderId(1001L) + .areaId(null) + .build(); + + AssigneeRecommendation result = strategy.recommend(context); + + assertFalse(result.hasRecommendation()); + verify(areaSecurityUserMapper, never()).selectListByAreaId(any()); + } + + @Test + void recommend_noUsersInArea_returnsNone() { + Long areaId = 100L; + OrderDispatchContext context = OrderDispatchContext.builder() + .orderId(1001L) + .areaId(areaId) + .build(); + + when(areaSecurityUserMapper.selectListByAreaId(areaId)).thenReturn(Collections.emptyList()); + + AssigneeRecommendation result = strategy.recommend(context); + + assertFalse(result.hasRecommendation()); + verify(areaSecurityUserMapper).selectListByAreaId(areaId); + } + + @Test + void recommend_hasUsers_returnsRecommendation() { + Long areaId = 100L; + OrderDispatchContext context = OrderDispatchContext.builder() + .orderId(1001L) + .areaId(areaId) + .build(); + + OpsAreaSecurityUserDO user1 = OpsAreaSecurityUserDO.builder() + .id(1L).areaId(areaId).userId(2001L).userName("张三").enabled(true).build(); + OpsAreaSecurityUserDO user2 = OpsAreaSecurityUserDO.builder() + .id(2L).areaId(areaId).userId(2002L).userName("李四").enabled(true).build(); + + when(areaSecurityUserMapper.selectListByAreaId(areaId)).thenReturn(Arrays.asList(user1, user2)); + + AssigneeRecommendation result = strategy.recommend(context); + + assertTrue(result.hasRecommendation()); + assertNotNull(result.getAssigneeId()); + assertTrue(result.getAssigneeId().equals(2001L) || result.getAssigneeId().equals(2002L)); + assertEquals(50, result.getScore()); + assertEquals("区域随机分配", result.getReason()); + assertEquals(areaId, result.getAreaId()); + } + + @Test + void recommend_singleUser_returnsThatUser() { + Long areaId = 100L; + OrderDispatchContext context = OrderDispatchContext.builder() + .orderId(1001L) + .areaId(areaId) + .build(); + + OpsAreaSecurityUserDO user = OpsAreaSecurityUserDO.builder() + .id(1L).areaId(areaId).userId(2001L).userName("张三").enabled(true).build(); + + when(areaSecurityUserMapper.selectListByAreaId(areaId)).thenReturn(Collections.singletonList(user)); + + AssigneeRecommendation result = strategy.recommend(context); + + assertTrue(result.hasRecommendation()); + assertEquals(2001L, result.getAssigneeId()); + assertEquals("张三", result.getAssigneeName()); + assertEquals(areaId, result.getAreaId()); + } + +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderExtQueryHandlerTest.java b/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderExtQueryHandlerTest.java new file mode 100644 index 0000000..0382862 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderExtQueryHandlerTest.java @@ -0,0 +1,190 @@ +package com.viewsh.module.ops.security.service.securityorder; + +import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO; +import com.viewsh.module.ops.enums.PriorityEnum; +import com.viewsh.module.ops.enums.WorkOrderStatusEnum; +import com.viewsh.module.ops.security.dal.dataobject.workorder.OpsOrderSecurityExtDO; +import com.viewsh.module.ops.security.dal.mysql.workorder.OpsOrderSecurityExtMapper; +import com.viewsh.module.ops.service.OrderDetailVO; +import com.viewsh.module.ops.service.OrderSummaryVO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * 安保工单扩展查询处理器测试 + * + * @author lzh + */ +@ExtendWith(MockitoExtension.class) +public class SecurityOrderExtQueryHandlerTest { + + @InjectMocks + private SecurityOrderExtQueryHandler handler; + + @Mock + private OpsOrderSecurityExtMapper securityExtMapper; + + private static final Long TEST_ORDER_ID = 10001L; + private OpsOrderSecurityExtDO testSecurityExt; + private OpsOrderDO testOrder; + + @BeforeEach + void setUp() { + testSecurityExt = OpsOrderSecurityExtDO.builder() + .id(1L) + .opsOrderId(TEST_ORDER_ID) + .alarmId("ALM20260310001") + .alarmType("intrusion") + .cameraId("CAM_001") + .roiId("ROI_001") + .imageUrl("https://oss.example.com/snapshot.jpg") + .assignedUserId(2001L) + .assignedUserName("张安保") + .assignedTeamId(301L) + .result("已排查,系误报") + .resultImgUrls("[\"https://oss/r1.jpg\"]") + .dispatchedTime(LocalDateTime.of(2026, 3, 10, 10, 0)) + .confirmedTime(LocalDateTime.of(2026, 3, 10, 10, 2)) + .completedTime(LocalDateTime.of(2026, 3, 10, 10, 30)) + .build(); + + testOrder = OpsOrderDO.builder() + .id(TEST_ORDER_ID) + .orderCode("SECURITY-20260310-0001") + .orderType("SECURITY") + .sourceType("ALARM") + .title("A栋3层入侵告警") + .description("摄像头检测到异常人员") + .priority(PriorityEnum.P1.getPriority()) + .status(WorkOrderStatusEnum.COMPLETED.getStatus()) + .areaId(100L) + .location("A栋3层东侧走廊") + .assigneeId(2001L) + .assigneeName("张安保") + .startTime(LocalDateTime.of(2026, 3, 10, 10, 5)) + .endTime(LocalDateTime.of(2026, 3, 10, 10, 30)) + .responseSeconds(300) + .completionSeconds(1500) + .build(); + } + + // ==================== supports() 测试 ==================== + + @Test + void testSupports_Security_ReturnsTrue() { + assertTrue(handler.supports("SECURITY")); + } + + @Test + void testSupports_Clean_ReturnsFalse() { + assertFalse(handler.supports("CLEAN")); + } + + @Test + void testSupports_Null_ReturnsFalse() { + assertFalse(handler.supports(null)); + } + + @Test + void testSupports_Empty_ReturnsFalse() { + assertFalse(handler.supports("")); + } + + // ==================== enrichWithExtInfo() 测试 ==================== + + @Test + void testEnrichWithExtInfo_HasExt_FieldsPopulated() { + when(securityExtMapper.selectByOpsOrderId(TEST_ORDER_ID)).thenReturn(testSecurityExt); + + OrderSummaryVO vo = new OrderSummaryVO(); + handler.enrichWithExtInfo(vo, TEST_ORDER_ID); + + // 验证 extInfo 非空 + assertNotNull(vo.getExtInfo()); + Map extInfo = vo.getExtInfo(); + + // 验证各字段映射 + assertEquals("ALM20260310001", extInfo.get("alarmId")); + assertEquals("intrusion", extInfo.get("alarmType")); + assertEquals("CAM_001", extInfo.get("cameraId")); + assertEquals("https://oss.example.com/snapshot.jpg", extInfo.get("imageUrl")); + assertEquals("张安保", extInfo.get("assignedUserName")); + assertEquals(testSecurityExt.getConfirmedTime(), extInfo.get("confirmedTime")); + assertEquals(testSecurityExt.getCompletedTime(), extInfo.get("completedTime")); + assertEquals("已排查,系误报", extInfo.get("result")); + } + + @Test + void testEnrichWithExtInfo_NoExt_ExtInfoNotSet() { + when(securityExtMapper.selectByOpsOrderId(TEST_ORDER_ID)).thenReturn(null); + + OrderSummaryVO vo = new OrderSummaryVO(); + handler.enrichWithExtInfo(vo, TEST_ORDER_ID); + + // 无扩展记录时,extInfo 未被填充 + assertTrue(vo.getExtInfo() == null || vo.getExtInfo().isEmpty()); + } + + // ==================== buildDetailVO() 测试 ==================== + + @Test + void testBuildDetailVO_HasExt_FullVO() { + when(securityExtMapper.selectByOpsOrderId(TEST_ORDER_ID)).thenReturn(testSecurityExt); + + OrderDetailVO vo = handler.buildDetailVO(testOrder); + + // 验证主表字段 + assertNotNull(vo); + assertEquals(TEST_ORDER_ID, vo.getId()); + assertEquals("SECURITY-20260310-0001", vo.getOrderCode()); + assertEquals("SECURITY", vo.getOrderType()); + assertEquals("ALARM", vo.getSourceType()); + assertEquals("A栋3层入侵告警", vo.getTitle()); + assertEquals(PriorityEnum.P1.getPriority(), vo.getPriority()); + assertEquals(WorkOrderStatusEnum.COMPLETED.getStatus(), vo.getStatus()); + assertEquals(100L, vo.getAreaId()); + assertEquals("A栋3层东侧走廊", vo.getLocation()); + + // 验证扩展字段 + assertNotNull(vo.getExtInfo()); + Map extInfo = vo.getExtInfo(); + assertEquals("ALM20260310001", extInfo.get("alarmId")); + assertEquals("intrusion", extInfo.get("alarmType")); + assertEquals("CAM_001", extInfo.get("cameraId")); + assertEquals("ROI_001", extInfo.get("roiId")); + assertEquals("https://oss.example.com/snapshot.jpg", extInfo.get("imageUrl")); + assertEquals(2001L, extInfo.get("assignedUserId")); + assertEquals("张安保", extInfo.get("assignedUserName")); + assertEquals(301L, extInfo.get("assignedTeamId")); + assertEquals("已排查,系误报", extInfo.get("result")); + assertEquals("[\"https://oss/r1.jpg\"]", extInfo.get("resultImgUrls")); + assertEquals(testSecurityExt.getDispatchedTime(), extInfo.get("dispatchedTime")); + assertEquals(testSecurityExt.getConfirmedTime(), extInfo.get("confirmedTime")); + assertEquals(testSecurityExt.getCompletedTime(), extInfo.get("completedTime")); + } + + @Test + void testBuildDetailVO_NoExt_ExtInfoNull() { + when(securityExtMapper.selectByOpsOrderId(TEST_ORDER_ID)).thenReturn(null); + + OrderDetailVO vo = handler.buildDetailVO(testOrder); + + // 主表字段正常 + assertNotNull(vo); + assertEquals(TEST_ORDER_ID, vo.getId()); + assertEquals("SECURITY", vo.getOrderType()); + + // 无扩展记录时,extInfo 未被填充 + assertTrue(vo.getExtInfo() == null || vo.getExtInfo().isEmpty()); + } +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderServiceTest.java b/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderServiceTest.java new file mode 100644 index 0000000..8be3593 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderServiceTest.java @@ -0,0 +1,437 @@ +package com.viewsh.module.ops.security.service.securityorder; + +import com.viewsh.framework.common.exception.ServiceException; +import com.viewsh.module.ops.core.event.OrderCreatedEvent; +import com.viewsh.module.ops.core.event.OrderEventPublisher; +import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO; +import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper; +import com.viewsh.module.ops.enums.OperatorTypeEnum; +import com.viewsh.module.ops.enums.PriorityEnum; +import com.viewsh.module.ops.enums.SourceTypeEnum; +import com.viewsh.module.ops.enums.WorkOrderStatusEnum; +import com.viewsh.module.ops.infrastructure.code.OrderCodeGenerator; +import com.viewsh.module.ops.infrastructure.id.OrderIdGenerator; +import com.viewsh.module.ops.security.dal.dataobject.workorder.OpsOrderSecurityExtDO; +import com.viewsh.module.ops.security.dal.mysql.workorder.OpsOrderSecurityExtMapper; +import com.viewsh.module.ops.service.fsm.OrderStateMachine; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 安保工单服务单元测试 + * + * @author lzh + */ +@ExtendWith(MockitoExtension.class) +public class SecurityOrderServiceTest { + + @InjectMocks + private SecurityOrderServiceImpl securityOrderService; + + @Mock + private OpsOrderMapper opsOrderMapper; + @Mock + private OpsOrderSecurityExtMapper securityExtMapper; + @Mock + private OrderIdGenerator orderIdGenerator; + @Mock + private OrderCodeGenerator orderCodeGenerator; + @Mock + private OrderEventPublisher orderEventPublisher; + @Mock + private OrderStateMachine orderStateMachine; + + // 模拟数据库 + private Map orderDB; + + private static final Long TEST_ORDER_ID = 10001L; + private static final String TEST_ORDER_CODE = "SECURITY-20260310-0001"; + + @BeforeEach + void setUp() { + orderDB = new HashMap<>(); + + // 配置 ID/编号 生成器 + lenient().when(orderIdGenerator.generate()).thenReturn(TEST_ORDER_ID); + lenient().when(orderCodeGenerator.generate("SECURITY")).thenReturn(TEST_ORDER_CODE); + + // 配置 Mapper 模拟 + lenient().when(opsOrderMapper.insert(any(OpsOrderDO.class))).thenAnswer(i -> { + OpsOrderDO order = i.getArgument(0); + orderDB.put(order.getId(), order); + return 1; + }); + lenient().when(opsOrderMapper.selectById(anyLong())).thenAnswer(i -> orderDB.get(i.getArgument(0))); + lenient().when(opsOrderMapper.updateById(any(OpsOrderDO.class))).thenReturn(1); + lenient().when(securityExtMapper.insert(any(OpsOrderSecurityExtDO.class))).thenReturn(1); + lenient().when(securityExtMapper.insertOrUpdateSelective(any(OpsOrderSecurityExtDO.class))).thenReturn(1); + } + + // ==================== 创建工单测试 ==================== + + @Test + void testCreateSecurityOrder_AlarmSource_Success() { + // 准备:告警来源请求 + SecurityOrderCreateReqDTO req = new SecurityOrderCreateReqDTO(); + req.setTitle("A栋3层入侵告警"); + req.setDescription("摄像头检测到异常人员"); + req.setPriority(PriorityEnum.P1.getPriority()); + req.setAreaId(100L); + req.setLocation("A栋3层东侧走廊"); + req.setAlarmId("ALM20260310001"); + req.setAlarmType("intrusion"); + req.setCameraId("CAM_001"); + req.setRoiId("ROI_001"); + req.setImageUrl("https://oss.example.com/alarm/snapshot.jpg"); + + // 执行 + Long orderId = securityOrderService.createSecurityOrder(req); + + // 验证返回值 + assertEquals(TEST_ORDER_ID, orderId); + + // 验证主表写入 + ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(OpsOrderDO.class); + verify(opsOrderMapper).insert(orderCaptor.capture()); + OpsOrderDO savedOrder = orderCaptor.getValue(); + assertEquals(TEST_ORDER_ID, savedOrder.getId()); + assertEquals(TEST_ORDER_CODE, savedOrder.getOrderCode()); + assertEquals("SECURITY", savedOrder.getOrderType()); + assertEquals(SourceTypeEnum.ALARM.getType(), savedOrder.getSourceType()); + assertEquals("A栋3层入侵告警", savedOrder.getTitle()); + assertEquals(PriorityEnum.P1.getPriority(), savedOrder.getPriority()); + assertEquals(WorkOrderStatusEnum.PENDING.getStatus(), savedOrder.getStatus()); + assertEquals(100L, savedOrder.getAreaId()); + + // 验证扩展表写入 + ArgumentCaptor extCaptor = ArgumentCaptor.forClass(OpsOrderSecurityExtDO.class); + verify(securityExtMapper).insert(extCaptor.capture()); + OpsOrderSecurityExtDO savedExt = extCaptor.getValue(); + assertEquals(TEST_ORDER_ID, savedExt.getOpsOrderId()); + assertEquals("ALM20260310001", savedExt.getAlarmId()); + assertEquals("intrusion", savedExt.getAlarmType()); + assertEquals("CAM_001", savedExt.getCameraId()); + assertEquals("ROI_001", savedExt.getRoiId()); + assertEquals("https://oss.example.com/alarm/snapshot.jpg", savedExt.getImageUrl()); + + // 验证事件发布 + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(OrderCreatedEvent.class); + verify(orderEventPublisher).publishOrderCreated(eventCaptor.capture()); + OrderCreatedEvent event = eventCaptor.getValue(); + assertEquals(TEST_ORDER_ID, event.getOrderId()); + assertEquals("SECURITY", event.getOrderType()); + assertEquals(TEST_ORDER_CODE, event.getOrderCode()); + assertEquals(100L, event.getAreaId()); + } + + @Test + void testCreateSecurityOrder_ManualSource_Success() { + // 准备:手动创建(无告警ID) + SecurityOrderCreateReqDTO req = new SecurityOrderCreateReqDTO(); + req.setTitle("保安巡查发现异常"); + req.setAreaId(200L); + + // 执行 + Long orderId = securityOrderService.createSecurityOrder(req); + + // 验证来源类型为 MANUAL + ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(OpsOrderDO.class); + verify(opsOrderMapper).insert(orderCaptor.capture()); + assertEquals(SourceTypeEnum.MANUAL.getType(), orderCaptor.getValue().getSourceType()); + } + + @Test + void testCreateSecurityOrder_ExplicitSourceType_Success() { + // 准备:显式指定来源类型 + SecurityOrderCreateReqDTO req = new SecurityOrderCreateReqDTO(); + req.setTitle("手动创建"); + req.setAreaId(200L); + req.setAlarmId("ALM001"); // 有告警ID + req.setSourceType("CUSTOM_SOURCE"); // 但显式指定了sourceType + + // 执行 + securityOrderService.createSecurityOrder(req); + + // 验证:显式指定的 sourceType 优先于自动推断 + ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(OpsOrderDO.class); + verify(opsOrderMapper).insert(orderCaptor.capture()); + assertEquals("CUSTOM_SOURCE", orderCaptor.getValue().getSourceType()); + } + + @Test + void testCreateSecurityOrder_DefaultPriority_P2() { + // 准备:不设置优先级 + SecurityOrderCreateReqDTO req = new SecurityOrderCreateReqDTO(); + req.setTitle("默认优先级工单"); + req.setAreaId(100L); + + // 执行 + securityOrderService.createSecurityOrder(req); + + // 验证默认 P2 + ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(OpsOrderDO.class); + verify(opsOrderMapper).insert(orderCaptor.capture()); + assertEquals(PriorityEnum.P2.getPriority(), orderCaptor.getValue().getPriority()); + } + + // ==================== 确认工单测试 ==================== + + @Test + void testConfirmOrder_Success() { + // 准备:已存在的 SECURITY 工单 + OpsOrderDO order = buildSecurityOrder(TEST_ORDER_ID, WorkOrderStatusEnum.DISPATCHED); + orderDB.put(TEST_ORDER_ID, order); + + Long userId = 2001L; + + // 执行 + securityOrderService.confirmOrder(TEST_ORDER_ID, userId); + + // 验证状态机调用 + verify(orderStateMachine).transition( + eq(order), + eq(WorkOrderStatusEnum.CONFIRMED), + eq(OperatorTypeEnum.SECURITY_GUARD), + eq(userId), + eq("安保人员确认接单") + ); + + // 验证不再直接写扩展表时间(由 EventListener 统一处理) + verify(securityExtMapper, never()).insertOrUpdateSelective(any()); + } + + @Test + void testConfirmOrder_OrderNotFound_ThrowsException() { + // 执行 + 验证:工单不存在抛异常 + ServiceException exception = assertThrows(ServiceException.class, + () -> securityOrderService.confirmOrder(999L, 2001L)); + assertTrue(exception.getMessage().contains("工单不存在")); + } + + @Test + void testConfirmOrder_WrongOrderType_ThrowsException() { + // 准备:CLEAN 类型工单 + OpsOrderDO cleanOrder = OpsOrderDO.builder() + .id(TEST_ORDER_ID) + .orderType("CLEAN") + .status(WorkOrderStatusEnum.DISPATCHED.getStatus()) + .build(); + orderDB.put(TEST_ORDER_ID, cleanOrder); + + // 执行 + 验证:类型不匹配抛异常 + ServiceException exception = assertThrows(ServiceException.class, + () -> securityOrderService.confirmOrder(TEST_ORDER_ID, 2001L)); + assertTrue(exception.getMessage().contains("工单类型不匹配")); + + // 验证状态机未被调用 + verify(orderStateMachine, never()).transition( + any(), any(), any(), anyLong(), anyString()); + } + + // ==================== 自动完单测试 ==================== + + @Test + void testAutoCompleteOrder_WithRemark_Success() { + // 准备 + OpsOrderDO order = buildSecurityOrder(TEST_ORDER_ID, WorkOrderStatusEnum.ARRIVED); + orderDB.put(TEST_ORDER_ID, order); + + // 执行 + securityOrderService.autoCompleteOrder(TEST_ORDER_ID, "告警自动解除"); + + // 验证状态机调用 + verify(orderStateMachine).transition( + eq(order), + eq(WorkOrderStatusEnum.COMPLETED), + eq(OperatorTypeEnum.SYSTEM), + isNull(), + eq("告警自动解除") + ); + + // 验证主表 endTime 更新 + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(OpsOrderDO.class); + verify(opsOrderMapper).updateById(updateCaptor.capture()); + OpsOrderDO updated = updateCaptor.getValue(); + assertEquals(TEST_ORDER_ID, updated.getId()); + assertNotNull(updated.getEndTime()); + + // 验证不再直接写扩展表时间 + verify(securityExtMapper, never()).insertOrUpdateSelective(any()); + } + + @Test + void testAutoCompleteOrder_WithoutRemark_DefaultReason() { + // 准备 + OpsOrderDO order = buildSecurityOrder(TEST_ORDER_ID, WorkOrderStatusEnum.ARRIVED); + orderDB.put(TEST_ORDER_ID, order); + + // 执行:remark 为空 + securityOrderService.autoCompleteOrder(TEST_ORDER_ID, null); + + // 验证使用默认备注 + verify(orderStateMachine).transition( + any(), eq(WorkOrderStatusEnum.COMPLETED), + eq(OperatorTypeEnum.SYSTEM), isNull(), + eq("系统自动完单") + ); + } + + @Test + void testAutoCompleteOrder_BlankRemark_DefaultReason() { + // 准备 + OpsOrderDO order = buildSecurityOrder(TEST_ORDER_ID, WorkOrderStatusEnum.ARRIVED); + orderDB.put(TEST_ORDER_ID, order); + + // 执行:remark 为空白字符串 + securityOrderService.autoCompleteOrder(TEST_ORDER_ID, " "); + + // 验证使用默认备注 + verify(orderStateMachine).transition( + any(), eq(WorkOrderStatusEnum.COMPLETED), + eq(OperatorTypeEnum.SYSTEM), isNull(), + eq("系统自动完单") + ); + } + + @Test + void testAutoCompleteOrder_OrderNotFound_ThrowsException() { + assertThrows(ServiceException.class, + () -> securityOrderService.autoCompleteOrder(999L, "test")); + } + + // ==================== 人工完单测试 ==================== + + @Test + void testManualCompleteOrder_WithImages_Success() { + // 准备 + OpsOrderDO order = buildSecurityOrder(TEST_ORDER_ID, WorkOrderStatusEnum.ARRIVED); + orderDB.put(TEST_ORDER_ID, order); + + // 准备完单请求,包含result和图片 + SecurityOrderCompleteReqDTO req = new SecurityOrderCompleteReqDTO(); + req.setOrderId(TEST_ORDER_ID); + req.setResult("已到现场排查,系误报"); + req.setResultImgUrls(Arrays.asList("https://oss/result1.jpg", "https://oss/result2.jpg")); + req.setOperatorId(2001L); + + // 执行 + securityOrderService.manualCompleteOrder(req); + + // 验证状态机调用 + verify(orderStateMachine).transition( + eq(order), + eq(WorkOrderStatusEnum.COMPLETED), + eq(OperatorTypeEnum.SECURITY_GUARD), + eq(2001L), + eq("安保人员提交处理结果") + ); + + // 验证扩展表:写入 result + resultImgUrls(不含 completedTime) + ArgumentCaptor extCaptor = ArgumentCaptor.forClass(OpsOrderSecurityExtDO.class); + verify(securityExtMapper).insertOrUpdateSelective(extCaptor.capture()); + OpsOrderSecurityExtDO extUpdate = extCaptor.getValue(); + assertEquals(TEST_ORDER_ID, extUpdate.getOpsOrderId()); + assertEquals("已到现场排查,系误报", extUpdate.getResult()); + assertNotNull(extUpdate.getResultImgUrls()); + assertTrue(extUpdate.getResultImgUrls().contains("result1.jpg")); + assertNull(extUpdate.getCompletedTime()); // 时间由 EventListener 写入 + + // 验证主表 endTime 更新 + ArgumentCaptor orderUpdateCaptor = ArgumentCaptor.forClass(OpsOrderDO.class); + verify(opsOrderMapper).updateById(orderUpdateCaptor.capture()); + assertNotNull(orderUpdateCaptor.getValue().getEndTime()); + } + + @Test + void testManualCompleteOrder_WithoutImages_Success() { + // 准备 + OpsOrderDO order = buildSecurityOrder(TEST_ORDER_ID, WorkOrderStatusEnum.ARRIVED); + orderDB.put(TEST_ORDER_ID, order); + + SecurityOrderCompleteReqDTO req = new SecurityOrderCompleteReqDTO(); + req.setOrderId(TEST_ORDER_ID); + req.setResult("已处理完毕"); + req.setResultImgUrls(null); // 无图片 + req.setOperatorId(2001L); + + // 执行 + securityOrderService.manualCompleteOrder(req); + + // 验证扩展表:result 有值,resultImgUrls 为 null + ArgumentCaptor extCaptor = ArgumentCaptor.forClass(OpsOrderSecurityExtDO.class); + verify(securityExtMapper).insertOrUpdateSelective(extCaptor.capture()); + assertEquals("已处理完毕", extCaptor.getValue().getResult()); + assertNull(extCaptor.getValue().getResultImgUrls()); + } + + @Test + void testManualCompleteOrder_EmptyImagesList_NoJsonWrite() { + // 准备 + OpsOrderDO order = buildSecurityOrder(TEST_ORDER_ID, WorkOrderStatusEnum.ARRIVED); + orderDB.put(TEST_ORDER_ID, order); + + SecurityOrderCompleteReqDTO req = new SecurityOrderCompleteReqDTO(); + req.setOrderId(TEST_ORDER_ID); + req.setResult("已处理"); + req.setResultImgUrls(Arrays.asList()); // 空列表 + req.setOperatorId(2001L); + + // 执行 + securityOrderService.manualCompleteOrder(req); + + // 验证:空列表不写入 resultImgUrls + ArgumentCaptor extCaptor = ArgumentCaptor.forClass(OpsOrderSecurityExtDO.class); + verify(securityExtMapper).insertOrUpdateSelective(extCaptor.capture()); + assertNull(extCaptor.getValue().getResultImgUrls()); + } + + // ==================== 查询测试 ==================== + + @Test + void testGetSecurityExt_Exists() { + OpsOrderSecurityExtDO ext = OpsOrderSecurityExtDO.builder() + .id(1L).opsOrderId(TEST_ORDER_ID).alarmId("ALM001").build(); + when(securityExtMapper.selectByOpsOrderId(TEST_ORDER_ID)).thenReturn(ext); + + OpsOrderSecurityExtDO result = securityOrderService.getSecurityExt(TEST_ORDER_ID); + assertNotNull(result); + assertEquals("ALM001", result.getAlarmId()); + } + + @Test + void testGetSecurityExt_NotExists() { + when(securityExtMapper.selectByOpsOrderId(999L)).thenReturn(null); + + OpsOrderSecurityExtDO result = securityOrderService.getSecurityExt(999L); + assertNull(result); + } + + // ==================== 辅助方法 ==================== + + private OpsOrderDO buildSecurityOrder(Long orderId, WorkOrderStatusEnum status) { + return OpsOrderDO.builder() + .id(orderId) + .orderCode(TEST_ORDER_CODE) + .orderType("SECURITY") + .sourceType(SourceTypeEnum.ALARM.getType()) + .title("安保测试工单") + .priority(PriorityEnum.P1.getPriority()) + .status(status.getStatus()) + .areaId(100L) + .location("A栋3层") + .build(); + } +} From 2e4432e51b6e70a83b3470316eaa8df7d568f7fe Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 11 Mar 2026 17:33:42 +0800 Subject: [PATCH 11/35] =?UTF-8?q?feat(ops):=20=E6=96=B0=E5=A2=9E=E5=AE=89?= =?UTF-8?q?=E4=BF=9D=E5=B7=A5=E5=8D=95=20Controller=20=E4=B8=8E=E5=BC=80?= =?UTF-8?q?=E6=94=BE=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 包含 SecurityOrderController(创建/确认/完单/自动完单)、 SecurityAreaUserController(区域人员绑定)、 SecurityOrderOpenController(外部回调), 以及对应的 VO 和权限配置。 Co-Authored-By: Claude Opus 4.6 --- .../security/SecurityAreaUserController.java | 88 ++++++++++++++++++ .../security/SecurityOrderController.java | 93 +++++++++++++++++++ .../vo/OpsAreaSecurityUserBindReqVO.java | 33 +++++++ .../vo/OpsAreaSecurityUserRespVO.java | 41 ++++++++ .../vo/OpsAreaSecurityUserUpdateReqVO.java | 29 ++++++ .../vo/SecurityOrderAutoCompleteReqVO.java | 23 +++++ .../vo/SecurityOrderCompleteReqVO.java | 30 ++++++ .../vo/SecurityOrderConfirmReqVO.java | 24 +++++ .../security/vo/SecurityOrderCreateReqVO.java | 54 +++++++++++ .../security/SecurityOrderOpenController.java | 72 ++++++++++++++ .../config/SecurityConfiguration.java | 4 +- 11 files changed, 490 insertions(+), 1 deletion(-) create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/SecurityAreaUserController.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/SecurityOrderController.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/OpsAreaSecurityUserBindReqVO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/OpsAreaSecurityUserRespVO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/OpsAreaSecurityUserUpdateReqVO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderAutoCompleteReqVO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderCompleteReqVO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderConfirmReqVO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderCreateReqVO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/open/security/SecurityOrderOpenController.java diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/SecurityAreaUserController.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/SecurityAreaUserController.java new file mode 100644 index 0000000..1421c89 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/SecurityAreaUserController.java @@ -0,0 +1,88 @@ +package com.viewsh.module.ops.controller.admin.security; + +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.module.ops.controller.admin.security.vo.OpsAreaSecurityUserBindReqVO; +import com.viewsh.module.ops.controller.admin.security.vo.OpsAreaSecurityUserRespVO; +import com.viewsh.module.ops.controller.admin.security.vo.OpsAreaSecurityUserUpdateReqVO; +import com.viewsh.module.ops.security.dal.dataobject.area.OpsAreaSecurityUserDO; +import com.viewsh.module.ops.security.service.area.OpsAreaSecurityUserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static com.viewsh.framework.common.pojo.CommonResult.success; + +/** + * 安保区域人员绑定管理 + * + * @author lzh + */ +@Tag(name = "安保区域人员绑定") +@RestController +@RequestMapping("/ops/security/area-user") +@Validated +public class SecurityAreaUserController { + + @Resource + private OpsAreaSecurityUserService areaSecurityUserService; + + @GetMapping("/list") + @Operation(summary = "查询区域绑定的安保人员") + @PreAuthorize("@ss.hasPermission('ops:security-area-user:query')") + public CommonResult> list( + @Parameter(description = "区域ID", required = true) @RequestParam("areaId") Long areaId) { + List list = areaSecurityUserService.listByAreaId(areaId); + List result = list.stream() + .map(this::convertToRespVO) + .toList(); + return success(result); + } + + @PostMapping("/bind") + @Operation(summary = "绑定安保人员到区域") + @PreAuthorize("@ss.hasPermission('ops:security-area-user:create')") + public CommonResult bind(@Valid @RequestBody OpsAreaSecurityUserBindReqVO reqVO) { + Long id = areaSecurityUserService.bindUser( + reqVO.getAreaId(), reqVO.getUserId(), reqVO.getUserName(), + reqVO.getTeamId(), reqVO.getSort()); + return success(id); + } + + @PutMapping("/update") + @Operation(summary = "更新绑定信息") + @PreAuthorize("@ss.hasPermission('ops:security-area-user:update')") + public CommonResult update(@Valid @RequestBody OpsAreaSecurityUserUpdateReqVO reqVO) { + areaSecurityUserService.updateBinding(reqVO.getId(), reqVO.getEnabled(), reqVO.getSort(), reqVO.getTeamId()); + return success(true); + } + + @DeleteMapping("/unbind") + @Operation(summary = "解除绑定") + @PreAuthorize("@ss.hasPermission('ops:security-area-user:delete')") + public CommonResult unbind( + @Parameter(description = "绑定记录ID", required = true) @RequestParam("id") Long id) { + areaSecurityUserService.unbindUser(id); + return success(true); + } + + private OpsAreaSecurityUserRespVO convertToRespVO(OpsAreaSecurityUserDO entity) { + OpsAreaSecurityUserRespVO vo = new OpsAreaSecurityUserRespVO(); + vo.setId(entity.getId()); + vo.setAreaId(entity.getAreaId()); + vo.setUserId(entity.getUserId()); + vo.setUserName(entity.getUserName()); + vo.setTeamId(entity.getTeamId()); + vo.setEnabled(entity.getEnabled()); + vo.setSort(entity.getSort()); + vo.setCreateTime(entity.getCreateTime()); + return vo; + } + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/SecurityOrderController.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/SecurityOrderController.java new file mode 100644 index 0000000..948ccc7 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/SecurityOrderController.java @@ -0,0 +1,93 @@ +package com.viewsh.module.ops.controller.admin.security; + +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.framework.security.core.util.SecurityFrameworkUtils; +import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderAutoCompleteReqVO; +import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderCompleteReqVO; +import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderConfirmReqVO; +import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderCreateReqVO; +import com.viewsh.module.ops.security.service.securityorder.SecurityOrderCompleteReqDTO; +import com.viewsh.module.ops.security.service.securityorder.SecurityOrderCreateReqDTO; +import com.viewsh.module.ops.security.service.securityorder.SecurityOrderService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import static com.viewsh.framework.common.pojo.CommonResult.success; + +/** + * 安保工单管理(Admin) + *

    + * 后台管理端安保工单接口: + * - 创建安保工单 + * - 确认工单 + * - 自动完单 + * - 人工提交结果完单 + * + * @author lzh + */ +@Tag(name = "安保工单") +@RestController +@RequestMapping("/ops/security/order") +@Validated +public class SecurityOrderController { + + @Resource + private SecurityOrderService securityOrderService; + + @PostMapping("/create") + @Operation(summary = "创建安保工单", description = "管理端创建安保工单,传入告警信息和区域,系统自动分配安保人员") + @PreAuthorize("@ss.hasPermission('ops:security-order:create')") + public CommonResult createOrder(@Valid @RequestBody SecurityOrderCreateReqVO reqVO) { + SecurityOrderCreateReqDTO dto = SecurityOrderCreateReqDTO.builder() + .title(reqVO.getTitle()) + .description(reqVO.getDescription()) + .priority(reqVO.getPriority()) + .areaId(reqVO.getAreaId()) + .location(reqVO.getLocation()) + .alarmId(reqVO.getAlarmId()) + .alarmType(reqVO.getAlarmType()) + .cameraId(reqVO.getCameraId()) + .roiId(reqVO.getRoiId()) + .imageUrl(reqVO.getImageUrl()) + .sourceType(reqVO.getSourceType()) + .build(); + Long orderId = securityOrderService.createSecurityOrder(dto); + return success(orderId); + } + + @PostMapping("/confirm") + @Operation(summary = "确认工单", description = "安保人员确认接单") + @PreAuthorize("@ss.hasPermission('ops:security-order:confirm')") + public CommonResult confirmOrder(@Valid @RequestBody SecurityOrderConfirmReqVO reqVO) { + securityOrderService.confirmOrder(reqVO.getOrderId(), reqVO.getUserId()); + return success(true); + } + + @PostMapping("/auto-complete") + @Operation(summary = "自动完单", description = "由外部系统调用,无需提交处理结果") + @PreAuthorize("@ss.hasPermission('ops:security-order:complete')") + public CommonResult autoCompleteOrder(@Valid @RequestBody SecurityOrderAutoCompleteReqVO reqVO) { + securityOrderService.autoCompleteOrder(reqVO.getOrderId(), reqVO.getRemark()); + return success(true); + } + + @PostMapping("/manual-complete") + @Operation(summary = "人工完单", description = "安保人员提交处理结果(result + 图片)完成工单") + @PreAuthorize("@ss.hasPermission('ops:security-order:complete')") + public CommonResult manualCompleteOrder(@Valid @RequestBody SecurityOrderCompleteReqVO reqVO) { + SecurityOrderCompleteReqDTO dto = SecurityOrderCompleteReqDTO.builder() + .orderId(reqVO.getOrderId()) + .result(reqVO.getResult()) + .resultImgUrls(reqVO.getResultImgUrls()) + .operatorId(SecurityFrameworkUtils.getLoginUserId()) + .build(); + securityOrderService.manualCompleteOrder(dto); + return success(true); + } + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/OpsAreaSecurityUserBindReqVO.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/OpsAreaSecurityUserBindReqVO.java new file mode 100644 index 0000000..c5a6865 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/OpsAreaSecurityUserBindReqVO.java @@ -0,0 +1,33 @@ +package com.viewsh.module.ops.controller.admin.security.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 区域安保人员绑定请求 VO + * + * @author lzh + */ +@Schema(description = "区域安保人员绑定请求") +@Data +public class OpsAreaSecurityUserBindReqVO { + + @Schema(description = "区域ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "100") + @NotNull(message = "区域ID不能为空") + private Long areaId; + + @Schema(description = "安保人员用户ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "2001") + @NotNull(message = "用户ID不能为空") + private Long userId; + + @Schema(description = "安保人员姓名", example = "张三") + private String userName; + + @Schema(description = "班组ID", example = "10") + private Long teamId; + + @Schema(description = "排序值", example = "0") + private Integer sort; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/OpsAreaSecurityUserRespVO.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/OpsAreaSecurityUserRespVO.java new file mode 100644 index 0000000..d733502 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/OpsAreaSecurityUserRespVO.java @@ -0,0 +1,41 @@ +package com.viewsh.module.ops.controller.admin.security.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 区域安保人员绑定响应 VO + * + * @author lzh + */ +@Schema(description = "区域安保人员绑定响应") +@Data +public class OpsAreaSecurityUserRespVO { + + @Schema(description = "绑定记录ID", example = "1") + private Long id; + + @Schema(description = "区域ID", example = "100") + private Long areaId; + + @Schema(description = "安保人员用户ID", example = "2001") + private Long userId; + + @Schema(description = "安保人员姓名", example = "张三") + private String userName; + + @Schema(description = "班组ID", example = "10") + private Long teamId; + + @Schema(description = "是否启用", example = "true") + private Boolean enabled; + + @Schema(description = "排序值", example = "0") + private Integer sort; + + @Schema(description = "创建时间") + private LocalDateTime createTime; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/OpsAreaSecurityUserUpdateReqVO.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/OpsAreaSecurityUserUpdateReqVO.java new file mode 100644 index 0000000..aff9778 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/OpsAreaSecurityUserUpdateReqVO.java @@ -0,0 +1,29 @@ +package com.viewsh.module.ops.controller.admin.security.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 区域安保人员绑定更新请求 VO + * + * @author lzh + */ +@Schema(description = "区域安保人员绑定更新请求") +@Data +public class OpsAreaSecurityUserUpdateReqVO { + + @Schema(description = "绑定记录ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "绑定记录ID不能为空") + private Long id; + + @Schema(description = "是否启用", example = "true") + private Boolean enabled; + + @Schema(description = "排序值", example = "0") + private Integer sort; + + @Schema(description = "班组ID", example = "10") + private Long teamId; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderAutoCompleteReqVO.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderAutoCompleteReqVO.java new file mode 100644 index 0000000..058e6b4 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderAutoCompleteReqVO.java @@ -0,0 +1,23 @@ +package com.viewsh.module.ops.controller.admin.security.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 安保工单自动完单请求 VO + * + * @author lzh + */ +@Schema(description = "安保工单自动完单请求") +@Data +public class SecurityOrderAutoCompleteReqVO { + + @Schema(description = "工单ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001") + @NotNull(message = "工单ID不能为空") + private Long orderId; + + @Schema(description = "备注", example = "告警自动解除") + private String remark; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderCompleteReqVO.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderCompleteReqVO.java new file mode 100644 index 0000000..1d4dd0b --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderCompleteReqVO.java @@ -0,0 +1,30 @@ +package com.viewsh.module.ops.controller.admin.security.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +/** + * 安保工单人工完单请求 VO + * + * @author lzh + */ +@Schema(description = "安保工单人工完单请求") +@Data +public class SecurityOrderCompleteReqVO { + + @Schema(description = "工单ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001") + @NotNull(message = "工单ID不能为空") + private Long orderId; + + @Schema(description = "处理结果描述", requiredMode = Schema.RequiredMode.REQUIRED, example = "已到现场排查,系误报") + @NotBlank(message = "处理结果不能为空") + private String result; + + @Schema(description = "处理结果图片URL列表", example = "[\"https://oss.example.com/result1.jpg\"]") + private List resultImgUrls; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderConfirmReqVO.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderConfirmReqVO.java new file mode 100644 index 0000000..8e04f6a --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderConfirmReqVO.java @@ -0,0 +1,24 @@ +package com.viewsh.module.ops.controller.admin.security.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 安保工单确认请求 VO + * + * @author lzh + */ +@Schema(description = "安保工单确认请求") +@Data +public class SecurityOrderConfirmReqVO { + + @Schema(description = "工单ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001") + @NotNull(message = "工单ID不能为空") + private Long orderId; + + @Schema(description = "安保人员user_id", requiredMode = Schema.RequiredMode.REQUIRED, example = "2001") + @NotNull(message = "用户ID不能为空") + private Long userId; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderCreateReqVO.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderCreateReqVO.java new file mode 100644 index 0000000..3719314 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderCreateReqVO.java @@ -0,0 +1,54 @@ +package com.viewsh.module.ops.controller.admin.security.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 安保工单创建请求 VO + * + * @author lzh + */ +@Schema(description = "安保工单创建请求") +@Data +public class SecurityOrderCreateReqVO { + + @Schema(description = "工单标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "A栋3层入侵告警") + @NotBlank(message = "工单标题不能为空") + private String title; + + @Schema(description = "工单描述", example = "摄像头检测到异常人员入侵") + private String description; + + @Schema(description = "优先级(0=P0紧急 1=P1重要 2=P2普通)", example = "1") + private Integer priority; + + @Schema(description = "区域ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "100") + @NotNull(message = "区域ID不能为空") + private Long areaId; + + @Schema(description = "具体位置描述", example = "A栋3层东侧走廊") + private String location; + + // ==================== 告警来源 ==================== + + @Schema(description = "关联告警ID", example = "ALM20260211001") + private String alarmId; + + @Schema(description = "告警类型: intrusion/leave_post/fire/fence", example = "intrusion") + private String alarmType; + + @Schema(description = "摄像头ID", example = "CAM_001") + private String cameraId; + + @Schema(description = "ROI区域ID", example = "ROI_001") + private String roiId; + + @Schema(description = "告警截图URL", example = "https://oss.example.com/alarm/snapshot.jpg") + private String imageUrl; + + @Schema(description = "来源类型(ALARM=告警触发/MANUAL=手动创建)", example = "ALARM") + private String sourceType; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/open/security/SecurityOrderOpenController.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/open/security/SecurityOrderOpenController.java new file mode 100644 index 0000000..fd550ca --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/open/security/SecurityOrderOpenController.java @@ -0,0 +1,72 @@ +package com.viewsh.module.ops.controller.open.security; + +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.framework.signature.core.annotation.ApiSignature; +import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderAutoCompleteReqVO; +import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderCreateReqVO; +import com.viewsh.module.ops.security.service.securityorder.SecurityOrderCreateReqDTO; +import com.viewsh.module.ops.security.service.securityorder.SecurityOrderService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.annotation.security.PermitAll; +import jakarta.validation.Valid; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static com.viewsh.framework.common.pojo.CommonResult.success; + +/** + * 安保工单 - 开放接口 + *

    + * 提供给外部告警系统调用,通过 {@link ApiSignature} 签名验证保护, + * 不走用户登录鉴权(Token)。 + *

    + * 实际路径前缀为 /open-api,由框架自动添加 + * + * @author lzh + */ +@Tag(name = "安保工单 - 开放接口") +@RestController +@RequestMapping("/ops/security/order") +@Validated +public class SecurityOrderOpenController { + + @Resource + private SecurityOrderService securityOrderService; + + @PostMapping("/create") + @Operation(summary = "创建安保工单", description = "由外部告警系统调用,传入告警信息和区域,系统自动分配安保人员") + @ApiSignature + @PermitAll + public CommonResult createOrder(@Valid @RequestBody SecurityOrderCreateReqVO reqVO) { + SecurityOrderCreateReqDTO dto = SecurityOrderCreateReqDTO.builder() + .title(reqVO.getTitle()) + .description(reqVO.getDescription()) + .priority(reqVO.getPriority()) + .areaId(reqVO.getAreaId()) + .location(reqVO.getLocation()) + .alarmId(reqVO.getAlarmId()) + .alarmType(reqVO.getAlarmType()) + .cameraId(reqVO.getCameraId()) + .roiId(reqVO.getRoiId()) + .imageUrl(reqVO.getImageUrl()) + .sourceType(reqVO.getSourceType()) + .build(); + Long orderId = securityOrderService.createSecurityOrder(dto); + return success(orderId); + } + + @PostMapping("/auto-complete") + @Operation(summary = "自动完单", description = "由外部告警系统调用,无需提交处理结果") + @ApiSignature + @PermitAll + public CommonResult autoCompleteOrder(@Valid @RequestBody SecurityOrderAutoCompleteReqVO req) { + securityOrderService.autoCompleteOrder(req.getOrderId(), req.getRemark()); + return success(true); + } + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/security/config/SecurityConfiguration.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/security/config/SecurityConfiguration.java index bd01ea6..fee3468 100644 --- a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/security/config/SecurityConfiguration.java +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/security/config/SecurityConfiguration.java @@ -8,7 +8,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; /** - * IoT 模块的 Security 配置 + * Ops 模块的 Security 配置 */ @Configuration("opsSecurityConfiguration") public class SecurityConfiguration { @@ -31,6 +31,8 @@ public class SecurityConfiguration { registry.requestMatchers("/druid/**").anonymous(); // RPC 服务的安全配置 registry.requestMatchers(ApiConstants.PREFIX + "/**").permitAll(); + // Open API 接口放行(由 @ApiSignature 签名保护) + registry.requestMatchers(buildOpenApi("/**")).permitAll(); } }; From 4a7128321eb71e43b9d62b41c61a964df39ccfad Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 11 Mar 2026 17:33:55 +0800 Subject: [PATCH 12/35] =?UTF-8?q?feat(ops):=20=E5=AE=89=E4=BF=9D=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E6=9E=84=E5=BB=BA=E9=85=8D=E7=BD=AE=E4=B8=8E=E6=9E=9A?= =?UTF-8?q?=E4=B8=BE=E6=89=A9=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - security-biz pom 新增 ops-biz、iot-api 依赖 - ops-server pom 引入 security-biz 模块 - 新增 SECURITY_GUARD 操作人类型、ALARM 来源类型 - 新增安保相关错误码 - dev/local 配置新增安保数据源 Co-Authored-By: Claude Opus 4.6 --- .../module/ops/enums/ErrorCodeConstants.java | 6 ++++ .../module/ops/enums/OperatorTypeEnum.java | 3 +- .../module/ops/enums/SourceTypeEnum.java | 3 +- .../viewsh-module-ops-server/pom.xml | 28 +++++++++++-------- .../src/main/resources/application-dev.yaml | 5 ++++ .../src/main/resources/application-local.yaml | 5 ++++ .../viewsh-module-security-biz/pom.xml | 7 +++++ 7 files changed, 44 insertions(+), 13 deletions(-) diff --git a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/ErrorCodeConstants.java b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/ErrorCodeConstants.java index ca60e2c..c6e7341 100644 --- a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/ErrorCodeConstants.java +++ b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/ErrorCodeConstants.java @@ -16,6 +16,12 @@ public interface ErrorCodeConstants { ErrorCode AREA_PARENT_LOOP = new ErrorCode(1_020_001_003, "不能将父级设置为自己或子孙节点"); ErrorCode AREA_CODE_EXISTS = new ErrorCode(1_020_001_004, "区域编码已存在"); + // ========== 安保工单 1-020-003-000 ============ + ErrorCode SECURITY_ORDER_NOT_FOUND = new ErrorCode(1_020_003_000, "工单不存在"); + ErrorCode SECURITY_ORDER_TYPE_MISMATCH = new ErrorCode(1_020_003_001, "工单类型不匹配,期望安保工单"); + ErrorCode SECURITY_AREA_USER_DUPLICATE = new ErrorCode(1_020_003_002, "该安保人员已绑定到此区域"); + ErrorCode SECURITY_AREA_USER_NOT_FOUND = new ErrorCode(1_020_003_003, "绑定记录不存在"); + // ========== 区域设备关联 1-020-002-000 ============ ErrorCode DEVICE_NOT_FOUND = new ErrorCode(1_020_002_000, "设备不存在"); ErrorCode DEVICE_ALREADY_BOUND = new ErrorCode(1_020_002_001, "该工牌已绑定至此区域"); diff --git a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/OperatorTypeEnum.java b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/OperatorTypeEnum.java index 740d3cb..9736241 100644 --- a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/OperatorTypeEnum.java +++ b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/OperatorTypeEnum.java @@ -18,7 +18,8 @@ public enum OperatorTypeEnum implements ArrayValuable { SYSTEM("SYSTEM", "系统"), CLEANER("CLEANER", "保洁员"), INSPECTOR("INSPECTOR", "巡检员"), - ADMIN("ADMIN", "管理员"); + ADMIN("ADMIN", "管理员"), + SECURITY_GUARD("SECURITY_GUARD", "安保员"); public static final String[] ARRAYS = Arrays.stream(values()).map(OperatorTypeEnum::getType).toArray(String[]::new); diff --git a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/SourceTypeEnum.java b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/SourceTypeEnum.java index a358e8b..bd7b013 100644 --- a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/SourceTypeEnum.java +++ b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/SourceTypeEnum.java @@ -18,7 +18,8 @@ public enum SourceTypeEnum implements ArrayValuable { TRAFFIC("TRAFFIC", "系统触发"), INSPECTION("INSPECTION", "巡检发现"), MANUAL("MANUAL", "手动创建"), - SCHEDULE("SCHEDULE", "定时排班"); + SCHEDULE("SCHEDULE", "定时排班"), + ALARM("ALARM", "告警触发"); public static final String[] ARRAYS = Arrays.stream(values()).map(SourceTypeEnum::getType).toArray(String[]::new); diff --git a/viewsh-module-ops/viewsh-module-ops-server/pom.xml b/viewsh-module-ops/viewsh-module-ops-server/pom.xml index 1ec8495..716a022 100644 --- a/viewsh-module-ops/viewsh-module-ops-server/pom.xml +++ b/viewsh-module-ops/viewsh-module-ops-server/pom.xml @@ -91,6 +91,12 @@ viewsh-spring-boot-starter-security + + + com.viewsh + viewsh-spring-boot-starter-protection + + com.viewsh @@ -126,17 +132,17 @@ viewsh-spring-boot-starter-job - - - com.viewsh - viewsh-spring-boot-starter-mq - - - org.apache.rocketmq - rocketmq-spring-boot-starter - - - + + + com.viewsh + viewsh-spring-boot-starter-mq + + + org.apache.rocketmq + rocketmq-spring-boot-starter + + + com.viewsh viewsh-spring-boot-starter-test diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/resources/application-dev.yaml b/viewsh-module-ops/viewsh-module-ops-server/src/main/resources/application-dev.yaml index acc3c5d..451f5f4 100644 --- a/viewsh-module-ops/viewsh-module-ops-server/src/main/resources/application-dev.yaml +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/resources/application-dev.yaml @@ -164,6 +164,11 @@ wx: # 芋道配置项,设置当前项目所有自定义的配置 viewsh: demo: true # 开启演示模式 + # API 签名配置:外部系统调用开放接口时使用(如安保工单的告警系统) + signature: + apps: + # 告警系统 - 用于安保工单的创建和自动完单接口 + alarm-system: "tQ3v5q1z2ZLu7hrU1yseaHwg1wJUcmF1" justauth: enabled: true diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/resources/application-local.yaml b/viewsh-module-ops/viewsh-module-ops-server/src/main/resources/application-local.yaml index 63f45f8..d1e926f 100644 --- a/viewsh-module-ops/viewsh-module-ops-server/src/main/resources/application-local.yaml +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/resources/application-local.yaml @@ -152,3 +152,8 @@ viewsh: mock-enable: true access-log: # 访问日志的配置项 enable: false + # API 签名配置:外部系统调用开放接口时使用(如安保工单的告警系统) + signature: + apps: + # 告警系统 - 用于安保工单的创建和自动完单接口 + alarm-system: "tQ3v5q1z2ZLu7hrU1yseaHwg1wJUcmF1" diff --git a/viewsh-module-ops/viewsh-module-security-biz/pom.xml b/viewsh-module-ops/viewsh-module-security-biz/pom.xml index b24d752..4203daf 100644 --- a/viewsh-module-ops/viewsh-module-security-biz/pom.xml +++ b/viewsh-module-ops/viewsh-module-security-biz/pom.xml @@ -48,5 +48,12 @@ com.viewsh viewsh-spring-boot-starter-biz-tenant + + + + com.viewsh + viewsh-spring-boot-starter-test + test + From fc9393e723f343c6a0994545f2d24f5a0d1a8ef1 Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 11 Mar 2026 17:34:08 +0800 Subject: [PATCH 13/35] =?UTF-8?q?refactor(ops):=20=E6=89=A9=E5=B1=95=20Log?= =?UTF-8?q?Type=20=E6=9E=9A=E4=B8=BE=EF=BC=8C=E8=A1=A5=E5=85=A8=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E4=B8=8E=20IoT=20?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E4=BA=8B=E4=BB=B6=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 ORDER_CREATED/CONFIRM/ARRIVED/COMPLETED 等工单生命周期枚举、 BEACON_ARRIVE_CONFIRMED/BEACON_COMPLETE_REQUESTED 等 IoT 审计枚举, 添加 getByCode() 反查方法支持中文 title 映射。 同步新增 LogModule 常量类收口模块标识。 Co-Authored-By: Claude Opus 4.6 --- .../log/enumeration/LogModule.java | 46 +++++++++++ .../log/enumeration/LogType.java | 80 +++++++++++-------- 2 files changed, 94 insertions(+), 32 deletions(-) create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/enumeration/LogModule.java diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/enumeration/LogModule.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/enumeration/LogModule.java new file mode 100644 index 0000000..daebabf --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/enumeration/LogModule.java @@ -0,0 +1,46 @@ +package com.viewsh.module.ops.infrastructure.log.enumeration; + +/** + * 日志模块常量 + *

    + * 用于 {@link com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecord} 的 module 字段, + * 避免各业务线硬编码字符串。 + * + * @author lzh + */ +public final class LogModule { + + /** 保洁 */ + public static final String CLEAN = "clean"; + + /** 安保 */ + public static final String SECURITY = "security"; + + /** 工程 @since 2026-03-11 预留 */ + public static final String FACILITIES = "facilities"; + + /** 客服 @since 2026-03-11 预留 */ + public static final String SERVICE = "service"; + + /** + * 根据工单类型(orderType)返回对应的日志模块标识。 + * 未知类型降级返回 orderType 小写。 + * + * @param orderType 工单类型,如 "CLEAN"、"SECURITY" + * @return 日志模块标识 + */ + public static String fromOrderType(String orderType) { + if (orderType == null) { + return CLEAN; + } + return switch (orderType) { + case "CLEAN" -> CLEAN; + case "SECURITY" -> SECURITY; + case "REPAIR" -> FACILITIES; + case "SERVICE" -> SERVICE; + default -> orderType.toLowerCase(); + }; + } + + private LogModule() {} +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/enumeration/LogType.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/enumeration/LogType.java index 93efae3..9fc32e9 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/enumeration/LogType.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/enumeration/LogType.java @@ -1,5 +1,8 @@ package com.viewsh.module.ops.infrastructure.log.enumeration; +import java.util.HashMap; +import java.util.Map; + /** * 日志类型枚举 * @@ -7,45 +10,51 @@ package com.viewsh.module.ops.infrastructure.log.enumeration; */ public enum LogType { - /** - * 派单日志 - */ - DISPATCH("ORDER_DISPATCHED", "派单"), + // ========== 注解体系(@BusinessLog)使用 ========== - /** - * 状态转换日志 - */ - TRANSITION("ORDER_STATUS_CHANGED", "状态转换"), + /** 派单 */ + ORDER_DISPATCHED("ORDER_DISPATCHED", "派单"), + /** 系统事件(注解默认值) */ + SYSTEM_EVENT("SYSTEM_EVENT", "系统"), - /** - * 生命周期日志 - */ - LIFECYCLE("ORDER_LIFECYCLE", "生命周期"), + // ========== 工单生命周期 ========== - /** - * 队列日志 - */ - QUEUE("ORDER_QUEUE_CHANGED", "队列"), + ORDER_CREATED("ORDER_CREATED", "工单创建"), + ORDER_CONFIRM("ORDER_CONFIRM", "工单确认"), + ORDER_ARRIVED("ORDER_ARRIVED", "到岗确认"), + ORDER_COMPLETED("ORDER_COMPLETED", "工单完成"), + ORDER_INTERRUPTED("ORDER_INTERRUPTED", "工单打断"), + ORDER_CANCELLED("ORDER_CANCELLED", "工单取消"), + ORDER_PAUSED("ORDER_PAUSED", "工单暂停"), + ORDER_RESUMED("ORDER_RESUMED", "工单恢复"), - /** - * 保洁员日志 - */ - CLEANER("CLEANER_ACTION", "保洁员"), + // ========== 语音播报 ========== - /** - * 设备日志 - */ - DEVICE("DEVICE_ACTION", "设备"), + TTS_SENT("TTS_SENT", "语音播报"), + TTS_FAILED("TTS_FAILED", "播报失败"), - /** - * 通知日志 - */ - NOTIFICATION("NOTIFICATION_SENT", "通知"), + // ========== 优先级 & 静默 ========== - /** - * 系统日志 - */ - SYSTEM("SYSTEM_EVENT", "系统"); + PRIORITY_UPGRADE("PRIORITY_UPGRADE", "优先级升级"), + PRIORITY_CEILING("PRIORITY_CEILING", "优先级封顶"), + ARRIVED_SILENT_IGNORE("ARRIVED_SILENT_IGNORE", "静默忽略"), + + // ========== IoT 审计事件(来自 ops-order-audit MQ) ========== + + BEACON_ARRIVE_CONFIRMED("BEACON_ARRIVE_CONFIRMED", "信标到岗确认"), + BEACON_LEAVE_WARNING_SENT("BEACON_LEAVE_WARNING_SENT", "离开区域警告"), + COMPLETE_SUPPRESSED_INVALID("COMPLETE_SUPPRESSED_INVALID", "作业时长不足抑制"), + BEACON_COMPLETE_REQUESTED("BEACON_COMPLETE_REQUESTED", "信号丢失自动完成请求"), + TTS_REQUEST("TTS_REQUEST", "语音播报请求"), + ARRIVE_REJECTED("ARRIVE_REJECTED", "到岗请求被拒绝"); + + private static final Map CODE_MAP = new HashMap<>(); + + static { + for (LogType type : values()) { + CODE_MAP.put(type.code, type); + } + } private final String code; private final String description; @@ -62,4 +71,11 @@ public enum LogType { public String getDescription() { return description; } + + /** + * 根据 code 反查枚举,找不到返回 null + */ + public static LogType getByCode(String code) { + return CODE_MAP.get(code); + } } From dc75c78d169b7291acd9c298fc65a7a29930b4fc Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 11 Mar 2026 17:34:19 +0800 Subject: [PATCH 14/35] =?UTF-8?q?fix(ops):=20=E4=BF=AE=E5=A4=8D=E4=B8=9A?= =?UTF-8?q?=E5=8A=A1=E6=97=A5=E5=BF=97=20title=20=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E8=8B=B1=E6=96=87=E5=B8=B8=E9=87=8F=EF=BC=8C=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E4=B8=AD=E6=96=87=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /business-logs 接口 title 回退取 eventType 时,通过 LogType.getByCode() 映射中文 description 作为标题。同步调整 @BusinessLog 注解 type 属性 改用 LogType 枚举。 Co-Authored-By: Claude Opus 4.6 --- .../log/annotation/BusinessLog.java | 6 ++-- .../log/aspect/BusinessLogAspect.java | 29 +++++++++---------- .../service/order/OpsOrderServiceImpl.java | 7 +++-- 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/annotation/BusinessLog.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/annotation/BusinessLog.java index e2790cc..1d16348 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/annotation/BusinessLog.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/annotation/BusinessLog.java @@ -16,14 +16,14 @@ import java.lang.annotation.*; *

      * {@code
      * // 基础用法(使用 LogType/LogScope)
    - * @BusinessLog(type = LogType.DISPATCH, scope = LogScope.ORDER,
    + * @BusinessLog(type = LogType.ORDER_DISPATCHED, scope = LogScope.ORDER,
      *             description = "自动派单", includeParams = true)
      * public DispatchResult dispatch(OrderDispatchContext context) {
      *     // ...
      * }
      *
      * // 新用法(直接指定 EventDomain 和 Module)
    - * @BusinessLog(module = "clean", domain = EventDomain.DEVICE,
    + * @BusinessLog(module = LogModule.CLEAN, domain = EventDomain.DEVICE,
      *             eventType = "TTS_SENT", description = "语音播报",
      *             deviceId = "#deviceId", personId = "#context.cleanerId")
      * public void broadcast(String text, Long deviceId) {
    @@ -46,7 +46,7 @@ public @interface BusinessLog {
          * 

    * 当指定了 domain 和 eventType 时,此字段可忽略 */ - LogType type() default LogType.SYSTEM; + LogType type() default LogType.SYSTEM_EVENT; /** * 日志作用域(旧版,保持兼容) diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/aspect/BusinessLogAspect.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/aspect/BusinessLogAspect.java index e4f692c..2c514ab 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/aspect/BusinessLogAspect.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/aspect/BusinessLogAspect.java @@ -330,21 +330,20 @@ public class BusinessLogAspect { return EventDomain.SYSTEM; } - switch (logType) { - case DISPATCH: - return EventDomain.DISPATCH; - case DEVICE: - return EventDomain.DEVICE; - case NOTIFICATION: - return EventDomain.DEVICE; - case CLEANER: - case SYSTEM: - case QUEUE: - case LIFECYCLE: - case TRANSITION: - default: - return EventDomain.SYSTEM; - } + return switch (logType) { + case ORDER_DISPATCHED, ORDER_INTERRUPTED, ORDER_PAUSED, ORDER_RESUMED -> + EventDomain.DISPATCH; + case ORDER_ARRIVED, ORDER_COMPLETED -> + EventDomain.BEACON; + case ORDER_CONFIRM -> + EventDomain.DEVICE; + case PRIORITY_UPGRADE, PRIORITY_CEILING, ARRIVED_SILENT_IGNORE -> + EventDomain.TRAFFIC; + case TTS_SENT, TTS_FAILED -> + EventDomain.DEVICE; + default -> + EventDomain.SYSTEM; + }; } /** diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/order/OpsOrderServiceImpl.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/order/OpsOrderServiceImpl.java index 96330da..23cd956 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/order/OpsOrderServiceImpl.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/order/OpsOrderServiceImpl.java @@ -10,6 +10,7 @@ import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper; import com.viewsh.module.ops.enums.OperatorTypeEnum; import com.viewsh.module.ops.enums.PriorityEnum; import com.viewsh.module.ops.enums.WorkOrderStatusEnum; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogType; import com.viewsh.module.ops.service.fsm.OrderStateMachine; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -364,10 +365,12 @@ public class OpsOrderServiceImpl implements OpsOrderService { } dto.setType(type); - // title: 优先使用 eventSummary,否则使用 eventType + // title: 优先使用 eventSummary,否则通过 LogType 枚举取中文描述 String title = logDO.getEventSummary(); if (title == null || title.isEmpty()) { - title = logDO.getEventType() != null ? logDO.getEventType() : "工单操作"; + LogType logType = LogType.getByCode(logDO.getEventType()); + title = logType != null ? logType.getDescription() + : (logDO.getEventType() != null ? logDO.getEventType() : "工单操作"); } dto.setTitle(title); From 5f804605c7b7a0492d4135900233fbde91bd5969 Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 11 Mar 2026 17:34:30 +0800 Subject: [PATCH 15/35] =?UTF-8?q?refactor(ops):=20=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=E6=95=A3=E8=90=BD=E7=9A=84=20eventType=20=E7=A1=AC=E7=BC=96?= =?UTF-8?q?=E7=A0=81=E4=B8=BA=20LogType=20=E6=9E=9A=E4=B8=BE=E5=BC=95?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 替换 CleanOrderCreateEventHandler、OrderLifecycleManagerImpl、 DispatchEngineImpl 中的字符串常量为 LogType.XXX.getCode(), 同时将 DispatchEngine 的 @BusinessLog description 改为"工单自动派发"。 Co-Authored-By: Claude Opus 4.6 --- .../consumer/CleanOrderCreateEventHandler.java | 18 ++++++++++-------- .../ops/core/dispatch/DispatchEngineImpl.java | 4 ++-- .../lifecycle/OrderLifecycleManagerImpl.java | 18 ++++++++++-------- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/CleanOrderCreateEventHandler.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/CleanOrderCreateEventHandler.java index 33ecab7..2e8640e 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/CleanOrderCreateEventHandler.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/CleanOrderCreateEventHandler.java @@ -14,6 +14,8 @@ import com.viewsh.module.ops.environment.integration.dto.CleanOrderCreateEventDT import com.viewsh.module.ops.environment.service.cleanorder.CleanOrderService; import com.viewsh.module.ops.infrastructure.log.enumeration.EventDomain; import com.viewsh.module.ops.infrastructure.log.enumeration.EventLevel; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogModule; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogType; import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecord; import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecorder; import jakarta.annotation.Resource; @@ -318,9 +320,9 @@ public class CleanOrderCreateEventHandler implements RocketMQListener { } eventLogRecorder.record(EventLogRecord.builder() - .module("clean") + .module(LogModule.CLEAN) .domain(domain) - .eventType("ORDER_CREATED") + .eventType(LogType.ORDER_CREATED.getCode()) .message(buildLogMessage(event, createReq)) .targetId(orderId) .targetType("order") @@ -346,9 +348,9 @@ public class CleanOrderCreateEventHandler implements RocketMQListener { extra.put("reason", "客流持续达标自动升级"); eventLogRecorder.record(EventLogRecord.builder() - .module("clean") + .module(LogModule.CLEAN) .domain(EventDomain.TRAFFIC) - .eventType("PRIORITY_UPGRADE") + .eventType(LogType.PRIORITY_UPGRADE.getCode()) .message(String.format("客流持续达标,工单优先级升级至%s [区域:%d]", newPriority.getDescription(), event.getAreaId())) .targetId(orderId) @@ -374,9 +376,9 @@ public class CleanOrderCreateEventHandler implements RocketMQListener { extra.put("reason", "已是P0最高优先级,无法继续升级"); eventLogRecorder.record(EventLogRecord.builder() - .module("clean") + .module(LogModule.CLEAN) .domain(EventDomain.TRAFFIC) - .eventType("PRIORITY_CEILING") + .eventType(LogType.PRIORITY_CEILING.getCode()) .message(String.format("客流持续达标但工单已是P0封顶 [区域:%d]", event.getAreaId())) .targetId(orderId) .targetType("order") @@ -401,9 +403,9 @@ public class CleanOrderCreateEventHandler implements RocketMQListener { extra.put("reason", "保洁员已在处理中,客流触发静默忽略"); eventLogRecorder.record(EventLogRecord.builder() - .module("clean") + .module(LogModule.CLEAN) .domain(EventDomain.TRAFFIC) - .eventType("ARRIVED_SILENT_IGNORE") + .eventType(LogType.ARRIVED_SILENT_IGNORE.getCode()) .message(String.format("保洁员已在处理中,客流触发静默忽略 [区域:%d]", event.getAreaId())) .targetId(orderId) .targetType("order") diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/dispatch/DispatchEngineImpl.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/dispatch/DispatchEngineImpl.java index 2365319..f109557 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/dispatch/DispatchEngineImpl.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/dispatch/DispatchEngineImpl.java @@ -77,9 +77,9 @@ public class DispatchEngineImpl implements DispatchEngine { @Override @BusinessLog( - type = LogType.DISPATCH, + type = LogType.ORDER_DISPATCHED, scope = LogScope.ORDER, - description = "工单调度", + description = "工单自动派发", includeParams = true, includeResult = true, result = "#result.success", diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/lifecycle/OrderLifecycleManagerImpl.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/lifecycle/OrderLifecycleManagerImpl.java index 10f86af..e91d099 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/lifecycle/OrderLifecycleManagerImpl.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/lifecycle/OrderLifecycleManagerImpl.java @@ -13,6 +13,8 @@ import com.viewsh.module.ops.enums.OperatorTypeEnum; import com.viewsh.module.ops.enums.PriorityEnum; import com.viewsh.module.ops.enums.WorkOrderStatusEnum; import com.viewsh.module.ops.infrastructure.log.enumeration.EventDomain; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogModule; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogType; import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecord; import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecorder; import jakarta.annotation.PostConstruct; @@ -180,7 +182,7 @@ public class OrderLifecycleManagerImpl implements OrderLifecycleManager { } // 记录业务日志 - recordStatusChangeLog(orderId, result, "ORDER_PAUSED", "工单暂停"); + recordStatusChangeLog(orderId, result, LogType.ORDER_PAUSED.getCode(), "工单暂停"); } @Override @@ -205,7 +207,7 @@ public class OrderLifecycleManagerImpl implements OrderLifecycleManager { } // 记录业务日志 - recordStatusChangeLog(orderId, result, "ORDER_RESUMED", "工单恢复"); + recordStatusChangeLog(orderId, result, LogType.ORDER_RESUMED.getCode(), "工单恢复"); } // ==================== 打断/恢复 ==================== @@ -240,9 +242,9 @@ public class OrderLifecycleManagerImpl implements OrderLifecycleManager { OpsOrderDO order = opsOrderMapper.selectById(orderId); eventLogRecorder.record(EventLogRecord.builder() - .module("clean") + .module(LogModule.fromOrderType(order != null ? order.getOrderType() : null)) .domain(EventDomain.DISPATCH) - .eventType("ORDER_INTERRUPTED") + .eventType(LogType.ORDER_INTERRUPTED.getCode()) .message("工单被P0紧急任务打断") .targetId(orderId) .targetType("order") @@ -296,7 +298,7 @@ public class OrderLifecycleManagerImpl implements OrderLifecycleManager { // 注意:IoT 触发的自动完成在 CleanOrderCompleteEventHandler 中记录日志 // 管理员手动完成时记录日志 if (operatorType == OperatorTypeEnum.ADMIN) { - recordStatusChangeLog(orderId, result, "ORDER_COMPLETED_MANUAL", "工单手动完成"); + recordStatusChangeLog(orderId, result, LogType.ORDER_COMPLETED.getCode(), "工单手动完成"); } } @@ -332,9 +334,9 @@ public class OrderLifecycleManagerImpl implements OrderLifecycleManager { extra.put("operatorType", operatorType != null ? operatorType.getType() : "SYSTEM"); eventLogRecorder.record(EventLogRecord.builder() - .module("clean") + .module(LogModule.fromOrderType(order != null ? order.getOrderType() : null)) .domain(EventDomain.SYSTEM) - .eventType("ORDER_CANCELLED") + .eventType(LogType.ORDER_CANCELLED.getCode()) .message("工单已取消: " + reason) .targetId(orderId) .targetType("order") @@ -426,7 +428,7 @@ public class OrderLifecycleManagerImpl implements OrderLifecycleManager { extra.put("newStatus", result.getNewStatus() != null ? result.getNewStatus().getStatus() : null); eventLogRecorder.record(EventLogRecord.builder() - .module("clean") + .module(LogModule.fromOrderType(order.getOrderType())) .domain(EventDomain.DISPATCH) .eventType(eventType) .message(message) From 6c8c57b932e65c3af557fd910c465f9fa67d0788 Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 11 Mar 2026 17:34:42 +0800 Subject: [PATCH 16/35] =?UTF-8?q?fix(ops):=20=E4=BF=9D=E6=B4=81=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=E6=97=A5=E5=BF=97=E5=8E=BB=E9=87=8D=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=88=B0=E5=B2=97/=E5=AE=8C=E6=88=90=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E8=AE=BE=E5=A4=87=E5=AD=97=E6=AE=B5=E4=B8=BA=20null?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuditEventHandler 跳过 BEACON_ARRIVE_CONFIRMED 和 BEACON_COMPLETE_REQUESTED 审计事件,避免与状态变更日志重复 - recordOrderArrivedLog 当 payload 无 deviceKey 时从工单主表兜底, null 字段不再输出 - recordOrderCompletedLog 同样增加 deviceKey 兜底逻辑 Co-Authored-By: Claude Opus 4.6 --- .../consumer/CleanOrderAuditEventHandler.java | 32 +++++++---- .../listener/CleanOrderEventListener.java | 55 ++++++++++++++++--- 2 files changed, 68 insertions(+), 19 deletions(-) diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/CleanOrderAuditEventHandler.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/CleanOrderAuditEventHandler.java index 8c696e8..051bfb6 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/CleanOrderAuditEventHandler.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/CleanOrderAuditEventHandler.java @@ -11,6 +11,8 @@ import com.viewsh.module.ops.environment.service.voice.TtsQueueMessage; import com.viewsh.module.ops.environment.service.voice.VoiceBroadcastService; import com.viewsh.module.ops.infrastructure.log.enumeration.EventDomain; import com.viewsh.module.ops.infrastructure.log.enumeration.EventLevel; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogModule; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogType; import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecord; import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecorder; import jakarta.annotation.Resource; @@ -112,15 +114,25 @@ public class CleanOrderAuditEventHandler implements RocketMQListener { return; } - // 1. 确定日志级别和域 - EventDomain domain = determineDomain(event.getAuditType()); - EventLevel level = determineLevel(event.getAuditType()); - String eventType = event.getAuditType() != null ? event.getAuditType() : "AUDIT"; + // 1. 跳过与状态变更日志重复的审计事件(到岗确认/自动完成请求已由 CleanOrderEventListener 记录) + String auditType = event.getAuditType(); + if (LogType.BEACON_ARRIVE_CONFIRMED.getCode().equals(auditType) + || LogType.BEACON_COMPLETE_REQUESTED.getCode().equals(auditType)) { + log.debug("[CleanOrderAuditEventHandler] 跳过重复审计事件: eventId={}, auditType={}", + event.getEventId(), auditType); + return; + } - // 2. 记录审计日志 + // 2. 确定日志级别和域 + EventDomain domain = determineDomain(auditType); + EventLevel level = determineLevel(auditType); + LogType logType = auditType != null ? LogType.getByCode(auditType) : null; + String eventType = logType != null ? logType.getCode() : (auditType != null ? auditType : "AUDIT"); + + // 3. 记录审计日志 eventLogRecorder.record( EventLogRecord.builder() - .module("clean") + .module(LogModule.CLEAN) .domain(domain) .eventType(eventType) .message(event.getMessage()) @@ -132,10 +144,10 @@ public class CleanOrderAuditEventHandler implements RocketMQListener { ); log.debug("[CleanOrderAuditEventHandler] 审计日志已记录: eventId={}, auditType={}", - event.getEventId(), event.getAuditType()); + event.getEventId(), auditType); - // 3. 如果是 TTS 请求,调用 IoT 模块下发语音 - if ("TTS_REQUEST".equals(event.getAuditType()) && event.getDeviceId() != null) { + // 2. 如果是 TTS 请求,调用 IoT 模块下发语音 + if (LogType.TTS_REQUEST.getCode().equals(auditType) && event.getDeviceId() != null) { handleTtsRequest(event); } } @@ -227,7 +239,7 @@ public class CleanOrderAuditEventHandler implements RocketMQListener { } if (auditType.startsWith("BEACON_") || auditType.contains("BEACON")) { return EventDomain.BEACON; - } else if (auditType.equals("TTS_REQUEST")) { + } else if (LogType.TTS_REQUEST.getCode().equals(auditType)) { return EventDomain.DEVICE; } else { return EventDomain.AUDIT; diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/listener/CleanOrderEventListener.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/listener/CleanOrderEventListener.java index 785cd29..df3e0db 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/listener/CleanOrderEventListener.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/listener/CleanOrderEventListener.java @@ -20,6 +20,8 @@ import com.viewsh.module.ops.environment.service.cleanorder.CleanOrderService; import com.viewsh.module.ops.environment.service.voice.TtsQueueMessage; import com.viewsh.module.ops.environment.service.voice.VoiceBroadcastService; import com.viewsh.module.ops.infrastructure.log.enumeration.EventDomain; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogModule; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogType; import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecord; import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecorder; import com.viewsh.module.system.api.notify.NotifyMessageSendApi; @@ -930,9 +932,9 @@ public class CleanOrderEventListener { private void recordOrderConfirmedLog(Long orderId, Long deviceId, OrderStateChangedEvent event) { try { eventLogRecorder.record(EventLogRecord.builder() - .module("clean") + .module(LogModule.CLEAN) .domain(EventDomain.DEVICE) - .eventType("ORDER_CONFIRM") + .eventType(LogType.ORDER_CONFIRM.getCode()) .message("工单已确认 (工牌按键)") .targetId(orderId) .targetType("order") @@ -954,12 +956,37 @@ public class CleanOrderEventListener { String deviceKey = (String) event.getPayload().get("deviceKey"); String beaconMac = (String) event.getPayload().get("beaconMac"); + // 兜底:payload 中没有 deviceKey 时从工单主表取 + if (deviceKey == null && orderId != null) { + OpsOrderDO order = opsOrderMapper.selectById(orderId); + if (order != null) { + deviceKey = order.getAssigneeDeviceKey(); + } + } + + // 构建可读消息,跳过值为 null 的字段 + StringBuilder msgBuilder = new StringBuilder("蓝牙信标自动到岗确认"); + StringBuilder detail = new StringBuilder(); + if (deviceKey != null) { + detail.append("设备:").append(deviceKey); + } + if (areaId != null) { + if (detail.length() > 0) detail.append(", "); + detail.append("区域:").append(areaId); + } + if (beaconMac != null) { + if (detail.length() > 0) detail.append(", "); + detail.append("信标:").append(beaconMac); + } + if (detail.length() > 0) { + msgBuilder.append(" [").append(detail).append("]"); + } + eventLogRecorder.record(EventLogRecord.builder() - .module("clean") + .module(LogModule.CLEAN) .domain(EventDomain.BEACON) - .eventType("ORDER_ARRIVED") - .message(String.format("蓝牙信标自动到岗确认 [设备:%s, 区域:%d, 信标:%s]", - deviceKey, areaId, beaconMac)) + .eventType(LogType.ORDER_ARRIVED.getCode()) + .message(msgBuilder.toString()) .targetId(orderId) .targetType("order") .deviceId(deviceId) @@ -980,6 +1007,14 @@ public class CleanOrderEventListener { String triggerSource = (String) event.getPayload().get("triggerSource"); String deviceKey = (String) event.getPayload().get("deviceKey"); + // 兜底:payload 中没有 deviceKey 时从工单主表取 + if (deviceKey == null && orderId != null) { + OpsOrderDO order = opsOrderMapper.selectById(orderId); + if (order != null) { + deviceKey = order.getAssigneeDeviceKey(); + } + } + // 构建日志消息 String message = "工单已完成"; if ("SIGNAL_LOSS_TIMEOUT".equals(triggerSource)) { @@ -989,13 +1024,15 @@ public class CleanOrderEventListener { long durationMinutes = ((Number) durationMs).longValue() / 60000; durationInfo = String.format(",作业时长: %d分钟", durationMinutes); } - message = "信号丢失超时自动完成 [设备:" + deviceKey + durationInfo + "]"; + message = "信号丢失超时自动完成" + + (deviceKey != null ? " [设备:" + deviceKey + durationInfo + "]" + : (durationInfo.isEmpty() ? "" : " [" + durationInfo.substring(1) + "]")); } EventLogRecord.EventLogRecordBuilder builder = EventLogRecord.builder() - .module("clean") + .module(LogModule.CLEAN) .domain(EventDomain.BEACON) - .eventType("ORDER_COMPLETED") + .eventType(LogType.ORDER_COMPLETED.getCode()) .message(message) .targetId(orderId) .targetType("order"); From 0345d0fe396baa470a65d060330bb4a540726f7e Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 11 Mar 2026 17:34:53 +0800 Subject: [PATCH 17/35] =?UTF-8?q?fix(ops):=20TTS=20=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E5=8E=BB=E9=99=A4=E5=86=97=E4=BD=99"?= =?UTF-8?q?=E8=AF=AD=E9=9F=B3=E6=92=AD=E6=8A=A5:"=E5=89=8D=E7=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VoiceBroadcastService 和 TtsQueueConsumer 记录 TTS_SENT 日志时 直接使用播报文本内容,title 由 LogType.TTS_SENT 的 description "语音播报"提供,避免 message 中重复出现。 Co-Authored-By: Claude Opus 4.6 --- .../service/voice/TtsQueueConsumer.java | 10 ++++++---- .../service/voice/VoiceBroadcastService.java | 16 +++++++++------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/voice/TtsQueueConsumer.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/voice/TtsQueueConsumer.java index 755c834..94a9635 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/voice/TtsQueueConsumer.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/voice/TtsQueueConsumer.java @@ -5,6 +5,8 @@ import com.viewsh.module.iot.api.device.IotDeviceControlApi; import com.viewsh.module.iot.api.device.dto.IotDeviceServiceInvokeReqDTO; import com.viewsh.module.ops.infrastructure.log.enumeration.EventDomain; import com.viewsh.module.ops.infrastructure.log.enumeration.EventLevel; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogModule; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogType; import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecord; import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecorder; import cn.hutool.core.map.MapUtil; @@ -315,8 +317,8 @@ public class TtsQueueConsumer { // 记录日志(循环消息只在启动时记录一次,重复播报不再写日志) if (message.getOrderId() != null && !message.isLoopable()) { - eventLogRecorder.info("clean", EventDomain.DEVICE, "TTS_SENT", - "语音播报: " + message.getText(), message.getOrderId(), message.getDeviceId(), null); + eventLogRecorder.info(LogModule.CLEAN, EventDomain.DEVICE, LogType.TTS_SENT.getCode(), + message.getText(), message.getOrderId(), message.getDeviceId(), null); } return true; @@ -327,9 +329,9 @@ public class TtsQueueConsumer { // 记录错误日志 eventLogRecorder.record(EventLogRecord.builder() - .module("clean") + .module(LogModule.CLEAN) .domain(EventDomain.DEVICE) - .eventType("TTS_FAILED") + .eventType(LogType.TTS_FAILED.getCode()) .message("语音播报失败: " + e.getMessage()) .targetId(message.getOrderId()) .targetType("order") diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/voice/VoiceBroadcastService.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/voice/VoiceBroadcastService.java index 751d484..ce3753e 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/voice/VoiceBroadcastService.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/voice/VoiceBroadcastService.java @@ -5,6 +5,8 @@ import com.viewsh.module.iot.api.device.IotDeviceControlApi; import com.viewsh.module.iot.api.device.dto.IotDeviceServiceInvokeReqDTO; import com.viewsh.module.ops.infrastructure.log.enumeration.EventDomain; import com.viewsh.module.ops.infrastructure.log.enumeration.EventLevel; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogModule; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogType; import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecord; import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecorder; import jakarta.annotation.Resource; @@ -221,18 +223,18 @@ public class VoiceBroadcastService { private void recordLog(Long deviceId, String text, Long orderId, boolean success, Exception e) { if (success) { if (orderId != null) { - eventLogRecorder.info("clean", EventDomain.DEVICE, "TTS_SENT", - "语音播报: " + text, orderId, deviceId, null); + eventLogRecorder.info(LogModule.CLEAN, EventDomain.DEVICE, LogType.TTS_SENT.getCode(), + text, orderId, deviceId, null); } else { - eventLogRecorder.info("clean", EventDomain.DEVICE, "TTS_SENT", - "语音播报: " + text, deviceId); + eventLogRecorder.info(LogModule.CLEAN, EventDomain.DEVICE, LogType.TTS_SENT.getCode(), + text, deviceId); } } else { if (orderId != null) { eventLogRecorder.record(EventLogRecord.builder() - .module("clean") + .module(LogModule.CLEAN) .domain(EventDomain.DEVICE) - .eventType("TTS_FAILED") + .eventType(LogType.TTS_FAILED.getCode()) .message("语音播报失败: " + (e != null ? e.getMessage() : "unknown")) .targetId(orderId) .targetType("order") @@ -240,7 +242,7 @@ public class VoiceBroadcastService { .level(EventLevel.ERROR) .build()); } else { - eventLogRecorder.error("clean", EventDomain.DEVICE, "TTS_FAILED", + eventLogRecorder.error(LogModule.CLEAN, EventDomain.DEVICE, LogType.TTS_FAILED.getCode(), "语音播报失败: " + (e != null ? e.getMessage() : "unknown"), deviceId, e); } } From 6e56dcb6a229f4d02fe7325f75d5aecbc0e4d0d0 Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 11 Mar 2026 17:35:05 +0800 Subject: [PATCH 18/35] =?UTF-8?q?feat(framework):=20API=20=E7=AD=BE?= =?UTF-8?q?=E5=90=8D=E3=80=81=E5=AE=89=E5=85=A8=E7=99=BD=E5=90=8D=E5=8D=95?= =?UTF-8?q?=E4=B8=8E=20Web=20=E9=85=8D=E7=BD=AE=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ApiSignatureProperties 配置类 - 调整签名自动配置与 Redis DAO 实现 - 更新安全白名单与 Web 属性配置 - 网关新增安保模块路由配置 Co-Authored-By: Claude Opus 4.6 --- .../config/ApiSignatureProperties.java | 32 ++ .../ViewshApiSignatureAutoConfiguration.java | 62 ++-- .../core/redis/ApiSignatureRedisDAO.java | 135 ++++--- .../config/AuthorizeRequestsCustomizer.java | 74 ++-- .../config/ViewshWebAutoConfiguration.java | 343 +++++++++--------- .../framework/web/config/WebProperties.java | 134 +++---- .../src/main/resources/application.yaml | 4 + 7 files changed, 427 insertions(+), 357 deletions(-) create mode 100644 viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/config/ApiSignatureProperties.java diff --git a/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/config/ApiSignatureProperties.java b/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/config/ApiSignatureProperties.java new file mode 100644 index 0000000..4cd1895 --- /dev/null +++ b/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/config/ApiSignatureProperties.java @@ -0,0 +1,32 @@ +package com.viewsh.framework.signature.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.Map; + +/** + * API 签名配置属性 + *

    + * 支持在 application.yaml 中配置 appId/appSecret,应用启动时自动加载到 Redis。 + * + *

    + * viewsh:
    + *   signature:
    + *     apps:
    + *       alarm-system: "your-app-secret"
    + *       third-party:  "another-secret"
    + * 
    + * + * @author lzh + */ +@ConfigurationProperties(prefix = "viewsh.signature") +@Data +public class ApiSignatureProperties { + + /** + * 签名应用列表:appId → appSecret + */ + private Map apps; + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/config/ViewshApiSignatureAutoConfiguration.java b/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/config/ViewshApiSignatureAutoConfiguration.java index 9aa434c..9f1d066 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/config/ViewshApiSignatureAutoConfiguration.java +++ b/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/config/ViewshApiSignatureAutoConfiguration.java @@ -1,28 +1,34 @@ -package com.viewsh.framework.signature.config; - -import com.viewsh.framework.redis.config.ViewshRedisAutoConfiguration; -import com.viewsh.framework.signature.core.aop.ApiSignatureAspect; -import com.viewsh.framework.signature.core.redis.ApiSignatureRedisDAO; -import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.context.annotation.Bean; -import org.springframework.data.redis.core.StringRedisTemplate; - -/** - * HTTP API 签名的自动配置类 - * - * @author Zhougang - */ -@AutoConfiguration(after = ViewshRedisAutoConfiguration.class) -public class ViewshApiSignatureAutoConfiguration { - - @Bean - public ApiSignatureAspect signatureAspect(ApiSignatureRedisDAO signatureRedisDAO) { - return new ApiSignatureAspect(signatureRedisDAO); - } - - @Bean - public ApiSignatureRedisDAO signatureRedisDAO(StringRedisTemplate stringRedisTemplate) { - return new ApiSignatureRedisDAO(stringRedisTemplate); - } - -} +package com.viewsh.framework.signature.config; + +import com.viewsh.framework.redis.config.ViewshRedisAutoConfiguration; +import com.viewsh.framework.signature.core.aop.ApiSignatureAspect; +import com.viewsh.framework.signature.core.redis.ApiSignatureRedisDAO; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.core.StringRedisTemplate; + +/** + * HTTP API 签名的自动配置类 + * + * @author Zhougang + */ +@AutoConfiguration(after = ViewshRedisAutoConfiguration.class) +@EnableConfigurationProperties(ApiSignatureProperties.class) +public class ViewshApiSignatureAutoConfiguration { + + @Bean + public ApiSignatureAspect signatureAspect(ApiSignatureRedisDAO signatureRedisDAO) { + return new ApiSignatureAspect(signatureRedisDAO); + } + + @Bean + public ApiSignatureRedisDAO signatureRedisDAO(StringRedisTemplate stringRedisTemplate, + ApiSignatureProperties properties) { + ApiSignatureRedisDAO dao = new ApiSignatureRedisDAO(stringRedisTemplate); + // 启动时将配置文件中的 appId/appSecret 同步到 Redis + dao.initApps(properties.getApps()); + return dao; + } + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/core/redis/ApiSignatureRedisDAO.java b/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/core/redis/ApiSignatureRedisDAO.java index f228d7d..8450d50 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/core/redis/ApiSignatureRedisDAO.java +++ b/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/core/redis/ApiSignatureRedisDAO.java @@ -1,57 +1,78 @@ -package com.viewsh.framework.signature.core.redis; - -import lombok.AllArgsConstructor; -import org.springframework.data.redis.core.StringRedisTemplate; - -import java.util.concurrent.TimeUnit; - -/** - * HTTP API 签名 Redis DAO - * - * @author Zhougang - */ -@AllArgsConstructor -public class ApiSignatureRedisDAO { - - private final StringRedisTemplate stringRedisTemplate; - - /** - * 验签随机数 - *

    - * KEY 格式:signature_nonce:%s // 参数为 随机数 - * VALUE 格式:String - * 过期时间:不固定 - */ - private static final String SIGNATURE_NONCE = "api_signature_nonce:%s:%s"; - - /** - * 签名密钥 - *

    - * HASH 结构 - * KEY 格式:%s // 参数为 appid - * VALUE 格式:String - * 过期时间:永不过期(预加载到 Redis) - */ - private static final String SIGNATURE_APPID = "api_signature_app"; - - // ========== 验签随机数 ========== - - public String getNonce(String appId, String nonce) { - return stringRedisTemplate.opsForValue().get(formatNonceKey(appId, nonce)); - } - - public Boolean setNonce(String appId, String nonce, int time, TimeUnit timeUnit) { - return stringRedisTemplate.opsForValue().setIfAbsent(formatNonceKey(appId, nonce), "", time, timeUnit); - } - - private static String formatNonceKey(String appId, String nonce) { - return String.format(SIGNATURE_NONCE, appId, nonce); - } - - // ========== 签名密钥 ========== - - public String getAppSecret(String appId) { - return (String) stringRedisTemplate.opsForHash().get(SIGNATURE_APPID, appId); - } - -} +package com.viewsh.framework.signature.core.redis; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * HTTP API 签名 Redis DAO + * + * @author Zhougang + */ +@AllArgsConstructor +@Slf4j +public class ApiSignatureRedisDAO { + + private final StringRedisTemplate stringRedisTemplate; + + /** + * 验签随机数 + *

    + * KEY 格式:signature_nonce:%s // 参数为 随机数 + * VALUE 格式:String + * 过期时间:不固定 + */ + private static final String SIGNATURE_NONCE = "api_signature_nonce:%s:%s"; + + /** + * 签名密钥 + *

    + * HASH 结构 + * KEY 格式:%s // 参数为 appid + * VALUE 格式:String + * 过期时间:永不过期(预加载到 Redis) + */ + private static final String SIGNATURE_APPID = "api_signature_app"; + + // ========== 验签随机数 ========== + + public String getNonce(String appId, String nonce) { + return stringRedisTemplate.opsForValue().get(formatNonceKey(appId, nonce)); + } + + public Boolean setNonce(String appId, String nonce, int time, TimeUnit timeUnit) { + return stringRedisTemplate.opsForValue().setIfAbsent(formatNonceKey(appId, nonce), "", time, timeUnit); + } + + private static String formatNonceKey(String appId, String nonce) { + return String.format(SIGNATURE_NONCE, appId, nonce); + } + + // ========== 签名密钥 ========== + + public String getAppSecret(String appId) { + return (String) stringRedisTemplate.opsForHash().get(SIGNATURE_APPID, appId); + } + + /** + * 从配置文件加载 appId/appSecret 到 Redis + *

    + * 先删除整个 Hash Key 再写入,确保 YAML 中移除的应用不会残留在 Redis 中。 + * + * @param apps appId → appSecret 映射 + */ + public void initApps(Map apps) { + if (apps == null || apps.isEmpty()) { + stringRedisTemplate.delete(SIGNATURE_APPID); + log.info("[initApps][配置为空,已清除 Redis 中的签名应用]"); + return; + } + stringRedisTemplate.delete(SIGNATURE_APPID); + stringRedisTemplate.opsForHash().putAll(SIGNATURE_APPID, apps); + log.info("[initApps][从配置文件加载 {} 个签名应用到 Redis: {}]", apps.size(), apps.keySet()); + } + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-security/src/main/java/com/viewsh/framework/security/config/AuthorizeRequestsCustomizer.java b/viewsh-framework/viewsh-spring-boot-starter-security/src/main/java/com/viewsh/framework/security/config/AuthorizeRequestsCustomizer.java index af00f3e..e21b328 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-security/src/main/java/com/viewsh/framework/security/config/AuthorizeRequestsCustomizer.java +++ b/viewsh-framework/viewsh-spring-boot-starter-security/src/main/java/com/viewsh/framework/security/config/AuthorizeRequestsCustomizer.java @@ -1,35 +1,39 @@ -package com.viewsh.framework.security.config; - -import com.viewsh.framework.web.config.WebProperties; -import jakarta.annotation.Resource; -import org.springframework.core.Ordered; -import org.springframework.security.config.Customizer; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; - -/** - * 自定义的 URL 的安全配置 - * 目的:每个 Maven Module 可以自定义规则! - * - * @author 芋道源码 - */ -public abstract class AuthorizeRequestsCustomizer - implements Customizer.AuthorizationManagerRequestMatcherRegistry>, Ordered { - - @Resource - private WebProperties webProperties; - - protected String buildAdminApi(String url) { - return webProperties.getAdminApi().getPrefix() + url; - } - - protected String buildAppApi(String url) { - return webProperties.getAppApi().getPrefix() + url; - } - - @Override - public int getOrder() { - return 0; - } - -} +package com.viewsh.framework.security.config; + +import com.viewsh.framework.web.config.WebProperties; +import jakarta.annotation.Resource; +import org.springframework.core.Ordered; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; + +/** + * 自定义的 URL 的安全配置 + * 目的:每个 Maven Module 可以自定义规则! + * + * @author 芋道源码 + */ +public abstract class AuthorizeRequestsCustomizer + implements Customizer.AuthorizationManagerRequestMatcherRegistry>, Ordered { + + @Resource + private WebProperties webProperties; + + protected String buildAdminApi(String url) { + return webProperties.getAdminApi().getPrefix() + url; + } + + protected String buildAppApi(String url) { + return webProperties.getAppApi().getPrefix() + url; + } + + protected String buildOpenApi(String url) { + return webProperties.getOpenApi().getPrefix() + url; + } + + @Override + public int getOrder() { + return 0; + } + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/config/ViewshWebAutoConfiguration.java b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/config/ViewshWebAutoConfiguration.java index 569ae64..4ccbf08 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/config/ViewshWebAutoConfiguration.java +++ b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/config/ViewshWebAutoConfiguration.java @@ -1,171 +1,172 @@ -package com.viewsh.framework.web.config; - -import cn.hutool.core.util.StrUtil; -import com.viewsh.framework.common.biz.infra.logger.ApiErrorLogCommonApi; -import com.viewsh.framework.common.enums.WebFilterOrderEnum; -import com.viewsh.framework.web.core.filter.CacheRequestBodyFilter; -import com.viewsh.framework.web.core.filter.DemoFilter; -import com.viewsh.framework.web.core.handler.GlobalExceptionHandler; -import com.viewsh.framework.web.core.handler.GlobalResponseBodyHandler; -import com.viewsh.framework.web.core.util.WebFrameworkUtils; -import com.google.common.collect.Maps; -import jakarta.servlet.Filter; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration; -import org.springframework.boot.autoconfigure.web.servlet.WebMvcRegistrations; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.boot.web.client.RestTemplateBuilder; -import org.springframework.boot.web.servlet.FilterRegistrationBean; -import org.springframework.cloud.client.loadbalancer.LoadBalanced; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Primary; -import org.springframework.core.annotation.Order; -import org.springframework.util.AntPathMatcher; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.client.RestTemplate; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import org.springframework.web.filter.CorsFilter; -import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; - -import java.util.Map; -import java.util.function.Predicate; - -@AutoConfiguration(beforeName = { - "com.fhs.trans.config.TransServiceConfig" // cloud 独有:避免一键改包后,RestTemplate 初始化的冲突。可见 https://t.zsxq.com/T4yj7 帖子 -}) -@EnableConfigurationProperties(WebProperties.class) -public class ViewshWebAutoConfiguration { - - /** - * 应用名 - */ - @Value("${spring.application.name}") - private String applicationName; - - @Bean - public WebMvcRegistrations webMvcRegistrations(WebProperties webProperties) { - return new WebMvcRegistrations() { - - @Override - public RequestMappingHandlerMapping getRequestMappingHandlerMapping() { - RequestMappingHandlerMapping mapping = new RequestMappingHandlerMapping(); - // 实例化时就带上前缀 - mapping.setPathPrefixes(buildPathPrefixes(webProperties)); - return mapping; - } - - /** - * 构建 prefix → 匹配条件的映射 - */ - private Map>> buildPathPrefixes(WebProperties webProperties) { - AntPathMatcher antPathMatcher = new AntPathMatcher("."); - Map>> pathPrefixes = Maps.newLinkedHashMapWithExpectedSize(2); - putPathPrefix(pathPrefixes, webProperties.getAdminApi(), antPathMatcher); - putPathPrefix(pathPrefixes, webProperties.getAppApi(), antPathMatcher); - return pathPrefixes; - } - - /** - * 设置 API 前缀,仅仅匹配 controller 包下的 - */ - private void putPathPrefix(Map>> pathPrefixes, WebProperties.Api api, AntPathMatcher matcher) { - if (api == null || StrUtil.isEmpty(api.getPrefix())) { - return; - } - pathPrefixes.put(api.getPrefix(), // api 前缀 - clazz -> clazz.isAnnotationPresent(RestController.class) - && matcher.match(api.getController(), clazz.getPackage().getName())); - } - - }; - } - - @Bean - @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") - public GlobalExceptionHandler globalExceptionHandler(ApiErrorLogCommonApi apiErrorLogApi) { - return new GlobalExceptionHandler(applicationName, apiErrorLogApi); - } - - @Bean - public GlobalResponseBodyHandler globalResponseBodyHandler() { - return new GlobalResponseBodyHandler(); - } - - @Bean - @SuppressWarnings("InstantiationOfUtilityClass") - public WebFrameworkUtils webFrameworkUtils(WebProperties webProperties) { - // 由于 WebFrameworkUtils 需要使用到 webProperties 属性,所以注册为一个 Bean - return new WebFrameworkUtils(webProperties); - } - - // ========== Filter 相关 ========== - - /** - * 创建 CorsFilter Bean,解决跨域问题 - */ - @Bean - @Order(value = WebFilterOrderEnum.CORS_FILTER) // 特殊:修复因执行顺序影响到跨域配置不生效问题 - public FilterRegistrationBean corsFilterBean() { - // 创建 CorsConfiguration 对象 - CorsConfiguration config = new CorsConfiguration(); - config.setAllowCredentials(true); - config.addAllowedOriginPattern("*"); // 设置访问源地址 - config.addAllowedHeader("*"); // 设置访问源请求头 - config.addAllowedMethod("*"); // 设置访问源请求方法 - // 创建 UrlBasedCorsConfigurationSource 对象 - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", config); // 对接口配置跨域设置 - return createFilterBean(new CorsFilter(source), WebFilterOrderEnum.CORS_FILTER); - } - - /** - * 创建 RequestBodyCacheFilter Bean,可重复读取请求内容 - */ - @Bean - public FilterRegistrationBean requestBodyCacheFilter() { - return createFilterBean(new CacheRequestBodyFilter(), WebFilterOrderEnum.REQUEST_BODY_CACHE_FILTER); - } - - /** - * 创建 DemoFilter Bean,演示模式 - */ - @Bean - @ConditionalOnProperty(value = "viewsh.demo", havingValue = "true") - public FilterRegistrationBean demoFilter() { - return createFilterBean(new DemoFilter(), WebFilterOrderEnum.DEMO_FILTER); - } - - public static FilterRegistrationBean createFilterBean(T filter, Integer order) { - FilterRegistrationBean bean = new FilterRegistrationBean<>(filter); - bean.setOrder(order); - return bean; - } - - /** - * 创建 RestTemplate 实例 - * - * @param restTemplateBuilder {@link RestTemplateAutoConfiguration#restTemplateBuilder} - */ - @Bean - @ConditionalOnMissingBean - @Primary - public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) { - return restTemplateBuilder.build(); - } - - /** - * 创建 RestTemplate 实例(支持负载均衡) - * - * @param restTemplateBuilder {@link RestTemplateAutoConfiguration#restTemplateBuilder} - */ - @Bean - @LoadBalanced - public RestTemplate loadBalancedRestTemplate(RestTemplateBuilder restTemplateBuilder) { - return restTemplateBuilder.build(); - } - -} +package com.viewsh.framework.web.config; + +import cn.hutool.core.util.StrUtil; +import com.viewsh.framework.common.biz.infra.logger.ApiErrorLogCommonApi; +import com.viewsh.framework.common.enums.WebFilterOrderEnum; +import com.viewsh.framework.web.core.filter.CacheRequestBodyFilter; +import com.viewsh.framework.web.core.filter.DemoFilter; +import com.viewsh.framework.web.core.handler.GlobalExceptionHandler; +import com.viewsh.framework.web.core.handler.GlobalResponseBodyHandler; +import com.viewsh.framework.web.core.util.WebFrameworkUtils; +import com.google.common.collect.Maps; +import jakarta.servlet.Filter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.WebMvcRegistrations; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.cloud.client.loadbalancer.LoadBalanced; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.core.annotation.Order; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +import java.util.Map; +import java.util.function.Predicate; + +@AutoConfiguration(beforeName = { + "com.fhs.trans.config.TransServiceConfig" // cloud 独有:避免一键改包后,RestTemplate 初始化的冲突。可见 https://t.zsxq.com/T4yj7 帖子 +}) +@EnableConfigurationProperties(WebProperties.class) +public class ViewshWebAutoConfiguration { + + /** + * 应用名 + */ + @Value("${spring.application.name}") + private String applicationName; + + @Bean + public WebMvcRegistrations webMvcRegistrations(WebProperties webProperties) { + return new WebMvcRegistrations() { + + @Override + public RequestMappingHandlerMapping getRequestMappingHandlerMapping() { + RequestMappingHandlerMapping mapping = new RequestMappingHandlerMapping(); + // 实例化时就带上前缀 + mapping.setPathPrefixes(buildPathPrefixes(webProperties)); + return mapping; + } + + /** + * 构建 prefix → 匹配条件的映射 + */ + private Map>> buildPathPrefixes(WebProperties webProperties) { + AntPathMatcher antPathMatcher = new AntPathMatcher("."); + Map>> pathPrefixes = Maps.newLinkedHashMapWithExpectedSize(3); + putPathPrefix(pathPrefixes, webProperties.getAdminApi(), antPathMatcher); + putPathPrefix(pathPrefixes, webProperties.getAppApi(), antPathMatcher); + putPathPrefix(pathPrefixes, webProperties.getOpenApi(), antPathMatcher); + return pathPrefixes; + } + + /** + * 设置 API 前缀,仅仅匹配 controller 包下的 + */ + private void putPathPrefix(Map>> pathPrefixes, WebProperties.Api api, AntPathMatcher matcher) { + if (api == null || StrUtil.isEmpty(api.getPrefix())) { + return; + } + pathPrefixes.put(api.getPrefix(), // api 前缀 + clazz -> clazz.isAnnotationPresent(RestController.class) + && matcher.match(api.getController(), clazz.getPackage().getName())); + } + + }; + } + + @Bean + @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") + public GlobalExceptionHandler globalExceptionHandler(ApiErrorLogCommonApi apiErrorLogApi) { + return new GlobalExceptionHandler(applicationName, apiErrorLogApi); + } + + @Bean + public GlobalResponseBodyHandler globalResponseBodyHandler() { + return new GlobalResponseBodyHandler(); + } + + @Bean + @SuppressWarnings("InstantiationOfUtilityClass") + public WebFrameworkUtils webFrameworkUtils(WebProperties webProperties) { + // 由于 WebFrameworkUtils 需要使用到 webProperties 属性,所以注册为一个 Bean + return new WebFrameworkUtils(webProperties); + } + + // ========== Filter 相关 ========== + + /** + * 创建 CorsFilter Bean,解决跨域问题 + */ + @Bean + @Order(value = WebFilterOrderEnum.CORS_FILTER) // 特殊:修复因执行顺序影响到跨域配置不生效问题 + public FilterRegistrationBean corsFilterBean() { + // 创建 CorsConfiguration 对象 + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.addAllowedOriginPattern("*"); // 设置访问源地址 + config.addAllowedHeader("*"); // 设置访问源请求头 + config.addAllowedMethod("*"); // 设置访问源请求方法 + // 创建 UrlBasedCorsConfigurationSource 对象 + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); // 对接口配置跨域设置 + return createFilterBean(new CorsFilter(source), WebFilterOrderEnum.CORS_FILTER); + } + + /** + * 创建 RequestBodyCacheFilter Bean,可重复读取请求内容 + */ + @Bean + public FilterRegistrationBean requestBodyCacheFilter() { + return createFilterBean(new CacheRequestBodyFilter(), WebFilterOrderEnum.REQUEST_BODY_CACHE_FILTER); + } + + /** + * 创建 DemoFilter Bean,演示模式 + */ + @Bean + @ConditionalOnProperty(value = "viewsh.demo", havingValue = "true") + public FilterRegistrationBean demoFilter() { + return createFilterBean(new DemoFilter(), WebFilterOrderEnum.DEMO_FILTER); + } + + public static FilterRegistrationBean createFilterBean(T filter, Integer order) { + FilterRegistrationBean bean = new FilterRegistrationBean<>(filter); + bean.setOrder(order); + return bean; + } + + /** + * 创建 RestTemplate 实例 + * + * @param restTemplateBuilder {@link RestTemplateAutoConfiguration#restTemplateBuilder} + */ + @Bean + @ConditionalOnMissingBean + @Primary + public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) { + return restTemplateBuilder.build(); + } + + /** + * 创建 RestTemplate 实例(支持负载均衡) + * + * @param restTemplateBuilder {@link RestTemplateAutoConfiguration#restTemplateBuilder} + */ + @Bean + @LoadBalanced + public RestTemplate loadBalancedRestTemplate(RestTemplateBuilder restTemplateBuilder) { + return restTemplateBuilder.build(); + } + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/config/WebProperties.java b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/config/WebProperties.java index 5ac860b..b8940f4 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/config/WebProperties.java +++ b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/config/WebProperties.java @@ -1,66 +1,68 @@ -package com.viewsh.framework.web.config; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.servlet.config.annotation.PathMatchConfigurer; - -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.NotNull; - -@ConfigurationProperties(prefix = "viewsh.web") -@Validated -@Data -public class WebProperties { - - @NotNull(message = "APP API 不能为空") - private Api appApi = new Api("/app-api", "**.controller.app.**"); - @NotNull(message = "Admin API 不能为空") - private Api adminApi = new Api("/admin-api", "**.controller.admin.**"); - - @NotNull(message = "Admin UI 不能为空") - private Ui adminUi; - - @Data - @AllArgsConstructor - @NoArgsConstructor - @Valid - public static class Api { - - /** - * API 前缀,实现所有 Controller 提供的 RESTFul API 的统一前缀 - * - * - * 意义:通过该前缀,避免 Swagger、Actuator 意外通过 Nginx 暴露出来给外部,带来安全性问题 - * 这样,Nginx 只需要配置转发到 /api/* 的所有接口即可。 - * - * @see ViewshWebAutoConfiguration#configurePathMatch(PathMatchConfigurer) - */ - @NotEmpty(message = "API 前缀不能为空") - private String prefix; - - /** - * Controller 所在包的 Ant 路径规则 - * - * 主要目的是,给该 Controller 设置指定的 {@link #prefix} - */ - @NotEmpty(message = "Controller 所在包不能为空") - private String controller; - - } - - @Data - @Valid - public static class Ui { - - /** - * 访问地址 - */ - private String url; - - } - -} +package com.viewsh.framework.web.config; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.servlet.config.annotation.PathMatchConfigurer; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +@ConfigurationProperties(prefix = "viewsh.web") +@Validated +@Data +public class WebProperties { + + @NotNull(message = "APP API 不能为空") + private Api appApi = new Api("/app-api", "**.controller.app.**"); + @NotNull(message = "Admin API 不能为空") + private Api adminApi = new Api("/admin-api", "**.controller.admin.**"); + + private Api openApi = new Api("/open-api", "**.controller.open.**"); + + @NotNull(message = "Admin UI 不能为空") + private Ui adminUi; + + @Data + @AllArgsConstructor + @NoArgsConstructor + @Valid + public static class Api { + + /** + * API 前缀,实现所有 Controller 提供的 RESTFul API 的统一前缀 + * + * + * 意义:通过该前缀,避免 Swagger、Actuator 意外通过 Nginx 暴露出来给外部,带来安全性问题 + * 这样,Nginx 只需要配置转发到 /api/* 的所有接口即可。 + * + * @see ViewshWebAutoConfiguration#configurePathMatch(PathMatchConfigurer) + */ + @NotEmpty(message = "API 前缀不能为空") + private String prefix; + + /** + * Controller 所在包的 Ant 路径规则 + * + * 主要目的是,给该 Controller 设置指定的 {@link #prefix} + */ + @NotEmpty(message = "Controller 所在包不能为空") + private String controller; + + } + + @Data + @Valid + public static class Ui { + + /** + * 访问地址 + */ + private String url; + + } + +} diff --git a/viewsh-gateway/src/main/resources/application.yaml b/viewsh-gateway/src/main/resources/application.yaml index 7d85f76..a4941c7 100644 --- a/viewsh-gateway/src/main/resources/application.yaml +++ b/viewsh-gateway/src/main/resources/application.yaml @@ -208,6 +208,10 @@ spring: - Path=/app-api/ops/** filters: - RewritePath=/app-api/ops/v3/api-docs, /v3/api-docs + - id: ops-open-api # 开放接口路由(签名验证,无需 Token) + uri: grayLb://ops-server + predicates: + - Path=/open-api/ops/** x-forwarded: prefix-enabled: false # 避免 Swagger 重复带上额外的 /admin-api/system 前缀 From 2a20f7a89fa9b2662979494ccdcd9941ae0bdf3d Mon Sep 17 00:00:00 2001 From: lzh Date: Fri, 13 Mar 2026 12:02:02 +0800 Subject: [PATCH 19/35] =?UTF-8?q?fix(framework):=20ApiRequestFilter=20?= =?UTF-8?q?=E7=BA=B3=E5=85=A5=20/open-api=20=E8=B7=AF=E5=BE=84=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20open-api=20=E5=A4=9A=E7=A7=9F=E6=88=B7?= =?UTF-8?q?=E6=8B=A6=E6=88=AA=E7=BC=BA=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TenantSecurityWebFilter 继承 ApiRequestFilter,之前 shouldNotFilter 仅匹配 /admin-api 和 /app-api,导致 /open-api 请求跳过租户校验,DB 层 getRequiredTenantId() 抛 NPE。现在补上 openApi prefix,外部系统需传 tenant-id Header。 Co-Authored-By: Claude Opus 4.6 --- .../web/core/filter/ApiRequestFilter.java | 57 ++++++++++--------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/filter/ApiRequestFilter.java b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/filter/ApiRequestFilter.java index 3e668d4..d90f7d2 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/filter/ApiRequestFilter.java +++ b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/filter/ApiRequestFilter.java @@ -1,27 +1,30 @@ -package com.viewsh.framework.web.core.filter; - -import cn.hutool.core.util.StrUtil; -import com.viewsh.framework.web.config.WebProperties; -import lombok.RequiredArgsConstructor; -import org.springframework.web.filter.OncePerRequestFilter; - -import jakarta.servlet.http.HttpServletRequest; - -/** - * 过滤 /admin-api、/app-api 等 API 请求的过滤器 - * - * @author 芋道源码 - */ -@RequiredArgsConstructor -public abstract class ApiRequestFilter extends OncePerRequestFilter { - - protected final WebProperties webProperties; - - @Override - protected boolean shouldNotFilter(HttpServletRequest request) { - // 只过滤 API 请求的地址 - String apiUri = request.getRequestURI().substring(request.getContextPath().length()); - return !StrUtil.startWithAny(apiUri, webProperties.getAdminApi().getPrefix(), webProperties.getAppApi().getPrefix()); - } - -} +package com.viewsh.framework.web.core.filter; + +import cn.hutool.core.util.StrUtil; +import com.viewsh.framework.web.config.WebProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * 过滤 /admin-api、/app-api、/open-api 等 API 请求的过滤器 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +public abstract class ApiRequestFilter extends OncePerRequestFilter { + + protected final WebProperties webProperties; + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + // 只过滤 API 请求的地址 + String apiUri = request.getRequestURI().substring(request.getContextPath().length()); + return !StrUtil.startWithAny(apiUri, + webProperties.getAdminApi().getPrefix(), + webProperties.getAppApi().getPrefix(), + webProperties.getOpenApi().getPrefix()); + } + +} From 825c8eecca262164e1755479b54684cae668f327 Mon Sep 17 00:00:00 2001 From: lzh Date: Sun, 15 Mar 2026 10:30:03 +0800 Subject: [PATCH 20/35] =?UTF-8?q?refactor(ops):=20=E6=8F=90=E5=8F=96=20Are?= =?UTF-8?q?aPathBuilder=20=E5=85=AC=E5=85=B1=E7=BB=84=E4=BB=B6=EF=BC=8C?= =?UTF-8?q?=E6=B6=88=E9=99=A4=E4=BF=9D=E6=B4=81/=E5=AE=89=E4=BF=9D=20build?= =?UTF-8?q?AreaPath=20=E9=87=8D=E5=A4=8D=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 CleanOrderServiceImpl 中的 buildAreaPath 私有方法提取到 ops-biz 公共层 AreaPathBuilder 组件,供各业务模块(保洁、安保等)共享使用。同时优化: - 用正则 matches("\d+") 替代 try-catch NumberFormatException 做数字校验 - 增加相邻重复ID去重保护 Co-Authored-By: Claude Opus 4.6 --- .../cleanorder/CleanOrderServiceImpl.java | 100 +--------------- .../infrastructure/area/AreaPathBuilder.java | 108 ++++++++++++++++++ .../vo/SecurityOrderFalseAlarmReqVO.java | 20 ++++ .../security/vo/SecurityOrderIdReqVO.java | 22 ++++ .../vo/SecurityOrderOpenConfirmReqVO.java | 20 ++++ 5 files changed, 174 insertions(+), 96 deletions(-) create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/area/AreaPathBuilder.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderFalseAlarmReqVO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderIdReqVO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderOpenConfirmReqVO.java diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanOrderServiceImpl.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanOrderServiceImpl.java index e0a8308..38cf86c 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanOrderServiceImpl.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanOrderServiceImpl.java @@ -10,9 +10,7 @@ import com.viewsh.module.ops.core.event.OrderCreatedEvent; import com.viewsh.module.ops.core.event.OrderEventPublisher; import com.viewsh.module.ops.core.lifecycle.OrderLifecycleManager; import com.viewsh.module.ops.core.lifecycle.model.OrderTransitionRequest; -import com.viewsh.module.ops.dal.dataobject.area.OpsBusAreaDO; import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO; -import com.viewsh.module.ops.dal.mysql.area.OpsBusAreaMapper; import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper; import com.viewsh.module.ops.enums.OperatorTypeEnum; import com.viewsh.module.ops.enums.PriorityEnum; @@ -21,6 +19,7 @@ import com.viewsh.module.ops.environment.dal.dataobject.CleanOrderAutoCreateReqD import com.viewsh.module.ops.environment.dal.dataobject.workorder.OpsOrderCleanExtDO; import com.viewsh.module.ops.environment.dal.mysql.workorder.OpsOrderCleanExtMapper; import com.viewsh.module.ops.environment.integration.listener.CleanOrderEventListener; +import com.viewsh.module.ops.infrastructure.area.AreaPathBuilder; import com.viewsh.module.ops.infrastructure.code.OrderCodeGenerator; import com.viewsh.module.ops.infrastructure.id.OrderIdGenerator; import jakarta.annotation.Resource; @@ -30,11 +29,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Duration; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; + /** * 保洁工单服务实现(重构版) @@ -88,7 +83,7 @@ public class CleanOrderServiceImpl implements CleanOrderService { private ObjectMapper objectMapper; @Resource - private OpsBusAreaMapper opsBusAreaMapper; + private AreaPathBuilder areaPathBuilder; // ==================== 工单创建 ==================== @@ -109,7 +104,7 @@ public class CleanOrderServiceImpl implements CleanOrderService { .priority(createReq.getPriority() != null ? createReq.getPriority() : PriorityEnum.P2.getPriority()) .status(WorkOrderStatusEnum.PENDING.getStatus()) .areaId(createReq.getAreaId()) - .location(buildAreaPath(createReq.getAreaId())) + .location(areaPathBuilder.buildPath(createReq.getAreaId())) .sourceType(createReq.getSourceType() != null ? createReq.getSourceType() : "TRAFFIC") // IoT集成字段 .triggerSource(createReq.getTriggerSource()) @@ -448,91 +443,4 @@ public class CleanOrderServiceImpl implements CleanOrderService { } } - /** - * 根据区域ID构建完整路径(如"园区/A栋/B层/电梯厅") - * - * @param areaId 区域ID - * @return 完整区域路径,用 "/" 分隔 - */ - private String buildAreaPath(Long areaId) { - // 1. 参数校验 - if (areaId == null) { - return null; - } - - // 2. 查询当前区域 - OpsBusAreaDO area = opsBusAreaMapper.selectById(areaId); - if (area == null) { - log.warn("区域不存在: areaId={}", areaId); - return null; - } - - // 3. 无父级路径,直接返回区域名称 - String parentPath = area.getParentPath(); - if (StrUtil.isEmpty(parentPath)) { - return area.getAreaName(); - } - - // 4. 解析父级ID列表(使用 Stream 过滤无效ID) - List parentIds = Arrays.stream(parentPath.split("/")) - .filter(StrUtil::isNotBlank) // 过滤空字符串 - .filter(pid -> { - try { - Long.parseLong(pid); - return true; - } catch (NumberFormatException e) { - log.warn("父级区域ID格式错误: areaId={}, parentId={}", areaId, pid); - return false; - } - }) - .map(Long::parseLong) - .filter(pid -> !pid.equals(areaId)) // 排除当前区域,避免循环引用 - .collect(Collectors.toList()); - - // 5. 在ID层面去重相邻重复的ID(只去除数据错误导致的重复,保留不同ID的相同名称) - List deduplicatedIds = new ArrayList<>(); - Long lastId = null; - for (Long parentId : parentIds) { - if (!parentId.equals(lastId)) { - deduplicatedIds.add(parentId); - lastId = parentId; - } else { - log.warn("检测到parent_path中重复的ID: areaId={}, duplicateId={}", areaId, parentId); - } - } - - // 6. 无有效父级,直接返回区域名称 - if (deduplicatedIds.isEmpty()) { - return area.getAreaName(); - } - - // 7. 批量查询所有父级区域(避免 N+1 查询) - List parents = opsBusAreaMapper.selectBatchIds(deduplicatedIds); - if (parents == null || parents.isEmpty()) { - log.warn("未找到父级区域: areaId={}, parentIds={}", areaId, deduplicatedIds); - return area.getAreaName(); - } - - // 8. 构建ID到区域的映射 - Map parentNameMap = parents.stream() - .collect(Collectors.toMap( - OpsBusAreaDO::getId, - OpsBusAreaDO::getAreaName, - (existing, replacement) -> existing // 处理重复key - )); - - // 9. 按顺序拼接区域路径(保持ID顺序) - List pathSegments = deduplicatedIds.stream() - .filter(parentNameMap::containsKey) // 过滤掉不存在的父级 - .map(parentNameMap::get) - .collect(Collectors.toList()); - - // 10. 拼接完整路径 - String path = String.join("/", pathSegments); - if (StrUtil.isBlank(path)) { - return area.getAreaName(); - } - - return path + "/" + area.getAreaName(); - } } diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/area/AreaPathBuilder.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/area/AreaPathBuilder.java new file mode 100644 index 0000000..a37ea40 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/area/AreaPathBuilder.java @@ -0,0 +1,108 @@ +package com.viewsh.module.ops.infrastructure.area; + +import cn.hutool.core.util.StrUtil; +import com.viewsh.module.ops.dal.dataobject.area.OpsBusAreaDO; +import com.viewsh.module.ops.dal.mysql.area.OpsBusAreaMapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 区域路径构建器 + *

    + * 根据 areaId 拼接完整的区域路径,如 "A园区/A栋/3层/电梯厅"。 + * 供保洁、安保等各业务模块共享使用。 + * + * @author lzh + */ +@Slf4j +@Component +public class AreaPathBuilder { + + @Resource + private OpsBusAreaMapper opsBusAreaMapper; + + /** + * 根据已查询到的区域对象构建完整路径 + * + * @param area 区域对象(非 null) + * @return 完整区域路径,用 "/" 分隔 + */ + public String buildPath(OpsBusAreaDO area) { + if (area == null) { + return null; + } + + String parentPath = area.getParentPath(); + if (StrUtil.isEmpty(parentPath)) { + return area.getAreaName(); + } + + // 解析父级ID列表 + List parentIds = Arrays.stream(parentPath.split("/")) + .filter(StrUtil::isNotBlank) + .filter(pid -> pid.matches("\\d+")) + .map(Long::parseLong) + .filter(pid -> !pid.equals(area.getId())) + .collect(Collectors.toList()); + + // ID层面去重相邻重复(数据异常保护) + List deduplicatedIds = new ArrayList<>(); + Long lastId = null; + for (Long parentId : parentIds) { + if (!parentId.equals(lastId)) { + deduplicatedIds.add(parentId); + lastId = parentId; + } else { + log.warn("检测到parent_path中重复的ID: areaId={}, duplicateId={}", area.getId(), parentId); + } + } + + if (deduplicatedIds.isEmpty()) { + return area.getAreaName(); + } + + // 批量查询父级区域 + List parents = opsBusAreaMapper.selectBatchIds(deduplicatedIds); + if (parents == null || parents.isEmpty()) { + log.warn("未找到父级区域: areaId={}, parentIds={}", area.getId(), deduplicatedIds); + return area.getAreaName(); + } + + Map parentNameMap = parents.stream() + .collect(Collectors.toMap(OpsBusAreaDO::getId, OpsBusAreaDO::getAreaName, (a, b) -> a)); + + List pathSegments = deduplicatedIds.stream() + .filter(parentNameMap::containsKey) + .map(parentNameMap::get) + .collect(Collectors.toList()); + + String path = String.join("/", pathSegments); + return StrUtil.isBlank(path) ? area.getAreaName() : path + "/" + area.getAreaName(); + } + + /** + * 根据 areaId 查询并构建完整路径 + * + * @param areaId 区域ID + * @return 完整区域路径,用 "/" 分隔;区域不存在时返回 null + */ + public String buildPath(Long areaId) { + if (areaId == null) { + return null; + } + OpsBusAreaDO area = opsBusAreaMapper.selectById(areaId); + if (area == null) { + log.warn("区域不存在: areaId={}", areaId); + return null; + } + return buildPath(area); + } + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderFalseAlarmReqVO.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderFalseAlarmReqVO.java new file mode 100644 index 0000000..9bf0cab --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderFalseAlarmReqVO.java @@ -0,0 +1,20 @@ +package com.viewsh.module.ops.controller.admin.security.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 安保工单误报请求 VO + * + * @author lzh + */ +@Schema(description = "安保工单误报请求") +@Data +public class SecurityOrderFalseAlarmReqVO { + + @Schema(description = "工单ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001") + @NotNull(message = "工单ID不能为空") + private Long orderId; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderIdReqVO.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderIdReqVO.java new file mode 100644 index 0000000..78342c7 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderIdReqVO.java @@ -0,0 +1,22 @@ +package com.viewsh.module.ops.controller.admin.security.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 安保工单 - 仅含工单ID的通用请求 VO + *

    + * 用于误报标记、开放接口确认等只需要工单ID的场景 + * + * @author lzh + */ +@Schema(description = "安保工单ID请求") +@Data +public class SecurityOrderIdReqVO { + + @Schema(description = "工单ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001") + @NotNull(message = "工单ID不能为空") + private Long orderId; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderOpenConfirmReqVO.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderOpenConfirmReqVO.java new file mode 100644 index 0000000..8a5b35e --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderOpenConfirmReqVO.java @@ -0,0 +1,20 @@ +package com.viewsh.module.ops.controller.admin.security.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 安保工单开放接口确认请求 VO(无需传 userId,默认使用已分配人员) + * + * @author lzh + */ +@Schema(description = "安保工单确认请求(开放接口)") +@Data +public class SecurityOrderOpenConfirmReqVO { + + @Schema(description = "工单ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001") + @NotNull(message = "工单ID不能为空") + private Long orderId; + +} From f32315f790e61f6bb20c0a4934e1cf0ac2859b3c Mon Sep 17 00:00:00 2001 From: lzh Date: Sun, 15 Mar 2026 10:31:50 +0800 Subject: [PATCH 21/35] =?UTF-8?q?feat(ops):=20=E5=AE=89=E4=BF=9D=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=E6=96=B0=E5=A2=9E=E8=AF=AF=E6=8A=A5=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=E3=80=81=E5=AE=8C=E5=96=84=E7=A1=AE=E8=AE=A4/=E5=AE=8C?= =?UTF-8?q?=E5=8D=95=E6=8E=A5=E5=8F=A3=E6=94=AF=E6=8C=81=20open-api=20?= =?UTF-8?q?=E5=9C=BA=E6=99=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 falseAlarmOrder 方法,标记误报并完成工单 - confirmOrder/manualCompleteOrder 支持 operatorId 为 null(open-api 自动取已分配人员) - 新增 resolveOperatorId 辅助方法,null 时记录 warn 日志 - createSecurityOrder 移除 location 透传,改用 AreaPathBuilder 自动拼接 - 消除 createSecurityOrder 中 area 重复查询(校验 + buildPath 共用同一 DO) - OpsOrderSecurityExtDO 新增 falseAlarm 字段 - SecurityOrderCompleteReqDTO.operatorId 移除 @NotNull 约束 Co-Authored-By: Claude Opus 4.6 --- .../workorder/OpsOrderSecurityExtDO.java | 4 + .../SecurityOrderCompleteReqDTO.java | 3 +- .../SecurityOrderCreateReqDTO.java | 2 - .../securityorder/SecurityOrderService.java | 8 ++ .../SecurityOrderServiceImpl.java | 73 ++++++++++++++++--- 5 files changed, 74 insertions(+), 16 deletions(-) diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/dataobject/workorder/OpsOrderSecurityExtDO.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/dataobject/workorder/OpsOrderSecurityExtDO.java index 5073164..426eeef 100644 --- a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/dataobject/workorder/OpsOrderSecurityExtDO.java +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/dataobject/workorder/OpsOrderSecurityExtDO.java @@ -81,6 +81,10 @@ public class OpsOrderSecurityExtDO extends BaseDO { * 处理结果图片URL,JSON数组 */ private String resultImgUrls; + /** + * 是否误报(true=误报) + */ + private Boolean falseAlarm; // ==================== 关键时间点 ==================== diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCompleteReqDTO.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCompleteReqDTO.java index ba1a094..3fce009 100644 --- a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCompleteReqDTO.java +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCompleteReqDTO.java @@ -29,9 +29,8 @@ public class SecurityOrderCompleteReqDTO { private List resultImgUrls; /** - * 操作人ID(由 Controller 层填充) + * 操作人ID(由 Controller 层填充,open-api 场景可为 null,Service 层会自动取已分配人员) */ - @NotNull(message = "操作人ID不能为空") private Long operatorId; } diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCreateReqDTO.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCreateReqDTO.java index 56d90ed..fae0fd5 100644 --- a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCreateReqDTO.java +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCreateReqDTO.java @@ -28,8 +28,6 @@ public class SecurityOrderCreateReqDTO { @NotNull(message = "区域ID不能为空") private Long areaId; - private String location; - // ==================== 告警来源 ==================== private String alarmId; diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderService.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderService.java index 245d602..b77cb47 100644 --- a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderService.java +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderService.java @@ -44,6 +44,14 @@ public interface SecurityOrderService { */ void manualCompleteOrder(SecurityOrderCompleteReqDTO req); + /** + * 误报标记(将工单标记为误报并完成) + * + * @param orderId 工单ID + * @param operatorId 操作人ID(可为 null,为 null 时取已分配人员) + */ + void falseAlarmOrder(Long orderId, Long operatorId); + /** * 根据工单ID查询安保扩展信息 * diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderServiceImpl.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderServiceImpl.java index 206a44c..8f453c9 100644 --- a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderServiceImpl.java +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderServiceImpl.java @@ -1,8 +1,9 @@ package com.viewsh.module.ops.security.service.securityorder; import cn.hutool.core.util.StrUtil; -import com.viewsh.module.ops.core.event.OrderEventPublisher; import com.viewsh.module.ops.core.event.OrderCreatedEvent; +import com.viewsh.module.ops.core.event.OrderEventPublisher; +import com.viewsh.module.ops.dal.dataobject.area.OpsBusAreaDO; import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO; import com.viewsh.module.ops.dal.mysql.area.OpsBusAreaMapper; import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper; @@ -11,17 +12,14 @@ import com.viewsh.module.ops.enums.PriorityEnum; import com.viewsh.module.ops.enums.SourceTypeEnum; import com.viewsh.module.ops.enums.WorkOrderStatusEnum; import com.viewsh.module.ops.enums.WorkOrderTypeEnum; +import com.viewsh.module.ops.infrastructure.area.AreaPathBuilder; import com.viewsh.module.ops.infrastructure.code.OrderCodeGenerator; +import com.viewsh.module.ops.infrastructure.id.OrderIdGenerator; import com.viewsh.module.ops.infrastructure.log.enumeration.EventDomain; import com.viewsh.module.ops.infrastructure.log.enumeration.LogModule; import com.viewsh.module.ops.infrastructure.log.enumeration.LogType; import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecord; import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecorder; -// 注意:confirm/complete 的业务日志由 SecurityOrderEventListener 统一记录 -// 本类仅记录 CREATE 日志(创建不经过状态变更事件) -import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception; -import static com.viewsh.module.ops.enums.ErrorCodeConstants.*; -import com.viewsh.module.ops.infrastructure.id.OrderIdGenerator; import com.viewsh.module.ops.security.dal.dataobject.workorder.OpsOrderSecurityExtDO; import com.viewsh.module.ops.security.dal.mysql.workorder.OpsOrderSecurityExtMapper; import com.viewsh.module.ops.service.fsm.OrderStateMachine; @@ -30,6 +28,9 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception; +import static com.viewsh.module.ops.enums.ErrorCodeConstants.*; + /** * 安保工单服务实现 * @@ -57,6 +58,9 @@ public class SecurityOrderServiceImpl implements SecurityOrderService { @Resource private OpsBusAreaMapper opsBusAreaMapper; + @Resource + private AreaPathBuilder areaPathBuilder; + @Resource private OrderStateMachine orderStateMachine; @@ -69,7 +73,8 @@ public class SecurityOrderServiceImpl implements SecurityOrderService { @Transactional(rollbackFor = Exception.class) public Long createSecurityOrder(SecurityOrderCreateReqDTO createReq) { // 0. 校验区域是否存在 - if (opsBusAreaMapper.selectById(createReq.getAreaId()) == null) { + OpsBusAreaDO area = opsBusAreaMapper.selectById(createReq.getAreaId()); + if (area == null) { throw exception(AREA_NOT_FOUND); } @@ -82,7 +87,7 @@ public class SecurityOrderServiceImpl implements SecurityOrderService { ? createReq.getSourceType() : (StrUtil.isNotBlank(createReq.getAlarmId()) ? SourceTypeEnum.ALARM.getType() : SourceTypeEnum.MANUAL.getType()); - // 3. 构建主表记录 + // 3. 构建主表记录(location 由 areaId 自动拼接) OpsOrderDO order = OpsOrderDO.builder() .id(orderId) .orderCode(orderCode) @@ -93,7 +98,7 @@ public class SecurityOrderServiceImpl implements SecurityOrderService { .priority(createReq.getPriority() != null ? createReq.getPriority() : PriorityEnum.P2.getPriority()) .status(WorkOrderStatusEnum.PENDING.getStatus()) .areaId(createReq.getAreaId()) - .location(createReq.getLocation()) + .location(areaPathBuilder.buildPath(area)) .build(); opsOrderMapper.insert(order); @@ -142,11 +147,14 @@ public class SecurityOrderServiceImpl implements SecurityOrderService { OpsOrderDO order = getOrderOrThrow(orderId); validateOrderType(order); + // 如果 userId 为 null(open-api 调用),取已分配人员 + Long effectiveUserId = resolveOperatorId(orderId, userId); + // 状态转换:DISPATCHED → CONFIRMED(扩展表时间 + 业务日志由 EventListener 统一记录) orderStateMachine.transition(order, WorkOrderStatusEnum.CONFIRMED, - OperatorTypeEnum.SECURITY_GUARD, userId, "安保人员确认接单"); + OperatorTypeEnum.SECURITY_GUARD, effectiveUserId, "安保人员确认接单"); - log.info("安保工单确认: orderId={}, userId={}", orderId, userId); + log.info("安保工单确认: orderId={}, userId={}", orderId, effectiveUserId); } @Override @@ -169,9 +177,12 @@ public class SecurityOrderServiceImpl implements SecurityOrderService { OpsOrderDO order = getOrderOrThrow(req.getOrderId()); validateOrderType(order); + // 如果 operatorId 为 null(open-api 调用),取已分配人员 + Long effectiveOperatorId = resolveOperatorId(req.getOrderId(), req.getOperatorId()); + // 状态转换 → COMPLETED(扩展表 completedTime + 业务日志由 EventListener 统一记录,主表 endTime 由状态机统一设置) orderStateMachine.transition(order, WorkOrderStatusEnum.COMPLETED, - OperatorTypeEnum.SECURITY_GUARD, req.getOperatorId(), "安保人员提交处理结果"); + OperatorTypeEnum.SECURITY_GUARD, effectiveOperatorId, "安保人员提交处理结果"); // 更新扩展表:结果 + 图片 OpsOrderSecurityExtDO extUpdate = new OpsOrderSecurityExtDO(); @@ -185,6 +196,29 @@ public class SecurityOrderServiceImpl implements SecurityOrderService { log.info("安保工单人工完单: orderId={}", req.getOrderId()); } + @Override + @Transactional(rollbackFor = Exception.class) + public void falseAlarmOrder(Long orderId, Long operatorId) { + OpsOrderDO order = getOrderOrThrow(orderId); + validateOrderType(order); + + // 如果 operatorId 为 null(open-api 调用),取已分配人员 + Long effectiveOperatorId = resolveOperatorId(orderId, operatorId); + + // 状态转换 → COMPLETED + orderStateMachine.transition(order, WorkOrderStatusEnum.COMPLETED, + OperatorTypeEnum.SECURITY_GUARD, effectiveOperatorId, "误报标记"); + + // 更新扩展表:标记误报 + 结果 + OpsOrderSecurityExtDO extUpdate = new OpsOrderSecurityExtDO(); + extUpdate.setOpsOrderId(orderId); + extUpdate.setFalseAlarm(true); + extUpdate.setResult("误报"); + securityExtMapper.insertOrUpdateSelective(extUpdate); + + log.info("安保工单误报标记: orderId={}", orderId); + } + @Override public OpsOrderSecurityExtDO getSecurityExt(Long opsOrderId) { return securityExtMapper.selectByOpsOrderId(opsOrderId); @@ -206,4 +240,19 @@ public class SecurityOrderServiceImpl implements SecurityOrderService { } } + /** + * 解析操作人ID:如果传入为 null,则取扩展表中已分配的安保人员 + */ + private Long resolveOperatorId(Long orderId, Long operatorId) { + if (operatorId != null) { + return operatorId; + } + OpsOrderSecurityExtDO ext = securityExtMapper.selectByOpsOrderId(orderId); + Long assignedUserId = ext != null ? ext.getAssignedUserId() : null; + if (assignedUserId == null) { + log.warn("工单未分配安保人员,操作人为空: orderId={}", orderId); + } + return assignedUserId; + } + } From ea64ca9c61846b1a792af775ae21d2e77e5ce03c Mon Sep 17 00:00:00 2001 From: lzh Date: Sun, 15 Mar 2026 10:33:51 +0800 Subject: [PATCH 22/35] =?UTF-8?q?feat(ops):=20=E5=AE=89=E4=BF=9D=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=20admin-api/open-api=20=E8=A1=A5=E5=85=A8=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E3=80=81=E6=8F=90=E4=BA=A4=E3=80=81=E8=AF=AF=E6=8A=A5?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit admin-api: - 新增 /false-alarm 误报标记接口(权限: ops:security-order:complete) - createOrder 移除 location 字段 open-api: - 新增 /confirm 确认工单(无需传 userId) - 新增 /submit 提交处理结果(结果描述 + 图片) - 新增 /false-alarm 误报标记 - createOrder 移除 location 字段 VO 优化: - 合并 SecurityOrderFalseAlarmReqVO 和 SecurityOrderOpenConfirmReqVO 为通用 SecurityOrderIdReqVO,消除重复定义 Co-Authored-By: Claude Opus 4.6 --- .../security/SecurityOrderController.java | 10 +++++- .../security/vo/SecurityOrderCreateReqVO.java | 3 -- .../vo/SecurityOrderFalseAlarmReqVO.java | 20 ----------- .../vo/SecurityOrderOpenConfirmReqVO.java | 20 ----------- .../security/SecurityOrderOpenController.java | 36 ++++++++++++++++++- 5 files changed, 44 insertions(+), 45 deletions(-) delete mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderFalseAlarmReqVO.java delete mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderOpenConfirmReqVO.java diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/SecurityOrderController.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/SecurityOrderController.java index 948ccc7..d908323 100644 --- a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/SecurityOrderController.java +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/SecurityOrderController.java @@ -6,6 +6,7 @@ import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderAutoCompl import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderCompleteReqVO; import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderConfirmReqVO; import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderCreateReqVO; +import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderIdReqVO; import com.viewsh.module.ops.security.service.securityorder.SecurityOrderCompleteReqDTO; import com.viewsh.module.ops.security.service.securityorder.SecurityOrderCreateReqDTO; import com.viewsh.module.ops.security.service.securityorder.SecurityOrderService; @@ -48,7 +49,6 @@ public class SecurityOrderController { .description(reqVO.getDescription()) .priority(reqVO.getPriority()) .areaId(reqVO.getAreaId()) - .location(reqVO.getLocation()) .alarmId(reqVO.getAlarmId()) .alarmType(reqVO.getAlarmType()) .cameraId(reqVO.getCameraId()) @@ -90,4 +90,12 @@ public class SecurityOrderController { return success(true); } + @PostMapping("/false-alarm") + @Operation(summary = "误报标记", description = "将安保工单标记为误报并完成") + @PreAuthorize("@ss.hasPermission('ops:security-order:complete')") + public CommonResult falseAlarmOrder(@Valid @RequestBody SecurityOrderIdReqVO reqVO) { + securityOrderService.falseAlarmOrder(reqVO.getOrderId(), SecurityFrameworkUtils.getLoginUserId()); + return success(true); + } + } diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderCreateReqVO.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderCreateReqVO.java index 3719314..d0a1b96 100644 --- a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderCreateReqVO.java +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderCreateReqVO.java @@ -28,9 +28,6 @@ public class SecurityOrderCreateReqVO { @NotNull(message = "区域ID不能为空") private Long areaId; - @Schema(description = "具体位置描述", example = "A栋3层东侧走廊") - private String location; - // ==================== 告警来源 ==================== @Schema(description = "关联告警ID", example = "ALM20260211001") diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderFalseAlarmReqVO.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderFalseAlarmReqVO.java deleted file mode 100644 index 9bf0cab..0000000 --- a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderFalseAlarmReqVO.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.viewsh.module.ops.controller.admin.security.vo; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; -import lombok.Data; - -/** - * 安保工单误报请求 VO - * - * @author lzh - */ -@Schema(description = "安保工单误报请求") -@Data -public class SecurityOrderFalseAlarmReqVO { - - @Schema(description = "工单ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001") - @NotNull(message = "工单ID不能为空") - private Long orderId; - -} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderOpenConfirmReqVO.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderOpenConfirmReqVO.java deleted file mode 100644 index 8a5b35e..0000000 --- a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderOpenConfirmReqVO.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.viewsh.module.ops.controller.admin.security.vo; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; -import lombok.Data; - -/** - * 安保工单开放接口确认请求 VO(无需传 userId,默认使用已分配人员) - * - * @author lzh - */ -@Schema(description = "安保工单确认请求(开放接口)") -@Data -public class SecurityOrderOpenConfirmReqVO { - - @Schema(description = "工单ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001") - @NotNull(message = "工单ID不能为空") - private Long orderId; - -} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/open/security/SecurityOrderOpenController.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/open/security/SecurityOrderOpenController.java index fd550ca..aa20670 100644 --- a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/open/security/SecurityOrderOpenController.java +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/open/security/SecurityOrderOpenController.java @@ -3,7 +3,10 @@ package com.viewsh.module.ops.controller.open.security; import com.viewsh.framework.common.pojo.CommonResult; import com.viewsh.framework.signature.core.annotation.ApiSignature; import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderAutoCompleteReqVO; +import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderCompleteReqVO; import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderCreateReqVO; +import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderIdReqVO; +import com.viewsh.module.ops.security.service.securityorder.SecurityOrderCompleteReqDTO; import com.viewsh.module.ops.security.service.securityorder.SecurityOrderCreateReqDTO; import com.viewsh.module.ops.security.service.securityorder.SecurityOrderService; import io.swagger.v3.oas.annotations.Operation; @@ -48,7 +51,6 @@ public class SecurityOrderOpenController { .description(reqVO.getDescription()) .priority(reqVO.getPriority()) .areaId(reqVO.getAreaId()) - .location(reqVO.getLocation()) .alarmId(reqVO.getAlarmId()) .alarmType(reqVO.getAlarmType()) .cameraId(reqVO.getCameraId()) @@ -69,4 +71,36 @@ public class SecurityOrderOpenController { return success(true); } + @PostMapping("/confirm") + @Operation(summary = "确认工单", description = "由外部系统调用,确认安保人员已接单,无需传 userId(自动取已分配人员)") + @ApiSignature + @PermitAll + public CommonResult confirmOrder(@Valid @RequestBody SecurityOrderIdReqVO reqVO) { + securityOrderService.confirmOrder(reqVO.getOrderId(), null); + return success(true); + } + + @PostMapping("/submit") + @Operation(summary = "工单提交", description = "由外部系统调用,提交处理结果(描述 + 图片),完成工单") + @ApiSignature + @PermitAll + public CommonResult submitOrder(@Valid @RequestBody SecurityOrderCompleteReqVO reqVO) { + SecurityOrderCompleteReqDTO dto = SecurityOrderCompleteReqDTO.builder() + .orderId(reqVO.getOrderId()) + .result(reqVO.getResult()) + .resultImgUrls(reqVO.getResultImgUrls()) + .build(); + securityOrderService.manualCompleteOrder(dto); + return success(true); + } + + @PostMapping("/false-alarm") + @Operation(summary = "误报标记", description = "由外部系统调用,将工单标记为误报并完成") + @ApiSignature + @PermitAll + public CommonResult falseAlarmOrder(@Valid @RequestBody SecurityOrderIdReqVO reqVO) { + securityOrderService.falseAlarmOrder(reqVO.getOrderId(), null); + return success(true); + } + } From c9d443a75b22fdbd5bdf2ff2e551dfafdb2f4ba5 Mon Sep 17 00:00:00 2001 From: lzh Date: Sun, 15 Mar 2026 10:35:30 +0800 Subject: [PATCH 23/35] =?UTF-8?q?feat(sql):=20=E5=AE=89=E4=BF=9D=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=E6=89=A9=E5=B1=95=E8=A1=A8=E6=96=B0=E5=A2=9E=20false?= =?UTF-8?q?=5Falarm=20=E5=AD=97=E6=AE=B5=EF=BC=8C=E9=99=84=E5=A2=9E?= =?UTF-8?q?=E9=87=8F=E8=BF=81=E7=A7=BB=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DDL: ops_order_security_ext 新增 false_alarm tinyint(1) 列 - 增量迁移: ops_order_security_ext_migrate.sql 供已部署环境 ALTER TABLE Co-Authored-By: Claude Opus 4.6 --- sql/mysql/ops_order_security_ext.sql | 1 + sql/mysql/ops_order_security_ext_migrate.sql | 9 +++++++++ 2 files changed, 10 insertions(+) create mode 100644 sql/mysql/ops_order_security_ext_migrate.sql diff --git a/sql/mysql/ops_order_security_ext.sql b/sql/mysql/ops_order_security_ext.sql index 983fad0..5dd86e2 100644 --- a/sql/mysql/ops_order_security_ext.sql +++ b/sql/mysql/ops_order_security_ext.sql @@ -21,6 +21,7 @@ CREATE TABLE `ops_order_security_ext` ( -- 处理结果(完成时提交) `result` text DEFAULT NULL COMMENT '处理结果描述', `result_img_urls` varchar(2048) DEFAULT NULL COMMENT '处理结果图片URL,JSON数组', + `false_alarm` tinyint(1) DEFAULT NULL COMMENT '是否误报: 1=误报', -- 关键时间点 `dispatched_time` datetime DEFAULT NULL COMMENT '派单时间', diff --git a/sql/mysql/ops_order_security_ext_migrate.sql b/sql/mysql/ops_order_security_ext_migrate.sql new file mode 100644 index 0000000..80f755e --- /dev/null +++ b/sql/mysql/ops_order_security_ext_migrate.sql @@ -0,0 +1,9 @@ +-- ---------------------------- +-- Incremental migration for ops_order_security_ext +-- Version: v1.1.0 (2026-03-15) +-- Description: Add false_alarm column for false alarm marking +-- ---------------------------- + +ALTER TABLE `ops_order_security_ext` + ADD COLUMN `false_alarm` tinyint(1) DEFAULT NULL COMMENT '是否误报: 1=误报' + AFTER `result_img_urls`; From 4796009e95818c980950d54ed75b3868f9e19ce9 Mon Sep 17 00:00:00 2001 From: lzh Date: Sun, 15 Mar 2026 17:23:14 +0800 Subject: [PATCH 24/35] =?UTF-8?q?fix(test):=20=E7=A7=BB=E9=99=A4=20Securit?= =?UTF-8?q?yOrderServiceTest=20=E4=B8=AD=E6=97=A0=E6=95=88=E7=9A=84=20setL?= =?UTF-8?q?ocation=20=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit location 由 AreaPathBuilder.buildPath() 自动生成,DTO 中无此字段,修复编译错误。 Co-Authored-By: Claude Opus 4.6 --- .../security/service/securityorder/SecurityOrderServiceTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderServiceTest.java b/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderServiceTest.java index 8be3593..343183a 100644 --- a/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderServiceTest.java +++ b/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderServiceTest.java @@ -90,7 +90,6 @@ public class SecurityOrderServiceTest { req.setDescription("摄像头检测到异常人员"); req.setPriority(PriorityEnum.P1.getPriority()); req.setAreaId(100L); - req.setLocation("A栋3层东侧走廊"); req.setAlarmId("ALM20260310001"); req.setAlarmType("intrusion"); req.setCameraId("CAM_001"); From 6a9aa82bac93b0479da1e0a7070a8641e9233c31 Mon Sep 17 00:00:00 2001 From: lzh Date: Tue, 17 Mar 2026 17:44:21 +0800 Subject: [PATCH 25/35] =?UTF-8?q?feat(infra):=20S3=20=E7=A7=81=E6=9C=89?= =?UTF-8?q?=E6=A1=B6=E9=A2=84=E7=AD=BE=E5=90=8D=E6=A0=B8=E5=BF=83=E8=83=BD?= =?UTF-8?q?=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - S3FileClient.buildDomain() 修复 COS virtual-hosted-style 域名生成 - S3FileClient.presignGetUrl() 支持跨桶签名及 endpoint 校验 - FileApi.presignGetUrl() 修复 Feign nullable 参数注解 Co-Authored-By: Claude Opus 4.6 --- .../viewsh/module/infra/api/file/FileApi.java | 146 ++--- .../file/core/client/s3/S3FileClient.java | 512 ++++++++++-------- 2 files changed, 352 insertions(+), 306 deletions(-) diff --git a/viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/FileApi.java b/viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/FileApi.java index 777a183..6dbfd7d 100644 --- a/viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/FileApi.java +++ b/viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/FileApi.java @@ -1,73 +1,73 @@ -package com.viewsh.module.infra.api.file; - -import com.viewsh.framework.common.pojo.CommonResult; -import com.viewsh.module.infra.api.file.dto.FileCreateReqDTO; -import com.viewsh.module.infra.enums.ApiConstants; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotEmpty; -import org.springframework.cloud.openfeign.FeignClient; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestParam; - -@FeignClient(name = ApiConstants.NAME) // TODO 芋艿:fallbackFactory = -@Tag(name = "RPC 服务 - 文件") -public interface FileApi { - - String PREFIX = ApiConstants.PREFIX + "/file"; - - /** - * 保存文件,并返回文件的访问路径 - * - * @param content 文件内容 - * @return 文件路径 - */ - default String createFile(byte[] content) { - return createFile(content, null, null, null); - } - - /** - * 保存文件,并返回文件的访问路径 - * - * @param content 文件内容 - * @param name 文件名称,允许空 - * @return 文件路径 - */ - default String createFile(byte[] content, String name) { - return createFile(content, name, null, null); - } - - /** - * 保存文件,并返回文件的访问路径 - * - * @param content 文件内容 - * @param name 文件名称,允许空 - * @param directory 目录,允许空 - * @param type 文件的 MIME 类型,允许空 - * @return 文件路径 - */ - default String createFile(@NotEmpty(message = "文件内容不能为空") byte[] content, - String name, String directory, String type) { - return createFile(new FileCreateReqDTO().setName(name).setDirectory(directory).setType(type).setContent(content)).getCheckedData(); - } - - @PostMapping(PREFIX + "/create") - @Operation(summary = "保存文件,并返回文件的访问路径") - CommonResult createFile(@Valid @RequestBody FileCreateReqDTO createReqDTO); - - /** - * 生成文件预签名地址,用于读取 - * - * @param url 完整的文件访问地址 - * @param expirationSeconds 访问有效期,单位秒 - * @return 文件预签名地址 - */ - @GetMapping(PREFIX + "/presigned-url") - @Operation(summary = "生成文件预签名地址,用于读取") - CommonResult presignGetUrl(@NotEmpty(message = "URL 不能为空") @RequestParam("url") String url, - Integer expirationSeconds); - -} +package com.viewsh.module.infra.api.file; + +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.module.infra.api.file.dto.FileCreateReqDTO; +import com.viewsh.module.infra.enums.ApiConstants; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +@FeignClient(name = ApiConstants.NAME) // TODO 芋艿:fallbackFactory = +@Tag(name = "RPC 服务 - 文件") +public interface FileApi { + + String PREFIX = ApiConstants.PREFIX + "/file"; + + /** + * 保存文件,并返回文件的访问路径 + * + * @param content 文件内容 + * @return 文件路径 + */ + default String createFile(byte[] content) { + return createFile(content, null, null, null); + } + + /** + * 保存文件,并返回文件的访问路径 + * + * @param content 文件内容 + * @param name 文件名称,允许空 + * @return 文件路径 + */ + default String createFile(byte[] content, String name) { + return createFile(content, name, null, null); + } + + /** + * 保存文件,并返回文件的访问路径 + * + * @param content 文件内容 + * @param name 文件名称,允许空 + * @param directory 目录,允许空 + * @param type 文件的 MIME 类型,允许空 + * @return 文件路径 + */ + default String createFile(@NotEmpty(message = "文件内容不能为空") byte[] content, + String name, String directory, String type) { + return createFile(new FileCreateReqDTO().setName(name).setDirectory(directory).setType(type).setContent(content)).getCheckedData(); + } + + @PostMapping(PREFIX + "/create") + @Operation(summary = "保存文件,并返回文件的访问路径") + CommonResult createFile(@Valid @RequestBody FileCreateReqDTO createReqDTO); + + /** + * 生成文件预签名地址,用于读取 + * + * @param url 完整的文件访问地址 + * @param expirationSeconds 访问有效期,单位秒 + * @return 文件预签名地址 + */ + @GetMapping(PREFIX + "/presigned-url") + @Operation(summary = "生成文件预签名地址,用于读取") + CommonResult presignGetUrl(@NotEmpty(message = "URL 不能为空") @RequestParam("url") String url, + @RequestParam(value = "expirationSeconds", required = false) Integer expirationSeconds); + +} diff --git a/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/framework/file/core/client/s3/S3FileClient.java b/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/framework/file/core/client/s3/S3FileClient.java index d892942..09def07 100644 --- a/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/framework/file/core/client/s3/S3FileClient.java +++ b/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/framework/file/core/client/s3/S3FileClient.java @@ -1,233 +1,279 @@ -package com.viewsh.module.infra.framework.file.core.client.s3; - -import cn.hutool.core.io.IoUtil; -import cn.hutool.core.util.BooleanUtil; -import cn.hutool.core.util.StrUtil; -import cn.hutool.http.HttpUtil; -import com.viewsh.framework.common.util.http.HttpUtils; -import com.viewsh.module.infra.framework.file.core.client.AbstractFileClient; -import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; -import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; -import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; -import software.amazon.awssdk.core.sync.RequestBody; -import software.amazon.awssdk.regions.Region; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.S3Configuration; -import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; -import software.amazon.awssdk.services.s3.model.GetObjectRequest; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; -import software.amazon.awssdk.services.s3.presigner.S3Presigner; -import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; -import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; - -import java.net.URI; -import java.net.URL; -import java.time.Duration; - -/** - * 基于 S3 协议的文件客户端,实现 MinIO、阿里云、腾讯云、七牛云、华为云等云服务 - * - * @author 芋道源码 - */ -public class S3FileClient extends AbstractFileClient { - - private static final Duration EXPIRATION_DEFAULT = Duration.ofHours(24); - - private S3Client client; - private S3Presigner presigner; - - public S3FileClient(Long id, S3FileClientConfig config) { - super(id, config); - } - - @Override - protected void doInit() { - // 补全 domain - if (StrUtil.isEmpty(config.getDomain())) { - config.setDomain(buildDomain()); - } - // 初始化 S3 客户端 - // 优先级:配置的 region > 从 endpoint 解析的 region > 默认值 us-east-1 - String regionStr = resolveRegion(); - Region region = Region.of(regionStr); - AwsCredentialsProvider credentialsProvider = StaticCredentialsProvider.create( - AwsBasicCredentials.create(config.getAccessKey(), config.getAccessSecret())); - URI endpoint = URI.create(buildEndpoint()); - S3Configuration serviceConfiguration = S3Configuration.builder() // Path-style 访问 - .pathStyleAccessEnabled(Boolean.TRUE.equals(config.getEnablePathStyleAccess())) - .chunkedEncodingEnabled(false) // 禁用分块编码,参见 https://t.zsxq.com/kBy57 - .build(); - client = S3Client.builder() - .credentialsProvider(credentialsProvider) - .region(region) - .endpointOverride(endpoint) - .serviceConfiguration(serviceConfiguration) - .build(); - presigner = S3Presigner.builder() - .credentialsProvider(credentialsProvider) - .region(region) - .endpointOverride(endpoint) - .serviceConfiguration(serviceConfiguration) - .build(); - } - - @Override - public String upload(byte[] content, String path, String type) { - // 构造 PutObjectRequest - PutObjectRequest putRequest = PutObjectRequest.builder() - .bucket(config.getBucket()) - .key(path) - .contentType(type) - .contentLength((long) content.length) - .build(); - // 上传文件 - client.putObject(putRequest, RequestBody.fromBytes(content)); - // 拼接返回路径 - return presignGetUrl(path, null); - } - - @Override - public void delete(String path) { - DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder() - .bucket(config.getBucket()) - .key(path) - .build(); - client.deleteObject(deleteRequest); - } - - @Override - public byte[] getContent(String path) { - GetObjectRequest getRequest = GetObjectRequest.builder() - .bucket(config.getBucket()) - .key(path) - .build(); - return IoUtil.readBytes(client.getObject(getRequest)); - } - - @Override - public String presignPutUrl(String path) { - return presigner.presignPutObject(PutObjectPresignRequest.builder() - .signatureDuration(EXPIRATION_DEFAULT) - .putObjectRequest(b -> b.bucket(config.getBucket()).key(path)).build()) - .url().toString(); - } - - @Override - public String presignGetUrl(String url, Integer expirationSeconds) { - // 1. 将 url 转换为 path - String path = StrUtil.removePrefix(url, config.getDomain() + "/"); - path = HttpUtils.decodeUtf8(HttpUtils.removeUrlQuery(path)); - - // 2.1 情况一:公开访问:无需签名 - // 考虑到老版本的兼容,所以必须是 config.getEnablePublicAccess() 为 false 时,才进行签名 - if (!BooleanUtil.isFalse(config.getEnablePublicAccess())) { - return config.getDomain() + "/" + path; - } - - // 2.2 情况二:私有访问:生成 GET 预签名 URL - String finalPath = path; - Duration expiration = expirationSeconds != null ? Duration.ofSeconds(expirationSeconds) : EXPIRATION_DEFAULT; - URL signedUrl = presigner.presignGetObject(GetObjectPresignRequest.builder() - .signatureDuration(expiration) - .getObjectRequest(b -> b.bucket(config.getBucket()).key(finalPath)).build()) - .url(); - return signedUrl.toString(); - } - - /** - * 基于 bucket + endpoint 构建访问的 Domain 地址 - * - * @return Domain 地址 - */ - private String buildDomain() { - // 如果已经是 http 或者 https,则不进行拼接.主要适配 MinIO - if (HttpUtil.isHttp(config.getEndpoint()) || HttpUtil.isHttps(config.getEndpoint())) { - return StrUtil.format("{}/{}", config.getEndpoint(), config.getBucket()); - } - // 阿里云、腾讯云、华为云都适合。七牛云比较特殊,必须有自定义域名 - return StrUtil.format("https://{}.{}", config.getBucket(), config.getEndpoint()); - } - - /** - * 节点地址补全协议头 - * - * @return 节点地址 - */ - private String buildEndpoint() { - // 如果已经是 http 或者 https,则不进行拼接 - if (HttpUtil.isHttp(config.getEndpoint()) || HttpUtil.isHttps(config.getEndpoint())) { - return config.getEndpoint(); - } - return StrUtil.format("https://{}", config.getEndpoint()); - } - - /** - * 解析 AWS 区域 - * 优先级:配置的 region > 从 endpoint 解析的 region > 默认值 us-east-1 - * - * @return 区域字符串 - */ - private String resolveRegion() { - // 1. 如果配置了 region,直接使用 - if (StrUtil.isNotEmpty(config.getRegion())) { - return config.getRegion(); - } - - // 2.1 尝试从 endpoint 中解析 region - String endpoint = config.getEndpoint(); - if (StrUtil.isEmpty(endpoint)) { - return "us-east-1"; - } - - // 2.2 移除协议头(http:// 或 https://) - String host = endpoint; - if (HttpUtil.isHttp(endpoint) || HttpUtil.isHttps(endpoint)) { - try { - host = URI.create(endpoint).getHost(); - } catch (Exception e) { - // 解析失败,使用默认值 - return "us-east-1"; - } - } - if (StrUtil.isEmpty(host)) { - return "us-east-1"; - } - - // 3.1 AWS S3 格式:s3.us-west-2.amazonaws.com 或 s3.amazonaws.com - if (host.contains("amazonaws.com")) { - // 匹配 s3.{region}.amazonaws.com 格式 - if (host.startsWith("s3.") && host.contains(".amazonaws.com")) { - String regionPart = host.substring(3, host.indexOf(".amazonaws.com")); - if (StrUtil.isNotEmpty(regionPart) && !regionPart.equals("accelerate")) { - return regionPart; - } - } - // s3.amazonaws.com 或 s3-accelerate.amazonaws.com 使用默认值 - return "us-east-1"; - } - // 3.2 阿里云 OSS 格式:oss-cn-beijing.aliyuncs.com - if (host.contains(S3FileClientConfig.ENDPOINT_ALIYUN)) { - // 匹配 oss-{region}.aliyuncs.com 格式 - if (host.startsWith("oss-") && host.contains("." + S3FileClientConfig.ENDPOINT_ALIYUN)) { - String regionPart = host.substring(4, host.indexOf("." + S3FileClientConfig.ENDPOINT_ALIYUN)); - if (StrUtil.isNotEmpty(regionPart)) { - return regionPart; - } - } - } - // 3.3 腾讯云 COS 格式:cos.ap-shanghai.myqcloud.com - if (host.contains(S3FileClientConfig.ENDPOINT_TENCENT)) { - // 匹配 cos.{region}.myqcloud.com 格式 - if (host.startsWith("cos.") && host.contains("." + S3FileClientConfig.ENDPOINT_TENCENT)) { - String regionPart = host.substring(4, host.indexOf("." + S3FileClientConfig.ENDPOINT_TENCENT)); - if (StrUtil.isNotEmpty(regionPart)) { - return regionPart; - } - } - } - - // 3.4 其他情况(MinIO、七牛云等)使用默认值 - return "us-east-1"; - } - -} +package com.viewsh.module.infra.framework.file.core.client.s3; + +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.BooleanUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.http.HttpUtil; +import com.viewsh.framework.common.util.http.HttpUtils; +import com.viewsh.module.infra.framework.file.core.client.AbstractFileClient; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +import java.net.URI; +import java.net.URL; +import java.time.Duration; + +/** + * 基于 S3 协议的文件客户端,实现 MinIO、阿里云、腾讯云、七牛云、华为云等云服务 + * + * @author 芋道源码 + */ +public class S3FileClient extends AbstractFileClient { + + private static final Duration EXPIRATION_DEFAULT = Duration.ofHours(24); + + private S3Client client; + private S3Presigner presigner; + + public S3FileClient(Long id, S3FileClientConfig config) { + super(id, config); + } + + @Override + protected void doInit() { + // 补全 domain + if (StrUtil.isEmpty(config.getDomain())) { + config.setDomain(buildDomain()); + } + // 初始化 S3 客户端 + // 优先级:配置的 region > 从 endpoint 解析的 region > 默认值 us-east-1 + String regionStr = resolveRegion(); + Region region = Region.of(regionStr); + AwsCredentialsProvider credentialsProvider = StaticCredentialsProvider.create( + AwsBasicCredentials.create(config.getAccessKey(), config.getAccessSecret())); + URI endpoint = URI.create(buildEndpoint()); + S3Configuration serviceConfiguration = S3Configuration.builder() // Path-style 访问 + .pathStyleAccessEnabled(Boolean.TRUE.equals(config.getEnablePathStyleAccess())) + .chunkedEncodingEnabled(false) // 禁用分块编码,参见 https://t.zsxq.com/kBy57 + .build(); + client = S3Client.builder() + .credentialsProvider(credentialsProvider) + .region(region) + .endpointOverride(endpoint) + .serviceConfiguration(serviceConfiguration) + .build(); + presigner = S3Presigner.builder() + .credentialsProvider(credentialsProvider) + .region(region) + .endpointOverride(endpoint) + .serviceConfiguration(serviceConfiguration) + .build(); + } + + @Override + public String upload(byte[] content, String path, String type) { + // 构造 PutObjectRequest + PutObjectRequest putRequest = PutObjectRequest.builder() + .bucket(config.getBucket()) + .key(path) + .contentType(type) + .contentLength((long) content.length) + .build(); + // 上传文件 + client.putObject(putRequest, RequestBody.fromBytes(content)); + // 拼接返回路径 + return presignGetUrl(path, null); + } + + @Override + public void delete(String path) { + DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder() + .bucket(config.getBucket()) + .key(path) + .build(); + client.deleteObject(deleteRequest); + } + + @Override + public byte[] getContent(String path) { + GetObjectRequest getRequest = GetObjectRequest.builder() + .bucket(config.getBucket()) + .key(path) + .build(); + return IoUtil.readBytes(client.getObject(getRequest)); + } + + @Override + public String presignPutUrl(String path) { + return presigner.presignPutObject(PutObjectPresignRequest.builder() + .signatureDuration(EXPIRATION_DEFAULT) + .putObjectRequest(b -> b.bucket(config.getBucket()).key(path)).build()) + .url().toString(); + } + + @Override + public String presignGetUrl(String url, Integer expirationSeconds) { + // 1. 将 url 转换为 path 和 bucket + String bucket = config.getBucket(); + boolean crossBucket = false; + String path = StrUtil.removePrefix(url, config.getDomain() + "/"); + // 兼容:域名不匹配时(如同账号不同桶),从 URL 中解析 bucket 和 path + if (path.equals(url) && (path.startsWith("http://") || path.startsWith("https://"))) { + try { + URI uri = URI.create(url); + String host = uri.getHost(); + String endpointHost = getEndpointHost(); + // 校验是否同一 S3 服务(如 cos.ap-shanghai.myqcloud.com),避免误签非本服务 URL + if (host != null && endpointHost != null && host.endsWith("." + endpointHost)) { + bucket = StrUtil.subBefore(host, ".", false); + crossBucket = true; + path = StrUtil.removePrefix(uri.getPath(), "/"); + } else { + // 非同服务 URL,原样返回 + return url; + } + } catch (Exception ignored) { + return url; + } + } + path = HttpUtils.decodeUtf8(HttpUtils.removeUrlQuery(path)); + + // 2.1 情况一:公开访问:无需签名 + if (!BooleanUtil.isFalse(config.getEnablePublicAccess())) { + // 跨桶 URL 原样返回,不拼接本桶 domain + if (crossBucket) { + return url; + } + return config.getDomain() + "/" + path; + } + + // 2.2 情况二:私有访问:生成 GET 预签名 URL + String finalPath = path; + String finalBucket = bucket; + Duration expiration = expirationSeconds != null ? Duration.ofSeconds(expirationSeconds) : EXPIRATION_DEFAULT; + URL signedUrl = presigner.presignGetObject(GetObjectPresignRequest.builder() + .signatureDuration(expiration) + .getObjectRequest(b -> b.bucket(finalBucket).key(finalPath)).build()) + .url(); + return signedUrl.toString(); + } + + /** + * 获取 endpoint 的 host 部分(不含协议头) + * 例如:https://cos.ap-shanghai.myqcloud.com → cos.ap-shanghai.myqcloud.com + */ + private String getEndpointHost() { + String endpoint = config.getEndpoint(); + if (HttpUtil.isHttp(endpoint) || HttpUtil.isHttps(endpoint)) { + try { + return URI.create(endpoint).getHost(); + } catch (Exception e) { + return null; + } + } + return endpoint; + } + + /** + * 基于 bucket + endpoint 构建访问的 Domain 地址 + * + * @return Domain 地址 + */ + private String buildDomain() { + if (HttpUtil.isHttp(config.getEndpoint()) || HttpUtil.isHttps(config.getEndpoint())) { + // Path-Style(如 MinIO):{endpoint}/{bucket} + if (Boolean.TRUE.equals(config.getEnablePathStyleAccess())) { + return StrUtil.format("{}/{}", config.getEndpoint(), config.getBucket()); + } + // Virtual-Hosted-Style(如腾讯云 COS、阿里云 OSS):{scheme}://{bucket}.{host} + URI uri = URI.create(config.getEndpoint()); + return StrUtil.format("{}://{}.{}", uri.getScheme(), config.getBucket(), uri.getHost()); + } + // 没有协议头,直接拼接为虚拟主机风格 + return StrUtil.format("https://{}.{}", config.getBucket(), config.getEndpoint()); + } + + /** + * 节点地址补全协议头 + * + * @return 节点地址 + */ + private String buildEndpoint() { + // 如果已经是 http 或者 https,则不进行拼接 + if (HttpUtil.isHttp(config.getEndpoint()) || HttpUtil.isHttps(config.getEndpoint())) { + return config.getEndpoint(); + } + return StrUtil.format("https://{}", config.getEndpoint()); + } + + /** + * 解析 AWS 区域 + * 优先级:配置的 region > 从 endpoint 解析的 region > 默认值 us-east-1 + * + * @return 区域字符串 + */ + private String resolveRegion() { + // 1. 如果配置了 region,直接使用 + if (StrUtil.isNotEmpty(config.getRegion())) { + return config.getRegion(); + } + + // 2.1 尝试从 endpoint 中解析 region + String endpoint = config.getEndpoint(); + if (StrUtil.isEmpty(endpoint)) { + return "us-east-1"; + } + + // 2.2 移除协议头(http:// 或 https://) + String host = endpoint; + if (HttpUtil.isHttp(endpoint) || HttpUtil.isHttps(endpoint)) { + try { + host = URI.create(endpoint).getHost(); + } catch (Exception e) { + // 解析失败,使用默认值 + return "us-east-1"; + } + } + if (StrUtil.isEmpty(host)) { + return "us-east-1"; + } + + // 3.1 AWS S3 格式:s3.us-west-2.amazonaws.com 或 s3.amazonaws.com + if (host.contains("amazonaws.com")) { + // 匹配 s3.{region}.amazonaws.com 格式 + if (host.startsWith("s3.") && host.contains(".amazonaws.com")) { + String regionPart = host.substring(3, host.indexOf(".amazonaws.com")); + if (StrUtil.isNotEmpty(regionPart) && !regionPart.equals("accelerate")) { + return regionPart; + } + } + // s3.amazonaws.com 或 s3-accelerate.amazonaws.com 使用默认值 + return "us-east-1"; + } + // 3.2 阿里云 OSS 格式:oss-cn-beijing.aliyuncs.com + if (host.contains(S3FileClientConfig.ENDPOINT_ALIYUN)) { + // 匹配 oss-{region}.aliyuncs.com 格式 + if (host.startsWith("oss-") && host.contains("." + S3FileClientConfig.ENDPOINT_ALIYUN)) { + String regionPart = host.substring(4, host.indexOf("." + S3FileClientConfig.ENDPOINT_ALIYUN)); + if (StrUtil.isNotEmpty(regionPart)) { + return regionPart; + } + } + } + // 3.3 腾讯云 COS 格式:cos.ap-shanghai.myqcloud.com + if (host.contains(S3FileClientConfig.ENDPOINT_TENCENT)) { + // 匹配 cos.{region}.myqcloud.com 格式 + if (host.startsWith("cos.") && host.contains("." + S3FileClientConfig.ENDPOINT_TENCENT)) { + String regionPart = host.substring(4, host.indexOf("." + S3FileClientConfig.ENDPOINT_TENCENT)); + if (StrUtil.isNotEmpty(regionPart)) { + return regionPart; + } + } + } + + // 3.4 其他情况(MinIO、七牛云等)使用默认值 + return "us-east-1"; + } + +} From a567c62ae2eb21203e169c4ff5575ba1fd9cbcd8 Mon Sep 17 00:00:00 2001 From: lzh Date: Tue, 17 Mar 2026 17:44:28 +0800 Subject: [PATCH 26/35] =?UTF-8?q?feat(infra):=20=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E5=8F=8A=E8=AF=A6=E6=83=85=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=A7=81=E6=9C=89=E6=A1=B6=E9=A2=84=E7=AD=BE?= =?UTF-8?q?=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FileController.getFilePage/getFile 对私有桶文件 URL 生成预签名访问地址 Co-Authored-By: Claude Opus 4.6 --- .../controller/admin/file/FileController.java | 286 +++++++++--------- 1 file changed, 149 insertions(+), 137 deletions(-) diff --git a/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/controller/admin/file/FileController.java b/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/controller/admin/file/FileController.java index effe775..bdb32f3 100644 --- a/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/controller/admin/file/FileController.java +++ b/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/controller/admin/file/FileController.java @@ -1,137 +1,149 @@ -package com.viewsh.module.infra.controller.admin.file; - -import cn.hutool.core.io.IoUtil; -import cn.hutool.core.util.StrUtil; -import cn.hutool.core.util.URLUtil; -import com.viewsh.framework.common.pojo.CommonResult; -import com.viewsh.framework.common.pojo.PageResult; -import com.viewsh.framework.common.util.object.BeanUtils; -import com.viewsh.framework.tenant.core.aop.TenantIgnore; -import com.viewsh.module.infra.controller.admin.file.vo.file.*; -import com.viewsh.module.infra.dal.dataobject.file.FileDO; -import com.viewsh.module.infra.service.file.FileService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.Parameters; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.annotation.Resource; -import jakarta.annotation.security.PermitAll; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.validation.Valid; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; - -import java.nio.charset.StandardCharsets; -import java.util.List; - -import static com.viewsh.framework.common.pojo.CommonResult.success; -import static com.viewsh.module.infra.framework.file.core.utils.FileTypeUtils.writeAttachment; - -@Tag(name = "管理后台 - 文件存储") -@RestController -@RequestMapping("/infra/file") -@Validated -@Slf4j -public class FileController { - - @Resource - private FileService fileService; - - @PostMapping("/upload") - @Operation(summary = "上传文件", description = "模式一:后端上传文件") - @Parameter(name = "file", description = "文件附件", required = true, - schema = @Schema(type = "string", format = "binary")) - public CommonResult uploadFile(@Valid FileUploadReqVO uploadReqVO) throws Exception { - MultipartFile file = uploadReqVO.getFile(); - byte[] content = IoUtil.readBytes(file.getInputStream()); - return success(fileService.createFile(content, file.getOriginalFilename(), - uploadReqVO.getDirectory(), file.getContentType())); - } - - @GetMapping("/presigned-url") - @Operation(summary = "获取文件预签名地址(上传)", description = "模式二:前端上传文件:用于前端直接上传七牛、阿里云 OSS 等文件存储器") - @Parameters({ - @Parameter(name = "name", description = "文件名称", required = true), - @Parameter(name = "directory", description = "文件目录") - }) - public CommonResult getFilePresignedUrl( - @RequestParam("name") String name, - @RequestParam(value = "directory", required = false) String directory) { - return success(fileService.presignPutUrl(name, directory)); - } - - @PostMapping("/create") - @Operation(summary = "创建文件", description = "模式二:前端上传文件:配合 presigned-url 接口,记录上传了上传的文件") - public CommonResult createFile(@Valid @RequestBody FileCreateReqVO createReqVO) { - return success(fileService.createFile(createReqVO)); - } - - @GetMapping("/get") - @Operation(summary = "获得文件") - @Parameter(name = "id", description = "编号", required = true) - @PreAuthorize("@ss.hasPermission('infra:file:query')") - public CommonResult getFile(@RequestParam("id") Long id) { - return success(BeanUtils.toBean(fileService.getFile(id), FileRespVO.class)); - } - - @DeleteMapping("/delete") - @Operation(summary = "删除文件") - @Parameter(name = "id", description = "编号", required = true) - @PreAuthorize("@ss.hasPermission('infra:file:delete')") - public CommonResult deleteFile(@RequestParam("id") Long id) throws Exception { - fileService.deleteFile(id); - return success(true); - } - - @DeleteMapping("/delete-list") - @Operation(summary = "批量删除文件") - @Parameter(name = "ids", description = "编号列表", required = true) - @PreAuthorize("@ss.hasPermission('infra:file:delete')") - public CommonResult deleteFileList(@RequestParam("ids") List ids) throws Exception { - fileService.deleteFileList(ids); - return success(true); - } - - @GetMapping("/{configId}/get/**") - @PermitAll - @TenantIgnore - @Operation(summary = "下载文件") - @Parameter(name = "configId", description = "配置编号", required = true) - public void getFileContent(HttpServletRequest request, - HttpServletResponse response, - @PathVariable("configId") Long configId) throws Exception { - // 获取请求的路径 - String path = StrUtil.subAfter(request.getRequestURI(), "/get/", false); - if (StrUtil.isEmpty(path)) { - throw new IllegalArgumentException("结尾的 path 路径必须传递"); - } - // 解码,解决中文路径的问题 - // https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/807/ - // https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/1432/ - path = URLUtil.decode(path, StandardCharsets.UTF_8, false); - - // 读取内容 - byte[] content = fileService.getFileContent(configId, path); - if (content == null) { - log.warn("[getFileContent][configId({}) path({}) 文件不存在]", configId, path); - response.setStatus(HttpStatus.NOT_FOUND.value()); - return; - } - writeAttachment(response, path, content); - } - - @GetMapping("/page") - @Operation(summary = "获得文件分页") - @PreAuthorize("@ss.hasPermission('infra:file:query')") - public CommonResult> getFilePage(@Valid FilePageReqVO pageVO) { - PageResult pageResult = fileService.getFilePage(pageVO); - return success(BeanUtils.toBean(pageResult, FileRespVO.class)); - } - -} +package com.viewsh.module.infra.controller.admin.file; + +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.core.util.URLUtil; +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.framework.common.pojo.PageResult; +import com.viewsh.framework.common.util.object.BeanUtils; +import com.viewsh.framework.tenant.core.aop.TenantIgnore; +import com.viewsh.module.infra.controller.admin.file.vo.file.*; +import com.viewsh.module.infra.dal.dataobject.file.FileDO; +import com.viewsh.module.infra.service.file.FileService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.annotation.security.PermitAll; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static com.viewsh.framework.common.pojo.CommonResult.success; +import static com.viewsh.module.infra.framework.file.core.utils.FileTypeUtils.writeAttachment; + +@Tag(name = "管理后台 - 文件存储") +@RestController +@RequestMapping("/infra/file") +@Validated +@Slf4j +public class FileController { + + @Resource + private FileService fileService; + + @PostMapping("/upload") + @Operation(summary = "上传文件", description = "模式一:后端上传文件") + @Parameter(name = "file", description = "文件附件", required = true, + schema = @Schema(type = "string", format = "binary")) + public CommonResult uploadFile(@Valid FileUploadReqVO uploadReqVO) throws Exception { + MultipartFile file = uploadReqVO.getFile(); + byte[] content = IoUtil.readBytes(file.getInputStream()); + return success(fileService.createFile(content, file.getOriginalFilename(), + uploadReqVO.getDirectory(), file.getContentType())); + } + + @GetMapping("/presigned-url") + @Operation(summary = "获取文件预签名地址(上传)", description = "模式二:前端上传文件:用于前端直接上传七牛、阿里云 OSS 等文件存储器") + @Parameters({ + @Parameter(name = "name", description = "文件名称", required = true), + @Parameter(name = "directory", description = "文件目录") + }) + public CommonResult getFilePresignedUrl( + @RequestParam("name") String name, + @RequestParam(value = "directory", required = false) String directory) { + return success(fileService.presignPutUrl(name, directory)); + } + + @PostMapping("/create") + @Operation(summary = "创建文件", description = "模式二:前端上传文件:配合 presigned-url 接口,记录上传了上传的文件") + public CommonResult createFile(@Valid @RequestBody FileCreateReqVO createReqVO) { + return success(fileService.createFile(createReqVO)); + } + + @GetMapping("/get") + @Operation(summary = "获得文件") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('infra:file:query')") + public CommonResult getFile(@RequestParam("id") Long id) { + FileRespVO respVO = BeanUtils.toBean(fileService.getFile(id), FileRespVO.class); + // 私有桶:对文件 URL 生成预签名访问地址 + if (respVO != null && StrUtil.isNotEmpty(respVO.getUrl())) { + respVO.setUrl(fileService.presignGetUrl(respVO.getUrl(), null)); + } + return success(respVO); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除文件") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('infra:file:delete')") + public CommonResult deleteFile(@RequestParam("id") Long id) throws Exception { + fileService.deleteFile(id); + return success(true); + } + + @DeleteMapping("/delete-list") + @Operation(summary = "批量删除文件") + @Parameter(name = "ids", description = "编号列表", required = true) + @PreAuthorize("@ss.hasPermission('infra:file:delete')") + public CommonResult deleteFileList(@RequestParam("ids") List ids) throws Exception { + fileService.deleteFileList(ids); + return success(true); + } + + @GetMapping("/{configId}/get/**") + @PermitAll + @TenantIgnore + @Operation(summary = "下载文件") + @Parameter(name = "configId", description = "配置编号", required = true) + public void getFileContent(HttpServletRequest request, + HttpServletResponse response, + @PathVariable("configId") Long configId) throws Exception { + // 获取请求的路径 + String path = StrUtil.subAfter(request.getRequestURI(), "/get/", false); + if (StrUtil.isEmpty(path)) { + throw new IllegalArgumentException("结尾的 path 路径必须传递"); + } + // 解码,解决中文路径的问题 + // https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/807/ + // https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/1432/ + path = URLUtil.decode(path, StandardCharsets.UTF_8, false); + + // 读取内容 + byte[] content = fileService.getFileContent(configId, path); + if (content == null) { + log.warn("[getFileContent][configId({}) path({}) 文件不存在]", configId, path); + response.setStatus(HttpStatus.NOT_FOUND.value()); + return; + } + writeAttachment(response, path, content); + } + + @GetMapping("/page") + @Operation(summary = "获得文件分页") + @PreAuthorize("@ss.hasPermission('infra:file:query')") + public CommonResult> getFilePage(@Valid FilePageReqVO pageVO) { + PageResult pageResult = fileService.getFilePage(pageVO); + PageResult voPageResult = BeanUtils.toBean(pageResult, FileRespVO.class); + // 私有桶:对文件 URL 生成预签名访问地址 + voPageResult.getList().forEach(vo -> { + if (StrUtil.isNotEmpty(vo.getUrl())) { + vo.setUrl(fileService.presignGetUrl(vo.getUrl(), null)); + } + }); + return success(voPageResult); + } + +} From d123057d73e78cadb07a0a48fb191886a87689a8 Mon Sep 17 00:00:00 2001 From: lzh Date: Tue, 17 Mar 2026 17:44:41 +0800 Subject: [PATCH 27/35] =?UTF-8?q?feat(system):=20=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=A4=B4=E5=83=8F=20URL=20=E9=A2=84=E7=AD=BE=E5=90=8D=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthController 登录权限接口返回预签名头像 - UserController 用户列表及详情返回预签名头像 - UserProfileController 个人中心预签名头像,保存时剥离签名参数 - OAuth2UserController 用户信息接口返回预签名头像 Co-Authored-By: Claude Opus 4.6 --- .../controller/admin/auth/AuthController.java | 363 +++++++++-------- .../admin/oauth2/OAuth2UserController.java | 170 ++++---- .../controller/admin/user/UserController.java | 377 +++++++++--------- .../admin/user/UserProfileController.java | 172 ++++---- 4 files changed, 567 insertions(+), 515 deletions(-) diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/auth/AuthController.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/auth/AuthController.java index 5f41e63..06358c2 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/auth/AuthController.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/auth/AuthController.java @@ -1,174 +1,189 @@ -package com.viewsh.module.system.controller.admin.auth; - -import cn.hutool.core.collection.CollUtil; -import cn.hutool.core.util.StrUtil; -import com.viewsh.framework.common.enums.CommonStatusEnum; -import com.viewsh.framework.common.enums.UserTypeEnum; -import com.viewsh.framework.common.pojo.CommonResult; -import com.viewsh.framework.security.config.SecurityProperties; -import com.viewsh.framework.security.core.util.SecurityFrameworkUtils; -import com.viewsh.module.system.controller.admin.auth.vo.*; -import com.viewsh.module.system.convert.auth.AuthConvert; -import com.viewsh.module.system.dal.dataobject.permission.MenuDO; -import com.viewsh.module.system.dal.dataobject.permission.RoleDO; -import com.viewsh.module.system.dal.dataobject.user.AdminUserDO; -import com.viewsh.module.system.enums.logger.LoginLogTypeEnum; -import com.viewsh.module.system.service.auth.AdminAuthService; -import com.viewsh.module.system.service.permission.MenuService; -import com.viewsh.module.system.service.permission.PermissionService; -import com.viewsh.module.system.service.permission.RoleService; -import com.viewsh.module.system.service.social.SocialClientService; -import com.viewsh.module.system.service.user.AdminUserService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.Parameters; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.annotation.Resource; -import jakarta.annotation.security.PermitAll; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.Valid; -import lombok.extern.slf4j.Slf4j; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; - -import java.util.Collections; -import java.util.List; -import java.util.Set; - -import static com.viewsh.framework.common.pojo.CommonResult.success; -import static com.viewsh.framework.common.util.collection.CollectionUtils.convertSet; -import static com.viewsh.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; - -@Tag(name = "管理后台 - 认证") -@RestController -@RequestMapping("/system/auth") -@Validated -@Slf4j -public class AuthController { - - @Resource - private AdminAuthService authService; - @Resource - private AdminUserService userService; - @Resource - private RoleService roleService; - @Resource - private MenuService menuService; - @Resource - private PermissionService permissionService; - @Resource - private SocialClientService socialClientService; - - @Resource - private SecurityProperties securityProperties; - - @PostMapping("/login") - @PermitAll - @Operation(summary = "使用账号密码登录") - public CommonResult login(@RequestBody @Valid AuthLoginReqVO reqVO) { - return success(authService.login(reqVO)); - } - - @PostMapping("/logout") - @PermitAll - @Operation(summary = "登出系统") - public CommonResult logout(HttpServletRequest request) { - String token = SecurityFrameworkUtils.obtainAuthorization(request, - securityProperties.getTokenHeader(), securityProperties.getTokenParameter()); - if (StrUtil.isNotBlank(token)) { - authService.logout(token, LoginLogTypeEnum.LOGOUT_SELF.getType()); - } - return success(true); - } - - @PostMapping("/refresh-token") - @PermitAll - @Operation(summary = "刷新令牌") - @Parameter(name = "refreshToken", description = "刷新令牌", required = true) - public CommonResult refreshToken(@RequestParam("refreshToken") String refreshToken) { - return success(authService.refreshToken(refreshToken)); - } - - @GetMapping("/get-permission-info") - @Operation(summary = "获取登录用户的权限信息") - public CommonResult getPermissionInfo() { - // 1.1 获得用户信息 - AdminUserDO user = userService.getUser(getLoginUserId()); - if (user == null) { - return success(null); - } - - // 1.2 获得角色列表 - Set roleIds = permissionService.getUserRoleIdListByUserId(getLoginUserId()); - if (CollUtil.isEmpty(roleIds)) { - return success(AuthConvert.INSTANCE.convert(user, Collections.emptyList(), Collections.emptyList())); - } - List roles = roleService.getRoleList(roleIds); - roles.removeIf(role -> !CommonStatusEnum.ENABLE.getStatus().equals(role.getStatus())); // 移除禁用的角色 - - // 1.3 获得菜单列表 - Set menuIds = permissionService.getRoleMenuListByRoleId(convertSet(roles, RoleDO::getId)); - List menuList = menuService.getMenuList(menuIds); - menuList = menuService.filterDisableMenus(menuList); - - // 2. 拼接结果返回 - return success(AuthConvert.INSTANCE.convert(user, roles, menuList)); - } - - @PostMapping("/register") - @PermitAll - @Operation(summary = "注册用户") - public CommonResult register(@RequestBody @Valid AuthRegisterReqVO registerReqVO) { - return success(authService.register(registerReqVO)); - } - - // ========== 短信登录相关 ========== - - @PostMapping("/sms-login") - @PermitAll - @Operation(summary = "使用短信验证码登录") - // 可按需开启限流:https://github.com/YunaiV/ruoyi-vue-pro/issues/851 - // @RateLimiter(time = 60, count = 6, keyResolver = ExpressionRateLimiterKeyResolver.class, keyArg = "#reqVO.mobile") - public CommonResult smsLogin(@RequestBody @Valid AuthSmsLoginReqVO reqVO) { - return success(authService.smsLogin(reqVO)); - } - - @PostMapping("/send-sms-code") - @PermitAll - @Operation(summary = "发送手机验证码") - public CommonResult sendLoginSmsCode(@RequestBody @Valid AuthSmsSendReqVO reqVO) { - authService.sendSmsCode(reqVO); - return success(true); - } - - @PostMapping("/reset-password") - @PermitAll - @Operation(summary = "重置密码") - public CommonResult resetPassword(@RequestBody @Valid AuthResetPasswordReqVO reqVO) { - authService.resetPassword(reqVO); - return success(true); - } - - // ========== 社交登录相关 ========== - - @GetMapping("/social-auth-redirect") - @PermitAll - @Operation(summary = "社交授权的跳转") - @Parameters({ - @Parameter(name = "type", description = "社交类型", required = true), - @Parameter(name = "redirectUri", description = "回调路径") - }) - public CommonResult socialLogin(@RequestParam("type") Integer type, - @RequestParam("redirectUri") String redirectUri) { - return success(socialClientService.getAuthorizeUrl( - type, UserTypeEnum.ADMIN.getValue(), redirectUri)); - } - - @PostMapping("/social-login") - @PermitAll - @Operation(summary = "社交快捷登录,使用 code 授权码", description = "适合未登录的用户,但是社交账号已绑定用户") - public CommonResult socialQuickLogin(@RequestBody @Valid AuthSocialLoginReqVO reqVO) { - return success(authService.socialLogin(reqVO)); - } - -} +package com.viewsh.module.system.controller.admin.auth; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import com.viewsh.framework.common.enums.CommonStatusEnum; +import com.viewsh.framework.common.enums.UserTypeEnum; +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.framework.security.config.SecurityProperties; +import com.viewsh.framework.security.core.util.SecurityFrameworkUtils; +import com.viewsh.module.infra.api.file.FileApi; +import com.viewsh.module.system.controller.admin.auth.vo.*; +import com.viewsh.module.system.convert.auth.AuthConvert; +import com.viewsh.module.system.dal.dataobject.permission.MenuDO; +import com.viewsh.module.system.dal.dataobject.permission.RoleDO; +import com.viewsh.module.system.dal.dataobject.user.AdminUserDO; +import com.viewsh.module.system.enums.logger.LoginLogTypeEnum; +import com.viewsh.module.system.service.auth.AdminAuthService; +import com.viewsh.module.system.service.permission.MenuService; +import com.viewsh.module.system.service.permission.PermissionService; +import com.viewsh.module.system.service.permission.RoleService; +import com.viewsh.module.system.service.social.SocialClientService; +import com.viewsh.module.system.service.user.AdminUserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.annotation.security.PermitAll; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import static com.viewsh.framework.common.pojo.CommonResult.success; +import static com.viewsh.framework.common.util.collection.CollectionUtils.convertSet; +import static com.viewsh.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - 认证") +@RestController +@RequestMapping("/system/auth") +@Validated +@Slf4j +public class AuthController { + + @Resource + private AdminAuthService authService; + @Resource + private AdminUserService userService; + @Resource + private RoleService roleService; + @Resource + private MenuService menuService; + @Resource + private PermissionService permissionService; + @Resource + private SocialClientService socialClientService; + + @Resource + private SecurityProperties securityProperties; + @Resource + private FileApi fileApi; + + @PostMapping("/login") + @PermitAll + @Operation(summary = "使用账号密码登录") + public CommonResult login(@RequestBody @Valid AuthLoginReqVO reqVO) { + return success(authService.login(reqVO)); + } + + @PostMapping("/logout") + @PermitAll + @Operation(summary = "登出系统") + public CommonResult logout(HttpServletRequest request) { + String token = SecurityFrameworkUtils.obtainAuthorization(request, + securityProperties.getTokenHeader(), securityProperties.getTokenParameter()); + if (StrUtil.isNotBlank(token)) { + authService.logout(token, LoginLogTypeEnum.LOGOUT_SELF.getType()); + } + return success(true); + } + + @PostMapping("/refresh-token") + @PermitAll + @Operation(summary = "刷新令牌") + @Parameter(name = "refreshToken", description = "刷新令牌", required = true) + public CommonResult refreshToken(@RequestParam("refreshToken") String refreshToken) { + return success(authService.refreshToken(refreshToken)); + } + + @GetMapping("/get-permission-info") + @Operation(summary = "获取登录用户的权限信息") + public CommonResult getPermissionInfo() { + // 1.1 获得用户信息 + AdminUserDO user = userService.getUser(getLoginUserId()); + if (user == null) { + return success(null); + } + + // 1.2 获得角色列表 + Set roleIds = permissionService.getUserRoleIdListByUserId(getLoginUserId()); + if (CollUtil.isEmpty(roleIds)) { + return success(AuthConvert.INSTANCE.convert(user, Collections.emptyList(), Collections.emptyList())); + } + List roles = roleService.getRoleList(roleIds); + roles.removeIf(role -> !CommonStatusEnum.ENABLE.getStatus().equals(role.getStatus())); // 移除禁用的角色 + + // 1.3 获得菜单列表 + Set menuIds = permissionService.getRoleMenuListByRoleId(convertSet(roles, RoleDO::getId)); + List menuList = menuService.getMenuList(menuIds); + menuList = menuService.filterDisableMenus(menuList); + + // 2. 拼接结果返回 + AuthPermissionInfoRespVO respVO = AuthConvert.INSTANCE.convert(user, roles, menuList); + // 私有桶:对头像 URL 生成预签名访问地址 + if (respVO.getUser() != null && StrUtil.isNotEmpty(respVO.getUser().getAvatar())) { + respVO.getUser().setAvatar(fileApi.presignGetUrl(respVO.getUser().getAvatar(), null).getCheckedData()); + } + return success(respVO); + } + + @PostMapping("/register") + @PermitAll + @Operation(summary = "注册用户") + public CommonResult register(@RequestBody @Valid AuthRegisterReqVO registerReqVO) { + return success(authService.register(registerReqVO)); + } + + // ========== 短信登录相关 ========== + + @PostMapping("/sms-login") + @PermitAll + @Operation(summary = "使用短信验证码登录") + // 可按需开启限流:https://github.com/YunaiV/ruoyi-vue-pro/issues/851 + // @RateLimiter(time = 60, count = 6, keyResolver = ExpressionRateLimiterKeyResolver.class, keyArg = "#reqVO.mobile") + public CommonResult smsLogin(@RequestBody @Valid AuthSmsLoginReqVO reqVO) { + return success(authService.smsLogin(reqVO)); + } + + @PostMapping("/send-sms-code") + @PermitAll + @Operation(summary = "发送手机验证码") + public CommonResult sendLoginSmsCode(@RequestBody @Valid AuthSmsSendReqVO reqVO) { + authService.sendSmsCode(reqVO); + return success(true); + } + + @PostMapping("/reset-password") + @PermitAll + @Operation(summary = "重置密码") + public CommonResult resetPassword(@RequestBody @Valid AuthResetPasswordReqVO reqVO) { + authService.resetPassword(reqVO); + return success(true); + } + + // ========== 社交登录相关 ========== + + @GetMapping("/social-auth-redirect") + @PermitAll + @Operation(summary = "社交授权的跳转") + @Parameters({ + @Parameter(name = "type", description = "社交类型", required = true), + @Parameter(name = "redirectUri", description = "回调路径") + }) + public CommonResult socialLogin(@RequestParam("type") Integer type, + @RequestParam("redirectUri") String redirectUri) { + return success(socialClientService.getAuthorizeUrl( + type, UserTypeEnum.ADMIN.getValue(), redirectUri)); + } + + @PostMapping("/social-login") + @PermitAll + @Operation(summary = "社交快捷登录,使用 code 授权码", description = "适合未登录的用户,但是社交账号已绑定用户") + public CommonResult socialQuickLogin(@RequestBody @Valid AuthSocialLoginReqVO reqVO) { + return success(authService.socialLogin(reqVO)); + } + + @PostMapping("/weixin-mini-app-login") + @PermitAll + @Operation(summary = "微信小程序一键登录", description = "通过微信手机号授权匹配管理员账号并自动绑定") + public CommonResult weixinMiniAppLogin(@RequestBody @Valid AuthWeixinMiniAppLoginReqVO reqVO) { + return success(authService.weixinMiniAppLogin(reqVO)); + } + +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/oauth2/OAuth2UserController.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/oauth2/OAuth2UserController.java index 6fec3e7..8ce3afb 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/oauth2/OAuth2UserController.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/oauth2/OAuth2UserController.java @@ -1,81 +1,89 @@ -package com.viewsh.module.system.controller.admin.oauth2; - -import cn.hutool.core.collection.CollUtil; -import com.viewsh.framework.common.pojo.CommonResult; -import com.viewsh.framework.common.util.object.BeanUtils; -import com.viewsh.module.system.controller.admin.oauth2.vo.user.OAuth2UserInfoRespVO; -import com.viewsh.module.system.controller.admin.oauth2.vo.user.OAuth2UserUpdateReqVO; -import com.viewsh.module.system.controller.admin.user.vo.profile.UserProfileUpdateReqVO; -import com.viewsh.module.system.dal.dataobject.dept.DeptDO; -import com.viewsh.module.system.dal.dataobject.dept.PostDO; -import com.viewsh.module.system.dal.dataobject.user.AdminUserDO; -import com.viewsh.module.system.service.dept.DeptService; -import com.viewsh.module.system.service.dept.PostService; -import com.viewsh.module.system.service.user.AdminUserService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; - -import jakarta.annotation.Resource; -import jakarta.validation.Valid; -import java.util.List; - -import static com.viewsh.framework.common.pojo.CommonResult.success; -import static com.viewsh.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; - -/** - * 提供给外部应用调用为主 - * - * 1. 在 getUserInfo 方法上,添加 @PreAuthorize("@ss.hasScope('user.read')") 注解,声明需要满足 scope = user.read - * 2. 在 updateUserInfo 方法上,添加 @PreAuthorize("@ss.hasScope('user.write')") 注解,声明需要满足 scope = user.write - * - * @author 芋道源码 - */ -@Tag(name = "管理后台 - OAuth2.0 用户") -@RestController -@RequestMapping("/system/oauth2/user") -@Validated -@Slf4j -public class OAuth2UserController { - - @Resource - private AdminUserService userService; - @Resource - private DeptService deptService; - @Resource - private PostService postService; - - @GetMapping("/get") - @Operation(summary = "获得用户基本信息") - @PreAuthorize("@ss.hasScope('user.read')") // - public CommonResult getUserInfo() { - // 获得用户基本信息 - AdminUserDO user = userService.getUser(getLoginUserId()); - OAuth2UserInfoRespVO resp = BeanUtils.toBean(user, OAuth2UserInfoRespVO.class); - // 获得部门信息 - if (user.getDeptId() != null) { - DeptDO dept = deptService.getDept(user.getDeptId()); - resp.setDept(BeanUtils.toBean(dept, OAuth2UserInfoRespVO.Dept.class)); - } - // 获得岗位信息 - if (CollUtil.isNotEmpty(user.getPostIds())) { - List posts = postService.getPostList(user.getPostIds()); - resp.setPosts(BeanUtils.toBean(posts, OAuth2UserInfoRespVO.Post.class)); - } - return success(resp); - } - - @PutMapping("/update") - @Operation(summary = "更新用户基本信息") - @PreAuthorize("@ss.hasScope('user.write')") - public CommonResult updateUserInfo(@Valid @RequestBody OAuth2UserUpdateReqVO reqVO) { - // 这里将 UserProfileUpdateReqVO =》UserProfileUpdateReqVO 对象,实现接口的复用。 - // 主要是,AdminUserService 没有自己的 BO 对象,所以复用只能这么做 - userService.updateUserProfile(getLoginUserId(), BeanUtils.toBean(reqVO, UserProfileUpdateReqVO.class)); - return success(true); - } - -} +package com.viewsh.module.system.controller.admin.oauth2; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.framework.common.util.object.BeanUtils; +import com.viewsh.module.infra.api.file.FileApi; +import com.viewsh.module.system.controller.admin.oauth2.vo.user.OAuth2UserInfoRespVO; +import com.viewsh.module.system.controller.admin.oauth2.vo.user.OAuth2UserUpdateReqVO; +import com.viewsh.module.system.controller.admin.user.vo.profile.UserProfileUpdateReqVO; +import com.viewsh.module.system.dal.dataobject.dept.DeptDO; +import com.viewsh.module.system.dal.dataobject.dept.PostDO; +import com.viewsh.module.system.dal.dataobject.user.AdminUserDO; +import com.viewsh.module.system.service.dept.DeptService; +import com.viewsh.module.system.service.dept.PostService; +import com.viewsh.module.system.service.user.AdminUserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import java.util.List; + +import static com.viewsh.framework.common.pojo.CommonResult.success; +import static com.viewsh.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +/** + * 提供给外部应用调用为主 + * + * 1. 在 getUserInfo 方法上,添加 @PreAuthorize("@ss.hasScope('user.read')") 注解,声明需要满足 scope = user.read + * 2. 在 updateUserInfo 方法上,添加 @PreAuthorize("@ss.hasScope('user.write')") 注解,声明需要满足 scope = user.write + * + * @author 芋道源码 + */ +@Tag(name = "管理后台 - OAuth2.0 用户") +@RestController +@RequestMapping("/system/oauth2/user") +@Validated +@Slf4j +public class OAuth2UserController { + + @Resource + private AdminUserService userService; + @Resource + private DeptService deptService; + @Resource + private PostService postService; + @Resource + private FileApi fileApi; + + @GetMapping("/get") + @Operation(summary = "获得用户基本信息") + @PreAuthorize("@ss.hasScope('user.read')") // + public CommonResult getUserInfo() { + // 获得用户基本信息 + AdminUserDO user = userService.getUser(getLoginUserId()); + OAuth2UserInfoRespVO resp = BeanUtils.toBean(user, OAuth2UserInfoRespVO.class); + // 获得部门信息 + if (user.getDeptId() != null) { + DeptDO dept = deptService.getDept(user.getDeptId()); + resp.setDept(BeanUtils.toBean(dept, OAuth2UserInfoRespVO.Dept.class)); + } + // 获得岗位信息 + if (CollUtil.isNotEmpty(user.getPostIds())) { + List posts = postService.getPostList(user.getPostIds()); + resp.setPosts(BeanUtils.toBean(posts, OAuth2UserInfoRespVO.Post.class)); + } + // 私有桶:对头像 URL 生成预签名访问地址 + if (StrUtil.isNotEmpty(resp.getAvatar())) { + resp.setAvatar(fileApi.presignGetUrl(resp.getAvatar(), null).getCheckedData()); + } + return success(resp); + } + + @PutMapping("/update") + @Operation(summary = "更新用户基本信息") + @PreAuthorize("@ss.hasScope('user.write')") + public CommonResult updateUserInfo(@Valid @RequestBody OAuth2UserUpdateReqVO reqVO) { + // 这里将 UserProfileUpdateReqVO =》UserProfileUpdateReqVO 对象,实现接口的复用。 + // 主要是,AdminUserService 没有自己的 BO 对象,所以复用只能这么做 + userService.updateUserProfile(getLoginUserId(), BeanUtils.toBean(reqVO, UserProfileUpdateReqVO.class)); + return success(true); + } + +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/user/UserController.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/user/UserController.java index 6df03e5..ad69ce3 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/user/UserController.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/user/UserController.java @@ -1,181 +1,196 @@ -package com.viewsh.module.system.controller.admin.user; - -import cn.hutool.core.collection.CollUtil; -import com.viewsh.framework.apilog.core.annotation.ApiAccessLog; -import com.viewsh.framework.common.enums.CommonStatusEnum; -import com.viewsh.framework.common.pojo.CommonResult; -import com.viewsh.framework.common.pojo.PageParam; -import com.viewsh.framework.common.pojo.PageResult; -import com.viewsh.framework.excel.core.util.ExcelUtils; -import com.viewsh.module.system.controller.admin.user.vo.user.*; -import com.viewsh.module.system.convert.user.UserConvert; -import com.viewsh.module.system.dal.dataobject.dept.DeptDO; -import com.viewsh.module.system.dal.dataobject.user.AdminUserDO; -import com.viewsh.module.system.enums.common.SexEnum; -import com.viewsh.module.system.service.dept.DeptService; -import com.viewsh.module.system.service.user.AdminUserService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.Parameters; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.annotation.Resource; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.validation.Valid; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; - -import java.io.IOException; -import java.util.Arrays; -import java.util.List; -import java.util.Map; - -import static com.viewsh.framework.apilog.core.enums.OperateTypeEnum.EXPORT; -import static com.viewsh.framework.common.pojo.CommonResult.success; -import static com.viewsh.framework.common.util.collection.CollectionUtils.convertList; - -@Tag(name = "管理后台 - 用户") -@RestController -@RequestMapping("/system/user") -@Validated -public class UserController { - - @Resource - private AdminUserService userService; - @Resource - private DeptService deptService; - - @PostMapping("/create") - @Operation(summary = "新增用户") - @PreAuthorize("@ss.hasPermission('system:user:create')") - public CommonResult createUser(@Valid @RequestBody UserSaveReqVO reqVO) { - Long id = userService.createUser(reqVO); - return success(id); - } - - @PutMapping("update") - @Operation(summary = "修改用户") - @PreAuthorize("@ss.hasPermission('system:user:update')") - public CommonResult updateUser(@Valid @RequestBody UserSaveReqVO reqVO) { - userService.updateUser(reqVO); - return success(true); - } - - @DeleteMapping("/delete") - @Operation(summary = "删除用户") - @Parameter(name = "id", description = "编号", required = true, example = "1024") - @PreAuthorize("@ss.hasPermission('system:user:delete')") - public CommonResult deleteUser(@RequestParam("id") Long id) { - userService.deleteUser(id); - return success(true); - } - - @DeleteMapping("/delete-list") - @Parameter(name = "ids", description = "编号列表", required = true) - @Operation(summary = "批量删除用户") - @PreAuthorize("@ss.hasPermission('system:user:delete')") - public CommonResult deleteUserList(@RequestParam("ids") List ids) { - userService.deleteUserList(ids); - return success(true); - } - - @PutMapping("/update-password") - @Operation(summary = "重置用户密码") - @PreAuthorize("@ss.hasPermission('system:user:update-password')") - public CommonResult updateUserPassword(@Valid @RequestBody UserUpdatePasswordReqVO reqVO) { - userService.updateUserPassword(reqVO.getId(), reqVO.getPassword()); - return success(true); - } - - @PutMapping("/update-status") - @Operation(summary = "修改用户状态") - @PreAuthorize("@ss.hasPermission('system:user:update')") - public CommonResult updateUserStatus(@Valid @RequestBody UserUpdateStatusReqVO reqVO) { - userService.updateUserStatus(reqVO.getId(), reqVO.getStatus()); - return success(true); - } - - @GetMapping("/page") - @Operation(summary = "获得用户分页列表") - @PreAuthorize("@ss.hasPermission('system:user:query')") - public CommonResult> getUserPage(@Valid UserPageReqVO pageReqVO) { - // 获得用户分页列表 - PageResult pageResult = userService.getUserPage(pageReqVO); - if (CollUtil.isEmpty(pageResult.getList())) { - return success(new PageResult<>(pageResult.getTotal())); - } - // 拼接数据 - Map deptMap = deptService.getDeptMap( - convertList(pageResult.getList(), AdminUserDO::getDeptId)); - return success(new PageResult<>(UserConvert.INSTANCE.convertList(pageResult.getList(), deptMap), - pageResult.getTotal())); - } - - @GetMapping({"/list-all-simple", "/simple-list"}) - @Operation(summary = "获取用户精简信息列表", description = "只包含被开启的用户,主要用于前端的下拉选项") - public CommonResult> getSimpleUserList() { - List list = userService.getUserListByStatus(CommonStatusEnum.ENABLE.getStatus()); - // 拼接数据 - Map deptMap = deptService.getDeptMap( - convertList(list, AdminUserDO::getDeptId)); - return success(UserConvert.INSTANCE.convertSimpleList(list, deptMap)); - } - - @GetMapping("/get") - @Operation(summary = "获得用户详情") - @Parameter(name = "id", description = "编号", required = true, example = "1024") - @PreAuthorize("@ss.hasPermission('system:user:query')") - public CommonResult getUser(@RequestParam("id") Long id) { - AdminUserDO user = userService.getUser(id); - if (user == null) { - return success(null); - } - // 拼接数据 - DeptDO dept = deptService.getDept(user.getDeptId()); - return success(UserConvert.INSTANCE.convert(user, dept)); - } - - @GetMapping("/export-excel") - @Operation(summary = "导出用户") - @PreAuthorize("@ss.hasPermission('system:user:export')") - @ApiAccessLog(operateType = EXPORT) - public void exportUserList(@Validated UserPageReqVO exportReqVO, - HttpServletResponse response) throws IOException { - exportReqVO.setPageSize(PageParam.PAGE_SIZE_NONE); - List list = userService.getUserPage(exportReqVO).getList(); - // 输出 Excel - Map deptMap = deptService.getDeptMap( - convertList(list, AdminUserDO::getDeptId)); - ExcelUtils.write(response, "用户数据.xls", "数据", UserRespVO.class, - UserConvert.INSTANCE.convertList(list, deptMap)); - } - - @GetMapping("/get-import-template") - @Operation(summary = "获得导入用户模板") - public void importTemplate(HttpServletResponse response) throws IOException { - // 手动创建导出 demo - List list = Arrays.asList( - UserImportExcelVO.builder().username("yunai").deptId(1L).email("yunai@iocoder.cn").mobile("15601691300") - .nickname("芋道").status(CommonStatusEnum.ENABLE.getStatus()).sex(SexEnum.MALE.getSex()).build(), - UserImportExcelVO.builder().username("yuanma").deptId(2L).email("yuanma@iocoder.cn").mobile("15601701300") - .nickname("源码").status(CommonStatusEnum.DISABLE.getStatus()).sex(SexEnum.FEMALE.getSex()).build() - ); - // 输出 - ExcelUtils.write(response, "用户导入模板.xls", "用户列表", UserImportExcelVO.class, list); - } - - @PostMapping("/import") - @Operation(summary = "导入用户") - @Parameters({ - @Parameter(name = "file", description = "Excel 文件", required = true), - @Parameter(name = "updateSupport", description = "是否支持更新,默认为 false", example = "true") - }) - @PreAuthorize("@ss.hasPermission('system:user:import')") - public CommonResult importExcel(@RequestParam("file") MultipartFile file, - @RequestParam(value = "updateSupport", required = false, defaultValue = "false") Boolean updateSupport) throws Exception { - List list = ExcelUtils.read(file, UserImportExcelVO.class); - return success(userService.importUserList(list, updateSupport)); - } - -} +package com.viewsh.module.system.controller.admin.user; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import com.viewsh.framework.apilog.core.annotation.ApiAccessLog; +import com.viewsh.framework.common.enums.CommonStatusEnum; +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.framework.common.pojo.PageParam; +import com.viewsh.framework.common.pojo.PageResult; +import com.viewsh.framework.excel.core.util.ExcelUtils; +import com.viewsh.module.infra.api.file.FileApi; +import com.viewsh.module.system.controller.admin.user.vo.user.*; +import com.viewsh.module.system.convert.user.UserConvert; +import com.viewsh.module.system.dal.dataobject.dept.DeptDO; +import com.viewsh.module.system.dal.dataobject.user.AdminUserDO; +import com.viewsh.module.system.enums.common.SexEnum; +import com.viewsh.module.system.service.dept.DeptService; +import com.viewsh.module.system.service.user.AdminUserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static com.viewsh.framework.apilog.core.enums.OperateTypeEnum.EXPORT; +import static com.viewsh.framework.common.pojo.CommonResult.success; +import static com.viewsh.framework.common.util.collection.CollectionUtils.convertList; + +@Tag(name = "管理后台 - 用户") +@RestController +@RequestMapping("/system/user") +@Validated +public class UserController { + + @Resource + private AdminUserService userService; + @Resource + private DeptService deptService; + @Resource + private FileApi fileApi; + + @PostMapping("/create") + @Operation(summary = "新增用户") + @PreAuthorize("@ss.hasPermission('system:user:create')") + public CommonResult createUser(@Valid @RequestBody UserSaveReqVO reqVO) { + Long id = userService.createUser(reqVO); + return success(id); + } + + @PutMapping("update") + @Operation(summary = "修改用户") + @PreAuthorize("@ss.hasPermission('system:user:update')") + public CommonResult updateUser(@Valid @RequestBody UserSaveReqVO reqVO) { + userService.updateUser(reqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除用户") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('system:user:delete')") + public CommonResult deleteUser(@RequestParam("id") Long id) { + userService.deleteUser(id); + return success(true); + } + + @DeleteMapping("/delete-list") + @Parameter(name = "ids", description = "编号列表", required = true) + @Operation(summary = "批量删除用户") + @PreAuthorize("@ss.hasPermission('system:user:delete')") + public CommonResult deleteUserList(@RequestParam("ids") List ids) { + userService.deleteUserList(ids); + return success(true); + } + + @PutMapping("/update-password") + @Operation(summary = "重置用户密码") + @PreAuthorize("@ss.hasPermission('system:user:update-password')") + public CommonResult updateUserPassword(@Valid @RequestBody UserUpdatePasswordReqVO reqVO) { + userService.updateUserPassword(reqVO.getId(), reqVO.getPassword()); + return success(true); + } + + @PutMapping("/update-status") + @Operation(summary = "修改用户状态") + @PreAuthorize("@ss.hasPermission('system:user:update')") + public CommonResult updateUserStatus(@Valid @RequestBody UserUpdateStatusReqVO reqVO) { + userService.updateUserStatus(reqVO.getId(), reqVO.getStatus()); + return success(true); + } + + @GetMapping("/page") + @Operation(summary = "获得用户分页列表") + @PreAuthorize("@ss.hasPermission('system:user:query')") + public CommonResult> getUserPage(@Valid UserPageReqVO pageReqVO) { + // 获得用户分页列表 + PageResult pageResult = userService.getUserPage(pageReqVO); + if (CollUtil.isEmpty(pageResult.getList())) { + return success(new PageResult<>(pageResult.getTotal())); + } + // 拼接数据 + Map deptMap = deptService.getDeptMap( + convertList(pageResult.getList(), AdminUserDO::getDeptId)); + List userList = UserConvert.INSTANCE.convertList(pageResult.getList(), deptMap); + // 私有桶:对头像 URL 生成预签名访问地址 + userList.forEach(vo -> { + if (StrUtil.isNotEmpty(vo.getAvatar())) { + vo.setAvatar(fileApi.presignGetUrl(vo.getAvatar(), null).getCheckedData()); + } + }); + return success(new PageResult<>(userList, pageResult.getTotal())); + } + + @GetMapping({"/list-all-simple", "/simple-list"}) + @Operation(summary = "获取用户精简信息列表", description = "只包含被开启的用户,主要用于前端的下拉选项") + public CommonResult> getSimpleUserList() { + List list = userService.getUserListByStatus(CommonStatusEnum.ENABLE.getStatus()); + // 拼接数据 + Map deptMap = deptService.getDeptMap( + convertList(list, AdminUserDO::getDeptId)); + return success(UserConvert.INSTANCE.convertSimpleList(list, deptMap)); + } + + @GetMapping("/get") + @Operation(summary = "获得用户详情") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('system:user:query')") + public CommonResult getUser(@RequestParam("id") Long id) { + AdminUserDO user = userService.getUser(id); + if (user == null) { + return success(null); + } + // 拼接数据 + DeptDO dept = deptService.getDept(user.getDeptId()); + UserRespVO respVO = UserConvert.INSTANCE.convert(user, dept); + // 私有桶:对头像 URL 生成预签名访问地址 + if (StrUtil.isNotEmpty(respVO.getAvatar())) { + respVO.setAvatar(fileApi.presignGetUrl(respVO.getAvatar(), null).getCheckedData()); + } + return success(respVO); + } + + @GetMapping("/export-excel") + @Operation(summary = "导出用户") + @PreAuthorize("@ss.hasPermission('system:user:export')") + @ApiAccessLog(operateType = EXPORT) + public void exportUserList(@Validated UserPageReqVO exportReqVO, + HttpServletResponse response) throws IOException { + exportReqVO.setPageSize(PageParam.PAGE_SIZE_NONE); + List list = userService.getUserPage(exportReqVO).getList(); + // 输出 Excel + Map deptMap = deptService.getDeptMap( + convertList(list, AdminUserDO::getDeptId)); + ExcelUtils.write(response, "用户数据.xls", "数据", UserRespVO.class, + UserConvert.INSTANCE.convertList(list, deptMap)); + } + + @GetMapping("/get-import-template") + @Operation(summary = "获得导入用户模板") + public void importTemplate(HttpServletResponse response) throws IOException { + // 手动创建导出 demo + List list = Arrays.asList( + UserImportExcelVO.builder().username("yunai").deptId(1L).email("yunai@iocoder.cn").mobile("15601691300") + .nickname("芋道").status(CommonStatusEnum.ENABLE.getStatus()).sex(SexEnum.MALE.getSex()).build(), + UserImportExcelVO.builder().username("yuanma").deptId(2L).email("yuanma@iocoder.cn").mobile("15601701300") + .nickname("源码").status(CommonStatusEnum.DISABLE.getStatus()).sex(SexEnum.FEMALE.getSex()).build() + ); + // 输出 + ExcelUtils.write(response, "用户导入模板.xls", "用户列表", UserImportExcelVO.class, list); + } + + @PostMapping("/import") + @Operation(summary = "导入用户") + @Parameters({ + @Parameter(name = "file", description = "Excel 文件", required = true), + @Parameter(name = "updateSupport", description = "是否支持更新,默认为 false", example = "true") + }) + @PreAuthorize("@ss.hasPermission('system:user:import')") + public CommonResult importExcel(@RequestParam("file") MultipartFile file, + @RequestParam(value = "updateSupport", required = false, defaultValue = "false") Boolean updateSupport) throws Exception { + List list = ExcelUtils.read(file, UserImportExcelVO.class); + return success(userService.importUserList(list, updateSupport)); + } + +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/user/UserProfileController.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/user/UserProfileController.java index bbec6be..9069887 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/user/UserProfileController.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/user/UserProfileController.java @@ -1,79 +1,93 @@ -package com.viewsh.module.system.controller.admin.user; - -import cn.hutool.core.collection.CollUtil; -import com.viewsh.framework.common.pojo.CommonResult; -import com.viewsh.framework.datapermission.core.annotation.DataPermission; -import com.viewsh.module.system.controller.admin.user.vo.profile.UserProfileRespVO; -import com.viewsh.module.system.controller.admin.user.vo.profile.UserProfileUpdatePasswordReqVO; -import com.viewsh.module.system.controller.admin.user.vo.profile.UserProfileUpdateReqVO; -import com.viewsh.module.system.convert.user.UserConvert; -import com.viewsh.module.system.dal.dataobject.dept.DeptDO; -import com.viewsh.module.system.dal.dataobject.dept.PostDO; -import com.viewsh.module.system.dal.dataobject.permission.RoleDO; -import com.viewsh.module.system.dal.dataobject.user.AdminUserDO; -import com.viewsh.module.system.service.dept.DeptService; -import com.viewsh.module.system.service.dept.PostService; -import com.viewsh.module.system.service.permission.PermissionService; -import com.viewsh.module.system.service.permission.RoleService; -import com.viewsh.module.system.service.user.AdminUserService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.annotation.Resource; -import jakarta.validation.Valid; -import lombok.extern.slf4j.Slf4j; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -import static com.viewsh.framework.common.pojo.CommonResult.success; -import static com.viewsh.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; - -@Tag(name = "管理后台 - 用户个人中心") -@RestController -@RequestMapping("/system/user/profile") -@Validated -@Slf4j -public class UserProfileController { - - @Resource - private AdminUserService userService; - @Resource - private DeptService deptService; - @Resource - private PostService postService; - @Resource - private PermissionService permissionService; - @Resource - private RoleService roleService; - - @GetMapping("/get") - @Operation(summary = "获得登录用户信息") - @DataPermission(enable = false) // 关闭数据权限,避免只查看自己时,查询不到部门。 - public CommonResult getUserProfile() { - // 获得用户基本信息 - AdminUserDO user = userService.getUser(getLoginUserId()); - // 获得用户角色 - List userRoles = roleService.getRoleListFromCache(permissionService.getUserRoleIdListByUserId(user.getId())); - // 获得部门信息 - DeptDO dept = user.getDeptId() != null ? deptService.getDept(user.getDeptId()) : null; - // 获得岗位信息 - List posts = CollUtil.isNotEmpty(user.getPostIds()) ? postService.getPostList(user.getPostIds()) : null; - return success(UserConvert.INSTANCE.convert(user, userRoles, dept, posts)); - } - - @PutMapping("/update") - @Operation(summary = "修改用户个人信息") - public CommonResult updateUserProfile(@Valid @RequestBody UserProfileUpdateReqVO reqVO) { - userService.updateUserProfile(getLoginUserId(), reqVO); - return success(true); - } - - @PutMapping("/update-password") - @Operation(summary = "修改用户个人密码") - public CommonResult updateUserProfilePassword(@Valid @RequestBody UserProfileUpdatePasswordReqVO reqVO) { - userService.updateUserPassword(getLoginUserId(), reqVO); - return success(true); - } - -} +package com.viewsh.module.system.controller.admin.user; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.framework.common.util.http.HttpUtils; +import com.viewsh.framework.datapermission.core.annotation.DataPermission; +import com.viewsh.module.infra.api.file.FileApi; +import com.viewsh.module.system.controller.admin.user.vo.profile.UserProfileRespVO; +import com.viewsh.module.system.controller.admin.user.vo.profile.UserProfileUpdatePasswordReqVO; +import com.viewsh.module.system.controller.admin.user.vo.profile.UserProfileUpdateReqVO; +import com.viewsh.module.system.convert.user.UserConvert; +import com.viewsh.module.system.dal.dataobject.dept.DeptDO; +import com.viewsh.module.system.dal.dataobject.dept.PostDO; +import com.viewsh.module.system.dal.dataobject.permission.RoleDO; +import com.viewsh.module.system.dal.dataobject.user.AdminUserDO; +import com.viewsh.module.system.service.dept.DeptService; +import com.viewsh.module.system.service.dept.PostService; +import com.viewsh.module.system.service.permission.PermissionService; +import com.viewsh.module.system.service.permission.RoleService; +import com.viewsh.module.system.service.user.AdminUserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static com.viewsh.framework.common.pojo.CommonResult.success; +import static com.viewsh.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - 用户个人中心") +@RestController +@RequestMapping("/system/user/profile") +@Validated +@Slf4j +public class UserProfileController { + + @Resource + private AdminUserService userService; + @Resource + private DeptService deptService; + @Resource + private PostService postService; + @Resource + private PermissionService permissionService; + @Resource + private RoleService roleService; + @Resource + private FileApi fileApi; + + @GetMapping("/get") + @Operation(summary = "获得登录用户信息") + @DataPermission(enable = false) // 关闭数据权限,避免只查看自己时,查询不到部门。 + public CommonResult getUserProfile() { + // 获得用户基本信息 + AdminUserDO user = userService.getUser(getLoginUserId()); + // 获得用户角色 + List userRoles = roleService.getRoleListFromCache(permissionService.getUserRoleIdListByUserId(user.getId())); + // 获得部门信息 + DeptDO dept = user.getDeptId() != null ? deptService.getDept(user.getDeptId()) : null; + // 获得岗位信息 + List posts = CollUtil.isNotEmpty(user.getPostIds()) ? postService.getPostList(user.getPostIds()) : null; + UserProfileRespVO respVO = UserConvert.INSTANCE.convert(user, userRoles, dept, posts); + // 私有桶:对头像 URL 生成预签名访问地址 + if (StrUtil.isNotEmpty(respVO.getAvatar())) { + respVO.setAvatar(fileApi.presignGetUrl(respVO.getAvatar(), null).getCheckedData()); + } + return success(respVO); + } + + @PutMapping("/update") + @Operation(summary = "修改用户个人信息") + public CommonResult updateUserProfile(@Valid @RequestBody UserProfileUpdateReqVO reqVO) { + // 私有桶:移除头像 URL 的预签名 Query 参数,避免存储过期签名 + if (StrUtil.isNotEmpty(reqVO.getAvatar())) { + reqVO.setAvatar(HttpUtils.removeUrlQuery(reqVO.getAvatar())); + } + userService.updateUserProfile(getLoginUserId(), reqVO); + return success(true); + } + + @PutMapping("/update-password") + @Operation(summary = "修改用户个人密码") + public CommonResult updateUserProfilePassword(@Valid @RequestBody UserProfileUpdatePasswordReqVO reqVO) { + userService.updateUserPassword(getLoginUserId(), reqVO); + return success(true); + } + +} From cde78989f05a54040858dbdf12e307fe570b6d10 Mon Sep 17 00:00:00 2001 From: lzh Date: Tue, 17 Mar 2026 17:44:51 +0800 Subject: [PATCH 28/35] =?UTF-8?q?feat(ops):=20=E5=B7=A5=E5=8D=95=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E5=AE=89=E4=BF=9D=E5=9B=BE=E7=89=87=20URL=20=E9=A2=84?= =?UTF-8?q?=E7=AD=BE=E5=90=8D=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OrderCenterController.getDetail 对 extInfo 中 imageUrl/resultImgUrls 生成预签名地址 - RPC 异常时降级返回原始 URL,避免接口整体失败 Co-Authored-By: Claude Opus 4.6 --- .../admin/OrderCenterController.java | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/OrderCenterController.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/OrderCenterController.java index 53433f1..42b3e9b 100644 --- a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/OrderCenterController.java +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/OrderCenterController.java @@ -1,7 +1,9 @@ package com.viewsh.module.ops.controller.admin; +import cn.hutool.core.util.StrUtil; import com.viewsh.framework.common.pojo.CommonResult; import com.viewsh.framework.common.pojo.PageResult; +import com.viewsh.module.infra.api.file.FileApi; import com.viewsh.module.ops.api.clean.QuickStatsRespDTO; import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.DashboardStatsRespVO; import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.WorkspaceStatsRespVO; @@ -23,7 +25,9 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.time.LocalDate; +import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import static com.viewsh.framework.common.pojo.CommonResult.success; @@ -43,6 +47,8 @@ public class OrderCenterController { @Resource private OrderQueryService orderQueryService; + @Resource + private FileApi fileApi; @Autowired(required = false) private CleanDashboardService cleanDashboardService; @@ -64,6 +70,8 @@ public class OrderCenterController { @PreAuthorize("@ss.hasPermission('ops:order-center:query')") public CommonResult getDetail(@PathVariable("id") Long id) { OrderDetailVO detail = orderQueryService.getDetail(id); + // 私有桶:对 extInfo 中的图片 URL 生成预签名访问地址 + presignExtInfoImageUrls(detail); return success(detail); } @@ -127,4 +135,43 @@ public class OrderCenterController { return success(opsStatisticsService.getWorkspaceStats()); } + /** + * 对 extInfo 中的图片 URL 生成预签名访问地址 + */ + private void presignExtInfoImageUrls(OrderDetailVO detail) { + if (detail == null || detail.getExtInfo() == null) { + return; + } + Map extInfo = detail.getExtInfo(); + // imageUrl:单个告警截图 + Object imageUrl = extInfo.get("imageUrl"); + if (imageUrl instanceof String url && StrUtil.isNotEmpty(url)) { + try { + extInfo.put("imageUrl", fileApi.presignGetUrl(url, null).getCheckedData()); + } catch (Exception e) { + log.warn("[presignExtInfoImageUrls] imageUrl 签名失败: {}", url, e); + } + } + // resultImgUrls:处理结果图片,JSON 数组字符串 如 ["url1","url2"] + Object resultImgUrls = extInfo.get("resultImgUrls"); + if (resultImgUrls instanceof String urlsJson && StrUtil.isNotEmpty(urlsJson)) { + try { + List urls = cn.hutool.json.JSONUtil.toList(urlsJson, String.class); + List signedUrls = urls.stream() + .map(u -> { + try { + return fileApi.presignGetUrl(u, null).getCheckedData(); + } catch (Exception e) { + log.warn("[presignExtInfoImageUrls] resultImgUrl 签名失败: {}", u, e); + return u; + } + }) + .collect(Collectors.toList()); + extInfo.put("resultImgUrls", cn.hutool.json.JSONUtil.toJsonStr(signedUrls)); + } catch (Exception e) { + log.warn("[presignExtInfoImageUrls] 解析 resultImgUrls 失败: {}", resultImgUrls, e); + } + } + } + } From 064ccdac89ba17fd92c55fe88f00cf08955ee319 Mon Sep 17 00:00:00 2001 From: lzh Date: Tue, 17 Mar 2026 18:01:53 +0800 Subject: [PATCH 29/35] =?UTF-8?q?feat(system):=20=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=BE=AE=E4=BF=A1=E5=B0=8F=E7=A8=8B=E5=BA=8F?= =?UTF-8?q?=E4=B8=80=E9=94=AE=E7=99=BB=E5=BD=95=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 /system/auth/weixin-mini-app-login 端点,通过微信手机号授权 匹配管理员账号并自动绑定,含绑定冲突检测: - 同一微信已绑定其他管理员 → 拒绝 - 同一管理员已绑定其他微信 → 拒绝 Co-Authored-By: Claude Opus 4.6 --- .../system/enums/ErrorCodeConstants.java | 345 ++++----- .../auth/vo/AuthWeixinMiniAppLoginReqVO.java | 31 + .../system/service/auth/AdminAuthService.java | 184 ++--- .../service/auth/AdminAuthServiceImpl.java | 667 ++++++++++-------- 4 files changed, 663 insertions(+), 564 deletions(-) create mode 100644 viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/auth/vo/AuthWeixinMiniAppLoginReqVO.java diff --git a/viewsh-module-system/viewsh-module-system-api/src/main/java/com/viewsh/module/system/enums/ErrorCodeConstants.java b/viewsh-module-system/viewsh-module-system-api/src/main/java/com/viewsh/module/system/enums/ErrorCodeConstants.java index badcd4e..f820f9e 100644 --- a/viewsh-module-system/viewsh-module-system-api/src/main/java/com/viewsh/module/system/enums/ErrorCodeConstants.java +++ b/viewsh-module-system/viewsh-module-system-api/src/main/java/com/viewsh/module/system/enums/ErrorCodeConstants.java @@ -1,171 +1,174 @@ -package com.viewsh.module.system.enums; - -import com.viewsh.framework.common.exception.ErrorCode; - -/** - * System 错误码枚举类 - * - * system 系统,使用 1-002-000-000 段 - */ -public interface ErrorCodeConstants { - - // ========== AUTH 模块 1-002-000-000 ========== - ErrorCode AUTH_LOGIN_BAD_CREDENTIALS = new ErrorCode(1_002_000_000, "登录失败,账号密码不正确"); - ErrorCode AUTH_LOGIN_USER_DISABLED = new ErrorCode(1_002_000_001, "登录失败,账号被禁用"); - ErrorCode AUTH_LOGIN_CAPTCHA_CODE_ERROR = new ErrorCode(1_002_000_004, "验证码不正确,原因:{}"); - ErrorCode AUTH_THIRD_LOGIN_NOT_BIND = new ErrorCode(1_002_000_005, "未绑定账号,需要进行绑定"); - ErrorCode AUTH_MOBILE_NOT_EXISTS = new ErrorCode(1_002_000_007, "手机号不存在"); - ErrorCode AUTH_REGISTER_CAPTCHA_CODE_ERROR = new ErrorCode(1_002_000_008, "验证码不正确,原因:{}"); - - // ========== 菜单模块 1-002-001-000 ========== - ErrorCode MENU_NAME_DUPLICATE = new ErrorCode(1_002_001_000, "已经存在该名字的菜单"); - ErrorCode MENU_PARENT_NOT_EXISTS = new ErrorCode(1_002_001_001, "父菜单不存在"); - ErrorCode MENU_PARENT_ERROR = new ErrorCode(1_002_001_002, "不能设置自己为父菜单"); - ErrorCode MENU_NOT_EXISTS = new ErrorCode(1_002_001_003, "菜单不存在"); - ErrorCode MENU_EXISTS_CHILDREN = new ErrorCode(1_002_001_004, "存在子菜单,无法删除"); - ErrorCode MENU_PARENT_NOT_DIR_OR_MENU = new ErrorCode(1_002_001_005, "父菜单的类型必须是目录或者菜单"); - ErrorCode MENU_COMPONENT_NAME_DUPLICATE = new ErrorCode(1_002_001_006, "已经存在该组件名的菜单"); - - // ========== 角色模块 1-002-002-000 ========== - ErrorCode ROLE_NOT_EXISTS = new ErrorCode(1_002_002_000, "角色不存在"); - ErrorCode ROLE_NAME_DUPLICATE = new ErrorCode(1_002_002_001, "已经存在名为【{}】的角色"); - ErrorCode ROLE_CODE_DUPLICATE = new ErrorCode(1_002_002_002, "已经存在标识为【{}】的角色"); - ErrorCode ROLE_CAN_NOT_UPDATE_SYSTEM_TYPE_ROLE = new ErrorCode(1_002_002_003, "不能操作类型为系统内置的角色"); - ErrorCode ROLE_IS_DISABLE = new ErrorCode(1_002_002_004, "名字为【{}】的角色已被禁用"); - ErrorCode ROLE_ADMIN_CODE_ERROR = new ErrorCode(1_002_002_005, "标识【{}】不能使用"); - - // ========== 用户模块 1-002-003-000 ========== - ErrorCode USER_USERNAME_EXISTS = new ErrorCode(1_002_003_000, "用户账号已经存在"); - ErrorCode USER_MOBILE_EXISTS = new ErrorCode(1_002_003_001, "手机号已经存在"); - ErrorCode USER_EMAIL_EXISTS = new ErrorCode(1_002_003_002, "邮箱已经存在"); - ErrorCode USER_NOT_EXISTS = new ErrorCode(1_002_003_003, "用户不存在"); - ErrorCode USER_IMPORT_LIST_IS_EMPTY = new ErrorCode(1_002_003_004, "导入用户数据不能为空!"); - ErrorCode USER_PASSWORD_FAILED = new ErrorCode(1_002_003_005, "用户密码校验失败"); - ErrorCode USER_IS_DISABLE = new ErrorCode(1_002_003_006, "名字为【{}】的用户已被禁用"); - ErrorCode USER_COUNT_MAX = new ErrorCode(1_002_003_008, "创建用户失败,原因:超过租户最大租户配额({})!"); - ErrorCode USER_IMPORT_INIT_PASSWORD = new ErrorCode(1_002_003_009, "初始密码不能为空"); - ErrorCode USER_MOBILE_NOT_EXISTS = new ErrorCode(1_002_003_010, "该手机号尚未注册"); - ErrorCode USER_REGISTER_DISABLED = new ErrorCode(1_002_003_011, "注册功能已关闭"); - - // ========== 部门模块 1-002-004-000 ========== - ErrorCode DEPT_NAME_DUPLICATE = new ErrorCode(1_002_004_000, "已经存在该名字的部门"); - ErrorCode DEPT_PARENT_NOT_EXITS = new ErrorCode(1_002_004_001,"父级部门不存在"); - ErrorCode DEPT_NOT_FOUND = new ErrorCode(1_002_004_002, "当前部门不存在"); - ErrorCode DEPT_EXITS_CHILDREN = new ErrorCode(1_002_004_003, "存在子部门,无法删除"); - ErrorCode DEPT_PARENT_ERROR = new ErrorCode(1_002_004_004, "不能设置自己为父部门"); - ErrorCode DEPT_NOT_ENABLE = new ErrorCode(1_002_004_006, "部门({})不处于开启状态,不允许选择"); - ErrorCode DEPT_PARENT_IS_CHILD = new ErrorCode(1_002_004_007, "不能设置自己的子部门为父部门"); - - // ========== 岗位模块 1-002-005-000 ========== - ErrorCode POST_NOT_FOUND = new ErrorCode(1_002_005_000, "当前岗位不存在"); - ErrorCode POST_NOT_ENABLE = new ErrorCode(1_002_005_001, "岗位({}) 不处于开启状态,不允许选择"); - ErrorCode POST_NAME_DUPLICATE = new ErrorCode(1_002_005_002, "已经存在该名字的岗位"); - ErrorCode POST_CODE_DUPLICATE = new ErrorCode(1_002_005_003, "已经存在该标识的岗位"); - - // ========== 字典类型 1-002-006-000 ========== - ErrorCode DICT_TYPE_NOT_EXISTS = new ErrorCode(1_002_006_001, "当前字典类型不存在"); - ErrorCode DICT_TYPE_NOT_ENABLE = new ErrorCode(1_002_006_002, "字典类型不处于开启状态,不允许选择"); - ErrorCode DICT_TYPE_NAME_DUPLICATE = new ErrorCode(1_002_006_003, "已经存在该名字的字典类型"); - ErrorCode DICT_TYPE_TYPE_DUPLICATE = new ErrorCode(1_002_006_004, "已经存在该类型的字典类型"); - ErrorCode DICT_TYPE_HAS_CHILDREN = new ErrorCode(1_002_006_005, "无法删除,该字典类型还有字典数据"); - - // ========== 字典数据 1-002-007-000 ========== - ErrorCode DICT_DATA_NOT_EXISTS = new ErrorCode(1_002_007_001, "当前字典数据不存在"); - ErrorCode DICT_DATA_NOT_ENABLE = new ErrorCode(1_002_007_002, "字典数据({})不处于开启状态,不允许选择"); - ErrorCode DICT_DATA_VALUE_DUPLICATE = new ErrorCode(1_002_007_003, "已经存在该值的字典数据"); - - // ========== 通知公告 1-002-008-000 ========== - ErrorCode NOTICE_NOT_FOUND = new ErrorCode(1_002_008_001, "当前通知公告不存在"); - - // ========== 短信渠道 1-002-011-000 ========== - ErrorCode SMS_CHANNEL_NOT_EXISTS = new ErrorCode(1_002_011_000, "短信渠道不存在"); - ErrorCode SMS_CHANNEL_DISABLE = new ErrorCode(1_002_011_001, "短信渠道不处于开启状态,不允许选择"); - ErrorCode SMS_CHANNEL_HAS_CHILDREN = new ErrorCode(1_002_011_002, "无法删除,该短信渠道还有短信模板"); - - // ========== 短信模板 1-002-012-000 ========== - ErrorCode SMS_TEMPLATE_NOT_EXISTS = new ErrorCode(1_002_012_000, "短信模板不存在"); - ErrorCode SMS_TEMPLATE_CODE_DUPLICATE = new ErrorCode(1_002_012_001, "已经存在编码为【{}】的短信模板"); - ErrorCode SMS_TEMPLATE_API_ERROR = new ErrorCode(1_002_012_002, "短信 API 模板调用失败,原因是:{}"); - ErrorCode SMS_TEMPLATE_API_AUDIT_CHECKING = new ErrorCode(1_002_012_003, "短信 API 模版无法使用,原因:审批中"); - ErrorCode SMS_TEMPLATE_API_AUDIT_FAIL = new ErrorCode(1_002_012_004, "短信 API 模版无法使用,原因:审批不通过,{}"); - ErrorCode SMS_TEMPLATE_API_NOT_FOUND = new ErrorCode(1_002_012_005, "短信 API 模版无法使用,原因:模版不存在"); - - // ========== 短信发送 1-002-013-000 ========== - ErrorCode SMS_SEND_MOBILE_NOT_EXISTS = new ErrorCode(1_002_013_000, "手机号不存在"); - ErrorCode SMS_SEND_MOBILE_TEMPLATE_PARAM_MISS = new ErrorCode(1_002_013_001, "模板参数({})缺失"); - ErrorCode SMS_SEND_TEMPLATE_NOT_EXISTS = new ErrorCode(1_002_013_002, "短信模板不存在"); - - // ========== 短信验证码 1-002-014-000 ========== - ErrorCode SMS_CODE_NOT_FOUND = new ErrorCode(1_002_014_000, "验证码不存在"); - ErrorCode SMS_CODE_EXPIRED = new ErrorCode(1_002_014_001, "验证码已过期"); - ErrorCode SMS_CODE_USED = new ErrorCode(1_002_014_002, "验证码已使用"); - ErrorCode SMS_CODE_EXCEED_SEND_MAXIMUM_QUANTITY_PER_DAY = new ErrorCode(1_002_014_004, "超过每日短信发送数量"); - ErrorCode SMS_CODE_SEND_TOO_FAST = new ErrorCode(1_002_014_005, "短信发送过于频繁"); - - // ========== 租户信息 1-002-015-000 ========== - ErrorCode TENANT_NOT_EXISTS = new ErrorCode(1_002_015_000, "租户不存在"); - ErrorCode TENANT_DISABLE = new ErrorCode(1_002_015_001, "名字为【{}】的租户已被禁用"); - ErrorCode TENANT_EXPIRE = new ErrorCode(1_002_015_002, "名字为【{}】的租户已过期"); - ErrorCode TENANT_CAN_NOT_UPDATE_SYSTEM = new ErrorCode(1_002_015_003, "系统租户不能进行修改、删除等操作!"); - ErrorCode TENANT_NAME_DUPLICATE = new ErrorCode(1_002_015_004, "名字为【{}】的租户已存在"); - ErrorCode TENANT_WEBSITE_DUPLICATE = new ErrorCode(1_002_015_005, "域名为【{}】的租户已存在"); - - // ========== 租户套餐 1-002-016-000 ========== - ErrorCode TENANT_PACKAGE_NOT_EXISTS = new ErrorCode(1_002_016_000, "租户套餐不存在"); - ErrorCode TENANT_PACKAGE_USED = new ErrorCode(1_002_016_001, "租户正在使用该套餐,请给租户重新设置套餐后再尝试删除"); - ErrorCode TENANT_PACKAGE_DISABLE = new ErrorCode(1_002_016_002, "名字为【{}】的租户套餐已被禁用"); - ErrorCode TENANT_PACKAGE_NAME_DUPLICATE = new ErrorCode(1_002_016_003, "已经存在该名字的租户套餐"); - - // ========== 社交用户 1-002-018-000 ========== - ErrorCode SOCIAL_USER_AUTH_FAILURE = new ErrorCode(1_002_018_000, "社交授权失败,原因是:{}"); - ErrorCode SOCIAL_USER_NOT_FOUND = new ErrorCode(1_002_018_001, "社交授权失败,找不到对应的用户"); - - ErrorCode SOCIAL_CLIENT_WEIXIN_MINI_APP_PHONE_CODE_ERROR = new ErrorCode(1_002_018_200, "获得手机号失败"); - ErrorCode SOCIAL_CLIENT_WEIXIN_MINI_APP_QRCODE_ERROR = new ErrorCode(1_002_018_201, "获得小程序码失败"); - ErrorCode SOCIAL_CLIENT_WEIXIN_MINI_APP_SUBSCRIBE_TEMPLATE_ERROR = new ErrorCode(1_002_018_202, "获得小程序订阅消息模版失败"); - ErrorCode SOCIAL_CLIENT_WEIXIN_MINI_APP_SUBSCRIBE_MESSAGE_ERROR = new ErrorCode(1_002_018_203, "发送小程序订阅消息失败"); - ErrorCode SOCIAL_CLIENT_WEIXIN_MINI_APP_ORDER_UPLOAD_SHIPPING_INFO_ERROR = new ErrorCode(1_002_018_204, "上传微信小程序发货信息失败"); - ErrorCode SOCIAL_CLIENT_WEIXIN_MINI_APP_ORDER_NOTIFY_CONFIRM_RECEIVE_ERROR = new ErrorCode(1_002_018_205, "上传微信小程序订单收货信息失败"); - ErrorCode SOCIAL_CLIENT_NOT_EXISTS = new ErrorCode(1_002_018_210, "社交客户端不存在"); - ErrorCode SOCIAL_CLIENT_UNIQUE = new ErrorCode(1_002_018_211, "社交客户端已存在配置"); - - // ========== OAuth2 客户端 1-002-020-000 ========= - ErrorCode OAUTH2_CLIENT_NOT_EXISTS = new ErrorCode(1_002_020_000, "OAuth2 客户端不存在"); - ErrorCode OAUTH2_CLIENT_EXISTS = new ErrorCode(1_002_020_001, "OAuth2 客户端编号已存在"); - ErrorCode OAUTH2_CLIENT_DISABLE = new ErrorCode(1_002_020_002, "OAuth2 客户端已禁用"); - ErrorCode OAUTH2_CLIENT_AUTHORIZED_GRANT_TYPE_NOT_EXISTS = new ErrorCode(1_002_020_003, "不支持该授权类型"); - ErrorCode OAUTH2_CLIENT_SCOPE_OVER = new ErrorCode(1_002_020_004, "授权范围过大"); - ErrorCode OAUTH2_CLIENT_REDIRECT_URI_NOT_MATCH = new ErrorCode(1_002_020_005, "无效 redirect_uri: {}"); - ErrorCode OAUTH2_CLIENT_CLIENT_SECRET_ERROR = new ErrorCode(1_002_020_006, "无效 client_secret: {}"); - - // ========== OAuth2 授权 1-002-021-000 ========= - ErrorCode OAUTH2_GRANT_CLIENT_ID_MISMATCH = new ErrorCode(1_002_021_000, "client_id 不匹配"); - ErrorCode OAUTH2_GRANT_REDIRECT_URI_MISMATCH = new ErrorCode(1_002_021_001, "redirect_uri 不匹配"); - ErrorCode OAUTH2_GRANT_STATE_MISMATCH = new ErrorCode(1_002_021_002, "state 不匹配"); - - // ========== OAuth2 授权 1-002-022-000 ========= - ErrorCode OAUTH2_CODE_NOT_EXISTS = new ErrorCode(1_002_022_000, "code 不存在"); - ErrorCode OAUTH2_CODE_EXPIRE = new ErrorCode(1_002_022_001, "code 已过期"); - - // ========== 邮箱账号 1-002-023-000 ========== - ErrorCode MAIL_ACCOUNT_NOT_EXISTS = new ErrorCode(1_002_023_000, "邮箱账号不存在"); - ErrorCode MAIL_ACCOUNT_RELATE_TEMPLATE_EXISTS = new ErrorCode(1_002_023_001, "无法删除,该邮箱账号还有邮件模板"); - - // ========== 邮件模版 1-002-024-000 ========== - ErrorCode MAIL_TEMPLATE_NOT_EXISTS = new ErrorCode(1_002_024_000, "邮件模版不存在"); - ErrorCode MAIL_TEMPLATE_CODE_EXISTS = new ErrorCode(1_002_024_001, "邮件模版 code({}) 已存在"); - - // ========== 邮件发送 1-002-025-000 ========== - ErrorCode MAIL_SEND_TEMPLATE_PARAM_MISS = new ErrorCode(1_002_025_000, "模板参数({})缺失"); - ErrorCode MAIL_SEND_MAIL_NOT_EXISTS = new ErrorCode(1_002_025_001, "邮箱不存在"); - - // ========== 站内信模版 1-002-026-000 ========== - ErrorCode NOTIFY_TEMPLATE_NOT_EXISTS = new ErrorCode(1_002_026_000, "站内信模版不存在"); - ErrorCode NOTIFY_TEMPLATE_CODE_DUPLICATE = new ErrorCode(1_002_026_001, "已经存在编码为【{}】的站内信模板"); - - // ========== 站内信模版 1-002-027-000 ========== - - // ========== 站内信发送 1-002-028-000 ========== - ErrorCode NOTIFY_SEND_TEMPLATE_PARAM_MISS = new ErrorCode(1_002_028_000, "模板参数({})缺失"); - -} +package com.viewsh.module.system.enums; + +import com.viewsh.framework.common.exception.ErrorCode; + +/** + * System 错误码枚举类 + * + * system 系统,使用 1-002-000-000 段 + */ +public interface ErrorCodeConstants { + + // ========== AUTH 模块 1-002-000-000 ========== + ErrorCode AUTH_LOGIN_BAD_CREDENTIALS = new ErrorCode(1_002_000_000, "登录失败,账号密码不正确"); + ErrorCode AUTH_LOGIN_USER_DISABLED = new ErrorCode(1_002_000_001, "登录失败,账号被禁用"); + ErrorCode AUTH_LOGIN_CAPTCHA_CODE_ERROR = new ErrorCode(1_002_000_004, "验证码不正确,原因:{}"); + ErrorCode AUTH_THIRD_LOGIN_NOT_BIND = new ErrorCode(1_002_000_005, "未绑定账号,需要进行绑定"); + ErrorCode AUTH_MOBILE_NOT_EXISTS = new ErrorCode(1_002_000_007, "手机号不存在"); + ErrorCode AUTH_REGISTER_CAPTCHA_CODE_ERROR = new ErrorCode(1_002_000_008, "验证码不正确,原因:{}"); + ErrorCode AUTH_WEIXIN_MINI_APP_PHONE_NOT_FOUND = new ErrorCode(1_002_000_009, "手机号未关联管理员账号"); + ErrorCode AUTH_WEIXIN_MINI_APP_BINDTO_OTHER_WECHAT = new ErrorCode(1_002_000_010, "该账号已绑定其他微信,请先解绑"); + ErrorCode AUTH_WEIXIN_MINI_APP_WECHAT_BINDTO_OTHER = new ErrorCode(1_002_000_011, "该微信已绑定其他账号"); + + // ========== 菜单模块 1-002-001-000 ========== + ErrorCode MENU_NAME_DUPLICATE = new ErrorCode(1_002_001_000, "已经存在该名字的菜单"); + ErrorCode MENU_PARENT_NOT_EXISTS = new ErrorCode(1_002_001_001, "父菜单不存在"); + ErrorCode MENU_PARENT_ERROR = new ErrorCode(1_002_001_002, "不能设置自己为父菜单"); + ErrorCode MENU_NOT_EXISTS = new ErrorCode(1_002_001_003, "菜单不存在"); + ErrorCode MENU_EXISTS_CHILDREN = new ErrorCode(1_002_001_004, "存在子菜单,无法删除"); + ErrorCode MENU_PARENT_NOT_DIR_OR_MENU = new ErrorCode(1_002_001_005, "父菜单的类型必须是目录或者菜单"); + ErrorCode MENU_COMPONENT_NAME_DUPLICATE = new ErrorCode(1_002_001_006, "已经存在该组件名的菜单"); + + // ========== 角色模块 1-002-002-000 ========== + ErrorCode ROLE_NOT_EXISTS = new ErrorCode(1_002_002_000, "角色不存在"); + ErrorCode ROLE_NAME_DUPLICATE = new ErrorCode(1_002_002_001, "已经存在名为【{}】的角色"); + ErrorCode ROLE_CODE_DUPLICATE = new ErrorCode(1_002_002_002, "已经存在标识为【{}】的角色"); + ErrorCode ROLE_CAN_NOT_UPDATE_SYSTEM_TYPE_ROLE = new ErrorCode(1_002_002_003, "不能操作类型为系统内置的角色"); + ErrorCode ROLE_IS_DISABLE = new ErrorCode(1_002_002_004, "名字为【{}】的角色已被禁用"); + ErrorCode ROLE_ADMIN_CODE_ERROR = new ErrorCode(1_002_002_005, "标识【{}】不能使用"); + + // ========== 用户模块 1-002-003-000 ========== + ErrorCode USER_USERNAME_EXISTS = new ErrorCode(1_002_003_000, "用户账号已经存在"); + ErrorCode USER_MOBILE_EXISTS = new ErrorCode(1_002_003_001, "手机号已经存在"); + ErrorCode USER_EMAIL_EXISTS = new ErrorCode(1_002_003_002, "邮箱已经存在"); + ErrorCode USER_NOT_EXISTS = new ErrorCode(1_002_003_003, "用户不存在"); + ErrorCode USER_IMPORT_LIST_IS_EMPTY = new ErrorCode(1_002_003_004, "导入用户数据不能为空!"); + ErrorCode USER_PASSWORD_FAILED = new ErrorCode(1_002_003_005, "用户密码校验失败"); + ErrorCode USER_IS_DISABLE = new ErrorCode(1_002_003_006, "名字为【{}】的用户已被禁用"); + ErrorCode USER_COUNT_MAX = new ErrorCode(1_002_003_008, "创建用户失败,原因:超过租户最大租户配额({})!"); + ErrorCode USER_IMPORT_INIT_PASSWORD = new ErrorCode(1_002_003_009, "初始密码不能为空"); + ErrorCode USER_MOBILE_NOT_EXISTS = new ErrorCode(1_002_003_010, "该手机号尚未注册"); + ErrorCode USER_REGISTER_DISABLED = new ErrorCode(1_002_003_011, "注册功能已关闭"); + + // ========== 部门模块 1-002-004-000 ========== + ErrorCode DEPT_NAME_DUPLICATE = new ErrorCode(1_002_004_000, "已经存在该名字的部门"); + ErrorCode DEPT_PARENT_NOT_EXITS = new ErrorCode(1_002_004_001,"父级部门不存在"); + ErrorCode DEPT_NOT_FOUND = new ErrorCode(1_002_004_002, "当前部门不存在"); + ErrorCode DEPT_EXITS_CHILDREN = new ErrorCode(1_002_004_003, "存在子部门,无法删除"); + ErrorCode DEPT_PARENT_ERROR = new ErrorCode(1_002_004_004, "不能设置自己为父部门"); + ErrorCode DEPT_NOT_ENABLE = new ErrorCode(1_002_004_006, "部门({})不处于开启状态,不允许选择"); + ErrorCode DEPT_PARENT_IS_CHILD = new ErrorCode(1_002_004_007, "不能设置自己的子部门为父部门"); + + // ========== 岗位模块 1-002-005-000 ========== + ErrorCode POST_NOT_FOUND = new ErrorCode(1_002_005_000, "当前岗位不存在"); + ErrorCode POST_NOT_ENABLE = new ErrorCode(1_002_005_001, "岗位({}) 不处于开启状态,不允许选择"); + ErrorCode POST_NAME_DUPLICATE = new ErrorCode(1_002_005_002, "已经存在该名字的岗位"); + ErrorCode POST_CODE_DUPLICATE = new ErrorCode(1_002_005_003, "已经存在该标识的岗位"); + + // ========== 字典类型 1-002-006-000 ========== + ErrorCode DICT_TYPE_NOT_EXISTS = new ErrorCode(1_002_006_001, "当前字典类型不存在"); + ErrorCode DICT_TYPE_NOT_ENABLE = new ErrorCode(1_002_006_002, "字典类型不处于开启状态,不允许选择"); + ErrorCode DICT_TYPE_NAME_DUPLICATE = new ErrorCode(1_002_006_003, "已经存在该名字的字典类型"); + ErrorCode DICT_TYPE_TYPE_DUPLICATE = new ErrorCode(1_002_006_004, "已经存在该类型的字典类型"); + ErrorCode DICT_TYPE_HAS_CHILDREN = new ErrorCode(1_002_006_005, "无法删除,该字典类型还有字典数据"); + + // ========== 字典数据 1-002-007-000 ========== + ErrorCode DICT_DATA_NOT_EXISTS = new ErrorCode(1_002_007_001, "当前字典数据不存在"); + ErrorCode DICT_DATA_NOT_ENABLE = new ErrorCode(1_002_007_002, "字典数据({})不处于开启状态,不允许选择"); + ErrorCode DICT_DATA_VALUE_DUPLICATE = new ErrorCode(1_002_007_003, "已经存在该值的字典数据"); + + // ========== 通知公告 1-002-008-000 ========== + ErrorCode NOTICE_NOT_FOUND = new ErrorCode(1_002_008_001, "当前通知公告不存在"); + + // ========== 短信渠道 1-002-011-000 ========== + ErrorCode SMS_CHANNEL_NOT_EXISTS = new ErrorCode(1_002_011_000, "短信渠道不存在"); + ErrorCode SMS_CHANNEL_DISABLE = new ErrorCode(1_002_011_001, "短信渠道不处于开启状态,不允许选择"); + ErrorCode SMS_CHANNEL_HAS_CHILDREN = new ErrorCode(1_002_011_002, "无法删除,该短信渠道还有短信模板"); + + // ========== 短信模板 1-002-012-000 ========== + ErrorCode SMS_TEMPLATE_NOT_EXISTS = new ErrorCode(1_002_012_000, "短信模板不存在"); + ErrorCode SMS_TEMPLATE_CODE_DUPLICATE = new ErrorCode(1_002_012_001, "已经存在编码为【{}】的短信模板"); + ErrorCode SMS_TEMPLATE_API_ERROR = new ErrorCode(1_002_012_002, "短信 API 模板调用失败,原因是:{}"); + ErrorCode SMS_TEMPLATE_API_AUDIT_CHECKING = new ErrorCode(1_002_012_003, "短信 API 模版无法使用,原因:审批中"); + ErrorCode SMS_TEMPLATE_API_AUDIT_FAIL = new ErrorCode(1_002_012_004, "短信 API 模版无法使用,原因:审批不通过,{}"); + ErrorCode SMS_TEMPLATE_API_NOT_FOUND = new ErrorCode(1_002_012_005, "短信 API 模版无法使用,原因:模版不存在"); + + // ========== 短信发送 1-002-013-000 ========== + ErrorCode SMS_SEND_MOBILE_NOT_EXISTS = new ErrorCode(1_002_013_000, "手机号不存在"); + ErrorCode SMS_SEND_MOBILE_TEMPLATE_PARAM_MISS = new ErrorCode(1_002_013_001, "模板参数({})缺失"); + ErrorCode SMS_SEND_TEMPLATE_NOT_EXISTS = new ErrorCode(1_002_013_002, "短信模板不存在"); + + // ========== 短信验证码 1-002-014-000 ========== + ErrorCode SMS_CODE_NOT_FOUND = new ErrorCode(1_002_014_000, "验证码不存在"); + ErrorCode SMS_CODE_EXPIRED = new ErrorCode(1_002_014_001, "验证码已过期"); + ErrorCode SMS_CODE_USED = new ErrorCode(1_002_014_002, "验证码已使用"); + ErrorCode SMS_CODE_EXCEED_SEND_MAXIMUM_QUANTITY_PER_DAY = new ErrorCode(1_002_014_004, "超过每日短信发送数量"); + ErrorCode SMS_CODE_SEND_TOO_FAST = new ErrorCode(1_002_014_005, "短信发送过于频繁"); + + // ========== 租户信息 1-002-015-000 ========== + ErrorCode TENANT_NOT_EXISTS = new ErrorCode(1_002_015_000, "租户不存在"); + ErrorCode TENANT_DISABLE = new ErrorCode(1_002_015_001, "名字为【{}】的租户已被禁用"); + ErrorCode TENANT_EXPIRE = new ErrorCode(1_002_015_002, "名字为【{}】的租户已过期"); + ErrorCode TENANT_CAN_NOT_UPDATE_SYSTEM = new ErrorCode(1_002_015_003, "系统租户不能进行修改、删除等操作!"); + ErrorCode TENANT_NAME_DUPLICATE = new ErrorCode(1_002_015_004, "名字为【{}】的租户已存在"); + ErrorCode TENANT_WEBSITE_DUPLICATE = new ErrorCode(1_002_015_005, "域名为【{}】的租户已存在"); + + // ========== 租户套餐 1-002-016-000 ========== + ErrorCode TENANT_PACKAGE_NOT_EXISTS = new ErrorCode(1_002_016_000, "租户套餐不存在"); + ErrorCode TENANT_PACKAGE_USED = new ErrorCode(1_002_016_001, "租户正在使用该套餐,请给租户重新设置套餐后再尝试删除"); + ErrorCode TENANT_PACKAGE_DISABLE = new ErrorCode(1_002_016_002, "名字为【{}】的租户套餐已被禁用"); + ErrorCode TENANT_PACKAGE_NAME_DUPLICATE = new ErrorCode(1_002_016_003, "已经存在该名字的租户套餐"); + + // ========== 社交用户 1-002-018-000 ========== + ErrorCode SOCIAL_USER_AUTH_FAILURE = new ErrorCode(1_002_018_000, "社交授权失败,原因是:{}"); + ErrorCode SOCIAL_USER_NOT_FOUND = new ErrorCode(1_002_018_001, "社交授权失败,找不到对应的用户"); + + ErrorCode SOCIAL_CLIENT_WEIXIN_MINI_APP_PHONE_CODE_ERROR = new ErrorCode(1_002_018_200, "获得手机号失败"); + ErrorCode SOCIAL_CLIENT_WEIXIN_MINI_APP_QRCODE_ERROR = new ErrorCode(1_002_018_201, "获得小程序码失败"); + ErrorCode SOCIAL_CLIENT_WEIXIN_MINI_APP_SUBSCRIBE_TEMPLATE_ERROR = new ErrorCode(1_002_018_202, "获得小程序订阅消息模版失败"); + ErrorCode SOCIAL_CLIENT_WEIXIN_MINI_APP_SUBSCRIBE_MESSAGE_ERROR = new ErrorCode(1_002_018_203, "发送小程序订阅消息失败"); + ErrorCode SOCIAL_CLIENT_WEIXIN_MINI_APP_ORDER_UPLOAD_SHIPPING_INFO_ERROR = new ErrorCode(1_002_018_204, "上传微信小程序发货信息失败"); + ErrorCode SOCIAL_CLIENT_WEIXIN_MINI_APP_ORDER_NOTIFY_CONFIRM_RECEIVE_ERROR = new ErrorCode(1_002_018_205, "上传微信小程序订单收货信息失败"); + ErrorCode SOCIAL_CLIENT_NOT_EXISTS = new ErrorCode(1_002_018_210, "社交客户端不存在"); + ErrorCode SOCIAL_CLIENT_UNIQUE = new ErrorCode(1_002_018_211, "社交客户端已存在配置"); + + // ========== OAuth2 客户端 1-002-020-000 ========= + ErrorCode OAUTH2_CLIENT_NOT_EXISTS = new ErrorCode(1_002_020_000, "OAuth2 客户端不存在"); + ErrorCode OAUTH2_CLIENT_EXISTS = new ErrorCode(1_002_020_001, "OAuth2 客户端编号已存在"); + ErrorCode OAUTH2_CLIENT_DISABLE = new ErrorCode(1_002_020_002, "OAuth2 客户端已禁用"); + ErrorCode OAUTH2_CLIENT_AUTHORIZED_GRANT_TYPE_NOT_EXISTS = new ErrorCode(1_002_020_003, "不支持该授权类型"); + ErrorCode OAUTH2_CLIENT_SCOPE_OVER = new ErrorCode(1_002_020_004, "授权范围过大"); + ErrorCode OAUTH2_CLIENT_REDIRECT_URI_NOT_MATCH = new ErrorCode(1_002_020_005, "无效 redirect_uri: {}"); + ErrorCode OAUTH2_CLIENT_CLIENT_SECRET_ERROR = new ErrorCode(1_002_020_006, "无效 client_secret: {}"); + + // ========== OAuth2 授权 1-002-021-000 ========= + ErrorCode OAUTH2_GRANT_CLIENT_ID_MISMATCH = new ErrorCode(1_002_021_000, "client_id 不匹配"); + ErrorCode OAUTH2_GRANT_REDIRECT_URI_MISMATCH = new ErrorCode(1_002_021_001, "redirect_uri 不匹配"); + ErrorCode OAUTH2_GRANT_STATE_MISMATCH = new ErrorCode(1_002_021_002, "state 不匹配"); + + // ========== OAuth2 授权 1-002-022-000 ========= + ErrorCode OAUTH2_CODE_NOT_EXISTS = new ErrorCode(1_002_022_000, "code 不存在"); + ErrorCode OAUTH2_CODE_EXPIRE = new ErrorCode(1_002_022_001, "code 已过期"); + + // ========== 邮箱账号 1-002-023-000 ========== + ErrorCode MAIL_ACCOUNT_NOT_EXISTS = new ErrorCode(1_002_023_000, "邮箱账号不存在"); + ErrorCode MAIL_ACCOUNT_RELATE_TEMPLATE_EXISTS = new ErrorCode(1_002_023_001, "无法删除,该邮箱账号还有邮件模板"); + + // ========== 邮件模版 1-002-024-000 ========== + ErrorCode MAIL_TEMPLATE_NOT_EXISTS = new ErrorCode(1_002_024_000, "邮件模版不存在"); + ErrorCode MAIL_TEMPLATE_CODE_EXISTS = new ErrorCode(1_002_024_001, "邮件模版 code({}) 已存在"); + + // ========== 邮件发送 1-002-025-000 ========== + ErrorCode MAIL_SEND_TEMPLATE_PARAM_MISS = new ErrorCode(1_002_025_000, "模板参数({})缺失"); + ErrorCode MAIL_SEND_MAIL_NOT_EXISTS = new ErrorCode(1_002_025_001, "邮箱不存在"); + + // ========== 站内信模版 1-002-026-000 ========== + ErrorCode NOTIFY_TEMPLATE_NOT_EXISTS = new ErrorCode(1_002_026_000, "站内信模版不存在"); + ErrorCode NOTIFY_TEMPLATE_CODE_DUPLICATE = new ErrorCode(1_002_026_001, "已经存在编码为【{}】的站内信模板"); + + // ========== 站内信模版 1-002-027-000 ========== + + // ========== 站内信发送 1-002-028-000 ========== + ErrorCode NOTIFY_SEND_TEMPLATE_PARAM_MISS = new ErrorCode(1_002_028_000, "模板参数({})缺失"); + +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/auth/vo/AuthWeixinMiniAppLoginReqVO.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/auth/vo/AuthWeixinMiniAppLoginReqVO.java new file mode 100644 index 0000000..442d19c --- /dev/null +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/auth/vo/AuthWeixinMiniAppLoginReqVO.java @@ -0,0 +1,31 @@ +package com.viewsh.module.system.controller.admin.auth.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Schema(description = "管理后台 - 微信小程序手机登录 Request VO") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class AuthWeixinMiniAppLoginReqVO { + + @Schema(description = "手机 code,小程序通过 wx.getPhoneNumber 方法获得", + requiredMode = Schema.RequiredMode.REQUIRED, example = "xxxx") + @NotEmpty(message = "手机 code 不能为空") + private String phoneCode; + + @Schema(description = "登录 code,小程序通过 wx.login 方法获得", + requiredMode = Schema.RequiredMode.REQUIRED, example = "yyyy") + @NotEmpty(message = "登录 code 不能为空") + private String loginCode; + + @Schema(description = "state", requiredMode = Schema.RequiredMode.REQUIRED, + example = "9b2ffbc1-7425-4155-9894-9d5c08541d62") + @NotEmpty(message = "state 不能为空") + private String state; +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/auth/AdminAuthService.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/auth/AdminAuthService.java index fa3d811..1e4ad39 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/auth/AdminAuthService.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/auth/AdminAuthService.java @@ -1,87 +1,97 @@ -package com.viewsh.module.system.service.auth; - -import com.viewsh.module.system.controller.admin.auth.vo.*; -import com.viewsh.module.system.dal.dataobject.user.AdminUserDO; -import jakarta.validation.Valid; - -/** - * 管理后台的认证 Service 接口 - * - * 提供用户的登录、登出的能力 - * - * @author 芋道源码 - */ -public interface AdminAuthService { - - /** - * 验证账号 + 密码。如果通过,则返回用户 - * - * @param username 账号 - * @param password 密码 - * @return 用户 - */ - AdminUserDO authenticate(String username, String password); - - /** - * 账号登录 - * - * @param reqVO 登录信息 - * @return 登录结果 - */ - AuthLoginRespVO login(@Valid AuthLoginReqVO reqVO); - - /** - * 基于 token 退出登录 - * - * @param token token - * @param logType 登出类型 - */ - void logout(String token, Integer logType); - - /** - * 短信验证码发送 - * - * @param reqVO 发送请求 - */ - void sendSmsCode(AuthSmsSendReqVO reqVO); - - /** - * 短信登录 - * - * @param reqVO 登录信息 - * @return 登录结果 - */ - AuthLoginRespVO smsLogin(AuthSmsLoginReqVO reqVO); - - /** - * 社交快捷登录,使用 code 授权码 - * - * @param reqVO 登录信息 - * @return 登录结果 - */ - AuthLoginRespVO socialLogin(@Valid AuthSocialLoginReqVO reqVO); - - /** - * 刷新访问令牌 - * - * @param refreshToken 刷新令牌 - * @return 登录结果 - */ - AuthLoginRespVO refreshToken(String refreshToken); - - /** - * 用户注册 - * - * @param createReqVO 注册用户 - * @return 注册结果 - */ - AuthLoginRespVO register(AuthRegisterReqVO createReqVO); - - /** - * 重置密码 - * - * @param reqVO 验证码信息 - */ - void resetPassword(AuthResetPasswordReqVO reqVO); - -} +package com.viewsh.module.system.service.auth; + +import com.viewsh.module.system.controller.admin.auth.vo.*; +import com.viewsh.module.system.dal.dataobject.user.AdminUserDO; +import jakarta.validation.Valid; + +/** + * 管理后台的认证 Service 接口 + * + * 提供用户的登录、登出的能力 + * + * @author 芋道源码 + */ +public interface AdminAuthService { + + /** + * 验证账号 + 密码。如果通过,则返回用户 + * + * @param username 账号 + * @param password 密码 + * @return 用户 + */ + AdminUserDO authenticate(String username, String password); + + /** + * 账号登录 + * + * @param reqVO 登录信息 + * @return 登录结果 + */ + AuthLoginRespVO login(@Valid AuthLoginReqVO reqVO); + + /** + * 基于 token 退出登录 + * + * @param token token + * @param logType 登出类型 + */ + void logout(String token, Integer logType); + + /** + * 短信验证码发送 + * + * @param reqVO 发送请求 + */ + void sendSmsCode(AuthSmsSendReqVO reqVO); + + /** + * 短信登录 + * + * @param reqVO 登录信息 + * @return 登录结果 + */ + AuthLoginRespVO smsLogin(AuthSmsLoginReqVO reqVO); + + /** + * 社交快捷登录,使用 code 授权码 + * + * @param reqVO 登录信息 + * @return 登录结果 + */ + AuthLoginRespVO socialLogin(@Valid AuthSocialLoginReqVO reqVO); + + /** + * 刷新访问令牌 + * + * @param refreshToken 刷新令牌 + * @return 登录结果 + */ + AuthLoginRespVO refreshToken(String refreshToken); + + /** + * 用户注册 + * + * @param createReqVO 注册用户 + * @return 注册结果 + */ + AuthLoginRespVO register(AuthRegisterReqVO createReqVO); + + /** + * 重置密码 + * + * @param reqVO 验证码信息 + */ + void resetPassword(AuthResetPasswordReqVO reqVO); + + /** + * 微信小程序一键登录(管理后台) + * + * 通过手机号匹配管理员账号,并绑定微信社交账号 + * + * @param reqVO 登录信息 + * @return 登录结果 + */ + AuthLoginRespVO weixinMiniAppLogin(@Valid AuthWeixinMiniAppLoginReqVO reqVO); + +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/auth/AdminAuthServiceImpl.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/auth/AdminAuthServiceImpl.java index cf250f0..521ae30 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/auth/AdminAuthServiceImpl.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/auth/AdminAuthServiceImpl.java @@ -1,306 +1,361 @@ -package com.viewsh.module.system.service.auth; - -import cn.hutool.core.util.ObjectUtil; -import com.viewsh.framework.common.enums.CommonStatusEnum; -import com.viewsh.framework.common.enums.UserTypeEnum; -import com.viewsh.framework.common.util.monitor.TracerUtils; -import com.viewsh.framework.common.util.object.BeanUtils; -import com.viewsh.framework.common.util.servlet.ServletUtils; -import com.viewsh.framework.common.util.validation.ValidationUtils; -import com.viewsh.framework.datapermission.core.annotation.DataPermission; -import com.viewsh.module.system.api.logger.dto.LoginLogCreateReqDTO; -import com.viewsh.module.system.api.sms.SmsCodeApi; -import com.viewsh.module.system.api.sms.dto.code.SmsCodeUseReqDTO; -import com.viewsh.module.system.api.social.dto.SocialUserBindReqDTO; -import com.viewsh.module.system.api.social.dto.SocialUserRespDTO; -import com.viewsh.module.system.controller.admin.auth.vo.*; -import com.viewsh.module.system.convert.auth.AuthConvert; -import com.viewsh.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO; -import com.viewsh.module.system.dal.dataobject.user.AdminUserDO; -import com.viewsh.module.system.enums.logger.LoginLogTypeEnum; -import com.viewsh.module.system.enums.logger.LoginResultEnum; -import com.viewsh.module.system.enums.oauth2.OAuth2ClientConstants; -import com.viewsh.module.system.enums.sms.SmsSceneEnum; -import com.viewsh.module.system.service.logger.LoginLogService; -import com.viewsh.module.system.service.member.MemberService; -import com.viewsh.module.system.service.oauth2.OAuth2TokenService; -import com.viewsh.module.system.service.social.SocialUserService; -import com.viewsh.module.system.service.user.AdminUserService; -import com.anji.captcha.model.common.ResponseModel; -import com.anji.captcha.model.vo.CaptchaVO; -import com.anji.captcha.service.CaptchaService; -import com.google.common.annotations.VisibleForTesting; -import jakarta.annotation.Resource; -import jakarta.validation.Validator; -import lombok.Setter; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Objects; - -import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception; -import static com.viewsh.framework.common.util.servlet.ServletUtils.getClientIP; -import static com.viewsh.module.system.enums.ErrorCodeConstants.*; - -/** - * Auth Service 实现类 - * - * @author 芋道源码 - */ -@Service -@Slf4j -public class AdminAuthServiceImpl implements AdminAuthService { - - @Resource - private AdminUserService userService; - @Resource - private LoginLogService loginLogService; - @Resource - private OAuth2TokenService oauth2TokenService; - @Resource - private SocialUserService socialUserService; - @Resource - private MemberService memberService; - @Resource - private Validator validator; - @Resource - private CaptchaService captchaService; - @Resource - private SmsCodeApi smsCodeApi; - - /** - * 验证码的开关,默认为 true - */ - @Value("${viewsh.captcha.enable:true}") - @Setter // 为了单测:开启或者关闭验证码 - private Boolean captchaEnable; - - @Override - public AdminUserDO authenticate(String username, String password) { - final LoginLogTypeEnum logTypeEnum = LoginLogTypeEnum.LOGIN_USERNAME; - // 校验账号是否存在 - AdminUserDO user = userService.getUserByUsername(username); - if (user == null) { - createLoginLog(null, username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS); - throw exception(AUTH_LOGIN_BAD_CREDENTIALS); - } - if (!userService.isPasswordMatch(password, user.getPassword())) { - createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS); - throw exception(AUTH_LOGIN_BAD_CREDENTIALS); - } - // 校验是否禁用 - if (CommonStatusEnum.isDisable(user.getStatus())) { - createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.USER_DISABLED); - throw exception(AUTH_LOGIN_USER_DISABLED); - } - return user; - } - - @Override - @DataPermission(enable = false) - public AuthLoginRespVO login(AuthLoginReqVO reqVO) { - // 校验验证码 - validateCaptcha(reqVO); - - // 使用账号密码,进行登录 - AdminUserDO user = authenticate(reqVO.getUsername(), reqVO.getPassword()); - - // 如果 socialType 非空,说明需要绑定社交用户 - if (reqVO.getSocialType() != null) { - socialUserService.bindSocialUser(new SocialUserBindReqDTO(user.getId(), getUserType().getValue(), - reqVO.getSocialType(), reqVO.getSocialCode(), reqVO.getSocialState())); - } - // 创建 Token 令牌,记录登录日志 - return createTokenAfterLoginSuccess(user.getId(), reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME); - } - - @Override - public void sendSmsCode(AuthSmsSendReqVO reqVO) { - // 如果是重置密码场景,需要校验图形验证码是否正确 - if (Objects.equals(SmsSceneEnum.ADMIN_MEMBER_RESET_PASSWORD.getScene(), reqVO.getScene())) { - ResponseModel response = doValidateCaptcha(reqVO); - if (!response.isSuccess()) { - throw exception(AUTH_REGISTER_CAPTCHA_CODE_ERROR, response.getRepMsg()); - } - } - - // 登录场景,验证是否存在 - if (userService.getUserByMobile(reqVO.getMobile()) == null) { - throw exception(AUTH_MOBILE_NOT_EXISTS); - } - // 发送验证码 - smsCodeApi.sendSmsCode(AuthConvert.INSTANCE.convert(reqVO).setCreateIp(getClientIP())); - } - - @Override - public AuthLoginRespVO smsLogin(AuthSmsLoginReqVO reqVO) { - // 校验验证码 - smsCodeApi.useSmsCode(AuthConvert.INSTANCE.convert(reqVO, SmsSceneEnum.ADMIN_MEMBER_LOGIN.getScene(), getClientIP())).checkError(); - - // 获得用户信息 - AdminUserDO user = userService.getUserByMobile(reqVO.getMobile()); - if (user == null) { - throw exception(USER_NOT_EXISTS); - } - - // 创建 Token 令牌,记录登录日志 - return createTokenAfterLoginSuccess(user.getId(), reqVO.getMobile(), LoginLogTypeEnum.LOGIN_MOBILE); - } - - private void createLoginLog(Long userId, String username, - LoginLogTypeEnum logTypeEnum, LoginResultEnum loginResult) { - // 插入登录日志 - LoginLogCreateReqDTO reqDTO = new LoginLogCreateReqDTO(); - reqDTO.setLogType(logTypeEnum.getType()); - reqDTO.setTraceId(TracerUtils.getTraceId()); - reqDTO.setUserId(userId); - reqDTO.setUserType(getUserType().getValue()); - reqDTO.setUsername(username); - reqDTO.setUserAgent(ServletUtils.getUserAgent()); - reqDTO.setUserIp(ServletUtils.getClientIP()); - reqDTO.setResult(loginResult.getResult()); - loginLogService.createLoginLog(reqDTO); - // 更新最后登录时间 - if (userId != null && Objects.equals(LoginResultEnum.SUCCESS.getResult(), loginResult.getResult())) { - userService.updateUserLogin(userId, ServletUtils.getClientIP()); - } - } - - @Override - public AuthLoginRespVO socialLogin(AuthSocialLoginReqVO reqVO) { - // 使用 code 授权码,进行登录。然后,获得到绑定的用户编号 - SocialUserRespDTO socialUser = socialUserService.getSocialUserByCode(UserTypeEnum.ADMIN.getValue(), reqVO.getType(), - reqVO.getCode(), reqVO.getState()); - if (socialUser == null || socialUser.getUserId() == null) { - throw exception(AUTH_THIRD_LOGIN_NOT_BIND); - } - - // 获得用户 - AdminUserDO user = userService.getUser(socialUser.getUserId()); - if (user == null) { - throw exception(USER_NOT_EXISTS); - } - - // 创建 Token 令牌,记录登录日志 - return createTokenAfterLoginSuccess(user.getId(), user.getUsername(), LoginLogTypeEnum.LOGIN_SOCIAL); - } - - @VisibleForTesting - void validateCaptcha(AuthLoginReqVO reqVO) { - ResponseModel response = doValidateCaptcha(reqVO); - // 校验验证码 - if (!response.isSuccess()) { - // 创建登录失败日志(验证码不正确) - createLoginLog(null, reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME, LoginResultEnum.CAPTCHA_CODE_ERROR); - throw exception(AUTH_LOGIN_CAPTCHA_CODE_ERROR, response.getRepMsg()); - } - } - - private ResponseModel doValidateCaptcha(CaptchaVerificationReqVO reqVO) { - // 如果验证码关闭,则不进行校验 - if (!captchaEnable) { - return ResponseModel.success(); - } - ValidationUtils.validate(validator, reqVO, CaptchaVerificationReqVO.CodeEnableGroup.class); - CaptchaVO captchaVO = new CaptchaVO(); - captchaVO.setCaptchaVerification(reqVO.getCaptchaVerification()); - return captchaService.verification(captchaVO); - } - - private AuthLoginRespVO createTokenAfterLoginSuccess(Long userId, String username, LoginLogTypeEnum logType) { - // 插入登陆日志 - createLoginLog(userId, username, logType, LoginResultEnum.SUCCESS); - // 创建访问令牌 - OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.createAccessToken(userId, getUserType().getValue(), - OAuth2ClientConstants.CLIENT_ID_DEFAULT, null); - // 构建返回结果 - return BeanUtils.toBean(accessTokenDO, AuthLoginRespVO.class); - } - - @Override - public AuthLoginRespVO refreshToken(String refreshToken) { - OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.refreshAccessToken(refreshToken, OAuth2ClientConstants.CLIENT_ID_DEFAULT); - return BeanUtils.toBean(accessTokenDO, AuthLoginRespVO.class); - } - - @Override - public void logout(String token, Integer logType) { - // 删除访问令牌 - OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.removeAccessToken(token); - if (accessTokenDO == null) { - return; - } - // 删除成功,则记录登出日志 - createLogoutLog(accessTokenDO.getUserId(), accessTokenDO.getUserType(), logType); - } - - private void createLogoutLog(Long userId, Integer userType, Integer logType) { - LoginLogCreateReqDTO reqDTO = new LoginLogCreateReqDTO(); - reqDTO.setLogType(logType); - reqDTO.setTraceId(TracerUtils.getTraceId()); - reqDTO.setUserId(userId); - reqDTO.setUserType(userType); - if (ObjectUtil.equal(getUserType().getValue(), userType)) { - reqDTO.setUsername(getUsername(userId)); - } else { - reqDTO.setUsername(memberService.getMemberUserMobile(userId)); - } - reqDTO.setUserAgent(ServletUtils.getUserAgent()); - reqDTO.setUserIp(ServletUtils.getClientIP()); - reqDTO.setResult(LoginResultEnum.SUCCESS.getResult()); - loginLogService.createLoginLog(reqDTO); - } - - private String getUsername(Long userId) { - if (userId == null) { - return null; - } - AdminUserDO user = userService.getUser(userId); - return user != null ? user.getUsername() : null; - } - - private UserTypeEnum getUserType() { - return UserTypeEnum.ADMIN; - } - - @Override - public AuthLoginRespVO register(AuthRegisterReqVO registerReqVO) { - // 1. 校验验证码 - validateCaptcha(registerReqVO); - - // 2. 校验用户名是否已存在 - Long userId = userService.registerUser(registerReqVO); - - // 3. 创建 Token 令牌,记录登录日志 - return createTokenAfterLoginSuccess(userId, registerReqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME); - } - - @VisibleForTesting - void validateCaptcha(AuthRegisterReqVO reqVO) { - ResponseModel response = doValidateCaptcha(reqVO); - // 验证不通过 - if (!response.isSuccess()) { - throw exception(AUTH_REGISTER_CAPTCHA_CODE_ERROR, response.getRepMsg()); - } - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void resetPassword(AuthResetPasswordReqVO reqVO) { - AdminUserDO userByMobile = userService.getUserByMobile(reqVO.getMobile()); - if (userByMobile == null) { - throw exception(USER_MOBILE_NOT_EXISTS); - } - - smsCodeApi.useSmsCode(new SmsCodeUseReqDTO() - .setCode(reqVO.getCode()) - .setMobile(reqVO.getMobile()) - .setScene(SmsSceneEnum.ADMIN_MEMBER_RESET_PASSWORD.getScene()) - .setUsedIp(getClientIP()) - ).checkError(); - - userService.updateUserPassword(userByMobile.getId(), reqVO.getPassword()); - } -} +package com.viewsh.module.system.service.auth; + +import cn.hutool.core.util.ObjectUtil; +import com.viewsh.framework.common.enums.CommonStatusEnum; +import com.viewsh.framework.common.enums.UserTypeEnum; +import com.viewsh.framework.common.util.monitor.TracerUtils; +import com.viewsh.framework.common.util.object.BeanUtils; +import com.viewsh.framework.common.util.servlet.ServletUtils; +import com.viewsh.framework.common.util.validation.ValidationUtils; +import com.viewsh.framework.datapermission.core.annotation.DataPermission; +import com.viewsh.module.system.api.logger.dto.LoginLogCreateReqDTO; +import com.viewsh.module.system.api.sms.SmsCodeApi; +import com.viewsh.module.system.api.sms.dto.code.SmsCodeUseReqDTO; +import com.viewsh.module.system.api.social.dto.SocialUserBindReqDTO; +import com.viewsh.module.system.api.social.dto.SocialUserRespDTO; +import com.viewsh.module.system.controller.admin.auth.vo.*; +import com.viewsh.module.system.convert.auth.AuthConvert; +import com.viewsh.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO; +import com.viewsh.module.system.dal.dataobject.user.AdminUserDO; +import com.viewsh.module.system.enums.logger.LoginLogTypeEnum; +import com.viewsh.module.system.enums.logger.LoginResultEnum; +import com.viewsh.module.system.enums.oauth2.OAuth2ClientConstants; +import com.viewsh.module.system.enums.sms.SmsSceneEnum; +import com.viewsh.module.system.service.logger.LoginLogService; +import com.viewsh.module.system.service.member.MemberService; +import com.viewsh.module.system.service.social.SocialClientService; +import com.viewsh.module.system.service.oauth2.OAuth2TokenService; +import com.viewsh.module.system.service.social.SocialUserService; +import com.viewsh.module.system.service.user.AdminUserService; +import com.anji.captcha.model.common.ResponseModel; +import com.anji.captcha.model.vo.CaptchaVO; +import com.anji.captcha.service.CaptchaService; +import com.google.common.annotations.VisibleForTesting; +import jakarta.annotation.Resource; +import jakarta.validation.Validator; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import cn.binarywang.wx.miniapp.bean.WxMaPhoneNumberInfo; +import cn.hutool.core.lang.Assert; +import com.viewsh.module.system.enums.social.SocialTypeEnum; + +import java.util.Objects; + +import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception; +import static com.viewsh.framework.common.util.servlet.ServletUtils.getClientIP; +import static com.viewsh.module.system.enums.ErrorCodeConstants.*; + +/** + * Auth Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Slf4j +public class AdminAuthServiceImpl implements AdminAuthService { + + @Resource + private AdminUserService userService; + @Resource + private LoginLogService loginLogService; + @Resource + private OAuth2TokenService oauth2TokenService; + @Resource + private SocialUserService socialUserService; + @Resource + private MemberService memberService; + @Resource + private SocialClientService socialClientService; + @Resource + private Validator validator; + @Resource + private CaptchaService captchaService; + @Resource + private SmsCodeApi smsCodeApi; + + /** + * 验证码的开关,默认为 true + */ + @Value("${viewsh.captcha.enable:true}") + @Setter // 为了单测:开启或者关闭验证码 + private Boolean captchaEnable; + + @Override + public AdminUserDO authenticate(String username, String password) { + final LoginLogTypeEnum logTypeEnum = LoginLogTypeEnum.LOGIN_USERNAME; + // 校验账号是否存在 + AdminUserDO user = userService.getUserByUsername(username); + if (user == null) { + createLoginLog(null, username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS); + throw exception(AUTH_LOGIN_BAD_CREDENTIALS); + } + if (!userService.isPasswordMatch(password, user.getPassword())) { + createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS); + throw exception(AUTH_LOGIN_BAD_CREDENTIALS); + } + // 校验是否禁用 + if (CommonStatusEnum.isDisable(user.getStatus())) { + createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.USER_DISABLED); + throw exception(AUTH_LOGIN_USER_DISABLED); + } + return user; + } + + @Override + @DataPermission(enable = false) + public AuthLoginRespVO login(AuthLoginReqVO reqVO) { + // 校验验证码 + validateCaptcha(reqVO); + + // 使用账号密码,进行登录 + AdminUserDO user = authenticate(reqVO.getUsername(), reqVO.getPassword()); + + // 如果 socialType 非空,说明需要绑定社交用户 + if (reqVO.getSocialType() != null) { + socialUserService.bindSocialUser(new SocialUserBindReqDTO(user.getId(), getUserType().getValue(), + reqVO.getSocialType(), reqVO.getSocialCode(), reqVO.getSocialState())); + } + // 创建 Token 令牌,记录登录日志 + return createTokenAfterLoginSuccess(user.getId(), reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME); + } + + @Override + public void sendSmsCode(AuthSmsSendReqVO reqVO) { + // 如果是重置密码场景,需要校验图形验证码是否正确 + if (Objects.equals(SmsSceneEnum.ADMIN_MEMBER_RESET_PASSWORD.getScene(), reqVO.getScene())) { + ResponseModel response = doValidateCaptcha(reqVO); + if (!response.isSuccess()) { + throw exception(AUTH_REGISTER_CAPTCHA_CODE_ERROR, response.getRepMsg()); + } + } + + // 登录场景,验证是否存在 + if (userService.getUserByMobile(reqVO.getMobile()) == null) { + throw exception(AUTH_MOBILE_NOT_EXISTS); + } + // 发送验证码 + smsCodeApi.sendSmsCode(AuthConvert.INSTANCE.convert(reqVO).setCreateIp(getClientIP())); + } + + @Override + public AuthLoginRespVO smsLogin(AuthSmsLoginReqVO reqVO) { + // 校验验证码 + smsCodeApi.useSmsCode(AuthConvert.INSTANCE.convert(reqVO, SmsSceneEnum.ADMIN_MEMBER_LOGIN.getScene(), getClientIP())).checkError(); + + // 获得用户信息 + AdminUserDO user = userService.getUserByMobile(reqVO.getMobile()); + if (user == null) { + throw exception(USER_NOT_EXISTS); + } + + // 创建 Token 令牌,记录登录日志 + return createTokenAfterLoginSuccess(user.getId(), reqVO.getMobile(), LoginLogTypeEnum.LOGIN_MOBILE); + } + + private void createLoginLog(Long userId, String username, + LoginLogTypeEnum logTypeEnum, LoginResultEnum loginResult) { + // 插入登录日志 + LoginLogCreateReqDTO reqDTO = new LoginLogCreateReqDTO(); + reqDTO.setLogType(logTypeEnum.getType()); + reqDTO.setTraceId(TracerUtils.getTraceId()); + reqDTO.setUserId(userId); + reqDTO.setUserType(getUserType().getValue()); + reqDTO.setUsername(username); + reqDTO.setUserAgent(ServletUtils.getUserAgent()); + reqDTO.setUserIp(ServletUtils.getClientIP()); + reqDTO.setResult(loginResult.getResult()); + loginLogService.createLoginLog(reqDTO); + // 更新最后登录时间 + if (userId != null && Objects.equals(LoginResultEnum.SUCCESS.getResult(), loginResult.getResult())) { + userService.updateUserLogin(userId, ServletUtils.getClientIP()); + } + } + + @Override + public AuthLoginRespVO socialLogin(AuthSocialLoginReqVO reqVO) { + // 使用 code 授权码,进行登录。然后,获得到绑定的用户编号 + SocialUserRespDTO socialUser = socialUserService.getSocialUserByCode(UserTypeEnum.ADMIN.getValue(), reqVO.getType(), + reqVO.getCode(), reqVO.getState()); + if (socialUser == null || socialUser.getUserId() == null) { + throw exception(AUTH_THIRD_LOGIN_NOT_BIND); + } + + // 获得用户 + AdminUserDO user = userService.getUser(socialUser.getUserId()); + if (user == null) { + throw exception(USER_NOT_EXISTS); + } + + // 创建 Token 令牌,记录登录日志 + return createTokenAfterLoginSuccess(user.getId(), user.getUsername(), LoginLogTypeEnum.LOGIN_SOCIAL); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public AuthLoginRespVO weixinMiniAppLogin(AuthWeixinMiniAppLoginReqVO reqVO) { + // 1. 通过 phoneCode 获取手机号 + WxMaPhoneNumberInfo phoneNumberInfo = socialClientService.getWxMaPhoneNumberInfo( + UserTypeEnum.ADMIN.getValue(), reqVO.getPhoneCode()); + Assert.notNull(phoneNumberInfo, "获得手机信息失败,结果为空"); + String mobile = phoneNumberInfo.getPurePhoneNumber(); + + // 2. 通过手机号查找管理员 + AdminUserDO user = userService.getUserByMobile(mobile); + if (user == null) { + throw exception(AUTH_WEIXIN_MINI_APP_PHONE_NOT_FOUND); + } + if (CommonStatusEnum.isDisable(user.getStatus())) { + throw exception(AUTH_LOGIN_USER_DISABLED); + } + + // 3. 通过 loginCode 获取社交用户(含 openid) + SocialUserRespDTO socialUser = socialUserService.getSocialUserByCode( + UserTypeEnum.ADMIN.getValue(), SocialTypeEnum.WECHAT_MINI_PROGRAM.getType(), + reqVO.getLoginCode(), reqVO.getState()); + Assert.notNull(socialUser, "社交用户不能为空"); + + // 4. 绑定冲突检测 + // 4a. 当前 openid 是否已绑定其他管理员 + if (socialUser.getUserId() != null && !socialUser.getUserId().equals(user.getId())) { + throw exception(AUTH_WEIXIN_MINI_APP_WECHAT_BINDTO_OTHER); + } + // 4b. 目标管理员是否已绑定其他微信 + SocialUserRespDTO existByUser = socialUserService.getSocialUserByUserId( + UserTypeEnum.ADMIN.getValue(), user.getId(), + SocialTypeEnum.WECHAT_MINI_PROGRAM.getType()); + if (existByUser != null && !existByUser.getOpenid().equals(socialUser.getOpenid())) { + throw exception(AUTH_WEIXIN_MINI_APP_BINDTO_OTHER_WECHAT); + } + + // 5. 绑定社交用户(无冲突且未绑定时) + if (existByUser == null) { + socialUserService.bindSocialUser(new SocialUserBindReqDTO(user.getId(), + getUserType().getValue(), SocialTypeEnum.WECHAT_MINI_PROGRAM.getType(), + reqVO.getLoginCode(), reqVO.getState())); + } + + // 6. 创建 Token 令牌,记录登录日志 + return createTokenAfterLoginSuccess(user.getId(), user.getUsername(), LoginLogTypeEnum.LOGIN_SOCIAL); + } + + @VisibleForTesting + void validateCaptcha(AuthLoginReqVO reqVO) { + ResponseModel response = doValidateCaptcha(reqVO); + // 校验验证码 + if (!response.isSuccess()) { + // 创建登录失败日志(验证码不正确) + createLoginLog(null, reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME, LoginResultEnum.CAPTCHA_CODE_ERROR); + throw exception(AUTH_LOGIN_CAPTCHA_CODE_ERROR, response.getRepMsg()); + } + } + + private ResponseModel doValidateCaptcha(CaptchaVerificationReqVO reqVO) { + // 如果验证码关闭,则不进行校验 + if (!captchaEnable) { + return ResponseModel.success(); + } + ValidationUtils.validate(validator, reqVO, CaptchaVerificationReqVO.CodeEnableGroup.class); + CaptchaVO captchaVO = new CaptchaVO(); + captchaVO.setCaptchaVerification(reqVO.getCaptchaVerification()); + return captchaService.verification(captchaVO); + } + + private AuthLoginRespVO createTokenAfterLoginSuccess(Long userId, String username, LoginLogTypeEnum logType) { + // 插入登陆日志 + createLoginLog(userId, username, logType, LoginResultEnum.SUCCESS); + // 创建访问令牌 + OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.createAccessToken(userId, getUserType().getValue(), + OAuth2ClientConstants.CLIENT_ID_DEFAULT, null); + // 构建返回结果 + return BeanUtils.toBean(accessTokenDO, AuthLoginRespVO.class); + } + + @Override + public AuthLoginRespVO refreshToken(String refreshToken) { + OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.refreshAccessToken(refreshToken, OAuth2ClientConstants.CLIENT_ID_DEFAULT); + return BeanUtils.toBean(accessTokenDO, AuthLoginRespVO.class); + } + + @Override + public void logout(String token, Integer logType) { + // 删除访问令牌 + OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.removeAccessToken(token); + if (accessTokenDO == null) { + return; + } + // 删除成功,则记录登出日志 + createLogoutLog(accessTokenDO.getUserId(), accessTokenDO.getUserType(), logType); + } + + private void createLogoutLog(Long userId, Integer userType, Integer logType) { + LoginLogCreateReqDTO reqDTO = new LoginLogCreateReqDTO(); + reqDTO.setLogType(logType); + reqDTO.setTraceId(TracerUtils.getTraceId()); + reqDTO.setUserId(userId); + reqDTO.setUserType(userType); + if (ObjectUtil.equal(getUserType().getValue(), userType)) { + reqDTO.setUsername(getUsername(userId)); + } else { + reqDTO.setUsername(memberService.getMemberUserMobile(userId)); + } + reqDTO.setUserAgent(ServletUtils.getUserAgent()); + reqDTO.setUserIp(ServletUtils.getClientIP()); + reqDTO.setResult(LoginResultEnum.SUCCESS.getResult()); + loginLogService.createLoginLog(reqDTO); + } + + private String getUsername(Long userId) { + if (userId == null) { + return null; + } + AdminUserDO user = userService.getUser(userId); + return user != null ? user.getUsername() : null; + } + + private UserTypeEnum getUserType() { + return UserTypeEnum.ADMIN; + } + + @Override + public AuthLoginRespVO register(AuthRegisterReqVO registerReqVO) { + // 1. 校验验证码 + validateCaptcha(registerReqVO); + + // 2. 校验用户名是否已存在 + Long userId = userService.registerUser(registerReqVO); + + // 3. 创建 Token 令牌,记录登录日志 + return createTokenAfterLoginSuccess(userId, registerReqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME); + } + + @VisibleForTesting + void validateCaptcha(AuthRegisterReqVO reqVO) { + ResponseModel response = doValidateCaptcha(reqVO); + // 验证不通过 + if (!response.isSuccess()) { + throw exception(AUTH_REGISTER_CAPTCHA_CODE_ERROR, response.getRepMsg()); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void resetPassword(AuthResetPasswordReqVO reqVO) { + AdminUserDO userByMobile = userService.getUserByMobile(reqVO.getMobile()); + if (userByMobile == null) { + throw exception(USER_MOBILE_NOT_EXISTS); + } + + smsCodeApi.useSmsCode(new SmsCodeUseReqDTO() + .setCode(reqVO.getCode()) + .setMobile(reqVO.getMobile()) + .setScene(SmsSceneEnum.ADMIN_MEMBER_RESET_PASSWORD.getScene()) + .setUsedIp(getClientIP()) + ).checkError(); + + userService.updateUserPassword(userByMobile.getId(), reqVO.getPassword()); + } +} From f792ee1678c2141d46f4fe3112940bf8dcf37519 Mon Sep 17 00:00:00 2001 From: lzh Date: Tue, 17 Mar 2026 18:04:37 +0800 Subject: [PATCH 30/35] =?UTF-8?q?refactor(system):=20=E7=A4=BE=E4=BA=A4?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=E5=88=97=E8=A1=A8=E9=80=BB=E8=BE=91=E4=B8=8B?= =?UTF-8?q?=E6=B2=89=E8=87=B3=20Service=20=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 SocialUserBindMapper 从 Controller 移除,数据组装逻辑移至 SocialUserService.getSocialUserBindList(),返回绑定时间字段; 修复 avatar 误用 getNickname() 的 bug Co-Authored-By: Claude Opus 4.6 --- .../admin/socail/SocialUserController.java | 161 ++++---- .../socail/vo/user/SocialUserRespVO.java | 96 ++--- .../service/social/SocialUserService.java | 179 ++++----- .../service/social/SocialUserServiceImpl.java | 362 +++++++++--------- 4 files changed, 405 insertions(+), 393 deletions(-) diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/socail/SocialUserController.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/socail/SocialUserController.java index ceeff21..6031188 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/socail/SocialUserController.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/socail/SocialUserController.java @@ -1,82 +1,79 @@ -package com.viewsh.module.system.controller.admin.socail; - -import com.viewsh.framework.common.enums.UserTypeEnum; -import com.viewsh.framework.common.pojo.CommonResult; -import com.viewsh.framework.common.pojo.PageResult; -import com.viewsh.framework.common.util.object.BeanUtils; -import com.viewsh.module.system.api.social.dto.SocialUserBindReqDTO; -import com.viewsh.module.system.controller.admin.socail.vo.user.SocialUserBindReqVO; -import com.viewsh.module.system.controller.admin.socail.vo.user.SocialUserPageReqVO; -import com.viewsh.module.system.controller.admin.socail.vo.user.SocialUserRespVO; -import com.viewsh.module.system.controller.admin.socail.vo.user.SocialUserUnbindReqVO; -import com.viewsh.module.system.dal.dataobject.social.SocialUserDO; -import com.viewsh.module.system.service.social.SocialUserService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.annotation.Resource; -import jakarta.validation.Valid; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -import static com.viewsh.framework.common.pojo.CommonResult.success; -import static com.viewsh.framework.common.util.collection.CollectionUtils.convertList; -import static com.viewsh.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; - -@Tag(name = "管理后台 - 社交用户") -@RestController -@RequestMapping("/system/social-user") -@Validated -public class SocialUserController { - - @Resource - private SocialUserService socialUserService; - - @PostMapping("/bind") - @Operation(summary = "社交绑定,使用 code 授权码") - public CommonResult socialBind(@RequestBody @Valid SocialUserBindReqVO reqVO) { - socialUserService.bindSocialUser(new SocialUserBindReqDTO().setSocialType(reqVO.getType()) - .setCode(reqVO.getCode()).setState(reqVO.getState()) - .setUserId(getLoginUserId()).setUserType(UserTypeEnum.ADMIN.getValue())); - return CommonResult.success(true); - } - - @DeleteMapping("/unbind") - @Operation(summary = "取消社交绑定") - public CommonResult socialUnbind(@RequestBody SocialUserUnbindReqVO reqVO) { - socialUserService.unbindSocialUser(getLoginUserId(), UserTypeEnum.ADMIN.getValue(), reqVO.getType(), reqVO.getOpenid()); - return CommonResult.success(true); - } - - @GetMapping("/get-bind-list") - @Operation(summary = "获得绑定社交用户列表") - public CommonResult> getBindSocialUserList() { - List list = socialUserService.getSocialUserList(getLoginUserId(), UserTypeEnum.ADMIN.getValue()); - return success(convertList(list, socialUser -> new SocialUserRespVO() // 返回精简信息 - .setId(socialUser.getId()).setType(socialUser.getType()).setOpenid(socialUser.getOpenid()) - .setNickname(socialUser.getNickname()).setAvatar(socialUser.getNickname()))); - } - - // ==================== 社交用户 CRUD ==================== - - @GetMapping("/get") - @Operation(summary = "获得社交用户") - @Parameter(name = "id", description = "编号", required = true, example = "1024") - @PreAuthorize("@ss.hasPermission('system:social-user:query')") - public CommonResult getSocialUser(@RequestParam("id") Long id) { - SocialUserDO socialUser = socialUserService.getSocialUser(id); - return success(BeanUtils.toBean(socialUser, SocialUserRespVO.class)); - } - - @GetMapping("/page") - @Operation(summary = "获得社交用户分页") - @PreAuthorize("@ss.hasPermission('system:social-user:query')") - public CommonResult> getSocialUserPage(@Valid SocialUserPageReqVO pageVO) { - PageResult pageResult = socialUserService.getSocialUserPage(pageVO); - return success(BeanUtils.toBean(pageResult, SocialUserRespVO.class)); - } - -} +package com.viewsh.module.system.controller.admin.socail; + +import com.viewsh.framework.common.enums.UserTypeEnum; +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.framework.common.pojo.PageResult; +import com.viewsh.framework.common.util.object.BeanUtils; +import com.viewsh.module.system.api.social.dto.SocialUserBindReqDTO; +import com.viewsh.module.system.controller.admin.socail.vo.user.SocialUserBindReqVO; +import com.viewsh.module.system.controller.admin.socail.vo.user.SocialUserPageReqVO; +import com.viewsh.module.system.controller.admin.socail.vo.user.SocialUserRespVO; +import com.viewsh.module.system.controller.admin.socail.vo.user.SocialUserUnbindReqVO; +import com.viewsh.module.system.dal.dataobject.social.SocialUserDO; +import com.viewsh.module.system.service.social.SocialUserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static com.viewsh.framework.common.pojo.CommonResult.success; +import static com.viewsh.framework.common.util.collection.CollectionUtils.convertList; +import static com.viewsh.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - 社交用户") +@RestController +@RequestMapping("/system/social-user") +@Validated +public class SocialUserController { + + @Resource + private SocialUserService socialUserService; + + @PostMapping("/bind") + @Operation(summary = "社交绑定,使用 code 授权码") + public CommonResult socialBind(@RequestBody @Valid SocialUserBindReqVO reqVO) { + socialUserService.bindSocialUser(new SocialUserBindReqDTO().setSocialType(reqVO.getType()) + .setCode(reqVO.getCode()).setState(reqVO.getState()) + .setUserId(getLoginUserId()).setUserType(UserTypeEnum.ADMIN.getValue())); + return CommonResult.success(true); + } + + @DeleteMapping("/unbind") + @Operation(summary = "取消社交绑定") + public CommonResult socialUnbind(@RequestBody SocialUserUnbindReqVO reqVO) { + socialUserService.unbindSocialUser(getLoginUserId(), UserTypeEnum.ADMIN.getValue(), reqVO.getType(), reqVO.getOpenid()); + return CommonResult.success(true); + } + + @GetMapping("/get-bind-list") + @Operation(summary = "获得绑定社交用户列表") + public CommonResult> getBindSocialUserList() { + return success(socialUserService.getSocialUserBindList(getLoginUserId(), UserTypeEnum.ADMIN.getValue())); + } + + // ==================== 社交用户 CRUD ==================== + + @GetMapping("/get") + @Operation(summary = "获得社交用户") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('system:social-user:query')") + public CommonResult getSocialUser(@RequestParam("id") Long id) { + SocialUserDO socialUser = socialUserService.getSocialUser(id); + return success(BeanUtils.toBean(socialUser, SocialUserRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获得社交用户分页") + @PreAuthorize("@ss.hasPermission('system:social-user:query')") + public CommonResult> getSocialUserPage(@Valid SocialUserPageReqVO pageVO) { + PageResult pageResult = socialUserService.getSocialUserPage(pageVO); + return success(BeanUtils.toBean(pageResult, SocialUserRespVO.class)); + } + +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/socail/vo/user/SocialUserRespVO.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/socail/vo/user/SocialUserRespVO.java index 820ad2d..4ebaa1c 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/socail/vo/user/SocialUserRespVO.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/socail/vo/user/SocialUserRespVO.java @@ -1,48 +1,48 @@ -package com.viewsh.module.system.controller.admin.socail.vo.user; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; - -import java.time.LocalDateTime; - -@Schema(description = "管理后台 - 社交用户 Response VO") -@Data -public class SocialUserRespVO { - - @Schema(description = "主键(自增策略)", requiredMode = Schema.RequiredMode.REQUIRED, example = "14569") - private Long id; - - @Schema(description = "社交平台的类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "30") - private Integer type; - - @Schema(description = "社交 openid", requiredMode = Schema.RequiredMode.REQUIRED, example = "666") - private String openid; - - @Schema(description = "社交 token", requiredMode = Schema.RequiredMode.REQUIRED, example = "666") - private String token; - - @Schema(description = "原始 Token 数据,一般是 JSON 格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "{}") - private String rawTokenInfo; - - @Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋艿") - private String nickname; - - @Schema(description = "用户头像", example = "https://www.iocoder.cn/xxx.png") - private String avatar; - - @Schema(description = "原始用户数据,一般是 JSON 格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "{}") - private String rawUserInfo; - - @Schema(description = "最后一次的认证 code", requiredMode = Schema.RequiredMode.REQUIRED, example = "666666") - private String code; - - @Schema(description = "最后一次的认证 state", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456") - private String state; - - @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) - private LocalDateTime createTime; - - @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) - private LocalDateTime updateTime; - -} +package com.viewsh.module.system.controller.admin.socail.vo.user; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 社交用户 Response VO") +@Data +public class SocialUserRespVO { + + @Schema(description = "主键(自增策略)", requiredMode = Schema.RequiredMode.REQUIRED, example = "14569") + private Long id; + + @Schema(description = "社交平台的类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "30") + private Integer type; + + @Schema(description = "社交 openid", requiredMode = Schema.RequiredMode.REQUIRED, example = "666") + private String openid; + + @Schema(description = "社交 token", requiredMode = Schema.RequiredMode.REQUIRED, example = "666") + private String token; + + @Schema(description = "原始 Token 数据,一般是 JSON 格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "{}") + private String rawTokenInfo; + + @Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋艿") + private String nickname; + + @Schema(description = "用户头像", example = "https://www.iocoder.cn/xxx.png") + private String avatar; + + @Schema(description = "原始用户数据,一般是 JSON 格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "{}") + private String rawUserInfo; + + @Schema(description = "最后一次的认证 code", requiredMode = Schema.RequiredMode.REQUIRED, example = "666666") + private String code; + + @Schema(description = "最后一次的认证 state", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456") + private String state; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updateTime; + +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/social/SocialUserService.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/social/SocialUserService.java index 63eee6f..d3a940a 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/social/SocialUserService.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/social/SocialUserService.java @@ -1,89 +1,90 @@ -package com.viewsh.module.system.service.social; - -import com.viewsh.framework.common.exception.ServiceException; -import com.viewsh.framework.common.pojo.PageResult; -import com.viewsh.module.system.api.social.dto.SocialUserBindReqDTO; -import com.viewsh.module.system.api.social.dto.SocialUserRespDTO; -import com.viewsh.module.system.controller.admin.socail.vo.user.SocialUserPageReqVO; -import com.viewsh.module.system.dal.dataobject.social.SocialUserDO; -import com.viewsh.module.system.enums.social.SocialTypeEnum; -import jakarta.validation.Valid; - -import java.util.List; - -/** - * 社交用户 Service 接口,例如说社交平台的授权登录 - * - * @author 芋道源码 - */ -public interface SocialUserService { - - /** - * 获得指定用户的社交用户列表 - * - * @param userId 用户编号 - * @param userType 用户类型 - * @return 社交用户列表 - */ - List getSocialUserList(Long userId, Integer userType); - - /** - * 绑定社交用户 - * - * @param reqDTO 绑定信息 - * @return 社交用户 openid - */ - String bindSocialUser(@Valid SocialUserBindReqDTO reqDTO); - - /** - * 取消绑定社交用户 - * - * @param userId 用户编号 - * @param userType 全局用户类型 - * @param socialType 社交平台的类型 {@link SocialTypeEnum} - * @param openid 社交平台的 openid - */ - void unbindSocialUser(Long userId, Integer userType, Integer socialType, String openid); - - /** - * 获得社交用户,基于 userId - * - * @param userType 用户类型 - * @param userId 用户编号 - * @param socialType 社交平台的类型 - * @return 社交用户 - */ - SocialUserRespDTO getSocialUserByUserId(Integer userType, Long userId, Integer socialType); - - /** - * 获得社交用户 - * - * 在认证信息不正确的情况下,也会抛出 {@link ServiceException} 业务异常 - * - * @param userType 用户类型 - * @param socialType 社交平台的类型 - * @param code 授权码 - * @param state state - * @return 社交用户 - */ - SocialUserRespDTO getSocialUserByCode(Integer userType, Integer socialType, String code, String state); - - // ==================== 社交用户 CRUD ==================== - - /** - * 获得社交用户 - * - * @param id 编号 - * @return 社交用户 - */ - SocialUserDO getSocialUser(Long id); - - /** - * 获得社交用户分页 - * - * @param pageReqVO 分页查询 - * @return 社交用户分页 - */ - PageResult getSocialUserPage(SocialUserPageReqVO pageReqVO); - -} +package com.viewsh.module.system.service.social; + +import com.viewsh.framework.common.exception.ServiceException; +import com.viewsh.framework.common.pojo.PageResult; +import com.viewsh.module.system.api.social.dto.SocialUserBindReqDTO; +import com.viewsh.module.system.api.social.dto.SocialUserRespDTO; +import com.viewsh.module.system.controller.admin.socail.vo.user.SocialUserPageReqVO; +import com.viewsh.module.system.controller.admin.socail.vo.user.SocialUserRespVO; +import com.viewsh.module.system.dal.dataobject.social.SocialUserDO; +import com.viewsh.module.system.enums.social.SocialTypeEnum; +import jakarta.validation.Valid; + +import java.util.List; + +/** + * 社交用户 Service 接口,例如说社交平台的授权登录 + * + * @author 芋道源码 + */ +public interface SocialUserService { + + /** + * 获得指定用户的社交用户列表(含绑定时间) + * + * @param userId 用户编号 + * @param userType 用户类型 + * @return 社交用户列表 + */ + List getSocialUserBindList(Long userId, Integer userType); + + /** + * 绑定社交用户 + * + * @param reqDTO 绑定信息 + * @return 社交用户 openid + */ + String bindSocialUser(@Valid SocialUserBindReqDTO reqDTO); + + /** + * 取消绑定社交用户 + * + * @param userId 用户编号 + * @param userType 全局用户类型 + * @param socialType 社交平台的类型 {@link SocialTypeEnum} + * @param openid 社交平台的 openid + */ + void unbindSocialUser(Long userId, Integer userType, Integer socialType, String openid); + + /** + * 获得社交用户,基于 userId + * + * @param userType 用户类型 + * @param userId 用户编号 + * @param socialType 社交平台的类型 + * @return 社交用户 + */ + SocialUserRespDTO getSocialUserByUserId(Integer userType, Long userId, Integer socialType); + + /** + * 获得社交用户 + * + * 在认证信息不正确的情况下,也会抛出 {@link ServiceException} 业务异常 + * + * @param userType 用户类型 + * @param socialType 社交平台的类型 + * @param code 授权码 + * @param state state + * @return 社交用户 + */ + SocialUserRespDTO getSocialUserByCode(Integer userType, Integer socialType, String code, String state); + + // ==================== 社交用户 CRUD ==================== + + /** + * 获得社交用户 + * + * @param id 编号 + * @return 社交用户 + */ + SocialUserDO getSocialUser(Long id); + + /** + * 获得社交用户分页 + * + * @param pageReqVO 分页查询 + * @return 社交用户分页 + */ + PageResult getSocialUserPage(SocialUserPageReqVO pageReqVO); + +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/social/SocialUserServiceImpl.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/social/SocialUserServiceImpl.java index 3fe66cc..af7c5e0 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/social/SocialUserServiceImpl.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/social/SocialUserServiceImpl.java @@ -1,174 +1,188 @@ -package com.viewsh.module.system.service.social; - -import cn.hutool.core.collection.CollUtil; -import cn.hutool.core.lang.Assert; -import com.viewsh.framework.common.exception.ServiceException; -import com.viewsh.framework.common.pojo.PageResult; -import com.viewsh.module.system.api.social.dto.SocialUserBindReqDTO; -import com.viewsh.module.system.api.social.dto.SocialUserRespDTO; -import com.viewsh.module.system.controller.admin.socail.vo.user.SocialUserPageReqVO; -import com.viewsh.module.system.dal.dataobject.social.SocialUserBindDO; -import com.viewsh.module.system.dal.dataobject.social.SocialUserDO; -import com.viewsh.module.system.dal.mysql.social.SocialUserBindMapper; -import com.viewsh.module.system.dal.mysql.social.SocialUserMapper; -import com.viewsh.module.system.enums.social.SocialTypeEnum; -import jakarta.annotation.Resource; -import jakarta.validation.constraints.NotNull; -import lombok.extern.slf4j.Slf4j; -import me.zhyd.oauth.model.AuthUser; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.validation.annotation.Validated; - -import java.util.Collections; -import java.util.List; - -import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception; -import static com.viewsh.framework.common.util.collection.CollectionUtils.convertSet; -import static com.viewsh.framework.common.util.json.JsonUtils.toJsonString; -import static com.viewsh.module.system.enums.ErrorCodeConstants.SOCIAL_USER_NOT_FOUND; - -/** - * 社交用户 Service 实现类 - * - * @author 芋道源码 - */ -@Service -@Validated -@Slf4j -public class SocialUserServiceImpl implements SocialUserService { - - @Resource - private SocialUserBindMapper socialUserBindMapper; - @Resource - private SocialUserMapper socialUserMapper; - - @Resource - private SocialClientService socialClientService; - - @Override - public List getSocialUserList(Long userId, Integer userType) { - // 获得绑定 - List socialUserBinds = socialUserBindMapper.selectListByUserIdAndUserType(userId, userType); - if (CollUtil.isEmpty(socialUserBinds)) { - return Collections.emptyList(); - } - // 获得社交用户 - return socialUserMapper.selectByIds(convertSet(socialUserBinds, SocialUserBindDO::getSocialUserId)); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public String bindSocialUser(SocialUserBindReqDTO reqDTO) { - // 获得社交用户 - SocialUserDO socialUser = authSocialUser(reqDTO.getSocialType(), reqDTO.getUserType(), - reqDTO.getCode(), reqDTO.getState()); - Assert.notNull(socialUser, "社交用户不能为空"); - - // 社交用户可能之前绑定过别的用户,需要进行解绑 - socialUserBindMapper.deleteByUserTypeAndSocialUserId(reqDTO.getUserType(), socialUser.getId()); - - // 用户可能之前已经绑定过该社交类型,需要进行解绑 - socialUserBindMapper.deleteByUserTypeAndUserIdAndSocialType(reqDTO.getUserType(), reqDTO.getUserId(), - socialUser.getType()); - - // 绑定当前登录的社交用户 - SocialUserBindDO socialUserBind = SocialUserBindDO.builder() - .userId(reqDTO.getUserId()).userType(reqDTO.getUserType()) - .socialUserId(socialUser.getId()).socialType(socialUser.getType()).build(); - socialUserBindMapper.insert(socialUserBind); - return socialUser.getOpenid(); - } - - @Override - public void unbindSocialUser(Long userId, Integer userType, Integer socialType, String openid) { - // 获得 openid 对应的 SocialUserDO 社交用户 - SocialUserDO socialUser = socialUserMapper.selectByTypeAndOpenid(socialType, openid); - if (socialUser == null) { - throw exception(SOCIAL_USER_NOT_FOUND); - } - - // 获得对应的社交绑定关系 - socialUserBindMapper.deleteByUserTypeAndUserIdAndSocialType(userType, userId, socialUser.getType()); - } - - @Override - public SocialUserRespDTO getSocialUserByUserId(Integer userType, Long userId, Integer socialType) { - // 获得绑定用户 - SocialUserBindDO socialUserBind = socialUserBindMapper.selectByUserIdAndUserTypeAndSocialType(userId, userType, socialType); - if (socialUserBind == null) { - return null; - } - // 获得社交用户 - SocialUserDO socialUser = socialUserMapper.selectById(socialUserBind.getSocialUserId()); - Assert.notNull(socialUser, "社交用户不能为空"); - return new SocialUserRespDTO(socialUser.getOpenid(), socialUser.getNickname(), socialUser.getAvatar(), - socialUserBind.getUserId()); - } - - @Override - public SocialUserRespDTO getSocialUserByCode(Integer userType, Integer socialType, String code, String state) { - // 获得社交用户 - SocialUserDO socialUser = authSocialUser(socialType, userType, code, state); - Assert.notNull(socialUser, "社交用户不能为空"); - - // 获得绑定用户 - SocialUserBindDO socialUserBind = socialUserBindMapper.selectByUserTypeAndSocialUserId(userType, - socialUser.getId()); - return new SocialUserRespDTO(socialUser.getOpenid(), socialUser.getNickname(), socialUser.getAvatar(), - socialUserBind != null ? socialUserBind.getUserId() : null); - } - - /** - * 授权获得对应的社交用户 - * 如果授权失败,则会抛出 {@link ServiceException} 异常 - * - * @param socialType 社交平台的类型 {@link SocialTypeEnum} - * @param userType 用户类型 - * @param code 授权码 - * @param state state - * @return 授权用户 - */ - @NotNull - public SocialUserDO authSocialUser(Integer socialType, Integer userType, String code, String state) { - // 优先从 DB 中获取,因为 code 有且可以使用一次。 - // 在社交登录时,当未绑定 User 时,需要绑定登录,此时需要 code 使用两次 - SocialUserDO socialUser = socialUserMapper.selectByTypeAndCodeAnState(socialType, code, state); - if (socialUser != null) { - return socialUser; - } - - // 请求获取 - AuthUser authUser = socialClientService.getAuthUser(socialType, userType, code, state); - Assert.notNull(authUser, "三方用户不能为空"); - - // 保存到 DB 中 - socialUser = socialUserMapper.selectByTypeAndOpenid(socialType, authUser.getUuid()); - if (socialUser == null) { - socialUser = new SocialUserDO(); - } - socialUser.setType(socialType).setCode(code).setState(state) // 需要保存 code + state 字段,保证后续可查询 - .setOpenid(authUser.getUuid()).setToken(authUser.getToken().getAccessToken()).setRawTokenInfo((toJsonString(authUser.getToken()))) - .setNickname(authUser.getNickname()).setAvatar(authUser.getAvatar()).setRawUserInfo(toJsonString(authUser.getRawUserInfo())); - if (socialUser.getId() == null) { - socialUserMapper.insert(socialUser); - } else { - socialUser.clean(); // 避免 updateTime 不更新:https://gitee.com/viewshcode/viewsh-boot-mini/issues/ID7FUL - socialUserMapper.updateById(socialUser); - } - return socialUser; - } - - // ==================== 社交用户 CRUD ==================== - - @Override - public SocialUserDO getSocialUser(Long id) { - return socialUserMapper.selectById(id); - } - - @Override - public PageResult getSocialUserPage(SocialUserPageReqVO pageReqVO) { - return socialUserMapper.selectPage(pageReqVO); - } - -} +package com.viewsh.module.system.service.social; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Assert; +import com.viewsh.framework.common.exception.ServiceException; +import com.viewsh.framework.common.pojo.PageResult; +import com.viewsh.module.system.api.social.dto.SocialUserBindReqDTO; +import com.viewsh.module.system.api.social.dto.SocialUserRespDTO; +import com.viewsh.module.system.controller.admin.socail.vo.user.SocialUserPageReqVO; +import com.viewsh.module.system.controller.admin.socail.vo.user.SocialUserRespVO; +import com.viewsh.module.system.dal.dataobject.social.SocialUserBindDO; +import com.viewsh.module.system.dal.dataobject.social.SocialUserDO; +import com.viewsh.module.system.dal.mysql.social.SocialUserBindMapper; +import com.viewsh.module.system.dal.mysql.social.SocialUserMapper; +import com.viewsh.module.system.enums.social.SocialTypeEnum; +import jakarta.annotation.Resource; +import jakarta.validation.constraints.NotNull; +import lombok.extern.slf4j.Slf4j; +import me.zhyd.oauth.model.AuthUser; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception; +import static com.viewsh.framework.common.util.collection.CollectionUtils.convertList; +import static com.viewsh.framework.common.util.collection.CollectionUtils.convertMap; +import static com.viewsh.framework.common.util.collection.CollectionUtils.convertSet; +import static com.viewsh.framework.common.util.json.JsonUtils.toJsonString; +import static com.viewsh.module.system.enums.ErrorCodeConstants.SOCIAL_USER_NOT_FOUND; + +/** + * 社交用户 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +@Slf4j +public class SocialUserServiceImpl implements SocialUserService { + + @Resource + private SocialUserBindMapper socialUserBindMapper; + @Resource + private SocialUserMapper socialUserMapper; + + @Resource + private SocialClientService socialClientService; + + @Override + public List getSocialUserBindList(Long userId, Integer userType) { + // 获得绑定关系 + List socialUserBinds = socialUserBindMapper.selectListByUserIdAndUserType(userId, userType); + if (CollUtil.isEmpty(socialUserBinds)) { + return Collections.emptyList(); + } + // 获得社交用户 + List socialUsers = socialUserMapper.selectByIds(convertSet(socialUserBinds, SocialUserBindDO::getSocialUserId)); + Map bindMap = convertMap(socialUserBinds, SocialUserBindDO::getSocialUserId); + // 组装返回 + return convertList(socialUsers, socialUser -> { + SocialUserBindDO bind = bindMap.get(socialUser.getId()); + return new SocialUserRespVO() + .setId(socialUser.getId()).setType(socialUser.getType()).setOpenid(socialUser.getOpenid()) + .setNickname(socialUser.getNickname()).setAvatar(socialUser.getAvatar()) + .setCreateTime(bind != null ? bind.getCreateTime() : null) + .setUpdateTime(bind != null ? bind.getUpdateTime() : null); + }); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public String bindSocialUser(SocialUserBindReqDTO reqDTO) { + // 获得社交用户 + SocialUserDO socialUser = authSocialUser(reqDTO.getSocialType(), reqDTO.getUserType(), + reqDTO.getCode(), reqDTO.getState()); + Assert.notNull(socialUser, "社交用户不能为空"); + + // 社交用户可能之前绑定过别的用户,需要进行解绑 + socialUserBindMapper.deleteByUserTypeAndSocialUserId(reqDTO.getUserType(), socialUser.getId()); + + // 用户可能之前已经绑定过该社交类型,需要进行解绑 + socialUserBindMapper.deleteByUserTypeAndUserIdAndSocialType(reqDTO.getUserType(), reqDTO.getUserId(), + socialUser.getType()); + + // 绑定当前登录的社交用户 + SocialUserBindDO socialUserBind = SocialUserBindDO.builder() + .userId(reqDTO.getUserId()).userType(reqDTO.getUserType()) + .socialUserId(socialUser.getId()).socialType(socialUser.getType()).build(); + socialUserBindMapper.insert(socialUserBind); + return socialUser.getOpenid(); + } + + @Override + public void unbindSocialUser(Long userId, Integer userType, Integer socialType, String openid) { + // 获得 openid 对应的 SocialUserDO 社交用户 + SocialUserDO socialUser = socialUserMapper.selectByTypeAndOpenid(socialType, openid); + if (socialUser == null) { + throw exception(SOCIAL_USER_NOT_FOUND); + } + + // 获得对应的社交绑定关系 + socialUserBindMapper.deleteByUserTypeAndUserIdAndSocialType(userType, userId, socialUser.getType()); + } + + @Override + public SocialUserRespDTO getSocialUserByUserId(Integer userType, Long userId, Integer socialType) { + // 获得绑定用户 + SocialUserBindDO socialUserBind = socialUserBindMapper.selectByUserIdAndUserTypeAndSocialType(userId, userType, socialType); + if (socialUserBind == null) { + return null; + } + // 获得社交用户 + SocialUserDO socialUser = socialUserMapper.selectById(socialUserBind.getSocialUserId()); + Assert.notNull(socialUser, "社交用户不能为空"); + return new SocialUserRespDTO(socialUser.getOpenid(), socialUser.getNickname(), socialUser.getAvatar(), + socialUserBind.getUserId()); + } + + @Override + public SocialUserRespDTO getSocialUserByCode(Integer userType, Integer socialType, String code, String state) { + // 获得社交用户 + SocialUserDO socialUser = authSocialUser(socialType, userType, code, state); + Assert.notNull(socialUser, "社交用户不能为空"); + + // 获得绑定用户 + SocialUserBindDO socialUserBind = socialUserBindMapper.selectByUserTypeAndSocialUserId(userType, + socialUser.getId()); + return new SocialUserRespDTO(socialUser.getOpenid(), socialUser.getNickname(), socialUser.getAvatar(), + socialUserBind != null ? socialUserBind.getUserId() : null); + } + + /** + * 授权获得对应的社交用户 + * 如果授权失败,则会抛出 {@link ServiceException} 异常 + * + * @param socialType 社交平台的类型 {@link SocialTypeEnum} + * @param userType 用户类型 + * @param code 授权码 + * @param state state + * @return 授权用户 + */ + @NotNull + public SocialUserDO authSocialUser(Integer socialType, Integer userType, String code, String state) { + // 优先从 DB 中获取,因为 code 有且可以使用一次。 + // 在社交登录时,当未绑定 User 时,需要绑定登录,此时需要 code 使用两次 + SocialUserDO socialUser = socialUserMapper.selectByTypeAndCodeAnState(socialType, code, state); + if (socialUser != null) { + return socialUser; + } + + // 请求获取 + AuthUser authUser = socialClientService.getAuthUser(socialType, userType, code, state); + Assert.notNull(authUser, "三方用户不能为空"); + + // 保存到 DB 中 + socialUser = socialUserMapper.selectByTypeAndOpenid(socialType, authUser.getUuid()); + if (socialUser == null) { + socialUser = new SocialUserDO(); + } + socialUser.setType(socialType).setCode(code).setState(state) // 需要保存 code + state 字段,保证后续可查询 + .setOpenid(authUser.getUuid()).setToken(authUser.getToken().getAccessToken()).setRawTokenInfo((toJsonString(authUser.getToken()))) + .setNickname(authUser.getNickname()).setAvatar(authUser.getAvatar()).setRawUserInfo(toJsonString(authUser.getRawUserInfo())); + if (socialUser.getId() == null) { + socialUserMapper.insert(socialUser); + } else { + socialUser.clean(); // 避免 updateTime 不更新:https://gitee.com/viewshcode/viewsh-boot-mini/issues/ID7FUL + socialUserMapper.updateById(socialUser); + } + return socialUser; + } + + // ==================== 社交用户 CRUD ==================== + + @Override + public SocialUserDO getSocialUser(Long id) { + return socialUserMapper.selectById(id); + } + + @Override + public PageResult getSocialUserPage(SocialUserPageReqVO pageReqVO) { + return socialUserMapper.selectPage(pageReqVO); + } + +} From f3299bd655849d45577a2f9e8177e50829723a1f Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 18 Mar 2026 15:05:42 +0800 Subject: [PATCH 31/35] =?UTF-8?q?feat(framework):=20=E6=96=B0=E5=A2=9E=20@?= =?UTF-8?q?OssPresignUrl=20=E6=B3=A8=E8=A7=A3=E4=B8=8E=20ResponseBodyAdvic?= =?UTF-8?q?e=20=E8=87=AA=E5=8A=A8=E9=A2=84=E7=AD=BE=E5=90=8D=E6=A1=86?= =?UTF-8?q?=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于 ResponseBodyAdvice 拦截 CommonResult 响应体,通过反射递归扫描 VO 中标注 @OssPresignUrl 的 String 字段,去重后批量调用 OssPresignUrlApi 一次性完成预签名,再回填到对应字段。 核心设计: - supports() 阶段通过泛型静态分析判断 VO 是否含注解字段, 无注解的接口零开销跳过(类似字典翻译注解思路) - 三级缓存:FIELD_CACHE / ALL_FIELDS_CACHE / HAS_PRESIGN_CACHE - 递归深度限制 MAX_SCAN_DEPTH=10 防止 StackOverflow - 仅扫描 com.viewsh.* 包,规避 Java 17 模块系统限制 - 异常静默降级,保留原始 URL Co-Authored-By: Claude Opus 4.6 (1M context) --- .../biz/infra/file/OssPresignUrlApi.java | 20 + .../config/ViewshWebAutoConfiguration.java | 11 + .../presign/annotation/OssPresignUrl.java | 17 + .../core/OssPresignResponseBodyAdvice.java | 345 ++++++++++++++++++ 4 files changed, 393 insertions(+) create mode 100644 viewsh-framework/viewsh-common/src/main/java/com/viewsh/framework/common/biz/infra/file/OssPresignUrlApi.java create mode 100644 viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/presign/annotation/OssPresignUrl.java create mode 100644 viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/presign/core/OssPresignResponseBodyAdvice.java diff --git a/viewsh-framework/viewsh-common/src/main/java/com/viewsh/framework/common/biz/infra/file/OssPresignUrlApi.java b/viewsh-framework/viewsh-common/src/main/java/com/viewsh/framework/common/biz/infra/file/OssPresignUrlApi.java new file mode 100644 index 0000000..b6c0f10 --- /dev/null +++ b/viewsh-framework/viewsh-common/src/main/java/com/viewsh/framework/common/biz/infra/file/OssPresignUrlApi.java @@ -0,0 +1,20 @@ +package com.viewsh.framework.common.biz.infra.file; + +import java.util.List; + +/** + * OSS 预签名 URL 通用接口 + *

    + * 由 infra 模块提供实现,供 {@code OssPresignResponseBodyAdvice} 等框架组件使用 + */ +public interface OssPresignUrlApi { + + /** + * 批量生成文件预签名地址 + * + * @param urls 原始 URL 列表 + * @return 签名后的 URL 列表(与入参顺序一致) + */ + List presignGetUrls(List urls); + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/config/ViewshWebAutoConfiguration.java b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/config/ViewshWebAutoConfiguration.java index 4ccbf08..3b36aaf 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/config/ViewshWebAutoConfiguration.java +++ b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/config/ViewshWebAutoConfiguration.java @@ -1,12 +1,14 @@ package com.viewsh.framework.web.config; import cn.hutool.core.util.StrUtil; +import com.viewsh.framework.common.biz.infra.file.OssPresignUrlApi; import com.viewsh.framework.common.biz.infra.logger.ApiErrorLogCommonApi; import com.viewsh.framework.common.enums.WebFilterOrderEnum; import com.viewsh.framework.web.core.filter.CacheRequestBodyFilter; import com.viewsh.framework.web.core.filter.DemoFilter; import com.viewsh.framework.web.core.handler.GlobalExceptionHandler; import com.viewsh.framework.web.core.handler.GlobalResponseBodyHandler; +import com.viewsh.framework.web.core.presign.core.OssPresignResponseBodyAdvice; import com.viewsh.framework.web.core.util.WebFrameworkUtils; import com.google.common.collect.Maps; import jakarta.servlet.Filter; @@ -17,6 +19,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration; import org.springframework.boot.autoconfigure.web.servlet.WebMvcRegistrations; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.cloud.client.loadbalancer.LoadBalanced; @@ -34,6 +37,8 @@ import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandl import java.util.Map; import java.util.function.Predicate; +// ⚠ 此类被 OssPresignUrlApiAutoConfiguration 通过 beforeName 字符串引用, +// 重命名/移动时须同步修改。搜索关键字:PRESIGN_AUTO_CONFIG_ORDERING @AutoConfiguration(beforeName = { "com.fhs.trans.config.TransServiceConfig" // cloud 独有:避免一键改包后,RestTemplate 初始化的冲突。可见 https://t.zsxq.com/T4yj7 帖子 }) @@ -96,6 +101,12 @@ public class ViewshWebAutoConfiguration { return new GlobalResponseBodyHandler(); } + @Bean + @ConditionalOnBean(OssPresignUrlApi.class) + public OssPresignResponseBodyAdvice ossPresignResponseBodyAdvice(OssPresignUrlApi ossPresignUrlApi) { + return new OssPresignResponseBodyAdvice(ossPresignUrlApi); + } + @Bean @SuppressWarnings("InstantiationOfUtilityClass") public WebFrameworkUtils webFrameworkUtils(WebProperties webProperties) { diff --git a/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/presign/annotation/OssPresignUrl.java b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/presign/annotation/OssPresignUrl.java new file mode 100644 index 0000000..08efbb6 --- /dev/null +++ b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/presign/annotation/OssPresignUrl.java @@ -0,0 +1,17 @@ +package com.viewsh.framework.web.core.presign.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 标记需要自动预签名的 OSS URL 字段 + *

    + * 使用方式:在 VO 的 String 类型字段上添加此注解, + * {@code OssPresignResponseBodyAdvice} 会在响应写出前批量替换为签名后的 URL。 + */ +@Target({ElementType.FIELD, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface OssPresignUrl { +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/presign/core/OssPresignResponseBodyAdvice.java b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/presign/core/OssPresignResponseBodyAdvice.java new file mode 100644 index 0000000..4d94d95 --- /dev/null +++ b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/presign/core/OssPresignResponseBodyAdvice.java @@ -0,0 +1,345 @@ +package com.viewsh.framework.web.core.presign.core; + +import cn.hutool.core.util.StrUtil; +import com.viewsh.framework.common.biz.infra.file.OssPresignUrlApi; +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.framework.common.pojo.PageResult; +import com.viewsh.framework.web.core.presign.annotation.OssPresignUrl; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 自动对 {@link CommonResult} 响应体中带 {@link OssPresignUrl} 注解的字段进行批量预签名 + *

    + * 流程: + *

      + *
    1. 反射递归扫描返回值中的 {@code @OssPresignUrl} 字段,收集原始 URL
    2. + *
    3. 去重后调用 {@link OssPresignUrlApi#presignGetUrls} — 1 次批量 RPC
    4. + *
    5. 将签名结果回填到对应字段
    6. + *
    + *

    + * 只扫描项目自身包({@code com.viewsh.})下的类,避免反射 JDK / 第三方库内部类导致 + * Java 17 模块系统 {@link InaccessibleObjectException}。 + */ +@Slf4j +@ControllerAdvice +public class OssPresignResponseBodyAdvice implements ResponseBodyAdvice { + + /** 项目包前缀,只有此前缀下的类才会被反射扫描 */ + private static final String BASE_PACKAGE = "com.viewsh."; + + /** 递归扫描最大深度,防止深层嵌套导致 StackOverflow */ + private static final int MAX_SCAN_DEPTH = 10; + + private final OssPresignUrlApi ossPresignUrlApi; + + /** 类 -> 带 @OssPresignUrl 注解的字段集合 缓存 */ + private static final Map, Set> FIELD_CACHE = new ConcurrentHashMap<>(); + + /** 类 -> 所有字段(含父类)缓存 */ + private static final Map, List> ALL_FIELDS_CACHE = new ConcurrentHashMap<>(); + + /** 空集合标记,避免对无注解字段的类反复扫描 */ + private static final Set EMPTY_FIELDS = Collections.emptySet(); + + /** 类 -> 是否包含(直接或嵌套)@OssPresignUrl 字段的缓存,用于 supports() 快速判断 */ + private static final Map, Boolean> HAS_PRESIGN_CACHE = new ConcurrentHashMap<>(); + + public OssPresignResponseBodyAdvice(OssPresignUrlApi ossPresignUrlApi) { + this.ossPresignUrlApi = ossPresignUrlApi; + } + + @Override + public boolean supports(MethodParameter returnType, Class> converterType) { + // 1. 必须是 CommonResult 返回类型 + if (!CommonResult.class.isAssignableFrom(returnType.getParameterType())) { + return false; + } + // 2. 通过泛型参数静态分析 VO 类是否包含 @OssPresignUrl 字段,无注解的接口直接跳过 + Class dataClass = resolveDataClass(returnType); + if (dataClass == null || !isProjectClass(dataClass)) { + // 无法解析泛型或非项目类,保守放行,交给 beforeBodyWrite 做运行时判断 + return true; + } + return hasPresignFields(dataClass, new HashSet<>(), 0); + } + + @Override + public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, + Class> selectedConverterType, + ServerHttpRequest request, ServerHttpResponse response) { + if (!(body instanceof CommonResult commonResult) || commonResult.getData() == null) { + return body; + } + try { + processData(commonResult.getData()); + } catch (Exception e) { + log.warn("[OssPresignResponseBodyAdvice] 预签名处理异常,保留原始 URL", e); + } + return body; + } + + // ===== 核心处理逻辑 ===== + + private void processData(Object data) { + // 1. 收集所有需要签名的 (对象, 字段, 原始URL) 三元组 + List entries = new ArrayList<>(); + collectUrls(data, entries, new IdentityHashMap<>(), 0); + if (entries.isEmpty()) { + return; + } + + // 2. 去重后批量签名 + LinkedHashSet uniqueUrls = new LinkedHashSet<>(); + for (FieldEntry entry : entries) { + uniqueUrls.add(entry.url); + } + List urlList = new ArrayList<>(uniqueUrls); + List signedList; + try { + signedList = ossPresignUrlApi.presignGetUrls(urlList); + } catch (Exception e) { + log.warn("[OssPresignResponseBodyAdvice] 批量签名 RPC 失败,保留原始 URL", e); + return; + } + + // 3. 构建映射并回填 + if (signedList.size() != urlList.size()) { + log.warn("[OssPresignResponseBodyAdvice] 签名结果数量({})与请求数量({})不一致,保留原始 URL", + signedList.size(), urlList.size()); + return; + } + Map signedMap = new HashMap<>(urlList.size()); + for (int i = 0; i < urlList.size(); i++) { + signedMap.put(urlList.get(i), signedList.get(i)); + } + for (FieldEntry entry : entries) { + String signed = signedMap.get(entry.url); + if (signed != null) { + try { + entry.field.set(entry.target, signed); + } catch (IllegalAccessException e) { + log.warn("[OssPresignResponseBodyAdvice] 回填签名 URL 失败: field={}", entry.field.getName(), e); + } + } + } + } + + /** + * 递归收集带 @OssPresignUrl 注解的字段值 + * + * @param obj 当前扫描的对象 + * @param entries 收集结果 + * @param visited 已访问对象(防止循环引用) + * @param depth 当前递归深度 + */ + private void collectUrls(Object obj, List entries, IdentityHashMap visited, int depth) { + if (obj == null || visited.containsKey(obj)) { + return; + } + if (depth > MAX_SCAN_DEPTH) { + log.debug("[OssPresignResponseBodyAdvice] 递归深度超过 {},停止扫描: class={}", MAX_SCAN_DEPTH, obj.getClass().getName()); + return; + } + visited.put(obj, Boolean.TRUE); + + // PageResult:遍历 list + if (obj instanceof PageResult pageResult) { + if (pageResult.getList() != null) { + for (Object item : pageResult.getList()) { + collectUrls(item, entries, visited, depth + 1); + } + } + return; + } + + // Collection:遍历元素 + if (obj instanceof Collection collection) { + for (Object item : collection) { + collectUrls(item, entries, visited, depth + 1); + } + return; + } + + // 只扫描项目自身包下的类 + Class clazz = obj.getClass(); + if (!isProjectClass(clazz)) { + return; + } + + // 获取带注解的字段 + Set annotatedFields = getAnnotatedFields(clazz); + + // 扫描所有字段 + for (Field field : getAllFields(clazz)) { + try { + Object value = field.get(obj); + if (value == null) { + continue; + } + if (annotatedFields.contains(field)) { + // 带注解的 String 字段:收集 URL + if (value instanceof String url && StrUtil.isNotEmpty(url)) { + entries.add(new FieldEntry(obj, field, url)); + } + } else { + // 非简单类型字段:递归(递归入口会再次判断 isProjectClass) + collectUrls(value, entries, visited, depth + 1); + } + } catch (IllegalAccessException e) { + log.debug("[OssPresignResponseBodyAdvice] 字段不可访问: class={}, field={}", clazz.getSimpleName(), field.getName()); + } + } + } + + // ===== 反射工具方法 ===== + + /** + * 判断是否为项目自身的类,只有项目类才需要反射扫描 @OssPresignUrl + */ + private boolean isProjectClass(Class clazz) { + return clazz.getName().startsWith(BASE_PACKAGE); + } + + /** + * 获取类中带 @OssPresignUrl 注解的字段集合(带缓存) + */ + private Set getAnnotatedFields(Class clazz) { + return FIELD_CACHE.computeIfAbsent(clazz, c -> { + Set result = new HashSet<>(); + for (Field field : getAllFields(c)) { + if (field.isAnnotationPresent(OssPresignUrl.class) && field.getType() == String.class) { + result.add(field); + } + } + return result.isEmpty() ? EMPTY_FIELDS : result; + }); + } + + /** + * 获取类的所有字段(含父类,止于非项目类),带缓存 + */ + private List getAllFields(Class clazz) { + return ALL_FIELDS_CACHE.computeIfAbsent(clazz, c -> { + List fields = new ArrayList<>(); + Class current = c; + while (current != null && isProjectClass(current)) { + for (Field field : current.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers())) { + continue; + } + field.setAccessible(true); + fields.add(field); + } + current = current.getSuperclass(); + } + return fields; + }); + } + + // ===== supports() 静态类型分析 ===== + + /** + * 从 CommonResult 的泛型参数中解析出 T 的实际 Class + *

    + * 支持:CommonResult<UserRespVO>、CommonResult<PageResult<UserRespVO>>、 + * CommonResult<List<UserRespVO>> + */ + private Class resolveDataClass(MethodParameter returnType) { + Type genericType = returnType.getGenericParameterType(); + // CommonResult + Class dataClass = extractFirstTypeArg(genericType); + if (dataClass == null) { + return null; + } + // 如果是 PageResult / Collection,继续剥一层 + if (PageResult.class.isAssignableFrom(dataClass) || Collection.class.isAssignableFrom(dataClass)) { + Type inner = genericType instanceof ParameterizedType pt ? pt.getActualTypeArguments()[0] : null; + Class innerClass = extractFirstTypeArg(inner); + return innerClass != null ? innerClass : dataClass; + } + return dataClass; + } + + /** + * 提取 ParameterizedType 的第一个类型参数的原始类 + */ + private Class extractFirstTypeArg(Type type) { + if (type instanceof ParameterizedType pt) { + Type[] args = pt.getActualTypeArguments(); + if (args.length > 0) { + Type arg = args[0]; + if (arg instanceof Class clazz) { + return clazz; + } + if (arg instanceof ParameterizedType argPt && argPt.getRawType() instanceof Class rawClass) { + return rawClass; + } + } + } + return null; + } + + /** + * 递归检查类(及其嵌套项目类字段)是否包含 @OssPresignUrl 注解(带缓存) + */ + private boolean hasPresignFields(Class clazz, Set> visiting, int depth) { + if (clazz == null || !isProjectClass(clazz) || depth > MAX_SCAN_DEPTH) { + return false; + } + // 缓存命中 + Boolean cached = HAS_PRESIGN_CACHE.get(clazz); + if (cached != null) { + return cached; + } + // 防止循环引用导致无限递归 + if (!visiting.add(clazz)) { + return false; + } + try { + // 直接有注解字段 + if (getAnnotatedFields(clazz) != EMPTY_FIELDS) { + HAS_PRESIGN_CACHE.put(clazz, true); + return true; + } + // 递归检查嵌套的项目类字段 + for (Field field : getAllFields(clazz)) { + Class fieldType = field.getType(); + if (isProjectClass(fieldType) && hasPresignFields(fieldType, visiting, depth + 1)) { + HAS_PRESIGN_CACHE.put(clazz, true); + return true; + } + // Collection 类型的字段,检查泛型参数 + if (Collection.class.isAssignableFrom(fieldType)) { + Type genericFieldType = field.getGenericType(); + Class elementClass = extractFirstTypeArg(genericFieldType); + if (elementClass != null && isProjectClass(elementClass) + && hasPresignFields(elementClass, visiting, depth + 1)) { + HAS_PRESIGN_CACHE.put(clazz, true); + return true; + } + } + } + HAS_PRESIGN_CACHE.put(clazz, false); + return false; + } finally { + visiting.remove(clazz); + } + } + + private record FieldEntry(Object target, Field field, String url) {} + +} From 807d44e398e6972fc37a2df1af332df07b657ba3 Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 18 Mar 2026 15:06:05 +0800 Subject: [PATCH 32/35] =?UTF-8?q?feat(infra):=20=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E9=A2=84=E7=AD=BE=E5=90=8D=20API=20=E5=8F=8A=E5=8D=95=E4=BD=93?= =?UTF-8?q?/=E5=BE=AE=E6=9C=8D=E5=8A=A1=E5=8F=8C=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 FileApi.presignGetUrls 批量签名接口(@NotEmpty + @Size(max=500)), FileServiceImpl 实现带 null 守卫。 自动配置设计: - 单体模式:ViewshFileAutoConfiguration 直连 FileService - 微服务模式:OssPresignUrlApiAutoConfiguration 通过 Feign 代理 - 通过 @ConditionalOnMissingBean 互斥,保证同一 JVM 只有一个实现 新增 OssPresignHelper 工具类,供 Handler 层处理动态 Map 字段 (如 extInfo 中的图片 URL),提供静默降级的单个/批量/JSON数组签名方法。 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../viewsh/module/infra/api/file/FileApi.java | 15 + .../infra/api/file/OssPresignHelper.java | 80 ++++ .../OssPresignUrlApiAutoConfiguration.java | 31 ++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../module/infra/api/file/FileApiImpl.java | 68 +-- .../config/ViewshFileAutoConfiguration.java | 49 +- .../infra/service/file/FileService.java | 187 ++++---- .../infra/service/file/FileServiceImpl.java | 424 +++++++++--------- 8 files changed, 508 insertions(+), 347 deletions(-) create mode 100644 viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/OssPresignHelper.java create mode 100644 viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/config/OssPresignUrlApiAutoConfiguration.java create mode 100644 viewsh-module-infra/viewsh-module-infra-api/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports diff --git a/viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/FileApi.java b/viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/FileApi.java index 6dbfd7d..2f75c3b 100644 --- a/viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/FileApi.java +++ b/viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/FileApi.java @@ -7,12 +7,15 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; +import java.util.List; + @FeignClient(name = ApiConstants.NAME) // TODO 芋艿:fallbackFactory = @Tag(name = "RPC 服务 - 文件") public interface FileApi { @@ -70,4 +73,16 @@ public interface FileApi { CommonResult presignGetUrl(@NotEmpty(message = "URL 不能为空") @RequestParam("url") String url, @RequestParam(value = "expirationSeconds", required = false) Integer expirationSeconds); + /** + * 批量生成文件预签名地址,用于读取 + * + * @param urls 完整的文件访问地址列表 + * @param expirationSeconds 访问有效期,单位秒 + * @return 签名后的 URL 列表(与入参顺序一致) + */ + @PostMapping(PREFIX + "/presigned-urls") + @Operation(summary = "批量生成文件预签名地址,用于读取") + CommonResult> presignGetUrls(@RequestBody @NotEmpty(message = "URL 列表不能为空") @Size(max = 500, message = "批量签名数量不能超过 500") List urls, + @RequestParam(value = "expirationSeconds", required = false) Integer expirationSeconds); + } diff --git a/viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/OssPresignHelper.java b/viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/OssPresignHelper.java new file mode 100644 index 0000000..f1eb6ec --- /dev/null +++ b/viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/OssPresignHelper.java @@ -0,0 +1,80 @@ +package com.viewsh.module.infra.api.file; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONUtil; +import lombok.extern.slf4j.Slf4j; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * OSS 预签名 URL 工具类 + *

    + * 供 Handler 层处理动态 Map 字段(如 extInfo)中的文件 URL + */ +@Slf4j +public final class OssPresignHelper { + + private OssPresignHelper() {} + + /** + * 单个 URL 签名(静默降级:失败返回原始 URL) + */ + public static String presignQuietly(FileApi fileApi, String url) { + if (StrUtil.isEmpty(url)) { + return url; + } + try { + return fileApi.presignGetUrl(url, null).getCheckedData(); + } catch (Exception e) { + log.warn("[presignQuietly] URL 签名失败: {}", url, e); + return url; + } + } + + /** + * JSON 数组 URL 签名:["url1","url2"] -> ["signed1","signed2"](静默降级) + */ + public static String presignJsonArrayQuietly(FileApi fileApi, String urlsJson) { + if (StrUtil.isEmpty(urlsJson)) { + return urlsJson; + } + try { + List urls = JSONUtil.toList(urlsJson, String.class); + if (urls.isEmpty()) { + return urlsJson; + } + Map signedMap = presignBatch(fileApi, urls); + List signedUrls = urls.stream() + .map(u -> signedMap.getOrDefault(u, u)) + .collect(Collectors.toList()); + return JSONUtil.toJsonStr(signedUrls); + } catch (Exception e) { + log.warn("[presignJsonArrayQuietly] JSON 数组签名失败: {}", urlsJson, e); + return urlsJson; + } + } + + /** + * 批量签名(一次 RPC),返回 原始URL -> 签名URL 映射 + */ + public static Map presignBatch(FileApi fileApi, Collection urls) { + if (urls == null || urls.isEmpty()) { + return Collections.emptyMap(); + } + // 去重 + List uniqueUrls = new ArrayList<>(new LinkedHashSet<>(urls)); + try { + List signedUrls = fileApi.presignGetUrls(uniqueUrls, null).getCheckedData(); + Map map = new HashMap<>(uniqueUrls.size()); + for (int i = 0; i < uniqueUrls.size(); i++) { + map.put(uniqueUrls.get(i), signedUrls.get(i)); + } + return map; + } catch (Exception e) { + log.warn("[presignBatch] 批量签名失败", e); + return Collections.emptyMap(); + } + } + +} diff --git a/viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/config/OssPresignUrlApiAutoConfiguration.java b/viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/config/OssPresignUrlApiAutoConfiguration.java new file mode 100644 index 0000000..fc46d5d --- /dev/null +++ b/viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/config/OssPresignUrlApiAutoConfiguration.java @@ -0,0 +1,31 @@ +package com.viewsh.module.infra.api.file.config; + +import com.viewsh.framework.common.biz.infra.file.OssPresignUrlApi; +import com.viewsh.module.infra.api.file.FileApi; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; + +/** + * 基于 {@link FileApi} Feign 客户端的 {@link OssPresignUrlApi} 自动配置 + *

    + * 微服务模式下,各服务通过 Feign 调用 infra-server 进行批量预签名。 + * 单体模式下,infra-server 中的 {@code ViewshFileAutoConfiguration} 会直连 FileService, + * 通过 {@link ConditionalOnMissingBean} 保证不冲突。 + */ +// ⚠ beforeName 引用了 ViewshWebAutoConfiguration 的全限定名(字符串), +// 因为 infra-api 不依赖 viewsh-spring-boot-starter-web,无法使用 before = ViewshWebAutoConfiguration.class。 +// 如果重命名/移动 ViewshWebAutoConfiguration,必须同步修改此处字符串。 +// grep 关键字以便重构时检索:PRESIGN_AUTO_CONFIG_ORDERING +@AutoConfiguration(beforeName = "com.viewsh.framework.web.config.ViewshWebAutoConfiguration") +public class OssPresignUrlApiAutoConfiguration { + + @Bean + @ConditionalOnBean(FileApi.class) + @ConditionalOnMissingBean(OssPresignUrlApi.class) + public OssPresignUrlApi ossPresignUrlApi(FileApi fileApi) { + return urls -> fileApi.presignGetUrls(urls, null).getCheckedData(); + } + +} diff --git a/viewsh-module-infra/viewsh-module-infra-api/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/viewsh-module-infra/viewsh-module-infra-api/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..f12d02f --- /dev/null +++ b/viewsh-module-infra/viewsh-module-infra-api/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.viewsh.module.infra.api.file.config.OssPresignUrlApiAutoConfiguration diff --git a/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/api/file/FileApiImpl.java b/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/api/file/FileApiImpl.java index 3820f8d..505823b 100644 --- a/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/api/file/FileApiImpl.java +++ b/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/api/file/FileApiImpl.java @@ -1,31 +1,37 @@ -package com.viewsh.module.infra.api.file; - -import com.viewsh.framework.common.pojo.CommonResult; -import com.viewsh.module.infra.api.file.dto.FileCreateReqDTO; -import com.viewsh.module.infra.service.file.FileService; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.RestController; - -import jakarta.annotation.Resource; - -import static com.viewsh.framework.common.pojo.CommonResult.success; - -@RestController // 提供 RESTful API 接口,给 Feign 调用 -@Validated -public class FileApiImpl implements FileApi { - - @Resource - private FileService fileService; - - @Override - public CommonResult createFile(FileCreateReqDTO createReqDTO) { - return success(fileService.createFile(createReqDTO.getContent(), createReqDTO.getName(), - createReqDTO.getDirectory(), createReqDTO.getType())); - } - - @Override - public CommonResult presignGetUrl(String url, Integer expirationSeconds) { - return success(fileService.presignGetUrl(url, expirationSeconds)); - } - -} +package com.viewsh.module.infra.api.file; + +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.module.infra.api.file.dto.FileCreateReqDTO; +import com.viewsh.module.infra.service.file.FileService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.annotation.Resource; +import java.util.List; + +import static com.viewsh.framework.common.pojo.CommonResult.success; + +@RestController // 提供 RESTful API 接口,给 Feign 调用 +@Validated +public class FileApiImpl implements FileApi { + + @Resource + private FileService fileService; + + @Override + public CommonResult createFile(FileCreateReqDTO createReqDTO) { + return success(fileService.createFile(createReqDTO.getContent(), createReqDTO.getName(), + createReqDTO.getDirectory(), createReqDTO.getType())); + } + + @Override + public CommonResult presignGetUrl(String url, Integer expirationSeconds) { + return success(fileService.presignGetUrl(url, expirationSeconds)); + } + + @Override + public CommonResult> presignGetUrls(List urls, Integer expirationSeconds) { + return success(fileService.presignGetUrls(urls, expirationSeconds)); + } + +} diff --git a/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/framework/file/config/ViewshFileAutoConfiguration.java b/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/framework/file/config/ViewshFileAutoConfiguration.java index 7188e5c..ccc5ee7 100644 --- a/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/framework/file/config/ViewshFileAutoConfiguration.java +++ b/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/framework/file/config/ViewshFileAutoConfiguration.java @@ -1,21 +1,28 @@ -package com.viewsh.module.infra.framework.file.config; - -import com.viewsh.module.infra.framework.file.core.client.FileClientFactory; -import com.viewsh.module.infra.framework.file.core.client.FileClientFactoryImpl; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -/** - * 文件配置类 - * - * @author 芋道源码 - */ -@Configuration(proxyBeanMethods = false) -public class ViewshFileAutoConfiguration { - - @Bean - public FileClientFactory fileClientFactory() { - return new FileClientFactoryImpl(); - } - -} +package com.viewsh.module.infra.framework.file.config; + +import com.viewsh.framework.common.biz.infra.file.OssPresignUrlApi; +import com.viewsh.module.infra.framework.file.core.client.FileClientFactory; +import com.viewsh.module.infra.framework.file.core.client.FileClientFactoryImpl; +import com.viewsh.module.infra.service.file.FileService; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 文件配置类 + * + * @author 芋道源码 + */ +@Configuration(proxyBeanMethods = false) +public class ViewshFileAutoConfiguration { + + @Bean + public FileClientFactory fileClientFactory() { + return new FileClientFactoryImpl(); + } + + @Bean + public OssPresignUrlApi ossPresignUrlApi(FileService fileService) { + return urls -> fileService.presignGetUrls(urls, null); + } + +} diff --git a/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/service/file/FileService.java b/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/service/file/FileService.java index a1640a8..e6a5d0c 100644 --- a/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/service/file/FileService.java +++ b/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/service/file/FileService.java @@ -1,89 +1,98 @@ -package com.viewsh.module.infra.service.file; - -import com.viewsh.framework.common.pojo.PageResult; -import com.viewsh.module.infra.controller.admin.file.vo.file.FileCreateReqVO; -import com.viewsh.module.infra.controller.admin.file.vo.file.FilePageReqVO; -import com.viewsh.module.infra.controller.admin.file.vo.file.FilePresignedUrlRespVO; -import com.viewsh.module.infra.dal.dataobject.file.FileDO; -import jakarta.validation.constraints.NotEmpty; - -import java.util.List; - -/** - * 文件 Service 接口 - * - * @author 芋道源码 - */ -public interface FileService { - - /** - * 获得文件分页 - * - * @param pageReqVO 分页查询 - * @return 文件分页 - */ - PageResult getFilePage(FilePageReqVO pageReqVO); - - /** - * 保存文件,并返回文件的访问路径 - * - * @param content 文件内容 - * @param name 文件名称,允许空 - * @param directory 目录,允许空 - * @param type 文件的 MIME 类型,允许空 - * @return 文件路径 - */ - String createFile(@NotEmpty(message = "文件内容不能为空") byte[] content, - String name, String directory, String type); - - /** - * 生成文件预签名地址信息,用于上传 - * - * @param name 文件名 - * @param directory 目录 - * @return 预签名地址信息 - */ - FilePresignedUrlRespVO presignPutUrl(@NotEmpty(message = "文件名不能为空") String name, - String directory); - /** - * 生成文件预签名地址信息,用于读取 - * - * @param url 完整的文件访问地址 - * @param expirationSeconds 访问有效期,单位秒 - * @return 文件预签名地址 - */ - String presignGetUrl(String url, Integer expirationSeconds); - - /** - * 创建文件 - * - * @param createReqVO 创建信息 - * @return 编号 - */ - Long createFile(FileCreateReqVO createReqVO); - FileDO getFile(Long id); - - /** - * 删除文件 - * - * @param id 编号 - */ - void deleteFile(Long id) throws Exception; - - /** - * 批量删除文件 - * - * @param ids 编号列表 - */ - void deleteFileList(List ids) throws Exception; - - /** - * 获得文件内容 - * - * @param configId 配置编号 - * @param path 文件路径 - * @return 文件内容 - */ - byte[] getFileContent(Long configId, String path) throws Exception; - -} +package com.viewsh.module.infra.service.file; + +import com.viewsh.framework.common.pojo.PageResult; +import com.viewsh.module.infra.controller.admin.file.vo.file.FileCreateReqVO; +import com.viewsh.module.infra.controller.admin.file.vo.file.FilePageReqVO; +import com.viewsh.module.infra.controller.admin.file.vo.file.FilePresignedUrlRespVO; +import com.viewsh.module.infra.dal.dataobject.file.FileDO; +import jakarta.validation.constraints.NotEmpty; + +import java.util.List; + +/** + * 文件 Service 接口 + * + * @author 芋道源码 + */ +public interface FileService { + + /** + * 获得文件分页 + * + * @param pageReqVO 分页查询 + * @return 文件分页 + */ + PageResult getFilePage(FilePageReqVO pageReqVO); + + /** + * 保存文件,并返回文件的访问路径 + * + * @param content 文件内容 + * @param name 文件名称,允许空 + * @param directory 目录,允许空 + * @param type 文件的 MIME 类型,允许空 + * @return 文件路径 + */ + String createFile(@NotEmpty(message = "文件内容不能为空") byte[] content, + String name, String directory, String type); + + /** + * 生成文件预签名地址信息,用于上传 + * + * @param name 文件名 + * @param directory 目录 + * @return 预签名地址信息 + */ + FilePresignedUrlRespVO presignPutUrl(@NotEmpty(message = "文件名不能为空") String name, + String directory); + /** + * 生成文件预签名地址信息,用于读取 + * + * @param url 完整的文件访问地址 + * @param expirationSeconds 访问有效期,单位秒 + * @return 文件预签名地址 + */ + String presignGetUrl(String url, Integer expirationSeconds); + + /** + * 批量生成文件预签名地址,用于读取 + * + * @param urls 完整的文件访问地址列表 + * @param expirationSeconds 访问有效期,单位秒 + * @return 签名后的 URL 列表(与入参顺序一致) + */ + List presignGetUrls(List urls, Integer expirationSeconds); + + /** + * 创建文件 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createFile(FileCreateReqVO createReqVO); + FileDO getFile(Long id); + + /** + * 删除文件 + * + * @param id 编号 + */ + void deleteFile(Long id) throws Exception; + + /** + * 批量删除文件 + * + * @param ids 编号列表 + */ + void deleteFileList(List ids) throws Exception; + + /** + * 获得文件内容 + * + * @param configId 配置编号 + * @param path 文件路径 + * @return 文件内容 + */ + byte[] getFileContent(Long configId, String path) throws Exception; + +} diff --git a/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/service/file/FileServiceImpl.java b/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/service/file/FileServiceImpl.java index d2ae57a..c3ee76e 100644 --- a/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/service/file/FileServiceImpl.java +++ b/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/service/file/FileServiceImpl.java @@ -1,206 +1,218 @@ -package com.viewsh.module.infra.service.file; - -import cn.hutool.core.date.LocalDateTimeUtil; -import cn.hutool.core.io.FileUtil; -import cn.hutool.core.lang.Assert; -import cn.hutool.core.util.StrUtil; -import cn.hutool.crypto.digest.DigestUtil; -import com.viewsh.framework.common.pojo.PageResult; -import com.viewsh.framework.common.util.http.HttpUtils; -import com.viewsh.framework.common.util.object.BeanUtils; -import com.viewsh.module.infra.controller.admin.file.vo.file.FileCreateReqVO; -import com.viewsh.module.infra.controller.admin.file.vo.file.FilePageReqVO; -import com.viewsh.module.infra.controller.admin.file.vo.file.FilePresignedUrlRespVO; -import com.viewsh.module.infra.dal.dataobject.file.FileDO; -import com.viewsh.module.infra.dal.mysql.file.FileMapper; -import com.viewsh.module.infra.framework.file.core.client.FileClient; -import com.viewsh.module.infra.framework.file.core.utils.FileTypeUtils; -import com.google.common.annotations.VisibleForTesting; -import jakarta.annotation.Resource; -import lombok.SneakyThrows; -import org.springframework.stereotype.Service; - -import java.util.List; - -import static cn.hutool.core.date.DatePattern.PURE_DATE_PATTERN; -import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception; -import static com.viewsh.module.infra.enums.ErrorCodeConstants.FILE_NOT_EXISTS; - -/** - * 文件 Service 实现类 - * - * @author 芋道源码 - */ -@Service -public class FileServiceImpl implements FileService { - - /** - * 上传文件的前缀,是否包含日期(yyyyMMdd) - * - * 目的:按照日期,进行分目录 - */ - static boolean PATH_PREFIX_DATE_ENABLE = true; - /** - * 上传文件的后缀,是否包含时间戳 - * - * 目的:保证文件的唯一性,避免覆盖 - * 定制:可按需调整成 UUID、或者其他方式 - */ - static boolean PATH_SUFFIX_TIMESTAMP_ENABLE = true; - - @Resource - private FileConfigService fileConfigService; - - @Resource - private FileMapper fileMapper; - - @Override - public PageResult getFilePage(FilePageReqVO pageReqVO) { - return fileMapper.selectPage(pageReqVO); - } - - @Override - @SneakyThrows - public String createFile(byte[] content, String name, String directory, String type) { - // 1.1 处理 type 为空的情况 - if (StrUtil.isEmpty(type)) { - type = FileTypeUtils.getMineType(content, name); - } - // 1.2 处理 name 为空的情况 - if (StrUtil.isEmpty(name)) { - name = DigestUtil.sha256Hex(content); - } - if (StrUtil.isEmpty(FileUtil.extName(name))) { - // 如果 name 没有后缀 type,则补充后缀 - String extension = FileTypeUtils.getExtension(type); - if (StrUtil.isNotEmpty(extension)) { - name = name + extension; - } - } - - // 2.1 生成上传的 path,需要保证唯一 - String path = generateUploadPath(name, directory); - // 2.2 上传到文件存储器 - FileClient client = fileConfigService.getMasterFileClient(); - Assert.notNull(client, "客户端(master) 不能为空"); - String url = client.upload(content, path, type); - - // 3. 保存到数据库 - fileMapper.insert(new FileDO().setConfigId(client.getId()) - .setName(name).setPath(path).setUrl(url) - .setType(type).setSize(content.length)); - return url; - } - - @VisibleForTesting - String generateUploadPath(String name, String directory) { - // 1. 生成前缀、后缀 - String prefix = null; - if (PATH_PREFIX_DATE_ENABLE) { - prefix = LocalDateTimeUtil.format(LocalDateTimeUtil.now(), PURE_DATE_PATTERN); - } - String suffix = null; - if (PATH_SUFFIX_TIMESTAMP_ENABLE) { - suffix = String.valueOf(System.currentTimeMillis()); - } - - // 2.1 先拼接 suffix 后缀 - if (StrUtil.isNotEmpty(suffix)) { - String ext = FileUtil.extName(name); - if (StrUtil.isNotEmpty(ext)) { - name = FileUtil.mainName(name) + StrUtil.C_UNDERLINE + suffix + StrUtil.DOT + ext; - } else { - name = name + StrUtil.C_UNDERLINE + suffix; - } - } - // 2.2 再拼接 prefix 前缀 - if (StrUtil.isNotEmpty(prefix)) { - name = prefix + StrUtil.SLASH + name; - } - // 2.3 最后拼接 directory 目录 - if (StrUtil.isNotEmpty(directory)) { - name = directory + StrUtil.SLASH + name; - } - return name; - } - - @Override - @SneakyThrows - public FilePresignedUrlRespVO presignPutUrl(String name, String directory) { - // 1. 生成上传的 path,需要保证唯一 - String path = generateUploadPath(name, directory); - - // 2. 获取文件预签名地址 - FileClient fileClient = fileConfigService.getMasterFileClient(); - String uploadUrl = fileClient.presignPutUrl(path); - String visitUrl = fileClient.presignGetUrl(path, null); - return new FilePresignedUrlRespVO().setConfigId(fileClient.getId()) - .setPath(path).setUploadUrl(uploadUrl).setUrl(visitUrl); - } - - @Override - public String presignGetUrl(String url, Integer expirationSeconds) { - FileClient fileClient = fileConfigService.getMasterFileClient(); - return fileClient.presignGetUrl(url, expirationSeconds); - } - - @Override - public Long createFile(FileCreateReqVO createReqVO) { - createReqVO.setUrl(HttpUtils.removeUrlQuery(createReqVO.getUrl())); // 目的:移除私有桶情况下,URL 的签名参数 - FileDO file = BeanUtils.toBean(createReqVO, FileDO.class); - fileMapper.insert(file); - return file.getId(); - } - - @Override - public FileDO getFile(Long id) { - return validateFileExists(id); - } - - @Override - public void deleteFile(Long id) throws Exception { - // 校验存在 - FileDO file = validateFileExists(id); - - // 从文件存储器中删除 - FileClient client = fileConfigService.getFileClient(file.getConfigId()); - Assert.notNull(client, "客户端({}) 不能为空", file.getConfigId()); - client.delete(file.getPath()); - - // 删除记录 - fileMapper.deleteById(id); - } - - @Override - @SneakyThrows - public void deleteFileList(List ids) { - // 删除文件 - List files = fileMapper.selectByIds(ids); - for (FileDO file : files) { - // 获取客户端 - FileClient client = fileConfigService.getFileClient(file.getConfigId()); - Assert.notNull(client, "客户端({}) 不能为空", file.getPath()); - // 删除文件 - client.delete(file.getPath()); - } - - // 删除记录 - fileMapper.deleteByIds(ids); - } - - private FileDO validateFileExists(Long id) { - FileDO fileDO = fileMapper.selectById(id); - if (fileDO == null) { - throw exception(FILE_NOT_EXISTS); - } - return fileDO; - } - - @Override - public byte[] getFileContent(Long configId, String path) throws Exception { - FileClient client = fileConfigService.getFileClient(configId); - Assert.notNull(client, "客户端({}) 不能为空", configId); - return client.getContent(path); - } - -} +package com.viewsh.module.infra.service.file; + +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.digest.DigestUtil; +import com.viewsh.framework.common.pojo.PageResult; +import com.viewsh.framework.common.util.http.HttpUtils; +import com.viewsh.framework.common.util.object.BeanUtils; +import com.viewsh.module.infra.controller.admin.file.vo.file.FileCreateReqVO; +import com.viewsh.module.infra.controller.admin.file.vo.file.FilePageReqVO; +import com.viewsh.module.infra.controller.admin.file.vo.file.FilePresignedUrlRespVO; +import com.viewsh.module.infra.dal.dataobject.file.FileDO; +import com.viewsh.module.infra.dal.mysql.file.FileMapper; +import com.viewsh.module.infra.framework.file.core.client.FileClient; +import com.viewsh.module.infra.framework.file.core.utils.FileTypeUtils; +import com.google.common.annotations.VisibleForTesting; +import jakarta.annotation.Resource; +import lombok.SneakyThrows; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; + +import static cn.hutool.core.date.DatePattern.PURE_DATE_PATTERN; +import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception; +import static com.viewsh.module.infra.enums.ErrorCodeConstants.FILE_NOT_EXISTS; + +/** + * 文件 Service 实现类 + * + * @author 芋道源码 + */ +@Service +public class FileServiceImpl implements FileService { + + /** + * 上传文件的前缀,是否包含日期(yyyyMMdd) + * + * 目的:按照日期,进行分目录 + */ + static boolean PATH_PREFIX_DATE_ENABLE = true; + /** + * 上传文件的后缀,是否包含时间戳 + * + * 目的:保证文件的唯一性,避免覆盖 + * 定制:可按需调整成 UUID、或者其他方式 + */ + static boolean PATH_SUFFIX_TIMESTAMP_ENABLE = true; + + @Resource + private FileConfigService fileConfigService; + + @Resource + private FileMapper fileMapper; + + @Override + public PageResult getFilePage(FilePageReqVO pageReqVO) { + return fileMapper.selectPage(pageReqVO); + } + + @Override + @SneakyThrows + public String createFile(byte[] content, String name, String directory, String type) { + // 1.1 处理 type 为空的情况 + if (StrUtil.isEmpty(type)) { + type = FileTypeUtils.getMineType(content, name); + } + // 1.2 处理 name 为空的情况 + if (StrUtil.isEmpty(name)) { + name = DigestUtil.sha256Hex(content); + } + if (StrUtil.isEmpty(FileUtil.extName(name))) { + // 如果 name 没有后缀 type,则补充后缀 + String extension = FileTypeUtils.getExtension(type); + if (StrUtil.isNotEmpty(extension)) { + name = name + extension; + } + } + + // 2.1 生成上传的 path,需要保证唯一 + String path = generateUploadPath(name, directory); + // 2.2 上传到文件存储器 + FileClient client = fileConfigService.getMasterFileClient(); + Assert.notNull(client, "客户端(master) 不能为空"); + String url = client.upload(content, path, type); + + // 3. 保存到数据库 + fileMapper.insert(new FileDO().setConfigId(client.getId()) + .setName(name).setPath(path).setUrl(url) + .setType(type).setSize(content.length)); + return url; + } + + @VisibleForTesting + String generateUploadPath(String name, String directory) { + // 1. 生成前缀、后缀 + String prefix = null; + if (PATH_PREFIX_DATE_ENABLE) { + prefix = LocalDateTimeUtil.format(LocalDateTimeUtil.now(), PURE_DATE_PATTERN); + } + String suffix = null; + if (PATH_SUFFIX_TIMESTAMP_ENABLE) { + suffix = String.valueOf(System.currentTimeMillis()); + } + + // 2.1 先拼接 suffix 后缀 + if (StrUtil.isNotEmpty(suffix)) { + String ext = FileUtil.extName(name); + if (StrUtil.isNotEmpty(ext)) { + name = FileUtil.mainName(name) + StrUtil.C_UNDERLINE + suffix + StrUtil.DOT + ext; + } else { + name = name + StrUtil.C_UNDERLINE + suffix; + } + } + // 2.2 再拼接 prefix 前缀 + if (StrUtil.isNotEmpty(prefix)) { + name = prefix + StrUtil.SLASH + name; + } + // 2.3 最后拼接 directory 目录 + if (StrUtil.isNotEmpty(directory)) { + name = directory + StrUtil.SLASH + name; + } + return name; + } + + @Override + @SneakyThrows + public FilePresignedUrlRespVO presignPutUrl(String name, String directory) { + // 1. 生成上传的 path,需要保证唯一 + String path = generateUploadPath(name, directory); + + // 2. 获取文件预签名地址 + FileClient fileClient = fileConfigService.getMasterFileClient(); + String uploadUrl = fileClient.presignPutUrl(path); + String visitUrl = fileClient.presignGetUrl(path, null); + return new FilePresignedUrlRespVO().setConfigId(fileClient.getId()) + .setPath(path).setUploadUrl(uploadUrl).setUrl(visitUrl); + } + + @Override + public String presignGetUrl(String url, Integer expirationSeconds) { + FileClient fileClient = fileConfigService.getMasterFileClient(); + return fileClient.presignGetUrl(url, expirationSeconds); + } + + @Override + public List presignGetUrls(List urls, Integer expirationSeconds) { + if (urls == null || urls.isEmpty()) { + return Collections.emptyList(); + } + FileClient fileClient = fileConfigService.getMasterFileClient(); + return urls.stream() + .map(url -> fileClient.presignGetUrl(url, expirationSeconds)) + .toList(); + } + + @Override + public Long createFile(FileCreateReqVO createReqVO) { + createReqVO.setUrl(HttpUtils.removeUrlQuery(createReqVO.getUrl())); // 目的:移除私有桶情况下,URL 的签名参数 + FileDO file = BeanUtils.toBean(createReqVO, FileDO.class); + fileMapper.insert(file); + return file.getId(); + } + + @Override + public FileDO getFile(Long id) { + return validateFileExists(id); + } + + @Override + public void deleteFile(Long id) throws Exception { + // 校验存在 + FileDO file = validateFileExists(id); + + // 从文件存储器中删除 + FileClient client = fileConfigService.getFileClient(file.getConfigId()); + Assert.notNull(client, "客户端({}) 不能为空", file.getConfigId()); + client.delete(file.getPath()); + + // 删除记录 + fileMapper.deleteById(id); + } + + @Override + @SneakyThrows + public void deleteFileList(List ids) { + // 删除文件 + List files = fileMapper.selectByIds(ids); + for (FileDO file : files) { + // 获取客户端 + FileClient client = fileConfigService.getFileClient(file.getConfigId()); + Assert.notNull(client, "客户端({}) 不能为空", file.getPath()); + // 删除文件 + client.delete(file.getPath()); + } + + // 删除记录 + fileMapper.deleteByIds(ids); + } + + private FileDO validateFileExists(Long id) { + FileDO fileDO = fileMapper.selectById(id); + if (fileDO == null) { + throw exception(FILE_NOT_EXISTS); + } + return fileDO; + } + + @Override + public byte[] getFileContent(Long configId, String path) throws Exception { + FileClient client = fileConfigService.getFileClient(configId); + Assert.notNull(client, "客户端({}) 不能为空", configId); + return client.getContent(path); + } + +} From ff153dd1a9bcf17e4c219d0fa0e44f6843b1b05e Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 18 Mar 2026 15:06:23 +0800 Subject: [PATCH 33/35] =?UTF-8?q?refactor(infra):=20=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E6=8E=A5=E5=8F=A3=E6=8E=A5=E5=85=A5=20@OssPr?= =?UTF-8?q?esignUrl=20=E8=87=AA=E5=8A=A8=E9=A2=84=E7=AD=BE=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FileRespVO.url 字段添加 @OssPresignUrl 注解,移除 Controller 中 手动调用 presignGetUrl 的逻辑,由框架层 ResponseBodyAdvice 统一处理。 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../controller/admin/file/FileController.java | 16 +--- .../admin/file/vo/file/FileRespVO.java | 74 ++++++++++--------- 2 files changed, 40 insertions(+), 50 deletions(-) diff --git a/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/controller/admin/file/FileController.java b/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/controller/admin/file/FileController.java index bdb32f3..d8ad04b 100644 --- a/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/controller/admin/file/FileController.java +++ b/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/controller/admin/file/FileController.java @@ -77,12 +77,7 @@ public class FileController { @Parameter(name = "id", description = "编号", required = true) @PreAuthorize("@ss.hasPermission('infra:file:query')") public CommonResult getFile(@RequestParam("id") Long id) { - FileRespVO respVO = BeanUtils.toBean(fileService.getFile(id), FileRespVO.class); - // 私有桶:对文件 URL 生成预签名访问地址 - if (respVO != null && StrUtil.isNotEmpty(respVO.getUrl())) { - respVO.setUrl(fileService.presignGetUrl(respVO.getUrl(), null)); - } - return success(respVO); + return success(BeanUtils.toBean(fileService.getFile(id), FileRespVO.class)); } @DeleteMapping("/delete") @@ -136,14 +131,7 @@ public class FileController { @PreAuthorize("@ss.hasPermission('infra:file:query')") public CommonResult> getFilePage(@Valid FilePageReqVO pageVO) { PageResult pageResult = fileService.getFilePage(pageVO); - PageResult voPageResult = BeanUtils.toBean(pageResult, FileRespVO.class); - // 私有桶:对文件 URL 生成预签名访问地址 - voPageResult.getList().forEach(vo -> { - if (StrUtil.isNotEmpty(vo.getUrl())) { - vo.setUrl(fileService.presignGetUrl(vo.getUrl(), null)); - } - }); - return success(voPageResult); + return success(BeanUtils.toBean(pageResult, FileRespVO.class)); } } diff --git a/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/controller/admin/file/vo/file/FileRespVO.java b/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/controller/admin/file/vo/file/FileRespVO.java index 4435d18..74193b8 100644 --- a/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/controller/admin/file/vo/file/FileRespVO.java +++ b/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/controller/admin/file/vo/file/FileRespVO.java @@ -1,36 +1,38 @@ -package com.viewsh.module.infra.controller.admin.file.vo.file; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; - -import java.time.LocalDateTime; - -@Schema(description = "管理后台 - 文件 Response VO,不返回 content 字段,太大") -@Data -public class FileRespVO { - - @Schema(description = "文件编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") - private Long id; - - @Schema(description = "配置编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "11") - private Long configId; - - @Schema(description = "文件路径", requiredMode = Schema.RequiredMode.REQUIRED, example = "viewsh.jpg") - private String path; - - @Schema(description = "原文件名", requiredMode = Schema.RequiredMode.REQUIRED, example = "viewsh.jpg") - private String name; - - @Schema(description = "文件 URL", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://www.iocoder.cn/viewsh.jpg") - private String url; - - @Schema(description = "文件MIME类型", example = "application/octet-stream") - private String type; - - @Schema(description = "文件大小", example = "2048", requiredMode = Schema.RequiredMode.REQUIRED) - private Integer size; - - @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) - private LocalDateTime createTime; - -} +package com.viewsh.module.infra.controller.admin.file.vo.file; + +import com.viewsh.framework.web.core.presign.annotation.OssPresignUrl; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 文件 Response VO,不返回 content 字段,太大") +@Data +public class FileRespVO { + + @Schema(description = "文件编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "配置编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "11") + private Long configId; + + @Schema(description = "文件路径", requiredMode = Schema.RequiredMode.REQUIRED, example = "viewsh.jpg") + private String path; + + @Schema(description = "原文件名", requiredMode = Schema.RequiredMode.REQUIRED, example = "viewsh.jpg") + private String name; + + @Schema(description = "文件 URL", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://www.iocoder.cn/viewsh.jpg") + @OssPresignUrl + private String url; + + @Schema(description = "文件MIME类型", example = "application/octet-stream") + private String type; + + @Schema(description = "文件大小", example = "2048", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer size; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} From 78aba0d1ed96be83ee3ff1091907042f798a4d4c Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 18 Mar 2026 15:06:43 +0800 Subject: [PATCH 34/35] =?UTF-8?q?refactor(system):=20=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=A4=B4=E5=83=8F=E9=A2=84=E7=AD=BE=E5=90=8D=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=20@OssPresignUrl=20=E5=A3=B0=E6=98=8E=E5=BC=8F=E5=A4=84?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 AuthPermissionInfoRespVO、OAuth2UserInfoRespVO、UserProfileRespVO、 UserRespVO 的 avatar 字段添加 @OssPresignUrl 注解,移除 AuthController、OAuth2UserController、UserController、 UserProfileController 中手动调用 fileApi.presignGetUrl 的代码, Controller 回归薄层职责。 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../controller/admin/auth/AuthController.java | 10 +- .../auth/vo/AuthPermissionInfoRespVO.java | 206 ++++--- .../admin/oauth2/OAuth2UserController.java | 8 - .../oauth2/vo/user/OAuth2UserInfoRespVO.java | 142 ++--- .../controller/admin/user/UserController.java | 17 +- .../admin/user/UserProfileController.java | 10 +- .../user/vo/profile/UserProfileRespVO.java | 120 ++-- .../admin/user/vo/user/UserRespVO.java | 152 ++--- .../social/SocialUserServiceImplTest.java | 579 +++++++++--------- 9 files changed, 608 insertions(+), 636 deletions(-) diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/auth/AuthController.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/auth/AuthController.java index 06358c2..a1154b4 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/auth/AuthController.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/auth/AuthController.java @@ -7,7 +7,6 @@ import com.viewsh.framework.common.enums.UserTypeEnum; import com.viewsh.framework.common.pojo.CommonResult; import com.viewsh.framework.security.config.SecurityProperties; import com.viewsh.framework.security.core.util.SecurityFrameworkUtils; -import com.viewsh.module.infra.api.file.FileApi; import com.viewsh.module.system.controller.admin.auth.vo.*; import com.viewsh.module.system.convert.auth.AuthConvert; import com.viewsh.module.system.dal.dataobject.permission.MenuDO; @@ -62,8 +61,6 @@ public class AuthController { @Resource private SecurityProperties securityProperties; - @Resource - private FileApi fileApi; @PostMapping("/login") @PermitAll @@ -115,12 +112,7 @@ public class AuthController { menuList = menuService.filterDisableMenus(menuList); // 2. 拼接结果返回 - AuthPermissionInfoRespVO respVO = AuthConvert.INSTANCE.convert(user, roles, menuList); - // 私有桶:对头像 URL 生成预签名访问地址 - if (respVO.getUser() != null && StrUtil.isNotEmpty(respVO.getUser().getAvatar())) { - respVO.getUser().setAvatar(fileApi.presignGetUrl(respVO.getUser().getAvatar(), null).getCheckedData()); - } - return success(respVO); + return success(AuthConvert.INSTANCE.convert(user, roles, menuList)); } @PostMapping("/register") diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/auth/vo/AuthPermissionInfoRespVO.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/auth/vo/AuthPermissionInfoRespVO.java index 250c210..c3935a7 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/auth/vo/AuthPermissionInfoRespVO.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/auth/vo/AuthPermissionInfoRespVO.java @@ -1,102 +1,104 @@ -package com.viewsh.module.system.controller.admin.auth.vo; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.List; -import java.util.Set; - -@Schema(description = "管理后台 - 登录用户的权限信息 Response VO,额外包括用户信息和角色列表") -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class AuthPermissionInfoRespVO { - - @Schema(description = "用户信息", requiredMode = Schema.RequiredMode.REQUIRED) - private UserVO user; - - @Schema(description = "角色标识数组", requiredMode = Schema.RequiredMode.REQUIRED) - private Set roles; - - @Schema(description = "操作权限数组", requiredMode = Schema.RequiredMode.REQUIRED) - private Set permissions; - - @Schema(description = "菜单树", requiredMode = Schema.RequiredMode.REQUIRED) - private List menus; - - @Schema(description = "用户信息 VO") - @Data - @NoArgsConstructor - @AllArgsConstructor - @Builder - public static class UserVO { - - @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") - private Long id; - - @Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋道源码") - private String nickname; - - @Schema(description = "用户头像", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://www.iocoder.cn/xx.jpg") - private String avatar; - - @Schema(description = "部门编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") - private Long deptId; - - @Schema(description = "用户账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "viewsh") - private String username; - - @Schema(description = "用户邮箱", example = "viewsh@iocoder.cn") - private String email; - - } - - @Schema(description = "管理后台 - 登录用户的菜单信息 Response VO") - @Data - @NoArgsConstructor - @AllArgsConstructor - @Builder - public static class MenuVO { - - @Schema(description = "菜单名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋道") - private Long id; - - @Schema(description = "父菜单 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") - private Long parentId; - - @Schema(description = "菜单名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋道") - private String name; - - @Schema(description = "路由地址,仅菜单类型为菜单或者目录时,才需要传", example = "post") - private String path; - - @Schema(description = "组件路径,仅菜单类型为菜单时,才需要传", example = "system/post/index") - private String component; - - @Schema(description = "组件名", example = "SystemUser") - private String componentName; - - @Schema(description = "菜单图标,仅菜单类型为菜单或者目录时,才需要传", example = "/menu/list") - private String icon; - - @Schema(description = "是否可见", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") - private Boolean visible; - - @Schema(description = "是否缓存", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") - private Boolean keepAlive; - - @Schema(description = "是否总是显示", example = "false") - private Boolean alwaysShow; - - /** - * 子路由 - */ - private List children; - - } - -} +package com.viewsh.module.system.controller.admin.auth.vo; + +import com.viewsh.framework.web.core.presign.annotation.OssPresignUrl; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Set; + +@Schema(description = "管理后台 - 登录用户的权限信息 Response VO,额外包括用户信息和角色列表") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class AuthPermissionInfoRespVO { + + @Schema(description = "用户信息", requiredMode = Schema.RequiredMode.REQUIRED) + private UserVO user; + + @Schema(description = "角色标识数组", requiredMode = Schema.RequiredMode.REQUIRED) + private Set roles; + + @Schema(description = "操作权限数组", requiredMode = Schema.RequiredMode.REQUIRED) + private Set permissions; + + @Schema(description = "菜单树", requiredMode = Schema.RequiredMode.REQUIRED) + private List menus; + + @Schema(description = "用户信息 VO") + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class UserVO { + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋道源码") + private String nickname; + + @Schema(description = "用户头像", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://www.iocoder.cn/xx.jpg") + @OssPresignUrl + private String avatar; + + @Schema(description = "部门编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + private Long deptId; + + @Schema(description = "用户账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "viewsh") + private String username; + + @Schema(description = "用户邮箱", example = "viewsh@iocoder.cn") + private String email; + + } + + @Schema(description = "管理后台 - 登录用户的菜单信息 Response VO") + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class MenuVO { + + @Schema(description = "菜单名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋道") + private Long id; + + @Schema(description = "父菜单 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long parentId; + + @Schema(description = "菜单名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋道") + private String name; + + @Schema(description = "路由地址,仅菜单类型为菜单或者目录时,才需要传", example = "post") + private String path; + + @Schema(description = "组件路径,仅菜单类型为菜单时,才需要传", example = "system/post/index") + private String component; + + @Schema(description = "组件名", example = "SystemUser") + private String componentName; + + @Schema(description = "菜单图标,仅菜单类型为菜单或者目录时,才需要传", example = "/menu/list") + private String icon; + + @Schema(description = "是否可见", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + private Boolean visible; + + @Schema(description = "是否缓存", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + private Boolean keepAlive; + + @Schema(description = "是否总是显示", example = "false") + private Boolean alwaysShow; + + /** + * 子路由 + */ + private List children; + + } + +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/oauth2/OAuth2UserController.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/oauth2/OAuth2UserController.java index 8ce3afb..ee7190e 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/oauth2/OAuth2UserController.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/oauth2/OAuth2UserController.java @@ -1,10 +1,8 @@ package com.viewsh.module.system.controller.admin.oauth2; import cn.hutool.core.collection.CollUtil; -import cn.hutool.core.util.StrUtil; import com.viewsh.framework.common.pojo.CommonResult; import com.viewsh.framework.common.util.object.BeanUtils; -import com.viewsh.module.infra.api.file.FileApi; import com.viewsh.module.system.controller.admin.oauth2.vo.user.OAuth2UserInfoRespVO; import com.viewsh.module.system.controller.admin.oauth2.vo.user.OAuth2UserUpdateReqVO; import com.viewsh.module.system.controller.admin.user.vo.profile.UserProfileUpdateReqVO; @@ -49,8 +47,6 @@ public class OAuth2UserController { private DeptService deptService; @Resource private PostService postService; - @Resource - private FileApi fileApi; @GetMapping("/get") @Operation(summary = "获得用户基本信息") @@ -69,10 +65,6 @@ public class OAuth2UserController { List posts = postService.getPostList(user.getPostIds()); resp.setPosts(BeanUtils.toBean(posts, OAuth2UserInfoRespVO.Post.class)); } - // 私有桶:对头像 URL 生成预签名访问地址 - if (StrUtil.isNotEmpty(resp.getAvatar())) { - resp.setAvatar(fileApi.presignGetUrl(resp.getAvatar(), null).getCheckedData()); - } return success(resp); } diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/oauth2/vo/user/OAuth2UserInfoRespVO.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/oauth2/vo/user/OAuth2UserInfoRespVO.java index dc36cb0..c9ce52b 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/oauth2/vo/user/OAuth2UserInfoRespVO.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/oauth2/vo/user/OAuth2UserInfoRespVO.java @@ -1,70 +1,72 @@ -package com.viewsh.module.system.controller.admin.oauth2.vo.user; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.List; - -@Schema(description = "管理后台 - OAuth2 获得用户基本信息 Response VO") -@Data -@NoArgsConstructor -@AllArgsConstructor -public class OAuth2UserInfoRespVO { - - @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") - private Long id; - - @Schema(description = "用户账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋艿") - private String username; - - @Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋道") - private String nickname; - - @Schema(description = "用户邮箱", example = "viewsh@iocoder.cn") - private String email; - @Schema(description = "手机号码", example = "15601691300") - private String mobile; - - @Schema(description = "用户性别,参见 SexEnum 枚举类", example = "1") - private Integer sex; - - @Schema(description = "用户头像", example = "https://www.iocoder.cn/xxx.png") - private String avatar; - - /** - * 所在部门 - */ - private Dept dept; - - /** - * 所属岗位数组 - */ - private List posts; - - @Schema(description = "部门") - @Data - public static class Dept { - - @Schema(description = "部门编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") - private Long id; - - @Schema(description = "部门名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "研发部") - private String name; - - } - - @Schema(description = "岗位") - @Data - public static class Post { - - @Schema(description = "岗位编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") - private Long id; - - @Schema(description = "岗位名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "开发") - private String name; - - } - -} +package com.viewsh.module.system.controller.admin.oauth2.vo.user; + +import com.viewsh.framework.web.core.presign.annotation.OssPresignUrl; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Schema(description = "管理后台 - OAuth2 获得用户基本信息 Response VO") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class OAuth2UserInfoRespVO { + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long id; + + @Schema(description = "用户账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋艿") + private String username; + + @Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋道") + private String nickname; + + @Schema(description = "用户邮箱", example = "viewsh@iocoder.cn") + private String email; + @Schema(description = "手机号码", example = "15601691300") + private String mobile; + + @Schema(description = "用户性别,参见 SexEnum 枚举类", example = "1") + private Integer sex; + + @Schema(description = "用户头像", example = "https://www.iocoder.cn/xxx.png") + @OssPresignUrl + private String avatar; + + /** + * 所在部门 + */ + private Dept dept; + + /** + * 所属岗位数组 + */ + private List posts; + + @Schema(description = "部门") + @Data + public static class Dept { + + @Schema(description = "部门编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long id; + + @Schema(description = "部门名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "研发部") + private String name; + + } + + @Schema(description = "岗位") + @Data + public static class Post { + + @Schema(description = "岗位编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long id; + + @Schema(description = "岗位名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "开发") + private String name; + + } + +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/user/UserController.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/user/UserController.java index ad69ce3..d040c8e 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/user/UserController.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/user/UserController.java @@ -1,14 +1,12 @@ package com.viewsh.module.system.controller.admin.user; import cn.hutool.core.collection.CollUtil; -import cn.hutool.core.util.StrUtil; import com.viewsh.framework.apilog.core.annotation.ApiAccessLog; import com.viewsh.framework.common.enums.CommonStatusEnum; import com.viewsh.framework.common.pojo.CommonResult; import com.viewsh.framework.common.pojo.PageParam; import com.viewsh.framework.common.pojo.PageResult; import com.viewsh.framework.excel.core.util.ExcelUtils; -import com.viewsh.module.infra.api.file.FileApi; import com.viewsh.module.system.controller.admin.user.vo.user.*; import com.viewsh.module.system.convert.user.UserConvert; import com.viewsh.module.system.dal.dataobject.dept.DeptDO; @@ -47,8 +45,6 @@ public class UserController { private AdminUserService userService; @Resource private DeptService deptService; - @Resource - private FileApi fileApi; @PostMapping("/create") @Operation(summary = "新增用户") @@ -113,12 +109,6 @@ public class UserController { Map deptMap = deptService.getDeptMap( convertList(pageResult.getList(), AdminUserDO::getDeptId)); List userList = UserConvert.INSTANCE.convertList(pageResult.getList(), deptMap); - // 私有桶:对头像 URL 生成预签名访问地址 - userList.forEach(vo -> { - if (StrUtil.isNotEmpty(vo.getAvatar())) { - vo.setAvatar(fileApi.presignGetUrl(vo.getAvatar(), null).getCheckedData()); - } - }); return success(new PageResult<>(userList, pageResult.getTotal())); } @@ -143,12 +133,7 @@ public class UserController { } // 拼接数据 DeptDO dept = deptService.getDept(user.getDeptId()); - UserRespVO respVO = UserConvert.INSTANCE.convert(user, dept); - // 私有桶:对头像 URL 生成预签名访问地址 - if (StrUtil.isNotEmpty(respVO.getAvatar())) { - respVO.setAvatar(fileApi.presignGetUrl(respVO.getAvatar(), null).getCheckedData()); - } - return success(respVO); + return success(UserConvert.INSTANCE.convert(user, dept)); } @GetMapping("/export-excel") diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/user/UserProfileController.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/user/UserProfileController.java index 9069887..37f43ca 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/user/UserProfileController.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/user/UserProfileController.java @@ -5,7 +5,6 @@ import cn.hutool.core.util.StrUtil; import com.viewsh.framework.common.pojo.CommonResult; import com.viewsh.framework.common.util.http.HttpUtils; import com.viewsh.framework.datapermission.core.annotation.DataPermission; -import com.viewsh.module.infra.api.file.FileApi; import com.viewsh.module.system.controller.admin.user.vo.profile.UserProfileRespVO; import com.viewsh.module.system.controller.admin.user.vo.profile.UserProfileUpdatePasswordReqVO; import com.viewsh.module.system.controller.admin.user.vo.profile.UserProfileUpdateReqVO; @@ -49,8 +48,6 @@ public class UserProfileController { private PermissionService permissionService; @Resource private RoleService roleService; - @Resource - private FileApi fileApi; @GetMapping("/get") @Operation(summary = "获得登录用户信息") @@ -64,12 +61,7 @@ public class UserProfileController { DeptDO dept = user.getDeptId() != null ? deptService.getDept(user.getDeptId()) : null; // 获得岗位信息 List posts = CollUtil.isNotEmpty(user.getPostIds()) ? postService.getPostList(user.getPostIds()) : null; - UserProfileRespVO respVO = UserConvert.INSTANCE.convert(user, userRoles, dept, posts); - // 私有桶:对头像 URL 生成预签名访问地址 - if (StrUtil.isNotEmpty(respVO.getAvatar())) { - respVO.setAvatar(fileApi.presignGetUrl(respVO.getAvatar(), null).getCheckedData()); - } - return success(respVO); + return success(UserConvert.INSTANCE.convert(user, userRoles, dept, posts)); } @PutMapping("/update") diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/user/vo/profile/UserProfileRespVO.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/user/vo/profile/UserProfileRespVO.java index 4478c94..49cfca2 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/user/vo/profile/UserProfileRespVO.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/user/vo/profile/UserProfileRespVO.java @@ -1,59 +1,61 @@ -package com.viewsh.module.system.controller.admin.user.vo.profile; - -import com.viewsh.module.system.controller.admin.dept.vo.dept.DeptSimpleRespVO; -import com.viewsh.module.system.controller.admin.dept.vo.post.PostSimpleRespVO; -import com.viewsh.module.system.controller.admin.permission.vo.role.RoleSimpleRespVO; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; - -import java.time.LocalDateTime; -import java.util.List; - -@Data -@Schema(description = "管理后台 - 用户个人中心信息 Response VO") -public class UserProfileRespVO { - - @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") - private Long id; - - @Schema(description = "用户账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "viewsh") - private String username; - - @Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋艿") - private String nickname; - - @Schema(description = "用户邮箱", example = "viewsh@iocoder.cn") - private String email; - - @Schema(description = "手机号码", example = "15601691300") - private String mobile; - - @Schema(description = "用户性别,参见 SexEnum 枚举类", example = "1") - private Integer sex; - - @Schema(description = "用户头像", example = "https://www.iocoder.cn/xxx.png") - private String avatar; - - @Schema(description = "最后登录 IP", requiredMode = Schema.RequiredMode.REQUIRED, example = "192.168.1.1") - private String loginIp; - - @Schema(description = "最后登录时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "时间戳格式") - private LocalDateTime loginDate; - - @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "时间戳格式") - private LocalDateTime createTime; - - /** - * 所属角色 - */ - private List roles; - /** - * 所在部门 - */ - private DeptSimpleRespVO dept; - /** - * 所属岗位数组 - */ - private List posts; - -} +package com.viewsh.module.system.controller.admin.user.vo.profile; + +import com.viewsh.framework.web.core.presign.annotation.OssPresignUrl; +import com.viewsh.module.system.controller.admin.dept.vo.dept.DeptSimpleRespVO; +import com.viewsh.module.system.controller.admin.dept.vo.post.PostSimpleRespVO; +import com.viewsh.module.system.controller.admin.permission.vo.role.RoleSimpleRespVO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +@Schema(description = "管理后台 - 用户个人中心信息 Response VO") +public class UserProfileRespVO { + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long id; + + @Schema(description = "用户账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "viewsh") + private String username; + + @Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋艿") + private String nickname; + + @Schema(description = "用户邮箱", example = "viewsh@iocoder.cn") + private String email; + + @Schema(description = "手机号码", example = "15601691300") + private String mobile; + + @Schema(description = "用户性别,参见 SexEnum 枚举类", example = "1") + private Integer sex; + + @Schema(description = "用户头像", example = "https://www.iocoder.cn/xxx.png") + @OssPresignUrl + private String avatar; + + @Schema(description = "最后登录 IP", requiredMode = Schema.RequiredMode.REQUIRED, example = "192.168.1.1") + private String loginIp; + + @Schema(description = "最后登录时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "时间戳格式") + private LocalDateTime loginDate; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "时间戳格式") + private LocalDateTime createTime; + + /** + * 所属角色 + */ + private List roles; + /** + * 所在部门 + */ + private DeptSimpleRespVO dept; + /** + * 所属岗位数组 + */ + private List posts; + +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/user/vo/user/UserRespVO.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/user/vo/user/UserRespVO.java index dedcafb..e39bb0d 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/user/vo/user/UserRespVO.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/user/vo/user/UserRespVO.java @@ -1,75 +1,77 @@ -package com.viewsh.module.system.controller.admin.user.vo.user; - -import com.viewsh.framework.excel.core.annotations.DictFormat; -import com.viewsh.framework.excel.core.convert.DictConvert; -import com.viewsh.module.system.enums.DictTypeConstants; -import cn.idev.excel.annotation.ExcelIgnoreUnannotated; -import cn.idev.excel.annotation.ExcelProperty; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; - -import java.time.LocalDateTime; -import java.util.Set; - -@Schema(description = "管理后台 - 用户信息 Response VO") -@Data -@ExcelIgnoreUnannotated -public class UserRespVO{ - - @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") - @ExcelProperty("用户编号") - private Long id; - - @Schema(description = "用户账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "viewsh") - @ExcelProperty("用户名称") - private String username; - - @Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋艿") - @ExcelProperty("用户昵称") - private String nickname; - - @Schema(description = "备注", example = "我是一个用户") - private String remark; - - @Schema(description = "部门ID", example = "我是一个用户") - private Long deptId; - @Schema(description = "部门名称", example = "IT 部") - @ExcelProperty("部门名称") - private String deptName; - - @Schema(description = "岗位编号数组", example = "1") - private Set postIds; - - @Schema(description = "用户邮箱", example = "viewsh@iocoder.cn") - @ExcelProperty("用户邮箱") - private String email; - - @Schema(description = "手机号码", example = "15601691300") - @ExcelProperty("手机号码") - private String mobile; - - @Schema(description = "用户性别,参见 SexEnum 枚举类", example = "1") - @ExcelProperty(value = "用户性别", converter = DictConvert.class) - @DictFormat(DictTypeConstants.USER_SEX) - private Integer sex; - - @Schema(description = "用户头像", example = "https://www.iocoder.cn/xxx.png") - private String avatar; - - @Schema(description = "状态,参见 CommonStatusEnum 枚举类", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") - @ExcelProperty(value = "帐号状态", converter = DictConvert.class) - @DictFormat(DictTypeConstants.COMMON_STATUS) - private Integer status; - - @Schema(description = "最后登录 IP", requiredMode = Schema.RequiredMode.REQUIRED, example = "192.168.1.1") - @ExcelProperty("最后登录IP") - private String loginIp; - - @Schema(description = "最后登录时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "时间戳格式") - @ExcelProperty("最后登录时间") - private LocalDateTime loginDate; - - @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "时间戳格式") - private LocalDateTime createTime; - -} +package com.viewsh.module.system.controller.admin.user.vo.user; + +import com.viewsh.framework.excel.core.annotations.DictFormat; +import com.viewsh.framework.excel.core.convert.DictConvert; +import com.viewsh.framework.web.core.presign.annotation.OssPresignUrl; +import com.viewsh.module.system.enums.DictTypeConstants; +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.Set; + +@Schema(description = "管理后台 - 用户信息 Response VO") +@Data +@ExcelIgnoreUnannotated +public class UserRespVO{ + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @ExcelProperty("用户编号") + private Long id; + + @Schema(description = "用户账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "viewsh") + @ExcelProperty("用户名称") + private String username; + + @Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋艿") + @ExcelProperty("用户昵称") + private String nickname; + + @Schema(description = "备注", example = "我是一个用户") + private String remark; + + @Schema(description = "部门ID", example = "我是一个用户") + private Long deptId; + @Schema(description = "部门名称", example = "IT 部") + @ExcelProperty("部门名称") + private String deptName; + + @Schema(description = "岗位编号数组", example = "1") + private Set postIds; + + @Schema(description = "用户邮箱", example = "viewsh@iocoder.cn") + @ExcelProperty("用户邮箱") + private String email; + + @Schema(description = "手机号码", example = "15601691300") + @ExcelProperty("手机号码") + private String mobile; + + @Schema(description = "用户性别,参见 SexEnum 枚举类", example = "1") + @ExcelProperty(value = "用户性别", converter = DictConvert.class) + @DictFormat(DictTypeConstants.USER_SEX) + private Integer sex; + + @Schema(description = "用户头像", example = "https://www.iocoder.cn/xxx.png") + @OssPresignUrl + private String avatar; + + @Schema(description = "状态,参见 CommonStatusEnum 枚举类", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @ExcelProperty(value = "帐号状态", converter = DictConvert.class) + @DictFormat(DictTypeConstants.COMMON_STATUS) + private Integer status; + + @Schema(description = "最后登录 IP", requiredMode = Schema.RequiredMode.REQUIRED, example = "192.168.1.1") + @ExcelProperty("最后登录IP") + private String loginIp; + + @Schema(description = "最后登录时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "时间戳格式") + @ExcelProperty("最后登录时间") + private LocalDateTime loginDate; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "时间戳格式") + private LocalDateTime createTime; + +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/test/java/com/viewsh/module/system/service/social/SocialUserServiceImplTest.java b/viewsh-module-system/viewsh-module-system-server/src/test/java/com/viewsh/module/system/service/social/SocialUserServiceImplTest.java index ac00f09..ecc9b1d 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/test/java/com/viewsh/module/system/service/social/SocialUserServiceImplTest.java +++ b/viewsh-module-system/viewsh-module-system-server/src/test/java/com/viewsh/module/system/service/social/SocialUserServiceImplTest.java @@ -1,288 +1,291 @@ -package com.viewsh.module.system.service.social; - -import com.viewsh.framework.common.enums.UserTypeEnum; -import com.viewsh.framework.common.pojo.PageResult; -import com.viewsh.framework.test.core.ut.BaseDbUnitTest; -import com.viewsh.module.system.api.social.dto.SocialUserBindReqDTO; -import com.viewsh.module.system.api.social.dto.SocialUserRespDTO; -import com.viewsh.module.system.controller.admin.socail.vo.user.SocialUserPageReqVO; -import com.viewsh.module.system.dal.dataobject.social.SocialUserBindDO; -import com.viewsh.module.system.dal.dataobject.social.SocialUserDO; -import com.viewsh.module.system.dal.mysql.social.SocialUserBindMapper; -import com.viewsh.module.system.dal.mysql.social.SocialUserMapper; -import com.viewsh.module.system.enums.social.SocialTypeEnum; -import jakarta.annotation.Resource; -import me.zhyd.oauth.model.AuthUser; -import org.junit.jupiter.api.Test; -import org.springframework.context.annotation.Import; -import org.springframework.test.context.bean.override.mockito.MockitoBean; - -import java.util.List; - -import static cn.hutool.core.util.RandomUtil.randomEle; -import static cn.hutool.core.util.RandomUtil.randomLong; -import static com.viewsh.framework.common.util.date.LocalDateTimeUtils.buildBetweenTime; -import static com.viewsh.framework.common.util.date.LocalDateTimeUtils.buildTime; -import static com.viewsh.framework.common.util.json.JsonUtils.toJsonString; -import static com.viewsh.framework.common.util.object.ObjectUtils.cloneIgnoreId; -import static com.viewsh.framework.test.core.util.AssertUtils.assertPojoEquals; -import static com.viewsh.framework.test.core.util.AssertUtils.assertServiceException; -import static com.viewsh.framework.test.core.util.RandomUtils.randomPojo; -import static com.viewsh.framework.test.core.util.RandomUtils.randomString; -import static com.viewsh.module.system.enums.ErrorCodeConstants.SOCIAL_USER_NOT_FOUND; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.eq; -import static org.mockito.Mockito.when; - -/** - * {@link SocialUserServiceImpl} 的单元测试类 - * - * @author 芋道源码 - */ -@Import(SocialUserServiceImpl.class) -public class SocialUserServiceImplTest extends BaseDbUnitTest { - - @Resource - private SocialUserServiceImpl socialUserService; - - @Resource - private SocialUserMapper socialUserMapper; - @Resource - private SocialUserBindMapper socialUserBindMapper; - - @MockitoBean - private SocialClientService socialClientService; - - @Test - public void testGetSocialUserList() { - Long userId = 1L; - Integer userType = UserTypeEnum.ADMIN.getValue(); - // mock 获得社交用户 - SocialUserDO socialUser = randomPojo(SocialUserDO.class).setType(SocialTypeEnum.GITEE.getType()); - socialUserMapper.insert(socialUser); // 可被查到 - socialUserMapper.insert(randomPojo(SocialUserDO.class)); // 不可被查到 - // mock 获得绑定 - socialUserBindMapper.insert(randomPojo(SocialUserBindDO.class) // 可被查询到 - .setUserId(userId).setUserType(userType).setSocialType(SocialTypeEnum.GITEE.getType()) - .setSocialUserId(socialUser.getId())); - socialUserBindMapper.insert(randomPojo(SocialUserBindDO.class) // 不可被查询到 - .setUserId(2L).setUserType(userType).setSocialType(SocialTypeEnum.DINGTALK.getType())); - - // 调用 - List result = socialUserService.getSocialUserList(userId, userType); - // 断言 - assertEquals(1, result.size()); - assertPojoEquals(socialUser, result.get(0)); - } - - @Test - public void testBindSocialUser() { - // 准备参数 - SocialUserBindReqDTO reqDTO = new SocialUserBindReqDTO() - .setUserId(1L).setUserType(UserTypeEnum.ADMIN.getValue()) - .setSocialType(SocialTypeEnum.GITEE.getType()).setCode("test_code").setState("test_state"); - // mock 数据:获得社交用户 - SocialUserDO socialUser = randomPojo(SocialUserDO.class).setType(reqDTO.getSocialType()) - .setCode(reqDTO.getCode()).setState(reqDTO.getState()); - socialUserMapper.insert(socialUser); - // mock 数据:用户可能之前已经绑定过该社交类型 - socialUserBindMapper.insert(randomPojo(SocialUserBindDO.class).setUserId(1L).setUserType(UserTypeEnum.ADMIN.getValue()) - .setSocialType(SocialTypeEnum.GITEE.getType()).setSocialUserId(-1L)); - // mock 数据:社交用户可能之前绑定过别的用户 - socialUserBindMapper.insert(randomPojo(SocialUserBindDO.class).setUserType(UserTypeEnum.ADMIN.getValue()) - .setSocialType(SocialTypeEnum.GITEE.getType()).setSocialUserId(socialUser.getId())); - - // 调用 - String openid = socialUserService.bindSocialUser(reqDTO); - // 断言 - List socialUserBinds = socialUserBindMapper.selectList(); - assertEquals(1, socialUserBinds.size()); - assertEquals(socialUser.getOpenid(), openid); - } - - @Test - public void testUnbindSocialUser_success() { - // 准备参数 - Long userId = 1L; - Integer userType = UserTypeEnum.ADMIN.getValue(); - Integer type = SocialTypeEnum.GITEE.getType(); - String openid = "test_openid"; - // mock 数据:社交用户 - SocialUserDO socialUser = randomPojo(SocialUserDO.class).setType(type).setOpenid(openid); - socialUserMapper.insert(socialUser); - // mock 数据:社交绑定关系 - SocialUserBindDO socialUserBind = randomPojo(SocialUserBindDO.class).setUserType(userType) - .setUserId(userId).setSocialType(type); - socialUserBindMapper.insert(socialUserBind); - - // 调用 - socialUserService.unbindSocialUser(userId, userType, type, openid); - // 断言 - assertEquals(0, socialUserBindMapper.selectCount(null).intValue()); - } - - @Test - public void testUnbindSocialUser_notFound() { - // 调用,并断言 - assertServiceException( - () -> socialUserService.unbindSocialUser(randomLong(), UserTypeEnum.ADMIN.getValue(), - SocialTypeEnum.GITEE.getType(), "test_openid"), - SOCIAL_USER_NOT_FOUND); - } - - @Test - public void testGetSocialUser() { - // 准备参数 - Integer userType = UserTypeEnum.ADMIN.getValue(); - Integer type = SocialTypeEnum.GITEE.getType(); - String code = "tudou"; - String state = "yuanma"; - // mock 社交用户 - SocialUserDO socialUserDO = randomPojo(SocialUserDO.class).setType(type).setCode(code).setState(state); - socialUserMapper.insert(socialUserDO); - // mock 社交用户的绑定 - Long userId = randomLong(); - SocialUserBindDO socialUserBind = randomPojo(SocialUserBindDO.class).setUserType(userType).setUserId(userId) - .setSocialType(type).setSocialUserId(socialUserDO.getId()); - socialUserBindMapper.insert(socialUserBind); - - // 调用 - SocialUserRespDTO socialUser = socialUserService.getSocialUserByCode(userType, type, code, state); - // 断言 - assertEquals(userId, socialUser.getUserId()); - assertEquals(socialUserDO.getOpenid(), socialUser.getOpenid()); - } - - @Test - public void testAuthSocialUser_exists() { - // 准备参数 - Integer socialType = SocialTypeEnum.GITEE.getType(); - Integer userType = randomEle(SocialTypeEnum.values()).getType(); - String code = "tudou"; - String state = "yuanma"; - // mock 方法 - SocialUserDO socialUser = randomPojo(SocialUserDO.class).setType(socialType).setCode(code).setState(state); - socialUserMapper.insert(socialUser); - - // 调用 - SocialUserDO result = socialUserService.authSocialUser(socialType, userType, code, state); - // 断言 - assertPojoEquals(socialUser, result); - } - - @Test - public void testAuthSocialUser_notNull() { - // mock 数据 - SocialUserDO socialUser = randomPojo(SocialUserDO.class, - o -> o.setType(SocialTypeEnum.GITEE.getType()).setCode("tudou").setState("yuanma")); - socialUserMapper.insert(socialUser); - // 准备参数 - Integer socialType = SocialTypeEnum.GITEE.getType(); - Integer userType = randomEle(SocialTypeEnum.values()).getType(); - String code = "tudou"; - String state = "yuanma"; - - // 调用 - SocialUserDO result = socialUserService.authSocialUser(socialType, userType, code, state); - // 断言 - assertPojoEquals(socialUser, result); - } - - @Test - public void testAuthSocialUser_insert() { - // 准备参数 - Integer socialType = SocialTypeEnum.GITEE.getType(); - Integer userType = randomEle(SocialTypeEnum.values()).getType(); - String code = "tudou"; - String state = "yuanma"; - // mock 方法 - AuthUser authUser = randomPojo(AuthUser.class); - when(socialClientService.getAuthUser(eq(socialType), eq(userType), eq(code), eq(state))).thenReturn(authUser); - - // 调用 - SocialUserDO result = socialUserService.authSocialUser(socialType, userType, code, state); - // 断言 - assertBindSocialUser(socialType, result, authUser); - assertEquals(code, result.getCode()); - assertEquals(state, result.getState()); - } - - @Test - public void testAuthSocialUser_update() { - // 准备参数 - Integer socialType = SocialTypeEnum.GITEE.getType(); - Integer userType = randomEle(SocialTypeEnum.values()).getType(); - String code = "tudou"; - String state = "yuanma"; - // mock 数据 - socialUserMapper.insert(randomPojo(SocialUserDO.class).setType(socialType).setOpenid("test_openid")); - // mock 方法 - AuthUser authUser = randomPojo(AuthUser.class); - when(socialClientService.getAuthUser(eq(socialType), eq(userType), eq(code), eq(state))).thenReturn(authUser); - - // 调用 - SocialUserDO result = socialUserService.authSocialUser(socialType, userType, code, state); - // 断言 - assertBindSocialUser(socialType, result, authUser); - assertEquals(code, result.getCode()); - assertEquals(state, result.getState()); - } - - private void assertBindSocialUser(Integer type, SocialUserDO socialUser, AuthUser authUser) { - assertEquals(authUser.getToken().getAccessToken(), socialUser.getToken()); - assertEquals(toJsonString(authUser.getToken()), socialUser.getRawTokenInfo()); - assertEquals(authUser.getNickname(), socialUser.getNickname()); - assertEquals(authUser.getAvatar(), socialUser.getAvatar()); - assertEquals(toJsonString(authUser.getRawUserInfo()), socialUser.getRawUserInfo()); - assertEquals(type, socialUser.getType()); - assertEquals(authUser.getUuid(), socialUser.getOpenid()); - } - - @Test - public void testGetSocialUser_id() { - // mock 数据 - SocialUserDO socialUserDO = randomPojo(SocialUserDO.class); - socialUserMapper.insert(socialUserDO); - // 参数准备 - Long id = socialUserDO.getId(); - - // 调用 - SocialUserDO dbSocialUserDO = socialUserService.getSocialUser(id); - // 断言 - assertPojoEquals(socialUserDO, dbSocialUserDO); - } - - @Test - public void testGetSocialUserPage() { - // mock 数据 - SocialUserDO dbSocialUser = randomPojo(SocialUserDO.class, o -> { // 等会查询到 - o.setType(SocialTypeEnum.GITEE.getType()); - o.setNickname("芋艿"); - o.setOpenid("viewshyuanma"); - o.setCreateTime(buildTime(2020, 1, 15)); - }); - socialUserMapper.insert(dbSocialUser); - // 测试 type 不匹配 - socialUserMapper.insert(cloneIgnoreId(dbSocialUser, o -> o.setType(SocialTypeEnum.DINGTALK.getType()))); - // 测试 nickname 不匹配 - socialUserMapper.insert(cloneIgnoreId(dbSocialUser, o -> o.setNickname(randomString()))); - // 测试 openid 不匹配 - socialUserMapper.insert(cloneIgnoreId(dbSocialUser, o -> o.setOpenid("java"))); - // 测试 createTime 不匹配 - socialUserMapper.insert(cloneIgnoreId(dbSocialUser, o -> o.setCreateTime(buildTime(2020, 1, 21)))); - // 准备参数 - SocialUserPageReqVO reqVO = new SocialUserPageReqVO(); - reqVO.setType(SocialTypeEnum.GITEE.getType()); - reqVO.setNickname("芋"); - reqVO.setOpenid("viewsh"); - reqVO.setCreateTime(buildBetweenTime(2020, 1, 10, 2020, 1, 20)); - - // 调用 - PageResult pageResult = socialUserService.getSocialUserPage(reqVO); - // 断言 - assertEquals(1, pageResult.getTotal()); - assertEquals(1, pageResult.getList().size()); - assertPojoEquals(dbSocialUser, pageResult.getList().get(0)); - } - -} +package com.viewsh.module.system.service.social; + +import com.viewsh.framework.common.enums.UserTypeEnum; +import com.viewsh.framework.common.pojo.PageResult; +import com.viewsh.framework.test.core.ut.BaseDbUnitTest; +import com.viewsh.module.system.api.social.dto.SocialUserBindReqDTO; +import com.viewsh.module.system.api.social.dto.SocialUserRespDTO; +import com.viewsh.module.system.controller.admin.socail.vo.user.SocialUserPageReqVO; +import com.viewsh.module.system.controller.admin.socail.vo.user.SocialUserRespVO; +import com.viewsh.module.system.dal.dataobject.social.SocialUserBindDO; +import com.viewsh.module.system.dal.dataobject.social.SocialUserDO; +import com.viewsh.module.system.dal.mysql.social.SocialUserBindMapper; +import com.viewsh.module.system.dal.mysql.social.SocialUserMapper; +import com.viewsh.module.system.enums.social.SocialTypeEnum; +import jakarta.annotation.Resource; +import me.zhyd.oauth.model.AuthUser; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.List; + +import static cn.hutool.core.util.RandomUtil.randomEle; +import static cn.hutool.core.util.RandomUtil.randomLong; +import static com.viewsh.framework.common.util.date.LocalDateTimeUtils.buildBetweenTime; +import static com.viewsh.framework.common.util.date.LocalDateTimeUtils.buildTime; +import static com.viewsh.framework.common.util.json.JsonUtils.toJsonString; +import static com.viewsh.framework.common.util.object.ObjectUtils.cloneIgnoreId; +import static com.viewsh.framework.test.core.util.AssertUtils.assertPojoEquals; +import static com.viewsh.framework.test.core.util.AssertUtils.assertServiceException; +import static com.viewsh.framework.test.core.util.RandomUtils.randomPojo; +import static com.viewsh.framework.test.core.util.RandomUtils.randomString; +import static com.viewsh.module.system.enums.ErrorCodeConstants.SOCIAL_USER_NOT_FOUND; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.when; + +/** + * {@link SocialUserServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(SocialUserServiceImpl.class) +public class SocialUserServiceImplTest extends BaseDbUnitTest { + + @Resource + private SocialUserServiceImpl socialUserService; + + @Resource + private SocialUserMapper socialUserMapper; + @Resource + private SocialUserBindMapper socialUserBindMapper; + + @MockitoBean + private SocialClientService socialClientService; + + @Test + public void testGetSocialUserBindList() { + Long userId = 1L; + Integer userType = UserTypeEnum.ADMIN.getValue(); + // mock 获得社交用户 + SocialUserDO socialUser = randomPojo(SocialUserDO.class).setType(SocialTypeEnum.GITEE.getType()); + socialUserMapper.insert(socialUser); // 可被查到 + socialUserMapper.insert(randomPojo(SocialUserDO.class)); // 不可被查到 + // mock 获得绑定 + socialUserBindMapper.insert(randomPojo(SocialUserBindDO.class) // 可被查询到 + .setUserId(userId).setUserType(userType).setSocialType(SocialTypeEnum.GITEE.getType()) + .setSocialUserId(socialUser.getId())); + socialUserBindMapper.insert(randomPojo(SocialUserBindDO.class) // 不可被查询到 + .setUserId(2L).setUserType(userType).setSocialType(SocialTypeEnum.DINGTALK.getType())); + + // 调用 + List result = socialUserService.getSocialUserBindList(userId, userType); + // 断言 + assertEquals(1, result.size()); + assertEquals(socialUser.getId(), result.get(0).getId()); + assertEquals(socialUser.getType(), result.get(0).getType()); + assertEquals(socialUser.getOpenid(), result.get(0).getOpenid()); + } + + @Test + public void testBindSocialUser() { + // 准备参数 + SocialUserBindReqDTO reqDTO = new SocialUserBindReqDTO() + .setUserId(1L).setUserType(UserTypeEnum.ADMIN.getValue()) + .setSocialType(SocialTypeEnum.GITEE.getType()).setCode("test_code").setState("test_state"); + // mock 数据:获得社交用户 + SocialUserDO socialUser = randomPojo(SocialUserDO.class).setType(reqDTO.getSocialType()) + .setCode(reqDTO.getCode()).setState(reqDTO.getState()); + socialUserMapper.insert(socialUser); + // mock 数据:用户可能之前已经绑定过该社交类型 + socialUserBindMapper.insert(randomPojo(SocialUserBindDO.class).setUserId(1L).setUserType(UserTypeEnum.ADMIN.getValue()) + .setSocialType(SocialTypeEnum.GITEE.getType()).setSocialUserId(-1L)); + // mock 数据:社交用户可能之前绑定过别的用户 + socialUserBindMapper.insert(randomPojo(SocialUserBindDO.class).setUserType(UserTypeEnum.ADMIN.getValue()) + .setSocialType(SocialTypeEnum.GITEE.getType()).setSocialUserId(socialUser.getId())); + + // 调用 + String openid = socialUserService.bindSocialUser(reqDTO); + // 断言 + List socialUserBinds = socialUserBindMapper.selectList(); + assertEquals(1, socialUserBinds.size()); + assertEquals(socialUser.getOpenid(), openid); + } + + @Test + public void testUnbindSocialUser_success() { + // 准备参数 + Long userId = 1L; + Integer userType = UserTypeEnum.ADMIN.getValue(); + Integer type = SocialTypeEnum.GITEE.getType(); + String openid = "test_openid"; + // mock 数据:社交用户 + SocialUserDO socialUser = randomPojo(SocialUserDO.class).setType(type).setOpenid(openid); + socialUserMapper.insert(socialUser); + // mock 数据:社交绑定关系 + SocialUserBindDO socialUserBind = randomPojo(SocialUserBindDO.class).setUserType(userType) + .setUserId(userId).setSocialType(type); + socialUserBindMapper.insert(socialUserBind); + + // 调用 + socialUserService.unbindSocialUser(userId, userType, type, openid); + // 断言 + assertEquals(0, socialUserBindMapper.selectCount(null).intValue()); + } + + @Test + public void testUnbindSocialUser_notFound() { + // 调用,并断言 + assertServiceException( + () -> socialUserService.unbindSocialUser(randomLong(), UserTypeEnum.ADMIN.getValue(), + SocialTypeEnum.GITEE.getType(), "test_openid"), + SOCIAL_USER_NOT_FOUND); + } + + @Test + public void testGetSocialUser() { + // 准备参数 + Integer userType = UserTypeEnum.ADMIN.getValue(); + Integer type = SocialTypeEnum.GITEE.getType(); + String code = "tudou"; + String state = "yuanma"; + // mock 社交用户 + SocialUserDO socialUserDO = randomPojo(SocialUserDO.class).setType(type).setCode(code).setState(state); + socialUserMapper.insert(socialUserDO); + // mock 社交用户的绑定 + Long userId = randomLong(); + SocialUserBindDO socialUserBind = randomPojo(SocialUserBindDO.class).setUserType(userType).setUserId(userId) + .setSocialType(type).setSocialUserId(socialUserDO.getId()); + socialUserBindMapper.insert(socialUserBind); + + // 调用 + SocialUserRespDTO socialUser = socialUserService.getSocialUserByCode(userType, type, code, state); + // 断言 + assertEquals(userId, socialUser.getUserId()); + assertEquals(socialUserDO.getOpenid(), socialUser.getOpenid()); + } + + @Test + public void testAuthSocialUser_exists() { + // 准备参数 + Integer socialType = SocialTypeEnum.GITEE.getType(); + Integer userType = randomEle(SocialTypeEnum.values()).getType(); + String code = "tudou"; + String state = "yuanma"; + // mock 方法 + SocialUserDO socialUser = randomPojo(SocialUserDO.class).setType(socialType).setCode(code).setState(state); + socialUserMapper.insert(socialUser); + + // 调用 + SocialUserDO result = socialUserService.authSocialUser(socialType, userType, code, state); + // 断言 + assertPojoEquals(socialUser, result); + } + + @Test + public void testAuthSocialUser_notNull() { + // mock 数据 + SocialUserDO socialUser = randomPojo(SocialUserDO.class, + o -> o.setType(SocialTypeEnum.GITEE.getType()).setCode("tudou").setState("yuanma")); + socialUserMapper.insert(socialUser); + // 准备参数 + Integer socialType = SocialTypeEnum.GITEE.getType(); + Integer userType = randomEle(SocialTypeEnum.values()).getType(); + String code = "tudou"; + String state = "yuanma"; + + // 调用 + SocialUserDO result = socialUserService.authSocialUser(socialType, userType, code, state); + // 断言 + assertPojoEquals(socialUser, result); + } + + @Test + public void testAuthSocialUser_insert() { + // 准备参数 + Integer socialType = SocialTypeEnum.GITEE.getType(); + Integer userType = randomEle(SocialTypeEnum.values()).getType(); + String code = "tudou"; + String state = "yuanma"; + // mock 方法 + AuthUser authUser = randomPojo(AuthUser.class); + when(socialClientService.getAuthUser(eq(socialType), eq(userType), eq(code), eq(state))).thenReturn(authUser); + + // 调用 + SocialUserDO result = socialUserService.authSocialUser(socialType, userType, code, state); + // 断言 + assertBindSocialUser(socialType, result, authUser); + assertEquals(code, result.getCode()); + assertEquals(state, result.getState()); + } + + @Test + public void testAuthSocialUser_update() { + // 准备参数 + Integer socialType = SocialTypeEnum.GITEE.getType(); + Integer userType = randomEle(SocialTypeEnum.values()).getType(); + String code = "tudou"; + String state = "yuanma"; + // mock 数据 + socialUserMapper.insert(randomPojo(SocialUserDO.class).setType(socialType).setOpenid("test_openid")); + // mock 方法 + AuthUser authUser = randomPojo(AuthUser.class); + when(socialClientService.getAuthUser(eq(socialType), eq(userType), eq(code), eq(state))).thenReturn(authUser); + + // 调用 + SocialUserDO result = socialUserService.authSocialUser(socialType, userType, code, state); + // 断言 + assertBindSocialUser(socialType, result, authUser); + assertEquals(code, result.getCode()); + assertEquals(state, result.getState()); + } + + private void assertBindSocialUser(Integer type, SocialUserDO socialUser, AuthUser authUser) { + assertEquals(authUser.getToken().getAccessToken(), socialUser.getToken()); + assertEquals(toJsonString(authUser.getToken()), socialUser.getRawTokenInfo()); + assertEquals(authUser.getNickname(), socialUser.getNickname()); + assertEquals(authUser.getAvatar(), socialUser.getAvatar()); + assertEquals(toJsonString(authUser.getRawUserInfo()), socialUser.getRawUserInfo()); + assertEquals(type, socialUser.getType()); + assertEquals(authUser.getUuid(), socialUser.getOpenid()); + } + + @Test + public void testGetSocialUser_id() { + // mock 数据 + SocialUserDO socialUserDO = randomPojo(SocialUserDO.class); + socialUserMapper.insert(socialUserDO); + // 参数准备 + Long id = socialUserDO.getId(); + + // 调用 + SocialUserDO dbSocialUserDO = socialUserService.getSocialUser(id); + // 断言 + assertPojoEquals(socialUserDO, dbSocialUserDO); + } + + @Test + public void testGetSocialUserPage() { + // mock 数据 + SocialUserDO dbSocialUser = randomPojo(SocialUserDO.class, o -> { // 等会查询到 + o.setType(SocialTypeEnum.GITEE.getType()); + o.setNickname("芋艿"); + o.setOpenid("viewshyuanma"); + o.setCreateTime(buildTime(2020, 1, 15)); + }); + socialUserMapper.insert(dbSocialUser); + // 测试 type 不匹配 + socialUserMapper.insert(cloneIgnoreId(dbSocialUser, o -> o.setType(SocialTypeEnum.DINGTALK.getType()))); + // 测试 nickname 不匹配 + socialUserMapper.insert(cloneIgnoreId(dbSocialUser, o -> o.setNickname(randomString()))); + // 测试 openid 不匹配 + socialUserMapper.insert(cloneIgnoreId(dbSocialUser, o -> o.setOpenid("java"))); + // 测试 createTime 不匹配 + socialUserMapper.insert(cloneIgnoreId(dbSocialUser, o -> o.setCreateTime(buildTime(2020, 1, 21)))); + // 准备参数 + SocialUserPageReqVO reqVO = new SocialUserPageReqVO(); + reqVO.setType(SocialTypeEnum.GITEE.getType()); + reqVO.setNickname("芋"); + reqVO.setOpenid("viewsh"); + reqVO.setCreateTime(buildBetweenTime(2020, 1, 10, 2020, 1, 20)); + + // 调用 + PageResult pageResult = socialUserService.getSocialUserPage(reqVO); + // 断言 + assertEquals(1, pageResult.getTotal()); + assertEquals(1, pageResult.getList().size()); + assertPojoEquals(dbSocialUser, pageResult.getList().get(0)); + } + +} From 88533c9d69b42394b1a3512660c51b7788166ae0 Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 18 Mar 2026 15:07:03 +0800 Subject: [PATCH 35/35] =?UTF-8?q?refactor(ops):=20=E5=AE=89=E4=BF=9D?= =?UTF-8?q?=E5=B7=A5=E5=8D=95=E5=9B=BE=E7=89=87=E9=A2=84=E7=AD=BE=E5=90=8D?= =?UTF-8?q?=E4=B8=8B=E6=B2=89=E8=87=B3=20SecurityOrderExtQueryHandler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 OrderCenterController 中的 presignExtInfoImageUrls 方法移除, 预签名逻辑下沉至 SecurityOrderExtQueryHandler 数据组装阶段, 通过 OssPresignHelper 就地处理 imageUrl 和 resultImgUrls。 security-biz 新增 infra-api 依赖,RpcConfiguration 注册 FileApi。 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../admin/OrderCenterController.java | 50 +------------------ .../rpc/config/RpcConfiguration.java | 4 +- .../viewsh-module-security-biz/pom.xml | 7 +++ .../SecurityOrderExtQueryHandler.java | 8 ++- 4 files changed, 17 insertions(+), 52 deletions(-) diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/OrderCenterController.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/OrderCenterController.java index 42b3e9b..8f47601 100644 --- a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/OrderCenterController.java +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/OrderCenterController.java @@ -1,9 +1,7 @@ package com.viewsh.module.ops.controller.admin; -import cn.hutool.core.util.StrUtil; import com.viewsh.framework.common.pojo.CommonResult; import com.viewsh.framework.common.pojo.PageResult; -import com.viewsh.module.infra.api.file.FileApi; import com.viewsh.module.ops.api.clean.QuickStatsRespDTO; import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.DashboardStatsRespVO; import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.WorkspaceStatsRespVO; @@ -25,9 +23,7 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.time.LocalDate; -import java.util.List; import java.util.Map; -import java.util.stream.Collectors; import static com.viewsh.framework.common.pojo.CommonResult.success; @@ -47,8 +43,6 @@ public class OrderCenterController { @Resource private OrderQueryService orderQueryService; - @Resource - private FileApi fileApi; @Autowired(required = false) private CleanDashboardService cleanDashboardService; @@ -69,10 +63,7 @@ public class OrderCenterController { @Parameter(name = "id", description = "工单ID", required = true) @PreAuthorize("@ss.hasPermission('ops:order-center:query')") public CommonResult getDetail(@PathVariable("id") Long id) { - OrderDetailVO detail = orderQueryService.getDetail(id); - // 私有桶:对 extInfo 中的图片 URL 生成预签名访问地址 - presignExtInfoImageUrls(detail); - return success(detail); + return success(orderQueryService.getDetail(id)); } @GetMapping("/stats") @@ -135,43 +126,4 @@ public class OrderCenterController { return success(opsStatisticsService.getWorkspaceStats()); } - /** - * 对 extInfo 中的图片 URL 生成预签名访问地址 - */ - private void presignExtInfoImageUrls(OrderDetailVO detail) { - if (detail == null || detail.getExtInfo() == null) { - return; - } - Map extInfo = detail.getExtInfo(); - // imageUrl:单个告警截图 - Object imageUrl = extInfo.get("imageUrl"); - if (imageUrl instanceof String url && StrUtil.isNotEmpty(url)) { - try { - extInfo.put("imageUrl", fileApi.presignGetUrl(url, null).getCheckedData()); - } catch (Exception e) { - log.warn("[presignExtInfoImageUrls] imageUrl 签名失败: {}", url, e); - } - } - // resultImgUrls:处理结果图片,JSON 数组字符串 如 ["url1","url2"] - Object resultImgUrls = extInfo.get("resultImgUrls"); - if (resultImgUrls instanceof String urlsJson && StrUtil.isNotEmpty(urlsJson)) { - try { - List urls = cn.hutool.json.JSONUtil.toList(urlsJson, String.class); - List signedUrls = urls.stream() - .map(u -> { - try { - return fileApi.presignGetUrl(u, null).getCheckedData(); - } catch (Exception e) { - log.warn("[presignExtInfoImageUrls] resultImgUrl 签名失败: {}", u, e); - return u; - } - }) - .collect(Collectors.toList()); - extInfo.put("resultImgUrls", cn.hutool.json.JSONUtil.toJsonStr(signedUrls)); - } catch (Exception e) { - log.warn("[presignExtInfoImageUrls] 解析 resultImgUrls 失败: {}", resultImgUrls, e); - } - } - } - } diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/rpc/config/RpcConfiguration.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/rpc/config/RpcConfiguration.java index d10bcb9..a9b6bc9 100644 --- a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/rpc/config/RpcConfiguration.java +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/rpc/config/RpcConfiguration.java @@ -1,5 +1,6 @@ package com.viewsh.module.ops.framework.rpc.config; +import com.viewsh.module.infra.api.file.FileApi; import com.viewsh.module.iot.api.device.IotDeviceControlApi; import com.viewsh.module.iot.api.device.IotDeviceQueryApi; import com.viewsh.module.iot.api.device.IotDeviceStatusQueryApi; @@ -12,7 +13,8 @@ import org.springframework.context.annotation.Configuration; NotifyMessageSendApi.class, IotDeviceControlApi.class, IotDeviceQueryApi.class, - IotDeviceStatusQueryApi.class + IotDeviceStatusQueryApi.class, + FileApi.class }) public class RpcConfiguration { } diff --git a/viewsh-module-ops/viewsh-module-security-biz/pom.xml b/viewsh-module-ops/viewsh-module-security-biz/pom.xml index 4203daf..8782196 100644 --- a/viewsh-module-ops/viewsh-module-security-biz/pom.xml +++ b/viewsh-module-ops/viewsh-module-security-biz/pom.xml @@ -31,6 +31,13 @@ ${revision} + + + com.viewsh + viewsh-module-infra-api + ${revision} + + com.viewsh diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderExtQueryHandler.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderExtQueryHandler.java index 84137af..3ce9c25 100644 --- a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderExtQueryHandler.java +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderExtQueryHandler.java @@ -1,5 +1,7 @@ package com.viewsh.module.ops.security.service.securityorder; +import com.viewsh.module.infra.api.file.FileApi; +import com.viewsh.module.infra.api.file.OssPresignHelper; import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO; import com.viewsh.module.ops.security.dal.dataobject.workorder.OpsOrderSecurityExtDO; import com.viewsh.module.ops.security.dal.mysql.workorder.OpsOrderSecurityExtMapper; @@ -27,6 +29,8 @@ public class SecurityOrderExtQueryHandler implements OrderExtQueryHandler { @Resource private OpsOrderSecurityExtMapper securityExtMapper; + @Resource + private FileApi fileApi; private static final String ORDER_TYPE_SECURITY = WorkOrderTypeEnum.SECURITY.getType(); @@ -88,12 +92,12 @@ public class SecurityOrderExtQueryHandler implements OrderExtQueryHandler { extInfo.put("alarmType", ext.getAlarmType()); extInfo.put("cameraId", ext.getCameraId()); extInfo.put("roiId", ext.getRoiId()); - extInfo.put("imageUrl", ext.getImageUrl()); + extInfo.put("imageUrl", OssPresignHelper.presignQuietly(fileApi, ext.getImageUrl())); extInfo.put("assignedUserId", ext.getAssignedUserId()); extInfo.put("assignedUserName", ext.getAssignedUserName()); extInfo.put("assignedTeamId", ext.getAssignedTeamId()); extInfo.put("result", ext.getResult()); - extInfo.put("resultImgUrls", ext.getResultImgUrls()); + extInfo.put("resultImgUrls", OssPresignHelper.presignJsonArrayQuietly(fileApi, ext.getResultImgUrls())); extInfo.put("dispatchedTime", ext.getDispatchedTime()); extInfo.put("confirmedTime", ext.getConfirmedTime()); extInfo.put("completedTime", ext.getCompletedTime());