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