UsAppAuthAppService.cs 8.97 KB
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
using FoodLabeling.Application.Contracts.Dtos.UsAppAuth;
using FoodLabeling.Application.Contracts.IServices;
using FoodLabeling.Application.Services.DbModels;
using FoodLabeling.Domain.Entities;
using Lazy.Captcha.Core;
using Mapster;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using SqlSugar;
using Volo.Abp;
using Volo.Abp.Application.Services;
using Volo.Abp.EventBus.Local;
using Volo.Abp.Security.Claims;
using Volo.Abp.Users;
using Yi.Framework.Core.Helper;
using Yi.Framework.Rbac.Domain.Entities;
using Yi.Framework.Rbac.Domain.Managers;
using Yi.Framework.Rbac.Domain.Shared.Consts;
using Yi.Framework.Rbac.Domain.Shared.Dtos;
using Yi.Framework.Rbac.Domain.Shared.Etos;
using Yi.Framework.Rbac.Domain.Shared.Options;
using Yi.Framework.SqlSugarCore.Abstractions;

namespace FoodLabeling.Application.Services;

/// <summary>
/// 美国版 App 登录:邮箱 + 密码(与 AccountManager 相同盐值哈希)签发 JWT,并返回 userlocation 绑定门店
/// </summary>
public class UsAppAuthAppService : ApplicationService, IUsAppAuthAppService
{
    private readonly IAccountManager _accountManager;
    private readonly ISqlSugarRepository<UserAggregateRoot, Guid> _userRepository;
    private readonly ISqlSugarDbContext _dbContext;
    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly ICaptcha _captcha;
    private readonly RbacOptions _rbacOptions;
    private readonly JwtOptions _jwtOptions;

    public UsAppAuthAppService(
        IAccountManager accountManager,
        ISqlSugarRepository<UserAggregateRoot, Guid> userRepository,
        ISqlSugarDbContext dbContext,
        IHttpContextAccessor httpContextAccessor,
        ICaptcha captcha,
        IOptions<JwtOptions> jwtOptions,
        IOptions<RbacOptions> rbacOptions)
    {
        _accountManager = accountManager;
        _userRepository = userRepository;
        _dbContext = dbContext;
        _httpContextAccessor = httpContextAccessor;
        _captcha = captcha;
        _jwtOptions = jwtOptions.Value;
        _rbacOptions = rbacOptions.Value;
    }

    protected ILocalEventBus LocalEventBus => LazyServiceProvider.LazyGetRequiredService<ILocalEventBus>();

    /// <summary>
    /// App 登录:签发 Token / RefreshToken,并返回当前账号绑定的门店列表
    /// </summary>
    /// <remarks>
    /// 行为与系统 <c>AccountService.PostLoginAsync</c> 一致(含验证码、登录日志事件)。
    /// 门店数据来自 <c>userlocation</c> 与 <c>location</c> 表。
    /// </remarks>
    /// <param name="input">邮箱、密码;若系统开启验证码则需传 Uuid、Code</param>
    /// <returns>Token、RefreshToken 与绑定门店</returns>
    /// <response code="200">登录成功</response>
    /// <response code="400">参数或验证码错误</response>
    /// <response code="500">服务器错误</response>
    [AllowAnonymous]
    public virtual async Task<UsAppLoginOutputDto> LoginAsync(UsAppLoginInputVo input)
    {
        if (string.IsNullOrWhiteSpace(input.Password) || string.IsNullOrWhiteSpace(input.Email))
        {
            throw new UserFriendlyException("请输入合理数据!");
        }

        ValidationImageCaptcha(input.Uuid, input.Code);

        var user = await FindActiveUserByEmailAsync(input.Email.Trim());
        if (user is null)
        {
            throw new UserFriendlyException("登录失败!邮箱不存在!");
        }

        if (user.EncryPassword.Password != MD5Helper.SHA2Encode(input.Password, user.EncryPassword.Salt))
        {
            throw new UserFriendlyException(UserConst.Login_Error);
        }

        // App 端不依赖 RBAC 权限体系:允许“无权限账号”登录拿 Token(H5 再做权限控制)
        var accessToken = CreateAppAccessToken(user);
        var refreshToken = _accountManager.CreateRefreshToken(user.Id);

        if (_httpContextAccessor.HttpContext is not null)
        {
            var loginEntity = new LoginLogAggregateRoot().GetInfoByHttpContext(_httpContextAccessor.HttpContext);
            var loginEto = loginEntity.Adapt<LoginEventArgs>();
            loginEto.UserName = user.UserName;
            loginEto.UserId = user.Id;
            await LocalEventBus.PublishAsync(loginEto);
        }

        var locations = await LoadBoundLocationsAsync(user.Id);

        return new UsAppLoginOutputDto
        {
            Token = accessToken,
            RefreshToken = refreshToken,
            Locations = locations
        };
    }

    /// <summary>
    /// 获取当前登录用户已绑定的门店(切换门店时可重新拉取)
    /// </summary>
    [Authorize]
    public virtual async Task<List<UsAppBoundLocationDto>> GetMyLocationsAsync()
    {
        if (!CurrentUser.Id.HasValue)
        {
            throw new UserFriendlyException("用户未登录");
        }

        return await LoadBoundLocationsAsync(CurrentUser.Id.Value);
    }

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

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

    /// <summary>
    /// 按邮箱查找未删除且启用的用户(邮箱比较忽略大小写)
    /// </summary>
    private async Task<UserAggregateRoot?> FindActiveUserByEmailAsync(string email)
    {
        var normalized = email.Trim().ToLowerInvariant();
        var users = await _userRepository._DbQueryable
            .Where(u => !u.IsDeleted && u.State == true)
            .Where(u => u.Email != null && SqlFunc.ToLower(u.Email) == normalized)
            .ToListAsync();
        return users.FirstOrDefault();
    }

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

        var claims = new List<Claim>
        {
            new(AbpClaimTypes.UserId, user.Id.ToString()),
            new(AbpClaimTypes.UserName, user.UserName)
        };

        if (!string.IsNullOrWhiteSpace(user.Email))
        {
            claims.Add(new Claim(AbpClaimTypes.Email, user.Email));
        }

        var token = new JwtSecurityToken(
            issuer: _jwtOptions.Issuer,
            audience: _jwtOptions.Audience,
            claims: 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 userIdStr = userId.ToString();
        var links = await _dbContext.SqlSugarClient.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 _dbContext.SqlSugarClient.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);
        }

        var line2 = line2Parts.Count > 0 ? string.Join(", ", line2Parts) : string.Empty;
        if (!string.IsNullOrEmpty(zip))
        {
            line2 = string.IsNullOrEmpty(line2) ? zip : $"{line2} {zip}";
        }

        var segments = new List<string>();
        if (!string.IsNullOrEmpty(street))
        {
            segments.Add(street);
        }

        if (!string.IsNullOrEmpty(line2))
        {
            segments.Add(line2);
        }

        return segments.Count == 0 ? "无" : string.Join(", ", segments);
    }
}