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

613 lines
20 KiB
Dart
Raw Permalink Normal View History

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;
}