Merge pull request #1 from msumshk/dev

1
This commit is contained in:
2025-12-03 13:00:37 +08:00
committed by GitHub
592 changed files with 50286 additions and 15 deletions

13
.config/dotnet-tools.json Normal file
View File

@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.0",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}

175
.github/workflows/ci-cd.yml vendored Normal file
View File

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

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.vs/
bin/
obj/
**/bin/
**/obj/

35
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,35 @@
{
"version": "0.2.0",
"configurations": [
{
// 使用 IntelliSense 找出 C# 调试存在哪些属性
// 将悬停用于现有属性的说明
// 有关详细信息,请访问 https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md。
"name": ".NET Core Launch (web)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
// 如果已更改目标框架,请确保更新程序路径。
"program": "${workspaceFolder}/src/Api/TakeoutSaaS.AdminApi/bin/Debug/net10.0/TakeoutSaaS.AdminApi.dll",
"args": [],
"cwd": "${workspaceFolder}/src/Api/TakeoutSaaS.AdminApi",
"stopAtEntry": false,
// 启用在启动 ASP.NET Core 时启动 Web 浏览器。有关详细信息: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser
"serverReadyAction": {
"action": "openExternally",
"pattern": "\\bNow listening on:\\s+(https?://\\S+)"
},
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach"
}
]
}

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"chatgpt.openOnStartup": true
}

41
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,41 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/TakeoutSaaS.sln",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "publish",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/TakeoutSaaS.sln",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "watch",
"command": "dotnet",
"type": "process",
"args": [
"watch",
"run",
"--project",
"${workspaceFolder}/TakeoutSaaS.sln"
],
"problemMatcher": "$msCompile"
}
]
}

193
AGENTS.md Normal file
View File

