feat(L): 新增品类管理, 收口涂鸦设备写入的归属校验

品类管理: 新建 product_category 品类字典表(V25, 灌入 8 个预设品类并回填存量 product.category), 后端提供 ProductCategory 增删改查/启停/字典接口, 后台新增「品类管理」页面(设备管理组, 与产品管理同级), 产品管理的品类下拉改为读字典(失败回退内置枚举)。

孤儿设备根治: TuyaController.bindDevices 收口三道校验 —— 绑定门禁(本平台未绑定涂鸦账号直接拒绝)、归属白名单(上报设备须在该用户涂鸦账号真实清单内, 拉取失败降级放行)、owner 校验(他人名下设备不改写; 恢复已删设备时归属改到当前用户)。

兜底自愈: 新增 OrphanTuyaDeviceCleanupTask, 每天 04:30 回收无绑定记录用户的涂鸦设备与 AI 档案(逻辑删除, 可恢复)。

Android: 查看设备时的补同步先确认后端绑定状态, 未绑定不再写库; 同步被拒时记录原因而非静默失败。单测 TuyaControllerBindTest 5 用例覆盖门禁/白名单/owner/恢复归属。
This commit is contained in:
2995840376 2026-10-28 01:10:59 +08:00
parent 4c2d92571e
commit 903b44c84b
17 changed files with 1202 additions and 9 deletions

View File

