fix(M): 修复品类中文乱码, 子设备在线状态按父网关判定

乱码: 服务器 mysql 客户端在 C locale 下 character_set_client=latin1, 迁移脚本经 Windows type|plink 管道执行时 UTF-8 字节被按 latin1 解读, 中文双重编码。修复: V25/V26 脚本首行加 SET NAMES utf8mb4, deploy-all.bat 的 mysql 调用补 --default-character-set=utf8mb4, 新增 V26 按 code 写回正确中文(幂等)。已验证 product_category.name 的 hex 为正确 UTF-8。

子设备在线: 涂鸦 Mesh/Zigbee 子设备无独立在线通道, SDK 与云端快照对其 online 恒 false, APP 上报后被写成离线。后端新增 resolveEffectiveOnlineStatus —— 上报在线即 online; 子设备上报离线时以父网关为准(父在线则 online); 设备列表输出再做一次父网关聚合, 历史数据立即生效。前端 DeviceInfo 增报 parentDeviceId, 并按品类推断 is_gateway。

APP: 网关下挂设备不再用涂鸦 SDK 快照覆盖后端结论; 列表状态改为在线/离线/状态未知三态; uniapp 端修复状态比大写 ONLINE 导致恒离线、且把状态字符串当 DP JSON 解析的问题。生产验证: JD2BTSA/JD6BTSA 由 offline 变为 online。
This commit is contained in:
2995840376 2026-10-28 01:25:46 +08:00
parent 903b44c84b
commit 066d374673
10 changed files with 217 additions and 18 deletions

View File

