SHA256
1046 lines
29 KiB
Dart
1046 lines
29 KiB
Dart
import 'dart:convert';
|
||
import 'dart:typed_data';
|
||
|
||
import 'package:crypto/crypto.dart';
|
||
import 'package:dio/dio.dart';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:pointycastle/export.dart';
|
||
|
||
part 'studio_backend_support.dart';
|
||
part 'studio_backend_models.dart';
|
||
|
||
class StudioBackendClient {
|
||
// 登录与会话相关接口。
|
||
StudioBackendClient({
|
||
required String baseUrl,
|
||
String languageCode = 'zh',
|
||
Dio? dio,
|
||
}) : languageCode = languageCode,
|
||
_dio =
|
||
dio ??
|
||
Dio(
|
||
BaseOptions(
|
||
baseUrl: _normalizeBaseUrl(baseUrl),
|
||
connectTimeout: const Duration(seconds: 10),
|
||
receiveTimeout: const Duration(seconds: 20),
|
||
sendTimeout: const Duration(seconds: 20),
|
||
responseType: ResponseType.json,
|
||
followRedirects: true,
|
||
validateStatus: (_) => true,
|
||
),
|
||
) {
|
||
_dio.options.baseUrl = _normalizeBaseUrl(baseUrl);
|
||
}
|
||
|
||
final Dio _dio;
|
||
final String languageCode;
|
||
final Map<String, String> _cookies = <String, String>{};
|
||
|
||
String get baseUrl => _dio.options.baseUrl;
|
||
|
||
Future<StudioBackendLoginResult> login({
|
||
required String workspace,
|
||
required String username,
|
||
required String password,
|
||
String authType = 'normal',
|
||
}) async {
|
||
final normalizedAuthType = authType.trim().toLowerCase();
|
||
final usePasswordHash =
|
||
normalizedAuthType.isEmpty || normalizedAuthType == 'normal';
|
||
final loginPayload = <String, dynamic>{
|
||
'customer': workspace,
|
||
'username': username,
|
||
'authType': normalizedAuthType == 'normal' ? '' : authType,
|
||
'encryption': usePasswordHash ? '1' : '0',
|
||
'password': usePasswordHash
|
||
? md5.convert(utf8.encode(password)).toString()
|
||
: password,
|
||
};
|
||
final encryptedPayload = _encryptLoginPayload(loginPayload);
|
||
|
||
final response = await _postJson(
|
||
'rest/auth/login',
|
||
encryptedPayload,
|
||
session: StudioBackendSession.initial(
|
||
baseUrl: baseUrl,
|
||
workspace: workspace,
|
||
locale: languageCode,
|
||
),
|
||
includeSessionCookies: true,
|
||
forceRawBody: true,
|
||
followRedirects: false,
|
||
);
|
||
|
||
final json = response.json;
|
||
final code = _extractCode(json);
|
||
final sessionMap = _extractMap(
|
||
json['session'] ?? json['context'] ?? json['data'] ?? <String, dynamic>{},
|
||
);
|
||
final context = _extractMap(json['context']);
|
||
|
||
return StudioBackendLoginResult(
|
||
success: _isSuccess(code),
|
||
code: code,
|
||
message: _extractMessage(json),
|
||
leftDays: _extractInt(json['leftDays']),
|
||
session: sessionMap.isEmpty
|
||
? null
|
||
: StudioBackendSession.fromJson(
|
||
baseUrl: baseUrl,
|
||
raw: sessionMap,
|
||
fallbackUser: username,
|
||
fallbackWorkspace: workspace,
|
||
fallbackLocale: languageCode,
|
||
),
|
||
appConfig: _extractMap(json['appConfig'] ?? context['appConfig']),
|
||
systemConfig: _extractMap(
|
||
json['systemConfig'] ?? context['systemConfig'],
|
||
),
|
||
raw: json,
|
||
);
|
||
}
|
||
|
||
Future<StudioBackendLoginStatusResult> checkLogin() async {
|
||
final response = await _getJson(
|
||
'rest/auth/loginStatus',
|
||
includeSessionCookies: true,
|
||
);
|
||
final json = response.json;
|
||
final code = _extractCode(json);
|
||
final context = _extractMap(json['context']);
|
||
final sessionMap = _extractMap(
|
||
json['session'] ?? context['session'] ?? context,
|
||
);
|
||
|
||
return StudioBackendLoginStatusResult(
|
||
success: _isSuccess(code),
|
||
code: code,
|
||
message: _extractMessage(json),
|
||
leftDays: _extractInt(json['leftDays']),
|
||
session: sessionMap.isEmpty
|
||
? null
|
||
: StudioBackendSession.fromJson(
|
||
baseUrl: baseUrl,
|
||
raw: sessionMap,
|
||
fallbackUser: _extractString(sessionMap['user']),
|
||
fallbackWorkspace: _extractString(sessionMap['db']),
|
||
fallbackLocale: languageCode,
|
||
),
|
||
appConfig: _extractMap(json['appConfig'] ?? context['appConfig']),
|
||
systemConfig: _extractMap(
|
||
json['systemConfig'] ?? context['systemConfig'],
|
||
),
|
||
raw: json,
|
||
);
|
||
}
|
||
|
||
Future<Map<String, dynamic>?> fetchLoginPageConfig() async {
|
||
final response = await _getJson('rest/auth/loginPageConfig');
|
||
final json = response.json;
|
||
if (_isSuccess(_extractCode(json)) && json['data'] is Map) {
|
||
return _extractMap(json['data']);
|
||
}
|
||
if (json is Map<String, dynamic>) {
|
||
return json;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
Future<StudioBackendUserProfile?> fetchUserProfile(
|
||
StudioBackendSession session,
|
||
) async {
|
||
final response = await _getJson('/user/getUser', session: session);
|
||
final json = response.json;
|
||
final rawUser = _extractMap(json['user'] ?? json['data'] ?? json);
|
||
if (rawUser.isEmpty) {
|
||
return null;
|
||
}
|
||
return StudioBackendUserProfile.fromJson(rawUser);
|
||
}
|
||
|
||
Future<StudioBackendOrg?> fetchOrgByUser(StudioBackendSession session) async {
|
||
final response = await _postJson('/cbt/org/byUser', {
|
||
'userCode': session.user,
|
||
}, session: session);
|
||
final json = response.json;
|
||
final rawOrg = _extractMap(json['data'] ?? json);
|
||
if (rawOrg.isEmpty) {
|
||
return null;
|
||
}
|
||
return StudioBackendOrg.fromJson(rawOrg);
|
||
}
|
||
|
||
Future<Set<String>> fetchPermissionIds(StudioBackendSession session) async {
|
||
final response = await _postJson('studio/access/permissions', {
|
||
'user': session.user,
|
||
}, session: session);
|
||
final json = response.json;
|
||
final candidates = <dynamic>[];
|
||
final map = Map<String, dynamic>.from(json);
|
||
for (final key in [
|
||
'menus',
|
||
'navs',
|
||
'data',
|
||
'result',
|
||
'items',
|
||
'permissions',
|
||
'nodes',
|
||
'children',
|
||
]) {
|
||
final value = map[key];
|
||
if (value is List) {
|
||
candidates.addAll(value);
|
||
} else if (value is Map) {
|
||
candidates.add(value);
|
||
}
|
||
}
|
||
if (candidates.isEmpty && map['id'] != null) {
|
||
candidates.add(map);
|
||
}
|
||
|
||
final ids = <String>{};
|
||
void walk(dynamic node) {
|
||
if (node is Map) {
|
||
final map = Map<String, dynamic>.from(node);
|
||
final id = _extractString(map['id']);
|
||
if (id.isNotEmpty) {
|
||
ids.add(id);
|
||
}
|
||
for (final key in ['children', 'navs', 'menus', 'items']) {
|
||
final value = map[key];
|
||
if (value is List) {
|
||
for (final child in value) {
|
||
walk(child);
|
||
}
|
||
}
|
||
}
|
||
} else if (node is List) {
|
||
for (final child in node) {
|
||
walk(child);
|
||
}
|
||
}
|
||
}
|
||
|
||
for (final item in candidates) {
|
||
walk(item);
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
Future<List<Map<String, dynamic>>> fetchUpgradeLogs({
|
||
StudioBackendSession? session,
|
||
}) async {
|
||
final response = await _getJson(
|
||
'/upgrade/log/loadAll',
|
||
session: session,
|
||
includeSessionCookies: true,
|
||
);
|
||
return _extractListOfMaps(response.json);
|
||
}
|
||
|
||
Future<List<Map<String, dynamic>>> fetchSqlTemplateIndex({
|
||
StudioBackendSession? session,
|
||
}) async {
|
||
final response = await _getJson(
|
||
'rest/sqlTemplate/tidOidList',
|
||
session: session,
|
||
);
|
||
return _extractListOfMaps(response.json);
|
||
}
|
||
|
||
Future<Map<String, dynamic>?> fetchSqlTemplate({
|
||
required String tid,
|
||
required String oid,
|
||
StudioBackendSession? session,
|
||
}) async {
|
||
final response = await _getJson(
|
||
'rest/sqlTemplate/$tid/$oid',
|
||
session: session,
|
||
);
|
||
final json = response.json;
|
||
final data = _extractMap(json['data'] ?? json);
|
||
return data.isEmpty ? null : data;
|
||
}
|
||
|
||
Future<StudioBackendActionResult> createSqlTemplate({
|
||
required String tid,
|
||
required String oid,
|
||
required Map<String, dynamic> payload,
|
||
StudioBackendSession? session,
|
||
}) async {
|
||
return _postAction(
|
||
'rest/sqlTemplate/$tid/$oid',
|
||
payload,
|
||
session: session,
|
||
);
|
||
}
|
||
|
||
Future<StudioBackendActionResult> updateSqlTemplate({
|
||
required String tid,
|
||
required String oid,
|
||
required Map<String, dynamic> payload,
|
||
StudioBackendSession? session,
|
||
}) async {
|
||
return _postAction(
|
||
'rest/sqlTemplate/update/$tid/$oid',
|
||
payload,
|
||
session: session,
|
||
);
|
||
}
|
||
|
||
Future<List<Map<String, dynamic>>> fetchCreationNavigator({
|
||
StudioBackendSession? session,
|
||
}) async {
|
||
final response = await _getJson(
|
||
'rest/creation/navigator',
|
||
session: session,
|
||
);
|
||
final json = response.json;
|
||
final structure = json['structure'];
|
||
if (structure is List) {
|
||
return structure
|
||
.whereType<Map>()
|
||
.map((item) => Map<String, dynamic>.from(item))
|
||
.toList();
|
||
}
|
||
return _extractListOfMaps(json['data'] ?? json);
|
||
}
|
||
|
||
Future<Map<String, dynamic>> fetchCreationNavigatorRecord({
|
||
StudioBackendSession? session,
|
||
}) async {
|
||
final response = await _getJson(
|
||
'rest/creation/navigator',
|
||
session: session,
|
||
);
|
||
return _extractMap(response.json);
|
||
}
|
||
|
||
Future<StudioBackendActionResult> saveCreationNavigator({
|
||
required List<Map<String, dynamic>> data,
|
||
int? version,
|
||
StudioBackendSession? session,
|
||
}) async {
|
||
return _postAction(
|
||
'rest/creation/navigator',
|
||
<String, dynamic>{
|
||
'data': data,
|
||
if (version != null) 'version': version,
|
||
},
|
||
session: session,
|
||
);
|
||
}
|
||
|
||
Future<List<Map<String, dynamic>>> fetchTableModels({
|
||
StudioBackendSession? session,
|
||
}) async {
|
||
final response = await _getJson(
|
||
'rest/creation/tableModels',
|
||
session: session,
|
||
);
|
||
return _extractListOfMaps(response.json);
|
||
}
|
||
|
||
Future<Map<String, dynamic>?> fetchTableModel({
|
||
required String code,
|
||
StudioBackendSession? session,
|
||
}) async {
|
||
final response = await _getJson(
|
||
'rest/creation/tableModels/$code',
|
||
session: session,
|
||
);
|
||
final data = _extractMap(response.json['data'] ?? response.json);
|
||
return data.isEmpty ? null : data;
|
||
}
|
||
|
||
Future<StudioBackendActionResult> createTableModel({
|
||
required Map<String, dynamic> payload,
|
||
StudioBackendSession? session,
|
||
}) async {
|
||
return _postAction(
|
||
'rest/creation/tableModels',
|
||
payload,
|
||
session: session,
|
||
);
|
||
}
|
||
|
||
Future<StudioBackendActionResult> updateTableModel({
|
||
required Map<String, dynamic> payload,
|
||
StudioBackendSession? session,
|
||
}) async {
|
||
return _postAction(
|
||
'rest/creation/update/tableModels',
|
||
payload,
|
||
session: session,
|
||
);
|
||
}
|
||
|
||
Future<StudioBackendActionResult> deleteTableModel({
|
||
required String code,
|
||
StudioBackendSession? session,
|
||
}) async {
|
||
return _deleteAction('rest/creation/tableModels/$code', session: session);
|
||
}
|
||
|
||
Future<List<Map<String, dynamic>>> fetchBillModels({
|
||
StudioBackendSession? session,
|
||
}) async {
|
||
final response = await _getJson('rest/creation/billModels', session: session);
|
||
return _extractListOfMaps(response.json);
|
||
}
|
||
|
||
Future<Map<String, dynamic>?> fetchBillModel({
|
||
required String code,
|
||
StudioBackendSession? session,
|
||
}) async {
|
||
final response = await _getJson(
|
||
'rest/creation/billModels/$code',
|
||
session: session,
|
||
);
|
||
final data = _extractMap(response.json['data'] ?? response.json);
|
||
return data.isEmpty ? null : data;
|
||
}
|
||
|
||
Future<StudioBackendActionResult> createBillModel({
|
||
required Map<String, dynamic> payload,
|
||
StudioBackendSession? session,
|
||
}) async {
|
||
return _postAction(
|
||
'rest/creation/billModels',
|
||
payload,
|
||
session: session,
|
||
);
|
||
}
|
||
|
||
Future<StudioBackendActionResult> updateBillModel({
|
||
required Map<String, dynamic> payload,
|
||
StudioBackendSession? session,
|
||
}) async {
|
||
return _postAction(
|
||
'rest/creation/update/billModels',
|
||
payload,
|
||
session: session,
|
||
);
|
||
}
|
||
|
||
Future<StudioBackendActionResult> deleteBillModel({
|
||
required String code,
|
||
StudioBackendSession? session,
|
||
}) async {
|
||
return _deleteAction('rest/creation/billModels/$code', session: session);
|
||
}
|
||
|
||
Future<List<Map<String, dynamic>>> fetchViewModelOptions({
|
||
StudioBackendSession? session,
|
||
}) async {
|
||
final response = await _getJson(
|
||
'rest/creation/filteringSelect/viewModel',
|
||
session: session,
|
||
);
|
||
return _extractListOfMaps(response.json);
|
||
}
|
||
|
||
Future<Map<String, dynamic>?> fetchViewModel({
|
||
required String tid,
|
||
required String oid,
|
||
StudioBackendSession? session,
|
||
}) async {
|
||
final response = await _getJson(
|
||
'rest/creation/viewModels/$tid/$oid',
|
||
session: session,
|
||
);
|
||
final list = _extractListOfMaps(response.json);
|
||
if (list.isNotEmpty) {
|
||
return list.first;
|
||
}
|
||
final data = _extractMap(response.json['data'] ?? response.json);
|
||
return data.isEmpty ? null : data;
|
||
}
|
||
|
||
Future<StudioBackendActionResult> createViewModel({
|
||
required Map<String, dynamic> payload,
|
||
StudioBackendSession? session,
|
||
}) async {
|
||
return _postAction(
|
||
'rest/creation/viewModels',
|
||
payload,
|
||
session: session,
|
||
);
|
||
}
|
||
|
||
Future<StudioBackendActionResult> updateViewModel({
|
||
required Map<String, dynamic> payload,
|
||
StudioBackendSession? session,
|
||
}) async {
|
||
return _postAction(
|
||
'rest/creation/update/viewModels',
|
||
payload,
|
||
session: session,
|
||
);
|
||
}
|
||
|
||
Future<StudioBackendActionResult> deleteViewModel({
|
||
required String tid,
|
||
required String oid,
|
||
StudioBackendSession? session,
|
||
}) async {
|
||
return _deleteAction('rest/creation/viewModels/$tid/$oid', session: session);
|
||
}
|
||
|
||
Future<StudioBackendActionResult> deleteSqlTemplate({
|
||
required String tid,
|
||
required String oid,
|
||
StudioBackendSession? session,
|
||
}) async {
|
||
return _deleteAction('rest/sqlTemplate/$tid/$oid', session: session);
|
||
}
|
||
|
||
Future<List<Map<String, dynamic>>> fetchPortalConfigs({
|
||
StudioBackendSession? session,
|
||
}) async {
|
||
final response = await _getJson('rest/portal/loadAll', session: session);
|
||
return _extractListOfMaps(response.json);
|
||
}
|
||
|
||
Future<StudioBackendActionResult> repairDepartment({
|
||
StudioBackendSession? session,
|
||
}) async {
|
||
final response = await _getJson(
|
||
'/cbt/department/repair',
|
||
session: session,
|
||
);
|
||
final raw = response.json;
|
||
final code = _extractCode(raw);
|
||
return StudioBackendActionResult(
|
||
success: _isSuccess(code),
|
||
code: code,
|
||
message: _extractMessage(raw),
|
||
data: _extractData(raw),
|
||
raw: raw,
|
||
);
|
||
}
|
||
|
||
Future<StudioBackendDownloadResult> downloadModelConfigs({
|
||
StudioBackendSession? session,
|
||
}) async {
|
||
final normalizedPath = _normalizePath('rest/modelSync/downloadConfigs');
|
||
final response = await _dio.get<List<int>>(
|
||
normalizedPath,
|
||
options: Options(
|
||
headers: _headers(
|
||
requestPath: normalizedPath,
|
||
session: session,
|
||
includeSessionCookies: true,
|
||
),
|
||
responseType: ResponseType.bytes,
|
||
followRedirects: true,
|
||
receiveTimeout: const Duration(seconds: 60),
|
||
),
|
||
);
|
||
_captureCookies(response);
|
||
final bytes = response.data ?? <int>[];
|
||
final filename = _filenameFromContentDisposition(
|
||
response.headers.value('content-disposition') ??
|
||
response.headers.value('Content-Disposition') ??
|
||
'',
|
||
fallback: 'download-configs.zip',
|
||
);
|
||
return StudioBackendDownloadResult(
|
||
bytes: Uint8List.fromList(bytes),
|
||
filename: filename,
|
||
contentType: response.headers.value('content-type') ?? '',
|
||
statusCode: response.statusCode ?? 0,
|
||
rawHeaders: response.headers.map,
|
||
);
|
||
}
|
||
|
||
Future<StudioBackendActionResult> runSql({
|
||
required String sql,
|
||
int limit = 100,
|
||
List<Object?> params = const <Object?>[],
|
||
StudioBackendSession? session,
|
||
}) async {
|
||
return _postAction(
|
||
'cbt/sqlrunner/run',
|
||
<String, dynamic>{
|
||
'sql': sql,
|
||
'limit': limit,
|
||
'params': params,
|
||
},
|
||
session: session,
|
||
);
|
||
}
|
||
|
||
Future<void> logout({StudioBackendSession? session}) async {
|
||
await _getJson(
|
||
'/rest/auth/logout',
|
||
session: session,
|
||
includeSessionCookies: true,
|
||
);
|
||
_cookies.clear();
|
||
}
|
||
|
||
Future<_BackendJsonResponse> _getJson(
|
||
String path, {
|
||
StudioBackendSession? session,
|
||
bool includeSessionCookies = false,
|
||
}) async {
|
||
final normalizedPath = _normalizePath(path);
|
||
final response = await _dio.get<dynamic>(
|
||
normalizedPath,
|
||
options: Options(
|
||
headers: _headers(
|
||
requestPath: normalizedPath,
|
||
session: session,
|
||
includeSessionCookies: includeSessionCookies,
|
||
),
|
||
),
|
||
);
|
||
_captureCookies(response);
|
||
return _BackendJsonResponse.fromResponse(response);
|
||
}
|
||
|
||
Future<_BackendJsonResponse> _postJson(
|
||
String path,
|
||
Object data, {
|
||
StudioBackendSession? session,
|
||
bool includeSessionCookies = false,
|
||
bool forceRawBody = false,
|
||
bool followRedirects = true,
|
||
}) async {
|
||
final normalizedPath = _normalizePath(path);
|
||
final response = await _dio.post<dynamic>(
|
||
normalizedPath,
|
||
data: data,
|
||
options: Options(
|
||
headers: _headers(
|
||
requestPath: normalizedPath,
|
||
session: session,
|
||
includeSessionCookies: includeSessionCookies,
|
||
json: true,
|
||
),
|
||
requestEncoder: forceRawBody && data is String
|
||
? (request, options) => utf8.encode(request)
|
||
: null,
|
||
followRedirects: followRedirects,
|
||
),
|
||
);
|
||
_captureCookies(response);
|
||
return _BackendJsonResponse.fromResponse(response);
|
||
}
|
||
|
||
Future<StudioBackendActionResult> _postAction(
|
||
String path,
|
||
Map<String, dynamic> data, {
|
||
StudioBackendSession? session,
|
||
bool includeSessionCookies = false,
|
||
bool json = true,
|
||
}) async {
|
||
final response = await _postJson(
|
||
path,
|
||
data,
|
||
session: session,
|
||
includeSessionCookies: includeSessionCookies,
|
||
followRedirects: true,
|
||
);
|
||
final raw = response.json;
|
||
final code = _extractCode(raw);
|
||
return StudioBackendActionResult(
|
||
success: _isSuccess(code),
|
||
code: code,
|
||
message: _extractMessage(raw),
|
||
data: _extractData(raw),
|
||
raw: raw,
|
||
);
|
||
}
|
||
|
||
Future<StudioBackendActionResult> _deleteAction(
|
||
String path, {
|
||
StudioBackendSession? session,
|
||
}) async {
|
||
final normalizedPath = _normalizePath(path);
|
||
final response = await _dio.delete<dynamic>(
|
||
normalizedPath,
|
||
options: Options(
|
||
headers: _headers(
|
||
requestPath: normalizedPath,
|
||
session: session,
|
||
includeSessionCookies: true,
|
||
),
|
||
followRedirects: true,
|
||
),
|
||
);
|
||
_captureCookies(response);
|
||
final rawResponse = _BackendJsonResponse.fromResponse(response).json;
|
||
final code = _extractCode(rawResponse);
|
||
return StudioBackendActionResult(
|
||
success: _isSuccess(code),
|
||
code: code,
|
||
message: _extractMessage(rawResponse),
|
||
data: _extractData(rawResponse),
|
||
raw: rawResponse,
|
||
);
|
||
}
|
||
|
||
Map<String, dynamic> _headers({
|
||
required String requestPath,
|
||
StudioBackendSession? session,
|
||
bool includeSessionCookies = false,
|
||
bool json = false,
|
||
}) {
|
||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||
final headers = <String, dynamic>{
|
||
'X-Client-Language': languageCode,
|
||
'x-locale': languageCode,
|
||
'x-Language': languageCode,
|
||
'x-Timezone': _localTimezoneCode(),
|
||
'X-cbtVersion': '2.9.x',
|
||
'X-device': 'browser',
|
||
'X-t': '$timestamp',
|
||
'X-v': _buildFab(requestPath, timestamp),
|
||
'Accept': 'application/json',
|
||
};
|
||
|
||
if (json) {
|
||
headers['Content-Type'] = 'application/json';
|
||
}
|
||
|
||
if (session != null) {
|
||
headers.addAll(session.toHeaders());
|
||
}
|
||
|
||
final cookieHeader = _cookieHeader(
|
||
includeSessionCookies: includeSessionCookies,
|
||
);
|
||
if (!kIsWeb && cookieHeader.isNotEmpty) {
|
||
headers['Cookie'] = cookieHeader;
|
||
}
|
||
|
||
return headers;
|
||
}
|
||
|
||
String _cookieHeader({required bool includeSessionCookies}) {
|
||
if (_cookies.isEmpty) {
|
||
return '';
|
||
}
|
||
if (!includeSessionCookies && _cookies.isEmpty) {
|
||
return '';
|
||
}
|
||
return _cookies.entries
|
||
.map((entry) => '${entry.key}=${entry.value}')
|
||
.join('; ');
|
||
}
|
||
|
||
void _captureCookies(Response<dynamic> response) {
|
||
final values = response.headers.map['set-cookie'];
|
||
if (values == null || values.isEmpty) {
|
||
return;
|
||
}
|
||
|
||
for (final headerValue in values) {
|
||
final parts = headerValue.split(';');
|
||
if (parts.isEmpty) {
|
||
continue;
|
||
}
|
||
final pair = parts.first.trim();
|
||
final index = pair.indexOf('=');
|
||
if (index <= 0) {
|
||
continue;
|
||
}
|
||
final name = pair.substring(0, index).trim();
|
||
final value = pair.substring(index + 1).trim();
|
||
if (name.isNotEmpty) {
|
||
_cookies[name] = value;
|
||
}
|
||
}
|
||
}
|
||
|
||
String _normalizePath(String path) {
|
||
if (path.startsWith('/')) {
|
||
return path.substring(1);
|
||
}
|
||
return path;
|
||
}
|
||
|
||
static String _normalizeBaseUrl(String baseUrl) {
|
||
final normalized = baseUrl.trim().isEmpty
|
||
? 'http://localhost:9001/'
|
||
: baseUrl.trim();
|
||
return normalized.endsWith('/') ? normalized : '$normalized/';
|
||
}
|
||
|
||
static bool _isSuccess(String code) {
|
||
return code == '0' || code == '200' || code == 'true';
|
||
}
|
||
|
||
static String _extractCode(Map<String, dynamic> json) {
|
||
for (final key in ['code', 'status', 'errorCode']) {
|
||
final value = json[key];
|
||
if (value != null && value.toString().isNotEmpty) {
|
||
return value.toString();
|
||
}
|
||
}
|
||
final httpStatus = json['_httpStatus'];
|
||
if (httpStatus != null && httpStatus.toString() != '200') {
|
||
return 'HTTP $httpStatus';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
static String _extractMessage(Map<String, dynamic> json) {
|
||
final redirectMessage = _extractRedirectMessage(json);
|
||
if (redirectMessage.isNotEmpty) {
|
||
return redirectMessage;
|
||
}
|
||
for (final key in ['msg', 'message', 'errorMsg', 'error', 'raw']) {
|
||
final value = json[key];
|
||
if (value != null && value.toString().isNotEmpty) {
|
||
return _truncateMessage(value.toString());
|
||
}
|
||
}
|
||
final httpStatus = json['_httpStatus'];
|
||
if (httpStatus != null && httpStatus.toString() != '200') {
|
||
return 'HTTP $httpStatus';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
static String _extractRedirectMessage(Map<String, dynamic> json) {
|
||
final httpStatus = int.tryParse(json['_httpStatus']?.toString() ?? '');
|
||
if (httpStatus == null ||
|
||
!const <int>{301, 302, 303, 307, 308}.contains(httpStatus)) {
|
||
return '';
|
||
}
|
||
|
||
final location = json['_location']?.toString() ?? '';
|
||
if (location.isEmpty) {
|
||
return 'HTTP $httpStatus,后端返回重定向,但没有 Location';
|
||
}
|
||
|
||
final uri = Uri.tryParse(location);
|
||
final path = uri?.path ?? location;
|
||
final db = uri?.queryParameters['db'] ?? '';
|
||
|
||
if (path == '/api/customerError') {
|
||
final suffix = db.isEmpty ? '' : ':$db';
|
||
return '工作区无效或后端未配置该客户$suffix';
|
||
}
|
||
if (path == '/init/admin') {
|
||
return '工作区尚未完成初始化:$location';
|
||
}
|
||
if (path == '/rest/redirect/noLicense') {
|
||
return '后端未安装 License';
|
||
}
|
||
if (path == '/rest/redirect/expire') {
|
||
return '后端 License 已过期';
|
||
}
|
||
if (path == '/rest/redirect/onlineExceed' ||
|
||
path == '/rest/redirect/onlineOver') {
|
||
return '后端在线人数超过授权限制';
|
||
}
|
||
if (path == '/rest/redirect/403') {
|
||
return '后端拒绝访问,可能是权限或 IP 风控拦截';
|
||
}
|
||
if (path == '/rest/redirect/timeout') {
|
||
return '登录会话已超时,请重试';
|
||
}
|
||
|
||
return 'HTTP $httpStatus,后端重定向到:$location';
|
||
}
|
||
|
||
static String _truncateMessage(String message) {
|
||
const maxLength = 600;
|
||
if (message.length <= maxLength) {
|
||
return message;
|
||
}
|
||
return '${message.substring(0, maxLength)}...';
|
||
}
|
||
|
||
static Map<String, dynamic> _extractMap(dynamic value) {
|
||
if (value is Map) {
|
||
return Map<String, dynamic>.from(value);
|
||
}
|
||
return <String, dynamic>{};
|
||
}
|
||
|
||
static List<Map<String, dynamic>> _extractListOfMaps(dynamic value) {
|
||
if (value is List) {
|
||
return value
|
||
.whereType<Map>()
|
||
.map((item) => Map<String, dynamic>.from(item))
|
||
.toList();
|
||
}
|
||
if (value is Map) {
|
||
final map = Map<String, dynamic>.from(value);
|
||
for (final key in ['data', 'list', 'items', 'rows', 'result']) {
|
||
final candidate = map[key];
|
||
if (candidate is List) {
|
||
return _extractListOfMaps(candidate);
|
||
}
|
||
}
|
||
}
|
||
return <Map<String, dynamic>>[];
|
||
}
|
||
|
||
static dynamic _extractData(Map<String, dynamic> json) {
|
||
for (final key in ['data', 'result', 'items', 'list']) {
|
||
final value = json[key];
|
||
if (value != null) {
|
||
return value;
|
||
}
|
||
}
|
||
return json;
|
||
}
|
||
|
||
static String _filenameFromContentDisposition(
|
||
String contentDisposition, {
|
||
required String fallback,
|
||
}) {
|
||
if (contentDisposition.isEmpty) {
|
||
return fallback;
|
||
}
|
||
final match = RegExp(
|
||
r'''filename\*?=(?:UTF-8'')?"?([^";]+)"?''',
|
||
).firstMatch(contentDisposition);
|
||
if (match != null) {
|
||
return Uri.decodeFull(match.group(1) ?? fallback);
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
static String _extractString(dynamic value) {
|
||
if (value == null) {
|
||
return '';
|
||
}
|
||
return value.toString();
|
||
}
|
||
|
||
static int? _extractInt(dynamic value) {
|
||
if (value == null) {
|
||
return null;
|
||
}
|
||
if (value is int) {
|
||
return value;
|
||
}
|
||
return int.tryParse(value.toString());
|
||
}
|
||
|
||
String _encryptLoginPayload(Map<String, dynamic> payload) {
|
||
final plainText = jsonEncode(payload);
|
||
final seed = _keyGen();
|
||
final keyHex = _sha1PrngSecureRandoms(seed);
|
||
final keyBytes = _hexToUnsignedBytes(keyHex);
|
||
final cipher = PaddedBlockCipher('AES/ECB/PKCS7')
|
||
..init(
|
||
true,
|
||
PaddedBlockCipherParameters<KeyParameter, Null>(
|
||
KeyParameter(Uint8List.fromList(keyBytes)),
|
||
null,
|
||
),
|
||
);
|
||
final encryptedBytes = cipher.process(
|
||
Uint8List.fromList(utf8.encode(plainText)),
|
||
);
|
||
return base64Encode(encryptedBytes);
|
||
}
|
||
|
||
String _keyGen() {
|
||
final seed = DateTime.now().millisecondsSinceEpoch ~/ 60000;
|
||
final head = sha1
|
||
.convert(utf8.encode(seed.toRadixString(16)))
|
||
.toString()
|
||
.substring(0, 12)
|
||
.toUpperCase();
|
||
final middle = md5
|
||
.convert(utf8.encode(seed.toRadixString(24)))
|
||
.toString()
|
||
.substring(0, 6)
|
||
.toLowerCase();
|
||
final tail = sha256
|
||
.convert(utf8.encode(seed.toRadixString(36)))
|
||
.toString()
|
||
.substring(0, 14)
|
||
.toUpperCase();
|
||
return '$head$middle$tail';
|
||
}
|
||
|
||
String _sha1PrngSecureRandoms(String seed, {int digestHexSize = 64}) {
|
||
var state = sha1.convert(utf8.encode(seed)).toString();
|
||
var output = sha1.convert(_hexToUnsignedBytes(state)).toString();
|
||
var key = output;
|
||
while (key.length < digestHexSize) {
|
||
state = _updateHexState(state, output);
|
||
output = sha1.convert(_hexToUnsignedBytes(state)).toString();
|
||
key += output;
|
||
}
|
||
return key.substring(0, digestHexSize);
|
||
}
|
||
|
||
String _updateHexState(String stateHex, String outputHex) {
|
||
final state = _hexToSignedBytes(stateHex);
|
||
final output = _hexToSignedBytes(outputHex);
|
||
var last = 1;
|
||
var changed = false;
|
||
for (var index = 0; index < state.length; index++) {
|
||
final raw = state[index] + output[index] + last;
|
||
changed = changed || state[index] != raw;
|
||
var normalized = raw;
|
||
if (normalized > 127) {
|
||
normalized -= 256;
|
||
} else if (normalized < -128) {
|
||
normalized += 256;
|
||
}
|
||
state[index] = normalized;
|
||
last = raw >> 8;
|
||
}
|
||
if (!changed && state.isNotEmpty) {
|
||
state[0] = state[0] + 1;
|
||
}
|
||
return _signedBytesToHex(state);
|
||
}
|
||
|
||
Uint8List _hexToUnsignedBytes(String hexText) {
|
||
if (hexText.length.isOdd) {
|
||
throw const FormatException('Invalid hex length');
|
||
}
|
||
final bytes = Uint8List(hexText.length ~/ 2);
|
||
for (var index = 0; index < bytes.length; index++) {
|
||
final pair = hexText.substring(index * 2, index * 2 + 2);
|
||
bytes[index] = int.parse(pair, radix: 16);
|
||
}
|
||
return bytes;
|
||
}
|
||
|
||
List<int> _hexToSignedBytes(String hexText) {
|
||
final unsigned = _hexToUnsignedBytes(hexText);
|
||
return unsigned.map((value) => value > 127 ? value - 256 : value).toList();
|
||
}
|
||
|
||
String _signedBytesToHex(List<int> signedBytes) {
|
||
final buffer = StringBuffer();
|
||
for (final value in signedBytes) {
|
||
final unsigned = value < 0 ? value + 256 : value;
|
||
buffer.write(unsigned.toRadixString(16).padLeft(2, '0'));
|
||
}
|
||
return buffer.toString();
|
||
}
|
||
|
||
String _buildFab(String requestPath, int timestamp, {String button = ''}) {
|
||
String uriPath = requestPath;
|
||
if (uriPath.toLowerCase().startsWith('http://') ||
|
||
uriPath.toLowerCase().startsWith('https://')) {
|
||
uriPath = Uri.parse(uriPath).path;
|
||
} else {
|
||
final fixed = uriPath.startsWith('/') ? uriPath : '/$uriPath';
|
||
uriPath = Uri.parse('http://x$fixed').path;
|
||
}
|
||
final encodedUri = Uri.encodeComponent(uriPath);
|
||
final seed =
|
||
'cyber$timestamp$encodedUri$timestamp$encodedUri${button}trans';
|
||
final base64Seed = base64Encode(utf8.encode(seed));
|
||
final digest = sha256.convert(utf8.encode(base64Seed));
|
||
return Uri.encodeComponent(base64Encode(digest.bytes));
|
||
}
|
||
}
|
||
|