@ -222,9 +222,13 @@ public class DeviceActivity extends AppCompatActivity {
.enqueue(new Callback<Result<Integer>>() {
@Override
public void onResponse(Call<Result<Integer>> call, Response<Result<Integer>> response) {
if (response.isSuccessful() && response.body() != null
&& response.body().isSuccess()) {
Result<Integer> body = response.body();
if (body != null && body.isSuccess()) {
// 同步成功(后端会自动对账:已不存在的设备会被清理)
} else {
// 后端拒绝(如未绑定涂鸦账号)时记录日志,不再静默失败
android.util.Log.w("DeviceActivity", "设备同步被拒绝: "
+ (body != null ? body.getMessage() : "未知原因"));
}
}

View File

@ -451,6 +451,41 @@ public class DevicesFragment extends Fragment {
private void maybeSyncMissingSdkDevices(final Long platformHomeId, List<BackendDevice> backendList) {
if (sdkMissingSyncDone || platformHomeId == null || !isAdded()) return;
sdkMissingSyncDone = true;
// ===== 绑定门禁(与后端 TuyaController.bindDevices 同款)=====
// 涂鸦 SDK 登录态与「已绑定到本平台」是两套状态:手机登录过涂鸦并不代表
// 已绑定本平台。若不校验就补同步,会把涂鸦侧设备写成本平台设备(孤儿设备),
// 表现为设备列表里出现无法控制的设备。这里先问后端绑定状态,未绑定直接跳过。
checkBackendBoundThenSync(platformHomeId, backendList);
}
private void checkBackendBoundThenSync(final Long platformHomeId, List<BackendDevice> backendList) {
String auth = session.getBearerToken();
if (auth == null) return;
ApiClient.getApiService().getTuyaBindStatus(auth)
.enqueue(new retrofit2.Callback<Result<Map<String, Object>>>() {
@Override
public void onResponse(retrofit2.Call<Result<Map<String, Object>>> call,
retrofit2.Response<Result<Map<String, Object>>> response) {
Result<Map<String, Object>> 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<Result<Map<String, Object>>> call, Throwable t) {
// 绑定状态查询失败不阻断:后端还有一道同款门禁兜底
doSyncMissingSdkDevices(platformHomeId, backendList);
}
});
}
private void doSyncMissingSdkDevices(final Long platformHomeId, List<BackendDevice> 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<Result<Integer>> call,
retrofit2.Response<Result<Integer>> response) {
Result<Integer> body = response.body();
if (body != null && body.isSuccess()) {
if (isAdded()) loadDevices();
} else {
// 后端拒绝(如未绑定涂鸦账号)时给出可读提示,不再静默失败
android.util.Log.w("DevicesFragment", "补同步被拒绝: "
+ (body != null ? body.getMessage() : "未知原因"));
}
}
@Override

View File

@ -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;
/**
* 产品品类管理控制器(后台)
*
* <p>品类原先只是 product.category 上的自由文本、可选值写死在前端;
* 这里把品类提升为后台可维护的字典,支撑运营侧增删改查。</p>
*/
@Tag(name = "品类管理", description = "产品品类字典管理")
@RestController
@RequestMapping("/api/v1/product-categories")
@RequiredArgsConstructor
public class ProductCategoryController {
private final ProductCategoryService productCategoryService;
@Operation(summary = "品类分页列表")
@GetMapping
public Result<Page<ProductCategory>> 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<List<ProductCategory>> listAll() {
return Result.success(productCategoryService.listEnabled());
}
@Operation(summary = "品类详情")
@GetMapping("/{id}")
public Result<ProductCategory> detail(@PathVariable Long id) {
return Result.success(productCategoryService.getById(id));
}
@Operation(summary = "创建品类")
@PostMapping
public Result<Long> create(@RequestBody ProductCategory category) {
return Result.success(productCategoryService.create(category));
}
@Operation(summary = "更新品类")
@PutMapping("/{id}")
public Result<Void> update(@PathVariable Long id, @RequestBody ProductCategory category) {
category.setId(id);
productCategoryService.update(category);
return Result.success();
}
@Operation(summary = "删除品类")
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
productCategoryService.delete(id);
return Result.success();
}
@Operation(summary = "启用/禁用品类")
@PutMapping("/{id}/enabled")
public Result<Void> toggleEnabled(@PathVariable Long id, @RequestParam Boolean enabled) {
productCategoryService.toggleEnabled(id, enabled);
return Result.success();
}
}

View File

@ -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 拿不到设备列表)
*
* <p><b>归属校验(2026-10-28 补齐):</b>本接口不需要涂鸦 token,因此历史上绕过了
* 唯一带绑定校验的 {@code getUserAccessToken} 门禁 —— 任何登录用户都能把「自己手机上
* 涂鸦 SDK 里的设备」写成本平台的设备,产生 owner 在 tuya_user_binding 中无记录的
* 「孤儿设备」,既无法控制又会被联动规则反复命中。现在统一收口三道校验:</p>
* <ol>
* <li><b>绑定门禁</b>:本平台未绑定涂鸦账号 → 拒绝(与其它涂鸦接口一致);</li>
* <li><b>归属白名单</b>:上报的 deviceId 必须存在于该用户涂鸦账号的真实设备清单中
* (拉取失败时降级放行并告警,不因涂鸦云故障阻断同步);</li>
* <li><b>owner 校验</b>:已存在(含已逻辑删除)的记录若属于他人,则跳过不动。</li>
* </ol>
*/
@Operation(summary = "绑定涂鸦设备")
@PostMapping("/devices/bind")
public Result<Integer> 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<TuyaBindRequest.DeviceInfo> 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<String> allowedDeviceIds = null;
try {
List<TuyaDeviceDTO> 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<String> 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");

View File

@ -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;
/**
* 产品品类实体(品类字典)
*
* <p>品类此前只是 product.category 上的自由文本,前端把可选值写死成 8 个枚举;
* 该表把品类变成后台可维护的字典,运营可自行增删改。</p>
*
* <p>兼容说明:product.category 字段保留不变(仍存品类 code),
* 本表只是"可选值来源",不影响历史产品数据与物模型解析。</p>
*/
@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;
}

View File

@ -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<Device> {
@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);
/**
* 恢复被逻辑删除的设备,并把归属人更新为当前用户
*
* <p>原 restoreById 只把 deleted 置回 0,不动 owner_id —— 恢复后设备仍挂在
* 原主人名下,新用户同步到同一 device_id 时就会出现"设备归属错乱"。
* 这里在恢复的同时改写 owner_id,保证设备归属与当前登录用户一致。</p>
*
* @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(孤儿设备自愈用)
*
* <p>孤儿设备 = device.owner_id 指向某个 member,但 tuya_user_binding 里没有该 member,
* 即设备在列表里却无法通过涂鸦云端控制。产生原因:APP 端在「查看设备」时会自动把
* 涂鸦 SDK 里的设备上报写库,而该上报路径历史上绕过了绑定校验。</p>
*/
@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<Long> selectOwnersWithoutTuyaBinding();
}

View File

@ -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<ProductCategory> {
/**
* 统计引用该品类编码的产品数(品类是否可删除的判定依据)
*/
@Select("SELECT COUNT(*) FROM product WHERE deleted = 0 AND category = #{code}")
long countProductsByCategory(@Param("code") String code);
}

View File

@ -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<ProductCategory> pageCategories(int page, int size, String keyword, Boolean enabled);
/**
* 查询全部启用的品类(字典下拉用)
*/
List<ProductCategory> listEnabled();
/**
* 查询品类详情
*/
ProductCategory getById(Long id);
/**
* 创建品类
*/
Long create(ProductCategory category);
/**
* 更新品类
*/
void update(ProductCategory category);
/**
* 删除品类(被产品引用时拒绝)
*/
void delete(Long id);
/**
* 启用/禁用品类
*/
void toggleEnabled(Long id, Boolean enabled);
}

View File

@ -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<ProductCategory> pageCategories(int page, int size, String keyword, Boolean enabled) {
LambdaQueryWrapper<ProductCategory> 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<ProductCategory> result = categoryMapper.selectPage(new Page<>(page, size), wrapper);
fillProductCount(result.getRecords());
return result;
}
@Override
public List<ProductCategory> listEnabled() {
LambdaQueryWrapper<ProductCategory> 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<ProductCategory> 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<ProductCategory> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(ProductCategory::getCode, code);
if (excludeId != null) {
wrapper.ne(ProductCategory::getId, excludeId);
}
return categoryMapper.selectCount(wrapper) > 0;
}
}

