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 new file mode 100644 index 00000000..e4f4b7eb --- /dev/null +++ b/fastbee-admin/src/main/java/com/fastbee/web/controller/system/SysUserSettingController.java @@ -0,0 +1,123 @@ +package com.fastbee.web.controller.system; + +import com.fastbee.common.annotation.Log; +import com.fastbee.common.core.controller.BaseController; +import com.fastbee.common.core.domain.AjaxResult; +import com.fastbee.common.enums.BusinessType; +import com.fastbee.common.utils.SecurityUtils; +import com.fastbee.system.domain.LoginRedirectOption; +import com.fastbee.system.domain.SysUserSetting; +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.PatchMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 个人系统设置 + * + *

本接口只处理「当前登录用户自己的」设置,无需额外的菜单权限点: + * 能登录的用户天然有权读写自己的偏好。全局开关不在这里, + * 仍走 SysConfigController 的 /system/config(权限 system:config:edit)。 + * + * @author Sumuxi + */ +@Api(tags = "个人设置") +@RestController +@RequestMapping("/system/userSetting") +public class SysUserSettingController extends BaseController { + + @Autowired + private ISysUserSettingService userSettingService; + + @ApiOperation("获取当前用户的设置") + @GetMapping() + public AjaxResult get() { + Long userId = SecurityUtils.getUserId(); + return AjaxResult.success(userSettingService.selectByUserId(userId)); + } + + @ApiOperation("获取登录落地页候选项") + @GetMapping("/loginRedirectOptions") + public AjaxResult loginRedirectOptions() { + return AjaxResult.success(userSettingService.listLoginRedirectOptions()); + } + + @ApiOperation("获取当前用户固定的任务栏标签") + @GetMapping("/pinnedTags") + public AjaxResult pinnedTags() { + return AjaxResult.success(userSettingService.getPinnedTags(SecurityUtils.getUserId())); + } + + /** + * 固定标签按「单条增量」写入,不再提供整份列表的覆盖写 + * + *

原先的 {@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(); + } + + /** + * 部分更新:只提交、也只写「用户改动过的那几项」 + * + *

刻意不是整体覆盖写(PUT):设置页有多个互相独立的配置项, + * 整体覆盖会让「保存 A 配置」把 B 配置冲回默认值。 + * 这里按入参中非 null 的字段逐列更新,未传的字段在 SQL 层面就碰不到。 + *

用 PATCH 而非 PUT:PUT 的语义是整体替换,与部分更新名实不符。 + */ + @ApiOperation("部分更新当前用户的设置") + @Log(title = "个人设置", businessType = BusinessType.UPDATE) + @PatchMapping() + public AjaxResult update(@RequestBody SysUserSetting setting) { + if (setting.getLoginRedirect() == null + && setting.getQueryGatewayChild() == null + && setting.getShowChild() == null) { + return AjaxResult.error("没有需要保存的配置项"); + } + // 只有在本次确实要改「登录后显示页面」时才做白名单校验; + // 未传(null)表示本次不改它,不能顺手用默认值把它覆盖掉 + if (setting.getLoginRedirect() != null && !isValidLoginRedirect(setting.getLoginRedirect())) { + return AjaxResult.error("登录后跳转页面不合法,请从系统菜单中选择"); + } + // 审计字段由服务端填充,不接受前端传入,避免越权写入他人操作名 + userSettingService.updatePartial(SecurityUtils.getUserId(), setting, SecurityUtils.getUsername()); + return AjaxResult.success(); + } + + /** + * 校验跳转目标为系统内已注册的一级页签 + * + *

该值最终进入 router.push。白名单取自 sys_menu(已剔除停用/隐藏/外链/二级嵌套页), + * 比单纯校验「以 / 开头」更严:只能落到真实存在的菜单路由上, + * 既挡住开放重定向,也避免指向被删除的菜单导致登录后 404。 + */ + private boolean isValidLoginRedirect(String redirect) { + for (LoginRedirectOption option : userSettingService.listLoginRedirectOptions()) { + if (option.getPath().equals(redirect)) { + return true; + } + } + return false; + } +} diff --git a/fastbee-common/src/main/java/com/fastbee/common/constant/CacheConstants.java b/fastbee-common/src/main/java/com/fastbee/common/constant/CacheConstants.java index e213bc6b..86455227 100644 --- a/fastbee-common/src/main/java/com/fastbee/common/constant/CacheConstants.java +++ b/fastbee-common/src/main/java/com/fastbee/common/constant/CacheConstants.java @@ -31,6 +31,11 @@ public class CacheConstants { */ public static final String SYS_DICT_KEY = "sys_dict:"; + /** + * 用户系统设置 cache key(后接 userId) + */ + public static final String SYS_USER_SETTING_KEY = "sys_user_setting:"; + /** * 数据库查询 cache key */ 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 new file mode 100644 index 00000000..5bc2c663 --- /dev/null +++ b/fastbee-common/src/main/java/com/fastbee/common/constant/UserSettingConstants.java @@ -0,0 +1,106 @@ +package com.fastbee.common.constant; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * 用户系统设置相关常量 + * + *

维度判据:读取方能否拿到当前登录用户。 + * 拿得到(HTTP 请求,有 SecurityContext)→ 落 {@code sys_user_setting},per-user; + * 拿不到(设备上电 {@code @Async} 线程)→ 落 {@code sys_config},全局。 + * + * @author Sumuxi + */ +public class UserSettingConstants { + + /** + * 登录后默认跳转路由 + * + *

取 /bigscreen:未在设置里指定页面的账号保持原有的数据大屏落地行为, + * 不因引入本功能而改变既有用户的使用习惯。 + * 对应 yack-web router/index.js constantRoutes 中 hidden 的顶层大屏路由。 + */ + public static final String DEFAULT_LOGIN_REDIRECT = "/bigscreen"; + + /** + * 内置固定路由:不在 sys_menu 菜单表中,但可作为登录落地页 + * + *

这两条由前端 constantRoutes 硬编码注册(菜单表里查不到): + * /index —— 首页,Layout 的默认子路由 + * /bigscreen—— 数据大屏,hidden 菜单,任何角色都可访问 + * 候选项 = 内置路由 + sys_menu 一级页签,缺一则默认值 /index 会被白名单拒绝。 + */ + public static final List BUILTIN_REDIRECTS = Collections.unmodifiableList(Arrays.asList( + new BuiltinRedirect("/index", "首页"), + new BuiltinRedirect("/bigscreen", "数据大屏") + )); + + /** + * 是否下发读取设备 IMEI/ICCID 指令的全局配置 key + * + *

读取方为设备上电异步线程,无登录上下文,故存 {@code sys_config} 而非用户设置表。 + * 取代原实现中运维经 {@code BackendController#/set} 手动写入的裸 Redis key {@code get_imei_iccid}。 + */ + public static final String READ_IMEI_ICCID_KEY = "iot.power_on.read_imei_iccid"; + + /** + * 固定标签的「未固定」空值 + * + *

列约束为 NOT NULL DEFAULT ('[]'),不存 NULL、不存空串。 + * 存空数组而非 NULL,读侧无需判空即可安全反序列化。 + */ + 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; + + /** + * 设备管理列表是否包含下级机构数据的默认值 + * + *

语义对应 {@code Device.showChild}:仅在机构用户({@code deptId} 非空)时参与 SQL, + * 开启时 {@code tenant_id} 落在「本机构及全部下级机构用户」集合内,关闭时仅本机构。 + * 原实现是设备管理页的勾选框 + 浏览器 localStorage(key {@code device-showChild}), + * 换浏览器/换电脑就丢,现改为落库、由「系统管理 → 系统设置」页维护。 + *

默认 false = 与引入本开关前完全一致(默认列表不含下级机构数据)。 + */ + public static final boolean DEFAULT_SHOW_CHILD = false; + + private UserSettingConstants() { + } + + /** + * 内置固定路由项 + */ + public static class BuiltinRedirect { + private final String path; + private final String name; + + public BuiltinRedirect(String path, String name) { + this.path = path; + this.name = name; + } + + public String getPath() { + return path; + } + + public String getName() { + return name; + } + } +} diff --git a/fastbee-common/src/main/java/com/fastbee/common/core/redis/RedisCache.java b/fastbee-common/src/main/java/com/fastbee/common/core/redis/RedisCache.java index d74beacf..f6e6be99 100644 --- a/fastbee-common/src/main/java/com/fastbee/common/core/redis/RedisCache.java +++ b/fastbee-common/src/main/java/com/fastbee/common/core/redis/RedisCache.java @@ -810,6 +810,32 @@ public class RedisCache { return stringRedisTemplate.opsForValue().get(key); } + /** + * 缓存基本的字符串 + * + * 与 {@link #getStrCacheObject(String)} 配套。 + * 走 StringRedisTemplate 而非 redisTemplate,避免 FastJson2 把值包成带引号的 JSON 字符串, + * 导致读回时多一层引号。 + * + * @param key 缓存键值 + * @param value 缓存值,不能为 null + */ + public void setStrCacheObject(final String key, final String value) { + stringRedisTemplate.opsForValue().set(key, value); + } + + /** + * 缓存基本的字符串并设置过期时间 + * + * @param key 缓存键值 + * @param value 缓存值,不能为 null + * @param timeout 时间 + * @param timeUnit 时间颗粒度 + */ + public void setStrCacheObject(final String key, final String value, final Integer timeout, final TimeUnit timeUnit) { + stringRedisTemplate.opsForValue().set(key, value, timeout, timeUnit); + } + /** * 删除单个对象 * diff --git a/fastbee-iot-data/src/main/java/com/fastbee/iot/data/executor/PowerOnTriggerTaskExecutor.java b/fastbee-iot-data/src/main/java/com/fastbee/iot/data/executor/PowerOnTriggerTaskExecutor.java index f4cbc188..3fff51e0 100644 --- a/fastbee-iot-data/src/main/java/com/fastbee/iot/data/executor/PowerOnTriggerTaskExecutor.java +++ b/fastbee-iot-data/src/main/java/com/fastbee/iot/data/executor/PowerOnTriggerTaskExecutor.java @@ -3,9 +3,11 @@ package com.fastbee.iot.data.executor; import com.fastbee.base.service.ISessionStore; import com.fastbee.base.session.Session; import com.fastbee.common.constant.FastBeeConstant; +import com.fastbee.common.constant.UserSettingConstants; import com.fastbee.common.core.mq.message.DeviceMessage; import com.fastbee.common.core.protocol.Message; import com.fastbee.common.core.redis.RedisKeyBuilder; +import com.fastbee.common.core.text.Convert; import com.fastbee.common.utils.StringUtils; import com.fastbee.common.utils.gateway.protocol.ByteUtils; import com.fastbee.iot.data.service.IDeviceMessageService; @@ -15,6 +17,7 @@ import com.fastbee.iot.domain.ModbusJob; import com.fastbee.iot.enums.DeviceType; import com.fastbee.iot.service.IDeviceService; import com.fastbee.iot.service.IModbusJobService; +import com.fastbee.system.service.ISysConfigService; import io.netty.buffer.Unpooled; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; @@ -68,6 +71,9 @@ public class PowerOnTriggerTaskExecutor { @Resource private IDeviceService deviceService; + @Resource + private ISysConfigService sysConfigService; + /** * 每台设备同一时刻仅允许一个"上电触发轮询"流水线运行,避免重复触发/并发下发导致应答错配 */ @@ -90,8 +96,9 @@ public class PowerOnTriggerTaskExecutor { //log.debug("设置上电触发标识,设备:{}", serialNumber); // 2、下发IMEI/ICCID读取指令 - if (redisTemplate.opsForValue().get("get_imei_iccid") == null){ - log.warn("无缓存标识,无法下发IMEI/ICCID指令"); + // 开关为全局配置:读该值的线程来自设备注册(@Async),无登录用户上下文,故不落用户设置表 + if (!isReadImeiIccidEnabled()) { + log.info("IMEI/ICCID读取指令已关闭,跳过下发,设备编号:{}", serialNumber); return; } Device device = deviceService.selectDeviceInfoBySerialNumber(serialNumber); @@ -131,6 +138,22 @@ public class PowerOnTriggerTaskExecutor { } } + /** + * 读取「是否下发IMEI/ICCID指令」全局开关 + * + *

配置缺失时按开启处理:设备出厂时 imei/iccid/经纬度均为空, + * 不下发则该字段永远无法填充,功能形同失效。 + * + * @return true 下发,false 不下发 + */ + private boolean isReadImeiIccidEnabled() { + String configValue = sysConfigService.selectConfigByKey(UserSettingConstants.READ_IMEI_ICCID_KEY); + if (StringUtils.isEmpty(configValue)) { + return true; + } + return Convert.toBool(configValue); + } + /** * 检查并消费上电触发标识(原子操作) * @param serialNumber 设备编号 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..437a5d28 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()); @@ -143,7 +159,7 @@ public class DeviceController extends BaseController { /** * 查询树形设备列表(用于报表生成等场景,网关设备可展开显示子设备) */ - @PreAuthorize("@ss.hasAnyPermi('iot:device:list,iot:report:generate')") + @PreAuthorize("@ss.hasAnyPermi('iot:device:list,iot:report:generate,dataCenter:repair:list')") @GetMapping("/treeList") @ApiOperation("查询树形设备列表(网关可展开子设备)") public AjaxResult treeList(Device device) { 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/service/impl/DeviceServiceImpl.java b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/impl/DeviceServiceImpl.java index d9ec3eda..8ff18684 100644 --- a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/impl/DeviceServiceImpl.java +++ b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/impl/DeviceServiceImpl.java @@ -1026,24 +1026,12 @@ public class DeviceServiceImpl implements IDeviceService { if (CollectionUtils.isEmpty(list)) { return new ArrayList<>(); } - List serialNumberList; - SysUser user = getLoginUser().getUser(); - if (null != user.getDept()) { - Long tenantId = user.getDept().getDeptUserId(); - serialNumberList = list.stream().filter(d -> tenantId.equals(d.getTenantId())) - .map(DeviceShortOutput::getSerialNumber).collect(Collectors.toList()); - } else { - serialNumberList = list.stream().map(DeviceShortOutput::getSerialNumber).collect(Collectors.toList()); + Integer entrance = null == device ? null : device.getEntrance(); + // 未处理告警数(alertCount)只有详情,根据产品查询设备详情 entrance=4 + if (Integer.valueOf(4).equals(entrance)) { + fillAlertCount(list); } - if (!CollectionUtils.isEmpty(serialNumberList)) { - List alist = alertLogService.selectDeviceAlertCount(serialNumberList); - for (DeviceAlertCount item : alist) { - list.stream() - .filter(it -> Objects.equals(it.getSerialNumber(), item.getSerialNumber())) - .forEach(it -> it.setAlertCount(item)); - } - } - fillShortListLatestReportTime(list, device.getEntrance()); + fillShortListLatestReportTime(list, entrance); long took = System.currentTimeMillis() - startTime; if (took > 1000L) { log.warn("selectDeviceShortList took: {}", took); @@ -1051,6 +1039,39 @@ public class DeviceServiceImpl implements IDeviceService { return list; } + /** + * 回填设备未处理告警数(DeviceShortOutput#alertCount)。 + *

+ * 只统计当前登录用户自己租户的设备:存在部门时按 dept.deptUserId 过滤, + * 因此「查询下级机构」带出来的下级机构设备不会带告警徽标。 + */ + private void fillAlertCount(List list) { + SysUser user = getLoginUser().getUser(); + Long deptUserId = null != user.getDept() ? user.getDept().getDeptUserId() : null; + List serialNumberList = list.stream() + .filter(d -> null == deptUserId || deptUserId.equals(d.getTenantId())) + .map(DeviceShortOutput::getSerialNumber) + .collect(Collectors.toList()); + if (CollectionUtils.isEmpty(serialNumberList)) { + return; + } + List alertCountList = alertLogService.selectDeviceAlertCount(serialNumberList); + if (CollectionUtils.isEmpty(alertCountList)) { + return; + } + // 先按 serialNumber 建索引再回填,避免原先的嵌套 stream 匹配(O(设备数 × 告警行数)) + Map alertCountMap = new HashMap<>(alertCountList.size()); + for (DeviceAlertCount item : alertCountList) { + alertCountMap.put(item.getSerialNumber(), item); + } + for (DeviceShortOutput row : list) { + DeviceAlertCount alertCount = alertCountMap.get(row.getSerialNumber()); + if (null != alertCount) { + row.setAlertCount(alertCount); + } + } + } + /** * 查询当前用户可见设备统计(与 shortList 相同范围) */ 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/LoginRedirectOption.java b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/domain/LoginRedirectOption.java new file mode 100644 index 00000000..f10531e9 --- /dev/null +++ b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/domain/LoginRedirectOption.java @@ -0,0 +1,50 @@ +package com.fastbee.system.domain; + +import java.io.Serializable; + +/** + * 登录落地页候选项(系统菜单) + * + *

数据源为 {@code sys_menu} 中可作为落地页的页签菜单,供用户选择登录后进入的页面。 + * 已剔除按钮(F)、停用(status=1)、隐藏(visible=1) 与外链(is_frame=0)菜单—— + * 外链若进入本列表会成为开放重定向入口。 + * + * @author Sumuxi + */ +public class LoginRedirectOption implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 完整路由 path,如 /iot/device + */ + private String path; + + /** + * 菜单名称 + */ + private String menuName; + + public LoginRedirectOption() { + } + + public LoginRedirectOption(String path, String menuName) { + this.path = path; + this.menuName = menuName; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public String getMenuName() { + return menuName; + } + + public void setMenuName(String menuName) { + this.menuName = menuName; + } +} 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 new file mode 100644 index 00000000..0bad61af --- /dev/null +++ b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/domain/SysUserSetting.java @@ -0,0 +1,157 @@ +package com.fastbee.system.domain; + +import com.fastbee.common.constant.UserSettingConstants; +import com.fastbee.common.core.domain.BaseEntity; +import io.swagger.annotations.ApiModelProperty; +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.apache.commons.lang3.builder.ToStringStyle; + +/** + * 用户系统设置对象 sys_user_setting + * + *

一行一用户(user_id 唯一)。所有设置列 NOT NULL + DEFAULT, + * 不存在「未配置过」的 NULL 态;业务默认值见 {@link UserSettingConstants}。 + * + * @author Sumuxi + */ +public class SysUserSetting extends BaseEntity { + private static final long serialVersionUID = 1L; + + /** + * 设置主键 + */ + @ApiModelProperty(value = "设置主键") + private Long settingId; + + /** + * 用户ID + */ + @ApiModelProperty(value = "用户ID") + private Long userId; + + /** + * 登录后跳转路由 path + */ + @ApiModelProperty(value = "登录后跳转路由path") + private String loginRedirect; + + /** + * 任务栏固定标签,JSON 数组字符串,按固定顺序存放 route path + * + *

只存用户右键「固定标签到任务栏」的 pinned 集合; + * 路由自身声明的 affix(如首页)由 constantRoutes 天然重建,不在此列。 + * 空数组字符串 "[]" 表示未固定,不存 NULL。 + */ + @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; + + /** + * 设备管理列表是否包含下级机构数据 + * + *

对应 {@code Device.showChild}:仅在机构用户(deptId 非空)时参与 SQL, + * 开启时 {@code tenant_id} 落在「本机构及全部下级机构用户」集合内,关闭时仅本机构。 + * 原为设备管理页勾选框 + 浏览器 localStorage,现改为落库、由系统设置页维护; + * 设备管理页只读取该值作为查询参数下发,页面内不再可就地修改。 + */ + @ApiModelProperty(value = "设备管理列表是否包含下级机构数据") + private Boolean showChild; + + public SysUserSetting() { + } + + /** + * 新建一份带默认值的设置(用户首次保存时使用) + * + * @param userId 用户ID + * @return 默认设置对象 + */ + public static SysUserSetting defaultOf(Long userId) { + SysUserSetting setting = new SysUserSetting(); + setting.setUserId(userId); + setting.setLoginRedirect(UserSettingConstants.DEFAULT_LOGIN_REDIRECT); + setting.setPinnedTags(UserSettingConstants.EMPTY_PINNED_TAGS); + setting.setQueryGatewayChild(UserSettingConstants.DEFAULT_QUERY_GATEWAY_CHILD); + setting.setShowChild(UserSettingConstants.DEFAULT_SHOW_CHILD); + return setting; + } + + public Long getSettingId() { + return settingId; + } + + public void setSettingId(Long settingId) { + this.settingId = settingId; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getLoginRedirect() { + return loginRedirect; + } + + public void setLoginRedirect(String loginRedirect) { + this.loginRedirect = loginRedirect; + } + + public String getPinnedTags() { + return pinnedTags; + } + + public void setPinnedTags(String pinnedTags) { + this.pinnedTags = pinnedTags; + } + + public Boolean getQueryGatewayChild() { + return queryGatewayChild; + } + + public void setQueryGatewayChild(Boolean queryGatewayChild) { + this.queryGatewayChild = queryGatewayChild; + } + + public Boolean getShowChild() { + return showChild; + } + + public void setShowChild(Boolean showChild) { + this.showChild = showChild; + } + + @Override + public String toString() { + return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE) + .append("settingId", getSettingId()) + .append("userId", getUserId()) + .append("loginRedirect", getLoginRedirect()) + .append("pinnedTags", getPinnedTags()) + .append("queryGatewayChild", getQueryGatewayChild()) + .append("showChild", getShowChild()) + .append("createBy", getCreateBy()) + .append("createTime", getCreateTime()) + .append("updateBy", getUpdateBy()) + .append("updateTime", getUpdateTime()) + .append("remark", getRemark()) + .toString(); + } +} 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 new file mode 100644 index 00000000..6af3d64c --- /dev/null +++ b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/mapper/SysUserSettingMapper.java @@ -0,0 +1,88 @@ +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; + +/** + * 用户系统设置 数据层 + * + * @author Sumuxi + */ +public interface SysUserSettingMapper { + + /** + * 按用户ID查询设置 + * + * @param userId 用户ID + * @return 设置,不存在返回 null + */ + SysUserSetting selectByUserId(Long userId); + + /** + * 部分更新设置:只更新入参中**非 null** 的列,未传的列保持库中原值 + * + *

调用方先 {@link #insertDefaultIfAbsent} 保证行存在,否则 UPDATE 0 行会静默丢改动。 + * 刻意不提供「整体覆盖写」的入口:设置页每次只提交用户改动过的那几项, + * 整体覆盖会让「保存 A 配置」把 B 配置冲回默认值。 + * + * @param setting 设置,userId 必填,其余字段只填本次要改的 + * @return 影响行数 + */ + int updateSetting(SysUserSetting setting); + + /** + * 仅更新固定标签列(整列覆盖) + * + *

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

固定标签的增量写入要在一行上做「锁定读 → 改 → 写」,行必须先存在。 + * 刻意不用 ON DUPLICATE 写其它列:那会覆盖 + * login_redirect / query_gateway_child / show_child,把用户已保存的设置冲回默认值。 + * + * @param userId 用户ID + * @param loginRedirect 新行使用的默认登录跳转 + * @param queryGatewayChild 新行使用的默认开关值 + * @param showChild 新行使用的默认「包含下级机构数据」值 + * @param operator 操作人用户名 + * @param now 当前时间 + * @return 影响行数(插入 1 / 已存在 0) + */ + int insertDefaultIfAbsent(@Param("userId") Long userId, + @Param("loginRedirect") String loginRedirect, + @Param("queryGatewayChild") Boolean queryGatewayChild, + @Param("showChild") Boolean showChild, + @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); + + /** + * 查询登录落地页候选项(可作为页签的菜单) + * + * @return 候选项列表 + */ + List selectLoginRedirectOptions(); +} 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 new file mode 100644 index 00000000..a676e9c1 --- /dev/null +++ b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/service/ISysUserSettingService.java @@ -0,0 +1,78 @@ +package com.fastbee.system.service; + +import com.fastbee.system.domain.LoginRedirectOption; +import com.fastbee.system.domain.SysUserSetting; + +import java.util.List; + +/** + * 用户系统设置 服务层 + * + *

所有方法的 userId 均由调用方显式传入,Service 内部不读 SecurityUtils。 + * 原因:设置读取方不只有 HTTP 请求线程(可拿到 SecurityContext), + * 还有设备上电等无登录上下文的线程,不能假设一定存在登录态。 + * + * @author Sumuxi + */ +public interface ISysUserSettingService { + + /** + * 查询用户设置;未保存过时返回带默认值的对象(不会返回 null) + * + * @param userId 用户ID + * @return 用户设置 + */ + SysUserSetting selectByUserId(Long userId); + + /** + * 部分更新用户设置:只写 {@code patch} 中非 null 的字段,未传的字段保持库中原值 + * + *

调用方每次只提交「用户真正改动过」的配置项,因此保存某一项不会把其它项冲成默认值。 + * 用户还没有设置行时先落一行默认值再更新,避免 UPDATE 0 行静默丢改动。 + * + * @param userId 用户ID + * @param patch 只填本次要改的字段,userId 字段忽略,以入参 userId 为准 + * @param operator 操作人用户名,写入 update_by + * @return 影响行数 + */ + int updatePartial(Long userId, SysUserSetting patch, String operator); + + /** + * 查询登录落地页候选项 + * + * @return 候选项列表 + */ + List listLoginRedirectOptions(); + + /** + * 固定一个任务栏标签 + * + *

增量写入:已固定则原样返回(幂等,**不改动它的位置**),未固定则追加到固定区末尾。 + * 并发安全由服务端「锁定读 → 改 → 写」保证——多个标签页/多端同时固定不会互相覆盖, + * 因为前端不再提交自己那份可能过期的完整列表。 + * + * @param userId 用户ID + * @param path 要固定的路由 path + * @param operator 操作人用户名,写入 update_by + */ + 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); + + /** + * 查询任务栏固定标签 + * + * @param userId 用户ID + * @return 固定标签的路由 path 列表,按固定顺序;未固定时返回空列表 + */ + List getPinnedTags(Long userId); +} 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 new file mode 100644 index 00000000..c11f370a --- /dev/null +++ b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/service/impl/SysUserSettingServiceImpl.java @@ -0,0 +1,268 @@ +package com.fastbee.system.service.impl; + +import com.alibaba.fastjson2.JSON; +import com.baomidou.dynamic.datasource.annotation.DS; +import com.fastbee.common.constant.CacheConstants; +import com.fastbee.common.constant.UserSettingConstants; +import com.fastbee.common.core.redis.RedisCache; +import com.fastbee.common.utils.StringUtils; +import com.fastbee.system.domain.LoginRedirectOption; +import com.fastbee.system.domain.SysUserSetting; +import com.fastbee.system.mapper.SysUserSettingMapper; +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; +import java.util.List; + +/** + * 用户系统设置 服务层实现 + * + *

读取策略:Redis 缓存未命中则查库并回填,未配置过则返回默认值对象。 + * 缓存值为空表示「该用户还没有记录」,此时不写入缓存—— + * 否则用户首次保存前,每次读都会穿透到数据库。 + * + * @author Sumuxi + */ +@Slf4j +@Service +public class SysUserSettingServiceImpl implements ISysUserSettingService { + + /** + * 缓存值的字段分隔符 + * + *

用 \n 而不是常见的逗号/竖线:loginRedirect 是路由 path 不含换行, + * pinnedTags 是 JSON 数组(元素为带引号的字符串)也不含裸换行, + * showChild 与 queryGatewayChild 是布尔字面量,四段互不冲突。 + *

字段顺序固定为:loginRedirect | pinnedTags | showChild | queryGatewayChild, + * 与 {@link #parseCached} 一一对应,增删字段必须同步改这两处。 + */ + private static final String CACHE_SEPARATOR = "\n"; + + @Autowired + private SysUserSettingMapper userSettingMapper; + + @Autowired + private RedisCache redisCache; + + @Override + @DS("master") + public SysUserSetting selectByUserId(Long userId) { + if (userId == null) { + return SysUserSetting.defaultOf(null); + } + String cacheKey = getCacheKey(userId); + // 缓存的是扁平字符串而非整个 POJO,理由同 SysConfigServiceImpl#selectConfigByKey: + // 本项目 Redis 序列化器是 FastJson2 + SupportAutoType,直接缓存继承 BaseEntity + // (含 Map params 等字段)的对象,反序列化存在取值不稳与 + // autoType 风险;缓存扁平字段可完全规避。 + String cached = redisCache.getStrCacheObject(cacheKey); + if (StringUtils.isNotEmpty(cached)) { + SysUserSetting fromCache = parseCached(cached, userId); + if (fromCache != null) { + return fromCache; + } + } + SysUserSetting setting = userSettingMapper.selectByUserId(userId); + if (setting == null) { + return SysUserSetting.defaultOf(userId); + } + writeCache(cacheKey, setting); + return setting; + } + + /** + * 把设置刷入缓存 + */ + private void writeCache(String cacheKey, SysUserSetting setting) { + redisCache.setStrCacheObject(cacheKey, + setting.getLoginRedirect() + CACHE_SEPARATOR + + setting.getPinnedTags() + CACHE_SEPARATOR + + setting.getShowChild() + CACHE_SEPARATOR + + setting.getQueryGatewayChild()); + } + + /** + * 解析缓存值,格式非法时返回 null 走查库 + * + *

段数必须严格等于 4:历史版本只缓存了 2 段(loginRedirect + pinnedTags)、 + * 后又扩到 3 段(追加 queryGatewayChild)。若按缺省值补齐,会把旧格式里 + * 那个位置的字段当成 showChild 读出来,故段数不等一律判为未命中,由查库回填新格式。 + * 缓存无 TTL,但每个用户最多回填一次。 + * + * @param cached 缓存字符串 + * @param userId 用户ID + * @return 设置对象,解析失败返回 null + */ + private SysUserSetting parseCached(String cached, Long userId) { + String[] parts = cached.split(CACHE_SEPARATOR, -1); + if (parts.length != 4) { + return null; + } + SysUserSetting setting = SysUserSetting.defaultOf(userId); + setting.setLoginRedirect(parts[0]); + setting.setPinnedTags(parts[1]); + setting.setShowChild(Boolean.parseBoolean(parts[2])); + setting.setQueryGatewayChild(Boolean.parseBoolean(parts[3])); + return setting; + } + + @Override + @DS("master") + @Transactional(rollbackFor = Exception.class) + public int updatePartial(Long userId, SysUserSetting patch, String operator) { + Date now = new Date(); + // 行必须先存在,否则后面的部分更新会 UPDATE 0 行、静默丢掉本次改动 + userSettingMapper.insertDefaultIfAbsent(userId, UserSettingConstants.DEFAULT_LOGIN_REDIRECT, + UserSettingConstants.DEFAULT_QUERY_GATEWAY_CHILD, UserSettingConstants.DEFAULT_SHOW_CHILD, + operator, now); + patch.setUserId(userId); + patch.setUpdateBy(operator); + patch.setUpdateTime(now); + int rows = userSettingMapper.updateSetting(patch); + // 写库成功后重查库再回填缓存:patch 里只有本次改动的字段, + // 直接拿它刷缓存会把未改动字段写成 null。 + refreshCache(userId, patch); + return rows; + } + + @Override + @DS("master") + @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, UserSettingConstants.DEFAULT_SHOW_CHILD, + operator, now); + List paths = parsePinnedTags(userSettingMapper.selectPinnedTagsForUpdate(userId)); + if (paths.contains(path)) { + // 已固定:幂等返回。不重复追加,也不把它挪到末尾,避免重复点击改变固定区顺序 + return; + } + paths.add(path); + writePinnedTags(userId, paths, operator, now); + } + + @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.toJSONString(paths)); + patch.setUpdateBy(operator); + patch.setUpdateTime(now); + userSettingMapper.updatePinnedTags(patch); + refreshCache(userId, patch); + } + + /** + * 重查一次并回填缓存,保证缓存与库内最终状态一致 + * + * @param userId 用户ID + * @param fallback 重查返回 null 时的兜底对象(正常不会发生) + */ + private void refreshCache(Long userId, SysUserSetting fallback) { + SysUserSetting latest = userSettingMapper.selectByUserId(userId); + writeCache(getCacheKey(userId), latest == null ? fallback : latest); + } + + @Override + public List getPinnedTags(Long userId) { + return parsePinnedTags(selectByUserId(userId).getPinnedTags()); + } + + /** + * 校验固定标签 path 是否合法 + * + *

只接受站内绝对路径,且排除协议相对地址({@code //host})、 + * 带协议头的地址与路径穿越。path 最终会进 router.push,混入外链会变成开放重定向入口。 + * + * @param path 待校验的路由 path + * @return 合法返回 true + */ + private boolean isValidPinnedPath(String path) { + if (StringUtils.isEmpty(path) || !path.startsWith("/")) { + return false; + } + return !path.startsWith("//") && !path.contains("://") && !path.contains(".."); + } + + /** + * 解析固定标签,脏数据降级为空列表 + * + *

列是 TEXT,允许人工改过;解析失败不应让整个设置页报错。 + * + * @param json JSON 数组字符串 + * @return path 列表 + */ + private List parsePinnedTags(String json) { + if (StringUtils.isEmpty(json)) { + return new ArrayList<>(); + } + try { + List list = JSON.parseArray(json, String.class); + return list == null ? new ArrayList<>() : list; + } catch (Exception e) { + log.warn("解析 pinned_tags 失败,降级为空列表,原始值:{}", json); + return new ArrayList<>(); + } + } + + @Override + @DS("master") + public List listLoginRedirectOptions() { + List options = new ArrayList<>(); + // 内置固定路由优先:/index 与 /bigscreen 由前端 constantRoutes 注册,不在 sys_menu 中 + for (UserSettingConstants.BuiltinRedirect builtin : UserSettingConstants.BUILTIN_REDIRECTS) { + options.add(new LoginRedirectOption(builtin.getPath(), builtin.getName())); + } + List menuOptions = userSettingMapper.selectLoginRedirectOptions(); + if (menuOptions != null && !menuOptions.isEmpty()) { + for (LoginRedirectOption option : menuOptions) { + // 菜单若与内置路由同名路径,以后者为准并去重,避免下拉出现重复项 + boolean duplicated = options.stream().anyMatch(o -> o.getPath().equals(option.getPath())); + if (!duplicated) { + options.add(option); + } + } + } + return options; + } + + private String getCacheKey(Long userId) { + return CacheConstants.SYS_USER_SETTING_KEY + userId; + } +} 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 new file mode 100644 index 00000000..24d04ec4 --- /dev/null +++ b/fastbee-service/fastbee-system-service/src/main/resources/mapper/system/SysUserSettingMapper.xml @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + select setting_id, user_id, login_redirect, pinned_tags, query_gateway_child, show_child, + create_by, create_time, update_by, update_time, remark + from sys_user_setting + + + + + + + update sys_user_setting + + login_redirect = #{loginRedirect}, + query_gateway_child = #{queryGatewayChild}, + show_child = #{showChild}, + update_by = #{updateBy}, + update_time = #{updateTime} + + where user_id = #{userId} + + + + + insert into sys_user_setting + (user_id, login_redirect, pinned_tags, query_gateway_child, show_child, create_by, create_time, update_by, update_time) + values + (#{userId}, #{loginRedirect}, '[]', #{queryGatewayChild}, #{showChild}, #{operator}, #{now}, #{operator}, #{now}) + on duplicate key update + user_id = user_id + + + + + + + + update sys_user_setting + set pinned_tags = #{pinnedTags}, + update_by = #{updateBy}, + update_time = #{updateTime} + where user_id = #{userId} + + + + + + diff --git a/sql/sys_user_setting.sql b/sql/sys_user_setting.sql new file mode 100644 index 00000000..4918599f --- /dev/null +++ b/sql/sys_user_setting.sql @@ -0,0 +1,116 @@ +-- ============================================= +-- 用户系统设置(用户维度)+ 设备侧全局开关入库 +-- 维度判据:读的时候拿不拿得到 userId +-- · 拿得到(HTTP 请求,有 SecurityContext)→ sys_user_setting,per-user +-- · 拿不到(设备上电 @Async 线程)→ sys_config,全局 +-- 配套代码:SysUserSetting* / PowerOnTriggerTaskExecutor:93 +-- 版本: 1.0 2026-10-05 +-- ============================================= + +-- ------------------------------------------------------------ +-- 一、用户维度设置表 +-- 一行一用户;所有设置列 NOT NULL + DEFAULT,不设「未配置过」这种 NULL 态 +-- 业务默认值由 Java 常量兜底(UserSettingConstants),不依赖 DB 兜底 +-- +-- pinned_tags:任务栏「固定标签到任务栏」的 JSON 数组,按固定顺序存 route path。 +-- * 原实现存浏览器 localStorage(key: pinned-tags),换浏览器/换电脑就丢, +-- 现改为落库,同一账号在任何设备登录都能恢复固定区。 +-- * 只存用户右键固定的 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 '[]'。 +-- +-- 已建过表的库用下面的 ALTER 补列(可重复执行前请先确认列不存在): +-- ALTER TABLE `sys_user_setting` +-- 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`; +-- ALTER TABLE `sys_user_setting` +-- ADD COLUMN `show_child` tinyint(1) NOT NULL DEFAULT 0 +-- COMMENT '设备管理列表是否包含下级机构数据(0否1是)' +-- AFTER `query_gateway_child`; +-- +-- 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 设置。 +-- +-- show_child:设备管理列表是否包含下级机构数据(原为设备管理页勾选框,值存在浏览器 +-- localStorage 的 device-showChild,换浏览器/换电脑就丢,现改为落库)。 +-- 生效条件同 Device.showChild:仅在 deptId 非空(机构用户)时参与 SQL,语义见 +-- DeviceMapper.xml#selectDeviceShortListJoinWhere —— 开启时 tenant_id 落在「本机构 +-- 及全部下级机构用户」集合内,关闭时仅本机构。 +-- 该值现在由「系统管理 → 系统设置」页维护,设备管理页只读不写(页面内的勾选框已移除)。 +-- 设备管理页进入时读一次该设置并作为查询参数下发,故本列是「前端读取、后端不感知」的偏好, +-- DeviceController 在未收到 showChild 时依然按 false 处理。 +-- 默认 0 = 与引入本开关前完全一致。 +-- ------------------------------------------------------------ +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是)', + `show_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 '更新者', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + `remark` varchar(500) DEFAULT NULL COMMENT '备注', + PRIMARY KEY (`setting_id`) USING BTREE, + UNIQUE KEY `uk_sys_user_setting_user` (`user_id`) USING BTREE COMMENT '每用户仅一行' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + ROW_FORMAT=DYNAMIC COMMENT='用户系统设置表'; + +-- ------------------------------------------------------------ +-- 二、设备侧全局开关 → 复用已有 sys_config 表 +-- 原实现:PowerOnTriggerTaskExecutor 直读运维手动写入的裸 Redis key +-- "get_imei_iccid"(经 BackendController /set 写入) +-- 新实现:改读 sys_config,运维改用已有的 PUT /system/config +-- config_type='Y' = 系统内置,deleteConfigByIds 会拒删 +-- ------------------------------------------------------------ +INSERT INTO `sys_config` + (`config_name`, `config_key`, `config_value`, `config_type`, `create_by`, `create_time`, `remark`) +SELECT '是否下发读取设备IMEI/ICCID指令', 'iot.power_on.read_imei_iccid', 'true', 'Y', 'system', NOW(), + '设备上电后是否下发 AT+IMEI/AT+ICCID/AT+LBS 指令读取;读该值的线程无登录上下文,故为全局配置' +WHERE NOT EXISTS ( + SELECT 1 FROM `sys_config` WHERE `config_key` = 'iot.power_on.read_imei_iccid' +); + +-- ------------------------------------------------------------ +-- 三、菜单:系统管理 → 个人设置 +-- 不设 system:setting:list 权限点的原因: +-- 接口读写的是「当前登录用户自己的」设置,无需菜单级授权; +-- 若填了权限点,非管理员账号将看不到也用不了自己的设置页。 +-- menu_type='C' 菜单,component 指向新建的 views/system/setting/index.vue +-- ------------------------------------------------------------ +INSERT INTO `sys_menu` + (`menu_name`, `parent_id`, `order_num`, `path`, `component`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`) +SELECT '系统设置', 1, 13, 'setting', 'system/setting/index', 1, 0, 'C', '0', '0', '', 'setting', 'system', NOW() +WHERE NOT EXISTS ( + SELECT 1 FROM `sys_menu` WHERE `parent_id` = 1 AND `path` = 'setting' +); + +-- ------------------------------------------------------------ +-- 四、清理历史遗留的裸 Redis key +-- 原开关 key 无前缀、不在任何 key 生成器中,与 RedisKeyBuilder 规范不符。 +-- 确认线上设备已完成一轮上电、开关已由 sys_config 接管后再执行: +-- redis-cli DEL get_imei_iccid +-- 切勿在切换完成前执行,否则 IMEI/ICCID 指令会立即停止下发。 +-- ------------------------------------------------------------ diff --git a/yack-web/src/api/system/userSetting.js b/yack-web/src/api/system/userSetting.js new file mode 100644 index 00000000..eacbf27a --- /dev/null +++ b/yack-web/src/api/system/userSetting.js @@ -0,0 +1,56 @@ +import request from '@/utils/request' + +// 获取当前用户的设置 +export function getUserSetting() { + return request({ + url: '/system/userSetting', + method: 'get' + }) +} + +// 部分更新当前用户的设置:data 里只放**本次改动过**的字段,未改动的一律不放 +// 用 PATCH 而非 PUT:服务端只按非空字段逐列更新,未传的字段保持库中原值, +// 这样保存某一项配置不会把其它配置冲回默认值 +export function updateUserSetting(data) { + return request({ + url: '/system/userSetting', + method: 'patch', + data: data + }) +} + +// 获取登录落地页候选项(取自系统菜单) +export function listLoginRedirectOptions() { + return request({ + url: '/system/userSetting/loginRedirectOptions', + method: 'get' + }) +} + +// 获取当前用户固定的任务栏标签 +export function getPinnedTags() { + return request({ + url: '/system/userSetting/pinnedTags', + method: 'get' + }) +} + +// 固定一个任务栏标签(服务端幂等追加到固定区末尾) +// 刻意不做「整份列表覆盖写」:前端持有的列表可能已被别的标签页改过, +// 整份提交会把对方的改动静默抹掉 +export function addPinnedTag(path) { + return request({ + url: '/system/userSetting/pinnedTags', + method: 'post', + params: { path } + }) +} + +// 取消固定一个任务栏标签(服务端幂等移除) +export function removePinnedTag(path) { + return request({ + url: '/system/userSetting/pinnedTags', + method: 'delete', + params: { path } + }) +} diff --git a/yack-web/src/lang/en-US/product.json b/yack-web/src/lang/en-US/product.json index c5a255a5..a3334347 100644 --- a/yack-web/src/lang/en-US/product.json +++ b/yack-web/src/lang/en-US/product.json @@ -426,11 +426,6 @@ "product.product-modbus.562372-44": "Add a single data register", "product.product-modbus.562372-45": "Add multiple data registers", "product.product-modbus.562372-46": "The default address cannot be empty", - "product.product-modbus.562372-47": "Register address cannot be empty", - "product.product-modbus.562372-48": "Read only (0- No, 1- Yes) cannot be empty", - "product.product-modbus.562372-49": "Modbus data type cannot be empty", - "product.product-modbus.562372-50": "The number of reads cannot be empty", - "product.product-modbus.562372-51": "Register type 1-IO register 2-data register cannot be empty", "product.product-modbus.562372-52": "Save successful", "product.product-modbus.562372-53": "Modified successfully", "product.product-modbus.562372-311": "Positional offset", @@ -551,5 +546,21 @@ "product.product-things-model.142341-200": "Click symbols to build the formula", "product.product-things-model.142341-201": "Reference product model identifiers", "product.product-things-model.142341-202": "No available identifiers", - "product.product-things-model.142341-203": "Add Calculated Model" + "product.product-things-model.142341-203": "Add Calculated Model", + "product.product-modbus.562372-320": "Batch", + "product.product-modbus.562372-321": "Scope: all {n} rows on this page", + "product.product-modbus.562372-323": "{n} rows updated", + "product.product-modbus.562372-325": "skipped {m} rows (data type mismatch)", + "product.product-modbus.562372-326": "Batch delete", + "product.product-modbus.562372-327": "Select rows to delete first", + "product.product-modbus.562372-328": "The {n} selected rows ({m} already saved) will be removed locally and take effect after you click Save. Continue?", + "product.product-modbus.562372-329": "1 (16-bit)", + "product.product-modbus.562372-330": "2 (32-bit)", + "product.product-modbus.562372-331": "Click \"Edit registers\" to enter edit mode first", + "product.product-modbus.562372-333": "Scope: {n} selected rows", + "product.product-modbus.562372-334": "Advances by read count · Enter or blur to generate", + "product.product-modbus.562372-335": "Generate", + "product.product-modbus.562372-336": "Generated {n} addresses: {from} -> {to}", + "product.product-modbus.562372-337": "{m} duplicate slave + register address pairs on this page", + "product.product-modbus.562372-338": "Product is published; click \"Unpublish\" first to edit registers" } diff --git a/yack-web/src/lang/zh-CN/product.json b/yack-web/src/lang/zh-CN/product.json index e9be7ae1..12af98fd 100644 --- a/yack-web/src/lang/zh-CN/product.json +++ b/yack-web/src/lang/zh-CN/product.json @@ -426,11 +426,6 @@ "product.product-modbus.562372-44": "添加单个数据寄存器", "product.product-modbus.562372-45": "添加多个数据寄存器", "product.product-modbus.562372-46": "默认地址不能为空", - "product.product-modbus.562372-47": "寄存器地址不能为空", - "product.product-modbus.562372-48": "是否只读(0-否,1-是)不能为空", - "product.product-modbus.562372-49": "modbus数据类型不能为空", - "product.product-modbus.562372-50": "读取个数不能为空", - "product.product-modbus.562372-51": "寄存器类型 1-IO寄存器 2-数据寄存器不能为空", "product.product-modbus.562372-52": "保存成功", "product.product-modbus.562372-53": "修改成功", "product.product-modbus.562372-311": "位偏移量", @@ -551,5 +546,21 @@ "product.product-things-model.142341-200": "点击符号快速拼接", "product.product-things-model.142341-201": "引用产品物模型标识", "product.product-things-model.142341-202": "暂无可用标识", - "product.product-things-model.142341-203": "新增计算模型" + "product.product-things-model.142341-203": "新增计算模型", + "product.product-modbus.562372-320": "批量", + "product.product-modbus.562372-321": "作用:本页 {n} 行", + "product.product-modbus.562372-323": "已应用 {n} 行", + "product.product-modbus.562372-325": "跳过 {m} 行(数据类型不匹配)", + "product.product-modbus.562372-326": "批量删除", + "product.product-modbus.562372-327": "请先勾选要删除的行", + "product.product-modbus.562372-328": "将删除勾选的 {n} 行(含已保存 {m} 行),确认后需点击保存生效", + "product.product-modbus.562372-329": "1(16位)", + "product.product-modbus.562372-330": "2(32位)", + "product.product-modbus.562372-331": "请先点击「编辑寄存器」进入编辑状态", + "product.product-modbus.562372-333": "作用:已勾选 {n} 行", + "product.product-modbus.562372-334": "按读取个数递增 · 回车或失焦即生成", + "product.product-modbus.562372-335": "一键生成", + "product.product-modbus.562372-336": "已生成 {n} 行地址:{from} → {to}", + "product.product-modbus.562372-337": "本页有 {m} 处「从机地址 + 寄存器地址」重复,请检查", + "product.product-modbus.562372-338": "产品当前为已发布状态,请先点击「取消发布」后再编辑寄存器" } diff --git a/yack-web/src/layout/components/TagsView/index.vue b/yack-web/src/layout/components/TagsView/index.vue index 4b33af48..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/dataRepair.vue b/yack-web/src/views/dataCenter/dataRepair.vue index 05e10874..f8e53789 100644 --- a/yack-web/src/views/dataCenter/dataRepair.vue +++ b/yack-web/src/views/dataCenter/dataRepair.vue @@ -47,16 +47,34 @@ + + + + + + @@ -124,7 +142,7 @@ - + + + + + + @@ -337,7 +372,7 @@ + + diff --git a/yack-web/src/views/iot/product/modbus-derive.js b/yack-web/src/views/iot/product/modbus-derive.js new file mode 100644 index 00000000..f47f3818 --- /dev/null +++ b/yack-web/src/views/iot/product/modbus-derive.js @@ -0,0 +1,44 @@ +/** + * modbus 配置派生规则(唯一口径,页面与批量面板共用) + * + * 红线:`读取个数(quantity)` 是 `数据类型(dataType)` 的派生量,不是独立字段。 + * 后端 ModbusProtocol#handleRead0304 完全按 quantity 分支解析: + * quantity==1 → 16 位(CalculateUtils.handleToUnSign16) + * quantity==2 → 32 位(CalculateUtils.parseValue) + * 其它值 → log.warn 直接丢弃 + * 字典 iot_modbus_data_type(sql/yack-init.sql:2503-2511): + * bit=1 位;ushort/short=16 位 1 个寄存器;long/ulong/float 的 ABCD/CDAB 变体=32 位 2 个寄存器。 + */ + +/** 32 位数据类型集合(占 2 个寄存器) */ +export const MODBUS_32BIT_TYPES = ['long-ABCD', 'long-CDAB', 'ulong-ABCD', 'ulong-CDAB', 'float-ABCD', 'float-CDAB']; + +/** 16 位数据类型集合(占 1 个寄存器) */ +export const MODBUS_16BIT_TYPES = ['ushort', 'short']; + +/** + * 由数据类型推导读取个数 + * @param {string} dataType 字典 iot_modbus_data_type 的值 + * @returns {number|null} 1 | 2;dataType 为空或未知类型返回 null(不派生,避免把 data_type 为空的历史行改坏) + */ +export function deriveQuantity(dataType) { + if (!dataType) return null; + if (dataType === 'bit') return 1; + if (MODBUS_16BIT_TYPES.indexOf(dataType) !== -1) return 1; + if (MODBUS_32BIT_TYPES.indexOf(dataType) !== -1) return 2; + return null; +} + +/** + * 单行占用的寄存器个数(一 key 生成地址序列时的步长) + * dataType 非空 → 以派生值为准(权威);dataType 为空的历史行 → 回退行内 quantity;再缺省 1 + * @param {object} row modbus 配置行 + * @returns {number} 1 | 2 | 行内 quantity + */ +export function resolveQuantity(row) { + if (!row) return 1; + const derived = deriveQuantity(row.dataType); + if (derived !== null) return derived; + const quantity = Number(row.quantity); + return quantity > 0 ? quantity : 1; +} diff --git a/yack-web/src/views/iot/product/product-modbus.vue b/yack-web/src/views/iot/product/product-modbus.vue index c297f742..f740c88a 100644 --- a/yack-web/src/views/iot/product/product-modbus.vue +++ b/yack-web/src/views/iot/product/product-modbus.vue @@ -93,7 +93,8 @@ - + + + @@ -128,19 +130,31 @@ + + +