265 lines
8.4 KiB
Dart
265 lines
8.4 KiB
Dart
|
|
import 'dart:convert';
|
|||
|
|
|
|||
|
|
import 'package:dio/dio.dart';
|
|||
|
|
|
|||
|
|
import '../models/user.dart';
|
|||
|
|
import '../utils/constants.dart';
|
|||
|
|
import '../utils/uuid.dart';
|
|||
|
|
import '../config/app_config.dart';
|
|||
|
|
import 'api_client.dart';
|
|||
|
|
|
|||
|
|
/// 认证服务:对接真实「爱视后台」/api/sys/* 接口。
|
|||
|
|
///
|
|||
|
|
/// mock 模式下([MockInterceptor] 挂载)请求路径与真实一致,
|
|||
|
|
/// 由拦截器本地返回示例数据,service 层代码两种模式完全统一。
|
|||
|
|
class AuthService {
|
|||
|
|
AuthService({Dio? dio, Dio? rawDio})
|
|||
|
|
: _dio = dio ?? ApiClient.instance.dio,
|
|||
|
|
_rawDio = rawDio ?? _createRawDio();
|
|||
|
|
|
|||
|
|
final Dio _dio;
|
|||
|
|
final Dio _rawDio;
|
|||
|
|
|
|||
|
|
/// 用于拉取验证码图片的独立 Dio 实例(不经过 ApiInterceptor 信封解包)。
|
|||
|
|
static Dio _createRawDio() {
|
|||
|
|
return Dio(
|
|||
|
|
BaseOptions(
|
|||
|
|
baseUrl: AppConfig.apiFullBaseUrl,
|
|||
|
|
connectTimeout: AppConstants.apiTimeout,
|
|||
|
|
receiveTimeout: AppConstants.apiTimeout,
|
|||
|
|
responseType: ResponseType.bytes,
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 获取验证码图片字节与对应 UUID。
|
|||
|
|
///
|
|||
|
|
/// 接口:GET /captcha.jpg?uuid=xxx,返回图片二进制。
|
|||
|
|
/// web 端生成 UUID 规则:[generateUuid]。
|
|||
|
|
Future<Captcha> fetchCaptcha() async {
|
|||
|
|
final uuid = generateUuid();
|
|||
|
|
|
|||
|
|
// mock 模式直接返回占位图,不发起真实请求。
|
|||
|
|
if (AppConstants.useMock) {
|
|||
|
|
return Captcha(uuid: uuid, imageBytes: _mockCaptchaBytes);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
final response = await _rawDio.get<List<int>>(
|
|||
|
|
'/captcha.jpg',
|
|||
|
|
queryParameters: {'uuid': uuid},
|
|||
|
|
);
|
|||
|
|
final bytes = response.data;
|
|||
|
|
if (bytes == null || bytes.isEmpty) {
|
|||
|
|
throw const AuthException('验证码加载失败');
|
|||
|
|
}
|
|||
|
|
return Captcha(uuid: uuid, imageBytes: bytes);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 登录。
|
|||
|
|
///
|
|||
|
|
/// 请求:POST /sys/login。
|
|||
|
|
/// 当 [AppConfig.enableCaptcha] 为真时,body 携带 `{username, password,
|
|||
|
|
/// captcha, uuid}`;否则仅发送 `{username, password}`,由后端会话策略决定
|
|||
|
|
/// 是否仍需校验验证码(前端已不再展示/收集验证码)。
|
|||
|
|
/// 成功后令牌写入 [TokenStore](内存态),失败抛出 [AuthException]。
|
|||
|
|
Future<User> login({
|
|||
|
|
required String account,
|
|||
|
|
required String password,
|
|||
|
|
String captcha = '',
|
|||
|
|
String uuid = '',
|
|||
|
|
}) async {
|
|||
|
|
try {
|
|||
|
|
// 仅当全局启用验证码时才把 captcha/uuid 填入请求体,
|
|||
|
|
// 关闭后登录界面不展示验证码,请求体也不携带这两个字段。
|
|||
|
|
final body = <String, dynamic>{
|
|||
|
|
'username': account,
|
|||
|
|
'password': password,
|
|||
|
|
};
|
|||
|
|
if (AppConfig.enableCaptcha) {
|
|||
|
|
body['captcha'] = captcha;
|
|||
|
|
body['uuid'] = uuid;
|
|||
|
|
}
|
|||
|
|
// 响应已被 ApiInterceptor 解包,data 即业务数据。
|
|||
|
|
final response = await _dio.post<dynamic>(
|
|||
|
|
'/sys/login',
|
|||
|
|
data: body,
|
|||
|
|
);
|
|||
|
|
final data = response.data;
|
|||
|
|
if (data is! Map<String, dynamic> || data['token'] == null) {
|
|||
|
|
throw const AuthException('登录响应格式异常');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
final token = data['token'].toString();
|
|||
|
|
TokenStore.instance.save(
|
|||
|
|
token: token,
|
|||
|
|
refreshToken: data['refreshToken']?.toString(),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// 尝试获取当前用户信息以补充显示名称、组织与角色 ID;失败不影响登录。
|
|||
|
|
final profile = await _tryFetchUserInfo(fallbackName: account);
|
|||
|
|
|
|||
|
|
// 登录响应中直接取 orgId/roleId;若不存在则使用 /sys/user/info 结果。
|
|||
|
|
final loginOrgId = data['orgId']?.toString();
|
|||
|
|
final loginRoleId = data['roleId']?.toString() ??
|
|||
|
|
(data['roles'] is List && (data['roles'] as List).isNotEmpty
|
|||
|
|
? (data['roles'] as List).first['roleId']?.toString()
|
|||
|
|
: null);
|
|||
|
|
final loginOrgType = data['orgType'];
|
|||
|
|
final loginSuperAdmin = data['superAdmin'];
|
|||
|
|
|
|||
|
|
return User(
|
|||
|
|
id: loginOrgId ?? account,
|
|||
|
|
account: account,
|
|||
|
|
displayName: profile.displayName,
|
|||
|
|
role: _joinRoleNames(data['roleNames']) ?? profile.role ?? '',
|
|||
|
|
token: token,
|
|||
|
|
orgId: loginOrgId ?? profile.orgId ?? '',
|
|||
|
|
roleId: loginRoleId ?? profile.roleId ?? '',
|
|||
|
|
orgType: loginOrgType is int
|
|||
|
|
? loginOrgType
|
|||
|
|
: int.tryParse(loginOrgType?.toString() ?? '') ??
|
|||
|
|
profile.orgType,
|
|||
|
|
superAdmin: loginSuperAdmin is bool
|
|||
|
|
? loginSuperAdmin
|
|||
|
|
: profile.superAdmin,
|
|||
|
|
);
|
|||
|
|
} on AuthException {
|
|||
|
|
rethrow;
|
|||
|
|
} on DioException catch (e) {
|
|||
|
|
throw AuthException(_errorMessage(e, fallback: '账号或密码错误'));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 登出:通知后端(失败忽略),并清空内存令牌。
|
|||
|
|
Future<void> logout() async {
|
|||
|
|
try {
|
|||
|
|
await _dio.post<dynamic>('/sys/logout');
|
|||
|
|
} catch (_) {
|
|||
|
|
// 登出接口失败不阻塞本地登出。
|
|||
|
|
} finally {
|
|||
|
|
TokenStore.instance.clear();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 拉取用户资料(GET /sys/user/info);失败时返回兜底值。
|
|||
|
|
Future<_UserProfile> _tryFetchUserInfo({required String fallbackName}) async {
|
|||
|
|
try {
|
|||
|
|
final response = await _dio.get<dynamic>('/sys/user/info');
|
|||
|
|
final data = response.data;
|
|||
|
|
if (data is Map<String, dynamic>) {
|
|||
|
|
final roleId = data['roleId']?.toString() ??
|
|||
|
|
_extractFirstRoleId(data['roles'] ?? data['role']);
|
|||
|
|
final orgId = data['orgId']?.toString() ??
|
|||
|
|
_extractNestedId(data['org'] ?? data['dept']);
|
|||
|
|
final rawOrgType = data['orgType'];
|
|||
|
|
final rawSuperAdmin = data['superAdmin'];
|
|||
|
|
return _UserProfile(
|
|||
|
|
displayName: (data['realName'] ??
|
|||
|
|
data['nickname'] ??
|
|||
|
|
data['username'] ??
|
|||
|
|
fallbackName)
|
|||
|
|
.toString(),
|
|||
|
|
role: _joinRoleNames(data['roleNames']),
|
|||
|
|
orgId: orgId,
|
|||
|
|
roleId: roleId,
|
|||
|
|
orgType: rawOrgType is int
|
|||
|
|
? rawOrgType
|
|||
|
|
: int.tryParse(rawOrgType?.toString() ?? '') ?? 0,
|
|||
|
|
superAdmin: rawSuperAdmin is bool ? rawSuperAdmin : false,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
} catch (_) {
|
|||
|
|
// 用户信息接口异常时使用兜底资料。
|
|||
|
|
}
|
|||
|
|
return _UserProfile(displayName: fallbackName, role: null);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 从角色对象或列表中提取第一个 roleId。
|
|||
|
|
static String? _extractFirstRoleId(dynamic value) {
|
|||
|
|
if (value is List && value.isNotEmpty) {
|
|||
|
|
return _extractFirstRoleId(value.first);
|
|||
|
|
}
|
|||
|
|
if (value is Map<String, dynamic>) {
|
|||
|
|
return value['roleId']?.toString() ?? value['id']?.toString();
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 从组织/部门对象中提取 ID。
|
|||
|
|
static String? _extractNestedId(dynamic value) {
|
|||
|
|
if (value is Map<String, dynamic>) {
|
|||
|
|
return value['orgId']?.toString() ??
|
|||
|
|
value['id']?.toString() ??
|
|||
|
|
value['deptId']?.toString();
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// roleNames 列表拼接为展示字符串。
|
|||
|
|
static String? _joinRoleNames(dynamic roleNames) {
|
|||
|
|
if (roleNames is List && roleNames.isNotEmpty) {
|
|||
|
|
return roleNames.map((e) => e.toString()).join('/');
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 从 Dio 异常中提取业务错误信息。
|
|||
|
|
static String _errorMessage(DioException e, {required String fallback}) {
|
|||
|
|
final error = e.error;
|
|||
|
|
if (error is ApiException && error.message.isNotEmpty) {
|
|||
|
|
return error.message;
|
|||
|
|
}
|
|||
|
|
if (e.type == DioExceptionType.connectionTimeout ||
|
|||
|
|
e.type == DioExceptionType.receiveTimeout ||
|
|||
|
|
e.type == DioExceptionType.connectionError) {
|
|||
|
|
return '网络异常,请检查网络后重试';
|
|||
|
|
}
|
|||
|
|
return fallback;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// mock 模式下的占位验证码图片:1x1 透明 PNG。
|
|||
|
|
static final List<int> _mockCaptchaBytes = base64Decode(
|
|||
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==',
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 用户资料中间结构(service 内部使用)。
|
|||
|
|
class _UserProfile {
|
|||
|
|
const _UserProfile({
|
|||
|
|
required this.displayName,
|
|||
|
|
this.role,
|
|||
|
|
this.orgId,
|
|||
|
|
this.roleId,
|
|||
|
|
this.orgType = 0,
|
|||
|
|
this.superAdmin = false,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
final String displayName;
|
|||
|
|
final String? role;
|
|||
|
|
final String? orgId;
|
|||
|
|
final String? roleId;
|
|||
|
|
final int orgType;
|
|||
|
|
final bool superAdmin;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 验证码数据。
|
|||
|
|
class Captcha {
|
|||
|
|
const Captcha({required this.uuid, required this.imageBytes});
|
|||
|
|
|
|||
|
|
/// 与该验证码图片绑定的 UUID,登录时必须原样回传。
|
|||
|
|
final String uuid;
|
|||
|
|
|
|||
|
|
/// 验证码 PNG/JPEG 图片字节。
|
|||
|
|
final List<int> imageBytes;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 认证异常。
|
|||
|
|
class AuthException implements Exception {
|
|||
|
|
const AuthException(this.message);
|
|||
|
|
|
|||
|
|
final String message;
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
String toString() => message;
|
|||
|
|
}
|