View File

@ -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;
/**
* 孤儿涂鸦设备自愈任务。
*
* <p><b>什么是孤儿设备</b>:device.owner_id 指向某个会员,但 tuya_user_binding 里没有该会员,
* 即设备出现在本平台设备列表,却已没有可用的涂鸦账号去控制它。</p>
*
* <p><b>成因</b>:APP 端「查看设备」时会把涂鸦 SDK 里的设备自动上报写库,而该上报路径
* 历史上绕过了绑定校验(已在 TuyaController.bindDevices 收口)。本任务作为兜底,
* 定期回收历史遗留的孤儿设备,避免它们继续被联动规则命中而产生大量失败指令。</p>
*
* <p>处理方式为逻辑删除(deleted=1):device 表 uk_device_id 唯一索引不含 deleted,
* 用户重新绑定并同步时会被自动恢复,历史指令与活动日志也不会悬空。</p>
*/
@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<Long> 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());
}
}
}

View File

@ -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;
/**
* 涂鸦设备绑定写入的归属校验单测
*
* <p>覆盖三道收口:绑定门禁、归属白名单、owner 校验。</p>
*/
@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<Device> 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));
}
}

View File

@ -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();

View File

@ -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<ProductCategory>) => {
return request({
url: '/product-categories',
method: 'post',
data
})
}
// 更新品类
export const updateProductCategory = (id: number, data: Partial<ProductCategory>) => {
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 }
})
}

View File

@ -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',

View File

