using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using FoodLabeling.Application.Contracts.Dtos.UsAppAuth;
using FoodLabeling.Application.Services.DbModels;
using FoodLabeling.Domain.Entities;
using FoodLabeling.Th.Application.MultiTenancy;
using FoodLabeling.Th.Application.Contracts;
using FoodLabeling.Th.Application.Contracts.Dtos.Auth;
using FoodLabeling.Th.Application.Contracts.IServices;
using Lazy.Captcha.Core;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using SqlSugar;
using Volo.Abp;
using Volo.Abp.Application.Services;
using Volo.Abp.MultiTenancy;
using Volo.Abp.Security.Claims;
using Yi.Framework.Rbac.Domain.Entities;
using Yi.Framework.Rbac.Domain.Helpers;
using Yi.Framework.Rbac.Domain.Managers;
using Yi.Framework.Rbac.Domain.Shared.Consts;
using Yi.Framework.Rbac.Domain.Shared.Options;
using Yi.Framework.SqlSugarCore.Abstractions;
namespace FoodLabeling.Th.Application.Services;
///
/// 泰额版 App 登录:解析租户后在租户独立库校验用户,JWT 写入 TenantId
///
public class ThAppAuthAppService : ApplicationService, IThAppAuthAppService
{
private readonly IAccountManager _accountManager;
private readonly ISqlSugarRepository _userRepository;
private readonly ISugarDbContextProvider _dbContextProvider;
private readonly TenantAdminAccountBootstrapper _adminAccountBootstrapper;
private readonly ICaptcha _captcha;
private readonly RbacOptions _rbacOptions;
private readonly JwtOptions _jwtOptions;
private readonly ITenantStore _tenantStore;
private readonly DbConnOptions _dbConnOptions;
public ThAppAuthAppService(
IAccountManager accountManager,
ISqlSugarRepository userRepository,
ISugarDbContextProvider dbContextProvider,
TenantAdminAccountBootstrapper adminAccountBootstrapper,
ICaptcha captcha,
IOptions jwtOptions,
IOptions rbacOptions,
ITenantStore tenantStore,
IOptions dbConnOptions)
{
_accountManager = accountManager;
_userRepository = userRepository;
_dbContextProvider = dbContextProvider;
_adminAccountBootstrapper = adminAccountBootstrapper;
_captcha = captcha;
_jwtOptions = jwtOptions.Value;
_rbacOptions = rbacOptions.Value;
_tenantStore = tenantStore;
_dbConnOptions = dbConnOptions.Value;
}
///
/// App 登录:校验租户后在租户业务库验证账号并签发 Token(Claim 含 TenantId)
///
///
/// 可空:与 th-web-auth/login 一致,按邮箱/用户名反查公司租户
/// (开通邮箱 fl_th_tenant_admin_credential 或各租户业务库 user 表)。
///
/// 示例(可不传 tenantId):
/// ```json
/// { "email": "mai@123.com", "password": "123456" }
/// ```
///
/// 登录参数
/// Token、RefreshToken、租户信息与绑定门店
/// 登录成功
/// 参数缺失、验证码错误或账号密码错误
/// 服务器错误
[AllowAnonymous]
[HttpPost("th-app-auth/login")]
public virtual async Task LoginAsync(ThAppLoginInputVo input)
{
if (string.IsNullOrWhiteSpace(input.Password)
|| string.IsNullOrWhiteSpace(input.Email))
{
throw new UserFriendlyException("请输入邮箱与密码!");
}
ValidationImageCaptcha(input.Uuid, input.Code);
var tenantId = await ResolveTenantIdForLoginAsync(input);
if (!tenantId.HasValue || tenantId.Value == Guid.Empty)
{
throw new UserFriendlyException("登录失败:未找到该邮箱对应的公司,请确认邮箱或联系管理员");
}
if (ThWebPlatformLoginHelper.IsBusinessTenantLogin(tenantId, null))
{
await EnsureNotHostPlatformAccountAsync(input.Email);
}
var tenantConfig = await TenantResolveHelper.ResolveTenantAsync(
_tenantStore, CurrentTenant, tenantId.Value);
// 与 th-web-auth 对齐:开通邮箱已在主库凭据中、但租户库仍为 Seed admin 时,登录前补同步
await EnsureTenantAdminLoginSyncedAsync(tenantId.Value, input.Email.Trim());
// 优先直连租户库查用户,避免 CurrentTenant.Change 后仓储仍连主库导致「邮箱不存在」
var user = await FindActiveUserByEmailInTenantDbAsync(tenantId.Value, input.Email.Trim())
?? throw new UserFriendlyException("登录失败!邮箱不存在!");
if (!UserPasswordHelper.VerifyPlainPassword(user, input.Password)
&& !user.JudgePassword(input.Password))
{
throw new UserFriendlyException(UserConst.Login_Error);
}
List locations;
List roleCodes;
using (CurrentTenant.Change(tenantId.Value, tenantConfig.Name))
{
locations = await LoadBoundLocationsAsync(user.Id);
roleCodes = await LoadUserRoleCodesAsync(user.Id);
}
var accessToken = CreateAppAccessToken(user, tenantId.Value, roleCodes);
var refreshToken = _accountManager.CreateRefreshToken(user.Id);
// 不发布 LoginEvent:事件在 UoW 完成时于「当前连接」写 LoginLog;泰额 App 校验在租户库,
// 完成阶段连接为主库 antis-foodlabeling-host,无 LoginLog 表会导致登录接口 500。
return new ThAppLoginOutputDto
{
Token = accessToken,
RefreshToken = refreshToken,
TenantId = tenantId.Value,
TenantName = tenantConfig.Name ?? string.Empty,
Locations = locations
};
}
///
[Authorize]
[HttpGet("th-app-auth/my-locations")]
public virtual async Task> GetMyLocationsAsync()
{
if (!CurrentUser.Id.HasValue)
{
throw new UserFriendlyException("用户未登录");
}
if (!CurrentTenant.Id.HasValue)
{
throw new UserFriendlyException("未识别租户,请重新登录或携带 __tenant");
}
using (CurrentTenant.Change(CurrentTenant.Id.Value, CurrentTenant.Name))
{
return await LoadBoundLocationsAsync(CurrentUser.Id.Value);
}
}
///
/// 解析登录租户:显式 tenantId 优先;否则按邮箱反查(与 th-web-auth 对齐)。
///
private async Task ResolveTenantIdForLoginAsync(ThAppLoginInputVo input)
{
if (input.TenantId.HasValue && input.TenantId.Value != Guid.Empty)
{
if (input.TenantId.Value == ThWebPlatformLoginHelper.DefaultTenantId)
{
return await ThLoginTenantResolver.TryResolveCompanyTenantIdByLoginAsync(
_dbConnOptions,
input.Email,
Logger);
}
return input.TenantId.Value;
}
return await ThLoginTenantResolver.TryResolveCompanyTenantIdByLoginAsync(
_dbConnOptions,
input.Email,
Logger);
}
///
/// 业务租户登录前校验:主库平台邮箱账号不得落入公司业务库。
///
private async Task EnsureNotHostPlatformAccountAsync(string email)
{
if (!ThWebPlatformLoginHelper.IsPlausiblePlatformEmail(email))
{
return;
}
using (CurrentTenant.Change(null))
{
var hostUser = await FindActiveUserByEmailAsync(email.Trim());
if (hostUser != null)
{
throw new UserFriendlyException(ThWebPlatformLoginHelper.PlatformAccountMustUseDefaultMessage);
}
}
}
///
/// 开通邮箱已写入主库凭据、但业务库仍为 Seed 的 admin 时,登录前补同步一次(与 th-web-auth 一致)。
///
private async Task EnsureTenantAdminLoginSyncedAsync(Guid tenantId, string loginAccount)
{
if (!ThWebPlatformLoginHelper.IsPlausiblePlatformEmail(loginAccount))
{
return;
}
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)
{
Logger.LogWarning(ex, "租户 {TenantId} App 登录前探测用户失败,尝试同步开通邮箱", tenantId);
}
try
{
await _adminAccountBootstrapper.ApplyLoginEmailAsync(tenantId);
await _adminAccountBootstrapper.EnsureTenantAdminRoleAsync(tenantId);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "租户 {TenantId} App 登录前同步管理员邮箱失败", tenantId);
}
}
private void ValidationImageCaptcha(string? uuid, string? code)
{
if (!_rbacOptions.EnableCaptcha)
{
return;
}
if (!_captcha.Validate(uuid, code))
{
throw new UserFriendlyException("验证码错误");
}
}
private async Task FindActiveUserByEmailAsync(string email)
{
var normalized = email.Trim().ToLowerInvariant();
// Change 租户后必须用 Provider 取当前库连接;仓储可能仍缓存主库连接
var db = (await _dbContextProvider.GetDbContextAsync()).SqlSugarClient;
var users = await db.Queryable()
.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();
}
///
/// 直连租户业务库查用户(绕过 CurrentTenant 连接切换不稳定问题)。
///
private async Task FindActiveUserByEmailInTenantDbAsync(Guid tenantId, string email)
{
var tenant = await ThTenantHostDataAccessor.LoadTenantAsync(_dbConnOptions, tenantId);
if (tenant is null || string.IsNullOrWhiteSpace(tenant.TenantConnectionString))
{
return null;
}
var dbType = tenant.DbType == default
? (_dbConnOptions.DbType ?? SqlSugar.DbType.MySql)
: tenant.DbType;
using var tenantDb = TenantBusinessDatabaseAccessor.CreateClient(
tenant.TenantConnectionString.Trim(),
dbType);
tenantDb.Ado.CommandTimeOut = 15;
var normalized = email.Trim().ToLowerInvariant();
var users = await tenantDb.Queryable()
.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();
}
private string CreateAppAccessToken(UserAggregateRoot user, Guid tenantId, IReadOnlyList roleCodes)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.SecurityKey));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var tenantIdStr = tenantId.ToString();
var claims = new List
{
new(AbpClaimTypes.UserId, user.Id.ToString()),
new(AbpClaimTypes.UserName, user.UserName),
new(AbpClaimTypes.TenantId, tenantIdStr),
new(TokenTypeConst.TenantId, tenantIdStr),
new(ThAppJwtClaims.ClientKind, ThAppJwtClaims.ClientKindThApp)
};
if (!string.IsNullOrWhiteSpace(user.Email))
{
claims.Add(new Claim(AbpClaimTypes.Email, user.Email));
}
var isAdmin = roleCodes.Any(r =>
string.Equals(r, UserConst.AdminRolesCode, StringComparison.OrdinalIgnoreCase));
if (isAdmin || string.Equals(user.UserName, UserConst.Admin, StringComparison.OrdinalIgnoreCase))
{
claims.Add(new Claim(TokenTypeConst.Permission, UserConst.AdminPermissionCode));
claims.Add(new Claim(TokenTypeConst.Roles, UserConst.AdminRolesCode));
}
else
{
foreach (var role in roleCodes.Where(r => !string.IsNullOrWhiteSpace(r)).Distinct(StringComparer.OrdinalIgnoreCase))
{
claims.Add(new Claim(AbpClaimTypes.Role, role));
}
}
var token = new JwtSecurityToken(
_jwtOptions.Issuer,
_jwtOptions.Audience,
claims,
expires: DateTime.Now.AddMinutes(_jwtOptions.ExpiresMinuteTime),
notBefore: DateTime.Now,
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
private async Task> LoadUserRoleCodesAsync(Guid userId)
{
var db = (await _dbContextProvider.GetDbContextAsync()).SqlSugarClient;
var rows = await db.Ado.SqlQueryAsync(
"""
SELECT r.RoleCode
FROM UserRole ur
INNER JOIN Role r ON ur.RoleId = r.Id
WHERE ur.UserId = @UserId AND r.IsDeleted = 0 AND r.State = 1
""",
new { UserId = userId });
return rows
.Select(r => r.RoleCode?.Trim())
.Where(r => !string.IsNullOrWhiteSpace(r))
.Cast()
.ToList();
}
private sealed class RoleCodeRow
{
public string? RoleCode { get; set; }
}
private async Task> LoadBoundLocationsAsync(Guid userId)
{
var db = (await _dbContextProvider.GetDbContextAsync()).SqlSugarClient;
var userIdStr = userId.ToString();
var links = await db.Queryable()
.Where(x => !x.IsDeleted && x.UserId == userIdStr)
.Select(x => x.LocationId)
.ToListAsync();
if (links.Count == 0)
{
return new List();
}
var wanted = links.Distinct().ToList();
var locations = (await db.Queryable()
.Where(x => !x.IsDeleted)
.Where(x => wanted.Contains(x.Id.ToString()))
.ToListAsync())
.OrderBy(x => x.OrderNum)
.ThenBy(x => x.LocationName)
.ToList();
return locations.Select(x => new UsAppBoundLocationDto
{
Id = x.Id.ToString(),
LocationCode = x.LocationCode ?? string.Empty,
LocationName = x.LocationName ?? string.Empty,
FullAddress = BuildFullAddress(x),
State = x.State
}).ToList();
}
private static string BuildFullAddress(LocationAggregateRoot loc)
{
var street = loc.Street?.Trim();
var city = loc.City?.Trim();
var state = loc.StateCode?.Trim();
var zip = loc.ZipCode?.Trim();
var line2Parts = new List();
if (!string.IsNullOrEmpty(city))
{
line2Parts.Add(city);
}
if (!string.IsNullOrEmpty(state))
{
line2Parts.Add(state);
}
if (!string.IsNullOrEmpty(zip))
{
line2Parts.Add(zip);
}
var line2 = string.Join(", ", line2Parts);
if (string.IsNullOrEmpty(street))
{
return line2;
}
return string.IsNullOrEmpty(line2) ? street : $"{street}, {line2}";
}
}