diff --git a/fastbee-admin/src/main/java/com/fastbee/web/controller/system/SysUserSettingController.java b/fastbee-admin/src/main/java/com/fastbee/web/controller/system/SysUserSettingController.java index f12b19a7..f2dd1edf 100644 --- a/fastbee-admin/src/main/java/com/fastbee/web/controller/system/SysUserSettingController.java +++ b/fastbee-admin/src/main/java/com/fastbee/web/controller/system/SysUserSettingController.java @@ -13,14 +13,15 @@ import com.fastbee.system.service.ISysUserSettingService; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.util.List; - /** * 个人系统设置 * @@ -57,10 +58,25 @@ public class SysUserSettingController extends BaseController { return AjaxResult.success(userSettingService.getPinnedTags(SecurityUtils.getUserId())); } - @ApiOperation("保存当前用户固定的任务栏标签") - @PutMapping("/pinnedTags") - public AjaxResult savePinnedTags(@RequestBody List paths) { - userSettingService.savePinnedTags(SecurityUtils.getUserId(), paths, SecurityUtils.getUsername()); + /** + * 固定标签按「单条增量」写入,不再提供整份列表的覆盖写 + * + *

原先的 {@code PUT} 需要前端提交自己内存里的完整列表,而这份列表可能已经过期 + * (另一个标签页/另一台设备刚改过),落库即把对方的改动静默抹掉。 + * 现在前端只发「固定这一个 / 取消这一个」,服务端在事务里锁定读改写, + * 并发操作互不覆盖,也不依赖前端先拉全量列表。 + */ + @ApiOperation("固定一个任务栏标签") + @PostMapping("/pinnedTags") + public AjaxResult addPinnedTag(@RequestParam String path) { + userSettingService.addPinnedTag(SecurityUtils.getUserId(), path, SecurityUtils.getUsername()); + return AjaxResult.success(); + } + + @ApiOperation("取消固定一个任务栏标签") + @DeleteMapping("/pinnedTags") + public AjaxResult removePinnedTag(@RequestParam String path) { + userSettingService.removePinnedTag(SecurityUtils.getUserId(), path, SecurityUtils.getUsername()); return AjaxResult.success(); } diff --git a/fastbee-common/src/main/java/com/fastbee/common/constant/UserSettingConstants.java b/fastbee-common/src/main/java/com/fastbee/common/constant/UserSettingConstants.java index f5b2ee71..d7675767 100644 --- a/fastbee-common/src/main/java/com/fastbee/common/constant/UserSettingConstants.java +++ b/fastbee-common/src/main/java/com/fastbee/common/constant/UserSettingConstants.java @@ -53,6 +53,22 @@ public class UserSettingConstants { */ public static final String EMPTY_PINNED_TAGS = "[]"; + /** + * 是否查询网关子设备的默认值 + * + *

语义:{@code /iot/device/shortList} 按设备编号查询时({@code serialNumber} 非空): + *

    + *
  1. 若命中的是网关子设备编号,则换算为其所属的网关设备({@code ifnull(gw_dev_code, serial_number)});
  2. + *
  3. 放开产品类型为「网关子设备(4)」的过滤 —— 这类设备 {@code gw_dev_code} 为空、实为独立设备, + * 只是产品被标成 4,默认在列表中被隐藏(否则设备管理页会多出一大批),仅在按编号精确定位时可见。
  4. + *
+ *

列表范围本身不变:{@code gw_dev_code is null} 恒生效,不带编号时 {@code device_type != 4} 依然过滤, + * 因此列表中永远不会出现挂在网关下的真子设备,默认列表也不会多出 device_type=4 的设备。 + * 默认 false = 与引入本开关前完全一致。 + *

维度归属:读该值的入口 {@code DeviceController#shortList} 有 SecurityContext,故为 per-user 设置。 + */ + public static final boolean DEFAULT_QUERY_GATEWAY_CHILD = false; + private UserSettingConstants() { } diff --git a/fastbee-open-api/src/main/java/com/fastbee/controller/device/DeviceController.java b/fastbee-open-api/src/main/java/com/fastbee/controller/device/DeviceController.java index b05ba2b3..63821c33 100644 --- a/fastbee-open-api/src/main/java/com/fastbee/controller/device/DeviceController.java +++ b/fastbee-open-api/src/main/java/com/fastbee/controller/device/DeviceController.java @@ -23,6 +23,9 @@ import com.fastbee.iot.model.SerialNumberVO; import com.fastbee.iot.model.dto.ThingsModelDTO; import com.fastbee.iot.model.gateWay.SubDeviceCreateByProductVO; import com.fastbee.iot.service.IDeviceService; +import com.fastbee.iot.utils.SerialNumberUtils; +import com.fastbee.system.domain.SysUserSetting; +import com.fastbee.system.service.ISysUserSettingService; import com.github.pagehelper.PageInfo; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; @@ -56,6 +59,9 @@ public class DeviceController extends BaseController { @Autowired private RedisCache redisCache; + @Autowired + private ISysUserSettingService userSettingService; + /** * 查询设备列表 */ @@ -113,6 +119,16 @@ public class DeviceController extends BaseController { public TableDataInfo shortList(Device device) { startPage(); LoginUser loginUser = getLoginUser(); + // 设备编号支持一次填多个(中英文逗号/换行/空格分隔),统一拆成去重后的 IN 查询集合; + // 单个编号同样得到 size=1 的集合走 IN;serialNumber 为空时集合为空,SQL 不加编号条件。 + device.setSerialNumbers(SerialNumberUtils.split(device.getSerialNumber())); + if (Objects.isNull(device.getQueryGatewayChild())) { + // 未显式指定时,按当前登录用户的个人设置决定「按编号查询时是否查询网关子设备」: + // 命中子设备编号则返回其所属网关,并放开「网关子设备(4)」产品过滤(仅按编号查时)。 + // 维度判据遵循「读该值时能否拿到 userId」:此处有 SecurityContext,故为 per-user 设置。 + SysUserSetting userSetting = userSettingService.selectByUserId(loginUser.getUserId()); + device.setQueryGatewayChild(userSetting != null && Boolean.TRUE.equals(userSetting.getQueryGatewayChild())); + } if (null == loginUser.getDeptId()) { // 终端用户查询设备 device.setTenantId(loginUser.getUserId()); diff --git a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/domain/Device.java b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/domain/Device.java index 89eec527..c0d57142 100644 --- a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/domain/Device.java +++ b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/domain/Device.java @@ -79,6 +79,18 @@ public class Device extends BaseEntity { @Excel(name = "设备编号") private String serialNumber; + /** + * 设备编号集合(批量查询) + * + *

前端一次填入多个编号时,Controller 用 {@code SerialNumberUtils} + * 把 {@link #serialNumber} 拆到本字段,Mapper 走 IN 查询; + * 只有一个编号时不填充本字段,仍走 {@link #serialNumber} 的等值匹配。 + */ + @Transient + @ApiModelProperty("设备编号集合(批量查询用)") + @TableField(exist = false) + private List serialNumbers; + /** * 基准点设备ID(结构安全监测用) * @@ -288,6 +300,11 @@ public class Device extends BaseEntity { @TableField(exist = false) private Boolean showChild; + @Transient + @ApiModelProperty("按编号查询时是否查询网关子设备:命中子设备编号返回其所属网关,并放开「网关子设备(4)」产品过滤(为空时由后端按当前用户的个人设置填充)") + @TableField(exist = false) + private Boolean queryGatewayChild; + @Transient @ApiModelProperty("查询入口(1=设备管理列表(需要查看更新时间) 2=数据联动、项目管理、设备绑定 3=全部设备)") @TableField(exist = false) diff --git a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/platform/service/impl/PlatformDeviceMappingServiceImpl.java b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/platform/service/impl/PlatformDeviceMappingServiceImpl.java index 7272aa7f..28056eb6 100644 --- a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/platform/service/impl/PlatformDeviceMappingServiceImpl.java +++ b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/platform/service/impl/PlatformDeviceMappingServiceImpl.java @@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.fastbee.iot.domain.Device; import com.fastbee.iot.mapper.DeviceMapper; import com.fastbee.iot.platform.domain.*; +import com.fastbee.iot.utils.SerialNumberUtils; import com.fastbee.iot.platform.flow.FlowContext; import com.fastbee.iot.platform.flow.PlatformFlowExecutor; import com.fastbee.iot.platform.mapper.PlatformDeviceMappingMapper; @@ -51,16 +52,23 @@ public class PlatformDeviceMappingServiceImpl extends ServiceImpl selectList(PlatformDeviceMapping query) { - String serialNumber = query.getSerialNumber(); + // 设备编号支持一次填多个(中英文逗号/换行/空格分隔),逐个做模糊匹配后按 OR 组合。 + // 保留原有 LIKE 语义(命中主设备编号或子设备编号),单值场景与改造前完全等价。 + List serialNumbers = SerialNumberUtils.split(query.getSerialNumber()); LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() .eq(query.getPlatformId() != null, PlatformDeviceMapping::getPlatformId, query.getPlatformId()) .eq(query.getDeviceId() != null, PlatformDeviceMapping::getDeviceId, query.getDeviceId()) - .and(StringUtils.hasText(serialNumber), w -> w - .like(PlatformDeviceMapping::getSerialNumber, serialNumber) - .or() - .apply("EXISTS (SELECT 1 FROM platform_device_sub_mapping s " + - "WHERE s.parent_mapping_id = platform_device_mapping.id AND s.sub_serial_number LIKE {0})", - "%" + serialNumber + "%")) + .and(!serialNumbers.isEmpty(), w -> { + for (int i = 0; i < serialNumbers.size(); i++) { + if (i > 0) { + w.or(); + } + String pattern = "%" + serialNumbers.get(i) + "%"; + w.apply("(platform_device_mapping.serial_number LIKE {0} OR EXISTS (SELECT 1 FROM platform_device_sub_mapping s " + + "WHERE s.parent_mapping_id = platform_device_mapping.id AND s.sub_serial_number LIKE {1}))", + pattern, pattern); + } + }) .eq(StringUtils.hasText(query.getSensorType()), PlatformDeviceMapping::getSensorType, query.getSensorType()) .eq(query.getNetStatus() != null, PlatformDeviceMapping::getNetStatus, query.getNetStatus()) .eq(StringUtils.hasText(query.getEnable()), PlatformDeviceMapping::getEnable, query.getEnable()) diff --git a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/utils/SerialNumberUtils.java b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/utils/SerialNumberUtils.java new file mode 100644 index 00000000..8c3caad3 --- /dev/null +++ b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/utils/SerialNumberUtils.java @@ -0,0 +1,52 @@ +package com.fastbee.iot.utils; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 设备编号批量输入解析工具 + * + *

前端允许一次填入多个设备编号,分隔符支持中英文逗号、换行与空格 + * (与批量输入弹框的提示文案一致)。此处统一归一化为去重后的编号集合, + * 供 Mapper 以 IN 查询使用;单个编号沿用原有的等值匹配分支,互不影响。 + * + * @author Sumuxi + */ +public final class SerialNumberUtils { + + /** + * 分隔符:英文逗号 / 中文逗号 / 任意空白(含换行、制表符) + * + *

用 + 合并连续分隔符,避免出现空串元素。 + */ + private static final Pattern SEPARATOR = Pattern.compile("[,,\\s]+"); + + private SerialNumberUtils() { + } + + /** + * 拆分并归一化设备编号 + * + * @param raw 原始输入,可为 null + * @return 去空、去重且保持输入顺序的编号列表;无有效值时返回空列表(非 null) + */ + public static List split(String raw) { + List result = new ArrayList<>(); + if (raw == null) { + return result; + } + // 用 LinkedHashSet 去重并保持输入顺序,避免重复编号导致 IN 列表冗余 + Set distinct = new LinkedHashSet<>(); + for (String item : SEPARATOR.split(raw)) { + String trimmed = item.trim(); + if (!trimmed.isEmpty()) { + distinct.add(trimmed); + } + } + result.addAll(distinct); + return result; + } +} diff --git a/fastbee-service/fastbee-iot-service/src/main/resources/mapper/iot/DeviceMapper.xml b/fastbee-service/fastbee-iot-service/src/main/resources/mapper/iot/DeviceMapper.xml index 87b6aaa1..c8994d15 100644 --- a/fastbee-service/fastbee-iot-service/src/main/resources/mapper/iot/DeviceMapper.xml +++ b/fastbee-service/fastbee-iot-service/src/main/resources/mapper/iot/DeviceMapper.xml @@ -428,7 +428,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" and d.gw_dev_code is null - and (p.device_type is null or p.device_type != 4) + + + and (p.device_type is null or p.device_type != 4) + and g.group_id = #{groupId} and d.device_name like concat('%', #{deviceName}, '%') @@ -441,7 +449,33 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" and d.product_name like concat('%', #{productName}, '%') - and d.serial_number = #{serialNumber} + + + + + + and d.serial_number in ( + select ifnull(sub.gw_dev_code, sub.serial_number) + from iot_device sub + where sub.serial_number in + + #{serialNumberItem} + + ) + + + and d.serial_number in + + #{serialNumberItem} + + + + + and d.serial_number = #{serialNumber} and d.gw_dev_code = #{gwDevCode} and d.status = #{status} and d.is_simulate = #{isSimulate} @@ -1027,7 +1061,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" and d.gw_dev_code is null - and (p.device_type is null or p.device_type != 4) + + + and (p.device_type is null or p.device_type != 4) + and g.group_id = #{groupId} and d.device_name like concat('%', #{deviceName}, '%') @@ -1040,7 +1082,33 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" and d.product_name like concat('%', #{productName}, '%') - and d.serial_number = #{serialNumber} + + + + + + and d.serial_number in ( + select ifnull(sub.gw_dev_code, sub.serial_number) + from iot_device sub + where sub.serial_number in + + #{serialNumberItem} + + ) + + + and d.serial_number in + + #{serialNumberItem} + + + + + and d.serial_number = #{serialNumber} and d.gw_dev_code = #{gwDevCode} and d.status = #{status} and d.is_simulate = #{isSimulate} diff --git a/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/domain/SysUserSetting.java b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/domain/SysUserSetting.java index 3e73555a..09843d7b 100644 --- a/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/domain/SysUserSetting.java +++ b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/domain/SysUserSetting.java @@ -45,6 +45,21 @@ public class SysUserSetting extends BaseEntity { @ApiModelProperty(value = "任务栏固定标签JSON数组") private String pinnedTags; + /** + * 是否查询网关子设备 + * + *

开启后,{@code /iot/device/shortList} 按设备编号查询时: + *

    + *
  1. 命中的若是网关子设备编号,返回其所属的网关设备;
  2. + *
  3. 放开产品类型为「网关子设备(4)」的过滤(这类设备 {@code gw_dev_code} 为空、实为独立设备, + * 只是产品被标成 4,默认隐藏,仅在按编号精确定位时才可见)。
  4. + *
+ *

列表范围本身不变:{@code gw_dev_code is null} 恒生效,不带编号时 {@code device_type != 4} + * 依然过滤,因此列表中永远不会出现「子设备」条目。 + */ + @ApiModelProperty(value = "是否查询网关子设备(按编号查时换算网关并放开子设备类产品过滤)") + private Boolean queryGatewayChild; + public SysUserSetting() { } @@ -59,6 +74,7 @@ public class SysUserSetting extends BaseEntity { setting.setUserId(userId); setting.setLoginRedirect(UserSettingConstants.DEFAULT_LOGIN_REDIRECT); setting.setPinnedTags(UserSettingConstants.EMPTY_PINNED_TAGS); + setting.setQueryGatewayChild(UserSettingConstants.DEFAULT_QUERY_GATEWAY_CHILD); return setting; } @@ -94,6 +110,14 @@ public class SysUserSetting extends BaseEntity { this.pinnedTags = pinnedTags; } + public Boolean getQueryGatewayChild() { + return queryGatewayChild; + } + + public void setQueryGatewayChild(Boolean queryGatewayChild) { + this.queryGatewayChild = queryGatewayChild; + } + @Override public String toString() { return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE) @@ -101,6 +125,7 @@ public class SysUserSetting extends BaseEntity { .append("userId", getUserId()) .append("loginRedirect", getLoginRedirect()) .append("pinnedTags", getPinnedTags()) + .append("queryGatewayChild", getQueryGatewayChild()) .append("createBy", getCreateBy()) .append("createTime", getCreateTime()) .append("updateBy", getUpdateBy()) diff --git a/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/mapper/SysUserSettingMapper.java b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/mapper/SysUserSettingMapper.java index 40f66ea4..4bc6ef2b 100644 --- a/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/mapper/SysUserSettingMapper.java +++ b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/mapper/SysUserSettingMapper.java @@ -2,7 +2,9 @@ package com.fastbee.system.mapper; import com.fastbee.system.domain.LoginRedirectOption; import com.fastbee.system.domain.SysUserSetting; +import org.apache.ibatis.annotations.Param; +import java.util.Date; import java.util.List; /** @@ -29,15 +31,48 @@ public interface SysUserSettingMapper { int upsert(SysUserSetting setting); /** - * 仅更新固定标签列 + * 仅更新固定标签列(整列覆盖) * - *

用户尚无记录时更新 0 行,调用方需自行判断是否改用 insert。 + *

只在 {@code addPinnedTag / removePinnedTag} 的「锁定读 → 改 → 写」事务内调用, + * 入参是**锁内读出并改好的完整数组**。对外不再提供「全量覆盖写」的入口—— + * 那会让两个标签页各自拿旧快照互相覆盖,是丢固定标签的直接成因。 * * @param setting 设置,userId 与 pinnedTags 必填 * @return 影响行数 */ int updatePinnedTags(SysUserSetting setting); + /** + * 确保用户设置行存在:不存在则插入默认行,已存在则不做任何修改 + * + *

固定标签的增量写入要在一行上做「锁定读 → 改 → 写」,行必须先存在。 + * 刻意不用 {@link #upsert}:upsert 的 ON DUPLICATE 子句会覆盖 + * login_redirect / query_gateway_child,把用户已保存的设置冲回默认值。 + * + * @param userId 用户ID + * @param loginRedirect 新行使用的默认登录跳转 + * @param queryGatewayChild 新行使用的默认开关值 + * @param operator 操作人用户名 + * @param now 当前时间 + * @return 影响行数(插入 1 / 已存在 0) + */ + int insertDefaultIfAbsent(@Param("userId") Long userId, + @Param("loginRedirect") String loginRedirect, + @Param("queryGatewayChild") Boolean queryGatewayChild, + @Param("operator") String operator, + @Param("now") Date now); + + /** + * 锁定读取固定标签列(SELECT ... FOR UPDATE) + * + *

必须在事务内调用。固定标签的写入是「读-改-写」,没有行锁时两个并发请求 + * 会各自读到同一份旧值,后写的把先写的覆盖掉。 + * + * @param userId 用户ID + * @return pinned_tags 原始 JSON 字符串;用户无记录时返回 null + */ + String selectPinnedTagsForUpdate(@Param("userId") Long userId); + /** * 查询登录落地页候选项(可作为页签的菜单) * diff --git a/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/service/ISysUserSettingService.java b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/service/ISysUserSettingService.java index 5927aac3..b03426ec 100644 --- a/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/service/ISysUserSettingService.java +++ b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/service/ISysUserSettingService.java @@ -41,15 +41,28 @@ public interface ISysUserSettingService { List listLoginRedirectOptions(); /** - * 保存任务栏固定标签 + * 固定一个任务栏标签 * - *

只落用户右键固定的 pinned 集合;路由自身声明的 affix 不在其中。 + *

增量写入:已固定则原样返回(幂等,**不改动它的位置**),未固定则追加到固定区末尾。 + * 并发安全由服务端「锁定读 → 改 → 写」保证——多个标签页/多端同时固定不会互相覆盖, + * 因为前端不再提交自己那份可能过期的完整列表。 * * @param userId 用户ID - * @param paths 固定标签的路由 path 列表,按固定顺序 + * @param path 要固定的路由 path * @param operator 操作人用户名,写入 update_by */ - void savePinnedTags(Long userId, List paths, String operator); + void addPinnedTag(Long userId, String path, String operator); + + /** + * 取消固定一个任务栏标签 + * + *

增量写入:未固定则不做任何修改(幂等)。 + * + * @param userId 用户ID + * @param path 要取消固定的路由 path + * @param operator 操作人用户名,写入 update_by + */ + void removePinnedTag(Long userId, String path, String operator); /** * 查询任务栏固定标签 diff --git a/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/service/impl/SysUserSettingServiceImpl.java b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/service/impl/SysUserSettingServiceImpl.java index d4e0eed6..35b519cd 100644 --- a/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/service/impl/SysUserSettingServiceImpl.java +++ b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/service/impl/SysUserSettingServiceImpl.java @@ -13,6 +13,7 @@ import com.fastbee.system.service.ISysUserSettingService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; import java.util.Date; @@ -35,7 +36,8 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService { * 缓存值的字段分隔符 * *

用 \n 而不是常见的逗号/竖线:loginRedirect 是路由 path 不含换行, - * pinnedTags 是 JSON 数组(元素为带引号的字符串)也不含裸换行。 + * pinnedTags 是 JSON 数组(元素为带引号的字符串)也不含裸换行, + * queryGatewayChild 是布尔字面量,三段互不冲突。 */ private static final String CACHE_SEPARATOR = "\n"; @@ -55,7 +57,7 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService { // 缓存的是扁平字符串而非整个 POJO,理由同 SysConfigServiceImpl#selectConfigByKey: // 本项目 Redis 序列化器是 FastJson2 + SupportAutoType,直接缓存继承 BaseEntity // (含 Map params 等字段)的对象,反序列化存在取值不稳与 - // autoType 风险;缓存两个扁平字段可完全规避。 + // autoType 风险;缓存扁平字段可完全规避。 String cached = redisCache.getStrCacheObject(cacheKey); if (StringUtils.isNotEmpty(cached)) { SysUserSetting fromCache = parseCached(cached, userId); @@ -76,24 +78,31 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService { */ private void writeCache(String cacheKey, SysUserSetting setting) { redisCache.setStrCacheObject(cacheKey, - setting.getLoginRedirect() + CACHE_SEPARATOR + setting.getPinnedTags()); + setting.getLoginRedirect() + CACHE_SEPARATOR + + setting.getPinnedTags() + CACHE_SEPARATOR + + setting.getQueryGatewayChild()); } /** * 解析缓存值,格式非法时返回 null 走查库 * + *

段数必须严格等于 3:旧版本只缓存了 2 段(loginRedirect + pinnedTags), + * 若按缺省值补齐会读到错误的 queryGatewayChild,故旧格式一律判为未命中, + * 由查库回填新格式。缓存无 TTL,但每个用户最多回填一次。 + * * @param cached 缓存字符串 * @param userId 用户ID * @return 设置对象,解析失败返回 null */ private SysUserSetting parseCached(String cached, Long userId) { - int idx = cached.indexOf(CACHE_SEPARATOR); - if (idx < 0) { + String[] parts = cached.split(CACHE_SEPARATOR, -1); + if (parts.length != 3) { return null; } SysUserSetting setting = SysUserSetting.defaultOf(userId); - setting.setLoginRedirect(cached.substring(0, idx)); - setting.setPinnedTags(cached.substring(idx + 1)); + setting.setLoginRedirect(parts[0]); + setting.setPinnedTags(parts[1]); + setting.setQueryGatewayChild(Boolean.parseBoolean(parts[2])); return setting; } @@ -102,6 +111,10 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService { public int save(Long userId, SysUserSetting setting) { SysUserSetting target = SysUserSetting.defaultOf(userId); target.setLoginRedirect(setting.getLoginRedirect()); + // 未传时落到默认值:列约束为 NOT NULL,直接写 null 会在 STRICT 模式下报错 + target.setQueryGatewayChild(setting.getQueryGatewayChild() == null + ? UserSettingConstants.DEFAULT_QUERY_GATEWAY_CHILD + : setting.getQueryGatewayChild()); target.setRemark(setting.getRemark()); Date now = new Date(); target.setUpdateBy(setting.getUpdateBy()); @@ -111,41 +124,79 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService { target.setCreateTime(now); int rows = userSettingMapper.upsert(target); - // 写库成功后立即刷缓存,避免读到旧值。 - // 回写时保留库中已有的 pinned_tags:upsert 的 ON DUPLICATE 子句不更新该列, - // 内存对象却是默认值,若直接回填缓存会把用户的固定标签"洗"成空数组。 - SysUserSetting latest = userSettingMapper.selectByUserId(userId); - writeCache(getCacheKey(userId), latest == null ? target : latest); + // 写库成功后立即刷缓存。必须重查库再回填:upsert 的 ON DUPLICATE 子句不更新 + // pinned_tags,而内存对象 target 的 pinnedTags 是默认空数组, + // 直接回填会把用户的固定标签"洗"成空。 + refreshCache(userId, target); return rows; } @Override @DS("master") - public void savePinnedTags(Long userId, List paths, String operator) { - String json = normalizePinnedTags(paths); + @Transactional(rollbackFor = Exception.class) + public void addPinnedTag(Long userId, String path, String operator) { + if (!isValidPinnedPath(path)) { + return; + } Date now = new Date(); + // 行必须先存在,后面的锁定读才有东西可锁 + userSettingMapper.insertDefaultIfAbsent(userId, UserSettingConstants.DEFAULT_LOGIN_REDIRECT, + UserSettingConstants.DEFAULT_QUERY_GATEWAY_CHILD, operator, now); + List paths = parsePinnedTags(userSettingMapper.selectPinnedTagsForUpdate(userId)); + if (paths.contains(path)) { + // 已固定:幂等返回。不重复追加,也不把它挪到末尾,避免重复点击改变固定区顺序 + return; + } + paths.add(path); + writePinnedTags(userId, paths, operator, now); + } - // 先判断记录是否存在,而不是靠 update 的影响行数: - // MySQL 在「值未变化」时 affected_rows 同样为 0,与「记录不存在」无法区分, - // 靠返回值判断会在重复提交同一份数据时误走 insert 分支。 - SysUserSetting exists = userSettingMapper.selectByUserId(userId); + @Override + @DS("master") + @Transactional(rollbackFor = Exception.class) + public void removePinnedTag(Long userId, String path, String operator) { + if (!isValidPinnedPath(path)) { + return; + } + // 刻意不先建行:没有记录 = 本来就没固定过任何标签, + // 取消固定不该凭空给用户创建一条设置记录 + List paths = parsePinnedTags(userSettingMapper.selectPinnedTagsForUpdate(userId)); + if (!paths.remove(path)) { + // 没固定过:幂等返回 + return; + } + writePinnedTags(userId, paths, operator, new Date()); + } + + /** + * 把锁定读出来的数组写回并刷新缓存 + * + *

必须在 {@code selectPinnedTagsForUpdate} 之后、同一事务内调用: + * 行锁只有握到事务提交才释放,提前提交会让并发请求读到中间态。 + * + * @param userId 用户ID + * @param paths 锁内读出并改好的完整列表 + * @param operator 操作人用户名 + * @param now 当前时间 + */ + private void writePinnedTags(Long userId, List paths, String operator, Date now) { SysUserSetting patch = SysUserSetting.defaultOf(userId); - patch.setPinnedTags(json); + patch.setPinnedTags(JSON.toJSONString(paths)); patch.setUpdateBy(operator); patch.setUpdateTime(now); + userSettingMapper.updatePinnedTags(patch); + refreshCache(userId, patch); + } - if (exists == null) { - // 首次固定标签:用户还没有设置记录,走 upsert 而非裸 insert,避免并发下撞唯一键 1062 - patch.setLoginRedirect(UserSettingConstants.DEFAULT_LOGIN_REDIRECT); - patch.setCreateBy(operator); - patch.setCreateTime(now); - userSettingMapper.upsert(patch); - } else { - userSettingMapper.updatePinnedTags(patch); - } - // 回缓存:整体重新查一次,保证与库内最终状态一致 + /** + * 重查一次并回填缓存,保证缓存与库内最终状态一致 + * + * @param userId 用户ID + * @param fallback 重查返回 null 时的兜底对象(正常不会发生) + */ + private void refreshCache(Long userId, SysUserSetting fallback) { SysUserSetting latest = userSettingMapper.selectByUserId(userId); - writeCache(getCacheKey(userId), latest == null ? patch : latest); + writeCache(getCacheKey(userId), latest == null ? fallback : latest); } @Override @@ -154,30 +205,19 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService { } /** - * 校验并序列化固定标签列表 + * 校验固定标签 path 是否合法 * - *

只接受站内绝对路径,去重且保持前端给定的固定顺序。 - * path 最终会进 router.push,混入外链会变成开放重定向入口。 + *

只接受站内绝对路径,且排除协议相对地址({@code //host})、 + * 带协议头的地址与路径穿越。path 最终会进 router.push,混入外链会变成开放重定向入口。 * - * @param paths 前端提交的 path 列表 - * @return JSON 数组字符串 + * @param path 待校验的路由 path + * @return 合法返回 true */ - private String normalizePinnedTags(List paths) { - List valid = new ArrayList<>(); - if (paths != null) { - for (String path : paths) { - if (StringUtils.isEmpty(path) || !path.startsWith("/")) { - continue; - } - if (path.startsWith("//") || path.contains("://") || path.contains("..")) { - continue; - } - if (!valid.contains(path)) { - valid.add(path); - } - } + private boolean isValidPinnedPath(String path) { + if (StringUtils.isEmpty(path) || !path.startsWith("/")) { + return false; } - return JSON.toJSONString(valid); + return !path.startsWith("//") && !path.contains("://") && !path.contains(".."); } /** diff --git a/fastbee-service/fastbee-system-service/src/main/resources/mapper/system/SysUserSettingMapper.xml b/fastbee-service/fastbee-system-service/src/main/resources/mapper/system/SysUserSettingMapper.xml index 6477db12..f49d7a83 100644 --- a/fastbee-service/fastbee-system-service/src/main/resources/mapper/system/SysUserSettingMapper.xml +++ b/fastbee-service/fastbee-system-service/src/main/resources/mapper/system/SysUserSettingMapper.xml @@ -9,6 +9,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + @@ -22,7 +23,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - select setting_id, user_id, login_redirect, pinned_tags, + select setting_id, user_id, login_redirect, pinned_tags, query_gateway_child, create_by, create_time, update_by, update_time, remark from sys_user_setting @@ -37,24 +38,54 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" 用 INSERT ... ON DUPLICATE KEY UPDATE 收敛「先查后插」的并发竞态: 并发首次保存不会因唯一键冲突抛 DuplicateKey,也不需要 SELECT FOR UPDATE。 create_time 保留首次写入时间,重复插入时不覆盖。 + pinned_tags 故意不在此更新:它由 tags-view 的独立写入路径维护, + 并入 upsert 会导致保存其它设置时把用户的固定标签冲成默认值。 --> insert into sys_user_setting - (user_id, login_redirect, pinned_tags, create_by, create_time, update_by, update_time, remark) + (user_id, login_redirect, pinned_tags, query_gateway_child, create_by, create_time, update_by, update_time, remark) values - (#{userId}, #{loginRedirect}, #{pinnedTags}, #{createBy}, #{createTime}, #{updateBy}, #{updateTime}, #{remark}) + (#{userId}, #{loginRedirect}, #{pinnedTags}, #{queryGatewayChild}, #{createBy}, #{createTime}, #{updateBy}, #{updateTime}, #{remark}) on duplicate key update - login_redirect = values(login_redirect), - update_by = values(update_by), - update_time = values(update_time), - remark = values(remark) + login_redirect = values(login_redirect), + query_gateway_child = values(query_gateway_child), + update_by = values(update_by), + update_time = values(update_time), + remark = values(remark) + + insert into sys_user_setting + (user_id, login_redirect, pinned_tags, query_gateway_child, create_by, create_time, update_by, update_time) + values + (#{userId}, #{loginRedirect}, '[]', #{queryGatewayChild}, #{operator}, #{now}, #{operator}, #{now}) + on duplicate key update + user_id = user_id + + + + + + update sys_user_setting diff --git a/sql/sys_user_setting.sql b/sql/sys_user_setting.sql index 3b6fb968..a8bfca7c 100644 --- a/sql/sys_user_setting.sql +++ b/sql/sys_user_setting.sql @@ -17,6 +17,12 @@ -- 现改为落库,同一账号在任何设备登录都能恢复固定区。 -- * 只存用户右键固定的 pinned 集合;路由自身声明的 affix(首页,router/index.js:80) -- 由 constantRoutes 天然重建,不入此列。 +-- * **写入语义:单条增量**。接口是 POST/DELETE 一个 path,服务端在事务里 +-- 「SELECT ... FOR UPDATE → 读数组 → 改 → UPDATE 整列」。 +-- 刻意**不提供提交整份列表的覆盖写接口**:前端手里那份列表可能已被 +-- 另一个标签页/另一台设备改过,整份提交会把对方的改动静默抹掉, +-- 这正是固定标签「用着用着就少了」的成因。 +-- * 增删都在 Java 侧按列表处理,**未使用任何 MySQL JSON 函数**。 -- * DEFAULT ('[]') 是 MySQL 8.0.13+ 的表达式默认值。TEXT 列不能带字面量 DEFAULT, -- 不给默认值时 STRICT_TRANS_TABLES 下会直接报错,故必须显式写空数组。 -- 低版本数据库请改为 VARCHAR(2000) NOT NULL DEFAULT '[]'。 @@ -26,12 +32,27 @@ -- ADD COLUMN `pinned_tags` text NOT NULL DEFAULT ('[]') -- COMMENT '任务栏固定标签JSON数组,按序存 path;空数组表示未固定' -- AFTER `login_redirect`; +-- ALTER TABLE `sys_user_setting` +-- ADD COLUMN `query_gateway_child` tinyint(1) NOT NULL DEFAULT 0 +-- COMMENT '按编号查询时是否查网关子设备(0否1是)' +-- AFTER `pinned_tags`; +-- +-- query_gateway_child:开启后 /iot/device/shortList **按设备编号查询时**(serialNumber 非空): +-- ① 命中的若是网关子设备编号,返回其所属的网关设备(子设备 gw_dev_code 存的就是网关 serial_number, +-- 取 ifnull(gw_dev_code, serial_number) 换算); +-- ② 放开产品类型为「网关子设备(4)」的过滤 —— 这类设备 gw_dev_code 为空、实为独立设备, +-- 只是产品被标成 4,默认在列表中被隐藏(否则设备管理页会多出一大批),仅在按编号精确定位时可见。 +-- 该开关**不改变列表范围** —— gw_dev_code is null 恒生效,不带编号时 device_type != 4 依然过滤, +-- 所以短列表始终只出顶层设备(网关 + 直连),既不出现挂在网关下的真子设备, +-- 默认列表也不会多出 device_type=4 的设备。 +-- 默认 0。读该值的入口是 DeviceController#shortList,有 SecurityContext,故为 per-user 设置。 -- ------------------------------------------------------------ CREATE TABLE IF NOT EXISTS `sys_user_setting` ( `setting_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '设置主键', `user_id` bigint(20) NOT NULL COMMENT '用户ID(sys_user.user_id)', `login_redirect` varchar(128) NOT NULL DEFAULT '/bigscreen' COMMENT '登录后跳转路由path', `pinned_tags` text NOT NULL DEFAULT ('[]') COMMENT '任务栏固定标签JSON数组,按序存 path;空数组表示未固定', + `query_gateway_child` tinyint(1) NOT NULL DEFAULT 0 COMMENT '按编号查询时是否查网关子设备(0否1是)', `create_by` varchar(64) DEFAULT NULL COMMENT '创建者', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_by` varchar(64) DEFAULT NULL COMMENT '更新者', diff --git a/yack-web/src/api/system/userSetting.js b/yack-web/src/api/system/userSetting.js index 7791f5f4..37b705ef 100644 --- a/yack-web/src/api/system/userSetting.js +++ b/yack-web/src/api/system/userSetting.js @@ -33,11 +33,22 @@ export function getPinnedTags() { }) } -// 保存当前用户固定的任务栏标签 -export function updatePinnedTags(paths) { +// 固定一个任务栏标签(服务端幂等追加到固定区末尾) +// 刻意不做「整份列表覆盖写」:前端持有的列表可能已被别的标签页改过, +// 整份提交会把对方的改动静默抹掉 +export function addPinnedTag(path) { return request({ url: '/system/userSetting/pinnedTags', - method: 'put', - data: paths + method: 'post', + params: { path } + }) +} + +// 取消固定一个任务栏标签(服务端幂等移除) +export function removePinnedTag(path) { + return request({ + url: '/system/userSetting/pinnedTags', + method: 'delete', + params: { path } }) } diff --git a/yack-web/src/layout/components/TagsView/index.vue b/yack-web/src/layout/components/TagsView/index.vue index 08cf470b..835ae092 100644 --- a/yack-web/src/layout/components/TagsView/index.vue +++ b/yack-web/src/layout/components/TagsView/index.vue @@ -77,6 +77,8 @@ + + diff --git a/yack-web/src/views/dataCenter/history.vue b/yack-web/src/views/dataCenter/history.vue index e107f4e7..5a019593 100644 --- a/yack-web/src/views/dataCenter/history.vue +++ b/yack-web/src/views/dataCenter/history.vue @@ -31,6 +31,14 @@ + + + {{ $t('device.index.105953-18') }} @@ -287,6 +295,7 @@