Parcourir la source

管理端家庭/公共设备逻辑重构

jiapu il y a 3 mois
Parent
commit
aa4ff12fa8

+ 15 - 15
code/backend/src/main/java/com/aijiuyi/admin/controller/DeviceController.java

@@ -16,7 +16,7 @@ import java.util.List;
 
 /**
  * 设备管理 Controller
- * 提供艾灸椅设备的增删改查、批量导入、以及设备绑定用户管理接口
+ * 提供艾灸椅设备的增删改查、批量导入、以及设备关联用户/成员管理接口
  */
 @RestController
 @RequestMapping("/device")
@@ -26,7 +26,7 @@ public class DeviceController {
     private DeviceService deviceService;
 
     /**
-     * 分页查询设备列表(含绑定用户数和用户姓名)
+     * 分页查询设备列表(含关联用户/成员数量和姓名)
      *
      * @param queryDTO 查询条件
      * @return 分页结果
@@ -50,7 +50,7 @@ public class DeviceController {
     }
 
     /**
-     * 新增设备(可同时绑定初始用户
+     * 新增设备(可同时关联初始用户/成员
      *
      * @param dto 设备信息
      * @return 操作结果
@@ -114,50 +114,50 @@ public class DeviceController {
         return Result.success();
     }
 
-    // ======================== 设备用户管理 ========================
+    // ======================== 设备关联用户/成员管理 ========================
 
     /**
-     * 查询设备绑定的用户列表
+     * 查询设备关联用户/成员列表
      *
      * @param deviceId 设备ID
-     * @return 绑定用户列表
+     * @return 关联用户/成员列表
      */
     @GetMapping("/{deviceId}/users")
-    @Log(value = "查询设备绑定用户列表", module = "设备管理", operationType = OperationType.QUERY)
+    @Log(value = "查询设备关联用户列表", module = "设备管理", operationType = OperationType.QUERY)
     public Result<List<DeviceUserVO>> getDeviceUsers(@PathVariable Long deviceId) {
         return Result.success(deviceService.getDeviceUsers(deviceId));
     }
 
     /**
-     * 为设备添加绑定用户
+     * 为设备添加成员/绑定用户
      *
      * @param deviceId  设备ID
      * @param profileId 用户档案ID(user_profile.id)
      * @return 操作结果
      */
     @PostMapping("/{deviceId}/users/{profileId}")
-    @Log(value = "添加设备绑定用户", module = "设备管理", operationType = OperationType.INSERT)
+    @Log(value = "添加设备关联用户", module = "设备管理", operationType = OperationType.INSERT)
     public Result<Void> addDeviceUser(@PathVariable Long deviceId, @PathVariable Long profileId) {
         deviceService.addDeviceUser(deviceId, profileId);
         return Result.success();
     }
 
     /**
-     * 解绑设备用户
+     * 移除设备成员/解绑设备用户
      *
      * @param deviceId  设备ID
      * @param profileId 用户档案ID
      * @return 操作结果
      */
     @DeleteMapping("/{deviceId}/users/{profileId}")
-    @Log(value = "解绑设备用户", module = "设备管理", operationType = OperationType.DELETE)
+    @Log(value = "移除设备关联用户", module = "设备管理", operationType = OperationType.DELETE)
     public Result<Void> removeDeviceUser(@PathVariable Long deviceId, @PathVariable Long profileId) {
         deviceService.removeDeviceUser(deviceId, profileId);
         return Result.success();
     }
 
     /**
-     * 设为主用户(同一设备下只有一个主用户
+     * 设为主用户(仅公共设备兼容旧绑定关系,家庭设备无主用户概念
      *
      * @param deviceId  设备ID
      * @param profileId 用户档案ID
@@ -171,14 +171,14 @@ public class DeviceController {
     }
 
     /**
-     * 批量解绑设备用户
+     * 批量移除设备成员/解绑设备用户
      *
      * @param deviceId 设备ID
-     * @param bindIds  绑定记录ID列表(user_device.id)
+     * @param bindIds  关联记录ID列表(家庭设备为 device_group_member.id,公共设备为 user_device.id)
      * @return 操作结果
      */
     @DeleteMapping("/{deviceId}/users/batch")
-    @Log(value = "批量解绑设备用户", module = "设备管理", operationType = OperationType.DELETE)
+    @Log(value = "批量移除设备关联用户", module = "设备管理", operationType = OperationType.DELETE)
     public Result<Void> batchRemoveDeviceUsers(@PathVariable Long deviceId, @RequestBody List<Long> bindIds) {
         deviceService.batchRemoveDeviceUsers(deviceId, bindIds);
         return Result.success();

+ 1 - 1
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/DeviceQueryDTO.java

@@ -14,7 +14,7 @@ public class DeviceQueryDTO {
     /** 设备名称(模糊搜索) */
     private String deviceName;
 
-    /** 绑定用户姓名(模糊搜索,匹配该设备下任意绑定用户) */
+    /** 关联用户/成员姓名(模糊搜索,家庭设备匹配群组成员,公共设备匹配绑定用户) */
     private String boundUserName;
 
     /** 设备类型:1=家庭设备,2=公共设备 */

+ 3 - 2
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/DeviceSaveDTO.java

@@ -47,8 +47,9 @@ public class DeviceSaveDTO {
     private String address;
 
     /**
-     * 绑定用户ID列表(user_profile.id,选填)
-     * 新增:绑定初始用户;编辑:与当前绑定用户做差集合并(null 表示不变更绑定关系,空列表表示清空所有绑定)
+     * 关联用户档案ID列表(user_profile.id,选填)
+     * 家庭设备:新增/同步群组成员;公共设备:新增/同步设备绑定用户。
+     * 编辑时 null 表示不变更关联关系,空列表表示清空所有关联用户/成员。
      */
     private List<Long> userIds;
 }

+ 3 - 3
code/backend/src/main/java/com/aijiuyi/admin/entity/Device.java

@@ -71,15 +71,15 @@ public class Device {
 
     // ======================== 非数据库字段(联表查询填充)========================
 
-    /** 绑定用户数量(联表聚合) */
+    /** 关联用户/成员数量(联表聚合) */
     @TableField(exist = false)
     private Integer boundUserCount;
 
-    /** 绑定用户姓名列表(联表聚合,顿号分隔) */
+    /** 关联用户/成员姓名列表(联表聚合,顿号分隔) */
     @TableField(exist = false)
     private String boundUserNames;
 
-    /** 绑定的用户ID列表(新增/编辑表单使用) */
+    /** 关联的用户档案ID列表(新增/编辑表单使用) */
     @TableField(exist = false)
     private List<Long> userIds;
 }

+ 5 - 5
code/backend/src/main/java/com/aijiuyi/admin/entity/DeviceUserVO.java

@@ -5,13 +5,13 @@ import lombok.Data;
 import java.time.LocalDateTime;
 
 /**
- * 设备绑定用户视图对象
- * 用于设备用户管理页面,联表查询 user_device + user_profile 的结果
+ * 设备关联用户/成员视图对象
+ * 家庭设备对应 device_group_member + user_profile,公共设备对应 user_device + user_profile。
  */
 @Data
 public class DeviceUserVO {
 
-    /** 绑定记录ID(user_device.id) */
+    /** 关联记录ID(家庭设备为 device_group_member.id,公共设备为 user_device.id) */
     private Long bindId;
 
     /** 用户档案ID(user_profile.id) */
@@ -23,9 +23,9 @@ public class DeviceUserVO {
     /** 用户手机号 */
     private String phone;
 
-    /** 绑定时间 */
+    /** 加入/绑定时间 */
     private LocalDateTime bindTime;
 
-    /** 是否主用户:1=是,0=否 */
+    /** 是否主用户:1=是,0=否;家庭设备固定返回 0 */
     private Integer isPrimary;
 }

+ 4 - 3
code/backend/src/main/java/com/aijiuyi/admin/mapper/DeviceMapper.java

@@ -18,7 +18,7 @@ import java.util.List;
 public interface DeviceMapper extends BaseMapper<Device> {
 
     /**
-     * 分页查询设备列表(联表聚合绑定用户数和用户姓名
+     * 分页查询设备列表(聚合家庭群组成员或公共设备绑定用户)
      *
      * @param page     分页参数
      * @param queryDTO 查询条件
@@ -27,10 +27,11 @@ public interface DeviceMapper extends BaseMapper<Device> {
     IPage<Device> pageWithUsers(Page<Device> page, @Param("q") DeviceQueryDTO queryDTO);
 
     /**
-     * 根据设备编号查询绑定的用户列表
+     * 根据设备编号查询用户列表。
+     * 家庭设备返回群组成员,公共设备返回设备绑定用户。
      *
      * @param deviceCode 设备编号
-     * @return 绑定用户列表
+     * @return 用户列表
      */
     List<DeviceUserVO> getUsersByDeviceCode(@Param("deviceCode") String deviceCode);
 }

+ 12 - 12
code/backend/src/main/java/com/aijiuyi/admin/service/DeviceService.java

@@ -15,7 +15,7 @@ import java.util.List;
 public interface DeviceService extends IService<Device> {
 
     /**
-     * 分页查询设备列表(联表聚合绑定用户信息
+     * 分页查询设备列表(聚合家庭群组成员或公共设备绑定用户)
      *
      * @param queryDTO 查询条件
      * @return 分页结果
@@ -23,28 +23,28 @@ public interface DeviceService extends IService<Device> {
     IPage<Device> pageList(DeviceQueryDTO queryDTO);
 
     /**
-     * 新增设备(可同时绑定初始用户
+     * 新增设备(可同时关联初始用户/成员
      *
      * @param dto 设备信息
      */
     void addDevice(DeviceSaveDTO dto);
 
     /**
-     * 修改设备信息(可同步更新绑定用户
+     * 修改设备信息(可同步更新关联用户/成员
      *
      * @param dto 设备信息
      */
     void updateDevice(DeviceSaveDTO dto);
 
     /**
-     * 删除设备(逻辑删除,同步解绑所有用户)
+     * 删除设备(逻辑删除,同步移除/解绑所有关联用户)
      *
      * @param id 设备ID
      */
     void deleteDevice(Long id);
 
     /**
-     * 批量删除设备(逻辑删除,同步解绑所有用户)
+     * 批量删除设备(逻辑删除,同步移除/解绑所有关联用户)
      *
      * @param ids 设备ID列表
      */
@@ -58,15 +58,15 @@ public interface DeviceService extends IService<Device> {
     void batchImport(List<Device> list);
 
     /**
-     * 查询设备绑定的用户列表
+     * 查询设备关联用户/成员列表
      *
      * @param deviceId 设备ID
-     * @return 绑定用户列表
+     * @return 关联用户/成员列表
      */
     List<DeviceUserVO> getDeviceUsers(Long deviceId);
 
     /**
-     * 为设备添加绑定用户
+     * 为设备添加成员/绑定用户
      *
      * @param deviceId  设备ID
      * @param profileId 用户档案ID(user_profile.id)
@@ -74,7 +74,7 @@ public interface DeviceService extends IService<Device> {
     void addDeviceUser(Long deviceId, Long profileId);
 
     /**
-     * 解绑设备用户
+     * 移除设备成员/解绑设备用户
      *
      * @param deviceId  设备ID
      * @param profileId 用户档案ID
@@ -82,7 +82,7 @@ public interface DeviceService extends IService<Device> {
     void removeDeviceUser(Long deviceId, Long profileId);
 
     /**
-     * 设为主用户(同一设备下只能有一个主用户
+     * 设为主用户(仅公共设备兼容旧绑定关系,家庭设备无主用户概念
      *
      * @param deviceId  设备ID
      * @param profileId 用户档案ID
@@ -90,10 +90,10 @@ public interface DeviceService extends IService<Device> {
     void setDeviceUserPrimary(Long deviceId, Long profileId);
 
     /**
-     * 批量解绑设备用户
+     * 批量移除设备成员/解绑设备用户
      *
      * @param deviceId 设备ID
-     * @param bindIds  绑定记录ID列表(user_device.id)
+     * @param bindIds  关联记录ID列表(家庭设备为 device_group_member.id,公共设备为 user_device.id)
      */
     void batchRemoveDeviceUsers(Long deviceId, List<Long> bindIds);
 }

+ 366 - 86
code/backend/src/main/java/com/aijiuyi/admin/service/impl/DeviceServiceImpl.java

@@ -6,10 +6,16 @@ import com.aijiuyi.admin.common.util.LogUtil;
 import com.aijiuyi.admin.controller.dto.DeviceQueryDTO;
 import com.aijiuyi.admin.controller.dto.DeviceSaveDTO;
 import com.aijiuyi.admin.entity.Device;
+import com.aijiuyi.admin.entity.DeviceGroup;
+import com.aijiuyi.admin.entity.DeviceGroupMember;
 import com.aijiuyi.admin.entity.DeviceUserVO;
 import com.aijiuyi.admin.entity.UserDevice;
+import com.aijiuyi.admin.entity.UserProfile;
 import com.aijiuyi.admin.mapper.DeviceMapper;
+import com.aijiuyi.admin.mapper.DeviceGroupMapper;
+import com.aijiuyi.admin.mapper.DeviceGroupMemberMapper;
 import com.aijiuyi.admin.mapper.UserDeviceMapper;
+import com.aijiuyi.admin.mapper.UserProfileMapper;
 import com.aijiuyi.admin.service.DeviceService;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
@@ -34,11 +40,24 @@ import java.util.stream.Collectors;
 @Service
 public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> implements DeviceService {
 
+    private static final int DEVICE_TYPE_FAMILY = 1;
+    private static final int DEVICE_TYPE_PUBLIC = 2;
+    private static final int STATUS_ENABLED = 1;
+
     @Autowired
     private UserDeviceMapper userDeviceMapper;
 
+    @Autowired
+    private DeviceGroupMapper deviceGroupMapper;
+
+    @Autowired
+    private DeviceGroupMemberMapper deviceGroupMemberMapper;
+
+    @Autowired
+    private UserProfileMapper userProfileMapper;
+
     /**
-     * 分页查询设备列表(联表聚合绑定用户信息)
+     * 分页查询设备列表(聚合家庭群组成员或公共设备绑定用户)
      *
      * @param queryDTO 查询条件
      * @return 分页结果
@@ -72,12 +91,12 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
         Device device = buildDeviceFromDto(dto);
         save(device);
         // 绑定初始用户
-        bindUsers(device.getDeviceCode(), device.getDeviceName(), device.getDeviceModel(), dto.getUserIds());
+        bindUsers(device, dto.getUserIds());
         LogUtil.info(DeviceServiceImpl.class, "新增设备[{}],编号[{}]", dto.getDeviceName(), dto.getDeviceCode());
     }
 
     /**
-     * 修改设备信息(可同步更新绑定用户
+     * 修改设备信息(可同步更新关联用户/成员
      *
      * @param dto 设备信息
      */
@@ -88,6 +107,11 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
         if (exist == null) {
             throw new BusinessException(ResultCode.DEVICE_NOT_FOUND);
         }
+        Integer targetDeviceType = resolveDeviceType(dto.getDeviceType());
+        Integer currentDeviceType = resolveDeviceType(exist);
+        if (!targetDeviceType.equals(currentDeviceType) && hasActiveDeviceUsers(exist.getDeviceCode())) {
+            throw new BusinessException(ResultCode.PARAM_ERROR.getCode(), "已有绑定用户或群组成员的设备不允许直接修改设备类型");
+        }
         // 若修改了序列号,校验唯一性
         if (StringUtils.hasText(dto.getSerialNo()) && !dto.getSerialNo().equals(exist.getSerialNo())) {
             long serialCount = lambdaQuery()
@@ -103,9 +127,11 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
         // 设备编号不允许修改,清除该字段防止误更新
         update.setDeviceCode(null);
         updateById(update);
-        // 若传入了 userIds(包括空列表),则同步更新绑定用户
+        update.setDeviceCode(exist.getDeviceCode());
+        syncUserDeviceInfo(update);
+        // 若传入了 userIds(包括空列表),则同步更新关联用户/成员
         if (dto.getUserIds() != null) {
-            reconcileUserBindings(exist.getDeviceCode(), exist.getDeviceName(), exist.getDeviceModel(), dto.getUserIds());
+            reconcileDeviceUsers(update, dto.getUserIds());
         }
         LogUtil.info(DeviceServiceImpl.class, "修改设备[{}]", dto.getId());
     }
@@ -139,7 +165,7 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
         if (CollectionUtils.isEmpty(ids)) {
             throw new BusinessException(ResultCode.PARAM_ERROR);
         }
-        // 先查出设备编号再删除,用于解绑用户
+        // 先查出设备编号再删除,用于移除/解绑关联用户
         List<Device> devices = listByIds(ids);
         removeByIds(ids);
         for (Device device : devices) {
@@ -171,10 +197,10 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
     }
 
     /**
-     * 查询设备绑定的用户列表
+     * 查询设备关联用户/成员列表
      *
      * @param deviceId 设备ID
-     * @return 绑定用户列表
+     * @return 关联用户/成员列表
      */
     @Override
     public List<DeviceUserVO> getDeviceUsers(Long deviceId) {
@@ -186,7 +212,7 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
     }
 
     /**
-     * 为设备添加绑定用户
+     * 为设备添加成员/绑定用户
      *
      * @param deviceId  设备ID
      * @param profileId 用户档案ID
@@ -197,27 +223,12 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
         if (device == null) {
             throw new BusinessException(ResultCode.DEVICE_NOT_FOUND);
         }
-        // 检查用户是否已绑定该设备
-        long count = userDeviceMapper.selectCount(
-                new LambdaQueryWrapper<UserDevice>()
-                        .eq(UserDevice::getDeviceCode, device.getDeviceCode())
-                        .eq(UserDevice::getUserId, profileId)
-                        .eq(UserDevice::getStatus, 1)
-        );
-        if (count > 0) {
-            throw new BusinessException(ResultCode.DEVICE_USER_ALREADY_BOUND);
+        if (isFamilyDevice(device)) {
+            addFamilyDeviceUser(device, profileId);
+        } else {
+            addPublicDeviceUser(device, profileId);
         }
-        UserDevice ud = new UserDevice();
-        ud.setUserId(profileId);
-        ud.setDeviceCode(device.getDeviceCode());
-        ud.setDeviceName(device.getDeviceName());
-        ud.setDeviceModel(device.getDeviceModel());
-        ud.setStatus(1);
-        ud.setIsPrimary(0);
-        ud.setOnlineStatus(0);
-        ud.setBindTime(LocalDateTime.now());
-        userDeviceMapper.insert(ud);
-        LogUtil.info(DeviceServiceImpl.class, "设备[{}]添加绑定用户[{}]", device.getDeviceCode(), profileId);
+        LogUtil.info(DeviceServiceImpl.class, "设备[{}]添加关联用户[{}]", device.getDeviceCode(), profileId);
     }
 
     /**
@@ -232,18 +243,16 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
         if (device == null) {
             throw new BusinessException(ResultCode.DEVICE_NOT_FOUND);
         }
-        LambdaUpdateWrapper<UserDevice> wrapper = new LambdaUpdateWrapper<>();
-        wrapper.eq(UserDevice::getDeviceCode, device.getDeviceCode())
-               .eq(UserDevice::getUserId, profileId)
-               .eq(UserDevice::getStatus, 1)
-               .set(UserDevice::getStatus, 0)
-               .set(UserDevice::getIsPrimary, 0);
-        userDeviceMapper.update(null, wrapper);
-        LogUtil.info(DeviceServiceImpl.class, "设备[{}]解绑用户[{}]", device.getDeviceCode(), profileId);
+        if (isFamilyDevice(device)) {
+            removeFamilyDeviceUser(device, profileId);
+        } else {
+            removePublicDeviceUser(device, profileId);
+        }
+        LogUtil.info(DeviceServiceImpl.class, "设备[{}]移除关联用户[{}]", device.getDeviceCode(), profileId);
     }
 
     /**
-     * 设为主用户(同一设备下只能有一个主用户
+     * 设为主用户(仅公共设备兼容旧绑定关系,家庭设备无主用户概念
      *
      * @param deviceId  设备ID
      * @param profileId 用户档案ID
@@ -255,27 +264,30 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
         if (device == null) {
             throw new BusinessException(ResultCode.DEVICE_NOT_FOUND);
         }
+        if (isFamilyDevice(device)) {
+            throw new BusinessException(ResultCode.PARAM_ERROR.getCode(), "家庭设备没有主用户概念");
+        }
         // 先清除该设备下所有用户的主用户标记
         LambdaUpdateWrapper<UserDevice> clearWrapper = new LambdaUpdateWrapper<>();
         clearWrapper.eq(UserDevice::getDeviceCode, device.getDeviceCode())
-                    .eq(UserDevice::getStatus, 1)
+                    .eq(UserDevice::getStatus, STATUS_ENABLED)
                     .set(UserDevice::getIsPrimary, 0);
         userDeviceMapper.update(null, clearWrapper);
         // 再将目标用户设为主用户
         LambdaUpdateWrapper<UserDevice> setWrapper = new LambdaUpdateWrapper<>();
         setWrapper.eq(UserDevice::getDeviceCode, device.getDeviceCode())
                   .eq(UserDevice::getUserId, profileId)
-                  .eq(UserDevice::getStatus, 1)
+                  .eq(UserDevice::getStatus, STATUS_ENABLED)
                   .set(UserDevice::getIsPrimary, 1);
         userDeviceMapper.update(null, setWrapper);
         LogUtil.info(DeviceServiceImpl.class, "设备[{}]设置主用户[{}]", device.getDeviceCode(), profileId);
     }
 
     /**
-     * 批量解绑设备用户(通过绑定记录ID批量解绑
+     * 批量移除设备成员/解绑设备用户(通过关联记录ID批量处理
      *
      * @param deviceId 设备ID(校验用)
-     * @param bindIds  绑定记录ID列表(user_device.id)
+     * @param bindIds  关联记录ID列表(家庭设备为 device_group_member.id,公共设备为 user_device.id)
      */
     @Override
     @Transactional(rollbackFor = Exception.class)
@@ -283,12 +295,21 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
         if (CollectionUtils.isEmpty(bindIds)) {
             return;
         }
-        LambdaUpdateWrapper<UserDevice> wrapper = new LambdaUpdateWrapper<>();
-        wrapper.in(UserDevice::getId, bindIds)
-               .set(UserDevice::getStatus, 0)
-               .set(UserDevice::getIsPrimary, 0);
-        userDeviceMapper.update(null, wrapper);
-        LogUtil.info(DeviceServiceImpl.class, "设备[{}]批量解绑用户,共[{}]条", deviceId, bindIds.size());
+        Device device = getById(deviceId);
+        if (device == null) {
+            throw new BusinessException(ResultCode.DEVICE_NOT_FOUND);
+        }
+        if (isFamilyDevice(device)) {
+            batchRemoveFamilyDeviceUsers(device, bindIds);
+        } else {
+            LambdaUpdateWrapper<UserDevice> wrapper = new LambdaUpdateWrapper<>();
+            wrapper.in(UserDevice::getId, bindIds)
+                    .eq(UserDevice::getDeviceCode, device.getDeviceCode())
+                    .set(UserDevice::getStatus, 0)
+                    .set(UserDevice::getIsPrimary, 0);
+            userDeviceMapper.update(null, wrapper);
+        }
+        LogUtil.info(DeviceServiceImpl.class, "设备[{}]批量移除关联用户,共[{}]条", deviceId, bindIds.size());
     }
 
     /**
@@ -316,55 +337,62 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
 
     private Integer resolveDeviceType(Integer deviceType) {
         if (deviceType == null) {
-            return 1;
+            return DEVICE_TYPE_FAMILY;
         }
-        if (!Integer.valueOf(1).equals(deviceType) && !Integer.valueOf(2).equals(deviceType)) {
+        if (!Integer.valueOf(DEVICE_TYPE_FAMILY).equals(deviceType) && !Integer.valueOf(DEVICE_TYPE_PUBLIC).equals(deviceType)) {
             throw new BusinessException(ResultCode.PARAM_ERROR.getCode(), "设备类型不合法");
         }
         return deviceType;
     }
 
+    private Integer resolveDeviceType(Device device) {
+        return resolveDeviceType(device.getDeviceType());
+    }
+
+    private boolean isFamilyDevice(Device device) {
+        return Integer.valueOf(DEVICE_TYPE_FAMILY).equals(resolveDeviceType(device));
+    }
+
     /**
-     * 为设备批量绑定用户(新增时使用)
+     * 为设备批量关联用户(新增时使用)
      *
-     * @param deviceCode  设备编号
-     * @param deviceName  设备名称
-     * @param deviceModel 设备型号
-     * @param userIds     用户ID列表
+     * @param device  设备信息
+     * @param userIds 用户档案ID列表
      */
-    private void bindUsers(String deviceCode, String deviceName, String deviceModel, List<Long> userIds) {
+    private void bindUsers(Device device, List<Long> userIds) {
         if (CollectionUtils.isEmpty(userIds)) {
             return;
         }
         for (Long profileId : userIds) {
-            UserDevice ud = new UserDevice();
-            ud.setUserId(profileId);
-            ud.setDeviceCode(deviceCode);
-            ud.setDeviceName(deviceName);
-            ud.setDeviceModel(deviceModel);
-            ud.setStatus(1);
-            ud.setIsPrimary(0);
-            ud.setOnlineStatus(0);
-            ud.setBindTime(LocalDateTime.now());
-            userDeviceMapper.insert(ud);
+            if (isFamilyDevice(device)) {
+                addFamilyDeviceUser(device, profileId);
+            } else {
+                addPublicDeviceUser(device, profileId);
+            }
         }
     }
 
     /**
-     * 同步更新设备绑定用户(编辑时使用)
-     * 对比当前绑定用户和新列表,新增差集,解绑多余项
+     * 同步更新设备关联用户(编辑时使用)
+     * 家庭设备同步群组成员,公共设备同步 user_device 绑定关系。
      *
-     * @param deviceCode  设备编号
-     * @param deviceName  设备名称
-     * @param deviceModel 设备型号
-     * @param newUserIds  新的用户ID列表
+     * @param device     设备信息
+     * @param newUserIds 新的用户档案ID列表
      */
-    private void reconcileUserBindings(String deviceCode, String deviceName, String deviceModel, List<Long> newUserIds) {
+    private void reconcileDeviceUsers(Device device, List<Long> newUserIds) {
+        if (isFamilyDevice(device)) {
+            reconcileFamilyDeviceUsers(device, newUserIds);
+            return;
+        }
+        reconcilePublicDeviceUsers(device, newUserIds);
+    }
+
+    private void reconcilePublicDeviceUsers(Device device, List<Long> newUserIds) {
         // 查询当前已绑定的用户
         List<UserDevice> currentBindings = userDeviceMapper.selectList(
                 new LambdaQueryWrapper<UserDevice>()
-                        .eq(UserDevice::getDeviceCode, deviceCode)
-                        .eq(UserDevice::getStatus, 1)
+                        .eq(UserDevice::getDeviceCode, device.getDeviceCode())
+                        .eq(UserDevice::getStatus, STATUS_ENABLED)
         );
         Set<Long> currentIds = currentBindings.stream()
                 .map(UserDevice::getUserId)
@@ -383,20 +411,263 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
         // 新增不在当前绑定中的用户
         for (Long profileId : newUserIds) {
             if (!currentIds.contains(profileId)) {
-                UserDevice ud = new UserDevice();
-                ud.setUserId(profileId);
-                ud.setDeviceCode(deviceCode);
-                ud.setDeviceName(deviceName);
-                ud.setDeviceModel(deviceModel);
-                ud.setStatus(1);
-                ud.setIsPrimary(0);
-                ud.setOnlineStatus(0);
-                ud.setBindTime(LocalDateTime.now());
-                userDeviceMapper.insert(ud);
+                addPublicDeviceUser(device, profileId);
+            }
+        }
+    }
+
+    private void reconcileFamilyDeviceUsers(Device device, List<Long> newUserIds) {
+        DeviceGroup group = ensureDeviceGroup(device.getDeviceCode());
+        List<DeviceGroupMember> currentMembers = deviceGroupMemberMapper.selectList(
+                new LambdaQueryWrapper<DeviceGroupMember>()
+                        .eq(DeviceGroupMember::getGroupId, group.getId())
+                        .eq(DeviceGroupMember::getStatus, STATUS_ENABLED)
+        );
+        Set<Long> currentIds = currentMembers.stream()
+                .map(DeviceGroupMember::getProfileId)
+                .collect(Collectors.toSet());
+        Set<Long> newIds = new HashSet<>(newUserIds);
+
+        for (DeviceGroupMember member : currentMembers) {
+            if (!newIds.contains(member.getProfileId())) {
+                removeFamilyDeviceUser(device, member.getProfileId());
+            }
+        }
+        for (Long profileId : newUserIds) {
+            if (!currentIds.contains(profileId)) {
+                addFamilyDeviceUser(device, profileId);
             }
         }
     }
 
+    private void addFamilyDeviceUser(Device device, Long profileId) {
+        UserProfile profile = requireProfile(profileId);
+        DeviceGroup group = ensureDeviceGroup(device.getDeviceCode());
+        DeviceGroupMember current = deviceGroupMemberMapper.selectOne(
+                new LambdaQueryWrapper<DeviceGroupMember>()
+                        .eq(DeviceGroupMember::getGroupId, group.getId())
+                        .eq(DeviceGroupMember::getProfileId, profileId)
+                        .last("LIMIT 1")
+        );
+        if (current != null && Integer.valueOf(STATUS_ENABLED).equals(current.getStatus())) {
+            throw new BusinessException(ResultCode.DEVICE_USER_ALREADY_BOUND);
+        }
+        checkFamilyMemberUnique(group.getId(), profile, profileId);
+        if (current == null) {
+            DeviceGroupMember member = new DeviceGroupMember();
+            member.setGroupId(group.getId());
+            member.setProfileId(profileId);
+            member.setAppUserId(profile.getUserId());
+            member.setStatus(STATUS_ENABLED);
+            member.setJoinTime(LocalDateTime.now());
+            deviceGroupMemberMapper.insert(member);
+        } else {
+            LambdaUpdateWrapper<DeviceGroupMember> wrapper = new LambdaUpdateWrapper<>();
+            wrapper.eq(DeviceGroupMember::getId, current.getId())
+                    .set(DeviceGroupMember::getAppUserId, profile.getUserId())
+                    .set(DeviceGroupMember::getStatus, STATUS_ENABLED)
+                    .set(DeviceGroupMember::getJoinTime, LocalDateTime.now());
+            deviceGroupMemberMapper.update(null, wrapper);
+        }
+        ensureUserDevice(device, profileId);
+    }
+
+    private void removeFamilyDeviceUser(Device device, Long profileId) {
+        DeviceGroup group = findDeviceGroup(device.getDeviceCode());
+        if (group != null) {
+            LambdaUpdateWrapper<DeviceGroupMember> memberWrapper = new LambdaUpdateWrapper<>();
+            memberWrapper.eq(DeviceGroupMember::getGroupId, group.getId())
+                    .eq(DeviceGroupMember::getProfileId, profileId)
+                    .eq(DeviceGroupMember::getStatus, STATUS_ENABLED)
+                    .set(DeviceGroupMember::getStatus, 0);
+            deviceGroupMemberMapper.update(null, memberWrapper);
+        }
+        removePublicDeviceUser(device, profileId);
+    }
+
+    private void batchRemoveFamilyDeviceUsers(Device device, List<Long> memberIds) {
+        DeviceGroup group = findDeviceGroup(device.getDeviceCode());
+        if (group == null) {
+            return;
+        }
+        List<DeviceGroupMember> members = deviceGroupMemberMapper.selectList(
+                new LambdaQueryWrapper<DeviceGroupMember>()
+                        .in(DeviceGroupMember::getId, memberIds)
+                        .eq(DeviceGroupMember::getGroupId, group.getId())
+                        .eq(DeviceGroupMember::getStatus, STATUS_ENABLED)
+        );
+        LambdaUpdateWrapper<DeviceGroupMember> wrapper = new LambdaUpdateWrapper<>();
+        wrapper.in(DeviceGroupMember::getId, memberIds)
+                .eq(DeviceGroupMember::getGroupId, group.getId())
+                .set(DeviceGroupMember::getStatus, 0);
+        deviceGroupMemberMapper.update(null, wrapper);
+        for (DeviceGroupMember member : members) {
+            removePublicDeviceUser(device, member.getProfileId());
+        }
+    }
+
+    private void addPublicDeviceUser(Device device, Long profileId) {
+        requireProfile(profileId);
+        UserDevice current = userDeviceMapper.selectOne(
+                new LambdaQueryWrapper<UserDevice>()
+                        .eq(UserDevice::getDeviceCode, device.getDeviceCode())
+                        .eq(UserDevice::getUserId, profileId)
+                        .last("LIMIT 1")
+        );
+        if (current != null && Integer.valueOf(STATUS_ENABLED).equals(current.getStatus())) {
+            throw new BusinessException(ResultCode.DEVICE_USER_ALREADY_BOUND);
+        }
+        if (current == null) {
+            UserDevice ud = new UserDevice();
+            ud.setUserId(profileId);
+            ud.setDeviceCode(device.getDeviceCode());
+            ud.setDeviceName(device.getDeviceName());
+            ud.setDeviceModel(device.getDeviceModel());
+            ud.setStatus(STATUS_ENABLED);
+            ud.setIsPrimary(0);
+            ud.setOnlineStatus(0);
+            ud.setBindTime(LocalDateTime.now());
+            userDeviceMapper.insert(ud);
+            return;
+        }
+        LambdaUpdateWrapper<UserDevice> wrapper = new LambdaUpdateWrapper<>();
+        wrapper.eq(UserDevice::getId, current.getId())
+                .set(UserDevice::getDeviceName, device.getDeviceName())
+                .set(UserDevice::getDeviceModel, device.getDeviceModel())
+                .set(UserDevice::getStatus, STATUS_ENABLED)
+                .set(UserDevice::getIsPrimary, 0)
+                .set(UserDevice::getBindTime, LocalDateTime.now());
+        userDeviceMapper.update(null, wrapper);
+    }
+
+    private void ensureUserDevice(Device device, Long profileId) {
+        UserDevice current = userDeviceMapper.selectOne(
+                new LambdaQueryWrapper<UserDevice>()
+                        .eq(UserDevice::getDeviceCode, device.getDeviceCode())
+                        .eq(UserDevice::getUserId, profileId)
+                        .last("LIMIT 1")
+        );
+        if (current == null) {
+            UserDevice ud = new UserDevice();
+            ud.setUserId(profileId);
+            ud.setDeviceCode(device.getDeviceCode());
+            ud.setDeviceName(device.getDeviceName());
+            ud.setDeviceModel(device.getDeviceModel());
+            ud.setStatus(STATUS_ENABLED);
+            ud.setIsPrimary(0);
+            ud.setOnlineStatus(0);
+            ud.setBindTime(LocalDateTime.now());
+            userDeviceMapper.insert(ud);
+            return;
+        }
+        LambdaUpdateWrapper<UserDevice> wrapper = new LambdaUpdateWrapper<>();
+        wrapper.eq(UserDevice::getId, current.getId())
+                .set(UserDevice::getDeviceName, device.getDeviceName())
+                .set(UserDevice::getDeviceModel, device.getDeviceModel())
+                .set(UserDevice::getStatus, STATUS_ENABLED);
+        userDeviceMapper.update(null, wrapper);
+    }
+
+    private void removePublicDeviceUser(Device device, Long profileId) {
+        LambdaUpdateWrapper<UserDevice> wrapper = new LambdaUpdateWrapper<>();
+        wrapper.eq(UserDevice::getDeviceCode, device.getDeviceCode())
+                .eq(UserDevice::getUserId, profileId)
+                .eq(UserDevice::getStatus, STATUS_ENABLED)
+                .set(UserDevice::getStatus, 0)
+                .set(UserDevice::getIsPrimary, 0);
+        userDeviceMapper.update(null, wrapper);
+    }
+
+    private void syncUserDeviceInfo(Device device) {
+        LambdaUpdateWrapper<UserDevice> wrapper = new LambdaUpdateWrapper<>();
+        wrapper.eq(UserDevice::getDeviceCode, device.getDeviceCode())
+                .eq(UserDevice::getStatus, STATUS_ENABLED)
+                .set(UserDevice::getDeviceName, device.getDeviceName())
+                .set(UserDevice::getDeviceModel, device.getDeviceModel());
+        userDeviceMapper.update(null, wrapper);
+    }
+
+    private UserProfile requireProfile(Long profileId) {
+        UserProfile profile = userProfileMapper.selectById(profileId);
+        if (profile == null) {
+            throw new BusinessException(ResultCode.PROFILE_NOT_FOUND);
+        }
+        return profile;
+    }
+
+    private DeviceGroup ensureDeviceGroup(String deviceCode) {
+        DeviceGroup group = findDeviceGroup(deviceCode);
+        if (group != null) {
+            return group;
+        }
+        group = new DeviceGroup();
+        group.setDeviceCode(deviceCode);
+        group.setStatus(STATUS_ENABLED);
+        deviceGroupMapper.insert(group);
+        return group;
+    }
+
+    private DeviceGroup findDeviceGroup(String deviceCode) {
+        return deviceGroupMapper.selectOne(
+                new LambdaQueryWrapper<DeviceGroup>()
+                        .eq(DeviceGroup::getDeviceCode, deviceCode)
+                        .eq(DeviceGroup::getStatus, STATUS_ENABLED)
+                        .last("LIMIT 1")
+        );
+    }
+
+    private void checkFamilyMemberUnique(Long groupId, UserProfile profile, Long excludeProfileId) {
+        if (StringUtils.hasText(profile.getPhone())) {
+            List<DeviceGroupMember> members = deviceGroupMemberMapper.selectList(
+                    new LambdaQueryWrapper<DeviceGroupMember>()
+                            .eq(DeviceGroupMember::getGroupId, groupId)
+                            .eq(DeviceGroupMember::getStatus, STATUS_ENABLED)
+            );
+            for (DeviceGroupMember member : members) {
+                if (member.getProfileId().equals(excludeProfileId)) {
+                    continue;
+                }
+                UserProfile memberProfile = userProfileMapper.selectById(member.getProfileId());
+                if (memberProfile != null && profile.getPhone().equals(memberProfile.getPhone())) {
+                    throw new BusinessException(ResultCode.PHONE_PROFILE_EXISTS.getCode(), "同一家庭群组内手机号不能重复");
+                }
+            }
+        }
+        if (profile.getUserId() != null) {
+            long count = deviceGroupMemberMapper.selectCount(
+                    new LambdaQueryWrapper<DeviceGroupMember>()
+                            .eq(DeviceGroupMember::getGroupId, groupId)
+                            .eq(DeviceGroupMember::getAppUserId, profile.getUserId())
+                            .ne(DeviceGroupMember::getProfileId, excludeProfileId)
+                            .eq(DeviceGroupMember::getStatus, STATUS_ENABLED)
+            );
+            if (count > 0) {
+                throw new BusinessException(ResultCode.DEVICE_USER_ALREADY_BOUND.getCode(), "该账号已在家庭群组中");
+            }
+        }
+    }
+
+    private boolean hasActiveDeviceUsers(String deviceCode) {
+        long publicCount = userDeviceMapper.selectCount(
+                new LambdaQueryWrapper<UserDevice>()
+                        .eq(UserDevice::getDeviceCode, deviceCode)
+                        .eq(UserDevice::getStatus, STATUS_ENABLED)
+        );
+        if (publicCount > 0) {
+            return true;
+        }
+        DeviceGroup group = findDeviceGroup(deviceCode);
+        if (group == null) {
+            return false;
+        }
+        long memberCount = deviceGroupMemberMapper.selectCount(
+                new LambdaQueryWrapper<DeviceGroupMember>()
+                        .eq(DeviceGroupMember::getGroupId, group.getId())
+                        .eq(DeviceGroupMember::getStatus, STATUS_ENABLED)
+        );
+        return memberCount > 0;
+    }
+
     /**
      * 解绑指定设备的所有用户
      *
@@ -405,9 +676,18 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
     private void unbindAllUsers(String deviceCode) {
         LambdaUpdateWrapper<UserDevice> wrapper = new LambdaUpdateWrapper<>();
         wrapper.eq(UserDevice::getDeviceCode, deviceCode)
-               .eq(UserDevice::getStatus, 1)
+               .eq(UserDevice::getStatus, STATUS_ENABLED)
                .set(UserDevice::getStatus, 0)
                .set(UserDevice::getIsPrimary, 0);
         userDeviceMapper.update(null, wrapper);
+
+        DeviceGroup group = findDeviceGroup(deviceCode);
+        if (group != null) {
+            LambdaUpdateWrapper<DeviceGroupMember> memberWrapper = new LambdaUpdateWrapper<>();
+            memberWrapper.eq(DeviceGroupMember::getGroupId, group.getId())
+                    .eq(DeviceGroupMember::getStatus, STATUS_ENABLED)
+                    .set(DeviceGroupMember::getStatus, 0);
+            deviceGroupMemberMapper.update(null, memberWrapper);
+        }
     }
 }

+ 105 - 30
code/backend/src/main/resources/mapper/DeviceMapper.xml

@@ -2,12 +2,6 @@
 <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 <mapper namespace="com.aijiuyi.admin.mapper.DeviceMapper">
 
-    <!--
-        分页查询设备列表,LEFT JOIN user_device + user_profile 聚合绑定用户数和姓名
-        bound_user_count:已绑定且未删除的用户数
-        bound_user_names:已绑定用户姓名,顿号分隔
-        绑定用户姓名过滤使用子查询,避免 LEFT JOIN 过滤导致无用户设备丢失
-    -->
     <select id="pageWithUsers" resultType="com.aijiuyi.admin.entity.Device">
         SELECT
             d.id,
@@ -27,14 +21,53 @@
             d.deleted,
             d.create_time,
             d.update_time,
-            COUNT(DISTINCT ud.user_id) AS bound_user_count,
-            GROUP_CONCAT(DISTINCT up.name ORDER BY ud.bind_time DESC SEPARATOR '、') AS bound_user_names
+            CASE
+                WHEN COALESCE(d.device_type, 1) = 1 THEN (
+                    SELECT COUNT(DISTINCT dgm.profile_id)
+                    FROM device_group dg
+                    JOIN device_group_member dgm ON dgm.group_id = dg.id
+                        AND dgm.deleted = 0
+                        AND dgm.status = 1
+                    JOIN user_profile up_count ON up_count.id = dgm.profile_id
+                        AND up_count.deleted = 0
+                    WHERE dg.device_code = d.device_code
+                      AND dg.deleted = 0
+                      AND dg.status = 1
+                )
+                ELSE (
+                    SELECT COUNT(DISTINCT ud_count.user_id)
+                    FROM user_device ud_count
+                    JOIN user_profile up_count ON up_count.id = ud_count.user_id
+                        AND up_count.deleted = 0
+                    WHERE ud_count.device_code = d.device_code
+                      AND ud_count.deleted = 0
+                      AND ud_count.status = 1
+                )
+            END AS bound_user_count,
+            CASE
+                WHEN COALESCE(d.device_type, 1) = 1 THEN (
+                    SELECT GROUP_CONCAT(DISTINCT up_name.name ORDER BY dgm_name.join_time DESC SEPARATOR '、')
+                    FROM device_group dg_name
+                    JOIN device_group_member dgm_name ON dgm_name.group_id = dg_name.id
+                        AND dgm_name.deleted = 0
+                        AND dgm_name.status = 1
+                    JOIN user_profile up_name ON up_name.id = dgm_name.profile_id
+                        AND up_name.deleted = 0
+                    WHERE dg_name.device_code = d.device_code
+                      AND dg_name.deleted = 0
+                      AND dg_name.status = 1
+                )
+                ELSE (
+                    SELECT GROUP_CONCAT(DISTINCT up_name.name ORDER BY ud_name.bind_time DESC SEPARATOR '、')
+                    FROM user_device ud_name
+                    JOIN user_profile up_name ON up_name.id = ud_name.user_id
+                        AND up_name.deleted = 0
+                    WHERE ud_name.device_code = d.device_code
+                      AND ud_name.deleted = 0
+                      AND ud_name.status = 1
+                )
+            END AS bound_user_names
         FROM device d
-        LEFT JOIN user_device ud ON ud.device_code = d.device_code
-            AND ud.deleted = 0
-            AND ud.status = 1
-        LEFT JOIN user_profile up ON up.id = ud.user_id
-            AND up.deleted = 0
         WHERE d.deleted = 0
         <if test="q.deviceCode != null and q.deviceCode != ''">
             AND d.device_code LIKE CONCAT('%', #{q.deviceCode}, '%')
@@ -66,24 +99,63 @@
             AND d.district_code = #{q.districtCode}
         </if>
         <if test="q.boundUserName != null and q.boundUserName != ''">
-            AND d.device_code IN (
-                SELECT ud2.device_code FROM user_device ud2
-                JOIN user_profile up2 ON up2.id = ud2.user_id
-                WHERE up2.name LIKE CONCAT('%', #{q.boundUserName}, '%')
-                  AND ud2.status = 1
-                  AND ud2.deleted = 0
-                  AND up2.deleted = 0
+            AND (
+                (
+                    COALESCE(d.device_type, 1) = 1
+                    AND EXISTS (
+                        SELECT 1
+                        FROM device_group dg2
+                        JOIN device_group_member dgm2 ON dgm2.group_id = dg2.id
+                            AND dgm2.deleted = 0
+                            AND dgm2.status = 1
+                        JOIN user_profile up2 ON up2.id = dgm2.profile_id
+                            AND up2.deleted = 0
+                        WHERE dg2.device_code = d.device_code
+                          AND dg2.deleted = 0
+                          AND dg2.status = 1
+                          AND up2.name LIKE CONCAT('%', #{q.boundUserName}, '%')
+                    )
+                )
+                OR
+                (
+                    COALESCE(d.device_type, 1) = 2
+                    AND EXISTS (
+                        SELECT 1
+                        FROM user_device ud2
+                        JOIN user_profile up2 ON up2.id = ud2.user_id
+                            AND up2.deleted = 0
+                        WHERE ud2.device_code = d.device_code
+                          AND ud2.status = 1
+                          AND ud2.deleted = 0
+                          AND up2.name LIKE CONCAT('%', #{q.boundUserName}, '%')
+                    )
+                )
             )
         </if>
-        GROUP BY d.id
         ORDER BY d.create_time DESC
     </select>
 
-    <!--
-        根据设备编号查询绑定的用户列表(联表 user_device + user_profile)
-        返回 DeviceUserVO 视图对象:bindId/userId/userName/phone/bindTime/isPrimary
-    -->
     <select id="getUsersByDeviceCode" resultType="com.aijiuyi.admin.entity.DeviceUserVO">
+        SELECT
+            dgm.id       AS bind_id,
+            dgm.profile_id AS user_id,
+            up.name      AS user_name,
+            up.phone,
+            dgm.join_time AS bind_time,
+            0            AS is_primary
+        FROM device d
+        JOIN device_group dg ON dg.device_code = d.device_code
+            AND dg.deleted = 0
+            AND dg.status = 1
+        JOIN device_group_member dgm ON dgm.group_id = dg.id
+            AND dgm.deleted = 0
+            AND dgm.status = 1
+        JOIN user_profile up ON up.id = dgm.profile_id
+            AND up.deleted = 0
+        WHERE d.device_code = #{deviceCode}
+          AND d.deleted = 0
+          AND COALESCE(d.device_type, 1) = 1
+        UNION ALL
         SELECT
             ud.id        AS bind_id,
             ud.user_id,
@@ -91,13 +163,16 @@
             up.phone,
             ud.bind_time,
             ud.is_primary
-        FROM user_device ud
+        FROM device d
+        JOIN user_device ud ON ud.device_code = d.device_code
+            AND ud.deleted = 0
+            AND ud.status = 1
         JOIN user_profile up ON up.id = ud.user_id
             AND up.deleted = 0
-        WHERE ud.device_code = #{deviceCode}
-          AND ud.deleted = 0
-          AND ud.status = 1
-        ORDER BY ud.is_primary DESC, ud.bind_time ASC
+        WHERE d.device_code = #{deviceCode}
+          AND d.deleted = 0
+          AND COALESCE(d.device_type, 1) = 2
+        ORDER BY is_primary DESC, bind_time ASC
     </select>
 
 </mapper>

+ 8 - 8
code/frontend/src/api/device.js

@@ -9,7 +9,7 @@ import request from '@/utils/request'
 
 /**
  * 分页查询设备列表
- * @param {Object} params 查询参数:deviceCode/deviceName/boundUserName/onlineStatus/firmwareVersion/pageNum/pageSize
+ * @param {Object} params 查询参数:deviceCode/deviceName/deviceType/boundUserName/onlineStatus/firmwareVersion/pageNum/pageSize
  */
 export function getDevicePageList(params) {
   return request.get('/device/page', { params })
@@ -25,7 +25,7 @@ export function getDeviceById(id) {
 
 /**
  * 新增设备
- * @param {Object} data 设备信息(含 userIds 可选初始绑定用户)
+ * @param {Object} data 设备信息(含 userIds 可选初始成员/绑定用户)
  */
 export function addDevice(data) {
   return request.post('/device', data)
@@ -66,7 +66,7 @@ export function batchImportDevice(list) {
 // ======================== 设备用户管理 ========================
 
 /**
- * 查询设备绑定的用户列表
+ * 查询设备关联的用户/成员列表
  * @param {string} deviceId 设备ID
  */
 export function getDeviceUsers(deviceId) {
@@ -74,7 +74,7 @@ export function getDeviceUsers(deviceId) {
 }
 
 /**
- * 为设备添加绑定用户
+ * 为设备添加成员/绑定用户
  * @param {string} deviceId  设备ID
  * @param {string} profileId 用户档案ID
  */
@@ -83,7 +83,7 @@ export function addDeviceUser(deviceId, profileId) {
 }
 
 /**
- * 解绑设备用户
+ * 移除设备成员/解绑设备用户
  * @param {string} deviceId  设备ID
  * @param {string} profileId 用户档案ID
  */
@@ -92,7 +92,7 @@ export function removeDeviceUser(deviceId, profileId) {
 }
 
 /**
- * 设为主用户
+ * 设为主用户(仅公共设备兼容旧绑定关系)
  * @param {string} deviceId  设备ID
  * @param {string} profileId 用户档案ID
  */
@@ -101,9 +101,9 @@ export function setDeviceUserPrimary(deviceId, profileId) {
 }
 
 /**
- * 批量解绑设备用户
+ * 批量移除设备成员/解绑设备用户
  * @param {string}   deviceId 设备ID
- * @param {string[]} bindIds  绑定记录ID列表(user_device.id)
+ * @param {string[]} bindIds  关联记录ID列表(家庭设备为 device_group_member.id,公共设备为 user_device.id)
  */
 export function batchRemoveDeviceUsers(deviceId, bindIds) {
   return request.delete(`/device/${deviceId}/users/batch`, { data: bindIds })

+ 52 - 18
code/frontend/src/views/device/index.vue

@@ -9,8 +9,8 @@
         <el-form-item label="设备名称">
           <el-input v-model="query.deviceName" placeholder="请输入设备名称" clearable style="width:160px" />
         </el-form-item>
-        <el-form-item label="绑定用户">
-          <el-input v-model="query.boundUserName" placeholder="用户姓名模糊搜索" clearable style="width:160px" />
+        <el-form-item label="关联用户/成员">
+          <el-input v-model="query.boundUserName" placeholder="姓名模糊搜索" clearable style="width:160px" />
         </el-form-item>
         <el-form-item label="设备类型">
           <el-select v-model="query.deviceType" placeholder="全部" clearable style="width:120px">
@@ -114,12 +114,12 @@
             </el-tag>
           </template>
         </el-table-column>
-        <el-table-column label="绑定用户数" width="95" align="center">
+        <el-table-column label="关联人数" width="95" align="center">
           <template #default="{ row }">
             <el-tag type="info" size="small">{{ row.boundUserCount || 0 }} 人</el-tag>
           </template>
         </el-table-column>
-        <el-table-column prop="boundUserNames" label="绑定用户" min-width="130">
+        <el-table-column prop="boundUserNames" label="关联用户/成员" min-width="130">
           <template #default="{ row }">{{ formatBoundUserNames(row) }}</template>
         </el-table-column>
         <el-table-column label="在线状态" width="90" align="center">
@@ -142,7 +142,9 @@
           <template #default="{ row }">
             <el-button size="small" type="primary" link @click="handleView(row)">查看</el-button>
             <el-button size="small" type="warning" link @click="handleEdit(row)">编辑</el-button>
-            <el-button size="small" type="success" link @click="handleManageUsers(row)">用户管理</el-button>
+            <el-button size="small" type="success" link @click="handleManageUsers(row)">
+              {{ manageUsersText(row) }}
+            </el-button>
             <el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button>
           </template>
         </el-table-column>
@@ -209,11 +211,12 @@
             v-model="deviceForm.deviceType"
             placeholder="请选择设备类型"
             style="width:100%"
-            :disabled="formMode === 'view'"
+            :disabled="isDeviceTypeDisabled"
           >
             <el-option label="家庭设备" :value="1" />
             <el-option label="公共设备" :value="2" />
           </el-select>
+          <div v-if="deviceTypeDisabledTip" class="form-tip">{{ deviceTypeDisabledTip }}</div>
         </el-form-item>
         <el-form-item label="设备序列号" prop="serialNo">
           <el-input
@@ -231,7 +234,7 @@
             :disabled="formMode === 'view'"
           />
         </el-form-item>
-        <el-form-item label="绑定用户">
+        <el-form-item :label="formUserLabel">
           <el-select
             v-model="deviceForm.userIds"
             multiple
@@ -239,7 +242,7 @@
             remote
             :remote-method="searchUserOptions"
             :loading="userOptionsLoading"
-            placeholder="可搜索并选择用户(选填)"
+            :placeholder="formUserPlaceholder"
             style="width:100%"
             :disabled="formMode === 'view'"
           >
@@ -457,6 +460,10 @@ function formatDeviceType(deviceType) {
   return Number(deviceType) === 2 ? '公共设备' : '家庭设备'
 }
 
+function manageUsersText(row) {
+  return Number(row.deviceType) === 2 ? '绑定用户' : '成员管理'
+}
+
 /** 导出表头配置 */
 const exportHeaders = [
   { label: '设备编号', prop: 'deviceCode' },
@@ -464,8 +471,8 @@ const exportHeaders = [
   { label: '设备型号', prop: 'deviceModel' },
   { label: '设备类型', prop: 'deviceTypeLabel' },
   { label: '所在地区', prop: 'regionText' },
-  { label: '绑定用户数', prop: 'boundUserCount' },
-  { label: '绑定用户', prop: 'boundUserNames' },
+  { label: '关联人数', prop: 'boundUserCount' },
+  { label: '关联用户/成员', prop: 'boundUserNames' },
   { label: '在线状态', prop: 'onlineStatusLabel' },
   { label: '固件版本', prop: 'firmwareVersion' },
   { label: '最后在线时间', prop: 'lastOnlineTime' }
@@ -535,6 +542,7 @@ const formDialogVisible = ref(false)
 const submitLoading = ref(false)
 const deviceFormRef = ref(null)
 const formMode = ref('add') // 'add' | 'edit' | 'view'
+const currentBoundUserCount = ref(0)
 
 const deviceForm = reactive({
   id: null,
@@ -550,6 +558,17 @@ const deviceForm = reactive({
   remark: ''
 })
 
+const formIsFamilyDevice = computed(() => Number(deviceForm.deviceType || 1) !== 2)
+const formUserLabel = computed(() => formIsFamilyDevice.value ? '家庭成员' : '绑定用户')
+const formUserPlaceholder = computed(() => formIsFamilyDevice.value ? '可搜索并选择家庭成员(选填)' : '可搜索并选择绑定用户(选填)')
+const isDeviceTypeDisabled = computed(() => formMode.value === 'view' || (formMode.value === 'edit' && currentBoundUserCount.value > 0))
+const deviceTypeDisabledTip = computed(() => {
+  if (formMode.value === 'edit' && currentBoundUserCount.value > 0) {
+    return '已有成员或绑定用户的设备不允许直接修改设备类型'
+  }
+  return ''
+})
+
 const deviceRules = {
   deviceCode: [
     { required: true, message: '请输入设备编号', trigger: 'blur' },
@@ -568,7 +587,7 @@ const deviceRules = {
   firmwareVersion: [{ required: true, message: '请输入固件版本', trigger: 'blur' }]
 }
 
-// 用户选项数据(供绑定用户下拉使用)
+// 用户选项数据(供关联用户/成员下拉使用)
 const userOptions = ref([])
 const userOptionsLoading = ref(false)
 
@@ -591,6 +610,7 @@ async function searchUserOptions(keyword) {
  */
 async function handleAdd() {
   formMode.value = 'add'
+  currentBoundUserCount.value = 0
   Object.assign(deviceForm, {
     id: null,
     deviceCode: '',
@@ -615,7 +635,8 @@ async function handleAdd() {
  */
 async function handleView(row) {
   formMode.value = 'view'
-  // 获取当前绑定用户列表,填充 userOptions 和 userIds 以实现回显
+  currentBoundUserCount.value = Number(row.boundUserCount) || 0
+  // 获取当前关联用户/成员列表,填充 userOptions 和 userIds 以实现回显
   const res = await getDeviceUsers(row.id)
   const boundUsers = res.data || []
   userOptions.value = boundUsers.map(u => ({ id: u.userId, name: u.userName, phone: u.phone }))
@@ -635,12 +656,13 @@ async function handleView(row) {
  */
 async function handleEdit(row) {
   formMode.value = 'edit'
+  currentBoundUserCount.value = Number(row.boundUserCount) || 0
   // 加载全量用户选项(供重新选择)
   await searchUserOptions('')
-  // 获取当前绑定用户列表,回显已选用户
+  // 获取当前关联用户/成员列表,回显已选用户
   const res = await getDeviceUsers(row.id)
   const boundUsers = res.data || []
-  // 将已绑定用户合并进 userOptions,防止 remote 模式下找不到对应选项
+  // 将已关联用户/成员合并进 userOptions,防止 remote 模式下找不到对应选项
   boundUsers.forEach(u => {
     if (!userOptions.value.find(o => o.id === u.userId)) {
       userOptions.value.push({ id: u.userId, name: u.userName, phone: u.phone })
@@ -683,7 +705,7 @@ async function submitForm() {
       districtCode: codes[2] || '',
       address: deviceForm.address || undefined,
       remark: deviceForm.remark || undefined,
-      userIds: deviceForm.userIds.length > 0 ? deviceForm.userIds : undefined
+      userIds: deviceForm.userIds || []
     }
     if (formMode.value === 'add') {
       await addDevice(payload)
@@ -707,7 +729,7 @@ async function submitForm() {
  */
 async function handleDelete(row) {
   await ElMessageBox.confirm(
-    `确认删除设备「${row.deviceName}」吗?删除后将同时解绑该设备的所有用户。`,
+    `确认删除设备「${row.deviceName}」吗?删除后将同时移除或解绑该设备的所有关联用户。`,
     '删除确认',
     { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }
   )
@@ -721,7 +743,7 @@ async function handleDelete(row) {
  */
 async function handleBatchDelete() {
   await ElMessageBox.confirm(
-    `确认批量删除选中的 ${selectedIds.value.length} 台设备吗?删除后将同时解绑这些设备的所有用户。`,
+    `确认批量删除选中的 ${selectedIds.value.length} 台设备吗?删除后将同时移除或解绑这些设备的所有关联用户。`,
     '批量删除确认',
     { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }
   )
@@ -774,7 +796,12 @@ function onImportSuccess() {
 function handleManageUsers(row) {
   router.push({
     path: '/device/users',
-    query: { deviceId: row.id, deviceCode: row.deviceCode, deviceName: row.deviceName }
+    query: {
+      deviceId: row.id,
+      deviceCode: row.deviceCode,
+      deviceName: row.deviceName,
+      deviceType: row.deviceType || 1
+    }
   })
 }
 
@@ -794,6 +821,13 @@ onMounted(loadData)
   }
 }
 
+.form-tip {
+  margin-top: 4px;
+  color: #909399;
+  font-size: 12px;
+  line-height: 1.4;
+}
+
 .action-card {
   :deep(.el-card__body) {
     padding: 14px 20px;

+ 38 - 58
code/frontend/src/views/device/users.vue

@@ -4,7 +4,7 @@
     <div class="page-header">
       <el-button :icon="ArrowLeft" @click="goBack">返回设备列表</el-button>
       <span class="page-title">
-        设备用户管理
+        {{ pageTitle }}
         <span class="device-info">{{ deviceName }}({{ deviceCode }})</span>
       </span>
     </div>
@@ -13,13 +13,13 @@
     <el-card class="action-card">
       <div class="action-bar">
         <div>
-          <el-button type="primary" :icon="Plus" @click="handleAddUser">添加用户</el-button>
+          <el-button type="primary" :icon="Plus" @click="handleAddUser">{{ addButtonText }}</el-button>
           <el-button
             type="danger"
             :icon="Delete"
             :disabled="selectedBindIds.length === 0"
             @click="handleBatchUnbind"
-          >批量解绑</el-button>
+          >{{ batchRemoveText }}</el-button>
         </div>
       </div>
     </el-card>
@@ -35,41 +35,25 @@
       >
         <el-table-column type="selection" width="50" align="center" />
         <el-table-column type="index" label="序号" width="60" align="center" />
-        <el-table-column prop="userId" label="用户ID" width="80" align="center" />
-        <el-table-column prop="userName" label="用户姓名" min-width="100" />
+        <el-table-column prop="userId" label="档案ID" width="80" align="center" />
+        <el-table-column prop="userName" label="姓名" min-width="100" />
         <el-table-column prop="phone" label="手机号" min-width="130">
           <template #default="{ row }">
             {{ row.phone ? row.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : '-' }}
           </template>
         </el-table-column>
-        <el-table-column prop="bindTime" label="绑定时间" width="170">
+        <el-table-column prop="bindTime" :label="timeColumnLabel" width="170">
           <template #default="{ row }">{{ formatDateTime(row.bindTime) }}</template>
         </el-table-column>
-        <el-table-column label="是否主用户" width="100" align="center">
-          <template #default="{ row }">
-            <el-tag :type="row.isPrimary === 1 ? 'warning' : 'info'" size="small">
-              {{ row.isPrimary === 1 ? '是' : '否' }}
-            </el-tag>
-          </template>
-        </el-table-column>
-        <el-table-column label="操作" width="170" align="center" fixed="right">
+        <el-table-column label="操作" width="90" align="center" fixed="right">
           <template #default="{ row }">
-            <el-button
-              size="small"
-              type="primary"
-              link
-              :disabled="row.isPrimary === 1"
-              @click="handleSetPrimary(row)"
-            >
-              设为主用户
-            </el-button>
             <el-button
               size="small"
               type="danger"
               link
               @click="handleUnbind(row)"
             >
-              解绑
+              {{ removeText }}
             </el-button>
           </template>
         </el-table-column>
@@ -90,8 +74,8 @@
     </el-card>
 
     <!-- 添加用户弹窗 -->
-    <el-dialog v-model="addUserDialogVisible" title="添加绑定用户" width="520px" destroy-on-close :close-on-click-modal="false">
-      <div class="add-user-tip">从用户档案列表中选择用户与该设备绑定</div>
+    <el-dialog v-model="addUserDialogVisible" :title="addDialogTitle" width="520px" destroy-on-close :close-on-click-modal="false">
+      <div class="add-user-tip">{{ addUserTip }}</div>
       <el-input
         v-model="addUserKeyword"
         placeholder="搜索用户姓名或手机号"
@@ -109,7 +93,7 @@
         @selection-change="handleAddUserSelect"
       >
         <el-table-column type="selection" width="50" align="center" />
-        <el-table-column prop="id" label="用户ID" width="80" align="center" />
+        <el-table-column prop="id" label="档案ID" width="80" align="center" />
         <el-table-column prop="name" label="姓名" min-width="100" />
         <el-table-column prop="phone" label="手机号" min-width="130">
           <template #default="{ row }">
@@ -124,7 +108,7 @@
           :loading="addUserSubmitLoading"
           :disabled="selectedAddUsers.length === 0"
           @click="submitAddUser"
-        >确认绑定({{ selectedAddUsers.length }} 人)</el-button>
+        >{{ confirmAddText }}({{ selectedAddUsers.length }} 人)</el-button>
       </template>
     </el-dialog>
   </div>
@@ -140,7 +124,6 @@ import {
   getDeviceUsers,
   addDeviceUser,
   removeDeviceUser,
-  setDeviceUserPrimary,
   batchRemoveDeviceUsers,
   getUserProfileOptions
 } from '@/api/device'
@@ -152,6 +135,18 @@ const router = useRouter()
 const deviceId = computed(() => route.query.deviceId ? Number(route.query.deviceId) : null)
 const deviceCode = computed(() => route.query.deviceCode || '')
 const deviceName = computed(() => route.query.deviceName || '')
+const deviceType = computed(() => Number(route.query.deviceType || 1))
+const isFamilyDevice = computed(() => deviceType.value !== 2)
+const pageTitle = computed(() => isFamilyDevice.value ? '家庭成员管理' : '公共设备绑定用户')
+const addButtonText = computed(() => isFamilyDevice.value ? '添加成员' : '添加绑定用户')
+const batchRemoveText = computed(() => isFamilyDevice.value ? '批量移除' : '批量解绑')
+const removeText = computed(() => isFamilyDevice.value ? '移除' : '解绑')
+const addDialogTitle = computed(() => isFamilyDevice.value ? '添加家庭成员' : '添加绑定用户')
+const addUserTip = computed(() => isFamilyDevice.value
+  ? '从用户档案列表中选择成员加入该家庭设备群组'
+  : '从用户档案列表中选择用户与该公共设备绑定')
+const confirmAddText = computed(() => isFamilyDevice.value ? '确认添加' : '确认绑定')
+const timeColumnLabel = computed(() => isFamilyDevice.value ? '加入时间' : '绑定时间')
 
 // 列表数据
 const allUsers = ref([])
@@ -166,7 +161,7 @@ const loading = ref(false)
 const selectedBindIds = ref([])
 
 /**
- * 加载设备绑定用户列表
+ * 加载设备关联用户/成员列表
  */
 async function loadData() {
   if (!deviceId.value) return
@@ -202,7 +197,7 @@ function handleCurrentChange() {
 }
 
 /**
- * 多选变化(选择的是 bindId,即 user_device.id
+ * 多选变化(家庭设备选择群组成员记录ID,公共设备选择绑定记录ID
  */
 function handleSelectionChange(selection) {
   selectedBindIds.value = selection.map(r => r.bindId)
@@ -223,12 +218,12 @@ function goBack() {
  */
 async function handleUnbind(row) {
   await ElMessageBox.confirm(
-    `确认解绑用户「${row.userName}」吗?`,
-    '解绑确认',
-    { type: 'warning', confirmButtonText: '确认解绑', cancelButtonText: '取消' }
+    isFamilyDevice.value ? `确认将成员「${row.userName}」从家庭群组中移除吗?` : `确认解绑用户「${row.userName}」吗?`,
+    isFamilyDevice.value ? '移除确认' : '解绑确认',
+    { type: 'warning', confirmButtonText: isFamilyDevice.value ? '确认移除' : '确认解绑', cancelButtonText: '取消' }
   )
   await removeDeviceUser(deviceId.value, row.userId)
-  ElMessage.success('解绑成功')
+  ElMessage.success(isFamilyDevice.value ? '移除成功' : '解绑成功')
   loadData()
 }
 
@@ -237,33 +232,18 @@ async function handleUnbind(row) {
  */
 async function handleBatchUnbind() {
   await ElMessageBox.confirm(
-    `确认批量解绑选中的 ${selectedBindIds.value.length} 位用户吗?`,
-    '批量解绑确认',
-    { type: 'warning', confirmButtonText: '确认解绑', cancelButtonText: '取消' }
+    isFamilyDevice.value
+      ? `确认批量移除选中的 ${selectedBindIds.value.length} 位成员吗?`
+      : `确认批量解绑选中的 ${selectedBindIds.value.length} 位用户吗?`,
+    isFamilyDevice.value ? '批量移除确认' : '批量解绑确认',
+    { type: 'warning', confirmButtonText: isFamilyDevice.value ? '确认移除' : '确认解绑', cancelButtonText: '取消' }
   )
   await batchRemoveDeviceUsers(deviceId.value, selectedBindIds.value)
-  ElMessage.success('批量解绑成功')
+  ElMessage.success(isFamilyDevice.value ? '批量移除成功' : '批量解绑成功')
   selectedBindIds.value = []
   loadData()
 }
 
-// ======================== 设为主用户 ========================
-
-/**
- * 设置主用户
- * @param {Object} row 行数据
- */
-async function handleSetPrimary(row) {
-  await ElMessageBox.confirm(
-    `确认将「${row.userName}」设为该设备的主用户吗?`,
-    '设置主用户',
-    { type: 'warning', confirmButtonText: '确认', cancelButtonText: '取消' }
-  )
-  await setDeviceUserPrimary(deviceId.value, row.userId)
-  ElMessage.success('设置成功')
-  loadData()
-}
-
 // ======================== 添加用户 ========================
 const addUserDialogVisible = ref(false)
 const addUserKeyword = ref('')
@@ -303,7 +283,7 @@ function handleAddUserSelect(selection) {
 }
 
 /**
- * 提交绑定用户
+ * 提交关联用户/成员
  */
 async function submitAddUser() {
   addUserSubmitLoading.value = true
@@ -318,7 +298,7 @@ async function submitAddUser() {
         // 跳过已绑定的情况
       }
     }
-    ElMessage.success(`成功绑定 ${successCount} 位用户`)
+    ElMessage.success(isFamilyDevice.value ? `成功添加 ${successCount} 位成员` : `成功绑定 ${successCount} 位用户`)
     addUserDialogVisible.value = false
     loadData()
   } finally {