186 lines
5.3 KiB
Dart
186 lines
5.3 KiB
Dart
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<String, Object?> json) =>
|
||
ApprovalTaskItem(
|
||
id: json['id']! as String,
|
||
name: json['name']! as String,
|
||
createdAt: DateTime.parse(json['createdAt']! as String),
|
||
leaveRequest: ApprovalLeaveRequest.fromJson(
|
||
Map<String, Object?>.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<String, Object?> 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<ApprovalTaskItem>> 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<String, Object?>.from(item as Map)),
|
||
)
|
||
.toList();
|
||
}
|
||
|
||
Future<void> 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<String, String> 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<String, Object?>)['detail']
|
||
as String?;
|
||
} catch (_) {}
|
||
throw ApprovalTaskException(message ?? '待办请求失败(${response.statusCode})');
|
||
}
|
||
|
||
_PendingApproval? _decodePending(String encoded) {
|
||
try {
|
||
final json = jsonDecode(encoded) as Map<String, Object?>;
|
||
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;
|
||
}
|