diff --git a/Android/app/src/main/java/com/xhai/iot/ui/DeviceActivity.java b/Android/app/src/main/java/com/xhai/iot/ui/DeviceActivity.java index 8964f96..37ceed8 100644 --- a/Android/app/src/main/java/com/xhai/iot/ui/DeviceActivity.java +++ b/Android/app/src/main/java/com/xhai/iot/ui/DeviceActivity.java @@ -222,9 +222,13 @@ public class DeviceActivity extends AppCompatActivity { .enqueue(new Callback>() { @Override public void onResponse(Call> call, Response> response) { - if (response.isSuccessful() && response.body() != null - && response.body().isSuccess()) { + Result body = response.body(); + if (body != null && body.isSuccess()) { // 同步成功(后端会自动对账:已不存在的设备会被清理) + } else { + // 后端拒绝(如未绑定涂鸦账号)时记录日志,不再静默失败 + android.util.Log.w("DeviceActivity", "设备同步被拒绝: " + + (body != null ? body.getMessage() : "未知原因")); } } diff --git a/Android/app/src/main/java/com/xhai/iot/ui/DevicesFragment.java b/Android/app/src/main/java/com/xhai/iot/ui/DevicesFragment.java index 764ffb4..f42e0c2 100644 --- a/Android/app/src/main/java/com/xhai/iot/ui/DevicesFragment.java +++ b/Android/app/src/main/java/com/xhai/iot/ui/DevicesFragment.java @@ -451,6 +451,41 @@ public class DevicesFragment extends Fragment { private void maybeSyncMissingSdkDevices(final Long platformHomeId, List backendList) { if (sdkMissingSyncDone || platformHomeId == null || !isAdded()) return; sdkMissingSyncDone = true; + // ===== 绑定门禁(与后端 TuyaController.bindDevices 同款)===== + // 涂鸦 SDK 登录态与「已绑定到本平台」是两套状态:手机登录过涂鸦并不代表 + // 已绑定本平台。若不校验就补同步,会把涂鸦侧设备写成本平台设备(孤儿设备), + // 表现为设备列表里出现无法控制的设备。这里先问后端绑定状态,未绑定直接跳过。 + checkBackendBoundThenSync(platformHomeId, backendList); + } + + private void checkBackendBoundThenSync(final Long platformHomeId, List backendList) { + String auth = session.getBearerToken(); + if (auth == null) return; + ApiClient.getApiService().getTuyaBindStatus(auth) + .enqueue(new retrofit2.Callback>>() { + @Override + public void onResponse(retrofit2.Call>> call, + retrofit2.Response>> response) { + Result> body = response.body(); + boolean bound = body != null && body.isSuccess() && body.getData() != null + && Boolean.TRUE.equals(body.getData().get("bound")); + if (!bound) { + android.util.Log.i("DevicesFragment", + "未绑定本平台涂鸦账号,跳过设备补同步(避免写入孤儿设备)"); + return; + } + doSyncMissingSdkDevices(platformHomeId, backendList); + } + + @Override + public void onFailure(retrofit2.Call>> call, Throwable t) { + // 绑定状态查询失败不阻断:后端还有一道同款门禁兜底 + doSyncMissingSdkDevices(platformHomeId, backendList); + } + }); + } + + private void doSyncMissingSdkDevices(final Long platformHomeId, List backendList) { com.xhai.iot.service.TuyaSdkService.getHomes( new com.xhai.iot.service.TuyaSdkService.HomeListCallback() { @Override @@ -517,7 +552,14 @@ public class DevicesFragment extends Fragment { @Override public void onResponse(retrofit2.Call> call, retrofit2.Response> response) { - if (isAdded()) loadDevices(); + Result body = response.body(); + if (body != null && body.isSuccess()) { + if (isAdded()) loadDevices(); + } else { + // 后端拒绝(如未绑定涂鸦账号)时给出可读提示,不再静默失败 + android.util.Log.w("DevicesFragment", "补同步被拒绝: " + + (body != null ? body.getMessage() : "未知原因")); + } } @Override diff --git a/backend/iot-device/src/main/java/com/iot/device/controller/ProductCategoryController.java b/backend/iot-device/src/main/java/com/iot/device/controller/ProductCategoryController.java new file mode 100644 index 0000000..0c7f91e --- /dev/null +++ b/backend/iot-device/src/main/java/com/iot/device/controller/ProductCategoryController.java @@ -0,0 +1,85 @@ +package com.iot.device.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.iot.common.result.Result; +import com.iot.device.entity.ProductCategory; +import com.iot.device.service.ProductCategoryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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; + +/** + * 产品品类管理控制器(后台) + * + *

品类原先只是 product.category 上的自由文本、可选值写死在前端; + * 这里把品类提升为后台可维护的字典,支撑运营侧增删改查。

+ */ +@Tag(name = "品类管理", description = "产品品类字典管理") +@RestController +@RequestMapping("/api/v1/product-categories") +@RequiredArgsConstructor +public class ProductCategoryController { + + private final ProductCategoryService productCategoryService; + + @Operation(summary = "品类分页列表") + @GetMapping + public Result> list( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) Boolean enabled) { + return Result.success(productCategoryService.pageCategories(page, size, keyword, enabled)); + } + + @Operation(summary = "全部启用品类(字典下拉用)") + @GetMapping("/all") + public Result> listAll() { + return Result.success(productCategoryService.listEnabled()); + } + + @Operation(summary = "品类详情") + @GetMapping("/{id}") + public Result detail(@PathVariable Long id) { + return Result.success(productCategoryService.getById(id)); + } + + @Operation(summary = "创建品类") + @PostMapping + public Result create(@RequestBody ProductCategory category) { + return Result.success(productCategoryService.create(category)); + } + + @Operation(summary = "更新品类") + @PutMapping("/{id}") + public Result update(@PathVariable Long id, @RequestBody ProductCategory category) { + category.setId(id); + productCategoryService.update(category); + return Result.success(); + } + + @Operation(summary = "删除品类") + @DeleteMapping("/{id}") + public Result delete(@PathVariable Long id) { + productCategoryService.delete(id); + return Result.success(); + } + + @Operation(summary = "启用/禁用品类") + @PutMapping("/{id}/enabled") + public Result toggleEnabled(@PathVariable Long id, @RequestParam Boolean enabled) { + productCategoryService.toggleEnabled(id, enabled); + return Result.success(); + } +} diff --git a/backend/iot-device/src/main/java/com/iot/device/controller/TuyaController.java b/backend/iot-device/src/main/java/com/iot/device/controller/TuyaController.java index 0923be5..3b6eacf 100644 --- a/backend/iot-device/src/main/java/com/iot/device/controller/TuyaController.java +++ b/backend/iot-device/src/main/java/com/iot/device/controller/TuyaController.java @@ -2,6 +2,7 @@ package com.iot.device.controller; import com.iot.common.exception.BusinessException; import com.iot.common.result.Result; +import com.iot.common.result.ResultCode; import com.iot.common.util.JwtUtil; import com.iot.device.dto.tuya.*; import com.iot.device.collector.DevicePlatform; @@ -287,13 +288,34 @@ public class TuyaController { * 绑定涂鸦设备到我们的平台 * 直接信任前端传来的设备详情(前端从涂鸦 SDK 拿到), * 不再依赖后端涂鸦 OpenAPI(占位 token 拿不到设备列表) + * + *

归属校验(2026-10-28 补齐):本接口不需要涂鸦 token,因此历史上绕过了 + * 唯一带绑定校验的 {@code getUserAccessToken} 门禁 —— 任何登录用户都能把「自己手机上 + * 涂鸦 SDK 里的设备」写成本平台的设备,产生 owner 在 tuya_user_binding 中无记录的 + * 「孤儿设备」,既无法控制又会被联动规则反复命中。现在统一收口三道校验:

+ *
    + *
  1. 绑定门禁:本平台未绑定涂鸦账号 → 拒绝(与其它涂鸦接口一致);
  2. + *
  3. 归属白名单:上报的 deviceId 必须存在于该用户涂鸦账号的真实设备清单中 + * (拉取失败时降级放行并告警,不因涂鸦云故障阻断同步);
  4. + *
  5. owner 校验:已存在(含已逻辑删除)的记录若属于他人,则跳过不动。
  6. + *
*/ @Operation(summary = "绑定涂鸦设备") @PostMapping("/devices/bind") public Result bindDevices(@RequestBody TuyaBindRequest req, HttpServletRequest request) { Long memberId = extractMemberId(request); + if (memberId == null) { + throw new BusinessException(ResultCode.UNAUTHORIZED, "请先登录"); + } + // ===== 绑定门禁:必须先在本平台绑定涂鸦账号 ===== + TuyaUserBinding binding = tuyaUserBindingMapper.selectByMemberId(memberId); + if (binding == null) { + throw new BusinessException(ResultCode.NOT_BIND_TUYA, "请先在个人中心绑定涂鸦账号"); + } + int count = 0; + int skipped = 0; List deviceList = req.getDevices(); // 兼容旧版本:如果只传了 deviceIds,从 id 构造基本信息 @@ -315,6 +337,24 @@ public class TuyaController { return Result.success(0); } + // ===== 归属白名单:以该用户涂鸦账号下的真实设备为准 ===== + // 注意 req.homeId 是「本平台家庭ID」,与涂鸦 homeId 不是同一套 ID, + // 因此这里用 0(拉取该账号下全部设备)做归属判定。 + java.util.Set allowedDeviceIds = null; + try { + List cloudDevices = tuyaCloudService.getDeviceList(memberId, 0L); + allowedDeviceIds = new java.util.HashSet<>(); + for (TuyaDeviceDTO d : cloudDevices) { + if (d != null && d.getId() != null && !d.getId().isEmpty()) { + allowedDeviceIds.add(d.getId()); + } + } + log.info("涂鸦设备同步:memberId={} 账号下真实设备 {} 台", memberId, allowedDeviceIds.size()); + } catch (Exception e) { + log.warn("拉取涂鸦设备清单失败,本次同步降级为不做归属校验: memberId={}, err={}", + memberId, e.getMessage()); + } + // 记录本次同步到的设备,用于同步后的"对账清理" java.util.Set syncedIds = new java.util.HashSet<>(); @@ -323,6 +363,13 @@ public class TuyaController { || tuyaDevice.getDeviceId().isEmpty()) { continue; } + // 归属白名单校验:非本账号的设备一律不落库 + if (allowedDeviceIds != null && !allowedDeviceIds.contains(tuyaDevice.getDeviceId())) { + log.warn("跳过非本涂鸦账号的设备: deviceId={}, memberId={}", + tuyaDevice.getDeviceId(), memberId); + skipped++; + continue; + } syncedIds.add(tuyaDevice.getDeviceId()); // 按 device_id 查所有记录(不区分 platform,避免 local 记录占用唯一索引导致冲突) Device existing = deviceMapper.selectByDeviceId(tuyaDevice.getDeviceId()); @@ -332,16 +379,34 @@ public class TuyaController { // 因此先把原记录恢复出来,再走下面的更新分支。 Device revoked = deviceMapper.selectAnyByDeviceId(tuyaDevice.getDeviceId()); if (revoked != null) { - deviceMapper.restoreById(revoked.getId()); + Long revokedOwner = revoked.getOwnerId(); + if (revokedOwner != null && revokedOwner != 0 && !revokedOwner.equals(memberId)) { + log.warn("跳过他人名下(已删除)的设备: deviceId={}, owner={}, memberId={}", + tuyaDevice.getDeviceId(), revokedOwner, memberId); + skipped++; + continue; + } + // 恢复的同时把归属改到当前用户,避免恢复后仍挂在原主人名下 + deviceMapper.restoreByIdWithOwner(revoked.getId(), memberId); existing = revoked; + existing.setOwnerId(memberId); log.info("设备重新绑定,恢复已删除记录: {}({})", tuyaDevice.getDeviceId(), revoked.getDeviceName()); } } if (existing != null) { + // ===== owner 校验:他人名下的设备一律不修改 ===== + Long existingOwner = existing.getOwnerId(); + if (existingOwner != null && existingOwner != 0 && !existingOwner.equals(memberId)) { + log.warn("跳过他人名下的设备: deviceId={}, owner={}, memberId={}", + tuyaDevice.getDeviceId(), existingOwner, memberId); + skipped++; + continue; + } // 已存在则更新为 tuya 平台 + 最新名称/状态(处理 local 记录或旧记录) Device update = new Device(); update.setId(existing.getId()); + update.setOwnerId(memberId); update.setDeviceName(tuyaDevice.getDeviceName() != null ? tuyaDevice.getDeviceName() : existing.getDeviceName()); update.setPlatform("tuya"); diff --git a/backend/iot-device/src/main/java/com/iot/device/entity/ProductCategory.java b/backend/iot-device/src/main/java/com/iot/device/entity/ProductCategory.java new file mode 100644 index 0000000..584749f --- /dev/null +++ b/backend/iot-device/src/main/java/com/iot/device/entity/ProductCategory.java @@ -0,0 +1,55 @@ +package com.iot.device.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.iot.common.entity.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 产品品类实体(品类字典) + * + *

品类此前只是 product.category 上的自由文本,前端把可选值写死成 8 个枚举; + * 该表把品类变成后台可维护的字典,运营可自行增删改。

+ * + *

兼容说明:product.category 字段保留不变(仍存品类 code), + * 本表只是"可选值来源",不影响历史产品数据与物模型解析。

+ */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("product_category") +@Schema(description = "产品品类") +public class ProductCategory extends BaseEntity { + + @Schema(description = "品类编码(light/switch/ac...)") + private String code; + + @Schema(description = "品类名称") + private String name; + + @Schema(description = "父级品类编码") + private String parentCode; + + @Schema(description = "图标") + private String icon; + + @Schema(description = "涂鸦品类编码(设备 category 字段)") + private String tuyaCode; + + @Schema(description = "是否启用") + private Boolean enabled; + + @Schema(description = "排序") + private Integer sort; + + @Schema(description = "备注") + private String remark; + + /** + * 引用该品类的产品数(列表查询时批量回填,非持久化) + */ + @Schema(description = "引用产品数") + @TableField(exist = false) + private Long productCount; +} diff --git a/backend/iot-device/src/main/java/com/iot/device/mapper/DeviceMapper.java b/backend/iot-device/src/main/java/com/iot/device/mapper/DeviceMapper.java index 291bd95..25fb94c 100644 --- a/backend/iot-device/src/main/java/com/iot/device/mapper/DeviceMapper.java +++ b/backend/iot-device/src/main/java/com/iot/device/mapper/DeviceMapper.java @@ -7,6 +7,8 @@ import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; +import java.util.List; + /** * 设备Mapper */ @@ -61,4 +63,31 @@ public interface DeviceMapper extends BaseMapper { @Update("UPDATE device SET deleted = 1, update_time = NOW() " + "WHERE deleted = 0 AND platform = 'tuya' AND owner_id = #{ownerId}") int invalidateTuyaDevicesByOwner(@Param("ownerId") Long ownerId); + + /** + * 恢复被逻辑删除的设备,并把归属人更新为当前用户 + * + *

原 restoreById 只把 deleted 置回 0,不动 owner_id —— 恢复后设备仍挂在 + * 原主人名下,新用户同步到同一 device_id 时就会出现"设备归属错乱"。 + * 这里在恢复的同时改写 owner_id,保证设备归属与当前登录用户一致。

+ * + * @param id 设备主键 + * @param ownerId 当前用户 + * @return 受影响行数 + */ + @Update("UPDATE device SET deleted = 0, owner_id = #{ownerId}, update_time = NOW() WHERE id = #{id}") + int restoreByIdWithOwner(@Param("id") Long id, @Param("ownerId") Long ownerId); + + /** + * 查询「名下仍有涂鸦设备、但已无涂鸦账号绑定记录」的用户ID(孤儿设备自愈用) + * + *

孤儿设备 = device.owner_id 指向某个 member,但 tuya_user_binding 里没有该 member, + * 即设备在列表里却无法通过涂鸦云端控制。产生原因:APP 端在「查看设备」时会自动把 + * 涂鸦 SDK 里的设备上报写库,而该上报路径历史上绕过了绑定校验。

+ */ + @Select("SELECT DISTINCT d.owner_id FROM device d " + + "WHERE d.deleted = 0 AND d.platform = 'tuya' " + + "AND d.owner_id IS NOT NULL AND d.owner_id > 0 " + + "AND NOT EXISTS (SELECT 1 FROM tuya_user_binding b WHERE b.member_id = d.owner_id)") + List selectOwnersWithoutTuyaBinding(); } diff --git a/backend/iot-device/src/main/java/com/iot/device/mapper/ProductCategoryMapper.java b/backend/iot-device/src/main/java/com/iot/device/mapper/ProductCategoryMapper.java new file mode 100644 index 0000000..49f9404 --- /dev/null +++ b/backend/iot-device/src/main/java/com/iot/device/mapper/ProductCategoryMapper.java @@ -0,0 +1,20 @@ +package com.iot.device.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.iot.device.entity.ProductCategory; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +/** + * 产品品类 Mapper + */ +@Mapper +public interface ProductCategoryMapper extends BaseMapper { + + /** + * 统计引用该品类编码的产品数(品类是否可删除的判定依据) + */ + @Select("SELECT COUNT(*) FROM product WHERE deleted = 0 AND category = #{code}") + long countProductsByCategory(@Param("code") String code); +} diff --git a/backend/iot-device/src/main/java/com/iot/device/service/ProductCategoryService.java b/backend/iot-device/src/main/java/com/iot/device/service/ProductCategoryService.java new file mode 100644 index 0000000..c4aec58 --- /dev/null +++ b/backend/iot-device/src/main/java/com/iot/device/service/ProductCategoryService.java @@ -0,0 +1,52 @@ +package com.iot.device.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.iot.device.entity.ProductCategory; + +import java.util.List; + +/** + * 产品品类服务 + */ +public interface ProductCategoryService { + + /** + * 分页查询品类 + * + * @param page 页码 + * @param size 每页条数 + * @param keyword 关键词(匹配编码/名称) + * @param enabled 启用状态筛选,null 表示不限 + */ + Page pageCategories(int page, int size, String keyword, Boolean enabled); + + /** + * 查询全部启用的品类(字典下拉用) + */ + List listEnabled(); + + /** + * 查询品类详情 + */ + ProductCategory getById(Long id); + + /** + * 创建品类 + */ + Long create(ProductCategory category); + + /** + * 更新品类 + */ + void update(ProductCategory category); + + /** + * 删除品类(被产品引用时拒绝) + */ + void delete(Long id); + + /** + * 启用/禁用品类 + */ + void toggleEnabled(Long id, Boolean enabled); +} diff --git a/backend/iot-device/src/main/java/com/iot/device/service/impl/ProductCategoryServiceImpl.java b/backend/iot-device/src/main/java/com/iot/device/service/impl/ProductCategoryServiceImpl.java new file mode 100644 index 0000000..33da406 --- /dev/null +++ b/backend/iot-device/src/main/java/com/iot/device/service/impl/ProductCategoryServiceImpl.java @@ -0,0 +1,180 @@ +package com.iot.device.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.iot.common.exception.BusinessException; +import com.iot.device.entity.ProductCategory; +import com.iot.device.mapper.ProductCategoryMapper; +import com.iot.device.service.ProductCategoryService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.Locale; + +/** + * 产品品类服务实现 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ProductCategoryServiceImpl implements ProductCategoryService { + + private final ProductCategoryMapper categoryMapper; + + @Override + public Page pageCategories(int page, int size, String keyword, Boolean enabled) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (StringUtils.hasText(keyword)) { + wrapper.and(wk -> wk.like(ProductCategory::getCode, keyword) + .or().like(ProductCategory::getName, keyword)); + } + if (enabled != null) { + wrapper.eq(ProductCategory::getEnabled, enabled); + } + wrapper.orderByAsc(ProductCategory::getSort) + .orderByDesc(ProductCategory::getCreateTime); + Page result = categoryMapper.selectPage(new Page<>(page, size), wrapper); + fillProductCount(result.getRecords()); + return result; + } + + @Override + public List listEnabled() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ProductCategory::getEnabled, true) + .orderByAsc(ProductCategory::getSort); + return categoryMapper.selectList(wrapper); + } + + @Override + public ProductCategory getById(Long id) { + ProductCategory category = categoryMapper.selectById(id); + if (category == null) { + throw new BusinessException("品类不存在"); + } + category.setProductCount(categoryMapper.countProductsByCategory(category.getCode())); + return category; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long create(ProductCategory category) { + normalize(category); + if (existsCode(category.getCode(), null)) { + throw new BusinessException("品类编码已存在:" + category.getCode()); + } + if (category.getEnabled() == null) { + category.setEnabled(true); + } + if (category.getSort() == null) { + category.setSort(999); + } + categoryMapper.insert(category); + log.info("创建品类成功: id={}, code={}, name={}", category.getId(), category.getCode(), category.getName()); + return category.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void update(ProductCategory category) { + if (category.getId() == null) { + throw new BusinessException("品类ID不能为空"); + } + ProductCategory existing = categoryMapper.selectById(category.getId()); + if (existing == null) { + throw new BusinessException("品类不存在"); + } + normalize(category); + if (existsCode(category.getCode(), category.getId())) { + throw new BusinessException("品类编码已存在:" + category.getCode()); + } + // 未提交的字段沿用原值,避免前端只传部分字段时把名称/排序清空 + if (!StringUtils.hasText(category.getName())) { + category.setName(existing.getName()); + } + if (category.getEnabled() == null) { + category.setEnabled(existing.getEnabled()); + } + if (category.getSort() == null) { + category.setSort(existing.getSort()); + } + categoryMapper.updateById(category); + log.info("更新品类成功: id={}, code={}", category.getId(), category.getCode()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void delete(Long id) { + ProductCategory existing = categoryMapper.selectById(id); + if (existing == null) { + throw new BusinessException("品类不存在"); + } + long used = categoryMapper.countProductsByCategory(existing.getCode()); + if (used > 0) { + throw new BusinessException("该品类已被 " + used + " 个产品引用,无法删除;可改为禁用"); + } + categoryMapper.deleteById(id); + log.info("删除品类成功: id={}, code={}", id, existing.getCode()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void toggleEnabled(Long id, Boolean enabled) { + ProductCategory existing = categoryMapper.selectById(id); + if (existing == null) { + throw new BusinessException("品类不存在"); + } + ProductCategory update = new ProductCategory(); + update.setId(id); + update.setEnabled(enabled != null && enabled); + categoryMapper.updateById(update); + log.info("品类启用状态变更: id={}, enabled={}", id, update.getEnabled()); + } + + /** + * 填充引用产品数 + */ + private void fillProductCount(List list) { + if (list == null || list.isEmpty()) { + return; + } + for (ProductCategory category : list) { + if (category.getCode() != null) { + category.setProductCount(categoryMapper.countProductsByCategory(category.getCode())); + } + } + } + + /** + * 编码标准化 + 必填校验 + */ + private void normalize(ProductCategory category) { + if (category == null) { + throw new BusinessException("品类不能为空"); + } + if (!StringUtils.hasText(category.getCode())) { + throw new BusinessException("品类编码不能为空"); + } + category.setCode(category.getCode().trim().toLowerCase(Locale.ROOT)); + if (!StringUtils.hasText(category.getName())) { + throw new BusinessException("品类名称不能为空"); + } + category.setName(category.getName().trim()); + } + + /** + * 编码是否已存在(excludeId 用于更新场景排除自身) + */ + private boolean existsCode(String code, Long excludeId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ProductCategory::getCode, code); + if (excludeId != null) { + wrapper.ne(ProductCategory::getId, excludeId); + } + return categoryMapper.selectCount(wrapper) > 0; + } +} diff --git a/backend/iot-device/src/main/java/com/iot/device/task/OrphanTuyaDeviceCleanupTask.java b/backend/iot-device/src/main/java/com/iot/device/task/OrphanTuyaDeviceCleanupTask.java new file mode 100644 index 0000000..31045b9 --- /dev/null +++ b/backend/iot-device/src/main/java/com/iot/device/task/OrphanTuyaDeviceCleanupTask.java @@ -0,0 +1,62 @@ +package com.iot.device.task; + +import com.iot.device.mapper.AiDeviceProfileMapper; +import com.iot.device.mapper.DeviceMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 孤儿涂鸦设备自愈任务。 + * + *

什么是孤儿设备:device.owner_id 指向某个会员,但 tuya_user_binding 里没有该会员, + * 即设备出现在本平台设备列表,却已没有可用的涂鸦账号去控制它。

+ * + *

成因:APP 端「查看设备」时会把涂鸦 SDK 里的设备自动上报写库,而该上报路径 + * 历史上绕过了绑定校验(已在 TuyaController.bindDevices 收口)。本任务作为兜底, + * 定期回收历史遗留的孤儿设备,避免它们继续被联动规则命中而产生大量失败指令。

+ * + *

处理方式为逻辑删除(deleted=1):device 表 uk_device_id 唯一索引不含 deleted, + * 用户重新绑定并同步时会被自动恢复,历史指令与活动日志也不会悬空。

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OrphanTuyaDeviceCleanupTask { + + private final DeviceMapper deviceMapper; + private final AiDeviceProfileMapper aiDeviceProfileMapper; + + /** + * 每天 04:30 执行一次 + */ + @Scheduled(cron = "0 30 4 * * ?") + public void cleanupOrphanTuyaDevices() { + try { + List owners = deviceMapper.selectOwnersWithoutTuyaBinding(); + if (owners == null || owners.isEmpty()) { + return; + } + int totalDevices = 0; + int totalProfiles = 0; + for (Long ownerId : owners) { + int devices = deviceMapper.invalidateTuyaDevicesByOwner(ownerId); + int profiles = aiDeviceProfileMapper.invalidateProfilesByOwner(ownerId); + if (devices > 0 || profiles > 0) { + log.info("孤儿涂鸦设备回收:会员 {} 无涂鸦账号绑定,失效设备 {} 个、AI 档案 {} 条", + ownerId, devices, profiles); + } + totalDevices += devices; + totalProfiles += profiles; + } + log.info("孤儿涂鸦设备回收完成:处理会员 {} 个,失效设备 {} 个、AI 档案 {} 条", + owners.size(), totalDevices, totalProfiles); + } catch (Exception e) { + log.warn("孤儿涂鸦设备回收任务执行失败: {}", e.getMessage()); + } + } +} + diff --git a/backend/iot-device/src/test/java/com/iot/device/controller/TuyaControllerBindTest.java b/backend/iot-device/src/test/java/com/iot/device/controller/TuyaControllerBindTest.java new file mode 100644 index 0000000..9e44290 --- /dev/null +++ b/backend/iot-device/src/test/java/com/iot/device/controller/TuyaControllerBindTest.java @@ -0,0 +1,201 @@ +package com.iot.device.controller; + +import com.iot.common.exception.BusinessException; +import com.iot.common.util.JwtUtil; +import com.iot.device.collector.CommandCollector; +import com.iot.device.collector.ObserveCollector; +import com.iot.device.dto.tuya.TuyaBindRequest; +import com.iot.device.dto.tuya.TuyaDeviceDTO; +import com.iot.device.entity.Device; +import com.iot.device.entity.TuyaUserBinding; +import com.iot.device.event.DeviceEventPublisher; +import com.iot.device.mapper.DeviceMapper; +import com.iot.device.mapper.TuyaUserBindingMapper; +import com.iot.device.service.AiDeviceProfileDetectService; +import com.iot.device.service.DeviceActivityService; +import com.iot.device.service.ProductService; +import com.iot.device.service.TuyaCloudService; +import com.iot.device.service.TuyaMessageSubscribeService; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 涂鸦设备绑定写入的归属校验单测 + * + *

覆盖三道收口:绑定门禁、归属白名单、owner 校验。

+ */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("涂鸦设备绑定归属校验") +class TuyaControllerBindTest { + + private static final Long MEMBER_ID = 1001L; + private static final Long OTHER_MEMBER_ID = 2002L; + + @Mock + private TuyaCloudService tuyaCloudService; + @Mock + private JwtUtil jwtUtil; + @Mock + private TuyaUserBindingMapper tuyaUserBindingMapper; + @Mock + private DeviceMapper deviceMapper; + @Mock + private DeviceEventPublisher deviceEventPublisher; + @Mock + private AiDeviceProfileDetectService aiDeviceProfileDetectService; + @Mock + private TuyaMessageSubscribeService tuyaMessageSubscribeService; + @Mock + private DeviceActivityService deviceActivityService; + @Mock + private ProductService productService; + @Mock + private CommandCollector commandCollector; + @Mock + private ObserveCollector observeCollector; + @Mock + private HttpServletRequest request; + + @InjectMocks + private TuyaController controller; + + @BeforeEach + void setUp() { + when(request.getHeader("Authorization")).thenReturn("Bearer test-token"); + when(jwtUtil.validateToken(anyString())).thenReturn(true); + when(jwtUtil.getUserIdFromToken(anyString())).thenReturn(MEMBER_ID); + } + + private void mockBound() { + TuyaUserBinding binding = new TuyaUserBinding(); + binding.setMemberId(MEMBER_ID); + when(tuyaUserBindingMapper.selectByMemberId(MEMBER_ID)).thenReturn(binding); + } + + private void mockCloudDevices(String... ids) { + TuyaDeviceDTO dto = new TuyaDeviceDTO(); + dto.setId("dev_ok"); + when(tuyaCloudService.getDeviceList(eq(MEMBER_ID), anyLong())) + .thenReturn(Collections.singletonList(dto)); + } + + private TuyaBindRequest requestOf(String... deviceIds) { + TuyaBindRequest req = new TuyaBindRequest(); + req.setHomeId(10L); + req.setDevices(new java.util.ArrayList<>()); + for (String id : deviceIds) { + TuyaBindRequest.DeviceInfo info = new TuyaBindRequest.DeviceInfo(); + info.setDeviceId(id); + info.setDeviceName("dev"); + info.setCategory("kj"); + info.setOnline(true); + info.setProductId("p1"); + req.getDevices().add(info); + } + return req; + } + + @Test + @DisplayName("未绑定涂鸦账号时拒绝写库") + void shouldRejectWhenNotBound() { + when(tuyaUserBindingMapper.selectByMemberId(MEMBER_ID)).thenReturn(null); + + BusinessException ex = assertThrows(BusinessException.class, + () -> controller.bindDevices(requestOf("dev_1"), request)); + assertTrue(ex.getMessage().contains("绑定涂鸦账号"), ex.getMessage()); + verify(deviceMapper, never()).insert(any()); + } + + @Test + @DisplayName("不在本账号涂鸦设备清单中的设备被跳过,不落库") + void shouldSkipDeviceOutsideCloudWhitelist() { + mockBound(); + mockCloudDevices("dev_ok"); + when(deviceMapper.selectByDeviceId(anyString())).thenReturn(null); + when(productService.ensureDeviceProduct(any(), anyString(), anyString(), anyString())) + .thenReturn(1L); + + controller.bindDevices(requestOf("dev_not_mine"), request); + + verify(deviceMapper, never()).insert(any()); + } + + @Test + @DisplayName("本账号设备正常落库并归属当前用户") + void shouldInsertOwnedDevice() { + mockBound(); + mockCloudDevices("dev_ok"); + when(deviceMapper.selectByDeviceId("dev_ok")).thenReturn(null); + when(productService.ensureDeviceProduct(any(), anyString(), anyString(), anyString())) + .thenReturn(1L); + + controller.bindDevices(requestOf("dev_ok"), request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Device.class); + verify(deviceMapper).insert(captor.capture()); + Device saved = captor.getValue(); + assertEquals(MEMBER_ID, saved.getOwnerId()); + assertEquals("tuya", saved.getPlatform()); + } + + @Test + @DisplayName("他人名下的设备不被改写") + void shouldNotTouchDeviceOwnedByOthers() { + mockBound(); + mockCloudDevices("dev_ok"); + Device existing = new Device(); + existing.setId(88L); + existing.setDeviceId("dev_ok"); + existing.setOwnerId(OTHER_MEMBER_ID); + when(deviceMapper.selectByDeviceId("dev_ok")).thenReturn(existing); + + controller.bindDevices(requestOf("dev_ok"), request); + + verify(deviceMapper, never()).updateById(any()); + verify(deviceMapper, never()).insert(any()); + } + + @Test + @DisplayName("恢复已删除设备时归属改到当前用户") + void shouldRestoreWithCurrentOwner() { + mockBound(); + mockCloudDevices("dev_ok"); + Device revoked = new Device(); + revoked.setId(99L); + revoked.setDeviceId("dev_ok"); + revoked.setOwnerId(MEMBER_ID); + revoked.setDeleted(1); + when(deviceMapper.selectByDeviceId("dev_ok")).thenReturn(null); + when(deviceMapper.selectAnyByDeviceId("dev_ok")).thenReturn(revoked); + when(productService.ensureDeviceProduct(any(), anyString(), anyString(), anyString())) + .thenReturn(1L); + + controller.bindDevices(requestOf("dev_ok"), request); + + verify(deviceMapper).restoreByIdWithOwner(eq(99L), eq(MEMBER_ID)); + } +} diff --git a/backend/sql/V25__add_product_category.sql b/backend/sql/V25__add_product_category.sql new file mode 100644 index 0000000..ac80102 --- /dev/null +++ b/backend/sql/V25__add_product_category.sql @@ -0,0 +1,67 @@ +-- ===================================================================== +-- V25 · 产品品类字典表(后台「品类管理」) +-- --------------------------------------------------------------------- +-- 背景: +-- product.category 一直只是一个自由文本 VARCHAR(32),后台前端把品类写死成 +-- 8 个枚举(light/switch/ac/curtain/sensor/lock/security/other), +-- 运营要增删品类只能改前端代码重新发布。 +-- +-- 处理: +-- 1) 新建 product_category 品类字典表(范式对齐 V22 的 platform_type) +-- 2) 灌入与前端硬编码一致的 8 个预设品类 +-- 3) 把存量 product.category 的取值回填进字典表 +-- +-- 兼容:product.category 字段保留不变(仍存品类 code), +-- 品类字典只是"可选值来源",避免历史数据与物模型解析受影响。 +-- +-- 无存储过程、无 DELIMITER;幂等可重复执行。 +-- ===================================================================== + +-- ---------- 1) 品类字典表 ---------- +CREATE TABLE IF NOT EXISTS product_category ( + id BIGINT NOT NULL COMMENT '主键', + code VARCHAR(32) NOT NULL COMMENT '品类编码(light/switch/ac...)', + name VARCHAR(64) NOT NULL COMMENT '品类名称', + parent_code VARCHAR(32) DEFAULT NULL COMMENT '父级品类编码', + icon VARCHAR(255) DEFAULT NULL COMMENT '图标', + tuya_code VARCHAR(32) DEFAULT NULL COMMENT '涂鸦品类编码(设备 category 字段)', + enabled TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用', + sort INT NOT NULL DEFAULT 0 COMMENT '排序', + remark VARCHAR(255) DEFAULT NULL COMMENT '备注', + tenant_id BIGINT DEFAULT NULL, + create_time DATETIME DEFAULT NULL, + update_time DATETIME DEFAULT NULL, + deleted INT DEFAULT 0, + create_by BIGINT DEFAULT NULL, + update_by BIGINT DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_product_category_code (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='产品品类字典表'; + +-- ---------- 2) 预设品类(与后台原有硬编码枚举一致) ---------- +INSERT INTO product_category +(id, code, name, parent_code, icon, tuya_code, enabled, sort, remark, create_time, update_time, deleted) +VALUES +(2250000000000000001, 'light', '照明', NULL, NULL, 'kj', 1, 10, '灯/灯泡/灯带', NOW(), NOW(), 0), +(2250000000000000002, 'switch', '开关/插座', NULL, NULL, 'kg', 1, 20, '开关/插座/墙面面板', NOW(), NOW(), 0), +(2250000000000000003, 'ac', '空调/暖通', NULL, NULL, 'kt', 1, 30, '空调/风扇/取暖器', NOW(), NOW(), 0), +(2250000000000000004, 'curtain', '窗帘', NULL, NULL, 'cl', 1, 40, '窗帘/遮阳', NOW(), NOW(), 0), +(2250000000000000005, 'sensor', '传感器', NULL, NULL, 'sensor', 1, 50, '温湿度/人体/门磁', NOW(), NOW(), 0), +(2250000000000000006, 'lock', '门锁', NULL, NULL, 'ms', 1, 60, '智能门锁', NOW(), NOW(), 0), +(2250000000000000007, 'security', '安防', NULL, NULL, 'security', 1, 70, '报警器/摄像头', NOW(), NOW(), 0), +(2250000000000000008, 'other', '其它', NULL, NULL, NULL, 1, 80, '未归类', NOW(), NOW(), 0) +ON DUPLICATE KEY UPDATE + name = VALUES(name), parent_code = VALUES(parent_code), tuya_code = VALUES(tuya_code), + sort = VALUES(sort), remark = VALUES(remark), update_time = NOW(); + +-- ---------- 3) 回填存量产品用到的品类 ---------- +-- 只回填字典表中尚不存在的 code;已存在(预设品类)保持原 id 与名称。 +INSERT INTO product_category +(id, code, name, parent_code, icon, tuya_code, enabled, sort, remark, create_time, update_time, deleted) +SELECT 2259000000000000000 + ROW_NUMBER() OVER (ORDER BY p.category), + p.category, p.category, NULL, NULL, NULL, 1, 900, '历史产品品类自动回填', NOW(), NOW(), 0 + FROM (SELECT DISTINCT category + FROM product + WHERE deleted = 0 AND category IS NOT NULL AND category <> '') p +ON DUPLICATE KEY UPDATE update_time = NOW(); + diff --git a/frontend/admin/src/api/productCategory.ts b/frontend/admin/src/api/productCategory.ts new file mode 100644 index 0000000..3b5839e --- /dev/null +++ b/frontend/admin/src/api/productCategory.ts @@ -0,0 +1,83 @@ +import request from './request' + +export interface ProductCategory { + id: number + code: string + name: string + parentCode?: string + icon?: string + tuyaCode?: string + enabled: boolean + sort: number + remark?: string + productCount?: number + createTime?: string + updateTime?: string +} + +export interface CategoryQuery { + keyword?: string + enabled?: boolean + pageNum: number + pageSize: number +} + +// 品类分页列表 +export const listProductCategories = (params: CategoryQuery) => { + return request({ + url: '/product-categories', + method: 'get', + params + }) +} + +// 全部启用品类(字典下拉用) +export const listAllProductCategories = () => { + return request({ + url: '/product-categories/all', + method: 'get' + }) +} + +// 品类详情 +export const getProductCategory = (id: number) => { + return request({ + url: `/product-categories/${id}`, + method: 'get' + }) +} + +// 创建品类 +export const createProductCategory = (data: Partial) => { + return request({ + url: '/product-categories', + method: 'post', + data + }) +} + +// 更新品类 +export const updateProductCategory = (id: number, data: Partial) => { + return request({ + url: `/product-categories/${id}`, + method: 'put', + data + }) +} + +// 删除品类 +export const deleteProductCategory = (id: number) => { + return request({ + url: `/product-categories/${id}`, + method: 'delete' + }) +} + +// 启用/禁用品类 +export const toggleProductCategory = (id: number, enabled: boolean) => { + return request({ + url: `/product-categories/${id}/enabled`, + method: 'put', + params: { enabled } + }) +} diff --git a/frontend/admin/src/router/index.ts b/frontend/admin/src/router/index.ts index 43b24a9..6ff52c8 100644 --- a/frontend/admin/src/router/index.ts +++ b/frontend/admin/src/router/index.ts @@ -73,6 +73,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/Product/ProductDetail.vue'), meta: { title: '产品详情', hidden: true, group: 'device' } }, + { + path: '/product-categories', + name: 'ProductCategories', + component: () => import('@/views/Product/ProductCategoryList.vue'), + meta: { title: '品类管理', icon: 'Grid', group: 'device' } + }, { path: '/system/users', name: 'SystemUsers', diff --git a/frontend/admin/src/views/Product/ProductCategoryList.vue b/frontend/admin/src/views/Product/ProductCategoryList.vue new file mode 100644 index 0000000..ea1d49d --- /dev/null +++ b/frontend/admin/src/views/Product/ProductCategoryList.vue @@ -0,0 +1,215 @@ + + + + + diff --git a/frontend/admin/src/views/Product/ProductList.vue b/frontend/admin/src/views/Product/ProductList.vue index 40fb328..9b8267f 100644 --- a/frontend/admin/src/views/Product/ProductList.vue +++ b/frontend/admin/src/views/Product/ProductList.vue @@ -148,17 +148,19 @@