Compare commits

...

10 Commits

Author SHA1 Message Date
msumshk
75f20558a5 chore: 删除原作者的 GitHub Actions 工作流 2026-02-05 14:27:25 +08:00
MSuMshk
22479ea787 chore: 更新 Docs 子模块引用
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 10:49:02 +08:00
MSuMshk
5a26f82628 refactor: 将 Permission 和 MenuDefinition 改为系统级实体
- Permission 和 MenuDefinition 改为继承 AuditableEntityBase(移除 TenantId)
- 添加 PortalType 枚举区分平台端/租户端
- Repository 使用 IgnoreQueryFilters() 查询系统级数据
- 更新所有相关 Handler 和 DTO,移除 TenantId 引用
- 与 AdminApi 保持一致的设计

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 14:49:27 +08:00
MSuMshk
e88c41c11e feat: 实现动态租户解析与移除菜单回退逻辑
1. 新增 ITenantCodeResolver 接口和 DatabaseTenantCodeResolver 实现
2. 修改 TenantResolutionMiddleware 支持从数据库动态解析租户编码
3. ITenantRepository 新增 FindIdByCodeAsync 方法
4. 移除 EfMenuRepository 中危险的系统菜单回退逻辑
5. 调整服务注册顺序确保依赖正确注入

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 13:05:52 +08:00
MSuMshk
cfacbf8363 feat: 补全租户端认证接口
- 新增 GET /api/tenant/v1/auth/profile 获取当前用户信息
- 新增 GET /api/tenant/v1/auth/permissions 获取当前用户权限码
- 新增 GET /api/tenant/v1/auth/menu 获取当前用户菜单树
- 移除 MeController,功能合并至 AuthController

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 12:39:23 +08:00
MSuMshk
286e6e9acb refactor: 移除租户端简化登录接口及相关依赖
- 移除 /api/tenant/v1/auth/login/simple 接口
- 移除 IAdminAuthService.LoginSimpleAsync 方法
- 移除 AdminAuthService.LoginSimpleAsync 实现
- 移除 IsLikelyPhone 辅助方法
- 清理未使用的 ITenantContextAccessor 和 ITenantRepository 依赖
- 添加 CLAUDE.md 开发规范文件

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 11:38:32 +08:00
MSuMshk
5338eb5434 chore: 更新开发和生产环境配置文件
- 更新 appsettings.Development.json 配置
- 更新 appsettings.Production.json 配置

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 09:11:55 +08:00
MSuMshk
6ad1e8295c fix: 更新Docs子模块修复watch脚本仓库识别
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 11:40:29 +08:00
root
82f24f7003 fix: 更新Docs子模块修复watch端口 2026-01-30 03:09:43 +00:00
root
8e315e4f17 fix: 租户端启动补齐字典与消息依赖 2026-01-30 02:42:22 +00:00
42 changed files with 618 additions and 490 deletions

View File

@@ -1,180 +0,0 @@
name: TakeoutSaaS CI/CD
on:
pull_request:
branches:
- main
workflow_dispatch:
env:
REGISTRY: ${{ secrets.REGISTRY }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PASSWORD: ${{ secrets.DEPLOY_PASSWORD }}
REGISTRY_NAMESPACE: kjkj-saas
jobs:
detect:
runs-on: ubuntu-latest
outputs:
services: ${{ steps.collect.outputs.services }}
image_tag: ${{ steps.collect.outputs.image_tag }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: collect
shell: bash
run: |
set -euo pipefail
BASE="${{ github.event.before }}"
if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then
if git rev-parse HEAD^ >/dev/null 2>&1; then
BASE="$(git rev-parse HEAD^)"
else
BASE=""
fi
fi
if [ -z "$BASE" ]; then
CHANGED=$(git ls-tree -r --name-only HEAD)
else
CHANGED=$(git diff --name-only "$BASE" HEAD || true)
fi
echo "本次变更文件:"
echo "$CHANGED"
deploy_all=false
services=()
hit() { echo "$CHANGED" | grep -qE "$1"; }
if hit '^src/(Domain|Application|Infrastructure|Core|Modules)/'; then deploy_all=true; fi
if hit '^Directory\.Build\.props$'; then deploy_all=true; fi
if hit '^src/Api/TakeoutSaaS.AdminApi/'; then services+=("admin-api"); fi
if hit '^src/Api/TakeoutSaaS.MiniApi/'; then services+=("mini-api"); fi
if hit '^src/Api/TakeoutSaaS.UserApi/'; then services+=("user-api"); fi
if $deploy_all || [ ${#services[@]} -eq 0 ]; then
services=("admin-api" "mini-api" "user-api")
fi
printf '需要处理的服务: %s\n' "${services[*]}"
SERVICES_LIST="${services[*]}"
export SERVICES_LIST
SERVICES_JSON=$(python -c "import json, os; print(json.dumps(os.environ.get('SERVICES_LIST','').split()))")
echo "services=$SERVICES_JSON" >> "$GITHUB_OUTPUT"
TAG=$(date +%Y%m%d%H%M%S)
echo "image_tag=$TAG" >> "$GITHUB_OUTPUT"
build:
runs-on: ubuntu-latest
needs: detect
if: needs.detect.outputs.services != '[]'
strategy:
matrix:
service: ${{ fromJson(needs.detect.outputs.services) }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.REGISTRY_USERNAME }}
password: ${{ env.REGISTRY_PASSWORD }}
- name: Build and push ${{ matrix.service }}
env:
SERVICE: ${{ matrix.service }}
IMAGE_TAG: ${{ needs.detect.outputs.image_tag }}
run: |
set -euo pipefail
case "$SERVICE" in
admin-api)
DOCKERFILE="src/Api/TakeoutSaaS.AdminApi/Dockerfile"
IMAGE="$REGISTRY/$REGISTRY_NAMESPACE/admin-api:$IMAGE_TAG"
;;
mini-api)
DOCKERFILE="src/Api/TakeoutSaaS.MiniApi/Dockerfile"
IMAGE="$REGISTRY/$REGISTRY_NAMESPACE/mini-api:$IMAGE_TAG"
;;
user-api)
DOCKERFILE="src/Api/TakeoutSaaS.UserApi/Dockerfile"
IMAGE="$REGISTRY/$REGISTRY_NAMESPACE/user-api:$IMAGE_TAG"
;;
*)
echo "未知服务:$SERVICE"
exit 1
;;
esac
if [ ! -f "$DOCKERFILE" ]; then
echo "未找到 Dockerfile: $DOCKERFILE"
exit 1
fi
docker build -f "$DOCKERFILE" -t "$IMAGE" .
docker push "$IMAGE"
deploy:
runs-on: ubuntu-latest
needs:
- detect
- build
if: needs.detect.outputs.services != '[]'
strategy:
matrix:
service: ${{ fromJson(needs.detect.outputs.services) }}
steps:
- name: Install sshpass
run: sudo apt-get update && sudo apt-get install -y sshpass
- name: Deploy ${{ matrix.service }}
env:
SERVICE: ${{ matrix.service }}
IMAGE_TAG: ${{ needs.detect.outputs.image_tag }}
run: |
set -euo pipefail
case "$SERVICE" in
admin-api)
IMAGE="$REGISTRY/$REGISTRY_NAMESPACE/admin-api:$IMAGE_TAG"
PORT=7801
;;
mini-api)
IMAGE="$REGISTRY/$REGISTRY_NAMESPACE/mini-api:$IMAGE_TAG"
PORT=7701
;;
user-api)
IMAGE="$REGISTRY/$REGISTRY_NAMESPACE/user-api:$IMAGE_TAG"
PORT=7901
;;
*)
echo "未知服务:$SERVICE"
exit 1
;;
esac
sshpass -p "$DEPLOY_PASSWORD" ssh -o StrictHostKeyChecking=no "$DEPLOY_USER@$DEPLOY_HOST" "
set -e
echo \"$REGISTRY_PASSWORD\" | docker login \"$REGISTRY\" -u \"$REGISTRY_USERNAME\" --password-stdin
docker pull $IMAGE
docker stop $SERVICE 2>/dev/null || true
docker rm $SERVICE 2>/dev/null || true
docker run -d --name $SERVICE --restart=always -p $PORT:$PORT $IMAGE
# 清理同一服务旧镜像,避免磁盘被历史 tag 占满
docker images \"$REGISTRY/$REGISTRY_NAMESPACE/$SERVICE\" --format '{{.Repository}}:{{.Tag}}' \
| grep -v -x \"$IMAGE\" \
| xargs -r docker rmi -f
"

