From 1169e1f2202b8d8668b2b4b1f2e2647e1737b3da Mon Sep 17 00:00:00 2001
From: MSuMshk <2039814060@qq.com>
Date: Sun, 23 Nov 2025 01:25:20 +0800
Subject: [PATCH] =?UTF-8?q?chore:=20=E5=90=8C=E6=AD=A5=E5=BD=93=E5=89=8D?=
=?UTF-8?q?=E5=BC=80=E5=8F=91=E5=86=85=E5=AE=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.vscode/launch.json | 35 +++++
.vscode/settings.json | 3 +
.vscode/tasks.json | 41 ++++++
0_Document/10_TODO.md | 10 +-
TakeoutSaaS.sln | 15 ---
.../Controllers/AuthController.cs | 77 +++++++++++
src/Api/TakeoutSaaS.AdminApi/Program.cs | 10 ++
.../TakeoutSaaS.AdminApi.csproj | 2 -
.../Controllers/AuthController.cs | 51 +++++++
.../Controllers/MeController.cs | 51 +++++++
src/Api/TakeoutSaaS.MiniApi/Program.cs | 2 +
.../TakeoutSaaS.MiniApi.csproj | 1 -
.../Abstractions/IAdminAuthService.cs | 16 +++
.../Identity/Abstractions/IJwtTokenService.cs | 13 ++
.../Abstractions/ILoginRateLimiter.cs | 13 ++
.../Identity/Abstractions/IMiniAuthService.cs | 16 +++
.../Abstractions/IRefreshTokenStore.cs | 16 +++
.../Abstractions/IWeChatAuthService.cs | 13 +-
.../Identity/Contracts/AdminLoginRequest.cs | 17 +++
.../Identity/Contracts/CurrentUserProfile.cs | 18 +++
.../Identity/Contracts/RefreshTokenRequest.cs | 13 ++
.../Identity/Contracts/TokenResponse.cs | 16 +++
.../Identity/Contracts/WeChatLoginRequest.cs | 23 ++++
.../IdentityServiceCollectionExtensions.cs | 28 ++++
.../Identity/Models/RefreshTokenDescriptor.cs | 12 ++
.../Identity/Services/AdminAuthService.cs | 87 ++++++++++++
.../Identity/Services/MiniAuthService.cs | 124 ++++++++++++++++++
.../TakeoutSaaS.Application/Services/.gitkeep | 1 -
.../TakeoutSaaS.Application.csproj | 5 +-
.../Security/ClaimsPrincipalExtensions.cs | 28 ++++
.../Identity/Entities/IdentityUser.cs | 54 ++++++++
.../Identity/Entities/MiniUser.cs | 39 ++++++
.../Repositories/IIdentityUserRepository.cs | 22 ++++
.../Repositories/IMiniUserRepository.cs | 18 +++
src/Gateway/TakeoutSaaS.ApiGateway/Program.cs | 88 +++++++------
.../Extensions/JwtAuthenticationExtensions.cs | 55 ++++++++
.../Extensions/ServiceCollectionExtensions.cs | 99 ++++++++++++++
.../Identity/Options/AdminSeedOptions.cs | 30 +++++
.../Identity/Options/JwtOptions.cs | 25 ++++
.../Identity/Options/LoginRateLimitOptions.cs | 15 +++
.../Options/RefreshTokenStoreOptions.cs | 9 ++
.../Identity/Options/WeChatMiniOptions.cs | 15 +++
.../Persistence/EfIdentityUserRepository.cs | 27 ++++
.../Persistence/EfMiniUserRepository.cs | 54 ++++++++
.../Persistence/IdentityDataSeeder.cs | 94 +++++++++++++
.../Identity/Persistence/IdentityDbContext.cs | 70 ++++++++++
.../Identity/Services/JwtTokenService.cs | 93 +++++++++++++
.../Services/RedisLoginRateLimiter.cs | 52 ++++++++
.../Services/RedisRefreshTokenStore.cs | 63 +++++++++
.../Identity/Services/WeChatAuthService.cs | 79 +++++++++++
.../TakeoutSaaS.Infrastructure.csproj | 8 +-
.../PermissionAuthorizeAttribute.cs | 37 ++++++
...uthorizationServiceCollectionExtensions.cs | 21 +++
.../PermissionAuthorizationHandler.cs | 41 ++++++
.../PermissionAuthorizationPolicyProvider.cs | 69 ++++++++++
.../Policies/PermissionRequirement.cs | 18 +++
.../TakeoutSaaS.Module.Authorization.csproj | 5 +-
.../TakeoutSaaS.Module.Identity.csproj | 11 --
58 files changed, 1886 insertions(+), 82 deletions(-)
create mode 100644 .vscode/launch.json
create mode 100644 .vscode/settings.json
create mode 100644 .vscode/tasks.json
create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs
create mode 100644 src/Api/TakeoutSaaS.MiniApi/Controllers/AuthController.cs
create mode 100644 src/Api/TakeoutSaaS.MiniApi/Controllers/MeController.cs
create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminAuthService.cs
create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Abstractions/IJwtTokenService.cs
create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Abstractions/ILoginRateLimiter.cs
create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Abstractions/IMiniAuthService.cs
create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Abstractions/IRefreshTokenStore.cs
rename src/{Modules/TakeoutSaaS.Module.Identity => Application/TakeoutSaaS.Application/Identity}/Abstractions/IWeChatAuthService.cs (64%)
create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Contracts/AdminLoginRequest.cs
create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Contracts/CurrentUserProfile.cs
create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Contracts/RefreshTokenRequest.cs
create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Contracts/TokenResponse.cs
create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Contracts/WeChatLoginRequest.cs
create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Extensions/IdentityServiceCollectionExtensions.cs
create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Models/RefreshTokenDescriptor.cs
create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs
create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Services/MiniAuthService.cs
delete mode 100644 src/Application/TakeoutSaaS.Application/Services/.gitkeep
create mode 100644 src/Core/TakeoutSaaS.Shared.Web/Security/ClaimsPrincipalExtensions.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Identity/Entities/IdentityUser.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Identity/Entities/MiniUser.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IMiniUserRepository.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/JwtAuthenticationExtensions.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminSeedOptions.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/JwtOptions.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/LoginRateLimitOptions.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/RefreshTokenStoreOptions.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/WeChatMiniOptions.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfMiniUserRepository.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/JwtTokenService.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisLoginRateLimiter.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisRefreshTokenStore.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/WeChatAuthService.cs
create mode 100644 src/Modules/TakeoutSaaS.Module.Authorization/Attributes/PermissionAuthorizeAttribute.cs
create mode 100644 src/Modules/TakeoutSaaS.Module.Authorization/Extensions/AuthorizationServiceCollectionExtensions.cs
create mode 100644 src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationHandler.cs
create mode 100644 src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationPolicyProvider.cs
create mode 100644 src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionRequirement.cs
delete mode 100644 src/Modules/TakeoutSaaS.Module.Identity/TakeoutSaaS.Module.Identity.csproj
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..933c59b
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,35 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ // 使用 IntelliSense 找出 C# 调试存在哪些属性
+ // 将悬停用于现有属性的说明
+ // 有关详细信息,请访问 https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md。
+ "name": ".NET Core Launch (web)",
+ "type": "coreclr",
+ "request": "launch",
+ "preLaunchTask": "build",
+ // 如果已更改目标框架,请确保更新程序路径。
+ "program": "${workspaceFolder}/src/Api/TakeoutSaaS.AdminApi/bin/Debug/net10.0/TakeoutSaaS.AdminApi.dll",
+ "args": [],
+ "cwd": "${workspaceFolder}/src/Api/TakeoutSaaS.AdminApi",
+ "stopAtEntry": false,
+ // 启用在启动 ASP.NET Core 时启动 Web 浏览器。有关详细信息: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser
+ "serverReadyAction": {
+ "action": "openExternally",
+ "pattern": "\\bNow listening on:\\s+(https?://\\S+)"
+ },
+ "env": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "sourceFileMap": {
+ "/Views": "${workspaceFolder}/Views"
+ }
+ },
+ {
+ "name": ".NET Core Attach",
+ "type": "coreclr",
+ "request": "attach"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..02be578
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "chatgpt.openOnStartup": true
+}
\ No newline at end of file
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 0000000..a48b929
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,41 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "build",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "build",
+ "${workspaceFolder}/TakeoutSaaS.sln",
+ "/property:GenerateFullPaths=true",
+ "/consoleloggerparameters:NoSummary;ForceNoAlign"
+ ],
+ "problemMatcher": "$msCompile"
+ },
+ {
+ "label": "publish",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "publish",
+ "${workspaceFolder}/TakeoutSaaS.sln",
+ "/property:GenerateFullPaths=true",
+ "/consoleloggerparameters:NoSummary;ForceNoAlign"
+ ],
+ "problemMatcher": "$msCompile"
+ },
+ {
+ "label": "watch",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "watch",
+ "run",
+ "--project",
+ "${workspaceFolder}/TakeoutSaaS.sln"
+ ],
+ "problemMatcher": "$msCompile"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/0_Document/10_TODO.md b/0_Document/10_TODO.md
index 7157dbf..9190463 100644
--- a/0_Document/10_TODO.md
+++ b/0_Document/10_TODO.md
@@ -1,4 +1,4 @@
-# TODO Roadmap
+# TODO Roadmap
说明:本清单覆盖当前阶段的骨架搭建与核心基础能力(不含部署与CI/CD,留到项目跑通后再做)。
@@ -11,10 +11,10 @@
- [x] 安全中间件:Security Headers、CORS 策略(按端区分)
## B. 认证与权限
-- [ ] JWT 颁发与刷新(AdminApi、MiniApi)
-- [ ] RBAC 权限模型(角色/权限/策略)与特性授权(AdminApi)
-- [ ] 小程序登录(微信 code2Session)并绑定用户账户(MiniApi)
-- [ ] 登录防刷限流(MiniApi)
+- [x] JWT 颁发与刷新(AdminApi、MiniApi)
+- [x] RBAC 权限模型(角色/权限/策略)与特性授权(AdminApi)
+- [x] 小程序登录(微信 code2Session)并绑定用户账户(MiniApi)
+- [x] 登录防刷限流(MiniApi)
## C. 多租户与参数字典
- [ ] 多租户中间件:从 Header/域名解析租户(Shared.Web + Tenancy)
diff --git a/TakeoutSaaS.sln b/TakeoutSaaS.sln
index 86c3d6d..d924b98 100644
--- a/TakeoutSaaS.sln
+++ b/TakeoutSaaS.sln
@@ -29,8 +29,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Infrastructure"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Modules", "Modules", "{EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Identity", "src\Modules\TakeoutSaaS.Module.Identity\TakeoutSaaS.Module.Identity.csproj", "{582EDD19-3C2F-4693-9595-CC367318CD19}"
-EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Authorization", "src\Modules\TakeoutSaaS.Module.Authorization\TakeoutSaaS.Module.Authorization.csproj", "{6CB8487D-5C74-487C-9D84-E57838BDA015}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Tenancy", "src\Modules\TakeoutSaaS.Module.Tenancy\TakeoutSaaS.Module.Tenancy.csproj", "{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}"
@@ -129,18 +127,6 @@ Global
{80B45C7D-9423-400A-8279-40D95BFEBC9D}.Release|x64.Build.0 = Release|Any CPU
{80B45C7D-9423-400A-8279-40D95BFEBC9D}.Release|x86.ActiveCfg = Release|Any CPU
{80B45C7D-9423-400A-8279-40D95BFEBC9D}.Release|x86.Build.0 = Release|Any CPU
- {582EDD19-3C2F-4693-9595-CC367318CD19}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {582EDD19-3C2F-4693-9595-CC367318CD19}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {582EDD19-3C2F-4693-9595-CC367318CD19}.Debug|x64.ActiveCfg = Debug|Any CPU
- {582EDD19-3C2F-4693-9595-CC367318CD19}.Debug|x64.Build.0 = Debug|Any CPU
- {582EDD19-3C2F-4693-9595-CC367318CD19}.Debug|x86.ActiveCfg = Debug|Any CPU
- {582EDD19-3C2F-4693-9595-CC367318CD19}.Debug|x86.Build.0 = Debug|Any CPU
- {582EDD19-3C2F-4693-9595-CC367318CD19}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {582EDD19-3C2F-4693-9595-CC367318CD19}.Release|Any CPU.Build.0 = Release|Any CPU
- {582EDD19-3C2F-4693-9595-CC367318CD19}.Release|x64.ActiveCfg = Release|Any CPU
- {582EDD19-3C2F-4693-9595-CC367318CD19}.Release|x64.Build.0 = Release|Any CPU
- {582EDD19-3C2F-4693-9595-CC367318CD19}.Release|x86.ActiveCfg = Release|Any CPU
- {582EDD19-3C2F-4693-9595-CC367318CD19}.Release|x86.Build.0 = Release|Any CPU
{6CB8487D-5C74-487C-9D84-E57838BDA015}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6CB8487D-5C74-487C-9D84-E57838BDA015}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6CB8487D-5C74-487C-9D84-E57838BDA015}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -242,7 +228,6 @@ Global
{9048EB7F-3875-A59E-E36B-5BD4C6F2A282} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{80B45C7D-9423-400A-8279-40D95BFEBC9D} = {9048EB7F-3875-A59E-E36B-5BD4C6F2A282}
{EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
- {582EDD19-3C2F-4693-9595-CC367318CD19} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
{6CB8487D-5C74-487C-9D84-E57838BDA015} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D} = {81034408-37C8-1011-444E-4C15C2FADA8E}
diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs
new file mode 100644
index 0000000..04bedfc
--- /dev/null
+++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs
@@ -0,0 +1,77 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using TakeoutSaaS.Application.Identity.Abstractions;
+using TakeoutSaaS.Application.Identity.Contracts;
+using TakeoutSaaS.Module.Authorization.Attributes;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Results;
+using TakeoutSaaS.Shared.Web.Api;
+using TakeoutSaaS.Shared.Web.Security;
+
+namespace TakeoutSaaS.AdminApi.Controllers;
+
+///
+/// 管理后台认证接口
+///
+[ApiVersion("1.0")]
+[Authorize]
+[Route("api/admin/v{version:apiVersion}/auth")]
+public sealed class AuthController : BaseApiController
+{
+ private readonly IAdminAuthService _authService;
+
+ ///
+ ///
+ ///
+ ///
+ public AuthController(IAdminAuthService authService)
+ {
+ _authService = authService;
+ }
+
+ ///
+ /// 登录获取 Token
+ ///
+ [HttpPost("login")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task>> Login([FromBody] AdminLoginRequest request, CancellationToken cancellationToken)
+ {
+ var response = await _authService.LoginAsync(request, cancellationToken);
+ return Ok(ApiResponse.Ok(response));
+ }
+
+ ///
+ /// 刷新 Token
+ ///
+ [HttpPost("refresh")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task>> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
+ {
+ var response = await _authService.RefreshTokenAsync(request, cancellationToken);
+ return Ok(ApiResponse.Ok(response));
+ }
+
+ ///
+ /// 获取当前用户信息
+ ///
+ [HttpGet("profile")]
+ [PermissionAuthorize("identity:profile:read")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task>> GetProfile(CancellationToken cancellationToken)
+ {
+ var userId = User.GetUserId();
+ if (userId == Guid.Empty)
+ {
+ return Unauthorized(ApiResponse.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识"));
+ }
+
+ var profile = await _authService.GetProfileAsync(userId, cancellationToken);
+ return Ok(ApiResponse.Ok(profile));
+ }
+}
diff --git a/src/Api/TakeoutSaaS.AdminApi/Program.cs b/src/Api/TakeoutSaaS.AdminApi/Program.cs
index 7e650e0..5359f9f 100644
--- a/src/Api/TakeoutSaaS.AdminApi/Program.cs
+++ b/src/Api/TakeoutSaaS.AdminApi/Program.cs
@@ -6,6 +6,9 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Serilog;
+using TakeoutSaaS.Application.Identity.Extensions;
+using TakeoutSaaS.Infrastructure.Identity.Extensions;
+using TakeoutSaaS.Module.Authorization.Extensions;
using TakeoutSaaS.Module.Tenancy;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
using TakeoutSaaS.Shared.Web.Extensions;
@@ -28,6 +31,11 @@ builder.Services.AddSharedSwagger(options =>
options.Description = "管理后台 API 文档";
options.EnableAuthorization = true;
});
+builder.Services.AddIdentityApplication();
+builder.Services.AddIdentityInfrastructure(builder.Configuration, enableAdminSeed: true);
+builder.Services.AddJwtAuthentication(builder.Configuration);
+builder.Services.AddAuthorization();
+builder.Services.AddPermissionAuthorization();
var adminOrigins = ResolveCorsOrigins(builder.Configuration, "Cors:Admin");
builder.Services.AddCors(options =>
@@ -44,6 +52,8 @@ var app = builder.Build();
app.UseCors("AdminApiCors");
app.UseSharedWebCore();
+app.UseAuthentication();
+app.UseAuthorization();
app.UseSharedSwagger();
app.MapControllers();
diff --git a/src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj b/src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj
index 9a71725..d4c9b45 100644
--- a/src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj
+++ b/src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj
@@ -13,9 +13,7 @@
-
-
diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/AuthController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/AuthController.cs
new file mode 100644
index 0000000..2c80505
--- /dev/null
+++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/AuthController.cs
@@ -0,0 +1,51 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using TakeoutSaaS.Application.Identity.Abstractions;
+using TakeoutSaaS.Application.Identity.Contracts;
+using TakeoutSaaS.Shared.Abstractions.Results;
+using TakeoutSaaS.Shared.Web.Api;
+
+namespace TakeoutSaaS.MiniApi.Controllers;
+
+///
+/// 小程序登录认证
+///
+[ApiVersion("1.0")]
+[Authorize]
+[Route("api/mini/v{version:apiVersion}/auth")]
+public sealed class AuthController : BaseApiController
+{
+ private readonly IMiniAuthService _authService;
+
+ public AuthController(IMiniAuthService authService)
+ {
+ _authService = authService;
+ }
+
+ ///
+ /// 微信登录
+ ///
+ [HttpPost("wechat/login")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task>> LoginWithWeChat([FromBody] WeChatLoginRequest request, CancellationToken cancellationToken)
+ {
+ var response = await _authService.LoginWithWeChatAsync(request, cancellationToken);
+ return Ok(ApiResponse.Ok(response));
+ }
+
+ ///
+ /// 刷新 Token
+ ///
+ [HttpPost("refresh")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task>> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
+ {
+ var response = await _authService.RefreshTokenAsync(request, cancellationToken);
+ return Ok(ApiResponse.Ok(response));
+ }
+}
diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/MeController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/MeController.cs
new file mode 100644
index 0000000..8ccdc14
--- /dev/null
+++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/MeController.cs
@@ -0,0 +1,51 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using TakeoutSaaS.Application.Identity.Abstractions;
+using TakeoutSaaS.Application.Identity.Contracts;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Results;
+using TakeoutSaaS.Shared.Web.Api;
+using TakeoutSaaS.Shared.Web.Security;
+
+namespace TakeoutSaaS.MiniApi.Controllers;
+
+///
+/// 当前用户信息
+///
+[ApiVersion("1.0")]
+[Authorize]
+[Route("api/mini/v{version:apiVersion}/me")]
+public sealed class MeController : BaseApiController
+{
+ private readonly IMiniAuthService _authService;
+
+ ///
+ ///
+ ///
+ ///
+ public MeController(IMiniAuthService authService)
+ {
+ _authService = authService;
+ }
+
+ ///
+ /// 获取用户档案
+ ///
+ [HttpGet]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task>> Get(CancellationToken cancellationToken)
+ {
+ var userId = User.GetUserId();
+ if (userId == Guid.Empty)
+ {
+ return Unauthorized(ApiResponse.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识"));
+ }
+
+ var profile = await _authService.GetProfileAsync(userId, cancellationToken);
+ return Ok(ApiResponse.Ok(profile));
+ }
+}
diff --git a/src/Api/TakeoutSaaS.MiniApi/Program.cs b/src/Api/TakeoutSaaS.MiniApi/Program.cs
index d6621d5..ccdb0fd 100644
--- a/src/Api/TakeoutSaaS.MiniApi/Program.cs
+++ b/src/Api/TakeoutSaaS.MiniApi/Program.cs
@@ -6,6 +6,8 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Serilog;
+using TakeoutSaaS.Application.Identity.Extensions;
+using TakeoutSaaS.Infrastructure.Identity.Extensions;
using TakeoutSaaS.Module.Tenancy;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
using TakeoutSaaS.Shared.Web.Extensions;
diff --git a/src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj b/src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj
index 6be5000..4a352b2 100644
--- a/src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj
+++ b/src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj
@@ -13,7 +13,6 @@
-
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminAuthService.cs
new file mode 100644
index 0000000..f60dffb
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminAuthService.cs
@@ -0,0 +1,16 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using TakeoutSaaS.Application.Identity.Contracts;
+
+namespace TakeoutSaaS.Application.Identity.Abstractions;
+
+///
+/// 管理后台认证服务。
+///
+public interface IAdminAuthService
+{
+ Task LoginAsync(AdminLoginRequest request, CancellationToken cancellationToken = default);
+ Task RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default);
+ Task GetProfileAsync(Guid userId, CancellationToken cancellationToken = default);
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IJwtTokenService.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IJwtTokenService.cs
new file mode 100644
index 0000000..4235181
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IJwtTokenService.cs
@@ -0,0 +1,13 @@
+using System.Threading;
+using System.Threading.Tasks;
+using TakeoutSaaS.Application.Identity.Contracts;
+
+namespace TakeoutSaaS.Application.Identity.Abstractions;
+
+///
+/// JWT 令牌服务契约。
+///
+public interface IJwtTokenService
+{
+ Task CreateTokensAsync(CurrentUserProfile profile, bool isNewUser = false, CancellationToken cancellationToken = default);
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/ILoginRateLimiter.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/ILoginRateLimiter.cs
new file mode 100644
index 0000000..f4a7c5c
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/ILoginRateLimiter.cs
@@ -0,0 +1,13 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace TakeoutSaaS.Application.Identity.Abstractions;
+
+///
+/// 登录限流器。
+///
+public interface ILoginRateLimiter
+{
+ Task EnsureAllowedAsync(string key, CancellationToken cancellationToken = default);
+ Task ResetAsync(string key, CancellationToken cancellationToken = default);
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IMiniAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IMiniAuthService.cs
new file mode 100644
index 0000000..11efdb4
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IMiniAuthService.cs
@@ -0,0 +1,16 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using TakeoutSaaS.Application.Identity.Contracts;
+
+namespace TakeoutSaaS.Application.Identity.Abstractions;
+
+///
+/// 小程序认证服务。
+///
+public interface IMiniAuthService
+{
+ Task LoginWithWeChatAsync(WeChatLoginRequest request, CancellationToken cancellationToken = default);
+ Task RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default);
+ Task GetProfileAsync(Guid userId, CancellationToken cancellationToken = default);
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IRefreshTokenStore.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IRefreshTokenStore.cs
new file mode 100644
index 0000000..d966ca2
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IRefreshTokenStore.cs
@@ -0,0 +1,16 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using TakeoutSaaS.Application.Identity.Models;
+
+namespace TakeoutSaaS.Application.Identity.Abstractions;
+
+///
+/// 刷新令牌存储。
+///
+public interface IRefreshTokenStore
+{
+ Task IssueAsync(Guid userId, DateTime expiresAt, CancellationToken cancellationToken = default);
+ Task GetAsync(string refreshToken, CancellationToken cancellationToken = default);
+ Task RevokeAsync(string refreshToken, CancellationToken cancellationToken = default);
+}
diff --git a/src/Modules/TakeoutSaaS.Module.Identity/Abstractions/IWeChatAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IWeChatAuthService.cs
similarity index 64%
rename from src/Modules/TakeoutSaaS.Module.Identity/Abstractions/IWeChatAuthService.cs
rename to src/Application/TakeoutSaaS.Application/Identity/Abstractions/IWeChatAuthService.cs
index f14abfe..417c8b9 100644
--- a/src/Modules/TakeoutSaaS.Module.Identity/Abstractions/IWeChatAuthService.cs
+++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IWeChatAuthService.cs
@@ -1,18 +1,18 @@
-namespace TakeoutSaaS.Module.Identity.Abstractions;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace TakeoutSaaS.Application.Identity.Abstractions;
///
-/// 微信登录服务抽象(code2Session)
+/// 微信 code2Session 服务契约。
///
public interface IWeChatAuthService
{
- ///
- /// 使用小程序登录 code 换取 openid/unionid/session_key
- ///
Task Code2SessionAsync(string code, CancellationToken cancellationToken = default);
}
///
-/// 微信会话信息
+/// 微信会话信息。
///
public sealed class WeChatSessionInfo
{
@@ -20,4 +20,3 @@ public sealed class WeChatSessionInfo
public string? UnionId { get; init; }
public string SessionKey { get; init; } = string.Empty;
}
-
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/AdminLoginRequest.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/AdminLoginRequest.cs
new file mode 100644
index 0000000..fbbe58d
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/AdminLoginRequest.cs
@@ -0,0 +1,17 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace TakeoutSaaS.Application.Identity.Contracts;
+
+///
+/// 管理后台登录请求。
+///
+public sealed class AdminLoginRequest
+{
+ [Required]
+ [MaxLength(64)]
+ public string Account { get; set; } = string.Empty;
+
+ [Required]
+ [MaxLength(128)]
+ public string Password { get; set; } = string.Empty;
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/CurrentUserProfile.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/CurrentUserProfile.cs
new file mode 100644
index 0000000..be4eb44
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/CurrentUserProfile.cs
@@ -0,0 +1,18 @@
+using System;
+
+namespace TakeoutSaaS.Application.Identity.Contracts;
+
+///
+/// 登录用户档案。
+///
+public sealed class CurrentUserProfile
+{
+ public Guid UserId { get; init; }
+ public string Account { get; init; } = string.Empty;
+ public string DisplayName { get; init; } = string.Empty;
+ public Guid TenantId { get; init; }
+ public Guid? MerchantId { get; init; }
+ public string[] Roles { get; init; } = Array.Empty();
+ public string[] Permissions { get; init; } = Array.Empty();
+ public string? Avatar { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/RefreshTokenRequest.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/RefreshTokenRequest.cs
new file mode 100644
index 0000000..67b3c53
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/RefreshTokenRequest.cs
@@ -0,0 +1,13 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace TakeoutSaaS.Application.Identity.Contracts;
+
+///
+/// 刷新令牌请求。
+///
+public sealed class RefreshTokenRequest
+{
+ [Required]
+ [MaxLength(256)]
+ public string RefreshToken { get; set; } = string.Empty;
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/TokenResponse.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/TokenResponse.cs
new file mode 100644
index 0000000..57b3ded
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/TokenResponse.cs
@@ -0,0 +1,16 @@
+using System;
+
+namespace TakeoutSaaS.Application.Identity.Contracts;
+
+///
+/// Access/Refresh 令牌响应。
+///
+public class TokenResponse
+{
+ public string AccessToken { get; init; } = string.Empty;
+ public DateTime AccessTokenExpiresAt { get; init; }
+ public string RefreshToken { get; init; } = string.Empty;
+ public DateTime RefreshTokenExpiresAt { get; init; }
+ public CurrentUserProfile? User { get; init; }
+ public bool IsNewUser { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/WeChatLoginRequest.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/WeChatLoginRequest.cs
new file mode 100644
index 0000000..27152e6
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/WeChatLoginRequest.cs
@@ -0,0 +1,23 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace TakeoutSaaS.Application.Identity.Contracts;
+
+///
+/// 微信小程序登录请求。
+///
+public sealed class WeChatLoginRequest
+{
+ [Required]
+ [MaxLength(128)]
+ public string Code { get; set; } = string.Empty;
+
+ [MaxLength(64)]
+ public string? Nickname { get; set; }
+
+ [MaxLength(256)]
+ public string? Avatar { get; set; }
+
+ public string? EncryptedData { get; set; }
+
+ public string? Iv { get; set; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Extensions/IdentityServiceCollectionExtensions.cs b/src/Application/TakeoutSaaS.Application/Identity/Extensions/IdentityServiceCollectionExtensions.cs
new file mode 100644
index 0000000..c5df667
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Extensions/IdentityServiceCollectionExtensions.cs
@@ -0,0 +1,28 @@
+using Microsoft.Extensions.DependencyInjection;
+using TakeoutSaaS.Application.Identity.Abstractions;
+using TakeoutSaaS.Application.Identity.Services;
+
+namespace TakeoutSaaS.Application.Identity.Extensions;
+
+///
+/// 应用层身份认证服务注入
+///
+public static class IdentityServiceCollectionExtensions
+{
+ ///
+ /// 注册身份认证相关应用服务
+ ///
+ /// 服务集合
+ /// 是否注册小程序认证服务
+ public static IServiceCollection AddIdentityApplication(this IServiceCollection services, bool enableMiniSupport = false)
+ {
+ services.AddScoped();
+
+ if (enableMiniSupport)
+ {
+ services.AddScoped();
+ }
+
+ return services;
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Models/RefreshTokenDescriptor.cs b/src/Application/TakeoutSaaS.Application/Identity/Models/RefreshTokenDescriptor.cs
new file mode 100644
index 0000000..f508c3e
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Models/RefreshTokenDescriptor.cs
@@ -0,0 +1,12 @@
+using System;
+
+namespace TakeoutSaaS.Application.Identity.Models;
+
+///
+/// 刷新令牌描述。
+///
+public sealed record class RefreshTokenDescriptor(
+ string Token,
+ Guid UserId,
+ DateTime ExpiresAt,
+ bool Revoked);
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs
new file mode 100644
index 0000000..477e53f
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs
@@ -0,0 +1,87 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Identity;
+using TakeoutSaaS.Application.Identity.Abstractions;
+using TakeoutSaaS.Application.Identity.Contracts;
+using TakeoutSaaS.Domain.Identity.Entities;
+using TakeoutSaaS.Domain.Identity.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+
+namespace TakeoutSaaS.Application.Identity.Services;
+
+///
+/// 管理后台认证服务实现。
+///
+public sealed class AdminAuthService : IAdminAuthService
+{
+ private readonly IIdentityUserRepository _userRepository;
+ private readonly IPasswordHasher _passwordHasher;
+ private readonly IJwtTokenService _jwtTokenService;
+ private readonly IRefreshTokenStore _refreshTokenStore;
+
+ public AdminAuthService(
+ IIdentityUserRepository userRepository,
+ IPasswordHasher passwordHasher,
+ IJwtTokenService jwtTokenService,
+ IRefreshTokenStore refreshTokenStore)
+ {
+ _userRepository = userRepository;
+ _passwordHasher = passwordHasher;
+ _jwtTokenService = jwtTokenService;
+ _refreshTokenStore = refreshTokenStore;
+ }
+
+ public async Task LoginAsync(AdminLoginRequest request, CancellationToken cancellationToken = default)
+ {
+ var user = await _userRepository.FindByAccountAsync(request.Account, cancellationToken)
+ ?? throw new BusinessException(ErrorCodes.Unauthorized, "账号或密码错误");
+
+ var result = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, request.Password);
+ if (result == PasswordVerificationResult.Failed)
+ {
+ throw new BusinessException(ErrorCodes.Unauthorized, "账号或密码错误");
+ }
+
+ var profile = BuildProfile(user);
+ return await _jwtTokenService.CreateTokensAsync(profile, false, cancellationToken);
+ }
+
+ public async Task RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default)
+ {
+ var descriptor = await _refreshTokenStore.GetAsync(request.RefreshToken, cancellationToken);
+ if (descriptor == null || descriptor.ExpiresAt <= DateTime.UtcNow || descriptor.Revoked)
+ {
+ throw new BusinessException(ErrorCodes.Unauthorized, "RefreshToken 无效或已过期");
+ }
+
+ var user = await _userRepository.FindByIdAsync(descriptor.UserId, cancellationToken)
+ ?? throw new BusinessException(ErrorCodes.Unauthorized, "用户不存在");
+
+ await _refreshTokenStore.RevokeAsync(descriptor.Token, cancellationToken);
+ var profile = BuildProfile(user);
+ return await _jwtTokenService.CreateTokensAsync(profile, false, cancellationToken);
+ }
+
+ public async Task GetProfileAsync(Guid userId, CancellationToken cancellationToken = default)
+ {
+ var user = await _userRepository.FindByIdAsync(userId, cancellationToken)
+ ?? throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
+
+ return BuildProfile(user);
+ }
+
+ private static CurrentUserProfile BuildProfile(IdentityUser user)
+ => new()
+ {
+ UserId = user.Id,
+ Account = user.Account,
+ DisplayName = user.DisplayName,
+ TenantId = user.TenantId,
+ MerchantId = user.MerchantId,
+ Roles = user.Roles,
+ Permissions = user.Permissions,
+ Avatar = user.Avatar
+ };
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Services/MiniAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Services/MiniAuthService.cs
new file mode 100644
index 0000000..0d27c61
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Services/MiniAuthService.cs
@@ -0,0 +1,124 @@
+using System;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using TakeoutSaaS.Application.Identity.Abstractions;
+using TakeoutSaaS.Application.Identity.Contracts;
+using TakeoutSaaS.Domain.Identity.Entities;
+using TakeoutSaaS.Domain.Identity.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.Identity.Services;
+
+///
+/// 小程序认证服务实现。
+///
+public sealed class MiniAuthService : IMiniAuthService
+{
+ private readonly IWeChatAuthService _weChatAuthService;
+ private readonly IMiniUserRepository _miniUserRepository;
+ private readonly IJwtTokenService _jwtTokenService;
+ private readonly IRefreshTokenStore _refreshTokenStore;
+ private readonly ILoginRateLimiter _rateLimiter;
+ private readonly IHttpContextAccessor _httpContextAccessor;
+ private readonly ITenantProvider _tenantProvider;
+
+ public MiniAuthService(
+ IWeChatAuthService weChatAuthService,
+ IMiniUserRepository miniUserRepository,
+ IJwtTokenService jwtTokenService,
+ IRefreshTokenStore refreshTokenStore,
+ ILoginRateLimiter rateLimiter,
+ IHttpContextAccessor httpContextAccessor,
+ ITenantProvider tenantProvider)
+ {
+ _weChatAuthService = weChatAuthService;
+ _miniUserRepository = miniUserRepository;
+ _jwtTokenService = jwtTokenService;
+ _refreshTokenStore = refreshTokenStore;
+ _rateLimiter = rateLimiter;
+ _httpContextAccessor = httpContextAccessor;
+ _tenantProvider = tenantProvider;
+ }
+
+ public async Task LoginWithWeChatAsync(WeChatLoginRequest request, CancellationToken cancellationToken = default)
+ {
+ var throttleKey = BuildThrottleKey();
+ await _rateLimiter.EnsureAllowedAsync(throttleKey, cancellationToken);
+
+ var session = await _weChatAuthService.Code2SessionAsync(request.Code, cancellationToken);
+ if (string.IsNullOrWhiteSpace(session.OpenId))
+ {
+ throw new BusinessException(ErrorCodes.Unauthorized, "获取微信用户信息失败");
+ }
+
+ var tenantId = _tenantProvider.GetCurrentTenantId();
+ if (tenantId == Guid.Empty)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "缺少租户标识");
+ }
+ var (user, isNew) = await GetOrBindMiniUserAsync(session.OpenId, session.UnionId, request.Nickname, request.Avatar, tenantId, cancellationToken);
+
+ await _rateLimiter.ResetAsync(throttleKey, cancellationToken);
+ var profile = BuildProfile(user);
+ return await _jwtTokenService.CreateTokensAsync(profile, isNew, cancellationToken);
+ }
+
+ public async Task RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default)
+ {
+ var descriptor = await _refreshTokenStore.GetAsync(request.RefreshToken, cancellationToken);
+ if (descriptor == null || descriptor.ExpiresAt <= DateTime.UtcNow || descriptor.Revoked)
+ {
+ throw new BusinessException(ErrorCodes.Unauthorized, "RefreshToken 无效或已过期");
+ }
+
+ var user = await _miniUserRepository.FindByIdAsync(descriptor.UserId, cancellationToken)
+ ?? throw new BusinessException(ErrorCodes.Unauthorized, "用户不存在");
+
+ await _refreshTokenStore.RevokeAsync(descriptor.Token, cancellationToken);
+ var profile = BuildProfile(user);
+ return await _jwtTokenService.CreateTokensAsync(profile, false, cancellationToken);
+ }
+
+ public async Task GetProfileAsync(Guid userId, CancellationToken cancellationToken = default)
+ {
+ var user = await _miniUserRepository.FindByIdAsync(userId, cancellationToken)
+ ?? throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
+
+ return BuildProfile(user);
+ }
+
+ private async Task<(MiniUser user, bool isNew)> GetOrBindMiniUserAsync(string openId, string? unionId, string? nickname, string? avatar, Guid tenantId, CancellationToken cancellationToken)
+ {
+ var existing = await _miniUserRepository.FindByOpenIdAsync(openId, cancellationToken);
+ if (existing != null)
+ {
+ return (existing, false);
+ }
+
+ var created = await _miniUserRepository.CreateOrUpdateAsync(openId, unionId, nickname, avatar, tenantId, cancellationToken);
+ return (created, true);
+ }
+
+ private static CurrentUserProfile BuildProfile(MiniUser user)
+ => new()
+ {
+ UserId = user.Id,
+ Account = user.OpenId,
+ DisplayName = user.Nickname,
+ TenantId = user.TenantId,
+ MerchantId = null,
+ Roles = Array.Empty(),
+ Permissions = Array.Empty(),
+ Avatar = user.Avatar
+ };
+
+ private string BuildThrottleKey()
+ {
+ var ip = _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress ?? IPAddress.Loopback;
+ return $"mini-login:{ip}";
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/Services/.gitkeep b/src/Application/TakeoutSaaS.Application/Services/.gitkeep
deleted file mode 100644
index 8b13789..0000000
--- a/src/Application/TakeoutSaaS.Application/Services/.gitkeep
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj b/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj
index 88dea96..233eb4d 100644
--- a/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj
+++ b/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj
@@ -4,9 +4,12 @@
enable
enable
+
+
+
+
-
diff --git a/src/Core/TakeoutSaaS.Shared.Web/Security/ClaimsPrincipalExtensions.cs b/src/Core/TakeoutSaaS.Shared.Web/Security/ClaimsPrincipalExtensions.cs
new file mode 100644
index 0000000..8502109
--- /dev/null
+++ b/src/Core/TakeoutSaaS.Shared.Web/Security/ClaimsPrincipalExtensions.cs
@@ -0,0 +1,28 @@
+using System;
+using System.Security.Claims;
+
+namespace TakeoutSaaS.Shared.Web.Security;
+
+///
+/// ClaimsPrincipal 便捷扩展
+///
+public static class ClaimsPrincipalExtensions
+{
+ ///
+ /// 获取当前用户 Id(不存在时返回 Guid.Empty)
+ ///
+ public static Guid GetUserId(this ClaimsPrincipal? principal)
+ {
+ if (principal == null)
+ {
+ return Guid.Empty;
+ }
+
+ var identifier = principal.FindFirstValue(ClaimTypes.NameIdentifier)
+ ?? principal.FindFirstValue("sub");
+
+ return Guid.TryParse(identifier, out var userId)
+ ? userId
+ : Guid.Empty;
+ }
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Entities/IdentityUser.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/IdentityUser.cs
new file mode 100644
index 0000000..123208a
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/IdentityUser.cs
@@ -0,0 +1,54 @@
+using System;
+
+namespace TakeoutSaaS.Domain.Identity.Entities;
+
+///
+/// 后台账号实体(平台/商户/员工)。
+///
+public sealed class IdentityUser
+{
+ ///
+ /// 用户 ID。
+ ///
+ public Guid Id { get; set; }
+
+ ///
+ /// 登录账号。
+ ///
+ public string Account { get; set; } = string.Empty;
+
+ ///
+ /// 展示名称。
+ ///
+ public string DisplayName { get; set; } = string.Empty;
+
+ ///
+ /// 密码哈希。
+ ///
+ public string PasswordHash { get; set; } = string.Empty;
+
+ ///
+ /// 所属租户。
+ ///
+ public Guid TenantId { get; set; }
+
+ ///
+ /// 所属商户(平台管理员为空)。
+ ///
+ public Guid? MerchantId { get; set; }
+
+ ///
+ /// 角色集合。
+ ///
+ public string[] Roles { get; set; } = Array.Empty();
+
+ ///
+ /// 权限集合。
+ ///
+ public string[] Permissions { get; set; } = Array.Empty();
+
+ ///
+ /// 头像地址。
+ ///
+ public string? Avatar { get; set; }
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Entities/MiniUser.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/MiniUser.cs
new file mode 100644
index 0000000..3c4fdf8
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/MiniUser.cs
@@ -0,0 +1,39 @@
+using System;
+
+namespace TakeoutSaaS.Domain.Identity.Entities;
+
+///
+/// 小程序用户。
+///
+public sealed class MiniUser
+{
+ ///
+ /// 用户 ID。
+ ///
+ public Guid Id { get; set; }
+
+ ///
+ /// 微信 OpenId。
+ ///
+ public string OpenId { get; set; } = string.Empty;
+
+ ///
+ /// 微信 UnionId,可为空。
+ ///
+ public string? UnionId { get; set; }
+
+ ///
+ /// 昵称。
+ ///
+ public string Nickname { get; set; } = string.Empty;
+
+ ///
+ /// 头像地址。
+ ///
+ public string? Avatar { get; set; }
+
+ ///
+ /// 所属租户。
+ ///
+ public Guid TenantId { get; set; }
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs
new file mode 100644
index 0000000..2d5eae1
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs
@@ -0,0 +1,22 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using TakeoutSaaS.Domain.Identity.Entities;
+
+namespace TakeoutSaaS.Domain.Identity.Repositories;
+
+///
+/// 后台用户仓储契约。
+///
+public interface IIdentityUserRepository
+{
+ ///
+ /// 根据账号获取后台用户。
+ ///
+ Task FindByAccountAsync(string account, CancellationToken cancellationToken = default);
+
+ ///
+ /// 根据 ID 获取后台用户。
+ ///
+ Task FindByIdAsync(Guid userId, CancellationToken cancellationToken = default);
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IMiniUserRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IMiniUserRepository.cs
new file mode 100644
index 0000000..db3e810
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IMiniUserRepository.cs
@@ -0,0 +1,18 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using TakeoutSaaS.Domain.Identity.Entities;
+
+namespace TakeoutSaaS.Domain.Identity.Repositories;
+
+///
+/// 小程序用户仓储契约。
+///
+public interface IMiniUserRepository
+{
+ Task FindByOpenIdAsync(string openId, CancellationToken cancellationToken = default);
+
+ Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default);
+
+ Task CreateOrUpdateAsync(string openId, string? unionId, string? nickname, string? avatar, Guid tenantId, CancellationToken cancellationToken = default);
+}
diff --git a/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs b/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs
index 3ec985c..1c832fa 100644
--- a/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs
+++ b/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs
@@ -1,49 +1,63 @@
+using System.Collections.Generic;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
+using Yarp.ReverseProxy.Configuration;
var builder = WebApplication.CreateBuilder(args);
-builder.Services.AddReverseProxy()
- .LoadFromMemory(new()
+var routes = new[]
+{
+ new RouteConfig
{
- Clusters =
+ RouteId = "admin-route",
+ ClusterId = "admin",
+ Match = new() { Path = "/api/admin/{**catch-all}" }
+ },
+ new RouteConfig
+ {
+ RouteId = "mini-route",
+ ClusterId = "mini",
+ Match = new() { Path = "/api/mini/{**catch-all}" }
+ },
+ new RouteConfig
+ {
+ RouteId = "user-route",
+ ClusterId = "user",
+ Match = new() { Path = "/api/user/{**catch-all}" }
+ }
+};
+
+var clusters = new[]
+{
+ new ClusterConfig
+ {
+ ClusterId = "admin",
+ Destinations = new Dictionary
{
- ["admin"] = new()
- {
- Destinations = { ["d1"] = new() { Address = "http://localhost:5001/" } }
- },
- ["mini"] = new()
- {
- Destinations = { ["d1"] = new() { Address = "http://localhost:5002/" } }
- },
- ["user"] = new()
- {
- Destinations = { ["d1"] = new() { Address = "http://localhost:5003/" } }
- }
- },
- Routes =
- {
- new()
- {
- RouteId = "admin-route",
- ClusterId = "admin",
- Match = new() { Path = "/api/admin/{**catch-all}" }
- },
- new()
- {
- RouteId = "mini-route",
- ClusterId = "mini",
- Match = new() { Path = "/api/mini/{**catch-all}" }
- },
- new()
- {
- RouteId = "user-route",
- ClusterId = "user",
- Match = new() { Path = "/api/user/{**catch-all}" }
- }
+ ["d1"] = new() { Address = "http://localhost:5001/" }
}
- });
+ },
+ new ClusterConfig
+ {
+ ClusterId = "mini",
+ Destinations = new Dictionary
+ {
+ ["d1"] = new() { Address = "http://localhost:5002/" }
+ }
+ },
+ new ClusterConfig
+ {
+ ClusterId = "user",
+ Destinations = new Dictionary
+ {
+ ["d1"] = new() { Address = "http://localhost:5003/" }
+ }
+ }
+};
+
+builder.Services.AddReverseProxy()
+ .LoadFromMemory(routes, clusters);
var app = builder.Build();
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/JwtAuthenticationExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/JwtAuthenticationExtensions.cs
new file mode 100644
index 0000000..79c475c
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/JwtAuthenticationExtensions.cs
@@ -0,0 +1,55 @@
+using System;
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using System.Text;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.IdentityModel.Tokens;
+using TakeoutSaaS.Infrastructure.Identity.Options;
+
+namespace TakeoutSaaS.Infrastructure.Identity.Extensions;
+
+///
+/// JWT 认证扩展
+///
+public static class JwtAuthenticationExtensions
+{
+ ///
+ /// 配置 JWT Bearer 认证
+ ///
+ public static IServiceCollection AddJwtAuthentication(this IServiceCollection services, IConfiguration configuration)
+ {
+ var jwtOptions = configuration.GetSection("Identity:Jwt").Get()
+ ?? throw new InvalidOperationException("缺少 Identity:Jwt 配置");
+
+ JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
+ JwtSecurityTokenHandler.DefaultOutboundClaimTypeMap.Clear();
+
+ services
+ .AddAuthentication(options =>
+ {
+ options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
+ options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
+ })
+ .AddJwtBearer(options =>
+ {
+ options.RequireHttpsMetadata = false;
+ options.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuer = true,
+ ValidIssuer = jwtOptions.Issuer,
+ ValidateAudience = true,
+ ValidAudience = jwtOptions.Audience,
+ ValidateIssuerSigningKey = true,
+ IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.Secret)),
+ ValidateLifetime = true,
+ ClockSkew = TimeSpan.FromMinutes(1),
+ NameClaimType = ClaimTypes.NameIdentifier,
+ RoleClaimType = ClaimTypes.Role
+ };
+ });
+
+ return services;
+ }
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..6b588c6
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,99 @@
+using System;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using TakeoutSaaS.Application.Identity.Abstractions;
+using TakeoutSaaS.Domain.Identity.Entities;
+using TakeoutSaaS.Domain.Identity.Repositories;
+using TakeoutSaaS.Infrastructure.Identity.Options;
+using TakeoutSaaS.Infrastructure.Identity.Persistence;
+using TakeoutSaaS.Infrastructure.Identity.Services;
+using DomainIdentityUser = TakeoutSaaS.Domain.Identity.Entities.IdentityUser;
+
+namespace TakeoutSaaS.Infrastructure.Identity.Extensions;
+
+///
+/// 身份认证基础设施注入
+///
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// 注册身份认证基础设施(数据库、Redis、JWT、限流等)
+ ///
+ /// 服务集合
+ /// 配置源
+ /// 是否启用小程序相关依赖(如微信登录)
+ /// 是否启用后台账号初始化
+ public static IServiceCollection AddIdentityInfrastructure(
+ this IServiceCollection services,
+ IConfiguration configuration,
+ bool enableMiniFeatures = false,
+ bool enableAdminSeed = false)
+ {
+ var dbConnection = configuration.GetConnectionString("IdentityDatabase");
+ if (string.IsNullOrWhiteSpace(dbConnection))
+ {
+ throw new InvalidOperationException("缺少 IdentityDatabase 连接字符串配置");
+ }
+
+ services.AddDbContext(options => options.UseNpgsql(dbConnection));
+
+ var redisConnection = configuration.GetConnectionString("Redis");
+ if (string.IsNullOrWhiteSpace(redisConnection))
+ {
+ throw new InvalidOperationException("缺少 Redis 连接字符串配置");
+ }
+
+ services.AddStackExchangeRedisCache(options =>
+ {
+ options.Configuration = redisConnection;
+ });
+
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped, PasswordHasher>();
+
+ services.AddOptions()
+ .Bind(configuration.GetSection("Identity:Jwt"))
+ .ValidateDataAnnotations()
+ .ValidateOnStart();
+
+ services.AddOptions()
+ .Bind(configuration.GetSection("Identity:LoginRateLimit"))
+ .ValidateDataAnnotations()
+ .ValidateOnStart();
+
+ services.AddOptions()
+ .Bind(configuration.GetSection("Identity:RefreshTokenStore"));
+
+ if (enableMiniFeatures)
+ {
+ services.AddOptions()
+ .Bind(configuration.GetSection("Identity:WeChatMini"))
+ .ValidateDataAnnotations()
+ .ValidateOnStart();
+
+ services.AddHttpClient(client =>
+ {
+ client.BaseAddress = new Uri("https://api.weixin.qq.com/");
+ client.Timeout = TimeSpan.FromSeconds(10);
+ });
+ }
+
+ if (enableAdminSeed)
+ {
+ services.AddOptions()
+ .Bind(configuration.GetSection("Identity:AdminSeed"))
+ .ValidateDataAnnotations()
+ .ValidateOnStart();
+
+ services.AddHostedService();
+ }
+
+ return services;
+ }
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminSeedOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminSeedOptions.cs
new file mode 100644
index 0000000..d1dd243
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminSeedOptions.cs
@@ -0,0 +1,30 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+
+namespace TakeoutSaaS.Infrastructure.Identity.Options;
+
+///
+/// 管理后台初始账号配置。
+///
+public sealed class AdminSeedOptions
+{
+ public List Users { get; set; } = new();
+}
+
+public sealed class SeedUserOptions
+{
+ [Required]
+ public string Account { get; set; } = string.Empty;
+
+ [Required]
+ public string Password { get; set; } = string.Empty;
+
+ [Required]
+ public string DisplayName { get; set; } = string.Empty;
+
+ public Guid TenantId { get; set; }
+ public Guid? MerchantId { get; set; }
+ public string[] Roles { get; set; } = Array.Empty();
+ public string[] Permissions { get; set; } = Array.Empty();
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/JwtOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/JwtOptions.cs
new file mode 100644
index 0000000..28e1052
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/JwtOptions.cs
@@ -0,0 +1,25 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace TakeoutSaaS.Infrastructure.Identity.Options;
+
+///
+/// JWT 配置。
+///
+public sealed class JwtOptions
+{
+ [Required]
+ public string Issuer { get; set; } = string.Empty;
+
+ [Required]
+ public string Audience { get; set; } = string.Empty;
+
+ [Required]
+ [MinLength(32)]
+ public string Secret { get; set; } = string.Empty;
+
+ [Range(5, 1440)]
+ public int AccessTokenExpirationMinutes { get; set; } = 60;
+
+ [Range(60, 1440 * 14)]
+ public int RefreshTokenExpirationMinutes { get; set; } = 60 * 24 * 7;
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/LoginRateLimitOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/LoginRateLimitOptions.cs
new file mode 100644
index 0000000..a5fb4f1
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/LoginRateLimitOptions.cs
@@ -0,0 +1,15 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace TakeoutSaaS.Infrastructure.Identity.Options;
+
+///
+/// 登录限流配置。
+///
+public sealed class LoginRateLimitOptions
+{
+ [Range(1, 3600)]
+ public int WindowSeconds { get; set; } = 60;
+
+ [Range(1, 100)]
+ public int MaxAttempts { get; set; } = 5;
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/RefreshTokenStoreOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/RefreshTokenStoreOptions.cs
new file mode 100644
index 0000000..ab69c3d
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/RefreshTokenStoreOptions.cs
@@ -0,0 +1,9 @@
+namespace TakeoutSaaS.Infrastructure.Identity.Options;
+
+///
+/// 刷新令牌存储配置。
+///
+public sealed class RefreshTokenStoreOptions
+{
+ public string Prefix { get; set; } = "identity:refresh:";
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/WeChatMiniOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/WeChatMiniOptions.cs
new file mode 100644
index 0000000..e30d274
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/WeChatMiniOptions.cs
@@ -0,0 +1,15 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace TakeoutSaaS.Infrastructure.Identity.Options;
+
+///
+/// 微信小程序配置。
+///
+public sealed class WeChatMiniOptions
+{
+ [Required]
+ public string AppId { get; set; } = string.Empty;
+
+ [Required]
+ public string Secret { get; set; } = string.Empty;
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs
new file mode 100644
index 0000000..e90127c
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs
@@ -0,0 +1,27 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+using TakeoutSaaS.Domain.Identity.Entities;
+using TakeoutSaaS.Domain.Identity.Repositories;
+
+namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
+
+///
+/// EF Core 后台用户仓储实现。
+///
+public sealed class EfIdentityUserRepository : IIdentityUserRepository
+{
+ private readonly IdentityDbContext _dbContext;
+
+ public EfIdentityUserRepository(IdentityDbContext dbContext)
+ {
+ _dbContext = dbContext;
+ }
+
+ public Task FindByAccountAsync(string account, CancellationToken cancellationToken = default)
+ => _dbContext.IdentityUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Account == account, cancellationToken);
+
+ public Task FindByIdAsync(Guid userId, CancellationToken cancellationToken = default)
+ => _dbContext.IdentityUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfMiniUserRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfMiniUserRepository.cs
new file mode 100644
index 0000000..d5c372b
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfMiniUserRepository.cs
@@ -0,0 +1,54 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+using TakeoutSaaS.Domain.Identity.Entities;
+using TakeoutSaaS.Domain.Identity.Repositories;
+
+namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
+
+///
+/// EF Core 小程序用户仓储实现。
+///
+public sealed class EfMiniUserRepository : IMiniUserRepository
+{
+ private readonly IdentityDbContext _dbContext;
+
+ public EfMiniUserRepository(IdentityDbContext dbContext)
+ {
+ _dbContext = dbContext;
+ }
+
+ public Task FindByOpenIdAsync(string openId, CancellationToken cancellationToken = default)
+ => _dbContext.MiniUsers.AsNoTracking().FirstOrDefaultAsync(x => x.OpenId == openId, cancellationToken);
+
+ public Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default)
+ => _dbContext.MiniUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
+
+ public async Task CreateOrUpdateAsync(string openId, string? unionId, string? nickname, string? avatar, Guid tenantId, CancellationToken cancellationToken = default)
+ {
+ var user = await _dbContext.MiniUsers.FirstOrDefaultAsync(x => x.OpenId == openId, cancellationToken);
+ if (user == null)
+ {
+ user = new MiniUser
+ {
+ Id = Guid.NewGuid(),
+ OpenId = openId,
+ UnionId = unionId,
+ Nickname = nickname ?? "小程序用户",
+ Avatar = avatar,
+ TenantId = tenantId
+ };
+ _dbContext.MiniUsers.Add(user);
+ }
+ else
+ {
+ user.UnionId = unionId ?? user.UnionId;
+ user.Nickname = nickname ?? user.Nickname;
+ user.Avatar = avatar ?? user.Avatar;
+ }
+
+ await _dbContext.SaveChangesAsync(cancellationToken);
+ return user;
+ }
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs
new file mode 100644
index 0000000..80327ac
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs
@@ -0,0 +1,94 @@
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using TakeoutSaaS.Domain.Identity.Entities;
+using TakeoutSaaS.Infrastructure.Identity.Options;
+using DomainIdentityUser = TakeoutSaaS.Domain.Identity.Entities.IdentityUser;
+
+namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
+
+///
+/// 后台账号初始化种子任务
+///
+public sealed class IdentityDataSeeder : IHostedService
+{
+ private readonly IServiceProvider _serviceProvider;
+ private readonly ILogger _logger;
+
+ public IdentityDataSeeder(IServiceProvider serviceProvider, ILogger logger)
+ {
+ _serviceProvider = serviceProvider;
+ _logger = logger;
+ }
+
+ public async Task StartAsync(CancellationToken cancellationToken)
+ {
+ using var scope = _serviceProvider.CreateScope();
+ var context = scope.ServiceProvider.GetRequiredService();
+ var options = scope.ServiceProvider.GetRequiredService>().Value;
+ var passwordHasher = scope.ServiceProvider.GetRequiredService>();
+
+ await context.Database.MigrateAsync(cancellationToken);
+
+ if (options.Users == null || options.Users.Count == 0)
+ {
+ _logger.LogInformation("AdminSeed 未配置账号,跳过后台账号初始化");
+ return;
+ }
+
+ foreach (var userOptions in options.Users)
+ {
+ var user = await context.IdentityUsers.FirstOrDefaultAsync(x => x.Account == userOptions.Account, cancellationToken);
+ var roles = NormalizeValues(userOptions.Roles);
+ var permissions = NormalizeValues(userOptions.Permissions);
+
+ if (user == null)
+ {
+ user = new DomainIdentityUser
+ {
+ Id = Guid.NewGuid(),
+ Account = userOptions.Account,
+ DisplayName = userOptions.DisplayName,
+ TenantId = userOptions.TenantId,
+ MerchantId = userOptions.MerchantId,
+ Avatar = null,
+ Roles = roles,
+ Permissions = permissions,
+ };
+ user.PasswordHash = passwordHasher.HashPassword(user, userOptions.Password);
+ context.IdentityUsers.Add(user);
+ _logger.LogInformation("已创建后台账号 {Account}", user.Account);
+ }
+ else
+ {
+ user.DisplayName = userOptions.DisplayName;
+ user.TenantId = userOptions.TenantId;
+ user.MerchantId = userOptions.MerchantId;
+ user.Roles = roles;
+ user.Permissions = permissions;
+ user.PasswordHash = passwordHasher.HashPassword(user, userOptions.Password);
+ _logger.LogInformation("已更新后台账号 {Account}", user.Account);
+ }
+ }
+
+ await context.SaveChangesAsync(cancellationToken);
+ }
+
+ public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ private static string[] NormalizeValues(string[]? values)
+ => values == null
+ ? Array.Empty()
+ : values
+ .Where(v => !string.IsNullOrWhiteSpace(v))
+ .Select(v => v.Trim())
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs
new file mode 100644
index 0000000..6168f7e
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs
@@ -0,0 +1,70 @@
+using System;
+using System.Linq;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.ChangeTracking;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using TakeoutSaaS.Domain.Identity.Entities;
+
+namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
+
+///
+/// 身份认证 DbContext。
+///
+public sealed class IdentityDbContext : DbContext
+{
+ public IdentityDbContext(DbContextOptions options)
+ : base(options)
+ {
+ }
+
+ public DbSet IdentityUsers => Set();
+ public DbSet MiniUsers => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ ConfigureIdentityUser(modelBuilder.Entity());
+ ConfigureMiniUser(modelBuilder.Entity());
+ }
+
+ private static void ConfigureIdentityUser(EntityTypeBuilder builder)
+ {
+ builder.ToTable("identity_users");
+ builder.HasKey(x => x.Id);
+ builder.Property(x => x.Account).HasMaxLength(64).IsRequired();
+ builder.Property(x => x.DisplayName).HasMaxLength(64).IsRequired();
+ builder.Property(x => x.PasswordHash).HasMaxLength(256).IsRequired();
+ builder.Property(x => x.Avatar).HasMaxLength(256);
+
+ var converter = new ValueConverter(
+ v => string.Join(',', v ?? Array.Empty()),
+ v => string.IsNullOrWhiteSpace(v) ? Array.Empty() : v.Split(',', StringSplitOptions.RemoveEmptyEntries));
+
+ var comparer = new ValueComparer(
+ (l, r) => l!.SequenceEqual(r!),
+ v => v.Aggregate(0, (current, item) => HashCode.Combine(current, item.GetHashCode())),
+ v => v.ToArray());
+
+ builder.Property(x => x.Roles)
+ .HasConversion(converter)
+ .Metadata.SetValueComparer(comparer);
+
+ builder.Property(x => x.Permissions)
+ .HasConversion(converter)
+ .Metadata.SetValueComparer(comparer);
+
+ builder.HasIndex(x => x.Account).IsUnique();
+ }
+
+ private static void ConfigureMiniUser(EntityTypeBuilder builder)
+ {
+ builder.ToTable("mini_users");
+ builder.HasKey(x => x.Id);
+ builder.Property(x => x.OpenId).HasMaxLength(128).IsRequired();
+ builder.Property(x => x.UnionId).HasMaxLength(128);
+ builder.Property(x => x.Nickname).HasMaxLength(64).IsRequired();
+ builder.Property(x => x.Avatar).HasMaxLength(256);
+
+ builder.HasIndex(x => x.OpenId).IsUnique();
+ }
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/JwtTokenService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/JwtTokenService.cs
new file mode 100644
index 0000000..fa728ac
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/JwtTokenService.cs
@@ -0,0 +1,93 @@
+using System;
+using System.Collections.Generic;
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Options;
+using Microsoft.IdentityModel.Tokens;
+using TakeoutSaaS.Application.Identity.Abstractions;
+using TakeoutSaaS.Application.Identity.Contracts;
+using TakeoutSaaS.Infrastructure.Identity.Options;
+
+namespace TakeoutSaaS.Infrastructure.Identity.Services;
+
+///
+/// JWT 令牌生成器。
+///
+public sealed class JwtTokenService : IJwtTokenService
+{
+ private readonly JwtSecurityTokenHandler _tokenHandler = new();
+ private readonly IRefreshTokenStore _refreshTokenStore;
+ private readonly JwtOptions _options;
+
+ public JwtTokenService(IRefreshTokenStore refreshTokenStore, IOptions options)
+ {
+ _refreshTokenStore = refreshTokenStore;
+ _options = options.Value;
+ }
+
+ public async Task CreateTokensAsync(CurrentUserProfile profile, bool isNewUser = false, CancellationToken cancellationToken = default)
+ {
+ var now = DateTime.UtcNow;
+ var accessExpires = now.AddMinutes(_options.AccessTokenExpirationMinutes);
+ var refreshExpires = now.AddMinutes(_options.RefreshTokenExpirationMinutes);
+
+ var claims = BuildClaims(profile);
+ var signingCredentials = new SigningCredentials(
+ new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Secret)),
+ SecurityAlgorithms.HmacSha256);
+
+ var jwt = new JwtSecurityToken(
+ issuer: _options.Issuer,
+ audience: _options.Audience,
+ claims: claims,
+ notBefore: now,
+ expires: accessExpires,
+ signingCredentials: signingCredentials);
+
+ var accessToken = _tokenHandler.WriteToken(jwt);
+ var refreshDescriptor = await _refreshTokenStore.IssueAsync(profile.UserId, refreshExpires, cancellationToken);
+
+ return new TokenResponse
+ {
+ AccessToken = accessToken,
+ AccessTokenExpiresAt = accessExpires,
+ RefreshToken = refreshDescriptor.Token,
+ RefreshTokenExpiresAt = refreshDescriptor.ExpiresAt,
+ User = profile,
+ IsNewUser = isNewUser
+ };
+ }
+
+ private static IEnumerable BuildClaims(CurrentUserProfile profile)
+ {
+ var userId = profile.UserId.ToString();
+ var claims = new List
+ {
+ new(JwtRegisteredClaimNames.Sub, userId),
+ new(ClaimTypes.NameIdentifier, userId),
+ new(JwtRegisteredClaimNames.UniqueName, profile.Account),
+ new("tenant_id", profile.TenantId.ToString()),
+ new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
+ };
+
+ if (profile.MerchantId.HasValue)
+ {
+ claims.Add(new Claim("merchant_id", profile.MerchantId.Value.ToString()));
+ }
+
+ foreach (var role in profile.Roles)
+ {
+ claims.Add(new Claim(ClaimTypes.Role, role));
+ }
+
+ foreach (var permission in profile.Permissions)
+ {
+ claims.Add(new Claim("permission", permission));
+ }
+
+ return claims;
+ }
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisLoginRateLimiter.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisLoginRateLimiter.cs
new file mode 100644
index 0000000..9c7e339
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisLoginRateLimiter.cs
@@ -0,0 +1,52 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Caching.Distributed;
+using Microsoft.Extensions.Options;
+using TakeoutSaaS.Application.Identity.Abstractions;
+using TakeoutSaaS.Infrastructure.Identity.Options;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+
+namespace TakeoutSaaS.Infrastructure.Identity.Services;
+
+///
+/// Redis 登录限流实现。
+///
+public sealed class RedisLoginRateLimiter : ILoginRateLimiter
+{
+ private readonly IDistributedCache _cache;
+ private readonly LoginRateLimitOptions _options;
+
+ public RedisLoginRateLimiter(IDistributedCache cache, IOptions options)
+ {
+ _cache = cache;
+ _options = options.Value;
+ }
+
+ public async Task EnsureAllowedAsync(string key, CancellationToken cancellationToken = default)
+ {
+ var cacheKey = BuildKey(key);
+ var current = await _cache.GetStringAsync(cacheKey, cancellationToken);
+ var count = string.IsNullOrWhiteSpace(current) ? 0 : int.Parse(current);
+ if (count >= _options.MaxAttempts)
+ {
+ throw new BusinessException(ErrorCodes.Forbidden, "尝试次数过多,请稍后再试");
+ }
+
+ count++;
+ await _cache.SetStringAsync(
+ cacheKey,
+ count.ToString(),
+ new DistributedCacheEntryOptions
+ {
+ AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(_options.WindowSeconds)
+ },
+ cancellationToken);
+ }
+
+ public Task ResetAsync(string key, CancellationToken cancellationToken = default)
+ => _cache.RemoveAsync(BuildKey(key), cancellationToken);
+
+ private static string BuildKey(string key) => $"identity:login:{key}";
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisRefreshTokenStore.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisRefreshTokenStore.cs
new file mode 100644
index 0000000..aabd967
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisRefreshTokenStore.cs
@@ -0,0 +1,63 @@
+using System;
+using System.Security.Cryptography;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Caching.Distributed;
+using Microsoft.Extensions.Options;
+using TakeoutSaaS.Application.Identity.Abstractions;
+using TakeoutSaaS.Application.Identity.Models;
+using TakeoutSaaS.Infrastructure.Identity.Options;
+
+namespace TakeoutSaaS.Infrastructure.Identity.Services;
+
+///
+/// Redis 刷新令牌存储。
+///
+public sealed class RedisRefreshTokenStore : IRefreshTokenStore
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
+ private readonly IDistributedCache _cache;
+ private readonly RefreshTokenStoreOptions _options;
+
+ public RedisRefreshTokenStore(IDistributedCache cache, IOptions options)
+ {
+ _cache = cache;
+ _options = options.Value;
+ }
+
+ public async Task IssueAsync(Guid userId, DateTime expiresAt, CancellationToken cancellationToken = default)
+ {
+ var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(48));
+ var descriptor = new RefreshTokenDescriptor(token, userId, expiresAt, false);
+
+ var key = BuildKey(token);
+ var entryOptions = new DistributedCacheEntryOptions { AbsoluteExpiration = expiresAt };
+ await _cache.SetStringAsync(key, JsonSerializer.Serialize(descriptor, JsonOptions), entryOptions, cancellationToken);
+
+ return descriptor;
+ }
+
+ public async Task GetAsync(string refreshToken, CancellationToken cancellationToken = default)
+ {
+ var json = await _cache.GetStringAsync(BuildKey(refreshToken), cancellationToken);
+ return string.IsNullOrWhiteSpace(json)
+ ? null
+ : JsonSerializer.Deserialize(json, JsonOptions);
+ }
+
+ public async Task RevokeAsync(string refreshToken, CancellationToken cancellationToken = default)
+ {
+ var descriptor = await GetAsync(refreshToken, cancellationToken);
+ if (descriptor == null)
+ {
+ return;
+ }
+
+ var updated = descriptor with { Revoked = true };
+ var entryOptions = new DistributedCacheEntryOptions { AbsoluteExpiration = updated.ExpiresAt };
+ await _cache.SetStringAsync(BuildKey(refreshToken), JsonSerializer.Serialize(updated, JsonOptions), entryOptions, cancellationToken);
+ }
+
+ private string BuildKey(string token) => $"{_options.Prefix}{token}";
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/WeChatAuthService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/WeChatAuthService.cs
new file mode 100644
index 0000000..6fa0efb
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/WeChatAuthService.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Net.Http;
+using System.Net.Http.Json;
+using System.Text.Json.Serialization;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Options;
+using TakeoutSaaS.Application.Identity.Abstractions;
+using TakeoutSaaS.Infrastructure.Identity.Options;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+
+namespace TakeoutSaaS.Infrastructure.Identity.Services;
+
+///
+/// 微信 code2Session 实现
+///
+public sealed class WeChatAuthService : IWeChatAuthService
+{
+ private readonly HttpClient _httpClient;
+ private readonly WeChatMiniOptions _options;
+
+ public WeChatAuthService(HttpClient httpClient, IOptions options)
+ {
+ _httpClient = httpClient;
+ _options = options.Value;
+ }
+
+ public async Task Code2SessionAsync(string code, CancellationToken cancellationToken = default)
+ {
+ var requestUri = $"sns/jscode2session?appid={Uri.EscapeDataString(_options.AppId)}&secret={Uri.EscapeDataString(_options.Secret)}&js_code={Uri.EscapeDataString(code)}&grant_type=authorization_code";
+ using var response = await _httpClient.GetAsync(requestUri, cancellationToken);
+ response.EnsureSuccessStatusCode();
+
+ var payload = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken);
+ if (payload == null)
+ {
+ throw new BusinessException(ErrorCodes.Unauthorized, "微信登录失败:响应为空");
+ }
+
+ if (payload.ErrorCode.HasValue && payload.ErrorCode.Value != 0)
+ {
+ var message = string.IsNullOrWhiteSpace(payload.ErrorMessage)
+ ? $"微信登录失败,错误码:{payload.ErrorCode}"
+ : payload.ErrorMessage;
+ throw new BusinessException(ErrorCodes.Unauthorized, message);
+ }
+
+ if (string.IsNullOrWhiteSpace(payload.OpenId) || string.IsNullOrWhiteSpace(payload.SessionKey))
+ {
+ throw new BusinessException(ErrorCodes.Unauthorized, "微信登录失败:返回数据无效");
+ }
+
+ return new WeChatSessionInfo
+ {
+ OpenId = payload.OpenId,
+ UnionId = payload.UnionId,
+ SessionKey = payload.SessionKey
+ };
+ }
+
+ private sealed class WeChatSessionResponse
+ {
+ [JsonPropertyName("openid")]
+ public string? OpenId { get; set; }
+
+ [JsonPropertyName("unionid")]
+ public string? UnionId { get; set; }
+
+ [JsonPropertyName("session_key")]
+ public string? SessionKey { get; set; }
+
+ [JsonPropertyName("errcode")]
+ public int? ErrorCode { get; set; }
+
+ [JsonPropertyName("errmsg")]
+ public string? ErrorMessage { get; set; }
+ }
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj b/src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj
index 10cd70f..92fa65a 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj
@@ -6,12 +6,18 @@
+
+
+
+
+
+
+
-
diff --git a/src/Modules/TakeoutSaaS.Module.Authorization/Attributes/PermissionAuthorizeAttribute.cs b/src/Modules/TakeoutSaaS.Module.Authorization/Attributes/PermissionAuthorizeAttribute.cs
new file mode 100644
index 0000000..b55f602
--- /dev/null
+++ b/src/Modules/TakeoutSaaS.Module.Authorization/Attributes/PermissionAuthorizeAttribute.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.AspNetCore.Authorization;
+using TakeoutSaaS.Module.Authorization.Policies;
+
+namespace TakeoutSaaS.Module.Authorization.Attributes;
+
+///
+/// 权限校验特性
+///
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
+public sealed class PermissionAuthorizeAttribute : AuthorizeAttribute
+{
+ public PermissionAuthorizeAttribute(params string[] permissions)
+ {
+ ArgumentNullException.ThrowIfNull(permissions);
+ var normalized = permissions
+ .Where(p => !string.IsNullOrWhiteSpace(p))
+ .Select(p => p.Trim())
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ if (normalized.Length == 0)
+ {
+ throw new ArgumentException("至少需要一个权限标识", nameof(permissions));
+ }
+
+ Permissions = normalized;
+ Policy = PermissionAuthorizationPolicyProvider.BuildPolicyName(normalized);
+ }
+
+ ///
+ /// 所需权限集合
+ ///
+ public IReadOnlyCollection Permissions { get; }
+}
diff --git a/src/Modules/TakeoutSaaS.Module.Authorization/Extensions/AuthorizationServiceCollectionExtensions.cs b/src/Modules/TakeoutSaaS.Module.Authorization/Extensions/AuthorizationServiceCollectionExtensions.cs
new file mode 100644
index 0000000..0e368a0
--- /dev/null
+++ b/src/Modules/TakeoutSaaS.Module.Authorization/Extensions/AuthorizationServiceCollectionExtensions.cs
@@ -0,0 +1,21 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.Extensions.DependencyInjection;
+using TakeoutSaaS.Module.Authorization.Policies;
+
+namespace TakeoutSaaS.Module.Authorization.Extensions;
+
+///
+/// 权限授权注入扩展
+///
+public static class AuthorizationServiceCollectionExtensions
+{
+ ///
+ /// 启用自定义权限策略提供者与处理器
+ ///
+ public static IServiceCollection AddPermissionAuthorization(this IServiceCollection services)
+ {
+ services.AddSingleton();
+ services.AddScoped();
+ return services;
+ }
+}
diff --git a/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationHandler.cs b/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationHandler.cs
new file mode 100644
index 0000000..b8df4fe
--- /dev/null
+++ b/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationHandler.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Authorization;
+
+namespace TakeoutSaaS.Module.Authorization.Policies;
+
+///
+/// 权限校验处理器
+///
+public sealed class PermissionAuthorizationHandler : AuthorizationHandler
+{
+ public const string PermissionClaimType = "permission";
+
+ protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, PermissionRequirement requirement)
+ {
+ if (context.User?.Identity?.IsAuthenticated != true)
+ {
+ return Task.CompletedTask;
+ }
+
+ var userPermissions = context.User
+ .FindAll(PermissionClaimType)
+ .Select(claim => claim.Value)
+ .Where(value => !string.IsNullOrWhiteSpace(value))
+ .Select(value => value.Trim())
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ if (userPermissions.Count == 0)
+ {
+ return Task.CompletedTask;
+ }
+
+ if (requirement.Permissions.Any(userPermissions.Contains))
+ {
+ context.Succeed(requirement);
+ }
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationPolicyProvider.cs b/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationPolicyProvider.cs
new file mode 100644
index 0000000..daaa3f0
--- /dev/null
+++ b/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationPolicyProvider.cs
@@ -0,0 +1,69 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.Extensions.Options;
+
+namespace TakeoutSaaS.Module.Authorization.Policies;
+
+///
+/// 权限策略提供者(按需动态构建策略)
+///
+public sealed class PermissionAuthorizationPolicyProvider : DefaultAuthorizationPolicyProvider
+{
+ public const string PolicyPrefix = "PERMISSION:";
+ private readonly AuthorizationOptions _options;
+
+ public PermissionAuthorizationPolicyProvider(IOptions options)
+ : base(options)
+ {
+ _options = options.Value;
+ }
+
+ public override Task GetPolicyAsync(string policyName)
+ {
+ if (policyName.StartsWith(PolicyPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ var existingPolicy = _options.GetPolicy(policyName);
+ if (existingPolicy != null)
+ {
+ return Task.FromResult(existingPolicy);
+ }
+
+ var permissions = ParsePermissions(policyName);
+ if (permissions.Length == 0)
+ {
+ return Task.FromResult(null);
+ }
+
+ var policy = new AuthorizationPolicyBuilder()
+ .AddRequirements(new PermissionRequirement(permissions))
+ .Build();
+
+ _options.AddPolicy(policyName, policy);
+ return Task.FromResult(policy);
+ }
+
+ return base.GetPolicyAsync(policyName);
+ }
+
+ ///
+ /// 根据权限集合构建策略名称
+ ///
+ public static string BuildPolicyName(IEnumerable permissions)
+ => $"{PolicyPrefix}{string.Join('|', NormalizePermissions(permissions))}";
+
+ private static string[] ParsePermissions(string policyName)
+ {
+ var raw = policyName[PolicyPrefix.Length..];
+ return NormalizePermissions(raw.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
+ }
+
+ private static string[] NormalizePermissions(IEnumerable permissions)
+ => permissions
+ .Where(p => !string.IsNullOrWhiteSpace(p))
+ .Select(p => p.Trim())
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+}
diff --git a/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionRequirement.cs b/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionRequirement.cs
new file mode 100644
index 0000000..2ed0421
--- /dev/null
+++ b/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionRequirement.cs
@@ -0,0 +1,18 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.AspNetCore.Authorization;
+
+namespace TakeoutSaaS.Module.Authorization.Policies;
+
+///
+/// 权限要求
+///
+public sealed class PermissionRequirement : IAuthorizationRequirement
+{
+ public PermissionRequirement(IReadOnlyCollection permissions)
+ {
+ Permissions = permissions ?? throw new ArgumentNullException(nameof(permissions));
+ }
+
+ public IReadOnlyCollection Permissions { get; }
+}
diff --git a/src/Modules/TakeoutSaaS.Module.Authorization/TakeoutSaaS.Module.Authorization.csproj b/src/Modules/TakeoutSaaS.Module.Authorization/TakeoutSaaS.Module.Authorization.csproj
index b407eac..9dfdb25 100644
--- a/src/Modules/TakeoutSaaS.Module.Authorization/TakeoutSaaS.Module.Authorization.csproj
+++ b/src/Modules/TakeoutSaaS.Module.Authorization/TakeoutSaaS.Module.Authorization.csproj
@@ -4,8 +4,11 @@
enable
enable
+
+
+
+
-
diff --git a/src/Modules/TakeoutSaaS.Module.Identity/TakeoutSaaS.Module.Identity.csproj b/src/Modules/TakeoutSaaS.Module.Identity/TakeoutSaaS.Module.Identity.csproj
deleted file mode 100644
index b407eac..0000000
--- a/src/Modules/TakeoutSaaS.Module.Identity/TakeoutSaaS.Module.Identity.csproj
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- net10.0
- enable
- enable
-
-
-
-
-
-