feat: 文件存储安全加固 + 认证增强 + 媒体URL保护 + provider 重构

## 后端安全加固
- 新增 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: 更新
This commit is contained in:
MingNian
2026-07-20 10:19:01 +08:00
parent 0d4fd88ce7
commit 9cea41705e
48 changed files with 1181 additions and 212 deletions

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../providers/auth_provider.dart';
import '../../widgets/admin_drawer.dart';
import '../../widgets/backoffice_ui.dart';
import 'admin_doctors_page.dart';
@@ -12,7 +13,11 @@ final adminPageProvider = NotifierProvider<AdminPageNotifier, String>(
class AdminPageNotifier extends Notifier<String> {
@override
String build() => 'doctors';
String build() {
ref.watch(userSessionIdentityProvider);
return 'doctors';
}
void set(String page) => state = page;
}

View File

@@ -274,11 +274,12 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
if (await notifier.isDuplicateReading(device, reading)) return false;
final api = ref.read(apiClientProvider);
await api.post('/api/health-records', data: reading.toHealthRecord());
final records = <Map<String, dynamic>>[reading.toHealthRecord()];
final heartRateRecord = reading.toHeartRateRecord();
if (heartRateRecord != null) {
await api.post('/api/health-records', data: heartRateRecord);
records.add(heartRateRecord);
}
await api.post('/api/health-records/batch', data: records);
await notifier.recordSuccessfulSync(device: device, reading: reading);
return true;
}

View File

