Compare commits
10 Commits
f1963ae1a5
...
75f20558a5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75f20558a5 | ||
|
|
22479ea787 | ||
|
|
5a26f82628 | ||
|
|
e88c41c11e | ||
|
|
cfacbf8363 | ||
|
|
286e6e9acb | ||
|
|
5338eb5434 | ||
|
|
6ad1e8295c | ||
|
|
82f24f7003 | ||
|
|
8e315e4f17 |
180
.github/workflows/ci-cd.yml
vendored
180
.github/workflows/ci-cd.yml
vendored
@@ -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
222
CLAUDE.md
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
# Repository expectations
|
||||||
|
# 编程规范_FOR_AI(TakeoutSaaS) - 终极完全体
|
||||||
|
|
||||||
|
> **核心指令**:你是一个高级 .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 版本**:新增包必须使用最新版,未经允许不得进行包降级。
|
||||||
Submodule TakeoutSaaS.Docs updated: c06773499d...de7aefd0ff
@@ -2,15 +2,17 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||||
using TakeoutSaaS.Application.Identity.Contracts;
|
using TakeoutSaaS.Application.Identity.Contracts;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||||
using TakeoutSaaS.Shared.Web.Api;
|
using TakeoutSaaS.Shared.Web.Api;
|
||||||
|
using TakeoutSaaS.Shared.Web.Security;
|
||||||
|
|
||||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 租户管理端登录认证。
|
/// 租户管理端登录认证。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>仅允许租户管理员登录获取 Token。</remarks>
|
/// <remarks>提供登录、刷新 Token、获取用户信息及菜单等能力。</remarks>
|
||||||
[ApiVersion("1.0")]
|
[ApiVersion("1.0")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[Route("api/tenant/v{version:apiVersion}/auth")]
|
[Route("api/tenant/v{version:apiVersion}/auth")]
|
||||||
@@ -34,24 +36,6 @@ public sealed class AuthController(IAdminAuthService authService) : BaseApiContr
|
|||||||
return ApiResponse<TokenResponse>.Ok(response);
|
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>
|
/// <summary>
|
||||||
/// 刷新 Token。
|
/// 刷新 Token。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -69,4 +53,87 @@ public sealed class AuthController(IAdminAuthService authService) : BaseApiContr
|
|||||||
// 2. 返回新的令牌
|
// 2. 返回新的令牌
|
||||||
return ApiResponse<TokenResponse>.Ok(response);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,10 +11,14 @@ using OpenTelemetry.Resources;
|
|||||||
using OpenTelemetry.Trace;
|
using OpenTelemetry.Trace;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using TakeoutSaaS.Application.App.Extensions;
|
using TakeoutSaaS.Application.App.Extensions;
|
||||||
|
using TakeoutSaaS.Application.Dictionary.Extensions;
|
||||||
using TakeoutSaaS.Application.Identity.Extensions;
|
using TakeoutSaaS.Application.Identity.Extensions;
|
||||||
|
using TakeoutSaaS.Application.Messaging.Extensions;
|
||||||
using TakeoutSaaS.Infrastructure.App.Extensions;
|
using TakeoutSaaS.Infrastructure.App.Extensions;
|
||||||
|
using TakeoutSaaS.Infrastructure.Dictionary.Extensions;
|
||||||
using TakeoutSaaS.Infrastructure.Identity.Extensions;
|
using TakeoutSaaS.Infrastructure.Identity.Extensions;
|
||||||
using TakeoutSaaS.Module.Authorization.Extensions;
|
using TakeoutSaaS.Module.Authorization.Extensions;
|
||||||
|
using TakeoutSaaS.Module.Messaging.Extensions;
|
||||||
using TakeoutSaaS.Module.Tenancy.Extensions;
|
using TakeoutSaaS.Module.Tenancy.Extensions;
|
||||||
using TakeoutSaaS.Shared.Web.Extensions;
|
using TakeoutSaaS.Shared.Web.Extensions;
|
||||||
using TakeoutSaaS.Shared.Web.Swagger;
|
using TakeoutSaaS.Shared.Web.Swagger;
|
||||||
@@ -81,8 +85,7 @@ if (isDevelopment)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 注册多租户解析、鉴权授权与权限策略
|
// 5. 注册鉴权授权与权限策略
|
||||||
builder.Services.AddTenantResolution(builder.Configuration);
|
|
||||||
builder.Services.AddJwtAuthentication(builder.Configuration);
|
builder.Services.AddJwtAuthentication(builder.Configuration);
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization();
|
||||||
builder.Services.AddPermissionAuthorization();
|
builder.Services.AddPermissionAuthorization();
|
||||||
@@ -94,6 +97,17 @@ builder.Services.AddIdentityApplication(enableMiniSupport: false);
|
|||||||
builder.Services.AddAppInfrastructure(builder.Configuration);
|
builder.Services.AddAppInfrastructure(builder.Configuration);
|
||||||
builder.Services.AddIdentityInfrastructure(builder.Configuration, enableMiniFeatures: false, enableAdminSeed: false);
|
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 采集
|
// 7. 配置 OpenTelemetry 采集
|
||||||
var otelSection = builder.Configuration.GetSection("Otel");
|
var otelSection = builder.Configuration.GetSection("Otel");
|
||||||
var otelEndpoint = otelSection.GetValue<string>("Endpoint");
|
var otelEndpoint = otelSection.GetValue<string>("Endpoint");
|
||||||
|
|||||||
@@ -56,7 +56,9 @@
|
|||||||
"/health",
|
"/health",
|
||||||
"/healthz"
|
"/healthz"
|
||||||
],
|
],
|
||||||
"RootDomain": ""
|
"RootDomain": "laosankeji.com",
|
||||||
|
"CodeTenantMap": {},
|
||||||
|
"ThrowIfUnresolved": false
|
||||||
},
|
},
|
||||||
"Cors": {
|
"Cors": {
|
||||||
"Tenant": []
|
"Tenant": []
|
||||||
@@ -65,5 +67,15 @@
|
|||||||
"Endpoint": "",
|
"Endpoint": "",
|
||||||
"Sampling": "ParentBasedAlwaysOn",
|
"Sampling": "ParentBasedAlwaysOn",
|
||||||
"UseConsoleExporter": true
|
"UseConsoleExporter": true
|
||||||
|
},
|
||||||
|
"RabbitMQ": {
|
||||||
|
"Host": "49.232.6.45",
|
||||||
|
"Port": 5672,
|
||||||
|
"Username": "admin",
|
||||||
|
"Password": "msumshk112233",
|
||||||
|
"VirtualHost": "/",
|
||||||
|
"Exchange": "takeout.events",
|
||||||
|
"ExchangeType": "topic",
|
||||||
|
"PrefetchCount": 20
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,5 +65,15 @@
|
|||||||
"Endpoint": "",
|
"Endpoint": "",
|
||||||
"Sampling": "ParentBasedAlwaysOn",
|
"Sampling": "ParentBasedAlwaysOn",
|
||||||
"UseConsoleExporter": false
|
"UseConsoleExporter": false
|
||||||
|
},
|
||||||
|
"RabbitMQ": {
|
||||||
|
"Host": "49.232.6.45",
|
||||||
|
"Port": 5672,
|
||||||
|
"Username": "admin",
|
||||||
|
"Password": "msumshk112233",
|
||||||
|
"VirtualHost": "/",
|
||||||
|
"Exchange": "takeout.events",
|
||||||
|
"ExchangeType": "topic",
|
||||||
|
"PrefetchCount": 20
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,6 @@ public interface IAdminAuthService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task<TokenResponse> LoginAsync(AdminLoginRequest request, CancellationToken cancellationToken = default);
|
Task<TokenResponse> LoginAsync(AdminLoginRequest request, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 简化登录:支持使用“账号@手机号”自动解析租户后登录。
|
|
||||||
/// </summary>
|
|
||||||
Task<TokenResponse> LoginSimpleAsync(AdminLoginRequest request, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 刷新 Token。
|
/// 刷新 Token。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -14,12 +14,6 @@ public sealed class PermissionDto
|
|||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||||
public long Id { get; init; }
|
public long Id { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 租户 ID(固定权限时为基准租户)。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long TenantId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 父级权限 ID。
|
/// 父级权限 ID。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -14,12 +14,6 @@ public sealed record PermissionTreeDto
|
|||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||||
public long Id { get; init; }
|
public long Id { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 租户 ID(雪花,序列化为字符串)。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long TenantId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 父级权限 ID。
|
/// 父级权限 ID。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -42,7 +36,7 @@ public sealed record PermissionTreeDto
|
|||||||
public string Name { get; init; } = string.Empty;
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 权限编码(租户内唯一)。
|
/// 权限编码(全局唯一)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Code { get; init; } = string.Empty;
|
public string Code { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ public sealed class CopyRoleTemplateCommandHandler(
|
|||||||
|
|
||||||
var permission = new Permission
|
var permission = new Permission
|
||||||
{
|
{
|
||||||
TenantId = tenantId,
|
|
||||||
Name = code,
|
Name = code,
|
||||||
Code = code,
|
Code = code,
|
||||||
Description = code
|
Description = code
|
||||||
|
|||||||
@@ -3,19 +3,17 @@ using TakeoutSaaS.Application.Identity.Commands;
|
|||||||
using TakeoutSaaS.Application.Identity.Contracts;
|
using TakeoutSaaS.Application.Identity.Contracts;
|
||||||
using TakeoutSaaS.Application.Identity;
|
using TakeoutSaaS.Application.Identity;
|
||||||
using TakeoutSaaS.Domain.Identity.Entities;
|
using TakeoutSaaS.Domain.Identity.Entities;
|
||||||
|
using TakeoutSaaS.Domain.Identity.Enums;
|
||||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 创建菜单处理器。
|
/// 创建菜单处理器。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CreateMenuCommandHandler(
|
public sealed class CreateMenuCommandHandler(IMenuRepository menuRepository)
|
||||||
IMenuRepository menuRepository,
|
|
||||||
ITenantProvider tenantProvider)
|
|
||||||
: IRequestHandler<CreateMenuCommand, MenuDefinitionDto>
|
: IRequestHandler<CreateMenuCommand, MenuDefinitionDto>
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -28,10 +26,9 @@ public sealed class CreateMenuCommandHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. 构造实体
|
// 2. 构造实体
|
||||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
|
||||||
var entity = new MenuDefinition
|
var entity = new MenuDefinition
|
||||||
{
|
{
|
||||||
TenantId = tenantId,
|
Portal = PortalType.Tenant,
|
||||||
ParentId = request.ParentId,
|
ParentId = request.ParentId,
|
||||||
Name = request.Name.Trim(),
|
Name = request.Name.Trim(),
|
||||||
Path = request.Path.Trim(),
|
Path = request.Path.Trim(),
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ public sealed class CreatePermissionCommandHandler(
|
|||||||
var sortOrder = request.SortOrder < 0 ? 0 : request.SortOrder;
|
var sortOrder = request.SortOrder < 0 ? 0 : request.SortOrder;
|
||||||
var permission = new Permission
|
var permission = new Permission
|
||||||
{
|
{
|
||||||
TenantId = tenantId,
|
|
||||||
ParentId = parentId,
|
ParentId = parentId,
|
||||||
SortOrder = sortOrder,
|
SortOrder = sortOrder,
|
||||||
Type = normalizedType,
|
Type = normalizedType,
|
||||||
@@ -60,7 +59,6 @@ public sealed class CreatePermissionCommandHandler(
|
|||||||
return new PermissionDto
|
return new PermissionDto
|
||||||
{
|
{
|
||||||
Id = permission.Id,
|
Id = permission.Id,
|
||||||
TenantId = permission.TenantId,
|
|
||||||
ParentId = permission.ParentId,
|
ParentId = permission.ParentId,
|
||||||
SortOrder = permission.SortOrder,
|
SortOrder = permission.SortOrder,
|
||||||
Type = permission.Type,
|
Type = permission.Type,
|
||||||
|
|||||||
@@ -4,16 +4,13 @@ using TakeoutSaaS.Application.Identity.Commands;
|
|||||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 删除菜单处理器。
|
/// 删除菜单处理器。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class DeleteMenuCommandHandler(
|
public sealed class DeleteMenuCommandHandler(IMenuRepository menuRepository)
|
||||||
IMenuRepository menuRepository,
|
|
||||||
ITenantProvider tenantProvider)
|
|
||||||
: IRequestHandler<DeleteMenuCommand, bool>
|
: IRequestHandler<DeleteMenuCommand, bool>
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -25,9 +22,8 @@ public sealed class DeleteMenuCommandHandler(
|
|||||||
throw new BusinessException(ErrorCodes.Forbidden, "菜单已固定,禁止删除");
|
throw new BusinessException(ErrorCodes.Forbidden, "菜单已固定,禁止删除");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 删除目标及可能的孤儿由外层保证
|
// 2. 删除目标
|
||||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
await menuRepository.DeleteAsync(request.Id, cancellationToken);
|
||||||
await menuRepository.DeleteAsync(request.Id, tenantId, cancellationToken);
|
|
||||||
|
|
||||||
// 3. 持久化
|
// 3. 持久化
|
||||||
await menuRepository.SaveChangesAsync(cancellationToken);
|
await menuRepository.SaveChangesAsync(cancellationToken);
|
||||||
|
|||||||
@@ -1,32 +1,27 @@
|
|||||||
using MediatR;
|
using MediatR;
|
||||||
using TakeoutSaaS.Application.Identity.Contracts;
|
using TakeoutSaaS.Application.Identity.Contracts;
|
||||||
using TakeoutSaaS.Application.Identity.Queries;
|
using TakeoutSaaS.Application.Identity.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Identity.Enums;
|
||||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 菜单列表查询处理器。
|
/// 菜单列表查询处理器。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ListMenusQueryHandler(
|
public sealed class ListMenusQueryHandler(IMenuRepository menuRepository)
|
||||||
IMenuRepository menuRepository,
|
|
||||||
ITenantProvider tenantProvider)
|
|
||||||
: IRequestHandler<ListMenusQuery, IReadOnlyList<MenuDefinitionDto>>
|
: IRequestHandler<ListMenusQuery, IReadOnlyList<MenuDefinitionDto>>
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<MenuDefinitionDto>> Handle(ListMenusQuery request, CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<MenuDefinitionDto>> Handle(ListMenusQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// 1. 获取租户
|
// 1. 查询租户端菜单
|
||||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
var entities = await menuRepository.GetByPortalAsync(PortalType.Tenant, cancellationToken);
|
||||||
|
|
||||||
// 2. 查询列表
|
// 2. 映射 DTO
|
||||||
var entities = await menuRepository.GetByTenantAsync(tenantId, cancellationToken);
|
|
||||||
|
|
||||||
// 3. 映射 DTO
|
|
||||||
var items = entities.Select(MenuMapper.ToDto).ToList();
|
var items = entities.Select(MenuMapper.ToDto).ToList();
|
||||||
|
|
||||||
// 4. 返回结果
|
// 3. 返回结果
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,32 +2,26 @@ using MediatR;
|
|||||||
using TakeoutSaaS.Application.Identity.Contracts;
|
using TakeoutSaaS.Application.Identity.Contracts;
|
||||||
using TakeoutSaaS.Application.Identity.Queries;
|
using TakeoutSaaS.Application.Identity.Queries;
|
||||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 菜单详情查询处理器。
|
/// 菜单详情查询处理器。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class MenuDetailQueryHandler(
|
public sealed class MenuDetailQueryHandler(IMenuRepository menuRepository)
|
||||||
IMenuRepository menuRepository,
|
|
||||||
ITenantProvider tenantProvider)
|
|
||||||
: IRequestHandler<MenuDetailQuery, MenuDefinitionDto?>
|
: IRequestHandler<MenuDetailQuery, MenuDefinitionDto?>
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<MenuDefinitionDto?> Handle(MenuDetailQuery request, CancellationToken cancellationToken)
|
public async Task<MenuDefinitionDto?> Handle(MenuDetailQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// 1. 获取租户
|
// 1. 查询实体
|
||||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
var entity = await menuRepository.FindByIdAsync(request.Id, cancellationToken);
|
||||||
|
|
||||||
// 2. 查询实体
|
|
||||||
var entity = await menuRepository.FindByIdAsync(request.Id, tenantId, cancellationToken);
|
|
||||||
if (entity is null)
|
if (entity is null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 映射并返回
|
// 2. 映射并返回
|
||||||
return MenuMapper.ToDto(entity);
|
return MenuMapper.ToDto(entity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using TakeoutSaaS.Application.Identity.Contracts;
|
using TakeoutSaaS.Application.Identity.Contracts;
|
||||||
using TakeoutSaaS.Domain.Identity.Entities;
|
using TakeoutSaaS.Domain.Identity.Entities;
|
||||||
|
using TakeoutSaaS.Domain.Identity.Enums;
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||||
|
|
||||||
@@ -79,23 +80,22 @@ internal static class MenuMapper
|
|||||||
/// 构建或更新菜单实体并返回 DTO。
|
/// 构建或更新菜单实体并返回 DTO。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="existing">已存在的菜单实体。</param>
|
/// <param name="existing">已存在的菜单实体。</param>
|
||||||
/// <param name="tenantId">租户 ID。</param>
|
/// <param name="portal">门户类型。</param>
|
||||||
/// <param name="name">菜单名称。</param>
|
|
||||||
/// <param name="payload">菜单 DTO 载荷。</param>
|
/// <param name="payload">菜单 DTO 载荷。</param>
|
||||||
/// <returns>菜单定义 DTO。</returns>
|
/// <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. 构造实体
|
// 1. 构造实体
|
||||||
var entity = existing ?? new MenuDefinition
|
var entity = existing ?? new MenuDefinition
|
||||||
{
|
{
|
||||||
TenantId = tenantId,
|
Portal = portal,
|
||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
|
|
||||||
// // 填充字段
|
// 2. 填充字段
|
||||||
FillEntity(entity, payload);
|
FillEntity(entity, payload);
|
||||||
|
|
||||||
// 2. 返回 DTO 映射
|
// 3. 返回 DTO 映射
|
||||||
return ToDto(entity);
|
return ToDto(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ public sealed class PermissionTreeQueryHandler(
|
|||||||
x => new PermissionTreeDto
|
x => new PermissionTreeDto
|
||||||
{
|
{
|
||||||
Id = x.Id,
|
Id = x.Id,
|
||||||
TenantId = x.TenantId,
|
|
||||||
ParentId = x.ParentId,
|
ParentId = x.ParentId,
|
||||||
SortOrder = x.SortOrder,
|
SortOrder = x.SortOrder,
|
||||||
Type = x.Type,
|
Type = x.Type,
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ public sealed class RoleDetailQueryHandler(
|
|||||||
.Select(x => new PermissionDto
|
.Select(x => new PermissionDto
|
||||||
{
|
{
|
||||||
Id = x.Id,
|
Id = x.Id,
|
||||||
TenantId = x.TenantId,
|
|
||||||
ParentId = x.ParentId,
|
ParentId = x.ParentId,
|
||||||
SortOrder = x.SortOrder,
|
SortOrder = x.SortOrder,
|
||||||
Type = x.Type,
|
Type = x.Type,
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ public sealed class SearchPermissionsQueryHandler(
|
|||||||
var items = paged.Select(permission => new PermissionDto
|
var items = paged.Select(permission => new PermissionDto
|
||||||
{
|
{
|
||||||
Id = permission.Id,
|
Id = permission.Id,
|
||||||
TenantId = permission.TenantId,
|
|
||||||
ParentId = permission.ParentId,
|
ParentId = permission.ParentId,
|
||||||
SortOrder = permission.SortOrder,
|
SortOrder = permission.SortOrder,
|
||||||
Type = permission.Type,
|
Type = permission.Type,
|
||||||
|
|||||||
@@ -5,16 +5,13 @@ using TakeoutSaaS.Application.Identity.Contracts;
|
|||||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 更新菜单处理器。
|
/// 更新菜单处理器。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class UpdateMenuCommandHandler(
|
public sealed class UpdateMenuCommandHandler(IMenuRepository menuRepository)
|
||||||
IMenuRepository menuRepository,
|
|
||||||
ITenantProvider tenantProvider)
|
|
||||||
: IRequestHandler<UpdateMenuCommand, MenuDefinitionDto?>
|
: IRequestHandler<UpdateMenuCommand, MenuDefinitionDto?>
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -27,8 +24,7 @@ public sealed class UpdateMenuCommandHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. 校验存在
|
// 2. 校验存在
|
||||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
var entity = await menuRepository.FindByIdAsync(request.Id, cancellationToken)
|
||||||
var entity = await menuRepository.FindByIdAsync(request.Id, tenantId, cancellationToken)
|
|
||||||
?? throw new BusinessException(ErrorCodes.NotFound, "菜单不存在");
|
?? throw new BusinessException(ErrorCodes.NotFound, "菜单不存在");
|
||||||
|
|
||||||
// 3. 更新字段
|
// 3. 更新字段
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ public sealed class UpdatePermissionCommandHandler(
|
|||||||
return new PermissionDto
|
return new PermissionDto
|
||||||
{
|
{
|
||||||
Id = permission.Id,
|
Id = permission.Id,
|
||||||
TenantId = permission.TenantId,
|
|
||||||
ParentId = permission.ParentId,
|
ParentId = permission.ParentId,
|
||||||
SortOrder = permission.SortOrder,
|
SortOrder = permission.SortOrder,
|
||||||
Type = permission.Type,
|
Type = permission.Type,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ using TakeoutSaaS.Application.Identity.Contracts;
|
|||||||
using TakeoutSaaS.Domain.Identity.Entities;
|
using TakeoutSaaS.Domain.Identity.Entities;
|
||||||
using TakeoutSaaS.Domain.Identity.Enums;
|
using TakeoutSaaS.Domain.Identity.Enums;
|
||||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||||
@@ -25,9 +24,7 @@ public sealed class AdminAuthService(
|
|||||||
IPasswordHasher<IdentityUser> passwordHasher,
|
IPasswordHasher<IdentityUser> passwordHasher,
|
||||||
IJwtTokenService jwtTokenService,
|
IJwtTokenService jwtTokenService,
|
||||||
IRefreshTokenStore refreshTokenStore,
|
IRefreshTokenStore refreshTokenStore,
|
||||||
ITenantProvider tenantProvider,
|
ITenantProvider tenantProvider) : IAdminAuthService
|
||||||
ITenantContextAccessor tenantContextAccessor,
|
|
||||||
ITenantRepository tenantRepository) : IAdminAuthService
|
|
||||||
{
|
{
|
||||||
private const string TenantAdminRoleCode = "tenant-admin";
|
private const string TenantAdminRoleCode = "tenant-admin";
|
||||||
|
|
||||||
@@ -89,60 +86,6 @@ public sealed class AdminAuthService(
|
|||||||
return await jwtTokenService.CreateTokensAsync(profile, false, cancellationToken);
|
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>
|
||||||
/// 刷新访问令牌:使用刷新令牌获取新的访问令牌和刷新令牌。
|
/// 刷新访问令牌:使用刷新令牌获取新的访问令牌和刷新令牌。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -199,9 +142,9 @@ public sealed class AdminAuthService(
|
|||||||
{
|
{
|
||||||
// 1. 读取档案以获取权限
|
// 1. 读取档案以获取权限
|
||||||
var profile = await GetProfileAsync(userId, cancellationToken);
|
var profile = await GetProfileAsync(userId, cancellationToken);
|
||||||
// 2. 读取菜单定义
|
|
||||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
// 2. 读取租户端菜单定义
|
||||||
var definitions = await menuRepository.GetByTenantAsync(tenantId, cancellationToken);
|
var definitions = await menuRepository.GetByPortalAsync(PortalType.Tenant, cancellationToken);
|
||||||
|
|
||||||
// 3. 生成菜单树
|
// 3. 生成菜单树
|
||||||
var menu = BuildMenuTree(definitions, profile.Permissions);
|
var menu = BuildMenuTree(definitions, profile.Permissions);
|
||||||
@@ -398,35 +341,6 @@ public sealed class AdminAuthService(
|
|||||||
await userRepository.SaveChangesAsync(cancellationToken);
|
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(
|
private static IReadOnlyList<MenuNodeDto> BuildMenuTree(
|
||||||
IReadOnlyList<Domain.Identity.Entities.MenuDefinition> definitions,
|
IReadOnlyList<Domain.Identity.Entities.MenuDefinition> definitions,
|
||||||
IReadOnlyList<string> permissions)
|
IReadOnlyList<string> permissions)
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
|
using TakeoutSaaS.Domain.Identity.Enums;
|
||||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||||
|
|
||||||
namespace TakeoutSaaS.Domain.Identity.Entities;
|
namespace TakeoutSaaS.Domain.Identity.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 管理端菜单定义。
|
/// 管理端菜单定义(系统级数据,不按租户隔离)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class MenuDefinition : MultiTenantEntityBase
|
public sealed class MenuDefinition : AuditableEntityBase
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门户类型(Admin=平台端,Tenant=租户端)。
|
||||||
|
/// </summary>
|
||||||
|
public PortalType Portal { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 父级菜单 ID,根节点为 0。
|
/// 父级菜单 ID,根节点为 0。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
|
using TakeoutSaaS.Domain.Identity.Enums;
|
||||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||||
|
|
||||||
namespace TakeoutSaaS.Domain.Identity.Entities;
|
namespace TakeoutSaaS.Domain.Identity.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 权限定义。
|
/// 权限定义(系统级数据,不按租户隔离)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class Permission : MultiTenantEntityBase
|
public sealed class Permission : AuditableEntityBase
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 父级权限 ID,根节点为 0。
|
/// 父级权限 ID,根节点为 0。
|
||||||
@@ -36,4 +37,9 @@ public sealed class Permission : MultiTenantEntityBase
|
|||||||
/// 描述。
|
/// 描述。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 门户类型(Admin=平台端,Tenant=租户端)。
|
||||||
|
/// </summary>
|
||||||
|
public PortalType Portal { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
17
src/Domain/TakeoutSaaS.Domain/Identity/Enums/PortalType.cs
Normal file
17
src/Domain/TakeoutSaaS.Domain/Identity/Enums/PortalType.cs
Normal 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
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using TakeoutSaaS.Domain.Identity.Entities;
|
using TakeoutSaaS.Domain.Identity.Entities;
|
||||||
|
using TakeoutSaaS.Domain.Identity.Enums;
|
||||||
|
|
||||||
namespace TakeoutSaaS.Domain.Identity.Repositories;
|
namespace TakeoutSaaS.Domain.Identity.Repositories;
|
||||||
|
|
||||||
@@ -8,14 +9,14 @@ namespace TakeoutSaaS.Domain.Identity.Repositories;
|
|||||||
public interface IMenuRepository
|
public interface IMenuRepository
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 按租户获取菜单列表。
|
/// 按门户类型获取菜单列表。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<IReadOnlyList<MenuDefinition>> GetByTenantAsync(long tenantId, CancellationToken cancellationToken = default);
|
Task<IReadOnlyList<MenuDefinition>> GetByPortalAsync(PortalType portal, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 根据 ID 查询菜单。
|
/// 根据 ID 查询菜单。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<MenuDefinition?> FindByIdAsync(long id, long tenantId, CancellationToken cancellationToken = default);
|
Task<MenuDefinition?> FindByIdAsync(long id, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 新增菜单。
|
/// 新增菜单。
|
||||||
@@ -30,7 +31,7 @@ public interface IMenuRepository
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 删除菜单。
|
/// 删除菜单。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task DeleteAsync(long id, long tenantId, CancellationToken cancellationToken = default);
|
Task DeleteAsync(long id, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 持久化变更。
|
/// 持久化变更。
|
||||||
|
|||||||
@@ -84,6 +84,14 @@ public interface ITenantRepository
|
|||||||
/// <returns>存在返回 true,否则 false。</returns>
|
/// <returns>存在返回 true,否则 false。</returns>
|
||||||
Task<bool> ExistsByCodeAsync(string code, CancellationToken cancellationToken = default);
|
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>
|
||||||
/// 判断租户名称是否存在(支持排除指定租户)。
|
/// 判断租户名称是否存在(支持排除指定租户)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -162,6 +162,20 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
|
|||||||
return context.Tenants.AnyAsync(x => x.Code == normalized, cancellationToken);
|
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 />
|
/// <inheritdoc />
|
||||||
public Task<bool> ExistsByNameAsync(string name, long? excludeTenantId = null, CancellationToken cancellationToken = default)
|
public Task<bool> ExistsByNameAsync(string name, long? excludeTenantId = null, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,36 +7,41 @@ namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// EF 权限仓储。
|
/// EF 权限仓储。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 权限是系统级数据,使用 IgnoreQueryFilters 忽略多租户过滤。
|
||||||
|
/// </remarks>
|
||||||
public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermissionRepository
|
public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermissionRepository
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 根据权限 ID 获取权限。
|
/// 根据权限 ID 获取权限。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="permissionId">权限 ID。</param>
|
/// <param name="permissionId">权限 ID。</param>
|
||||||
/// <param name="tenantId">租户 ID。</param>
|
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
/// <returns>权限实体或 null。</returns>
|
/// <returns>权限实体或 null。</returns>
|
||||||
public Task<Permission?> FindByIdAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default)
|
public Task<Permission?> FindByIdAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default)
|
||||||
=> dbContext.Permissions
|
=> dbContext.Permissions
|
||||||
|
.IgnoreQueryFilters()
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(x => x.Id == permissionId && x.TenantId == tenantId && x.DeletedAt == null, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == permissionId && x.DeletedAt == null, cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 根据权限编码获取权限。
|
/// 根据权限编码获取权限。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="code">权限编码。</param>
|
/// <param name="code">权限编码。</param>
|
||||||
/// <param name="tenantId">租户 ID。</param>
|
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
/// <returns>权限实体或 null。</returns>
|
/// <returns>权限实体或 null。</returns>
|
||||||
public Task<Permission?> FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default)
|
public Task<Permission?> FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default)
|
||||||
=> dbContext.Permissions
|
=> dbContext.Permissions
|
||||||
|
.IgnoreQueryFilters()
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(x => x.Code == code && x.TenantId == tenantId && x.DeletedAt == null, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Code == code && x.DeletedAt == null, cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 根据权限编码集合批量获取权限。
|
/// 根据权限编码集合批量获取权限。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="tenantId">租户 ID。</param>
|
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
|
||||||
/// <param name="codes">权限编码集合。</param>
|
/// <param name="codes">权限编码集合。</param>
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
/// <returns>权限列表。</returns>
|
/// <returns>权限列表。</returns>
|
||||||
@@ -49,10 +54,11 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
|
|||||||
.Distinct()
|
.Distinct()
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
// 2. 读取租户权限
|
// 2. 读取权限(忽略租户过滤)
|
||||||
return dbContext.Permissions
|
return dbContext.Permissions
|
||||||
|
.IgnoreQueryFilters()
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && normalizedCodes.Contains(x.Code))
|
.Where(x => x.DeletedAt == null && normalizedCodes.Contains(x.Code))
|
||||||
.ToListAsync(cancellationToken)
|
.ToListAsync(cancellationToken)
|
||||||
.ContinueWith(t => (IReadOnlyList<Permission>)t.Result, cancellationToken);
|
.ContinueWith(t => (IReadOnlyList<Permission>)t.Result, cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -60,30 +66,32 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 根据权限 ID 集合批量获取权限。
|
/// 根据权限 ID 集合批量获取权限。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="tenantId">租户 ID。</param>
|
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
|
||||||
/// <param name="permissionIds">权限 ID 集合。</param>
|
/// <param name="permissionIds">权限 ID 集合。</param>
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
/// <returns>权限列表。</returns>
|
/// <returns>权限列表。</returns>
|
||||||
public Task<IReadOnlyList<Permission>> GetByIdsAsync(long tenantId, IEnumerable<long> permissionIds, CancellationToken cancellationToken = default)
|
public Task<IReadOnlyList<Permission>> GetByIdsAsync(long tenantId, IEnumerable<long> permissionIds, CancellationToken cancellationToken = default)
|
||||||
=> dbContext.Permissions
|
=> dbContext.Permissions
|
||||||
|
.IgnoreQueryFilters()
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && permissionIds.Contains(x.Id))
|
.Where(x => x.DeletedAt == null && permissionIds.Contains(x.Id))
|
||||||
.ToListAsync(cancellationToken)
|
.ToListAsync(cancellationToken)
|
||||||
.ContinueWith(t => (IReadOnlyList<Permission>)t.Result, cancellationToken);
|
.ContinueWith(t => (IReadOnlyList<Permission>)t.Result, cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 按关键字搜索权限。
|
/// 按关键字搜索权限。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="tenantId">租户 ID。</param>
|
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
|
||||||
/// <param name="keyword">搜索关键字。</param>
|
/// <param name="keyword">搜索关键字。</param>
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
/// <returns>权限列表。</returns>
|
/// <returns>权限列表。</returns>
|
||||||
public Task<IReadOnlyList<Permission>> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default)
|
public Task<IReadOnlyList<Permission>> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// 1. 构建基础查询
|
// 1. 构建基础查询(忽略租户过滤)
|
||||||
var query = dbContext.Permissions
|
var query = dbContext.Permissions
|
||||||
|
.IgnoreQueryFilters()
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(x => x.TenantId == tenantId && x.DeletedAt == null);
|
.Where(x => x.DeletedAt == null);
|
||||||
if (!string.IsNullOrWhiteSpace(keyword))
|
if (!string.IsNullOrWhiteSpace(keyword))
|
||||||
{
|
{
|
||||||
// 2. 追加关键字过滤
|
// 2. 追加关键字过滤
|
||||||
@@ -128,13 +136,15 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
|
|||||||
/// 删除指定权限。
|
/// 删除指定权限。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="permissionId">权限 ID。</param>
|
/// <param name="permissionId">权限 ID。</param>
|
||||||
/// <param name="tenantId">租户 ID。</param>
|
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
/// <returns>异步任务。</returns>
|
/// <returns>异步任务。</returns>
|
||||||
public async Task DeleteAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default)
|
public async Task DeleteAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// 1. 查询目标权限
|
// 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)
|
if (entity != null)
|
||||||
{
|
{
|
||||||
// 2. 删除实体
|
// 2. 删除实体
|
||||||
|
|||||||
@@ -160,7 +160,6 @@ public sealed class IdentityDbContext(
|
|||||||
{
|
{
|
||||||
builder.ToTable("permissions");
|
builder.ToTable("permissions");
|
||||||
builder.HasKey(x => x.Id);
|
builder.HasKey(x => x.Id);
|
||||||
builder.Property(x => x.TenantId).IsRequired();
|
|
||||||
builder.Property(x => x.ParentId).IsRequired();
|
builder.Property(x => x.ParentId).IsRequired();
|
||||||
builder.Property(x => x.SortOrder).IsRequired();
|
builder.Property(x => x.SortOrder).IsRequired();
|
||||||
builder.Property(x => x.Type).HasMaxLength(16).IsRequired();
|
builder.Property(x => x.Type).HasMaxLength(16).IsRequired();
|
||||||
@@ -169,9 +168,8 @@ public sealed class IdentityDbContext(
|
|||||||
builder.Property(x => x.Description).HasMaxLength(256);
|
builder.Property(x => x.Description).HasMaxLength(256);
|
||||||
ConfigureAuditableEntity(builder);
|
ConfigureAuditableEntity(builder);
|
||||||
ConfigureSoftDeleteEntity(builder);
|
ConfigureSoftDeleteEntity(builder);
|
||||||
builder.HasIndex(x => x.TenantId);
|
builder.HasIndex(x => x.Code).IsUnique();
|
||||||
builder.HasIndex(x => new { x.TenantId, x.ParentId, x.SortOrder });
|
builder.HasIndex(x => new { x.Portal, x.ParentId, x.SortOrder });
|
||||||
builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ConfigureRoleTemplate(EntityTypeBuilder<RoleTemplate> builder)
|
private static void ConfigureRoleTemplate(EntityTypeBuilder<RoleTemplate> builder)
|
||||||
@@ -226,7 +224,7 @@ public sealed class IdentityDbContext(
|
|||||||
{
|
{
|
||||||
builder.ToTable("menu_definitions");
|
builder.ToTable("menu_definitions");
|
||||||
builder.HasKey(x => x.Id);
|
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.ParentId).IsRequired();
|
||||||
builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
|
builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
|
||||||
builder.Property(x => x.Path).HasMaxLength(256).IsRequired();
|
builder.Property(x => x.Path).HasMaxLength(256).IsRequired();
|
||||||
@@ -241,6 +239,6 @@ public sealed class IdentityDbContext(
|
|||||||
builder.Property(x => x.AuthListJson).HasColumnType("text");
|
builder.Property(x => x.AuthListJson).HasColumnType("text");
|
||||||
ConfigureAuditableEntity(builder);
|
ConfigureAuditableEntity(builder);
|
||||||
ConfigureSoftDeleteEntity(builder);
|
ConfigureSoftDeleteEntity(builder);
|
||||||
builder.HasIndex(x => new { x.TenantId, x.ParentId, x.SortOrder });
|
builder.HasIndex(x => new { x.Portal, x.ParentId, x.SortOrder });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +1,39 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TakeoutSaaS.Domain.Identity.Entities;
|
using TakeoutSaaS.Domain.Identity.Entities;
|
||||||
|
using TakeoutSaaS.Domain.Identity.Enums;
|
||||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||||
using TakeoutSaaS.Infrastructure.Identity.Persistence;
|
using TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Infrastructure.Identity.Repositories;
|
namespace TakeoutSaaS.Infrastructure.Identity.Repositories;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 菜单仓储 EF 实现。
|
/// 菜单仓储 EF 实现。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class EfMenuRepository(IdentityDbContext dbContext, ITenantContextAccessor tenantContextAccessor) : IMenuRepository
|
public sealed class EfMenuRepository(IdentityDbContext dbContext) : IMenuRepository
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<MenuDefinition>> GetByTenantAsync(long tenantId, CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<MenuDefinition>> GetByPortalAsync(PortalType portal, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// 1. 优先返回租户自定义菜单
|
// 1. 按门户类型查询菜单(忽略租户过滤器)
|
||||||
var tenantMenus = await dbContext.MenuDefinitions
|
var menus = await dbContext.MenuDefinitions
|
||||||
|
.IgnoreQueryFilters()
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(x => x.TenantId == tenantId)
|
.Where(x => x.Portal == portal && x.DeletedAt == null)
|
||||||
.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)
|
|
||||||
.OrderBy(x => x.ParentId)
|
.OrderBy(x => x.ParentId)
|
||||||
.ThenBy(x => x.SortOrder)
|
.ThenBy(x => x.SortOrder)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
return systemMenus;
|
return menus;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<MenuDefinition?> FindByIdAsync(long id, long tenantId, CancellationToken cancellationToken = default)
|
public async Task<MenuDefinition?> FindByIdAsync(long id, 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"))
|
|
||||||
{
|
{
|
||||||
|
// 1. 按 ID 查询菜单(忽略租户过滤器)
|
||||||
return await dbContext.MenuDefinitions
|
return await dbContext.MenuDefinitions
|
||||||
|
.IgnoreQueryFilters()
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(x => x.Id == id && x.TenantId == 0 && x.DeletedAt == null, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == id && x.DeletedAt == null, cancellationToken);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -77,11 +50,12 @@ public sealed class EfMenuRepository(IdentityDbContext dbContext, ITenantContext
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <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
|
var entity = await dbContext.MenuDefinitions
|
||||||
.FirstOrDefaultAsync(x => x.Id == id && x.TenantId == tenantId, cancellationToken);
|
.IgnoreQueryFilters()
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
|
||||||
// 2. 存在则删除
|
// 2. 存在则删除
|
||||||
if (entity is not null)
|
if (entity is not null)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using MassTransit;
|
using MassTransit;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||||
using TakeoutSaaS.Application.Identity.Events;
|
using TakeoutSaaS.Application.Identity.Events;
|
||||||
|
|
||||||
@@ -7,9 +8,20 @@ namespace TakeoutSaaS.Infrastructure.Logs.Publishers;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 身份模块操作日志发布器(基于 MassTransit Outbox)。
|
/// 身份模块操作日志发布器(基于 MassTransit Outbox)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class IdentityOperationLogPublisher(IPublishEndpoint publishEndpoint) : IIdentityOperationLogPublisher
|
public sealed class IdentityOperationLogPublisher(
|
||||||
|
ILogger<IdentityOperationLogPublisher> logger,
|
||||||
|
IPublishEndpoint? publishEndpoint = null) : IIdentityOperationLogPublisher
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task PublishAsync(IdentityUserOperationLogMessage message, CancellationToken cancellationToken = default)
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,17 @@ public static class MessagingServiceCollectionExtensions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static IServiceCollection AddMessagingModule(this IServiceCollection services, IConfiguration configuration)
|
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>()
|
services.AddOptions<RabbitMqOptions>()
|
||||||
.Bind(configuration.GetSection("RabbitMQ"))
|
.Bind(rabbitMqSection)
|
||||||
.ValidateDataAnnotations()
|
.ValidateDataAnnotations()
|
||||||
.ValidateOnStart();
|
.ValidateOnStart();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ public static class TenantServiceCollectionExtensions
|
|||||||
{
|
{
|
||||||
services.TryAddSingleton<ITenantContextAccessor, TenantContextAccessor>();
|
services.TryAddSingleton<ITenantContextAccessor, TenantContextAccessor>();
|
||||||
services.TryAddScoped<ITenantProvider, TenantProvider>();
|
services.TryAddScoped<ITenantProvider, TenantProvider>();
|
||||||
|
services.TryAddScoped<ITenantCodeResolver, DatabaseTenantCodeResolver>();
|
||||||
|
|
||||||
services.AddOptions<TenantResolutionOptions>()
|
services.AddOptions<TenantResolutionOptions>()
|
||||||
.Bind(configuration.GetSection("Tenancy"))
|
.Bind(configuration.GetSection("Tenancy"))
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\..\TakeoutSaaS.BuildingBlocks\src\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />
|
<ProjectReference Include="..\..\..\TakeoutSaaS.BuildingBlocks\src\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Domain\TakeoutSaaS.Domain\TakeoutSaaS.Domain.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public sealed class TenantResolutionMiddleware(
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 解析租户并将上下文注入请求。
|
/// 解析租户并将上下文注入请求。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task InvokeAsync(HttpContext context)
|
public async Task InvokeAsync(HttpContext context, ITenantCodeResolver tenantCodeResolver)
|
||||||
{
|
{
|
||||||
var options = optionsMonitor.CurrentValue ?? new TenantResolutionOptions();
|
var options = optionsMonitor.CurrentValue ?? new TenantResolutionOptions();
|
||||||
if (ShouldSkip(context.Request.Path, options))
|
if (ShouldSkip(context.Request.Path, options))
|
||||||
@@ -31,7 +31,7 @@ public sealed class TenantResolutionMiddleware(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var tenantContext = ResolveTenant(context, options);
|
var tenantContext = await ResolveTenantAsync(context, options, tenantCodeResolver);
|
||||||
tenantContextAccessor.Current = tenantContext;
|
tenantContextAccessor.Current = tenantContext;
|
||||||
context.Items[TenantConstants.HttpContextItemKey] = 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 request = context.Request;
|
||||||
var isAuthenticated = context.User?.Identity?.IsAuthenticated == true;
|
var isAuthenticated = context.User?.Identity?.IsAuthenticated == true;
|
||||||
@@ -117,9 +120,10 @@ public sealed class TenantResolutionMiddleware(
|
|||||||
request.Headers.TryGetValue(options.TenantCodeHeaderName, out var codeHeader))
|
request.Headers.TryGetValue(options.TenantCodeHeaderName, out var codeHeader))
|
||||||
{
|
{
|
||||||
var code = codeHeader.FirstOrDefault();
|
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;
|
var host = request.Host.Host;
|
||||||
if (!string.IsNullOrWhiteSpace(host))
|
if (!string.IsNullOrWhiteSpace(host))
|
||||||
{
|
{
|
||||||
|
// 4.1 精确域名映射
|
||||||
if (options.DomainTenantMap.TryGetValue(host, out var tenantFromHost) && tenantFromHost > 0)
|
if (options.DomainTenantMap.TryGetValue(host, out var tenantFromHost) && tenantFromHost > 0)
|
||||||
{
|
{
|
||||||
return new TenantContext(tenantFromHost, null, $"host:{host}");
|
return new TenantContext(tenantFromHost, null, $"host:{host}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4.2 子域名解析
|
||||||
var codeFromHost = ResolveCodeFromHost(host, options.RootDomain);
|
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;
|
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))
|
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)
|
private static string? ResolveCodeFromHost(string host, string? rootDomain)
|
||||||
|
|||||||
Reference in New Issue
Block a user