Commit 13b6d2e35e81b6418624fe1f4602693ab1c2c14e

Authored by 杨鑫
2 parents 3af4878d e5c6f343

Merge branch 'main' of http://39.98.150.180/wangming/Food-Labeling-Management-Platform

美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppAuth/UsAppBoundLocationDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.UsAppAuth;
  2 +
  3 +/// <summary>
  4 +/// App 端展示的绑定门店信息
  5 +/// </summary>
  6 +public class UsAppBoundLocationDto
  7 +{
  8 + /// <summary>门店主键 Id(Guid 字符串)</summary>
  9 + public string Id { get; set; } = string.Empty;
  10 +
  11 + /// <summary>业务编码,如 LOC-1</summary>
  12 + public string LocationCode { get; set; } = string.Empty;
  13 +
  14 + /// <summary>门店名称</summary>
  15 + public string LocationName { get; set; } = string.Empty;
  16 +
  17 + /// <summary>拼接后的完整地址,便于移动端展示</summary>
  18 + public string FullAddress { get; set; } = string.Empty;
  19 +
  20 + /// <summary>门店是否启用</summary>
  21 + public bool State { get; set; }
  22 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppAuth/UsAppLoginInputVo.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.UsAppAuth;
  2 +
  3 +/// <summary>
  4 +/// 美国版 App 登录入参(使用邮箱作为账号,支持图形验证码)
  5 +/// </summary>
  6 +public class UsAppLoginInputVo
  7 +{
  8 + /// <summary>登录邮箱(对应 user.Email)</summary>
  9 + public string Email { get; set; } = string.Empty;
  10 +
  11 + public string Password { get; set; } = string.Empty;
  12 +
  13 + /// <summary>图形验证码 UUID(开启验证码时必填)</summary>
  14 + public string? Uuid { get; set; }
  15 +
  16 + /// <summary>图形验证码(开启验证码时必填)</summary>
  17 + public string? Code { get; set; }
  18 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppAuth/UsAppLoginOutputDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.UsAppAuth;
  2 +
  3 +/// <summary>
  4 +/// 美国版 App 登录返回(含 Token 与绑定门店)
  5 +/// </summary>
  6 +public class UsAppLoginOutputDto
  7 +{
  8 + public string Token { get; set; } = string.Empty;
  9 +
  10 + public string RefreshToken { get; set; } = string.Empty;
  11 +
  12 + /// <summary>当前账号在 userlocation 中绑定的门店列表</summary>
  13 + public List<UsAppBoundLocationDto> Locations { get; set; } = new();
  14 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IUsAppAuthAppService.cs 0 → 100644
  1 +using FoodLabeling.Application.Contracts.Dtos.UsAppAuth;
  2 +using Volo.Abp.Application.Services;
  3 +
  4 +namespace FoodLabeling.Application.Contracts.IServices;
  5 +
  6 +/// <summary>
  7 +/// 美国版移动端认证(登录返回绑定门店)
  8 +/// </summary>
  9 +public interface IUsAppAuthAppService : IApplicationService
  10 +{
  11 + /// <summary>
  12 + /// App 登录:使用邮箱 + 密码校验并签发 Token,同时返回 userlocation 绑定的门店
  13 + /// </summary>
  14 + Task<UsAppLoginOutputDto> LoginAsync(UsAppLoginInputVo input);
  15 +
  16 + /// <summary>
  17 + /// 获取当前登录账号已绑定的门店(用于切换门店等场景)
  18 + /// </summary>
  19 + Task<List<UsAppBoundLocationDto>> GetMyLocationsAsync();
  20 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabeling.Application.csproj
... ... @@ -2,6 +2,10 @@
2 2 <Import Project="..\..\..\common.props" />
3 3  
4 4 <ItemGroup>
  5 + <PackageReference Include="Lazy.Captcha.Core" Version="2.0.7" />
  6 + </ItemGroup>
  7 +
  8 + <ItemGroup>
5 9 <ProjectReference Include="..\..\..\framework\Yi.Framework.Ddd.Application\Yi.Framework.Ddd.Application.csproj" />
6 10 <ProjectReference Include="..\FoodLabeling.Application.Contracts\FoodLabeling.Application.Contracts.csproj" />
7 11 <ProjectReference Include="..\FoodLabeling.Domain\FoodLabeling.Domain.csproj" />
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppAuthAppService.cs 0 → 100644
  1 +using System;
  2 +using System.Collections.Generic;
  3 +using System.IdentityModel.Tokens.Jwt;
  4 +using System.Linq;
  5 +using System.Security.Claims;
  6 +using System.Text;
  7 +using System.Threading.Tasks;
  8 +using FoodLabeling.Application.Contracts.Dtos.UsAppAuth;
  9 +using FoodLabeling.Application.Contracts.IServices;
  10 +using FoodLabeling.Application.Services.DbModels;
  11 +using FoodLabeling.Domain.Entities;
  12 +using Lazy.Captcha.Core;
  13 +using Mapster;
  14 +using Microsoft.AspNetCore.Authorization;
  15 +using Microsoft.AspNetCore.Http;
  16 +using Microsoft.Extensions.Options;
  17 +using Microsoft.IdentityModel.Tokens;
  18 +using SqlSugar;
  19 +using Volo.Abp;
  20 +using Volo.Abp.Application.Services;
  21 +using Volo.Abp.EventBus.Local;
  22 +using Volo.Abp.Security.Claims;
  23 +using Volo.Abp.Users;
  24 +using Yi.Framework.Core.Helper;
  25 +using Yi.Framework.Rbac.Domain.Entities;
  26 +using Yi.Framework.Rbac.Domain.Managers;
  27 +using Yi.Framework.Rbac.Domain.Shared.Consts;
  28 +using Yi.Framework.Rbac.Domain.Shared.Dtos;
  29 +using Yi.Framework.Rbac.Domain.Shared.Etos;
  30 +using Yi.Framework.Rbac.Domain.Shared.Options;
  31 +using Yi.Framework.SqlSugarCore.Abstractions;
  32 +
  33 +namespace FoodLabeling.Application.Services;
  34 +
  35 +/// <summary>
  36 +/// 美国版 App 登录:邮箱 + 密码(与 AccountManager 相同盐值哈希)签发 JWT,并返回 userlocation 绑定门店
  37 +/// </summary>
  38 +public class UsAppAuthAppService : ApplicationService, IUsAppAuthAppService
  39 +{
  40 + private readonly IAccountManager _accountManager;
  41 + private readonly ISqlSugarRepository<UserAggregateRoot, Guid> _userRepository;
  42 + private readonly ISqlSugarDbContext _dbContext;
  43 + private readonly IHttpContextAccessor _httpContextAccessor;
  44 + private readonly ICaptcha _captcha;
  45 + private readonly RbacOptions _rbacOptions;
  46 + private readonly JwtOptions _jwtOptions;
  47 +
  48 + public UsAppAuthAppService(
  49 + IAccountManager accountManager,
  50 + ISqlSugarRepository<UserAggregateRoot, Guid> userRepository,
  51 + ISqlSugarDbContext dbContext,
  52 + IHttpContextAccessor httpContextAccessor,
  53 + ICaptcha captcha,
  54 + IOptions<JwtOptions> jwtOptions,
  55 + IOptions<RbacOptions> rbacOptions)
  56 + {
  57 + _accountManager = accountManager;
  58 + _userRepository = userRepository;
  59 + _dbContext = dbContext;
  60 + _httpContextAccessor = httpContextAccessor;
  61 + _captcha = captcha;
  62 + _jwtOptions = jwtOptions.Value;
  63 + _rbacOptions = rbacOptions.Value;
  64 + }
  65 +
  66 + protected ILocalEventBus LocalEventBus => LazyServiceProvider.LazyGetRequiredService<ILocalEventBus>();
  67 +
  68 + /// <summary>
  69 + /// App 登录:签发 Token / RefreshToken,并返回当前账号绑定的门店列表
  70 + /// </summary>
  71 + /// <remarks>
  72 + /// 行为与系统 <c>AccountService.PostLoginAsync</c> 一致(含验证码、登录日志事件)。
  73 + /// 门店数据来自 <c>userlocation</c> 与 <c>location</c> 表。
  74 + /// </remarks>
  75 + /// <param name="input">邮箱、密码;若系统开启验证码则需传 Uuid、Code</param>
  76 + /// <returns>Token、RefreshToken 与绑定门店</returns>
  77 + /// <response code="200">登录成功</response>
  78 + /// <response code="400">参数或验证码错误</response>
  79 + /// <response code="500">服务器错误</response>
  80 + [AllowAnonymous]
  81 + public virtual async Task<UsAppLoginOutputDto> LoginAsync(UsAppLoginInputVo input)
  82 + {
  83 + if (string.IsNullOrWhiteSpace(input.Password) || string.IsNullOrWhiteSpace(input.Email))
  84 + {
  85 + throw new UserFriendlyException("请输入合理数据!");
  86 + }
  87 +
  88 + ValidationImageCaptcha(input.Uuid, input.Code);
  89 +
  90 + var user = await FindActiveUserByEmailAsync(input.Email.Trim());
  91 + if (user is null)
  92 + {
  93 + throw new UserFriendlyException("登录失败!邮箱不存在!");
  94 + }
  95 +
  96 + if (user.EncryPassword.Password != MD5Helper.SHA2Encode(input.Password, user.EncryPassword.Salt))
  97 + {
  98 + throw new UserFriendlyException(UserConst.Login_Error);
  99 + }
  100 +
  101 + // App 端不依赖 RBAC 权限体系:允许“无权限账号”登录拿 Token(H5 再做权限控制)
  102 + var accessToken = CreateAppAccessToken(user);
  103 + var refreshToken = _accountManager.CreateRefreshToken(user.Id);
  104 +
  105 + if (_httpContextAccessor.HttpContext is not null)
  106 + {
  107 + var loginEntity = new LoginLogAggregateRoot().GetInfoByHttpContext(_httpContextAccessor.HttpContext);
  108 + var loginEto = loginEntity.Adapt<LoginEventArgs>();
  109 + loginEto.UserName = user.UserName;
  110 + loginEto.UserId = user.Id;
  111 + await LocalEventBus.PublishAsync(loginEto);
  112 + }
  113 +
  114 + var locations = await LoadBoundLocationsAsync(user.Id);
  115 +
  116 + return new UsAppLoginOutputDto
  117 + {
  118 + Token = accessToken,
  119 + RefreshToken = refreshToken,
  120 + Locations = locations
  121 + };
  122 + }
  123 +
  124 + /// <summary>
  125 + /// 获取当前登录用户已绑定的门店(切换门店时可重新拉取)
  126 + /// </summary>
  127 + [Authorize]
  128 + public virtual async Task<List<UsAppBoundLocationDto>> GetMyLocationsAsync()
  129 + {
  130 + if (!CurrentUser.Id.HasValue)
  131 + {
  132 + throw new UserFriendlyException("用户未登录");
  133 + }
  134 +
  135 + return await LoadBoundLocationsAsync(CurrentUser.Id.Value);
  136 + }
  137 +
  138 + private void ValidationImageCaptcha(string? uuid, string? code)
  139 + {
  140 + if (!_rbacOptions.EnableCaptcha)
  141 + {
  142 + return;
  143 + }
  144 +
  145 + if (!_captcha.Validate(uuid, code))
  146 + {
  147 + throw new UserFriendlyException("验证码错误");
  148 + }
  149 + }
  150 +
  151 + /// <summary>
  152 + /// 按邮箱查找未删除且启用的用户(邮箱比较忽略大小写)
  153 + /// </summary>
  154 + private async Task<UserAggregateRoot?> FindActiveUserByEmailAsync(string email)
  155 + {
  156 + var normalized = email.Trim().ToLowerInvariant();
  157 + var users = await _userRepository._DbQueryable
  158 + .Where(u => !u.IsDeleted && u.State == true)
  159 + .Where(u => u.Email != null && SqlFunc.ToLower(u.Email) == normalized)
  160 + .ToListAsync();
  161 + return users.FirstOrDefault();
  162 + }
  163 +
  164 + private string CreateAppAccessToken(UserAggregateRoot user)
  165 + {
  166 + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.SecurityKey));
  167 + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
  168 +
  169 + var claims = new List<Claim>
  170 + {
  171 + new(AbpClaimTypes.UserId, user.Id.ToString()),
  172 + new(AbpClaimTypes.UserName, user.UserName)
  173 + };
  174 +
  175 + if (!string.IsNullOrWhiteSpace(user.Email))
  176 + {
  177 + claims.Add(new Claim(AbpClaimTypes.Email, user.Email));
  178 + }
  179 +
  180 + var token = new JwtSecurityToken(
  181 + issuer: _jwtOptions.Issuer,
  182 + audience: _jwtOptions.Audience,
  183 + claims: claims,
  184 + expires: DateTime.Now.AddMinutes(_jwtOptions.ExpiresMinuteTime),
  185 + notBefore: DateTime.Now,
  186 + signingCredentials: creds);
  187 +
  188 + return new JwtSecurityTokenHandler().WriteToken(token);
  189 + }
  190 +
  191 + private async Task<List<UsAppBoundLocationDto>> LoadBoundLocationsAsync(Guid userId)
  192 + {
  193 + var userIdStr = userId.ToString();
  194 + var links = await _dbContext.SqlSugarClient.Queryable<UserLocationDbEntity>()
  195 + .Where(x => !x.IsDeleted && x.UserId == userIdStr)
  196 + .Select(x => x.LocationId)
  197 + .ToListAsync();
  198 +
  199 + if (links.Count == 0)
  200 + {
  201 + return new List<UsAppBoundLocationDto>();
  202 + }
  203 +
  204 + var wanted = links.Distinct().ToList();
  205 + var locations = (await _dbContext.SqlSugarClient.Queryable<LocationAggregateRoot>()
  206 + .Where(x => !x.IsDeleted)
  207 + .Where(x => wanted.Contains(x.Id.ToString()))
  208 + .ToListAsync())
  209 + .OrderBy(x => x.OrderNum)
  210 + .ThenBy(x => x.LocationName)
  211 + .ToList();
  212 +
  213 + return locations.Select(x => new UsAppBoundLocationDto
  214 + {
  215 + Id = x.Id.ToString(),
  216 + LocationCode = x.LocationCode ?? string.Empty,
  217 + LocationName = x.LocationName ?? string.Empty,
  218 + FullAddress = BuildFullAddress(x),
  219 + State = x.State
  220 + }).ToList();
  221 + }
  222 +
  223 + private static string BuildFullAddress(LocationAggregateRoot loc)
  224 + {
  225 + var street = loc.Street?.Trim();
  226 + var city = loc.City?.Trim();
  227 + var state = loc.StateCode?.Trim();
  228 + var zip = loc.ZipCode?.Trim();
  229 +
  230 + var line2Parts = new List<string>();
  231 + if (!string.IsNullOrEmpty(city))
  232 + {
  233 + line2Parts.Add(city);
  234 + }
  235 +
  236 + if (!string.IsNullOrEmpty(state))
  237 + {
  238 + line2Parts.Add(state);
  239 + }
  240 +
  241 + var line2 = line2Parts.Count > 0 ? string.Join(", ", line2Parts) : string.Empty;
  242 + if (!string.IsNullOrEmpty(zip))
  243 + {
  244 + line2 = string.IsNullOrEmpty(line2) ? zip : $"{line2} {zip}";
  245 + }
  246 +
  247 + var segments = new List<string>();
  248 + if (!string.IsNullOrEmpty(street))
  249 + {
  250 + segments.Add(street);
  251 + }
  252 +
  253 + if (!string.IsNullOrEmpty(line2))
  254 + {
  255 + segments.Add(line2);
  256 + }
  257 +
  258 + return segments.Count == 0 ? "无" : string.Join(", ", segments);
  259 + }
  260 +}
... ...
项目相关文档/美国版App登录接口说明.md 0 → 100644
  1 +# 美国版 App 登录接口说明
  2 +
  3 +## 概述
  4 +
  5 +美国版移动端认证由 `food-labeling-us` 模块的 **`UsAppAuthAppService`** 提供,采用 ABP 约定式动态 API。宿主统一前缀为 **`/api/app`**,建议以 Swagger 为准核对路径(本地示例:`http://localhost:19001/swagger`,搜索 `UsAppAuth`)。
  6 +
  7 +| 说明 | 内容 |
  8 +|------|------|
  9 +| 账号标识 | 使用 **`User.Email`**(邮箱)登录,邮箱比对**忽略大小写** |
  10 +| 密码 | 与 Web 共用 `User` 表,校验方式与 RBAC **`AccountManager`** 一致(盐值 + `MD5Helper.SHA2Encode`) |
  11 +| 权限 | **App 登录不校验角色/权限**(允许“未配置权限”的账号登录);H5 管理端再按 RBAC 权限控制 |
  12 +| 验证码 | 当配置 **`Rbac:EnableCaptcha`** 为 `true` 时,需先拉取图形验证码,本接口入参传 `uuid`、`code`;未开启时可传空或不传 |
  13 +
  14 +---
  15 +
  16 +## 接口 1:App 登录
  17 +
  18 +签发 **Access Token**、**Refresh Token**,并返回当前用户在 **`userlocation`** 中绑定的门店列表(关联 **`location`** 表详情)。
  19 +
  20 +### HTTP
  21 +
  22 +- **方法**:`POST`
  23 +- **路径**:`/api/app/us-app-auth/login`
  24 +- **Content-Type**:`application/json`
  25 +- **鉴权**:无需登录(匿名)
  26 +
  27 +### 请求体参数(UsAppLoginInputVo)
  28 +
  29 +| 参数名(JSON) | 类型 | 必填 | 说明 |
  30 +|----------------|------|------|------|
  31 +| `email` | string | 是 | 登录邮箱,对应数据库 `User.Email` |
  32 +| `password` | string | 是 | 明文密码 |
  33 +| `uuid` | string | 条件 | 图形验证码 UUID;**开启验证码时必填** |
  34 +| `code` | string | 条件 | 图形验证码;**开启验证码时必填** |
  35 +
  36 +### 传参示例(请求 Body)
  37 +
  38 +未开启图形验证码时:
  39 +
  40 +```json
  41 +{
  42 + "email": "admin@example.com",
  43 + "password": "123456"
  44 +}
  45 +```
  46 +
  47 +开启图形验证码时(需与系统验证码接口返回的 `uuid`、用户输入的验证码一致):
  48 +
  49 +```json
  50 +{
  51 + "email": "test@example.com",
  52 + "password": "您的密码",
  53 + "uuid": "验证码接口返回的 uuid",
  54 + "code": "用户看到的验证码"
  55 +}
  56 +```
  57 +
  58 +### 响应体(UsAppLoginOutputDto)
  59 +
  60 +| 字段(JSON) | 类型 | 说明 |
  61 +|--------------|------|------|
  62 +| `token` | string | 访问令牌(Bearer),后续业务接口放在 Header `Authorization: Bearer {token}` |
  63 +| `refreshToken` | string | 刷新令牌(与系统账号体系一致,用于刷新 access token,具体用法与 Web 一致) |
  64 +| `locations` | array | 绑定门店列表,元素见下表 |
  65 +
  66 +#### `locations[]` 元素(UsAppBoundLocationDto)
  67 +
  68 +| 字段(JSON) | 类型 | 说明 |
  69 +|--------------|------|------|
  70 +| `id` | string | 门店主键(Guid 字符串) |
  71 +| `locationCode` | string | 业务编码,如 LOC-1 |
  72 +| `locationName` | string | 门店名称 |
  73 +| `fullAddress` | string | 拼接后的完整地址(街道、城市、州、邮编等;无数据时可能为 `"无"`) |
  74 +| `state` | bool | 门店是否启用 |
  75 +
  76 +### 响应示例
  77 +
  78 +```json
  79 +{
  80 + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  81 + "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  82 + "locations": [
  83 + {
  84 + "id": "a2696b9e-2277-11f1-b4c6-00163e0c7c4f",
  85 + "locationCode": "LOC-1",
  86 + "locationName": "Downtown Kitchen",
  87 + "fullAddress": "123 Main St, New York, NY 10001",
  88 + "state": true
  89 + }
  90 + ]
  91 +}
  92 +```
  93 +
  94 +### 常见错误提示(业务异常文案)
  95 +
  96 +- 邮箱或密码为空:`请输入合理数据!`
  97 +- 邮箱在库中不存在(未删除且启用用户中无匹配邮箱):`登录失败!邮箱不存在!`
  98 +- 密码错误:`登录失败!用户名或密码错误!`(与 `UserConst.Login_Error` 一致)
  99 +- 验证码错误(开启验证码时):`验证码错误`
  100 +
  101 +---
  102 +
  103 +## 接口 2:获取当前账号绑定门店
  104 +
  105 +无需重新登录即可刷新 **`userlocation`** 绑定门店列表(例如切换门店前先同步列表)。
  106 +
  107 +### HTTP
  108 +
  109 +- **方法**:`GET`
  110 +- **路径**:`/api/app/us-app-auth/my-locations`
  111 +- **鉴权**:需要登录,请求头携带 **`Authorization: Bearer {token}`**(使用接口 1 返回的 `token`)
  112 +
  113 +### 请求参数
  114 +
  115 +无 Query / Body 参数;用户身份由 JWT 解析。
  116 +
  117 +### 传参示例
  118 +
  119 +```http
  120 +GET /api/app/us-app-auth/my-locations HTTP/1.1
  121 +Host: localhost:19001
  122 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
  123 +```
  124 +
  125 +若前端统一约定 GET 使用 `data` 封装,可自行在客户端组装;本接口服务端**不读取额外 Query 参数**。
  126 +
  127 +### 响应体
  128 +
  129 +与登录接口中 **`locations`** 相同:**`UsAppBoundLocationDto[]`**(数组)。
  130 +
  131 +### 响应示例
  132 +
  133 +```json
  134 +[
  135 + {
  136 + "id": "a2696b9e-2277-11f1-b4c6-00163e0c7c4f",
  137 + "locationCode": "LOC-1",
  138 + "locationName": "Downtown Kitchen",
  139 + "fullAddress": "123 Main St, New York, NY 10001",
  140 + "state": true
  141 + }
  142 +]
  143 +```
  144 +
  145 +### 常见错误
  146 +
  147 +- 未登录或 Token 无效:按网关/ABP 返回 401 及统一错误体
  148 +- 无用户上下文:`用户未登录`
  149 +
  150 +---
  151 +
  152 +## 与其他登录方式的区别
  153 +
  154 +| 场景 | 说明 |
  155 +|------|------|
  156 +| Web 管理端 | 仍使用 RBAC **`AccountService.PostLoginAsync`**,一般为人 **`userName`** + 密码 |
  157 +| 美国版 App | **仅**本模块 **`/api/app/us-app-auth/login`** 使用 **邮箱 + 密码** |
  158 +
  159 +两者共用同一 `User` 表与 JWT 体系;App 端需保证账号已维护 **`Email`** 字段,否则无法通过邮箱登录。
... ...