import 'dart:convert'; import 'dart:math'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; class ApprovalTaskItem { const ApprovalTaskItem({ required this.id, required this.name, required this.createdAt, required this.leaveRequest, }); final String id; final String name; final DateTime createdAt; final ApprovalLeaveRequest leaveRequest; factory ApprovalTaskItem.fromJson(Map json) => ApprovalTaskItem( id: json['id']! as String, name: json['name']! as String, createdAt: DateTime.parse(json['createdAt']! as String), leaveRequest: ApprovalLeaveRequest.fromJson( Map.from(json['leaveRequest']! as Map), ), ); } class ApprovalLeaveRequest { const ApprovalLeaveRequest({ required this.id, required this.type, required this.startsAt, required this.endsAt, required this.reason, required this.status, required this.version, }); final String id; final String type; final DateTime startsAt; final DateTime endsAt; final String reason; final String status; final int version; factory ApprovalLeaveRequest.fromJson(Map json) => ApprovalLeaveRequest( id: json['id']! as String, type: json['type']! as String, startsAt: DateTime.parse(json['startsAt']! as String), endsAt: DateTime.parse(json['endsAt']! as String), reason: json['reason']! as String, status: json['status']! as String, version: json['version']! as int, ); } class ApprovalTaskException implements Exception { const ApprovalTaskException(this.message); final String message; @override String toString() => message; } class ApprovalTaskRepository { ApprovalTaskRepository({ http.Client? client, this.baseUrl = const String.fromEnvironment( 'AIOA_API_BASE_URL', defaultValue: 'http://127.0.0.1:8080/api/v1', ), this.accessToken = const String.fromEnvironment('AIOA_ACCESS_TOKEN'), }) : _client = client ?? http.Client(); final http.Client _client; final String baseUrl; final String accessToken; Future> list() async { final response = await _client.get( Uri.parse('$baseUrl/approval-tasks'), headers: _headers, ); _requireSuccess(response); return (jsonDecode(response.body) as List) .map( (item) => ApprovalTaskItem.fromJson(Map.from(item as Map)), ) .toList(); } Future decide({ required ApprovalTaskItem task, required bool approved, String? comment, }) async { final action = approved ? 'approve' : 'reject'; final payload = jsonEncode({ 'version': task.leaveRequest.version, if (comment != null && comment.trim().isNotEmpty) 'comment': comment.trim(), }); final preferences = await SharedPreferences.getInstance(); final storageKey = 'approval.pending.${task.id}.$action'; final existing = preferences.getString(storageKey); final pending = existing == null ? null : _decodePending(existing); final idempotencyKey = pending?.payload == payload ? pending!.key : _newKey(action); await preferences.setString( storageKey, jsonEncode({'key': idempotencyKey, 'payload': payload}), ); late http.Response response; try { response = await _client.post( Uri.parse('$baseUrl/approval-tasks/${task.id}/$action'), headers: { ..._headers, 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey, }, body: payload, ); } catch (_) { throw const ApprovalTaskException('网络不可用,审批请求已保存,可安全重试'); } if (response.statusCode < 200 || response.statusCode >= 300) { if (response.statusCode < 500 && response.statusCode != 401) { await preferences.remove(storageKey); } _requireSuccess(response); } await preferences.remove(storageKey); } Map get _headers => { 'Accept': 'application/json', if (accessToken.isNotEmpty) 'Authorization': 'Bearer $accessToken', }; void _requireSuccess(http.Response response) { if (response.statusCode >= 200 && response.statusCode < 300) return; String? message; try { message = (jsonDecode(response.body) as Map)['detail'] as String?; } catch (_) {} throw ApprovalTaskException(message ?? '待办请求失败(${response.statusCode})'); } _PendingApproval? _decodePending(String encoded) { try { final json = jsonDecode(encoded) as Map; return _PendingApproval( json['key']! as String, json['payload']! as String, ); } catch (_) { return null; } } String _newKey(String action) { final random = Random.secure(); final entropy = List.generate( 12, (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'), ).join(); return 'approval-$action-${DateTime.now().microsecondsSinceEpoch}-$entropy'; } } class _PendingApproval { const _PendingApproval(this.key, this.payload); final String key; final String payload; }