## 后端安全加固 - 新增 UserUploadPathResolver: 用户上传文件路径安全解析, 防目录穿越 - LocalReportFileStorage: 文件存储路径安全加固 - local_account_file_cleanup: 账号删除时文件清理逻辑增强 - AuthService: 认证逻辑增强 - file_endpoints / report_endpoints: 文件访问接口安全加固 - ai_chat_endpoints / doctor_endpoints: 接口安全调整 - Program.cs: 服务注册调整 ## 前端认证与媒体 - 新增 authenticated_network_image.dart: 带认证的图片加载组件 - auth_provider: 认证状态管理大幅增强(+173) - api_client: 网络客户端增强(+124) - chat_provider: 聊天 provider 重构(+76) - omron_device_provider: 蓝牙设备 provider 增强(+53) - sse_handler: SSE 处理增强(+35) - consultation_provider / data_providers / conversation_history_provider: 调整 ## 页面调整 - remaining_pages: 健康档案/饮食记录等页面增强(+115) - home_page / chat_messages_view: 主页微调 - doctor 端多页微调(consultations/dashboard/followups/patient_detail/profile/report_detail/reports) - report_pages / settings_pages / notification_prefs_page: 微调 - device_scan_page / diet_capture_page / admin_home_page: 微调 ## 测试 - 新增 file_path_security_tests: 文件路径安全测试 - 新增 protected_media_url_test: 媒体URL保护测试 - 新增 user_session_identity_test: 用户会话身份测试 - account_deletion_tests / application_service_tests / auth_tests: 更新
276 lines
8.3 KiB
Dart
276 lines
8.3 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../models/ble_device.dart';
|
|
import '../models/bp_reading.dart';
|
|
import '../services/health_ble_service.dart';
|
|
import 'auth_provider.dart';
|
|
import 'data_providers.dart';
|
|
|
|
const _boundDevicesKey = 'bound_ble_devices';
|
|
const _readingFingerprintsKey = 'ble_reading_fingerprints';
|
|
const _legacyBpMacKey = 'bp_device_mac';
|
|
const _legacyBpNameKey = 'bp_device_name';
|
|
const _legacyBpLastSyncKey = 'bp_last_sync';
|
|
|
|
final healthBleServiceProvider = Provider<HealthBleService>((ref) {
|
|
final service = HealthBleService();
|
|
ref.onDispose(service.dispose);
|
|
return service;
|
|
});
|
|
|
|
class DeviceBindState {
|
|
final List<BoundBleDevice> devices;
|
|
final bool isConnected;
|
|
|
|
const DeviceBindState({this.devices = const [], this.isConnected = false});
|
|
|
|
bool get isBound => devices.isNotEmpty;
|
|
|
|
DeviceBindState copyWith({List<BoundBleDevice>? devices, bool? isConnected}) {
|
|
return DeviceBindState(
|
|
devices: devices ?? this.devices,
|
|
isConnected: isConnected ?? this.isConnected,
|
|
);
|
|
}
|
|
|
|
List<BoundBleDevice> devicesOfType(BleDeviceType type) {
|
|
return devices.where((device) => device.type == type).toList();
|
|
}
|
|
|
|
BoundBleDevice? findById(String id) {
|
|
for (final device in devices) {
|
|
if (device.id == id) return device;
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
class DeviceBindNotifier extends Notifier<DeviceBindState> {
|
|
StreamSubscription<bool>? _connSub;
|
|
late String? _sessionIdentity;
|
|
|
|
@override
|
|
DeviceBindState build() {
|
|
_sessionIdentity = ref.watch(userSessionIdentityProvider);
|
|
ref.onDispose(() => _connSub?.cancel());
|
|
if (_sessionIdentity != null) _loadBinding(_sessionIdentity!);
|
|
_listenConnection();
|
|
return const DeviceBindState();
|
|
}
|
|
|
|
void _listenConnection() {
|
|
_connSub?.cancel();
|
|
_connSub = ref.read(healthBleServiceProvider).connectionState.listen((
|
|
connected,
|
|
) {
|
|
if (state.isConnected != connected) {
|
|
state = state.copyWith(isConnected: connected);
|
|
}
|
|
});
|
|
}
|
|
|
|
String _accountKey(String baseKey, [String? session]) =>
|
|
'$baseKey:${session ?? _sessionIdentity}';
|
|
|
|
Future<void> _loadBinding(String session) async {
|
|
final db = ref.read(localDbProvider);
|
|
await _migrateLegacyBloodPressureDevice(session);
|
|
final raw = await db.read(_accountKey(_boundDevicesKey, session));
|
|
final devices = _decodeDevices(raw);
|
|
if (_sessionIdentity != session) return;
|
|
state = state.copyWith(devices: devices);
|
|
}
|
|
|
|
Future<void> _migrateLegacyBloodPressureDevice(String session) async {
|
|
final db = ref.read(localDbProvider);
|
|
final accountKey = _accountKey(_boundDevicesKey, session);
|
|
final existing = await db.read(accountKey);
|
|
final oldSharedDevices = await db.read(_boundDevicesKey);
|
|
if (existing == null && oldSharedDevices != null) {
|
|
await db.write(accountKey, oldSharedDevices);
|
|
await db.delete(_boundDevicesKey);
|
|
final oldFingerprints = await db.read(_readingFingerprintsKey);
|
|
if (oldFingerprints != null) {
|
|
await db.write(
|
|
_accountKey(_readingFingerprintsKey, session),
|
|
oldFingerprints,
|
|
);
|
|
await db.delete(_readingFingerprintsKey);
|
|
}
|
|
return;
|
|
}
|
|
final legacyMac = await db.read(_legacyBpMacKey);
|
|
if (existing != null || legacyMac == null || legacyMac.isEmpty) return;
|
|
|
|
final legacyName = await db.read(_legacyBpNameKey);
|
|
final migrated = BoundBleDevice(
|
|
id: legacyMac,
|
|
name: legacyName?.isNotEmpty == true ? legacyName! : '血压计',
|
|
type: BleDeviceType.bloodPressure,
|
|
serviceUuid: BleDeviceType.bloodPressure.serviceUuid,
|
|
);
|
|
await db.write(accountKey, jsonEncode([migrated.toJson()]));
|
|
await db.delete(_legacyBpMacKey);
|
|
await db.delete(_legacyBpNameKey);
|
|
await db.delete(_legacyBpLastSyncKey);
|
|
}
|
|
|
|
List<BoundBleDevice> _decodeDevices(String? raw) {
|
|
if (raw == null || raw.isEmpty) return [];
|
|
try {
|
|
final decoded = jsonDecode(raw);
|
|
if (decoded is! List) return [];
|
|
return decoded
|
|
.whereType<Map>()
|
|
.map(
|
|
(item) => BoundBleDevice.fromJson(Map<String, dynamic>.from(item)),
|
|
)
|
|
.whereType<BoundBleDevice>()
|
|
.toList();
|
|
} catch (_) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
Future<void> _saveDevices(List<BoundBleDevice> devices) async {
|
|
final db = ref.read(localDbProvider);
|
|
await db.write(
|
|
_accountKey(_boundDevicesKey),
|
|
jsonEncode(devices.map((device) => device.toJson()).toList()),
|
|
);
|
|
state = state.copyWith(devices: devices);
|
|
}
|
|
|
|
Future<void> bind(BoundBleDevice device) async {
|
|
final devices = [...state.devices];
|
|
final index = devices.indexWhere((item) => item.id == device.id);
|
|
if (index >= 0) {
|
|
devices[index] = device.copyWith(lastSyncAt: devices[index].lastSyncAt);
|
|
} else {
|
|
devices.add(device);
|
|
}
|
|
await _saveDevices(devices);
|
|
}
|
|
|
|
Future<void> unbind(String id) async {
|
|
await _saveDevices(
|
|
state.devices.where((device) => device.id != id).toList(),
|
|
);
|
|
}
|
|
|
|
Future<void> recordSuccessfulSync({
|
|
required BoundBleDevice device,
|
|
required BpReading reading,
|
|
}) async {
|
|
await _rememberReadingFingerprint(device, reading);
|
|
final syncedAt = DateTime.now();
|
|
final devices = state.devices.map((item) {
|
|
if (item.id != device.id) return item;
|
|
return item.copyWith(lastSyncAt: syncedAt);
|
|
}).toList();
|
|
await _saveDevices(devices);
|
|
ref.invalidate(latestHealthProvider);
|
|
}
|
|
|
|
Future<bool> isDuplicateReading(
|
|
BoundBleDevice device,
|
|
BpReading reading,
|
|
) async {
|
|
final fingerprints = await _loadFingerprints();
|
|
final now = DateTime.now();
|
|
final pruned = _pruneFingerprints(fingerprints, now);
|
|
if (pruned.length != fingerprints.length) {
|
|
await _saveFingerprints(pruned);
|
|
}
|
|
|
|
final key = _readingFingerprint(device, reading);
|
|
final lastSeenRaw = pruned[key];
|
|
if (lastSeenRaw == null) return false;
|
|
final lastSeen = DateTime.tryParse(lastSeenRaw)?.toLocal();
|
|
if (lastSeen == null) return false;
|
|
if (reading.hasDeviceTimestamp) return true;
|
|
return now.difference(lastSeen).inSeconds < 10;
|
|
}
|
|
|
|
Future<void> _rememberReadingFingerprint(
|
|
BoundBleDevice device,
|
|
BpReading reading,
|
|
) async {
|
|
final now = DateTime.now();
|
|
final fingerprints = _pruneFingerprints(await _loadFingerprints(), now);
|
|
fingerprints[_readingFingerprint(device, reading)] = now
|
|
.toUtc()
|
|
.toIso8601String();
|
|
await _saveFingerprints(fingerprints);
|
|
}
|
|
|
|
Future<Map<String, String>> _loadFingerprints() async {
|
|
final db = ref.read(localDbProvider);
|
|
final raw = await db.read(_accountKey(_readingFingerprintsKey));
|
|
if (raw == null || raw.isEmpty) return {};
|
|
try {
|
|
final decoded = jsonDecode(raw);
|
|
if (decoded is! Map) return {};
|
|
return decoded.map(
|
|
(key, value) => MapEntry(key.toString(), value.toString()),
|
|
);
|
|
} catch (_) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
Future<void> _saveFingerprints(Map<String, String> fingerprints) async {
|
|
final db = ref.read(localDbProvider);
|
|
await db.write(
|
|
_accountKey(_readingFingerprintsKey),
|
|
jsonEncode(fingerprints),
|
|
);
|
|
}
|
|
|
|
Future<void> clearCurrentAccountData() async {
|
|
final session = _sessionIdentity;
|
|
if (session == null) return;
|
|
final db = ref.read(localDbProvider);
|
|
await db.delete(_accountKey(_boundDevicesKey, session));
|
|
await db.delete(_accountKey(_readingFingerprintsKey, session));
|
|
state = const DeviceBindState();
|
|
}
|
|
|
|
Map<String, String> _pruneFingerprints(
|
|
Map<String, String> fingerprints,
|
|
DateTime now,
|
|
) {
|
|
final pruned = <String, String>{};
|
|
for (final entry in fingerprints.entries) {
|
|
final seenAt = DateTime.tryParse(entry.value)?.toLocal();
|
|
if (seenAt == null) continue;
|
|
if (now.difference(seenAt).inHours <= 24) {
|
|
pruned[entry.key] = entry.value;
|
|
}
|
|
}
|
|
return pruned;
|
|
}
|
|
|
|
String _readingFingerprint(BoundBleDevice device, BpReading reading) {
|
|
final timePart = reading.hasDeviceTimestamp
|
|
? reading.timestamp.toUtc().toIso8601String()
|
|
: 'no-device-time';
|
|
return [
|
|
device.id,
|
|
device.type.code,
|
|
timePart,
|
|
reading.systolic,
|
|
reading.diastolic,
|
|
reading.pulse ?? 0,
|
|
].join('|');
|
|
}
|
|
}
|
|
|
|
final omronDeviceProvider =
|
|
NotifierProvider<DeviceBindNotifier, DeviceBindState>(
|
|
DeviceBindNotifier.new,
|
|
);
|