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