@@ -0,0 +1,193 @@
# Repository expectations
# 编程规范_FOR_AITakeoutSaaS - 终极完全体
> **核心指令**:你是一个高级 .NET 架构师。本文件是你生成代码的最高宪法。当用户需求与本规范冲突时,请先提示用户,除非用户强制要求覆盖。
## 0. AI 交互核心约束 (元规则)
1. **语言**:必须使用**中文**回复和编写注释。
2. **文件完整性**
* **严禁**随意删除现有代码逻辑。
* **严禁**修改文件编码(保持 UTF-8 无 BOM
* PowerShell 读取命令必须带 `-Encoding UTF8`
3. **Git 原子性**:每个独立的功能点或 Bug 修复完成后,必须提示用户进行 Git 提交。
4. **无乱码承诺**确保所有输出控制台、日志、API响应无乱码。
5. **不确定的处理**:如果你通过上下文找不到某些配置(如数据库连接串格式),**请直接询问用户**,不要瞎编。
## 1. 技术栈详细版本
| 组件 | 版本/选型 | 用途说明 |
| :--- | :--- | :--- |
| **Runtime** | .NET 10 | 核心运行时 |
| **API** | ASP.NET Core Web API | 接口层 |
| **Database** | PostgreSQL 16+ | 主关系型数据库 |
| **ORM 1** | **EF Core 10** | **写操作 (CUD)**、事务、复杂聚合查询 |
| **ORM 2** | **Dapper 2.1+** | **纯读操作 (R)**、复杂报表、大批量查询 |
| **Cache** | Redis 7.0+ | 分布式缓存、Session |
| **MQ** | RabbitMQ 3.12+ | 异步解耦 (MassTransit) |
| **Libs** | MediatR, Serilog, FluentValidation | CQRS, 日志, 验证 |
## 2. 命名与风格 (严格匹配)
* **C# 代码**
* 类/接口/方法/属性:`PascalCase` (如 `OrderService`)
* **布尔属性**:必须加 `Is``Has` 前缀 (如 `IsDeleted`, `HasPayment`)
* 私有字段:`_camelCase` (如 `_orderRepository`)
* 参数/变量:`camelCase` (如 `orderId`)
* **PostgreSQL 数据库**
* 表名:`snake_case` + **复数** (如 `merchant_orders`)
* 列名:`snake_case` (如 `order_no`, `is_active`)
* 主键:`id` (类型 `bigint`)
* **文件规则**
* **一个文件一个类**。文件名必须与类名完全一致。
## 3. 分层架构 (Clean Architecture)
**你生成的代码必须严格归类到以下目录:**
* **`src/Api`**: 仅负责路由与 DTO 转换,**禁止**包含业务逻辑。
* **`src/Application`**: 业务编排层。必须使用 **CQRS** (`IRequestHandler`) 和 **Mediator**
* **`src/Domain`**: 核心领域层。包含实体、枚举、领域异常。**禁止**依赖 EF Core 等外部库。
* **`src/Infrastructure`**: 基础设施层。实现仓储、数据库上下文、第三方服务。
## 4. 注释与文档
* **强制 XML 注释**:所有 `public` 的类、方法、属性必须有 `<summary>`
* **步骤注释**:超过 5 行的业务逻辑,必须分步注释:
```csharp
// 1. 验证库存
// 2. 扣减余额
```
* **Swagger**:必须开启 JWT 鉴权按钮Request/Response 示例必须清晰。
## 5. 异常处理 (防御性编程)
* **禁止空 Catch**:严禁 `catch (Exception) {}`,必须记录日志或抛出。
* **异常分级**
* 预期业务错误 -> `BusinessException` (含 ErrorCode)
* 参数验证错误 -> `ValidationException`
* **全局响应**:通过中间件统一转换为 `ProblemDetails` JSON 格式。
## 6. 异步与日志
* **全异步**:所有 I/O 操作必须 `await`。**严禁** `.Result` 或 `.Wait()`。
* **结构化日志**
* ❌ `_logger.LogInfo("订单 " + id + " 创建成功");`
* ✅ `_logger.LogInformation("订单 {OrderId} 创建成功", id);`
* **脱敏**:严禁打印密码、密钥、支付凭证等敏感信息。
## 7. 依赖注入 (DI)
* **构造函数注入**:统一使用构造函数注入。
* **禁止项**
* ❌ 禁止使用 `[Inject]` 属性注入。
* ❌ 禁止使用 `ServiceLocator` (服务定位器模式)。
* ❌ 禁止在静态类中持有 ServiceProvider。
## 8. 数据访问规范 (重点执行)
### 8.1 Entity Framework Core (写/事务)
1. **无跟踪查询**:只读查询**必须**加 `.AsNoTracking()`。
2. **杜绝 N+1**:严禁在 `foreach` 循环中查询数据库。必须使用 `.Include()`。
3. **复杂查询**:关联表超过 2 层时,考虑使用 `.AsSplitQuery()`。
### 8.2 Dapper (读/报表)
1. **SQL 注入防御****严禁**拼接 SQL 字符串。必须使用参数化查询 (`@Param`)。
2. **字段映射**:注意 PostgreSQL (`snake_case`) 与 C# (`PascalCase`) 的映射配置。
## 9. 多租户与 ID 策略
* **ID 生成**
* **强制**使用 **雪花算法 (Snowflake ID)**。
* 类型C# `long` <-> DB `bigint`。
* **禁止**使用 UUID 或 自增 INT。
* **租户隔离**
* 所有业务表必须包含 `tenant_id`。
* 写入时自动填充,读取时强制过滤。
## 10. API 设计与序列化 (前端兼容)
* **大整数处理**
* 所有 `long` 类型 (Snowflake ID) 在 DTO 中**必须序列化为 string**。
* 方案DTO 属性加 `[JsonConverter(typeof(ToStringJsonConverter))]` 或全局配置。
* **DTO 规范**
* 输入:`XxxRequest`
* 输出:`XxxDto`
* **禁止** Controller 直接返回 Entity。
## 11. 模块化与复用
* **核心模块划分**Identity (身份), Tenancy (租户), Dictionary (字典), Storage (存储)。
* **公共库 (Shared)**:通用工具类、扩展方法、常量定义必须放在 `Core/Shared` 项目中,避免重复造轮子。
## 12. 测试规范
* **模式**Arrange-Act-Assert (AAA)。
* **工具**xUnit + Moq + FluentAssertions。
* **覆盖率**:核心 Domain 逻辑必须 100% 覆盖Service 层 ≥ 70%。
## 13. Git 工作流
* **提交格式 (Conventional Commits)**
* `feat`: 新功能
* `fix`: 修复 Bug
* `refactor`: 重构
* `docs`: 文档
* `style`: 格式调整
* **分支规范**`feature/功能名``bugfix/问题描述`。
## 14. 性能优化 (显式指令)
* **投影查询**:使用 `.Select(x => new Dto { ... })` 只查询需要的字段,减少 I/O。
* **缓存策略**Cache-Aside 模式。数据更新后必须立即失效缓存。
* **批量操作**
* EF Core 10使用 `ExecuteUpdateAsync` / `ExecuteDeleteAsync`。
* Dapper使用 `ExecuteAsync` 进行批量插入。
## 15. 安全规范
* **SQL 注入**:已在第 8 条强制参数化。
* **身份认证**Admin 端使用 JWT + RBAC小程序端使用 Session/Token。
* **密码存储**:必须使用 PBKDF2 或 BCrypt 加盐哈希。
## 16. 绝对禁止事项 (AI 自检清单)
**生成代码前,请自查是否违反以下红线:**
1. [ ] **SQL 注入**:是否拼接了 SQL 字符串?
2. [ ] **架构违规**:是否在 Controller/Domain 中使用了 DbContext
3. [ ] **数据泄露**:是否返回了 Entity 或打印了密码?
4. [ ] **同步阻塞**:是否使用了 `.Result` 或 `.Wait()`
5. [ ] **性能陷阱**:是否在循环中查询了数据库 (N+1)
6. [ ] **精度丢失**Long 类型的 ID 是否转为了 String
7. [ ] **配置硬编码**:是否直接写死了连接串或密钥?
## 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<Foo> 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 = ...` 带来的副作用。
(其余规则继续遵循上文约束:分层、命名、异步、日志、验证、租户/ID 策略等。)
## 18. .NET 10 极致性能优化最佳实践(增量)
> 侧重零分配、并发与底层优化,遵循 2025 推荐方案。
1. **Span/ReadOnlySpan 优先**API 参数尽量用 `ReadOnlySpan<char>` 处理字符串/切片,避免 Substring/复制。
2. **栈分配与数组池**:小缓冲用 `stackalloc`,大缓冲统一用 `ArrayPool<T>.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<T>`,减少 Task 分配。
9. **Parallel.ForEachAsync 控并发**I/O 并发用 Parallel.ForEachAsync 控制并行度,替代粗暴 Task.WhenAll。
10. **避免 Task.Run**:在 ASP.NET Core 请求中不使用 Task.Run 做后台工作,改用 IHostedService 或 Channel 模式。
11. **Channel<T> 代替锁**:多线程数据传递优先使用 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利用防击穿合并并发请求。
## 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 再推送 MQRabbitMQ/Kafka禁止事务提交后直接发消息以避免不一致。
5. **共享资源必加分布式锁**:涉及库存扣减、定时任务抢占等共享资源时,必须引入分布式锁(如 Redis RedLock防止并发竞争与脏写。
---
# Working agreements
- 严格遵循上述技术栈和命名规范。

10
Directory.Build.props Normal file
View File

@@ -0,0 +1,10 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
</Project>

188
Document/01_项目概述.md Normal file
View File

@@ -0,0 +1,188 @@
# 外卖SaaS系统 - 项目概述
## 1. 项目简介
### 1.1 项目背景
外卖SaaS系统是一个面向餐饮企业的多租户外卖管理平台旨在为中小型餐饮企业提供完整的外卖业务解决方案。系统支持商家入驻、菜品管理、订单处理、配送管理等核心功能。
### 1.2 项目目标
- 提供稳定、高效的外卖业务管理平台
- 支持多租户架构,实现数据隔离和资源共享
- 提供完善的商家管理和运营工具
- 支持灵活的配送模式(自配送、第三方配送)
- 提供实时数据分析和报表功能
### 1.3 核心价值
- **降低成本**SaaS模式降低企业IT投入成本
- **快速上线**:开箱即用,快速开展外卖业务
- **灵活扩展**:支持业务增长和功能定制
- **数据驱动**:提供数据分析,辅助经营决策
## 2. 业务模块
### 2.1 租户管理模块
- 租户注册与认证
- 租户信息管理
- 套餐订阅管理
- 权限与配额管理
### 2.2 商家管理模块
- 商家入驻审核
- 商家信息管理
- 门店管理(支持多门店)
- 营业时间设置
- 配送范围设置
### 2.3 菜品管理模块
- 菜品分类管理
- 菜品信息管理(名称、价格、图片、描述)
- 菜品规格管理(大份、小份等)
- 菜品库存管理
- 菜品上下架管理
### 2.4 订单管理模块
- 订单创建与支付
- 订单状态流转(待支付、待接单、制作中、配送中、已完成、已取消)
- 订单查询与筛选
- 订单退款处理
- 订单统计分析
### 2.5 配送管理模块
- 配送员管理
- 配送任务分配
- 配送路线规划
- 配送状态跟踪
- 配送费用计算
### 2.6 用户管理模块
- 用户注册与登录
- 用户信息管理
- 收货地址管理
- 用户订单历史
- 用户评价管理
### 2.7 支付管理模块
- 多支付方式支持(微信、支付宝、余额)
- 支付回调处理
- 退款处理
- 账单管理
### 2.8 营销管理模块
- 优惠券管理
- 满减活动
- 会员积分
- 推广活动
### 2.9 数据分析模块
- 销售数据统计
- 订单趋势分析
- 用户行为分析
- 商家经营报表
- 平台运营大盘
### 2.10 系统管理模块
- 系统配置管理
- 日志管理
- 权限管理
- 消息通知管理
## 3. 用户角色
### 3.1 平台管理员Web管理端
- 管理所有租户和商家
- 系统配置和维护
- 数据监控和分析
- 审核商家入驻
- 平台运营管理
### 3.2 租户管理员Web管理端
- 管理租户下的所有商家
- 查看租户数据报表
- 管理租户套餐和权限
- 租户配置管理
### 3.3 商家管理员Web管理端
- 管理门店信息
- 管理菜品和订单
- 查看经营数据
- 管理配送(自配送或第三方配送对接)
- 营销活动管理
### 3.4 商家员工Web管理端
- 处理订单(接单/出餐/发货)
- 更新菜品状态
- 订单打印与出餐看板
### 3.5 普通用户/消费者(小程序端 + Web用户端
- 浏览商家和菜品
- 下单和支付
- 查看订单状态
- 评价和反馈
- 收货地址管理
- 优惠券领取和使用
## 4. 系统特性
### 4.1 多租户架构
- 数据隔离:每个租户数据完全隔离
- 资源共享:共享基础设施,降低成本
- 灵活配置:支持租户级别的个性化配置
### 4.2 高可用性
- 服务高可用:支持集群部署
- 数据高可用:数据库主从复制
- 故障自动恢复
### 4.3 高性能
- 缓存策略Redis缓存热点数据
- 数据库优化:索引优化、查询优化
- 异步处理:消息队列处理耗时任务
### 4.4 安全性
- 身份认证JWT Token认证
- 权限控制基于角色的访问控制RBAC
- 数据加密:敏感数据加密存储
- 接口防护:限流、防重放攻击
### 4.5 可扩展性
- 微服务架构:支持服务独立扩展
- 插件化设计:支持功能模块插拔
- API开放提供开放API接口
## 5. 技术选型
- **后端框架**.NET 10
- **ORM框架**Entity Framework Core 10 + Dapper
- **数据库**PostgreSQL 16+
- **缓存**Redis 7.0+
- **消息队列**RabbitMQ 3.12+
- **API文档**Swagger/OpenAPI
- **日志**Serilog
- **认证授权**JWT + OAuth2.0
## 6. 项目里程碑
### Phase 1基础功能1-2个月
- 租户管理
- 商家管理
- 菜品管理
- 订单管理(基础流程)
### Phase 2核心功能2-3个月
- 配送管理
- 支付集成
- 用户管理
- 基础营销功能
### Phase 3高级功能3-4个月
- 数据分析
- 高级营销
- 系统优化
- 性能调优
### Phase 4完善与上线1个月
- 测试与修复
- 文档完善
- 部署上线
- 运维监控

253
Document/02_技术架构.md Normal file
View File

@@ -0,0 +1,253 @@
# 外卖SaaS系统 - 技术架构
## 1. 技术栈
### 1.1 后端技术栈
- **.NET 10**:最新的.NET平台提供高性能和现代化开发体验
- **ASP.NET Core Web API**构建RESTful API服务
- **Entity Framework Core 10**最新ORM框架用于复杂查询和实体管理
- **Dapper 2.1+**轻量级ORM用于高性能查询和批量操作
- **PostgreSQL 16+**主数据库支持JSON、全文搜索等高级特性
- **Redis 7.0+**:缓存和会话存储
- **RabbitMQ 3.12+**:消息队列,处理异步任务
### 1.2 开发工具和框架
- **AutoMapper**:对象映射
- **FluentValidation**:数据验证
- **Serilog**:结构化日志
- **MediatR**CQRS和中介者模式实现
- **Hangfire**:后台任务调度
- **Polly**:弹性和瞬态故障处理
- **Swagger/Swashbuckle**API文档生成
### 1.3 认证授权
- **JWT (JSON Web Token)**:无状态身份认证
- **IdentityServer/Duende IdentityServer**OAuth2.0和OpenID Connect
- **ASP.NET Core Identity**:用户身份管理
### 1.4 测试框架
- **xUnit**:单元测试框架
- **Moq**Mock框架
- **FluentAssertions**:断言库
- **Testcontainers**:集成测试容器化
### 1.5 DevOps工具
- **Docker**:容器化部署
- **Docker Compose**:本地开发环境
- **GitHub Actions/GitLab CI**CI/CD流水线
- **Nginx**:反向代理和负载均衡
## 2. 系统架构
### 2.1 整体架构
```
┌─────────────────────────────────────────────────────────────┐
│ 客户端层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ Web管理端 │ │ Web用户端 │ │ 小程序端(用户) │ │
│ └──────────┘ └──────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ API网关层 │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Nginx / API Gateway (路由、限流、认证、日志) │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 应用服务层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │租户服务 │ │商家服务 │ │订单服务 │ │配送服务 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │用户服务 │ │支付服务 │ │营销服务 │ │通知服务 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 基础设施层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │PostgreSQL │ │ Redis │ │ RabbitMQ │ │ MinIO │ │
│ │ (主库) │ │ (缓存) │ │ (消息队列)│ │(对象存储) │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### 2.2 分层架构
#### 2.2.1 表现层 (Presentation Layer)
- **TakeoutSaaS.AdminApi**:管理后台 Web API 项目(/api/admin/v1
- Controllers后台管理API控制器
- Filters过滤器异常处理、日志、验证
- Middleware中间件认证、租户识别、RBAC
- Models请求/响应DTO
- **TakeoutSaaS.MiniApi**:小程序/用户端 Web API 项目(/api/mini/v1
- Controllers用户端API控制器
- Filters过滤器异常处理、限流、签名校验
- Middleware中间件小程序登录态、租户识别、CORS
- Models请求/响应DTO
#### 2.2.2 应用层 (Application Layer)
- **TakeoutSaaS.Application**:应用逻辑
- Services应用服务
- DTOs数据传输对象
- Interfaces服务接口
- ValidatorsFluentValidation验证器
- MappingsAutoMapper配置
- Commands/QueriesCQRS命令和查询
#### 2.2.3 领域层 (Domain Layer)
- **TakeoutSaaS.Domain**:领域模型
- Entities实体类
- ValueObjects值对象
- Enums枚举
- Events领域事件
- Interfaces仓储接口
- Specifications规约模式
#### 2.2.4 基础设施层 (Infrastructure Layer)
- **TakeoutSaaS.Infrastructure**:基础设施实现
- Data数据访问
- EFCoreEF Core DbContext和配置
- DapperDapper查询实现
- Repositories仓储实现
- Migrations数据库迁移
- CacheRedis缓存实现
- MessageQueueRabbitMQ实现
- ExternalServices第三方服务集成
#### 2.2.5 共享层 (Shared Layer)
- **TakeoutSaaS.Shared**:共享组件
- Constants常量定义
- Exceptions自定义异常
- Extensions扩展方法
- Helpers辅助类
- Results统一返回结果
## 3. 核心设计模式
### 3.1 多租户模式
- **数据隔离策略**每个租户独立Schema
- **租户识别**通过HTTP Header或JWT Token识别租户
- **动态切换**:运行时动态切换数据库连接
### 3.2 CQRS模式
- **命令Command**:处理写操作,修改数据
- **查询Query**:处理读操作,不修改数据
- **分离优势**:读写分离,优化性能
### 3.3 仓储模式
- **抽象数据访问**:统一数据访问接口
- **EF Core仓储**:复杂查询和事务处理
- **Dapper仓储**:高性能查询和批量操作
### 3.4 工作单元模式
- **事务管理**:统一管理数据库事务
- **批量提交**:减少数据库往返次数
### 3.5 领域驱动设计DDD
- **聚合根**:定义实体边界
- **值对象**:不可变对象
- **领域事件**:解耦业务逻辑
## 4. 数据访问策略
### 4.1 EF Core使用场景
- 复杂的实体关系查询
- 需要变更跟踪的操作
- 事务性操作
- 数据库迁移管理
### 4.2 Dapper使用场景
- 高性能查询(大数据量)
- 复杂SQL查询
- 批量插入/更新
- 报表统计查询
- 存储过程调用
### 4.3 混合使用策略
```csharp
// EF Core - 复杂查询和实体管理
public async Task<Order> GetOrderWithDetailsAsync(Guid orderId)
{
return await _dbContext.Orders
.Include(o => o.OrderItems)
.Include(o => o.Customer)
.FirstOrDefaultAsync(o => o.Id == orderId);
}
// Dapper - 高性能统计查询
public async Task<OrderStatistics> GetOrderStatisticsAsync(DateTime startDate, DateTime endDate)
{
var sql = @"
SELECT
COUNT(*) as TotalOrders,
SUM(total_amount) as TotalAmount,
AVG(total_amount) as AvgAmount
FROM orders
WHERE created_at BETWEEN @StartDate AND @EndDate";
return await _connection.QueryFirstOrDefaultAsync<OrderStatistics>(sql,
new { StartDate = startDate, EndDate = endDate });
}
```
## 5. 缓存策略
### 5.1 缓存层次
- **L1缓存**内存缓存IMemoryCache- 进程内缓存
- **L2缓存**Redis缓存 - 分布式缓存
### 5.2 缓存场景
- 商家信息缓存30分钟
- 菜品信息缓存15分钟
- 用户会话缓存2小时
- 配置信息缓存1小时
- 热点数据缓存(动态过期)
### 5.3 缓存更新策略
- **Cache-Aside**:旁路缓存,先查缓存,未命中查数据库
- **Write-Through**:写入时同步更新缓存
- **Write-Behind**:异步更新缓存
## 6. 消息队列应用
### 6.1 异步任务
- 订单状态变更通知
- 短信/邮件发送
- 数据统计计算
- 日志持久化
### 6.2 事件驱动
- 订单创建事件
- 支付成功事件
- 配送状态变更事件
## 7. 安全设计
### 7.1 认证机制
- JWT Token认证
- Refresh Token刷新
- Token过期管理
### 7.2 授权机制
- 基于角色的访问控制RBAC
- 基于策略的授权
- 资源级权限控制
### 7.3 数据安全
- 敏感数据加密(密码、支付信息)
- HTTPS传输加密
- SQL注入防护
- XSS防护
### 7.4 接口安全
- 请求签名验证
- 接口限流Rate Limiting
- 防重放攻击
- CORS跨域配置

View File

@@ -0,0 +1,641 @@
# 外卖SaaS系统 - 数据库设计
## 1. 数据库设计原则
### 1.1 命名规范
- **表名**:小写字母,下划线分隔,复数形式(如:`orders`, `order_items`
- **字段名**:小写字母,下划线分隔(如:`created_at`, `total_amount`
- **主键**:统一使用 `id`,类型为 UUID
- **外键**`表名_id`(如:`order_id`, `merchant_id`
- **索引**`idx_表名_字段名`(如:`idx_orders_merchant_id`
### 1.2 通用字段
所有表都包含以下字段:
- `id`UUID主键
- `created_at`TIMESTAMP创建时间
- `updated_at`TIMESTAMP更新时间
- `deleted_at`TIMESTAMP软删除时间可选
- `tenant_id`UUID租户ID多租户隔离
### 1.3 数据类型规范
- **金额**DECIMAL(18,2)
- **时间**TIMESTAMP WITH TIME ZONE
- **布尔**BOOLEAN
- **枚举**VARCHAR 或 INTEGER
- **JSON数据**JSONB
## 2. 核心表结构
### 2.1 租户管理
#### tenants租户表
```sql
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
code VARCHAR(50) UNIQUE NOT NULL,
contact_name VARCHAR(50),
contact_phone VARCHAR(20),
contact_email VARCHAR(100),
status INTEGER NOT NULL DEFAULT 1, -- 1:正常 2:冻结 3:过期
subscription_plan VARCHAR(50), -- 订阅套餐
subscription_start_date TIMESTAMP WITH TIME ZONE,
subscription_end_date TIMESTAMP WITH TIME ZONE,
max_merchants INTEGER DEFAULT 10, -- 最大商家数
max_orders_per_day INTEGER DEFAULT 1000, -- 每日订单限额
settings JSONB, -- 租户配置
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_tenants_code ON tenants(code);
CREATE INDEX idx_tenants_status ON tenants(status);
```
### 2.2 商家管理
#### merchants商家表
```sql
CREATE TABLE merchants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name VARCHAR(100) NOT NULL,
logo_url VARCHAR(500),
description TEXT,
contact_phone VARCHAR(20),
contact_person VARCHAR(50),
business_license VARCHAR(100), -- 营业执照号
status INTEGER NOT NULL DEFAULT 1, -- 1:正常 2:休息 3:停业
rating DECIMAL(3,2) DEFAULT 0, -- 评分
total_sales INTEGER DEFAULT 0, -- 总销量
settings JSONB, -- 商家配置
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_merchants_tenant_id ON merchants(tenant_id);
CREATE INDEX idx_merchants_status ON merchants(status);
```
#### merchant_stores门店表
```sql
CREATE TABLE merchant_stores (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
merchant_id UUID NOT NULL REFERENCES merchants(id),
name VARCHAR(100) NOT NULL,
address VARCHAR(500) NOT NULL,
latitude DECIMAL(10,7), -- 纬度
longitude DECIMAL(10,7), -- 经度
phone VARCHAR(20),
business_hours JSONB, -- 营业时间 {"monday": {"open": "09:00", "close": "22:00"}}
delivery_range INTEGER DEFAULT 3000, -- 配送范围(米)
min_order_amount DECIMAL(18,2) DEFAULT 0, -- 起送价
delivery_fee DECIMAL(18,2) DEFAULT 0, -- 配送费
status INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_merchant_stores_merchant_id ON merchant_stores(merchant_id);
CREATE INDEX idx_merchant_stores_location ON merchant_stores USING GIST(point(longitude, latitude));
```
### 2.3 菜品管理
#### categories菜品分类表
```sql
CREATE TABLE categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
merchant_id UUID NOT NULL REFERENCES merchants(id),
name VARCHAR(50) NOT NULL,
sort_order INTEGER DEFAULT 0,
status INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_categories_merchant_id ON categories(merchant_id);
```
#### dishes菜品表
```sql
CREATE TABLE dishes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
merchant_id UUID NOT NULL REFERENCES merchants(id),
category_id UUID REFERENCES categories(id),
name VARCHAR(100) NOT NULL,
description TEXT,
image_url VARCHAR(500),
price DECIMAL(18,2) NOT NULL,
original_price DECIMAL(18,2), -- 原价
unit VARCHAR(20) DEFAULT '', -- 单位
stock INTEGER, -- 库存NULL表示不限
sales_count INTEGER DEFAULT 0, -- 销量
rating DECIMAL(3,2) DEFAULT 0, -- 评分
sort_order INTEGER DEFAULT 0,
status INTEGER NOT NULL DEFAULT 1, -- 1:上架 2:下架
tags JSONB, -- 标签 ["热销", "新品"]
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_dishes_merchant_id ON dishes(merchant_id);
CREATE INDEX idx_dishes_category_id ON dishes(category_id);
CREATE INDEX idx_dishes_status ON dishes(status);
```
#### dish_specs菜品规格表
```sql
CREATE TABLE dish_specs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
dish_id UUID NOT NULL REFERENCES dishes(id),
name VARCHAR(50) NOT NULL, -- 规格名称(如:大份、小份)
price DECIMAL(18,2) NOT NULL,
stock INTEGER,
status INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_dish_specs_dish_id ON dish_specs(dish_id);
```
### 2.4 用户管理
#### users用户表
```sql
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
phone VARCHAR(20) UNIQUE NOT NULL,
nickname VARCHAR(50),
avatar_url VARCHAR(500),
gender INTEGER, -- 0:未知 1:男 2:女
birthday DATE,
balance DECIMAL(18,2) DEFAULT 0, -- 余额
points INTEGER DEFAULT 0, -- 积分
status INTEGER NOT NULL DEFAULT 1,
last_login_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_users_phone ON users(phone);
```
#### user_addresses用户地址表
```sql
CREATE TABLE user_addresses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
contact_name VARCHAR(50) NOT NULL,
contact_phone VARCHAR(20) NOT NULL,
province VARCHAR(50),
city VARCHAR(50),
district VARCHAR(50),
address VARCHAR(500) NOT NULL,
house_number VARCHAR(50), -- 门牌号
latitude DECIMAL(10,7),
longitude DECIMAL(10,7),
is_default BOOLEAN DEFAULT FALSE,
label VARCHAR(20), -- 标签:家、公司等
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_user_addresses_user_id ON user_addresses(user_id);
```
### 2.5 订单管理
#### orders订单表
```sql
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
order_no VARCHAR(50) UNIQUE NOT NULL, -- 订单号
merchant_id UUID NOT NULL REFERENCES merchants(id),
store_id UUID NOT NULL REFERENCES merchant_stores(id),
user_id UUID NOT NULL REFERENCES users(id),
-- 收货信息
delivery_address VARCHAR(500) NOT NULL,
delivery_latitude DECIMAL(10,7),
delivery_longitude DECIMAL(10,7),
contact_name VARCHAR(50) NOT NULL,
contact_phone VARCHAR(20) NOT NULL,
-- 金额信息
dish_amount DECIMAL(18,2) NOT NULL, -- 菜品金额
delivery_fee DECIMAL(18,2) DEFAULT 0, -- 配送费
package_fee DECIMAL(18,2) DEFAULT 0, -- 打包费
discount_amount DECIMAL(18,2) DEFAULT 0, -- 优惠金额
total_amount DECIMAL(18,2) NOT NULL, -- 总金额
actual_amount DECIMAL(18,2) NOT NULL, -- 实付金额
-- 订单状态
status INTEGER NOT NULL DEFAULT 1, -- 1:待支付 2:待接单 3:制作中 4:待配送 5:配送中 6:已完成 7:已取消
payment_status INTEGER DEFAULT 0, -- 0:未支付 1:已支付 2:已退款
payment_method VARCHAR(20), -- 支付方式
payment_time TIMESTAMP WITH TIME ZONE,
-- 时间信息
estimated_delivery_time TIMESTAMP WITH TIME ZONE, -- 预计送达时间
accepted_at TIMESTAMP WITH TIME ZONE, -- 接单时间
cooking_at TIMESTAMP WITH TIME ZONE, -- 开始制作时间
delivered_at TIMESTAMP WITH TIME ZONE, -- 送达时间
completed_at TIMESTAMP WITH TIME ZONE, -- 完成时间
cancelled_at TIMESTAMP WITH TIME ZONE, -- 取消时间
remark TEXT, -- 备注
cancel_reason TEXT, -- 取消原因
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_orders_tenant_id ON orders(tenant_id);
CREATE INDEX idx_orders_order_no ON orders(order_no);
CREATE INDEX idx_orders_merchant_id ON orders(merchant_id);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_created_at ON orders(created_at);
```
#### order_items订单明细表
```sql
CREATE TABLE order_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
order_id UUID NOT NULL REFERENCES orders(id),
dish_id UUID NOT NULL REFERENCES dishes(id),
dish_name VARCHAR(100) NOT NULL, -- 冗余字段,防止菜品被删除
dish_image_url VARCHAR(500),
spec_id UUID REFERENCES dish_specs(id),
spec_name VARCHAR(50),
price DECIMAL(18,2) NOT NULL, -- 单价
quantity INTEGER NOT NULL, -- 数量
amount DECIMAL(18,2) NOT NULL, -- 小计
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_order_items_order_id ON order_items(order_id);
CREATE INDEX idx_order_items_dish_id ON order_items(dish_id);
```
### 2.6 配送管理
#### delivery_drivers配送员表
```sql
CREATE TABLE delivery_drivers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
merchant_id UUID REFERENCES merchants(id), -- NULL表示平台配送员
name VARCHAR(50) NOT NULL,
phone VARCHAR(20) UNIQUE NOT NULL,
id_card VARCHAR(18), -- 身份证号
vehicle_type VARCHAR(20), -- 车辆类型:电动车、摩托车
vehicle_number VARCHAR(20), -- 车牌号
status INTEGER NOT NULL DEFAULT 1, -- 1:空闲 2:配送中 3:休息 4:离线
current_latitude DECIMAL(10,7), -- 当前位置
current_longitude DECIMAL(10,7),
rating DECIMAL(3,2) DEFAULT 0,
total_deliveries INTEGER DEFAULT 0, -- 总配送单数
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_delivery_drivers_merchant_id ON delivery_drivers(merchant_id);
CREATE INDEX idx_delivery_drivers_status ON delivery_drivers(status);
```
#### delivery_tasks配送任务表
```sql
CREATE TABLE delivery_tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
order_id UUID NOT NULL REFERENCES orders(id),
driver_id UUID REFERENCES delivery_drivers(id),
pickup_address VARCHAR(500) NOT NULL, -- 取餐地址
pickup_latitude DECIMAL(10,7),
pickup_longitude DECIMAL(10,7),
delivery_address VARCHAR(500) NOT NULL, -- 送餐地址
delivery_latitude DECIMAL(10,7),
delivery_longitude DECIMAL(10,7),
distance INTEGER, -- 配送距离(米)
estimated_time INTEGER, -- 预计时长(分钟)
status INTEGER NOT NULL DEFAULT 1, -- 1:待分配 2:待取餐 3:配送中 4:已送达 5:异常
assigned_at TIMESTAMP WITH TIME ZONE, -- 分配时间
picked_at TIMESTAMP WITH TIME ZONE, -- 取餐时间
delivered_at TIMESTAMP WITH TIME ZONE, -- 送达时间
delivery_fee DECIMAL(18,2) DEFAULT 0, -- 配送费
remark TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_delivery_tasks_order_id ON delivery_tasks(order_id);
CREATE INDEX idx_delivery_tasks_driver_id ON delivery_tasks(driver_id);
CREATE INDEX idx_delivery_tasks_status ON delivery_tasks(status);
```
### 2.7 支付管理
#### payments支付记录表
```sql
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
order_id UUID NOT NULL REFERENCES orders(id),
user_id UUID NOT NULL REFERENCES users(id),
payment_no VARCHAR(50) UNIQUE NOT NULL, -- 支付单号
payment_method VARCHAR(20) NOT NULL, -- 支付方式wechat、alipay、balance
amount DECIMAL(18,2) NOT NULL,
status INTEGER NOT NULL DEFAULT 0, -- 0:待支付 1:支付中 2:成功 3:失败 4:已退款
third_party_no VARCHAR(100), -- 第三方支付单号
paid_at TIMESTAMP WITH TIME ZONE,
callback_data JSONB, -- 回调数据
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_payments_order_id ON payments(order_id);
CREATE INDEX idx_payments_payment_no ON payments(payment_no);
CREATE INDEX idx_payments_user_id ON payments(user_id);
```
#### refunds退款记录表
```sql
CREATE TABLE refunds (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
order_id UUID NOT NULL REFERENCES orders(id),
payment_id UUID NOT NULL REFERENCES payments(id),
refund_no VARCHAR(50) UNIQUE NOT NULL,
amount DECIMAL(18,2) NOT NULL,
reason TEXT,
status INTEGER NOT NULL DEFAULT 0, -- 0:待审核 1:退款中 2:成功 3:失败
third_party_no VARCHAR(100),
refunded_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_refunds_order_id ON refunds(order_id);
CREATE INDEX idx_refunds_payment_id ON refunds(payment_id);
```
### 2.8 营销管理
#### coupons优惠券表
```sql
CREATE TABLE coupons (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
merchant_id UUID REFERENCES merchants(id), -- NULL表示平台券
name VARCHAR(100) NOT NULL,
type INTEGER NOT NULL, -- 1:满减券 2:折扣券 3:代金券
discount_type INTEGER NOT NULL, -- 1:固定金额 2:百分比
discount_value DECIMAL(18,2) NOT NULL, -- 优惠值
min_order_amount DECIMAL(18,2) DEFAULT 0, -- 最低消费
max_discount_amount DECIMAL(18,2), -- 最大优惠金额(折扣券用)
total_quantity INTEGER NOT NULL, -- 总数量
received_quantity INTEGER DEFAULT 0, -- 已领取数量
used_quantity INTEGER DEFAULT 0, -- 已使用数量
valid_start_time TIMESTAMP WITH TIME ZONE NOT NULL,
valid_end_time TIMESTAMP WITH TIME ZONE NOT NULL,
status INTEGER NOT NULL DEFAULT 1, -- 1:正常 2:停用
description TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_coupons_merchant_id ON coupons(merchant_id);
CREATE INDEX idx_coupons_status ON coupons(status);
```
#### user_coupons用户优惠券表
```sql
CREATE TABLE user_coupons (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
coupon_id UUID NOT NULL REFERENCES coupons(id),
status INTEGER NOT NULL DEFAULT 1, -- 1:未使用 2:已使用 3:已过期
used_order_id UUID REFERENCES orders(id),
received_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
used_at TIMESTAMP WITH TIME ZONE,
expired_at TIMESTAMP WITH TIME ZONE NOT NULL
);
CREATE INDEX idx_user_coupons_user_id ON user_coupons(user_id);
CREATE INDEX idx_user_coupons_coupon_id ON user_coupons(coupon_id);
CREATE INDEX idx_user_coupons_status ON user_coupons(status);
```
### 2.9 评价管理
#### reviews评价表
```sql
CREATE TABLE reviews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
order_id UUID NOT NULL REFERENCES orders(id),
user_id UUID NOT NULL REFERENCES users(id),
merchant_id UUID NOT NULL REFERENCES merchants(id),
rating INTEGER NOT NULL, -- 评分 1-5
taste_rating INTEGER, -- 口味评分
package_rating INTEGER, -- 包装评分
delivery_rating INTEGER, -- 配送评分
content TEXT,
images JSONB, -- 评价图片
is_anonymous BOOLEAN DEFAULT FALSE,
reply_content TEXT, -- 商家回复
reply_at TIMESTAMP WITH TIME ZONE,
status INTEGER NOT NULL DEFAULT 1, -- 1:正常 2:隐藏
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_reviews_order_id ON reviews(order_id);
CREATE INDEX idx_reviews_user_id ON reviews(user_id);
CREATE INDEX idx_reviews_merchant_id ON reviews(merchant_id);
```
### 2.10 系统管理
#### system_users系统用户表
```sql
CREATE TABLE system_users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES tenants(id), -- NULL表示平台管理员
merchant_id UUID REFERENCES merchants(id), -- NULL表示租户管理员
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
real_name VARCHAR(50),
phone VARCHAR(20),
email VARCHAR(100),
role_id UUID REFERENCES roles(id),
status INTEGER NOT NULL DEFAULT 1,
last_login_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_system_users_username ON system_users(username);
CREATE INDEX idx_system_users_tenant_id ON system_users(tenant_id);
```
#### roles角色表
```sql
CREATE TABLE roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES tenants(id),
name VARCHAR(50) NOT NULL,
code VARCHAR(50) NOT NULL,
description TEXT,
permissions JSONB, -- 权限列表
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_roles_tenant_id ON roles(tenant_id);
```
#### operation_logs操作日志表
```sql
CREATE TABLE operation_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES tenants(id),
user_id UUID,
user_type VARCHAR(20), -- system_user, merchant_user, customer
module VARCHAR(50), -- 模块
action VARCHAR(50), -- 操作
description TEXT,
ip_address VARCHAR(50),
user_agent TEXT,
request_data JSONB,
response_data JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_operation_logs_tenant_id ON operation_logs(tenant_id);
CREATE INDEX idx_operation_logs_user_id ON operation_logs(user_id);
CREATE INDEX idx_operation_logs_created_at ON operation_logs(created_at);
```
## 3. 数据库索引策略
### 3.1 主键索引
- 所有表使用UUID作为主键自动创建主键索引
### 3.2 外键索引
- 所有外键字段创建索引,提升关联查询性能
### 3.3 业务索引
- 订单号、支付单号等唯一业务字段创建唯一索引
- 状态字段创建普通索引
- 时间字段created_at创建索引支持时间范围查询
### 3.4 复合索引
```sql
-- 订单查询常用复合索引
CREATE INDEX idx_orders_merchant_status_created ON orders(merchant_id, status, created_at DESC);
-- 用户订单查询
CREATE INDEX idx_orders_user_status_created ON orders(user_id, status, created_at DESC);
```
### 3.5 地理位置索引
```sql
-- 使用PostGIS扩展支持地理位置查询
CREATE EXTENSION IF NOT EXISTS postgis;
-- 门店位置索引
CREATE INDEX idx_merchant_stores_location ON merchant_stores
USING GIST(ST_MakePoint(longitude, latitude));
```
## 4. 数据库优化
### 4.1 分区策略
```sql
-- 订单表按月分区
CREATE TABLE orders_2024_01 PARTITION OF orders
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE orders_2024_02 PARTITION OF orders
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
```
### 4.2 物化视图
```sql
-- 商家统计物化视图
CREATE MATERIALIZED VIEW merchant_statistics AS
SELECT
m.id as merchant_id,
m.name,
COUNT(DISTINCT o.id) as total_orders,
SUM(o.actual_amount) as total_revenue,
AVG(r.rating) as avg_rating
FROM merchants m
LEFT JOIN orders o ON m.id = o.merchant_id AND o.status = 6
LEFT JOIN reviews r ON m.id = r.merchant_id
GROUP BY m.id, m.name;
CREATE UNIQUE INDEX ON merchant_statistics(merchant_id);
```
### 4.3 查询优化建议
- 避免SELECT *,只查询需要的字段
- 使用EXPLAIN分析查询计划
- 合理使用JOIN避免过多关联
- 大数据量查询使用分页
- 使用prepared statement防止SQL注入
## 5. 数据备份策略
### 5.1 备份方案
- **全量备份**每天凌晨2点执行
- **增量备份**每4小时执行一次
- **WAL归档**实时归档支持PITR
### 5.2 备份脚本示例
```bash
#!/bin/bash
# 全量备份
pg_dump -h localhost -U postgres -d takeout_saas -F c -f /backup/full_$(date +%Y%m%d).dump
# 保留最近30天的备份
find /backup -name "full_*.dump" -mtime +30 -delete
```
## 6. 数据迁移
### 6.1 EF Core Migrations
```bash
# 添加迁移
dotnet ef migrations add InitialCreate --project TakeoutSaaS.Infrastructure
# 更新数据库
dotnet ef database update --project TakeoutSaaS.Infrastructure
```
### 6.2 版本控制
- 所有数据库变更通过Migration管理
- Migration文件纳入版本控制
- 生产环境变更需要审核

View File

@@ -0,0 +1,885 @@
# 外卖SaaS系统 - API接口设计
## 1. API设计规范
### 1.1 RESTful规范
- 使用标准HTTP方法GET、POST、PUT、DELETE、PATCH
- URL使用名词复数形式`/api/orders`
- 使用HTTP状态码表示请求结果
- 版本控制:`/api/v1/orders`
### 1.2 请求规范
- **Content-Type**`application/json`
- **认证方式**Bearer Token (JWT)
- **租户识别**通过Header `X-Tenant-Id` 或从Token中解析
### 1.3 响应规范
```json
{
"success": true,
"code": 200,
"message": "操作成功",
"data": {},
"timestamp": "2024-01-01T12:00:00Z"
}
```
### 1.4 错误响应
```json
{
"success": false,
"code": 400,
"message": "参数错误",
"errors": [
{
"field": "phone",
"message": "手机号格式不正确"
}
],
"timestamp": "2024-01-01T12:00:00Z"
}
```
### 1.5 HTTP状态码
- **200 OK**:请求成功
- **201 Created**:创建成功
- **204 No Content**:删除成功
- **400 Bad Request**:参数错误
- **401 Unauthorized**:未认证
- **403 Forbidden**:无权限
- **404 Not Found**:资源不存在
- **409 Conflict**:资源冲突
- **422 Unprocessable Entity**:业务逻辑错误
- **500 Internal Server Error**:服务器错误
### 1.6 分页规范
```json
// 请求参数
{
"pageIndex": 1,
"pageSize": 20,
"sortBy": "createdAt",
"sortOrder": "desc"
}
// 响应格式
{
"success": true,
"data": {
"items": [],
"totalCount": 100,
"pageIndex": 1,
"pageSize": 20,
"totalPages": 5
}
}
```
## 2. 认证授权接口
### 2.1 用户登录
```http
POST /api/v1/auth/login
Content-Type: application/json
{
"phone": "13800138000",
"password": "password123",
"loginType": "customer" // customer, merchant, system
}
Response:
{
"success": true,
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs...",
"expiresIn": 7200,
"tokenType": "Bearer",
"userInfo": {
"id": "uuid",
"phone": "13800138000",
"nickname": "",
"avatar": "https://..."
}
}
}
```
### 2.2 刷新Token
```http
POST /api/v1/auth/refresh
Content-Type: application/json
{
"refreshToken": "eyJhbGciOiJIUzI1NiIs..."
}
Response:
{
"success": true,
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"expiresIn": 7200
}
}
```
### 2.3 用户注册
```http
POST /api/v1/auth/register
Content-Type: application/json
{
"phone": "13800138000",
"password": "password123",
"verificationCode": "123456",
"nickname": ""
}
```
### 2.4 发送验证码
```http
POST /api/v1/auth/send-code
Content-Type: application/json
{
"phone": "13800138000",
"type": "register" // register, login, reset_password
}
```
## 3. 商家管理接口
### 3.1 获取商家列表
```http
GET /api/v1/merchants?pageIndex=1&pageSize=20&keyword=&status=1
Authorization: Bearer {token}
Response:
{
"success": true,
"data": {
"items": [
{
"id": "uuid",
"name": "",
"logo": "https://...",
"rating": 4.5,
"totalSales": 1000,
"status": 1,
"createdAt": "2024-01-01T12:00:00Z"
}
],
"totalCount": 50,
"pageIndex": 1,
"pageSize": 20
}
}
```
### 3.2 获取商家详情
```http
GET /api/v1/merchants/{id}
Authorization: Bearer {token}
Response:
{
"success": true,
"data": {
"id": "uuid",
"name": "",
"logo": "https://...",
"description": "20",
"contactPhone": "400-123-4567",
"rating": 4.5,
"totalSales": 1000,
"status": 1,
"stores": [
{
"id": "uuid",
"name": "",
"address": "...",
"phone": "010-12345678"
}
]
}
}
```
### 3.3 创建商家
```http
POST /api/v1/merchants
Authorization: Bearer {token}
Content-Type: application/json
{
"name": "",
"logo": "https://...",
"description": "20",
"contactPhone": "400-123-4567",
"contactPerson": "",
"businessLicense": "91110000..."
}
```
### 3.4 更新商家信息
```http
PUT /api/v1/merchants/{id}
Authorization: Bearer {token}
Content-Type: application/json
{
"name": "",
"logo": "https://...",
"description": "20"
}
```
### 3.5 删除商家
```http
DELETE /api/v1/merchants/{id}
Authorization: Bearer {token}
```
## 4. 菜品管理接口
### 4.1 获取菜品列表
```http
GET /api/v1/dishes?merchantId={merchantId}&categoryId={categoryId}&keyword=&status=1&pageIndex=1&pageSize=20
Authorization: Bearer {token}
Response:
{
"success": true,
"data": {
"items": [
{
"id": "uuid",
"name": "",
"description": "",
"image": "https://...",
"price": 38.00,
"originalPrice": 48.00,
"salesCount": 500,
"rating": 4.8,
"status": 1,
"tags": ["", ""]
}
],
"totalCount": 100
}
}
```
### 4.2 获取菜品详情
```http
GET /api/v1/dishes/{id}
Authorization: Bearer {token}
Response:
{
"success": true,
"data": {
"id": "uuid",
"name": "",
"description": "...",
"image": "https://...",
"price": 38.00,
"originalPrice": 48.00,
"unit": "",
"stock": 100,
"salesCount": 500,
"rating": 4.8,
"status": 1,
"tags": ["", ""],
"specs": [
{
"id": "uuid",
"name": "",
"price": 48.00,
"stock": 50
},
{
"id": "uuid",
"name": "",
"price": 28.00,
"stock": 50
}
]
}
}
```
### 4.3 创建菜品
```http
POST /api/v1/dishes
Authorization: Bearer {token}
Content-Type: application/json
{
"merchantId": "uuid",
"categoryId": "uuid",
"name": "",
"description": "",
"image": "https://...",
"price": 38.00,
"originalPrice": 48.00,
"unit": "",
"stock": 100,
"tags": ["", ""],
"specs": [
{
"name": "",
"price": 48.00,
"stock": 50
}
]
}
```
### 4.4 更新菜品
```http
PUT /api/v1/dishes/{id}
Authorization: Bearer {token}
Content-Type: application/json
{
"name": "",
"price": 38.00,
"stock": 100,
"status": 1
}
```
### 4.5 批量上下架
```http
PATCH /api/v1/dishes/batch-status
Authorization: Bearer {token}
Content-Type: application/json
{
"dishIds": ["uuid1", "uuid2"],
"status": 1 // 1: 2:
}
```
## 5. 订单管理接口
### 5.1 创建订单
```http
POST /api/v1/orders
Authorization: Bearer {token}
Content-Type: application/json
{
"merchantId": "uuid",
"storeId": "uuid",
"items": [
{
"dishId": "uuid",
"specId": "uuid",
"quantity": 2,
"price": 38.00
}
],
"deliveryAddress": {
"contactName": "",
"contactPhone": "13800138000",
"address": "...",
"latitude": 39.9042,
"longitude": 116.4074
},
"remark": "",
"couponId": "uuid"
}
Response:
{
"success": true,
"data": {
"orderId": "uuid",
"orderNo": "202401010001",
"totalAmount": 76.00,
"deliveryFee": 5.00,
"discountAmount": 10.00,
"actualAmount": 71.00,
"paymentInfo": {
"paymentNo": "PAY202401010001",
"qrCode": "https://..." //
}
}
}
```
### 5.2 获取订单列表
```http
GET /api/v1/orders?status=&startDate=&endDate=&pageIndex=1&pageSize=20
Authorization: Bearer {token}
Response:
{
"success": true,
"data": {
"items": [
{
"id": "uuid",
"orderNo": "202401010001",
"merchantName": "",
"totalAmount": 76.00,
"actualAmount": 71.00,
"status": 2,
"statusText": "",
"createdAt": "2024-01-01T12:00:00Z",
"items": [
{
"dishName": "",
"specName": "",
"quantity": 2,
"price": 38.00
}
]
}
],
"totalCount": 50
}
}
```
### 5.3 获取订单详情
```http
GET /api/v1/orders/{id}
Authorization: Bearer {token}
Response:
{
"success": true,
"data": {
"id": "uuid",
"orderNo": "202401010001",
"merchant": {
"id": "uuid",
"name": "",
"phone": "400-123-4567"
},
"items": [
{
"dishName": "",
"dishImage": "https://...",
"specName": "",
"quantity": 2,
"price": 38.00,
"amount": 76.00
}
],
"deliveryAddress": {
"contactName": "",
"contactPhone": "13800138000",
"address": "..."
},
"dishAmount": 76.00,
"deliveryFee": 5.00,
"packageFee": 2.00,
"discountAmount": 10.00,
"totalAmount": 83.00,
"actualAmount": 73.00,
"status": 2,
"statusText": "",
"paymentStatus": 1,
"paymentMethod": "wechat",
"estimatedDeliveryTime": "2024-01-01T13:00:00Z",
"createdAt": "2024-01-01T12:00:00Z",
"paidAt": "2024-01-01T12:05:00Z",
"remark": "",
"timeline": [
{
"status": "created",
"statusText": "",
"time": "2024-01-01T12:00:00Z"
},
{
"status": "paid",
"statusText": "",
"time": "2024-01-01T12:05:00Z"
}
]
}
}
```
### 5.4 商家接单
```http
POST /api/v1/orders/{id}/accept
Authorization: Bearer {token}
Content-Type: application/json
{
"estimatedTime": 30 //
}
```
### 5.5 开始制作
```http
POST /api/v1/orders/{id}/cooking
Authorization: Bearer {token}
```
### 5.6 订单完成
```http
POST /api/v1/orders/{id}/complete
Authorization: Bearer {token}
```
### 5.7 取消订单
```http
POST /api/v1/orders/{id}/cancel
Authorization: Bearer {token}
Content-Type: application/json
{
"reason": "",
"cancelBy": "customer" // customer, merchant, system
}
```
## 6. 支付接口
### 6.1 创建支付
```http
POST /api/v1/payments
Authorization: Bearer {token}
Content-Type: application/json
{
"orderId": "uuid",
"paymentMethod": "wechat", // wechat, alipay, balance
"amount": 71.00
}
Response:
{
"success": true,
"data": {
"paymentNo": "PAY202401010001",
"qrCode": "https://...", //
"deepLink": "weixin://..." //
}
}
```
### 6.2 查询支付状态
```http
GET /api/v1/payments/{paymentNo}
Authorization: Bearer {token}
Response:
{
"success": true,
"data": {
"paymentNo": "PAY202401010001",
"status": 2, // 0: 1: 2: 3:
"amount": 71.00,
"paidAt": "2024-01-01T12:05:00Z"
}
}
```
### 6.3 支付回调(第三方调用)
```http
POST /api/v1/payments/callback/wechat
Content-Type: application/json
{
"out_trade_no": "PAY202401010001",
"transaction_id": "4200001234567890",
"total_fee": 7100,
"result_code": "SUCCESS"
}
```
### 6.4 申请退款
```http
POST /api/v1/refunds
Authorization: Bearer {token}
Content-Type: application/json
{
"orderId": "uuid",
"amount": 71.00,
"reason": ""
}
```
## 7. 配送管理接口
### 7.1 获取配送任务列表
```http
GET /api/v1/delivery-tasks?status=&driverId=&pageIndex=1&pageSize=20
Authorization: Bearer {token}
```
### 7.2 分配配送员
```http
POST /api/v1/delivery-tasks/{id}/assign
Authorization: Bearer {token}
Content-Type: application/json
{
"driverId": "uuid"
}
```
### 7.3 配送员接单
```http
POST /api/v1/delivery-tasks/{id}/accept
Authorization: Bearer {token}
```
### 7.4 确认取餐
```http
POST /api/v1/delivery-tasks/{id}/pickup
Authorization: Bearer {token}
```
### 7.5 确认送达
```http
POST /api/v1/delivery-tasks/{id}/deliver
Authorization: Bearer {token}
Content-Type: application/json
{
"deliveryCode": "123456" //
}
```
### 7.6 更新配送员位置
```http
POST /api/v1/delivery-drivers/location
Authorization: Bearer {token}
Content-Type: application/json
{
"latitude": 39.9042,
"longitude": 116.4074
}
```
## 8. 营销管理接口
### 8.1 获取优惠券列表
```http
GET /api/v1/coupons?merchantId=&status=1&pageIndex=1&pageSize=20
Authorization: Bearer {token}
```
### 8.2 领取优惠券
```http
POST /api/v1/coupons/{id}/receive
Authorization: Bearer {token}
```
### 8.3 获取用户优惠券
```http
GET /api/v1/user-coupons?status=1&pageIndex=1&pageSize=20
Authorization: Bearer {token}
Response:
{
"success": true,
"data": {
"items": [
{
"id": "uuid",
"couponName": "5010",
"discountValue": 10.00,
"minOrderAmount": 50.00,
"status": 1,
"expiredAt": "2024-12-31T23:59:59Z"
}
]
}
}
```
### 8.4 获取可用优惠券
```http
GET /api/v1/user-coupons/available?merchantId={merchantId}&amount={amount}
Authorization: Bearer {token}
```
## 9. 评价管理接口
### 9.1 创建评价
```http
POST /api/v1/reviews
Authorization: Bearer {token}
Content-Type: application/json
{
"orderId": "uuid",
"rating": 5,
"tasteRating": 5,
"packageRating": 5,
"deliveryRating": 5,
"content": "",
"images": ["https://...", "https://..."],
"isAnonymous": false
}
```
### 9.2 获取商家评价列表
```http
GET /api/v1/reviews?merchantId={merchantId}&rating=&pageIndex=1&pageSize=20
Response:
{
"success": true,
"data": {
"items": [
{
"id": "uuid",
"userName": "",
"userAvatar": "https://...",
"rating": 5,
"content": "",
"images": ["https://..."],
"createdAt": "2024-01-01T12:00:00Z",
"replyContent": "",
"replyAt": "2024-01-01T13:00:00Z"
}
],
"totalCount": 100,
"statistics": {
"avgRating": 4.8,
"totalReviews": 100,
"rating5Count": 80,
"rating4Count": 15,
"rating3Count": 3,
"rating2Count": 1,
"rating1Count": 1
}
}
}
```
### 9.3 商家回复评价
```http
POST /api/v1/reviews/{id}/reply
Authorization: Bearer {token}
Content-Type: application/json
{
"replyContent": ""
}
```
## 10. 数据统计接口
### 10.1 商家数据概览
```http
GET /api/v1/statistics/merchant/overview?merchantId={merchantId}&startDate=&endDate=
Authorization: Bearer {token}
Response:
{
"success": true,
"data": {
"totalOrders": 1000,
"totalRevenue": 50000.00,
"avgOrderAmount": 50.00,
"completionRate": 0.95,
"todayOrders": 50,
"todayRevenue": 2500.00,
"orderTrend": [
{
"date": "2024-01-01",
"orders": 50,
"revenue": 2500.00
}
],
"topDishes": [
{
"dishId": "uuid",
"dishName": "",
"salesCount": 200,
"revenue": 7600.00
}
]
}
}
```
### 10.2 平台数据大盘
```http
GET /api/v1/statistics/platform/dashboard?startDate=&endDate=
Authorization: Bearer {token}
Response:
{
"success": true,
"data": {
"totalMerchants": 100,
"totalUsers": 10000,
"totalOrders": 50000,
"totalRevenue": 2500000.00,
"activeMerchants": 80,
"activeUsers": 5000,
"todayOrders": 500,
"todayRevenue": 25000.00
}
}
```
## 11. 文件上传接口
### 11.1 上传图片
```http
POST /api/v1/files/upload
Authorization: Bearer {token}
Content-Type: multipart/form-data
file: <binary>
type: dish_image // dish_image, merchant_logo, user_avatar, review_image
Response:
{
"success": true,
"data": {
"url": "https://cdn.example.com/images/xxx.jpg",
"fileName": "xxx.jpg",
"fileSize": 102400
}
}
```
## 12. WebSocket实时通知
### 12.1 连接WebSocket
```javascript
// 连接地址
ws://api.example.com/ws?token={jwt_token}
// 订阅主题
{
"action": "subscribe",
"topics": ["order.new", "order.status", "delivery.location"]
}
// 接收消息
{
"topic": "order.new",
"data": {
"orderId": "uuid",
"orderNo": "202401010001",
"merchantId": "uuid"
},
"timestamp": "2024-01-01T12:00:00Z"
}
```
### 12.2 消息主题
- `order.new`:新订单通知
- `order.status`:订单状态变更
- `delivery.location`:配送员位置更新
- `payment.success`:支付成功通知

1011
Document/05_部署运维.md Normal file

File diff suppressed because it is too large Load Diff

395
Document/06_开发规范.md Normal file
View File

@@ -0,0 +1,395 @@
# 外卖SaaS系统 - 开发规范
## 1. 代码规范
### 1.1 命名规范
#### C#命名规范
```csharp
// 类名PascalCase
public class OrderService { }
// 接口I + PascalCase
public interface IOrderRepository { }
// 方法PascalCase
public async Task<Order> CreateOrderAsync() { }
// 私有字段_camelCase
private readonly IOrderRepository _orderRepository;
// 公共属性PascalCase
public string OrderNo { get; set; }
// 局部变量camelCase
var orderTotal = 100.00m;
// 常量PascalCase
public const int MaxOrderItems = 50;
// 枚举PascalCase
public enum OrderStatus
{
Pending = 1,
Confirmed = 2,
Completed = 3
}
```
#### 数据库命名规范
```sql
-- 表名:小写,下划线分隔,复数
orders
order_items
merchant_stores
-- 字段名:小写,下划线分隔
order_no
created_at
total_amount
-- 索引idx_表名_字段名
idx_orders_merchant_id
idx_orders_created_at
-- 外键fk_表名_引用表名
fk_orders_merchants
```
### 1.2 代码组织
#### 项目结构
```
TakeoutSaaS/
├── src/
│ ├── TakeoutSaaS.Api/ # Web API层
│ │ ├── Controllers/ # 控制器
│ │ ├── Filters/ # 过滤器
│ │ ├── Middleware/ # 中间件
│ │ ├── Models/ # DTO模型
│ │ └── Program.cs
│ ├── TakeoutSaaS.Application/ # 应用层
│ │ ├── Services/ # 应用服务
│ │ ├── DTOs/ # 数据传输对象
│ │ ├── Interfaces/ # 服务接口
│ │ ├── Validators/ # 验证器
│ │ ├── Mappings/ # 对象映射
│ │ └── Commands/ # CQRS命令
│ │ └── Queries/ # CQRS查询
│ ├── TakeoutSaaS.Domain/ # 领域层
│ │ ├── Entities/ # 实体
│ │ ├── ValueObjects/ # 值对象
│ │ ├── Enums/ # 枚举
│ │ ├── Events/ # 领域事件
│ │ └── Interfaces/ # 仓储接口
│ ├── TakeoutSaaS.Infrastructure/ # 基础设施层
│ │ ├── Data/ # 数据访问
│ │ │ ├── EFCore/ # EF Core实现
│ │ │ ├── Dapper/ # Dapper实现
│ │ │ └── Repositories/ # 仓储实现
│ │ ├── Cache/ # 缓存实现
│ │ ├── MessageQueue/ # 消息队列
│ │ └── ExternalServices/ # 外部服务
│ └── TakeoutSaaS.Shared/ # 共享层
│ ├── Constants/ # 常量
│ ├── Exceptions/ # 异常
│ ├── Extensions/ # 扩展方法
│ └── Results/ # 统一返回结果
├── tests/
│ ├── TakeoutSaaS.UnitTests/ # 单元测试
│ ├── TakeoutSaaS.IntegrationTests/ # 集成测试
│ └── TakeoutSaaS.PerformanceTests/ # 性能测试
└── docs/ # 文档
```
### 1.3 代码注释
```csharp
/// <summary>
/// 订单服务接口
/// </summary>
public interface IOrderService
{
/// <summary>
/// 创建订单
/// </summary>
/// <param name="request">订单创建请求</param>
/// <returns>订单信息</returns>
/// <exception cref="BusinessException">业务异常</exception>
Task<OrderDto> CreateOrderAsync(CreateOrderRequest request);
}
// 复杂业务逻辑添加注释
public async Task<decimal> CalculateOrderAmount(Order order)
{
// 1. 计算菜品总金额
var dishAmount = order.Items.Sum(x => x.Price * x.Quantity);
// 2. 计算配送费(距离 > 3km每公里加收2元
var deliveryFee = CalculateDeliveryFee(order.Distance);
// 3. 应用优惠券折扣
var discount = await ApplyCouponDiscountAsync(order.CouponId, dishAmount);
// 4. 计算最终金额
return dishAmount + deliveryFee - discount;
}
```
### 1.4 异常处理
```csharp
// 自定义业务异常
public class BusinessException : Exception
{
public int ErrorCode { get; }
public BusinessException(int errorCode, string message)
: base(message)
{
ErrorCode = errorCode;
}
}
// 全局异常处理中间件
public class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (BusinessException ex)
{
_logger.LogWarning(ex, "业务异常:{Message}", ex.Message);
await HandleBusinessExceptionAsync(context, ex);
}
catch (Exception ex)
{
_logger.LogError(ex, "系统异常:{Message}", ex.Message);
await HandleSystemExceptionAsync(context, ex);
}
}
private static Task HandleBusinessExceptionAsync(HttpContext context, BusinessException ex)
{
context.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;
return context.Response.WriteAsJsonAsync(new
{
success = false,
code = ex.ErrorCode,
message = ex.Message
});
}
}
// 使用示例
public async Task<Order> GetOrderAsync(Guid orderId)
{
var order = await _orderRepository.GetByIdAsync(orderId);
if (order == null)
{
throw new BusinessException(404, "订单不存在");
}
return order;
}
```
## 2. Git工作流
### 2.1 分支管理
```
main # 主分支,生产环境代码
├── develop # 开发分支
│ ├── feature/order-management # 功能分支
│ ├── feature/payment-integration # 功能分支
│ └── bugfix/order-calculation # 修复分支
└── hotfix/critical-bug # 紧急修复分支
```
### 2.2 分支命名规范
- **功能分支**`feature/功能名称`(如:`feature/order-management`
- **修复分支**`bugfix/问题描述`(如:`bugfix/order-calculation`
- **紧急修复**`hotfix/问题描述`(如:`hotfix/payment-error`
- **发布分支**`release/版本号`(如:`release/v1.0.0`
### 2.3 提交信息规范
```bash
# 格式:<type>(<scope>): <subject>
# type类型
# feat: 新功能
# fix: 修复bug
# docs: 文档更新
# style: 代码格式调整
# refactor: 重构
# perf: 性能优化
# test: 测试相关
# chore: 构建/工具相关
# 示例
git commit -m "feat(order): 添加订单创建功能"
git commit -m "fix(payment): 修复支付回调处理错误"
git commit -m "docs(api): 更新API文档"
git commit -m "refactor(service): 重构订单服务"
```
### 2.4 工作流程
```bash
# 1. 从develop创建功能分支
git checkout develop
git pull origin develop
git checkout -b feature/order-management
# 2. 开发并提交
git add .
git commit -m "feat(order): 添加订单创建功能"
# 3. 推送到远程
git push origin feature/order-management
# 4. 创建Pull Request到develop分支
# 5. 代码审查通过后合并
# 6. 删除功能分支
git branch -d feature/order-management
git push origin --delete feature/order-management
```
## 3. 代码审查
### 3.1 审查清单
- [ ] 代码符合命名规范
- [ ] 代码逻辑清晰,易于理解
- [ ] 适当的注释和文档
- [ ] 异常处理完善
- [ ] 单元测试覆盖
- [ ] 性能考虑N+1查询、大数据量处理
- [ ] 安全性考虑SQL注入、XSS、权限校验
- [ ] 日志记录完善
- [ ] 无硬编码配置
- [ ] 符合SOLID原则
### 3.2 审查重点
```csharp
// ❌ 不好的实践
public class OrderService
{
public Order CreateOrder(CreateOrderRequest request)
{
// 直接在服务层操作DbContext
var order = new Order();
_dbContext.Orders.Add(order);
_dbContext.SaveChanges();
// 硬编码配置
var deliveryFee = 5.0m;
// 没有异常处理
// 没有日志记录
return order;
}
}
// ✅ 好的实践
public class OrderService : IOrderService
{
private readonly IOrderRepository _orderRepository;
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<OrderService> _logger;
private readonly IOptions<OrderSettings> _settings;
public async Task<OrderDto> CreateOrderAsync(CreateOrderRequest request)
{
try
{
// 参数验证
if (request == null)
throw new ArgumentNullException(nameof(request));
_logger.LogInformation("创建订单:{@Request}", request);
// 业务逻辑
var order = new Order
{
// ... 初始化订单
DeliveryFee = _settings.Value.DefaultDeliveryFee
};
// 使用仓储
await _orderRepository.AddAsync(order);
await _unitOfWork.SaveChangesAsync();
_logger.LogInformation("订单创建成功:{OrderId}", order.Id);
return _mapper.Map<OrderDto>(order);
}
catch (Exception ex)
{
_logger.LogError(ex, "创建订单失败:{@Request}", request);
throw;
}
}
}
```
## 4. 单元测试规范
### 4.1 测试命名
- 命名格式:`MethodName_Scenario_ExpectedResult`
- 测试覆盖率要求:核心业务逻辑 >= 80%
### 4.2 测试示例
```csharp
[Fact]
public async Task CreateOrder_ValidRequest_ReturnsOrderDto()
{
// Arrange
var request = new CreateOrderRequest { /* ... */ };
// Act
var result = await _orderService.CreateOrderAsync(request);
// Assert
result.Should().NotBeNull();
result.OrderNo.Should().NotBeNullOrEmpty();
}
```
## 5. 性能优化规范
### 5.1 数据库查询优化
- 避免N+1查询使用Include预加载
- 大数据量查询使用Dapper
- 合理使用索引
### 5.2 缓存策略
- 商家信息30分钟
- 菜品信息15分钟
- 配置信息1小时
- 用户会话2小时
## 6. 文档要求
### 6.1 代码文档
- 所有公共API必须有XML文档注释
- 复杂业务逻辑添加详细注释
- README.md说明项目结构和运行方式
### 6.2 变更日志
维护CHANGELOG.md记录版本变更

View File

@@ -0,0 +1,321 @@
# 外卖SaaS系统 - 系统架构图
## 1. 整体架构图
```
┌─────────────────────────────────────────────────────────────────────┐
│ 客户端层 │
│ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ Web管理端 │ │ Web用户端 │ │ 小程序端(用户) │ │
│ │ (React/Vue) │ │ (React/Vue) │ │ (微信/支付宝) │ │
│ └──────────────┘ └──────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ API网关层 │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Nginx / API Gateway │ │
│ │ - 路由转发 │ │
│ │ - 负载均衡 │ │
│ │ - 限流熔断 │ │
│ │ - SSL终止 │ │
│ └───────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ 应用服务层 │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 租户服务 │ │ 商家服务 │ │ 菜品服务 │ │
│ │ - 租户管理 │ │ - 商家管理 │ │ - 菜品管理 │ │
│ │ - 权限管理 │ │ - 门店管理 │ │ - 分类管理 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 订单服务 │ │ 配送服务 │ │ 用户服务 │ │
│ │ - 订单管理 │ │ - 配送员管理 │ │ - 用户管理 │ │
│ │ - 订单流转 │ │ - 任务分配 │ │ - 地址管理 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 支付服务 │ │ 营销服务 │ │ 通知服务 │ │
│ │ - 支付处理 │ │ - 优惠券 │ │ - 短信通知 │ │
│ │ - 退款处理 │ │ - 活动管理 │ │ - 推送通知 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ 基础设施层 │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ PostgreSQL │ │ Redis │ │ RabbitMQ │ │
│ │ - 主数据库 │ │ - 缓存 │ │ - 消息队列 │ │
│ │ - 主从复制 │ │ - 会话存储 │ │ - 异步任务 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ MinIO/OSS │ │ Elasticsearch│ │ Prometheus │ │
│ │ - 对象存储 │ │ - 日志存储 │ │ - 监控告警 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
## 2. 应用分层架构
```
┌─────────────────────────────────────────────────────────────┐
│ Presentation Layer │
│ (表现层) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ TakeoutSaaS.Api │ │
│ │ - Controllers (控制器) │ │
│ │ - Filters (过滤器) │ │
│ │ - Middleware (中间件) │ │
│ │ - Models (DTO模型) │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Application Layer │
│ (应用层) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ TakeoutSaaS.Application │ │
│ │ - Services (应用服务) │ │
│ │ - DTOs (数据传输对象) │ │
│ │ - Interfaces (服务接口) │ │
│ │ - Validators (验证器) │ │
│ │ - Mappings (对象映射) │ │
│ │ - Commands/Queries (CQRS) │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Domain Layer │
│ (领域层) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ TakeoutSaaS.Domain │ │
│ │ - Entities (实体) │ │
│ │ - ValueObjects (值对象) │ │
│ │ - Enums (枚举) │ │
│ │ - Events (领域事件) │ │
│ │ - Interfaces (仓储接口) │ │
│ │ - Specifications (规约) │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Infrastructure Layer │
│ (基础设施层) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ TakeoutSaaS.Infrastructure │ │
│ │ - Data (数据访问) │ │
│ │ - EFCore (EF Core实现) │ │
│ │ - Dapper (Dapper实现) │ │
│ │ - Repositories (仓储实现) │ │
│ │ - Cache (缓存实现) │ │
│ │ - MessageQueue (消息队列) │ │
│ │ - ExternalServices (外部服务) │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
## 3. 订单处理流程图
```
用户下单 → 创建订单 → 支付 → 商家接单 → 制作 → 配送 → 完成
│ │ │ │ │ │ │
│ │ │ │ │ │ └─→ 订单完成
│ │ │ │ │ └─→ 配送中
│ │ │ │ └─→ 制作中
│ │ │ └─→ 待制作
│ │ └─→ 待接单
│ └─→ 待支付
└─→ 订单创建
取消流程:
用户取消 ──→ 退款处理 ──→ 订单取消
商家拒单 ──→ 退款处理 ──→ 订单取消
超时未支付 ──→ 自动取消
```
## 4. 数据流转图
```
┌──────────┐
│ 客户端 │
└────┬─────┘
│ HTTP Request
┌──────────────────┐
│ API Gateway │
│ (Nginx) │
└────┬─────────────┘
│ 路由转发
┌──────────────────┐
│ Web API │
│ - 认证授权 │
│ - 参数验证 │
└────┬─────────────┘
│ 调用服务
┌──────────────────┐
│ Application │
│ Service │
│ - 业务逻辑 │
└────┬─────────────┘
│ 数据访问
┌──────────────────┐ ┌──────────┐
│ Repository │────→│ Cache │
│ - EF Core │ │ (Redis) │
│ - Dapper │ └──────────┘
└────┬─────────────┘
│ SQL查询
┌──────────────────┐
│ PostgreSQL │
│ Database │
└──────────────────┘
```
## 5. 多租户数据隔离架构
```
┌─────────────────────────────────────────────────┐
│ 租户识别中间件 │
│ - 从JWT Token解析租户ID │
│ - 从HTTP Header获取租户ID │
└─────────────────────┬───────────────────────────┘
┌─────────────────────────────────────────────────┐
│ 租户上下文 │
│ - 当前租户ID │
│ - 租户配置信息 │
└─────────────────────┬───────────────────────────┘
┌─────────────────────────────────────────────────┐
│ 数据访问层 │
│ - 自动添加租户ID过滤 │
│ - 全局查询过滤器 │
└─────────────────────┬───────────────────────────┘
┌─────────────────────────────────────────────────┐
│ 数据库 │
│ 租户A数据 │ 租户B数据 │ 租户C数据 │
│ (tenant_id = A) (tenant_id = B) (tenant_id = C)│
└─────────────────────────────────────────────────┘
```
## 6. 缓存架构
```
┌──────────────┐
│ Application │
└──────┬───────┘
┌──────────────────────────────────┐
│ Cache Aside Pattern │
│ 1. 查询缓存 │
│ 2. 缓存未命中,查询数据库 │
│ 3. 写入缓存 │
└──────┬───────────────────────────┘
├─→ L1 Cache (Memory Cache)
│ - 进程内缓存
│ - 热点数据
└─→ L2 Cache (Redis)
- 分布式缓存
- 会话数据
- 共享数据
```
## 7. 消息队列架构
```
┌──────────────┐
│ Producer │
│ (订单服务) │
└──────┬───────┘
│ 发布事件
┌──────────────────┐
│ RabbitMQ │
│ Exchange │
└──────┬───────────┘
├─→ Queue: order.created
│ └─→ Consumer: 通知服务
├─→ Queue: order.paid
│ └─→ Consumer: 库存服务
└─→ Queue: order.completed
└─→ Consumer: 统计服务
```
## 8. 部署架构
```
┌─────────────────────────────────────────────────┐
│ 负载均衡器 (Nginx) │
└─────────────┬───────────────────────────────────┘
┌───────┴───────┐
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ API 1 │ │ API 2 │
│ (容器) │ │ (容器) │
└────┬─────┘ └────┬─────┘
│ │
└───────┬───────┘
┌───────┴────────┬──────────────┐
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│PostgreSQL│ │ Redis │ │ RabbitMQ │
│ 主从 │ │ 哨兵 │ │ 集群 │
└──────────┘ └──────────┘ └──────────┘
```
## 9. 监控架构
```
┌──────────────────────────────────────────┐
│ 应用程序 │
│ - 业务指标 │
│ - 性能指标 │
│ - 日志输出 │
└─────────┬────────────────────────────────┘
┌─────┴─────┬──────────┐
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│Metrics │ │ Logs │ │Traces │
│ │ │ │ │ │
└───┬────┘ └───┬────┘ └───┬────┘
│ │ │
▼ ▼ ▼
┌──────────────────────────────┐
│ Prometheus │
│ Elasticsearch │
│ Jaeger │
└─────────┬────────────────────┘
┌──────────────────────────────┐
│ Grafana │
│ Kibana │
│ - 可视化仪表板 │
│ - 告警配置 │
└──────────────────────────────┘
```

View File

@@ -0,0 +1,145 @@
# 编程规范_FOR_AITakeoutSaaS - 终极完全体
> **核心指令**:你是一个高级 .NET 架构师。本文件是你生成代码的最高宪法。当用户需求与本规范冲突时,请先提示用户,除非用户强制要求覆盖。
## 0. AI 交互核心约束 (元规则)
1. **语言**:必须使用**中文**回复和编写注释。
2. **文件完整性**
* **严禁**随意删除现有代码逻辑。
* **严禁**修改文件编码(保持 UTF-8 无 BOM
* PowerShell 读取命令必须带 `-Encoding UTF8`
3. **Git 原子性**:每个独立的功能点或 Bug 修复完成后,必须提示用户进行 Git 提交。
4. **无乱码承诺**确保所有输出控制台、日志、API响应无乱码。
5. **不确定的处理**:如果你通过上下文找不到某些配置(如数据库连接串格式),**请直接询问用户**,不要瞎编。
## 1. 技术栈详细版本
| 组件 | 版本/选型 | 用途说明 |
| :--- | :--- | :--- |
| **Runtime** | .NET 10 | 核心运行时 |
| **API** | ASP.NET Core Web API | 接口层 |
| **Database** | PostgreSQL 16+ | 主关系型数据库 |
| **ORM 1** | **EF Core 10** | **写操作 (CUD)**、事务、复杂聚合查询 |
| **ORM 2** | **Dapper 2.1+** | **纯读操作 (R)**、复杂报表、大批量查询 |
| **Cache** | Redis 7.0+ | 分布式缓存、Session |
| **MQ** | RabbitMQ 3.12+ | 异步解耦 (MassTransit) |
| **Libs** | MediatR, Serilog, FluentValidation | CQRS, 日志, 验证 |
## 2. 命名与风格 (严格匹配)
* **C# 代码**
* 类/接口/方法/属性:`PascalCase` (如 `OrderService`)
* **布尔属性**:必须加 `Is``Has` 前缀 (如 `IsDeleted`, `HasPayment`)
* 私有字段:`_camelCase` (如 `_orderRepository`)
* 参数/变量:`camelCase` (如 `orderId`)
* **PostgreSQL 数据库**
* 表名:`snake_case` + **复数** (如 `merchant_orders`)
* 列名:`snake_case` (如 `order_no`, `is_active`)
* 主键:`id` (类型 `bigint`)
* **文件规则**
* **一个文件一个类**。文件名必须与类名完全一致。
## 3. 分层架构 (Clean Architecture)
**你生成的代码必须严格归类到以下目录:**
* **`src/Api`**: 仅负责路由与 DTO 转换,**禁止**包含业务逻辑。
* **`src/Application`**: 业务编排层。必须使用 **CQRS** (`IRequestHandler`) 和 **Mediator**
* **`src/Domain`**: 核心领域层。包含实体、枚举、领域异常。**禁止**依赖 EF Core 等外部库。
* **`src/Infrastructure`**: 基础设施层。实现仓储、数据库上下文、第三方服务。
## 4. 注释与文档
* **强制 XML 注释**:所有 `public` 的类、方法、属性必须有 `<summary>`
* **步骤注释**:超过 5 行的业务逻辑,必须分步注释:
```csharp
// 1. 验证库存
// 2. 扣减余额
```
* **Swagger**:必须开启 JWT 鉴权按钮Request/Response 示例必须清晰。
## 5. 异常处理 (防御性编程)
* **禁止空 Catch**:严禁 `catch (Exception) {}`,必须记录日志或抛出。
* **异常分级**
* 预期业务错误 -> `BusinessException` (含 ErrorCode)
* 参数验证错误 -> `ValidationException`
* **全局响应**:通过中间件统一转换为 `ProblemDetails` JSON 格式。
## 6. 异步与日志
* **全异步**:所有 I/O 操作必须 `await`。**严禁** `.Result` 或 `.Wait()`。
* **结构化日志**
* ❌ `_logger.LogInfo("订单 " + id + " 创建成功");`
* ✅ `_logger.LogInformation("订单 {OrderId} 创建成功", id);`
* **脱敏**:严禁打印密码、密钥、支付凭证等敏感信息。
## 7. 依赖注入 (DI)
* **构造函数注入**:统一使用构造函数注入。
* **禁止项**
* ❌ 禁止使用 `[Inject]` 属性注入。
* ❌ 禁止使用 `ServiceLocator` (服务定位器模式)。
* ❌ 禁止在静态类中持有 ServiceProvider。
## 8. 数据访问规范 (重点执行)
### 8.1 Entity Framework Core (写/事务)
1. **无跟踪查询**:只读查询**必须**加 `.AsNoTracking()`。
2. **杜绝 N+1**:严禁在 `foreach` 循环中查询数据库。必须使用 `.Include()`。
3. **复杂查询**:关联表超过 2 层时,考虑使用 `.AsSplitQuery()`。
### 8.2 Dapper (读/报表)
1. **SQL 注入防御****严禁**拼接 SQL 字符串。必须使用参数化查询 (`@Param`)。
2. **字段映射**:注意 PostgreSQL (`snake_case`) 与 C# (`PascalCase`) 的映射配置。
## 9. 多租户与 ID 策略
* **ID 生成**
* **强制**使用 **雪花算法 (Snowflake ID)**。
* 类型C# `long` <-> DB `bigint`。
* **禁止**使用 UUID 或 自增 INT。
* **租户隔离**
* 所有业务表必须包含 `tenant_id`。
* 写入时自动填充,读取时强制过滤。
## 10. API 设计与序列化 (前端兼容)
* **大整数处理**
* 所有 `long` 类型 (Snowflake ID) 在 DTO 中**必须序列化为 string**。
* 方案DTO 属性加 `[JsonConverter(typeof(ToStringJsonConverter))]` 或全局配置。
* **DTO 规范**
* 输入:`XxxRequest`
* 输出:`XxxDto`
* **禁止** Controller 直接返回 Entity。
## 11. 模块化与复用
* **核心模块划分**Identity (身份), Tenancy (租户), Dictionary (字典), Storage (存储)。
* **公共库 (Shared)**:通用工具类、扩展方法、常量定义必须放在 `Core/Shared` 项目中,避免重复造轮子。
## 12. 测试规范
* **模式**Arrange-Act-Assert (AAA)。
* **工具**xUnit + Moq + FluentAssertions。
* **覆盖率**:核心 Domain 逻辑必须 100% 覆盖Service 层 ≥ 70%。
## 13. Git 工作流
* **提交格式 (Conventional Commits)**
* `feat`: 新功能
* `fix`: 修复 Bug
* `refactor`: 重构
* `docs`: 文档
* `style`: 格式调整
* **分支规范**`feature/功能名``bugfix/问题描述`。
## 14. 性能优化 (显式指令)
* **投影查询**:使用 `.Select(x => new Dto { ... })` 只查询需要的字段,减少 I/O。
* **缓存策略**Cache-Aside 模式。数据更新后必须立即失效缓存。
* **批量操作**
* EF Core 10使用 `ExecuteUpdateAsync` / `ExecuteDeleteAsync`。
* Dapper使用 `ExecuteAsync` 进行批量插入。
## 15. 安全规范
* **SQL 注入**:已在第 8 条强制参数化。
* **身份认证**Admin 端使用 JWT + RBAC小程序端使用 Session/Token。
* **密码存储**:必须使用 PBKDF2 或 BCrypt 加盐哈希。
## 16. 绝对禁止事项 (AI 自检清单)
**生成代码前,请自查是否违反以下红线:**
1. [ ] **SQL 注入**:是否拼接了 SQL 字符串?
2. [ ] **架构违规**:是否在 Controller/Domain 中使用了 DbContext
3. [ ] **数据泄露**:是否返回了 Entity 或打印了密码?
4. [ ] **同步阻塞**:是否使用了 `.Result` 或 `.Wait()`
5. [ ] **性能陷阱**:是否在循环中查询了数据库 (N+1)
6. [ ] **精度丢失**Long 类型的 ID 是否转为了 String
7. [ ] **配置硬编码**:是否直接写死了连接串或密钥?
---

View File

@@ -0,0 +1,79 @@
# 服务器文档
> 汇总原 12~15 号服务器记录,统一追踪账号、密码、用途与到期时间,便于统一维护。
## 1. 阿里云网关服务器
### 基础信息
- IP: 47.94.199.87
- 账户: root
- 密码: cJ5q2k2iW7XnMA^!
- 配置: 2 核 CPU / 2 GB 内存(阿里云轻量应用服务器)
- 地点: 北京
- 用途: 网关
- 到期时间: 2026-12-18
### 建议补充
- 系统版本: 待补充(执行 `cat /etc/os-release`
- 带宽/磁盘: 待补充
- 安全组/开放端口: 待补充
- 备份与监控: 待补充
- 变更记录: 待补充
## 2. 腾讯云主机 PostgreSQL 服务器
### 基础信息
- IP: 120.53.222.17
- 账户: ubuntu
- 密码: P3y$nJt#zaa4%fh5
- 配置: 2 核 CPU / 4 GB 内存
- 地点: 北京
- 用途: 主 PostgreSQL / 数据库服务器
- 到期时间: 2026-11-26 11:22:01
### 建议补充
- 系统版本: 待补充(执行 `cat /etc/os-release`
- 带宽/磁盘: 待补充
- 数据目录: 待补充(示例 `/var/lib/postgresql`
- 数据备份/监控: 待补充
- 安全组/开放端口: 待补充
- 变更记录: 待补充
## 3. 天翼云主机 应用服务器
### 基础信息
- IP: 49.7.179.246
- 账户: root
- 密码: 7zE&84XI6~w57W7N
- 配置: 4 核 CPU / 8 GB 内存(天翼云)
- 地点: 北京
- 用途: 主应用服务器(承载 Admin/User/Mini API 或网关实例)
- 到期时间: 2027-10-04 17:17:57
### 建议补充
- 系统版本: 待补充(执行 `cat /etc/os-release`
- 带宽/磁盘: 待补充
- 部署路径: 待补充(示例 `/opt/takeoutsaas`
- 进程/端口: 待补充(示例 `AdminApi/UserApi/MiniApi``:8080`
- 日志/监控: 待补充Serilog 文件目录、进程监控方式)
- 安全组/开放端口: 待补充(按 API/网关暴露的 HTTP/HTTPS 端口)
- 变更记录: 待补充
## 4. 腾讯云 Redis/RabbitMQ 服务器
### 基础信息
- IP: 49.232.6.45
- 账户: ubuntu
- 密码: Z7NsRjT&XnWg7%7X
- 配置: 2 核 CPU / 4 GB 内存
- 地点: 北京
- 用途: Redis 与 RabbitMQ
- 到期时间: 2028-11-26
### 建议补充
- 系统版本: 待补充(执行 `cat /etc/os-release`
- 带宽/磁盘: 待补充
- 安全组/开放端口: 待补充Redis 6379RabbitMQ 5672/15672 等)
- 数据持久化与备份: 待补充
- 监控与告警: 待补充
- 变更记录: 待补充

View File

@@ -0,0 +1,131 @@
# 设计时 DbContext 配置指引
> 目的:在执行 `dotnet ef` 命令时无需硬编码数据库连接,可根据 appsettings 与环境变量自动加载。本文覆盖环境变量设置、配置目录指定等细节。
## 三库迁移命令 只需更改 SnowflakeIds_App 迁移关键字
> 先生成迁移,再执行数据库更新。启动项目统一用 AdminApi 确保加载最新配置。
### 生成迁移
```bash
# App 主库
dotnet tool run dotnet-ef migrations add SnowflakeIds_App `
--project src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj `
--startup-project src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj `
--context TakeoutSaaS.Infrastructure.App.Persistence.TakeoutAppDbContext
# Identity 库
dotnet tool run dotnet-ef migrations add SnowflakeIds_Identity `
--project src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj `
--startup-project src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj `
--context TakeoutSaaS.Infrastructure.Identity.Persistence.IdentityDbContext
# Dictionary 库
dotnet tool run dotnet-ef migrations add SnowflakeIds_Dictionary `
--project src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj `
--startup-project src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj `
--context TakeoutSaaS.Infrastructure.Dictionary.Persistence.DictionaryDbContext
```
### 更新数据库
```bash
dotnet tool run dotnet-ef database update `
--project src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj `
--startup-project src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj `
--context TakeoutSaaS.Infrastructure.App.Persistence.TakeoutAppDbContext
dotnet tool run dotnet-ef database update `
--project src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj `
--startup-project src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj `
--context TakeoutSaaS.Infrastructure.Identity.Persistence.IdentityDbContext
dotnet tool run dotnet-ef database update `
--project src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj `
--startup-project src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj `
--context TakeoutSaaS.Infrastructure.Dictionary.Persistence.DictionaryDbContext
```
## 一、设计时工厂读取逻辑概述
设计时工厂(`DesignTimeDbContextFactoryBase<T>`)按下面顺序解析连接串:
1. 若设置了 `TAKEOUTSAAS_APP_CONNECTION` / `TAKEOUTSAAS_IDENTITY_CONNECTION` / `TAKEOUTSAAS_DICTIONARY_CONNECTION` 等环境变量,则优先使用。
2. 否则查找配置文件:
- 从当前目录开始向上找到含 `TakeoutSaaS.sln` 的仓库根。
- 依次检查 `src/Api/TakeoutSaaS.AdminApi``src/Api/TakeoutSaaS.UserApi``src/Api/TakeoutSaaS.MiniApi` 等目录,如果存在 `appsettings.json``appsettings.{Environment}.json` 则加载。
- 若未找到,可通过环境变量 `TAKEOUTSAAS_APPSETTINGS_DIR` 指定包含 appsettings 文件的目录。
配置结构示例(出现在 AdminApi/MiniApi/UserApi 的 appsettings
```json
"Database": {
"DataSources": {
"AppDatabase": {
"Write": "Host=120.53...;Database=takeout_app_db;Username=...;Password=...",
"Reads": [
"Host=120.53...;Database=takeout_app_db;Username=...;Password=..."
]
},
"IdentityDatabase": {
"Write": "...",
"Reads": [ "..." ]
},
"DictionaryDatabase": {
"Write": "...",
"Reads": [ "..." ]
}
}
}
```
设计时工厂会根据数据源名称(`DatabaseConstants.AppDataSource` 等)读取 `Write` 连接串,实现与运行时一致。
## 二、环境变量配置
### 1. Windows PowerShell
```powershell
# 指向包含 appsettings.json 的目录
$env:TAKEOUTSAAS_APPSETTINGS_DIR = \"D:\\HAZCode\\TakeOut\\src\\Api\\TakeoutSaaS.AdminApi\"
#(可选)覆盖 AppDatabase 连接串
$env:TAKEOUTSAAS_APP_CONNECTION = \"Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=***\"
#(可选)覆盖 IdentityDatabase 连接串
$env:TAKEOUTSAAS_IDENTITY_CONNECTION = \"Host=...;Database=takeout_identity_db;Username=...;Password=...\"
#(可选)覆盖 DictionaryDatabase 连接串
$env:TAKEOUTSAAS_DICTIONARY_CONNECTION = "Host=...;Database=takeout_dictionary_db;Username=...;Password=..."
```
### 2. Linux / macOS
```bash
export TAKEOUTSAAS_APPSETTINGS_DIR=/home/user/TakeOut/src/Api/TakeoutSaaS.AdminApi
export TAKEOUTSAAS_APP_CONNECTION=\"Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=***\"
export TAKEOUTSAAS_IDENTITY_CONNECTION=\"Host=...;Database=takeout_identity_db;Username=...;Password=...\"
export TAKEOUTSAAS_DICTIONARY_CONNECTION="Host=...;Database=takeout_dictionary_db;Username=...;Password=..."
```
> 注意:若设置了 `TAKEOUTSAAS_APP_CONNECTION`,则无需在 appsettings 中提供 `Write` 连接串,反之亦然。不要将明文密码写入代码仓库,建议使用 Secret Manager 或部署环境的安全存储。
## 三、执行脚本示例
完成上述环境变量配置后即可执行:
```powershell
# TakeoutAppDbContext业务库
dotnet tool run dotnet-ef database update `
--project src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj `
--startup-project src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj `
--context TakeoutSaaS.Infrastructure.App.Persistence.TakeoutAppDbContext
# IdentityDbContext身份库
dotnet tool run dotnet-ef database update `
--project src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj `
--startup-project src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj `
--context TakeoutSaaS.Infrastructure.Identity.Persistence.IdentityDbContext
# DictionaryDbContext字典库
dotnet tool run dotnet-ef database update `
--project src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj `
--startup-project src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj `
--context TakeoutSaaS.Infrastructure.Dictionary.Persistence.DictionaryDbContext
```
若需迁移 Identity/Dictionary 等上下文,替换 `--context` 参数为对应类型即可。
## 四、常见问题
1. **未找到 appsettings**:确保 `TAKEOUTSAAS_APPSETTINGS_DIR` 指向存在 `appsettings.json` 的目录,或将命令在 API 项目目录中执行。
2. **密码错误**:确认远程 PostgreSQL 用户/密码是否与 appsettings 或环境变量一致,避免在 CLI 中使用默认的账号。
3. **多环境配置**`ASPNETCORE_ENVIRONMENT` 变量可控制加载 `appsettings.{Environment}.json`;默认是 Development。

65
Document/11_SystemTodo.md Normal file
View File

@@ -0,0 +1,65 @@
# TODO Roadmap
> 当前列表为原 11 号文档中的待办事项,已迁移到此处并统一以复选框形式标记。若无特殊说明,均尚未完成。
## 1. 配置与基础设施(高优)
- [x] Development/Production 数据库连接与 Secret 落地Staging 暂不需要)。
- [x] Redis 服务部署完毕并记录配置。
- [x] RabbitMQ 服务部署完毕并记录配置。
- [x] COS 密钥配置补录完毕。
- [ ] OSS 密钥配置补录完毕(已忽略,待采购后再补录)。
- [ ] SMS 平台密钥配置补录完毕(已忽略,待采购后再补录)。
- [x] WeChat Mini 程序密钥配置补录完毕AppIDwx30f91e6afe79f405AppSecret64324a7f604245301066ba7c3add488e已同步到 admin/mini 配置并登记更新人)。
- [x] PostgreSQL 基础实例部署完毕并记录配置。
- [x] Postgres/Redis 接入文档 + IaC/脚本补齐(见 Document/infra/postgres_redis.md 与 deploy/postgres|redis
- [x] RabbitMQ/Redis/Hangfire storage scripts available (see deploy/postgres and deploy/redis).
- [ ] admin/mini/user/gateway 网关域名、证书、CORS 列表整理完成。(忽略,暂时不用完成)
- [ ] Hangfire Dashboard 启用并新增 Admin 角色验证/网关白名单。(忽略,暂时不用完成)
## 2. 数据与迁移(高优)
- [x] App/Identity/Dictionary/Hangfire 四个 DbContext 均生成初始 Migration 并成功 update database。
- [x] 商户/门店/商品/订单/支付/配送等实体与仓储实现完成,提供 CRUD + 查询。
- [x] 系统参数、默认租户、管理员账号、基础字典的种子脚本可重复执行。
## 3. 稳定性与质量(低优先级)
- [ ] Dictionary/Identity/Storage/Sms/Messaging/Scheduler 的 xUnit+FluentAssertions 单元测试框架搭建。
- [ ] WebApplicationFactory + Testcontainers 拉起 Postgres/Redis/RabbitMQ 的集成测试模板。
- [ ] .editorconfig、.globalconfig、Roslyn 分析器配置仓库通用规则并启用 CI 检查。
## 4. 安全与合规
- [x] RBAC 权限、租户隔离、用户/权限洞察 API 完整演示并在 Swagger 中提供示例。
- [x] 现状梳理:租户解析/过滤已具备TenantResolutionMiddleware、TenantAwareDbContextJWT 已写入 roles/permissions/tenant_idJwtTokenServicePermissionAuthorize 已在 Admin API 使用CurrentUserProfile 含角色/权限/租户;但仅有内嵌 string[] 权限存储,无角色/权限表与洞察查询Swagger 缺少示例与多租户示例。
- [x] 差距与步骤:
- [x] 增加权限/租户洞察查询(按用户、按租户分页)并确保带 tenant 过滤TenantAwareDbContext 或 Dapper 参数化)。
- [x] 输出可读的角色/权限列表(基于现有种子/配置的只读查询。【已落地RBAC1 模型 + 角色/权限管理 APISwagger 示例后续补充】
- [x] 为洞察接口和 /auth/profile 增加 Swagger 示例,包含 tenant_id、roles、permissions展示 Bearer 示例与租户 Header 示例。
- [ ] 若用 Dapper 读侧SQL 必须参数化并显式过滤 tenant_id。
- [x] 计划顺序Step A 设计应用层洞察 DTO/QueryStep B Admin API 只读端点Authorize/PermissionAuthorizeStep C Swagger 示例扩展Step D 校验租户过滤与忽略路径配置。
- [x] Step D 校验Admin API 管道已在 Auth 之前使用 TenantResolution中间件未忽略新接口查询使用 TenantAwareDbContext + ITenantProvider 双重租户校验,暂无需调整。后续若加 Dapper 读侧需显式带 tenant 过滤。
- [ ] 登录/刷新流程增加 IP 校验、租户隔离、验证码/频率限制。
- [ ] 登录/权限/敏感操作日志可追溯,提供查询接口或 Kibana Saved Search。
- [ ] Secret Store/KeyVault/KMS 管理敏感配置,禁止密钥写入 Git/数据库明文。
## 5. 观测与运维
- [x] TraceId 贯通Serilog 输出 Console/FileELK 待后续配置)。
- [x] Prometheus exporter 暴露关键指标,/health 探针与告警规则同步推送。
- [ ] PostgreSQL 全量/增量备份脚本及一次真实恢复演练报告。
## 6. 业务能力补全
- [ ] 商户/门店/菜品 API 完成并在 MQ 中投递上架/支付成功事件。
- [ ] 配送对接 API 支持下单/取消/查询并完成签名验签中间件。
- [ ] 小程序端商品浏览、下单、支付、评价、图片直传等 API 可闭环跑通。
## 7. 前后台 UI 对接
- [ ] Admin UI 通过 OpenAPI 生成或手写界面,接入 Hangfire Dashboard/MQ 监控只读模式。
- [ ] 小程序端完成登录、菜单浏览、下单、支付、物流轨迹、素材直传闭环。
## 8. CI/CD 与发布
- [ ] CI/CD 流水线覆盖构建、发布、静态扫描、数据库迁移。
- [ ] Dev/Staging/Prod 多环境配置矩阵 + 基础设施 IaC 脚本。
- [ ] 版本与发布说明模板整理并在仓库中提供示例。
## 9. 文档与知识库
- [ ] 接口文档、领域模型、关键约束使用 Markdown 或 API Portal 完整记录。
- [ ] 运行手册包含部署步骤、资源拓扑、故障排查手册。
- [ ] 安全合规模板覆盖数据分级、密钥管理、审计流程并形成可复用表格。

