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 一致(含验证码、登录日志事件)。
/// 门店数据来自 userlocation 与 location 表。
///
/// 邮箱、密码;若系统开启验证码则需传 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.RoleCode 或 Role.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; }
}
}