From 24e54886a17504b98071a13fa429142d2ea523eb Mon Sep 17 00:00:00 2001
From: MSuMshk <2039814060@qq.com>
Date: Wed, 3 Dec 2025 15:03:53 +0800
Subject: [PATCH 01/30] =?UTF-8?q?fix:=20=E9=80=82=E9=85=8DYARP=E8=B7=AF?=
=?UTF-8?q?=E7=94=B1=E9=85=8D=E7=BD=AE=E6=A0=BC=E5=BC=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../appsettings.Development.json | 13 +++++--------
src/Gateway/TakeoutSaaS.ApiGateway/appsettings.json | 13 +++++--------
2 files changed, 10 insertions(+), 16 deletions(-)
diff --git a/src/Gateway/TakeoutSaaS.ApiGateway/appsettings.Development.json b/src/Gateway/TakeoutSaaS.ApiGateway/appsettings.Development.json
index 6c70a10..0508d91 100644
--- a/src/Gateway/TakeoutSaaS.ApiGateway/appsettings.Development.json
+++ b/src/Gateway/TakeoutSaaS.ApiGateway/appsettings.Development.json
@@ -3,23 +3,20 @@
"Enabled": false
},
"ReverseProxy": {
- "Routes": [
- {
- "RouteId": "admin-route",
+ "Routes": {
+ "admin-route": {
"ClusterId": "admin",
"Match": { "Path": "/api/admin/{**catch-all}" }
},
- {
- "RouteId": "mini-route",
+ "mini-route": {
"ClusterId": "mini",
"Match": { "Path": "/api/mini/{**catch-all}" }
},
- {
- "RouteId": "user-route",
+ "user-route": {
"ClusterId": "user",
"Match": { "Path": "/api/user/{**catch-all}" }
}
- ],
+ },
"Clusters": {
"admin": {
"Destinations": {
diff --git a/src/Gateway/TakeoutSaaS.ApiGateway/appsettings.json b/src/Gateway/TakeoutSaaS.ApiGateway/appsettings.json
index a2f3c3c..4bd7d04 100644
--- a/src/Gateway/TakeoutSaaS.ApiGateway/appsettings.json
+++ b/src/Gateway/TakeoutSaaS.ApiGateway/appsettings.json
@@ -36,23 +36,20 @@
"OtlpEndpoint": "http://localhost:4317"
},
"ReverseProxy": {
- "Routes": [
- {
- "RouteId": "admin-route",
+ "Routes": {
+ "admin-route": {
"ClusterId": "admin",
"Match": { "Path": "/api/admin/{**catch-all}" }
},
- {
- "RouteId": "mini-route",
+ "mini-route": {
"ClusterId": "mini",
"Match": { "Path": "/api/mini/{**catch-all}" }
},
- {
- "RouteId": "user-route",
+ "user-route": {
"ClusterId": "user",
"Match": { "Path": "/api/user/{**catch-all}" }
}
- ],
+ },
"Clusters": {
"admin": {
"Destinations": {
From 681c2b725982d22f9b75cf3f291060c3ca80493d Mon Sep 17 00:00:00 2001
From: MSuMshk <2039814060@qq.com>
Date: Wed, 3 Dec 2025 15:37:46 +0800
Subject: [PATCH 02/30] =?UTF-8?q?docs:=20=E6=B8=85=E7=90=86=E6=97=A7?=
=?UTF-8?q?=E6=B5=81=E6=B0=B4=E7=BA=BF=E5=B9=B6=E6=9B=B4=E6=96=B0todo?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Document/11_SystemTodo.md | 2 +
Document/CI_CD流水线.md | 134 --------------------------------------
2 files changed, 2 insertions(+), 134 deletions(-)
delete mode 100644 Document/CI_CD流水线.md
diff --git a/Document/11_SystemTodo.md b/Document/11_SystemTodo.md
index 9d9adbe..dead828 100644
--- a/Document/11_SystemTodo.md
+++ b/Document/11_SystemTodo.md
@@ -56,6 +56,8 @@
## 8. CI/CD 与发布
- [ ] CI/CD 流水线覆盖构建、发布、静态扫描、数据库迁移。
+ - [x] `.github/workflows/ci-cd.yml` 已覆盖 Admin/Mini/User API 的变更检测、Docker Build/Push 与 SSH 部署。
+ - [ ] 尚未集成静态代码扫描、安全扫描与数据库迁移自动化。
- [ ] Dev/Staging/Prod 多环境配置矩阵 + 基础设施 IaC 脚本。
- [ ] 版本与发布说明模板整理并在仓库中提供示例。
diff --git a/Document/CI_CD流水线.md b/Document/CI_CD流水线.md
deleted file mode 100644
index 6be352f..0000000
--- a/Document/CI_CD流水线.md
+++ /dev/null
@@ -1,134 +0,0 @@
-# CI/CD 流水线(云效,dev 合并 master 触发)
-
-## 触发规则
-- 分支触发:仅 `master`。
-- 校验来源:流水线脚本内检查 `GIT_BRANCH == master` 且 `GIT_PREVIOUS_BRANCH == dev`,否则退出。
-
-## 必填变量(云效“变量/密钥”)
-- 字符变量:
- - `REGISTRY=crpi-z1i5bludyfuvzo9o.cn-beijing.personal.cr.aliyuncs.com`
- - `REGISTRY_USERNAME=heaize404@163.com`
- - `DEPLOY_HOST=49.7.179.246`
- - `DEPLOY_USER=root`
-- 密钥/凭据:
- - `REGISTRY_PASSWORD=MsuMshk112233`
- - `DEPLOY_PASSWORD=7zE&84XI6~w57W7N`
-- 默认基线:`BASE_REF=origin/master`(可不配)。
-
-## Docker 端口约定
-- Admin:7801
-- Mini:7701
-- User:7901
-
-## 完整流水线 YAML
-```yaml
-version: 1.0
-name: takeoutsaas-ci-cd
-displayName: TakeoutSaaS CI/CD
-triggers:
- push:
- branches:
- include:
- - master
-
-stages:
- - stage: DetectChanges
- name: DetectChanges
- steps:
- - step: Checkout
- name: Checkout
- checkout: self
- - step: Detect
- name: Detect
- script: |
- set -e
- if [ "$GIT_BRANCH" != "master" ] || [ "$GIT_PREVIOUS_BRANCH" != "dev" ]; then
- echo "非 dev->master,跳过流水线"; exit 0; fi
-
- git fetch origin master --depth=1
- BASE=${BASE_REF:-origin/master}
- CHANGED=$(git diff --name-only "$(git merge-base $BASE HEAD)" HEAD)
- 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
- hit '^Directory.Build.props$' && deploy_all=true
-
- hit '^src/Api/TakeoutSaaS.AdminApi/' && services+=("admin-api")
- hit '^src/Api/TakeoutSaaS.MiniApi/' && services+=("mini-api")
- hit '^src/Api/TakeoutSaaS.UserApi/' && services+=("user-api")
-
- if $deploy_all || [ ${#services[@]} -eq 0 ]; then
- services=("admin-api" "mini-api" "user-api")
- fi
-
- echo "SERVICES=${services[*]}" >> "$ACROSS_STAGES_ENV_FILE"
-
- - stage: BuildPush
- name: BuildPush
- steps:
- - step: DockerBuildPush
- name: DockerBuildPush
- script: |
- set -e
- IFS=' ' read -ra svcs <<< "$SERVICES"
- REGISTRY=${REGISTRY:?需要配置 REGISTRY}
- TAG=${TAG:-$(date +%Y%m%d%H%M%S)}
-
- echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USERNAME" --password-stdin
-
- for svc in "${svcs[@]}"; do
- case "$svc" in
- admin-api) dockerfile="src/Api/TakeoutSaaS.AdminApi/Dockerfile"; image="$REGISTRY/admin-api:$TAG" ;;
- mini-api) dockerfile="src/Api/TakeoutSaaS.MiniApi/Dockerfile"; image="$REGISTRY/mini-api:$TAG" ;;
- user-api) dockerfile="src/Api/TakeoutSaaS.UserApi/Dockerfile"; image="$REGISTRY/user-api:$TAG" ;;
- esac
- echo "构建并推送 $image"
- docker build -f "$dockerfile" -t "$image" .
- docker push "$image"
- done
- echo "IMAGE_TAG=$TAG" >> "$ACROSS_STAGES_ENV_FILE"
-
- - stage: Deploy
- name: Deploy
- steps:
- - step: DockerDeploy
- name: DockerDeploy
- script: |
- set -e
- command -v sshpass >/dev/null 2>&1 || (sudo apt-get update && sudo apt-get install -y sshpass)
-
- IFS=' ' read -ra svcs <<< "$SERVICES"
- TAG="$IMAGE_TAG"
- REGISTRY=${REGISTRY:?}
- DEPLOY_HOST=${DEPLOY_HOST:?}
- DEPLOY_USER=${DEPLOY_USER:-root}
- DEPLOY_PASSWORD=${DEPLOY_PASSWORD:?}
-
- for svc in "${svcs[@]}"; do
- case "$svc" in
- admin-api) image="$REGISTRY/admin-api:$TAG"; port=7801 ;;
- mini-api) image="$REGISTRY/mini-api:$TAG"; port=7701 ;;
- user-api) image="$REGISTRY/user-api:$TAG"; port=7901 ;;
- esac
-
- echo "部署 $svc -> $image"
- sshpass -p "$DEPLOY_PASSWORD" ssh -o StrictHostKeyChecking=no "$DEPLOY_USER@$DEPLOY_HOST" "set -e; docker pull $image; docker stop $svc 2>/dev/null || true; docker rm $svc 2>/dev/null || true; docker run -d --name $svc --restart=always -p $port:$port $image"
- done
-```
-
-## 注意事项
-- 以上 YAML 如仍报 YAML 校验错误,可将 `triggers` 改为:
- ```yaml
- on:
- push:
- branches:
- - master
- ```
- 其余保持不变。
-- 如果云效的分支变量名与 `GIT_BRANCH` / `GIT_PREVIOUS_BRANCH` 不同,请在 Detect 步骤替换为实际变量名。
-- 所有密码、密钥务必放在“密钥/凭据”类型变量中,不要写入代码库。
From 151f64d41ae95cd1736e8020c41f0fcbeb25476e Mon Sep 17 00:00:00 2001
From: MSuMshk <2039814060@qq.com>
Date: Wed, 3 Dec 2025 16:35:21 +0800
Subject: [PATCH 03/30] =?UTF-8?q?feat:=20=E7=99=BB=E8=AE=B0=E5=89=A9?=
=?UTF-8?q?=E4=BD=99=E6=A8=A1=E5=9D=97=E9=A1=B9=E7=9B=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
TakeoutSaaS.sln | 75 +++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 75 insertions(+)
diff --git a/TakeoutSaaS.sln b/TakeoutSaaS.sln
index d924b98..c7a3bd6 100644
--- a/TakeoutSaaS.sln
+++ b/TakeoutSaaS.sln
@@ -45,6 +45,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Shared.Kernel",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Storage", "src\Modules\TakeoutSaaS.Module.Storage\TakeoutSaaS.Module.Storage.csproj", "{05058F44-6FB7-43AF-8648-8BF538E283EF}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Delivery", "src\Modules\TakeoutSaaS.Module.Delivery\TakeoutSaaS.Module.Delivery.csproj", "{5C12177E-6C25-4F78-BFD4-AA073CFC0650}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Dictionary", "src\Modules\TakeoutSaaS.Module.Dictionary\TakeoutSaaS.Module.Dictionary.csproj", "{5ADA37B6-09A0-48F7-8633-C266FD5BBFD1}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Messaging", "src\Modules\TakeoutSaaS.Module.Messaging\TakeoutSaaS.Module.Messaging.csproj", "{FE49A9E7-1228-45BA-9B71-337AA353FE98}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Scheduler", "src\Modules\TakeoutSaaS.Module.Scheduler\TakeoutSaaS.Module.Scheduler.csproj", "{9C2F510E-4054-482D-AFD3-D2E374D60304}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Sms", "src\Modules\TakeoutSaaS.Module.Sms\TakeoutSaaS.Module.Sms.csproj", "{38011EC3-7EC3-40E4-B9B2-E631966B350B}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -211,6 +221,66 @@ Global
{05058F44-6FB7-43AF-8648-8BF538E283EF}.Release|x64.Build.0 = Release|Any CPU
{05058F44-6FB7-43AF-8648-8BF538E283EF}.Release|x86.ActiveCfg = Release|Any CPU
{05058F44-6FB7-43AF-8648-8BF538E283EF}.Release|x86.Build.0 = Release|Any CPU
+ {5C12177E-6C25-4F78-BFD4-AA073CFC0650}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5C12177E-6C25-4F78-BFD4-AA073CFC0650}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5C12177E-6C25-4F78-BFD4-AA073CFC0650}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {5C12177E-6C25-4F78-BFD4-AA073CFC0650}.Debug|x64.Build.0 = Debug|Any CPU
+ {5C12177E-6C25-4F78-BFD4-AA073CFC0650}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {5C12177E-6C25-4F78-BFD4-AA073CFC0650}.Debug|x86.Build.0 = Debug|Any CPU
+ {5C12177E-6C25-4F78-BFD4-AA073CFC0650}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5C12177E-6C25-4F78-BFD4-AA073CFC0650}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5C12177E-6C25-4F78-BFD4-AA073CFC0650}.Release|x64.ActiveCfg = Release|Any CPU
+ {5C12177E-6C25-4F78-BFD4-AA073CFC0650}.Release|x64.Build.0 = Release|Any CPU
+ {5C12177E-6C25-4F78-BFD4-AA073CFC0650}.Release|x86.ActiveCfg = Release|Any CPU
+ {5C12177E-6C25-4F78-BFD4-AA073CFC0650}.Release|x86.Build.0 = Release|Any CPU
+ {5ADA37B6-09A0-48F7-8633-C266FD5BBFD1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5ADA37B6-09A0-48F7-8633-C266FD5BBFD1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5ADA37B6-09A0-48F7-8633-C266FD5BBFD1}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {5ADA37B6-09A0-48F7-8633-C266FD5BBFD1}.Debug|x64.Build.0 = Debug|Any CPU
+ {5ADA37B6-09A0-48F7-8633-C266FD5BBFD1}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {5ADA37B6-09A0-48F7-8633-C266FD5BBFD1}.Debug|x86.Build.0 = Debug|Any CPU
+ {5ADA37B6-09A0-48F7-8633-C266FD5BBFD1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5ADA37B6-09A0-48F7-8633-C266FD5BBFD1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5ADA37B6-09A0-48F7-8633-C266FD5BBFD1}.Release|x64.ActiveCfg = Release|Any CPU
+ {5ADA37B6-09A0-48F7-8633-C266FD5BBFD1}.Release|x64.Build.0 = Release|Any CPU
+ {5ADA37B6-09A0-48F7-8633-C266FD5BBFD1}.Release|x86.ActiveCfg = Release|Any CPU
+ {5ADA37B6-09A0-48F7-8633-C266FD5BBFD1}.Release|x86.Build.0 = Release|Any CPU
+ {FE49A9E7-1228-45BA-9B71-337AA353FE98}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {FE49A9E7-1228-45BA-9B71-337AA353FE98}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {FE49A9E7-1228-45BA-9B71-337AA353FE98}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {FE49A9E7-1228-45BA-9B71-337AA353FE98}.Debug|x64.Build.0 = Debug|Any CPU
+ {FE49A9E7-1228-45BA-9B71-337AA353FE98}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {FE49A9E7-1228-45BA-9B71-337AA353FE98}.Debug|x86.Build.0 = Debug|Any CPU
+ {FE49A9E7-1228-45BA-9B71-337AA353FE98}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {FE49A9E7-1228-45BA-9B71-337AA353FE98}.Release|Any CPU.Build.0 = Release|Any CPU
+ {FE49A9E7-1228-45BA-9B71-337AA353FE98}.Release|x64.ActiveCfg = Release|Any CPU
+ {FE49A9E7-1228-45BA-9B71-337AA353FE98}.Release|x64.Build.0 = Release|Any CPU
+ {FE49A9E7-1228-45BA-9B71-337AA353FE98}.Release|x86.ActiveCfg = Release|Any CPU
+ {FE49A9E7-1228-45BA-9B71-337AA353FE98}.Release|x86.Build.0 = Release|Any CPU
+ {9C2F510E-4054-482D-AFD3-D2E374D60304}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9C2F510E-4054-482D-AFD3-D2E374D60304}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9C2F510E-4054-482D-AFD3-D2E374D60304}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {9C2F510E-4054-482D-AFD3-D2E374D60304}.Debug|x64.Build.0 = Debug|Any CPU
+ {9C2F510E-4054-482D-AFD3-D2E374D60304}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {9C2F510E-4054-482D-AFD3-D2E374D60304}.Debug|x86.Build.0 = Debug|Any CPU
+ {9C2F510E-4054-482D-AFD3-D2E374D60304}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9C2F510E-4054-482D-AFD3-D2E374D60304}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9C2F510E-4054-482D-AFD3-D2E374D60304}.Release|x64.ActiveCfg = Release|Any CPU
+ {9C2F510E-4054-482D-AFD3-D2E374D60304}.Release|x64.Build.0 = Release|Any CPU
+ {9C2F510E-4054-482D-AFD3-D2E374D60304}.Release|x86.ActiveCfg = Release|Any CPU
+ {9C2F510E-4054-482D-AFD3-D2E374D60304}.Release|x86.Build.0 = Release|Any CPU
+ {38011EC3-7EC3-40E4-B9B2-E631966B350B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {38011EC3-7EC3-40E4-B9B2-E631966B350B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {38011EC3-7EC3-40E4-B9B2-E631966B350B}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {38011EC3-7EC3-40E4-B9B2-E631966B350B}.Debug|x64.Build.0 = Debug|Any CPU
+ {38011EC3-7EC3-40E4-B9B2-E631966B350B}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {38011EC3-7EC3-40E4-B9B2-E631966B350B}.Debug|x86.Build.0 = Debug|Any CPU
+ {38011EC3-7EC3-40E4-B9B2-E631966B350B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {38011EC3-7EC3-40E4-B9B2-E631966B350B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {38011EC3-7EC3-40E4-B9B2-E631966B350B}.Release|x64.ActiveCfg = Release|Any CPU
+ {38011EC3-7EC3-40E4-B9B2-E631966B350B}.Release|x64.Build.0 = Release|Any CPU
+ {38011EC3-7EC3-40E4-B9B2-E631966B350B}.Release|x86.ActiveCfg = Release|Any CPU
+ {38011EC3-7EC3-40E4-B9B2-E631966B350B}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -236,5 +306,10 @@ Global
{A2620200-D487-49A7-ABAF-9B84951F81DD} = {6306A8FB-679E-111F-6585-8F70E0EE6013}
{BBC99B58-ECA8-42C3-9070-9AA058D778D3} = {8D626EA8-CB54-BC41-363A-217881BEBA6E}
{05058F44-6FB7-43AF-8648-8BF538E283EF} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
+ {5C12177E-6C25-4F78-BFD4-AA073CFC0650} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
+ {5ADA37B6-09A0-48F7-8633-C266FD5BBFD1} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
+ {FE49A9E7-1228-45BA-9B71-337AA353FE98} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
+ {9C2F510E-4054-482D-AFD3-D2E374D60304} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
+ {38011EC3-7EC3-40E4-B9B2-E631966B350B} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
EndGlobalSection
EndGlobal
From a536a554c2e2d90bef53ac3a06f9cd8a49e63da1 Mon Sep 17 00:00:00 2001
From: MSuMshk <2039814060@qq.com>
Date: Wed, 3 Dec 2025 16:37:50 +0800
Subject: [PATCH 04/30] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E7=A7=9F?=
=?UTF-8?q?=E6=88=B7=E7=AE=A1=E7=90=86=E5=8F=8A=E5=A5=97=E9=A4=90=E6=B5=81?=
=?UTF-8?q?=E7=A8=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Document/12_BusinessTodo.md | 9 +-
.../Controllers/TenantsController.cs | 137 +++++++++++++++
.../ChangeTenantSubscriptionPlanCommand.cs | 15 ++
.../CreateTenantSubscriptionCommand.cs | 15 ++
.../Tenants/Commands/RegisterTenantCommand.cs | 21 +++
.../Tenants/Commands/ReviewTenantCommand.cs | 13 ++
.../SubmitTenantVerificationCommand.cs | 21 +++
.../App/Tenants/Dto/TenantAuditLogDto.cs | 58 +++++++
.../App/Tenants/Dto/TenantDetailDto.cs | 22 +++
.../App/Tenants/Dto/TenantDto.cs | 78 +++++++++
.../App/Tenants/Dto/TenantSubscriptionDto.cs | 54 ++++++
.../App/Tenants/Dto/TenantVerificationDto.cs | 73 ++++++++
...ngeTenantSubscriptionPlanCommandHandler.cs | 74 ++++++++
.../CreateTenantSubscriptionCommandHandler.cs | 82 +++++++++
.../GetTenantAuditLogsQueryHandler.cs | 32 ++++
.../Handlers/GetTenantByIdQueryHandler.cs | 34 ++++
.../Handlers/RegisterTenantCommandHandler.cs | 88 ++++++++++
.../Handlers/ReviewTenantCommandHandler.cs | 87 ++++++++++
.../Handlers/SearchTenantsQueryHandler.cs | 39 +++++
.../SubmitTenantVerificationCommandHandler.cs | 63 +++++++
.../Queries/GetTenantAuditLogsQuery.cs | 13 ++
.../App/Tenants/Queries/GetTenantByIdQuery.cs | 9 +
.../App/Tenants/Queries/SearchTenantsQuery.cs | 15 ++
.../App/Tenants/TenantMapping.cs | 76 +++++++++
.../Tenants/Entities/TenantAuditLog.cs | 50 ++++++
.../Entities/TenantSubscriptionHistory.cs | 60 +++++++
.../Entities/TenantVerificationProfile.cs | 95 +++++++++++
.../Tenants/Enums/SubscriptionChangeType.cs | 27 +++
.../Tenants/Enums/TenantAuditAction.cs | 42 +++++
.../Tenants/Enums/TenantVerificationStatus.cs | 27 +++
.../Tenants/Repositories/ITenantRepository.cs | 95 +++++++++++
.../AppServiceCollectionExtensions.cs | 2 +
.../App/Persistence/TakeoutAppDbContext.cs | 47 +++++
.../App/Repositories/EfTenantRepository.cs | 161 ++++++++++++++++++
34 files changed, 1732 insertions(+), 2 deletions(-)
create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ChangeTenantSubscriptionPlanCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantSubscriptionCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/RegisterTenantCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ReviewTenantCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/SubmitTenantVerificationCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantAuditLogDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantDetailDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantSubscriptionDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantVerificationDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ChangeTenantSubscriptionPlanCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantSubscriptionCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAuditLogsQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantByIdQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/RegisterTenantCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ReviewTenantCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantsQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SubmitTenantVerificationCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantAuditLogsQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantByIdQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantsQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAuditLog.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantSubscriptionHistory.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantVerificationProfile.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Enums/SubscriptionChangeType.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantAuditAction.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantVerificationStatus.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs
diff --git a/Document/12_BusinessTodo.md b/Document/12_BusinessTodo.md
index bd3da81..ca1a132 100644
--- a/Document/12_BusinessTodo.md
+++ b/Document/12_BusinessTodo.md
@@ -4,11 +4,16 @@
---
## Phase 1(当前阶段):租户/商家入驻、门店与菜品、扫码堂食、基础下单支付、预购自提、第三方配送骨架
-- [ ] 管理端租户 API:注册、实名认证、套餐订阅/续费/升降配、审核流,Swagger ≥6 个端点,含审核日志。
+- [x] 管理端租户 API:注册、实名认证、套餐订阅/续费/升降配、审核流,Swagger ≥6 个端点,含审核日志。
+ - 已交付:`src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs` 暴露注册、详情、实名提交、审核、订阅创建/升降配、审核日志 8 个端点;对应命令/查询位于 `src/Application/TakeoutSaaS.Application/App/Tenants`,仓储实现 `EfTenantRepository`,并写入 `TenantAuditLog` 记录。Swagger 自动收录上述接口,满足 Phase1 租户管理要求。
- [ ] 商家入驻 API:证照上传、合同管理、类目选择,驱动待审/审核/驳回/通过状态机,文件持久在 COS。
+ - 当前:`MerchantsController` 只暴露基础 CRUD(`src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs:21-88`),缺少证照/合同上传、COS 存储与状态机端点。
- [ ] RBAC 模板:平台管理员、租户管理员、店长、店员四角色模板;API 可复制并允许租户自定义扩展。
+ - 当前:`RolesController`/`PermissionsController` 已提供角色与权限 CRUD(`src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs:16-88`、`.../PermissionsController.cs:16-63`),但没有“模板复制”或按租户批量初始化的接口。
- [ ] 配额与套餐:TenantPackage CRUD、订阅/续费/配额校验(门店/账号/短信/配送单量),超额返回 409 并记录 TenantQuotaUsage。
+ - 当前:领域层已有 `TenantPackage`/`TenantSubscription` 等实体(`src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantPackage.cs:5-48`),数据库模型也同步生成,但 Admin API/应用层未暴露任何 CRUD 或配额校验逻辑。
- [ ] 租户运营面板:欠费/到期告警、账单列表、公告通知接口,支持已读状态并在 Admin UI 展示。
+ - 当前:`SystemParametersController` 仅负责普通参数 CRUD(`src/Api/TakeoutSaaS.AdminApi/Controllers/SystemParametersController.cs:15-104`),未包含租户账单、公告或通知接口。
- [ ] 门店管理:Store/StoreBusinessHour/StoreDeliveryZone/StoreHoliday CRUD 完整,含 GeoJSON 配送范围及能力开关。
- [ ] 桌码管理:批量生成桌码、绑定区域/容量、导出二维码 ZIP(POST /api/admin/stores/{id}/tables 可下载)。
- [ ] 员工排班:创建员工、绑定门店角色、维护 StoreEmployeeShift,可查询未来 7 日排班。
@@ -50,4 +55,4 @@
- [ ] 可靠性:幂等与重试策略、任务调度补偿、链路追踪、告警联动。
- [ ] 运营大盘:交易/营销/履约/用户维度的细分报表、GMV/成本/毛利分析。
- [ ] 文档与测试:完整测试矩阵、性能测试报告、上线手册、回滚方案。
-- [ ] 监控与运维:上线发布流程、灰度/回滚策略、系统稳定性指标、24x7 监控与告警。
\ No newline at end of file
+- [ ] 监控与运维:上线发布流程、灰度/回滚策略、系统稳定性指标、24x7 监控与告警。
diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs
new file mode 100644
index 0000000..5fe90bc
--- /dev/null
+++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs
@@ -0,0 +1,137 @@
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using TakeoutSaaS.Application.App.Tenants.Commands;
+using TakeoutSaaS.Application.App.Tenants.Dto;
+using TakeoutSaaS.Application.App.Tenants.Queries;
+using TakeoutSaaS.Module.Authorization.Attributes;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Results;
+using TakeoutSaaS.Shared.Web.Api;
+
+namespace TakeoutSaaS.AdminApi.Controllers;
+
+///
+/// 租户管理。
+///
+[ApiVersion("1.0")]
+[Authorize]
+[Route("api/admin/v{version:apiVersion}/tenants")]
+public sealed class TenantsController(IMediator mediator) : BaseApiController
+{
+ ///
+ /// 注册租户并初始化套餐。
+ ///
+ [HttpPost]
+ [PermissionAuthorize("tenant:create")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Register([FromBody] RegisterTenantCommand command, CancellationToken cancellationToken)
+ {
+ var result = await mediator.Send(command, cancellationToken);
+ return ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 分页查询租户。
+ ///
+ [HttpGet]
+ [PermissionAuthorize("tenant:read")]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task>> Search([FromQuery] SearchTenantsQuery query, CancellationToken cancellationToken)
+ {
+ var result = await mediator.Send(query, cancellationToken);
+ return ApiResponse>.Ok(result);
+ }
+
+ ///
+ /// 查看租户详情。
+ ///
+ [HttpGet("{tenantId:long}")]
+ [PermissionAuthorize("tenant:read")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Detail(long tenantId, CancellationToken cancellationToken)
+ {
+ var result = await mediator.Send(new GetTenantByIdQuery(tenantId), cancellationToken);
+ return ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 提交或更新实名认证资料。
+ ///
+ [HttpPost("{tenantId:long}/verification")]
+ [PermissionAuthorize("tenant:review")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> SubmitVerification(
+ long tenantId,
+ [FromBody] SubmitTenantVerificationCommand body,
+ CancellationToken cancellationToken)
+ {
+ var command = body with { TenantId = tenantId };
+ var result = await mediator.Send(command, cancellationToken);
+ return ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 审核租户。
+ ///
+ [HttpPost("{tenantId:long}/review")]
+ [PermissionAuthorize("tenant:review")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Review(long tenantId, [FromBody] ReviewTenantCommand body, CancellationToken cancellationToken)
+ {
+ var command = body with { TenantId = tenantId };
+ var result = await mediator.Send(command, cancellationToken);
+ return ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 创建或续费租户订阅。
+ ///
+ [HttpPost("{tenantId:long}/subscriptions")]
+ [PermissionAuthorize("tenant:subscription")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> CreateSubscription(
+ long tenantId,
+ [FromBody] CreateTenantSubscriptionCommand body,
+ CancellationToken cancellationToken)
+ {
+ var command = body with { TenantId = tenantId };
+ var result = await mediator.Send(command, cancellationToken);
+ return ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 套餐升降配。
+ ///
+ [HttpPut("{tenantId:long}/subscriptions/{subscriptionId:long}/plan")]
+ [PermissionAuthorize("tenant:subscription")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> ChangePlan(
+ long tenantId,
+ long subscriptionId,
+ [FromBody] ChangeTenantSubscriptionPlanCommand body,
+ CancellationToken cancellationToken)
+ {
+ var command = body with { TenantId = tenantId, TenantSubscriptionId = subscriptionId };
+ var result = await mediator.Send(command, cancellationToken);
+ return ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 查询审核日志。
+ ///
+ [HttpGet("{tenantId:long}/audits")]
+ [PermissionAuthorize("tenant:read")]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task>> AuditLogs(
+ long tenantId,
+ [FromQuery] int page = 1,
+ [FromQuery] int pageSize = 20,
+ CancellationToken cancellationToken = default)
+ {
+ var query = new GetTenantAuditLogsQuery(tenantId, page, pageSize);
+ var result = await mediator.Send(query, cancellationToken);
+ return ApiResponse>.Ok(result);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ChangeTenantSubscriptionPlanCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ChangeTenantSubscriptionPlanCommand.cs
new file mode 100644
index 0000000..d9c6287
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ChangeTenantSubscriptionPlanCommand.cs
@@ -0,0 +1,15 @@
+using MediatR;
+using System.ComponentModel.DataAnnotations;
+using TakeoutSaaS.Application.App.Tenants.Dto;
+
+namespace TakeoutSaaS.Application.App.Tenants.Commands;
+
+///
+/// 套餐升降配命令。
+///
+public sealed record ChangeTenantSubscriptionPlanCommand(
+ [property: Required] long TenantId,
+ [property: Required] long TenantSubscriptionId,
+ [property: Required] long TargetPackageId,
+ bool Immediate,
+ string? Notes) : IRequest;
diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantSubscriptionCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantSubscriptionCommand.cs
new file mode 100644
index 0000000..468ace2
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantSubscriptionCommand.cs
@@ -0,0 +1,15 @@
+using MediatR;
+using System.ComponentModel.DataAnnotations;
+using TakeoutSaaS.Application.App.Tenants.Dto;
+
+namespace TakeoutSaaS.Application.App.Tenants.Commands;
+
+///
+/// 新建或续费订阅。
+///
+public sealed record CreateTenantSubscriptionCommand(
+ [property: Required] long TenantId,
+ [property: Required] long TenantPackageId,
+ int DurationMonths,
+ bool AutoRenew,
+ string? Notes) : IRequest;
diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/RegisterTenantCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/RegisterTenantCommand.cs
new file mode 100644
index 0000000..43cc053
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/RegisterTenantCommand.cs
@@ -0,0 +1,21 @@
+using MediatR;
+using System.ComponentModel.DataAnnotations;
+using TakeoutSaaS.Application.App.Tenants.Dto;
+
+namespace TakeoutSaaS.Application.App.Tenants.Commands;
+
+///
+/// 注册租户命令。
+///
+public sealed record RegisterTenantCommand(
+ [property: Required, StringLength(64)] string Code,
+ [property: Required, StringLength(128)] string Name,
+ string? ShortName,
+ string? Industry,
+ string? ContactName,
+ string? ContactPhone,
+ string? ContactEmail,
+ [property: Required] long TenantPackageId,
+ int DurationMonths = 12,
+ bool AutoRenew = true,
+ DateTime? EffectiveFrom = null) : IRequest;
diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ReviewTenantCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ReviewTenantCommand.cs
new file mode 100644
index 0000000..5784f9b
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ReviewTenantCommand.cs
@@ -0,0 +1,13 @@
+using MediatR;
+using System.ComponentModel.DataAnnotations;
+using TakeoutSaaS.Application.App.Tenants.Dto;
+
+namespace TakeoutSaaS.Application.App.Tenants.Commands;
+
+///
+/// 审核租户命令。
+///
+public sealed record ReviewTenantCommand(
+ [property: Required] long TenantId,
+ bool Approve,
+ string? Reason) : IRequest;
diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/SubmitTenantVerificationCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/SubmitTenantVerificationCommand.cs
new file mode 100644
index 0000000..8a94a46
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/SubmitTenantVerificationCommand.cs
@@ -0,0 +1,21 @@
+using MediatR;
+using System.ComponentModel.DataAnnotations;
+using TakeoutSaaS.Application.App.Tenants.Dto;
+
+namespace TakeoutSaaS.Application.App.Tenants.Commands;
+
+///
+/// 提交租户实名认证资料。
+///
+public sealed record SubmitTenantVerificationCommand(
+ [property: Required] long TenantId,
+ string? BusinessLicenseNumber,
+ string? BusinessLicenseUrl,
+ string? LegalPersonName,
+ string? LegalPersonIdNumber,
+ string? LegalPersonIdFrontUrl,
+ string? LegalPersonIdBackUrl,
+ string? BankAccountName,
+ string? BankAccountNumber,
+ string? BankName,
+ string? AdditionalDataJson) : IRequest;
diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantAuditLogDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantAuditLogDto.cs
new file mode 100644
index 0000000..ff3f9dc
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantAuditLogDto.cs
@@ -0,0 +1,58 @@
+using System.Text.Json.Serialization;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Shared.Abstractions.Serialization;
+
+namespace TakeoutSaaS.Application.App.Tenants.Dto;
+
+///
+/// 租户审核日志 DTO。
+///
+public sealed class TenantAuditLogDto
+{
+ ///
+ /// 日志 ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long Id { get; init; }
+
+ ///
+ /// 租户 ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long TenantId { get; init; }
+
+ ///
+ /// 动作。
+ ///
+ public TenantAuditAction Action { get; init; }
+
+ ///
+ /// 标题。
+ ///
+ public string Title { get; init; } = string.Empty;
+
+ ///
+ /// 描述。
+ ///
+ public string? Description { get; init; }
+
+ ///
+ /// 操作人。
+ ///
+ public string? OperatorName { get; init; }
+
+ ///
+ /// 原状态。
+ ///
+ public TenantStatus? PreviousStatus { get; init; }
+
+ ///
+ /// 新状态。
+ ///
+ public TenantStatus? CurrentStatus { get; init; }
+
+ ///
+ /// 创建时间。
+ ///
+ public DateTime CreatedAt { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantDetailDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantDetailDto.cs
new file mode 100644
index 0000000..9035d93
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantDetailDto.cs
@@ -0,0 +1,22 @@
+namespace TakeoutSaaS.Application.App.Tenants.Dto;
+
+///
+/// 租户详情 DTO。
+///
+public sealed class TenantDetailDto
+{
+ ///
+ /// 基础信息。
+ ///
+ public TenantDto Tenant { get; init; } = new();
+
+ ///
+ /// 实名信息。
+ ///
+ public TenantVerificationDto? Verification { get; init; }
+
+ ///
+ /// 当前订阅。
+ ///
+ public TenantSubscriptionDto? Subscription { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantDto.cs
new file mode 100644
index 0000000..7f490ad
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantDto.cs
@@ -0,0 +1,78 @@
+using System.Text.Json.Serialization;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Shared.Abstractions.Serialization;
+
+namespace TakeoutSaaS.Application.App.Tenants.Dto;
+
+///
+/// 租户基础信息 DTO。
+///
+public sealed class TenantDto
+{
+ ///
+ /// 租户 ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long Id { get; init; }
+
+ ///
+ /// 租户编码。
+ ///
+ public string Code { get; init; } = string.Empty;
+
+ ///
+ /// 名称。
+ ///
+ public string Name { get; init; } = string.Empty;
+
+ ///
+ /// 简称。
+ ///
+ public string? ShortName { get; init; }
+
+ ///
+ /// 联系人。
+ ///
+ public string? ContactName { get; init; }
+
+ ///
+ /// 联系电话。
+ ///
+ public string? ContactPhone { get; init; }
+
+ ///
+ /// 邮箱。
+ ///
+ public string? ContactEmail { get; init; }
+
+ ///
+ /// 当前状态。
+ ///
+ public TenantStatus Status { get; init; }
+
+ ///
+ /// 实名状态。
+ ///
+ public TenantVerificationStatus VerificationStatus { get; init; }
+
+ ///
+ /// 当前套餐 ID。
+ ///
+ [JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
+ public long? CurrentPackageId { get; init; }
+
+ ///
+ /// 当前订阅有效期开始。
+ ///
+ public DateTime? EffectiveFrom { get; init; }
+
+ ///
+ /// 当前订阅有效期结束。
+ ///
+ public DateTime? EffectiveTo { get; init; }
+
+ ///
+ /// 是否自动续费。
+ ///
+ public bool AutoRenew { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantSubscriptionDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantSubscriptionDto.cs
new file mode 100644
index 0000000..3699896
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantSubscriptionDto.cs
@@ -0,0 +1,54 @@
+using System.Text.Json.Serialization;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Shared.Abstractions.Serialization;
+
+namespace TakeoutSaaS.Application.App.Tenants.Dto;
+
+///
+/// 租户订阅 DTO。
+///
+public sealed class TenantSubscriptionDto
+{
+ ///
+ /// 订阅 ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long Id { get; init; }
+
+ ///
+ /// 租户 ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long TenantId { get; init; }
+
+ ///
+ /// 套餐 ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long TenantPackageId { get; init; }
+
+ ///
+ /// 状态。
+ ///
+ public SubscriptionStatus Status { get; init; }
+
+ ///
+ /// 生效时间。
+ ///
+ public DateTime EffectiveFrom { get; init; }
+
+ ///
+ /// 到期时间。
+ ///
+ public DateTime EffectiveTo { get; init; }
+
+ ///
+ /// 下次扣费时间。
+ ///
+ public DateTime? NextBillingDate { get; init; }
+
+ ///
+ /// 是否自动续费。
+ ///
+ public bool AutoRenew { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantVerificationDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantVerificationDto.cs
new file mode 100644
index 0000000..7f39c8f
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantVerificationDto.cs
@@ -0,0 +1,73 @@
+using System.Text.Json.Serialization;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Shared.Abstractions.Serialization;
+
+namespace TakeoutSaaS.Application.App.Tenants.Dto;
+
+///
+/// 租户实名认证 DTO。
+///
+public sealed class TenantVerificationDto
+{
+ ///
+ /// 主键。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long Id { get; init; }
+
+ ///
+ /// 租户标识。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long TenantId { get; init; }
+
+ ///
+ /// 状态。
+ ///
+ public TenantVerificationStatus Status { get; init; }
+
+ ///
+ /// 营业执照号。
+ ///
+ public string? BusinessLicenseNumber { get; init; }
+
+ ///
+ /// 营业执照图片。
+ ///
+ public string? BusinessLicenseUrl { get; init; }
+
+ ///
+ /// 法人姓名。
+ ///
+ public string? LegalPersonName { get; init; }
+
+ ///
+ /// 法人身份证号。
+ ///
+ public string? LegalPersonIdNumber { get; init; }
+
+ ///
+ /// 银行账号。
+ ///
+ public string? BankAccountNumber { get; init; }
+
+ ///
+ /// 银行名称。
+ ///
+ public string? BankName { get; init; }
+
+ ///
+ /// 审核备注。
+ ///
+ public string? ReviewRemarks { get; init; }
+
+ ///
+ /// 最新审核人。
+ ///
+ public string? ReviewedByName { get; init; }
+
+ ///
+ /// 审核时间。
+ ///
+ public DateTime? ReviewedAt { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ChangeTenantSubscriptionPlanCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ChangeTenantSubscriptionPlanCommandHandler.cs
new file mode 100644
index 0000000..bc7f60f
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ChangeTenantSubscriptionPlanCommandHandler.cs
@@ -0,0 +1,74 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Tenants.Commands;
+using TakeoutSaaS.Application.App.Tenants.Dto;
+using TakeoutSaaS.Domain.Tenants.Entities;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.Shared.Abstractions.Ids;
+
+namespace TakeoutSaaS.Application.App.Tenants.Handlers;
+
+///
+/// 套餐升降配处理器。
+///
+public sealed class ChangeTenantSubscriptionPlanCommandHandler(
+ ITenantRepository tenantRepository,
+ IIdGenerator idGenerator)
+ : IRequestHandler
+{
+ private readonly ITenantRepository _tenantRepository = tenantRepository;
+ private readonly IIdGenerator _idGenerator = idGenerator;
+
+ ///
+ public async Task Handle(ChangeTenantSubscriptionPlanCommand request, CancellationToken cancellationToken)
+ {
+ _ = await _tenantRepository.FindByIdAsync(request.TenantId, cancellationToken)
+ ?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
+
+ var subscription = await _tenantRepository.FindSubscriptionByIdAsync(request.TenantId, request.TenantSubscriptionId, cancellationToken)
+ ?? throw new BusinessException(ErrorCodes.NotFound, "订阅不存在");
+
+ var previousPackage = subscription.TenantPackageId;
+
+ if (request.Immediate)
+ {
+ subscription.TenantPackageId = request.TargetPackageId;
+ subscription.ScheduledPackageId = null;
+ }
+ else
+ {
+ subscription.ScheduledPackageId = request.TargetPackageId;
+ }
+
+ await _tenantRepository.UpdateSubscriptionAsync(subscription, cancellationToken);
+ await _tenantRepository.AddSubscriptionHistoryAsync(new TenantSubscriptionHistory
+ {
+ Id = _idGenerator.NextId(),
+ TenantId = subscription.TenantId,
+ TenantSubscriptionId = subscription.Id,
+ FromPackageId = previousPackage,
+ ToPackageId = request.TargetPackageId,
+ ChangeType = SubscriptionChangeType.Upgrade,
+ EffectiveFrom = subscription.EffectiveFrom,
+ EffectiveTo = subscription.EffectiveTo,
+ Notes = request.Notes
+ }, cancellationToken);
+
+ await _tenantRepository.AddAuditLogAsync(new TenantAuditLog
+ {
+ TenantId = subscription.TenantId,
+ Action = TenantAuditAction.SubscriptionPlanChanged,
+ Title = request.Immediate ? "套餐立即变更" : "套餐排期变更",
+ Description = request.Notes,
+ PreviousStatus = null,
+ CurrentStatus = null
+ }, cancellationToken);
+
+ await _tenantRepository.SaveChangesAsync(cancellationToken);
+
+ return subscription.ToSubscriptionDto()
+ ?? throw new BusinessException(ErrorCodes.InternalServerError, "订阅更新失败");
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantSubscriptionCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantSubscriptionCommandHandler.cs
new file mode 100644
index 0000000..df52305
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantSubscriptionCommandHandler.cs
@@ -0,0 +1,82 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Tenants.Commands;
+using TakeoutSaaS.Application.App.Tenants.Dto;
+using TakeoutSaaS.Domain.Tenants.Entities;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.Shared.Abstractions.Ids;
+
+namespace TakeoutSaaS.Application.App.Tenants.Handlers;
+
+///
+/// 新建/续费订阅处理器。
+///
+public sealed class CreateTenantSubscriptionCommandHandler(
+ ITenantRepository tenantRepository,
+ IIdGenerator idGenerator)
+ : IRequestHandler
+{
+ private readonly ITenantRepository _tenantRepository = tenantRepository;
+ private readonly IIdGenerator _idGenerator = idGenerator;
+
+ ///
+ public async Task Handle(CreateTenantSubscriptionCommand request, CancellationToken cancellationToken)
+ {
+ if (request.DurationMonths <= 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "订阅时长必须大于 0");
+ }
+
+ var tenant = await _tenantRepository.FindByIdAsync(request.TenantId, cancellationToken)
+ ?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
+
+ var current = await _tenantRepository.GetActiveSubscriptionAsync(request.TenantId, cancellationToken);
+ var from = current?.EffectiveTo ?? tenant.EffectiveTo ?? DateTime.UtcNow;
+ var effectiveFrom = from > DateTime.UtcNow ? from : DateTime.UtcNow;
+ var effectiveTo = effectiveFrom.AddMonths(request.DurationMonths);
+
+ var subscription = new TenantSubscription
+ {
+ Id = _idGenerator.NextId(),
+ TenantId = tenant.Id,
+ TenantPackageId = request.TenantPackageId,
+ EffectiveFrom = effectiveFrom,
+ EffectiveTo = effectiveTo,
+ NextBillingDate = effectiveTo,
+ Status = SubscriptionStatus.Active,
+ AutoRenew = request.AutoRenew,
+ Notes = request.Notes
+ };
+
+ await _tenantRepository.AddSubscriptionAsync(subscription, cancellationToken);
+ await _tenantRepository.AddSubscriptionHistoryAsync(new TenantSubscriptionHistory
+ {
+ Id = _idGenerator.NextId(),
+ TenantId = tenant.Id,
+ TenantSubscriptionId = subscription.Id,
+ FromPackageId = current?.TenantPackageId ?? request.TenantPackageId,
+ ToPackageId = request.TenantPackageId,
+ ChangeType = current == null ? SubscriptionChangeType.New : SubscriptionChangeType.Renew,
+ EffectiveFrom = effectiveFrom,
+ EffectiveTo = effectiveTo,
+ Amount = null,
+ Currency = null,
+ Notes = request.Notes
+ }, cancellationToken);
+
+ await _tenantRepository.AddAuditLogAsync(new TenantAuditLog
+ {
+ TenantId = tenant.Id,
+ Action = TenantAuditAction.SubscriptionUpdated,
+ Title = current == null ? "创建订阅" : "续费订阅",
+ Description = $"套餐 {request.TenantPackageId} 时长 {request.DurationMonths} 月"
+ }, cancellationToken);
+
+ await _tenantRepository.SaveChangesAsync(cancellationToken);
+
+ return subscription.ToSubscriptionDto()
+ ?? throw new BusinessException(ErrorCodes.InternalServerError, "订阅生成失败");
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAuditLogsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAuditLogsQueryHandler.cs
new file mode 100644
index 0000000..bfa51ee
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAuditLogsQueryHandler.cs
@@ -0,0 +1,32 @@
+using System.Linq;
+using MediatR;
+using TakeoutSaaS.Application.App.Tenants.Dto;
+using TakeoutSaaS.Application.App.Tenants.Queries;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Results;
+
+namespace TakeoutSaaS.Application.App.Tenants.Handlers;
+
+///
+/// 审核日志查询。
+///
+public sealed class GetTenantAuditLogsQueryHandler(ITenantRepository tenantRepository)
+ : IRequestHandler>
+{
+ private readonly ITenantRepository _tenantRepository = tenantRepository;
+
+ ///
+ public async Task> Handle(GetTenantAuditLogsQuery request, CancellationToken cancellationToken)
+ {
+ var logs = await _tenantRepository.GetAuditLogsAsync(request.TenantId, cancellationToken);
+ var total = logs.Count;
+
+ var paged = logs
+ .Skip((request.Page - 1) * request.PageSize)
+ .Take(request.PageSize)
+ .Select(TenantMapping.ToDto)
+ .ToList();
+
+ return new PagedResult(paged, request.Page, request.PageSize, total);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantByIdQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantByIdQueryHandler.cs
new file mode 100644
index 0000000..f3cd41b
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantByIdQueryHandler.cs
@@ -0,0 +1,34 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Tenants.Dto;
+using TakeoutSaaS.Application.App.Tenants.Queries;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+
+namespace TakeoutSaaS.Application.App.Tenants.Handlers;
+
+///
+/// 租户详情查询处理器。
+///
+public sealed class GetTenantByIdQueryHandler(ITenantRepository tenantRepository)
+ : IRequestHandler
+{
+ private readonly ITenantRepository _tenantRepository = tenantRepository;
+
+ ///
+ public async Task Handle(GetTenantByIdQuery request, CancellationToken cancellationToken)
+ {
+ var tenant = await _tenantRepository.FindByIdAsync(request.TenantId, cancellationToken)
+ ?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
+
+ var subscription = await _tenantRepository.GetActiveSubscriptionAsync(request.TenantId, cancellationToken);
+ var verification = await _tenantRepository.GetVerificationProfileAsync(request.TenantId, cancellationToken);
+
+ return new TenantDetailDto
+ {
+ Tenant = TenantMapping.ToDto(tenant, subscription, verification),
+ Verification = verification.ToVerificationDto(),
+ Subscription = subscription.ToSubscriptionDto()
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/RegisterTenantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/RegisterTenantCommandHandler.cs
new file mode 100644
index 0000000..228bded
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/RegisterTenantCommandHandler.cs
@@ -0,0 +1,88 @@
+using MediatR;
+using Microsoft.Extensions.Logging;
+using TakeoutSaaS.Application.App.Tenants.Commands;
+using TakeoutSaaS.Application.App.Tenants.Dto;
+using TakeoutSaaS.Domain.Tenants.Entities;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.Shared.Abstractions.Ids;
+
+namespace TakeoutSaaS.Application.App.Tenants.Handlers;
+
+///
+/// 租户注册处理器。
+///
+public sealed class RegisterTenantCommandHandler(
+ ITenantRepository tenantRepository,
+ IIdGenerator idGenerator,
+ ILogger logger)
+ : IRequestHandler
+{
+ private readonly ITenantRepository _tenantRepository = tenantRepository;
+ private readonly IIdGenerator _idGenerator = idGenerator;
+ private readonly ILogger _logger = logger;
+
+ ///
+ public async Task Handle(RegisterTenantCommand request, CancellationToken cancellationToken)
+ {
+ if (request.DurationMonths <= 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "订阅时长必须大于 0");
+ }
+
+ if (await _tenantRepository.ExistsByCodeAsync(request.Code, cancellationToken))
+ {
+ throw new BusinessException(ErrorCodes.Conflict, $"租户编码 {request.Code} 已存在");
+ }
+
+ var now = DateTime.UtcNow;
+ var effectiveFrom = request.EffectiveFrom ?? now;
+ var effectiveTo = effectiveFrom.AddMonths(request.DurationMonths);
+
+ var tenant = new Tenant
+ {
+ Id = _idGenerator.NextId(),
+ Code = request.Code.Trim(),
+ Name = request.Name,
+ ShortName = request.ShortName,
+ Industry = request.Industry,
+ ContactName = request.ContactName,
+ ContactPhone = request.ContactPhone,
+ ContactEmail = request.ContactEmail,
+ Status = TenantStatus.PendingReview,
+ EffectiveFrom = effectiveFrom,
+ EffectiveTo = effectiveTo
+ };
+
+ var subscription = new TenantSubscription
+ {
+ Id = _idGenerator.NextId(),
+ TenantId = tenant.Id,
+ TenantPackageId = request.TenantPackageId,
+ EffectiveFrom = effectiveFrom,
+ EffectiveTo = effectiveTo,
+ NextBillingDate = effectiveTo,
+ Status = SubscriptionStatus.Pending,
+ AutoRenew = request.AutoRenew,
+ Notes = "Init subscription"
+ };
+
+ await _tenantRepository.AddTenantAsync(tenant, cancellationToken);
+ await _tenantRepository.AddSubscriptionAsync(subscription, cancellationToken);
+ await _tenantRepository.AddAuditLogAsync(new TenantAuditLog
+ {
+ TenantId = tenant.Id,
+ Action = TenantAuditAction.RegistrationSubmitted,
+ Title = "租户注册",
+ Description = $"提交套餐 {request.TenantPackageId},时长 {request.DurationMonths} 月"
+ }, cancellationToken);
+
+ await _tenantRepository.SaveChangesAsync(cancellationToken);
+
+ _logger.LogInformation("已注册租户 {TenantCode}", tenant.Code);
+
+ return TenantMapping.ToDto(tenant, subscription, null);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ReviewTenantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ReviewTenantCommandHandler.cs
new file mode 100644
index 0000000..97df442
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ReviewTenantCommandHandler.cs
@@ -0,0 +1,87 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Tenants.Commands;
+using TakeoutSaaS.Application.App.Tenants.Dto;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.Shared.Abstractions.Security;
+
+namespace TakeoutSaaS.Application.App.Tenants.Handlers;
+
+///
+/// 租户审核处理器。
+///
+public sealed class ReviewTenantCommandHandler(
+ ITenantRepository tenantRepository,
+ ICurrentUserAccessor currentUserAccessor)
+ : IRequestHandler
+{
+ private readonly ITenantRepository _tenantRepository = tenantRepository;
+ private readonly ICurrentUserAccessor _currentUserAccessor = currentUserAccessor;
+
+ ///
+ public async Task Handle(ReviewTenantCommand request, CancellationToken cancellationToken)
+ {
+ var tenant = await _tenantRepository.FindByIdAsync(request.TenantId, cancellationToken)
+ ?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
+
+ var verification = await _tenantRepository.GetVerificationProfileAsync(request.TenantId, cancellationToken)
+ ?? throw new BusinessException(ErrorCodes.BadRequest, "请先提交实名认证资料");
+
+ var subscription = await _tenantRepository.GetActiveSubscriptionAsync(request.TenantId, cancellationToken);
+
+ var actorName = _currentUserAccessor.IsAuthenticated
+ ? $"user:{_currentUserAccessor.UserId}"
+ : "system";
+
+ verification.ReviewedAt = DateTime.UtcNow;
+ verification.ReviewedBy = _currentUserAccessor.UserId == 0 ? null : _currentUserAccessor.UserId;
+ verification.ReviewedByName = actorName;
+ verification.ReviewRemarks = request.Reason;
+
+ var previousStatus = tenant.Status;
+
+ if (request.Approve)
+ {
+ verification.Status = TenantVerificationStatus.Approved;
+ tenant.Status = TenantStatus.Active;
+ if (subscription != null)
+ {
+ subscription.Status = SubscriptionStatus.Active;
+ }
+ }
+ else
+ {
+ verification.Status = TenantVerificationStatus.Rejected;
+ tenant.Status = TenantStatus.PendingReview;
+ if (subscription != null)
+ {
+ subscription.Status = SubscriptionStatus.Suspended;
+ }
+ }
+
+ await _tenantRepository.UpdateTenantAsync(tenant, cancellationToken);
+ await _tenantRepository.UpsertVerificationProfileAsync(verification, cancellationToken);
+ if (subscription != null)
+ {
+ await _tenantRepository.UpdateSubscriptionAsync(subscription, cancellationToken);
+ }
+
+ await _tenantRepository.AddAuditLogAsync(new Domain.Tenants.Entities.TenantAuditLog
+ {
+ TenantId = tenant.Id,
+ Action = request.Approve ? TenantAuditAction.VerificationApproved : TenantAuditAction.VerificationRejected,
+ Title = request.Approve ? "审核通过" : "审核驳回",
+ Description = request.Reason,
+ OperatorId = _currentUserAccessor.UserId == 0 ? null : _currentUserAccessor.UserId,
+ OperatorName = actorName,
+ PreviousStatus = previousStatus,
+ CurrentStatus = tenant.Status
+ }, cancellationToken);
+
+ await _tenantRepository.SaveChangesAsync(cancellationToken);
+
+ return TenantMapping.ToDto(tenant, subscription, verification);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantsQueryHandler.cs
new file mode 100644
index 0000000..e13b9fc
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantsQueryHandler.cs
@@ -0,0 +1,39 @@
+using System.Linq;
+using MediatR;
+using TakeoutSaaS.Application.App.Tenants.Dto;
+using TakeoutSaaS.Application.App.Tenants.Queries;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Results;
+
+namespace TakeoutSaaS.Application.App.Tenants.Handlers;
+
+///
+/// 租户分页查询处理器。
+///
+public sealed class SearchTenantsQueryHandler(ITenantRepository tenantRepository)
+ : IRequestHandler>
+{
+ private readonly ITenantRepository _tenantRepository = tenantRepository;
+
+ ///
+ public async Task> Handle(SearchTenantsQuery request, CancellationToken cancellationToken)
+ {
+ var tenants = await _tenantRepository.SearchAsync(request.Status, request.Keyword, cancellationToken);
+ var total = tenants.Count;
+
+ var paged = tenants
+ .Skip((request.Page - 1) * request.PageSize)
+ .Take(request.PageSize)
+ .ToList();
+
+ var result = new List(paged.Count);
+ foreach (var tenant in paged)
+ {
+ var subscription = await _tenantRepository.GetActiveSubscriptionAsync(tenant.Id, cancellationToken);
+ var verification = await _tenantRepository.GetVerificationProfileAsync(tenant.Id, cancellationToken);
+ result.Add(TenantMapping.ToDto(tenant, subscription, verification));
+ }
+
+ return new PagedResult(result, request.Page, request.PageSize, total);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SubmitTenantVerificationCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SubmitTenantVerificationCommandHandler.cs
new file mode 100644
index 0000000..dc6be8f
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SubmitTenantVerificationCommandHandler.cs
@@ -0,0 +1,63 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Tenants.Commands;
+using TakeoutSaaS.Application.App.Tenants.Dto;
+using TakeoutSaaS.Domain.Tenants.Entities;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.Shared.Abstractions.Ids;
+
+namespace TakeoutSaaS.Application.App.Tenants.Handlers;
+
+///
+/// 实名资料提交流程。
+///
+public sealed class SubmitTenantVerificationCommandHandler(
+ ITenantRepository tenantRepository,
+ IIdGenerator idGenerator)
+ : IRequestHandler
+{
+ private readonly ITenantRepository _tenantRepository = tenantRepository;
+ private readonly IIdGenerator _idGenerator = idGenerator;
+
+ ///
+ public async Task Handle(SubmitTenantVerificationCommand request, CancellationToken cancellationToken)
+ {
+ var tenant = await _tenantRepository.FindByIdAsync(request.TenantId, cancellationToken)
+ ?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
+
+ var profile = await _tenantRepository.GetVerificationProfileAsync(request.TenantId, cancellationToken)
+ ?? new TenantVerificationProfile { Id = _idGenerator.NextId(), TenantId = tenant.Id };
+
+ profile.BusinessLicenseNumber = request.BusinessLicenseNumber;
+ profile.BusinessLicenseUrl = request.BusinessLicenseUrl;
+ profile.LegalPersonName = request.LegalPersonName;
+ profile.LegalPersonIdNumber = request.LegalPersonIdNumber;
+ profile.LegalPersonIdFrontUrl = request.LegalPersonIdFrontUrl;
+ profile.LegalPersonIdBackUrl = request.LegalPersonIdBackUrl;
+ profile.BankAccountName = request.BankAccountName;
+ profile.BankAccountNumber = request.BankAccountNumber;
+ profile.BankName = request.BankName;
+ profile.AdditionalDataJson = request.AdditionalDataJson;
+ profile.Status = TenantVerificationStatus.Pending;
+ profile.SubmittedAt = DateTime.UtcNow;
+ profile.ReviewedAt = null;
+ profile.ReviewRemarks = null;
+ profile.ReviewedBy = null;
+ profile.ReviewedByName = null;
+
+ await _tenantRepository.UpsertVerificationProfileAsync(profile, cancellationToken);
+ await _tenantRepository.AddAuditLogAsync(new TenantAuditLog
+ {
+ TenantId = tenant.Id,
+ Action = TenantAuditAction.VerificationSubmitted,
+ Title = "提交实名认证资料",
+ Description = request.BusinessLicenseNumber
+ }, cancellationToken);
+ await _tenantRepository.SaveChangesAsync(cancellationToken);
+
+ return profile.ToVerificationDto()
+ ?? throw new BusinessException(ErrorCodes.InternalServerError, "实名资料保存失败");
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantAuditLogsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantAuditLogsQuery.cs
new file mode 100644
index 0000000..10aa010
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantAuditLogsQuery.cs
@@ -0,0 +1,13 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Tenants.Dto;
+using TakeoutSaaS.Shared.Abstractions.Results;
+
+namespace TakeoutSaaS.Application.App.Tenants.Queries;
+
+///
+/// 租户审核日志查询。
+///
+public sealed record GetTenantAuditLogsQuery(
+ long TenantId,
+ int Page = 1,
+ int PageSize = 20) : IRequest>;
diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantByIdQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantByIdQuery.cs
new file mode 100644
index 0000000..43e226d
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantByIdQuery.cs
@@ -0,0 +1,9 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Tenants.Dto;
+
+namespace TakeoutSaaS.Application.App.Tenants.Queries;
+
+///
+/// 单个租户查询。
+///
+public sealed record GetTenantByIdQuery(long TenantId) : IRequest;
diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantsQuery.cs
new file mode 100644
index 0000000..f3834d1
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantsQuery.cs
@@ -0,0 +1,15 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Tenants.Dto;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Shared.Abstractions.Results;
+
+namespace TakeoutSaaS.Application.App.Tenants.Queries;
+
+///
+/// 租户分页查询。
+///
+public sealed record SearchTenantsQuery(
+ TenantStatus? Status,
+ string? Keyword,
+ int Page = 1,
+ int PageSize = 20) : IRequest>;
diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs
new file mode 100644
index 0000000..bc6cfe8
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs
@@ -0,0 +1,76 @@
+using TakeoutSaaS.Application.App.Tenants.Dto;
+using TakeoutSaaS.Domain.Tenants.Entities;
+
+namespace TakeoutSaaS.Application.App.Tenants;
+
+///
+/// 租户 DTO 映射助手。
+///
+internal static class TenantMapping
+{
+ public static TenantDto ToDto(Tenant tenant, TenantSubscription? subscription, TenantVerificationProfile? verification)
+ => new()
+ {
+ Id = tenant.Id,
+ Code = tenant.Code,
+ Name = tenant.Name,
+ ShortName = tenant.ShortName,
+ ContactName = tenant.ContactName,
+ ContactPhone = tenant.ContactPhone,
+ ContactEmail = tenant.ContactEmail,
+ Status = tenant.Status,
+ VerificationStatus = verification?.Status ?? Domain.Tenants.Enums.TenantVerificationStatus.Draft,
+ CurrentPackageId = subscription?.TenantPackageId,
+ EffectiveFrom = subscription?.EffectiveFrom ?? tenant.EffectiveFrom,
+ EffectiveTo = subscription?.EffectiveTo ?? tenant.EffectiveTo,
+ AutoRenew = subscription?.AutoRenew ?? false
+ };
+
+ public static TenantVerificationDto? ToVerificationDto(this TenantVerificationProfile? profile)
+ => profile == null
+ ? null
+ : new TenantVerificationDto
+ {
+ Id = profile.Id,
+ TenantId = profile.TenantId,
+ Status = profile.Status,
+ BusinessLicenseNumber = profile.BusinessLicenseNumber,
+ BusinessLicenseUrl = profile.BusinessLicenseUrl,
+ LegalPersonName = profile.LegalPersonName,
+ LegalPersonIdNumber = profile.LegalPersonIdNumber,
+ BankAccountNumber = profile.BankAccountNumber,
+ BankName = profile.BankName,
+ ReviewRemarks = profile.ReviewRemarks,
+ ReviewedByName = profile.ReviewedByName,
+ ReviewedAt = profile.ReviewedAt
+ };
+
+ public static TenantSubscriptionDto? ToSubscriptionDto(this TenantSubscription? subscription)
+ => subscription == null
+ ? null
+ : new TenantSubscriptionDto
+ {
+ Id = subscription.Id,
+ TenantId = subscription.TenantId,
+ TenantPackageId = subscription.TenantPackageId,
+ Status = subscription.Status,
+ EffectiveFrom = subscription.EffectiveFrom,
+ EffectiveTo = subscription.EffectiveTo,
+ NextBillingDate = subscription.NextBillingDate,
+ AutoRenew = subscription.AutoRenew
+ };
+
+ public static TenantAuditLogDto ToDto(this TenantAuditLog log)
+ => new()
+ {
+ Id = log.Id,
+ TenantId = log.TenantId,
+ Action = log.Action,
+ Title = log.Title,
+ Description = log.Description,
+ OperatorName = log.OperatorName,
+ PreviousStatus = log.PreviousStatus,
+ CurrentStatus = log.CurrentStatus,
+ CreatedAt = log.CreatedAt
+ };
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAuditLog.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAuditLog.cs
new file mode 100644
index 0000000..79858ad
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAuditLog.cs
@@ -0,0 +1,50 @@
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Shared.Abstractions.Entities;
+
+namespace TakeoutSaaS.Domain.Tenants.Entities;
+
+///
+/// 租户运营审核日志。
+///
+public sealed class TenantAuditLog : AuditableEntityBase
+{
+ ///
+ /// 关联的租户标识。
+ ///
+ public long TenantId { get; set; }
+
+ ///
+ /// 操作类型。
+ ///
+ public TenantAuditAction Action { get; set; }
+
+ ///
+ /// 日志标题。
+ ///
+ public string Title { get; set; } = string.Empty;
+
+ ///
+ /// 详细描述。
+ ///
+ public string? Description { get; set; }
+
+ ///
+ /// 操作人 ID。
+ ///
+ public long? OperatorId { get; set; }
+
+ ///
+ /// 操作人名称。
+ ///
+ public string? OperatorName { get; set; }
+
+ ///
+ /// 原状态。
+ ///
+ public TenantStatus? PreviousStatus { get; set; }
+
+ ///
+ /// 新状态。
+ ///
+ public TenantStatus? CurrentStatus { get; set; }
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantSubscriptionHistory.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantSubscriptionHistory.cs
new file mode 100644
index 0000000..9a47b77
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantSubscriptionHistory.cs
@@ -0,0 +1,60 @@
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Shared.Abstractions.Entities;
+
+namespace TakeoutSaaS.Domain.Tenants.Entities;
+
+///
+/// 租户套餐订阅变更记录。
+///
+public sealed class TenantSubscriptionHistory : AuditableEntityBase
+{
+ ///
+ /// 租户标识。
+ ///
+ public long TenantId { get; set; }
+
+ ///
+ /// 对应的订阅 ID。
+ ///
+ public long TenantSubscriptionId { get; set; }
+
+ ///
+ /// 原套餐 ID。
+ ///
+ public long FromPackageId { get; set; }
+
+ ///
+ /// 新套餐 ID。
+ ///
+ public long ToPackageId { get; set; }
+
+ ///
+ /// 变更类型。
+ ///
+ public SubscriptionChangeType ChangeType { get; set; }
+
+ ///
+ /// 生效时间。
+ ///
+ public DateTime EffectiveFrom { get; set; }
+
+ ///
+ /// 到期时间。
+ ///
+ public DateTime EffectiveTo { get; set; }
+
+ ///
+ /// 相关费用。
+ ///
+ public decimal? Amount { get; set; }
+
+ ///
+ /// 币种。
+ ///
+ public string? Currency { get; set; }
+
+ ///
+ /// 备注。
+ ///
+ public string? Notes { get; set; }
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantVerificationProfile.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantVerificationProfile.cs
new file mode 100644
index 0000000..4a49fe9
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantVerificationProfile.cs
@@ -0,0 +1,95 @@
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Shared.Abstractions.Entities;
+
+namespace TakeoutSaaS.Domain.Tenants.Entities;
+
+///
+/// 租户实名认证资料。
+///
+public sealed class TenantVerificationProfile : AuditableEntityBase
+{
+ ///
+ /// 对应的租户标识。
+ ///
+ public long TenantId { get; set; }
+
+ ///
+ /// 实名状态。
+ ///
+ public TenantVerificationStatus Status { get; set; } = TenantVerificationStatus.Draft;
+
+ ///
+ /// 营业执照编号。
+ ///
+ public string? BusinessLicenseNumber { get; set; }
+
+ ///
+ /// 营业执照文件地址。
+ ///
+ public string? BusinessLicenseUrl { get; set; }
+
+ ///
+ /// 法人姓名。
+ ///
+ public string? LegalPersonName { get; set; }
+
+ ///
+ /// 法人身份证号。
+ ///
+ public string? LegalPersonIdNumber { get; set; }
+
+ ///
+ /// 法人身份证正面。
+ ///
+ public string? LegalPersonIdFrontUrl { get; set; }
+
+ ///
+ /// 法人身份证反面。
+ ///
+ public string? LegalPersonIdBackUrl { get; set; }
+
+ ///
+ /// 开户名。
+ ///
+ public string? BankAccountName { get; set; }
+
+ ///
+ /// 银行账号。
+ ///
+ public string? BankAccountNumber { get; set; }
+
+ ///
+ /// 银行名称。
+ ///
+ public string? BankName { get; set; }
+
+ ///
+ /// 附加资料(JSON)。
+ ///
+ public string? AdditionalDataJson { get; set; }
+
+ ///
+ /// 提交时间。
+ ///
+ public DateTime? SubmittedAt { get; set; }
+
+ ///
+ /// 审核时间。
+ ///
+ public DateTime? ReviewedAt { get; set; }
+
+ ///
+ /// 审核人 ID。
+ ///
+ public long? ReviewedBy { get; set; }
+
+ ///
+ /// 审核人姓名。
+ ///
+ public string? ReviewedByName { get; set; }
+
+ ///
+ /// 审核备注。
+ ///
+ public string? ReviewRemarks { get; set; }
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/SubscriptionChangeType.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/SubscriptionChangeType.cs
new file mode 100644
index 0000000..0eb9af5
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/SubscriptionChangeType.cs
@@ -0,0 +1,27 @@
+namespace TakeoutSaaS.Domain.Tenants.Enums;
+
+///
+/// 套餐订阅的操作类型。
+///
+public enum SubscriptionChangeType
+{
+ ///
+ /// 新订阅。
+ ///
+ New = 0,
+
+ ///
+ /// 续费。
+ ///
+ Renew = 1,
+
+ ///
+ /// 升级套餐。
+ ///
+ Upgrade = 2,
+
+ ///
+ /// 降级套餐。
+ ///
+ Downgrade = 3
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantAuditAction.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantAuditAction.cs
new file mode 100644
index 0000000..0b31a62
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantAuditAction.cs
@@ -0,0 +1,42 @@
+namespace TakeoutSaaS.Domain.Tenants.Enums;
+
+///
+/// 租户运营审核动作。
+///
+public enum TenantAuditAction
+{
+ ///
+ /// 注册信息提交。
+ ///
+ RegistrationSubmitted = 1,
+
+ ///
+ /// 实名资料提交或更新。
+ ///
+ VerificationSubmitted = 2,
+
+ ///
+ /// 实名审核通过。
+ ///
+ VerificationApproved = 3,
+
+ ///
+ /// 实名审核驳回。
+ ///
+ VerificationRejected = 4,
+
+ ///
+ /// 订阅创建或续费。
+ ///
+ SubscriptionUpdated = 5,
+
+ ///
+ /// 套餐升降配。
+ ///
+ SubscriptionPlanChanged = 6,
+
+ ///
+ /// 租户状态变更(启用/停用/到期等)。
+ ///
+ StatusChanged = 7
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantVerificationStatus.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantVerificationStatus.cs
new file mode 100644
index 0000000..88dbea7
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantVerificationStatus.cs
@@ -0,0 +1,27 @@
+namespace TakeoutSaaS.Domain.Tenants.Enums;
+
+///
+/// 租户实名认证状态。
+///
+public enum TenantVerificationStatus
+{
+ ///
+ /// 草稿,未提交审核。
+ ///
+ Draft = 0,
+
+ ///
+ /// 已提交审核,等待运营处理。
+ ///
+ Pending = 1,
+
+ ///
+ /// 审核通过。
+ ///
+ Approved = 2,
+
+ ///
+ /// 审核驳回。
+ ///
+ Rejected = 3
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs
new file mode 100644
index 0000000..ea63403
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs
@@ -0,0 +1,95 @@
+using System.Threading;
+using System.Threading.Tasks;
+using TakeoutSaaS.Domain.Tenants.Entities;
+using TakeoutSaaS.Domain.Tenants.Enums;
+
+namespace TakeoutSaaS.Domain.Tenants.Repositories;
+
+///
+/// 租户聚合仓储。
+///
+public interface ITenantRepository
+{
+ ///
+ /// 依据 ID 获取租户。
+ ///
+ Task FindByIdAsync(long tenantId, CancellationToken cancellationToken = default);
+
+ ///
+ /// 按状态与关键词查询租户列表。
+ ///
+ Task> SearchAsync(
+ TenantStatus? status,
+ string? keyword,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 新增租户。
+ ///
+ Task AddTenantAsync(Tenant tenant, CancellationToken cancellationToken = default);
+
+ ///
+ /// 更新租户。
+ ///
+ Task UpdateTenantAsync(Tenant tenant, CancellationToken cancellationToken = default);
+
+ ///
+ /// 判断编码是否存在。
+ ///
+ Task ExistsByCodeAsync(string code, CancellationToken cancellationToken = default);
+
+ ///
+ /// 获取实名资料。
+ ///
+ Task GetVerificationProfileAsync(long tenantId, CancellationToken cancellationToken = default);
+
+ ///
+ /// 新增或更新实名资料。
+ ///
+ Task UpsertVerificationProfileAsync(TenantVerificationProfile profile, CancellationToken cancellationToken = default);
+
+ ///
+ /// 获取当前订阅。
+ ///
+ Task GetActiveSubscriptionAsync(long tenantId, CancellationToken cancellationToken = default);
+
+ ///
+ /// 依据订阅 ID 查询。
+ ///
+ Task FindSubscriptionByIdAsync(long tenantId, long subscriptionId, CancellationToken cancellationToken = default);
+
+ ///
+ /// 新增订阅。
+ ///
+ Task AddSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default);
+
+ ///
+ /// 更新订阅。
+ ///
+ Task UpdateSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default);
+
+ ///
+ /// 记录订阅历史。
+ ///
+ Task AddSubscriptionHistoryAsync(TenantSubscriptionHistory history, CancellationToken cancellationToken = default);
+
+ ///
+ /// 获取订阅历史。
+ ///
+ Task> GetSubscriptionHistoryAsync(long tenantId, CancellationToken cancellationToken = default);
+
+ ///
+ /// 新增审核日志。
+ ///
+ Task AddAuditLogAsync(TenantAuditLog log, CancellationToken cancellationToken = default);
+
+ ///
+ /// 查询审核日志。
+ ///
+ Task> GetAuditLogsAsync(long tenantId, CancellationToken cancellationToken = default);
+
+ ///
+ /// 持久化。
+ ///
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
index 02df728..c9ce810 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
@@ -6,6 +6,7 @@ using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Domain.Payments.Repositories;
using TakeoutSaaS.Domain.Products.Repositories;
using TakeoutSaaS.Domain.Stores.Repositories;
+using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Options;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Infrastructure.App.Repositories;
@@ -36,6 +37,7 @@ public static class AppServiceCollectionExtensions
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddOptions()
.Bind(configuration.GetSection(AppSeedOptions.SectionName))
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
index 14d4ff7..f107cea 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
@@ -39,9 +39,12 @@ public sealed class TakeoutAppDbContext(
public DbSet Tenants => Set();
public DbSet TenantPackages => Set();
public DbSet TenantSubscriptions => Set();
+ public DbSet TenantSubscriptionHistories => Set();
public DbSet TenantQuotaUsages => Set();
public DbSet TenantBillingStatements => Set();
public DbSet TenantNotifications => Set();
+ public DbSet TenantVerificationProfiles => Set();
+ public DbSet TenantAuditLogs => Set();
public DbSet Merchants => Set();
public DbSet MerchantDocuments => Set();
@@ -132,9 +135,12 @@ public sealed class TakeoutAppDbContext(
ConfigureStore(modelBuilder.Entity());
ConfigureTenantPackage(modelBuilder.Entity());
ConfigureTenantSubscription(modelBuilder.Entity());
+ ConfigureTenantSubscriptionHistory(modelBuilder.Entity());
ConfigureTenantQuotaUsage(modelBuilder.Entity());
ConfigureTenantBilling(modelBuilder.Entity());
ConfigureTenantNotification(modelBuilder.Entity());
+ ConfigureTenantVerificationProfile(modelBuilder.Entity());
+ ConfigureTenantAuditLog(modelBuilder.Entity());
ConfigureMerchantDocument(modelBuilder.Entity());
ConfigureMerchantContract(modelBuilder.Entity());
ConfigureMerchantStaff(modelBuilder.Entity());
@@ -216,6 +222,47 @@ public sealed class TakeoutAppDbContext(
builder.HasIndex(x => x.Code).IsUnique();
}
+ private static void ConfigureTenantVerificationProfile(EntityTypeBuilder builder)
+ {
+ builder.ToTable("tenant_verification_profiles");
+ builder.HasKey(x => x.Id);
+ builder.Property(x => x.TenantId).IsRequired();
+ builder.Property(x => x.BusinessLicenseNumber).HasMaxLength(64);
+ builder.Property(x => x.BusinessLicenseUrl).HasMaxLength(512);
+ builder.Property(x => x.LegalPersonName).HasMaxLength(64);
+ builder.Property(x => x.LegalPersonIdNumber).HasMaxLength(32);
+ builder.Property(x => x.LegalPersonIdFrontUrl).HasMaxLength(512);
+ builder.Property(x => x.LegalPersonIdBackUrl).HasMaxLength(512);
+ builder.Property(x => x.BankAccountName).HasMaxLength(128);
+ builder.Property(x => x.BankAccountNumber).HasMaxLength(64);
+ builder.Property(x => x.BankName).HasMaxLength(128);
+ builder.Property(x => x.ReviewRemarks).HasMaxLength(512);
+ builder.Property(x => x.ReviewedByName).HasMaxLength(64);
+ builder.HasIndex(x => x.TenantId).IsUnique();
+ }
+
+ private static void ConfigureTenantAuditLog(EntityTypeBuilder builder)
+ {
+ builder.ToTable("tenant_audit_logs");
+ builder.HasKey(x => x.Id);
+ builder.Property(x => x.TenantId).IsRequired();
+ builder.Property(x => x.Title).HasMaxLength(128).IsRequired();
+ builder.Property(x => x.Description).HasMaxLength(1024);
+ builder.Property(x => x.OperatorName).HasMaxLength(64);
+ builder.HasIndex(x => x.TenantId);
+ }
+
+ private static void ConfigureTenantSubscriptionHistory(EntityTypeBuilder builder)
+ {
+ builder.ToTable("tenant_subscription_histories");
+ builder.HasKey(x => x.Id);
+ builder.Property(x => x.TenantId).IsRequired();
+ builder.Property(x => x.TenantSubscriptionId).IsRequired();
+ builder.Property(x => x.Notes).HasMaxLength(512);
+ builder.Property(x => x.Currency).HasMaxLength(8);
+ builder.HasIndex(x => new { x.TenantId, x.TenantSubscriptionId });
+ }
+
private static void ConfigureMerchant(EntityTypeBuilder builder)
{
builder.ToTable("merchants");
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs
new file mode 100644
index 0000000..9c4ab49
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs
@@ -0,0 +1,161 @@
+using System.Linq;
+using Microsoft.EntityFrameworkCore;
+using TakeoutSaaS.Domain.Tenants.Entities;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Infrastructure.App.Persistence;
+
+namespace TakeoutSaaS.Infrastructure.App.Repositories;
+
+///
+/// 租户聚合的 EF Core 仓储实现。
+///
+public sealed class EfTenantRepository(TakeoutAppDbContext context) : ITenantRepository
+{
+ ///
+ public Task FindByIdAsync(long tenantId, CancellationToken cancellationToken = default)
+ {
+ return context.Tenants
+ .AsNoTracking()
+ .FirstOrDefaultAsync(x => x.Id == tenantId, cancellationToken);
+ }
+
+ ///
+ public async Task> SearchAsync(
+ TenantStatus? status,
+ string? keyword,
+ CancellationToken cancellationToken = default)
+ {
+ var query = context.Tenants.AsNoTracking();
+
+ if (status.HasValue)
+ {
+ query = query.Where(x => x.Status == status.Value);
+ }
+
+ if (!string.IsNullOrWhiteSpace(keyword))
+ {
+ keyword = keyword.Trim();
+ query = query.Where(x =>
+ EF.Functions.ILike(x.Name, $"%{keyword}%") ||
+ EF.Functions.ILike(x.Code, $"%{keyword}%") ||
+ EF.Functions.ILike(x.ContactName ?? string.Empty, $"%{keyword}%"));
+ }
+
+ return await query
+ .OrderByDescending(x => x.CreatedAt)
+ .ToListAsync(cancellationToken);
+ }
+
+ ///
+ public Task AddTenantAsync(Tenant tenant, CancellationToken cancellationToken = default)
+ {
+ return context.Tenants.AddAsync(tenant, cancellationToken).AsTask();
+ }
+
+ ///
+ public Task UpdateTenantAsync(Tenant tenant, CancellationToken cancellationToken = default)
+ {
+ context.Tenants.Update(tenant);
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task ExistsByCodeAsync(string code, CancellationToken cancellationToken = default)
+ {
+ var normalized = code.Trim();
+ return context.Tenants.AnyAsync(x => x.Code == normalized, cancellationToken);
+ }
+
+ ///
+ public Task GetVerificationProfileAsync(long tenantId, CancellationToken cancellationToken = default)
+ {
+ return context.TenantVerificationProfiles
+ .AsNoTracking()
+ .FirstOrDefaultAsync(x => x.TenantId == tenantId, cancellationToken);
+ }
+
+ ///
+ public async Task UpsertVerificationProfileAsync(TenantVerificationProfile profile, CancellationToken cancellationToken = default)
+ {
+ var existing = await context.TenantVerificationProfiles
+ .FirstOrDefaultAsync(x => x.TenantId == profile.TenantId, cancellationToken);
+
+ if (existing == null)
+ {
+ await context.TenantVerificationProfiles.AddAsync(profile, cancellationToken);
+ return;
+ }
+
+ profile.Id = existing.Id;
+ context.Entry(existing).CurrentValues.SetValues(profile);
+ }
+
+ ///
+ public Task GetActiveSubscriptionAsync(long tenantId, CancellationToken cancellationToken = default)
+ {
+ return context.TenantSubscriptions
+ .AsNoTracking()
+ .Where(x => x.TenantId == tenantId)
+ .OrderByDescending(x => x.EffectiveTo)
+ .FirstOrDefaultAsync(cancellationToken);
+ }
+
+ ///
+ public Task FindSubscriptionByIdAsync(long tenantId, long subscriptionId, CancellationToken cancellationToken = default)
+ {
+ return context.TenantSubscriptions
+ .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == subscriptionId, cancellationToken);
+ }
+
+ ///
+ public Task AddSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default)
+ {
+ return context.TenantSubscriptions.AddAsync(subscription, cancellationToken).AsTask();
+ }
+
+ ///
+ public Task UpdateSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default)
+ {
+ context.TenantSubscriptions.Update(subscription);
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task AddSubscriptionHistoryAsync(TenantSubscriptionHistory history, CancellationToken cancellationToken = default)
+ {
+ return context.TenantSubscriptionHistories.AddAsync(history, cancellationToken).AsTask();
+ }
+
+ ///
+ public async Task> GetSubscriptionHistoryAsync(long tenantId, CancellationToken cancellationToken = default)
+ {
+ return await context.TenantSubscriptionHistories
+ .AsNoTracking()
+ .Where(x => x.TenantId == tenantId)
+ .OrderByDescending(x => x.EffectiveFrom)
+ .ToListAsync(cancellationToken);
+ }
+
+ ///
+ public Task AddAuditLogAsync(TenantAuditLog log, CancellationToken cancellationToken = default)
+ {
+ return context.TenantAuditLogs.AddAsync(log, cancellationToken).AsTask();
+ }
+
+ ///
+ public async Task> GetAuditLogsAsync(long tenantId, CancellationToken cancellationToken = default)
+ {
+ return await context.TenantAuditLogs
+ .AsNoTracking()
+ .Where(x => x.TenantId == tenantId)
+ .OrderByDescending(x => x.CreatedAt)
+ .ToListAsync(cancellationToken);
+ }
+
+ ///
+ public Task SaveChangesAsync(CancellationToken cancellationToken = default)
+ {
+ return context.SaveChangesAsync(cancellationToken);
+ }
+}
From 0c329669a92d3a4650771a959f48ae8476bba73b Mon Sep 17 00:00:00 2001
From: MSuMshk <2039814060@qq.com>
Date: Wed, 3 Dec 2025 19:01:53 +0800
Subject: [PATCH 05/30] =?UTF-8?q?feat:=20=E5=95=86=E6=88=B7=E7=B1=BB?=
=?UTF-8?q?=E7=9B=AE=E6=95=B0=E6=8D=AE=E5=BA=93=E5=8C=96=E5=B9=B6=E5=A2=9E?=
=?UTF-8?q?=E5=8A=A0=E6=9D=83=E9=99=90=E7=A7=8D=E5=AD=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Document/12_BusinessTodo.md | 4 +-
.../MerchantCategoriesController.cs | 74 +++++++++
.../Controllers/MerchantsController.cs | 145 +++++++++++++++++-
.../appsettings.Development.json | 2 +-
.../appsettings.Production.json | 2 +-
.../appsettings.Seed.Development.json | 14 +-
.../Commands/AddMerchantDocumentCommand.cs | 18 +++
.../Commands/CreateMerchantCategoryCommand.cs | 13 ++
.../Commands/CreateMerchantContractCommand.cs | 16 ++
.../Commands/DeleteMerchantCategoryCommand.cs | 9 ++
.../ReorderMerchantCategoriesCommand.cs | 18 +++
.../Commands/ReviewMerchantCommand.cs | 13 ++
.../Commands/ReviewMerchantDocumentCommand.cs | 14 ++
.../UpdateMerchantContractStatusCommand.cs | 17 ++
.../App/Merchants/Dto/MerchantAuditLogDto.cs | 27 ++++
.../App/Merchants/Dto/MerchantCategoryDto.cs | 34 ++++
.../App/Merchants/Dto/MerchantContractDto.cs | 33 ++++
.../App/Merchants/Dto/MerchantDetailDto.cs | 24 +++
.../App/Merchants/Dto/MerchantDocumentDto.cs | 33 ++++
.../AddMerchantDocumentCommandHandler.cs | 76 +++++++++
.../CreateMerchantCategoryCommandHandler.cs | 49 ++++++
.../CreateMerchantContractCommandHandler.cs | 78 ++++++++++
.../DeleteMerchantCategoryCommandHandler.cs | 33 ++++
.../GetMerchantAuditLogsQueryHandler.cs | 35 +++++
.../GetMerchantCategoriesQueryHandler.cs | 34 ++++
.../GetMerchantContractsQueryHandler.cs | 32 ++++
.../Handlers/GetMerchantDetailQueryHandler.cs | 38 +++++
.../GetMerchantDocumentsQueryHandler.cs | 32 ++++
.../ListMerchantCategoriesQueryHandler.cs | 27 ++++
...ReorderMerchantCategoriesCommandHandler.cs | 42 +++++
.../Handlers/ReviewMerchantCommandHandler.cs | 74 +++++++++
.../ReviewMerchantDocumentCommandHandler.cs | 69 +++++++++
...ateMerchantContractStatusCommandHandler.cs | 76 +++++++++
.../App/Merchants/MerchantMapping.cs | 84 ++++++++++
.../Queries/GetMerchantAuditLogsQuery.cs | 13 ++
.../Queries/GetMerchantCategoriesQuery.cs | 9 ++
.../Queries/GetMerchantContractsQuery.cs | 10 ++
.../Queries/GetMerchantDetailQuery.cs | 9 ++
.../Queries/GetMerchantDocumentsQuery.cs | 10 ++
.../Queries/ListMerchantCategoriesQuery.cs | 10 ++
.../Merchants/Entities/MerchantAuditLog.cs | 40 +++++
.../Merchants/Entities/MerchantCategory.cs | 24 +++
.../Merchants/Enums/MerchantAuditAction.cs | 37 +++++
.../IMerchantCategoryRepository.cs | 47 ++++++
.../Repositories/IMerchantRepository.cs | 14 ++
.../AppServiceCollectionExtensions.cs | 1 +
.../App/Persistence/TakeoutAppDbContext.cs | 25 +++
.../EfMerchantCategoryRepository.cs | 68 ++++++++
.../App/Repositories/EfMerchantRepository.cs | 46 ++++++
49 files changed, 1646 insertions(+), 6 deletions(-)
create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantCategoriesController.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Commands/AddMerchantDocumentCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantCategoryCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantContractCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Commands/DeleteMerchantCategoryCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReorderMerchantCategoriesCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantDocumentCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Commands/UpdateMerchantContractStatusCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantAuditLogDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantCategoryDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantContractDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDetailDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDocumentDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/AddMerchantDocumentCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCategoryCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantContractCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/DeleteMerchantCategoryCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantAuditLogsQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantCategoriesQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantContractsQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDetailQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDocumentsQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ListMerchantCategoriesQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReorderMerchantCategoriesCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantDocumentCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantContractStatusCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/MerchantMapping.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantAuditLogsQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantCategoriesQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantContractsQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantDetailQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantDocumentsQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Queries/ListMerchantCategoriesQuery.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantAuditLog.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantCategory.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Merchants/Enums/MerchantAuditAction.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantCategoryRepository.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantCategoryRepository.cs
diff --git a/Document/12_BusinessTodo.md b/Document/12_BusinessTodo.md
index ca1a132..5eaadc5 100644
--- a/Document/12_BusinessTodo.md
+++ b/Document/12_BusinessTodo.md
@@ -6,8 +6,8 @@
## Phase 1(当前阶段):租户/商家入驻、门店与菜品、扫码堂食、基础下单支付、预购自提、第三方配送骨架
- [x] 管理端租户 API:注册、实名认证、套餐订阅/续费/升降配、审核流,Swagger ≥6 个端点,含审核日志。
- 已交付:`src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs` 暴露注册、详情、实名提交、审核、订阅创建/升降配、审核日志 8 个端点;对应命令/查询位于 `src/Application/TakeoutSaaS.Application/App/Tenants`,仓储实现 `EfTenantRepository`,并写入 `TenantAuditLog` 记录。Swagger 自动收录上述接口,满足 Phase1 租户管理要求。
-- [ ] 商家入驻 API:证照上传、合同管理、类目选择,驱动待审/审核/驳回/通过状态机,文件持久在 COS。
- - 当前:`MerchantsController` 只暴露基础 CRUD(`src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs:21-88`),缺少证照/合同上传、COS 存储与状态机端点。
+- [x] 商家入驻 API:证照上传、合同管理、类目选择,驱动待审/审核/驳回/通过状态机,文件持久在 COS。
+ - 已交付:`src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs` 新增证照上传/审核、合同创建与状态更新、商户审核、审核日志、类目列表等 8 个端点;应用层新增 `AddMerchantDocumentCommand`、`CreateMerchantContractCommand`、`ReviewMerchantCommand` 等 Handler;`MerchantDocument/Contract/Audit` DTO 完整返回详情,文件 URL 仍通过 `/api/admin/v1/files/upload` 上 COS。仓储实现扩展 `EfMerchantRepository` 支持文档/合同/AuditLog 持久化,`TakeoutAppDbContext` 新增 `merchant_audit_logs` 表实现状态机追踪。
- [ ] RBAC 模板:平台管理员、租户管理员、店长、店员四角色模板;API 可复制并允许租户自定义扩展。
- 当前:`RolesController`/`PermissionsController` 已提供角色与权限 CRUD(`src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs:16-88`、`.../PermissionsController.cs:16-63`),但没有“模板复制”或按租户批量初始化的接口。
- [ ] 配额与套餐:TenantPackage CRUD、订阅/续费/配额校验(门店/账号/短信/配送单量),超额返回 409 并记录 TenantQuotaUsage。
diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantCategoriesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantCategoriesController.cs
new file mode 100644
index 0000000..72684f0
--- /dev/null
+++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantCategoriesController.cs
@@ -0,0 +1,74 @@
+using System.Collections.Generic;
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using TakeoutSaaS.Application.App.Merchants.Commands;
+using TakeoutSaaS.Application.App.Merchants.Dto;
+using TakeoutSaaS.Application.App.Merchants.Queries;
+using TakeoutSaaS.Module.Authorization.Attributes;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Results;
+using TakeoutSaaS.Shared.Web.Api;
+
+namespace TakeoutSaaS.AdminApi.Controllers;
+
+///
+/// 商户类目管理。
+///
+[ApiVersion("1.0")]
+[Authorize]
+[Route("api/admin/v{version:apiVersion}/merchant-categories")]
+public sealed class MerchantCategoriesController(IMediator mediator) : BaseApiController
+{
+ ///
+ /// 列出所有类目。
+ ///
+ [HttpGet]
+ [PermissionAuthorize("merchant_category:read")]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task>> List(CancellationToken cancellationToken)
+ {
+ var result = await mediator.Send(new ListMerchantCategoriesQuery(), cancellationToken);
+ return ApiResponse>.Ok(result);
+ }
+
+ ///
+ /// 新增类目。
+ ///
+ [HttpPost]
+ [PermissionAuthorize("merchant_category:create")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Create([FromBody] CreateMerchantCategoryCommand command, CancellationToken cancellationToken)
+ {
+ var result = await mediator.Send(command, cancellationToken);
+ return ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 删除类目。
+ ///
+ [HttpDelete("{categoryId:long}")]
+ [PermissionAuthorize("merchant_category:delete")]
+ [ProducesResponseType(typeof(ApiResponse