using System; using System.Collections.Generic; using System.Globalization; using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Security.Claims; using System.Text; using System.Threading.Tasks; using FoodLabeling.Application.Contracts; using FoodLabeling.Application.Contracts.Dtos.AuthScope; using FoodLabeling.Application.Contracts.Dtos.UsAppAuth; using FoodLabeling.Application.Contracts.IServices; using FoodLabeling.Application.Helpers; using FoodLabeling.Application.Services.DbModels; using Microsoft.Extensions.Caching.Distributed; using FoodLabeling.Domain.Entities; using Lazy.Captcha.Core; using Mapster; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; 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.EventBus.Local; using Volo.Abp.Security.Claims; using Volo.Abp.Uow; using Volo.Abp.Users; using Yi.Framework.Rbac.Application.Contracts.Dtos.Account; using Yi.Framework.Rbac.Application.Contracts.IServices; 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.Dtos; using Yi.Framework.Rbac.Domain.Shared.Etos; using Yi.Framework.Rbac.Domain.Shared.Options; using Yi.Framework.SqlSugarCore.Abstractions; namespace FoodLabeling.Application.Services; /// /// 美国版 App 登录:邮箱 + 密码(与 AccountManager 相同盐值哈希)签发 JWT,并返回 userlocation 绑定门店 /// public class UsAppAuthAppService : ApplicationService, IUsAppAuthAppService { private readonly IAccountManager _accountManager; private readonly ISqlSugarRepository _userRepository; private readonly ISqlSugarDbContext _dbContext; private readonly IHttpContextAccessor _httpContextAccessor; private readonly ICaptcha _captcha; private readonly RbacOptions _rbacOptions; private readonly JwtOptions _jwtOptions; private readonly IForgotPasswordByEmailService _forgotPasswordByEmailService; private readonly IDistributedCache _distributedCache; public UsAppAuthAppService( IAccountManager accountManager, ISqlSugarRepository userRepository, ISqlSugarDbContext dbContext, IHttpContextAccessor httpContextAccessor, ICaptcha captcha, IOptions jwtOptions, IOptions rbacOptions, IForgotPasswordByEmailService forgotPasswordByEmailService, IDistributedCache distributedCache) { _accountManager = accountManager; _userRepository = userRepository; _dbContext = dbContext; _httpContextAccessor = httpContextAccessor; _captcha = captcha; _jwtOptions = jwtOptions.Value; _rbacOptions = rbacOptions.Value; _forgotPasswordByEmailService = forgotPasswordByEmailService; _distributedCache = distributedCache; } protected ILocalEventBus LocalEventBus => LazyServiceProvider.LazyGetRequiredService(); /// /// App 登录:签发 Token / RefreshToken,并返回当前账号绑定的门店列表 /// /// /// 行为与系统 AccountService.PostLoginAsync 一致(含验证码、登录日志事件)。 /// 门店数据来自 userlocationlocation 表。 /// /// 邮箱、密码;若系统开启验证码则需传 Uuid、Code /// Token、RefreshToken 与绑定门店 /// 登录成功 /// 参数或验证码错误 /// 服务器错误 [AllowAnonymous] public virtual async Task 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 (!UserPasswordHelper.VerifyPlainPassword(user, input.Password)) { 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(); 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 }; } /// [AllowAnonymous] [HttpPost("us-app-auth/forgot-password/email/send-code")] public virtual Task PostSendForgotPasswordCodeByEmailAsync(EmailCaptchaImageDto input) => _forgotPasswordByEmailService.SendForgotPasswordCodeAsync(input); /// [AllowAnonymous] [UnitOfWork] [HttpPost("us-app-auth/forgot-password/email/reset")] public virtual Task PostResetPasswordByEmailAsync(RetrievePasswordByEmailDto input) => _forgotPasswordByEmailService.ResetPasswordByEmailAsync(input); /// /// 获取当前登录用户已绑定的门店(切换门店时可重新拉取) /// [Authorize] public virtual async Task> GetMyLocationsAsync() { if (!CurrentUser.Id.HasValue) { throw new UserFriendlyException("用户未登录"); } var list = await LoadBoundLocationsAsync(CurrentUser.Id.Value); if (ReportsRoleHelper.IsAdminRole(CurrentUser)) { var cached = await UsAppAuthScopeHelper.GetAdminScopeCacheAsync( _distributedCache, CurrentUser.Id.Value); if (cached?.Location is not null && !string.IsNullOrWhiteSpace(cached.Location.Id)) { if (list.All(x => x.Id != cached.Location.Id)) { list.Insert(0, cached.Location); } } } return list; } /// [Authorize] public virtual async Task> GetAdminScopeCompaniesAsync() { UsAppAuthScopeHelper.EnsureAdminAppToken(CurrentUser); return await UsAppAuthScopeHelper.ListCompaniesAsync(_dbContext.SqlSugarClient); } /// [Authorize] public virtual async Task> GetAdminScopeRegionsAsync(string partnerId) { UsAppAuthScopeHelper.EnsureAdminAppToken(CurrentUser); return await UsAppAuthScopeHelper.ListRegionsAsync(_dbContext.SqlSugarClient, partnerId); } /// [Authorize] public virtual async Task> GetAdminScopeLocationsAsync( string partnerId, string groupId) { UsAppAuthScopeHelper.EnsureAdminAppToken(CurrentUser); return await UsAppAuthScopeHelper.ListLocationsAsync( _dbContext.SqlSugarClient, partnerId, groupId); } /// [Authorize] public virtual async Task SelectAdminScopeLocationAsync( AuthScopeSelectLocationInputVo input) { UsAppAuthScopeHelper.EnsureAdminAppToken(CurrentUser); if (!CurrentUser.Id.HasValue) { throw new UserFriendlyException("用户未登录"); } return await UsAppAuthScopeHelper.SelectLocationAsync( _dbContext.SqlSugarClient, _distributedCache, CurrentUser.Id.Value, new UsAppSelectAdminScopeLocationInputVo { PartnerId = input.PartnerId, GroupId = input.GroupId, LocationId = input.LocationId }); } /// /// 查询单个门店详情(Location 页):地址、门店电话、经营时间、店长(角色含 manager 的绑定用户) /// /// /// 仅当当前登录用户在 userlocation 中绑定该 locationId 时可查;否则返回业务异常。 /// /// 店长:在同店绑定用户中,取 Role.RoleCodeRole.RoleName(忽略大小写)包含 manager 的第一条; /// 若无匹配则店长姓名与电话均为「无」。 /// /// OperatingHours:读取 location.OperatingHours;为空时返回「无」。 /// /// 门店主键(Guid 字符串) /// 与原型一致的展示字段 /// 成功 /// 未登录、门店标识无效、未绑定或门店不存在 /// 服务器错误 [Authorize] public virtual async Task GetLocationDetailAsync(string locationId) { if (!CurrentUser.Id.HasValue) { throw new UserFriendlyException("用户未登录"); } var lid = (locationId ?? string.Empty).Trim(); if (string.IsNullOrEmpty(lid) || !Guid.TryParse(lid, out var locationGuid)) { throw new UserFriendlyException("无效的门店标识"); } await UsAppPrintLogScopeHelper.EnsureUserCanAccessLocationAsync( CurrentUser, _dbContext.SqlSugarClient, lid); var locRows = await _dbContext.SqlSugarClient.Queryable() .Where(x => !x.IsDeleted && x.Id == locationGuid) .ToListAsync(); var loc = locRows.FirstOrDefault(); if (loc is null) { throw new UserFriendlyException("门店不存在或已删除"); } var (mgrName, mgrPhone) = await TryResolveStoreManagerAsync(lid); return new UsAppLocationDetailOutputDto { LocationId = loc.Id.ToString(), LocationName = string.IsNullOrWhiteSpace(loc.LocationName) ? "无" : loc.LocationName.Trim(), FullAddress = BuildFullAddress(loc), StorePhone = FormatStorePhoneDisplay(loc.Phone), OperatingHours = string.IsNullOrWhiteSpace(loc.OperatingHours) ? "无" : loc.OperatingHours.Trim(), ManagerName = mgrName, ManagerPhone = mgrPhone }; } /// [Authorize] public virtual async Task GetMyProfileAsync() { if (!CurrentUser.Id.HasValue) { throw new UserFriendlyException("用户未登录"); } var userId = CurrentUser.Id.Value; var user = await _userRepository.GetByIdAsync(userId); if (user is null || user.IsDeleted || !user.State) { throw new UserFriendlyException("用户不存在或已停用"); } // 避免 SqlSugar 在该环境下对 Role 关联表达式解析异常(Select 不支持),这里改用显式 SQL 查询角色。 var roleRows = await _dbContext.SqlSugarClient.Ado.SqlQueryAsync( @"SELECT r.RoleName, 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 ORDER BY r.OrderNum ASC", new { UserId = userId }); var roleNames = roleRows.Select(x => x.RoleName?.Trim()).Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); var roleDisplay = roleNames.Count == 0 ? "无" : string.Join(", ", roleNames); var primaryCode = roleRows.FirstOrDefault()?.RoleCode?.Trim(); var fullName = !string.IsNullOrWhiteSpace(user.Name?.Trim()) ? user.Name.Trim() : (!string.IsNullOrWhiteSpace(user.Nick?.Trim()) ? user.Nick.Trim() : user.UserName.Trim()); return new UsAppMyProfileOutputDto { FullName = fullName, Email = string.IsNullOrWhiteSpace(user.Email) ? "无" : user.Email.Trim(), Phone = FormatPhoneDisplay(user.Phone), EmployeeId = string.IsNullOrWhiteSpace(user.UserName) ? "无" : user.UserName.Trim(), RoleDisplay = roleDisplay, PrimaryRoleCode = string.IsNullOrWhiteSpace(primaryCode) ? null : primaryCode }; } /// [Authorize] [UnitOfWork] public virtual async Task ChangePasswordAsync(UsAppChangePasswordInputVo input) { if (input is null) { throw new UserFriendlyException("入参不能为空"); } if (!CurrentUser.Id.HasValue) { throw new UserFriendlyException("用户未登录"); } var current = input.CurrentPassword ?? string.Empty; var newPwd = input.NewPassword ?? string.Empty; var confirm = input.ConfirmNewPassword ?? string.Empty; if (string.IsNullOrWhiteSpace(current) || string.IsNullOrWhiteSpace(newPwd) || string.IsNullOrWhiteSpace(confirm)) { throw new UserFriendlyException("请填写当前密码、新密码与确认密码"); } if (!string.Equals(newPwd, confirm, StringComparison.Ordinal)) { throw new UserFriendlyException("新密码与确认密码不一致"); } if (string.Equals(current, newPwd, StringComparison.Ordinal)) { throw new UserFriendlyException("新密码不能与当前密码相同"); } ValidateAppPasswordComplexity(newPwd); var userId = CurrentUser.Id.Value; var user = await _userRepository.GetByIdAsync(userId); if (user is null || user.IsDeleted || !user.State) { throw new UserFriendlyException("用户不存在或已停用"); } if (!user.JudgePassword(current)) { throw new UserFriendlyException(UserConst.Login_Error); } user.EncryPassword.Password = newPwd; user.BuildPassword(); await _userRepository.UpdateAsync(user); } private static string FormatPhoneDisplay(long? phone) { if (!phone.HasValue) { return "无"; } var digits = Math.Abs(phone.Value).ToString(CultureInfo.InvariantCulture); if (digits.Length == 10) { return $"+1 ({digits[..3]}) {digits.Substring(3, 3)}-{digits.Substring(6, 4)}"; } if (digits.Length == 11 && digits.StartsWith("1", StringComparison.Ordinal)) { return $"+1 ({digits[1..4]}) {digits.Substring(4, 3)}-{digits.Substring(7, 4)}"; } return $"+{digits}"; } private static string FormatStorePhoneDisplay(string? phone) { var t = phone?.Trim(); return string.IsNullOrEmpty(t) ? "无" : t; } private async Task<(string Name, string Phone)> TryResolveStoreManagerAsync(string locationIdTrimmed) { var userIdStrings = await _dbContext.SqlSugarClient.Queryable() .Where(x => !x.IsDeleted && x.LocationId == locationIdTrimmed) .Select(x => x.UserId) .Distinct() .ToListAsync(); var userGuids = userIdStrings .Select(s => Guid.TryParse(s, out var g) ? (Guid?)g : null) .Where(g => g.HasValue) .Select(g => g!.Value) .ToList(); if (userGuids.Count == 0) { return ("无", "无"); } var rows = await _dbContext.SqlSugarClient.Ado.SqlQueryAsync( @"SELECT u.Name, u.Nick, u.UserName, u.Phone FROM User u INNER JOIN UserRole ur ON u.Id = ur.UserId INNER JOIN Role r ON ur.RoleId = r.Id WHERE u.IsDeleted = 0 AND u.State = 1 AND r.IsDeleted = 0 AND r.State = 1 AND (LOWER(r.RoleCode) LIKE '%manager%' OR LOWER(r.RoleName) LIKE '%manager%') AND u.Id IN (@UserIds) ORDER BY u.Name ASC", new { UserIds = userGuids }); var row = rows.FirstOrDefault(); if (row is null) { return ("无", "无"); } var displayName = !string.IsNullOrWhiteSpace(row.Name?.Trim()) ? row.Name!.Trim() : (!string.IsNullOrWhiteSpace(row.Nick?.Trim()) ? row.Nick!.Trim() : (row.UserName?.Trim() ?? "无")); return (displayName, FormatPhoneDisplay(row.Phone)); } private static void ValidateAppPasswordComplexity(string password) { if (password.Length < 8) { throw new UserFriendlyException("新密码至少 8 位"); } if (!password.Any(char.IsUpper)) { throw new UserFriendlyException("新密码需包含大写字母"); } if (!password.Any(char.IsLower)) { throw new UserFriendlyException("新密码需包含小写字母"); } if (!password.Any(char.IsDigit)) { throw new UserFriendlyException("新密码需包含至少一个数字"); } if (!password.Any(c => !char.IsLetterOrDigit(c))) { throw new UserFriendlyException("新密码需包含至少一个特殊字符"); } } private void ValidationImageCaptcha(string? uuid, string? code) { if (!_rbacOptions.EnableCaptcha) { return; } if (!_captcha.Validate(uuid, code)) { throw new UserFriendlyException("验证码错误"); } } /// /// 按邮箱或用户名(邮箱形字符串写在 UserName 时)查找未删除且启用的用户;比较忽略大小写,Email 命中优先。 /// private async Task 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) || 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) { var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.SecurityKey)); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var claims = new List { new(AbpClaimTypes.UserId, user.Id.ToString()), new(AbpClaimTypes.UserName, user.UserName), new(UsAppJwtClaims.ClientKind, UsAppJwtClaims.ClientKindUsApp) }; 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> LoadBoundLocationsAsync(Guid userId) { var userIdStr = userId.ToString(); var links = await _dbContext.SqlSugarClient.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 _dbContext.SqlSugarClient.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); } 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(); if (!string.IsNullOrEmpty(street)) { segments.Add(street); } if (!string.IsNullOrEmpty(line2)) { segments.Add(line2); } return segments.Count == 0 ? "无" : string.Join(", ", segments); } private sealed class MyProfileRoleRow { public string? RoleName { get; set; } public string? RoleCode { get; set; } } private sealed class LocationManagerRow { public string? Name { get; set; } public string? Nick { get; set; } public string? UserName { get; set; } public long? Phone { get; set; } } }