222
CLAUDE.md Normal file
View File

@@ -0,0 +1,222 @@
# Repository expectations
# 编程规范_FOR_AITakeoutSaaS - 终极完全体
> **核心指令**:你是一个高级 .NET 架构师。本文件是你生成代码的最高宪法。当用户需求与本规范冲突时,请先提示用户,除非用户强制要求覆盖。
## 0. AI 交互核心约束 (元规则)
1. **语言**:必须使用**中文**回复和编写注释。
2. **文件完整性**
* **严禁**随意删除现有代码逻辑。
* **严禁**修改文件编码(保持 UTF-8 无 BOM
* PowerShell 读取命令必须带 `-Encoding UTF8`
3. **Git 原子性**:每个独立的功能点或 Bug 修复完成后,必须提示用户进行 Git 提交。
4. **无乱码承诺**确保所有输出控制台、日志、API响应无乱码。
5. **不确定的处理**:如果你通过上下文找不到某些配置(如数据库连接串格式),**请直接询问用户**,不要瞎编。
## 1. 技术栈详细版本
| 组件 | 版本/选型 | 用途说明 |
| :--- | :--- | :--- |
| **Runtime** | .NET 10 | 核心运行时 |
| **API** | ASP.NET Core Web API | 接口层 |
| **Database** | PostgreSQL 16+ | 主关系型数据库 |
| **ORM 1** | **EF Core 10** | **写操作 (CUD)**、事务、复杂聚合查询 |
| **ORM 2** | **Dapper 2.1+** | **纯读操作 (R)**、复杂报表、大批量查询 |
| **Cache** | Redis 7.0+ | 分布式缓存、Session |
| **MQ** | RabbitMQ 3.12+ | 异步解耦 (MassTransit) |
| **Libs** | MediatR, Serilog, FluentValidation | CQRS, 日志, 验证 |
## 2. 命名与风格 (严格匹配)
* **C# 代码**
* 类/接口/方法/属性:`PascalCase` (如 `OrderService`)
* **布尔属性**:必须加 `Is``Has` 前缀 (如 `IsDeleted`, `HasPayment`)
* 私有字段:`_camelCase` (如 `_orderRepository`)
* 参数/变量:`camelCase` (如 `orderId`)
* **PostgreSQL 数据库**
* 表名:`snake_case` + **复数** (如 `merchant_orders`)
* 列名:`snake_case` (如 `order_no`, `is_active`)
* 主键:`id` (类型 `bigint`)
* **文件规则**
* **一个文件一个类**。文件名必须与类名完全一致。
## 3. 分层架构 (Clean Architecture)
**你生成的代码必须严格归类到以下目录:**
* **`src/Api`**: 仅负责路由与 DTO 转换,**禁止**包含业务逻辑。
* **`src/Application`**: 业务编排层。必须使用 **CQRS** (`IRequestHandler`) 和 **Mediator**
* **`src/Domain`**: 核心领域层。包含实体、枚举、领域异常。**禁止**依赖 EF Core 等外部库。
* **`src/Infrastructure`**: 基础设施层。实现仓储、数据库上下文、第三方服务。
## 4. 注释与文档
* **强制 XML 注释**:所有 `public` 的类、方法、属性必须有 `<summary>`
* **分段逻辑注释 (强制)**
* **空行必注**:代码中每当出现空行分隔逻辑块时,**必须**在空行后的第一行添加 `//` 注释,简要说明紧接着这段代码的意图或作用。
* **步骤化**对于稍微复杂的业务逻辑必须结合序号1., 2., ...)进行标记。
* **示例**
```csharp
// 1. 验证用户是否存在
var user = await _repo.GetAsync(id);
if (user == null) return NotFound();
// 2. 扣减余额逻辑
user.Balance -= amount;
// 3. 保存更改
await _unitOfWork.SaveChangesAsync();
```
* **Swagger**:必须开启 JWT 鉴权按钮Request/Response 示例必须清晰。
## 5. 异常处理 (防御性编程)
* **禁止空 Catch**:严禁 `catch (Exception) {}`,必须记录日志或抛出。
* **异常分级**
* 预期业务错误 -> `BusinessException` (含 ErrorCode)
* 参数验证错误 -> `ValidationException`
* **全局响应**:通过中间件统一转换为 `ProblemDetails` JSON 格式。
## 6. 异步与日志
* **全异步**:所有 I/O 操作必须 `await`。**严禁** `.Result` 或 `.Wait()`。
* **结构化日志**
* ❌ `_logger.LogInfo("订单 " + id + " 创建成功");`
* ✅ `_logger.LogInformation("订单 {OrderId} 创建成功", id);`
* **脱敏**:严禁打印密码、密钥、支付凭证等敏感信息。
## 7. 依赖注入 (DI)
* **构造函数注入**:统一使用构造函数注入。
* **禁止项**
* ❌ 禁止使用 `[Inject]` 属性注入。
* ❌ 禁止使用 `ServiceLocator` (服务定位器模式)。
* ❌ 禁止在静态类中持有 ServiceProvider。
## 8. 数据访问规范 (重点执行)
### 8.1 Entity Framework Core (写/事务)
1. **无跟踪查询**:只读查询**必须**加 `.AsNoTracking()`。
2. **杜绝 N+1**:严禁在 `foreach` 循环中查询数据库。必须使用 `.Include()`。
3. **复杂查询**:关联表超过 2 层时,考虑使用 `.AsSplitQuery()`。
### 8.2 Dapper (读/报表)
1. **SQL 注入防御****严禁**拼接 SQL 字符串。必须使用参数化查询 (`@Param`)。
2. **字段映射**:注意 PostgreSQL (`snake_case`) 与 C# (`PascalCase`) 的映射配置。
## 9. 多租户与 ID 策略
* **ID 生成**
* **强制**使用 **雪花算法 (Snowflake ID)**。
* 类型C# `long` <-> DB `bigint`。
* **禁止**使用 UUID 或 自增 INT。
* **租户隔离**
* 所有业务表必须包含 `tenant_id`。
* 写入时自动填充,读取时强制过滤。
## 10. API 设计与序列化 (前端兼容)
* **大整数处理**
* 所有 `long` 类型 (Snowflake ID) 在 DTO 中**必须序列化为 string**。
* 方案DTO 属性加 `[JsonConverter(typeof(ToStringJsonConverter))]` 或全局配置。
* **DTO 规范**
* 输入:`XxxRequest`
* 输出:`XxxDto`
* **禁止** Controller 直接返回 Entity。
## 11. 模块化与复用
* **核心模块划分**Identity (身份), Tenancy (租户), Dictionary (字典), Storage (存储)。
* **公共库 (Shared)**:通用工具类、扩展方法、常量定义必须放在 `Core/Shared` 项目中,避免重复造轮子。
## 12. 测试规范
* **模式**Arrange-Act-Assert (AAA)。
* **工具**xUnit + Moq + FluentAssertions。
* **覆盖率**:核心 Domain 逻辑必须 100% 覆盖Service 层 ≥ 70%。
## 13. Git 工作流
* **提交格式 (Conventional Commits)**
* **格式要求**`<type>: <中文说明>`(类型英文,说明中文)
* `feat`: 新功能
* `fix`: 修复 Bug
* `refactor`: 重构
* `docs`: 文档
* `style`: 格式调整
* **分支规范**`feature/功能名``bugfix/问题描述`。
## 14. 性能优化 (显式指令)
* **投影查询**:使用 `.Select(x => new Dto { ... })` 只查询需要的字段,减少 I/O。
* **缓存策略**Cache-Aside 模式。数据更新后必须立即失效缓存。
* **批量操作**
* EF Core 10使用 `ExecuteUpdateAsync` / `ExecuteDeleteAsync`。
* Dapper使用 `ExecuteAsync` 进行批量插入。
## 15. 安全规范
* **SQL 注入**:已在第 8 条强制参数化。
* **身份认证**Admin 端使用 JWT + RBAC小程序端使用 Session/Token。
* **密码存储**:必须使用 PBKDF2 或 BCrypt 加盐哈希。
## 16. 绝对禁止事项 (AI 自检清单)
**生成代码前,请自查是否违反以下红线:**
1. [ ] **SQL 注入**:是否拼接了 SQL 字符串?
2. [ ] **架构违规**:是否在 Controller/Domain 中使用了 DbContext
3. [ ] **数据泄露**:是否返回了 Entity 或打印了密码?
4. [ ] **同步阻塞**:是否使用了 `.Result` 或 `.Wait()`
5. [ ] **性能陷阱**:是否在循环中查询了数据库 (N+1)
6. [ ] **精度丢失**Long 类型的 ID 是否转为了 String
7. [ ] **配置硬编码**:是否直接写死了连接串或密钥?
8. [ ] **文档注释**:是否给所有 `public` 类/方法/属性添加了 `<summary>`
9. [ ] **逻辑注释**:是否在每个空行分隔的逻辑块上方添加了说明注释?
## 17. 现代语法范式 (.NET 10 / C# 14)
> **原则**:拥抱新特性以减少样板代码,但严禁牺牲可读性。
1. **主构造函数 (Primary Constructor)**
* **强制**:依赖注入场景必须使用 `class Service(IDep dep) { }`。
* **禁止**:在主构造函数类中显式定义 `private readonly` 字段来承接参数(直接使用参数即可)。
2. **对象初始化与不可变性**
* **DTO/Command**:必须使用 `record` 类型。
* **属性定义**:默认使用 `init`;必填项加 `required`;逻辑变更使用 `with` 表达式。
3. **集合表达式**
* **统一**:使用 `[]` 初始化集合。
* **拼接**:使用 `[..array1, ..array2]` 替代 `Concat`。
4. **模式匹配 (Pattern Matching)**
* **替代**:严禁复杂的 `if-else if` 链,强制使用 `switch` 表达式。
* **判空**:使用 `is not null` 替代 `!= null`。
5. **极简语法糖**
* **Field 关键字**:属性后备字段必须使用 `field` 关键字(如 `set => field = value.Trim();`)。
* **空值赋值**:使用 `?.=` 简化判空赋值逻辑。
* **字符串**:多行文本强制用 `"""` (Raw String Literal)。
## 18. 极致性能规约 (High Performance)
> **原则**:默认编写"零分配 (Zero-Allocation)"代码,热点路径拒绝 GC 压力。
1. **内存管理**
* **字符串处理**API 参数解析层**强制**使用 `ReadOnlySpan<char>`,严禁使用 `Substring`。
* **数组分配**:小块内存 (<1KB) 使用 `stackalloc`;大块内存 (>1KB) 必须使用 `ArrayPool<T>.Shared`。
2. **并发模型**
* **无锁编程**:进程内生产者-消费者模型**必须**使用 `System.Threading.Channels`,严禁使用 `lock` 或 `BlockingCollection`。
* **并行控制**I/O 密集型并发必须使用 `Parallel.ForEachAsync` 并配置 `MaxDegreeOfParallelism`。
* **异步返回**:热点路径下,若可能同步完成,返回类型必须为 `ValueTask<T>`。
3. **序列化与查找**
* **JSON****全线废弃** Newtonsoft.Json。必须使用 `System.Text.Json` 配合源生成器 (`[JsonSerializable]`) 以支持 NativeAOT。
* **静态集合**:只读字典/集合**必须**使用 `FrozenDictionary` / `FrozenSet`。
* **SIMD 搜索**:多字符匹配场景使用 `SearchValues` 替代 `IndexOfAny`。
4. **缓存架构**
* **统一入口**:必须使用 `HybridCache` (或类似多级缓存抽象),禁止直接操作 `IDistributedCache` 以避免缓存击穿。
## 19. 云原生架构规范 (Architecture)
> **原则**:默认零信任,默认分布式,默认可观测。
1. **容器与部署**
* **基底镜像**:生产环境**强制**使用 Chiseled 镜像 (`runtime-deps:10.0-jammy-chiseled`),无 Shell、无 Root最大化安全。
* **健康检查**:必须包含 Liveness (存活) 和 Readiness (就绪) 探针。
2. **服务间通信**
* **同步调用**:内部微服务间**严禁**使用 REST/JSON**必须**使用 gRPC (Protobuf)。
* **契约管理**Proto 文件必须作为单一事实来源 (Single Source of Truth) 统一管理。
3. **数据一致性 (关键)**
* **Outbox 模式**:领域事件发布**严禁**直接调用 MQ。必须在同一数据库事务中写入 `Outbox` 表,由独立 Worker 异步推送。
* **幂等性**:所有消费者 (Consumer) 必须实现基于 `MessageId` 的幂等处理逻辑。
4. **可观测性 (Observability)**
* **OpenTelemetry**:严禁依赖特定厂商 SDK。必须统一输出 OTLP 标准格式 (Metrics/Logs/Traces)。
* **关联ID**:确保 `TraceId` 在 HTTP Headers 和 MQ Metadata 中全程透传。
5. **并发控制**
* **分布式锁**:任何涉及跨实例的资源竞争(如库存扣减、定时任务),**必须**使用 Redis RedLock 或同等机制。
---
# Working agreements
- 严格遵循上述技术栈和命名规范。
## 20. 工程与依赖约束
* **构建校验**:每次修改代码后,必须执行 `dotnet build`,确保无错误和警告。
* **NuGet 版本**:新增包必须使用最新版,未经允许不得进行包降级。