@ -0,0 +1,215 @@
<template>
<div class="page-container">
<el-card shadow="never" class="mb16">
<el-form :inline="true" :model="query" @submit.prevent="loadData(1)">
<el-form-item label="关键词">
<el-input
v-model="query.keyword"
placeholder="品类编码/名称"
clearable
style="width: 180px"
@keyup.enter="loadData(1)"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.enabled" placeholder="全部" clearable style="width: 120px">
<el-option label="启用" :value="true" />
<el-option label="禁用" :value="false" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadData(1)">查询</el-button>
<el-button @click="resetQuery">重置</el-button>
<el-button type="success" @click="openEdit()">新增品类</el-button>
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column prop="code" label="品类编码" width="140" />
<el-table-column prop="name" label="品类名称" min-width="140" show-overflow-tooltip />
<el-table-column label="涂鸦编码" width="120">
<template #default="{ row }">
<span v-if="row.tuyaCode">{{ row.tuyaCode }}</span>
<span v-else class="muted">-</span>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column label="引用产品" width="100" align="center">
<template #default="{ row }">
<el-link v-if="row.productCount" type="primary" @click="goProducts(row)">
{{ row.productCount }} 个
</el-link>
<span v-else class="muted">0</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">
{{ row.enabled ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip />
<el-table-column prop="createTime" label="创建时间" width="165" />
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" @click="openEdit(row)">编辑</el-button>
<el-button size="small" :type="row.enabled ? 'warning' : 'success'" @click="onToggle(row)">
{{ row.enabled ? '禁用' : '启用' }}
</el-button>
<el-button size="small" type="danger" plain @click="onDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="size"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@change="loadData()"
/>
</div>
</el-card>
<!-- 品类编辑 -->
<el-dialog v-model="editVisible" :title="form.id ? '编辑品类' : '新增品类'" width="560px">
<el-form :model="form" label-width="100px">
<el-form-item label="品类编码" required>
<el-input v-model="form.code" placeholder="如:light(英文,唯一)" />
<div class="muted">产品与设备的品类均以该编码为准,建议小写英文</div>
</el-form-item>
<el-form-item label="品类名称" required>
<el-input v-model="form.name" placeholder="如:照明" />
</el-form-item>
<el-form-item label="涂鸦编码">
<el-input v-model="form.tuyaCode" placeholder="涂鸦设备 category 字段值,如 kj" />
</el-form-item>
<el-form-item label="父级编码">
<el-input v-model="form.parentCode" placeholder="选填,用于二级品类" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" :max="9999" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.enabled" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
listProductCategories,
createProductCategory,
updateProductCategory,
deleteProductCategory,
toggleProductCategory,
type ProductCategory
} from '@/api/productCategory'
const loading = ref(false)
const saving = ref(false)
const list = ref<ProductCategory[]>([])
const total = ref(0)
const page = ref(1)
const size = ref(10)
const query = ref<{ keyword?: string; enabled?: boolean }>({})
const loadData = async (toPage?: number) => {
if (toPage) page.value = toPage
loading.value = true
try {
const res: any = await listProductCategories({
pageNum: page.value,
pageSize: size.value,
keyword: query.value.keyword || undefined,
enabled: query.value.enabled
})
list.value = res.records || []
total.value = Number(res.total || 0)
} finally {
loading.value = false
}
}
const resetQuery = () => {
query.value = {}
loadData(1)
}
// ---------- 品类编辑 ----------
const editVisible = ref(false)
const form = ref<Partial<ProductCategory>>({})
const openEdit = (row?: ProductCategory) => {
form.value = row
? { ...row }
: { code: '', name: '', tuyaCode: '', parentCode: '', sort: 100, enabled: true, remark: '' }
editVisible.value = true
}
const onSave = async () => {
if (!form.value.code) { ElMessage.warning('请填写品类编码'); return }
if (!form.value.name) { ElMessage.warning('请填写品类名称'); return }
saving.value = true
try {
if (form.value.id) {
await updateProductCategory(form.value.id, form.value)
} else {
await createProductCategory(form.value)
}
ElMessage.success('保存成功')
editVisible.value = false
loadData()
} finally {
saving.value = false
}
}
const onDelete = (row: ProductCategory) => {
const tip = row.productCount
? `该品类已被 ${row.productCount} 个产品引用,无法删除,可改为禁用。`
: ''
ElMessageBox.confirm(`确认删除品类「${row.name}」?${tip}`, '提示', { type: 'warning' })
.then(async () => {
await deleteProductCategory(row.id)
ElMessage.success('已删除')
loadData()
})
.catch(() => {})
}
const onToggle = async (row: ProductCategory) => {
await toggleProductCategory(row.id, !row.enabled)
ElMessage.success(row.enabled ? '已禁用' : '已启用')
loadData()
}
// ---------- 跳转 ----------
const router = useRouter()
const goProducts = (row: ProductCategory) =>
router.push({ path: '/products', query: { category: row.code } })
onMounted(() => loadData())
</script>
<style scoped>
.page-container { padding: 0; }
.mb16 { margin-bottom: 16px; }
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
.muted { color: #909399; font-size: 12px; }
</style>

View File

@ -148,17 +148,19 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
listProducts, createProduct, updateProduct, deleteProduct, reconcileProducts,
syncProductSpec,
type Product
} from '@/api/product'
import { listAllProductCategories, type ProductCategory } from '@/api/productCategory'
import SpecEditor from './components/SpecEditor.vue'
import OtaPanel from './components/OtaPanel.vue'
const categories = [
// 品类字典由后台「品类管理」维护;接口不可用时回退到内置枚举,保证页面可用
const FALLBACK_CATEGORIES = [
{ value: 'light', label: '照明' },
{ value: 'switch', label: '开关/插座' },
{ value: 'ac', label: '空调/暖通' },
@ -168,7 +170,20 @@ const categories = [
{ value: 'security', label: '安防' },
{ value: 'other', label: '其它' }
]
const categoryLabel = (v?: string) => categories.find(c => c.value === v)?.label || v || '-'
const categories = ref<{ value: string; label: string }[]>([...FALLBACK_CATEGORIES])
const categoryLabel = (v?: string) => categories.value.find(c => c.value === v)?.label || v || '-'
const loadCategories = async () => {
try {
const res: any = await listAllProductCategories()
const arr: ProductCategory[] = Array.isArray(res) ? res : res?.records || []
if (arr.length > 0) {
categories.value = arr.map(c => ({ value: c.code, label: c.name }))
}
} catch (e) {
console.warn('品类字典加载失败,使用内置枚举:', e)
}
}
const loading = ref(false)
const list = ref<Product[]>([])
@ -280,10 +295,18 @@ const onSyncSpec = async (row: Product) => {
// ---------- 跳转 ----------
const router = useRouter()
const route = useRoute()
const goDetail = (row: Product) => router.push(`/products/${row.id}`)
const goDevices = (row: Product) => router.push({ path: '/devices', query: { productId: String(row.id) } })
onMounted(() => loadData())
onMounted(() => {
// 支持从「品类管理」跳转过来时按品类预置筛选
if (route.query.category) {
query.value.category = String(route.query.category)
}
loadCategories()
loadData()
})
</script>
<style scoped>

View File

@ -72,11 +72,15 @@ export default defineConfig({
target: 'http://localhost:9081',
changeOrigin: true
},
// 产品管理 / 物模型 / 固件 OTA (9081)
// 产品管理 / 物模型 / 固件 OTA / 品类管理 (9081)
'/api/v1/products': {
target: 'http://localhost:9081',
changeOrigin: true
},
'/api/v1/product-categories': {
target: 'http://localhost:9081',
changeOrigin: true
},
'/api/v1/ota-tasks': {
target: 'http://localhost:9081',
changeOrigin: true