diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppAuth/UsAppBoundLocationDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppAuth/UsAppBoundLocationDto.cs new file mode 100644 index 0000000..7ae9a4f --- /dev/null +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppAuth/UsAppBoundLocationDto.cs @@ -0,0 +1,22 @@ +namespace FoodLabeling.Application.Contracts.Dtos.UsAppAuth; + +/// +/// App 端展示的绑定门店信息 +/// +public class UsAppBoundLocationDto +{ + /// 门店主键 Id(Guid 字符串) + public string Id { get; set; } = string.Empty; + + /// 业务编码,如 LOC-1 + public string LocationCode { get; set; } = string.Empty; + + /// 门店名称 + public string LocationName { get; set; } = string.Empty; + + /// 拼接后的完整地址,便于移动端展示 + public string FullAddress { get; set; } = string.Empty; + + /// 门店是否启用 + public bool State { get; set; } +} diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppAuth/UsAppLoginInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppAuth/UsAppLoginInputVo.cs new file mode 100644 index 0000000..b04588c --- /dev/null +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppAuth/UsAppLoginInputVo.cs @@ -0,0 +1,18 @@ +namespace FoodLabeling.Application.Contracts.Dtos.UsAppAuth; + +/// +/// 美国版 App 登录入参(使用邮箱作为账号,支持图形验证码) +/// +public class UsAppLoginInputVo +{ + /// 登录邮箱(对应 user.Email) + public string Email { get; set; } = string.Empty; + + public string Password { get; set; } = string.Empty; + + /// 图形验证码 UUID(开启验证码时必填) + public string? Uuid { get; set; } + + /// 图形验证码(开启验证码时必填) + public string? Code { get; set; } +} diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppAuth/UsAppLoginOutputDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppAuth/UsAppLoginOutputDto.cs new file mode 100644 index 0000000..302bded --- /dev/null +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppAuth/UsAppLoginOutputDto.cs @@ -0,0 +1,14 @@ +namespace FoodLabeling.Application.Contracts.Dtos.UsAppAuth; + +/// +/// 美国版 App 登录返回(含 Token 与绑定门店) +/// +public class UsAppLoginOutputDto +{ + public string Token { get; set; } = string.Empty; + + public string RefreshToken { get; set; } = string.Empty; + + /// 当前账号在 userlocation 中绑定的门店列表 + public List Locations { get; set; } = new(); +} diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IUsAppAuthAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IUsAppAuthAppService.cs new file mode 100644 index 0000000..1b439e0 --- /dev/null +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IUsAppAuthAppService.cs @@ -0,0 +1,20 @@ +using FoodLabeling.Application.Contracts.Dtos.UsAppAuth; +using Volo.Abp.Application.Services; + +namespace FoodLabeling.Application.Contracts.IServices; + +/// +/// 美国版移动端认证(登录返回绑定门店) +/// +public interface IUsAppAuthAppService : IApplicationService +{ + /// + /// App 登录:使用邮箱 + 密码校验并签发 Token,同时返回 userlocation 绑定的门店 + /// + Task LoginAsync(UsAppLoginInputVo input); + + /// + /// 获取当前登录账号已绑定的门店(用于切换门店等场景) + /// + Task> GetMyLocationsAsync(); +} diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabeling.Application.csproj b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabeling.Application.csproj index 5133544..9c45fa3 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabeling.Application.csproj +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabeling.Application.csproj @@ -2,6 +2,10 @@ + + + + diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppAuthAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppAuthAppService.cs new file mode 100644 index 0000000..93cd84c --- /dev/null +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppAuthAppService.cs @@ -0,0 +1,260 @@ +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; + +/// +/// 美国版 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; + + public UsAppAuthAppService( + IAccountManager accountManager, + ISqlSugarRepository userRepository, + ISqlSugarDbContext dbContext, + IHttpContextAccessor httpContextAccessor, + ICaptcha captcha, + IOptions jwtOptions, + IOptions rbacOptions) + { + _accountManager = accountManager; + _userRepository = userRepository; + _dbContext = dbContext; + _httpContextAccessor = httpContextAccessor; + _captcha = captcha; + _jwtOptions = jwtOptions.Value; + _rbacOptions = rbacOptions.Value; + } + + 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 (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(); + 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 + }; + } + + /// + /// 获取当前登录用户已绑定的门店(切换门店时可重新拉取) + /// + [Authorize] + public virtual async Task> 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("验证码错误"); + } + } + + /// + /// 按邮箱查找未删除且启用的用户(邮箱比较忽略大小写) + /// + 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) + .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 + { + 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> 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); + } +} diff --git a/项目相关文档/美国版App登录接口说明.md b/项目相关文档/美国版App登录接口说明.md new file mode 100644 index 0000000..fd04601 --- /dev/null +++ b/项目相关文档/美国版App登录接口说明.md @@ -0,0 +1,159 @@ +# 美国版 App 登录接口说明 + +## 概述 + +美国版移动端认证由 `food-labeling-us` 模块的 **`UsAppAuthAppService`** 提供,采用 ABP 约定式动态 API。宿主统一前缀为 **`/api/app`**,建议以 Swagger 为准核对路径(本地示例:`http://localhost:19001/swagger`,搜索 `UsAppAuth`)。 + +| 说明 | 内容 | +|------|------| +| 账号标识 | 使用 **`User.Email`**(邮箱)登录,邮箱比对**忽略大小写** | +| 密码 | 与 Web 共用 `User` 表,校验方式与 RBAC **`AccountManager`** 一致(盐值 + `MD5Helper.SHA2Encode`) | +| 权限 | **App 登录不校验角色/权限**(允许“未配置权限”的账号登录);H5 管理端再按 RBAC 权限控制 | +| 验证码 | 当配置 **`Rbac:EnableCaptcha`** 为 `true` 时,需先拉取图形验证码,本接口入参传 `uuid`、`code`;未开启时可传空或不传 | + +--- + +## 接口 1:App 登录 + +签发 **Access Token**、**Refresh Token**,并返回当前用户在 **`userlocation`** 中绑定的门店列表(关联 **`location`** 表详情)。 + +### HTTP + +- **方法**:`POST` +- **路径**:`/api/app/us-app-auth/login` +- **Content-Type**:`application/json` +- **鉴权**:无需登录(匿名) + +### 请求体参数(UsAppLoginInputVo) + +| 参数名(JSON) | 类型 | 必填 | 说明 | +|----------------|------|------|------| +| `email` | string | 是 | 登录邮箱,对应数据库 `User.Email` | +| `password` | string | 是 | 明文密码 | +| `uuid` | string | 条件 | 图形验证码 UUID;**开启验证码时必填** | +| `code` | string | 条件 | 图形验证码;**开启验证码时必填** | + +### 传参示例(请求 Body) + +未开启图形验证码时: + +```json +{ + "email": "admin@example.com", + "password": "123456" +} +``` + +开启图形验证码时(需与系统验证码接口返回的 `uuid`、用户输入的验证码一致): + +```json +{ + "email": "test@example.com", + "password": "您的密码", + "uuid": "验证码接口返回的 uuid", + "code": "用户看到的验证码" +} +``` + +### 响应体(UsAppLoginOutputDto) + +| 字段(JSON) | 类型 | 说明 | +|--------------|------|------| +| `token` | string | 访问令牌(Bearer),后续业务接口放在 Header `Authorization: Bearer {token}` | +| `refreshToken` | string | 刷新令牌(与系统账号体系一致,用于刷新 access token,具体用法与 Web 一致) | +| `locations` | array | 绑定门店列表,元素见下表 | + +#### `locations[]` 元素(UsAppBoundLocationDto) + +| 字段(JSON) | 类型 | 说明 | +|--------------|------|------| +| `id` | string | 门店主键(Guid 字符串) | +| `locationCode` | string | 业务编码,如 LOC-1 | +| `locationName` | string | 门店名称 | +| `fullAddress` | string | 拼接后的完整地址(街道、城市、州、邮编等;无数据时可能为 `"无"`) | +| `state` | bool | 门店是否启用 | + +### 响应示例 + +```json +{ + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "locations": [ + { + "id": "a2696b9e-2277-11f1-b4c6-00163e0c7c4f", + "locationCode": "LOC-1", + "locationName": "Downtown Kitchen", + "fullAddress": "123 Main St, New York, NY 10001", + "state": true + } + ] +} +``` + +### 常见错误提示(业务异常文案) + +- 邮箱或密码为空:`请输入合理数据!` +- 邮箱在库中不存在(未删除且启用用户中无匹配邮箱):`登录失败!邮箱不存在!` +- 密码错误:`登录失败!用户名或密码错误!`(与 `UserConst.Login_Error` 一致) +- 验证码错误(开启验证码时):`验证码错误` + +--- + +## 接口 2:获取当前账号绑定门店 + +无需重新登录即可刷新 **`userlocation`** 绑定门店列表(例如切换门店前先同步列表)。 + +### HTTP + +- **方法**:`GET` +- **路径**:`/api/app/us-app-auth/my-locations` +- **鉴权**:需要登录,请求头携带 **`Authorization: Bearer {token}`**(使用接口 1 返回的 `token`) + +### 请求参数 + +无 Query / Body 参数;用户身份由 JWT 解析。 + +### 传参示例 + +```http +GET /api/app/us-app-auth/my-locations HTTP/1.1 +Host: localhost:19001 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +若前端统一约定 GET 使用 `data` 封装,可自行在客户端组装;本接口服务端**不读取额外 Query 参数**。 + +### 响应体 + +与登录接口中 **`locations`** 相同:**`UsAppBoundLocationDto[]`**(数组)。 + +### 响应示例 + +```json +[ + { + "id": "a2696b9e-2277-11f1-b4c6-00163e0c7c4f", + "locationCode": "LOC-1", + "locationName": "Downtown Kitchen", + "fullAddress": "123 Main St, New York, NY 10001", + "state": true + } +] +``` + +### 常见错误 + +- 未登录或 Token 无效:按网关/ABP 返回 401 及统一错误体 +- 无用户上下文:`用户未登录` + +--- + +## 与其他登录方式的区别 + +| 场景 | 说明 | +|------|------| +| Web 管理端 | 仍使用 RBAC **`AccountService.PostLoginAsync`**,一般为人 **`userName`** + 密码 | +| 美国版 App | **仅**本模块 **`/api/app/us-app-auth/login`** 使用 **邮箱 + 密码** | + +两者共用同一 `User` 表与 JWT 体系;App 端需保证账号已维护 **`Email`** 字段,否则无法通过邮箱登录。