Files
CloudHome-Ops/lib/services/auth_service.dart

265 lines
8.4 KiB
Dart
Raw Permalink Normal View History

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;
}