Files
Public/apps/native/lib/api/oa_client.dart
T
daiyongkang 76f266645d Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。
排除 node_modules、构建产物、安装包与 .env 密钥。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 10:04:03 +00:00

359 lines
12 KiB
Dart

import 'dart:convert';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:http_parser/http_parser.dart';
import 'package:uuid/uuid.dart';
import '../app_config.dart';
import '../session/session.dart';
class ApiException implements Exception {
ApiException(this.message);
final String message;
@override
String toString() => message;
}
class OaClient {
OaClient(this.session);
final SessionStore session;
bool _refreshing = false;
Uri _uri(String path, [Map<String, String>? query]) {
final base = session.apiBase.replaceAll(RegExp(r'/$'), '');
final p = path.startsWith('/') ? path : '/$path';
return Uri.parse('$base$p').replace(queryParameters: query);
}
Map<String, String> _headers({bool auth = true}) {
final h = <String, String>{
'Content-Type': 'application/json',
'X-Request-Id': const Uuid().v4(),
};
if (auth && session.accessToken.isNotEmpty) {
h['Authorization'] = 'Bearer ${session.accessToken}';
}
return h;
}
Future<dynamic> get(String path, {Map<String, String>? query}) =>
_send('GET', path, query: query);
Future<dynamic> post(String path, [Map<String, dynamic>? body]) =>
_send('POST', path, body: body);
Future<dynamic> patch(String path, [Map<String, dynamic>? body]) =>
_send('PATCH', path, body: body);
final _fileCache = <String, Uint8List>{};
Uint8List? peekFile(String id) => _fileCache[id];
Future<Uint8List?> fileBytes(String id) async {
if (id.isEmpty) return null;
final hit = _fileCache[id];
if (hit != null) return hit;
final uri = _uri('/files/$id');
final headers = <String, String>{
'X-Request-Id': const Uuid().v4(),
if (session.accessToken.isNotEmpty) 'Authorization': 'Bearer ${session.accessToken}',
};
try {
var res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 30));
if (res.statusCode == 401 && session.refreshToken.isNotEmpty) {
final ok = await _refresh();
if (ok) {
headers['Authorization'] = 'Bearer ${session.accessToken}';
res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 30));
}
}
if (res.statusCode >= 400 || res.bodyBytes.isEmpty) return null;
if (_fileCache.length > 80) _fileCache.remove(_fileCache.keys.first);
_fileCache[id] = res.bodyBytes;
return res.bodyBytes;
} catch (_) {
return null;
}
}
Future<Uint8List?> getBytes(String path, {Map<String, String>? query}) async {
final uri = _uri(path, query);
final headers = <String, String>{
'X-Request-Id': const Uuid().v4(),
if (session.accessToken.isNotEmpty) 'Authorization': 'Bearer ${session.accessToken}',
};
try {
var res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 20));
if (res.statusCode == 401 && session.refreshToken.isNotEmpty) {
final ok = await _refresh();
if (ok) {
headers['Authorization'] = 'Bearer ${session.accessToken}';
res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 20));
}
}
if (res.statusCode >= 400 || res.bodyBytes.isEmpty) return null;
return res.bodyBytes;
} catch (_) {
return null;
}
}
Future<Map<String, dynamic>> uploadFile({
required String filePath,
required String filename,
required String bizType,
required String bizId,
String? mime,
}) async {
final uri = _uri('/files');
final type = _mediaType(filename, mime);
Future<http.Response> send() async {
final req = http.MultipartRequest('POST', uri);
req.headers['Authorization'] = 'Bearer ${session.accessToken}';
req.headers['X-Request-Id'] = const Uuid().v4();
req.fields['bizType'] = bizType;
req.fields['bizId'] = bizId;
req.files.add(await http.MultipartFile.fromPath('file', filePath, filename: filename, contentType: type));
return http.Response.fromStream(await req.send().timeout(const Duration(seconds: 60)));
}
var res = await send();
if (res.statusCode == 401 && session.refreshToken.isNotEmpty) {
final ok = await _refresh();
if (ok) res = await send();
}
if ((res.statusCode == 502 || res.statusCode == 503) && session.accessToken.isNotEmpty) {
await Future<void>.delayed(const Duration(milliseconds: 700));
res = await send();
}
dynamic json;
try {
json = res.body.isEmpty ? null : jsonDecode(res.body);
} catch (_) {
throw ApiException(res.statusCode >= 400 ? '上传失败 ${res.statusCode}' : '响应无法解析');
}
if (json is Map && json['code'] != null && json['code'] != 0) {
throw ApiException('${json['message'] ?? '上传失败'}');
}
if (res.statusCode >= 400) {
final msg = json is Map ? (json['message'] ?? json['error'] ?? res.statusCode) : res.statusCode;
throw ApiException('$msg');
}
final data = json is Map && json.containsKey('data') ? json['data'] : json;
return data is Map ? Map<String, dynamic>.from(data) : {'id': '$data'};
}
Future<dynamic> _send(
String method,
String path, {
Map<String, String>? query,
Map<String, dynamic>? body,
bool retry = true,
}) async {
final uri = _uri(path, query);
final headers = _headers(
auth: !path.startsWith('/auth/login') &&
!path.startsWith('/auth/sms-code') &&
path != '/auth/login-options' &&
path != '/system/app-release' &&
!path.startsWith('/legal'),
);
http.Response res;
try {
if (method == 'GET') {
res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 30));
} else if (method == 'PATCH') {
res = await http.patch(uri, headers: headers, body: jsonEncode(body ?? {})).timeout(const Duration(seconds: 30));
} else {
res = await http.post(uri, headers: headers, body: jsonEncode(body ?? {})).timeout(const Duration(seconds: 30));
}
} catch (e) {
throw ApiException('网络不可用:$e');
}
if ((res.statusCode == 502 || res.statusCode == 503) && retry) {
await Future<void>.delayed(const Duration(milliseconds: 700));
return _send(method, path, query: query, body: body, retry: false);
}
if (res.statusCode == 401 && retry && session.refreshToken.isNotEmpty && !path.contains('/auth/refresh')) {
final ok = await _refresh();
if (ok) return _send(method, path, query: query, body: body, retry: false);
await session.clear();
throw ApiException('登录已过期,请重新登录');
}
dynamic json;
try {
json = res.body.isEmpty ? null : jsonDecode(res.body);
} catch (_) {
throw ApiException(res.statusCode >= 400 ? '请求失败 ${res.statusCode}' : '响应无法解析');
}
if (json is Map && json['code'] != null && json['code'] != 0) {
throw ApiException('${json['message'] ?? '请求失败'}');
}
if (res.statusCode >= 400) {
final msg = json is Map ? (json['message'] ?? json['error'] ?? res.statusCode) : res.statusCode;
throw ApiException('$msg');
}
if (json is Map && json.containsKey('data')) return json['data'];
return json;
}
Future<bool> _refresh() async {
if (_refreshing) return false;
_refreshing = true;
try {
final uri = _uri('/auth/refresh');
final res = await http.post(
uri,
headers: _headers(auth: false),
body: jsonEncode({
'refreshToken': session.refreshToken,
'clientKind': 'mobile',
}),
);
if (res.statusCode >= 400) return false;
final json = jsonDecode(res.body);
final data = json is Map && json['data'] is Map ? json['data'] as Map : json;
final access = '${data['accessToken'] ?? ''}';
final refresh = '${data['refreshToken'] ?? ''}';
if (access.isEmpty) return false;
await session.setTokens(access, refresh.isEmpty ? session.refreshToken : refresh);
return true;
} catch (_) {
return false;
} finally {
_refreshing = false;
}
}
MediaType _mediaType(String filename, String? mime) {
var raw = (mime ?? '').split(';').first.trim().toLowerCase();
if (raw == 'image/jpg' || raw == 'image/pjpeg') raw = 'image/jpeg';
if (raw.isEmpty || raw == 'application/octet-stream') {
final n = filename.toLowerCase();
if (n.endsWith('.jpg') || n.endsWith('.jpeg')) {
raw = 'image/jpeg';
} else if (n.endsWith('.png')) {
raw = 'image/png';
} else if (n.endsWith('.gif')) {
raw = 'image/gif';
} else if (n.endsWith('.webp')) {
raw = 'image/webp';
} else if (n.endsWith('.m4a') || n.endsWith('.mp4')) {
raw = 'audio/mp4';
} else if (n.endsWith('.pdf')) {
raw = 'application/pdf';
} else {
raw = 'application/octet-stream';
}
}
try {
return MediaType.parse(raw);
} catch (_) {
return MediaType('application', 'octet-stream');
}
}
}
List<Map<String, dynamic>> asMaps(dynamic data) {
final list = extractList(data);
return list.whereType<Map>().map((e) => Map<String, dynamic>.from(e)).toList();
}
List<dynamic> extractList(dynamic data) {
if (data is List) return data;
if (data is Map) {
for (final k in ['items', 'list', 'rows', 'records', 'data']) {
if (data[k] is List) return data[k] as List;
}
}
return const [];
}
String scalarText(dynamic v) {
if (v == null) return '';
if (v is Map) {
return '${v['displayName'] ?? v['name'] ?? v['title'] ?? v['departmentName'] ?? ''}'.trim();
}
if (v is List) {
return v.map(scalarText).where((e) => e.isNotEmpty).join('');
}
final s = '$v'.trim();
if (s.startsWith('{') && (s.contains('id:') || s.contains('tenantId'))) return '';
return s;
}
String pickTitle(Map<String, dynamic> row) {
for (final k in [
'title',
'name',
'displayName',
'projectName',
'bidNo',
'contractNo',
'claimNo',
'loanNo',
'projectNo',
'purpose',
'reason',
'label',
]) {
final s = scalarText(row[k]);
if (s.isNotEmpty) return s;
}
return row['id']?.toString() ?? '未命名';
}
String pickSub(Map<String, dynamic> row) {
for (final k in [
'departmentName',
'department',
'status',
'title',
'bidNo',
'contractNo',
'claimNo',
'kind',
'mobile',
'lastText',
'employmentStatus',
]) {
final s = scalarText(row[k]);
if (s.isNotEmpty) return s;
}
return '';
}
String fmtTime(dynamic v) {
if (v == null) return '';
final s = '$v';
final t = DateTime.tryParse(s);
if (t != null) {
final local = t.toLocal();
final date = '${local.year.toString().padLeft(4, '0')}-${local.month.toString().padLeft(2, '0')}-${local.day.toString().padLeft(2, '0')}';
final time = '${local.hour.toString().padLeft(2, '0')}:${local.minute.toString().padLeft(2, '0')}:${local.second.toString().padLeft(2, '0')}';
return '$date $time';
}
if (s.length >= 16) return s.substring(0, 16).replaceFirst('T', ' ');
return s;
}
String shortTime(dynamic v) {
if (v == null) return '';
final t = DateTime.tryParse('$v');
if (t == null) return fmtTime(v);
final now = DateTime.now();
final local = t.toLocal();
final today = DateTime(now.year, now.month, now.day);
final day = DateTime(local.year, local.month, local.day);
final hh = local.hour.toString().padLeft(2, '0');
final mm = local.minute.toString().padLeft(2, '0');
if (day == today) return '$hh:$mm';
if (day == today.subtract(const Duration(days: 1))) return '昨天';
const week = ['', '', '', '', '', '', ''];
if (today.difference(day).inDays < 7) return '${week[local.weekday - 1]}';
return '${local.month}/${local.day}';
}
const defaultApiHint = AppConfig.defaultApiBase;