@@ -81,7 +81,10 @@ class DietFoodValidationException implements Exception {
class DietNotifier extends Notifier<DietState> {
@override
DietState build() => DietState();
DietState build() {
ref.watch(userSessionIdentityProvider);
return DietState();
}
void setImage(String path) {
state = state.copyWith(imagePath: path);

View File

@@ -9,6 +9,7 @@ import '../../widgets/backoffice_ui.dart';
final _consListProvider = FutureProvider<List<Map<String, dynamic>>>((
ref,
) async {
ref.watch(userSessionIdentityProvider);
final api = ref.read(apiClientProvider);
final res = await api.get('/api/doctor/consultations');
return (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];

View File

@@ -7,6 +7,7 @@ import '../../widgets/backoffice_ui.dart';
import '../doctor/doctor_home_page.dart' show doctorPageProvider;
final _dashboardProvider = FutureProvider<Map<String, dynamic>?>((ref) async {
ref.watch(userSessionIdentityProvider);
final api = ref.read(apiClientProvider);
final res = await api.get('/api/doctor/dashboard');
return res.data['data'] as Map<String, dynamic>?;

View File

@@ -9,6 +9,7 @@ import '../../widgets/app_toast.dart';
import '../../widgets/backoffice_ui.dart';
final _ptsSimple = FutureProvider<List<Map<String, dynamic>>>((ref) async {
ref.watch(userSessionIdentityProvider);
final api = ref.read(apiClientProvider);
final res = await api.get('/api/doctor/patients-simple');
return (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
@@ -50,7 +51,9 @@ class _DoctorFollowUpEditPageState
_titleCtrl.text = d['title'] ?? '';
_notesCtrl.text = d['notes'] ?? '';
_pid = d['userId']?.toString();
final at = DateTime.tryParse(d['scheduledAt']?.toString() ?? '');
final at = DateTime.tryParse(
d['scheduledAt']?.toString() ?? '',
)?.toLocal();
if (at != null) {
_date = at;
_time = TimeOfDay.fromDateTime(at);
@@ -261,7 +264,7 @@ class _DoctorFollowUpEditPageState
final data = {
'userId': _pid,
'title': _titleCtrl.text.trim(),
'scheduledAt': at.toIso8601String(),
'scheduledAt': at.toUtc().toIso8601String(),
'notes': _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim(),
};
if (isEdit) {
@@ -294,6 +297,7 @@ final _fupDetailForEdit = FutureProvider.family<Map<String, dynamic>?, String>((
ref,
id,
) async {
ref.watch(userSessionIdentityProvider);
final api = ref.read(apiClientProvider);
final res = await api.get('/api/doctor/follow-ups');
final items = (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];

View File

@@ -8,6 +8,7 @@ import '../../utils/backoffice_formatters.dart';
import '../../widgets/backoffice_ui.dart';
final _fupRefresh = FutureProvider<String?>((ref) async {
ref.watch(userSessionIdentityProvider);
final api = ref.read(apiClientProvider);
final res = await api.get('/api/doctor/follow-ups');
final items = (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
@@ -21,7 +22,11 @@ final _fupList = NotifierProvider<FupListN, List<Map<String, dynamic>>>(
class FupListN extends Notifier<List<Map<String, dynamic>>> {
@override
List<Map<String, dynamic>> build() => [];
List<Map<String, dynamic>> build() {
ref.watch(userSessionIdentityProvider);
return [];
}
void replace(List<Map<String, dynamic>> v) => state = v;
void markDone(String id) => state = state
.map((f) => f['id'] == id ? {...f, 'status': 'Completed'} : f)

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../providers/auth_provider.dart';
import '../../widgets/doctor_drawer.dart';
import '../../widgets/backoffice_ui.dart';
import 'doctor_dashboard_page.dart';
@@ -97,6 +98,10 @@ final doctorPageProvider = NotifierProvider<DoctorPageNotifier, String>(
class DoctorPageNotifier extends Notifier<String> {
@override
String build() => 'dashboard';
String build() {
ref.watch(userSessionIdentityProvider);
return 'dashboard';
}
void set(String page) => state = page;
}

View File

@@ -8,6 +8,7 @@ import '../../widgets/backoffice_ui.dart';
final _patientDetailProvider =
FutureProvider.family<Map<String, dynamic>?, String>((ref, id) async {
ref.watch(userSessionIdentityProvider);
final api = ref.read(apiClientProvider);
final res = await api.get('/api/doctor/patients/$id');
return res.data['data'] as Map<String, dynamic>?;

View File

@@ -7,6 +7,7 @@ import '../../widgets/app_toast.dart';
import '../../widgets/backoffice_ui.dart';
final _docProfileProvider = FutureProvider<Map<String, dynamic>?>((ref) async {
ref.watch(userSessionIdentityProvider);
final api = ref.read(apiClientProvider);
final res = await api.get('/api/doctor/profile');
return res.data['data'] as Map<String, dynamic>?;

View File

@@ -10,6 +10,7 @@ import '../../widgets/backoffice_ui.dart';
final _reportDetailProvider =
FutureProvider.family<Map<String, dynamic>?, String>((ref, id) async {
ref.watch(userSessionIdentityProvider);
final api = ref.read(apiClientProvider);
final res = await api.get('/api/doctor/reports/$id');
return res.data['data'] as Map<String, dynamic>?;

View File

@@ -9,6 +9,7 @@ import '../../widgets/backoffice_ui.dart';
final _reportsProvider = FutureProvider<List<Map<String, dynamic>>>((
ref,
) async {
ref.watch(userSessionIdentityProvider);
final api = ref.read(apiClientProvider);
final res = await api.get('/api/doctor/reports');
return (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];

View File

@@ -85,6 +85,7 @@ class _HomePageState extends ConsumerState<HomePage>
}
void _sendMessage() {
if (ref.read(chatProvider).isStreaming) return;
final text = _textCtrl.text.trim();
final imagePath = _pickedImagePath;
if (text.isEmpty && imagePath == null) return;
@@ -425,6 +426,9 @@ class _HomePageState extends ConsumerState<HomePage>
}
Widget _buildInputBar() {
final isStreaming = ref.watch(
chatProvider.select((state) => state.isStreaming),
);
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 10),
child: Container(
@@ -490,7 +494,9 @@ class _HomePageState extends ConsumerState<HomePage>
color: Colors.transparent,
child: InkWell(
customBorder: const CircleBorder(),
onTap: _sendMessage,
onTap: isStreaming
? () => ref.read(chatProvider.notifier).stopGenerating()
: _sendMessage,
child: Ink(
width: 38,
height: 38,
@@ -498,8 +504,8 @@ class _HomePageState extends ConsumerState<HomePage>
gradient: AppColors.primaryGradient,
shape: BoxShape.circle,
),
child: const Icon(
LucideIcons.send,
child: Icon(
isStreaming ? Icons.stop_rounded : LucideIcons.send,
size: 18,
color: Colors.white,
),

View File

@@ -6,12 +6,12 @@ import '../../../core/app_colors.dart';
import '../../../core/app_design_tokens.dart';
import '../../../core/app_module_visuals.dart';
import '../../../core/app_theme.dart';
import '../../../core/api_client.dart' show baseUrl;
import '../../../core/navigation_provider.dart';
import '../../../providers/chat_provider.dart';
import '../../../providers/data_providers.dart';
import '../../../widgets/ai_content.dart';
import '../../../widgets/app_toast.dart';
import '../../../widgets/authenticated_network_image.dart';
ChatMessage messageAtDisplayIndex(List<ChatMessage> messages, int index) =>
messages[index];
@@ -1045,8 +1045,8 @@ class ChatMessagesView extends ConsumerWidget {
child: localPath != null
? Image.file(File(localPath), fit: BoxFit.cover)
: imageUrl != null
? Image.network(
_mediaUrl(imageUrl),
? AuthenticatedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
errorBuilder: (_, e, s) => Container(
width: 80,
@@ -1122,7 +1122,11 @@ class ChatMessagesView extends ConsumerWidget {
static void _showFullImage(BuildContext context, String? path) {
if (path == null) return;
final resolvedPath = _mediaUrl(path);
final isNetwork =
path.startsWith('http://') ||
path.startsWith('https://') ||
path.startsWith('/uploads/') ||
path.startsWith('/api/');
showDialog(
context: context,
builder: (ctx) => Dialog(
@@ -1134,9 +1138,12 @@ class ChatMessagesView extends ConsumerWidget {
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: InteractiveViewer(
child: resolvedPath.startsWith('http')
? Image.network(resolvedPath, fit: BoxFit.contain)
: Image.file(File(resolvedPath), fit: BoxFit.contain),
child: isNetwork
? AuthenticatedNetworkImage(
imageUrl: path,
fit: BoxFit.contain,
)
: Image.file(File(path), fit: BoxFit.contain),
),
),
Positioned(
@@ -1160,12 +1167,6 @@ class ChatMessagesView extends ConsumerWidget {
);
}
static String _mediaUrl(String path) {
if (path.startsWith('http://') || path.startsWith('https://')) return path;
if (path.startsWith('/uploads/')) return '$baseUrl$path';
return path;
}
/// 处理 AI 回复里的 markdown 链接点击:
/// - app://diet → 触发拍照/相册选择,跳到饮食拍照流程
/// - app://report → 跳到报告列表(用户可在那里上传新报告)

View File

@@ -27,6 +27,7 @@ class DietRecordListPage extends ConsumerStatefulWidget {
class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
List<Map<String, dynamic>> _data = [];
bool _loading = true;
String? _loadError;
int _trendDays = 7;
DateTime _selectedDate = DateTime.now();
@@ -37,12 +38,22 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
}
Future<void> _refresh() async {
final records = await ref.read(dietServiceProvider).getRecords();
if (mounted) {
try {
final records = await ref.read(dietServiceProvider).getRecords();
if (!mounted) return;
setState(() {
_data = records;
_loading = false;
_loadError = null;
});
} catch (_) {
if (!mounted) return;
if (_data.isEmpty) {
setState(() => _loadError = '网络异常或服务暂时不可用,请稍后重试');
} else {
AppToast.show(context, '刷新失败,已保留当前记录', type: AppToastType.error);
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
@@ -104,7 +115,19 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
onPressed: () => popRoute(ref),
),
),
body: const Center(child: CircularProgressIndicator()),
body: _loadError == null
? const Center(child: CircularProgressIndicator())
: AppErrorState(
title: '饮食记录加载失败',
subtitle: _loadError,
onRetry: () {
setState(() {
_loading = true;
_loadError = null;
});
_refresh();
},
),
);
}
@@ -390,33 +413,67 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
void _showEditDialog(String id, num cal) {
final ctrl = TextEditingController(text: '$cal');
showDialog(
var saving = false;
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('修改热量'),
content: TextField(
controller: ctrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: '热量(千卡)'),
barrierDismissible: !saving,
builder: (ctx) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: const Text('修改热量'),
content: TextField(
controller: ctrl,
enabled: !saving,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: '热量(千卡)'),
),
actions: [
TextButton(
onPressed: saving ? null : () => Navigator.pop(ctx),
child: const Text('取消'),
),
TextButton(
onPressed: saving
? null
: () async {
final calories = int.tryParse(ctrl.text.trim());
if (calories == null || calories < 0) {
AppToast.show(
context,
'请输入正确的热量',
type: AppToastType.warning,
);
return;
}
setDialogState(() => saving = true);
try {
await ref.read(dietServiceProvider).updateRecord(id, {
'totalCalories': calories,
});
if (!ctx.mounted) return;
Navigator.pop(ctx);
await _refresh();
} catch (_) {
if (!ctx.mounted) return;
setDialogState(() => saving = false);
AppToast.show(
context,
'保存失败,请检查网络后重试',
type: AppToastType.error,
);
}
},
child: saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('保存'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('取消'),
),
TextButton(
onPressed: () async {
Navigator.pop(ctx);
await ref.read(dietServiceProvider).updateRecord(id, {
'totalCalories': int.tryParse(ctrl.text) ?? 0,
});
_refresh();
},
child: const Text('保存'),
),
],
),
);
).whenComplete(ctrl.dispose);
}
}
@@ -2546,7 +2603,7 @@ class _FollowUpItem extends StatelessWidget {
String _formatDateTime(String? iso) {
if (iso == null) return '';
final dt = DateTime.tryParse(iso);
final dt = DateTime.tryParse(iso)?.toLocal();
if (dt == null) return iso;
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
}

View File

@@ -19,6 +19,7 @@ import '../../widgets/enterprise_widgets.dart';
import '../../widgets/app_error_state.dart';
import '../../widgets/app_empty_state.dart';
import '../../widgets/app_toast.dart';
import '../../widgets/authenticated_network_image.dart';
const _reportPageColor = AppColors.report;
const _reportPageSoft = Color(0xFFF0F0FF);
@@ -202,6 +203,7 @@ class ReportNotifier extends Notifier<ReportState> {
@override
ReportState build() {
ref.watch(userSessionIdentityProvider);
ref.onDispose(() => _pollTimer?.cancel());
Future.microtask(() => loadReports());
return ReportState();
@@ -237,7 +239,7 @@ class ReportNotifier extends Notifier<ReportState> {
title: title,
type: m['fileType']?.toString() ?? 'Image',
uploadedAt:
DateTime.tryParse(m['createdAt']?.toString() ?? '') ??
DateTime.tryParse(m['createdAt']?.toString() ?? '')?.toLocal() ??
DateTime.now(),
fileUrl: m['fileUrl']?.toString(),
hasAnalysis: m['aiSummary'] != null,
@@ -249,7 +251,9 @@ class ReportNotifier extends Notifier<ReportState> {
doctorComment: m['doctorComment']?.toString(),
doctorRecommendation: m['doctorRecommendation']?.toString(),
doctorName: m['doctorName']?.toString(),
reviewedAt: DateTime.tryParse(m['reviewedAt']?.toString() ?? ''),
reviewedAt: DateTime.tryParse(
m['reviewedAt']?.toString() ?? '',
)?.toLocal(),
);
}).toList();
state = state.copyWith(
@@ -1053,8 +1057,8 @@ class _ReportOriginalPageState extends ConsumerState<ReportOriginalPage> {
child: InteractiveViewer(
minScale: 0.7,
maxScale: 4,
child: Image.network(
imageUrl,
child: AuthenticatedNetworkImage(
imageUrl: imageUrl,
key: ValueKey('$imageUrl-$_reloadToken'),
fit: BoxFit.contain,
loadingBuilder: (context, child, progress) {

View File

@@ -21,7 +21,8 @@ final notificationPrefsProvider =
class NotificationPrefsNotifier extends Notifier<NotificationPrefsViewState> {
@override
NotificationPrefsViewState build() {
Future.microtask(load);
final session = ref.watch(userSessionIdentityProvider);
if (session != null) Future.microtask(load);
return const NotificationPrefsViewState(loading: true);
}

View File

@@ -6,6 +6,7 @@ import '../../core/app_design_tokens.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
import '../../providers/omron_device_provider.dart';
class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key});
@@ -172,6 +173,7 @@ class SettingsPage extends ConsumerWidget {
);
if (ok == true) {
await ref.read(apiClientProvider).delete('/api/user/account');
await ref.read(omronDeviceProvider.notifier).clearCurrentAccountData();
await ref.read(authProvider.notifier).logout();
goRoute(ref, 'login');
}