View File

@@ -2,15 +2,17 @@ using Microsoft.AspNetCore.Authorization;
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.TenantApi.Controllers;
/// <summary>
/// 租户管理端登录认证。
/// </summary>
/// <remarks>仅允许租户管理员登录获取 Token。</remarks>
/// <remarks>提供登录、刷新 Token、获取用户信息及菜单等能力。</remarks>
[ApiVersion("1.0")]
[Authorize]
[Route("api/tenant/v{version:apiVersion}/auth")]
@@ -34,24 +36,6 @@ public sealed class AuthController(IAdminAuthService authService) : BaseApiContr
return ApiResponse<TokenResponse>.Ok(response);
}
/// <summary>
/// 简化登录:支持使用“账号@手机号”自动解析租户后登录。
/// </summary>
/// <param name="request">登录请求。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>访问令牌与刷新令牌。</returns>
[HttpPost("login/simple")]
[AllowAnonymous]
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<TokenResponse>> LoginSimple([FromBody] AdminLoginRequest request, CancellationToken cancellationToken)
{
// 1. 调用认证服务完成简化登录
var response = await authService.LoginSimpleAsync(request, cancellationToken);
// 2. 返回令牌
return ApiResponse<TokenResponse>.Ok(response);
}
/// <summary>
/// 刷新 Token。
/// </summary>
@@ -69,4 +53,87 @@ public sealed class AuthController(IAdminAuthService authService) : BaseApiContr
// 2. 返回新的令牌
return ApiResponse<TokenResponse>.Ok(response);
}
/// <summary>
/// 获取当前用户信息。
/// </summary>
/// <remarks>
/// 示例响应:
/// <code>
/// {
/// "success": true,
/// "code": 200,
/// "data": {
/// "userId": "900123456789012345",
/// "account": "admin",
/// "displayName": "租户管理员",
/// "tenantId": "100000000000000001",
/// "roles": ["tenant-admin"],
/// "permissions": ["identity:profile:read", "merchant:read"]
/// }
/// }
/// </code>
/// </remarks>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>当前用户档案信息。</returns>
[HttpGet("profile")]
[ProducesResponseType(typeof(ApiResponse<CurrentUserProfile>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<CurrentUserProfile>), StatusCodes.Status401Unauthorized)]
public async Task<ApiResponse<CurrentUserProfile>> GetProfile(CancellationToken cancellationToken)
{
// 1. 从 JWT 中获取当前用户标识
var userId = User.GetUserId();
if (userId == 0)
{
return ApiResponse<CurrentUserProfile>.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识");
}
// 2. 读取用户档案并返回
var profile = await authService.GetProfileAsync(userId, cancellationToken);
return ApiResponse<CurrentUserProfile>.Ok(profile);
}
/// <summary>
/// 获取当前用户的权限码列表。
/// </summary>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>权限码数组。</returns>
[HttpGet("permissions")]
[ProducesResponseType(typeof(ApiResponse<string[]>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<string[]>), StatusCodes.Status401Unauthorized)]
public async Task<ApiResponse<string[]>> GetPermissions(CancellationToken cancellationToken)
{
// 1. 从 JWT 中获取当前用户标识
var userId = User.GetUserId();
if (userId == 0)
{
return ApiResponse<string[]>.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识");
}
// 2. 读取用户档案获取权限
var profile = await authService.GetProfileAsync(userId, cancellationToken);
return ApiResponse<string[]>.Ok(profile.Permissions);
}
/// <summary>
/// 获取当前用户的菜单树(按权限过滤)。
/// </summary>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>当前用户可见的菜单树。</returns>
[HttpGet("menu")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<MenuNodeDto>>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<MenuNodeDto>>), StatusCodes.Status401Unauthorized)]
public async Task<ApiResponse<IReadOnlyList<MenuNodeDto>>> GetMenuTree(CancellationToken cancellationToken)
{
// 1. 获取当前用户标识
var userId = User.GetUserId();
if (userId == 0)
{
return ApiResponse<IReadOnlyList<MenuNodeDto>>.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识");
}
// 2. 生成菜单树
var menu = await authService.GetMenuTreeAsync(userId, cancellationToken);
return ApiResponse<IReadOnlyList<MenuNodeDto>>.Ok(menu);
}
}

