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:
parent
4c2d92571e
commit
903b44c84b
@ -222,9 +222,13 @@ public class DeviceActivity extends AppCompatActivity {
|
|||||||
.enqueue(new Callback<Result<Integer>>() {
|
.enqueue(new Callback<Result<Integer>>() {
|
||||||
@Override
|
@Override
|
||||||
public void onResponse(Call<Result<Integer>> call, Response<Result<Integer>> response) {
|
public void onResponse(Call<Result<Integer>> call, Response<Result<Integer>> response) {
|
||||||
if (response.isSuccessful() && response.body() != null
|
Result<Integer> body = response.body();
|
||||||
&& response.body().isSuccess()) {
|
if (body != null && body.isSuccess()) {
|
||||||
// 同步成功(后端会自动对账:已不存在的设备会被清理)
|
// 同步成功(后端会自动对账:已不存在的设备会被清理)
|
||||||
|
} else {
|
||||||
|
// 后端拒绝(如未绑定涂鸦账号)时记录日志,不再静默失败
|
||||||
|
android.util.Log.w("DeviceActivity", "设备同步被拒绝: "
|
||||||
|
+ (body != null ? body.getMessage() : "未知原因"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -451,6 +451,41 @@ public class DevicesFragment extends Fragment {
|
|||||||
private void maybeSyncMissingSdkDevices(final Long platformHomeId, List<BackendDevice> backendList) {
|
private void maybeSyncMissingSdkDevices(final Long platformHomeId, List<BackendDevice> backendList) {
|
||||||
if (sdkMissingSyncDone || platformHomeId == null || !isAdded()) return;
|
if (sdkMissingSyncDone || platformHomeId == null || !isAdded()) return;
|
||||||
sdkMissingSyncDone = true;
|
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(
|
com.xhai.iot.service.TuyaSdkService.getHomes(
|
||||||
new com.xhai.iot.service.TuyaSdkService.HomeListCallback() {
|
new com.xhai.iot.service.TuyaSdkService.HomeListCallback() {
|
||||||
@Override
|
@Override
|
||||||
@ -517,7 +552,14 @@ public class DevicesFragment extends Fragment {
|
|||||||
@Override
|
@Override
|
||||||
public void onResponse(retrofit2.Call<Result<Integer>> call,
|
public void onResponse(retrofit2.Call<Result<Integer>> call,
|
||||||
retrofit2.Response<Result<Integer>> response) {
|
retrofit2.Response<Result<Integer>> response) {
|
||||||
|
Result<Integer> body = response.body();
|
||||||
|
if (body != null && body.isSuccess()) {
|
||||||
if (isAdded()) loadDevices();
|
if (isAdded()) loadDevices();
|
||||||
|
} else {
|
||||||
|
// 后端拒绝(如未绑定涂鸦账号)时给出可读提示,不再静默失败
|
||||||
|
android.util.Log.w("DevicesFragment", "补同步被拒绝: "
|
||||||
|
+ (body != null ? body.getMessage() : "未知原因"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ package com.iot.device.controller;
|
|||||||
|
|
||||||
import com.iot.common.exception.BusinessException;
|
import com.iot.common.exception.BusinessException;
|
||||||
import com.iot.common.result.Result;
|
import com.iot.common.result.Result;
|
||||||
|
import com.iot.common.result.ResultCode;
|
||||||
import com.iot.common.util.JwtUtil;
|
import com.iot.common.util.JwtUtil;
|
||||||
import com.iot.device.dto.tuya.*;
|
import com.iot.device.dto.tuya.*;
|
||||||
import com.iot.device.collector.DevicePlatform;
|
import com.iot.device.collector.DevicePlatform;
|
||||||
@ -287,13 +288,34 @@ public class TuyaController {
|
|||||||
* 绑定涂鸦设备到我们的平台
|
* 绑定涂鸦设备到我们的平台
|
||||||
* 直接信任前端传来的设备详情(前端从涂鸦 SDK 拿到),
|
* 直接信任前端传来的设备详情(前端从涂鸦 SDK 拿到),
|
||||||
* 不再依赖后端涂鸦 OpenAPI(占位 token 拿不到设备列表)
|
* 不再依赖后端涂鸦 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 = "绑定涂鸦设备")
|
@Operation(summary = "绑定涂鸦设备")
|
||||||
@PostMapping("/devices/bind")
|
@PostMapping("/devices/bind")
|
||||||
public Result<Integer> bindDevices(@RequestBody TuyaBindRequest req,
|
public Result<Integer> bindDevices(@RequestBody TuyaBindRequest req,
|
||||||
HttpServletRequest request) {
|
HttpServletRequest request) {
|
||||||
Long memberId = extractMemberId(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 count = 0;
|
||||||
|
int skipped = 0;
|
||||||
|
|
||||||
List<TuyaBindRequest.DeviceInfo> deviceList = req.getDevices();
|
List<TuyaBindRequest.DeviceInfo> deviceList = req.getDevices();
|
||||||
// 兼容旧版本:如果只传了 deviceIds,从 id 构造基本信息
|
// 兼容旧版本:如果只传了 deviceIds,从 id 构造基本信息
|
||||||
@ -315,6 +337,24 @@ public class TuyaController {
|
|||||||
return Result.success(0);
|
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<>();
|
java.util.Set<String> syncedIds = new java.util.HashSet<>();
|
||||||
|
|
||||||
@ -323,6 +363,13 @@ public class TuyaController {
|
|||||||
|| tuyaDevice.getDeviceId().isEmpty()) {
|
|| tuyaDevice.getDeviceId().isEmpty()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// 归属白名单校验:非本账号的设备一律不落库
|
||||||
|
if (allowedDeviceIds != null && !allowedDeviceIds.contains(tuyaDevice.getDeviceId())) {
|
||||||
|
log.warn("跳过非本涂鸦账号的设备: deviceId={}, memberId={}",
|
||||||
|
tuyaDevice.getDeviceId(), memberId);
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
syncedIds.add(tuyaDevice.getDeviceId());
|
syncedIds.add(tuyaDevice.getDeviceId());
|
||||||
// 按 device_id 查所有记录(不区分 platform,避免 local 记录占用唯一索引导致冲突)
|
// 按 device_id 查所有记录(不区分 platform,避免 local 记录占用唯一索引导致冲突)
|
||||||
Device existing = deviceMapper.selectByDeviceId(tuyaDevice.getDeviceId());
|
Device existing = deviceMapper.selectByDeviceId(tuyaDevice.getDeviceId());
|
||||||
@ -332,16 +379,34 @@ public class TuyaController {
|
|||||||
// 因此先把原记录恢复出来,再走下面的更新分支。
|
// 因此先把原记录恢复出来,再走下面的更新分支。
|
||||||
Device revoked = deviceMapper.selectAnyByDeviceId(tuyaDevice.getDeviceId());
|
Device revoked = deviceMapper.selectAnyByDeviceId(tuyaDevice.getDeviceId());
|
||||||
if (revoked != null) {
|
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 = revoked;
|
||||||
|
existing.setOwnerId(memberId);
|
||||||
log.info("设备重新绑定,恢复已删除记录: {}({})",
|
log.info("设备重新绑定,恢复已删除记录: {}({})",
|
||||||
tuyaDevice.getDeviceId(), revoked.getDeviceName());
|
tuyaDevice.getDeviceId(), revoked.getDeviceName());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (existing != null) {
|
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 记录或旧记录)
|
// 已存在则更新为 tuya 平台 + 最新名称/状态(处理 local 记录或旧记录)
|
||||||
Device update = new Device();
|
Device update = new Device();
|
||||||
update.setId(existing.getId());
|
update.setId(existing.getId());
|
||||||
|
update.setOwnerId(memberId);
|
||||||
update.setDeviceName(tuyaDevice.getDeviceName() != null
|
update.setDeviceName(tuyaDevice.getDeviceName() != null
|
||||||
? tuyaDevice.getDeviceName() : existing.getDeviceName());
|
? tuyaDevice.getDeviceName() : existing.getDeviceName());
|
||||||
update.setPlatform("tuya");
|
update.setPlatform("tuya");
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -7,6 +7,8 @@ import org.apache.ibatis.annotations.Param;
|
|||||||
import org.apache.ibatis.annotations.Select;
|
import org.apache.ibatis.annotations.Select;
|
||||||
import org.apache.ibatis.annotations.Update;
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设备Mapper
|
* 设备Mapper
|
||||||
*/
|
*/
|
||||||
@ -61,4 +63,31 @@ public interface DeviceMapper extends BaseMapper<Device> {
|
|||||||
@Update("UPDATE device SET deleted = 1, update_time = NOW() "
|
@Update("UPDATE device SET deleted = 1, update_time = NOW() "
|
||||||
+ "WHERE deleted = 0 AND platform = 'tuya' AND owner_id = #{ownerId}")
|
+ "WHERE deleted = 0 AND platform = 'tuya' AND owner_id = #{ownerId}")
|
||||||
int invalidateTuyaDevicesByOwner(@Param("ownerId") Long 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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
67
backend/sql/V25__add_product_category.sql
Normal file
67
backend/sql/V25__add_product_category.sql
Normal 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();
|
||||||
|
|
||||||
83
frontend/admin/src/api/productCategory.ts
Normal file
83
frontend/admin/src/api/productCategory.ts
Normal 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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -73,6 +73,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/Product/ProductDetail.vue'),
|
component: () => import('@/views/Product/ProductDetail.vue'),
|
||||||
meta: { title: '产品详情', hidden: true, group: 'device' }
|
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',
|
path: '/system/users',
|
||||||
name: 'SystemUsers',
|
name: 'SystemUsers',
|
||||||
|
|||||||
215
frontend/admin/src/views/Product/ProductCategoryList.vue
Normal file
215
frontend/admin/src/views/Product/ProductCategoryList.vue
Normal 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>
|
||||||
@ -148,17 +148,19 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
listProducts, createProduct, updateProduct, deleteProduct, reconcileProducts,
|
listProducts, createProduct, updateProduct, deleteProduct, reconcileProducts,
|
||||||
syncProductSpec,
|
syncProductSpec,
|
||||||
type Product
|
type Product
|
||||||
} from '@/api/product'
|
} from '@/api/product'
|
||||||
|
import { listAllProductCategories, type ProductCategory } from '@/api/productCategory'
|
||||||
import SpecEditor from './components/SpecEditor.vue'
|
import SpecEditor from './components/SpecEditor.vue'
|
||||||
import OtaPanel from './components/OtaPanel.vue'
|
import OtaPanel from './components/OtaPanel.vue'
|
||||||
|
|
||||||
const categories = [
|
// 品类字典由后台「品类管理」维护;接口不可用时回退到内置枚举,保证页面可用
|
||||||
|
const FALLBACK_CATEGORIES = [
|
||||||
{ value: 'light', label: '照明' },
|
{ value: 'light', label: '照明' },
|
||||||
{ value: 'switch', label: '开关/插座' },
|
{ value: 'switch', label: '开关/插座' },
|
||||||
{ value: 'ac', label: '空调/暖通' },
|
{ value: 'ac', label: '空调/暖通' },
|
||||||
@ -168,7 +170,20 @@ const categories = [
|
|||||||
{ value: 'security', label: '安防' },
|
{ value: 'security', label: '安防' },
|
||||||
{ value: 'other', 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 loading = ref(false)
|
||||||
const list = ref<Product[]>([])
|
const list = ref<Product[]>([])
|
||||||
@ -280,10 +295,18 @@ const onSyncSpec = async (row: Product) => {
|
|||||||
|
|
||||||
// ---------- 跳转 ----------
|
// ---------- 跳转 ----------
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const goDetail = (row: Product) => router.push(`/products/${row.id}`)
|
const goDetail = (row: Product) => router.push(`/products/${row.id}`)
|
||||||
const goDevices = (row: Product) => router.push({ path: '/devices', query: { productId: String(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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@ -72,11 +72,15 @@ export default defineConfig({
|
|||||||
target: 'http://localhost:9081',
|
target: 'http://localhost:9081',
|
||||||
changeOrigin: true
|
changeOrigin: true
|
||||||
},
|
},
|
||||||
// 产品管理 / 物模型 / 固件 OTA (9081)
|
// 产品管理 / 物模型 / 固件 OTA / 品类管理 (9081)
|
||||||
'/api/v1/products': {
|
'/api/v1/products': {
|
||||||
target: 'http://localhost:9081',
|
target: 'http://localhost:9081',
|
||||||
changeOrigin: true
|
changeOrigin: true
|
||||||
},
|
},
|
||||||
|
'/api/v1/product-categories': {
|
||||||
|
target: 'http://localhost:9081',
|
||||||
|
changeOrigin: true
|
||||||
|
},
|
||||||
'/api/v1/ota-tasks': {
|
'/api/v1/ota-tasks': {
|
||||||
target: 'http://localhost:9081',
|
target: 'http://localhost:9081',
|
||||||
changeOrigin: true
|
changeOrigin: true
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user