using FoodLabeling.Th.Application.Contracts.Dtos.Auth; using FoodLabeling.Th.Application.Contracts.IServices; using FoodLabeling.Th.Application.MultiTenancy; using Lazy.Captcha.Core; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SqlSugar; using Volo.Abp; using Volo.Abp.Application.Services; using Volo.Abp.MultiTenancy; using Yi.Framework.Rbac.Domain.Entities; using Yi.Framework.Rbac.Domain.Managers; using Yi.Framework.Rbac.Domain.Shared.Options; using Yi.Framework.SqlSugarCore.Abstractions; using Yi.Framework.TenantManagement.Application.Contracts; namespace FoodLabeling.Th.Application.Services; /// /// 泰额版 Web 管理端登录:租户库校验 + RBAC 全量 Token(含 TenantId Claim) /// public class ThWebAuthAppService : ApplicationService, IThWebAuthAppService { private readonly IAccountManager _accountManager; private readonly ISqlSugarRepository _userRepository; private readonly TenantAdminAccountBootstrapper _adminAccountBootstrapper; private readonly IThTenantDatabaseBackgroundInitializer _backgroundInitializer; private readonly ITenantService _tenantService; private readonly ICaptcha _captcha; private readonly RbacOptions _rbacOptions; private readonly DbConnOptions _dbConnOptions; private readonly ITenantStore _tenantStore; public ThWebAuthAppService( IAccountManager accountManager, ISqlSugarRepository userRepository, TenantAdminAccountBootstrapper adminAccountBootstrapper, IThTenantDatabaseBackgroundInitializer backgroundInitializer, ITenantService tenantService, ICaptcha captcha, IOptions rbacOptions, IOptions dbConnOptions, ITenantStore tenantStore) { _accountManager = accountManager; _userRepository = userRepository; _adminAccountBootstrapper = adminAccountBootstrapper; _backgroundInitializer = backgroundInitializer; _tenantService = tenantService; _captcha = captcha; _rbacOptions = rbacOptions.Value; _dbConnOptions = dbConnOptions.Value; _tenantStore = tenantStore; } /// /// Web 管理端登录 /// /// /// 校验租户后在租户独立库验证账号,签发含 TenantId 与 RBAC 权限的 JWT。 /// /// 泰额 H5 与公司 Web 共用此接口。选 **Default** 或 tenantId 为空时: /// 1) 主库平台邮箱优先走平台登录; /// 2) 否则按开通邮箱(fl_th_tenant_admin_credential)或各租户业务库 user 表反查公司租户,支持 Team Member 不选公司、仅邮箱/用户名密码登录; /// 3) 再回落 Default 业务库。 /// 选具体公司 tenantId 时在该公司业务库校验;若登录标识为邮箱且已存在于主库(平台管理员邮箱),则拒绝并提示改用 Default。 /// /// 示例请求(公司管理员,可不传 tenantId): /// ```json /// { /// "userName": "mai@123.com", /// "password": "123456" /// } /// ``` /// /// 参数说明: /// - tenantId: 可选,租户 Guid(与 tenantName 二选一;均可空,邮箱可反查公司) /// - tenantName: 可选,租户名称 /// - userName: 登录账号或邮箱 /// - password: 明文密码 /// /// 登录参数 /// Token 与租户信息 /// 登录成功 /// 参数缺失、验证码错误或账号密码错误 /// 服务器错误 [AllowAnonymous] [HttpPost("th-web-auth/login")] public virtual async Task LoginAsync(ThWebLoginInputVo input) { if (string.IsNullOrWhiteSpace(input.Password) || string.IsNullOrWhiteSpace(input.UserName)) { throw new UserFriendlyException("请输入账号与密码"); } ValidationImageCaptcha(input.Uuid, input.Code); // H5 登录页选 Default / 空 tenantId:优先主库平台账号;否则按开通邮箱反查公司 if (ThWebPlatformLoginHelper.ShouldTryPlatformLogin(input.TenantId, input.TenantName)) { var platformLogin = await TryPlatformLoginAsync(input); if (platformLogin != null) { return platformLogin; } var companyTenantId = await ThLoginTenantResolver.TryResolveCompanyTenantIdByLoginAsync( _dbConnOptions, input.UserName, Logger); if (companyTenantId.HasValue) { input.TenantId = companyTenantId; input.TenantName = null; } else if (!string.IsNullOrWhiteSpace(input.TenantName) && !string.Equals( input.TenantName.Trim(), ThWebPlatformLoginHelper.DefaultTenantName, StringComparison.OrdinalIgnoreCase)) { // 仅传了公司名称、未传 Id:按名称解析,勿强行落到 Default input.TenantId = null; } else if (!input.TenantId.HasValue || input.TenantId.Value == Guid.Empty) { input.TenantId = ThWebPlatformLoginHelper.DefaultTenantId; } } else { await EnsureNotHostPlatformAccountAsync(input.UserName); } TenantConfiguration tenantConfig; try { tenantConfig = await TenantResolveHelper.ResolveTenantAsync( _tenantStore, CurrentTenant, input.TenantId, input.TenantName); } catch (UserFriendlyException) { throw; } catch (Exception ex) { Logger.LogWarning(ex, "解析租户失败"); throw new UserFriendlyException($"租户解析失败:{ex.GetBaseException().Message}"); } var tenantId = tenantConfig.Id; try { // 开通后后台 Seed/建表可能尚未完成;登录前确保业务库就绪并补齐开通邮箱 await EnsureTenantBusinessDatabaseReadyAsync(tenantId); await EnsureTenantAdminLoginSyncedAsync(tenantId, input.UserName.Trim()); using (CurrentTenant.Change(tenantId, tenantConfig.Name)) { var user = await FindActiveUserByEmailAsync(input.UserName.Trim()); if (user == null) { var initMessage = await ResolveTenantNotReadyLoginMessageAsync(tenantId); throw new UserFriendlyException( initMessage ?? "登录失败:账号不存在或已禁用"); } if (!user.JudgePassword(input.Password)) { throw new UserFriendlyException("登录失败:账号或密码错误"); } var accessToken = await _accountManager.GetTokenByUserIdAsync(user.Id); var refreshToken = _accountManager.CreateRefreshToken(user.Id); return new ThWebLoginOutputDto { Token = accessToken, RefreshToken = refreshToken, TenantId = tenantId, TenantName = tenantConfig.Name ?? string.Empty }; } } catch (UserFriendlyException) { throw; } catch (Exception ex) { Logger.LogError(ex, "租户 {TenantId}/{TenantName} Web 登录异常", tenantId, tenantConfig.Name); if (IsTenantDatabaseNotReady(ex)) { throw new UserFriendlyException( await ResolveTenantNotReadyLoginMessageAsync(tenantId) ?? "登录失败:租户业务库尚未初始化完成,请稍候再试"); } throw new UserFriendlyException( "登录失败:租户业务库初始化异常,请联系管理员处理"); } } /// /// 业务租户登录前校验:主库平台邮箱账号不得落入公司业务库。 /// 仅拦截邮箱形态(与 TryPlatformLoginAsync 一致);租户默认 UserName=admin 与主库 admin 同名时允许走租户库。 /// private async Task EnsureNotHostPlatformAccountAsync(string userName) { if (string.IsNullOrWhiteSpace(userName)) { return; } // 租户管理员默认账号常为 admin,与主库平台 UserName 同名;不可按用户名拦截 if (!ThWebPlatformLoginHelper.IsPlausiblePlatformEmail(userName)) { return; } using (CurrentTenant.Change(null)) { var hostUser = await FindActiveUserByEmailAsync(userName.Trim()); if (hostUser != null) { throw new UserFriendlyException(ThWebPlatformLoginHelper.PlatformAccountMustUseDefaultMessage); } } } /// /// 主库平台登录:CurrentTenant 为空签发无 TenantId 的 JWT,供 Vue3Router/vben5 使用。 /// 主库无对应邮箱用户时返回 null,由调用方回落 Default/公司租户库登录。 /// private async Task TryPlatformLoginAsync(ThWebLoginInputVo input) { if (!ThWebPlatformLoginHelper.IsPlausiblePlatformEmail(input.UserName)) { return null; } using (CurrentTenant.Change(null)) { var user = await FindActiveUserByEmailAsync(input.UserName.Trim()); if (user == null) { return null; } if (!user.JudgePassword(input.Password)) { throw new UserFriendlyException("登录失败:账号或密码错误"); } var accessToken = await _accountManager.GetTokenByUserIdAsync(user.Id); var refreshToken = _accountManager.CreateRefreshToken(user.Id); return new ThWebLoginOutputDto { Token = accessToken, RefreshToken = refreshToken, TenantId = Guid.Empty, TenantName = ThWebPlatformLoginHelper.PlatformTenantDisplayName }; } } private void ValidationImageCaptcha(string? uuid, string? code) { if (!_rbacOptions.EnableCaptcha) { return; } if (!_captcha.Validate(uuid, code)) { throw new UserFriendlyException("验证码错误或已过期"); } } /// /// 开通后后台建表未完成时,登录会报 User 表不存在;此处探测并补初始化。 /// 后台任务进行中但 User 已可查时允许登录,避免误报「正在初始化」。 /// private async Task EnsureTenantBusinessDatabaseReadyAsync(Guid tenantId) { try { using (CurrentTenant.Change(tenantId)) { await _userRepository._DbQueryable.Take(1).Select(u => u.Id).ToListAsync(); } return; } catch (Exception ex) when (IsTenantDatabaseNotReady(ex)) { if (_backgroundInitializer.IsRunning(tenantId)) { throw new UserFriendlyException("登录失败:租户业务库正在初始化,请稍候再试"); } Logger.LogWarning(ex, "租户 {TenantId} 业务库未就绪,登录前同步初始化", tenantId); await _tenantService.InitAsync(tenantId); await _adminAccountBootstrapper.ApplyLoginEmailAsync(tenantId); await _adminAccountBootstrapper.EnsureTenantAdminRoleAsync(tenantId); } } /// /// 开通邮箱已写入主库凭据、但业务库仍为 Seed 的 admin 时,登录前补同步一次。 /// private async Task EnsureTenantAdminLoginSyncedAsync(Guid tenantId, string loginAccount) { if (!ThWebPlatformLoginHelper.IsPlausiblePlatformEmail(loginAccount)) { return; } var normalized = loginAccount.Trim().ToLowerInvariant(); var credentialAccount = await _adminAccountBootstrapper.GetProvisionedLoginAccountAsync(tenantId); if (string.IsNullOrWhiteSpace(credentialAccount) || !string.Equals(credentialAccount, loginAccount.Trim(), StringComparison.OrdinalIgnoreCase)) { return; } try { using (CurrentTenant.Change(tenantId)) { var existing = await FindActiveUserByEmailAsync(loginAccount); if (existing != null) { await _adminAccountBootstrapper.EnsureTenantAdminRoleAsync(tenantId); return; } } } catch (Exception ex) when (IsTenantDatabaseNotReady(ex)) { Logger.LogWarning(ex, "租户 {TenantId} 查询用户失败(库未就绪),跳过邮箱探测", tenantId); } try { await _adminAccountBootstrapper.ApplyLoginEmailAsync(tenantId); await _adminAccountBootstrapper.EnsureTenantAdminRoleAsync(tenantId); } catch (Exception ex) { Logger.LogWarning( ex, "租户 {TenantId} 登录前同步管理员邮箱 {LoginAccount} 失败", tenantId, normalized); } } /// /// 租户库未就绪 / 初始化中时返回明确文案;账号确实不存在则返回 null。 /// private async Task ResolveTenantNotReadyLoginMessageAsync(Guid tenantId) { if (_backgroundInitializer.IsRunning(tenantId)) { return "登录失败:租户业务库正在初始化,请稍候再试"; } try { using (CurrentTenant.Change(tenantId)) { var activeUserCount = await _userRepository._DbQueryable .CountAsync(u => !u.IsDeleted); if (activeUserCount == 0) { return "登录失败:租户业务库尚未初始化完成,请稍候再试"; } } } catch (Exception ex) when (IsTenantDatabaseNotReady(ex)) { return "登录失败:租户业务库尚未初始化完成,请稍候再试"; } return null; } private static bool IsTenantDatabaseNotReady(Exception ex) { var message = ex.GetBaseException().Message; return message.Contains("doesn't exist", StringComparison.OrdinalIgnoreCase) || message.Contains("Unknown database", StringComparison.OrdinalIgnoreCase) || message.Contains("Unknown Database", StringComparison.OrdinalIgnoreCase) || message.Contains("Unknown table", StringComparison.OrdinalIgnoreCase) || (message.Contains("Table", StringComparison.OrdinalIgnoreCase) && message.Contains("exist", StringComparison.OrdinalIgnoreCase)); } private async Task FindActiveUserByEmailAsync(string email) { var normalized = email.Trim().ToLowerInvariant(); var users = await _userRepository._DbQueryable .Where(u => !u.IsDeleted && u.State) .Where(u => (u.Email != null && SqlFunc.ToLower(u.Email) == normalized) || SqlFunc.ToLower(u.UserName) == normalized) .ToListAsync(); return users.FirstOrDefault(u => u.Email != null && string.Equals(u.Email.Trim(), normalized, StringComparison.OrdinalIgnoreCase)) ?? users.FirstOrDefault(); } }