View File

@@ -1,41 +0,0 @@
using Microsoft.AspNetCore.Authorization;
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.TenantApi.Controllers;
/// <summary>
/// 当前租户管理员信息。
/// </summary>
[ApiVersion("1.0")]
[Authorize(Roles = "tenant-admin")]
[Route("api/tenant/v{version:apiVersion}/me")]
public sealed class MeController(IAdminAuthService authService) : BaseApiController
{
/// <summary>
/// 获取当前用户档案。
/// </summary>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>当前用户档案信息。</returns>
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<CurrentUserProfile>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<CurrentUserProfile>), StatusCodes.Status401Unauthorized)]
public async Task<ApiResponse<CurrentUserProfile>> Get(CancellationToken cancellationToken)
{
// 1. 从 JWT 中解析用户标识
var userId = User.GetUserId();
if (userId == 0)
{
return ApiResponse<CurrentUserProfile>.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识");
}
// 2. 查询用户档案并返回
var profile = await authService.GetProfileAsync(userId, cancellationToken);
return ApiResponse<CurrentUserProfile>.Ok(profile);
}
}

View File

@@ -11,10 +11,14 @@ using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using Serilog;
using TakeoutSaaS.Application.App.Extensions;
using TakeoutSaaS.Application.Dictionary.Extensions;
using TakeoutSaaS.Application.Identity.Extensions;
using TakeoutSaaS.Application.Messaging.Extensions;
using TakeoutSaaS.Infrastructure.App.Extensions;
using TakeoutSaaS.Infrastructure.Dictionary.Extensions;
using TakeoutSaaS.Infrastructure.Identity.Extensions;
using TakeoutSaaS.Module.Authorization.Extensions;
using TakeoutSaaS.Module.Messaging.Extensions;
using TakeoutSaaS.Module.Tenancy.Extensions;
using TakeoutSaaS.Shared.Web.Extensions;
using TakeoutSaaS.Shared.Web.Swagger;
@@ -81,8 +85,7 @@ if (isDevelopment)
});
}
// 5. 注册多租户解析、鉴权授权与权限策略
builder.Services.AddTenantResolution(builder.Configuration);
// 5. 注册鉴权授权与权限策略
builder.Services.AddJwtAuthentication(builder.Configuration);
builder.Services.AddAuthorization();
builder.Services.AddPermissionAuthorization();
@@ -94,6 +97,17 @@ builder.Services.AddIdentityApplication(enableMiniSupport: false);
builder.Services.AddAppInfrastructure(builder.Configuration);
builder.Services.AddIdentityInfrastructure(builder.Configuration, enableMiniFeatures: false, enableAdminSeed: false);
// 7. 注册多租户解析(依赖 ITenantRepository需在 Infrastructure 之后)
builder.Services.AddTenantResolution(builder.Configuration);
// 6. (空行后) 注册字典模块(系统参数、字典项、缓存等)
builder.Services.AddDictionaryApplication();
builder.Services.AddDictionaryInfrastructure(builder.Configuration);
// 6. (空行后) 注册消息发布能力(未配置 RabbitMQ 时自动降级为 NoOp 实现)
builder.Services.AddMessagingApplication();
builder.Services.AddMessagingModule(builder.Configuration);
// 7. 配置 OpenTelemetry 采集
var otelSection = builder.Configuration.GetSection("Otel");
var otelEndpoint = otelSection.GetValue<string>("Endpoint");

View File

@@ -56,7 +56,9 @@
"/health",
"/healthz"
],
"RootDomain": ""
"RootDomain": "laosankeji.com",
"CodeTenantMap": {},
"ThrowIfUnresolved": false
},
"Cors": {
"Tenant": []
@@ -65,5 +67,15 @@
"Endpoint": "",
"Sampling": "ParentBasedAlwaysOn",
"UseConsoleExporter": true
},
"RabbitMQ": {
"Host": "49.232.6.45",
"Port": 5672,
"Username": "admin",
"Password": "msumshk112233",
"VirtualHost": "/",
"Exchange": "takeout.events",
"ExchangeType": "topic",
"PrefetchCount": 20
}
}

View File

@@ -65,5 +65,15 @@
"Endpoint": "",
"Sampling": "ParentBasedAlwaysOn",
"UseConsoleExporter": false
},
"RabbitMQ": {
"Host": "49.232.6.45",
"Port": 5672,
"Username": "admin",
"Password": "msumshk112233",
"VirtualHost": "/",
"Exchange": "takeout.events",
"ExchangeType": "topic",
"PrefetchCount": 20
}
}

View File

