ThAppAuthAppService.cs 9.15 KB
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.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.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;

/// <summary>
/// 泰额版 App 登录:先解析租户,再在租户独立库校验用户,JWT 写入 TenantId
/// </summary>
public class ThAppAuthAppService : ApplicationService, IThAppAuthAppService
{
    private readonly IAccountManager _accountManager;
    private readonly ISqlSugarRepository<UserAggregateRoot, Guid> _userRepository;
    private readonly ISugarDbContextProvider<ISqlSugarDbContext> _dbContextProvider;
    private readonly ICaptcha _captcha;
    private readonly RbacOptions _rbacOptions;
    private readonly JwtOptions _jwtOptions;
    private readonly ITenantStore _tenantStore;

    public ThAppAuthAppService(
        IAccountManager accountManager,
        ISqlSugarRepository<UserAggregateRoot, Guid> userRepository,
        ISugarDbContextProvider<ISqlSugarDbContext> dbContextProvider,
        ICaptcha captcha,
        IOptions<JwtOptions> jwtOptions,
        IOptions<RbacOptions> rbacOptions,
        ITenantStore tenantStore)
    {
        _accountManager = accountManager;
        _userRepository = userRepository;
        _dbContextProvider = dbContextProvider;
        _captcha = captcha;
        _jwtOptions = jwtOptions.Value;
        _rbacOptions = rbacOptions.Value;
        _tenantStore = tenantStore;
    }

    /// <inheritdoc />
    [AllowAnonymous]
    [HttpPost("th-app-auth/login")]
    public virtual async Task<ThAppLoginOutputDto> LoginAsync(ThAppLoginInputVo input)
    {
        if (input.TenantId == Guid.Empty
            || string.IsNullOrWhiteSpace(input.Password)
            || string.IsNullOrWhiteSpace(input.Email))
        {
            throw new UserFriendlyException("请输入租户、邮箱与密码!");
        }

        ValidationImageCaptcha(input.Uuid, input.Code);

        var tenantConfig = await ResolveTenantAsync(input.TenantId);

        UserAggregateRoot user;
        List<UsAppBoundLocationDto> locations;
        using (CurrentTenant.Change(input.TenantId, tenantConfig.Name))
        {
            user = await FindActiveUserByEmailAsync(input.Email.Trim())
                   ?? throw new UserFriendlyException("登录失败!邮箱不存在!");
            if (!UserPasswordHelper.VerifyPlainPassword(user, input.Password))
            {
                throw new UserFriendlyException(UserConst.Login_Error);
            }

            locations = await LoadBoundLocationsAsync(user.Id);
        }

        var accessToken = CreateAppAccessToken(user, input.TenantId);
        var refreshToken = _accountManager.CreateRefreshToken(user.Id);

        // 不发布 LoginEvent:事件在 UoW 完成时于「当前连接」写 LoginLog;泰额 App 校验在租户库,
        // 完成阶段连接为主库 antis-foodlabeling-host,无 LoginLog 表会导致登录接口 500。

        return new ThAppLoginOutputDto
        {
            Token = accessToken,
            RefreshToken = refreshToken,
            TenantId = input.TenantId,
            TenantName = tenantConfig.Name ?? string.Empty,
            Locations = locations
        };
    }

    /// <inheritdoc />
    [Authorize]
    [HttpGet("th-app-auth/my-locations")]
    public virtual async Task<List<UsAppBoundLocationDto>> 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);
        }
    }

    private async Task<TenantConfiguration> ResolveTenantAsync(Guid tenantId)
    {
        TenantConfiguration? config;
        using (CurrentTenant.Change(null))
        {
            config = await _tenantStore.FindAsync(tenantId);
        }

        if (config is null)
        {
            throw new UserFriendlyException("租户不存在或已停用");
        }

        if (string.IsNullOrWhiteSpace(config.ConnectionStrings?.Default))
        {
            throw new UserFriendlyException("租户未配置业务库连接串");
        }

        return config;
    }

    private void ValidationImageCaptcha(string? uuid, string? code)
    {
        if (!_rbacOptions.EnableCaptcha)
        {
            return;
        }

        if (!_captcha.Validate(uuid, code))
        {
            throw new UserFriendlyException("验证码错误");
        }
    }

    private async Task<UserAggregateRoot?> 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();
    }

    private string CreateAppAccessToken(UserAggregateRoot user, Guid tenantId)
    {
        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.SecurityKey));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var tenantIdStr = tenantId.ToString();
        var claims = new List<Claim>
        {
            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 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<List<UsAppBoundLocationDto>> LoadBoundLocationsAsync(Guid userId)
    {
        var db = (await _dbContextProvider.GetDbContextAsync()).SqlSugarClient;
        var userIdStr = userId.ToString();
        var links = await db.Queryable<UserLocationDbEntity>()
            .Where(x => !x.IsDeleted && x.UserId == userIdStr)
            .Select(x => x.LocationId)
            .ToListAsync();

        if (links.Count == 0)
        {
            return new List<UsAppBoundLocationDto>();
        }

        var wanted = links.Distinct().ToList();
        var locations = (await db.Queryable<LocationAggregateRoot>()
                .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<string>();
        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}";
    }
}