View File

@@ -0,0 +1,53 @@
# 里程碑待办追踪
> 按“小程序版模块规划”划分四个里程碑;每个里程碑只含对应范围的任务,便于分阶段推进。
---
## Phase 1当前阶段租户/商家入驻、门店与菜品、扫码堂食、基础下单支付、预购自提、第三方配送骨架
- [ ] 管理端租户 API注册、实名认证、套餐订阅/续费/升降配、审核流Swagger ≥6 个端点,含审核日志。
- [ ] 商家入驻 API证照上传、合同管理、类目选择驱动待审/审核/驳回/通过状态机,文件持久在 COS。
- [ ] RBAC 模板平台管理员、租户管理员、店长、店员四角色模板API 可复制并允许租户自定义扩展。
- [ ] 配额与套餐TenantPackage CRUD、订阅/续费/配额校验(门店/账号/短信/配送单量),超额返回 409 并记录 TenantQuotaUsage。
- [ ] 租户运营面板:欠费/到期告警、账单列表、公告通知接口,支持已读状态并在 Admin UI 展示。
- [ ] 门店管理Store/StoreBusinessHour/StoreDeliveryZone/StoreHoliday CRUD 完整,含 GeoJSON 配送范围及能力开关。
- [ ] 桌码管理:批量生成桌码、绑定区域/容量、导出二维码 ZIPPOST /api/admin/stores/{id}/tables 可下载)。
- [ ] 员工排班:创建员工、绑定门店角色、维护 StoreEmployeeShift可查询未来 7 日排班。
- [ ] 桌码扫码入口Mini 端解析二维码GET /api/mini/tables/{code}/context 返回门店、桌台、公告。
- [ ] 菜品建模分类、SPU、SKU、规格/加料组、价格策略、媒资 CRUD + 上下架流程Mini 端可拉取完整 JSON。
- [ ] 库存体系SKU 库存、批次、调整、售罄管理,支持预售/档期锁定并在订单中扣减/释放。
- [ ] 自提档期门店配置自提时间窗、容量、截单时间Mini 端据此限制下单时间。
- [ ] 购物车服务ShoppingCart/CartItem/CartItemAddon API 支持并发锁、限购、券/积分预校验,保证并发无脏数据。
- [ ] 订单与支付:堂食/自提/配送下单、微信/支付宝支付、优惠券/积分抵扣、订单状态机与通知链路齐全。
- [ ] 桌台账单:合单/拆单、结账、电子小票、桌台释放,完成结账后恢复 Idle 并生成票据 URL。
- [ ] 自配送骨架骑手管理、取送件信息录入、费用补贴记录Admin 端可派单并更新 DeliveryOrder。
- [ ] 第三方配送抽象:统一下单/取消/加价/查询接口,支持达达、美团、闪送等,含回调验签与异常补偿骨架。
- [ ] 预购自提核销:提货码生成、手机号/二维码核销、自提柜/前台流程,超时自动取消或退款,记录操作者与时间。
- [ ] 指标与日志Prometheus 输出订单创建、支付成功率、配送回调耗时等Grafana ≥8 个图表;关键流程日志记录 TraceId + 业务 ID。
- [ ] 测试Phase 1 核心 API 具备 ≥30 条自动化用例(单元 + 集成),覆盖租户→商户→下单链路。
---
## Phase 2下一阶段拼单、优惠券与基础营销、会员积分/会员日、客服聊天、同城自配送调度、搜索
- [ ] 拼单引擎GroupOrder/Participant CRUD、发起/加入/成团条件、自动解散与退款、团内消息与提醒。
- [ ] 优惠券与基础营销:模板管理、领券、核销、库存/有效期/叠加规则,基础抽奖/秒杀/满减活动。
- [ ] 会员与积分:会员档案、等级/成长值、会员日通知;积分获取/消耗、有效期、黑名单。
- [ ] 客服聊天:实时会话、机器人/人工切换、排队/转接、消息模板、敏感词审查、工单流转与评价。
- [ ] 同城自配送调度:骑手智能指派、路线估时、无接触配送、费用补贴策略、调度看板。
- [ ] 搜索:门店/菜品/活动/优惠券搜索,过滤/排序、热门/历史记录、联想与纠错。
---
## Phase 3分销返利、签到打卡、预约预订、地图导航、社区、高阶营销、风控与补偿
- [ ] 分销返利AffiliatePartner/Order/Payout 管理,佣金阶梯、结算周期、税务信息、违规处理。
- [ ] 签到打卡CheckInCampaign/Record、连签奖励、补签、积分/券/成长值奖励、反作弊机制。
- [ ] 预约预订:档期/资源占用、预约下单/支付、提醒/改期/取消、到店核销与履约记录。
- [ ] 地图导航扩展:附近门店/推荐、距离/路线规划、跳转原生导航、导航请求埋点。
- [ ] 社区:动态发布、评论、点赞、话题/标签、图片/视频审核、举报与风控,店铺口碑展示。
- [ ] 高阶营销:秒杀/抽奖/裂变、裂变海报、爆款推荐位、多渠道投放分析。
- [ ] 风控与审计:黑名单、频率限制、异常行为监控、审计日志、补偿与告警体系。
---
## Phase 4性能优化、缓存、运营大盘、测试与文档、上线与监控
- [ ] 性能与缓存:热点接口缓存、慢查询治理、批处理优化、异步化改造。
- [ ] 可靠性:幂等与重试策略、任务调度补偿、链路追踪、告警联动。
- [ ] 运营大盘:交易/营销/履约/用户维度的细分报表、GMV/成本/毛利分析。
- [ ] 文档与测试:完整测试矩阵、性能测试报告、上线手册、回滚方案。
- [ ] 监控与运维:上线发布流程、灰度/回滚策略、系统稳定性指标、24x7 监控与告警。

View File