@ -51,6 +51,15 @@ public class TuyaDevice {
@SerializedName("parentDeviceId")
private String parentDeviceId;
/**
* 后端原始状态值(online / offline / unknown)
*
* <p>子设备(网关下挂)没有独立在线通道,后端会按父网关聚合状态,
* 无法用 online/offline 两态表达"未知",因此保留原始值供 UI 区分展示。</p>
*/
@SerializedName("statusRaw")
private String statusRaw;
/** 是否为网关 */
@SerializedName("isGateway")
private Integer isGateway;
@ -94,6 +103,9 @@ public class TuyaDevice {
public String getParentDeviceId() { return parentDeviceId; }
public void setParentDeviceId(String parentDeviceId) { this.parentDeviceId = parentDeviceId; }
public String getStatusRaw() { return statusRaw; }
public void setStatusRaw(String statusRaw) { this.statusRaw = statusRaw; }
public Integer getIsGateway() { return isGateway; }
public void setIsGateway(Integer isGateway) { this.isGateway = isGateway; }

View File

@ -581,6 +581,8 @@ public class DevicesFragment extends Fragment {
td.setPlatform(bd.getPlatform());
td.setParentDeviceId(bd.getParentDeviceId());
td.setIsGateway(bd.getIsGateway());
// 保留后端原始状态,供列表区分"在线/离线/状态未知"
td.setStatusRaw(bd.getStatus());
// 后端设备类型作为兜底(涂鸦设备若 SDK 有 category 会覆盖)
if (bd.getDeviceType() != null && !bd.getDeviceType().isEmpty()) {
td.setCategory(bd.getDeviceType());
@ -593,10 +595,20 @@ public class DevicesFragment extends Fragment {
// 在线状态以涂鸦 SDK 实时数据为准(后端 status 可能未及时刷新)
boolean sdkOnline = com.xhai.iot.service.TuyaSdkService.resolveDeviceOnline(
bd.getDeviceId(), devBean, bd.isOnline(), bd.getParentDeviceId());
td.setOnline(sdkOnline);
if (sdkOnline != bd.isOnline()) {
com.xhai.iot.service.TuyaSdkService
.reportStatusToBackend(bd.getDeviceId(), sdkOnline, null);
// 网关下挂设备(子设备)特殊处理:涂鸦 SDK / 云端快照对 Mesh、Zigbee
// 子设备的 online 恒为 false(通电在线也一样),照搬会把明明在线的
// 子设备显示成离线;此时以「父网关在线」为权威依据 —— 后端已按父网关
// 聚合过状态,这里直接采信,不再用 SDK 快照覆盖。
boolean isSubDevice = bd.getParentDeviceId() != null
&& !bd.getParentDeviceId().isEmpty();
if (isSubDevice) {
td.setOnline(bd.isOnline());
} else {
td.setOnline(sdkOnline);
if (sdkOnline != bd.isOnline()) {
com.xhai.iot.service.TuyaSdkService
.reportStatusToBackend(bd.getDeviceId(), sdkOnline, null);
}
}
if (devBean.getDps() != null) {
td.setDps(devBean.getDps());

View File

@ -145,7 +145,7 @@ public class DeviceRowAdapter extends RecyclerView.Adapter<DeviceRowAdapter.View
TuyaDevice device = devices.get(position);
boolean online = device.isOnline();
holder.tvName.setText(device.getName());
holder.tvSub.setText((online ? "在线" : "离线") + " · 涂鸦");
holder.tvSub.setText(statusText(device) + " · 涂鸦");
// 图标
String iconUrl = device.getIcon();
@ -175,6 +175,26 @@ public class DeviceRowAdapter extends RecyclerView.Adapter<DeviceRowAdapter.View
});
}
/**
* 在线状态文案。
*
* <p>后端对网关下挂的子设备会按父网关聚合状态,取不到权威信息时用 unknown
* 表达"状态未知",而不是一律兜底成"离线"(曾导致通电在线的子设备显示离线)。</p>
*/
private String statusText(TuyaDevice device) {
String raw = device.getStatusRaw();
if (raw == null || raw.isEmpty()) {
return device.isOnline() ? "在线" : "离线";
}
if ("online".equalsIgnoreCase(raw)) {
return "在线";
}
if ("offline".equalsIgnoreCase(raw)) {
return "离线";
}
return "状态未知";
}
/**
* 绑定胶囊开关与状态文字(抽成独立方法)。
* 局部刷新时只调用这里,不重载图标,避免列表闪烁

View File

@ -23,6 +23,7 @@ import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.LinkedHashMap;
@ -412,8 +413,12 @@ public class TuyaController {
update.setPlatform("tuya");
update.setDeviceType(tuyaDevice.getCategory() != null
? tuyaDevice.getCategory() : existing.getDeviceType());
update.setStatus(Boolean.TRUE.equals(tuyaDevice.getOnline())
? "online" : "offline");
update.setStatus(resolveEffectiveOnlineStatus(existing, tuyaDevice.getOnline()));
if (tuyaDevice.getParentDeviceId() != null && !tuyaDevice.getParentDeviceId().isEmpty()) {
// 子设备的父子关系由前端从涂鸦 SDK 一并上报
update.setParentDeviceId(tuyaDevice.getParentDeviceId());
update.setIsGateway(0);
}
update.setPlatformProductId(tuyaDevice.getProductId() != null
? tuyaDevice.getProductId() : existing.getPlatformProductId());
update.setIcon(tuyaDevice.getIcon() != null
@ -435,8 +440,9 @@ public class TuyaController {
localDevice.setPlatform("tuya");
localDevice.setDeviceType(tuyaDevice.getCategory() != null
? tuyaDevice.getCategory() : "unknown");
localDevice.setStatus(Boolean.TRUE.equals(tuyaDevice.getOnline())
? "online" : "offline");
localDevice.setStatus(resolveEffectiveOnlineStatus(null, tuyaDevice.getOnline()));
localDevice.setParentDeviceId(tuyaDevice.getParentDeviceId());
localDevice.setIsGateway(resolveIsGateway(tuyaDevice) ? 1 : 0);
localDevice.setLocation(req.getRoomId() != null
? String.valueOf(req.getRoomId()) : null);
// 家庭/房间真实关联(V11: device.home_id/room_id)
@ -526,7 +532,7 @@ public class TuyaController {
update.setPlatform(existing.getPlatform());
}
if (req.getOnline() != null) {
update.setStatus(req.getOnline() ? "online" : "offline");
update.setStatus(resolveEffectiveOnlineStatus(existing, req.getOnline()));
}
// 更新DP能力(存到capabilities字段的dps子键,便于查询当前状态)
if (req.getDps() != null && !req.getDps().isEmpty()) {
@ -561,6 +567,58 @@ public class TuyaController {
return Result.success(1);
}
/**
* 计算涂鸦设备的有效在线状态。
*
* <p><b>为什么需要它:</b>涂鸦 Mesh/Zigbee 子设备没有独立的在线通道 —— 涂鸦 SDK 与
* OpenAPI 快照对它们的 online 恒为 false(实测:通电在线的 JD6BTSA 云端仍为 false)。
* APP 把这个 false 上报后,device.status 就被写成 offline,用户看到「明明通电、网关
* 在线,子设备却显示离线」。</p>
*
* <p><b>判定规则:</b></p>
* <ul>
* <li>上报在线 → online;</li>
* <li>子设备(parent_device_id 非空)上报离线 → 以父网关为准:父网关在线则 online,
* 否则 offline;</li>
* <li>顶层设备上报离线 → offline。</li>
* </ul>
*/
private String resolveEffectiveOnlineStatus(Device device, Boolean reportedOnline) {
if (Boolean.TRUE.equals(reportedOnline)) {
return "online";
}
String parentId = device == null ? null : device.getParentDeviceId();
if (parentId != null && !parentId.isEmpty()) {
Device parent = deviceMapper.selectByDeviceId(parentId);
if (parent != null && "online".equalsIgnoreCase(parent.getStatus())) {
log.info("子设备在线状态按父网关判定为在线: device={}, parent={}",
device.getDeviceId(), parentId);
return "online";
}
}
return "offline";
}
/**
* 判断是否为网关设备。
*
* <p>子设备一定有父设备,因此「没有父设备」且品类属于网关类(wg/gateway/gw)即为网关。</p>
*/
private boolean resolveIsGateway(TuyaBindRequest.DeviceInfo info) {
if (info == null) {
return false;
}
if (info.getParentDeviceId() != null && !info.getParentDeviceId().isEmpty()) {
return false;
}
String category = info.getCategory();
if (category == null) {
return false;
}
String c = category.trim().toLowerCase(Locale.ROOT);
return "wg".equals(c) || "gateway".equals(c) || "gw".equals(c) || "sub_gateway".equals(c);
}
// ==================== 设备控制 ====================
/**

View File

@ -57,5 +57,15 @@ public class TuyaBindRequest {
private String productId;
@Schema(description = "设备图标URL")
private String icon;
/**
* 父设备(网关)ID
*
* <p>涂鸦 Mesh/Zigbee 子设备没有独立在线通道,涂鸦 SDK 与云端快照对它们的
* online 恒为 false(实测通电在线的子设备也恒 false)。子设备的在线状态只能
* 依据「父网关是否在线」判定,因此前端需把涂鸦 SDK 里的父设备关系一并上报,
* 后端据此写入 device.parent_device_id。</p>
*/
@Schema(description = "父设备(网关)ID")
private String parentDeviceId;
}
}

View File

@ -174,9 +174,55 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
Page<Device> result = page(new Page<>(query.getPageNum(), query.getPageSize()), wrapper);
fillProductInfo(result.getRecords());
resolveSubDeviceStatus(result.getRecords());
return result;
}
/**
* 子设备在线状态聚合(仅影响返回结果,不写库)。
*
* <p>涂鸦 Mesh/Zigbee 子设备没有独立的在线通道 —— 涂鸦 SDK 与 OpenAPI 快照对它们的
* online 恒为 false,APP 把这个 false 上报后,库里存的就是 offline。用户明明看到
* 子设备通电、网关在线,列表里却显示离线。</p>
*
* <p>这里以「父网关在线」为判据做一次聚合:父网关在线 → 子设备按在线展示;
* 父网关不在线且子设备状态未知 → 按离线展示。写入侧同样有对<EFBFBD><EFBFBD>规则
* (TuyaController.resolveEffectiveOnlineStatus),这里是展示侧的兜底,
* 保证历史数据也能立即显示正确。</p>
*/
private void resolveSubDeviceStatus(List<Device> devices) {
if (devices == null || devices.isEmpty()) {
return;
}
Set<String> parentIds = devices.stream()
.map(Device::getParentDeviceId)
.filter(StringUtils::hasText)
.collect(Collectors.toSet());
if (parentIds.isEmpty()) {
return;
}
Map<String, Device> parentMap = baseMapper.selectList(new LambdaQueryWrapper<Device>()
.in(Device::getDeviceId, parentIds))
.stream()
.collect(Collectors.toMap(Device::getDeviceId, d -> d, (a, b) -> a));
for (Device device : devices) {
String parentId = device.getParentDeviceId();
if (!StringUtils.hasText(parentId)) {
continue;
}
Device parent = parentMap.get(parentId);
if (parent == null) {
continue;
}
if ("online".equalsIgnoreCase(parent.getStatus())) {
device.setStatus("online");
} else if (!StringUtils.hasText(device.getStatus())
|| "unknown".equalsIgnoreCase(device.getStatus())) {
device.setStatus("offline");
}
}
}
/**
* 批量回填设备所属产品名称与业务编码(供后台设备列表展示"所属产品"列)。
*/

View File

@ -15,8 +15,13 @@
-- 品类字典只是"可选值来源",避免历史数据与物模型解析受影响。
--
-- 无存储过程、无 DELIMITER;幂等可重复执行。
--
-- 注意:本脚本含中文字面量,首行必须 SET NAMES utf8mb4 —— 服务器 mysql 客户端在 C locale 下
-- character_set_client 默认 latin1,缺少该设置会把 UTF-8 字节按 latin1 解读,导致中文双重编码乱码。
-- =====================================================================
SET NAMES utf8mb4;
-- ---------- 1) 品类字典表 ----------
CREATE TABLE IF NOT EXISTS product_category (
id BIGINT NOT NULL COMMENT '主键',

View File

@ -0,0 +1,32 @@
-- =====================================================================
-- V26 · 修复品类字典中文乱码(双重编码)
-- ---------------------------------------------------------------------
-- 现象:后台「品类管理」品类名称显示为 "煉明" 之类乱码。
--
-- 成因:服务器 mysql 客户端运行在 C locale,character_set_client 默认 latin1
-- (容器内 `SELECT @@character_set_client` → latin1)。
-- 迁移脚本经 Windows `type | plink` 管道喂给客户端时,UTF-8 字节被当作
-- latin1 字符接收,再按 utf8mb4 列存储 → 中文变成双重编码。
-- (历史数据如 product.name 中文正常,是因为当时用 docker cp 进容器再执行,
-- 没走管道。)
--
-- 处理:
-- 1) 本脚本首行 SET NAMES utf8mb4,保证脚本里的中文字面量按 utf8mb4 解释;
-- 2) 直接按 code 写回正确的中文名称与备注(幂等:仅在值不一致时才更新);
-- 3) 后续所有含中文的迁移脚本都需在首行加 SET NAMES utf8mb4,
-- deploy-all.bat 也已为 mysql 客户端补 --default-character-set=utf8mb4。
--
-- 无存储过程、无 DELIMITER;幂等可重复执行。
-- =====================================================================
SET NAMES utf8mb4;
UPDATE product_category SET name = '照明', remark = '灯/灯泡/灯带', update_time = NOW() WHERE deleted = 0 AND code = 'light';
UPDATE product_category SET name = '开关/插座', remark = '开关/插座/墙面面板', update_time = NOW() WHERE deleted = 0 AND code = 'switch';
UPDATE product_category SET name = '空调/暖通', remark = '空调/风扇/取暖器', update_time = NOW() WHERE deleted = 0 AND code = 'ac';
UPDATE product_category SET name = '窗帘', remark = '窗帘/遮阳', update_time = NOW() WHERE deleted = 0 AND code = 'curtain';
UPDATE product_category SET name = '传感器', remark = '温湿度/人体/门磁', update_time = NOW() WHERE deleted = 0 AND code = 'sensor';
UPDATE product_category SET name = '门锁', remark = '智能门锁', update_time = NOW() WHERE deleted = 0 AND code = 'lock';
UPDATE product_category SET name = '安防', remark = '报警器/摄像头', update_time = NOW() WHERE deleted = 0 AND code = 'security';
UPDATE product_category SET name = '其它', remark = '未归类', update_time = NOW() WHERE deleted = 0 AND code = 'other';

