Files
CloudHome-Ops/lib/services/device_service.dart

613 lines
20 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../models/alert.dart';
import '../models/device.dart';
import '../models/org_node.dart';
import '../utils/constants.dart';
import '../utils/session_org_scope.dart';
import '../utils/store_local_cache.dart';
import 'org_service.dart';
import 'api_client.dart';
/// 设备与告警数据服务:对接真实「爱视后台」/api/device/* 接口。
///
/// - 设备列表:GET /device/info/list(响应三种形态均兼容,见 [_extractList]);
/// - 设备详情:GET /device/info/info/{id};
/// - 设备指令:POST /device/command/sendByMac | sendByOrg(service 已实现,
/// 下发 UI 属下一轮迭代);
/// - 告警:真实后端无 alerts 端点,real 模式下由设备列表推导
/// (offline→critical、warning→warning);mock 模式使用示例告警。
/// - 设备分组:GET/POST /device/group/*;
/// - 激活码:POST/GET /device/activationCode/*;
/// - 门店配置:GET/POST /device/storeConfig/*。
class DeviceService {
DeviceService({Dio? dio}) : _dio = dio ?? ApiClient.instance.dio;
final Dio _dio;
/// 获取设备总数量(后端分页响应中的 totalCount)。
///
/// GET /device/info/list?page=1&limit=1
Future<int> fetchDeviceTotalCount() async {
final response = await _dio.get<dynamic>(
'/device/info/list',
queryParameters: {'page': 1, 'limit': 1},
);
final data = response.data;
if (data is Map<String, dynamic>) {
final total = data['totalCount'];
if (total is int) return total;
if (total is String) return int.tryParse(total) ?? 0;
// 部分后端把 total 放到 page.totalCount。
final page = data['page'];
if (page is Map<String, dynamic>) {
final pageTotal = page['totalCount'];
if (pageTotal is int) return pageTotal;
if (pageTotal is String) return int.tryParse(pageTotal) ?? 0;
}
}
return 0;
}
/// 按状态统计设备数量(O(1):limit=1 仅取 totalCount,不下拉全量)。
///
/// [status] 取值:'online' / 'offline' / 'warning';传 null 表示全部。
/// GET /device/info/list?page=1&limit=1&status=xxx
Future<int> fetchDeviceCountByStatus({String? status}) async {
final query = <String, dynamic>{'page': 1, 'limit': 1};
if (status != null) query['status'] = status;
final response = await _dio.get<dynamic>(
'/device/info/list',
queryParameters: query,
);
final data = response.data;
if (data is Map<String, dynamic>) {
final total = data['totalCount'];
if (total is int) return total;
if (total is String) return int.tryParse(total) ?? 0;
final page = data['page'];
if (page is Map<String, dynamic>) {
final pageTotal = page['totalCount'];
if (pageTotal is int) return pageTotal;
if (pageTotal is String) return int.tryParse(pageTotal) ?? 0;
}
}
return 0;
}
/// 获取设备列表。
Future<List<Device>> fetchDevices({
int page = 1,
int limit = AppConstants.deviceListPageSize,
String? status,
String? mac,
String? deviceSn,
String? deviceUid,
String? groupId,
String? storeOrgId,
}) async {
final response = await _dio.get<dynamic>(
'/device/info/list',
queryParameters: {
'page': page,
'limit': limit,
if (status != null) 'status': status,
if (mac != null) 'mac': mac,
if (deviceSn != null) 'deviceSn': deviceSn,
if (deviceUid != null) 'deviceUid': deviceUid,
if (groupId != null) 'groupId': groupId,
if (storeOrgId != null) 'storeOrgId': storeOrgId,
},
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(Device.fromJson)
.toList();
}
/// 按多个门店(组织节点)批量拉取并合并设备列表。
///
/// 后端 `/device/info/list` 仅接受单个 `storeOrgId`,故对每个门店 orgId 并发
/// 请求 [fetchDevices],再把结果合并、按 [Device.id] 去重(同一设备不会因归属
/// 多个选中组织而重复出现)。
///
/// [storeOrgIds] 为空时直接走 [fetchDevices](等效于「全部门店」),保持原行为。
Future<List<Device>> fetchDevicesByOrgSet(
Set<String> storeOrgIds, {
String? status,
String? mac,
String? deviceSn,
String? deviceUid,
String? groupId,
}) async {
if (storeOrgIds.isEmpty) {
return fetchDevices(
status: status,
mac: mac,
deviceSn: deviceSn,
deviceUid: deviceUid,
groupId: groupId,
);
}
final lists = await Future.wait(
storeOrgIds.map(
(id) => _fetchDevicesForStore(
id,
status: status,
mac: mac,
deviceSn: deviceSn,
deviceUid: deviceUid,
groupId: groupId,
),
),
);
final map = <String, Device>{};
for (final d in lists.expand((x) => x)) {
map[d.id] = d;
}
return map.values.toList();
}
/// 按单个门店 orgId 拉取设备列表(构造查询参数 + 解析)。
///
/// 抽取自 [fetchDevices] 的单店请求逻辑,供 [fetchDevicesByOrgSet] 复用,
/// 避免多门店合并时重复拼装 queryParameters。
Future<List<Device>> _fetchDevicesForStore(
String storeOrgId, {
String? status,
String? mac,
String? deviceSn,
String? deviceUid,
String? groupId,
}) async {
final response = await _dio.get<dynamic>(
'/device/info/list',
queryParameters: {
'page': 1,
'limit': AppConstants.deviceListPageSize,
if (status != null) 'status': status,
if (mac != null) 'mac': mac,
if (deviceSn != null) 'deviceSn': deviceSn,
if (deviceUid != null) 'deviceUid': deviceUid,
if (groupId != null) 'groupId': groupId,
'storeOrgId': storeOrgId,
},
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(Device.fromJson)
.toList();
}
/// 按 ID 获取设备详情;不存在时抛出 [DeviceNotFoundException]。
///
/// 注意:详情接口 /device/info/info/{id} 返回的 `status` / `lastSyncTime`
/// 可能滞后于列表接口。Web 端设备列表走 /device/info/list 并显示最新在线状态,
/// 因此本方法在拿到详情后,再用 [Device.mac] 到列表接口取一次最新状态并合并,
/// 使手机端详情页与 Web 端列表/心跳保持一致。
Future<Device> fetchDeviceById(String id) async {
try {
final response =
await _dio.get<dynamic>('/device/info/info/$id');
final data = response.data;
if (data is! Map<String, dynamic>) {
throw DeviceNotFoundException('未找到设备:$id');
}
final detail = Device.fromJson(data);
return _mergeWithFreshListStatus(detail);
} on DioException catch (e) {
final error = e.error;
if (error is ApiException) {
throw DeviceNotFoundException('${error.message}($id)');
}
throw DeviceNotFoundException('设备详情加载失败:$id');
}
}
/// 用 [detail.mac] 从列表接口拉取最新状态并合并。
///
/// 列表接口 /device/info/list 与 Web 端同源,状态/心跳刷新更及时;
/// 合并时以列表的 [status] 与 [lastSyncTime] 覆盖详情,其余字段保留详情。
Future<Device> _mergeWithFreshListStatus(Device detail) async {
if (detail.mac.isEmpty) return detail;
try {
final freshList = await fetchDevices(mac: detail.mac, limit: 1);
if (freshList.isEmpty) return detail;
final fresh = freshList.first;
return detail.copyWith(
status: fresh.status,
lastSyncTime: fresh.lastSyncTime ?? detail.lastSyncTime,
);
} on Object {
// 列表接口失败时不阻断详情展示,保持原样。
return detail;
}
}
/// 更新设备信息(修改分组、房号等)。
///
/// POST /device/info/update,body 至少含 `id` 与需修改字段。
Future<void> updateDevice(Map<String, dynamic> data) async {
await _dio.post<dynamic>('/device/info/update', data: data);
}
/// 新增设备。
///
/// POST /device/info/save,必填字段参考 Web 端:groupId、mac。
Future<void> saveDeviceInfo(Map<String, dynamic> data) async {
await _dio.post<dynamic>('/device/info/save', data: data);
}
/// 删除单个设备。
///
/// 后端 `POST /device/info/delete` 接收的是**裸 JSON 数组** `[id]`,与 Web 端
/// 设备列表批量删除保持一致(Web 调用 `Ao(t)` → `$.adornData(t,!1)` →
/// `JSON.stringify([id,...])`)。
/// 旧实现误传 `{'ids': [id]}`(对象),后端无法解析请求体 → 返回「未知异常」。
/// id 在可转为数字时按数字发送(复刻 Web 端 `Yo()` 的 `Number(id)` 行为),
/// 否则保留原字符串,保证数字/字符串两种主键都能正确命中。
Future<void> deleteDevice(String id) async {
final idValue = int.tryParse(id) ?? id;
await _dio.post<dynamic>('/device/info/delete', data: [idValue]);
}
/// 按 MAC 查询设备(最多返回 1 条,用于判断设备是否已存在)。
Future<Device?> fetchDeviceByMac(String mac) async {
if (mac.isEmpty) return null;
final devices = await fetchDevices(mac: mac, limit: 1);
return devices.isNotEmpty ? devices.first : null;
}
/// 扫码激活/绑定设备:把设备真正写入后端。
///
/// 逻辑:
/// 1. 先按 MAC 查询设备是否已存在;
/// 2. 存在则走 `/device/info/update` 更新归属(门店/分组/房号);
/// 3. 不存在则走 `/device/info/save` 新增设备。
///
/// 这样手机端扫码激活后,Web 端设备列表能立即看到该设备。
Future<void> activateDeviceByScan({
required String mac,
required String storeId,
String? groupId,
String? roomNo,
}) async {
final existing = await fetchDeviceByMac(mac);
if (existing != null && existing.id.isNotEmpty) {
final data = <String, dynamic>{
'id': existing.id,
'mac': mac,
};
if (groupId != null && groupId.isNotEmpty) data['groupId'] = groupId;
if (roomNo != null && roomNo.isNotEmpty) data['roomId'] = roomNo;
await updateDevice(data);
return;
}
final data = <String, dynamic>{
'mac': mac,
// 门店在后端设备表中通常映射为 orgId / storeOrgId;同时携带两个别名,
// 兼容不同后端版本。
'orgId': storeId,
'storeOrgId': storeId,
};
if (groupId != null && groupId.isNotEmpty) data['groupId'] = groupId;
if (roomNo != null && roomNo.isNotEmpty) data['roomId'] = roomNo;
await saveDeviceInfo(data);
}
/// 按 MAC 下发设备指令。
///
/// [command] 取值见 [DeviceCommand];[data] 为指令参数(可为空)。
Future<void> sendCommandByMac({
required String mac,
required String command,
Map<String, dynamic>? data,
}) async {
await _dio.post<dynamic>(
'/device/command/sendByMac',
data: {'mac': mac, 'command': command, 'data': data ?? <String, dynamic>{}},
);
}
/// 按组织下发设备指令(批量)。
Future<void> sendCommandByOrg({
required String orgId,
required String command,
Map<String, dynamic>? data,
}) async {
await _dio.post<dynamic>(
'/device/command/sendByOrg',
data: {
'orgId': orgId,
'command': command,
'data': data ?? <String, dynamic>{},
},
);
}
/// 获取告警列表。
///
/// 真实后端无 alerts 端点:real 模式下由设备列表推导——
/// 仅为异常设备生成告警(offline→critical、warning→warning);
/// 在线设备的级别映射为 info(见 [alertLevelFor]),但不产生告警条目。
/// mock 模式下返回内置示例告警。
Future<List<Alert>> fetchAlerts() async {
if (AppConstants.useMock) {
final response = await _dio.get<dynamic>('/alerts');
final data = response.data;
if (data is List) {
return data
.whereType<Map<String, dynamic>>()
.map(Alert.fromJson)
.toList();
}
return <Alert>[];
}
final devices = await fetchDevices(limit: AppConstants.deviceListPageSize);
// 受限账号:仅保留其组织子树内门店所属设备的告警,避免泄露跨组织设备告警。
final scope = SessionOrgScope.instance;
List<Device> scopedDevices = devices;
if (scope.enabled) {
final ids = await OrgService().scopedOrgIdSet();
if (ids.isNotEmpty) {
scopedDevices = devices.where((d) => ids.contains(d.storeId)).toList();
}
}
final alerts = <Alert>[];
for (final device in scopedDevices) {
if (device.status == DeviceStatus.online) {
continue;
}
alerts.add(_deriveAlert(device));
}
// 按级别严重程度排序:critical 在前。
alerts.sort((a, b) => a.level.index.compareTo(b.level.index));
return alerts;
}
/// 设备状态 → 告警级别映射(offline→critical、warning→warning、online→info)。
static AlertLevel alertLevelFor(DeviceStatus status) {
switch (status) {
case DeviceStatus.offline:
return AlertLevel.critical;
case DeviceStatus.warning:
return AlertLevel.warning;
case DeviceStatus.online:
return AlertLevel.info;
}
}
/// 由异常设备推导告警条目。
static Alert _deriveAlert(Device device) {
final offline = device.status == DeviceStatus.offline;
return Alert(
id: 'derived-${device.id}',
deviceId: device.id,
deviceName: device.name,
title: offline ? '设备离线' : '设备指标异常',
message: offline
? '设备 ${device.mac} 最近无心跳上报,请检查电源与网络连接。'
: '设备 ${device.mac} 上报状态为「告警」,请登录后台查看详情。',
level: alertLevelFor(device.status),
occurredAt: device.lastSyncTime ?? DateTime.now(),
acknowledged: false,
);
}
// ================ 设备分组管理 ================
/// 获取设备分组列表(分页,按门店筛选)。
///
/// GET /device/group/list?page=&limit=&orgId=&storeId=
///
/// 注意:Web 端该接口使用 `orgId` 作为门店过滤参数,移动端同时携带
/// `orgId` 与 `storeId`,避免后端因参数名差异而忽略门店过滤,导致
/// 返回全部门店分组。
Future<List<OrgNode>> getDeviceGroupList({
int page = 1,
int limit = 20,
required String storeId,
}) async {
final response = await _dio.get<dynamic>(
'/device/group/list',
queryParameters: {
'page': page,
'limit': limit,
'orgId': storeId,
'storeId': storeId,
},
);
final list = _extractList(response.data);
return list
.whereType<Map<String, dynamic>>()
.map(OrgNode.fromJson)
.toList();
}
/// 获取设备分组详情。
///
/// GET /device/group/info/{id}
Future<OrgNode> getDeviceGroupInfo(String id) async {
final response = await _dio.get<dynamic>('/device/group/info/$id');
final data = response.data;
if (data is! Map<String, dynamic>) {
throw DeviceNotFoundException('设备分组信息加载失败:$id');
}
return OrgNode.fromJson(data);
}
/// 新增设备分组。
///
/// POST /device/group/save,body 为分组数据。
Future<void> saveDeviceGroup(Map<String, dynamic> data) async {
await _dio.post<dynamic>('/device/group/save', data: data);
}
/// 修改设备分组。
///
/// POST /device/group/update,body 为分组数据(含 id)。
Future<void> updateDeviceGroup(Map<String, dynamic> data) async {
await _dio.post<dynamic>('/device/group/update', data: data);
}
/// 批量删除设备分组。
///
/// POST /device/group/delete,body 为 `{ids: [...]}`。
Future<void> deleteDeviceGroups(List<String> ids) async {
await _dio.post<dynamic>(
'/device/group/delete',
data: {'ids': ids},
);
}
// ================ 激活码管理 ================
/// 生成门店激活码。
///
/// POST /device/activationCode/generate,body 为 `{orgId, maxActivateCount}`。
Future<void> generateActivationCode(
String orgId,
int maxActivateCount,
) async {
await _dio.post<dynamic>(
'/device/activationCode/generate',
data: {
'orgId': orgId,
'maxActivateCount': maxActivateCount,
},
);
}
/// 获取激活码信息。
///
/// GET /device/activationCode/info/{orgId}
Future<Map<String, dynamic>> getActivationCodeInfo(String orgId) async {
final response =
await _dio.get<dynamic>('/device/activationCode/info/$orgId');
final data = response.data;
if (data is! Map<String, dynamic>) {
throw DeviceNotFoundException('激活码信息加载失败:$orgId');
}
return data;
}
/// 重置激活码。
///
/// POST /device/activationCode/reset/{orgId}
Future<void> resetActivationCode(String orgId) async {
await _dio.post<dynamic>('/device/activationCode/reset/$orgId');
}
// ================ 门店配置 ================
/// 获取门店配置。
///
/// GET /device/storeConfig/info/{orgId}(与 Web 后台门店配置弹窗同源,
/// 见 screen-front src/api/device.ts `getStoreConfig`)。
Future<Map<String, dynamic>> getStoreConfig(String orgId) async {
final response =
await _dio.get<dynamic>('/device/storeConfig/info/$orgId');
final data = response.data;
if (data is! Map<String, dynamic>) {
throw DeviceNotFoundException('门店配置加载失败:$orgId');
}
return data;
}
/// 新增或修改门店配置。
///
/// POST /device/storeConfig/createOrUpdate(与 Web 后台 `createOrUpdateStoreConfig`
/// 严格一致;早期误写 /saveOrUpdate 会导致后端路由不匹配、保存失效)。
Future<void> createOrUpdateStoreConfig(Map<String, dynamic> data) async {
await _dio.post<dynamic>(
'/device/storeConfig/createOrUpdate',
data: data,
);
}
/// 重置门店配置密码。
///
/// POST /device/storeConfig/resetPassword/{orgId}
Future<void> resetStoreConfigPassword(String orgId) async {
await _dio.post<dynamic>(
'/device/storeConfig/resetPassword/$orgId',
);
}
// ================ 扫码绑定(前端占位) ================
/// 扫码绑定设备到门店/分组。
///
/// 当前后端无扫码绑定接口,前端占位:本地记录绑定关系并写回设备归属,
/// 不发起真实请求。
///
/// [scanPayload] 为扫码解析出的设备标识(mac/sn/二维码内容);
/// [storeId]/[storeName] 为目标门店;[groupId] 为分组(可空)。
///
/// TODO(后端): POST /device/bind {scanPayload, storeId, groupId},
/// 由后台按 scanPayload 定位设备并写归属。
Future<void> bindDeviceByScan(
String scanPayload,
String storeId,
String storeName,
String? groupId,
) async {
// 占位:把绑定关系写入本地缓存(key = deviceId,此处以 scanPayload 作为标识)。
await StoreLocalCache.instance.saveDeviceBinding(
scanPayload,
storeId,
storeName,
groupId,
);
// 占位:本地 updateDevice 写 groupId/storeName,便于详情页回显。
try {
await updateDevice({
'id': storeId,
'groupId': groupId ?? '',
'storeName': storeName,
});
} on Object {
// 真实后台可能没有该设备/接口,占位阶段忽略失败。
}
// TODO(后端): POST /device/bind {scanPayload, storeId, groupId}。
debugPrint('[DeviceService.bindDeviceByScan] 占位未上报: '
'payload=$scanPayload storeId=$storeId');
}
// ================ 工具方法 ================
/// 兼容设备列表响应的三种形态:
/// `{list: [...], totalCount}` / `{page: {list, totalCount}}` / 直接数组。
static List<dynamic> _extractList(dynamic data) {
if (data is List) {
return data;
}
if (data is Map<String, dynamic>) {
final list = data['list'];
if (list is List) {
return list;
}
final page = data['page'];
if (page is Map<String, dynamic> && page['list'] is List) {
return page['list'] as List;
}
}
return const <dynamic>[];
}
}
/// 设备不存在异常。
class DeviceNotFoundException implements Exception {
const DeviceNotFoundException(this.message);
final String message;
@override
String toString() => message;
}