@@ -0,0 +1,62 @@
# App 数据种子使用说明App:Seed
> 作用:在启动时自动创建默认租户与基础字典,便于本地/测试环境快速落地必备数据。由 `AppDataSeeder` 执行,支持幂等多次运行。
## 配置入口
- 文件位置:`appsettings.Seed.{Environment}.json`AdminApi 下新增独立种子文件,示例已写入 Development
- 配置节:`App:Seed`
示例(已写入 `src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json`
```json
{
"App": {
"Seed": {
"Enabled": true,
"DefaultTenant": {
"TenantId": 1000000000001,
"Code": "demo",
"Name": "Demo租户",
"ShortName": "Demo",
"ContactName": "DemoAdmin",
"ContactPhone": "13800000000"
},
"DictionaryGroups": [
{
"Code": "order_status",
"Name": "订单状态",
"Scope": "Business",
"Items": [
{ "Key": "pending", "Value": "待支付", "SortOrder": 10 },
{ "Key": "paid", "Value": "已支付", "SortOrder": 20 },
{ "Key": "finished", "Value": "已完成", "SortOrder": 30 }
]
},
{
"Code": "store_tags",
"Name": "门店标签",
"Scope": "Business",
"Items": [
{ "Key": "hot", "Value": "热门", "SortOrder": 10 },
{ "Key": "new", "Value": "新店", "SortOrder": 20 }
]
}
]
}
}
}
```
## 字段说明
- `Enabled`: 是否启用种子
- `DefaultTenant`: 默认租户(使用雪花 long ID0 表示让雪花生成)
- `DictionaryGroups`: 基础字典,`Scope` 可选 `System`/`Business``Items` 支持幂等运行更新
## 运行方式
1. 确保 Admin API 已调用 `AddAppInfrastructure`Program.cs 已注册,会启用 `AppDataSeeder`)。
2. 修改 `appsettings.Seed.{Environment}.json``App:Seed` 后,启动 Admin API即会自动执行种子逻辑幂等
3. 查看日志:`AppSeed` 前缀会输出创建/更新结果。
## 注意事项
- ID 必须用 long雪花不要再使用 Guid/自增。
- 系统租户使用 `TenantId = 0`;业务租户请填写实际雪花 ID。
- 字典分组编码需唯一;重复运行会按编码合并更新。
- 生产环境请按需开启 `Enabled`,避免误写入。

View File

@@ -0,0 +1,40 @@
# 14_OpenTelemetry 接入指引
> 现状Admin/Mini/User API 已集成 OTel 埋点,可导出到 Collector/控制台/文件日志,默认关闭 OTLP 导出。
## 1. 依赖与版本
- NuGet`OpenTelemetry.Extensions.Hosting``OpenTelemetry.Instrumentation.AspNetCore``OpenTelemetry.Instrumentation.Http``OpenTelemetry.Instrumentation.EntityFrameworkCore``OpenTelemetry.Instrumentation.Runtime``OpenTelemetry.Exporter.OpenTelemetryProtocol``OpenTelemetry.Exporter.Console`
- 当前 EF Core instrumentation 由 NuGet 回退到 `1.10.0-beta.1`(会提示 NU1603/NU1902待可用时统一升级到稳定版以消除告警。
## 2. 程序内配置Admin/Mini/User API
- Resource`ServiceName` 分别为 `TakeoutSaaS.AdminApi|MiniApi|UserApi``ServiceInstanceId = Environment.MachineName`
- Tracing开启 ASP.NET Core、HttpClient、EF Core禁用 SQL 文本、Runtime采样器默认 `ParentBased + AlwaysOn`
- Metrics开启 ASP.NET Core、HttpClient、Runtime。
- Exporter
- OTLP可选读取 `Otel:Endpoint`,非空时启用。
- Console`Otel:UseConsoleExporter`(默认 Dev 开启Prod 关闭)。
- 日志Serilog 输出 Console + 文件(按天滚动,保留 7 天),模板已包含 TraceId/SpanId通过 Enrich FromLogContext
## 3. appsettings 配置键
```json
"Otel": {
"Endpoint": "", // 为空则不推 OTLP例如 http://otel-collector:4317
"Sampling": "ParentBasedAlwaysOn",
"UseConsoleExporter": true // Dev 默认 trueProd 建议 false
}
```
- 环境变量可覆盖:`OTEL_SERVICE_NAME``OTEL_EXPORTER_OTLP_ENDPOINT` 等。
## 4. Collector/后端接入建议
- Collector 监听 4317/4318gRPC/HTTP OTLP做采样/脱敏/分流,再转发 Jaeger/Tempo/ELK/Datadog 等。
- 生产注意:限制导出 SQL 文本(已关闭)、对敏感字段脱敏,必要时在 Collector 做 TraceIdRatioBased 采样以控量。
## 5. 验证步骤
1) 开启 `Otel:UseConsoleExporter=true`,本地运行 API观察控制台是否输出 Span/Metric。
2) 配置 `Otel:Endpoint=http://localhost:4317` 并启动 Collector使用 Jaeger/Tempo UI 或 `curl http://localhost:4318/v1/traces` 验证链路。
3) 文件日志:查看 `logs/admin-api-*.log` 等,确认包含 TraceId/SpanId。
## 6. 后续工作
- 待 NuGet 源更新后,升级到稳定版 OTel 包并消除 NU1603/NU1902 告警。
- 如需采集日志到 ELK可直接用 Filebeat/Vector 读取 `logs/*.log` 推送,无需改代码。
- 如需控制采样率或关闭某些 instrumentation调整 appsettings 中的 Sampling/开关后重启即可。

View File

