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), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Delete(long categoryId, CancellationToken cancellationToken) + { + var success = await mediator.Send(new DeleteMerchantCategoryCommand(categoryId), cancellationToken); + return success + ? ApiResponse.Ok(null) + : ApiResponse.Error(ErrorCodes.NotFound, "类目不存在"); + } + + /// + /// 批量调整类目排序。 + /// + [HttpPost("reorder")] + [PermissionAuthorize("merchant_category:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Reorder([FromBody] ReorderMerchantCategoriesCommand command, CancellationToken cancellationToken) + { + await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(null); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs index dd99bb6..d63bf56 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs @@ -99,7 +99,7 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController } /// - /// 获取商户详情。 + /// 获取商户概览。 /// [HttpGet("{merchantId:long}")] [PermissionAuthorize("merchant:read")] @@ -112,4 +112,147 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController ? ApiResponse.Error(ErrorCodes.NotFound, "商户不存在") : ApiResponse.Ok(result); } + + /// + /// 获取商户详细资料(含证照、合同)。 + /// + [HttpGet("{merchantId:long}/detail")] + [PermissionAuthorize("merchant:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> FullDetail(long merchantId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetMerchantDetailQuery(merchantId), cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 上传商户证照信息(先通过文件上传接口获取 COS 地址)。 + /// + [HttpPost("{merchantId:long}/documents")] + [PermissionAuthorize("merchant:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CreateDocument( + long merchantId, + [FromBody] AddMerchantDocumentCommand body, + CancellationToken cancellationToken) + { + var command = body with { MerchantId = merchantId }; + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 商户证照列表。 + /// + [HttpGet("{merchantId:long}/documents")] + [PermissionAuthorize("merchant:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Documents(long merchantId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetMerchantDocumentsQuery(merchantId), cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 审核指定证照。 + /// + [HttpPost("{merchantId:long}/documents/{documentId:long}/review")] + [PermissionAuthorize("merchant:review")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> ReviewDocument( + long merchantId, + long documentId, + [FromBody] ReviewMerchantDocumentCommand body, + CancellationToken cancellationToken) + { + var command = body with { MerchantId = merchantId, DocumentId = documentId }; + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 新增商户合同。 + /// + [HttpPost("{merchantId:long}/contracts")] + [PermissionAuthorize("merchant:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CreateContract( + long merchantId, + [FromBody] CreateMerchantContractCommand body, + CancellationToken cancellationToken) + { + var command = body with { MerchantId = merchantId }; + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 合同列表。 + /// + [HttpGet("{merchantId:long}/contracts")] + [PermissionAuthorize("merchant:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Contracts(long merchantId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetMerchantContractsQuery(merchantId), cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 更新合同状态(生效/终止等)。 + /// + [HttpPut("{merchantId:long}/contracts/{contractId:long}/status")] + [PermissionAuthorize("merchant:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> UpdateContractStatus( + long merchantId, + long contractId, + [FromBody] UpdateMerchantContractStatusCommand body, + CancellationToken cancellationToken) + { + var command = body with { MerchantId = merchantId, ContractId = contractId }; + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 审核商户(通过/驳回)。 + /// + [HttpPost("{merchantId:long}/review")] + [PermissionAuthorize("merchant:review")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Review(long merchantId, [FromBody] ReviewMerchantCommand body, CancellationToken cancellationToken) + { + var command = body with { MerchantId = merchantId }; + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 审核日志。 + /// + [HttpGet("{merchantId:long}/audits")] + [PermissionAuthorize("merchant:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> AuditLogs( + long merchantId, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + CancellationToken cancellationToken = default) + { + var result = await mediator.Send(new GetMerchantAuditLogsQuery(merchantId, page, pageSize), cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 可选商户类目列表。 + /// + [HttpGet("categories")] + [PermissionAuthorize("merchant:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Categories(CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetMerchantCategoriesQuery(), cancellationToken); + return ApiResponse>.Ok(result); + } } diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json index 1bc5044..3955d16 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json @@ -173,4 +173,4 @@ "Sampling": "ParentBasedAlwaysOn", "UseConsoleExporter": true } -} \ No newline at end of file +} diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json index 1bc5044..3955d16 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json @@ -173,4 +173,4 @@ "Sampling": "ParentBasedAlwaysOn", "UseConsoleExporter": true } -} \ No newline at end of file +} diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json index 3079e81..6f897a7 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json @@ -46,7 +46,19 @@ "Password": "Admin@123456", "TenantId": 1000000000001, "Roles": [ "PlatformAdmin" ], - "Permissions": [ "merchant:*", "store:*", "product:*", "order:*", "payment:*", "delivery:*" ] + "Permissions": [ + "merchant:*", + "merchant_category:*", + "merchant_category:read", + "merchant_category:create", + "merchant_category:update", + "merchant_category:delete", + "store:*", + "product:*", + "order:*", + "payment:*", + "delivery:*" + ] } ] } diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/AddMerchantDocumentCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/AddMerchantDocumentCommand.cs new file mode 100644 index 0000000..cf0d4a3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/AddMerchantDocumentCommand.cs @@ -0,0 +1,18 @@ +using System; +using System.ComponentModel.DataAnnotations; +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Domain.Merchants.Enums; + +namespace TakeoutSaaS.Application.App.Merchants.Commands; + +/// +/// 新增商户证照。 +/// +public sealed record AddMerchantDocumentCommand( + [property: Required] long MerchantId, + [property: Required] MerchantDocumentType DocumentType, + [property: Required, MaxLength(512)] string FileUrl, + [property: MaxLength(64)] string? DocumentNumber, + DateTime? IssuedAt, + DateTime? ExpiresAt) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantCategoryCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantCategoryCommand.cs new file mode 100644 index 0000000..115d4ba --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantCategoryCommand.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; + +namespace TakeoutSaaS.Application.App.Merchants.Commands; + +/// +/// 新增商户类目。 +/// +public sealed record CreateMerchantCategoryCommand( + [property: Required, MaxLength(64)] string Name, + int? DisplayOrder, + bool IsActive = true) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantContractCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantContractCommand.cs new file mode 100644 index 0000000..176cf98 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantContractCommand.cs @@ -0,0 +1,16 @@ +using System; +using System.ComponentModel.DataAnnotations; +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; + +namespace TakeoutSaaS.Application.App.Merchants.Commands; + +/// +/// 新建商户合同。 +/// +public sealed record CreateMerchantContractCommand( + [property: Required] long MerchantId, + [property: Required, MaxLength(64)] string ContractNumber, + DateTime StartDate, + DateTime EndDate, + [property: Required, MaxLength(512)] string FileUrl) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/DeleteMerchantCategoryCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/DeleteMerchantCategoryCommand.cs new file mode 100644 index 0000000..ffa0326 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/DeleteMerchantCategoryCommand.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; +using MediatR; + +namespace TakeoutSaaS.Application.App.Merchants.Commands; + +/// +/// 删除商户类目。 +/// +public sealed record DeleteMerchantCategoryCommand([property: Required] long CategoryId) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReorderMerchantCategoriesCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReorderMerchantCategoriesCommand.cs new file mode 100644 index 0000000..79bd657 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReorderMerchantCategoriesCommand.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using MediatR; + +namespace TakeoutSaaS.Application.App.Merchants.Commands; + +/// +/// 调整类目排序。 +/// +public sealed record ReorderMerchantCategoriesCommand( + [property: Required, MinLength(1)] IReadOnlyList Items) : IRequest; + +/// +/// 类目排序条目。 +/// +public sealed record MerchantCategoryOrderItem( + [property: Required] long CategoryId, + [property: Range(-1000, 100000)] int DisplayOrder); diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantCommand.cs new file mode 100644 index 0000000..792cddc --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantCommand.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; + +namespace TakeoutSaaS.Application.App.Merchants.Commands; + +/// +/// 审核商户入驻。 +/// +public sealed record ReviewMerchantCommand( + [property: Required] long MerchantId, + bool Approve, + string? Remarks) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantDocumentCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantDocumentCommand.cs new file mode 100644 index 0000000..23d6e2a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantDocumentCommand.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; + +namespace TakeoutSaaS.Application.App.Merchants.Commands; + +/// +/// 审核商户证照。 +/// +public sealed record ReviewMerchantDocumentCommand( + [property: Required] long MerchantId, + [property: Required] long DocumentId, + bool Approve, + string? Remarks) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/UpdateMerchantContractStatusCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/UpdateMerchantContractStatusCommand.cs new file mode 100644 index 0000000..3514b58 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/UpdateMerchantContractStatusCommand.cs @@ -0,0 +1,17 @@ +using System; +using System.ComponentModel.DataAnnotations; +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Domain.Merchants.Enums; + +namespace TakeoutSaaS.Application.App.Merchants.Commands; + +/// +/// 更新合同状态。 +/// +public sealed record UpdateMerchantContractStatusCommand( + [property: Required] long MerchantId, + [property: Required] long ContractId, + [property: Required] ContractStatus Status, + DateTime? SignedAt, + string? Reason) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantAuditLogDto.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantAuditLogDto.cs new file mode 100644 index 0000000..2c42c35 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantAuditLogDto.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Merchants.Dto; + +/// +/// 商户审核日志 DTO。 +/// +public sealed class MerchantAuditLogDto +{ + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long MerchantId { get; init; } + + public MerchantAuditAction Action { get; init; } + + public string Title { get; init; } = string.Empty; + + public string? Description { get; init; } + + public string? OperatorName { get; init; } + + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantCategoryDto.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantCategoryDto.cs new file mode 100644 index 0000000..0d8636b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantCategoryDto.cs @@ -0,0 +1,34 @@ +using System; + +namespace TakeoutSaaS.Application.App.Merchants.Dto; + +/// +/// 商户类目 DTO。 +/// +public sealed record MerchantCategoryDto +{ + /// + /// 类目标识。 + /// + public long Id { get; init; } + + /// + /// 类目名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 显示顺序。 + /// + public int DisplayOrder { get; init; } + + /// + /// 是否启用。 + /// + public bool IsActive { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantContractDto.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantContractDto.cs new file mode 100644 index 0000000..3c12bb3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantContractDto.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Merchants.Dto; + +/// +/// 商户合同 DTO。 +/// +public sealed class MerchantContractDto +{ + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long MerchantId { get; init; } + + public string ContractNumber { get; init; } = string.Empty; + + public ContractStatus Status { get; init; } + + public DateTime StartDate { get; init; } + + public DateTime EndDate { get; init; } + + public string FileUrl { get; init; } = string.Empty; + + public DateTime? SignedAt { get; init; } + + public DateTime? TerminatedAt { get; init; } + + public string? TerminationReason { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDetailDto.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDetailDto.cs new file mode 100644 index 0000000..4c8dedb --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDetailDto.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace TakeoutSaaS.Application.App.Merchants.Dto; + +/// +/// 商户详情 DTO。 +/// +public sealed class MerchantDetailDto +{ + /// + /// 基础信息。 + /// + public MerchantDto Merchant { get; init; } = new(); + + /// + /// 证照列表。 + /// + public IReadOnlyList Documents { get; init; } = []; + + /// + /// 合同列表。 + /// + public IReadOnlyList Contracts { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDocumentDto.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDocumentDto.cs new file mode 100644 index 0000000..8723031 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDocumentDto.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Merchants.Dto; + +/// +/// 商户证照 DTO。 +/// +public sealed class MerchantDocumentDto +{ + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long MerchantId { get; init; } + + public MerchantDocumentType DocumentType { get; init; } + + public MerchantDocumentStatus Status { get; init; } + + public string FileUrl { get; init; } = string.Empty; + + public string? DocumentNumber { get; init; } + + public DateTime? IssuedAt { get; init; } + + public DateTime? ExpiresAt { get; init; } + + public string? Remarks { get; init; } + + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/AddMerchantDocumentCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/AddMerchantDocumentCommandHandler.cs new file mode 100644 index 0000000..46e2923 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/AddMerchantDocumentCommandHandler.cs @@ -0,0 +1,76 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Commands; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Domain.Merchants.Entities; +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Ids; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 处理证照上传。 +/// +public sealed class AddMerchantDocumentCommandHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider, + IIdGenerator idGenerator, + ICurrentUserAccessor currentUserAccessor) + : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository = merchantRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly IIdGenerator _idGenerator = idGenerator; + private readonly ICurrentUserAccessor _currentUserAccessor = currentUserAccessor; + + public async Task Handle(AddMerchantDocumentCommand request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var merchant = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在"); + + var document = new MerchantDocument + { + Id = _idGenerator.NextId(), + MerchantId = merchant.Id, + DocumentType = request.DocumentType, + Status = MerchantDocumentStatus.Pending, + FileUrl = request.FileUrl.Trim(), + DocumentNumber = request.DocumentNumber?.Trim(), + IssuedAt = request.IssuedAt, + ExpiresAt = request.ExpiresAt + }; + + await _merchantRepository.AddDocumentAsync(document, cancellationToken); + await _merchantRepository.AddAuditLogAsync(new MerchantAuditLog + { + TenantId = tenantId, + MerchantId = merchant.Id, + Action = MerchantAuditAction.DocumentUploaded, + Title = "上传证照", + Description = $"类型:{request.DocumentType}", + OperatorId = ResolveOperatorId(), + OperatorName = ResolveOperatorName() + }, cancellationToken); + + await _merchantRepository.SaveChangesAsync(cancellationToken); + + return MerchantMapping.ToDto(document); + } + + private long? ResolveOperatorId() + { + var id = _currentUserAccessor.UserId; + return id == 0 ? null : id; + } + + private string ResolveOperatorName() + { + var id = _currentUserAccessor.UserId; + return id == 0 ? "system" : $"user:{id}"; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCategoryCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCategoryCommandHandler.cs new file mode 100644 index 0000000..84dd79e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCategoryCommandHandler.cs @@ -0,0 +1,49 @@ +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Commands; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Domain.Merchants.Entities; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 创建类目处理器。 +/// +public sealed class CreateMerchantCategoryCommandHandler( + IMerchantCategoryRepository categoryRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + private readonly IMerchantCategoryRepository _categoryRepository = categoryRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + public async Task Handle(CreateMerchantCategoryCommand request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var normalizedName = request.Name.Trim(); + + if (await _categoryRepository.ExistsAsync(normalizedName, tenantId, cancellationToken)) + { + throw new BusinessException(ErrorCodes.Conflict, $"类目“{normalizedName}”已存在"); + } + + var categories = await _categoryRepository.ListAsync(tenantId, cancellationToken); + var targetOrder = request.DisplayOrder ?? (categories.Count == 0 ? 1 : categories.Max(x => x.DisplayOrder) + 1); + + var entity = new MerchantCategory + { + Name = normalizedName, + DisplayOrder = targetOrder, + IsActive = request.IsActive + }; + + await _categoryRepository.AddAsync(entity, cancellationToken); + await _categoryRepository.SaveChangesAsync(cancellationToken); + + return MerchantMapping.ToDto(entity); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantContractCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantContractCommandHandler.cs new file mode 100644 index 0000000..0c4aecb --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantContractCommandHandler.cs @@ -0,0 +1,78 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Commands; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Domain.Merchants.Entities; +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Ids; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 创建商户合同。 +/// +public sealed class CreateMerchantContractCommandHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider, + IIdGenerator idGenerator, + ICurrentUserAccessor currentUserAccessor) + : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository = merchantRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly IIdGenerator _idGenerator = idGenerator; + private readonly ICurrentUserAccessor _currentUserAccessor = currentUserAccessor; + + public async Task Handle(CreateMerchantContractCommand request, CancellationToken cancellationToken) + { + if (request.EndDate <= request.StartDate) + { + throw new BusinessException(ErrorCodes.BadRequest, "合同结束时间必须晚于开始时间"); + } + + var tenantId = _tenantProvider.GetCurrentTenantId(); + var merchant = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在"); + + var contract = new MerchantContract + { + Id = _idGenerator.NextId(), + MerchantId = merchant.Id, + ContractNumber = request.ContractNumber.Trim(), + StartDate = request.StartDate, + EndDate = request.EndDate, + FileUrl = request.FileUrl.Trim() + }; + + await _merchantRepository.AddContractAsync(contract, cancellationToken); + await _merchantRepository.AddAuditLogAsync(new MerchantAuditLog + { + TenantId = tenantId, + MerchantId = merchant.Id, + Action = MerchantAuditAction.ContractUpdated, + Title = "新增合同", + Description = $"合同号:{contract.ContractNumber}", + OperatorId = ResolveOperatorId(), + OperatorName = ResolveOperatorName() + }, cancellationToken); + + await _merchantRepository.SaveChangesAsync(cancellationToken); + return MerchantMapping.ToDto(contract); + } + + private long? ResolveOperatorId() + { + var id = _currentUserAccessor.UserId; + return id == 0 ? null : id; + } + + private string ResolveOperatorName() + { + var id = _currentUserAccessor.UserId; + return id == 0 ? "system" : $"user:{id}"; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/DeleteMerchantCategoryCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/DeleteMerchantCategoryCommandHandler.cs new file mode 100644 index 0000000..f0084b5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/DeleteMerchantCategoryCommandHandler.cs @@ -0,0 +1,33 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Commands; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 删除类目处理器。 +/// +public sealed class DeleteMerchantCategoryCommandHandler( + IMerchantCategoryRepository categoryRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + private readonly IMerchantCategoryRepository _categoryRepository = categoryRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + public async Task Handle(DeleteMerchantCategoryCommand request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var existing = await _categoryRepository.FindByIdAsync(request.CategoryId, tenantId, cancellationToken); + + if (existing == null) + { + return false; + } + + await _categoryRepository.RemoveAsync(existing, cancellationToken); + await _categoryRepository.SaveChangesAsync(cancellationToken); + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantAuditLogsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantAuditLogsQueryHandler.cs new file mode 100644 index 0000000..f43146c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantAuditLogsQueryHandler.cs @@ -0,0 +1,35 @@ +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Application.App.Merchants.Queries; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 读取商户审核日志。 +/// +public sealed class GetMerchantAuditLogsQueryHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + private readonly IMerchantRepository _merchantRepository = merchantRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + public async Task> Handle(GetMerchantAuditLogsQuery request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var logs = await _merchantRepository.GetAuditLogsAsync(request.MerchantId, tenantId, cancellationToken); + var total = logs.Count; + var paged = logs + .Skip((request.Page - 1) * request.PageSize) + .Take(request.PageSize) + .Select(MerchantMapping.ToDto) + .ToList(); + + return new PagedResult(paged, request.Page, request.PageSize, total); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantCategoriesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantCategoriesQueryHandler.cs new file mode 100644 index 0000000..e0f9e89 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantCategoriesQueryHandler.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Queries; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 读取可选类目。 +/// +public sealed class GetMerchantCategoriesQueryHandler( + IMerchantCategoryRepository categoryRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + private readonly IMerchantCategoryRepository _categoryRepository = categoryRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + public async Task> Handle(GetMerchantCategoriesQuery request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var categories = await _categoryRepository.ListAsync(tenantId, cancellationToken); + + return categories + .Where(x => x.IsActive) + .Select(x => x.Name.Trim()) + .Where(x => x.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList() + .AsReadOnly(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantContractsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantContractsQueryHandler.cs new file mode 100644 index 0000000..50b20ab --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantContractsQueryHandler.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Application.App.Merchants.Queries; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 查询合同列表。 +/// +public sealed class GetMerchantContractsQueryHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + private readonly IMerchantRepository _merchantRepository = merchantRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + public async Task> Handle(GetMerchantContractsQuery request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + _ = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在"); + + var contracts = await _merchantRepository.GetContractsAsync(request.MerchantId, tenantId, cancellationToken); + return MerchantMapping.ToContractDtos(contracts); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDetailQueryHandler.cs new file mode 100644 index 0000000..f21f1d0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDetailQueryHandler.cs @@ -0,0 +1,38 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Application.App.Merchants.Queries; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 商户详情处理器。 +/// +public sealed class GetMerchantDetailQueryHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository = merchantRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + public async Task Handle(GetMerchantDetailQuery request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var merchant = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在"); + + var documents = await _merchantRepository.GetDocumentsAsync(request.MerchantId, tenantId, cancellationToken); + var contracts = await _merchantRepository.GetContractsAsync(request.MerchantId, tenantId, cancellationToken); + + return new MerchantDetailDto + { + Merchant = MerchantMapping.ToDto(merchant), + Documents = MerchantMapping.ToDocumentDtos(documents), + Contracts = MerchantMapping.ToContractDtos(contracts) + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDocumentsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDocumentsQueryHandler.cs new file mode 100644 index 0000000..f8ceeb4 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDocumentsQueryHandler.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Application.App.Merchants.Queries; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 查询证照列表。 +/// +public sealed class GetMerchantDocumentsQueryHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + private readonly IMerchantRepository _merchantRepository = merchantRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + public async Task> Handle(GetMerchantDocumentsQuery request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + _ = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在"); + + var documents = await _merchantRepository.GetDocumentsAsync(request.MerchantId, tenantId, cancellationToken); + return MerchantMapping.ToDocumentDtos(documents); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ListMerchantCategoriesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ListMerchantCategoriesQueryHandler.cs new file mode 100644 index 0000000..c3de6cd --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ListMerchantCategoriesQueryHandler.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Application.App.Merchants.Queries; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 列出类目。 +/// +public sealed class ListMerchantCategoriesQueryHandler( + IMerchantCategoryRepository categoryRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + private readonly IMerchantCategoryRepository _categoryRepository = categoryRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + public async Task> Handle(ListMerchantCategoriesQuery request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var categories = await _categoryRepository.ListAsync(tenantId, cancellationToken); + return MerchantMapping.ToCategoryDtos(categories); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReorderMerchantCategoriesCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReorderMerchantCategoriesCommandHandler.cs new file mode 100644 index 0000000..a9be94d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReorderMerchantCategoriesCommandHandler.cs @@ -0,0 +1,42 @@ +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Commands; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 类目排序处理器。 +/// +public sealed class ReorderMerchantCategoriesCommandHandler( + IMerchantCategoryRepository categoryRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + private readonly IMerchantCategoryRepository _categoryRepository = categoryRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + public async Task Handle(ReorderMerchantCategoriesCommand request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var categories = await _categoryRepository.ListAsync(tenantId, cancellationToken); + var map = categories.ToDictionary(x => x.Id); + + foreach (var item in request.Items) + { + if (!map.TryGetValue(item.CategoryId, out var category)) + { + throw new BusinessException(ErrorCodes.NotFound, $"类目 {item.CategoryId} 不存在"); + } + + category.DisplayOrder = item.DisplayOrder; + } + + await _categoryRepository.UpdateRangeAsync(map.Values, cancellationToken); + await _categoryRepository.SaveChangesAsync(cancellationToken); + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantCommandHandler.cs new file mode 100644 index 0000000..02ea882 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantCommandHandler.cs @@ -0,0 +1,74 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Commands; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Domain.Merchants.Entities; +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 商户审核处理器。 +/// +public sealed class ReviewMerchantCommandHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUserAccessor) + : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository = merchantRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ICurrentUserAccessor _currentUserAccessor = currentUserAccessor; + + public async Task Handle(ReviewMerchantCommand request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var merchant = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在"); + + if (request.Approve && merchant.Status == MerchantStatus.Approved) + { + return MerchantMapping.ToDto(merchant); + } + + var previousStatus = merchant.Status; + merchant.Status = request.Approve ? MerchantStatus.Approved : MerchantStatus.Rejected; + merchant.ReviewRemarks = request.Remarks; + merchant.LastReviewedAt = DateTime.UtcNow; + if (request.Approve && merchant.JoinedAt == null) + { + merchant.JoinedAt = DateTime.UtcNow; + } + + await _merchantRepository.UpdateMerchantAsync(merchant, cancellationToken); + await _merchantRepository.AddAuditLogAsync(new MerchantAuditLog + { + TenantId = tenantId, + MerchantId = merchant.Id, + Action = MerchantAuditAction.MerchantReviewed, + Title = request.Approve ? "商户审核通过" : "商户审核驳回", + Description = request.Remarks, + OperatorId = ResolveOperatorId(), + OperatorName = ResolveOperatorName() + }, cancellationToken); + await _merchantRepository.SaveChangesAsync(cancellationToken); + + return MerchantMapping.ToDto(merchant); + } + + private long? ResolveOperatorId() + { + var id = _currentUserAccessor.UserId; + return id == 0 ? null : id; + } + + private string ResolveOperatorName() + { + var id = _currentUserAccessor.UserId; + return id == 0 ? "system" : $"user:{id}"; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantDocumentCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantDocumentCommandHandler.cs new file mode 100644 index 0000000..20c1a3a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantDocumentCommandHandler.cs @@ -0,0 +1,69 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Commands; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Domain.Merchants.Entities; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 审核证照处理器。 +/// +public sealed class ReviewMerchantDocumentCommandHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUserAccessor) + : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository = merchantRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ICurrentUserAccessor _currentUserAccessor = currentUserAccessor; + + public async Task Handle(ReviewMerchantDocumentCommand request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var document = await _merchantRepository.FindDocumentByIdAsync(request.MerchantId, tenantId, request.DocumentId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "证照不存在"); + + var targetStatus = request.Approve ? MerchantDocumentStatus.Approved : MerchantDocumentStatus.Rejected; + if (document.Status == targetStatus && document.Remarks == request.Remarks) + { + return MerchantMapping.ToDto(document); + } + + document.Status = targetStatus; + document.Remarks = request.Remarks; + + await _merchantRepository.UpdateDocumentAsync(document, cancellationToken); + await _merchantRepository.AddAuditLogAsync(new MerchantAuditLog + { + TenantId = tenantId, + MerchantId = document.MerchantId, + Action = MerchantAuditAction.DocumentReviewed, + Title = request.Approve ? "证照审核通过" : "证照审核驳回", + Description = request.Remarks, + OperatorId = ResolveOperatorId(), + OperatorName = ResolveOperatorName() + }, cancellationToken); + await _merchantRepository.SaveChangesAsync(cancellationToken); + + return MerchantMapping.ToDto(document); + } + + private long? ResolveOperatorId() + { + var id = _currentUserAccessor.UserId; + return id == 0 ? null : id; + } + + private string ResolveOperatorName() + { + var id = _currentUserAccessor.UserId; + return id == 0 ? "system" : $"user:{id}"; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantContractStatusCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantContractStatusCommandHandler.cs new file mode 100644 index 0000000..5e368f8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantContractStatusCommandHandler.cs @@ -0,0 +1,76 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Commands; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Domain.Merchants.Entities; +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 更新合同状态处理器。 +/// +public sealed class UpdateMerchantContractStatusCommandHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUserAccessor) + : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository = merchantRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ICurrentUserAccessor _currentUserAccessor = currentUserAccessor; + + public async Task Handle(UpdateMerchantContractStatusCommand request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var contract = await _merchantRepository.FindContractByIdAsync(request.MerchantId, tenantId, request.ContractId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "合同不存在"); + + if (request.Status == ContractStatus.Active) + { + contract.Status = ContractStatus.Active; + contract.SignedAt = request.SignedAt ?? DateTime.UtcNow; + } + else if (request.Status == ContractStatus.Terminated) + { + contract.Status = ContractStatus.Terminated; + contract.TerminatedAt = DateTime.UtcNow; + contract.TerminationReason = request.Reason; + } + else + { + contract.Status = request.Status; + } + + await _merchantRepository.UpdateContractAsync(contract, cancellationToken); + await _merchantRepository.AddAuditLogAsync(new MerchantAuditLog + { + TenantId = tenantId, + MerchantId = contract.MerchantId, + Action = MerchantAuditAction.ContractStatusChanged, + Title = $"合同状态变更为 {request.Status}", + Description = request.Reason, + OperatorId = ResolveOperatorId(), + OperatorName = ResolveOperatorName() + }, cancellationToken); + + await _merchantRepository.SaveChangesAsync(cancellationToken); + return MerchantMapping.ToDto(contract); + } + + private long? ResolveOperatorId() + { + var id = _currentUserAccessor.UserId; + return id == 0 ? null : id; + } + + private string ResolveOperatorName() + { + var id = _currentUserAccessor.UserId; + return id == 0 ? "system" : $"user:{id}"; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/MerchantMapping.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/MerchantMapping.cs new file mode 100644 index 0000000..8c61873 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/MerchantMapping.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; +using System.Linq; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Domain.Merchants.Entities; + +namespace TakeoutSaaS.Application.App.Merchants; + +/// +/// 商户 DTO 映射工具。 +/// +internal static class MerchantMapping +{ + public static MerchantDto ToDto(Merchant merchant) => new() + { + Id = merchant.Id, + TenantId = merchant.TenantId, + BrandName = merchant.BrandName, + BrandAlias = merchant.BrandAlias, + LogoUrl = merchant.LogoUrl, + Category = merchant.Category, + ContactPhone = merchant.ContactPhone, + ContactEmail = merchant.ContactEmail, + Status = merchant.Status, + JoinedAt = merchant.JoinedAt, + CreatedAt = merchant.CreatedAt + }; + + public static MerchantDocumentDto ToDto(MerchantDocument document) => new() + { + Id = document.Id, + MerchantId = document.MerchantId, + DocumentType = document.DocumentType, + Status = document.Status, + FileUrl = document.FileUrl, + DocumentNumber = document.DocumentNumber, + IssuedAt = document.IssuedAt, + ExpiresAt = document.ExpiresAt, + Remarks = document.Remarks, + CreatedAt = document.CreatedAt + }; + + public static MerchantContractDto ToDto(MerchantContract contract) => new() + { + Id = contract.Id, + MerchantId = contract.MerchantId, + ContractNumber = contract.ContractNumber, + Status = contract.Status, + StartDate = contract.StartDate, + EndDate = contract.EndDate, + FileUrl = contract.FileUrl, + SignedAt = contract.SignedAt, + TerminatedAt = contract.TerminatedAt, + TerminationReason = contract.TerminationReason + }; + + public static MerchantAuditLogDto ToDto(MerchantAuditLog log) => new() + { + Id = log.Id, + MerchantId = log.MerchantId, + Action = log.Action, + Title = log.Title, + Description = log.Description, + OperatorName = log.OperatorName, + CreatedAt = log.CreatedAt + }; + + public static MerchantCategoryDto ToDto(MerchantCategory category) => new() + { + Id = category.Id, + Name = category.Name, + DisplayOrder = category.DisplayOrder, + IsActive = category.IsActive, + CreatedAt = category.CreatedAt + }; + + public static IReadOnlyList ToDocumentDtos(IEnumerable documents) + => documents.Select(ToDto).ToList(); + + public static IReadOnlyList ToContractDtos(IEnumerable contracts) + => contracts.Select(ToDto).ToList(); + + public static IReadOnlyList ToCategoryDtos(IEnumerable categories) + => categories.Select(ToDto).ToList(); +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantAuditLogsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantAuditLogsQuery.cs new file mode 100644 index 0000000..e82a58c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantAuditLogsQuery.cs @@ -0,0 +1,13 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Merchants.Queries; + +/// +/// 商户审核日志查询。 +/// +public sealed record GetMerchantAuditLogsQuery( + long MerchantId, + int Page = 1, + int PageSize = 20) : IRequest>; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantCategoriesQuery.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantCategoriesQuery.cs new file mode 100644 index 0000000..c55fd86 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantCategoriesQuery.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; +using MediatR; + +namespace TakeoutSaaS.Application.App.Merchants.Queries; + +/// +/// 获取商户可选类目。 +/// +public sealed record GetMerchantCategoriesQuery() : IRequest>; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantContractsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantContractsQuery.cs new file mode 100644 index 0000000..8940465 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantContractsQuery.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; + +namespace TakeoutSaaS.Application.App.Merchants.Queries; + +/// +/// 查询商户合同。 +/// +public sealed record GetMerchantContractsQuery(long MerchantId) : IRequest>; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantDetailQuery.cs new file mode 100644 index 0000000..f3b3eaa --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantDetailQuery.cs @@ -0,0 +1,9 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; + +namespace TakeoutSaaS.Application.App.Merchants.Queries; + +/// +/// 商户详情查询。 +/// +public sealed record GetMerchantDetailQuery(long MerchantId) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantDocumentsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantDocumentsQuery.cs new file mode 100644 index 0000000..997f02a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantDocumentsQuery.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; + +namespace TakeoutSaaS.Application.App.Merchants.Queries; + +/// +/// 查询商户证照。 +/// +public sealed record GetMerchantDocumentsQuery(long MerchantId) : IRequest>; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/ListMerchantCategoriesQuery.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/ListMerchantCategoriesQuery.cs new file mode 100644 index 0000000..4e4ef4a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/ListMerchantCategoriesQuery.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; + +namespace TakeoutSaaS.Application.App.Merchants.Queries; + +/// +/// 管理端获取完整类目列表。 +/// +public sealed record ListMerchantCategoriesQuery() : IRequest>; diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantAuditLog.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantAuditLog.cs new file mode 100644 index 0000000..e41d3e6 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantAuditLog.cs @@ -0,0 +1,40 @@ +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Merchants.Entities; + +/// +/// 商户入驻审核日志。 +/// +public sealed class MerchantAuditLog : MultiTenantEntityBase +{ + /// + /// 商户标识。 + /// + public long MerchantId { get; set; } + + /// + /// 动作类型。 + /// + public MerchantAuditAction 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; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantCategory.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantCategory.cs new file mode 100644 index 0000000..d55dc26 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantCategory.cs @@ -0,0 +1,24 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Merchants.Entities; + +/// +/// 商户可选类目。 +/// +public sealed class MerchantCategory : MultiTenantEntityBase +{ + /// + /// 类目名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 显示顺序,越小越靠前。 + /// + public int DisplayOrder { get; set; } + + /// + /// 是否可用。 + /// + public bool IsActive { get; set; } = true; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/MerchantAuditAction.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/MerchantAuditAction.cs new file mode 100644 index 0000000..330f6c4 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/MerchantAuditAction.cs @@ -0,0 +1,37 @@ +namespace TakeoutSaaS.Domain.Merchants.Enums; + +/// +/// 商户审核日志动作。 +/// +public enum MerchantAuditAction +{ + /// + /// 提交入驻申请或资料。 + /// + ApplicationSubmitted = 0, + + /// + /// 上传/更新证照。 + /// + DocumentUploaded = 1, + + /// + /// 证照审核。 + /// + DocumentReviewed = 2, + + /// + /// 合同创建或更新。 + /// + ContractUpdated = 3, + + /// + /// 合同状态变更(生效/终止)。 + /// + ContractStatusChanged = 4, + + /// + /// 商户审核结果。 + /// + MerchantReviewed = 5 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantCategoryRepository.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantCategoryRepository.cs new file mode 100644 index 0000000..f8669d6 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantCategoryRepository.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Merchants.Entities; + +namespace TakeoutSaaS.Domain.Merchants.Repositories; + +/// +/// 商户类目仓储契约。 +/// +public interface IMerchantCategoryRepository +{ + /// + /// 列出当前租户的类目。 + /// + Task> ListAsync(long tenantId, CancellationToken cancellationToken = default); + + /// + /// 是否存在同名类目。 + /// + Task ExistsAsync(string name, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 查找类目。 + /// + Task FindByIdAsync(long id, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 新增类目。 + /// + Task AddAsync(MerchantCategory category, CancellationToken cancellationToken = default); + + /// + /// 删除类目。 + /// + Task RemoveAsync(MerchantCategory category, CancellationToken cancellationToken = default); + + /// + /// 批量更新类目信息。 + /// + Task UpdateRangeAsync(IEnumerable categories, CancellationToken cancellationToken = default); + + /// + /// 持久化更改。 + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantRepository.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantRepository.cs index 9e8710a..c467a2e 100644 --- a/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantRepository.cs @@ -30,11 +30,13 @@ public interface IMerchantRepository /// 获取指定商户的合同列表。 /// Task> GetContractsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default); + Task FindContractByIdAsync(long merchantId, long tenantId, long contractId, CancellationToken cancellationToken = default); /// /// 获取指定商户的资质文件列表。 /// Task> GetDocumentsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default); + Task FindDocumentByIdAsync(long merchantId, long tenantId, long documentId, CancellationToken cancellationToken = default); /// /// 新增商户主体。 @@ -50,11 +52,13 @@ public interface IMerchantRepository /// 新增商户合同。 /// Task AddContractAsync(MerchantContract contract, CancellationToken cancellationToken = default); + Task UpdateContractAsync(MerchantContract contract, CancellationToken cancellationToken = default); /// /// 新增商户资质文件。 /// Task AddDocumentAsync(MerchantDocument document, CancellationToken cancellationToken = default); + Task UpdateDocumentAsync(MerchantDocument document, CancellationToken cancellationToken = default); /// /// 持久化变更。 @@ -70,4 +74,14 @@ public interface IMerchantRepository /// 删除商户。 /// Task DeleteMerchantAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 记录审核日志。 + /// + Task AddAuditLogAsync(MerchantAuditLog log, CancellationToken cancellationToken = default); + + /// + /// 获取审核日志。 + /// + Task> GetAuditLogsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default); } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs index c9ce810..c9a416d 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs @@ -32,6 +32,7 @@ public static class AppServiceCollectionExtensions services.AddPostgresDbContext(DatabaseConstants.AppDataSource); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs index f107cea..78dca47 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs @@ -50,6 +50,8 @@ public sealed class TakeoutAppDbContext( public DbSet MerchantDocuments => Set(); public DbSet MerchantContracts => Set(); public DbSet MerchantStaff => Set(); + public DbSet MerchantAuditLogs => Set(); + public DbSet MerchantCategories => Set(); public DbSet Stores => Set(); public DbSet StoreBusinessHours => Set(); @@ -144,6 +146,8 @@ public sealed class TakeoutAppDbContext( ConfigureMerchantDocument(modelBuilder.Entity()); ConfigureMerchantContract(modelBuilder.Entity()); ConfigureMerchantStaff(modelBuilder.Entity()); + ConfigureMerchantAuditLog(modelBuilder.Entity()); + ConfigureMerchantCategory(modelBuilder.Entity()); ConfigureStoreBusinessHour(modelBuilder.Entity()); ConfigureStoreHoliday(modelBuilder.Entity()); ConfigureStoreDeliveryZone(modelBuilder.Entity()); @@ -499,6 +503,27 @@ public sealed class TakeoutAppDbContext( builder.HasIndex(x => new { x.TenantId, x.MerchantId, x.Phone }); } + private static void ConfigureMerchantAuditLog(EntityTypeBuilder builder) + { + builder.ToTable("merchant_audit_logs"); + builder.HasKey(x => x.Id); + builder.Property(x => x.MerchantId).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 => new { x.TenantId, x.MerchantId }); + } + + private static void ConfigureMerchantCategory(EntityTypeBuilder builder) + { + builder.ToTable("merchant_categories"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Name).HasMaxLength(64).IsRequired(); + builder.Property(x => x.DisplayOrder).HasDefaultValue(0); + builder.Property(x => x.IsActive).IsRequired(); + builder.HasIndex(x => new { x.TenantId, x.Name }).IsUnique(); + } + private static void ConfigureStoreBusinessHour(EntityTypeBuilder builder) { builder.ToTable("store_business_hours"); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantCategoryRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantCategoryRepository.cs new file mode 100644 index 0000000..8b83a3e --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantCategoryRepository.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Merchants.Entities; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// 商户类目的 EF Core 仓储实现。 +/// +public sealed class EfMerchantCategoryRepository(TakeoutAppDbContext context) + : IMerchantCategoryRepository +{ + /// + public async Task> ListAsync(long tenantId, CancellationToken cancellationToken = default) + { + var items = await context.MerchantCategories + .AsNoTracking() + .Where(x => x.TenantId == tenantId) + .OrderBy(x => x.DisplayOrder) + .ThenBy(x => x.CreatedAt) + .ToListAsync(cancellationToken); + + return items; + } + + /// + public Task ExistsAsync(string name, long tenantId, CancellationToken cancellationToken = default) + { + return context.MerchantCategories.AnyAsync( + x => x.TenantId == tenantId && x.Name == name, cancellationToken); + } + + /// + public Task FindByIdAsync(long id, long tenantId, CancellationToken cancellationToken = default) + { + return context.MerchantCategories + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == id, cancellationToken); + } + + /// + public Task AddAsync(MerchantCategory category, CancellationToken cancellationToken = default) + { + return context.MerchantCategories.AddAsync(category, cancellationToken).AsTask(); + } + + /// + public Task RemoveAsync(MerchantCategory category, CancellationToken cancellationToken = default) + { + context.MerchantCategories.Remove(category); + return Task.CompletedTask; + } + + /// + public Task UpdateRangeAsync(IEnumerable categories, CancellationToken cancellationToken = default) + { + context.MerchantCategories.UpdateRange(categories); + return Task.CompletedTask; + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs index 6158f92..35e00a8 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs @@ -67,6 +67,14 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context) : IMerchan return contracts; } + /// + public Task FindContractByIdAsync(long merchantId, long tenantId, long contractId, CancellationToken cancellationToken = default) + { + return context.MerchantContracts + .Where(x => x.TenantId == tenantId && x.MerchantId == merchantId && x.Id == contractId) + .FirstOrDefaultAsync(cancellationToken); + } + /// public async Task> GetDocumentsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default) { @@ -79,6 +87,14 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context) : IMerchan return documents; } + /// + public Task FindDocumentByIdAsync(long merchantId, long tenantId, long documentId, CancellationToken cancellationToken = default) + { + return context.MerchantDocuments + .Where(x => x.TenantId == tenantId && x.MerchantId == merchantId && x.Id == documentId) + .FirstOrDefaultAsync(cancellationToken); + } + /// public Task AddMerchantAsync(Merchant merchant, CancellationToken cancellationToken = default) { @@ -97,12 +113,26 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context) : IMerchan return context.MerchantContracts.AddAsync(contract, cancellationToken).AsTask(); } + /// + public Task UpdateContractAsync(MerchantContract contract, CancellationToken cancellationToken = default) + { + context.MerchantContracts.Update(contract); + return Task.CompletedTask; + } + /// public Task AddDocumentAsync(MerchantDocument document, CancellationToken cancellationToken = default) { return context.MerchantDocuments.AddAsync(document, cancellationToken).AsTask(); } + /// + public Task UpdateDocumentAsync(MerchantDocument document, CancellationToken cancellationToken = default) + { + context.MerchantDocuments.Update(document); + return Task.CompletedTask; + } + /// public Task SaveChangesAsync(CancellationToken cancellationToken = default) { @@ -130,4 +160,20 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context) : IMerchan context.Merchants.Remove(existing); } + + /// + public Task AddAuditLogAsync(MerchantAuditLog log, CancellationToken cancellationToken = default) + { + return context.MerchantAuditLogs.AddAsync(log, cancellationToken).AsTask(); + } + + /// + public async Task> GetAuditLogsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default) + { + return await context.MerchantAuditLogs + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.MerchantId == merchantId) + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(cancellationToken); + } } From ea33e6fefedbf15b0b208f762ce5530b7811d5d5 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Wed, 3 Dec 2025 19:55:25 +0800 Subject: [PATCH 06/30] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9ERBAC=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E6=A8=A1=E6=9D=BF=E5=A4=8D=E5=88=B6=E4=B8=8E=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Document/12_BusinessTodo.md | 4 +- Document/API边界与自检清单.md | 1 + .../Controllers/RolesController.cs | 73 +++ .../Abstractions/IRoleTemplateProvider.cs | 23 + .../Commands/CopyRoleTemplateCommand.cs | 30 ++ .../InitializeRoleTemplatesCommand.cs | 16 + .../Contracts/PermissionTemplateDto.cs | 22 + .../Identity/Contracts/RoleTemplateDto.cs | 29 ++ .../IdentityServiceCollectionExtensions.cs | 2 + .../CopyRoleTemplateCommandHandler.cs | 131 +++++ .../Handlers/GetRoleTemplateQueryHandler.cs | 20 + .../InitializeRoleTemplatesCommandHandler.cs | 57 +++ .../Handlers/ListRoleTemplatesQueryHandler.cs | 26 + .../Identity/Handlers/TemplateMapper.cs | 37 ++ .../Identity/Queries/GetRoleTemplateQuery.cs | 15 + .../Queries/ListRoleTemplatesQuery.cs | 10 + .../Templates/PermissionTemplateDefinition.cs | 22 + .../Templates/RoleTemplateDefinition.cs | 29 ++ .../Templates/RoleTemplateProvider.cs | 480 ++++++++++++++++++ .../Repositories/IPermissionRepository.cs | 1 + .../Repositories/IRolePermissionRepository.cs | 1 + .../Persistence/EfPermissionRepository.cs | 15 + .../Persistence/EfRolePermissionRepository.cs | 12 + 23 files changed, 1054 insertions(+), 2 deletions(-) create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Abstractions/IRoleTemplateProvider.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Commands/CopyRoleTemplateCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Commands/InitializeRoleTemplatesCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Contracts/PermissionTemplateDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Contracts/RoleTemplateDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Handlers/CopyRoleTemplateCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Handlers/GetRoleTemplateQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Handlers/InitializeRoleTemplatesCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Handlers/ListRoleTemplatesQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Handlers/TemplateMapper.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Queries/GetRoleTemplateQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Queries/ListRoleTemplatesQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Templates/PermissionTemplateDefinition.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateDefinition.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateProvider.cs diff --git a/Document/12_BusinessTodo.md b/Document/12_BusinessTodo.md index 5eaadc5..55128c6 100644 --- a/Document/12_BusinessTodo.md +++ b/Document/12_BusinessTodo.md @@ -8,8 +8,8 @@ - 已交付:`src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs` 暴露注册、详情、实名提交、审核、订阅创建/升降配、审核日志 8 个端点;对应命令/查询位于 `src/Application/TakeoutSaaS.Application/App/Tenants`,仓储实现 `EfTenantRepository`,并写入 `TenantAuditLog` 记录。Swagger 自动收录上述接口,满足 Phase1 租户管理要求。 - [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`),但没有“模板复制”或按租户批量初始化的接口。 +- [x] RBAC 模板:平台管理员、租户管理员、店长、店员四角色模板;API 可复制并允许租户自定义扩展。 + - 已交付:新增模板目录 `RoleTemplateProvider`(`src/Application/TakeoutSaaS.Application/Identity/Templates`),提供四个预置角色与权限定义;应用层新增模板列表/详情查询、复制与租户批量初始化命令(Handlers 位于 `src/Application/TakeoutSaaS.Application/Identity/Handlers`)。管理端 `RolesController` 暴露模板列表、详情、按模板复制、批量初始化端点(`src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs`),复制时自动补齐缺失权限并保留租户自定义授权。 - [ ] 配额与套餐:TenantPackage CRUD、订阅/续费/配额校验(门店/账号/短信/配送单量),超额返回 409 并记录 TenantQuotaUsage。 - 当前:领域层已有 `TenantPackage`/`TenantSubscription` 等实体(`src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantPackage.cs:5-48`),数据库模型也同步生成,但 Admin API/应用层未暴露任何 CRUD 或配额校验逻辑。 - [ ] 租户运营面板:欠费/到期告警、账单列表、公告通知接口,支持已读状态并在 Admin UI 展示。 diff --git a/Document/API边界与自检清单.md b/Document/API边界与自检清单.md index d1fa76d..57bf98e 100644 --- a/Document/API边界与自检清单.md +++ b/Document/API边界与自检清单.md @@ -15,6 +15,7 @@ 3. DTO 是否按管理口径,未暴露用户端字段? 4. 是否使用参数化/AsNoTracking/投影,避免 N+1? 5. 路由和 Swagger 示例是否含租户/权限说明? +- **自检记录**:RolesController 新增模板列表/详情/复制/初始化端点,均已套用 `[Authorize]` + `PermissionAuthorize`、仅调用 CQRS/DTO,依赖租户头隔离。 ## 2. UserApi(C 端用户) - **面向对象**:App/H5 普通用户。 diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs index a9f7148..850e5df 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using MediatR; using Microsoft.AspNetCore.Authorization; @@ -20,6 +21,78 @@ namespace TakeoutSaaS.AdminApi.Controllers; [Route("api/admin/v{version:apiVersion}/roles")] public sealed class RolesController(IMediator mediator) : BaseApiController { + /// + /// 获取预置角色模板列表。 + /// + /// + /// 示例:GET /api/admin/v1/roles/templates + /// + [HttpGet("templates")] + [PermissionAuthorize("identity:role:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> ListTemplates(CancellationToken cancellationToken) + { + var result = await mediator.Send(new ListRoleTemplatesQuery(), cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 获取单个角色模板详情。 + /// + /// + /// 示例:GET /api/admin/v1/roles/templates/tenant-admin + /// + [HttpGet("templates/{templateCode}")] + [PermissionAuthorize("identity:role:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> GetTemplate(string templateCode, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetRoleTemplateQuery { TemplateCode = templateCode }, cancellationToken); + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "角色模板不存在") + : ApiResponse.Ok(result); + } + + /// + /// 按模板复制角色并绑定权限。 + /// + /// + /// 示例:POST /api/admin/v1/roles/templates/store-manager/copy + /// Body: { "roleName": "新区店长" } + /// + [HttpPost("templates/{templateCode}/copy")] + [PermissionAuthorize("identity:role:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CopyFromTemplate( + string templateCode, + [FromBody, Required] CopyRoleTemplateCommand command, + CancellationToken cancellationToken) + { + command = command with { TemplateCode = templateCode }; + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 为当前租户批量初始化预置角色模板。 + /// + /// + /// 示例:POST /api/admin/v1/roles/templates/init + /// Body: { "templateCodes": ["tenant-admin","store-manager","store-staff"] } + /// + [HttpPost("templates/init")] + [PermissionAuthorize("identity:role:create")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> InitializeTemplates( + [FromBody] InitializeRoleTemplatesCommand? command, + CancellationToken cancellationToken) + { + command ??= new InitializeRoleTemplatesCommand(); + var result = await mediator.Send(command, cancellationToken); + return ApiResponse>.Ok(result); + } + /// /// 分页查询角色。 /// diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IRoleTemplateProvider.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IRoleTemplateProvider.cs new file mode 100644 index 0000000..6f183ca --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IRoleTemplateProvider.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using TakeoutSaaS.Application.Identity.Templates; + +namespace TakeoutSaaS.Application.Identity.Abstractions; + +/// +/// 角色模板提供者,用于获取预置模板定义。 +/// +public interface IRoleTemplateProvider +{ + /// + /// 获取全部角色模板定义。 + /// + /// 模板定义集合。 + IReadOnlyList GetTemplates(); + + /// + /// 根据模板编码查找模板。 + /// + /// 模板编码。 + /// 模板定义;不存在时返回 null。 + RoleTemplateDefinition? FindByCode(string templateCode); +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/CopyRoleTemplateCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/CopyRoleTemplateCommand.cs new file mode 100644 index 0000000..efaa1a3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/CopyRoleTemplateCommand.cs @@ -0,0 +1,30 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 从预置模板复制角色并绑定权限。 +/// +public sealed record CopyRoleTemplateCommand : IRequest +{ + /// + /// 模板编码。 + /// + public string TemplateCode { get; init; } = string.Empty; + + /// + /// 复制后角色名称(为空则使用模板名称)。 + /// + public string? RoleName { get; init; } + + /// + /// 复制后角色编码(为空则使用模板编码)。 + /// + public string? RoleCode { get; init; } + + /// + /// 角色描述(为空则沿用模板描述)。 + /// + public string? Description { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/InitializeRoleTemplatesCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/InitializeRoleTemplatesCommand.cs new file mode 100644 index 0000000..dc1a583 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/InitializeRoleTemplatesCommand.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 批量为当前租户初始化角色模板。 +/// +public sealed record InitializeRoleTemplatesCommand : IRequest> +{ + /// + /// 需要初始化的模板编码列表(为空则全部)。 + /// + public IReadOnlyCollection? TemplateCodes { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/PermissionTemplateDto.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/PermissionTemplateDto.cs new file mode 100644 index 0000000..e3e1f34 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/PermissionTemplateDto.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Application.Identity.Contracts; + +/// +/// 权限模板 DTO。 +/// +public sealed record PermissionTemplateDto +{ + /// + /// 权限编码。 + /// + public string Code { get; init; } = string.Empty; + + /// + /// 权限名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 权限描述。 + /// + public string? Description { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/RoleTemplateDto.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/RoleTemplateDto.cs new file mode 100644 index 0000000..fc43f5b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/RoleTemplateDto.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; + +namespace TakeoutSaaS.Application.Identity.Contracts; + +/// +/// 角色模板 DTO。 +/// +public sealed record RoleTemplateDto +{ + /// + /// 模板编码。 + /// + public string TemplateCode { get; init; } = string.Empty; + + /// + /// 模板名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 模板描述。 + /// + public string? Description { get; init; } + + /// + /// 包含的权限定义。 + /// + public IReadOnlyList Permissions { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Extensions/IdentityServiceCollectionExtensions.cs b/src/Application/TakeoutSaaS.Application/Identity/Extensions/IdentityServiceCollectionExtensions.cs index c5df667..f680ef7 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Extensions/IdentityServiceCollectionExtensions.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Extensions/IdentityServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Services; +using TakeoutSaaS.Application.Identity.Templates; namespace TakeoutSaaS.Application.Identity.Extensions; @@ -17,6 +18,7 @@ public static class IdentityServiceCollectionExtensions public static IServiceCollection AddIdentityApplication(this IServiceCollection services, bool enableMiniSupport = false) { services.AddScoped(); + services.AddSingleton(); if (enableMiniSupport) { diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CopyRoleTemplateCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CopyRoleTemplateCommandHandler.cs new file mode 100644 index 0000000..d5da9be --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CopyRoleTemplateCommandHandler.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.Identity.Abstractions; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Domain.Identity.Entities; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 角色模板复制处理器。 +/// +public sealed class CopyRoleTemplateCommandHandler( + IRoleTemplateProvider roleTemplateProvider, + IRoleRepository roleRepository, + IPermissionRepository permissionRepository, + IRolePermissionRepository rolePermissionRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(CopyRoleTemplateCommand request, CancellationToken cancellationToken) + { + var template = roleTemplateProvider.FindByCode(request.TemplateCode) + ?? throw new BusinessException(ErrorCodes.NotFound, $"角色模板 {request.TemplateCode} 不存在"); + + var tenantId = tenantProvider.GetCurrentTenantId(); + var roleCode = string.IsNullOrWhiteSpace(request.RoleCode) ? template.TemplateCode : request.RoleCode.Trim(); + var roleName = string.IsNullOrWhiteSpace(request.RoleName) ? template.Name : request.RoleName.Trim(); + var roleDescription = request.Description ?? template.Description; + + // 1. 准备或更新角色主体(幂等创建)。 + var role = await roleRepository.FindByCodeAsync(roleCode, tenantId, cancellationToken); + if (role is null) + { + role = new Role + { + TenantId = tenantId, + Name = roleName, + Code = roleCode, + Description = roleDescription + }; + await roleRepository.AddAsync(role, cancellationToken); + } + else + { + if (!string.IsNullOrWhiteSpace(request.RoleName)) + { + role.Name = roleName; + } + + if (request.Description is not null) + { + role.Description = roleDescription; + } + + await roleRepository.UpdateAsync(role, cancellationToken); + } + + // 2. 确保模板权限全部存在,不存在则按模板定义创建。 + var targetPermissionCodes = template.Permissions + .Select(permission => permission.Code) + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var existingPermissions = await permissionRepository.GetByCodesAsync(tenantId, targetPermissionCodes, cancellationToken); + var permissionMap = existingPermissions.ToDictionary(x => x.Code, StringComparer.OrdinalIgnoreCase); + + foreach (var permissionDefinition in template.Permissions) + { + if (permissionMap.ContainsKey(permissionDefinition.Code)) + { + continue; + } + + var permission = new Permission + { + TenantId = tenantId, + Name = permissionDefinition.Name, + Code = permissionDefinition.Code, + Description = permissionDefinition.Description + }; + + await permissionRepository.AddAsync(permission, cancellationToken); + permissionMap[permissionDefinition.Code] = permission; + } + + await roleRepository.SaveChangesAsync(cancellationToken); + + // 3. 绑定缺失的权限,保留租户自定义的已有授权。 + var rolePermissions = await rolePermissionRepository.GetByRoleIdsAsync(tenantId, new[] { role.Id }, cancellationToken); + var existingPermissionIds = rolePermissions + .Select(x => x.PermissionId) + .ToHashSet(); + + var targetPermissionIds = targetPermissionCodes + .Select(code => permissionMap[code].Id) + .ToHashSet(); + + var toAdd = targetPermissionIds.Except(existingPermissionIds).ToArray(); + if (toAdd.Length > 0) + { + var relations = toAdd.Select(permissionId => new RolePermission + { + TenantId = tenantId, + RoleId = role.Id, + PermissionId = permissionId + }); + + await rolePermissionRepository.AddRangeAsync(relations, cancellationToken); + } + + await rolePermissionRepository.SaveChangesAsync(cancellationToken); + + return new RoleDto + { + Id = role.Id, + TenantId = role.TenantId, + Name = role.Name, + Code = role.Code, + Description = role.Description + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetRoleTemplateQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetRoleTemplateQueryHandler.cs new file mode 100644 index 0000000..70206e7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetRoleTemplateQueryHandler.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Abstractions; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Application.Identity.Queries; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 角色模板详情查询处理器。 +/// +public sealed class GetRoleTemplateQueryHandler(IRoleTemplateProvider roleTemplateProvider) + : IRequestHandler +{ + /// + public Task Handle(GetRoleTemplateQuery request, CancellationToken cancellationToken) + { + var template = roleTemplateProvider.FindByCode(request.TemplateCode); + return Task.FromResult(template is null ? null : TemplateMapper.ToDto(template)); + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/InitializeRoleTemplatesCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/InitializeRoleTemplatesCommandHandler.cs new file mode 100644 index 0000000..765819d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/InitializeRoleTemplatesCommandHandler.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.Identity.Abstractions; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 租户角色模板批量初始化处理器。 +/// +public sealed class InitializeRoleTemplatesCommandHandler( + IRoleTemplateProvider roleTemplateProvider, + IMediator mediator) + : IRequestHandler> +{ + /// + public async Task> Handle(InitializeRoleTemplatesCommand request, CancellationToken cancellationToken) + { + // 1. 解析需要初始化的模板编码,默认取全部预置模板。 + var requestedCodes = request.TemplateCodes? + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Select(code => code.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var targetCodes = requestedCodes?.Length > 0 + ? requestedCodes + : roleTemplateProvider.GetTemplates().Select(template => template.TemplateCode).ToArray(); + + if (targetCodes.Length == 0) + { + return Array.Empty(); + } + + // 2. 逐个复制模板,幂等写入角色与权限。 + var roles = new List(targetCodes.Length); + foreach (var templateCode in targetCodes) + { + var template = roleTemplateProvider.FindByCode(templateCode) + ?? throw new BusinessException(ErrorCodes.NotFound, $"角色模板 {templateCode} 不存在"); + + var role = await mediator.Send(new CopyRoleTemplateCommand + { + TemplateCode = template.TemplateCode + }, cancellationToken); + + roles.Add(role); + } + + return roles; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/ListRoleTemplatesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ListRoleTemplatesQueryHandler.cs new file mode 100644 index 0000000..011029a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ListRoleTemplatesQueryHandler.cs @@ -0,0 +1,26 @@ +using System; +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.Identity.Abstractions; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Application.Identity.Queries; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 角色模板列表查询处理器。 +/// +public sealed class ListRoleTemplatesQueryHandler(IRoleTemplateProvider roleTemplateProvider) + : IRequestHandler> +{ + /// + public Task> Handle(ListRoleTemplatesQuery request, CancellationToken cancellationToken) + { + var templates = roleTemplateProvider.GetTemplates() + .OrderBy(template => template.TemplateCode, StringComparer.OrdinalIgnoreCase) + .Select(TemplateMapper.ToDto) + .ToArray(); + + return Task.FromResult>(templates); + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/TemplateMapper.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/TemplateMapper.cs new file mode 100644 index 0000000..47b9670 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/TemplateMapper.cs @@ -0,0 +1,37 @@ +using System.Linq; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Application.Identity.Templates; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 模板 DTO 映射工具。 +/// +internal static class TemplateMapper +{ + /// + /// 将角色模板定义映射为 DTO。 + /// + /// 角色模板定义。 + /// 模板 DTO。 + public static RoleTemplateDto ToDto(RoleTemplateDefinition definition) + { + return new RoleTemplateDto + { + TemplateCode = definition.TemplateCode, + Name = definition.Name, + Description = definition.Description, + Permissions = definition.Permissions.Select(ToDto).ToArray() + }; + } + + private static PermissionTemplateDto ToDto(PermissionTemplateDefinition definition) + { + return new PermissionTemplateDto + { + Code = definition.Code, + Name = definition.Name, + Description = definition.Description + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Queries/GetRoleTemplateQuery.cs b/src/Application/TakeoutSaaS.Application/Identity/Queries/GetRoleTemplateQuery.cs new file mode 100644 index 0000000..b282312 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Queries/GetRoleTemplateQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Queries; + +/// +/// 获取单个角色模板详情。 +/// +public sealed record GetRoleTemplateQuery : IRequest +{ + /// + /// 模板编码。 + /// + public string TemplateCode { get; init; } = string.Empty; +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Queries/ListRoleTemplatesQuery.cs b/src/Application/TakeoutSaaS.Application/Identity/Queries/ListRoleTemplatesQuery.cs new file mode 100644 index 0000000..cf25f1e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Queries/ListRoleTemplatesQuery.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Queries; + +/// +/// 查询角色模板列表。 +/// +public sealed record ListRoleTemplatesQuery : IRequest>; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Templates/PermissionTemplateDefinition.cs b/src/Application/TakeoutSaaS.Application/Identity/Templates/PermissionTemplateDefinition.cs new file mode 100644 index 0000000..2bb4a46 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Templates/PermissionTemplateDefinition.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Application.Identity.Templates; + +/// +/// 权限模板定义。 +/// +public sealed record PermissionTemplateDefinition +{ + /// + /// 权限编码(唯一键)。 + /// + public required string Code { get; init; } + + /// + /// 权限名称。 + /// + public required string Name { get; init; } + + /// + /// 权限描述。 + /// + public string? Description { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateDefinition.cs b/src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateDefinition.cs new file mode 100644 index 0000000..6c4f3cc --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateDefinition.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; + +namespace TakeoutSaaS.Application.Identity.Templates; + +/// +/// 角色模板定义。 +/// +public sealed record RoleTemplateDefinition +{ + /// + /// 模板编码(唯一键)。 + /// + public required string TemplateCode { get; init; } + + /// + /// 角色名称。 + /// + public required string Name { get; init; } + + /// + /// 角色描述。 + /// + public string? Description { get; init; } + + /// + /// 模板绑定的权限集合。 + /// + public IReadOnlyList Permissions { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateProvider.cs b/src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateProvider.cs new file mode 100644 index 0000000..e492fd0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateProvider.cs @@ -0,0 +1,480 @@ +using System; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Linq; +using TakeoutSaaS.Application.Identity.Abstractions; + +namespace TakeoutSaaS.Application.Identity.Templates; + +/// +/// 预置角色模板提供者。 +/// +public sealed class RoleTemplateProvider : IRoleTemplateProvider +{ + private static readonly FrozenDictionary PermissionDefinitions = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["identity:role:read"] = new() + { + Code = "identity:role:read", + Name = "角色查询", + Description = "查看当前租户的角色列表与详情。" + }, + ["identity:role:create"] = new() + { + Code = "identity:role:create", + Name = "角色创建", + Description = "在当前租户内创建新角色。" + }, + ["identity:role:update"] = new() + { + Code = "identity:role:update", + Name = "角色更新", + Description = "修改角色名称与描述。" + }, + ["identity:role:delete"] = new() + { + Code = "identity:role:delete", + Name = "角色删除", + Description = "删除租户内的角色记录。" + }, + ["identity:role:bind-permission"] = new() + { + Code = "identity:role:bind-permission", + Name = "角色权限绑定", + Description = "为角色绑定或调整权限集合。" + }, + ["identity:permission:read"] = new() + { + Code = "identity:permission:read", + Name = "权限查询", + Description = "查看权限列表与详情。" + }, + ["identity:permission:create"] = new() + { + Code = "identity:permission:create", + Name = "权限创建", + Description = "创建新的权限定义。" + }, + ["identity:permission:update"] = new() + { + Code = "identity:permission:update", + Name = "权限更新", + Description = "修改权限名称或描述。" + }, + ["identity:permission:delete"] = new() + { + Code = "identity:permission:delete", + Name = "权限删除", + Description = "删除租户内的权限记录。" + }, + ["identity:profile:read"] = new() + { + Code = "identity:profile:read", + Name = "个人信息查看", + Description = "查看当前登录人的账号与权限信息。" + }, + ["tenant:create"] = new() + { + Code = "tenant:create", + Name = "租户创建", + Description = "创建新的租户记录。" + }, + ["tenant:read"] = new() + { + Code = "tenant:read", + Name = "租户查询", + Description = "查看租户列表与详情。" + }, + ["tenant:review"] = new() + { + Code = "tenant:review", + Name = "租户审核", + Description = "审核租户实名与资质信息。" + }, + ["tenant:subscription"] = new() + { + Code = "tenant:subscription", + Name = "租户订阅管理", + Description = "创建、续费或调整租户套餐订阅。" + }, + ["merchant:create"] = new() + { + Code = "merchant:create", + Name = "商户创建", + Description = "创建或提交商户入驻信息。" + }, + ["merchant:read"] = new() + { + Code = "merchant:read", + Name = "商户查看", + Description = "查看商户资料与审核状态。" + }, + ["merchant:update"] = new() + { + Code = "merchant:update", + Name = "商户更新", + Description = "更新商户资料或合同等信息。" + }, + ["merchant:delete"] = new() + { + Code = "merchant:delete", + Name = "商户删除", + Description = "删除或作废商户记录。" + }, + ["merchant:review"] = new() + { + Code = "merchant:review", + Name = "商户审核", + Description = "审核商户入驻、合同与证照。" + }, + ["merchant_category:read"] = new() + { + Code = "merchant_category:read", + Name = "类目查询", + Description = "查看经营类目列表。" + }, + ["merchant_category:create"] = new() + { + Code = "merchant_category:create", + Name = "类目创建", + Description = "新增经营类目。" + }, + ["merchant_category:update"] = new() + { + Code = "merchant_category:update", + Name = "类目更新", + Description = "调整经营类目名称或顺序。" + }, + ["merchant_category:delete"] = new() + { + Code = "merchant_category:delete", + Name = "类目删除", + Description = "删除经营类目。" + }, + ["store:create"] = new() + { + Code = "store:create", + Name = "门店创建", + Description = "创建新的门店记录。" + }, + ["store:read"] = new() + { + Code = "store:read", + Name = "门店查看", + Description = "查看门店列表与详情。" + }, + ["store:update"] = new() + { + Code = "store:update", + Name = "门店更新", + Description = "更新门店资料与配置。" + }, + ["store:delete"] = new() + { + Code = "store:delete", + Name = "门店删除", + Description = "删除或停用门店。" + }, + ["product:create"] = new() + { + Code = "product:create", + Name = "商品创建", + Description = "创建商品、菜品或规格。" + }, + ["product:read"] = new() + { + Code = "product:read", + Name = "商品查看", + Description = "查看商品/菜品列表与详情。" + }, + ["product:update"] = new() + { + Code = "product:update", + Name = "商品更新", + Description = "更新商品、菜品或上下架状态。" + }, + ["product:delete"] = new() + { + Code = "product:delete", + Name = "商品删除", + Description = "删除商品或菜品。" + }, + ["order:create"] = new() + { + Code = "order:create", + Name = "订单创建", + Description = "创建订单或手工录单。" + }, + ["order:read"] = new() + { + Code = "order:read", + Name = "订单查看", + Description = "查看订单列表与详情。" + }, + ["order:update"] = new() + { + Code = "order:update", + Name = "订单更新", + Description = "修改订单状态或履约信息。" + }, + ["order:delete"] = new() + { + Code = "order:delete", + Name = "订单删除", + Description = "删除或作废订单。" + }, + ["delivery:create"] = new() + { + Code = "delivery:create", + Name = "配送创建", + Description = "创建或发起配送单。" + }, + ["delivery:read"] = new() + { + Code = "delivery:read", + Name = "配送查看", + Description = "查看配送订单与轨迹。" + }, + ["delivery:update"] = new() + { + Code = "delivery:update", + Name = "配送更新", + Description = "更新配送状态或骑手信息。" + }, + ["delivery:delete"] = new() + { + Code = "delivery:delete", + Name = "配送删除", + Description = "取消或删除配送单。" + }, + ["payment:create"] = new() + { + Code = "payment:create", + Name = "支付创建", + Description = "创建收款或支付记录。" + }, + ["payment:read"] = new() + { + Code = "payment:read", + Name = "支付查看", + Description = "查看支付、退款记录。" + }, + ["payment:update"] = new() + { + Code = "payment:update", + Name = "支付更新", + Description = "更新支付状态或补充信息。" + }, + ["payment:delete"] = new() + { + Code = "payment:delete", + Name = "支付删除", + Description = "删除或作废支付记录。" + }, + ["dictionary:group:read"] = new() + { + Code = "dictionary:group:read", + Name = "字典分组查询", + Description = "查看字典分组与明细。" + }, + ["dictionary:group:create"] = new() + { + Code = "dictionary:group:create", + Name = "字典分组创建", + Description = "新增字典分组。" + }, + ["dictionary:group:update"] = new() + { + Code = "dictionary:group:update", + Name = "字典分组更新", + Description = "修改字典分组信息。" + }, + ["dictionary:group:delete"] = new() + { + Code = "dictionary:group:delete", + Name = "字典分组删除", + Description = "删除字典分组。" + }, + ["dictionary:item:create"] = new() + { + Code = "dictionary:item:create", + Name = "字典项创建", + Description = "新增字典项。" + }, + ["dictionary:item:update"] = new() + { + Code = "dictionary:item:update", + Name = "字典项更新", + Description = "调整字典项内容。" + }, + ["dictionary:item:delete"] = new() + { + Code = "dictionary:item:delete", + Name = "字典项删除", + Description = "删除字典项。" + }, + ["system-parameter:create"] = new() + { + Code = "system-parameter:create", + Name = "系统参数创建", + Description = "新增系统参数配置。" + }, + ["system-parameter:read"] = new() + { + Code = "system-parameter:read", + Name = "系统参数查询", + Description = "查看系统参数列表与详情。" + }, + ["system-parameter:update"] = new() + { + Code = "system-parameter:update", + Name = "系统参数更新", + Description = "更新系统参数配置。" + }, + ["system-parameter:delete"] = new() + { + Code = "system-parameter:delete", + Name = "系统参数删除", + Description = "删除系统参数配置。" + } + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + + private static readonly FrozenDictionary Templates = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["platform-admin"] = new() + { + TemplateCode = "platform-admin", + Name = "平台管理员", + Description = "平台全量权限,负责租户、商户、配置与运维。", + Permissions = BuildPermissions(PermissionDefinitions.Keys) + }, + ["tenant-admin"] = new() + { + TemplateCode = "tenant-admin", + Name = "租户管理员", + Description = "管理本租户的门店、商品、订单与团队权限。", + Permissions = BuildPermissions(new[] + { + "identity:profile:read", + "identity:role:read", + "identity:role:create", + "identity:role:update", + "identity:role:delete", + "identity:role:bind-permission", + "identity:permission:read", + "identity:permission:create", + "identity:permission:update", + "identity:permission:delete", + "tenant:read", + "tenant:subscription", + "merchant:read", + "merchant:update", + "merchant_category:read", + "merchant_category:create", + "merchant_category:update", + "merchant_category:delete", + "store:create", + "store:read", + "store:update", + "store:delete", + "product:create", + "product:read", + "product:update", + "product:delete", + "order:create", + "order:read", + "order:update", + "delivery:create", + "delivery:read", + "delivery:update", + "payment:create", + "payment:read", + "payment:update", + "dictionary:group:read", + "dictionary:group:create", + "dictionary:group:update", + "dictionary:group:delete", + "dictionary:item:create", + "dictionary:item:update", + "dictionary:item:delete", + "system-parameter:read" + }) + }, + ["store-manager"] = new() + { + TemplateCode = "store-manager", + Name = "店长", + Description = "负责门店日常运营、商品与订单管理。", + Permissions = BuildPermissions(new[] + { + "identity:profile:read", + "store:read", + "store:update", + "product:create", + "product:read", + "product:update", + "order:create", + "order:read", + "order:update", + "delivery:read", + "delivery:update", + "payment:read", + "payment:update", + "dictionary:group:read", + "dictionary:item:create", + "dictionary:item:update", + "dictionary:item:delete" + }) + }, + ["store-staff"] = new() + { + TemplateCode = "store-staff", + Name = "店员", + Description = "处理订单履约、配送跟踪与收款查询。", + Permissions = BuildPermissions(new[] + { + "identity:profile:read", + "store:read", + "product:read", + "order:read", + "order:update", + "delivery:read", + "payment:read" + }) + } + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + + /// + public IReadOnlyList GetTemplates() + => Templates.Values.ToArray(); + + /// + public RoleTemplateDefinition? FindByCode(string templateCode) + { + if (string.IsNullOrWhiteSpace(templateCode)) + { + return null; + } + + return Templates.GetValueOrDefault(templateCode); + } + + private static IReadOnlyList BuildPermissions(IEnumerable codes) + { + return codes + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Select(code => code.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(code => PermissionDefinitions.TryGetValue(code, out var definition) + ? definition + : new PermissionTemplateDefinition + { + Code = code, + Name = code, + Description = "未在预置表中定义的权限,请补充描述。" + }) + .ToArray(); + } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IPermissionRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IPermissionRepository.cs index 2bf97a0..7f78dde 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IPermissionRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IPermissionRepository.cs @@ -12,6 +12,7 @@ public interface IPermissionRepository { Task FindByIdAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default); Task FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default); + Task> GetByCodesAsync(long tenantId, IEnumerable codes, CancellationToken cancellationToken = default); Task> GetByIdsAsync(long tenantId, IEnumerable permissionIds, CancellationToken cancellationToken = default); Task> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default); Task AddAsync(Permission permission, CancellationToken cancellationToken = default); diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRolePermissionRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRolePermissionRepository.cs index 6ace0ce..5ef9d8d 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRolePermissionRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRolePermissionRepository.cs @@ -11,6 +11,7 @@ namespace TakeoutSaaS.Domain.Identity.Repositories; public interface IRolePermissionRepository { Task> GetByRoleIdsAsync(long tenantId, IEnumerable roleIds, CancellationToken cancellationToken = default); + Task AddRangeAsync(IEnumerable rolePermissions, CancellationToken cancellationToken = default); Task ReplaceRolePermissionsAsync(long tenantId, long roleId, IEnumerable permissionIds, CancellationToken cancellationToken = default); Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfPermissionRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfPermissionRepository.cs index c07f15d..37b7a0d 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfPermissionRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfPermissionRepository.cs @@ -1,3 +1,4 @@ +using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Identity.Entities; using TakeoutSaaS.Domain.Identity.Repositories; @@ -15,6 +16,20 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi public Task FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default) => dbContext.Permissions.AsNoTracking().FirstOrDefaultAsync(x => x.Code == code && x.TenantId == tenantId, cancellationToken); + public Task> GetByCodesAsync(long tenantId, IEnumerable codes, CancellationToken cancellationToken = default) + { + var normalizedCodes = codes + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Select(code => code.Trim()) + .Distinct() + .ToArray(); + + return dbContext.Permissions.AsNoTracking() + .Where(x => x.TenantId == tenantId && normalizedCodes.Contains(x.Code)) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + } + public Task> GetByIdsAsync(long tenantId, IEnumerable permissionIds, CancellationToken cancellationToken = default) => dbContext.Permissions.AsNoTracking() .Where(x => x.TenantId == tenantId && permissionIds.Contains(x.Id)) diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRolePermissionRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRolePermissionRepository.cs index 0409fff..30376da 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRolePermissionRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRolePermissionRepository.cs @@ -1,3 +1,4 @@ +using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Identity.Entities; using TakeoutSaaS.Domain.Identity.Repositories; @@ -15,6 +16,17 @@ public sealed class EfRolePermissionRepository(IdentityDbContext dbContext) : IR .ToListAsync(cancellationToken) .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + public async Task AddRangeAsync(IEnumerable rolePermissions, CancellationToken cancellationToken = default) + { + var toAdd = rolePermissions as RolePermission[] ?? rolePermissions.ToArray(); + if (toAdd.Length == 0) + { + return; + } + + await dbContext.RolePermissions.AddRangeAsync(toAdd, cancellationToken); + } + public async Task ReplaceRolePermissionsAsync(long tenantId, long roleId, IEnumerable permissionIds, CancellationToken cancellationToken = default) { var existing = await dbContext.RolePermissions From 19137f3cf75bc58b7f1494f679c17fd9ac05dcca Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Wed, 3 Dec 2025 20:17:55 +0800 Subject: [PATCH 07/30] =?UTF-8?q?feat:=20=E5=A5=97=E9=A4=90=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E4=B8=8E=E9=85=8D=E9=A2=9D=E6=A0=A1=E9=AA=8C=E8=83=BD?= =?UTF-8?q?=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Document/12_BusinessTodo.md | 4 +- Document/API边界与自检清单.md | 4 +- .../Controllers/TenantPackagesController.cs | 90 +++++++++++++ .../Controllers/TenantsController.cs | 18 +++ .../Commands/CheckTenantQuotaCommand.cs | 26 ++++ .../Commands/CreateTenantPackageCommand.cs | 71 ++++++++++ .../Commands/DeleteTenantPackageCommand.cs | 14 ++ .../Commands/UpdateTenantPackageCommand.cs | 76 +++++++++++ .../App/Tenants/Dto/QuotaCheckResultDto.cs | 29 ++++ .../App/Tenants/Dto/TenantPackageDto.cs | 77 +++++++++++ .../CheckTenantQuotaCommandHandler.cs | 127 ++++++++++++++++++ .../CreateTenantPackageCommandHandler.cs | 46 +++++++ .../DeleteTenantPackageCommandHandler.cs | 20 +++ .../GetTenantPackageByIdQueryHandler.cs | 20 +++ .../SearchTenantPackagesQueryHandler.cs | 33 +++++ .../UpdateTenantPackageCommandHandler.cs | 48 +++++++ .../Queries/GetTenantPackageByIdQuery.cs | 15 +++ .../Queries/SearchTenantPackagesQuery.cs | 31 +++++ .../App/Tenants/TenantMapping.cs | 18 +++ .../Templates/RoleTemplateProvider.cs | 31 +++++ .../Repositories/ITenantPackageRepository.cs | 42 ++++++ .../ITenantQuotaUsageRepository.cs | 38 ++++++ .../AppServiceCollectionExtensions.cs | 2 + .../Repositories/EfTenantPackageRepository.cs | 69 ++++++++++ .../EfTenantQuotaUsageRepository.cs | 51 +++++++ 25 files changed, 996 insertions(+), 4 deletions(-) create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CheckTenantQuotaCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantPackageCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/DeleteTenantPackageCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantPackageCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Dto/QuotaCheckResultDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantPackageDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CheckTenantQuotaCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantPackageCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantPackageCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageByIdQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantPackagesQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantPackageCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantPackageByIdQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantPackagesQuery.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPackageRepository.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantQuotaUsageRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantPackageRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantQuotaUsageRepository.cs diff --git a/Document/12_BusinessTodo.md b/Document/12_BusinessTodo.md index 55128c6..02f9c18 100644 --- a/Document/12_BusinessTodo.md +++ b/Document/12_BusinessTodo.md @@ -10,8 +10,8 @@ - 已交付:`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` 表实现状态机追踪。 - [x] RBAC 模板:平台管理员、租户管理员、店长、店员四角色模板;API 可复制并允许租户自定义扩展。 - 已交付:新增模板目录 `RoleTemplateProvider`(`src/Application/TakeoutSaaS.Application/Identity/Templates`),提供四个预置角色与权限定义;应用层新增模板列表/详情查询、复制与租户批量初始化命令(Handlers 位于 `src/Application/TakeoutSaaS.Application/Identity/Handlers`)。管理端 `RolesController` 暴露模板列表、详情、按模板复制、批量初始化端点(`src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs`),复制时自动补齐缺失权限并保留租户自定义授权。 -- [ ] 配额与套餐:TenantPackage CRUD、订阅/续费/配额校验(门店/账号/短信/配送单量),超额返回 409 并记录 TenantQuotaUsage。 - - 当前:领域层已有 `TenantPackage`/`TenantSubscription` 等实体(`src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantPackage.cs:5-48`),数据库模型也同步生成,但 Admin API/应用层未暴露任何 CRUD 或配额校验逻辑。 +- [x] 配额与套餐:TenantPackage CRUD、订阅/续费/配额校验(门店/账号/短信/配送单量),超额返回 409 并记录 TenantQuotaUsage。 + - 已交付:新增套餐仓储与命令/查询/DTO(`src/Application/TakeoutSaaS.Application/App/Tenants`),Admin 端新增 `TenantPackagesController` 提供套餐列表/详情/创建/更新/删除接口。新增配额校验命令与租户接口 `/api/admin/v1/tenants/{id}/quotas/check`,基于当前订阅套餐限额校验并占用配额,超额抛出 409 并写入 `TenantQuotaUsage`。仓储注册于 `AddAppInfrastructure`。 - [ ] 租户运营面板:欠费/到期告警、账单列表、公告通知接口,支持已读状态并在 Admin UI 展示。 - 当前:`SystemParametersController` 仅负责普通参数 CRUD(`src/Api/TakeoutSaaS.AdminApi/Controllers/SystemParametersController.cs:15-104`),未包含租户账单、公告或通知接口。 - [ ] 门店管理:Store/StoreBusinessHour/StoreDeliveryZone/StoreHoliday CRUD 完整,含 GeoJSON 配送范围及能力开关。 diff --git a/Document/API边界与自检清单.md b/Document/API边界与自检清单.md index 57bf98e..af5bc58 100644 --- a/Document/API边界与自检清单.md +++ b/Document/API边界与自检清单.md @@ -8,14 +8,14 @@ - **鉴权**:JWT + RBAC(`[Authorize]` + `PermissionAuthorize`),必须带租户头 `X-Tenant-Id/Code`。 - **路由前缀**:`api/admin/v{version}/...`。 - **DTO/约束**:仅管理字段,禁止返回 C 端敏感信息;long -> string;严禁实体直接返回。 -- **现有控制器**:`AuthController`、`DeliveriesController`、`DictionaryController`、`FilesController`、`MerchantsController`、`OrdersController`、`PaymentsController`、`PermissionsController`、`RolesController`、`StoresController`、`SystemParametersController`、`UserPermissionsController`、`HealthController`。 +- **现有控制器**:`AuthController`、`DeliveriesController`、`DictionaryController`、`FilesController`、`MerchantsController`、`OrdersController`、`PaymentsController`、`PermissionsController`、`RolesController`、`StoresController`、`SystemParametersController`、`TenantPackagesController`、`TenantsController`、`UserPermissionsController`、`HealthController`。 - **自检清单**: 1. 是否需要权限/租户过滤?未加则补 `[Authorize]` + 租户解析。 2. 是否调用了应用层 CQRS,而非在 Controller 写业务? 3. DTO 是否按管理口径,未暴露用户端字段? 4. 是否使用参数化/AsNoTracking/投影,避免 N+1? 5. 路由和 Swagger 示例是否含租户/权限说明? -- **自检记录**:RolesController 新增模板列表/详情/复制/初始化端点,均已套用 `[Authorize]` + `PermissionAuthorize`、仅调用 CQRS/DTO,依赖租户头隔离。 +- **自检记录**:RolesController 新增模板列表/详情/复制/初始化端点,均已套用 `[Authorize]` + `PermissionAuthorize`、仅调用 CQRS/DTO,依赖租户头隔离。TenantPackagesController 与 TenantsController(配额校验) 均使用权限码、DTO 映射,配额校验要求携带租户头防越权。 ## 2. UserApi(C 端用户) - **面向对象**:App/H5 普通用户。 diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs new file mode 100644 index 0000000..6f5d77a --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs @@ -0,0 +1,90 @@ +using System.ComponentModel.DataAnnotations; +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.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 租户套餐管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/tenant-packages")] +public sealed class TenantPackagesController(IMediator mediator) : BaseApiController +{ + /// + /// 分页查询租户套餐。 + /// + [HttpGet] + [PermissionAuthorize("tenant-package:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Search([FromQuery] SearchTenantPackagesQuery query, CancellationToken cancellationToken) + { + var result = await mediator.Send(query, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 查看套餐详情。 + /// + [HttpGet("{tenantPackageId:long}")] + [PermissionAuthorize("tenant-package:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Detail(long tenantPackageId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetTenantPackageByIdQuery { TenantPackageId = tenantPackageId }, cancellationToken); + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "套餐不存在") + : ApiResponse.Ok(result); + } + + /// + /// 创建套餐。 + /// + [HttpPost] + [PermissionAuthorize("tenant-package:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create([FromBody, Required] CreateTenantPackageCommand command, CancellationToken cancellationToken) + { + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 更新套餐。 + /// + [HttpPut("{tenantPackageId:long}")] + [PermissionAuthorize("tenant-package:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Update(long tenantPackageId, [FromBody, Required] UpdateTenantPackageCommand command, CancellationToken cancellationToken) + { + command = command with { TenantPackageId = tenantPackageId }; + var result = await mediator.Send(command, cancellationToken); + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "套餐不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除套餐。 + /// + [HttpDelete("{tenantPackageId:long}")] + [PermissionAuthorize("tenant-package:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Delete(long tenantPackageId, CancellationToken cancellationToken) + { + var command = new DeleteTenantPackageCommand { TenantPackageId = tenantPackageId }; + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs index 5fe90bc..f1c3bcd 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -134,4 +135,21 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController var result = await mediator.Send(query, cancellationToken); return ApiResponse>.Ok(result); } + + /// + /// 配额校验并占用额度(门店/账号/短信/配送)。 + /// + /// 需在请求头携带 X-Tenant-Id 对应的租户。 + [HttpPost("{tenantId:long}/quotas/check")] + [PermissionAuthorize("tenant:quota:check")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CheckQuota( + long tenantId, + [FromBody, Required] CheckTenantQuotaCommand body, + CancellationToken cancellationToken) + { + var command = body with { TenantId = tenantId }; + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CheckTenantQuotaCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CheckTenantQuotaCommand.cs new file mode 100644 index 0000000..3941e81 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CheckTenantQuotaCommand.cs @@ -0,0 +1,26 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 校验并消费租户配额命令。 +/// +public sealed record CheckTenantQuotaCommand : IRequest +{ + /// + /// 目标租户 ID。 + /// + public long TenantId { get; init; } + + /// + /// 配额类型。 + /// + public TenantQuotaType QuotaType { get; init; } + + /// + /// 本次申请使用量。 + /// + public decimal Delta { get; init; } = 1; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantPackageCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantPackageCommand.cs new file mode 100644 index 0000000..c809fca --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantPackageCommand.cs @@ -0,0 +1,71 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 创建租户套餐命令。 +/// +public sealed record CreateTenantPackageCommand : IRequest +{ + /// + /// 套餐名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 套餐描述。 + /// + public string? Description { get; init; } + + /// + /// 套餐类型。 + /// + public TenantPackageType PackageType { get; init; } = TenantPackageType.Standard; + + /// + /// 月付价格。 + /// + public decimal? MonthlyPrice { get; init; } + + /// + /// 年付价格。 + /// + public decimal? YearlyPrice { get; init; } + + /// + /// 最大门店数。 + /// + public int? MaxStoreCount { get; init; } + + /// + /// 最大账号数。 + /// + public int? MaxAccountCount { get; init; } + + /// + /// 存储上限(GB)。 + /// + public int? MaxStorageGb { get; init; } + + /// + /// 短信额度。 + /// + public int? MaxSmsCredits { get; init; } + + /// + /// 配送单上限。 + /// + public int? MaxDeliveryOrders { get; init; } + + /// + /// 权益明细 JSON。 + /// + public string? FeaturePoliciesJson { get; init; } + + /// + /// 是否可售。 + /// + public bool IsActive { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/DeleteTenantPackageCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/DeleteTenantPackageCommand.cs new file mode 100644 index 0000000..9f46a76 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/DeleteTenantPackageCommand.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 删除租户套餐命令。 +/// +public sealed record DeleteTenantPackageCommand : IRequest +{ + /// + /// 套餐 ID。 + /// + public long TenantPackageId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantPackageCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantPackageCommand.cs new file mode 100644 index 0000000..a4529d3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantPackageCommand.cs @@ -0,0 +1,76 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 更新租户套餐命令。 +/// +public sealed record UpdateTenantPackageCommand : IRequest +{ + /// + /// 套餐 ID。 + /// + public long TenantPackageId { get; init; } + + /// + /// 套餐名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 套餐描述。 + /// + public string? Description { get; init; } + + /// + /// 套餐类型。 + /// + public TenantPackageType PackageType { get; init; } = TenantPackageType.Standard; + + /// + /// 月付价格。 + /// + public decimal? MonthlyPrice { get; init; } + + /// + /// 年付价格。 + /// + public decimal? YearlyPrice { get; init; } + + /// + /// 最大门店数。 + /// + public int? MaxStoreCount { get; init; } + + /// + /// 最大账号数。 + /// + public int? MaxAccountCount { get; init; } + + /// + /// 存储上限(GB)。 + /// + public int? MaxStorageGb { get; init; } + + /// + /// 短信额度。 + /// + public int? MaxSmsCredits { get; init; } + + /// + /// 配送单上限。 + /// + public int? MaxDeliveryOrders { get; init; } + + /// + /// 权益明细 JSON。 + /// + public string? FeaturePoliciesJson { get; init; } + + /// + /// 是否可售。 + /// + public bool IsActive { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/QuotaCheckResultDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/QuotaCheckResultDto.cs new file mode 100644 index 0000000..b9ce7e6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/QuotaCheckResultDto.cs @@ -0,0 +1,29 @@ +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Tenants.Dto; + +/// +/// 配额校验结果。 +/// +public sealed class QuotaCheckResultDto +{ + /// + /// 配额类型。 + /// + public TenantQuotaType QuotaType { get; init; } + + /// + /// 当前配额上限,null 表示无限制。 + /// + public decimal? Limit { get; init; } + + /// + /// 已使用数量。 + /// + public decimal Used { get; init; } + + /// + /// 剩余额度,null 表示无限制。 + /// + public decimal? Remaining { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantPackageDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantPackageDto.cs new file mode 100644 index 0000000..52f9e90 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantPackageDto.cs @@ -0,0 +1,77 @@ +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 TenantPackageDto +{ + /// + /// 套餐 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 套餐名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 描述。 + /// + public string? Description { get; init; } + + /// + /// 套餐类型。 + /// + public TenantPackageType PackageType { get; init; } + + /// + /// 月付价格。 + /// + public decimal? MonthlyPrice { get; init; } + + /// + /// 年付价格。 + /// + public decimal? YearlyPrice { get; init; } + + /// + /// 最大门店数。 + /// + public int? MaxStoreCount { get; init; } + + /// + /// 最大账号数。 + /// + public int? MaxAccountCount { get; init; } + + /// + /// 存储上限(GB)。 + /// + public int? MaxStorageGb { get; init; } + + /// + /// 短信额度。 + /// + public int? MaxSmsCredits { get; init; } + + /// + /// 配送单上限。 + /// + public int? MaxDeliveryOrders { get; init; } + + /// + /// 权益明细 JSON。 + /// + public string? FeaturePoliciesJson { get; init; } + + /// + /// 是否可售。 + /// + public bool IsActive { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CheckTenantQuotaCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CheckTenantQuotaCommandHandler.cs new file mode 100644 index 0000000..77cae64 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CheckTenantQuotaCommandHandler.cs @@ -0,0 +1,127 @@ +using System; +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.Tenancy; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 配额校验处理器。 +/// +public sealed class CheckTenantQuotaCommandHandler( + ITenantRepository tenantRepository, + ITenantPackageRepository packageRepository, + ITenantQuotaUsageRepository quotaUsageRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(CheckTenantQuotaCommand request, CancellationToken cancellationToken) + { + if (request.Delta <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "配额消耗量必须大于 0"); + } + + var currentTenantId = tenantProvider.GetCurrentTenantId(); + if (currentTenantId == 0 || currentTenantId != request.TenantId) + { + throw new BusinessException(ErrorCodes.Forbidden, "租户上下文不匹配,请在请求头 X-Tenant-Id 指定目标租户"); + } + + // 1. 获取租户与当前订阅。 + _ = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在"); + + var subscription = await tenantRepository.GetActiveSubscriptionAsync(request.TenantId, cancellationToken); + if (subscription == null || subscription.EffectiveTo <= DateTime.UtcNow) + { + throw new BusinessException(ErrorCodes.Conflict, "订阅不存在或已到期"); + } + + var package = await packageRepository.FindByIdAsync(subscription.TenantPackageId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "套餐不存在"); + + var limit = ResolveLimit(package, request.QuotaType); + + // 2. 加载配额使用记录并计算。 + var usage = await quotaUsageRepository.FindAsync(request.TenantId, request.QuotaType, cancellationToken) + ?? new TenantQuotaUsage + { + TenantId = request.TenantId, + QuotaType = request.QuotaType, + LimitValue = limit ?? 0, + UsedValue = 0, + ResetCycle = ResolveResetCycle(request.QuotaType) + }; + + var usedAfter = usage.UsedValue + request.Delta; + if (limit.HasValue && usedAfter > (decimal)limit.Value) + { + usage.LimitValue = limit.Value; + await PersistUsageAsync(usage, quotaUsageRepository, cancellationToken); + throw new BusinessException(ErrorCodes.Conflict, $"{request.QuotaType} 配额不足"); + } + + usage.LimitValue = limit ?? usage.LimitValue; + usage.UsedValue = usedAfter; + usage.ResetCycle ??= ResolveResetCycle(request.QuotaType); + + await PersistUsageAsync(usage, quotaUsageRepository, cancellationToken); + + return new QuotaCheckResultDto + { + QuotaType = request.QuotaType, + Limit = limit, + Used = usage.UsedValue, + Remaining = limit.HasValue ? limit.Value - usage.UsedValue : null + }; + } + + private static decimal? ResolveLimit(TenantPackage package, TenantQuotaType quotaType) + { + return quotaType switch + { + TenantQuotaType.StoreCount => package.MaxStoreCount, + TenantQuotaType.AccountCount => package.MaxAccountCount, + TenantQuotaType.Storage => package.MaxStorageGb, + TenantQuotaType.SmsCredits => package.MaxSmsCredits, + TenantQuotaType.DeliveryOrders => package.MaxDeliveryOrders, + _ => null + }; + } + + private static string ResolveResetCycle(TenantQuotaType quotaType) + { + return quotaType switch + { + TenantQuotaType.SmsCredits => "monthly", + TenantQuotaType.DeliveryOrders => "monthly", + _ => "lifetime" + }; + } + + private static async Task PersistUsageAsync( + TenantQuotaUsage usage, + ITenantQuotaUsageRepository quotaUsageRepository, + CancellationToken cancellationToken) + { + // 判断是否为新增。 + if (usage.Id == 0) + { + await quotaUsageRepository.AddAsync(usage, cancellationToken); + } + else + { + await quotaUsageRepository.UpdateAsync(usage, cancellationToken); + } + + await quotaUsageRepository.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantPackageCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantPackageCommandHandler.cs new file mode 100644 index 0000000..bc8cd90 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantPackageCommandHandler.cs @@ -0,0 +1,46 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 创建租户套餐处理器。 +/// +public sealed class CreateTenantPackageCommandHandler(ITenantPackageRepository packageRepository) + : IRequestHandler +{ + /// + public async Task Handle(CreateTenantPackageCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Name)) + { + throw new BusinessException(ErrorCodes.BadRequest, "套餐名称不能为空"); + } + + var package = new TenantPackage + { + Name = request.Name.Trim(), + Description = request.Description, + PackageType = request.PackageType, + MonthlyPrice = request.MonthlyPrice, + YearlyPrice = request.YearlyPrice, + MaxStoreCount = request.MaxStoreCount, + MaxAccountCount = request.MaxAccountCount, + MaxStorageGb = request.MaxStorageGb, + MaxSmsCredits = request.MaxSmsCredits, + MaxDeliveryOrders = request.MaxDeliveryOrders, + FeaturePoliciesJson = request.FeaturePoliciesJson, + IsActive = request.IsActive + }; + + await packageRepository.AddAsync(package, cancellationToken); + await packageRepository.SaveChangesAsync(cancellationToken); + + return package.ToDto(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantPackageCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantPackageCommandHandler.cs new file mode 100644 index 0000000..232ea5e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantPackageCommandHandler.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 删除租户套餐处理器。 +/// +public sealed class DeleteTenantPackageCommandHandler(ITenantPackageRepository packageRepository) + : IRequestHandler +{ + /// + public async Task Handle(DeleteTenantPackageCommand request, CancellationToken cancellationToken) + { + await packageRepository.DeleteAsync(request.TenantPackageId, cancellationToken); + await packageRepository.SaveChangesAsync(cancellationToken); + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageByIdQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageByIdQueryHandler.cs new file mode 100644 index 0000000..ac6edac --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageByIdQueryHandler.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 套餐详情查询处理器。 +/// +public sealed class GetTenantPackageByIdQueryHandler(ITenantPackageRepository packageRepository) + : IRequestHandler +{ + /// + public async Task Handle(GetTenantPackageByIdQuery request, CancellationToken cancellationToken) + { + var package = await packageRepository.FindByIdAsync(request.TenantPackageId, cancellationToken); + return package?.ToDto(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantPackagesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantPackagesQueryHandler.cs new file mode 100644 index 0000000..5b4993c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantPackagesQueryHandler.cs @@ -0,0 +1,33 @@ +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 SearchTenantPackagesQueryHandler(ITenantPackageRepository packageRepository) + : IRequestHandler> +{ + /// + public async Task> Handle(SearchTenantPackagesQuery request, CancellationToken cancellationToken) + { + var packages = await packageRepository.SearchAsync(request.Keyword, request.IsActive, cancellationToken); + + var ordered = packages.OrderByDescending(x => x.CreatedAt).ToList(); + var pageIndex = request.Page <= 0 ? 1 : request.Page; + var size = request.PageSize <= 0 ? 20 : request.PageSize; + + var pagedItems = ordered + .Skip((pageIndex - 1) * size) + .Take(size) + .Select(x => x.ToDto()) + .ToList(); + + return new PagedResult(pagedItems, pageIndex, size, ordered.Count); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantPackageCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantPackageCommandHandler.cs new file mode 100644 index 0000000..77a1664 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantPackageCommandHandler.cs @@ -0,0 +1,48 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 更新租户套餐处理器。 +/// +public sealed class UpdateTenantPackageCommandHandler(ITenantPackageRepository packageRepository) + : IRequestHandler +{ + /// + public async Task Handle(UpdateTenantPackageCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Name)) + { + throw new BusinessException(ErrorCodes.BadRequest, "套餐名称不能为空"); + } + + var package = await packageRepository.FindByIdAsync(request.TenantPackageId, cancellationToken); + if (package == null) + { + return null; + } + + package.Name = request.Name.Trim(); + package.Description = request.Description; + package.PackageType = request.PackageType; + package.MonthlyPrice = request.MonthlyPrice; + package.YearlyPrice = request.YearlyPrice; + package.MaxStoreCount = request.MaxStoreCount; + package.MaxAccountCount = request.MaxAccountCount; + package.MaxStorageGb = request.MaxStorageGb; + package.MaxSmsCredits = request.MaxSmsCredits; + package.MaxDeliveryOrders = request.MaxDeliveryOrders; + package.FeaturePoliciesJson = request.FeaturePoliciesJson; + package.IsActive = request.IsActive; + + await packageRepository.UpdateAsync(package, cancellationToken); + await packageRepository.SaveChangesAsync(cancellationToken); + + return package.ToDto(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantPackageByIdQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantPackageByIdQuery.cs new file mode 100644 index 0000000..252b5fe --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantPackageByIdQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 获取套餐详情查询。 +/// +public sealed record GetTenantPackageByIdQuery : IRequest +{ + /// + /// 套餐 ID。 + /// + public long TenantPackageId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantPackagesQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantPackagesQuery.cs new file mode 100644 index 0000000..c81a068 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantPackagesQuery.cs @@ -0,0 +1,31 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 分页查询租户套餐。 +/// +public sealed record SearchTenantPackagesQuery : IRequest> +{ + /// + /// 搜索关键词(名称/描述)。 + /// + public string? Keyword { get; init; } + + /// + /// 是否筛选可售套餐。 + /// + public bool? IsActive { get; init; } + + /// + /// 页码。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 20; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs index bc6cfe8..ab7fb4f 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs @@ -73,4 +73,22 @@ internal static class TenantMapping CurrentStatus = log.CurrentStatus, CreatedAt = log.CreatedAt }; + + public static TenantPackageDto ToDto(this TenantPackage package) + => new() + { + Id = package.Id, + Name = package.Name, + Description = package.Description, + PackageType = package.PackageType, + MonthlyPrice = package.MonthlyPrice, + YearlyPrice = package.YearlyPrice, + MaxStoreCount = package.MaxStoreCount, + MaxAccountCount = package.MaxAccountCount, + MaxStorageGb = package.MaxStorageGb, + MaxSmsCredits = package.MaxSmsCredits, + MaxDeliveryOrders = package.MaxDeliveryOrders, + FeaturePoliciesJson = package.FeaturePoliciesJson, + IsActive = package.IsActive + }; } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateProvider.cs b/src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateProvider.cs index e492fd0..1d1482e 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateProvider.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateProvider.cs @@ -98,6 +98,36 @@ public sealed class RoleTemplateProvider : IRoleTemplateProvider Name = "租户订阅管理", Description = "创建、续费或调整租户套餐订阅。" }, + ["tenant:quota:check"] = new() + { + Code = "tenant:quota:check", + Name = "租户配额校验", + Description = "校验并占用门店、账号、短信、配送等配额。" + }, + ["tenant-package:read"] = new() + { + Code = "tenant-package:read", + Name = "套餐查询", + Description = "查看平台套餐定义列表与详情。" + }, + ["tenant-package:create"] = new() + { + Code = "tenant-package:create", + Name = "套餐创建", + Description = "创建平台套餐定义。" + }, + ["tenant-package:update"] = new() + { + Code = "tenant-package:update", + Name = "套餐更新", + Description = "更新平台套餐定义。" + }, + ["tenant-package:delete"] = new() + { + Code = "tenant-package:delete", + Name = "套餐删除", + Description = "删除平台套餐定义。" + }, ["merchant:create"] = new() { Code = "merchant:create", @@ -369,6 +399,7 @@ public sealed class RoleTemplateProvider : IRoleTemplateProvider "identity:permission:delete", "tenant:read", "tenant:subscription", + "tenant:quota:check", "merchant:read", "merchant:update", "merchant_category:read", diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPackageRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPackageRepository.cs new file mode 100644 index 0000000..7b63e27 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPackageRepository.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Tenants.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Repositories; + +/// +/// 租户套餐仓储。 +/// +public interface ITenantPackageRepository +{ + /// + /// 按 ID 查询套餐。 + /// + Task FindByIdAsync(long id, CancellationToken cancellationToken = default); + + /// + /// 按关键词与启用状态搜索套餐。 + /// + Task> SearchAsync(string? keyword, bool? isActive, CancellationToken cancellationToken = default); + + /// + /// 新增套餐。 + /// + Task AddAsync(TenantPackage package, CancellationToken cancellationToken = default); + + /// + /// 更新套餐。 + /// + Task UpdateAsync(TenantPackage package, CancellationToken cancellationToken = default); + + /// + /// 删除套餐。 + /// + Task DeleteAsync(long id, CancellationToken cancellationToken = default); + + /// + /// 持久化。 + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantQuotaUsageRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantQuotaUsageRepository.cs new file mode 100644 index 0000000..ec3e7d7 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantQuotaUsageRepository.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Domain.Tenants.Repositories; + +/// +/// 租户配额使用仓储。 +/// +public interface ITenantQuotaUsageRepository +{ + /// + /// 获取租户指定配额的使用情况。 + /// + Task FindAsync(long tenantId, TenantQuotaType quotaType, CancellationToken cancellationToken = default); + + /// + /// 按租户批量获取配额使用记录。 + /// + Task> GetByTenantAsync(long tenantId, CancellationToken cancellationToken = default); + + /// + /// 新增配额使用记录。 + /// + Task AddAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default); + + /// + /// 更新配额使用记录。 + /// + Task UpdateAsync(TenantQuotaUsage usage, 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 c9a416d..657f769 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs @@ -39,6 +39,8 @@ public static class AppServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddOptions() .Bind(configuration.GetSection(AppSeedOptions.SectionName)) diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantPackageRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantPackageRepository.cs new file mode 100644 index 0000000..0a2cd22 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantPackageRepository.cs @@ -0,0 +1,69 @@ +using System.Linq; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// 租户套餐仓储实现。 +/// +public sealed class EfTenantPackageRepository(TakeoutAppDbContext context) : ITenantPackageRepository +{ + /// + public Task FindByIdAsync(long id, CancellationToken cancellationToken = default) + { + return context.TenantPackages.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + } + + /// + public async Task> SearchAsync(string? keyword, bool? isActive, CancellationToken cancellationToken = default) + { + var query = context.TenantPackages.AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(keyword)) + { + var normalized = keyword.Trim(); + query = query.Where(x => EF.Functions.ILike(x.Name, $"%{normalized}%") || EF.Functions.ILike(x.Description ?? string.Empty, $"%{normalized}%")); + } + + if (isActive.HasValue) + { + query = query.Where(x => x.IsActive == isActive.Value); + } + + return await query + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(cancellationToken); + } + + /// + public Task AddAsync(TenantPackage package, CancellationToken cancellationToken = default) + { + return context.TenantPackages.AddAsync(package, cancellationToken).AsTask(); + } + + /// + public Task UpdateAsync(TenantPackage package, CancellationToken cancellationToken = default) + { + context.TenantPackages.Update(package); + return Task.CompletedTask; + } + + /// + public async Task DeleteAsync(long id, CancellationToken cancellationToken = default) + { + var entity = await context.TenantPackages.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (entity != null) + { + context.TenantPackages.Remove(entity); + } + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantQuotaUsageRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantQuotaUsageRepository.cs new file mode 100644 index 0000000..778bada --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantQuotaUsageRepository.cs @@ -0,0 +1,51 @@ +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; + +/// +/// 租户配额使用仓储实现。 +/// +public sealed class EfTenantQuotaUsageRepository(TakeoutAppDbContext context) : ITenantQuotaUsageRepository +{ + /// + public Task FindAsync(long tenantId, TenantQuotaType quotaType, CancellationToken cancellationToken = default) + { + return context.TenantQuotaUsages + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.QuotaType == quotaType, cancellationToken); + } + + /// + public Task> GetByTenantAsync(long tenantId, CancellationToken cancellationToken = default) + { + return context.TenantQuotaUsages + .AsNoTracking() + .Where(x => x.TenantId == tenantId) + .OrderBy(x => x.QuotaType) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + } + + /// + public Task AddAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default) + { + return context.TenantQuotaUsages.AddAsync(usage, cancellationToken).AsTask(); + } + + /// + public Task UpdateAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default) + { + context.TenantQuotaUsages.Update(usage); + return Task.CompletedTask; + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +} From 6a8414179909eb2b698b48deeadf4edca0bd0547 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Wed, 3 Dec 2025 20:38:26 +0800 Subject: [PATCH 08/30] =?UTF-8?q?feat:=20=E8=A7=92=E8=89=B2=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E6=94=B9=E4=B8=BA=E6=95=B0=E6=8D=AE=E5=BA=93=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=94=AF=E6=8C=81=E5=89=8D=E7=AB=AF=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/RolesController.cs | 47 +- .../appsettings.Seed.Development.json | 231 +++++++- .../Abstractions/IRoleTemplateProvider.cs | 23 - .../Commands/CreateRoleTemplateCommand.cs | 36 ++ .../Commands/DeleteRoleTemplateCommand.cs | 14 + .../Commands/UpdateRoleTemplateCommand.cs | 36 ++ .../Identity/Contracts/RoleTemplateDto.cs | 5 + .../IdentityServiceCollectionExtensions.cs | 2 - .../CopyRoleTemplateCommandHandler.cs | 34 +- .../CreateRoleTemplateCommandHandler.cs | 52 ++ .../DeleteRoleTemplateCommandHandler.cs | 25 + .../Handlers/GetRoleTemplateQueryHandler.cs | 18 +- .../InitializeRoleTemplatesCommandHandler.cs | 24 +- .../Handlers/ListRoleTemplatesQueryHandler.cs | 23 +- .../Identity/Handlers/TemplateMapper.cs | 36 +- .../UpdateRoleTemplateCommandHandler.cs | 47 ++ .../Queries/ListRoleTemplatesQuery.cs | 8 +- .../Templates/PermissionTemplateDefinition.cs | 22 - .../Templates/RoleTemplateDefinition.cs | 29 - .../Templates/RoleTemplateProvider.cs | 511 ------------------ .../Identity/Entities/RoleTemplate.cs | 29 + .../Entities/RoleTemplatePermission.cs | 19 + .../Repositories/IRoleTemplateRepository.cs | 28 + .../Extensions/ServiceCollectionExtensions.cs | 1 + .../Identity/Options/AdminSeedOptions.cs | 38 ++ .../Persistence/EfRoleTemplateRepository.cs | 117 ++++ .../Persistence/IdentityDataSeeder.cs | 64 +++ .../Identity/Persistence/IdentityDbContext.cs | 34 ++ 28 files changed, 901 insertions(+), 652 deletions(-) delete mode 100644 src/Application/TakeoutSaaS.Application/Identity/Abstractions/IRoleTemplateProvider.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Commands/CreateRoleTemplateCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Commands/DeleteRoleTemplateCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateRoleTemplateCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateRoleTemplateCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteRoleTemplateCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateRoleTemplateCommandHandler.cs delete mode 100644 src/Application/TakeoutSaaS.Application/Identity/Templates/PermissionTemplateDefinition.cs delete mode 100644 src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateDefinition.cs delete mode 100644 src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateProvider.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Identity/Entities/RoleTemplate.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Identity/Entities/RoleTemplatePermission.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleTemplateRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRoleTemplateRepository.cs diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs index 850e5df..1033f5c 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs @@ -30,9 +30,9 @@ public sealed class RolesController(IMediator mediator) : BaseApiController [HttpGet("templates")] [PermissionAuthorize("identity:role:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] - public async Task>> ListTemplates(CancellationToken cancellationToken) + public async Task>> ListTemplates([FromQuery] bool? isActive, CancellationToken cancellationToken) { - var result = await mediator.Send(new ListRoleTemplatesQuery(), cancellationToken); + var result = await mediator.Send(new ListRoleTemplatesQuery { IsActive = isActive }, cancellationToken); return ApiResponse>.Ok(result); } @@ -54,6 +54,49 @@ public sealed class RolesController(IMediator mediator) : BaseApiController : ApiResponse.Ok(result); } + /// + /// 创建角色模板。 + /// + [HttpPost("templates")] + [PermissionAuthorize("role-template:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CreateTemplate([FromBody, Required] CreateRoleTemplateCommand command, CancellationToken cancellationToken) + { + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 更新角色模板。 + /// + [HttpPut("templates/{templateCode}")] + [PermissionAuthorize("role-template:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> UpdateTemplate( + string templateCode, + [FromBody, Required] UpdateRoleTemplateCommand command, + CancellationToken cancellationToken) + { + command = command with { TemplateCode = templateCode }; + var result = await mediator.Send(command, cancellationToken); + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "角色模板不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除角色模板。 + /// + [HttpDelete("templates/{templateCode}")] + [PermissionAuthorize("role-template:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> DeleteTemplate(string templateCode, CancellationToken cancellationToken) + { + var result = await mediator.Send(new DeleteRoleTemplateCommand { TemplateCode = templateCode }, cancellationToken); + return ApiResponse.Ok(result); + } + /// /// 按模板复制角色并绑定权限。 /// diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json index 6f897a7..3092532 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json @@ -39,6 +39,171 @@ }, "Identity": { "AdminSeed": { + "RoleTemplates": [ + { + "TemplateCode": "platform-admin", + "Name": "平台管理员", + "Description": "平台全量权限", + "IsActive": true, + "Permissions": [ + "identity:profile:read", + "identity:role:read", + "identity:role:create", + "identity:role:update", + "identity:role:delete", + "identity:role:bind-permission", + "identity:permission:read", + "identity:permission:create", + "identity:permission:update", + "identity:permission:delete", + "role-template:read", + "role-template:create", + "role-template:update", + "role-template:delete", + "tenant:create", + "tenant:read", + "tenant:review", + "tenant:subscription", + "tenant:quota:check", + "tenant-package:read", + "tenant-package:create", + "tenant-package:update", + "tenant-package:delete", + "merchant:create", + "merchant:read", + "merchant:update", + "merchant:delete", + "merchant:review", + "merchant_category:read", + "merchant_category:create", + "merchant_category:update", + "merchant_category:delete", + "store:create", + "store:read", + "store:update", + "store:delete", + "product:create", + "product:read", + "product:update", + "product:delete", + "order:create", + "order:read", + "order:update", + "order:delete", + "payment:create", + "payment:read", + "payment:update", + "payment:delete", + "delivery:create", + "delivery:read", + "delivery:update", + "delivery:delete", + "dictionary:group:read", + "dictionary:group:create", + "dictionary:group:update", + "dictionary:group:delete", + "dictionary:item:create", + "dictionary:item:update", + "dictionary:item:delete", + "system-parameter:create", + "system-parameter:read", + "system-parameter:update", + "system-parameter:delete" + ] + }, + { + "TemplateCode": "tenant-admin", + "Name": "租户管理员", + "Description": "管理本租户的门店、商品、订单与权限", + "IsActive": true, + "Permissions": [ + "identity:profile:read", + "identity:role:read", + "identity:role:create", + "identity:role:update", + "identity:role:delete", + "identity:role:bind-permission", + "identity:permission:read", + "identity:permission:create", + "identity:permission:update", + "identity:permission:delete", + "tenant:read", + "tenant:subscription", + "tenant:quota:check", + "merchant:read", + "merchant:update", + "merchant_category:read", + "merchant_category:create", + "merchant_category:update", + "merchant_category:delete", + "store:create", + "store:read", + "store:update", + "store:delete", + "product:create", + "product:read", + "product:update", + "product:delete", + "order:create", + "order:read", + "order:update", + "delivery:create", + "delivery:read", + "delivery:update", + "payment:create", + "payment:read", + "payment:update", + "dictionary:group:read", + "dictionary:group:create", + "dictionary:group:update", + "dictionary:group:delete", + "dictionary:item:create", + "dictionary:item:update", + "dictionary:item:delete", + "system-parameter:read" + ] + }, + { + "TemplateCode": "store-manager", + "Name": "店长", + "Description": "负责门店运营与商品、订单管理", + "IsActive": true, + "Permissions": [ + "identity:profile:read", + "store:read", + "store:update", + "product:create", + "product:read", + "product:update", + "order:create", + "order:read", + "order:update", + "delivery:read", + "delivery:update", + "payment:read", + "payment:update", + "dictionary:group:read", + "dictionary:item:create", + "dictionary:item:update", + "dictionary:item:delete" + ] + }, + { + "TemplateCode": "store-staff", + "Name": "店员", + "Description": "处理订单履约与收款查询", + "IsActive": true, + "Permissions": [ + "identity:profile:read", + "store:read", + "product:read", + "order:read", + "order:update", + "delivery:read", + "payment:read" + ] + } + ], "Users": [ { "Account": "admin", @@ -47,17 +212,69 @@ "TenantId": 1000000000001, "Roles": [ "PlatformAdmin" ], "Permissions": [ - "merchant:*", - "merchant_category:*", + "identity:profile:read", + "identity:role:read", + "identity:role:create", + "identity:role:update", + "identity:role:delete", + "identity:role:bind-permission", + "identity:permission:read", + "identity:permission:create", + "identity:permission:update", + "identity:permission:delete", + "role-template:read", + "role-template:create", + "role-template:update", + "role-template:delete", + "tenant:create", + "tenant:read", + "tenant:review", + "tenant:subscription", + "tenant:quota:check", + "tenant-package:read", + "tenant-package:create", + "tenant-package:update", + "tenant-package:delete", + "merchant:create", + "merchant:read", + "merchant:update", + "merchant:delete", + "merchant:review", "merchant_category:read", "merchant_category:create", "merchant_category:update", "merchant_category:delete", - "store:*", - "product:*", - "order:*", - "payment:*", - "delivery:*" + "store:create", + "store:read", + "store:update", + "store:delete", + "product:create", + "product:read", + "product:update", + "product:delete", + "order:create", + "order:read", + "order:update", + "order:delete", + "payment:create", + "payment:read", + "payment:update", + "payment:delete", + "delivery:create", + "delivery:read", + "delivery:update", + "delivery:delete", + "dictionary:group:read", + "dictionary:group:create", + "dictionary:group:update", + "dictionary:group:delete", + "dictionary:item:create", + "dictionary:item:update", + "dictionary:item:delete", + "system-parameter:create", + "system-parameter:read", + "system-parameter:update", + "system-parameter:delete" ] } ] diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IRoleTemplateProvider.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IRoleTemplateProvider.cs deleted file mode 100644 index 6f183ca..0000000 --- a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IRoleTemplateProvider.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Collections.Generic; -using TakeoutSaaS.Application.Identity.Templates; - -namespace TakeoutSaaS.Application.Identity.Abstractions; - -/// -/// 角色模板提供者,用于获取预置模板定义。 -/// -public interface IRoleTemplateProvider -{ - /// - /// 获取全部角色模板定义。 - /// - /// 模板定义集合。 - IReadOnlyList GetTemplates(); - - /// - /// 根据模板编码查找模板。 - /// - /// 模板编码。 - /// 模板定义;不存在时返回 null。 - RoleTemplateDefinition? FindByCode(string templateCode); -} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/CreateRoleTemplateCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/CreateRoleTemplateCommand.cs new file mode 100644 index 0000000..7ca69d1 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/CreateRoleTemplateCommand.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 创建角色模板命令。 +/// +public sealed record CreateRoleTemplateCommand : IRequest +{ + /// + /// 模板编码。 + /// + public string TemplateCode { get; init; } = string.Empty; + + /// + /// 模板名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 模板描述。 + /// + public string? Description { get; init; } + + /// + /// 是否启用。 + /// + public bool IsActive { get; init; } = true; + + /// + /// 权限编码集合。 + /// + public IReadOnlyCollection PermissionCodes { get; init; } = Array.Empty(); +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/DeleteRoleTemplateCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/DeleteRoleTemplateCommand.cs new file mode 100644 index 0000000..7f29db6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/DeleteRoleTemplateCommand.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 删除角色模板命令。 +/// +public sealed record DeleteRoleTemplateCommand : IRequest +{ + /// + /// 模板编码。 + /// + public string TemplateCode { get; init; } = string.Empty; +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateRoleTemplateCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateRoleTemplateCommand.cs new file mode 100644 index 0000000..d88b3ba --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateRoleTemplateCommand.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 更新角色模板命令。 +/// +public sealed record UpdateRoleTemplateCommand : IRequest +{ + /// + /// 模板编码(路径参数)。 + /// + public string TemplateCode { get; init; } = string.Empty; + + /// + /// 模板名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 模板描述。 + /// + public string? Description { get; init; } + + /// + /// 是否启用。 + /// + public bool IsActive { get; init; } = true; + + /// + /// 权限编码集合。 + /// + public IReadOnlyCollection PermissionCodes { get; init; } = Array.Empty(); +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/RoleTemplateDto.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/RoleTemplateDto.cs index fc43f5b..4690fa2 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Contracts/RoleTemplateDto.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/RoleTemplateDto.cs @@ -22,6 +22,11 @@ public sealed record RoleTemplateDto /// public string? Description { get; init; } + /// + /// 是否启用。 + /// + public bool IsActive { get; init; } + /// /// 包含的权限定义。 /// diff --git a/src/Application/TakeoutSaaS.Application/Identity/Extensions/IdentityServiceCollectionExtensions.cs b/src/Application/TakeoutSaaS.Application/Identity/Extensions/IdentityServiceCollectionExtensions.cs index f680ef7..c5df667 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Extensions/IdentityServiceCollectionExtensions.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Extensions/IdentityServiceCollectionExtensions.cs @@ -1,7 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Services; -using TakeoutSaaS.Application.Identity.Templates; namespace TakeoutSaaS.Application.Identity.Extensions; @@ -18,7 +17,6 @@ public static class IdentityServiceCollectionExtensions public static IServiceCollection AddIdentityApplication(this IServiceCollection services, bool enableMiniSupport = false) { services.AddScoped(); - services.AddSingleton(); if (enableMiniSupport) { diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CopyRoleTemplateCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CopyRoleTemplateCommandHandler.cs index d5da9be..54b658b 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CopyRoleTemplateCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CopyRoleTemplateCommandHandler.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.Linq; using MediatR; -using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Commands; using TakeoutSaaS.Application.Identity.Contracts; using TakeoutSaaS.Domain.Identity.Entities; @@ -17,7 +16,7 @@ namespace TakeoutSaaS.Application.Identity.Handlers; /// 角色模板复制处理器。 /// public sealed class CopyRoleTemplateCommandHandler( - IRoleTemplateProvider roleTemplateProvider, + IRoleTemplateRepository roleTemplateRepository, IRoleRepository roleRepository, IPermissionRepository permissionRepository, IRolePermissionRepository rolePermissionRepository, @@ -27,9 +26,16 @@ public sealed class CopyRoleTemplateCommandHandler( /// public async Task Handle(CopyRoleTemplateCommand request, CancellationToken cancellationToken) { - var template = roleTemplateProvider.FindByCode(request.TemplateCode) + var template = await roleTemplateRepository.FindByCodeAsync(request.TemplateCode, cancellationToken) ?? throw new BusinessException(ErrorCodes.NotFound, $"角色模板 {request.TemplateCode} 不存在"); + var templatePermissions = await roleTemplateRepository.GetPermissionsAsync(template.Id, cancellationToken); + var permissionCodes = templatePermissions + .Select(x => x.PermissionCode) + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + var tenantId = tenantProvider.GetCurrentTenantId(); var roleCode = string.IsNullOrWhiteSpace(request.RoleCode) ? template.TemplateCode : request.RoleCode.Trim(); var roleName = string.IsNullOrWhiteSpace(request.RoleName) ? template.Name : request.RoleName.Trim(); @@ -64,18 +70,12 @@ public sealed class CopyRoleTemplateCommandHandler( } // 2. 确保模板权限全部存在,不存在则按模板定义创建。 - var targetPermissionCodes = template.Permissions - .Select(permission => permission.Code) - .Where(code => !string.IsNullOrWhiteSpace(code)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - - var existingPermissions = await permissionRepository.GetByCodesAsync(tenantId, targetPermissionCodes, cancellationToken); + var existingPermissions = await permissionRepository.GetByCodesAsync(tenantId, permissionCodes, cancellationToken); var permissionMap = existingPermissions.ToDictionary(x => x.Code, StringComparer.OrdinalIgnoreCase); - foreach (var permissionDefinition in template.Permissions) + foreach (var code in permissionCodes) { - if (permissionMap.ContainsKey(permissionDefinition.Code)) + if (permissionMap.ContainsKey(code)) { continue; } @@ -83,13 +83,13 @@ public sealed class CopyRoleTemplateCommandHandler( var permission = new Permission { TenantId = tenantId, - Name = permissionDefinition.Name, - Code = permissionDefinition.Code, - Description = permissionDefinition.Description + Name = code, + Code = code, + Description = code }; await permissionRepository.AddAsync(permission, cancellationToken); - permissionMap[permissionDefinition.Code] = permission; + permissionMap[code] = permission; } await roleRepository.SaveChangesAsync(cancellationToken); @@ -100,7 +100,7 @@ public sealed class CopyRoleTemplateCommandHandler( .Select(x => x.PermissionId) .ToHashSet(); - var targetPermissionIds = targetPermissionCodes + var targetPermissionIds = permissionCodes .Select(code => permissionMap[code].Id) .ToHashSet(); diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateRoleTemplateCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateRoleTemplateCommandHandler.cs new file mode 100644 index 0000000..64460ef --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateRoleTemplateCommandHandler.cs @@ -0,0 +1,52 @@ +using System; +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Domain.Identity.Entities; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 创建角色模板处理器。 +/// +public sealed class CreateRoleTemplateCommandHandler(IRoleTemplateRepository roleTemplateRepository) + : IRequestHandler +{ + /// + public async Task Handle(CreateRoleTemplateCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.TemplateCode) || string.IsNullOrWhiteSpace(request.Name)) + { + throw new BusinessException(ErrorCodes.BadRequest, "模板编码与名称不能为空"); + } + + var existing = await roleTemplateRepository.FindByCodeAsync(request.TemplateCode, cancellationToken); + if (existing != null) + { + throw new BusinessException(ErrorCodes.Conflict, $"模板编码 {request.TemplateCode} 已存在"); + } + + var template = new RoleTemplate + { + TemplateCode = request.TemplateCode.Trim(), + Name = request.Name.Trim(), + Description = request.Description, + IsActive = request.IsActive + }; + + var permissions = request.PermissionCodes + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Select(code => code.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + await roleTemplateRepository.AddAsync(template, permissions, cancellationToken); + await roleTemplateRepository.SaveChangesAsync(cancellationToken); + + return TemplateMapper.ToDto(template, permissions); + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteRoleTemplateCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteRoleTemplateCommandHandler.cs new file mode 100644 index 0000000..9947217 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteRoleTemplateCommandHandler.cs @@ -0,0 +1,25 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Domain.Identity.Repositories; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 删除角色模板处理器。 +/// +public sealed class DeleteRoleTemplateCommandHandler(IRoleTemplateRepository roleTemplateRepository) + : IRequestHandler +{ + public async Task Handle(DeleteRoleTemplateCommand request, CancellationToken cancellationToken) + { + var template = await roleTemplateRepository.FindByCodeAsync(request.TemplateCode, cancellationToken); + if (template == null) + { + return false; + } + + await roleTemplateRepository.DeleteAsync(template.Id, cancellationToken); + await roleTemplateRepository.SaveChangesAsync(cancellationToken); + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetRoleTemplateQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetRoleTemplateQueryHandler.cs index 70206e7..b8b7bbe 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetRoleTemplateQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetRoleTemplateQueryHandler.cs @@ -1,20 +1,28 @@ +using System.Linq; using MediatR; -using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Contracts; using TakeoutSaaS.Application.Identity.Queries; +using TakeoutSaaS.Domain.Identity.Repositories; namespace TakeoutSaaS.Application.Identity.Handlers; /// /// 角色模板详情查询处理器。 /// -public sealed class GetRoleTemplateQueryHandler(IRoleTemplateProvider roleTemplateProvider) +public sealed class GetRoleTemplateQueryHandler(IRoleTemplateRepository roleTemplateRepository) : IRequestHandler { /// - public Task Handle(GetRoleTemplateQuery request, CancellationToken cancellationToken) + public async Task Handle(GetRoleTemplateQuery request, CancellationToken cancellationToken) { - var template = roleTemplateProvider.FindByCode(request.TemplateCode); - return Task.FromResult(template is null ? null : TemplateMapper.ToDto(template)); + var template = await roleTemplateRepository.FindByCodeAsync(request.TemplateCode, cancellationToken); + if (template == null) + { + return null; + } + + var permissions = await roleTemplateRepository.GetPermissionsAsync(template.Id, cancellationToken); + var codes = permissions.Select(x => x.PermissionCode).ToArray(); + return TemplateMapper.ToDto(template, codes); } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/InitializeRoleTemplatesCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/InitializeRoleTemplatesCommandHandler.cs index 765819d..483b336 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/InitializeRoleTemplatesCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/InitializeRoleTemplatesCommandHandler.cs @@ -2,9 +2,9 @@ using System; using System.Collections.Generic; using System.Linq; using MediatR; -using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Commands; using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Domain.Identity.Repositories; using TakeoutSaaS.Shared.Abstractions.Constants; using TakeoutSaaS.Shared.Abstractions.Exceptions; @@ -14,39 +14,47 @@ namespace TakeoutSaaS.Application.Identity.Handlers; /// 租户角色模板批量初始化处理器。 /// public sealed class InitializeRoleTemplatesCommandHandler( - IRoleTemplateProvider roleTemplateProvider, + IRoleTemplateRepository roleTemplateRepository, IMediator mediator) : IRequestHandler> { /// public async Task> Handle(InitializeRoleTemplatesCommand request, CancellationToken cancellationToken) { - // 1. 解析需要初始化的模板编码,默认取全部预置模板。 + // 1. 解析需要初始化的模板编码,默认取全部模板。 var requestedCodes = request.TemplateCodes? .Where(code => !string.IsNullOrWhiteSpace(code)) .Select(code => code.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); + var availableTemplates = await roleTemplateRepository.GetAllAsync(true, cancellationToken); + var availableCodes = availableTemplates.Select(t => t.TemplateCode).ToHashSet(StringComparer.OrdinalIgnoreCase); + var targetCodes = requestedCodes?.Length > 0 ? requestedCodes - : roleTemplateProvider.GetTemplates().Select(template => template.TemplateCode).ToArray(); + : availableTemplates.Select(template => template.TemplateCode).ToArray(); if (targetCodes.Length == 0) { return Array.Empty(); } + foreach (var code in targetCodes) + { + if (!availableCodes.Contains(code)) + { + throw new BusinessException(ErrorCodes.NotFound, $"角色模板 {code} 不存在或未启用"); + } + } + // 2. 逐个复制模板,幂等写入角色与权限。 var roles = new List(targetCodes.Length); foreach (var templateCode in targetCodes) { - var template = roleTemplateProvider.FindByCode(templateCode) - ?? throw new BusinessException(ErrorCodes.NotFound, $"角色模板 {templateCode} 不存在"); - var role = await mediator.Send(new CopyRoleTemplateCommand { - TemplateCode = template.TemplateCode + TemplateCode = templateCode }, cancellationToken); roles.Add(role); diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/ListRoleTemplatesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ListRoleTemplatesQueryHandler.cs index 011029a..2b533cd 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/ListRoleTemplatesQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ListRoleTemplatesQueryHandler.cs @@ -1,26 +1,35 @@ -using System; +using System.Collections.Generic; using System.Linq; using MediatR; -using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Contracts; using TakeoutSaaS.Application.Identity.Queries; +using TakeoutSaaS.Domain.Identity.Repositories; namespace TakeoutSaaS.Application.Identity.Handlers; /// /// 角色模板列表查询处理器。 /// -public sealed class ListRoleTemplatesQueryHandler(IRoleTemplateProvider roleTemplateProvider) +public sealed class ListRoleTemplatesQueryHandler(IRoleTemplateRepository roleTemplateRepository) : IRequestHandler> { /// - public Task> Handle(ListRoleTemplatesQuery request, CancellationToken cancellationToken) + public async Task> Handle(ListRoleTemplatesQuery request, CancellationToken cancellationToken) { - var templates = roleTemplateProvider.GetTemplates() + var templates = await roleTemplateRepository.GetAllAsync(request.IsActive, cancellationToken); + var permissionsMap = await roleTemplateRepository.GetPermissionsAsync(templates.Select(t => t.Id), cancellationToken); + + var dtos = templates .OrderBy(template => template.TemplateCode, StringComparer.OrdinalIgnoreCase) - .Select(TemplateMapper.ToDto) + .Select(template => + { + var codes = permissionsMap.TryGetValue(template.Id, out var perms) + ? (IReadOnlyCollection)perms.Select(p => p.PermissionCode).ToArray() + : Array.Empty(); + return TemplateMapper.ToDto(template, codes); + }) .ToArray(); - return Task.FromResult>(templates); + return dtos; } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/TemplateMapper.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/TemplateMapper.cs index 47b9670..73e0509 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/TemplateMapper.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/TemplateMapper.cs @@ -1,6 +1,7 @@ +using System.Collections.Generic; using System.Linq; using TakeoutSaaS.Application.Identity.Contracts; -using TakeoutSaaS.Application.Identity.Templates; +using TakeoutSaaS.Domain.Identity.Entities; namespace TakeoutSaaS.Application.Identity.Handlers; @@ -10,28 +11,27 @@ namespace TakeoutSaaS.Application.Identity.Handlers; internal static class TemplateMapper { /// - /// 将角色模板定义映射为 DTO。 + /// 将角色模板与权限编码集合映射为 DTO。 /// - /// 角色模板定义。 + /// 角色模板实体。 + /// 权限编码集合。 /// 模板 DTO。 - public static RoleTemplateDto ToDto(RoleTemplateDefinition definition) + public static RoleTemplateDto ToDto(RoleTemplate template, IReadOnlyCollection permissionCodes) { return new RoleTemplateDto { - TemplateCode = definition.TemplateCode, - Name = definition.Name, - Description = definition.Description, - Permissions = definition.Permissions.Select(ToDto).ToArray() - }; - } - - private static PermissionTemplateDto ToDto(PermissionTemplateDefinition definition) - { - return new PermissionTemplateDto - { - Code = definition.Code, - Name = definition.Name, - Description = definition.Description + TemplateCode = template.TemplateCode, + Name = template.Name, + Description = template.Description, + IsActive = template.IsActive, + Permissions = permissionCodes + .Select(code => new PermissionTemplateDto + { + Code = code, + Name = code, + Description = null + }) + .ToArray() }; } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateRoleTemplateCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateRoleTemplateCommandHandler.cs new file mode 100644 index 0000000..1f07395 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateRoleTemplateCommandHandler.cs @@ -0,0 +1,47 @@ +using System; +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 更新角色模板处理器。 +/// +public sealed class UpdateRoleTemplateCommandHandler(IRoleTemplateRepository roleTemplateRepository) + : IRequestHandler +{ + /// + public async Task Handle(UpdateRoleTemplateCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.TemplateCode) || string.IsNullOrWhiteSpace(request.Name)) + { + throw new BusinessException(ErrorCodes.BadRequest, "模板编码与名称不能为空"); + } + + var template = await roleTemplateRepository.FindByCodeAsync(request.TemplateCode, cancellationToken); + if (template == null) + { + return null; + } + + template.Name = request.Name.Trim(); + template.Description = request.Description; + template.IsActive = request.IsActive; + + var permissions = request.PermissionCodes + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Select(code => code.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + await roleTemplateRepository.UpdateAsync(template, permissions, cancellationToken); + await roleTemplateRepository.SaveChangesAsync(cancellationToken); + + return TemplateMapper.ToDto(template, permissions); + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Queries/ListRoleTemplatesQuery.cs b/src/Application/TakeoutSaaS.Application/Identity/Queries/ListRoleTemplatesQuery.cs index cf25f1e..7da3def 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Queries/ListRoleTemplatesQuery.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Queries/ListRoleTemplatesQuery.cs @@ -7,4 +7,10 @@ namespace TakeoutSaaS.Application.Identity.Queries; /// /// 查询角色模板列表。 /// -public sealed record ListRoleTemplatesQuery : IRequest>; +public sealed record ListRoleTemplatesQuery : IRequest> +{ + /// + /// 是否仅返回启用模板。 + /// + public bool? IsActive { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Templates/PermissionTemplateDefinition.cs b/src/Application/TakeoutSaaS.Application/Identity/Templates/PermissionTemplateDefinition.cs deleted file mode 100644 index 2bb4a46..0000000 --- a/src/Application/TakeoutSaaS.Application/Identity/Templates/PermissionTemplateDefinition.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace TakeoutSaaS.Application.Identity.Templates; - -/// -/// 权限模板定义。 -/// -public sealed record PermissionTemplateDefinition -{ - /// - /// 权限编码(唯一键)。 - /// - public required string Code { get; init; } - - /// - /// 权限名称。 - /// - public required string Name { get; init; } - - /// - /// 权限描述。 - /// - public string? Description { get; init; } -} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateDefinition.cs b/src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateDefinition.cs deleted file mode 100644 index 6c4f3cc..0000000 --- a/src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateDefinition.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Collections.Generic; - -namespace TakeoutSaaS.Application.Identity.Templates; - -/// -/// 角色模板定义。 -/// -public sealed record RoleTemplateDefinition -{ - /// - /// 模板编码(唯一键)。 - /// - public required string TemplateCode { get; init; } - - /// - /// 角色名称。 - /// - public required string Name { get; init; } - - /// - /// 角色描述。 - /// - public string? Description { get; init; } - - /// - /// 模板绑定的权限集合。 - /// - public IReadOnlyList Permissions { get; init; } = []; -} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateProvider.cs b/src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateProvider.cs deleted file mode 100644 index 1d1482e..0000000 --- a/src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateProvider.cs +++ /dev/null @@ -1,511 +0,0 @@ -using System; -using System.Collections.Frozen; -using System.Collections.Generic; -using System.Linq; -using TakeoutSaaS.Application.Identity.Abstractions; - -namespace TakeoutSaaS.Application.Identity.Templates; - -/// -/// 预置角色模板提供者。 -/// -public sealed class RoleTemplateProvider : IRoleTemplateProvider -{ - private static readonly FrozenDictionary PermissionDefinitions = - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["identity:role:read"] = new() - { - Code = "identity:role:read", - Name = "角色查询", - Description = "查看当前租户的角色列表与详情。" - }, - ["identity:role:create"] = new() - { - Code = "identity:role:create", - Name = "角色创建", - Description = "在当前租户内创建新角色。" - }, - ["identity:role:update"] = new() - { - Code = "identity:role:update", - Name = "角色更新", - Description = "修改角色名称与描述。" - }, - ["identity:role:delete"] = new() - { - Code = "identity:role:delete", - Name = "角色删除", - Description = "删除租户内的角色记录。" - }, - ["identity:role:bind-permission"] = new() - { - Code = "identity:role:bind-permission", - Name = "角色权限绑定", - Description = "为角色绑定或调整权限集合。" - }, - ["identity:permission:read"] = new() - { - Code = "identity:permission:read", - Name = "权限查询", - Description = "查看权限列表与详情。" - }, - ["identity:permission:create"] = new() - { - Code = "identity:permission:create", - Name = "权限创建", - Description = "创建新的权限定义。" - }, - ["identity:permission:update"] = new() - { - Code = "identity:permission:update", - Name = "权限更新", - Description = "修改权限名称或描述。" - }, - ["identity:permission:delete"] = new() - { - Code = "identity:permission:delete", - Name = "权限删除", - Description = "删除租户内的权限记录。" - }, - ["identity:profile:read"] = new() - { - Code = "identity:profile:read", - Name = "个人信息查看", - Description = "查看当前登录人的账号与权限信息。" - }, - ["tenant:create"] = new() - { - Code = "tenant:create", - Name = "租户创建", - Description = "创建新的租户记录。" - }, - ["tenant:read"] = new() - { - Code = "tenant:read", - Name = "租户查询", - Description = "查看租户列表与详情。" - }, - ["tenant:review"] = new() - { - Code = "tenant:review", - Name = "租户审核", - Description = "审核租户实名与资质信息。" - }, - ["tenant:subscription"] = new() - { - Code = "tenant:subscription", - Name = "租户订阅管理", - Description = "创建、续费或调整租户套餐订阅。" - }, - ["tenant:quota:check"] = new() - { - Code = "tenant:quota:check", - Name = "租户配额校验", - Description = "校验并占用门店、账号、短信、配送等配额。" - }, - ["tenant-package:read"] = new() - { - Code = "tenant-package:read", - Name = "套餐查询", - Description = "查看平台套餐定义列表与详情。" - }, - ["tenant-package:create"] = new() - { - Code = "tenant-package:create", - Name = "套餐创建", - Description = "创建平台套餐定义。" - }, - ["tenant-package:update"] = new() - { - Code = "tenant-package:update", - Name = "套餐更新", - Description = "更新平台套餐定义。" - }, - ["tenant-package:delete"] = new() - { - Code = "tenant-package:delete", - Name = "套餐删除", - Description = "删除平台套餐定义。" - }, - ["merchant:create"] = new() - { - Code = "merchant:create", - Name = "商户创建", - Description = "创建或提交商户入驻信息。" - }, - ["merchant:read"] = new() - { - Code = "merchant:read", - Name = "商户查看", - Description = "查看商户资料与审核状态。" - }, - ["merchant:update"] = new() - { - Code = "merchant:update", - Name = "商户更新", - Description = "更新商户资料或合同等信息。" - }, - ["merchant:delete"] = new() - { - Code = "merchant:delete", - Name = "商户删除", - Description = "删除或作废商户记录。" - }, - ["merchant:review"] = new() - { - Code = "merchant:review", - Name = "商户审核", - Description = "审核商户入驻、合同与证照。" - }, - ["merchant_category:read"] = new() - { - Code = "merchant_category:read", - Name = "类目查询", - Description = "查看经营类目列表。" - }, - ["merchant_category:create"] = new() - { - Code = "merchant_category:create", - Name = "类目创建", - Description = "新增经营类目。" - }, - ["merchant_category:update"] = new() - { - Code = "merchant_category:update", - Name = "类目更新", - Description = "调整经营类目名称或顺序。" - }, - ["merchant_category:delete"] = new() - { - Code = "merchant_category:delete", - Name = "类目删除", - Description = "删除经营类目。" - }, - ["store:create"] = new() - { - Code = "store:create", - Name = "门店创建", - Description = "创建新的门店记录。" - }, - ["store:read"] = new() - { - Code = "store:read", - Name = "门店查看", - Description = "查看门店列表与详情。" - }, - ["store:update"] = new() - { - Code = "store:update", - Name = "门店更新", - Description = "更新门店资料与配置。" - }, - ["store:delete"] = new() - { - Code = "store:delete", - Name = "门店删除", - Description = "删除或停用门店。" - }, - ["product:create"] = new() - { - Code = "product:create", - Name = "商品创建", - Description = "创建商品、菜品或规格。" - }, - ["product:read"] = new() - { - Code = "product:read", - Name = "商品查看", - Description = "查看商品/菜品列表与详情。" - }, - ["product:update"] = new() - { - Code = "product:update", - Name = "商品更新", - Description = "更新商品、菜品或上下架状态。" - }, - ["product:delete"] = new() - { - Code = "product:delete", - Name = "商品删除", - Description = "删除商品或菜品。" - }, - ["order:create"] = new() - { - Code = "order:create", - Name = "订单创建", - Description = "创建订单或手工录单。" - }, - ["order:read"] = new() - { - Code = "order:read", - Name = "订单查看", - Description = "查看订单列表与详情。" - }, - ["order:update"] = new() - { - Code = "order:update", - Name = "订单更新", - Description = "修改订单状态或履约信息。" - }, - ["order:delete"] = new() - { - Code = "order:delete", - Name = "订单删除", - Description = "删除或作废订单。" - }, - ["delivery:create"] = new() - { - Code = "delivery:create", - Name = "配送创建", - Description = "创建或发起配送单。" - }, - ["delivery:read"] = new() - { - Code = "delivery:read", - Name = "配送查看", - Description = "查看配送订单与轨迹。" - }, - ["delivery:update"] = new() - { - Code = "delivery:update", - Name = "配送更新", - Description = "更新配送状态或骑手信息。" - }, - ["delivery:delete"] = new() - { - Code = "delivery:delete", - Name = "配送删除", - Description = "取消或删除配送单。" - }, - ["payment:create"] = new() - { - Code = "payment:create", - Name = "支付创建", - Description = "创建收款或支付记录。" - }, - ["payment:read"] = new() - { - Code = "payment:read", - Name = "支付查看", - Description = "查看支付、退款记录。" - }, - ["payment:update"] = new() - { - Code = "payment:update", - Name = "支付更新", - Description = "更新支付状态或补充信息。" - }, - ["payment:delete"] = new() - { - Code = "payment:delete", - Name = "支付删除", - Description = "删除或作废支付记录。" - }, - ["dictionary:group:read"] = new() - { - Code = "dictionary:group:read", - Name = "字典分组查询", - Description = "查看字典分组与明细。" - }, - ["dictionary:group:create"] = new() - { - Code = "dictionary:group:create", - Name = "字典分组创建", - Description = "新增字典分组。" - }, - ["dictionary:group:update"] = new() - { - Code = "dictionary:group:update", - Name = "字典分组更新", - Description = "修改字典分组信息。" - }, - ["dictionary:group:delete"] = new() - { - Code = "dictionary:group:delete", - Name = "字典分组删除", - Description = "删除字典分组。" - }, - ["dictionary:item:create"] = new() - { - Code = "dictionary:item:create", - Name = "字典项创建", - Description = "新增字典项。" - }, - ["dictionary:item:update"] = new() - { - Code = "dictionary:item:update", - Name = "字典项更新", - Description = "调整字典项内容。" - }, - ["dictionary:item:delete"] = new() - { - Code = "dictionary:item:delete", - Name = "字典项删除", - Description = "删除字典项。" - }, - ["system-parameter:create"] = new() - { - Code = "system-parameter:create", - Name = "系统参数创建", - Description = "新增系统参数配置。" - }, - ["system-parameter:read"] = new() - { - Code = "system-parameter:read", - Name = "系统参数查询", - Description = "查看系统参数列表与详情。" - }, - ["system-parameter:update"] = new() - { - Code = "system-parameter:update", - Name = "系统参数更新", - Description = "更新系统参数配置。" - }, - ["system-parameter:delete"] = new() - { - Code = "system-parameter:delete", - Name = "系统参数删除", - Description = "删除系统参数配置。" - } - }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); - - private static readonly FrozenDictionary Templates = - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["platform-admin"] = new() - { - TemplateCode = "platform-admin", - Name = "平台管理员", - Description = "平台全量权限,负责租户、商户、配置与运维。", - Permissions = BuildPermissions(PermissionDefinitions.Keys) - }, - ["tenant-admin"] = new() - { - TemplateCode = "tenant-admin", - Name = "租户管理员", - Description = "管理本租户的门店、商品、订单与团队权限。", - Permissions = BuildPermissions(new[] - { - "identity:profile:read", - "identity:role:read", - "identity:role:create", - "identity:role:update", - "identity:role:delete", - "identity:role:bind-permission", - "identity:permission:read", - "identity:permission:create", - "identity:permission:update", - "identity:permission:delete", - "tenant:read", - "tenant:subscription", - "tenant:quota:check", - "merchant:read", - "merchant:update", - "merchant_category:read", - "merchant_category:create", - "merchant_category:update", - "merchant_category:delete", - "store:create", - "store:read", - "store:update", - "store:delete", - "product:create", - "product:read", - "product:update", - "product:delete", - "order:create", - "order:read", - "order:update", - "delivery:create", - "delivery:read", - "delivery:update", - "payment:create", - "payment:read", - "payment:update", - "dictionary:group:read", - "dictionary:group:create", - "dictionary:group:update", - "dictionary:group:delete", - "dictionary:item:create", - "dictionary:item:update", - "dictionary:item:delete", - "system-parameter:read" - }) - }, - ["store-manager"] = new() - { - TemplateCode = "store-manager", - Name = "店长", - Description = "负责门店日常运营、商品与订单管理。", - Permissions = BuildPermissions(new[] - { - "identity:profile:read", - "store:read", - "store:update", - "product:create", - "product:read", - "product:update", - "order:create", - "order:read", - "order:update", - "delivery:read", - "delivery:update", - "payment:read", - "payment:update", - "dictionary:group:read", - "dictionary:item:create", - "dictionary:item:update", - "dictionary:item:delete" - }) - }, - ["store-staff"] = new() - { - TemplateCode = "store-staff", - Name = "店员", - Description = "处理订单履约、配送跟踪与收款查询。", - Permissions = BuildPermissions(new[] - { - "identity:profile:read", - "store:read", - "product:read", - "order:read", - "order:update", - "delivery:read", - "payment:read" - }) - } - }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); - - /// - public IReadOnlyList GetTemplates() - => Templates.Values.ToArray(); - - /// - public RoleTemplateDefinition? FindByCode(string templateCode) - { - if (string.IsNullOrWhiteSpace(templateCode)) - { - return null; - } - - return Templates.GetValueOrDefault(templateCode); - } - - private static IReadOnlyList BuildPermissions(IEnumerable codes) - { - return codes - .Where(code => !string.IsNullOrWhiteSpace(code)) - .Select(code => code.Trim()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Select(code => PermissionDefinitions.TryGetValue(code, out var definition) - ? definition - : new PermissionTemplateDefinition - { - Code = code, - Name = code, - Description = "未在预置表中定义的权限,请补充描述。" - }) - .ToArray(); - } -} diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Entities/RoleTemplate.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/RoleTemplate.cs new file mode 100644 index 0000000..2570321 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/RoleTemplate.cs @@ -0,0 +1,29 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Identity.Entities; + +/// +/// 角色模板定义(平台级)。 +/// +public sealed class RoleTemplate : AuditableEntityBase +{ + /// + /// 模板编码(唯一)。 + /// + public string TemplateCode { get; set; } = string.Empty; + + /// + /// 模板名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 模板描述。 + /// + public string? Description { get; set; } + + /// + /// 是否启用。 + /// + public bool IsActive { get; set; } = true; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Entities/RoleTemplatePermission.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/RoleTemplatePermission.cs new file mode 100644 index 0000000..f3f9896 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/RoleTemplatePermission.cs @@ -0,0 +1,19 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Identity.Entities; + +/// +/// 角色模板-权限关系(平台级)。 +/// +public sealed class RoleTemplatePermission : AuditableEntityBase +{ + /// + /// 模板 ID。 + /// + public long RoleTemplateId { get; set; } + + /// + /// 权限编码。 + /// + public string PermissionCode { get; set; } = string.Empty; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleTemplateRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleTemplateRepository.cs new file mode 100644 index 0000000..963754a --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleTemplateRepository.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Identity.Entities; + +namespace TakeoutSaaS.Domain.Identity.Repositories; + +/// +/// 角色模板仓储。 +/// +public interface IRoleTemplateRepository +{ + Task> GetAllAsync(bool? isActive, CancellationToken cancellationToken = default); + + Task FindByCodeAsync(string templateCode, CancellationToken cancellationToken = default); + + Task> GetPermissionsAsync(long roleTemplateId, CancellationToken cancellationToken = default); + + Task>> GetPermissionsAsync(IEnumerable roleTemplateIds, CancellationToken cancellationToken = default); + + Task AddAsync(RoleTemplate template, IEnumerable permissionCodes, CancellationToken cancellationToken = default); + + Task UpdateAsync(RoleTemplate template, IEnumerable permissionCodes, CancellationToken cancellationToken = default); + + Task DeleteAsync(long roleTemplateId, CancellationToken cancellationToken = default); + + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs index 0c8dc63..d98587f 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs @@ -55,6 +55,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminSeedOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminSeedOptions.cs index 5a2f813..31ee1cf 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminSeedOptions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminSeedOptions.cs @@ -11,6 +11,11 @@ public sealed class AdminSeedOptions /// 初始用户列表。 /// public List Users { get; set; } = new(); + + /// + /// 角色模板种子列表。 + /// + public List RoleTemplates { get; set; } = new(); } /// @@ -56,3 +61,36 @@ public sealed class SeedUserOptions /// public string[] Permissions { get; set; } = Array.Empty(); } + +/// +/// 角色模板种子配置。 +/// +public sealed class RoleTemplateSeedOptions +{ + /// + /// 模板编码。 + /// + [Required] + public string TemplateCode { get; set; } = string.Empty; + + /// + /// 模板名称。 + /// + [Required] + public string Name { get; set; } = string.Empty; + + /// + /// 模板描述。 + /// + public string? Description { get; set; } + + /// + /// 是否启用。 + /// + public bool IsActive { get; set; } = true; + + /// + /// 权限编码集合。 + /// + public string[] Permissions { get; set; } = Array.Empty(); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRoleTemplateRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRoleTemplateRepository.cs new file mode 100644 index 0000000..e8c071b --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRoleTemplateRepository.cs @@ -0,0 +1,117 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Identity.Entities; +using TakeoutSaaS.Domain.Identity.Repositories; + +namespace TakeoutSaaS.Infrastructure.Identity.Persistence; + +/// +/// 角色模板仓储实现。 +/// +public sealed class EfRoleTemplateRepository(IdentityDbContext dbContext) : IRoleTemplateRepository +{ + public Task> GetAllAsync(bool? isActive, CancellationToken cancellationToken = default) + { + var query = dbContext.RoleTemplates.AsNoTracking(); + if (isActive.HasValue) + { + query = query.Where(x => x.IsActive == isActive.Value); + } + + return query + .OrderBy(x => x.TemplateCode) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + } + + public Task FindByCodeAsync(string templateCode, CancellationToken cancellationToken = default) + { + var normalized = templateCode.Trim(); + return dbContext.RoleTemplates.AsNoTracking().FirstOrDefaultAsync(x => x.TemplateCode == normalized, cancellationToken); + } + + public Task> GetPermissionsAsync(long roleTemplateId, CancellationToken cancellationToken = default) + { + return dbContext.RoleTemplatePermissions.AsNoTracking() + .Where(x => x.RoleTemplateId == roleTemplateId) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + } + + public async Task>> GetPermissionsAsync(IEnumerable roleTemplateIds, CancellationToken cancellationToken = default) + { + var ids = roleTemplateIds.Distinct().ToArray(); + if (ids.Length == 0) + { + return new Dictionary>(); + } + + var permissions = await dbContext.RoleTemplatePermissions.AsNoTracking() + .Where(x => ids.Contains(x.RoleTemplateId)) + .ToListAsync(cancellationToken); + + return permissions + .GroupBy(x => x.RoleTemplateId) + .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); + } + + public async Task AddAsync(RoleTemplate template, IEnumerable permissionCodes, CancellationToken cancellationToken = default) + { + template.TemplateCode = template.TemplateCode.Trim(); + template.Name = template.Name.Trim(); + await dbContext.RoleTemplates.AddAsync(template, cancellationToken); + await ReplacePermissionsInternalAsync(template, permissionCodes, cancellationToken); + } + + public async Task UpdateAsync(RoleTemplate template, IEnumerable permissionCodes, CancellationToken cancellationToken = default) + { + template.TemplateCode = template.TemplateCode.Trim(); + template.Name = template.Name.Trim(); + dbContext.RoleTemplates.Update(template); + await ReplacePermissionsInternalAsync(template, permissionCodes, cancellationToken); + } + + public async Task DeleteAsync(long roleTemplateId, CancellationToken cancellationToken = default) + { + var entity = await dbContext.RoleTemplates.FirstOrDefaultAsync(x => x.Id == roleTemplateId, cancellationToken); + if (entity != null) + { + var permissions = dbContext.RoleTemplatePermissions.Where(x => x.RoleTemplateId == roleTemplateId); + dbContext.RoleTemplatePermissions.RemoveRange(permissions); + dbContext.RoleTemplates.Remove(entity); + } + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => dbContext.SaveChangesAsync(cancellationToken); + + private async Task ReplacePermissionsInternalAsync(RoleTemplate template, IEnumerable permissionCodes, CancellationToken cancellationToken) + { + // 确保模板已持久化,便于 FK 正确填充 + if (!dbContext.Entry(template).IsKeySet || template.Id == 0) + { + await dbContext.SaveChangesAsync(cancellationToken); + } + + var normalized = permissionCodes + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Select(code => code.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var existing = await dbContext.RoleTemplatePermissions + .Where(x => x.RoleTemplateId == template.Id) + .ToListAsync(cancellationToken); + + dbContext.RoleTemplatePermissions.RemoveRange(existing); + + var toAdd = normalized.Select(code => new RoleTemplatePermission + { + RoleTemplateId = template.Id, + PermissionCode = code + }); + + await dbContext.RoleTemplatePermissions.AddRangeAsync(toAdd, cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs index a9246dd..f4ec0a0 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs @@ -12,6 +12,8 @@ using DomainIdentityUser = TakeoutSaaS.Domain.Identity.Entities.IdentityUser; using DomainPermission = TakeoutSaaS.Domain.Identity.Entities.Permission; using DomainRole = TakeoutSaaS.Domain.Identity.Entities.Role; using DomainRolePermission = TakeoutSaaS.Domain.Identity.Entities.RolePermission; +using DomainRoleTemplate = TakeoutSaaS.Domain.Identity.Entities.RoleTemplate; +using DomainRoleTemplatePermission = TakeoutSaaS.Domain.Identity.Entities.RoleTemplatePermission; using DomainUserRole = TakeoutSaaS.Domain.Identity.Entities.UserRole; namespace TakeoutSaaS.Infrastructure.Identity.Persistence; @@ -37,6 +39,8 @@ public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger return; } + await SeedRoleTemplatesAsync(context, options.RoleTemplates, cancellationToken); + foreach (var userOptions in options.Users) { using var tenantScope = EnterTenantScope(tenantContextAccessor, userOptions.TenantId); @@ -159,6 +163,66 @@ public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + private static async Task SeedRoleTemplatesAsync( + IdentityDbContext context, + IList templates, + CancellationToken cancellationToken) + { + if (templates is null || templates.Count == 0) + { + return; + } + + foreach (var templateOptions in templates) + { + if (string.IsNullOrWhiteSpace(templateOptions.TemplateCode) || string.IsNullOrWhiteSpace(templateOptions.Name)) + { + continue; + } + + var code = templateOptions.TemplateCode.Trim(); + var existing = await context.RoleTemplates.FirstOrDefaultAsync(x => x.TemplateCode == code, cancellationToken); + + if (existing == null) + { + existing = new DomainRoleTemplate + { + TemplateCode = code, + Name = templateOptions.Name.Trim(), + Description = templateOptions.Description, + IsActive = templateOptions.IsActive + }; + + await context.RoleTemplates.AddAsync(existing, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + else + { + existing.Name = templateOptions.Name.Trim(); + existing.Description = templateOptions.Description; + existing.IsActive = templateOptions.IsActive; + context.RoleTemplates.Update(existing); + await context.SaveChangesAsync(cancellationToken); + } + + var permissionCodes = NormalizeValues(templateOptions.Permissions); + var existingPermissions = await context.RoleTemplatePermissions + .Where(x => x.RoleTemplateId == existing.Id) + .ToListAsync(cancellationToken); + + context.RoleTemplatePermissions.RemoveRange(existingPermissions); + + var toAdd = permissionCodes.Select(code => new DomainRoleTemplatePermission + { + RoleTemplateId = existing.Id, + PermissionCode = code + }); + + await context.RoleTemplatePermissions.AddRangeAsync(toAdd, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + } + private static string[] NormalizeValues(string[]? values) => values == null ? [] diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs index dfe4d4f..854b265 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs @@ -34,6 +34,16 @@ public sealed class IdentityDbContext( /// public DbSet Roles => Set(); + /// + /// 角色模板集合(平台级)。 + /// + public DbSet RoleTemplates => Set(); + + /// + /// 角色模板权限集合。 + /// + public DbSet RoleTemplatePermissions => Set(); + /// /// 权限集合。 /// @@ -59,6 +69,8 @@ public sealed class IdentityDbContext( ConfigureIdentityUser(modelBuilder.Entity()); ConfigureMiniUser(modelBuilder.Entity()); ConfigureRole(modelBuilder.Entity()); + ConfigureRoleTemplate(modelBuilder.Entity()); + ConfigureRoleTemplatePermission(modelBuilder.Entity()); ConfigurePermission(modelBuilder.Entity()); ConfigureUserRole(modelBuilder.Entity()); ConfigureRolePermission(modelBuilder.Entity()); @@ -133,6 +145,28 @@ public sealed class IdentityDbContext( builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique(); } + private static void ConfigureRoleTemplate(EntityTypeBuilder builder) + { + builder.ToTable("role_templates"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TemplateCode).HasMaxLength(64).IsRequired(); + builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); + builder.Property(x => x.Description).HasMaxLength(256); + builder.Property(x => x.IsActive).IsRequired(); + ConfigureAuditableEntity(builder); + builder.HasIndex(x => x.TemplateCode).IsUnique(); + } + + private static void ConfigureRoleTemplatePermission(EntityTypeBuilder builder) + { + builder.ToTable("role_template_permissions"); + builder.HasKey(x => x.Id); + builder.Property(x => x.RoleTemplateId).IsRequired(); + builder.Property(x => x.PermissionCode).HasMaxLength(128).IsRequired(); + ConfigureAuditableEntity(builder); + builder.HasIndex(x => new { x.RoleTemplateId, x.PermissionCode }).IsUnique(); + } + private static void ConfigureUserRole(EntityTypeBuilder builder) { builder.ToTable("user_roles"); From 075906266a5731a89e2ae6a2ad9b0397b79ff2d1 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Wed, 3 Dec 2025 20:44:42 +0800 Subject: [PATCH 09/30] =?UTF-8?q?docs:=E6=9B=B4=E6=96=B0=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E7=BC=96=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Document/{API边界与自检清单.md => 15_API边界与自检清单.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Document/{API边界与自检清单.md => 15_API边界与自检清单.md} (100%) diff --git a/Document/API边界与自检清单.md b/Document/15_API边界与自检清单.md similarity index 100% rename from Document/API边界与自检清单.md rename to Document/15_API边界与自检清单.md From 9fe7d9606d6a82fd592beec3dd6f755c00418b6b Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Wed, 3 Dec 2025 21:08:28 +0800 Subject: [PATCH 10/30] =?UTF-8?q?feat:=20=E7=A7=9F=E6=88=B7=E8=B4=A6?= =?UTF-8?q?=E5=8D=95=E5=85=AC=E5=91=8A=E9=80=9A=E7=9F=A5=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Document/12_BusinessTodo.md | 4 +- Document/15_API边界与自检清单.md | 4 +- .../TenantAnnouncementsController.cs | 106 ++++++++++++++++++ .../Controllers/TenantBillingsController.cs | 79 +++++++++++++ .../TenantNotificationsController.cs | 49 ++++++++ .../appsettings.Seed.Development.json | 27 +++++ .../CreateTenantAnnouncementCommand.cs | 20 ++++ .../Commands/CreateTenantBillingCommand.cs | 21 ++++ .../DeleteTenantAnnouncementCommand.cs | 12 ++ .../MarkTenantAnnouncementReadCommand.cs | 13 +++ .../Commands/MarkTenantBillingPaidCommand.cs | 15 +++ .../MarkTenantNotificationReadCommand.cs | 13 +++ .../UpdateTenantAnnouncementCommand.cs | 21 ++++ .../App/Tenants/Dto/TenantAnnouncementDto.cs | 35 ++++++ .../App/Tenants/Dto/TenantBillingDto.cs | 33 ++++++ .../App/Tenants/Dto/TenantNotificationDto.cs | 31 +++++ .../CreateTenantAnnouncementCommandHandler.cs | 41 +++++++ .../CreateTenantBillingCommandHandler.cs | 42 +++++++ .../DeleteTenantAnnouncementCommandHandler.cs | 19 ++++ .../GetTenantAnnouncementQueryHandler.cs | 28 +++++ .../Handlers/GetTenantBillQueryHandler.cs | 19 ++++ ...arkTenantAnnouncementReadCommandHandler.cs | 49 ++++++++ .../MarkTenantBillingPaidCommandHandler.cs | 32 ++++++ ...arkTenantNotificationReadCommandHandler.cs | 31 +++++ .../SearchTenantAnnouncementsQueryHandler.cs | 54 +++++++++ .../Handlers/SearchTenantBillsQueryHandler.cs | 27 +++++ .../SearchTenantNotificationsQueryHandler.cs | 33 ++++++ .../UpdateTenantAnnouncementCommandHandler.cs | 42 +++++++ .../Queries/GetTenantAnnouncementQuery.cs | 13 +++ .../App/Tenants/Queries/GetTenantBillQuery.cs | 13 +++ .../Queries/SearchTenantAnnouncementsQuery.cs | 19 ++++ .../Tenants/Queries/SearchTenantBillsQuery.cs | 19 ++++ .../Queries/SearchTenantNotificationsQuery.cs | 18 +++ .../App/Tenants/TenantMapping.cs | 45 ++++++++ .../Tenants/Entities/TenantAnnouncement.cs | 45 ++++++++ .../Entities/TenantAnnouncementRead.cs | 24 ++++ .../Tenants/Enums/TenantAnnouncementType.cs | 22 ++++ .../ITenantAnnouncementReadRepository.cs | 20 ++++ .../ITenantAnnouncementRepository.cs | 30 +++++ .../Repositories/ITenantBillingRepository.cs | 30 +++++ .../ITenantNotificationRepository.cs | 29 +++++ .../AppServiceCollectionExtensions.cs | 4 + .../App/Persistence/TakeoutAppDbContext.cs | 33 ++++++ .../EfTenantAnnouncementReadRepository.cs | 38 +++++++ .../EfTenantAnnouncementRepository.cs | 78 +++++++++++++ .../Repositories/EfTenantBillingRepository.cs | 73 ++++++++++++ .../EfTenantNotificationRepository.cs | 73 ++++++++++++ 47 files changed, 1522 insertions(+), 4 deletions(-) create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/TenantAnnouncementsController.cs create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/TenantBillingsController.cs create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/TenantNotificationsController.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantAnnouncementCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantBillingCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/DeleteTenantAnnouncementCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantAnnouncementReadCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantBillingPaidCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantNotificationReadCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantAnnouncementCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantAnnouncementDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantBillingDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantNotificationDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantAnnouncementCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantBillingCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantAnnouncementCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAnnouncementQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantBillQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantAnnouncementReadCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantBillingPaidCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantNotificationReadCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantAnnouncementsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantBillsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantNotificationsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantAnnouncementCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantAnnouncementQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantBillQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantAnnouncementsQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantBillsQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantNotificationsQuery.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAnnouncement.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAnnouncementRead.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantAnnouncementType.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementReadRepository.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementRepository.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantNotificationRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementReadRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantBillingRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantNotificationRepository.cs diff --git a/Document/12_BusinessTodo.md b/Document/12_BusinessTodo.md index 02f9c18..ff43c0e 100644 --- a/Document/12_BusinessTodo.md +++ b/Document/12_BusinessTodo.md @@ -12,8 +12,8 @@ - 已交付:新增模板目录 `RoleTemplateProvider`(`src/Application/TakeoutSaaS.Application/Identity/Templates`),提供四个预置角色与权限定义;应用层新增模板列表/详情查询、复制与租户批量初始化命令(Handlers 位于 `src/Application/TakeoutSaaS.Application/Identity/Handlers`)。管理端 `RolesController` 暴露模板列表、详情、按模板复制、批量初始化端点(`src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs`),复制时自动补齐缺失权限并保留租户自定义授权。 - [x] 配额与套餐:TenantPackage CRUD、订阅/续费/配额校验(门店/账号/短信/配送单量),超额返回 409 并记录 TenantQuotaUsage。 - 已交付:新增套餐仓储与命令/查询/DTO(`src/Application/TakeoutSaaS.Application/App/Tenants`),Admin 端新增 `TenantPackagesController` 提供套餐列表/详情/创建/更新/删除接口。新增配额校验命令与租户接口 `/api/admin/v1/tenants/{id}/quotas/check`,基于当前订阅套餐限额校验并占用配额,超额抛出 409 并写入 `TenantQuotaUsage`。仓储注册于 `AddAppInfrastructure`。 -- [ ] 租户运营面板:欠费/到期告警、账单列表、公告通知接口,支持已读状态并在 Admin UI 展示。 - - 当前:`SystemParametersController` 仅负责普通参数 CRUD(`src/Api/TakeoutSaaS.AdminApi/Controllers/SystemParametersController.cs:15-104`),未包含租户账单、公告或通知接口。 +- [x] 租户运营面板:欠费/到期告警、账单列表、公告通知接口,支持已读状态并在 Admin UI 展示。 + - 已交付:新增账单/公告/通知实体与仓储,Admin 端提供 `/tenants/{id}/billings`(列表/详情/创建/标记支付)、`/announcements`(列表/详情/创建/更新/删除/已读)、`/notifications`(列表/已读)端点;权限码补充 `tenant-bill:*`、`tenant-announcement:*`、`tenant-notification:*`,种子模板更新;配额/订阅告警可通过通知表承载。 - [ ] 门店管理:Store/StoreBusinessHour/StoreDeliveryZone/StoreHoliday CRUD 完整,含 GeoJSON 配送范围及能力开关。 - [ ] 桌码管理:批量生成桌码、绑定区域/容量、导出二维码 ZIP(POST /api/admin/stores/{id}/tables 可下载)。 - [ ] 员工排班:创建员工、绑定门店角色、维护 StoreEmployeeShift,可查询未来 7 日排班。 diff --git a/Document/15_API边界与自检清单.md b/Document/15_API边界与自检清单.md index af5bc58..53e284a 100644 --- a/Document/15_API边界与自检清单.md +++ b/Document/15_API边界与自检清单.md @@ -8,14 +8,14 @@ - **鉴权**:JWT + RBAC(`[Authorize]` + `PermissionAuthorize`),必须带租户头 `X-Tenant-Id/Code`。 - **路由前缀**:`api/admin/v{version}/...`。 - **DTO/约束**:仅管理字段,禁止返回 C 端敏感信息;long -> string;严禁实体直接返回。 -- **现有控制器**:`AuthController`、`DeliveriesController`、`DictionaryController`、`FilesController`、`MerchantsController`、`OrdersController`、`PaymentsController`、`PermissionsController`、`RolesController`、`StoresController`、`SystemParametersController`、`TenantPackagesController`、`TenantsController`、`UserPermissionsController`、`HealthController`。 +- **现有控制器**:`AuthController`、`DeliveriesController`、`DictionaryController`、`FilesController`、`MerchantsController`、`OrdersController`、`PaymentsController`、`PermissionsController`、`RolesController`、`StoresController`、`SystemParametersController`、`TenantPackagesController`、`TenantsController`、`TenantBillingsController`、`TenantAnnouncementsController`、`TenantNotificationsController`、`UserPermissionsController`、`HealthController`。 - **自检清单**: 1. 是否需要权限/租户过滤?未加则补 `[Authorize]` + 租户解析。 2. 是否调用了应用层 CQRS,而非在 Controller 写业务? 3. DTO 是否按管理口径,未暴露用户端字段? 4. 是否使用参数化/AsNoTracking/投影,避免 N+1? 5. 路由和 Swagger 示例是否含租户/权限说明? -- **自检记录**:RolesController 新增模板列表/详情/复制/初始化端点,均已套用 `[Authorize]` + `PermissionAuthorize`、仅调用 CQRS/DTO,依赖租户头隔离。TenantPackagesController 与 TenantsController(配额校验) 均使用权限码、DTO 映射,配额校验要求携带租户头防越权。 +- **自检记录**:RolesController 新增模板列表/详情/复制/初始化端点,均已套用 `[Authorize]` + `PermissionAuthorize`、仅调用 CQRS/DTO,依赖租户头隔离。TenantPackagesController 与 TenantsController(配额校验) 均使用权限码、DTO 映射,配额校验要求携带租户头防越权。新增租户账单/公告/通知控制器,全部采用 CQRS、权限校验与租户参数,列表分页、未暴露实体。 ## 2. UserApi(C 端用户) - **面向对象**:App/H5 普通用户。 diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantAnnouncementsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantAnnouncementsController.cs new file mode 100644 index 0000000..e556b3d --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantAnnouncementsController.cs @@ -0,0 +1,106 @@ +using System.ComponentModel.DataAnnotations; +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.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 租户公告管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/tenants/{tenantId:long}/announcements")] +public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiController +{ + /// + /// 分页查询公告。 + /// + [HttpGet] + [PermissionAuthorize("tenant-announcement:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Search(long tenantId, [FromQuery] SearchTenantAnnouncementsQuery query, CancellationToken cancellationToken) + { + query = query with { TenantId = tenantId }; + var result = await mediator.Send(query, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 公告详情。 + /// + [HttpGet("{announcementId:long}")] + [PermissionAuthorize("tenant-announcement:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Detail(long tenantId, long announcementId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetTenantAnnouncementQuery { TenantId = tenantId, AnnouncementId = announcementId }, cancellationToken); + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "公告不存在") + : ApiResponse.Ok(result); + } + + /// + /// 创建公告。 + /// + [HttpPost] + [PermissionAuthorize("tenant-announcement:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create(long tenantId, [FromBody, Required] CreateTenantAnnouncementCommand command, CancellationToken cancellationToken) + { + command = command with { TenantId = tenantId }; + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 更新公告。 + /// + [HttpPut("{announcementId:long}")] + [PermissionAuthorize("tenant-announcement:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Update(long tenantId, long announcementId, [FromBody, Required] UpdateTenantAnnouncementCommand command, CancellationToken cancellationToken) + { + command = command with { TenantId = tenantId, AnnouncementId = announcementId }; + var result = await mediator.Send(command, cancellationToken); + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "公告不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除公告。 + /// + [HttpDelete("{announcementId:long}")] + [PermissionAuthorize("tenant-announcement:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Delete(long tenantId, long announcementId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new DeleteTenantAnnouncementCommand { TenantId = tenantId, AnnouncementId = announcementId }, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 标记公告已读。 + /// + [HttpPost("{announcementId:long}/read")] + [PermissionAuthorize("tenant-announcement:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> MarkRead(long tenantId, long announcementId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new MarkTenantAnnouncementReadCommand { TenantId = tenantId, AnnouncementId = announcementId }, cancellationToken); + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "公告不存在") + : ApiResponse.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantBillingsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantBillingsController.cs new file mode 100644 index 0000000..fb2093f --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantBillingsController.cs @@ -0,0 +1,79 @@ +using System.ComponentModel.DataAnnotations; +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.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 租户账单管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/tenants/{tenantId:long}/billings")] +public sealed class TenantBillingsController(IMediator mediator) : BaseApiController +{ + /// + /// 分页查询账单。 + /// + [HttpGet] + [PermissionAuthorize("tenant-bill:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Search(long tenantId, [FromQuery] SearchTenantBillsQuery query, CancellationToken cancellationToken) + { + query = query with { TenantId = tenantId }; + var result = await mediator.Send(query, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 账单详情。 + /// + [HttpGet("{billingId:long}")] + [PermissionAuthorize("tenant-bill:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Detail(long tenantId, long billingId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetTenantBillQuery { TenantId = tenantId, BillingId = billingId }, cancellationToken); + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "账单不存在") + : ApiResponse.Ok(result); + } + + /// + /// 创建账单。 + /// + [HttpPost] + [PermissionAuthorize("tenant-bill:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create(long tenantId, [FromBody, Required] CreateTenantBillingCommand command, CancellationToken cancellationToken) + { + command = command with { TenantId = tenantId }; + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 标记账单已支付。 + /// + [HttpPost("{billingId:long}/pay")] + [PermissionAuthorize("tenant-bill:pay")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> MarkPaid(long tenantId, long billingId, [FromBody, Required] MarkTenantBillingPaidCommand command, CancellationToken cancellationToken) + { + command = command with { TenantId = tenantId, BillingId = billingId }; + var result = await mediator.Send(command, cancellationToken); + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "账单不存在") + : ApiResponse.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantNotificationsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantNotificationsController.cs new file mode 100644 index 0000000..84babb5 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantNotificationsController.cs @@ -0,0 +1,49 @@ +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.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 租户通知接口。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/tenants/{tenantId:long}/notifications")] +public sealed class TenantNotificationsController(IMediator mediator) : BaseApiController +{ + /// + /// 分页查询通知。 + /// + [HttpGet] + [PermissionAuthorize("tenant-notification:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Search(long tenantId, [FromQuery] SearchTenantNotificationsQuery query, CancellationToken cancellationToken) + { + query = query with { TenantId = tenantId }; + var result = await mediator.Send(query, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 标记通知已读。 + /// + [HttpPost("{notificationId:long}/read")] + [PermissionAuthorize("tenant-notification:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> MarkRead(long tenantId, long notificationId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new MarkTenantNotificationReadCommand { TenantId = tenantId, NotificationId = notificationId }, cancellationToken); + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "通知不存在") + : ApiResponse.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json index 3092532..6ebda51 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json @@ -60,6 +60,15 @@ "role-template:create", "role-template:update", "role-template:delete", + "tenant-bill:read", + "tenant-bill:create", + "tenant-bill:pay", + "tenant-announcement:read", + "tenant-announcement:create", + "tenant-announcement:update", + "tenant-announcement:delete", + "tenant-notification:read", + "tenant-notification:update", "tenant:create", "tenant:read", "tenant:review", @@ -127,6 +136,15 @@ "identity:permission:create", "identity:permission:update", "identity:permission:delete", + "tenant-bill:read", + "tenant-bill:create", + "tenant-bill:pay", + "tenant-announcement:read", + "tenant-announcement:create", + "tenant-announcement:update", + "tenant-announcement:delete", + "tenant-notification:read", + "tenant-notification:update", "tenant:read", "tenant:subscription", "tenant:quota:check", @@ -226,6 +244,15 @@ "role-template:create", "role-template:update", "role-template:delete", + "tenant-bill:read", + "tenant-bill:create", + "tenant-bill:pay", + "tenant-announcement:read", + "tenant-announcement:create", + "tenant-announcement:update", + "tenant-announcement:delete", + "tenant-notification:read", + "tenant-notification:update", "tenant:create", "tenant:read", "tenant:review", diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantAnnouncementCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantAnnouncementCommand.cs new file mode 100644 index 0000000..da68b6f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantAnnouncementCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 创建租户公告命令。 +/// +public sealed record CreateTenantAnnouncementCommand : IRequest +{ + public long TenantId { get; init; } + public string Title { get; init; } = string.Empty; + public string Content { get; init; } = string.Empty; + public TenantAnnouncementType AnnouncementType { get; init; } = TenantAnnouncementType.System; + public int Priority { get; init; } = 0; + public DateTime EffectiveFrom { get; init; } = DateTime.UtcNow; + public DateTime? EffectiveTo { get; init; } + public bool IsActive { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantBillingCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantBillingCommand.cs new file mode 100644 index 0000000..16c771d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantBillingCommand.cs @@ -0,0 +1,21 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 创建租户账单命令。 +/// +public sealed record CreateTenantBillingCommand : IRequest +{ + public long TenantId { get; init; } + public string StatementNo { get; init; } = string.Empty; + public DateTime PeriodStart { get; init; } + public DateTime PeriodEnd { get; init; } + public decimal AmountDue { get; init; } + public decimal AmountPaid { get; init; } + public TenantBillingStatus Status { get; init; } = TenantBillingStatus.Pending; + public DateTime DueDate { get; init; } + public string? LineItemsJson { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/DeleteTenantAnnouncementCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/DeleteTenantAnnouncementCommand.cs new file mode 100644 index 0000000..c67877c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/DeleteTenantAnnouncementCommand.cs @@ -0,0 +1,12 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 删除租户公告命令。 +/// +public sealed record DeleteTenantAnnouncementCommand : IRequest +{ + public long TenantId { get; init; } + public long AnnouncementId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantAnnouncementReadCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantAnnouncementReadCommand.cs new file mode 100644 index 0000000..85c679a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantAnnouncementReadCommand.cs @@ -0,0 +1,13 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 标记公告已读命令。 +/// +public sealed record MarkTenantAnnouncementReadCommand : IRequest +{ + public long TenantId { get; init; } + public long AnnouncementId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantBillingPaidCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantBillingPaidCommand.cs new file mode 100644 index 0000000..5479882 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantBillingPaidCommand.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 标记租户账单已支付命令。 +/// +public sealed record MarkTenantBillingPaidCommand : IRequest +{ + public long TenantId { get; init; } + public long BillingId { get; init; } + public decimal AmountPaid { get; init; } + public DateTime PaidAt { get; init; } = DateTime.UtcNow; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantNotificationReadCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantNotificationReadCommand.cs new file mode 100644 index 0000000..31ddb58 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantNotificationReadCommand.cs @@ -0,0 +1,13 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 标记通知已读命令。 +/// +public sealed record MarkTenantNotificationReadCommand : IRequest +{ + public long TenantId { get; init; } + public long NotificationId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantAnnouncementCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantAnnouncementCommand.cs new file mode 100644 index 0000000..57c6569 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantAnnouncementCommand.cs @@ -0,0 +1,21 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 更新租户公告命令。 +/// +public sealed record UpdateTenantAnnouncementCommand : IRequest +{ + public long TenantId { get; init; } + public long AnnouncementId { get; init; } + public string Title { get; init; } = string.Empty; + public string Content { get; init; } = string.Empty; + public TenantAnnouncementType AnnouncementType { get; init; } = TenantAnnouncementType.System; + public int Priority { get; init; } = 0; + public DateTime EffectiveFrom { get; init; } = DateTime.UtcNow; + public DateTime? EffectiveTo { get; init; } + public bool IsActive { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantAnnouncementDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantAnnouncementDto.cs new file mode 100644 index 0000000..5a0a7bf --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantAnnouncementDto.cs @@ -0,0 +1,35 @@ +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 TenantAnnouncementDto +{ + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + public string Title { get; init; } = string.Empty; + + public string Content { get; init; } = string.Empty; + + public TenantAnnouncementType AnnouncementType { get; init; } + + public int Priority { get; init; } + + public DateTime EffectiveFrom { get; init; } + + public DateTime? EffectiveTo { get; init; } + + public bool IsActive { get; init; } + + public bool IsRead { get; init; } + + public DateTime? ReadAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantBillingDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantBillingDto.cs new file mode 100644 index 0000000..d2b426b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantBillingDto.cs @@ -0,0 +1,33 @@ +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 TenantBillingDto +{ + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + public string StatementNo { get; init; } = string.Empty; + + public DateTime PeriodStart { get; init; } + + public DateTime PeriodEnd { get; init; } + + public decimal AmountDue { get; init; } + + public decimal AmountPaid { get; init; } + + public TenantBillingStatus Status { get; init; } + + public DateTime DueDate { get; init; } + + public string? LineItemsJson { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantNotificationDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantNotificationDto.cs new file mode 100644 index 0000000..e6ab6a9 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantNotificationDto.cs @@ -0,0 +1,31 @@ +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 TenantNotificationDto +{ + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + public string Title { get; init; } = string.Empty; + + public string Message { get; init; } = string.Empty; + + public TenantNotificationChannel Channel { get; init; } + + public TenantNotificationSeverity Severity { get; init; } + + public DateTime SentAt { get; init; } + + public DateTime? ReadAt { get; init; } + + public string? MetadataJson { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantAnnouncementCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantAnnouncementCommandHandler.cs new file mode 100644 index 0000000..ba48bb6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantAnnouncementCommandHandler.cs @@ -0,0 +1,41 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 创建公告处理器。 +/// +public sealed class CreateTenantAnnouncementCommandHandler(ITenantAnnouncementRepository announcementRepository) + : IRequestHandler +{ + public async Task Handle(CreateTenantAnnouncementCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Title) || string.IsNullOrWhiteSpace(request.Content)) + { + throw new BusinessException(ErrorCodes.BadRequest, "公告标题和内容不能为空"); + } + + var announcement = new TenantAnnouncement + { + TenantId = request.TenantId, + Title = request.Title.Trim(), + Content = request.Content, + AnnouncementType = request.AnnouncementType, + Priority = request.Priority, + EffectiveFrom = request.EffectiveFrom, + EffectiveTo = request.EffectiveTo, + IsActive = request.IsActive + }; + + await announcementRepository.AddAsync(announcement, cancellationToken); + await announcementRepository.SaveChangesAsync(cancellationToken); + + return announcement.ToDto(false, null); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantBillingCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantBillingCommandHandler.cs new file mode 100644 index 0000000..f07f889 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantBillingCommandHandler.cs @@ -0,0 +1,42 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 创建租户账单处理器。 +/// +public sealed class CreateTenantBillingCommandHandler(ITenantBillingRepository billingRepository) + : IRequestHandler +{ + public async Task Handle(CreateTenantBillingCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.StatementNo)) + { + throw new BusinessException(ErrorCodes.BadRequest, "账单编号不能为空"); + } + + var bill = new TenantBillingStatement + { + TenantId = request.TenantId, + StatementNo = request.StatementNo.Trim(), + PeriodStart = request.PeriodStart, + PeriodEnd = request.PeriodEnd, + AmountDue = request.AmountDue, + AmountPaid = request.AmountPaid, + Status = request.Status, + DueDate = request.DueDate, + LineItemsJson = request.LineItemsJson + }; + + await billingRepository.AddAsync(bill, cancellationToken); + await billingRepository.SaveChangesAsync(cancellationToken); + + return bill.ToDto(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantAnnouncementCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantAnnouncementCommandHandler.cs new file mode 100644 index 0000000..5464299 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantAnnouncementCommandHandler.cs @@ -0,0 +1,19 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 删除公告处理器。 +/// +public sealed class DeleteTenantAnnouncementCommandHandler(ITenantAnnouncementRepository announcementRepository) + : IRequestHandler +{ + public async Task Handle(DeleteTenantAnnouncementCommand request, CancellationToken cancellationToken) + { + await announcementRepository.DeleteAsync(request.TenantId, request.AnnouncementId, cancellationToken); + await announcementRepository.SaveChangesAsync(cancellationToken); + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAnnouncementQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAnnouncementQueryHandler.cs new file mode 100644 index 0000000..bfc8f87 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAnnouncementQueryHandler.cs @@ -0,0 +1,28 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 公告详情查询处理器。 +/// +public sealed class GetTenantAnnouncementQueryHandler( + ITenantAnnouncementRepository announcementRepository, + ITenantAnnouncementReadRepository readRepository) + : IRequestHandler +{ + public async Task Handle(GetTenantAnnouncementQuery request, CancellationToken cancellationToken) + { + var announcement = await announcementRepository.FindByIdAsync(request.TenantId, request.AnnouncementId, cancellationToken); + if (announcement == null) + { + return null; + } + + var reads = await readRepository.GetByAnnouncementAsync(request.TenantId, request.AnnouncementId, cancellationToken); + var readRecord = reads.FirstOrDefault(); + return announcement.ToDto(readRecord != null, readRecord?.ReadAt); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantBillQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantBillQueryHandler.cs new file mode 100644 index 0000000..c3e96d6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantBillQueryHandler.cs @@ -0,0 +1,19 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 账单详情查询处理器。 +/// +public sealed class GetTenantBillQueryHandler(ITenantBillingRepository billingRepository) + : IRequestHandler +{ + public async Task Handle(GetTenantBillQuery request, CancellationToken cancellationToken) + { + var bill = await billingRepository.FindByIdAsync(request.TenantId, request.BillingId, cancellationToken); + return bill?.ToDto(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantAnnouncementReadCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantAnnouncementReadCommandHandler.cs new file mode 100644 index 0000000..63f9604 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantAnnouncementReadCommandHandler.cs @@ -0,0 +1,49 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Entities; +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 MarkTenantAnnouncementReadCommandHandler( + ITenantAnnouncementRepository announcementRepository, + ITenantAnnouncementReadRepository readRepository, + ICurrentUserAccessor? currentUserAccessor = null) + : IRequestHandler +{ + public async Task Handle(MarkTenantAnnouncementReadCommand request, CancellationToken cancellationToken) + { + var announcement = await announcementRepository.FindByIdAsync(request.TenantId, request.AnnouncementId, cancellationToken); + if (announcement == null) + { + return null; + } + + var userId = currentUserAccessor?.UserId ?? 0; + var existing = await readRepository.FindAsync(request.TenantId, request.AnnouncementId, userId == 0 ? null : userId, cancellationToken); + + if (existing == null) + { + var record = new TenantAnnouncementRead + { + TenantId = request.TenantId, + AnnouncementId = request.AnnouncementId, + UserId = userId == 0 ? null : userId, + ReadAt = DateTime.UtcNow + }; + + await readRepository.AddAsync(record, cancellationToken); + await readRepository.SaveChangesAsync(cancellationToken); + existing = record; + } + + return announcement.ToDto(true, existing.ReadAt); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantBillingPaidCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantBillingPaidCommandHandler.cs new file mode 100644 index 0000000..3d708af --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantBillingPaidCommandHandler.cs @@ -0,0 +1,32 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 标记账单支付处理器。 +/// +public sealed class MarkTenantBillingPaidCommandHandler(ITenantBillingRepository billingRepository) + : IRequestHandler +{ + public async Task Handle(MarkTenantBillingPaidCommand request, CancellationToken cancellationToken) + { + var bill = await billingRepository.FindByIdAsync(request.TenantId, request.BillingId, cancellationToken); + if (bill == null) + { + return null; + } + + bill.AmountPaid = request.AmountPaid; + bill.Status = TenantBillingStatus.Paid; + bill.DueDate = bill.DueDate; + + await billingRepository.UpdateAsync(bill, cancellationToken); + await billingRepository.SaveChangesAsync(cancellationToken); + + return bill.ToDto(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantNotificationReadCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantNotificationReadCommandHandler.cs new file mode 100644 index 0000000..48a8400 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantNotificationReadCommandHandler.cs @@ -0,0 +1,31 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 标记通知已读处理器。 +/// +public sealed class MarkTenantNotificationReadCommandHandler(ITenantNotificationRepository notificationRepository) + : IRequestHandler +{ + public async Task Handle(MarkTenantNotificationReadCommand request, CancellationToken cancellationToken) + { + var notification = await notificationRepository.FindByIdAsync(request.TenantId, request.NotificationId, cancellationToken); + if (notification == null) + { + return null; + } + + if (notification.ReadAt == null) + { + notification.ReadAt = DateTime.UtcNow; + await notificationRepository.UpdateAsync(notification, cancellationToken); + await notificationRepository.SaveChangesAsync(cancellationToken); + } + + return notification.ToDto(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantAnnouncementsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantAnnouncementsQueryHandler.cs new file mode 100644 index 0000000..ee5f689 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantAnnouncementsQueryHandler.cs @@ -0,0 +1,54 @@ +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 SearchTenantAnnouncementsQueryHandler( + ITenantAnnouncementRepository announcementRepository, + ITenantAnnouncementReadRepository announcementReadRepository) + : IRequestHandler> +{ + public async Task> Handle(SearchTenantAnnouncementsQuery request, CancellationToken cancellationToken) + { + var effectiveAt = request.OnlyEffective == true ? DateTime.UtcNow : (DateTime?)null; + var announcements = await announcementRepository.SearchAsync(request.TenantId, request.AnnouncementType, request.IsActive, effectiveAt, cancellationToken); + + var readMap = new Dictionary(); + foreach (var announcement in announcements) + { + var reads = await announcementReadRepository.GetByAnnouncementAsync(request.TenantId, announcement.Id, cancellationToken); + var readRecord = reads.FirstOrDefault(); + if (readRecord != null) + { + readMap[announcement.Id] = (true, readRecord.ReadAt); + } + } + + var ordered = announcements + .OrderByDescending(x => x.Priority) + .ThenByDescending(x => x.CreatedAt) + .ToList(); + + var page = request.Page <= 0 ? 1 : request.Page; + var size = request.PageSize <= 0 ? 20 : request.PageSize; + + var items = ordered + .Skip((page - 1) * size) + .Take(size) + .Select(a => + { + readMap.TryGetValue(a.Id, out var read); + return a.ToDto(read.isRead, read.readAt); + }) + .ToList(); + + return new PagedResult(items, page, size, ordered.Count); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantBillsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantBillsQueryHandler.cs new file mode 100644 index 0000000..5bd2ec7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantBillsQueryHandler.cs @@ -0,0 +1,27 @@ +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 SearchTenantBillsQueryHandler(ITenantBillingRepository billingRepository) + : IRequestHandler> +{ + public async Task> Handle(SearchTenantBillsQuery request, CancellationToken cancellationToken) + { + var bills = await billingRepository.SearchAsync(request.TenantId, request.Status, request.From, request.To, cancellationToken); + + var ordered = bills.OrderByDescending(x => x.PeriodEnd).ToList(); + var page = request.Page <= 0 ? 1 : request.Page; + var size = request.PageSize <= 0 ? 20 : request.PageSize; + var items = ordered.Skip((page - 1) * size).Take(size).Select(x => x.ToDto()).ToList(); + + return new PagedResult(items, page, size, ordered.Count); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantNotificationsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantNotificationsQueryHandler.cs new file mode 100644 index 0000000..ec05cd6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantNotificationsQueryHandler.cs @@ -0,0 +1,33 @@ +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 SearchTenantNotificationsQueryHandler(ITenantNotificationRepository notificationRepository) + : IRequestHandler> +{ + public async Task> Handle(SearchTenantNotificationsQuery request, CancellationToken cancellationToken) + { + var notifications = await notificationRepository.SearchAsync( + request.TenantId, + request.Severity, + request.UnreadOnly, + null, + null, + cancellationToken); + + var ordered = notifications.OrderByDescending(x => x.SentAt).ToList(); + var page = request.Page <= 0 ? 1 : request.Page; + var size = request.PageSize <= 0 ? 20 : request.PageSize; + var items = ordered.Skip((page - 1) * size).Take(size).Select(x => x.ToDto()).ToList(); + + return new PagedResult(items, page, size, ordered.Count); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantAnnouncementCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantAnnouncementCommandHandler.cs new file mode 100644 index 0000000..ea76e80 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantAnnouncementCommandHandler.cs @@ -0,0 +1,42 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 更新公告处理器。 +/// +public sealed class UpdateTenantAnnouncementCommandHandler(ITenantAnnouncementRepository announcementRepository) + : IRequestHandler +{ + public async Task Handle(UpdateTenantAnnouncementCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Title) || string.IsNullOrWhiteSpace(request.Content)) + { + throw new BusinessException(ErrorCodes.BadRequest, "公告标题和内容不能为空"); + } + + var announcement = await announcementRepository.FindByIdAsync(request.TenantId, request.AnnouncementId, cancellationToken); + if (announcement == null) + { + return null; + } + + announcement.Title = request.Title.Trim(); + announcement.Content = request.Content; + announcement.AnnouncementType = request.AnnouncementType; + announcement.Priority = request.Priority; + announcement.EffectiveFrom = request.EffectiveFrom; + announcement.EffectiveTo = request.EffectiveTo; + announcement.IsActive = request.IsActive; + + await announcementRepository.UpdateAsync(announcement, cancellationToken); + await announcementRepository.SaveChangesAsync(cancellationToken); + + return announcement.ToDto(false, null); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantAnnouncementQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantAnnouncementQuery.cs new file mode 100644 index 0000000..52bdcfb --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantAnnouncementQuery.cs @@ -0,0 +1,13 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 公告详情查询。 +/// +public sealed record GetTenantAnnouncementQuery : IRequest +{ + public long TenantId { get; init; } + public long AnnouncementId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantBillQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantBillQuery.cs new file mode 100644 index 0000000..f22eb99 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantBillQuery.cs @@ -0,0 +1,13 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 获取账单详情查询。 +/// +public sealed record GetTenantBillQuery : IRequest +{ + public long TenantId { get; init; } + public long BillingId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantAnnouncementsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantAnnouncementsQuery.cs new file mode 100644 index 0000000..3f059ad --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantAnnouncementsQuery.cs @@ -0,0 +1,19 @@ +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 SearchTenantAnnouncementsQuery : IRequest> +{ + public long TenantId { get; init; } + public TenantAnnouncementType? AnnouncementType { get; init; } + public bool? IsActive { get; init; } + public bool? OnlyEffective { get; init; } + public int Page { get; init; } = 1; + public int PageSize { get; init; } = 20; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantBillsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantBillsQuery.cs new file mode 100644 index 0000000..b8747e0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantBillsQuery.cs @@ -0,0 +1,19 @@ +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 SearchTenantBillsQuery : IRequest> +{ + public long TenantId { get; init; } + public TenantBillingStatus? Status { get; init; } + public DateTime? From { get; init; } + public DateTime? To { get; init; } + public int Page { get; init; } = 1; + public int PageSize { get; init; } = 20; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantNotificationsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantNotificationsQuery.cs new file mode 100644 index 0000000..92875ff --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantNotificationsQuery.cs @@ -0,0 +1,18 @@ +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 SearchTenantNotificationsQuery : IRequest> +{ + public long TenantId { get; init; } + public TenantNotificationSeverity? Severity { get; init; } + public bool? UnreadOnly { get; init; } + public int Page { get; init; } = 1; + public int PageSize { get; init; } = 20; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs index ab7fb4f..d361892 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs @@ -91,4 +91,49 @@ internal static class TenantMapping FeaturePoliciesJson = package.FeaturePoliciesJson, IsActive = package.IsActive }; + + public static TenantBillingDto ToDto(this TenantBillingStatement bill) + => new() + { + Id = bill.Id, + TenantId = bill.TenantId, + StatementNo = bill.StatementNo, + PeriodStart = bill.PeriodStart, + PeriodEnd = bill.PeriodEnd, + AmountDue = bill.AmountDue, + AmountPaid = bill.AmountPaid, + Status = bill.Status, + DueDate = bill.DueDate, + LineItemsJson = bill.LineItemsJson + }; + + public static TenantAnnouncementDto ToDto(this TenantAnnouncement announcement, bool isRead, DateTime? readAt) + => new() + { + Id = announcement.Id, + TenantId = announcement.TenantId, + Title = announcement.Title, + Content = announcement.Content, + AnnouncementType = announcement.AnnouncementType, + Priority = announcement.Priority, + EffectiveFrom = announcement.EffectiveFrom, + EffectiveTo = announcement.EffectiveTo, + IsActive = announcement.IsActive, + IsRead = isRead, + ReadAt = readAt + }; + + public static TenantNotificationDto ToDto(this TenantNotification notification) + => new() + { + Id = notification.Id, + TenantId = notification.TenantId, + Title = notification.Title, + Message = notification.Message, + Channel = notification.Channel, + Severity = notification.Severity, + SentAt = notification.SentAt, + ReadAt = notification.ReadAt, + MetadataJson = notification.MetadataJson + }; } diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAnnouncement.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAnnouncement.cs new file mode 100644 index 0000000..451efdb --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAnnouncement.cs @@ -0,0 +1,45 @@ +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Entities; + +/// +/// 租户公告。 +/// +public sealed class TenantAnnouncement : MultiTenantEntityBase +{ + /// + /// 公告标题。 + /// + public string Title { get; set; } = string.Empty; + + /// + /// 公告正文(可为 Markdown/HTML,前端自行渲染)。 + /// + public string Content { get; set; } = string.Empty; + + /// + /// 公告类型。 + /// + public TenantAnnouncementType AnnouncementType { get; set; } = TenantAnnouncementType.System; + + /// + /// 展示优先级,数值越大越靠前。 + /// + public int Priority { get; set; } = 0; + + /// + /// 生效时间(UTC)。 + /// + public DateTime EffectiveFrom { get; set; } = DateTime.UtcNow; + + /// + /// 失效时间(UTC),为空表示长期有效。 + /// + public DateTime? EffectiveTo { get; set; } + + /// + /// 是否启用。 + /// + public bool IsActive { get; set; } = true; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAnnouncementRead.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAnnouncementRead.cs new file mode 100644 index 0000000..fa52716 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAnnouncementRead.cs @@ -0,0 +1,24 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Entities; + +/// +/// 租户公告已读记录。 +/// +public sealed class TenantAnnouncementRead : MultiTenantEntityBase +{ + /// + /// 公告 ID。 + /// + public long AnnouncementId { get; set; } + + /// + /// 已读用户 ID(后台账号),为空表示租户级已读。 + /// + public long? UserId { get; set; } + + /// + /// 已读时间。 + /// + public DateTime ReadAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantAnnouncementType.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantAnnouncementType.cs new file mode 100644 index 0000000..d55a6d9 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantAnnouncementType.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Domain.Tenants.Enums; + +/// +/// 租户公告类型。 +/// +public enum TenantAnnouncementType +{ + /// + /// 系统公告。 + /// + System = 0, + + /// + /// 账单/订阅相关提醒。 + /// + Billing = 1, + + /// + /// 运营通知。 + /// + Operation = 2 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementReadRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementReadRepository.cs new file mode 100644 index 0000000..86a758b --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementReadRepository.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Tenants.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Repositories; + +/// +/// 公告已读仓储。 +/// +public interface ITenantAnnouncementReadRepository +{ + Task> GetByAnnouncementAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default); + + Task FindAsync(long tenantId, long announcementId, long? userId, CancellationToken cancellationToken = default); + + Task AddAsync(TenantAnnouncementRead record, CancellationToken cancellationToken = default); + + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementRepository.cs new file mode 100644 index 0000000..f42b041 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementRepository.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Domain.Tenants.Repositories; + +/// +/// 租户公告仓储。 +/// +public interface ITenantAnnouncementRepository +{ + Task> SearchAsync( + long tenantId, + TenantAnnouncementType? type, + bool? isActive, + DateTime? effectiveAt, + CancellationToken cancellationToken = default); + + Task FindByIdAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default); + + Task AddAsync(TenantAnnouncement announcement, CancellationToken cancellationToken = default); + + Task UpdateAsync(TenantAnnouncement announcement, CancellationToken cancellationToken = default); + + Task DeleteAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default); + + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs new file mode 100644 index 0000000..88d7fef --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Domain.Tenants.Repositories; + +/// +/// 租户账单仓储。 +/// +public interface ITenantBillingRepository +{ + Task> SearchAsync( + long tenantId, + TenantBillingStatus? status, + DateTime? from, + DateTime? to, + CancellationToken cancellationToken = default); + + Task FindByIdAsync(long tenantId, long billingId, CancellationToken cancellationToken = default); + + Task FindByStatementNoAsync(long tenantId, string statementNo, CancellationToken cancellationToken = default); + + Task AddAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default); + + Task UpdateAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default); + + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantNotificationRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantNotificationRepository.cs new file mode 100644 index 0000000..2ee3735 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantNotificationRepository.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Domain.Tenants.Repositories; + +/// +/// 租户通知仓储。 +/// +public interface ITenantNotificationRepository +{ + Task> SearchAsync( + long tenantId, + TenantNotificationSeverity? severity, + bool? unreadOnly, + DateTime? from, + DateTime? to, + CancellationToken cancellationToken = default); + + Task FindByIdAsync(long tenantId, long notificationId, CancellationToken cancellationToken = default); + + Task AddAsync(TenantNotification notification, CancellationToken cancellationToken = default); + + Task UpdateAsync(TenantNotification notification, 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 657f769..ed6f4eb 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs @@ -39,6 +39,10 @@ public static class AppServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs index 78dca47..f2f6177 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs @@ -43,6 +43,8 @@ public sealed class TakeoutAppDbContext( public DbSet TenantQuotaUsages => Set(); public DbSet TenantBillingStatements => Set(); public DbSet TenantNotifications => Set(); + public DbSet TenantAnnouncements => Set(); + public DbSet TenantAnnouncementReads => Set(); public DbSet TenantVerificationProfiles => Set(); public DbSet TenantAuditLogs => Set(); @@ -141,6 +143,8 @@ public sealed class TakeoutAppDbContext( ConfigureTenantQuotaUsage(modelBuilder.Entity()); ConfigureTenantBilling(modelBuilder.Entity()); ConfigureTenantNotification(modelBuilder.Entity()); + ConfigureTenantAnnouncement(modelBuilder.Entity()); + ConfigureTenantAnnouncementRead(modelBuilder.Entity()); ConfigureTenantVerificationProfile(modelBuilder.Entity()); ConfigureTenantAuditLog(modelBuilder.Entity()); ConfigureMerchantDocument(modelBuilder.Entity()); @@ -465,6 +469,35 @@ public sealed class TakeoutAppDbContext( builder.HasIndex(x => new { x.TenantId, x.Channel, x.SentAt }); } + private static void ConfigureTenantAnnouncement(EntityTypeBuilder builder) + { + builder.ToTable("tenant_announcements"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); + builder.Property(x => x.Title).HasMaxLength(128).IsRequired(); + builder.Property(x => x.Content).HasColumnType("text").IsRequired(); + builder.Property(x => x.AnnouncementType).HasConversion(); + builder.Property(x => x.Priority).IsRequired(); + builder.Property(x => x.IsActive).IsRequired(); + ConfigureAuditableEntity(builder); + ConfigureSoftDeleteEntity(builder); + builder.HasIndex(x => new { x.TenantId, x.AnnouncementType, x.IsActive }); + builder.HasIndex(x => new { x.TenantId, x.EffectiveFrom, x.EffectiveTo }); + } + + private static void ConfigureTenantAnnouncementRead(EntityTypeBuilder builder) + { + builder.ToTable("tenant_announcement_reads"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); + builder.Property(x => x.AnnouncementId).IsRequired(); + builder.Property(x => x.UserId); + builder.Property(x => x.ReadAt).IsRequired(); + ConfigureAuditableEntity(builder); + ConfigureSoftDeleteEntity(builder); + builder.HasIndex(x => new { x.TenantId, x.AnnouncementId, x.UserId }).IsUnique(); + } + private static void ConfigureMerchantDocument(EntityTypeBuilder builder) { builder.ToTable("merchant_documents"); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementReadRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementReadRepository.cs new file mode 100644 index 0000000..ebc63ea --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementReadRepository.cs @@ -0,0 +1,38 @@ +using System.Linq; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// EF 公告已读仓储。 +/// +public sealed class EfTenantAnnouncementReadRepository(TakeoutAppDbContext context) : ITenantAnnouncementReadRepository +{ + public Task> GetByAnnouncementAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default) + { + return context.TenantAnnouncementReads.AsNoTracking() + .Where(x => x.TenantId == tenantId && x.AnnouncementId == announcementId) + .OrderBy(x => x.ReadAt) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + } + + public Task FindAsync(long tenantId, long announcementId, long? userId, CancellationToken cancellationToken = default) + { + return context.TenantAnnouncementReads + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.AnnouncementId == announcementId && x.UserId == userId, cancellationToken); + } + + public Task AddAsync(TenantAnnouncementRead record, CancellationToken cancellationToken = default) + { + return context.TenantAnnouncementReads.AddAsync(record, cancellationToken).AsTask(); + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementRepository.cs new file mode 100644 index 0000000..a03d206 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementRepository.cs @@ -0,0 +1,78 @@ +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 租户公告仓储。 +/// +public sealed class EfTenantAnnouncementRepository(TakeoutAppDbContext context) : ITenantAnnouncementRepository +{ + public Task> SearchAsync( + long tenantId, + TenantAnnouncementType? type, + bool? isActive, + DateTime? effectiveAt, + CancellationToken cancellationToken = default) + { + var query = context.TenantAnnouncements.AsNoTracking() + .Where(x => x.TenantId == tenantId); + + if (type.HasValue) + { + query = query.Where(x => x.AnnouncementType == type.Value); + } + + if (isActive.HasValue) + { + query = query.Where(x => x.IsActive == isActive.Value); + } + + if (effectiveAt.HasValue) + { + var at = effectiveAt.Value; + query = query.Where(x => x.EffectiveFrom <= at && (x.EffectiveTo == null || x.EffectiveTo >= at)); + } + + return query + .OrderByDescending(x => x.Priority) + .ThenByDescending(x => x.CreatedAt) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + } + + public Task FindByIdAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default) + { + return context.TenantAnnouncements.AsNoTracking() + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == announcementId, cancellationToken); + } + + public Task AddAsync(TenantAnnouncement announcement, CancellationToken cancellationToken = default) + { + return context.TenantAnnouncements.AddAsync(announcement, cancellationToken).AsTask(); + } + + public Task UpdateAsync(TenantAnnouncement announcement, CancellationToken cancellationToken = default) + { + context.TenantAnnouncements.Update(announcement); + return Task.CompletedTask; + } + + public async Task DeleteAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default) + { + var entity = await context.TenantAnnouncements.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == announcementId, cancellationToken); + if (entity != null) + { + context.TenantAnnouncements.Remove(entity); + } + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantBillingRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantBillingRepository.cs new file mode 100644 index 0000000..23acd1d --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantBillingRepository.cs @@ -0,0 +1,73 @@ +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 租户账单仓储。 +/// +public sealed class EfTenantBillingRepository(TakeoutAppDbContext context) : ITenantBillingRepository +{ + public Task> SearchAsync( + long tenantId, + TenantBillingStatus? status, + DateTime? from, + DateTime? to, + CancellationToken cancellationToken = default) + { + var query = context.TenantBillingStatements.AsNoTracking() + .Where(x => x.TenantId == tenantId); + + if (status.HasValue) + { + query = query.Where(x => x.Status == status.Value); + } + + if (from.HasValue) + { + query = query.Where(x => x.PeriodStart >= from.Value); + } + + if (to.HasValue) + { + query = query.Where(x => x.PeriodEnd <= to.Value); + } + + return query + .OrderByDescending(x => x.PeriodEnd) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + } + + public Task FindByIdAsync(long tenantId, long billingId, CancellationToken cancellationToken = default) + { + return context.TenantBillingStatements.AsNoTracking() + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == billingId, cancellationToken); + } + + public Task FindByStatementNoAsync(long tenantId, string statementNo, CancellationToken cancellationToken = default) + { + return context.TenantBillingStatements.AsNoTracking() + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StatementNo == statementNo, cancellationToken); + } + + public Task AddAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default) + { + return context.TenantBillingStatements.AddAsync(bill, cancellationToken).AsTask(); + } + + public Task UpdateAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default) + { + context.TenantBillingStatements.Update(bill); + return Task.CompletedTask; + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantNotificationRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantNotificationRepository.cs new file mode 100644 index 0000000..1265c25 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantNotificationRepository.cs @@ -0,0 +1,73 @@ +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 租户通知仓储。 +/// +public sealed class EfTenantNotificationRepository(TakeoutAppDbContext context) : ITenantNotificationRepository +{ + public Task> SearchAsync( + long tenantId, + TenantNotificationSeverity? severity, + bool? unreadOnly, + DateTime? from, + DateTime? to, + CancellationToken cancellationToken = default) + { + var query = context.TenantNotifications.AsNoTracking() + .Where(x => x.TenantId == tenantId); + + if (severity.HasValue) + { + query = query.Where(x => x.Severity == severity.Value); + } + + if (unreadOnly == true) + { + query = query.Where(x => x.ReadAt == null); + } + + if (from.HasValue) + { + query = query.Where(x => x.SentAt >= from.Value); + } + + if (to.HasValue) + { + query = query.Where(x => x.SentAt <= to.Value); + } + + return query + .OrderByDescending(x => x.SentAt) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + } + + public Task FindByIdAsync(long tenantId, long notificationId, CancellationToken cancellationToken = default) + { + return context.TenantNotifications + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == notificationId, cancellationToken); + } + + public Task AddAsync(TenantNotification notification, CancellationToken cancellationToken = default) + { + return context.TenantNotifications.AddAsync(notification, cancellationToken).AsTask(); + } + + public Task UpdateAsync(TenantNotification notification, CancellationToken cancellationToken = default) + { + context.TenantNotifications.Update(notification); + return Task.CompletedTask; + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +} From a3dc5f54e5605d916b82412124030b9eb71c2ac5 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Wed, 3 Dec 2025 21:13:52 +0800 Subject: [PATCH 11/30] =?UTF-8?q?chore:=20=E4=BC=98=E5=8C=96=E5=85=AC?= =?UTF-8?q?=E5=91=8A=E5=B7=B2=E8=AF=BB=E6=89=B9=E9=87=8F=E4=B8=8E=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=BB=B4=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GetTenantAnnouncementQueryHandler.cs | 19 ++++++- .../SearchTenantAnnouncementsQueryHandler.cs | 50 ++++++++++++++----- .../ITenantAnnouncementReadRepository.cs | 2 + .../EfTenantAnnouncementReadRepository.cs | 26 ++++++++++ 4 files changed, 82 insertions(+), 15 deletions(-) diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAnnouncementQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAnnouncementQueryHandler.cs index bfc8f87..70c6201 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAnnouncementQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAnnouncementQueryHandler.cs @@ -2,6 +2,7 @@ using MediatR; using TakeoutSaaS.Application.App.Tenants.Dto; using TakeoutSaaS.Application.App.Tenants.Queries; using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Security; namespace TakeoutSaaS.Application.App.Tenants.Handlers; @@ -10,7 +11,8 @@ namespace TakeoutSaaS.Application.App.Tenants.Handlers; /// public sealed class GetTenantAnnouncementQueryHandler( ITenantAnnouncementRepository announcementRepository, - ITenantAnnouncementReadRepository readRepository) + ITenantAnnouncementReadRepository readRepository, + ICurrentUserAccessor? currentUserAccessor = null) : IRequestHandler { public async Task Handle(GetTenantAnnouncementQuery request, CancellationToken cancellationToken) @@ -21,7 +23,20 @@ public sealed class GetTenantAnnouncementQueryHandler( return null; } - var reads = await readRepository.GetByAnnouncementAsync(request.TenantId, request.AnnouncementId, cancellationToken); + var userId = currentUserAccessor?.UserId ?? 0; + var reads = await readRepository.GetByAnnouncementAsync( + request.TenantId, + new[] { request.AnnouncementId }, + userId == 0 ? null : userId, + cancellationToken); + + // 如无用户级已读,再查租户级已读 + if (reads.Count == 0) + { + var tenantReads = await readRepository.GetByAnnouncementAsync(request.TenantId, new[] { request.AnnouncementId }, null, cancellationToken); + reads = tenantReads; + } + var readRecord = reads.FirstOrDefault(); return announcement.ToDto(readRecord != null, readRecord?.ReadAt); } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantAnnouncementsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantAnnouncementsQueryHandler.cs index ee5f689..1734531 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantAnnouncementsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantAnnouncementsQueryHandler.cs @@ -1,9 +1,11 @@ +using System.Collections.Generic; 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; +using TakeoutSaaS.Shared.Abstractions.Security; namespace TakeoutSaaS.Application.App.Tenants.Handlers; @@ -12,7 +14,8 @@ namespace TakeoutSaaS.Application.App.Tenants.Handlers; /// public sealed class SearchTenantAnnouncementsQueryHandler( ITenantAnnouncementRepository announcementRepository, - ITenantAnnouncementReadRepository announcementReadRepository) + ITenantAnnouncementReadRepository announcementReadRepository, + ICurrentUserAccessor? currentUserAccessor = null) : IRequestHandler> { public async Task> Handle(SearchTenantAnnouncementsQuery request, CancellationToken cancellationToken) @@ -20,17 +23,6 @@ public sealed class SearchTenantAnnouncementsQueryHandler( var effectiveAt = request.OnlyEffective == true ? DateTime.UtcNow : (DateTime?)null; var announcements = await announcementRepository.SearchAsync(request.TenantId, request.AnnouncementType, request.IsActive, effectiveAt, cancellationToken); - var readMap = new Dictionary(); - foreach (var announcement in announcements) - { - var reads = await announcementReadRepository.GetByAnnouncementAsync(request.TenantId, announcement.Id, cancellationToken); - var readRecord = reads.FirstOrDefault(); - if (readRecord != null) - { - readMap[announcement.Id] = (true, readRecord.ReadAt); - } - } - var ordered = announcements .OrderByDescending(x => x.Priority) .ThenByDescending(x => x.CreatedAt) @@ -39,9 +31,41 @@ public sealed class SearchTenantAnnouncementsQueryHandler( var page = request.Page <= 0 ? 1 : request.Page; var size = request.PageSize <= 0 ? 20 : request.PageSize; - var items = ordered + var pageItems = ordered .Skip((page - 1) * size) .Take(size) + .ToList(); + + var announcementIds = pageItems.Select(x => x.Id).ToArray(); + var userId = currentUserAccessor?.UserId ?? 0; + + var readMap = new Dictionary(); + if (announcementIds.Length > 0) + { + // 优先查询当前用户维度的已读,其次租户级已读(UserId null) + var reads = new List(); + if (userId != 0) + { + var userReads = await announcementReadRepository.GetByAnnouncementAsync(request.TenantId, announcementIds, userId, cancellationToken); + reads.AddRange(userReads); + } + + var tenantReads = await announcementReadRepository.GetByAnnouncementAsync(request.TenantId, announcementIds, null, cancellationToken); + reads.AddRange(tenantReads); + + foreach (var read in reads.OrderByDescending(x => x.ReadAt)) + { + // 若已存在用户级标记,跳过租户级覆盖 + if (readMap.ContainsKey(read.AnnouncementId) && read.UserId.HasValue) + { + continue; + } + + readMap[read.AnnouncementId] = (true, read.ReadAt); + } + } + + var items = pageItems .Select(a => { readMap.TryGetValue(a.Id, out var read); diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementReadRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementReadRepository.cs index 86a758b..5265ff2 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementReadRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementReadRepository.cs @@ -12,6 +12,8 @@ public interface ITenantAnnouncementReadRepository { Task> GetByAnnouncementAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default); + Task> GetByAnnouncementAsync(long tenantId, IEnumerable announcementIds, long? userId, CancellationToken cancellationToken = default); + Task FindAsync(long tenantId, long announcementId, long? userId, CancellationToken cancellationToken = default); Task AddAsync(TenantAnnouncementRead record, CancellationToken cancellationToken = default); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementReadRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementReadRepository.cs index ebc63ea..96a1fd3 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementReadRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementReadRepository.cs @@ -20,6 +20,32 @@ public sealed class EfTenantAnnouncementReadRepository(TakeoutAppDbContext conte .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); } + public Task> GetByAnnouncementAsync(long tenantId, IEnumerable announcementIds, long? userId, CancellationToken cancellationToken = default) + { + var ids = announcementIds.Distinct().ToArray(); + if (ids.Length == 0) + { + return Task.FromResult>(Array.Empty()); + } + + var query = context.TenantAnnouncementReads.AsNoTracking() + .Where(x => x.TenantId == tenantId && ids.Contains(x.AnnouncementId)); + + if (userId.HasValue) + { + query = query.Where(x => x.UserId == userId.Value); + } + else + { + query = query.Where(x => x.UserId == null); + } + + return query + .OrderByDescending(x => x.ReadAt) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + } + public Task FindAsync(long tenantId, long announcementId, long? userId, CancellationToken cancellationToken = default) { return context.TenantAnnouncementReads From 83c81d5fd10a1181b83753cf60738da797544577 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Wed, 3 Dec 2025 21:28:44 +0800 Subject: [PATCH 12/30] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0Phase1=E5=89=A9?= =?UTF-8?q?=E4=BD=99=E4=BB=BB=E5=8A=A1=E7=8E=B0=E7=8A=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Document/12_BusinessTodo.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Document/12_BusinessTodo.md b/Document/12_BusinessTodo.md index ff43c0e..2e1525e 100644 --- a/Document/12_BusinessTodo.md +++ b/Document/12_BusinessTodo.md @@ -9,26 +9,41 @@ - [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` 表实现状态机追踪。 - [x] RBAC 模板:平台管理员、租户管理员、店长、店员四角色模板;API 可复制并允许租户自定义扩展。 - - 已交付:新增模板目录 `RoleTemplateProvider`(`src/Application/TakeoutSaaS.Application/Identity/Templates`),提供四个预置角色与权限定义;应用层新增模板列表/详情查询、复制与租户批量初始化命令(Handlers 位于 `src/Application/TakeoutSaaS.Application/Identity/Handlers`)。管理端 `RolesController` 暴露模板列表、详情、按模板复制、批量初始化端点(`src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs`),复制时自动补齐缺失权限并保留租户自定义授权。 + - 已交付:角色模板改为数据库驱动,新增 `RoleTemplate/RoleTemplatePermission` 实体与仓储接口/实现;应用层提供模板列表/详情/创建/更新/删除、按模板复制与租户批量初始化命令/查询;Admin 端 `RolesController` 暴露模板 CRUD 与复制/初始化端点(`src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs`),复制时补齐缺失权限且保留租户自定义授权;预置模板/权限种子写入 `src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.*.json`。 - [x] 配额与套餐:TenantPackage CRUD、订阅/续费/配额校验(门店/账号/短信/配送单量),超额返回 409 并记录 TenantQuotaUsage。 - 已交付:新增套餐仓储与命令/查询/DTO(`src/Application/TakeoutSaaS.Application/App/Tenants`),Admin 端新增 `TenantPackagesController` 提供套餐列表/详情/创建/更新/删除接口。新增配额校验命令与租户接口 `/api/admin/v1/tenants/{id}/quotas/check`,基于当前订阅套餐限额校验并占用配额,超额抛出 409 并写入 `TenantQuotaUsage`。仓储注册于 `AddAppInfrastructure`。 - [x] 租户运营面板:欠费/到期告警、账单列表、公告通知接口,支持已读状态并在 Admin UI 展示。 - 已交付:新增账单/公告/通知实体与仓储,Admin 端提供 `/tenants/{id}/billings`(列表/详情/创建/标记支付)、`/announcements`(列表/详情/创建/更新/删除/已读)、`/notifications`(列表/已读)端点;权限码补充 `tenant-bill:*`、`tenant-announcement:*`、`tenant-notification:*`,种子模板更新;配额/订阅告警可通过通知表承载。 - [ ] 门店管理:Store/StoreBusinessHour/StoreDeliveryZone/StoreHoliday CRUD 完整,含 GeoJSON 配送范围及能力开关。 + - 当前:仅有门店基础 CRUD(`src/Api/TakeoutSaaS.AdminApi/Controllers/StoresController.cs`),未实现营业时间、配送区/GeoJSON、节假日的命令/查询与 API;相关实体和仓储方法存在但未暴露。 - [ ] 桌码管理:批量生成桌码、绑定区域/容量、导出二维码 ZIP(POST /api/admin/stores/{id}/tables 可下载)。 + - 当前:模型/仓储已包含 `StoreTable`/`StoreTableArea`,但缺少命令/查询/控制器,未实现批量生成、区域容量绑定和二维码导出。 - [ ] 员工排班:创建员工、绑定门店角色、维护 StoreEmployeeShift,可查询未来 7 日排班。 + - 当前:存在 `StoreEmployeeShift` 表模型,未提供应用层命令/查询和 Admin API,排班创建/查询能力缺失。 - [ ] 桌码扫码入口:Mini 端解析二维码,GET /api/mini/tables/{code}/context 返回门店、桌台、公告。 + - 当前:MiniApi 无桌码相关接口,未实现桌码解析与上下文返回。 - [ ] 菜品建模:分类、SPU、SKU、规格/加料组、价格策略、媒资 CRUD + 上下架流程;Mini 端可拉取完整 JSON。 + - 当前:Admin 仅有基础商品 CRUD(Product 级),未覆盖 SKU/规格/加料组、价格策略、媒资与上下架流程,Mini 端也未提供完整商品 JSON 拉取接口。 - [ ] 库存体系:SKU 库存、批次、调整、售罄管理,支持预售/档期锁定并在订单中扣减/释放。 + - 当前:存在 `InventoryItem/InventoryBatch/InventoryAdjustment` 领域模型与 DbSet,但未提供库存调整/锁定命令、与订单扣减/释放或预售档期锁定的应用层逻辑与 API。 - [ ] 自提档期:门店配置自提时间窗、容量、截单时间;Mini 端据此限制下单时间。 + - 当前:仅有门店/商品的自提开关字段(`SupportsPickup`/`EnablePickup`),未实现自提时间窗、容量、截单配置及 Mini 端下单限制。 - [ ] 购物车服务:ShoppingCart/CartItem/CartItemAddon API 支持并发锁、限购、券/积分预校验,保证并发无脏数据。 + - 当前:领域层与表结构已有 `ShoppingCart/CartItem/CartItemAddon`,但缺少 CQRS 命令/查询、并发锁/限购/券积分预校验以及任何 Admin/Mini 端接口。 - [ ] 订单与支付:堂食/自提/配送下单、微信/支付宝支付、优惠券/积分抵扣、订单状态机与通知链路齐全。 + - 当前:Admin 端 `OrdersController`/`PaymentsController` 仅提供基础 CRUD,未覆盖堂食/自提/配送业务流、微信/支付宝支付、优惠券/积分抵扣、订单状态机、通知链路及与库存/配送的集成,Mini 端也无下单/支付接口。 - [ ] 桌台账单:合单/拆单、结账、电子小票、桌台释放,完成结账后恢复 Idle 并生成票据 URL。 + - 当前:无桌台账单/合单/拆单/结账或电子小票逻辑,桌台仅有基础实体定义。 - [ ] 自配送骨架:骑手管理、取送件信息录入、费用补贴记录,Admin 端可派单并更新 DeliveryOrder。 + - 当前:`DeliveryOrder` CRUD 支持录入 `CourierName/Phone`,但缺少骑手管理、派单流程、取送件详情与补贴记录等自配送骨架。 - [ ] 第三方配送抽象:统一下单/取消/加价/查询接口,支持达达、美团、闪送等,含回调验签与异常补偿骨架。 + - 当前:尚未提供第三方配送抽象、回调验签或补偿逻辑,配送模块仅有基础 CRUD。 - [ ] 预购自提核销:提货码生成、手机号/二维码核销、自提柜/前台流程,超时自动取消或退款,记录操作者与时间。 + - 当前:存在 `Reservation` 实体及订单字段 `ReservationId/CheckInCode`,但未实现提货码生成、核销接口、超时取消/退款或核销人记录,未与订单支付联动。 - [ ] 指标与日志:Prometheus 输出订单创建、支付成功率、配送回调耗时等,Grafana ≥8 个图表;关键流程日志记录 TraceId + 业务 ID。 + - 当前:Admin/Mini/User API 与网关已接入 OpenTelemetry(OTLP 与 Prometheus 导出)和 TraceId 结构化日志,但缺少订单/支付/配送等业务指标定义、Prometheus 爬取路径说明及 Grafana 图表配置。 - [ ] 测试:Phase 1 核心 API 具备 ≥30 条自动化用例(单元 + 集成),覆盖租户→商户→下单链路。 + - 当前:仓库尚无自动化测试项目/用例,Phase 1 链路未覆盖 xUnit/Moq/FluentAssertions 的单元或集成测试。 --- ## Phase 2(下一阶段):拼单、优惠券与基础营销、会员积分/会员日、客服聊天、同城自配送调度、搜索 From bf88f0e0412bbb4d002b510e2b2b974ae09706d0 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Wed, 3 Dec 2025 22:56:35 +0800 Subject: [PATCH 13/30] docs: add xml comments for tenant modules --- .../ChangeTenantSubscriptionPlanCommand.cs | 36 +++++++-- .../CreateTenantAnnouncementCommand.cs | 31 ++++++++ .../Commands/CreateTenantBillingCommand.cs | 35 +++++++++ .../CreateTenantSubscriptionCommand.cs | 35 +++++++-- .../DeleteTenantAnnouncementCommand.cs | 7 ++ .../MarkTenantAnnouncementReadCommand.cs | 7 ++ .../Commands/MarkTenantBillingPaidCommand.cs | 15 ++++ .../MarkTenantNotificationReadCommand.cs | 7 ++ .../Tenants/Commands/RegisterTenantCommand.cs | 74 ++++++++++++++++--- .../Tenants/Commands/ReviewTenantCommand.cs | 22 +++++- .../SubmitTenantVerificationCommand.cs | 70 +++++++++++++++--- .../UpdateTenantAnnouncementCommand.cs | 35 +++++++++ .../App/Tenants/Dto/TenantAnnouncementDto.cs | 33 +++++++++ .../App/Tenants/Dto/TenantBillingDto.cs | 30 ++++++++ .../App/Tenants/Dto/TenantNotificationDto.cs | 27 +++++++ .../Queries/GetTenantAnnouncementQuery.cs | 7 ++ .../App/Tenants/Queries/GetTenantBillQuery.cs | 7 ++ .../Queries/SearchTenantAnnouncementsQuery.cs | 23 ++++++ .../Tenants/Queries/SearchTenantBillsQuery.cs | 23 ++++++ .../Queries/SearchTenantNotificationsQuery.cs | 19 +++++ .../ITenantAnnouncementReadRepository.cs | 32 ++++++++ .../ITenantAnnouncementRepository.cs | 36 +++++++++ .../Repositories/ITenantBillingRepository.cs | 37 ++++++++++ .../ITenantNotificationRepository.cs | 31 ++++++++ .../EfTenantAnnouncementReadRepository.cs | 5 ++ .../EfTenantAnnouncementRepository.cs | 6 ++ .../Repositories/EfTenantBillingRepository.cs | 6 ++ .../EfTenantNotificationRepository.cs | 5 ++ 28 files changed, 661 insertions(+), 40 deletions(-) diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ChangeTenantSubscriptionPlanCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ChangeTenantSubscriptionPlanCommand.cs index d9c6287..5c052be 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ChangeTenantSubscriptionPlanCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ChangeTenantSubscriptionPlanCommand.cs @@ -7,9 +7,33 @@ 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; +public sealed record ChangeTenantSubscriptionPlanCommand : IRequest +{ + /// + /// 租户 ID(雪花算法)。 + /// + [Required] + public long TenantId { get; init; } + + /// + /// 现有订阅 ID。 + /// + [Required] + public long TenantSubscriptionId { get; init; } + + /// + /// 目标套餐 ID。 + /// + [Required] + public long TargetPackageId { get; init; } + + /// + /// 是否立即生效,否则在下一结算周期生效。 + /// + public bool Immediate { get; init; } + + /// + /// 调整备注。 + /// + public string? Notes { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantAnnouncementCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantAnnouncementCommand.cs index da68b6f..dd6735a 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantAnnouncementCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantAnnouncementCommand.cs @@ -9,12 +9,43 @@ namespace TakeoutSaaS.Application.App.Tenants.Commands; /// public sealed record CreateTenantAnnouncementCommand : IRequest { + /// + /// 租户 ID(雪花算法)。 + /// public long TenantId { get; init; } + + /// + /// 公告标题。 + /// public string Title { get; init; } = string.Empty; + + /// + /// 公告正文内容。 + /// public string Content { get; init; } = string.Empty; + + /// + /// 公告类型。 + /// public TenantAnnouncementType AnnouncementType { get; init; } = TenantAnnouncementType.System; + + /// + /// 优先级,数值越大越靠前。 + /// public int Priority { get; init; } = 0; + + /// + /// 生效开始时间(UTC)。 + /// public DateTime EffectiveFrom { get; init; } = DateTime.UtcNow; + + /// + /// 生效结束时间(UTC),为空则长期有效。 + /// public DateTime? EffectiveTo { get; init; } + + /// + /// 是否启用。 + /// public bool IsActive { get; init; } = true; } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantBillingCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantBillingCommand.cs index 16c771d..1bc64a7 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantBillingCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantBillingCommand.cs @@ -9,13 +9,48 @@ namespace TakeoutSaaS.Application.App.Tenants.Commands; /// public sealed record CreateTenantBillingCommand : IRequest { + /// + /// 租户 ID(雪花算法)。 + /// public long TenantId { get; init; } + + /// + /// 账单编号。 + /// public string StatementNo { get; init; } = string.Empty; + + /// + /// 计费周期开始时间(UTC)。 + /// public DateTime PeriodStart { get; init; } + + /// + /// 计费周期结束时间(UTC)。 + /// public DateTime PeriodEnd { get; init; } + + /// + /// 应付金额。 + /// public decimal AmountDue { get; init; } + + /// + /// 已付金额。 + /// public decimal AmountPaid { get; init; } + + /// + /// 账单状态。 + /// public TenantBillingStatus Status { get; init; } = TenantBillingStatus.Pending; + + /// + /// 到期日(UTC)。 + /// public DateTime DueDate { get; init; } + + /// + /// 账单明细 JSON。 + /// public string? LineItemsJson { get; init; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantSubscriptionCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantSubscriptionCommand.cs index 468ace2..b31a92d 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantSubscriptionCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantSubscriptionCommand.cs @@ -7,9 +7,32 @@ 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; +public sealed record CreateTenantSubscriptionCommand : IRequest +{ + /// + /// 租户 ID(雪花算法)。 + /// + [Required] + public long TenantId { get; init; } + + /// + /// 套餐 ID。 + /// + [Required] + public long TenantPackageId { get; init; } + + /// + /// 订阅时长(月)。 + /// + public int DurationMonths { get; init; } + + /// + /// 是否自动续费。 + /// + public bool AutoRenew { get; init; } + + /// + /// 备注信息。 + /// + public string? Notes { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/DeleteTenantAnnouncementCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/DeleteTenantAnnouncementCommand.cs index c67877c..3cd5964 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/DeleteTenantAnnouncementCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/DeleteTenantAnnouncementCommand.cs @@ -7,6 +7,13 @@ namespace TakeoutSaaS.Application.App.Tenants.Commands; /// public sealed record DeleteTenantAnnouncementCommand : IRequest { + /// + /// 租户 ID(雪花算法)。 + /// public long TenantId { get; init; } + + /// + /// 公告 ID。 + /// public long AnnouncementId { get; init; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantAnnouncementReadCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantAnnouncementReadCommand.cs index 85c679a..97ce6e1 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantAnnouncementReadCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantAnnouncementReadCommand.cs @@ -8,6 +8,13 @@ namespace TakeoutSaaS.Application.App.Tenants.Commands; /// public sealed record MarkTenantAnnouncementReadCommand : IRequest { + /// + /// 租户 ID(雪花算法)。 + /// public long TenantId { get; init; } + + /// + /// 公告 ID。 + /// public long AnnouncementId { get; init; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantBillingPaidCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantBillingPaidCommand.cs index 5479882..e2c9f18 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantBillingPaidCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantBillingPaidCommand.cs @@ -8,8 +8,23 @@ namespace TakeoutSaaS.Application.App.Tenants.Commands; /// public sealed record MarkTenantBillingPaidCommand : IRequest { + /// + /// 租户 ID(雪花算法)。 + /// public long TenantId { get; init; } + + /// + /// 账单 ID。 + /// public long BillingId { get; init; } + + /// + /// 本次支付金额。 + /// public decimal AmountPaid { get; init; } + + /// + /// 支付时间(UTC)。 + /// public DateTime PaidAt { get; init; } = DateTime.UtcNow; } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantNotificationReadCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantNotificationReadCommand.cs index 31ddb58..149a74a 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantNotificationReadCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantNotificationReadCommand.cs @@ -8,6 +8,13 @@ namespace TakeoutSaaS.Application.App.Tenants.Commands; /// public sealed record MarkTenantNotificationReadCommand : IRequest { + /// + /// 租户 ID(雪花算法)。 + /// public long TenantId { get; init; } + + /// + /// 通知 ID。 + /// public long NotificationId { get; init; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/RegisterTenantCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/RegisterTenantCommand.cs index 43cc053..d26a96b 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/RegisterTenantCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/RegisterTenantCommand.cs @@ -7,15 +7,65 @@ 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; +public sealed record RegisterTenantCommand : IRequest +{ + /// + /// 唯一租户编码。 + /// + [Required] + [StringLength(64)] + public string Code { get; init; } = string.Empty; + + /// + /// 租户名称。 + /// + [Required] + [StringLength(128)] + public string Name { get; init; } = string.Empty; + + /// + /// 租户简称。 + /// + public string? ShortName { get; init; } + + /// + /// 行业类型。 + /// + public string? Industry { get; init; } + + /// + /// 联系人姓名。 + /// + public string? ContactName { get; init; } + + /// + /// 联系人电话。 + /// + public string? ContactPhone { get; init; } + + /// + /// 联系人邮箱。 + /// + public string? ContactEmail { get; init; } + + /// + /// 购买套餐 ID。 + /// + [Required] + public long TenantPackageId { get; init; } + + /// + /// 订阅时长(月),默认 12 个月。 + /// + public int DurationMonths { get; init; } = 12; + + /// + /// 是否自动续费。 + /// + public bool AutoRenew { get; init; } = true; + + /// + /// 生效时间(UTC),为空则立即生效。 + /// + public DateTime? EffectiveFrom { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ReviewTenantCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ReviewTenantCommand.cs index 5784f9b..7b1c6b6 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ReviewTenantCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ReviewTenantCommand.cs @@ -7,7 +7,21 @@ namespace TakeoutSaaS.Application.App.Tenants.Commands; /// /// 审核租户命令。 /// -public sealed record ReviewTenantCommand( - [property: Required] long TenantId, - bool Approve, - string? Reason) : IRequest; +public sealed record ReviewTenantCommand : IRequest +{ + /// + /// 租户 ID(雪花算法)。 + /// + [Required] + public long TenantId { get; init; } + + /// + /// 是否通过审核。 + /// + public bool Approve { get; init; } + + /// + /// 审核备注或拒绝原因。 + /// + public string? Reason { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/SubmitTenantVerificationCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/SubmitTenantVerificationCommand.cs index 8a94a46..94de46d 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/SubmitTenantVerificationCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/SubmitTenantVerificationCommand.cs @@ -7,15 +7,61 @@ 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; +public sealed record SubmitTenantVerificationCommand : IRequest +{ + /// + /// 租户 ID(雪花算法)。 + /// + [Required] + public long TenantId { get; init; } + + /// + /// 营业执照编号。 + /// + public string? BusinessLicenseNumber { get; init; } + + /// + /// 营业执照扫描件地址。 + /// + public string? BusinessLicenseUrl { get; init; } + + /// + /// 法人姓名。 + /// + public string? LegalPersonName { get; init; } + + /// + /// 法人身份证号。 + /// + public string? LegalPersonIdNumber { get; init; } + + /// + /// 法人身份证人像面图片地址。 + /// + public string? LegalPersonIdFrontUrl { get; init; } + + /// + /// 法人身份证国徽面图片地址。 + /// + public string? LegalPersonIdBackUrl { get; init; } + + /// + /// 对公账户户名。 + /// + public string? BankAccountName { get; init; } + + /// + /// 对公银行账号。 + /// + public string? BankAccountNumber { get; init; } + + /// + /// 开户行名称。 + /// + public string? BankName { get; init; } + + /// + /// 其他补充资料 JSON。 + /// + public string? AdditionalDataJson { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantAnnouncementCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantAnnouncementCommand.cs index 57c6569..b495d69 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantAnnouncementCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantAnnouncementCommand.cs @@ -9,13 +9,48 @@ namespace TakeoutSaaS.Application.App.Tenants.Commands; /// public sealed record UpdateTenantAnnouncementCommand : IRequest { + /// + /// 租户 ID(雪花算法)。 + /// public long TenantId { get; init; } + + /// + /// 公告 ID。 + /// public long AnnouncementId { get; init; } + + /// + /// 公告标题。 + /// public string Title { get; init; } = string.Empty; + + /// + /// 公告内容。 + /// public string Content { get; init; } = string.Empty; + + /// + /// 公告类型。 + /// public TenantAnnouncementType AnnouncementType { get; init; } = TenantAnnouncementType.System; + + /// + /// 优先级,数值越大越靠前。 + /// public int Priority { get; init; } = 0; + + /// + /// 生效开始时间(UTC)。 + /// public DateTime EffectiveFrom { get; init; } = DateTime.UtcNow; + + /// + /// 生效结束时间(UTC),为空则长期有效。 + /// public DateTime? EffectiveTo { get; init; } + + /// + /// 是否启用。 + /// public bool IsActive { get; init; } = true; } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantAnnouncementDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantAnnouncementDto.cs index 5a0a7bf..79fbbe8 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantAnnouncementDto.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantAnnouncementDto.cs @@ -9,27 +9,60 @@ namespace TakeoutSaaS.Application.App.Tenants.Dto; /// public sealed class TenantAnnouncementDto { + /// + /// 公告 ID(雪花算法,序列化为字符串)。 + /// [JsonConverter(typeof(SnowflakeIdJsonConverter))] public long Id { get; init; } + /// + /// 租户 ID(雪花算法,序列化为字符串)。 + /// [JsonConverter(typeof(SnowflakeIdJsonConverter))] public long TenantId { get; init; } + /// + /// 公告标题。 + /// public string Title { get; init; } = string.Empty; + /// + /// 公告正文内容。 + /// public string Content { get; init; } = string.Empty; + /// + /// 公告类型。 + /// public TenantAnnouncementType AnnouncementType { get; init; } + /// + /// 优先级,数值越大越靠前。 + /// public int Priority { get; init; } + /// + /// 生效开始时间(UTC)。 + /// public DateTime EffectiveFrom { get; init; } + /// + /// 生效结束时间(UTC),为空则长期有效。 + /// public DateTime? EffectiveTo { get; init; } + /// + /// 是否启用。 + /// public bool IsActive { get; init; } + /// + /// 当前用户是否已读。 + /// public bool IsRead { get; init; } + /// + /// 已读时间(UTC)。 + /// public DateTime? ReadAt { get; init; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantBillingDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantBillingDto.cs index d2b426b..1007224 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantBillingDto.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantBillingDto.cs @@ -9,25 +9,55 @@ namespace TakeoutSaaS.Application.App.Tenants.Dto; /// public sealed class TenantBillingDto { + /// + /// 账单 ID(雪花算法,序列化为字符串)。 + /// [JsonConverter(typeof(SnowflakeIdJsonConverter))] public long Id { get; init; } + /// + /// 租户 ID(雪花算法,序列化为字符串)。 + /// [JsonConverter(typeof(SnowflakeIdJsonConverter))] public long TenantId { get; init; } + /// + /// 账单编号。 + /// public string StatementNo { get; init; } = string.Empty; + /// + /// 计费周期开始时间(UTC)。 + /// public DateTime PeriodStart { get; init; } + /// + /// 计费周期结束时间(UTC)。 + /// public DateTime PeriodEnd { get; init; } + /// + /// 应付金额。 + /// public decimal AmountDue { get; init; } + /// + /// 已付金额。 + /// public decimal AmountPaid { get; init; } + /// + /// 账单状态。 + /// public TenantBillingStatus Status { get; init; } + /// + /// 到期日(UTC)。 + /// public DateTime DueDate { get; init; } + /// + /// 账单明细 JSON。 + /// public string? LineItemsJson { get; init; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantNotificationDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantNotificationDto.cs index e6ab6a9..9a33244 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantNotificationDto.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantNotificationDto.cs @@ -9,23 +9,50 @@ namespace TakeoutSaaS.Application.App.Tenants.Dto; /// public sealed class TenantNotificationDto { + /// + /// 通知 ID(雪花算法,序列化为字符串)。 + /// [JsonConverter(typeof(SnowflakeIdJsonConverter))] public long Id { get; init; } + /// + /// 租户 ID(雪花算法,序列化为字符串)。 + /// [JsonConverter(typeof(SnowflakeIdJsonConverter))] public long TenantId { get; init; } + /// + /// 通知标题。 + /// public string Title { get; init; } = string.Empty; + /// + /// 通知内容。 + /// public string Message { get; init; } = string.Empty; + /// + /// 通道类型(如站内信、短信、邮件)。 + /// public TenantNotificationChannel Channel { get; init; } + /// + /// 通知等级。 + /// public TenantNotificationSeverity Severity { get; init; } + /// + /// 发送时间(UTC)。 + /// public DateTime SentAt { get; init; } + /// + /// 阅读时间(UTC)。 + /// public DateTime? ReadAt { get; init; } + /// + /// 附加元数据 JSON。 + /// public string? MetadataJson { get; init; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantAnnouncementQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantAnnouncementQuery.cs index 52bdcfb..74a28b3 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantAnnouncementQuery.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantAnnouncementQuery.cs @@ -8,6 +8,13 @@ namespace TakeoutSaaS.Application.App.Tenants.Queries; /// public sealed record GetTenantAnnouncementQuery : IRequest { + /// + /// 租户 ID(雪花算法)。 + /// public long TenantId { get; init; } + + /// + /// 公告 ID。 + /// public long AnnouncementId { get; init; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantBillQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantBillQuery.cs index f22eb99..a5ece15 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantBillQuery.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantBillQuery.cs @@ -8,6 +8,13 @@ namespace TakeoutSaaS.Application.App.Tenants.Queries; /// public sealed record GetTenantBillQuery : IRequest { + /// + /// 租户 ID(雪花算法)。 + /// public long TenantId { get; init; } + + /// + /// 账单 ID。 + /// public long BillingId { get; init; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantAnnouncementsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantAnnouncementsQuery.cs index 3f059ad..140acb1 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantAnnouncementsQuery.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantAnnouncementsQuery.cs @@ -10,10 +10,33 @@ namespace TakeoutSaaS.Application.App.Tenants.Queries; /// public sealed record SearchTenantAnnouncementsQuery : IRequest> { + /// + /// 租户 ID(雪花算法)。 + /// public long TenantId { get; init; } + + /// + /// 公告类型筛选。 + /// public TenantAnnouncementType? AnnouncementType { get; init; } + + /// + /// 是否筛选启用状态。 + /// public bool? IsActive { get; init; } + + /// + /// 仅返回当前有效期内的公告。 + /// public bool? OnlyEffective { get; init; } + + /// + /// 页码(从 1 开始)。 + /// public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// public int PageSize { get; init; } = 20; } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantBillsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantBillsQuery.cs index b8747e0..67d0103 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantBillsQuery.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantBillsQuery.cs @@ -10,10 +10,33 @@ namespace TakeoutSaaS.Application.App.Tenants.Queries; /// public sealed record SearchTenantBillsQuery : IRequest> { + /// + /// 租户 ID(雪花算法)。 + /// public long TenantId { get; init; } + + /// + /// 账单状态筛选。 + /// public TenantBillingStatus? Status { get; init; } + + /// + /// 账单起始时间(UTC)筛选。 + /// public DateTime? From { get; init; } + + /// + /// 账单结束时间(UTC)筛选。 + /// public DateTime? To { get; init; } + + /// + /// 页码(从 1 开始)。 + /// public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// public int PageSize { get; init; } = 20; } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantNotificationsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantNotificationsQuery.cs index 92875ff..ff5e8eb 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantNotificationsQuery.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantNotificationsQuery.cs @@ -10,9 +10,28 @@ namespace TakeoutSaaS.Application.App.Tenants.Queries; /// public sealed record SearchTenantNotificationsQuery : IRequest> { + /// + /// 租户 ID(雪花算法)。 + /// public long TenantId { get; init; } + + /// + /// 通知等级筛选。 + /// public TenantNotificationSeverity? Severity { get; init; } + + /// + /// 仅返回未读通知。 + /// public bool? UnreadOnly { get; init; } + + /// + /// 页码(从 1 开始)。 + /// public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// public int PageSize { get; init; } = 20; } diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementReadRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementReadRepository.cs index 5265ff2..ac76bc8 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementReadRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementReadRepository.cs @@ -10,13 +10,45 @@ namespace TakeoutSaaS.Domain.Tenants.Repositories; /// public interface ITenantAnnouncementReadRepository { + /// + /// 按公告查询已读记录。 + /// + /// 租户 ID。 + /// 公告 ID。 + /// 取消标记。 + /// 指定公告的已读列表。 Task> GetByAnnouncementAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default); + /// + /// 批量按公告查询已读记录,可选按用户过滤。 + /// + /// 租户 ID。 + /// 公告 ID 集合。 + /// 用户 ID,空则不按用户筛选。 + /// 取消标记。 + /// 匹配条件的已读列表。 Task> GetByAnnouncementAsync(long tenantId, IEnumerable announcementIds, long? userId, CancellationToken cancellationToken = default); + /// + /// 查询指定用户对某公告的已读记录。 + /// + /// 租户 ID。 + /// 公告 ID。 + /// 用户 ID。 + /// 取消标记。 + /// 已读记录,未读返回 null。 Task FindAsync(long tenantId, long announcementId, long? userId, CancellationToken cancellationToken = default); + /// + /// 新增已读记录。 + /// + /// 已读实体。 + /// 取消标记。 Task AddAsync(TenantAnnouncementRead record, CancellationToken cancellationToken = default); + /// + /// 保存变更。 + /// + /// 取消标记。 Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementRepository.cs index f42b041..c3e34b6 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementRepository.cs @@ -11,6 +11,15 @@ namespace TakeoutSaaS.Domain.Tenants.Repositories; /// public interface ITenantAnnouncementRepository { + /// + /// 查询公告列表,按类型、启用状态与生效时间筛选。 + /// + /// 租户 ID。 + /// 公告类型。 + /// 启用状态。 + /// 生效时间点,为空不限制。 + /// 取消标记。 + /// 公告集合。 Task> SearchAsync( long tenantId, TenantAnnouncementType? type, @@ -18,13 +27,40 @@ public interface ITenantAnnouncementRepository DateTime? effectiveAt, CancellationToken cancellationToken = default); + /// + /// 按 ID 获取公告。 + /// + /// 租户 ID。 + /// 公告 ID。 + /// 取消标记。 + /// 公告实体或 null。 Task FindByIdAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default); + /// + /// 新增公告。 + /// + /// 公告实体。 + /// 取消标记。 Task AddAsync(TenantAnnouncement announcement, CancellationToken cancellationToken = default); + /// + /// 更新公告。 + /// + /// 公告实体。 + /// 取消标记。 Task UpdateAsync(TenantAnnouncement announcement, CancellationToken cancellationToken = default); + /// + /// 删除公告。 + /// + /// 租户 ID。 + /// 公告 ID。 + /// 取消标记。 Task DeleteAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default); + /// + /// 保存变更。 + /// + /// 取消标记。 Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs index 88d7fef..d4ddfa8 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs @@ -11,6 +11,15 @@ namespace TakeoutSaaS.Domain.Tenants.Repositories; /// public interface ITenantBillingRepository { + /// + /// 查询账单列表,按状态与时间范围筛选。 + /// + /// 租户 ID。 + /// 账单状态。 + /// 开始时间(UTC)。 + /// 结束时间(UTC)。 + /// 取消标记。 + /// 账单集合。 Task> SearchAsync( long tenantId, TenantBillingStatus? status, @@ -18,13 +27,41 @@ public interface ITenantBillingRepository DateTime? to, CancellationToken cancellationToken = default); + /// + /// 按 ID 获取账单。 + /// + /// 租户 ID。 + /// 账单 ID。 + /// 取消标记。 + /// 账单实体或 null。 Task FindByIdAsync(long tenantId, long billingId, CancellationToken cancellationToken = default); + /// + /// 按账单编号获取账单。 + /// + /// 租户 ID。 + /// 账单编号。 + /// 取消标记。 + /// 账单实体或 null。 Task FindByStatementNoAsync(long tenantId, string statementNo, CancellationToken cancellationToken = default); + /// + /// 新增账单。 + /// + /// 账单实体。 + /// 取消标记。 Task AddAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default); + /// + /// 更新账单。 + /// + /// 账单实体。 + /// 取消标记。 Task UpdateAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default); + /// + /// 保存变更。 + /// + /// 取消标记。 Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantNotificationRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantNotificationRepository.cs index 2ee3735..b66093d 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantNotificationRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantNotificationRepository.cs @@ -11,6 +11,16 @@ namespace TakeoutSaaS.Domain.Tenants.Repositories; /// public interface ITenantNotificationRepository { + /// + /// 查询通知列表,按等级、未读状态与时间范围筛选。 + /// + /// 租户 ID。 + /// 通知等级。 + /// 仅返回未读。 + /// 开始时间(UTC)。 + /// 结束时间(UTC)。 + /// 取消标记。 + /// 通知集合。 Task> SearchAsync( long tenantId, TenantNotificationSeverity? severity, @@ -19,11 +29,32 @@ public interface ITenantNotificationRepository DateTime? to, CancellationToken cancellationToken = default); + /// + /// 按 ID 获取通知。 + /// + /// 租户 ID。 + /// 通知 ID。 + /// 取消标记。 + /// 通知实体或 null。 Task FindByIdAsync(long tenantId, long notificationId, CancellationToken cancellationToken = default); + /// + /// 新增通知。 + /// + /// 通知实体。 + /// 取消标记。 Task AddAsync(TenantNotification notification, CancellationToken cancellationToken = default); + /// + /// 更新通知。 + /// + /// 通知实体。 + /// 取消标记。 Task UpdateAsync(TenantNotification notification, CancellationToken cancellationToken = default); + /// + /// 保存变更。 + /// + /// 取消标记。 Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementReadRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementReadRepository.cs index 96a1fd3..94b26a2 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementReadRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementReadRepository.cs @@ -11,6 +11,7 @@ namespace TakeoutSaaS.Infrastructure.App.Repositories; /// public sealed class EfTenantAnnouncementReadRepository(TakeoutAppDbContext context) : ITenantAnnouncementReadRepository { + /// public Task> GetByAnnouncementAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default) { return context.TenantAnnouncementReads.AsNoTracking() @@ -20,6 +21,7 @@ public sealed class EfTenantAnnouncementReadRepository(TakeoutAppDbContext conte .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); } + /// public Task> GetByAnnouncementAsync(long tenantId, IEnumerable announcementIds, long? userId, CancellationToken cancellationToken = default) { var ids = announcementIds.Distinct().ToArray(); @@ -46,17 +48,20 @@ public sealed class EfTenantAnnouncementReadRepository(TakeoutAppDbContext conte .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); } + /// public Task FindAsync(long tenantId, long announcementId, long? userId, CancellationToken cancellationToken = default) { return context.TenantAnnouncementReads .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.AnnouncementId == announcementId && x.UserId == userId, cancellationToken); } + /// public Task AddAsync(TenantAnnouncementRead record, CancellationToken cancellationToken = default) { return context.TenantAnnouncementReads.AddAsync(record, cancellationToken).AsTask(); } + /// public Task SaveChangesAsync(CancellationToken cancellationToken = default) { return context.SaveChangesAsync(cancellationToken); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementRepository.cs index a03d206..404152f 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementRepository.cs @@ -12,6 +12,7 @@ namespace TakeoutSaaS.Infrastructure.App.Repositories; /// public sealed class EfTenantAnnouncementRepository(TakeoutAppDbContext context) : ITenantAnnouncementRepository { + /// public Task> SearchAsync( long tenantId, TenantAnnouncementType? type, @@ -45,23 +46,27 @@ public sealed class EfTenantAnnouncementRepository(TakeoutAppDbContext context) .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); } + /// public Task FindByIdAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default) { return context.TenantAnnouncements.AsNoTracking() .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == announcementId, cancellationToken); } + /// public Task AddAsync(TenantAnnouncement announcement, CancellationToken cancellationToken = default) { return context.TenantAnnouncements.AddAsync(announcement, cancellationToken).AsTask(); } + /// public Task UpdateAsync(TenantAnnouncement announcement, CancellationToken cancellationToken = default) { context.TenantAnnouncements.Update(announcement); return Task.CompletedTask; } + /// public async Task DeleteAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default) { var entity = await context.TenantAnnouncements.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == announcementId, cancellationToken); @@ -71,6 +76,7 @@ public sealed class EfTenantAnnouncementRepository(TakeoutAppDbContext context) } } + /// public Task SaveChangesAsync(CancellationToken cancellationToken = default) { return context.SaveChangesAsync(cancellationToken); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantBillingRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantBillingRepository.cs index 23acd1d..3083de5 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantBillingRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantBillingRepository.cs @@ -12,6 +12,7 @@ namespace TakeoutSaaS.Infrastructure.App.Repositories; /// public sealed class EfTenantBillingRepository(TakeoutAppDbContext context) : ITenantBillingRepository { + /// public Task> SearchAsync( long tenantId, TenantBillingStatus? status, @@ -43,29 +44,34 @@ public sealed class EfTenantBillingRepository(TakeoutAppDbContext context) : ITe .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); } + /// public Task FindByIdAsync(long tenantId, long billingId, CancellationToken cancellationToken = default) { return context.TenantBillingStatements.AsNoTracking() .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == billingId, cancellationToken); } + /// public Task FindByStatementNoAsync(long tenantId, string statementNo, CancellationToken cancellationToken = default) { return context.TenantBillingStatements.AsNoTracking() .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StatementNo == statementNo, cancellationToken); } + /// public Task AddAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default) { return context.TenantBillingStatements.AddAsync(bill, cancellationToken).AsTask(); } + /// public Task UpdateAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default) { context.TenantBillingStatements.Update(bill); return Task.CompletedTask; } + /// public Task SaveChangesAsync(CancellationToken cancellationToken = default) { return context.SaveChangesAsync(cancellationToken); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantNotificationRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantNotificationRepository.cs index 1265c25..56cea39 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantNotificationRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantNotificationRepository.cs @@ -12,6 +12,7 @@ namespace TakeoutSaaS.Infrastructure.App.Repositories; /// public sealed class EfTenantNotificationRepository(TakeoutAppDbContext context) : ITenantNotificationRepository { + /// public Task> SearchAsync( long tenantId, TenantNotificationSeverity? severity, @@ -49,23 +50,27 @@ public sealed class EfTenantNotificationRepository(TakeoutAppDbContext context) .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); } + /// public Task FindByIdAsync(long tenantId, long notificationId, CancellationToken cancellationToken = default) { return context.TenantNotifications .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == notificationId, cancellationToken); } + /// public Task AddAsync(TenantNotification notification, CancellationToken cancellationToken = default) { return context.TenantNotifications.AddAsync(notification, cancellationToken).AsTask(); } + /// public Task UpdateAsync(TenantNotification notification, CancellationToken cancellationToken = default) { context.TenantNotifications.Update(notification); return Task.CompletedTask; } + /// public Task SaveChangesAsync(CancellationToken cancellationToken = default) { return context.SaveChangesAsync(cancellationToken); From 17d143a35109c5b1170ce8770f801342822ea7d4 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Wed, 3 Dec 2025 23:18:17 +0800 Subject: [PATCH 14/30] docs: refine tenant repository comments --- AGENTS.md | 112 +++++++++++------- .../Repositories/ITenantPackageRepository.cs | 16 ++- .../ITenantQuotaUsageRepository.cs | 12 ++ .../Tenants/Repositories/ITenantRepository.cs | 41 +++++++ 4 files changed, 136 insertions(+), 45 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1ed8c30..0990858 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,11 +47,21 @@ ## 4. 注释与文档 * **强制 XML 注释**:所有 `public` 的类、方法、属性必须有 ``。 -* **步骤注释**:超过 5 行的业务逻辑,必须分步注释: - ```csharp - // 1. 验证库存 - // 2. 扣减余额 - ``` +* **分段逻辑注释 (强制)**: + * **空行必注**:代码中每当出现空行分隔逻辑块时,**必须**在空行后的第一行添加 `//` 注释,简要说明紧接着这段代码的意图或作用。 + * **步骤化**:对于稍微复杂的业务逻辑,必须结合序号(1., 2., ...)进行标记。 + * **示例**: + ```csharp + // 1. 验证用户是否存在 + var user = await _repo.GetAsync(id); + if (user == null) return NotFound(); + + // 2. (空行后) 扣减余额逻辑 + user.Balance -= amount; + + // 3. (空行后) 保存更改 + await _unitOfWork.SaveChangesAsync(); + ``` * **Swagger**:必须开启 JWT 鉴权按钮,Request/Response 示例必须清晰。 ## 5. 异常处理 (防御性编程) @@ -142,50 +152,64 @@ 5. [ ] **性能陷阱**:是否在循环中查询了数据库 (N+1)? 6. [ ] **精度丢失**:Long 类型的 ID 是否转为了 String? 7. [ ] **配置硬编码**:是否直接写死了连接串或密钥? +8. [ ] **文档注释**:是否给所有 `public` 类/方法/属性添加了 ``? +9. [ ] **逻辑注释**:是否在每个空行分隔的逻辑块上方添加了说明注释? -## 17. .NET 10 / C# 14 现代语法最佳实践(增量) -> 2025 年推荐的 20 条语法规范,新增特性优先,保持极简。 -1. **field 关键字**:属性内直接使用 `field` 处理后备字段,`set => field = value.Trim();`。 -2. **空值条件赋值 `?.=`**:仅对象非空时赋值,减少 `if`。 -3. **未绑定泛型 nameof**:`nameof(List<>)` 获取泛型类型名,无需占位类型参数。 -4. **Lambda 参数修饰符**:在 Lambda 中可用 `ref/out/in` 与默认参数,例如 `(ref int x, int bonus = 10) => x += bonus;`。 -5. **主构造函数 (Primary Constructor)**:服务/数据类优先 `class Foo(IDep dep, ILogger logger) { }`。 -6. **record/required/init**:DTO 默认用 record;关键属性用 `required`;不可变属性用 `init`。 -7. **集合表达式与展开**:使用 `[]` 创建集合,`[..other]` 拼接,`str[1..^1]` 进行切片。 -8. **模式匹配**:列表模式 `[1, 2, .. var rest]`、属性模式 `{ IsActive: true }`、switch 表达式简化分支。 -9. **文件范围命名空间/全局 using**:减少缩进与重复引用;复杂泛型用别名。 -10. **顶级语句**:Program.cs 保持顶级语句风格。 -11. **原始/UTF-8 字面量**:多行文本用 `"""`,性能场景用 `"text"u8`。 -12. **不可变命令优先**:命令/DTO 优先用 record 和 `with` 非破坏性拷贝,例如 `command = command with { MerchantId = merchantId };`,避免直接 `command.Property = ...` 带来的副作用。 +## 17. 现代语法范式 (.NET 10 / C# 14) +> **原则**:拥抱新特性以减少样板代码,但严禁牺牲可读性。 -(其余规则继续遵循上文约束:分层、命名、异步、日志、验证、租户/ID 策略等。) +1. **主构造函数 (Primary Constructor)**: + * **强制**:依赖注入场景必须使用 `class Service(IDep dep) { }`。 + * **禁止**:在主构造函数类中显式定义 `private readonly` 字段来承接参数(直接使用参数即可)。 +2. **对象初始化与不可变性**: + * **DTO/Command**:必须使用 `record` 类型。 + * **属性定义**:默认使用 `init`;必填项加 `required`;逻辑变更使用 `with` 表达式。 +3. **集合表达式**: + * **统一**:使用 `[]` 初始化集合。 + * **拼接**:使用 `[..array1, ..array2]` 替代 `Concat`。 +4. **模式匹配 (Pattern Matching)**: + * **替代**:严禁复杂的 `if-else if` 链,强制使用 `switch` 表达式。 + * **判空**:使用 `is not null` 替代 `!= null`。 +5. **极简语法糖**: + * **Field 关键字**:属性后备字段必须使用 `field` 关键字(如 `set => field = value.Trim();`)。 + * **空值赋值**:使用 `?.=` 简化判空赋值逻辑。 + * **字符串**:多行文本强制用 `"""` (Raw String Literal)。 -## 18. .NET 10 极致性能优化最佳实践(增量) -> 侧重零分配、并发与底层优化,遵循 2025 推荐方案。 -1. **Span/ReadOnlySpan 优先**:API 参数尽量用 `ReadOnlySpan` 处理字符串/切片,避免 Substring/复制。 -2. **栈分配与数组池**:小缓冲用 `stackalloc`,大缓冲统一用 `ArrayPool.Shared`,禁止直接 `new` 大数组。 -3. **UTF-8 字面量**:常量字节使用 `"text"u8`,避免运行时编码。 -4. **避免装箱**:热点路径规避隐式装箱,必要时用 `ref struct` 约束栈分配。 -5. **Frozen 集合**:只读查找表用 `FrozenDictionary/FrozenSet`,初始化后不再修改。 -6. **SearchValues SIMD 查找**:Span 内多字符搜索用 `SearchValues.Create(...)` + `ContainsAny`。 -7. **预设集合容量**:`List/Dictionary` 预知规模必须指定 `Capacity`。 -8. **ValueTask 热点返回**:可能同步完成的异步返回 `ValueTask`,减少 Task 分配。 -9. **Parallel.ForEachAsync 控并发**:I/O 并发用 Parallel.ForEachAsync 控制并行度,替代粗暴 Task.WhenAll。 -10. **避免 Task.Run**:在 ASP.NET Core 请求中不使用 Task.Run 做后台工作,改用 IHostedService 或 Channel 模式。 -11. **Channel 代替锁**:多线程数据传递优先使用 Channels,实现无锁生产者-消费者。 -12. **NativeAOT/PGO/向量化**:微服务/工具开启 NativeAOT;保留动态 PGO;计算密集场景考虑 System.Runtime.Intrinsics。 -13. **System.Text.Json + 源生成器**:全面替换 Newtonsoft.Json;使用 `[JsonSerializable]` + 生成的 `JsonSerializerContext`,兼容 NativeAOT,零反射。 -14. **Pipelines 处理流**:TCP/文件流解析使用 `PipeReader/PipeWriter`,获得零拷贝与缓冲管理。 -15. **HybridCache**:内存+分布式缓存统一用 HybridCache,利用防击穿合并并发请求。 +## 18. 极致性能规约 (High Performance) +> **原则**:默认编写“零分配 (Zero-Allocation)”代码,热点路径拒绝 GC 压力。 -## 19. 架构优化(增量) -> 架构优化方案 -1. **Chiseled 容器优先**:生产镜像基于 `mcr.microsoft.com/dotnet/runtime-deps:10.0-jammy-chiseled`,无 Shell、非 root,缩小攻击面,符合零信任要求。 -2. **默认集成 OpenTelemetry**:架构内置 OTel,统一通过 OTLP 导出 Metrics/Traces/Logs,避免依赖专有 APM 探针。 -3. **内部同步调用首选 gRPC**:微服务间禁止 JSON over HTTP,同步调用统一使用 gRPC,配合 Protobuf 源生成器获取强类型契约与更小载荷。 -4. **Outbox 模式强制**:处理领域事件时,事件记录必须与业务数据同事务写入 Outbox 表;后台 Worker 轮询 Outbox 再推送 MQ(RabbitMQ/Kafka),禁止事务提交后直接发消息以避免不一致。 -5. **共享资源必加分布式锁**:涉及库存扣减、定时任务抢占等共享资源时,必须引入分布式锁(如 Redis RedLock),防止并发竞争与脏写。 +1. **内存管理**: + * **字符串处理**:API 参数解析层**强制**使用 `ReadOnlySpan`,严禁使用 `Substring`。 + * **数组分配**:小块内存 (<1KB) 使用 `stackalloc`;大块内存 (>1KB) 必须使用 `ArrayPool.Shared`。 +2. **并发模型**: + * **无锁编程**:进程内生产者-消费者模型**必须**使用 `System.Threading.Channels`,严禁使用 `lock` 或 `BlockingCollection`。 + * **并行控制**:I/O 密集型并发必须使用 `Parallel.ForEachAsync` 并配置 `MaxDegreeOfParallelism`。 + * **异步返回**:热点路径下,若可能同步完成,返回类型必须为 `ValueTask`。 +3. **序列化与查找**: + * **JSON**:**全线废弃** Newtonsoft.Json。必须使用 `System.Text.Json` 配合源生成器 (`[JsonSerializable]`) 以支持 NativeAOT。 + * **静态集合**:只读字典/集合**必须**使用 `FrozenDictionary` / `FrozenSet`。 + * **SIMD 搜索**:多字符匹配场景使用 `SearchValues` 替代 `IndexOfAny`。 +4. **缓存架构**: + * **统一入口**:必须使用 `HybridCache` (或类似多级缓存抽象),禁止直接操作 `IDistributedCache` 以避免缓存击穿。 +## 19. 云原生架构规范 (Architecture) +> **原则**:默认零信任,默认分布式,默认可观测。 + +1. **容器与部署**: + * **基底镜像**:生产环境**强制**使用 Chiseled 镜像 (`runtime-deps:10.0-jammy-chiseled`),无 Shell、无 Root,最大化安全。 + * **健康检查**:必须包含 Liveness (存活) 和 Readiness (就绪) 探针。 +2. **服务间通信**: + * **同步调用**:内部微服务间**严禁**使用 REST/JSON,**必须**使用 gRPC (Protobuf)。 + * **契约管理**:Proto 文件必须作为单一事实来源 (Single Source of Truth) 统一管理。 +3. **数据一致性 (关键)**: + * **Outbox 模式**:领域事件发布**严禁**直接调用 MQ。必须在同一数据库事务中写入 `Outbox` 表,由独立 Worker 异步推送。 + * **幂等性**:所有消费者 (Consumer) 必须实现基于 `MessageId` 的幂等处理逻辑。 +4. **可观测性 (Observability)**: + * **OpenTelemetry**:严禁依赖特定厂商 SDK。必须统一输出 OTLP 标准格式 (Metrics/Logs/Traces)。 + * **关联ID**:确保 `TraceId` 在 HTTP Headers 和 MQ Metadata 中全程透传。 +5. **并发控制**: + * **分布式锁**:任何涉及跨实例的资源竞争(如库存扣减、定时任务),**必须**使用 Redis RedLock 或同等机制。 + --- diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPackageRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPackageRepository.cs index 7b63e27..05e9f9f 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPackageRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPackageRepository.cs @@ -11,32 +11,46 @@ namespace TakeoutSaaS.Domain.Tenants.Repositories; public interface ITenantPackageRepository { /// - /// 按 ID 查询套餐。 + /// 按套餐 ID 查询套餐。 /// + /// 套餐 ID(雪花算法)。 + /// 取消标记。 + /// 匹配的套餐实体,未找到返回 null。 Task FindByIdAsync(long id, CancellationToken cancellationToken = default); /// /// 按关键词与启用状态搜索套餐。 /// + /// 名称或描述关键字,空则不按关键字过滤。 + /// 启用状态,空则不按状态过滤。 + /// 取消标记。 + /// 符合条件的套餐列表。 Task> SearchAsync(string? keyword, bool? isActive, CancellationToken cancellationToken = default); /// /// 新增套餐。 /// + /// 套餐实体。 + /// 取消标记。 Task AddAsync(TenantPackage package, CancellationToken cancellationToken = default); /// /// 更新套餐。 /// + /// 套餐实体。 + /// 取消标记。 Task UpdateAsync(TenantPackage package, CancellationToken cancellationToken = default); /// /// 删除套餐。 /// + /// 套餐 ID(雪花算法)。 + /// 取消标记。 Task DeleteAsync(long id, CancellationToken cancellationToken = default); /// /// 持久化。 /// + /// 取消标记。 Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantQuotaUsageRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantQuotaUsageRepository.cs index ec3e7d7..07eae39 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantQuotaUsageRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantQuotaUsageRepository.cs @@ -14,25 +14,37 @@ public interface ITenantQuotaUsageRepository /// /// 获取租户指定配额的使用情况。 /// + /// 租户 ID(雪花算法)。 + /// 配额类型。 + /// 取消标记。 + /// 配额使用记录,未初始化则返回 null。 Task FindAsync(long tenantId, TenantQuotaType quotaType, CancellationToken cancellationToken = default); /// /// 按租户批量获取配额使用记录。 /// + /// 租户 ID(雪花算法)。 + /// 取消标记。 + /// 该租户的所有配额使用记录。 Task> GetByTenantAsync(long tenantId, CancellationToken cancellationToken = default); /// /// 新增配额使用记录。 /// + /// 配额使用实体。 + /// 取消标记。 Task AddAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default); /// /// 更新配额使用记录。 /// + /// 配额使用实体。 + /// 取消标记。 Task UpdateAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default); /// /// 持久化。 /// + /// 取消标记。 Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs index ea63403..33dedf9 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs @@ -13,11 +13,18 @@ public interface ITenantRepository /// /// 依据 ID 获取租户。 /// + /// 租户 ID(雪花算法)。 + /// 取消标记。 + /// 租户实体,未找到返回 null。 Task FindByIdAsync(long tenantId, CancellationToken cancellationToken = default); /// /// 按状态与关键词查询租户列表。 /// + /// 租户状态,为空不按状态过滤。 + /// 名称或编码关键字,为空不按关键字过滤。 + /// 取消标记。 + /// 符合条件的租户列表。 Task> SearchAsync( TenantStatus? status, string? keyword, @@ -26,70 +33,104 @@ public interface ITenantRepository /// /// 新增租户。 /// + /// 租户实体。 + /// 取消标记。 Task AddTenantAsync(Tenant tenant, CancellationToken cancellationToken = default); /// /// 更新租户。 /// + /// 租户实体。 + /// 取消标记。 Task UpdateTenantAsync(Tenant tenant, CancellationToken cancellationToken = default); /// /// 判断编码是否存在。 /// + /// 租户编码。 + /// 取消标记。 + /// 存在返回 true,否则 false。 Task ExistsByCodeAsync(string code, CancellationToken cancellationToken = default); /// /// 获取实名资料。 /// + /// 租户 ID(雪花算法)。 + /// 取消标记。 + /// 实名资料实体,未提交返回 null。 Task GetVerificationProfileAsync(long tenantId, CancellationToken cancellationToken = default); /// /// 新增或更新实名资料。 /// + /// 实名资料实体。 + /// 取消标记。 Task UpsertVerificationProfileAsync(TenantVerificationProfile profile, CancellationToken cancellationToken = default); /// /// 获取当前订阅。 /// + /// 租户 ID(雪花算法)。 + /// 取消标记。 + /// 当前有效订阅,若无则 null。 Task GetActiveSubscriptionAsync(long tenantId, CancellationToken cancellationToken = default); /// /// 依据订阅 ID 查询。 /// + /// 租户 ID(雪花算法)。 + /// 订阅 ID(雪花算法)。 + /// 取消标记。 + /// 订阅实体,未找到返回 null。 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); /// /// 获取订阅历史。 /// + /// 租户 ID(雪花算法)。 + /// 取消标记。 + /// 订阅历史列表。 Task> GetSubscriptionHistoryAsync(long tenantId, CancellationToken cancellationToken = default); /// /// 新增审核日志。 /// + /// 审核日志实体。 + /// 取消标记。 Task AddAuditLogAsync(TenantAuditLog log, CancellationToken cancellationToken = default); /// /// 查询审核日志。 /// + /// 租户 ID(雪花算法)。 + /// 取消标记。 + /// 审核日志列表。 Task> GetAuditLogsAsync(long tenantId, CancellationToken cancellationToken = default); /// /// 持久化。 /// + /// 取消标记。 Task SaveChangesAsync(CancellationToken cancellationToken = default); } From 9051a024ea0226a0c9bd8751d451ca85e8c0202c Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Thu, 4 Dec 2025 08:38:31 +0800 Subject: [PATCH 15/30] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E9=97=A8?= =?UTF-8?q?=E5=BA=97=E5=AD=90=E8=B5=84=E6=BA=90=E7=AE=A1=E7=90=86=E4=B8=8E?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Document/12_BusinessTodo.md | 4 +- .../Controllers/StoresController.cs | 193 ++++++++++++++++++ .../CreateStoreBusinessHourCommand.cs | 46 +++++ .../App/Stores/Commands/CreateStoreCommand.cs | 10 + .../CreateStoreDeliveryZoneCommand.cs | 45 ++++ .../Commands/CreateStoreHolidayCommand.cs | 30 +++ .../DeleteStoreBusinessHourCommand.cs | 19 ++ .../DeleteStoreDeliveryZoneCommand.cs | 19 ++ .../Commands/DeleteStoreHolidayCommand.cs | 19 ++ .../UpdateStoreBusinessHourCommand.cs | 51 +++++ .../App/Stores/Commands/UpdateStoreCommand.cs | 10 + .../UpdateStoreDeliveryZoneCommand.cs | 50 +++++ .../Commands/UpdateStoreHolidayCommand.cs | 35 ++++ .../App/Stores/Dto/StoreBusinessHourDto.cs | 64 ++++++ .../App/Stores/Dto/StoreDeliveryZoneDto.cs | 63 ++++++ .../App/Stores/Dto/StoreDto.cs | 10 + .../App/Stores/Dto/StoreHolidayDto.cs | 48 +++++ .../CreateStoreBusinessHourCommandHandler.cs | 57 ++++++ .../Handlers/CreateStoreCommandHandler.cs | 6 +- .../CreateStoreDeliveryZoneCommandHandler.cs | 57 ++++++ .../CreateStoreHolidayCommandHandler.cs | 54 +++++ .../DeleteStoreBusinessHourCommandHandler.cs | 46 +++++ .../DeleteStoreDeliveryZoneCommandHandler.cs | 46 +++++ .../DeleteStoreHolidayCommandHandler.cs | 46 +++++ .../Handlers/GetStoreByIdQueryHandler.cs | 2 + .../ListStoreBusinessHoursQueryHandler.cs | 31 +++ .../ListStoreDeliveryZonesQueryHandler.cs | 31 +++ .../Handlers/ListStoreHolidaysQueryHandler.cs | 31 +++ .../Handlers/SearchStoresQueryHandler.cs | 2 + .../UpdateStoreBusinessHourCommandHandler.cs | 59 ++++++ .../Handlers/UpdateStoreCommandHandler.cs | 4 + .../UpdateStoreDeliveryZoneCommandHandler.cs | 59 ++++++ .../UpdateStoreHolidayCommandHandler.cs | 56 +++++ .../Queries/ListStoreBusinessHoursQuery.cs | 15 ++ .../Queries/ListStoreDeliveryZonesQuery.cs | 15 ++ .../Stores/Queries/ListStoreHolidaysQuery.cs | 15 ++ .../App/Stores/StoreMapping.cs | 64 ++++++ ...CreateStoreBusinessHourCommandValidator.cs | 21 ++ ...CreateStoreDeliveryZoneCommandValidator.cs | 24 +++ .../CreateStoreHolidayCommandValidator.cs | 20 ++ ...UpdateStoreBusinessHourCommandValidator.cs | 22 ++ ...UpdateStoreDeliveryZoneCommandValidator.cs | 25 +++ .../UpdateStoreHolidayCommandValidator.cs | 21 ++ .../Stores/Repositories/IStoreRepository.cs | 45 ++++ .../App/Repositories/EfStoreRepository.cs | 84 ++++++++ 45 files changed, 1671 insertions(+), 3 deletions(-) create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreBusinessHourCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreDeliveryZoneCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreHolidayCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreBusinessHourCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreDeliveryZoneCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreHolidayCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreBusinessHourCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreDeliveryZoneCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreHolidayCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreBusinessHourDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreDeliveryZoneDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreHolidayDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreBusinessHourCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreDeliveryZoneCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreHolidayCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreBusinessHourCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreDeliveryZoneCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreHolidayCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreBusinessHoursQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreDeliveryZonesQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreHolidaysQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreBusinessHourCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreDeliveryZoneCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreHolidayCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreBusinessHoursQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreDeliveryZonesQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreHolidaysQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreBusinessHourCommandValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreDeliveryZoneCommandValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreHolidayCommandValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreBusinessHourCommandValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreDeliveryZoneCommandValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreHolidayCommandValidator.cs diff --git a/Document/12_BusinessTodo.md b/Document/12_BusinessTodo.md index 2e1525e..0d2cad0 100644 --- a/Document/12_BusinessTodo.md +++ b/Document/12_BusinessTodo.md @@ -14,8 +14,8 @@ - 已交付:新增套餐仓储与命令/查询/DTO(`src/Application/TakeoutSaaS.Application/App/Tenants`),Admin 端新增 `TenantPackagesController` 提供套餐列表/详情/创建/更新/删除接口。新增配额校验命令与租户接口 `/api/admin/v1/tenants/{id}/quotas/check`,基于当前订阅套餐限额校验并占用配额,超额抛出 409 并写入 `TenantQuotaUsage`。仓储注册于 `AddAppInfrastructure`。 - [x] 租户运营面板:欠费/到期告警、账单列表、公告通知接口,支持已读状态并在 Admin UI 展示。 - 已交付:新增账单/公告/通知实体与仓储,Admin 端提供 `/tenants/{id}/billings`(列表/详情/创建/标记支付)、`/announcements`(列表/详情/创建/更新/删除/已读)、`/notifications`(列表/已读)端点;权限码补充 `tenant-bill:*`、`tenant-announcement:*`、`tenant-notification:*`,种子模板更新;配额/订阅告警可通过通知表承载。 -- [ ] 门店管理:Store/StoreBusinessHour/StoreDeliveryZone/StoreHoliday CRUD 完整,含 GeoJSON 配送范围及能力开关。 - - 当前:仅有门店基础 CRUD(`src/Api/TakeoutSaaS.AdminApi/Controllers/StoresController.cs`),未实现营业时间、配送区/GeoJSON、节假日的命令/查询与 API;相关实体和仓储方法存在但未暴露。 +- [x] 门店管理:Store/StoreBusinessHour/StoreDeliveryZone/StoreHoliday CRUD 完整,含 GeoJSON 配送范围及能力开关。 + - 进展:已补充营业时间/配送区/节假日的命令、查询、验证与处理器,Admin API 新增子路由完成 CRUD,门店能力开关(预约/排队)已对外暴露;仓储扩展读写删除并保持租户过滤。 - [ ] 桌码管理:批量生成桌码、绑定区域/容量、导出二维码 ZIP(POST /api/admin/stores/{id}/tables 可下载)。 - 当前:模型/仓储已包含 `StoreTable`/`StoreTableArea`,但缺少命令/查询/控制器,未实现批量生成、区域容量绑定和二维码导出。 - [ ] 员工排班:创建员工、绑定门店角色、维护 StoreEmployeeShift,可查询未来 7 日排班。 diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/StoresController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoresController.cs index 6abdac2..3acccb7 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/StoresController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoresController.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -114,4 +115,196 @@ public sealed class StoresController(IMediator mediator) : BaseApiController ? ApiResponse.Ok(null) : ApiResponse.Error(ErrorCodes.NotFound, "门店不存在"); } + + /// + /// 查询门店营业时段。 + /// + [HttpGet("{storeId:long}/business-hours")] + [PermissionAuthorize("store:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> ListBusinessHours(long storeId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new ListStoreBusinessHoursQuery { StoreId = storeId }, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 新增营业时段。 + /// + [HttpPost("{storeId:long}/business-hours")] + [PermissionAuthorize("store:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CreateBusinessHour(long storeId, [FromBody] CreateStoreBusinessHourCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 更新营业时段。 + /// + [HttpPut("{storeId:long}/business-hours/{businessHourId:long}")] + [PermissionAuthorize("store:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> UpdateBusinessHour(long storeId, long businessHourId, [FromBody] UpdateStoreBusinessHourCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0 || command.BusinessHourId == 0) + { + command = command with { StoreId = storeId, BusinessHourId = businessHourId }; + } + + var result = await mediator.Send(command, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "营业时段不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除营业时段。 + /// + [HttpDelete("{storeId:long}/business-hours/{businessHourId:long}")] + [PermissionAuthorize("store:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> DeleteBusinessHour(long storeId, long businessHourId, CancellationToken cancellationToken) + { + var success = await mediator.Send(new DeleteStoreBusinessHourCommand { StoreId = storeId, BusinessHourId = businessHourId }, cancellationToken); + return success + ? ApiResponse.Ok(null) + : ApiResponse.Error(ErrorCodes.NotFound, "营业时段不存在"); + } + + /// + /// 查询配送区域。 + /// + [HttpGet("{storeId:long}/delivery-zones")] + [PermissionAuthorize("store:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> ListDeliveryZones(long storeId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new ListStoreDeliveryZonesQuery { StoreId = storeId }, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 新增配送区域。 + /// + [HttpPost("{storeId:long}/delivery-zones")] + [PermissionAuthorize("store:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CreateDeliveryZone(long storeId, [FromBody] CreateStoreDeliveryZoneCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 更新配送区域。 + /// + [HttpPut("{storeId:long}/delivery-zones/{deliveryZoneId:long}")] + [PermissionAuthorize("store:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> UpdateDeliveryZone(long storeId, long deliveryZoneId, [FromBody] UpdateStoreDeliveryZoneCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0 || command.DeliveryZoneId == 0) + { + command = command with { StoreId = storeId, DeliveryZoneId = deliveryZoneId }; + } + + var result = await mediator.Send(command, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "配送区域不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除配送区域。 + /// + [HttpDelete("{storeId:long}/delivery-zones/{deliveryZoneId:long}")] + [PermissionAuthorize("store:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> DeleteDeliveryZone(long storeId, long deliveryZoneId, CancellationToken cancellationToken) + { + var success = await mediator.Send(new DeleteStoreDeliveryZoneCommand { StoreId = storeId, DeliveryZoneId = deliveryZoneId }, cancellationToken); + return success + ? ApiResponse.Ok(null) + : ApiResponse.Error(ErrorCodes.NotFound, "配送区域不存在"); + } + + /// + /// 查询门店节假日。 + /// + [HttpGet("{storeId:long}/holidays")] + [PermissionAuthorize("store:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> ListHolidays(long storeId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new ListStoreHolidaysQuery { StoreId = storeId }, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 新增节假日配置。 + /// + [HttpPost("{storeId:long}/holidays")] + [PermissionAuthorize("store:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CreateHoliday(long storeId, [FromBody] CreateStoreHolidayCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 更新节假日配置。 + /// + [HttpPut("{storeId:long}/holidays/{holidayId:long}")] + [PermissionAuthorize("store:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> UpdateHoliday(long storeId, long holidayId, [FromBody] UpdateStoreHolidayCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0 || command.HolidayId == 0) + { + command = command with { StoreId = storeId, HolidayId = holidayId }; + } + + var result = await mediator.Send(command, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "节假日配置不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除节假日配置。 + /// + [HttpDelete("{storeId:long}/holidays/{holidayId:long}")] + [PermissionAuthorize("store:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> DeleteHoliday(long storeId, long holidayId, CancellationToken cancellationToken) + { + var success = await mediator.Send(new DeleteStoreHolidayCommand { StoreId = storeId, HolidayId = holidayId }, cancellationToken); + return success + ? ApiResponse.Ok(null) + : ApiResponse.Error(ErrorCodes.NotFound, "节假日配置不存在"); + } } diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreBusinessHourCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreBusinessHourCommand.cs new file mode 100644 index 0000000..345e990 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreBusinessHourCommand.cs @@ -0,0 +1,46 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 创建营业时段命令。 +/// +public sealed record CreateStoreBusinessHourCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 星期几。 + /// + public DayOfWeek DayOfWeek { get; init; } + + /// + /// 时段类型。 + /// + public BusinessHourType HourType { get; init; } = BusinessHourType.Normal; + + /// + /// 开始时间。 + /// + public TimeSpan StartTime { get; init; } + + /// + /// 结束时间。 + /// + public TimeSpan EndTime { get; init; } + + /// + /// 容量限制。 + /// + public int? CapacityLimit { get; init; } + + /// + /// 备注。 + /// + public string? Notes { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreCommand.cs index 21eb9c5..3315f92 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreCommand.cs @@ -98,4 +98,14 @@ public sealed class CreateStoreCommand : IRequest /// 支持配送。 /// public bool SupportsDelivery { get; set; } = true; + + /// + /// 支持预约。 + /// + public bool SupportsReservation { get; set; } + + /// + /// 支持排队叫号。 + /// + public bool SupportsQueueing { get; set; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreDeliveryZoneCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreDeliveryZoneCommand.cs new file mode 100644 index 0000000..af1af08 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreDeliveryZoneCommand.cs @@ -0,0 +1,45 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 创建配送区域命令。 +/// +public sealed record CreateStoreDeliveryZoneCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 区域名称。 + /// + public string ZoneName { get; init; } = string.Empty; + + /// + /// GeoJSON。 + /// + public string PolygonGeoJson { get; init; } = string.Empty; + + /// + /// 起送价。 + /// + public decimal? MinimumOrderAmount { get; init; } + + /// + /// 配送费。 + /// + public decimal? DeliveryFee { get; init; } + + /// + /// 预计分钟。 + /// + public int? EstimatedMinutes { get; init; } + + /// + /// 排序。 + /// + public int SortOrder { get; init; } = 100; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreHolidayCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreHolidayCommand.cs new file mode 100644 index 0000000..896c090 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreHolidayCommand.cs @@ -0,0 +1,30 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 创建节假日配置命令。 +/// +public sealed record CreateStoreHolidayCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 日期。 + /// + public DateTime Date { get; init; } + + /// + /// 是否闭店。 + /// + public bool IsClosed { get; init; } = true; + + /// + /// 说明。 + /// + public string? Reason { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreBusinessHourCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreBusinessHourCommand.cs new file mode 100644 index 0000000..d101680 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreBusinessHourCommand.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 删除营业时段命令。 +/// +public sealed record DeleteStoreBusinessHourCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 营业时段 ID。 + /// + public long BusinessHourId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreDeliveryZoneCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreDeliveryZoneCommand.cs new file mode 100644 index 0000000..0e9e57d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreDeliveryZoneCommand.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 删除配送区域命令。 +/// +public sealed record DeleteStoreDeliveryZoneCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 配送区域 ID。 + /// + public long DeliveryZoneId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreHolidayCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreHolidayCommand.cs new file mode 100644 index 0000000..8e86eb8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreHolidayCommand.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 删除节假日配置命令。 +/// +public sealed record DeleteStoreHolidayCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 节假日 ID。 + /// + public long HolidayId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreBusinessHourCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreBusinessHourCommand.cs new file mode 100644 index 0000000..7c13e2e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreBusinessHourCommand.cs @@ -0,0 +1,51 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 更新营业时段命令。 +/// +public sealed record UpdateStoreBusinessHourCommand : IRequest +{ + /// + /// 营业时段 ID。 + /// + public long BusinessHourId { get; init; } + + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 星期几。 + /// + public DayOfWeek DayOfWeek { get; init; } + + /// + /// 时段类型。 + /// + public BusinessHourType HourType { get; init; } = BusinessHourType.Normal; + + /// + /// 开始时间。 + /// + public TimeSpan StartTime { get; init; } + + /// + /// 结束时间。 + /// + public TimeSpan EndTime { get; init; } + + /// + /// 容量限制。 + /// + public int? CapacityLimit { get; init; } + + /// + /// 备注。 + /// + public string? Notes { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreCommand.cs index 63a178d..c7ef3b5 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreCommand.cs @@ -103,4 +103,14 @@ public sealed record UpdateStoreCommand : IRequest /// 支持配送。 /// public bool SupportsDelivery { get; init; } = true; + + /// + /// 支持预约。 + /// + public bool SupportsReservation { get; init; } + + /// + /// 支持排队叫号。 + /// + public bool SupportsQueueing { get; init; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreDeliveryZoneCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreDeliveryZoneCommand.cs new file mode 100644 index 0000000..e21ded5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreDeliveryZoneCommand.cs @@ -0,0 +1,50 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 更新配送区域命令。 +/// +public sealed record UpdateStoreDeliveryZoneCommand : IRequest +{ + /// + /// 配送区域 ID。 + /// + public long DeliveryZoneId { get; init; } + + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 区域名称。 + /// + public string ZoneName { get; init; } = string.Empty; + + /// + /// GeoJSON。 + /// + public string PolygonGeoJson { get; init; } = string.Empty; + + /// + /// 起送价。 + /// + public decimal? MinimumOrderAmount { get; init; } + + /// + /// 配送费。 + /// + public decimal? DeliveryFee { get; init; } + + /// + /// 预计分钟。 + /// + public int? EstimatedMinutes { get; init; } + + /// + /// 排序。 + /// + public int SortOrder { get; init; } = 100; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreHolidayCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreHolidayCommand.cs new file mode 100644 index 0000000..c228bb1 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreHolidayCommand.cs @@ -0,0 +1,35 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 更新节假日配置命令。 +/// +public sealed record UpdateStoreHolidayCommand : IRequest +{ + /// + /// 节假日 ID。 + /// + public long HolidayId { get; init; } + + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 日期。 + /// + public DateTime Date { get; init; } + + /// + /// 是否闭店。 + /// + public bool IsClosed { get; init; } = true; + + /// + /// 说明。 + /// + public string? Reason { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreBusinessHourDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreBusinessHourDto.cs new file mode 100644 index 0000000..0a28ebd --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreBusinessHourDto.cs @@ -0,0 +1,64 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 门店营业时段 DTO。 +/// +public sealed record StoreBusinessHourDto +{ + /// + /// 营业时段 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 星期几。 + /// + public DayOfWeek DayOfWeek { get; init; } + + /// + /// 时段类型。 + /// + public BusinessHourType HourType { get; init; } + + /// + /// 开始时间。 + /// + public TimeSpan StartTime { get; init; } + + /// + /// 结束时间。 + /// + public TimeSpan EndTime { get; init; } + + /// + /// 容量限制。 + /// + public int? CapacityLimit { get; init; } + + /// + /// 备注。 + /// + public string? Notes { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreDeliveryZoneDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreDeliveryZoneDto.cs new file mode 100644 index 0000000..588aa43 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreDeliveryZoneDto.cs @@ -0,0 +1,63 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 门店配送区域 DTO。 +/// +public sealed record StoreDeliveryZoneDto +{ + /// + /// 配送区域 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 区域名称。 + /// + public string ZoneName { get; init; } = string.Empty; + + /// + /// GeoJSON。 + /// + public string PolygonGeoJson { get; init; } = string.Empty; + + /// + /// 起送价。 + /// + public decimal? MinimumOrderAmount { get; init; } + + /// + /// 配送费。 + /// + public decimal? DeliveryFee { get; init; } + + /// + /// 预计分钟。 + /// + public int? EstimatedMinutes { get; init; } + + /// + /// 排序。 + /// + public int SortOrder { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreDto.cs index 5412717..8cf3cc0 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreDto.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreDto.cs @@ -112,6 +112,16 @@ public sealed class StoreDto /// public bool SupportsDelivery { get; init; } + /// + /// 支持预约。 + /// + public bool SupportsReservation { get; init; } + + /// + /// 支持排队叫号。 + /// + public bool SupportsQueueing { get; init; } + /// /// 创建时间。 /// diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreHolidayDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreHolidayDto.cs new file mode 100644 index 0000000..a861b85 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreHolidayDto.cs @@ -0,0 +1,48 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 门店节假日 DTO。 +/// +public sealed record StoreHolidayDto +{ + /// + /// 节假日 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 日期。 + /// + public DateTime Date { get; init; } + + /// + /// 是否闭店。 + /// + public bool IsClosed { get; init; } + + /// + /// 说明。 + /// + public string? Reason { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreBusinessHourCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreBusinessHourCommandHandler.cs new file mode 100644 index 0000000..9b474b3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreBusinessHourCommandHandler.cs @@ -0,0 +1,57 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 创建营业时段处理器。 +/// +public sealed class CreateStoreBusinessHourCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IStoreRepository _storeRepository = storeRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(CreateStoreBusinessHourCommand request, CancellationToken cancellationToken) + { + // 1. 校验门店存在 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var store = await _storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 2. 构建实体 + var hour = new StoreBusinessHour + { + StoreId = request.StoreId, + DayOfWeek = request.DayOfWeek, + HourType = request.HourType, + StartTime = request.StartTime, + EndTime = request.EndTime, + CapacityLimit = request.CapacityLimit, + Notes = request.Notes?.Trim() + }; + + // 3. 持久化 + await _storeRepository.AddBusinessHoursAsync(new[] { hour }, cancellationToken); + await _storeRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("创建营业时段 {BusinessHourId} 对应门店 {StoreId}", hour.Id, request.StoreId); + + // 4. 返回 DTO + return StoreMapping.ToDto(hour); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreCommandHandler.cs index f254d6f..104c302 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreCommandHandler.cs @@ -39,7 +39,9 @@ public sealed class CreateStoreCommandHandler(IStoreRepository storeRepository, DeliveryRadiusKm = request.DeliveryRadiusKm, SupportsDineIn = request.SupportsDineIn, SupportsPickup = request.SupportsPickup, - SupportsDelivery = request.SupportsDelivery + SupportsDelivery = request.SupportsDelivery, + SupportsReservation = request.SupportsReservation, + SupportsQueueing = request.SupportsQueueing }; // 2. 持久化 @@ -73,6 +75,8 @@ public sealed class CreateStoreCommandHandler(IStoreRepository storeRepository, SupportsDineIn = store.SupportsDineIn, SupportsPickup = store.SupportsPickup, SupportsDelivery = store.SupportsDelivery, + SupportsReservation = store.SupportsReservation, + SupportsQueueing = store.SupportsQueueing, CreatedAt = store.CreatedAt }; } diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreDeliveryZoneCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreDeliveryZoneCommandHandler.cs new file mode 100644 index 0000000..c5858b3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreDeliveryZoneCommandHandler.cs @@ -0,0 +1,57 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 创建配送区域处理器。 +/// +public sealed class CreateStoreDeliveryZoneCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IStoreRepository _storeRepository = storeRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(CreateStoreDeliveryZoneCommand request, CancellationToken cancellationToken) + { + // 1. 校验门店存在 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var store = await _storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 2. 构建实体 + var zone = new StoreDeliveryZone + { + StoreId = request.StoreId, + ZoneName = request.ZoneName.Trim(), + PolygonGeoJson = request.PolygonGeoJson.Trim(), + MinimumOrderAmount = request.MinimumOrderAmount, + DeliveryFee = request.DeliveryFee, + EstimatedMinutes = request.EstimatedMinutes, + SortOrder = request.SortOrder + }; + + // 3. 持久化 + await _storeRepository.AddDeliveryZonesAsync(new[] { zone }, cancellationToken); + await _storeRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("创建配送区域 {DeliveryZoneId} 对应门店 {StoreId}", zone.Id, request.StoreId); + + // 4. 返回 DTO + return StoreMapping.ToDto(zone); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreHolidayCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreHolidayCommandHandler.cs new file mode 100644 index 0000000..f5f5692 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreHolidayCommandHandler.cs @@ -0,0 +1,54 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 创建节假日配置处理器。 +/// +public sealed class CreateStoreHolidayCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IStoreRepository _storeRepository = storeRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(CreateStoreHolidayCommand request, CancellationToken cancellationToken) + { + // 1. 校验门店存在 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var store = await _storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 2. 构建实体 + var holiday = new StoreHoliday + { + StoreId = request.StoreId, + Date = request.Date, + IsClosed = request.IsClosed, + Reason = request.Reason?.Trim() + }; + + // 3. 持久化 + await _storeRepository.AddHolidaysAsync(new[] { holiday }, cancellationToken); + await _storeRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("创建节假日 {HolidayId} 对应门店 {StoreId}", holiday.Id, request.StoreId); + + // 4. 返回 DTO + return StoreMapping.ToDto(holiday); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreBusinessHourCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreBusinessHourCommandHandler.cs new file mode 100644 index 0000000..816cd88 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreBusinessHourCommandHandler.cs @@ -0,0 +1,46 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 删除营业时段处理器。 +/// +public sealed class DeleteStoreBusinessHourCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IStoreRepository _storeRepository = storeRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(DeleteStoreBusinessHourCommand request, CancellationToken cancellationToken) + { + // 1. 读取时段 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var existing = await _storeRepository.FindBusinessHourByIdAsync(request.BusinessHourId, tenantId, cancellationToken); + if (existing is null) + { + return false; + } + + // 2. 校验门店归属 + if (existing.StoreId != request.StoreId) + { + return false; + } + + // 3. 删除 + await _storeRepository.DeleteBusinessHourAsync(request.BusinessHourId, tenantId, cancellationToken); + await _storeRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("删除营业时段 {BusinessHourId} 对应门店 {StoreId}", request.BusinessHourId, request.StoreId); + + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreDeliveryZoneCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreDeliveryZoneCommandHandler.cs new file mode 100644 index 0000000..c7e7abb --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreDeliveryZoneCommandHandler.cs @@ -0,0 +1,46 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 删除配送区域处理器。 +/// +public sealed class DeleteStoreDeliveryZoneCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IStoreRepository _storeRepository = storeRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(DeleteStoreDeliveryZoneCommand request, CancellationToken cancellationToken) + { + // 1. 读取区域 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var existing = await _storeRepository.FindDeliveryZoneByIdAsync(request.DeliveryZoneId, tenantId, cancellationToken); + if (existing is null) + { + return false; + } + + // 2. 校验门店归属 + if (existing.StoreId != request.StoreId) + { + return false; + } + + // 3. 删除 + await _storeRepository.DeleteDeliveryZoneAsync(request.DeliveryZoneId, tenantId, cancellationToken); + await _storeRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("删除配送区域 {DeliveryZoneId} 对应门店 {StoreId}", request.DeliveryZoneId, request.StoreId); + + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreHolidayCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreHolidayCommandHandler.cs new file mode 100644 index 0000000..a262ffe --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreHolidayCommandHandler.cs @@ -0,0 +1,46 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 删除节假日配置处理器。 +/// +public sealed class DeleteStoreHolidayCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IStoreRepository _storeRepository = storeRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(DeleteStoreHolidayCommand request, CancellationToken cancellationToken) + { + // 1. 读取配置 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var existing = await _storeRepository.FindHolidayByIdAsync(request.HolidayId, tenantId, cancellationToken); + if (existing is null) + { + return false; + } + + // 2. 校验门店归属 + if (existing.StoreId != request.StoreId) + { + return false; + } + + // 3. 删除 + await _storeRepository.DeleteHolidayAsync(request.HolidayId, tenantId, cancellationToken); + await _storeRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("删除节假日 {HolidayId} 对应门店 {StoreId}", request.HolidayId, request.StoreId); + + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreByIdQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreByIdQueryHandler.cs index 995ddde..ecd7415 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreByIdQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreByIdQueryHandler.cs @@ -48,6 +48,8 @@ public sealed class GetStoreByIdQueryHandler( SupportsDineIn = store.SupportsDineIn, SupportsPickup = store.SupportsPickup, SupportsDelivery = store.SupportsDelivery, + SupportsReservation = store.SupportsReservation, + SupportsQueueing = store.SupportsQueueing, CreatedAt = store.CreatedAt }; } diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreBusinessHoursQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreBusinessHoursQueryHandler.cs new file mode 100644 index 0000000..f0f2c5d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreBusinessHoursQueryHandler.cs @@ -0,0 +1,31 @@ +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 营业时段列表查询处理器。 +/// +public sealed class ListStoreBusinessHoursQueryHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + private readonly IStoreRepository _storeRepository = storeRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + /// + public async Task> Handle(ListStoreBusinessHoursQuery request, CancellationToken cancellationToken) + { + // 1. 查询时段列表 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var hours = await _storeRepository.GetBusinessHoursAsync(request.StoreId, tenantId, cancellationToken); + + // 2. 映射 DTO + return hours.Select(StoreMapping.ToDto).ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreDeliveryZonesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreDeliveryZonesQueryHandler.cs new file mode 100644 index 0000000..ffcc741 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreDeliveryZonesQueryHandler.cs @@ -0,0 +1,31 @@ +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 配送区域列表查询处理器。 +/// +public sealed class ListStoreDeliveryZonesQueryHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + private readonly IStoreRepository _storeRepository = storeRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + /// + public async Task> Handle(ListStoreDeliveryZonesQuery request, CancellationToken cancellationToken) + { + // 1. 查询配送区域 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var zones = await _storeRepository.GetDeliveryZonesAsync(request.StoreId, tenantId, cancellationToken); + + // 2. 映射 DTO + return zones.Select(StoreMapping.ToDto).ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreHolidaysQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreHolidaysQueryHandler.cs new file mode 100644 index 0000000..558b8f2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreHolidaysQueryHandler.cs @@ -0,0 +1,31 @@ +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 门店节假日列表查询处理器。 +/// +public sealed class ListStoreHolidaysQueryHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + private readonly IStoreRepository _storeRepository = storeRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + /// + public async Task> Handle(ListStoreHolidaysQuery request, CancellationToken cancellationToken) + { + // 1. 查询节假日 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var holidays = await _storeRepository.GetHolidaysAsync(request.StoreId, tenantId, cancellationToken); + + // 2. 映射 DTO + return holidays.Select(StoreMapping.ToDto).ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/SearchStoresQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/SearchStoresQueryHandler.cs index 1bb609d..f9b2330 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/SearchStoresQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/SearchStoresQueryHandler.cs @@ -75,6 +75,8 @@ public sealed class SearchStoresQueryHandler( SupportsDineIn = store.SupportsDineIn, SupportsPickup = store.SupportsPickup, SupportsDelivery = store.SupportsDelivery, + SupportsReservation = store.SupportsReservation, + SupportsQueueing = store.SupportsQueueing, CreatedAt = store.CreatedAt }; } diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreBusinessHourCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreBusinessHourCommandHandler.cs new file mode 100644 index 0000000..b85bd55 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreBusinessHourCommandHandler.cs @@ -0,0 +1,59 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 更新营业时段处理器。 +/// +public sealed class UpdateStoreBusinessHourCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IStoreRepository _storeRepository = storeRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(UpdateStoreBusinessHourCommand request, CancellationToken cancellationToken) + { + // 1. 读取时段 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var existing = await _storeRepository.FindBusinessHourByIdAsync(request.BusinessHourId, tenantId, cancellationToken); + if (existing is null) + { + return null; + } + + // 2. 校验门店归属 + if (existing.StoreId != request.StoreId) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "营业时段不属于该门店"); + } + + // 3. 更新字段 + existing.DayOfWeek = request.DayOfWeek; + existing.HourType = request.HourType; + existing.StartTime = request.StartTime; + existing.EndTime = request.EndTime; + existing.CapacityLimit = request.CapacityLimit; + existing.Notes = request.Notes?.Trim(); + + // 4. 持久化 + await _storeRepository.UpdateBusinessHourAsync(existing, cancellationToken); + await _storeRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("更新营业时段 {BusinessHourId} 对应门店 {StoreId}", existing.Id, existing.StoreId); + + // 5. 返回 DTO + return StoreMapping.ToDto(existing); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreCommandHandler.cs index 934bb54..025ae4d 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreCommandHandler.cs @@ -51,6 +51,8 @@ public sealed class UpdateStoreCommandHandler( existing.SupportsDineIn = request.SupportsDineIn; existing.SupportsPickup = request.SupportsPickup; existing.SupportsDelivery = request.SupportsDelivery; + existing.SupportsReservation = request.SupportsReservation; + existing.SupportsQueueing = request.SupportsQueueing; // 3. 持久化 await _storeRepository.UpdateStoreAsync(existing, cancellationToken); @@ -83,6 +85,8 @@ public sealed class UpdateStoreCommandHandler( SupportsDineIn = store.SupportsDineIn, SupportsPickup = store.SupportsPickup, SupportsDelivery = store.SupportsDelivery, + SupportsReservation = store.SupportsReservation, + SupportsQueueing = store.SupportsQueueing, CreatedAt = store.CreatedAt }; } diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreDeliveryZoneCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreDeliveryZoneCommandHandler.cs new file mode 100644 index 0000000..ecf7f66 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreDeliveryZoneCommandHandler.cs @@ -0,0 +1,59 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 更新配送区域处理器。 +/// +public sealed class UpdateStoreDeliveryZoneCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IStoreRepository _storeRepository = storeRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(UpdateStoreDeliveryZoneCommand request, CancellationToken cancellationToken) + { + // 1. 读取区域 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var existing = await _storeRepository.FindDeliveryZoneByIdAsync(request.DeliveryZoneId, tenantId, cancellationToken); + if (existing is null) + { + return null; + } + + // 2. 校验门店归属 + if (existing.StoreId != request.StoreId) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "配送区域不属于该门店"); + } + + // 3. 更新字段 + existing.ZoneName = request.ZoneName.Trim(); + existing.PolygonGeoJson = request.PolygonGeoJson.Trim(); + existing.MinimumOrderAmount = request.MinimumOrderAmount; + existing.DeliveryFee = request.DeliveryFee; + existing.EstimatedMinutes = request.EstimatedMinutes; + existing.SortOrder = request.SortOrder; + + // 4. 持久化 + await _storeRepository.UpdateDeliveryZoneAsync(existing, cancellationToken); + await _storeRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("更新配送区域 {DeliveryZoneId} 对应门店 {StoreId}", existing.Id, existing.StoreId); + + // 5. 返回 DTO + return StoreMapping.ToDto(existing); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreHolidayCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreHolidayCommandHandler.cs new file mode 100644 index 0000000..878eaeb --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreHolidayCommandHandler.cs @@ -0,0 +1,56 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 更新节假日配置处理器。 +/// +public sealed class UpdateStoreHolidayCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IStoreRepository _storeRepository = storeRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(UpdateStoreHolidayCommand request, CancellationToken cancellationToken) + { + // 1. 读取配置 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var existing = await _storeRepository.FindHolidayByIdAsync(request.HolidayId, tenantId, cancellationToken); + if (existing is null) + { + return null; + } + + // 2. 校验门店归属 + if (existing.StoreId != request.StoreId) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "节假日配置不属于该门店"); + } + + // 3. 更新字段 + existing.Date = request.Date; + existing.IsClosed = request.IsClosed; + existing.Reason = request.Reason?.Trim(); + + // 4. 持久化 + await _storeRepository.UpdateHolidayAsync(existing, cancellationToken); + await _storeRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("更新节假日 {HolidayId} 对应门店 {StoreId}", existing.Id, existing.StoreId); + + // 5. 返回 DTO + return StoreMapping.ToDto(existing); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreBusinessHoursQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreBusinessHoursQuery.cs new file mode 100644 index 0000000..dac381a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreBusinessHoursQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 营业时段列表查询。 +/// +public sealed record ListStoreBusinessHoursQuery : IRequest> +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreDeliveryZonesQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreDeliveryZonesQuery.cs new file mode 100644 index 0000000..c3ed9ed --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreDeliveryZonesQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 配送区域列表查询。 +/// +public sealed record ListStoreDeliveryZonesQuery : IRequest> +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreHolidaysQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreHolidaysQuery.cs new file mode 100644 index 0000000..bf14cd2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreHolidaysQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 门店节假日列表查询。 +/// +public sealed record ListStoreHolidaysQuery : IRequest> +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs b/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs new file mode 100644 index 0000000..06c9094 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs @@ -0,0 +1,64 @@ +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Entities; + +namespace TakeoutSaaS.Application.App.Stores; + +/// +/// 门店相关映射助手。 +/// +public static class StoreMapping +{ + /// + /// 映射营业时段 DTO。 + /// + /// 营业时段实体。 + /// DTO。 + public static StoreBusinessHourDto ToDto(StoreBusinessHour hour) => new() + { + Id = hour.Id, + TenantId = hour.TenantId, + StoreId = hour.StoreId, + DayOfWeek = hour.DayOfWeek, + HourType = hour.HourType, + StartTime = hour.StartTime, + EndTime = hour.EndTime, + CapacityLimit = hour.CapacityLimit, + Notes = hour.Notes, + CreatedAt = hour.CreatedAt + }; + + /// + /// 映射配送区域 DTO。 + /// + /// 配送区域实体。 + /// DTO。 + public static StoreDeliveryZoneDto ToDto(StoreDeliveryZone zone) => new() + { + Id = zone.Id, + TenantId = zone.TenantId, + StoreId = zone.StoreId, + ZoneName = zone.ZoneName, + PolygonGeoJson = zone.PolygonGeoJson, + MinimumOrderAmount = zone.MinimumOrderAmount, + DeliveryFee = zone.DeliveryFee, + EstimatedMinutes = zone.EstimatedMinutes, + SortOrder = zone.SortOrder, + CreatedAt = zone.CreatedAt + }; + + /// + /// 映射节假日 DTO。 + /// + /// 节假日实体。 + /// DTO。 + public static StoreHolidayDto ToDto(StoreHoliday holiday) => new() + { + Id = holiday.Id, + TenantId = holiday.TenantId, + StoreId = holiday.StoreId, + Date = holiday.Date, + IsClosed = holiday.IsClosed, + Reason = holiday.Reason, + CreatedAt = holiday.CreatedAt + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreBusinessHourCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreBusinessHourCommandValidator.cs new file mode 100644 index 0000000..b0f1b80 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreBusinessHourCommandValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 创建营业时段命令验证器。 +/// +public sealed class CreateStoreBusinessHourCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public CreateStoreBusinessHourCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.StartTime).LessThan(x => x.EndTime).WithMessage("结束时间必须晚于开始时间"); + RuleFor(x => x.CapacityLimit).GreaterThanOrEqualTo(0).When(x => x.CapacityLimit.HasValue); + RuleFor(x => x.Notes).MaximumLength(256); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreDeliveryZoneCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreDeliveryZoneCommandValidator.cs new file mode 100644 index 0000000..b8878e3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreDeliveryZoneCommandValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 创建配送区域命令验证器。 +/// +public sealed class CreateStoreDeliveryZoneCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public CreateStoreDeliveryZoneCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.ZoneName).NotEmpty().MaximumLength(64); + RuleFor(x => x.PolygonGeoJson).NotEmpty(); + RuleFor(x => x.MinimumOrderAmount).GreaterThanOrEqualTo(0).When(x => x.MinimumOrderAmount.HasValue); + RuleFor(x => x.DeliveryFee).GreaterThanOrEqualTo(0).When(x => x.DeliveryFee.HasValue); + RuleFor(x => x.EstimatedMinutes).GreaterThan(0).When(x => x.EstimatedMinutes.HasValue); + RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreHolidayCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreHolidayCommandValidator.cs new file mode 100644 index 0000000..b6c140f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreHolidayCommandValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 创建节假日命令验证器。 +/// +public sealed class CreateStoreHolidayCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public CreateStoreHolidayCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.Date).NotEmpty(); + RuleFor(x => x.Reason).MaximumLength(256); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreBusinessHourCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreBusinessHourCommandValidator.cs new file mode 100644 index 0000000..e7e76d5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreBusinessHourCommandValidator.cs @@ -0,0 +1,22 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 更新营业时段命令验证器。 +/// +public sealed class UpdateStoreBusinessHourCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public UpdateStoreBusinessHourCommandValidator() + { + RuleFor(x => x.BusinessHourId).GreaterThan(0); + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.StartTime).LessThan(x => x.EndTime).WithMessage("结束时间必须晚于开始时间"); + RuleFor(x => x.CapacityLimit).GreaterThanOrEqualTo(0).When(x => x.CapacityLimit.HasValue); + RuleFor(x => x.Notes).MaximumLength(256); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreDeliveryZoneCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreDeliveryZoneCommandValidator.cs new file mode 100644 index 0000000..5de6927 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreDeliveryZoneCommandValidator.cs @@ -0,0 +1,25 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 更新配送区域命令验证器。 +/// +public sealed class UpdateStoreDeliveryZoneCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public UpdateStoreDeliveryZoneCommandValidator() + { + RuleFor(x => x.DeliveryZoneId).GreaterThan(0); + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.ZoneName).NotEmpty().MaximumLength(64); + RuleFor(x => x.PolygonGeoJson).NotEmpty(); + RuleFor(x => x.MinimumOrderAmount).GreaterThanOrEqualTo(0).When(x => x.MinimumOrderAmount.HasValue); + RuleFor(x => x.DeliveryFee).GreaterThanOrEqualTo(0).When(x => x.DeliveryFee.HasValue); + RuleFor(x => x.EstimatedMinutes).GreaterThan(0).When(x => x.EstimatedMinutes.HasValue); + RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreHolidayCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreHolidayCommandValidator.cs new file mode 100644 index 0000000..4567bef --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreHolidayCommandValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 更新节假日命令验证器。 +/// +public sealed class UpdateStoreHolidayCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public UpdateStoreHolidayCommandValidator() + { + RuleFor(x => x.HolidayId).GreaterThan(0); + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.Date).NotEmpty(); + RuleFor(x => x.Reason).MaximumLength(256); + } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs index bbbf7e0..252140b 100644 --- a/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs @@ -26,16 +26,31 @@ public interface IStoreRepository /// Task> GetBusinessHoursAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 依据标识获取营业时段。 + /// + Task FindBusinessHourByIdAsync(long businessHourId, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取门店配送区域配置。 /// Task> GetDeliveryZonesAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 依据标识获取配送区域。 + /// + Task FindDeliveryZoneByIdAsync(long deliveryZoneId, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取门店节假日配置。 /// Task> GetHolidaysAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 依据标识获取节假日配置。 + /// + Task FindHolidayByIdAsync(long holidayId, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取门店桌台区域。 /// @@ -61,16 +76,31 @@ public interface IStoreRepository /// Task AddBusinessHoursAsync(IEnumerable hours, CancellationToken cancellationToken = default); + /// + /// 更新营业时段。 + /// + Task UpdateBusinessHourAsync(StoreBusinessHour hour, CancellationToken cancellationToken = default); + /// /// 新增配送区域。 /// Task AddDeliveryZonesAsync(IEnumerable zones, CancellationToken cancellationToken = default); + /// + /// 更新配送区域。 + /// + Task UpdateDeliveryZoneAsync(StoreDeliveryZone zone, CancellationToken cancellationToken = default); + /// /// 新增节假日配置。 /// Task AddHolidaysAsync(IEnumerable holidays, CancellationToken cancellationToken = default); + /// + /// 更新节假日配置。 + /// + Task UpdateHolidayAsync(StoreHoliday holiday, CancellationToken cancellationToken = default); + /// /// 新增桌台区域。 /// @@ -91,6 +121,21 @@ public interface IStoreRepository /// Task SaveChangesAsync(CancellationToken cancellationToken = default); + /// + /// 删除营业时段。 + /// + Task DeleteBusinessHourAsync(long businessHourId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 删除配送区域。 + /// + Task DeleteDeliveryZoneAsync(long deliveryZoneId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 删除节假日。 + /// + Task DeleteHolidayAsync(long holidayId, long tenantId, CancellationToken cancellationToken = default); + /// /// 更新门店。 /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs index 3a0934a..b944cf3 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs @@ -58,6 +58,14 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos return hours; } + /// + public Task FindBusinessHourByIdAsync(long businessHourId, long tenantId, CancellationToken cancellationToken = default) + { + return context.StoreBusinessHours + .Where(x => x.TenantId == tenantId && x.Id == businessHourId) + .FirstOrDefaultAsync(cancellationToken); + } + /// public async Task> GetDeliveryZonesAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) { @@ -70,6 +78,14 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos return zones; } + /// + public Task FindDeliveryZoneByIdAsync(long deliveryZoneId, long tenantId, CancellationToken cancellationToken = default) + { + return context.StoreDeliveryZones + .Where(x => x.TenantId == tenantId && x.Id == deliveryZoneId) + .FirstOrDefaultAsync(cancellationToken); + } + /// public async Task> GetHolidaysAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) { @@ -82,6 +98,14 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos return holidays; } + /// + public Task FindHolidayByIdAsync(long holidayId, long tenantId, CancellationToken cancellationToken = default) + { + return context.StoreHolidays + .Where(x => x.TenantId == tenantId && x.Id == holidayId) + .FirstOrDefaultAsync(cancellationToken); + } + /// public async Task> GetTableAreasAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) { @@ -131,18 +155,39 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos return context.StoreBusinessHours.AddRangeAsync(hours, cancellationToken); } + /// + public Task UpdateBusinessHourAsync(StoreBusinessHour hour, CancellationToken cancellationToken = default) + { + context.StoreBusinessHours.Update(hour); + return Task.CompletedTask; + } + /// public Task AddDeliveryZonesAsync(IEnumerable zones, CancellationToken cancellationToken = default) { return context.StoreDeliveryZones.AddRangeAsync(zones, cancellationToken); } + /// + public Task UpdateDeliveryZoneAsync(StoreDeliveryZone zone, CancellationToken cancellationToken = default) + { + context.StoreDeliveryZones.Update(zone); + return Task.CompletedTask; + } + /// public Task AddHolidaysAsync(IEnumerable holidays, CancellationToken cancellationToken = default) { return context.StoreHolidays.AddRangeAsync(holidays, cancellationToken); } + /// + public Task UpdateHolidayAsync(StoreHoliday holiday, CancellationToken cancellationToken = default) + { + context.StoreHolidays.Update(holiday); + return Task.CompletedTask; + } + /// public Task AddTableAreasAsync(IEnumerable areas, CancellationToken cancellationToken = default) { @@ -167,6 +212,45 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos return context.SaveChangesAsync(cancellationToken); } + /// + public async Task DeleteBusinessHourAsync(long businessHourId, long tenantId, CancellationToken cancellationToken = default) + { + var existing = await context.StoreBusinessHours + .Where(x => x.TenantId == tenantId && x.Id == businessHourId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing != null) + { + context.StoreBusinessHours.Remove(existing); + } + } + + /// + public async Task DeleteDeliveryZoneAsync(long deliveryZoneId, long tenantId, CancellationToken cancellationToken = default) + { + var existing = await context.StoreDeliveryZones + .Where(x => x.TenantId == tenantId && x.Id == deliveryZoneId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing != null) + { + context.StoreDeliveryZones.Remove(existing); + } + } + + /// + public async Task DeleteHolidayAsync(long holidayId, long tenantId, CancellationToken cancellationToken = default) + { + var existing = await context.StoreHolidays + .Where(x => x.TenantId == tenantId && x.Id == holidayId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing != null) + { + context.StoreHolidays.Remove(existing); + } + } + /// public Task UpdateStoreAsync(Store store, CancellationToken cancellationToken = default) { From 1a5209a8b1df2d10d4dc2ea8bf1ccfe19b61db39 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Thu, 4 Dec 2025 09:10:00 +0800 Subject: [PATCH 16/30] =?UTF-8?q?feat:=20=E6=A1=8C=E7=A0=81=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=94=AF=E6=8C=81=E5=8C=BA=E5=9F=9F=E3=80=81=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E7=94=9F=E6=88=90=E4=B8=8E=E4=BA=8C=E7=BB=B4=E7=A0=81?= =?UTF-8?q?=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Document/12_BusinessTodo.md | 4 +- .../Controllers/StoreTableAreasController.cs | 87 +++++++++++++ .../Controllers/StoreTablesController.cs | 121 ++++++++++++++++++ .../appsettings.Seed.Development.json | 28 ++++ .../Commands/CreateStoreTableAreaCommand.cs | 30 +++++ .../Commands/DeleteStoreTableAreaCommand.cs | 19 +++ .../Commands/DeleteStoreTableCommand.cs | 19 +++ .../Commands/GenerateStoreTablesCommand.cs | 45 +++++++ .../Commands/UpdateStoreTableAreaCommand.cs | 35 +++++ .../Commands/UpdateStoreTableCommand.cs | 46 +++++++ .../App/Stores/Dto/StoreTableAreaDto.cs | 48 +++++++ .../App/Stores/Dto/StoreTableDto.cs | 65 ++++++++++ .../App/Stores/Dto/StoreTableExportResult.cs | 22 ++++ .../CreateStoreTableAreaCommandHandler.cs | 58 +++++++++ .../DeleteStoreTableAreaCommandHandler.cs | 52 ++++++++ .../DeleteStoreTableCommandHandler.cs | 36 ++++++ .../ExportStoreTableQRCodesQueryHandler.cs | 86 +++++++++++++ .../GenerateStoreTablesCommandHandler.cs | 73 +++++++++++ .../ListStoreTableAreasQueryHandler.cs | 26 ++++ .../Handlers/ListStoreTablesQueryHandler.cs | 39 ++++++ .../UpdateStoreTableAreaCommandHandler.cs | 59 +++++++++ .../UpdateStoreTableCommandHandler.cs | 72 +++++++++++ .../Queries/ExportStoreTableQRCodesQuery.cs | 25 ++++ .../Queries/ListStoreTableAreasQuery.cs | 15 +++ .../Stores/Queries/ListStoreTablesQuery.cs | 26 ++++ .../App/Stores/StoreMapping.cs | 35 +++++ .../CreateStoreTableAreaCommandValidator.cs | 21 +++ .../GenerateStoreTablesCommandValidator.cs | 23 ++++ .../UpdateStoreTableAreaCommandValidator.cs | 22 ++++ .../UpdateStoreTableCommandValidator.cs | 22 ++++ .../TakeoutSaaS.Application.csproj | Bin 2662 -> 2782 bytes .../Stores/Repositories/IStoreRepository.cs | 30 +++++ .../App/Repositories/EfStoreRepository.cs | 56 ++++++++ 33 files changed, 1343 insertions(+), 2 deletions(-) create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/StoreTableAreasController.cs create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/StoreTablesController.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreTableAreaCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreTableAreaCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreTableCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Commands/GenerateStoreTablesCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreTableAreaCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreTableCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreTableAreaDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreTableDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreTableExportResult.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreTableAreaCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreTableAreaCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreTableCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ExportStoreTableQRCodesQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GenerateStoreTablesCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreTableAreasQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreTablesQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreTableAreaCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreTableCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Queries/ExportStoreTableQRCodesQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreTableAreasQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreTablesQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreTableAreaCommandValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Validators/GenerateStoreTablesCommandValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreTableAreaCommandValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreTableCommandValidator.cs diff --git a/Document/12_BusinessTodo.md b/Document/12_BusinessTodo.md index 0d2cad0..44e9773 100644 --- a/Document/12_BusinessTodo.md +++ b/Document/12_BusinessTodo.md @@ -16,8 +16,8 @@ - 已交付:新增账单/公告/通知实体与仓储,Admin 端提供 `/tenants/{id}/billings`(列表/详情/创建/标记支付)、`/announcements`(列表/详情/创建/更新/删除/已读)、`/notifications`(列表/已读)端点;权限码补充 `tenant-bill:*`、`tenant-announcement:*`、`tenant-notification:*`,种子模板更新;配额/订阅告警可通过通知表承载。 - [x] 门店管理:Store/StoreBusinessHour/StoreDeliveryZone/StoreHoliday CRUD 完整,含 GeoJSON 配送范围及能力开关。 - 进展:已补充营业时间/配送区/节假日的命令、查询、验证与处理器,Admin API 新增子路由完成 CRUD,门店能力开关(预约/排队)已对外暴露;仓储扩展读写删除并保持租户过滤。 -- [ ] 桌码管理:批量生成桌码、绑定区域/容量、导出二维码 ZIP(POST /api/admin/stores/{id}/tables 可下载)。 - - 当前:模型/仓储已包含 `StoreTable`/`StoreTableArea`,但缺少命令/查询/控制器,未实现批量生成、区域容量绑定和二维码导出。 +- [x] 桌码管理:批量生成桌码、绑定区域/容量、导出二维码 ZIP(POST /api/admin/stores/{id}/tables 可下载)。 + - 进展:新增桌台区域/桌码 DTO、命令、查询、验证与处理器,支持批量生成桌码、区域绑定和更新;Admin API 增加桌台区域与桌码 CRUD 及二维码 ZIP 导出端点,使用 QRCoder 生成 SVG 并打包下载;仓储补齐桌台/区域的查找、更新、删除。 - [ ] 员工排班:创建员工、绑定门店角色、维护 StoreEmployeeShift,可查询未来 7 日排班。 - 当前:存在 `StoreEmployeeShift` 表模型,未提供应用层命令/查询和 Admin API,排班创建/查询能力缺失。 - [ ] 桌码扫码入口:Mini 端解析二维码,GET /api/mini/tables/{code}/context 返回门店、桌台、公告。 diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreTableAreasController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreTableAreasController.cs new file mode 100644 index 0000000..26275da --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreTableAreasController.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.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}/stores/{storeId:long}/table-areas")] +public sealed class StoreTableAreasController(IMediator mediator) : BaseApiController +{ + /// + /// 查询区域列表。 + /// + [HttpGet] + [PermissionAuthorize("store-table-area:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> List(long storeId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new ListStoreTableAreasQuery { StoreId = storeId }, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 创建区域。 + /// + [HttpPost] + [PermissionAuthorize("store-table-area:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create(long storeId, [FromBody] CreateStoreTableAreaCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 更新区域。 + /// + [HttpPut("{areaId:long}")] + [PermissionAuthorize("store-table-area:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Update(long storeId, long areaId, [FromBody] UpdateStoreTableAreaCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0 || command.AreaId == 0) + { + command = command with { StoreId = storeId, AreaId = areaId }; + } + + var result = await mediator.Send(command, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "桌台区域不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除区域。 + /// + [HttpDelete("{areaId:long}")] + [PermissionAuthorize("store-table-area:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Delete(long storeId, long areaId, CancellationToken cancellationToken) + { + var success = await mediator.Send(new DeleteStoreTableAreaCommand { StoreId = storeId, AreaId = areaId }, cancellationToken); + return success + ? ApiResponse.Ok(null) + : ApiResponse.Error(ErrorCodes.NotFound, "桌台区域不存在或不可删除"); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreTablesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreTablesController.cs new file mode 100644 index 0000000..01af828 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreTablesController.cs @@ -0,0 +1,121 @@ +using System.Collections.Generic; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Enums; +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}/stores/{storeId:long}/tables")] +public sealed class StoreTablesController(IMediator mediator) : BaseApiController +{ + /// + /// 查询桌码列表。 + /// + [HttpGet] + [PermissionAuthorize("store-table:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> List( + long storeId, + [FromQuery] long? areaId, + [FromQuery] StoreTableStatus? status, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new ListStoreTablesQuery + { + StoreId = storeId, + AreaId = areaId, + Status = status + }, cancellationToken); + + return ApiResponse>.Ok(result); + } + + /// + /// 批量生成桌码。 + /// + [HttpPost] + [PermissionAuthorize("store-table:create")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Generate(long storeId, [FromBody] GenerateStoreTablesCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 更新桌码。 + /// + [HttpPut("{tableId:long}")] + [PermissionAuthorize("store-table:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Update(long storeId, long tableId, [FromBody] UpdateStoreTableCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0 || command.TableId == 0) + { + command = command with { StoreId = storeId, TableId = tableId }; + } + + var result = await mediator.Send(command, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "桌码不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除桌码。 + /// + [HttpDelete("{tableId:long}")] + [PermissionAuthorize("store-table:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Delete(long storeId, long tableId, CancellationToken cancellationToken) + { + var success = await mediator.Send(new DeleteStoreTableCommand { StoreId = storeId, TableId = tableId }, cancellationToken); + return success + ? ApiResponse.Ok(null) + : ApiResponse.Error(ErrorCodes.NotFound, "桌码不存在"); + } + + /// + /// 导出桌码二维码 ZIP。 + /// + [HttpPost("export")] + [PermissionAuthorize("store-table:export")] + [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task Export(long storeId, [FromBody] ExportStoreTableQRCodesQuery query, CancellationToken cancellationToken) + { + if (query.StoreId == 0) + { + query = query with { StoreId = storeId }; + } + + var result = await mediator.Send(query, cancellationToken); + if (result is null) + { + return Ok(ApiResponse.Error(ErrorCodes.NotFound, "未找到可导出的桌码")); + } + + return File(result.Content, result.ContentType, result.FileName); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json index 6ebda51..0b9e0da 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json @@ -91,6 +91,15 @@ "store:read", "store:update", "store:delete", + "store-table-area:read", + "store-table-area:create", + "store-table-area:update", + "store-table-area:delete", + "store-table:read", + "store-table:create", + "store-table:update", + "store-table:delete", + "store-table:export", "product:create", "product:read", "product:update", @@ -158,6 +167,15 @@ "store:read", "store:update", "store:delete", + "store-table-area:read", + "store-table-area:create", + "store-table-area:update", + "store-table-area:delete", + "store-table:read", + "store-table:create", + "store-table:update", + "store-table:delete", + "store-table:export", "product:create", "product:read", "product:update", @@ -190,6 +208,14 @@ "identity:profile:read", "store:read", "store:update", + "store-table-area:read", + "store-table-area:create", + "store-table-area:update", + "store-table-area:delete", + "store-table:read", + "store-table:create", + "store-table:update", + "store-table:export", "product:create", "product:read", "product:update", @@ -214,6 +240,8 @@ "Permissions": [ "identity:profile:read", "store:read", + "store-table-area:read", + "store-table:read", "product:read", "order:read", "order:update", diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreTableAreaCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreTableAreaCommand.cs new file mode 100644 index 0000000..60511b4 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreTableAreaCommand.cs @@ -0,0 +1,30 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 创建桌台区域命令。 +/// +public sealed record CreateStoreTableAreaCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 区域名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 区域描述。 + /// + public string? Description { get; init; } + + /// + /// 排序值。 + /// + public int SortOrder { get; init; } = 100; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreTableAreaCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreTableAreaCommand.cs new file mode 100644 index 0000000..9f74c48 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreTableAreaCommand.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 删除桌台区域命令。 +/// +public sealed record DeleteStoreTableAreaCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 区域 ID。 + /// + public long AreaId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreTableCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreTableCommand.cs new file mode 100644 index 0000000..0480296 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreTableCommand.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 删除桌码命令。 +/// +public sealed record DeleteStoreTableCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 桌台 ID。 + /// + public long TableId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/GenerateStoreTablesCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/GenerateStoreTablesCommand.cs new file mode 100644 index 0000000..4c10d56 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/GenerateStoreTablesCommand.cs @@ -0,0 +1,45 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 批量生成桌码命令。 +/// +public sealed record GenerateStoreTablesCommand : IRequest> +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 桌码前缀。 + /// + public string TableCodePrefix { get; init; } = "T"; + + /// + /// 起始序号。 + /// + public int StartNumber { get; init; } = 1; + + /// + /// 生成数量。 + /// + public int Count { get; init; } + + /// + /// 默认容量。 + /// + public int DefaultCapacity { get; init; } = 2; + + /// + /// 区域 ID。 + /// + public long? AreaId { get; init; } + + /// + /// 标签。 + /// + public string? Tags { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreTableAreaCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreTableAreaCommand.cs new file mode 100644 index 0000000..b113491 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreTableAreaCommand.cs @@ -0,0 +1,35 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 更新桌台区域命令。 +/// +public sealed record UpdateStoreTableAreaCommand : IRequest +{ + /// + /// 区域 ID。 + /// + public long AreaId { get; init; } + + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 区域名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 区域描述。 + /// + public string? Description { get; init; } + + /// + /// 排序值。 + /// + public int SortOrder { get; init; } = 100; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreTableCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreTableCommand.cs new file mode 100644 index 0000000..d93e92c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreTableCommand.cs @@ -0,0 +1,46 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 更新桌码命令。 +/// +public sealed record UpdateStoreTableCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 桌台 ID。 + /// + public long TableId { get; init; } + + /// + /// 区域 ID。 + /// + public long? AreaId { get; init; } + + /// + /// 桌码。 + /// + public string TableCode { get; init; } = string.Empty; + + /// + /// 容量。 + /// + public int Capacity { get; init; } + + /// + /// 标签。 + /// + public string? Tags { get; init; } + + /// + /// 状态。 + /// + public StoreTableStatus Status { get; init; } = StoreTableStatus.Idle; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreTableAreaDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreTableAreaDto.cs new file mode 100644 index 0000000..8c5e533 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreTableAreaDto.cs @@ -0,0 +1,48 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 桌台区域 DTO。 +/// +public sealed record StoreTableAreaDto +{ + /// + /// 区域 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 区域名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 区域描述。 + /// + public string? Description { get; init; } + + /// + /// 排序值。 + /// + public int SortOrder { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreTableDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreTableDto.cs new file mode 100644 index 0000000..b66b60c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreTableDto.cs @@ -0,0 +1,65 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 桌台 DTO。 +/// +public sealed record StoreTableDto +{ + /// + /// 桌台 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 区域 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long? AreaId { get; init; } + + /// + /// 桌码。 + /// + public string TableCode { get; init; } = string.Empty; + + /// + /// 容量。 + /// + public int Capacity { get; init; } + + /// + /// 标签。 + /// + public string? Tags { get; init; } + + /// + /// 状态。 + /// + public StoreTableStatus Status { get; init; } + + /// + /// 二维码地址。 + /// + public string? QrCodeUrl { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreTableExportResult.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreTableExportResult.cs new file mode 100644 index 0000000..fea6b4e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreTableExportResult.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 桌台二维码导出结果。 +/// +public sealed record StoreTableExportResult +{ + /// + /// 文件名。 + /// + public string FileName { get; init; } = string.Empty; + + /// + /// 内容类型。 + /// + public string ContentType { get; init; } = string.Empty; + + /// + /// 文件内容。 + /// + public byte[] Content { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreTableAreaCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreTableAreaCommandHandler.cs new file mode 100644 index 0000000..4999f4f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreTableAreaCommandHandler.cs @@ -0,0 +1,58 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 创建桌台区域处理器。 +/// +public sealed class CreateStoreTableAreaCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(CreateStoreTableAreaCommand request, CancellationToken cancellationToken) + { + // 1. 校验门店存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 2. 校验区域名称唯一 + var existingAreas = await storeRepository.GetTableAreasAsync(request.StoreId, tenantId, cancellationToken); + var hasDuplicate = existingAreas.Any(x => x.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase)); + if (hasDuplicate) + { + throw new BusinessException(ErrorCodes.Conflict, "区域名称已存在"); + } + + // 3. 构建实体 + var area = new StoreTableArea + { + StoreId = request.StoreId, + Name = request.Name.Trim(), + Description = request.Description?.Trim(), + SortOrder = request.SortOrder + }; + + // 4. 持久化 + await storeRepository.AddTableAreasAsync(new[] { area }, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("创建桌台区域 {AreaId} 对应门店 {StoreId}", area.Id, request.StoreId); + + // 5. 返回 DTO + return StoreMapping.ToDto(area); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreTableAreaCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreTableAreaCommandHandler.cs new file mode 100644 index 0000000..48dad11 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreTableAreaCommandHandler.cs @@ -0,0 +1,52 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 删除桌台区域处理器。 +/// +public sealed class DeleteStoreTableAreaCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(DeleteStoreTableAreaCommand request, CancellationToken cancellationToken) + { + // 1. 读取区域 + var tenantId = tenantProvider.GetCurrentTenantId(); + var area = await storeRepository.FindTableAreaByIdAsync(request.AreaId, tenantId, cancellationToken); + if (area is null) + { + return false; + } + + // 2. 校验门店归属 + if (area.StoreId != request.StoreId) + { + return false; + } + + // 3. 校验区域下无桌码 + var tables = await storeRepository.GetTablesAsync(request.StoreId, tenantId, cancellationToken); + var hasTable = tables.Any(x => x.AreaId == request.AreaId); + if (hasTable) + { + throw new BusinessException(ErrorCodes.Conflict, "区域下仍有桌码,无法删除"); + } + + // 4. 删除 + await storeRepository.DeleteTableAreaAsync(request.AreaId, tenantId, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("删除桌台区域 {AreaId} 对应门店 {StoreId}", request.AreaId, request.StoreId); + + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreTableCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreTableCommandHandler.cs new file mode 100644 index 0000000..9828253 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreTableCommandHandler.cs @@ -0,0 +1,36 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 删除桌码处理器。 +/// +public sealed class DeleteStoreTableCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(DeleteStoreTableCommand request, CancellationToken cancellationToken) + { + // 1. 读取桌码 + var tenantId = tenantProvider.GetCurrentTenantId(); + var table = await storeRepository.FindTableByIdAsync(request.TableId, tenantId, cancellationToken); + if (table is null || table.StoreId != request.StoreId) + { + return false; + } + + // 2. 删除 + await storeRepository.DeleteTableAsync(request.TableId, tenantId, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("删除桌码 {TableId} 对应门店 {StoreId}", request.TableId, request.StoreId); + + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ExportStoreTableQRCodesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ExportStoreTableQRCodesQueryHandler.cs new file mode 100644 index 0000000..95ead70 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ExportStoreTableQRCodesQueryHandler.cs @@ -0,0 +1,86 @@ +using System.IO.Compression; +using System.Linq; +using System.Text; +using MediatR; +using Microsoft.Extensions.Logging; +using QRCoder; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 导出桌码二维码处理器。 +/// +public sealed class ExportStoreTableQRCodesQueryHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(ExportStoreTableQRCodesQuery request, CancellationToken cancellationToken) + { + // 1. 校验门店存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + return null; + } + + // 2. 获取桌码列表 + var tables = await storeRepository.GetTablesAsync(request.StoreId, tenantId, cancellationToken); + if (request.AreaId.HasValue) + { + tables = tables.Where(x => x.AreaId == request.AreaId.Value).ToList(); + } + + if (tables.Count == 0) + { + return null; + } + + // 3. 生成 ZIP + var template = string.IsNullOrWhiteSpace(request.QrContentTemplate) ? "{code}" : request.QrContentTemplate!; + using var memoryStream = new MemoryStream(); + using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true, Encoding.UTF8)) + { + foreach (var table in tables) + { + var content = BuildPayload(template, table.TableCode); + var svg = RenderSvg(content); + var entry = archive.CreateEntry($"{table.TableCode}.svg", CompressionLevel.Fastest); + using var entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + writer.Write(svg); + } + } + + // 4. 返回导出结果 + var fileName = $"store_{request.StoreId}_tables_{DateTime.UtcNow:yyyyMMddHHmmss}.zip"; + logger.LogInformation("导出门店 {StoreId} 桌码二维码 {Count} 个", request.StoreId, tables.Count); + return new StoreTableExportResult + { + FileName = fileName, + ContentType = "application/zip", + Content = memoryStream.ToArray() + }; + } + + private static string BuildPayload(string template, string tableCode) + { + var payload = template.Replace("{code}", tableCode, StringComparison.OrdinalIgnoreCase); + return string.IsNullOrWhiteSpace(payload) ? tableCode : payload; + } + + private static string RenderSvg(string payload) + { + using var generator = new QRCodeGenerator(); + var data = generator.CreateQrCode(payload, QRCodeGenerator.ECCLevel.Q); + var svg = new SvgQRCode(data); + return svg.GetGraphic(5); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GenerateStoreTablesCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GenerateStoreTablesCommandHandler.cs new file mode 100644 index 0000000..ad2cf70 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GenerateStoreTablesCommandHandler.cs @@ -0,0 +1,73 @@ +using System.Linq; +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 批量生成桌码处理器。 +/// +public sealed class GenerateStoreTablesCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler> +{ + /// + public async Task> Handle(GenerateStoreTablesCommand request, CancellationToken cancellationToken) + { + // 1. 校验门店存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 2. 校验区域归属 + if (request.AreaId.HasValue) + { + var area = await storeRepository.FindTableAreaByIdAsync(request.AreaId.Value, tenantId, cancellationToken); + if (area is null || area.StoreId != request.StoreId) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "桌台区域不存在或不属于该门店"); + } + } + + // 3. 校验桌码唯一性 + var existingTables = await storeRepository.GetTablesAsync(request.StoreId, tenantId, cancellationToken); + var newCodes = Enumerable.Range(request.StartNumber, request.Count) + .Select(i => $"{request.TableCodePrefix.Trim()}{i}") + .ToList(); + var conflicts = existingTables.Where(t => newCodes.Contains(t.TableCode, StringComparer.OrdinalIgnoreCase)).ToList(); + if (conflicts.Count > 0) + { + throw new BusinessException(ErrorCodes.Conflict, "桌码已存在,生成失败"); + } + + // 4. 构建实体 + var tables = newCodes.Select(code => new StoreTable + { + StoreId = request.StoreId, + AreaId = request.AreaId, + TableCode = code, + Capacity = request.DefaultCapacity, + Tags = request.Tags?.Trim() + }).ToList(); + + // 5. 持久化 + await storeRepository.AddTablesAsync(tables, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("批量创建桌码 {Count} 条 对应门店 {StoreId}", tables.Count, request.StoreId); + + // 6. 返回 DTO + return tables.Select(StoreMapping.ToDto).ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreTableAreasQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreTableAreasQueryHandler.cs new file mode 100644 index 0000000..5de8fa5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreTableAreasQueryHandler.cs @@ -0,0 +1,26 @@ +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 桌台区域列表查询处理器。 +/// +public sealed class ListStoreTableAreasQueryHandler(IStoreRepository storeRepository, ITenantProvider tenantProvider) + : IRequestHandler> +{ + /// + public async Task> Handle(ListStoreTableAreasQuery request, CancellationToken cancellationToken) + { + // 1. 查询区域列表 + var tenantId = tenantProvider.GetCurrentTenantId(); + var areas = await storeRepository.GetTableAreasAsync(request.StoreId, tenantId, cancellationToken); + + // 2. 映射 DTO + return areas.Select(StoreMapping.ToDto).ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreTablesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreTablesQueryHandler.cs new file mode 100644 index 0000000..786b67f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreTablesQueryHandler.cs @@ -0,0 +1,39 @@ +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 桌码列表查询处理器。 +/// +public sealed class ListStoreTablesQueryHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + /// + public async Task> Handle(ListStoreTablesQuery request, CancellationToken cancellationToken) + { + // 1. 查询桌码列表 + var tenantId = tenantProvider.GetCurrentTenantId(); + var tables = await storeRepository.GetTablesAsync(request.StoreId, tenantId, cancellationToken); + + // 2. 过滤 + if (request.AreaId.HasValue) + { + tables = tables.Where(x => x.AreaId == request.AreaId.Value).ToList(); + } + + if (request.Status.HasValue) + { + tables = tables.Where(x => x.Status == request.Status.Value).ToList(); + } + + // 3. 映射 DTO + return tables.Select(StoreMapping.ToDto).ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreTableAreaCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreTableAreaCommandHandler.cs new file mode 100644 index 0000000..e488d6b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreTableAreaCommandHandler.cs @@ -0,0 +1,59 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 更新桌台区域处理器。 +/// +public sealed class UpdateStoreTableAreaCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(UpdateStoreTableAreaCommand request, CancellationToken cancellationToken) + { + // 1. 读取区域 + var tenantId = tenantProvider.GetCurrentTenantId(); + var area = await storeRepository.FindTableAreaByIdAsync(request.AreaId, tenantId, cancellationToken); + if (area is null) + { + return null; + } + + // 2. 校验门店归属 + if (area.StoreId != request.StoreId) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "区域不属于该门店"); + } + + // 3. 名称唯一校验 + var areas = await storeRepository.GetTableAreasAsync(request.StoreId, tenantId, cancellationToken); + var hasDuplicate = areas.Any(x => x.Id != request.AreaId && x.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase)); + if (hasDuplicate) + { + throw new BusinessException(ErrorCodes.Conflict, "区域名称已存在"); + } + + // 4. 更新字段 + area.Name = request.Name.Trim(); + area.Description = request.Description?.Trim(); + area.SortOrder = request.SortOrder; + + // 5. 持久化 + await storeRepository.UpdateTableAreaAsync(area, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("更新桌台区域 {AreaId} 对应门店 {StoreId}", area.Id, area.StoreId); + + // 6. 返回 DTO + return StoreMapping.ToDto(area); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreTableCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreTableCommandHandler.cs new file mode 100644 index 0000000..b6f8474 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreTableCommandHandler.cs @@ -0,0 +1,72 @@ +using System.Linq; +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 更新桌码处理器。 +/// +public sealed class UpdateStoreTableCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(UpdateStoreTableCommand request, CancellationToken cancellationToken) + { + // 1. 读取桌码 + var tenantId = tenantProvider.GetCurrentTenantId(); + var table = await storeRepository.FindTableByIdAsync(request.TableId, tenantId, cancellationToken); + if (table is null) + { + return null; + } + + // 2. 校验门店归属 + if (table.StoreId != request.StoreId) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "桌码不属于该门店"); + } + + // 3. 校验区域归属 + if (request.AreaId.HasValue) + { + var area = await storeRepository.FindTableAreaByIdAsync(request.AreaId.Value, tenantId, cancellationToken); + if (area is null || area.StoreId != request.StoreId) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "桌台区域不存在或不属于该门店"); + } + } + + // 4. 校验桌码唯一 + var tables = await storeRepository.GetTablesAsync(request.StoreId, tenantId, cancellationToken); + var exists = tables.Any(x => x.Id != request.TableId && x.TableCode.Equals(request.TableCode, StringComparison.OrdinalIgnoreCase)); + if (exists) + { + throw new BusinessException(ErrorCodes.Conflict, "桌码已存在"); + } + + // 5. 更新字段 + table.AreaId = request.AreaId; + table.TableCode = request.TableCode.Trim(); + table.Capacity = request.Capacity; + table.Tags = request.Tags?.Trim(); + table.Status = request.Status; + + // 6. 持久化 + await storeRepository.UpdateTableAsync(table, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("更新桌码 {TableId} 对应门店 {StoreId}", table.Id, table.StoreId); + + // 7. 返回 DTO + return StoreMapping.ToDto(table); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ExportStoreTableQRCodesQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ExportStoreTableQRCodesQuery.cs new file mode 100644 index 0000000..230fda0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ExportStoreTableQRCodesQuery.cs @@ -0,0 +1,25 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 导出桌码二维码查询。 +/// +public sealed record ExportStoreTableQRCodesQuery : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 区域筛选。 + /// + public long? AreaId { get; init; } + + /// + /// 内容模板,使用 {code} 占位。 + /// + public string? QrContentTemplate { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreTableAreasQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreTableAreasQuery.cs new file mode 100644 index 0000000..6af7efc --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreTableAreasQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 门店桌台区域列表查询。 +/// +public sealed record ListStoreTableAreasQuery : IRequest> +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreTablesQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreTablesQuery.cs new file mode 100644 index 0000000..707e915 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreTablesQuery.cs @@ -0,0 +1,26 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 门店桌码列表查询。 +/// +public sealed record ListStoreTablesQuery : IRequest> +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 区域筛选。 + /// + public long? AreaId { get; init; } + + /// + /// 状态筛选。 + /// + public StoreTableStatus? Status { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs b/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs index 06c9094..65f1be5 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs @@ -61,4 +61,39 @@ public static class StoreMapping Reason = holiday.Reason, CreatedAt = holiday.CreatedAt }; + + /// + /// 映射桌台区域 DTO。 + /// + /// 区域实体。 + /// DTO。 + public static StoreTableAreaDto ToDto(StoreTableArea area) => new() + { + Id = area.Id, + TenantId = area.TenantId, + StoreId = area.StoreId, + Name = area.Name, + Description = area.Description, + SortOrder = area.SortOrder, + CreatedAt = area.CreatedAt + }; + + /// + /// 映射桌台 DTO。 + /// + /// 桌台实体。 + /// DTO。 + public static StoreTableDto ToDto(StoreTable table) => new() + { + Id = table.Id, + TenantId = table.TenantId, + StoreId = table.StoreId, + AreaId = table.AreaId, + TableCode = table.TableCode, + Capacity = table.Capacity, + Tags = table.Tags, + Status = table.Status, + QrCodeUrl = table.QrCodeUrl, + CreatedAt = table.CreatedAt + }; } diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreTableAreaCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreTableAreaCommandValidator.cs new file mode 100644 index 0000000..9f6649b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreTableAreaCommandValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 创建桌台区域命令验证器。 +/// +public sealed class CreateStoreTableAreaCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public CreateStoreTableAreaCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.Name).NotEmpty().MaximumLength(64); + RuleFor(x => x.Description).MaximumLength(256); + RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/GenerateStoreTablesCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/GenerateStoreTablesCommandValidator.cs new file mode 100644 index 0000000..534caca --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/GenerateStoreTablesCommandValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 批量生成桌码命令验证器。 +/// +public sealed class GenerateStoreTablesCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public GenerateStoreTablesCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.TableCodePrefix).NotEmpty().MaximumLength(16); + RuleFor(x => x.StartNumber).GreaterThan(0); + RuleFor(x => x.Count).GreaterThan(0).LessThanOrEqualTo(500); + RuleFor(x => x.DefaultCapacity).GreaterThan(0).LessThanOrEqualTo(50); + RuleFor(x => x.Tags).MaximumLength(128); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreTableAreaCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreTableAreaCommandValidator.cs new file mode 100644 index 0000000..31d2b98 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreTableAreaCommandValidator.cs @@ -0,0 +1,22 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 更新桌台区域命令验证器。 +/// +public sealed class UpdateStoreTableAreaCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public UpdateStoreTableAreaCommandValidator() + { + RuleFor(x => x.AreaId).GreaterThan(0); + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.Name).NotEmpty().MaximumLength(64); + RuleFor(x => x.Description).MaximumLength(256); + RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreTableCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreTableCommandValidator.cs new file mode 100644 index 0000000..340453e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreTableCommandValidator.cs @@ -0,0 +1,22 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 更新桌码命令验证器。 +/// +public sealed class UpdateStoreTableCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public UpdateStoreTableCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.TableId).GreaterThan(0); + RuleFor(x => x.TableCode).NotEmpty().MaximumLength(32); + RuleFor(x => x.Capacity).GreaterThan(0).LessThanOrEqualTo(50); + RuleFor(x => x.Tags).MaximumLength(128); + } +} diff --git a/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj b/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj index acb8fc249037eb514993bb3fd71605d4a2d80387..9b5901be43b39566733d808024c10ef2afdce8a5 100644 GIT binary patch delta 53 zcmaDRa!+)_F4oCw*f}O2U^3zjWC&t#X2@qqVMt{tn!KMyl3kC%ltGWdaIzwo?&JrI JTASF?SOB|A4zd6M delta 24 gcmca7`b=cQF4oDbxMU_jVAPtd!%?>R9oq+H0EG1lH2?qr diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs index 252140b..7ed73f1 100644 --- a/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs @@ -56,11 +56,21 @@ public interface IStoreRepository /// Task> GetTableAreasAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 依据标识获取桌台区域。 + /// + Task FindTableAreaByIdAsync(long areaId, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取门店桌台列表。 /// Task> GetTablesAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 依据标识获取桌台。 + /// + Task FindTableByIdAsync(long tableId, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取门店员工排班。 /// @@ -106,11 +116,21 @@ public interface IStoreRepository /// Task AddTableAreasAsync(IEnumerable areas, CancellationToken cancellationToken = default); + /// + /// 更新桌台区域。 + /// + Task UpdateTableAreaAsync(StoreTableArea area, CancellationToken cancellationToken = default); + /// /// 新增桌台。 /// Task AddTablesAsync(IEnumerable tables, CancellationToken cancellationToken = default); + /// + /// 更新桌台。 + /// + Task UpdateTableAsync(StoreTable table, CancellationToken cancellationToken = default); + /// /// 新增排班。 /// @@ -136,6 +156,16 @@ public interface IStoreRepository /// Task DeleteHolidayAsync(long holidayId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 删除桌台区域。 + /// + Task DeleteTableAreaAsync(long areaId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 删除桌台。 + /// + Task DeleteTableAsync(long tableId, long tenantId, CancellationToken cancellationToken = default); + /// /// 更新门店。 /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs index b944cf3..2e146e2 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs @@ -118,6 +118,14 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos return areas; } + /// + public Task FindTableAreaByIdAsync(long areaId, long tenantId, CancellationToken cancellationToken = default) + { + return context.StoreTableAreas + .Where(x => x.TenantId == tenantId && x.Id == areaId) + .FirstOrDefaultAsync(cancellationToken); + } + /// public async Task> GetTablesAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) { @@ -130,6 +138,14 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos return tables; } + /// + public Task FindTableByIdAsync(long tableId, long tenantId, CancellationToken cancellationToken = default) + { + return context.StoreTables + .Where(x => x.TenantId == tenantId && x.Id == tableId) + .FirstOrDefaultAsync(cancellationToken); + } + /// public async Task> GetShiftsAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) { @@ -194,12 +210,26 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos return context.StoreTableAreas.AddRangeAsync(areas, cancellationToken); } + /// + public Task UpdateTableAreaAsync(StoreTableArea area, CancellationToken cancellationToken = default) + { + context.StoreTableAreas.Update(area); + return Task.CompletedTask; + } + /// public Task AddTablesAsync(IEnumerable tables, CancellationToken cancellationToken = default) { return context.StoreTables.AddRangeAsync(tables, cancellationToken); } + /// + public Task UpdateTableAsync(StoreTable table, CancellationToken cancellationToken = default) + { + context.StoreTables.Update(table); + return Task.CompletedTask; + } + /// public Task AddShiftsAsync(IEnumerable shifts, CancellationToken cancellationToken = default) { @@ -251,6 +281,32 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos } } + /// + public async Task DeleteTableAreaAsync(long areaId, long tenantId, CancellationToken cancellationToken = default) + { + var existing = await context.StoreTableAreas + .Where(x => x.TenantId == tenantId && x.Id == areaId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing != null) + { + context.StoreTableAreas.Remove(existing); + } + } + + /// + public async Task DeleteTableAsync(long tableId, long tenantId, CancellationToken cancellationToken = default) + { + var existing = await context.StoreTables + .Where(x => x.TenantId == tenantId && x.Id == tableId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing != null) + { + context.StoreTables.Remove(existing); + } + } + /// public Task UpdateStoreAsync(Store store, CancellationToken cancellationToken = default) { From 19422df0f1401092d306f80b961de218694d17f0 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Thu, 4 Dec 2025 09:32:03 +0800 Subject: [PATCH 17/30] =?UTF-8?q?feat:=20=E9=97=A8=E5=BA=97=E5=91=98?= =?UTF-8?q?=E5=B7=A5=E4=B8=8E=E6=8E=92=E7=8F=AD=E7=AE=A1=E7=90=86=E4=B8=8A?= =?UTF-8?q?=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Document/12_BusinessTodo.md | 4 +- .../Controllers/StoreShiftsController.cs | 99 +++++++++++++++++++ .../Controllers/StoreStaffsController.cs | 98 ++++++++++++++++++ .../appsettings.Seed.Development.json | 23 +++++ .../CreateStoreEmployeeShiftCommand.cs | 46 +++++++++ .../Commands/CreateStoreStaffCommand.cs | 36 +++++++ .../DeleteStoreEmployeeShiftCommand.cs | 19 ++++ .../Commands/DeleteStoreStaffCommand.cs | 19 ++++ .../UpdateStoreEmployeeShiftCommand.cs | 51 ++++++++++ .../Commands/UpdateStoreStaffCommand.cs | 46 +++++++++ .../App/Stores/Dto/StoreEmployeeShiftDto.cs | 65 ++++++++++++ .../App/Stores/Dto/StoreStaffDto.cs | 60 +++++++++++ .../CreateStoreEmployeeShiftCommandHandler.cs | 72 ++++++++++++++ .../CreateStoreStaffCommandHandler.cs | 56 +++++++++++ .../DeleteStoreEmployeeShiftCommandHandler.cs | 36 +++++++ .../DeleteStoreStaffCommandHandler.cs | 35 +++++++ .../ListStoreEmployeeShiftsQueryHandler.cs | 37 +++++++ .../Handlers/ListStoreStaffQueryHandler.cs | 47 +++++++++ .../UpdateStoreEmployeeShiftCommandHandler.cs | 71 +++++++++++++ .../UpdateStoreStaffCommandHandler.cs | 55 +++++++++++ .../Queries/ListStoreEmployeeShiftsQuery.cs | 30 ++++++ .../App/Stores/Queries/ListStoreStaffQuery.cs | 26 +++++ .../App/Stores/StoreMapping.cs | 38 +++++++ ...reateStoreEmployeeShiftCommandValidator.cs | 22 +++++ .../CreateStoreStaffCommandValidator.cs | 21 ++++ ...pdateStoreEmployeeShiftCommandValidator.cs | 23 +++++ .../UpdateStoreStaffCommandValidator.cs | 22 +++++ .../Repositories/IMerchantRepository.cs | 15 +++ .../Stores/Repositories/IStoreRepository.cs | 19 +++- .../App/Repositories/EfMerchantRepository.cs | 35 +++++++ .../App/Repositories/EfStoreRepository.cs | 46 ++++++++- 31 files changed, 1265 insertions(+), 7 deletions(-) create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/StoreShiftsController.cs create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/StoreStaffsController.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreEmployeeShiftCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreStaffCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreEmployeeShiftCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreStaffCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreEmployeeShiftCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreStaffCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreEmployeeShiftDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreStaffDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreEmployeeShiftCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreStaffCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreEmployeeShiftCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreStaffCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreEmployeeShiftsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreStaffQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreEmployeeShiftCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreStaffCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreEmployeeShiftsQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreStaffQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreEmployeeShiftCommandValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreStaffCommandValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreEmployeeShiftCommandValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreStaffCommandValidator.cs diff --git a/Document/12_BusinessTodo.md b/Document/12_BusinessTodo.md index 44e9773..870b3e7 100644 --- a/Document/12_BusinessTodo.md +++ b/Document/12_BusinessTodo.md @@ -18,8 +18,8 @@ - 进展:已补充营业时间/配送区/节假日的命令、查询、验证与处理器,Admin API 新增子路由完成 CRUD,门店能力开关(预约/排队)已对外暴露;仓储扩展读写删除并保持租户过滤。 - [x] 桌码管理:批量生成桌码、绑定区域/容量、导出二维码 ZIP(POST /api/admin/stores/{id}/tables 可下载)。 - 进展:新增桌台区域/桌码 DTO、命令、查询、验证与处理器,支持批量生成桌码、区域绑定和更新;Admin API 增加桌台区域与桌码 CRUD 及二维码 ZIP 导出端点,使用 QRCoder 生成 SVG 并打包下载;仓储补齐桌台/区域的查找、更新、删除。 -- [ ] 员工排班:创建员工、绑定门店角色、维护 StoreEmployeeShift,可查询未来 7 日排班。 - - 当前:存在 `StoreEmployeeShift` 表模型,未提供应用层命令/查询和 Admin API,排班创建/查询能力缺失。 +- [x] 员工排班:创建员工、绑定门店角色、维护 StoreEmployeeShift,可查询未来 7 日排班。 + - 进展:新增门店员工 DTO/命令/查询/验证与处理器,支持员工创建/更新/删除及按门店查询;新增排班 CRUD(默认查询未来 7 天),校验员工归属、时间冲突;Admin API 增加员工与排班控制器及权限种子,仓储补充排班查询/更新/删除。 - [ ] 桌码扫码入口:Mini 端解析二维码,GET /api/mini/tables/{code}/context 返回门店、桌台、公告。 - 当前:MiniApi 无桌码相关接口,未实现桌码解析与上下文返回。 - [ ] 菜品建模:分类、SPU、SKU、规格/加料组、价格策略、媒资 CRUD + 上下架流程;Mini 端可拉取完整 JSON。 diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreShiftsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreShiftsController.cs new file mode 100644 index 0000000..4b6fa2d --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreShiftsController.cs @@ -0,0 +1,99 @@ +using System.Collections.Generic; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.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}/stores/{storeId:long}/shifts")] +public sealed class StoreShiftsController(IMediator mediator) : BaseApiController +{ + /// + /// 查询排班(默认未来 7 天)。 + /// + [HttpGet] + [PermissionAuthorize("store-shift:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> List( + long storeId, + [FromQuery] DateTime? from, + [FromQuery] DateTime? to, + [FromQuery] long? staffId, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new ListStoreEmployeeShiftsQuery + { + StoreId = storeId, + From = from, + To = to, + StaffId = staffId + }, cancellationToken); + + return ApiResponse>.Ok(result); + } + + /// + /// 创建排班。 + /// + [HttpPost] + [PermissionAuthorize("store-shift:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create(long storeId, [FromBody] CreateStoreEmployeeShiftCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 更新排班。 + /// + [HttpPut("{shiftId:long}")] + [PermissionAuthorize("store-shift:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Update(long storeId, long shiftId, [FromBody] UpdateStoreEmployeeShiftCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0 || command.ShiftId == 0) + { + command = command with { StoreId = storeId, ShiftId = shiftId }; + } + + var result = await mediator.Send(command, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "排班不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除排班。 + /// + [HttpDelete("{shiftId:long}")] + [PermissionAuthorize("store-shift:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Delete(long storeId, long shiftId, CancellationToken cancellationToken) + { + var success = await mediator.Send(new DeleteStoreEmployeeShiftCommand { StoreId = storeId, ShiftId = shiftId }, cancellationToken); + return success + ? ApiResponse.Ok(null) + : ApiResponse.Error(ErrorCodes.NotFound, "排班不存在"); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreStaffsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreStaffsController.cs new file mode 100644 index 0000000..f578d05 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreStaffsController.cs @@ -0,0 +1,98 @@ +using System.Collections.Generic; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Merchants.Enums; +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}/stores/{storeId:long}/staffs")] +public sealed class StoreStaffsController(IMediator mediator) : BaseApiController +{ + /// + /// 查询门店员工列表。 + /// + [HttpGet] + [PermissionAuthorize("store-staff:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> List( + long storeId, + [FromQuery] StaffRoleType? role, + [FromQuery] StaffStatus? status, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new ListStoreStaffQuery + { + StoreId = storeId, + RoleType = role, + Status = status + }, cancellationToken); + + return ApiResponse>.Ok(result); + } + + /// + /// 创建门店员工。 + /// + [HttpPost] + [PermissionAuthorize("store-staff:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create(long storeId, [FromBody] CreateStoreStaffCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 更新门店员工。 + /// + [HttpPut("{staffId:long}")] + [PermissionAuthorize("store-staff:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Update(long storeId, long staffId, [FromBody] UpdateStoreStaffCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0 || command.StaffId == 0) + { + command = command with { StoreId = storeId, StaffId = staffId }; + } + + var result = await mediator.Send(command, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "员工不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除门店员工。 + /// + [HttpDelete("{staffId:long}")] + [PermissionAuthorize("store-staff:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Delete(long storeId, long staffId, CancellationToken cancellationToken) + { + var success = await mediator.Send(new DeleteStoreStaffCommand { StoreId = storeId, StaffId = staffId }, cancellationToken); + return success + ? ApiResponse.Ok(null) + : ApiResponse.Error(ErrorCodes.NotFound, "员工不存在"); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json index 0b9e0da..27eaa88 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json @@ -100,6 +100,14 @@ "store-table:update", "store-table:delete", "store-table:export", + "store-staff:read", + "store-staff:create", + "store-staff:update", + "store-staff:delete", + "store-shift:read", + "store-shift:create", + "store-shift:update", + "store-shift:delete", "product:create", "product:read", "product:update", @@ -176,6 +184,14 @@ "store-table:update", "store-table:delete", "store-table:export", + "store-staff:read", + "store-staff:create", + "store-staff:update", + "store-staff:delete", + "store-shift:read", + "store-shift:create", + "store-shift:update", + "store-shift:delete", "product:create", "product:read", "product:update", @@ -216,6 +232,12 @@ "store-table:create", "store-table:update", "store-table:export", + "store-staff:read", + "store-staff:create", + "store-staff:update", + "store-shift:read", + "store-shift:create", + "store-shift:update", "product:create", "product:read", "product:update", @@ -242,6 +264,7 @@ "store:read", "store-table-area:read", "store-table:read", + "store-shift:read", "product:read", "order:read", "order:update", diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreEmployeeShiftCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreEmployeeShiftCommand.cs new file mode 100644 index 0000000..2729a6f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreEmployeeShiftCommand.cs @@ -0,0 +1,46 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Merchants.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 创建员工排班命令。 +/// +public sealed record CreateStoreEmployeeShiftCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 员工 ID。 + /// + public long StaffId { get; init; } + + /// + /// 班次日期。 + /// + public DateTime ShiftDate { get; init; } + + /// + /// 开始时间。 + /// + public TimeSpan StartTime { get; init; } + + /// + /// 结束时间。 + /// + public TimeSpan EndTime { get; init; } + + /// + /// 排班角色。 + /// + public StaffRoleType RoleType { get; init; } = StaffRoleType.FrontDesk; + + /// + /// 备注。 + /// + public string? Notes { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreStaffCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreStaffCommand.cs new file mode 100644 index 0000000..26dce0d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreStaffCommand.cs @@ -0,0 +1,36 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Merchants.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 创建门店员工命令。 +/// +public sealed record CreateStoreStaffCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 姓名。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 手机号。 + /// + public string Phone { get; init; } = string.Empty; + + /// + /// 邮箱。 + /// + public string? Email { get; init; } + + /// + /// 角色。 + /// + public StaffRoleType RoleType { get; init; } = StaffRoleType.FrontDesk; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreEmployeeShiftCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreEmployeeShiftCommand.cs new file mode 100644 index 0000000..0e75d12 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreEmployeeShiftCommand.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 删除员工排班命令。 +/// +public sealed record DeleteStoreEmployeeShiftCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 排班 ID。 + /// + public long ShiftId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreStaffCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreStaffCommand.cs new file mode 100644 index 0000000..6c9c94b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreStaffCommand.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 删除门店员工命令。 +/// +public sealed record DeleteStoreStaffCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 员工 ID。 + /// + public long StaffId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreEmployeeShiftCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreEmployeeShiftCommand.cs new file mode 100644 index 0000000..c1da376 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreEmployeeShiftCommand.cs @@ -0,0 +1,51 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Merchants.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 更新员工排班命令。 +/// +public sealed record UpdateStoreEmployeeShiftCommand : IRequest +{ + /// + /// 排班 ID。 + /// + public long ShiftId { get; init; } + + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 员工 ID。 + /// + public long StaffId { get; init; } + + /// + /// 班次日期。 + /// + public DateTime ShiftDate { get; init; } + + /// + /// 开始时间。 + /// + public TimeSpan StartTime { get; init; } + + /// + /// 结束时间。 + /// + public TimeSpan EndTime { get; init; } + + /// + /// 排班角色。 + /// + public StaffRoleType RoleType { get; init; } = StaffRoleType.FrontDesk; + + /// + /// 备注。 + /// + public string? Notes { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreStaffCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreStaffCommand.cs new file mode 100644 index 0000000..335a9cf --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreStaffCommand.cs @@ -0,0 +1,46 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Merchants.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 更新门店员工命令。 +/// +public sealed record UpdateStoreStaffCommand : IRequest +{ + /// + /// 员工 ID。 + /// + public long StaffId { get; init; } + + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 姓名。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 手机号。 + /// + public string Phone { get; init; } = string.Empty; + + /// + /// 邮箱。 + /// + public string? Email { get; init; } + + /// + /// 角色。 + /// + public StaffRoleType RoleType { get; init; } = StaffRoleType.FrontDesk; + + /// + /// 状态。 + /// + public StaffStatus Status { get; init; } = StaffStatus.Active; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreEmployeeShiftDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreEmployeeShiftDto.cs new file mode 100644 index 0000000..05c9866 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreEmployeeShiftDto.cs @@ -0,0 +1,65 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 员工排班 DTO。 +/// +public sealed record StoreEmployeeShiftDto +{ + /// + /// 排班 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 员工 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StaffId { get; init; } + + /// + /// 班次日期。 + /// + public DateTime ShiftDate { get; init; } + + /// + /// 开始时间。 + /// + public TimeSpan StartTime { get; init; } + + /// + /// 结束时间。 + /// + public TimeSpan EndTime { get; init; } + + /// + /// 排班角色。 + /// + public StaffRoleType RoleType { get; init; } + + /// + /// 备注。 + /// + public string? Notes { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreStaffDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreStaffDto.cs new file mode 100644 index 0000000..a11cbc7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreStaffDto.cs @@ -0,0 +1,60 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 门店员工 DTO。 +/// +public sealed record StoreStaffDto +{ + /// + /// 员工 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 商户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long MerchantId { get; init; } + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long? StoreId { get; init; } + + /// + /// 姓名。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 手机号。 + /// + public string Phone { get; init; } = string.Empty; + + /// + /// 邮箱。 + /// + public string? Email { get; init; } + + /// + /// 角色类型。 + /// + public StaffRoleType RoleType { get; init; } + + /// + /// 状态。 + /// + public StaffStatus Status { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreEmployeeShiftCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreEmployeeShiftCommandHandler.cs new file mode 100644 index 0000000..b47af84 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreEmployeeShiftCommandHandler.cs @@ -0,0 +1,72 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 创建排班处理器。 +/// +public sealed class CreateStoreEmployeeShiftCommandHandler( + IStoreRepository storeRepository, + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(CreateStoreEmployeeShiftCommand request, CancellationToken cancellationToken) + { + // 1. 校验门店存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 2. 校验员工归属与状态 + var staff = await merchantRepository.FindStaffByIdAsync(request.StaffId, tenantId, cancellationToken); + if (staff is null || (staff.StoreId.HasValue && staff.StoreId != request.StoreId)) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "员工不存在或不属于该门店"); + } + + // 3. 校验日期与冲突 + var from = request.ShiftDate.Date; + var to = request.ShiftDate.Date; + var shifts = await storeRepository.GetShiftsAsync(request.StoreId, tenantId, from, to, cancellationToken); + var hasConflict = shifts.Any(x => x.StaffId == request.StaffId && x.ShiftDate == request.ShiftDate); + if (hasConflict) + { + throw new BusinessException(ErrorCodes.Conflict, "该员工当日已存在排班"); + } + + // 4. 构建实体 + var shift = new StoreEmployeeShift + { + StoreId = request.StoreId, + StaffId = request.StaffId, + ShiftDate = request.ShiftDate.Date, + StartTime = request.StartTime, + EndTime = request.EndTime, + RoleType = request.RoleType, + Notes = request.Notes?.Trim() + }; + + // 5. 持久化 + await storeRepository.AddShiftsAsync(new[] { shift }, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("创建排班 {ShiftId} 员工 {StaffId} 门店 {StoreId}", shift.Id, shift.StaffId, shift.StoreId); + + // 6. 返回 DTO + return StoreMapping.ToDto(shift); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreStaffCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreStaffCommandHandler.cs new file mode 100644 index 0000000..5ae4974 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreStaffCommandHandler.cs @@ -0,0 +1,56 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Merchants.Entities; +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 创建门店员工处理器。 +/// +public sealed class CreateStoreStaffCommandHandler( + IMerchantRepository merchantRepository, + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(CreateStoreStaffCommand request, CancellationToken cancellationToken) + { + // 1. 校验门店 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 2. 组装员工 + var staff = new MerchantStaff + { + MerchantId = store.MerchantId, + StoreId = request.StoreId, + Name = request.Name.Trim(), + Phone = request.Phone.Trim(), + Email = request.Email?.Trim(), + RoleType = request.RoleType, + Status = StaffStatus.Active + }; + + // 3. 持久化 + await merchantRepository.AddStaffAsync(staff, cancellationToken); + await merchantRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("创建门店员工 {StaffId} 门店 {StoreId}", staff.Id, request.StoreId); + + // 4. 返回 DTO + return StoreMapping.ToDto(staff); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreEmployeeShiftCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreEmployeeShiftCommandHandler.cs new file mode 100644 index 0000000..d29a555 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreEmployeeShiftCommandHandler.cs @@ -0,0 +1,36 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 删除排班处理器。 +/// +public sealed class DeleteStoreEmployeeShiftCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(DeleteStoreEmployeeShiftCommand request, CancellationToken cancellationToken) + { + // 1. 读取排班 + var tenantId = tenantProvider.GetCurrentTenantId(); + var shift = await storeRepository.FindShiftByIdAsync(request.ShiftId, tenantId, cancellationToken); + if (shift is null || shift.StoreId != request.StoreId) + { + return false; + } + + // 2. 删除 + await storeRepository.DeleteShiftAsync(request.ShiftId, tenantId, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("删除排班 {ShiftId} 门店 {StoreId}", request.ShiftId, request.StoreId); + + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreStaffCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreStaffCommandHandler.cs new file mode 100644 index 0000000..5d438f8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreStaffCommandHandler.cs @@ -0,0 +1,35 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 删除门店员工处理器。 +/// +public sealed class DeleteStoreStaffCommandHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(DeleteStoreStaffCommand request, CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var staff = await merchantRepository.FindStaffByIdAsync(request.StaffId, tenantId, cancellationToken); + if (staff is null || staff.StoreId != request.StoreId) + { + return false; + } + + // 逻辑删除未定义,直接物理删除 + await merchantRepository.DeleteStaffAsync(staff.Id, tenantId, cancellationToken); + await merchantRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("删除门店员工 {StaffId} 门店 {StoreId}", request.StaffId, request.StoreId); + + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreEmployeeShiftsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreEmployeeShiftsQueryHandler.cs new file mode 100644 index 0000000..2dde5dd --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreEmployeeShiftsQueryHandler.cs @@ -0,0 +1,37 @@ +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 排班列表查询处理器。 +/// +public sealed class ListStoreEmployeeShiftsQueryHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + /// + public async Task> Handle(ListStoreEmployeeShiftsQuery request, CancellationToken cancellationToken) + { + // 1. 时间范围 + var from = request.From ?? DateTime.UtcNow.Date; + var to = request.To ?? from.AddDays(7); + var tenantId = tenantProvider.GetCurrentTenantId(); + + // 2. 查询排班 + var shifts = await storeRepository.GetShiftsAsync(request.StoreId, tenantId, from, to, cancellationToken); + + if (request.StaffId.HasValue) + { + shifts = shifts.Where(x => x.StaffId == request.StaffId.Value).ToList(); + } + + // 3. 映射 DTO + return shifts.Select(StoreMapping.ToDto).ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreStaffQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreStaffQueryHandler.cs new file mode 100644 index 0000000..5a2e770 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreStaffQueryHandler.cs @@ -0,0 +1,47 @@ +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 门店员工列表查询处理器。 +/// +public sealed class ListStoreStaffQueryHandler( + IMerchantRepository merchantRepository, + IStoreRepository storeRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + /// + public async Task> Handle(ListStoreStaffQuery request, CancellationToken cancellationToken) + { + // 1. 校验门店存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + return []; + } + + // 2. 查询员工 + var staffs = await merchantRepository.GetStaffByStoreAsync(request.StoreId, tenantId, cancellationToken); + + if (request.RoleType.HasValue) + { + staffs = staffs.Where(x => x.RoleType == request.RoleType.Value).ToList(); + } + + if (request.Status.HasValue) + { + staffs = staffs.Where(x => x.Status == request.Status.Value).ToList(); + } + + // 3. 映射 DTO + return staffs.Select(StoreMapping.ToDto).ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreEmployeeShiftCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreEmployeeShiftCommandHandler.cs new file mode 100644 index 0000000..ed2a975 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreEmployeeShiftCommandHandler.cs @@ -0,0 +1,71 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 更新排班处理器。 +/// +public sealed class UpdateStoreEmployeeShiftCommandHandler( + IStoreRepository storeRepository, + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(UpdateStoreEmployeeShiftCommand request, CancellationToken cancellationToken) + { + // 1. 读取排班 + var tenantId = tenantProvider.GetCurrentTenantId(); + var shift = await storeRepository.FindShiftByIdAsync(request.ShiftId, tenantId, cancellationToken); + if (shift is null) + { + return null; + } + + // 2. 校验门店归属 + if (shift.StoreId != request.StoreId) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "排班不属于该门店"); + } + + // 3. 校验员工归属 + var staff = await merchantRepository.FindStaffByIdAsync(request.StaffId, tenantId, cancellationToken); + if (staff is null || (staff.StoreId.HasValue && staff.StoreId != request.StoreId)) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "员工不存在或不属于该门店"); + } + + // 4. 冲突校验 + var shifts = await storeRepository.GetShiftsAsync(request.StoreId, tenantId, request.ShiftDate.Date, request.ShiftDate.Date, cancellationToken); + var hasConflict = shifts.Any(x => x.Id != request.ShiftId && x.StaffId == request.StaffId && x.ShiftDate == request.ShiftDate); + if (hasConflict) + { + throw new BusinessException(ErrorCodes.Conflict, "该员工当日已存在排班"); + } + + // 5. 更新字段 + shift.StaffId = request.StaffId; + shift.ShiftDate = request.ShiftDate.Date; + shift.StartTime = request.StartTime; + shift.EndTime = request.EndTime; + shift.RoleType = request.RoleType; + shift.Notes = request.Notes?.Trim(); + + // 6. 持久化 + await storeRepository.UpdateShiftAsync(shift, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("更新排班 {ShiftId} 员工 {StaffId} 门店 {StoreId}", shift.Id, shift.StaffId, shift.StoreId); + + // 7. 返回 DTO + return StoreMapping.ToDto(shift); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreStaffCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreStaffCommandHandler.cs new file mode 100644 index 0000000..28c1321 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreStaffCommandHandler.cs @@ -0,0 +1,55 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 更新门店员工处理器。 +/// +public sealed class UpdateStoreStaffCommandHandler( + IMerchantRepository merchantRepository, + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(UpdateStoreStaffCommand request, CancellationToken cancellationToken) + { + // 1. 校验门店 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + return null; + } + + // 2. 读取员工 + var staff = await merchantRepository.FindStaffByIdAsync(request.StaffId, tenantId, cancellationToken); + if (staff is null || staff.StoreId != request.StoreId) + { + return null; + } + + // 3. 更新字段 + staff.Name = request.Name.Trim(); + staff.Phone = request.Phone.Trim(); + staff.Email = request.Email?.Trim(); + staff.RoleType = request.RoleType; + staff.Status = request.Status; + + // 4. 持久化 + await merchantRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("更新门店员工 {StaffId} 门店 {StoreId}", staff.Id, staff.StoreId); + + // 5. 返回 DTO + return StoreMapping.ToDto(staff); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreEmployeeShiftsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreEmployeeShiftsQuery.cs new file mode 100644 index 0000000..0684b06 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreEmployeeShiftsQuery.cs @@ -0,0 +1,30 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 员工排班列表查询(支持日期区间)。 +/// +public sealed record ListStoreEmployeeShiftsQuery : IRequest> +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 开始日期(含),默认今日。 + /// + public DateTime? From { get; init; } + + /// + /// 结束日期(含),默认今日+7。 + /// + public DateTime? To { get; init; } + + /// + /// 可选员工筛选。 + /// + public long? StaffId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreStaffQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreStaffQuery.cs new file mode 100644 index 0000000..23004f9 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreStaffQuery.cs @@ -0,0 +1,26 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Merchants.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 门店员工列表查询。 +/// +public sealed record ListStoreStaffQuery : IRequest> +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 角色筛选。 + /// + public StaffRoleType? RoleType { get; init; } + + /// + /// 状态筛选。 + /// + public StaffStatus? Status { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs b/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs index 65f1be5..8a9a0d2 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs @@ -1,4 +1,5 @@ using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Merchants.Entities; using TakeoutSaaS.Domain.Stores.Entities; namespace TakeoutSaaS.Application.App.Stores; @@ -96,4 +97,41 @@ public static class StoreMapping QrCodeUrl = table.QrCodeUrl, CreatedAt = table.CreatedAt }; + + /// + /// 映射排班 DTO。 + /// + /// 排班实体。 + /// DTO。 + public static StoreEmployeeShiftDto ToDto(StoreEmployeeShift shift) => new() + { + Id = shift.Id, + TenantId = shift.TenantId, + StoreId = shift.StoreId, + StaffId = shift.StaffId, + ShiftDate = shift.ShiftDate, + StartTime = shift.StartTime, + EndTime = shift.EndTime, + RoleType = shift.RoleType, + Notes = shift.Notes, + CreatedAt = shift.CreatedAt + }; + + /// + /// 映射门店员工 DTO。 + /// + /// 员工实体。 + /// DTO。 + public static StoreStaffDto ToDto(MerchantStaff staff) => new() + { + Id = staff.Id, + TenantId = staff.TenantId, + MerchantId = staff.MerchantId, + StoreId = staff.StoreId, + Name = staff.Name, + Phone = staff.Phone, + Email = staff.Email, + RoleType = staff.RoleType, + Status = staff.Status + }; } diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreEmployeeShiftCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreEmployeeShiftCommandValidator.cs new file mode 100644 index 0000000..9a0cb21 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreEmployeeShiftCommandValidator.cs @@ -0,0 +1,22 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 创建排班命令验证器。 +/// +public sealed class CreateStoreEmployeeShiftCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public CreateStoreEmployeeShiftCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.StaffId).GreaterThan(0); + RuleFor(x => x.ShiftDate).NotEmpty(); + RuleFor(x => x.StartTime).LessThan(x => x.EndTime).WithMessage("结束时间必须晚于开始时间"); + RuleFor(x => x.Notes).MaximumLength(256); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreStaffCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreStaffCommandValidator.cs new file mode 100644 index 0000000..7e4e44b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreStaffCommandValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 创建门店员工命令验证器。 +/// +public sealed class CreateStoreStaffCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public CreateStoreStaffCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.Name).NotEmpty().MaximumLength(64); + RuleFor(x => x.Phone).NotEmpty().MaximumLength(32); + RuleFor(x => x.Email).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.Email)); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreEmployeeShiftCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreEmployeeShiftCommandValidator.cs new file mode 100644 index 0000000..53eb742 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreEmployeeShiftCommandValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 更新排班命令验证器。 +/// +public sealed class UpdateStoreEmployeeShiftCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public UpdateStoreEmployeeShiftCommandValidator() + { + RuleFor(x => x.ShiftId).GreaterThan(0); + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.StaffId).GreaterThan(0); + RuleFor(x => x.ShiftDate).NotEmpty(); + RuleFor(x => x.StartTime).LessThan(x => x.EndTime).WithMessage("结束时间必须晚于开始时间"); + RuleFor(x => x.Notes).MaximumLength(256); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreStaffCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreStaffCommandValidator.cs new file mode 100644 index 0000000..27794d2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreStaffCommandValidator.cs @@ -0,0 +1,22 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 更新门店员工命令验证器。 +/// +public sealed class UpdateStoreStaffCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public UpdateStoreStaffCommandValidator() + { + RuleFor(x => x.StaffId).GreaterThan(0); + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.Name).NotEmpty().MaximumLength(64); + RuleFor(x => x.Phone).NotEmpty().MaximumLength(32); + RuleFor(x => x.Email).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.Email)); + } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantRepository.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantRepository.cs index c467a2e..0a746c6 100644 --- a/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantRepository.cs @@ -26,6 +26,16 @@ public interface IMerchantRepository /// Task> GetStaffAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 获取指定门店的员工列表。 + /// + Task> GetStaffByStoreAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 依据标识获取员工。 + /// + Task FindStaffByIdAsync(long staffId, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取指定商户的合同列表。 /// @@ -75,6 +85,11 @@ public interface IMerchantRepository /// Task DeleteMerchantAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 删除员工。 + /// + Task DeleteStaffAsync(long staffId, long tenantId, CancellationToken cancellationToken = default); + /// /// 记录审核日志。 /// diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs index 7ed73f1..a4d0146 100644 --- a/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs @@ -72,9 +72,14 @@ public interface IStoreRepository Task FindTableByIdAsync(long tableId, long tenantId, CancellationToken cancellationToken = default); /// - /// 获取门店员工排班。 + /// 获取门店员工排班(可选时间范围)。 /// - Task> GetShiftsAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + Task> GetShiftsAsync(long storeId, long tenantId, DateTime? from = null, DateTime? to = null, CancellationToken cancellationToken = default); + + /// + /// 依据标识获取排班。 + /// + Task FindShiftByIdAsync(long shiftId, long tenantId, CancellationToken cancellationToken = default); /// /// 新增门店。 @@ -136,6 +141,11 @@ public interface IStoreRepository /// Task AddShiftsAsync(IEnumerable shifts, CancellationToken cancellationToken = default); + /// + /// 更新排班。 + /// + Task UpdateShiftAsync(StoreEmployeeShift shift, CancellationToken cancellationToken = default); + /// /// 持久化变更。 /// @@ -166,6 +176,11 @@ public interface IStoreRepository /// Task DeleteTableAsync(long tableId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 删除排班。 + /// + Task DeleteShiftAsync(long shiftId, long tenantId, CancellationToken cancellationToken = default); + /// /// 更新门店。 /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs index 35e00a8..fa7f85c 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs @@ -55,6 +55,26 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context) : IMerchan return staffs; } + /// + public async Task> GetStaffByStoreAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) + { + var staffs = await context.MerchantStaff + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.StoreId == storeId) + .OrderBy(x => x.Name) + .ToListAsync(cancellationToken); + + return staffs; + } + + /// + public Task FindStaffByIdAsync(long staffId, long tenantId, CancellationToken cancellationToken = default) + { + return context.MerchantStaff + .Where(x => x.TenantId == tenantId && x.Id == staffId) + .FirstOrDefaultAsync(cancellationToken); + } + /// public async Task> GetContractsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default) { @@ -161,6 +181,21 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context) : IMerchan context.Merchants.Remove(existing); } + /// + public async Task DeleteStaffAsync(long staffId, long tenantId, CancellationToken cancellationToken = default) + { + var existing = await context.MerchantStaff + .Where(x => x.TenantId == tenantId && x.Id == staffId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing == null) + { + return; + } + + context.MerchantStaff.Remove(existing); + } + /// public Task AddAuditLogAsync(MerchantAuditLog log, CancellationToken cancellationToken = default) { diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs index 2e146e2..5fb6ebf 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs @@ -147,11 +147,23 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos } /// - public async Task> GetShiftsAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) + public async Task> GetShiftsAsync(long storeId, long tenantId, DateTime? from = null, DateTime? to = null, CancellationToken cancellationToken = default) { - var shifts = await context.StoreEmployeeShifts + var query = context.StoreEmployeeShifts .AsNoTracking() - .Where(x => x.TenantId == tenantId && x.StoreId == storeId) + .Where(x => x.TenantId == tenantId && x.StoreId == storeId); + + if (from.HasValue) + { + query = query.Where(x => x.ShiftDate >= from.Value.Date); + } + + if (to.HasValue) + { + query = query.Where(x => x.ShiftDate <= to.Value.Date); + } + + var shifts = await query .OrderBy(x => x.ShiftDate) .ThenBy(x => x.StartTime) .ToListAsync(cancellationToken); @@ -159,6 +171,14 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos return shifts; } + /// + public Task FindShiftByIdAsync(long shiftId, long tenantId, CancellationToken cancellationToken = default) + { + return context.StoreEmployeeShifts + .Where(x => x.TenantId == tenantId && x.Id == shiftId) + .FirstOrDefaultAsync(cancellationToken); + } + /// public Task AddStoreAsync(Store store, CancellationToken cancellationToken = default) { @@ -236,6 +256,13 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos return context.StoreEmployeeShifts.AddRangeAsync(shifts, cancellationToken); } + /// + public Task UpdateShiftAsync(StoreEmployeeShift shift, CancellationToken cancellationToken = default) + { + context.StoreEmployeeShifts.Update(shift); + return Task.CompletedTask; + } + /// public Task SaveChangesAsync(CancellationToken cancellationToken = default) { @@ -307,6 +334,19 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos } } + /// + public async Task DeleteShiftAsync(long shiftId, long tenantId, CancellationToken cancellationToken = default) + { + var existing = await context.StoreEmployeeShifts + .Where(x => x.TenantId == tenantId && x.Id == shiftId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing != null) + { + context.StoreEmployeeShifts.Remove(existing); + } + } + /// public Task UpdateStoreAsync(Store store, CancellationToken cancellationToken = default) { From 9220e0ca36b7819f3dfa2e5c44be15589495506a Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Thu, 4 Dec 2025 09:37:05 +0800 Subject: [PATCH 18/30] =?UTF-8?q?feat:=20Mini=20=E6=A1=8C=E7=A0=81?= =?UTF-8?q?=E6=89=AB=E7=A0=81=E4=B8=8A=E4=B8=8B=E6=96=87=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Document/12_BusinessTodo.md | 4 +- .../Controllers/TablesController.cs | 35 ++++++++++ .../App/Stores/Dto/StoreTableContextDto.cs | 64 +++++++++++++++++++ .../GetStoreTableContextQueryHandler.cs | 55 ++++++++++++++++ .../Queries/GetStoreTableContextQuery.cs | 15 +++++ .../GetStoreTableContextQueryValidator.cs | 18 ++++++ .../Stores/Repositories/IStoreRepository.cs | 5 ++ .../App/Repositories/EfStoreRepository.cs | 8 +++ 8 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 src/Api/TakeoutSaaS.MiniApi/Controllers/TablesController.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreTableContextDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreTableContextQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetStoreTableContextQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Validators/GetStoreTableContextQueryValidator.cs diff --git a/Document/12_BusinessTodo.md b/Document/12_BusinessTodo.md index 870b3e7..455b188 100644 --- a/Document/12_BusinessTodo.md +++ b/Document/12_BusinessTodo.md @@ -20,8 +20,8 @@ - 进展:新增桌台区域/桌码 DTO、命令、查询、验证与处理器,支持批量生成桌码、区域绑定和更新;Admin API 增加桌台区域与桌码 CRUD 及二维码 ZIP 导出端点,使用 QRCoder 生成 SVG 并打包下载;仓储补齐桌台/区域的查找、更新、删除。 - [x] 员工排班:创建员工、绑定门店角色、维护 StoreEmployeeShift,可查询未来 7 日排班。 - 进展:新增门店员工 DTO/命令/查询/验证与处理器,支持员工创建/更新/删除及按门店查询;新增排班 CRUD(默认查询未来 7 天),校验员工归属、时间冲突;Admin API 增加员工与排班控制器及权限种子,仓储补充排班查询/更新/删除。 -- [ ] 桌码扫码入口:Mini 端解析二维码,GET /api/mini/tables/{code}/context 返回门店、桌台、公告。 - - 当前:MiniApi 无桌码相关接口,未实现桌码解析与上下文返回。 +- [x] 桌码扫码入口:Mini 端解析二维码,GET /api/mini/tables/{code}/context 返回门店、桌台、公告。 + - 进展:新增桌码上下文查询 DTO/验证/处理器,可按桌码解析返回门店名称/公告/标签及桌台信息;MiniApi 增加 `TablesController` 提供 `/context` 端点,仓储支持按桌码查询。 - [ ] 菜品建模:分类、SPU、SKU、规格/加料组、价格策略、媒资 CRUD + 上下架流程;Mini 端可拉取完整 JSON。 - 当前:Admin 仅有基础商品 CRUD(Product 级),未覆盖 SKU/规格/加料组、价格策略、媒资与上下架流程,Mini 端也未提供完整商品 JSON 拉取接口。 - [ ] 库存体系:SKU 库存、批次、调整、售罄管理,支持预售/档期锁定并在订单中扣减/释放。 diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/TablesController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/TablesController.cs new file mode 100644 index 0000000..504e35a --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/TablesController.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.MiniApi.Controllers; + +/// +/// 桌码上下文。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/mini/v{version:apiVersion}/tables")] +public sealed class TablesController(IMediator mediator) : BaseApiController +{ + /// + /// 解析桌码并返回上下文。 + /// + [HttpGet("{code}/context")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> GetContext(string code, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetStoreTableContextQuery { TableCode = code }, cancellationToken); + return result is null + ? ApiResponse.Error(ErrorCodes.NotFound, "桌码不存在") + : ApiResponse.Ok(result); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreTableContextDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreTableContextDto.cs new file mode 100644 index 0000000..4ce0e86 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreTableContextDto.cs @@ -0,0 +1,64 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 桌码上下文 DTO。 +/// +public sealed record StoreTableContextDto +{ + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 门店名称。 + /// + public string StoreName { get; init; } = string.Empty; + + /// + /// 门店公告。 + /// + public string? Announcement { get; init; } + + /// + /// 门店标签。 + /// + public string? Tags { get; init; } + + /// + /// 桌台 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TableId { get; init; } + + /// + /// 桌码。 + /// + public string TableCode { get; init; } = string.Empty; + + /// + /// 区域 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long? AreaId { get; init; } + + /// + /// 容量。 + /// + public int Capacity { get; init; } + + /// + /// 标签。 + /// + public string? TableTags { get; init; } + + /// + /// 状态。 + /// + public StoreTableStatus Status { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreTableContextQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreTableContextQueryHandler.cs new file mode 100644 index 0000000..8ef1bea --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreTableContextQueryHandler.cs @@ -0,0 +1,55 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 桌码上下文查询处理器。 +/// +public sealed class GetStoreTableContextQueryHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(GetStoreTableContextQuery request, CancellationToken cancellationToken) + { + // 1. 查询桌码 + var tenantId = tenantProvider.GetCurrentTenantId(); + var table = await storeRepository.FindTableByCodeAsync(request.TableCode, tenantId, cancellationToken); + if (table is null) + { + logger.LogWarning("未找到桌码 {TableCode}", request.TableCode); + return null; + } + + // 2. 查询门店 + var store = await storeRepository.FindByIdAsync(table.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 3. 组装上下文 + return new StoreTableContextDto + { + StoreId = store.Id, + StoreName = store.Name, + Announcement = store.Announcement, + Tags = store.Tags, + TableId = table.Id, + TableCode = table.TableCode, + AreaId = table.AreaId, + Capacity = table.Capacity, + TableTags = table.Tags, + Status = table.Status + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetStoreTableContextQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetStoreTableContextQuery.cs new file mode 100644 index 0000000..2ed641e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetStoreTableContextQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 桌码上下文查询。 +/// +public sealed record GetStoreTableContextQuery : IRequest +{ + /// + /// 桌码。 + /// + public string TableCode { get; init; } = string.Empty; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/GetStoreTableContextQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/GetStoreTableContextQueryValidator.cs new file mode 100644 index 0000000..bb80b34 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/GetStoreTableContextQueryValidator.cs @@ -0,0 +1,18 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Queries; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 桌码上下文查询验证器。 +/// +public sealed class GetStoreTableContextQueryValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public GetStoreTableContextQueryValidator() + { + RuleFor(x => x.TableCode).NotEmpty().MaximumLength(32); + } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs index a4d0146..2cdbfac 100644 --- a/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs @@ -71,6 +71,11 @@ public interface IStoreRepository /// Task FindTableByIdAsync(long tableId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 依据桌码获取桌台。 + /// + Task FindTableByCodeAsync(string tableCode, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取门店员工排班(可选时间范围)。 /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs index 5fb6ebf..0722b3c 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs @@ -146,6 +146,14 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos .FirstOrDefaultAsync(cancellationToken); } + /// + public Task FindTableByCodeAsync(string tableCode, long tenantId, CancellationToken cancellationToken = default) + { + return context.StoreTables + .Where(x => x.TenantId == tenantId && x.TableCode == tableCode) + .FirstOrDefaultAsync(cancellationToken); + } + /// public async Task> GetShiftsAsync(long storeId, long tenantId, DateTime? from = null, DateTime? to = null, CancellationToken cancellationToken = default) { From de5f13ec834a1eb0c78a277175af41014f5bc387 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Thu, 4 Dec 2025 10:03:42 +0800 Subject: [PATCH 19/30] =?UTF-8?q?feat:=20=E5=95=86=E5=93=81=E4=B8=8A?= =?UTF-8?q?=E6=9E=B6/=E4=B8=8B=E6=9E=B6=E4=B8=8E=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Document/12_BusinessTodo.md | 2 +- .../Controllers/ProductsController.cs | 55 ++++++++ .../appsettings.Seed.Development.json | 1 + .../Commands/PublishProductCommand.cs | 20 +++ .../Commands/UnpublishProductCommand.cs | 20 +++ .../App/Products/Dto/ProductAddonGroupDto.cs | 47 +++++++ .../App/Products/Dto/ProductAddonOptionDto.cs | 37 +++++ .../Products/Dto/ProductAttributeGroupDto.cs | 42 ++++++ .../Products/Dto/ProductAttributeOptionDto.cs | 32 +++++ .../App/Products/Dto/ProductDetailDto.cs | 39 +++++ .../App/Products/Dto/ProductDto.cs | 2 +- .../App/Products/Dto/ProductMediaAssetDto.cs | 43 ++++++ .../App/Products/Dto/ProductPricingRuleDto.cs | 43 ++++++ .../App/Products/Dto/ProductSkuDto.cs | 62 ++++++++ .../Handlers/GetProductByIdQueryHandler.cs | 27 +--- .../Handlers/GetProductDetailQueryHandler.cs | 59 ++++++++ .../Handlers/PublishProductCommandHandler.cs | 49 +++++++ .../Handlers/SearchProductsQueryHandler.cs | 7 +- .../UnpublishProductCommandHandler.cs | 39 +++++ .../App/Products/ProductMapping.cs | 133 ++++++++++++++++++ .../Products/Queries/GetProductDetailQuery.cs | 15 ++ .../PublishProductCommandValidator.cs | 19 +++ .../UnpublishProductCommandValidator.cs | 19 +++ .../Repositories/IProductRepository.cs | 2 +- .../App/Repositories/EfProductRepository.cs | 7 +- 25 files changed, 785 insertions(+), 36 deletions(-) create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Commands/PublishProductCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Commands/UnpublishProductCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAddonGroupDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAddonOptionDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAttributeGroupDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAttributeOptionDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductDetailDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductMediaAssetDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductPricingRuleDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductSkuDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductDetailQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Handlers/PublishProductCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Handlers/UnpublishProductCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/ProductMapping.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Queries/GetProductDetailQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Validators/PublishProductCommandValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Validators/UnpublishProductCommandValidator.cs diff --git a/Document/12_BusinessTodo.md b/Document/12_BusinessTodo.md index 455b188..cb4399c 100644 --- a/Document/12_BusinessTodo.md +++ b/Document/12_BusinessTodo.md @@ -23,7 +23,7 @@ - [x] 桌码扫码入口:Mini 端解析二维码,GET /api/mini/tables/{code}/context 返回门店、桌台、公告。 - 进展:新增桌码上下文查询 DTO/验证/处理器,可按桌码解析返回门店名称/公告/标签及桌台信息;MiniApi 增加 `TablesController` 提供 `/context` 端点,仓储支持按桌码查询。 - [ ] 菜品建模:分类、SPU、SKU、规格/加料组、价格策略、媒资 CRUD + 上下架流程;Mini 端可拉取完整 JSON。 - - 当前:Admin 仅有基础商品 CRUD(Product 级),未覆盖 SKU/规格/加料组、价格策略、媒资与上下架流程,Mini 端也未提供完整商品 JSON 拉取接口。 + - 进展:补充商品全量详情 DTO/查询与映射,支持按门店过滤;新增 Admin 上下架接口与全量详情端点,权限新增 `product:publish`。仍需完成 SKU/规格/加料/媒资/价格策略替换接口及 Mini 菜单拉取。 - [ ] 库存体系:SKU 库存、批次、调整、售罄管理,支持预售/档期锁定并在订单中扣减/释放。 - 当前:存在 `InventoryItem/InventoryBatch/InventoryAdjustment` 领域模型与 DbSet,但未提供库存调整/锁定命令、与订单扣减/释放或预售档期锁定的应用层逻辑与 API。 - [ ] 自提档期:门店配置自提时间窗、容量、截单时间;Mini 端据此限制下单时间。 diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs index 24e334c..4fd43b3 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs @@ -116,4 +116,59 @@ public sealed class ProductsController(IMediator mediator) : BaseApiController ? ApiResponse.Ok(null) : ApiResponse.Error(ErrorCodes.NotFound, "商品不存在"); } + + /// + /// 获取商品全量详情。 + /// + [HttpGet("{productId:long}/detail")] + [PermissionAuthorize("product:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> FullDetail(long productId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetProductDetailQuery { ProductId = productId }, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "商品不存在") + : ApiResponse.Ok(result); + } + + /// + /// 上架商品。 + /// + [HttpPost("{productId:long}/publish")] + [PermissionAuthorize("product:publish")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Publish(long productId, [FromBody] PublishProductCommand command, CancellationToken cancellationToken) + { + if (command.ProductId == 0) + { + command = command with { ProductId = productId }; + } + + var result = await mediator.Send(command, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "商品不存在") + : ApiResponse.Ok(result); + } + + /// + /// 下架商品。 + /// + [HttpPost("{productId:long}/unpublish")] + [PermissionAuthorize("product:publish")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Unpublish(long productId, [FromBody] UnpublishProductCommand command, CancellationToken cancellationToken) + { + if (command.ProductId == 0) + { + command = command with { ProductId = productId }; + } + + var result = await mediator.Send(command, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "商品不存在") + : ApiResponse.Ok(result); + } } diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json index 27eaa88..47d4125 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json @@ -112,6 +112,7 @@ "product:read", "product:update", "product:delete", + "product:publish", "order:create", "order:read", "order:update", diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/PublishProductCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/PublishProductCommand.cs new file mode 100644 index 0000000..f8aa134 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/PublishProductCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Commands; + +/// +/// 上架商品命令。 +/// +public sealed record PublishProductCommand : IRequest +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } + + /// + /// 上架备注。 + /// + public string? Reason { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/UnpublishProductCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/UnpublishProductCommand.cs new file mode 100644 index 0000000..d59aef5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/UnpublishProductCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Commands; + +/// +/// 下架商品命令。 +/// +public sealed record UnpublishProductCommand : IRequest +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } + + /// + /// 下架原因。 + /// + public string? Reason { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAddonGroupDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAddonGroupDto.cs new file mode 100644 index 0000000..c249464 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAddonGroupDto.cs @@ -0,0 +1,47 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// 加料组 DTO。 +/// +public sealed record ProductAddonGroupDto +{ + /// + /// 组 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 商品 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long ProductId { get; init; } + + /// + /// 名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 最小选择数。 + /// + public int MinSelect { get; init; } + + /// + /// 最大选择数。 + /// + public int MaxSelect { get; init; } + + /// + /// 排序。 + /// + public int SortOrder { get; init; } + + /// + /// 加料选项。 + /// + public IReadOnlyList Options { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAddonOptionDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAddonOptionDto.cs new file mode 100644 index 0000000..544ba67 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAddonOptionDto.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// 加料选项 DTO。 +/// +public sealed record ProductAddonOptionDto +{ + /// + /// 选项 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 所属加料组 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long AddonGroupId { get; init; } + + /// + /// 名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 额外价格。 + /// + public decimal? ExtraPrice { get; init; } + + /// + /// 排序。 + /// + public int SortOrder { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAttributeGroupDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAttributeGroupDto.cs new file mode 100644 index 0000000..7e5ce67 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAttributeGroupDto.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// 规格组 DTO。 +/// +public sealed record ProductAttributeGroupDto +{ + /// + /// 组 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 商品 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long ProductId { get; init; } + + /// + /// 名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 选择类型。 + /// + public int SelectionType { get; init; } + + /// + /// 排序。 + /// + public int SortOrder { get; init; } + + /// + /// 规格选项。 + /// + public IReadOnlyList Options { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAttributeOptionDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAttributeOptionDto.cs new file mode 100644 index 0000000..f9fc8ce --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAttributeOptionDto.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// 规格选项 DTO。 +/// +public sealed record ProductAttributeOptionDto +{ + /// + /// 选项 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 规格组 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long AttributeGroupId { get; init; } + + /// + /// 名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 排序。 + /// + public int SortOrder { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductDetailDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductDetailDto.cs new file mode 100644 index 0000000..cc9ec59 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductDetailDto.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// 商品全量详情 DTO。 +/// +public sealed record ProductDetailDto +{ + /// + /// SPU 基础信息。 + /// + public ProductDto Product { get; init; } = new(); + + /// + /// SKU 列表。 + /// + public IReadOnlyList Skus { get; init; } = []; + + /// + /// 规格组与选项。 + /// + public IReadOnlyList AttributeGroups { get; init; } = []; + + /// + /// 加料组与选项。 + /// + public IReadOnlyList AddonGroups { get; init; } = []; + + /// + /// 价格策略。 + /// + public IReadOnlyList PricingRules { get; init; } = []; + + /// + /// 媒资列表。 + /// + public IReadOnlyList MediaAssets { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductDto.cs index bfcd321..27adb83 100644 --- a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductDto.cs +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductDto.cs @@ -5,7 +5,7 @@ using TakeoutSaaS.Shared.Abstractions.Serialization; namespace TakeoutSaaS.Application.App.Products.Dto; /// -/// 商品 DTO。 +/// 商品 DTO(含 SPU 基础信息)。 /// public sealed class ProductDto { diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductMediaAssetDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductMediaAssetDto.cs new file mode 100644 index 0000000..d4a3279 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductMediaAssetDto.cs @@ -0,0 +1,43 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Products.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// 商品媒资 DTO。 +/// +public sealed record ProductMediaAssetDto +{ + /// + /// 媒资 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 商品 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long ProductId { get; init; } + + /// + /// 类型。 + /// + public MediaAssetType MediaType { get; init; } + + /// + /// URL。 + /// + public string Url { get; init; } = string.Empty; + + /// + /// 文案。 + /// + public string? Caption { get; init; } + + /// + /// 排序。 + /// + public int SortOrder { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductPricingRuleDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductPricingRuleDto.cs new file mode 100644 index 0000000..04961fb --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductPricingRuleDto.cs @@ -0,0 +1,43 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Products.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// 价格策略 DTO。 +/// +public sealed record ProductPricingRuleDto +{ + /// + /// 策略 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 商品 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long ProductId { get; init; } + + /// + /// 策略类型。 + /// + public PricingRuleType RuleType { get; init; } + + /// + /// 价格。 + /// + public decimal Price { get; init; } + + /// + /// 条件 JSON。 + /// + public string ConditionsJson { get; init; } = string.Empty; + + /// + /// 星期规则。 + /// + public string? WeekdaysJson { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductSkuDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductSkuDto.cs new file mode 100644 index 0000000..4b76869 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductSkuDto.cs @@ -0,0 +1,62 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// SKU DTO。 +/// +public sealed record ProductSkuDto +{ + /// + /// SKU ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 商品 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long ProductId { get; init; } + + /// + /// 编码。 + /// + public string SkuCode { get; init; } = string.Empty; + + /// + /// 条形码。 + /// + public string? Barcode { get; init; } + + /// + /// 售价。 + /// + public decimal Price { get; init; } + + /// + /// 原价。 + /// + public decimal? OriginalPrice { get; init; } + + /// + /// 库存。 + /// + public int? StockQuantity { get; init; } + + /// + /// 重量。 + /// + public decimal? Weight { get; init; } + + /// + /// 规格属性 JSON。 + /// + public string AttributesJson { get; init; } = string.Empty; + + /// + /// 排序。 + /// + public int SortOrder { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductByIdQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductByIdQueryHandler.cs index c3b6a60..bed5199 100644 --- a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductByIdQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductByIdQueryHandler.cs @@ -23,31 +23,6 @@ public sealed class GetProductByIdQueryHandler( { var tenantId = _tenantProvider.GetCurrentTenantId(); var product = await _productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken); - return product == null ? null : MapToDto(product); + return product == null ? null : ProductMapping.ToDto(product); } - - private static ProductDto MapToDto(Product product) => new() - { - Id = product.Id, - TenantId = product.TenantId, - StoreId = product.StoreId, - CategoryId = product.CategoryId, - SpuCode = product.SpuCode, - Name = product.Name, - Subtitle = product.Subtitle, - Unit = product.Unit, - Price = product.Price, - OriginalPrice = product.OriginalPrice, - StockQuantity = product.StockQuantity, - MaxQuantityPerOrder = product.MaxQuantityPerOrder, - Status = product.Status, - CoverImage = product.CoverImage, - GalleryImages = product.GalleryImages, - Description = product.Description, - EnableDineIn = product.EnableDineIn, - EnablePickup = product.EnablePickup, - EnableDelivery = product.EnableDelivery, - IsFeatured = product.IsFeatured, - CreatedAt = product.CreatedAt - }; } diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductDetailQueryHandler.cs new file mode 100644 index 0000000..5445bb0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductDetailQueryHandler.cs @@ -0,0 +1,59 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Application.App.Products.Queries; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Products.Handlers; + +/// +/// 商品全量详情查询处理器。 +/// +public sealed class GetProductDetailQueryHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(GetProductDetailQuery request, CancellationToken cancellationToken) + { + // 1. 读取 SPU + var tenantId = tenantProvider.GetCurrentTenantId(); + var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken); + if (product is null) + { + return null; + } + + // 2. 查询子项 + var skusTask = productRepository.GetSkusAsync(product.Id, tenantId, cancellationToken); + var attrGroupsTask = productRepository.GetAttributeGroupsAsync(product.Id, tenantId, cancellationToken); + var attrOptionsTask = productRepository.GetAttributeOptionsAsync(product.Id, tenantId, cancellationToken); + var addonGroupsTask = productRepository.GetAddonGroupsAsync(product.Id, tenantId, cancellationToken); + var addonOptionsTask = productRepository.GetAddonOptionsAsync(product.Id, tenantId, cancellationToken); + var mediaTask = productRepository.GetMediaAssetsAsync(product.Id, tenantId, cancellationToken); + var pricingTask = productRepository.GetPricingRulesAsync(product.Id, tenantId, cancellationToken); + + await Task.WhenAll(skusTask, attrGroupsTask, attrOptionsTask, addonGroupsTask, addonOptionsTask, mediaTask, pricingTask); + + // 3. 组装 DTO + var attrOptions = attrOptionsTask.Result.ToLookup(x => x.AttributeGroupId); + var addonOptions = addonOptionsTask.Result.ToLookup(x => x.AddonGroupId); + + var detail = new ProductDetailDto + { + Product = ProductMapping.ToDto(product), + Skus = skusTask.Result.Select(ProductMapping.ToDto).ToList(), + AttributeGroups = attrGroupsTask.Result + .Select(g => ProductMapping.ToDto(g, attrOptions[g.Id].ToList())) + .ToList(), + AddonGroups = addonGroupsTask.Result + .Select(g => ProductMapping.ToDto(g, addonOptions[g.Id].ToList())) + .ToList(), + MediaAssets = mediaTask.Result.Select(ProductMapping.ToDto).ToList(), + PricingRules = pricingTask.Result.Select(ProductMapping.ToDto).ToList() + }; + + return detail; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/PublishProductCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/PublishProductCommandHandler.cs new file mode 100644 index 0000000..fd71ada --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/PublishProductCommandHandler.cs @@ -0,0 +1,49 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Products.Commands; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Application.App.Products.Queries; +using TakeoutSaaS.Domain.Products.Enums; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Products.Handlers; + +/// +/// 商品上架处理器。 +/// +public sealed class PublishProductCommandHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(PublishProductCommand request, CancellationToken cancellationToken) + { + // 1. 读取商品 + var tenantId = tenantProvider.GetCurrentTenantId(); + var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken); + if (product is null) + { + return null; + } + + // 2. 校验 SKU 可售 + var skus = await productRepository.GetSkusAsync(product.Id, tenantId, cancellationToken); + if (skus.Count == 0) + { + throw new BusinessException(ErrorCodes.Conflict, "请先配置可售 SKU 后再上架"); + } + + // 3. 上架 + product.Status = ProductStatus.OnSale; + await productRepository.UpdateProductAsync(product, cancellationToken); + await productRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("商品上架 {ProductId}", product.Id); + + return ProductMapping.ToDto(product); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/SearchProductsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/SearchProductsQueryHandler.cs index 70e91f2..660a35b 100644 --- a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/SearchProductsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/SearchProductsQueryHandler.cs @@ -22,12 +22,7 @@ public sealed class SearchProductsQueryHandler( public async Task> Handle(SearchProductsQuery request, CancellationToken cancellationToken) { var tenantId = _tenantProvider.GetCurrentTenantId(); - var products = await _productRepository.SearchAsync(tenantId, request.CategoryId, request.Status, cancellationToken); - - if (request.StoreId.HasValue) - { - products = products.Where(x => x.StoreId == request.StoreId.Value).ToList(); - } + var products = await _productRepository.SearchAsync(tenantId, request.StoreId, request.CategoryId, request.Status, cancellationToken); var sorted = ApplySorting(products, request.SortBy, request.SortDescending); var paged = sorted diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/UnpublishProductCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/UnpublishProductCommandHandler.cs new file mode 100644 index 0000000..83fe4c8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/UnpublishProductCommandHandler.cs @@ -0,0 +1,39 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Products.Commands; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Domain.Products.Enums; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Products.Handlers; + +/// +/// 商品下架处理器。 +/// +public sealed class UnpublishProductCommandHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(UnpublishProductCommand request, CancellationToken cancellationToken) + { + // 1. 读取商品 + var tenantId = tenantProvider.GetCurrentTenantId(); + var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken); + if (product is null) + { + return null; + } + + // 2. 下架 + product.Status = ProductStatus.OffShelf; + await productRepository.UpdateProductAsync(product, cancellationToken); + await productRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("商品下架 {ProductId}", product.Id); + + return ProductMapping.ToDto(product); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/ProductMapping.cs b/src/Application/TakeoutSaaS.Application/App/Products/ProductMapping.cs new file mode 100644 index 0000000..77cf89f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/ProductMapping.cs @@ -0,0 +1,133 @@ +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Domain.Products.Entities; + +namespace TakeoutSaaS.Application.App.Products; + +/// +/// 商品映射辅助。 +/// +public static class ProductMapping +{ + /// + /// 映射 SPU DTO。 + /// + /// 商品实体。 + /// DTO。 + public static ProductDto ToDto(Product product) => new() + { + Id = product.Id, + TenantId = product.TenantId, + StoreId = product.StoreId, + CategoryId = product.CategoryId, + SpuCode = product.SpuCode, + Name = product.Name, + Subtitle = product.Subtitle, + Unit = product.Unit, + Price = product.Price, + OriginalPrice = product.OriginalPrice, + StockQuantity = product.StockQuantity, + MaxQuantityPerOrder = product.MaxQuantityPerOrder, + Status = product.Status, + CoverImage = product.CoverImage, + GalleryImages = product.GalleryImages, + Description = product.Description, + EnableDineIn = product.EnableDineIn, + EnablePickup = product.EnablePickup, + EnableDelivery = product.EnableDelivery, + IsFeatured = product.IsFeatured, + CreatedAt = product.CreatedAt + }; + + /// + /// 映射 SKU DTO。 + /// + public static ProductSkuDto ToDto(ProductSku sku) => new() + { + Id = sku.Id, + ProductId = sku.ProductId, + SkuCode = sku.SkuCode, + Barcode = sku.Barcode, + Price = sku.Price, + OriginalPrice = sku.OriginalPrice, + StockQuantity = sku.StockQuantity, + Weight = sku.Weight, + AttributesJson = sku.AttributesJson, + SortOrder = sku.SortOrder + }; + + /// + /// 映射规格组 DTO。 + /// + public static ProductAttributeGroupDto ToDto(ProductAttributeGroup group, IReadOnlyList options) => new() + { + Id = group.Id, + ProductId = group.ProductId, + Name = group.Name, + SelectionType = (int)group.SelectionType, + SortOrder = group.SortOrder, + Options = options.Select(ToDto).ToList() + }; + + /// + /// 映射规格选项 DTO。 + /// + public static ProductAttributeOptionDto ToDto(ProductAttributeOption option) => new() + { + Id = option.Id, + AttributeGroupId = option.AttributeGroupId, + Name = option.Name, + SortOrder = option.SortOrder + }; + + /// + /// 映射加料组 DTO。 + /// + public static ProductAddonGroupDto ToDto(ProductAddonGroup group, IReadOnlyList options) => new() + { + Id = group.Id, + ProductId = group.ProductId, + Name = group.Name, + MinSelect = group.MinSelect ?? 0, + MaxSelect = group.MaxSelect ?? 0, + SortOrder = group.SortOrder, + Options = options.Select(ToDto).ToList() + }; + + /// + /// 映射加料选项 DTO。 + /// + public static ProductAddonOptionDto ToDto(ProductAddonOption option) => new() + { + Id = option.Id, + AddonGroupId = option.AddonGroupId, + Name = option.Name, + ExtraPrice = option.ExtraPrice, + SortOrder = option.SortOrder + }; + + /// + /// 映射媒资 DTO。 + /// + public static ProductMediaAssetDto ToDto(ProductMediaAsset asset) => new() + { + Id = asset.Id, + ProductId = asset.ProductId, + MediaType = asset.MediaType, + Url = asset.Url, + Caption = asset.Caption, + SortOrder = asset.SortOrder + }; + + /// + /// 映射价格策略 DTO。 + /// + public static ProductPricingRuleDto ToDto(ProductPricingRule rule) => new() + { + Id = rule.Id, + ProductId = rule.ProductId, + RuleType = rule.RuleType, + Price = rule.Price, + ConditionsJson = rule.ConditionsJson, + WeekdaysJson = rule.WeekdaysJson + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Queries/GetProductDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Products/Queries/GetProductDetailQuery.cs new file mode 100644 index 0000000..a6e2c19 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Queries/GetProductDetailQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Queries; + +/// +/// 商品全量详情查询。 +/// +public sealed record GetProductDetailQuery : IRequest +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Validators/PublishProductCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Products/Validators/PublishProductCommandValidator.cs new file mode 100644 index 0000000..4964237 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Validators/PublishProductCommandValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Products.Commands; + +namespace TakeoutSaaS.Application.App.Products.Validators; + +/// +/// 上架商品命令验证器。 +/// +public sealed class PublishProductCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public PublishProductCommandValidator() + { + RuleFor(x => x.ProductId).GreaterThan(0); + RuleFor(x => x.Reason).MaximumLength(256); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Validators/UnpublishProductCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Products/Validators/UnpublishProductCommandValidator.cs new file mode 100644 index 0000000..1ee952c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Validators/UnpublishProductCommandValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Products.Commands; + +namespace TakeoutSaaS.Application.App.Products.Validators; + +/// +/// 下架商品命令验证器。 +/// +public sealed class UnpublishProductCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public UnpublishProductCommandValidator() + { + RuleFor(x => x.ProductId).GreaterThan(0); + RuleFor(x => x.Reason).MaximumLength(256); + } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs b/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs index 6f01804..833f555 100644 --- a/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs @@ -19,7 +19,7 @@ public interface IProductRepository /// /// 按分类与状态筛选商品列表。 /// - Task> SearchAsync(long tenantId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default); + Task> SearchAsync(long tenantId, long? storeId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default); /// /// 获取租户下的商品分类。 diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs index 65666bb..41d6ace 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs @@ -27,12 +27,17 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR } /// - public async Task> SearchAsync(long tenantId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default) + public async Task> SearchAsync(long tenantId, long? storeId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default) { var query = context.Products .AsNoTracking() .Where(x => x.TenantId == tenantId); + if (storeId.HasValue) + { + query = query.Where(x => x.StoreId == storeId.Value); + } + if (categoryId.HasValue) { query = query.Where(x => x.CategoryId == categoryId.Value); From b8d93337f29702efa0e2c2b946bb4304a611fd2b Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Thu, 4 Dec 2025 10:42:58 +0800 Subject: [PATCH 20/30] =?UTF-8?q?feat:=20=E8=8F=9C=E5=93=81=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E6=9F=A5=E8=AF=A2=E4=B8=8E=E5=AD=90=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/ProductsController.cs | 85 +++++++++++ .../appsettings.Seed.Development.json | 43 ++++++ .../Controllers/MenusController.cs | 38 +++++ .../Commands/ReplaceProductAddonsCommand.cs | 20 +++ .../ReplaceProductAttributesCommand.cs | 20 +++ .../Commands/ReplaceProductMediaCommand.cs | 20 +++ .../ReplaceProductPricingRulesCommand.cs | 20 +++ .../Commands/ReplaceProductSkusCommand.cs | 20 +++ .../Products/Dto/ProductCategoryMenuDto.cs | 48 +++++++ .../App/Products/Dto/StoreMenuDto.cs | 33 +++++ .../Handlers/GetProductDetailQueryHandler.cs | 20 +-- .../Handlers/GetStoreMenuQueryHandler.cs | 136 ++++++++++++++++++ .../ReplaceProductAddonsCommandHandler.cs | 74 ++++++++++ .../ReplaceProductAttributesCommandHandler.cs | 77 ++++++++++ .../ReplaceProductMediaCommandHandler.cs | 51 +++++++ ...eplaceProductPricingRulesCommandHandler.cs | 52 +++++++ .../ReplaceProductSkusCommandHandler.cs | 61 ++++++++ .../App/Products/Queries/GetStoreMenuQuery.cs | 21 +++ .../ReplaceProductAddonsCommandValidator.cs | 31 ++++ ...eplaceProductAttributesCommandValidator.cs | 28 ++++ .../ReplaceProductMediaCommandValidator.cs | 24 ++++ ...laceProductPricingRulesCommandValidator.cs | 23 +++ .../ReplaceProductSkusCommandValidator.cs | 26 ++++ .../Repositories/IProductRepository.cs | 43 +++++- .../App/Repositories/EfProductRepository.cs | 129 ++++++++++++++++- 25 files changed, 1133 insertions(+), 10 deletions(-) create mode 100644 src/Api/TakeoutSaaS.MiniApi/Controllers/MenusController.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductAddonsCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductAttributesCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductMediaCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductPricingRulesCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductSkusCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductCategoryMenuDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Dto/StoreMenuDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetStoreMenuQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductAddonsCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductAttributesCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductMediaCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductPricingRulesCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductSkusCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Queries/GetStoreMenuQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductAddonsCommandValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductAttributesCommandValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductMediaCommandValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductPricingRulesCommandValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductSkusCommandValidator.cs diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs index 4fd43b3..3eb9fe1 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs @@ -171,4 +171,89 @@ public sealed class ProductsController(IMediator mediator) : BaseApiController ? ApiResponse.Error(ErrorCodes.NotFound, "商品不存在") : ApiResponse.Ok(result); } + + /// + /// 替换商品 SKU。 + /// + [HttpPut("{productId:long}/skus")] + [PermissionAuthorize("product-sku:update")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> ReplaceSkus(long productId, [FromBody] ReplaceProductSkusCommand command, CancellationToken cancellationToken) + { + if (command.ProductId == 0) + { + command = command with { ProductId = productId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 替换商品规格。 + /// + [HttpPut("{productId:long}/attributes")] + [PermissionAuthorize("product-attr:update")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> ReplaceAttributes(long productId, [FromBody] ReplaceProductAttributesCommand command, CancellationToken cancellationToken) + { + if (command.ProductId == 0) + { + command = command with { ProductId = productId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 替换商品加料。 + /// + [HttpPut("{productId:long}/addons")] + [PermissionAuthorize("product-addon:update")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> ReplaceAddons(long productId, [FromBody] ReplaceProductAddonsCommand command, CancellationToken cancellationToken) + { + if (command.ProductId == 0) + { + command = command with { ProductId = productId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 替换商品媒资。 + /// + [HttpPut("{productId:long}/media")] + [PermissionAuthorize("product-media:update")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> ReplaceMedia(long productId, [FromBody] ReplaceProductMediaCommand command, CancellationToken cancellationToken) + { + if (command.ProductId == 0) + { + command = command with { ProductId = productId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 替换商品价格策略。 + /// + [HttpPut("{productId:long}/pricing-rules")] + [PermissionAuthorize("product-pricing:update")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> ReplacePricingRules(long productId, [FromBody] ReplaceProductPricingRulesCommand command, CancellationToken cancellationToken) + { + if (command.ProductId == 0) + { + command = command with { ProductId = productId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse>.Ok(result); + } } diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json index 47d4125..1af6231 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json @@ -113,6 +113,16 @@ "product:update", "product:delete", "product:publish", + "product-sku:read", + "product-sku:update", + "product-attr:read", + "product-attr:update", + "product-addon:read", + "product-addon:update", + "product-media:read", + "product-media:update", + "product-pricing:read", + "product-pricing:update", "order:create", "order:read", "order:update", @@ -197,6 +207,17 @@ "product:read", "product:update", "product:delete", + "product:publish", + "product-sku:read", + "product-sku:update", + "product-attr:read", + "product-attr:update", + "product-addon:read", + "product-addon:update", + "product-media:read", + "product-media:update", + "product-pricing:read", + "product-pricing:update", "order:create", "order:read", "order:update", @@ -242,6 +263,17 @@ "product:create", "product:read", "product:update", + "product:publish", + "product-sku:read", + "product-sku:update", + "product-attr:read", + "product-attr:update", + "product-addon:read", + "product-addon:update", + "product-media:read", + "product-media:update", + "product-pricing:read", + "product-pricing:update", "order:create", "order:read", "order:update", @@ -331,6 +363,17 @@ "product:read", "product:update", "product:delete", + "product:publish", + "product-sku:read", + "product-sku:update", + "product-attr:read", + "product-attr:update", + "product-addon:read", + "product-addon:update", + "product-media:read", + "product-media:update", + "product-pricing:read", + "product-pricing:update", "order:create", "order:read", "order:update", diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/MenusController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/MenusController.cs new file mode 100644 index 0000000..549db99 --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/MenusController.cs @@ -0,0 +1,38 @@ +using System; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Application.App.Products.Queries; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.MiniApi.Controllers; + +/// +/// 小程序端菜单查询。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/mini/v{version:apiVersion}/stores/{storeId:long}/menu")] +public sealed class MenusController(IMediator mediator) : BaseApiController +{ + /// + /// 获取门店菜单(含分类与商品详情)。 + /// + [HttpGet] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> GetMenu(long storeId, [FromQuery] DateTime? updatedAfter, CancellationToken cancellationToken) + { + // 1. 组装查询 + var query = new GetStoreMenuQuery + { + StoreId = storeId, + UpdatedAfter = updatedAfter + }; + // 2. 拉取菜单 + var result = await mediator.Send(query, cancellationToken); + return ApiResponse.Ok(result); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductAddonsCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductAddonsCommand.cs new file mode 100644 index 0000000..41ec401 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductAddonsCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Commands; + +/// +/// 替换商品加料命令。 +/// +public sealed record ReplaceProductAddonsCommand : IRequest> +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } + + /// + /// 加料组。 + /// + public IReadOnlyList AddonGroups { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductAttributesCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductAttributesCommand.cs new file mode 100644 index 0000000..4ec120f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductAttributesCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Commands; + +/// +/// 替换商品规格命令。 +/// +public sealed record ReplaceProductAttributesCommand : IRequest> +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } + + /// + /// 规格组。 + /// + public IReadOnlyList AttributeGroups { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductMediaCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductMediaCommand.cs new file mode 100644 index 0000000..6e099a0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductMediaCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Commands; + +/// +/// 替换商品媒资命令。 +/// +public sealed record ReplaceProductMediaCommand : IRequest> +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } + + /// + /// 媒资列表。 + /// + public IReadOnlyList MediaAssets { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductPricingRulesCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductPricingRulesCommand.cs new file mode 100644 index 0000000..067b11f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductPricingRulesCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Commands; + +/// +/// 替换商品价格策略命令。 +/// +public sealed record ReplaceProductPricingRulesCommand : IRequest> +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } + + /// + /// 价格策略。 + /// + public IReadOnlyList PricingRules { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductSkusCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductSkusCommand.cs new file mode 100644 index 0000000..8f82fdf --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductSkusCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Commands; + +/// +/// 替换商品 SKU 命令。 +/// +public sealed record ReplaceProductSkusCommand : IRequest> +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } + + /// + /// SKU 列表。 + /// + public IReadOnlyList Skus { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductCategoryMenuDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductCategoryMenuDto.cs new file mode 100644 index 0000000..d758957 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductCategoryMenuDto.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// 门店菜单分类 DTO。 +/// +public sealed record ProductCategoryMenuDto +{ + /// + /// 分类 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 分类名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 分类描述。 + /// + public string? Description { get; init; } + + /// + /// 排序。 + /// + public int SortOrder { get; init; } + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; init; } + + /// + /// 分类下商品列表。 + /// + public IReadOnlyList Products { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/StoreMenuDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/StoreMenuDto.cs new file mode 100644 index 0000000..5429d43 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/StoreMenuDto.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// 门店菜单数据传输对象。 +/// +public sealed record StoreMenuDto +{ + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 分类与商品集合。 + /// + public IReadOnlyList Categories { get; init; } = []; + + /// + /// 菜单生成时间(UTC)。 + /// + public DateTime GeneratedAt { get; init; } + + /// + /// 客户端请求的增量时间(UTC)。 + /// + public DateTime? UpdatedAfter { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductDetailQueryHandler.cs index 5445bb0..828f1c8 100644 --- a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductDetailQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductDetailQueryHandler.cs @@ -37,21 +37,25 @@ public sealed class GetProductDetailQueryHandler( await Task.WhenAll(skusTask, attrGroupsTask, attrOptionsTask, addonGroupsTask, addonOptionsTask, mediaTask, pricingTask); // 3. 组装 DTO - var attrOptions = attrOptionsTask.Result.ToLookup(x => x.AttributeGroupId); - var addonOptions = addonOptionsTask.Result.ToLookup(x => x.AddonGroupId); - + var skus = await skusTask; + var attrGroups = await attrGroupsTask; + var attrOptions = (await attrOptionsTask).ToLookup(x => x.AttributeGroupId); + var addonGroups = await addonGroupsTask; + var addonOptions = (await addonOptionsTask).ToLookup(x => x.AddonGroupId); + var mediaAssets = await mediaTask; + var pricingRules = await pricingTask; var detail = new ProductDetailDto { Product = ProductMapping.ToDto(product), - Skus = skusTask.Result.Select(ProductMapping.ToDto).ToList(), - AttributeGroups = attrGroupsTask.Result + Skus = skus.Select(ProductMapping.ToDto).ToList(), + AttributeGroups = attrGroups .Select(g => ProductMapping.ToDto(g, attrOptions[g.Id].ToList())) .ToList(), - AddonGroups = addonGroupsTask.Result + AddonGroups = addonGroups .Select(g => ProductMapping.ToDto(g, addonOptions[g.Id].ToList())) .ToList(), - MediaAssets = mediaTask.Result.Select(ProductMapping.ToDto).ToList(), - PricingRules = pricingTask.Result.Select(ProductMapping.ToDto).ToList() + MediaAssets = mediaAssets.Select(ProductMapping.ToDto).ToList(), + PricingRules = pricingRules.Select(ProductMapping.ToDto).ToList() }; return detail; diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetStoreMenuQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetStoreMenuQueryHandler.cs new file mode 100644 index 0000000..bde1465 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetStoreMenuQueryHandler.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Application.App.Products.Queries; +using TakeoutSaaS.Domain.Products.Entities; +using TakeoutSaaS.Domain.Products.Enums; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Products.Handlers; + +/// +/// 门店菜单查询处理器。 +/// +public sealed class GetStoreMenuQueryHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(GetStoreMenuQuery request, CancellationToken cancellationToken) + { + // 1. 准备上下文 + var tenantId = tenantProvider.GetCurrentTenantId(); + var updatedAfterUtc = request.UpdatedAfter?.ToUniversalTime(); + // 2. 获取分类 + var categories = await productRepository.GetCategoriesByStoreAsync(tenantId, request.StoreId, true, cancellationToken); + // 3. 读取上架商品(支持增量) + var products = await productRepository.SearchAsync(tenantId, request.StoreId, null, ProductStatus.OnSale, cancellationToken, updatedAfterUtc); + if (products.Count == 0) + { + logger.LogInformation("门店 {StoreId} 没有上架商品,返回空菜单", request.StoreId); + return new StoreMenuDto + { + StoreId = request.StoreId, + GeneratedAt = DateTime.UtcNow, + UpdatedAfter = updatedAfterUtc, + Categories = categories + .OrderBy(x => x.SortOrder) + .Select(category => new ProductCategoryMenuDto + { + Id = category.Id, + StoreId = category.StoreId, + Name = category.Name, + Description = category.Description, + SortOrder = category.SortOrder, + IsEnabled = category.IsEnabled, + Products = [] + }) + .ToList() + }; + } + + // 4. 并发加载子表数据 + var productIds = products.Select(x => x.Id).ToList(); + var skusTask = productRepository.GetSkusByProductIdsAsync(productIds, tenantId, cancellationToken); + var attributeGroupsTask = productRepository.GetAttributeGroupsByProductIdsAsync(productIds, tenantId, cancellationToken); + var addonGroupsTask = productRepository.GetAddonGroupsByProductIdsAsync(productIds, tenantId, cancellationToken); + var mediaTask = productRepository.GetMediaAssetsByProductIdsAsync(productIds, tenantId, cancellationToken); + var pricingTask = productRepository.GetPricingRulesByProductIdsAsync(productIds, tenantId, cancellationToken); + await Task.WhenAll(skusTask, attributeGroupsTask, addonGroupsTask, mediaTask, pricingTask); + var attributeGroups = await attributeGroupsTask; + var addonGroups = await addonGroupsTask; + // 批量读取规格与加料选项 + var attributeOptionsTask = attributeGroups.Count == 0 + ? Task.FromResult>(Array.Empty()) + : productRepository.GetAttributeOptionsByGroupIdsAsync(attributeGroups.Select(x => x.Id).ToList(), tenantId, cancellationToken); + var addonOptionsTask = addonGroups.Count == 0 + ? Task.FromResult>(Array.Empty()) + : productRepository.GetAddonOptionsByGroupIdsAsync(addonGroups.Select(x => x.Id).ToList(), tenantId, cancellationToken); + await Task.WhenAll(attributeOptionsTask, addonOptionsTask); + + // 5. 建立查找表 + var skuLookup = (await skusTask).ToLookup(x => x.ProductId); + var attrGroupLookup = attributeGroups.ToLookup(x => x.ProductId); + var attrOptionLookup = (await attributeOptionsTask).ToLookup(x => x.AttributeGroupId); + var addonGroupLookup = addonGroups.ToLookup(x => x.ProductId); + var addonOptionLookup = (await addonOptionsTask).ToLookup(x => x.AddonGroupId); + var mediaLookup = (await mediaTask).ToLookup(x => x.ProductId); + var pricingLookup = (await pricingTask).ToLookup(x => x.ProductId); + // 6. 组装商品详情 + var productDetails = products.ToDictionary( + product => product.Id, + product => + { + var attributeDtos = attrGroupLookup[product.Id] + .Select(group => ProductMapping.ToDto(group, attrOptionLookup[group.Id].ToList())) + .ToList(); + var addonDtos = addonGroupLookup[product.Id] + .Select(group => ProductMapping.ToDto(group, addonOptionLookup[group.Id].ToList())) + .ToList(); + return new ProductDetailDto + { + Product = ProductMapping.ToDto(product), + Skus = skuLookup[product.Id].Select(ProductMapping.ToDto).ToList(), + AttributeGroups = attributeDtos, + AddonGroups = addonDtos, + MediaAssets = mediaLookup[product.Id].Select(ProductMapping.ToDto).ToList(), + PricingRules = pricingLookup[product.Id].Select(ProductMapping.ToDto).ToList() + }; + }); + // 7. 组装分类菜单 + var productsByCategory = products.ToLookup(x => x.CategoryId); + var categoryMenu = categories + .OrderBy(x => x.SortOrder) + .Select(category => + { + var categoryProducts = productsByCategory[category.Id] + .Select(p => productDetails[p.Id]) + .ToList(); + return new ProductCategoryMenuDto + { + Id = category.Id, + StoreId = category.StoreId, + Name = category.Name, + Description = category.Description, + SortOrder = category.SortOrder, + IsEnabled = category.IsEnabled, + Products = categoryProducts + }; + }) + .ToList(); + return new StoreMenuDto + { + StoreId = request.StoreId, + GeneratedAt = DateTime.UtcNow, + UpdatedAfter = updatedAfterUtc, + Categories = categoryMenu + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductAddonsCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductAddonsCommandHandler.cs new file mode 100644 index 0000000..af61144 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductAddonsCommandHandler.cs @@ -0,0 +1,74 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Products.Commands; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Domain.Products.Entities; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Products.Handlers; + +/// +/// 替换加料处理器。 +/// +public sealed class ReplaceProductAddonsCommandHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler> +{ + /// + public async Task> Handle(ReplaceProductAddonsCommand request, CancellationToken cancellationToken) + { + // 1. 校验商品 + var tenantId = tenantProvider.GetCurrentTenantId(); + var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken); + if (product is null) + { + throw new BusinessException(ErrorCodes.NotFound, "商品不存在"); + } + + // 2. 校验组名唯一 + var names = request.AddonGroups.Select(x => x.Name.Trim()).ToList(); + if (names.Count != names.Distinct(StringComparer.OrdinalIgnoreCase).Count()) + { + throw new BusinessException(ErrorCodes.Conflict, "加料组名称重复"); + } + + // 3. 替换 + await productRepository.RemoveAddonGroupsAsync(request.ProductId, tenantId, cancellationToken); + // 重新插入组 + var groupEntities = request.AddonGroups.Select(g => new ProductAddonGroup + { + ProductId = request.ProductId, + Name = g.Name.Trim(), + MinSelect = g.MinSelect, + MaxSelect = g.MaxSelect, + SortOrder = g.SortOrder + }).ToList(); + await productRepository.AddAddonGroupsAsync(groupEntities, [], cancellationToken); + await productRepository.SaveChangesAsync(cancellationToken); + // 重新建立组与请求的映射 + var groupIdLookup = groupEntities.Zip(request.AddonGroups, (entity, dto) => (entity, dto)) + .ToDictionary(x => x.dto, x => x.entity.Id); + // 构建选项实体 + var optionEntities = request.AddonGroups + .SelectMany(dto => dto.Options.Select(o => new ProductAddonOption + { + AddonGroupId = groupIdLookup[dto], + Name = o.Name.Trim(), + ExtraPrice = o.ExtraPrice, + SortOrder = o.SortOrder + })) + .ToList(); + await productRepository.AddAddonGroupsAsync([], optionEntities, cancellationToken); + await productRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("替换商品 {ProductId} 加料组 {Count} 个", request.ProductId, groupEntities.Count); + + return groupEntities + .Select(g => ProductMapping.ToDto(g, optionEntities.Where(o => o.AddonGroupId == g.Id).ToList())) + .ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductAttributesCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductAttributesCommandHandler.cs new file mode 100644 index 0000000..9cef4d1 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductAttributesCommandHandler.cs @@ -0,0 +1,77 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Products.Commands; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Domain.Products.Entities; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Products.Handlers; + +/// +/// 替换规格处理器。 +/// +public sealed class ReplaceProductAttributesCommandHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler> +{ + /// + public async Task> Handle(ReplaceProductAttributesCommand request, CancellationToken cancellationToken) + { + // 1. 校验商品 + var tenantId = tenantProvider.GetCurrentTenantId(); + var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken); + if (product is null) + { + throw new BusinessException(ErrorCodes.NotFound, "商品不存在"); + } + + // 2. 组名唯一 + var groupNames = request.AttributeGroups.Select(x => x.Name.Trim()).ToList(); + if (groupNames.Count != groupNames.Distinct(StringComparer.OrdinalIgnoreCase).Count()) + { + throw new BusinessException(ErrorCodes.Conflict, "规格组名称重复"); + } + + // 3. 替换 + await productRepository.RemoveAttributeGroupsAsync(request.ProductId, tenantId, cancellationToken); + + var groupEntities = request.AttributeGroups.Select(g => new ProductAttributeGroup + { + ProductId = request.ProductId, + Name = g.Name.Trim(), + SelectionType = (Domain.Products.Enums.AttributeSelectionType)g.SelectionType, + SortOrder = g.SortOrder + }).ToList(); + + // 4. 持久化(分批保障 FK 正确) + await productRepository.AddAttributeGroupsAsync(groupEntities, [], cancellationToken); + await productRepository.SaveChangesAsync(cancellationToken); + + // 重新建立选项的 GroupId 映射 + var groupIdLookup = groupEntities.Zip(request.AttributeGroups, (entity, dto) => (entity, dto)) + .ToDictionary(x => x.dto, x => x.entity.Id); + + var optionEntities = request.AttributeGroups + .SelectMany(dto => dto.Options.Select(o => new ProductAttributeOption + { + AttributeGroupId = groupIdLookup[dto], + Name = o.Name.Trim(), + SortOrder = o.SortOrder + })) + .ToList(); + + await productRepository.AddAttributeGroupsAsync([], optionEntities, cancellationToken); + await productRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("替换商品 {ProductId} 规格组 {GroupCount} 个", request.ProductId, groupEntities.Count); + + // 5. 返回 DTO + return groupEntities + .Select(g => ProductMapping.ToDto(g, optionEntities.Where(o => o.AttributeGroupId == g.Id).ToList())) + .ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductMediaCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductMediaCommandHandler.cs new file mode 100644 index 0000000..4c704f4 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductMediaCommandHandler.cs @@ -0,0 +1,51 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Products.Commands; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Domain.Products.Entities; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Products.Handlers; + +/// +/// 替换媒资处理器。 +/// +public sealed class ReplaceProductMediaCommandHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler> +{ + /// + public async Task> Handle(ReplaceProductMediaCommand request, CancellationToken cancellationToken) + { + // 1. 校验商品 + var tenantId = tenantProvider.GetCurrentTenantId(); + var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken); + if (product is null) + { + throw new BusinessException(ErrorCodes.NotFound, "商品不存在"); + } + + // 2. 替换 + await productRepository.RemoveMediaAssetsAsync(request.ProductId, tenantId, cancellationToken); + + var assets = request.MediaAssets.Select(a => new ProductMediaAsset + { + ProductId = request.ProductId, + MediaType = a.MediaType, + Url = a.Url.Trim(), + Caption = a.Caption?.Trim(), + SortOrder = a.SortOrder + }).ToList(); + + await productRepository.AddMediaAssetsAsync(assets, cancellationToken); + await productRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("替换商品 {ProductId} 媒资 {Count} 条", request.ProductId, assets.Count); + + return assets.Select(ProductMapping.ToDto).ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductPricingRulesCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductPricingRulesCommandHandler.cs new file mode 100644 index 0000000..8c02739 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductPricingRulesCommandHandler.cs @@ -0,0 +1,52 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Products.Commands; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Domain.Products.Entities; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Products.Handlers; + +/// +/// 替换价格策略处理器。 +/// +public sealed class ReplaceProductPricingRulesCommandHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler> +{ + /// + public async Task> Handle(ReplaceProductPricingRulesCommand request, CancellationToken cancellationToken) + { + // 1. 校验商品 + var tenantId = tenantProvider.GetCurrentTenantId(); + var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken); + if (product is null) + { + throw new BusinessException(ErrorCodes.NotFound, "商品不存在"); + } + + // 2. 替换 + await productRepository.RemovePricingRulesAsync(request.ProductId, tenantId, cancellationToken); + + var rules = request.PricingRules.Select(r => new ProductPricingRule + { + ProductId = request.ProductId, + RuleType = r.RuleType, + ConditionsJson = r.ConditionsJson.Trim(), + Price = r.Price, + WeekdaysJson = r.WeekdaysJson, + SortOrder = 0 + }).ToList(); + + await productRepository.AddPricingRulesAsync(rules, cancellationToken); + await productRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("替换商品 {ProductId} 价格策略 {Count} 条", request.ProductId, rules.Count); + + return rules.Select(ProductMapping.ToDto).ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductSkusCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductSkusCommandHandler.cs new file mode 100644 index 0000000..9f62d45 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductSkusCommandHandler.cs @@ -0,0 +1,61 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Products.Commands; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Domain.Products.Entities; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Products.Handlers; + +/// +/// 替换 SKU 处理器。 +/// +public sealed class ReplaceProductSkusCommandHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler> +{ + /// + public async Task> Handle(ReplaceProductSkusCommand request, CancellationToken cancellationToken) + { + // 1. 校验商品存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken); + if (product is null) + { + throw new BusinessException(ErrorCodes.NotFound, "商品不存在"); + } + + // 2. 校验 SKU 唯一性 + var codes = request.Skus.Select(x => x.SkuCode.Trim()).ToList(); + if (codes.Count != codes.Distinct(StringComparer.OrdinalIgnoreCase).Count()) + { + throw new BusinessException(ErrorCodes.Conflict, "SKU 编码重复"); + } + + // 3. 替换 + await productRepository.RemoveSkusAsync(request.ProductId, tenantId, cancellationToken); + var entities = request.Skus.Select(x => new ProductSku + { + ProductId = request.ProductId, + SkuCode = x.SkuCode.Trim(), + Barcode = x.Barcode?.Trim(), + Price = x.Price, + OriginalPrice = x.OriginalPrice, + StockQuantity = x.StockQuantity, + Weight = x.Weight, + AttributesJson = x.AttributesJson ?? string.Empty, + SortOrder = x.SortOrder + }).ToList(); + + await productRepository.AddSkusAsync(entities, cancellationToken); + await productRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("替换商品 {ProductId} 的 SKU 数量 {Count}", request.ProductId, entities.Count); + + return entities.Select(ProductMapping.ToDto).ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Queries/GetStoreMenuQuery.cs b/src/Application/TakeoutSaaS.Application/App/Products/Queries/GetStoreMenuQuery.cs new file mode 100644 index 0000000..472806b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Queries/GetStoreMenuQuery.cs @@ -0,0 +1,21 @@ +using System; +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Queries; + +/// +/// 获取门店菜单查询。 +/// +public sealed record GetStoreMenuQuery : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 增量时间(UTC)。 + /// + public DateTime? UpdatedAfter { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductAddonsCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductAddonsCommandValidator.cs new file mode 100644 index 0000000..37f52a8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductAddonsCommandValidator.cs @@ -0,0 +1,31 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Products.Commands; + +namespace TakeoutSaaS.Application.App.Products.Validators; + +/// +/// 替换加料验证器。 +/// +public sealed class ReplaceProductAddonsCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public ReplaceProductAddonsCommandValidator() + { + RuleFor(x => x.ProductId).GreaterThan(0); + RuleForEach(x => x.AddonGroups).ChildRules(group => + { + group.RuleFor(x => x.Name).NotEmpty().MaximumLength(64); + group.RuleFor(x => x.MinSelect).GreaterThanOrEqualTo(0); + group.RuleFor(x => x.MaxSelect).GreaterThanOrEqualTo(x => x.MinSelect); + group.RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); + group.RuleForEach(x => x.Options).ChildRules(opt => + { + opt.RuleFor(x => x.Name).NotEmpty().MaximumLength(64); + opt.RuleFor(x => x.ExtraPrice).GreaterThanOrEqualTo(0).When(x => x.ExtraPrice.HasValue); + opt.RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); + }); + }); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductAttributesCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductAttributesCommandValidator.cs new file mode 100644 index 0000000..7cc5e10 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductAttributesCommandValidator.cs @@ -0,0 +1,28 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Products.Commands; + +namespace TakeoutSaaS.Application.App.Products.Validators; + +/// +/// 替换规格验证器。 +/// +public sealed class ReplaceProductAttributesCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public ReplaceProductAttributesCommandValidator() + { + RuleFor(x => x.ProductId).GreaterThan(0); + RuleForEach(x => x.AttributeGroups).ChildRules(group => + { + group.RuleFor(x => x.Name).NotEmpty().MaximumLength(64); + group.RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); + group.RuleForEach(x => x.Options).ChildRules(opt => + { + opt.RuleFor(x => x.Name).NotEmpty().MaximumLength(64); + opt.RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); + }); + }); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductMediaCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductMediaCommandValidator.cs new file mode 100644 index 0000000..fa27cb7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductMediaCommandValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Products.Commands; + +namespace TakeoutSaaS.Application.App.Products.Validators; + +/// +/// 替换媒资验证器。 +/// +public sealed class ReplaceProductMediaCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public ReplaceProductMediaCommandValidator() + { + RuleFor(x => x.ProductId).GreaterThan(0); + RuleForEach(x => x.MediaAssets).ChildRules(asset => + { + asset.RuleFor(x => x.Url).NotEmpty().MaximumLength(512); + asset.RuleFor(x => x.Caption).MaximumLength(256); + asset.RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); + }); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductPricingRulesCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductPricingRulesCommandValidator.cs new file mode 100644 index 0000000..5cdac59 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductPricingRulesCommandValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Products.Commands; + +namespace TakeoutSaaS.Application.App.Products.Validators; + +/// +/// 替换价格策略验证器。 +/// +public sealed class ReplaceProductPricingRulesCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public ReplaceProductPricingRulesCommandValidator() + { + RuleFor(x => x.ProductId).GreaterThan(0); + RuleForEach(x => x.PricingRules).ChildRules(rule => + { + rule.RuleFor(x => x.Price).GreaterThan(0); + rule.RuleFor(x => x.ConditionsJson).NotEmpty(); + }); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductSkusCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductSkusCommandValidator.cs new file mode 100644 index 0000000..7e4a387 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductSkusCommandValidator.cs @@ -0,0 +1,26 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Products.Commands; + +namespace TakeoutSaaS.Application.App.Products.Validators; + +/// +/// 替换 SKU 验证器。 +/// +public sealed class ReplaceProductSkusCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public ReplaceProductSkusCommandValidator() + { + RuleFor(x => x.ProductId).GreaterThan(0); + RuleForEach(x => x.Skus).ChildRules(sku => + { + sku.RuleFor(x => x.SkuCode).NotEmpty().MaximumLength(64); + sku.RuleFor(x => x.Price).GreaterThan(0); + sku.RuleFor(x => x.OriginalPrice).GreaterThan(0).When(x => x.OriginalPrice.HasValue); + sku.RuleFor(x => x.StockQuantity).GreaterThanOrEqualTo(0).When(x => x.StockQuantity.HasValue); + sku.RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); + }); + } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs b/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs index 833f555..451c535 100644 --- a/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -19,48 +20,88 @@ public interface IProductRepository /// /// 按分类与状态筛选商品列表。 /// - Task> SearchAsync(long tenantId, long? storeId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default); + Task> SearchAsync(long tenantId, long? storeId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default, DateTime? updatedAfter = null); /// /// 获取租户下的商品分类。 /// Task> GetCategoriesAsync(long tenantId, CancellationToken cancellationToken = default); + /// + /// 获取门店商品分类。 + /// + Task> GetCategoriesByStoreAsync(long tenantId, long storeId, bool onlyEnabled = true, CancellationToken cancellationToken = default); + /// /// 获取商品 SKU。 /// Task> GetSkusAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 批量获取商品 SKU。 + /// + Task> GetSkusByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取商品加料组与选项。 /// Task> GetAddonGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 批量获取商品加料组。 + /// + Task> GetAddonGroupsByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取商品加料选项。 /// Task> GetAddonOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 批量获取商品加料选项。 + /// + Task> GetAddonOptionsByGroupIdsAsync(IReadOnlyCollection addonGroupIds, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取商品规格组与选项。 /// Task> GetAttributeGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 批量获取商品规格组。 + /// + Task> GetAttributeGroupsByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取商品规格选项。 /// Task> GetAttributeOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 批量获取商品规格选项。 + /// + Task> GetAttributeOptionsByGroupIdsAsync(IReadOnlyCollection attributeGroupIds, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取商品媒资。 /// Task> GetMediaAssetsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 批量获取商品媒资。 + /// + Task> GetMediaAssetsByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取商品定价规则。 /// Task> GetPricingRulesAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 批量获取商品定价规则。 + /// + Task> GetPricingRulesByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default); + /// /// 新增分类。 /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs index 41d6ace..8d0ae9c 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Products.Entities; @@ -27,7 +28,7 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR } /// - public async Task> SearchAsync(long tenantId, long? storeId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default) + public async Task> SearchAsync(long tenantId, long? storeId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default, DateTime? updatedAfter = null) { var query = context.Products .AsNoTracking() @@ -48,6 +49,11 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR query = query.Where(x => x.Status == status.Value); } + if (updatedAfter.HasValue) + { + query = query.Where(x => (x.UpdatedAt ?? x.CreatedAt) >= updatedAfter.Value); + } + var products = await query .OrderBy(x => x.Name) .ToListAsync(cancellationToken); @@ -67,6 +73,22 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR return categories; } + /// + public async Task> GetCategoriesByStoreAsync(long tenantId, long storeId, bool onlyEnabled = true, CancellationToken cancellationToken = default) + { + var query = context.ProductCategories + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.StoreId == storeId); + if (onlyEnabled) + { + query = query.Where(x => x.IsEnabled); + } + var categories = await query + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + return categories; + } + /// public async Task> GetSkusAsync(long productId, long tenantId, CancellationToken cancellationToken = default) { @@ -79,6 +101,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR return skus; } + /// + public async Task> GetSkusByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default) + { + if (productIds.Count == 0) + { + return Array.Empty(); + } + var skus = await context.ProductSkus + .AsNoTracking() + .Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId)) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + return skus; + } + /// public async Task> GetAddonGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default) { @@ -91,6 +128,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR return groups; } + /// + public async Task> GetAddonGroupsByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default) + { + if (productIds.Count == 0) + { + return Array.Empty(); + } + var groups = await context.ProductAddonGroups + .AsNoTracking() + .Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId)) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + return groups; + } + /// public async Task> GetAddonOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default) { @@ -114,6 +166,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR return options; } + /// + public async Task> GetAddonOptionsByGroupIdsAsync(IReadOnlyCollection addonGroupIds, long tenantId, CancellationToken cancellationToken = default) + { + if (addonGroupIds.Count == 0) + { + return Array.Empty(); + } + var options = await context.ProductAddonOptions + .AsNoTracking() + .Where(x => x.TenantId == tenantId && addonGroupIds.Contains(x.AddonGroupId)) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + return options; + } + /// public async Task> GetAttributeGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default) { @@ -126,6 +193,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR return groups; } + /// + public async Task> GetAttributeGroupsByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default) + { + if (productIds.Count == 0) + { + return Array.Empty(); + } + var groups = await context.ProductAttributeGroups + .AsNoTracking() + .Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId)) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + return groups; + } + /// public async Task> GetAttributeOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default) { @@ -149,6 +231,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR return options; } + /// + public async Task> GetAttributeOptionsByGroupIdsAsync(IReadOnlyCollection attributeGroupIds, long tenantId, CancellationToken cancellationToken = default) + { + if (attributeGroupIds.Count == 0) + { + return Array.Empty(); + } + var options = await context.ProductAttributeOptions + .AsNoTracking() + .Where(x => x.TenantId == tenantId && attributeGroupIds.Contains(x.AttributeGroupId)) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + return options; + } + /// public async Task> GetMediaAssetsAsync(long productId, long tenantId, CancellationToken cancellationToken = default) { @@ -161,6 +258,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR return assets; } + /// + public async Task> GetMediaAssetsByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default) + { + if (productIds.Count == 0) + { + return Array.Empty(); + } + var assets = await context.ProductMediaAssets + .AsNoTracking() + .Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId)) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + return assets; + } + /// public async Task> GetPricingRulesAsync(long productId, long tenantId, CancellationToken cancellationToken = default) { @@ -173,6 +285,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR return rules; } + /// + public async Task> GetPricingRulesByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default) + { + if (productIds.Count == 0) + { + return Array.Empty(); + } + var rules = await context.ProductPricingRules + .AsNoTracking() + .Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId)) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + return rules; + } + /// public Task AddCategoryAsync(ProductCategory category, CancellationToken cancellationToken = default) { From 37a9fd7158b979f496f50fcb3c4c7cb8396c1666 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Thu, 4 Dec 2025 10:45:48 +0800 Subject: [PATCH 21/30] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E4=B8=9A?= =?UTF-8?q?=E5=8A=A1=E9=87=8C=E7=A8=8B=E7=A2=91=E5=BE=85=E5=8A=9E=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Document/12_BusinessTodo.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Document/12_BusinessTodo.md b/Document/12_BusinessTodo.md index cb4399c..7ced471 100644 --- a/Document/12_BusinessTodo.md +++ b/Document/12_BusinessTodo.md @@ -22,8 +22,8 @@ - 进展:新增门店员工 DTO/命令/查询/验证与处理器,支持员工创建/更新/删除及按门店查询;新增排班 CRUD(默认查询未来 7 天),校验员工归属、时间冲突;Admin API 增加员工与排班控制器及权限种子,仓储补充排班查询/更新/删除。 - [x] 桌码扫码入口:Mini 端解析二维码,GET /api/mini/tables/{code}/context 返回门店、桌台、公告。 - 进展:新增桌码上下文查询 DTO/验证/处理器,可按桌码解析返回门店名称/公告/标签及桌台信息;MiniApi 增加 `TablesController` 提供 `/context` 端点,仓储支持按桌码查询。 -- [ ] 菜品建模:分类、SPU、SKU、规格/加料组、价格策略、媒资 CRUD + 上下架流程;Mini 端可拉取完整 JSON。 - - 进展:补充商品全量详情 DTO/查询与映射,支持按门店过滤;新增 Admin 上下架接口与全量详情端点,权限新增 `product:publish`。仍需完成 SKU/规格/加料/媒资/价格策略替换接口及 Mini 菜单拉取。 +- [x] 菜品建模:分类、SPU、SKU、规格/加料组、价格策略、媒资 CRUD + 上下架流程;Mini 端可拉取完整 JSON。 + - 已交付:Admin 侧补齐 SKU/规格/加料/媒资/定价替换命令、验证与端点,并新增上/下架接口与全量详情;权限种子补充 `product:publish` 与子资源读写。Mini 侧新增门店菜单接口,按门店返回分类 + 商品全量 JSON(含 SKU/规格/加料/媒资/定价),支持 `updatedAfter` 增量。 - [ ] 库存体系:SKU 库存、批次、调整、售罄管理,支持预售/档期锁定并在订单中扣减/释放。 - 当前:存在 `InventoryItem/InventoryBatch/InventoryAdjustment` 领域模型与 DbSet,但未提供库存调整/锁定命令、与订单扣减/释放或预售档期锁定的应用层逻辑与 API。 - [ ] 自提档期:门店配置自提时间窗、容量、截单时间;Mini 端据此限制下单时间。 From cd8862b223d71979f4ed1d13c634bcfa0dd99850 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Thu, 4 Dec 2025 10:49:45 +0800 Subject: [PATCH 22/30] =?UTF-8?q?docs:=20=E6=A0=87=E8=AE=B0=E5=B7=B2?= =?UTF-8?q?=E4=BA=A4=E4=BB=98=E7=9A=84=E9=97=A8=E5=BA=97=E4=B8=8E=E6=A1=8C?= =?UTF-8?q?=E7=A0=81=E7=9B=B8=E5=85=B3=E9=87=8C=E7=A8=8B=E7=A2=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Document/12_BusinessTodo.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Document/12_BusinessTodo.md b/Document/12_BusinessTodo.md index 7ced471..ae522a1 100644 --- a/Document/12_BusinessTodo.md +++ b/Document/12_BusinessTodo.md @@ -15,13 +15,13 @@ - [x] 租户运营面板:欠费/到期告警、账单列表、公告通知接口,支持已读状态并在 Admin UI 展示。 - 已交付:新增账单/公告/通知实体与仓储,Admin 端提供 `/tenants/{id}/billings`(列表/详情/创建/标记支付)、`/announcements`(列表/详情/创建/更新/删除/已读)、`/notifications`(列表/已读)端点;权限码补充 `tenant-bill:*`、`tenant-announcement:*`、`tenant-notification:*`,种子模板更新;配额/订阅告警可通过通知表承载。 - [x] 门店管理:Store/StoreBusinessHour/StoreDeliveryZone/StoreHoliday CRUD 完整,含 GeoJSON 配送范围及能力开关。 - - 进展:已补充营业时间/配送区/节假日的命令、查询、验证与处理器,Admin API 新增子路由完成 CRUD,门店能力开关(预约/排队)已对外暴露;仓储扩展读写删除并保持租户过滤。 + - 已交付:营业时间/配送区/节假日命令、查询、验证与处理器齐全,Admin API 子路由完成 CRUD,门店能力开关(预约/排队)对外暴露,仓储读写删除均带租户过滤。 - [x] 桌码管理:批量生成桌码、绑定区域/容量、导出二维码 ZIP(POST /api/admin/stores/{id}/tables 可下载)。 - - 进展:新增桌台区域/桌码 DTO、命令、查询、验证与处理器,支持批量生成桌码、区域绑定和更新;Admin API 增加桌台区域与桌码 CRUD 及二维码 ZIP 导出端点,使用 QRCoder 生成 SVG 并打包下载;仓储补齐桌台/区域的查找、更新、删除。 + - 已交付:桌台区域/桌码 DTO、命令、查询、验证与处理器完善,支持批量生成、区域绑定/更新;Admin API 增加区域/桌码 CRUD 与二维码 ZIP 导出(QRCoder 生成 SVG 打包),仓储补齐查找、更新、删除。 - [x] 员工排班:创建员工、绑定门店角色、维护 StoreEmployeeShift,可查询未来 7 日排班。 - - 进展:新增门店员工 DTO/命令/查询/验证与处理器,支持员工创建/更新/删除及按门店查询;新增排班 CRUD(默认查询未来 7 天),校验员工归属、时间冲突;Admin API 增加员工与排班控制器及权限种子,仓储补充排班查询/更新/删除。 + - 已交付:门店员工 DTO/命令/查询/验证/处理器完成,支持创建/更新/删除/查询;排班 CRUD(默认未来 7 天)含归属与时间冲突校验;Admin API 增加员工与排班控制器及权限种子,仓储含排班查询/更新/删除。 - [x] 桌码扫码入口:Mini 端解析二维码,GET /api/mini/tables/{code}/context 返回门店、桌台、公告。 - - 进展:新增桌码上下文查询 DTO/验证/处理器,可按桌码解析返回门店名称/公告/标签及桌台信息;MiniApi 增加 `TablesController` 提供 `/context` 端点,仓储支持按桌码查询。 + - 已交付:桌码上下文查询 DTO/验证/处理器完成,可按桌码返回门店名称/公告/标签与桌台信息;MiniApi 新增 `TablesController` `/context` 端点,仓储支持按桌码查询。 - [x] 菜品建模:分类、SPU、SKU、规格/加料组、价格策略、媒资 CRUD + 上下架流程;Mini 端可拉取完整 JSON。 - 已交付:Admin 侧补齐 SKU/规格/加料/媒资/定价替换命令、验证与端点,并新增上/下架接口与全量详情;权限种子补充 `product:publish` 与子资源读写。Mini 侧新增门店菜单接口,按门店返回分类 + 商品全量 JSON(含 SKU/规格/加料/媒资/定价),支持 `updatedAfter` 增量。 - [ ] 库存体系:SKU 库存、批次、调整、售罄管理,支持预售/档期锁定并在订单中扣减/释放。 From 8e4c2b0e45bcd8f2fb50e7a42ad4ca56ab55ae2e Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Thu, 4 Dec 2025 11:25:01 +0800 Subject: [PATCH 23/30] chore: add documentation comments and stylecop rules --- .editorconfig | 6 ++ Directory.Build.props | 3 + .../Controllers/AuthController.cs | 11 ++- .../Controllers/DeliveriesController.cs | 21 ++++-- .../Controllers/DictionaryController.cs | 29 ++++++-- .../Controllers/FilesController.cs | 9 ++- .../Controllers/HealthController.cs | 3 + .../MerchantCategoriesController.cs | 12 ++++ .../Controllers/MerchantsController.cs | 51 ++++++++++++-- .../Controllers/OrdersController.cs | 21 ++++-- .../Controllers/PaymentsController.cs | 21 ++++-- .../Controllers/PermissionsController.cs | 14 ++++ .../Controllers/ProductsController.cs | 21 ++++-- .../Controllers/RolesController.cs | 40 +++++++++++ .../Controllers/StoresController.cs | 21 ++++-- .../Controllers/SystemParametersController.cs | 15 +++++ .../TenantAnnouncementsController.cs | 22 ++++++ .../Controllers/TenantBillingsController.cs | 16 +++++ .../TenantNotificationsController.cs | 8 +++ .../Controllers/TenantPackagesController.cs | 17 +++++ .../Controllers/TenantsController.cs | 33 +++++++++ .../Controllers/UserPermissionsController.cs | 2 + src/Api/TakeoutSaaS.AdminApi/Program.cs | 19 ++++-- .../Controllers/AuthController.cs | 14 ++-- .../Controllers/FilesController.cs | 9 ++- .../Controllers/HealthController.cs | 3 + .../Controllers/MeController.cs | 10 ++- src/Api/TakeoutSaaS.MiniApi/Program.cs | 11 +++ .../Controllers/HealthController.cs | 3 + src/Api/TakeoutSaaS.UserApi/Program.cs | 11 +++ .../CreateDeliveryOrderCommandHandler.cs | 14 ++-- .../DeleteDeliveryOrderCommandHandler.cs | 18 ++--- .../GetDeliveryOrderByIdQueryHandler.cs | 15 +++-- .../SearchDeliveryOrdersQueryHandler.cs | 15 +++-- .../UpdateDeliveryOrderCommandHandler.cs | 24 ++++--- .../AddMerchantDocumentCommandHandler.cs | 25 ++++--- .../CreateMerchantCategoryCommandHandler.cs | 18 ++--- .../Handlers/CreateMerchantCommandHandler.cs | 12 ++-- .../CreateMerchantContractCommandHandler.cs | 27 ++++---- .../DeleteMerchantCategoryCommandHandler.cs | 13 ++-- .../Handlers/DeleteMerchantCommandHandler.cs | 14 ++-- .../GetMerchantAuditLogsQueryHandler.cs | 11 +-- .../Handlers/GetMerchantByIdQueryHandler.cs | 9 ++- .../GetMerchantCategoriesQueryHandler.cs | 9 ++- .../GetMerchantContractsQueryHandler.cs | 11 ++- .../Handlers/GetMerchantDetailQueryHandler.cs | 14 ++-- .../GetMerchantDocumentsQueryHandler.cs | 11 ++- .../ListMerchantCategoriesQueryHandler.cs | 10 +-- ...ReorderMerchantCategoriesCommandHandler.cs | 14 ++-- .../Handlers/ReviewMerchantCommandHandler.cs | 23 ++++--- .../ReviewMerchantDocumentCommandHandler.cs | 23 ++++--- .../Handlers/SearchMerchantsQueryHandler.cs | 11 +-- .../Handlers/UpdateMerchantCommandHandler.cs | 14 ++-- ...ateMerchantContractStatusCommandHandler.cs | 23 ++++--- .../Handlers/CreateOrderCommandHandler.cs | 18 +++-- .../Handlers/DeleteOrderCommandHandler.cs | 17 +++-- .../Handlers/GetOrderByIdQueryHandler.cs | 18 ++--- .../Handlers/SearchOrdersQueryHandler.cs | 13 ++-- .../Handlers/UpdateOrderCommandHandler.cs | 24 +++---- ...ngeTenantSubscriptionPlanCommandHandler.cs | 22 +++--- .../CheckTenantQuotaCommandHandler.cs | 8 ++- .../CreateTenantAnnouncementCommandHandler.cs | 3 + .../CreateTenantBillingCommandHandler.cs | 4 ++ .../CreateTenantPackageCommandHandler.cs | 3 + .../CreateTenantSubscriptionCommandHandler.cs | 26 ++++--- .../DeleteTenantAnnouncementCommandHandler.cs | 3 + .../DeleteTenantPackageCommandHandler.cs | 3 + .../GetTenantAnnouncementQueryHandler.cs | 3 + .../GetTenantAuditLogsQueryHandler.cs | 7 +- .../Handlers/GetTenantBillQueryHandler.cs | 3 + .../Handlers/GetTenantByIdQueryHandler.cs | 11 +-- .../GetTenantPackageByIdQueryHandler.cs | 3 + ...arkTenantAnnouncementReadCommandHandler.cs | 4 ++ .../MarkTenantBillingPaidCommandHandler.cs | 4 ++ ...arkTenantNotificationReadCommandHandler.cs | 3 + .../Handlers/RegisterTenantCommandHandler.cs | 28 ++++---- .../Handlers/ReviewTenantCommandHandler.cs | 34 +++++----- .../SearchTenantAnnouncementsQueryHandler.cs | 7 ++ .../Handlers/SearchTenantBillsQueryHandler.cs | 3 + .../SearchTenantNotificationsQueryHandler.cs | 3 + .../SearchTenantPackagesQueryHandler.cs | 3 + .../Handlers/SearchTenantsQueryHandler.cs | 12 ++-- .../SubmitTenantVerificationCommandHandler.cs | 20 +++--- .../UpdateTenantAnnouncementCommandHandler.cs | 5 ++ .../UpdateTenantPackageCommandHandler.cs | 4 ++ .../Services/DictionaryAppService.cs | 27 +++++++- .../Handlers/AssignUserRolesCommandHandler.cs | 5 ++ .../BindRolePermissionsCommandHandler.cs | 5 ++ .../CopyRoleTemplateCommandHandler.cs | 6 +- .../CreatePermissionCommandHandler.cs | 5 ++ .../Handlers/CreateRoleCommandHandler.cs | 5 ++ .../CreateRoleTemplateCommandHandler.cs | 5 ++ .../DeletePermissionCommandHandler.cs | 5 ++ .../Handlers/DeleteRoleCommandHandler.cs | 5 ++ .../DeleteRoleTemplateCommandHandler.cs | 4 ++ .../Handlers/GetRoleTemplateQueryHandler.cs | 4 ++ .../GetUserPermissionsQueryHandler.cs | 29 ++++---- .../Handlers/ListRoleTemplatesQueryHandler.cs | 3 + .../Handlers/SearchPermissionsQueryHandler.cs | 5 ++ .../Handlers/SearchRolesQueryHandler.cs | 5 ++ .../SearchUserPermissionsQueryHandler.cs | 27 ++++---- .../UpdatePermissionCommandHandler.cs | 4 ++ .../Handlers/UpdateRoleCommandHandler.cs | 4 ++ .../Contracts/SendVerificationCodeRequest.cs | 2 - .../VerifyVerificationCodeRequest.cs | 2 - .../Sms/Services/VerificationCodeService.cs | 8 +++ .../Storage/Contracts/DirectUploadRequest.cs | 2 - .../Storage/Contracts/UploadFileRequest.cs | 2 - .../Storage/Services/FileStorageService.cs | 10 +++ .../Constants/DatabaseConstants.cs | 2 +- .../Constants/ErrorCodes.cs | 32 ++++++++- .../Results/ApiResponse.cs | 21 +++++- .../Middleware/RequestLoggingMiddleware.cs | 1 - .../HttpContextCurrentUserAccessor.cs | 2 - .../Deliveries/Entities/DeliveryOrder.cs | 3 + .../Repositories/IPermissionRepository.cs | 67 +++++++++++++++++++ .../Repositories/IRolePermissionRepository.cs | 29 ++++++++ .../Identity/Repositories/IRoleRepository.cs | 59 ++++++++++++++++ .../Repositories/IRoleTemplateRepository.cs | 49 ++++++++++++++ .../Repositories/IUserRoleRepository.cs | 30 +++++++++ .../Repositories/IMerchantRepository.cs | 32 +++++++++ .../Queues/Entities/QueueTicket.cs | 3 + src/Gateway/TakeoutSaaS.ApiGateway/Program.cs | 14 +++- .../App/Repositories/EfDeliveryRepository.cs | 2 - .../App/Repositories/EfMerchantRepository.cs | 2 - .../App/Repositories/EfOrderRepository.cs | 2 - .../App/Repositories/EfPaymentRepository.cs | 2 - .../App/Repositories/EfProductRepository.cs | 2 - .../App/Repositories/EfStoreRepository.cs | 2 - .../Repositories/EfDictionaryRepository.cs | 1 - .../Persistence/EfIdentityUserRepository.cs | 1 - .../Persistence/EfMiniUserRepository.cs | 1 - .../Services/RabbitMqMessagePublisher.cs | 4 ++ .../Services/RabbitMqMessageSubscriber.cs | 4 ++ .../Services/AliyunSmsSender.cs | 2 - .../Services/TencentSmsSender.cs | 5 ++ .../Models/StorageDirectUploadRequest.cs | 2 - .../Models/StorageUploadRequest.cs | 1 - .../Providers/AliyunOssStorageProvider.cs | 8 +++ .../Providers/S3StorageProviderBase.cs | 7 ++ .../TenantProvider.cs | 2 - .../TenantResolutionMiddleware.cs | 2 - 142 files changed, 1309 insertions(+), 439 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..32193b8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,6 @@ +# EditorConfig +root = true + +[*.cs] +dotnet_diagnostic.SA1600.severity = error +dotnet_diagnostic.SA1601.severity = error diff --git a/Directory.Build.props b/Directory.Build.props index 59c4f40..f83743e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,5 +6,8 @@ latest false + + + diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs index 72f23f4..b38d018 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs @@ -13,21 +13,16 @@ using TakeoutSaaS.Shared.Web.Api; using TakeoutSaaS.Shared.Web.Security; namespace TakeoutSaaS.AdminApi.Controllers; - /// /// 管理后台认证接口 /// -/// -/// -/// -/// +/// 提供登录、刷新 Token 以及用户权限查询能力。 +/// 认证服务 [ApiVersion("1.0")] [Authorize] [Route("api/admin/v{version:apiVersion}/auth")] public sealed class AuthController(IAdminAuthService authService) : BaseApiController { - - /// /// 登录获取 Token /// @@ -84,12 +79,14 @@ public sealed class AuthController(IAdminAuthService authService) : BaseApiContr [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)] public async Task> GetProfile(CancellationToken cancellationToken) { + // 1. 从 JWT 中获取当前用户标识 var userId = User.GetUserId(); if (userId == 0) { return ApiResponse.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识"); } + // 2. 读取用户档案并返回 var profile = await authService.GetProfileAsync(userId, cancellationToken); return ApiResponse.Ok(profile); } diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/DeliveriesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/DeliveriesController.cs index fe5d2cf..ae7c092 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/DeliveriesController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/DeliveriesController.cs @@ -16,16 +16,11 @@ namespace TakeoutSaaS.AdminApi.Controllers; /// /// 配送单管理。 /// -/// -/// 初始化控制器。 -/// [ApiVersion("1.0")] [Authorize] [Route("api/admin/v{version:apiVersion}/deliveries")] public sealed class DeliveriesController(IMediator mediator) : BaseApiController { - - /// /// 创建配送单。 /// @@ -34,7 +29,10 @@ public sealed class DeliveriesController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> Create([FromBody] CreateDeliveryOrderCommand command, CancellationToken cancellationToken) { + // 1. 创建配送单 var result = await mediator.Send(command, cancellationToken); + + // 2. 返回创建结果 return ApiResponse.Ok(result); } @@ -53,6 +51,7 @@ public sealed class DeliveriesController(IMediator mediator) : BaseApiController [FromQuery] bool sortDesc = true, CancellationToken cancellationToken = default) { + // 1. 组装查询参数 var result = await mediator.Send(new SearchDeliveryOrdersQuery { OrderId = orderId, @@ -63,6 +62,7 @@ public sealed class DeliveriesController(IMediator mediator) : BaseApiController SortDescending = sortDesc }, cancellationToken); + // 2. 返回分页结果 return ApiResponse>.Ok(result); } @@ -75,7 +75,10 @@ public sealed class DeliveriesController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Detail(long deliveryOrderId, CancellationToken cancellationToken) { + // 1. 查询配送单详情 var result = await mediator.Send(new GetDeliveryOrderByIdQuery { DeliveryOrderId = deliveryOrderId }, cancellationToken); + + // 2. 返回详情或 404 return result == null ? ApiResponse.Error(ErrorCodes.NotFound, "配送单不存在") : ApiResponse.Ok(result); @@ -90,11 +93,16 @@ public sealed class DeliveriesController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Update(long deliveryOrderId, [FromBody] UpdateDeliveryOrderCommand command, CancellationToken cancellationToken) { + // 1. 确保命令携带配送单标识 if (command.DeliveryOrderId == 0) { command = command with { DeliveryOrderId = deliveryOrderId }; } + + // 2. 执行更新 var result = await mediator.Send(command, cancellationToken); + + // 3. 返回更新结果或 404 return result == null ? ApiResponse.Error(ErrorCodes.NotFound, "配送单不存在") : ApiResponse.Ok(result); @@ -109,7 +117,10 @@ public sealed class DeliveriesController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Delete(long deliveryOrderId, CancellationToken cancellationToken) { + // 1. 执行删除 var success = await mediator.Send(new DeleteDeliveryOrderCommand { DeliveryOrderId = deliveryOrderId }, cancellationToken); + + // 2. 返回结果或 404 return success ? ApiResponse.Ok(null) : ApiResponse.Error(ErrorCodes.NotFound, "配送单不存在"); diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryController.cs index d98e420..28e0d61 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryController.cs @@ -12,17 +12,12 @@ namespace TakeoutSaaS.AdminApi.Controllers; /// /// 参数字典管理。 /// -/// -/// 初始化字典控制器。 -/// /// 字典服务 [ApiVersion("1.0")] [Authorize] [Route("api/admin/v{version:apiVersion}/dictionaries")] public sealed class DictionaryController(IDictionaryAppService dictionaryAppService) : BaseApiController { - - /// /// 查询字典分组。 /// @@ -31,7 +26,10 @@ public sealed class DictionaryController(IDictionaryAppService dictionaryAppServ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] public async Task>> GetGroups([FromQuery] DictionaryGroupQuery query, CancellationToken cancellationToken) { + // 1. 查询字典分组 var groups = await dictionaryAppService.SearchGroupsAsync(query, cancellationToken); + + // 2. 返回分组列表 return ApiResponse>.Ok(groups); } @@ -43,7 +41,10 @@ public sealed class DictionaryController(IDictionaryAppService dictionaryAppServ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> CreateGroup([FromBody] CreateDictionaryGroupRequest request, CancellationToken cancellationToken) { + // 1. 创建字典分组 var group = await dictionaryAppService.CreateGroupAsync(request, cancellationToken); + + // 2. 返回创建结果 return ApiResponse.Ok(group); } @@ -55,7 +56,10 @@ public sealed class DictionaryController(IDictionaryAppService dictionaryAppServ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> UpdateGroup(long groupId, [FromBody] UpdateDictionaryGroupRequest request, CancellationToken cancellationToken) { + // 1. 更新字典分组 var group = await dictionaryAppService.UpdateGroupAsync(groupId, request, cancellationToken); + + // 2. 返回更新结果 return ApiResponse.Ok(group); } @@ -67,7 +71,10 @@ public sealed class DictionaryController(IDictionaryAppService dictionaryAppServ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> DeleteGroup(long groupId, CancellationToken cancellationToken) { + // 1. 删除字典分组 await dictionaryAppService.DeleteGroupAsync(groupId, cancellationToken); + + // 2. 返回成功响应 return ApiResponse.Success(); } @@ -79,7 +86,10 @@ public sealed class DictionaryController(IDictionaryAppService dictionaryAppServ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> CreateItem(long groupId, [FromBody] CreateDictionaryItemRequest request, CancellationToken cancellationToken) { + // 1. 绑定分组标识 request.GroupId = groupId; + + // 2. 创建字典项 var item = await dictionaryAppService.CreateItemAsync(request, cancellationToken); return ApiResponse.Ok(item); } @@ -92,7 +102,10 @@ public sealed class DictionaryController(IDictionaryAppService dictionaryAppServ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> UpdateItem(long itemId, [FromBody] UpdateDictionaryItemRequest request, CancellationToken cancellationToken) { + // 1. 更新字典项 var item = await dictionaryAppService.UpdateItemAsync(itemId, request, cancellationToken); + + // 2. 返回更新结果 return ApiResponse.Ok(item); } @@ -104,7 +117,10 @@ public sealed class DictionaryController(IDictionaryAppService dictionaryAppServ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> DeleteItem(long itemId, CancellationToken cancellationToken) { + // 1. 删除字典项 await dictionaryAppService.DeleteItemAsync(itemId, cancellationToken); + + // 2. 返回成功响应 return ApiResponse.Success(); } @@ -115,7 +131,10 @@ public sealed class DictionaryController(IDictionaryAppService dictionaryAppServ [ProducesResponseType(typeof(ApiResponse>>), StatusCodes.Status200OK)] public async Task>>> BatchGet([FromBody] DictionaryBatchQueryRequest request, CancellationToken cancellationToken) { + // 1. 批量读取并命中缓存 var dictionaries = await dictionaryAppService.GetCachedItemsAsync(request, cancellationToken); + + // 2. 返回批量结果 return ApiResponse>>.Ok(dictionaries); } } diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/FilesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/FilesController.cs index f53d344..34091de 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/FilesController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/FilesController.cs @@ -19,8 +19,6 @@ namespace TakeoutSaaS.AdminApi.Controllers; [Route("api/admin/v{version:apiVersion}/files")] public sealed class FilesController(IFileStorageService fileStorageService) : BaseApiController { - private readonly IFileStorageService _fileStorageService = fileStorageService; - /// /// 上传图片或文件。 /// @@ -30,23 +28,28 @@ public sealed class FilesController(IFileStorageService fileStorageService) : Ba [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] public async Task> Upload([FromForm] IFormFile? file, [FromForm] string? type, CancellationToken cancellationToken) { + // 1. 校验文件有效性 if (file == null || file.Length == 0) { return ApiResponse.Error(ErrorCodes.BadRequest, "文件不能为空"); } + // 2. 解析上传类型 if (!UploadFileTypeParser.TryParse(type, out var uploadType)) { return ApiResponse.Error(ErrorCodes.BadRequest, "上传类型不合法"); } + // 3. 提取请求来源 var origin = Request.Headers["Origin"].FirstOrDefault() ?? Request.Headers["Referer"].FirstOrDefault(); await using var stream = file.OpenReadStream(); - var result = await _fileStorageService.UploadAsync( + // 4. 调用存储服务执行上传 + var result = await fileStorageService.UploadAsync( new UploadFileRequest(uploadType, stream, file.FileName, file.ContentType ?? string.Empty, file.Length, origin), cancellationToken); + // 5. 返回上传结果 return ApiResponse.Ok(result); } } diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/HealthController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/HealthController.cs index 4db5f17..25edb1b 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/HealthController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/HealthController.cs @@ -23,7 +23,10 @@ public class HealthController : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public ApiResponse Get() { + // 1. 构造健康状态 var payload = new { status = "OK", service = "AdminApi", time = DateTime.UtcNow }; + + // 2. 返回健康响应 return ApiResponse.Ok(payload); } } diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantCategoriesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantCategoriesController.cs index 72684f0..8a930be 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantCategoriesController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantCategoriesController.cs @@ -29,7 +29,10 @@ public sealed class MerchantCategoriesController(IMediator mediator) : BaseApiCo [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] public async Task>> List(CancellationToken cancellationToken) { + // 1. 查询所有类目 var result = await mediator.Send(new ListMerchantCategoriesQuery(), cancellationToken); + + // 2. 返回类目列表 return ApiResponse>.Ok(result); } @@ -41,7 +44,10 @@ public sealed class MerchantCategoriesController(IMediator mediator) : BaseApiCo [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> Create([FromBody] CreateMerchantCategoryCommand command, CancellationToken cancellationToken) { + // 1. 创建类目 var result = await mediator.Send(command, cancellationToken); + + // 2. 返回创建结果 return ApiResponse.Ok(result); } @@ -54,7 +60,10 @@ public sealed class MerchantCategoriesController(IMediator mediator) : BaseApiCo [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Delete(long categoryId, CancellationToken cancellationToken) { + // 1. 执行删除 var success = await mediator.Send(new DeleteMerchantCategoryCommand(categoryId), cancellationToken); + + // 2. 返回删除结果或 404 return success ? ApiResponse.Ok(null) : ApiResponse.Error(ErrorCodes.NotFound, "类目不存在"); @@ -68,7 +77,10 @@ public sealed class MerchantCategoriesController(IMediator mediator) : BaseApiCo [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> Reorder([FromBody] ReorderMerchantCategoriesCommand command, CancellationToken cancellationToken) { + // 1. 执行排序调整 await mediator.Send(command, cancellationToken); + + // 2. 返回成功结果 return ApiResponse.Ok(null); } } diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs index d63bf56..828d98b 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs @@ -16,16 +16,11 @@ namespace TakeoutSaaS.AdminApi.Controllers; /// /// 商户管理。 /// -/// -/// 初始化控制器。 -/// [ApiVersion("1.0")] [Authorize] [Route("api/admin/v{version:apiVersion}/merchants")] public sealed class MerchantsController(IMediator mediator) : BaseApiController { - - /// /// 创建商户。 /// @@ -34,7 +29,10 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> Create([FromBody] CreateMerchantCommand command, CancellationToken cancellationToken) { + // 1. 创建商户 var result = await mediator.Send(command, cancellationToken); + + // 2. 返回创建结果 return ApiResponse.Ok(result); } @@ -52,6 +50,7 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController [FromQuery] bool sortDesc = true, CancellationToken cancellationToken = default) { + // 1. 组装查询参数并执行查询 var result = await mediator.Send(new SearchMerchantsQuery { Status = status, @@ -60,6 +59,8 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController SortBy = sortBy, SortDescending = sortDesc }, cancellationToken); + + // 2. 返回分页结果 return ApiResponse>.Ok(result); } @@ -72,12 +73,16 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Update(long merchantId, [FromBody] UpdateMerchantCommand command, CancellationToken cancellationToken) { + // 1. 绑定商户标识 if (command.MerchantId == 0) { command = command with { MerchantId = merchantId }; } + // 2. 执行更新 var result = await mediator.Send(command, cancellationToken); + + // 3. 返回更新结果或 404 return result == null ? ApiResponse.Error(ErrorCodes.NotFound, "商户不存在") : ApiResponse.Ok(result); @@ -92,7 +97,10 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Delete(long merchantId, CancellationToken cancellationToken) { + // 1. 执行删除 var success = await mediator.Send(new DeleteMerchantCommand { MerchantId = merchantId }, cancellationToken); + + // 2. 返回删除结果或 404 return success ? ApiResponse.Ok(null) : ApiResponse.Error(ErrorCodes.NotFound, "商户不存在"); @@ -107,7 +115,10 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Detail(long merchantId, CancellationToken cancellationToken) { + // 1. 查询商户概览 var result = await mediator.Send(new GetMerchantByIdQuery { MerchantId = merchantId }, cancellationToken); + + // 2. 返回结果或 404 return result == null ? ApiResponse.Error(ErrorCodes.NotFound, "商户不存在") : ApiResponse.Ok(result); @@ -121,7 +132,10 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> FullDetail(long merchantId, CancellationToken cancellationToken) { + // 1. 查询商户详细资料 var result = await mediator.Send(new GetMerchantDetailQuery(merchantId), cancellationToken); + + // 2. 返回详情 return ApiResponse.Ok(result); } @@ -136,7 +150,10 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController [FromBody] AddMerchantDocumentCommand body, CancellationToken cancellationToken) { + // 1. 绑定商户标识 var command = body with { MerchantId = merchantId }; + + // 2. 创建证照记录 var result = await mediator.Send(command, cancellationToken); return ApiResponse.Ok(result); } @@ -149,7 +166,10 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] public async Task>> Documents(long merchantId, CancellationToken cancellationToken) { + // 1. 查询证照列表 var result = await mediator.Send(new GetMerchantDocumentsQuery(merchantId), cancellationToken); + + // 2. 返回证照集合 return ApiResponse>.Ok(result); } @@ -165,7 +185,10 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController [FromBody] ReviewMerchantDocumentCommand body, CancellationToken cancellationToken) { + // 1. 绑定商户与证照标识 var command = body with { MerchantId = merchantId, DocumentId = documentId }; + + // 2. 执行审核 var result = await mediator.Send(command, cancellationToken); return ApiResponse.Ok(result); } @@ -181,7 +204,10 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController [FromBody] CreateMerchantContractCommand body, CancellationToken cancellationToken) { + // 1. 绑定商户标识 var command = body with { MerchantId = merchantId }; + + // 2. 创建合同 var result = await mediator.Send(command, cancellationToken); return ApiResponse.Ok(result); } @@ -194,7 +220,10 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] public async Task>> Contracts(long merchantId, CancellationToken cancellationToken) { + // 1. 查询合同列表 var result = await mediator.Send(new GetMerchantContractsQuery(merchantId), cancellationToken); + + // 2. 返回合同集合 return ApiResponse>.Ok(result); } @@ -210,7 +239,10 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController [FromBody] UpdateMerchantContractStatusCommand body, CancellationToken cancellationToken) { + // 1. 绑定商户与合同标识 var command = body with { MerchantId = merchantId, ContractId = contractId }; + + // 2. 更新合同状态 var result = await mediator.Send(command, cancellationToken); return ApiResponse.Ok(result); } @@ -223,7 +255,10 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> Review(long merchantId, [FromBody] ReviewMerchantCommand body, CancellationToken cancellationToken) { + // 1. 绑定商户标识 var command = body with { MerchantId = merchantId }; + + // 2. 执行审核 var result = await mediator.Send(command, cancellationToken); return ApiResponse.Ok(result); } @@ -240,7 +275,10 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController [FromQuery] int pageSize = 20, CancellationToken cancellationToken = default) { + // 1. 查询审核日志 var result = await mediator.Send(new GetMerchantAuditLogsQuery(merchantId, page, pageSize), cancellationToken); + + // 2. 返回日志分页 return ApiResponse>.Ok(result); } @@ -252,7 +290,10 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] public async Task>> Categories(CancellationToken cancellationToken) { + // 1. 查询可选类目 var result = await mediator.Send(new GetMerchantCategoriesQuery(), cancellationToken); + + // 2. 返回类目列表 return ApiResponse>.Ok(result); } } diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/OrdersController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/OrdersController.cs index 4d04390..85f69e4 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/OrdersController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/OrdersController.cs @@ -17,16 +17,11 @@ namespace TakeoutSaaS.AdminApi.Controllers; /// /// 订单管理。 /// -/// -/// 初始化控制器。 -/// [ApiVersion("1.0")] [Authorize] [Route("api/admin/v{version:apiVersion}/orders")] public sealed class OrdersController(IMediator mediator) : BaseApiController { - - /// /// 创建订单。 /// @@ -35,7 +30,10 @@ public sealed class OrdersController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> Create([FromBody] CreateOrderCommand command, CancellationToken cancellationToken) { + // 1. 创建订单 var result = await mediator.Send(command, cancellationToken); + + // 2. 返回创建结果 return ApiResponse.Ok(result); } @@ -56,6 +54,7 @@ public sealed class OrdersController(IMediator mediator) : BaseApiController [FromQuery] bool sortDesc = true, CancellationToken cancellationToken = default) { + // 1. 组装查询参数并执行查询 var result = await mediator.Send(new SearchOrdersQuery { StoreId = storeId, @@ -68,6 +67,7 @@ public sealed class OrdersController(IMediator mediator) : BaseApiController SortDescending = sortDesc }, cancellationToken); + // 2. 返回分页结果 return ApiResponse>.Ok(result); } @@ -80,7 +80,10 @@ public sealed class OrdersController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Detail(long orderId, CancellationToken cancellationToken) { + // 1. 查询订单详情 var result = await mediator.Send(new GetOrderByIdQuery { OrderId = orderId }, cancellationToken); + + // 2. 返回详情或 404 return result == null ? ApiResponse.Error(ErrorCodes.NotFound, "订单不存在") : ApiResponse.Ok(result); @@ -95,11 +98,16 @@ public sealed class OrdersController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Update(long orderId, [FromBody] UpdateOrderCommand command, CancellationToken cancellationToken) { + // 1. 确保命令包含订单标识 if (command.OrderId == 0) { command = command with { OrderId = orderId }; } + + // 2. 执行更新 var result = await mediator.Send(command, cancellationToken); + + // 3. 返回更新结果或 404 return result == null ? ApiResponse.Error(ErrorCodes.NotFound, "订单不存在") : ApiResponse.Ok(result); @@ -114,7 +122,10 @@ public sealed class OrdersController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Delete(long orderId, CancellationToken cancellationToken) { + // 1. 执行删除 var success = await mediator.Send(new DeleteOrderCommand { OrderId = orderId }, cancellationToken); + + // 2. 返回结果或 404 return success ? ApiResponse.Ok(null) : ApiResponse.Error(ErrorCodes.NotFound, "订单不存在"); diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/PaymentsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/PaymentsController.cs index de8f322..83df2fe 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/PaymentsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/PaymentsController.cs @@ -16,16 +16,11 @@ namespace TakeoutSaaS.AdminApi.Controllers; /// /// 支付记录管理。 /// -/// -/// 初始化控制器。 -/// [ApiVersion("1.0")] [Authorize] [Route("api/admin/v{version:apiVersion}/payments")] public sealed class PaymentsController(IMediator mediator) : BaseApiController { - - /// /// 创建支付记录。 /// @@ -34,7 +29,10 @@ public sealed class PaymentsController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> Create([FromBody] CreatePaymentCommand command, CancellationToken cancellationToken) { + // 1. 创建支付记录 var result = await mediator.Send(command, cancellationToken); + + // 2. 返回创建结果 return ApiResponse.Ok(result); } @@ -53,6 +51,7 @@ public sealed class PaymentsController(IMediator mediator) : BaseApiController [FromQuery] bool sortDesc = true, CancellationToken cancellationToken = default) { + // 1. 组装查询参数并执行查询 var result = await mediator.Send(new SearchPaymentsQuery { OrderId = orderId, @@ -63,6 +62,7 @@ public sealed class PaymentsController(IMediator mediator) : BaseApiController SortDescending = sortDesc }, cancellationToken); + // 2. 返回分页结果 return ApiResponse>.Ok(result); } @@ -75,7 +75,10 @@ public sealed class PaymentsController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Detail(long paymentId, CancellationToken cancellationToken) { + // 1. 查询支付记录详情 var result = await mediator.Send(new GetPaymentByIdQuery { PaymentId = paymentId }, cancellationToken); + + // 2. 返回详情或 404 return result == null ? ApiResponse.Error(ErrorCodes.NotFound, "支付记录不存在") : ApiResponse.Ok(result); @@ -90,11 +93,16 @@ public sealed class PaymentsController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Update(long paymentId, [FromBody] UpdatePaymentCommand command, CancellationToken cancellationToken) { + // 1. 确保命令包含支付记录标识 if (command.PaymentId == 0) { command = command with { PaymentId = paymentId }; } + + // 2. 执行更新 var result = await mediator.Send(command, cancellationToken); + + // 3. 返回更新结果或 404 return result == null ? ApiResponse.Error(ErrorCodes.NotFound, "支付记录不存在") : ApiResponse.Ok(result); @@ -109,7 +117,10 @@ public sealed class PaymentsController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Delete(long paymentId, CancellationToken cancellationToken) { + // 1. 执行删除 var success = await mediator.Send(new DeletePaymentCommand { PaymentId = paymentId }, cancellationToken); + + // 2. 返回结果或 404 return success ? ApiResponse.Ok(null) : ApiResponse.Error(ErrorCodes.NotFound, "支付记录不存在"); diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/PermissionsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/PermissionsController.cs index 26b8518..c3646b4 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/PermissionsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/PermissionsController.cs @@ -31,7 +31,10 @@ public sealed class PermissionsController(IMediator mediator) : BaseApiControlle [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] public async Task>> Search([FromQuery] SearchPermissionsQuery query, CancellationToken cancellationToken) { + // 1. 查询权限分页 var result = await mediator.Send(query, cancellationToken); + + // 2. 返回分页数据 return ApiResponse>.Ok(result); } @@ -43,7 +46,10 @@ public sealed class PermissionsController(IMediator mediator) : BaseApiControlle [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> Create([FromBody, Required] CreatePermissionCommand command, CancellationToken cancellationToken) { + // 1. 创建权限 var result = await mediator.Send(command, cancellationToken); + + // 2. 返回创建结果 return ApiResponse.Ok(result); } @@ -56,8 +62,13 @@ public sealed class PermissionsController(IMediator mediator) : BaseApiControlle [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Update(long permissionId, [FromBody, Required] UpdatePermissionCommand command, CancellationToken cancellationToken) { + // 1. 绑定权限标识 command = command with { PermissionId = permissionId }; + + // 2. 执行更新 var result = await mediator.Send(command, cancellationToken); + + // 3. 返回更新结果或 404 return result is null ? ApiResponse.Error(StatusCodes.Status404NotFound, "权限不存在") : ApiResponse.Ok(result); @@ -71,7 +82,10 @@ public sealed class PermissionsController(IMediator mediator) : BaseApiControlle [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> Delete(long permissionId, CancellationToken cancellationToken) { + // 1. 构建删除命令 var command = new DeletePermissionCommand { PermissionId = permissionId }; + + // 2. 执行删除 var result = await mediator.Send(command, cancellationToken); return ApiResponse.Ok(result); } diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs index 24e334c..5cd7e8e 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs @@ -16,16 +16,11 @@ namespace TakeoutSaaS.AdminApi.Controllers; /// /// 商品管理。 /// -/// -/// 初始化控制器。 -/// [ApiVersion("1.0")] [Authorize] [Route("api/admin/v{version:apiVersion}/products")] public sealed class ProductsController(IMediator mediator) : BaseApiController { - - /// /// 创建商品。 /// @@ -34,7 +29,10 @@ public sealed class ProductsController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> Create([FromBody] CreateProductCommand command, CancellationToken cancellationToken) { + // 1. 创建商品 var result = await mediator.Send(command, cancellationToken); + + // 2. 返回创建结果 return ApiResponse.Ok(result); } @@ -54,6 +52,7 @@ public sealed class ProductsController(IMediator mediator) : BaseApiController [FromQuery] bool sortDesc = true, CancellationToken cancellationToken = default) { + // 1. 组装查询参数并执行查询 var result = await mediator.Send(new SearchProductsQuery { StoreId = storeId, @@ -65,6 +64,7 @@ public sealed class ProductsController(IMediator mediator) : BaseApiController SortDescending = sortDesc }, cancellationToken); + // 2. 返回分页结果 return ApiResponse>.Ok(result); } @@ -77,7 +77,10 @@ public sealed class ProductsController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Detail(long productId, CancellationToken cancellationToken) { + // 1. 查询商品详情 var result = await mediator.Send(new GetProductByIdQuery { ProductId = productId }, cancellationToken); + + // 2. 返回详情或 404 return result == null ? ApiResponse.Error(ErrorCodes.NotFound, "商品不存在") : ApiResponse.Ok(result); @@ -92,11 +95,16 @@ public sealed class ProductsController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Update(long productId, [FromBody] UpdateProductCommand command, CancellationToken cancellationToken) { + // 1. 确保命令包含商品标识 if (command.ProductId == 0) { command = command with { ProductId = productId }; } + + // 2. 执行更新 var result = await mediator.Send(command, cancellationToken); + + // 3. 返回更新结果或 404 return result == null ? ApiResponse.Error(ErrorCodes.NotFound, "商品不存在") : ApiResponse.Ok(result); @@ -111,7 +119,10 @@ public sealed class ProductsController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Delete(long productId, CancellationToken cancellationToken) { + // 1. 执行删除 var success = await mediator.Send(new DeleteProductCommand { ProductId = productId }, cancellationToken); + + // 2. 返回结果或 404 return success ? ApiResponse.Ok(null) : ApiResponse.Error(ErrorCodes.NotFound, "商品不存在"); diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs index 1033f5c..6a46937 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs @@ -32,7 +32,10 @@ public sealed class RolesController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] public async Task>> ListTemplates([FromQuery] bool? isActive, CancellationToken cancellationToken) { + // 1. 查询模板列表 var result = await mediator.Send(new ListRoleTemplatesQuery { IsActive = isActive }, cancellationToken); + + // 2. 返回模板集合 return ApiResponse>.Ok(result); } @@ -48,7 +51,10 @@ public sealed class RolesController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> GetTemplate(string templateCode, CancellationToken cancellationToken) { + // 1. 查询指定模板 var result = await mediator.Send(new GetRoleTemplateQuery { TemplateCode = templateCode }, cancellationToken); + + // 2. 返回模板或 404 return result is null ? ApiResponse.Error(StatusCodes.Status404NotFound, "角色模板不存在") : ApiResponse.Ok(result); @@ -62,7 +68,10 @@ public sealed class RolesController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> CreateTemplate([FromBody, Required] CreateRoleTemplateCommand command, CancellationToken cancellationToken) { + // 1. 创建模板 var result = await mediator.Send(command, cancellationToken); + + // 2. 返回创建结果 return ApiResponse.Ok(result); } @@ -78,8 +87,13 @@ public sealed class RolesController(IMediator mediator) : BaseApiController [FromBody, Required] UpdateRoleTemplateCommand command, CancellationToken cancellationToken) { + // 1. 绑定模板编码 command = command with { TemplateCode = templateCode }; + + // 2. 执行更新 var result = await mediator.Send(command, cancellationToken); + + // 3. 返回更新结果或 404 return result is null ? ApiResponse.Error(StatusCodes.Status404NotFound, "角色模板不存在") : ApiResponse.Ok(result); @@ -93,7 +107,10 @@ public sealed class RolesController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> DeleteTemplate(string templateCode, CancellationToken cancellationToken) { + // 1. 删除模板 var result = await mediator.Send(new DeleteRoleTemplateCommand { TemplateCode = templateCode }, cancellationToken); + + // 2. 返回执行结果 return ApiResponse.Ok(result); } @@ -112,7 +129,10 @@ public sealed class RolesController(IMediator mediator) : BaseApiController [FromBody, Required] CopyRoleTemplateCommand command, CancellationToken cancellationToken) { + // 1. 绑定模板编码 command = command with { TemplateCode = templateCode }; + + // 2. 复制模板并返回角色 var result = await mediator.Send(command, cancellationToken); return ApiResponse.Ok(result); } @@ -131,7 +151,10 @@ public sealed class RolesController(IMediator mediator) : BaseApiController [FromBody] InitializeRoleTemplatesCommand? command, CancellationToken cancellationToken) { + // 1. 确保命令实例存在 command ??= new InitializeRoleTemplatesCommand(); + + // 2. 执行初始化 var result = await mediator.Send(command, cancellationToken); return ApiResponse>.Ok(result); } @@ -149,7 +172,10 @@ public sealed class RolesController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] public async Task>> Search([FromQuery] SearchRolesQuery query, CancellationToken cancellationToken) { + // 1. 查询角色分页 var result = await mediator.Send(query, cancellationToken); + + // 2. 返回分页数据 return ApiResponse>.Ok(result); } @@ -161,7 +187,10 @@ public sealed class RolesController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> Create([FromBody, Required] CreateRoleCommand command, CancellationToken cancellationToken) { + // 1. 创建角色 var result = await mediator.Send(command, cancellationToken); + + // 2. 返回创建结果 return ApiResponse.Ok(result); } @@ -174,8 +203,13 @@ public sealed class RolesController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Update(long roleId, [FromBody, Required] UpdateRoleCommand command, CancellationToken cancellationToken) { + // 1. 绑定角色标识 command = command with { RoleId = roleId }; + + // 2. 执行更新 var result = await mediator.Send(command, cancellationToken); + + // 3. 返回更新结果或 404 return result is null ? ApiResponse.Error(StatusCodes.Status404NotFound, "角色不存在") : ApiResponse.Ok(result); @@ -189,7 +223,10 @@ public sealed class RolesController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> Delete(long roleId, CancellationToken cancellationToken) { + // 1. 构建删除命令 var command = new DeleteRoleCommand { RoleId = roleId }; + + // 2. 执行删除 var result = await mediator.Send(command, cancellationToken); return ApiResponse.Ok(result); } @@ -202,7 +239,10 @@ public sealed class RolesController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> BindPermissions(long roleId, [FromBody, Required] BindRolePermissionsCommand command, CancellationToken cancellationToken) { + // 1. 绑定角色标识 command = command with { RoleId = roleId }; + + // 2. 执行覆盖式授权 var result = await mediator.Send(command, cancellationToken); return ApiResponse.Ok(result); } diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/StoresController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoresController.cs index 6abdac2..7ae4958 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/StoresController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoresController.cs @@ -16,16 +16,11 @@ namespace TakeoutSaaS.AdminApi.Controllers; /// /// 门店管理。 /// -/// -/// 初始化控制器。 -/// [ApiVersion("1.0")] [Authorize] [Route("api/admin/v{version:apiVersion}/stores")] public sealed class StoresController(IMediator mediator) : BaseApiController { - - /// /// 创建门店。 /// @@ -34,7 +29,10 @@ public sealed class StoresController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> Create([FromBody] CreateStoreCommand command, CancellationToken cancellationToken) { + // 1. 创建门店 var result = await mediator.Send(command, cancellationToken); + + // 2. 返回创建结果 return ApiResponse.Ok(result); } @@ -53,6 +51,7 @@ public sealed class StoresController(IMediator mediator) : BaseApiController [FromQuery] bool sortDesc = true, CancellationToken cancellationToken = default) { + // 1. 组装查询参数并执行查询 var result = await mediator.Send(new SearchStoresQuery { MerchantId = merchantId, @@ -63,6 +62,7 @@ public sealed class StoresController(IMediator mediator) : BaseApiController SortDescending = sortDesc }, cancellationToken); + // 2. 返回分页结果 return ApiResponse>.Ok(result); } @@ -75,7 +75,10 @@ public sealed class StoresController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Detail(long storeId, CancellationToken cancellationToken) { + // 1. 查询门店详情 var result = await mediator.Send(new GetStoreByIdQuery { StoreId = storeId }, cancellationToken); + + // 2. 返回详情或 404 return result == null ? ApiResponse.Error(ErrorCodes.NotFound, "门店不存在") : ApiResponse.Ok(result); @@ -90,11 +93,16 @@ public sealed class StoresController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Update(long storeId, [FromBody] UpdateStoreCommand command, CancellationToken cancellationToken) { + // 1. 确保命令包含门店标识 if (command.StoreId == 0) { command = command with { StoreId = storeId }; } + + // 2. 执行更新 var result = await mediator.Send(command, cancellationToken); + + // 3. 返回更新结果或 404 return result == null ? ApiResponse.Error(ErrorCodes.NotFound, "门店不存在") : ApiResponse.Ok(result); @@ -109,7 +117,10 @@ public sealed class StoresController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Delete(long storeId, CancellationToken cancellationToken) { + // 1. 执行删除 var success = await mediator.Send(new DeleteStoreCommand { StoreId = storeId }, cancellationToken); + + // 2. 返回结果或 404 return success ? ApiResponse.Ok(null) : ApiResponse.Error(ErrorCodes.NotFound, "门店不存在"); diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/SystemParametersController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/SystemParametersController.cs index 045d649..f1f2e8d 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/SystemParametersController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/SystemParametersController.cs @@ -31,7 +31,10 @@ public sealed class SystemParametersController(IMediator mediator) : BaseApiCont [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> Create([FromBody] CreateSystemParameterCommand command, CancellationToken cancellationToken) { + // 1. 创建系统参数 var result = await mediator.Send(command, cancellationToken); + + // 2. 返回创建结果 return ApiResponse.Ok(result); } @@ -50,6 +53,7 @@ public sealed class SystemParametersController(IMediator mediator) : BaseApiCont [FromQuery] bool sortDesc = true, CancellationToken cancellationToken = default) { + // 1. 组合查询参数 var result = await mediator.Send(new SearchSystemParametersQuery { Keyword = keyword, @@ -60,6 +64,7 @@ public sealed class SystemParametersController(IMediator mediator) : BaseApiCont SortDescending = sortDesc }, cancellationToken); + // 2. 返回分页结果 return ApiResponse>.Ok(result); } @@ -72,7 +77,10 @@ public sealed class SystemParametersController(IMediator mediator) : BaseApiCont [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Detail(long parameterId, CancellationToken cancellationToken) { + // 1. 查询参数详情 var result = await mediator.Send(new GetSystemParameterByIdQuery(parameterId), cancellationToken); + + // 2. 返回详情或 404 return result == null ? ApiResponse.Error(ErrorCodes.NotFound, "系统参数不存在") : ApiResponse.Ok(result); @@ -87,12 +95,16 @@ public sealed class SystemParametersController(IMediator mediator) : BaseApiCont [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Update(long parameterId, [FromBody] UpdateSystemParameterCommand command, CancellationToken cancellationToken) { + // 1. 确保命令包含参数标识 if (command.ParameterId == 0) { command = command with { ParameterId = parameterId }; } + // 2. 执行更新 var result = await mediator.Send(command, cancellationToken); + + // 3. 返回结果或 404 return result == null ? ApiResponse.Error(ErrorCodes.NotFound, "系统参数不存在") : ApiResponse.Ok(result); @@ -107,7 +119,10 @@ public sealed class SystemParametersController(IMediator mediator) : BaseApiCont [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Delete(long parameterId, CancellationToken cancellationToken) { + // 1. 执行删除 var success = await mediator.Send(new DeleteSystemParameterCommand { ParameterId = parameterId }, cancellationToken); + + // 2. 返回成功或 404 return success ? ApiResponse.Success() : ApiResponse.Error(ErrorCodes.NotFound, "系统参数不存在"); diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantAnnouncementsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantAnnouncementsController.cs index e556b3d..d64b89f 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantAnnouncementsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantAnnouncementsController.cs @@ -28,8 +28,13 @@ public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiC [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] public async Task>> Search(long tenantId, [FromQuery] SearchTenantAnnouncementsQuery query, CancellationToken cancellationToken) { + // 1. 绑定租户标识 query = query with { TenantId = tenantId }; + + // 2. 查询公告列表 var result = await mediator.Send(query, cancellationToken); + + // 3. 返回分页结果 return ApiResponse>.Ok(result); } @@ -42,7 +47,10 @@ public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiC [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Detail(long tenantId, long announcementId, CancellationToken cancellationToken) { + // 1. 查询指定公告 var result = await mediator.Send(new GetTenantAnnouncementQuery { TenantId = tenantId, AnnouncementId = announcementId }, cancellationToken); + + // 2. 返回详情或 404 return result is null ? ApiResponse.Error(StatusCodes.Status404NotFound, "公告不存在") : ApiResponse.Ok(result); @@ -56,7 +64,10 @@ public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiC [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> Create(long tenantId, [FromBody, Required] CreateTenantAnnouncementCommand command, CancellationToken cancellationToken) { + // 1. 绑定租户标识 command = command with { TenantId = tenantId }; + + // 2. 创建公告并返回 var result = await mediator.Send(command, cancellationToken); return ApiResponse.Ok(result); } @@ -70,8 +81,13 @@ public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiC [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Update(long tenantId, long announcementId, [FromBody, Required] UpdateTenantAnnouncementCommand command, CancellationToken cancellationToken) { + // 1. 绑定租户与公告标识 command = command with { TenantId = tenantId, AnnouncementId = announcementId }; + + // 2. 执行更新 var result = await mediator.Send(command, cancellationToken); + + // 3. 返回更新结果或 404 return result is null ? ApiResponse.Error(StatusCodes.Status404NotFound, "公告不存在") : ApiResponse.Ok(result); @@ -85,7 +101,10 @@ public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiC [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> Delete(long tenantId, long announcementId, CancellationToken cancellationToken) { + // 1. 删除公告 var result = await mediator.Send(new DeleteTenantAnnouncementCommand { TenantId = tenantId, AnnouncementId = announcementId }, cancellationToken); + + // 2. 返回执行结果 return ApiResponse.Ok(result); } @@ -98,7 +117,10 @@ public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiC [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> MarkRead(long tenantId, long announcementId, CancellationToken cancellationToken) { + // 1. 标记公告已读 var result = await mediator.Send(new MarkTenantAnnouncementReadCommand { TenantId = tenantId, AnnouncementId = announcementId }, cancellationToken); + + // 2. 返回结果或 404 return result is null ? ApiResponse.Error(StatusCodes.Status404NotFound, "公告不存在") : ApiResponse.Ok(result); diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantBillingsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantBillingsController.cs index fb2093f..85ca964 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantBillingsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantBillingsController.cs @@ -28,8 +28,13 @@ public sealed class TenantBillingsController(IMediator mediator) : BaseApiContro [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] public async Task>> Search(long tenantId, [FromQuery] SearchTenantBillsQuery query, CancellationToken cancellationToken) { + // 1. 绑定租户标识 query = query with { TenantId = tenantId }; + + // 2. 查询账单列表 var result = await mediator.Send(query, cancellationToken); + + // 3. 返回分页结果 return ApiResponse>.Ok(result); } @@ -42,7 +47,10 @@ public sealed class TenantBillingsController(IMediator mediator) : BaseApiContro [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Detail(long tenantId, long billingId, CancellationToken cancellationToken) { + // 1. 查询账单详情 var result = await mediator.Send(new GetTenantBillQuery { TenantId = tenantId, BillingId = billingId }, cancellationToken); + + // 2. 返回详情或 404 return result is null ? ApiResponse.Error(StatusCodes.Status404NotFound, "账单不存在") : ApiResponse.Ok(result); @@ -56,7 +64,10 @@ public sealed class TenantBillingsController(IMediator mediator) : BaseApiContro [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> Create(long tenantId, [FromBody, Required] CreateTenantBillingCommand command, CancellationToken cancellationToken) { + // 1. 绑定租户标识 command = command with { TenantId = tenantId }; + + // 2. 创建账单 var result = await mediator.Send(command, cancellationToken); return ApiResponse.Ok(result); } @@ -70,8 +81,13 @@ public sealed class TenantBillingsController(IMediator mediator) : BaseApiContro [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> MarkPaid(long tenantId, long billingId, [FromBody, Required] MarkTenantBillingPaidCommand command, CancellationToken cancellationToken) { + // 1. 绑定租户与账单标识 command = command with { TenantId = tenantId, BillingId = billingId }; + + // 2. 标记支付状态 var result = await mediator.Send(command, cancellationToken); + + // 3. 返回结果或 404 return result is null ? ApiResponse.Error(StatusCodes.Status404NotFound, "账单不存在") : ApiResponse.Ok(result); diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantNotificationsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantNotificationsController.cs index 84babb5..dd79892 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantNotificationsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantNotificationsController.cs @@ -27,8 +27,13 @@ public sealed class TenantNotificationsController(IMediator mediator) : BaseApiC [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] public async Task>> Search(long tenantId, [FromQuery] SearchTenantNotificationsQuery query, CancellationToken cancellationToken) { + // 1. 绑定租户标识 query = query with { TenantId = tenantId }; + + // 2. 查询通知列表 var result = await mediator.Send(query, cancellationToken); + + // 3. 返回分页结果 return ApiResponse>.Ok(result); } @@ -41,7 +46,10 @@ public sealed class TenantNotificationsController(IMediator mediator) : BaseApiC [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> MarkRead(long tenantId, long notificationId, CancellationToken cancellationToken) { + // 1. 标记通知为已读 var result = await mediator.Send(new MarkTenantNotificationReadCommand { TenantId = tenantId, NotificationId = notificationId }, cancellationToken); + + // 2. 返回结果或 404 return result is null ? ApiResponse.Error(StatusCodes.Status404NotFound, "通知不存在") : ApiResponse.Ok(result); diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs index 6f5d77a..3504bd9 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs @@ -28,7 +28,10 @@ public sealed class TenantPackagesController(IMediator mediator) : BaseApiContro [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] public async Task>> Search([FromQuery] SearchTenantPackagesQuery query, CancellationToken cancellationToken) { + // 1. 查询租户套餐分页 var result = await mediator.Send(query, cancellationToken); + + // 2. 返回结果 return ApiResponse>.Ok(result); } @@ -41,7 +44,10 @@ public sealed class TenantPackagesController(IMediator mediator) : BaseApiContro [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Detail(long tenantPackageId, CancellationToken cancellationToken) { + // 1. 查询套餐详情 var result = await mediator.Send(new GetTenantPackageByIdQuery { TenantPackageId = tenantPackageId }, cancellationToken); + + // 2. 返回查询结果或 404 return result is null ? ApiResponse.Error(StatusCodes.Status404NotFound, "套餐不存在") : ApiResponse.Ok(result); @@ -55,7 +61,10 @@ public sealed class TenantPackagesController(IMediator mediator) : BaseApiContro [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> Create([FromBody, Required] CreateTenantPackageCommand command, CancellationToken cancellationToken) { + // 1. 执行创建 var result = await mediator.Send(command, cancellationToken); + + // 2. 返回创建结果 return ApiResponse.Ok(result); } @@ -68,8 +77,13 @@ public sealed class TenantPackagesController(IMediator mediator) : BaseApiContro [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Update(long tenantPackageId, [FromBody, Required] UpdateTenantPackageCommand command, CancellationToken cancellationToken) { + // 1. 绑定路由 ID command = command with { TenantPackageId = tenantPackageId }; + + // 2. 执行更新 var result = await mediator.Send(command, cancellationToken); + + // 3. 返回更新结果或 404 return result is null ? ApiResponse.Error(StatusCodes.Status404NotFound, "套餐不存在") : ApiResponse.Ok(result); @@ -83,7 +97,10 @@ public sealed class TenantPackagesController(IMediator mediator) : BaseApiContro [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> Delete(long tenantPackageId, CancellationToken cancellationToken) { + // 1. 构建删除命令 var command = new DeleteTenantPackageCommand { TenantPackageId = tenantPackageId }; + + // 2. 执行删除并返回 var result = await mediator.Send(command, cancellationToken); return ApiResponse.Ok(result); } diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs index f1c3bcd..0d91ee2 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs @@ -29,7 +29,10 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> Register([FromBody] RegisterTenantCommand command, CancellationToken cancellationToken) { + // 1. 注册租户并初始化套餐 var result = await mediator.Send(command, cancellationToken); + + // 2. 返回注册结果 return ApiResponse.Ok(result); } @@ -41,7 +44,10 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] public async Task>> Search([FromQuery] SearchTenantsQuery query, CancellationToken cancellationToken) { + // 1. 查询租户分页 var result = await mediator.Send(query, cancellationToken); + + // 2. 返回分页数据 return ApiResponse>.Ok(result); } @@ -53,7 +59,10 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> Detail(long tenantId, CancellationToken cancellationToken) { + // 1. 查询租户详情 var result = await mediator.Send(new GetTenantByIdQuery(tenantId), cancellationToken); + + // 2. 返回租户信息 return ApiResponse.Ok(result); } @@ -68,8 +77,13 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController [FromBody] SubmitTenantVerificationCommand body, CancellationToken cancellationToken) { + // 1. 合并路由中的租户标识 var command = body with { TenantId = tenantId }; + + // 2. 提交或更新认证资料 var result = await mediator.Send(command, cancellationToken); + + // 3. 返回认证结果 return ApiResponse.Ok(result); } @@ -81,8 +95,13 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> Review(long tenantId, [FromBody] ReviewTenantCommand body, CancellationToken cancellationToken) { + // 1. 绑定租户标识 var command = body with { TenantId = tenantId }; + + // 2. 执行审核 var result = await mediator.Send(command, cancellationToken); + + // 3. 返回审核结果 return ApiResponse.Ok(result); } @@ -97,7 +116,10 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController [FromBody] CreateTenantSubscriptionCommand body, CancellationToken cancellationToken) { + // 1. 绑定租户并创建或续费订阅 var command = body with { TenantId = tenantId }; + + // 2. 返回订阅结果 var result = await mediator.Send(command, cancellationToken); return ApiResponse.Ok(result); } @@ -114,8 +136,13 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController [FromBody] ChangeTenantSubscriptionPlanCommand body, CancellationToken cancellationToken) { + // 1. 绑定租户与订阅标识 var command = body with { TenantId = tenantId, TenantSubscriptionId = subscriptionId }; + + // 2. 执行升降配 var result = await mediator.Send(command, cancellationToken); + + // 3. 返回调整后的订阅 return ApiResponse.Ok(result); } @@ -131,7 +158,10 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController [FromQuery] int pageSize = 20, CancellationToken cancellationToken = default) { + // 1. 构造审核日志查询 var query = new GetTenantAuditLogsQuery(tenantId, page, pageSize); + + // 2. 查询并返回分页结果 var result = await mediator.Send(query, cancellationToken); return ApiResponse>.Ok(result); } @@ -148,7 +178,10 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController [FromBody, Required] CheckTenantQuotaCommand body, CancellationToken cancellationToken) { + // 1. 绑定租户标识 var command = body with { TenantId = tenantId }; + + // 2. 校验并占用配额 var result = await mediator.Send(command, cancellationToken); return ApiResponse.Ok(result); } diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/UserPermissionsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/UserPermissionsController.cs index 328499a..f2f54e2 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/UserPermissionsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/UserPermissionsController.cs @@ -58,6 +58,7 @@ public sealed class UserPermissionsController(IAdminAuthService authService) : B [FromQuery] SearchUserPermissionsQuery query, CancellationToken cancellationToken) { + // 1. 查询当前租户的用户权限概览 var result = await authService.SearchUserPermissionsAsync( query.Keyword, query.Page, @@ -66,6 +67,7 @@ public sealed class UserPermissionsController(IAdminAuthService authService) : B query.SortDescending, cancellationToken); + // 2. 返回分页结果 return ApiResponse>.Ok(result); } } diff --git a/src/Api/TakeoutSaaS.AdminApi/Program.cs b/src/Api/TakeoutSaaS.AdminApi/Program.cs index 32b6036..432bd87 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Program.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Program.cs @@ -26,13 +26,16 @@ using TakeoutSaaS.Module.Tenancy.Extensions; using TakeoutSaaS.Shared.Web.Extensions; using TakeoutSaaS.Shared.Web.Swagger; +// 1. 创建构建器与日志模板 var builder = WebApplication.CreateBuilder(args); const string logTemplate = "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [TraceId:{TraceId}] [SpanId:{SpanId}] [Service:{Service}] {SourceContext} {Message:lj}{NewLine}{Exception}"; +// 2. 加载种子配置文件 builder.Configuration .AddJsonFile("appsettings.Seed.json", optional: true, reloadOnChange: true) .AddJsonFile($"appsettings.Seed.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true); +// 3. 配置 Serilog 输出 builder.Host.UseSerilog((context, _, configuration) => { configuration @@ -47,6 +50,7 @@ builder.Host.UseSerilog((context, _, configuration) => outputTemplate: logTemplate); }); +// 4. 注册通用 Web 能力与 Swagger builder.Services.AddSharedWebCore(); builder.Services.AddSharedSwagger(options => { @@ -54,6 +58,8 @@ builder.Services.AddSharedSwagger(options => options.Description = "管理后台 API 文档"; options.EnableAuthorization = true; }); + +// 5. 注册领域与基础设施模块 builder.Services.AddIdentityApplication(); builder.Services.AddIdentityInfrastructure(builder.Configuration, enableAdminSeed: true); builder.Services.AddAppInfrastructure(builder.Configuration); @@ -71,6 +77,8 @@ builder.Services.AddMessagingModule(builder.Configuration); builder.Services.AddMessagingApplication(); builder.Services.AddSchedulerModule(builder.Configuration); builder.Services.AddHealthChecks(); + +// 6. 配置 OpenTelemetry 采集 var otelSection = builder.Configuration.GetSection("Otel"); var otelEndpoint = otelSection.GetValue("Endpoint"); var useConsoleExporter = otelSection.GetValue("UseConsoleExporter") ?? builder.Environment.IsDevelopment(); @@ -86,7 +94,6 @@ builder.Services.AddOpenTelemetry() .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() .AddEntityFrameworkCoreInstrumentation(); - if (!string.IsNullOrWhiteSpace(otelEndpoint)) { tracing.AddOtlpExporter(exporter => @@ -94,7 +101,6 @@ builder.Services.AddOpenTelemetry() exporter.Endpoint = new Uri(otelEndpoint); }); } - if (useConsoleExporter) { tracing.AddConsoleExporter(); @@ -107,7 +113,6 @@ builder.Services.AddOpenTelemetry() .AddHttpClientInstrumentation() .AddRuntimeInstrumentation() .AddPrometheusExporter(); - if (!string.IsNullOrWhiteSpace(otelEndpoint)) { metrics.AddOtlpExporter(exporter => @@ -115,13 +120,13 @@ builder.Services.AddOpenTelemetry() exporter.Endpoint = new Uri(otelEndpoint); }); } - if (useConsoleExporter) { metrics.AddConsoleExporter(); } }); +// 7. 解析并配置 CORS var adminOrigins = ResolveCorsOrigins(builder.Configuration, "Cors:Admin"); builder.Services.AddCors(options => { @@ -131,8 +136,8 @@ builder.Services.AddCors(options => }); }); +// 8. 构建应用并配置中间件管道 var app = builder.Build(); - app.UseCors("AdminApiCors"); app.UseTenantResolution(); app.UseSharedWebCore(); @@ -140,12 +145,12 @@ app.UseAuthentication(); app.UseAuthorization(); app.UseSharedSwagger(); app.UseSchedulerDashboard(builder.Configuration); - app.MapHealthChecks("/healthz"); app.MapPrometheusScrapingEndpoint(); app.MapControllers(); app.Run(); +// 9. 解析配置中的 CORS 域名 static string[] ResolveCorsOrigins(IConfiguration configuration, string sectionKey) { var origins = configuration.GetSection(sectionKey).Get(); @@ -155,6 +160,7 @@ static string[] ResolveCorsOrigins(IConfiguration configuration, string sectionK .ToArray() ?? []; } +// 10. 构建 CORS 策略 static void ConfigureCorsPolicy(CorsPolicyBuilder policy, string[] origins) { if (origins.Length == 0) @@ -166,7 +172,6 @@ static void ConfigureCorsPolicy(CorsPolicyBuilder policy, string[] origins) policy.WithOrigins(origins) .AllowCredentials(); } - policy .AllowAnyHeader() .AllowAnyMethod(); diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/AuthController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/AuthController.cs index 332dec2..5062afd 100644 --- a/src/Api/TakeoutSaaS.MiniApi/Controllers/AuthController.cs +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/AuthController.cs @@ -10,17 +10,13 @@ namespace TakeoutSaaS.MiniApi.Controllers; /// /// 小程序登录认证 /// -/// -/// 小程序登录认证 -/// -/// +/// 提供小程序端的微信登录与 Token 刷新能力。 +/// 小程序认证服务 [ApiVersion("1.0")] [Authorize] [Route("api/mini/v{version:apiVersion}/auth")] public sealed class AuthController(IMiniAuthService authService) : BaseApiController { - - /// /// 微信登录 /// @@ -29,7 +25,10 @@ public sealed class AuthController(IMiniAuthService authService) : BaseApiContro [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> LoginWithWeChat([FromBody] WeChatLoginRequest request, CancellationToken cancellationToken) { + // 1. 调用认证服务完成微信登录 var response = await authService.LoginWithWeChatAsync(request, cancellationToken); + + // 2. 返回访问与刷新令牌 return ApiResponse.Ok(response); } @@ -41,7 +40,10 @@ public sealed class AuthController(IMiniAuthService authService) : BaseApiContro [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken) { + // 1. 调用认证服务刷新 Token var response = await authService.RefreshTokenAsync(request, cancellationToken); + + // 2. 返回新的令牌 return ApiResponse.Ok(response); } } diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/FilesController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/FilesController.cs index a795c9e..67d5961 100644 --- a/src/Api/TakeoutSaaS.MiniApi/Controllers/FilesController.cs +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/FilesController.cs @@ -19,8 +19,6 @@ namespace TakeoutSaaS.MiniApi.Controllers; [Route("api/mini/v{version:apiVersion}/files")] public sealed class FilesController(IFileStorageService fileStorageService) : BaseApiController { - private readonly IFileStorageService _fileStorageService = fileStorageService; - /// /// 上传图片或文件。 /// @@ -30,23 +28,28 @@ public sealed class FilesController(IFileStorageService fileStorageService) : Ba [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] public async Task> Upload([FromForm] IFormFile? file, [FromForm] string? type, CancellationToken cancellationToken) { + // 1. 校验文件有效性 if (file == null || file.Length == 0) { return ApiResponse.Error(ErrorCodes.BadRequest, "文件不能为空"); } + // 2. 解析上传类型 if (!UploadFileTypeParser.TryParse(type, out var uploadType)) { return ApiResponse.Error(ErrorCodes.BadRequest, "上传类型不合法"); } + // 3. 提取请求来源 var origin = Request.Headers["Origin"].FirstOrDefault() ?? Request.Headers["Referer"].FirstOrDefault(); await using var stream = file.OpenReadStream(); - var result = await _fileStorageService.UploadAsync( + // 4. 调用存储服务执行上传 + var result = await fileStorageService.UploadAsync( new UploadFileRequest(uploadType, stream, file.FileName, file.ContentType ?? string.Empty, file.Length, origin), cancellationToken); + // 5. 返回上传结果 return ApiResponse.Ok(result); } } diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/HealthController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/HealthController.cs index c2673f8..d4e9920 100644 --- a/src/Api/TakeoutSaaS.MiniApi/Controllers/HealthController.cs +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/HealthController.cs @@ -23,7 +23,10 @@ public class HealthController : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public ApiResponse Get() { + // 1. 构造健康状态 var payload = new { status = "OK", service = "MiniApi", time = DateTime.UtcNow }; + + // 2. 返回健康响应 return ApiResponse.Ok(payload); } } diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/MeController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/MeController.cs index 4bd29e4..4c29f25 100644 --- a/src/Api/TakeoutSaaS.MiniApi/Controllers/MeController.cs +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/MeController.cs @@ -16,17 +16,13 @@ namespace TakeoutSaaS.MiniApi.Controllers; /// /// 当前用户信息 /// -/// -/// -/// -/// +/// 提供小程序端当前用户档案查询。 +/// 小程序认证服务 [ApiVersion("1.0")] [Authorize] [Route("api/mini/v{version:apiVersion}/me")] public sealed class MeController(IMiniAuthService authService) : BaseApiController { - - /// /// 获取用户档案 /// @@ -35,12 +31,14 @@ public sealed class MeController(IMiniAuthService authService) : BaseApiControll [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)] public async Task> Get(CancellationToken cancellationToken) { + // 1. 从 JWT 中解析用户标识 var userId = User.GetUserId(); if (userId == 0) { return ApiResponse.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识"); } + // 2. 查询用户档案并返回 var profile = await authService.GetProfileAsync(userId, cancellationToken); return ApiResponse.Ok(profile); } diff --git a/src/Api/TakeoutSaaS.MiniApi/Program.cs b/src/Api/TakeoutSaaS.MiniApi/Program.cs index 3c5adac..d4df34c 100644 --- a/src/Api/TakeoutSaaS.MiniApi/Program.cs +++ b/src/Api/TakeoutSaaS.MiniApi/Program.cs @@ -18,9 +18,11 @@ using TakeoutSaaS.Shared.Kernel.Ids; using TakeoutSaaS.Shared.Web.Extensions; using TakeoutSaaS.Shared.Web.Swagger; +// 1. 创建构建器与日志模板 var builder = WebApplication.CreateBuilder(args); const string logTemplate = "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [TraceId:{TraceId}] [SpanId:{SpanId}] [Service:{Service}] {SourceContext} {Message:lj}{NewLine}{Exception}"; +// 2. 注册雪花 ID 生成器与 Serilog builder.Services.AddSingleton(_ => new SnowflakeIdGenerator()); builder.Host.UseSerilog((_, _, configuration) => { @@ -36,6 +38,7 @@ builder.Host.UseSerilog((_, _, configuration) => outputTemplate: logTemplate); }); +// 3. 注册通用 Web 能力与 Swagger builder.Services.AddSharedWebCore(); builder.Services.AddSharedSwagger(options => { @@ -43,6 +46,8 @@ builder.Services.AddSharedSwagger(options => options.Description = "小程序 API 文档"; options.EnableAuthorization = true; }); + +// 4. 注册多租户与业务模块 builder.Services.AddTenantResolution(builder.Configuration); builder.Services.AddStorageModule(builder.Configuration); builder.Services.AddStorageApplication(); @@ -51,6 +56,8 @@ builder.Services.AddSmsApplication(builder.Configuration); builder.Services.AddMessagingModule(builder.Configuration); builder.Services.AddMessagingApplication(); builder.Services.AddHealthChecks(); + +// 5. 配置 OpenTelemetry 采集 var otelSection = builder.Configuration.GetSection("Otel"); var otelEndpoint = otelSection.GetValue("Endpoint"); var useConsoleExporter = otelSection.GetValue("UseConsoleExporter") ?? builder.Environment.IsDevelopment(); @@ -102,6 +109,7 @@ builder.Services.AddOpenTelemetry() } }); +// 6. 配置 CORS var miniOrigins = ResolveCorsOrigins(builder.Configuration, "Cors:Mini"); builder.Services.AddCors(options => { @@ -111,6 +119,7 @@ builder.Services.AddCors(options => }); }); +// 7. 构建应用并配置中间件管道 var app = builder.Build(); app.UseCors("MiniApiCors"); @@ -123,6 +132,7 @@ app.MapPrometheusScrapingEndpoint(); app.MapControllers(); app.Run(); +// 8. 解析配置中的 CORS 域名 static string[] ResolveCorsOrigins(IConfiguration configuration, string sectionKey) { var origins = configuration.GetSection(sectionKey).Get(); @@ -132,6 +142,7 @@ static string[] ResolveCorsOrigins(IConfiguration configuration, string sectionK .ToArray() ?? []; } +// 9. 构建 CORS 策略 static void ConfigureCorsPolicy(CorsPolicyBuilder policy, string[] origins) { if (origins.Length == 0) diff --git a/src/Api/TakeoutSaaS.UserApi/Controllers/HealthController.cs b/src/Api/TakeoutSaaS.UserApi/Controllers/HealthController.cs index e1fa6d5..0056def 100644 --- a/src/Api/TakeoutSaaS.UserApi/Controllers/HealthController.cs +++ b/src/Api/TakeoutSaaS.UserApi/Controllers/HealthController.cs @@ -23,7 +23,10 @@ public class HealthController : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public ApiResponse Get() { + // 1. 构造健康状态 var payload = new { status = "OK", service = "UserApi", time = DateTime.UtcNow }; + + // 2. 返回健康响应 return ApiResponse.Ok(payload); } } diff --git a/src/Api/TakeoutSaaS.UserApi/Program.cs b/src/Api/TakeoutSaaS.UserApi/Program.cs index 5c93992..a531e11 100644 --- a/src/Api/TakeoutSaaS.UserApi/Program.cs +++ b/src/Api/TakeoutSaaS.UserApi/Program.cs @@ -12,9 +12,11 @@ using TakeoutSaaS.Shared.Kernel.Ids; using TakeoutSaaS.Shared.Web.Extensions; using TakeoutSaaS.Shared.Web.Swagger; +// 1. 创建构建器与日志模板 var builder = WebApplication.CreateBuilder(args); const string logTemplate = "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [TraceId:{TraceId}] [SpanId:{SpanId}] [Service:{Service}] {SourceContext} {Message:lj}{NewLine}{Exception}"; +// 2. 注册雪花 ID 生成器与 Serilog builder.Services.AddSingleton(_ => new SnowflakeIdGenerator()); builder.Host.UseSerilog((_, _, configuration) => { @@ -30,6 +32,7 @@ builder.Host.UseSerilog((_, _, configuration) => outputTemplate: logTemplate); }); +// 3. 注册通用 Web 能力与 Swagger builder.Services.AddSharedWebCore(); builder.Services.AddSharedSwagger(options => { @@ -37,8 +40,12 @@ builder.Services.AddSharedSwagger(options => options.Description = "C 端用户 API 文档"; options.EnableAuthorization = true; }); + +// 4. 注册多租户与健康检查 builder.Services.AddTenantResolution(builder.Configuration); builder.Services.AddHealthChecks(); + +// 5. 配置 OpenTelemetry 采集 var otelSection = builder.Configuration.GetSection("Otel"); var otelEndpoint = otelSection.GetValue("Endpoint"); var useConsoleExporter = otelSection.GetValue("UseConsoleExporter") ?? builder.Environment.IsDevelopment(); @@ -90,6 +97,7 @@ builder.Services.AddOpenTelemetry() } }); +// 6. 配置 CORS var userOrigins = ResolveCorsOrigins(builder.Configuration, "Cors:User"); builder.Services.AddCors(options => { @@ -99,6 +107,7 @@ builder.Services.AddCors(options => }); }); +// 7. 构建应用并配置中间件管道 var app = builder.Build(); app.UseCors("UserApiCors"); @@ -111,6 +120,7 @@ app.MapPrometheusScrapingEndpoint(); app.MapControllers(); app.Run(); +// 8. 解析配置中的 CORS 域名 static string[] ResolveCorsOrigins(IConfiguration configuration, string sectionKey) { var origins = configuration.GetSection(sectionKey).Get(); @@ -120,6 +130,7 @@ static string[] ResolveCorsOrigins(IConfiguration configuration, string sectionK .ToArray() ?? []; } +// 9. 构建 CORS 策略 static void ConfigureCorsPolicy(CorsPolicyBuilder policy, string[] origins) { if (origins.Length == 0) diff --git a/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/CreateDeliveryOrderCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/CreateDeliveryOrderCommandHandler.cs index 1a67240..ad4149f 100644 --- a/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/CreateDeliveryOrderCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/CreateDeliveryOrderCommandHandler.cs @@ -13,12 +13,10 @@ namespace TakeoutSaaS.Application.App.Deliveries.Handlers; public sealed class CreateDeliveryOrderCommandHandler(IDeliveryRepository deliveryRepository, ILogger logger) : IRequestHandler { - private readonly IDeliveryRepository _deliveryRepository = deliveryRepository; - private readonly ILogger _logger = logger; - /// public async Task Handle(CreateDeliveryOrderCommand request, CancellationToken cancellationToken) { + // 1. 构建配送单实体 var deliveryOrder = new DeliveryOrder { OrderId = request.OrderId, @@ -34,10 +32,14 @@ public sealed class CreateDeliveryOrderCommandHandler(IDeliveryRepository delive FailureReason = request.FailureReason?.Trim() }; - await _deliveryRepository.AddDeliveryOrderAsync(deliveryOrder, cancellationToken); - await _deliveryRepository.SaveChangesAsync(cancellationToken); - _logger.LogInformation("创建配送单 {DeliveryOrderId} 对应订单 {OrderId}", deliveryOrder.Id, deliveryOrder.OrderId); + // 2. 持久化配送单 + await deliveryRepository.AddDeliveryOrderAsync(deliveryOrder, cancellationToken); + await deliveryRepository.SaveChangesAsync(cancellationToken); + // 3. 记录日志 + logger.LogInformation("创建配送单 {DeliveryOrderId} 对应订单 {OrderId}", deliveryOrder.Id, deliveryOrder.OrderId); + + // 4. 映射 DTO 返回 return MapToDto(deliveryOrder, []); } diff --git a/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/DeleteDeliveryOrderCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/DeleteDeliveryOrderCommandHandler.cs index 40974f1..308c942 100644 --- a/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/DeleteDeliveryOrderCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/DeleteDeliveryOrderCommandHandler.cs @@ -15,23 +15,23 @@ public sealed class DeleteDeliveryOrderCommandHandler( ILogger logger) : IRequestHandler { - private readonly IDeliveryRepository _deliveryRepository = deliveryRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - private readonly ILogger _logger = logger; - /// public async Task Handle(DeleteDeliveryOrderCommand request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var existing = await _deliveryRepository.FindByIdAsync(request.DeliveryOrderId, tenantId, cancellationToken); + // 1. 获取租户并定位配送单 + var tenantId = tenantProvider.GetCurrentTenantId(); + var existing = await deliveryRepository.FindByIdAsync(request.DeliveryOrderId, tenantId, cancellationToken); if (existing == null) { return false; } - await _deliveryRepository.DeleteDeliveryOrderAsync(request.DeliveryOrderId, tenantId, cancellationToken); - await _deliveryRepository.SaveChangesAsync(cancellationToken); - _logger.LogInformation("删除配送单 {DeliveryOrderId}", request.DeliveryOrderId); + // 2. 删除并保存 + await deliveryRepository.DeleteDeliveryOrderAsync(request.DeliveryOrderId, tenantId, cancellationToken); + await deliveryRepository.SaveChangesAsync(cancellationToken); + + // 3. 记录删除日志 + logger.LogInformation("删除配送单 {DeliveryOrderId}", request.DeliveryOrderId); return true; } diff --git a/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/GetDeliveryOrderByIdQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/GetDeliveryOrderByIdQueryHandler.cs index 24e425f..d863d4b 100644 --- a/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/GetDeliveryOrderByIdQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/GetDeliveryOrderByIdQueryHandler.cs @@ -15,20 +15,23 @@ public sealed class GetDeliveryOrderByIdQueryHandler( ITenantProvider tenantProvider) : IRequestHandler { - private readonly IDeliveryRepository _deliveryRepository = deliveryRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - /// public async Task Handle(GetDeliveryOrderByIdQuery request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var order = await _deliveryRepository.FindByIdAsync(request.DeliveryOrderId, tenantId, cancellationToken); + // 1. 读取当前租户标识 + var tenantId = tenantProvider.GetCurrentTenantId(); + + // 2. 查询配送单主体 + var order = await deliveryRepository.FindByIdAsync(request.DeliveryOrderId, tenantId, cancellationToken); if (order == null) { return null; } - var events = await _deliveryRepository.GetEventsAsync(order.Id, tenantId, cancellationToken); + // 3. 查询配送事件明细 + var events = await deliveryRepository.GetEventsAsync(order.Id, tenantId, cancellationToken); + + // 4. 映射为 DTO 返回 return MapToDto(order, events); } diff --git a/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/SearchDeliveryOrdersQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/SearchDeliveryOrdersQueryHandler.cs index cc748db..7fcdd5a 100644 --- a/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/SearchDeliveryOrdersQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/SearchDeliveryOrdersQueryHandler.cs @@ -15,21 +15,25 @@ public sealed class SearchDeliveryOrdersQueryHandler( ITenantProvider tenantProvider) : IRequestHandler> { - private readonly IDeliveryRepository _deliveryRepository = deliveryRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - /// public async Task> Handle(SearchDeliveryOrdersQuery request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var orders = await _deliveryRepository.SearchAsync(tenantId, request.Status, request.OrderId, cancellationToken); + // 1. 获取当前租户标识 + var tenantId = tenantProvider.GetCurrentTenantId(); + // 2. 查询配送单列表(租户隔离) + var orders = await deliveryRepository.SearchAsync(tenantId, request.Status, request.OrderId, cancellationToken); + + // 3. 本地排序 var sorted = ApplySorting(orders, request.SortBy, request.SortDescending); + + // 4. 本地分页 var paged = sorted .Skip((request.Page - 1) * request.PageSize) .Take(request.PageSize) .ToList(); + // 5. 映射 DTO var items = paged.Select(order => new DeliveryOrderDto { Id = order.Id, @@ -48,6 +52,7 @@ public sealed class SearchDeliveryOrdersQueryHandler( CreatedAt = order.CreatedAt }).ToList(); + // 6. 返回分页结果 return new PagedResult(items, request.Page, request.PageSize, orders.Count); } diff --git a/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/UpdateDeliveryOrderCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/UpdateDeliveryOrderCommandHandler.cs index c8df929..0c3f2c2 100644 --- a/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/UpdateDeliveryOrderCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/UpdateDeliveryOrderCommandHandler.cs @@ -17,20 +17,20 @@ public sealed class UpdateDeliveryOrderCommandHandler( ILogger logger) : IRequestHandler { - private readonly IDeliveryRepository _deliveryRepository = deliveryRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - private readonly ILogger _logger = logger; - /// public async Task Handle(UpdateDeliveryOrderCommand request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var existing = await _deliveryRepository.FindByIdAsync(request.DeliveryOrderId, tenantId, cancellationToken); + // 1. 获取当前租户标识 + var tenantId = tenantProvider.GetCurrentTenantId(); + + // 2. 查询目标配送单 + var existing = await deliveryRepository.FindByIdAsync(request.DeliveryOrderId, tenantId, cancellationToken); if (existing == null) { return null; } + // 3. 更新字段 existing.OrderId = request.OrderId; existing.Provider = request.Provider; existing.ProviderOrderId = request.ProviderOrderId?.Trim(); @@ -43,11 +43,15 @@ public sealed class UpdateDeliveryOrderCommandHandler( existing.DeliveredAt = request.DeliveredAt; existing.FailureReason = request.FailureReason?.Trim(); - await _deliveryRepository.UpdateDeliveryOrderAsync(existing, cancellationToken); - await _deliveryRepository.SaveChangesAsync(cancellationToken); - _logger.LogInformation("更新配送单 {DeliveryOrderId}", existing.Id); + // 4. 持久化变更 + await deliveryRepository.UpdateDeliveryOrderAsync(existing, cancellationToken); + await deliveryRepository.SaveChangesAsync(cancellationToken); - var events = await _deliveryRepository.GetEventsAsync(existing.Id, tenantId, cancellationToken); + // 5. 记录更新日志 + logger.LogInformation("更新配送单 {DeliveryOrderId}", existing.Id); + + // 6. 查询事件并返回映射结果 + var events = await deliveryRepository.GetEventsAsync(existing.Id, tenantId, cancellationToken); return MapToDto(existing, events); } diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/AddMerchantDocumentCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/AddMerchantDocumentCommandHandler.cs index 46e2923..2acfd29 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/AddMerchantDocumentCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/AddMerchantDocumentCommandHandler.cs @@ -22,20 +22,17 @@ public sealed class AddMerchantDocumentCommandHandler( ICurrentUserAccessor currentUserAccessor) : IRequestHandler { - private readonly IMerchantRepository _merchantRepository = merchantRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - private readonly IIdGenerator _idGenerator = idGenerator; - private readonly ICurrentUserAccessor _currentUserAccessor = currentUserAccessor; - public async Task Handle(AddMerchantDocumentCommand request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var merchant = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken) + // 1. 获取租户并查询商户 + var tenantId = tenantProvider.GetCurrentTenantId(); + var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken) ?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在"); + // 2. 构建证照记录 var document = new MerchantDocument { - Id = _idGenerator.NextId(), + Id = idGenerator.NextId(), MerchantId = merchant.Id, DocumentType = request.DocumentType, Status = MerchantDocumentStatus.Pending, @@ -45,8 +42,9 @@ public sealed class AddMerchantDocumentCommandHandler( ExpiresAt = request.ExpiresAt }; - await _merchantRepository.AddDocumentAsync(document, cancellationToken); - await _merchantRepository.AddAuditLogAsync(new MerchantAuditLog + // 3. 持久化与审计 + await merchantRepository.AddDocumentAsync(document, cancellationToken); + await merchantRepository.AddAuditLogAsync(new MerchantAuditLog { TenantId = tenantId, MerchantId = merchant.Id, @@ -57,20 +55,21 @@ public sealed class AddMerchantDocumentCommandHandler( OperatorName = ResolveOperatorName() }, cancellationToken); - await _merchantRepository.SaveChangesAsync(cancellationToken); + await merchantRepository.SaveChangesAsync(cancellationToken); + // 4. 返回 DTO return MerchantMapping.ToDto(document); } private long? ResolveOperatorId() { - var id = _currentUserAccessor.UserId; + var id = currentUserAccessor.UserId; return id == 0 ? null : id; } private string ResolveOperatorName() { - var id = _currentUserAccessor.UserId; + var id = currentUserAccessor.UserId; return id == 0 ? "system" : $"user:{id}"; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCategoryCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCategoryCommandHandler.cs index 84dd79e..c94e995 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCategoryCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCategoryCommandHandler.cs @@ -18,22 +18,23 @@ public sealed class CreateMerchantCategoryCommandHandler( ITenantProvider tenantProvider) : IRequestHandler { - private readonly IMerchantCategoryRepository _categoryRepository = categoryRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - public async Task Handle(CreateMerchantCategoryCommand request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); + // 1. 获取租户上下文 + var tenantId = tenantProvider.GetCurrentTenantId(); var normalizedName = request.Name.Trim(); - if (await _categoryRepository.ExistsAsync(normalizedName, tenantId, cancellationToken)) + // 2. 检查重名 + if (await categoryRepository.ExistsAsync(normalizedName, tenantId, cancellationToken)) { throw new BusinessException(ErrorCodes.Conflict, $"类目“{normalizedName}”已存在"); } - var categories = await _categoryRepository.ListAsync(tenantId, cancellationToken); + // 3. 计算排序 + var categories = await categoryRepository.ListAsync(tenantId, cancellationToken); var targetOrder = request.DisplayOrder ?? (categories.Count == 0 ? 1 : categories.Max(x => x.DisplayOrder) + 1); + // 4. 构建实体 var entity = new MerchantCategory { Name = normalizedName, @@ -41,8 +42,9 @@ public sealed class CreateMerchantCategoryCommandHandler( IsActive = request.IsActive }; - await _categoryRepository.AddAsync(entity, cancellationToken); - await _categoryRepository.SaveChangesAsync(cancellationToken); + // 5. 持久化并返回 + await categoryRepository.AddAsync(entity, cancellationToken); + await categoryRepository.SaveChangesAsync(cancellationToken); return MerchantMapping.ToDto(entity); } diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCommandHandler.cs index 70c0982..54a5bba 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCommandHandler.cs @@ -13,12 +13,10 @@ namespace TakeoutSaaS.Application.App.Merchants.Handlers; public sealed class CreateMerchantCommandHandler(IMerchantRepository merchantRepository, ILogger logger) : IRequestHandler { - private readonly IMerchantRepository _merchantRepository = merchantRepository; - private readonly ILogger _logger = logger; - /// public async Task Handle(CreateMerchantCommand request, CancellationToken cancellationToken) { + // 1. 构建商户实体 var merchant = new Merchant { BrandName = request.BrandName.Trim(), @@ -31,10 +29,12 @@ public sealed class CreateMerchantCommandHandler(IMerchantRepository merchantRep JoinedAt = DateTime.UtcNow }; - await _merchantRepository.AddMerchantAsync(merchant, cancellationToken); - await _merchantRepository.SaveChangesAsync(cancellationToken); + // 2. 持久化 + await merchantRepository.AddMerchantAsync(merchant, cancellationToken); + await merchantRepository.SaveChangesAsync(cancellationToken); - _logger.LogInformation("创建商户 {MerchantId} - {BrandName}", merchant.Id, merchant.BrandName); + // 3. 记录日志 + logger.LogInformation("创建商户 {MerchantId} - {BrandName}", merchant.Id, merchant.BrandName); return MapToDto(merchant); } diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantContractCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantContractCommandHandler.cs index 0c4aecb..ff809dc 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantContractCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantContractCommandHandler.cs @@ -22,25 +22,23 @@ public sealed class CreateMerchantContractCommandHandler( ICurrentUserAccessor currentUserAccessor) : IRequestHandler { - private readonly IMerchantRepository _merchantRepository = merchantRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - private readonly IIdGenerator _idGenerator = idGenerator; - private readonly ICurrentUserAccessor _currentUserAccessor = currentUserAccessor; - public async Task Handle(CreateMerchantContractCommand request, CancellationToken cancellationToken) { + // 1. 校验时间 if (request.EndDate <= request.StartDate) { throw new BusinessException(ErrorCodes.BadRequest, "合同结束时间必须晚于开始时间"); } - var tenantId = _tenantProvider.GetCurrentTenantId(); - var merchant = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken) + // 2. 查询商户 + var tenantId = tenantProvider.GetCurrentTenantId(); + var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken) ?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在"); + // 3. 构建合同 var contract = new MerchantContract { - Id = _idGenerator.NextId(), + Id = idGenerator.NextId(), MerchantId = merchant.Id, ContractNumber = request.ContractNumber.Trim(), StartDate = request.StartDate, @@ -48,8 +46,9 @@ public sealed class CreateMerchantContractCommandHandler( FileUrl = request.FileUrl.Trim() }; - await _merchantRepository.AddContractAsync(contract, cancellationToken); - await _merchantRepository.AddAuditLogAsync(new MerchantAuditLog + // 4. 持久化与审计 + await merchantRepository.AddContractAsync(contract, cancellationToken); + await merchantRepository.AddAuditLogAsync(new MerchantAuditLog { TenantId = tenantId, MerchantId = merchant.Id, @@ -60,19 +59,21 @@ public sealed class CreateMerchantContractCommandHandler( OperatorName = ResolveOperatorName() }, cancellationToken); - await _merchantRepository.SaveChangesAsync(cancellationToken); + await merchantRepository.SaveChangesAsync(cancellationToken); + + // 5. 返回 DTO return MerchantMapping.ToDto(contract); } private long? ResolveOperatorId() { - var id = _currentUserAccessor.UserId; + var id = currentUserAccessor.UserId; return id == 0 ? null : id; } private string ResolveOperatorName() { - var id = _currentUserAccessor.UserId; + var id = currentUserAccessor.UserId; return id == 0 ? "system" : $"user:{id}"; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/DeleteMerchantCategoryCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/DeleteMerchantCategoryCommandHandler.cs index f0084b5..e4cf48e 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/DeleteMerchantCategoryCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/DeleteMerchantCategoryCommandHandler.cs @@ -13,21 +13,20 @@ public sealed class DeleteMerchantCategoryCommandHandler( ITenantProvider tenantProvider) : IRequestHandler { - private readonly IMerchantCategoryRepository _categoryRepository = categoryRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - public async Task Handle(DeleteMerchantCategoryCommand request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var existing = await _categoryRepository.FindByIdAsync(request.CategoryId, tenantId, cancellationToken); + // 1. 获取租户上下文 + var tenantId = tenantProvider.GetCurrentTenantId(); + var existing = await categoryRepository.FindByIdAsync(request.CategoryId, tenantId, cancellationToken); if (existing == null) { return false; } - await _categoryRepository.RemoveAsync(existing, cancellationToken); - await _categoryRepository.SaveChangesAsync(cancellationToken); + // 2. 删除并保存 + await categoryRepository.RemoveAsync(existing, cancellationToken); + await categoryRepository.SaveChangesAsync(cancellationToken); return true; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/DeleteMerchantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/DeleteMerchantCommandHandler.cs index 8f69ff0..c79f74f 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/DeleteMerchantCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/DeleteMerchantCommandHandler.cs @@ -15,25 +15,21 @@ public sealed class DeleteMerchantCommandHandler( ILogger logger) : IRequestHandler { - private readonly IMerchantRepository _merchantRepository = merchantRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - private readonly ILogger _logger = logger; - /// public async Task Handle(DeleteMerchantCommand request, CancellationToken cancellationToken) { // 1. 校验存在性 - var tenantId = _tenantProvider.GetCurrentTenantId(); - var existing = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken); + var tenantId = tenantProvider.GetCurrentTenantId(); + var existing = await merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken); if (existing == null) { return false; } // 2. 删除 - await _merchantRepository.DeleteMerchantAsync(request.MerchantId, tenantId, cancellationToken); - await _merchantRepository.SaveChangesAsync(cancellationToken); - _logger.LogInformation("删除商户 {MerchantId}", request.MerchantId); + await merchantRepository.DeleteMerchantAsync(request.MerchantId, tenantId, cancellationToken); + await merchantRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("删除商户 {MerchantId}", request.MerchantId); return true; } diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantAuditLogsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantAuditLogsQueryHandler.cs index f43146c..cfb010d 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantAuditLogsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantAuditLogsQueryHandler.cs @@ -16,20 +16,21 @@ public sealed class GetMerchantAuditLogsQueryHandler( ITenantProvider tenantProvider) : IRequestHandler> { - private readonly IMerchantRepository _merchantRepository = merchantRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - public async Task> Handle(GetMerchantAuditLogsQuery request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var logs = await _merchantRepository.GetAuditLogsAsync(request.MerchantId, tenantId, cancellationToken); + // 1. 获取租户上下文并查询日志 + var tenantId = tenantProvider.GetCurrentTenantId(); + var logs = await merchantRepository.GetAuditLogsAsync(request.MerchantId, tenantId, cancellationToken); var total = logs.Count; + + // 2. 分页映射 var paged = logs .Skip((request.Page - 1) * request.PageSize) .Take(request.PageSize) .Select(MerchantMapping.ToDto) .ToList(); + // 3. 返回结果 return new PagedResult(paged, request.Page, request.PageSize, total); } } diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantByIdQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantByIdQueryHandler.cs index 3c2313f..8272c6c 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantByIdQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantByIdQueryHandler.cs @@ -12,19 +12,18 @@ namespace TakeoutSaaS.Application.App.Merchants.Handlers; public sealed class GetMerchantByIdQueryHandler(IMerchantRepository merchantRepository, ITenantProvider tenantProvider) : IRequestHandler { - private readonly IMerchantRepository _merchantRepository = merchantRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - /// public async Task Handle(GetMerchantByIdQuery request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var merchant = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken); + // 1. 获取租户上下文 + var tenantId = tenantProvider.GetCurrentTenantId(); + var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken); if (merchant == null) { return null; } + // 2. 返回 DTO return new MerchantDto { Id = merchant.Id, diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantCategoriesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantCategoriesQueryHandler.cs index e0f9e89..19c9b2f 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantCategoriesQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantCategoriesQueryHandler.cs @@ -15,14 +15,13 @@ public sealed class GetMerchantCategoriesQueryHandler( ITenantProvider tenantProvider) : IRequestHandler> { - private readonly IMerchantCategoryRepository _categoryRepository = categoryRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - public async Task> Handle(GetMerchantCategoriesQuery request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var categories = await _categoryRepository.ListAsync(tenantId, cancellationToken); + // 1. 获取租户上下文并读取类目 + var tenantId = tenantProvider.GetCurrentTenantId(); + var categories = await categoryRepository.ListAsync(tenantId, cancellationToken); + // 2. 过滤启用类目并去重 return categories .Where(x => x.IsActive) .Select(x => x.Name.Trim()) diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantContractsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantContractsQueryHandler.cs index 50b20ab..46842e1 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantContractsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantContractsQueryHandler.cs @@ -17,16 +17,15 @@ public sealed class GetMerchantContractsQueryHandler( ITenantProvider tenantProvider) : IRequestHandler> { - private readonly IMerchantRepository _merchantRepository = merchantRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - public async Task> Handle(GetMerchantContractsQuery request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - _ = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken) + // 1. 获取租户上下文并校验商户存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + _ = await merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken) ?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在"); - var contracts = await _merchantRepository.GetContractsAsync(request.MerchantId, tenantId, cancellationToken); + // 2. 查询合同列表 + var contracts = await merchantRepository.GetContractsAsync(request.MerchantId, tenantId, cancellationToken); return MerchantMapping.ToContractDtos(contracts); } } diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDetailQueryHandler.cs index f21f1d0..0468a29 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDetailQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDetailQueryHandler.cs @@ -16,18 +16,18 @@ public sealed class GetMerchantDetailQueryHandler( ITenantProvider tenantProvider) : IRequestHandler { - private readonly IMerchantRepository _merchantRepository = merchantRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - public async Task Handle(GetMerchantDetailQuery request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var merchant = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken) + // 1. 获取租户上下文并查询商户 + var tenantId = tenantProvider.GetCurrentTenantId(); + var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken) ?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在"); - var documents = await _merchantRepository.GetDocumentsAsync(request.MerchantId, tenantId, cancellationToken); - var contracts = await _merchantRepository.GetContractsAsync(request.MerchantId, tenantId, cancellationToken); + // 2. 查询证照与合同 + var documents = await merchantRepository.GetDocumentsAsync(request.MerchantId, tenantId, cancellationToken); + var contracts = await merchantRepository.GetContractsAsync(request.MerchantId, tenantId, cancellationToken); + // 3. 返回明细 DTO return new MerchantDetailDto { Merchant = MerchantMapping.ToDto(merchant), diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDocumentsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDocumentsQueryHandler.cs index f8ceeb4..3be839c 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDocumentsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDocumentsQueryHandler.cs @@ -17,16 +17,15 @@ public sealed class GetMerchantDocumentsQueryHandler( ITenantProvider tenantProvider) : IRequestHandler> { - private readonly IMerchantRepository _merchantRepository = merchantRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - public async Task> Handle(GetMerchantDocumentsQuery request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - _ = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken) + // 1. 获取租户上下文并校验商户存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + _ = await merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken) ?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在"); - var documents = await _merchantRepository.GetDocumentsAsync(request.MerchantId, tenantId, cancellationToken); + // 2. 查询证照列表 + var documents = await merchantRepository.GetDocumentsAsync(request.MerchantId, tenantId, cancellationToken); return MerchantMapping.ToDocumentDtos(documents); } } diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ListMerchantCategoriesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ListMerchantCategoriesQueryHandler.cs index c3de6cd..7da5375 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ListMerchantCategoriesQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ListMerchantCategoriesQueryHandler.cs @@ -15,13 +15,13 @@ public sealed class ListMerchantCategoriesQueryHandler( ITenantProvider tenantProvider) : IRequestHandler> { - private readonly IMerchantCategoryRepository _categoryRepository = categoryRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - public async Task> Handle(ListMerchantCategoriesQuery request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var categories = await _categoryRepository.ListAsync(tenantId, cancellationToken); + // 1. 获取租户上下文 + var tenantId = tenantProvider.GetCurrentTenantId(); + var categories = await categoryRepository.ListAsync(tenantId, cancellationToken); + + // 2. 映射 DTO return MerchantMapping.ToCategoryDtos(categories); } } diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReorderMerchantCategoriesCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReorderMerchantCategoriesCommandHandler.cs index a9be94d..e06ea65 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReorderMerchantCategoriesCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReorderMerchantCategoriesCommandHandler.cs @@ -16,15 +16,14 @@ public sealed class ReorderMerchantCategoriesCommandHandler( ITenantProvider tenantProvider) : IRequestHandler { - private readonly IMerchantCategoryRepository _categoryRepository = categoryRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - public async Task Handle(ReorderMerchantCategoriesCommand request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var categories = await _categoryRepository.ListAsync(tenantId, cancellationToken); + // 1. 获取租户并查询类目 + var tenantId = tenantProvider.GetCurrentTenantId(); + var categories = await categoryRepository.ListAsync(tenantId, cancellationToken); var map = categories.ToDictionary(x => x.Id); + // 2. 更新排序 foreach (var item in request.Items) { if (!map.TryGetValue(item.CategoryId, out var category)) @@ -35,8 +34,9 @@ public sealed class ReorderMerchantCategoriesCommandHandler( category.DisplayOrder = item.DisplayOrder; } - await _categoryRepository.UpdateRangeAsync(map.Values, cancellationToken); - await _categoryRepository.SaveChangesAsync(cancellationToken); + // 3. 持久化 + await categoryRepository.UpdateRangeAsync(map.Values, cancellationToken); + await categoryRepository.SaveChangesAsync(cancellationToken); return true; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantCommandHandler.cs index 02ea882..c684c09 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantCommandHandler.cs @@ -20,21 +20,20 @@ public sealed class ReviewMerchantCommandHandler( ICurrentUserAccessor currentUserAccessor) : IRequestHandler { - private readonly IMerchantRepository _merchantRepository = merchantRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - private readonly ICurrentUserAccessor _currentUserAccessor = currentUserAccessor; - public async Task Handle(ReviewMerchantCommand request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var merchant = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken) + // 1. 读取商户 + var tenantId = tenantProvider.GetCurrentTenantId(); + var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken) ?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在"); + // 2. 已审核通过则直接返回 if (request.Approve && merchant.Status == MerchantStatus.Approved) { return MerchantMapping.ToDto(merchant); } + // 3. 更新审核状态 var previousStatus = merchant.Status; merchant.Status = request.Approve ? MerchantStatus.Approved : MerchantStatus.Rejected; merchant.ReviewRemarks = request.Remarks; @@ -44,8 +43,9 @@ public sealed class ReviewMerchantCommandHandler( merchant.JoinedAt = DateTime.UtcNow; } - await _merchantRepository.UpdateMerchantAsync(merchant, cancellationToken); - await _merchantRepository.AddAuditLogAsync(new MerchantAuditLog + // 4. 持久化与审计 + await merchantRepository.UpdateMerchantAsync(merchant, cancellationToken); + await merchantRepository.AddAuditLogAsync(new MerchantAuditLog { TenantId = tenantId, MerchantId = merchant.Id, @@ -55,20 +55,21 @@ public sealed class ReviewMerchantCommandHandler( OperatorId = ResolveOperatorId(), OperatorName = ResolveOperatorName() }, cancellationToken); - await _merchantRepository.SaveChangesAsync(cancellationToken); + await merchantRepository.SaveChangesAsync(cancellationToken); + // 5. 返回 DTO return MerchantMapping.ToDto(merchant); } private long? ResolveOperatorId() { - var id = _currentUserAccessor.UserId; + var id = currentUserAccessor.UserId; return id == 0 ? null : id; } private string ResolveOperatorName() { - var id = _currentUserAccessor.UserId; + var id = currentUserAccessor.UserId; return id == 0 ? "system" : $"user:{id}"; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantDocumentCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantDocumentCommandHandler.cs index 20c1a3a..a8fe365 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantDocumentCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantDocumentCommandHandler.cs @@ -20,27 +20,27 @@ public sealed class ReviewMerchantDocumentCommandHandler( ICurrentUserAccessor currentUserAccessor) : IRequestHandler { - private readonly IMerchantRepository _merchantRepository = merchantRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - private readonly ICurrentUserAccessor _currentUserAccessor = currentUserAccessor; - public async Task Handle(ReviewMerchantDocumentCommand request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var document = await _merchantRepository.FindDocumentByIdAsync(request.MerchantId, tenantId, request.DocumentId, cancellationToken) + // 1. 读取证照 + var tenantId = tenantProvider.GetCurrentTenantId(); + var document = await merchantRepository.FindDocumentByIdAsync(request.MerchantId, tenantId, request.DocumentId, cancellationToken) ?? throw new BusinessException(ErrorCodes.NotFound, "证照不存在"); + // 2. 若状态无变化且备注相同,直接返回 var targetStatus = request.Approve ? MerchantDocumentStatus.Approved : MerchantDocumentStatus.Rejected; if (document.Status == targetStatus && document.Remarks == request.Remarks) { return MerchantMapping.ToDto(document); } + // 3. 更新状态 document.Status = targetStatus; document.Remarks = request.Remarks; - await _merchantRepository.UpdateDocumentAsync(document, cancellationToken); - await _merchantRepository.AddAuditLogAsync(new MerchantAuditLog + // 4. 持久化与审计 + await merchantRepository.UpdateDocumentAsync(document, cancellationToken); + await merchantRepository.AddAuditLogAsync(new MerchantAuditLog { TenantId = tenantId, MerchantId = document.MerchantId, @@ -50,20 +50,21 @@ public sealed class ReviewMerchantDocumentCommandHandler( OperatorId = ResolveOperatorId(), OperatorName = ResolveOperatorName() }, cancellationToken); - await _merchantRepository.SaveChangesAsync(cancellationToken); + await merchantRepository.SaveChangesAsync(cancellationToken); + // 5. 返回 DTO return MerchantMapping.ToDto(document); } private long? ResolveOperatorId() { - var id = _currentUserAccessor.UserId; + var id = currentUserAccessor.UserId; return id == 0 ? null : id; } private string ResolveOperatorName() { - var id = _currentUserAccessor.UserId; + var id = currentUserAccessor.UserId; return id == 0 ? "system" : $"user:{id}"; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/SearchMerchantsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/SearchMerchantsQueryHandler.cs index 8d46242..b545129 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/SearchMerchantsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/SearchMerchantsQueryHandler.cs @@ -15,21 +15,21 @@ public sealed class SearchMerchantsQueryHandler( ITenantProvider tenantProvider) : IRequestHandler> { - private readonly IMerchantRepository _merchantRepository = merchantRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - /// public async Task> Handle(SearchMerchantsQuery request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var merchants = await _merchantRepository.SearchAsync(tenantId, request.Status, cancellationToken); + // 1. 获取租户并查询商户 + var tenantId = tenantProvider.GetCurrentTenantId(); + var merchants = await merchantRepository.SearchAsync(tenantId, request.Status, cancellationToken); + // 2. 排序与分页 var sorted = ApplySorting(merchants, request.SortBy, request.SortDescending); var paged = sorted .Skip((request.Page - 1) * request.PageSize) .Take(request.PageSize) .ToList(); + // 3. 映射 DTO var items = paged.Select(merchant => new MerchantDto { Id = merchant.Id, @@ -45,6 +45,7 @@ public sealed class SearchMerchantsQueryHandler( CreatedAt = merchant.CreatedAt }).ToList(); + // 4. 返回分页结果 return new PagedResult(items, request.Page, request.PageSize, merchants.Count); } diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantCommandHandler.cs index 38753c9..ed1a879 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantCommandHandler.cs @@ -16,16 +16,12 @@ public sealed class UpdateMerchantCommandHandler( ILogger logger) : IRequestHandler { - private readonly IMerchantRepository _merchantRepository = merchantRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - private readonly ILogger _logger = logger; - /// public async Task Handle(UpdateMerchantCommand request, CancellationToken cancellationToken) { // 1. 读取现有商户 - var tenantId = _tenantProvider.GetCurrentTenantId(); - var existing = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken); + var tenantId = tenantProvider.GetCurrentTenantId(); + var existing = await merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken); if (existing == null) { return null; @@ -41,9 +37,9 @@ public sealed class UpdateMerchantCommandHandler( existing.Status = request.Status; // 3. 持久化 - await _merchantRepository.UpdateMerchantAsync(existing, cancellationToken); - await _merchantRepository.SaveChangesAsync(cancellationToken); - _logger.LogInformation("更新商户 {MerchantId} - {BrandName}", existing.Id, existing.BrandName); + await merchantRepository.UpdateMerchantAsync(existing, cancellationToken); + await merchantRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("更新商户 {MerchantId} - {BrandName}", existing.Id, existing.BrandName); // 4. 返回 DTO return MapToDto(existing); diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantContractStatusCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantContractStatusCommandHandler.cs index 5e368f8..2d2ba95 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantContractStatusCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantContractStatusCommandHandler.cs @@ -20,16 +20,14 @@ public sealed class UpdateMerchantContractStatusCommandHandler( ICurrentUserAccessor currentUserAccessor) : IRequestHandler { - private readonly IMerchantRepository _merchantRepository = merchantRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - private readonly ICurrentUserAccessor _currentUserAccessor = currentUserAccessor; - public async Task Handle(UpdateMerchantContractStatusCommand request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var contract = await _merchantRepository.FindContractByIdAsync(request.MerchantId, tenantId, request.ContractId, cancellationToken) + // 1. 查询合同 + var tenantId = tenantProvider.GetCurrentTenantId(); + var contract = await merchantRepository.FindContractByIdAsync(request.MerchantId, tenantId, request.ContractId, cancellationToken) ?? throw new BusinessException(ErrorCodes.NotFound, "合同不存在"); + // 2. 更新状态 if (request.Status == ContractStatus.Active) { contract.Status = ContractStatus.Active; @@ -46,8 +44,9 @@ public sealed class UpdateMerchantContractStatusCommandHandler( contract.Status = request.Status; } - await _merchantRepository.UpdateContractAsync(contract, cancellationToken); - await _merchantRepository.AddAuditLogAsync(new MerchantAuditLog + // 3. 持久化与审计 + await merchantRepository.UpdateContractAsync(contract, cancellationToken); + await merchantRepository.AddAuditLogAsync(new MerchantAuditLog { TenantId = tenantId, MerchantId = contract.MerchantId, @@ -58,19 +57,21 @@ public sealed class UpdateMerchantContractStatusCommandHandler( OperatorName = ResolveOperatorName() }, cancellationToken); - await _merchantRepository.SaveChangesAsync(cancellationToken); + await merchantRepository.SaveChangesAsync(cancellationToken); + + // 4. 返回 DTO return MerchantMapping.ToDto(contract); } private long? ResolveOperatorId() { - var id = _currentUserAccessor.UserId; + var id = currentUserAccessor.UserId; return id == 0 ? null : id; } private string ResolveOperatorName() { - var id = _currentUserAccessor.UserId; + var id = currentUserAccessor.UserId; return id == 0 ? "system" : $"user:{id}"; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/CreateOrderCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/CreateOrderCommandHandler.cs index a7b1289..8d38f2c 100644 --- a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/CreateOrderCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/CreateOrderCommandHandler.cs @@ -17,17 +17,13 @@ public sealed class CreateOrderCommandHandler( ILogger logger) : IRequestHandler { - private readonly IOrderRepository _orderRepository = orderRepository; - private readonly IIdGenerator _idGenerator = idGenerator; - private readonly ILogger _logger = logger; - /// public async Task Handle(CreateOrderCommand request, CancellationToken cancellationToken) { // 1. 构建订单 var order = new Order { - Id = _idGenerator.NextId(), + Id = idGenerator.NextId(), OrderNo = request.OrderNo.Trim(), StoreId = request.StoreId, Channel = request.Channel, @@ -77,15 +73,17 @@ public sealed class CreateOrderCommandHandler( } // 4. 持久化 - await _orderRepository.AddOrderAsync(order, cancellationToken); + await orderRepository.AddOrderAsync(order, cancellationToken); if (items.Count > 0) { - await _orderRepository.AddItemsAsync(items, cancellationToken); + await orderRepository.AddItemsAsync(items, cancellationToken); } - await _orderRepository.SaveChangesAsync(cancellationToken); - _logger.LogInformation("创建订单 {OrderNo} ({OrderId})", order.OrderNo, order.Id); + await orderRepository.SaveChangesAsync(cancellationToken); - // 5. 返回 DTO + // 5. 记录日志 + logger.LogInformation("创建订单 {OrderNo} ({OrderId})", order.OrderNo, order.Id); + + // 6. 返回 DTO return MapToDto(order, items, [], []); } diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/DeleteOrderCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/DeleteOrderCommandHandler.cs index d376e47..77e6e95 100644 --- a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/DeleteOrderCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/DeleteOrderCommandHandler.cs @@ -15,26 +15,25 @@ public sealed class DeleteOrderCommandHandler( ILogger logger) : IRequestHandler { - private readonly IOrderRepository _orderRepository = orderRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - private readonly ILogger _logger = logger; - /// public async Task Handle(DeleteOrderCommand request, CancellationToken cancellationToken) { // 1. 校验存在性 - var tenantId = _tenantProvider.GetCurrentTenantId(); - var existing = await _orderRepository.FindByIdAsync(request.OrderId, tenantId, cancellationToken); + var tenantId = tenantProvider.GetCurrentTenantId(); + var existing = await orderRepository.FindByIdAsync(request.OrderId, tenantId, cancellationToken); if (existing == null) { return false; } // 2. 删除 - await _orderRepository.DeleteOrderAsync(request.OrderId, tenantId, cancellationToken); - await _orderRepository.SaveChangesAsync(cancellationToken); - _logger.LogInformation("删除订单 {OrderId}", request.OrderId); + await orderRepository.DeleteOrderAsync(request.OrderId, tenantId, cancellationToken); + await orderRepository.SaveChangesAsync(cancellationToken); + // 3. 记录日志 + logger.LogInformation("删除订单 {OrderId}", request.OrderId); + + // 4. 返回执行结果 return true; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/GetOrderByIdQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/GetOrderByIdQueryHandler.cs index 5d6b791..3a45377 100644 --- a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/GetOrderByIdQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/GetOrderByIdQueryHandler.cs @@ -15,23 +15,25 @@ public sealed class GetOrderByIdQueryHandler( ITenantProvider tenantProvider) : IRequestHandler { - private readonly IOrderRepository _orderRepository = orderRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - /// public async Task Handle(GetOrderByIdQuery request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var order = await _orderRepository.FindByIdAsync(request.OrderId, tenantId, cancellationToken); + // 1. 获取当前租户 + var tenantId = tenantProvider.GetCurrentTenantId(); + + // 2. 查询订单主体 + var order = await orderRepository.FindByIdAsync(request.OrderId, tenantId, cancellationToken); if (order == null) { return null; } - var items = await _orderRepository.GetItemsAsync(order.Id, tenantId, cancellationToken); - var histories = await _orderRepository.GetStatusHistoryAsync(order.Id, tenantId, cancellationToken); - var refunds = await _orderRepository.GetRefundsAsync(order.Id, tenantId, cancellationToken); + // 3. 查询关联明细 + var items = await orderRepository.GetItemsAsync(order.Id, tenantId, cancellationToken); + var histories = await orderRepository.GetStatusHistoryAsync(order.Id, tenantId, cancellationToken); + var refunds = await orderRepository.GetRefundsAsync(order.Id, tenantId, cancellationToken); + // 4. 映射并返回 return MapToDto(order, items, histories, refunds); } diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/SearchOrdersQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/SearchOrdersQueryHandler.cs index 867332b..90844c1 100644 --- a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/SearchOrdersQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/SearchOrdersQueryHandler.cs @@ -15,20 +15,20 @@ public sealed class SearchOrdersQueryHandler( ITenantProvider tenantProvider) : IRequestHandler> { - private readonly IOrderRepository _orderRepository = orderRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - /// public async Task> Handle(SearchOrdersQuery request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var orders = await _orderRepository.SearchAsync(tenantId, request.Status, request.PaymentStatus, cancellationToken); + // 1. 获取当前租户并查询订单 + var tenantId = tenantProvider.GetCurrentTenantId(); + var orders = await orderRepository.SearchAsync(tenantId, request.Status, request.PaymentStatus, cancellationToken); + // 2. 可选过滤:门店 if (request.StoreId.HasValue) { orders = orders.Where(x => x.StoreId == request.StoreId.Value).ToList(); } + // 3. 可选过滤:订单号模糊 if (!string.IsNullOrWhiteSpace(request.OrderNo)) { var orderNo = request.OrderNo.Trim(); @@ -37,12 +37,14 @@ public sealed class SearchOrdersQueryHandler( .ToList(); } + // 4. 排序与分页 var sorted = ApplySorting(orders, request.SortBy, request.SortDescending); var paged = sorted .Skip((request.Page - 1) * request.PageSize) .Take(request.PageSize) .ToList(); + // 5. 映射 DTO var items = paged.Select(order => new OrderDto { Id = order.Id, @@ -70,6 +72,7 @@ public sealed class SearchOrdersQueryHandler( CreatedAt = order.CreatedAt }).ToList(); + // 6. 返回分页结果 return new PagedResult(items, request.Page, request.PageSize, orders.Count); } diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/UpdateOrderCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/UpdateOrderCommandHandler.cs index 46df4ed..53f6c88 100644 --- a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/UpdateOrderCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/UpdateOrderCommandHandler.cs @@ -17,16 +17,12 @@ public sealed class UpdateOrderCommandHandler( ILogger logger) : IRequestHandler { - private readonly IOrderRepository _orderRepository = orderRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - private readonly ILogger _logger = logger; - /// public async Task Handle(UpdateOrderCommand request, CancellationToken cancellationToken) { // 1. 读取订单 - var tenantId = _tenantProvider.GetCurrentTenantId(); - var existing = await _orderRepository.FindByIdAsync(request.OrderId, tenantId, cancellationToken); + var tenantId = tenantProvider.GetCurrentTenantId(); + var existing = await orderRepository.FindByIdAsync(request.OrderId, tenantId, cancellationToken); if (existing == null) { return null; @@ -55,14 +51,16 @@ public sealed class UpdateOrderCommandHandler( existing.Remark = request.Remark?.Trim(); // 3. 持久化 - await _orderRepository.UpdateOrderAsync(existing, cancellationToken); - await _orderRepository.SaveChangesAsync(cancellationToken); - _logger.LogInformation("更新订单 {OrderNo} ({OrderId})", existing.OrderNo, existing.Id); + await orderRepository.UpdateOrderAsync(existing, cancellationToken); + await orderRepository.SaveChangesAsync(cancellationToken); - // 4. 读取关联数据并返回 - var items = await _orderRepository.GetItemsAsync(existing.Id, tenantId, cancellationToken); - var histories = await _orderRepository.GetStatusHistoryAsync(existing.Id, tenantId, cancellationToken); - var refunds = await _orderRepository.GetRefundsAsync(existing.Id, tenantId, cancellationToken); + // 4. 记录更新日志 + logger.LogInformation("更新订单 {OrderNo} ({OrderId})", existing.OrderNo, existing.Id); + + // 5. 读取关联数据并返回 + var items = await orderRepository.GetItemsAsync(existing.Id, tenantId, cancellationToken); + var histories = await orderRepository.GetStatusHistoryAsync(existing.Id, tenantId, cancellationToken); + var refunds = await orderRepository.GetRefundsAsync(existing.Id, tenantId, cancellationToken); return MapToDto(existing, items, histories, refunds); } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ChangeTenantSubscriptionPlanCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ChangeTenantSubscriptionPlanCommandHandler.cs index bc7f60f..de73ccf 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ChangeTenantSubscriptionPlanCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ChangeTenantSubscriptionPlanCommandHandler.cs @@ -18,20 +18,19 @@ public sealed class ChangeTenantSubscriptionPlanCommandHandler( 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) + // 1. 校验租户与订阅存在性 + _ = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken) ?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在"); - var subscription = await _tenantRepository.FindSubscriptionByIdAsync(request.TenantId, request.TenantSubscriptionId, cancellationToken) + var subscription = await tenantRepository.FindSubscriptionByIdAsync(request.TenantId, request.TenantSubscriptionId, cancellationToken) ?? throw new BusinessException(ErrorCodes.NotFound, "订阅不存在"); var previousPackage = subscription.TenantPackageId; + // 2. 根据立即生效或排期设置目标套餐 if (request.Immediate) { subscription.TenantPackageId = request.TargetPackageId; @@ -42,10 +41,11 @@ public sealed class ChangeTenantSubscriptionPlanCommandHandler( subscription.ScheduledPackageId = request.TargetPackageId; } - await _tenantRepository.UpdateSubscriptionAsync(subscription, cancellationToken); - await _tenantRepository.AddSubscriptionHistoryAsync(new TenantSubscriptionHistory + // 3. 更新订阅并记录变更历史 + await tenantRepository.UpdateSubscriptionAsync(subscription, cancellationToken); + await tenantRepository.AddSubscriptionHistoryAsync(new TenantSubscriptionHistory { - Id = _idGenerator.NextId(), + Id = idGenerator.NextId(), TenantId = subscription.TenantId, TenantSubscriptionId = subscription.Id, FromPackageId = previousPackage, @@ -56,7 +56,8 @@ public sealed class ChangeTenantSubscriptionPlanCommandHandler( Notes = request.Notes }, cancellationToken); - await _tenantRepository.AddAuditLogAsync(new TenantAuditLog + // 4. 记录审计日志 + await tenantRepository.AddAuditLogAsync(new TenantAuditLog { TenantId = subscription.TenantId, Action = TenantAuditAction.SubscriptionPlanChanged, @@ -66,7 +67,8 @@ public sealed class ChangeTenantSubscriptionPlanCommandHandler( CurrentStatus = null }, cancellationToken); - await _tenantRepository.SaveChangesAsync(cancellationToken); + // 5. 保存并返回 DTO + await tenantRepository.SaveChangesAsync(cancellationToken); return subscription.ToSubscriptionDto() ?? throw new BusinessException(ErrorCodes.InternalServerError, "订阅更新失败"); diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CheckTenantQuotaCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CheckTenantQuotaCommandHandler.cs index 77cae64..5fc67aa 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CheckTenantQuotaCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CheckTenantQuotaCommandHandler.cs @@ -24,18 +24,20 @@ public sealed class CheckTenantQuotaCommandHandler( /// public async Task Handle(CheckTenantQuotaCommand request, CancellationToken cancellationToken) { + // 1. 校验请求参数 if (request.Delta <= 0) { throw new BusinessException(ErrorCodes.BadRequest, "配额消耗量必须大于 0"); } + // 2. 校验租户上下文 var currentTenantId = tenantProvider.GetCurrentTenantId(); if (currentTenantId == 0 || currentTenantId != request.TenantId) { throw new BusinessException(ErrorCodes.Forbidden, "租户上下文不匹配,请在请求头 X-Tenant-Id 指定目标租户"); } - // 1. 获取租户与当前订阅。 + // 3. 获取租户与当前订阅 _ = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken) ?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在"); @@ -50,7 +52,7 @@ public sealed class CheckTenantQuotaCommandHandler( var limit = ResolveLimit(package, request.QuotaType); - // 2. 加载配额使用记录并计算。 + // 4. 加载配额使用记录并计算 var usage = await quotaUsageRepository.FindAsync(request.TenantId, request.QuotaType, cancellationToken) ?? new TenantQuotaUsage { @@ -69,12 +71,14 @@ public sealed class CheckTenantQuotaCommandHandler( throw new BusinessException(ErrorCodes.Conflict, $"{request.QuotaType} 配额不足"); } + // 5. 更新使用并保存 usage.LimitValue = limit ?? usage.LimitValue; usage.UsedValue = usedAfter; usage.ResetCycle ??= ResolveResetCycle(request.QuotaType); await PersistUsageAsync(usage, quotaUsageRepository, cancellationToken); + // 6. 返回结果 return new QuotaCheckResultDto { QuotaType = request.QuotaType, diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantAnnouncementCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantAnnouncementCommandHandler.cs index ba48bb6..4894acb 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantAnnouncementCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantAnnouncementCommandHandler.cs @@ -16,11 +16,13 @@ public sealed class CreateTenantAnnouncementCommandHandler(ITenantAnnouncementRe { public async Task Handle(CreateTenantAnnouncementCommand request, CancellationToken cancellationToken) { + // 1. 校验标题与内容 if (string.IsNullOrWhiteSpace(request.Title) || string.IsNullOrWhiteSpace(request.Content)) { throw new BusinessException(ErrorCodes.BadRequest, "公告标题和内容不能为空"); } + // 2. 构建公告实体 var announcement = new TenantAnnouncement { TenantId = request.TenantId, @@ -33,6 +35,7 @@ public sealed class CreateTenantAnnouncementCommandHandler(ITenantAnnouncementRe IsActive = request.IsActive }; + // 3. 持久化并返回 DTO await announcementRepository.AddAsync(announcement, cancellationToken); await announcementRepository.SaveChangesAsync(cancellationToken); diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantBillingCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantBillingCommandHandler.cs index f07f889..42b629c 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantBillingCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantBillingCommandHandler.cs @@ -16,11 +16,13 @@ public sealed class CreateTenantBillingCommandHandler(ITenantBillingRepository b { public async Task Handle(CreateTenantBillingCommand request, CancellationToken cancellationToken) { + // 1. 校验账单编号 if (string.IsNullOrWhiteSpace(request.StatementNo)) { throw new BusinessException(ErrorCodes.BadRequest, "账单编号不能为空"); } + // 2. 构建账单实体 var bill = new TenantBillingStatement { TenantId = request.TenantId, @@ -34,9 +36,11 @@ public sealed class CreateTenantBillingCommandHandler(ITenantBillingRepository b LineItemsJson = request.LineItemsJson }; + // 3. 持久化账单 await billingRepository.AddAsync(bill, cancellationToken); await billingRepository.SaveChangesAsync(cancellationToken); + // 4. 返回 DTO return bill.ToDto(); } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantPackageCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantPackageCommandHandler.cs index bc8cd90..c14a993 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantPackageCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantPackageCommandHandler.cs @@ -17,11 +17,13 @@ public sealed class CreateTenantPackageCommandHandler(ITenantPackageRepository p /// public async Task Handle(CreateTenantPackageCommand request, CancellationToken cancellationToken) { + // 1. 校验套餐名称 if (string.IsNullOrWhiteSpace(request.Name)) { throw new BusinessException(ErrorCodes.BadRequest, "套餐名称不能为空"); } + // 2. 构建套餐实体 var package = new TenantPackage { Name = request.Name.Trim(), @@ -38,6 +40,7 @@ public sealed class CreateTenantPackageCommandHandler(ITenantPackageRepository p IsActive = request.IsActive }; + // 3. 持久化并返回 await packageRepository.AddAsync(package, cancellationToken); await packageRepository.SaveChangesAsync(cancellationToken); diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantSubscriptionCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantSubscriptionCommandHandler.cs index df52305..4d59ffe 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantSubscriptionCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantSubscriptionCommandHandler.cs @@ -18,28 +18,28 @@ public sealed class CreateTenantSubscriptionCommandHandler( IIdGenerator idGenerator) : IRequestHandler { - private readonly ITenantRepository _tenantRepository = tenantRepository; - private readonly IIdGenerator _idGenerator = idGenerator; - /// public async Task Handle(CreateTenantSubscriptionCommand request, CancellationToken cancellationToken) { + // 1. 校验订阅时长 if (request.DurationMonths <= 0) { throw new BusinessException(ErrorCodes.BadRequest, "订阅时长必须大于 0"); } - var tenant = await _tenantRepository.FindByIdAsync(request.TenantId, cancellationToken) + // 2. 获取租户与当前订阅 + var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken) ?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在"); - var current = await _tenantRepository.GetActiveSubscriptionAsync(request.TenantId, cancellationToken); + 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); + // 3. 创建订阅实体 var subscription = new TenantSubscription { - Id = _idGenerator.NextId(), + Id = idGenerator.NextId(), TenantId = tenant.Id, TenantPackageId = request.TenantPackageId, EffectiveFrom = effectiveFrom, @@ -50,10 +50,11 @@ public sealed class CreateTenantSubscriptionCommandHandler( Notes = request.Notes }; - await _tenantRepository.AddSubscriptionAsync(subscription, cancellationToken); - await _tenantRepository.AddSubscriptionHistoryAsync(new TenantSubscriptionHistory + // 4. 记录订阅与历史 + await tenantRepository.AddSubscriptionAsync(subscription, cancellationToken); + await tenantRepository.AddSubscriptionHistoryAsync(new TenantSubscriptionHistory { - Id = _idGenerator.NextId(), + Id = idGenerator.NextId(), TenantId = tenant.Id, TenantSubscriptionId = subscription.Id, FromPackageId = current?.TenantPackageId ?? request.TenantPackageId, @@ -66,7 +67,8 @@ public sealed class CreateTenantSubscriptionCommandHandler( Notes = request.Notes }, cancellationToken); - await _tenantRepository.AddAuditLogAsync(new TenantAuditLog + // 5. 记录审计 + await tenantRepository.AddAuditLogAsync(new TenantAuditLog { TenantId = tenant.Id, Action = TenantAuditAction.SubscriptionUpdated, @@ -74,8 +76,10 @@ public sealed class CreateTenantSubscriptionCommandHandler( Description = $"套餐 {request.TenantPackageId} 时长 {request.DurationMonths} 月" }, cancellationToken); - await _tenantRepository.SaveChangesAsync(cancellationToken); + // 6. 保存变更 + await tenantRepository.SaveChangesAsync(cancellationToken); + // 7. 返回 DTO return subscription.ToSubscriptionDto() ?? throw new BusinessException(ErrorCodes.InternalServerError, "订阅生成失败"); } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantAnnouncementCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantAnnouncementCommandHandler.cs index 5464299..d479dfd 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantAnnouncementCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantAnnouncementCommandHandler.cs @@ -12,8 +12,11 @@ public sealed class DeleteTenantAnnouncementCommandHandler(ITenantAnnouncementRe { public async Task Handle(DeleteTenantAnnouncementCommand request, CancellationToken cancellationToken) { + // 1. 删除公告 await announcementRepository.DeleteAsync(request.TenantId, request.AnnouncementId, cancellationToken); await announcementRepository.SaveChangesAsync(cancellationToken); + + // 2. 返回执行结果 return true; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantPackageCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantPackageCommandHandler.cs index 232ea5e..c0f6fb4 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantPackageCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantPackageCommandHandler.cs @@ -13,8 +13,11 @@ public sealed class DeleteTenantPackageCommandHandler(ITenantPackageRepository p /// public async Task Handle(DeleteTenantPackageCommand request, CancellationToken cancellationToken) { + // 1. 删除套餐 await packageRepository.DeleteAsync(request.TenantPackageId, cancellationToken); await packageRepository.SaveChangesAsync(cancellationToken); + + // 2. 返回执行结果 return true; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAnnouncementQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAnnouncementQueryHandler.cs index 70c6201..90fa4b1 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAnnouncementQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAnnouncementQueryHandler.cs @@ -17,12 +17,14 @@ public sealed class GetTenantAnnouncementQueryHandler( { public async Task Handle(GetTenantAnnouncementQuery request, CancellationToken cancellationToken) { + // 1. 查询公告主体 var announcement = await announcementRepository.FindByIdAsync(request.TenantId, request.AnnouncementId, cancellationToken); if (announcement == null) { return null; } + // 2. 优先查用户级已读 var userId = currentUserAccessor?.UserId ?? 0; var reads = await readRepository.GetByAnnouncementAsync( request.TenantId, @@ -37,6 +39,7 @@ public sealed class GetTenantAnnouncementQueryHandler( reads = tenantReads; } + // 3. 返回 DTO 并附带已读状态 var readRecord = reads.FirstOrDefault(); return announcement.ToDto(readRecord != null, readRecord?.ReadAt); } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAuditLogsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAuditLogsQueryHandler.cs index bfa51ee..41d308f 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAuditLogsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAuditLogsQueryHandler.cs @@ -13,20 +13,21 @@ 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); + // 1. 查询审核日志 + var logs = await tenantRepository.GetAuditLogsAsync(request.TenantId, cancellationToken); var total = logs.Count; + // 2. 分页映射 var paged = logs .Skip((request.Page - 1) * request.PageSize) .Take(request.PageSize) .Select(TenantMapping.ToDto) .ToList(); + // 3. 返回分页结果 return new PagedResult(paged, request.Page, request.PageSize, total); } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantBillQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantBillQueryHandler.cs index c3e96d6..5dbd0e2 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantBillQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantBillQueryHandler.cs @@ -13,7 +13,10 @@ public sealed class GetTenantBillQueryHandler(ITenantBillingRepository billingRe { public async Task Handle(GetTenantBillQuery request, CancellationToken cancellationToken) { + // 1. 查询账单 var bill = await billingRepository.FindByIdAsync(request.TenantId, request.BillingId, cancellationToken); + + // 2. 返回 DTO 或 null return bill?.ToDto(); } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantByIdQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantByIdQueryHandler.cs index f3cd41b..c4f89f9 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantByIdQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantByIdQueryHandler.cs @@ -13,17 +13,18 @@ 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) + // 1. 查询租户 + 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); + // 2. 查询订阅与认证 + var subscription = await tenantRepository.GetActiveSubscriptionAsync(request.TenantId, cancellationToken); + var verification = await tenantRepository.GetVerificationProfileAsync(request.TenantId, cancellationToken); + // 3. 组装返回 return new TenantDetailDto { Tenant = TenantMapping.ToDto(tenant, subscription, verification), diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageByIdQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageByIdQueryHandler.cs index ac6edac..4b6b898 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageByIdQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageByIdQueryHandler.cs @@ -14,7 +14,10 @@ public sealed class GetTenantPackageByIdQueryHandler(ITenantPackageRepository pa /// public async Task Handle(GetTenantPackageByIdQuery request, CancellationToken cancellationToken) { + // 1. 查询套餐 var package = await packageRepository.FindByIdAsync(request.TenantPackageId, cancellationToken); + + // 2. 返回 DTO 或 null return package?.ToDto(); } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantAnnouncementReadCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantAnnouncementReadCommandHandler.cs index 63f9604..8c0a06b 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantAnnouncementReadCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantAnnouncementReadCommandHandler.cs @@ -20,15 +20,18 @@ public sealed class MarkTenantAnnouncementReadCommandHandler( { public async Task Handle(MarkTenantAnnouncementReadCommand request, CancellationToken cancellationToken) { + // 1. 查询公告 var announcement = await announcementRepository.FindByIdAsync(request.TenantId, request.AnnouncementId, cancellationToken); if (announcement == null) { return null; } + // 2. 确定用户标识 var userId = currentUserAccessor?.UserId ?? 0; var existing = await readRepository.FindAsync(request.TenantId, request.AnnouncementId, userId == 0 ? null : userId, cancellationToken); + // 3. 如未读则写入已读记录 if (existing == null) { var record = new TenantAnnouncementRead @@ -44,6 +47,7 @@ public sealed class MarkTenantAnnouncementReadCommandHandler( existing = record; } + // 4. 返回带已读时间的公告 DTO return announcement.ToDto(true, existing.ReadAt); } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantBillingPaidCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantBillingPaidCommandHandler.cs index 3d708af..6727959 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantBillingPaidCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantBillingPaidCommandHandler.cs @@ -14,19 +14,23 @@ public sealed class MarkTenantBillingPaidCommandHandler(ITenantBillingRepository { public async Task Handle(MarkTenantBillingPaidCommand request, CancellationToken cancellationToken) { + // 1. 查询账单 var bill = await billingRepository.FindByIdAsync(request.TenantId, request.BillingId, cancellationToken); if (bill == null) { return null; } + // 2. 更新支付状态 bill.AmountPaid = request.AmountPaid; bill.Status = TenantBillingStatus.Paid; bill.DueDate = bill.DueDate; + // 3. 持久化变更 await billingRepository.UpdateAsync(bill, cancellationToken); await billingRepository.SaveChangesAsync(cancellationToken); + // 4. 返回 DTO return bill.ToDto(); } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantNotificationReadCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantNotificationReadCommandHandler.cs index 48a8400..1d91e66 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantNotificationReadCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantNotificationReadCommandHandler.cs @@ -13,12 +13,14 @@ public sealed class MarkTenantNotificationReadCommandHandler(ITenantNotification { public async Task Handle(MarkTenantNotificationReadCommand request, CancellationToken cancellationToken) { + // 1. 查询通知 var notification = await notificationRepository.FindByIdAsync(request.TenantId, request.NotificationId, cancellationToken); if (notification == null) { return null; } + // 2. 若未读则标记已读 if (notification.ReadAt == null) { notification.ReadAt = DateTime.UtcNow; @@ -26,6 +28,7 @@ public sealed class MarkTenantNotificationReadCommandHandler(ITenantNotification await notificationRepository.SaveChangesAsync(cancellationToken); } + // 3. 返回 DTO return notification.ToDto(); } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/RegisterTenantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/RegisterTenantCommandHandler.cs index 228bded..e236ab1 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/RegisterTenantCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/RegisterTenantCommandHandler.cs @@ -20,30 +20,30 @@ public sealed class RegisterTenantCommandHandler( 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) { + // 1. 校验订阅时长 if (request.DurationMonths <= 0) { throw new BusinessException(ErrorCodes.BadRequest, "订阅时长必须大于 0"); } - if (await _tenantRepository.ExistsByCodeAsync(request.Code, cancellationToken)) + // 2. 检查租户编码唯一性 + if (await tenantRepository.ExistsByCodeAsync(request.Code, cancellationToken)) { throw new BusinessException(ErrorCodes.Conflict, $"租户编码 {request.Code} 已存在"); } + // 3. 计算生效时间 var now = DateTime.UtcNow; var effectiveFrom = request.EffectiveFrom ?? now; var effectiveTo = effectiveFrom.AddMonths(request.DurationMonths); + // 4. 构建租户实体 var tenant = new Tenant { - Id = _idGenerator.NextId(), + Id = idGenerator.NextId(), Code = request.Code.Trim(), Name = request.Name, ShortName = request.ShortName, @@ -56,9 +56,10 @@ public sealed class RegisterTenantCommandHandler( EffectiveTo = effectiveTo }; + // 5. 构建订阅实体 var subscription = new TenantSubscription { - Id = _idGenerator.NextId(), + Id = idGenerator.NextId(), TenantId = tenant.Id, TenantPackageId = request.TenantPackageId, EffectiveFrom = effectiveFrom, @@ -69,9 +70,10 @@ public sealed class RegisterTenantCommandHandler( Notes = "Init subscription" }; - await _tenantRepository.AddTenantAsync(tenant, cancellationToken); - await _tenantRepository.AddSubscriptionAsync(subscription, cancellationToken); - await _tenantRepository.AddAuditLogAsync(new TenantAuditLog + // 6. 持久化租户、订阅和审计日志 + await tenantRepository.AddTenantAsync(tenant, cancellationToken); + await tenantRepository.AddSubscriptionAsync(subscription, cancellationToken); + await tenantRepository.AddAuditLogAsync(new TenantAuditLog { TenantId = tenant.Id, Action = TenantAuditAction.RegistrationSubmitted, @@ -79,10 +81,12 @@ public sealed class RegisterTenantCommandHandler( Description = $"提交套餐 {request.TenantPackageId},时长 {request.DurationMonths} 月" }, cancellationToken); - await _tenantRepository.SaveChangesAsync(cancellationToken); + await tenantRepository.SaveChangesAsync(cancellationToken); - _logger.LogInformation("已注册租户 {TenantCode}", tenant.Code); + // 7. 记录日志 + logger.LogInformation("已注册租户 {TenantCode}", tenant.Code); + // 8. 返回 DTO 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 index 97df442..022d1e1 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ReviewTenantCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ReviewTenantCommandHandler.cs @@ -17,31 +17,32 @@ public sealed class ReviewTenantCommandHandler( 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) + // 1. 获取租户与认证资料 + var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken) ?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在"); - var verification = await _tenantRepository.GetVerificationProfileAsync(request.TenantId, cancellationToken) + var verification = await tenantRepository.GetVerificationProfileAsync(request.TenantId, cancellationToken) ?? throw new BusinessException(ErrorCodes.BadRequest, "请先提交实名认证资料"); - var subscription = await _tenantRepository.GetActiveSubscriptionAsync(request.TenantId, cancellationToken); + var subscription = await tenantRepository.GetActiveSubscriptionAsync(request.TenantId, cancellationToken); - var actorName = _currentUserAccessor.IsAuthenticated - ? $"user:{_currentUserAccessor.UserId}" + // 2. 记录审核人 + var actorName = currentUserAccessor.IsAuthenticated + ? $"user:{currentUserAccessor.UserId}" : "system"; + // 3. 写入审核信息 verification.ReviewedAt = DateTime.UtcNow; - verification.ReviewedBy = _currentUserAccessor.UserId == 0 ? null : _currentUserAccessor.UserId; + verification.ReviewedBy = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId; verification.ReviewedByName = actorName; verification.ReviewRemarks = request.Reason; var previousStatus = tenant.Status; + // 4. 更新租户与订阅状态 if (request.Approve) { verification.Status = TenantVerificationStatus.Approved; @@ -61,26 +62,29 @@ public sealed class ReviewTenantCommandHandler( } } - await _tenantRepository.UpdateTenantAsync(tenant, cancellationToken); - await _tenantRepository.UpsertVerificationProfileAsync(verification, cancellationToken); + // 5. 持久化租户与认证资料 + await tenantRepository.UpdateTenantAsync(tenant, cancellationToken); + await tenantRepository.UpsertVerificationProfileAsync(verification, cancellationToken); if (subscription != null) { - await _tenantRepository.UpdateSubscriptionAsync(subscription, cancellationToken); + await tenantRepository.UpdateSubscriptionAsync(subscription, cancellationToken); } - await _tenantRepository.AddAuditLogAsync(new Domain.Tenants.Entities.TenantAuditLog + // 6. 记录审核日志 + 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, + OperatorId = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId, OperatorName = actorName, PreviousStatus = previousStatus, CurrentStatus = tenant.Status }, cancellationToken); - await _tenantRepository.SaveChangesAsync(cancellationToken); + // 7. 保存并返回 DTO + await tenantRepository.SaveChangesAsync(cancellationToken); return TenantMapping.ToDto(tenant, subscription, verification); } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantAnnouncementsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantAnnouncementsQueryHandler.cs index 1734531..be2b952 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantAnnouncementsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantAnnouncementsQueryHandler.cs @@ -20,22 +20,27 @@ public sealed class SearchTenantAnnouncementsQueryHandler( { public async Task> Handle(SearchTenantAnnouncementsQuery request, CancellationToken cancellationToken) { + // 1. 过滤有效期条件 var effectiveAt = request.OnlyEffective == true ? DateTime.UtcNow : (DateTime?)null; var announcements = await announcementRepository.SearchAsync(request.TenantId, request.AnnouncementType, request.IsActive, effectiveAt, cancellationToken); + // 2. 排序(优先级/时间) var ordered = announcements .OrderByDescending(x => x.Priority) .ThenByDescending(x => x.CreatedAt) .ToList(); + // 3. 计算分页参数 var page = request.Page <= 0 ? 1 : request.Page; var size = request.PageSize <= 0 ? 20 : request.PageSize; + // 4. 分页 var pageItems = ordered .Skip((page - 1) * size) .Take(size) .ToList(); + // 5. 构建已读映射 var announcementIds = pageItems.Select(x => x.Id).ToArray(); var userId = currentUserAccessor?.UserId ?? 0; @@ -65,6 +70,7 @@ public sealed class SearchTenantAnnouncementsQueryHandler( } } + // 6. 映射 DTO 并带上已读状态 var items = pageItems .Select(a => { @@ -73,6 +79,7 @@ public sealed class SearchTenantAnnouncementsQueryHandler( }) .ToList(); + // 7. 返回分页结果 return new PagedResult(items, page, size, ordered.Count); } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantBillsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantBillsQueryHandler.cs index 5bd2ec7..05b11cb 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantBillsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantBillsQueryHandler.cs @@ -15,13 +15,16 @@ public sealed class SearchTenantBillsQueryHandler(ITenantBillingRepository billi { public async Task> Handle(SearchTenantBillsQuery request, CancellationToken cancellationToken) { + // 1. 查询账单 var bills = await billingRepository.SearchAsync(request.TenantId, request.Status, request.From, request.To, cancellationToken); + // 2. 排序与分页 var ordered = bills.OrderByDescending(x => x.PeriodEnd).ToList(); var page = request.Page <= 0 ? 1 : request.Page; var size = request.PageSize <= 0 ? 20 : request.PageSize; var items = ordered.Skip((page - 1) * size).Take(size).Select(x => x.ToDto()).ToList(); + // 3. 返回分页结果 return new PagedResult(items, page, size, ordered.Count); } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantNotificationsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantNotificationsQueryHandler.cs index ec05cd6..bab5aca 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantNotificationsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantNotificationsQueryHandler.cs @@ -15,6 +15,7 @@ public sealed class SearchTenantNotificationsQueryHandler(ITenantNotificationRep { public async Task> Handle(SearchTenantNotificationsQuery request, CancellationToken cancellationToken) { + // 1. 查询通知 var notifications = await notificationRepository.SearchAsync( request.TenantId, request.Severity, @@ -23,11 +24,13 @@ public sealed class SearchTenantNotificationsQueryHandler(ITenantNotificationRep null, cancellationToken); + // 2. 排序与分页 var ordered = notifications.OrderByDescending(x => x.SentAt).ToList(); var page = request.Page <= 0 ? 1 : request.Page; var size = request.PageSize <= 0 ? 20 : request.PageSize; var items = ordered.Skip((page - 1) * size).Take(size).Select(x => x.ToDto()).ToList(); + // 3. 返回分页结果 return new PagedResult(items, page, size, ordered.Count); } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantPackagesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantPackagesQueryHandler.cs index 5b4993c..12a2318 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantPackagesQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantPackagesQueryHandler.cs @@ -16,8 +16,10 @@ public sealed class SearchTenantPackagesQueryHandler(ITenantPackageRepository pa /// public async Task> Handle(SearchTenantPackagesQuery request, CancellationToken cancellationToken) { + // 1. 查询套餐 var packages = await packageRepository.SearchAsync(request.Keyword, request.IsActive, cancellationToken); + // 2. 排序与分页 var ordered = packages.OrderByDescending(x => x.CreatedAt).ToList(); var pageIndex = request.Page <= 0 ? 1 : request.Page; var size = request.PageSize <= 0 ? 20 : request.PageSize; @@ -28,6 +30,7 @@ public sealed class SearchTenantPackagesQueryHandler(ITenantPackageRepository pa .Select(x => x.ToDto()) .ToList(); + // 3. 返回分页结果 return new PagedResult(pagedItems, pageIndex, size, ordered.Count); } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantsQueryHandler.cs index e13b9fc..2813e31 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantsQueryHandler.cs @@ -13,27 +13,29 @@ 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); + // 1. 查询租户列表 + var tenants = await tenantRepository.SearchAsync(request.Status, request.Keyword, cancellationToken); var total = tenants.Count; + // 2. 分页 var paged = tenants .Skip((request.Page - 1) * request.PageSize) .Take(request.PageSize) .ToList(); + // 3. 映射 DTO(带订阅与认证) 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); + var subscription = await tenantRepository.GetActiveSubscriptionAsync(tenant.Id, cancellationToken); + var verification = await tenantRepository.GetVerificationProfileAsync(tenant.Id, cancellationToken); result.Add(TenantMapping.ToDto(tenant, subscription, verification)); } + // 4. 返回分页结果 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 index dc6be8f..8771c72 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SubmitTenantVerificationCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SubmitTenantVerificationCommandHandler.cs @@ -18,18 +18,18 @@ public sealed class SubmitTenantVerificationCommandHandler( 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) + // 1. 获取租户 + 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 }; + // 2. 读取或初始化实名资料 + var profile = await tenantRepository.GetVerificationProfileAsync(request.TenantId, cancellationToken) + ?? new TenantVerificationProfile { Id = idGenerator.NextId(), TenantId = tenant.Id }; + // 3. 填充资料 profile.BusinessLicenseNumber = request.BusinessLicenseNumber; profile.BusinessLicenseUrl = request.BusinessLicenseUrl; profile.LegalPersonName = request.LegalPersonName; @@ -47,16 +47,18 @@ public sealed class SubmitTenantVerificationCommandHandler( profile.ReviewedBy = null; profile.ReviewedByName = null; - await _tenantRepository.UpsertVerificationProfileAsync(profile, cancellationToken); - await _tenantRepository.AddAuditLogAsync(new TenantAuditLog + // 4. 保存资料并记录审计 + 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); + await tenantRepository.SaveChangesAsync(cancellationToken); + // 5. 返回 DTO return profile.ToVerificationDto() ?? throw new BusinessException(ErrorCodes.InternalServerError, "实名资料保存失败"); } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantAnnouncementCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantAnnouncementCommandHandler.cs index ea76e80..c802631 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantAnnouncementCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantAnnouncementCommandHandler.cs @@ -15,17 +15,20 @@ public sealed class UpdateTenantAnnouncementCommandHandler(ITenantAnnouncementRe { public async Task Handle(UpdateTenantAnnouncementCommand request, CancellationToken cancellationToken) { + // 1. 校验输入 if (string.IsNullOrWhiteSpace(request.Title) || string.IsNullOrWhiteSpace(request.Content)) { throw new BusinessException(ErrorCodes.BadRequest, "公告标题和内容不能为空"); } + // 2. 查询公告 var announcement = await announcementRepository.FindByIdAsync(request.TenantId, request.AnnouncementId, cancellationToken); if (announcement == null) { return null; } + // 3. 更新字段 announcement.Title = request.Title.Trim(); announcement.Content = request.Content; announcement.AnnouncementType = request.AnnouncementType; @@ -34,9 +37,11 @@ public sealed class UpdateTenantAnnouncementCommandHandler(ITenantAnnouncementRe announcement.EffectiveTo = request.EffectiveTo; announcement.IsActive = request.IsActive; + // 4. 持久化 await announcementRepository.UpdateAsync(announcement, cancellationToken); await announcementRepository.SaveChangesAsync(cancellationToken); + // 5. 返回 DTO return announcement.ToDto(false, null); } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantPackageCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantPackageCommandHandler.cs index 77a1664..7821bbf 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantPackageCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantPackageCommandHandler.cs @@ -16,17 +16,20 @@ public sealed class UpdateTenantPackageCommandHandler(ITenantPackageRepository p /// public async Task Handle(UpdateTenantPackageCommand request, CancellationToken cancellationToken) { + // 1. 校验必填项 if (string.IsNullOrWhiteSpace(request.Name)) { throw new BusinessException(ErrorCodes.BadRequest, "套餐名称不能为空"); } + // 2. 查询套餐 var package = await packageRepository.FindByIdAsync(request.TenantPackageId, cancellationToken); if (package == null) { return null; } + // 3. 更新字段 package.Name = request.Name.Trim(); package.Description = request.Description; package.PackageType = request.PackageType; @@ -40,6 +43,7 @@ public sealed class UpdateTenantPackageCommandHandler(ITenantPackageRepository p package.FeaturePoliciesJson = request.FeaturePoliciesJson; package.IsActive = request.IsActive; + // 4. 持久化并返回 await packageRepository.UpdateAsync(package, cancellationToken); await packageRepository.SaveChangesAsync(cancellationToken); diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs index 5ff8e9a..1c8f628 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs @@ -21,18 +21,20 @@ public sealed class DictionaryAppService( ITenantProvider tenantProvider, ILogger logger) : IDictionaryAppService { - public async Task CreateGroupAsync(CreateDictionaryGroupRequest request, CancellationToken cancellationToken = default) { + // 1. 规范化编码并确定租户 var normalizedCode = NormalizeCode(request.Code); var targetTenant = ResolveTargetTenant(request.Scope); + // 2. 校验编码唯一 var existing = await repository.FindGroupByCodeAsync(normalizedCode, cancellationToken); if (existing != null) { throw new BusinessException(ErrorCodes.Conflict, $"字典分组编码 {normalizedCode} 已存在"); } + // 3. 构建分组实体 var group = new DictionaryGroup { Id = 0, @@ -44,6 +46,7 @@ public sealed class DictionaryAppService( IsEnabled = true }; + // 4. 持久化并返回 await repository.AddGroupAsync(group, cancellationToken); await repository.SaveChangesAsync(cancellationToken); logger.LogInformation("创建字典分组:{Code}({Scope})", group.Code, group.Scope); @@ -52,13 +55,16 @@ public sealed class DictionaryAppService( public async Task UpdateGroupAsync(long groupId, UpdateDictionaryGroupRequest request, CancellationToken cancellationToken = default) { + // 1. 读取分组并校验权限 var group = await RequireGroupAsync(groupId, cancellationToken); EnsureScopePermission(group.Scope); + // 2. 更新字段 group.Name = request.Name.Trim(); group.Description = request.Description?.Trim(); group.IsEnabled = request.IsEnabled; + // 3. 持久化并失效缓存 await repository.SaveChangesAsync(cancellationToken); await InvalidateCacheAsync(group, cancellationToken); logger.LogInformation("更新字典分组:{GroupId}", group.Id); @@ -67,9 +73,11 @@ public sealed class DictionaryAppService( public async Task DeleteGroupAsync(long groupId, CancellationToken cancellationToken = default) { + // 1. 读取分组并校验权限 var group = await RequireGroupAsync(groupId, cancellationToken); EnsureScopePermission(group.Scope); + // 2. 删除并失效缓存 await repository.RemoveGroupAsync(group, cancellationToken); await repository.SaveChangesAsync(cancellationToken); await InvalidateCacheAsync(group, cancellationToken); @@ -78,10 +86,12 @@ public sealed class DictionaryAppService( public async Task> SearchGroupsAsync(DictionaryGroupQuery request, CancellationToken cancellationToken = default) { + // 1. 确定查询范围并校验权限 var tenantId = tenantProvider.GetCurrentTenantId(); var scope = ResolveScopeForQuery(request.Scope, tenantId); EnsureScopePermission(scope); + // 2. 查询分组及可选项 var groups = await repository.SearchGroupsAsync(scope, cancellationToken); var includeItems = request.IncludeItems; var result = new List(groups.Count); @@ -91,6 +101,7 @@ public sealed class DictionaryAppService( IReadOnlyList items = Array.Empty(); if (includeItems) { + // 查询分组下字典项 var itemEntities = await repository.GetItemsByGroupIdAsync(group.Id, cancellationToken); items = itemEntities.Select(MapItem).ToList(); } @@ -103,9 +114,11 @@ public sealed class DictionaryAppService( public async Task CreateItemAsync(CreateDictionaryItemRequest request, CancellationToken cancellationToken = default) { + // 1. 校验分组与权限 var group = await RequireGroupAsync(request.GroupId, cancellationToken); EnsureScopePermission(group.Scope); + // 2. 构建字典项 var item = new DictionaryItem { Id = 0, @@ -119,6 +132,7 @@ public sealed class DictionaryAppService( IsEnabled = request.IsEnabled }; + // 3. 持久化并失效缓存 await repository.AddItemAsync(item, cancellationToken); await repository.SaveChangesAsync(cancellationToken); await InvalidateCacheAsync(group, cancellationToken); @@ -128,16 +142,19 @@ public sealed class DictionaryAppService( public async Task UpdateItemAsync(long itemId, UpdateDictionaryItemRequest request, CancellationToken cancellationToken = default) { + // 1. 读取字典项与分组并校验权限 var item = await RequireItemAsync(itemId, cancellationToken); var group = await RequireGroupAsync(item.GroupId, cancellationToken); EnsureScopePermission(group.Scope); + // 2. 更新字段 item.Value = request.Value.Trim(); item.Description = request.Description?.Trim(); item.SortOrder = request.SortOrder; item.IsDefault = request.IsDefault; item.IsEnabled = request.IsEnabled; + // 3. 持久化并失效缓存 await repository.SaveChangesAsync(cancellationToken); await InvalidateCacheAsync(group, cancellationToken); logger.LogInformation("更新字典项:{ItemId}", item.Id); @@ -146,10 +163,12 @@ public sealed class DictionaryAppService( public async Task DeleteItemAsync(long itemId, CancellationToken cancellationToken = default) { + // 1. 读取字典项与分组并校验权限 var item = await RequireItemAsync(itemId, cancellationToken); var group = await RequireGroupAsync(item.GroupId, cancellationToken); EnsureScopePermission(group.Scope); + // 2. 删除并失效缓存 await repository.RemoveItemAsync(item, cancellationToken); await repository.SaveChangesAsync(cancellationToken); await InvalidateCacheAsync(group, cancellationToken); @@ -158,6 +177,7 @@ public sealed class DictionaryAppService( public async Task>> GetCachedItemsAsync(DictionaryBatchQueryRequest request, CancellationToken cancellationToken = default) { + // 1. 规范化编码 var normalizedCodes = request.Codes .Where(code => !string.IsNullOrWhiteSpace(code)) .Select(NormalizeCode) @@ -169,6 +189,7 @@ public sealed class DictionaryAppService( return new Dictionary>(StringComparer.OrdinalIgnoreCase); } + // 2. 按租户合并系统与业务字典 var tenantId = tenantProvider.GetCurrentTenantId(); var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); @@ -190,6 +211,7 @@ public sealed class DictionaryAppService( private async Task RequireGroupAsync(long groupId, CancellationToken cancellationToken) { + // 1. 读取分组,找不到抛异常 var group = await repository.FindGroupByIdAsync(groupId, cancellationToken); if (group == null) { @@ -201,6 +223,7 @@ public sealed class DictionaryAppService( private async Task RequireItemAsync(long itemId, CancellationToken cancellationToken) { + // 1. 读取字典项,找不到抛异常 var item = await repository.FindItemByIdAsync(itemId, cancellationToken); if (item == null) { @@ -269,12 +292,14 @@ public sealed class DictionaryAppService( private async Task> GetOrLoadCacheAsync(long tenantId, string code, CancellationToken cancellationToken) { + // 1. 先查缓存 var cached = await cache.GetAsync(tenantId, code, cancellationToken); if (cached != null) { return cached; } + // 2. 从仓储加载并写入缓存 var entities = await repository.GetItemsByCodesAsync(new[] { code }, tenantId, includeSystem: false, cancellationToken); var items = entities .Where(item => item.IsEnabled && (item.Group?.IsEnabled ?? true)) diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/AssignUserRolesCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/AssignUserRolesCommandHandler.cs index 8105f69..adaea12 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/AssignUserRolesCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/AssignUserRolesCommandHandler.cs @@ -15,9 +15,14 @@ public sealed class AssignUserRolesCommandHandler( { public async Task Handle(AssignUserRolesCommand request, CancellationToken cancellationToken) { + // 1. 获取租户上下文 var tenantId = tenantProvider.GetCurrentTenantId(); + + // 2. 覆盖式绑定角色 await userRoleRepository.ReplaceUserRolesAsync(tenantId, request.UserId, request.RoleIds, cancellationToken); await userRoleRepository.SaveChangesAsync(cancellationToken); + + // 3. 返回执行结果 return true; } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/BindRolePermissionsCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/BindRolePermissionsCommandHandler.cs index eee1e9e..d5a6d0e 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/BindRolePermissionsCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/BindRolePermissionsCommandHandler.cs @@ -15,9 +15,14 @@ public sealed class BindRolePermissionsCommandHandler( { public async Task Handle(BindRolePermissionsCommand request, CancellationToken cancellationToken) { + // 1. 获取租户上下文 var tenantId = tenantProvider.GetCurrentTenantId(); + + // 2. 覆盖式绑定权限 await rolePermissionRepository.ReplaceRolePermissionsAsync(tenantId, request.RoleId, request.PermissionIds, cancellationToken); await rolePermissionRepository.SaveChangesAsync(cancellationToken); + + // 3. 返回执行结果 return true; } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CopyRoleTemplateCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CopyRoleTemplateCommandHandler.cs index 54b658b..e254b5f 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CopyRoleTemplateCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CopyRoleTemplateCommandHandler.cs @@ -26,6 +26,7 @@ public sealed class CopyRoleTemplateCommandHandler( /// public async Task Handle(CopyRoleTemplateCommand request, CancellationToken cancellationToken) { + // 1. 查询模板与模板权限 var template = await roleTemplateRepository.FindByCodeAsync(request.TemplateCode, cancellationToken) ?? throw new BusinessException(ErrorCodes.NotFound, $"角色模板 {request.TemplateCode} 不存在"); @@ -36,6 +37,7 @@ public sealed class CopyRoleTemplateCommandHandler( .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); + // 2. 计算角色名称/编码与描述 var tenantId = tenantProvider.GetCurrentTenantId(); var roleCode = string.IsNullOrWhiteSpace(request.RoleCode) ? template.TemplateCode : request.RoleCode.Trim(); var roleName = string.IsNullOrWhiteSpace(request.RoleName) ? template.Name : request.RoleName.Trim(); @@ -69,7 +71,7 @@ public sealed class CopyRoleTemplateCommandHandler( await roleRepository.UpdateAsync(role, cancellationToken); } - // 2. 确保模板权限全部存在,不存在则按模板定义创建。 + // 3. 确保模板权限全部存在,不存在则按模板定义创建。 var existingPermissions = await permissionRepository.GetByCodesAsync(tenantId, permissionCodes, cancellationToken); var permissionMap = existingPermissions.ToDictionary(x => x.Code, StringComparer.OrdinalIgnoreCase); @@ -94,7 +96,7 @@ public sealed class CopyRoleTemplateCommandHandler( await roleRepository.SaveChangesAsync(cancellationToken); - // 3. 绑定缺失的权限,保留租户自定义的已有授权。 + // 4. 绑定缺失的权限,保留租户自定义的已有授权。 var rolePermissions = await rolePermissionRepository.GetByRoleIdsAsync(tenantId, new[] { role.Id }, cancellationToken); var existingPermissionIds = rolePermissions .Select(x => x.PermissionId) diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreatePermissionCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreatePermissionCommandHandler.cs index 275946e..1e5cfc0 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreatePermissionCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreatePermissionCommandHandler.cs @@ -17,7 +17,10 @@ public sealed class CreatePermissionCommandHandler( { public async Task Handle(CreatePermissionCommand request, CancellationToken cancellationToken) { + // 1. 获取租户上下文 var tenantId = tenantProvider.GetCurrentTenantId(); + + // 2. 构建权限实体 var permission = new Permission { TenantId = tenantId, @@ -26,9 +29,11 @@ public sealed class CreatePermissionCommandHandler( Description = request.Description }; + // 3. 持久化 await permissionRepository.AddAsync(permission, cancellationToken); await permissionRepository.SaveChangesAsync(cancellationToken); + // 4. 返回 DTO return new PermissionDto { Id = permission.Id, diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateRoleCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateRoleCommandHandler.cs index 717393a..71fdcca 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateRoleCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateRoleCommandHandler.cs @@ -17,7 +17,10 @@ public sealed class CreateRoleCommandHandler( { public async Task Handle(CreateRoleCommand request, CancellationToken cancellationToken) { + // 1. 获取租户上下文 var tenantId = tenantProvider.GetCurrentTenantId(); + + // 2. 构建角色实体 var role = new Role { TenantId = tenantId, @@ -26,9 +29,11 @@ public sealed class CreateRoleCommandHandler( Description = request.Description }; + // 3. 持久化 await roleRepository.AddAsync(role, cancellationToken); await roleRepository.SaveChangesAsync(cancellationToken); + // 4. 返回 DTO return new RoleDto { Id = role.Id, diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateRoleTemplateCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateRoleTemplateCommandHandler.cs index 64460ef..b418306 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateRoleTemplateCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateRoleTemplateCommandHandler.cs @@ -19,17 +19,20 @@ public sealed class CreateRoleTemplateCommandHandler(IRoleTemplateRepository rol /// public async Task Handle(CreateRoleTemplateCommand request, CancellationToken cancellationToken) { + // 1. 校验必填 if (string.IsNullOrWhiteSpace(request.TemplateCode) || string.IsNullOrWhiteSpace(request.Name)) { throw new BusinessException(ErrorCodes.BadRequest, "模板编码与名称不能为空"); } + // 2. 检查编码唯一 var existing = await roleTemplateRepository.FindByCodeAsync(request.TemplateCode, cancellationToken); if (existing != null) { throw new BusinessException(ErrorCodes.Conflict, $"模板编码 {request.TemplateCode} 已存在"); } + // 3. 构建模板实体 var template = new RoleTemplate { TemplateCode = request.TemplateCode.Trim(), @@ -38,12 +41,14 @@ public sealed class CreateRoleTemplateCommandHandler(IRoleTemplateRepository rol IsActive = request.IsActive }; + // 4. 清洗权限编码 var permissions = request.PermissionCodes .Where(code => !string.IsNullOrWhiteSpace(code)) .Select(code => code.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); + // 5. 持久化并返回 DTO await roleTemplateRepository.AddAsync(template, permissions, cancellationToken); await roleTemplateRepository.SaveChangesAsync(cancellationToken); diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeletePermissionCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeletePermissionCommandHandler.cs index 9dc2ce8..a494786 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeletePermissionCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeletePermissionCommandHandler.cs @@ -15,9 +15,14 @@ public sealed class DeletePermissionCommandHandler( { public async Task Handle(DeletePermissionCommand request, CancellationToken cancellationToken) { + // 1. 获取租户上下文 var tenantId = tenantProvider.GetCurrentTenantId(); + + // 2. 删除权限 await permissionRepository.DeleteAsync(request.PermissionId, tenantId, cancellationToken); await permissionRepository.SaveChangesAsync(cancellationToken); + + // 3. 返回执行结果 return true; } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteRoleCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteRoleCommandHandler.cs index c45241a..66ab38c 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteRoleCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteRoleCommandHandler.cs @@ -15,9 +15,14 @@ public sealed class DeleteRoleCommandHandler( { public async Task Handle(DeleteRoleCommand request, CancellationToken cancellationToken) { + // 1. 获取租户上下文 var tenantId = tenantProvider.GetCurrentTenantId(); + + // 2. 删除角色 await roleRepository.DeleteAsync(request.RoleId, tenantId, cancellationToken); await roleRepository.SaveChangesAsync(cancellationToken); + + // 3. 返回执行结果 return true; } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteRoleTemplateCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteRoleTemplateCommandHandler.cs index 9947217..b641553 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteRoleTemplateCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteRoleTemplateCommandHandler.cs @@ -12,14 +12,18 @@ public sealed class DeleteRoleTemplateCommandHandler(IRoleTemplateRepository rol { public async Task Handle(DeleteRoleTemplateCommand request, CancellationToken cancellationToken) { + // 1. 查询模板 var template = await roleTemplateRepository.FindByCodeAsync(request.TemplateCode, cancellationToken); if (template == null) { return false; } + // 2. 删除并保存 await roleTemplateRepository.DeleteAsync(template.Id, cancellationToken); await roleTemplateRepository.SaveChangesAsync(cancellationToken); + + // 3. 返回执行结果 return true; } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetRoleTemplateQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetRoleTemplateQueryHandler.cs index b8b7bbe..2895c7d 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetRoleTemplateQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetRoleTemplateQueryHandler.cs @@ -15,14 +15,18 @@ public sealed class GetRoleTemplateQueryHandler(IRoleTemplateRepository roleTemp /// public async Task Handle(GetRoleTemplateQuery request, CancellationToken cancellationToken) { + // 1. 查询模板 var template = await roleTemplateRepository.FindByCodeAsync(request.TemplateCode, cancellationToken); if (template == null) { return null; } + // 2. 查询模板权限 var permissions = await roleTemplateRepository.GetPermissionsAsync(template.Id, cancellationToken); var codes = permissions.Select(x => x.PermissionCode).ToArray(); + + // 3. 返回 DTO return TemplateMapper.ToDto(template, codes); } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetUserPermissionsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetUserPermissionsQueryHandler.cs index a5a2a42..d50be4b 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetUserPermissionsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetUserPermissionsQueryHandler.cs @@ -20,26 +20,22 @@ public sealed class GetUserPermissionsQueryHandler( ITenantProvider tenantProvider) : IRequestHandler { - private readonly IIdentityUserRepository _identityUserRepository = identityUserRepository; - private readonly IUserRoleRepository _userRoleRepository = userRoleRepository; - private readonly IRoleRepository _roleRepository = roleRepository; - private readonly IPermissionRepository _permissionRepository = permissionRepository; - private readonly IRolePermissionRepository _rolePermissionRepository = rolePermissionRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - /// public async Task Handle(GetUserPermissionsQuery request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var user = await _identityUserRepository.FindByIdAsync(request.UserId, cancellationToken); + // 1. 获取租户并查询用户 + var tenantId = tenantProvider.GetCurrentTenantId(); + var user = await identityUserRepository.FindByIdAsync(request.UserId, cancellationToken); if (user == null || user.TenantId != tenantId) { return null; } + // 2. 解析角色与权限 var roleCodes = await ResolveUserRolesAsync(tenantId, user.Id, cancellationToken); var permissionCodes = await ResolveUserPermissionsAsync(tenantId, user.Id, cancellationToken); + // 3. 返回用户权限概览 return new UserPermissionDto { UserId = user.Id, @@ -55,34 +51,39 @@ public sealed class GetUserPermissionsQueryHandler( private async Task ResolveUserRolesAsync(long tenantId, long userId, CancellationToken cancellationToken) { - var relations = await _userRoleRepository.GetByUserIdAsync(tenantId, userId, cancellationToken); + // 1. 查询用户角色关系 + var relations = await userRoleRepository.GetByUserIdAsync(tenantId, userId, cancellationToken); var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray(); if (roleIds.Length == 0) { return Array.Empty(); } - var roles = await _roleRepository.GetByIdsAsync(tenantId, roleIds, cancellationToken); + // 2. 查询角色编码 + var roles = await roleRepository.GetByIdsAsync(tenantId, roleIds, cancellationToken); return roles.Select(x => x.Code).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); } private async Task ResolveUserPermissionsAsync(long tenantId, long userId, CancellationToken cancellationToken) { - var relations = await _userRoleRepository.GetByUserIdAsync(tenantId, userId, cancellationToken); + // 1. 查询用户角色关系 + var relations = await userRoleRepository.GetByUserIdAsync(tenantId, userId, cancellationToken); var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray(); if (roleIds.Length == 0) { return Array.Empty(); } - var rolePermissions = await _rolePermissionRepository.GetByRoleIdsAsync(tenantId, roleIds, cancellationToken); + // 2. 查询角色-权限关系 + var rolePermissions = await rolePermissionRepository.GetByRoleIdsAsync(tenantId, roleIds, cancellationToken); var permissionIds = rolePermissions.Select(x => x.PermissionId).Distinct().ToArray(); if (permissionIds.Length == 0) { return Array.Empty(); } - var permissions = await _permissionRepository.GetByIdsAsync(tenantId, permissionIds, cancellationToken); + // 3. 查询权限编码 + var permissions = await permissionRepository.GetByIdsAsync(tenantId, permissionIds, cancellationToken); return permissions.Select(x => x.Code).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/ListRoleTemplatesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ListRoleTemplatesQueryHandler.cs index 2b533cd..571ebf5 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/ListRoleTemplatesQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ListRoleTemplatesQueryHandler.cs @@ -16,9 +16,11 @@ public sealed class ListRoleTemplatesQueryHandler(IRoleTemplateRepository roleTe /// public async Task> Handle(ListRoleTemplatesQuery request, CancellationToken cancellationToken) { + // 1. 查询模板与权限映射 var templates = await roleTemplateRepository.GetAllAsync(request.IsActive, cancellationToken); var permissionsMap = await roleTemplateRepository.GetPermissionsAsync(templates.Select(t => t.Id), cancellationToken); + // 2. 排序并映射 DTO var dtos = templates .OrderBy(template => template.TemplateCode, StringComparer.OrdinalIgnoreCase) .Select(template => @@ -30,6 +32,7 @@ public sealed class ListRoleTemplatesQueryHandler(IRoleTemplateRepository roleTe }) .ToArray(); + // 3. 返回结果 return dtos; } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchPermissionsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchPermissionsQueryHandler.cs index 97bdd1b..62a4ce9 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchPermissionsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchPermissionsQueryHandler.cs @@ -19,9 +19,11 @@ public sealed class SearchPermissionsQueryHandler( { public async Task> Handle(SearchPermissionsQuery request, CancellationToken cancellationToken) { + // 1. 获取租户上下文并查询权限 var tenantId = tenantProvider.GetCurrentTenantId(); var permissions = await permissionRepository.SearchAsync(tenantId, request.Keyword, cancellationToken); + // 2. 排序 var sorted = request.SortBy?.ToLowerInvariant() switch { "name" => request.SortDescending @@ -35,11 +37,13 @@ public sealed class SearchPermissionsQueryHandler( : permissions.OrderBy(x => x.CreatedAt) }; + // 3. 分页 var paged = sorted .Skip((request.Page - 1) * request.PageSize) .Take(request.PageSize) .ToList(); + // 4. 映射 DTO var items = paged.Select(permission => new PermissionDto { Id = permission.Id, @@ -49,6 +53,7 @@ public sealed class SearchPermissionsQueryHandler( Description = permission.Description }).ToList(); + // 5. 返回分页结果 return new PagedResult(items, request.Page, request.PageSize, permissions.Count); } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchRolesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchRolesQueryHandler.cs index bd11a5d..3e18b69 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchRolesQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchRolesQueryHandler.cs @@ -19,9 +19,11 @@ public sealed class SearchRolesQueryHandler( { public async Task> Handle(SearchRolesQuery request, CancellationToken cancellationToken) { + // 1. 获取租户上下文并查询角色 var tenantId = tenantProvider.GetCurrentTenantId(); var roles = await roleRepository.SearchAsync(tenantId, request.Keyword, cancellationToken); + // 2. 排序 var sorted = request.SortBy?.ToLowerInvariant() switch { "name" => request.SortDescending @@ -32,11 +34,13 @@ public sealed class SearchRolesQueryHandler( : roles.OrderBy(x => x.CreatedAt) }; + // 3. 分页 var paged = sorted .Skip((request.Page - 1) * request.PageSize) .Take(request.PageSize) .ToList(); + // 4. 映射 DTO var items = paged.Select(role => new RoleDto { Id = role.Id, @@ -46,6 +50,7 @@ public sealed class SearchRolesQueryHandler( Description = role.Description }).ToList(); + // 5. 返回分页结果 return new PagedResult(items, request.Page, request.PageSize, roles.Count); } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchUserPermissionsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchUserPermissionsQueryHandler.cs index 07e3595..599d6fd 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchUserPermissionsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchUserPermissionsQueryHandler.cs @@ -22,25 +22,21 @@ public sealed class SearchUserPermissionsQueryHandler( ITenantProvider tenantProvider) : IRequestHandler> { - private readonly IIdentityUserRepository _identityUserRepository = identityUserRepository; - private readonly IUserRoleRepository _userRoleRepository = userRoleRepository; - private readonly IRoleRepository _roleRepository = roleRepository; - private readonly IPermissionRepository _permissionRepository = permissionRepository; - private readonly IRolePermissionRepository _rolePermissionRepository = rolePermissionRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - /// public async Task> Handle(SearchUserPermissionsQuery request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var users = await _identityUserRepository.SearchAsync(tenantId, request.Keyword, cancellationToken); + // 1. 获取租户并查询用户 + var tenantId = tenantProvider.GetCurrentTenantId(); + var users = await identityUserRepository.SearchAsync(tenantId, request.Keyword, cancellationToken); + // 2. 排序与分页 var sorted = SortUsers(users, request.SortBy, request.SortDescending); var paged = sorted .Skip((request.Page - 1) * request.PageSize) .Take(request.PageSize) .ToList(); + // 3. 解析角色与权限 var resolved = await ResolveRolesAndPermissionsAsync(tenantId, paged, cancellationToken); var items = paged.Select(user => new UserPermissionDto { @@ -81,23 +77,27 @@ public sealed class SearchUserPermissionsQueryHandler( IReadOnlyCollection users, CancellationToken cancellationToken) { + // 1. 查询用户角色关系 var userIds = users.Select(x => x.Id).ToArray(); - var userRoleRelations = await _userRoleRepository.GetByUserIdsAsync(tenantId, userIds, cancellationToken); + var userRoleRelations = await userRoleRepository.GetByUserIdsAsync(tenantId, userIds, cancellationToken); var roleIds = userRoleRelations.Select(x => x.RoleId).Distinct().ToArray(); + // 2. 查询角色信息 var roles = roleIds.Length == 0 ? Array.Empty() - : await _roleRepository.GetByIdsAsync(tenantId, roleIds, cancellationToken); + : await roleRepository.GetByIdsAsync(tenantId, roleIds, cancellationToken); var roleCodeMap = roles.ToDictionary(r => r.Id, r => r.Code, comparer: EqualityComparer.Default); + // 3. 查询角色-权限关系 var rolePermissions = roleIds.Length == 0 ? Array.Empty() - : await _rolePermissionRepository.GetByRoleIdsAsync(tenantId, roleIds, cancellationToken); + : await rolePermissionRepository.GetByRoleIdsAsync(tenantId, roleIds, cancellationToken); var permissionIds = rolePermissions.Select(x => x.PermissionId).Distinct().ToArray(); + // 4. 查询权限详情 var permissions = permissionIds.Length == 0 ? Array.Empty() - : await _permissionRepository.GetByIdsAsync(tenantId, permissionIds, cancellationToken); + : await permissionRepository.GetByIdsAsync(tenantId, permissionIds, cancellationToken); var permissionCodeMap = permissions.ToDictionary(p => p.Id, p => p.Code, comparer: EqualityComparer.Default); var rolePermissionsLookup = rolePermissions @@ -107,6 +107,7 @@ public sealed class SearchUserPermissionsQueryHandler( var result = new Dictionary(); foreach (var userId in userIds) { + // 5. 聚合用户角色与权限编码 var rolesForUser = userRoleRelations.Where(ur => ur.UserId == userId).Select(ur => ur.RoleId).Distinct().ToArray(); var roleCodes = rolesForUser .Select(rid => roleCodeMap.GetValueOrDefault(rid)) diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdatePermissionCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdatePermissionCommandHandler.cs index b123164..cde1cdd 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdatePermissionCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdatePermissionCommandHandler.cs @@ -16,6 +16,7 @@ public sealed class UpdatePermissionCommandHandler( { public async Task Handle(UpdatePermissionCommand request, CancellationToken cancellationToken) { + // 1. 获取租户上下文并查询权限 var tenantId = tenantProvider.GetCurrentTenantId(); var permission = await permissionRepository.FindByIdAsync(request.PermissionId, tenantId, cancellationToken); if (permission == null) @@ -23,12 +24,15 @@ public sealed class UpdatePermissionCommandHandler( return null; } + // 2. 更新字段 permission.Name = request.Name; permission.Description = request.Description; + // 3. 持久化 await permissionRepository.UpdateAsync(permission, cancellationToken); await permissionRepository.SaveChangesAsync(cancellationToken); + // 4. 返回 DTO return new PermissionDto { Id = permission.Id, diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateRoleCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateRoleCommandHandler.cs index c9b6a2d..49898de 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateRoleCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateRoleCommandHandler.cs @@ -16,6 +16,7 @@ public sealed class UpdateRoleCommandHandler( { public async Task Handle(UpdateRoleCommand request, CancellationToken cancellationToken) { + // 1. 获取租户上下文并查询角色 var tenantId = tenantProvider.GetCurrentTenantId(); var role = await roleRepository.FindByIdAsync(request.RoleId, tenantId, cancellationToken); if (role == null) @@ -23,12 +24,15 @@ public sealed class UpdateRoleCommandHandler( return null; } + // 2. 更新字段 role.Name = request.Name; role.Description = request.Description; + // 3. 持久化 await roleRepository.UpdateAsync(role, cancellationToken); await roleRepository.SaveChangesAsync(cancellationToken); + // 4. 返回 DTO return new RoleDto { Id = role.Id, diff --git a/src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeRequest.cs b/src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeRequest.cs index 402385f..5a1bca6 100644 --- a/src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeRequest.cs +++ b/src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeRequest.cs @@ -10,8 +10,6 @@ namespace TakeoutSaaS.Application.Sms.Contracts; /// public sealed class SendVerificationCodeRequest(string phoneNumber, string scene, SmsProviderKind? provider = null) { - - /// /// 手机号(支持 +86 前缀或纯 11 位)。 /// diff --git a/src/Application/TakeoutSaaS.Application/Sms/Contracts/VerifyVerificationCodeRequest.cs b/src/Application/TakeoutSaaS.Application/Sms/Contracts/VerifyVerificationCodeRequest.cs index 034230c..9eb1262 100644 --- a/src/Application/TakeoutSaaS.Application/Sms/Contracts/VerifyVerificationCodeRequest.cs +++ b/src/Application/TakeoutSaaS.Application/Sms/Contracts/VerifyVerificationCodeRequest.cs @@ -8,8 +8,6 @@ namespace TakeoutSaaS.Application.Sms.Contracts; /// public sealed class VerifyVerificationCodeRequest(string phoneNumber, string scene, string code) { - - /// /// 手机号。 /// diff --git a/src/Application/TakeoutSaaS.Application/Sms/Services/VerificationCodeService.cs b/src/Application/TakeoutSaaS.Application/Sms/Services/VerificationCodeService.cs index 88806c9..70048b1 100644 --- a/src/Application/TakeoutSaaS.Application/Sms/Services/VerificationCodeService.cs +++ b/src/Application/TakeoutSaaS.Application/Sms/Services/VerificationCodeService.cs @@ -30,6 +30,7 @@ public sealed class VerificationCodeService( /// public async Task SendAsync(SendVerificationCodeRequest request, CancellationToken cancellationToken = default) { + // 1. 参数校验 if (string.IsNullOrWhiteSpace(request.PhoneNumber)) { throw new BusinessException(ErrorCodes.BadRequest, "手机号不能为空"); @@ -40,6 +41,7 @@ public sealed class VerificationCodeService( throw new BusinessException(ErrorCodes.BadRequest, "场景不能为空"); } + // 2. 解析模板与缓存键 var smsOptions = smsOptionsMonitor.CurrentValue; var codeOptions = codeOptionsMonitor.CurrentValue; var templateCode = ResolveTemplate(request.Scene, smsOptions); @@ -48,8 +50,10 @@ public sealed class VerificationCodeService( var cacheKey = $"{codeOptions.CachePrefix}:{tenantKey}:{request.Scene}:{phone}"; var cooldownKey = $"{cacheKey}:cooldown"; + // 3. 检查冷却期 await EnsureCooldownAsync(cooldownKey, codeOptions.CooldownSeconds, cancellationToken).ConfigureAwait(false); + // 4. 生成验证码并发送短信 var code = GenerateCode(codeOptions.CodeLength); var variables = new Dictionary { { "code", code } }; var sender = senderResolver.Resolve(request.Provider); @@ -61,6 +65,7 @@ public sealed class VerificationCodeService( throw new BusinessException(ErrorCodes.InternalServerError, $"短信发送失败:{smsResult.Message}"); } + // 5. 写入验证码与冷却缓存 var expiresAt = DateTimeOffset.UtcNow.AddMinutes(codeOptions.ExpireMinutes); await cache.SetStringAsync(cacheKey, code, new DistributedCacheEntryOptions { @@ -83,11 +88,13 @@ public sealed class VerificationCodeService( /// public async Task VerifyAsync(VerifyVerificationCodeRequest request, CancellationToken cancellationToken = default) { + // 1. 基础校验 if (string.IsNullOrWhiteSpace(request.Code)) { return false; } + // 2. 读取验证码 var codeOptions = codeOptionsMonitor.CurrentValue; var phone = NormalizePhoneNumber(request.PhoneNumber); var tenantKey = tenantProvider.GetCurrentTenantId() == 0 ? "platform" : tenantProvider.GetCurrentTenantId().ToString(); @@ -99,6 +106,7 @@ public sealed class VerificationCodeService( return false; } + // 3. 比对成功后清除缓存 var success = string.Equals(cachedCode, request.Code, StringComparison.Ordinal); if (success) { diff --git a/src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadRequest.cs b/src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadRequest.cs index b757e36..48e05eb 100644 --- a/src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadRequest.cs +++ b/src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadRequest.cs @@ -10,8 +10,6 @@ namespace TakeoutSaaS.Application.Storage.Contracts; /// public sealed class DirectUploadRequest(UploadFileType fileType, string fileName, string contentType, long contentLength, string? requestOrigin) { - - /// /// 文件类型。 /// diff --git a/src/Application/TakeoutSaaS.Application/Storage/Contracts/UploadFileRequest.cs b/src/Application/TakeoutSaaS.Application/Storage/Contracts/UploadFileRequest.cs index ea19f91..011f39d 100644 --- a/src/Application/TakeoutSaaS.Application/Storage/Contracts/UploadFileRequest.cs +++ b/src/Application/TakeoutSaaS.Application/Storage/Contracts/UploadFileRequest.cs @@ -17,8 +17,6 @@ public sealed class UploadFileRequest( long contentLength, string? requestOrigin) { - - /// /// 文件分类。 /// diff --git a/src/Application/TakeoutSaaS.Application/Storage/Services/FileStorageService.cs b/src/Application/TakeoutSaaS.Application/Storage/Services/FileStorageService.cs index f2105c5..6ce4e2c 100644 --- a/src/Application/TakeoutSaaS.Application/Storage/Services/FileStorageService.cs +++ b/src/Application/TakeoutSaaS.Application/Storage/Services/FileStorageService.cs @@ -35,11 +35,13 @@ public sealed class FileStorageService( /// public async Task UploadAsync(UploadFileRequest request, CancellationToken cancellationToken = default) { + // 1. 校验请求 if (request is null) { throw new BusinessException(ErrorCodes.BadRequest, "上传请求不能为空"); } + // 2. 读取安全配置并校验来源/大小/类型 var options = optionsMonitor.CurrentValue; var security = options.Security; ValidateOrigin(request.RequestOrigin, security); @@ -50,15 +52,18 @@ public sealed class FileStorageService( var contentType = NormalizeContentType(request.ContentType, extension); ResetStream(request.Content); + // 3. 生成对象键与元数据 var objectKey = BuildObjectKey(request.FileType, extension); var metadata = BuildMetadata(request.FileType); var expires = TimeSpan.FromMinutes(Math.Max(1, security.DefaultUrlExpirationMinutes)); var provider = providerResolver.Resolve(); + // 4. 上传到对象存储 var uploadResult = await provider.UploadAsync( new StorageUploadRequest(objectKey, request.Content, contentType, request.ContentLength, true, expires, metadata), cancellationToken).ConfigureAwait(false); + // 5. 追加防盗链签名并返回 var finalUrl = AppendAntiLeechToken(uploadResult.SignedUrl ?? uploadResult.Url, objectKey, expires, security); logger.LogInformation("文件上传成功:{ObjectKey} ({Size} bytes)", objectKey, request.ContentLength); @@ -73,11 +78,13 @@ public sealed class FileStorageService( /// public async Task CreateDirectUploadAsync(DirectUploadRequest request, CancellationToken cancellationToken = default) { + // 1. 校验请求 if (request is null) { throw new BusinessException(ErrorCodes.BadRequest, "直传请求不能为空"); } + // 2. 校验来源/大小/类型 var options = optionsMonitor.CurrentValue; var security = options.Security; ValidateOrigin(request.RequestOrigin, security); @@ -87,14 +94,17 @@ public sealed class FileStorageService( ValidateExtension(request.FileType, extension, security); var contentType = NormalizeContentType(request.ContentType, extension); + // 3. 构建直传参数 var objectKey = BuildObjectKey(request.FileType, extension); var provider = providerResolver.Resolve(); var expires = TimeSpan.FromMinutes(Math.Max(1, security.DefaultUrlExpirationMinutes)); + // 4. 向存储获取直传凭证 var directResult = await provider.CreateDirectUploadAsync( new StorageDirectUploadRequest(objectKey, contentType, request.ContentLength, expires), cancellationToken).ConfigureAwait(false); + // 5. 构造直传结果并追加防盗链 var finalDownloadUrl = directResult.SignedDownloadUrl != null ? AppendAntiLeechToken(directResult.SignedDownloadUrl, objectKey, expires, security) : null; diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Constants/DatabaseConstants.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Constants/DatabaseConstants.cs index cf8a78e..2f4022e 100644 --- a/src/Core/TakeoutSaaS.Shared.Abstractions/Constants/DatabaseConstants.cs +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Constants/DatabaseConstants.cs @@ -6,7 +6,7 @@ public static class DatabaseConstants { /// - /// 默认业务库(AppDatabase)。 + /// 默认业务库(AppDatabase). /// public const string AppDataSource = "AppDatabase"; diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Constants/ErrorCodes.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Constants/ErrorCodes.cs index 2d7ed97..3214155 100644 --- a/src/Core/TakeoutSaaS.Shared.Abstractions/Constants/ErrorCodes.cs +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Constants/ErrorCodes.cs @@ -5,15 +5,43 @@ namespace TakeoutSaaS.Shared.Abstractions.Constants; /// public static class ErrorCodes { + /// + /// 请求参数错误。 + /// public const int BadRequest = 400; + + /// + /// 未授权访问。 + /// public const int Unauthorized = 401; + + /// + /// 权限不足。 + /// public const int Forbidden = 403; + + /// + /// 资源未找到。 + /// public const int NotFound = 404; + + /// + /// 资源冲突。 + /// public const int Conflict = 409; + + /// + /// 校验失败。 + /// public const int ValidationFailed = 422; + + /// + /// 服务器内部错误。 + /// public const int InternalServerError = 500; - // 业务自定义区间(10000+) + /// + /// 业务自定义错误(10000+)。 + /// public const int BusinessError = 10001; } - diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.cs index b49a215..62365b3 100644 --- a/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.cs +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.cs @@ -6,7 +6,7 @@ namespace TakeoutSaaS.Shared.Abstractions.Results; /// /// 统一的 API 返回结果包装。 /// -/// 数据载荷类型 +/// 数据载荷类型。 public sealed record ApiResponse { /// @@ -92,6 +92,9 @@ public sealed record ApiResponse Timestamp = DateTime.UtcNow }; + /// + /// 解析当前 TraceId。 + /// private static string ResolveTraceId() { if (!string.IsNullOrWhiteSpace(TraceContext.TraceId)) @@ -113,9 +116,19 @@ public sealed record ApiResponse } } +/// +/// 作为 TraceId 缺失时的本地雪花 ID 备用生成器。 +/// internal sealed class IdFallbackGenerator { + /// + /// 延迟初始化的单例实例承载。 + /// private static readonly Lazy Lazy = new(() => new IdFallbackGenerator()); + + /// + /// 获取备用雪花生成器单例。 + /// public static IdFallbackGenerator Instance => Lazy.Value; private readonly object _sync = new(); @@ -126,6 +139,9 @@ internal sealed class IdFallbackGenerator { } + /// + /// 生成雪花风格的本地备用 ID。 + /// public long NextId() { lock (_sync) @@ -149,6 +165,9 @@ internal sealed class IdFallbackGenerator } } + /// + /// 等待到下一个毫秒以避免序列冲突。 + /// private static long WaitNextMillis(long lastTimestamp) { var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); diff --git a/src/Core/TakeoutSaaS.Shared.Web/Middleware/RequestLoggingMiddleware.cs b/src/Core/TakeoutSaaS.Shared.Web/Middleware/RequestLoggingMiddleware.cs index 7c5be5f..9ec3592 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/Middleware/RequestLoggingMiddleware.cs +++ b/src/Core/TakeoutSaaS.Shared.Web/Middleware/RequestLoggingMiddleware.cs @@ -11,7 +11,6 @@ namespace TakeoutSaaS.Shared.Web.Middleware; /// public sealed class RequestLoggingMiddleware(RequestDelegate next, ILogger logger) { - public async Task InvokeAsync(HttpContext context) { var stopwatch = Stopwatch.StartNew(); diff --git a/src/Core/TakeoutSaaS.Shared.Web/Security/HttpContextCurrentUserAccessor.cs b/src/Core/TakeoutSaaS.Shared.Web/Security/HttpContextCurrentUserAccessor.cs index e7f5209..3d5c423 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/Security/HttpContextCurrentUserAccessor.cs +++ b/src/Core/TakeoutSaaS.Shared.Web/Security/HttpContextCurrentUserAccessor.cs @@ -12,8 +12,6 @@ namespace TakeoutSaaS.Shared.Web.Security; /// public sealed class HttpContextCurrentUserAccessor(IHttpContextAccessor httpContextAccessor) : ICurrentUserAccessor { - - /// public long UserId { diff --git a/src/Domain/TakeoutSaaS.Domain/Deliveries/Entities/DeliveryOrder.cs b/src/Domain/TakeoutSaaS.Domain/Deliveries/Entities/DeliveryOrder.cs index c547d6b..f408473 100644 --- a/src/Domain/TakeoutSaaS.Domain/Deliveries/Entities/DeliveryOrder.cs +++ b/src/Domain/TakeoutSaaS.Domain/Deliveries/Entities/DeliveryOrder.cs @@ -8,6 +8,9 @@ namespace TakeoutSaaS.Domain.Deliveries.Entities; /// public sealed class DeliveryOrder : MultiTenantEntityBase { + /// + /// 获取或设置关联订单 ID。 + /// public long OrderId { get; set; } /// diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IPermissionRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IPermissionRepository.cs index 7f78dde..b6e7833 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IPermissionRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IPermissionRepository.cs @@ -10,13 +10,80 @@ namespace TakeoutSaaS.Domain.Identity.Repositories; /// public interface IPermissionRepository { + /// + /// 根据 ID 查询权限。 + /// + /// 权限 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 权限实体或 null。 Task FindByIdAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 根据编码查询权限。 + /// + /// 权限编码。 + /// 租户 ID。 + /// 取消标记。 + /// 权限实体或 null。 Task FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 根据编码集合查询权限列表。 + /// + /// 租户 ID。 + /// 权限编码集合。 + /// 取消标记。 + /// 权限集合。 Task> GetByCodesAsync(long tenantId, IEnumerable codes, CancellationToken cancellationToken = default); + + /// + /// 根据 ID 集合查询权限列表。 + /// + /// 租户 ID。 + /// 权限 ID 集合。 + /// 取消标记。 + /// 权限集合。 Task> GetByIdsAsync(long tenantId, IEnumerable permissionIds, CancellationToken cancellationToken = default); + + /// + /// 按关键字搜索权限。 + /// + /// 租户 ID。 + /// 关键字。 + /// 取消标记。 + /// 权限集合。 Task> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default); + + /// + /// 新增权限。 + /// + /// 权限实体。 + /// 取消标记。 + /// 异步操作任务。 Task AddAsync(Permission permission, CancellationToken cancellationToken = default); + + /// + /// 更新权限。 + /// + /// 权限实体。 + /// 取消标记。 + /// 异步操作任务。 Task UpdateAsync(Permission permission, CancellationToken cancellationToken = default); + + /// + /// 删除权限。 + /// + /// 权限 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 异步操作任务。 Task DeleteAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 保存仓储变更。 + /// + /// 取消标记。 + /// 异步操作任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRolePermissionRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRolePermissionRepository.cs index 5ef9d8d..3f45d5c 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRolePermissionRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRolePermissionRepository.cs @@ -10,8 +10,37 @@ namespace TakeoutSaaS.Domain.Identity.Repositories; /// public interface IRolePermissionRepository { + /// + /// 根据角色 ID 集合获取角色权限关系。 + /// + /// 租户 ID。 + /// 角色 ID 集合。 + /// 取消标记。 + /// 角色权限关系列表。 Task> GetByRoleIdsAsync(long tenantId, IEnumerable roleIds, CancellationToken cancellationToken = default); + + /// + /// 批量新增角色权限关系。 + /// + /// 角色权限集合。 + /// 取消标记。 + /// 异步操作任务。 Task AddRangeAsync(IEnumerable rolePermissions, CancellationToken cancellationToken = default); + + /// + /// 替换角色的权限集合。 + /// + /// 租户 ID。 + /// 角色 ID。 + /// 权限 ID 集合。 + /// 取消标记。 + /// 异步操作任务。 Task ReplaceRolePermissionsAsync(long tenantId, long roleId, IEnumerable permissionIds, CancellationToken cancellationToken = default); + + /// + /// 提交持久化变更。 + /// + /// 取消标记。 + /// 异步操作任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleRepository.cs index 822266e..f740fce 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleRepository.cs @@ -10,12 +10,71 @@ namespace TakeoutSaaS.Domain.Identity.Repositories; /// public interface IRoleRepository { + /// + /// 根据 ID 查询角色。 + /// + /// 角色 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 角色实体或 null。 Task FindByIdAsync(long roleId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 根据编码查询角色。 + /// + /// 角色编码。 + /// 租户 ID。 + /// 取消标记。 + /// 角色实体或 null。 Task FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 批量获取角色列表。 + /// + /// 租户 ID。 + /// 角色 ID 集合。 + /// 取消标记。 + /// 角色集合。 Task> GetByIdsAsync(long tenantId, IEnumerable roleIds, CancellationToken cancellationToken = default); + + /// + /// 按关键字搜索角色。 + /// + /// 租户 ID。 + /// 关键字。 + /// 取消标记。 + /// 角色集合。 Task> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default); + + /// + /// 新增角色。 + /// + /// 角色实体。 + /// 取消标记。 + /// 异步操作任务。 Task AddAsync(Role role, CancellationToken cancellationToken = default); + + /// + /// 更新角色。 + /// + /// 角色实体。 + /// 取消标记。 + /// 异步操作任务。 Task UpdateAsync(Role role, CancellationToken cancellationToken = default); + + /// + /// 删除角色。 + /// + /// 角色 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 异步操作任务。 Task DeleteAsync(long roleId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 保存仓储变更。 + /// + /// 取消标记。 + /// 异步操作任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleTemplateRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleTemplateRepository.cs index 963754a..3a9ce61 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleTemplateRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleTemplateRepository.cs @@ -10,19 +10,68 @@ namespace TakeoutSaaS.Domain.Identity.Repositories; /// public interface IRoleTemplateRepository { + /// + /// 查询角色模板列表。 + /// + /// 启用状态过滤。 + /// 取消标记。 + /// 角色模板集合。 Task> GetAllAsync(bool? isActive, CancellationToken cancellationToken = default); + /// + /// 通过模板编码获取模板信息。 + /// + /// 模板编码。 + /// 取消标记。 + /// 模板实体或 null。 Task FindByCodeAsync(string templateCode, CancellationToken cancellationToken = default); + /// + /// 获取模板的权限列表。 + /// + /// 模板 ID。 + /// 取消标记。 + /// 权限集合。 Task> GetPermissionsAsync(long roleTemplateId, CancellationToken cancellationToken = default); + /// + /// 批量获取多个模板的权限映射。 + /// + /// 模板 ID 集合。 + /// 取消标记。 + /// 模板与权限列表的映射。 Task>> GetPermissionsAsync(IEnumerable roleTemplateIds, CancellationToken cancellationToken = default); + /// + /// 新增模板及其权限。 + /// + /// 模板实体。 + /// 权限编码集合。 + /// 取消标记。 + /// 异步操作任务。 Task AddAsync(RoleTemplate template, IEnumerable permissionCodes, CancellationToken cancellationToken = default); + /// + /// 更新模板及权限。 + /// + /// 模板实体。 + /// 权限编码集合。 + /// 取消标记。 + /// 异步操作任务。 Task UpdateAsync(RoleTemplate template, IEnumerable permissionCodes, CancellationToken cancellationToken = default); + /// + /// 删除模板。 + /// + /// 模板 ID。 + /// 取消标记。 + /// 异步操作任务。 Task DeleteAsync(long roleTemplateId, CancellationToken cancellationToken = default); + /// + /// 保存仓储变更。 + /// + /// 取消标记。 + /// 异步操作任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IUserRoleRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IUserRoleRepository.cs index aa9b9c8..68c0915 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IUserRoleRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IUserRoleRepository.cs @@ -10,8 +10,38 @@ namespace TakeoutSaaS.Domain.Identity.Repositories; /// public interface IUserRoleRepository { + /// + /// 批量获取指定用户的角色关系。 + /// + /// 租户 ID。 + /// 用户 ID 集合。 + /// 取消标记。 + /// 用户角色关系集合。 Task> GetByUserIdsAsync(long tenantId, IEnumerable userIds, CancellationToken cancellationToken = default); + + /// + /// 获取单个用户的角色关系。 + /// + /// 租户 ID。 + /// 用户 ID。 + /// 取消标记。 + /// 指定用户的角色关系列表。 Task> GetByUserIdAsync(long tenantId, long userId, CancellationToken cancellationToken = default); + + /// + /// 替换用户的角色列表。 + /// + /// 租户 ID。 + /// 用户 ID。 + /// 角色 ID 集合。 + /// 取消标记。 + /// 异步操作任务。 Task ReplaceUserRolesAsync(long tenantId, long userId, IEnumerable roleIds, CancellationToken cancellationToken = default); + + /// + /// 提交持久化变更。 + /// + /// 取消标记。 + /// 异步操作任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantRepository.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantRepository.cs index c467a2e..3a7df9b 100644 --- a/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantRepository.cs @@ -30,12 +30,30 @@ public interface IMerchantRepository /// 获取指定商户的合同列表。 /// Task> GetContractsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 根据合同 ID 获取合同详情。 + /// + /// 商户 ID。 + /// 租户 ID。 + /// 合同 ID。 + /// 取消标记。 + /// 合同实体或 null。 Task FindContractByIdAsync(long merchantId, long tenantId, long contractId, CancellationToken cancellationToken = default); /// /// 获取指定商户的资质文件列表。 /// Task> GetDocumentsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 根据文件 ID 获取资质文件详情。 + /// + /// 商户 ID。 + /// 租户 ID。 + /// 文件 ID。 + /// 取消标记。 + /// 资质文件实体或 null。 Task FindDocumentByIdAsync(long merchantId, long tenantId, long documentId, CancellationToken cancellationToken = default); /// @@ -52,12 +70,26 @@ public interface IMerchantRepository /// 新增商户合同。 /// Task AddContractAsync(MerchantContract contract, CancellationToken cancellationToken = default); + + /// + /// 更新商户合同。 + /// + /// 合同实体。 + /// 取消标记。 + /// 异步操作任务。 Task UpdateContractAsync(MerchantContract contract, CancellationToken cancellationToken = default); /// /// 新增商户资质文件。 /// Task AddDocumentAsync(MerchantDocument document, CancellationToken cancellationToken = default); + + /// + /// 更新商户资质文件。 + /// + /// 资质文件实体。 + /// 取消标记。 + /// 异步操作任务。 Task UpdateDocumentAsync(MerchantDocument document, CancellationToken cancellationToken = default); /// diff --git a/src/Domain/TakeoutSaaS.Domain/Queues/Entities/QueueTicket.cs b/src/Domain/TakeoutSaaS.Domain/Queues/Entities/QueueTicket.cs index e235909..2c07860 100644 --- a/src/Domain/TakeoutSaaS.Domain/Queues/Entities/QueueTicket.cs +++ b/src/Domain/TakeoutSaaS.Domain/Queues/Entities/QueueTicket.cs @@ -8,6 +8,9 @@ namespace TakeoutSaaS.Domain.Queues.Entities; /// public sealed class QueueTicket : MultiTenantEntityBase { + /// + /// 获取或设置所属门店 ID。 + /// public long StoreId { get; set; } /// diff --git a/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs b/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs index 2e76835..c087384 100644 --- a/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs +++ b/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs @@ -14,8 +14,8 @@ using System.Threading.RateLimiting; const string CorsPolicyName = "GatewayCors"; +// 1. 创建构建器并配置 Serilog var builder = WebApplication.CreateBuilder(args); - builder.Host.UseSerilog((context, services, loggerConfiguration) => { loggerConfiguration @@ -24,9 +24,11 @@ builder.Host.UseSerilog((context, services, loggerConfiguration) => .Enrich.FromLogContext(); }); +// 2. 配置 YARP 反向代理 builder.Services.AddReverseProxy() .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")); +// 3. 转发头部配置 builder.Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; @@ -34,6 +36,7 @@ builder.Services.Configure(options => options.KnownProxies.Clear(); }); +// 4. 配置 CORS builder.Services.AddCors(options => { options.AddPolicy(CorsPolicyName, policy => @@ -44,6 +47,7 @@ builder.Services.AddCors(options => }); }); +// 5. 配置网关限流 builder.Services.Configure(builder.Configuration.GetSection("Gateway:RateLimiting")); var rateLimitOptions = builder.Configuration.GetSection("Gateway:RateLimiting").Get() ?? new(); @@ -66,6 +70,7 @@ if (rateLimitOptions.Enabled) }); } +// 6. 配置 OpenTelemetry var otelOptions = builder.Configuration.GetSection("OpenTelemetry").Get() ?? new(); if (otelOptions.Enabled) { @@ -117,10 +122,13 @@ if (otelOptions.Enabled) }); } +// 7. 构建应用 var app = builder.Build(); +// 8. 转发头中间件 app.UseForwardedHeaders(); +// 9. 全局异常处理中间件 app.UseExceptionHandler(errorApp => { // 1. 捕获所有未处理异常并返回统一结构。 @@ -145,6 +153,7 @@ app.UseExceptionHandler(errorApp => }); }); +// 10. 请求日志 app.UseSerilogRequestLogging(options => { options.MessageTemplate = "网关请求 {RequestMethod} {RequestPath} => {StatusCode} 用时 {Elapsed:0.000} 秒"; @@ -156,6 +165,7 @@ app.UseSerilogRequestLogging(options => }; }); +// 11. CORS 与限流 app.UseCors(CorsPolicyName); if (rateLimitOptions.Enabled) @@ -163,6 +173,7 @@ if (rateLimitOptions.Enabled) app.UseRateLimiter(); } +// 12. 透传请求头并保证 Trace app.Use(async (context, next) => { // 1. 确保请求拥有可追踪的 ID。 @@ -187,6 +198,7 @@ app.Use(async (context, next) => await next(context); }); +// 13. 映射反向代理与健康接口 app.MapReverseProxy(); app.MapGet("/", () => Results.Json(new diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfDeliveryRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfDeliveryRepository.cs index 34917c7..2a67038 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfDeliveryRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfDeliveryRepository.cs @@ -15,8 +15,6 @@ namespace TakeoutSaaS.Infrastructure.App.Repositories; /// public sealed class EfDeliveryRepository(TakeoutAppDbContext context) : IDeliveryRepository { - - /// public Task FindByIdAsync(long deliveryOrderId, long tenantId, CancellationToken cancellationToken = default) { diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs index 35e00a8..cef1a73 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs @@ -15,8 +15,6 @@ namespace TakeoutSaaS.Infrastructure.App.Repositories; /// public sealed class EfMerchantRepository(TakeoutAppDbContext context) : IMerchantRepository { - - /// public Task FindByIdAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default) { diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfOrderRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfOrderRepository.cs index c73a185..b5a2c7f 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfOrderRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfOrderRepository.cs @@ -16,8 +16,6 @@ namespace TakeoutSaaS.Infrastructure.App.Repositories; /// public sealed class EfOrderRepository(TakeoutAppDbContext context) : IOrderRepository { - - /// public Task FindByIdAsync(long orderId, long tenantId, CancellationToken cancellationToken = default) { diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfPaymentRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfPaymentRepository.cs index 90be2a6..4178313 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfPaymentRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfPaymentRepository.cs @@ -15,8 +15,6 @@ namespace TakeoutSaaS.Infrastructure.App.Repositories; /// public sealed class EfPaymentRepository(TakeoutAppDbContext context) : IPaymentRepository { - - /// public Task FindByIdAsync(long paymentId, long tenantId, CancellationToken cancellationToken = default) { diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs index 65666bb..244578a 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs @@ -15,8 +15,6 @@ namespace TakeoutSaaS.Infrastructure.App.Repositories; /// public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductRepository { - - /// public Task FindByIdAsync(long productId, long tenantId, CancellationToken cancellationToken = default) { diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs index 3a0934a..53f6b4f 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs @@ -15,8 +15,6 @@ namespace TakeoutSaaS.Infrastructure.App.Repositories; /// public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepository { - - /// public Task FindByIdAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) { diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfDictionaryRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfDictionaryRepository.cs index 232ad67..64e8608 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfDictionaryRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfDictionaryRepository.cs @@ -12,7 +12,6 @@ namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories; /// public sealed class EfDictionaryRepository(DictionaryDbContext context) : IDictionaryRepository { - public Task FindGroupByIdAsync(long id, CancellationToken cancellationToken = default) => context.DictionaryGroups.FirstOrDefaultAsync(group => group.Id == id, cancellationToken); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs index 1f2bc95..02701f8 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs @@ -13,7 +13,6 @@ namespace TakeoutSaaS.Infrastructure.Identity.Persistence; /// public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIdentityUserRepository { - public Task FindByAccountAsync(string account, CancellationToken cancellationToken = default) => dbContext.IdentityUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Account == account, cancellationToken); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfMiniUserRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfMiniUserRepository.cs index 3276793..83c7b61 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfMiniUserRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfMiniUserRepository.cs @@ -12,7 +12,6 @@ namespace TakeoutSaaS.Infrastructure.Identity.Persistence; /// public sealed class EfMiniUserRepository(IdentityDbContext dbContext) : IMiniUserRepository { - public Task FindByOpenIdAsync(string openId, CancellationToken cancellationToken = default) => dbContext.MiniUsers.AsNoTracking().FirstOrDefaultAsync(x => x.OpenId == openId, cancellationToken); diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessagePublisher.cs b/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessagePublisher.cs index 701a992..6506acb 100644 --- a/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessagePublisher.cs +++ b/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessagePublisher.cs @@ -22,17 +22,21 @@ public sealed class RabbitMqMessagePublisher(RabbitMqConnectionFactory connectio /// public Task PublishAsync(string routingKey, T message, CancellationToken cancellationToken = default) { + // 1. 确保通道可用 EnsureChannel(); var options = optionsMonitor.CurrentValue; var channel = _channel ?? throw new InvalidOperationException("RabbitMQ channel is not available."); + // 2. 声明交换机 channel.ExchangeDeclare(options.Exchange, options.ExchangeType, durable: true, autoDelete: false); + // 3. 序列化消息并设置属性 var body = serializer.Serialize(message); var props = channel.CreateBasicProperties(); props.ContentType = "application/json"; props.DeliveryMode = 2; props.MessageId = Guid.NewGuid().ToString("N"); + // 4. 发布消息 channel.BasicPublish(options.Exchange, routingKey, props, body); logger.LogDebug("发布消息到交换机 {Exchange} RoutingKey {RoutingKey}", options.Exchange, routingKey); return Task.CompletedTask; diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessageSubscriber.cs b/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessageSubscriber.cs index 1acba98..ef50289 100644 --- a/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessageSubscriber.cs +++ b/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessageSubscriber.cs @@ -21,16 +21,19 @@ public sealed class RabbitMqMessageSubscriber(RabbitMqConnectionFactory connecti /// public async Task SubscribeAsync(string queue, string routingKey, Func> handler, CancellationToken cancellationToken = default) { + // 1. 确保通道可用 EnsureChannel(); var options = optionsMonitor.CurrentValue; var channel = _channel ?? throw new InvalidOperationException("RabbitMQ channel is not available."); + // 2. 声明交换机、队列及绑定 channel.ExchangeDeclare(options.Exchange, options.ExchangeType, durable: true, autoDelete: false); channel.QueueDeclare(queue, durable: true, exclusive: false, autoDelete: false); channel.QueueBind(queue, options.Exchange, routingKey); channel.BasicQos(0, options.PrefetchCount, global: false); + // 3. 设置消费者回调 var consumer = new AsyncEventingBasicConsumer(channel); consumer.Received += async (_, ea) => { @@ -61,6 +64,7 @@ public sealed class RabbitMqMessageSubscriber(RabbitMqConnectionFactory connecti } }; + // 4. 开始消费 channel.BasicConsume(queue, autoAck: false, consumer); await Task.CompletedTask.ConfigureAwait(false); } diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Services/AliyunSmsSender.cs b/src/Modules/TakeoutSaaS.Module.Sms/Services/AliyunSmsSender.cs index c94a47c..166d66e 100644 --- a/src/Modules/TakeoutSaaS.Module.Sms/Services/AliyunSmsSender.cs +++ b/src/Modules/TakeoutSaaS.Module.Sms/Services/AliyunSmsSender.cs @@ -13,8 +13,6 @@ namespace TakeoutSaaS.Module.Sms.Services; public sealed class AliyunSmsSender(IHttpClientFactory httpClientFactory, IOptionsMonitor optionsMonitor, ILogger logger) : ISmsSender { - private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; - /// public SmsProviderKind Provider => SmsProviderKind.Aliyun; diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Services/TencentSmsSender.cs b/src/Modules/TakeoutSaaS.Module.Sms/Services/TencentSmsSender.cs index f7b9737..72f8083 100644 --- a/src/Modules/TakeoutSaaS.Module.Sms/Services/TencentSmsSender.cs +++ b/src/Modules/TakeoutSaaS.Module.Sms/Services/TencentSmsSender.cs @@ -27,6 +27,7 @@ public sealed class TencentSmsSender(IHttpClientFactory httpClientFactory, IOpti /// public async Task SendAsync(SmsSendRequest request, CancellationToken cancellationToken = default) { + // 1. 读取配置并处理 Mock var options = optionsMonitor.CurrentValue; if (options.UseMock) { @@ -34,6 +35,7 @@ public sealed class TencentSmsSender(IHttpClientFactory httpClientFactory, IOpti return new SmsSendResult { Success = true, Message = "Mocked" }; } + // 2. 构建请求负载与签名所需字段 var tencent = options.Tencent; var payload = BuildPayload(request, tencent); var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); @@ -44,6 +46,7 @@ public sealed class TencentSmsSender(IHttpClientFactory httpClientFactory, IOpti var stringToSign = BuildStringToSign(canonicalRequest, timestamp, date); var signature = Sign(stringToSign, tencent.SecretKey, date); + // 3. 构建 HTTP 请求 using var httpClient = httpClientFactory.CreateClient(nameof(TencentSmsSender)); using var httpRequest = new HttpRequestMessage(HttpMethod.Post, tencent.Endpoint) { @@ -58,6 +61,7 @@ public sealed class TencentSmsSender(IHttpClientFactory httpClientFactory, IOpti httpRequest.Headers.Add("Authorization", $"TC3-HMAC-SHA256 Credential={tencent.SecretId}/{date}/{Service}/tc3_request, SignedHeaders=content-type;host, Signature={signature}"); + // 4. 发送请求并读取响应 var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); @@ -67,6 +71,7 @@ public sealed class TencentSmsSender(IHttpClientFactory httpClientFactory, IOpti return new SmsSendResult { Success = false, Message = content }; } + // 5. 解析响应 using var doc = JsonDocument.Parse(content); var root = doc.RootElement.GetProperty("Response"); var status = root.GetProperty("SendStatusSet")[0]; diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadRequest.cs b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadRequest.cs index 80d6a81..201e394 100644 --- a/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadRequest.cs +++ b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadRequest.cs @@ -12,8 +12,6 @@ namespace TakeoutSaaS.Module.Storage.Models; /// 签名有效期。 public sealed class StorageDirectUploadRequest(string objectKey, string contentType, long contentLength, TimeSpan expires) { - - /// /// 目标对象键。 /// diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageUploadRequest.cs b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageUploadRequest.cs index 3d055ab..b718c18 100644 --- a/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageUploadRequest.cs +++ b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageUploadRequest.cs @@ -25,7 +25,6 @@ public sealed class StorageUploadRequest( TimeSpan signedUrlExpires, IDictionary? metadata = null) { - /// /// 对象键。 /// diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Providers/AliyunOssStorageProvider.cs b/src/Modules/TakeoutSaaS.Module.Storage/Providers/AliyunOssStorageProvider.cs index abb74ed..b12fd4b 100644 --- a/src/Modules/TakeoutSaaS.Module.Storage/Providers/AliyunOssStorageProvider.cs +++ b/src/Modules/TakeoutSaaS.Module.Storage/Providers/AliyunOssStorageProvider.cs @@ -28,6 +28,7 @@ public sealed class AliyunOssStorageProvider(IOptionsMonitor opt /// public async Task UploadAsync(StorageUploadRequest request, CancellationToken cancellationToken = default) { + // 1. 准备元数据 var options = CurrentOptions; var metadata = new ObjectMetadata { @@ -41,13 +42,16 @@ public sealed class AliyunOssStorageProvider(IOptionsMonitor opt } // Aliyun OSS SDK 支持异步方法,如未支持将同步封装为任务。 + // 2. 上传对象 await PutObjectAsync(options.AliyunOss.Bucket, request.ObjectKey, request.Content, metadata, cancellationToken) .ConfigureAwait(false); + // 3. 生成签名或公有 URL var signedUrl = request.GenerateSignedUrl ? await GenerateDownloadUrlAsync(request.ObjectKey, request.SignedUrlExpires, cancellationToken).ConfigureAwait(false) : null; + // 4. 返回上传结果 return new StorageUploadResult { ObjectKey = request.ObjectKey, @@ -61,10 +65,12 @@ public sealed class AliyunOssStorageProvider(IOptionsMonitor opt /// public Task CreateDirectUploadAsync(StorageDirectUploadRequest request, CancellationToken cancellationToken = default) { + // 1. 计算过期时间并生成直传/下载链接 var expiresAt = DateTimeOffset.UtcNow.Add(request.Expires); var uploadUrl = GeneratePresignedUrl(request.ObjectKey, request.Expires, SignHttpMethod.Put, request.ContentType); var downloadUrl = GeneratePresignedUrl(request.ObjectKey, request.Expires, SignHttpMethod.Get, null); + // 2. 返回直传参数 var result = new StorageDirectUploadResult { UploadUrl = uploadUrl, @@ -80,6 +86,7 @@ public sealed class AliyunOssStorageProvider(IOptionsMonitor opt /// public Task GenerateDownloadUrlAsync(string objectKey, TimeSpan expires, CancellationToken cancellationToken = default) { + // 1. 生成预签名下载 URL var url = GeneratePresignedUrl(objectKey, expires, SignHttpMethod.Get, null); return Task.FromResult(url); } @@ -110,6 +117,7 @@ public sealed class AliyunOssStorageProvider(IOptionsMonitor opt private async Task PutObjectAsync(string bucket, string key, Stream content, ObjectMetadata metadata, CancellationToken cancellationToken) { var client = EnsureClient(); + // SDK 无异步则封装为 Task await Task.Run(() => client.PutObject(bucket, key, content, metadata), cancellationToken).ConfigureAwait(false); } diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Providers/S3StorageProviderBase.cs b/src/Modules/TakeoutSaaS.Module.Storage/Providers/S3StorageProviderBase.cs index afdfe96..f42e34c 100644 --- a/src/Modules/TakeoutSaaS.Module.Storage/Providers/S3StorageProviderBase.cs +++ b/src/Modules/TakeoutSaaS.Module.Storage/Providers/S3StorageProviderBase.cs @@ -65,6 +65,7 @@ public abstract class S3StorageProviderBase : IObjectStorageProvider, IDisposabl /// public virtual async Task UploadAsync(StorageUploadRequest request, CancellationToken cancellationToken = default) { + // 1. 构建上传请求 var putRequest = new PutObjectRequest { BucketName = Bucket, @@ -79,12 +80,15 @@ public abstract class S3StorageProviderBase : IObjectStorageProvider, IDisposabl putRequest.Metadata[kv.Key] = kv.Value; } + // 2. 执行上传 await Client.PutObjectAsync(putRequest, cancellationToken).ConfigureAwait(false); + // 3. 根据需要生成签名 URL var signedUrl = request.GenerateSignedUrl ? GenerateSignedUrl(request.ObjectKey, request.SignedUrlExpires) : null; + // 4. 返回上传结果 return new StorageUploadResult { ObjectKey = request.ObjectKey, @@ -98,10 +102,12 @@ public abstract class S3StorageProviderBase : IObjectStorageProvider, IDisposabl /// public virtual Task CreateDirectUploadAsync(StorageDirectUploadRequest request, CancellationToken cancellationToken = default) { + // 1. 计算过期时间并生成直传 URL var expiresAt = DateTimeOffset.UtcNow.Add(request.Expires); var uploadUrl = GenerateSignedUrl(request.ObjectKey, request.Expires, HttpVerb.PUT, request.ContentType); var signedDownload = GenerateSignedUrl(request.ObjectKey, request.Expires); + // 2. 返回直传参数 var result = new StorageDirectUploadResult { UploadUrl = uploadUrl, @@ -117,6 +123,7 @@ public abstract class S3StorageProviderBase : IObjectStorageProvider, IDisposabl /// public virtual Task GenerateDownloadUrlAsync(string objectKey, TimeSpan expires, CancellationToken cancellationToken = default) { + // 1. 生成下载签名 URL var url = GenerateSignedUrl(objectKey, expires); return Task.FromResult(url); } diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantProvider.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantProvider.cs index 069202d..9f6b1b9 100644 --- a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantProvider.cs +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantProvider.cs @@ -11,8 +11,6 @@ namespace TakeoutSaaS.Module.Tenancy; /// 租户上下文访问器 public sealed class TenantProvider(ITenantContextAccessor tenantContextAccessor) : ITenantProvider { - - /// public long GetCurrentTenantId() => tenantContextAccessor.Current?.TenantId ?? 0; diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionMiddleware.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionMiddleware.cs index 2a117bf..a5f6530 100644 --- a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionMiddleware.cs +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionMiddleware.cs @@ -20,8 +20,6 @@ public sealed class TenantResolutionMiddleware( ITenantContextAccessor tenantContextAccessor, IOptionsMonitor optionsMonitor) { - - /// /// 解析租户并将上下文注入请求。 /// From 7e6125c6879002bf5f3c10a2634d02b73f605731 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Thu, 4 Dec 2025 11:31:26 +0800 Subject: [PATCH 24/30] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E5=BA=93?= =?UTF-8?q?=E5=AD=98=E9=94=81=E5=AE=9A=E5=B9=82=E7=AD=89=E4=B8=8E=E6=89=B9?= =?UTF-8?q?=E6=AC=A1=E6=89=A3=E5=87=8F=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/InventoryController.cs | 149 ++++++++++++++++++ .../appsettings.Seed.Development.json | 24 +++ .../Commands/AdjustInventoryCommand.cs | 46 ++++++ .../Commands/DeductInventoryCommand.cs | 35 ++++ .../Commands/LockInventoryCommand.cs | 41 +++++ .../ReleaseExpiredInventoryLocksCommand.cs | 8 + .../Commands/ReleaseInventoryCommand.cs | 35 ++++ .../Commands/UpsertInventoryBatchCommand.cs | 45 ++++++ .../App/Inventory/Dto/InventoryBatchDto.cs | 53 +++++++ .../App/Inventory/Dto/InventoryItemDto.cs | 99 ++++++++++++ .../Handlers/AdjustInventoryCommandHandler.cs | 85 ++++++++++ .../Handlers/DeductInventoryCommandHandler.cs | 110 +++++++++++++ .../GetInventoryBatchesQueryHandler.cs | 26 +++ .../Handlers/GetInventoryItemQueryHandler.cs | 26 +++ .../Handlers/LockInventoryCommandHandler.cs | 92 +++++++++++ ...easeExpiredInventoryLocksCommandHandler.cs | 60 +++++++ .../ReleaseInventoryCommandHandler.cs | 76 +++++++++ .../UpsertInventoryBatchCommandHandler.cs | 57 +++++++ .../App/Inventory/InventoryMapping.cs | 49 ++++++ .../Queries/GetInventoryBatchesQuery.cs | 20 +++ .../Queries/GetInventoryItemQuery.cs | 20 +++ .../AdjustInventoryCommandValidator.cs | 21 +++ .../DeductInventoryCommandValidator.cs | 21 +++ .../LockInventoryCommandValidator.cs | 21 +++ .../ReleaseInventoryCommandValidator.cs | 21 +++ .../UpsertInventoryBatchCommandValidator.cs | 22 +++ .../Inventory/Entities/InventoryBatch.cs | 8 + .../Inventory/Entities/InventoryItem.cs | 49 ++++++ .../Inventory/Entities/InventoryLockRecord.cs | 52 ++++++ .../Enums/InventoryBatchConsumeStrategy.cs | 17 ++ .../Inventory/Enums/InventoryLockStatus.cs | 22 +++ .../Repositories/IInventoryRepository.cs | 95 +++++++++++ .../AppServiceCollectionExtensions.cs | 2 + .../App/Persistence/TakeoutAppDbContext.cs | 18 +++ .../App/Repositories/EfInventoryRepository.cs | 145 +++++++++++++++++ 35 files changed, 1670 insertions(+) create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/InventoryController.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Commands/AdjustInventoryCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Commands/DeductInventoryCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Commands/LockInventoryCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Commands/ReleaseExpiredInventoryLocksCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Commands/ReleaseInventoryCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Commands/UpsertInventoryBatchCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Dto/InventoryBatchDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Dto/InventoryItemDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/AdjustInventoryCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/DeductInventoryCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/GetInventoryBatchesQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/GetInventoryItemQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/LockInventoryCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/ReleaseExpiredInventoryLocksCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/ReleaseInventoryCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/UpsertInventoryBatchCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/InventoryMapping.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Queries/GetInventoryBatchesQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Queries/GetInventoryItemQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Validators/AdjustInventoryCommandValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Validators/DeductInventoryCommandValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Validators/LockInventoryCommandValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Validators/ReleaseInventoryCommandValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Inventory/Validators/UpsertInventoryBatchCommandValidator.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryLockRecord.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Inventory/Enums/InventoryBatchConsumeStrategy.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Inventory/Enums/InventoryLockStatus.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Inventory/Repositories/IInventoryRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfInventoryRepository.cs diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/InventoryController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/InventoryController.cs new file mode 100644 index 0000000..410d5f2 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/InventoryController.cs @@ -0,0 +1,149 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Inventory.Commands; +using TakeoutSaaS.Application.App.Inventory.Dto; +using TakeoutSaaS.Application.App.Inventory.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}/stores/{storeId:long}/inventory")] +public sealed class InventoryController(IMediator mediator) : BaseApiController +{ + /// + /// 查询库存。 + /// + [HttpGet("{productSkuId:long}")] + [PermissionAuthorize("inventory:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Get(long storeId, long productSkuId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetInventoryItemQuery { StoreId = storeId, ProductSkuId = productSkuId }, cancellationToken); + return result is null + ? ApiResponse.Error(ErrorCodes.NotFound, "库存不存在") + : ApiResponse.Ok(result); + } + + /// + /// 调整库存(入库/盘点/报损)。 + /// + [HttpPost("adjust")] + [PermissionAuthorize("inventory:adjust")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Adjust(long storeId, [FromBody] AdjustInventoryCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 锁定库存(下单占用)。 + /// + [HttpPost("lock")] + [PermissionAuthorize("inventory:lock")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Lock(long storeId, [FromBody] LockInventoryCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 释放库存(取消订单等)。 + /// + [HttpPost("release")] + [PermissionAuthorize("inventory:release")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Release(long storeId, [FromBody] ReleaseInventoryCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 扣减库存(支付或履约成功)。 + /// + [HttpPost("deduct")] + [PermissionAuthorize("inventory:deduct")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Deduct(long storeId, [FromBody] DeductInventoryCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 查询批次列表。 + /// + [HttpGet("{productSkuId:long}/batches")] + [PermissionAuthorize("inventory:batch:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> GetBatches(long storeId, long productSkuId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetInventoryBatchesQuery + { + StoreId = storeId, + ProductSkuId = productSkuId + }, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 新增或更新批次。 + /// + [HttpPost("{productSkuId:long}/batches")] + [PermissionAuthorize("inventory:batch:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> UpsertBatch(long storeId, long productSkuId, [FromBody] UpsertInventoryBatchCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0 || command.ProductSkuId == 0) + { + command = command with { StoreId = storeId, ProductSkuId = productSkuId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 释放过期锁定。 + /// + [HttpPost("locks/expire")] + [PermissionAuthorize("inventory:release")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> ReleaseExpiredLocks(CancellationToken cancellationToken) + { + var count = await mediator.Send(new ReleaseExpiredInventoryLocksCommand(), cancellationToken); + return ApiResponse.Ok(count); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json index 1af6231..4c483b1 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json @@ -218,6 +218,14 @@ "product-media:update", "product-pricing:read", "product-pricing:update", + "inventory:read", + "inventory:adjust", + "inventory:lock", + "inventory:release", + "inventory:deduct", + "inventory:batch:read", + "inventory:batch:update", + "inventory:lock:expire", "order:create", "order:read", "order:update", @@ -274,6 +282,14 @@ "product-media:update", "product-pricing:read", "product-pricing:update", + "inventory:read", + "inventory:adjust", + "inventory:lock", + "inventory:release", + "inventory:deduct", + "inventory:batch:read", + "inventory:batch:update", + "inventory:lock:expire", "order:create", "order:read", "order:update", @@ -374,6 +390,14 @@ "product-media:update", "product-pricing:read", "product-pricing:update", + "inventory:read", + "inventory:adjust", + "inventory:lock", + "inventory:release", + "inventory:deduct", + "inventory:batch:read", + "inventory:batch:update", + "inventory:lock:expire", "order:create", "order:read", "order:update", diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/AdjustInventoryCommand.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/AdjustInventoryCommand.cs new file mode 100644 index 0000000..7ee93f7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/AdjustInventoryCommand.cs @@ -0,0 +1,46 @@ +using MediatR; +using TakeoutSaaS.Application.App.Inventory.Dto; +using TakeoutSaaS.Domain.Inventory.Enums; + +namespace TakeoutSaaS.Application.App.Inventory.Commands; + +/// +/// 库存调整命令。 +/// +public sealed record AdjustInventoryCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// SKU ID。 + /// + public long ProductSkuId { get; init; } + + /// + /// 调整数量,正数入库,负数出库。 + /// + public int QuantityDelta { get; init; } + + /// + /// 调整类型。 + /// + public InventoryAdjustmentType AdjustmentType { get; init; } = InventoryAdjustmentType.Manual; + + /// + /// 原因说明。 + /// + public string? Reason { get; init; } + + /// + /// 安全库存阈值(可选)。 + /// + public int? SafetyStock { get; init; } + + /// + /// 是否售罄标记。 + /// + public bool? IsSoldOut { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/DeductInventoryCommand.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/DeductInventoryCommand.cs new file mode 100644 index 0000000..d97026f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/DeductInventoryCommand.cs @@ -0,0 +1,35 @@ +using MediatR; +using TakeoutSaaS.Application.App.Inventory.Dto; + +namespace TakeoutSaaS.Application.App.Inventory.Commands; + +/// +/// 扣减库存命令(履约/支付成功)。 +/// +public sealed record DeductInventoryCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// SKU ID。 + /// + public long ProductSkuId { get; init; } + + /// + /// 扣减数量。 + /// + public int Quantity { get; init; } + + /// + /// 是否预售锁定转扣减。 + /// + public bool IsPresaleOrder { get; init; } + + /// + /// 幂等键(与锁定请求一致可避免重复扣减)。 + /// + public string? IdempotencyKey { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/LockInventoryCommand.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/LockInventoryCommand.cs new file mode 100644 index 0000000..f19754b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/LockInventoryCommand.cs @@ -0,0 +1,41 @@ +using System; +using MediatR; +using TakeoutSaaS.Application.App.Inventory.Dto; + +namespace TakeoutSaaS.Application.App.Inventory.Commands; + +/// +/// 锁定库存命令。 +/// +public sealed record LockInventoryCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// SKU ID。 + /// + public long ProductSkuId { get; init; } + + /// + /// 锁定数量。 + /// + public int Quantity { get; init; } + + /// + /// 是否按预售逻辑锁定。 + /// + public bool IsPresaleOrder { get; init; } + + /// + /// 锁定过期时间(UTC),超时可释放。 + /// + public DateTime? ExpiresAt { get; init; } + + /// + /// 幂等键(同一键重复调用返回同一结果)。 + /// + public string IdempotencyKey { get; init; } = string.Empty; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/ReleaseExpiredInventoryLocksCommand.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/ReleaseExpiredInventoryLocksCommand.cs new file mode 100644 index 0000000..a721448 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/ReleaseExpiredInventoryLocksCommand.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Inventory.Commands; + +/// +/// 释放过期库存锁定命令。 +/// +public sealed record ReleaseExpiredInventoryLocksCommand : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/ReleaseInventoryCommand.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/ReleaseInventoryCommand.cs new file mode 100644 index 0000000..dd7c889 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/ReleaseInventoryCommand.cs @@ -0,0 +1,35 @@ +using MediatR; +using TakeoutSaaS.Application.App.Inventory.Dto; + +namespace TakeoutSaaS.Application.App.Inventory.Commands; + +/// +/// 释放库存命令。 +/// +public sealed record ReleaseInventoryCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// SKU ID。 + /// + public long ProductSkuId { get; init; } + + /// + /// 释放数量。 + /// + public int Quantity { get; init; } + + /// + /// 是否预售锁定释放。 + /// + public bool IsPresaleOrder { get; init; } + + /// + /// 幂等键(与锁定请求一致可避免重复释放)。 + /// + public string? IdempotencyKey { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/UpsertInventoryBatchCommand.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/UpsertInventoryBatchCommand.cs new file mode 100644 index 0000000..8943014 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/UpsertInventoryBatchCommand.cs @@ -0,0 +1,45 @@ +using MediatR; +using TakeoutSaaS.Application.App.Inventory.Dto; + +namespace TakeoutSaaS.Application.App.Inventory.Commands; + +/// +/// 新增或更新库存批次命令。 +/// +public sealed record UpsertInventoryBatchCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// SKU ID。 + /// + public long ProductSkuId { get; init; } + + /// + /// 批次号。 + /// + public string BatchNumber { get; init; } = string.Empty; + + /// + /// 生产日期。 + /// + public DateTime? ProductionDate { get; init; } + + /// + /// 过期日期。 + /// + public DateTime? ExpireDate { get; init; } + + /// + /// 入库数量。 + /// + public int Quantity { get; init; } + + /// + /// 剩余数量。 + /// + public int RemainingQuantity { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Dto/InventoryBatchDto.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Dto/InventoryBatchDto.cs new file mode 100644 index 0000000..eed3c53 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Dto/InventoryBatchDto.cs @@ -0,0 +1,53 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Inventory.Dto; + +/// +/// 库存批次 DTO。 +/// +public sealed record InventoryBatchDto +{ + /// + /// 批次 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// SKU ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long ProductSkuId { get; init; } + + /// + /// 批次号。 + /// + public string BatchNumber { get; init; } = string.Empty; + + /// + /// 生产日期。 + /// + public DateTime? ProductionDate { get; init; } + + /// + /// 过期日期。 + /// + public DateTime? ExpireDate { get; init; } + + /// + /// 入库数量。 + /// + public int Quantity { get; init; } + + /// + /// 剩余数量。 + /// + public int RemainingQuantity { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Dto/InventoryItemDto.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Dto/InventoryItemDto.cs new file mode 100644 index 0000000..f02902a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Dto/InventoryItemDto.cs @@ -0,0 +1,99 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Inventory.Dto; + +/// +/// 库存项 DTO。 +/// +public sealed record InventoryItemDto +{ + /// + /// 库存记录 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// SKU ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long ProductSkuId { get; init; } + + /// + /// 批次号。 + /// + public string? BatchNumber { get; init; } + + /// + /// 可用库存。 + /// + public int QuantityOnHand { get; init; } + + /// + /// 已锁定库存。 + /// + public int QuantityReserved { get; init; } + + /// + /// 安全库存。 + /// + public int? SafetyStock { get; init; } + + /// + /// 储位。 + /// + public string? Location { get; init; } + + /// + /// 过期日期。 + /// + public DateTime? ExpireDate { get; init; } + + /// + /// 是否预售。 + /// + public bool IsPresale { get; init; } + + /// + /// 预售开始时间。 + /// + public DateTime? PresaleStartTime { get; init; } + + /// + /// 预售结束时间。 + /// + public DateTime? PresaleEndTime { get; init; } + + /// + /// 预售上限。 + /// + public int? PresaleCapacity { get; init; } + + /// + /// 已锁定预售量。 + /// + public int PresaleLocked { get; init; } + + /// + /// 限购数量。 + /// + public int? MaxQuantityPerOrder { get; init; } + + /// + /// 是否售罄。 + /// + public bool IsSoldOut { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/AdjustInventoryCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/AdjustInventoryCommandHandler.cs new file mode 100644 index 0000000..0590589 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/AdjustInventoryCommandHandler.cs @@ -0,0 +1,85 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Inventory.Commands; +using TakeoutSaaS.Application.App.Inventory.Dto; +using TakeoutSaaS.Domain.Inventory.Entities; +using TakeoutSaaS.Domain.Inventory.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Inventory.Handlers; + +/// +/// 库存调整处理器。 +/// +public sealed class AdjustInventoryCommandHandler( + IInventoryRepository inventoryRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(AdjustInventoryCommand request, CancellationToken cancellationToken) + { + // 1. 读取库存 + var tenantId = tenantProvider.GetCurrentTenantId(); + var item = await inventoryRepository.GetForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken); + // 2. 初始化或校验存在性 + if (item is null) + { + if (request.QuantityDelta < 0) + { + throw new BusinessException(ErrorCodes.NotFound, "库存不存在,无法扣减"); + } + + // 初始化库存记录 + item = new InventoryItem + { + TenantId = tenantId, + StoreId = request.StoreId, + ProductSkuId = request.ProductSkuId, + QuantityOnHand = request.QuantityDelta, + QuantityReserved = 0, + SafetyStock = request.SafetyStock, + IsSoldOut = false + }; + await inventoryRepository.AddItemAsync(item, cancellationToken); + } + + // 3. 应用调整 + var newQuantity = item.QuantityOnHand + request.QuantityDelta; + if (newQuantity < 0) + { + throw new BusinessException(ErrorCodes.Conflict, "库存不足,无法扣减"); + } + + item.QuantityOnHand = newQuantity; + item.SafetyStock = request.SafetyStock ?? item.SafetyStock; + item.IsSoldOut = request.IsSoldOut ?? IsSoldOut(item); + + // 4. 写入调整记录 + var adjustment = new InventoryAdjustment + { + TenantId = tenantId, + InventoryItemId = item.Id, + AdjustmentType = request.AdjustmentType, + Quantity = request.QuantityDelta, + Reason = request.Reason, + OperatorId = null, + OccurredAt = DateTime.UtcNow + }; + await inventoryRepository.AddAdjustmentAsync(adjustment, cancellationToken); + await inventoryRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("调整库存 SKU {ProductSkuId} 门店 {StoreId} 变更 {Delta}", request.ProductSkuId, request.StoreId, request.QuantityDelta); + return InventoryMapping.ToDto(item); + } + + // 辅助:售罄判定 + private static bool IsSoldOut(InventoryItem item) + { + var available = item.QuantityOnHand - item.QuantityReserved - item.PresaleLocked; + var safety = item.SafetyStock ?? 0; + return available <= safety || item.QuantityOnHand <= 0; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/DeductInventoryCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/DeductInventoryCommandHandler.cs new file mode 100644 index 0000000..0cbde90 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/DeductInventoryCommandHandler.cs @@ -0,0 +1,110 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Inventory.Commands; +using TakeoutSaaS.Application.App.Inventory.Dto; +using TakeoutSaaS.Domain.Inventory.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Inventory.Handlers; + +/// +/// 库存扣减处理器。 +/// +public sealed class DeductInventoryCommandHandler( + IInventoryRepository inventoryRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(DeductInventoryCommand request, CancellationToken cancellationToken) + { + // 1. 读取库存 + var tenantId = tenantProvider.GetCurrentTenantId(); + var item = await inventoryRepository.GetForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken); + if (item is null) + { + throw new BusinessException(ErrorCodes.NotFound, "库存不存在"); + } + + // 1.1 幂等:若锁记录已扣减/释放则直接返回 + if (!string.IsNullOrWhiteSpace(request.IdempotencyKey)) + { + var lockRecord = await inventoryRepository.FindLockByKeyAsync(tenantId, request.IdempotencyKey, cancellationToken); + if (lockRecord is not null) + { + if (lockRecord.Status == Domain.Inventory.Enums.InventoryLockStatus.Deducted) + { + return InventoryMapping.ToDto(item); + } + + if (lockRecord.Status == Domain.Inventory.Enums.InventoryLockStatus.Locked) + { + request = request with { Quantity = lockRecord.Quantity, IsPresaleOrder = lockRecord.IsPresale }; + await inventoryRepository.MarkLockStatusAsync(lockRecord, Domain.Inventory.Enums.InventoryLockStatus.Deducted, cancellationToken); + } + } + } + + // 2. 计算扣减来源 + var isPresale = request.IsPresaleOrder || item.IsPresale; + if (isPresale) + { + if (item.PresaleLocked < request.Quantity) + { + throw new BusinessException(ErrorCodes.Conflict, "预售锁定不足,无法扣减"); + } + + item.PresaleLocked -= request.Quantity; + } + else + { + if (item.QuantityReserved < request.Quantity) + { + throw new BusinessException(ErrorCodes.Conflict, "锁定库存不足,无法扣减"); + } + + item.QuantityReserved -= request.Quantity; + } + + var remaining = item.QuantityOnHand - request.Quantity; + if (remaining < 0) + { + throw new BusinessException(ErrorCodes.Conflict, "可用库存不足,无法扣减"); + } + + // 3. 扣减可用量并按批次消耗 + item.QuantityOnHand = remaining; + // 3.1 批次扣减(非预售) + if (!isPresale) + { + var batches = await inventoryRepository.GetBatchesForConsumeAsync(tenantId, request.StoreId, request.ProductSkuId, item.BatchConsumeStrategy, cancellationToken); + var need = request.Quantity; + foreach (var batch in batches) + { + if (need <= 0) + { + break; + } + + var take = Math.Min(batch.RemainingQuantity, need); + batch.RemainingQuantity -= take; + need -= take; + await inventoryRepository.UpdateBatchAsync(batch, cancellationToken); + } + + if (need > 0) + { + throw new BusinessException(ErrorCodes.Conflict, "批次数量不足,无法扣减"); + } + } + + item.IsSoldOut = item.QuantityOnHand - item.QuantityReserved - item.PresaleLocked <= (item.SafetyStock ?? 0); + await inventoryRepository.UpdateItemAsync(item, cancellationToken); + await inventoryRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("扣减库存 门店 {StoreId} SKU {ProductSkuId} 数量 {Quantity}", request.StoreId, request.ProductSkuId, request.Quantity); + return InventoryMapping.ToDto(item); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/GetInventoryBatchesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/GetInventoryBatchesQueryHandler.cs new file mode 100644 index 0000000..3796fda --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/GetInventoryBatchesQueryHandler.cs @@ -0,0 +1,26 @@ +using MediatR; +using TakeoutSaaS.Application.App.Inventory.Dto; +using TakeoutSaaS.Application.App.Inventory.Queries; +using TakeoutSaaS.Domain.Inventory.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Inventory.Handlers; + +/// +/// 库存批次查询处理器。 +/// +public sealed class GetInventoryBatchesQueryHandler( + IInventoryRepository inventoryRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + /// + public async Task> Handle(GetInventoryBatchesQuery request, CancellationToken cancellationToken) + { + // 1. 读取批次 + var tenantId = tenantProvider.GetCurrentTenantId(); + var batches = await inventoryRepository.GetBatchesAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken); + // 2. 映射 + return batches.Select(InventoryMapping.ToDto).ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/GetInventoryItemQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/GetInventoryItemQueryHandler.cs new file mode 100644 index 0000000..f9a940c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/GetInventoryItemQueryHandler.cs @@ -0,0 +1,26 @@ +using MediatR; +using TakeoutSaaS.Application.App.Inventory.Dto; +using TakeoutSaaS.Application.App.Inventory.Queries; +using TakeoutSaaS.Domain.Inventory.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Inventory.Handlers; + +/// +/// 查询库存处理器。 +/// +public sealed class GetInventoryItemQueryHandler( + IInventoryRepository inventoryRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(GetInventoryItemQuery request, CancellationToken cancellationToken) + { + // 1. 读取库存 + var tenantId = tenantProvider.GetCurrentTenantId(); + var item = await inventoryRepository.FindBySkuAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken); + // 2. 返回 DTO + return item is null ? null : InventoryMapping.ToDto(item); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/LockInventoryCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/LockInventoryCommandHandler.cs new file mode 100644 index 0000000..42f69fa --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/LockInventoryCommandHandler.cs @@ -0,0 +1,92 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Inventory.Commands; +using TakeoutSaaS.Application.App.Inventory.Dto; +using TakeoutSaaS.Domain.Inventory.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Inventory.Handlers; + +/// +/// 库存锁定处理器。 +/// +public sealed class LockInventoryCommandHandler( + IInventoryRepository inventoryRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(LockInventoryCommand request, CancellationToken cancellationToken) + { + // 1. 读取库存 + var tenantId = tenantProvider.GetCurrentTenantId(); + var item = await inventoryRepository.GetForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken); + if (item is null) + { + throw new BusinessException(ErrorCodes.NotFound, "库存不存在"); + } + + // 1.1 幂等处理 + var existingLock = await inventoryRepository.FindLockByKeyAsync(tenantId, request.IdempotencyKey, cancellationToken); + if (existingLock is not null) + { + return InventoryMapping.ToDto(item); + } + + // 2. 校验可用量 + var now = DateTime.UtcNow; + var isPresale = request.IsPresaleOrder || item.IsPresale; + if (isPresale) + { + if (item.PresaleStartTime.HasValue && now < item.PresaleStartTime.Value) + { + throw new BusinessException(ErrorCodes.Conflict, "预售尚未开始"); + } + + if (item.PresaleEndTime.HasValue && now > item.PresaleEndTime.Value) + { + throw new BusinessException(ErrorCodes.Conflict, "预售已结束"); + } + } + + var available = isPresale + ? (item.PresaleCapacity ?? item.QuantityOnHand) - item.PresaleLocked + : item.QuantityOnHand - item.QuantityReserved; + if (available < request.Quantity) + { + throw new BusinessException(ErrorCodes.Conflict, "库存不足,无法锁定"); + } + + // 3. 执行锁定 + if (isPresale) + { + item.PresaleLocked += request.Quantity; + } + else + { + item.QuantityReserved += request.Quantity; + } + + item.IsSoldOut = item.QuantityOnHand - item.QuantityReserved - item.PresaleLocked <= (item.SafetyStock ?? 0); + await inventoryRepository.UpdateItemAsync(item, cancellationToken); + var lockRecord = new Domain.Inventory.Entities.InventoryLockRecord + { + TenantId = tenantId, + StoreId = request.StoreId, + ProductSkuId = request.ProductSkuId, + Quantity = request.Quantity, + IsPresale = isPresale, + IdempotencyKey = request.IdempotencyKey, + ExpiresAt = request.ExpiresAt, + Status = Domain.Inventory.Enums.InventoryLockStatus.Locked + }; + + await inventoryRepository.AddLockAsync(lockRecord, cancellationToken); + await inventoryRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("锁定库存 门店 {StoreId} SKU {ProductSkuId} 数量 {Quantity}", request.StoreId, request.ProductSkuId, request.Quantity); + return InventoryMapping.ToDto(item); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/ReleaseExpiredInventoryLocksCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/ReleaseExpiredInventoryLocksCommandHandler.cs new file mode 100644 index 0000000..0ef2884 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/ReleaseExpiredInventoryLocksCommandHandler.cs @@ -0,0 +1,60 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Inventory.Commands; +using TakeoutSaaS.Domain.Inventory.Enums; +using TakeoutSaaS.Domain.Inventory.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Inventory.Handlers; + +/// +/// 释放过期锁定处理器。 +/// +public sealed class ReleaseExpiredInventoryLocksCommandHandler( + IInventoryRepository inventoryRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(ReleaseExpiredInventoryLocksCommand request, CancellationToken cancellationToken) + { + // 1. 查询过期锁 + var tenantId = tenantProvider.GetCurrentTenantId(); + var now = DateTime.UtcNow; + var expiredLocks = await inventoryRepository.FindExpiredLocksAsync(tenantId, now, cancellationToken); + if (expiredLocks.Count == 0) + { + return 0; + } + + // 2. 释放锁对应库存 + var affected = 0; + foreach (var lockRecord in expiredLocks) + { + var item = await inventoryRepository.GetForUpdateAsync(tenantId, lockRecord.StoreId, lockRecord.ProductSkuId, cancellationToken); + if (item is null) + { + continue; + } + + if (lockRecord.IsPresale) + { + item.PresaleLocked = Math.Max(0, item.PresaleLocked - lockRecord.Quantity); + } + else + { + item.QuantityReserved = Math.Max(0, item.QuantityReserved - lockRecord.Quantity); + } + + item.IsSoldOut = item.QuantityOnHand - item.QuantityReserved - item.PresaleLocked <= (item.SafetyStock ?? 0); + await inventoryRepository.UpdateItemAsync(item, cancellationToken); + await inventoryRepository.MarkLockStatusAsync(lockRecord, InventoryLockStatus.Released, cancellationToken); + affected++; + } + + await inventoryRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("释放过期库存锁定 {Count} 条", affected); + return affected; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/ReleaseInventoryCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/ReleaseInventoryCommandHandler.cs new file mode 100644 index 0000000..b159471 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/ReleaseInventoryCommandHandler.cs @@ -0,0 +1,76 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Inventory.Commands; +using TakeoutSaaS.Application.App.Inventory.Dto; +using TakeoutSaaS.Domain.Inventory.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Inventory.Handlers; + +/// +/// 库存释放处理器。 +/// +public sealed class ReleaseInventoryCommandHandler( + IInventoryRepository inventoryRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(ReleaseInventoryCommand request, CancellationToken cancellationToken) + { + // 1. 读取库存 + var tenantId = tenantProvider.GetCurrentTenantId(); + var item = await inventoryRepository.GetForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken); + if (item is null) + { + throw new BusinessException(ErrorCodes.NotFound, "库存不存在"); + } + + // 1.1 幂等处理:若提供键且锁记录不存在,直接视为已释放 + if (!string.IsNullOrWhiteSpace(request.IdempotencyKey)) + { + var lockRecord = await inventoryRepository.FindLockByKeyAsync(tenantId, request.IdempotencyKey, cancellationToken); + if (lockRecord is not null) + { + if (lockRecord.Status != Domain.Inventory.Enums.InventoryLockStatus.Locked) + { + return InventoryMapping.ToDto(item); + } + + // 将数量同步为锁记录数,避免重复释放不一致 + request = request with { Quantity = lockRecord.Quantity }; + await inventoryRepository.MarkLockStatusAsync(lockRecord, Domain.Inventory.Enums.InventoryLockStatus.Released, cancellationToken); + } + } + + // 2. 计算释放 + var isPresale = request.IsPresaleOrder || item.IsPresale; + if (isPresale) + { + if (item.PresaleLocked < request.Quantity) + { + throw new BusinessException(ErrorCodes.Conflict, "预售锁定不足"); + } + + item.PresaleLocked -= request.Quantity; + } + else + { + if (item.QuantityReserved < request.Quantity) + { + throw new BusinessException(ErrorCodes.Conflict, "锁定库存不足"); + } + + item.QuantityReserved -= request.Quantity; + } + + item.IsSoldOut = item.QuantityOnHand - item.QuantityReserved - item.PresaleLocked <= (item.SafetyStock ?? 0); + await inventoryRepository.UpdateItemAsync(item, cancellationToken); + await inventoryRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("释放库存 门店 {StoreId} SKU {ProductSkuId} 数量 {Quantity}", request.StoreId, request.ProductSkuId, request.Quantity); + return InventoryMapping.ToDto(item); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/UpsertInventoryBatchCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/UpsertInventoryBatchCommandHandler.cs new file mode 100644 index 0000000..dea621d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/UpsertInventoryBatchCommandHandler.cs @@ -0,0 +1,57 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Inventory.Commands; +using TakeoutSaaS.Application.App.Inventory.Dto; +using TakeoutSaaS.Domain.Inventory.Entities; +using TakeoutSaaS.Domain.Inventory.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Inventory.Handlers; + +/// +/// 批次维护处理器。 +/// +public sealed class UpsertInventoryBatchCommandHandler( + IInventoryRepository inventoryRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(UpsertInventoryBatchCommand request, CancellationToken cancellationToken) + { + // 1. 读取批次 + var tenantId = tenantProvider.GetCurrentTenantId(); + var batch = await inventoryRepository.GetBatchForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, request.BatchNumber, cancellationToken); + // 2. 创建或更新 + if (batch is null) + { + batch = new InventoryBatch + { + TenantId = tenantId, + StoreId = request.StoreId, + ProductSkuId = request.ProductSkuId, + BatchNumber = request.BatchNumber, + ProductionDate = request.ProductionDate, + ExpireDate = request.ExpireDate, + Quantity = request.Quantity, + RemainingQuantity = request.RemainingQuantity + }; + await inventoryRepository.AddBatchAsync(batch, cancellationToken); + } + else + { + batch.ProductionDate = request.ProductionDate; + batch.ExpireDate = request.ExpireDate; + batch.Quantity = request.Quantity; + batch.RemainingQuantity = request.RemainingQuantity; + await inventoryRepository.UpdateBatchAsync(batch, cancellationToken); + } + + await inventoryRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("维护批次 门店 {StoreId} SKU {ProductSkuId} 批次 {BatchNumber}", request.StoreId, request.ProductSkuId, request.BatchNumber); + return InventoryMapping.ToDto(batch); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/InventoryMapping.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/InventoryMapping.cs new file mode 100644 index 0000000..b4c9cbd --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/InventoryMapping.cs @@ -0,0 +1,49 @@ +using TakeoutSaaS.Application.App.Inventory.Dto; +using TakeoutSaaS.Domain.Inventory.Entities; + +namespace TakeoutSaaS.Application.App.Inventory; + +/// +/// 库存映射辅助。 +/// +public static class InventoryMapping +{ + /// + /// 映射库存 DTO。 + /// + public static InventoryItemDto ToDto(InventoryItem item) => new() + { + Id = item.Id, + TenantId = item.TenantId, + StoreId = item.StoreId, + ProductSkuId = item.ProductSkuId, + BatchNumber = item.BatchNumber, + QuantityOnHand = item.QuantityOnHand, + QuantityReserved = item.QuantityReserved, + SafetyStock = item.SafetyStock, + Location = item.Location, + ExpireDate = item.ExpireDate, + IsPresale = item.IsPresale, + PresaleStartTime = item.PresaleStartTime, + PresaleEndTime = item.PresaleEndTime, + PresaleCapacity = item.PresaleCapacity, + PresaleLocked = item.PresaleLocked, + MaxQuantityPerOrder = item.MaxQuantityPerOrder, + IsSoldOut = item.IsSoldOut + }; + + /// + /// 映射批次 DTO。 + /// + public static InventoryBatchDto ToDto(InventoryBatch batch) => new() + { + Id = batch.Id, + StoreId = batch.StoreId, + ProductSkuId = batch.ProductSkuId, + BatchNumber = batch.BatchNumber, + ProductionDate = batch.ProductionDate, + ExpireDate = batch.ExpireDate, + Quantity = batch.Quantity, + RemainingQuantity = batch.RemainingQuantity + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Queries/GetInventoryBatchesQuery.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Queries/GetInventoryBatchesQuery.cs new file mode 100644 index 0000000..c95f4cc --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Queries/GetInventoryBatchesQuery.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Inventory.Dto; + +namespace TakeoutSaaS.Application.App.Inventory.Queries; + +/// +/// 查询库存批次列表。 +/// +public sealed record GetInventoryBatchesQuery : IRequest> +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// SKU ID。 + /// + public long ProductSkuId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Queries/GetInventoryItemQuery.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Queries/GetInventoryItemQuery.cs new file mode 100644 index 0000000..446b59a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Queries/GetInventoryItemQuery.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Inventory.Dto; + +namespace TakeoutSaaS.Application.App.Inventory.Queries; + +/// +/// 按门店与 SKU 查询库存。 +/// +public sealed record GetInventoryItemQuery : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// SKU ID。 + /// + public long ProductSkuId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/AdjustInventoryCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/AdjustInventoryCommandValidator.cs new file mode 100644 index 0000000..e74ea14 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/AdjustInventoryCommandValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Inventory.Commands; + +namespace TakeoutSaaS.Application.App.Inventory.Validators; + +/// +/// 库存调整命令验证器。 +/// +public sealed class AdjustInventoryCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public AdjustInventoryCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.ProductSkuId).GreaterThan(0); + RuleFor(x => x.QuantityDelta).NotEqual(0); + RuleFor(x => x.SafetyStock).GreaterThanOrEqualTo(0).When(x => x.SafetyStock.HasValue); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/DeductInventoryCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/DeductInventoryCommandValidator.cs new file mode 100644 index 0000000..53eba84 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/DeductInventoryCommandValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Inventory.Commands; + +namespace TakeoutSaaS.Application.App.Inventory.Validators; + +/// +/// 扣减库存命令验证器。 +/// +public sealed class DeductInventoryCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public DeductInventoryCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.ProductSkuId).GreaterThan(0); + RuleFor(x => x.Quantity).GreaterThan(0); + RuleFor(x => x.IdempotencyKey).MaximumLength(128); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/LockInventoryCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/LockInventoryCommandValidator.cs new file mode 100644 index 0000000..38aa7ec --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/LockInventoryCommandValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Inventory.Commands; + +namespace TakeoutSaaS.Application.App.Inventory.Validators; + +/// +/// 库存锁定命令验证器。 +/// +public sealed class LockInventoryCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public LockInventoryCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.ProductSkuId).GreaterThan(0); + RuleFor(x => x.Quantity).GreaterThan(0); + RuleFor(x => x.IdempotencyKey).NotEmpty().MaximumLength(128); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/ReleaseInventoryCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/ReleaseInventoryCommandValidator.cs new file mode 100644 index 0000000..ed0b8dc --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/ReleaseInventoryCommandValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Inventory.Commands; + +namespace TakeoutSaaS.Application.App.Inventory.Validators; + +/// +/// 释放库存命令验证器。 +/// +public sealed class ReleaseInventoryCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public ReleaseInventoryCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.ProductSkuId).GreaterThan(0); + RuleFor(x => x.Quantity).GreaterThan(0); + RuleFor(x => x.IdempotencyKey).MaximumLength(128); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/UpsertInventoryBatchCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/UpsertInventoryBatchCommandValidator.cs new file mode 100644 index 0000000..abcb278 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/UpsertInventoryBatchCommandValidator.cs @@ -0,0 +1,22 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Inventory.Commands; + +namespace TakeoutSaaS.Application.App.Inventory.Validators; + +/// +/// 批次维护命令验证器。 +/// +public sealed class UpsertInventoryBatchCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public UpsertInventoryBatchCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.ProductSkuId).GreaterThan(0); + RuleFor(x => x.BatchNumber).NotEmpty().MaximumLength(64); + RuleFor(x => x.Quantity).GreaterThan(0); + RuleFor(x => x.RemainingQuantity).GreaterThanOrEqualTo(0); + } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryBatch.cs b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryBatch.cs index eee47f4..7f7b12f 100644 --- a/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryBatch.cs +++ b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryBatch.cs @@ -1,3 +1,5 @@ +using System; +using System.ComponentModel.DataAnnotations; using TakeoutSaaS.Shared.Abstractions.Entities; namespace TakeoutSaaS.Domain.Inventory.Entities; @@ -41,4 +43,10 @@ public sealed class InventoryBatch : MultiTenantEntityBase /// 剩余数量。 /// public int RemainingQuantity { get; set; } + + /// + /// 并发控制字段。 + /// + [Timestamp] + public byte[] RowVersion { get; set; } = Array.Empty(); } diff --git a/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryItem.cs b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryItem.cs index 6aca234..32390c0 100644 --- a/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryItem.cs +++ b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryItem.cs @@ -1,4 +1,7 @@ +using System; +using System.ComponentModel.DataAnnotations; using TakeoutSaaS.Shared.Abstractions.Entities; +using TakeoutSaaS.Domain.Inventory.Enums; namespace TakeoutSaaS.Domain.Inventory.Entities; @@ -46,4 +49,50 @@ public sealed class InventoryItem : MultiTenantEntityBase /// 过期日期。 /// public DateTime? ExpireDate { get; set; } + + /// + /// 是否预售商品。 + /// + public bool IsPresale { get; set; } + + /// + /// 预售开始时间(UTC)。 + /// + public DateTime? PresaleStartTime { get; set; } + + /// + /// 预售结束时间(UTC)。 + /// + public DateTime? PresaleEndTime { get; set; } + + /// + /// 预售名额(上限)。 + /// + public int? PresaleCapacity { get; set; } + + /// + /// 当前预售已锁定数量。 + /// + public int PresaleLocked { get; set; } + + /// + /// 单品限购(覆盖商品级 MaxQuantityPerOrder)。 + /// + public int? MaxQuantityPerOrder { get; set; } + + /// + /// 是否标记售罄。 + /// + public bool IsSoldOut { get; set; } + + /// + /// 批次扣减策略。 + /// + public InventoryBatchConsumeStrategy BatchConsumeStrategy { get; set; } = InventoryBatchConsumeStrategy.Fifo; + + /// + /// 并发控制字段。 + /// + [Timestamp] + public byte[] RowVersion { get; set; } = Array.Empty(); } diff --git a/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryLockRecord.cs b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryLockRecord.cs new file mode 100644 index 0000000..89e6304 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryLockRecord.cs @@ -0,0 +1,52 @@ +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Domain.Inventory.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Inventory.Entities; + +/// +/// 库存锁定记录。 +/// +public sealed class InventoryLockRecord : MultiTenantEntityBase +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; set; } + + /// + /// SKU ID。 + /// + public long ProductSkuId { get; set; } + + /// + /// 锁定数量。 + /// + public int Quantity { get; set; } + + /// + /// 是否预售锁定。 + /// + public bool IsPresale { get; set; } + + /// + /// 幂等键。 + /// + public string IdempotencyKey { get; set; } = string.Empty; + + /// + /// 过期时间(UTC)。 + /// + public DateTime? ExpiresAt { get; set; } + + /// + /// 锁定状态。 + /// + public InventoryLockStatus Status { get; set; } = InventoryLockStatus.Locked; + + /// + /// 并发控制字段。 + /// + [Timestamp] + public byte[] RowVersion { get; set; } = Array.Empty(); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Inventory/Enums/InventoryBatchConsumeStrategy.cs b/src/Domain/TakeoutSaaS.Domain/Inventory/Enums/InventoryBatchConsumeStrategy.cs new file mode 100644 index 0000000..8ee0cef --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Inventory/Enums/InventoryBatchConsumeStrategy.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Domain.Inventory.Enums; + +/// +/// 批次扣减策略。 +/// +public enum InventoryBatchConsumeStrategy +{ + /// + /// 先进先出。 + /// + Fifo = 0, + + /// + /// 先到期先出。 + /// + Fefo = 1 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Inventory/Enums/InventoryLockStatus.cs b/src/Domain/TakeoutSaaS.Domain/Inventory/Enums/InventoryLockStatus.cs new file mode 100644 index 0000000..a2be4a6 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Inventory/Enums/InventoryLockStatus.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Domain.Inventory.Enums; + +/// +/// 库存锁定状态。 +/// +public enum InventoryLockStatus +{ + /// + /// 已锁定。 + /// + Locked = 0, + + /// + /// 已释放。 + /// + Released = 1, + + /// + /// 已扣减。 + /// + Deducted = 2 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Inventory/Repositories/IInventoryRepository.cs b/src/Domain/TakeoutSaaS.Domain/Inventory/Repositories/IInventoryRepository.cs new file mode 100644 index 0000000..bba4734 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Inventory/Repositories/IInventoryRepository.cs @@ -0,0 +1,95 @@ +using System; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Inventory.Entities; +using TakeoutSaaS.Domain.Inventory.Enums; + +namespace TakeoutSaaS.Domain.Inventory.Repositories; + +/// +/// 库存仓储契约。 +/// +public interface IInventoryRepository +{ + /// + /// 依据标识查询库存。 + /// + Task FindByIdAsync(long inventoryItemId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 按门店与 SKU 查询库存(只读)。 + /// + Task FindBySkuAsync(long tenantId, long storeId, long productSkuId, CancellationToken cancellationToken = default); + + /// + /// 按门店与 SKU 查询库存(跟踪用于更新)。 + /// + Task GetForUpdateAsync(long tenantId, long storeId, long productSkuId, CancellationToken cancellationToken = default); + + /// + /// 新增库存记录。 + /// + Task AddItemAsync(InventoryItem item, CancellationToken cancellationToken = default); + + /// + /// 更新库存记录。 + /// + Task UpdateItemAsync(InventoryItem item, CancellationToken cancellationToken = default); + + /// + /// 新增库存调整记录。 + /// + Task AddAdjustmentAsync(InventoryAdjustment adjustment, CancellationToken cancellationToken = default); + + /// + /// 新增锁定记录。 + /// + Task AddLockAsync(InventoryLockRecord lockRecord, CancellationToken cancellationToken = default); + + /// + /// 按幂等键查询锁记录。 + /// + Task FindLockByKeyAsync(long tenantId, string idempotencyKey, CancellationToken cancellationToken = default); + + /// + /// 更新锁状态。 + /// + Task MarkLockStatusAsync(InventoryLockRecord lockRecord, InventoryLockStatus status, CancellationToken cancellationToken = default); + + /// + /// 查询过期锁定。 + /// + Task> FindExpiredLocksAsync(long tenantId, DateTime utcNow, CancellationToken cancellationToken = default); + + /// + /// 查询批次列表。 + /// + Task> GetBatchesAsync(long tenantId, long storeId, long productSkuId, CancellationToken cancellationToken = default); + + /// + /// 批次扣减读取(带排序策略)。 + /// + Task> GetBatchesForConsumeAsync(long tenantId, long storeId, long productSkuId, InventoryBatchConsumeStrategy strategy, CancellationToken cancellationToken = default); + + /// + /// 查询批次(跟踪用于更新)。 + /// + Task GetBatchForUpdateAsync(long tenantId, long storeId, long productSkuId, string batchNumber, CancellationToken cancellationToken = default); + + /// + /// 新增批次。 + /// + Task AddBatchAsync(InventoryBatch batch, CancellationToken cancellationToken = default); + + /// + /// 更新批次。 + /// + Task UpdateBatchAsync(InventoryBatch batch, 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 ed6f4eb..7333909 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using TakeoutSaaS.Domain.Deliveries.Repositories; +using TakeoutSaaS.Domain.Inventory.Repositories; using TakeoutSaaS.Domain.Merchants.Repositories; using TakeoutSaaS.Domain.Orders.Repositories; using TakeoutSaaS.Domain.Payments.Repositories; @@ -45,6 +46,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 f2f6177..1630554 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs @@ -76,6 +76,7 @@ public sealed class TakeoutAppDbContext( public DbSet InventoryItems => Set(); public DbSet InventoryAdjustments => Set(); public DbSet InventoryBatches => Set(); + public DbSet InventoryLockRecords => Set(); public DbSet ShoppingCarts => Set(); public DbSet CartItems => Set(); @@ -170,6 +171,7 @@ public sealed class TakeoutAppDbContext( ConfigureInventoryItem(modelBuilder.Entity()); ConfigureInventoryAdjustment(modelBuilder.Entity()); ConfigureInventoryBatch(modelBuilder.Entity()); + ConfigureInventoryLockRecord(modelBuilder.Entity()); ConfigureShoppingCart(modelBuilder.Entity()); ConfigureCartItem(modelBuilder.Entity()); ConfigureCartItemAddon(modelBuilder.Entity()); @@ -703,6 +705,7 @@ public sealed class TakeoutAppDbContext( builder.Property(x => x.ProductSkuId).IsRequired(); builder.Property(x => x.BatchNumber).HasMaxLength(64); builder.Property(x => x.Location).HasMaxLength(64); + builder.Property(x => x.RowVersion).IsRowVersion(); builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ProductSkuId, x.BatchNumber }); } @@ -723,9 +726,24 @@ public sealed class TakeoutAppDbContext( builder.Property(x => x.StoreId).IsRequired(); builder.Property(x => x.ProductSkuId).IsRequired(); builder.Property(x => x.BatchNumber).HasMaxLength(64).IsRequired(); + builder.Property(x => x.RowVersion).IsRowVersion(); builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ProductSkuId, x.BatchNumber }).IsUnique(); } + private static void ConfigureInventoryLockRecord(EntityTypeBuilder builder) + { + builder.ToTable("inventory_lock_records"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.ProductSkuId).IsRequired(); + builder.Property(x => x.Quantity).IsRequired(); + builder.Property(x => x.IdempotencyKey).HasMaxLength(128).IsRequired(); + builder.Property(x => x.Status).HasConversion(); + builder.Property(x => x.RowVersion).IsRowVersion(); + builder.HasIndex(x => new { x.TenantId, x.IdempotencyKey }).IsUnique(); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ProductSkuId, x.Status }); + } + private static void ConfigureShoppingCart(EntityTypeBuilder builder) { builder.ToTable("shopping_carts"); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfInventoryRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfInventoryRepository.cs new file mode 100644 index 0000000..0cc6526 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfInventoryRepository.cs @@ -0,0 +1,145 @@ +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Inventory.Entities; +using TakeoutSaaS.Domain.Inventory.Enums; +using TakeoutSaaS.Domain.Inventory.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// 库存仓储 EF 实现。 +/// +/// +/// 提供库存与批次的读写能力。 +/// +public sealed class EfInventoryRepository(TakeoutAppDbContext context) : IInventoryRepository +{ + /// + public Task FindByIdAsync(long inventoryItemId, long tenantId, CancellationToken cancellationToken = default) + { + return context.InventoryItems + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.Id == inventoryItemId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task FindBySkuAsync(long tenantId, long storeId, long productSkuId, CancellationToken cancellationToken = default) + { + return context.InventoryItems + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.StoreId == storeId && x.ProductSkuId == productSkuId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task GetForUpdateAsync(long tenantId, long storeId, long productSkuId, CancellationToken cancellationToken = default) + { + return context.InventoryItems + .Where(x => x.TenantId == tenantId && x.StoreId == storeId && x.ProductSkuId == productSkuId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task AddItemAsync(InventoryItem item, CancellationToken cancellationToken = default) + { + return context.InventoryItems.AddAsync(item, cancellationToken).AsTask(); + } + + /// + public Task UpdateItemAsync(InventoryItem item, CancellationToken cancellationToken = default) + { + context.InventoryItems.Update(item); + return Task.CompletedTask; + } + + /// + public Task AddAdjustmentAsync(InventoryAdjustment adjustment, CancellationToken cancellationToken = default) + { + return context.InventoryAdjustments.AddAsync(adjustment, cancellationToken).AsTask(); + } + + /// + public Task AddLockAsync(InventoryLockRecord lockRecord, CancellationToken cancellationToken = default) + { + return context.InventoryLockRecords.AddAsync(lockRecord, cancellationToken).AsTask(); + } + + /// + public Task FindLockByKeyAsync(long tenantId, string idempotencyKey, CancellationToken cancellationToken = default) + { + return context.InventoryLockRecords + .Where(x => x.TenantId == tenantId && x.IdempotencyKey == idempotencyKey) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task MarkLockStatusAsync(InventoryLockRecord lockRecord, InventoryLockStatus status, CancellationToken cancellationToken = default) + { + lockRecord.Status = status; + context.InventoryLockRecords.Update(lockRecord); + return Task.CompletedTask; + } + + /// + public async Task> FindExpiredLocksAsync(long tenantId, DateTime utcNow, CancellationToken cancellationToken = default) + { + var locks = await context.InventoryLockRecords + .Where(x => x.TenantId == tenantId && x.Status == InventoryLockStatus.Locked && x.ExpiresAt != null && x.ExpiresAt <= utcNow) + .ToListAsync(cancellationToken); + return locks; + } + + /// + public async Task> GetBatchesForConsumeAsync(long tenantId, long storeId, long productSkuId, InventoryBatchConsumeStrategy strategy, CancellationToken cancellationToken = default) + { + var query = context.InventoryBatches + .Where(x => x.TenantId == tenantId && x.StoreId == storeId && x.ProductSkuId == productSkuId); + + query = strategy == InventoryBatchConsumeStrategy.Fefo + ? query.OrderBy(x => x.ExpireDate ?? DateTime.MaxValue).ThenBy(x => x.BatchNumber) + : query.OrderBy(x => x.BatchNumber); + + return await query.ToListAsync(cancellationToken); + } + + /// + public async Task> GetBatchesAsync(long tenantId, long storeId, long productSkuId, CancellationToken cancellationToken = default) + { + var batches = await context.InventoryBatches + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.StoreId == storeId && x.ProductSkuId == productSkuId) + .OrderBy(x => x.ExpireDate ?? DateTime.MaxValue) + .ThenBy(x => x.BatchNumber) + .ToListAsync(cancellationToken); + + return batches; + } + + /// + public Task GetBatchForUpdateAsync(long tenantId, long storeId, long productSkuId, string batchNumber, CancellationToken cancellationToken = default) + { + return context.InventoryBatches + .Where(x => x.TenantId == tenantId && x.StoreId == storeId && x.ProductSkuId == productSkuId && x.BatchNumber == batchNumber) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task AddBatchAsync(InventoryBatch batch, CancellationToken cancellationToken = default) + { + return context.InventoryBatches.AddAsync(batch, cancellationToken).AsTask(); + } + + /// + public Task UpdateBatchAsync(InventoryBatch batch, CancellationToken cancellationToken = default) + { + context.InventoryBatches.Update(batch); + return Task.CompletedTask; + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +} From 2022d1c3776e5cb83ac85c586d18640b937daac1 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Thu, 4 Dec 2025 11:32:47 +0800 Subject: [PATCH 25/30] =?UTF-8?q?docs:=20=E6=A0=87=E8=AE=B0=E5=BA=93?= =?UTF-8?q?=E5=AD=98=E4=BD=93=E7=B3=BB=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Document/12_BusinessTodo.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Document/12_BusinessTodo.md b/Document/12_BusinessTodo.md index ae522a1..13a4e25 100644 --- a/Document/12_BusinessTodo.md +++ b/Document/12_BusinessTodo.md @@ -24,8 +24,8 @@ - 已交付:桌码上下文查询 DTO/验证/处理器完成,可按桌码返回门店名称/公告/标签与桌台信息;MiniApi 新增 `TablesController` `/context` 端点,仓储支持按桌码查询。 - [x] 菜品建模:分类、SPU、SKU、规格/加料组、价格策略、媒资 CRUD + 上下架流程;Mini 端可拉取完整 JSON。 - 已交付:Admin 侧补齐 SKU/规格/加料/媒资/定价替换命令、验证与端点,并新增上/下架接口与全量详情;权限种子补充 `product:publish` 与子资源读写。Mini 侧新增门店菜单接口,按门店返回分类 + 商品全量 JSON(含 SKU/规格/加料/媒资/定价),支持 `updatedAfter` 增量。 -- [ ] 库存体系:SKU 库存、批次、调整、售罄管理,支持预售/档期锁定并在订单中扣减/释放。 - - 当前:存在 `InventoryItem/InventoryBatch/InventoryAdjustment` 领域模型与 DbSet,但未提供库存调整/锁定命令、与订单扣减/释放或预售档期锁定的应用层逻辑与 API。 +- [x] 库存体系:SKU 库存、批次、调整、售罄管理,支持预售/档期锁定并在订单中扣减/释放。 + - 已交付:库存模型补充预售/限购/并发字段与批次策略(FIFO/FEFO),新增锁定记录与幂等、过期释放;应用层提供调整/锁定/释放/扣减/批次维护命令与查询,Admin API 暴露库存与批次端点及权限种子。需后续生成迁移落库,并可按需将过期释放接入定时任务。 - [ ] 自提档期:门店配置自提时间窗、容量、截单时间;Mini 端据此限制下单时间。 - 当前:仅有门店/商品的自提开关字段(`SupportsPickup`/`EnablePickup`),未实现自提时间窗、容量、截单配置及 Mini 端下单限制。 - [ ] 购物车服务:ShoppingCart/CartItem/CartItemAddon API 支持并发锁、限购、券/积分预校验,保证并发无脏数据。 From 7d6b7d87604b75b4349e94c67ba4390796ad1f36 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Thu, 4 Dec 2025 11:51:09 +0800 Subject: [PATCH 26/30] =?UTF-8?q?docs:=20=E6=A0=87=E8=AE=B0=E8=87=AA?= =?UTF-8?q?=E6=8F=90=E6=A1=A3=E6=9C=9F=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Document/12_BusinessTodo.md | 4 +- .../Controllers/StorePickupController.cs | 114 ++++++++++++++++++ .../appsettings.Seed.Development.json | 12 ++ .../Controllers/PickupSlotsController.cs | 31 +++++ .../Commands/CreateStorePickupSlotCommand.cs | 50 ++++++++ .../Commands/DeleteStorePickupSlotCommand.cs | 19 +++ .../Commands/UpdateStorePickupSlotCommand.cs | 55 +++++++++ .../UpsertStorePickupSettingCommand.cs | 35 ++++++ .../App/Stores/Dto/StorePickupSettingDto.cs | 42 +++++++ .../App/Stores/Dto/StorePickupSlotDto.cs | 62 ++++++++++ .../CreateStorePickupSlotCommandHandler.cs | 64 ++++++++++ .../DeleteStorePickupSlotCommandHandler.cs | 28 +++++ .../GetAvailablePickupSlotsQueryHandler.cs | 83 +++++++++++++ .../GetStorePickupSettingQueryHandler.cs | 37 ++++++ .../ListStorePickupSlotsQueryHandler.cs | 38 ++++++ .../UpdateStorePickupSlotCommandHandler.cs | 57 +++++++++ .../UpsertStorePickupSettingCommandHandler.cs | 63 ++++++++++ .../Queries/GetAvailablePickupSlotsQuery.cs | 21 ++++ .../Queries/GetStorePickupSettingQuery.cs | 15 +++ .../Queries/ListStorePickupSlotsQuery.cs | 15 +++ .../CreateStorePickupSlotCommandValidator.cs | 23 ++++ .../DeleteStorePickupSlotCommandValidator.cs | 19 +++ .../UpdateStorePickupSlotCommandValidator.cs | 24 ++++ ...psertStorePickupSettingCommandValidator.cs | 21 ++++ .../Stores/Entities/StorePickupSetting.cs | 41 +++++++ .../Stores/Entities/StorePickupSlot.cs | 61 ++++++++++ .../Stores/Repositories/IStoreRepository.cs | 40 ++++++ .../App/Persistence/TakeoutAppDbContext.cs | 26 ++++ .../App/Repositories/EfStoreRepository.cs | 67 ++++++++++ 29 files changed, 1165 insertions(+), 2 deletions(-) create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/StorePickupController.cs create mode 100644 src/Api/TakeoutSaaS.MiniApi/Controllers/PickupSlotsController.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStorePickupSlotCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStorePickupSlotCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStorePickupSlotCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpsertStorePickupSettingCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Dto/StorePickupSettingDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Dto/StorePickupSlotDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStorePickupSlotCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStorePickupSlotCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetAvailablePickupSlotsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStorePickupSettingQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStorePickupSlotsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStorePickupSlotCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpsertStorePickupSettingCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetAvailablePickupSlotsQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetStorePickupSettingQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStorePickupSlotsQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStorePickupSlotCommandValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Validators/DeleteStorePickupSlotCommandValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStorePickupSlotCommandValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpsertStorePickupSettingCommandValidator.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Stores/Entities/StorePickupSetting.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Stores/Entities/StorePickupSlot.cs diff --git a/Document/12_BusinessTodo.md b/Document/12_BusinessTodo.md index 13a4e25..00d98dd 100644 --- a/Document/12_BusinessTodo.md +++ b/Document/12_BusinessTodo.md @@ -26,8 +26,8 @@ - 已交付:Admin 侧补齐 SKU/规格/加料/媒资/定价替换命令、验证与端点,并新增上/下架接口与全量详情;权限种子补充 `product:publish` 与子资源读写。Mini 侧新增门店菜单接口,按门店返回分类 + 商品全量 JSON(含 SKU/规格/加料/媒资/定价),支持 `updatedAfter` 增量。 - [x] 库存体系:SKU 库存、批次、调整、售罄管理,支持预售/档期锁定并在订单中扣减/释放。 - 已交付:库存模型补充预售/限购/并发字段与批次策略(FIFO/FEFO),新增锁定记录与幂等、过期释放;应用层提供调整/锁定/释放/扣减/批次维护命令与查询,Admin API 暴露库存与批次端点及权限种子。需后续生成迁移落库,并可按需将过期释放接入定时任务。 -- [ ] 自提档期:门店配置自提时间窗、容量、截单时间;Mini 端据此限制下单时间。 - - 当前:仅有门店/商品的自提开关字段(`SupportsPickup`/`EnablePickup`),未实现自提时间窗、容量、截单配置及 Mini 端下单限制。 +- [x] 自提档期:门店配置自提时间窗、容量、截单时间;Mini 端据此限制下单时间。 + - 已交付:新增自提设置与档期实体/表、并发控制,Admin 端提供自提配置与档期 CRUD 权限/接口;Mini 端提供按日期查询可用档期,包含截单与容量校验。下单限制待后续与订单流程联调。 - [ ] 购物车服务:ShoppingCart/CartItem/CartItemAddon API 支持并发锁、限购、券/积分预校验,保证并发无脏数据。 - 当前:领域层与表结构已有 `ShoppingCart/CartItem/CartItemAddon`,但缺少 CQRS 命令/查询、并发锁/限购/券积分预校验以及任何 Admin/Mini 端接口。 - [ ] 订单与支付:堂食/自提/配送下单、微信/支付宝支付、优惠券/积分抵扣、订单状态机与通知链路齐全。 diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/StorePickupController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/StorePickupController.cs new file mode 100644 index 0000000..f07addd --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/StorePickupController.cs @@ -0,0 +1,114 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.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}/stores/{storeId:long}/pickup")] +public sealed class StorePickupController(IMediator mediator) : BaseApiController +{ + /// + /// 获取自提配置。 + /// + [HttpGet("settings")] + [PermissionAuthorize("pickup-setting:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> GetSetting(long storeId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetStorePickupSettingQuery { StoreId = storeId }, cancellationToken); + return result is null + ? ApiResponse.Error(ErrorCodes.NotFound, "未配置自提设置") + : ApiResponse.Ok(result); + } + + /// + /// 更新自提配置。 + /// + [HttpPut("settings")] + [PermissionAuthorize("pickup-setting:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> UpsertSetting(long storeId, [FromBody] UpsertStorePickupSettingCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 查询档期列表。 + /// + [HttpGet("slots")] + [PermissionAuthorize("pickup-slot:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> ListSlots(long storeId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new ListStorePickupSlotsQuery { StoreId = storeId }, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 创建档期。 + /// + [HttpPost("slots")] + [PermissionAuthorize("pickup-slot:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CreateSlot(long storeId, [FromBody] CreateStorePickupSlotCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 更新档期。 + /// + [HttpPut("slots/{slotId:long}")] + [PermissionAuthorize("pickup-slot:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> UpdateSlot(long storeId, long slotId, [FromBody] UpdateStorePickupSlotCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0 || command.SlotId == 0) + { + command = command with { StoreId = storeId, SlotId = slotId }; + } + + var result = await mediator.Send(command, cancellationToken); + return result is null + ? ApiResponse.Error(ErrorCodes.NotFound, "档期不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除档期。 + /// + [HttpDelete("slots/{slotId:long}")] + [PermissionAuthorize("pickup-slot:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> DeleteSlot(long storeId, long slotId, CancellationToken cancellationToken) + { + var success = await mediator.Send(new DeleteStorePickupSlotCommand { StoreId = storeId, SlotId = slotId }, cancellationToken); + return success ? ApiResponse.Ok(null) : ApiResponse.Error(ErrorCodes.NotFound, "档期不存在"); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json index 4c483b1..5e78e4f 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json @@ -290,6 +290,12 @@ "inventory:batch:read", "inventory:batch:update", "inventory:lock:expire", + "pickup-setting:read", + "pickup-setting:update", + "pickup-slot:read", + "pickup-slot:create", + "pickup-slot:update", + "pickup-slot:delete", "order:create", "order:read", "order:update", @@ -398,6 +404,12 @@ "inventory:batch:read", "inventory:batch:update", "inventory:lock:expire", + "pickup-setting:read", + "pickup-setting:update", + "pickup-slot:read", + "pickup-slot:create", + "pickup-slot:update", + "pickup-slot:delete", "order:create", "order:read", "order:update", diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/PickupSlotsController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/PickupSlotsController.cs new file mode 100644 index 0000000..66ce98d --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/PickupSlotsController.cs @@ -0,0 +1,31 @@ +using System; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.MiniApi.Controllers; + +/// +/// 小程序端自提档期查询。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/mini/v{version:apiVersion}/stores/{storeId:long}/pickup-slots")] +public sealed class PickupSlotsController(IMediator mediator) : BaseApiController +{ + /// + /// 获取指定日期可用档期。 + /// + [HttpGet] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> GetSlots(long storeId, [FromQuery] DateTime date, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetAvailablePickupSlotsQuery { StoreId = storeId, Date = date }, cancellationToken); + return ApiResponse>.Ok(result); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStorePickupSlotCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStorePickupSlotCommand.cs new file mode 100644 index 0000000..0de3fa9 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStorePickupSlotCommand.cs @@ -0,0 +1,50 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 创建自提档期命令。 +/// +public sealed record CreateStorePickupSlotCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 开始时间。 + /// + public TimeSpan StartTime { get; init; } + + /// + /// 结束时间。 + /// + public TimeSpan EndTime { get; init; } + + /// + /// 截单分钟。 + /// + public int CutoffMinutes { get; init; } = 30; + + /// + /// 容量。 + /// + public int Capacity { get; init; } + + /// + /// 适用星期。 + /// + public string Weekdays { get; init; } = string.Empty; + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStorePickupSlotCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStorePickupSlotCommand.cs new file mode 100644 index 0000000..e1a80f5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStorePickupSlotCommand.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 删除自提档期命令。 +/// +public sealed record DeleteStorePickupSlotCommand : IRequest +{ + /// + /// 档期 ID。 + /// + public long SlotId { get; init; } + + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStorePickupSlotCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStorePickupSlotCommand.cs new file mode 100644 index 0000000..9505407 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStorePickupSlotCommand.cs @@ -0,0 +1,55 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 更新自提档期命令。 +/// +public sealed record UpdateStorePickupSlotCommand : IRequest +{ + /// + /// 档期 ID。 + /// + public long SlotId { get; init; } + + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 开始时间。 + /// + public TimeSpan StartTime { get; init; } + + /// + /// 结束时间。 + /// + public TimeSpan EndTime { get; init; } + + /// + /// 截单分钟。 + /// + public int CutoffMinutes { get; init; } + + /// + /// 容量。 + /// + public int Capacity { get; init; } + + /// + /// 适用星期。 + /// + public string Weekdays { get; init; } = string.Empty; + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpsertStorePickupSettingCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpsertStorePickupSettingCommand.cs new file mode 100644 index 0000000..4822b8f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpsertStorePickupSettingCommand.cs @@ -0,0 +1,35 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 新增或更新自提配置命令。 +/// +public sealed record UpsertStorePickupSettingCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 是否允许当天。 + /// + public bool AllowToday { get; init; } = true; + + /// + /// 可预约天数。 + /// + public int AllowDaysAhead { get; init; } = 3; + + /// + /// 默认截单分钟。 + /// + public int DefaultCutoffMinutes { get; init; } = 30; + + /// + /// 单笔最大份数。 + /// + public int? MaxQuantityPerOrder { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StorePickupSettingDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StorePickupSettingDto.cs new file mode 100644 index 0000000..5b071cd --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StorePickupSettingDto.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 自提配置 DTO。 +/// +public sealed record StorePickupSettingDto +{ + /// + /// 配置 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 是否允许当天自提。 + /// + public bool AllowToday { get; init; } + + /// + /// 可预约天数。 + /// + public int AllowDaysAhead { get; init; } + + /// + /// 默认截单分钟。 + /// + public int DefaultCutoffMinutes { get; init; } + + /// + /// 单笔最大自提份数。 + /// + public int? MaxQuantityPerOrder { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StorePickupSlotDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StorePickupSlotDto.cs new file mode 100644 index 0000000..5dc36ce --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StorePickupSlotDto.cs @@ -0,0 +1,62 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 自提档期 DTO。 +/// +public sealed record StorePickupSlotDto +{ + /// + /// 档期 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 开始时间。 + /// + public TimeSpan StartTime { get; init; } + + /// + /// 结束时间。 + /// + public TimeSpan EndTime { get; init; } + + /// + /// 截单分钟。 + /// + public int CutoffMinutes { get; init; } + + /// + /// 容量。 + /// + public int Capacity { get; init; } + + /// + /// 已占用。 + /// + public int ReservedCount { get; init; } + + /// + /// 适用星期(1-7)。 + /// + public string Weekdays { get; init; } = string.Empty; + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStorePickupSlotCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStorePickupSlotCommandHandler.cs new file mode 100644 index 0000000..9454424 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStorePickupSlotCommandHandler.cs @@ -0,0 +1,64 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 创建自提档期处理器。 +/// +public sealed class CreateStorePickupSlotCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(CreateStorePickupSlotCommand request, CancellationToken cancellationToken) + { + // 1. 校验门店 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 2. 新建档期 + var slot = new StorePickupSlot + { + TenantId = tenantId, + StoreId = request.StoreId, + Name = request.Name.Trim(), + StartTime = request.StartTime, + EndTime = request.EndTime, + CutoffMinutes = request.CutoffMinutes, + Capacity = request.Capacity, + ReservedCount = 0, + Weekdays = request.Weekdays, + IsEnabled = request.IsEnabled + }; + await storeRepository.AddPickupSlotsAsync(new[] { slot }, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("创建自提档期 {SlotId} for store {StoreId}", slot.Id, request.StoreId); + return new StorePickupSlotDto + { + Id = slot.Id, + StoreId = slot.StoreId, + Name = slot.Name, + StartTime = slot.StartTime, + EndTime = slot.EndTime, + CutoffMinutes = slot.CutoffMinutes, + Capacity = slot.Capacity, + ReservedCount = slot.ReservedCount, + Weekdays = slot.Weekdays, + IsEnabled = slot.IsEnabled + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStorePickupSlotCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStorePickupSlotCommandHandler.cs new file mode 100644 index 0000000..c42e4d0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStorePickupSlotCommandHandler.cs @@ -0,0 +1,28 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 删除自提档期处理器。 +/// +public sealed class DeleteStorePickupSlotCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(DeleteStorePickupSlotCommand request, CancellationToken cancellationToken) + { + // 1. 删除档期 + var tenantId = tenantProvider.GetCurrentTenantId(); + await storeRepository.DeletePickupSlotAsync(request.SlotId, tenantId, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("删除自提档期 {SlotId}", request.SlotId); + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetAvailablePickupSlotsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetAvailablePickupSlotsQueryHandler.cs new file mode 100644 index 0000000..bbfb8f7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetAvailablePickupSlotsQueryHandler.cs @@ -0,0 +1,83 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 可用自提档期查询处理器。 +/// +public sealed class GetAvailablePickupSlotsQueryHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + /// + public async Task> Handle(GetAvailablePickupSlotsQuery request, CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var date = request.Date.Date; + // 1. 读取配置 + var setting = await storeRepository.GetPickupSettingAsync(request.StoreId, tenantId, cancellationToken); + var allowDays = setting?.AllowDaysAhead ?? 0; + var allowToday = setting?.AllowToday ?? false; + var defaultCutoff = setting?.DefaultCutoffMinutes ?? 30; + + // 2. 校验日期范围 + if (!allowToday && date == DateTime.UtcNow.Date) + { + return []; + } + + if (date > DateTime.UtcNow.Date.AddDays(allowDays)) + { + return []; + } + + // 3. 读取档期 + var slots = await storeRepository.GetPickupSlotsAsync(request.StoreId, tenantId, cancellationToken); + var weekday = (int)date.DayOfWeek; + weekday = weekday == 0 ? 7 : weekday; + var nowUtc = DateTime.UtcNow; + + // 4. 过滤可用 + var available = slots + .Where(x => x.IsEnabled && ContainsDay(x.Weekdays, weekday)) + .Select(slot => + { + var cutoff = slot.CutoffMinutes == 0 ? defaultCutoff : slot.CutoffMinutes; + var slotStartUtc = date.Add(slot.StartTime); + // 判断截单 + var cutoffTime = slotStartUtc.AddMinutes(-cutoff); + var isCutoff = nowUtc > cutoffTime; + var remaining = slot.Capacity - slot.ReservedCount; + return (slot, isCutoff, remaining); + }) + .Where(x => !x.isCutoff && x.remaining > 0) + .Select(x => new StorePickupSlotDto + { + Id = x.slot.Id, + StoreId = x.slot.StoreId, + Name = x.slot.Name, + StartTime = x.slot.StartTime, + EndTime = x.slot.EndTime, + CutoffMinutes = x.slot.CutoffMinutes, + Capacity = x.slot.Capacity, + ReservedCount = x.slot.ReservedCount, + Weekdays = x.slot.Weekdays, + IsEnabled = x.slot.IsEnabled + }) + .ToList(); + + return available; + } + + private static bool ContainsDay(string weekdays, int target) + { + // 解析适用星期 + var parts = weekdays.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return parts.Any(p => int.TryParse(p, out var val) && val == target); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStorePickupSettingQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStorePickupSettingQueryHandler.cs new file mode 100644 index 0000000..f7899e3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStorePickupSettingQueryHandler.cs @@ -0,0 +1,37 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 获取自提配置处理器。 +/// +public sealed class GetStorePickupSettingQueryHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(GetStorePickupSettingQuery request, CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var setting = await storeRepository.GetPickupSettingAsync(request.StoreId, tenantId, cancellationToken); + if (setting is null) + { + return null; + } + + return new StorePickupSettingDto + { + Id = setting.Id, + StoreId = setting.StoreId, + AllowToday = setting.AllowToday, + AllowDaysAhead = setting.AllowDaysAhead, + DefaultCutoffMinutes = setting.DefaultCutoffMinutes, + MaxQuantityPerOrder = setting.MaxQuantityPerOrder + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStorePickupSlotsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStorePickupSlotsQueryHandler.cs new file mode 100644 index 0000000..146a7a4 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStorePickupSlotsQueryHandler.cs @@ -0,0 +1,38 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 自提档期列表查询处理器。 +/// +public sealed class ListStorePickupSlotsQueryHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + /// + public async Task> Handle(ListStorePickupSlotsQuery request, CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var slots = await storeRepository.GetPickupSlotsAsync(request.StoreId, tenantId, cancellationToken); + return slots + .Select(x => new StorePickupSlotDto + { + Id = x.Id, + StoreId = x.StoreId, + Name = x.Name, + StartTime = x.StartTime, + EndTime = x.EndTime, + CutoffMinutes = x.CutoffMinutes, + Capacity = x.Capacity, + ReservedCount = x.ReservedCount, + Weekdays = x.Weekdays, + IsEnabled = x.IsEnabled + }) + .ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStorePickupSlotCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStorePickupSlotCommandHandler.cs new file mode 100644 index 0000000..30355a3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStorePickupSlotCommandHandler.cs @@ -0,0 +1,57 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 更新自提档期处理器。 +/// +public sealed class UpdateStorePickupSlotCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(UpdateStorePickupSlotCommand request, CancellationToken cancellationToken) + { + // 1. 查询档期 + var tenantId = tenantProvider.GetCurrentTenantId(); + var slot = await storeRepository.FindPickupSlotByIdAsync(request.SlotId, tenantId, cancellationToken); + if (slot is null || slot.StoreId != request.StoreId) + { + return null; + } + + // 2. 更新字段 + slot.Name = request.Name.Trim(); + slot.StartTime = request.StartTime; + slot.EndTime = request.EndTime; + slot.CutoffMinutes = request.CutoffMinutes; + slot.Capacity = request.Capacity; + slot.Weekdays = request.Weekdays; + slot.IsEnabled = request.IsEnabled; + await storeRepository.UpdatePickupSlotAsync(slot, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("更新自提档期 {SlotId}", request.SlotId); + return new StorePickupSlotDto + { + Id = slot.Id, + StoreId = slot.StoreId, + Name = slot.Name, + StartTime = slot.StartTime, + EndTime = slot.EndTime, + CutoffMinutes = slot.CutoffMinutes, + Capacity = slot.Capacity, + ReservedCount = slot.ReservedCount, + Weekdays = slot.Weekdays, + IsEnabled = slot.IsEnabled + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpsertStorePickupSettingCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpsertStorePickupSettingCommandHandler.cs new file mode 100644 index 0000000..abb8866 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpsertStorePickupSettingCommandHandler.cs @@ -0,0 +1,63 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 自提配置维护处理器。 +/// +public sealed class UpsertStorePickupSettingCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(UpsertStorePickupSettingCommand request, CancellationToken cancellationToken) + { + // 1. 校验门店存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 2. 读取或创建配置 + var setting = await storeRepository.GetPickupSettingAsync(request.StoreId, tenantId, cancellationToken); + if (setting is null) + { + setting = new StorePickupSetting + { + TenantId = tenantId, + StoreId = request.StoreId + }; + await storeRepository.AddPickupSettingAsync(setting, cancellationToken); + } + + // 3. 更新字段 + setting.AllowToday = request.AllowToday; + setting.AllowDaysAhead = request.AllowDaysAhead; + setting.DefaultCutoffMinutes = request.DefaultCutoffMinutes; + setting.MaxQuantityPerOrder = request.MaxQuantityPerOrder; + await storeRepository.UpdatePickupSettingAsync(setting, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("更新门店 {StoreId} 自提配置", request.StoreId); + return new StorePickupSettingDto + { + Id = setting.Id, + StoreId = setting.StoreId, + AllowToday = setting.AllowToday, + AllowDaysAhead = setting.AllowDaysAhead, + DefaultCutoffMinutes = setting.DefaultCutoffMinutes, + MaxQuantityPerOrder = setting.MaxQuantityPerOrder + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetAvailablePickupSlotsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetAvailablePickupSlotsQuery.cs new file mode 100644 index 0000000..f3310b8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetAvailablePickupSlotsQuery.cs @@ -0,0 +1,21 @@ +using System; +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 获取可用自提档期查询。 +/// +public sealed record GetAvailablePickupSlotsQuery : IRequest> +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 目标日期(本地日期部分)。 + /// + public DateTime Date { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetStorePickupSettingQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetStorePickupSettingQuery.cs new file mode 100644 index 0000000..f0a4e28 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetStorePickupSettingQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 获取门店自提配置查询。 +/// +public sealed record GetStorePickupSettingQuery : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStorePickupSlotsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStorePickupSlotsQuery.cs new file mode 100644 index 0000000..88660af --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStorePickupSlotsQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 门店档期列表查询。 +/// +public sealed record ListStorePickupSlotsQuery : IRequest> +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStorePickupSlotCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStorePickupSlotCommandValidator.cs new file mode 100644 index 0000000..75def24 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStorePickupSlotCommandValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 创建自提档期验证器。 +/// +public sealed class CreateStorePickupSlotCommandValidator : AbstractValidator +{ + /// + /// 初始化规则。 + /// + public CreateStorePickupSlotCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.Name).NotEmpty().MaximumLength(64); + RuleFor(x => x.Capacity).GreaterThan(0); + RuleFor(x => x.Weekdays).NotEmpty().MaximumLength(32); + RuleFor(x => x.CutoffMinutes).GreaterThanOrEqualTo(0); + RuleFor(x => x.EndTime).GreaterThan(x => x.StartTime); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/DeleteStorePickupSlotCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/DeleteStorePickupSlotCommandValidator.cs new file mode 100644 index 0000000..a7b70f5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/DeleteStorePickupSlotCommandValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 删除自提档期验证器。 +/// +public sealed class DeleteStorePickupSlotCommandValidator : AbstractValidator +{ + /// + /// 初始化规则。 + /// + public DeleteStorePickupSlotCommandValidator() + { + RuleFor(x => x.SlotId).GreaterThan(0); + RuleFor(x => x.StoreId).GreaterThan(0); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStorePickupSlotCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStorePickupSlotCommandValidator.cs new file mode 100644 index 0000000..7ae91d0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStorePickupSlotCommandValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 更新自提档期验证器。 +/// +public sealed class UpdateStorePickupSlotCommandValidator : AbstractValidator +{ + /// + /// 初始化规则。 + /// + public UpdateStorePickupSlotCommandValidator() + { + RuleFor(x => x.SlotId).GreaterThan(0); + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.Name).NotEmpty().MaximumLength(64); + RuleFor(x => x.Capacity).GreaterThan(0); + RuleFor(x => x.Weekdays).NotEmpty().MaximumLength(32); + RuleFor(x => x.CutoffMinutes).GreaterThanOrEqualTo(0); + RuleFor(x => x.EndTime).GreaterThan(x => x.StartTime); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpsertStorePickupSettingCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpsertStorePickupSettingCommandValidator.cs new file mode 100644 index 0000000..2cc3d04 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpsertStorePickupSettingCommandValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 自提配置验证器。 +/// +public sealed class UpsertStorePickupSettingCommandValidator : AbstractValidator +{ + /// + /// 初始化规则。 + /// + public UpsertStorePickupSettingCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.AllowDaysAhead).GreaterThanOrEqualTo(0); + RuleFor(x => x.DefaultCutoffMinutes).GreaterThanOrEqualTo(0); + RuleFor(x => x.MaxQuantityPerOrder).GreaterThan(0).When(x => x.MaxQuantityPerOrder.HasValue); + } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StorePickupSetting.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StorePickupSetting.cs new file mode 100644 index 0000000..d47984c --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StorePickupSetting.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Stores.Entities; + +/// +/// 门店自提配置。 +/// +public sealed class StorePickupSetting : MultiTenantEntityBase +{ + /// + /// 门店标识。 + /// + public long StoreId { get; set; } + + /// + /// 是否允许当天自提。 + /// + public bool AllowToday { get; set; } = true; + + /// + /// 可预约天数(含当天)。 + /// + public int AllowDaysAhead { get; set; } = 3; + + /// + /// 默认截单分钟(开始前多少分钟截止)。 + /// + public int DefaultCutoffMinutes { get; set; } = 30; + + /// + /// 单笔自提最大份数。 + /// + public int? MaxQuantityPerOrder { get; set; } + + /// + /// 并发控制字段。 + /// + [Timestamp] + public byte[] RowVersion { get; set; } = Array.Empty(); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StorePickupSlot.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StorePickupSlot.cs new file mode 100644 index 0000000..8ab3f67 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StorePickupSlot.cs @@ -0,0 +1,61 @@ +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Stores.Entities; + +/// +/// 门店自提档期。 +/// +public sealed class StorePickupSlot : MultiTenantEntityBase +{ + /// + /// 门店标识。 + /// + public long StoreId { get; set; } + + /// + /// 档期名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 当天开始时间(UTC)。 + /// + public TimeSpan StartTime { get; set; } + + /// + /// 当天结束时间(UTC)。 + /// + public TimeSpan EndTime { get; set; } + + /// + /// 截单分钟(开始前多少分钟截止)。 + /// + public int CutoffMinutes { get; set; } = 30; + + /// + /// 容量(份数)。 + /// + public int Capacity { get; set; } + + /// + /// 已占用数量。 + /// + public int ReservedCount { get; set; } + + /// + /// 适用星期(逗号分隔 1-7)。 + /// + public string Weekdays { get; set; } = "1,2,3,4,5,6,7"; + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; set; } = true; + + /// + /// 并发控制字段。 + /// + [Timestamp] + public byte[] RowVersion { get; set; } = Array.Empty(); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs index 2cdbfac..b7a4766 100644 --- a/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs @@ -76,6 +76,41 @@ public interface IStoreRepository /// Task FindTableByCodeAsync(string tableCode, long tenantId, CancellationToken cancellationToken = default); + /// + /// 获取门店自提配置。 + /// + Task GetPickupSettingAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 新增自提配置。 + /// + Task AddPickupSettingAsync(StorePickupSetting setting, CancellationToken cancellationToken = default); + + /// + /// 更新自提配置。 + /// + Task UpdatePickupSettingAsync(StorePickupSetting setting, CancellationToken cancellationToken = default); + + /// + /// 获取门店自提档期。 + /// + Task> GetPickupSlotsAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 依据标识获取档期。 + /// + Task FindPickupSlotByIdAsync(long slotId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 新增加档期。 + /// + Task AddPickupSlotsAsync(IEnumerable slots, CancellationToken cancellationToken = default); + + /// + /// 更新档期。 + /// + Task UpdatePickupSlotAsync(StorePickupSlot slot, CancellationToken cancellationToken = default); + /// /// 获取门店员工排班(可选时间范围)。 /// @@ -181,6 +216,11 @@ public interface IStoreRepository /// Task DeleteTableAsync(long tableId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 删除自提档期。 + /// + Task DeletePickupSlotAsync(long slotId, long tenantId, CancellationToken cancellationToken = default); + /// /// 删除排班。 /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs index 1630554..0d38a33 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs @@ -62,6 +62,8 @@ public sealed class TakeoutAppDbContext( public DbSet StoreTableAreas => Set(); public DbSet StoreTables => Set(); public DbSet StoreEmployeeShifts => Set(); + public DbSet StorePickupSettings => Set(); + public DbSet StorePickupSlots => Set(); public DbSet ProductCategories => Set(); public DbSet Products => Set(); @@ -159,6 +161,8 @@ public sealed class TakeoutAppDbContext( ConfigureStoreTableArea(modelBuilder.Entity()); ConfigureStoreTable(modelBuilder.Entity()); ConfigureStoreEmployeeShift(modelBuilder.Entity()); + ConfigureStorePickupSetting(modelBuilder.Entity()); + ConfigureStorePickupSlot(modelBuilder.Entity()); ConfigureProductCategory(modelBuilder.Entity()); ConfigureProduct(modelBuilder.Entity()); ConfigureProductAttributeGroup(modelBuilder.Entity()); @@ -622,6 +626,28 @@ public sealed class TakeoutAppDbContext( builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ShiftDate, x.StaffId }).IsUnique(); } + private static void ConfigureStorePickupSetting(EntityTypeBuilder builder) + { + builder.ToTable("store_pickup_settings"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.DefaultCutoffMinutes).HasDefaultValue(30); + builder.Property(x => x.RowVersion).IsRowVersion(); + builder.HasIndex(x => new { x.TenantId, x.StoreId }).IsUnique(); + } + + private static void ConfigureStorePickupSlot(EntityTypeBuilder builder) + { + builder.ToTable("store_pickup_slots"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.Name).HasMaxLength(64).IsRequired(); + builder.Property(x => x.Weekdays).HasMaxLength(32).IsRequired(); + builder.Property(x => x.CutoffMinutes).HasDefaultValue(30); + builder.Property(x => x.RowVersion).IsRowVersion(); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Name }); + } + private static void ConfigureProductAttributeGroup(EntityTypeBuilder builder) { builder.ToTable("product_attribute_groups"); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs index 0722b3c..1e1886c 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Stores.Entities; @@ -154,6 +155,59 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos .FirstOrDefaultAsync(cancellationToken); } + /// + public Task GetPickupSettingAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) + { + return context.StorePickupSettings + .Where(x => x.TenantId == tenantId && x.StoreId == storeId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task AddPickupSettingAsync(StorePickupSetting setting, CancellationToken cancellationToken = default) + { + return context.StorePickupSettings.AddAsync(setting, cancellationToken).AsTask(); + } + + /// + public Task UpdatePickupSettingAsync(StorePickupSetting setting, CancellationToken cancellationToken = default) + { + context.StorePickupSettings.Update(setting); + return Task.CompletedTask; + } + + /// + public async Task> GetPickupSlotsAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) + { + var slots = await context.StorePickupSlots + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.StoreId == storeId) + .OrderBy(x => x.StartTime) + .ToListAsync(cancellationToken); + return slots; + } + + /// + public Task FindPickupSlotByIdAsync(long slotId, long tenantId, CancellationToken cancellationToken = default) + { + return context.StorePickupSlots + .Where(x => x.TenantId == tenantId && x.Id == slotId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task AddPickupSlotsAsync(IEnumerable slots, CancellationToken cancellationToken = default) + { + return context.StorePickupSlots.AddRangeAsync(slots, cancellationToken); + } + + /// + public Task UpdatePickupSlotAsync(StorePickupSlot slot, CancellationToken cancellationToken = default) + { + context.StorePickupSlots.Update(slot); + return Task.CompletedTask; + } + /// public async Task> GetShiftsAsync(long storeId, long tenantId, DateTime? from = null, DateTime? to = null, CancellationToken cancellationToken = default) { @@ -342,6 +396,19 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos } } + /// + public async Task DeletePickupSlotAsync(long slotId, long tenantId, CancellationToken cancellationToken = default) + { + var existing = await context.StorePickupSlots + .Where(x => x.TenantId == tenantId && x.Id == slotId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing != null) + { + context.StorePickupSlots.Remove(existing); + } + } + /// public async Task DeleteShiftAsync(long shiftId, long tenantId, CancellationToken cancellationToken = default) { From 32ab138572323d503405c8b7fb531bce67972357 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Thu, 4 Dec 2025 11:59:02 +0800 Subject: [PATCH 27/30] =?UTF-8?q?chore:=20=E5=BC=80=E5=90=AF=E5=90=84?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=20Swagger=20=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs b/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs index 8b1fb25..51385fb 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs +++ b/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs @@ -37,12 +37,6 @@ public static class SwaggerExtensions /// public static IApplicationBuilder UseSharedSwagger(this IApplicationBuilder app) { - var env = app.ApplicationServices.GetRequiredService(); - if (!env.IsDevelopment()) - { - return app; - } - var provider = app.ApplicationServices.GetRequiredService(); var settings = app.ApplicationServices.GetRequiredService(); From 37e7d721f3e2228eb2c26c18fdc258f8dad0fc35 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Thu, 4 Dec 2025 12:45:26 +0800 Subject: [PATCH 28/30] =?UTF-8?q?docs:=20=E5=AE=8C=E5=96=84=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E6=B3=A8=E9=87=8A=E4=B8=8EStyleCop=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .editorconfig | 7 ++ Directory.Build.props | 3 + .../Controllers/AuthController.cs | 15 ++- .../Controllers/DeliveriesController.cs | 22 +++- .../Controllers/DictionaryController.cs | 27 +++++ .../Controllers/FilesController.cs | 2 - .../Controllers/HealthController.cs | 2 - .../MerchantCategoriesController.cs | 13 ++- .../Controllers/MerchantsController.cs | 21 +++- .../Controllers/OrdersController.cs | 1 - .../Controllers/PaymentsController.cs | 1 - .../Controllers/PermissionsController.cs | 28 +++-- .../Controllers/ProductsController.cs | 1 - .../Controllers/RolesController.cs | 4 +- .../Controllers/StoresController.cs | 1 - .../Controllers/SystemParametersController.cs | 1 - .../TenantAnnouncementsController.cs | 3 +- .../Controllers/TenantBillingsController.cs | 3 +- .../TenantNotificationsController.cs | 1 - .../Controllers/TenantPackagesController.cs | 19 +++- .../Controllers/TenantsController.cs | 4 +- .../Controllers/UserPermissionsController.cs | 4 +- src/Api/TakeoutSaaS.AdminApi/Program.cs | 9 +- .../Controllers/AuthController.cs | 6 ++ .../Controllers/FilesController.cs | 6 +- .../Controllers/HealthController.cs | 2 - .../Controllers/MeController.cs | 6 +- src/Api/TakeoutSaaS.MiniApi/Program.cs | 3 - .../Controllers/HealthController.cs | 2 - src/Api/TakeoutSaaS.UserApi/Program.cs | 3 - ...pApplicationServiceCollectionExtensions.cs | 2 +- .../Commands/AddMerchantDocumentCommand.cs | 3 +- .../Commands/CreateMerchantCategoryCommand.cs | 2 +- .../Commands/CreateMerchantContractCommand.cs | 3 +- .../Commands/DeleteMerchantCategoryCommand.cs | 2 +- .../ReorderMerchantCategoriesCommand.cs | 3 +- .../Commands/ReviewMerchantCommand.cs | 2 +- .../Commands/ReviewMerchantDocumentCommand.cs | 2 +- .../UpdateMerchantContractStatusCommand.cs | 3 +- .../App/Merchants/Dto/MerchantCategoryDto.cs | 2 - .../App/Merchants/Dto/MerchantDetailDto.cs | 2 - .../CreateMerchantCategoryCommandHandler.cs | 1 - .../GetMerchantAuditLogsQueryHandler.cs | 1 - .../GetMerchantCategoriesQueryHandler.cs | 2 - .../GetMerchantContractsQueryHandler.cs | 1 - .../GetMerchantDocumentsQueryHandler.cs | 1 - .../ListMerchantCategoriesQueryHandler.cs | 1 - ...ReorderMerchantCategoriesCommandHandler.cs | 1 - .../ReviewMerchantDocumentCommandHandler.cs | 2 +- .../App/Merchants/MerchantMapping.cs | 2 - .../Queries/GetMerchantCategoriesQuery.cs | 1 - .../Queries/GetMerchantContractsQuery.cs | 1 - .../Queries/GetMerchantDocumentsQuery.cs | 1 - .../Queries/ListMerchantCategoriesQuery.cs | 1 - .../Handlers/CreateOrderCommandHandler.cs | 1 + .../Handlers/SearchPaymentsQueryHandler.cs | 1 - .../CheckTenantQuotaCommandHandler.cs | 1 - .../GetTenantAuditLogsQueryHandler.cs | 1 - ...arkTenantAnnouncementReadCommandHandler.cs | 2 - .../SearchTenantAnnouncementsQueryHandler.cs | 2 - .../Handlers/SearchTenantBillsQueryHandler.cs | 1 - .../SearchTenantNotificationsQueryHandler.cs | 1 - .../SearchTenantPackagesQueryHandler.cs | 1 - .../Handlers/SearchTenantsQueryHandler.cs | 1 - .../Abstractions/IDictionaryCache.cs | 3 - .../Contracts/CreateDictionaryItemRequest.cs | 2 +- .../Dictionary/Models/DictionaryGroupDto.cs | 3 +- .../Services/DictionaryAppService.cs | 1 - .../Abstractions/IAdminAuthService.cs | 3 - .../Identity/Abstractions/IJwtTokenService.cs | 2 - .../Abstractions/ILoginRateLimiter.cs | 3 - .../Identity/Abstractions/IMiniAuthService.cs | 3 - .../Abstractions/IRefreshTokenStore.cs | 3 - .../Abstractions/IWeChatAuthService.cs | 3 - .../Commands/CreateRoleTemplateCommand.cs | 1 - .../InitializeRoleTemplatesCommand.cs | 1 - .../Commands/UpdateRoleTemplateCommand.cs | 1 - .../Identity/Contracts/RoleTemplateDto.cs | 2 - .../Identity/Contracts/UserPermissionDto.cs | 1 - .../CopyRoleTemplateCommandHandler.cs | 3 - .../CreateRoleTemplateCommandHandler.cs | 2 - .../Handlers/GetRoleTemplateQueryHandler.cs | 1 - .../GetUserPermissionsQueryHandler.cs | 2 - .../InitializeRoleTemplatesCommandHandler.cs | 3 - .../Handlers/ListRoleTemplatesQueryHandler.cs | 2 - .../Handlers/SearchPermissionsQueryHandler.cs | 2 - .../Handlers/SearchRolesQueryHandler.cs | 2 - .../SearchUserPermissionsQueryHandler.cs | 3 - .../Identity/Handlers/TemplateMapper.cs | 2 - .../UpdateRoleTemplateCommandHandler.cs | 2 - .../Queries/ListRoleTemplatesQuery.cs | 1 - .../Identity/Services/AdminAuthService.cs | 5 +- .../Identity/Services/MiniAuthService.cs | 6 +- .../Messaging/Abstractions/IEventPublisher.cs | 3 - .../Messaging/Services/EventPublisher.cs | 2 - .../Abstractions/IVerificationCodeService.cs | 2 - .../Contracts/SendVerificationCodeResponse.cs | 2 - .../Sms/Services/VerificationCodeService.cs | 5 +- .../Abstractions/IFileStorageService.cs | 2 - .../Storage/Contracts/DirectUploadResponse.cs | 3 - .../Storage/Contracts/UploadFileRequest.cs | 1 - .../Extensions/UploadFileTypeParser.cs | 1 - .../Storage/Services/FileStorageService.cs | 10 +- .../Data/IDapperExecutor.cs | 1 + .../Diagnostics/TraceContext.cs | 2 - .../Exceptions/ValidationException.cs | 3 - .../Results/ApiResponse.NonGeneric.cs | 12 +++ .../Results/ApiResponse.cs | 21 ++++ .../Results/PagedResult.cs | 2 - .../Tenancy/ITenantProvider.cs | 3 +- .../Ids/SnowflakeIdGenerator.cs | 1 - .../Extensions/ServiceCollectionExtensions.cs | 1 - .../Filters/ValidateModelAttribute.cs | 1 - .../Middleware/CorrelationIdMiddleware.cs | 13 +-- .../Middleware/ExceptionHandlingMiddleware.cs | 6 +- .../Middleware/RequestLoggingMiddleware.cs | 3 +- .../Security/ClaimsPrincipalExtensions.cs | 1 - .../HttpContextCurrentUserAccessor.cs | 2 +- .../Swagger/ConfigureSwaggerOptions.cs | 4 +- .../Repositories/IDeliveryRepository.cs | 35 +++++- .../Dictionary/Entities/DictionaryGroup.cs | 1 - .../Repositories/IDictionaryRepository.cs | 37 ++++++- .../Repositories/IIdentityUserRepository.cs | 15 ++- .../Repositories/IPermissionRepository.cs | 3 - .../Repositories/IRolePermissionRepository.cs | 3 - .../Identity/Repositories/IRoleRepository.cs | 3 - .../Repositories/IRoleTemplateRepository.cs | 3 - .../Repositories/IUserRoleRepository.cs | 3 - .../IMerchantCategoryRepository.cs | 25 ++++- .../Repositories/IMerchantRepository.cs | 51 ++++++++- .../Orders/Repositories/IOrderRepository.cs | 49 ++++++++- .../Repositories/IPaymentRepository.cs | 34 +++++- .../Repositories/IProductRepository.cs | 102 +++++++++++++++++- .../Stores/Repositories/IStoreRepository.cs | 65 ++++++++++- .../ISystemParameterRepository.cs | 24 ++++- .../ITenantAnnouncementReadRepository.cs | 5 +- .../ITenantAnnouncementRepository.cs | 7 +- .../Repositories/ITenantBillingRepository.cs | 6 +- .../ITenantNotificationRepository.cs | 6 +- .../Repositories/ITenantPackageRepository.cs | 7 +- .../ITenantQuotaUsageRepository.cs | 6 +- .../Tenants/Repositories/ITenantRepository.cs | 10 +- src/Gateway/TakeoutSaaS.ApiGateway/Program.cs | 7 +- .../App/Options/AppSeedOptions.cs | 2 - .../App/Options/DictionarySeedGroupOptions.cs | 1 - .../App/Persistence/AppDataSeeder.cs | 2 - .../App/Repositories/EfDeliveryRepository.cs | 1 - .../EfMerchantCategoryRepository.cs | 2 - .../App/Repositories/EfMerchantRepository.cs | 1 - .../App/Repositories/EfOrderRepository.cs | 1 - .../App/Repositories/EfPaymentRepository.cs | 1 - .../App/Repositories/EfProductRepository.cs | 1 - .../App/Repositories/EfStoreRepository.cs | 1 - .../EfTenantAnnouncementReadRepository.cs | 1 - .../EfTenantAnnouncementRepository.cs | 1 - .../Repositories/EfTenantBillingRepository.cs | 1 - .../EfTenantNotificationRepository.cs | 1 - .../Repositories/EfTenantPackageRepository.cs | 1 - .../EfTenantQuotaUsageRepository.cs | 1 - .../App/Repositories/EfTenantRepository.cs | 1 - .../DatabaseServiceCollectionExtensions.cs | 2 +- .../Common/Persistence/AppDbContext.cs | 4 +- .../Common/Persistence/DapperExecutor.cs | 2 +- .../Persistence/DatabaseConnectionFactory.cs | 5 +- .../DesignTimeDbContextFactoryBase.cs | 3 - .../ModelBuilderCommentExtensions.cs | 4 +- .../Persistence/TenantAwareDbContext.cs | 4 +- .../DictionaryServiceCollectionExtensions.cs | 4 +- .../Repositories/EfDictionaryRepository.cs | 3 +- .../EfSystemParameterRepository.cs | 1 - .../Services/DistributedDictionaryCache.cs | 2 +- .../Extensions/JwtAuthenticationExtensions.cs | 7 +- .../Extensions/ServiceCollectionExtensions.cs | 3 - .../Persistence/EfIdentityUserRepository.cs | 4 - .../Persistence/EfMiniUserRepository.cs | 3 - .../Persistence/EfPermissionRepository.cs | 1 - .../Persistence/EfRolePermissionRepository.cs | 1 - .../Persistence/EfRoleTemplateRepository.cs | 2 - .../Persistence/IdentityDataSeeder.cs | 2 - .../Identity/Persistence/IdentityDbContext.cs | 1 - .../Identity/Services/JwtTokenService.cs | 8 +- .../Services/RedisLoginRateLimiter.cs | 3 - .../Services/RedisRefreshTokenStore.cs | 7 +- .../Identity/Services/WeChatAuthService.cs | 6 +- .../20251202005208_InitSnowflake_App.cs | 3 +- ...20251202005247_InitSnowflake_Dictionary.cs | 3 +- ...20251202043204_AddSystemParametersTable.cs | 3 +- .../20251202005226_InitSnowflake_Identity.cs | 3 +- .../IdentityDb/20251202084523_AddRbacModel.cs | 3 +- .../PermissionAuthorizationHandler.cs | 3 - .../TakeoutSaaS.Module.Delivery.csproj | 3 + .../Abstractions/IMessagePublisher.cs | 3 - .../Abstractions/IMessageSubscriber.cs | 4 - .../Services/RabbitMqMessagePublisher.cs | 2 - .../Abstractions/IRecurringJobRegistrar.cs | 3 - .../Abstractions/ISmsSender.cs | 2 - .../Models/SmsSendRequest.cs | 2 - .../Options/SmsOptions.cs | 2 - .../Services/AliyunSmsSender.cs | 1 - .../Services/SmsSenderResolver.cs | 3 - .../Services/TencentSmsSender.cs | 5 +- .../Abstractions/IObjectStorageProvider.cs | 2 - .../Models/StorageDirectUploadResult.cs | 3 - .../Models/StorageUploadRequest.cs | 3 - .../Providers/AliyunOssStorageProvider.cs | 6 -- .../Providers/QiniuKodoStorageProvider.cs | 1 - .../Providers/S3StorageProviderBase.cs | 5 - .../Providers/TencentCosStorageProvider.cs | 1 - .../Services/StorageProviderResolver.cs | 3 - .../TenantContextAccessor.cs | 1 - .../TenantResolutionMiddleware.cs | 1 - .../TenantResolutionOptions.cs | 2 +- stylecop.json | 7 ++ 213 files changed, 695 insertions(+), 446 deletions(-) create mode 100644 stylecop.json diff --git a/.editorconfig b/.editorconfig index 32193b8..6c8b717 100644 --- a/.editorconfig +++ b/.editorconfig @@ -4,3 +4,10 @@ root = true [*.cs] dotnet_diagnostic.SA1600.severity = error dotnet_diagnostic.SA1601.severity = error +dotnet_diagnostic.SA1615.severity = error +dotnet_diagnostic.SA1629.severity = none +dotnet_diagnostic.SA1202.severity = none +dotnet_diagnostic.SA1200.severity = none +dotnet_diagnostic.SA1623.severity = none +dotnet_diagnostic.SA1111.severity = none +dotnet_diagnostic.SA1101.severity = none diff --git a/Directory.Build.props b/Directory.Build.props index f83743e..6f0ab73 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -9,5 +9,8 @@ + + + diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs index b38d018..32b4e5a 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs @@ -1,8 +1,4 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Contracts; @@ -26,6 +22,9 @@ public sealed class AuthController(IAdminAuthService authService) : BaseApiContr /// /// 登录获取 Token /// + /// 登录请求。 + /// 取消标记。 + /// 包含访问令牌与刷新令牌的响应。 [HttpPost("login")] [AllowAnonymous] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -38,6 +37,9 @@ public sealed class AuthController(IAdminAuthService authService) : BaseApiContr /// /// 刷新 Token /// + /// 刷新令牌请求。 + /// 取消标记。 + /// 新的访问令牌与刷新令牌。 [HttpPost("refresh")] [AllowAnonymous] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -73,6 +75,8 @@ public sealed class AuthController(IAdminAuthService authService) : BaseApiContr /// } /// /// + /// 取消标记。 + /// 当前用户档案信息。 [HttpGet("profile")] [PermissionAuthorize("identity:profile:read")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -116,6 +120,9 @@ public sealed class AuthController(IAdminAuthService authService) : BaseApiContr /// } /// /// + /// 目标用户 ID。 + /// 取消标记。 + /// 用户权限概览,未找到则返回 404。 [HttpGet("permissions/{userId:long}")] [PermissionAuthorize("identity:permission:read")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/DeliveriesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/DeliveriesController.cs index ae7c092..6f0f953 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/DeliveriesController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/DeliveriesController.cs @@ -1,6 +1,5 @@ using MediatR; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Application.App.Deliveries.Commands; using TakeoutSaaS.Application.App.Deliveries.Dto; @@ -24,6 +23,9 @@ public sealed class DeliveriesController(IMediator mediator) : BaseApiController /// /// 创建配送单。 /// + /// 创建命令。 + /// 取消标记。 + /// 创建后的配送单。 [HttpPost] [PermissionAuthorize("delivery:create")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -39,6 +41,14 @@ public sealed class DeliveriesController(IMediator mediator) : BaseApiController /// /// 查询配送单列表。 /// + /// 订单 ID。 + /// 配送状态。 + /// 页码。 + /// 每页大小。 + /// 排序字段。 + /// 是否倒序。 + /// 取消标记。 + /// 配送单分页列表。 [HttpGet] [PermissionAuthorize("delivery:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -69,6 +79,9 @@ public sealed class DeliveriesController(IMediator mediator) : BaseApiController /// /// 获取配送单详情。 /// + /// 配送单 ID。 + /// 取消标记。 + /// 配送单详情或未找到。 [HttpGet("{deliveryOrderId:long}")] [PermissionAuthorize("delivery:read")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -87,6 +100,10 @@ public sealed class DeliveriesController(IMediator mediator) : BaseApiController /// /// 更新配送单。 /// + /// 配送单 ID。 + /// 更新命令。 + /// 取消标记。 + /// 更新后的配送单或未找到。 [HttpPut("{deliveryOrderId:long}")] [PermissionAuthorize("delivery:update")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -111,6 +128,9 @@ public sealed class DeliveriesController(IMediator mediator) : BaseApiController /// /// 删除配送单。 /// + /// 配送单 ID。 + /// 取消标记。 + /// 删除结果,未找到则返回错误。 [HttpDelete("{deliveryOrderId:long}")] [PermissionAuthorize("delivery:delete")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryController.cs index 28e0d61..f2cc347 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryController.cs @@ -21,6 +21,9 @@ public sealed class DictionaryController(IDictionaryAppService dictionaryAppServ /// /// 查询字典分组。 /// + /// 分组查询条件。 + /// 取消标记。 + /// 分组列表。 [HttpGet] [PermissionAuthorize("dictionary:group:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -36,6 +39,9 @@ public sealed class DictionaryController(IDictionaryAppService dictionaryAppServ /// /// 创建字典分组。 /// + /// 创建分组请求。 + /// 取消标记。 + /// 创建后的分组。 [HttpPost] [PermissionAuthorize("dictionary:group:create")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -51,6 +57,10 @@ public sealed class DictionaryController(IDictionaryAppService dictionaryAppServ /// /// 更新字典分组。 /// + /// 分组 ID。 + /// 更新请求。 + /// 取消标记。 + /// 更新后的分组。 [HttpPut("{groupId:long}")] [PermissionAuthorize("dictionary:group:update")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -66,6 +76,9 @@ public sealed class DictionaryController(IDictionaryAppService dictionaryAppServ /// /// 删除字典分组。 /// + /// 分组 ID。 + /// 取消标记。 + /// 操作结果。 [HttpDelete("{groupId:long}")] [PermissionAuthorize("dictionary:group:delete")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -81,6 +94,10 @@ public sealed class DictionaryController(IDictionaryAppService dictionaryAppServ /// /// 创建字典项。 /// + /// 分组 ID。 + /// 创建请求。 + /// 取消标记。 + /// 创建的字典项。 [HttpPost("{groupId:long}/items")] [PermissionAuthorize("dictionary:item:create")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -97,6 +114,10 @@ public sealed class DictionaryController(IDictionaryAppService dictionaryAppServ /// /// 更新字典项。 /// + /// 字典项 ID。 + /// 更新请求。 + /// 取消标记。 + /// 更新后的字典项。 [HttpPut("items/{itemId:long}")] [PermissionAuthorize("dictionary:item:update")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -112,6 +133,9 @@ public sealed class DictionaryController(IDictionaryAppService dictionaryAppServ /// /// 删除字典项。 /// + /// 字典项 ID。 + /// 取消标记。 + /// 操作结果。 [HttpDelete("items/{itemId:long}")] [PermissionAuthorize("dictionary:item:delete")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -127,6 +151,9 @@ public sealed class DictionaryController(IDictionaryAppService dictionaryAppServ /// /// 批量获取字典项(命中缓存)。 /// + /// 批量查询请求。 + /// 取消标记。 + /// 分组编码到字典项列表的映射。 [HttpPost("batch")] [ProducesResponseType(typeof(ApiResponse>>), StatusCodes.Status200OK)] public async Task>>> BatchGet([FromBody] DictionaryBatchQueryRequest request, CancellationToken cancellationToken) diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/FilesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/FilesController.cs index 34091de..a6d77a4 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/FilesController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/FilesController.cs @@ -1,6 +1,4 @@ -using System.Linq; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Application.Storage.Abstractions; using TakeoutSaaS.Application.Storage.Contracts; diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/HealthController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/HealthController.cs index 25edb1b..90dc606 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/HealthController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/HealthController.cs @@ -1,6 +1,4 @@ -using System; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Shared.Abstractions.Results; using TakeoutSaaS.Shared.Web.Api; diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantCategoriesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantCategoriesController.cs index 8a930be..497cc87 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantCategoriesController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantCategoriesController.cs @@ -1,7 +1,5 @@ -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; @@ -24,6 +22,8 @@ public sealed class MerchantCategoriesController(IMediator mediator) : BaseApiCo /// /// 列出所有类目。 /// + /// 取消标记。 + /// 类目列表。 [HttpGet] [PermissionAuthorize("merchant_category:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -39,6 +39,9 @@ public sealed class MerchantCategoriesController(IMediator mediator) : BaseApiCo /// /// 新增类目。 /// + /// 创建命令。 + /// 取消标记。 + /// 创建的类目。 [HttpPost] [PermissionAuthorize("merchant_category:create")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -54,6 +57,9 @@ public sealed class MerchantCategoriesController(IMediator mediator) : BaseApiCo /// /// 删除类目。 /// + /// 类目 ID。 + /// 取消标记。 + /// 删除结果,未找到则返回错误。 [HttpDelete("{categoryId:long}")] [PermissionAuthorize("merchant_category:delete")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -72,6 +78,9 @@ public sealed class MerchantCategoriesController(IMediator mediator) : BaseApiCo /// /// 批量调整类目排序。 /// + /// 排序命令。 + /// 取消标记。 + /// 执行结果。 [HttpPost("reorder")] [PermissionAuthorize("merchant_category:update")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs index 828d98b..b886c5c 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs @@ -1,6 +1,5 @@ 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; @@ -24,6 +23,9 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController /// /// 创建商户。 /// + /// 创建命令。 + /// 取消标记。 + /// 创建后的商户。 [HttpPost] [PermissionAuthorize("merchant:create")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -39,6 +41,13 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController /// /// 查询商户列表。 /// + /// 状态筛选。 + /// 页码。 + /// 每页大小。 + /// 排序字段。 + /// 是否倒序。 + /// 取消标记。 + /// 商户分页结果。 [HttpGet] [PermissionAuthorize("merchant:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -67,6 +76,10 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController /// /// 更新商户。 /// + /// 商户 ID。 + /// 更新命令。 + /// 取消标记。 + /// 更新后的商户或未找到。 [HttpPut("{merchantId:long}")] [PermissionAuthorize("merchant:update")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -91,6 +104,9 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController /// /// 删除商户。 /// + /// 商户 ID。 + /// 取消标记。 + /// 删除结果。 [HttpDelete("{merchantId:long}")] [PermissionAuthorize("merchant:delete")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -109,6 +125,9 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController /// /// 获取商户概览。 /// + /// 商户 ID。 + /// 取消标记。 + /// 商户概览或未找到。 [HttpGet("{merchantId:long}")] [PermissionAuthorize("merchant:read")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/OrdersController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/OrdersController.cs index 85f69e4..9a0db09 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/OrdersController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/OrdersController.cs @@ -1,6 +1,5 @@ using MediatR; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Application.App.Orders.Commands; using TakeoutSaaS.Application.App.Orders.Dto; diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/PaymentsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/PaymentsController.cs index 83df2fe..ecf4fee 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/PaymentsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/PaymentsController.cs @@ -1,6 +1,5 @@ using MediatR; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Application.App.Payments.Commands; using TakeoutSaaS.Application.App.Payments.Dto; diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/PermissionsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/PermissionsController.cs index c3646b4..5eda408 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/PermissionsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/PermissionsController.cs @@ -1,7 +1,6 @@ using System.ComponentModel.DataAnnotations; using MediatR; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Application.Identity.Commands; using TakeoutSaaS.Application.Identity.Contracts; @@ -26,49 +25,48 @@ public sealed class PermissionsController(IMediator mediator) : BaseApiControlle /// /// 示例:GET /api/admin/v1/permissions?keyword=order&page=1&pageSize=20 /// + /// 查询条件。 + /// 取消标记。 + /// 权限的分页结果。 [HttpGet] [PermissionAuthorize("identity:permission:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] public async Task>> Search([FromQuery] SearchPermissionsQuery query, CancellationToken cancellationToken) { - // 1. 查询权限分页 var result = await mediator.Send(query, cancellationToken); - - // 2. 返回分页数据 return ApiResponse>.Ok(result); } /// /// 创建权限。 /// + /// 创建命令。 + /// 取消标记。 + /// 创建的权限。 [HttpPost] [PermissionAuthorize("identity:permission:create")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> Create([FromBody, Required] CreatePermissionCommand command, CancellationToken cancellationToken) { - // 1. 创建权限 var result = await mediator.Send(command, cancellationToken); - - // 2. 返回创建结果 return ApiResponse.Ok(result); } /// /// 更新权限。 /// + /// 权限 ID。 + /// 更新命令。 + /// 取消标记。 + /// 更新后的权限,未找到时返回 404。 [HttpPut("{permissionId:long}")] [PermissionAuthorize("identity:permission:update")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Update(long permissionId, [FromBody, Required] UpdatePermissionCommand command, CancellationToken cancellationToken) { - // 1. 绑定权限标识 command = command with { PermissionId = permissionId }; - - // 2. 执行更新 var result = await mediator.Send(command, cancellationToken); - - // 3. 返回更新结果或 404 return result is null ? ApiResponse.Error(StatusCodes.Status404NotFound, "权限不存在") : ApiResponse.Ok(result); @@ -77,15 +75,15 @@ public sealed class PermissionsController(IMediator mediator) : BaseApiControlle /// /// 删除权限。 /// + /// 权限 ID。 + /// 取消标记。 + /// 删除结果。 [HttpDelete("{permissionId:long}")] [PermissionAuthorize("identity:permission:delete")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> Delete(long permissionId, CancellationToken cancellationToken) { - // 1. 构建删除命令 var command = new DeletePermissionCommand { PermissionId = permissionId }; - - // 2. 执行删除 var result = await mediator.Send(command, cancellationToken); return ApiResponse.Ok(result); } diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs index 5cd7e8e..1bc75f0 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs @@ -1,6 +1,5 @@ using MediatR; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Application.App.Products.Commands; using TakeoutSaaS.Application.App.Products.Dto; diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs index 6a46937..d22f627 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs @@ -1,9 +1,7 @@ -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; using MediatR; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using System.ComponentModel.DataAnnotations; using TakeoutSaaS.Application.Identity.Commands; using TakeoutSaaS.Application.Identity.Contracts; using TakeoutSaaS.Application.Identity.Queries; diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/StoresController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoresController.cs index 7ae4958..ffcde02 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/StoresController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoresController.cs @@ -1,6 +1,5 @@ using MediatR; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Application.App.Stores.Commands; using TakeoutSaaS.Application.App.Stores.Dto; diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/SystemParametersController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/SystemParametersController.cs index f1f2e8d..7a45183 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/SystemParametersController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/SystemParametersController.cs @@ -1,6 +1,5 @@ using MediatR; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Application.App.SystemParameters.Commands; using TakeoutSaaS.Application.App.SystemParameters.Dto; diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantAnnouncementsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantAnnouncementsController.cs index d64b89f..1ba6147 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantAnnouncementsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantAnnouncementsController.cs @@ -1,8 +1,7 @@ -using System.ComponentModel.DataAnnotations; using MediatR; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using System.ComponentModel.DataAnnotations; using TakeoutSaaS.Application.App.Tenants.Commands; using TakeoutSaaS.Application.App.Tenants.Dto; using TakeoutSaaS.Application.App.Tenants.Queries; diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantBillingsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantBillingsController.cs index 85ca964..aa306c8 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantBillingsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantBillingsController.cs @@ -1,8 +1,7 @@ -using System.ComponentModel.DataAnnotations; using MediatR; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using System.ComponentModel.DataAnnotations; using TakeoutSaaS.Application.App.Tenants.Commands; using TakeoutSaaS.Application.App.Tenants.Dto; using TakeoutSaaS.Application.App.Tenants.Queries; diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantNotificationsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantNotificationsController.cs index dd79892..aa11643 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantNotificationsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantNotificationsController.cs @@ -1,6 +1,5 @@ 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; diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs index 3504bd9..455c095 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs @@ -1,8 +1,7 @@ -using System.ComponentModel.DataAnnotations; using MediatR; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using System.ComponentModel.DataAnnotations; using TakeoutSaaS.Application.App.Tenants.Commands; using TakeoutSaaS.Application.App.Tenants.Dto; using TakeoutSaaS.Application.App.Tenants.Queries; @@ -23,6 +22,9 @@ public sealed class TenantPackagesController(IMediator mediator) : BaseApiContro /// /// 分页查询租户套餐。 /// + /// 查询条件。 + /// 取消标记。 + /// 租户套餐分页结果。 [HttpGet] [PermissionAuthorize("tenant-package:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -38,6 +40,9 @@ public sealed class TenantPackagesController(IMediator mediator) : BaseApiContro /// /// 查看套餐详情。 /// + /// 套餐 ID。 + /// 取消标记。 + /// 套餐详情或未找到。 [HttpGet("{tenantPackageId:long}")] [PermissionAuthorize("tenant-package:read")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -56,6 +61,9 @@ public sealed class TenantPackagesController(IMediator mediator) : BaseApiContro /// /// 创建套餐。 /// + /// 创建命令。 + /// 取消标记。 + /// 创建后的套餐。 [HttpPost] [PermissionAuthorize("tenant-package:create")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -71,6 +79,10 @@ public sealed class TenantPackagesController(IMediator mediator) : BaseApiContro /// /// 更新套餐。 /// + /// 套餐 ID。 + /// 更新命令。 + /// 取消标记。 + /// 更新后的套餐或未找到。 [HttpPut("{tenantPackageId:long}")] [PermissionAuthorize("tenant-package:update")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -92,6 +104,9 @@ public sealed class TenantPackagesController(IMediator mediator) : BaseApiContro /// /// 删除套餐。 /// + /// 套餐 ID。 + /// 取消标记。 + /// 删除结果。 [HttpDelete("{tenantPackageId:long}")] [PermissionAuthorize("tenant-package:delete")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs index 0d91ee2..585ab2c 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs @@ -1,13 +1,11 @@ -using System.ComponentModel.DataAnnotations; using MediatR; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using System.ComponentModel.DataAnnotations; 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; diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/UserPermissionsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/UserPermissionsController.cs index f2f54e2..c6a4a04 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/UserPermissionsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/UserPermissionsController.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Contracts; @@ -51,6 +50,9 @@ public sealed class UserPermissionsController(IAdminAuthService authService) : B /// } /// /// + /// 搜索条件。 + /// 取消标记。 + /// 分页的用户权限概览。 [HttpGet] [PermissionAuthorize("identity:permission:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] diff --git a/src/Api/TakeoutSaaS.AdminApi/Program.cs b/src/Api/TakeoutSaaS.AdminApi/Program.cs index 432bd87..3b0c4b6 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Program.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Program.cs @@ -1,10 +1,4 @@ -using System; -using System.Linq; -using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Cors.Infrastructure; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; @@ -101,6 +95,7 @@ builder.Services.AddOpenTelemetry() exporter.Endpoint = new Uri(otelEndpoint); }); } + if (useConsoleExporter) { tracing.AddConsoleExporter(); @@ -120,6 +115,7 @@ builder.Services.AddOpenTelemetry() exporter.Endpoint = new Uri(otelEndpoint); }); } + if (useConsoleExporter) { metrics.AddConsoleExporter(); @@ -172,6 +168,7 @@ static void ConfigureCorsPolicy(CorsPolicyBuilder policy, string[] origins) policy.WithOrigins(origins) .AllowCredentials(); } + policy .AllowAnyHeader() .AllowAnyMethod(); diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/AuthController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/AuthController.cs index 5062afd..11e7d10 100644 --- a/src/Api/TakeoutSaaS.MiniApi/Controllers/AuthController.cs +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/AuthController.cs @@ -20,6 +20,9 @@ public sealed class AuthController(IMiniAuthService authService) : BaseApiContro /// /// 微信登录 /// + /// 微信登录请求。 + /// 取消标记。 + /// 包含访问令牌与刷新令牌的响应。 [HttpPost("wechat/login")] [AllowAnonymous] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -35,6 +38,9 @@ public sealed class AuthController(IMiniAuthService authService) : BaseApiContro /// /// 刷新 Token /// + /// 刷新令牌请求。 + /// 取消标记。 + /// 新的访问令牌与刷新令牌。 [HttpPost("refresh")] [AllowAnonymous] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/FilesController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/FilesController.cs index 67d5961..0d651fb 100644 --- a/src/Api/TakeoutSaaS.MiniApi/Controllers/FilesController.cs +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/FilesController.cs @@ -1,6 +1,4 @@ -using System.Linq; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Application.Storage.Abstractions; using TakeoutSaaS.Application.Storage.Contracts; @@ -22,6 +20,10 @@ public sealed class FilesController(IFileStorageService fileStorageService) : Ba /// /// 上传图片或文件。 /// + /// 上传文件。 + /// 上传类型。 + /// 取消标记。 + /// 上传结果,包含访问链接等信息。 [HttpPost("upload")] [RequestFormLimits(MultipartBodyLengthLimit = 30 * 1024 * 1024)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/HealthController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/HealthController.cs index d4e9920..e19775c 100644 --- a/src/Api/TakeoutSaaS.MiniApi/Controllers/HealthController.cs +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/HealthController.cs @@ -1,6 +1,4 @@ -using System; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Shared.Abstractions.Results; using TakeoutSaaS.Shared.Web.Api; diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/MeController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/MeController.cs index 4c29f25..7411644 100644 --- a/src/Api/TakeoutSaaS.MiniApi/Controllers/MeController.cs +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/MeController.cs @@ -1,8 +1,4 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Contracts; @@ -26,6 +22,8 @@ public sealed class MeController(IMiniAuthService authService) : BaseApiControll /// /// 获取用户档案 /// + /// 取消标记。 + /// 当前用户档案信息。 [HttpGet] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)] diff --git a/src/Api/TakeoutSaaS.MiniApi/Program.cs b/src/Api/TakeoutSaaS.MiniApi/Program.cs index d4df34c..8369004 100644 --- a/src/Api/TakeoutSaaS.MiniApi/Program.cs +++ b/src/Api/TakeoutSaaS.MiniApi/Program.cs @@ -1,7 +1,4 @@ -using System; -using System.Linq; using Microsoft.AspNetCore.Cors.Infrastructure; -using Microsoft.Extensions.Configuration; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; diff --git a/src/Api/TakeoutSaaS.UserApi/Controllers/HealthController.cs b/src/Api/TakeoutSaaS.UserApi/Controllers/HealthController.cs index 0056def..08fe92b 100644 --- a/src/Api/TakeoutSaaS.UserApi/Controllers/HealthController.cs +++ b/src/Api/TakeoutSaaS.UserApi/Controllers/HealthController.cs @@ -1,6 +1,4 @@ -using System; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Shared.Abstractions.Results; using TakeoutSaaS.Shared.Web.Api; diff --git a/src/Api/TakeoutSaaS.UserApi/Program.cs b/src/Api/TakeoutSaaS.UserApi/Program.cs index a531e11..d59e501 100644 --- a/src/Api/TakeoutSaaS.UserApi/Program.cs +++ b/src/Api/TakeoutSaaS.UserApi/Program.cs @@ -1,7 +1,4 @@ -using System; -using System.Linq; using Microsoft.AspNetCore.Cors.Infrastructure; -using Microsoft.Extensions.Configuration; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; diff --git a/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs b/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs index 62284b1..c4af1ce 100644 --- a/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs +++ b/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs @@ -1,7 +1,7 @@ -using System.Reflection; using FluentValidation; using MediatR; using Microsoft.Extensions.DependencyInjection; +using System.Reflection; using TakeoutSaaS.Application.App.Common.Behaviors; namespace TakeoutSaaS.Application.App.Extensions; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/AddMerchantDocumentCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/AddMerchantDocumentCommand.cs index cf0d4a3..bc40f63 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/AddMerchantDocumentCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/AddMerchantDocumentCommand.cs @@ -1,6 +1,5 @@ -using System; -using System.ComponentModel.DataAnnotations; using MediatR; +using System.ComponentModel.DataAnnotations; using TakeoutSaaS.Application.App.Merchants.Dto; using TakeoutSaaS.Domain.Merchants.Enums; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantCategoryCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantCategoryCommand.cs index 115d4ba..fe01142 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantCategoryCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantCategoryCommand.cs @@ -1,5 +1,5 @@ -using System.ComponentModel.DataAnnotations; using MediatR; +using System.ComponentModel.DataAnnotations; using TakeoutSaaS.Application.App.Merchants.Dto; namespace TakeoutSaaS.Application.App.Merchants.Commands; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantContractCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantContractCommand.cs index 176cf98..957ba01 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantContractCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantContractCommand.cs @@ -1,6 +1,5 @@ -using System; -using System.ComponentModel.DataAnnotations; using MediatR; +using System.ComponentModel.DataAnnotations; using TakeoutSaaS.Application.App.Merchants.Dto; namespace TakeoutSaaS.Application.App.Merchants.Commands; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/DeleteMerchantCategoryCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/DeleteMerchantCategoryCommand.cs index ffa0326..98cfd21 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/DeleteMerchantCategoryCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/DeleteMerchantCategoryCommand.cs @@ -1,5 +1,5 @@ -using System.ComponentModel.DataAnnotations; using MediatR; +using System.ComponentModel.DataAnnotations; namespace TakeoutSaaS.Application.App.Merchants.Commands; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReorderMerchantCategoriesCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReorderMerchantCategoriesCommand.cs index 79bd657..542e6aa 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReorderMerchantCategoriesCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReorderMerchantCategoriesCommand.cs @@ -1,6 +1,5 @@ -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; using MediatR; +using System.ComponentModel.DataAnnotations; namespace TakeoutSaaS.Application.App.Merchants.Commands; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantCommand.cs index 792cddc..362e20b 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantCommand.cs @@ -1,5 +1,5 @@ -using System.ComponentModel.DataAnnotations; using MediatR; +using System.ComponentModel.DataAnnotations; using TakeoutSaaS.Application.App.Merchants.Dto; namespace TakeoutSaaS.Application.App.Merchants.Commands; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantDocumentCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantDocumentCommand.cs index 23d6e2a..0e01c26 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantDocumentCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantDocumentCommand.cs @@ -1,5 +1,5 @@ -using System.ComponentModel.DataAnnotations; using MediatR; +using System.ComponentModel.DataAnnotations; using TakeoutSaaS.Application.App.Merchants.Dto; namespace TakeoutSaaS.Application.App.Merchants.Commands; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/UpdateMerchantContractStatusCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/UpdateMerchantContractStatusCommand.cs index 3514b58..f89f9bf 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/UpdateMerchantContractStatusCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/UpdateMerchantContractStatusCommand.cs @@ -1,6 +1,5 @@ -using System; -using System.ComponentModel.DataAnnotations; using MediatR; +using System.ComponentModel.DataAnnotations; using TakeoutSaaS.Application.App.Merchants.Dto; using TakeoutSaaS.Domain.Merchants.Enums; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantCategoryDto.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantCategoryDto.cs index 0d8636b..c8741ca 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantCategoryDto.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantCategoryDto.cs @@ -1,5 +1,3 @@ -using System; - namespace TakeoutSaaS.Application.App.Merchants.Dto; /// diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDetailDto.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDetailDto.cs index 4c8dedb..2fa8b95 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDetailDto.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDetailDto.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; - namespace TakeoutSaaS.Application.App.Merchants.Dto; /// diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCategoryCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCategoryCommandHandler.cs index c94e995..645189c 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCategoryCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCategoryCommandHandler.cs @@ -1,4 +1,3 @@ -using System.Linq; using MediatR; using TakeoutSaaS.Application.App.Merchants.Commands; using TakeoutSaaS.Application.App.Merchants.Dto; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantAuditLogsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantAuditLogsQueryHandler.cs index cfb010d..2fd67e5 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantAuditLogsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantAuditLogsQueryHandler.cs @@ -1,4 +1,3 @@ -using System.Linq; using MediatR; using TakeoutSaaS.Application.App.Merchants.Dto; using TakeoutSaaS.Application.App.Merchants.Queries; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantCategoriesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantCategoriesQueryHandler.cs index 19c9b2f..4ffc992 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantCategoriesQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantCategoriesQueryHandler.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; -using System.Linq; using MediatR; using TakeoutSaaS.Application.App.Merchants.Queries; using TakeoutSaaS.Domain.Merchants.Repositories; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantContractsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantContractsQueryHandler.cs index 46842e1..8ce236b 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantContractsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantContractsQueryHandler.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using MediatR; using TakeoutSaaS.Application.App.Merchants.Dto; using TakeoutSaaS.Application.App.Merchants.Queries; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDocumentsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDocumentsQueryHandler.cs index 3be839c..c93f19c 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDocumentsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDocumentsQueryHandler.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using MediatR; using TakeoutSaaS.Application.App.Merchants.Dto; using TakeoutSaaS.Application.App.Merchants.Queries; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ListMerchantCategoriesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ListMerchantCategoriesQueryHandler.cs index 7da5375..c0ba13b 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ListMerchantCategoriesQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ListMerchantCategoriesQueryHandler.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using MediatR; using TakeoutSaaS.Application.App.Merchants.Dto; using TakeoutSaaS.Application.App.Merchants.Queries; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReorderMerchantCategoriesCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReorderMerchantCategoriesCommandHandler.cs index e06ea65..cb71205 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReorderMerchantCategoriesCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReorderMerchantCategoriesCommandHandler.cs @@ -1,4 +1,3 @@ -using System.Linq; using MediatR; using TakeoutSaaS.Application.App.Merchants.Commands; using TakeoutSaaS.Domain.Merchants.Repositories; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantDocumentCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantDocumentCommandHandler.cs index a8fe365..7260afb 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantDocumentCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantDocumentCommandHandler.cs @@ -1,8 +1,8 @@ using MediatR; using TakeoutSaaS.Application.App.Merchants.Commands; using TakeoutSaaS.Application.App.Merchants.Dto; -using TakeoutSaaS.Domain.Merchants.Enums; using TakeoutSaaS.Domain.Merchants.Entities; +using TakeoutSaaS.Domain.Merchants.Enums; using TakeoutSaaS.Domain.Merchants.Repositories; using TakeoutSaaS.Shared.Abstractions.Constants; using TakeoutSaaS.Shared.Abstractions.Exceptions; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/MerchantMapping.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/MerchantMapping.cs index 8c61873..4b52d54 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/MerchantMapping.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/MerchantMapping.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; -using System.Linq; using TakeoutSaaS.Application.App.Merchants.Dto; using TakeoutSaaS.Domain.Merchants.Entities; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantCategoriesQuery.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantCategoriesQuery.cs index c55fd86..558bcc6 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantCategoriesQuery.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantCategoriesQuery.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using MediatR; namespace TakeoutSaaS.Application.App.Merchants.Queries; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantContractsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantContractsQuery.cs index 8940465..bdf17d6 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantContractsQuery.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantContractsQuery.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using MediatR; using TakeoutSaaS.Application.App.Merchants.Dto; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantDocumentsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantDocumentsQuery.cs index 997f02a..f3dd1bf 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantDocumentsQuery.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantDocumentsQuery.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using MediatR; using TakeoutSaaS.Application.App.Merchants.Dto; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/ListMerchantCategoriesQuery.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/ListMerchantCategoriesQuery.cs index 4e4ef4a..5fa29e9 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/ListMerchantCategoriesQuery.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/ListMerchantCategoriesQuery.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using MediatR; using TakeoutSaaS.Application.App.Merchants.Dto; diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/CreateOrderCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/CreateOrderCommandHandler.cs index 8d38f2c..9b3cc0f 100644 --- a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/CreateOrderCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/CreateOrderCommandHandler.cs @@ -78,6 +78,7 @@ public sealed class CreateOrderCommandHandler( { await orderRepository.AddItemsAsync(items, cancellationToken); } + await orderRepository.SaveChangesAsync(cancellationToken); // 5. 记录日志 diff --git a/src/Application/TakeoutSaaS.Application/App/Payments/Handlers/SearchPaymentsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Payments/Handlers/SearchPaymentsQueryHandler.cs index 9d6c4ae..a265022 100644 --- a/src/Application/TakeoutSaaS.Application/App/Payments/Handlers/SearchPaymentsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Payments/Handlers/SearchPaymentsQueryHandler.cs @@ -1,7 +1,6 @@ using MediatR; using TakeoutSaaS.Application.App.Payments.Dto; using TakeoutSaaS.Application.App.Payments.Queries; -using TakeoutSaaS.Domain.Payments.Entities; using TakeoutSaaS.Domain.Payments.Repositories; using TakeoutSaaS.Shared.Abstractions.Results; using TakeoutSaaS.Shared.Abstractions.Tenancy; diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CheckTenantQuotaCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CheckTenantQuotaCommandHandler.cs index 5fc67aa..169ddbb 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CheckTenantQuotaCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CheckTenantQuotaCommandHandler.cs @@ -1,4 +1,3 @@ -using System; using MediatR; using TakeoutSaaS.Application.App.Tenants.Commands; using TakeoutSaaS.Application.App.Tenants.Dto; diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAuditLogsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAuditLogsQueryHandler.cs index 41d308f..76aafb6 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAuditLogsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAuditLogsQueryHandler.cs @@ -1,4 +1,3 @@ -using System.Linq; using MediatR; using TakeoutSaaS.Application.App.Tenants.Dto; using TakeoutSaaS.Application.App.Tenants.Queries; diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantAnnouncementReadCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantAnnouncementReadCommandHandler.cs index 8c0a06b..1600ecf 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantAnnouncementReadCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantAnnouncementReadCommandHandler.cs @@ -3,8 +3,6 @@ using TakeoutSaaS.Application.App.Tenants.Commands; using TakeoutSaaS.Application.App.Tenants.Dto; using TakeoutSaaS.Domain.Tenants.Entities; 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; diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantAnnouncementsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantAnnouncementsQueryHandler.cs index be2b952..2c15546 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantAnnouncementsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantAnnouncementsQueryHandler.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; -using System.Linq; using MediatR; using TakeoutSaaS.Application.App.Tenants.Dto; using TakeoutSaaS.Application.App.Tenants.Queries; diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantBillsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantBillsQueryHandler.cs index 05b11cb..66821fc 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantBillsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantBillsQueryHandler.cs @@ -1,4 +1,3 @@ -using System.Linq; using MediatR; using TakeoutSaaS.Application.App.Tenants.Dto; using TakeoutSaaS.Application.App.Tenants.Queries; diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantNotificationsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantNotificationsQueryHandler.cs index bab5aca..294a639 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantNotificationsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantNotificationsQueryHandler.cs @@ -1,4 +1,3 @@ -using System.Linq; using MediatR; using TakeoutSaaS.Application.App.Tenants.Dto; using TakeoutSaaS.Application.App.Tenants.Queries; diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantPackagesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantPackagesQueryHandler.cs index 12a2318..21c3ba7 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantPackagesQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantPackagesQueryHandler.cs @@ -1,4 +1,3 @@ -using System.Linq; using MediatR; using TakeoutSaaS.Application.App.Tenants.Dto; using TakeoutSaaS.Application.App.Tenants.Queries; diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantsQueryHandler.cs index 2813e31..755b911 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantsQueryHandler.cs @@ -1,4 +1,3 @@ -using System.Linq; using MediatR; using TakeoutSaaS.Application.App.Tenants.Dto; using TakeoutSaaS.Application.App.Tenants.Queries; diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryCache.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryCache.cs index ebdc59f..f9ea54b 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryCache.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryCache.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Application.Dictionary.Models; namespace TakeoutSaaS.Application.Dictionary.Abstractions; diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/CreateDictionaryItemRequest.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/CreateDictionaryItemRequest.cs index 668d369..b19ccce 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/CreateDictionaryItemRequest.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/CreateDictionaryItemRequest.cs @@ -1,6 +1,6 @@ +using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; using TakeoutSaaS.Shared.Abstractions.Serialization; -using System.ComponentModel.DataAnnotations; namespace TakeoutSaaS.Application.Dictionary.Contracts; diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryGroupDto.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryGroupDto.cs index 95a81f0..cdee6bf 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryGroupDto.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryGroupDto.cs @@ -1,6 +1,5 @@ -using TakeoutSaaS.Domain.Dictionary.Enums; - using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Dictionary.Enums; using TakeoutSaaS.Shared.Abstractions.Serialization; namespace TakeoutSaaS.Application.Dictionary.Models; diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs index 1c8f628..65616a8 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.Extensions.Logging; using TakeoutSaaS.Application.Dictionary.Abstractions; using TakeoutSaaS.Application.Dictionary.Contracts; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminAuthService.cs index d1cd0a7..8961c72 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminAuthService.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminAuthService.cs @@ -1,6 +1,3 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Application.Identity.Contracts; using TakeoutSaaS.Shared.Abstractions.Results; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IJwtTokenService.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IJwtTokenService.cs index 4235181..5bf401e 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IJwtTokenService.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IJwtTokenService.cs @@ -1,5 +1,3 @@ -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Application.Identity.Contracts; namespace TakeoutSaaS.Application.Identity.Abstractions; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/ILoginRateLimiter.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/ILoginRateLimiter.cs index f4a7c5c..66f91a9 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/ILoginRateLimiter.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/ILoginRateLimiter.cs @@ -1,6 +1,3 @@ -using System.Threading; -using System.Threading.Tasks; - namespace TakeoutSaaS.Application.Identity.Abstractions; /// diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IMiniAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IMiniAuthService.cs index c7a509e..8c5250a 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IMiniAuthService.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IMiniAuthService.cs @@ -1,6 +1,3 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Application.Identity.Contracts; namespace TakeoutSaaS.Application.Identity.Abstractions; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IRefreshTokenStore.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IRefreshTokenStore.cs index 29a1bf6..938b4cf 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IRefreshTokenStore.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IRefreshTokenStore.cs @@ -1,6 +1,3 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Application.Identity.Models; namespace TakeoutSaaS.Application.Identity.Abstractions; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IWeChatAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IWeChatAuthService.cs index 417c8b9..c53b72c 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IWeChatAuthService.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IWeChatAuthService.cs @@ -1,6 +1,3 @@ -using System.Threading; -using System.Threading.Tasks; - namespace TakeoutSaaS.Application.Identity.Abstractions; /// diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/CreateRoleTemplateCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/CreateRoleTemplateCommand.cs index 7ca69d1..de2bd4c 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Commands/CreateRoleTemplateCommand.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/CreateRoleTemplateCommand.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using MediatR; using TakeoutSaaS.Application.Identity.Contracts; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/InitializeRoleTemplatesCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/InitializeRoleTemplatesCommand.cs index dc1a583..7eb39f9 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Commands/InitializeRoleTemplatesCommand.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/InitializeRoleTemplatesCommand.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using MediatR; using TakeoutSaaS.Application.Identity.Contracts; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateRoleTemplateCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateRoleTemplateCommand.cs index d88b3ba..ee6d5bf 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateRoleTemplateCommand.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateRoleTemplateCommand.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using MediatR; using TakeoutSaaS.Application.Identity.Contracts; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/RoleTemplateDto.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/RoleTemplateDto.cs index 4690fa2..d3d9415 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Contracts/RoleTemplateDto.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/RoleTemplateDto.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; - namespace TakeoutSaaS.Application.Identity.Contracts; /// diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/UserPermissionDto.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/UserPermissionDto.cs index f3393ad..3506491 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Contracts/UserPermissionDto.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/UserPermissionDto.cs @@ -1,4 +1,3 @@ -using System; using System.Text.Json.Serialization; using TakeoutSaaS.Shared.Abstractions.Serialization; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CopyRoleTemplateCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CopyRoleTemplateCommandHandler.cs index e254b5f..807371a 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CopyRoleTemplateCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CopyRoleTemplateCommandHandler.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; using MediatR; using TakeoutSaaS.Application.Identity.Commands; using TakeoutSaaS.Application.Identity.Contracts; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateRoleTemplateCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateRoleTemplateCommandHandler.cs index b418306..2bec7ea 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateRoleTemplateCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateRoleTemplateCommandHandler.cs @@ -1,5 +1,3 @@ -using System; -using System.Linq; using MediatR; using TakeoutSaaS.Application.Identity.Commands; using TakeoutSaaS.Application.Identity.Contracts; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetRoleTemplateQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetRoleTemplateQueryHandler.cs index 2895c7d..be16a6e 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetRoleTemplateQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetRoleTemplateQueryHandler.cs @@ -1,4 +1,3 @@ -using System.Linq; using MediatR; using TakeoutSaaS.Application.Identity.Contracts; using TakeoutSaaS.Application.Identity.Queries; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetUserPermissionsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetUserPermissionsQueryHandler.cs index d50be4b..915f386 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetUserPermissionsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetUserPermissionsQueryHandler.cs @@ -1,5 +1,3 @@ -using System; -using System.Linq; using MediatR; using TakeoutSaaS.Application.Identity.Contracts; using TakeoutSaaS.Application.Identity.Queries; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/InitializeRoleTemplatesCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/InitializeRoleTemplatesCommandHandler.cs index 483b336..7ec7a0e 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/InitializeRoleTemplatesCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/InitializeRoleTemplatesCommandHandler.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; using MediatR; using TakeoutSaaS.Application.Identity.Commands; using TakeoutSaaS.Application.Identity.Contracts; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/ListRoleTemplatesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ListRoleTemplatesQueryHandler.cs index 571ebf5..e99254b 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/ListRoleTemplatesQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ListRoleTemplatesQueryHandler.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; -using System.Linq; using MediatR; using TakeoutSaaS.Application.Identity.Contracts; using TakeoutSaaS.Application.Identity.Queries; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchPermissionsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchPermissionsQueryHandler.cs index 62a4ce9..df6245b 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchPermissionsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchPermissionsQueryHandler.cs @@ -1,5 +1,3 @@ -using System; -using System.Linq; using MediatR; using TakeoutSaaS.Application.Identity.Contracts; using TakeoutSaaS.Application.Identity.Queries; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchRolesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchRolesQueryHandler.cs index 3e18b69..7309de3 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchRolesQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchRolesQueryHandler.cs @@ -1,5 +1,3 @@ -using System; -using System.Linq; using MediatR; using TakeoutSaaS.Application.Identity.Contracts; using TakeoutSaaS.Application.Identity.Queries; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchUserPermissionsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchUserPermissionsQueryHandler.cs index 599d6fd..4184be8 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchUserPermissionsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchUserPermissionsQueryHandler.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; using MediatR; using TakeoutSaaS.Application.Identity.Contracts; using TakeoutSaaS.Application.Identity.Queries; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/TemplateMapper.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/TemplateMapper.cs index 73e0509..aaf6d71 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/TemplateMapper.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/TemplateMapper.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; -using System.Linq; using TakeoutSaaS.Application.Identity.Contracts; using TakeoutSaaS.Domain.Identity.Entities; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateRoleTemplateCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateRoleTemplateCommandHandler.cs index 1f07395..a98bf6e 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateRoleTemplateCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateRoleTemplateCommandHandler.cs @@ -1,5 +1,3 @@ -using System; -using System.Linq; using MediatR; using TakeoutSaaS.Application.Identity.Commands; using TakeoutSaaS.Application.Identity.Contracts; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Queries/ListRoleTemplatesQuery.cs b/src/Application/TakeoutSaaS.Application/Identity/Queries/ListRoleTemplatesQuery.cs index 7da3def..1b76a6d 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Queries/ListRoleTemplatesQuery.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Queries/ListRoleTemplatesQuery.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using MediatR; using TakeoutSaaS.Application.Identity.Contracts; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs index 4836eb8..72e8f2d 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; using Microsoft.AspNetCore.Identity; using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Contracts; @@ -80,7 +77,7 @@ public sealed class AdminAuthService( // 3. 撤销旧刷新令牌(防止重复使用) await refreshTokenStore.RevokeAsync(descriptor.Token, cancellationToken); - + // 4. 生成新的令牌对 var profile = await BuildProfileAsync(user, cancellationToken); return await jwtTokenService.CreateTokensAsync(profile, false, cancellationToken); diff --git a/src/Application/TakeoutSaaS.Application/Identity/Services/MiniAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Services/MiniAuthService.cs index 5d83289..8234d7e 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Services/MiniAuthService.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Services/MiniAuthService.cs @@ -1,5 +1,5 @@ -using System.Net; using Microsoft.AspNetCore.Http; +using System.Net; using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Contracts; using TakeoutSaaS.Domain.Identity.Entities; @@ -54,7 +54,7 @@ public sealed class MiniAuthService( // 5. 登录成功后重置限流计数 await rateLimiter.ResetAsync(throttleKey, cancellationToken); - + // 6. 构建用户档案并生成令牌 var profile = BuildProfile(user); return await jwtTokenService.CreateTokensAsync(profile, isNew, cancellationToken); @@ -82,7 +82,7 @@ public sealed class MiniAuthService( // 3. 撤销旧刷新令牌(防止重复使用) await refreshTokenStore.RevokeAsync(descriptor.Token, cancellationToken); - + // 4. 生成新的令牌对 var profile = BuildProfile(user); return await jwtTokenService.CreateTokensAsync(profile, false, cancellationToken); diff --git a/src/Application/TakeoutSaaS.Application/Messaging/Abstractions/IEventPublisher.cs b/src/Application/TakeoutSaaS.Application/Messaging/Abstractions/IEventPublisher.cs index b471f0a..c17a6f7 100644 --- a/src/Application/TakeoutSaaS.Application/Messaging/Abstractions/IEventPublisher.cs +++ b/src/Application/TakeoutSaaS.Application/Messaging/Abstractions/IEventPublisher.cs @@ -1,6 +1,3 @@ -using System.Threading; -using System.Threading.Tasks; - namespace TakeoutSaaS.Application.Messaging.Abstractions; /// diff --git a/src/Application/TakeoutSaaS.Application/Messaging/Services/EventPublisher.cs b/src/Application/TakeoutSaaS.Application/Messaging/Services/EventPublisher.cs index 60b04d3..d87dc80 100644 --- a/src/Application/TakeoutSaaS.Application/Messaging/Services/EventPublisher.cs +++ b/src/Application/TakeoutSaaS.Application/Messaging/Services/EventPublisher.cs @@ -1,5 +1,3 @@ -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Application.Messaging.Abstractions; using TakeoutSaaS.Module.Messaging.Abstractions; diff --git a/src/Application/TakeoutSaaS.Application/Sms/Abstractions/IVerificationCodeService.cs b/src/Application/TakeoutSaaS.Application/Sms/Abstractions/IVerificationCodeService.cs index 514b843..4eab7cc 100644 --- a/src/Application/TakeoutSaaS.Application/Sms/Abstractions/IVerificationCodeService.cs +++ b/src/Application/TakeoutSaaS.Application/Sms/Abstractions/IVerificationCodeService.cs @@ -1,5 +1,3 @@ -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Application.Sms.Contracts; namespace TakeoutSaaS.Application.Sms.Abstractions; diff --git a/src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeResponse.cs b/src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeResponse.cs index 5b6cf77..e9f84f1 100644 --- a/src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeResponse.cs +++ b/src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeResponse.cs @@ -1,5 +1,3 @@ -using System; - namespace TakeoutSaaS.Application.Sms.Contracts; /// diff --git a/src/Application/TakeoutSaaS.Application/Sms/Services/VerificationCodeService.cs b/src/Application/TakeoutSaaS.Application/Sms/Services/VerificationCodeService.cs index 70048b1..1fbac0f 100644 --- a/src/Application/TakeoutSaaS.Application/Sms/Services/VerificationCodeService.cs +++ b/src/Application/TakeoutSaaS.Application/Sms/Services/VerificationCodeService.cs @@ -1,15 +1,14 @@ -using System.Security.Cryptography; -using System.Text; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using System.Security.Cryptography; +using System.Text; using TakeoutSaaS.Application.Sms.Abstractions; using TakeoutSaaS.Application.Sms.Contracts; using TakeoutSaaS.Application.Sms.Options; using TakeoutSaaS.Module.Sms.Abstractions; using TakeoutSaaS.Module.Sms.Models; using TakeoutSaaS.Module.Sms.Options; -using TakeoutSaaS.Module.Sms; using TakeoutSaaS.Shared.Abstractions.Constants; using TakeoutSaaS.Shared.Abstractions.Exceptions; using TakeoutSaaS.Shared.Abstractions.Tenancy; diff --git a/src/Application/TakeoutSaaS.Application/Storage/Abstractions/IFileStorageService.cs b/src/Application/TakeoutSaaS.Application/Storage/Abstractions/IFileStorageService.cs index f164c5c..b92f66d 100644 --- a/src/Application/TakeoutSaaS.Application/Storage/Abstractions/IFileStorageService.cs +++ b/src/Application/TakeoutSaaS.Application/Storage/Abstractions/IFileStorageService.cs @@ -1,5 +1,3 @@ -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Application.Storage.Contracts; namespace TakeoutSaaS.Application.Storage.Abstractions; diff --git a/src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadResponse.cs b/src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadResponse.cs index 4989657..de1ab0b 100644 --- a/src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadResponse.cs +++ b/src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadResponse.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; - namespace TakeoutSaaS.Application.Storage.Contracts; /// diff --git a/src/Application/TakeoutSaaS.Application/Storage/Contracts/UploadFileRequest.cs b/src/Application/TakeoutSaaS.Application/Storage/Contracts/UploadFileRequest.cs index 011f39d..43e12f2 100644 --- a/src/Application/TakeoutSaaS.Application/Storage/Contracts/UploadFileRequest.cs +++ b/src/Application/TakeoutSaaS.Application/Storage/Contracts/UploadFileRequest.cs @@ -1,4 +1,3 @@ -using System.IO; using TakeoutSaaS.Application.Storage.Enums; namespace TakeoutSaaS.Application.Storage.Contracts; diff --git a/src/Application/TakeoutSaaS.Application/Storage/Extensions/UploadFileTypeParser.cs b/src/Application/TakeoutSaaS.Application/Storage/Extensions/UploadFileTypeParser.cs index dd712ba..89efe3d 100644 --- a/src/Application/TakeoutSaaS.Application/Storage/Extensions/UploadFileTypeParser.cs +++ b/src/Application/TakeoutSaaS.Application/Storage/Extensions/UploadFileTypeParser.cs @@ -1,4 +1,3 @@ -using System; using TakeoutSaaS.Application.Storage.Enums; namespace TakeoutSaaS.Application.Storage.Extensions; diff --git a/src/Application/TakeoutSaaS.Application/Storage/Services/FileStorageService.cs b/src/Application/TakeoutSaaS.Application/Storage/Services/FileStorageService.cs index 6ce4e2c..a887bf9 100644 --- a/src/Application/TakeoutSaaS.Application/Storage/Services/FileStorageService.cs +++ b/src/Application/TakeoutSaaS.Application/Storage/Services/FileStorageService.cs @@ -1,13 +1,7 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Security.Cryptography; -using System.Text; -using System.Threading; -using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using System.Security.Cryptography; +using System.Text; using TakeoutSaaS.Application.Storage.Abstractions; using TakeoutSaaS.Application.Storage.Contracts; using TakeoutSaaS.Application.Storage.Enums; diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Data/IDapperExecutor.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Data/IDapperExecutor.cs index 3783a93..0423468 100644 --- a/src/Core/TakeoutSaaS.Shared.Abstractions/Data/IDapperExecutor.cs +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Data/IDapperExecutor.cs @@ -29,6 +29,7 @@ public interface IDapperExecutor /// 连接角色(读/写)。 /// 命令委托,提供已打开的连接和取消标记。 /// 取消标记。 + /// 异步执行任务。 Task ExecuteAsync( string dataSourceName, DatabaseConnectionRole role, diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Diagnostics/TraceContext.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Diagnostics/TraceContext.cs index ad8aa43..ae8284b 100644 --- a/src/Core/TakeoutSaaS.Shared.Abstractions/Diagnostics/TraceContext.cs +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Diagnostics/TraceContext.cs @@ -1,5 +1,3 @@ -using System.Threading; - namespace TakeoutSaaS.Shared.Abstractions.Diagnostics; /// diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Exceptions/ValidationException.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Exceptions/ValidationException.cs index a87def6..f95bf0b 100644 --- a/src/Core/TakeoutSaaS.Shared.Abstractions/Exceptions/ValidationException.cs +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Exceptions/ValidationException.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; - namespace TakeoutSaaS.Shared.Abstractions.Exceptions; /// diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.NonGeneric.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.NonGeneric.cs index d3e8d6d..04f400c 100644 --- a/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.NonGeneric.cs +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.NonGeneric.cs @@ -8,24 +8,36 @@ public static class ApiResponse /// /// 仅返回成功消息(无数据)。 /// + /// 提示信息。 + /// 封装后的成功响应。 public static ApiResponse Success(string? message = "操作成功") => ApiResponse.Ok(message: message); /// /// 成功且携带数据。 /// + /// 业务数据。 + /// 提示信息。 + /// 封装后的成功响应。 public static ApiResponse Ok(object? data, string? message = "操作成功") => data is null ? ApiResponse.Ok(message: message) : ApiResponse.Ok(data, message); /// /// 错误返回。 /// + /// 错误码。 + /// 错误提示。 + /// 封装后的失败响应。 public static ApiResponse Failure(int code, string message) => ApiResponse.Error(code, message); /// /// 错误返回(附带详情)。 /// + /// 错误码。 + /// 错误提示。 + /// 错误详情。 + /// 封装后的失败响应。 public static ApiResponse Error(int code, string message, object? errors = null) => ApiResponse.Error(code, message, errors); } diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.cs index 62365b3..f89a1b7 100644 --- a/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.cs +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.cs @@ -47,36 +47,53 @@ public sealed record ApiResponse /// /// 成功返回。 /// + /// 业务数据。 + /// 提示信息。 + /// 封装后的成功响应。 public static ApiResponse Ok(T data, string? message = "操作成功") => Create(true, 200, message, data); /// /// 无数据的成功返回。 /// + /// 提示信息。 + /// 封装后的成功响应。 public static ApiResponse Ok(string? message = "操作成功") => Create(true, 200, message, default); /// /// 兼容旧名称:成功结果。 /// + /// 业务数据。 + /// 提示信息。 + /// 封装后的成功响应。 public static ApiResponse SuccessResult(T data, string? message = "操作成功") => Ok(data, message); /// /// 错误返回。 /// + /// 错误码。 + /// 错误提示。 + /// 错误详情。 + /// 封装后的失败响应。 public static ApiResponse Error(int code, string message, object? errors = null) => Create(false, code, message, default, errors); /// /// 兼容旧名称:失败结果。 /// + /// 错误码。 + /// 错误提示。 + /// 封装后的失败响应。 public static ApiResponse Failure(int code, string message) => Error(code, message); /// /// 附加错误详情。 /// + /// 错误详情。 + /// 包含错误详情的新响应。 public ApiResponse WithErrors(object? errors) => this with { Errors = errors }; @@ -95,6 +112,7 @@ public sealed record ApiResponse /// /// 解析当前 TraceId。 /// + /// 当前有效的 TraceId。 private static string ResolveTraceId() { if (!string.IsNullOrWhiteSpace(TraceContext.TraceId)) @@ -142,6 +160,7 @@ internal sealed class IdFallbackGenerator /// /// 生成雪花风格的本地备用 ID。 /// + /// 本地生成的雪花 ID。 public long NextId() { lock (_sync) @@ -168,6 +187,8 @@ internal sealed class IdFallbackGenerator /// /// 等待到下一个毫秒以避免序列冲突。 /// + /// 上一毫秒的时间戳。 + /// 下一个时间戳(毫秒)。 private static long WaitNextMillis(long lastTimestamp) { var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Results/PagedResult.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/PagedResult.cs index 69c5ba4..4bc4c7e 100644 --- a/src/Core/TakeoutSaaS.Shared.Abstractions/Results/PagedResult.cs +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/PagedResult.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; - namespace TakeoutSaaS.Shared.Abstractions.Results; /// diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantProvider.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantProvider.cs index 02c818a..76358db 100644 --- a/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantProvider.cs +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantProvider.cs @@ -6,7 +6,8 @@ namespace TakeoutSaaS.Shared.Abstractions.Tenancy; public interface ITenantProvider { /// - /// 获取当前租户 ID,未解析时返回 Guid.Empty。 + /// 获取当前租户 ID,未解析时返回 0。 /// + /// 当前请求绑定的租户 ID,未解析时为 0。 long GetCurrentTenantId(); } diff --git a/src/Core/TakeoutSaaS.Shared.Kernel/Ids/SnowflakeIdGenerator.cs b/src/Core/TakeoutSaaS.Shared.Kernel/Ids/SnowflakeIdGenerator.cs index 06d22fb..862edb9 100644 --- a/src/Core/TakeoutSaaS.Shared.Kernel/Ids/SnowflakeIdGenerator.cs +++ b/src/Core/TakeoutSaaS.Shared.Kernel/Ids/SnowflakeIdGenerator.cs @@ -1,6 +1,5 @@ using System.Diagnostics; using System.Security.Cryptography; -using System.Threading; using TakeoutSaaS.Shared.Abstractions.Ids; namespace TakeoutSaaS.Shared.Kernel.Ids; diff --git a/src/Core/TakeoutSaaS.Shared.Web/Extensions/ServiceCollectionExtensions.cs b/src/Core/TakeoutSaaS.Shared.Web/Extensions/ServiceCollectionExtensions.cs index dd5f1e3..7ac65ce 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/TakeoutSaaS.Shared.Web/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.Extensions.DependencyInjection; using TakeoutSaaS.Shared.Abstractions.Security; using TakeoutSaaS.Shared.Web.Filters; diff --git a/src/Core/TakeoutSaaS.Shared.Web/Filters/ValidateModelAttribute.cs b/src/Core/TakeoutSaaS.Shared.Web/Filters/ValidateModelAttribute.cs index 4c802e0..c6ad12f 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/Filters/ValidateModelAttribute.cs +++ b/src/Core/TakeoutSaaS.Shared.Web/Filters/ValidateModelAttribute.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using TakeoutSaaS.Shared.Abstractions.Constants; diff --git a/src/Core/TakeoutSaaS.Shared.Web/Middleware/CorrelationIdMiddleware.cs b/src/Core/TakeoutSaaS.Shared.Web/Middleware/CorrelationIdMiddleware.cs index e1dadd9..eb7d0a9 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/Middleware/CorrelationIdMiddleware.cs +++ b/src/Core/TakeoutSaaS.Shared.Web/Middleware/CorrelationIdMiddleware.cs @@ -1,9 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; +using System.Diagnostics; using TakeoutSaaS.Shared.Abstractions.Diagnostics; using TakeoutSaaS.Shared.Abstractions.Ids; @@ -49,10 +46,10 @@ public sealed class CorrelationIdMiddleware(RequestDelegate next, ILogger - { - ["TraceId"] = traceId, - ["SpanId"] = spanId - })) + { + ["TraceId"] = traceId, + ["SpanId"] = spanId + })) { try { diff --git a/src/Core/TakeoutSaaS.Shared.Web/Middleware/ExceptionHandlingMiddleware.cs b/src/Core/TakeoutSaaS.Shared.Web/Middleware/ExceptionHandlingMiddleware.cs index 2babea2..c52d364 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/Middleware/ExceptionHandlingMiddleware.cs +++ b/src/Core/TakeoutSaaS.Shared.Web/Middleware/ExceptionHandlingMiddleware.cs @@ -1,10 +1,8 @@ -using System; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using System.Text.Json; +using System.Text.Json.Serialization; using TakeoutSaaS.Shared.Abstractions.Constants; using TakeoutSaaS.Shared.Abstractions.Exceptions; using TakeoutSaaS.Shared.Abstractions.Results; diff --git a/src/Core/TakeoutSaaS.Shared.Web/Middleware/RequestLoggingMiddleware.cs b/src/Core/TakeoutSaaS.Shared.Web/Middleware/RequestLoggingMiddleware.cs index 9ec3592..7162688 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/Middleware/RequestLoggingMiddleware.cs +++ b/src/Core/TakeoutSaaS.Shared.Web/Middleware/RequestLoggingMiddleware.cs @@ -1,7 +1,6 @@ -using System.Diagnostics; -using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; +using System.Diagnostics; using TakeoutSaaS.Shared.Abstractions.Diagnostics; namespace TakeoutSaaS.Shared.Web.Middleware; diff --git a/src/Core/TakeoutSaaS.Shared.Web/Security/ClaimsPrincipalExtensions.cs b/src/Core/TakeoutSaaS.Shared.Web/Security/ClaimsPrincipalExtensions.cs index 05a90b2..630789b 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/Security/ClaimsPrincipalExtensions.cs +++ b/src/Core/TakeoutSaaS.Shared.Web/Security/ClaimsPrincipalExtensions.cs @@ -1,4 +1,3 @@ -using System; using System.Security.Claims; namespace TakeoutSaaS.Shared.Web.Security; diff --git a/src/Core/TakeoutSaaS.Shared.Web/Security/HttpContextCurrentUserAccessor.cs b/src/Core/TakeoutSaaS.Shared.Web/Security/HttpContextCurrentUserAccessor.cs index 3d5c423..bc43ea8 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/Security/HttpContextCurrentUserAccessor.cs +++ b/src/Core/TakeoutSaaS.Shared.Web/Security/HttpContextCurrentUserAccessor.cs @@ -1,5 +1,5 @@ -using System.Security.Claims; using Microsoft.AspNetCore.Http; +using System.Security.Claims; using TakeoutSaaS.Shared.Abstractions.Security; namespace TakeoutSaaS.Shared.Web.Security; diff --git a/src/Core/TakeoutSaaS.Shared.Web/Swagger/ConfigureSwaggerOptions.cs b/src/Core/TakeoutSaaS.Shared.Web/Swagger/ConfigureSwaggerOptions.cs index e167f00..68590fc 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/Swagger/ConfigureSwaggerOptions.cs +++ b/src/Core/TakeoutSaaS.Shared.Web/Swagger/ConfigureSwaggerOptions.cs @@ -1,6 +1,4 @@ -using System; -using Microsoft.AspNetCore.Mvc.ApiExplorer; -using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Microsoft.OpenApi; diff --git a/src/Domain/TakeoutSaaS.Domain/Deliveries/Repositories/IDeliveryRepository.cs b/src/Domain/TakeoutSaaS.Domain/Deliveries/Repositories/IDeliveryRepository.cs index 27d5c95..f97f67e 100644 --- a/src/Domain/TakeoutSaaS.Domain/Deliveries/Repositories/IDeliveryRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Deliveries/Repositories/IDeliveryRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Deliveries.Entities; using TakeoutSaaS.Domain.Deliveries.Enums; @@ -14,45 +11,77 @@ public interface IDeliveryRepository /// /// 依据标识获取配送单。 /// + /// 配送单 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 配送单实体或 null。 Task FindByIdAsync(long deliveryOrderId, long tenantId, CancellationToken cancellationToken = default); /// /// 依据订单标识获取配送单。 /// + /// 订单 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 配送单实体或 null。 Task FindByOrderIdAsync(long orderId, long tenantId, CancellationToken cancellationToken = default); /// /// 获取配送事件轨迹。 /// + /// 配送单 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 配送事件列表。 Task> GetEventsAsync(long deliveryOrderId, long tenantId, CancellationToken cancellationToken = default); /// /// 新增配送单。 /// + /// 配送单实体。 + /// 取消标记。 + /// 异步任务。 Task AddDeliveryOrderAsync(DeliveryOrder deliveryOrder, CancellationToken cancellationToken = default); /// /// 新增配送事件。 /// + /// 配送事件。 + /// 取消标记。 + /// 异步任务。 Task AddEventAsync(DeliveryEvent deliveryEvent, CancellationToken cancellationToken = default); /// /// 持久化变更。 /// + /// 取消标记。 + /// 异步任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); /// /// 按状态查询配送单。 /// + /// 租户 ID。 + /// 配送状态。 + /// 订单 ID。 + /// 取消标记。 + /// 配送单列表。 Task> SearchAsync(long tenantId, DeliveryStatus? status, long? orderId, CancellationToken cancellationToken = default); /// /// 更新配送单。 /// + /// 配送单实体。 + /// 取消标记。 + /// 异步任务。 Task UpdateDeliveryOrderAsync(DeliveryOrder deliveryOrder, CancellationToken cancellationToken = default); /// /// 删除配送单及事件。 /// + /// 配送单 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 异步任务。 Task DeleteDeliveryOrderAsync(long deliveryOrderId, long tenantId, CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryGroup.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryGroup.cs index 68694b0..bf48fca 100644 --- a/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryGroup.cs +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryGroup.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using TakeoutSaaS.Domain.Dictionary.Enums; using TakeoutSaaS.Shared.Abstractions.Entities; diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/IDictionaryRepository.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/IDictionaryRepository.cs index 9a9b427..d0d4294 100644 --- a/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/IDictionaryRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/IDictionaryRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Dictionary.Entities; using TakeoutSaaS.Domain.Dictionary.Enums; @@ -14,55 +11,89 @@ public interface IDictionaryRepository /// /// 依据 ID 获取分组。 /// + /// 分组 ID。 + /// 取消标记。 + /// 分组实体或 null。 Task FindGroupByIdAsync(long id, CancellationToken cancellationToken = default); /// /// 依据编码获取分组。 /// + /// 分组编码。 + /// 取消标记。 + /// 分组实体或 null。 Task FindGroupByCodeAsync(string code, CancellationToken cancellationToken = default); /// /// 搜索分组,可按作用域过滤。 /// + /// 作用域。 + /// 取消标记。 + /// 分组集合。 Task> SearchGroupsAsync(DictionaryScope? scope, CancellationToken cancellationToken = default); /// /// 新增分组。 /// + /// 分组实体。 + /// 取消标记。 + /// 异步任务。 Task AddGroupAsync(DictionaryGroup group, CancellationToken cancellationToken = default); /// /// 删除分组。 /// + /// 分组实体。 + /// 取消标记。 + /// 异步任务。 Task RemoveGroupAsync(DictionaryGroup group, CancellationToken cancellationToken = default); /// /// 依据 ID 获取字典项。 /// + /// 字典项 ID。 + /// 取消标记。 + /// 字典项或 null。 Task FindItemByIdAsync(long id, CancellationToken cancellationToken = default); /// /// 获取某分组下的所有字典项。 /// + /// 分组 ID。 + /// 取消标记。 + /// 字典项集合。 Task> GetItemsByGroupIdAsync(long groupId, CancellationToken cancellationToken = default); /// /// 按分组编码集合获取字典项(可包含系统参数)。 /// + /// 分组编码集合。 + /// 租户 ID。 + /// 是否包含系统分组。 + /// 取消标记。 + /// 字典项集合。 Task> GetItemsByCodesAsync(IEnumerable codes, long tenantId, bool includeSystem, CancellationToken cancellationToken = default); /// /// 新增字典项。 /// + /// 字典项实体。 + /// 取消标记。 + /// 异步任务。 Task AddItemAsync(DictionaryItem item, CancellationToken cancellationToken = default); /// /// 删除字典项。 /// + /// 字典项实体。 + /// 取消标记。 + /// 异步任务。 Task RemoveItemAsync(DictionaryItem item, CancellationToken cancellationToken = default); /// /// 持久化更改。 /// + /// 取消标记。 + /// 异步任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs index 5b809cf..cc3a497 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Identity.Entities; namespace TakeoutSaaS.Domain.Identity.Repositories; @@ -14,11 +10,17 @@ public interface IIdentityUserRepository /// /// 根据账号获取后台用户。 /// + /// 账号。 + /// 取消标记。 + /// 后台用户或 null。 Task FindByAccountAsync(string account, CancellationToken cancellationToken = default); /// /// 根据 ID 获取后台用户。 /// + /// 用户 ID。 + /// 取消标记。 + /// 后台用户或 null。 Task FindByIdAsync(long userId, CancellationToken cancellationToken = default); /// @@ -27,10 +29,15 @@ public interface IIdentityUserRepository /// 租户 ID。 /// 可选关键字(账号/名称)。 /// 取消标记。 + /// 后台用户列表。 Task> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default); /// /// 获取指定租户、用户集合对应的用户(只读)。 /// + /// 租户 ID。 + /// 用户 ID 集合。 + /// 取消标记。 + /// 后台用户列表。 Task> GetByIdsAsync(long tenantId, IEnumerable userIds, CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IPermissionRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IPermissionRepository.cs index b6e7833..d643394 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IPermissionRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IPermissionRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Identity.Entities; namespace TakeoutSaaS.Domain.Identity.Repositories; diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRolePermissionRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRolePermissionRepository.cs index 3f45d5c..4502d1e 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRolePermissionRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRolePermissionRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Identity.Entities; namespace TakeoutSaaS.Domain.Identity.Repositories; diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleRepository.cs index f740fce..726a513 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Identity.Entities; namespace TakeoutSaaS.Domain.Identity.Repositories; diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleTemplateRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleTemplateRepository.cs index 3a9ce61..f175722 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleTemplateRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleTemplateRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Identity.Entities; namespace TakeoutSaaS.Domain.Identity.Repositories; diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IUserRoleRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IUserRoleRepository.cs index 68c0915..6759d08 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IUserRoleRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IUserRoleRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Identity.Entities; namespace TakeoutSaaS.Domain.Identity.Repositories; diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantCategoryRepository.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantCategoryRepository.cs index f8669d6..cabdd80 100644 --- a/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantCategoryRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantCategoryRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Merchants.Entities; namespace TakeoutSaaS.Domain.Merchants.Repositories; @@ -13,35 +10,57 @@ public interface IMerchantCategoryRepository /// /// 列出当前租户的类目。 /// + /// 租户 ID。 + /// 取消标记。 + /// 类目列表。 Task> ListAsync(long tenantId, CancellationToken cancellationToken = default); /// /// 是否存在同名类目。 /// + /// 类目名称。 + /// 租户 ID。 + /// 取消标记。 + /// 存在返回 true。 Task ExistsAsync(string name, long tenantId, CancellationToken cancellationToken = default); /// /// 查找类目。 /// + /// 类目 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 类目实体或 null。 Task FindByIdAsync(long id, long tenantId, CancellationToken cancellationToken = default); /// /// 新增类目。 /// + /// 类目实体。 + /// 取消标记。 + /// 异步任务。 Task AddAsync(MerchantCategory category, CancellationToken cancellationToken = default); /// /// 删除类目。 /// + /// 类目实体。 + /// 取消标记。 + /// 异步任务。 Task RemoveAsync(MerchantCategory category, CancellationToken cancellationToken = default); /// /// 批量更新类目信息。 /// + /// 类目集合。 + /// 取消标记。 + /// 异步任务。 Task UpdateRangeAsync(IEnumerable categories, CancellationToken cancellationToken = default); /// /// 持久化更改。 /// + /// 取消标记。 + /// 异步任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantRepository.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantRepository.cs index 3a7df9b..1e1ee19 100644 --- a/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Merchants.Entities; using TakeoutSaaS.Domain.Merchants.Enums; @@ -14,21 +11,37 @@ public interface IMerchantRepository /// /// 依据标识获取商户。 /// + /// 商户 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 商户实体或 null。 Task FindByIdAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default); /// /// 按状态筛选商户列表。 /// + /// 租户 ID。 + /// 状态过滤。 + /// 取消标记。 + /// 商户集合。 Task> SearchAsync(long tenantId, MerchantStatus? status, CancellationToken cancellationToken = default); /// /// 获取指定商户的员工列表。 /// + /// 商户 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 员工集合。 Task> GetStaffAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default); /// /// 获取指定商户的合同列表。 /// + /// 商户 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 合同集合。 Task> GetContractsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default); /// @@ -44,6 +57,10 @@ public interface IMerchantRepository /// /// 获取指定商户的资质文件列表。 /// + /// 商户 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 资质文件列表。 Task> GetDocumentsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default); /// @@ -59,16 +76,25 @@ public interface IMerchantRepository /// /// 新增商户主体。 /// + /// 商户实体。 + /// 取消标记。 + /// 异步任务。 Task AddMerchantAsync(Merchant merchant, CancellationToken cancellationToken = default); /// /// 新增商户员工。 /// + /// 员工实体。 + /// 取消标记。 + /// 异步任务。 Task AddStaffAsync(MerchantStaff staff, CancellationToken cancellationToken = default); /// /// 新增商户合同。 /// + /// 合同实体。 + /// 取消标记。 + /// 异步任务。 Task AddContractAsync(MerchantContract contract, CancellationToken cancellationToken = default); /// @@ -82,6 +108,9 @@ public interface IMerchantRepository /// /// 新增商户资质文件。 /// + /// 资质文件实体。 + /// 取消标记。 + /// 异步任务。 Task AddDocumentAsync(MerchantDocument document, CancellationToken cancellationToken = default); /// @@ -95,25 +124,41 @@ public interface IMerchantRepository /// /// 持久化变更。 /// + /// 取消标记。 + /// 异步任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); /// /// 更新商户信息。 /// + /// 商户实体。 + /// 取消标记。 + /// 异步任务。 Task UpdateMerchantAsync(Merchant merchant, CancellationToken cancellationToken = default); /// /// 删除商户。 /// + /// 商户 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 异步任务。 Task DeleteMerchantAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default); /// /// 记录审核日志。 /// + /// 审核日志实体。 + /// 取消标记。 + /// 异步任务。 Task AddAuditLogAsync(MerchantAuditLog log, CancellationToken cancellationToken = default); /// /// 获取审核日志。 /// + /// 商户 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 审核日志列表。 Task> GetAuditLogsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Orders/Repositories/IOrderRepository.cs b/src/Domain/TakeoutSaaS.Domain/Orders/Repositories/IOrderRepository.cs index d48ecf3..96a3bd6 100644 --- a/src/Domain/TakeoutSaaS.Domain/Orders/Repositories/IOrderRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Orders/Repositories/IOrderRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Orders.Entities; using TakeoutSaaS.Domain.Orders.Enums; using TakeoutSaaS.Domain.Payments.Enums; @@ -15,65 +12,111 @@ public interface IOrderRepository /// /// 依据标识获取订单。 /// + /// 订单 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 订单实体或 null。 Task FindByIdAsync(long orderId, long tenantId, CancellationToken cancellationToken = default); /// /// 依据订单号获取订单。 /// + /// 订单号。 + /// 租户 ID。 + /// 取消标记。 + /// 订单实体或 null。 Task FindByOrderNoAsync(string orderNo, long tenantId, CancellationToken cancellationToken = default); /// /// 按状态筛选订单列表。 /// + /// 租户 ID。 + /// 订单状态。 + /// 支付状态。 + /// 取消标记。 + /// 订单集合。 Task> SearchAsync(long tenantId, OrderStatus? status, PaymentStatus? paymentStatus, CancellationToken cancellationToken = default); /// /// 获取订单明细行。 /// + /// 订单 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 订单明细集合。 Task> GetItemsAsync(long orderId, long tenantId, CancellationToken cancellationToken = default); /// /// 获取订单状态流转记录。 /// + /// 订单 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 状态变更记录列表。 Task> GetStatusHistoryAsync(long orderId, long tenantId, CancellationToken cancellationToken = default); /// /// 获取订单退款申请。 /// + /// 订单 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 退款申请列表。 Task> GetRefundsAsync(long orderId, long tenantId, CancellationToken cancellationToken = default); /// /// 新增订单。 /// + /// 订单实体。 + /// 取消标记。 + /// 异步任务。 Task AddOrderAsync(Order order, CancellationToken cancellationToken = default); /// /// 新增订单明细。 /// + /// 明细集合。 + /// 取消标记。 + /// 异步任务。 Task AddItemsAsync(IEnumerable items, CancellationToken cancellationToken = default); /// /// 新增订单状态记录。 /// + /// 状态记录。 + /// 取消标记。 + /// 异步任务。 Task AddStatusHistoryAsync(OrderStatusHistory history, CancellationToken cancellationToken = default); /// /// 新增退款申请。 /// + /// 退款申请实体。 + /// 取消标记。 + /// 异步任务。 Task AddRefundAsync(RefundRequest refund, CancellationToken cancellationToken = default); /// /// 持久化变更。 /// + /// 取消标记。 + /// 异步任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); /// /// 更新订单。 /// + /// 订单实体。 + /// 取消标记。 + /// 异步任务。 Task UpdateOrderAsync(Order order, CancellationToken cancellationToken = default); /// /// 删除订单。 /// + /// 订单 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 异步任务。 Task DeleteOrderAsync(long orderId, long tenantId, CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Payments/Repositories/IPaymentRepository.cs b/src/Domain/TakeoutSaaS.Domain/Payments/Repositories/IPaymentRepository.cs index f12c937..7983a5a 100644 --- a/src/Domain/TakeoutSaaS.Domain/Payments/Repositories/IPaymentRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Payments/Repositories/IPaymentRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Payments.Entities; using TakeoutSaaS.Domain.Payments.Enums; @@ -14,45 +11,76 @@ public interface IPaymentRepository /// /// 依据标识获取支付记录。 /// + /// 支付记录 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 支付记录或 null。 Task FindByIdAsync(long paymentId, long tenantId, CancellationToken cancellationToken = default); /// /// 依据订单标识获取支付记录。 /// + /// 订单 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 支付记录或 null。 Task FindByOrderIdAsync(long orderId, long tenantId, CancellationToken cancellationToken = default); /// /// 获取支付对应的退款记录。 /// + /// 支付记录 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 退款记录列表。 Task> GetRefundsAsync(long paymentId, long tenantId, CancellationToken cancellationToken = default); /// /// 新增支付记录。 /// + /// 支付实体。 + /// 取消标记。 + /// 异步任务。 Task AddPaymentAsync(PaymentRecord payment, CancellationToken cancellationToken = default); /// /// 新增退款记录。 /// + /// 退款实体。 + /// 取消标记。 + /// 异步任务。 Task AddRefundAsync(PaymentRefundRecord refund, CancellationToken cancellationToken = default); /// /// 持久化变更。 /// + /// 取消标记。 + /// 异步任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); /// /// 按状态筛选支付记录。 /// + /// 租户 ID。 + /// 支付状态。 + /// 取消标记。 + /// 支付记录列表。 Task> SearchAsync(long tenantId, PaymentStatus? status, CancellationToken cancellationToken = default); /// /// 更新支付记录。 /// + /// 支付实体。 + /// 取消标记。 + /// 异步任务。 Task UpdatePaymentAsync(PaymentRecord payment, CancellationToken cancellationToken = default); /// /// 删除支付记录及关联退款。 /// + /// 支付记录 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 异步任务。 Task DeletePaymentAsync(long paymentId, long tenantId, CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs b/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs index 6f01804..1cc8155 100644 --- a/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Products.Entities; using TakeoutSaaS.Domain.Products.Enums; @@ -14,135 +11,234 @@ public interface IProductRepository /// /// 依据标识获取商品。 /// + /// 商品 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 商品实体或 null。 Task FindByIdAsync(long productId, long tenantId, CancellationToken cancellationToken = default); /// /// 按分类与状态筛选商品列表。 /// + /// 租户 ID。 + /// 分类 ID。 + /// 商品状态。 + /// 取消标记。 + /// 商品集合。 Task> SearchAsync(long tenantId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default); /// /// 获取租户下的商品分类。 /// + /// 租户 ID。 + /// 取消标记。 + /// 分类集合。 Task> GetCategoriesAsync(long tenantId, CancellationToken cancellationToken = default); /// /// 获取商品 SKU。 /// + /// 商品 ID。 + /// 租户 ID。 + /// 取消标记。 + /// SKU 集合。 Task> GetSkusAsync(long productId, long tenantId, CancellationToken cancellationToken = default); /// /// 获取商品加料组与选项。 /// + /// 商品 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 加料组集合。 Task> GetAddonGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); /// /// 获取商品加料选项。 /// + /// 商品 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 加料选项集合。 Task> GetAddonOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); /// /// 获取商品规格组与选项。 /// + /// 商品 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 规格组集合。 Task> GetAttributeGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); /// /// 获取商品规格选项。 /// + /// 商品 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 规格选项集合。 Task> GetAttributeOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); /// /// 获取商品媒资。 /// + /// 商品 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 媒资列表。 Task> GetMediaAssetsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); /// /// 获取商品定价规则。 /// + /// 商品 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 定价规则集合。 Task> GetPricingRulesAsync(long productId, long tenantId, CancellationToken cancellationToken = default); /// /// 新增分类。 /// + /// 分类实体。 + /// 取消标记。 + /// 异步任务。 Task AddCategoryAsync(ProductCategory category, CancellationToken cancellationToken = default); /// /// 新增商品。 /// + /// 商品实体。 + /// 取消标记。 + /// 异步任务。 Task AddProductAsync(Product product, CancellationToken cancellationToken = default); /// /// 新增 SKU。 /// + /// SKU 集合。 + /// 取消标记。 + /// 异步任务。 Task AddSkusAsync(IEnumerable skus, CancellationToken cancellationToken = default); /// /// 新增加料组与选项。 /// + /// 加料组集合。 + /// 加料选项集合。 + /// 取消标记。 + /// 异步任务。 Task AddAddonGroupsAsync(IEnumerable groups, IEnumerable options, CancellationToken cancellationToken = default); /// /// 新增规格组与选项。 /// + /// 规格组集合。 + /// 规格选项集合。 + /// 取消标记。 + /// 异步任务。 Task AddAttributeGroupsAsync(IEnumerable groups, IEnumerable options, CancellationToken cancellationToken = default); /// /// 新增媒资。 /// + /// 媒资集合。 + /// 取消标记。 + /// 异步任务。 Task AddMediaAssetsAsync(IEnumerable assets, CancellationToken cancellationToken = default); /// /// 新增定价规则。 /// + /// 定价规则集合。 + /// 取消标记。 + /// 异步任务。 Task AddPricingRulesAsync(IEnumerable rules, CancellationToken cancellationToken = default); /// /// 持久化变更。 /// + /// 取消标记。 + /// 异步任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); /// /// 更新商品。 /// + /// 商品实体。 + /// 取消标记。 + /// 异步任务。 Task UpdateProductAsync(Product product, CancellationToken cancellationToken = default); /// /// 删除商品。 /// + /// 商品 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 异步任务。 Task DeleteProductAsync(long productId, long tenantId, CancellationToken cancellationToken = default); /// /// 更新分类。 /// + /// 分类实体。 + /// 取消标记。 + /// 异步任务。 Task UpdateCategoryAsync(ProductCategory category, CancellationToken cancellationToken = default); /// /// 删除分类。 /// + /// 分类 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 异步任务。 Task DeleteCategoryAsync(long categoryId, long tenantId, CancellationToken cancellationToken = default); /// /// 删除商品下的 SKU。 /// + /// 商品 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 异步任务。 Task RemoveSkusAsync(long productId, long tenantId, CancellationToken cancellationToken = default); /// /// 删除商品下的加料组及选项。 /// + /// 商品 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 异步任务。 Task RemoveAddonGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); /// /// 删除商品下的规格组及选项。 /// + /// 商品 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 异步任务。 Task RemoveAttributeGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); /// /// 删除商品媒资。 /// + /// 商品 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 异步任务。 Task RemoveMediaAssetsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); /// /// 删除商品定价规则。 /// + /// 商品 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 异步任务。 Task RemovePricingRulesAsync(long productId, long tenantId, CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs index bbbf7e0..c7ecd4a 100644 --- a/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Stores.Entities; using TakeoutSaaS.Domain.Stores.Enums; @@ -14,90 +11,152 @@ public interface IStoreRepository /// /// 依据标识获取门店。 /// + /// 门店 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 门店实体或 null。 Task FindByIdAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); /// /// 按租户筛选门店列表。 /// + /// 租户 ID。 + /// 状态过滤。 + /// 取消标记。 + /// 门店集合。 Task> SearchAsync(long tenantId, StoreStatus? status, CancellationToken cancellationToken = default); /// /// 获取门店营业时段。 /// + /// 门店 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 营业时段列表。 Task> GetBusinessHoursAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); /// /// 获取门店配送区域配置。 /// + /// 门店 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 配送区域列表。 Task> GetDeliveryZonesAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); /// /// 获取门店节假日配置。 /// + /// 门店 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 节假日配置列表。 Task> GetHolidaysAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); /// /// 获取门店桌台区域。 /// + /// 门店 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 桌台区域列表。 Task> GetTableAreasAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); /// /// 获取门店桌台列表。 /// + /// 门店 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 桌台列表。 Task> GetTablesAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); /// /// 获取门店员工排班。 /// + /// 门店 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 排班列表。 Task> GetShiftsAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); /// /// 新增门店。 /// + /// 门店实体。 + /// 取消标记。 + /// 异步任务。 Task AddStoreAsync(Store store, CancellationToken cancellationToken = default); /// /// 新增营业时段。 /// + /// 营业时段集合。 + /// 取消标记。 + /// 异步任务。 Task AddBusinessHoursAsync(IEnumerable hours, CancellationToken cancellationToken = default); /// /// 新增配送区域。 /// + /// 配送区域集合。 + /// 取消标记。 + /// 异步任务。 Task AddDeliveryZonesAsync(IEnumerable zones, CancellationToken cancellationToken = default); /// /// 新增节假日配置。 /// + /// 节假日集合。 + /// 取消标记。 + /// 异步任务。 Task AddHolidaysAsync(IEnumerable holidays, CancellationToken cancellationToken = default); /// /// 新增桌台区域。 /// + /// 桌台区域集合。 + /// 取消标记。 + /// 异步任务。 Task AddTableAreasAsync(IEnumerable areas, CancellationToken cancellationToken = default); /// /// 新增桌台。 /// + /// 桌台集合。 + /// 取消标记。 + /// 异步任务。 Task AddTablesAsync(IEnumerable tables, CancellationToken cancellationToken = default); /// /// 新增排班。 /// + /// 排班集合。 + /// 取消标记。 + /// 异步任务。 Task AddShiftsAsync(IEnumerable shifts, CancellationToken cancellationToken = default); /// /// 持久化变更。 /// + /// 取消标记。 + /// 异步任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); /// /// 更新门店。 /// + /// 门店实体。 + /// 取消标记。 + /// 异步任务。 Task UpdateStoreAsync(Store store, CancellationToken cancellationToken = default); /// /// 删除门店。 /// + /// 门店 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 异步任务。 Task DeleteStoreAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/SystemParameters/Repositories/ISystemParameterRepository.cs b/src/Domain/TakeoutSaaS.Domain/SystemParameters/Repositories/ISystemParameterRepository.cs index c222342..ced9e53 100644 --- a/src/Domain/TakeoutSaaS.Domain/SystemParameters/Repositories/ISystemParameterRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/SystemParameters/Repositories/ISystemParameterRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.SystemParameters.Entities; namespace TakeoutSaaS.Domain.SystemParameters.Repositories; @@ -13,35 +10,56 @@ public interface ISystemParameterRepository /// /// 根据标识获取系统参数。 /// + /// 参数 ID。 + /// 取消标记。 + /// 系统参数或 null。 Task FindByIdAsync(long id, CancellationToken cancellationToken = default); /// /// 根据键获取系统参数(当前租户)。 /// + /// 参数键。 + /// 取消标记。 + /// 系统参数或 null。 Task FindByKeyAsync(string key, CancellationToken cancellationToken = default); /// /// 查询系统参数列表。 /// + /// 关键字。 + /// 启用状态。 + /// 取消标记。 + /// 参数列表。 Task> SearchAsync(string? keyword, bool? isEnabled, CancellationToken cancellationToken = default); /// /// 新增系统参数。 /// + /// 参数实体。 + /// 取消标记。 + /// 异步任务。 Task AddAsync(SystemParameter parameter, CancellationToken cancellationToken = default); /// /// 删除系统参数。 /// + /// 参数实体。 + /// 取消标记。 + /// 异步任务。 Task RemoveAsync(SystemParameter parameter, CancellationToken cancellationToken = default); /// /// 更新系统参数。 /// + /// 参数实体。 + /// 取消标记。 + /// 异步任务。 Task UpdateAsync(SystemParameter parameter, CancellationToken cancellationToken = default); /// /// 持久化更改。 /// + /// 取消标记。 + /// 异步任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementReadRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementReadRepository.cs index ac76bc8..53af993 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementReadRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementReadRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Tenants.Entities; namespace TakeoutSaaS.Domain.Tenants.Repositories; @@ -44,11 +41,13 @@ public interface ITenantAnnouncementReadRepository /// /// 已读实体。 /// 取消标记。 + /// 异步任务。 Task AddAsync(TenantAnnouncementRead record, CancellationToken cancellationToken = default); /// /// 保存变更。 /// /// 取消标记。 + /// 异步任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementRepository.cs index c3e34b6..2314d10 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Tenants.Entities; using TakeoutSaaS.Domain.Tenants.Enums; @@ -41,6 +38,7 @@ public interface ITenantAnnouncementRepository /// /// 公告实体。 /// 取消标记。 + /// 异步任务。 Task AddAsync(TenantAnnouncement announcement, CancellationToken cancellationToken = default); /// @@ -48,6 +46,7 @@ public interface ITenantAnnouncementRepository /// /// 公告实体。 /// 取消标记。 + /// 异步任务。 Task UpdateAsync(TenantAnnouncement announcement, CancellationToken cancellationToken = default); /// @@ -56,11 +55,13 @@ public interface ITenantAnnouncementRepository /// 租户 ID。 /// 公告 ID。 /// 取消标记。 + /// 异步任务。 Task DeleteAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default); /// /// 保存变更。 /// /// 取消标记。 + /// 异步任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs index d4ddfa8..9edf1a4 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Tenants.Entities; using TakeoutSaaS.Domain.Tenants.Enums; @@ -50,6 +47,7 @@ public interface ITenantBillingRepository /// /// 账单实体。 /// 取消标记。 + /// 异步任务。 Task AddAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default); /// @@ -57,11 +55,13 @@ public interface ITenantBillingRepository /// /// 账单实体。 /// 取消标记。 + /// 异步任务。 Task UpdateAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default); /// /// 保存变更。 /// /// 取消标记。 + /// 异步任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantNotificationRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantNotificationRepository.cs index b66093d..c81f613 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantNotificationRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantNotificationRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Tenants.Entities; using TakeoutSaaS.Domain.Tenants.Enums; @@ -43,6 +40,7 @@ public interface ITenantNotificationRepository /// /// 通知实体。 /// 取消标记。 + /// 异步任务。 Task AddAsync(TenantNotification notification, CancellationToken cancellationToken = default); /// @@ -50,11 +48,13 @@ public interface ITenantNotificationRepository /// /// 通知实体。 /// 取消标记。 + /// 异步任务。 Task UpdateAsync(TenantNotification notification, CancellationToken cancellationToken = default); /// /// 保存变更。 /// /// 取消标记。 + /// 异步任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPackageRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPackageRepository.cs index 05e9f9f..4fe61d5 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPackageRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPackageRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Tenants.Entities; namespace TakeoutSaaS.Domain.Tenants.Repositories; @@ -32,6 +29,7 @@ public interface ITenantPackageRepository /// /// 套餐实体。 /// 取消标记。 + /// 异步任务。 Task AddAsync(TenantPackage package, CancellationToken cancellationToken = default); /// @@ -39,6 +37,7 @@ public interface ITenantPackageRepository /// /// 套餐实体。 /// 取消标记。 + /// 异步任务。 Task UpdateAsync(TenantPackage package, CancellationToken cancellationToken = default); /// @@ -46,11 +45,13 @@ public interface ITenantPackageRepository /// /// 套餐 ID(雪花算法)。 /// 取消标记。 + /// 异步任务。 Task DeleteAsync(long id, CancellationToken cancellationToken = default); /// /// 持久化。 /// /// 取消标记。 + /// 异步任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantQuotaUsageRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantQuotaUsageRepository.cs index 07eae39..ff1b791 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantQuotaUsageRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantQuotaUsageRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Tenants.Entities; using TakeoutSaaS.Domain.Tenants.Enums; @@ -33,6 +30,7 @@ public interface ITenantQuotaUsageRepository /// /// 配额使用实体。 /// 取消标记。 + /// 异步任务。 Task AddAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default); /// @@ -40,11 +38,13 @@ public interface ITenantQuotaUsageRepository /// /// 配额使用实体。 /// 取消标记。 + /// 异步任务。 Task UpdateAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default); /// /// 持久化。 /// /// 取消标记。 + /// 异步任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs index 33dedf9..c396852 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs @@ -1,5 +1,3 @@ -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Tenants.Entities; using TakeoutSaaS.Domain.Tenants.Enums; @@ -35,6 +33,7 @@ public interface ITenantRepository /// /// 租户实体。 /// 取消标记。 + /// 异步任务。 Task AddTenantAsync(Tenant tenant, CancellationToken cancellationToken = default); /// @@ -42,6 +41,7 @@ public interface ITenantRepository /// /// 租户实体。 /// 取消标记。 + /// 异步任务。 Task UpdateTenantAsync(Tenant tenant, CancellationToken cancellationToken = default); /// @@ -65,6 +65,7 @@ public interface ITenantRepository /// /// 实名资料实体。 /// 取消标记。 + /// 异步任务。 Task UpsertVerificationProfileAsync(TenantVerificationProfile profile, CancellationToken cancellationToken = default); /// @@ -89,6 +90,7 @@ public interface ITenantRepository /// /// 订阅实体。 /// 取消标记。 + /// 异步任务。 Task AddSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default); /// @@ -96,6 +98,7 @@ public interface ITenantRepository /// /// 订阅实体。 /// 取消标记。 + /// 异步任务。 Task UpdateSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default); /// @@ -103,6 +106,7 @@ public interface ITenantRepository /// /// 订阅历史实体。 /// 取消标记。 + /// 异步任务。 Task AddSubscriptionHistoryAsync(TenantSubscriptionHistory history, CancellationToken cancellationToken = default); /// @@ -118,6 +122,7 @@ public interface ITenantRepository /// /// 审核日志实体。 /// 取消标记。 + /// 异步任务。 Task AddAuditLogAsync(TenantAuditLog log, CancellationToken cancellationToken = default); /// @@ -132,5 +137,6 @@ public interface ITenantRepository /// 持久化。 /// /// 取消标记。 + /// 异步任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs b/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs index c087384..6011bd9 100644 --- a/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs +++ b/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs @@ -1,16 +1,13 @@ -using System.Diagnostics; -using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.HttpOverrides; -using Microsoft.AspNetCore.RateLimiting; using OpenTelemetry.Logs; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; using Serilog; -using TakeoutSaaS.ApiGateway.Configuration; +using System.Diagnostics; using System.Threading.RateLimiting; +using TakeoutSaaS.ApiGateway.Configuration; const string CorsPolicyName = "GatewayCors"; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/AppSeedOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/AppSeedOptions.cs index b3ec66d..4099e12 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/AppSeedOptions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/AppSeedOptions.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; - namespace TakeoutSaaS.Infrastructure.App.Options; /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/DictionarySeedGroupOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/DictionarySeedGroupOptions.cs index 2cec142..c59ebb4 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/DictionarySeedGroupOptions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/DictionarySeedGroupOptions.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using TakeoutSaaS.Domain.Dictionary.Enums; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/AppDataSeeder.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/AppDataSeeder.cs index 4072f0c..bd3fca8 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/AppDataSeeder.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/AppDataSeeder.cs @@ -1,11 +1,9 @@ -using System.Linq; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using TakeoutSaaS.Domain.Dictionary.Entities; -using TakeoutSaaS.Domain.Dictionary.Enums; using TakeoutSaaS.Domain.SystemParameters.Entities; using TakeoutSaaS.Domain.Tenants.Entities; using TakeoutSaaS.Domain.Tenants.Enums; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfDeliveryRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfDeliveryRepository.cs index 2a67038..3bc734d 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfDeliveryRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfDeliveryRepository.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Deliveries.Entities; using TakeoutSaaS.Domain.Deliveries.Enums; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantCategoryRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantCategoryRepository.cs index 8b83a3e..f832908 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantCategoryRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantCategoryRepository.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; -using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Merchants.Entities; using TakeoutSaaS.Domain.Merchants.Repositories; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs index cef1a73..7aa7c65 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Merchants.Entities; using TakeoutSaaS.Domain.Merchants.Enums; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfOrderRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfOrderRepository.cs index b5a2c7f..ba663d4 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfOrderRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfOrderRepository.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Orders.Entities; using TakeoutSaaS.Domain.Orders.Enums; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfPaymentRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfPaymentRepository.cs index 4178313..1b6ea8d 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfPaymentRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfPaymentRepository.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Payments.Entities; using TakeoutSaaS.Domain.Payments.Enums; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs index 244578a..a888990 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Products.Entities; using TakeoutSaaS.Domain.Products.Enums; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs index 53f6b4f..c813978 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Stores.Entities; using TakeoutSaaS.Domain.Stores.Enums; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementReadRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementReadRepository.cs index 94b26a2..23236ae 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementReadRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementReadRepository.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Tenants.Entities; using TakeoutSaaS.Domain.Tenants.Repositories; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementRepository.cs index 404152f..5ed5afd 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementRepository.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Tenants.Entities; using TakeoutSaaS.Domain.Tenants.Enums; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantBillingRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantBillingRepository.cs index 3083de5..7b939d5 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantBillingRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantBillingRepository.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Tenants.Entities; using TakeoutSaaS.Domain.Tenants.Enums; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantNotificationRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantNotificationRepository.cs index 56cea39..27417cd 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantNotificationRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantNotificationRepository.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Tenants.Entities; using TakeoutSaaS.Domain.Tenants.Enums; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantPackageRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantPackageRepository.cs index 0a2cd22..d23b429 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantPackageRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantPackageRepository.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Tenants.Entities; using TakeoutSaaS.Domain.Tenants.Repositories; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantQuotaUsageRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantQuotaUsageRepository.cs index 778bada..dd9c564 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantQuotaUsageRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantQuotaUsageRepository.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Tenants.Entities; using TakeoutSaaS.Domain.Tenants.Enums; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs index 9c4ab49..850046d 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Tenants.Entities; using TakeoutSaaS.Domain.Tenants.Enums; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Extensions/DatabaseServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Extensions/DatabaseServiceCollectionExtensions.cs index 4f50569..e9940a0 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Extensions/DatabaseServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Extensions/DatabaseServiceCollectionExtensions.cs @@ -3,8 +3,8 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using TakeoutSaaS.Infrastructure.Common.Options; using TakeoutSaaS.Infrastructure.Common.Persistence; -using TakeoutSaaS.Shared.Abstractions.Ids; using TakeoutSaaS.Shared.Abstractions.Data; +using TakeoutSaaS.Shared.Abstractions.Ids; using TakeoutSaaS.Shared.Kernel.Ids; namespace TakeoutSaaS.Infrastructure.Common.Extensions; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/AppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/AppDbContext.cs index 4733274..1f06200 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/AppDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/AppDbContext.cs @@ -1,9 +1,9 @@ -using System.Reflection; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; +using System.Reflection; using TakeoutSaaS.Shared.Abstractions.Entities; -using TakeoutSaaS.Shared.Abstractions.Security; using TakeoutSaaS.Shared.Abstractions.Ids; +using TakeoutSaaS.Shared.Abstractions.Security; namespace TakeoutSaaS.Infrastructure.Common.Persistence; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DapperExecutor.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DapperExecutor.cs index e0761aa..d9defe1 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DapperExecutor.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DapperExecutor.cs @@ -1,6 +1,6 @@ -using System.Data; using Microsoft.Extensions.Logging; using Npgsql; +using System.Data; using TakeoutSaaS.Shared.Abstractions.Data; namespace TakeoutSaaS.Infrastructure.Common.Persistence; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DatabaseConnectionFactory.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DatabaseConnectionFactory.cs index 7852aaa..54fee08 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DatabaseConnectionFactory.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DatabaseConnectionFactory.cs @@ -1,9 +1,8 @@ -using System.ComponentModel.DataAnnotations; -using System.Linq; -using System.Security.Cryptography; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using System.ComponentModel.DataAnnotations; +using System.Security.Cryptography; using TakeoutSaaS.Infrastructure.Common.Options; using TakeoutSaaS.Shared.Abstractions.Data; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DesignTime/DesignTimeDbContextFactoryBase.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DesignTime/DesignTimeDbContextFactoryBase.cs index 5a2cf5f..a2dcba9 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DesignTime/DesignTimeDbContextFactoryBase.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DesignTime/DesignTimeDbContextFactoryBase.cs @@ -1,6 +1,3 @@ -using System; -using System.IO; -using System.Linq; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; using Microsoft.Extensions.Configuration; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/ModelBuilderCommentExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/ModelBuilderCommentExtensions.cs index e143c8f..193409c 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/ModelBuilderCommentExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/ModelBuilderCommentExtensions.cs @@ -1,8 +1,8 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; using System.Collections.Concurrent; using System.Reflection; using System.Xml.Linq; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata; namespace TakeoutSaaS.Infrastructure.Common.Persistence; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/TenantAwareDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/TenantAwareDbContext.cs index f743d24..66e0f41 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/TenantAwareDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/TenantAwareDbContext.cs @@ -1,9 +1,9 @@ -using System.Reflection; using Microsoft.EntityFrameworkCore; +using System.Reflection; using TakeoutSaaS.Shared.Abstractions.Entities; +using TakeoutSaaS.Shared.Abstractions.Ids; using TakeoutSaaS.Shared.Abstractions.Security; using TakeoutSaaS.Shared.Abstractions.Tenancy; -using TakeoutSaaS.Shared.Abstractions.Ids; namespace TakeoutSaaS.Infrastructure.Common.Persistence; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs index 41cc530..0055d45 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs @@ -1,16 +1,14 @@ -using System; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using TakeoutSaaS.Application.Dictionary.Abstractions; using TakeoutSaaS.Domain.Dictionary.Repositories; +using TakeoutSaaS.Domain.SystemParameters.Repositories; using TakeoutSaaS.Infrastructure.Common.Extensions; -using TakeoutSaaS.Infrastructure.Common.Options; using TakeoutSaaS.Infrastructure.Dictionary.Options; using TakeoutSaaS.Infrastructure.Dictionary.Persistence; using TakeoutSaaS.Infrastructure.Dictionary.Repositories; using TakeoutSaaS.Infrastructure.Dictionary.Services; using TakeoutSaaS.Shared.Abstractions.Constants; -using TakeoutSaaS.Domain.SystemParameters.Repositories; namespace TakeoutSaaS.Infrastructure.Dictionary.Extensions; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfDictionaryRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfDictionaryRepository.cs index 64e8608..4ce7005 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfDictionaryRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfDictionaryRepository.cs @@ -1,9 +1,8 @@ -using System.Linq; using Microsoft.EntityFrameworkCore; -using TakeoutSaaS.Infrastructure.Dictionary.Persistence; using TakeoutSaaS.Domain.Dictionary.Entities; using TakeoutSaaS.Domain.Dictionary.Enums; using TakeoutSaaS.Domain.Dictionary.Repositories; +using TakeoutSaaS.Infrastructure.Dictionary.Persistence; namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfSystemParameterRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfSystemParameterRepository.cs index 1ec7868..d7e515c 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfSystemParameterRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfSystemParameterRepository.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.SystemParameters.Entities; using TakeoutSaaS.Domain.SystemParameters.Repositories; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Services/DistributedDictionaryCache.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Services/DistributedDictionaryCache.cs index 372c467..4897f82 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Services/DistributedDictionaryCache.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Services/DistributedDictionaryCache.cs @@ -1,6 +1,6 @@ -using System.Text.Json; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Options; +using System.Text.Json; using TakeoutSaaS.Application.Dictionary.Abstractions; using TakeoutSaaS.Application.Dictionary.Models; using TakeoutSaaS.Infrastructure.Dictionary.Options; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/JwtAuthenticationExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/JwtAuthenticationExtensions.cs index 79c475c..b346321 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/JwtAuthenticationExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/JwtAuthenticationExtensions.cs @@ -1,11 +1,10 @@ -using System; -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Text; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; using TakeoutSaaS.Infrastructure.Identity.Options; namespace TakeoutSaaS.Infrastructure.Identity.Extensions; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs index d98587f..ef2fd47 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs @@ -1,12 +1,9 @@ -using System; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using TakeoutSaaS.Application.Identity.Abstractions; -using TakeoutSaaS.Domain.Identity.Entities; using TakeoutSaaS.Domain.Identity.Repositories; using TakeoutSaaS.Infrastructure.Common.Extensions; -using TakeoutSaaS.Infrastructure.Common.Options; using TakeoutSaaS.Infrastructure.Identity.Options; using TakeoutSaaS.Infrastructure.Identity.Persistence; using TakeoutSaaS.Infrastructure.Identity.Services; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs index 02701f8..487428f 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Identity.Entities; using TakeoutSaaS.Domain.Identity.Repositories; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfMiniUserRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfMiniUserRepository.cs index 83c7b61..78c3d52 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfMiniUserRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfMiniUserRepository.cs @@ -1,6 +1,3 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Identity.Entities; using TakeoutSaaS.Domain.Identity.Repositories; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfPermissionRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfPermissionRepository.cs index 37b7a0d..b8dbafc 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfPermissionRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfPermissionRepository.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Identity.Entities; using TakeoutSaaS.Domain.Identity.Repositories; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRolePermissionRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRolePermissionRepository.cs index 30376da..0ba4322 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRolePermissionRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRolePermissionRepository.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Identity.Entities; using TakeoutSaaS.Domain.Identity.Repositories; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRoleTemplateRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRoleTemplateRepository.cs index e8c071b..2f77700 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRoleTemplateRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRoleTemplateRepository.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; -using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Identity.Entities; using TakeoutSaaS.Domain.Identity.Repositories; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs index f4ec0a0..01c8da2 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs @@ -1,5 +1,3 @@ -using System; -using System.Linq; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs index 854b265..268a7c0 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using TakeoutSaaS.Domain.Identity.Entities; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/JwtTokenService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/JwtTokenService.cs index 78d3f0e..71c3080 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/JwtTokenService.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/JwtTokenService.cs @@ -1,8 +1,8 @@ +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Tokens; using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Contracts; using TakeoutSaaS.Infrastructure.Identity.Options; @@ -32,7 +32,7 @@ public sealed class JwtTokenService(IRefreshTokenStore refreshTokenStore, IOptio // 1. 构建 JWT Claims(包含用户 ID、账号、租户 ID、商户 ID、角色、权限等) var claims = BuildClaims(profile); - + // 2. 创建签名凭据(使用 HMAC SHA256 算法) var signingCredentials = new SigningCredentials( new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Secret)), @@ -49,7 +49,7 @@ public sealed class JwtTokenService(IRefreshTokenStore refreshTokenStore, IOptio // 4. 序列化 JWT 为字符串 var accessToken = _tokenHandler.WriteToken(jwt); - + // 5. 生成刷新令牌并存储到 Redis var refreshDescriptor = await refreshTokenStore.IssueAsync(profile.UserId, refreshExpires, cancellationToken); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisLoginRateLimiter.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisLoginRateLimiter.cs index e997c2e..3516290 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisLoginRateLimiter.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisLoginRateLimiter.cs @@ -1,6 +1,3 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Options; using TakeoutSaaS.Application.Identity.Abstractions; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisRefreshTokenStore.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisRefreshTokenStore.cs index 36105e9..6c5651e 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisRefreshTokenStore.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisRefreshTokenStore.cs @@ -1,10 +1,7 @@ -using System; -using System.Security.Cryptography; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Options; +using System.Security.Cryptography; +using System.Text.Json; using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Models; using TakeoutSaaS.Infrastructure.Identity.Options; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/WeChatAuthService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/WeChatAuthService.cs index 1bd8b1b..bbc6328 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/WeChatAuthService.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/WeChatAuthService.cs @@ -1,10 +1,6 @@ -using System; -using System.Net.Http; +using Microsoft.Extensions.Options; using System.Net.Http.Json; using System.Text.Json.Serialization; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Options; using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Infrastructure.Identity.Options; using TakeoutSaaS.Shared.Abstractions.Constants; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251202005208_InitSnowflake_App.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251202005208_InitSnowflake_App.cs index 4b31527..6e26a56 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251202005208_InitSnowflake_App.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251202005208_InitSnowflake_App.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251202005247_InitSnowflake_Dictionary.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251202005247_InitSnowflake_Dictionary.cs index 374be49..7a1aa27 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251202005247_InitSnowflake_Dictionary.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251202005247_InitSnowflake_Dictionary.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251202043204_AddSystemParametersTable.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251202043204_AddSystemParametersTable.cs index 816fe03..27d688a 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251202043204_AddSystemParametersTable.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251202043204_AddSystemParametersTable.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251202005226_InitSnowflake_Identity.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251202005226_InitSnowflake_Identity.cs index 772ad91..4893332 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251202005226_InitSnowflake_Identity.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251202005226_InitSnowflake_Identity.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251202084523_AddRbacModel.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251202084523_AddRbacModel.cs index aa64905..42a404e 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251202084523_AddRbacModel.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251202084523_AddRbacModel.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable diff --git a/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationHandler.cs b/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationHandler.cs index b8df4fe..780e58d 100644 --- a/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationHandler.cs +++ b/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationHandler.cs @@ -1,6 +1,3 @@ -using System; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; namespace TakeoutSaaS.Module.Authorization.Policies; diff --git a/src/Modules/TakeoutSaaS.Module.Delivery/TakeoutSaaS.Module.Delivery.csproj b/src/Modules/TakeoutSaaS.Module.Delivery/TakeoutSaaS.Module.Delivery.csproj index b407eac..ff77596 100644 --- a/src/Modules/TakeoutSaaS.Module.Delivery/TakeoutSaaS.Module.Delivery.csproj +++ b/src/Modules/TakeoutSaaS.Module.Delivery/TakeoutSaaS.Module.Delivery.csproj @@ -4,6 +4,9 @@ enable enable + + + diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessagePublisher.cs b/src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessagePublisher.cs index 666c554..456be39 100644 --- a/src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessagePublisher.cs +++ b/src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessagePublisher.cs @@ -1,6 +1,3 @@ -using System.Threading; -using System.Threading.Tasks; - namespace TakeoutSaaS.Module.Messaging.Abstractions; /// diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessageSubscriber.cs b/src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessageSubscriber.cs index 1e7e0bd..685c523 100644 --- a/src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessageSubscriber.cs +++ b/src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessageSubscriber.cs @@ -1,7 +1,3 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - namespace TakeoutSaaS.Module.Messaging.Abstractions; /// diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessagePublisher.cs b/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessagePublisher.cs index 6506acb..3f2178c 100644 --- a/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessagePublisher.cs +++ b/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessagePublisher.cs @@ -1,5 +1,3 @@ -using System; -using System.Text; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using RabbitMQ.Client; diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Abstractions/IRecurringJobRegistrar.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Abstractions/IRecurringJobRegistrar.cs index 79f5a29..198da74 100644 --- a/src/Modules/TakeoutSaaS.Module.Scheduler/Abstractions/IRecurringJobRegistrar.cs +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Abstractions/IRecurringJobRegistrar.cs @@ -1,6 +1,3 @@ -using System.Threading; -using System.Threading.Tasks; - namespace TakeoutSaaS.Module.Scheduler.Abstractions; /// diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Abstractions/ISmsSender.cs b/src/Modules/TakeoutSaaS.Module.Sms/Abstractions/ISmsSender.cs index 5cf7dd5..01380db 100644 --- a/src/Modules/TakeoutSaaS.Module.Sms/Abstractions/ISmsSender.cs +++ b/src/Modules/TakeoutSaaS.Module.Sms/Abstractions/ISmsSender.cs @@ -1,5 +1,3 @@ -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Module.Sms.Models; namespace TakeoutSaaS.Module.Sms.Abstractions; diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendRequest.cs b/src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendRequest.cs index 643f299..fe3b333 100644 --- a/src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendRequest.cs +++ b/src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendRequest.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; - namespace TakeoutSaaS.Module.Sms.Models; /// diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Options/SmsOptions.cs b/src/Modules/TakeoutSaaS.Module.Sms/Options/SmsOptions.cs index 5520760..32c3ada 100644 --- a/src/Modules/TakeoutSaaS.Module.Sms/Options/SmsOptions.cs +++ b/src/Modules/TakeoutSaaS.Module.Sms/Options/SmsOptions.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using TakeoutSaaS.Module.Sms; namespace TakeoutSaaS.Module.Sms.Options; diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Services/AliyunSmsSender.cs b/src/Modules/TakeoutSaaS.Module.Sms/Services/AliyunSmsSender.cs index 166d66e..11a4b2e 100644 --- a/src/Modules/TakeoutSaaS.Module.Sms/Services/AliyunSmsSender.cs +++ b/src/Modules/TakeoutSaaS.Module.Sms/Services/AliyunSmsSender.cs @@ -1,4 +1,3 @@ -using System.Net.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using TakeoutSaaS.Module.Sms.Abstractions; diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Services/SmsSenderResolver.cs b/src/Modules/TakeoutSaaS.Module.Sms/Services/SmsSenderResolver.cs index e47f6be..2d5906b 100644 --- a/src/Modules/TakeoutSaaS.Module.Sms/Services/SmsSenderResolver.cs +++ b/src/Modules/TakeoutSaaS.Module.Sms/Services/SmsSenderResolver.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; using Microsoft.Extensions.Options; using TakeoutSaaS.Module.Sms.Abstractions; using TakeoutSaaS.Module.Sms.Options; diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Services/TencentSmsSender.cs b/src/Modules/TakeoutSaaS.Module.Sms/Services/TencentSmsSender.cs index 72f8083..ee14a8f 100644 --- a/src/Modules/TakeoutSaaS.Module.Sms/Services/TencentSmsSender.cs +++ b/src/Modules/TakeoutSaaS.Module.Sms/Services/TencentSmsSender.cs @@ -1,10 +1,9 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using System.Globalization; -using System.Net.Http; using System.Security.Cryptography; using System.Text; using System.Text.Json; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using TakeoutSaaS.Module.Sms.Abstractions; using TakeoutSaaS.Module.Sms.Models; using TakeoutSaaS.Module.Sms.Options; diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Abstractions/IObjectStorageProvider.cs b/src/Modules/TakeoutSaaS.Module.Storage/Abstractions/IObjectStorageProvider.cs index c53009c..41b6e31 100644 --- a/src/Modules/TakeoutSaaS.Module.Storage/Abstractions/IObjectStorageProvider.cs +++ b/src/Modules/TakeoutSaaS.Module.Storage/Abstractions/IObjectStorageProvider.cs @@ -1,5 +1,3 @@ -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Module.Storage.Models; namespace TakeoutSaaS.Module.Storage.Abstractions; diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadResult.cs b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadResult.cs index 8bfade5..15d11dd 100644 --- a/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadResult.cs +++ b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadResult.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; - namespace TakeoutSaaS.Module.Storage.Models; /// diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageUploadRequest.cs b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageUploadRequest.cs index b718c18..795a614 100644 --- a/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageUploadRequest.cs +++ b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageUploadRequest.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.IO; - namespace TakeoutSaaS.Module.Storage.Models; /// diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Providers/AliyunOssStorageProvider.cs b/src/Modules/TakeoutSaaS.Module.Storage/Providers/AliyunOssStorageProvider.cs index b12fd4b..0244720 100644 --- a/src/Modules/TakeoutSaaS.Module.Storage/Providers/AliyunOssStorageProvider.cs +++ b/src/Modules/TakeoutSaaS.Module.Storage/Providers/AliyunOssStorageProvider.cs @@ -1,10 +1,4 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading; -using System.Threading.Tasks; using Aliyun.OSS; -using Aliyun.OSS.Util; using Microsoft.Extensions.Options; using TakeoutSaaS.Module.Storage.Abstractions; using TakeoutSaaS.Module.Storage.Models; diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Providers/QiniuKodoStorageProvider.cs b/src/Modules/TakeoutSaaS.Module.Storage/Providers/QiniuKodoStorageProvider.cs index df6b05d..bccb657 100644 --- a/src/Modules/TakeoutSaaS.Module.Storage/Providers/QiniuKodoStorageProvider.cs +++ b/src/Modules/TakeoutSaaS.Module.Storage/Providers/QiniuKodoStorageProvider.cs @@ -1,4 +1,3 @@ -using System; using Microsoft.Extensions.Options; using TakeoutSaaS.Module.Storage.Options; diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Providers/S3StorageProviderBase.cs b/src/Modules/TakeoutSaaS.Module.Storage/Providers/S3StorageProviderBase.cs index f42e34c..0745830 100644 --- a/src/Modules/TakeoutSaaS.Module.Storage/Providers/S3StorageProviderBase.cs +++ b/src/Modules/TakeoutSaaS.Module.Storage/Providers/S3StorageProviderBase.cs @@ -1,8 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Amazon; using Amazon.Runtime; using Amazon.S3; using Amazon.S3.Model; diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Providers/TencentCosStorageProvider.cs b/src/Modules/TakeoutSaaS.Module.Storage/Providers/TencentCosStorageProvider.cs index fdd7795..259d6e4 100644 --- a/src/Modules/TakeoutSaaS.Module.Storage/Providers/TencentCosStorageProvider.cs +++ b/src/Modules/TakeoutSaaS.Module.Storage/Providers/TencentCosStorageProvider.cs @@ -1,4 +1,3 @@ -using System; using Microsoft.Extensions.Options; using TakeoutSaaS.Module.Storage.Options; diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Services/StorageProviderResolver.cs b/src/Modules/TakeoutSaaS.Module.Storage/Services/StorageProviderResolver.cs index 598d1ee..b1920c0 100644 --- a/src/Modules/TakeoutSaaS.Module.Storage/Services/StorageProviderResolver.cs +++ b/src/Modules/TakeoutSaaS.Module.Storage/Services/StorageProviderResolver.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; using Microsoft.Extensions.Options; using TakeoutSaaS.Module.Storage.Abstractions; using TakeoutSaaS.Module.Storage.Options; diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantContextAccessor.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantContextAccessor.cs index 2a7f7ab..b3b0589 100644 --- a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantContextAccessor.cs +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantContextAccessor.cs @@ -1,4 +1,3 @@ -using System.Threading; using TakeoutSaaS.Shared.Abstractions.Tenancy; namespace TakeoutSaaS.Module.Tenancy; diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionMiddleware.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionMiddleware.cs index a5f6530..46c9770 100644 --- a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionMiddleware.cs +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionMiddleware.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionOptions.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionOptions.cs index 7cd928d..e43afa3 100644 --- a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionOptions.cs +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionOptions.cs @@ -33,7 +33,7 @@ public sealed class TenantResolutionOptions /// 根域(不含子域),用于形如 {tenant}.rootDomain 的场景,例如 admin.takeoutsaas.com。 /// public string? RootDomain { get; set; } - + /// /// 需要跳过租户解析的路径集合(如健康检查),默认仅包含 /health。 /// diff --git a/stylecop.json b/stylecop.json new file mode 100644 index 0000000..c5f59ab --- /dev/null +++ b/stylecop.json @@ -0,0 +1,7 @@ +{ + "settings": { + "documentationRules": { + "documentationCulture": "zh-CN" + } + } +} From 23b69f6f556eeb16c3456c7d006e0b74f45ae562 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Thu, 4 Dec 2025 14:23:04 +0800 Subject: [PATCH 29/30] =?UTF-8?q?docs:=20=E5=AE=8C=E5=96=84AdminApi?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E5=99=A8=E6=B3=A8=E9=87=8A=E5=92=8C=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .../Controllers/FilesController.cs | 1 + .../Controllers/MerchantsController.cs | 10 ++++++++++ .../Controllers/OrdersController.cs | 5 +++++ .../Controllers/PaymentsController.cs | 5 +++++ .../Controllers/ProductsController.cs | 5 +++++ .../Controllers/RolesController.cs | 12 ++++++++++++ .../Controllers/StoresController.cs | 5 +++++ .../Controllers/SystemParametersController.cs | 5 +++++ .../Controllers/TenantAnnouncementsController.cs | 6 ++++++ .../Controllers/TenantBillingsController.cs | 4 ++++ .../Controllers/TenantNotificationsController.cs | 2 ++ .../Controllers/TenantsController.cs | 9 +++++++++ 13 files changed, 70 insertions(+) diff --git a/.gitignore b/.gitignore index 3857e65..16baddd 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ bin/ obj/ **/bin/ **/obj/ +.claude/ diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/FilesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/FilesController.cs index a6d77a4..cd92566 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/FilesController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/FilesController.cs @@ -20,6 +20,7 @@ public sealed class FilesController(IFileStorageService fileStorageService) : Ba /// /// 上传图片或文件。 /// + /// 文件上传响应信息。 [HttpPost("upload")] [RequestFormLimits(MultipartBodyLengthLimit = 30 * 1024 * 1024)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs index b886c5c..c5b52d0 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs @@ -146,6 +146,7 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController /// /// 获取商户详细资料(含证照、合同)。 /// + /// 创建的证照信息。 [HttpGet("{merchantId:long}/detail")] [PermissionAuthorize("merchant:read")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -161,6 +162,7 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController /// /// 上传商户证照信息(先通过文件上传接口获取 COS 地址)。 /// + /// 创建的证照信息。 [HttpPost("{merchantId:long}/documents")] [PermissionAuthorize("merchant:update")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -180,6 +182,7 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController /// /// 商户证照列表。 /// + /// 商户证照列表。 [HttpGet("{merchantId:long}/documents")] [PermissionAuthorize("merchant:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -195,6 +198,7 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController /// /// 审核指定证照。 /// + /// 审核后的证照信息。 [HttpPost("{merchantId:long}/documents/{documentId:long}/review")] [PermissionAuthorize("merchant:review")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -215,6 +219,7 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController /// /// 新增商户合同。 /// + /// 创建的合同信息。 [HttpPost("{merchantId:long}/contracts")] [PermissionAuthorize("merchant:update")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -234,6 +239,7 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController /// /// 合同列表。 /// + /// 商户合同列表。 [HttpGet("{merchantId:long}/contracts")] [PermissionAuthorize("merchant:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -249,6 +255,7 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController /// /// 更新合同状态(生效/终止等)。 /// + /// 更新后的合同信息。 [HttpPut("{merchantId:long}/contracts/{contractId:long}/status")] [PermissionAuthorize("merchant:update")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -269,6 +276,7 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController /// /// 审核商户(通过/驳回)。 /// + /// 审核后的商户信息。 [HttpPost("{merchantId:long}/review")] [PermissionAuthorize("merchant:review")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -285,6 +293,7 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController /// /// 审核日志。 /// + /// 商户审核日志分页结果。 [HttpGet("{merchantId:long}/audits")] [PermissionAuthorize("merchant:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -304,6 +313,7 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController /// /// 可选商户类目列表。 /// + /// 可选的商户类目列表。 [HttpGet("categories")] [PermissionAuthorize("merchant:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/OrdersController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/OrdersController.cs index 9a0db09..d190e96 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/OrdersController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/OrdersController.cs @@ -24,6 +24,7 @@ public sealed class OrdersController(IMediator mediator) : BaseApiController /// /// 创建订单。 /// + /// 创建的订单信息。 [HttpPost] [PermissionAuthorize("order:create")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -39,6 +40,7 @@ public sealed class OrdersController(IMediator mediator) : BaseApiController /// /// 查询订单列表。 /// + /// 订单分页列表。 [HttpGet] [PermissionAuthorize("order:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -73,6 +75,7 @@ public sealed class OrdersController(IMediator mediator) : BaseApiController /// /// 获取订单详情。 /// + /// 订单详情。 [HttpGet("{orderId:long}")] [PermissionAuthorize("order:read")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -91,6 +94,7 @@ public sealed class OrdersController(IMediator mediator) : BaseApiController /// /// 更新订单。 /// + /// 更新后的订单信息。 [HttpPut("{orderId:long}")] [PermissionAuthorize("order:update")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -115,6 +119,7 @@ public sealed class OrdersController(IMediator mediator) : BaseApiController /// /// 删除订单。 /// + /// 删除结果。 [HttpDelete("{orderId:long}")] [PermissionAuthorize("order:delete")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/PaymentsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/PaymentsController.cs index ecf4fee..287f893 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/PaymentsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/PaymentsController.cs @@ -23,6 +23,7 @@ public sealed class PaymentsController(IMediator mediator) : BaseApiController /// /// 创建支付记录。 /// + /// 创建的支付记录信息。 [HttpPost] [PermissionAuthorize("payment:create")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -38,6 +39,7 @@ public sealed class PaymentsController(IMediator mediator) : BaseApiController /// /// 查询支付记录列表。 /// + /// 支付记录分页列表。 [HttpGet] [PermissionAuthorize("payment:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -68,6 +70,7 @@ public sealed class PaymentsController(IMediator mediator) : BaseApiController /// /// 获取支付记录详情。 /// + /// 支付记录详情。 [HttpGet("{paymentId:long}")] [PermissionAuthorize("payment:read")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -86,6 +89,7 @@ public sealed class PaymentsController(IMediator mediator) : BaseApiController /// /// 更新支付记录。 /// + /// 更新后的支付记录信息。 [HttpPut("{paymentId:long}")] [PermissionAuthorize("payment:update")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -110,6 +114,7 @@ public sealed class PaymentsController(IMediator mediator) : BaseApiController /// /// 删除支付记录。 /// + /// 删除结果。 [HttpDelete("{paymentId:long}")] [PermissionAuthorize("payment:delete")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs index 1bc75f0..30653f4 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs @@ -23,6 +23,7 @@ public sealed class ProductsController(IMediator mediator) : BaseApiController /// /// 创建商品。 /// + /// 创建的商品信息。 [HttpPost] [PermissionAuthorize("product:create")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -38,6 +39,7 @@ public sealed class ProductsController(IMediator mediator) : BaseApiController /// /// 查询商品列表。 /// + /// 商品分页列表。 [HttpGet] [PermissionAuthorize("product:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -70,6 +72,7 @@ public sealed class ProductsController(IMediator mediator) : BaseApiController /// /// 获取商品详情。 /// + /// 商品详情。 [HttpGet("{productId:long}")] [PermissionAuthorize("product:read")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -88,6 +91,7 @@ public sealed class ProductsController(IMediator mediator) : BaseApiController /// /// 更新商品。 /// + /// 更新后的商品信息。 [HttpPut("{productId:long}")] [PermissionAuthorize("product:update")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -112,6 +116,7 @@ public sealed class ProductsController(IMediator mediator) : BaseApiController /// /// 删除商品。 /// + /// 删除结果。 [HttpDelete("{productId:long}")] [PermissionAuthorize("product:delete")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs index d22f627..7c97263 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs @@ -25,6 +25,7 @@ public sealed class RolesController(IMediator mediator) : BaseApiController /// /// 示例:GET /api/admin/v1/roles/templates /// + /// 角色模板列表。 [HttpGet("templates")] [PermissionAuthorize("identity:role:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -43,6 +44,7 @@ public sealed class RolesController(IMediator mediator) : BaseApiController /// /// 示例:GET /api/admin/v1/roles/templates/tenant-admin /// + /// 角色模板详情。 [HttpGet("templates/{templateCode}")] [PermissionAuthorize("identity:role:read")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -61,6 +63,7 @@ public sealed class RolesController(IMediator mediator) : BaseApiController /// /// 创建角色模板。 /// + /// 创建的角色模板信息。 [HttpPost("templates")] [PermissionAuthorize("role-template:create")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -76,6 +79,7 @@ public sealed class RolesController(IMediator mediator) : BaseApiController /// /// 更新角色模板。 /// + /// 更新后的角色模板信息。 [HttpPut("templates/{templateCode}")] [PermissionAuthorize("role-template:update")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -100,6 +104,7 @@ public sealed class RolesController(IMediator mediator) : BaseApiController /// /// 删除角色模板。 /// + /// 删除结果。 [HttpDelete("templates/{templateCode}")] [PermissionAuthorize("role-template:delete")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -119,6 +124,7 @@ public sealed class RolesController(IMediator mediator) : BaseApiController /// 示例:POST /api/admin/v1/roles/templates/store-manager/copy /// Body: { "roleName": "新区店长" } /// + /// 创建的角色信息。 [HttpPost("templates/{templateCode}/copy")] [PermissionAuthorize("identity:role:create")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -142,6 +148,7 @@ public sealed class RolesController(IMediator mediator) : BaseApiController /// 示例:POST /api/admin/v1/roles/templates/init /// Body: { "templateCodes": ["tenant-admin","store-manager","store-staff"] } /// + /// 创建的角色列表。 [HttpPost("templates/init")] [PermissionAuthorize("identity:role:create")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -165,6 +172,7 @@ public sealed class RolesController(IMediator mediator) : BaseApiController /// GET /api/admin/v1/roles?keyword=ops&page=1&pageSize=20 /// Header: Authorization: Bearer <JWT> + X-Tenant-Id /// + /// 角色分页结果。 [HttpGet] [PermissionAuthorize("identity:role:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -180,6 +188,7 @@ public sealed class RolesController(IMediator mediator) : BaseApiController /// /// 创建角色。 /// + /// 创建的角色信息。 [HttpPost] [PermissionAuthorize("identity:role:create")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -195,6 +204,7 @@ public sealed class RolesController(IMediator mediator) : BaseApiController /// /// 更新角色。 /// + /// 更新后的角色信息。 [HttpPut("{roleId:long}")] [PermissionAuthorize("identity:role:update")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -216,6 +226,7 @@ public sealed class RolesController(IMediator mediator) : BaseApiController /// /// 删除角色。 /// + /// 删除结果。 [HttpDelete("{roleId:long}")] [PermissionAuthorize("identity:role:delete")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -232,6 +243,7 @@ public sealed class RolesController(IMediator mediator) : BaseApiController /// /// 绑定角色权限(覆盖式)。 /// + /// 是否绑定成功。 [HttpPut("{roleId:long}/permissions")] [PermissionAuthorize("identity:role:bind-permission")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/StoresController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoresController.cs index ffcde02..f5479fa 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/StoresController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoresController.cs @@ -23,6 +23,7 @@ public sealed class StoresController(IMediator mediator) : BaseApiController /// /// 创建门店。 /// + /// 创建的门店信息。 [HttpPost] [PermissionAuthorize("store:create")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -38,6 +39,7 @@ public sealed class StoresController(IMediator mediator) : BaseApiController /// /// 查询门店列表。 /// + /// 门店分页列表。 [HttpGet] [PermissionAuthorize("store:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -68,6 +70,7 @@ public sealed class StoresController(IMediator mediator) : BaseApiController /// /// 获取门店详情。 /// + /// 门店详情。 [HttpGet("{storeId:long}")] [PermissionAuthorize("store:read")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -86,6 +89,7 @@ public sealed class StoresController(IMediator mediator) : BaseApiController /// /// 更新门店。 /// + /// 更新后的门店信息。 [HttpPut("{storeId:long}")] [PermissionAuthorize("store:update")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -110,6 +114,7 @@ public sealed class StoresController(IMediator mediator) : BaseApiController /// /// 删除门店。 /// + /// 删除结果。 [HttpDelete("{storeId:long}")] [PermissionAuthorize("store:delete")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/SystemParametersController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/SystemParametersController.cs index 7a45183..7a2fcb2 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/SystemParametersController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/SystemParametersController.cs @@ -25,6 +25,7 @@ public sealed class SystemParametersController(IMediator mediator) : BaseApiCont /// /// 创建系统参数。 /// + /// 创建的系统参数信息。 [HttpPost] [PermissionAuthorize("system-parameter:create")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -40,6 +41,7 @@ public sealed class SystemParametersController(IMediator mediator) : BaseApiCont /// /// 查询系统参数列表。 /// + /// 分页的系统参数列表。 [HttpGet] [PermissionAuthorize("system-parameter:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -70,6 +72,7 @@ public sealed class SystemParametersController(IMediator mediator) : BaseApiCont /// /// 获取系统参数详情。 /// + /// 系统参数详情。 [HttpGet("{parameterId:long}")] [PermissionAuthorize("system-parameter:read")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -88,6 +91,7 @@ public sealed class SystemParametersController(IMediator mediator) : BaseApiCont /// /// 更新系统参数。 /// + /// 更新后的系统参数信息。 [HttpPut("{parameterId:long}")] [PermissionAuthorize("system-parameter:update")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -112,6 +116,7 @@ public sealed class SystemParametersController(IMediator mediator) : BaseApiCont /// /// 删除系统参数。 /// + /// 删除结果。 [HttpDelete("{parameterId:long}")] [PermissionAuthorize("system-parameter:delete")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantAnnouncementsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantAnnouncementsController.cs index 1ba6147..b99b30f 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantAnnouncementsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantAnnouncementsController.cs @@ -22,6 +22,7 @@ public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiC /// /// 分页查询公告。 /// + /// 租户公告分页结果。 [HttpGet] [PermissionAuthorize("tenant-announcement:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -40,6 +41,7 @@ public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiC /// /// 公告详情。 /// + /// 租户公告详情。 [HttpGet("{announcementId:long}")] [PermissionAuthorize("tenant-announcement:read")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -58,6 +60,7 @@ public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiC /// /// 创建公告。 /// + /// 创建的公告信息。 [HttpPost] [PermissionAuthorize("tenant-announcement:create")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -74,6 +77,7 @@ public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiC /// /// 更新公告。 /// + /// 更新后的公告信息。 [HttpPut("{announcementId:long}")] [PermissionAuthorize("tenant-announcement:update")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -95,6 +99,7 @@ public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiC /// /// 删除公告。 /// + /// 删除结果。 [HttpDelete("{announcementId:long}")] [PermissionAuthorize("tenant-announcement:delete")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -110,6 +115,7 @@ public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiC /// /// 标记公告已读。 /// + /// 标记已读后的公告信息。 [HttpPost("{announcementId:long}/read")] [PermissionAuthorize("tenant-announcement:read")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantBillingsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantBillingsController.cs index aa306c8..9de398f 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantBillingsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantBillingsController.cs @@ -22,6 +22,7 @@ public sealed class TenantBillingsController(IMediator mediator) : BaseApiContro /// /// 分页查询账单。 /// + /// 租户账单分页结果。 [HttpGet] [PermissionAuthorize("tenant-bill:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -40,6 +41,7 @@ public sealed class TenantBillingsController(IMediator mediator) : BaseApiContro /// /// 账单详情。 /// + /// 租户账单详情。 [HttpGet("{billingId:long}")] [PermissionAuthorize("tenant-bill:read")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -58,6 +60,7 @@ public sealed class TenantBillingsController(IMediator mediator) : BaseApiContro /// /// 创建账单。 /// + /// 创建的账单信息。 [HttpPost] [PermissionAuthorize("tenant-bill:create")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -74,6 +77,7 @@ public sealed class TenantBillingsController(IMediator mediator) : BaseApiContro /// /// 标记账单已支付。 /// + /// 标记支付后的账单信息。 [HttpPost("{billingId:long}/pay")] [PermissionAuthorize("tenant-bill:pay")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantNotificationsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantNotificationsController.cs index aa11643..18c78ce 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantNotificationsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantNotificationsController.cs @@ -21,6 +21,7 @@ public sealed class TenantNotificationsController(IMediator mediator) : BaseApiC /// /// 分页查询通知。 /// + /// 租户通知分页结果。 [HttpGet] [PermissionAuthorize("tenant-notification:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -39,6 +40,7 @@ public sealed class TenantNotificationsController(IMediator mediator) : BaseApiC /// /// 标记通知已读。 /// + /// 标记已读后的通知信息。 [HttpPost("{notificationId:long}/read")] [PermissionAuthorize("tenant-notification:update")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs index 585ab2c..f3bc0a0 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs @@ -22,6 +22,7 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController /// /// 注册租户并初始化套餐。 /// + /// 注册的租户信息。 [HttpPost] [PermissionAuthorize("tenant:create")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -37,6 +38,7 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController /// /// 分页查询租户。 /// + /// 租户分页结果。 [HttpGet] [PermissionAuthorize("tenant:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -52,6 +54,7 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController /// /// 查看租户详情。 /// + /// 租户详情。 [HttpGet("{tenantId:long}")] [PermissionAuthorize("tenant:read")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -67,6 +70,7 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController /// /// 提交或更新实名认证资料。 /// + /// 提交的实名认证信息。 [HttpPost("{tenantId:long}/verification")] [PermissionAuthorize("tenant:review")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -88,6 +92,7 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController /// /// 审核租户。 /// + /// 审核后的租户信息。 [HttpPost("{tenantId:long}/review")] [PermissionAuthorize("tenant:review")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -106,6 +111,7 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController /// /// 创建或续费租户订阅。 /// + /// 创建或续费的订阅信息。 [HttpPost("{tenantId:long}/subscriptions")] [PermissionAuthorize("tenant:subscription")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -125,6 +131,7 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController /// /// 套餐升降配。 /// + /// 更新后的订阅信息。 [HttpPut("{tenantId:long}/subscriptions/{subscriptionId:long}/plan")] [PermissionAuthorize("tenant:subscription")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -147,6 +154,7 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController /// /// 查询审核日志。 /// + /// 租户审核日志分页结果。 [HttpGet("{tenantId:long}/audits")] [PermissionAuthorize("tenant:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -168,6 +176,7 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController /// 配额校验并占用额度(门店/账号/短信/配送)。 /// /// 需在请求头携带 X-Tenant-Id 对应的租户。 + /// 配额校验结果。 [HttpPost("{tenantId:long}/quotas/check")] [PermissionAuthorize("tenant:quota:check")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] From 7f6eabfead2506792b03a1c6d4e876ded6fa0cb6 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Thu, 4 Dec 2025 14:34:24 +0800 Subject: [PATCH 30/30] style: temporarily disable stylecop analyzers --- Directory.Build.props | 4 ---- .../TakeoutSaaS.Module.Sms/Services/AliyunSmsSender.cs | 2 ++ 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 6f0ab73..e975914 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,11 +6,7 @@ latest false - - - - diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Services/AliyunSmsSender.cs b/src/Modules/TakeoutSaaS.Module.Sms/Services/AliyunSmsSender.cs index 11a4b2e..213cfa0 100644 --- a/src/Modules/TakeoutSaaS.Module.Sms/Services/AliyunSmsSender.cs +++ b/src/Modules/TakeoutSaaS.Module.Sms/Services/AliyunSmsSender.cs @@ -24,6 +24,8 @@ public sealed class AliyunSmsSender(IHttpClientFactory httpClientFactory, IOptio logger.LogInformation("Mock 发送阿里云短信到 {Phone}, Template:{Template}", request.PhoneNumber, request.TemplateCode); return Task.FromResult(new SmsSendResult { Success = true, Message = "Mocked" }); } + // 预留 HttpClient,便于后续接入阿里云正式签名请求 + using var httpClient = httpClientFactory.CreateClient(nameof(AliyunSmsSender)); // 占位:保留待接入阿里云正式签名流程,当前返回未实现。 logger.LogWarning("阿里云短信尚未启用,请配置腾讯云或开启 UseMock。");