浏览代码

app端通过设备ID查询设备详情

jiapu 3 月之前
父节点
当前提交
95772c3951

+ 209 - 1
code/app对接接口说明.md

@@ -16,6 +16,8 @@
 | 1 | 查询用户背部产品穴位坐标列表 | `GET` | `/api/user/acupoint/{profileId}/product-back-coordinates` | 查询指定子用户身体部位为背部且产品穴位为是的穴位坐标 |
 | 2 | 查询当前账号下的设备列表 | `GET` | `/api/app/device/list` | 通过 Token 获取当前 APP 账号可使用的设备列表 |
 | 3 | 用户手动输入设备编号绑定设备 | `POST` | `/api/app/user/device/bind-by-code` | APP 端按设备编号绑定到当前 Token 账号下,编号不存在返回错误提示 |
+| 4 | 查询设备详情 | `GET` | `/api/device/{id}` | 后台管理系统查询指定设备的详细信息 |
+| 5 | APP 端查询设备详情 | `GET` | `/api/app/device/{id}` | APP 端查询当前账号可访问的设备详情(仅限已绑定的家庭/公共设备) |
 
 ## 3. 接口详情
 
@@ -320,4 +322,210 @@ curl -X POST "http://localhost:18888/api/app/user/device/bind-by-code" \
 - 成功响应中的 `userId` 为后端自动关联的用户档案 ID,对应 `user_profile.id`。
 - 后端会用 `deviceCode` 查询设备主表,存在时自动写入 `deviceName`、`deviceModel`。
 - 绑定成功后,绑定记录默认 `status=1`、`isPrimary=0`。