View File

@ -45,10 +45,10 @@ echo [3/8] 应用未执行的 SQL 迁移(%BACKEND_DIR%\sql\V*.sql,按 db_mig
echo ============================================
set MIG_DIR=%BACKEND_DIR%\sql
set CHK_FILE=%TEMP%\_mig_chk.txt
%PLINK% %PLINK_OPTS% "docker exec -i iot-mysql mysql -uroot -p'IoT.p2026.Xk9Qm' iot_platform -e \"CREATE TABLE IF NOT EXISTS db_migrations_applied (name VARCHAR(80) NOT NULL PRIMARY KEY, applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)\"" 2>nul
%PLINK% %PLINK_OPTS% "docker exec -i iot-mysql mysql --default-character-set=utf8mb4 -uroot -p'IoT.p2026.Xk9Qm' iot_platform -e \"CREATE TABLE IF NOT EXISTS db_migrations_applied (name VARCHAR(80) NOT NULL PRIMARY KEY, applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)\"" 2>nul
REM 首次使用(tracking 表为空)则标记当前所有 V 文件为已应用:假定目标库已手工迁移到最新。
REM 全新库请先手动执行 sql/ 下全部 V 迁移,再运行本脚本(或清空 db_migrations_applied 让其补跑,需迁移文件幂等)。
%PLINK% %PLINK_OPTS% "docker exec -i iot-mysql mysql -uroot -p'IoT.p2026.Xk9Qm' iot_platform -N -e \"SELECT COUNT(*) FROM db_migrations_applied\"" 2>nul > "%CHK_FILE%"
%PLINK% %PLINK_OPTS% "docker exec -i iot-mysql mysql --default-character-set=utf8mb4 -uroot -p'IoT.p2026.Xk9Qm' iot_platform -N -e \"SELECT COUNT(*) FROM db_migrations_applied\"" 2>nul > "%CHK_FILE%"
set /p MIG_CNT=<"%CHK_FILE%"
if "!MIG_CNT!"=="0" (
echo 首次使用:标记当前所有 V 迁移为已应用(假定库已迁移到最新)
@ -118,12 +118,12 @@ goto :eof
:: ============ 迁移子例程(顶层执行,避开括号块内转义问题) ============
:mig_seed
%PLINK% %PLINK_OPTS% "docker exec -i iot-mysql mysql -uroot -p'IoT.p2026.Xk9Qm' iot_platform -e \"INSERT IGNORE INTO db_migrations_applied(name) VALUES('%1')\"" 2>nul
%PLINK% %PLINK_OPTS% "docker exec -i iot-mysql mysql --default-character-set=utf8mb4 -uroot -p'IoT.p2026.Xk9Qm' iot_platform -e \"INSERT IGNORE INTO db_migrations_applied(name) VALUES('%1')\"" 2>nul
goto :eof
:mig_apply
set CHK=0
%PLINK% %PLINK_OPTS% "docker exec -i iot-mysql mysql -uroot -p'IoT.p2026.Xk9Qm' iot_platform -N -e \"SELECT COUNT(*) FROM db_migrations_applied WHERE name='%1'\"" 2>nul > "%CHK_FILE%"
%PLINK% %PLINK_OPTS% "docker exec -i iot-mysql mysql --default-character-set=utf8mb4 -uroot -p'IoT.p2026.Xk9Qm' iot_platform -N -e \"SELECT COUNT(*) FROM db_migrations_applied WHERE name='%1'\"" 2>nul > "%CHK_FILE%"
set /p CHK=<"%CHK_FILE%"
if not "!CHK!"=="0" (
echo 跳过已应用 %1
@ -131,7 +131,7 @@ if not "!CHK!"=="0" (
)
echo 应用 %1 ...
REM --force: 迁移脚本中"已存在"的 DDL(重复列/索引)属正常情况,不应中断整体部署
type "%MIG_DIR%\%1" | %PLINK% %PLINK_OPTS% "docker exec -i iot-mysql mysql --force -uroot -p'IoT.p2026.Xk9Qm' iot_platform"
type "%MIG_DIR%\%1" | %PLINK% %PLINK_OPTS% "docker exec -i iot-mysql mysql --force --default-character-set=utf8mb4 -uroot -p'IoT.p2026.Xk9Qm' iot_platform"
if !ERRORLEVEL! NEQ 0 ( echo [错误] 迁移 %1 执行失败。 & pause & exit /b 1 )
%PLINK% %PLINK_OPTS% "docker exec -i iot-mysql mysql -uroot -p'IoT.p2026.Xk9Qm' iot_platform -e \"INSERT IGNORE INTO db_migrations_applied(name) VALUES('%1')\"" 2>nul
%PLINK% %PLINK_OPTS% "docker exec -i iot-mysql mysql --default-character-set=utf8mb4 -uroot -p'IoT.p2026.Xk9Qm' iot_platform -e \"INSERT IGNORE INTO db_migrations_applied(name) VALUES('%1')\"" 2>nul
goto :eof

View File

@ -85,8 +85,12 @@ class DeviceService {
name: item.deviceName || item.name,
type: item.deviceType || item.type || 'default',
category: item.category || 'default',
online: item.status === 'ONLINE' || item.online,
status: this.parseStatus(item.statusData || item.status),
// 后端状态是小写的 online/offline(网关下挂子设备可能为 unknown),
// 这里统一按小写比较并兼容布尔值;此前比的是大写 'ONLINE',导致设备恒显示离线。
online: String(item.status ?? '').toLowerCase() === 'online' || item.online === true,
// status 字段是状态字符串而非 DP JSON,不能拿去做 JSON 解析;
// 真正的 DP 在 capabilities.dps 里。
status: this.parseStatus(item.capabilities?.dps || item.statusData || {}),
platform: 'local' as const,
icon: item.icon,
roomId: item.roomId,