@@ -13,11 +13,6 @@ public interface IAdminAuthService
/// </summary>
Task<TokenResponse> LoginAsync(AdminLoginRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// 简化登录:支持使用“账号@手机号”自动解析租户后登录。
/// </summary>
Task<TokenResponse> LoginSimpleAsync(AdminLoginRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// 刷新 Token。
/// </summary>

View File

@@ -14,12 +14,6 @@ public sealed class PermissionDto
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID固定权限时为基准租户
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 父级权限 ID。
/// </summary>

View File

@@ -14,12 +14,6 @@ public sealed record PermissionTreeDto
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID雪花序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 父级权限 ID。
/// </summary>
@@ -42,7 +36,7 @@ public sealed record PermissionTreeDto
public string Name { get; init; } = string.Empty;
/// <summary>
/// 权限编码(租户内唯一)。
/// 权限编码(全局唯一)。
/// </summary>
public string Code { get; init; } = string.Empty;

View File

@@ -81,7 +81,6 @@ public sealed class CopyRoleTemplateCommandHandler(
var permission = new Permission
{
TenantId = tenantId,
Name = code,
Code = code,
Description = code

View File

@@ -3,19 +3,17 @@ using TakeoutSaaS.Application.Identity.Commands;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Application.Identity;
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Handlers;
/// <summary>
/// 创建菜单处理器。
/// </summary>
public sealed class CreateMenuCommandHandler(
IMenuRepository menuRepository,
ITenantProvider tenantProvider)
public sealed class CreateMenuCommandHandler(IMenuRepository menuRepository)
: IRequestHandler<CreateMenuCommand, MenuDefinitionDto>
{
/// <inheritdoc />
@@ -28,10 +26,9 @@ public sealed class CreateMenuCommandHandler(
}
// 2. 构造实体
var tenantId = tenantProvider.GetCurrentTenantId();
var entity = new MenuDefinition
{
TenantId = tenantId,
Portal = PortalType.Tenant,
ParentId = request.ParentId,
Name = request.Name.Trim(),
Path = request.Path.Trim(),

View File

@@ -43,7 +43,6 @@ public sealed class CreatePermissionCommandHandler(
var sortOrder = request.SortOrder < 0 ? 0 : request.SortOrder;
var permission = new Permission
{
TenantId = tenantId,
ParentId = parentId,
SortOrder = sortOrder,
Type = normalizedType,
@@ -60,7 +59,6 @@ public sealed class CreatePermissionCommandHandler(
return new PermissionDto
{
Id = permission.Id,
TenantId = permission.TenantId,
ParentId = permission.ParentId,
SortOrder = permission.SortOrder,
Type = permission.Type,

View File

@@ -4,16 +4,13 @@ using TakeoutSaaS.Application.Identity.Commands;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Handlers;
/// <summary>
/// 删除菜单处理器。
/// </summary>
public sealed class DeleteMenuCommandHandler(
IMenuRepository menuRepository,
ITenantProvider tenantProvider)
public sealed class DeleteMenuCommandHandler(IMenuRepository menuRepository)
: IRequestHandler<DeleteMenuCommand, bool>
{
/// <inheritdoc />
@@ -25,9 +22,8 @@ public sealed class DeleteMenuCommandHandler(
throw new BusinessException(ErrorCodes.Forbidden, "菜单已固定,禁止删除");
}
// 2. 删除目标及可能的孤儿由外层保证
var tenantId = tenantProvider.GetCurrentTenantId();
await menuRepository.DeleteAsync(request.Id, tenantId, cancellationToken);
// 2. 删除目标
await menuRepository.DeleteAsync(request.Id, cancellationToken);
// 3. 持久化
await menuRepository.SaveChangesAsync(cancellationToken);

View File

@@ -1,32 +1,27 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Application.Identity.Queries;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Handlers;
/// <summary>
/// 菜单列表查询处理器。
/// </summary>
public sealed class ListMenusQueryHandler(
IMenuRepository menuRepository,
ITenantProvider tenantProvider)
public sealed class ListMenusQueryHandler(IMenuRepository menuRepository)
: IRequestHandler<ListMenusQuery, IReadOnlyList<MenuDefinitionDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<MenuDefinitionDto>> Handle(ListMenusQuery request, CancellationToken cancellationToken)
{
// 1. 获取租户
var tenantId = tenantProvider.GetCurrentTenantId();
// 1. 查询租户端菜单
var entities = await menuRepository.GetByPortalAsync(PortalType.Tenant, cancellationToken);
// 2. 查询列表
var entities = await menuRepository.GetByTenantAsync(tenantId, cancellationToken);
// 3. 映射 DTO
// 2. 映射 DTO
var items = entities.Select(MenuMapper.ToDto).ToList();
// 4. 返回结果
// 3. 返回结果
return items;
}
}

View File

@@ -2,32 +2,26 @@ using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Application.Identity.Queries;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Handlers;
/// <summary>
/// 菜单详情查询处理器。
/// </summary>
public sealed class MenuDetailQueryHandler(
IMenuRepository menuRepository,
ITenantProvider tenantProvider)
public sealed class MenuDetailQueryHandler(IMenuRepository menuRepository)
: IRequestHandler<MenuDetailQuery, MenuDefinitionDto?>
{
/// <inheritdoc />
public async Task<MenuDefinitionDto?> Handle(MenuDetailQuery request, CancellationToken cancellationToken)
{
// 1. 获取租户
var tenantId = tenantProvider.GetCurrentTenantId();
// 2. 查询实体
var entity = await menuRepository.FindByIdAsync(request.Id, tenantId, cancellationToken);
// 1. 查询实体
var entity = await menuRepository.FindByIdAsync(request.Id, cancellationToken);
if (entity is null)
{
return null;
}
// 3. 映射并返回
// 2. 映射并返回
return MenuMapper.ToDto(entity);
}
}

View File

@@ -1,6 +1,7 @@
using System.Text.Json;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Domain.Identity.Enums;
namespace TakeoutSaaS.Application.Identity.Handlers;
@@ -79,23 +80,22 @@ internal static class MenuMapper
/// 构建或更新菜单实体并返回 DTO。
/// </summary>
/// <param name="existing">已存在的菜单实体。</param>
/// <param name="tenantId">租户 ID。</param>
/// <param name="name">菜单名称。</param>
/// <param name="portal">门户类型。</param>
/// <param name="payload">菜单 DTO 载荷。</param>
/// <returns>菜单定义 DTO。</returns>
public static MenuDefinitionDto FromCommand(MenuDefinition? existing, long tenantId, string name, MenuDefinitionDto payload)
public static MenuDefinitionDto FromCommand(MenuDefinition? existing, PortalType portal, MenuDefinitionDto payload)
{
// 1. 构造实体
var entity = existing ?? new MenuDefinition
{
TenantId = tenantId,
Portal = portal,
CreatedAt = DateTime.UtcNow
};
// // 填充字段
// 2. 填充字段
FillEntity(entity, payload);
// 2. 返回 DTO 映射
// 3. 返回 DTO 映射
return ToDto(entity);
}

View File

@@ -32,7 +32,6 @@ public sealed class PermissionTreeQueryHandler(
x => new PermissionTreeDto
{
Id = x.Id,
TenantId = x.TenantId,
ParentId = x.ParentId,
SortOrder = x.SortOrder,
Type = x.Type,

View File

@@ -56,7 +56,6 @@ public sealed class RoleDetailQueryHandler(
.Select(x => new PermissionDto
{
Id = x.Id,
TenantId = x.TenantId,
ParentId = x.ParentId,
SortOrder = x.SortOrder,
Type = x.Type,

View File

@@ -60,7 +60,6 @@ public sealed class SearchPermissionsQueryHandler(
var items = paged.Select(permission => new PermissionDto
{
Id = permission.Id,
TenantId = permission.TenantId,
ParentId = permission.ParentId,
SortOrder = permission.SortOrder,
Type = permission.Type,

View File

@@ -5,16 +5,13 @@ using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Handlers;
/// <summary>
/// 更新菜单处理器。
/// </summary>
public sealed class UpdateMenuCommandHandler(
IMenuRepository menuRepository,
ITenantProvider tenantProvider)
public sealed class UpdateMenuCommandHandler(IMenuRepository menuRepository)
: IRequestHandler<UpdateMenuCommand, MenuDefinitionDto?>
{
/// <inheritdoc />
@@ -27,8 +24,7 @@ public sealed class UpdateMenuCommandHandler(
}
// 2. 校验存在
var tenantId = tenantProvider.GetCurrentTenantId();
var entity = await menuRepository.FindByIdAsync(request.Id, tenantId, cancellationToken)
var entity = await menuRepository.FindByIdAsync(request.Id, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "菜单不存在");
// 3. 更新字段

View File

@@ -61,7 +61,6 @@ public sealed class UpdatePermissionCommandHandler(
return new PermissionDto
{
Id = permission.Id,
TenantId = permission.TenantId,
ParentId = permission.ParentId,
SortOrder = permission.SortOrder,
Type = permission.Type,

View File

@@ -4,7 +4,6 @@ using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Results;
@@ -25,9 +24,7 @@ public sealed class AdminAuthService(
IPasswordHasher<IdentityUser> passwordHasher,
IJwtTokenService jwtTokenService,
IRefreshTokenStore refreshTokenStore,
ITenantProvider tenantProvider,
ITenantContextAccessor tenantContextAccessor,
ITenantRepository tenantRepository) : IAdminAuthService
ITenantProvider tenantProvider) : IAdminAuthService
{
private const string TenantAdminRoleCode = "tenant-admin";
@@ -89,60 +86,6 @@ public sealed class AdminAuthService(
return await jwtTokenService.CreateTokensAsync(profile, false, cancellationToken);
}
/// <summary>
/// 简化登录:支持使用“账号@手机号”解析租户后登录。
/// </summary>
/// <param name="request">登录请求</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>令牌响应</returns>
public async Task<TokenResponse> LoginSimpleAsync(AdminLoginRequest request, CancellationToken cancellationToken = default)
{
// 1. 标准化输入
var rawAccount = request.Account?.Trim() ?? string.Empty;
// 2. 尝试解析 “账号@手机号”
var atIndex = rawAccount.LastIndexOf('@');
if (atIndex > 0 && atIndex < rawAccount.Length - 1)
{
var accountPart = rawAccount[..atIndex].Trim();
var phonePart = rawAccount[(atIndex + 1)..].Trim();
if (IsLikelyPhone(phonePart))
{
if (string.IsNullOrWhiteSpace(accountPart))
{
throw new BusinessException(ErrorCodes.BadRequest, "账号格式错误,应为 账号@手机号");
}
var tenantId = await tenantRepository.FindTenantIdByContactPhoneAsync(phonePart, cancellationToken);
if (!tenantId.HasValue || tenantId.Value == 0)
{
throw new BusinessException(ErrorCodes.Unauthorized, "账号或密码错误");
}
var originalTenant = tenantContextAccessor.Current;
tenantContextAccessor.Current = new TenantContext(tenantId.Value, null, "login:simple:contact_phone");
try
{
return await LoginAsync(new AdminLoginRequest { Account = accountPart, Password = request.Password }, cancellationToken);
}
finally
{
tenantContextAccessor.Current = originalTenant;
}
}
}
// 3. 未携带手机号时要求外部已解析租户Header/Host 等)
if (tenantProvider.GetCurrentTenantId() == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "缺少租户标识,请使用 账号@手机号 登录");
}
// 4. 走原有登录逻辑
return await LoginAsync(new AdminLoginRequest { Account = rawAccount, Password = request.Password }, cancellationToken);
}
/// <summary>
/// 刷新访问令牌:使用刷新令牌获取新的访问令牌和刷新令牌。
/// </summary>
@@ -199,9 +142,9 @@ public sealed class AdminAuthService(
{
// 1. 读取档案以获取权限
var profile = await GetProfileAsync(userId, cancellationToken);
// 2. 读取菜单定义
var tenantId = tenantProvider.GetCurrentTenantId();
var definitions = await menuRepository.GetByTenantAsync(tenantId, cancellationToken);
// 2. 读取租户端菜单定义
var definitions = await menuRepository.GetByPortalAsync(PortalType.Tenant, cancellationToken);
// 3. 生成菜单树
var menu = BuildMenuTree(definitions, profile.Permissions);
@@ -398,35 +341,6 @@ public sealed class AdminAuthService(
await userRepository.SaveChangesAsync(cancellationToken);
}
private static bool IsLikelyPhone(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var span = value.AsSpan();
if (span[0] == '+')
{
span = span[1..];
}
if (span.Length < 6 || span.Length > 32)
{
return false;
}
foreach (var ch in span)
{
if (!char.IsDigit(ch))
{
return false;
}
}
return true;
}
private static IReadOnlyList<MenuNodeDto> BuildMenuTree(
IReadOnlyList<Domain.Identity.Entities.MenuDefinition> definitions,
IReadOnlyList<string> permissions)

View File

@@ -1,12 +1,17 @@
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Identity.Entities;
/// <summary>
/// 管理端菜单定义。
/// 管理端菜单定义(系统级数据,不按租户隔离)
/// </summary>
public sealed class MenuDefinition : MultiTenantEntityBase
public sealed class MenuDefinition : AuditableEntityBase
{
/// <summary>
/// 门户类型Admin=平台端Tenant=租户端)。
/// </summary>
public PortalType Portal { get; set; }
/// <summary>
/// 父级菜单 ID根节点为 0。
/// </summary>

View File

@@ -1,11 +1,12 @@
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Identity.Entities;
/// <summary>
/// 权限定义。
/// 权限定义(系统级数据,不按租户隔离)
/// </summary>
public sealed class Permission : MultiTenantEntityBase
public sealed class Permission : AuditableEntityBase
{
/// <summary>
/// 父级权限 ID根节点为 0。
@@ -36,4 +37,9 @@ public sealed class Permission : MultiTenantEntityBase
/// 描述。
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 门户类型Admin=平台端Tenant=租户端)。
/// </summary>
public PortalType Portal { get; set; }
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Identity.Enums;
/// <summary>
/// 后台端类型(用于区分平台管理端与租户管理端)。
/// </summary>
public enum PortalType
{
/// <summary>
/// 平台管理端Admin
/// </summary>
Admin = 0,
/// <summary>
/// 租户管理端Tenant
/// </summary>
Tenant = 1
}

View File

@@ -1,4 +1,5 @@
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Domain.Identity.Enums;
namespace TakeoutSaaS.Domain.Identity.Repositories;
@@ -8,14 +9,14 @@ namespace TakeoutSaaS.Domain.Identity.Repositories;
public interface IMenuRepository
{
/// <summary>
/// 按租户获取菜单列表。
/// 按门户类型获取菜单列表。
/// </summary>
Task<IReadOnlyList<MenuDefinition>> GetByTenantAsync(long tenantId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<MenuDefinition>> GetByPortalAsync(PortalType portal, CancellationToken cancellationToken = default);
/// <summary>
/// 根据 ID 查询菜单。
/// </summary>
Task<MenuDefinition?> FindByIdAsync(long id, long tenantId, CancellationToken cancellationToken = default);
Task<MenuDefinition?> FindByIdAsync(long id, CancellationToken cancellationToken = default);
/// <summary>
/// 新增菜单。
@@ -30,7 +31,7 @@ public interface IMenuRepository
/// <summary>
/// 删除菜单。
/// </summary>
Task DeleteAsync(long id, long tenantId, CancellationToken cancellationToken = default);
Task DeleteAsync(long id, CancellationToken cancellationToken = default);
/// <summary>
/// 持久化变更。

View File

@@ -84,6 +84,14 @@ public interface ITenantRepository
/// <returns>存在返回 true否则 false。</returns>
Task<bool> ExistsByCodeAsync(string code, CancellationToken cancellationToken = default);
/// <summary>
/// 依据租户编码查询租户 ID。
/// </summary>
/// <param name="code">租户编码。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>租户 ID未找到返回 null。</returns>
Task<long?> FindIdByCodeAsync(string code, CancellationToken cancellationToken = default);
/// <summary>
/// 判断租户名称是否存在(支持排除指定租户)。
/// </summary>

View File

@@ -162,6 +162,20 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
return context.Tenants.AnyAsync(x => x.Code == normalized, cancellationToken);
}
/// <inheritdoc />
public Task<long?> FindIdByCodeAsync(string code, CancellationToken cancellationToken = default)
{
// 1. 标准化编码
var normalized = code.Trim();
// 2. 查询租户 ID仅查询未删除且状态正常的租户
return context.Tenants
.AsNoTracking()
.Where(x => x.Code == normalized && x.DeletedAt == null)
.Select(x => (long?)x.Id)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task<bool> ExistsByNameAsync(string name, long? excludeTenantId = null, CancellationToken cancellationToken = default)
{

View File

@@ -7,36 +7,41 @@ namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
/// <summary>
/// EF 权限仓储。
/// </summary>
/// <remarks>
/// 权限是系统级数据,使用 IgnoreQueryFilters 忽略多租户过滤。
/// </remarks>
public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermissionRepository
{
/// <summary>
/// 根据权限 ID 获取权限。
/// </summary>
/// <param name="permissionId">权限 ID。</param>
/// <param name="tenantId">租户 ID。</param>
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>权限实体或 null。</returns>
public Task<Permission?> FindByIdAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default)
=> dbContext.Permissions
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == permissionId && x.TenantId == tenantId && x.DeletedAt == null, cancellationToken);
.FirstOrDefaultAsync(x => x.Id == permissionId && x.DeletedAt == null, cancellationToken);
/// <summary>
/// 根据权限编码获取权限。
/// </summary>
/// <param name="code">权限编码。</param>
/// <param name="tenantId">租户 ID。</param>
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>权限实体或 null。</returns>
public Task<Permission?> FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default)
=> dbContext.Permissions
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Code == code && x.TenantId == tenantId && x.DeletedAt == null, cancellationToken);
.FirstOrDefaultAsync(x => x.Code == code && x.DeletedAt == null, cancellationToken);
/// <summary>
/// 根据权限编码集合批量获取权限。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
/// <param name="codes">权限编码集合。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>权限列表。</returns>
@@ -49,10 +54,11 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
.Distinct()
.ToArray();
// 2. 读取租户权限
// 2. 读取权限(忽略租户过滤)
return dbContext.Permissions
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && normalizedCodes.Contains(x.Code))
.Where(x => x.DeletedAt == null && normalizedCodes.Contains(x.Code))
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<Permission>)t.Result, cancellationToken);
}
@@ -60,30 +66,32 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
/// <summary>
/// 根据权限 ID 集合批量获取权限。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
/// <param name="permissionIds">权限 ID 集合。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>权限列表。</returns>
public Task<IReadOnlyList<Permission>> GetByIdsAsync(long tenantId, IEnumerable<long> permissionIds, CancellationToken cancellationToken = default)
=> dbContext.Permissions
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && permissionIds.Contains(x.Id))
.Where(x => x.DeletedAt == null && permissionIds.Contains(x.Id))
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<Permission>)t.Result, cancellationToken);
/// <summary>
/// 按关键字搜索权限。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
/// <param name="keyword">搜索关键字。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>权限列表。</returns>
public Task<IReadOnlyList<Permission>> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default)
{
// 1. 构建基础查询
// 1. 构建基础查询(忽略租户过滤)
var query = dbContext.Permissions
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.DeletedAt == null);
.Where(x => x.DeletedAt == null);
if (!string.IsNullOrWhiteSpace(keyword))
{
// 2. 追加关键字过滤
@@ -128,13 +136,15 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
/// 删除指定权限。
/// </summary>
/// <param name="permissionId">权限 ID。</param>
/// <param name="tenantId">租户 ID。</param>
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步任务。</returns>
public async Task DeleteAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default)
{
// 1. 查询目标权限
var entity = await dbContext.Permissions.FirstOrDefaultAsync(x => x.Id == permissionId && x.TenantId == tenantId, cancellationToken);
var entity = await dbContext.Permissions
.IgnoreQueryFilters()
.FirstOrDefaultAsync(x => x.Id == permissionId, cancellationToken);
if (entity != null)
{
// 2. 删除实体

View File

@@ -160,7 +160,6 @@ public sealed class IdentityDbContext(
{
builder.ToTable("permissions");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.ParentId).IsRequired();
builder.Property(x => x.SortOrder).IsRequired();
builder.Property(x => x.Type).HasMaxLength(16).IsRequired();
@@ -169,9 +168,8 @@ public sealed class IdentityDbContext(
builder.Property(x => x.Description).HasMaxLength(256);
ConfigureAuditableEntity(builder);
ConfigureSoftDeleteEntity(builder);
builder.HasIndex(x => x.TenantId);
builder.HasIndex(x => new { x.TenantId, x.ParentId, x.SortOrder });
builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique();
builder.HasIndex(x => x.Code).IsUnique();
builder.HasIndex(x => new { x.Portal, x.ParentId, x.SortOrder });
}
private static void ConfigureRoleTemplate(EntityTypeBuilder<RoleTemplate> builder)
@@ -226,7 +224,7 @@ public sealed class IdentityDbContext(
{
builder.ToTable("menu_definitions");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.Portal).HasConversion<int>().IsRequired();
builder.Property(x => x.ParentId).IsRequired();
builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
builder.Property(x => x.Path).HasMaxLength(256).IsRequired();
@@ -241,6 +239,6 @@ public sealed class IdentityDbContext(
builder.Property(x => x.AuthListJson).HasColumnType("text");
ConfigureAuditableEntity(builder);
ConfigureSoftDeleteEntity(builder);
builder.HasIndex(x => new { x.TenantId, x.ParentId, x.SortOrder });
builder.HasIndex(x => new { x.Portal, x.ParentId, x.SortOrder });
}
}

View File

@@ -1,66 +1,39 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Infrastructure.Identity.Persistence;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Infrastructure.Identity.Repositories;
/// <summary>
/// 菜单仓储 EF 实现。
/// </summary>
public sealed class EfMenuRepository(IdentityDbContext dbContext, ITenantContextAccessor tenantContextAccessor) : IMenuRepository
public sealed class EfMenuRepository(IdentityDbContext dbContext) : IMenuRepository
{
/// <inheritdoc />
public async Task<IReadOnlyList<MenuDefinition>> GetByTenantAsync(long tenantId, CancellationToken cancellationToken = default)
public async Task<IReadOnlyList<MenuDefinition>> GetByPortalAsync(PortalType portal, CancellationToken cancellationToken = default)
{
// 1. 优先返回租户自定义菜单
var tenantMenus = await dbContext.MenuDefinitions
// 1. 按门户类型查询菜单(忽略租户过滤器)
var menus = await dbContext.MenuDefinitions
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.TenantId == tenantId)
.OrderBy(x => x.ParentId)
.ThenBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
if (tenantMenus.Count > 0)
{
return tenantMenus;
}
// 2. (空行后) 回退系统默认菜单TenantId=0
using (tenantContextAccessor.EnterTenantScope(0, "menu"))
{
var systemMenus = await dbContext.MenuDefinitions
.AsNoTracking()
.Where(x => x.TenantId == 0 && x.DeletedAt == null)
.Where(x => x.Portal == portal && x.DeletedAt == null)
.OrderBy(x => x.ParentId)
.ThenBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
return systemMenus;
}
return menus;
}
/// <inheritdoc />
public async Task<MenuDefinition?> FindByIdAsync(long id, long tenantId, CancellationToken cancellationToken = default)
{
// 1. 优先查租户菜单
var tenantMenu = await dbContext.MenuDefinitions
.AsNoTracking()
.FirstOrDefaultAsync(
x => x.Id == id && x.TenantId == tenantId,
cancellationToken);
if (tenantMenu != null)
{
return tenantMenu;
}
// 2. (空行后) 回退查系统默认菜单TenantId=0
using (tenantContextAccessor.EnterTenantScope(0, "menu"))
public async Task<MenuDefinition?> FindByIdAsync(long id, CancellationToken cancellationToken = default)
{
// 1. 按 ID 查询菜单(忽略租户过滤器)
return await dbContext.MenuDefinitions
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id && x.TenantId == 0 && x.DeletedAt == null, cancellationToken);
}
.FirstOrDefaultAsync(x => x.Id == id && x.DeletedAt == null, cancellationToken);
}
/// <inheritdoc />
@@ -77,11 +50,12 @@ public sealed class EfMenuRepository(IdentityDbContext dbContext, ITenantContext
}
/// <inheritdoc />
public async Task DeleteAsync(long id, long tenantId, CancellationToken cancellationToken = default)
public async Task DeleteAsync(long id, CancellationToken cancellationToken = default)
{
// 1. 查询目标
// 1. 查询目标(忽略租户过滤器)
var entity = await dbContext.MenuDefinitions
.FirstOrDefaultAsync(x => x.Id == id && x.TenantId == tenantId, cancellationToken);
.IgnoreQueryFilters()
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
// 2. 存在则删除
if (entity is not null)

View File

@@ -1,4 +1,5 @@
using MassTransit;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.Identity.Abstractions;
using TakeoutSaaS.Application.Identity.Events;
@@ -7,9 +8,20 @@ namespace TakeoutSaaS.Infrastructure.Logs.Publishers;
/// <summary>
/// 身份模块操作日志发布器(基于 MassTransit Outbox
/// </summary>
public sealed class IdentityOperationLogPublisher(IPublishEndpoint publishEndpoint) : IIdentityOperationLogPublisher
public sealed class IdentityOperationLogPublisher(
ILogger<IdentityOperationLogPublisher> logger,
IPublishEndpoint? publishEndpoint = null) : IIdentityOperationLogPublisher
{
/// <inheritdoc />
public Task PublishAsync(IdentityUserOperationLogMessage message, CancellationToken cancellationToken = default)
=> publishEndpoint.Publish(message, cancellationToken);
{
if (publishEndpoint is null)
{
logger.LogDebug("未配置 MassTransit已跳过操作日志消息发布{OperationType}", message.OperationType);
return Task.CompletedTask;
}
// 1. (空行后) 已配置 MassTransit 时正常发布消息
return publishEndpoint.Publish(message, cancellationToken);
}
}

View File

@@ -17,8 +17,17 @@ public static class MessagingServiceCollectionExtensions
/// </summary>
public static IServiceCollection AddMessagingModule(this IServiceCollection services, IConfiguration configuration)
{
var rabbitMqSection = configuration.GetSection("RabbitMQ");
if (!rabbitMqSection.Exists())
{
services.AddSingleton<IMessagePublisher, NoOpMessagePublisher>();
services.AddSingleton<IMessageSubscriber, NoOpMessageSubscriber>();
return services;
}
// 1. (空行后) 存在 RabbitMQ 配置时才启用真实 MQ 能力(启动时验证配置完整性)
services.AddOptions<RabbitMqOptions>()
.Bind(configuration.GetSection("RabbitMQ"))
.Bind(rabbitMqSection)
.ValidateDataAnnotations()
.ValidateOnStart();

View File

@@ -0,0 +1,21 @@
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Module.Messaging.Abstractions;
namespace TakeoutSaaS.Module.Messaging.Services;
/// <summary>
/// 空实现消息发布器:用于未配置 RabbitMQ 的开发/测试场景,避免启动依赖外部 MQ。
/// </summary>
public sealed class NoOpMessagePublisher(ILogger<NoOpMessagePublisher> logger) : IMessagePublisher
{
/// <inheritdoc />
public Task PublishAsync<T>(string routingKey, T message, CancellationToken cancellationToken = default)
{
logger.LogDebug(
"未配置 RabbitMQ已跳过消息发布RoutingKey={RoutingKey} MessageType={MessageType}",
routingKey,
typeof(T).FullName ?? typeof(T).Name);
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,25 @@
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Module.Messaging.Abstractions;
namespace TakeoutSaaS.Module.Messaging.Services;
/// <summary>
/// 空实现消息订阅器:用于未配置 RabbitMQ 的开发/测试场景。
/// </summary>
public sealed class NoOpMessageSubscriber(ILogger<NoOpMessageSubscriber> logger) : IMessageSubscriber
{
/// <inheritdoc />
public Task SubscribeAsync<T>(string queue, string routingKey, Func<T, CancellationToken, Task<bool>> handler, CancellationToken cancellationToken = default)
{
logger.LogWarning("未配置 RabbitMQ消息订阅被禁用Queue={Queue} RoutingKey={RoutingKey}", queue, routingKey);
return Task.CompletedTask;
}
// 1. (空行后) 释放资源NoOp 实现无实际资源)
/// <inheritdoc />
public ValueTask DisposeAsync()
{
logger.LogDebug("NoOpMessageSubscriber 已释放。");
return ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,22 @@
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Module.Tenancy;
/// <summary>
/// 基于数据库的租户编码解析器实现。
/// </summary>
public sealed class DatabaseTenantCodeResolver(ITenantRepository tenantRepository) : ITenantCodeResolver
{
/// <inheritdoc />
public Task<long?> ResolveAsync(string code, CancellationToken cancellationToken = default)
{
// 1. 参数校验
if (string.IsNullOrWhiteSpace(code))
{
return Task.FromResult<long?>(null);
}
// 2. 从数据库查询租户 ID
return tenantRepository.FindIdByCodeAsync(code, cancellationToken);
}
}

View File

@@ -18,6 +18,7 @@ public static class TenantServiceCollectionExtensions
{
services.TryAddSingleton<ITenantContextAccessor, TenantContextAccessor>();
services.TryAddScoped<ITenantProvider, TenantProvider>();
services.TryAddScoped<ITenantCodeResolver, DatabaseTenantCodeResolver>();
services.AddOptions<TenantResolutionOptions>()
.Bind(configuration.GetSection("Tenancy"))

View File

@@ -0,0 +1,15 @@
namespace TakeoutSaaS.Module.Tenancy;
/// <summary>
/// 租户编码解析器:根据租户编码查询租户 ID。
/// </summary>
public interface ITenantCodeResolver
{
/// <summary>
/// 根据租户编码查询租户 ID。
/// </summary>
/// <param name="code">租户编码。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>租户 ID未找到返回 null。</returns>
Task<long?> ResolveAsync(string code, CancellationToken cancellationToken = default);
}

View File

@@ -6,6 +6,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\TakeoutSaaS.BuildingBlocks\src\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />
<ProjectReference Include="..\..\Domain\TakeoutSaaS.Domain\TakeoutSaaS.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />

View File

@@ -22,7 +22,7 @@ public sealed class TenantResolutionMiddleware(
/// <summary>
/// 解析租户并将上下文注入请求。
/// </summary>
public async Task InvokeAsync(HttpContext context)
public async Task InvokeAsync(HttpContext context, ITenantCodeResolver tenantCodeResolver)
{
var options = optionsMonitor.CurrentValue ?? new TenantResolutionOptions();
if (ShouldSkip(context.Request.Path, options))
@@ -31,7 +31,7 @@ public sealed class TenantResolutionMiddleware(
return;
}
var tenantContext = ResolveTenant(context, options);
var tenantContext = await ResolveTenantAsync(context, options, tenantCodeResolver);
tenantContextAccessor.Current = tenantContext;
context.Items[TenantConstants.HttpContextItemKey] = tenantContext;
@@ -86,7 +86,10 @@ public sealed class TenantResolutionMiddleware(
});
}
private static TenantContext ResolveTenant(HttpContext context, TenantResolutionOptions options)
private static async Task<TenantContext> ResolveTenantAsync(
HttpContext context,
TenantResolutionOptions options,
ITenantCodeResolver tenantCodeResolver)
{
var request = context.Request;
var isAuthenticated = context.User?.Identity?.IsAuthenticated == true;
@@ -117,9 +120,10 @@ public sealed class TenantResolutionMiddleware(
request.Headers.TryGetValue(options.TenantCodeHeaderName, out var codeHeader))
{
var code = codeHeader.FirstOrDefault();
if (TryResolveByCode(code, options, out var tenantFromCode))
var tenantFromCode = await ResolveByCodeAsync(code, options, tenantCodeResolver, context.RequestAborted);
if (tenantFromCode.HasValue)
{
return new TenantContext(tenantFromCode, code, $"header:{options.TenantCodeHeaderName}");
return new TenantContext(tenantFromCode.Value, code, $"header:{options.TenantCodeHeaderName}");
}
}
@@ -127,30 +131,43 @@ public sealed class TenantResolutionMiddleware(
var host = request.Host.Host;
if (!string.IsNullOrWhiteSpace(host))
{
// 4.1 精确域名映射
if (options.DomainTenantMap.TryGetValue(host, out var tenantFromHost) && tenantFromHost > 0)
{
return new TenantContext(tenantFromHost, null, $"host:{host}");
}
// 4.2 子域名解析
var codeFromHost = ResolveCodeFromHost(host, options.RootDomain);
if (TryResolveByCode(codeFromHost, options, out var tenantFromSubdomain))
var tenantFromSubdomain = await ResolveByCodeAsync(codeFromHost, options, tenantCodeResolver, context.RequestAborted);
if (tenantFromSubdomain.HasValue)
{
return new TenantContext(tenantFromSubdomain, codeFromHost, $"host:{host}");
return new TenantContext(tenantFromSubdomain.Value, codeFromHost, $"host:{host}");
}
}
return TenantContext.Empty;
}
private static bool TryResolveByCode(string? code, TenantResolutionOptions options, out long tenantId)
private static async Task<long?> ResolveByCodeAsync(
string? code,
TenantResolutionOptions options,
ITenantCodeResolver tenantCodeResolver,
CancellationToken cancellationToken)
{
tenantId = 0;
if (string.IsNullOrWhiteSpace(code))
{
return false;
return null;
}
return options.CodeTenantMap.TryGetValue(code, out tenantId) && tenantId > 0;
// 1. 优先从静态配置查找(兼容旧配置)
if (options.CodeTenantMap.TryGetValue(code, out var tenantId) && tenantId > 0)
{
return tenantId;
}
// 2. 从数据库动态查询
return await tenantCodeResolver.ResolveAsync(code, cancellationToken);
}
private static string? ResolveCodeFromHost(string host, string? rootDomain)