-- APP 可根据失败响应中的 `message` 直接弹窗,例如设备编号不存在时提示“设备不存在”。
+- APP 可根据失败响应中的 `message` 直接弹窗,例如设备编号不存在时提示"设备不存在"。
+
+### 3.4 查询设备详情
+
+#### 3.4.1 接口用途
+
+用于后台管理系统查询指定设备的详细信息,包括设备基本信息、在线状态、关联用户等。
+
+#### 3.4.2 请求信息
+
+- 请求方式:`GET`
+- URI:`/device/{id}`
+- 完整示例:`http://localhost:18888/device/1001`
+
+#### 3.4.3 请求参数
+
+| 参数名 | 类型 | 是否必填 | 说明 | 示例 |
+| --- | --- | --- | --- | --- |
+| `id` | `number` | 是 | 设备 ID,对应 `device.id`,通过路径参数传递 | `1001` |
+
+#### 3.4.4 调用示例
+
+```http
+GET /device/1001 HTTP/1.1
+Host: localhost:18888
+Authorization: Bearer <token>
+```
+
+```bash
+curl -X GET "http://localhost:18888/device/1001" \
+  -H "Authorization: Bearer <token>"
+```
+
+#### 3.4.5 返回示例
+
+```json
+{
+  "code": 200,
+  "message": "操作成功",
+  "data": {
+    "id": 1001,
+    "deviceCode": "AJY-0001",
+    "deviceName": "艾灸椅一号",
+    "deviceModel": "AJY-2026",
+    "deviceType": 1,
+    "serialNo": "SN202606020001",
+    "firmwareVersion": "1.0.0",
+    "onlineStatus": 0,
+    "lastOnlineTime": "2026-06-02 10:30:00",
+    "provinceCode": "440000",
+    "cityCode": "440300",
+    "districtCode": "440305",
+    "address": "广东省深圳市南山区",
+    "remark": "测试设备",
+    "deleted": 0,
+    "createTime": "2026-06-01 09:00:00",
+    "updateTime": "2026-06-02 10:30:00",
+    "boundUserCount": 3,
+    "boundUserNames": "张三、李四、王五"
+  }
+}
+```
+
+#### 3.4.6 字段说明
+
+| 字段名 | 类型 | 说明 |
+| --- | --- | --- |
+| `id` | `number` | 设备 ID,对应 `device.id` |
+| `deviceCode` | `string` | 设备编号(唯一) |
+| `deviceName` | `string` | 设备名称 |
+| `deviceModel` | `string` | 设备型号:`AJY-2026` / `AJY-2025` |
+| `deviceType` | `number` | 设备类型:`1=家庭设备`,`2=公共设备` |
+| `serialNo` | `string` | 设备序列号(唯一) |
+| `firmwareVersion` | `string` | 固件版本 |
+| `onlineStatus` | `number` | 在线状态:`1=在线`,`0=离线` |
+| `lastOnlineTime` | `string` | 最后在线时间 |
+| `provinceCode` | `string` | 省级行政区划编码(GB/T 2260) |
+| `cityCode` | `string` | 市级行政区划编码 |
+| `districtCode` | `string` | 区/县级行政区划编码 |
+| `address` | `string` | 详细地址 |
+| `remark` | `string` | 备注 |
+| `deleted` | `number` | 逻辑删除:`0=未删除`,`1=已删除` |
+| `createTime` | `string` | 创建时间 |
+| `updateTime` | `string` | 更新时间 |
+| `boundUserCount` | `number` | 关联用户/成员数量(非数据库字段,联表聚合) |
+| `boundUserNames` | `string` | 关联用户/成员姓名列表(联表聚合,顿号分隔) |
+
+#### 3.4.7 备注
+
+- 本接口为后台管理系统专用,需要管理员 Token。
+- `boundUserCount`、`boundUserNames` 为联表聚合查询结果,非数据库原生字段。
+- 公共设备返回所有已绑定的用户列表;家庭设备返回该家庭群组的所有成员。
+
+### 3.5 APP 端查询设备详情
+
+#### 3.5.1 接口用途
+
+用于 APP 端查询当前登录账号可访问的设备详情。
+
+后端会根据 Token 中的 APP 账号 ID 校验访问权限:
+- 家庭设备(`deviceType=1`):校验当前 APP 账号是否在该设备的家庭群组内(`device_group_member`)
+- 公共设备(`deviceType=2`):校验当前 APP 账号下的用户档案是否已绑定该设备(`user_device`)
+- 越权或设备不存在时返回 `403` 或 `1300` 错误
+
+#### 3.5.2 请求信息
+
+- 请求方式:`GET`
+- URI:`/api/app/device/{id}`
+- 完整示例:`http://localhost:18888/api/app/device/1001`
+
+#### 3.5.3 请求参数
+
+| 参数名 | 类型 | 是否必填 | 说明 | 示例 |
+| --- | --- | --- | --- | --- |
+| `id` | `number` | 是 | 设备 ID,对应 `device.id`,通过路径参数传递 | `1001` |
+
+#### 3.5.4 调用示例
+
+```http
+GET /api/app/device/1001 HTTP/1.1
+Host: localhost:18888
+Authorization: Bearer <token>
+```
+
+```bash
+curl -X GET "http://localhost:18888/api/app/device/1001" \
+  -H "Authorization: Bearer <token>"
+```
+
+#### 3.5.5 返回示例
+
+```json
+{
+  "code": 200,
+  "message": "操作成功",
+  "data": {
+    "id": 1001,
+    "deviceCode": "AJY-0001",
+    "deviceName": "艾灸椅一号",
+    "deviceModel": "AJY-2026",
+    "deviceType": 1,
+    "serialNo": "SN202606020001",
+    "firmwareVersion": "1.0.0",
+    "onlineStatus": 0,
+    "lastOnlineTime": "2026-06-02 10:30:00",
+    "provinceCode": "440000",
+    "cityCode": "440300",
+    "districtCode": "440305",
+    "address": "广东省深圳市南山区",
+    "remark": "测试设备",
+    "deleted": 0,
+    "createTime": "2026-06-01 09:00:00",
+    "updateTime": "2026-06-02 10:30:00"
+  }
+}
+```
+
+#### 3.5.6 字段说明
+
+| 字段名 | 类型 | 说明 |
+| --- | --- | --- |
+| `id` | `number` | 设备 ID,对应 `device.id` |
+| `deviceCode` | `string` | 设备编号 |
+| `deviceName` | `string` | 设备名称 |
+| `deviceModel` | `string` | 设备型号 |
+| `deviceType` | `number` | 设备类型:`1=家庭设备`,`2=公共设备` |
+| `serialNo` | `string` | 设备序列号 |
+| `firmwareVersion` | `string` | 固件版本 |
+| `onlineStatus` | `number` | 在线状态:`1=在线`,`0=离线` |
+| `lastOnlineTime` | `string` | 最后在线时间 |
+| `provinceCode` | `string` | 省级行政区划编码(GB/T 2260) |
+| `cityCode` | `string` | 市级行政区划编码 |
+| `districtCode` | `string` | 区/县级行政区划编码 |
+| `address` | `string` | 详细地址 |
+| `remark` | `string` | 备注 |
+| `deleted` | `number` | 逻辑删除:`0=未删除`,`1=已删除` |
+| `createTime` | `string` | 创建时间 |
+| `updateTime` | `string` | 更新时间 |
+
+#### 3.5.7 失败返回示例
+
+设备不存在:
+
+```json
+{
+  "code": 1300,
+  "message": "设备不存在",
+  "data": null
+}
+```
+
+当前账号无权限访问该设备:
+
+```json
+{
+  "code": 403,
+  "message": "无权访问该设备",
+  "data": null
+}
+```
+
+#### 3.5.8 备注
+
+- 本接口需要 APP 登录后的 Token,不接受后台管理员 Token。
+- 越权校验在 Service 层完成,家庭设备校验 `device_group_member.app_user_id`,公共设备校验 `user_device.user_id` 是否归属当前 APP 账号下的用户档案。
+- 后端不会返回 `boundUserCount`、`boundUserNames`、`userIds` 等聚合字段,避免暴露其他用户信息。
+- 路径参数 `id` 为 `device.id`(主键),不是设备编号 `deviceCode`,APP 调用时需先通过列表接口获取设备主键。

+ 8 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/AppDeviceController.java

