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

265 lines
8.4 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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