@@ -0,0 +1,52 @@
# API 边界与自检清单
> 目的:明确 Admin/User/Mini 三个 API 的职责边界避免跨端耦合。开发新接口或改动现有控制器时请对照自检确保租户、安全、DTO、路由符合约定。
## 1. AdminApi管理后台
- **面向对象**:运营、客服、商户管理员。
- **职责**:租户/门店/商品/订单/支付/配送/字典/权限/RBAC/审计/任务调度等后台管理与洞察。
- **鉴权**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`
- **自检清单**
1. 是否需要权限/租户过滤?未加则补 `[Authorize]` + 租户解析。
2. 是否调用了应用层 CQRS而非在 Controller 写业务?
3. DTO 是否按管理口径,未暴露用户端字段?
4. 是否使用参数化/AsNoTracking/投影,避免 N+1
5. 路由和 Swagger 示例是否含租户/权限说明?
## 2. UserApiC 端用户)
- **面向对象**App/H5 普通用户。
- **职责**:菜单浏览、下单、支付、评价、地址、售后、订单查询、支付/配送回调(验证签名)等用户闭环。
- **鉴权**:用户 JWT租户隔离幂等接口需校验。
- **路由前缀**`api/user/v{version}/...`
- **DTO/约束**仅用户侧可见字段屏蔽后台配置字段long -> string。
- **现有控制器**:当前仅 `HealthController`(业务接口待补)。
- **自检清单**
1. 是否暴露给用户的纯前台功能?后台配置请放 AdminApi。
2. 是否做租户隔离、用户鉴权、签名/幂等校验?
3. 响应是否脱敏且只含用户需要的字段?
4. 是否避免跨端复用后台 DTO/命令?
5. 回调路由是否验证签名/防重放?
## 3. MiniApi小程序端
- **面向对象**:微信/小程序前端。
- **职责**:小程序登录/刷新、当前用户档案、订阅消息、直传凭证、小程序场景特定的下单/浏览等。
- **鉴权**:小程序登录态/Token租户隔离必要时区分渠道。
- **路由前缀**`api/mini/v{version}/...`
- **DTO/约束**遵循小程序接口规范错误码与前端对齐long -> string。
- **现有控制器**`AuthController``MeController``FilesController``HealthController`
- **自检清单**
1. 是否为小程序特有流程code2session、订阅消息、直传等通用用户接口放 UserApi。
2. 是否完成租户/鉴权校验,区分渠道标识?
3. 请求/响应是否符合小程序对错误码与字段的约定?
4. 是否避免使用后台管理 DTO/权限模型?
5. 上传/直传接口是否限制 MIME/大小并做鉴权?
## 4. 共通约束
- **分层**Controller 仅做路由/DTO 转换,业务放 Application 层 Handler。
- **租户**:所有写/读需租户过滤;严禁跨租户访问。
- **日志/观测**TraceId/SpanId 已贯通;/metrics、/healthz 按服务暴露。
- **命名**:输入 `XxxRequest`、输出 `XxxDto`;文件名与类名一致;布尔属性加 `Is/Has`
- **发布前检查**:运行 `dotnet build`,必要时补 Swagger 示例、单元测试(核心逻辑 100% 覆盖,服务 ≥70%)。

134
Document/CI_CD流水线.md Normal file
View File

@@ -0,0 +1,134 @@
# 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 端口约定
- Admin7801
- Mini7701
- User7901
## 完整流水线 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 步骤替换为实际变量名。
- 所有密码、密钥务必放在“密钥/凭据”类型变量中,不要写入代码库。

195
Document/README.md Normal file
View File

@@ -0,0 +1,195 @@
# 外卖SaaS系统 - 文档中心
欢迎查阅外卖SaaS系统的完整文档。本文档中心包含了项目的所有技术文档和开发指南。
## 📚 文档目录
### 1. [项目概述](01_项目概述.md)
- 项目简介与背景
- 核心业务模块介绍
- 用户角色说明
- 系统特性
- 技术选型
- 项目里程碑
**适合人群**:项目经理、产品经理、新加入的开发人员
---
### 2. [技术架构](02_技术架构.md)
- 技术栈详解
- 系统架构设计
- 分层架构说明
- 核心设计模式
- 数据访问策略EF Core + Dapper
- 缓存策略
- 消息队列应用
- 安全设计
**适合人群**:架构师、技术负责人、高级开发人员
---
### 3. [数据库设计](03_数据库设计.md)
- 数据库设计原则
- 命名规范
- 核心表结构
- 租户管理
- 商家管理
- 菜品管理
- 订单管理
- 配送管理
- 支付管理
- 营销管理
- 系统管理
- 索引策略
- 数据库优化
- 备份策略
**适合人群**:数据库管理员、后端开发人员
---
### 4A. [管理后台 API 设计](04A_管理后台API.md)
- 角色与权限(平台/租户/商家)
- 租户与商家管理
- 菜品与分类管理
- 订单流转与售后
- 优惠券与评价管理
- 统计报表与文件上传
### 4B. [小程序/用户端 API 设计](04B_小程序API.md)
- 小程序登录与用户信息
- 商家与门店浏览
- 菜品与分类列表
- 购物车同步
- 订单创建/查询/取消
- 支付对接(微信/支付宝)
- 优惠券领取与使用、评价发布
**适合人群**:前端开发人员(小程序/Web用户端、后端开发人员、接口对接人员
---
### 5. [部署运维](05_部署运维.md)
- 环境要求
- 本地开发环境搭建
- Docker部署
- Nginx配置
- 数据库部署(主从复制)
- Redis部署哨兵模式
- CI/CD配置
- 监控告警Prometheus + Grafana
- 日志管理ELK Stack
- 安全加固
- 性能优化
- 故障恢复
**适合人群**运维工程师、DevOps工程师、系统管理员
---
### 6. [开发规范](06_开发规范.md)
- 代码规范
- 命名规范
- 代码组织
- 代码注释
- 异常处理
- Git工作流
- 分支管理
- 提交信息规范
- 代码审查标准
- 单元测试规范
- 性能优化规范
- 安全规范
- 日志规范
- 配置管理
- API设计规范
**适合人群**:所有开发人员
---
### 7. [系统架构图](07_系统架构图.md)
- 整体架构图
- 应用分层架构
- 订单处理流程图
- 数据流转图
- 多租户数据隔离架构
- 缓存架构
- 消息队列架构
- 部署架构
- 监控架构
**适合人群**:架构师、技术负责人、所有开发人员
---
## 🚀 快速导航
### 我是新人,从哪里开始?
1. 先阅读 [项目概述](01_项目概述.md) 了解项目背景和业务
2. 查看 [系统架构图](07_系统架构图.md) 理解系统整体架构
3. 阅读 [开发规范](06_开发规范.md) 了解开发要求
4. 参考 [部署运维](05_部署运维.md) 搭建本地开发环境
### 我要开发新功能
1. 查看 [数据库设计](03_数据库设计.md) 了解数据模型
2. 参考 [API接口设计](04_API接口设计.md) 设计接口
3. 遵循 [开发规范](06_开发规范.md) 编写代码
4. 参考 [技术架构](02_技术架构.md) 选择合适的技术方案
### 我要部署系统
1. 阅读 [部署运维](05_部署运维.md) 了解部署流程
2. 参考 [系统架构图](07_系统架构图.md) 理解部署架构
3. 按照文档配置监控和日志系统
### 我要对接API
1. 查看 [API接口设计](04_API接口设计.md) 了解接口规范
2. 参考接口文档进行开发和测试
---
## 📖 文档更新记录
### v1.0.0 (2024-01-01)
- ✅ 完成项目概述文档
- ✅ 完成技术架构文档
- ✅ 完成数据库设计文档
- ✅ 完成API接口设计文档
- ✅ 完成部署运维文档
- ✅ 完成开发规范文档
- ✅ 完成系统架构图文档
---
## 💡 文档贡献
如果您发现文档有任何问题或需要改进的地方,欢迎:
1. 提交 Issue 反馈问题
2. 提交 Pull Request 改进文档
3. 联系项目负责人
---
## 📞 联系方式
- 项目地址https://github.com/your-org/takeout-saas
- 问题反馈https://github.com/your-org/takeout-saas/issues
- 邮箱dev@example.com
---
## 📝 文档规范
本文档使用 Markdown 格式编写,遵循以下规范:
- 使用清晰的标题层级
- 代码示例使用语法高亮
- 重要内容使用加粗或引用
- 保持文档简洁易读
- 及时更新文档内容
---
**最后更新时间**2024-01-01
**文档版本**v1.0.0

View File

@@ -0,0 +1,101 @@
# PostgreSQL 与 Redis 接入手册
> 本文档补齐 `Document/10_TODO.md` 中“Postgres/Redis 接入文档与 IaC/脚本”的要求,统一描述连接信息、账号权限、运维流程,以及可复用的部署脚本位置。
## 1. 运行环境总览
| 组件 | 地址/端口 | 主要数据库/实例 | 说明 |
| --- | --- | --- | --- |
| PostgreSQL | `120.53.222.17:5432` | `takeout_app_db``takeout_identity_db``takeout_dictionary_db``takeout_hangfire_db` | 线上实例,所有业务上下文共用。 |
| Redis | `49.232.6.45:6379` | 单节点 | 业务缓存/登录限流/刷新令牌存储。 |
> 注意:所有业务账号都只具备既有库的读写权限,无 `CREATEDB`。若需新库,需使用平台管理员账号(`postgres`)或联系 DBA。
## 2. 账号与库映射
| 数据库 | 角色 | 密码 | 用途 |
| --- | --- | --- | --- |
| `takeout_app_db` | `app_user` | `AppUser112233` | 业务域 (`TakeoutAppDbContext`) |
| `takeout_identity_db` | `identity_user` | `IdentityUser112233` | 身份域 (`IdentityDbContext`) |
| `takeout_dictionary_db` | `dictionary_user` | `DictionaryUser112233` | 字典域 (`DictionaryDbContext`) |
| `takeout_hangfire_db` | `hangfire_user` | `HangFire112233` | 后台调度/Hangfire |
Redis 密码:`MsuMshk112233`,见 `appsettings.*.json -> Redis`
## 3. 环境变量/配置注入
### PowerShell
```powershell
$env:TAKEOUTSAAS_APPSETTINGS_DIR = "D:\HAZCode\TakeOut\src\Api\TakeoutSaaS.AdminApi"
$env:TAKEOUTSAAS_APP_CONNECTION = "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true"
$env:TAKEOUTSAAS_IDENTITY_CONNECTION = "Host=120.53.222.17;Port=5432;Database=takeout_identity_db;Username=identity_user;Password=IdentityUser112233;Pooling=true"
$env:TAKEOUTSAAS_DICTIONARY_CONNECTION = "Host=120.53.222.17;Port=5432;Database=takeout_dictionary_db;Username=dictionary_user;Password=DictionaryUser112233;Pooling=true"
```
### Bash
```bash
export TAKEOUTSAAS_APPSETTINGS_DIR=/home/user/TakeOut/src/Api/TakeoutSaaS.AdminApi
export TAKEOUTSAAS_APP_CONNECTION="Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true"
export TAKEOUTSAAS_IDENTITY_CONNECTION="Host=120.53.222.17;Port=5432;Database=takeout_identity_db;Username=identity_user;Password=IdentityUser112233;Pooling=true"
export TAKEOUTSAAS_DICTIONARY_CONNECTION="Host=120.53.222.17;Port=5432;Database=takeout_dictionary_db;Username=dictionary_user;Password=DictionaryUser112233;Pooling=true"
```
Redis 连接字符串直接写入 `appsettings.*.json` 即可,如:
```jsonc
"Redis": "49.232.6.45:6379,password=MsuMshk112233,abortConnect=false"
```
## 4. 运维指南
### PostgreSQL
1. **只读账号验证**
```powershell
psql "host=120.53.222.17 port=5432 dbname=takeout_app_db user=app_user password=AppUser112233"
```
2. **备份**
```bash
pg_dump -h 120.53.222.17 -p 5432 -U postgres -F c -d takeout_app_db -f backup/takeout_app_db_$(date +%Y%m%d).dump
pg_dumpall -h 120.53.222.17 -p 5432 -U postgres > backup/all_$(date +%Y%m%d).sql
```
3. **恢复**
```bash
pg_restore -h 120.53.222.17 -p 5432 -U postgres -d takeout_app_db backup/takeout_app_db_xxx.dump
psql -h 120.53.222.17 -p 5432 -U postgres -f backup/all_yyyymmdd.sql
```
4. **账号/权限策略**
- `app_user` / `identity_user` / `dictionary_user` 拥有 `CONNECT`、`TEMP`、Schema `public` 的 CRUD 权限。
- `hangfire_user` 仅能访问 `takeout_hangfire_db`,不可访问业务库。
- 创建新表/列时,通过 EF Migration 自动添加 COMMENT。
### Redis
1. **连接验证**
```bash
redis-cli -h 49.232.6.45 -p 6379 -a MsuMshk112233 ping
```
2. **备份**
```bash
redis-cli -h 49.232.6.45 -p 6379 -a MsuMshk112233 save # 触发 RDB
redis-cli -h 49.232.6.45 -p 6379 -a MsuMshk112233 bgsave # 后台
```
RDB/AOF 文件在服务器 `redis.conf` 定义的目录(默认 `/var/lib/redis`)。
3. **常见运维项**
- `CONFIG GET dir` / `CONFIG GET dbfilename` 可查看持久化路径。
- `INFO memory` 监控内存;开启 `maxmemory` + `allkeys-lru` 保护。
## 5. IaC / 脚本
| 文件 | 说明 |
| --- | --- |
| `deploy/postgres/create_databases.sql` | 基于 `postgres` 管理员执行,创建四个业务库及角色、授予权限、补 COMMENT。 |
| `deploy/postgres/bootstrap.ps1` | PowerShell 包装脚本,调用 `psql` 执行上面的 SQL默认读取 `postgres` 管理员账号)。 |
| `deploy/postgres/README.md` | 介绍如何在本地/测试环境执行 bootstrap 并校验连接。 |
| `deploy/redis/docker-compose.yml` | 可复用的 Redis 部署Redis 7 + AOF便于本地或测试环境一键拉起。 |
| `deploy/redis/redis.conf` | compose/裸机均可共用的配置(`requirepass`、持久化等已写好)。 |
| `deploy/redis/README.md` | 说明如何使用 compose 或将 `redis.conf` 部署到现有实例。 |
> 线上目前为裸机安装(非容器),如需创建新环境/快速恢复,可直接运行上述脚本达到同样配置;即使在现有机器上,也可把 SQL/配置当作“最终规范”确保环境一致性。

View File

@@ -0,0 +1,89 @@
# 外卖SaaS小程序导向模块脑图
## 1. 目标
- 聚焦小程序用户体验:高效点单、扫码堂食、同城履约、实时状态
- 平台/租户多租户隔离,支持商家快速入驻与运营
- 门店同城即时配送为主,第三方配送对接兜底,统一回调验签
## 2. 端侧
- 小程序用户端:扫码进店/堂食点餐、店铺浏览、菜品/套餐、购物车、下单支付、拼单、预购自提、同城配送、订单跟踪、优惠券/积分/会员、营销活动、评价、地址管理、搜索、签到、预约、社区、客服聊天、地图导航
- 管理后台(商家/店员):门店配置、桌码管理、菜品与库存、订单/拼单/自提处理、配送调度与回调、营销配置、分销配置、客服工单/会话、数据看板
- 平台运营后台:租户/商家审核、套餐与配额、全局配置、日志审计、监控看板、分销/返利策略、活动审核
## 3. 核心业务模块
- 租户与平台
- 租户生命周期:注册、实名认证/资质审核、套餐订阅、续费/升降配、停用/注销
- 配额与权限:门店数/账号数/存储/短信/配送单量RBAC 角色模板,租户级配置继承与覆盖
- 平台运营:租户监控、账单/计费/催缴、公告/通知、合规与风控(黑名单/频率限制)
- 商家与门店
- 入驻与资质审核:证照上传、合同、店铺类目、品牌与连锁
- 门店配置:多店管理、营业时间、休息/打烊、门店状态、区域/配送范围、到店导航信息
- 桌码/场景:桌码生成与绑定、桌台容量/区域、桌码标签(堂食/快餐/档口)
- 门店运营:员工账号与分工(前台/后厨/配送)、交班与收银、公告/售罄提示
- 菜品与库存
- 菜品建模:分类/标签/排序、规格与属性(辣度/份量)、套餐与组合、加料/做法/备注
- 价格策略:会员价、门店价、时间段价、限时折扣、区域价
- 库存与可售:总库存/门店库存/档期库存、售罄/预售、批量导入与同步、条码/编码
- 媒资:图片/视频、SPU/SKU 编码、营养/过敏源/溯源信息
- 扫码点餐/堂食
- 桌码入口:扫码识别门店/桌台、桌台上下文、预加载菜单
- 点餐流程:购物车并发锁、加菜/催单、口味备注、拆单/并单、多人同桌分付/代付
- 账单与核销:桌台账单合并、结账/买单、电子小票、发票抬头、桌台释放
- 预购自提
- 档期配置:自提时间窗/容量、预售库存、截单时间
- 下单与支付:自提地址确认、档期校验、预售与现制、库存锁定
- 提货核销:提货码/手机号核销、自提柜/前台、超时/取消/退款、代取人
- 下单与支付
- 购物车与校验:库存/限购/门店状态/配送范围/桌台状态、券和积分叠加规则
- 支付与结算:微信/支付宝/余额/优惠券/积分抵扣、回调幂等、预授权、分账/对账
- 售后与状态机:退单/部分退款、异常单处理、状态机(待付→待接→制作→配送/自提→完成/取消)
- 同城即时配送(门店履约)
- 自配送:骑手管理、取/送件信息、路线估时、导航、取餐码、费用与补贴
- 第三方配送:统一抽象(下单/取消/加价/查询)、多渠道择优、回调验签、异常重试与补偿
- 配送体验:预计送达/计价、配送进度推送、无接触配送、收货码、投诉与赔付
- 拼单
- 团单规则:发起/加入、成团条件(人数/金额/时间)、拼主与参团人、支付/锁单策略
- 失败与退款:超时/人数不足自动解散、自动退款/原路退、通知
- 履约:同单配送至拼主地址、收件信息可见范围、团内消息/提醒
- 优惠券与营销工具
- 券种与发放:满减/折扣/新人券/裂变券/桌码专属券/会员券,渠道(领券中心/活动/分享/桌码)
- 核销规则:适用范围(门店/品类/菜品)、叠加/互斥、最低消费、有效期、库存与风控
- 活动组件:抽奖、分享裂变、秒杀/限时抢购、满减活动、爆款/推荐位、裂变海报
- 会员与积分/会员营销
- 会员体系:付费会员/积分会员、等级/成长值、会员权益(专享价/券/运费/客服)
- 积分运营:获取(消费/签到/任务/活动)、消耗(抵扣/兑换)、有效期、黑名单
- 会员运营:会员日、续费提醒、沉睡唤醒、专属活动、分层运营与 A/B
- 客服工具
- 会话:实时聊天、机器人/人工切换、快捷回复、排队/转接、消息模板/通知
- 质量与风控:会话记录与审计、敏感词、评价与回访、工单流转
- 分销返利
- 规则:商品/类目返利、佣金阶梯、结算周期、税务信息、违规处理
- 链路:分享链接/小程序码、点击与下单跟踪、确认收货后结算、售后扣回
- 地图导航
- 门店/自提点定位、距离/路况、路线规划、附近门店/推荐、跳转原生导航
- 签到打卡
- 规则:每日/任务签到、连签奖励、补签、积分/券/成长值奖励、反作弊
- 预约预订
- 档期/资源:时间段、人员/座位/设备占用、容量校验
- 流程:预约下单/支付、提醒/改期/取消、到店核销与履约记录
- 搜索查询
- 内容:门店/菜品/活动/优惠券搜索,过滤/排序,热门/历史搜索
- 体验:纠错/联想、推荐、结果埋点与转化分析
- 用户社区
- 社区运营:动态发布/评论/点赞、话题/标签、图片/视频审核、举报与风控
- 互动:店铺口碑、菜品晒图、官方/商家号发布、置顶与精选
- 数据与分析
- 交易分析:销售/订单/客单/转化漏斗、支付与退款、品类/单品分析
- 营销分析:券发放与核销、活动效果(拼单/秒杀/抽奖/分销)、会员留存与复购
- 履约分析:配送时效、超时/异常、堂食与自提拆分、投诉与赔付
- 运营大盘:租户/商家健康度、活跃度、留存、GMV、成本与毛利
- 系统与运维
- 安全与合规RBAC、租户隔离、限流与风控设备/IP/账户/店铺)、敏感词与内容安全
- 配置与网关:字典/参数、灰度/开关、网关限流/鉴权/租户透传
- 可靠性:任务调度(订单/拼单超时、券过期、回调补偿)、幂等与重试、健康检查/告警、日志/链路追踪、备份恢复
## 4. 里程碑(建议)
- Phase 1租户/商家入驻、门店与菜品、桌码扫码堂食、基础下单支付、预购自提、第三方配送骨架
- Phase 2拼单、优惠券与基础营销组件、会员积分/会员日、客服聊天、同城自配送调度、搜索
- Phase 3分销返利、签到打卡、预约预订、地图导航、社区、营销活动丰富秒杀/抽奖等)、风控与审计、补偿与告警体系
- Phase 4性能优化与缓存、运营大盘与细分报表、测试与文档完善、上线与监控

Binary file not shown.

171
README.md
View File

@@ -1,32 +1,173 @@
## 项目名称
> 请介绍一下你的项目吧
# 外卖SaaS系统 (TakeoutSaaS)
## 项目简介
外卖SaaS系统是一个基于.NET 10的多租户外卖管理平台为中小型餐饮企业提供完整的外卖业务解决方案。系统采用现代化的技术栈支持商家管理、菜品管理、订单处理、配送管理、支付集成等核心功能。
### 核心特性
- 🏢 **多租户架构**支持多租户数据隔离SaaS模式运营
- 🍔 **商家管理**:完善的商家入驻、门店管理、菜品管理功能
- 📦 **订单管理**:订单全生命周期管理,实时状态跟踪
🚚 配送管理:配送任务、路线规划、第三方配送对接
- 💰 **支付集成**:支持微信、支付宝等多种支付方式
- 🎁 **营销功能**:优惠券、满减活动、会员积分
- 📊 **数据分析**:实时数据统计、经营报表、趋势分析
- 🔒 **安全可靠**JWT认证、权限控制、数据加密
## 技术栈
### 后端技术
- **.NET 10**:最新的.NET平台
- **ASP.NET Core Web API**RESTful API服务
- **Entity Framework Core 10**最新ORM框架
- **Dapper 2.1+**:高性能数据访问
- **PostgreSQL 16+**:主数据库
- **Redis 7.0+**:分布式缓存
- **RabbitMQ 3.12+**:消息队列
### 开发框架
- **AutoMapper**:对象映射
- **FluentValidation**:数据验证
- **Serilog**:结构化日志
- **MediatR**CQRS模式
- **Hangfire**:后台任务
- **Swagger**API文档
## 运行条件
> 列出运行该项目所必须的条件和相关依赖
* 条件一
* 条件二
* 条件三
### 开发环境要求
* .NET SDK 10.0 或更高版本
* PostgreSQL 16+
* Redis 7.0+
* RabbitMQ 3.12+(可选)
* Docker Desktop推荐用于容器化开发
### 推荐IDE
* Visual Studio 2022
* JetBrains Rider
* Visual Studio Code
## 运行说明
> 说明如何运行和使用你的项目,建议给出具体的步骤说明
* 操作一
* 操作二
* 操作三
## 快速开始
### 1. 克隆项目
```bash
git clone https://github.com/your-org/takeout-saas.git
cd takeout-saas
```
### 2. 使用Docker Compose启动依赖服务推荐
```bash
# 启动PostgreSQL、Redis、RabbitMQ等服务
docker-compose up -d
# 查看服务状态
docker-compose ps
```
### 3. 配置数据库连接
编辑 `src/TakeoutSaaS.Api/appsettings.Development.json`
### 4. 执行数据库迁移
```bash
cd src/TakeoutSaaS.Api
dotnet ef database update
```
### 5. 运行项目
```bash
dotnet run
```
访问 API 文档:
- 管理后台 AdminApi Swaggerhttp://localhost:5001/swagger
- 小程序/用户端 MiniApi Swaggerhttp://localhost:5002/swagger
## 项目结构
```
TakeoutSaaS/
├── 0_Document/ # 项目文档
│ ├── 01_项目概述.md
│ ├── 02_技术架构.md
│ ├── 03_数据库设计.md
│ ├── 04A_管理后台API.md
│ ├── 04B_小程序API.md
│ ├── 05_部署运维.md
│ └── 06_开发规范.md
├── src/
│ ├── TakeoutSaaS.AdminApi/ # 管理后台 Web API
│ ├── TakeoutSaaS.MiniApi/ # 小程序/用户端 Web API
│ ├── TakeoutSaaS.Application/ # 应用层
│ ├── TakeoutSaaS.Domain/ # 领域层
│ ├── TakeoutSaaS.Infrastructure/ # 基础设施层
│ └── TakeoutSaaS.Shared/ # 共享层
├── tests/
│ ├── TakeoutSaaS.UnitTests/ # 单元测试
│ └── TakeoutSaaS.IntegrationTests/ # 集成测试
├── docker-compose.yml # Docker编排文件
└── README.md
```
## 测试说明
> 如果有测试相关内容需要说明,请填写在这里
### 运行单元测试
```bash
dotnet test tests/TakeoutSaaS.UnitTests
```
### 运行集成测试
```bash
dotnet test tests/TakeoutSaaS.IntegrationTests
```
## 技术架构
> 使用的技术框架或系统架构图等相关说明,请填写在这里
## 部署说明
### Docker部署
```bash
# 构建镜像
docker build -t takeout-saas-api:latest .
# 运行容器
docker run -d -p 8080:80 --name takeout-api takeout-saas-api:latest
```
详细部署文档请参考:[部署运维文档](0_Document/05_部署运维.md)
## 文档
- [项目概述](0_Document/01_项目概述.md) - 系统介绍和业务说明
- [技术架构](0_Document/02_技术架构.md) - 技术栈和架构设计
- [数据库设计](0_Document/03_数据库设计.md) - 数据模型和表结构
- [API接口设计](0_Document/04_API接口设计.md) - RESTful API规范
- [部署运维](0_Document/05_部署运维.md) - 部署和运维指南
- [开发规范](0_Document/06_开发规范.md) - 代码规范和最佳实践
## 开发规范
请遵循项目的[开发规范](0_Document/06_开发规范.md)
## 贡献指南
1. Fork 本仓库
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'feat: Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 创建 Pull Request
## 许可证
本项目采用 MIT 许可证
## 联系方式
- 项目地址https://github.com/your-org/takeout-saas
- 问题反馈https://github.com/your-org/takeout-saas/issues
## 协作者
> 高效的协作会激发无尽的创造力,将他们的名字记录在这里吧
感谢所有为本项目做出贡献的开发者!
---
⭐ 如果这个项目对你有帮助,请给我们一个星标!

240
TakeoutSaaS.sln Normal file
View File

@@ -0,0 +1,240 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Api", "Api", "{81034408-37C8-1011-444E-4C15C2FADA8E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.AdminApi", "src\Api\TakeoutSaaS.AdminApi\TakeoutSaaS.AdminApi.csproj", "{0F32CC9C-E8B2-4854-BBF0-D8D2DDFFA954}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{8D626EA8-CB54-BC41-363A-217881BEBA6E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Shared.Web", "src\Core\TakeoutSaaS.Shared.Web\TakeoutSaaS.Shared.Web.csproj", "{022FCF39-EC48-46EA-AC08-FA2EAD1548B7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Shared.Abstractions", "src\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj", "{0DA03B31-E718-4424-A1F0-9989E79FFE81}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Application", "Application", "{22BAF98C-8415-17C4-B26A-D537657BC863}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Application", "src\Application\TakeoutSaaS.Application\TakeoutSaaS.Application.csproj", "{27FA49F3-FC1A-44F7-B2A9-3833AC3A2E00}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Domain", "Domain", "{8B290487-4C16-E85E-E807-F579CBE9FC4D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Domain", "src\Domain\TakeoutSaaS.Domain\TakeoutSaaS.Domain.csproj", "{464913F5-70F2-4661-B3AF-B1C87FFFA4EC}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Infrastructure", "Infrastructure", "{9048EB7F-3875-A59E-E36B-5BD4C6F2A282}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Infrastructure", "src\Infrastructure\TakeoutSaaS.Infrastructure\TakeoutSaaS.Infrastructure.csproj", "{80B45C7D-9423-400A-8279-40D95BFEBC9D}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Modules", "Modules", "{EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Authorization", "src\Modules\TakeoutSaaS.Module.Authorization\TakeoutSaaS.Module.Authorization.csproj", "{6CB8487D-5C74-487C-9D84-E57838BDA015}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Tenancy", "src\Modules\TakeoutSaaS.Module.Tenancy\TakeoutSaaS.Module.Tenancy.csproj", "{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.MiniApi", "src\Api\TakeoutSaaS.MiniApi\TakeoutSaaS.MiniApi.csproj", "{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.UserApi", "src\Api\TakeoutSaaS.UserApi\TakeoutSaaS.UserApi.csproj", "{1C0BCC51-AF18-44F3-A1E6-A693F74276B5}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Gateway", "Gateway", "{6306A8FB-679E-111F-6585-8F70E0EE6013}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.ApiGateway", "src\Gateway\TakeoutSaaS.ApiGateway\TakeoutSaaS.ApiGateway.csproj", "{A2620200-D487-49A7-ABAF-9B84951F81DD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Shared.Kernel", "src\Core\TakeoutSaaS.Shared.Kernel\TakeoutSaaS.Shared.Kernel.csproj", "{BBC99B58-ECA8-42C3-9070-9AA058D778D3}"
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
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{0F32CC9C-E8B2-4854-BBF0-D8D2DDFFA954}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0F32CC9C-E8B2-4854-BBF0-D8D2DDFFA954}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0F32CC9C-E8B2-4854-BBF0-D8D2DDFFA954}.Debug|x64.ActiveCfg = Debug|Any CPU
{0F32CC9C-E8B2-4854-BBF0-D8D2DDFFA954}.Debug|x64.Build.0 = Debug|Any CPU
{0F32CC9C-E8B2-4854-BBF0-D8D2DDFFA954}.Debug|x86.ActiveCfg = Debug|Any CPU
{0F32CC9C-E8B2-4854-BBF0-D8D2DDFFA954}.Debug|x86.Build.0 = Debug|Any CPU
{0F32CC9C-E8B2-4854-BBF0-D8D2DDFFA954}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0F32CC9C-E8B2-4854-BBF0-D8D2DDFFA954}.Release|Any CPU.Build.0 = Release|Any CPU
{0F32CC9C-E8B2-4854-BBF0-D8D2DDFFA954}.Release|x64.ActiveCfg = Release|Any CPU
{0F32CC9C-E8B2-4854-BBF0-D8D2DDFFA954}.Release|x64.Build.0 = Release|Any CPU
{0F32CC9C-E8B2-4854-BBF0-D8D2DDFFA954}.Release|x86.ActiveCfg = Release|Any CPU
{0F32CC9C-E8B2-4854-BBF0-D8D2DDFFA954}.Release|x86.Build.0 = Release|Any CPU
{022FCF39-EC48-46EA-AC08-FA2EAD1548B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{022FCF39-EC48-46EA-AC08-FA2EAD1548B7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{022FCF39-EC48-46EA-AC08-FA2EAD1548B7}.Debug|x64.ActiveCfg = Debug|Any CPU
{022FCF39-EC48-46EA-AC08-FA2EAD1548B7}.Debug|x64.Build.0 = Debug|Any CPU
{022FCF39-EC48-46EA-AC08-FA2EAD1548B7}.Debug|x86.ActiveCfg = Debug|Any CPU
{022FCF39-EC48-46EA-AC08-FA2EAD1548B7}.Debug|x86.Build.0 = Debug|Any CPU
{022FCF39-EC48-46EA-AC08-FA2EAD1548B7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{022FCF39-EC48-46EA-AC08-FA2EAD1548B7}.Release|Any CPU.Build.0 = Release|Any CPU
{022FCF39-EC48-46EA-AC08-FA2EAD1548B7}.Release|x64.ActiveCfg = Release|Any CPU
{022FCF39-EC48-46EA-AC08-FA2EAD1548B7}.Release|x64.Build.0 = Release|Any CPU
{022FCF39-EC48-46EA-AC08-FA2EAD1548B7}.Release|x86.ActiveCfg = Release|Any CPU
{022FCF39-EC48-46EA-AC08-FA2EAD1548B7}.Release|x86.Build.0 = Release|Any CPU
{0DA03B31-E718-4424-A1F0-9989E79FFE81}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0DA03B31-E718-4424-A1F0-9989E79FFE81}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0DA03B31-E718-4424-A1F0-9989E79FFE81}.Debug|x64.ActiveCfg = Debug|Any CPU
{0DA03B31-E718-4424-A1F0-9989E79FFE81}.Debug|x64.Build.0 = Debug|Any CPU
{0DA03B31-E718-4424-A1F0-9989E79FFE81}.Debug|x86.ActiveCfg = Debug|Any CPU
{0DA03B31-E718-4424-A1F0-9989E79FFE81}.Debug|x86.Build.0 = Debug|Any CPU
{0DA03B31-E718-4424-A1F0-9989E79FFE81}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0DA03B31-E718-4424-A1F0-9989E79FFE81}.Release|Any CPU.Build.0 = Release|Any CPU
{0DA03B31-E718-4424-A1F0-9989E79FFE81}.Release|x64.ActiveCfg = Release|Any CPU
{0DA03B31-E718-4424-A1F0-9989E79FFE81}.Release|x64.Build.0 = Release|Any CPU
{0DA03B31-E718-4424-A1F0-9989E79FFE81}.Release|x86.ActiveCfg = Release|Any CPU
{0DA03B31-E718-4424-A1F0-9989E79FFE81}.Release|x86.Build.0 = Release|Any CPU
{27FA49F3-FC1A-44F7-B2A9-3833AC3A2E00}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{27FA49F3-FC1A-44F7-B2A9-3833AC3A2E00}.Debug|Any CPU.Build.0 = Debug|Any CPU
{27FA49F3-FC1A-44F7-B2A9-3833AC3A2E00}.Debug|x64.ActiveCfg = Debug|Any CPU
{27FA49F3-FC1A-44F7-B2A9-3833AC3A2E00}.Debug|x64.Build.0 = Debug|Any CPU
{27FA49F3-FC1A-44F7-B2A9-3833AC3A2E00}.Debug|x86.ActiveCfg = Debug|Any CPU
{27FA49F3-FC1A-44F7-B2A9-3833AC3A2E00}.Debug|x86.Build.0 = Debug|Any CPU
{27FA49F3-FC1A-44F7-B2A9-3833AC3A2E00}.Release|Any CPU.ActiveCfg = Release|Any CPU
{27FA49F3-FC1A-44F7-B2A9-3833AC3A2E00}.Release|Any CPU.Build.0 = Release|Any CPU
{27FA49F3-FC1A-44F7-B2A9-3833AC3A2E00}.Release|x64.ActiveCfg = Release|Any CPU
{27FA49F3-FC1A-44F7-B2A9-3833AC3A2E00}.Release|x64.Build.0 = Release|Any CPU
{27FA49F3-FC1A-44F7-B2A9-3833AC3A2E00}.Release|x86.ActiveCfg = Release|Any CPU
{27FA49F3-FC1A-44F7-B2A9-3833AC3A2E00}.Release|x86.Build.0 = Release|Any CPU
{464913F5-70F2-4661-B3AF-B1C87FFFA4EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{464913F5-70F2-4661-B3AF-B1C87FFFA4EC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{464913F5-70F2-4661-B3AF-B1C87FFFA4EC}.Debug|x64.ActiveCfg = Debug|Any CPU
{464913F5-70F2-4661-B3AF-B1C87FFFA4EC}.Debug|x64.Build.0 = Debug|Any CPU
{464913F5-70F2-4661-B3AF-B1C87FFFA4EC}.Debug|x86.ActiveCfg = Debug|Any CPU
{464913F5-70F2-4661-B3AF-B1C87FFFA4EC}.Debug|x86.Build.0 = Debug|Any CPU
{464913F5-70F2-4661-B3AF-B1C87FFFA4EC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{464913F5-70F2-4661-B3AF-B1C87FFFA4EC}.Release|Any CPU.Build.0 = Release|Any CPU
{464913F5-70F2-4661-B3AF-B1C87FFFA4EC}.Release|x64.ActiveCfg = Release|Any CPU
{464913F5-70F2-4661-B3AF-B1C87FFFA4EC}.Release|x64.Build.0 = Release|Any CPU
{464913F5-70F2-4661-B3AF-B1C87FFFA4EC}.Release|x86.ActiveCfg = Release|Any CPU
{464913F5-70F2-4661-B3AF-B1C87FFFA4EC}.Release|x86.Build.0 = Release|Any CPU
{80B45C7D-9423-400A-8279-40D95BFEBC9D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{80B45C7D-9423-400A-8279-40D95BFEBC9D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{80B45C7D-9423-400A-8279-40D95BFEBC9D}.Debug|x64.ActiveCfg = Debug|Any CPU
{80B45C7D-9423-400A-8279-40D95BFEBC9D}.Debug|x64.Build.0 = Debug|Any CPU
{80B45C7D-9423-400A-8279-40D95BFEBC9D}.Debug|x86.ActiveCfg = Debug|Any CPU
{80B45C7D-9423-400A-8279-40D95BFEBC9D}.Debug|x86.Build.0 = Debug|Any CPU
{80B45C7D-9423-400A-8279-40D95BFEBC9D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{80B45C7D-9423-400A-8279-40D95BFEBC9D}.Release|Any CPU.Build.0 = Release|Any CPU
{80B45C7D-9423-400A-8279-40D95BFEBC9D}.Release|x64.ActiveCfg = Release|Any CPU
{80B45C7D-9423-400A-8279-40D95BFEBC9D}.Release|x64.Build.0 = Release|Any CPU
{80B45C7D-9423-400A-8279-40D95BFEBC9D}.Release|x86.ActiveCfg = Release|Any CPU
{80B45C7D-9423-400A-8279-40D95BFEBC9D}.Release|x86.Build.0 = Release|Any CPU
{6CB8487D-5C74-487C-9D84-E57838BDA015}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6CB8487D-5C74-487C-9D84-E57838BDA015}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6CB8487D-5C74-487C-9D84-E57838BDA015}.Debug|x64.ActiveCfg = Debug|Any CPU
{6CB8487D-5C74-487C-9D84-E57838BDA015}.Debug|x64.Build.0 = Debug|Any CPU
{6CB8487D-5C74-487C-9D84-E57838BDA015}.Debug|x86.ActiveCfg = Debug|Any CPU
{6CB8487D-5C74-487C-9D84-E57838BDA015}.Debug|x86.Build.0 = Debug|Any CPU
{6CB8487D-5C74-487C-9D84-E57838BDA015}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6CB8487D-5C74-487C-9D84-E57838BDA015}.Release|Any CPU.Build.0 = Release|Any CPU
{6CB8487D-5C74-487C-9D84-E57838BDA015}.Release|x64.ActiveCfg = Release|Any CPU
{6CB8487D-5C74-487C-9D84-E57838BDA015}.Release|x64.Build.0 = Release|Any CPU
{6CB8487D-5C74-487C-9D84-E57838BDA015}.Release|x86.ActiveCfg = Release|Any CPU
{6CB8487D-5C74-487C-9D84-E57838BDA015}.Release|x86.Build.0 = Release|Any CPU
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}.Debug|x64.ActiveCfg = Debug|Any CPU
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}.Debug|x64.Build.0 = Debug|Any CPU
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}.Debug|x86.ActiveCfg = Debug|Any CPU
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}.Debug|x86.Build.0 = Debug|Any CPU
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}.Release|Any CPU.Build.0 = Release|Any CPU
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}.Release|x64.ActiveCfg = Release|Any CPU
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}.Release|x64.Build.0 = Release|Any CPU
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}.Release|x86.ActiveCfg = Release|Any CPU
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}.Release|x86.Build.0 = Release|Any CPU
{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D}.Debug|x64.ActiveCfg = Debug|Any CPU
{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D}.Debug|x64.Build.0 = Debug|Any CPU
{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D}.Debug|x86.ActiveCfg = Debug|Any CPU
{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D}.Debug|x86.Build.0 = Debug|Any CPU
{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D}.Release|Any CPU.Build.0 = Release|Any CPU
{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D}.Release|x64.ActiveCfg = Release|Any CPU
{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D}.Release|x64.Build.0 = Release|Any CPU
{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D}.Release|x86.ActiveCfg = Release|Any CPU
{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D}.Release|x86.Build.0 = Release|Any CPU
{1C0BCC51-AF18-44F3-A1E6-A693F74276B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1C0BCC51-AF18-44F3-A1E6-A693F74276B5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1C0BCC51-AF18-44F3-A1E6-A693F74276B5}.Debug|x64.ActiveCfg = Debug|Any CPU
{1C0BCC51-AF18-44F3-A1E6-A693F74276B5}.Debug|x64.Build.0 = Debug|Any CPU
{1C0BCC51-AF18-44F3-A1E6-A693F74276B5}.Debug|x86.ActiveCfg = Debug|Any CPU
{1C0BCC51-AF18-44F3-A1E6-A693F74276B5}.Debug|x86.Build.0 = Debug|Any CPU
{1C0BCC51-AF18-44F3-A1E6-A693F74276B5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1C0BCC51-AF18-44F3-A1E6-A693F74276B5}.Release|Any CPU.Build.0 = Release|Any CPU
{1C0BCC51-AF18-44F3-A1E6-A693F74276B5}.Release|x64.ActiveCfg = Release|Any CPU
{1C0BCC51-AF18-44F3-A1E6-A693F74276B5}.Release|x64.Build.0 = Release|Any CPU
{1C0BCC51-AF18-44F3-A1E6-A693F74276B5}.Release|x86.ActiveCfg = Release|Any CPU
{1C0BCC51-AF18-44F3-A1E6-A693F74276B5}.Release|x86.Build.0 = Release|Any CPU
{A2620200-D487-49A7-ABAF-9B84951F81DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A2620200-D487-49A7-ABAF-9B84951F81DD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A2620200-D487-49A7-ABAF-9B84951F81DD}.Debug|x64.ActiveCfg = Debug|Any CPU
{A2620200-D487-49A7-ABAF-9B84951F81DD}.Debug|x64.Build.0 = Debug|Any CPU
{A2620200-D487-49A7-ABAF-9B84951F81DD}.Debug|x86.ActiveCfg = Debug|Any CPU
{A2620200-D487-49A7-ABAF-9B84951F81DD}.Debug|x86.Build.0 = Debug|Any CPU
{A2620200-D487-49A7-ABAF-9B84951F81DD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A2620200-D487-49A7-ABAF-9B84951F81DD}.Release|Any CPU.Build.0 = Release|Any CPU
{A2620200-D487-49A7-ABAF-9B84951F81DD}.Release|x64.ActiveCfg = Release|Any CPU
{A2620200-D487-49A7-ABAF-9B84951F81DD}.Release|x64.Build.0 = Release|Any CPU
{A2620200-D487-49A7-ABAF-9B84951F81DD}.Release|x86.ActiveCfg = Release|Any CPU
{A2620200-D487-49A7-ABAF-9B84951F81DD}.Release|x86.Build.0 = Release|Any CPU
{BBC99B58-ECA8-42C3-9070-9AA058D778D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BBC99B58-ECA8-42C3-9070-9AA058D778D3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BBC99B58-ECA8-42C3-9070-9AA058D778D3}.Debug|x64.ActiveCfg = Debug|Any CPU
{BBC99B58-ECA8-42C3-9070-9AA058D778D3}.Debug|x64.Build.0 = Debug|Any CPU
{BBC99B58-ECA8-42C3-9070-9AA058D778D3}.Debug|x86.ActiveCfg = Debug|Any CPU
{BBC99B58-ECA8-42C3-9070-9AA058D778D3}.Debug|x86.Build.0 = Debug|Any CPU
{BBC99B58-ECA8-42C3-9070-9AA058D778D3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BBC99B58-ECA8-42C3-9070-9AA058D778D3}.Release|Any CPU.Build.0 = Release|Any CPU
{BBC99B58-ECA8-42C3-9070-9AA058D778D3}.Release|x64.ActiveCfg = Release|Any CPU
{BBC99B58-ECA8-42C3-9070-9AA058D778D3}.Release|x64.Build.0 = Release|Any CPU
{BBC99B58-ECA8-42C3-9070-9AA058D778D3}.Release|x86.ActiveCfg = Release|Any CPU
{BBC99B58-ECA8-42C3-9070-9AA058D778D3}.Release|x86.Build.0 = Release|Any CPU
{05058F44-6FB7-43AF-8648-8BF538E283EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{05058F44-6FB7-43AF-8648-8BF538E283EF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{05058F44-6FB7-43AF-8648-8BF538E283EF}.Debug|x64.ActiveCfg = Debug|Any CPU
{05058F44-6FB7-43AF-8648-8BF538E283EF}.Debug|x64.Build.0 = Debug|Any CPU
{05058F44-6FB7-43AF-8648-8BF538E283EF}.Debug|x86.ActiveCfg = Debug|Any CPU
{05058F44-6FB7-43AF-8648-8BF538E283EF}.Debug|x86.Build.0 = Debug|Any CPU
{05058F44-6FB7-43AF-8648-8BF538E283EF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{05058F44-6FB7-43AF-8648-8BF538E283EF}.Release|Any CPU.Build.0 = Release|Any CPU
{05058F44-6FB7-43AF-8648-8BF538E283EF}.Release|x64.ActiveCfg = Release|Any CPU
{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
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{81034408-37C8-1011-444E-4C15C2FADA8E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{0F32CC9C-E8B2-4854-BBF0-D8D2DDFFA954} = {81034408-37C8-1011-444E-4C15C2FADA8E}
{8D626EA8-CB54-BC41-363A-217881BEBA6E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{022FCF39-EC48-46EA-AC08-FA2EAD1548B7} = {8D626EA8-CB54-BC41-363A-217881BEBA6E}
{0DA03B31-E718-4424-A1F0-9989E79FFE81} = {8D626EA8-CB54-BC41-363A-217881BEBA6E}
{22BAF98C-8415-17C4-B26A-D537657BC863} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{27FA49F3-FC1A-44F7-B2A9-3833AC3A2E00} = {22BAF98C-8415-17C4-B26A-D537657BC863}
{8B290487-4C16-E85E-E807-F579CBE9FC4D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{464913F5-70F2-4661-B3AF-B1C87FFFA4EC} = {8B290487-4C16-E85E-E807-F579CBE9FC4D}
{9048EB7F-3875-A59E-E36B-5BD4C6F2A282} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{80B45C7D-9423-400A-8279-40D95BFEBC9D} = {9048EB7F-3875-A59E-E36B-5BD4C6F2A282}
{EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{6CB8487D-5C74-487C-9D84-E57838BDA015} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D} = {81034408-37C8-1011-444E-4C15C2FADA8E}
{1C0BCC51-AF18-44F3-A1E6-A693F74276B5} = {81034408-37C8-1011-444E-4C15C2FADA8E}
{6306A8FB-679E-111F-6585-8F70E0EE6013} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{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}
EndGlobalSection
EndGlobal

47
deploy/postgres/README.md Normal file
View File

@@ -0,0 +1,47 @@
# PostgreSQL 部署脚本
本目录提供在测试/预发布环境快速拉起 PostgreSQL 的脚本,复用线上同名数据库与账号,方便迁移/恢复。
## 目录结构
- `create_databases.sql`:创建四个业务库与对应角色(可多次执行,存在则跳过)。
- `bootstrap.ps1`PowerShell 包装脚本,调用 `psql` 执行 SQL。
## 前置条件
1. 已安装 PostgreSQL 12+,并能以管理员身份访问(默认使用 `postgres`)。
2. 本地已配置 `psql` 可执行命令。
## 使用方法
```powershell
cd deploy/postgres
.\bootstrap.ps1 `
-Host 120.53.222.17 `
-Port 5432 `
-AdminUser postgres `
-AdminPassword "超级管理员密码"
```
脚本会:
1. 创建/更新以下角色与库:
- `app_user` / `takeout_app_db`
- `identity_user` / `takeout_identity_db`
- `dictionary_user` / `takeout_dictionary_db`
- `hangfire_user` / `takeout_hangfire_db`
2. 为库设置 COMMENT授予 Schema `public` 的 CRUD 权限。
3. 输出执行日志,失败时终止。
## 自定义
- 如需修改密码或新增库,编辑 `create_databases.sql` 后重新运行脚本。
- 若在本地拉起测试库,可把 `Host` 指向 `localhost`,其余参数保持一致。
## 常见问题
| 问题 | 处理方式 |
| --- | --- |
| `psql : command not found` | 确认 PostgreSQL bin 目录已加入 PATH。 |
| `permission denied to create database` | 改用具有 `CREATEDB` 权限的管理员执行脚本。 |
| 需要删除库 | 先 `DROP DATABASE xxx`,再运行脚本重新创建。 |

View File

@@ -0,0 +1,37 @@
param(
[string]$Host = "120.53.222.17",
[int]$Port = 5432,
[string]$AdminUser = "postgres",
[string]$AdminPassword = ""
)
if (-not (Get-Command psql -ErrorAction SilentlyContinue)) {
throw "psql command not found. Add PostgreSQL bin directory to PATH."
}
if ([string]::IsNullOrWhiteSpace($AdminPassword)) {
Write-Warning "AdminPassword not provided. You will be prompted by psql."
}
$sqlPath = Join-Path $PSScriptRoot "create_databases.sql"
if (-not (Test-Path $sqlPath)) {
throw "Cannot find create_databases.sql under $PSScriptRoot."
}
$env:PGPASSWORD = $AdminPassword
$arguments = @(
"-h", $Host,
"-p", $Port,
"-U", $AdminUser,
"-f", $sqlPath
)
Write-Host "Executing create_databases.sql on $Host:$Port as $AdminUser ..."
& psql @arguments
if ($LASTEXITCODE -ne 0) {
throw "psql returned non-zero exit code ($LASTEXITCODE)."
}
Write-Host "PostgreSQL databases and roles ensured successfully."

View File

@@ -0,0 +1,83 @@
-- Reusable provisioning script for Takeout SaaS PostgreSQL databases.
-- Execute with a superuser (e.g. postgres). Safe to re-run.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'app_user') THEN
CREATE ROLE app_user LOGIN PASSWORD 'AppUser112233';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'identity_user') THEN
CREATE ROLE identity_user LOGIN PASSWORD 'IdentityUser112233';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'dictionary_user') THEN
CREATE ROLE dictionary_user LOGIN PASSWORD 'DictionaryUser112233';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'hangfire_user') THEN
CREATE ROLE hangfire_user LOGIN PASSWORD 'HangFire112233';
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'takeout_app_db') THEN
CREATE DATABASE takeout_app_db OWNER app_user ENCODING 'UTF8';
END IF;
END $$;
COMMENT ON DATABASE takeout_app_db IS 'Takeout SaaS 业务域数据库';
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'takeout_identity_db') THEN
CREATE DATABASE takeout_identity_db OWNER identity_user ENCODING 'UTF8';
END IF;
END $$;
COMMENT ON DATABASE takeout_identity_db IS 'Takeout SaaS 身份域数据库';
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'takeout_dictionary_db') THEN
CREATE DATABASE takeout_dictionary_db OWNER dictionary_user ENCODING 'UTF8';
END IF;
END $$;
COMMENT ON DATABASE takeout_dictionary_db IS 'Takeout SaaS 字典域数据库';
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'takeout_hangfire_db') THEN
CREATE DATABASE takeout_hangfire_db OWNER hangfire_user ENCODING 'UTF8';
END IF;
END $$;
COMMENT ON DATABASE takeout_hangfire_db IS 'Takeout SaaS 调度/Hangfire 数据库';
-- Ensure privileges and default schema permissions
\connect takeout_app_db
GRANT CONNECT, TEMP ON DATABASE takeout_app_db TO app_user;
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO app_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO app_user;
\connect takeout_identity_db
GRANT CONNECT, TEMP ON DATABASE takeout_identity_db TO identity_user;
GRANT USAGE ON SCHEMA public TO identity_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO identity_user;
GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO identity_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO identity_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO identity_user;
\connect takeout_dictionary_db
GRANT CONNECT, TEMP ON DATABASE takeout_dictionary_db TO dictionary_user;
GRANT USAGE ON SCHEMA public TO dictionary_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO dictionary_user;
GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO dictionary_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO dictionary_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO dictionary_user;
\connect takeout_hangfire_db
GRANT CONNECT, TEMP ON DATABASE takeout_hangfire_db TO hangfire_user;
GRANT USAGE ON SCHEMA public TO hangfire_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO hangfire_user;
GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO hangfire_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO hangfire_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO hangfire_user;

View File

@@ -0,0 +1,34 @@
groups:
- name: takeoutsaas-app
interval: 30s
rules:
- alert: HighErrorRate
expr: |
sum(rate(http_server_request_duration_seconds_count{http_response_status_code=~"5.."}[5m]))
/ sum(rate(http_server_request_duration_seconds_count[5m])) > 0.05
for: 5m
labels:
severity: critical
annotations:
summary: "API 5xx 错误率过高"
description: "过去 5 分钟 5xx 占比超过 5%,请检查依赖或发布"
- alert: HighP95Latency
expr: |
histogram_quantile(0.95, sum(rate(http_server_request_duration_seconds_bucket[5m])) by (le, service_name))
> 1
for: 5m
labels:
severity: warning
annotations:
summary: "API P95 延迟过高"
description: "过去 5 分钟 P95 超过 1s请排查热点接口或依赖"
- alert: InstanceDown
expr: up{job=~"admin-api|mini-api|user-api"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: "实例不可达"
description: "Prometheus 抓取失败,实例处于 down 状态"

View File

@@ -0,0 +1,28 @@
global:
scrape_interval: 15s
evaluation_interval: 30s
rule_files:
- alert.rules.yml
scrape_configs:
- job_name: admin-api
metrics_path: /metrics
static_configs:
- targets: ["admin-api:8080"]
labels:
service: admin-api
- job_name: mini-api
metrics_path: /metrics
static_configs:
- targets: ["mini-api:8080"]
labels:
service: mini-api
- job_name: user-api
metrics_path: /metrics
static_configs:
- targets: ["user-api:8080"]
labels:
service: user-api

34
deploy/redis/README.md Normal file
View File

@@ -0,0 +1,34 @@
# Redis 部署脚本
本目录提供可复用的 Redis 配置,既可在本地通过 Docker Compose 启动,也可将 `redis.conf` 拷贝到现有服务器,确保与线上一致。
## 1. 部署步骤 (裸机)\n\n1. 将 \\
edis.conf\\ 拷贝到服务器(例如 /etc/redis/redis.conf。\n2. 根据需要修改数据目录(\\dir\\)和绑定地址。\n3. 使用系统服务或 \\
edis-server redis.conf\\ 启动。\n4. 确认开放端口 6379保证通过 \\
edis-cli -h <host> -a <pwd> ping\\ 可访问。\n\n## 2. 配置说明\n\n- \\
equirepass\\ 已设置为 MsuMshk112233。\n- 启用 appendonlyAOF并每秒 fsync。\n- \\maxmemory-policy\\ 为 allkeys-lru适合缓存场景。\n- \\protected-mode no\\ 允许远程连接,需结合安全组或防火墙限制来源 IP。\n\n## 3. 常用命令使用 `redis.conf`
1.`redis.conf` 拷贝到服务器 `/etc/redis/redis.conf`(或自定义目录)。
2. 修改 `dir` 指向实际数据目录。
3. 使用系统服务或 `redis-server redis.conf` 启动。
关键配置已包含:
- `requirepass`(密码)
- `protected-mode no`(允许远程连接)
- `appendonly yes` + `appendfsync everysec`
- `maxmemory-policy allkeys-lru`
## 3. 常用命令
在应用或 CLI 中使用:
```bash
redis-cli -h 49.232.6.45 -p 6379 -a MsuMshk112233 ping
```
`appsettings.*.json` 的格式:`"Redis": "49.232.6.45:6379,password=MsuMshk112233,abortConnect=false"`
## 4. 备份
- RDB 文件:`dump.rdb`

25
deploy/redis/redis.conf Normal file
View File

@@ -0,0 +1,25 @@
bind 0.0.0.0
port 6379
protected-mode no
requirepass MsuMshk112233
timeout 0
tcp-keepalive 300
daemonize no
loglevel notice
databases 16
save 900 1
save 300 10
save 60 10000
appendonly yes
appendfilename "appendonly.aof"
appendfsync everysec
dir /data
maxmemory-policy allkeys-lru

View File

@@ -0,0 +1,133 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.Identity.Abstractions;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Module.Authorization.Attributes;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
using TakeoutSaaS.Shared.Web.Security;
namespace TakeoutSaaS.AdminApi.Controllers;
/// <summary>
/// 管理后台认证接口
/// </summary>
/// <remarks>
///
/// </remarks>
/// <param name="authService"></param>
[ApiVersion("1.0")]
[Authorize]
[Route("api/admin/v{version:apiVersion}/auth")]
public sealed class AuthController(IAdminAuthService authService) : BaseApiController
{
/// <summary>
/// 登录获取 Token
/// </summary>
[HttpPost("login")]
[AllowAnonymous]
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<TokenResponse>> Login([FromBody] AdminLoginRequest request, CancellationToken cancellationToken)
{
var response = await authService.LoginAsync(request, cancellationToken);
return ApiResponse<TokenResponse>.Ok(response);
}
/// <summary>
/// 刷新 Token
/// </summary>
[HttpPost("refresh")]
[AllowAnonymous]
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<TokenResponse>> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
{
var response = await authService.RefreshTokenAsync(request, cancellationToken);
return ApiResponse<TokenResponse>.Ok(response);
}
/// <summary>
/// 获取当前用户信息
/// </summary>
/// <remarks>
/// 示例:
/// <code>
/// GET /api/admin/v1/auth/profile
/// Header: Authorization: Bearer &lt;JWT&gt;
/// 响应:
/// {
/// "success": true,
/// "code": 200,
/// "message": "操作成功",
/// "data": {
/// "userId": "900123456789012345",
/// "account": "admin@tenant1",
/// "displayName": "租户管理员",
/// "tenantId": "100000000000000001",
/// "merchantId": null,
/// "roles": ["TenantAdmin"],
/// "permissions": ["identity:permission:read", "merchant:read", "order:read"],
/// "avatar": "https://cdn.example.com/avatar.png"
/// }
/// }
/// </code>
/// </remarks>
[HttpGet("profile")]
[PermissionAuthorize("identity:profile:read")]
[ProducesResponseType(typeof(ApiResponse<CurrentUserProfile>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<CurrentUserProfile>), StatusCodes.Status401Unauthorized)]
public async Task<ApiResponse<CurrentUserProfile>> GetProfile(CancellationToken cancellationToken)
{
var userId = User.GetUserId();
if (userId == 0)
{
return ApiResponse<CurrentUserProfile>.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识");
}
var profile = await authService.GetProfileAsync(userId, cancellationToken);
return ApiResponse<CurrentUserProfile>.Ok(profile);
}
/// <summary>
/// 查询指定用户的角色与权限概览(当前租户范围)。
/// </summary>
/// <remarks>
/// 示例:
/// <code>
/// GET /api/admin/v1/auth/permissions/900123456789012346
/// Header: Authorization: Bearer &lt;JWT&gt;
/// 响应:
/// {
/// "success": true,
/// "code": 200,
/// "data": {
/// "userId": "900123456789012346",
/// "tenantId": "100000000000000001",
/// "merchantId": "200000000000000001",
/// "account": "ops.manager",
/// "displayName": "运营经理",
/// "roles": ["OpsManager", "Reporter"],
/// "permissions": ["delivery:read", "order:read", "payment:read"],
/// "createdAt": "2025-12-01T08:30:00Z"
/// }
/// }
/// </code>
/// </remarks>
[HttpGet("permissions/{userId:long}")]
[PermissionAuthorize("identity:permission:read")]
[ProducesResponseType(typeof(ApiResponse<UserPermissionDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<UserPermissionDto>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<UserPermissionDto>> GetUserPermissions(long userId, CancellationToken cancellationToken)
{
var result = await authService.GetUserPermissionsAsync(userId, cancellationToken);
return result is null
? ApiResponse<UserPermissionDto>.Error(ErrorCodes.NotFound, "用户不存在或不属于当前租户")
: ApiResponse<UserPermissionDto>.Ok(result);
}
}

View File

@@ -0,0 +1,117 @@
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;
using TakeoutSaaS.Application.App.Deliveries.Queries;
using TakeoutSaaS.Domain.Deliveries.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;
/// <summary>
/// 配送单管理。
/// </summary>
/// <remarks>
/// 初始化控制器。
/// </remarks>
[ApiVersion("1.0")]
[Authorize]
[Route("api/admin/v{version:apiVersion}/deliveries")]
public sealed class DeliveriesController(IMediator mediator) : BaseApiController
{
/// <summary>
/// 创建配送单。
/// </summary>
[HttpPost]
[PermissionAuthorize("delivery:create")]
[ProducesResponseType(typeof(ApiResponse<DeliveryOrderDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<DeliveryOrderDto>> Create([FromBody] CreateDeliveryOrderCommand command, CancellationToken cancellationToken)
{
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<DeliveryOrderDto>.Ok(result);
}
/// <summary>
/// 查询配送单列表。
/// </summary>
[HttpGet]
[PermissionAuthorize("delivery:read")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<DeliveryOrderDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<DeliveryOrderDto>>> List(
[FromQuery] long? orderId,
[FromQuery] DeliveryStatus? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? sortBy = null,
[FromQuery] bool sortDesc = true,
CancellationToken cancellationToken = default)
{
var result = await mediator.Send(new SearchDeliveryOrdersQuery
{
OrderId = orderId,
Status = status,
Page = page,
PageSize = pageSize,
SortBy = sortBy,
SortDescending = sortDesc
}, cancellationToken);
return ApiResponse<PagedResult<DeliveryOrderDto>>.Ok(result);
}
/// <summary>
/// 获取配送单详情。
/// </summary>
[HttpGet("{deliveryOrderId:long}")]
[PermissionAuthorize("delivery:read")]
[ProducesResponseType(typeof(ApiResponse<DeliveryOrderDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<DeliveryOrderDto>> Detail(long deliveryOrderId, CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetDeliveryOrderByIdQuery { DeliveryOrderId = deliveryOrderId }, cancellationToken);
return result == null
? ApiResponse<DeliveryOrderDto>.Error(ErrorCodes.NotFound, "配送单不存在")
: ApiResponse<DeliveryOrderDto>.Ok(result);
}
/// <summary>
/// 更新配送单。
/// </summary>
[HttpPut("{deliveryOrderId:long}")]
[PermissionAuthorize("delivery:update")]
[ProducesResponseType(typeof(ApiResponse<DeliveryOrderDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<DeliveryOrderDto>> Update(long deliveryOrderId, [FromBody] UpdateDeliveryOrderCommand command, CancellationToken cancellationToken)
{
if (command.DeliveryOrderId == 0)
{
command = command with { DeliveryOrderId = deliveryOrderId };
}
var result = await mediator.Send(command, cancellationToken);
return result == null
? ApiResponse<DeliveryOrderDto>.Error(ErrorCodes.NotFound, "配送单不存在")
: ApiResponse<DeliveryOrderDto>.Ok(result);
}
/// <summary>
/// 删除配送单。
/// </summary>
[HttpDelete("{deliveryOrderId:long}")]
[PermissionAuthorize("delivery:delete")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<object>> Delete(long deliveryOrderId, CancellationToken cancellationToken)
{
var success = await mediator.Send(new DeleteDeliveryOrderCommand { DeliveryOrderId = deliveryOrderId }, cancellationToken);
return success
? ApiResponse<object>.Ok(null)
: ApiResponse<object>.Error(ErrorCodes.NotFound, "配送单不存在");
}
}

View File

@@ -0,0 +1,121 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.Dictionary.Abstractions;
using TakeoutSaaS.Application.Dictionary.Contracts;
using TakeoutSaaS.Application.Dictionary.Models;
using TakeoutSaaS.Module.Authorization.Attributes;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
namespace TakeoutSaaS.AdminApi.Controllers;
/// <summary>
/// 参数字典管理。
/// </summary>
/// <remarks>
/// 初始化字典控制器。
/// </remarks>
/// <param name="dictionaryAppService">字典服务</param>
[ApiVersion("1.0")]
[Authorize]
[Route("api/admin/v{version:apiVersion}/dictionaries")]
public sealed class DictionaryController(IDictionaryAppService dictionaryAppService) : BaseApiController
{
/// <summary>
/// 查询字典分组。
/// </summary>
[HttpGet]
[PermissionAuthorize("dictionary:group:read")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<DictionaryGroupDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<IReadOnlyList<DictionaryGroupDto>>> GetGroups([FromQuery] DictionaryGroupQuery query, CancellationToken cancellationToken)
{
var groups = await dictionaryAppService.SearchGroupsAsync(query, cancellationToken);
return ApiResponse<IReadOnlyList<DictionaryGroupDto>>.Ok(groups);
}
/// <summary>
/// 创建字典分组。
/// </summary>
[HttpPost]
[PermissionAuthorize("dictionary:group:create")]
[ProducesResponseType(typeof(ApiResponse<DictionaryGroupDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<DictionaryGroupDto>> CreateGroup([FromBody] CreateDictionaryGroupRequest request, CancellationToken cancellationToken)
{
var group = await dictionaryAppService.CreateGroupAsync(request, cancellationToken);
return ApiResponse<DictionaryGroupDto>.Ok(group);
}
/// <summary>
/// 更新字典分组。
/// </summary>
[HttpPut("{groupId:long}")]
[PermissionAuthorize("dictionary:group:update")]
[ProducesResponseType(typeof(ApiResponse<DictionaryGroupDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<DictionaryGroupDto>> UpdateGroup(long groupId, [FromBody] UpdateDictionaryGroupRequest request, CancellationToken cancellationToken)
{
var group = await dictionaryAppService.UpdateGroupAsync(groupId, request, cancellationToken);
return ApiResponse<DictionaryGroupDto>.Ok(group);
}
/// <summary>
/// 删除字典分组。
/// </summary>
[HttpDelete("{groupId:long}")]
[PermissionAuthorize("dictionary:group:delete")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<ApiResponse<object>> DeleteGroup(long groupId, CancellationToken cancellationToken)
{
await dictionaryAppService.DeleteGroupAsync(groupId, cancellationToken);
return ApiResponse.Success();
}
/// <summary>
/// 创建字典项。
/// </summary>
[HttpPost("{groupId:long}/items")]
[PermissionAuthorize("dictionary:item:create")]
[ProducesResponseType(typeof(ApiResponse<DictionaryItemDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<DictionaryItemDto>> CreateItem(long groupId, [FromBody] CreateDictionaryItemRequest request, CancellationToken cancellationToken)
{
request.GroupId = groupId;
var item = await dictionaryAppService.CreateItemAsync(request, cancellationToken);
return ApiResponse<DictionaryItemDto>.Ok(item);
}
/// <summary>
/// 更新字典项。
/// </summary>
[HttpPut("items/{itemId:long}")]
[PermissionAuthorize("dictionary:item:update")]
[ProducesResponseType(typeof(ApiResponse<DictionaryItemDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<DictionaryItemDto>> UpdateItem(long itemId, [FromBody] UpdateDictionaryItemRequest request, CancellationToken cancellationToken)
{
var item = await dictionaryAppService.UpdateItemAsync(itemId, request, cancellationToken);
return ApiResponse<DictionaryItemDto>.Ok(item);
}
/// <summary>
/// 删除字典项。
/// </summary>
[HttpDelete("items/{itemId:long}")]
[PermissionAuthorize("dictionary:item:delete")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<ApiResponse<object>> DeleteItem(long itemId, CancellationToken cancellationToken)
{
await dictionaryAppService.DeleteItemAsync(itemId, cancellationToken);
return ApiResponse.Success();
}
/// <summary>
/// 批量获取字典项(命中缓存)。
/// </summary>
[HttpPost("batch")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyDictionary<string, IReadOnlyList<DictionaryItemDto>>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<IReadOnlyDictionary<string, IReadOnlyList<DictionaryItemDto>>>> BatchGet([FromBody] DictionaryBatchQueryRequest request, CancellationToken cancellationToken)
{
var dictionaries = await dictionaryAppService.GetCachedItemsAsync(request, cancellationToken);
return ApiResponse<IReadOnlyDictionary<string, IReadOnlyList<DictionaryItemDto>>>.Ok(dictionaries);
}
}

View File

@@ -0,0 +1,52 @@
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;
using TakeoutSaaS.Application.Storage.Extensions;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
namespace TakeoutSaaS.AdminApi.Controllers;
/// <summary>
/// 管理后台文件上传。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/admin/v{version:apiVersion}/files")]
public sealed class FilesController(IFileStorageService fileStorageService) : BaseApiController
{
private readonly IFileStorageService _fileStorageService = fileStorageService;
/// <summary>
/// 上传图片或文件。
/// </summary>
[HttpPost("upload")]
[RequestFormLimits(MultipartBodyLengthLimit = 30 * 1024 * 1024)]
[ProducesResponseType(typeof(ApiResponse<FileUploadResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<FileUploadResponse>), StatusCodes.Status400BadRequest)]
public async Task<ApiResponse<FileUploadResponse>> Upload([FromForm] IFormFile? file, [FromForm] string? type, CancellationToken cancellationToken)
{
if (file == null || file.Length == 0)
{
return ApiResponse<FileUploadResponse>.Error(ErrorCodes.BadRequest, "文件不能为空");
}
if (!UploadFileTypeParser.TryParse(type, out var uploadType))
{
return ApiResponse<FileUploadResponse>.Error(ErrorCodes.BadRequest, "上传类型不合法");
}
var origin = Request.Headers["Origin"].FirstOrDefault() ?? Request.Headers["Referer"].FirstOrDefault();
await using var stream = file.OpenReadStream();
var result = await _fileStorageService.UploadAsync(
new UploadFileRequest(uploadType, stream, file.FileName, file.ContentType ?? string.Empty, file.Length, origin),
cancellationToken);
return ApiResponse<FileUploadResponse>.Ok(result);
}
}

View File

@@ -0,0 +1,29 @@
using System;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
namespace TakeoutSaaS.AdminApi.Controllers;
/// <summary>
/// 管理后台 - 健康检查。
/// </summary>
[ApiVersion("1.0")]
[Route("api/admin/v{version:apiVersion}/[controller]")]
public class HealthController : BaseApiController
{
/// <summary>
/// 获取服务健康状态。
/// </summary>
/// <returns>健康状态</returns>
[HttpGet]
[AllowAnonymous]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public ApiResponse<object> Get()
{
var payload = new { status = "OK", service = "AdminApi", time = DateTime.UtcNow };
return ApiResponse<object>.Ok(payload);
}
}

View File

@@ -0,0 +1,115 @@
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.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;
/// <summary>
/// 商户管理。
/// </summary>
/// <remarks>
/// 初始化控制器。
/// </remarks>
[ApiVersion("1.0")]
[Authorize]
[Route("api/admin/v{version:apiVersion}/merchants")]
public sealed class MerchantsController(IMediator mediator) : BaseApiController
{
/// <summary>
/// 创建商户。
/// </summary>
[HttpPost]
[PermissionAuthorize("merchant:create")]
[ProducesResponseType(typeof(ApiResponse<MerchantDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MerchantDto>> Create([FromBody] CreateMerchantCommand command, CancellationToken cancellationToken)
{
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<MerchantDto>.Ok(result);
}
/// <summary>
/// 查询商户列表。
/// </summary>
[HttpGet]
[PermissionAuthorize("merchant:read")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<MerchantDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<MerchantDto>>> List(
[FromQuery] MerchantStatus? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? sortBy = null,
[FromQuery] bool sortDesc = true,
CancellationToken cancellationToken = default)
{
var result = await mediator.Send(new SearchMerchantsQuery
{
Status = status,
Page = page,
PageSize = pageSize,
SortBy = sortBy,
SortDescending = sortDesc
}, cancellationToken);
return ApiResponse<PagedResult<MerchantDto>>.Ok(result);
}
/// <summary>
/// 更新商户。
/// </summary>
[HttpPut("{merchantId:long}")]
[PermissionAuthorize("merchant:update")]
[ProducesResponseType(typeof(ApiResponse<MerchantDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<MerchantDto>> Update(long merchantId, [FromBody] UpdateMerchantCommand command, CancellationToken cancellationToken)
{
if (command.MerchantId == 0)
{
command = command with { MerchantId = merchantId };
}
var result = await mediator.Send(command, cancellationToken);
return result == null
? ApiResponse<MerchantDto>.Error(ErrorCodes.NotFound, "商户不存在")
: ApiResponse<MerchantDto>.Ok(result);
}
/// <summary>
/// 删除商户。
/// </summary>
[HttpDelete("{merchantId:long}")]
[PermissionAuthorize("merchant:delete")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<object>> Delete(long merchantId, CancellationToken cancellationToken)
{
var success = await mediator.Send(new DeleteMerchantCommand { MerchantId = merchantId }, cancellationToken);
return success
? ApiResponse<object>.Ok(null)
: ApiResponse<object>.Error(ErrorCodes.NotFound, "商户不存在");
}
/// <summary>
/// 获取商户详情。
/// </summary>
[HttpGet("{merchantId:long}")]
[PermissionAuthorize("merchant:read")]
[ProducesResponseType(typeof(ApiResponse<MerchantDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<MerchantDto>> Detail(long merchantId, CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetMerchantByIdQuery { MerchantId = merchantId }, cancellationToken);
return result == null
? ApiResponse<MerchantDto>.Error(ErrorCodes.NotFound, "商户不存在")
: ApiResponse<MerchantDto>.Ok(result);
}
}

View File

@@ -0,0 +1,122 @@
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;
using TakeoutSaaS.Application.App.Orders.Queries;
using TakeoutSaaS.Domain.Orders.Enums;
using TakeoutSaaS.Domain.Payments.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;
/// <summary>
/// 订单管理。
/// </summary>
/// <remarks>
/// 初始化控制器。
/// </remarks>
[ApiVersion("1.0")]
[Authorize]
[Route("api/admin/v{version:apiVersion}/orders")]
public sealed class OrdersController(IMediator mediator) : BaseApiController
{
/// <summary>
/// 创建订单。
/// </summary>
[HttpPost]
[PermissionAuthorize("order:create")]
[ProducesResponseType(typeof(ApiResponse<OrderDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<OrderDto>> Create([FromBody] CreateOrderCommand command, CancellationToken cancellationToken)
{
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<OrderDto>.Ok(result);
}
/// <summary>
/// 查询订单列表。
/// </summary>
[HttpGet]
[PermissionAuthorize("order:read")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<OrderDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<OrderDto>>> List(
[FromQuery] long? storeId,
[FromQuery] OrderStatus? status,
[FromQuery] PaymentStatus? paymentStatus,
[FromQuery] string? orderNo,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? sortBy = null,
[FromQuery] bool sortDesc = true,
CancellationToken cancellationToken = default)
{
var result = await mediator.Send(new SearchOrdersQuery
{
StoreId = storeId,
Status = status,
PaymentStatus = paymentStatus,
OrderNo = orderNo,
Page = page,
PageSize = pageSize,
SortBy = sortBy,
SortDescending = sortDesc
}, cancellationToken);
return ApiResponse<PagedResult<OrderDto>>.Ok(result);
}
/// <summary>
/// 获取订单详情。
/// </summary>
[HttpGet("{orderId:long}")]
[PermissionAuthorize("order:read")]
[ProducesResponseType(typeof(ApiResponse<OrderDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<OrderDto>> Detail(long orderId, CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetOrderByIdQuery { OrderId = orderId }, cancellationToken);
return result == null
? ApiResponse<OrderDto>.Error(ErrorCodes.NotFound, "订单不存在")
: ApiResponse<OrderDto>.Ok(result);
}
/// <summary>
/// 更新订单。
/// </summary>
[HttpPut("{orderId:long}")]
[PermissionAuthorize("order:update")]
[ProducesResponseType(typeof(ApiResponse<OrderDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<OrderDto>> Update(long orderId, [FromBody] UpdateOrderCommand command, CancellationToken cancellationToken)
{
if (command.OrderId == 0)
{
command = command with { OrderId = orderId };
}
var result = await mediator.Send(command, cancellationToken);
return result == null
? ApiResponse<OrderDto>.Error(ErrorCodes.NotFound, "订单不存在")
: ApiResponse<OrderDto>.Ok(result);
}
/// <summary>
/// 删除订单。
/// </summary>
[HttpDelete("{orderId:long}")]
[PermissionAuthorize("order:delete")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<object>> Delete(long orderId, CancellationToken cancellationToken)
{
var success = await mediator.Send(new DeleteOrderCommand { OrderId = orderId }, cancellationToken);
return success
? ApiResponse<object>.Ok(null)
: ApiResponse<object>.Error(ErrorCodes.NotFound, "订单不存在");
}
}

View File

@@ -0,0 +1,117 @@
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;
using TakeoutSaaS.Application.App.Payments.Queries;
using TakeoutSaaS.Domain.Payments.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;
/// <summary>
/// 支付记录管理。
/// </summary>
/// <remarks>
/// 初始化控制器。
/// </remarks>
[ApiVersion("1.0")]
[Authorize]
[Route("api/admin/v{version:apiVersion}/payments")]
public sealed class PaymentsController(IMediator mediator) : BaseApiController
{
/// <summary>
/// 创建支付记录。
/// </summary>
[HttpPost]
[PermissionAuthorize("payment:create")]
[ProducesResponseType(typeof(ApiResponse<PaymentDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PaymentDto>> Create([FromBody] CreatePaymentCommand command, CancellationToken cancellationToken)
{
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<PaymentDto>.Ok(result);
}
/// <summary>
/// 查询支付记录列表。
/// </summary>
[HttpGet]
[PermissionAuthorize("payment:read")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<PaymentDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<PaymentDto>>> List(
[FromQuery] long? orderId,
[FromQuery] PaymentStatus? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? sortBy = null,
[FromQuery] bool sortDesc = true,
CancellationToken cancellationToken = default)
{
var result = await mediator.Send(new SearchPaymentsQuery
{
OrderId = orderId,
Status = status,
Page = page,
PageSize = pageSize,
SortBy = sortBy,
SortDescending = sortDesc
}, cancellationToken);
return ApiResponse<PagedResult<PaymentDto>>.Ok(result);
}
/// <summary>
/// 获取支付记录详情。
/// </summary>
[HttpGet("{paymentId:long}")]
[PermissionAuthorize("payment:read")]
[ProducesResponseType(typeof(ApiResponse<PaymentDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<PaymentDto>> Detail(long paymentId, CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetPaymentByIdQuery { PaymentId = paymentId }, cancellationToken);
return result == null
? ApiResponse<PaymentDto>.Error(ErrorCodes.NotFound, "支付记录不存在")
: ApiResponse<PaymentDto>.Ok(result);
}
/// <summary>
/// 更新支付记录。
/// </summary>
[HttpPut("{paymentId:long}")]
[PermissionAuthorize("payment:update")]
[ProducesResponseType(typeof(ApiResponse<PaymentDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<PaymentDto>> Update(long paymentId, [FromBody] UpdatePaymentCommand command, CancellationToken cancellationToken)
{
if (command.PaymentId == 0)
{
command = command with { PaymentId = paymentId };
}
var result = await mediator.Send(command, cancellationToken);
return result == null
? ApiResponse<PaymentDto>.Error(ErrorCodes.NotFound, "支付记录不存在")
: ApiResponse<PaymentDto>.Ok(result);
}
/// <summary>
/// 删除支付记录。
/// </summary>
[HttpDelete("{paymentId:long}")]
[PermissionAuthorize("payment:delete")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<object>> Delete(long paymentId, CancellationToken cancellationToken)
{
var success = await mediator.Send(new DeletePaymentCommand { PaymentId = paymentId }, cancellationToken);
return success
? ApiResponse<object>.Ok(null)
: ApiResponse<object>.Error(ErrorCodes.NotFound, "支付记录不存在");
}
}

View File

@@ -0,0 +1,78 @@
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;
using TakeoutSaaS.Application.Identity.Queries;
using TakeoutSaaS.Module.Authorization.Attributes;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
namespace TakeoutSaaS.AdminApi.Controllers;
/// <summary>
/// 权限管理。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/admin/v{version:apiVersion}/permissions")]
public sealed class PermissionsController(IMediator mediator) : BaseApiController
{
/// <summary>
/// 分页查询权限。
/// </summary>
/// <remarks>
/// 示例GET /api/admin/v1/permissions?keyword=order&amp;page=1&amp;pageSize=20
/// </remarks>
[HttpGet]
[PermissionAuthorize("identity:permission:read")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<PermissionDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<PermissionDto>>> Search([FromQuery] SearchPermissionsQuery query, CancellationToken cancellationToken)
{
var result = await mediator.Send(query, cancellationToken);
return ApiResponse<PagedResult<PermissionDto>>.Ok(result);
}
/// <summary>
/// 创建权限。
/// </summary>
[HttpPost]
[PermissionAuthorize("identity:permission:create")]
[ProducesResponseType(typeof(ApiResponse<PermissionDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PermissionDto>> Create([FromBody, Required] CreatePermissionCommand command, CancellationToken cancellationToken)
{
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<PermissionDto>.Ok(result);
}
/// <summary>
/// 更新权限。
/// </summary>
[HttpPut("{permissionId:long}")]
[PermissionAuthorize("identity:permission:update")]
[ProducesResponseType(typeof(ApiResponse<PermissionDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<PermissionDto>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<PermissionDto>> Update(long permissionId, [FromBody, Required] UpdatePermissionCommand command, CancellationToken cancellationToken)
{
command = command with { PermissionId = permissionId };
var result = await mediator.Send(command, cancellationToken);
return result is null
? ApiResponse<PermissionDto>.Error(StatusCodes.Status404NotFound, "权限不存在")
: ApiResponse<PermissionDto>.Ok(result);
}
/// <summary>
/// 删除权限。
/// </summary>
[HttpDelete("{permissionId:long}")]
[PermissionAuthorize("identity:permission:delete")]
[ProducesResponseType(typeof(ApiResponse<bool>), StatusCodes.Status200OK)]
public async Task<ApiResponse<bool>> Delete(long permissionId, CancellationToken cancellationToken)
{
var command = new DeletePermissionCommand { PermissionId = permissionId };
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<bool>.Ok(result);
}
}

View File

@@ -0,0 +1,119 @@
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;
using TakeoutSaaS.Application.App.Products.Queries;
using TakeoutSaaS.Domain.Products.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;
/// <summary>
/// 商品管理。
/// </summary>
/// <remarks>
/// 初始化控制器。
/// </remarks>
[ApiVersion("1.0")]
[Authorize]
[Route("api/admin/v{version:apiVersion}/products")]
public sealed class ProductsController(IMediator mediator) : BaseApiController
{
/// <summary>
/// 创建商品。
/// </summary>
[HttpPost]
[PermissionAuthorize("product:create")]
[ProducesResponseType(typeof(ApiResponse<ProductDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<ProductDto>> Create([FromBody] CreateProductCommand command, CancellationToken cancellationToken)
{
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<ProductDto>.Ok(result);
}
/// <summary>
/// 查询商品列表。
/// </summary>
[HttpGet]
[PermissionAuthorize("product:read")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<ProductDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<ProductDto>>> List(
[FromQuery] long? storeId,
[FromQuery] long? categoryId,
[FromQuery] ProductStatus? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? sortBy = null,
[FromQuery] bool sortDesc = true,
CancellationToken cancellationToken = default)
{
var result = await mediator.Send(new SearchProductsQuery
{
StoreId = storeId,
CategoryId = categoryId,
Status = status,
Page = page,
PageSize = pageSize,
SortBy = sortBy,
SortDescending = sortDesc
}, cancellationToken);
return ApiResponse<PagedResult<ProductDto>>.Ok(result);
}
/// <summary>
/// 获取商品详情。
/// </summary>
[HttpGet("{productId:long}")]
[PermissionAuthorize("product:read")]
[ProducesResponseType(typeof(ApiResponse<ProductDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<ProductDto>> Detail(long productId, CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetProductByIdQuery { ProductId = productId }, cancellationToken);
return result == null
? ApiResponse<ProductDto>.Error(ErrorCodes.NotFound, "商品不存在")
: ApiResponse<ProductDto>.Ok(result);
}
/// <summary>
/// 更新商品。
/// </summary>
[HttpPut("{productId:long}")]
[PermissionAuthorize("product:update")]
[ProducesResponseType(typeof(ApiResponse<ProductDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<ProductDto>> Update(long productId, [FromBody] UpdateProductCommand command, CancellationToken cancellationToken)
{
if (command.ProductId == 0)
{
command = command with { ProductId = productId };
}
var result = await mediator.Send(command, cancellationToken);
return result == null
? ApiResponse<ProductDto>.Error(ErrorCodes.NotFound, "商品不存在")
: ApiResponse<ProductDto>.Ok(result);
}
/// <summary>
/// 删除商品。
/// </summary>
[HttpDelete("{productId:long}")]
[PermissionAuthorize("product:delete")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<object>> Delete(long productId, CancellationToken cancellationToken)
{
var success = await mediator.Send(new DeleteProductCommand { ProductId = productId }, cancellationToken);
return success
? ApiResponse<object>.Ok(null)
: ApiResponse<object>.Error(ErrorCodes.NotFound, "商品不存在");
}
}

View File

@@ -0,0 +1,93 @@
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;
using TakeoutSaaS.Application.Identity.Queries;
using TakeoutSaaS.Module.Authorization.Attributes;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
namespace TakeoutSaaS.AdminApi.Controllers;
/// <summary>
/// 角色管理。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/admin/v{version:apiVersion}/roles")]
public sealed class RolesController(IMediator mediator) : BaseApiController
{
/// <summary>
/// 分页查询角色。
/// </summary>
/// <remarks>
/// 示例:
/// GET /api/admin/v1/roles?keyword=ops&amp;page=1&amp;pageSize=20
/// Header: Authorization: Bearer &lt;JWT&gt; + X-Tenant-Id
/// </remarks>
[HttpGet]
[PermissionAuthorize("identity:role:read")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<RoleDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<RoleDto>>> Search([FromQuery] SearchRolesQuery query, CancellationToken cancellationToken)
{
var result = await mediator.Send(query, cancellationToken);
return ApiResponse<PagedResult<RoleDto>>.Ok(result);
}
/// <summary>
/// 创建角色。
/// </summary>
[HttpPost]
[PermissionAuthorize("identity:role:create")]
[ProducesResponseType(typeof(ApiResponse<RoleDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<RoleDto>> Create([FromBody, Required] CreateRoleCommand command, CancellationToken cancellationToken)
{
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<RoleDto>.Ok(result);
}
/// <summary>
/// 更新角色。
/// </summary>
[HttpPut("{roleId:long}")]
[PermissionAuthorize("identity:role:update")]
[ProducesResponseType(typeof(ApiResponse<RoleDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<RoleDto>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<RoleDto>> Update(long roleId, [FromBody, Required] UpdateRoleCommand command, CancellationToken cancellationToken)
{
command = command with { RoleId = roleId };
var result = await mediator.Send(command, cancellationToken);
return result is null
? ApiResponse<RoleDto>.Error(StatusCodes.Status404NotFound, "角色不存在")
: ApiResponse<RoleDto>.Ok(result);
}
/// <summary>
/// 删除角色。
/// </summary>
[HttpDelete("{roleId:long}")]
[PermissionAuthorize("identity:role:delete")]
[ProducesResponseType(typeof(ApiResponse<bool>), StatusCodes.Status200OK)]
public async Task<ApiResponse<bool>> Delete(long roleId, CancellationToken cancellationToken)
{
var command = new DeleteRoleCommand { RoleId = roleId };
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<bool>.Ok(result);
}
/// <summary>
/// 绑定角色权限(覆盖式)。
/// </summary>
[HttpPut("{roleId:long}/permissions")]
[PermissionAuthorize("identity:role:bind-permission")]
[ProducesResponseType(typeof(ApiResponse<bool>), StatusCodes.Status200OK)]
public async Task<ApiResponse<bool>> BindPermissions(long roleId, [FromBody, Required] BindRolePermissionsCommand command, CancellationToken cancellationToken)
{
command = command with { RoleId = roleId };
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<bool>.Ok(result);
}
}

View File

@@ -0,0 +1,117 @@
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;
/// <summary>
/// 门店管理。
/// </summary>
/// <remarks>
/// 初始化控制器。
/// </remarks>
[ApiVersion("1.0")]
[Authorize]
[Route("api/admin/v{version:apiVersion}/stores")]
public sealed class StoresController(IMediator mediator) : BaseApiController
{
/// <summary>
/// 创建门店。
/// </summary>
[HttpPost]
[PermissionAuthorize("store:create")]
[ProducesResponseType(typeof(ApiResponse<StoreDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<StoreDto>> Create([FromBody] CreateStoreCommand command, CancellationToken cancellationToken)
{
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<StoreDto>.Ok(result);
}
/// <summary>
/// 查询门店列表。
/// </summary>
[HttpGet]
[PermissionAuthorize("store:read")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<StoreDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<StoreDto>>> List(
[FromQuery] long? merchantId,
[FromQuery] StoreStatus? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? sortBy = null,
[FromQuery] bool sortDesc = true,
CancellationToken cancellationToken = default)
{
var result = await mediator.Send(new SearchStoresQuery
{
MerchantId = merchantId,
Status = status,
Page = page,
PageSize = pageSize,
SortBy = sortBy,
SortDescending = sortDesc
}, cancellationToken);
return ApiResponse<PagedResult<StoreDto>>.Ok(result);
}
/// <summary>
/// 获取门店详情。
/// </summary>
[HttpGet("{storeId:long}")]
[PermissionAuthorize("store:read")]
[ProducesResponseType(typeof(ApiResponse<StoreDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<StoreDto>> Detail(long storeId, CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetStoreByIdQuery { StoreId = storeId }, cancellationToken);
return result == null
? ApiResponse<StoreDto>.Error(ErrorCodes.NotFound, "门店不存在")
: ApiResponse<StoreDto>.Ok(result);
}
/// <summary>
/// 更新门店。
/// </summary>
[HttpPut("{storeId:long}")]
[PermissionAuthorize("store:update")]
[ProducesResponseType(typeof(ApiResponse<StoreDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<StoreDto>> Update(long storeId, [FromBody] UpdateStoreCommand command, CancellationToken cancellationToken)
{
if (command.StoreId == 0)
{
command = command with { StoreId = storeId };
}
var result = await mediator.Send(command, cancellationToken);
return result == null
? ApiResponse<StoreDto>.Error(ErrorCodes.NotFound, "门店不存在")
: ApiResponse<StoreDto>.Ok(result);
}
/// <summary>
/// 删除门店。
/// </summary>
[HttpDelete("{storeId:long}")]
[PermissionAuthorize("store:delete")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<object>> Delete(long storeId, CancellationToken cancellationToken)
{
var success = await mediator.Send(new DeleteStoreCommand { StoreId = storeId }, cancellationToken);
return success
? ApiResponse<object>.Ok(null)
: ApiResponse<object>.Error(ErrorCodes.NotFound, "门店不存在");
}
}

View File

@@ -0,0 +1,115 @@
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;
using TakeoutSaaS.Application.App.SystemParameters.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;
/// <summary>
/// 系统参数管理。
/// </summary>
/// <remarks>
/// 提供参数的新增、修改、查询与删除。
/// </remarks>
[ApiVersion("1.0")]
[Authorize]
[Route("api/admin/v{version:apiVersion}/system-parameters")]
public sealed class SystemParametersController(IMediator mediator) : BaseApiController
{
/// <summary>
/// 创建系统参数。
/// </summary>
[HttpPost]
[PermissionAuthorize("system-parameter:create")]
[ProducesResponseType(typeof(ApiResponse<SystemParameterDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<SystemParameterDto>> Create([FromBody] CreateSystemParameterCommand command, CancellationToken cancellationToken)
{
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<SystemParameterDto>.Ok(result);
}
/// <summary>
/// 查询系统参数列表。
/// </summary>
[HttpGet]
[PermissionAuthorize("system-parameter:read")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<SystemParameterDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<SystemParameterDto>>> List(
[FromQuery] string? keyword,
[FromQuery] bool? isEnabled,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? sortBy = null,
[FromQuery] bool sortDesc = true,
CancellationToken cancellationToken = default)
{
var result = await mediator.Send(new SearchSystemParametersQuery
{
Keyword = keyword,
IsEnabled = isEnabled,
Page = page,
PageSize = pageSize,
SortBy = sortBy,
SortDescending = sortDesc
}, cancellationToken);
return ApiResponse<PagedResult<SystemParameterDto>>.Ok(result);
}
/// <summary>
/// 获取系统参数详情。
/// </summary>
[HttpGet("{parameterId:long}")]
[PermissionAuthorize("system-parameter:read")]
[ProducesResponseType(typeof(ApiResponse<SystemParameterDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<SystemParameterDto>> Detail(long parameterId, CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetSystemParameterByIdQuery(parameterId), cancellationToken);
return result == null
? ApiResponse<SystemParameterDto>.Error(ErrorCodes.NotFound, "系统参数不存在")
: ApiResponse<SystemParameterDto>.Ok(result);
}
/// <summary>
/// 更新系统参数。
/// </summary>
[HttpPut("{parameterId:long}")]
[PermissionAuthorize("system-parameter:update")]
[ProducesResponseType(typeof(ApiResponse<SystemParameterDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<SystemParameterDto>> Update(long parameterId, [FromBody] UpdateSystemParameterCommand command, CancellationToken cancellationToken)
{
if (command.ParameterId == 0)
{
command = command with { ParameterId = parameterId };
}
var result = await mediator.Send(command, cancellationToken);
return result == null
? ApiResponse<SystemParameterDto>.Error(ErrorCodes.NotFound, "系统参数不存在")
: ApiResponse<SystemParameterDto>.Ok(result);
}
/// <summary>
/// 删除系统参数。
/// </summary>
[HttpDelete("{parameterId:long}")]
[PermissionAuthorize("system-parameter:delete")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<object>> Delete(long parameterId, CancellationToken cancellationToken)
{
var success = await mediator.Send(new DeleteSystemParameterCommand { ParameterId = parameterId }, cancellationToken);
return success
? ApiResponse.Success()
: ApiResponse<object>.Error(ErrorCodes.NotFound, "系统参数不存在");
}
}

View File

@@ -0,0 +1,71 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.Identity.Abstractions;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Application.Identity.Queries;
using TakeoutSaaS.Module.Authorization.Attributes;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
namespace TakeoutSaaS.AdminApi.Controllers;
/// <summary>
/// 用户权限洞察接口。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/admin/v{version:apiVersion}/users/permissions")]
public sealed class UserPermissionsController(IAdminAuthService authService) : BaseApiController
{
/// <summary>
/// 分页查询当前租户用户的角色与权限概览。
/// </summary>
/// <remarks>
/// 示例:
/// <code>
/// GET /api/admin/v1/users/permissions?keyword=ops&amp;page=1&amp;pageSize=20&amp;sortBy=createdAt&amp;sortDescending=true
/// Header: Authorization: Bearer &lt;JWT&gt;
/// 响应:
/// {
/// "success": true,
/// "code": 200,
/// "data": {
/// "items": [
/// {
/// "userId": "900123456789012346",
/// "tenantId": "100000000000000001",
/// "merchantId": "200000000000000001",
/// "account": "ops.manager",
/// "displayName": "运营经理",
/// "roles": ["OpsManager", "Reporter"],
/// "permissions": ["delivery:read", "order:read", "payment:read"],
/// "createdAt": "2025-12-01T08:30:00Z"
/// }
/// ],
/// "page": 1,
/// "pageSize": 20,
/// "totalCount": 1,
/// "totalPages": 1
/// }
/// }
/// </code>
/// </remarks>
[HttpGet]
[PermissionAuthorize("identity:permission:read")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<UserPermissionDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<UserPermissionDto>>> Search(
[FromQuery] SearchUserPermissionsQuery query,
CancellationToken cancellationToken)
{
var result = await authService.SearchUserPermissionsAsync(
query.Keyword,
query.Page,
query.PageSize,
query.SortBy,
query.SortDescending,
cancellationToken);
return ApiResponse<PagedResult<UserPermissionDto>>.Ok(result);
}
}

View File

@@ -0,0 +1,12 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj
RUN dotnet publish src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 7801
ENV ASPNETCORE_URLS=http://+:7801
ENTRYPOINT ["dotnet", "TakeoutSaaS.AdminApi.dll"]

View File

@@ -0,0 +1,173 @@
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;
using Serilog;
using TakeoutSaaS.Application.App.Extensions;
using TakeoutSaaS.Application.Identity.Extensions;
using TakeoutSaaS.Application.Messaging.Extensions;
using TakeoutSaaS.Application.Sms.Extensions;
using TakeoutSaaS.Application.Storage.Extensions;
using TakeoutSaaS.Infrastructure.App.Extensions;
using TakeoutSaaS.Infrastructure.Identity.Extensions;
using TakeoutSaaS.Module.Authorization.Extensions;
using TakeoutSaaS.Module.Dictionary.Extensions;
using TakeoutSaaS.Module.Messaging.Extensions;
using TakeoutSaaS.Module.Scheduler.Extensions;
using TakeoutSaaS.Module.Sms.Extensions;
using TakeoutSaaS.Module.Storage.Extensions;
using TakeoutSaaS.Module.Tenancy.Extensions;
using TakeoutSaaS.Shared.Web.Extensions;
using TakeoutSaaS.Shared.Web.Swagger;
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}";
builder.Configuration
.AddJsonFile("appsettings.Seed.json", optional: true, reloadOnChange: true)
.AddJsonFile($"appsettings.Seed.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true);
builder.Host.UseSerilog((context, _, configuration) =>
{
configuration
.Enrich.FromLogContext()
.Enrich.WithProperty("Service", "AdminApi")
.WriteTo.Console(outputTemplate: logTemplate)
.WriteTo.File(
"logs/admin-api-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 7,
shared: true,
outputTemplate: logTemplate);
});
builder.Services.AddSharedWebCore();
builder.Services.AddSharedSwagger(options =>
{
options.Title = "外卖SaaS - 管理后台";
options.Description = "管理后台 API 文档";
options.EnableAuthorization = true;
});
builder.Services.AddIdentityApplication();
builder.Services.AddIdentityInfrastructure(builder.Configuration, enableAdminSeed: true);
builder.Services.AddAppInfrastructure(builder.Configuration);
builder.Services.AddAppApplication();
builder.Services.AddJwtAuthentication(builder.Configuration);
builder.Services.AddAuthorization();
builder.Services.AddPermissionAuthorization();
builder.Services.AddTenantResolution(builder.Configuration);
builder.Services.AddDictionaryModule(builder.Configuration);
builder.Services.AddStorageModule(builder.Configuration);
builder.Services.AddStorageApplication();
builder.Services.AddSmsModule(builder.Configuration);
builder.Services.AddSmsApplication(builder.Configuration);
builder.Services.AddMessagingModule(builder.Configuration);
builder.Services.AddMessagingApplication();
builder.Services.AddSchedulerModule(builder.Configuration);
builder.Services.AddHealthChecks();
var otelSection = builder.Configuration.GetSection("Otel");
var otelEndpoint = otelSection.GetValue<string>("Endpoint");
var useConsoleExporter = otelSection.GetValue<bool?>("UseConsoleExporter") ?? builder.Environment.IsDevelopment();
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource.AddService(
serviceName: "TakeoutSaaS.AdminApi",
serviceVersion: "1.0.0",
serviceInstanceId: Environment.MachineName))
.WithTracing(tracing =>
{
tracing
.SetSampler(new ParentBasedSampler(new AlwaysOnSampler()))
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation();
if (!string.IsNullOrWhiteSpace(otelEndpoint))
{
tracing.AddOtlpExporter(exporter =>
{
exporter.Endpoint = new Uri(otelEndpoint);
});
}
if (useConsoleExporter)
{
tracing.AddConsoleExporter();
}
})
.WithMetrics(metrics =>
{
metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddPrometheusExporter();
if (!string.IsNullOrWhiteSpace(otelEndpoint))
{
metrics.AddOtlpExporter(exporter =>
{
exporter.Endpoint = new Uri(otelEndpoint);
});
}
if (useConsoleExporter)
{
metrics.AddConsoleExporter();
}
});
var adminOrigins = ResolveCorsOrigins(builder.Configuration, "Cors:Admin");
builder.Services.AddCors(options =>
{
options.AddPolicy("AdminApiCors", policy =>
{
ConfigureCorsPolicy(policy, adminOrigins);
});
});
var app = builder.Build();
app.UseCors("AdminApiCors");
app.UseTenantResolution();
app.UseSharedWebCore();
app.UseAuthentication();
app.UseAuthorization();
app.UseSharedSwagger();
app.UseSchedulerDashboard(builder.Configuration);
app.MapHealthChecks("/healthz");
app.MapPrometheusScrapingEndpoint();
app.MapControllers();
app.Run();
static string[] ResolveCorsOrigins(IConfiguration configuration, string sectionKey)
{
var origins = configuration.GetSection(sectionKey).Get<string[]>();
return origins?
.Where(origin => !string.IsNullOrWhiteSpace(origin))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray() ?? [];
}
static void ConfigureCorsPolicy(CorsPolicyBuilder policy, string[] origins)
{
if (origins.Length == 0)
{
policy.AllowAnyOrigin();
}
else
{
policy.WithOrigins(origins)
.AllowCredentials();
}
policy
.AllowAnyHeader()
.AllowAnyMethod();
}

View File

@@ -0,0 +1,12 @@
{
"profiles": {
"TakeoutSaaS.AdminApi": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:2676;http://localhost:2680"
}
}
}

View File

@@ -0,0 +1,176 @@
{
"ConnectionStrings": {
"Redis": "49.232.6.45:6379,password=MsuMshk112233,abortConnect=false"
},
"Database": {
"DataSources": {
"AppDatabase": {
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
"Reads": [
"Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
],
"CommandTimeoutSeconds": 30,
"MaxRetryCount": 3,
"MaxRetryDelaySeconds": 5
},
"IdentityDatabase": {
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_identity_db;Username=identity_user;Password=IdentityUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
"Reads": [
"Host=120.53.222.17;Port=5432;Database=takeout_identity_db;Username=identity_user;Password=IdentityUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
],
"CommandTimeoutSeconds": 30,
"MaxRetryCount": 3,
"MaxRetryDelaySeconds": 5
},
"DictionaryDatabase": {
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_dictionary_db;Username=dictionary_user;Password=DictionaryUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
"Reads": [
"Host=120.53.222.17;Port=5432;Database=takeout_dictionary_db;Username=dictionary_user;Password=DictionaryUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
],
"CommandTimeoutSeconds": 30,
"MaxRetryCount": 3,
"MaxRetryDelaySeconds": 5
}
}
},
"Identity": {
"Jwt": {
"Issuer": "takeout-saas",
"Audience": "takeout-saas-clients",
"Secret": "psZEx_O##]Mq(W.1$?8Aia*LM03sXGGx",
"AccessTokenExpirationMinutes": 120,
"RefreshTokenExpirationMinutes": 10080
},
"LoginRateLimit": {
"WindowSeconds": 60,
"MaxAttempts": 5
},
"RefreshTokenStore": {
"Prefix": "identity:refresh:"
},
"AdminSeed": {
"Users": []
}
},
"Dictionary": {
"Cache": {
"SlidingExpiration": "00:30:00"
}
},
"Tenancy": {
"TenantIdHeaderName": "X-Tenant-Id",
"TenantCodeHeaderName": "X-Tenant-Code",
"IgnoredPaths": [
"/health"
],
"RootDomain": ""
},
"Storage": {
"Provider": "TencentCos",
"CdnBaseUrl": "https://saas2025-1388556178.cos.ap-beijing.myqcloud.com",
"TencentCos": {
"SecretId": "AKID52mHageV8ZnnY5NRL3Xq270fAcw2vb5R",
"SecretKey": "B8sPitsiEXcS4ScaMvGMErFOL3ZqsgFa",
"Region": "ap-beijing",
"Bucket": "saas2025-1388556178",
"Endpoint": "https://saas2025-1388556178.cos.ap-beijing.myqcloud.com",
"CdnBaseUrl": "https://saas2025-1388556178.cos.ap-beijing.myqcloud.com",
"UseHttps": true,
"ForcePathStyle": false
},
"QiniuKodo": {
"AccessKey": "QINIU_ACCESS_KEY",
"SecretKey": "QINIU_SECRET_KEY",
"Bucket": "takeout-files",
"DownloadDomain": "",
"Endpoint": "",
"UseHttps": true,
"SignedUrlExpirationMinutes": 30
},
"AliyunOss": {
"AccessKeyId": "OSS_ACCESS_KEY_ID",
"AccessKeySecret": "OSS_ACCESS_KEY_SECRET",
"Endpoint": "https://oss-cn-hangzhou.aliyuncs.com",
"Bucket": "takeout-files",
"CdnBaseUrl": "",
"UseHttps": true
},
"Security": {
"MaxFileSizeBytes": 10485760,
"AllowedImageExtensions": [
".jpg",
".jpeg",
".png",
".webp",
".gif"
],
"AllowedFileExtensions": [
".jpg",
".jpeg",
".png",
".webp",
".gif",
".pdf"
],
"DefaultUrlExpirationMinutes": 30,
"EnableRefererValidation": true,
"AllowedReferers": [
"https://admin.example.com",
"https://miniapp.example.com"
],
"AntiLeechTokenSecret": "ReplaceWithARandomToken"
}
},
"Sms": {
"Provider": "Tencent",
"DefaultSignName": "外卖SaaS",
"UseMock": true,
"Tencent": {
"SecretId": "TENCENT_SMS_SECRET_ID",
"SecretKey": "TENCENT_SMS_SECRET_KEY",
"SdkAppId": "1400000000",
"SignName": "外卖SaaS",
"Region": "ap-beijing",
"Endpoint": "https://sms.tencentcloudapi.com"
},
"Aliyun": {
"AccessKeyId": "ALIYUN_SMS_AK",
"AccessKeySecret": "ALIYUN_SMS_SK",
"Endpoint": "dysmsapi.aliyuncs.com",
"SignName": "外卖SaaS",
"Region": "cn-hangzhou"
},
"SceneTemplates": {
"login": "LOGIN_TEMPLATE_ID",
"register": "REGISTER_TEMPLATE_ID",
"reset": "RESET_TEMPLATE_ID"
},
"VerificationCode": {
"CodeLength": 6,
"ExpireMinutes": 5,
"CooldownSeconds": 60,
"CachePrefix": "sms:code"
}
},
"RabbitMQ": {
"Host": "49.232.6.45",
"Port": 5672,
"Username": "Admin",
"Password": "MsuMshk112233",
"VirtualHost": "/",
"Exchange": "takeout.events",
"ExchangeType": "topic",
"PrefetchCount": 20
},
"Scheduler": {
"ConnectionString": "Host=120.53.222.17;Port=5432;Database=takeout_hangfire_db;Username=hangfire_user;Password=HangFire112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
"WorkerCount": 5,
"DashboardEnabled": false,
"DashboardPath": "/hangfire"
},
"Otel": {
"Endpoint": "",
"Sampling": "ParentBasedAlwaysOn",
"UseConsoleExporter": true
}
}

View File

@@ -0,0 +1,176 @@
{
"ConnectionStrings": {
"Redis": "49.232.6.45:6379,password=MsuMshk112233,abortConnect=false"
},
"Database": {
"DataSources": {
"AppDatabase": {
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
"Reads": [
"Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
],
"CommandTimeoutSeconds": 30,
"MaxRetryCount": 3,
"MaxRetryDelaySeconds": 5
},
"IdentityDatabase": {
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_identity_db;Username=identity_user;Password=IdentityUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
"Reads": [
"Host=120.53.222.17;Port=5432;Database=takeout_identity_db;Username=identity_user;Password=IdentityUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
],
"CommandTimeoutSeconds": 30,
"MaxRetryCount": 3,
"MaxRetryDelaySeconds": 5
},
"DictionaryDatabase": {
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_dictionary_db;Username=dictionary_user;Password=DictionaryUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
"Reads": [
"Host=120.53.222.17;Port=5432;Database=takeout_dictionary_db;Username=dictionary_user;Password=DictionaryUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
],
"CommandTimeoutSeconds": 30,
"MaxRetryCount": 3,
"MaxRetryDelaySeconds": 5
}
}
},
"Identity": {
"Jwt": {
"Issuer": "takeout-saas",
"Audience": "takeout-saas-clients",
"Secret": "psZEx_O##]Mq(W.1$?8Aia*LM03sXGGx",
"AccessTokenExpirationMinutes": 120,
"RefreshTokenExpirationMinutes": 10080
},
"LoginRateLimit": {
"WindowSeconds": 60,
"MaxAttempts": 5
},
"RefreshTokenStore": {
"Prefix": "identity:refresh:"
},
"AdminSeed": {
"Users": []
}
},
"Dictionary": {
"Cache": {
"SlidingExpiration": "00:30:00"
}
},
"Tenancy": {
"TenantIdHeaderName": "X-Tenant-Id",
"TenantCodeHeaderName": "X-Tenant-Code",
"IgnoredPaths": [
"/health"
],
"RootDomain": ""
},
"Storage": {
"Provider": "TencentCos",
"CdnBaseUrl": "https://saas2025-1388556178.cos.ap-beijing.myqcloud.com",
"TencentCos": {
"SecretId": "AKID52mHageV8ZnnY5NRL3Xq270fAcw2vb5R",
"SecretKey": "B8sPitsiEXcS4ScaMvGMErFOL3ZqsgFa",
"Region": "ap-beijing",
"Bucket": "saas2025-1388556178",
"Endpoint": "https://saas2025-1388556178.cos.ap-beijing.myqcloud.com",
"CdnBaseUrl": "https://saas2025-1388556178.cos.ap-beijing.myqcloud.com",
"UseHttps": true,
"ForcePathStyle": false
},
"QiniuKodo": {
"AccessKey": "QINIU_ACCESS_KEY",
"SecretKey": "QINIU_SECRET_KEY",
"Bucket": "takeout-files",
"DownloadDomain": "",
"Endpoint": "",
"UseHttps": true,
"SignedUrlExpirationMinutes": 30
},
"AliyunOss": {
"AccessKeyId": "OSS_ACCESS_KEY_ID",
"AccessKeySecret": "OSS_ACCESS_KEY_SECRET",
"Endpoint": "https://oss-cn-hangzhou.aliyuncs.com",
"Bucket": "takeout-files",
"CdnBaseUrl": "",
"UseHttps": true
},
"Security": {
"MaxFileSizeBytes": 10485760,
"AllowedImageExtensions": [
".jpg",
".jpeg",
".png",
".webp",
".gif"
],
"AllowedFileExtensions": [
".jpg",
".jpeg",
".png",
".webp",
".gif",
".pdf"
],
"DefaultUrlExpirationMinutes": 30,
"EnableRefererValidation": true,
"AllowedReferers": [
"https://admin.example.com",
"https://miniapp.example.com"
],
"AntiLeechTokenSecret": "ReplaceWithARandomToken"
}
},
"Sms": {
"Provider": "Tencent",
"DefaultSignName": "外卖SaaS",
"UseMock": true,
"Tencent": {
"SecretId": "TENCENT_SMS_SECRET_ID",
"SecretKey": "TENCENT_SMS_SECRET_KEY",
"SdkAppId": "1400000000",
"SignName": "外卖SaaS",
"Region": "ap-beijing",
"Endpoint": "https://sms.tencentcloudapi.com"
},
"Aliyun": {
"AccessKeyId": "ALIYUN_SMS_AK",
"AccessKeySecret": "ALIYUN_SMS_SK",
"Endpoint": "dysmsapi.aliyuncs.com",
"SignName": "外卖SaaS",
"Region": "cn-hangzhou"
},
"SceneTemplates": {
"login": "LOGIN_TEMPLATE_ID",
"register": "REGISTER_TEMPLATE_ID",
"reset": "RESET_TEMPLATE_ID"
},
"VerificationCode": {
"CodeLength": 6,
"ExpireMinutes": 5,
"CooldownSeconds": 60,
"CachePrefix": "sms:code"
}
},
"RabbitMQ": {
"Host": "49.232.6.45",
"Port": 5672,
"Username": "Admin",
"Password": "MsuMshk112233",
"VirtualHost": "/",
"Exchange": "takeout.events",
"ExchangeType": "topic",
"PrefetchCount": 20
},
"Scheduler": {
"ConnectionString": "Host=120.53.222.17;Port=5432;Database=takeout_hangfire_db;Username=hangfire_user;Password=HangFire112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
"WorkerCount": 5,
"DashboardEnabled": false,
"DashboardPath": "/hangfire"
},
"Otel": {
"Endpoint": "",
"Sampling": "ParentBasedAlwaysOn",
"UseConsoleExporter": true
}
}

View File

@@ -0,0 +1,54 @@
{
"App": {
"Seed": {
"Enabled": true,
"DefaultTenant": {
"TenantId": 1000000000001,
"Code": "demo",
"Name": "Demo租户",
"ShortName": "Demo",
"ContactName": "DemoAdmin",
"ContactPhone": "13800000000"
},
"DictionaryGroups": [
{
"Code": "order_status",
"Name": "订单状态",
"Scope": "Business",
"Items": [
{ "Key": "pending", "Value": "待支付", "SortOrder": 10 },
{ "Key": "paid", "Value": "已支付", "SortOrder": 20 },
{ "Key": "finished", "Value": "已完成", "SortOrder": 30 }
]
},
{
"Code": "store_tags",
"Name": "门店标签",
"Scope": "Business",
"Items": [
{ "Key": "hot", "Value": "热门", "SortOrder": 10 },
{ "Key": "new", "Value": "新店", "SortOrder": 20 }
]
}
],
"SystemParameters": [
{ "Key": "site_name", "Value": "外卖SaaS Demo", "Description": "演示环境站点名称", "SortOrder": 10, "IsEnabled": true },
{ "Key": "order_auto_cancel_minutes", "Value": "30", "Description": "待支付自动取消时间(分钟)", "SortOrder": 20, "IsEnabled": true }
]
}
},
"Identity": {
"AdminSeed": {
"Users": [
{
"Account": "admin",
"DisplayName": "平台管理员",
"Password": "Admin@123456",
"TenantId": 1000000000001,
"Roles": [ "PlatformAdmin" ],
"Permissions": [ "merchant:*", "store:*", "product:*", "order:*", "payment:*", "delivery:*" ]
}
]
}
}
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.Identity.Abstractions;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
namespace TakeoutSaaS.MiniApi.Controllers;
/// <summary>
/// 小程序登录认证
/// </summary>
/// <remarks>
/// 小程序登录认证
/// </remarks>
/// <param name="authService"></param>
[ApiVersion("1.0")]
[Authorize]
[Route("api/mini/v{version:apiVersion}/auth")]
public sealed class AuthController(IMiniAuthService authService) : BaseApiController
{
/// <summary>
/// 微信登录
/// </summary>
[HttpPost("wechat/login")]
[AllowAnonymous]
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<TokenResponse>> LoginWithWeChat([FromBody] WeChatLoginRequest request, CancellationToken cancellationToken)
{
var response = await authService.LoginWithWeChatAsync(request, cancellationToken);
return ApiResponse<TokenResponse>.Ok(response);
}
/// <summary>
/// 刷新 Token
/// </summary>
[HttpPost("refresh")]
[AllowAnonymous]
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<TokenResponse>> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
{
var response = await authService.RefreshTokenAsync(request, cancellationToken);
return ApiResponse<TokenResponse>.Ok(response);
}
}

View File

@@ -0,0 +1,52 @@
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;
using TakeoutSaaS.Application.Storage.Extensions;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
namespace TakeoutSaaS.MiniApi.Controllers;
/// <summary>
/// 小程序文件上传。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/mini/v{version:apiVersion}/files")]
public sealed class FilesController(IFileStorageService fileStorageService) : BaseApiController
{
private readonly IFileStorageService _fileStorageService = fileStorageService;
/// <summary>
/// 上传图片或文件。
/// </summary>
[HttpPost("upload")]
[RequestFormLimits(MultipartBodyLengthLimit = 30 * 1024 * 1024)]
[ProducesResponseType(typeof(ApiResponse<FileUploadResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<FileUploadResponse>), StatusCodes.Status400BadRequest)]
public async Task<ApiResponse<FileUploadResponse>> Upload([FromForm] IFormFile? file, [FromForm] string? type, CancellationToken cancellationToken)
{
if (file == null || file.Length == 0)
{
return ApiResponse<FileUploadResponse>.Error(ErrorCodes.BadRequest, "文件不能为空");
}
if (!UploadFileTypeParser.TryParse(type, out var uploadType))
{
return ApiResponse<FileUploadResponse>.Error(ErrorCodes.BadRequest, "上传类型不合法");
}
var origin = Request.Headers["Origin"].FirstOrDefault() ?? Request.Headers["Referer"].FirstOrDefault();
await using var stream = file.OpenReadStream();
var result = await _fileStorageService.UploadAsync(
new UploadFileRequest(uploadType, stream, file.FileName, file.ContentType ?? string.Empty, file.Length, origin),
cancellationToken);
return ApiResponse<FileUploadResponse>.Ok(result);
}
}

View File

@@ -0,0 +1,29 @@
using System;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
namespace TakeoutSaaS.MiniApi.Controllers;
/// <summary>
/// 小程序端 - 健康检查。
/// </summary>
[ApiVersion("1.0")]
[AllowAnonymous]
[Route("api/mini/v{version:apiVersion}/[controller]")]
public class HealthController : BaseApiController
{
/// <summary>
/// 获取服务健康状态。
/// </summary>
/// <returns>健康状态</returns>
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public ApiResponse<object> Get()
{
var payload = new { status = "OK", service = "MiniApi", time = DateTime.UtcNow };
return ApiResponse<object>.Ok(payload);
}
}

View File

@@ -0,0 +1,47 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.Identity.Abstractions;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
using TakeoutSaaS.Shared.Web.Security;
namespace TakeoutSaaS.MiniApi.Controllers;
/// <summary>
/// 当前用户信息
/// </summary>
/// <remarks>
///
/// </remarks>
/// <param name="authService"></param>
[ApiVersion("1.0")]
[Authorize]
[Route("api/mini/v{version:apiVersion}/me")]
public sealed class MeController(IMiniAuthService authService) : BaseApiController
{
/// <summary>
/// 获取用户档案
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<CurrentUserProfile>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<CurrentUserProfile>), StatusCodes.Status401Unauthorized)]
public async Task<ApiResponse<CurrentUserProfile>> Get(CancellationToken cancellationToken)
{
var userId = User.GetUserId();
if (userId == 0)
{
return ApiResponse<CurrentUserProfile>.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识");
}
var profile = await authService.GetProfileAsync(userId, cancellationToken);
return ApiResponse<CurrentUserProfile>.Ok(profile);
}
}

View File

@@ -0,0 +1,12 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj
RUN dotnet publish src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 7701
ENV ASPNETCORE_URLS=http://+:7701
ENTRYPOINT ["dotnet", "TakeoutSaaS.MiniApi.dll"]

View File

@@ -0,0 +1,147 @@
using System;
using System.Linq;
using Microsoft.AspNetCore.Cors.Infrastructure;
using Microsoft.Extensions.Configuration;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using Serilog;
using TakeoutSaaS.Application.Messaging.Extensions;
using TakeoutSaaS.Application.Sms.Extensions;
using TakeoutSaaS.Application.Storage.Extensions;
using TakeoutSaaS.Module.Messaging.Extensions;
using TakeoutSaaS.Module.Sms.Extensions;
using TakeoutSaaS.Module.Storage.Extensions;
using TakeoutSaaS.Module.Tenancy.Extensions;
using TakeoutSaaS.Shared.Web.Extensions;
using TakeoutSaaS.Shared.Web.Swagger;
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}";
builder.Host.UseSerilog((_, _, configuration) =>
{
configuration
.Enrich.FromLogContext()
.Enrich.WithProperty("Service", "MiniApi")
.WriteTo.Console(outputTemplate: logTemplate)
.WriteTo.File(
"logs/mini-api-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 7,
shared: true,
outputTemplate: logTemplate);
});
builder.Services.AddSharedWebCore();
builder.Services.AddSharedSwagger(options =>
{
options.Title = "外卖SaaS - 小程序端";
options.Description = "小程序 API 文档";
options.EnableAuthorization = true;
});
builder.Services.AddTenantResolution(builder.Configuration);
builder.Services.AddStorageModule(builder.Configuration);
builder.Services.AddStorageApplication();
builder.Services.AddSmsModule(builder.Configuration);
builder.Services.AddSmsApplication(builder.Configuration);
builder.Services.AddMessagingModule(builder.Configuration);
builder.Services.AddMessagingApplication();
builder.Services.AddHealthChecks();
var otelSection = builder.Configuration.GetSection("Otel");
var otelEndpoint = otelSection.GetValue<string>("Endpoint");
var useConsoleExporter = otelSection.GetValue<bool?>("UseConsoleExporter") ?? builder.Environment.IsDevelopment();
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource.AddService(
serviceName: "TakeoutSaaS.MiniApi",
serviceVersion: "1.0.0",
serviceInstanceId: Environment.MachineName))
.WithTracing(tracing =>
{
tracing
.SetSampler(new ParentBasedSampler(new AlwaysOnSampler()))
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation();
if (!string.IsNullOrWhiteSpace(otelEndpoint))
{
tracing.AddOtlpExporter(exporter =>
{
exporter.Endpoint = new Uri(otelEndpoint);
});
}
if (useConsoleExporter)
{
tracing.AddConsoleExporter();
}
})
.WithMetrics(metrics =>
{
metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddPrometheusExporter();
if (!string.IsNullOrWhiteSpace(otelEndpoint))
{
metrics.AddOtlpExporter(exporter =>
{
exporter.Endpoint = new Uri(otelEndpoint);
});
}
if (useConsoleExporter)
{
metrics.AddConsoleExporter();
}
});
var miniOrigins = ResolveCorsOrigins(builder.Configuration, "Cors:Mini");
builder.Services.AddCors(options =>
{
options.AddPolicy("MiniApiCors", policy =>
{
ConfigureCorsPolicy(policy, miniOrigins);
});
});
var app = builder.Build();
app.UseCors("MiniApiCors");
app.UseTenantResolution();
app.UseSharedWebCore();
app.UseSharedSwagger();
app.MapHealthChecks("/healthz");
app.MapPrometheusScrapingEndpoint();
app.MapControllers();
app.Run();
static string[] ResolveCorsOrigins(IConfiguration configuration, string sectionKey)
{
var origins = configuration.GetSection(sectionKey).Get<string[]>();
return origins?
.Where(origin => !string.IsNullOrWhiteSpace(origin))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray() ?? [];
}
static void ConfigureCorsPolicy(CorsPolicyBuilder policy, string[] origins)
{
if (origins.Length == 0)
{
policy.AllowAnyOrigin();
}
else
{
policy.WithOrigins(origins)
.AllowCredentials();
}
policy
.AllowAnyHeader()
.AllowAnyMethod();
}

View File

@@ -0,0 +1,12 @@
{
"profiles": {
"TakeoutSaaS.MiniApi": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:2678;http://localhost:2681"
}
}
}

Binary file not shown.

View File

@@ -0,0 +1,165 @@
{
"Database": {
"DataSources": {
"AppDatabase": {
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
"Reads": [
"Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
],
"CommandTimeoutSeconds": 30,
"MaxRetryCount": 3,
"MaxRetryDelaySeconds": 5
},
"IdentityDatabase": {
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_identity_db;Username=identity_user;Password=IdentityUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
"Reads": [
"Host=120.53.222.17;Port=5432;Database=takeout_identity_db;Username=identity_user;Password=IdentityUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
],
"CommandTimeoutSeconds": 30,
"MaxRetryCount": 3,
"MaxRetryDelaySeconds": 5
},
"DictionaryDatabase": {
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_dictionary_db;Username=dictionary_user;Password=DictionaryUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
"Reads": [
"Host=120.53.222.17;Port=5432;Database=takeout_dictionary_db;Username=dictionary_user;Password=DictionaryUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
],
"CommandTimeoutSeconds": 30,
"MaxRetryCount": 3,
"MaxRetryDelaySeconds": 5
}
}
},
"Redis": "49.232.6.45:6379,password=MsuMshk112233,abortConnect=false",
"Identity": {
"Jwt": {
"Issuer": "takeout-saas",
"Audience": "takeout-saas-clients",
"Secret": "psZEx_O##]Mq(W.1$?8Aia*LM03sXGGx",
"AccessTokenExpirationMinutes": 120,
"RefreshTokenExpirationMinutes": 10080
},
"LoginRateLimit": {
"WindowSeconds": 60,
"MaxAttempts": 5
},
"RefreshTokenStore": {
"Prefix": "identity:refresh:"
}
},
"Dictionary": {
"Cache": {
"SlidingExpiration": "00:30:00"
}
},
"Tenancy": {
"TenantIdHeaderName": "X-Tenant-Id",
"TenantCodeHeaderName": "X-Tenant-Code",
"IgnoredPaths": [
"/health"
],
"RootDomain": ""
},
"Storage": {
"Provider": "TencentCos",
"CdnBaseUrl": "https://saas2025-1388556178.cos.ap-beijing.myqcloud.com",
"TencentCos": {
"SecretId": "AKID52mHageV8ZnnY5NRL3Xq270fAcw2vb5R",
"SecretKey": "B8sPitsiEXcS4ScaMvGMErFOL3ZqsgFa",
"Region": "ap-beijing",
"Bucket": "saas2025-1388556178",
"Endpoint": "https://saas2025-1388556178.cos.ap-beijing.myqcloud.com",
"CdnBaseUrl": "https://saas2025-1388556178.cos.ap-beijing.myqcloud.com",
"UseHttps": true,
"ForcePathStyle": false
},
"QiniuKodo": {
"AccessKey": "QINIU_ACCESS_KEY",
"SecretKey": "QINIU_SECRET_KEY",
"Bucket": "takeout-files",
"DownloadDomain": "",
"Endpoint": "",
"UseHttps": true,
"SignedUrlExpirationMinutes": 30
},
"AliyunOss": {
"AccessKeyId": "OSS_ACCESS_KEY_ID",
"AccessKeySecret": "OSS_ACCESS_KEY_SECRET",
"Endpoint": "https://oss-cn-hangzhou.aliyuncs.com",
"Bucket": "takeout-files",
"CdnBaseUrl": "",
"UseHttps": true
},
"Security": {
"MaxFileSizeBytes": 10485760,
"AllowedImageExtensions": [
".jpg",
".jpeg",
".png",
".webp",
".gif"
],
"AllowedFileExtensions": [
".jpg",
".jpeg",
".png",
".webp",
".gif",
".pdf"
],
"DefaultUrlExpirationMinutes": 30,
"EnableRefererValidation": true,
"AllowedReferers": [
"https://admin.example.com",
"https://miniapp.example.com"
],
"AntiLeechTokenSecret": "ReplaceWithARandomToken"
}
},
"Sms": {
"Provider": "Tencent",
"DefaultSignName": "外卖SaaS",
"UseMock": true,
"Tencent": {
"SecretId": "TENCENT_SMS_SECRET_ID",
"SecretKey": "TENCENT_SMS_SECRET_KEY",
"SdkAppId": "1400000000",
"SignName": "外卖SaaS",
"Region": "ap-beijing",
"Endpoint": "https://sms.tencentcloudapi.com"
},
"Aliyun": {
"AccessKeyId": "ALIYUN_SMS_AK",
"AccessKeySecret": "ALIYUN_SMS_SK",
"Endpoint": "dysmsapi.aliyuncs.com",
"SignName": "外卖SaaS",
"Region": "cn-hangzhou"
},
"SceneTemplates": {
"login": "LOGIN_TEMPLATE_ID",
"register": "REGISTER_TEMPLATE_ID",
"reset": "RESET_TEMPLATE_ID"
},
"VerificationCode": {
"CodeLength": 6,
"ExpireMinutes": 5,
"CooldownSeconds": 60,
"CachePrefix": "sms:code"
}
},
"RabbitMQ": {
"Host": "49.232.6.45",
"Port": 5672,
"Username": "Admin",
"Password": "MsuMshk112233",
"VirtualHost": "/",
"Exchange": "takeout.events",
"ExchangeType": "topic",
"PrefetchCount": 20
},
"Otel": {
"Endpoint": "",
"Sampling": "ParentBasedAlwaysOn",
"UseConsoleExporter": true
}
}

View File

@@ -0,0 +1,165 @@
{
"Database": {
"DataSources": {
"AppDatabase": {
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
"Reads": [
"Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
],
"CommandTimeoutSeconds": 30,
"MaxRetryCount": 3,
"MaxRetryDelaySeconds": 5
},
"IdentityDatabase": {
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_identity_db;Username=identity_user;Password=IdentityUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
"Reads": [
"Host=120.53.222.17;Port=5432;Database=takeout_identity_db;Username=identity_user;Password=IdentityUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
],
"CommandTimeoutSeconds": 30,
"MaxRetryCount": 3,
"MaxRetryDelaySeconds": 5
},
"DictionaryDatabase": {
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_dictionary_db;Username=dictionary_user;Password=DictionaryUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
"Reads": [
"Host=120.53.222.17;Port=5432;Database=takeout_dictionary_db;Username=dictionary_user;Password=DictionaryUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
],
"CommandTimeoutSeconds": 30,
"MaxRetryCount": 3,
"MaxRetryDelaySeconds": 5
}
}
},
"Redis": "49.232.6.45:6379,password=MsuMshk112233,abortConnect=false",
"Identity": {
"Jwt": {
"Issuer": "takeout-saas",
"Audience": "takeout-saas-clients",
"Secret": "psZEx_O##]Mq(W.1$?8Aia*LM03sXGGx",
"AccessTokenExpirationMinutes": 120,
"RefreshTokenExpirationMinutes": 10080
},
"LoginRateLimit": {
"WindowSeconds": 60,
"MaxAttempts": 5
},
"RefreshTokenStore": {
"Prefix": "identity:refresh:"
}
},
"Dictionary": {
"Cache": {
"SlidingExpiration": "00:30:00"
}
},
"Tenancy": {
"TenantIdHeaderName": "X-Tenant-Id",
"TenantCodeHeaderName": "X-Tenant-Code",
"IgnoredPaths": [
"/health"
],
"RootDomain": ""
},
"Storage": {
"Provider": "TencentCos",
"CdnBaseUrl": "https://saas2025-1388556178.cos.ap-beijing.myqcloud.com",
"TencentCos": {
"SecretId": "AKID52mHageV8ZnnY5NRL3Xq270fAcw2vb5R",
"SecretKey": "B8sPitsiEXcS4ScaMvGMErFOL3ZqsgFa",
"Region": "ap-beijing",
"Bucket": "saas2025-1388556178",
"Endpoint": "https://saas2025-1388556178.cos.ap-beijing.myqcloud.com",
"CdnBaseUrl": "https://saas2025-1388556178.cos.ap-beijing.myqcloud.com",
"UseHttps": true,
"ForcePathStyle": false
},
"QiniuKodo": {
"AccessKey": "QINIU_ACCESS_KEY",
"SecretKey": "QINIU_SECRET_KEY",
"Bucket": "takeout-files",
"DownloadDomain": "",
"Endpoint": "",
"UseHttps": true,
"SignedUrlExpirationMinutes": 30
},
"AliyunOss": {
"AccessKeyId": "OSS_ACCESS_KEY_ID",
"AccessKeySecret": "OSS_ACCESS_KEY_SECRET",
"Endpoint": "https://oss-cn-hangzhou.aliyuncs.com",
"Bucket": "takeout-files",
"CdnBaseUrl": "",
"UseHttps": true
},
"Security": {
"MaxFileSizeBytes": 10485760,
"AllowedImageExtensions": [
".jpg",
".jpeg",
".png",
".webp",
".gif"
],
"AllowedFileExtensions": [
".jpg",
".jpeg",
".png",
".webp",
".gif",
".pdf"
],
"DefaultUrlExpirationMinutes": 30,
"EnableRefererValidation": true,
"AllowedReferers": [
"https://admin.example.com",
"https://miniapp.example.com"
],
"AntiLeechTokenSecret": "ReplaceWithARandomToken"
}
},
"Sms": {
"Provider": "Tencent",
"DefaultSignName": "外卖SaaS",
"UseMock": true,
"Tencent": {
"SecretId": "TENCENT_SMS_SECRET_ID",
"SecretKey": "TENCENT_SMS_SECRET_KEY",
"SdkAppId": "1400000000",
"SignName": "外卖SaaS",
"Region": "ap-beijing",
"Endpoint": "https://sms.tencentcloudapi.com"
},
"Aliyun": {
"AccessKeyId": "ALIYUN_SMS_AK",
"AccessKeySecret": "ALIYUN_SMS_SK",
"Endpoint": "dysmsapi.aliyuncs.com",
"SignName": "外卖SaaS",
"Region": "cn-hangzhou"
},
"SceneTemplates": {
"login": "LOGIN_TEMPLATE_ID",
"register": "REGISTER_TEMPLATE_ID",
"reset": "RESET_TEMPLATE_ID"
},
"VerificationCode": {
"CodeLength": 6,
"ExpireMinutes": 5,
"CooldownSeconds": 60,
"CachePrefix": "sms:code"
}
},
"RabbitMQ": {
"Host": "49.232.6.45",
"Port": 5672,
"Username": "Admin",
"Password": "MsuMshk112233",
"VirtualHost": "/",
"Exchange": "takeout.events",
"ExchangeType": "topic",
"PrefetchCount": 20
},
"Otel": {
"Endpoint": "",
"Sampling": "ParentBasedAlwaysOn",
"UseConsoleExporter": true
}
}

View File

@@ -0,0 +1,29 @@
using System;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
namespace TakeoutSaaS.UserApi.Controllers;
/// <summary>
/// 用户端 - 健康检查。
/// </summary>
[ApiVersion("1.0")]
[AllowAnonymous]
[Route("api/user/v{version:apiVersion}/[controller]")]
public class HealthController : BaseApiController
{
/// <summary>
/// 获取服务健康状态。
/// </summary>
/// <returns>健康状态</returns>
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public ApiResponse<object> Get()
{
var payload = new { status = "OK", service = "UserApi", time = DateTime.UtcNow };
return ApiResponse<object>.Ok(payload);
}
}

View File

@@ -0,0 +1,12 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore src/Api/TakeoutSaaS.UserApi/TakeoutSaaS.UserApi.csproj
RUN dotnet publish src/Api/TakeoutSaaS.UserApi/TakeoutSaaS.UserApi.csproj -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 7901
ENV ASPNETCORE_URLS=http://+:7901
ENTRYPOINT ["dotnet", "TakeoutSaaS.UserApi.dll"]

View File

@@ -0,0 +1,135 @@
using System;
using System.Linq;
using Microsoft.AspNetCore.Cors.Infrastructure;
using Microsoft.Extensions.Configuration;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using Serilog;
using TakeoutSaaS.Module.Tenancy.Extensions;
using TakeoutSaaS.Shared.Web.Extensions;
using TakeoutSaaS.Shared.Web.Swagger;
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}";
builder.Host.UseSerilog((_, _, configuration) =>
{
configuration
.Enrich.FromLogContext()
.Enrich.WithProperty("Service", "UserApi")
.WriteTo.Console(outputTemplate: logTemplate)
.WriteTo.File(
"logs/user-api-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 7,
shared: true,
outputTemplate: logTemplate);
});
builder.Services.AddSharedWebCore();
builder.Services.AddSharedSwagger(options =>
{
options.Title = "外卖SaaS - 用户端";
options.Description = "C 端用户 API 文档";
options.EnableAuthorization = true;
});
builder.Services.AddTenantResolution(builder.Configuration);
builder.Services.AddHealthChecks();
var otelSection = builder.Configuration.GetSection("Otel");
var otelEndpoint = otelSection.GetValue<string>("Endpoint");
var useConsoleExporter = otelSection.GetValue<bool?>("UseConsoleExporter") ?? builder.Environment.IsDevelopment();
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource.AddService(
serviceName: "TakeoutSaaS.UserApi",
serviceVersion: "1.0.0",
serviceInstanceId: Environment.MachineName))
.WithTracing(tracing =>
{
tracing
.SetSampler(new ParentBasedSampler(new AlwaysOnSampler()))
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation();
if (!string.IsNullOrWhiteSpace(otelEndpoint))
{
tracing.AddOtlpExporter(exporter =>
{
exporter.Endpoint = new Uri(otelEndpoint);
});
}
if (useConsoleExporter)
{
tracing.AddConsoleExporter();
}
})
.WithMetrics(metrics =>
{
metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddPrometheusExporter();
if (!string.IsNullOrWhiteSpace(otelEndpoint))
{
metrics.AddOtlpExporter(exporter =>
{
exporter.Endpoint = new Uri(otelEndpoint);
});
}
if (useConsoleExporter)
{
metrics.AddConsoleExporter();
}
});
var userOrigins = ResolveCorsOrigins(builder.Configuration, "Cors:User");
builder.Services.AddCors(options =>
{
options.AddPolicy("UserApiCors", policy =>
{
ConfigureCorsPolicy(policy, userOrigins);
});
});
var app = builder.Build();
app.UseCors("UserApiCors");
app.UseTenantResolution();
app.UseSharedWebCore();
app.UseSharedSwagger();
app.MapHealthChecks("/healthz");
app.MapPrometheusScrapingEndpoint();
app.MapControllers();
app.Run();
static string[] ResolveCorsOrigins(IConfiguration configuration, string sectionKey)
{
var origins = configuration.GetSection(sectionKey).Get<string[]>();
return origins?
.Where(origin => !string.IsNullOrWhiteSpace(origin))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray() ?? [];
}
static void ConfigureCorsPolicy(CorsPolicyBuilder policy, string[] origins)
{
if (origins.Length == 0)
{
policy.AllowAnyOrigin();
}
else
{
policy.WithOrigins(origins)
.AllowCredentials();
}
policy
.AllowAnyHeader()
.AllowAnyMethod();
}

View File

@@ -0,0 +1,12 @@
{
"profiles": {
"TakeoutSaaS.UserApi": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:2679;http://localhost:2682"
}
}
}

Binary file not shown.

View File

@@ -0,0 +1,68 @@
{
"Database": {
"DataSources": {
"AppDatabase": {
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
"Reads": [
"Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
],
"CommandTimeoutSeconds": 30,
"MaxRetryCount": 3,
"MaxRetryDelaySeconds": 5
},
"IdentityDatabase": {
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_identity_db;Username=identity_user;Password=IdentityUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
"Reads": [
"Host=120.53.222.17;Port=5432;Database=takeout_identity_db;Username=identity_user;Password=IdentityUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
],
"CommandTimeoutSeconds": 30,
"MaxRetryCount": 3,
"MaxRetryDelaySeconds": 5
},
"DictionaryDatabase": {
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_dictionary_db;Username=dictionary_user;Password=DictionaryUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
"Reads": [
"Host=120.53.222.17;Port=5432;Database=takeout_dictionary_db;Username=dictionary_user;Password=DictionaryUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
],
"CommandTimeoutSeconds": 30,
"MaxRetryCount": 3,
"MaxRetryDelaySeconds": 5
}
}
},
"Redis": "49.232.6.45:6379,password=MsuMshk112233,abortConnect=false",
"Identity": {
"Jwt": {
"Issuer": "takeout-saas",
"Audience": "takeout-saas-clients",
"Secret": "psZEx_O##]Mq(W.1$?8Aia*LM03sXGGx",
"AccessTokenExpirationMinutes": 120,
"RefreshTokenExpirationMinutes": 10080
},
"LoginRateLimit": {
"WindowSeconds": 60,
"MaxAttempts": 5
},
"RefreshTokenStore": {
"Prefix": "identity:refresh:"
}
},
"Dictionary": {
"Cache": {
"SlidingExpiration": "00:30:00"
}
},
"Tenancy": {
"TenantIdHeaderName": "X-Tenant-Id",
"TenantCodeHeaderName": "X-Tenant-Code",
"IgnoredPaths": [
"/health"
],
"RootDomain": ""
},
"Otel": {
"Endpoint": "",
"Sampling": "ParentBasedAlwaysOn",
"UseConsoleExporter": true
}
}

View File

@@ -0,0 +1,68 @@
{
"Database": {
"DataSources": {
"AppDatabase": {
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
"Reads": [
"Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
],
"CommandTimeoutSeconds": 30,
"MaxRetryCount": 3,
"MaxRetryDelaySeconds": 5
},
"IdentityDatabase": {
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_identity_db;Username=identity_user;Password=IdentityUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
"Reads": [
"Host=120.53.222.17;Port=5432;Database=takeout_identity_db;Username=identity_user;Password=IdentityUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
],
"CommandTimeoutSeconds": 30,
"MaxRetryCount": 3,
"MaxRetryDelaySeconds": 5
},
"DictionaryDatabase": {
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_dictionary_db;Username=dictionary_user;Password=DictionaryUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
"Reads": [
"Host=120.53.222.17;Port=5432;Database=takeout_dictionary_db;Username=dictionary_user;Password=DictionaryUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
],
"CommandTimeoutSeconds": 30,
"MaxRetryCount": 3,
"MaxRetryDelaySeconds": 5
}
}
},
"Redis": "49.232.6.45:6379,password=MsuMshk112233,abortConnect=false",
"Identity": {
"Jwt": {
"Issuer": "takeout-saas",
"Audience": "takeout-saas-clients",
"Secret": "psZEx_O##]Mq(W.1$?8Aia*LM03sXGGx",
"AccessTokenExpirationMinutes": 120,
"RefreshTokenExpirationMinutes": 10080
},
"LoginRateLimit": {
"WindowSeconds": 60,
"MaxAttempts": 5
},
"RefreshTokenStore": {
"Prefix": "identity:refresh:"
}
},
"Dictionary": {
"Cache": {
"SlidingExpiration": "00:30:00"
}
},
"Tenancy": {
"TenantIdHeaderName": "X-Tenant-Id",
"TenantCodeHeaderName": "X-Tenant-Code",
"IgnoredPaths": [
"/health"
],
"RootDomain": ""
},
"Otel": {
"Endpoint": "",
"Sampling": "ParentBasedAlwaysOn",
"UseConsoleExporter": true
}
}

View File

@@ -0,0 +1,35 @@
using FluentValidation;
using MediatR;
namespace TakeoutSaaS.Application.App.Common.Behaviors;
/// <summary>
/// MediatR 请求验证行为,统一触发 FluentValidation。
/// </summary>
/// <typeparam name="TRequest">请求类型。</typeparam>
/// <typeparam name="TResponse">响应类型。</typeparam>
public sealed class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators) : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull, IRequest<TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators = validators;
/// <summary>
/// 执行验证并在通过时继续后续处理。
/// </summary>
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
if (_validators.Any())
{
var context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll(_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
var failures = validationResults.SelectMany(r => r.Errors).Where(f => f is not null).ToList();
if (failures.Count > 0)
{
throw new ValidationException(failures);
}
}
return await next();
}
}

View File

@@ -0,0 +1,66 @@
using MediatR;
using TakeoutSaaS.Application.App.Deliveries.Dto;
using TakeoutSaaS.Domain.Deliveries.Enums;
namespace TakeoutSaaS.Application.App.Deliveries.Commands;
/// <summary>
/// 创建配送单命令。
/// </summary>
public sealed class CreateDeliveryOrderCommand : IRequest<DeliveryOrderDto>
{
/// <summary>
/// 订单 ID。
/// </summary>
public long OrderId { get; set; }
/// <summary>
/// 服务商。
/// </summary>
public DeliveryProvider Provider { get; set; } = DeliveryProvider.InHouse;
/// <summary>
/// 第三方单号。
/// </summary>
public string? ProviderOrderId { get; set; }
/// <summary>
/// 状态。
/// </summary>
public DeliveryStatus Status { get; set; } = DeliveryStatus.Pending;
/// <summary>
/// 配送费。
/// </summary>
public decimal? DeliveryFee { get; set; }
/// <summary>
/// 骑手姓名。
/// </summary>
public string? CourierName { get; set; }
/// <summary>
/// 骑手电话。
/// </summary>
public string? CourierPhone { get; set; }
/// <summary>
/// 下发时间。
/// </summary>
public DateTime? DispatchedAt { get; set; }
/// <summary>
/// 取餐时间。
/// </summary>
public DateTime? PickedUpAt { get; set; }
/// <summary>
/// 完成时间。
/// </summary>
public DateTime? DeliveredAt { get; set; }
/// <summary>
/// 异常原因。
/// </summary>
public string? FailureReason { get; set; }
}

View File

@@ -0,0 +1,14 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Deliveries.Commands;
/// <summary>
/// 删除配送单命令。
/// </summary>
public sealed class DeleteDeliveryOrderCommand : IRequest<bool>
{
/// <summary>
/// 配送单 ID。
/// </summary>
public long DeliveryOrderId { get; set; }
}

View File

@@ -0,0 +1,71 @@
using MediatR;
using TakeoutSaaS.Application.App.Deliveries.Dto;
using TakeoutSaaS.Domain.Deliveries.Enums;
namespace TakeoutSaaS.Application.App.Deliveries.Commands;
/// <summary>
/// 更新配送单命令。
/// </summary>
public sealed record UpdateDeliveryOrderCommand : IRequest<DeliveryOrderDto?>
{
/// <summary>
/// 配送单 ID。
/// </summary>
public long DeliveryOrderId { get; init; }
/// <summary>
/// 订单 ID。
/// </summary>
public long OrderId { get; init; }
/// <summary>
/// 服务商。
/// </summary>
public DeliveryProvider Provider { get; init; } = DeliveryProvider.InHouse;
/// <summary>
/// 第三方单号。
/// </summary>
public string? ProviderOrderId { get; init; }
/// <summary>
/// 状态。
/// </summary>
public DeliveryStatus Status { get; init; } = DeliveryStatus.Pending;
/// <summary>
/// 配送费。
/// </summary>
public decimal? DeliveryFee { get; init; }
/// <summary>
/// 骑手姓名。
/// </summary>
public string? CourierName { get; init; }
/// <summary>
/// 骑手电话。
/// </summary>
public string? CourierPhone { get; init; }
/// <summary>
/// 下发时间。
/// </summary>
public DateTime? DispatchedAt { get; init; }
/// <summary>
/// 取餐时间。
/// </summary>
public DateTime? PickedUpAt { get; init; }
/// <summary>
/// 完成时间。
/// </summary>
public DateTime? DeliveredAt { get; init; }
/// <summary>
/// 异常原因。
/// </summary>
public string? FailureReason { get; init; }
}

View File

@@ -0,0 +1,43 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Deliveries.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Deliveries.Dto;
/// <summary>
/// 配送事件 DTO。
/// </summary>
public sealed class DeliveryEventDto
{
/// <summary>
/// 事件 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 配送单 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long DeliveryOrderId { get; init; }
/// <summary>
/// 事件类型。
/// </summary>
public DeliveryEventType EventType { get; init; }
/// <summary>
/// 描述。
/// </summary>
public string? Message { get; init; }
/// <summary>
/// 事件时间。
/// </summary>
public DateTime OccurredAt { get; init; }
/// <summary>
/// 原始载荷。
/// </summary>
public string? Payload { get; init; }
}

View File

@@ -0,0 +1,89 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Deliveries.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Deliveries.Dto;
/// <summary>
/// 配送单 DTO。
/// </summary>
public sealed class DeliveryOrderDto
{
/// <summary>
/// 配送单 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 订单 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long OrderId { get; init; }
/// <summary>
/// 配送服务商。
/// </summary>
public DeliveryProvider Provider { get; init; }
/// <summary>
/// 第三方配送单号。
/// </summary>
public string? ProviderOrderId { get; init; }
/// <summary>
/// 状态。
/// </summary>
public DeliveryStatus Status { get; init; }
/// <summary>
/// 配送费。
/// </summary>
public decimal? DeliveryFee { get; init; }
/// <summary>
/// 骑手姓名。
/// </summary>
public string? CourierName { get; init; }
/// <summary>
/// 骑手电话。
/// </summary>
public string? CourierPhone { get; init; }
/// <summary>
/// 下发时间。
/// </summary>
public DateTime? DispatchedAt { get; init; }
/// <summary>
/// 取餐时间。
/// </summary>
public DateTime? PickedUpAt { get; init; }
/// <summary>
/// 完成时间。
/// </summary>
public DateTime? DeliveredAt { get; init; }
/// <summary>
/// 异常原因。
/// </summary>
public string? FailureReason { get; init; }
/// <summary>
/// 事件列表。
/// </summary>
public IReadOnlyList<DeliveryEventDto> Events { get; init; } = Array.Empty<DeliveryEventDto>();
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
}

View File

@@ -0,0 +1,70 @@
using MediatR;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.Deliveries.Commands;
using TakeoutSaaS.Application.App.Deliveries.Dto;
using TakeoutSaaS.Domain.Deliveries.Entities;
using TakeoutSaaS.Domain.Deliveries.Repositories;
namespace TakeoutSaaS.Application.App.Deliveries.Handlers;
/// <summary>
/// 创建配送单命令处理器。
/// </summary>
public sealed class CreateDeliveryOrderCommandHandler(IDeliveryRepository deliveryRepository, ILogger<CreateDeliveryOrderCommandHandler> logger)
: IRequestHandler<CreateDeliveryOrderCommand, DeliveryOrderDto>
{
private readonly IDeliveryRepository _deliveryRepository = deliveryRepository;
private readonly ILogger<CreateDeliveryOrderCommandHandler> _logger = logger;
/// <inheritdoc />
public async Task<DeliveryOrderDto> Handle(CreateDeliveryOrderCommand request, CancellationToken cancellationToken)
{
var deliveryOrder = new DeliveryOrder
{
OrderId = request.OrderId,
Provider = request.Provider,
ProviderOrderId = request.ProviderOrderId?.Trim(),
Status = request.Status,
DeliveryFee = request.DeliveryFee,
CourierName = request.CourierName?.Trim(),
CourierPhone = request.CourierPhone?.Trim(),
DispatchedAt = request.DispatchedAt,
PickedUpAt = request.PickedUpAt,
DeliveredAt = request.DeliveredAt,
FailureReason = request.FailureReason?.Trim()
};
await _deliveryRepository.AddDeliveryOrderAsync(deliveryOrder, cancellationToken);
await _deliveryRepository.SaveChangesAsync(cancellationToken);
_logger.LogInformation("创建配送单 {DeliveryOrderId} 对应订单 {OrderId}", deliveryOrder.Id, deliveryOrder.OrderId);
return MapToDto(deliveryOrder, []);
}
private static DeliveryOrderDto MapToDto(DeliveryOrder deliveryOrder, IReadOnlyList<DeliveryEvent> events) => new()
{
Id = deliveryOrder.Id,
TenantId = deliveryOrder.TenantId,
OrderId = deliveryOrder.OrderId,
Provider = deliveryOrder.Provider,
ProviderOrderId = deliveryOrder.ProviderOrderId,
Status = deliveryOrder.Status,
DeliveryFee = deliveryOrder.DeliveryFee,
CourierName = deliveryOrder.CourierName,
CourierPhone = deliveryOrder.CourierPhone,
DispatchedAt = deliveryOrder.DispatchedAt,
PickedUpAt = deliveryOrder.PickedUpAt,
DeliveredAt = deliveryOrder.DeliveredAt,
FailureReason = deliveryOrder.FailureReason,
CreatedAt = deliveryOrder.CreatedAt,
Events = events.Select(x => new DeliveryEventDto
{
Id = x.Id,
DeliveryOrderId = x.DeliveryOrderId,
EventType = x.EventType,
Message = x.Message,
OccurredAt = x.OccurredAt,
Payload = x.Payload
}).ToList()
};
}

View File

@@ -0,0 +1,38 @@
using MediatR;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.Deliveries.Commands;
using TakeoutSaaS.Domain.Deliveries.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Deliveries.Handlers;
/// <summary>
/// 删除配送单命令处理器。
/// </summary>
public sealed class DeleteDeliveryOrderCommandHandler(
IDeliveryRepository deliveryRepository,
ITenantProvider tenantProvider,
ILogger<DeleteDeliveryOrderCommandHandler> logger)
: IRequestHandler<DeleteDeliveryOrderCommand, bool>
{
private readonly IDeliveryRepository _deliveryRepository = deliveryRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
private readonly ILogger<DeleteDeliveryOrderCommandHandler> _logger = logger;
/// <inheritdoc />
public async Task<bool> Handle(DeleteDeliveryOrderCommand request, CancellationToken cancellationToken)
{
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);
return true;
}
}

View File

@@ -0,0 +1,61 @@
using MediatR;
using TakeoutSaaS.Application.App.Deliveries.Dto;
using TakeoutSaaS.Application.App.Deliveries.Queries;
using TakeoutSaaS.Domain.Deliveries.Entities;
using TakeoutSaaS.Domain.Deliveries.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Deliveries.Handlers;
/// <summary>
/// 配送单详情查询处理器。
/// </summary>
public sealed class GetDeliveryOrderByIdQueryHandler(
IDeliveryRepository deliveryRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetDeliveryOrderByIdQuery, DeliveryOrderDto?>
{
private readonly IDeliveryRepository _deliveryRepository = deliveryRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
/// <inheritdoc />
public async Task<DeliveryOrderDto?> Handle(GetDeliveryOrderByIdQuery request, CancellationToken cancellationToken)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
var order = await _deliveryRepository.FindByIdAsync(request.DeliveryOrderId, tenantId, cancellationToken);
if (order == null)
{
return null;
}
var events = await _deliveryRepository.GetEventsAsync(order.Id, tenantId, cancellationToken);
return MapToDto(order, events);
}
private static DeliveryOrderDto MapToDto(DeliveryOrder deliveryOrder, IReadOnlyList<DeliveryEvent> events) => new()
{
Id = deliveryOrder.Id,
TenantId = deliveryOrder.TenantId,
OrderId = deliveryOrder.OrderId,
Provider = deliveryOrder.Provider,
ProviderOrderId = deliveryOrder.ProviderOrderId,
Status = deliveryOrder.Status,
DeliveryFee = deliveryOrder.DeliveryFee,
CourierName = deliveryOrder.CourierName,
CourierPhone = deliveryOrder.CourierPhone,
DispatchedAt = deliveryOrder.DispatchedAt,
PickedUpAt = deliveryOrder.PickedUpAt,
DeliveredAt = deliveryOrder.DeliveredAt,
FailureReason = deliveryOrder.FailureReason,
CreatedAt = deliveryOrder.CreatedAt,
Events = events.Select(x => new DeliveryEventDto
{
Id = x.Id,
DeliveryOrderId = x.DeliveryOrderId,
EventType = x.EventType,
Message = x.Message,
OccurredAt = x.OccurredAt,
Payload = x.Payload
}).ToList()
};
}

View File

@@ -0,0 +1,66 @@
using MediatR;
using TakeoutSaaS.Application.App.Deliveries.Dto;
using TakeoutSaaS.Application.App.Deliveries.Queries;
using TakeoutSaaS.Domain.Deliveries.Repositories;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Deliveries.Handlers;
/// <summary>
/// 配送单列表查询处理器。
/// </summary>
public sealed class SearchDeliveryOrdersQueryHandler(
IDeliveryRepository deliveryRepository,
ITenantProvider tenantProvider)
: IRequestHandler<SearchDeliveryOrdersQuery, PagedResult<DeliveryOrderDto>>
{
private readonly IDeliveryRepository _deliveryRepository = deliveryRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
/// <inheritdoc />
public async Task<PagedResult<DeliveryOrderDto>> Handle(SearchDeliveryOrdersQuery request, CancellationToken cancellationToken)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
var orders = await _deliveryRepository.SearchAsync(tenantId, request.Status, request.OrderId, cancellationToken);
var sorted = ApplySorting(orders, request.SortBy, request.SortDescending);
var paged = sorted
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.ToList();
var items = paged.Select(order => new DeliveryOrderDto
{
Id = order.Id,
TenantId = order.TenantId,
OrderId = order.OrderId,
Provider = order.Provider,
ProviderOrderId = order.ProviderOrderId,
Status = order.Status,
DeliveryFee = order.DeliveryFee,
CourierName = order.CourierName,
CourierPhone = order.CourierPhone,
DispatchedAt = order.DispatchedAt,
PickedUpAt = order.PickedUpAt,
DeliveredAt = order.DeliveredAt,
FailureReason = order.FailureReason,
CreatedAt = order.CreatedAt
}).ToList();
return new PagedResult<DeliveryOrderDto>(items, request.Page, request.PageSize, orders.Count);
}
private static IOrderedEnumerable<Domain.Deliveries.Entities.DeliveryOrder> ApplySorting(
IReadOnlyCollection<Domain.Deliveries.Entities.DeliveryOrder> orders,
string? sortBy,
bool sortDescending)
{
return sortBy?.ToLowerInvariant() switch
{
"status" => sortDescending ? orders.OrderByDescending(x => x.Status) : orders.OrderBy(x => x.Status),
"provider" => sortDescending ? orders.OrderByDescending(x => x.Provider) : orders.OrderBy(x => x.Provider),
_ => sortDescending ? orders.OrderByDescending(x => x.CreatedAt) : orders.OrderBy(x => x.CreatedAt)
};
}
}

View File

@@ -0,0 +1,80 @@
using MediatR;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.Deliveries.Commands;
using TakeoutSaaS.Application.App.Deliveries.Dto;
using TakeoutSaaS.Domain.Deliveries.Entities;
using TakeoutSaaS.Domain.Deliveries.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Deliveries.Handlers;
/// <summary>
/// 更新配送单命令处理器。
/// </summary>
public sealed class UpdateDeliveryOrderCommandHandler(
IDeliveryRepository deliveryRepository,
ITenantProvider tenantProvider,
ILogger<UpdateDeliveryOrderCommandHandler> logger)
: IRequestHandler<UpdateDeliveryOrderCommand, DeliveryOrderDto?>
{
private readonly IDeliveryRepository _deliveryRepository = deliveryRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
private readonly ILogger<UpdateDeliveryOrderCommandHandler> _logger = logger;
/// <inheritdoc />
public async Task<DeliveryOrderDto?> Handle(UpdateDeliveryOrderCommand request, CancellationToken cancellationToken)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
var existing = await _deliveryRepository.FindByIdAsync(request.DeliveryOrderId, tenantId, cancellationToken);
if (existing == null)
{
return null;
}
existing.OrderId = request.OrderId;
existing.Provider = request.Provider;
existing.ProviderOrderId = request.ProviderOrderId?.Trim();
existing.Status = request.Status;
existing.DeliveryFee = request.DeliveryFee;
existing.CourierName = request.CourierName?.Trim();
existing.CourierPhone = request.CourierPhone?.Trim();
existing.DispatchedAt = request.DispatchedAt;
existing.PickedUpAt = request.PickedUpAt;
existing.DeliveredAt = request.DeliveredAt;
existing.FailureReason = request.FailureReason?.Trim();
await _deliveryRepository.UpdateDeliveryOrderAsync(existing, cancellationToken);
await _deliveryRepository.SaveChangesAsync(cancellationToken);
_logger.LogInformation("更新配送单 {DeliveryOrderId}", existing.Id);
var events = await _deliveryRepository.GetEventsAsync(existing.Id, tenantId, cancellationToken);
return MapToDto(existing, events);
}
private static DeliveryOrderDto MapToDto(DeliveryOrder deliveryOrder, IReadOnlyList<DeliveryEvent> events) => new()
{
Id = deliveryOrder.Id,
TenantId = deliveryOrder.TenantId,
OrderId = deliveryOrder.OrderId,
Provider = deliveryOrder.Provider,
ProviderOrderId = deliveryOrder.ProviderOrderId,
Status = deliveryOrder.Status,
DeliveryFee = deliveryOrder.DeliveryFee,
CourierName = deliveryOrder.CourierName,
CourierPhone = deliveryOrder.CourierPhone,
DispatchedAt = deliveryOrder.DispatchedAt,
PickedUpAt = deliveryOrder.PickedUpAt,
DeliveredAt = deliveryOrder.DeliveredAt,
FailureReason = deliveryOrder.FailureReason,
CreatedAt = deliveryOrder.CreatedAt,
Events = events.Select(x => new DeliveryEventDto
{
Id = x.Id,
DeliveryOrderId = x.DeliveryOrderId,
EventType = x.EventType,
Message = x.Message,
OccurredAt = x.OccurredAt,
Payload = x.Payload
}).ToList()
};
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.Deliveries.Dto;
namespace TakeoutSaaS.Application.App.Deliveries.Queries;
/// <summary>
/// 配送单详情查询。
/// </summary>
public sealed class GetDeliveryOrderByIdQuery : IRequest<DeliveryOrderDto?>
{
/// <summary>
/// 配送单 ID。
/// </summary>
public long DeliveryOrderId { get; init; }
}

View File

@@ -0,0 +1,42 @@
using MediatR;
using TakeoutSaaS.Application.App.Deliveries.Dto;
using TakeoutSaaS.Domain.Deliveries.Enums;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Deliveries.Queries;
/// <summary>
/// 配送单列表查询。
/// </summary>
public sealed class SearchDeliveryOrdersQuery : IRequest<PagedResult<DeliveryOrderDto>>
{
/// <summary>
/// 订单 ID可选
/// </summary>
public long? OrderId { get; init; }
/// <summary>
/// 配送状态。
/// </summary>
public DeliveryStatus? Status { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
/// <summary>
/// 排序字段createdAt/status/provider
/// </summary>
public string? SortBy { get; init; }
/// <summary>
/// 是否倒序。
/// </summary>
public bool SortDescending { get; init; } = true;
}

View File

@@ -0,0 +1,23 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Deliveries.Commands;
namespace TakeoutSaaS.Application.App.Deliveries.Validators;
/// <summary>
/// 创建配送单命令验证器。
/// </summary>
public sealed class CreateDeliveryOrderCommandValidator : AbstractValidator<CreateDeliveryOrderCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public CreateDeliveryOrderCommandValidator()
{
RuleFor(x => x.OrderId).GreaterThan(0);
RuleFor(x => x.ProviderOrderId).MaximumLength(64);
RuleFor(x => x.CourierName).MaximumLength(64);
RuleFor(x => x.CourierPhone).MaximumLength(32);
RuleFor(x => x.FailureReason).MaximumLength(256);
RuleFor(x => x.DeliveryFee).GreaterThanOrEqualTo(0).When(x => x.DeliveryFee.HasValue);
}
}

View File

@@ -0,0 +1,20 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Deliveries.Queries;
namespace TakeoutSaaS.Application.App.Deliveries.Validators;
/// <summary>
/// 配送单列表查询验证器。
/// </summary>
public sealed class SearchDeliveryOrdersQueryValidator : AbstractValidator<SearchDeliveryOrdersQuery>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public SearchDeliveryOrdersQueryValidator()
{
RuleFor(x => x.Page).GreaterThan(0);
RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
RuleFor(x => x.SortBy).MaximumLength(64);
}
}

View File

@@ -0,0 +1,24 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Deliveries.Commands;
namespace TakeoutSaaS.Application.App.Deliveries.Validators;
/// <summary>
/// 更新配送单命令验证器。
/// </summary>
public sealed class UpdateDeliveryOrderCommandValidator : AbstractValidator<UpdateDeliveryOrderCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public UpdateDeliveryOrderCommandValidator()
{
RuleFor(x => x.DeliveryOrderId).GreaterThan(0);
RuleFor(x => x.OrderId).GreaterThan(0);
RuleFor(x => x.ProviderOrderId).MaximumLength(64);
RuleFor(x => x.CourierName).MaximumLength(64);
RuleFor(x => x.CourierPhone).MaximumLength(32);
RuleFor(x => x.FailureReason).MaximumLength(256);
RuleFor(x => x.DeliveryFee).GreaterThanOrEqualTo(0).When(x => x.DeliveryFee.HasValue);
}
}

View File

@@ -0,0 +1,27 @@
using System.Reflection;
using FluentValidation;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using TakeoutSaaS.Application.App.Common.Behaviors;
namespace TakeoutSaaS.Application.App.Extensions;
/// <summary>
/// 业务应用层服务注册。
/// </summary>
public static class AppApplicationServiceCollectionExtensions
{
/// <summary>
/// 注册业务应用层MediatR 处理器等)。
/// </summary>
/// <param name="services">服务集合。</param>
/// <returns>服务集合。</returns>
public static IServiceCollection AddAppApplication(this IServiceCollection services)
{
services.AddMediatR(Assembly.GetExecutingAssembly());
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
return services;
}
}

View File

@@ -0,0 +1,53 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Domain.Merchants.Enums;
namespace TakeoutSaaS.Application.App.Merchants.Commands;
/// <summary>
/// 创建商户命令。
/// </summary>
public sealed class CreateMerchantCommand : IRequest<MerchantDto>
{
/// <summary>
/// 品牌名称。
/// </summary>
[Required, MaxLength(128)]
public string BrandName { get; init; } = string.Empty;
/// <summary>
/// 品牌简称。
/// </summary>
[MaxLength(64)]
public string? BrandAlias { get; init; }
/// <summary>
/// 品牌 Logo。
/// </summary>
[MaxLength(256)]
public string? LogoUrl { get; init; }
/// <summary>
/// 品类。
/// </summary>
[MaxLength(64)]
public string? Category { get; init; }
/// <summary>
/// 联系电话。
/// </summary>
[Required, MaxLength(32)]
public string ContactPhone { get; init; } = string.Empty;
/// <summary>
/// 联系邮箱。
/// </summary>
[MaxLength(128)]
public string? ContactEmail { get; init; }
/// <summary>
/// 状态,可用于直接设为审核通过等场景。
/// </summary>
public MerchantStatus Status { get; init; } = MerchantStatus.Pending;
}

View File

@@ -0,0 +1,14 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Merchants.Commands;
/// <summary>
/// 删除商户命令。
/// </summary>
public sealed class DeleteMerchantCommand : IRequest<bool>
{
/// <summary>
/// 商户 ID。
/// </summary>
public long MerchantId { get; init; }
}

View File

@@ -0,0 +1,51 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Domain.Merchants.Enums;
namespace TakeoutSaaS.Application.App.Merchants.Commands;
/// <summary>
/// 更新商户命令。
/// </summary>
public sealed record UpdateMerchantCommand : IRequest<MerchantDto?>
{
/// <summary>
/// 商户 ID。
/// </summary>
public long MerchantId { get; init; }
/// <summary>
/// 品牌名称。
/// </summary>
public string BrandName { get; init; } = string.Empty;
/// <summary>
/// 品牌简称。
/// </summary>
public string? BrandAlias { get; init; }
/// <summary>
/// Logo 地址。
/// </summary>
public string? LogoUrl { get; init; }
/// <summary>
/// 品类。
/// </summary>
public string? Category { get; init; }
/// <summary>
/// 联系电话。
/// </summary>
public string ContactPhone { get; init; } = string.Empty;
/// <summary>
/// 联系邮箱。
/// </summary>
public string? ContactEmail { get; init; }
/// <summary>
/// 入驻状态。
/// </summary>
public MerchantStatus Status { get; init; }
}

View File

@@ -0,0 +1,68 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Merchants.Dto;
/// <summary>
/// 商户 DTO。
/// </summary>
public sealed class MerchantDto
{
/// <summary>
/// 商户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 品牌名称。
/// </summary>
public string BrandName { get; init; } = string.Empty;
/// <summary>
/// 品牌简称。
/// </summary>
public string? BrandAlias { get; init; }
/// <summary>
/// 品牌 Logo。
/// </summary>
public string? LogoUrl { get; init; }
/// <summary>
/// 品类。
/// </summary>
public string? Category { get; init; }
/// <summary>
/// 联系电话。
/// </summary>
public string ContactPhone { get; init; } = string.Empty;
/// <summary>
/// 联系邮箱。
/// </summary>
public string? ContactEmail { get; init; }
/// <summary>
/// 入驻状态。
/// </summary>
public MerchantStatus Status { get; init; }
/// <summary>
/// 入驻时间。
/// </summary>
public DateTime? JoinedAt { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
}

View File

@@ -0,0 +1,55 @@
using MediatR;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.Merchants.Commands;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Domain.Merchants.Entities;
using TakeoutSaaS.Domain.Merchants.Repositories;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 创建商户命令处理器。
/// </summary>
public sealed class CreateMerchantCommandHandler(IMerchantRepository merchantRepository, ILogger<CreateMerchantCommandHandler> logger)
: IRequestHandler<CreateMerchantCommand, MerchantDto>
{
private readonly IMerchantRepository _merchantRepository = merchantRepository;
private readonly ILogger<CreateMerchantCommandHandler> _logger = logger;
/// <inheritdoc />
public async Task<MerchantDto> Handle(CreateMerchantCommand request, CancellationToken cancellationToken)
{
var merchant = new Merchant
{
BrandName = request.BrandName.Trim(),
BrandAlias = request.BrandAlias?.Trim(),
LogoUrl = request.LogoUrl?.Trim(),
Category = request.Category?.Trim(),
ContactPhone = request.ContactPhone.Trim(),
ContactEmail = request.ContactEmail?.Trim(),
Status = request.Status,
JoinedAt = DateTime.UtcNow
};
await _merchantRepository.AddMerchantAsync(merchant, cancellationToken);
await _merchantRepository.SaveChangesAsync(cancellationToken);
_logger.LogInformation("创建商户 {MerchantId} - {BrandName}", merchant.Id, merchant.BrandName);
return MapToDto(merchant);
}
private static MerchantDto MapToDto(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
};
}

View File

@@ -0,0 +1,40 @@
using MediatR;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.Merchants.Commands;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 删除商户命令处理器。
/// </summary>
public sealed class DeleteMerchantCommandHandler(
IMerchantRepository merchantRepository,
ITenantProvider tenantProvider,
ILogger<DeleteMerchantCommandHandler> logger)
: IRequestHandler<DeleteMerchantCommand, bool>
{
private readonly IMerchantRepository _merchantRepository = merchantRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
private readonly ILogger<DeleteMerchantCommandHandler> _logger = logger;
/// <inheritdoc />
public async Task<bool> Handle(DeleteMerchantCommand request, CancellationToken cancellationToken)
{
// 1. 校验存在性
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);
return true;
}
}

View File

@@ -0,0 +1,43 @@
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;
/// <summary>
/// 获取商户详情查询处理器。
/// </summary>
public sealed class GetMerchantByIdQueryHandler(IMerchantRepository merchantRepository, ITenantProvider tenantProvider)
: IRequestHandler<GetMerchantByIdQuery, MerchantDto?>
{
private readonly IMerchantRepository _merchantRepository = merchantRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
/// <inheritdoc />
public async Task<MerchantDto?> Handle(GetMerchantByIdQuery request, CancellationToken cancellationToken)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
var merchant = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken);
if (merchant == null)
{
return null;
}
return new MerchantDto
{
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
};
}
}

Some files were not shown because too many files have changed in this diff Show More