@@ -6,9 +6,11 @@ import com.aijiuyi.admin.common.entity.Result;
 import com.aijiuyi.admin.common.enums.OperationType;
 import com.aijiuyi.admin.controller.dto.AppDeviceBindDTO;
 import com.aijiuyi.admin.controller.dto.AppDeviceSelectDTO;
+import com.aijiuyi.admin.entity.Device;
 import com.aijiuyi.admin.service.AppDeviceGroupService;
 import org.springframework.beans.factory.annotation.Autowired;
 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.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
@@ -45,4 +47,10 @@ public class AppDeviceController {
     public Result<Map<String, Object>> select(@RequestBody @Valid AppDeviceSelectDTO dto) {
         return Result.success(appDeviceGroupService.selectDevice(RequestContext.getUserId(), dto));
     }
+
+    @GetMapping("/{id}")
+    @Log(value = "App查询设备详情", module = "App设备", operationType = OperationType.QUERY)
+    public Result<Device> getDetail(@PathVariable Long id) {
+        return Result.success(appDeviceGroupService.getDeviceDetail(RequestContext.getUserId(), id));
+    }
 }

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

@@ -4,6 +4,7 @@ import com.aijiuyi.admin.controller.dto.AppDeviceBindDTO;
 import com.aijiuyi.admin.controller.dto.AppDeviceSelectDTO;
 import com.aijiuyi.admin.controller.dto.AppGroupMemberDTO;
 import com.aijiuyi.admin.controller.dto.AppProfileDTO;
+import com.aijiuyi.admin.entity.Device;
 
 import java.util.List;
 import java.util.Map;
@@ -28,4 +29,15 @@ public interface AppDeviceGroupService {
     Map<String, Object> getCurrentProfile(Long appUserId);
 
     Map<String, Object> updateCurrentProfile(Long appUserId, AppProfileDTO dto);
+
+    /**
+     * App端查询设备详情。
+     * 校验设备属于当前 APP 用户(家庭设备走 device_group_member,公共设备走 user_device),
+     * 越权时直接抛 FORBIDDEN。返回仅供 App 展示的设备基础信息。
+     *
+     * @param appUserId 当前登录的 APP 账号 ID
+     * @param deviceId  设备主键 ID(device.id)
+     * @return 设备详情
+     */
+    Device getDeviceDetail(Long appUserId, Long deviceId);
 }

+ 46 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/impl/AppDeviceGroupServiceImpl.java

@@ -300,6 +300,52 @@ public class AppDeviceGroupServiceImpl implements AppDeviceGroupService {
         return buildCurrentProfileMap(appUser, profile);
     }
 
+    @Override
+    public Device getDeviceDetail(Long appUserId, Long deviceId) {
+        requireAppUser(appUserId);
+        if (deviceId == null) {
+            throw new BusinessException(ResultCode.PARAM_ERROR.getCode(), "设备ID不能为空");
+        }
+        Device device = deviceMapper.selectById(deviceId);
+        if (device == null) {
+            throw new BusinessException(ResultCode.DEVICE_NOT_FOUND);
+        }
+        if (isFamilyDevice(device)) {
+            DeviceGroup group = findDeviceGroupByCode(device.getDeviceCode());
+            if (group == null) {
+                throw new BusinessException(ResultCode.FORBIDDEN.getCode(), "无权访问该设备");
+            }
+            DeviceGroupMember member = findActiveMemberByAppUserId(group.getId(), appUserId);
+            if (member == null) {
+                throw new BusinessException(ResultCode.FORBIDDEN.getCode(), "无权访问该设备");
+            }
+        } else if (isPublicDevice(device)) {
+            List<UserProfile> profiles = userProfileMapper.selectList(
+                    new LambdaQueryWrapper<UserProfile>().eq(UserProfile::getUserId, appUserId)
+            );
+            if (CollectionUtils.isEmpty(profiles)) {
+                throw new BusinessException(ResultCode.FORBIDDEN.getCode(), "无权访问该设备");
+            }
+            List<Long> profileIds = profiles.stream().map(UserProfile::getId).collect(Collectors.toList());
+            long count = userDeviceMapper.selectCount(
+                    new LambdaQueryWrapper<UserDevice>()
+                            .in(UserDevice::getUserId, profileIds)
+                            .eq(UserDevice::getDeviceCode, device.getDeviceCode())
+                            .eq(UserDevice::getStatus, STATUS_ENABLED)
+            );
+            if (count <= 0) {
+                throw new BusinessException(ResultCode.FORBIDDEN.getCode(), "无权访问该设备");
+            }
+        } else {
+            throw new BusinessException(ResultCode.FORBIDDEN.getCode(), "设备类型不支持");
+        }
+        // 非数据库字段(boundUserCount/boundUserNames/userIds)不需要返回给 APP,清空避免暴露
+        device.setBoundUserCount(null);
+        device.setBoundUserNames(null);
+        device.setUserIds(null);
+        return device;
+    }
+
     private AppUser requireAppUser(Long appUserId) {
         AppUser appUser = appUserMapper.selectById(appUserId);
         if (appUser == null) {