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 fetchCaptcha() async { final uuid = generateUuid(); // mock 模式直接返回占位图,不发起真实请求。 if (AppConstants.useMock) { return Captcha(uuid: uuid, imageBytes: _mockCaptchaBytes); } final response = await _rawDio.get>( '/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 login({ required String account, required String password, String captcha = '', String uuid = '', }) async { try { // 仅当全局启用验证码时才把 captcha/uuid 填入请求体, // 关闭后登录界面不展示验证码,请求体也不携带这两个字段。 final body = { 'username': account, 'password': password, }; if (AppConfig.enableCaptcha) { body['captcha'] = captcha; body['uuid'] = uuid; } // 响应已被 ApiInterceptor 解包,data 即业务数据。 final response = await _dio.post( '/sys/login', data: body, ); final data = response.data; if (data is! Map || 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 logout() async { try { await _dio.post('/sys/logout'); } catch (_) { // 登出接口失败不阻塞本地登出。 } finally { TokenStore.instance.clear(); } } /// 拉取用户资料(GET /sys/user/info);失败时返回兜底值。 Future<_UserProfile> _tryFetchUserInfo({required String fallbackName}) async { try { final response = await _dio.get('/sys/user/info'); final data = response.data; if (data is Map) { 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) { return value['roleId']?.toString() ?? value['id']?.toString(); } return null; } /// 从组织/部门对象中提取 ID。 static String? _extractNestedId(dynamic value) { if (value is Map) { 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 _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 imageBytes; } /// 认证异常。 class AuthException implements Exception { const AuthException(this.message); final String message; @override String toString() => message; }